1254 lines
38 KiB
TypeScript
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>
|
|
);
|
|
}
|