Files
rustycms/admin-ui/src/components/ContentForm.tsx

1254 lines
38 KiB
TypeScript

"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<string, unknown>;
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<typeof useForm>["register"];
setValue: ReturnType<typeof useForm>["setValue"];
setError: ReturnType<typeof useForm>["setError"];
clearErrors: ReturnType<typeof useForm>["clearErrors"];
error: ReturnType<typeof useForm>["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<HTMLInputElement>) => {
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<HTMLInputElement>) => {
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 (
<div>
<Label className="mb-1 block font-medium">
_slug <span className="text-red-500">*</span>
</Label>
<div className="flex h-9 items-center rounded-md border border-input bg-background font-mono text-sm shadow-xs ring-offset-background focus-within:ring-[3px] focus-within:ring-ring/50">
<span className="shrink-0 border-r border-input bg-muted/50 px-3 py-2 text-muted-foreground">
{slugPrefix}
</span>
<input
type="text"
value={suffix}
onChange={handleSuffixChange}
onBlur={handleBlur}
placeholder={suffixPlaceholder}
className="h-full flex-1 min-w-0 border-0 bg-transparent px-3 py-1 text-base outline-none placeholder:text-muted-foreground md:text-sm"
autoComplete="off"
aria-label={t("slugSuffixAriaLabel")}
/>
</div>
<p className="mt-1 text-xs text-gray-500">{t("slugHint")}</p>
{error ? (
<p className="mt-1 text-sm text-red-600">{String(error.message)}</p>
) : null}
</div>
);
}
return (
<div>
<Label className="mb-1 block font-medium">
_slug <span className="text-red-500">*</span>
</Label>
<Input
type="text"
{...registered}
onBlur={handleBlur}
placeholder={t("slugPlaceholder")}
className="font-mono"
autoComplete="off"
/>
<p className="mt-1 text-xs text-gray-500">{t("slugHint")}</p>
{error ? (
<p className="mt-1 text-sm text-red-600">{String(error.message)}</p>
) : null}
</div>
);
}
/** 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 (
<div className="min-h-0 flex-1 overflow-auto">
{isLoading && (
<p className="text-muted-foreground text-sm">{t("loadingAssets")}</p>
)}
{error && (
<p className="text-destructive text-sm">{String((error as Error).message)}</p>
)}
{!isLoading && !error && assets.length === 0 && (
<p className="text-muted-foreground text-sm">{t("noAssets")}</p>
)}
{!isLoading && assets.length > 0 && (
<div className="grid grid-cols-3 sm:grid-cols-4 gap-3">
{assets.map((asset) => {
const src = getBaseUrl() + asset.url;
return (
<button
key={asset.url}
type="button"
onClick={() => onSelect(asset.url)}
className="flex flex-col items-center rounded-lg border border-input bg-muted/30 p-2 hover:bg-muted/60 focus:ring-2 focus:ring-ring focus:ring-offset-2"
>
<span className="aspect-square w-full overflow-hidden rounded border bg-background flex items-center justify-center">
{/\.(svg)$/i.test(asset.filename) ? (
<img src={src} alt="" className="max-h-full max-w-full object-contain" />
) : (
<img src={src} alt="" className="h-full w-full object-cover" />
)}
</span>
<span className="mt-1 truncate w-full text-center text-xs text-muted-foreground">
{asset.filename}
</span>
</button>
);
})}
</div>
)}
</div>
);
}
/** 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 (
<div>
{label}
{previewUrl && (
<div className="mt-2 rounded-lg border border-input overflow-hidden bg-muted/20 inline-block max-w-[200px]">
<img
src={previewUrl}
alt=""
className="max-h-40 w-full object-contain"
/>
</div>
)}
<div className="mt-2 flex flex-wrap items-center gap-2">
<Input
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="/api/assets/…"
readOnly={readonly}
className="flex-1 min-w-[200px] font-mono text-sm"
/>
{!readonly && (
<Dialog open={pickerOpen} onOpenChange={setPickerOpen}>
<DialogTrigger asChild>
<Button type="button" variant="outline" size="sm">
<Icon icon="mdi:image-multiple" className="mr-1 size-4" />
{t("pickFromAssets")}
</Button>
</DialogTrigger>
<DialogContent className="max-h-[85vh] max-w-2xl overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle>{t("pickAsset")}</DialogTitle>
</DialogHeader>
<AssetPickerContent
onSelect={(url) => {
onChange(url);
setPickerOpen(false);
}}
/>
</DialogContent>
</Dialog>
)}
</div>
{fieldError ? (
<p className="mt-1 text-sm text-red-600">
{String((fieldError as { message?: string })?.message)}
</p>
) : null}
</div>
);
}
/** Builds form initial values from API response: references → slug strings, objects recursively. */
function buildDefaultValues(
schema: SchemaDefinition,
initialValues?: Record<string, unknown>,
): Record<string, unknown> {
const out: Record<string, unknown> = {};
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<string, FieldDefinition> },
value as Record<string, unknown>,
);
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<string, FieldDefinition> },
def.default as Record<string, unknown>,
);
} 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<string, FieldDefinition> },
{},
);
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<string, unknown>) => {
const payload: Record<string, unknown> = {};
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<typeof setError>[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 (
<form onSubmit={handleSubmit(onSubmit)} className="max-w-2xl space-y-8">
{errors.root && (
<div className="rounded bg-red-50 p-3 text-sm text-red-700" role="alert">
{errors.root.message}
</div>
)}
{showSlugField && (
<SlugField
collection={collection}
currentSlug={slug}
slugPrefix={!isEdit ? slugPrefixForCollection(collection) : undefined}
locale={locale}
register={register}
setValue={setValue}
setError={setError}
clearErrors={clearErrors}
error={errors._slug}
slugValue={watch("_slug") as string | undefined}
/>
)}
<div>
<Label className="mb-1 block font-medium">{t("status")}</Label>
<Controller
name="_status"
control={control!}
render={({ field }) => {
const value = (field.value as string) ?? "draft";
return (
<div className="flex flex-wrap gap-2" role="group" aria-label={t("status")}>
<Button
type="button"
variant={value === "draft" ? "default" : "outline"}
size="sm"
onClick={() => field.onChange("draft")}
className="min-w-[100px]"
>
{t("statusDraft")}
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => field.onChange("published")}
className={`min-w-[100px] ${value === "published" ? "border-success-300 bg-success-50 text-success-700 hover:bg-success-100 hover:text-success-800 hover:border-success-400" : ""}`}
>
{t("statusPublished")}
</Button>
</div>
);
}}
/>
<p className="mt-0.5 text-xs text-muted-foreground">{t("statusHint")}</p>
</div>
{hasSection
? formItems.map((item) =>
item.kind === "object" ? (
<ObjectFieldSet
key={item.name}
name={item.name}
def={item.def}
register={register}
control={control}
errors={errors}
isEdit={isEdit}
locale={locale}
/>
) : (
<CollapsibleSection
key={item.title}
title={item.title}
defaultOpen={true}
contentClassName="space-y-4"
>
{item.entries.map(([name, def]) => (
<Field
key={name}
name={name}
def={def}
register={register}
control={control}
errors={errors}
isEdit={isEdit}
locale={locale}
/>
))}
</CollapsibleSection>
),
)
: Object.entries(fields).map(([name, def]) =>
(def as FieldDefinition).type === "object" && (def as FieldDefinition).fields ? (
<ObjectFieldSet
key={name}
name={name}
def={def as FieldDefinition}
register={register}
control={control}
errors={errors}
isEdit={isEdit}
locale={locale}
/>
) : (
<Field
key={name}
name={name}
def={def as FieldDefinition}
register={register}
control={control}
errors={errors}
isEdit={isEdit}
locale={locale}
/>
),
)}
<div className="flex flex-wrap gap-3 pt-6">
<Button type="submit" disabled={isSubmitting} className="min-h-[44px] sm:min-h-0">
{isSubmitting ? t("saving") : t("save")}
</Button>
{isEdit && (
<Button variant="outline" asChild className="min-h-[44px] sm:min-h-0">
<a href={`/content/${collection}${slugParam}`}>
{t("backToList")}
</a>
</Button>
)}
</div>
</form>
);
}
function getFieldError(errors: Record<string, unknown>, 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<string, unknown>)[p]
: undefined;
}
return current;
}
function ObjectFieldSet({
name,
def,
register,
control,
errors,
isEdit,
locale,
}: {
name: string;
def: FieldDefinition;
register: ReturnType<typeof useForm>["register"];
control: ReturnType<typeof useForm>["control"];
errors: ReturnType<typeof useForm>["formState"]["errors"];
isEdit: boolean;
locale?: string;
}) {
const nestedFields = def.fields ?? {};
const displayName = name.split(".").pop() ?? name;
const title = (def.description as string) || displayName;
return (
<CollapsibleSection title={title} defaultOpen={true} contentClassName="space-y-4">
{Object.entries(nestedFields).map(([subName, subDef]) => (
<Field
key={subName}
name={`${name}.${subName}`}
def={subDef as FieldDefinition}
register={register}
control={control}
errors={errors}
isEdit={isEdit}
locale={locale}
/>
))}
</CollapsibleSection>
);
}
function StringMapField({
value,
onChange,
label,
description,
error,
}: {
value: Record<string, string>;
onChange: (v: Record<string, string>) => 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<string, string> = {};
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 (
<div>
{label}
{description ? <p className="mt-0.5 text-xs text-gray-500">{description}</p> : null}
<div className="mt-2 space-y-2 rounded border border-gray-200 bg-white p-3">
{entries.map(([k, v], i) => (
<div key={i} className="flex flex-wrap items-center gap-2">
<input
type="text"
value={k}
onChange={(e) => 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"
/>
<span className="text-gray-400"></span>
<input
type="text"
value={v}
onChange={(e) => 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"
/>
<button
type="button"
onClick={() => removeRow(i)}
title={t("removeEntry")}
aria-label={t("removeEntry")}
className="rounded border border-gray-300 bg-gray-100 p-1.5 text-gray-600 hover:bg-gray-200 hover:text-gray-900"
>
<Icon icon="mdi:delete-outline" className="size-5" />
</button>
</div>
))}
<button
type="button"
onClick={addRow}
className="rounded border border-dashed border-gray-300 bg-gray-50 px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-100"
>
{t("addEntry")}
</button>
</div>
{error ? (
<p className="mt-1 text-sm text-red-600">
{String((error as { message?: string })?.message)}
</p>
) : null}
</div>
);
}
function Field({
name,
def,
register,
control,
errors,
isEdit,
locale,
}: {
name: string;
def: FieldDefinition;
register: ReturnType<typeof useForm>["register"];
control: ReturnType<typeof useForm>["control"];
errors: ReturnType<typeof useForm>["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<string, unknown>, name);
const fieldId = useId();
const fieldDescription = def.description ? (
<p className="mt-0.5 text-xs text-gray-500">{def.description}</p>
) : null;
const label = (
<>
<Label className="mb-1 block font-bold">
{name.split(".").pop()}
{required && <span className="text-red-500"> *</span>}
</Label>
{fieldDescription}
</>
);
if (type === "boolean") {
return (
<div>
<div className="flex items-start gap-2">
<Controller
name={name}
control={control!}
render={({ field }) => (
<Checkbox
id={fieldId}
checked={!!field.value}
onCheckedChange={(checked) => field.onChange(!!checked)}
className="mt-0.5"
/>
)}
/>
<label htmlFor={fieldId} className="cursor-pointer text-sm font-medium text-gray-700">
{name.split(".").pop()}
{required && <span className="text-red-500"> *</span>}
</label>
</div>
{fieldDescription}
</div>
);
}
if (type === "number") {
return (
<div>
{label}
<Input
type="number"
step="any"
{...register(name, { valueAsNumber: true, required })}
/>
{fieldError ? (
<p className="mt-1 text-sm text-red-600">
{String((fieldError as { message?: string })?.message)}
</p>
) : null}
</div>
);
}
if (type === "integer") {
return (
<div>
{label}
<Input
type="number"
step={1}
{...register(name, { valueAsNumber: true, required })}
/>
{fieldError ? (
<p className="mt-1 text-sm text-red-600">
{String((fieldError as { message?: string })?.message)}
</p>
) : null}
</div>
);
}
if (type === "textOrRef" || type === "markdown") {
return (
<Controller
name={name}
control={control!}
rules={{ required }}
render={({ field }) => (
<MarkdownEditor
value={typeof field.value === "string" ? field.value : ""}
onChange={field.onChange}
label={label}
error={fieldError}
required={required}
readOnly={readonly}
rows={12}
/>
)}
/>
);
}
if (type === "richtext" || type === "html") {
return (
<div>
{label}
<Textarea
{...register(name, { required })}
rows={10}
readOnly={readonly}
className="font-mono"
/>
{fieldError ? (
<p className="mt-1 text-sm text-red-600">
{String((fieldError as { message?: string })?.message)}
</p>
) : null}
</div>
);
}
if (type === "datetime") {
const parseDatetime = (v: unknown): { date: string; time: string } => {
if (v == null || v === "") return { date: "", time: "" };
const s = typeof v === "string" ? v.trim() : String(v);
if (!s) return { date: "", time: "" };
const match = s.match(/^(\d{4}-\d{2}-\d{2})T?(\d{2}:\d{2})?/);
if (match) return { date: match[1] ?? "", time: match[2] ?? "00:00" };
return { date: "", time: "" };
};
return (
<div>
{label}
<Controller
name={name}
control={control!}
rules={{ required }}
render={({ field }) => {
const { date, time } = parseDatetime(field.value);
const update = (newDate: string, newTime: string) => {
if (newDate && newTime) field.onChange(`${newDate}T${newTime}:00`);
else if (newDate) field.onChange(`${newDate}T00:00:00`);
else field.onChange("");
};
return (
<div className="flex flex-wrap items-center gap-3">
<Input
type="date"
value={date}
onChange={(e) => update(e.target.value, time)}
onBlur={field.onBlur}
readOnly={readonly}
className="w-auto"
/>
<Input
type="time"
value={time}
onChange={(e) => update(date, e.target.value)}
onBlur={field.onBlur}
readOnly={readonly}
className="w-auto"
/>
</div>
);
}}
/>
{fieldError ? (
<p className="mt-1 text-sm text-red-600">
{String((fieldError as { message?: string })?.message)}
</p>
) : null}
</div>
);
}
const isRefArray =
type === "array" &&
(def.items?.type === "reference" || def.items?.type === "referenceOrInline");
if (isRefArray) {
return (
<Controller
name={name}
control={control!}
rules={{ required }}
render={({ field }) => (
<ReferenceArrayField
name={name}
def={def}
value={Array.isArray(field.value) ? field.value : []}
onChange={field.onChange}
required={required}
error={fieldError}
locale={locale}
label={label}
/>
)}
/>
);
}
if (type === "reference") {
return (
<Controller
name={name}
control={control!}
rules={{ required }}
render={({ field }) => (
<ReferenceField
name={name}
def={def}
value={typeof field.value === "string" ? field.value : ""}
onChange={field.onChange}
required={required}
error={fieldError}
locale={locale}
label={label}
readOnly={readonly}
/>
)}
/>
);
}
if (type === "referenceOrInline") {
return (
<Controller
name={name}
control={control!}
rules={{ required }}
render={({ field }) => (
<ReferenceOrInlineField
name={name}
def={def}
value={
typeof field.value === "string"
? field.value
: field.value != null &&
typeof field.value === "object" &&
!Array.isArray(field.value)
? (field.value as Record<string, unknown>)
: ""
}
onChange={field.onChange}
required={required}
error={fieldError}
locale={locale}
label={label}
readOnly={readonly}
/>
)}
/>
);
}
if (type === "object" && def.fields) {
return (
<ObjectFieldSet
name={name}
def={def}
register={register}
control={control}
errors={errors}
isEdit={isEdit}
locale={locale}
/>
);
}
const additionalProps = def.additionalProperties as FieldDefinition | undefined;
if (type === "object" && additionalProps) {
return (
<Controller
name={name}
control={control!}
rules={{ required }}
render={({ field }) => (
<StringMapField
value={
field.value &&
typeof field.value === "object" &&
!Array.isArray(field.value)
? (field.value as Record<string, string>)
: {}
}
onChange={field.onChange}
label={label}
error={fieldError}
/>
)}
/>
);
}
const enumValues = def.enum as string[] | undefined;
if (Array.isArray(enumValues) && enumValues.length > 0) {
return (
<div>
{label}
<Controller
name={name}
control={control!}
rules={{ required }}
render={({ field }) => (
<Select value={field.value as string} onValueChange={field.onChange}>
<SelectTrigger className="w-full">
<SelectValue placeholder={t("pleaseSelect")} />
</SelectTrigger>
<SelectContent>
{enumValues.map((v) => (
<SelectItem key={String(v)} value={String(v)}>
{String(v)}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
{fieldError ? (
<p className="mt-1 text-sm text-red-600">
{String((fieldError as { message?: string })?.message)}
</p>
) : null}
</div>
);
}
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 === "string" && isImageUrlField(def, name)) {
return (
<Controller
name={name}
control={control!}
rules={{ required }}
render={({ field }) => (
<ImageUrlField
value={safeStringValue(field.value)}
onChange={field.onChange}
label={label}
fieldError={fieldError}
required={required}
readonly={readonly}
/>
)}
/>
);
}
return (
<div>
{label}
<Controller
name={name}
control={control!}
rules={{ required }}
render={({ field }) => (
<Input
type="text"
name={field.name}
value={safeStringValue(field.value)}
onChange={(e) => field.onChange(e.target.value)}
onBlur={field.onBlur}
readOnly={readonly}
/>
)}
/>
{fieldError ? (
<p className="mt-1 text-sm text-red-600">
{String((fieldError as { message?: string })?.message)}
</p>
) : null}
</div>
);
}