"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 { CollapsibleSection } from "./ui/collapsible"; 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 if this string field is used for image/asset URLs (by description or name). */ function isImageUrlField(def: FieldDefinition, name: string): boolean { const desc = def.description && String(def.description).toLowerCase(); if (desc && (desc.includes("image") || desc.includes("asset"))) return true; if (name === "image" || name === "imageUrl" || name === "asset") return true; return false; } 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 === "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; const isRefArray = items?.type === "reference" || items?.type === "referenceOrInline"; out[key] = isRefArray ? [] : ""; } 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"; } 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 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 Object.entries(fields)) { 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(); } return (
{errors.root && (
{errors.root.message}
)} {showSlugField && ( )}
{ const value = (field.value as string) ?? "draft"; return (
); }} />

{t("statusHint")}

{hasSection ? formItems.map((item) => item.kind === "object" ? ( ) : ( {item.entries.map(([name, def]) => ( ))} ), ) : Object.entries(fields).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}
); } 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} ); 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 === "richtext" || type === "html") { return (
{label}