feat(admin): reorganize housekeeping and strengthen shared workflows
CI / check (push) Successful in 1m5s
CI / deploy (push) Successful in 1m4s

This commit is contained in:
Simo committed 2026-09-06 17:17:30 +02:00
1 parent 35f66d879f
commit a070004e7b
35 files changed
+1667 -462

No files matched your search

+206 -158
View File
@@ -1,8 +1,8 @@
"use client";
import { useTranslations } from "next-intl";
import { useMemo, useRef, useState } from "react";
import { useFormStatus } from "react-dom";
import { useMemo, useRef, useState, useTransition } from "react";
import { useUnsavedChanges } from "@/hooks/use-unsaved-changes";
import { slugify } from "@/lib/format";
import { ArticlePreview, type ArticlePreviewData } from "./article-preview";
import { MediaPicker } from "./media-picker";
@@ -25,6 +25,17 @@ export function ArticleForm({
};
}) {
const t = useTranslations("pages.admin.articles.form");
const tAction = useTranslations("pages.admin.actions");
const [dirty, setDirty] = useState(false);
const [pending, startTransition] = useTransition();
const [saveError, setSaveError] = useState(false);
const saving = useRef(false);
const editVersion = useRef(0);
function markDirty() {
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));
@@ -39,175 +50,212 @@ export function ArticleForm({
return (
<form
ref={formRef}
action={action}
onChangeCapture={markDirty}
onSubmit={(event) => {
event.preventDefault();
if (saving.current) return;
const data = new FormData(event.currentTarget);
const submittedVersion = editVersion.current;
saving.current = true;
setSaveError(false);
startTransition(async () => {
try {
await action(data);
if (editVersion.current === submittedVersion) setDirty(false);
} catch (error) {
if (
error &&
typeof error === "object" &&
"digest" in error &&
String(error.digest).startsWith("NEXT_REDIRECT")
) {
setDirty(false);
throw error;
}
setSaveError(true);
} finally {
saving.current = false;
}
});
}}
className="admin-card"
style={{ display: "grid", gap: "1rem" }}
>
<div className="grid gap-4 md:grid-cols-2">
<label className="block">
<span className="text-xs font-medium text-[var(--admin-text-muted)]">
{t("title")}
</span>
<input
name="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={t("titlePlaceholder")}
required
className="input input-bordered mt-1 w-full"
/>
</label>
<label className="block">
<span className="text-xs font-medium text-[var(--admin-text-muted)]">
{t("slug")}
</span>
<input
name="slug"
value={slugTouched ? slug : suggestedSlug}
onChange={(e) => {
setSlug(e.target.value);
setSlugTouched(true);
}}
placeholder={t("slugPlaceholder")}
className="input input-bordered mt-1 w-full"
/>
</label>
</div>
<div className="block">
<span className="text-xs font-medium text-[var(--admin-text-muted)]">
{t("image")}
</span>
<div className="mt-1 flex gap-2 items-start">
<input
aria-label={t("image")}
name="image"
value={image}
onChange={(e) => {
setImage(e.target.value);
setImageError(false);
}}
placeholder={t("imagePlaceholder")}
className="input input-bordered min-w-0 flex-1"
/>
<MediaPicker
onSelect={(url) => {
setImage(url);
setImageError(false);
}}
current={image}
/>
</div>
{image && !imageError ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={image}
alt="preview"
onError={() => setImageError(true)}
className="mt-2 rounded-lg border border-[var(--admin-text-muted)]/14 object-cover"
style={{ maxHeight: 160 }}
/>
) : null}
{imageError ? (
<p className="muted mt-1" style={{ fontSize: "0.78rem" }}>
Image not found — enter a valid URL or browse.
<fieldset disabled={pending} className="contents">
{saveError && (
<p role="alert" className="text-sm">
{tAction("saveError")}
</p>
) : null}
</div>
<label className="block">
<span className="text-xs font-medium text-[var(--admin-text-muted)]">
{t("body")}
</span>
<textarea
name="shortStory"
defaultValue={defaultValues?.shortStory ?? ""}
placeholder={t("bodyPlaceholder")}
rows={2}
className="input input-bordered mt-1 w-full resize-y"
/>
</label>
<div className="block">
<span className="text-xs font-medium text-[var(--admin-text-muted)]">
{t("body")}
</span>
<RichText
name="fullStory"
defaultValue={defaultValues?.fullStory ?? ""}
placeholder={t("bodyPlaceholder")}
rows={12}
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
<label className="block">
<span className="text-xs font-medium text-[var(--admin-text-muted)]">
{t("status")}
</span>
<select
name="status"
value={status}
onChange={(e) => setStatus(e.target.value)}
className="input input-bordered mt-1 w-full"
>
<option value="draft">{t("statusDraft")}</option>
<option value="scheduled">{t("statusScheduled")}</option>
<option value="published">{t("statusPublished")}</option>
</select>
</label>
{status === "scheduled" ? (
)}
<div className="grid gap-4 md:grid-cols-2">
<label className="block">
<span className="text-xs font-medium text-[var(--admin-text-muted)]">
{t("publishAt")}
{t("title")}
</span>
<input
type="datetime-local"
name="publishAt"
defaultValue={defaultValues?.publishAt ?? ""}
name="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={t("titlePlaceholder")}
required
className="input input-bordered mt-1 w-full"
/>
</label>
) : null}
</div>
<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"
className="btn btn-outline"
onClick={() => {
if (!formRef.current) return;
const values = new FormData(formRef.current);
setPreview({
title: String(values.get("title") ?? ""),
image: String(values.get("image") ?? ""),
summary: String(values.get("shortStory") ?? ""),
body: String(values.get("fullStory") ?? ""),
});
}}
>
Preview
</button>
<ArticleSaveButton label={t("save")} />
</div>
<ArticlePreview data={preview} onClose={() => setPreview(null)} />
<label className="block">
<span className="text-xs font-medium text-[var(--admin-text-muted)]">
{t("slug")}
</span>
<input
name="slug"
value={slugTouched ? slug : suggestedSlug}
onChange={(e) => {
setSlug(e.target.value);
setSlugTouched(true);
}}
placeholder={t("slugPlaceholder")}
className="input input-bordered mt-1 w-full"
/>
</label>
</div>
<div className="block">
<span className="text-xs font-medium text-[var(--admin-text-muted)]">
{t("image")}
</span>
<div className="mt-1 flex gap-2 items-start">
<input
aria-label={t("image")}
name="image"
value={image}
onChange={(e) => {
setImage(e.target.value);
setImageError(false);
}}
placeholder={t("imagePlaceholder")}
className="input input-bordered min-w-0 flex-1"
/>
<MediaPicker
onSelect={(url) => {
setImage(url);
markDirty();
setImageError(false);
}}
current={image}
/>
</div>
{image && !imageError ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={image}
alt="preview"
onError={() => setImageError(true)}
className="mt-2 rounded-lg border border-[var(--admin-text-muted)]/14 object-cover"
style={{ maxHeight: 160 }}
/>
) : null}
{imageError ? (
<p className="muted mt-1" style={{ fontSize: "0.78rem" }}>
Image not found — enter a valid URL or browse.
</p>
) : null}
</div>
<label className="block">
<span className="text-xs font-medium text-[var(--admin-text-muted)]">
{t("body")}
</span>
<textarea
name="shortStory"
defaultValue={defaultValues?.shortStory ?? ""}
placeholder={t("bodyPlaceholder")}
rows={2}
className="input input-bordered mt-1 w-full resize-y"
/>
</label>
<div className="block">
<span className="text-xs font-medium text-[var(--admin-text-muted)]">
{t("body")}
</span>
<RichText
onChange={markDirty}
name="fullStory"
defaultValue={defaultValues?.fullStory ?? ""}
placeholder={t("bodyPlaceholder")}
rows={12}
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
<label className="block">
<span className="text-xs font-medium text-[var(--admin-text-muted)]">
{t("status")}
</span>
<select
name="status"
value={status}
onChange={(e) => setStatus(e.target.value)}
className="input input-bordered mt-1 w-full"
>
<option value="draft">{t("statusDraft")}</option>
<option value="scheduled">{t("statusScheduled")}</option>
<option value="published">{t("statusPublished")}</option>
</select>
</label>
{status === "scheduled" ? (
<label className="block">
<span className="text-xs font-medium text-[var(--admin-text-muted)]">
{t("publishAt")}
</span>
<input
type="datetime-local"
name="publishAt"
defaultValue={defaultValues?.publishAt ?? ""}
required
className="input input-bordered mt-1 w-full"
/>
</label>
) : null}
</div>
<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"
className="btn btn-outline"
onClick={() => {
if (!formRef.current) return;
const values = new FormData(formRef.current);
setPreview({
title: String(values.get("title") ?? ""),
image: String(values.get("image") ?? ""),
summary: String(values.get("shortStory") ?? ""),
body: String(values.get("fullStory") ?? ""),
});
}}
>
{tAction("preview")}
</button>
<button
type="submit"
className="btn btn-primary"
disabled={pending}
aria-busy={pending}
>
{pending ? tAction("saving") : t("save")}
</button>
{dirty && (
<span
role="status"
className="self-center text-xs text-[var(--admin-text-muted)]"
>
{tAction("unsaved")}
</span>
)}
</div>
<ArticlePreview data={preview} onClose={() => setPreview(null)} />
</fieldset>
</form>
);
}
function ArticleSaveButton({ label }: { label: string }) {
const { pending } = useFormStatus();
return (
<button
type="submit"
className="btn btn-primary"
disabled={pending}
aria-busy={pending}
>
{pending ? "Saving…" : label}
</button>
);
}