feat(cms): add recovery history and operational reliability tools
CI / check (push) Failing after 22s
CI / deploy (push) Skipped
CI / publish-container (push) Skipped

This commit is contained in:
Simo committed 2026-09-09 21:57:56 +02:00
1 parent 89526af344
commit 1ef405be0a
92 files changed
+6384 -267

No files matched your search

+30
View File
@@ -9,6 +9,7 @@ import type { ArticleSaveResult } from "@/lib/article-input";
import { slugify } from "@/lib/format";
import { ArticlePreview, type ArticlePreviewData } from "./article-preview";
import { useArticleRecovery } from "./article-recovery";
import { FormFieldError, useFormErrorFocus } from "./form-feedback";
import { MediaPicker } from "./media-picker";
import { RichText } from "./rich-text";
@@ -36,6 +37,7 @@ export function ArticleForm({
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);
@@ -86,6 +88,7 @@ export function ArticleForm({
dirty,
restoreDraft,
);
useFormErrorFocus(formRef, fieldErrors, pending);
const [preview, setPreview] = useState<ArticlePreviewData | null>(null);
const suggestedSlug = useMemo(() => slugify(title), [title]);
@@ -105,12 +108,14 @@ export function ArticleForm({
const submittedVersion = editVersion.current;
saving.current = true;
setSaveError(null);
setFieldErrors({});
startTransition(async () => {
try {
await recovery.wait();
const result = await action(data);
if (result && !result.ok) {
setSaveError(result.error);
setFieldErrors(result.fieldErrors ?? {});
return;
}
if (editVersion.current === submittedVersion) setDirty(false);
@@ -151,6 +156,10 @@ export function ArticleForm({
</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)}
@@ -158,6 +167,7 @@ export function ArticleForm({
required
className="input input-bordered mt-1 w-full"
/>
<FormFieldError name="title" errors={fieldErrors} />
</label>
<label className="block">
@@ -186,6 +196,10 @@ export function ArticleForm({
<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) => {
@@ -195,6 +209,7 @@ export function ArticleForm({
placeholder={t("imagePlaceholder")}
className="input input-bordered min-w-0 flex-1"
/>
<FormFieldError name="image" errors={fieldErrors} />
<MediaPicker
onSelect={(url) => {
setImage(url);
@@ -227,6 +242,10 @@ export function ArticleForm({
</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)}
@@ -234,6 +253,7 @@ export function ArticleForm({
rows={2}
className="input input-bordered mt-1 w-full resize-y"
/>
<FormFieldError name="shortStory" errors={fieldErrors} />
</label>
<div className="block">
@@ -257,6 +277,10 @@ export function ArticleForm({
</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"
@@ -265,6 +289,7 @@ export function ArticleForm({
<option value="scheduled">{t("statusScheduled")}</option>
<option value="published">{t("statusPublished")}</option>
</select>
<FormFieldError name="status" errors={fieldErrors} />
</label>
{status === "scheduled" ? (
@@ -275,11 +300,16 @@ export function ArticleForm({
<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>