"use client"; import { useId, useEffect, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { useForm, Controller } from "react-hook-form"; import { useQuery } from "@tanstack/react-query"; import { Icon } from "@iconify/react"; import { useTranslations } from "next-intl"; import { toast } from "sonner"; import type { SchemaDefinition, FieldDefinition } from "@/lib/api"; import { checkSlug, getBaseUrl, fetchAssets } from "@/lib/api"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { Label } from "@/components/ui/label"; import { Checkbox } from "@/components/ui/checkbox"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; import { ReferenceArrayField } from "./ReferenceArrayField"; import { ReferenceField } from "./ReferenceField"; import { ReferenceOrInlineField } from "./ReferenceOrInlineField"; import { MarkdownEditor } from "./MarkdownEditor"; import { CodeField, isCodeFieldLanguage } from "./CodeField"; import { CollapsibleSection } from "./ui/collapsible"; /** Wrapper class for each form field to separate them visually. */ const FIELD_BLOCK_CLASS = "rounded-xl border border-gray-200 bg-white p-4 shadow-[0_1px_2px_rgba(0,0,0,0.04)]"; type Props = { collection: string; schema: SchemaDefinition; initialValues?: Record; slug?: string; locale?: string; onSuccess?: () => void; }; /** Slug prefix for new entries: collection name with underscores → hyphens, plus trailing hyphen (e.g. calendar_item → calendar-item-). */ export function slugPrefixForCollection(collection: string): string { return collection.replace(/_/g, "-") + "-"; } /** _slug field with format and duplicate check via API. When slugPrefix is set, shows prefix as fixed text + input for the rest. */ function SlugField({ collection, currentSlug, slugPrefix, locale, register, setValue, setError, clearErrors, error, slugValue, }: { collection: string; currentSlug?: string; slugPrefix?: string; locale?: string; register: ReturnType["register"]; setValue: ReturnType["setValue"]; setError: ReturnType["setError"]; clearErrors: ReturnType["clearErrors"]; error: ReturnType["formState"]["errors"]["_slug"]; slugValue: string | undefined; }) { const t = useTranslations("ContentForm"); const registered = register("_slug", { required: t("slugRequired"), validate: slugPrefix && !currentSlug ? (v) => { const val = (v ?? "").trim(); if (!val) return true; const normalized = val.toLowerCase(); if (!normalized.startsWith(slugPrefix.toLowerCase())) { return t("slugMustStartWith", { prefix: slugPrefix }); } return true; } : undefined, }); const fullSlug = (slugValue ?? "").trim(); const suffix = slugPrefix && fullSlug.toLowerCase().startsWith(slugPrefix.toLowerCase()) ? fullSlug.slice(slugPrefix.length) : slugPrefix ? fullSlug : fullSlug; const handleSuffixChange = (e: React.ChangeEvent) => { const v = e.target.value; const normalized = slugPrefix ? v .toLowerCase() .replace(/\s+/g, "-") .replace(/[^a-z0-9-]/g, "") : v; setValue("_slug", slugPrefix ? slugPrefix + normalized : v, { shouldValidate: true, }); }; const handleBlur = async (e: React.FocusEvent) => { const value = slugPrefix ? slugPrefix + (e.target.value ?? "") : (e.target.value ?? ""); const full = value.trim(); if (!full) return; if (slugPrefix && !full.toLowerCase().startsWith(slugPrefix.toLowerCase())) return; try { const res = await checkSlug(collection, full, { exclude: currentSlug ?? undefined, _locale: locale, }); if (!res.valid && res.error) { setError("_slug", { type: "server", message: res.error }); return; } if (!res.available) { setError("_slug", { type: "server", message: t("slugInUse") }); return; } clearErrors("_slug"); } catch { // Network error etc. } }; const suffixPlaceholder = slugPrefix ? t("slugSuffixPlaceholder") : t("slugPlaceholder"); if (slugPrefix) { return (
{slugPrefix}

{t("slugHint")}

{error ? (

{String(error.message)}

) : null}
); } return (

{t("slugHint")}

{error ? (

{String(error.message)}

) : null}
); } /** True only when the schema explicitly requests image/asset URL UI via widget (no heuristics). */ function isImageUrlField(def: FieldDefinition): boolean { return def.widget === "imageUrl" || def.widget === "assetUrl"; } function assetPreviewUrl(value: string | undefined): string | null { if (!value || typeof value !== "string") return null; const v = value.trim(); if (!v) return null; if (v.startsWith("http://") || v.startsWith("https://")) return v; if (v.startsWith("/api/assets/")) return getBaseUrl() + v; if (/\.(jpg|jpeg|png|webp|gif|avif|svg)(\?|$)/i.test(v)) return getBaseUrl() + (v.startsWith("/") ? v : "/" + v); return null; } /** Asset picker content: grid of assets, click to select URL. Use inside DialogContent. */ function AssetPickerContent({ onSelect }: { onSelect: (url: string) => void }) { const t = useTranslations("ContentForm"); const { data, isLoading, error } = useQuery({ queryKey: ["assets", "all"], queryFn: () => fetchAssets(), }); const assets = data?.assets ?? []; return (
{isLoading && (

{t("loadingAssets")}

)} {error && (

{String((error as Error).message)}

)} {!isLoading && !error && assets.length === 0 && (

{t("noAssets")}

)} {!isLoading && assets.length > 0 && (
{assets.map((asset) => { const src = getBaseUrl() + asset.url; return ( ); })}
)}
); } /** String field for image/asset URL: preview + input + asset picker button. */ function ImageUrlField({ value, onChange, label, fieldError, required, readonly, }: { value: string; onChange: (v: string) => void; label: React.ReactNode; fieldError: unknown; required: boolean; readonly: boolean; }) { const t = useTranslations("ContentForm"); const [pickerOpen, setPickerOpen] = useState(false); const previewUrl = assetPreviewUrl(value); return (
{label} {previewUrl && (
)}
onChange(e.target.value)} placeholder="/api/assets/…" readOnly={readonly} className="flex-1 min-w-[200px] font-mono text-sm" /> {!readonly && ( {t("pickAsset")} { onChange(url); setPickerOpen(false); }} /> )}
{fieldError ? (

{String((fieldError as { message?: string })?.message)}

) : null}
); } /** Builds form initial values from API response: references → slug strings, objects recursively. */ function buildDefaultValues( schema: SchemaDefinition, initialValues?: Record, ): Record { const out: Record = {}; const fields = schema.fields ?? {}; if (initialValues) { for (const [key, value] of Object.entries(initialValues)) { const def = fields[key] as FieldDefinition | undefined; if (value == null) { out[key] = value; continue; } if (def?.type === "multiSelect" && !Array.isArray(value)) { out[key] = []; continue; } if (def?.type === "reference") { out[key] = typeof value === "object" && value !== null && "_slug" in value ? String((value as { _slug?: string })._slug ?? "") : typeof value === "string" ? value : ""; continue; } if (def?.type === "array" && Array.isArray(value)) { const items = def.items as FieldDefinition | undefined; const isRefArray = items?.type === "reference" || items?.type === "referenceOrInline"; if (isRefArray) { out[key] = value.map((v) => typeof v === "object" && v !== null && "_slug" in v ? (v as { _slug: string })._slug : String(v), ); continue; } } if ( def?.type === "object" && def.fields && typeof value === "object" && value !== null && !Array.isArray(value) ) { out[key] = buildDefaultValues( { name: key, fields: def.fields as Record }, value as Record, ); continue; } if ( def?.type === "object" && def.additionalProperties && typeof value === "object" && value !== null && !Array.isArray(value) ) { out[key] = value; continue; } if ( typeof value === "object" && value !== null && "_slug" in value && (def?.type === "reference" || !def) ) { out[key] = String((value as { _slug?: string })._slug ?? ""); continue; } out[key] = value; } } for (const key of Object.keys(fields)) { if (!(key in out)) { const def = fields[key] as FieldDefinition | undefined; if (def?.default !== undefined && def.default !== null) { if ( def?.type === "object" && def.fields && typeof def.default === "object" && def.default !== null && !Array.isArray(def.default) ) { out[key] = buildDefaultValues( { name: key, fields: def.fields as Record, }, def.default as Record, ); } else { out[key] = def.default; } } else if (def?.type === "boolean") out[key] = false; else if (def?.type === "array") { const items = (def as FieldDefinition).items as | FieldDefinition | undefined; const isRefArray = items?.type === "reference" || items?.type === "referenceOrInline"; const isPrimitiveArray = items?.type === "string" || items?.type === "number" || items?.type === "integer"; out[key] = isRefArray || isPrimitiveArray ? [] : ""; } else if (def?.type === "object" && def.fields) out[key] = buildDefaultValues( { name: key, fields: def.fields as Record }, {}, ); else if (def?.type === "object" && def.additionalProperties) out[key] = {}; else out[key] = ""; } } return out; } export function ContentForm({ collection, schema, initialValues, slug, locale, onSuccess, }: Props) { const t = useTranslations("ContentForm"); const router = useRouter(); const isEdit = !!slug; const defaultValues = buildDefaultValues(schema, initialValues); if (!isEdit && !initialValues?._slug && defaultValues._slug === undefined) { defaultValues._slug = slugPrefixForCollection(collection); } if (!isEdit && defaultValues._status === undefined) { defaultValues._status = "draft"; } // Backend treats missing _status as published; show same in UI when editing existing entry. if (isEdit && defaultValues._status === undefined) { defaultValues._status = "published"; } const { register, handleSubmit, setValue, setError, clearErrors, control, watch, formState: { errors, isSubmitting, isDirty }, } = useForm({ defaultValues }); // Unsaved changes: warn when leaving (browser or in-app) useEffect(() => { const onBeforeUnload = (e: BeforeUnloadEvent) => { if (isDirty) e.preventDefault(); }; window.addEventListener("beforeunload", onBeforeUnload); return () => window.removeEventListener("beforeunload", onBeforeUnload); }, [isDirty]); const onSubmit = async (data: Record) => { const payload: Record = {}; for (const [key, value] of Object.entries(data)) { const def = schema.fields?.[key]; const items = def?.type === "array" ? def.items : undefined; const isRefArray = items?.type === "reference" || items?.type === "referenceOrInline"; if (def?.type === "array" && isRefArray) { payload[key] = Array.isArray(value) ? value : typeof value === "string" ? value .split(/[\n,]+/) .map((s) => s.trim()) .filter(Boolean) : []; } else { payload[key] = value; } } if (isEdit) { delete payload._slug; } try { delete payload._contentSource; const params = locale ? { _locale: locale } : {}; if (isEdit) { const { updateEntry } = await import("@/lib/api"); await updateEntry(collection, slug!, payload, params); clearErrors("root"); toast.success(t("savedSuccessfully")); onSuccess?.(); } else { const { createEntry } = await import("@/lib/api"); await createEntry(collection, payload, params); onSuccess?.(); const newSlug = payload._slug as string | undefined; const q = locale ? `?_locale=${locale}` : ""; if (newSlug) { router.push( `/content/${collection}/${encodeURIComponent(newSlug)}${q}`, ); } else { router.push(`/content/${collection}${q}`); } } } catch (err) { const apiErrors = (err as Error & { apiErrors?: string[] }).apiErrors; if (Array.isArray(apiErrors) && apiErrors.length > 0) { const fieldErrRe = /^Field '([^']+)': (.+)$/; for (const line of apiErrors) { const m = line.match(fieldErrRe); if (m) setError(m[1] as Parameters[0], { type: "server", message: m[2], }); } const msg = apiErrors.join(" "); setError("root", { message: msg }); toast.error(msg); } else { const msg = err instanceof Error ? err.message : t("errorSaving"); setError("root", { message: msg }); toast.error(msg); } } }; const onSubmitRef = useRef(onSubmit); onSubmitRef.current = onSubmit; useEffect(() => { const onKeyDown = (e: KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && e.key === "s") { e.preventDefault(); if (isDirty) handleSubmit((data) => onSubmitRef.current(data))(); } }; window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); }, [isDirty, handleSubmit]); const fields = schema.fields ?? {}; const order = schema.fieldOrder; const orderedFieldEntries: [string, FieldDefinition][] = order?.length ? [ ...order.filter((n) => n in fields).map((n) => [n, fields[n] as FieldDefinition] as [string, FieldDefinition]), ...Object.keys(fields) .filter((n) => !order.includes(n)) .map((n) => [n, fields[n] as FieldDefinition] as [string, FieldDefinition]), ] : (Object.entries(fields) as [string, FieldDefinition][]); const slugParam = locale ? `?_locale=${locale}` : ""; const showSlugField = !isEdit && !(fields._slug != null); type FormItem = | { kind: "object"; name: string; def: FieldDefinition } | { kind: "section"; title: string; entries: [string, FieldDefinition][] }; const hasSection = Object.entries(fields).some( ([, def]) => def && (def as FieldDefinition).type !== "object" && (def as FieldDefinition).section != null && (def as FieldDefinition).section !== "", ); const formItems: FormItem[] = []; if (hasSection) { let currentTitle: string | null = null; let currentEntries: [string, FieldDefinition][] = []; const flush = () => { if (currentEntries.length > 0) { formItems.push({ kind: "section", title: currentTitle ?? "Details", entries: currentEntries, }); currentEntries = []; } currentTitle = null; }; for (const [name, def] of orderedFieldEntries) { const fd = def as FieldDefinition; if (fd.type === "object" && fd.fields) { flush(); formItems.push({ kind: "object", name, def: fd }); } else { const title = fd.section ?? "Details"; if (currentTitle !== null && currentTitle !== title) { flush(); } currentTitle = title; currentEntries.push([name, fd]); } } flush(); } const onInvalid = (errs: Record) => { const firstKey = Object.keys(errs).find((k) => k !== "root" && errs[k]); if (firstKey) { const el = document.querySelector(`[name="${firstKey}"], [data-field="${firstKey}"]`); if (el instanceof HTMLElement) el.scrollIntoView({ behavior: "smooth", block: "center" }); } toast.error(t("validationErrors")); }; return (
{errors.root && (
{errors.root.message}
)} {showSlugField && (
)}
{ const value = (field.value as string) ?? "published"; return (
); }} />

{t("statusHint")}

{hasSection ? formItems.map((item, sectionIndex) => item.kind === "object" ? ( ) : ( {item.entries.map(([name, def]) => (
))}
), ) : orderedFieldEntries.map(([name, def]) => (def as FieldDefinition).type === "object" && (def as FieldDefinition).fields ? ( ) : (
), )}
{isEdit && ( )}
); } function getFieldError(errors: Record, name: string): unknown { const parts = name.split("."); let current: unknown = errors; for (const p of parts) { current = current != null && typeof current === "object" && p in current ? (current as Record)[p] : undefined; } return current; } function ObjectFieldSet({ name, def, register, control, errors, isEdit, locale, }: { name: string; def: FieldDefinition; register: ReturnType["register"]; control: ReturnType["control"]; errors: ReturnType["formState"]["errors"]; isEdit: boolean; locale?: string; }) { const nestedFields = def.fields ?? {}; const displayName = name.split(".").pop() ?? name; const title = (def.description as string) || displayName; return ( {Object.entries(nestedFields).map(([subName, subDef]) => (
))}
); } function StringMapField({ value, onChange, label, description, error, }: { value: Record; onChange: (v: Record) => void; label: React.ReactNode; description?: string | null; error?: unknown; }) { const t = useTranslations("ContentForm"); const obj = value && typeof value === "object" && !Array.isArray(value) ? value : {}; const baseEntries = Object.entries(obj); const entries: [string, string][] = baseEntries.length > 0 && baseEntries[baseEntries.length - 1][0] === "" ? baseEntries : [...baseEntries, ["", ""]]; const update = (pairs: [string, string][]) => { const next: Record = {}; for (const [k, v] of pairs) { const key = k.trim(); if (key !== "") next[key] = v; } onChange(next); }; const setRow = (i: number, key: string, val: string) => { const next = entries.map((e, idx) => idx === i ? ([key, val] as [string, string]) : e, ); update(next); }; const removeRow = (i: number) => { update(entries.filter((_, idx) => idx !== i)); }; const addRow = () => update([...entries, ["", ""]]); return (
{label} {description ? (

{description}

) : null}
{entries.map(([k, v], i) => (
setRow(i, e.target.value, v)} placeholder={t("keyPlaceholder")} className="min-w-[120px] flex-1 rounded border border-gray-300 px-2 py-1.5 text-sm font-mono" /> setRow(i, k, e.target.value)} placeholder={t("valuePlaceholder")} className="min-w-[160px] flex-2 rounded border border-gray-300 px-2 py-1.5 text-sm" />
))}
{error ? (

{String((error as { message?: string })?.message)}

) : null}
); } /** Array of strings or numbers: list of inputs with add/remove. */ function PrimitiveArrayField({ def, value, onChange, error, label, }: { name: string; def: FieldDefinition; value: unknown; onChange: (v: (string | number)[]) => void; required: boolean; error?: unknown; label: React.ReactNode; }) { const t = useTranslations("ContentForm"); const itemsDef = def.items as FieldDefinition | undefined; const itemType = (itemsDef?.type ?? "string") as string; const isNumber = itemType === "number" || itemType === "integer"; const arr = Array.isArray(value) ? value : []; const list: (string | number)[] = arr.map((v) => isNumber ? (typeof v === "number" ? v : Number(v) || 0) : String(v ?? ""), ); const update = (next: (string | number)[]) => { onChange(next); }; const setItem = (index: number, val: string | number) => { const next = [...list]; next[index] = val; update(next); }; const remove = (index: number) => { update(list.filter((_, i) => i !== index)); }; const add = () => { update([...list, isNumber ? 0 : ""]); }; const placeholder = isNumber ? t("arrayItemPlaceholderNumber") : t("arrayItemPlaceholder"); return (
{label}

{t("arrayHint")}

{list.length === 0 ? (

{t("arrayAddItem")} ↓

) : null} {list.map((item, i) => (
{isNumber ? ( setItem(i, e.target.valueAsNumber ?? 0)} placeholder={placeholder} className="min-w-[120px] flex-1" /> ) : ( setItem(i, e.target.value)} placeholder={placeholder} className="min-w-[200px] flex-1" /> )}
))}
{error ? (

{String((error as { message?: string })?.message)}

) : null}
); } function Field({ name, def, register, control, errors, isEdit, locale, }: { name: string; def: FieldDefinition; register: ReturnType["register"]; control: ReturnType["control"]; errors: ReturnType["formState"]["errors"]; isEdit: boolean; locale?: string; }) { const t = useTranslations("ContentForm"); const type = (def.type ?? "string") as string; const required = !!def.required; const isSlug = name === "_slug"; const readonly = isEdit && isSlug; const fieldError = getFieldError(errors as Record, name); const fieldId = useId(); const fieldDescription = def.description ? (

{def.description}

) : null; const label = ( <> {fieldDescription} ); const safeStringValue = (v: unknown): string => { if (v == null) return ""; if (typeof v === "string") return v; if (typeof v === "object" && "_slug" in v) return String((v as { _slug?: string })._slug ?? ""); return String(v); }; if (type === "boolean") { return (
( field.onChange(!!checked)} className="mt-0.5" /> )} />
{fieldDescription}
); } if (type === "number") { return (
{label} {fieldError ? (

{String((fieldError as { message?: string })?.message)}

) : null}
); } if (type === "integer") { return (
{label} {fieldError ? (

{String((fieldError as { message?: string })?.message)}

) : null}
); } if (type === "textOrRef" || type === "markdown") { return ( ( )} /> ); } if (type === "html") { return ( ( )} /> ); } if (type === "richtext") { return (
{label}