yap-blog/src/components/admin/PostForm.tsx
matt 6d84ae1224 Add author accounts with per-tag posting rights
The seeded account is the single admin; it can create author accounts
on the new /admin/users page (username + password) and grant each one
access to specific tags. Authors sign in to a Posts-only panel where
they can write, edit, publish, and unpublish their own posts — every
post must carry at least one granted tag, tags outside the grants are
rejected server-side, and only the admin can create tags or delete
posts (or anything else: pages, comments, settings, and backups stay
admin-only). Admin-only URLs bounce authors to their post list, and
foreign post editors 404.

posts.author_id records ownership; deleting an account keeps its posts
as unowned, admin-managed rows and signs the account out everywhere.
Backups (export v3) store the owner's username per post and re-attach
ownership on import when the account still exists.

Also fixes a latent form bug: a missing newTags field (author forms
don't render it) failed zod validation with an invisible error.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 22:10:02 -04:00

324 lines
12 KiB
TypeScript

"use client";
import Link from "next/link";
import { useActionState, useId, useRef, 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 { Button, ErrorText, HelpText, Input, Label, Select } from "@/components/ui";
import type { Tag } from "@/db/schema";
import { type FormState, firstFieldError, initialFormState } from "@/lib/forms";
import type { PostWithTags } from "@/lib/services/posts";
import { slugify } from "@/lib/slug";
import { uploadImageFile } from "@/lib/upload-client";
type Props = {
post?: PostWithTags;
/** For authors this is just their granted tags, not every tag. */
allTags: Tag[];
defaultAuthor: string;
/** Admins can mint tags inline; authors cannot. */
canCreateTags: boolean;
action: (prev: FormState, formData: FormData) => Promise<FormState>;
};
// Fields living on the settings panel — used to route validation errors
// to the tab the user needs to fix.
const SETTINGS_FIELDS = [
"title",
"slug",
"authorName",
"featuredImageUrl",
"featuredImageAlt",
"tagIds",
"newTags",
];
/**
* WordPress-style layout: the Content tab is a full-height writing canvas;
* everything descriptive (title, slug, author, featured image, tags) lives
* on the Settings tab. Both panels stay mounted so the single form submits
* all fields regardless of the visible tab, and the sticky action bar
* keeps status + save reachable from either.
*/
export function PostForm({ post, allTags, defaultAuthor, canCreateTags, action }: Props) {
const [state, formAction] = useActionState(action, initialFormState);
const ids = useId();
// The visible tab is derived: an explicit click wins until the next
// action result arrives; a result with field errors routes to the tab
// that contains them. No effects, no cascading renders.
const [tabChoice, setTabChoice] = useState<{
tab: "content" | "settings";
forState: FormState;
}>({ tab: "content", forState: initialFormState });
const [title, setTitle] = useState(post?.title ?? "");
const [slug, setSlug] = useState(post?.slug ?? "");
const [slugTouched, setSlugTouched] = useState(post !== undefined);
const [authorName, setAuthorName] = useState(post?.authorName ?? defaultAuthor);
const [imageUrl, setImageUrl] = useState(post?.featuredImageUrl ?? "");
const [imageAlt, setImageAlt] = useState(post?.featuredImageAlt ?? "");
const [status, setStatus] = useState<string>(post?.status ?? "draft");
const [selectedTagIds, setSelectedTagIds] = useState<Set<number>>(
() => new Set(post?.tags.map((t) => t.id) ?? []),
);
const [newTags, setNewTags] = useState("");
const [featuredUpload, setFeaturedUpload] = useState<
{ kind: "idle" } | { kind: "uploading" } | { kind: "error"; message: string }
>({ kind: "idle" });
const featuredFileRef = useRef<HTMLInputElement>(null);
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 });
function toggleTag(id: number) {
setSelectedTagIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
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="Post editor sections"
activeId={tab}
onSelect={(id) => setTab(id as typeof tab)}
tabs={[
{ id: "content", label: "Content", hasError: contentHasError },
{ id: "settings", label: "Post 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 post</SubmitButton>
<Link href="/admin/posts" 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={post?.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: /posts/{slug || "…"} leave blank to generate from the title.
</HelpText>
<ErrorText id={`${ids}-slug-error`}>{err("slug")}</ErrorText>
</div>
<div>
<Label htmlFor={`${ids}-author`}>Author name</Label>
<Input
id={`${ids}-author`}
name="authorName"
value={authorName}
onChange={(e) => setAuthorName(e.target.value)}
required
aria-invalid={err("authorName") ? true : undefined}
aria-describedby={err("authorName") ? `${ids}-author-error` : undefined}
/>
<ErrorText id={`${ids}-author-error`}>{err("authorName")}</ErrorText>
</div>
<fieldset className="rounded-lg border border-edge p-4">
<legend className="px-1 text-sm font-medium text-ink-strong">Featured image</legend>
<div className="space-y-4">
<div>
<Label htmlFor={`${ids}-image-url`}>Image URL (optional)</Label>
<div className="flex gap-2">
<Input
id={`${ids}-image-url`}
name="featuredImageUrl"
placeholder="https://example.com/image.jpg or upload →"
value={imageUrl}
onChange={(e) => setImageUrl(e.target.value)}
aria-invalid={err("featuredImageUrl") ? true : undefined}
aria-describedby={
err("featuredImageUrl") ? `${ids}-image-url-error` : undefined
}
/>
<Button
type="button"
variant="secondary"
className="shrink-0"
disabled={featuredUpload.kind === "uploading"}
onClick={() => featuredFileRef.current?.click()}
>
{featuredUpload.kind === "uploading" ? "Uploading…" : "Upload"}
</Button>
<input
ref={featuredFileRef}
type="file"
accept="image/png,image/jpeg,image/webp,image/gif,image/avif"
hidden
data-testid="featured-image-input"
onChange={async (event) => {
const file = event.target.files?.[0];
event.target.value = "";
if (!file) return;
setFeaturedUpload({ kind: "uploading" });
const result = await uploadImageFile(file);
if ("error" in result) {
setFeaturedUpload({ kind: "error", message: result.error });
} else {
setImageUrl(result.url);
setFeaturedUpload({ kind: "idle" });
}
}}
/>
</div>
{featuredUpload.kind === "error" && (
<ErrorText>{featuredUpload.message}</ErrorText>
)}
<ErrorText id={`${ids}-image-url-error`}>{err("featuredImageUrl")}</ErrorText>
</div>
<div>
<Label htmlFor={`${ids}-image-alt`}>Alt text (optional)</Label>
<Input
id={`${ids}-image-alt`}
name="featuredImageAlt"
value={imageAlt}
onChange={(e) => setImageAlt(e.target.value)}
aria-describedby={`${ids}-image-alt-help`}
/>
<HelpText id={`${ids}-image-alt-help`}>
Describe the image for screen-reader users; leave blank if purely decorative.
</HelpText>
</div>
</div>
</fieldset>
<fieldset className="rounded-lg border border-edge p-4">
<legend className="px-1 text-sm font-medium text-ink-strong">Tags</legend>
{allTags.length > 0 ? (
<ul className="flex flex-wrap gap-x-5 gap-y-2">
{allTags.map((tag) => (
<li key={tag.id}>
<label className="inline-flex cursor-pointer items-center gap-2 text-sm text-ink">
<input
type="checkbox"
name="tagIds"
value={tag.id}
checked={selectedTagIds.has(tag.id)}
onChange={() => toggleTag(tag.id)}
className="size-4 accent-(--link)"
/>
{tag.name}
</label>
</li>
))}
</ul>
) : (
<p className="text-sm text-ink-muted">
{canCreateTags
? "No tags exist yet — create some below."
: "You have not been given access to any tags yet — ask the admin."}
</p>
)}
<ErrorText>{err("tagIds")}</ErrorText>
{canCreateTags ? (
<div className="mt-4">
<Label htmlFor={`${ids}-new-tags`}>New tags (optional)</Label>
<Input
id={`${ids}-new-tags`}
name="newTags"
value={newTags}
onChange={(e) => setNewTags(e.target.value)}
placeholder="design, typescript"
aria-describedby={`${ids}-new-tags-help`}
/>
<HelpText id={`${ids}-new-tags-help`}>
Comma-separated. Created and attached to this post on save.
</HelpText>
<ErrorText>{err("newTags")}</ErrorText>
</div>
) : (
<HelpText>Posts must carry at least one of your tags.</HelpText>
)}
</fieldset>
</div>
</form>
);
}