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:
Peter Meier
2026-03-13 10:55:33 +01:00
parent 7754d800f5
commit 606455c59b
42 changed files with 3814 additions and 421 deletions

View File

@@ -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}