feat(editorial): validate publications and preserve partial event updates
This commit is contained in:
1 parent
76f0420d64
commit
db4acbb46e
14 files changed
+613
-16
No files matched your search
@@ -7,10 +7,12 @@ import { useUnsavedChanges } from "@/hooks/use-unsaved-changes";
|
||||
import type { ArticleDraft } from "@/lib/article-draft";
|
||||
import type { ArticleSaveResult } from "@/lib/article-input";
|
||||
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({
|
||||
@@ -91,14 +93,33 @@ export function ArticleForm({
|
||||
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}
|
||||
onChangeCapture={() => {
|
||||
markDirty();
|
||||
if (formRef.current)
|
||||
setBody(String(new FormData(formRef.current).get("fullStory") ?? ""));
|
||||
}}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (saving.current) return;
|
||||
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);
|
||||
@@ -262,7 +283,13 @@ export function ArticleForm({
|
||||
</span>
|
||||
<RichText
|
||||
key={bodyVersion}
|
||||
onChange={markDirty}
|
||||
onChange={() => {
|
||||
markDirty();
|
||||
if (formRef.current)
|
||||
setBody(
|
||||
String(new FormData(formRef.current).get("fullStory") ?? ""),
|
||||
);
|
||||
}}
|
||||
name="fullStory"
|
||||
defaultValue={body}
|
||||
placeholder={t("bodyPlaceholder")}
|
||||
@@ -314,6 +341,10 @@ export function ArticleForm({
|
||||
) : 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"
|
||||
@@ -334,7 +365,11 @@ export function ArticleForm({
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary"
|
||||
disabled={pending}
|
||||
disabled={
|
||||
pending ||
|
||||
(status !== "draft" &&
|
||||
issues.some((issue) => issue.severity === "error"))
|
||||
}
|
||||
aria-busy={pending}
|
||||
>
|
||||
{pending ? tAction("saving") : t("save")}
|
||||
|
||||
@@ -25,9 +25,11 @@ export interface ArticlePreviewData {
|
||||
export function ArticlePreview({
|
||||
data,
|
||||
onClose,
|
||||
title,
|
||||
}: {
|
||||
data: ArticlePreviewData | null;
|
||||
onClose: () => void;
|
||||
title?: string;
|
||||
}) {
|
||||
const t = useTranslations("pages.admin.articles.form");
|
||||
const document = data
|
||||
@@ -42,13 +44,13 @@ export function ArticlePreview({
|
||||
>
|
||||
<DialogContent className="sm:max-w-4xl h-[85dvh] flex flex-col min-h-0">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("previewTitle")}</DialogTitle>
|
||||
<DialogTitle>{title ?? t("previewTitle")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("previewDescription")}
|
||||
</p>
|
||||
<iframe
|
||||
title={t("previewFrame")}
|
||||
title={title ?? t("previewFrame")}
|
||||
sandbox=""
|
||||
srcDoc={document}
|
||||
className="min-h-0 flex-1 w-full bg-background rounded border"
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "@/components/link";
|
||||
import type { PublicationIssue } from "@/lib/publication-preflight";
|
||||
export function PublicationPreflight({
|
||||
issues,
|
||||
paths,
|
||||
}: {
|
||||
issues: PublicationIssue[];
|
||||
paths: string[];
|
||||
}) {
|
||||
const t = useTranslations("pages.admin.publicationPreflight");
|
||||
return (
|
||||
<section
|
||||
className="rounded-lg border border-[var(--admin-border)] p-4 space-y-2"
|
||||
aria-label={t("title")}
|
||||
>
|
||||
<h3 className="font-semibold">{t("title")}</h3>
|
||||
<p className="text-xs text-muted-foreground">{t("description")}</p>
|
||||
{issues.length ? (
|
||||
<ul className="space-y-1 text-sm">
|
||||
{issues.map((issue) => (
|
||||
<li
|
||||
key={issue.code}
|
||||
className={
|
||||
issue.severity === "error"
|
||||
? "text-destructive"
|
||||
: "text-muted-foreground"
|
||||
}
|
||||
>
|
||||
{t(issue.code)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-sm">{t("ready")}</p>
|
||||
)}
|
||||
<p className="text-xs font-medium">{t("affectedPages")}</p>
|
||||
<div className="flex flex-wrap gap-3 text-sm">
|
||||
{paths.map((path) => (
|
||||
<Link
|
||||
key={path}
|
||||
href={path}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline break-all"
|
||||
>
|
||||
{path}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t("previewHint")}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user