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 (
+
+ );
+}
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/components/lore/lore-entity-tabs.tsx b/apps/web/src/components/lore/lore-entity-tabs.tsx
index 0af1054..82684d1 100644
--- a/apps/web/src/components/lore/lore-entity-tabs.tsx
+++ b/apps/web/src/components/lore/lore-entity-tabs.tsx
@@ -185,7 +185,7 @@ export function LoreEntityTabs({ fallbackEntries, worldId }: LoreEntityTabsProps
Edit
@@ -207,7 +207,7 @@ export function LoreEntityTabs({ fallbackEntries, worldId }: LoreEntityTabsProps
Add {category.label}
diff --git a/apps/web/src/components/lore/lore-entry-detail-client.tsx b/apps/web/src/components/lore/lore-entry-detail-client.tsx
index 2bddb5f..2a135dc 100644
--- a/apps/web/src/components/lore/lore-entry-detail-client.tsx
+++ b/apps/web/src/components/lore/lore-entry-detail-client.tsx
@@ -178,13 +178,13 @@ function LoreEntryContent({ entry, worldId }: { entry: LoreEntry; worldId: strin
Suggest edit
Extend this lore
diff --git a/apps/web/src/lib/api/auth.ts b/apps/web/src/lib/api/auth.ts
index cd05608..239eb7b 100644
--- a/apps/web/src/lib/api/auth.ts
+++ b/apps/web/src/lib/api/auth.ts
@@ -44,8 +44,8 @@ export function verifyAuthChallenge(input: {
return apiClient.post
('/auth/verify', input);
}
-export function refreshAuthSession() {
- return apiClient.post('/auth/refresh', {});
+export function refreshAuthSession(options: { signal?: AbortSignal | null } = {}) {
+ return apiClient.post('/auth/refresh', {}, { ...options, timeoutMs: null });
}
export function logoutAuthSession() {
diff --git a/apps/web/src/lib/api/client.ts b/apps/web/src/lib/api/client.ts
index 868344f..a48f745 100644
--- a/apps/web/src/lib/api/client.ts
+++ b/apps/web/src/lib/api/client.ts
@@ -8,8 +8,11 @@ type RequestOptions = {
body?: TBody | undefined;
headers?: HeadersInit;
signal?: AbortSignal | null;
+ timeoutMs?: number | null;
};
+const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
+
function getApiBaseUrl() {
return env.apiBaseUrl;
}
@@ -22,6 +25,43 @@ function hasJsonBody(body: unknown): body is JsonBody {
return body !== undefined;
}
+function createTimeoutSignal(milliseconds: number) {
+ if (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') {
+ return AbortSignal.timeout(milliseconds);
+ }
+
+ const controller = new AbortController();
+ globalThis.setTimeout(() => controller.abort(), milliseconds);
+ return controller.signal;
+}
+
+function combineSignals(signals: AbortSignal[]): AbortSignal {
+ const activeSignals = signals.filter((signal) => !signal.aborted);
+
+ if (activeSignals.length !== signals.length) {
+ const controller = new AbortController();
+ const abortedSignal = signals.find((signal) => signal.aborted);
+ controller.abort(abortedSignal?.reason);
+ return controller.signal;
+ }
+
+ if (activeSignals.length === 1) {
+ return activeSignals[0]!;
+ }
+
+ if (typeof AbortSignal !== 'undefined' && typeof AbortSignal.any === 'function') {
+ return AbortSignal.any(activeSignals);
+ }
+
+ const controller = new AbortController();
+
+ for (const signal of activeSignals) {
+ signal.addEventListener('abort', () => controller.abort(signal.reason), { once: true });
+ }
+
+ return controller.signal;
+}
+
async function parseJsonResponse(response: Response) {
if (response.status === 204) {
return undefined as TResponse;
@@ -71,8 +111,15 @@ async function request(
headers,
};
- if (options.signal) {
- requestInit.signal = options.signal;
+ const timeoutMs =
+ options.timeoutMs === undefined ? DEFAULT_REQUEST_TIMEOUT_MS : options.timeoutMs;
+ const signals = [
+ ...(timeoutMs === null ? [] : [createTimeoutSignal(timeoutMs)]),
+ ...(options.signal ? [options.signal] : []),
+ ];
+
+ if (signals.length) {
+ requestInit.signal = combineSignals(signals);
}
if (hasJsonBody(options.body)) {
diff --git a/apps/web/src/lib/api/contributions.ts b/apps/web/src/lib/api/contributions.ts
new file mode 100644
index 0000000..6ea08d1
--- /dev/null
+++ b/apps/web/src/lib/api/contributions.ts
@@ -0,0 +1,144 @@
+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 | null;
+ targetLoreEntryId?: string | null;
+ title: string;
+};
+
+export type ContributionsQuery = {
+ kind?: ContributionKind;
+ page?: number;
+ pageSize?: number;
+ status?: ContributionStatus;
+};
+
+export type ContributionsListResponse = {
+ contributions: Contribution[];
+ pagination: {
+ page: number;
+ pageSize: number;
+ total: number;
+ };
+};
+
+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}`,
+ };
+}
+
+function buildContributionListPath(worldId: string, query: ContributionsQuery = {}) {
+ const searchParams = new URLSearchParams();
+
+ if (query.kind) {
+ searchParams.set('kind', query.kind);
+ }
+
+ if (query.page) {
+ searchParams.set('page', String(query.page));
+ }
+
+ if (query.pageSize) {
+ searchParams.set('pageSize', String(query.pageSize));
+ }
+
+ if (query.status) {
+ searchParams.set('status', query.status);
+ }
+
+ const queryString = searchParams.toString();
+
+ return `${contributionPath(worldId)}${queryString ? `?${queryString}` : ''}`;
+}
+
+export function listContributions(
+ worldId: string,
+ query: ContributionsQuery,
+ accessToken: string,
+ options: { signal?: AbortSignal } = {},
+) {
+ return apiClient.get(buildContributionListPath(worldId, query), {
+ headers: authHeaders(accessToken),
+ ...(options.signal ? { signal: options.signal } : {}),
+ });
+}
+
+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),
+ });
+}
diff --git a/apps/web/src/providers/auth-session-provider.tsx b/apps/web/src/providers/auth-session-provider.tsx
index a04693d..ee26a8a 100644
--- a/apps/web/src/providers/auth-session-provider.tsx
+++ b/apps/web/src/providers/auth-session-provider.tsx
@@ -21,6 +21,8 @@ import {
const AUTH_SESSION_CHANNEL = 'hivelore-auth-session';
const AUTH_REFRESH_LOCK = 'hivelore-auth-refresh';
+const AUTH_REFRESH_LOCK_TIMEOUT_MS = 10_000;
+const AUTH_REFRESH_BROADCAST_WAIT_MS = 750;
type AuthSessionContextValue = {
accessToken: string | null;
@@ -43,6 +45,32 @@ function refreshAuthSessionOnce() {
return refreshSessionPromise;
}
+function createTimeoutSignal(milliseconds: number) {
+ if (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') {
+ return AbortSignal.timeout(milliseconds);
+ }
+
+ const controller = new AbortController();
+ window.setTimeout(() => controller.abort(), milliseconds);
+ return controller.signal;
+}
+
+function isAbortError(error: unknown) {
+ return (
+ error instanceof DOMException && (error.name === 'AbortError' || error.name === 'TimeoutError')
+ );
+}
+
+function isTerminalRefreshError(error: unknown) {
+ return error instanceof ApiError && (error.status === 401 || error.status === 403);
+}
+
+function delay(milliseconds: number) {
+ return new Promise((resolve) => {
+ window.setTimeout(resolve, milliseconds);
+ });
+}
+
export function AuthSessionProvider({ children }: { children: ReactNode }) {
const [accessToken, setAccessToken] = useState(null);
const [isSessionLoading, setIsSessionLoading] = useState(true);
@@ -79,13 +107,40 @@ export function AuthSessionProvider({ children }: { children: ReactNode }) {
return refreshAuthSessionOnce();
};
- const lockedRefresh: Promise = (
- typeof navigator !== 'undefined' && navigator.locks
- ? navigator.locks.request(AUTH_REFRESH_LOCK, async () => performRefresh())
- : performRefresh()
- ) as Promise;
+ const waitForBroadcastedSession = async () => {
+ await delay(AUTH_REFRESH_BROADCAST_WAIT_MS);
- const refreshPromise = lockedRefresh
+ if (
+ latestSessionRef.current &&
+ latestSessionRef.current.accessToken !== startingAccessToken
+ ) {
+ return latestSessionRef.current;
+ }
+
+ throw new Error('Could not acquire the auth refresh lock before it timed out.');
+ };
+
+ const refreshWithOptionalLock = async () => {
+ if (typeof navigator === 'undefined' || !navigator.locks) {
+ return performRefresh();
+ }
+
+ try {
+ return await navigator.locks.request(
+ AUTH_REFRESH_LOCK,
+ { signal: createTimeoutSignal(AUTH_REFRESH_LOCK_TIMEOUT_MS) },
+ async () => performRefresh(),
+ );
+ } catch (error) {
+ if (isAbortError(error)) {
+ return waitForBroadcastedSession();
+ }
+
+ throw error;
+ }
+ };
+
+ const refreshPromise = refreshWithOptionalLock()
.then((session) => {
setSession(session);
channelRef.current?.postMessage({
@@ -95,7 +150,7 @@ export function AuthSessionProvider({ children }: { children: ReactNode }) {
return session;
})
.catch((error) => {
- if (error instanceof ApiError && error.status === 401) {
+ if (isTerminalRefreshError(error)) {
accessTokenRef.current = null;
latestSessionRef.current = null;
userRef.current = null;
@@ -156,8 +211,8 @@ export function AuthSessionProvider({ children }: { children: ReactNode }) {
setSession(session);
}
})
- .catch(() => {
- if (isMounted) {
+ .catch((error) => {
+ if (isMounted && isTerminalRefreshError(error)) {
setAccessToken(null);
setUser(null);
}