Enhance documentation and admin UI: Add detailed implementation guidelines in CLAUDE.md, introduce a referrer index in README.md, and update admin UI translations for improved user experience. Update package dependencies for better functionality and performance.

This commit is contained in:
Peter Meier
2026-03-13 10:55:33 +01:00
parent 7754d800f5
commit 606455c59b
42 changed files with 3814 additions and 421 deletions

14
.claude/instructions.md Normal file
View File

@@ -0,0 +1,14 @@
# RustyCMS Hinweise für die AI
Vollständiger Kontext steht in **CLAUDE.md** im Repo-Root (bzw. `.cursor/rules/project.mdc`).
## Erklärungen beim Implementieren
Beim Umsetzen von Aufgaben **kurz und nachvollziehbar erklären**:
1. **Ziel und Vorgehen**: Am Anfang in 12 Sätzen: **was** wird umgesetzt und **wie** (welche Dateien, welches Muster).
2. **Schritte sichtbar machen**: Bei mehreren Änderungen kurz benennen („Als Nächstes: …“, „Dann: …“).
3. **Ungewöhnliche Entscheidungen begründen**: Abweichungen von Konventionen kurz **warum**.
4. **Am Ende zusammenfassen**: 24 Sätze: Was wurde geändert, wo es liegt, was der Nutzer prüfen kann.
Erklärungen knapp halten, aber so, dass Gedankengang und Änderungen nachvollziehbar sind.

View File

@@ -6,6 +6,19 @@
---
## Arbeitsweise: Erklärungen beim Implementieren
Beim Umsetzen von Aufgaben soll die AI **kurz und nachvollziehbar erklären, was sie tut**:
1. **Ziel und Vorgehen**: Zu Beginn (oder im ersten Schritt) in 12 Sätzen sagen, **was** umgesetzt wird und **wie** (welche Dateien, welches Muster).
2. **Schritte sichtbar machen**: Bei mehreren Änderungen z. B. kurz benennen: „Als Nächstes: …“, „Dann: …“, damit der Ablauf klar ist.
3. **Ungewöhnliche Entscheidungen begründen**: Wenn etwas von Konventionen abweicht (andere Lib, anderer Ordner, Workaround), **warum** (z. B. „Damit Tailwind-Klassen zuverlässig greifen“).
4. **Am Ende zusammenfassen**: In 24 Sätzen: Was wurde geändert, wo es liegt, was der Nutzer prüfen kann.
Die Erklärungen sollen **knapp** bleiben, aber so formuliert sein, dass jemand den Gedankengang und die Änderungen nachvollziehen kann, ohne alles selbst zu lesen.
---
## Projektstruktur
```

View File

@@ -11,6 +11,7 @@ A file-based headless CMS written in Rust. Content types are defined as JSON5 sc
- **Optional SQLite**: Switch store via env same API, different storage layer
- **REST API**: Full CRUD endpoints, auto-generated per content type
- **References & _resolve**: Reference fields as `{ _type, _slug }`; embed with `?_resolve=all`
- **Referrer index**: Reverse index of who references which entry; file-based in `content/_referrers.json`; full reindex when file is missing; `GET /api/content/:type/:slug/referrers`
- **Reusable partials**: `reusable` schemas and `useFields` for shared field groups (e.g. layout)
- **Optional API auth**: Protect write access (POST/PUT/DELETE) with an API key via env
- **Admin UI**: Next.js web interface for browsing collections, creating and editing content; manage types (list, create, edit, delete) with schema files and JSON Schema export updated on save
@@ -125,6 +126,10 @@ RUSTYCMS_STORE=sqlite cargo run
RUSTYCMS_API_KEY=your-secret-token cargo run
```
### Referrer index
When not using `RUSTYCMS_ENVIRONMENTS`, the API maintains a **reverse index** of references: for each entry it knows which other entries reference it. This is stored as a single JSON file in the content directory: **`content/_referrers.json`**. On startup, if that file is missing, a full reindex is run over all collections and locales; on every create/update/delete the index is updated incrementally. Use **`GET /api/content/:type/:slug/referrers`** to list referrers (response: array of `{ collection, slug, field, locale? }`). Documented in Swagger UI and OpenAPI spec.
## Project structure
```
@@ -259,6 +264,8 @@ Add a `.json5` file under `types/`:
| `fields` | object | Nested fields for object type |
| `collection`| string | Target collection for reference type |
| `useFields` | string | For `type: "object"`: use fields from this schema (partial) |
| `widget` | string | For `string`: `"textarea"` = multi-line; `"code"` = code editor with syntax highlighting (requires `codeLanguage`) |
| `codeLanguage` | string | When `widget: "code"`: `"css"`, `"javascript"`, `"json"`, or `"html"` used for highlighting and copy in admin UI |
### Field types
@@ -427,6 +434,7 @@ curl -X POST http://localhost:3000/api/content/product \
| `DELETE` | `/api/schemas/:type` | Delete type definition (Admin UI: Delete type) |
| `GET` | `/api/content/:type` | List all entries |
| `GET` | `/api/content/:type/:slug` | Get single entry |
| `GET` | `/api/content/:type/:slug/referrers` | List entries that reference this entry |
| `POST` | `/api/content/:type` | Create entry |
| `PUT` | `/api/content/:type/:slug` | Update entry |
| `DELETE` | `/api/content/:type/:slug` | Delete entry |

View File

@@ -10,6 +10,7 @@
"dashboard": "Dashboard",
"types": "Typen",
"assets": "Assets",
"settings": "Einstellungen",
"login": "Anmelden",
"logout": "Abmelden",
"searchPlaceholder": "Sammlungen suchen…",
@@ -20,6 +21,7 @@
"noResults": "Keine Ergebnisse für \"{query}\""
},
"ContentForm": {
"copyCode": "Code kopieren",
"slugRequired": "Slug ist erforderlich.",
"slugInUse": "Slug bereits vergeben.",
"slugMustStartWith": "Der Slug muss mit \"{prefix}\" beginnen.",
@@ -36,6 +38,11 @@
"pleaseSelect": "— Bitte auswählen —",
"removeEntry": "Entfernen",
"addEntry": "+ Eintrag hinzufügen",
"arrayAddItem": "Eintrag hinzufügen",
"arrayRemoveItem": "Eintrag entfernen",
"arrayItemPlaceholder": "Wert",
"arrayItemPlaceholderNumber": "Zahl",
"arrayHint": "Liste von Werten. Einträge unten hinzufügen oder entfernen.",
"keyPlaceholder": "Schlüssel",
"valuePlaceholder": "Wert",
"pickAsset": "Bild wählen",
@@ -58,6 +65,7 @@
"typesLabel": "Typen: {collections}",
"selectType": "\u2014 Typ w\u00e4hlen \u2014",
"newEntry": "Neuer Eintrag",
"openEntry": "Eintrag \u00f6ffnen",
"noCollection": "Keine Referenz-Collection im Schema. Setze {collectionCode} oder {collectionsCode} im Typ, oder starte die API und lade die Seite neu."
},
"ReferenceArrayField": {
@@ -72,9 +80,10 @@
"moveUp": "Nach oben",
"moveDown": "Nach unten",
"remove": "Entfernen",
"newComponent": "+ Neue {collection}-Komponente",
"newComponent": "Neue {collection}-Komponente",
"createNewComponent": "+ Neue Komponente erstellen\u2026",
"openInNewTab": "In neuem Tab \u00f6ffnen; dann Seite neu laden.",
"openEntry": "Eintrag \u00f6ffnen",
"noCollection": "Keine Referenz-Collection im Schema. Setze {collectionCode} oder {collectionsCode} im Typ, oder starte die API und lade die Seite neu."
},
"MarkdownEditor": {
@@ -112,6 +121,9 @@
"editSchema": "Schema bearbeiten",
"hidePreview": "Daten-Vorschau ausblenden",
"showPreview": "Daten-Vorschau",
"sectionSchema": "Schema",
"sectionDataPreview": "Aktuelle Daten",
"copyCode": "Code kopieren",
"loading": "Laden\u2026",
"errorLoading": "Fehler beim Laden"
},
@@ -127,6 +139,34 @@
"ContentLocaleSwitcher": {
"label": "Inhaltssprache"
},
"Settings": {
"title": "Einstellungen",
"connection": "Verbindung",
"apiUrl": "API-URL",
"backendStatus": "Backend-Status",
"checking": "Prüfe…",
"apiReachable": "API erreichbar",
"apiUnreachable": "API nicht erreichbar",
"contentLocales": "Inhalts-Sprachen",
"default": "Standard",
"thisDevice": "Dieses Gerät",
"uiLanguage": "Oberflächen-Sprache",
"itemsPerPage": "Einträge pro Seite",
"itemsPerPageHint": "Gilt für Inhaltslisten.",
"refreshData": "Daten aktualisieren",
"refreshDataSuccess": "Daten aktualisiert.",
"clearSession": "Session bereinigen",
"clearSessionHint": "Entfernt API-Schlüssel und alle Admin-Voreinstellungen (z. B. auf geteilten Rechnern).",
"clearSessionConfirmTitle": "Session bereinigen?",
"clearSessionConfirmDescription": "Du wirst abgemeldet und alle gespeicherten Einstellungen (z. B. Einträge pro Seite) werden gelöscht. Anschließend kannst du dich wieder anmelden.",
"clearSessionConfirmAction": "Bereinigen",
"cancel": "Abbrechen",
"apiKeyStatus": "API-Schlüssel",
"apiKeyFromEnv": "Aus Umgebung (nur Lesen).",
"apiKeyManual": "Manuell gesetzt (diese Sitzung).",
"logout": "Abmelden",
"login": "Anmelden"
},
"Dashboard": {
"title": "Dashboard",
"subtitle": "W\u00e4hle eine Sammlung zur Inhaltsverwaltung.",
@@ -141,6 +181,10 @@
"title": "Typen",
"newType": "Neuer Typ",
"description": "Inhaltstypen (Sammlungen). Schema bearbeiten oder Typ l\u00f6schen. Beim L\u00f6schen wird nur die Typdefinitionsdatei entfernt; vorhandene Inhaltseintr\u00e4ge bleiben erhalten.",
"searchPlaceholder": "Inhaltstypen suchen…",
"filterByTag": "Tag:",
"tagAll": "Alle",
"noResults": "Keine Typen entsprechen Ihrer Suche oder dem Filter.",
"loading": "Laden\u2026",
"errorLoading": "Fehler beim Laden der Typen: {error}",
"noTypes": "Noch keine Typen vorhanden. Erstelle einen mit \"Neuer Typ\".",
@@ -176,9 +220,44 @@
"fieldsLabel": "Felder",
"addField": "Feld hinzuf\u00fcgen",
"fieldNamePlaceholder": "Feldname",
"fieldTypeLabel": "Feldtyp",
"required": "Pflichtfeld",
"removeField": "Feld entfernen",
"collectionPlaceholder": "Sammlung (z.\u00a0B. seite)",
"allowedSlugsPlaceholder": "Erlaubte Slugs (kommagetrennt, optional)",
"allowedCollectionsPlaceholder": "Erlaubte Inhaltstypen (kommagetrennt, optional)",
"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.",
"arrayEachEntry": "Ein Listeneintrag ist",
"itemKindString": "Einfacher Text (string)",
"itemKindNumber": "Zahl (number)",
"itemKindObject": "Objekt (feste Unterfelder pro Eintrag)",
"itemKindReference": "Referenz (Slug auf einen Eintrag einer Sammlung)",
"objectItemFieldsLabel": "Felder pro Listeneintrag",
"addObjectField": "Unterfeld hinzuf\u00fcgen",
"objectFieldNamePlaceholder": "Unterfeldname",
"arrayReferenceHelp": "Alle Slugs in der Liste m\u00fcssen in dieser Sammlung existieren (oder in der Whitelist stehen).",
"multiSelectOptions": "Optionen",
"multiSelectOptionsPlaceholder": "z.\u00a0B. option1, option2, option3",
"multiSelectOptionsHelp": "Komma- oder zeilengetrennte Liste erlaubter Werte. Es k\u00f6nnen mehrere ausgew\u00e4hlt werden.",
"stringWidgetLabel": "Eingabeart",
"stringWidgetSingleline": "Einzeilig",
"stringWidgetTextarea": "Mehrzeilig (Textbereich)",
"stringWidgetCode": "Code (Syntax-Hervorhebung)",
"codeLanguageLabel": "Code-Sprache",
"codeLanguageCss": "CSS",
"codeLanguageJavascript": "JavaScript",
"codeLanguageJson": "JSON",
"codeLanguageHtml": "HTML",
"defaultValueLabel": "Standardwert",
"defaultValuePlaceholder": "z.\u00a0B. \"text\", 0, true, [\"a\",\"b\"]",
"defaultValueHelp": "JSON-Wert; leer lassen = keiner. Wird bei neuen Eintr\u00e4gen verwendet.",
"defaultValueInvalid": "Ung\u00fcltiges JSON f\u00fcr Standardwert im Feld \"{field}\"",
"defaultValueBoolean": "Standard: angehakt",
"defaultValueEmpty": "Leer lassen = keiner",
"defaultValueMultiSelectSetOptions": "Zuerst Optionen oben eintragen, dann Standard ausw\u00e4hlen.",
"defaultValueArrayPlaceholder": "Kommagetrennte Werte",
"fieldDescriptionPlaceholder": "Feldbeschreibung (optional)",
"creating": "Erstellen\u2026",
"createType": "Typ erstellen",
@@ -204,9 +283,44 @@
"fieldsLabel": "Felder",
"addField": "Feld hinzuf\u00fcgen",
"fieldNamePlaceholder": "Feldname",
"fieldTypeLabel": "Feldtyp",
"required": "Pflichtfeld",
"removeField": "Feld entfernen",
"collectionPlaceholder": "Sammlung (z.\u00a0B. seite)",
"allowedSlugsPlaceholder": "Erlaubte Slugs (kommagetrennt, optional)",
"allowedCollectionsPlaceholder": "Erlaubte Inhaltstypen (kommagetrennt, optional)",
"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.",
"arrayEachEntry": "Ein Listeneintrag ist",
"itemKindString": "Einfacher Text (string)",
"itemKindNumber": "Zahl (number)",
"itemKindObject": "Objekt (feste Unterfelder pro Eintrag)",
"itemKindReference": "Referenz (Slug auf einen Eintrag einer Sammlung)",
"objectItemFieldsLabel": "Felder pro Listeneintrag",
"addObjectField": "Unterfeld hinzuf\u00fcgen",
"objectFieldNamePlaceholder": "Unterfeldname",
"arrayReferenceHelp": "Alle Slugs in der Liste m\u00fcssen in dieser Sammlung existieren (oder in der Whitelist stehen).",
"multiSelectOptions": "Optionen",
"multiSelectOptionsPlaceholder": "z.\u00a0B. option1, option2, option3",
"multiSelectOptionsHelp": "Komma- oder zeilengetrennte Liste erlaubter Werte. Es k\u00f6nnen mehrere ausgew\u00e4hlt werden.",
"stringWidgetLabel": "Eingabeart",
"stringWidgetSingleline": "Einzeilig",
"stringWidgetTextarea": "Mehrzeilig (Textbereich)",
"stringWidgetCode": "Code (Syntax-Hervorhebung)",
"codeLanguageLabel": "Code-Sprache",
"codeLanguageCss": "CSS",
"codeLanguageJavascript": "JavaScript",
"codeLanguageJson": "JSON",
"codeLanguageHtml": "HTML",
"defaultValueLabel": "Standardwert",
"defaultValuePlaceholder": "z.\u00a0B. \"text\", 0, true, [\"a\",\"b\"]",
"defaultValueHelp": "JSON-Wert; leer lassen = keiner. Wird bei neuen Eintr\u00e4gen verwendet.",
"defaultValueInvalid": "Ung\u00fcltiges JSON f\u00fcr Standardwert im Feld \"{field}\"",
"defaultValueBoolean": "Standard: angehakt",
"defaultValueEmpty": "Leer lassen = keiner",
"defaultValueMultiSelectSetOptions": "Zuerst Optionen oben eintragen, dann Standard ausw\u00e4hlen.",
"defaultValueArrayPlaceholder": "Kommagetrennte Werte",
"fieldDescriptionPlaceholder": "Feldbeschreibung (optional)",
"saving": "Speichern\u2026",
"save": "Speichern",
@@ -227,20 +341,33 @@
"noEntries": "Keine Einträge.",
"noEntriesCreate": "Noch keine Einträge. Erstellen Sie den ersten.",
"edit": "Bearbeiten",
"delete": "Löschen",
"draft": "Entwurf",
"searchPlaceholder": "Suchen…",
"loading": "Laden…",
"sortBy": "Sortieren nach {field}",
"sortAsc": "Aufsteigend",
"sortDesc": "Absteigend"
"sortDesc": "Absteigend",
"typeDependencies": "Typ-Abhängigkeiten",
"confirmDelete": "\"{slug}\" löschen?",
"confirmDeleteDescription": "Dies kann nicht rückgängig gemacht werden.",
"cancel": "Abbrechen",
"deleting": "Löschen…",
"yesDelete": "Ja, löschen",
"deleted": "Eintrag gelöscht.",
"errorDeleting": "Fehler beim Löschen."
},
"ContentNewPage": {
"breadcrumbNew": "Neu",
"title": "Neuen Eintrag anlegen"
"title": "Neuen Eintrag anlegen",
"sectionSchema": "Schema"
},
"ContentEditPage": {
"title": "Eintrag bearbeiten",
"apiLink": "API-Link (Daten-Vorschau):"
"apiLink": "API-Link (Daten-Vorschau):",
"referrersSection": "Referenziert von",
"noReferrers": "Kein anderer Eintrag verweist auf diesen.",
"openReferrer": "Eintrag öffnen"
},
"AssetsPage": {
"titleAll": "Alle Assets",

View File

@@ -10,6 +10,7 @@
"dashboard": "Dashboard",
"types": "Types",
"assets": "Assets",
"settings": "Settings",
"login": "Login",
"logout": "Logout",
"searchPlaceholder": "Search collections…",
@@ -20,6 +21,7 @@
"noResults": "No results for \"{query}\""
},
"ContentForm": {
"copyCode": "Copy code",
"slugRequired": "Slug is required.",
"slugInUse": "Slug already in use.",
"slugMustStartWith": "Slug must start with \"{prefix}\".",
@@ -36,6 +38,11 @@
"pleaseSelect": "— Please select —",
"removeEntry": "Remove",
"addEntry": "+ Add entry",
"arrayAddItem": "Add item",
"arrayRemoveItem": "Remove item",
"arrayItemPlaceholder": "Value",
"arrayItemPlaceholderNumber": "Number",
"arrayHint": "List of values. Add or remove items below.",
"keyPlaceholder": "Key",
"valuePlaceholder": "Value",
"pickAsset": "Pick image",
@@ -58,6 +65,7 @@
"typesLabel": "Types: {collections}",
"selectType": "— Select type —",
"newEntry": "New entry",
"openEntry": "Open entry",
"noCollection": "No reference collection in schema. Set {collectionCode} or {collectionsCode} in the type, or start the API and reload the page."
},
"ReferenceArrayField": {
@@ -72,9 +80,10 @@
"moveUp": "Move up",
"moveDown": "Move down",
"remove": "Remove",
"newComponent": "+ New {collection} component",
"newComponent": "New {collection} component",
"createNewComponent": "+ Create new component…",
"openInNewTab": "Open in new tab; then reload this page.",
"openEntry": "Open entry",
"noCollection": "No reference collection in schema. Set {collectionCode} or {collectionsCode} in the type, or start the API and reload the page."
},
"MarkdownEditor": {
@@ -112,6 +121,9 @@
"editSchema": "Edit schema",
"hidePreview": "Hide data preview",
"showPreview": "Data preview",
"sectionSchema": "Schema",
"sectionDataPreview": "Current data",
"copyCode": "Copy code",
"loading": "Loading…",
"errorLoading": "Error loading"
},
@@ -127,6 +139,34 @@
"ContentLocaleSwitcher": {
"label": "Content language"
},
"Settings": {
"title": "Settings",
"connection": "Connection",
"apiUrl": "API URL",
"backendStatus": "Backend status",
"checking": "Checking…",
"apiReachable": "API reachable",
"apiUnreachable": "API unreachable",
"contentLocales": "Content locales",
"default": "default",
"thisDevice": "This device",
"uiLanguage": "UI language",
"itemsPerPage": "Items per page",
"itemsPerPageHint": "Applies to content list pages.",
"refreshData": "Refresh data",
"refreshDataSuccess": "Data refreshed.",
"clearSession": "Clear session",
"clearSessionHint": "Removes API key and all admin preferences (e.g. on shared devices).",
"clearSessionConfirmTitle": "Clear session?",
"clearSessionConfirmDescription": "This will log you out and remove all stored preferences (e.g. items per page). You can log in again afterwards.",
"clearSessionConfirmAction": "Clear",
"cancel": "Cancel",
"apiKeyStatus": "API key",
"apiKeyFromEnv": "Set from environment (read-only).",
"apiKeyManual": "Set manually (this session).",
"logout": "Log out",
"login": "Log in"
},
"Dashboard": {
"title": "Dashboard",
"subtitle": "Choose a collection to manage content.",
@@ -141,6 +181,10 @@
"title": "Types",
"newType": "New type",
"description": "Content types (collections). Edit the schema or delete a type. Deleting removes the type definition file; existing content entries are not removed.",
"searchPlaceholder": "Search content types…",
"filterByTag": "Tag:",
"tagAll": "All",
"noResults": "No content types match your search or filter.",
"loading": "Loading…",
"errorLoading": "Error loading types: {error}",
"noTypes": "No types yet. Create one with \"New type\".",
@@ -176,9 +220,44 @@
"fieldsLabel": "Fields",
"addField": "Add field",
"fieldNamePlaceholder": "Field name",
"fieldTypeLabel": "Field type",
"required": "Required",
"removeField": "Remove field",
"collectionPlaceholder": "Collection (e.g. page)",
"allowedSlugsPlaceholder": "Allowed slugs (comma-separated, optional)",
"allowedCollectionsPlaceholder": "Allowed content types (comma-separated, optional)",
"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.",
"arrayEachEntry": "One list entry is",
"itemKindString": "Plain text (string)",
"itemKindNumber": "Number",
"itemKindObject": "Object (fixed sub-fields per entry)",
"itemKindReference": "Reference (slug to an entry in a collection)",
"objectItemFieldsLabel": "Fields on each list object",
"addObjectField": "Add sub-field",
"objectFieldNamePlaceholder": "Sub-field name",
"arrayReferenceHelp": "All slugs in the list must exist in this collection (unless you use allowed slugs).",
"multiSelectOptions": "Options",
"multiSelectOptionsPlaceholder": "e.g. option1, option2, option3",
"multiSelectOptionsHelp": "Comma- or newline-separated list of allowed values. User can select multiple.",
"stringWidgetLabel": "Input style",
"stringWidgetSingleline": "Single line",
"stringWidgetTextarea": "Multi-line (textarea)",
"stringWidgetCode": "Code (syntax highlighting)",
"codeLanguageLabel": "Code language",
"codeLanguageCss": "CSS",
"codeLanguageJavascript": "JavaScript",
"codeLanguageJson": "JSON",
"codeLanguageHtml": "HTML",
"defaultValueLabel": "Default value",
"defaultValuePlaceholder": "e.g. \"text\", 0, true, [\"a\",\"b\"]",
"defaultValueHelp": "JSON value; leave empty for none. Used when creating new entries.",
"defaultValueInvalid": "Invalid JSON for default value in field \"{field}\"",
"defaultValueBoolean": "Default: checked",
"defaultValueEmpty": "Leave empty for none",
"defaultValueMultiSelectSetOptions": "Set options above first, then choose defaults.",
"defaultValueArrayPlaceholder": "Comma-separated values",
"fieldDescriptionPlaceholder": "Field description (optional)",
"creating": "Creating…",
"createType": "Create type",
@@ -204,9 +283,44 @@
"fieldsLabel": "Fields",
"addField": "Add field",
"fieldNamePlaceholder": "Field name",
"fieldTypeLabel": "Field type",
"required": "Required",
"removeField": "Remove field",
"collectionPlaceholder": "Collection (e.g. page)",
"allowedSlugsPlaceholder": "Allowed slugs (comma-separated, optional)",
"allowedCollectionsPlaceholder": "Allowed content types (comma-separated, optional)",
"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.",
"arrayEachEntry": "One list entry is",
"itemKindString": "Plain text (string)",
"itemKindNumber": "Number",
"itemKindObject": "Object (fixed sub-fields per entry)",
"itemKindReference": "Reference (slug to an entry in a collection)",
"objectItemFieldsLabel": "Fields on each list object",
"addObjectField": "Add sub-field",
"objectFieldNamePlaceholder": "Sub-field name",
"arrayReferenceHelp": "All slugs in the list must exist in this collection (unless you use allowed slugs).",
"multiSelectOptions": "Options",
"multiSelectOptionsPlaceholder": "e.g. option1, option2, option3",
"multiSelectOptionsHelp": "Comma- or newline-separated list of allowed values. User can select multiple.",
"stringWidgetLabel": "Input style",
"stringWidgetSingleline": "Single line",
"stringWidgetTextarea": "Multi-line (textarea)",
"stringWidgetCode": "Code (syntax highlighting)",
"codeLanguageLabel": "Code language",
"codeLanguageCss": "CSS",
"codeLanguageJavascript": "JavaScript",
"codeLanguageJson": "JSON",
"codeLanguageHtml": "HTML",
"defaultValueLabel": "Default value",
"defaultValuePlaceholder": "e.g. \"text\", 0, true, [\"a\",\"b\"]",
"defaultValueHelp": "JSON value; leave empty for none. Used when creating new entries.",
"defaultValueInvalid": "Invalid JSON for default value in field \"{field}\"",
"defaultValueBoolean": "Default: checked",
"defaultValueEmpty": "Leave empty for none",
"defaultValueMultiSelectSetOptions": "Set options above first, then choose defaults.",
"defaultValueArrayPlaceholder": "Comma-separated values",
"fieldDescriptionPlaceholder": "Field description (optional)",
"saving": "Saving…",
"save": "Save",
@@ -227,20 +341,33 @@
"noEntries": "No entries.",
"noEntriesCreate": "No entries yet. Create the first one.",
"edit": "Edit",
"delete": "Delete",
"draft": "Draft",
"searchPlaceholder": "Search…",
"loading": "Loading…",
"sortBy": "Sort by {field}",
"sortAsc": "Ascending",
"sortDesc": "Descending"
"sortDesc": "Descending",
"typeDependencies": "Type dependencies",
"confirmDelete": "Delete \"{slug}\"?",
"confirmDeleteDescription": "This cannot be undone.",
"cancel": "Cancel",
"deleting": "Deleting…",
"yesDelete": "Yes, delete",
"deleted": "Entry deleted.",
"errorDeleting": "Error deleting entry."
},
"ContentNewPage": {
"breadcrumbNew": "New",
"title": "Create new entry"
"title": "Create new entry",
"sectionSchema": "Schema"
},
"ContentEditPage": {
"title": "Edit entry",
"apiLink": "API link (data preview):"
"apiLink": "API link (data preview):",
"referrersSection": "Referenced by",
"noReferrers": "No other entries reference this one.",
"openReferrer": "Open entry"
},
"AssetsPage": {
"titleAll": "All assets",

View File

@@ -11,6 +11,7 @@
"@fontsource-variable/space-grotesk": "^5.2.10",
"@iconify/react": "^6.0.2",
"@tanstack/react-query": "^5.90.21",
"@xyflow/react": "^12.10.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
@@ -22,6 +23,7 @@
"react-dom": "19.2.3",
"react-hook-form": "^7.71.1",
"react-markdown": "^10.1.0",
"react-syntax-highlighter": "^16.1.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tailwindcss-animate": "^1.0.7"
@@ -31,6 +33,7 @@
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/react-syntax-highlighter": "^15.5.13",
"eslint": "^9",
"eslint-config-next": "16.1.6",
"tailwindcss": "^4",
@@ -242,6 +245,15 @@
"node": ">=6.0.0"
}
},
"node_modules/@babel/runtime": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
"integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/template": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
@@ -4585,6 +4597,55 @@
"tslib": "^2.4.0"
}
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT"
},
"node_modules/@types/d3-drag": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
"integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==",
"license": "MIT",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"license": "MIT",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-selection": {
"version": "3.0.11",
"resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
"integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
"license": "MIT"
},
"node_modules/@types/d3-transition": {
"version": "3.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
"integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==",
"license": "MIT",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-zoom": {
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz",
"integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==",
"license": "MIT",
"dependencies": {
"@types/d3-interpolate": "*",
"@types/d3-selection": "*"
}
},
"node_modules/@types/debug": {
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
@@ -4657,6 +4718,12 @@
"undici-types": "~6.21.0"
}
},
"node_modules/@types/prismjs": {
"version": "1.26.6",
"resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.6.tgz",
"integrity": "sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw==",
"license": "MIT"
},
"node_modules/@types/react": {
"version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
@@ -4676,6 +4743,16 @@
"@types/react": "^19.2.0"
}
},
"node_modules/@types/react-syntax-highlighter": {
"version": "15.5.13",
"resolved": "https://registry.npmjs.org/@types/react-syntax-highlighter/-/react-syntax-highlighter-15.5.13.tgz",
"integrity": "sha512-uLGJ87j6Sz8UaBAooU0T6lWJ0dBmjZgN1PZTrj05TNql2/XpC6+4HhMT5syIdFUUt+FASfCeLLv4kBygNU+8qA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/react": "*"
}
},
"node_modules/@types/unist": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
@@ -5226,6 +5303,38 @@
"win32"
]
},
"node_modules/@xyflow/react": {
"version": "12.10.1",
"resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.10.1.tgz",
"integrity": "sha512-5eSWtIK/+rkldOuFbOOz44CRgQRjtS9v5nufk77DV+XBnfCGL9HAQ8PG00o2ZYKqkEU/Ak6wrKC95Tu+2zuK3Q==",
"license": "MIT",
"dependencies": {
"@xyflow/system": "0.0.75",
"classcat": "^5.0.3",
"zustand": "^4.4.0"
},
"peerDependencies": {
"react": ">=17",
"react-dom": ">=17"
}
},
"node_modules/@xyflow/system": {
"version": "0.0.75",
"resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.75.tgz",
"integrity": "sha512-iXs+AGFLi8w/VlAoc/iSxk+CxfT6o64Uw/k0CKASOPqjqz6E0rb5jFZgJtXGZCpfQI6OQpu5EnumP5fGxQheaQ==",
"license": "MIT",
"dependencies": {
"@types/d3-drag": "^3.0.7",
"@types/d3-interpolate": "^3.0.4",
"@types/d3-selection": "^3.0.10",
"@types/d3-transition": "^3.0.8",
"@types/d3-zoom": "^3.0.8",
"d3-drag": "^3.0.0",
"d3-interpolate": "^3.0.1",
"d3-selection": "^3.0.0",
"d3-zoom": "^3.0.0"
}
},
"node_modules/acorn": {
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
@@ -5767,6 +5876,12 @@
"url": "https://polar.sh/cva"
}
},
"node_modules/classcat": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz",
"integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==",
"license": "MIT"
},
"node_modules/client-only": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
@@ -5863,6 +5978,111 @@
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT"
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-dispatch": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-drag": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
"integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-selection": "3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-selection": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-transition": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
"integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3",
"d3-dispatch": "1 - 3",
"d3-ease": "1 - 3",
"d3-interpolate": "1 - 3",
"d3-timer": "1 - 3"
},
"engines": {
"node": ">=12"
},
"peerDependencies": {
"d3-selection": "2 - 3"
}
},
"node_modules/d3-zoom": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
"integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-drag": "2 - 3",
"d3-interpolate": "1 - 3",
"d3-selection": "2 - 3",
"d3-transition": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/damerau-levenshtein": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
@@ -6797,6 +7017,19 @@
"reusify": "^1.0.4"
}
},
"node_modules/fault": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/fault/-/fault-1.0.4.tgz",
"integrity": "sha512-CJ0HCB5tL5fYTEA7ToAq5+kTwd++Borf1/bifxd9iT70QcXr4MRrO3Llf8Ifs70q+SJcGHFtnIE/Nw6giCtECA==",
"license": "MIT",
"dependencies": {
"format": "^0.2.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/file-entry-cache": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
@@ -6877,6 +7110,14 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/format": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/format/-/format-0.2.2.tgz",
"integrity": "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==",
"engines": {
"node": ">=0.4.x"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@@ -7174,6 +7415,19 @@
"node": ">= 0.4"
}
},
"node_modules/hast-util-parse-selector": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz",
"integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-to-jsx-runtime": {
"version": "2.3.6",
"resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz",
@@ -7214,6 +7468,23 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/hastscript": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz",
"integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"comma-separated-tokens": "^2.0.0",
"hast-util-parse-selector": "^4.0.0",
"property-information": "^7.0.0",
"space-separated-tokens": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/hermes-estree": {
"version": "0.25.1",
"resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz",
@@ -7231,6 +7502,21 @@
"hermes-estree": "0.25.1"
}
},
"node_modules/highlight.js": {
"version": "10.7.3",
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz",
"integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==",
"license": "BSD-3-Clause",
"engines": {
"node": "*"
}
},
"node_modules/highlightjs-vue": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/highlightjs-vue/-/highlightjs-vue-1.0.0.tgz",
"integrity": "sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA==",
"license": "CC0-1.0"
},
"node_modules/html-url-attributes": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
@@ -8271,6 +8557,20 @@
"loose-envify": "cli.js"
}
},
"node_modules/lowlight": {
"version": "1.20.0",
"resolved": "https://registry.npmjs.org/lowlight/-/lowlight-1.20.0.tgz",
"integrity": "sha512-8Ktj+prEb1RoCPkEOrPMYUN/nCggB7qAWe3a7OpMjWQkh3l2RD5wKRQ+o8Q8YuI9RG/xs95waaI/E6ym/7NsTw==",
"license": "MIT",
"dependencies": {
"fault": "^1.0.0",
"highlight.js": "~10.7.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@@ -9519,6 +9819,15 @@
"node": ">= 0.8.0"
}
},
"node_modules/prismjs": {
"version": "1.30.0",
"resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz",
"integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
@@ -9812,6 +10121,26 @@
}
}
},
"node_modules/react-syntax-highlighter": {
"version": "16.1.1",
"resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-16.1.1.tgz",
"integrity": "sha512-PjVawBGy80C6YbC5DDZJeUjBmC7skaoEUdvfFQediQHgCL7aKyVHe57SaJGfQsloGDac+gCpTfRdtxzWWKmCXA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.4",
"highlight.js": "^10.4.1",
"highlightjs-vue": "^1.0.0",
"lowlight": "^1.17.0",
"prismjs": "^1.30.0",
"refractor": "^5.0.0"
},
"engines": {
"node": ">= 16.20.2"
},
"peerDependencies": {
"react": ">= 0.14.0"
}
},
"node_modules/reflect.getprototypeof": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@@ -9835,6 +10164,22 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/refractor": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/refractor/-/refractor-5.0.0.tgz",
"integrity": "sha512-QXOrHQF5jOpjjLfiNk5GFnWhRXvxjUVnlFxkeDmewR5sXkr3iM46Zo+CnRR8B+MDVqkULW4EcLVcRBNOPXHosw==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"@types/prismjs": "^1.0.0",
"hastscript": "^9.0.0",
"parse-entities": "^4.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/regexp.prototype.flags": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
@@ -11251,6 +11596,34 @@
"zod": "^3.25.0 || ^4.0.0"
}
},
"node_modules/zustand": {
"version": "4.5.7",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
"integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
"license": "MIT",
"dependencies": {
"use-sync-external-store": "^1.2.2"
},
"engines": {
"node": ">=12.7.0"
},
"peerDependencies": {
"@types/react": ">=16.8",
"immer": ">=9.0.6",
"react": ">=16.8"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"immer": {
"optional": true
},
"react": {
"optional": true
}
}
},
"node_modules/zwitch": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",

View File

@@ -12,6 +12,7 @@
"@fontsource-variable/space-grotesk": "^5.2.10",
"@iconify/react": "^6.0.2",
"@tanstack/react-query": "^5.90.21",
"@xyflow/react": "^12.10.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
@@ -23,6 +24,7 @@
"react-dom": "19.2.3",
"react-hook-form": "^7.71.1",
"react-markdown": "^10.1.0",
"react-syntax-highlighter": "^16.1.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tailwindcss-animate": "^1.0.7"
@@ -32,6 +34,7 @@
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/react-syntax-highlighter": "^15.5.13",
"eslint": "^9",
"eslint-config-next": "16.1.6",
"tailwindcss": "^4",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -32,8 +32,13 @@ import { ReferenceArrayField } from "./ReferenceArrayField";
import { ReferenceField } from "./ReferenceField";
import { ReferenceOrInlineField } from "./ReferenceOrInlineField";
import { MarkdownEditor } from "./MarkdownEditor";
import { CodeField, isCodeFieldLanguage } from "./CodeField";
import { CollapsibleSection } from "./ui/collapsible";
/** Wrapper class for each form field to separate them visually. */
const FIELD_BLOCK_CLASS =
"rounded-xl border border-gray-200 bg-white p-4 shadow-[0_1px_2px_rgba(0,0,0,0.04)]";
type Props = {
collection: string;
schema: SchemaDefinition;
@@ -100,16 +105,24 @@ function SlugField({
const handleSuffixChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const v = e.target.value;
const normalized = slugPrefix
? v.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "")
? v
.toLowerCase()
.replace(/\s+/g, "-")
.replace(/[^a-z0-9-]/g, "")
: v;
setValue("_slug", slugPrefix ? slugPrefix + normalized : v, { shouldValidate: true });
setValue("_slug", slugPrefix ? slugPrefix + normalized : v, {
shouldValidate: true,
});
};
const handleBlur = async (e: React.FocusEvent<HTMLInputElement>) => {
const value = slugPrefix ? slugPrefix + (e.target.value ?? "") : (e.target.value ?? "");
const value = slugPrefix
? slugPrefix + (e.target.value ?? "")
: (e.target.value ?? "");
const full = value.trim();
if (!full) return;
if (slugPrefix && !full.toLowerCase().startsWith(slugPrefix.toLowerCase())) return;
if (slugPrefix && !full.toLowerCase().startsWith(slugPrefix.toLowerCase()))
return;
try {
const res = await checkSlug(collection, full, {
exclude: currentSlug ?? undefined,
@@ -129,7 +142,9 @@ function SlugField({
}
};
const suffixPlaceholder = slugPrefix ? t("slugSuffixPlaceholder") : t("slugPlaceholder");
const suffixPlaceholder = slugPrefix
? t("slugSuffixPlaceholder")
: t("slugPlaceholder");
if (slugPrefix) {
return (
@@ -195,7 +210,8 @@ function assetPreviewUrl(value: string | undefined): string | null {
if (!v) return null;
if (v.startsWith("http://") || v.startsWith("https://")) return v;
if (v.startsWith("/api/assets/")) return getBaseUrl() + v;
if (/\.(jpg|jpeg|png|webp|gif|avif|svg)(\?|$)/i.test(v)) return getBaseUrl() + (v.startsWith("/") ? v : "/" + v);
if (/\.(jpg|jpeg|png|webp|gif|avif|svg)(\?|$)/i.test(v))
return getBaseUrl() + (v.startsWith("/") ? v : "/" + v);
return null;
}
@@ -214,7 +230,9 @@ function AssetPickerContent({ onSelect }: { onSelect: (url: string) => void }) {
<p className="text-muted-foreground text-sm">{t("loadingAssets")}</p>
)}
{error && (
<p className="text-destructive text-sm">{String((error as Error).message)}</p>
<p className="text-destructive text-sm">
{String((error as Error).message)}
</p>
)}
{!isLoading && !error && assets.length === 0 && (
<p className="text-muted-foreground text-sm">{t("noAssets")}</p>
@@ -232,9 +250,17 @@ function AssetPickerContent({ onSelect }: { onSelect: (url: string) => void }) {
>
<span className="aspect-square w-full overflow-hidden rounded border bg-background flex items-center justify-center">
{/\.(svg)$/i.test(asset.filename) ? (
<img src={src} alt="" className="max-h-full max-w-full object-contain" />
<img
src={src}
alt=""
className="max-h-full max-w-full object-contain"
/>
) : (
<img src={src} alt="" className="h-full w-full object-cover" />
<img
src={src}
alt=""
className="h-full w-full object-cover"
/>
)}
</span>
<span className="mt-1 truncate w-full text-center text-xs text-muted-foreground">
@@ -335,6 +361,10 @@ function buildDefaultValues(
out[key] = value;
continue;
}
if (def?.type === "multiSelect" && !Array.isArray(value)) {
out[key] = [];
continue;
}
if (def?.type === "reference") {
out[key] =
typeof value === "object" && value !== null && "_slug" in value
@@ -404,7 +434,10 @@ function buildDefaultValues(
!Array.isArray(def.default)
) {
out[key] = buildDefaultValues(
{ name: key, fields: def.fields as Record<string, FieldDefinition> },
{
name: key,
fields: def.fields as Record<string, FieldDefinition>,
},
def.default as Record<string, unknown>,
);
} else {
@@ -412,10 +445,16 @@ function buildDefaultValues(
}
} else if (def?.type === "boolean") out[key] = false;
else if (def?.type === "array") {
const items = (def as FieldDefinition).items;
const items = (def as FieldDefinition).items as
| FieldDefinition
| undefined;
const isRefArray =
items?.type === "reference" || items?.type === "referenceOrInline";
out[key] = isRefArray ? [] : "";
const isPrimitiveArray =
items?.type === "string" ||
items?.type === "number" ||
items?.type === "integer";
out[key] = isRefArray || isPrimitiveArray ? [] : "";
} else if (def?.type === "object" && def.fields)
out[key] = buildDefaultValues(
{ name: key, fields: def.fields as Record<string, FieldDefinition> },
@@ -447,6 +486,10 @@ export function ContentForm({
if (!isEdit && defaultValues._status === undefined) {
defaultValues._status = "draft";
}
// Backend treats missing _status as published; show same in UI when editing existing entry.
if (isEdit && defaultValues._status === undefined) {
defaultValues._status = "published";
}
const {
register,
handleSubmit,
@@ -478,7 +521,10 @@ export function ContentForm({
payload[key] = Array.isArray(value)
? value
: typeof value === "string"
? value.split(/[\n,]+/).map((s) => s.trim()).filter(Boolean)
? value
.split(/[\n,]+/)
.map((s) => s.trim())
.filter(Boolean)
: [];
} else {
payload[key] = value;
@@ -503,7 +549,9 @@ export function ContentForm({
const newSlug = payload._slug as string | undefined;
const q = locale ? `?_locale=${locale}` : "";
if (newSlug) {
router.push(`/content/${collection}/${encodeURIComponent(newSlug)}${q}`);
router.push(
`/content/${collection}/${encodeURIComponent(newSlug)}${q}`,
);
} else {
router.push(`/content/${collection}${q}`);
}
@@ -514,7 +562,11 @@ export function ContentForm({
const fieldErrRe = /^Field '([^']+)': (.+)$/;
for (const line of apiErrors) {
const m = line.match(fieldErrRe);
if (m) setError(m[1] as Parameters<typeof setError>[0], { type: "server", message: m[2] });
if (m)
setError(m[1] as Parameters<typeof setError>[0], {
type: "server",
message: m[2],
});
}
const msg = apiErrors.join(" ");
setError("root", { message: msg });
@@ -560,7 +612,11 @@ export function ContentForm({
let currentEntries: [string, FieldDefinition][] = [];
const flush = () => {
if (currentEntries.length > 0) {
formItems.push({ kind: "section", title: currentTitle ?? "Details", entries: currentEntries });
formItems.push({
kind: "section",
title: currentTitle ?? "Details",
entries: currentEntries,
});
currentEntries = [];
}
currentTitle = null;
@@ -583,9 +639,12 @@ export function ContentForm({
}
return (
<form onSubmit={handleSubmit(onSubmit)} className="max-w-2xl space-y-8">
<form onSubmit={handleSubmit(onSubmit)} className="max-w-2xl space-y-6">
{errors.root && (
<div className="rounded bg-red-50 p-3 text-sm text-red-700" role="alert">
<div
className="rounded bg-red-50 p-3 text-sm text-red-700"
role="alert"
>
{errors.root.message}
</div>
)}
@@ -609,12 +668,16 @@ export function ContentForm({
name="_status"
control={control!}
render={({ field }) => {
const value = (field.value as string) ?? "draft";
const value = (field.value as string) ?? "published";
return (
<div className="flex flex-wrap gap-2" role="group" aria-label={t("status")}>
<div
className="flex flex-wrap gap-2"
role="group"
aria-label={t("status")}
>
<Button
type="button"
variant={value === "draft" ? "default" : "outline"}
variant={value === "draft" ? "destructive" : "outline"}
size="sm"
onClick={() => field.onChange("draft")}
className="min-w-[100px]"
@@ -626,7 +689,7 @@ export function ContentForm({
variant="outline"
size="sm"
onClick={() => field.onChange("published")}
className={`min-w-[100px] ${value === "published" ? "border-success-300 bg-success-50 text-success-700 hover:bg-success-100 hover:text-success-800 hover:border-success-400" : ""}`}
className={`min-w-[100px] ${value === "published" ? "border-2 border-success-400 bg-success-200 text-success-800 hover:bg-success-300 hover:border-success-500 hover:text-success-900" : ""}`}
>
{t("statusPublished")}
</Button>
@@ -634,10 +697,12 @@ export function ContentForm({
);
}}
/>
<p className="mt-0.5 text-xs text-muted-foreground">{t("statusHint")}</p>
<p className="mt-0.5 text-xs text-muted-foreground">
{t("statusHint")}
</p>
</div>
{hasSection
? formItems.map((item) =>
? formItems.map((item, sectionIndex) =>
item.kind === "object" ? (
<ObjectFieldSet
key={item.name}
@@ -651,14 +716,14 @@ export function ContentForm({
/>
) : (
<CollapsibleSection
key={item.title}
key={`section-${sectionIndex}-${item.title}`}
title={item.title}
defaultOpen={true}
contentClassName="space-y-4"
>
{item.entries.map(([name, def]) => (
<div key={name} className={FIELD_BLOCK_CLASS}>
<Field
key={name}
name={name}
def={def}
register={register}
@@ -667,12 +732,14 @@ export function ContentForm({
isEdit={isEdit}
locale={locale}
/>
</div>
))}
</CollapsibleSection>
),
)
: Object.entries(fields).map(([name, def]) =>
(def as FieldDefinition).type === "object" && (def as FieldDefinition).fields ? (
(def as FieldDefinition).type === "object" &&
(def as FieldDefinition).fields ? (
<ObjectFieldSet
key={name}
name={name}
@@ -684,8 +751,8 @@ export function ContentForm({
locale={locale}
/>
) : (
<div key={name} className={FIELD_BLOCK_CLASS}>
<Field
key={name}
name={name}
def={def as FieldDefinition}
register={register}
@@ -694,17 +761,20 @@ export function ContentForm({
isEdit={isEdit}
locale={locale}
/>
</div>
),
)}
<div className="flex flex-wrap gap-3 pt-6">
<Button type="submit" disabled={isSubmitting} className="min-h-[44px] sm:min-h-0">
<Button
type="submit"
disabled={isSubmitting}
className="min-h-[44px] sm:min-h-0"
>
{isSubmitting ? t("saving") : t("save")}
</Button>
{isEdit && (
<Button variant="outline" asChild className="min-h-[44px] sm:min-h-0">
<a href={`/content/${collection}${slugParam}`}>
{t("backToList")}
</a>
<a href={`/content/${collection}${slugParam}`}>{t("backToList")}</a>
</Button>
)}
</div>
@@ -745,10 +815,14 @@ function ObjectFieldSet({
const displayName = name.split(".").pop() ?? name;
const title = (def.description as string) || displayName;
return (
<CollapsibleSection title={title} defaultOpen={true} contentClassName="space-y-4">
<CollapsibleSection
title={title}
defaultOpen={true}
contentClassName="space-y-4"
>
{Object.entries(nestedFields).map(([subName, subDef]) => (
<div key={subName} className={FIELD_BLOCK_CLASS}>
<Field
key={subName}
name={`${name}.${subName}`}
def={subDef as FieldDefinition}
register={register}
@@ -757,6 +831,7 @@ function ObjectFieldSet({
isEdit={isEdit}
locale={locale}
/>
</div>
))}
</CollapsibleSection>
);
@@ -804,7 +879,9 @@ function StringMapField({
return (
<div>
{label}
{description ? <p className="mt-0.5 text-xs text-gray-500">{description}</p> : null}
{description ? (
<p className="mt-0.5 text-xs text-gray-500">{description}</p>
) : null}
<div className="mt-2 space-y-2 rounded border border-gray-200 bg-white p-3">
{entries.map(([k, v], i) => (
<div key={i} className="flex flex-wrap items-center gap-2">
@@ -815,7 +892,11 @@ function StringMapField({
placeholder={t("keyPlaceholder")}
className="min-w-[120px] flex-1 rounded border border-gray-300 px-2 py-1.5 text-sm font-mono"
/>
<span className="text-gray-400"></span>
<Icon
icon="mdi:arrow-right"
className="size-4 shrink-0 text-gray-400"
aria-hidden
/>
<input
type="text"
value={v}
@@ -851,6 +932,104 @@ function StringMapField({
);
}
/** Array of strings or numbers: list of inputs with add/remove. */
function PrimitiveArrayField({
def,
value,
onChange,
error,
label,
}: {
name: string;
def: FieldDefinition;
value: unknown;
onChange: (v: (string | number)[]) => void;
required: boolean;
error?: unknown;
label: React.ReactNode;
}) {
const t = useTranslations("ContentForm");
const itemsDef = def.items as FieldDefinition | undefined;
const itemType = (itemsDef?.type ?? "string") as string;
const isNumber = itemType === "number" || itemType === "integer";
const arr = Array.isArray(value) ? value : [];
const list: (string | number)[] = arr.map((v) =>
isNumber ? (typeof v === "number" ? v : Number(v) || 0) : String(v ?? ""),
);
const update = (next: (string | number)[]) => {
onChange(next);
};
const setItem = (index: number, val: string | number) => {
const next = [...list];
next[index] = val;
update(next);
};
const remove = (index: number) => {
update(list.filter((_, i) => i !== index));
};
const add = () => {
update([...list, isNumber ? 0 : ""]);
};
const placeholder = isNumber
? t("arrayItemPlaceholderNumber")
: t("arrayItemPlaceholder");
return (
<div>
{label}
<p className="mt-0.5 text-xs text-muted-foreground">{t("arrayHint")}</p>
<div className="mt-2 space-y-2 rounded-md border border-input bg-muted/20 p-3">
{list.length === 0 ? (
<p className="text-sm text-muted-foreground">{t("arrayAddItem")} </p>
) : null}
{list.map((item, i) => (
<div key={i} className="flex flex-wrap items-center gap-2">
{isNumber ? (
<Input
type="number"
step={itemType === "integer" ? 1 : "any"}
value={item}
onChange={(e) => setItem(i, e.target.valueAsNumber ?? 0)}
placeholder={placeholder}
className="min-w-[120px] flex-1"
/>
) : (
<Input
type="text"
value={item}
onChange={(e) => setItem(i, e.target.value)}
placeholder={placeholder}
className="min-w-[200px] flex-1"
/>
)}
<Button
type="button"
variant="outline"
size="icon-sm"
onClick={() => remove(i)}
title={t("arrayRemoveItem")}
aria-label={t("arrayRemoveItem")}
>
<Icon icon="mdi:delete-outline" className="size-4" />
</Button>
</div>
))}
<Button type="button" variant="outline" size="sm" onClick={add}>
<Icon icon="mdi:plus" className="size-4" />
{t("arrayAddItem")}
</Button>
</div>
{error ? (
<p className="mt-1 text-sm text-red-600">
{String((error as { message?: string })?.message)}
</p>
) : null}
</div>
);
}
function Field({
name,
def,
@@ -890,6 +1069,14 @@ function Field({
</>
);
const safeStringValue = (v: unknown): string => {
if (v == null) return "";
if (typeof v === "string") return v;
if (typeof v === "object" && "_slug" in v)
return String((v as { _slug?: string })._slug ?? "");
return String(v);
};
if (type === "boolean") {
return (
<div>
@@ -906,7 +1093,10 @@ function Field({
/>
)}
/>
<label htmlFor={fieldId} className="cursor-pointer text-sm font-medium text-gray-700">
<label
htmlFor={fieldId}
className="cursor-pointer text-sm font-medium text-gray-700"
>
{name.split(".").pop()}
{required && <span className="text-red-500"> *</span>}
</label>
@@ -973,7 +1163,30 @@ function Field({
);
}
if (type === "richtext" || type === "html") {
if (type === "html") {
return (
<Controller
name={name}
control={control!}
rules={{ required }}
render={({ field }) => (
<CodeField
name={field.name}
value={safeStringValue(field.value)}
onChange={field.onChange}
onBlur={field.onBlur}
language="html"
label={label}
readonly={readonly}
fieldError={fieldError}
copyLabel={t("copyCode")}
/>
)}
/>
);
}
if (type === "richtext") {
return (
<div>
{label}
@@ -1011,7 +1224,8 @@ function Field({
render={({ field }) => {
const { date, time } = parseDatetime(field.value);
const update = (newDate: string, newTime: string) => {
if (newDate && newTime) field.onChange(`${newDate}T${newTime}:00`);
if (newDate && newTime)
field.onChange(`${newDate}T${newTime}:00`);
else if (newDate) field.onChange(`${newDate}T00:00:00`);
else field.onChange("");
};
@@ -1048,7 +1262,8 @@ function Field({
const isRefArray =
type === "array" &&
(def.items?.type === "reference" || def.items?.type === "referenceOrInline");
(def.items?.type === "reference" ||
def.items?.type === "referenceOrInline");
if (isRefArray) {
return (
<Controller
@@ -1071,6 +1286,36 @@ function Field({
);
}
const itemsType =
def.items && typeof def.items === "object"
? (def.items as FieldDefinition).type
: undefined;
const isPrimitiveArray =
type === "array" &&
(itemsType === "string" ||
itemsType === "number" ||
itemsType === "integer");
if (isPrimitiveArray) {
return (
<Controller
name={name}
control={control!}
rules={{ required }}
render={({ field }) => (
<PrimitiveArrayField
name={name}
def={def}
value={field.value}
onChange={field.onChange}
required={required}
error={fieldError}
label={label}
/>
)}
/>
);
}
if (type === "reference") {
return (
<Controller
@@ -1139,7 +1384,9 @@ function Field({
);
}
const additionalProps = def.additionalProperties as FieldDefinition | undefined;
const additionalProps = def.additionalProperties as
| FieldDefinition
| undefined;
if (type === "object" && additionalProps) {
return (
<Controller
@@ -1165,6 +1412,47 @@ function Field({
}
const enumValues = def.enum as string[] | undefined;
if (type === "multiSelect" && Array.isArray(enumValues) && enumValues.length > 0) {
return (
<div>
{label}
<Controller
name={name}
control={control!}
rules={{ required }}
render={({ field }) => {
const selected = Array.isArray(field.value) ? (field.value as string[]) : [];
const toggle = (opt: string) => {
const next = selected.includes(opt)
? selected.filter((s) => s !== opt)
: [...selected, opt];
field.onChange(next);
};
return (
<div className="flex flex-wrap gap-3">
{enumValues.map((opt) => (
<label key={opt} className="flex cursor-pointer items-center gap-2">
<Checkbox
checked={selected.includes(opt)}
onCheckedChange={() => toggle(opt)}
/>
<span className="text-sm">{String(opt)}</span>
</label>
))}
</div>
);
}}
/>
{fieldError ? (
<p className="mt-1 text-sm text-red-600">
{String((fieldError as { message?: string })?.message)}
</p>
) : null}
</div>
);
}
if (Array.isArray(enumValues) && enumValues.length > 0) {
return (
<div>
@@ -1174,7 +1462,10 @@ function Field({
control={control!}
rules={{ required }}
render={({ field }) => (
<Select value={field.value as string} onValueChange={field.onChange}>
<Select
value={field.value as string}
onValueChange={field.onChange}
>
<SelectTrigger className="w-full">
<SelectValue placeholder={t("pleaseSelect")} />
</SelectTrigger>
@@ -1197,14 +1488,6 @@ function Field({
);
}
const safeStringValue = (v: unknown): string => {
if (v == null) return "";
if (typeof v === "string") return v;
if (typeof v === "object" && "_slug" in v)
return String((v as { _slug?: string })._slug ?? "");
return String(v);
};
if (type === "string" && isImageUrlField(def, name)) {
return (
<Controller
@@ -1225,6 +1508,59 @@ function Field({
);
}
const codeLanguage = def.codeLanguage;
if (type === "string" && def.widget === "code" && isCodeFieldLanguage(codeLanguage)) {
return (
<Controller
name={name}
control={control!}
rules={{ required }}
render={({ field }) => (
<CodeField
name={field.name}
value={safeStringValue(field.value)}
onChange={field.onChange}
onBlur={field.onBlur}
language={codeLanguage}
label={label}
readonly={readonly}
fieldError={fieldError}
copyLabel={t("copyCode")}
/>
)}
/>
);
}
if (type === "string" && def.widget === "textarea") {
return (
<div>
{label}
<Controller
name={name}
control={control!}
rules={{ required }}
render={({ field }) => (
<Textarea
name={field.name}
value={safeStringValue(field.value)}
onChange={(e) => field.onChange(e.target.value)}
onBlur={field.onBlur}
readOnly={readonly}
rows={5}
className="min-h-[100px] w-full"
/>
)}
/>
{fieldError ? (
<p className="mt-1 text-sm text-red-600">
{String((fieldError as { message?: string })?.message)}
</p>
) : null}
</div>
);
}
return (
<div>
{label}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,31 @@
{
_slug: "demo-welcome",
title: "Welcome",
body: "This is the demo content. Replace types and content with your own; only this demo type and entry stay in version control.",
image: "/api/assets/mountains-w300.webp",
"_status": "published",
"body": "This is the demo content. Replace types and content with your own; only this demo type and entry stay in version control.",
"bodyHtml": "",
"bodyMarkdown": "",
"bodyRichtext": "",
"bodyTextOrRef": "",
"count": null,
"css": "",
"image": "/api/assets/mountains-w300.webp",
"isActive": false,
"labels": [],
"mainImage": "",
"meta": {
"key": "",
"value": ""
},
"multiSelect": [
"option1",
"option2"
],
"publishedAt": "",
"quantity": null,
"relatedPage": "",
"relatedPages": [],
"scores": [],
"singleSelect": "",
"summary": "",
"tags": [],
"title": "Welcome"
}

View File

@@ -11,6 +11,7 @@ use axum::Json;
use serde_json::{json, Value};
use tokio::sync::RwLock;
use crate::referrers::{Referrer, ReferrerIndex};
use crate::schema::types::{SchemaDefinition, VALID_FIELD_TYPES};
use crate::schema::validator;
use crate::schema::SchemaRegistry;
@@ -53,6 +54,10 @@ pub struct AppState {
pub stores: Option<HashMap<String, Arc<dyn ContentStore>>>,
/// Assets dir per environment when environments is set. Key = env name.
pub assets_dirs: Option<HashMap<String, PathBuf>>,
/// Reverse index for "who references this entry?". Updated on create/update/delete. None when environments are used.
pub referrer_index: Option<Arc<RwLock<ReferrerIndex>>>,
/// Path to persist referrer index (e.g. content/_referrers.json).
pub referrer_index_path: Option<PathBuf>,
}
impl AppState {
@@ -726,6 +731,7 @@ pub async fn create_entry(
// Normalize reference arrays: objects with _slug → slug strings (so clients can send resolved refs)
validator::normalize_reference_arrays(&schema, &mut body);
validator::normalize_multi_select(&schema, &mut body);
// Validate against schema (type checks, constraints, strict mode, …)
let errors = validator::validate_content(&schema, &body);
@@ -766,6 +772,27 @@ pub async fn create_entry(
.map_err(ApiError::from)?;
state.cache.invalidate_collection(&env, &collection).await;
// Update referrer index: this entry references (ref_coll, ref_slug)
if let (Some(ref idx), Some(ref path)) = (&state.referrer_index, &state.referrer_index_path) {
let refs = validator::extract_references(&schema, &body);
let referrer = Referrer {
collection: collection.clone(),
slug: slug.clone(),
field: String::new(), // we add per (ref_coll, ref_slug, field) below
locale: locale_ref.map(str::to_string),
};
let mut index = idx.write().await;
for (ref_coll, ref_slug, field) in refs {
let mut r = referrer.clone();
r.field = field;
index.add_referrer(&ref_coll, &ref_slug, r);
}
drop(index);
if let Err(e) = idx.read().await.save(path) {
tracing::warn!("Failed to save referrer index: {}", e);
}
}
// Return created entry (with reference format)
let entry = store
.get(&collection, &slug, locale_ref)
@@ -846,6 +873,7 @@ pub async fn update_entry(
// Normalize reference arrays: objects with _slug → slug strings (so clients can send resolved refs)
validator::normalize_reference_arrays(&schema, &mut body);
validator::normalize_multi_select(&schema, &mut body);
// Load existing content for readonly check
let existing = store
@@ -902,6 +930,28 @@ pub async fn update_entry(
.map_err(ApiError::from)?;
state.cache.invalidate_collection(&env, &collection).await;
// Update referrer index: remove old refs from this entry, add new refs
if let (Some(ref idx), Some(ref path)) = (&state.referrer_index, &state.referrer_index_path) {
let mut index = idx.write().await;
index.remove_all_referrers_from(&collection, &slug);
let refs = validator::extract_references(&schema, &body);
let referrer = Referrer {
collection: collection.clone(),
slug: slug.clone(),
field: String::new(),
locale: locale_ref.map(str::to_string),
};
for (ref_coll, ref_slug, field) in refs {
let mut r = referrer.clone();
r.field = field;
index.add_referrer(&ref_coll, &ref_slug, r);
}
drop(index);
if let Err(e) = idx.read().await.save(path) {
tracing::warn!("Failed to save referrer index: {}", e);
}
}
// Return updated entry (with reference format)
let entry = store
.get(&collection, &slug, locale_ref)
@@ -933,6 +983,22 @@ pub async fn update_entry(
Ok(Json(formatted))
}
// ---------------------------------------------------------------------------
// GET /api/content/:collection/:slug/referrers
// ---------------------------------------------------------------------------
pub async fn get_referrers(
State(state): State<Arc<AppState>>,
Path((collection, slug)): Path<(String, String)>,
) -> Result<Json<Value>, ApiError> {
let referrers = match &state.referrer_index {
Some(idx) => idx.read().await.get_referrers(&collection, &slug),
None => vec![],
};
Ok(Json(
serde_json::to_value(&referrers).unwrap_or_else(|_| json!([])),
))
}
// ---------------------------------------------------------------------------
// GET /api/locales
// ---------------------------------------------------------------------------
@@ -982,6 +1048,17 @@ pub async fn delete_entry(
)));
}
// Update referrer index: remove this entry from every (ref_coll, ref_slug) it referenced; and remove key (collection, slug)
if let (Some(ref idx), Some(ref path)) = (&state.referrer_index, &state.referrer_index_path) {
let mut index = idx.write().await;
index.remove_all_referrers_from(&collection, &slug);
index.remove_referenced_entry(&collection, &slug);
drop(index);
if let Err(e) = idx.read().await.save(path) {
tracing::warn!("Failed to save referrer index: {}", e);
}
}
store
.delete(&collection, &slug, locale_ref)
.await

View File

@@ -235,6 +235,41 @@ pub fn generate_spec(registry: &SchemaRegistry, server_url: &str) -> Value {
}
}),
);
// GET /api/content/:collection/:slug/referrers who references this entry
paths.insert(
format!("/api/content/{}/{{slug}}/referrers", name),
json!({
"get": {
"summary": format!("List referrers of '{}' entry", name),
"description": "Returns all entries that reference this entry (reverse index). Empty when not using referrer index (e.g. with RUSTYCMS_ENVIRONMENTS).",
"operationId": format!("get{}Referrers", pascal),
"tags": [tag],
"parameters": [
{ "name": "slug", "in": "path", "required": true, "schema": { "type": "string" }, "description": "Entry slug" }
],
"responses": {
"200": {
"description": "List of referrers (collection, slug, field, locale)",
"content": { "application/json": {
"schema": {
"type": "array",
"items": {
"type": "object",
"properties": {
"collection": { "type": "string", "description": "Collection of the referring entry" },
"slug": { "type": "string", "description": "Slug of the referring entry" },
"field": { "type": "string", "description": "Field that holds the reference" },
"locale": { "type": "string", "nullable": true, "description": "Locale of the referring entry if applicable" }
}
}
}
}}
}
}
}
}),
);
}
// ── Asset management ─────────────────────────────────────────────────
@@ -701,6 +736,7 @@ const API_INDEX_HTML: &str = r#"<!DOCTYPE html>
<li><code>POST</code> /api/content/:type Create entry</li>
<li><code>PUT</code> /api/content/:type/:slug Update entry</li>
<li><code>DELETE</code> /api/content/:type/:slug Delete entry</li>
<li><code>GET</code> /api/content/:type/:slug/referrers List entries that reference this entry (reverse index)</li>
<li><code>GET</code> <a href="/api/transform?url=https://httpbin.org/image/png&w=80&h=80">/api/transform</a> Transform image from URL (w, h, ar, fit, format)</li>
<li><code>GET</code> <a href="/health">/health</a> Health check</li>
</ul>

View File

@@ -41,6 +41,10 @@ pub fn create_router(state: Arc<AppState>) -> Router {
"/api/content/:collection",
get(handlers::list_entries).post(handlers::create_entry),
)
.route(
"/api/content/:collection/:slug/referrers",
get(handlers::get_referrers),
)
.route(
"/api/content/:collection/:slug",
get(handlers::get_entry)

View File

@@ -1,5 +1,6 @@
//! Library for RustyCMS (used by the main server binary and by tools like export-json-schema).
pub mod api;
pub mod referrers;
pub mod schema;
pub mod store;

View File

@@ -13,6 +13,8 @@ use tracing_subscriber::EnvFilter;
use rustycms::api::cache::ContentCache;
use rustycms::api::handlers::AppState;
use rustycms::referrers::{Referrer, ReferrerIndex};
use rustycms::schema::validator;
use rustycms::schema::SchemaRegistry;
use rustycms::store::{filesystem::FileStore, sqlite::SqliteStore, ContentStore};
@@ -69,20 +71,14 @@ fn detect_locales(content_dir: &std::path::Path) -> Option<Vec<String>> {
}
fn reload_schemas(
types_dir: &PathBuf,
server_url: &str,
registry: &Arc<RwLock<SchemaRegistry>>,
openapi_spec: &Arc<RwLock<serde_json::Value>>,
cache: &Arc<ContentCache>,
rt_handle: tokio::runtime::Handle,
types_dir: PathBuf,
server_url: String,
registry: Arc<RwLock<SchemaRegistry>>,
openapi_spec: Arc<RwLock<serde_json::Value>>,
cache: Arc<ContentCache>,
) {
let types_dir = types_dir.clone();
let server_url = server_url.to_string();
let registry = Arc::clone(registry);
let openapi_spec = Arc::clone(openapi_spec);
let cache = Arc::clone(cache);
std::thread::spawn(move || {
let rt = tokio::runtime::Handle::current();
rt.block_on(async move {
rt_handle.spawn(async move {
match SchemaRegistry::load(&types_dir) {
Ok(new_registry) => {
let spec = rustycms::api::openapi::generate_spec(&new_registry, &server_url);
@@ -96,7 +92,6 @@ fn reload_schemas(
}
}
});
});
}
#[tokio::main]
@@ -223,6 +218,67 @@ async fn main() -> anyhow::Result<()> {
tracing::info!("Webhooks enabled: {} URL(s)", webhook_urls.len());
}
// Reverse referrer index (file-based in content dir). Only when not using environments (single content root).
// When the index file is missing, run a full reindex over all collections and save.
let (referrer_index, referrer_index_path) = if environments.is_none() {
let path = assets_dir.parent().unwrap().join("_referrers.json");
let index = if path.exists() {
ReferrerIndex::load(&path)
} else {
tracing::info!("Referrer index not found, building full index from content…");
let mut index = ReferrerIndex::new();
let collections_with_schema: Vec<(String, rustycms::schema::types::SchemaDefinition)> = {
let guard = registry.read().await;
guard
.collection_names()
.into_iter()
.filter_map(|c| {
guard.get(&c).filter(|s| !s.reusable).map(|s| (c, s.clone()))
})
.collect()
};
for (collection, schema) in collections_with_schema {
let locale_opts: Vec<Option<&str>> = locales
.as_ref()
.map(|l| l.iter().map(|s| s.as_str()).map(Some).collect())
.unwrap_or_else(|| vec![None]);
for locale_ref in locale_opts {
match store.list(&collection, locale_ref).await {
Ok(entries) => {
for (slug, value) in entries {
let refs = validator::extract_references(&schema, &value);
let referrer = Referrer {
collection: collection.clone(),
slug: slug.clone(),
field: String::new(),
locale: locale_ref.map(str::to_string),
};
for (ref_coll, ref_slug, field) in refs {
let mut r = referrer.clone();
r.field = field;
index.add_referrer(&ref_coll, &ref_slug, r);
}
}
}
Err(e) => tracing::warn!("List {} (locale {:?}) failed: {}", collection, locale_ref, e),
}
}
}
if let Err(e) = index.save(&path) {
tracing::warn!("Failed to save referrer index: {}", e);
} else {
tracing::info!("Referrer index saved to {}", path.display());
}
index
};
if path.exists() {
tracing::info!("Referrer index loaded from {}", path.display());
}
(Some(Arc::new(RwLock::new(index))), Some(path))
} else {
(None, None)
};
let state = Arc::new(AppState {
registry: Arc::clone(&registry),
store,
@@ -239,9 +295,12 @@ async fn main() -> anyhow::Result<()> {
environments,
stores: stores_map,
assets_dirs: assets_dirs_map,
referrer_index,
referrer_index_path,
});
// Hot-reload: watch types_dir and reload schemas on change
// Hot-reload: watch types_dir and reload schemas on change (run reload on main Tokio runtime from watcher thread)
let rt_handle = tokio::runtime::Handle::current();
let types_dir_for_callback = cli.types_dir.canonicalize().unwrap_or_else(|_| cli.types_dir.clone());
let (tx, rx) = std::sync::mpsc::channel();
let mut watcher = RecommendedWatcher::new(
@@ -275,7 +334,14 @@ async fn main() -> anyhow::Result<()> {
// Debounce: wait for editor to finish writing, drain extra events, then reload once
std::thread::sleep(Duration::from_millis(800));
while rx.try_recv().is_ok() {}
reload_schemas(&types_dir_watch, &server_url_watch, &registry, &openapi_spec, &cache);
reload_schemas(
rt_handle.clone(),
types_dir_watch.clone(),
server_url_watch.clone(),
Arc::clone(&registry),
Arc::clone(&openapi_spec),
Arc::clone(&cache),
);
}
});
tracing::info!("Hot-reload: watching {}", cli.types_dir.display());

123
src/referrers.rs Normal file
View File

@@ -0,0 +1,123 @@
//! Reverse index: for each (collection, slug) store which entries reference it.
//! Updated on create/update/delete so we can answer "where is this entry referenced?".
//! Persisted as a single JSON file in the content directory (e.g. `content/_referrers.json`).
use std::collections::HashMap;
use std::path::Path;
use serde::{Deserialize, Serialize};
/// One referrer: an entry that points to another via a reference field.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
pub struct Referrer {
pub collection: String,
pub slug: String,
pub field: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub locale: Option<String>,
}
fn key(collection: &str, slug: &str) -> String {
format!("{}:{}", collection, slug)
}
/// In-memory index: (ref_collection, ref_slug) -> list of referrers.
/// Persisted as JSON file so it survives restarts.
#[derive(Debug, Default, Clone)]
pub struct ReferrerIndex {
/// "collection:slug" -> list of referrers
index: HashMap<String, Vec<Referrer>>,
}
impl ReferrerIndex {
pub fn new() -> Self {
Self::default()
}
/// Load index from JSON file. Missing or invalid file => empty index.
pub fn load(path: &Path) -> Self {
let data = match std::fs::read_to_string(path) {
Ok(s) => s,
_ => return Self::new(),
};
let index: HashMap<String, Vec<Referrer>> = match serde_json::from_str(&data) {
Ok(m) => m,
_ => return Self::new(),
};
Self { index }
}
/// Persist index to JSON file.
pub fn save(&self, path: &Path) -> std::io::Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let data = serde_json::to_string_pretty(&self.index)?;
std::fs::write(path, data)
}
/// Remove this (referrer_collection, referrer_slug) from the referrer list of (ref_collection, ref_slug).
pub fn remove_referrer(
&mut self,
ref_collection: &str,
ref_slug: &str,
referrer: &Referrer,
) {
let k = key(ref_collection, ref_slug);
if let Some(list) = self.index.get_mut(&k) {
list.retain(|r| {
r.collection != referrer.collection
|| r.slug != referrer.slug
|| r.field != referrer.field
|| r.locale != referrer.locale
});
if list.is_empty() {
self.index.remove(&k);
}
}
}
/// Add this referrer to the list for (ref_collection, ref_slug).
pub fn add_referrer(
&mut self,
ref_collection: &str,
ref_slug: &str,
referrer: Referrer,
) {
let k = key(ref_collection, ref_slug);
self.index
.entry(k)
.or_default()
.push(referrer);
}
/// Remove all referrers that are (referrer_collection, referrer_slug) from every key.
/// Used when an entry is updated: clear its old refs, then add new refs.
pub fn remove_all_referrers_from(&mut self, referrer_collection: &str, referrer_slug: &str) {
let keys_to_check: Vec<String> = self.index.keys().cloned().collect();
for k in keys_to_check {
let empty = if let Some(list) = self.index.get_mut(&k) {
list.retain(|r| r.collection != referrer_collection || r.slug != referrer_slug);
list.is_empty()
} else {
false
};
if empty {
self.index.remove(&k);
}
}
}
/// Get all referrers of (collection, slug).
pub fn get_referrers(&self, collection: &str, slug: &str) -> Vec<Referrer> {
self.index
.get(&key(collection, slug))
.cloned()
.unwrap_or_default()
}
/// Remove the list for (ref_collection, ref_slug). Used when that entry is deleted.
pub fn remove_referenced_entry(&mut self, ref_collection: &str, ref_slug: &str) {
self.index.remove(&key(ref_collection, ref_slug));
}
}

View File

@@ -81,7 +81,7 @@ fn field_to_json_schema(fd: &FieldDefinition) -> Value {
}
}
"reference" => {
let desc = if let Some(ref list) = fd.collections {
let mut desc = if let Some(ref list) = fd.collections {
if list.is_empty() {
"Reference (slug)".to_string()
} else {
@@ -92,10 +92,20 @@ fn field_to_json_schema(fd: &FieldDefinition) -> Value {
} else {
"Reference (slug)".to_string()
};
if let Some(ref allowed) = fd.allowed_slugs {
if !allowed.is_empty() {
desc.push_str(&format!(" Allowed slugs: {}.", allowed.join(", ")));
}
}
if let Some(ref allowed) = fd.allowed_collections {
if !allowed.is_empty() {
desc.push_str(&format!(" Allowed content types: {}.", allowed.join(", ")));
}
}
json!({ "type": "string", "description": desc })
},
"referenceOrInline" => {
let ref_desc = if let Some(ref list) = fd.collections {
let mut ref_desc = if let Some(ref list) = fd.collections {
if list.is_empty() {
"Reference (slug)".to_string()
} else {
@@ -106,6 +116,16 @@ fn field_to_json_schema(fd: &FieldDefinition) -> Value {
} else {
"Reference (slug)".to_string()
};
if let Some(ref allowed) = fd.allowed_slugs {
if !allowed.is_empty() {
ref_desc.push_str(&format!(" Allowed slugs: {}.", allowed.join(", ")));
}
}
if let Some(ref allowed) = fd.allowed_collections {
if !allowed.is_empty() {
ref_desc.push_str(&format!(" Allowed content types: {}.", allowed.join(", ")));
}
}
let ref_schema = json!({ "type": "string", "description": ref_desc });
let inline_schema = if let Some(ref nested) = fd.fields {
let mut props = serde_json::Map::new();
@@ -128,6 +148,14 @@ fn field_to_json_schema(fd: &FieldDefinition) -> Value {
};
json!({ "oneOf": [ ref_schema, inline_schema ] })
},
"multiSelect" => {
let items = if let Some(ref ev) = fd.enum_values {
json!({ "type": "string", "enum": ev })
} else {
json!({ "type": "string" })
};
json!({ "type": "array", "items": items })
},
_ => json!({ "type": "string" }),
};
@@ -136,9 +164,11 @@ fn field_to_json_schema(fd: &FieldDefinition) -> Value {
if let Some(ref desc) = fd.description {
obj.insert("description".to_string(), json!(desc));
}
if fd.field_type != "multiSelect" {
if let Some(ref ev) = fd.enum_values {
obj.insert("enum".to_string(), json!(ev));
}
}
if let Some(ref dv) = fd.default {
obj.insert("default".to_string(), dv.clone());
}

View File

@@ -150,6 +150,14 @@ pub struct FieldDefinition {
#[serde(skip_serializing_if = "Option::is_none", default)]
pub collections: Option<Vec<String>>,
/// Optional whitelist of allowed slugs for reference fields. If set and non-empty, only these slugs are valid.
#[serde(rename = "allowedSlugs", skip_serializing_if = "Option::is_none", default)]
pub allowed_slugs: Option<Vec<String>>,
/// Optional whitelist of allowed content types (collections) for reference fields. If set and non-empty, only entries from these collections are valid (intersection with collection/collections).
#[serde(rename = "allowedCollections", skip_serializing_if = "Option::is_none", default)]
pub allowed_collections: Option<Vec<String>>,
/// Human-readable description (appears in Swagger UI).
#[serde(skip_serializing_if = "Option::is_none", default)]
pub description: Option<String>,
@@ -158,6 +166,14 @@ pub struct FieldDefinition {
#[serde(skip_serializing_if = "Option::is_none", default)]
pub section: Option<String>,
/// Optional hint for admin UI how to render the field (e.g. "textarea" for string → multi-line input, "code" for code editor with syntax highlighting).
#[serde(skip_serializing_if = "Option::is_none", default)]
pub widget: Option<String>,
/// When widget is "code", language for syntax highlighting (e.g. "css", "javascript", "json"). Passed through to admin UI.
#[serde(rename = "codeLanguage", skip_serializing_if = "Option::is_none", default)]
pub code_language: Option<String>,
// ── String constraints ───────────────────────────────────────────────
#[serde(skip_serializing_if = "Option::is_none", default, rename = "minLength")]
@@ -196,6 +212,7 @@ pub const VALID_FIELD_TYPES: &[&str] = &[
"richtext", "html", "markdown",
"textOrRef", // string = inline text, or "file:path" = content loaded from file
"array", "object", "reference", "referenceOrInline",
"multiSelect", // array of strings, each must be one of enum; UI: checkboxes or multi-select
];
impl FieldDefinition {
@@ -204,15 +221,25 @@ impl FieldDefinition {
}
/// Collections to try for reference resolution/validation (polymorphic or single).
/// When `allowed_collections` is set and non-empty, returns the intersection with collection(s).
pub fn reference_collections(&self) -> Vec<&str> {
if let Some(ref list) = self.collections {
if !list.is_empty() {
return list.iter().map(String::as_str).collect();
}
}
if let Some(ref c) = self.collection {
return vec![c.as_str()];
}
let base: Vec<&str> = if let Some(ref list) = self.collections {
if list.is_empty() {
vec![]
} else {
list.iter().map(String::as_str).collect()
}
} else if let Some(ref c) = self.collection {
vec![c.as_str()]
} else {
vec![]
};
if let Some(ref allowed) = self.allowed_collections {
if !allowed.is_empty() {
let set: std::collections::HashSet<_> = allowed.iter().map(String::as_str).collect();
return base.into_iter().filter(|c| set.contains(c)).collect();
}
}
base
}
}

View File

@@ -96,11 +96,12 @@ fn validate_field(
errors: &mut Vec<ValidationError>,
) {
// ── Null handling ────────────────────────────────────────────────────
// Optional fields (not required) may be null; required fields may not.
if value.is_null() {
if !fd.nullable {
if fd.required {
errors.push(ValidationError {
field: field_name.to_string(),
message: "Field does not allow null (set nullable: true to permit)".to_string(),
message: "Field is required".to_string(),
});
}
return;
@@ -112,7 +113,7 @@ fn validate_field(
"number" => value.is_number(),
"integer" => value.is_i64() || value.is_u64() || value.as_f64().map(|f| f.fract() == 0.0 && f.is_finite()).unwrap_or(false),
"boolean" => value.is_boolean(),
"array" => value.is_array(),
"array" | "multiSelect" => value.is_array(),
"object" => value.is_object(),
"reference" => value.is_string(),
"referenceOrInline" => value.is_string() || value.is_object(),
@@ -131,15 +132,41 @@ fn validate_field(
return; // no point checking constraints if type is wrong
}
// ── Enum constraint ─────────────────────────────────────────────────
// ── Enum constraint (single value) ───────────────────────────────────
// Optional string+enum: empty string = no selection, allowed
if fd.field_type != "multiSelect" {
if let Some(ref allowed) = fd.enum_values {
if !allowed.contains(value) {
let empty_ok = !fd.required && value.as_str().map_or(false, |s| s.is_empty());
if !empty_ok {
errors.push(ValidationError {
field: field_name.to_string(),
message: format!("Value must be one of: {:?}", allowed),
});
}
}
}
}
// ── multiSelect: array of strings, each must be in enum ──────────────
if fd.field_type == "multiSelect" {
if let Some(arr) = value.as_array() {
let allowed: Vec<&Value> = fd.enum_values.as_ref().map(|e| e.iter().collect()).unwrap_or_default();
for (i, item) in arr.iter().enumerate() {
if !item.is_string() {
errors.push(ValidationError {
field: format!("{}[{}]", field_name, i),
message: "Each item must be a string".to_string(),
});
} else if !allowed.is_empty() && !allowed.iter().any(|v| v.as_str() == item.as_str()) {
errors.push(ValidationError {
field: format!("{}[{}]", field_name, i),
message: format!("Item must be one of: {:?}", fd.enum_values),
});
}
}
}
}
// ── String constraints ──────────────────────────────────────────────
if let Some(s) = value.as_str() {
@@ -270,6 +297,11 @@ pub fn apply_defaults(schema: &SchemaDefinition, content: &mut Value) {
continue;
}
if fd.field_type == "multiSelect" {
obj.insert(name.clone(), Value::Array(vec![]));
continue;
}
if let Some(ref default_value) = fd.default {
obj.insert(name.clone(), default_value.clone());
continue;
@@ -285,6 +317,27 @@ pub fn apply_defaults(schema: &SchemaDefinition, content: &mut Value) {
}
}
// ---------------------------------------------------------------------------
// Normalize multiSelect: string (e.g. "") → [] before validation
// ---------------------------------------------------------------------------
/// Coerce multiSelect fields that have a string value (e.g. from old data or form) to [].
pub fn normalize_multi_select(schema: &SchemaDefinition, content: &mut Value) {
let obj = match content.as_object_mut() {
Some(o) => o,
None => return,
};
for (field_name, fd) in &schema.fields {
if fd.field_type == "multiSelect" {
if let Some(v) = obj.get_mut(field_name) {
if v.is_string() {
*v = Value::Array(vec![]);
}
}
}
}
}
// ---------------------------------------------------------------------------
// Normalize reference arrays (used before validation on create/update)
// ---------------------------------------------------------------------------
@@ -458,6 +511,9 @@ pub fn validate_references(
continue;
}
if let Some(Value::String(slug)) = obj.get(field_name) {
if slug.trim().is_empty() {
continue; // empty = no selection, valid for optional reference
}
let found = colls.iter().any(|c| entry_exists(c, slug));
if !found {
errors.push(ValidationError {
@@ -467,6 +523,16 @@ pub fn validate_references(
slug, colls
),
});
} else if let Some(ref allowed) = fd.allowed_slugs {
if !allowed.is_empty() && !allowed.iter().any(|s| s.as_str() == slug) {
errors.push(ValidationError {
field: field_name.clone(),
message: format!(
"Slug '{}' is not in the allowed list for this reference",
slug
),
});
}
}
}
continue;
@@ -485,6 +551,9 @@ pub fn validate_references(
if let Some(Value::Array(arr)) = obj.get(field_name) {
for (i, item) in arr.iter().enumerate() {
if let Some(slug) = item.as_str() {
if slug.trim().is_empty() {
continue; // empty = no selection in list
}
let found = colls.iter().any(|c| entry_exists(c, slug));
if !found {
errors.push(ValidationError {
@@ -494,6 +563,16 @@ pub fn validate_references(
slug, colls
),
});
} else if let Some(ref allowed) = items.allowed_slugs {
if !allowed.is_empty() && !allowed.iter().any(|s| s.as_str() == slug) {
errors.push(ValidationError {
field: format!("{}[{}]", field_name, i),
message: format!(
"Slug '{}' is not in the allowed list for this reference",
slug
),
});
}
}
}
}
@@ -505,3 +584,77 @@ pub fn validate_references(
errors
}
// ---------------------------------------------------------------------------
// Extract references (for reverse index / referrers)
// ---------------------------------------------------------------------------
/// One referenced entry: (collection, slug). Used to update the referrer index.
fn parse_ref_value(value: &str, collections: &[&str]) -> Option<(String, String)> {
let value = value.trim();
if value.is_empty() {
return None;
}
if let Some((coll, slug)) = value.split_once(':') {
let slug = slug.trim();
if !slug.is_empty() && collections.contains(&coll) {
return Some((coll.to_string(), slug.to_string()));
}
}
if let Some(&coll) = collections.first() {
return Some((coll.to_string(), value.to_string()));
}
None
}
/// One reference: (referenced collection, referenced slug, field name).
pub type ExtractedRef = (String, String, String);
/// Extract all (referenced collection, referenced slug, field name) from content for referrer index updates.
/// Only considers reference and referenceOrInline (string) fields; referenceOrInline as object is inline, not a ref.
pub fn extract_references(schema: &SchemaDefinition, content: &Value) -> Vec<ExtractedRef> {
let mut out = Vec::new();
let obj = match content.as_object() {
Some(o) => o,
None => return out,
};
for (field_name, fd) in &schema.fields {
let is_ref = fd.field_type == "reference"
|| (fd.field_type == "referenceOrInline" && obj.get(field_name).map_or(false, |v| v.is_string()));
if is_ref {
let colls = fd.reference_collections();
if colls.is_empty() {
continue;
}
if let Some(Value::String(s)) = obj.get(field_name) {
if let Some((c, slug)) = parse_ref_value(s, &colls) {
out.push((c, slug, field_name.clone()));
}
}
continue;
}
if fd.field_type == "array" {
if let Some(ref items) = fd.items {
let is_ref_item = items.field_type == "reference"
|| items.field_type == "referenceOrInline";
if !is_ref_item {
continue;
}
let colls = items.reference_collections();
if colls.is_empty() {
continue;
}
if let Some(Value::Array(arr)) = obj.get(field_name) {
for item in arr.iter() {
if let Some(s) = item.as_str() {
if let Some((c, slug)) = parse_ref_value(s, &colls) {
out.push((c, slug, field_name.clone()));
}
}
}
}
}
}
}
out
}

View File

@@ -1,21 +1,119 @@
{
name: "demo",
description: "Demo content type (kept in version control as example).",
tags: ["content"],
description: "Demo content type documents all supported field types (kept in version control as example).",
tags: [
"content"
],
category: "content",
fields: {
title: {
type: "string",
required: true,
description: "Title",
description: "string single line (required)",
},
body: {
summary: {
type: "string",
description: "Optional body text",
description: "string single line, optional",
},
image: {
css: {
type: "string",
description: "Image URL (asset path)",
widget: "code",
codeLanguage: "css",
description: "string + widget: code, codeLanguage: css code field with syntax highlighting and copy",
},
singleSelect: {
type: "string",
enum: [
"A",
"B",
"C"
],
description: "string + enum single-value dropdown (Select)",
},
count: {
type: "number",
description: "number floating point",
},
quantity: {
type: "integer",
description: "integer whole number",
},
isActive: {
type: "boolean",
description: "boolean checkbox, true/false",
},
publishedAt: {
type: "datetime",
description: "datetime ISO 8601 date-time",
},
bodyRichtext: {
type: "richtext",
description: "richtext rich text editor",
},
bodyHtml: {
type: "html",
description: "html HTML content",
},
bodyMarkdown: {
type: "markdown",
description: "markdown Markdown content",
},
bodyTextOrRef: {
type: "textOrRef",
description: "textOrRef inline text or file:path to load from file",
},
tags: {
type: "multiSelect",
enum: [
"news",
"tutorial",
"announcement"
],
description: "multiSelect + enum checkboxes, value is string[]",
},
labels: {
type: "array",
items: {
type: "string"
},
description: "array items type string (list of strings)",
},
scores: {
type: "array",
items: {
type: "number"
},
description: "array items type number",
},
relatedPages: {
type: "array",
items: {
type: "reference",
collection: "page"
},
description: "array items type reference (slugs to page collection)",
},
meta: {
type: "object",
fields: {
key: {
type: "string"
},
value: {
type: "string"
},
},
description: "object fixed sub-fields (key, value)",
},
mainImage: {
type: "reference",
collection: "img",
description: "reference single slug to collection (e.g. img)",
},
relatedPage: {
type: "referenceOrInline",
collection: "page",
description: "referenceOrInline slug ref or inline object (collection must exist)",
},
},
}