This commit is contained in:
Nông Đức Huy
2026-08-13 23:20:23 +07:00
parent 1e356a2578
commit d9f159a8c3
84 changed files with 7992 additions and 180 deletions
@@ -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>
);
}