Enhance admin UI and schema management: Introduce generic handling for image/asset URL fields, ensuring explicit widget usage for image previews. Update translations for new UI elements and implement field ordering in schema definitions. Add functionality for managing field extensions and improve asset filtering in the admin UI.

This commit is contained in:
Peter Meier
2026-03-14 00:08:52 +01:00
parent 084a1d9e2a
commit 11d46049d1
23 changed files with 662 additions and 117 deletions

View File

@@ -115,6 +115,11 @@ Normalisierung: `admin-ui/src/components/ReferenceOrInlineField.tsx` via `normal
- **Scroll**: `html, body { height: 100%; overflow: hidden }` — Sidebar scrollt unabhängig von der Seite
- **Neue Komponente**: immer prüfen ob Übersetzungs-Namespace in beiden Message-Dateien vorhanden
### Asset-/Bild-URL-Felder (generisch halten)
- **Keine Heuristik**: Die Admin-UI soll String-Felder nicht anhand von Feldname oder Beschreibung (z.B. „Image URL“) als Bild-URL interpretieren. Das wäre nicht generisch.
- **Explizites Widget**: Bildvorschau + Asset-Picker nur anzeigen, wenn im Schema für das Feld **explizit** `widget: "imageUrl"` (oder `"assetUrl"`) gesetzt ist. Implementierung: `ContentForm.tsx` Entscheidung nur über `def.widget === "imageUrl"`, nicht über `isImageUrlField()` mit Name/Description.
- **Typ `img`**: Bleibt generisch (kein Widget auf `src`) → normales Textfeld. Wo gewollt, kann ein eigener Typ oder ein Feld mit `widget: "imageUrl"` die Bild-UI bekommen.
## Axum Routing
Literale Routen haben Vorrang vor Wildcard-Routen — Reihenfolge egal, Axum löst korrekt auf:

View File

@@ -245,6 +245,7 @@
"stringWidgetSingleline": "Einzeilig",
"stringWidgetTextarea": "Mehrzeilig (Textbereich)",
"stringWidgetCode": "Code (Syntax-Hervorhebung)",
"stringWidgetImageUrl": "Bild- / Asset-URL (Vorschau + Picker)",
"codeLanguageLabel": "Code-Sprache",
"codeLanguageCss": "CSS",
"codeLanguageJavascript": "JavaScript",
@@ -280,8 +281,18 @@
"tagsLabel": "Tags (kommagetrennt)",
"tagsPlaceholder": "z.\u00a0B. inhalt, blog",
"strictLabel": "Strikt (unbekannte Felder ablehnen)",
"extendsLabel": "Erweitert",
"extendsDescription": "Dieser Typ erbt Felder von diesen Typen. Gehe zum jeweiligen Typ, um geerbte Felder zu bearbeiten.",
"extendsPlaceholder": "Typ zum Erweitern wählen",
"extendsNoneAvailable": "Keine weiteren Typen zum Hinzufügen",
"addExtend": "Hinzufügen",
"removeExtend": "Aus Erweiterungen entfernen",
"fieldsLabel": "Felder",
"addField": "Feld hinzuf\u00fcgen",
"moveFieldUp": "Feld nach oben",
"moveFieldDown": "Feld nach unten",
"moveFieldToTop": "Ganz nach oben",
"moveFieldToBottom": "Ganz nach unten",
"fieldNamePlaceholder": "Feldname",
"fieldTypeLabel": "Feldtyp",
"required": "Pflichtfeld",
@@ -289,6 +300,10 @@
"collectionPlaceholder": "Sammlung (z.\u00a0B. seite)",
"allowedSlugsPlaceholder": "Erlaubte Slugs (kommagetrennt, optional)",
"allowedCollectionsPlaceholder": "Erlaubte Inhaltstypen (kommagetrennt, optional)",
"patternLabel": "Pattern (Regex)",
"patternPlaceholder": "z. B. ^[A-Z]{2,4}-\\d{3,6}$",
"minLengthLabel": "Min. Länge",
"maxLengthLabel": "Max. Länge",
"arrayItemType": "Array-Elementtyp",
"itemTypePlaceholder": "z.\u00a0B. string, reference",
"arrayExplain": "Dieses Feld ist in JSON eine Liste [ ]. Jeder Eintrag hat denselben Typ—w\u00e4hle unten, was ein Element ist.",
@@ -308,6 +323,7 @@
"stringWidgetSingleline": "Einzeilig",
"stringWidgetTextarea": "Mehrzeilig (Textbereich)",
"stringWidgetCode": "Code (Syntax-Hervorhebung)",
"stringWidgetImageUrl": "Bild- / Asset-URL (Vorschau + Picker)",
"codeLanguageLabel": "Code-Sprache",
"codeLanguageCss": "CSS",
"codeLanguageJavascript": "JavaScript",
@@ -379,6 +395,8 @@
"searchPlaceholder": "Nach Dateiname suchen…",
"dateFrom": "Von Datum",
"dateTo": "Bis Datum",
"mimeFilter": "Typ",
"mimeFilterAll": "Alle Typen",
"upload": "Hochladen",
"uploading": "Wird hochgeladen…",
"uploadedCount": "{count} Datei(en) hochgeladen.",

View File

@@ -245,6 +245,7 @@
"stringWidgetSingleline": "Single line",
"stringWidgetTextarea": "Multi-line (textarea)",
"stringWidgetCode": "Code (syntax highlighting)",
"stringWidgetImageUrl": "Image / asset URL (preview + picker)",
"codeLanguageLabel": "Code language",
"codeLanguageCss": "CSS",
"codeLanguageJavascript": "JavaScript",
@@ -280,8 +281,18 @@
"tagsLabel": "Tags (comma-separated)",
"tagsPlaceholder": "e.g. content, blog",
"strictLabel": "Strict (reject unknown fields)",
"extendsLabel": "Extends",
"extendsDescription": "This type inherits fields from these types. Edit the type there to change inherited fields.",
"extendsPlaceholder": "Select type to extend",
"extendsNoneAvailable": "No other types to add",
"addExtend": "Add",
"removeExtend": "Remove from extends",
"fieldsLabel": "Fields",
"addField": "Add field",
"moveFieldUp": "Move field up",
"moveFieldDown": "Move field down",
"moveFieldToTop": "Move to top",
"moveFieldToBottom": "Move to bottom",
"fieldNamePlaceholder": "Field name",
"fieldTypeLabel": "Field type",
"required": "Required",
@@ -289,6 +300,10 @@
"collectionPlaceholder": "Collection (e.g. page)",
"allowedSlugsPlaceholder": "Allowed slugs (comma-separated, optional)",
"allowedCollectionsPlaceholder": "Allowed content types (comma-separated, optional)",
"patternLabel": "Pattern (regex)",
"patternPlaceholder": "e.g. ^[A-Z]{2,4}-\\d{3,6}$",
"minLengthLabel": "Min length",
"maxLengthLabel": "Max length",
"arrayItemType": "Array item type",
"itemTypePlaceholder": "e.g. string, reference",
"arrayExplain": "This field is a list [ ] in JSON. Every position in the list has the same type—pick what one entry is below.",
@@ -308,6 +323,7 @@
"stringWidgetSingleline": "Single line",
"stringWidgetTextarea": "Multi-line (textarea)",
"stringWidgetCode": "Code (syntax highlighting)",
"stringWidgetImageUrl": "Image / asset URL (preview + picker)",
"codeLanguageLabel": "Code language",
"codeLanguageCss": "CSS",
"codeLanguageJavascript": "JavaScript",
@@ -379,6 +395,8 @@
"searchPlaceholder": "Search by filename…",
"dateFrom": "From date",
"dateTo": "To date",
"mimeFilter": "Type",
"mimeFilterAll": "All types",
"upload": "Upload",
"uploading": "Uploading…",
"uploadedCount": "Uploaded {count} file(s).",

View File

@@ -184,7 +184,7 @@ export default function NewTypePage() {
};
return (
<div className="max-w-2xl">
<div className="max-w-2xl p-4 sm:p-5 md:p-6">
<h1 className="mb-6 text-2xl font-semibold text-gray-900">{t("title")}</h1>
<p className="mb-6 text-sm text-gray-600">
{t("description", { path: "types/<name>.json" })}

View File

@@ -6,7 +6,7 @@ import Link from "next/link";
import { Icon } from "@iconify/react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { fetchSchema, updateSchema, type SchemaDefinition, type FieldDefinition } from "@/lib/api";
import { fetchSchemaRaw, fetchSchemaNames, updateSchema, type SchemaDefinition, type FieldDefinition } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -39,6 +39,9 @@ type FieldRow = {
required: boolean;
description: string;
defaultValue: string;
pattern: string;
minLength: string;
maxLength: string;
collection: string;
allowedSlugs: string;
allowedCollections: string;
@@ -51,6 +54,8 @@ type FieldRow = {
itemAllowedCollections: string;
itemSubFields: ItemSubFieldRow[];
original?: FieldDefinition;
/** Whether the field card is collapsed in the UI (default true). */
collapsed: boolean;
};
function nextId() {
@@ -59,7 +64,16 @@ function nextId() {
function schemaToFieldRows(schema: SchemaDefinition): FieldRow[] {
const fields = schema.fields ?? {};
return Object.entries(fields).map(([name, def]) => {
const order = schema.fieldOrder;
const names = order?.length
? [
...order.filter((n) => n in fields),
...Object.keys(fields).filter((n) => !order.includes(n)),
]
: Object.keys(fields);
return names.map((name) => {
const def = fields[name]!;
const allowedSlugs = Array.isArray(def.allowedSlugs) ? def.allowedSlugs.join(", ") : "";
const allowedCollections = Array.isArray(def.allowedCollections) ? def.allowedCollections.join(", ") : "";
const enumOpt = def.enum;
@@ -73,6 +87,11 @@ function schemaToFieldRows(schema: SchemaDefinition): FieldRow[] {
defaultVal !== undefined && defaultVal !== null
? JSON.stringify(defaultVal)
: "";
const pattern = (def.pattern as string) ?? "";
const minLen = def.minLength as number | undefined;
const maxLen = def.maxLength as number | undefined;
const minLength = minLen != null ? String(minLen) : "";
const maxLength = maxLen != null ? String(maxLen) : "";
const items = def.items as FieldDefinition | undefined;
const it = items?.type;
const itemType =
@@ -97,6 +116,9 @@ function schemaToFieldRows(schema: SchemaDefinition): FieldRow[] {
required: !!def.required,
description: (def.description as string) ?? "",
defaultValue,
pattern,
minLength,
maxLength,
collection: (def.collection as string) ?? "",
allowedSlugs,
allowedCollections,
@@ -109,6 +131,7 @@ function schemaToFieldRows(schema: SchemaDefinition): FieldRow[] {
itemAllowedCollections,
itemSubFields,
original: def,
collapsed: true,
};
});
}
@@ -121,8 +144,8 @@ export default function EditTypePage() {
const queryClient = useQueryClient();
const { data: schema, isLoading, error: fetchError } = useQuery({
queryKey: ["schema", name],
queryFn: () => fetchSchema(name),
queryKey: ["schema-raw", name],
queryFn: () => fetchSchemaRaw(name),
enabled: !!name,
});
@@ -130,7 +153,18 @@ export default function EditTypePage() {
const [category, setCategory] = useState("");
const [tagsStr, setTagsStr] = useState("");
const [strict, setStrict] = useState(false);
const [extendsList, setExtendsList] = useState<string[]>([]);
const [selectedExtend, setSelectedExtend] = useState<string>("");
const [fields, setFields] = useState<FieldRow[]>([]);
const { data: schemaNamesData } = useQuery({
queryKey: ["schema-names"],
queryFn: fetchSchemaNames,
});
const allTypeNames = schemaNamesData?.names ?? [];
const availableExtendNames = allTypeNames.filter(
(n) => n !== name && !extendsList.includes(n)
);
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
@@ -140,14 +174,26 @@ export default function EditTypePage() {
setCategory(schema.category ?? "");
setTagsStr(schema.tags?.length ? schema.tags.join(", ") : "");
setStrict(!!schema.strict);
const ext = schema.extends;
setExtendsList(ext ? (Array.isArray(ext) ? [...ext] : [ext]) : []);
setFields(schemaToFieldRows(schema));
}
}, [schema]);
const addExtend = () => {
if (!selectedExtend || selectedExtend === name) return;
setExtendsList((prev) => (prev.includes(selectedExtend) ? prev : [...prev, selectedExtend]));
setSelectedExtend("");
};
const removeExtend = (typeName: string) => {
setExtendsList((prev) => prev.filter((t) => t !== typeName));
};
const addField = () => {
setFields((prev) => [
...prev,
{ 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" }] },
{ id: nextId(), name: "", type: "string", required: false, description: "", defaultValue: "", pattern: "", minLength: "", maxLength: "", collection: "", allowedSlugs: "", allowedCollections: "", enumOptions: "", widget: "", codeLanguage: "", itemType: "", itemCollection: "", itemAllowedSlugs: "", itemAllowedCollections: "", itemSubFields: [{ id: nextId(), name: "", type: "string" }], collapsed: true },
]);
};
@@ -155,6 +201,48 @@ export default function EditTypePage() {
setFields((prev) => (prev.length <= 1 ? prev : prev.filter((f) => f.id !== id)));
};
const moveFieldUp = (id: string) => {
setFields((prev) => {
const i = prev.findIndex((f) => f.id === id);
if (i <= 0) return prev;
const next = [...prev];
[next[i - 1], next[i]] = [next[i], next[i - 1]];
return next;
});
};
const moveFieldDown = (id: string) => {
setFields((prev) => {
const i = prev.findIndex((f) => f.id === id);
if (i < 0 || i >= prev.length - 1) return prev;
const next = [...prev];
[next[i], next[i + 1]] = [next[i + 1], next[i]];
return next;
});
};
const moveFieldToTop = (id: string) => {
setFields((prev) => {
const i = prev.findIndex((f) => f.id === id);
if (i <= 0) return prev;
const next = [...prev];
const [item] = next.splice(i, 1);
next.unshift(item);
return next;
});
};
const moveFieldToBottom = (id: string) => {
setFields((prev) => {
const i = prev.findIndex((f) => f.id === id);
if (i < 0 || i >= prev.length - 1) return prev;
const next = [...prev];
const [item] = next.splice(i, 1);
next.push(item);
return next;
});
};
const updateField = (id: string, patch: Partial<FieldRow>) => {
setFields((prev) => prev.map((f) => (f.id === id ? { ...f, ...patch } : f)));
};
@@ -198,6 +286,16 @@ export default function EditTypePage() {
.filter(Boolean);
if (opts.length) field.enum = opts;
}
if (row.type === "string" || row.type === "richtext" || row.type === "html" || row.type === "markdown") {
if (row.pattern.trim()) field.pattern = row.pattern.trim();
else delete field.pattern;
const minL = row.minLength.trim() ? parseInt(row.minLength, 10) : NaN;
const maxL = row.maxLength.trim() ? parseInt(row.maxLength, 10) : NaN;
if (!Number.isNaN(minL) && minL >= 0) field.minLength = minL;
else delete field.minLength;
if (!Number.isNaN(maxL) && maxL >= 0) field.maxLength = maxL;
else delete field.maxLength;
}
if (row.type === "string") {
if (row.widget.trim() === "textarea") {
field.widget = "textarea";
@@ -206,6 +304,9 @@ export default function EditTypePage() {
field.widget = "code";
const lang = row.codeLanguage?.trim();
field.codeLanguage = CODE_LANGUAGES.includes(lang as (typeof CODE_LANGUAGES)[number]) ? lang : "javascript";
} else if (row.widget.trim() === "imageUrl" || row.widget.trim() === "assetUrl") {
field.widget = row.widget.trim() as "imageUrl" | "assetUrl";
delete field.codeLanguage;
} else {
delete field.widget;
delete field.codeLanguage;
@@ -261,6 +362,8 @@ export default function EditTypePage() {
category: category.trim() || undefined,
tags: tagsStr.trim() ? tagsStr.split(",").map((t) => t.trim()).filter(Boolean) : undefined,
strict,
extends: extendsList.length > 0 ? (extendsList.length === 1 ? extendsList[0] : extendsList) : undefined,
fieldOrder: fieldNames.length > 0 ? fieldNames : undefined,
fields: fieldsObj,
};
@@ -268,7 +371,7 @@ export default function EditTypePage() {
try {
await updateSchema(name, payload);
await queryClient.invalidateQueries({ queryKey: ["collections"] });
await queryClient.invalidateQueries({ queryKey: ["schema", name] });
await queryClient.invalidateQueries({ queryKey: ["schema-raw", name] });
router.push("/admin/types");
router.refresh();
} catch (err) {
@@ -279,7 +382,7 @@ export default function EditTypePage() {
if (!name) {
return (
<div>
<div className="p-4 sm:p-5 md:p-6">
<p className="text-gray-500">{t("missingName")}</p>
<Link href="/admin/types" className="link-accent mt-2 inline-block">{t("backToTypes")}</Link>
</div>
@@ -287,12 +390,12 @@ export default function EditTypePage() {
}
if (isLoading || !schema) {
return <p className="text-gray-500">{t("loading")}</p>;
return <p className="p-4 sm:p-5 md:p-6 text-gray-500">{t("loading")}</p>;
}
if (fetchError) {
return (
<div>
<div className="p-4 sm:p-5 md:p-6">
<p className="text-red-600">{t("errorLoading", { error: String(fetchError) })}</p>
<Link href="/admin/types" className="link-accent mt-2 inline-block">{t("backToTypes")}</Link>
</div>
@@ -300,7 +403,7 @@ export default function EditTypePage() {
}
return (
<div className="max-w-2xl">
<div className="max-w-2xl p-4 sm:p-5 md:p-6">
<h1 className="mb-6 text-2xl font-semibold text-gray-900">{t("title", { name })}</h1>
<p className="mb-6 text-sm text-gray-600">{t("description")}</p>
@@ -353,6 +456,67 @@ export default function EditTypePage() {
<label htmlFor="strict" className="text-sm text-gray-700">{t("strictLabel")}</label>
</div>
<div className="rounded-lg border border-gray-200 bg-gray-50/50 p-4">
<Label className="mb-1 block font-medium">{t("extendsLabel")}</Label>
<p className="mb-3 text-sm text-gray-600">{t("extendsDescription")}</p>
{extendsList.length > 0 && (
<ul className="mb-3 flex flex-wrap gap-2">
{extendsList.map((typeName) => (
<li key={typeName} className="inline-flex items-center gap-1 rounded-md border border-gray-300 bg-white pr-1 shadow-sm">
<Link
href={`/admin/types/${encodeURIComponent(typeName)}/edit`}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm text-gray-800 hover:bg-gray-50"
>
<Icon icon="mdi:file-document-outline" className="size-4 text-gray-500" aria-hidden />
{typeName}
</Link>
<button
type="button"
onClick={() => removeExtend(typeName)}
title={t("removeExtend")}
aria-label={t("removeExtend")}
className="rounded p-1 text-gray-500 hover:bg-red-50 hover:text-red-700"
>
<Icon icon="mdi:close" className="size-4" aria-hidden />
</button>
</li>
))}
</ul>
)}
<div className="flex gap-2">
<Select
value={selectedExtend}
onValueChange={setSelectedExtend}
>
<SelectTrigger className="w-[280px]">
<SelectValue placeholder={t("extendsPlaceholder")} />
</SelectTrigger>
<SelectContent>
{availableExtendNames.map((typeName) => (
<SelectItem key={typeName} value={typeName}>
{typeName}
</SelectItem>
))}
{availableExtendNames.length === 0 && (
<SelectItem value="__none__" disabled>
{t("extendsNoneAvailable")}
</SelectItem>
)}
</SelectContent>
</Select>
<Button
type="button"
variant="outline"
size="sm"
onClick={addExtend}
disabled={!selectedExtend}
>
<Icon icon="mdi:plus" className="size-4" aria-hidden />
{t("addExtend")}
</Button>
</div>
</div>
<div>
<div className="mb-2 flex items-center justify-between">
<Label>{t("fieldsLabel")}</Label>
@@ -362,21 +526,90 @@ export default function EditTypePage() {
</Button>
</div>
<div className="space-y-3 rounded-lg border border-gray-200 bg-gray-50/50 p-4">
{fields.map((f) => (
{fields.map((f, index) => (
<div
key={f.id}
className="flex flex-col gap-3 rounded border border-gray-200 bg-white p-4"
className="flex flex-col gap-3 rounded border border-gray-300 bg-white p-4"
>
<div>
<Label className="mb-1 block text-xs font-medium text-gray-600">{t("fieldNamePlaceholder")}</Label>
<div className="flex items-center justify-between gap-2 rounded-md bg-accent-50/60 px-3 py-2 -mx-1 border border-accent-100/80">
<button
type="button"
onClick={() => updateField(f.id, { collapsed: !f.collapsed })}
className="flex min-w-0 flex-1 items-center gap-2 text-left"
aria-expanded={!f.collapsed}
>
<Icon
icon={f.collapsed ? "mdi:chevron-right" : "mdi:chevron-down"}
className="size-5 shrink-0 text-accent-600"
aria-hidden
/>
<span className="truncate text-xs font-bold text-accent-800">
{f.name.trim() || t("fieldNamePlaceholder")}
</span>
{f.type && (
<span className="truncate text-xs text-accent-600">({f.type})</span>
)}
</button>
<div className="flex shrink-0 gap-0.5">
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 text-gray-500 hover:text-gray-700"
disabled={index === 0}
onClick={() => moveFieldToTop(f.id)}
title={t("moveFieldToTop")}
aria-label={t("moveFieldToTop")}
>
<Icon icon="mdi:chevron-double-up" className="size-4" aria-hidden />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 text-gray-500 hover:text-gray-700"
disabled={index === 0}
onClick={() => moveFieldUp(f.id)}
title={t("moveFieldUp")}
aria-label={t("moveFieldUp")}
>
<Icon icon="mdi:chevron-up" className="size-4" aria-hidden />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 text-gray-500 hover:text-gray-700"
disabled={index === fields.length - 1}
onClick={() => moveFieldDown(f.id)}
title={t("moveFieldDown")}
aria-label={t("moveFieldDown")}
>
<Icon icon="mdi:chevron-down" className="size-4" aria-hidden />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 text-gray-500 hover:text-gray-700"
disabled={index === fields.length - 1}
onClick={() => moveFieldToBottom(f.id)}
title={t("moveFieldToBottom")}
aria-label={t("moveFieldToBottom")}
>
<Icon icon="mdi:chevron-double-down" className="size-4" aria-hidden />
</Button>
</div>
</div>
{!f.collapsed && (
<>
<Input
type="text"
value={f.name}
onChange={(e) => updateField(f.id, { name: e.target.value })}
placeholder={t("fieldNamePlaceholder")}
className="h-9 text-sm"
className="h-9 text-sm bg-white border-accent-200"
/>
</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 })}>
@@ -400,6 +633,42 @@ export default function EditTypePage() {
className="h-9 text-sm"
/>
</div>
{(f.type === "string" || f.type === "richtext" || f.type === "html" || f.type === "markdown") && (
<div className="grid gap-2 sm:grid-cols-3">
<div>
<Label className="mb-1 block text-xs font-medium text-gray-600">{t("patternLabel")}</Label>
<Input
type="text"
value={f.pattern}
onChange={(e) => updateField(f.id, { pattern: e.target.value })}
placeholder={t("patternPlaceholder") || "e.g. ^[A-Z]{2,4}-\\d{3,6}$"}
className="h-9 font-mono text-sm"
/>
</div>
<div>
<Label className="mb-1 block text-xs font-medium text-gray-600">{t("minLengthLabel")}</Label>
<Input
type="number"
min={0}
value={f.minLength}
onChange={(e) => updateField(f.id, { minLength: e.target.value })}
placeholder="—"
className="h-9 text-sm"
/>
</div>
<div>
<Label className="mb-1 block text-xs font-medium text-gray-600">{t("maxLengthLabel")}</Label>
<Input
type="number"
min={0}
value={f.maxLength}
onChange={(e) => updateField(f.id, { maxLength: e.target.value })}
placeholder="—"
className="h-9 text-sm"
/>
</div>
</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">
@@ -465,10 +734,15 @@ export default function EditTypePage() {
<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"}
value={
f.widget === "code" ? "code"
: f.widget === "textarea" ? "textarea"
: f.widget === "imageUrl" || f.widget === "assetUrl" ? "imageUrl"
: "singleline"
}
onValueChange={(v) =>
updateField(f.id, {
widget: v === "code" ? "code" : v === "textarea" ? "textarea" : "",
widget: v === "code" ? "code" : v === "textarea" ? "textarea" : v === "imageUrl" ? "imageUrl" : "",
codeLanguage: v === "code" && !f.codeLanguage ? "javascript" : v === "code" ? f.codeLanguage : "",
})
}
@@ -480,6 +754,7 @@ export default function EditTypePage() {
<SelectItem value="singleline">{t("stringWidgetSingleline")}</SelectItem>
<SelectItem value="textarea">{t("stringWidgetTextarea")}</SelectItem>
<SelectItem value="code">{t("stringWidgetCode")}</SelectItem>
<SelectItem value="imageUrl">{t("stringWidgetImageUrl")}</SelectItem>
</SelectContent>
</Select>
</div>
@@ -628,6 +903,8 @@ export default function EditTypePage() {
)}
</div>
)}
</>
)}
</div>
))}
</div>

View File

@@ -91,7 +91,7 @@ export default function TypesPage() {
};
return (
<div>
<div className="p-4 sm:p-5 md:p-6">
<div className="mb-6 flex items-center justify-between">
<h1 className="text-2xl font-semibold text-gray-900">{t("title")}</h1>
<Button asChild>

View File

@@ -38,6 +38,13 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
const API_BASE = process.env.NEXT_PUBLIC_RUSTYCMS_API_URL ?? "http://127.0.0.1:3000";
const ALL = "__all__";
@@ -75,6 +82,7 @@ export default function AssetsPage() {
const q = searchParams.get("_q") ?? "";
const dateFrom = searchParams.get("_from") ?? "";
const dateTo = searchParams.get("_to") ?? "";
const mimeFilter = searchParams.get("_mime") ?? "";
// Folder navigation: ALL | ROOT ("") | folder name
const [selected, setSelected] = useState<string>(ALL);
@@ -140,8 +148,11 @@ export default function AssetsPage() {
return true;
});
}
if (mimeFilter) {
list = list.filter((a) => a.mime_type === mimeFilter);
}
return list;
}, [rawAssets, q, dateFrom, dateTo]);
}, [rawAssets, q, dateFrom, dateTo, mimeFilter]);
// ── Upload ──────────────────────────────────────────────────────────────
@@ -281,6 +292,13 @@ export default function AssetsPage() {
router.push(`${pathname}?${sp.toString()}`);
}
function setMimeFilter(mime: string) {
const sp = new URLSearchParams(searchParams.toString());
if (mime) sp.set("_mime", mime);
else sp.delete("_mime");
router.push(`${pathname}?${sp.toString()}`);
}
// ── Upload hint ───────────────────────────────────────────────────────────
const uploadHint =
@@ -297,7 +315,7 @@ export default function AssetsPage() {
];
return (
<div className="-m-4 flex h-full min-h-0 flex-col md:-m-6 md:flex-row">
<div className="flex h-full min-h-0 flex-col md:flex-row">
{/* ── Folder sidebar (desktop) ── */}
<aside className="hidden w-52 shrink-0 flex-col border-r border-border bg-accent-50/40 md:flex">
<div className="flex items-center justify-between px-4 py-3">
@@ -436,14 +454,14 @@ export default function AssetsPage() {
{/* ── Main content ── */}
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
{/* Header */}
<div className="flex flex-col gap-3 border-b border-border px-4 py-3 md:px-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-col gap-2 border-b border-border px-4 py-2 md:px-6">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<h1 className="truncate text-base font-semibold text-foreground sm:text-lg">
<h1 className="truncate text-sm font-semibold text-foreground sm:text-base">
{selected === ALL ? t("titleAll") : selected === ROOT ? t("titleRoot") : selected}
</h1>
<p className="text-xs text-muted-foreground">
{assets.length === (assetsData?.total ?? 0) && !q.trim() && !dateFrom && !dateTo
<p className="text-[11px] text-muted-foreground">
{assets.length === (assetsData?.total ?? 0) && !q.trim() && !dateFrom && !dateTo && !mimeFilter
? t("assetCount", { count: assets.length })
: t("assetCountFiltered", { count: assets.length, total: assetsData?.total ?? 0 })}
</p>
@@ -452,29 +470,46 @@ export default function AssetsPage() {
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
size="sm"
className="min-h-[44px] w-full shrink-0 sm:w-auto"
className="h-8 w-full shrink-0 text-xs sm:w-auto"
>
<Icon icon="mdi:upload" className="size-4" aria-hidden />
<Icon icon="mdi:upload" className="size-3.5" aria-hidden />
{uploading ? t("uploading") : t("upload")}
</Button>
</div>
<div className="flex flex-wrap items-center gap-3">
<SearchBar placeholder={t("searchPlaceholder")} paramName="_q" />
<div className="flex flex-wrap items-center gap-2">
<label className="text-xs font-medium text-muted-foreground">{t("dateFrom")}</label>
<SearchBar placeholder={t("searchPlaceholder")} paramName="_q" compact />
<div className="flex items-center gap-1.5">
<label className="sr-only sm:not-sr-only sm:text-[11px] sm:font-medium sm:text-muted-foreground">{t("mimeFilter")}</label>
<Select value={mimeFilter || "__all__"} onValueChange={(v) => setMimeFilter(v === "__all__" ? "" : v)}>
<SelectTrigger size="sm" className="h-8 min-w-[120px] text-xs" aria-label={t("mimeFilter")}>
<SelectValue placeholder={t("mimeFilterAll")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">{t("mimeFilterAll")}</SelectItem>
<SelectItem value="image/jpeg">JPEG</SelectItem>
<SelectItem value="image/png">PNG</SelectItem>
<SelectItem value="image/webp">WebP</SelectItem>
<SelectItem value="image/avif">AVIF</SelectItem>
<SelectItem value="image/gif">GIF</SelectItem>
<SelectItem value="image/svg+xml">SVG</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-1.5">
<label className="sr-only sm:not-sr-only sm:text-[11px] sm:font-medium sm:text-muted-foreground">{t("dateFrom")}</label>
<input
type="date"
value={dateFrom}
onChange={(e) => setDateFilter(e.target.value, dateTo)}
className="h-9 rounded-md border border-input bg-background px-3 py-1 text-sm text-foreground touch-manipulation"
className="h-8 w-[130px] rounded-md border border-input bg-background px-2 py-0.5 text-xs text-foreground touch-manipulation"
aria-label={t("dateFrom")}
/>
<label className="text-xs font-medium text-muted-foreground">{t("dateTo")}</label>
<label className="sr-only sm:not-sr-only sm:text-[11px] sm:font-medium sm:text-muted-foreground">{t("dateTo")}</label>
<input
type="date"
value={dateTo}
onChange={(e) => setDateFilter(dateFrom, e.target.value)}
className="h-9 rounded-md border border-input bg-background px-3 py-1 text-sm text-foreground touch-manipulation"
className="h-8 w-[130px] rounded-md border border-input bg-background px-2 py-0.5 text-xs text-foreground touch-manipulation"
aria-label={t("dateTo")}
/>
</div>
@@ -491,8 +526,8 @@ export default function AssetsPage() {
onChange={(e) => handleFiles(e.target.files)}
/>
{/* Scrollable grid area */}
<div className="flex-1 overflow-y-auto p-4 md:p-6">
{/* Scrollable grid area (container: grid responds to this width, not viewport) */}
<div className="@container flex-1 overflow-y-auto p-4 md:p-6">
{/* Drop zone */}
<div
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
@@ -520,9 +555,9 @@ export default function AssetsPage() {
<p className="text-sm text-muted-foreground">{t("noAssets")}</p>
)}
{/* Grid */}
{/* Grid: 2 cols when container narrow, 4 cols when container wide */}
{assets.length > 0 && (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 sm:gap-4 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
<div className="grid grid-cols-1 gap-3 @sm:grid-cols-2 @lg:grid-cols-3 @lg:gap-4">
{assets.map((asset) => (
<AssetCard
key={assetPath(asset)}
@@ -913,54 +948,54 @@ function AssetCard({
</div>
{/* Mobile: always-visible action row (touch-friendly) */}
<div className="flex items-center justify-end gap-1 border-t border-border/50 px-2 py-1.5 md:hidden">
<button type="button" onClick={(e) => { e.stopPropagation(); onCopy(); }} className="inline-flex size-9 shrink-0 items-center justify-center rounded-md border border-border bg-white text-gray-700 hover:bg-accent-50 touch-manipulation" title="Copy URL" aria-label="Copy URL">
<Icon icon="mdi:content-copy" className="size-4" aria-hidden />
<div className="flex items-center justify-end gap-0.5 border-t border-border/50 px-1.5 py-1 md:hidden">
<button type="button" onClick={(e) => { e.stopPropagation(); onCopy(); }} className="inline-flex size-7 shrink-0 items-center justify-center rounded border border-border bg-white text-gray-700 hover:bg-accent-50 touch-manipulation" title="Copy URL" aria-label="Copy URL">
<Icon icon="mdi:content-copy" className="size-3.5" aria-hidden />
</button>
<button type="button" onClick={(e) => { e.stopPropagation(); onRename(); }} className="inline-flex size-9 shrink-0 items-center justify-center rounded-md border border-border bg-white text-gray-700 hover:bg-accent-50 touch-manipulation" title="Rename" aria-label="Rename">
<Icon icon="mdi:pencil" className="size-4" aria-hidden />
<button type="button" onClick={(e) => { e.stopPropagation(); onRename(); }} className="inline-flex size-7 shrink-0 items-center justify-center rounded border border-border bg-white text-gray-700 hover:bg-accent-50 touch-manipulation" title="Rename" aria-label="Rename">
<Icon icon="mdi:pencil" className="size-3.5" aria-hidden />
</button>
<button type="button" onClick={(e) => { e.stopPropagation(); onCopyTransform(); }} className="inline-flex size-9 shrink-0 items-center justify-center rounded-md border border-border bg-white text-gray-700 hover:bg-accent-50 touch-manipulation" title="Copy with transformation" aria-label="Copy with transformation">
<Icon icon="mdi:image-multiple-outline" className="size-4" aria-hidden />
<button type="button" onClick={(e) => { e.stopPropagation(); onCopyTransform(); }} className="inline-flex size-7 shrink-0 items-center justify-center rounded border border-border bg-white text-gray-700 hover:bg-accent-50 touch-manipulation" title="Copy with transformation" aria-label="Copy with transformation">
<Icon icon="mdi:image-multiple-outline" className="size-3.5" aria-hidden />
</button>
<button type="button" onClick={(e) => { e.stopPropagation(); onDelete(); }} className="inline-flex size-9 shrink-0 items-center justify-center rounded-md border border-border bg-white text-gray-700 hover:bg-destructive hover:text-white touch-manipulation" title="Delete" aria-label="Delete">
<Icon icon="mdi:trash-can-outline" className="size-4" aria-hidden />
<button type="button" onClick={(e) => { e.stopPropagation(); onDelete(); }} className="inline-flex size-7 shrink-0 items-center justify-center rounded border border-border bg-white text-gray-700 hover:bg-destructive hover:text-white touch-manipulation" title="Delete" aria-label="Delete">
<Icon icon="mdi:trash-can-outline" className="size-3.5" aria-hidden />
</button>
</div>
{/* Hover actions stopPropagation so click doesnt trigger thumbnail preview */}
<div className="absolute inset-x-0 top-0 hidden flex-wrap justify-end gap-0.5 p-1 opacity-0 transition-opacity group-hover:opacity-100 md:flex">
<div className="absolute inset-x-0 top-0 hidden flex-wrap justify-end gap-0.5 p-0.5 opacity-0 transition-opacity group-hover:opacity-100 md:flex">
<button
type="button"
onClick={(e) => { e.stopPropagation(); onCopy(); }}
title="Copy URL"
className="rounded bg-white/90 p-1 shadow-sm hover:bg-white"
className="rounded bg-white/90 p-0.5 shadow-sm hover:bg-white"
>
<Icon icon="mdi:content-copy" className="size-4 text-gray-700" aria-hidden />
<Icon icon="mdi:content-copy" className="size-3.5 text-gray-700" aria-hidden />
</button>
<button
type="button"
onClick={(e) => { e.stopPropagation(); onRename(); }}
title="Rename"
className="rounded bg-white/90 p-1 shadow-sm hover:bg-white"
className="rounded bg-white/90 p-0.5 shadow-sm hover:bg-white"
>
<Icon icon="mdi:pencil" className="size-4 text-gray-700" aria-hidden />
<Icon icon="mdi:pencil" className="size-3.5 text-gray-700" aria-hidden />
</button>
<button
type="button"
onClick={(e) => { e.stopPropagation(); onCopyTransform(); }}
title="Copy with transformation"
className="rounded bg-white/90 p-1 shadow-sm hover:bg-white"
className="rounded bg-white/90 p-0.5 shadow-sm hover:bg-white"
>
<Icon icon="mdi:image-multiple-outline" className="size-4 text-gray-700" aria-hidden />
<Icon icon="mdi:image-multiple-outline" className="size-3.5 text-gray-700" aria-hidden />
</button>
<button
type="button"
onClick={(e) => { e.stopPropagation(); onDelete(); }}
title="Delete"
className="rounded bg-white/90 p-1 shadow-sm hover:bg-destructive hover:text-white"
className="rounded bg-white/90 p-0.5 shadow-sm hover:bg-destructive hover:text-white"
>
<Icon icon="mdi:trash-can-outline" className="size-4" aria-hidden />
<Icon icon="mdi:trash-can-outline" className="size-3.5" aria-hidden />
</button>
</div>
</div>

View File

@@ -6,7 +6,7 @@ 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, fetchReferrers } from "@/lib/api";
import { fetchSchema, fetchEntry, fetchLocales, fetchReferrers, getBaseUrl, collapseAssetUrlsForAdmin } from "@/lib/api";
import { ContentForm } from "@/components/ContentForm";
import { SchemaAndPreviewBar } from "@/components/SchemaAndPreviewBar";
import { ContentLocaleSwitcher } from "@/components/ContentLocaleSwitcher";
@@ -38,9 +38,10 @@ export default function ContentEditPage() {
isLoading: entryLoading,
error: entryError,
} = useQuery({
queryKey: ["entry", collection, slug, locale ?? ""],
queryKey: ["entry", collection, slug, locale ?? "", "all"],
queryFn: () =>
fetchEntry(collection, slug, {
_resolve: "all",
...(locale ? { _locale: locale } : {}),
}),
enabled: !!collection && !!slug,
@@ -78,8 +79,8 @@ export default function ContentEditPage() {
if (!collection || !slug) {
return (
<div className="rounded bg-amber-50 p-4 text-amber-800">
Missing collection or slug.
<div className="p-4 sm:p-5 md:p-6">
<div className="rounded bg-amber-50 p-4 text-amber-800">Missing collection or slug.</div>
</div>
);
}
@@ -90,7 +91,7 @@ export default function ContentEditPage() {
const error = schemaError ?? entryError;
return (
<div>
<div className="p-4 sm:p-5 md:p-6">
<Breadcrumbs
items={[
{ label: tBread("content"), href: "/" },
@@ -225,7 +226,7 @@ export default function ContentEditPage() {
<ContentForm
collection={collection}
schema={schema}
initialValues={entry as Record<string, unknown>}
initialValues={collapseAssetUrlsForAdmin(entry, getBaseUrl()) as Record<string, unknown>}
slug={slug}
locale={locale}
onSuccess={onSuccess}

View File

@@ -37,8 +37,8 @@ export default function ContentNewPage() {
if (!collection) {
return (
<div className="rounded bg-amber-50 p-4 text-amber-800">
Missing collection name.
<div className="p-4 sm:p-5 md:p-6">
<div className="rounded bg-amber-50 p-4 text-amber-800">Missing collection name.</div>
</div>
);
}
@@ -56,7 +56,7 @@ export default function ContentNewPage() {
}, [collection]);
return (
<div>
<div className="p-4 sm:p-5 md:p-6">
<Breadcrumbs
items={[
{ label: tBread("content"), href: "/" },

View File

@@ -83,8 +83,8 @@ export default function ContentListPage() {
if (!collection) {
return (
<div className="rounded bg-amber-50 p-4 text-amber-800">
Missing collection name.
<div className="p-4 sm:p-5 md:p-6">
<div className="rounded bg-amber-50 p-4 text-amber-800">Missing collection name.</div>
</div>
);
}
@@ -130,7 +130,7 @@ export default function ContentListPage() {
};
return (
<div>
<div className="p-4 sm:p-5 md:p-6">
<Breadcrumbs
items={[
{ label: tBread("content"), href: "/" },

View File

@@ -15,7 +15,7 @@ export default async function DashboardPage() {
}
return (
<div>
<div className="p-4 sm:p-5 md:p-6">
<h1 className="mb-6 text-2xl font-semibold text-gray-900">{t("title")}</h1>
<div className="mb-6 flex flex-wrap items-center gap-3">
<p className="text-gray-600">

View File

@@ -4,7 +4,7 @@ import { SettingsContent } from "./SettingsContent";
export default async function SettingsPage() {
const locale = await getLocale();
return (
<div>
<div className="p-4 sm:p-5 md:p-6">
<SettingsContent locale={locale} />
</div>
);

View File

@@ -46,8 +46,10 @@ export function AppShell({ locale, children }: AppShellProps) {
</button>
<span className="text-sm font-semibold text-gray-900 truncate">RustyCMS Admin</span>
</header>
<main className="min-h-0 flex-1 overflow-auto p-4 sm:p-5 md:p-6">
<main className="flex min-h-0 flex-1 flex-col overflow-hidden">
<div className="min-h-0 flex-1 flex flex-col overflow-auto">
<ErrorBoundary>{children}</ErrorBoundary>
</div>
</main>
</div>
</div>

View File

@@ -196,12 +196,9 @@ function SlugField({
);
}
/** True if this string field is used for image/asset URLs (by description or name). */
function isImageUrlField(def: FieldDefinition, name: string): boolean {
const desc = def.description && String(def.description).toLowerCase();
if (desc && (desc.includes("image") || desc.includes("asset"))) return true;
if (name === "image" || name === "imageUrl" || name === "asset") return true;
return false;
/** True only when the schema explicitly requests image/asset URL UI via widget (no heuristics). */
function isImageUrlField(def: FieldDefinition): boolean {
return def.widget === "imageUrl" || def.widget === "assetUrl";
}
function assetPreviewUrl(value: string | undefined): string | null {
@@ -593,6 +590,16 @@ export function ContentForm({
}, [isDirty, handleSubmit]);
const fields = schema.fields ?? {};
const order = schema.fieldOrder;
const orderedFieldEntries: [string, FieldDefinition][] = order?.length
? [
...order.filter((n) => n in fields).map((n) => [n, fields[n] as FieldDefinition] as [string, FieldDefinition]),
...Object.keys(fields)
.filter((n) => !order.includes(n))
.map((n) => [n, fields[n] as FieldDefinition] as [string, FieldDefinition]),
]
: (Object.entries(fields) as [string, FieldDefinition][]);
const slugParam = locale ? `?_locale=${locale}` : "";
const showSlugField = !isEdit && !(fields._slug != null);
@@ -621,7 +628,7 @@ export function ContentForm({
}
currentTitle = null;
};
for (const [name, def] of Object.entries(fields)) {
for (const [name, def] of orderedFieldEntries) {
const fd = def as FieldDefinition;
if (fd.type === "object" && fd.fields) {
flush();
@@ -737,7 +744,7 @@ export function ContentForm({
</CollapsibleSection>
),
)
: Object.entries(fields).map(([name, def]) =>
: orderedFieldEntries.map(([name, def]) =>
(def as FieldDefinition).type === "object" &&
(def as FieldDefinition).fields ? (
<ObjectFieldSet
@@ -1488,7 +1495,7 @@ function Field({
);
}
if (type === "string" && isImageUrlField(def, name)) {
if (type === "string" && isImageUrlField(def)) {
return (
<Controller
name={name}

View File

@@ -2,7 +2,9 @@
import { useMemo } from "react";
import { useTranslations } from "next-intl";
import { useQuery } from "@tanstack/react-query";
import type { FieldDefinition } from "@/lib/api";
import { getBaseUrl, fetchEntry } from "@/lib/api";
import { ReferenceField } from "./ReferenceField";
type Props = {
@@ -56,6 +58,24 @@ export function ReferenceOrInlineField({
}, [value]);
const isReference = typeof normalizedValue === "string";
const slugForPreview = typeof normalizedValue === "string" ? normalizedValue : null;
const resolvedSrc =
value != null && typeof value === "object" && !Array.isArray(value) && "src" in (value as Record<string, unknown>)
? String((value as Record<string, unknown>).src ?? "")
: null;
const { data: resolvedImg } = useQuery({
queryKey: ["entry", "img", slugForPreview ?? ""],
queryFn: () => fetchEntry("img", slugForPreview!, {}),
enabled: !!def.collection && def.collection === "img" && !!slugForPreview && !resolvedSrc,
});
const previewUrl = useMemo(() => {
const src = resolvedSrc ?? (resolvedImg as Record<string, unknown> | undefined)?.src;
if (!src || typeof src !== "string") return null;
const s = src.trim();
if (!s) return null;
if (s.startsWith("http://") || s.startsWith("https://")) return s;
return getBaseUrl() + (s.startsWith("/") ? s : `/${s}`);
}, [resolvedSrc, resolvedImg]);
const inlineFields = (def.fields as Record<string, FieldDefinition> | undefined) ?? {};
const inlineValue = useMemo(
() =>
@@ -112,6 +132,16 @@ export function ReferenceOrInlineField({
</div>
{isReference ? (
<>
{def.collection === "img" && previewUrl && (
<div className="rounded-lg border border-gray-200 bg-gray-50/50 p-2">
<img
src={previewUrl}
alt=""
className="max-h-32 w-auto max-w-full object-contain"
/>
</div>
)}
<ReferenceField
name={name}
def={refDef(def)}
@@ -122,8 +152,24 @@ export function ReferenceOrInlineField({
locale={locale}
label={null}
/>
</>
) : (
<div className="rounded border border-gray-200 bg-gray-50/50 p-4">
{(def.collection === "img" || "src" in inlineFields) && inlineValue.src && (
<div className="mb-3 rounded-lg border border-gray-200 bg-gray-50/50 p-2">
<img
src={
typeof inlineValue.src === "string" && inlineValue.src.trim()
? inlineValue.src.startsWith("http://") || inlineValue.src.startsWith("https://")
? inlineValue.src
: getBaseUrl() + (String(inlineValue.src).startsWith("/") ? "" : "/") + String(inlineValue.src)
: ""
}
alt=""
className="max-h-32 w-auto max-w-full object-contain"
/>
</div>
)}
<p className="mb-3 text-xs text-gray-500">{t("inlineObject")}</p>
<div className="space-y-3">
{Object.entries(inlineFields).map(([subName, subDef]) => {

View File

@@ -7,9 +7,12 @@ import { Icon } from "@iconify/react";
export function SearchBar({
placeholder = "Search…",
paramName = "_q",
compact,
}: {
placeholder?: string;
paramName?: string;
/** Smaller height (h-8, text-sm) for dense layouts */
compact?: boolean;
}) {
const router = useRouter();
const pathname = usePathname();
@@ -45,10 +48,10 @@ export function SearchBar({
}, [value]); // eslint-disable-line react-hooks/exhaustive-deps
return (
<div className="relative w-full min-w-0 sm:w-56">
<div className={`relative min-w-0 ${compact ? "w-36 sm:w-40" : "w-full sm:w-56"}`}>
<Icon
icon="mdi:magnify"
className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"
className={`pointer-events-none absolute top-1/2 -translate-y-1/2 text-muted-foreground ${compact ? "left-2 size-3.5" : "left-2.5 size-4"}`}
aria-hidden
/>
<input
@@ -56,7 +59,11 @@ export function SearchBar({
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder={placeholder}
className="h-10 w-full min-w-0 rounded-md border border-input bg-background pl-8 pr-3 text-base text-foreground placeholder:text-muted-foreground focus:border-ring focus:outline-none focus:ring-[3px] focus:ring-ring/50 sm:h-9 sm:text-sm [touch-action:manipulation]"
className={`w-full min-w-0 rounded-md border border-input bg-background pl-7 pr-2 text-foreground placeholder:text-muted-foreground focus:border-ring focus:outline-none focus:ring-[3px] focus:ring-ring/50 touch-manipulation ${
compact
? "h-8 text-sm"
: "h-10 pl-8 pr-3 text-base sm:h-9 sm:text-sm"
}`}
/>
</div>
);

View File

@@ -6,6 +6,30 @@
export const getBaseUrl = () =>
process.env.NEXT_PUBLIC_RUSTYCMS_API_URL || "http://127.0.0.1:3000";
/**
* Recursively collapse absolute asset URLs to relative paths so the admin
* keeps and displays /api/assets/... and the backend stores relative.
*/
export function collapseAssetUrlsForAdmin(
value: unknown,
baseUrl: string
): unknown {
const prefix = `${baseUrl.replace(/\/+$/, "")}/api/assets/`;
if (typeof value === "string") {
if (value.startsWith(prefix)) return `/api/assets/${value.slice(prefix.length)}`;
return value;
}
if (Array.isArray(value)) return value.map((v) => collapseAssetUrlsForAdmin(v, baseUrl));
if (value != null && typeof value === "object") {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value)) {
out[k] = collapseAssetUrlsForAdmin(v, baseUrl);
}
return out;
}
return value;
}
const STORAGE_KEY = "rustycms_admin_api_key";
const PER_PAGE_KEY = "rustycms_per_page";
const DEFAULT_PER_PAGE = 25;
@@ -133,6 +157,8 @@ export type SchemaDefinition = {
description?: string;
tags?: string[];
category?: string;
/** Optional order of field names for admin UI. Fields not listed appear after. */
fieldOrder?: string[];
fields: Record<string, FieldDefinition>;
extends?: string | string[];
reusable?: boolean;
@@ -155,6 +181,13 @@ export async function fetchCollections(): Promise<CollectionsResponse> {
return res.json();
}
/** All type names (including reusable). Use for extends dropdown. */
export async function fetchSchemaNames(): Promise<{ names: string[] }> {
const res = await fetch(`${getBaseUrl()}/api/schemas`, { headers: getHeaders() });
if (!res.ok) throw new Error(`Schema names: ${res.status}`);
return res.json();
}
export async function fetchSchema(
collection: string
): Promise<SchemaDefinition> {
@@ -166,6 +199,18 @@ export async function fetchSchema(
return res.json();
}
/** Raw schema as stored on disk (own fields + extends, unresolved). Use for type editor. */
export async function fetchSchemaRaw(
name: string
): Promise<SchemaDefinition> {
const res = await fetch(
`${getBaseUrl()}/api/schemas/${encodeURIComponent(name)}/raw`,
{ headers: getHeaders() }
);
if (!res.ok) throw new Error(`Schema raw ${name}: ${res.status}`);
return res.json();
}
export async function createSchema(
schema: SchemaDefinition
): Promise<SchemaDefinition> {

View File

@@ -1,5 +0,0 @@
{
"_status": "published",
"title": "About",
"slug": "about",
}

View File

@@ -1,5 +0,0 @@
{
"_status": "published",
"title": "Home",
"slug": "home",
}

View File

@@ -368,6 +368,51 @@ pub async fn get_collection_schema(
Ok(Json(serde_json::to_value(schema).unwrap()))
}
// ---------------------------------------------------------------------------
// GET /api/schemas list all type names (including reusable, for extends dropdown)
// ---------------------------------------------------------------------------
pub async fn list_schema_names(
State(state): State<Arc<AppState>>,
) -> Json<Value> {
let names: Vec<String> = {
let registry = state.registry.read().await;
registry.names()
};
Json(json!({ "names": names }))
}
// ---------------------------------------------------------------------------
// GET /api/schemas/:name/raw schema as stored on disk (own fields + extends, unresolved)
// ---------------------------------------------------------------------------
pub async fn get_schema_raw(
State(state): State<Arc<AppState>>,
Path(name): Path<String>,
) -> Result<Json<Value>, ApiError> {
let path_json5 = state.types_dir.join(format!("{}.json5", name));
let path_json = state.types_dir.join(format!("{}.json", name));
let path = if path_json5.exists() {
path_json5
} else if path_json.exists() {
path_json
} else {
return Err(ApiError::NotFound(format!("Schema '{}' not found", name)));
};
let content = tokio::fs::read_to_string(&path)
.await
.map_err(|e| ApiError::Internal(format!("Failed to read schema file: {}", e)))?;
let schema: SchemaDefinition = json5::from_str(&content)
.map_err(|e| ApiError::BadRequest(format!("Invalid schema file: {}", e)))?;
if schema.name != name {
return Err(ApiError::BadRequest(format!(
"Schema file name mismatch: expected '{}', got '{}'",
name, schema.name
)));
}
Ok(Json(serde_json::to_value(schema).unwrap()))
}
// ---------------------------------------------------------------------------
// GET /api/collections/:collection/slug-check?slug=xxx&exclude=yyy&_locale=zzz
// ---------------------------------------------------------------------------

View File

@@ -23,7 +23,11 @@ pub fn create_router(state: Arc<AppState>) -> Router {
.route("/api-docs/openapi.json", get(openapi::openapi_json))
// Collection schema endpoints
.route("/api/collections", get(handlers::list_collections))
.route("/api/schemas", post(handlers::create_schema))
.route(
"/api/schemas",
get(handlers::list_schema_names).post(handlers::create_schema),
)
.route("/api/schemas/:name/raw", get(handlers::get_schema_raw))
.route(
"/api/schemas/:name",
put(handlers::update_schema).delete(handlers::delete_schema),

View File

@@ -84,6 +84,10 @@ pub struct SchemaDefinition {
#[serde(rename = "defaultOrder", skip_serializing_if = "Option::is_none", default)]
pub default_order: Option<String>,
/// Optional order of field names for admin UI. Not all fields need to be listed; any missing appear after.
#[serde(rename = "fieldOrder", skip_serializing_if = "Option::is_none", default)]
pub field_order: Option<Vec<String>>,
pub fields: IndexMap<String, FieldDefinition>,
}

View File

@@ -1,8 +1,49 @@
{
// Page with layout, SEO and optional top fullwidth banner (extends content_layout + seo)
name: "page",
description: "Simple page type for demo references.",
description: "Page with 3-row content layout, SEO and optional top banner",
tags: [
"content",
"layout"
],
category: "content",
extends: [
"content_layout",
"seo"
],
fields: {
title: { type: "string", required: true },
slug: { type: "string", required: true },
slug: {
type: "string",
required: true,
unique: true,
description: "URL slug"
},
name: {
type: "string",
required: true,
description: "Internal page name"
},
linkName: {
type: "string",
required: true,
description: "Display name in navigation"
},
icon: {
type: "string",
description: "Icon identifier (optional)"
},
headline: {
type: "string",
required: true
},
subheadline: {
type: "string"
},
topFullwidthBanner: {
type: "reference",
collection: "fullwidth_banner",
description: "Hero banner at the top"
},
// row1/row2/row3 (JustifyContent, AlignItems, Content) come from extends: content_layout single source of truth
}
}