feat(admin): reorganize housekeeping and strengthen shared workflows
This commit is contained in:
1 parent
35f66d879f
commit
a070004e7b
35 files changed
+1667
-462
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user