"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; 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>({}); const [saveError, setSaveError] = useState(null); const router = useRouter(); const saving = useRef(false); const request = useRef(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(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(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 (
{ 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", }} >
{localRecovery.panel} {recovery.panel} {saveError && (

{saveError}

)}
{t("image")}
{ setImage(e.target.value); setImageError(false); }} placeholder={t("imagePlaceholder")} className="input input-bordered min-w-0 flex-1" /> { setImage(url); markDirty(); setImageError(false); }} current={image} />
{image && !imageError ? ( // eslint-disable-next-line @next/next/no-img-element {t("imagePreview")} setImageError(true)} className="mt-2 rounded-lg border border-[var(--admin-text-muted)]/14 object-cover" style={{ maxHeight: 160 }} /> ) : null} {imageError ? (

{t("imageNotFound")}

) : null}