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:
@@ -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:
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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).",
|
||||
|
||||
@@ -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" })}
|
||||
|
||||
@@ -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>
|
||||
<Input
|
||||
type="text"
|
||||
value={f.name}
|
||||
onChange={(e) => updateField(f.id, { name: e.target.value })}
|
||||
placeholder={t("fieldNamePlaceholder")}
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
<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 bg-white border-accent-200"
|
||||
/>
|
||||
<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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<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 doesn’t 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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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: "/" },
|
||||
|
||||
@@ -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: "/" },
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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">
|
||||
<ErrorBoundary>{children}</ErrorBoundary>
|
||||
<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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,18 +132,44 @@ export function ReferenceOrInlineField({
|
||||
</div>
|
||||
|
||||
{isReference ? (
|
||||
<ReferenceField
|
||||
name={name}
|
||||
def={refDef(def)}
|
||||
value={normalizedValue as string}
|
||||
onChange={(v) => onChange(v)}
|
||||
required={required}
|
||||
error={error}
|
||||
locale={locale}
|
||||
label={null}
|
||||
/>
|
||||
<>
|
||||
{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)}
|
||||
value={normalizedValue as string}
|
||||
onChange={(v) => onChange(v)}
|
||||
required={required}
|
||||
error={error}
|
||||
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]) => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"_status": "published",
|
||||
"title": "About",
|
||||
"slug": "about",
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"_status": "published",
|
||||
"title": "Home",
|
||||
"slug": "home",
|
||||
}
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user