357 lines
11 KiB
TypeScript
357 lines
11 KiB
TypeScript
'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<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-500' : '',
|
|
)}
|
|
>
|
|
{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-500 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-500 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
|
|
/>
|
|
<span className="text-ink-500 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>
|
|
|
|
<FormError message={error} />
|
|
|
|
<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>
|
|
);
|
|
}
|