"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; }; // 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(post?.status ?? "draft"); const [selectedTagIds, setSelectedTagIds] = useState>( () => 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(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 (
setTab(id as typeof tab)} tabs={[ { id: "content", label: "Content", hasError: contentHasError }, { id: "settings", label: "Post settings", hasError: settingsHasError }, ]} />
Save post Cancel
{state.formError}
); }