feat(cms): add recovery history and operational reliability tools
This commit is contained in:
1 parent
89526af344
commit
1ef405be0a
92 files changed
+6384
-267
No files matched your search
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user