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:
14
.claude/instructions.md
Normal file
14
.claude/instructions.md
Normal 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 1–2 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**: 2–4 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.
|
||||
13
CLAUDE.md
13
CLAUDE.md
@@ -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 1–2 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 2–4 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
|
||||
|
||||
```
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
373
admin-ui/package-lock.json
generated
373
admin-ui/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
190
admin-ui/src/app/admin/DefaultValueField.tsx
Normal file
190
admin-ui/src/app/admin/DefaultValueField.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
249
admin-ui/src/app/settings/SettingsContent.tsx
Normal file
249
admin-ui/src/app/settings/SettingsContent.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
11
admin-ui/src/app/settings/page.tsx
Normal file
11
admin-ui/src/app/settings/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
86
admin-ui/src/components/CodeBlock.tsx
Normal file
86
admin-ui/src/components/CodeBlock.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
65
admin-ui/src/components/CodeField.tsx
Normal file
65
admin-ui/src/components/CodeField.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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"}` : "";
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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`}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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={() => {
|
||||
|
||||
217
admin-ui/src/components/TypeDependencyGraph.tsx
Normal file
217
admin-ui/src/components/TypeDependencyGraph.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
98
src/main.rs
98
src/main.rs
@@ -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(®istry),
|
||||
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, ®istry, &openapi_spec, &cache);
|
||||
reload_schemas(
|
||||
rt_handle.clone(),
|
||||
types_dir_watch.clone(),
|
||||
server_url_watch.clone(),
|
||||
Arc::clone(®istry),
|
||||
Arc::clone(&openapi_spec),
|
||||
Arc::clone(&cache),
|
||||
);
|
||||
}
|
||||
});
|
||||
tracing::info!("Hot-reload: watching {}", cli.types_dir.display());
|
||||
|
||||
123
src/referrers.rs
Normal file
123
src/referrers.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
112
types/demo.json5
112
types/demo.json5
@@ -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)",
|
||||
},
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user