Files
EpicNext-Cms/src/components/admin/article-form.tsx
T
Simo 52f6d1491f
CI / check (push) Successful in 3m12s
CI / deploy (push) Successful in 1m13s
CI / publish-container (push) Successful in 42s
fix(news): persist publication requests and retry cache delivery
2026-09-13 18:33:45 +02:00

447 lines
13 KiB
TypeScript

"use client";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useEffect, useMemo, useRef, useState, useTransition } from "react";
import { useFormRecovery } from "@/hooks/use-form-recovery";
import { useUnsavedChanges } from "@/hooks/use-unsaved-changes";
import type { ArticleDraft } from "@/lib/article-draft";
import type { ArticleSaveResult } from "@/lib/article-input";
import { type ArticleRequest, articleRequest } from "@/lib/article-request";
import { slugify } from "@/lib/format";
import { publicationIssues } from "@/lib/publication-preflight";
import { ArticlePreview, type ArticlePreviewData } from "./article-preview";
import { useArticleRecovery } from "./article-recovery";
import { FormFieldError, useFormErrorFocus } from "./form-feedback";
import { MediaPicker } from "./media-picker";
import { PublicationPreflight } from "./publication-preflight";
import { RichText } from "./rich-text";
export function ArticleForm({
action,
userId,
defaultValues,
articleKey = "new",
baseToken = "",
}: {
action: (formData: FormData) => Promise<ArticleSaveResult | undefined>;
userId?: string;
edit?: boolean;
articleKey?: string;
baseToken?: string;
defaultValues?: {
title?: string;
slug?: string;
image?: string;
shortStory?: string;
fullStory?: string;
status?: string;
publishAt?: string;
};
}) {
const t = useTranslations("pages.admin.articles.form");
const tAction = useTranslations("pages.admin.actions");
const [dirty, setDirty] = useState(false);
const [pending, startTransition] = useTransition();
const [fieldErrors, setFieldErrors] = useState<Record<string, string[]>>({});
const [saveError, setSaveError] = useState<string | null>(null);
const router = useRouter();
const saving = useRef(false);
const request = useRef<ArticleRequest | undefined>(undefined);
const requestScope = `${userId ?? "current"}:${articleKey}`;
const editVersion = useRef(0);
function markDirty() {
localRecovery.changed();
editVersion.current++;
setDirty(true);
}
useUnsavedChanges(dirty, tAction("leaveUnsaved"));
const [title, setTitle] = useState(defaultValues?.title ?? "");
const [slug, setSlug] = useState(defaultValues?.slug ?? "");
const [slugTouched, setSlugTouched] = useState(Boolean(defaultValues?.slug));
const [image, setImage] = useState(defaultValues?.image ?? "");
const [imageError, setImageError] = useState(false);
const [status, setStatus] = useState(defaultValues?.status ?? "published");
const [publishAt, setPublishAt] = useState("");
useEffect(() => {
if (!defaultValues?.publishAt) return;
const date = new Date(defaultValues.publishAt);
if (Number.isNaN(date.getTime())) return;
const local = new Date(date.getTime() - date.getTimezoneOffset() * 60_000);
setPublishAt(local.toISOString().slice(0, 16));
}, [defaultValues?.publishAt]);
const formRef = useRef<HTMLFormElement>(null);
const [token, setToken] = useState(baseToken);
const [summary, setSummary] = useState(defaultValues?.shortStory ?? "");
const [body, setBody] = useState(defaultValues?.fullStory ?? "");
const [bodyVersion, setBodyVersion] = useState(0);
function restoreDraft(draft: ArticleDraft, revision: boolean) {
setTitle(draft.title);
setSlug(draft.slug);
setSlugTouched(true);
setImage(draft.image);
setImageError(false);
setSummary(draft.shortStory);
setBody(draft.fullStory);
setBodyVersion((v) => v + 1);
setStatus("draft");
setPublishAt("");
setToken(revision ? baseToken : draft.baseToken);
markDirty();
}
const recovery = useArticleRecovery(
articleKey,
formRef,
saving,
dirty,
restoreDraft,
);
const localRecovery = useFormRecovery({
serverDraft: recovery.draft,
userId,
kind: "article",
record: articleKey,
dirty,
snapshot: () => ({
title,
slug: slugTouched ? slug : slugify(title),
image,
shortStory: summary,
fullStory: body,
status,
publishAt,
}),
restore: (payload) =>
restoreDraft({ ...payload, baseToken } as ArticleDraft, false),
});
useFormErrorFocus(formRef, fieldErrors, pending);
const [preview, setPreview] = useState<ArticlePreviewData | null>(null);
const suggestedSlug = useMemo(() => slugify(title), [title]);
const effectiveSlug = slugify(slugTouched ? slug : suggestedSlug);
const issues = publicationIssues({
kind: "article",
image,
body,
slug: slugTouched ? slug : suggestedSlug,
normalizedSlug: effectiveSlug,
status,
publishAt,
});
return (
<form
ref={formRef}
onChangeCapture={() => {
markDirty();
if (formRef.current)
setBody(String(new FormData(formRef.current).get("fullStory") ?? ""));
}}
onSubmit={(event) => {
event.preventDefault();
if (
saving.current ||
(status !== "draft" &&
issues.some((issue) => issue.severity === "error"))
)
return;
const data = new FormData(event.currentTarget);
if (status === "scheduled" && publishAt) {
const date = new Date(publishAt);
if (!Number.isNaN(date.getTime()))
data.set("publishAt", date.toISOString());
}
localRecovery.flush();
const submittedVersion = editVersion.current;
saving.current = true;
setSaveError(null);
setFieldErrors({});
startTransition(async () => {
try {
await recovery.wait();
let storage: Storage | undefined;
try {
storage = window.sessionStorage;
} catch {
/* Storage may be disabled. */
}
request.current = await articleRequest(
data,
requestScope,
request.current,
storage,
);
data.set("requestKey", request.current.key);
const result = await action(data);
if (result && !result.ok) {
setSaveError(result.error);
setFieldErrors(result.fieldErrors ?? {});
return;
}
if (result?.ok && editVersion.current === submittedVersion) {
setDirty(false);
localRecovery.clear();
}
if (result?.ok) {
request.current = undefined;
try {
storage?.removeItem(`cms:news:request:${requestScope}`);
} catch {
/* Storage may be disabled. */
}
await recovery.clear().catch(() => {});
}
if (result?.ok && result.data?.redirectTo)
router.push(result.data.redirectTo);
} catch (error) {
if (
error &&
typeof error === "object" &&
"digest" in error &&
String(error.digest).startsWith("NEXT_REDIRECT")
) {
setDirty(false);
throw error;
}
setSaveError(tAction("saveError"));
} finally {
saving.current = false;
}
});
}}
className="admin-card"
style={{
display: "grid",
gridTemplateColumns: "minmax(0, 1fr)",
gap: "1rem",
}}
>
<fieldset disabled={pending} className="contents">
<input type="hidden" name="baseToken" value={token} />
{localRecovery.panel}
{recovery.panel}
{saveError && (
<p role="alert" className="text-sm">
{saveError}
</p>
)}
<div className="grid gap-4 md:grid-cols-2">
<label className="block">
<span className="text-xs font-medium text-[var(--admin-text-muted)]">
{t("title")}
</span>
<input
name="title"
aria-invalid={!!fieldErrors.title?.length}
aria-describedby={
fieldErrors.title?.length ? "error-title" : undefined
}
maxLength={255}
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={t("titlePlaceholder")}
required
className="input input-bordered mt-1 w-full"
/>
<FormFieldError name="title" errors={fieldErrors} />
</label>
<label className="block">
<span className="text-xs font-medium text-[var(--admin-text-muted)]">
{t("slug")}
</span>
<input
name="slug"
maxLength={255}
value={slugTouched ? slug : suggestedSlug}
onChange={(e) => {
setSlug(e.target.value);
setSlugTouched(true);
}}
placeholder={t("slugPlaceholder")}
className="input input-bordered mt-1 w-full"
/>
</label>
</div>
<div className="block">
<span className="text-xs font-medium text-[var(--admin-text-muted)]">
{t("image")}
</span>
<div className="mt-1 flex gap-2 items-start">
<input
aria-label={t("image")}
name="image"
aria-invalid={!!fieldErrors.image?.length}
aria-describedby={
fieldErrors.image?.length ? "error-image" : undefined
}
maxLength={255}
value={image}
onChange={(e) => {
setImage(e.target.value);
setImageError(false);
}}
placeholder={t("imagePlaceholder")}
className="input input-bordered min-w-0 flex-1"
/>
<FormFieldError name="image" errors={fieldErrors} />
<MediaPicker
onSelect={(url) => {
setImage(url);
markDirty();
setImageError(false);
}}
current={image}
/>
</div>
{image && !imageError ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={image}
alt={t("imagePreview")}
onError={() => setImageError(true)}
className="mt-2 rounded-lg border border-[var(--admin-text-muted)]/14 object-cover"
style={{ maxHeight: 160 }}
/>
) : null}
{imageError ? (
<p className="muted mt-1" style={{ fontSize: "0.78rem" }}>
{t("imageNotFound")}
</p>
) : null}
</div>
<label className="block">
<span className="text-xs font-medium text-[var(--admin-text-muted)]">
{t("summary")}
</span>
<textarea
name="shortStory"
aria-invalid={!!fieldErrors.shortStory?.length}
aria-describedby={
fieldErrors.shortStory?.length ? "error-shortStory" : undefined
}
maxLength={255}
value={summary}
onChange={(event) => setSummary(event.target.value)}
placeholder={t("bodyPlaceholder")}
rows={2}
className="input input-bordered mt-1 w-full resize-y"
/>
<FormFieldError name="shortStory" errors={fieldErrors} />
</label>
<div className="block">
<span className="text-xs font-medium text-[var(--admin-text-muted)]">
{t("body")}
</span>
<RichText
ariaLabel={t("body")}
key={bodyVersion}
onChange={() => {
markDirty();
if (formRef.current)
setBody(
String(new FormData(formRef.current).get("fullStory") ?? ""),
);
}}
name="fullStory"
defaultValue={body}
placeholder={t("bodyPlaceholder")}
rows={12}
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
<label className="block">
<span className="text-xs font-medium text-[var(--admin-text-muted)]">
{t("status")}
</span>
<select
name="status"
aria-invalid={!!fieldErrors.status?.length}
aria-describedby={
fieldErrors.status?.length ? "error-status" : undefined
}
value={status}
onChange={(e) => setStatus(e.target.value)}
className="input input-bordered mt-1 w-full"
>
<option value="draft">{t("statusDraft")}</option>
<option value="scheduled">{t("statusScheduled")}</option>
<option value="published">{t("statusPublished")}</option>
</select>
<FormFieldError name="status" errors={fieldErrors} />
</label>
{status === "scheduled" ? (
<label className="block">
<span className="text-xs font-medium text-[var(--admin-text-muted)]">
{t("publishAt")}
</span>
<input
type="datetime-local"
name="publishAt"
aria-invalid={!!fieldErrors.publishAt?.length}
aria-describedby={
fieldErrors.publishAt?.length ? "error-publishAt" : undefined
}
value={publishAt}
onChange={(e) => setPublishAt(e.target.value)}
required
className="input input-bordered mt-1 w-full"
/>
<FormFieldError name="publishAt" errors={fieldErrors} />
</label>
) : null}
</div>
<PublicationPreflight
issues={issues}
paths={["/news", `/news/${effectiveSlug}`, "/"]}
/>
<div className="sticky bottom-0 flex flex-wrap gap-2 border-t border-[var(--admin-border)] bg-[var(--admin-surface)] py-3">
<button
type="button"
className="btn btn-outline"
onClick={() => {
if (!formRef.current) return;
const values = new FormData(formRef.current);
setPreview({
title: String(values.get("title") ?? ""),
image: String(values.get("image") ?? ""),
summary: String(values.get("shortStory") ?? ""),
body: String(values.get("fullStory") ?? ""),
});
}}
>
{tAction("preview")}
</button>
<button
type="submit"
className="btn btn-primary"
disabled={
pending ||
(status !== "draft" &&
issues.some((issue) => issue.severity === "error"))
}
aria-busy={pending}
>
{pending ? tAction("saving") : t("save")}
</button>
{dirty && (
<span
role="status"
className="self-center text-xs text-[var(--admin-text-muted)]"
>
{tAction("unsaved")}
</span>
)}
</div>
<ArticlePreview data={preview} onClose={() => setPreview(null)} />
</fieldset>
</form>
);
}