'use client'; import { useRouter } from 'next/navigation'; import { useTranslations } from 'next-intl'; import { useEffect, useState, type FormEvent } from 'react'; import { isApiClientError, type ContentEntryPayload } from '@sport/api-client'; import { DEFAULT_LOCALE, LOCALES, type AdminContentEntry, type ContentStatus, type ContentType, type Locale, } from '@sport/types'; import { Badge, Button, FormError, Input, Skeleton, Textarea, cn } from '@sport/ui'; import { browserApi } from '@/lib/api'; interface Fields { slug: string; title: string; excerpt: string; body: string; metaTitle: string; metaDescription: string; } interface Draft { type: ContentType; status: ContentStatus; translations: Record; } const EMPTY: Fields = { slug: '', title: '', excerpt: '', body: '', metaTitle: '', metaDescription: '', }; function emptyDraft(type: ContentType): Draft { return { type, status: 'DRAFT', translations: Object.fromEntries( LOCALES.map((locale) => [locale, { ...EMPTY }]), ) as Draft['translations'], }; } function toDraft(entry: AdminContentEntry): Draft { return { type: entry.type, status: entry.status, translations: Object.fromEntries( LOCALES.map((locale) => { const source = entry.translations[locale]; return [ locale, source ? { slug: source.slug, title: source.title, excerpt: source.excerpt ?? '', body: source.body, metaTitle: source.metaTitle ?? '', metaDescription: source.metaDescription ?? '', } : { ...EMPTY }, ]; }), ) as Draft['translations'], }; } /** * Writes one page or post, in every language. * * A full page rather than a dialog: the body is long-form Markdown, and a * modal that scrolls internally is a poor place to write anything longer than * a sentence. */ export function ContentEditor({ entryId, initialType, }: { entryId: string; initialType: ContentType; }) { const t = useTranslations('cms'); const router = useRouter(); const isNew = entryId === 'new'; const [draft, setDraft] = useState(() => (isNew ? emptyDraft(initialType) : null)); const [locale, setLocale] = useState(DEFAULT_LOCALE); const [error, setError] = useState(null); const [saving, setSaving] = useState(false); const [confirmingDelete, setConfirmingDelete] = useState(false); useEffect(() => { if (isNew) return; let cancelled = false; browserApi.contentAdmin .getById(entryId) .then((entry) => { if (!cancelled) setDraft(toDraft(entry)); }) .catch((caught: unknown) => { if (!cancelled) setError(isApiClientError(caught) ? caught.message : t('loadFailed')); }); return () => { cancelled = true; }; }, [entryId, isNew, t]); function setField(key: K, value: string) { setDraft((current) => current ? { ...current, translations: { ...current.translations, [locale]: { ...current.translations[locale], [key]: value }, }, } : current, ); } async function save(event: FormEvent, status: ContentStatus) { event.preventDefault(); if (!draft) return; setError(null); setSaving(true); try { const payload: ContentEntryPayload = { type: draft.type, status, // Only languages actually written. Sending an empty title would fail // the schema and block saving a post drafted in one language, which is // how most of them start. translations: Object.fromEntries( LOCALES.filter((key) => draft.translations[key].title.trim()).map((key) => { const fields = draft.translations[key]; return [ key, { slug: fields.slug.trim(), title: fields.title.trim(), excerpt: fields.excerpt.trim() || null, body: fields.body, metaTitle: fields.metaTitle.trim() || null, metaDescription: fields.metaDescription.trim() || null, }, ]; }), ), }; const saved = isNew ? await browserApi.contentAdmin.create(payload) : await browserApi.contentAdmin.update(entryId, payload); setDraft(toDraft(saved)); // Replace rather than push: going "back" from a freshly created entry // should reach the list, not an empty create form that would make a // second copy on save. if (isNew) router.replace(`/cms/${saved.id}`); } catch (caught) { setError(isApiClientError(caught) ? caught.message : t('saveFailed')); } finally { setSaving(false); } } async function remove() { setSaving(true); try { await browserApi.contentAdmin.remove(entryId); router.push('/cms'); } catch (caught) { setError(isApiClientError(caught) ? caught.message : t('deleteFailed')); setConfirmingDelete(false); setSaving(false); } } if (error && !draft) { return (

{error}

); } if (!draft) { return (
); } const fields = draft.translations[locale]; return (
void save(event, draft.status)} className="space-y-6">
{t(`type.${draft.type}`)} {t(`status.${draft.status}`)}
{LOCALES.map((key) => ( ))}
{draft.type === 'POST' ? ( ) : null}