diff --git a/apps/api/prisma/migrations/20260810133000_publish_initial_world_bibles/migration.sql b/apps/api/prisma/migrations/20260810133000_publish_initial_world_bibles/migration.sql new file mode 100644 index 0000000..7851ea0 --- /dev/null +++ b/apps/api/prisma/migrations/20260810133000_publish_initial_world_bibles/migration.sql @@ -0,0 +1,4 @@ +UPDATE "WorldBibleVersion" +SET "publishedAt" = COALESCE("publishedAt", "createdAt") +WHERE "versionNumber" = 1 + AND "publishedAt" IS NULL; diff --git a/apps/api/prisma/seed.ts b/apps/api/prisma/seed.ts index d999c72..b01a451 100644 --- a/apps/api/prisma/seed.ts +++ b/apps/api/prisma/seed.ts @@ -144,6 +144,7 @@ async function main() { }, ], }, + publishedAt: new Date(), }, create: { worldId: world.id, @@ -164,6 +165,7 @@ async function main() { }, ], }, + publishedAt: new Date(), }, }); diff --git a/apps/web/src/app/worlds/[worldId]/contribute/page.tsx b/apps/web/src/app/worlds/[worldId]/contribute/page.tsx index b402720..079ba0f 100644 --- a/apps/web/src/app/worlds/[worldId]/contribute/page.tsx +++ b/apps/web/src/app/worlds/[worldId]/contribute/page.tsx @@ -1,13 +1,54 @@ -import { LoreContributeClient } from '@/components/lore/lore-contribute-client'; +import { ContributionEditorForm } from '@/components/contributions/contribution-editor-form'; +import type { ContributionKind } from '@/lib/api/contributions'; +import { loreCategories } from '@/lib/worlds/constants'; type ContributePageProps = { params: Promise<{ worldId: string }>; - searchParams: Promise<{ entryId?: string; type?: string }>; + searchParams: Promise<{ targetLoreEntryId?: string; type?: string }>; }; +function getInitialKind(type?: string): ContributionKind { + return type?.toLowerCase() === 'stories' || type?.toLowerCase() === 'story' ? 'STORY' : 'LORE'; +} + +function getUnsupportedTypeLabel(type?: string) { + const normalizedType = type?.toLowerCase(); + + if (!normalizedType || ['lore', 'stories', 'story'].includes(normalizedType)) { + return undefined; + } + + return ( + loreCategories.find((category) => category.id === normalizedType)?.label ?? 'that category' + ); +} + export default async function ContributePage({ params, searchParams }: ContributePageProps) { const { worldId } = await params; const query = await searchParams; + const unsupportedTypeLabel = getUnsupportedTypeLabel(query.type); + + return ( +
+
+

+ World {worldId} +

+

+ Contribution editor +

+

+ Author a structured draft, save it against this world, then submit it into the proposal + queue for canon voting. +

+
- return ; + +
+ ); } diff --git a/apps/web/src/app/worlds/[worldId]/lore/edit/page.tsx b/apps/web/src/app/worlds/[worldId]/lore/edit/page.tsx new file mode 100644 index 0000000..be748b9 --- /dev/null +++ b/apps/web/src/app/worlds/[worldId]/lore/edit/page.tsx @@ -0,0 +1,13 @@ +import { LoreContributeClient } from '@/components/lore/lore-contribute-client'; + +type LoreEditPageProps = { + params: Promise<{ worldId: string }>; + searchParams: Promise<{ entryId?: string; type?: string }>; +}; + +export default async function LoreEditPage({ params, searchParams }: LoreEditPageProps) { + const { worldId } = await params; + const query = await searchParams; + + return ; +} diff --git a/apps/web/src/app/worlds/[worldId]/page.tsx b/apps/web/src/app/worlds/[worldId]/page.tsx index a780101..e4a322c 100644 --- a/apps/web/src/app/worlds/[worldId]/page.tsx +++ b/apps/web/src/app/worlds/[worldId]/page.tsx @@ -86,13 +86,13 @@ function WorldHubContent({ hub }: { hub: WorldHub }) { Create Lore Entry Write Story Contribution diff --git a/apps/web/src/components/contributions/contribution-editor-form.tsx b/apps/web/src/components/contributions/contribution-editor-form.tsx new file mode 100644 index 0000000..1b8c446 --- /dev/null +++ b/apps/web/src/components/contributions/contribution-editor-form.tsx @@ -0,0 +1,673 @@ +'use client'; + +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; +import { type FormEvent, useEffect, useMemo, useRef, useState } from 'react'; + +import { RichTextEditor, type StructuredEditorContent } from '@/components/editor/rich-text-editor'; +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Select } from '@/components/ui/select'; +import { Textarea } from '@/components/ui/textarea'; +import { ApiError } from '@/lib/api/errors'; +import { + createContribution, + listContributions, + submitContribution, + updateContribution, + type Contribution, + type ContributionKind, +} from '@/lib/api/contributions'; +import { useAuthSession } from '@/providers/auth-session-provider'; + +const STRUCTURED_DOCUMENT_MAX_BYTES = 100 * 1024; + +const emptyDocument: StructuredEditorContent = { + type: 'doc', + content: [], +}; + +type ContributionEditorFormProps = { + initialKind: ContributionKind; + targetLoreEntryId?: string; + unsupportedType?: string; + worldId: string; +}; + +function getErrorMessage(error: unknown) { + if (error instanceof ApiError) { + return error.body?.error ?? 'Contribution could not be saved.'; + } + + if (error instanceof Error) { + return error.message; + } + + return 'Contribution could not be saved.'; +} + +function getPreflightErrorMessage(error: unknown) { + if (error instanceof ApiError) { + return error.body?.error ?? 'Contribution access could not be checked.'; + } + + if (error instanceof Error) { + return error.message; + } + + return 'Contribution access could not be checked.'; +} + +function getRefreshFailureMessage(error: unknown) { + if (isTerminalRefreshError(error)) { + return 'Your session expired. Sign in again before saving this contribution.'; + } + + return 'Could not refresh your session. Your draft is still on this page; try again before leaving.'; +} + +function isTerminalRefreshError(error: unknown) { + return error instanceof ApiError && (error.status === 401 || error.status === 403); +} + +function hasMeaningfulText(value: unknown): boolean { + if (Array.isArray(value)) { + return value.some(hasMeaningfulText); + } + + if (!value || typeof value !== 'object') { + return false; + } + + if ('text' in value && typeof value.text === 'string' && value.text.trim()) { + return true; + } + + return Object.values(value).some(hasMeaningfulText); +} + +function getStructuredDocumentBytes(content: StructuredEditorContent) { + return new TextEncoder().encode(JSON.stringify(content)).length; +} + +export function ContributionEditorForm({ + initialKind, + targetLoreEntryId, + unsupportedType, + worldId, +}: ContributionEditorFormProps) { + const router = useRouter(); + const { accessToken, isSessionLoading, refreshSession, user } = useAuthSession(); + const accessTokenRef = useRef(accessToken); + const hasAllowedAccessRef = useRef(false); + const lastPreflightKeyRef = useRef(null); + const operationInFlightRef = useRef(false); + const [content, setContent] = useState(emptyDocument); + const [draft, setDraft] = useState(null); + const [error, setError] = useState(null); + const [kind, setKind] = useState(initialKind); + const [permissionMessage, setPermissionMessage] = useState(null); + const [permissionRetryKey, setPermissionRetryKey] = useState(0); + const [permissionStatus, setPermissionStatus] = useState< + 'checking' | 'allowed' | 'denied' | 'error' | 'reauth' + >('checking'); + const [isSaving, setIsSaving] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const [summary, setSummary] = useState(''); + const [targetId, setTargetId] = useState(targetLoreEntryId ?? ''); + const [title, setTitle] = useState(''); + accessTokenRef.current = accessToken; + + useEffect(() => { + if (isSessionLoading) { + return; + } + + const preflightKey = `${worldId}:${permissionRetryKey}`; + + if (hasAllowedAccessRef.current && lastPreflightKeyRef.current === preflightKey) { + return; + } + + const initialAccessToken = accessTokenRef.current; + + if (!initialAccessToken) { + setPermissionStatus('reauth'); + setPermissionMessage('Please sign in before drafting contributions in this world.'); + return; + } + + const preflightAccessToken = initialAccessToken; + const controller = new AbortController(); + lastPreflightKeyRef.current = preflightKey; + + setPermissionStatus((currentStatus) => + currentStatus === 'allowed' ? currentStatus : 'checking', + ); + setError(null); + setPermissionMessage(null); + + async function checkAccess() { + let token = preflightAccessToken; + + try { + await listContributions(worldId, { page: 1, pageSize: 1 }, token, { + signal: controller.signal, + }); + + if (!controller.signal.aborted) { + hasAllowedAccessRef.current = true; + setPermissionStatus('allowed'); + setPermissionMessage(null); + } + } catch (nextError: unknown) { + if (controller.signal.aborted) { + return; + } + + if (nextError instanceof ApiError && nextError.status === 401) { + try { + const session = await refreshSession(); + token = session.accessToken; + + await listContributions(worldId, { page: 1, pageSize: 1 }, token, { + signal: controller.signal, + }); + + if (!controller.signal.aborted) { + hasAllowedAccessRef.current = true; + setPermissionStatus('allowed'); + setPermissionMessage(null); + } + } catch (refreshError) { + if (!controller.signal.aborted) { + setPermissionStatus(isTerminalRefreshError(refreshError) ? 'reauth' : 'error'); + setPermissionMessage(getRefreshFailureMessage(refreshError)); + } + } + + return; + } + + if (nextError instanceof ApiError && nextError.status === 403) { + setPermissionStatus('denied'); + setPermissionMessage('You do not have permission to draft contributions in this world.'); + return; + } + + if (nextError instanceof ApiError && nextError.status === 404) { + setPermissionStatus('denied'); + setPermissionMessage('World not found.'); + return; + } + + setPermissionStatus('error'); + setPermissionMessage(getPreflightErrorMessage(nextError)); + } + } + + checkAccess(); + + return () => { + controller.abort(); + }; + }, [isSessionLoading, permissionRetryKey, refreshSession, worldId]); + + useEffect(() => { + const hasUnsavedWork = Boolean(title.trim() || summary.trim() || hasMeaningfulText(content)); + + if (!hasUnsavedWork || draft?.status === 'SUBMITTED') { + return; + } + + function handleBeforeUnload(event: BeforeUnloadEvent) { + event.preventDefault(); + event.returnValue = ''; + } + + window.addEventListener('beforeunload', handleBeforeUnload); + + return () => { + window.removeEventListener('beforeunload', handleBeforeUnload); + }; + }, [content, draft?.status, summary, title]); + + const contentBytes = getStructuredDocumentBytes(content); + const isContentTooLarge = contentBytes > STRUCTURED_DOCUMENT_MAX_BYTES; + const contentKilobytes = Math.ceil(contentBytes / 1024); + const contentLimitKilobytes = STRUCTURED_DOCUMENT_MAX_BYTES / 1024; + const isFormLocked = draft?.status === 'SUBMITTED' || isSaving || isSubmitting; + + const canSave = useMemo( + () => + Boolean( + accessToken && + permissionStatus === 'allowed' && + title.trim() && + hasMeaningfulText(content) && + !isContentTooLarge && + !isSaving && + !isSubmitting, + ), + [accessToken, content, isContentTooLarge, isSaving, isSubmitting, permissionStatus, title], + ); + + const canSubmit = canSave && !isSubmitting; + + function buildPayload() { + const trimmedSummary = summary.trim(); + const trimmedTargetId = targetId.trim(); + + return { + content, + kind, + ...(trimmedSummary ? { summary: trimmedSummary } : draft ? { summary: null } : {}), + ...(trimmedTargetId + ? { targetLoreEntryId: trimmedTargetId } + : draft + ? { targetLoreEntryId: null } + : {}), + title: title.trim(), + }; + } + + async function runWithAuthRetry(operation: (token: string) => Promise) { + const currentAccessToken = accessTokenRef.current; + + if (!currentAccessToken) { + throw new Error('Please sign in before saving a contribution.'); + } + + try { + return await operation(currentAccessToken); + } catch (nextError) { + if (!(nextError instanceof ApiError) || nextError.status !== 401) { + throw nextError; + } + + try { + const session = await refreshSession(); + accessTokenRef.current = session.accessToken; + return operation(session.accessToken); + } catch (refreshError) { + throw new Error(getRefreshFailureMessage(refreshError)); + } + } + } + + async function saveDraft(options: { internal?: boolean } = {}) { + if (operationInFlightRef.current && !options.internal) { + return null; + } + + if (!options.internal) { + operationInFlightRef.current = true; + } + + setError(null); + + if (!accessTokenRef.current) { + setError('Please sign in before saving a contribution.'); + if (!options.internal) { + operationInFlightRef.current = false; + } + return null; + } + + if (permissionStatus !== 'allowed') { + setError('You do not have permission to draft contributions in this world.'); + if (!options.internal) { + operationInFlightRef.current = false; + } + return null; + } + + if (isContentTooLarge) { + setError(`Contribution body must stay under ${contentLimitKilobytes} KB.`); + if (!options.internal) { + operationInFlightRef.current = false; + } + return null; + } + + setIsSaving(true); + + try { + const payload = buildPayload(); + const response = await runWithAuthRetry((token) => + draft + ? updateContribution(worldId, draft.id, payload, token) + : createContribution(worldId, payload, token), + ); + + setDraft(response.contribution); + return response.contribution; + } catch (nextError) { + setError(getErrorMessage(nextError)); + return null; + } finally { + setIsSaving(false); + if (!options.internal) { + operationInFlightRef.current = false; + } + } + } + + async function handleSave(event: FormEvent) { + event.preventDefault(); + + if (isSubmitting) { + return; + } + + await saveDraft(); + } + + async function handleSubmitForVote() { + if (operationInFlightRef.current) { + return; + } + + operationInFlightRef.current = true; + setError(null); + + if (!accessTokenRef.current) { + setError('Please sign in before submitting a contribution.'); + operationInFlightRef.current = false; + return; + } + + setIsSubmitting(true); + + try { + const currentDraft = await saveDraft({ internal: true }); + + if (!currentDraft) { + return; + } + + const response = await runWithAuthRetry((token) => + submitContribution(worldId, currentDraft.id, token), + ); + setDraft(response.contribution); + + if (response.proposal?.id) { + router.push(`/worlds/${worldId}/proposals/${response.proposal.id}`); + } + } catch (nextError) { + setError(getErrorMessage(nextError)); + } finally { + setIsSubmitting(false); + operationInFlightRef.current = false; + } + } + + const shouldShowEditor = permissionStatus === 'allowed' || hasAllowedAccessRef.current; + const signInHref = `/login?next=${encodeURIComponent(`/worlds/${worldId}/contribute`)}`; + const shouldShowSessionRecovery = + hasAllowedAccessRef.current && + !accessToken && + !isSessionLoading && + draft?.status !== 'SUBMITTED'; + + return ( +
+ {permissionStatus === 'checking' ? ( + + +

Checking contribution access...

+
+
+ ) : null} + + {error ? ( + + Contribution was not saved + {error} + + ) : null} + + {unsupportedType ? ( + + Contribution type not supported yet + + The contribution API currently supports lore updates and stories.{' '} + {unsupportedType} is not supported yet, so this draft will be saved as + a lore update until typed contribution categories are added. + + + ) : null} + + {permissionStatus === 'denied' || + permissionStatus === 'error' || + permissionStatus === 'reauth' ? ( + + + + + {permissionStatus === 'error' + ? 'Could not check contribution access' + : permissionStatus === 'reauth' + ? 'Sign in again' + : 'Contribution access unavailable'} + + + {permissionMessage ?? + (permissionStatus === 'error' + ? 'Try checking contribution access again.' + : permissionStatus === 'reauth' + ? 'Your session expired. Sign in again before drafting contributions.' + : 'You do not have permission to draft contributions in this world.')} + + +
+ {permissionStatus === 'reauth' ? ( + + Sign in + + ) : null} + {permissionStatus === 'error' ? ( + + ) : null} + + Back to World + +
+
+
+ ) : null} + + {!shouldShowEditor ? null : ( + <> + {shouldShowSessionRecovery ? ( + + Sign in again before saving + + Your draft is still on this page. Sign in again in a new tab, then return here to + save or submit it. + +
+ + Sign in + +
+
+ ) : null} + + {draft?.status === 'SUBMITTED' ? ( + + Proposal submitted + + This contribution is locked as a proposal and ready for the voting flow. + + + ) : null} + +
+ + + Draft contribution + + Structured content tied to this world before it enters canon voting. + + + +
+ + + +