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