From 90967171439060812062ab9be52015d193ac291a Mon Sep 17 00:00:00 2001 From: Kareem Naous <62202089+KareemN1936@users.noreply.github.com> Date: Sat, 8 Aug 2026 19:47:07 +0300 Subject: [PATCH 01/14] feat(ui): add contribution editor workflow --- .../app/worlds/[worldId]/contribute/page.tsx | 47 ++- .../contribution-editor-form.tsx | 317 ++++++++++++++++++ .../components/editor/rich-text-editor.tsx | 11 +- apps/web/src/lib/api/contributions.ts | 92 +++++ 4 files changed, 459 insertions(+), 8 deletions(-) create mode 100644 apps/web/src/components/contributions/contribution-editor-form.tsx create mode 100644 apps/web/src/lib/api/contributions.ts 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/components/contributions/contribution-editor-form.tsx b/apps/web/src/components/contributions/contribution-editor-form.tsx new file mode 100644 index 0000000..b0b17e6 --- /dev/null +++ b/apps/web/src/components/contributions/contribution-editor-form.tsx @@ -0,0 +1,317 @@ +'use client'; + +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; +import { type FormEvent, useEffect, useMemo, 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, + submitContribution, + updateContribution, + type Contribution, + type ContributionKind, +} from '@/lib/api/contributions'; +import { useAuthSession } from '@/providers/auth-session-provider'; + +const emptyDocument: StructuredEditorContent = { + type: 'doc', + content: [], +}; + +type ContributionEditorFormProps = { + initialKind: ContributionKind; + targetLoreEntryId?: 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 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); +} + +export function ContributionEditorForm({ + initialKind, + targetLoreEntryId, + worldId, +}: ContributionEditorFormProps) { + const router = useRouter(); + const { accessToken, isSessionLoading, user } = useAuthSession(); + const [content, setContent] = useState(emptyDocument); + const [draft, setDraft] = useState(null); + const [error, setError] = useState(null); + const [kind, setKind] = useState(initialKind); + const [isSaving, setIsSaving] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const [summary, setSummary] = useState(''); + const [targetId, setTargetId] = useState(targetLoreEntryId ?? ''); + const [title, setTitle] = useState(''); + + useEffect(() => { + if (!isSessionLoading && !accessToken) { + router.replace('/login'); + } + }, [accessToken, isSessionLoading, router]); + + const canSave = useMemo( + () => Boolean(accessToken && title.trim() && hasMeaningfulText(content) && !isSaving), + [accessToken, content, isSaving, title], + ); + + const canSubmit = canSave && !isSubmitting; + + function buildPayload() { + return { + content, + kind, + ...(summary.trim() ? { summary: summary.trim() } : {}), + ...(targetId.trim() ? { targetLoreEntryId: targetId.trim() } : {}), + title: title.trim(), + }; + } + + async function saveDraft() { + setError(null); + + if (!accessToken) { + setError('Please sign in before saving a contribution.'); + return null; + } + + setIsSaving(true); + + try { + const payload = buildPayload(); + const response = draft + ? await updateContribution(worldId, draft.id, payload, accessToken) + : await createContribution(worldId, payload, accessToken); + + setDraft(response.contribution); + return response.contribution; + } catch (nextError) { + setError(getErrorMessage(nextError)); + return null; + } finally { + setIsSaving(false); + } + } + + async function handleSave(event: FormEvent) { + event.preventDefault(); + await saveDraft(); + } + + async function handleSubmitForVote() { + setError(null); + + if (!accessToken) { + setError('Please sign in before submitting a contribution.'); + return; + } + + setIsSubmitting(true); + + try { + const currentDraft = draft ?? (await saveDraft()); + + if (!currentDraft) { + return; + } + + const response = await submitContribution(worldId, currentDraft.id, accessToken); + setDraft(response.contribution); + + if (response.proposal?.id) { + router.push(`/worlds/${worldId}/proposals/${response.proposal.id}`); + } + } catch (nextError) { + setError(getErrorMessage(nextError)); + } finally { + setIsSubmitting(false); + } + } + + return ( +
+ {error ? ( + + Contribution was not saved + {error} + + ) : 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. + + + +
+ + + +