From 11d46049d15a7927d741d384c1c65a735a4acc48 Mon Sep 17 00:00:00 2001 From: Peter Meier Date: Sat, 14 Mar 2026 00:08:52 +0100 Subject: [PATCH] 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. --- CLAUDE.md | 5 + admin-ui/messages/de.json | 18 + admin-ui/messages/en.json | 18 + admin-ui/src/app/admin/new-type/page.tsx | 2 +- .../src/app/admin/types/[name]/edit/page.tsx | 323 ++++++++++++++++-- admin-ui/src/app/admin/types/page.tsx | 2 +- admin-ui/src/app/assets/page.tsx | 111 +++--- .../app/content/[collection]/[slug]/page.tsx | 13 +- .../src/app/content/[collection]/new/page.tsx | 6 +- .../src/app/content/[collection]/page.tsx | 6 +- admin-ui/src/app/page.tsx | 2 +- admin-ui/src/app/settings/page.tsx | 2 +- admin-ui/src/components/AppShell.tsx | 6 +- admin-ui/src/components/ContentForm.tsx | 25 +- .../src/components/ReferenceOrInlineField.tsx | 66 +++- admin-ui/src/components/SearchBar.tsx | 13 +- admin-ui/src/lib/api.ts | 45 +++ content/de/page/about.json5 | 5 - content/de/page/home.json5 | 5 - src/api/handlers.rs | 45 +++ src/api/routes.rs | 6 +- src/schema/types.rs | 4 + types/page.json5 | 51 ++- 23 files changed, 662 insertions(+), 117 deletions(-) delete mode 100644 content/de/page/about.json5 delete mode 100644 content/de/page/home.json5 diff --git a/CLAUDE.md b/CLAUDE.md index e109126..f2bec1b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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: diff --git a/admin-ui/messages/de.json b/admin-ui/messages/de.json index db860a5..621f930 100644 --- a/admin-ui/messages/de.json +++ b/admin-ui/messages/de.json @@ -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.", diff --git a/admin-ui/messages/en.json b/admin-ui/messages/en.json index 92cfe27..c897232 100644 --- a/admin-ui/messages/en.json +++ b/admin-ui/messages/en.json @@ -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).", diff --git a/admin-ui/src/app/admin/new-type/page.tsx b/admin-ui/src/app/admin/new-type/page.tsx index 08952db..3a65654 100644 --- a/admin-ui/src/app/admin/new-type/page.tsx +++ b/admin-ui/src/app/admin/new-type/page.tsx @@ -184,7 +184,7 @@ export default function NewTypePage() { }; return ( -
+

{t("title")}

{t("description", { path: "types/.json" })} diff --git a/admin-ui/src/app/admin/types/[name]/edit/page.tsx b/admin-ui/src/app/admin/types/[name]/edit/page.tsx index 90b2885..235ca6b 100644 --- a/admin-ui/src/app/admin/types/[name]/edit/page.tsx +++ b/admin-ui/src/app/admin/types/[name]/edit/page.tsx @@ -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([]); + const [selectedExtend, setSelectedExtend] = useState(""); const [fields, setFields] = useState([]); + + 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(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) => { 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 ( -

+

{t("missingName")}

{t("backToTypes")}
@@ -287,12 +390,12 @@ export default function EditTypePage() { } if (isLoading || !schema) { - return

{t("loading")}

; + return

{t("loading")}

; } if (fetchError) { return ( -
+

{t("errorLoading", { error: String(fetchError) })}

{t("backToTypes")}
@@ -300,7 +403,7 @@ export default function EditTypePage() { } return ( -
+

{t("title", { name })}

{t("description")}

@@ -353,6 +456,67 @@ export default function EditTypePage() {
+
+ +

{t("extendsDescription")}

+ {extendsList.length > 0 && ( +
    + {extendsList.map((typeName) => ( +
  • + + + {typeName} + + +
  • + ))} +
+ )} +
+ + +
+
+
@@ -362,21 +526,90 @@ export default function EditTypePage() {
- {fields.map((f) => ( + {fields.map((f, index) => (
-
- - updateField(f.id, { name: e.target.value })} - placeholder={t("fieldNamePlaceholder")} - className="h-9 text-sm" - /> +
+ +
+ + + + +
+ {!f.collapsed && ( + <> + updateField(f.id, { name: e.target.value })} + placeholder={t("fieldNamePlaceholder")} + className="h-9 text-sm bg-white border-accent-200" + />
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" + /> +
+
+ + updateField(f.id, { minLength: e.target.value })} + placeholder="—" + className="h-9 text-sm" + /> +
+
+ + updateField(f.id, { maxLength: e.target.value })} + placeholder="—" + className="h-9 text-sm" + /> +
+
+ )}
)} + + )}
))}
diff --git a/admin-ui/src/app/admin/types/page.tsx b/admin-ui/src/app/admin/types/page.tsx index 205901a..6056a16 100644 --- a/admin-ui/src/app/admin/types/page.tsx +++ b/admin-ui/src/app/admin/types/page.tsx @@ -91,7 +91,7 @@ export default function TypesPage() { }; return ( -
+

{t("title")}

-
- -
- +
+ +
+ + +
+
+ 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")} /> - + 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")} />
@@ -491,8 +526,8 @@ export default function AssetsPage() { onChange={(e) => handleFiles(e.target.files)} /> - {/* Scrollable grid area */} -
+ {/* Scrollable grid area (container: grid responds to this width, not viewport) */} +
{/* Drop zone */}
{ e.preventDefault(); setDragOver(true); }} @@ -520,9 +555,9 @@ export default function AssetsPage() {

{t("noAssets")}

)} - {/* Grid */} + {/* Grid: 2 cols when container narrow, 4 cols when container wide */} {assets.length > 0 && ( -
+
{assets.map((asset) => ( {/* Mobile: always-visible action row (touch-friendly) */} -
- - - -
{/* Hover actions – stopPropagation so click doesn’t trigger thumbnail preview */} -
+
diff --git a/admin-ui/src/app/content/[collection]/[slug]/page.tsx b/admin-ui/src/app/content/[collection]/[slug]/page.tsx index 5dc4f9f..af56819 100644 --- a/admin-ui/src/app/content/[collection]/[slug]/page.tsx +++ b/admin-ui/src/app/content/[collection]/[slug]/page.tsx @@ -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 ( -
- Missing collection or slug. +
+
Missing collection or slug.
); } @@ -90,7 +91,7 @@ export default function ContentEditPage() { const error = schemaError ?? entryError; return ( -
+
} + initialValues={collapseAssetUrlsForAdmin(entry, getBaseUrl()) as Record} slug={slug} locale={locale} onSuccess={onSuccess} diff --git a/admin-ui/src/app/content/[collection]/new/page.tsx b/admin-ui/src/app/content/[collection]/new/page.tsx index 01f558f..42353cc 100644 --- a/admin-ui/src/app/content/[collection]/new/page.tsx +++ b/admin-ui/src/app/content/[collection]/new/page.tsx @@ -37,8 +37,8 @@ export default function ContentNewPage() { if (!collection) { return ( -
- Missing collection name. +
+
Missing collection name.
); } @@ -56,7 +56,7 @@ export default function ContentNewPage() { }, [collection]); return ( -
+
- Missing collection name. +
+
Missing collection name.
); } @@ -130,7 +130,7 @@ export default function ContentListPage() { }; return ( -
+
+

{t("title")}

diff --git a/admin-ui/src/app/settings/page.tsx b/admin-ui/src/app/settings/page.tsx index 280de88..e318f2e 100644 --- a/admin-ui/src/app/settings/page.tsx +++ b/admin-ui/src/app/settings/page.tsx @@ -4,7 +4,7 @@ import { SettingsContent } from "./SettingsContent"; export default async function SettingsPage() { const locale = await getLocale(); return ( -

+
); diff --git a/admin-ui/src/components/AppShell.tsx b/admin-ui/src/components/AppShell.tsx index 5b86308..2a5a17a 100644 --- a/admin-ui/src/components/AppShell.tsx +++ b/admin-ui/src/components/AppShell.tsx @@ -46,8 +46,10 @@ export function AppShell({ locale, children }: AppShellProps) { RustyCMS Admin -
- {children} +
+
+ {children} +
diff --git a/admin-ui/src/components/ContentForm.tsx b/admin-ui/src/components/ContentForm.tsx index 655e0f3..74c1faf 100644 --- a/admin-ui/src/components/ContentForm.tsx +++ b/admin-ui/src/components/ContentForm.tsx @@ -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({ ), ) - : Object.entries(fields).map(([name, def]) => + : orderedFieldEntries.map(([name, def]) => (def as FieldDefinition).type === "object" && (def as FieldDefinition).fields ? ( ) + ? String((value as Record).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 | 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 | undefined) ?? {}; const inlineValue = useMemo( () => @@ -112,18 +132,44 @@ export function ReferenceOrInlineField({
{isReference ? ( - onChange(v)} - required={required} - error={error} - locale={locale} - label={null} - /> + <> + {def.collection === "img" && previewUrl && ( +
+ +
+ )} + onChange(v)} + required={required} + error={error} + locale={locale} + label={null} + /> + ) : (
+ {(def.collection === "img" || "src" in inlineFields) && inlineValue.src && ( +
+ +
+ )}

{t("inlineObject")}

{Object.entries(inlineFields).map(([subName, subDef]) => { diff --git a/admin-ui/src/components/SearchBar.tsx b/admin-ui/src/components/SearchBar.tsx index 1e13418..a7d4d22 100644 --- a/admin-ui/src/components/SearchBar.tsx +++ b/admin-ui/src/components/SearchBar.tsx @@ -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 ( -
+
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" + }`} />
); diff --git a/admin-ui/src/lib/api.ts b/admin-ui/src/lib/api.ts index b745b4e..1d1e4b0 100644 --- a/admin-ui/src/lib/api.ts +++ b/admin-ui/src/lib/api.ts @@ -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 = {}; + 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; extends?: string | string[]; reusable?: boolean; @@ -155,6 +181,13 @@ export async function fetchCollections(): Promise { 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 { @@ -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 { + 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 { diff --git a/content/de/page/about.json5 b/content/de/page/about.json5 deleted file mode 100644 index feb3993..0000000 --- a/content/de/page/about.json5 +++ /dev/null @@ -1,5 +0,0 @@ -{ - "_status": "published", - "title": "About", - "slug": "about", -} diff --git a/content/de/page/home.json5 b/content/de/page/home.json5 deleted file mode 100644 index 045419c..0000000 --- a/content/de/page/home.json5 +++ /dev/null @@ -1,5 +0,0 @@ -{ - "_status": "published", - "title": "Home", - "slug": "home", -} diff --git a/src/api/handlers.rs b/src/api/handlers.rs index ef1f98f..cc1e4a8 100644 --- a/src/api/handlers.rs +++ b/src/api/handlers.rs @@ -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>, +) -> Json { + let names: Vec = { + 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>, + Path(name): Path, +) -> Result, 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 // --------------------------------------------------------------------------- diff --git a/src/api/routes.rs b/src/api/routes.rs index b8ed6b1..a14554b 100644 --- a/src/api/routes.rs +++ b/src/api/routes.rs @@ -23,7 +23,11 @@ pub fn create_router(state: Arc) -> 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), diff --git a/src/schema/types.rs b/src/schema/types.rs index 9feb905..b0f04fc 100644 --- a/src/schema/types.rs +++ b/src/schema/types.rs @@ -84,6 +84,10 @@ pub struct SchemaDefinition { #[serde(rename = "defaultOrder", skip_serializing_if = "Option::is_none", default)] pub default_order: Option, + /// 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>, + pub fields: IndexMap, } diff --git a/types/page.json5 b/types/page.json5 index ff8d3b5..a634008 100644 --- a/types/page.json5 +++ b/types/page.json5 @@ -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 + } +} \ No newline at end of file