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 (
+
+ );
+}
diff --git a/apps/web/src/components/editor/rich-text-editor.tsx b/apps/web/src/components/editor/rich-text-editor.tsx
index 3e936b6..b2e9406 100644
--- a/apps/web/src/components/editor/rich-text-editor.tsx
+++ b/apps/web/src/components/editor/rich-text-editor.tsx
@@ -7,12 +7,15 @@ import { useEffect } from 'react';
import { EditorToolbar } from '@/components/editor/editor-toolbar';
import { cn } from '@/lib/styles';
+export type StructuredEditorContent = Record;
+
export type RichTextEditorProps = {
className?: string;
disabled?: boolean;
- initialContent?: string;
+ initialContent?: StructuredEditorContent | string;
label?: string;
onChange?: (html: string) => void;
+ onJsonChange?: (content: StructuredEditorContent) => void;
placeholder?: string;
readOnly?: boolean;
};
@@ -23,6 +26,7 @@ export function RichTextEditor({
initialContent = '',
label = 'Contribution editor',
onChange,
+ onJsonChange,
placeholder = 'Draft lore contribution notes...',
readOnly = false,
}: RichTextEditorProps) {
@@ -42,6 +46,7 @@ export function RichTextEditor({
],
onUpdate: ({ editor: updatedEditor }) => {
onChange?.(updatedEditor.getHTML());
+ onJsonChange?.(updatedEditor.getJSON() as StructuredEditorContent);
},
});
@@ -65,10 +70,6 @@ export function RichTextEditor({
role="textbox"
/>
-
- Semi-controlled editor: `initialContent` seeds TipTap once, and `onChange` reports HTML
- updates for parent-owned draft state.
-
);
}
diff --git a/apps/web/src/lib/api/contributions.ts b/apps/web/src/lib/api/contributions.ts
new file mode 100644
index 0000000..f7d351a
--- /dev/null
+++ b/apps/web/src/lib/api/contributions.ts
@@ -0,0 +1,92 @@
+import { apiClient } from '@/lib/api/client';
+
+export type ContributionKind = 'LORE' | 'STORY';
+export type ContributionStatus = 'DRAFT' | 'SUBMITTED';
+
+export type StructuredContributionContent = Record;
+
+export type ContributionTargetLoreEntry = {
+ id: string;
+ loreType: string;
+ slug: string;
+ title: string;
+ worldId: string;
+};
+
+export type Proposal = {
+ id: string;
+ proposalType: string;
+ status: string;
+ submittedAt: string | null;
+ title: string;
+ worldId: string;
+};
+
+export type Contribution = {
+ id: string;
+ authorId: string;
+ content: StructuredContributionContent;
+ createdAt: string;
+ kind: ContributionKind;
+ proposal: Proposal | null;
+ proposalId: string | null;
+ status: ContributionStatus;
+ submittedAt: string | null;
+ summary: string | null;
+ targetLoreEntry: ContributionTargetLoreEntry | null;
+ targetLoreEntryId: string | null;
+ title: string;
+ updatedAt: string;
+ worldId: string;
+};
+
+export type ContributionInput = {
+ content: StructuredContributionContent;
+ kind: ContributionKind;
+ summary?: string;
+ targetLoreEntryId?: string;
+ title: string;
+};
+
+function contributionPath(worldId: string, contributionId?: string) {
+ const base = `/worlds/${encodeURIComponent(worldId)}/contributions`;
+
+ return contributionId ? `${base}/${encodeURIComponent(contributionId)}` : base;
+}
+
+function authHeaders(accessToken: string) {
+ return {
+ authorization: `Bearer ${accessToken}`,
+ };
+}
+
+export function createContribution(worldId: string, input: ContributionInput, accessToken: string) {
+ return apiClient.post<{ contribution: Contribution }>(contributionPath(worldId), input, {
+ headers: authHeaders(accessToken),
+ });
+}
+
+export function updateContribution(
+ worldId: string,
+ contributionId: string,
+ input: Partial,
+ accessToken: string,
+) {
+ return apiClient.patch<{ contribution: Contribution }>(
+ contributionPath(worldId, contributionId),
+ input,
+ {
+ headers: authHeaders(accessToken),
+ },
+ );
+}
+
+export function submitContribution(worldId: string, contributionId: string, accessToken: string) {
+ return apiClient.post<{
+ alreadySubmitted: boolean;
+ contribution: Contribution;
+ proposal: Proposal | null;
+ }>(`${contributionPath(worldId, contributionId)}/submit`, undefined, {
+ headers: authHeaders(accessToken),
+ });
+}
From fcac55c79ee809128c6d5c24afe5e4342c2a9c39 Mon Sep 17 00:00:00 2001
From: Kareem Naous <62202089+KareemN1936@users.noreply.github.com>
Date: Mon, 10 Aug 2026 01:56:23 +0300
Subject: [PATCH 02/14] fix(contributions): address editor review blockers
---
apps/web/src/app/worlds/[worldId]/page.tsx | 2 +-
.../contribution-editor-form.tsx | 378 +++++++++++-------
apps/web/src/lib/api/contributions.ts | 46 +++
3 files changed, 290 insertions(+), 136 deletions(-)
diff --git a/apps/web/src/app/worlds/[worldId]/page.tsx b/apps/web/src/app/worlds/[worldId]/page.tsx
index a780101..fdc973c 100644
--- a/apps/web/src/app/worlds/[worldId]/page.tsx
+++ b/apps/web/src/app/worlds/[worldId]/page.tsx
@@ -92,7 +92,7 @@ function WorldHubContent({ hub }: { hub: WorldHub }) {
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
index b0b17e6..3191b8e 100644
--- a/apps/web/src/components/contributions/contribution-editor-form.tsx
+++ b/apps/web/src/components/contributions/contribution-editor-form.tsx
@@ -15,6 +15,7 @@ import { Textarea } from '@/components/ui/textarea';
import { ApiError } from '@/lib/api/errors';
import {
createContribution,
+ listContributions,
submitContribution,
updateContribution,
type Contribution,
@@ -22,6 +23,8 @@ import {
} from '@/lib/api/contributions';
import { useAuthSession } from '@/providers/auth-session-provider';
+const STRUCTURED_DOCUMENT_MAX_BYTES = 100 * 1024;
+
const emptyDocument: StructuredEditorContent = {
type: 'doc',
content: [],
@@ -61,6 +64,10 @@ function hasMeaningfulText(value: unknown): boolean {
return Object.values(value).some(hasMeaningfulText);
}
+function getStructuredDocumentBytes(content: StructuredEditorContent) {
+ return new TextEncoder().encode(JSON.stringify(content)).length;
+}
+
export function ContributionEditorForm({
initialKind,
targetLoreEntryId,
@@ -72,6 +79,9 @@ export function ContributionEditorForm({
const [draft, setDraft] = useState(null);
const [error, setError] = useState(null);
const [kind, setKind] = useState(initialKind);
+ const [permissionStatus, setPermissionStatus] = useState<'checking' | 'allowed' | 'denied'>(
+ 'checking',
+ );
const [isSaving, setIsSaving] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [summary, setSummary] = useState('');
@@ -84,9 +94,61 @@ export function ContributionEditorForm({
}
}, [accessToken, isSessionLoading, router]);
+ useEffect(() => {
+ if (isSessionLoading) {
+ return;
+ }
+
+ if (!accessToken) {
+ setPermissionStatus('denied');
+ return;
+ }
+
+ const controller = new AbortController();
+
+ setPermissionStatus('checking');
+ setError(null);
+
+ listContributions(worldId, { page: 1, pageSize: 1 }, accessToken)
+ .then(() => {
+ if (!controller.signal.aborted) {
+ setPermissionStatus('allowed');
+ }
+ })
+ .catch((nextError: unknown) => {
+ if (controller.signal.aborted) {
+ return;
+ }
+
+ setPermissionStatus('denied');
+ setError(
+ nextError instanceof ApiError && nextError.status === 403
+ ? 'You do not have permission to draft contributions in this world.'
+ : getErrorMessage(nextError),
+ );
+ });
+
+ return () => {
+ controller.abort();
+ };
+ }, [accessToken, isSessionLoading, worldId]);
+
+ 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 canSave = useMemo(
- () => Boolean(accessToken && title.trim() && hasMeaningfulText(content) && !isSaving),
- [accessToken, content, isSaving, title],
+ () =>
+ Boolean(
+ accessToken &&
+ permissionStatus === 'allowed' &&
+ title.trim() &&
+ hasMeaningfulText(content) &&
+ !isContentTooLarge &&
+ !isSaving,
+ ),
+ [accessToken, content, isContentTooLarge, isSaving, permissionStatus, title],
);
const canSubmit = canSave && !isSubmitting;
@@ -109,6 +171,16 @@ export function ContributionEditorForm({
return null;
}
+ if (permissionStatus !== 'allowed') {
+ setError('You do not have permission to draft contributions in this world.');
+ return null;
+ }
+
+ if (isContentTooLarge) {
+ setError(`Contribution body must stay under ${contentLimitKilobytes} KB.`);
+ return null;
+ }
+
setIsSaving(true);
try {
@@ -143,7 +215,7 @@ export function ContributionEditorForm({
setIsSubmitting(true);
try {
- const currentDraft = draft ?? (await saveDraft());
+ const currentDraft = await saveDraft();
if (!currentDraft) {
return;
@@ -164,6 +236,14 @@ export function ContributionEditorForm({
return (