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

@@ -0,0 +1,190 @@
"use client";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { Textarea } from "@/components/ui/textarea";
/** Minimal field shape for default value editing (same as FieldRow from type editor). */
export type DefaultValueFieldRow = {
id: string;
type: string;
defaultValue: string;
enumOptions: string;
widget: string;
itemType: string;
};
/** Default value input: same UI as on content detail page (Input, Checkbox, Textarea). */
export function DefaultValueField({
field,
updateField,
t,
}: {
field: DefaultValueFieldRow;
updateField: (id: string, patch: Partial<DefaultValueFieldRow>) => void;
t: (key: string) => string;
}) {
const id = field.id;
const raw = field.defaultValue.trim();
let parsed: unknown = undefined;
if (raw) {
try {
parsed = JSON.parse(raw);
} catch {
// leave parsed undefined, show raw in fallback
}
}
const setDefault = (value: unknown) => {
if (value === undefined || value === null || (typeof value === "string" && value === "")) {
updateField(id, { defaultValue: "" });
return;
}
updateField(id, { defaultValue: JSON.stringify(value) });
};
const type = field.type;
if (type === "boolean") {
const checked = parsed === true;
return (
<div>
<Label className="mb-1 block text-xs font-medium text-gray-600">{t("defaultValueLabel")}</Label>
<label className="flex cursor-pointer items-center gap-2">
<Checkbox
checked={checked}
onCheckedChange={(c) => setDefault(!!c)}
/>
<span className="text-sm">{t("defaultValueBoolean")}</span>
</label>
</div>
);
}
if (type === "number" || type === "integer") {
const num = typeof parsed === "number" && Number.isFinite(parsed) ? parsed : "";
return (
<div>
<Label className="mb-1 block text-xs font-medium text-gray-600">{t("defaultValueLabel")}</Label>
<Input
type="number"
step={type === "integer" ? 1 : "any"}
value={num === "" ? "" : num}
onChange={(e) => {
const v = e.target.value;
if (v === "") setDefault(undefined);
else setDefault(type === "integer" ? parseInt(v, 10) : parseFloat(v));
}}
placeholder={t("defaultValueEmpty")}
className="h-9 text-sm"
/>
</div>
);
}
if (type === "multiSelect") {
const opts = field.enumOptions
? field.enumOptions.split(/[\n,]+/).map((s) => s.trim()).filter(Boolean)
: [];
const selected: string[] = Array.isArray(parsed) ? (parsed as string[]) : [];
const toggle = (opt: string) => {
const next = selected.includes(opt) ? selected.filter((s) => s !== opt) : [...selected, opt];
setDefault(next);
};
return (
<div>
<Label className="mb-1 block text-xs font-medium text-gray-600">{t("defaultValueLabel")}</Label>
{opts.length > 0 ? (
<div className="flex flex-wrap gap-3">
{opts.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">{opt}</span>
</label>
))}
</div>
) : (
<p className="text-xs text-gray-500">{t("defaultValueMultiSelectSetOptions")}</p>
)}
</div>
);
}
if (
type === "string" ||
type === "richtext" ||
type === "html" ||
type === "markdown" ||
type === "textOrRef" ||
type === "datetime" ||
type === "reference" ||
type === "referenceOrInline"
) {
const str = typeof parsed === "string" ? parsed : raw ? String(parsed ?? raw) : "";
const isTextarea = type === "string" && field.widget === "textarea";
return (
<div>
<Label className="mb-1 block text-xs font-medium text-gray-600">{t("defaultValueLabel")}</Label>
{isTextarea ? (
<Textarea
value={str}
onChange={(e) => setDefault(e.target.value || undefined)}
placeholder={t("defaultValueEmpty")}
rows={3}
className="min-h-[60px] w-full text-sm"
/>
) : (
<Input
type="text"
value={str}
onChange={(e) => setDefault(e.target.value || undefined)}
placeholder={t("defaultValueEmpty")}
className="h-9 text-sm"
/>
)}
</div>
);
}
if (type === "array") {
const itemType = field.itemType || "string";
if (itemType === "string") {
const arr = Array.isArray(parsed) ? (parsed as string[]) : [];
const str = arr.join(", ");
return (
<div>
<Label className="mb-1 block text-xs font-medium text-gray-600">{t("defaultValueLabel")}</Label>
<Input
type="text"
value={str}
onChange={(e) => {
const s = e.target.value;
if (!s.trim()) setDefault(undefined);
else setDefault(s.split(",").map((x) => x.trim()).filter(Boolean));
}}
placeholder={t("defaultValueArrayPlaceholder")}
className="h-9 text-sm"
/>
</div>
);
}
}
return (
<div>
<Label className="mb-1 block text-xs font-medium text-gray-600">{t("defaultValueLabel")}</Label>
<Input
type="text"
value={raw}
onChange={(e) => updateField(id, { defaultValue: e.target.value })}
placeholder={t("defaultValuePlaceholder")}
className="h-9 font-mono text-sm"
/>
<p className="mt-0.5 text-xs text-gray-500">{t("defaultValueHelp")}</p>
</div>
);
}

View File

@@ -18,19 +18,35 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { DefaultValueField } from "@/app/admin/DefaultValueField";
const FIELD_TYPES = [
"string", "number", "integer", "boolean", "datetime",
"richtext", "html", "markdown", "reference", "array", "object",
"multiSelect",
] as const;
const OBJECT_SUBFIELD_TYPES = ["string", "number", "integer", "boolean"] as const;
type ItemSubFieldRow = { id: string; name: string; type: string };
type FieldRow = {
id: string;
name: string;
type: string;
required: boolean;
description: string;
defaultValue: string;
collection: string;
allowedSlugs: string;
allowedCollections: string;
enumOptions: string;
widget: string;
itemType: string;
itemCollection: string;
itemAllowedSlugs: string;
itemAllowedCollections: string;
itemSubFields: ItemSubFieldRow[];
};
function nextId() {
@@ -46,8 +62,9 @@ export default function NewTypePage() {
const [category, setCategory] = useState("");
const [tagsStr, setTagsStr] = useState("");
const [strict, setStrict] = useState(false);
const emptySubFields = (): ItemSubFieldRow[] => [{ id: nextId(), name: "", type: "string" }];
const [fields, setFields] = useState<FieldRow[]>([
{ id: nextId(), name: "title", type: "string", required: true, description: "", collection: "" },
{ id: nextId(), name: "title", type: "string", required: true, description: "", defaultValue: "", collection: "", allowedSlugs: "", allowedCollections: "", enumOptions: "", widget: "", itemType: "", itemCollection: "", itemAllowedSlugs: "", itemAllowedCollections: "", itemSubFields: emptySubFields() },
]);
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
@@ -55,7 +72,7 @@ export default function NewTypePage() {
const addField = () => {
setFields((prev) => [
...prev,
{ id: nextId(), name: "", type: "string", required: false, description: "", collection: "" },
{ id: nextId(), name: "", type: "string", required: false, description: "", defaultValue: "", collection: "", allowedSlugs: "", allowedCollections: "", enumOptions: "", widget: "", itemType: "", itemCollection: "", itemAllowedSlugs: "", itemAllowedCollections: "", itemSubFields: emptySubFields() },
]);
};
@@ -81,12 +98,67 @@ export default function NewTypePage() {
for (const row of fields) {
const fn = row.name.trim();
if (!fn) continue;
fieldsObj[fn] = {
const isRef = row.type === "reference" || row.type === "referenceOrInline";
const allowedSlugs = isRef && row.allowedSlugs.trim()
? row.allowedSlugs.split(",").map((s) => s.trim()).filter(Boolean)
: undefined;
const allowedCollections = isRef && row.allowedCollections.trim()
? row.allowedCollections.split(",").map((s) => s.trim()).filter(Boolean)
: undefined;
const base: FieldDefinition = {
type: row.type,
required: row.required,
description: row.description.trim() || undefined,
collection: row.type === "reference" && row.collection.trim() ? row.collection.trim() : undefined,
...(allowedSlugs?.length ? { allowedSlugs } : {}),
...(allowedCollections?.length ? { allowedCollections } : {}),
};
if (row.type === "multiSelect" && row.enumOptions.trim()) {
const opts = row.enumOptions
.split(/[\n,]+/)
.map((s) => s.trim())
.filter(Boolean);
if (opts.length) base.enum = opts;
}
if (row.type === "string" && row.widget.trim() === "textarea") {
base.widget = "textarea";
}
if (row.defaultValue.trim()) {
try {
base.default = JSON.parse(row.defaultValue) as unknown;
} catch {
setError(t("defaultValueInvalid", { field: fn }));
return;
}
}
if (row.type === "array") {
if (row.itemType === "reference" && row.itemCollection.trim()) {
const itemAllowedSlugs = row.itemAllowedSlugs.trim()
? row.itemAllowedSlugs.split(",").map((s) => s.trim()).filter(Boolean)
: undefined;
const itemAllowedCollections = row.itemAllowedCollections.trim()
? row.itemAllowedCollections.split(",").map((s) => s.trim()).filter(Boolean)
: undefined;
base.items = {
type: "reference",
collection: row.itemCollection.trim(),
...(itemAllowedSlugs?.length ? { allowedSlugs: itemAllowedSlugs } : {}),
...(itemAllowedCollections?.length ? { allowedCollections: itemAllowedCollections } : {}),
};
} else if (row.itemType === "number") {
base.items = { type: "number" };
} else if (row.itemType === "object") {
const sub: Record<string, FieldDefinition> = {};
for (const sf of row.itemSubFields) {
const n = sf.name.trim();
if (n) sub[n] = { type: sf.type };
}
base.items = Object.keys(sub).length ? { type: "object", fields: sub } : { type: "object", fields: { _: { type: "string" } } };
} else {
base.items = { type: "string" };
}
}
fieldsObj[fn] = base;
}
const schema: SchemaDefinition = {
@@ -190,60 +262,239 @@ export default function NewTypePage() {
{fields.map((f) => (
<div
key={f.id}
className="grid gap-2 rounded border border-gray-200 bg-white p-3 sm:grid-cols-[1fr_1fr_auto_auto]"
className="flex flex-col gap-3 rounded border border-gray-200 bg-white p-4"
>
<Input
type="text"
value={f.name}
onChange={(e) => updateField(f.id, { name: e.target.value })}
placeholder={t("fieldNamePlaceholder")}
className="h-8 text-sm"
/>
<Select
value={f.type}
onValueChange={(v) => updateField(f.id, { type: v })}
>
<SelectTrigger className="h-8 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{FIELD_TYPES.map((type) => (
<SelectItem key={type} value={type}>{type}</SelectItem>
))}
</SelectContent>
</Select>
<label className="flex items-center gap-1 text-sm text-foreground">
<Checkbox
checked={f.required}
onCheckedChange={(checked) => updateField(f.id, { required: !!checked })}
<div>
<Label className="mb-1 block text-xs font-medium text-gray-600">{t("fieldNamePlaceholder")}</Label>
<Input
type="text"
value={f.name}
onChange={(e) => updateField(f.id, { name: e.target.value })}
placeholder={t("fieldNamePlaceholder")}
className="h-9 text-sm"
/>
{t("required")}
</label>
<button
type="button"
onClick={() => removeField(f.id)}
title={t("removeField")}
aria-label={t("removeField")}
className="rounded p-1.5 text-red-600 hover:bg-red-50"
>
<Icon icon="mdi:delete-outline" className="size-5" aria-hidden />
</button>
</div>
<div>
<Label className="mb-1 block text-xs font-medium text-gray-600">{t("fieldTypeLabel")}</Label>
<Select value={f.type} onValueChange={(v) => updateField(f.id, { type: v })}>
<SelectTrigger className="h-9 w-full text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{FIELD_TYPES.map((type) => (
<SelectItem key={type} value={type}>{type}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="mb-1 block text-xs font-medium text-gray-600">{t("fieldDescriptionPlaceholder")}</Label>
<Input
type="text"
value={f.description}
onChange={(e) => updateField(f.id, { description: e.target.value })}
placeholder={t("fieldDescriptionPlaceholder")}
className="h-9 text-sm"
/>
</div>
<DefaultValueField field={f} updateField={updateField} t={t} />
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-gray-200 pt-3">
<label className="flex items-center gap-2 text-sm text-foreground">
<Checkbox
checked={f.required}
onCheckedChange={(checked) => updateField(f.id, { required: !!checked })}
/>
{t("required")}
</label>
<button
type="button"
onClick={() => removeField(f.id)}
title={t("removeField")}
aria-label={t("removeField")}
className="inline-flex items-center gap-1 rounded-md border border-red-200 bg-red-50 px-2 py-1 text-sm text-red-700 hover:bg-red-100"
>
<Icon icon="mdi:delete-outline" className="size-4" aria-hidden />
{t("removeField")}
</button>
</div>
{f.type === "reference" && (
<Input
type="text"
value={f.collection}
onChange={(e) => updateField(f.id, { collection: e.target.value })}
placeholder={t("collectionPlaceholder")}
className="h-8 text-sm sm:col-span-2"
className="h-9 text-sm"
/>
)}
<Input
type="text"
value={f.description}
onChange={(e) => updateField(f.id, { description: e.target.value })}
placeholder={t("fieldDescriptionPlaceholder")}
className="h-8 text-sm sm:col-span-2"
/>
{(f.type === "reference" || f.type === "referenceOrInline") && (
<div className="flex flex-col gap-2">
<Input
type="text"
value={f.allowedSlugs}
onChange={(e) => updateField(f.id, { allowedSlugs: e.target.value })}
placeholder={t("allowedSlugsPlaceholder")}
className="h-9 text-sm"
/>
<Input
type="text"
value={f.allowedCollections}
onChange={(e) => updateField(f.id, { allowedCollections: e.target.value })}
placeholder={t("allowedCollectionsPlaceholder")}
className="h-9 text-sm"
/>
</div>
)}
{f.type === "multiSelect" && (
<div className="flex flex-col gap-1">
<Label className="text-sm font-medium text-gray-800">{t("multiSelectOptions")}</Label>
<Input
type="text"
value={f.enumOptions}
onChange={(e) => updateField(f.id, { enumOptions: e.target.value })}
placeholder={t("multiSelectOptionsPlaceholder")}
className="h-9 text-sm"
/>
<p className="text-xs text-gray-500">{t("multiSelectOptionsHelp")}</p>
</div>
)}
{f.type === "string" && (
<div>
<Label className="mb-1 block text-sm font-medium text-gray-800">{t("stringWidgetLabel")}</Label>
<Select
value={f.widget || "singleline"}
onValueChange={(v) => updateField(f.id, { widget: v === "textarea" ? "textarea" : "" })}
>
<SelectTrigger className="h-9 w-full text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="singleline">{t("stringWidgetSingleline")}</SelectItem>
<SelectItem value="textarea">{t("stringWidgetTextarea")}</SelectItem>
</SelectContent>
</Select>
</div>
)}
{f.type === "array" && (
<div className="flex flex-col gap-3 rounded-lg border border-violet-200/80 bg-violet-50/50 p-4">
<p className="text-sm leading-relaxed text-gray-800">{t("arrayExplain")}</p>
<div>
<Label className="mb-1 block text-sm font-medium text-gray-800">{t("arrayEachEntry")}</Label>
<Select
value={f.itemType || "string"}
onValueChange={(v) =>
updateField(f.id, {
itemType: v,
itemSubFields: v === "object" && (!f.itemSubFields?.length) ? emptySubFields() : f.itemSubFields,
})
}
>
<SelectTrigger className="h-9 w-full text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="string">{t("itemKindString")}</SelectItem>
<SelectItem value="number">{t("itemKindNumber")}</SelectItem>
<SelectItem value="reference">{t("itemKindReference")}</SelectItem>
<SelectItem value="object">{t("itemKindObject")}</SelectItem>
</SelectContent>
</Select>
</div>
{f.itemType === "reference" && (
<div className="flex flex-col gap-3 border-t border-violet-200/70 pt-4">
<p className="text-sm text-gray-700">{t("arrayReferenceHelp")}</p>
<Input value={f.itemCollection} onChange={(e) => updateField(f.id, { itemCollection: e.target.value })} placeholder={t("collectionPlaceholder")} className="h-9 text-sm" />
<Input value={f.itemAllowedSlugs} onChange={(e) => updateField(f.id, { itemAllowedSlugs: e.target.value })} placeholder={t("allowedSlugsPlaceholder")} className="h-9 text-sm" />
<Input value={f.itemAllowedCollections} onChange={(e) => updateField(f.id, { itemAllowedCollections: e.target.value })} placeholder={t("allowedCollectionsPlaceholder")} className="h-9 text-sm" />
</div>
)}
{f.itemType === "object" && (
<div className="space-y-2 border-t border-violet-200/60 pt-2">
<Label className="text-xs font-medium">{t("objectItemFieldsLabel")}</Label>
{(f.itemSubFields ?? []).map((sf) => (
<div key={sf.id} className="flex flex-wrap items-center gap-2">
<Input
value={sf.name}
onChange={(e) =>
setFields((prev) =>
prev.map((row) =>
row.id !== f.id
? row
: {
...row,
itemSubFields: row.itemSubFields.map((s) =>
s.id === sf.id ? { ...s, name: e.target.value } : s,
),
},
),
)
}
placeholder={t("objectFieldNamePlaceholder")}
className="h-8 min-w-[120px] flex-1 text-sm"
/>
<Select
value={sf.type}
onValueChange={(v) =>
setFields((prev) =>
prev.map((row) =>
row.id !== f.id
? row
: {
...row,
itemSubFields: row.itemSubFields.map((s) =>
s.id === sf.id ? { ...s, type: v } : s,
),
},
),
)
}
>
<SelectTrigger className="h-8 w-32 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{OBJECT_SUBFIELD_TYPES.map((ty) => (
<SelectItem key={ty} value={ty}>{ty}</SelectItem>
))}
</SelectContent>
</Select>
<button
type="button"
className="rounded p-1 text-red-600 hover:bg-red-50"
aria-label={t("removeField")}
onClick={() =>
setFields((prev) =>
prev.map((row) =>
row.id !== f.id
? row
: {
...row,
itemSubFields: row.itemSubFields.filter((s) => s.id !== sf.id),
},
),
)
}
>
<Icon icon="mdi:delete-outline" className="size-4" />
</button>
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
updateField(f.id, {
itemSubFields: [...(f.itemSubFields ?? []), { id: nextId(), name: "", type: "string" }],
})
}
>
<Icon icon="mdi:plus" className="size-4" aria-hidden />
{t("addObjectField")}
</Button>
</div>
)}
</div>
)}
</div>
))}
</div>

View File

@@ -18,20 +18,38 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { DefaultValueField } from "@/app/admin/DefaultValueField";
const FIELD_TYPES = [
"string", "number", "integer", "boolean", "datetime",
"richtext", "html", "markdown", "reference", "array", "object",
"textOrRef", "referenceOrInline",
"textOrRef", "referenceOrInline", "multiSelect",
] as const;
const OBJECT_SUBFIELD_TYPES = ["string", "number", "integer", "boolean"] as const;
type ItemSubFieldRow = { id: string; name: string; type: string };
const CODE_LANGUAGES = ["css", "javascript", "json", "html"] as const;
type FieldRow = {
id: string;
name: string;
type: string;
required: boolean;
description: string;
defaultValue: string;
collection: string;
allowedSlugs: string;
allowedCollections: string;
enumOptions: string;
widget: string;
codeLanguage: string;
itemType: string;
itemCollection: string;
itemAllowedSlugs: string;
itemAllowedCollections: string;
itemSubFields: ItemSubFieldRow[];
original?: FieldDefinition;
};
@@ -41,15 +59,58 @@ function nextId() {
function schemaToFieldRows(schema: SchemaDefinition): FieldRow[] {
const fields = schema.fields ?? {};
return Object.entries(fields).map(([name, def]) => ({
id: nextId(),
name,
type: def.type ?? "string",
required: !!def.required,
description: (def.description as string) ?? "",
collection: (def.collection as string) ?? "",
original: def,
}));
return Object.entries(fields).map(([name, def]) => {
const allowedSlugs = Array.isArray(def.allowedSlugs) ? def.allowedSlugs.join(", ") : "";
const allowedCollections = Array.isArray(def.allowedCollections) ? def.allowedCollections.join(", ") : "";
const enumOpt = def.enum;
const enumOptions = Array.isArray(enumOpt)
? (enumOpt as unknown[]).map((v) => String(v)).join(", ")
: "";
const widget = (def.widget as string) ?? "";
const codeLanguage = (def.codeLanguage as string) ?? "";
const defaultVal = def.default;
const defaultValue =
defaultVal !== undefined && defaultVal !== null
? JSON.stringify(defaultVal)
: "";
const items = def.items as FieldDefinition | undefined;
const it = items?.type;
const itemType =
it === "reference" ? "reference" : it === "number" ? "number" : it === "object" ? "object" : it === "string" ? "string" : "";
const itemCollection = (items?.collection as string) ?? "";
const itemAllowedSlugs = Array.isArray(items?.allowedSlugs) ? (items.allowedSlugs as string[]).join(", ") : "";
const itemAllowedCollections = Array.isArray(items?.allowedCollections) ? (items.allowedCollections as string[]).join(", ") : "";
let itemSubFields: ItemSubFieldRow[] = [{ id: nextId(), name: "", type: "string" }];
if (it === "object" && items?.fields && typeof items.fields === "object") {
const f = items.fields as Record<string, FieldDefinition>;
itemSubFields = Object.entries(f).map(([fn, fd]) => ({
id: nextId(),
name: fn,
type: (fd?.type as string) ?? "string",
}));
if (itemSubFields.length === 0) itemSubFields = [{ id: nextId(), name: "", type: "string" }];
}
return {
id: nextId(),
name,
type: def.type ?? "string",
required: !!def.required,
description: (def.description as string) ?? "",
defaultValue,
collection: (def.collection as string) ?? "",
allowedSlugs,
allowedCollections,
enumOptions,
widget,
codeLanguage,
itemType,
itemCollection,
itemAllowedSlugs,
itemAllowedCollections,
itemSubFields,
original: def,
};
});
}
export default function EditTypePage() {
@@ -86,7 +147,7 @@ export default function EditTypePage() {
const addField = () => {
setFields((prev) => [
...prev,
{ id: nextId(), name: "", type: "string", required: false, description: "", collection: "" },
{ id: nextId(), name: "", type: "string", required: false, description: "", defaultValue: "", collection: "", allowedSlugs: "", allowedCollections: "", enumOptions: "", widget: "", codeLanguage: "", itemType: "", itemCollection: "", itemAllowedSlugs: "", itemAllowedCollections: "", itemSubFields: [{ id: nextId(), name: "", type: "string" }] },
]);
};
@@ -110,13 +171,87 @@ export default function EditTypePage() {
const fn = row.name.trim();
if (!fn) continue;
const base = row.original ?? {};
fieldsObj[fn] = {
const isRef = row.type === "reference" || row.type === "referenceOrInline";
const allowedSlugs = isRef && row.allowedSlugs.trim()
? row.allowedSlugs.split(",").map((s) => s.trim()).filter(Boolean)
: undefined;
const allowedCollections = isRef && row.allowedCollections.trim()
? row.allowedCollections.split(",").map((s) => s.trim()).filter(Boolean)
: undefined;
const field: FieldDefinition = {
...base,
type: row.type,
required: row.required,
description: row.description.trim() || undefined,
collection: row.type === "reference" && row.collection.trim() ? row.collection.trim() : undefined,
};
if (isRef) {
if (allowedSlugs?.length) field.allowedSlugs = allowedSlugs;
else delete field.allowedSlugs;
if (allowedCollections?.length) field.allowedCollections = allowedCollections;
else delete field.allowedCollections;
}
if (row.type === "multiSelect" && row.enumOptions.trim()) {
const opts = row.enumOptions
.split(/[\n,]+/)
.map((s) => s.trim())
.filter(Boolean);
if (opts.length) field.enum = opts;
}
if (row.type === "string") {
if (row.widget.trim() === "textarea") {
field.widget = "textarea";
delete field.codeLanguage;
} else if (row.widget.trim() === "code") {
field.widget = "code";
const lang = row.codeLanguage?.trim();
field.codeLanguage = CODE_LANGUAGES.includes(lang as (typeof CODE_LANGUAGES)[number]) ? lang : "javascript";
} else {
delete field.widget;
delete field.codeLanguage;
}
}
if (row.defaultValue.trim()) {
try {
field.default = JSON.parse(row.defaultValue) as unknown;
} catch {
setError(t("defaultValueInvalid", { field: fn }));
return;
}
} else {
delete field.default;
}
if (row.type === "array") {
if (row.itemType === "reference" && row.itemCollection.trim()) {
const itemAllowedSlugs = row.itemAllowedSlugs.trim()
? row.itemAllowedSlugs.split(",").map((s) => s.trim()).filter(Boolean)
: undefined;
const itemAllowedCollections = row.itemAllowedCollections.trim()
? row.itemAllowedCollections.split(",").map((s) => s.trim()).filter(Boolean)
: undefined;
field.items = {
type: "reference",
collection: row.itemCollection.trim(),
...(itemAllowedSlugs?.length ? { allowedSlugs: itemAllowedSlugs } : {}),
...(itemAllowedCollections?.length ? { allowedCollections: itemAllowedCollections } : {}),
};
} else if (row.itemType === "number") {
field.items = { type: "number" };
} else if (row.itemType === "object") {
const sub: Record<string, FieldDefinition> = {};
for (const sf of row.itemSubFields ?? []) {
const n = sf.name.trim();
if (n) sub[n] = { type: sf.type };
}
field.items =
Object.keys(sub).length > 0
? { type: "object", fields: sub }
: (row.original?.items as FieldDefinition) ?? { type: "object", fields: { _: { type: "string" } } };
} else {
field.items = { type: "string" };
}
}
fieldsObj[fn] = field;
}
const payload: SchemaDefinition = {
@@ -230,60 +365,269 @@ export default function EditTypePage() {
{fields.map((f) => (
<div
key={f.id}
className="grid gap-2 rounded border border-gray-200 bg-white p-3 sm:grid-cols-[1fr_1fr_auto_auto]"
className="flex flex-col gap-3 rounded border border-gray-200 bg-white p-4"
>
<Input
type="text"
value={f.name}
onChange={(e) => updateField(f.id, { name: e.target.value })}
placeholder={t("fieldNamePlaceholder")}
className="h-8 text-sm"
/>
<Select
value={f.type}
onValueChange={(v) => updateField(f.id, { type: v })}
>
<SelectTrigger className="h-8 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{FIELD_TYPES.map((type) => (
<SelectItem key={type} value={type}>{type}</SelectItem>
))}
</SelectContent>
</Select>
<label className="flex items-center gap-1 text-sm text-foreground">
<Checkbox
checked={f.required}
onCheckedChange={(checked) => updateField(f.id, { required: !!checked })}
<div>
<Label className="mb-1 block text-xs font-medium text-gray-600">{t("fieldNamePlaceholder")}</Label>
<Input
type="text"
value={f.name}
onChange={(e) => updateField(f.id, { name: e.target.value })}
placeholder={t("fieldNamePlaceholder")}
className="h-9 text-sm"
/>
{t("required")}
</label>
<button
type="button"
onClick={() => removeField(f.id)}
title={t("removeField")}
aria-label={t("removeField")}
className="rounded p-1.5 text-red-600 hover:bg-red-50"
>
<Icon icon="mdi:delete-outline" className="size-5" aria-hidden />
</button>
</div>
<div>
<Label className="mb-1 block text-xs font-medium text-gray-600">{t("fieldTypeLabel")}</Label>
<Select value={f.type} onValueChange={(v) => updateField(f.id, { type: v })}>
<SelectTrigger className="h-9 w-full text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{FIELD_TYPES.map((type) => (
<SelectItem key={type} value={type}>{type}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="mb-1 block text-xs font-medium text-gray-600">{t("fieldDescriptionPlaceholder")}</Label>
<Input
type="text"
value={f.description}
onChange={(e) => updateField(f.id, { description: e.target.value })}
placeholder={t("fieldDescriptionPlaceholder")}
className="h-9 text-sm"
/>
</div>
<DefaultValueField field={f} updateField={updateField} t={t} />
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-gray-200 pt-3">
<label className="flex items-center gap-2 text-sm text-foreground">
<Checkbox
checked={f.required}
onCheckedChange={(checked) => updateField(f.id, { required: !!checked })}
/>
{t("required")}
</label>
<button
type="button"
onClick={() => removeField(f.id)}
title={t("removeField")}
aria-label={t("removeField")}
className="inline-flex items-center gap-1 rounded-md border border-red-200 bg-red-50 px-2 py-1 text-sm text-red-700 hover:bg-red-100"
>
<Icon icon="mdi:delete-outline" className="size-4" aria-hidden />
{t("removeField")}
</button>
</div>
{f.type === "reference" && (
<Input
type="text"
value={f.collection}
onChange={(e) => updateField(f.id, { collection: e.target.value })}
placeholder={t("collectionPlaceholder")}
className="h-8 text-sm sm:col-span-2"
className="h-9 text-sm"
/>
)}
<Input
type="text"
value={f.description}
onChange={(e) => updateField(f.id, { description: e.target.value })}
placeholder={t("fieldDescriptionPlaceholder")}
className="h-8 text-sm sm:col-span-2"
/>
{(f.type === "reference" || f.type === "referenceOrInline") && (
<div className="flex flex-col gap-2">
<Input
type="text"
value={f.allowedSlugs}
onChange={(e) => updateField(f.id, { allowedSlugs: e.target.value })}
placeholder={t("allowedSlugsPlaceholder")}
className="h-9 text-sm"
/>
<Input
type="text"
value={f.allowedCollections}
onChange={(e) => updateField(f.id, { allowedCollections: e.target.value })}
placeholder={t("allowedCollectionsPlaceholder")}
className="h-9 text-sm"
/>
</div>
)}
{f.type === "multiSelect" && (
<div className="flex flex-col gap-1">
<Label className="text-sm font-medium text-gray-800">{t("multiSelectOptions")}</Label>
<Input
type="text"
value={f.enumOptions}
onChange={(e) => updateField(f.id, { enumOptions: e.target.value })}
placeholder={t("multiSelectOptionsPlaceholder")}
className="h-9 text-sm"
/>
<p className="text-xs text-gray-500">{t("multiSelectOptionsHelp")}</p>
</div>
)}
{f.type === "string" && (
<div className="space-y-2">
<div>
<Label className="mb-1 block text-sm font-medium text-gray-800">{t("stringWidgetLabel")}</Label>
<Select
value={f.widget === "code" ? "code" : f.widget === "textarea" ? "textarea" : "singleline"}
onValueChange={(v) =>
updateField(f.id, {
widget: v === "code" ? "code" : v === "textarea" ? "textarea" : "",
codeLanguage: v === "code" && !f.codeLanguage ? "javascript" : v === "code" ? f.codeLanguage : "",
})
}
>
<SelectTrigger className="h-9 w-full text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="singleline">{t("stringWidgetSingleline")}</SelectItem>
<SelectItem value="textarea">{t("stringWidgetTextarea")}</SelectItem>
<SelectItem value="code">{t("stringWidgetCode")}</SelectItem>
</SelectContent>
</Select>
</div>
{f.widget === "code" && (
<div>
<Label className="mb-1 block text-sm font-medium text-gray-800">{t("codeLanguageLabel")}</Label>
<Select
value={CODE_LANGUAGES.includes(f.codeLanguage as (typeof CODE_LANGUAGES)[number]) ? f.codeLanguage : "javascript"}
onValueChange={(v) => updateField(f.id, { codeLanguage: v })}
>
<SelectTrigger className="h-9 w-full text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="css">{t("codeLanguageCss")}</SelectItem>
<SelectItem value="javascript">{t("codeLanguageJavascript")}</SelectItem>
<SelectItem value="json">{t("codeLanguageJson")}</SelectItem>
<SelectItem value="html">{t("codeLanguageHtml")}</SelectItem>
</SelectContent>
</Select>
</div>
)}
</div>
)}
{f.type === "array" && (
<div className="flex flex-col gap-3 rounded-lg border border-violet-200/80 bg-violet-50/50 p-4">
<p className="text-sm leading-relaxed text-gray-800">{t("arrayExplain")}</p>
<div>
<Label className="mb-1 block text-sm font-medium text-gray-800">{t("arrayEachEntry")}</Label>
<Select
value={f.itemType || "string"}
onValueChange={(v) =>
updateField(f.id, {
itemType: v,
itemSubFields:
v === "object" && (!f.itemSubFields?.length)
? [{ id: nextId(), name: "", type: "string" }]
: f.itemSubFields,
})
}
>
<SelectTrigger className="h-9 w-full text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="string">{t("itemKindString")}</SelectItem>
<SelectItem value="number">{t("itemKindNumber")}</SelectItem>
<SelectItem value="reference">{t("itemKindReference")}</SelectItem>
<SelectItem value="object">{t("itemKindObject")}</SelectItem>
</SelectContent>
</Select>
</div>
{f.itemType === "reference" && (
<div className="flex flex-col gap-3 border-t border-violet-200/70 pt-4">
<p className="text-sm text-gray-700">{t("arrayReferenceHelp")}</p>
<Input value={f.itemCollection} onChange={(e) => updateField(f.id, { itemCollection: e.target.value })} placeholder={t("collectionPlaceholder")} className="h-9 text-sm" />
<Input value={f.itemAllowedSlugs} onChange={(e) => updateField(f.id, { itemAllowedSlugs: e.target.value })} placeholder={t("allowedSlugsPlaceholder")} className="h-9 text-sm" />
<Input value={f.itemAllowedCollections} onChange={(e) => updateField(f.id, { itemAllowedCollections: e.target.value })} placeholder={t("allowedCollectionsPlaceholder")} className="h-9 text-sm" />
</div>
)}
{f.itemType === "object" && (
<div className="space-y-2 border-t border-violet-200/60 pt-2">
<Label className="text-xs font-medium">{t("objectItemFieldsLabel")}</Label>
{(f.itemSubFields ?? []).map((sf) => (
<div key={sf.id} className="flex flex-wrap items-center gap-2">
<Input
value={sf.name}
onChange={(e) =>
setFields((prev) =>
prev.map((row) =>
row.id !== f.id
? row
: {
...row,
itemSubFields: row.itemSubFields.map((s) =>
s.id === sf.id ? { ...s, name: e.target.value } : s,
),
},
),
)
}
placeholder={t("objectFieldNamePlaceholder")}
className="h-8 min-w-[120px] flex-1 text-sm"
/>
<Select
value={sf.type}
onValueChange={(v) =>
setFields((prev) =>
prev.map((row) =>
row.id !== f.id
? row
: {
...row,
itemSubFields: row.itemSubFields.map((s) =>
s.id === sf.id ? { ...s, type: v } : s,
),
},
),
)
}
>
<SelectTrigger className="h-8 w-32 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{OBJECT_SUBFIELD_TYPES.map((ty) => (
<SelectItem key={ty} value={ty}>{ty}</SelectItem>
))}
</SelectContent>
</Select>
<button
type="button"
className="rounded p-1 text-red-600 hover:bg-red-50"
aria-label={t("removeField")}
onClick={() =>
setFields((prev) =>
prev.map((row) =>
row.id !== f.id
? row
: {
...row,
itemSubFields: row.itemSubFields.filter((s) => s.id !== sf.id),
},
),
)
}
>
<Icon icon="mdi:delete-outline" className="size-4" />
</button>
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
updateField(f.id, {
itemSubFields: [...(f.itemSubFields ?? []), { id: nextId(), name: "", type: "string" }],
})
}
>
<Icon icon="mdi:plus" className="size-4" aria-hidden />
{t("addObjectField")}
</Button>
</div>
)}
</div>
)}
</div>
))}
</div>

View File

@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useMemo, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useQuery, useQueryClient } from "@tanstack/react-query";
@@ -10,6 +10,7 @@ import { toast } from "sonner";
import { fetchCollections, deleteSchema } from "@/lib/api";
import type { CollectionMeta } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
AlertDialog,
AlertDialogAction,
@@ -43,6 +44,37 @@ export default function TypesPage() {
const types = data?.collections ?? [];
const [search, setSearch] = useState("");
const [selectedTag, setSelectedTag] = useState<string | null>(null);
const allTags = useMemo(() => {
const set = new Set<string>();
for (const c of types) {
for (const tag of c.tags ?? []) {
set.add(tag);
}
}
return Array.from(set).sort();
}, [types]);
const filteredTypes = useMemo(() => {
let list = types;
const q = search.trim().toLowerCase();
if (q) {
list = list.filter(
(c) =>
c.name.toLowerCase().includes(q) ||
(c.description ?? "").toLowerCase().includes(q) ||
(c.category ?? "").toLowerCase().includes(q) ||
(c.tags ?? []).some((tag) => tag.toLowerCase().includes(q)),
);
}
if (selectedTag) {
list = list.filter((c) => (c.tags ?? []).includes(selectedTag));
}
return list;
}, [types, search, selectedTag]);
const handleDoDelete = async () => {
if (!pendingDelete) return;
setDeleting(true);
@@ -69,7 +101,52 @@ export default function TypesPage() {
</Link>
</Button>
</div>
<p className="mb-6 text-sm text-gray-600">{t("description")}</p>
<p className="mb-4 text-sm text-gray-600">{t("description")}</p>
{!isLoading && !fetchError && types.length > 0 && (
<>
<div className="relative max-w-md mb-4">
<Input
type="search"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t("searchPlaceholder")}
aria-label={t("searchPlaceholder")}
className="w-full"
/>
</div>
{allTags.length > 0 && (
<div className="flex flex-wrap items-center gap-2 mb-4">
<span className="text-sm text-gray-600">{t("filterByTag")}</span>
<button
type="button"
onClick={() => setSelectedTag(null)}
className={`rounded px-2.5 py-1 text-sm transition-colors ${
selectedTag === null
? "bg-accent-200 font-medium text-gray-900"
: "bg-accent-100/70 text-gray-700 hover:bg-accent-200/80"
}`}
>
{t("tagAll")}
</button>
{allTags.map((tag) => (
<button
key={tag}
type="button"
onClick={() => setSelectedTag(selectedTag === tag ? null : tag)}
className={`rounded px-2.5 py-1 text-sm transition-colors ${
selectedTag === tag
? "bg-accent-200 font-medium text-gray-900"
: "bg-accent-100/70 text-gray-700 hover:bg-accent-200/80"
}`}
>
{tag}
</button>
))}
</div>
)}
</>
)}
{isLoading && <p className="text-gray-500">{t("loading")}</p>}
{fetchError && (
@@ -78,8 +155,11 @@ export default function TypesPage() {
{!isLoading && !fetchError && types.length === 0 && (
<p className="text-gray-500">{t("noTypes")}</p>
)}
{!isLoading && !fetchError && types.length > 0 && filteredTypes.length === 0 && (
<p className="text-gray-500">{t("noResults")}</p>
)}
{!isLoading && !fetchError && types.length > 0 && (
{!isLoading && !fetchError && types.length > 0 && filteredTypes.length > 0 && (
<div className="overflow-x-auto -mx-4 px-4 sm:mx-0 sm:px-0 rounded-lg border border-gray-200">
<Table className="min-w-[400px]">
<TableHeader>
@@ -91,7 +171,7 @@ export default function TypesPage() {
</TableRow>
</TableHeader>
<TableBody>
{types.map((c: CollectionMeta) => (
{filteredTypes.map((c: CollectionMeta) => (
<TableRow key={c.name}>
<TableCell className="font-medium">{c.name}</TableCell>
<TableCell className="max-w-xs truncate text-gray-600" title={c.description}>
@@ -100,20 +180,22 @@ export default function TypesPage() {
<TableCell className="text-gray-600">{c.category ?? "—"}</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-2">
<Button variant="outline" size="sm" asChild>
<Link href={`/admin/types/${encodeURIComponent(c.name)}/edit`}>
<Button variant="outline" size="icon-sm" asChild>
<Link
href={`/admin/types/${encodeURIComponent(c.name)}/edit`}
aria-label={t("edit")}
>
<Icon icon="mdi:pencil-outline" className="size-4" aria-hidden />
{t("edit")}
</Link>
</Button>
<Button
variant="outline"
size="sm"
size="icon-sm"
onClick={() => setPendingDelete(c.name)}
aria-label={t("delete")}
className="border-red-200 text-red-700 hover:bg-red-50 hover:text-red-800"
>
<Icon icon="mdi:delete-outline" className="size-4" aria-hidden />
{t("delete")}
</Button>
</div>
</TableCell>

View File

@@ -6,17 +6,20 @@ import { useParams, useSearchParams } from "next/navigation";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Icon } from "@iconify/react";
import { useTranslations } from "next-intl";
import { fetchSchema, fetchEntry, fetchLocales } from "@/lib/api";
import { fetchSchema, fetchEntry, fetchLocales, fetchReferrers } from "@/lib/api";
import { ContentForm } from "@/components/ContentForm";
import { SchemaAndPreviewBar } from "@/components/SchemaAndPreviewBar";
import { ContentLocaleSwitcher } from "@/components/ContentLocaleSwitcher";
import { Breadcrumbs } from "@/components/Breadcrumbs";
import { CollapsibleSection } from "@/components/ui/collapsible";
import { CodeBlock } from "@/components/CodeBlock";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
export default function ContentEditPage() {
const t = useTranslations("ContentEditPage");
const tList = useTranslations("ContentForm");
const tSchema = useTranslations("SchemaAndPreviewBar");
const params = useParams();
const searchParams = useSearchParams();
const queryClient = useQueryClient();
@@ -50,11 +53,29 @@ export default function ContentEditPage() {
const locales = localesData?.locales ?? [];
const defaultLocale = localesData?.default ?? null;
const { data: referrers = [] } = useQuery({
queryKey: ["referrers", collection, slug],
queryFn: () => fetchReferrers(collection, slug),
enabled: !!collection && !!slug,
});
const onSuccess = () => {
void queryClient.invalidateQueries({ queryKey: ["entry", collection, slug] });
void queryClient.invalidateQueries({ queryKey: ["content", collection] });
};
const tBread = useTranslations("Breadcrumbs");
useEffect(() => {
document.title =
collection && slug && schema && entry
? `${slug}${collection} — RustyCMS Admin`
: "RustyCMS Admin";
return () => {
document.title = "RustyCMS Admin";
};
}, [collection, slug, schema, entry]);
if (!collection || !slug) {
return (
<div className="rounded bg-amber-50 p-4 text-amber-800">
@@ -67,16 +88,6 @@ export default function ContentEditPage() {
const listHref = `/content/${collection}${localeQ}`;
const isLoading = schemaLoading || entryLoading;
const error = schemaError ?? entryError;
const tBread = useTranslations("Breadcrumbs");
useEffect(() => {
document.title = schema && entry
? `${slug}${collection} — RustyCMS Admin`
: "RustyCMS Admin";
return () => {
document.title = "RustyCMS Admin";
};
}, [collection, slug, schema, entry]);
return (
<div>
@@ -109,6 +120,89 @@ export default function ContentEditPage() {
/>
</div>
{schema && (
<CollapsibleSection
title={
<span className="flex items-center gap-2">
<Icon icon="mdi:script-text-outline" className="size-5 text-gray-500" aria-hidden />
{tSchema("sectionSchema")}
</span>
}
defaultOpen={false}
className="mb-4"
contentClassName="max-h-[60vh] overflow-auto"
>
<CodeBlock
code={JSON.stringify(schema, null, 2)}
language="json"
copyLabel={tSchema("copyCode")}
/>
</CollapsibleSection>
)}
<CollapsibleSection
title={
<span className="flex items-center gap-2">
<Icon icon="mdi:database-outline" className="size-5 text-gray-500" aria-hidden />
{tSchema("sectionDataPreview")}
</span>
}
defaultOpen={false}
className="mb-4"
contentClassName="max-h-[60vh] overflow-auto"
>
{entryLoading ? (
<p className="text-sm text-gray-500">{tSchema("loading")}</p>
) : entryError ? (
<p className="text-sm text-red-600">
{entryError instanceof Error ? entryError.message : tSchema("errorLoading")}
</p>
) : entry ? (
<CodeBlock
code={JSON.stringify(entry, null, 2)}
language="json"
copyLabel={tSchema("copyCode")}
/>
) : (
<p className="text-sm text-gray-500">{tSchema("errorLoading")}</p>
)}
</CollapsibleSection>
<CollapsibleSection
title={
<span className="flex items-center gap-2">
<Icon icon="mdi:link-variant" className="size-5 text-gray-500" aria-hidden />
{t("referrersSection")}
{referrers.length > 0 && (
<span className="rounded bg-gray-200 px-1.5 py-0.5 text-xs font-medium text-gray-700">
{referrers.length}
</span>
)}
</span>
}
defaultOpen={referrers.length > 0}
className="mb-4"
>
{referrers.length === 0 ? (
<p className="text-sm text-muted-foreground">{t("noReferrers")}</p>
) : (
<ul className="space-y-2">
{referrers.map((r, i) => (
<li key={`${r.collection}-${r.slug}-${r.field}-${i}`} className="flex flex-wrap items-center gap-2 text-sm">
<Link
href={`/content/${r.collection}/${encodeURIComponent(r.slug)}${r.locale ? `?_locale=${r.locale}` : ""}`}
className="font-mono text-accent-700 hover:underline"
title={t("openReferrer")}
>
{r.collection} / {r.slug}
</Link>
<span className="text-muted-foreground">({r.field})</span>
</li>
))}
</ul>
)}
</CollapsibleSection>
<h1 className="mb-6 text-2xl font-semibold text-gray-900">
{t("title")} {slug}
</h1>

View File

@@ -11,6 +11,7 @@ import { ContentForm } from "@/components/ContentForm";
import { SchemaAndEditBar } from "@/components/SchemaAndEditBar";
import { ContentLocaleSwitcher } from "@/components/ContentLocaleSwitcher";
import { Breadcrumbs } from "@/components/Breadcrumbs";
import { CollapsibleSection } from "@/components/ui/collapsible";
import { Button } from "@/components/ui/button";
export default function ContentNewPage() {
@@ -80,6 +81,24 @@ export default function ContentNewPage() {
<SchemaAndEditBar schema={schema ?? null} collection={collection} />
</div>
{schema && (
<CollapsibleSection
title={
<span className="flex items-center gap-2">
<Icon icon="mdi:script-text-outline" className="size-5 text-gray-500" aria-hidden />
{tNew("sectionSchema")}
</span>
}
defaultOpen={false}
className="mb-4"
contentClassName="max-h-[60vh] overflow-auto"
>
<pre className="whitespace-pre-wrap wrap-break-word font-mono text-xs text-gray-800">
{JSON.stringify(schema, null, 2)}
</pre>
</CollapsibleSection>
)}
<h1 className="mb-6 text-2xl font-semibold text-gray-900">
{t("title")} {collection}
</h1>

View File

@@ -1,17 +1,29 @@
"use client";
import { useEffect } from "react";
import { useEffect, useState } from "react";
import Link from "next/link";
import { useParams, useSearchParams } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Icon } from "@iconify/react";
import { useTranslations } from "next-intl";
import { fetchContentList, fetchLocales } from "@/lib/api";
import { toast } from "sonner";
import { fetchContentList, fetchSchema, fetchLocales, getPerPage, deleteEntry } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { CodeBlock } from "@/components/CodeBlock";
import { SearchBar } from "@/components/SearchBar";
import { PaginationLinks } from "@/components/PaginationLinks";
import { ContentLocaleSwitcher } from "@/components/ContentLocaleSwitcher";
import { Breadcrumbs } from "@/components/Breadcrumbs";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import {
Table,
TableBody,
@@ -21,20 +33,31 @@ import {
TableRow,
} from "@/components/ui/table";
import { Skeleton } from "@/components/ui/skeleton";
const PER_PAGE = 20;
import { CollapsibleSection } from "@/components/ui/collapsible";
import { TypeDependencyGraph } from "@/components/TypeDependencyGraph";
export default function ContentListPage() {
const t = useTranslations("ContentListPage");
const tSchema = useTranslations("SchemaAndPreviewBar");
const params = useParams();
const searchParams = useSearchParams();
const queryClient = useQueryClient();
const collection = typeof params.collection === "string" ? params.collection : "";
const [pendingDelete, setPendingDelete] = useState<string | null>(null);
const [deleting, setDeleting] = useState(false);
const page = Math.max(1, parseInt(searchParams.get("_page") ?? "1", 10) || 1);
const sort = searchParams.get("_sort") ?? undefined;
const order = (searchParams.get("_order") ?? "asc") as "asc" | "desc";
const q = searchParams.get("_q") ?? undefined;
const locale = searchParams.get("_locale") ?? undefined;
const perPage = getPerPage();
const { data: schema } = useQuery({
queryKey: ["schema", collection],
queryFn: () => fetchSchema(collection),
enabled: !!collection,
});
const { data: localesData } = useQuery({
queryKey: ["locales"],
@@ -45,7 +68,7 @@ export default function ContentListPage() {
const listParams = {
_page: page,
_per_page: PER_PAGE,
_per_page: perPage,
_status: "all" as const,
...(sort ? { _sort: sort, _order: order } : {}),
...(q?.trim() ? { _q: q.trim() } : {}),
@@ -91,6 +114,21 @@ export default function ContentListPage() {
};
}, [collection]);
const handleDoDelete = async () => {
if (!pendingDelete) return;
setDeleting(true);
try {
await deleteEntry(collection, pendingDelete, locale ? { _locale: locale } : {});
await queryClient.invalidateQueries({ queryKey: ["content", collection] });
setPendingDelete(null);
toast.success(t("deleted"));
} catch (e) {
toast.error(e instanceof Error ? e.message : t("errorDeleting"));
} finally {
setDeleting(false);
}
};
return (
<div>
<Breadcrumbs
@@ -99,7 +137,7 @@ export default function ContentListPage() {
{ label: collection },
]}
/>
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:flex-wrap sm:items-center sm:justify-between">
<div className="mb-4 flex flex-col gap-4 sm:flex-row sm:flex-wrap sm:items-center sm:justify-between">
<h1 className="text-xl font-semibold text-gray-900 sm:text-2xl truncate">
{collection}
</h1>
@@ -122,6 +160,39 @@ export default function ContentListPage() {
</div>
</div>
{schema && (
<CollapsibleSection
title={
<span className="flex items-center gap-2">
<Icon icon="mdi:script-text-outline" className="size-5 text-gray-500" aria-hidden />
{tSchema("sectionSchema")}
</span>
}
defaultOpen={false}
className="mb-4"
contentClassName="max-h-[60vh] overflow-auto"
>
<CodeBlock
code={JSON.stringify(schema, null, 2)}
language="json"
copyLabel={tSchema("copyCode")}
/>
</CollapsibleSection>
)}
<CollapsibleSection
title={
<span className="flex items-center gap-2">
<Icon icon="mdi:graph-outline" className="size-5 text-gray-500" aria-hidden />
{t("typeDependencies")}
</span>
}
defaultOpen={false}
className="mb-4"
>
<TypeDependencyGraph currentCollection={collection} />
</CollapsibleSection>
{isLoading && (
<div className="space-y-3">
<Skeleton className="h-10 w-full max-w-md" />
@@ -130,7 +201,7 @@ export default function ContentListPage() {
<TableHeader>
<TableRow>
<TableHead>_slug</TableHead>
<TableHead className="w-24 text-right">{t("colActions")}</TableHead>
<TableHead className="w-28 text-right">{t("colActions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -152,31 +223,24 @@ export default function ContentListPage() {
)}
{!isLoading && !error && items.length === 0 && (
<div
className="mx-auto max-w-md rounded-xl border border-gray-200 bg-gray-50/80 px-8 text-center shadow-sm"
style={{ marginTop: "1.25rem", marginBottom: "1.25rem", paddingTop: "2rem", paddingBottom: "2.5rem" }}
>
<p className="text-base text-gray-600" style={{ marginBottom: "1.5rem" }}>
{t("noEntriesCreate")}
</p>
<div className="flex justify-center" style={{ marginBottom: "1rem" }}>
<Button asChild className="min-h-[44px] sm:min-h-0">
<Link href={`/content/${collection}/new${localeQ ? `?${localeQ}` : ""}`}>
<Icon icon="mdi:plus" className="size-5" aria-hidden />
{t("newEntry")}
</Link>
</Button>
</div>
<div className="py-8 text-center">
<p className="text-muted-foreground mb-4">{t("noEntriesCreate")}</p>
<Button asChild className="min-h-[44px] sm:min-h-0">
<Link href={`/content/${collection}/new${localeQ ? `?${localeQ}` : ""}`}>
<Icon icon="mdi:plus" className="size-5" aria-hidden />
{t("newEntry")}
</Link>
</Button>
</div>
)}
{!isLoading && !error && items.length > 0 && (
<>
<div className="overflow-x-auto -mx-4 px-4 sm:mx-0 sm:px-0 rounded-lg border border-gray-200">
<Table className="min-w-[280px]">
<Table className="min-w-[280px] table-fixed">
<TableHeader>
<TableRow>
<TableHead>
<TableHead className="w-64 max-w-[16rem]">
<Link
href={`/content/${collection}?${sortQuery("_slug", nextSlugOrder)}`}
className="inline-flex items-center gap-1 font-medium hover:underline"
@@ -192,7 +256,7 @@ export default function ContentListPage() {
)}
</Link>
</TableHead>
<TableHead className="w-24 text-right">{t("colActions")}</TableHead>
<TableHead className="w-28 text-right">{t("colActions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -203,8 +267,14 @@ export default function ContentListPage() {
const editHref = `/content/${collection}/${encodeURIComponent(slug)}${localeQ ? `?${localeQ}` : ""}`;
return (
<TableRow key={slug}>
<TableCell className="font-mono text-sm">
{slug}
<TableCell className="w-64 max-w-[16rem] font-mono text-sm">
<Link
href={editHref}
className="block truncate text-accent-700 hover:underline hover:text-accent-900"
title={slug}
>
{slug}
</Link>
{isDraft && (
<span className="ml-2 rounded bg-amber-100 px-1.5 py-0.5 text-xs font-medium text-amber-800">
{t("draft")}
@@ -212,12 +282,22 @@ export default function ContentListPage() {
)}
</TableCell>
<TableCell className="text-right">
<Button variant="outline" size="sm" asChild className="min-h-[44px] sm:min-h-0">
<Link href={editHref}>
<Icon icon="mdi:pencil-outline" className="size-4" aria-hidden />
{t("edit")}
</Link>
</Button>
<div className="flex items-center justify-end gap-2">
<Button variant="outline" size="icon-sm" asChild className="min-h-[44px] w-11 sm:min-h-0">
<Link href={editHref} aria-label={t("edit")}>
<Icon icon="mdi:pencil-outline" className="size-4" aria-hidden />
</Link>
</Button>
<Button
variant="outline"
size="icon-sm"
onClick={() => setPendingDelete(slug)}
aria-label={t("delete")}
className="min-h-[44px] w-11 border-red-200 text-red-700 hover:bg-red-50 hover:text-red-800 sm:min-h-0"
>
<Icon icon="mdi:delete-outline" className="size-4" aria-hidden />
</Button>
</div>
</TableCell>
</TableRow>
);
@@ -235,6 +315,25 @@ export default function ContentListPage() {
order={order}
q={q ?? undefined}
/>
<AlertDialog open={!!pendingDelete} onOpenChange={(o) => !o && setPendingDelete(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("confirmDelete", { slug: pendingDelete ?? "" })}</AlertDialogTitle>
<AlertDialogDescription>{t("confirmDeleteDescription")}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleting}>{t("cancel")}</AlertDialogCancel>
<AlertDialogAction
onClick={handleDoDelete}
disabled={deleting}
className="bg-destructive text-white hover:bg-destructive/90"
>
{deleting ? t("deleting") : t("yesDelete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)}
</div>

View File

@@ -0,0 +1,249 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { useTranslations } from "next-intl";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import {
getBaseUrl,
getApiKey,
setApiKey,
getPerPage,
setPerPage,
clearSession,
fetchHealth,
fetchLocales,
PER_PAGE_OPTIONS,
} from "@/lib/api";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
const UI_LOCALES = ["en", "de"] as const;
function setLocaleCookie(l: string) {
if (typeof document !== "undefined") {
document.cookie = `locale=${l}; path=/; max-age=31536000`;
}
}
type Props = { locale: string };
export function SettingsContent({ locale }: Props) {
const t = useTranslations("Settings");
const router = useRouter();
const queryClient = useQueryClient();
const [perPage, setPerPageState] = useState(getPerPage);
const [clearSessionOpen, setClearSessionOpen] = useState(false);
const { data: health, isLoading: healthLoading } = useQuery({
queryKey: ["health"],
queryFn: fetchHealth,
staleTime: 10_000,
});
const { data: localesData } = useQuery({
queryKey: ["locales"],
queryFn: fetchLocales,
staleTime: 60_000,
});
const apiKey = typeof window !== "undefined" ? getApiKey() : null;
const hasEnvKey = typeof window !== "undefined" && !!process.env.NEXT_PUBLIC_RUSTYCMS_API_KEY;
const setUILocale = (l: string) => {
setLocaleCookie(l);
router.refresh();
};
const handleLogout = () => {
setApiKey(null);
router.refresh();
};
const handlePerPageChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const n = parseInt(e.target.value, 10);
if (PER_PAGE_OPTIONS.includes(n as (typeof PER_PAGE_OPTIONS)[number])) {
setPerPage(n);
setPerPageState(n);
queryClient.invalidateQueries({ queryKey: ["content"] });
}
};
const handleRefreshData = () => {
queryClient.invalidateQueries();
toast.success(t("refreshDataSuccess"));
};
const handleClearSession = () => {
clearSession();
setClearSessionOpen(false);
queryClient.invalidateQueries();
router.refresh();
};
return (
<>
<h1 className="mb-6 text-2xl font-semibold text-gray-900">{t("title")}</h1>
<section className="mb-8 rounded-xl border border-accent-200/80 bg-white p-6 shadow-sm">
<h2 className="mb-4 text-lg font-medium text-gray-900">{t("connection")}</h2>
<dl className="space-y-3">
<div>
<dt className="text-sm font-medium text-gray-500">{t("apiUrl")}</dt>
<dd className="mt-0.5 font-mono text-sm text-gray-900">{getBaseUrl()}</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">{t("backendStatus")}</dt>
<dd className="mt-0.5">
{healthLoading ? (
<span className="text-sm text-gray-500">{t("checking")}</span>
) : health?.ok ? (
<span className="inline-flex items-center gap-1.5 text-sm text-green-700">
<span className="size-2 rounded-full bg-green-500" aria-hidden />
{t("apiReachable")}
</span>
) : (
<span className="inline-flex items-center gap-1.5 text-sm text-red-600">
<span className="size-2 rounded-full bg-red-500" aria-hidden />
{t("apiUnreachable")}
{health?.status != null && ` (${health.status})`}
</span>
)}
</dd>
</div>
{localesData && (localesData.locales?.length ?? 0) > 0 && (
<div>
<dt className="text-sm font-medium text-gray-500">{t("contentLocales")}</dt>
<dd className="mt-0.5 text-sm text-gray-900">
{localesData.locales.join(", ")}
{localesData.default && ` (${t("default")}: ${localesData.default})`}
</dd>
</div>
)}
</dl>
</section>
<section className="mb-8 rounded-xl border border-accent-200/80 bg-white p-6 shadow-sm">
<h2 className="mb-4 text-lg font-medium text-gray-900">{t("thisDevice")}</h2>
<dl className="space-y-4">
<div>
<dt className="text-sm font-medium text-gray-500">{t("uiLanguage")}</dt>
<dd className="mt-1.5 flex gap-1">
{UI_LOCALES.map((l) => (
<button
key={l}
type="button"
onClick={() => setUILocale(l)}
className={`rounded px-3 py-1.5 text-sm font-medium transition-colors ${
locale === l
? "bg-accent-200/70 text-gray-900"
: "bg-accent-100/70 text-gray-700 hover:bg-accent-200/80 hover:text-gray-900"
}`}
aria-pressed={locale === l}
>
{l.toUpperCase()}
</button>
))}
</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">{t("itemsPerPage")}</dt>
<dd className="mt-1.5">
<select
value={perPage}
onChange={handlePerPageChange}
className="rounded border border-accent-200/80 bg-white px-3 py-1.5 text-sm text-gray-900 focus:border-accent-300 focus:outline-none focus:ring-1 focus:ring-accent-200/80"
aria-describedby="per-page-hint"
>
{PER_PAGE_OPTIONS.map((n) => (
<option key={n} value={n}>
{n}
</option>
))}
</select>
<p id="per-page-hint" className="mt-1 text-xs text-gray-500">
{t("itemsPerPageHint")}
</p>
</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">{t("apiKeyStatus")}</dt>
<dd className="mt-1.5 flex flex-wrap items-center gap-2">
{apiKey ? (
<>
<span className="text-sm text-gray-700">
{hasEnvKey ? t("apiKeyFromEnv") : t("apiKeyManual")}
</span>
{!hasEnvKey && (
<button
type="button"
onClick={handleLogout}
className="rounded bg-accent-200/80 px-2.5 py-1 text-sm font-medium text-gray-800 hover:bg-accent-300/80"
>
{t("logout")}
</button>
)}
</>
) : (
<Link
href="/login"
className="rounded bg-accent-200/80 px-2.5 py-1 text-sm font-medium text-gray-800 no-underline hover:bg-accent-300/80"
>
{t("login")}
</Link>
)}
</dd>
</div>
<div>
<button
type="button"
onClick={handleRefreshData}
className="rounded border border-accent-200/80 bg-accent-50/80 px-3 py-1.5 text-sm font-medium text-gray-800 hover:bg-accent-100/80"
>
{t("refreshData")}
</button>
</div>
</dl>
</section>
<section className="rounded-xl border border-amber-200/80 bg-amber-50/30 p-6 shadow-sm">
<h2 className="mb-2 text-lg font-medium text-gray-900">{t("clearSession")}</h2>
<p className="mb-4 text-sm text-gray-600">{t("clearSessionHint")}</p>
<button
type="button"
onClick={() => setClearSessionOpen(true)}
className="rounded border border-amber-300/80 bg-amber-100/80 px-3 py-1.5 text-sm font-medium text-amber-900 hover:bg-amber-200/80"
>
{t("clearSession")}
</button>
</section>
<AlertDialog open={clearSessionOpen} onOpenChange={setClearSessionOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("clearSessionConfirmTitle")}</AlertDialogTitle>
<AlertDialogDescription>
{t("clearSessionConfirmDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("cancel")}</AlertDialogCancel>
<AlertDialogAction onClick={handleClearSession} className="bg-amber-600 hover:bg-amber-700">
{t("clearSessionConfirmAction")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View File

@@ -0,0 +1,11 @@
import { getLocale } from "next-intl/server";
import { SettingsContent } from "./SettingsContent";
export default async function SettingsPage() {
const locale = await getLocale();
return (
<div>
<SettingsContent locale={locale} />
</div>
);
}

View File

@@ -0,0 +1,86 @@
"use client";
import { useCallback, useState } from "react";
import { PrismLight as SyntaxHighlighter } from "react-syntax-highlighter";
import json from "react-syntax-highlighter/dist/esm/languages/prism/json";
import css from "react-syntax-highlighter/dist/esm/languages/prism/css";
import javascript from "react-syntax-highlighter/dist/esm/languages/prism/javascript";
import markup from "react-syntax-highlighter/dist/esm/languages/prism/markup";
import { oneLight } from "react-syntax-highlighter/dist/esm/styles/prism";
import { Icon } from "@iconify/react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
SyntaxHighlighter.registerLanguage("json", json);
SyntaxHighlighter.registerLanguage("css", css);
SyntaxHighlighter.registerLanguage("javascript", javascript);
SyntaxHighlighter.registerLanguage("html", markup);
export type CodeBlockLanguage = "json" | "css" | "javascript" | "html";
type Props = {
code: string;
language: CodeBlockLanguage;
className?: string;
/** Optional copy button label for aria */
copyLabel?: string;
};
export function CodeBlock({ code, language, className, copyLabel = "Copy" }: Props) {
const [copied, setCopied] = useState(false);
const handleCopy = useCallback(async () => {
try {
await navigator.clipboard.writeText(code);
setCopied(true);
toast.success("Copied to clipboard");
setTimeout(() => setCopied(false), 2000);
} catch {
toast.error("Copy failed");
}
}, [code]);
return (
<div className={cn("relative rounded-md border border-gray-200 bg-gray-50/80", className)}>
<div className="absolute right-2 top-2 z-10">
<Button
type="button"
variant="secondary"
size="xs"
onClick={handleCopy}
aria-label={copyLabel}
className="gap-1.5"
>
{copied ? (
<Icon icon="mdi:check" className="size-4 text-green-600" aria-hidden />
) : (
<Icon icon="mdi:content-copy" className="size-4" aria-hidden />
)}
{copied ? "Copied" : "Copy"}
</Button>
</div>
<SyntaxHighlighter
language={language}
style={oneLight}
customStyle={{
margin: 0,
padding: "1rem 1rem 1rem 1rem",
paddingTop: "2.5rem",
fontSize: "0.75rem",
lineHeight: 1.5,
background: "transparent",
border: "none",
}}
codeTagProps={{
className: "font-mono text-gray-800",
}}
wrapLongLines
showLineNumbers={false}
PreTag="div"
>
{code}
</SyntaxHighlighter>
</div>
);
}

View File

@@ -0,0 +1,65 @@
"use client";
import { Textarea } from "@/components/ui/textarea";
import { CodeBlock, type CodeBlockLanguage } from "@/components/CodeBlock";
import { cn } from "@/lib/utils";
const SUPPORTED_CODE_LANGUAGES: CodeBlockLanguage[] = ["json", "css", "javascript", "html"];
export function isCodeFieldLanguage(value: string | undefined): value is CodeBlockLanguage {
return value !== undefined && SUPPORTED_CODE_LANGUAGES.includes(value as CodeBlockLanguage);
}
type CodeFieldProps = {
name: string;
value: string;
onChange: (value: string) => void;
onBlur: () => void;
language: CodeBlockLanguage;
label: React.ReactNode;
readonly?: boolean;
fieldError?: unknown;
copyLabel?: string;
};
export function CodeField({
name,
value,
onChange,
onBlur,
language,
label,
readonly,
fieldError,
copyLabel = "Copy",
}: CodeFieldProps) {
return (
<div className="space-y-2">
{label}
<Textarea
name={name}
value={value}
onChange={(e) => onChange(e.target.value)}
onBlur={onBlur}
readOnly={readonly}
rows={8}
className={cn(
"min-h-[120px] w-full font-mono text-sm",
"rounded-md border border-gray-200 bg-white"
)}
spellCheck={false}
/>
<CodeBlock
code={value || " "}
language={language}
copyLabel={copyLabel}
className="mt-2"
/>
{fieldError ? (
<p className="mt-1 text-sm text-red-600">
{String((fieldError as { message?: string })?.message)}
</p>
) : null}
</div>
);
}

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}

View File

@@ -96,11 +96,11 @@ export function DashboardCollectionList({ collections }: Props) {
) : (
<ul className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{filtered.map((c) => (
<li key={c.name}>
<li key={c.name} className="h-full min-h-0">
<Link
href={`/content/${c.name}`}
data-slot="button"
className="flex min-h-[48px] flex-col items-stretch justify-center gap-1 rounded-lg border border-accent-200 bg-accent-50/50 px-4 py-3 font-medium text-gray-900 no-underline hover:border-accent-300 hover:bg-accent-100/80 active:bg-accent-200/60 touch-manipulation"
className="flex h-full min-h-[48px] flex-col items-stretch justify-center gap-1 rounded-lg border border-accent-200 bg-accent-50/50 px-4 py-3 font-medium text-gray-900 no-underline hover:border-accent-300 hover:bg-accent-100/80 active:bg-accent-200/60 touch-manipulation"
>
<span className="font-medium">{c.name}</span>
{c.description && (

View File

@@ -27,6 +27,8 @@ export function PaginationLinks({
q,
}: Props) {
const t = useTranslations("PaginationLinks");
if (totalPages <= 1) return null;
const base = `/content/${collection}`;
const localeQ = locale ? `&_locale=${locale}` : "";
const sortQ = sort ? `&_sort=${sort}&_order=${order ?? "asc"}` : "";

View File

@@ -18,6 +18,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Icon } from "@iconify/react";
import { Button } from "@/components/ui/button";
type Props = {
@@ -31,14 +32,19 @@ type Props = {
label: React.ReactNode;
};
/** Referenced collection(s) from schema: single collection or list for polymorphic. */
/** Referenced collection(s) from schema: single collection or list for polymorphic. Optional allowedCollections whitelist is applied. */
function getCollections(def: FieldDefinition): string[] {
const items = def.items as FieldDefinition | undefined;
if (!items) return [];
if (items.collection) return [items.collection];
if (Array.isArray(items.collections) && items.collections.length > 0)
return items.collections;
return [];
let base: string[] = [];
if (items.collection) base = [items.collection];
else if (Array.isArray(items.collections) && items.collections.length > 0)
base = items.collections;
if (Array.isArray(items.allowedCollections) && items.allowedCollections.length > 0) {
const set = new Set(items.allowedCollections);
base = base.filter((c) => set.has(c));
}
return base;
}
export function ReferenceArrayField({
@@ -126,7 +132,9 @@ export function ReferenceArrayField({
});
type OptionItem = { slug: string; collection: string };
const options: OptionItem[] =
const itemDef = def.items;
const allowedSlugs = itemDef?.allowedSlugs;
const rawOptions: OptionItem[] =
multipleCollections.length > 1 && multiQueries.data
? multiQueries.data.flatMap(({ collection: coll, items }) =>
(items as { _slug?: string }[])
@@ -143,6 +151,11 @@ export function ReferenceArrayField({
}))
.filter((o) => o.slug);
const options =
allowedSlugs?.length && allowedSlugs.length > 0
? rawOptions.filter((o) => allowedSlugs.includes(o.slug))
: rawOptions;
const isLoading =
schemaCollections.length <= 1
? singleQuery.isLoading
@@ -230,9 +243,10 @@ export function ReferenceArrayField({
<Link
href={`/content/${collForSlug}/${encodeURIComponent(slug)}${locale ? `?_locale=${locale}` : ""}`}
className="rounded p-1 text-accent-700 hover:bg-accent-50 hover:text-accent-900"
title="Open entry"
title={t("openEntry")}
aria-label={t("openEntry")}
>
<Icon icon="mdi:open-in-new" className="size-4" aria-hidden />
</Link>
)}
<button
@@ -346,7 +360,7 @@ export function ReferenceArrayField({
{collectionsForNew.length > 0 ? (
<span className="flex shrink-0 flex-col gap-0.5">
{collectionsForNew.length === 1 ? (
<Button variant="outline" size="sm" asChild>
<Button variant="outline" asChild>
<Link
href={
collectionsForNew[0]
@@ -356,6 +370,7 @@ export function ReferenceArrayField({
target="_blank"
rel="noopener noreferrer"
>
<Icon icon="mdi:plus" className="size-4 shrink-0" aria-hidden />
{t("newComponent", { collection: collectionsForNew[0] })}
</Link>
</Button>

View File

@@ -15,6 +15,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Icon } from "@iconify/react";
import { Button } from "@/components/ui/button";
type Props = {
@@ -29,12 +30,17 @@ type Props = {
readOnly?: boolean;
};
/** Target collection(s) from schema: single collection or list for polymorphic. */
/** Target collection(s) from schema: single collection or list for polymorphic. Optional allowedCollections whitelist is applied. */
function getCollections(def: FieldDefinition): string[] {
if (def.collection) return [def.collection];
if (Array.isArray(def.collections) && def.collections.length > 0)
return def.collections;
return [];
let base: string[] = [];
if (def.collection) base = [def.collection];
else if (Array.isArray(def.collections) && def.collections.length > 0)
base = def.collections;
if (Array.isArray(def.allowedCollections) && def.allowedCollections.length > 0) {
const set = new Set(def.allowedCollections);
base = base.filter((c) => set.has(c));
}
return base;
}
export function ReferenceField({
@@ -100,7 +106,7 @@ export function ReferenceField({
});
type OptionItem = { slug: string; collection: string; label: string };
const options: OptionItem[] =
const rawOptions: OptionItem[] =
multipleCollections.length > 1 && multiQueries.data
? multiQueries.data.flatMap(({ collection: coll, items }) =>
(items as Record<string, unknown>[])
@@ -122,6 +128,12 @@ export function ReferenceField({
}))
.filter((o) => o.slug);
const allowedSlugs = def.allowedSlugs;
const options =
allowedSlugs?.length && allowedSlugs.length > 0
? rawOptions.filter((o) => allowedSlugs.includes(o.slug))
: rawOptions;
const isLoading =
schemaCollections.length <= 1
? singleQuery.isLoading
@@ -199,22 +211,25 @@ export function ReferenceField({
</div>
<div className="flex shrink-0 gap-1">
{currentValue && effectiveCollection && (
<Button variant="outline" size="sm" asChild>
<Button variant="outline" asChild>
<Link
href={`/content/${effectiveCollection}/${encodeURIComponent(currentValue.includes(":") ? currentValue.split(":")[1] : currentValue)}${locale ? `?_locale=${locale}` : ""}`}
title={t("openEntry")}
aria-label={t("openEntry")}
>
<span aria-hidden></span>
<Icon icon="mdi:open-in-new" className="size-4" aria-hidden />
</Link>
</Button>
)}
{effectiveCollection ? (
<Button variant="outline" size="sm" asChild className="shrink-0">
<Button variant="outline" asChild className="shrink-0">
<Link
href={`/content/${effectiveCollection}/new${locale ? `?_locale=${locale}` : ""}`}
target="_blank"
rel="noopener noreferrer"
>
<span aria-hidden>+</span> {t("newEntry")}
<Icon icon="mdi:plus" className="size-4" aria-hidden />
{t("newEntry")}
</Link>
</Button>
) : null}

View File

@@ -3,7 +3,6 @@
import Link from "next/link";
import { Icon } from "@iconify/react";
import { useTranslations } from "next-intl";
import { SchemaPanel } from "@/components/SchemaPanel";
import type { SchemaDefinition } from "@/lib/api";
import { Button } from "@/components/ui/button";
@@ -17,7 +16,6 @@ export function SchemaAndEditBar({ schema, collection, className = "" }: Props)
const t = useTranslations("SchemaAndEditBar");
return (
<div className={`flex flex-wrap items-center gap-2 ${className}`}>
<SchemaPanel schema={schema} />
{schema && (
<Button variant="outline" size="sm" asChild>
<Link href={`/admin/types/${encodeURIComponent(collection)}/edit`}>

View File

@@ -1,11 +1,8 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { Icon } from "@iconify/react";
import { useQuery } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { fetchEntry } from "@/lib/api";
import type { SchemaDefinition } from "@/lib/api";
import { Button } from "@/components/ui/button";
@@ -17,98 +14,22 @@ type Props = {
className?: string;
};
const boxClass = "overflow-hidden rounded-lg border border-gray-200 bg-gray-50";
const innerClass = "max-h-[60vh] overflow-auto p-4";
const preClass = "whitespace-pre-wrap break-words font-mono text-xs text-gray-800";
/** Bar with only the "Edit schema" link. Schema and data preview are shown in accordions on the page. */
export function SchemaAndPreviewBar({
schema,
collection,
slug,
locale,
className = "",
}: Props) {
const t = useTranslations("SchemaAndPreviewBar");
const [schemaOpen, setSchemaOpen] = useState(false);
const [previewOpen, setPreviewOpen] = useState(false);
const { data: previewData, isLoading, error, isFetching } = useQuery({
queryKey: ["entry-preview", collection, slug, locale ?? ""],
queryFn: () =>
fetchEntry(collection, slug, {
_resolve: "all",
...(locale ? { _locale: locale } : {}),
}),
enabled: previewOpen,
});
const schemaJson = schema ? JSON.stringify(schema, null, 2) : "";
const previewJson = previewData != null ? JSON.stringify(previewData, null, 2) : "";
return (
<div className={className}>
<div className="flex flex-wrap items-center gap-2">
{schema && (
<>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setSchemaOpen((v) => !v)}
aria-expanded={schemaOpen}
>
<Icon
icon={schemaOpen ? "mdi:eye-off-outline" : "mdi:eye-outline"}
className="size-4 text-gray-500"
aria-hidden
/>
{schemaOpen ? t("hideSchema") : t("showSchema")}
</Button>
<Button variant="outline" size="sm" asChild>
<Link href={`/admin/types/${encodeURIComponent(collection)}/edit`}>
<Icon icon="mdi:pencil-outline" className="size-4 text-gray-500" aria-hidden />
{t("editSchema")}
</Link>
</Button>
</>
)}
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setPreviewOpen((v) => !v)}
aria-expanded={previewOpen}
>
<Icon icon="mdi:code-json" className="size-4 text-gray-500" aria-hidden />
{previewOpen ? t("hidePreview") : t("showPreview")}
{schema && (
<Button variant="outline" size="sm" asChild>
<Link href={`/admin/types/${encodeURIComponent(collection)}/edit`}>
<Icon icon="mdi:pencil-outline" className="size-4 text-gray-500" aria-hidden />
{t("editSchema")}
</Link>
</Button>
</div>
{(schemaOpen || previewOpen) && (
<div className="mt-3 flex flex-col gap-3">
{schemaOpen && (
<div className={boxClass}>
<div className={innerClass}>
<pre className={preClass}>{schemaJson}</pre>
</div>
</div>
)}
{previewOpen && (
<div className={boxClass}>
<div className={innerClass}>
{isLoading || isFetching ? (
<p className="text-sm text-gray-500">{t("loading")}</p>
) : error ? (
<p className="text-sm text-red-600">
{error instanceof Error ? error.message : t("errorLoading")}
</p>
) : (
<pre className={preClass}>{previewJson}</pre>
)}
</div>
</div>
)}
</div>
)}
</div>
);

View File

@@ -1,6 +1,6 @@
"use client";
import { useState, useMemo } from "react";
import { useState, useMemo, useEffect } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { Icon } from "@iconify/react";
@@ -23,10 +23,12 @@ export function Sidebar({ locale, mobileOpen = false, onClose }: SidebarProps) {
const pathname = usePathname();
const [search, setSearch] = useState("");
const [, setLogoutVersion] = useState(0);
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
const apiKey = getApiKey();
const hasEnvKey = typeof window !== "undefined" && !!process.env.NEXT_PUBLIC_RUSTYCMS_API_KEY;
const hasEnvKey = mounted && typeof window !== "undefined" && !!process.env.NEXT_PUBLIC_RUSTYCMS_API_KEY;
const hasStoredKey =
typeof window !== "undefined" && !!sessionStorage.getItem("rustycms_admin_api_key");
mounted && typeof window !== "undefined" && !!sessionStorage.getItem("rustycms_admin_api_key");
const { data, isLoading, error } = useQuery({
queryKey: ["collections"],
queryFn: fetchCollections,
@@ -135,6 +137,18 @@ export function Sidebar({ locale, mobileOpen = false, onClose }: SidebarProps) {
/>
{t("assets")}
</Link>
<Link
href="/settings"
onClick={onClose}
className={`${navLinkClass} ${pathname === "/settings" ? "bg-accent-200/70 text-gray-900" : "text-gray-700 hover:bg-accent-100/80 hover:text-gray-900"}`}
>
<Icon
icon="mdi:cog-outline"
className="size-5 shrink-0"
aria-hidden
/>
{t("settings")}
</Link>
</div>
<hr className="my-3 shrink-0 border-accent-200/50" />
<div className="shrink-0 px-1 pb-2">
@@ -211,7 +225,16 @@ export function Sidebar({ locale, mobileOpen = false, onClose }: SidebarProps) {
</div>
</nav>
<div className="mt-2 border-t border-accent-200/50 pt-2">
{hasStoredKey ? (
{!mounted ? (
<Link
href="/login"
onClick={onClose}
className={`${navLinkClass} w-full text-left text-gray-700 no-underline hover:bg-accent-100/80 hover:text-gray-900 ${pathname === "/login" ? "bg-accent-200/70 font-medium text-gray-900" : ""}`}
>
<Icon icon="mdi:login" className="size-4" aria-hidden />
{t("login")}
</Link>
) : hasStoredKey ? (
<button
type="button"
onClick={() => {

View File

@@ -0,0 +1,217 @@
"use client";
import { useMemo, useCallback } from "react";
import { useRouter } from "next/navigation";
import { useQuery, useQueries } from "@tanstack/react-query";
import {
ReactFlow,
type Node,
type Edge,
Background,
Controls,
MiniMap,
Panel,
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import { fetchCollections, fetchSchema } from "@/lib/api";
import type { SchemaDefinition } from "@/lib/api";
const CIRCLE_RADIUS = 220;
const CENTER_X = 380;
const CENTER_Y = 280;
type GraphData = { nodes: Node[]; edges: Edge[] };
function buildGraph(
collectionNames: string[],
schemas: (SchemaDefinition | null)[],
currentCollection: string
): GraphData {
const allNodes: Node[] = [];
const allEdges: Edge[] = [];
const seenEdges = new Set<string>();
const visibleNames = collectionNames.filter((_, i) => !schemas[i]?.reusable);
const visibleSet = new Set(visibleNames);
visibleNames.forEach((name, i) => {
const angle = (2 * Math.PI * i) / Math.max(1, visibleNames.length);
const x = CENTER_X + CIRCLE_RADIUS * Math.cos(angle);
const y = CENTER_Y + CIRCLE_RADIUS * Math.sin(angle);
const isCurrent = name === currentCollection;
allNodes.push({
id: name,
type: "default",
position: { x, y },
data: { label: name },
className: isCurrent ? "!ring-2 !ring-accent-500 !rounded-lg" : "",
});
});
schemas.forEach((schema, idx) => {
if (!schema || !collectionNames[idx] || schema.reusable) return;
const source = schema.name;
if (!visibleSet.has(source)) return;
const extendNames: string[] = [];
const raw = schema.extends;
if (typeof raw === "string") extendNames.push(raw);
else if (Array.isArray(raw)) extendNames.push(...raw);
extendNames.forEach((target) => {
if (!visibleSet.has(target)) return;
const key = `extend:${source}-${target}`;
if (seenEdges.has(key)) return;
seenEdges.add(key);
allEdges.push({
id: key,
source,
target,
label: "extends",
labelBgPadding: [4, 2],
labelBgBorderRadius: 2,
type: "smoothstep",
});
});
const fields = schema.fields ?? {};
Object.values(fields).forEach((fd) => {
const refs: string[] = [];
if (fd.type === "reference" && fd.collection) refs.push(fd.collection);
if (fd.type === "referenceOrInline" && fd.collection) refs.push(fd.collection);
if (fd.collections?.length) refs.push(...fd.collections);
if (fd.items?.type === "reference" && fd.items.collection) refs.push(fd.items.collection);
refs.forEach((target) => {
if (!visibleSet.has(target)) return;
const key = `ref:${source}-${target}`;
if (seenEdges.has(key)) return;
seenEdges.add(key);
allEdges.push({
id: key,
source,
target,
label: "references",
labelBgPadding: [4, 2],
labelBgBorderRadius: 2,
type: "smoothstep",
});
});
});
});
// Focus on current collection and its direct neighbours only
const connected = new Set<string>([currentCollection]);
allEdges.forEach((e) => {
if (e.source === currentCollection || e.target === currentCollection) {
connected.add(e.source);
connected.add(e.target);
}
});
const filteredNodes = allNodes.filter((n) => connected.has(n.id));
const filteredEdges = allEdges.filter(
(e) => connected.has(e.source) && connected.has(e.target)
);
// Re-layout filtered nodes in a circle
const nodes = filteredNodes.map((n, i) => {
const angle = (2 * Math.PI * i) / Math.max(1, filteredNodes.length);
const x = CENTER_X + CIRCLE_RADIUS * Math.cos(angle);
const y = CENTER_Y + CIRCLE_RADIUS * Math.sin(angle);
const isCurrent = n.id === currentCollection;
return {
...n,
position: { x, y },
className: isCurrent ? "!ring-2 !ring-accent-500 !rounded-lg" : "",
};
});
return { nodes, edges: filteredEdges };
}
type Props = { currentCollection: string };
export function TypeDependencyGraph({ currentCollection }: Props) {
const router = useRouter();
const onNodeClick = useCallback(
(_: React.MouseEvent, node: Node) => {
router.push(`/content/${node.id}`);
},
[router]
);
const { data: collectionsData } = useQuery({
queryKey: ["collections"],
queryFn: fetchCollections,
});
const names = useMemo(
() => (collectionsData?.collections ?? []).filter((c) => !c.name.startsWith("_")).map((c) => c.name),
[collectionsData]
);
const schemaQueries = useQueries({
queries: names.map((name) => ({
queryKey: ["schema", name],
queryFn: () => fetchSchema(name),
staleTime: 60_000,
})),
});
const schemas = useMemo(
() => schemaQueries.map((q) => (q.data ?? null) as SchemaDefinition | null),
[schemaQueries]
);
const { nodes, edges } = useMemo(
() => buildGraph(names, schemas, currentCollection),
[names, schemas, currentCollection]
);
const isLoading = schemaQueries.some((q) => q.isLoading);
if (names.length === 0 && !collectionsData) {
return (
<div className="flex h-[400px] items-center justify-center rounded-lg border border-gray-200 bg-gray-50/50 text-gray-500">
Loading collections
</div>
);
}
if (nodes.length === 0) {
return (
<div className="flex h-[400px] items-center justify-center rounded-lg border border-gray-200 bg-gray-50/50 text-gray-500">
No content types to display.
</div>
);
}
return (
<div className="h-[500px] w-full rounded-lg border border-gray-200 bg-white">
{isLoading && (
<div className="absolute left-2 top-2 rounded bg-white/90 px-2 py-1 text-xs text-gray-600 shadow">
Loading schemas
</div>
)}
<ReactFlow
nodes={nodes}
edges={edges}
onNodeClick={onNodeClick}
fitView
fitViewOptions={{ padding: 0.2 }}
minZoom={0.2}
maxZoom={1.5}
defaultEdgeOptions={{ zIndex: 0 }}
proOptions={{ hideAttribution: true }}
>
<Background gap={12} size={1} />
<Controls showInteractive={false} />
<MiniMap nodeStrokeWidth={3} zoomable pannable />
<Panel position="bottom-left" className="text-xs text-gray-500">
extends / references
</Panel>
</ReactFlow>
</div>
);
}

View File

@@ -5,20 +5,20 @@ import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 no-underline",
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 no-underline",
{
variants: {
variant: {
default:
"bg-accent-700 text-white hover:bg-accent-800 focus-visible:ring-accent-500/40",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20",
outline:
"border border-accent-200 bg-background text-accent-800 shadow-xs hover:bg-accent-50 dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
"border border-accent-200 bg-background text-accent-800 shadow-xs hover:bg-accent-50",
secondary:
"bg-accent-100 text-accent-800 border border-accent-200/80 hover:bg-accent-200/80 hover:border-accent-300",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
"hover:bg-accent hover:text-accent-foreground",
link: "text-accent-700 underline-offset-4 hover:text-accent-800 hover:underline",
},
size: {

View File

@@ -8,7 +8,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
type={type}
data-slot="input"
className={cn(
"h-9 w-full min-w-0 rounded-md border border-input bg-background text-foreground px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"h-9 w-full min-w-0 rounded-md border border-input bg-background text-foreground px-3 py-1 text-sm shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
"aria-invalid:border-destructive aria-invalid:ring-destructive/20",
className

View File

@@ -37,7 +37,7 @@ function SelectTrigger({
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-2 rounded-md border border-input bg-background text-foreground px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
"flex w-fit min-w-0 items-center justify-between gap-2 rounded-md border border-input bg-background text-foreground h-9 px-3 py-1 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground data-[size=sm]:h-8 data-[size=sm]:py-1 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
className
)}
{...props}

View File

@@ -7,6 +7,9 @@ export const getBaseUrl = () =>
process.env.NEXT_PUBLIC_RUSTYCMS_API_URL || "http://127.0.0.1:3000";
const STORAGE_KEY = "rustycms_admin_api_key";
const PER_PAGE_KEY = "rustycms_per_page";
const DEFAULT_PER_PAGE = 25;
const PER_PAGE_OPTIONS = [10, 25, 50, 100] as const;
/** Client-side only: key set by login when no env key. */
let clientApiKey: string | null = null;
@@ -38,6 +41,47 @@ export function syncStoredApiKey(): void {
if (stored) clientApiKey = stored;
}
/** Items per page for content lists (stored in localStorage). */
export function getPerPage(): number {
if (typeof window === "undefined") return DEFAULT_PER_PAGE;
const v = localStorage.getItem(PER_PAGE_KEY);
const n = v ? parseInt(v, 10) : NaN;
return PER_PAGE_OPTIONS.includes(n as (typeof PER_PAGE_OPTIONS)[number])
? n
: DEFAULT_PER_PAGE;
}
export function setPerPage(n: number): void {
if (typeof window === "undefined") return;
if (PER_PAGE_OPTIONS.includes(n as (typeof PER_PAGE_OPTIONS)[number])) {
localStorage.setItem(PER_PAGE_KEY, String(n));
}
}
export { PER_PAGE_OPTIONS, DEFAULT_PER_PAGE };
/** Clear API key and all rustycms_* localStorage (e.g. for shared devices). */
export function clearSession(): void {
setApiKey(null);
if (typeof window === "undefined") return;
const keys: string[] = [];
for (let i = 0; i < localStorage.length; i++) {
const k = localStorage.key(i);
if (k?.startsWith("rustycms_")) keys.push(k);
}
keys.forEach((k) => localStorage.removeItem(k));
}
/** Check backend health (GET /health). */
export async function fetchHealth(): Promise<{ ok: boolean; status?: number }> {
try {
const res = await fetch(`${getBaseUrl()}/health`, { cache: "no-store" });
return { ok: res.ok, status: res.status };
} catch {
return { ok: false };
}
}
const getHeaders = (): HeadersInit => {
const headers: HeadersInit = {
"Content-Type": "application/json",
@@ -68,11 +112,19 @@ export type FieldDefinition = {
description?: string;
collection?: string;
collections?: string[];
/** Optional whitelist of allowed slugs for reference fields. Only these slugs are valid. */
allowedSlugs?: string[];
/** Optional whitelist of allowed content types (collections). Only these collections are valid (intersection with collection/collections). */
allowedCollections?: string[];
enum?: unknown[];
default?: unknown;
items?: FieldDefinition;
/** Optional section key for grouping fields in the admin UI (collapsible blocks). */
section?: string;
/** Optional hint for admin UI (e.g. "textarea" for string → multi-line input, "code" for code field with syntax highlighting). */
widget?: string;
/** When widget is "code", language for syntax highlighting: "css", "javascript", "json", "html". */
codeLanguage?: string;
[key: string]: unknown;
};
@@ -221,6 +273,25 @@ export async function fetchEntry<T = Record<string, unknown>>(
return res.json();
}
/** Referrer: an entry that references another (from GET .../referrers). */
export type Referrer = {
collection: string;
slug: string;
field: string;
locale?: string | null;
};
export async function fetchReferrers(
collection: string,
slug: string
): Promise<Referrer[]> {
const url = `${getBaseUrl()}/api/content/${encodeURIComponent(collection)}/${encodeURIComponent(slug)}/referrers`;
const res = await fetch(url, { headers: getHeaders() });
if (!res.ok) return [];
const data = await res.json();
return Array.isArray(data) ? data : [];
}
export async function createEntry(
collection: string,
data: Record<string, unknown>,