Stage M7
This commit is contained in:
@@ -0,0 +1,361 @@
|
||||
'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, Input, Skeleton, 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<Locale, Fields>;
|
||||
}
|
||||
|
||||
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<Draft | null>(() => (isNew ? emptyDraft(initialType) : null));
|
||||
const [locale, setLocale] = useState<Locale>(DEFAULT_LOCALE);
|
||||
const [error, setError] = useState<string | null>(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<K extends keyof Fields>(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 (
|
||||
<p role="alert" className="text-danger text-sm">
|
||||
{error}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (!draft) {
|
||||
return (
|
||||
<div className="space-y-3" aria-busy="true">
|
||||
<Skeleton className="h-10 w-80" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const fields = draft.translations[locale];
|
||||
|
||||
return (
|
||||
<form onSubmit={(event) => void save(event, draft.status)} className="space-y-6">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Badge variant="outline">{t(`type.${draft.type}`)}</Badge>
|
||||
<Badge variant={draft.status === 'PUBLISHED' ? 'success' : 'neutral'}>
|
||||
{t(`status.${draft.status}`)}
|
||||
</Badge>
|
||||
|
||||
<div className="border-ink-200 rounded-card ml-auto flex overflow-hidden border">
|
||||
{LOCALES.map((key) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={() => setLocale(key)}
|
||||
aria-pressed={locale === key}
|
||||
className={cn(
|
||||
'px-3 py-1.5 text-xs font-semibold uppercase tracking-widest transition-colors',
|
||||
locale === key ? 'bg-ink-950 text-white' : 'hover:bg-ink-100 bg-white',
|
||||
// A language with no title yet is not written — worth showing,
|
||||
// because an operator switching tabs needs to know which
|
||||
// versions will actually be saved.
|
||||
!draft.translations[key].title.trim() && locale !== key ? 'text-ink-400' : '',
|
||||
)}
|
||||
>
|
||||
{key}
|
||||
{draft.translations[key].title.trim() ? '' : ' ·'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<label className="block space-y-1.5">
|
||||
<span className="text-xs font-semibold uppercase tracking-widest">{t('title')}</span>
|
||||
<Input value={fields.title} onChange={(event) => setField('title', event.target.value)} />
|
||||
</label>
|
||||
|
||||
<label className="block space-y-1.5">
|
||||
<span className="text-xs font-semibold uppercase tracking-widest">{t('slug')}</span>
|
||||
<Input
|
||||
value={fields.slug}
|
||||
onChange={(event) => setField('slug', event.target.value)}
|
||||
placeholder="how-to-layer"
|
||||
/>
|
||||
<span className="text-ink-400 text-xs">{t('slugHint')}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{draft.type === 'POST' ? (
|
||||
<label className="block space-y-1.5">
|
||||
<span className="text-xs font-semibold uppercase tracking-widest">{t('excerpt')}</span>
|
||||
<Input
|
||||
value={fields.excerpt}
|
||||
onChange={(event) => setField('excerpt', event.target.value)}
|
||||
/>
|
||||
<span className="text-ink-400 text-xs">{t('excerptHint')}</span>
|
||||
</label>
|
||||
) : null}
|
||||
|
||||
<label className="block space-y-1.5">
|
||||
<span className="text-xs font-semibold uppercase tracking-widest">{t('body')}</span>
|
||||
<textarea
|
||||
value={fields.body}
|
||||
onChange={(event) => setField('body', event.target.value)}
|
||||
rows={22}
|
||||
spellCheck
|
||||
className="border-ink-200 focus:border-ink-950 w-full border px-3 py-2 font-mono text-sm leading-relaxed outline-none"
|
||||
/>
|
||||
<span className="text-ink-400 text-xs">{t('bodyHint')}</span>
|
||||
</label>
|
||||
|
||||
<details className="border-ink-200 border p-4">
|
||||
<summary className="cursor-pointer text-xs font-semibold uppercase tracking-widest">
|
||||
{t('seo')}
|
||||
</summary>
|
||||
<div className="mt-4 grid gap-4 sm:grid-cols-2">
|
||||
<label className="block space-y-1.5">
|
||||
<span className="text-xs font-semibold uppercase tracking-widest">
|
||||
{t('metaTitle')}
|
||||
</span>
|
||||
<Input
|
||||
value={fields.metaTitle}
|
||||
onChange={(event) => setField('metaTitle', event.target.value)}
|
||||
placeholder={fields.title}
|
||||
/>
|
||||
</label>
|
||||
<label className="block space-y-1.5">
|
||||
<span className="text-xs font-semibold uppercase tracking-widest">
|
||||
{t('metaDescription')}
|
||||
</span>
|
||||
<Input
|
||||
value={fields.metaDescription}
|
||||
onChange={(event) => setField('metaDescription', event.target.value)}
|
||||
placeholder={fields.excerpt}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
{error ? (
|
||||
<p role="alert" className="text-danger text-sm">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="border-ink-200 flex flex-wrap items-center gap-2 border-t pt-4">
|
||||
<Button type="submit" variant="outline" disabled={saving}>
|
||||
{saving ? t('saving') : t('saveDraft')}
|
||||
</Button>
|
||||
|
||||
{/*
|
||||
Publish and save are separate buttons rather than a status dropdown
|
||||
plus save. "Publish" is the consequential action and should say so —
|
||||
picking PUBLISHED from a select and pressing Save reads as neither.
|
||||
*/}
|
||||
<Button type="button" disabled={saving} onClick={(event) => void save(event, 'PUBLISHED')}>
|
||||
{draft.status === 'PUBLISHED' ? t('update') : t('publish')}
|
||||
</Button>
|
||||
|
||||
{draft.status === 'PUBLISHED' ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
disabled={saving}
|
||||
onClick={(event) => void save(event, 'DRAFT')}
|
||||
>
|
||||
{t('unpublish')}
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{!isNew ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant={confirmingDelete ? 'destructive' : 'ghost'}
|
||||
disabled={saving}
|
||||
className="ml-auto"
|
||||
onClick={() => (confirmingDelete ? void remove() : setConfirmingDelete(true))}
|
||||
>
|
||||
{confirmingDelete ? t('deleteConfirm') : t('delete')}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user