Enhance documentation and admin UI: Add detailed implementation guidelines in CLAUDE.md, introduce a referrer index in README.md, and update admin UI translations for improved user experience. Update package dependencies for better functionality and performance.
This commit is contained in:
@@ -32,8 +32,13 @@ 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;
|
||||
@@ -100,16 +105,24 @@ function SlugField({
|
||||
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
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, "-")
|
||||
.replace(/[^a-z0-9-]/g, "")
|
||||
: v;
|
||||
setValue("_slug", slugPrefix ? slugPrefix + normalized : v, { shouldValidate: true });
|
||||
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 value = slugPrefix
|
||||
? slugPrefix + (e.target.value ?? "")
|
||||
: (e.target.value ?? "");
|
||||
const full = value.trim();
|
||||
if (!full) return;
|
||||
if (slugPrefix && !full.toLowerCase().startsWith(slugPrefix.toLowerCase())) return;
|
||||
if (slugPrefix && !full.toLowerCase().startsWith(slugPrefix.toLowerCase()))
|
||||
return;
|
||||
try {
|
||||
const res = await checkSlug(collection, full, {
|
||||
exclude: currentSlug ?? undefined,
|
||||
@@ -129,7 +142,9 @@ function SlugField({
|
||||
}
|
||||
};
|
||||
|
||||
const suffixPlaceholder = slugPrefix ? t("slugSuffixPlaceholder") : t("slugPlaceholder");
|
||||
const suffixPlaceholder = slugPrefix
|
||||
? t("slugSuffixPlaceholder")
|
||||
: t("slugPlaceholder");
|
||||
|
||||
if (slugPrefix) {
|
||||
return (
|
||||
@@ -195,7 +210,8 @@ function assetPreviewUrl(value: string | undefined): string | null {
|
||||
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);
|
||||
if (/\.(jpg|jpeg|png|webp|gif|avif|svg)(\?|$)/i.test(v))
|
||||
return getBaseUrl() + (v.startsWith("/") ? v : "/" + v);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -214,7 +230,9 @@ function AssetPickerContent({ onSelect }: { onSelect: (url: string) => void }) {
|
||||
<p className="text-muted-foreground text-sm">{t("loadingAssets")}</p>
|
||||
)}
|
||||
{error && (
|
||||
<p className="text-destructive text-sm">{String((error as Error).message)}</p>
|
||||
<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>
|
||||
@@ -232,9 +250,17 @@ function AssetPickerContent({ onSelect }: { onSelect: (url: string) => void }) {
|
||||
>
|
||||
<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="max-h-full max-w-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
<img src={src} alt="" className="h-full w-full object-cover" />
|
||||
<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">
|
||||
@@ -335,6 +361,10 @@ function buildDefaultValues(
|
||||
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
|
||||
@@ -404,7 +434,10 @@ function buildDefaultValues(
|
||||
!Array.isArray(def.default)
|
||||
) {
|
||||
out[key] = buildDefaultValues(
|
||||
{ name: key, fields: def.fields as Record<string, FieldDefinition> },
|
||||
{
|
||||
name: key,
|
||||
fields: def.fields as Record<string, FieldDefinition>,
|
||||
},
|
||||
def.default as Record<string, unknown>,
|
||||
);
|
||||
} else {
|
||||
@@ -412,10 +445,16 @@ function buildDefaultValues(
|
||||
}
|
||||
} else if (def?.type === "boolean") out[key] = false;
|
||||
else if (def?.type === "array") {
|
||||
const items = (def as FieldDefinition).items;
|
||||
const items = (def as FieldDefinition).items as
|
||||
| FieldDefinition
|
||||
| undefined;
|
||||
const isRefArray =
|
||||
items?.type === "reference" || items?.type === "referenceOrInline";
|
||||
out[key] = isRefArray ? [] : "";
|
||||
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<string, FieldDefinition> },
|
||||
@@ -447,6 +486,10 @@ export function ContentForm({
|
||||
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,
|
||||
@@ -478,7 +521,10 @@ export function ContentForm({
|
||||
payload[key] = Array.isArray(value)
|
||||
? value
|
||||
: typeof value === "string"
|
||||
? value.split(/[\n,]+/).map((s) => s.trim()).filter(Boolean)
|
||||
? value
|
||||
.split(/[\n,]+/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
} else {
|
||||
payload[key] = value;
|
||||
@@ -503,7 +549,9 @@ export function ContentForm({
|
||||
const newSlug = payload._slug as string | undefined;
|
||||
const q = locale ? `?_locale=${locale}` : "";
|
||||
if (newSlug) {
|
||||
router.push(`/content/${collection}/${encodeURIComponent(newSlug)}${q}`);
|
||||
router.push(
|
||||
`/content/${collection}/${encodeURIComponent(newSlug)}${q}`,
|
||||
);
|
||||
} else {
|
||||
router.push(`/content/${collection}${q}`);
|
||||
}
|
||||
@@ -514,7 +562,11 @@ export function ContentForm({
|
||||
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] });
|
||||
if (m)
|
||||
setError(m[1] as Parameters<typeof setError>[0], {
|
||||
type: "server",
|
||||
message: m[2],
|
||||
});
|
||||
}
|
||||
const msg = apiErrors.join(" ");
|
||||
setError("root", { message: msg });
|
||||
@@ -560,7 +612,11 @@ export function ContentForm({
|
||||
let currentEntries: [string, FieldDefinition][] = [];
|
||||
const flush = () => {
|
||||
if (currentEntries.length > 0) {
|
||||
formItems.push({ kind: "section", title: currentTitle ?? "Details", entries: currentEntries });
|
||||
formItems.push({
|
||||
kind: "section",
|
||||
title: currentTitle ?? "Details",
|
||||
entries: currentEntries,
|
||||
});
|
||||
currentEntries = [];
|
||||
}
|
||||
currentTitle = null;
|
||||
@@ -583,9 +639,12 @@ export function ContentForm({
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="max-w-2xl space-y-8">
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="max-w-2xl space-y-6">
|
||||
{errors.root && (
|
||||
<div className="rounded bg-red-50 p-3 text-sm text-red-700" role="alert">
|
||||
<div
|
||||
className="rounded bg-red-50 p-3 text-sm text-red-700"
|
||||
role="alert"
|
||||
>
|
||||
{errors.root.message}
|
||||
</div>
|
||||
)}
|
||||
@@ -609,12 +668,16 @@ export function ContentForm({
|
||||
name="_status"
|
||||
control={control!}
|
||||
render={({ field }) => {
|
||||
const value = (field.value as string) ?? "draft";
|
||||
const value = (field.value as string) ?? "published";
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2" role="group" aria-label={t("status")}>
|
||||
<div
|
||||
className="flex flex-wrap gap-2"
|
||||
role="group"
|
||||
aria-label={t("status")}
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant={value === "draft" ? "default" : "outline"}
|
||||
variant={value === "draft" ? "destructive" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => field.onChange("draft")}
|
||||
className="min-w-[100px]"
|
||||
@@ -626,7 +689,7 @@ export function ContentForm({
|
||||
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" : ""}`}
|
||||
className={`min-w-[100px] ${value === "published" ? "border-2 border-success-400 bg-success-200 text-success-800 hover:bg-success-300 hover:border-success-500 hover:text-success-900" : ""}`}
|
||||
>
|
||||
{t("statusPublished")}
|
||||
</Button>
|
||||
@@ -634,10 +697,12 @@ export function ContentForm({
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">{t("statusHint")}</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{t("statusHint")}
|
||||
</p>
|
||||
</div>
|
||||
{hasSection
|
||||
? formItems.map((item) =>
|
||||
? formItems.map((item, sectionIndex) =>
|
||||
item.kind === "object" ? (
|
||||
<ObjectFieldSet
|
||||
key={item.name}
|
||||
@@ -651,28 +716,30 @@ export function ContentForm({
|
||||
/>
|
||||
) : (
|
||||
<CollapsibleSection
|
||||
key={item.title}
|
||||
key={`section-${sectionIndex}-${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}
|
||||
/>
|
||||
<div key={name} className={FIELD_BLOCK_CLASS}>
|
||||
<Field
|
||||
name={name}
|
||||
def={def}
|
||||
register={register}
|
||||
control={control}
|
||||
errors={errors}
|
||||
isEdit={isEdit}
|
||||
locale={locale}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</CollapsibleSection>
|
||||
),
|
||||
)
|
||||
: Object.entries(fields).map(([name, def]) =>
|
||||
(def as FieldDefinition).type === "object" && (def as FieldDefinition).fields ? (
|
||||
(def as FieldDefinition).type === "object" &&
|
||||
(def as FieldDefinition).fields ? (
|
||||
<ObjectFieldSet
|
||||
key={name}
|
||||
name={name}
|
||||
@@ -684,27 +751,30 @@ export function ContentForm({
|
||||
locale={locale}
|
||||
/>
|
||||
) : (
|
||||
<Field
|
||||
key={name}
|
||||
name={name}
|
||||
def={def as FieldDefinition}
|
||||
register={register}
|
||||
control={control}
|
||||
errors={errors}
|
||||
isEdit={isEdit}
|
||||
locale={locale}
|
||||
/>
|
||||
<div key={name} className={FIELD_BLOCK_CLASS}>
|
||||
<Field
|
||||
name={name}
|
||||
def={def as FieldDefinition}
|
||||
register={register}
|
||||
control={control}
|
||||
errors={errors}
|
||||
isEdit={isEdit}
|
||||
locale={locale}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
<div className="flex flex-wrap gap-3 pt-6">
|
||||
<Button type="submit" disabled={isSubmitting} className="min-h-[44px] sm:min-h-0">
|
||||
<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>
|
||||
<a href={`/content/${collection}${slugParam}`}>{t("backToList")}</a>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -745,18 +815,23 @@ function ObjectFieldSet({
|
||||
const displayName = name.split(".").pop() ?? name;
|
||||
const title = (def.description as string) || displayName;
|
||||
return (
|
||||
<CollapsibleSection title={title} defaultOpen={true} contentClassName="space-y-4">
|
||||
<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}
|
||||
/>
|
||||
<div key={subName} className={FIELD_BLOCK_CLASS}>
|
||||
<Field
|
||||
name={`${name}.${subName}`}
|
||||
def={subDef as FieldDefinition}
|
||||
register={register}
|
||||
control={control}
|
||||
errors={errors}
|
||||
isEdit={isEdit}
|
||||
locale={locale}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</CollapsibleSection>
|
||||
);
|
||||
@@ -804,7 +879,9 @@ function StringMapField({
|
||||
return (
|
||||
<div>
|
||||
{label}
|
||||
{description ? <p className="mt-0.5 text-xs text-gray-500">{description}</p> : null}
|
||||
{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">
|
||||
@@ -815,7 +892,11 @@ function StringMapField({
|
||||
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>
|
||||
<Icon
|
||||
icon="mdi:arrow-right"
|
||||
className="size-4 shrink-0 text-gray-400"
|
||||
aria-hidden
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={v}
|
||||
@@ -851,6 +932,104 @@ function StringMapField({
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<div>
|
||||
{label}
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">{t("arrayHint")}</p>
|
||||
<div className="mt-2 space-y-2 rounded-md border border-input bg-muted/20 p-3">
|
||||
{list.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t("arrayAddItem")} ↓</p>
|
||||
) : null}
|
||||
{list.map((item, i) => (
|
||||
<div key={i} className="flex flex-wrap items-center gap-2">
|
||||
{isNumber ? (
|
||||
<Input
|
||||
type="number"
|
||||
step={itemType === "integer" ? 1 : "any"}
|
||||
value={item}
|
||||
onChange={(e) => setItem(i, e.target.valueAsNumber ?? 0)}
|
||||
placeholder={placeholder}
|
||||
className="min-w-[120px] flex-1"
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
type="text"
|
||||
value={item}
|
||||
onChange={(e) => setItem(i, e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="min-w-[200px] flex-1"
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
onClick={() => remove(i)}
|
||||
title={t("arrayRemoveItem")}
|
||||
aria-label={t("arrayRemoveItem")}
|
||||
>
|
||||
<Icon icon="mdi:delete-outline" className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button type="button" variant="outline" size="sm" onClick={add}>
|
||||
<Icon icon="mdi:plus" className="size-4" />
|
||||
{t("arrayAddItem")}
|
||||
</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,
|
||||
@@ -890,6 +1069,14 @@ function Field({
|
||||
</>
|
||||
);
|
||||
|
||||
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 (
|
||||
<div>
|
||||
@@ -906,7 +1093,10 @@ function Field({
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<label htmlFor={fieldId} className="cursor-pointer text-sm font-medium text-gray-700">
|
||||
<label
|
||||
htmlFor={fieldId}
|
||||
className="cursor-pointer text-sm font-medium text-gray-700"
|
||||
>
|
||||
{name.split(".").pop()}
|
||||
{required && <span className="text-red-500"> *</span>}
|
||||
</label>
|
||||
@@ -973,7 +1163,30 @@ function Field({
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "richtext" || type === "html") {
|
||||
if (type === "html") {
|
||||
return (
|
||||
<Controller
|
||||
name={name}
|
||||
control={control!}
|
||||
rules={{ required }}
|
||||
render={({ field }) => (
|
||||
<CodeField
|
||||
name={field.name}
|
||||
value={safeStringValue(field.value)}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
language="html"
|
||||
label={label}
|
||||
readonly={readonly}
|
||||
fieldError={fieldError}
|
||||
copyLabel={t("copyCode")}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "richtext") {
|
||||
return (
|
||||
<div>
|
||||
{label}
|
||||
@@ -1011,7 +1224,8 @@ function Field({
|
||||
render={({ field }) => {
|
||||
const { date, time } = parseDatetime(field.value);
|
||||
const update = (newDate: string, newTime: string) => {
|
||||
if (newDate && newTime) field.onChange(`${newDate}T${newTime}:00`);
|
||||
if (newDate && newTime)
|
||||
field.onChange(`${newDate}T${newTime}:00`);
|
||||
else if (newDate) field.onChange(`${newDate}T00:00:00`);
|
||||
else field.onChange("");
|
||||
};
|
||||
@@ -1048,7 +1262,8 @@ function Field({
|
||||
|
||||
const isRefArray =
|
||||
type === "array" &&
|
||||
(def.items?.type === "reference" || def.items?.type === "referenceOrInline");
|
||||
(def.items?.type === "reference" ||
|
||||
def.items?.type === "referenceOrInline");
|
||||
if (isRefArray) {
|
||||
return (
|
||||
<Controller
|
||||
@@ -1071,6 +1286,36 @@ function Field({
|
||||
);
|
||||
}
|
||||
|
||||
const itemsType =
|
||||
def.items && typeof def.items === "object"
|
||||
? (def.items as FieldDefinition).type
|
||||
: undefined;
|
||||
const isPrimitiveArray =
|
||||
type === "array" &&
|
||||
(itemsType === "string" ||
|
||||
itemsType === "number" ||
|
||||
itemsType === "integer");
|
||||
if (isPrimitiveArray) {
|
||||
return (
|
||||
<Controller
|
||||
name={name}
|
||||
control={control!}
|
||||
rules={{ required }}
|
||||
render={({ field }) => (
|
||||
<PrimitiveArrayField
|
||||
name={name}
|
||||
def={def}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
required={required}
|
||||
error={fieldError}
|
||||
label={label}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "reference") {
|
||||
return (
|
||||
<Controller
|
||||
@@ -1139,7 +1384,9 @@ function Field({
|
||||
);
|
||||
}
|
||||
|
||||
const additionalProps = def.additionalProperties as FieldDefinition | undefined;
|
||||
const additionalProps = def.additionalProperties as
|
||||
| FieldDefinition
|
||||
| undefined;
|
||||
if (type === "object" && additionalProps) {
|
||||
return (
|
||||
<Controller
|
||||
@@ -1165,6 +1412,47 @@ function Field({
|
||||
}
|
||||
|
||||
const enumValues = def.enum as string[] | undefined;
|
||||
|
||||
if (type === "multiSelect" && Array.isArray(enumValues) && enumValues.length > 0) {
|
||||
return (
|
||||
<div>
|
||||
{label}
|
||||
<Controller
|
||||
name={name}
|
||||
control={control!}
|
||||
rules={{ required }}
|
||||
render={({ field }) => {
|
||||
const selected = Array.isArray(field.value) ? (field.value as string[]) : [];
|
||||
const toggle = (opt: string) => {
|
||||
const next = selected.includes(opt)
|
||||
? selected.filter((s) => s !== opt)
|
||||
: [...selected, opt];
|
||||
field.onChange(next);
|
||||
};
|
||||
return (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{enumValues.map((opt) => (
|
||||
<label key={opt} className="flex cursor-pointer items-center gap-2">
|
||||
<Checkbox
|
||||
checked={selected.includes(opt)}
|
||||
onCheckedChange={() => toggle(opt)}
|
||||
/>
|
||||
<span className="text-sm">{String(opt)}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
{fieldError ? (
|
||||
<p className="mt-1 text-sm text-red-600">
|
||||
{String((fieldError as { message?: string })?.message)}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (Array.isArray(enumValues) && enumValues.length > 0) {
|
||||
return (
|
||||
<div>
|
||||
@@ -1174,7 +1462,10 @@ function Field({
|
||||
control={control!}
|
||||
rules={{ required }}
|
||||
render={({ field }) => (
|
||||
<Select value={field.value as string} onValueChange={field.onChange}>
|
||||
<Select
|
||||
value={field.value as string}
|
||||
onValueChange={field.onChange}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder={t("pleaseSelect")} />
|
||||
</SelectTrigger>
|
||||
@@ -1197,14 +1488,6 @@ function Field({
|
||||
);
|
||||
}
|
||||
|
||||
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
|
||||
@@ -1225,6 +1508,59 @@ function Field({
|
||||
);
|
||||
}
|
||||
|
||||
const codeLanguage = def.codeLanguage;
|
||||
if (type === "string" && def.widget === "code" && isCodeFieldLanguage(codeLanguage)) {
|
||||
return (
|
||||
<Controller
|
||||
name={name}
|
||||
control={control!}
|
||||
rules={{ required }}
|
||||
render={({ field }) => (
|
||||
<CodeField
|
||||
name={field.name}
|
||||
value={safeStringValue(field.value)}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
language={codeLanguage}
|
||||
label={label}
|
||||
readonly={readonly}
|
||||
fieldError={fieldError}
|
||||
copyLabel={t("copyCode")}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "string" && def.widget === "textarea") {
|
||||
return (
|
||||
<div>
|
||||
{label}
|
||||
<Controller
|
||||
name={name}
|
||||
control={control!}
|
||||
rules={{ required }}
|
||||
render={({ field }) => (
|
||||
<Textarea
|
||||
name={field.name}
|
||||
value={safeStringValue(field.value)}
|
||||
onChange={(e) => field.onChange(e.target.value)}
|
||||
onBlur={field.onBlur}
|
||||
readOnly={readonly}
|
||||
rows={5}
|
||||
className="min-h-[100px] w-full"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{fieldError ? (
|
||||
<p className="mt-1 text-sm text-red-600">
|
||||
{String((fieldError as { message?: string })?.message)}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{label}
|
||||
|
||||
Reference in New Issue
Block a user