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>
324 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|