feat(editorial): validate publications and preserve partial event updates
CI / check (push) Successful in 57s
CI / deploy (push) Successful in 18s
CI / publish-container (push) Successful in 1m20s

This commit is contained in:
Simo committed 2026-09-11 00:36:55 +02:00
1 parent 76f0420d64
commit db4acbb46e
14 files changed
+613 -16

No files matched your search

+39 -4
View File
@@ -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")}
+4 -2
View File
@@ -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>
);
}