151 lines
5.2 KiB
TypeScript
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>
|
|
);
|
|
}
|