yap-blog/src/components/admin/PageForm.tsx
2026-07-02 21:32:33 -04:00

151 lines
5.2 KiB
TypeScript

"use client";
import Link from "next/link";
import { useActionState, useId, useState } from "react";
import { FormErrorBanner } from "@/components/admin/Flash";
import { FormTabs } from "@/components/admin/FormTabs";
import { RichTextEditor } from "@/components/admin/RichTextEditor";
import { SubmitButton } from "@/components/admin/SubmitButton";
import { ErrorText, HelpText, Input, Label, Select } from "@/components/ui";
import type { Page } from "@/db/schema";
import { type FormState, firstFieldError, initialFormState } from "@/lib/forms";
import { slugify } from "@/lib/slug";
type Props = {
page?: Page;
action: (prev: FormState, formData: FormData) => Promise<FormState>;
};
const SETTINGS_FIELDS = ["title", "slug"];
/** Same tabbed layout as PostForm: full-height canvas + settings panel. */
export function PageForm({ page, action }: Props) {
const [state, formAction] = useActionState(action, initialFormState);
const ids = useId();
// See PostForm: explicit tab clicks win until the next action result,
// which routes to the tab containing validation errors.
const [tabChoice, setTabChoice] = useState<{
tab: "content" | "settings";
forState: FormState;
}>({ tab: "content", forState: initialFormState });
const [title, setTitle] = useState(page?.title ?? "");
const [slug, setSlug] = useState(page?.slug ?? "");
const [slugTouched, setSlugTouched] = useState(page !== undefined);
const [status, setStatus] = useState<string>(page?.status ?? "draft");
const err = (field: string) => firstFieldError(state, field);
const errorKeys = Object.keys(state.fieldErrors ?? {});
const settingsHasError = errorKeys.some((key) => SETTINGS_FIELDS.includes(key));
const contentHasError = errorKeys.includes("body");
const tab =
tabChoice.forState === state
? tabChoice.tab
: settingsHasError
? "settings"
: contentHasError
? "content"
: tabChoice.tab;
const setTab = (next: "content" | "settings") =>
setTabChoice({ tab: next, forState: state });
return (
<form action={formAction}>
<div className="sticky top-0 z-20 -mb-px flex flex-wrap items-center justify-between gap-x-4 gap-y-2 border-b border-edge bg-background pt-1">
<FormTabs
idBase={ids}
label="Page editor sections"
activeId={tab}
onSelect={(id) => setTab(id as typeof tab)}
tabs={[
{ id: "content", label: "Content", hasError: contentHasError },
{ id: "settings", label: "Page settings", hasError: settingsHasError },
]}
/>
<div className="flex items-center gap-3 pb-1.5">
<Select
aria-label="Status"
name="status"
value={status}
onChange={(e) => setStatus(e.target.value)}
className="w-32"
>
<option value="draft">Draft</option>
<option value="published">Published</option>
</Select>
<SubmitButton>Save page</SubmitButton>
<Link href="/admin/pages" className="text-sm text-ink-muted hover:text-ink-strong">
Cancel
</Link>
</div>
</div>
<div className="pt-5">
<FormErrorBanner>{state.formError}</FormErrorBanner>
</div>
<div
role="tabpanel"
id={`${ids}-panel-content`}
aria-labelledby={`${ids}-tab-content`}
hidden={tab !== "content"}
>
<RichTextEditor
name="body"
label="Body"
initialHTML={page?.body ?? ""}
error={err("body")}
minHeightClassName="min-h-[max(24rem,calc(100dvh-24rem))]"
/>
</div>
<div
role="tabpanel"
id={`${ids}-panel-settings`}
aria-labelledby={`${ids}-tab-settings`}
hidden={tab !== "settings"}
className="max-w-3xl space-y-6"
>
<div>
<Label htmlFor={`${ids}-title`}>Title</Label>
<Input
id={`${ids}-title`}
name="title"
value={title}
onChange={(e) => {
setTitle(e.target.value);
if (!slugTouched) setSlug(slugify(e.target.value));
}}
required
aria-invalid={err("title") ? true : undefined}
aria-describedby={err("title") ? `${ids}-title-error` : undefined}
/>
<ErrorText id={`${ids}-title-error`}>{err("title")}</ErrorText>
</div>
<div>
<Label htmlFor={`${ids}-slug`}>Slug</Label>
<Input
id={`${ids}-slug`}
name="slug"
value={slug}
onChange={(e) => {
setSlug(e.target.value);
setSlugTouched(e.target.value !== "");
}}
aria-invalid={err("slug") ? true : undefined}
aria-describedby={`${ids}-slug-help${err("slug") ? ` ${ids}-slug-error` : ""}`}
/>
<HelpText id={`${ids}-slug-help`}>
Public URL: /pages/{slug || "…"} leave blank to generate from the title.
</HelpText>
<ErrorText id={`${ids}-slug-error`}>{err("slug")}</ErrorText>
</div>
<HelpText>Published pages can be linked from the top navigation.</HelpText>
</div>
</form>
);
}