chore: custom content types design groundwork
This commit is contained in:
1 parent
58d58f5a04
commit
e6e088c963
12 files changed
+929
-3
No files matched your search
@@ -20,6 +20,12 @@ Back-office board groups: Sites, Sites: form, Sites: delete, Authors, Authors: f
|
|||||||
- Only individual states that render a relative time or a countdown carry the "Clock-sensitive" tag (13 states, listed in states.md). No Home or Articles frame is tagged as a whole.
|
- Only individual states that render a relative time or a countdown carry the "Clock-sensitive" tag (13 states, listed in states.md). No Home or Articles frame is tagged as a whole.
|
||||||
- Role-restricted variants drawn: read-only Authors list and detail, hidden Sites entry, Sites 403, own-author edit.
|
- Role-restricted variants drawn: read-only Authors list and detail, hidden Sites entry, Sites 403, own-author edit.
|
||||||
|
|
||||||
|
## Pass 4: custom content types
|
||||||
|
Board `RenewCMS Screens Schema.dc.html` (frames: `SchemaFrame`, mounted in `AppFrame`): 9 groups, 65 states (56 available now, 9 later). 1 Schema list 6, 2 Schema builder 16, 3 Review and delete 7, 4 Field types 6, 5 Content and entry list 9, 6 Entry editor 13, 7 Relations 3, 8 Permissions 2, 9 Export and import 3. Plus tables: scope, routes, new components with their Nuxt UI mapping, errors to copy, needs backend (4 rows), decisions to confirm, accessibility. Specs: screens/schema.md, content.md, entry.md, schema-later.md.
|
||||||
|
- AppFrame gains a Schema item (owner and admin) and a Content group (sidebar and mobile drawer). Nothing else in AppFrame changed.
|
||||||
|
- Backend gaps still open: built-in types and fields, display names for created_by and updated_by, a type icon, per-change entry counts.
|
||||||
|
- Not done: Back-office and Prototype boards were not compared visually after the sidebar change (see Checks below).
|
||||||
|
|
||||||
## Specs
|
## Specs
|
||||||
routes.md · components.md · states.md · foundations.md · change-list.md · activity-spec.js · screens/*.md (24 files)
|
routes.md · components.md · states.md · foundations.md · change-list.md · activity-spec.js · screens/*.md (24 files)
|
||||||
|
|
||||||
|
|||||||
@@ -89,3 +89,35 @@ Frames: `DirectoryFrame.dc.html` (Sites, Authors, editor section content; the li
|
|||||||
| SeoStatusChip | New | UBadge | Lightbulb + count (warning soft) or eye-off "Hidden" (outline). Articles rows and cards |
|
| SeoStatusChip | New | UBadge | Lightbulb + count (warning soft) or eye-off "Hidden" (outline). Articles rows and cards |
|
||||||
| NeedsAttentionToggle | New | UButton (pressed state) | Articles toolbar, sand when on |
|
| NeedsAttentionToggle | New | UButton (pressed state) | Articles toolbar, sand when on |
|
||||||
| TitleTemplateField | New | UInput + live example | Sites form |
|
| TitleTemplateField | New | UInput + live example | Sites form |
|
||||||
|
|
||||||
|
|
||||||
|
## Custom content types (pass 4)
|
||||||
|
|
||||||
|
Frames: `SchemaFrame.dc.html` (all schema, content and entry bodies; relation, permissions and import frames are Later), mounted in `AppFrame.dc.html`. Existing pieces are reused unchanged: dialogs and sheets, toasts, alerts, paginator, skeletons, empty states, SlugField, CodeSnippet, the single switch.
|
||||||
|
|
||||||
|
**AppFrame changes.** Sidebar: a "Schema" item (icon shapes) after Users, owner and admin only. A "Content" group under the main items, one row per custom type (default icon files, name truncated with the full name in the tooltip, selected row sand). Over 5 types it shows the first 5 and the selected one, then "Show all N". Mobile drawer: the group is one collapsed row "All content types" with the count; expanded it lists the types at 44 px. New props on the shell state: `content` (types), `contentOn`, `contentOpen`. The group is hidden when there are no custom types. No existing item changed.
|
||||||
|
|
||||||
|
| Component | Status | Nuxt UI | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| SchemaTable | New | UTable; below 640 px a list of UCard | Same pattern as SitesTable. Built-in rows add the lock and a Built-in badge |
|
||||||
|
| BuiltInBadge | New | UBadge outline + lock icon | Existing badges have no lock; same size as the Linked badge |
|
||||||
|
| FieldList | New | List of UButton rows, useSortable, UBadge | No Nuxt UI sortable list. Keyboard: the grip is a button; Space picks up, arrows move, Space drops, Esc cancels. Move up and Move down buttons on the selected row. Same approach as AuthorList |
|
||||||
|
| FieldInspector | New | UForm, UFormField, UInput, UInputNumber, UInputDate, USelect, UTextarea, USwitch | Composition only. Controls follow the field type |
|
||||||
|
| FieldTypePicker | New | URadioGroup variant="card" in a grid, icon in the label | Existing radio cards fit; only the icon and the lock state are added |
|
||||||
|
| KeyField | Existing | SlugField (UInput + UButton + UBadge) | Reused for the type key and field keys. Locked variant already exists |
|
||||||
|
| ChoicesEditor | New | UInput + UButton rows, useSortable | UInputTags can't reorder or flag a blank or duplicate per row |
|
||||||
|
| ApiPreview | New | UCollapsible + CodeSnippet + UButton | CodeSnippet is reused. Sheet on mobile |
|
||||||
|
| ChangeReview | New | UModal (sheet on mobile), UBadge, list rows | Dialog pattern unchanged. Rows reuse the UAlert-style row of SuggestionList |
|
||||||
|
| SchemaRefusal, DeleteTypeDialog | New | UModal + UAlert | Dialog pattern unchanged. Delete uses color="error" |
|
||||||
|
| ContentNavGroup | New | UNavigationMenu group "Content" (NavGroup) | Adds the 5 + selected cap, "Show all N" and the collapsed mobile row |
|
||||||
|
| EntryTable | New | UTable with column visibility | Cells per field type (FieldCell). Cards on mobile |
|
||||||
|
| ColumnPicker | New | UPopover + UCheckbox list; bottom sheet on mobile | UDropdownMenu closes on each pick and has no cap message |
|
||||||
|
| SortSelect | New | USelect + UButton icon | ID, Created and Updated only |
|
||||||
|
| FieldControl | New | UInput, UInputNumber, UInputDate, USelect, USwitch (the single toggle), MarkdownField | One switch per field type, generated from the schema |
|
||||||
|
| MarkdownField | New | UTextarea + UTabs (Write, Preview) + toolbar of UButton | The article editor is a full document editor. This is a compact one |
|
||||||
|
| FieldCell | New | text, UBadge outline (choice), icon + text (yes or no) | Renders one value per type, with the empty dash |
|
||||||
|
| EntryForm | New | UForm generated from the schema | Provenance footer, schema-changed alert |
|
||||||
|
| RelationPicker (later) | New | URadioGroup card + USelect | Sentence preview is text |
|
||||||
|
| RelationField (later) | New | UInputMenu multiple | Chips and search |
|
||||||
|
| PermissionsGrid (later) | New | UTable + UCheckbox; UCard list on mobile | |
|
||||||
|
| SchemaImportDialog (later) | New | UModal + UFileUpload | |
|
||||||
@@ -16,6 +16,13 @@
|
|||||||
| /article/:id/edit | Editor gains the "Site and authors" section (Site select and authors, any role that can edit the article) | none | PATCH /v1/article/{id} {site_id, author_ids[]} |
|
| /article/:id/edit | Editor gains the "Site and authors" section (Site select and authors, any role that can edit the article) | none | PATCH /v1/article/{id} {site_id, author_ids[]} |
|
||||||
| /profile | Profile gains the "Author profile" card, all roles | none | POST /v1/author/me |
|
| /profile | Profile gains the "Author profile" card, all roles | none | POST /v1/author/me |
|
||||||
| /integration/api-keys | API keys | none | GET/POST /v1/api-key, DELETE /v1/api-key/{id} |
|
| /integration/api-keys | API keys | none | GET/POST /v1/api-key, DELETE /v1/api-key/{id} |
|
||||||
|
| /schema | Schema list, custom and built-in types (owner or admin; others get a 403 page and no sidebar entry). Available now | none | GET /v1/content-type |
|
||||||
|
| /schema/new | Schema builder, new type (owner or admin). Available now | none | POST /v1/content-type |
|
||||||
|
| /schema/:key | Schema builder for a custom type, read-only view for a built-in (owner or admin). Review step on save when the type has entries. Available now | none | GET/PUT/DELETE /v1/content-type/{key} |
|
||||||
|
| /content/:key | Entry list (any verified user). Available now | page, size, sort | GET /v1/content-type/{key}, GET /v1/content-type/{key}/entry?page=&size=&sort= |
|
||||||
|
| /content/:key/new | New entry (any verified user). Available now | none | POST /v1/content-type/{key}/entry |
|
||||||
|
| /content/:key/:id | Entry editor (any verified user). Available now | none | GET/PUT/DELETE /v1/content-type/{key}/entry/{id} |
|
||||||
|
| /settings/permissions | Permissions grid (owner or admin). Later | none | not built |
|
||||||
|
|
||||||
## Pagination in the URL (Articles, Media)
|
## Pagination in the URL (Articles, Media)
|
||||||
`/articles?page=2&size=50` and `/media?page=7&size=50`. Parameter names follow the repo (activity already uses `page` and `size`). Allowed sizes 5, 25, 50, 100. Defaults `page=1`, `size=25`, omitted from the URL when default. Changing search, tab, filter or size resets `page` to 1. `page` above the last page redirects (replace) to the last page and shows "That page doesn't exist. Showing the last page." An invalid `size` falls back to 25.
|
`/articles?page=2&size=50` and `/media?page=7&size=50`. Parameter names follow the repo (activity already uses `page` and `size`). Allowed sizes 5, 25, 50, 100. Defaults `page=1`, `size=25`, omitted from the URL when default. Changing search, tab, filter or size resets `page` to 1. `page` above the last page redirects (replace) to the last page and shows "That page doesn't exist. Showing the last page." An invalid `size` falls back to 25.
|
||||||
@@ -43,3 +50,6 @@ Sites pagination: `/settings/sites?q=&page=&size=` with the same rules as Articl
|
|||||||
|
|
||||||
## SEO
|
## SEO
|
||||||
No new page routes. The panel and dialog are overlays on `/article/:id/edit`: `?panel=seo` (optionally `&field=seo_title|seo_description|slug|cover|authors|body` for the suggestion jump) and `?dialog=seo-api`. `/articles?seo=suggestions` is the Needs attention filter. API: GET /v1/article/{id}/seo, GET /v1/site/{key}/sitemap?page=&size=, PATCH /v1/article/{id} (new fields), PATCH /v1/site/{id} {site_name, title_template, default_description, social_image_id, default_robots}, GET /v1/article/?seo=ready|suggestions|hidden. 401 without a session; 403 owner_only on site SEO fields; 409 slug_taken {suggestion} on PATCH slug; 400 on invalid canonical_url or title_template.
|
No new page routes. The panel and dialog are overlays on `/article/:id/edit`: `?panel=seo` (optionally `&field=seo_title|seo_description|slug|cover|authors|body` for the suggestion jump) and `?dialog=seo-api`. `/articles?seo=suggestions` is the Needs attention filter. API: GET /v1/article/{id}/seo, GET /v1/site/{key}/sitemap?page=&size=, PATCH /v1/article/{id} (new fields), PATCH /v1/site/{id} {site_name, title_template, default_description, social_image_id, default_robots}, GET /v1/article/?seo=ready|suggestions|hidden. 401 without a session; 403 owner_only on site SEO fields; 409 slug_taken {suggestion} on PATCH slug; 400 on invalid canonical_url or title_template.
|
||||||
|
|
||||||
|
## Custom content types in the URL
|
||||||
|
`/content/events?page=2&size=50&sort=created_at:desc`. `page` and `size` follow Articles (sizes 5, 25, 50, 100; default 25). `sort` is one parameter, `<id|created_at|updated_at>:<asc|desc>`, default `updated_at:desc`. Defaults are omitted from the URL; a size or sort change resets `page` to 1; invalid values are replaced silently by the default; a `page` past the end falls back to the last page. The response carries `total`, `page`, `size` and `sort`: use them for the summary ("Showing 1–5 of 37"), the pager and the "37 entries use this content type" line of the review step. No filter or search on custom fields yet. Auth: `/schema*` owner or admin (403 page otherwise), `/content*` any verified user (unverified accounts are redirected to /register/pending). Unknown type: 404 content_type_not_found page. Unknown entry: 404 entry_not_found page.
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Content
|
||||||
|
|
||||||
|
Route: `/content/:key`. API: GET /v1/content-type/{key} · GET /v1/content-type/{key}/entry?page=&size=&sort=. Auth: any verified user.
|
||||||
|
|
||||||
|
Visual reference: `RenewCMS Screens Schema.dc.html`, groups "Content and entry list". Frames: `SchemaFrame.dc.html` mounted in `AppFrame.dc.html` (shell, sidebar, topbar, drawer). Every state is drawn in light and dark (theme switch) at 1440×900 (shown at 50%) and 390×844 (shown at 75%). Copy is exact. Strings in a Copy cell are separated by " · " and listed in reading order. Nothing is clock-sensitive.
|
||||||
|
|
||||||
|
### 5 Content navigation and entry list (group "5 Content and entry list", 9 states)
|
||||||
|
|
||||||
|
Route: `/content/:key`. API: GET /v1/content-type/{key}, GET /v1/content-type/{key}/entry. Auth: Any verified user.
|
||||||
|
|
||||||
|
The sidebar gets a "Content" group, one item per custom type (a default icon: types have no icon in the API). With more than 5 types it shows the first 5 and the selected one, then "Show all N"; long names truncate with the full name in a tooltip. On mobile the group is one collapsed row in the drawer. List: ID, up to 6 chosen field columns (default: first 4), Created, Updated, Updated by. Sort: ID, Created, Updated, either direction (default Updated, newest first). Page size 5, 25, 50, 100 (default 25). No filter or search. URL: /content/events?page=2&size=50&sort=created_at:desc (defaults omitted). The response carries total, page, size and sort; the summary, the pager and the review dialog use them.
|
||||||
|
|
||||||
|
| # | State | Trigger | Copy | Scope |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 1 | Populated | /content/events. 7 entries, one page: no pager, only the count. | Content · Events · events · 7 fields · New entry · Sort by · Last updated · Columns · Edit content type · ID · Title · Starts on · Ends on · Online · Created · Updated · Updated by · 7 entries | Available now |
|
||||||
|
| 2 | Paginated, 5 per page | /content/events?size=5. 37 entries. | Showing 1–5 of 37 · 5 per page · Page 1 of 8 (mobile) | Available now |
|
||||||
|
| 3 | Columns menu | The Columns button. Popover on desktop, sheet on mobile. Created and updated always show. | Columns · 4 of 6 · Choose up to 6 fields to show. 4 selected. · Created and updated dates always show · Reset · Done | Available now |
|
||||||
|
| 4 | Many types in the sidebar | 14 custom types. Long names truncate. Mobile shows the drawer with the group expanded. | Content · Frequently asked questions and answers (truncated) · Show all 14 · All content types 14 (mobile, collapsed) | Available now |
|
||||||
|
| 5 | Loading | First load or retry. | (skeleton) | Available now |
|
||||||
|
| 6 | Empty for this type | Total is 0. The hint points to New entry. | No entries yet · Use New entry to add the first event. Each entry follows the fields of this content type. · New entry | Available now |
|
||||||
|
| 7 | Error | 500 or network failure. | Couldn't load entries · GET /v1/content-type/events/entry failed. Check your connection and try again. · Try again | Available now |
|
||||||
|
| 8 | Forbidden (403) | The API answers 403. Unverified accounts are redirected to /register/pending before this. | You don't have access to Events · Your account can't open this content right now. If you think this is a mistake, ask an owner or an admin. · Go to Home | Available now |
|
||||||
|
| 9 | Type not found | 404 content_type_not_found on /content/unknown. | Content type not found · It may have been deleted, or the address may be mistyped. · Go to Home | Available now |
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Entry editor
|
||||||
|
|
||||||
|
Route: `/content/:key/new`, `/content/:key/:id`. API: POST /v1/content-type/{key}/entry · GET/PUT/DELETE /v1/content-type/{key}/entry/{id}. Auth: any verified user.
|
||||||
|
|
||||||
|
Visual reference: `RenewCMS Screens Schema.dc.html`, groups "Entry editor". Frames: `SchemaFrame.dc.html` mounted in `AppFrame.dc.html` (shell, sidebar, topbar, drawer). Every state is drawn in light and dark (theme switch) at 1440×900 (shown at 50%) and 390×844 (shown at 75%). Copy is exact. Strings in a Copy cell are separated by " · " and listed in reading order. Nothing is clock-sensitive.
|
||||||
|
|
||||||
|
### 6 Entry editor (group "6 Entry editor", 13 states)
|
||||||
|
|
||||||
|
Route: `/content/:key/new, /content/:key/:id`. API: POST /v1/content-type/{key}/entry, GET/PUT/DELETE /v1/content-type/{key}/entry/{id}. Auth: Any verified user.
|
||||||
|
|
||||||
|
The form is generated from the schema, one control per field in schema order. Required fields carry * and the line "Fields marked * are required." Defaults are pre-filled on create and tagged Default. Saving replaces the whole entry.
|
||||||
|
|
||||||
|
| # | State | Trigger | Copy | Scope |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 1 | New entry | /content/events/new. Online is pre-filled from its default. | New entry · Events · Fields marked * are required. · Default · Each entry needs a different title. · Between 1 and 5000. · Choose an option · Pick a date · Save · Cancel · New entry · Not saved yet | Available now |
|
||||||
|
| 2 | Editing | /content/events/42. Save is disabled until something changes. | Lime plaster workshop · Events · Entry 42 · Saved · Delete · Cancel · Save · Created by · Updated by · Inès Duarte · 12 Oct 2026, 09:14 · Marta Alves · 15 Oct 2026, 11:02 | Available now |
|
||||||
|
| 3 | Unsaved changes | Any edit. | Unsaved changes · 32 / 80 | Available now |
|
||||||
|
| 4 | Markdown preview | The Preview tab of a rich text field. | Write · Preview | Available now |
|
||||||
|
| 5 | Saving | PUT or POST in flight. Form dims. | Saving… | Available now |
|
||||||
|
| 6 | Saved | 200. | Saved at 11:02 · Entry saved · "Lime plaster workshop" is up to date. | Available now |
|
||||||
|
| 7 | Save failed | 500 or network failure. Values stay. | Save failed · Couldn't save the entry · Something went wrong on our side. Your changes are still here. Try again in a moment. · Try again | Available now |
|
||||||
|
| 8 | Required fields empty | 422 invalid_entry: title: required, starts_on: required. The first field with an error takes focus. | Fill in Title. · Pick a date for Starts on. · Not saved | Available now |
|
||||||
|
| 9 | Rule errors | 422 invalid_entry for max, type, max and options. One rule per field is shown at a time. | Title can be up to 80 characters. · 91 / 80 · Ends on must be a date, like 2026-11-21. · Capacity must be at most 5000. · Choose one of: Talk, Workshop or Meetup. · Not saved | Available now |
|
||||||
|
| 10 | Duplicate unique value (409) | 409 invalid_entry: title: unique. | Not saved · Another entry already uses this title. Change it and save again. · Another entry already uses this Title. Enter a different one. | Available now |
|
||||||
|
| 11 | Entry not found | 404 entry_not_found, for example after a delete elsewhere. | Entry not found · It may have been deleted by someone else. · Back to Events | Available now |
|
||||||
|
| 12 | Schema changed since load | 422 invalid_entry: room: required, or unknown_field. The form is read-only until reloaded. | The fields of Events changed · A field was removed and a new required field, Room, was added after you opened this form. Reload the form to continue. What you typed here can't be saved against the old fields. · Reload form | Available now |
|
||||||
|
| 13 | Delete entry: confirm | Delete. | Delete this entry? · "Lime plaster workshop" will be deleted from Events. This can't be undone. · Cancel · Delete entry | Available now |
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# Schema: later
|
||||||
|
|
||||||
|
All states on this page are **Later**: relations, permissions and schema export and import are not built. Drawn now so the model and the layout are settled.
|
||||||
|
|
||||||
|
Visual reference: `RenewCMS Screens Schema.dc.html`, groups "Relations", "Permissions", "Export and import". Frames: `SchemaFrame.dc.html` mounted in `AppFrame.dc.html` (shell, sidebar, topbar, drawer). Every state is drawn in light and dark (theme switch) at 1440×900 (shown at 50%) and 390×844 (shown at 75%). Copy is exact. Strings in a Copy cell are separated by " · " and listed in reading order. Nothing is clock-sensitive.
|
||||||
|
|
||||||
|
### 7 Relation field and picker (group "7 Relations", 3 states)
|
||||||
|
|
||||||
|
Route: `/schema/:key, /content/:key/:id`. API: not built. Auth: Owner or admin (picker), any verified user (form).
|
||||||
|
|
||||||
|
All later. The model reads as two sentences, with a live preview of both sides. Three shapes: one to one, one to many, many to many.
|
||||||
|
|
||||||
|
| # | State | Trigger | Copy | Scope |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 1 | Relation in the builder | A seventh type, Relation, in the type gallery. | Relation · Related content type · How are they connected? · One to one · Each Event has one Venue, and each Venue has one Event. · One to many · Each Event has one Venue. A Venue has many Events. · Many to many · Each Event has many Venues, and each Venue has many Events. · Each Event has one Venue · Each Venue has many Events · shown on Venue as the field "Events" | Later |
|
||||||
|
| 2 | Relation in the entry form | Search and pick; chips for many. | Venue · Speakers · Search to add · An event can have many speakers. | Later |
|
||||||
|
| 3 | Relation in the list | One chip, two chips and +N, or a dash. | One chip for a single relation. Up to two chips and a "+N" for many. Empty shows a dash. | Later |
|
||||||
|
|
||||||
|
### 8 Permissions grid (group "8 Permissions", 2 states)
|
||||||
|
|
||||||
|
Route: `/settings/permissions`. API: not built. Auth: Owner or admin.
|
||||||
|
|
||||||
|
All later. Rows are types, columns are actions, one role at a time. Built-in rows are locked. Mobile collapses to one card per type with four checkboxes.
|
||||||
|
|
||||||
|
| # | State | Trigger | Copy | Scope |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 1 | Default: today's rules | Role User selected. Everything on, as today. | Permissions · What each role can do with each content type. · Today's rules are the starting point · Owners and admins edit the schema. Any verified user can read, add, edit and delete entries. · Owner · Admin · User · Read · Create · Update · Delete · Built-in types. RenewCMS sets these rules. · Schema (create, edit and delete content types): owners and admins only. Set by RenewCMS. · No changes · Discard · Save permissions | Later |
|
||||||
|
| 2 | Changed, not saved | Two types restricted for the User role. | 4 changes not saved · Changed | Later |
|
||||||
|
|
||||||
|
### 9 Schema export and import (group "9 Export and import", 3 states)
|
||||||
|
|
||||||
|
Route: `/schema (dialogs)`. API: not built. Auth: Owner or admin.
|
||||||
|
|
||||||
|
All later. Dev-to-production flow: export a file, import it elsewhere, preview, same compatibility review as group 3, explicit confirm.
|
||||||
|
|
||||||
|
| # | State | Trigger | Copy | Scope |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 1 | Export | Export on /schema. | Export schema · Download the fields of your content types as a file. Entries aren't included. · All content types · 3 content types · One content type · Choose which one · File name · renewcms-schema.json · Import this file on another RenewCMS site to create the same content types. · Cancel · Download file | Later |
|
||||||
|
| 2 | Import preview | A file was chosen. | Import schema · renewcms-schema.json · 3 content types · Create 1, change 1, no change 1 · Recipes · New content type with 4 fields. · Events · Add 2 fields, remove Description. Events has 37 entries on this site. · Removes data · Press mentions · Same as on this site. · Existing entries are checked against the changes when you import. If any entry doesn't fit, nothing is imported. · Cancel · Import schema | Later |
|
||||||
|
| 3 | Import refused | The server answers schema_incompatible. | Nothing was imported · Some entries on this site don't fit the imported fields. RenewCMS can't tell which entries. The flagged change can cause this. · Try again | Later |
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# Schema
|
||||||
|
|
||||||
|
Route: `/schema`, `/schema/new`, `/schema/:key`. API: GET/POST /v1/content-type · GET/PUT/DELETE /v1/content-type/{key}. Auth: owner or admin.
|
||||||
|
|
||||||
|
Custom content types: an owner or admin defines a type (for example Events) made of 1 to 50 ordered fields; any verified user then writes entries. Everything about a type happens on one screen. Primary words: content type, field, choice list, entry. "Key" is kept for the API identifier. The key of a type is chosen at creation and never changes; the key and type of a saved field never change. Built-in types (Articles, Authors, Sites, Media, Users) are listed with a lock and a "Built-in" badge and open a read-only view. Non-owner, non-admin users see no Schema entry; a direct link shows a 403 page.
|
||||||
|
|
||||||
|
Visual reference: `RenewCMS Screens Schema.dc.html`, groups "Schema list", "Schema builder", "Review and delete", "Field types". Frames: `SchemaFrame.dc.html` mounted in `AppFrame.dc.html` (shell, sidebar, topbar, drawer). Every state is drawn in light and dark (theme switch) at 1440×900 (shown at 50%) and 390×844 (shown at 75%). Copy is exact. Strings in a Copy cell are separated by " · " and listed in reading order. Nothing is clock-sensitive.
|
||||||
|
|
||||||
|
### 1 Schema: list of types (group "1 Schema list", 6 states)
|
||||||
|
|
||||||
|
Route: `/schema, /schema/:key (built-in)`. API: GET /v1/content-type. Auth: Owner or admin.
|
||||||
|
|
||||||
|
Sidebar entry "Schema" for owners and admins only. Built-in types are listed with a lock and a Built-in badge, always after the custom ones. They open a read-only view. Sample data: the field counts of built-ins are illustrative.
|
||||||
|
|
||||||
|
| # | State | Trigger | Copy | Scope |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 1 | Populated | An owner or admin opens /schema. | Schema · Content types and the fields they are made of. · New content type · Name · Key · Fields · Last updated · Built-in types are managed by RenewCMS. You can read their fields, but not change them. · Built-in · 3 content types · 5 built-in | Available now |
|
||||||
|
| 2 | Loading | First load or retry. Skeleton rows appear after 150 ms. New content type is disabled. | (skeleton) · New content type (disabled) | Available now |
|
||||||
|
| 3 | Empty: no custom type yet | GET /v1/content-type returns []. Built-ins are still listed below. | No content types yet · Create a content type to manage your own kind of content, like events or recipes. The built-in types stay as they are. · New content type | Available now |
|
||||||
|
| 4 | Error | 500 or network failure on the list request. | Couldn't load content types · GET /v1/content-type failed. Check your connection and try again. · Try again | Available now |
|
||||||
|
| 5 | Forbidden (403) | A user who is not owner or admin types /schema. There is no sidebar entry, so the page explains instead of redirecting. | You don't have access to Schema · Only owners and admins can change content types. You can still add and edit entries from the Content menu. · Go to Home | Available now |
|
||||||
|
| 6 | Built-in type: read-only view | Click Articles in the list. No edit, no delete, no add field. | Articles · Built-in · Managed by RenewCMS · Articles is a built-in type. You can read its fields here, but you can't edit it, delete it or add fields. · Label · Key · Type · Rules · Entries of built-in types are managed from their own screens, like Articles and Authors. | Available now |
|
||||||
|
|
||||||
|
### 2 Schema builder (group "2 Schema builder", 16 states)
|
||||||
|
|
||||||
|
Route: `/schema/new, /schema/:key`. API: POST /v1/content-type, GET/PUT /v1/content-type/{key}. Auth: Owner or admin.
|
||||||
|
|
||||||
|
One screen, three regions: type name and key on top, ordered field list on the left, field inspector on the right, collapsible API preview at the bottom. Mobile: the list is the page, the inspector is a 92% sheet, the API preview is a sheet. Field keys and types of saved fields are locked; fields added since the last save can still choose both.
|
||||||
|
|
||||||
|
| # | State | Trigger | Copy | Scope |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 1 | New type | /schema/new. One starter field is added because a type needs at least one. | New content type · Name · For example, Events · Key · events · Used in URLs and the API. Lowercase letters, digits and hyphens. It can't change after you save. · Fields · 1 of 50 · Add field · Field: Title · Label · Key · The name used in the API. Starts with a letter. Lowercase letters, digits and underscores. · Type · Required · An entry can't be saved with this field empty. · Unique values · Default value · Used when an entry is created and this field is left out. · Shortest · Longest · Counted in characters. Entries show a counter. · Save · Discard | Available now |
|
||||||
|
| 2 | Editing an existing type | /schema/events with 37 entries. Key is locked, saved field keys and types are locked. | 37 entries use this content type. Changes are checked against them when you save. · Key (lock) · Used in the API, so it can't change. · Can't change after saving · Saved | Available now |
|
||||||
|
| 3 | Locked key: tooltip | Hover or focus the locked key. | The key is used in URLs and the API, so it can't change after the type is created. | Available now |
|
||||||
|
| 4 | Unsaved changes | Two fields added, Capacity changed, Description removed. Rows carry New and Changed badges; removal shows an undo row. | Unsaved changes · New · Changed · Description will be removed · Undo · Save · Discard | Available now |
|
||||||
|
| 5 | Field errors | Checked as the user types, and again on Save. The row is outlined and shows an alert icon. Save is disabled until all are fixed. | 3 problems to fix · Give the field a label. · Lowest value can't be above the highest value. · Choices can't be blank. · Two choices are the same. | Available now |
|
||||||
|
| 6 | Type-level errors | New type with no name, a one-letter key and no fields. | Give the content type a name. · Use 2 to 40 lowercase letters, digits or hyphens. · No fields yet · A content type needs at least one field. · 3 problems to fix | Available now |
|
||||||
|
| 7 | Field key error | A field added since the last save has an invalid key (hyphen). Checked as the user types. Field keys use underscores; type keys use hyphens. | Key · Start with a letter. Use lowercase letters, digits and underscores. · 1 problem to fix | Available now |
|
||||||
|
| 8 | Choice list editor | A Choice field is selected. Add, remove and reorder; blanks and duplicates are flagged per row. | Choices · 5 of 50 · Add a choice · Add · Add 1 to 50 choices. Each one needs a different name. Removing a choice that entries use will be refused when you save. · Choices can't be blank. · Two choices are the same. | Available now |
|
||||||
|
| 9 | Keyboard reorder | Focus the grip, press Space. Up and Down move, Space drops, Esc cancels. Move up, Move down and Remove buttons are on the selected row. | Moving Ends on. Position 2 of 7. Press Up or Down to move, Space to drop, Esc to cancel. (live region, polite) | Available now |
|
||||||
|
| 10 | API preview open | The panel at the bottom. Updates as fields change. Copy JSON copies the sample entry. | API preview · Where entries of this type live · A sample entry. Values come from each field's type and default. · Updates as you edit. Fields you add or remove here appear after you save. · Copy JSON · Copied | Available now |
|
||||||
|
| 11 | Saving | PUT in flight. Inputs dim, Save shows the loader, nothing can be dragged. | Saving… | Available now |
|
||||||
|
| 12 | Saved | 200. A toast confirms, the badges clear. | Saved at 11:02 · Content type saved · Events now has 8 fields. | Available now |
|
||||||
|
| 13 | Save failed | 500 or network failure. Nothing was sent as saved; the edits stay. | Save failed · Couldn't save the content type · Something went wrong on our side. Your changes are still here. Try again in a moment. · Try again | Available now |
|
||||||
|
| 14 | Key already taken | 409 content_type_key_taken on POST. | The key "events" is already used by another content type. Choose a different key. | Available now |
|
||||||
|
| 15 | Schema incompatible | 409 schema_incompatible on PUT. The review dialog (group 3) closes into this state. Changes of the kinds that can cause it are marked. | Nothing was changed · Some entries don't fit the new fields, so the whole change was refused. RenewCMS can't tell which entries. The changes that can cause this are marked. · Review changes · Possible cause | Available now |
|
||||||
|
| 16 | Offline | navigator.onLine is false. Edits are kept; Save is disabled. | You're offline. Changes will be saved when you reconnect. · Waiting for connection | Available now |
|
||||||
|
|
||||||
|
### 3 Schema change review and delete type (group "3 Review and delete", 7 states)
|
||||||
|
|
||||||
|
Route: `/schema/:key (dialogs)`. API: PUT /v1/content-type/{key}, DELETE /v1/content-type/{key}. Auth: Owner or admin.
|
||||||
|
|
||||||
|
The review opens on Save when the type already has entries (37 here). It is informational: the server decides compatibility. Destructive changes (removing a field or a choice, tightening a limit, making a field unique or required) carry a warning flag. Per-change entry counts and a full diff are later.
|
||||||
|
|
||||||
|
| # | State | Trigger | Copy | Scope |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 1 | Review before saving | Save on a type that has entries. | Review changes to Events · Apply them to save the content type. · Add 2 fields, change 1, remove 1 · Events has 37 entries. Each one is checked against the new fields when you apply. · Add Speaker · Text, optional. · Add Room · Choice, required, default "Main hall". Existing entries get the default, so they still fit. · Change Capacity · Highest value goes from 5000 to 2000. · Entries may not fit · Remove Description · Entries stop returning its values. · Removes data · Flagged changes can be refused if existing entries don't fit. If that happens, nothing is changed. · Back to editing · Apply changes | Available now |
|
||||||
|
| 2 | Applying | PUT in flight. Dialog cannot be dismissed. | Applying… | Available now |
|
||||||
|
| 3 | Refused (schema_incompatible) | 409 schema_incompatible. States that nothing changed and names the kind of change that likely caused it, never an entry. | Nothing was changed · RenewCMS refused the changes because some entries don't fit the new fields. It doesn't say which entries. · Your content type is as it was · No field was added, changed or removed. · The change that can cause this in your edit · Possible cause · This kind of change is refused when an entry is over the new limit, a new required field has no default, a field becomes unique while entries share a value, or a removed choice is still in use. · Discard changes · Back to editing | Available now |
|
||||||
|
| 4 | Review with counts and diff | Per-change entry counts and a full diff need an API that does not exist yet. | Affects 0 entries · Affects 37 entries · Affects 3 entries · Affects 31 entries · View full diff · Per-change counts and diff come later | Later |
|
||||||
|
| 5 | Delete type: blocked | Delete content type on a type with entries. No confirm button. | You can't delete Events yet · A content type can only be deleted when it has no entries. · Events has 37 entries · Delete all of its entries first, then delete the content type. · Close · View entries | Available now |
|
||||||
|
| 6 | Delete type: confirm | Delete content type on an empty type. | Delete Recipes? · Recipes has no entries. Its fields and its API routes will be deleted. This can't be undone. · Key: recipes · Cancel · Delete content type | Available now |
|
||||||
|
| 7 | Delete type: server fallback (409) | 409 content_type_has_entries. An entry was added after the page loaded. | Recipes now has entries · Someone added an entry after this page loaded, so nothing was deleted. Delete the entries first, then try again. · Close · View entries | Available now |
|
||||||
|
|
||||||
|
### 4 Field-type gallery (group "4 Field types", 6 states)
|
||||||
|
|
||||||
|
Route: `reference board`. API: none. Auth: n/a.
|
||||||
|
|
||||||
|
One frame per type: inspector options, the entry form control (default and open states), the list cell with its empty state, the error message for each rule, and the filter widget. Filters are later: filtering on custom fields is not in the API. Mobile drops the inspector column (the builder frames show it as a sheet).
|
||||||
|
|
||||||
|
| # | State | Trigger | Copy | Scope |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 1 | Text | type: "text" | Text · Short text, like a title or a name · Shortest · Longest · characters · 74 / 80 · Fill in Title. · Title must be text. · Title needs at least 3 characters. · Title can be up to 80 characters. · Another entry already uses this Title. Enter a different one. | Available now |
|
||||||
|
| 2 | Rich text | type: "richtext" | Rich text · Long text with formatting, written in Markdown · Write · Preview · Markdown. Switch to Preview to check the result. · 86 / 5000 · Not available for rich text. (Unique) | Available now |
|
||||||
|
| 3 | Number | type: "number" | Number · Whole or decimal numbers · Lowest value · Highest value · Enter a number · Between 1 and 5000. · Description must be a number. · Capacity must be at least 1. · Capacity must be at most 5000. | Available now |
|
||||||
|
| 4 | Yes or no | type: "boolean" | Yes or no · An on or off switch · On by default · Yes or no fields have no limits. · Not available for yes or no fields. (Unique) · Choose yes or no for Online. · Online must be yes or no. | Available now |
|
||||||
|
| 5 | Date | type: "date" | Date · A calendar day, without a time · Earliest date · Latest date · Pick a date · Pick a date for Starts on. · Starts on must be a date, like 2026-11-21. · Starts on can't be before 1 Jan 2026. · Starts on can't be after 31 Dec 2027. | Available now |
|
||||||
|
| 6 | Choice | type: "select" | Choice · One option from a list you define · Choices · Choose an option · Choose one of: Talk, Workshop or Meetup. · Another entry already uses this choice for Category. | Available now |
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
<sc-for list="{{ nav }}" as="n" hint-placeholder-count="3">
|
<sc-for list="{{ nav }}" as="n" hint-placeholder-count="3">
|
||||||
<div data-act="nav-{{ n.key }}" style="display:flex;align-items:center;gap:10px;height:32px;padding:0 10px;background:{{ n.bg }};color:{{ n.fg }};font-weight:{{ n.fw }}"><span aria-hidden="true" style="display:inline-block;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ n.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ n.icon }}.svg') center / contain no-repeat"></span>{{ n.t }}<sc-if value="{{ n.badge }}"><span style="margin-left:auto;height:18px;padding:0 6px;display:inline-flex;align-items:center;background:var(--ui-info);color:var(--ui-bg);font:500 11px 'IBM Plex Sans',sans-serif">Update</span></sc-if><sc-if value="{{ n.chev }}"><span aria-hidden="true" style="margin-left:{{ n.chevML }};display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ n.chev }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ n.chev }}.svg') center / contain no-repeat"></span></sc-if></div><sc-if value="{{ n.sub }}"><div style="display:flex;flex-direction:column;gap:2px;margin-left:19px;border-left:1px solid var(--ui-border)"><span style="padding:6px 10px 2px 12px;font-size:11px;line-height:16px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--ui-text-muted)">Media</span><sc-for list="{{ n.subs }}" as="x" hint-placeholder-count="2"><div data-act="nav-{{ x.t }}" style="display:flex;align-items:center;gap:10px;height:32px;padding:0 10px 0 12px;background:{{ x.bg }};color:{{ x.fg }};font-weight:{{ x.fw }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ x.ic }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ x.ic }}.svg') center / contain no-repeat"></span>{{ x.t }}</div></sc-for><sc-if value="{{ n.hasSites }}"><span style="padding:6px 10px 2px 12px;font-size:11px;line-height:16px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--ui-text-muted)">Publishing</span></sc-if><sc-for list="{{ n.subs2 }}" as="x2" hint-placeholder-count="1"><div data-act="nav-{{ x2.t }}" style="display:flex;align-items:center;gap:10px;height:32px;padding:0 10px 0 12px;background:{{ x2.bg }};color:{{ x2.fg }};font-weight:{{ x2.fw }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ x2.ic }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ x2.ic }}.svg') center / contain no-repeat"></span>{{ x2.t }}</div></sc-for></div></sc-if>
|
<div data-act="nav-{{ n.key }}" style="display:flex;align-items:center;gap:10px;height:32px;padding:0 10px;background:{{ n.bg }};color:{{ n.fg }};font-weight:{{ n.fw }}"><span aria-hidden="true" style="display:inline-block;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ n.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ n.icon }}.svg') center / contain no-repeat"></span>{{ n.t }}<sc-if value="{{ n.badge }}"><span style="margin-left:auto;height:18px;padding:0 6px;display:inline-flex;align-items:center;background:var(--ui-info);color:var(--ui-bg);font:500 11px 'IBM Plex Sans',sans-serif">Update</span></sc-if><sc-if value="{{ n.chev }}"><span aria-hidden="true" style="margin-left:{{ n.chevML }};display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ n.chev }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ n.chev }}.svg') center / contain no-repeat"></span></sc-if></div><sc-if value="{{ n.sub }}"><div style="display:flex;flex-direction:column;gap:2px;margin-left:19px;border-left:1px solid var(--ui-border)"><span style="padding:6px 10px 2px 12px;font-size:11px;line-height:16px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--ui-text-muted)">Media</span><sc-for list="{{ n.subs }}" as="x" hint-placeholder-count="2"><div data-act="nav-{{ x.t }}" style="display:flex;align-items:center;gap:10px;height:32px;padding:0 10px 0 12px;background:{{ x.bg }};color:{{ x.fg }};font-weight:{{ x.fw }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ x.ic }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ x.ic }}.svg') center / contain no-repeat"></span>{{ x.t }}</div></sc-for><sc-if value="{{ n.hasSites }}"><span style="padding:6px 10px 2px 12px;font-size:11px;line-height:16px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--ui-text-muted)">Publishing</span></sc-if><sc-for list="{{ n.subs2 }}" as="x2" hint-placeholder-count="1"><div data-act="nav-{{ x2.t }}" style="display:flex;align-items:center;gap:10px;height:32px;padding:0 10px 0 12px;background:{{ x2.bg }};color:{{ x2.fg }};font-weight:{{ x2.fw }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ x2.ic }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ x2.ic }}.svg') center / contain no-repeat"></span>{{ x2.t }}</div></sc-for></div></sc-if>
|
||||||
</sc-for>
|
</sc-for>
|
||||||
|
<sc-if value="{{ contentShow }}"><div style="display:flex;flex-direction:column;gap:2px;margin-top:8px"><span style="padding:6px 10px 2px;font-size:11px;line-height:16px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--ui-text-muted)">Content</span><sc-if value="{{ contentColl }}"><div style="display:flex;align-items:center;gap:10px;height:32px;padding:0 10px;color:var(--ui-text-toned)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/layers.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/layers.svg') center / contain no-repeat"></span><span style="flex:1">All content types</span><span style="font-size:12px;color:var(--ui-text-muted)">{{ contentCount }}</span><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/chevron-right.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/chevron-right.svg') center / contain no-repeat"></span></div></sc-if><sc-for list="{{ contentRows }}" as="c" hint-placeholder-count="3"><div data-act="nav-content-{{ c.key }}" title="{{ c.name }}" style="display:flex;align-items:center;gap:10px;height:32px;padding:0 10px;background:{{ c.bg }};color:{{ c.fg }};font-weight:{{ c.fw }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ c.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ c.icon }}.svg') center / contain no-repeat"></span><span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ c.name }}</span></div></sc-for><sc-if value="{{ contentMore }}"><div style="display:flex;align-items:center;gap:10px;height:32px;padding:0 10px;color:var(--ui-text-muted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/ellipsis.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/ellipsis.svg') center / contain no-repeat"></span><span>{{ contentMore }}</span></div></sc-if></div></sc-if>
|
||||||
<div style="flex:1"></div>
|
<div style="flex:1"></div>
|
||||||
<sc-if value="{{ s.boot }}"><div style="display:flex;align-items:center;gap:10px;height:40px;padding:0 6px"><span style="width:26px;height:26px;background:var(--ui-bg-accented)"></span><span style="width:80px;height:12px;background:var(--ui-bg-accented)"></span></div></sc-if>
|
<sc-if value="{{ s.boot }}"><div style="display:flex;align-items:center;gap:10px;height:40px;padding:0 6px"><span style="width:26px;height:26px;background:var(--ui-bg-accented)"></span><span style="width:80px;height:12px;background:var(--ui-bg-accented)"></span></div></sc-if>
|
||||||
<sc-if value="{{ notBoot }}"><div data-act="usermenu" role="button" aria-haspopup="menu" aria-expanded="{{ menuOpen }}" title="Account" style="display:flex;align-items:center;gap:10px;height:40px;padding:0 6px;background:{{ chipBg }};color:{{ chipFg }}"><span style="width:26px;height:26px;flex-shrink:0;background:var(--rc-sand);color:var(--rc-on-sand);display:flex;align-items:center;justify-content:center;font:600 11px 'IBM Plex Sans',sans-serif">{{ ini }}</span><span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:500">{{ who }}</span><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/chevrons-up-down.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/chevrons-up-down.svg') center / contain no-repeat"></span></div></sc-if>
|
<sc-if value="{{ notBoot }}"><div data-act="usermenu" role="button" aria-haspopup="menu" aria-expanded="{{ menuOpen }}" title="Account" style="display:flex;align-items:center;gap:10px;height:40px;padding:0 6px;background:{{ chipBg }};color:{{ chipFg }}"><span style="width:26px;height:26px;flex-shrink:0;background:var(--rc-sand);color:var(--rc-on-sand);display:flex;align-items:center;justify-content:center;font:600 11px 'IBM Plex Sans',sans-serif">{{ ini }}</span><span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:500">{{ who }}</span><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/chevrons-up-down.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/chevrons-up-down.svg') center / contain no-repeat"></span></div></sc-if>
|
||||||
@@ -368,7 +369,7 @@
|
|||||||
</sc-if>
|
</sc-if>
|
||||||
|
|
||||||
{{ purgeDlg }}
|
{{ purgeDlg }}
|
||||||
<sc-if value="{{ navDrawer }}"><div data-act="mnav-close" style="position:absolute;inset:0;z-index:60;display:flex;background:var(--rc-overlay)"><div role="navigation" aria-label="Main" style="width:300px;max-width:85%;height:100%;background:var(--ui-bg);box-shadow:var(--rc-shadow-modal);display:flex;flex-direction:column;padding:12px;gap:2px"><div style="display:flex;align-items:center;gap:10px;height:40px;padding:0 6px;margin-bottom:10px"><svg width="26" height="26" viewBox="0 0 16 16" shape-rendering="crispEdges"><rect width="16" height="16" fill="#111"></rect><path fill="#fff" fill-rule="evenodd" d="M4 3h6v1h1v1h1v3h-1v1h-1v1h1v1h1v2h-2v-2h-1v-1H6v3H4zm2 2v3h4V5z"></path></svg><span style="flex:1;font-weight:600;font-size:14px;color:var(--ui-text-highlighted)">RenewCMS</span></div><sc-for list="{{ nav }}" as="n" hint-placeholder-count="3"><div data-act="nav-{{ n.key }}" style="display:flex;align-items:center;gap:10px;height:44px;padding:0 10px;background:{{ n.bg }};color:{{ n.fg }};font-weight:{{ n.fw }}"><span aria-hidden="true" style="display:inline-block;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ n.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ n.icon }}.svg') center / contain no-repeat"></span><span style="flex:1">{{ n.t }}</span><sc-if value="{{ n.chev }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ n.chev }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ n.chev }}.svg') center / contain no-repeat"></span></sc-if></div><sc-if value="{{ n.sub }}"><div style="display:flex;flex-direction:column;gap:2px;margin-left:19px;border-left:1px solid var(--ui-border)"><span style="padding:6px 10px 2px 12px;font-size:11px;line-height:16px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--ui-text-muted)">Media</span><sc-for list="{{ n.subs }}" as="x" hint-placeholder-count="2"><div data-act="nav-{{ x.t }}" style="display:flex;align-items:center;gap:10px;height:44px;padding:0 10px 0 12px;background:{{ x.bg }};color:{{ x.fg }};font-weight:{{ x.fw }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ x.ic }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ x.ic }}.svg') center / contain no-repeat"></span>{{ x.t }}</div></sc-for><sc-if value="{{ n.hasSites }}"><span style="padding:6px 10px 2px 12px;font-size:11px;line-height:16px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--ui-text-muted)">Publishing</span></sc-if><sc-for list="{{ n.subs2 }}" as="x2" hint-placeholder-count="1"><div data-act="nav-{{ x2.t }}" style="display:flex;align-items:center;gap:10px;height:44px;padding:0 10px 0 12px;background:{{ x2.bg }};color:{{ x2.fg }};font-weight:{{ x2.fw }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ x2.ic }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ x2.ic }}.svg') center / contain no-repeat"></span>{{ x2.t }}</div></sc-for></div></sc-if></sc-for><div style="flex:1"></div><div style="display:flex;align-items:center;gap:10px;height:44px;padding:0 6px;color:var(--ui-text-highlighted)"><span style="width:26px;height:26px;flex-shrink:0;background:var(--rc-sand);color:var(--rc-on-sand);display:flex;align-items:center;justify-content:center;font:600 11px 'IBM Plex Sans',sans-serif">{{ ini }}</span><span style="flex:1;font-weight:500">{{ who }}</span></div></div></div></sc-if>
|
<sc-if value="{{ navDrawer }}"><div data-act="mnav-close" style="position:absolute;inset:0;z-index:60;display:flex;background:var(--rc-overlay)"><div role="navigation" aria-label="Main" style="width:300px;max-width:85%;height:100%;background:var(--ui-bg);box-shadow:var(--rc-shadow-modal);display:flex;flex-direction:column;padding:12px;gap:2px"><div style="display:flex;align-items:center;gap:10px;height:40px;padding:0 6px;margin-bottom:10px"><svg width="26" height="26" viewBox="0 0 16 16" shape-rendering="crispEdges"><rect width="16" height="16" fill="#111"></rect><path fill="#fff" fill-rule="evenodd" d="M4 3h6v1h1v1h1v3h-1v1h-1v1h1v1h1v2h-2v-2h-1v-1H6v3H4zm2 2v3h4V5z"></path></svg><span style="flex:1;font-weight:600;font-size:14px;color:var(--ui-text-highlighted)">RenewCMS</span></div><sc-for list="{{ nav }}" as="n" hint-placeholder-count="3"><div data-act="nav-{{ n.key }}" style="display:flex;align-items:center;gap:10px;height:44px;padding:0 10px;background:{{ n.bg }};color:{{ n.fg }};font-weight:{{ n.fw }}"><span aria-hidden="true" style="display:inline-block;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ n.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ n.icon }}.svg') center / contain no-repeat"></span><span style="flex:1">{{ n.t }}</span><sc-if value="{{ n.chev }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ n.chev }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ n.chev }}.svg') center / contain no-repeat"></span></sc-if></div><sc-if value="{{ n.sub }}"><div style="display:flex;flex-direction:column;gap:2px;margin-left:19px;border-left:1px solid var(--ui-border)"><span style="padding:6px 10px 2px 12px;font-size:11px;line-height:16px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--ui-text-muted)">Media</span><sc-for list="{{ n.subs }}" as="x" hint-placeholder-count="2"><div data-act="nav-{{ x.t }}" style="display:flex;align-items:center;gap:10px;height:44px;padding:0 10px 0 12px;background:{{ x.bg }};color:{{ x.fg }};font-weight:{{ x.fw }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ x.ic }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ x.ic }}.svg') center / contain no-repeat"></span>{{ x.t }}</div></sc-for><sc-if value="{{ n.hasSites }}"><span style="padding:6px 10px 2px 12px;font-size:11px;line-height:16px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--ui-text-muted)">Publishing</span></sc-if><sc-for list="{{ n.subs2 }}" as="x2" hint-placeholder-count="1"><div data-act="nav-{{ x2.t }}" style="display:flex;align-items:center;gap:10px;height:44px;padding:0 10px 0 12px;background:{{ x2.bg }};color:{{ x2.fg }};font-weight:{{ x2.fw }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ x2.ic }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ x2.ic }}.svg') center / contain no-repeat"></span>{{ x2.t }}</div></sc-for></div></sc-if></sc-for><sc-if value="{{ contentShow }}"><div style="display:flex;flex-direction:column;gap:2px;margin-top:8px"><span style="padding:6px 10px 2px;font-size:11px;line-height:16px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--ui-text-muted)">Content</span><sc-if value="{{ contentColl }}"><div style="display:flex;align-items:center;gap:10px;height:44px;padding:0 10px;color:var(--ui-text-toned)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/layers.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/layers.svg') center / contain no-repeat"></span><span style="flex:1">All content types</span><span style="font-size:12px;color:var(--ui-text-muted)">{{ contentCount }}</span><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/chevron-right.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/chevron-right.svg') center / contain no-repeat"></span></div></sc-if><sc-for list="{{ contentRows }}" as="c" hint-placeholder-count="3"><div data-act="nav-content-{{ c.key }}" title="{{ c.name }}" style="display:flex;align-items:center;gap:10px;height:44px;padding:0 10px;background:{{ c.bg }};color:{{ c.fg }};font-weight:{{ c.fw }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkiLine truncated
|
||||||
<sc-if value="{{ lk.sheet }}"><div style="position:absolute;inset:0;z-index:55;background:var(--rc-overlay);display:flex;align-items:flex-end"><div role="dialog" aria-label="Link" style="width:100%;background:var(--ui-bg);box-shadow:var(--rc-shadow-modal);display:flex;flex-direction:column"><div style="display:flex;justify-content:center;padding-top:8px"><span style="display:block;width:36px;height:4px;background:var(--ui-border-accented)"></span></div><div style="display:flex;flex-direction:column;gap:2px;padding:12px 16px 12px;border-bottom:1px solid var(--ui-border)"><span style="font-weight:600;font-size:16px;line-height:24px;color:var(--ui-text-highlighted)">timber windows</span><span style="font:400 12px/18px 'IBM Plex Mono',monospace;color:var(--ui-text-toned);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">/articles/winter-checklist</span></div><div style="display:flex;flex-direction:column;padding:8px 0 16px"><span style="height:48px;padding:0 16px;display:flex;align-items:center;gap:12px;color:var(--ui-text-highlighted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:18px;height:18px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/pencil.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/pencil.svg') center / contain no-repeat"></span>Edit link</span><span style="height:48px;padding:0 16px;display:flex;align-items:center;gap:12px;color:var(--ui-text-highlighted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:18px;height:18px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/external-link.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/external-link.svg') center / contain no-repeat"></span>Open link</span><span style="height:48px;padding:0 16px;display:flex;align-items:center;gap:12px;color:var(--ui-error)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:18px;height:18px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/unlink.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/unlink.svg') center / contain no-repeat"></span>Remove link</span></div></div></div></sc-if>
|
<sc-if value="{{ lk.sheet }}"><div style="position:absolute;inset:0;z-index:55;background:var(--rc-overlay);display:flex;align-items:flex-end"><div role="dialog" aria-label="Link" style="width:100%;background:var(--ui-bg);box-shadow:var(--rc-shadow-modal);display:flex;flex-direction:column"><div style="display:flex;justify-content:center;padding-top:8px"><span style="display:block;width:36px;height:4px;background:var(--ui-border-accented)"></span></div><div style="display:flex;flex-direction:column;gap:2px;padding:12px 16px 12px;border-bottom:1px solid var(--ui-border)"><span style="font-weight:600;font-size:16px;line-height:24px;color:var(--ui-text-highlighted)">timber windows</span><span style="font:400 12px/18px 'IBM Plex Mono',monospace;color:var(--ui-text-toned);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">/articles/winter-checklist</span></div><div style="display:flex;flex-direction:column;padding:8px 0 16px"><span style="height:48px;padding:0 16px;display:flex;align-items:center;gap:12px;color:var(--ui-text-highlighted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:18px;height:18px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/pencil.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/pencil.svg') center / contain no-repeat"></span>Edit link</span><span style="height:48px;padding:0 16px;display:flex;align-items:center;gap:12px;color:var(--ui-text-highlighted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:18px;height:18px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/external-link.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/external-link.svg') center / contain no-repeat"></span>Open link</span><span style="height:48px;padding:0 16px;display:flex;align-items:center;gap:12px;color:var(--ui-error)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:18px;height:18px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/unlink.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/unlink.svg') center / contain no-repeat"></span>Remove link</span></div></div></div></sc-if>
|
||||||
<sc-if value="{{ edSheet }}"><div style="position:absolute;inset:0;z-index:55;background:var(--rc-overlay);display:flex;align-items:flex-end"><div role="dialog" aria-modal="true" aria-label="Details" style="width:100%;height:92%;background:var(--ui-bg);box-shadow:var(--rc-shadow-modal);display:flex;flex-direction:column"><div style="display:flex;justify-content:center;padding-top:8px;flex-shrink:0"><span style="display:block;width:36px;height:4px;background:var(--ui-border-accented)"></span></div><div style="padding:8px 16px 0 16px;display:flex;align-items:center;gap:12px;flex-shrink:0"><span style="flex:1;font:600 16px/24px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Details</span><span data-act="modal-close" style="width:44px;height:44px;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/x.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/x.svg') center / contain no-repeat"></span></span></div><div style="flex:1;min-height:0;overflow:hidden;padding:12px 16px 16px;display:flex;flex-direction:column;gap:16px"><div style="display:flex;flex-direction:column;gap:12px;padding-bottom:16px;border-bottom:1px solid var(--ui-border)"><div data-act="meta-toggle" role="button" tabindex="0" aria-expanded="{{ metaOpen }}" aria-controls="meta-body" style="display:flex;align-items:center;gap:8px;min-height:24px;color:var(--ui-text-highlighted)"><span style="display:flex;transform:rotate({{ metaRot }})"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat"></span></span><span style="font-weight:600;font-size:14px">Site and authors</span></div><sc-if value="{{ metaOpen }}"><div id="meta-body" style="display:flex;flex-direction:column;gap:16px">{{ edMeta }}</div></sc-if><sc-if value="{{ metaClosed }}"><span style="padding-left:22px;color:var(--ui-text-muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ metaSummary }}</span></sc-if></div><div style="display:flex;flex-direction:column;gap:12px;padding-bottom:16px;border-bottom:1px solid var(--ui-border)"><div data-act="seo-toggle" role="button" tabindex="0" aria-expanded="{{ seoOpen }}" aria-controls="seo-body" style="display:flex;align-items:center;gap:8px;min-height:24px;color:var(--ui-text-highlighted)"><span style="display:flex;transform:rotate({{ seoRot }})"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat"></span></span><span style="font-weight:600;font-size:14px">Search and sharing</span></div><sc-if value="{{ seoOpen }}"><div id="seo-body" style="display:flex;flex-direction:column;gap:12px">{{ edSeo }}</div></sc-if><sc-if value="{{ seoClosed }}"><span style="padding-left:22px;color:{{ seoFg }};overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ seoSummary }}</span></sc-if></div><div style="display:grid;grid-template-columns:auto 1fr;gap:8px 12px"><span style="color:var(--ui-text-muted)">Created</span><span>12 Mar 2026, 09:14</span><span style="color:var(--ui-text-muted)">Updated</span><span>Today, 11:02</span><span style="color:var(--ui-text-muted)">ID</span><span style="font-family:'IBM Plex Mono',monospace">12</span></div><div style="display:flex;flex-direction:column;gap:6px"><span style="color:var(--ui-text-muted)">Public API URL</span><span style="padding:6px 8px;background:var(--ui-bg-elevated);font:400 12px 'IBM Plex Mono',monospace">/v1/article/12</span></div></div></div></div></sc-if>
|
<sc-if value="{{ edSheet }}"><div style="position:absolute;inset:0;z-index:55;background:var(--rc-overlay);display:flex;align-items:flex-end"><div role="dialog" aria-modal="true" aria-label="Details" style="width:100%;height:92%;background:var(--ui-bg);box-shadow:var(--rc-shadow-modal);display:flex;flex-direction:column"><div style="display:flex;justify-content:center;padding-top:8px;flex-shrink:0"><span style="display:block;width:36px;height:4px;background:var(--ui-border-accented)"></span></div><div style="padding:8px 16px 0 16px;display:flex;align-items:center;gap:12px;flex-shrink:0"><span style="flex:1;font:600 16px/24px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Details</span><span data-act="modal-close" style="width:44px;height:44px;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/x.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/x.svg') center / contain no-repeat"></span></span></div><div style="flex:1;min-height:0;overflow:hidden;padding:12px 16px 16px;display:flex;flex-direction:column;gap:16px"><div style="display:flex;flex-direction:column;gap:12px;padding-bottom:16px;border-bottom:1px solid var(--ui-border)"><div data-act="meta-toggle" role="button" tabindex="0" aria-expanded="{{ metaOpen }}" aria-controls="meta-body" style="display:flex;align-items:center;gap:8px;min-height:24px;color:var(--ui-text-highlighted)"><span style="display:flex;transform:rotate({{ metaRot }})"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat"></span></span><span style="font-weight:600;font-size:14px">Site and authors</span></div><sc-if value="{{ metaOpen }}"><div id="meta-body" style="display:flex;flex-direction:column;gap:16px">{{ edMeta }}</div></sc-if><sc-if value="{{ metaClosed }}"><span style="padding-left:22px;color:var(--ui-text-muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ metaSummary }}</span></sc-if></div><div style="display:flex;flex-direction:column;gap:12px;padding-bottom:16px;border-bottom:1px solid var(--ui-border)"><div data-act="seo-toggle" role="button" tabindex="0" aria-expanded="{{ seoOpen }}" aria-controls="seo-body" style="display:flex;align-items:center;gap:8px;min-height:24px;color:var(--ui-text-highlighted)"><span style="display:flex;transform:rotate({{ seoRot }})"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/chevron-down.svg') center / contain no-repeat"></span></span><span style="font-weight:600;font-size:14px">Search and sharing</span></div><sc-if value="{{ seoOpen }}"><div id="seo-body" style="display:flex;flex-direction:column;gap:12px">{{ edSeo }}</div></sc-if><sc-if value="{{ seoClosed }}"><span style="padding-left:22px;color:{{ seoFg }};overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ seoSummary }}</span></sc-if></div><div style="display:grid;grid-template-columns:auto 1fr;gap:8px 12px"><span style="color:var(--ui-text-muted)">Created</span><span>12 Mar 2026, 09:14</span><span style="color:var(--ui-text-muted)">Updated</span><span>Today, 11:02</span><span style="color:var(--ui-text-muted)">ID</span><span style="font-family:'IBM Plex Mono',monospace">12</span></div><div style="display:flex;flex-direction:column;gap:6px"><span style="color:var(--ui-text-muted)">Public API URL</span><span style="padding:6px 8px;background:var(--ui-bg-elevated);font:400 12px 'IBM Plex Mono',monospace">/v1/article/12</span></div></div></div></div></sc-if>
|
||||||
{{ slotOverlays }}
|
{{ slotOverlays }}
|
||||||
@@ -387,9 +388,10 @@ class Component extends DCLogic {
|
|||||||
const s = this.props.s || {}
|
const s = this.props.s || {}
|
||||||
const m = !!this.props.mobile
|
const m = !!this.props.mobile
|
||||||
const k = s.kind || 'home'
|
const k = s.kind || 'home'
|
||||||
const navItems = s.role === 'user' ? [['home', 'house', 'Home'], ['articles', 'file-text', 'Articles'], ['media', 'images', 'Media'], ['authors', 'feather', 'Authors'], ['activity', 'history', 'Activity']] : [['home', 'house', 'Home'], ['articles', 'file-text', 'Articles'], ['media', 'images', 'Media'], ['authors', 'feather', 'Authors'], ['users', 'users', 'Users'], ['activity', 'history', 'Activity'], ['integration', 'plug', 'Integration'], ['settings', 'settings', 'Settings']]
|
const navItems = s.role === 'user' ? [['home', 'house', 'Home'], ['articles', 'file-text', 'Articles'], ['media', 'images', 'Media'], ['authors', 'feather', 'Authors'], ['activity', 'history', 'Activity']] : [['home', 'house', 'Home'], ['articles', 'file-text', 'Articles'], ['media', 'images', 'Media'], ['authors', 'feather', 'Authors'], ['users', 'users', 'Users'], ['schema', 'shapes', 'Schema'], ['activity', 'history', 'Activity'], ['integration', 'plug', 'Integration'], ['settings', 'settings', 'Settings']]
|
||||||
const cr0 = s.crumbs || [], setOpen = s.nav === 'settings' || !!s.sideOpen, parentOn = (key) => s.nav === key && (key !== 'settings' || cr0.length <= 1)
|
const cr0 = s.crumbs || [], setOpen = s.nav === 'settings' || !!s.sideOpen, parentOn = (key) => s.nav === key && (key !== 'settings' || cr0.length <= 1)
|
||||||
const nav = navItems.map(([key, icon, t]) => { const on = parentOn(key), open = key === 'settings' && setOpen; return { key, icon, t, bg: on ? 'var(--rc-sand)' : 'transparent', badge: key === 'settings' && !!s.navBadge, fg: on ? 'var(--rc-on-sand)' : open ? 'var(--ui-text-highlighted)' : 'var(--ui-text-toned)', fw: on || open ? 500 : 400, chev: key === 'settings' ? (open ? 'chevron-down' : 'chevron-right') : '', chevML: key === 'settings' && s.navBadge ? '8px' : 'auto', sub: open, hasSites: !s.role || s.role === 'owner', subs2: (!s.role || s.role === 'owner') ? (() => { const o = cr0[cr0.length - 1] === 'Sites'; return [{ t: 'Sites', ic: 'globe', bg: o ? 'var(--rc-sand)' : 'transparent', fg: o ? 'var(--rc-on-sand)' : 'var(--ui-text-toned)', fw: o ? 500 : 400 }] })() : [], subs: ['Variants', 'Cache'].map(x => { const o = cr0[cr0.length - 1] === x; return { t: x, ic: x === 'Variants' ? 'layers' : 'database', bg: o ? 'var(--rc-sand)' : 'transparent', fg: o ? 'var(--rc-on-sand)' : 'var(--ui-text-toned)', fw: o ? 500 : 400 } }) } })
|
const nav = navItems.map(([key, icon, t]) => { const on = parentOn(key), open = key === 'settings' && setOpen; return { key, icon, t, bg: on ? 'var(--rc-sand)' : 'transparent', badge: key === 'settings' && !!s.navBadge, fg: on ? 'var(--rc-on-sand)' : open ? 'var(--ui-text-highlighted)' : 'var(--ui-text-toned)', fw: on || open ? 500 : 400, chev: key === 'settings' ? (open ? 'chevron-down' : 'chevron-right') : '', chevML: key === 'settings' && s.navBadge ? '8px' : 'auto', sub: open, hasSites: !s.role || s.role === 'owner', subs2: (!s.role || s.role === 'owner') ? (() => { const o = cr0[cr0.length - 1] === 'Sites'; return [{ t: 'Sites', ic: 'globe', bg: o ? 'var(--rc-sand)' : 'transparent', fg: o ? 'var(--rc-on-sand)' : 'var(--ui-text-toned)', fw: o ? 500 : 400 }] })() : [], subs: ['Variants', 'Cache'].map(x => { const o = cr0[cr0.length - 1] === x; return { t: x, ic: x === 'Variants' ? 'layers' : 'database', bg: o ? 'var(--rc-sand)' : 'transparent', fg: o ? 'var(--rc-on-sand)' : 'var(--ui-text-toned)', fw: o ? 500 : 400 } }) } })
|
||||||
|
const CT = s.content || [], ctOn = s.contentOn, ctMax = 5, ctVis = CT.slice(0, Math.max(ctMax, CT.findIndex(x => x.key === ctOn) + 1)), contentRows = (m ? (s.contentOpen ? ctVis : []) : ctVis).map(x => { const on = x.key === ctOn; return { key: x.key, name: x.name, icon: x.icon || 'file-text', bg: on ? 'var(--rc-sand)' : 'transparent', fg: on ? 'var(--rc-on-sand)' : 'var(--ui-text-toned)', fw: on ? 500 : 400 } })
|
||||||
const crumbs = (s.crumbs || []).map((t, i, a) => ({ t, sep: i > 0, fg: i === a.length - 1 ? 'var(--ui-text-highlighted)' : 'var(--ui-text-muted)', fw: i === a.length - 1 ? 500 : 400 }))
|
const crumbs = (s.crumbs || []).map((t, i, a) => ({ t, sep: i > 0, fg: i === a.length - 1 ? 'var(--ui-text-highlighted)' : 'var(--ui-text-muted)', fw: i === a.length - 1 ? 500 : 400 }))
|
||||||
const L = s.list || {}
|
const L = s.list || {}
|
||||||
const counts = L.counts || [6, 4, 2]
|
const counts = L.counts || [6, 4, 2]
|
||||||
@@ -541,7 +543,7 @@ class Component extends DCLogic {
|
|||||||
chipBg: s.userMenu || s.nav === 'profile' ? 'var(--rc-sand)' : 'transparent', chipFg: s.userMenu || s.nav === 'profile' ? 'var(--rc-on-sand)' : 'var(--ui-text-highlighted)',
|
chipBg: s.userMenu || s.nav === 'profile' ? 'var(--rc-sand)' : 'transparent', chipFg: s.userMenu || s.nav === 'profile' ? 'var(--rc-on-sand)' : 'var(--ui-text-highlighted)',
|
||||||
isSettings: k === 'settings', isProfile: k === 'profile', pfLoading: k === 'profile' && !!pf.loading, pfReady: k === 'profile' && !pf.loading,
|
isSettings: k === 'settings', isProfile: k === 'profile', pfLoading: k === 'profile' && !!pf.loading, pfReady: k === 'profile' && !pf.loading,
|
||||||
upBtnDir: m ? 'column' : 'row', upBtnAlign: m ? 'stretch' : 'flex-start', cardPad: '16px', cardPadX: m ? '16px' : '20px', inH: m ? '44px' : '40px', pfCols: m ? 'minmax(0,1fr)' : 'minmax(0,1fr) minmax(0,1.4fr)', pwDir: m ? 'column' : 'row', pwAlign: m ? 'stretch' : 'flex-end', edTitle, edBody: e.body || '', sampleBody: !e.body, tabs, rows, save, counter, pub, mFields, up, toasts, endpoints, toolbar,
|
upBtnDir: m ? 'column' : 'row', upBtnAlign: m ? 'stretch' : 'flex-start', cardPad: '16px', cardPadX: m ? '16px' : '20px', inH: m ? '44px' : '40px', pfCols: m ? 'minmax(0,1fr)' : 'minmax(0,1fr) minmax(0,1.4fr)', pwDir: m ? 'column' : 'row', pwAlign: m ? 'stretch' : 'flex-end', edTitle, edBody: e.body || '', sampleBody: !e.body, tabs, rows, save, counter, pub, mFields, up, toasts, endpoints, toolbar,
|
||||||
desk: !m, mob: m, notBoot: !s.boot,
|
contentShow: CT.length > 0, contentRows, contentColl: m && !s.contentOpen, contentCount: String(CT.length), contentMore: (!m || s.contentOpen) && CT.length > ctVis.length ? 'Show all ' + CT.length : '', desk: !m, mob: m, notBoot: !s.boot,
|
||||||
fw: m ? '390px' : '1440px', fh: m ? '844px' : '900px', dir: m ? 'column' : 'row',
|
fw: m ? '390px' : '1440px', fh: m ? '844px' : '900px', dir: m ? 'column' : 'row',
|
||||||
mLead: s.mLead || 'menu',
|
mLead: s.mLead || 'menu',
|
||||||
navDrawer: m && !!s.drawer, isSlot: !!this.props.slotBody, slotBody: this.props.slotBody || null, slotOverlays: this.props.slotOverlays || null, isBoot: !!s.boot, isHome: k === 'home' && !s.boot, isList: k === 'articles', isEditor: k === 'editor', isInteg: k === 'integration', isKeys: k === 'apikeys', isError: k === 'error', isUsers: k === 'users',
|
navDrawer: m && !!s.drawer, isSlot: !!this.props.slotBody, slotBody: this.props.slotBody || null, slotOverlays: this.props.slotOverlays || null, isBoot: !!s.boot, isHome: k === 'home' && !s.boot, isList: k === 'articles', isEditor: k === 'editor', isInteg: k === 'integration', isKeys: k === 'apikeys', isError: k === 'error', isUsers: k === 'users',
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<script src="./support.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<x-dc>
|
||||||
|
<helmet>
|
||||||
|
<meta name="design_doc_mode" content="canvas">
|
||||||
|
<link rel="icon" href="brand/favicon.svg">
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||||
|
<style>body{margin:0;font-family:'IBM Plex Sans',sans-serif;font-size:13px;line-height:20px}*{box-sizing:border-box}a{color:inherit;text-underline-offset:3px}a:hover{opacity:.8}code{font-family:'IBM Plex Mono',monospace;font-size:12px}</style>
|
||||||
|
</helmet>
|
||||||
|
<div ref="{{ rootRef }}" data-screen-label="Schema screens" style="background:var(--rc-canvas);color:var(--ui-text);padding:64px;display:flex;flex-direction:column;gap:72px;width:max-content;min-width:100%">
|
||||||
|
<div style="display:flex;align-items:flex-end;gap:32px;max-width:1400px">
|
||||||
|
<div style="flex:1;display:flex;flex-direction:column;gap:10px">
|
||||||
|
<h1 style="margin:0;font:600 36px/44px 'IBM Plex Sans',sans-serif;letter-spacing:-0.02em;color:var(--ui-text-highlighted)">Screens: custom content types</h1>
|
||||||
|
<p style="margin:0;max-width:900px;font-size:16px;line-height:26px;color:var(--ui-text-toned);text-wrap:pretty">Schema list, builder, change review, field-type gallery, content navigation, entry list and entry editor, plus the later screens: relations, permissions, export and import. Every state at 1440 px (shown at 50%) and 390 px (shown at 75%); the switch shows dark. Each state is tagged <b>Available now</b> or <b>Later</b>. Spec tables at the end: scope, routes, components with their Nuxt UI mapping, errors, needs backend, decisions, accessibility. Same shell, tokens and copy tone as <a href="RenewCMS Screens Back-office.dc.html">the back-office board</a>. Changed in AppFrame: a Schema item and a Content group in the sidebar and the mobile drawer.</p>
|
||||||
|
</div>
|
||||||
|
<div role="radiogroup" aria-label="Theme" style="display:flex;border:1px solid var(--ui-border-accented);background:var(--ui-bg)">
|
||||||
|
<sc-for list="{{ modes }}" as="m" hint-placeholder-count="3">
|
||||||
|
<button role="radio" aria-checked="{{ m.on }}" onClick="{{ m.pick }}" style="all:unset;cursor:pointer;height:36px;padding:0 14px;display:flex;align-items:center;font-weight:500;font-size:14px;background:{{ m.bg }};color:{{ m.fg }}" style-focus-visible="outline:2px solid var(--ui-primary);outline-offset:2px">{{ m.label }}</button>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<sc-for list="{{ groups }}" as="g" hint-placeholder-count="9">
|
||||||
|
<section data-screen-label="{{ g.label }}" style="display:flex;flex-direction:column;gap:28px">
|
||||||
|
<div style="display:flex;flex-direction:column;gap:6px;max-width:1100px">
|
||||||
|
<h2 style="margin:0;font:600 28px/36px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">{{ g.title }}</h2>
|
||||||
|
<div style="display:flex;gap:16px;flex-wrap:wrap;font:400 14px/22px 'IBM Plex Mono',monospace;color:var(--ui-text-toned)"><span>{{ g.route }}</span><span style="color:var(--ui-text-muted)">{{ g.api }}</span><span style="color:var(--ui-text-muted)">Auth: {{ g.auth }}</span></div>
|
||||||
|
<p style="margin:4px 0 0;font-size:15px;line-height:24px;color:var(--ui-text-toned);text-wrap:pretty">{{ g.note }}</p>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;flex-wrap:wrap;gap:48px 40px;align-items:flex-start">
|
||||||
|
<sc-for list="{{ g.states }}" as="st" hint-placeholder-count="4">
|
||||||
|
<div style="display:flex;flex-direction:column;gap:12px;width:1037px">
|
||||||
|
<div style="display:flex;flex-direction:column;gap:2px"><span style="font:600 16px/24px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">{{ st.name }}<span style="margin-left:10px;padding:1px 6px;border:{{ st.chipBd }};color:var(--ui-text-toned);font:500 11px/16px 'IBM Plex Mono',monospace;vertical-align:middle">{{ st.scopeLabel }}</span></span><span style="font-size:13px;color:var(--ui-text-muted)">{{ st.trigger }}</span></div>
|
||||||
|
<div style="display:flex;gap:24px;align-items:flex-start">
|
||||||
|
<div style="width:720px;height:450px;overflow:hidden;border:1px solid var(--ui-border);flex-shrink:0"><div style="width:1440px;height:900px;zoom:0.5"><dc-import name="SchemaFrame" s="{{ st.s }}" mobile="{{ false }}" hint-size="1440px,900px"></dc-import></div></div>
|
||||||
|
<div style="width:293px;height:633px;overflow:hidden;border:1px solid var(--ui-border);flex-shrink:0"><div style="width:390px;height:844px;zoom:0.75"><dc-import name="SchemaFrame" s="{{ st.s }}" mobile="{{ true }}" hint-size="390px,844px"></dc-import></div></div>
|
||||||
|
</div>
|
||||||
|
<p style="margin:0;font-size:12px;line-height:18px;color:var(--ui-text-muted);text-wrap:pretty"><b style="color:var(--ui-text-toned)">Copy:</b> {{ st.copy }}</p>
|
||||||
|
</div>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</sc-for>
|
||||||
|
<sc-for list="{{ tables }}" as="t" hint-placeholder-count="3">
|
||||||
|
<section style="display:flex;flex-direction:column;gap:10px;max-width:1240px">
|
||||||
|
<h2 style="margin:0;font:600 22px/30px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">{{ t.title }}</h2>
|
||||||
|
<div style="background:var(--ui-bg);border:1px solid var(--ui-border)">
|
||||||
|
<div style="display:grid;grid-template-columns:{{ t.tpl }};gap:16px;padding:8px 12px;background:var(--ui-bg-elevated);color:var(--ui-text-muted);font-weight:500"><sc-for list="{{ t.cols }}" as="c" hint-placeholder-count="3"><span>{{ c }}</span></sc-for></div>
|
||||||
|
<sc-for list="{{ t.rows }}" as="r" hint-placeholder-count="4"><div style="display:grid;grid-template-columns:{{ t.tpl }};gap:16px;padding:10px 12px;border-top:1px solid var(--ui-border);line-height:20px"><sc-for list="{{ r.cells }}" as="c" hint-placeholder-count="3"><span style="color:var(--ui-text-highlighted);text-wrap:pretty;overflow-wrap:anywhere">{{ c }}</span></sc-for></div></sc-for>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
</x-dc>
|
||||||
|
<script type="text/x-dc" data-dc-script data-props="{"theme":{"editor":"enum","default":"light","options":["system","light","dark"],"tsType":"string"}}">
|
||||||
|
class Component extends DCLogic {
|
||||||
|
state = { mode: this.props.theme ?? 'light', spec: { groups: [], tables: [] } }
|
||||||
|
rootRef = React.createRef()
|
||||||
|
light = { '--ui-bg': '#ffffff', '--ui-bg-muted': '#fafafa', '--ui-bg-elevated': '#f3f2f0', '--ui-bg-accented': '#e6e4e0', '--ui-bg-inverted': '#111111', '--ui-text-dimmed': '#999999', '--ui-text-muted': '#666666', '--ui-text-toned': '#444444', '--ui-text': '#222222', '--ui-text-highlighted': '#111111', '--ui-text-inverted': '#ffffff', '--ui-border': '#e6e4e0', '--ui-border-muted': '#f3f2f0', '--ui-border-accented': '#8a8783', '--ui-primary': '#111111', '--ui-secondary': '#635643', '--ui-success': '#315b33', '--ui-info': '#34536d', '--ui-warning': '#6c4700', '--ui-error': '#882c21', '--rc-sand': '#e8dfd1', '--rc-on-sand': '#111111', '--rc-success-soft': '#eaefea', '--rc-info-soft': '#ebeef0', '--rc-warning-soft': '#f0ede6', '--rc-error-soft': '#f3eae9', '--rc-overlay': 'rgb(10 10 10 / 0.4)', '--rc-shadow-pop': '0 4px 16px rgb(17 17 17 / 0.08), 0 1px 2px rgb(17 17 17 / 0.06)', '--rc-shadow-modal': '0 16px 48px rgb(17 17 17 / 0.18)', '--rc-canvas': '#ecebe8' }
|
||||||
|
dark = { '--ui-bg': '#111111', '--ui-bg-muted': '#0a0a0a', '--ui-bg-elevated': '#1a1a1a', '--ui-bg-accented': '#222222', '--ui-bg-inverted': '#fafafa', '--ui-text-dimmed': '#666666', '--ui-text-muted': '#999999', '--ui-text-toned': '#cfccc7', '--ui-text': '#e6e4e0', '--ui-text-highlighted': '#fafafa', '--ui-text-inverted': '#111111', '--ui-border': '#333333', '--ui-border-muted': '#222222', '--ui-border-accented': '#666666', '--ui-primary': '#fafafa', '--ui-secondary': '#d4c5b0', '--ui-success': '#a3caa3', '--ui-info': '#a5c2db', '--ui-warning': '#dfb67d', '--ui-error': '#fba394', '--rc-sand': '#d4c5b0', '--rc-on-sand': '#111111', '--rc-success-soft': '#1f241f', '--rc-info-soft': '#202428', '--rc-warning-soft': '#2a251e', '--rc-error-soft': '#2c201f', '--rc-overlay': 'rgb(0 0 0 / 0.6)', '--rc-shadow-pop': '0 4px 16px rgb(0 0 0 / 0.5)', '--rc-shadow-modal': '0 16px 48px rgb(0 0 0 / 0.6)', '--rc-canvas': '#1c1c1c' }
|
||||||
|
resolved() { if (this.state.mode === 'system') return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; return this.state.mode }
|
||||||
|
apply() { const el = this.rootRef.current; if (!el) return; const vars = this.resolved() === 'dark' ? this.dark : this.light; for (const k in vars) el.style.setProperty(k, vars[k]); document.body.style.background = vars['--rc-canvas'] }
|
||||||
|
componentDidMount() { this.apply(); import('./schema-spec.js').then(x => this.setState({ spec: { groups: x.groups, tables: x.tables } })).catch(() => {}) }
|
||||||
|
componentDidUpdate(p) { if (p.theme !== this.props.theme && this.props.theme) this.setState({ mode: this.props.theme }); this.apply() }
|
||||||
|
renderVals() {
|
||||||
|
const modes = [['system', 'System'], ['light', 'Light'], ['dark', 'Dark']].map(([k, label]) => { const on = this.state.mode === k; return { label, on, bg: on ? 'var(--ui-primary)' : 'transparent', fg: on ? 'var(--ui-bg)' : 'var(--ui-text-toned)', pick: () => this.setState({ mode: k }) } })
|
||||||
|
const groups = this.state.spec.groups.map(g => ({ ...g, states: g.states.map(s => ({ ...s, scopeLabel: s.scope === 'later' ? 'Later' : 'Available now', chipBd: s.scope === 'later' ? '1px dashed var(--ui-border-accented)' : '1px solid var(--ui-border-accented)' })) }))
|
||||||
|
const tables = this.state.spec.tables.map(t => ({ ...t, rows: t.rows.map(r => ({ cells: r })) }))
|
||||||
|
return { rootRef: this.rootRef, modes, groups, tables }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,426 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<script src="./support.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<x-dc>
|
||||||
|
<sc-if value="{{ framed }}" hint-placeholder-val="{{ true }}"><dc-import name="AppFrame" s="{{ shell }}" mobile="{{ mob }}" slot-body="{{ main }}" slot-overlays="{{ ov }}" hint-size="100%,100%"></dc-import></sc-if>
|
||||||
|
<sc-if value="{{ bare }}"><div style="width:{{ bw }};height:{{ bh }};overflow:hidden;position:relative;background:var(--ui-bg-muted);color:var(--ui-text);font-family:'IBM Plex Sans',sans-serif;font-size:13px;line-height:20px">{{ main }}</div></sc-if>
|
||||||
|
</x-dc>
|
||||||
|
<script type="text/x-dc" data-dc-script data-props="{"$preview":{"width":1440,"height":900},"s":{"editor":null,"tsType":"object"},"mobile":{"editor":null,"tsType":"boolean"}}">
|
||||||
|
class Component extends DCLogic {
|
||||||
|
renderVals() {
|
||||||
|
const s = this.props.s || {}, m = !!this.props.mobile
|
||||||
|
const P = t => Object.fromEntries(t.split(';').filter(x => x.trim()).map(x => { const i = x.indexOf(':'); const k = x.slice(0, i).trim(); return [k.startsWith('--') ? k : k.replace(/-([a-z])/g, (_, c) => c.toUpperCase()), x.slice(i + 1).trim()] }))
|
||||||
|
const h = React.createElement
|
||||||
|
const D = (t, ...c) => h('div', { style: P(t) }, ...c)
|
||||||
|
const S = (t, ...c) => h('span', { style: P(t) }, ...c)
|
||||||
|
const I = (n, z = 16, t = '') => h('span', { 'aria-hidden': 'true', style: { display: 'inline-block', flexShrink: 0, width: z, height: z, backgroundColor: 'currentColor', WebkitMask: 'url(https://api.iconify.design/lucide/' + n + '.svg) center / contain no-repeat', mask: 'url(https://api.iconify.design/lucide/' + n + '.svg) center / contain no-repeat', ...(t ? P(t) : {}) } })
|
||||||
|
const mono = 'font-family:\'IBM Plex Mono\',monospace;'
|
||||||
|
const bh = m ? 44 : 32, ih = m ? 44 : 32, fs = m ? 16 : 13
|
||||||
|
const B = (label, o = {}) => {
|
||||||
|
const k = o.k || 'outline'
|
||||||
|
const bg = k === 'primary' ? 'var(--ui-primary)' : k === 'danger' ? 'var(--ui-error)' : k === 'ghost' ? 'transparent' : 'var(--ui-bg)'
|
||||||
|
const fg = k === 'primary' || k === 'danger' ? 'var(--ui-bg)' : k === 'del' ? 'var(--ui-error)' : 'var(--ui-text-highlighted)'
|
||||||
|
const bd = k === 'primary' || k === 'danger' ? bg : k === 'ghost' ? 'transparent' : 'var(--ui-border-accented)'
|
||||||
|
return S('height:' + (o.h || bh) + 'px;' + (o.w ? 'width:' + o.w + 'px;' : '') + 'padding:0 ' + (o.px ?? 12) + 'px;display:flex;align-items:center;justify-content:center;gap:6px;background:' + bg + ';color:' + fg + ';border:1px solid ' + bd + ';font-weight:500;font-size:' + (o.fs || 13) + 'px;white-space:nowrap;flex-shrink:0;opacity:' + (o.dis ? 0.4 : 1) + ';' + (o.full ? 'flex:1;' : '') + (o.ring ? 'outline:2px solid var(--ui-primary);outline-offset:2px;' : ''), o.icon && I(o.icon, o.z || 16), label)
|
||||||
|
}
|
||||||
|
const IB = (ic, o = {}) => B(null, { k: 'ghost', icon: ic, w: o.w || (m ? 44 : 28), h: o.h || (m ? 44 : 28), px: 0, dis: o.dis, ring: o.ring, z: o.z || 16 })
|
||||||
|
const Alert = (tone, icon, title, text, ...actions) => D('display:flex;gap:10px;padding:12px 14px;background:var(--rc-' + tone + '-soft);color:var(--ui-' + tone + ')', S('display:flex;padding-top:2px', I(icon)), D('flex:1;display:flex;flex-direction:column;gap:8px', D('display:flex;flex-direction:column;gap:2px', S('font-weight:600', title), text && S('color:var(--ui-text-toned);text-wrap:pretty', text)), actions.length > 0 && D('display:flex;gap:8px;flex-wrap:wrap', ...actions.map((a, i) => B(a, { icon: i === 0 && /again|Retry|Reload/.test(a) ? 'rotate-cw' : null, z: 14, h: m ? 44 : 28, fs: 12, px: 10, k: 'outline' })))))
|
||||||
|
const Later = (t = 'Later') => S('align-self:flex-start;height:20px;padding:0 6px;display:inline-flex;align-items:center;border:1px dashed var(--ui-border-accented);color:var(--ui-text-toned);white-space:nowrap;flex-shrink:0;font:500 11px/16px \'IBM Plex Mono\',monospace', t)
|
||||||
|
const BDG = { req: 'border:1px solid var(--ui-border-accented);color:var(--ui-text-toned)', uniq: 'border:1px solid var(--ui-border-accented);color:var(--ui-text-toned)', neu: 'background:var(--rc-sand);color:var(--rc-on-sand)', chg: 'background:var(--rc-info-soft);color:var(--ui-info)', err: 'background:var(--rc-error-soft);color:var(--ui-error)', warn: 'background:var(--rc-warning-soft);color:var(--ui-warning)', built: 'border:1px solid var(--ui-border-accented);color:var(--ui-text-toned)', ok: 'background:var(--rc-success-soft);color:var(--ui-success)' }
|
||||||
|
const Bd = (t, k, ic) => S('height:20px;padding:0 6px;display:inline-flex;align-items:center;gap:4px;font-size:12px;font-weight:500;white-space:nowrap;flex-shrink:0;' + BDG[k], ic && I(ic, 12), t)
|
||||||
|
const Seg = (opts, on, w) => D('display:flex;border:1px solid var(--ui-border-accented);width:' + (w || '100%'), ...opts.map(([k, t]) => S('flex:1;height:' + bh + 'px;padding:0 10px;display:flex;align-items:center;justify-content:center;font-weight:' + (on === k ? 500 : 400) + ';background:' + (on === k ? 'var(--rc-sand)' : 'var(--ui-bg)') + ';color:' + (on === k ? 'var(--rc-on-sand)' : 'var(--ui-text-toned)') + ';font-size:' + fs + 'px', t)))
|
||||||
|
const Sw = (on, lab, help, dis) => D('display:flex;gap:12px;align-items:flex-start;opacity:' + (dis ? 0.6 : 1), S('width:32px;height:18px;margin-top:2px;padding:2px;display:flex;flex-shrink:0;justify-content:' + (on ? 'flex-end' : 'flex-start') + ';background:' + (on ? 'var(--ui-primary)' : 'var(--ui-bg-accented)') + ';' + (on ? '' : 'outline:1px solid var(--ui-border-accented);outline-offset:-1px'), S('width:14px;height:14px;background:var(--ui-bg)')), D('display:flex;flex-direction:column;gap:2px', lab && S('font-weight:500;color:var(--ui-text-highlighted)', lab), help && S('color:var(--ui-text-muted);font-size:12px;line-height:18px;text-wrap:pretty', help)))
|
||||||
|
const Chk = on => S('width:16px;height:16px;flex-shrink:0;display:inline-flex;align-items:center;justify-content:center;box-sizing:border-box;border:1px solid ' + (on ? 'var(--ui-primary)' : 'var(--ui-border-accented)') + ';background:' + (on ? 'var(--ui-primary)' : 'var(--ui-bg)') + ';color:var(--ui-bg)', on && I('check', 12))
|
||||||
|
const Skel = (w, hh = 14) => S('display:block;height:' + hh + 'px;width:' + w + ';background:var(--ui-bg-accented)')
|
||||||
|
const F = (lab, val, o = {}) => D('display:flex;flex-direction:column;gap:6px;min-width:0', (lab || o.count) && D('display:flex;justify-content:space-between;gap:8px', S('font-weight:500;color:var(--ui-text-highlighted)', lab, o.req && ' *', o.opt && S('font-weight:400;color:var(--ui-text-muted)', ' (optional)')), o.count && S(mono + 'font-size:12px;color:' + (o.countErr ? 'var(--ui-error)' : 'var(--ui-text-muted)'), o.count)),
|
||||||
|
D('display:flex;align-items:' + (o.area ? 'flex-start' : 'center') + ';min-height:' + (o.area ? (m ? 120 : 88) : ih) + 'px;border:1px solid ' + (o.err ? 'var(--ui-error)' : o.focus ? 'var(--ui-primary)' : 'var(--ui-border-accented)') + ';' + (o.err ? 'box-shadow:inset 0 0 0 1px var(--ui-error);' : o.focus ? 'box-shadow:inset 0 0 0 1px var(--ui-primary);' : '') + 'background:' + (o.dis || o.lock ? 'var(--ui-bg-elevated)' : 'var(--ui-bg)') + ';font-size:' + fs + 'px;opacity:' + (o.dis ? 0.6 : 1), o.pre, S('flex:1;min-width:0;padding:' + (o.area ? '8px 10px' : '0 10px') + ';color:' + (o.ph || val === '' ? 'var(--ui-text-muted)' : 'var(--ui-text-highlighted)') + ';' + (o.area ? '' : 'overflow:hidden;text-overflow:ellipsis;white-space:nowrap;') + (o.mono ? mono + 'font-size:' + (m ? 14 : 12) + 'px;' : ''), val === '' && o.phText ? o.phText : val), o.auto && Bd('Auto', 'neu'), o.auto && I('pencil', 14, 'margin:0 10px 0 8px;color:var(--ui-text-muted)'), o.lock && I('lock', 14, 'margin-right:10px;color:var(--ui-text-muted)'), o.sel && I('chevron-down', 14, 'margin-right:10px;color:var(--ui-text-muted)'), o.suffix && S('padding-right:10px;color:var(--ui-text-muted);font-size:12px', o.suffix), o.ico && I(o.ico, 14, 'margin-right:10px;color:var(--ui-text-muted)')),
|
||||||
|
o.err && S('display:flex;gap:6px;align-items:flex-start;color:var(--ui-error);font-size:12px;line-height:18px', I('circle-alert', 14, 'margin-top:2px'), o.err), o.help && S('color:var(--ui-text-muted);font-size:12px;line-height:18px;text-wrap:pretty', o.help))
|
||||||
|
const Shell = (o, body) => D('position:absolute;inset:0;z-index:' + (o.z || 50) + ';background:var(--rc-overlay);display:flex;align-items:' + (m ? 'flex-end' : 'flex-start') + ';justify-content:center;padding:' + (m ? '0' : (o.top || 140) + 'px 16px 16px'),
|
||||||
|
D('width:' + (m ? '100%' : (o.w || 460) + 'px') + ';max-height:100%;background:var(--ui-bg);box-shadow:var(--rc-shadow-modal);display:flex;flex-direction:column', m && D('display:flex;justify-content:center;padding-top:8px;flex-shrink:0', S('display:block;width:36px;height:4px;background:var(--ui-border-accented)')),
|
||||||
|
D('padding:20px 24px 0;display:flex;align-items:flex-start;gap:12px', D('flex:1;display:flex;flex-direction:column;gap:4px', S('font:600 16px/24px \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted)', o.title), o.desc && S('color:var(--ui-text-muted);text-wrap:pretty', o.desc)), o.later && Later(), o.close && S('width:' + (m ? 44 : 28) + 'px;height:' + (m ? 44 : 28) + 'px;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted)', I('x'))),
|
||||||
|
D('padding:20px 24px 0;display:flex;flex-direction:column;gap:16px;min-height:0;overflow:hidden', body),
|
||||||
|
D('padding:20px 24px;display:flex;flex-direction:' + (m ? 'column-reverse' : 'row') + ';justify-content:flex-end;gap:8px;flex-shrink:0', o.cancel && B(o.cancel, { dis: o.loading }), o.confirm && S('height:' + bh + 'px;padding:0 12px;display:flex;align-items:center;justify-content:center;gap:6px;background:' + (o.tone === 'danger' ? 'var(--ui-error)' : 'var(--ui-primary)') + ';color:var(--ui-bg);font-weight:500;white-space:nowrap;opacity:' + (o.cdis ? 0.4 : 1), o.loading && I('loader-circle'), o.confirm))))
|
||||||
|
const Sheet = (o, body, foot) => D('position:absolute;inset:0;z-index:' + (o.z || 40) + ';display:flex;align-items:flex-end;background:var(--rc-overlay)',
|
||||||
|
D('width:100%;height:92%;background:var(--ui-bg);box-shadow:var(--rc-shadow-modal);display:flex;flex-direction:column', D('display:flex;justify-content:center;padding-top:8px;flex-shrink:0', S('display:block;width:36px;height:4px;background:var(--ui-border-accented)')),
|
||||||
|
D('flex-shrink:0;display:flex;align-items:center;gap:12px;padding:4px 6px 4px 16px;height:56px;border-bottom:1px solid var(--ui-border)', S('flex:1;min-width:0;font:600 16px/24px \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap', o.title), o.later && Later(), S('width:44px;height:44px;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted)', I('x'))),
|
||||||
|
D('flex:1;min-height:0;overflow:hidden;padding:16px;display:flex;flex-direction:column;gap:16px', body), foot && D('flex-shrink:0;padding:12px 16px;border-top:1px solid var(--ui-border);display:flex;gap:8px', foot)))
|
||||||
|
const Toast = (tone, icon, title, text, action) => D('position:absolute;z-index:60;display:flex;gap:10px;padding:12px 14px;background:var(--ui-bg);border:1px solid var(--ui-border);box-shadow:var(--rc-shadow-pop);' + (m ? 'left:16px;right:16px;bottom:' + (s.toastB || 16) + 'px' : 'right:24px;bottom:24px;width:356px'), S('margin-top:2px;display:flex;color:var(--ui-' + tone + ')', I(icon)), D('flex:1;display:flex;flex-direction:column;gap:6px', D('display:flex;flex-direction:column;gap:2px', S('font-weight:600;color:var(--ui-text-highlighted)', title), S('color:var(--ui-text-muted)', text)), action && B(action, { icon: 'rotate-cw', z: 14, h: m ? 44 : 28, fs: 12, px: 10 })), I('x'))
|
||||||
|
const Offline = () => D('flex-shrink:0;display:flex;align-items:center;gap:10px;padding:10px 16px;background:var(--ui-bg-inverted);color:var(--ui-text-inverted)', I('wifi-off'), S('flex:1', 'You\'re offline. Changes will be saved when you reconnect.'))
|
||||||
|
const Tip = t => D('position:absolute;z-index:30;width:260px;padding:8px 10px;background:var(--ui-bg-inverted);color:var(--ui-text-inverted);font-size:12px;line-height:18px;box-shadow:var(--rc-shadow-pop)', t)
|
||||||
|
const Page = (pad, ...c) => D('flex:1;min-width:0;min-height:0;display:flex;flex-direction:column;position:relative;overflow:hidden;padding:' + pad + ';gap:16px', ...c)
|
||||||
|
const Title = (t, sub, ...acts) => D('display:flex;align-items:flex-end;gap:16px;flex-wrap:wrap;flex-shrink:0', D('flex:1;min-width:200px;display:flex;flex-direction:column;gap:2px', !m && S('font:600 20px/28px \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted)', t), sub && S('color:var(--ui-text-muted)', sub)), ...acts)
|
||||||
|
const pad = m ? '16px' : '24px'
|
||||||
|
const Fab = l => m && D('position:absolute;left:16px;right:16px;bottom:16px;height:48px;z-index:5;display:flex;align-items:center;justify-content:center;gap:8px;background:var(--ui-primary);color:var(--ui-bg);font-weight:500;font-size:15px;box-shadow:var(--rc-shadow-pop)', I('plus', 18), l)
|
||||||
|
const Center = (icon, title, text, ...acts) => D('flex:1;display:flex;align-items:center;justify-content:center;padding:24px', D('max-width:420px;display:flex;flex-direction:column;align-items:center;gap:12px;text-align:center', S('width:44px;height:44px;display:flex;align-items:center;justify-content:center;background:var(--ui-bg-elevated);color:var(--ui-text-toned)', I(icon, 20)), S('font:600 24px/32px \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted)', title), S('color:var(--ui-text-muted);text-wrap:pretty', text), D('display:flex;gap:8px', ...acts)))
|
||||||
|
const Empty = (icon, title, text, action) => D('background:var(--ui-bg);border:1px solid var(--ui-border);padding:40px 24px;display:flex;flex-direction:column;align-items:center;gap:12px;text-align:center', S('width:44px;height:44px;display:flex;align-items:center;justify-content:center;background:var(--ui-bg-elevated);color:var(--ui-text-toned)', I(icon, 20)), S('font-weight:600;font-size:15px;color:var(--ui-text-highlighted)', title), S('color:var(--ui-text-muted);max-width:360px;text-wrap:pretty', text), action && B(action, { k: 'primary', icon: 'plus', px: 16 }))
|
||||||
|
|
||||||
|
// ---------- data
|
||||||
|
const FT = { text: ['type', 'Text', 'Short text, like a title or a name'], richtext: ['pilcrow', 'Rich text', 'Long text with formatting, written in Markdown'], number: ['hash', 'Number', 'Whole or decimal numbers'], boolean: ['toggle-left', 'Yes or no', 'An on or off switch'], date: ['calendar', 'Date', 'A calendar day, without a time'], select: ['list', 'Choice', 'One option from a list you define'] }
|
||||||
|
const EV = [
|
||||||
|
{ key: 'title', label: 'Title', type: 'text', req: true, uniq: true, max: 80 }, { key: 'starts_on', label: 'Starts on', type: 'date', req: true }, { key: 'ends_on', label: 'Ends on', type: 'date' },
|
||||||
|
{ key: 'online', label: 'Online', type: 'boolean', def: false }, { key: 'capacity', label: 'Capacity', type: 'number', min: 1, max: 5000 }, { key: 'category', label: 'Category', type: 'select', opts: ['Talk', 'Workshop', 'Meetup'] }, { key: 'description', label: 'Description', type: 'richtext' }].map(f => ({ ...f, saved: true }))
|
||||||
|
const RC = [{ key: 'title', label: 'Title', type: 'text', req: true }, { key: 'servings', label: 'Servings', type: 'number', min: 1 }, { key: 'vegetarian', label: 'Vegetarian', type: 'boolean' }, { key: 'method', label: 'Method', type: 'richtext' }].map(f => ({ ...f, saved: true }))
|
||||||
|
const SPK = { key: 'speaker', label: 'Speaker', type: 'text', isNew: true }, ROOM = { key: 'room', label: 'Room', type: 'select', req: true, def: 'Main hall', opts: ['Main hall', 'Studio'], isNew: true }
|
||||||
|
const CAPC = { ...EV[4], max: 2000, changed: true }
|
||||||
|
const DIRTY = [EV[0], EV[1], EV[2], EV[3], CAPC, EV[5], SPK, ROOM]
|
||||||
|
const CT = n => ['Events', 'Recipes', 'Press mentions', 'Frequently asked questions and answers', 'Job openings', 'Testimonials', 'Suppliers', 'Certifications', 'Case studies', 'Glossary terms', 'Partners', 'Downloads', 'Locations', 'Awards'].slice(0, n).map(x => ({ key: x === 'Events' ? 'events' : x.toLowerCase().replace(/[^a-z0-9]+/g, '-'), name: x, icon: 'files' }))
|
||||||
|
const shell = { kind: 'slot', display: 'Marta Alves', role: s.role || 'owner', content: CT(s.many ? 14 : 3), contentOn: 'events', navBadge: false }
|
||||||
|
const BUILTIN = [['Articles', 'articles', 5], ['Authors', 'authors', 5], ['Sites', 'sites', 8], ['Media', 'media', 6], ['Users', 'users', 6]]
|
||||||
|
const CUSTOM = [['Events', 'events', 7, '15 Oct 2026'], ['Recipes', 'recipes', 4, '2 Oct 2026'], ['Press mentions', 'press-mentions', 5, '18 Sep 2026']]
|
||||||
|
const ROWS = [
|
||||||
|
{ id: 42, title: 'Lime plaster workshop', starts_on: '21 Nov 2026', ends_on: '22 Nov 2026', online: false, capacity: 18, category: 'Workshop', description: 'Two days of hands-on work with lime putty and old stone.', c: '12 Oct 2026', u: '15 Oct 2026, 11:02', by: 'Marta Alves' },
|
||||||
|
{ id: 41, title: 'Heat pumps: what the numbers say', starts_on: '3 Nov 2026', ends_on: '', online: true, capacity: 500, category: 'Talk', description: 'A look at running costs after two winters.', c: '8 Oct 2026', u: '14 Oct 2026, 16:40', by: 'Jonas Pereira' },
|
||||||
|
{ id: 40, title: 'Open day at the Rue Verte site', starts_on: '12 Nov 2026', ends_on: '12 Nov 2026', online: false, capacity: 120, category: 'Meetup', description: '', c: '2 Oct 2026', u: '12 Oct 2026, 09:15', by: 'Inès Duarte' },
|
||||||
|
{ id: 39, title: 'Winter windows clinic', starts_on: '5 Dec 2026', ends_on: '', online: false, capacity: null, category: 'Workshop', description: 'Bring a window, leave with a plan.', c: '30 Sep 2026', u: '9 Oct 2026, 14:22', by: 'Marta Alves' },
|
||||||
|
{ id: 38, title: 'Timber joinery for beginners', starts_on: '14 Jan 2027', ends_on: '15 Jan 2027', online: false, capacity: 12, category: 'Workshop', description: 'Mortise, tenon and a lot of patience.', c: '28 Sep 2026', u: '7 Oct 2026, 10:05', by: 'Inès Duarte' },
|
||||||
|
{ id: 37, title: 'Q&A with the site managers', starts_on: '28 Oct 2026', ends_on: '', online: true, capacity: null, category: 'Meetup', description: '', c: '21 Sep 2026', u: '2 Oct 2026, 17:48', by: 'Jonas Pereira' },
|
||||||
|
{ id: 36, title: 'Reclaimed floorboards talk', starts_on: '17 Oct 2026', ends_on: '', online: false, capacity: 60, category: 'Talk', description: 'Where to find them and how to lay them.', c: '14 Sep 2026', u: '29 Sep 2026, 08:30', by: 'Marta Alves' }]
|
||||||
|
const mdSample = 'Two days of hands-on work with **lime putty** and old stone.\n\n- Bring work clothes\n- Lunch is included'
|
||||||
|
const sampleVal = f => f.type === 'text' ? (f.def ?? 'Example text') : f.type === 'richtext' ? (f.def ?? 'Markdown text') : f.type === 'number' ? (f.def ?? f.min ?? 0) : f.type === 'boolean' ? (f.def ?? false) : f.type === 'date' ? (f.def ?? '2026-11-21') : (f.def ?? (f.opts && f.opts[0]) ?? null)
|
||||||
|
const apiJson = (fs_, key) => JSON.stringify({ id: 42, content_type: key, data: Object.fromEntries(fs_.map(f => [f.key, sampleVal(f)])), created_by: 3, updated_by: 3, created_at: '2026-10-15T11:02:00Z', updated_at: '2026-10-15T11:02:00Z' }, null, 2)
|
||||||
|
const MM = t => t === 'number' ? ['Lowest value', 'Highest value', ''] : t === 'date' ? ['Earliest date', 'Latest date', ''] : ['Shortest', 'Longest', 'characters']
|
||||||
|
const dot = (t, ic) => S('display:inline-flex;align-items:center;gap:6px', I(ic, 14), t)
|
||||||
|
|
||||||
|
// ---------- inspector
|
||||||
|
const Insp = (f, o = {}) => {
|
||||||
|
const t = f.type, e = f.err || {}, one = m || o.one, locked = !!f.saved
|
||||||
|
const [mnL, mxL, suf] = MM(t), canU = ['text', 'number', 'date', 'select'].includes(t), hasMM = ['text', 'richtext', 'number', 'date'].includes(t)
|
||||||
|
const g2 = one ? '1fr' : '1fr 1fr'
|
||||||
|
const tile = k => { const on = k === t, dim = locked && !on; return D('display:flex;gap:10px;align-items:flex-start;padding:10px;min-height:56px;border:1px solid ' + (on ? 'var(--ui-primary)' : 'var(--ui-border-accented)') + ';background:' + (on ? 'var(--rc-sand)' : 'var(--ui-bg)') + ';color:' + (on ? 'var(--rc-on-sand)' : 'var(--ui-text-highlighted)') + ';opacity:' + (dim ? 0.4 : 1), I(FT[k][0], 16, 'margin-top:2px'), D('display:flex;flex-direction:column;min-width:0', S('font-weight:500', FT[k][1]), S('font-size:12px;line-height:16px;color:' + (on ? 'var(--rc-on-sand)' : 'var(--ui-text-muted)') + ';text-wrap:pretty', FT[k][2]))) }
|
||||||
|
const mmErr = e.min || ''
|
||||||
|
const minV = f.min != null ? String(f.min) : '', maxV = f.max != null ? String(f.max) : ''
|
||||||
|
const defCtl = t === 'boolean' ? Sw(!!f.def, 'On by default', 'New entries start with this switch ' + (f.def ? 'on' : 'off') + ' when the field is left out.') : F('Default value', f.def != null ? String(f.def) : '', { opt: true, ph: f.def == null, phText: t === 'select' ? 'None' : t === 'date' ? 'No default' : 'No default', sel: t === 'select', ico: t === 'date' ? 'calendar' : null, area: t === 'richtext', err: e.def, dis: !!f.uniq, help: f.uniq ? 'A field with unique values can\'t have a default.' : e.def ? '' : 'Used when an entry is created and this field is left out.' })
|
||||||
|
const left = D('display:flex;flex-direction:column;gap:14px;min-width:0', Sw(!!f.req, 'Required', 'An entry can\'t be saved with this field empty.'), Sw(!!f.uniq, 'Unique values', canU ? 'Each entry needs a different value.' : t === 'richtext' ? 'Not available for rich text.' : 'Not available for yes or no fields.', !canU), defCtl)
|
||||||
|
const right = hasMM ? D('display:flex;flex-direction:column;gap:14px;min-width:0', F(mnL, minV, { suffix: suf, ph: minV === '', phText: 'No minimum', err: mmErr, help: mmErr ? '' : null }), F(mxL, maxV, { suffix: suf, ph: maxV === '', phText: 'No maximum', help: mmErr ? '' : t === 'text' || t === 'richtext' ? 'Counted in characters. Entries show a counter.' : t === 'number' ? 'Entries outside the range are refused.' : 'Entries outside the range are refused.' })) : D('display:flex;flex-direction:column;gap:6px;min-width:0', S('font-weight:500;color:var(--ui-text-highlighted)', 'Limits'), S('color:var(--ui-text-muted);font-size:12px;line-height:18px', t === 'boolean' ? 'Yes or no fields have no limits.' : 'Limits come from the choices below.'))
|
||||||
|
const ch = t === 'select' ? Choices(f) : null
|
||||||
|
return D('display:flex;flex-direction:column;gap:' + (m ? 16 : 14) + 'px;min-width:0',
|
||||||
|
D('display:grid;grid-template-columns:' + g2 + ';gap:14px', F('Label', f.label, { req: true, err: e.label, focus: o.focus === 'label', ph: !f.label, phText: 'For example, Title' }),
|
||||||
|
D('display:flex;flex-direction:column;gap:6px;min-width:0', F('Key', f.key || '', { mono: true, auto: !locked && !e.key && !!f.key, lock: locked, err: e.key, ph: !f.key, phText: 'title', dis: false, help: locked ? 'Used in the API, so it can\'t change.' : e.key ? '' : 'The name used in the API. Lowercase letters, digits and underscores.' }))),
|
||||||
|
D('display:flex;flex-direction:column;gap:6px', D('display:flex;justify-content:space-between;gap:8px', S('font-weight:500;color:var(--ui-text-highlighted)', 'Type'), locked && S('color:var(--ui-text-muted);font-size:12px;display:flex;gap:4px;align-items:center', I('lock', 12), 'Can\'t change after saving')), D('display:grid;grid-template-columns:repeat(' + (one ? 2 : 3) + ',minmax(0,1fr));gap:8px', ...Object.keys(FT).map(tile))),
|
||||||
|
D('display:grid;grid-template-columns:' + g2 + ';gap:14px;align-items:start', left, right),
|
||||||
|
ch)
|
||||||
|
}
|
||||||
|
const Choices = f => {
|
||||||
|
const e = f.err || {}, ops = f.opts || [], bad = e.choices || {}
|
||||||
|
return D('display:flex;flex-direction:column;gap:8px', D('display:flex;justify-content:space-between', S('font-weight:500;color:var(--ui-text-highlighted)', 'Choices', ' *'), S(mono + 'font-size:12px;color:var(--ui-text-muted)', ops.length + ' of 50')),
|
||||||
|
...ops.map((o_, i) => D('display:flex;flex-direction:column;gap:4px', D('display:flex;align-items:center;gap:4px', S('width:' + (m ? 44 : 28) + 'px;height:' + (m ? 44 : 28) + 'px;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted)', I('grip-vertical', 16)), D('flex:1;height:' + ih + 'px;display:flex;align-items:center;padding:0 10px;font-size:' + fs + 'px;border:1px solid ' + (bad[i] ? 'var(--ui-error)' : 'var(--ui-border-accented)') + ';background:var(--ui-bg);color:' + (o_ ? 'var(--ui-text-highlighted)' : 'var(--ui-text-muted)'), o_ || 'Choice'), IB('x')), bad[i] && S('margin-left:' + (m ? 48 : 32) + 'px;display:flex;gap:6px;color:var(--ui-error);font-size:12px;line-height:18px', I('circle-alert', 14, 'margin-top:2px'), bad[i]))),
|
||||||
|
D('display:flex;gap:8px;padding-left:' + (m ? 48 : 32) + 'px', D('flex:1;height:' + ih + 'px;display:flex;align-items:center;padding:0 10px;border:1px dashed var(--ui-border-accented);color:var(--ui-text-muted);font-size:' + fs + 'px', 'Add a choice'), B('Add', { icon: 'plus' })),
|
||||||
|
S('color:var(--ui-text-muted);font-size:12px;line-height:18px', 'Add 1 to 50 choices. Each one needs a different name. Removing a choice that entries use will be refused when you save.'))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- field list
|
||||||
|
const FRow = (f, i, o = {}) => {
|
||||||
|
const on = o.sel === i, err = !!f.err, kb = o.kb === i
|
||||||
|
const rm = on && D('display:flex;gap:2px;margin-left:auto', IB('chevron-up', { dis: i === 0 }), IB('chevron-down'), IB('trash-2'))
|
||||||
|
return D('display:flex;align-items:center;gap:' + (m ? 4 : 2) + 'px;min-height:' + (m ? 64 : 56) + 'px;padding:6px 8px 6px 4px;border:1px solid ' + (err ? 'var(--ui-error)' : kb ? 'var(--ui-primary)' : 'var(--ui-border)') + ';' + (kb ? 'outline:2px solid var(--ui-primary);outline-offset:-1px;box-shadow:var(--rc-shadow-pop);' : '') + 'background:' + (on ? 'var(--rc-sand)' : 'var(--ui-bg)') + ';color:' + (on ? 'var(--rc-on-sand)' : 'var(--ui-text-highlighted)') + ';opacity:' + (o.dim ? 0.6 : 1),
|
||||||
|
S('width:' + (m ? 36 : 24) + 'px;height:32px;display:flex;align-items:center;justify-content:center;flex-shrink:0;color:' + (on ? 'var(--rc-on-sand)' : 'var(--ui-text-muted)'), I('grip-vertical', 16)),
|
||||||
|
S('width:28px;height:28px;flex-shrink:0;display:flex;align-items:center;justify-content:center;background:' + (on ? 'var(--ui-bg)' : 'var(--ui-bg-elevated)') + ';color:var(--ui-text-highlighted)', I(FT[f.type][0], 16)),
|
||||||
|
D('flex:1;min-width:0;display:flex;flex-direction:column;margin-left:8px', S('font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap', f.label || 'Untitled field'), S(mono + 'font-size:12px;line-height:16px;color:' + (on ? 'var(--rc-on-sand)' : 'var(--ui-text-muted)') + ';overflow:hidden;text-overflow:ellipsis;white-space:nowrap', f.key || '–')),
|
||||||
|
!on && D('display:flex;gap:4px;flex-shrink:0;align-items:center', f.isNew && Bd('New', 'neu'), f.changed && Bd('Changed', 'chg'), o.cause && f.changed && Bd('Possible cause', 'warn', 'triangle-alert'), f.req && Bd('Required', 'req'), f.uniq && Bd('Unique', 'uniq'), err && I('circle-alert', 16, 'color:var(--ui-error)')),
|
||||||
|
on && D('display:flex;gap:4px;flex-shrink:0;align-items:center;margin-right:2px', f.isNew && Bd('New', 'neu'), f.changed && Bd('Changed', 'chg'), f.req && Bd('Required', 'req'), f.uniq && Bd('Unique', 'uniq')), on && !m && rm)
|
||||||
|
}
|
||||||
|
const ApiPanel = (fl, key, open) => {
|
||||||
|
const routes = [['GET', '/v1/content-type/' + key + '/entry'], ['POST', '/v1/content-type/' + key + '/entry'], ['GET', '/v1/content-type/' + key + '/entry/{id}'], ['PUT', '/v1/content-type/' + key + '/entry/{id}'], ['DELETE', '/v1/content-type/' + key + '/entry/{id}']]
|
||||||
|
const head = D('height:44px;flex-shrink:0;display:flex;align-items:center;gap:10px;padding:0 ' + (m ? 12 : 24) + 'px;border-top:1px solid var(--ui-border);background:var(--ui-bg);color:var(--ui-text-highlighted)', I(open ? 'chevron-down' : 'chevron-right', 14), S('font-weight:600', 'API preview'), !m && S(mono + 'font-size:12px;color:var(--ui-text-muted);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap', 'GET /v1/content-type/' + key + '/entry'), m && S('flex:1'), B('Copy JSON', { icon: 'copy', h: m ? 44 : 28, fs: 12, px: 10 }))
|
||||||
|
if (!open) return head
|
||||||
|
return D('flex-shrink:0;display:flex;flex-direction:column', head, D('display:grid;grid-template-columns:1fr 1.4fr;gap:16px;height:248px;padding:0 24px 16px;background:var(--ui-bg)', D('display:flex;flex-direction:column;gap:6px;min-width:0', S('color:var(--ui-text-muted);font-size:12px', 'Where entries of this type live'), D('display:flex;flex-direction:column;gap:2px;padding:10px 12px;background:var(--ui-bg-elevated);border:1px solid var(--ui-border)', ...routes.map(r => S(mono + 'font-size:12px;line-height:19px;color:var(--ui-text-highlighted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis', S('display:inline-block;width:56px;color:var(--ui-text-muted)', r[0]), r[1]))), S('color:var(--ui-text-muted);font-size:12px;line-height:18px;text-wrap:pretty', 'Updates as you edit. Fields you add or remove here appear after you save.')), D('display:flex;flex-direction:column;gap:6px;min-width:0', S('color:var(--ui-text-muted);font-size:12px', 'A sample entry. Values come from each field\'s type and default.'), S('display:block;flex:1;overflow:hidden;padding:10px 12px;background:var(--ui-bg-elevated);border:1px solid var(--ui-border);white-space:pre;' + mono + 'font-size:12px;line-height:19px;color:var(--ui-text-highlighted)', apiJson(fl, key)))))
|
||||||
|
}
|
||||||
|
const stChip = (kind, n) => { const M = { clean: ['check', 'Saved', 'var(--ui-text-muted)'], dirty: ['circle-dot', 'Unsaved changes', 'var(--ui-warning)'], saving: ['loader-circle', 'Saving…', 'var(--ui-text-muted)'], saved: ['check', 'Saved at 11:02', 'var(--ui-text-muted)'], fail: ['circle-alert', 'Save failed', 'var(--ui-error)'], offline: ['wifi-off', 'Waiting for connection', 'var(--ui-text-muted)'], errs: ['circle-alert', n + (n === 1 ? ' problem' : ' problems') + ' to fix', 'var(--ui-error)'] }[kind]; return S('display:flex;align-items:center;gap:6px;white-space:nowrap;font-size:12px;color:' + M[2], I(M[0], 14), M[1]) }
|
||||||
|
|
||||||
|
// ---------- entry controls
|
||||||
|
const hint = f => { const t = f.type; if (t === 'text' || t === 'richtext') return [f.min && 'At least ' + f.min + ' characters', f.max && 'Up to ' + f.max + ' characters', f.uniq && 'Each entry needs a different one'].filter(Boolean).join('. ') + (f.min || f.max || f.uniq ? '.' : ''); if (t === 'number') return f.min != null && f.max != null ? 'Between ' + f.min + ' and ' + f.max + '.' : f.min != null ? 'At least ' + f.min + '.' : f.max != null ? 'At most ' + f.max + '.' : ''; if (t === 'date') return [f.min && 'From ' + f.min, f.max && 'until ' + f.max].filter(Boolean).join(' ') ; return t === 'select' ? '' : '' }
|
||||||
|
const MdBox = (o, val) => D('display:flex;flex-direction:column;border:1px solid ' + (o.err ? 'var(--ui-error)' : o.focus ? 'var(--ui-primary)' : 'var(--ui-border-accented)') + ';background:' + (o.dis ? 'var(--ui-bg-elevated)' : 'var(--ui-bg)') + (o.err ? ';box-shadow:inset 0 0 0 1px var(--ui-error)' : ''),
|
||||||
|
D('display:flex;align-items:center;border-bottom:1px solid var(--ui-border);padding:0 6px;gap:2px;overflow:hidden', D('display:flex;gap:14px;padding:0 6px;margin-right:8px', ...[['write', 'Write'], ['preview', 'Preview']].map(([k, t]) => S('height:' + (m ? 44 : 32) + 'px;display:flex;align-items:center;font-weight:' + (o.tab === k || (!o.tab && k === 'write') ? 500 : 400) + ';color:' + (o.tab === k || (!o.tab && k === 'write') ? 'var(--ui-text-highlighted)' : 'var(--ui-text-toned)') + ';border-bottom:2px solid ' + (o.tab === k || (!o.tab && k === 'write') ? 'var(--ui-primary)' : 'transparent'), t))), ...(o.tab === 'preview' ? [] : ['bold', 'italic', 'heading', 'link', 'list'].map(ic => S('width:' + (m ? 40 : 28) + 'px;height:' + (m ? 40 : 28) + 'px;display:flex;align-items:center;justify-content:center;color:var(--ui-text-toned)', I(ic, 16))))),
|
||||||
|
o.tab === 'preview' ? D('padding:10px 12px;min-height:' + (m ? 120 : 96) + 'px;display:flex;flex-direction:column;gap:8px;font-size:' + fs + 'px', S('', 'Two days of hands-on work with ', S('font-weight:600', 'lime putty'), ' and old stone.'), D('display:flex;flex-direction:column;gap:2px;padding-left:18px', S('display:list-item;list-style:square', 'Bring work clothes'), S('display:list-item;list-style:square', 'Lunch is included'))) : S('display:block;white-space:pre-wrap;padding:8px 10px;min-height:' + (m ? 120 : 96) + 'px;font-size:' + (m ? 14 : 12) + 'px;line-height:' + (m ? 22 : 19) + 'px;' + mono + 'color:' + (val ? 'var(--ui-text-highlighted)' : 'var(--ui-text-muted)'), val || 'Write in Markdown'))
|
||||||
|
const Cal = () => { const cells = [...Array(6).fill(''), ...Array.from({ length: 30 }, (_, i) => i + 1)]; return D('width:' + (m ? 280 : 252) + 'px;padding:8px;background:var(--ui-bg);border:1px solid var(--ui-border);box-shadow:var(--rc-shadow-pop)', D('display:flex;align-items:center;justify-content:space-between;height:32px', IB('chevron-left'), S('font-weight:600', 'November 2026'), IB('chevron-right')), D('display:grid;grid-template-columns:repeat(7,1fr)', ...['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'].map(d => S('height:28px;display:flex;align-items:center;justify-content:center;font-size:12px;color:var(--ui-text-muted)', d)), ...cells.map(c => S('height:' + (m ? 34 : 30) + 'px;display:flex;align-items:center;justify-content:center;background:' + (c === 21 ? 'var(--ui-primary)' : 'transparent') + ';color:' + (c === 21 ? 'var(--ui-bg)' : 'var(--ui-text-highlighted)'), c))) ) }
|
||||||
|
const Ctl = (f, o = {}) => {
|
||||||
|
const t = f.type, v = o.v == null ? '' : String(o.v)
|
||||||
|
const lab = D('display:flex;justify-content:space-between;gap:8px', S('font-weight:500;color:var(--ui-text-highlighted)', f.label, f.req && ' *'), o.count && S(mono + 'font-size:12px;color:' + (o.countErr ? 'var(--ui-error)' : 'var(--ui-text-muted)'), o.count), o.def && Bd('Default', 'neu'))
|
||||||
|
const bd = o.err ? 'var(--ui-error)' : o.focus ? 'var(--ui-primary)' : 'var(--ui-border-accented)'
|
||||||
|
const box = (...inner) => D('display:flex;align-items:center;min-height:' + ih + 'px;border:1px solid ' + bd + ';background:' + (o.dis ? 'var(--ui-bg-elevated)' : 'var(--ui-bg)') + ';font-size:' + fs + 'px;' + (o.err ? 'box-shadow:inset 0 0 0 1px var(--ui-error);' : o.focus ? 'box-shadow:inset 0 0 0 1px var(--ui-primary);' : ''), ...inner)
|
||||||
|
const txt = (val, ph) => S('flex:1;min-width:0;padding:0 10px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:' + (val ? 'var(--ui-text-highlighted)' : 'var(--ui-text-muted)'), val || ph || '')
|
||||||
|
let ctl
|
||||||
|
if (t === 'boolean') ctl = Sw(v === 'true', v === 'true' ? 'On' : 'Off', null, o.dis)
|
||||||
|
else if (t === 'richtext') ctl = MdBox(o, v)
|
||||||
|
else if (t === 'number') ctl = box(txt(v, 'Enter a number'), D('display:flex;border-left:1px solid var(--ui-border);height:' + ih + 'px;color:var(--ui-text-muted)', S('width:' + (m ? 40 : 28) + 'px;display:flex;align-items:center;justify-content:center', I('minus', 14)), S('width:' + (m ? 40 : 28) + 'px;display:flex;align-items:center;justify-content:center;border-left:1px solid var(--ui-border)', I('plus', 14))))
|
||||||
|
else if (t === 'date') ctl = box(txt(v, 'Pick a date'), I('calendar', 14, 'margin-right:10px;color:var(--ui-text-muted)'))
|
||||||
|
else if (t === 'select') ctl = box(txt(v, 'Choose an option'), I('chevron-down', 14, 'margin-right:10px;color:var(--ui-text-muted)'))
|
||||||
|
else ctl = box(txt(v, ''))
|
||||||
|
const hp = o.help === undefined ? hint(f) : o.help
|
||||||
|
return D('display:flex;flex-direction:column;gap:6px;min-width:0;position:relative', lab, ctl, o.err && S('display:flex;gap:6px;align-items:flex-start;color:var(--ui-error);font-size:12px;line-height:18px', I('circle-alert', 14, 'margin-top:2px'), o.err), !o.err && hp && S('color:var(--ui-text-muted);font-size:12px;line-height:18px;text-wrap:pretty', hp), o.extra)
|
||||||
|
}
|
||||||
|
const Cell = (f, v) => {
|
||||||
|
if (v === undefined || v === null || v === '') return S('color:var(--ui-text-muted)', '–')
|
||||||
|
if (f.type === 'boolean') return S('display:flex;align-items:center;gap:6px', I(v ? 'check' : 'minus', 14, 'color:var(--ui-text-muted)'), v ? 'Yes' : 'No')
|
||||||
|
if (f.type === 'select') return S('height:20px;padding:0 6px;display:inline-flex;align-items:center;border:1px solid var(--ui-border-accented);color:var(--ui-text-toned);font-size:12px;justify-self:start', v)
|
||||||
|
if (f.type === 'number') return S('justify-self:end;font-variant-numeric:tabular-nums', v)
|
||||||
|
return S('overflow:hidden;text-overflow:ellipsis;white-space:nowrap;' + (f.type === 'richtext' ? 'color:var(--ui-text-toned)' : ''), v)
|
||||||
|
}
|
||||||
|
const mt = (k) => k === 'true' || k === 'false'
|
||||||
|
|
||||||
|
// ---------- pages
|
||||||
|
let main = null; const ov = []; let bare = false, bw = '1440px', bhh = '900px'
|
||||||
|
const v = s.v, k = s.k
|
||||||
|
|
||||||
|
// 1 schema list
|
||||||
|
const typesTable = (custom) => {
|
||||||
|
const cols = '24px minmax(0,1.4fr) minmax(0,1fr) 90px 150px 24px'
|
||||||
|
const hdr = D('display:grid;grid-template-columns:' + cols + ';gap:12px;height:36px;align-items:center;padding:0 12px;background:var(--ui-bg-elevated);color:var(--ui-text-muted);font-weight:500', S('', ''), S('', 'Name'), S('', 'Key'), S('', 'Fields'), S('', 'Last updated'), S('', ''))
|
||||||
|
const r = (name, key, n, up, built) => D('display:grid;grid-template-columns:' + cols + ';gap:12px;height:48px;align-items:center;padding:0 12px;border-top:1px solid var(--ui-border)', I(built ? 'lock' : 'file-stack', 16, 'color:var(--ui-text-muted)'), D('display:flex;align-items:center;gap:8px;min-width:0', S('font-weight:500;color:var(--ui-text-highlighted)', name), built && Bd('Built-in', 'built', 'lock')), S(mono + 'font-size:12px;color:var(--ui-text-muted)', key), S('', String(n)), S('color:var(--ui-text-muted)', up || 'Managed by RenewCMS'), I('chevron-right', 14, 'color:var(--ui-text-muted)'))
|
||||||
|
const mr = (name, key, n, up, built) => D('display:flex;align-items:center;gap:12px;padding:12px 8px 12px 16px;border-top:1px solid var(--ui-border);min-height:68px', D('flex:1;min-width:0;display:flex;flex-direction:column;gap:4px', D('display:flex;align-items:center;gap:8px;flex-wrap:wrap', S('font-weight:500;font-size:15px;line-height:21px;color:var(--ui-text-highlighted)', name), built && Bd('Built-in', 'built', 'lock')), S('font-size:12px;color:var(--ui-text-muted)', S(mono, key), ' · ' + n + ' fields' + (up ? ' · Updated ' + up : ''))), S('width:36px;height:44px;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted)', I('chevron-right', 18)))
|
||||||
|
const note = D('display:flex;align-items:center;gap:8px;padding:10px 12px;border-top:1px solid var(--ui-border);background:var(--ui-bg-muted);color:var(--ui-text-toned);font-size:12px;line-height:18px', I('lock', 14, 'flex-shrink:0'), S('', 'Built-in types are managed by RenewCMS. You can read their fields, but not change them.'))
|
||||||
|
const rr = m ? mr : r
|
||||||
|
return D('background:var(--ui-bg);border:1px solid var(--ui-border)', !m && hdr, ...(custom ? CUSTOM.map(x => rr(x[0], x[1], x[2], x[3])) : []), note, ...BUILTIN.map(x => rr(x[0], x[1], x[2], '', true)))
|
||||||
|
}
|
||||||
|
if (v === 'types') {
|
||||||
|
Object.assign(shell, { nav: 'schema', crumbs: ['Schema'], mTitle: 'Schema' })
|
||||||
|
const newBtn = B('New content type', { k: 'primary', icon: 'plus', px: 14, dis: k === 'loading' })
|
||||||
|
const impExp = s.later ? [B('Import', { icon: 'upload' }), B('Export', { icon: 'download' })] : []
|
||||||
|
if (k === 'forbidden') { shell.role = 'user'; shell.crumbs = ['Schema']; main = Center('lock', 'You don\'t have access to Schema', 'Only owners and admins can change content types. You can still add and edit entries from the Content menu.', B('Go to Home', { k: 'primary', px: 16 })) }
|
||||||
|
else if (k === 'builtin') {
|
||||||
|
shell.crumbs = ['Schema', 'Articles']; shell.mTitle = 'Articles'; shell.mLead = 'arrow-left'
|
||||||
|
const AF = [['Title', 'title', 'text', 'Required · up to 200 characters'], ['Slug', 'slug', 'text', 'Required · unique'], ['Body', 'body', 'richtext', 'Up to 10,000 characters'], ['Online', 'online', 'boolean', 'Off by default'], ['Published on', 'published_on', 'date', '']]
|
||||||
|
const cols = '28px minmax(0,1fr) minmax(0,1fr) 120px minmax(0,1.2fr)'
|
||||||
|
main = Page(pad, D('display:flex;align-items:center;gap:12px;flex-shrink:0', I('lock', 18, 'color:var(--ui-text-muted)'), S('font:600 20px/28px \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted)', 'Articles'), Bd('Built-in', 'built', 'lock')),
|
||||||
|
Alert('info', 'info', 'Managed by RenewCMS', 'Articles is a built-in type. You can read its fields here, but you can\'t edit it, delete it or add fields.'),
|
||||||
|
m ? D('background:var(--ui-bg);border:1px solid var(--ui-border)', ...AF.map(f => D('display:flex;gap:12px;align-items:center;padding:12px 16px;border-top:1px solid var(--ui-border);min-height:64px', S('width:28px;height:28px;display:flex;align-items:center;justify-content:center;background:var(--ui-bg-elevated)', I(FT[f[2]][0], 16)), D('flex:1;min-width:0;display:flex;flex-direction:column', S('font-weight:500', f[0]), S('font-size:12px;color:var(--ui-text-muted)', S(mono, f[1]), f[3] ? ' · ' + f[3] : ''))))) :
|
||||||
|
D('background:var(--ui-bg);border:1px solid var(--ui-border)', D('display:grid;grid-template-columns:' + cols + ';gap:12px;height:36px;align-items:center;padding:0 12px;background:var(--ui-bg-elevated);color:var(--ui-text-muted);font-weight:500', S('', ''), S('', 'Label'), S('', 'Key'), S('', 'Type'), S('', 'Rules')), ...AF.map(f => D('display:grid;grid-template-columns:' + cols + ';gap:12px;height:48px;align-items:center;padding:0 12px;border-top:1px solid var(--ui-border)', I(FT[f[2]][0], 16, 'color:var(--ui-text-toned)'), S('font-weight:500', f[0]), S(mono + 'font-size:12px;color:var(--ui-text-muted)', f[1]), S('', FT[f[2]][1]), S('color:var(--ui-text-muted)', f[3] || '–')))), S('color:var(--ui-text-muted);font-size:12px', 'Entries of built-in types are managed from their own screens, like Articles and Authors.'))
|
||||||
|
} else {
|
||||||
|
const body = []
|
||||||
|
if (k === 'loading') body.push(D('background:var(--ui-bg);border:1px solid var(--ui-border)', ...Array.from({ length: m ? 6 : 8 }, (_, i) => D('height:' + (m ? 68 : 48) + 'px;display:flex;align-items:center;gap:16px;padding:0 16px;border-top:' + (i ? '1px solid var(--ui-border)' : 'none'), Skel('18px', 16), Skel(i % 2 ? '28%' : '20%'), Skel('14%', 12), !m && Skel('40px', 12), !m && Skel('90px', 12)))))
|
||||||
|
else if (k === 'error') body.push(Alert('error', 'circle-x', 'Couldn\'t load content types', 'GET /v1/content-type failed. Check your connection and try again.', 'Try again'))
|
||||||
|
else if (k === 'empty') body.push(Empty('shapes', 'No content types yet', 'Create a content type to manage your own kind of content, like events or recipes. The built-in types stay as they are.', 'New content type'), typesTable(false))
|
||||||
|
else body.push(typesTable(true), S('color:var(--ui-text-muted)', CUSTOM.length + ' content types · ' + BUILTIN.length + ' built-in'))
|
||||||
|
main = Page(pad, Title('Schema', 'Content types and the fields they are made of.', ...impExp, s.later && Later(), newBtn), ...body)
|
||||||
|
if (m && k !== 'loading' && k !== 'error' && k !== 'empty') ov.push(Fab('New content type'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2 builder
|
||||||
|
const bld = () => {
|
||||||
|
let fl = EV, name = 'Events', key = 'events', sel = 0, st = 'clean', isNew = false, api = k === 'api', kb = -1, tErr = {}, banner = null, removed = null, cause = false, tip = false, toast = null, info = true, nErr = 0
|
||||||
|
if (v === 'review') { const rk = k; if (rk === 'confirm' || rk === 'fallback') { fl = RC; name = 'Recipes'; key = 'recipes'; info = false } else if (rk === 'blocked') { sel = 0 } else { fl = DIRTY; sel = 4; st = 'dirty'; removed = EV[6] } }
|
||||||
|
if (v === 'rel') { }
|
||||||
|
if (v === 'builder') {
|
||||||
|
if (k === 'locked') { sel = 1; tip = true }
|
||||||
|
if (['dirty', 'saving', 'saved', 'fail', 'incompat', 'offline'].includes(k)) { fl = DIRTY; sel = 4; st = k === 'incompat' ? 'dirty' : k; removed = EV[6] }
|
||||||
|
if (k === 'saved') { removed = null; st = 'saved'; fl = DIRTY.map(f => ({ ...f, isNew: false, changed: false, saved: true })); toast = Toast('success', 'circle-check', 'Content type saved', 'Events now has 8 fields.') }
|
||||||
|
if (k === 'fail') banner = Alert('error', 'circle-x', 'Couldn\'t save the content type', 'Something went wrong on our side. Your changes are still here. Try again in a moment.', 'Try again')
|
||||||
|
if (k === 'incompat') { banner = Alert('error', 'circle-x', 'Nothing was changed', 'Some entries don\'t fit the new fields, so the whole change was refused. RenewCMS can\'t tell which entries. The changes that can cause this are marked.', 'Review changes'); cause = true }
|
||||||
|
if (k === 'offline') { }
|
||||||
|
if (k === 'ferr') { fl = EV.map((f, i) => i === 2 ? { ...f, label: '', err: { label: 'Give the field a label.' } } : i === 4 ? { ...f, min: 6000, err: { min: 'Lowest value can\'t be above the highest value.' } } : i === 5 ? { ...f, opts: ['Talk', 'Workshop', '', 'Talk'], err: { choices: { 2: 'Choices can\'t be blank.', 3: 'Two choices are the same.' } } } : f); sel = 4; st = 'errs'; nErr = 3 }
|
||||||
|
if (k === 'choices') { fl = EV.map((f, i) => i === 5 ? { ...f, opts: ['Talk', 'Workshop', 'Meetup', '', 'Talk'], err: { choices: { 3: 'Choices can\'t be blank.', 4: 'Two choices are the same.' } } } : f); sel = 5; st = 'errs'; nErr = 1 }
|
||||||
|
if (k === 'kb') { fl = [EV[0], EV[2], EV[1], ...EV.slice(3)]; sel = 1; kb = 1; st = 'dirty' }
|
||||||
|
if (k === 'new') { fl = [{ key: 'title', label: 'Title', type: 'text', isNew: true }]; name = ''; key = ''; isNew = true; sel = 0; st = 'clean'; info = false }
|
||||||
|
if (k === 'terr') { fl = []; name = ''; key = 'e'; isNew = true; sel = -1; st = 'errs'; nErr = 3; info = false; tErr = { name: 'Give the content type a name.', key: 'Use 2 to 40 lowercase letters, digits or hyphens.', fields: 'A content type needs at least one field.' } }
|
||||||
|
if (k === 'taken') { fl = [{ key: 'title', label: 'Title', type: 'text', req: true, isNew: true }, { key: 'starts_on', label: 'Starts on', type: 'date', isNew: true }]; name = 'Events'; key = 'events'; isNew = true; sel = 0; st = 'errs'; nErr = 1; info = false; tErr = { key: 'The key "events" is already used by another content type. Choose a different key.' } }
|
||||||
|
}
|
||||||
|
const f = fl[sel], dim = st === 'saving'
|
||||||
|
const nameF = F('Name', name, { req: true, err: tErr.name, ph: !name, phText: 'For example, Events', dis: dim })
|
||||||
|
const keyF = F('Key', key, { mono: true, lock: !isNew, auto: isNew && !!key && !tErr.key, err: tErr.key, ph: !key, phText: 'events', dis: dim, help: tErr.key ? '' : isNew ? 'Used in URLs and the API. Choose it carefully: it can\'t change after you save.' : null })
|
||||||
|
const save = D('display:flex;align-items:center;gap:' + (m ? 8 : 12) + 'px;flex-shrink:0', !m && stChip(st, nErr), !m && B('Discard', { dis: st === 'clean' || st === 'saving' || st === 'saved' || (st === 'errs' && isNew && k === 'new') }), !m && B(st === 'saving' ? 'Saving…' : 'Save', { k: 'primary', icon: st === 'saving' ? 'loader-circle' : null, px: 16, dis: st === 'clean' || st === 'saved' || st === 'saving' || st === 'errs' || st === 'offline' }))
|
||||||
|
const top = m ? D('display:flex;flex-direction:column;gap:12px;padding:16px;flex-shrink:0;background:var(--ui-bg);border-bottom:1px solid var(--ui-border)', nameF, keyF) : D('display:flex;align-items:' + (tErr.name || tErr.key ? 'flex-start' : 'flex-end') + ';gap:16px;padding:16px 24px;flex-shrink:0;background:var(--ui-bg);border-bottom:1px solid var(--ui-border);position:relative', D('width:320px', nameF), D('width:' + (tErr.key ? 360 : 240) + 'px', keyF), D('flex:1'), D('display:flex;align-items:center;gap:12px;height:32px;margin-top:' + (tErr.name || tErr.key ? 22 : 0) + 'px', save, IB('ellipsis')), tip && D('position:absolute;left:352px;top:84px;z-index:30', Tip('The key is used in URLs and the API, so it can\'t change after the type is created.')))
|
||||||
|
const infoBar = info && !isNew && D('flex-shrink:0;display:flex;align-items:center;gap:8px;padding:8px ' + (m ? 16 : 24) + 'px;background:var(--rc-info-soft);color:var(--ui-info);font-size:12px;line-height:18px', I('info', 14, 'flex-shrink:0'), S('color:var(--ui-text-toned)', '37 entries use this content type. Changes are checked against them when you save.'))
|
||||||
|
const listHdr = D('display:flex;align-items:center;justify-content:space-between;height:28px', S('font-weight:600;color:var(--ui-text-highlighted)', 'Fields'), S(mono + 'font-size:12px;color:var(--ui-text-muted)', fl.length + ' of 50'))
|
||||||
|
const live = kb >= 0 && D('padding:8px 10px;background:var(--ui-bg-inverted);color:var(--ui-text-inverted);font-size:12px;line-height:18px', S('font-weight:600', 'Moving Ends on. Position 2 of 7.'), ' Press Up or Down to move, Space to drop, Esc to cancel.')
|
||||||
|
const rows = fl.length ? fl.map((x, i) => FRow(x, i, { sel: m ? -1 : sel, kb, dim, cause })) : [D('display:flex;flex-direction:column;align-items:center;gap:8px;text-align:center;padding:32px 16px;border:1px dashed ' + (tErr.fields ? 'var(--ui-error)' : 'var(--ui-border-accented)'), I('rows-3', 20, 'color:var(--ui-text-muted)'), S('font-weight:600', 'No fields yet'), S('color:' + (tErr.fields ? 'var(--ui-error)' : 'var(--ui-text-muted)'), tErr.fields || 'Add the first field.'))]
|
||||||
|
const rem = removed && D('display:flex;align-items:center;gap:8px;min-height:44px;padding:0 12px;border:1px dashed var(--ui-border-accented);color:var(--ui-text-toned)', I('minus', 14), S('flex:1', removed.label + ' will be removed'), S('text-decoration:underline;text-underline-offset:3px;font-weight:500;color:var(--ui-text-highlighted)', 'Undo'))
|
||||||
|
const addB = B('Add field', { icon: 'plus', full: false, dis: dim })
|
||||||
|
const apiRow = m && D('display:flex;align-items:center;gap:10px;min-height:44px;padding:0 12px;border:1px solid var(--ui-border);background:var(--ui-bg);color:var(--ui-text-highlighted)', I('braces', 16), S('flex:1;font-weight:500', 'API preview'), I('chevron-right', 14, 'color:var(--ui-text-muted)'))
|
||||||
|
const list = D('display:flex;flex-direction:column;gap:8px;' + (m ? 'padding:16px;flex:1;min-height:0;overflow:hidden' : 'width:380px;flex-shrink:0;border-right:1px solid var(--ui-border);padding:16px;overflow:hidden;background:var(--ui-bg-muted)'), listHdr, live, ...rows, rem, addB, apiRow)
|
||||||
|
const inspPane = !m && D('flex:1;min-width:0;overflow:hidden;padding:16px 24px;background:var(--ui-bg);display:flex;flex-direction:column;gap:14px', f ? D('display:flex;align-items:center;gap:8px', S('font:600 15px/24px \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted)', 'Field: ' + (f.label || 'Untitled field')), f.isNew && Bd('New', 'neu')) : S('font:600 15px/24px \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted)', 'Field settings'), f ? D('opacity:' + (dim ? 0.6 : 1), Insp(f)) : S('color:var(--ui-text-muted)', 'Add a field, then choose its type and rules here.'))
|
||||||
|
const bar = m && D('flex-shrink:0;display:flex;align-items:center;gap:8px;padding:12px 16px;border-top:1px solid var(--ui-border);background:var(--ui-bg)', stChip(st, nErr), D('flex:1'), B('Discard', { dis: st === 'clean' || st === 'saved' || st === 'saving' || k === 'new' }), B(st === 'saving' ? 'Saving…' : 'Save', { k: 'primary', icon: st === 'saving' ? 'loader-circle' : null, px: 16, dis: ['clean', 'saved', 'saving', 'errs', 'offline'].includes(st) }))
|
||||||
|
shell.nav = 'schema'; shell.crumbs = ['Schema', isNew ? 'New content type' : name]; shell.mTitle = isNew ? 'New content type' : name; shell.mLead = 'arrow-left'; shell.mAction = 'ellipsis'
|
||||||
|
shell.offline = false
|
||||||
|
main = D('flex:1;min-width:0;min-height:0;display:flex;flex-direction:column;position:relative', st === 'offline' && Offline(), top, infoBar, banner && D('padding:12px ' + (m ? 16 : 24) + 'px 0', banner), D('flex:1;min-height:0;display:flex;' + (m ? 'flex-direction:column' : ''), list, inspPane), m ? bar : ApiPanel(fl, key || 'events', api))
|
||||||
|
if (m && f && (s.sheet || (v === 'builder' && ['edit-sheet', 'choices'].includes(k)))) ov.push(Sheet({ title: 'Field: ' + (f.label || 'Untitled field') }, Insp(f), [B('Done', { k: 'primary', full: true })]))
|
||||||
|
if (m && api) ov.push(Sheet({ title: 'API preview' }, [S('color:var(--ui-text-muted);font-size:12px', 'GET /v1/content-type/' + key + '/entry'), S('display:block;flex:1;overflow:hidden;padding:10px 12px;background:var(--ui-bg-elevated);border:1px solid var(--ui-border);white-space:pre;' + mono + 'font-size:12px;line-height:19px;color:var(--ui-text-highlighted)', apiJson(fl, key))], [B('Copy JSON', { k: 'primary', full: true, icon: 'copy' })]))
|
||||||
|
if (toast) ov.push(toast)
|
||||||
|
}
|
||||||
|
if (v === 'builder') { bld() }
|
||||||
|
|
||||||
|
// 3 review
|
||||||
|
const chg = (icon, tone, title, text, flag, extra) => D('display:flex;gap:10px;padding:10px 12px;border:1px solid var(--ui-border);background:' + (flag ? 'var(--rc-warning-soft)' : 'var(--ui-bg)'), S('margin-top:2px;display:flex;color:' + (flag ? 'var(--ui-warning)' : 'var(--ui-text-toned)'), I(icon)), D('flex:1;min-width:0;display:flex;flex-direction:column;gap:2px', D('display:flex;align-items:center;gap:8px;flex-wrap:wrap', S('font-weight:600;color:var(--ui-text-highlighted)', title), flag && Bd(flag, 'warn', 'triangle-alert'), extra), S('color:var(--ui-text-toned);text-wrap:pretty', text)))
|
||||||
|
if (v === 'review') {
|
||||||
|
bld()
|
||||||
|
const sum = D('display:flex;flex-direction:column;gap:2px', S('font-weight:600;font-size:15px;color:var(--ui-text-highlighted)', 'Add 2 fields, change 1, remove 1'), S('color:var(--ui-text-muted)', 'Events has 37 entries. Each one is checked against the new fields when you apply.'))
|
||||||
|
const lat = t => k === 'later' ? Later(t) : null
|
||||||
|
const list = [chg('plus', 'n', 'Add Speaker', 'Text, optional.', null, lat('Affects 0 entries')), chg('plus', 'n', 'Add Room', 'Choice, required, default "Main hall". Existing entries get the default, so they still fit.', null, lat('Affects 37 entries')), chg('pencil', 'n', 'Change Capacity', 'Highest value goes from 5000 to 2000.', 'Entries may not fit', lat('Affects 3 entries')), chg('minus', 'n', 'Remove Description', 'Entries stop returning its values.', 'Removes data', lat('Affects 31 entries'))]
|
||||||
|
if (k === 'review' || k === 'later') ov.push(Shell({ title: 'Review changes to Events', desc: 'Apply them to save the content type.', w: k === 'later' ? 640 : 560, top: 60, cancel: 'Back to editing', confirm: 'Apply changes', later: false }, [sum, D('display:flex;flex-direction:column;gap:8px', ...list), k === 'later' ? D('display:flex;align-items:center;gap:8px', B('View full diff', { icon: 'git-compare', h: m ? 44 : 28, fs: 12, px: 10 }), Later('Per-change counts and diff come later')) : S('color:var(--ui-text-muted);font-size:12px;line-height:18px', 'Flagged changes can be refused if existing entries don\'t fit. If that happens, nothing is changed.')]))
|
||||||
|
if (k === 'applying') ov.push(Shell({ title: 'Review changes to Events', desc: 'Apply them to save the content type.', w: 560, top: 60, cancel: 'Back to editing', confirm: 'Applying…', loading: true, cdis: true }, [sum, D('display:flex;flex-direction:column;gap:8px', ...list)]))
|
||||||
|
if (k === 'refused') ov.push(Shell({ title: 'Nothing was changed', desc: 'RenewCMS refused the changes because some entries don\'t fit the new fields. It doesn\'t say which entries.', w: 560, top: 60, close: true, cancel: 'Discard changes', confirm: 'Back to editing' }, [Alert('error', 'circle-x', 'Your content type is as it was', 'No field was added, changed or removed.'), S('font-weight:600;color:var(--ui-text-highlighted)', 'The change that can cause this in your edit'), chg('pencil', 'n', 'Change Capacity', 'Highest value goes from 5000 to 2000. An entry may have a higher value.', 'Possible cause'), S('color:var(--ui-text-muted);font-size:12px;line-height:18px;text-wrap:pretty', 'This kind of change is refused when an entry is over the new limit, a new required field has no default, a field becomes unique while entries share a value, or a removed choice is still in use.')]))
|
||||||
|
if (k === 'blocked') ov.push(Shell({ title: 'You can\'t delete Events yet', desc: 'A content type can only be deleted when it has no entries.', close: true, cancel: 'Close', confirm: 'View entries' }, [Alert('warning', 'triangle-alert', 'Events has 37 entries', 'Delete all of its entries first, then delete the content type.')]))
|
||||||
|
if (k === 'confirm') ov.push(Shell({ title: 'Delete Recipes?', desc: 'Recipes has no entries. Its fields and its API routes will be deleted. This can\'t be undone.', close: true, cancel: 'Cancel', confirm: 'Delete content type', tone: 'danger' }, [S('color:var(--ui-text-toned)', 'Key: ', S(mono + 'font-size:12px', 'recipes'))]))
|
||||||
|
if (k === 'fallback') ov.push(Shell({ title: 'Delete Recipes?', desc: 'Recipes has no entries. Its fields and its API routes will be deleted. This can\'t be undone.', close: true, cancel: 'Close', confirm: 'View entries' }, [Alert('error', 'circle-x', 'Recipes now has entries', 'Someone added an entry after this page loaded, so nothing was deleted. Delete the entries first, then try again.')]))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4 gallery
|
||||||
|
const GF = { text: EV[0], richtext: { key: 'description', label: 'Description', type: 'richtext', max: 5000 }, number: EV[4], boolean: EV[3], date: { key: 'starts_on', label: 'Starts on', type: 'date', req: true, max: '31 Dec 2027' }, select: { ...EV[5], req: false } }
|
||||||
|
if (v === 'gallery') {
|
||||||
|
bare = true; if (m) { bw = '390px'; bhh = '844px' }
|
||||||
|
const f = { ...GF[k], saved: false }, t = k, L = f.label
|
||||||
|
const ERR = { text: [['required', 422, 'Fill in ' + L + '.'], ['type', 422, L + ' must be text.'], ['min', 422, L + ' needs at least 3 characters.'], ['max', 422, L + ' can be up to 80 characters.'], ['options', '', 'Not used for text.'], ['unique', 409, 'Another entry already uses this ' + L + '. Enter a different one.']], richtext: [['required', 422, 'Fill in ' + L + '.'], ['type', 422, L + ' must be text.'], ['min', 422, L + ' needs at least 10 characters.'], ['max', 422, L + ' can be up to 5000 characters.'], ['options', '', 'Not used for rich text.'], ['unique', '', 'Not used for rich text.']], number: [['required', 422, 'Fill in ' + L + '.'], ['type', 422, L + ' must be a number.'], ['min', 422, L + ' must be at least 1.'], ['max', 422, L + ' must be at most 5000.'], ['options', '', 'Not used for numbers.'], ['unique', 409, 'Another entry already uses this ' + L + '.']], boolean: [['required', 422, 'Choose yes or no for ' + L + '.'], ['type', 422, L + ' must be yes or no.'], ['min', '', 'Not used for yes or no.'], ['max', '', 'Not used for yes or no.'], ['options', '', 'Not used for yes or no.'], ['unique', '', 'Not used for yes or no.']], date: [['required', 422, 'Pick a date for ' + L + '.'], ['type', 422, L + ' must be a date, like 2026-11-21.'], ['min', 422, L + ' can\'t be before 1 Jan 2026.'], ['max', 422, L + ' can\'t be after 31 Dec 2027.'], ['options', '', 'Not used for dates.'], ['unique', 409, 'Another entry already uses this date for ' + L + '.']], select: [['required', 422, 'Choose an option for ' + L + '.'], ['type', 422, L + ' must be one of the choices.'], ['min', '', 'Not used for choices.'], ['max', '', 'Not used for choices.'], ['options', 422, 'Choose one of: Talk, Workshop or Meetup.'], ['unique', 409, 'Another entry already uses this choice for ' + L + '.']] }[t]
|
||||||
|
const SAMPLE = { text: ['Lime plaster workshop', ''], richtext: [mdSample, ''], number: ['18', ''], boolean: ['true', 'false'], date: ['21 Nov 2026', ''], select: ['Workshop', ''] }[t]
|
||||||
|
const sec = (title, ...c) => D('display:flex;flex-direction:column;gap:10px;min-width:0', S('font:600 15px/24px \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted)', title), ...c)
|
||||||
|
const card = (...c) => D('background:var(--ui-bg);border:1px solid var(--ui-border);padding:16px;display:flex;flex-direction:column;gap:14px', ...c)
|
||||||
|
const specCtl = { text: Ctl(f, { v: 'Lime plaster workshop, two days of work with the whole team on site', count: '74 / 80', focus: true }), richtext: Ctl({ ...f, max: 5000 }, { v: mdSample, tab: 'write', count: '86 / 5000', help: 'Markdown. Switch to Preview to check the result.' }), number: Ctl(f, { v: '18', focus: true }), boolean: Ctl(f, { v: 'true' }), date: Ctl(f, { v: '21 Nov 2026', focus: true, extra: D('position:absolute;left:0;top:68px;z-index:3', Cal()) }), select: Ctl(f, { v: 'Workshop', focus: true, extra: D('position:absolute;left:0;top:66px;right:0;z-index:3;border:1px solid var(--ui-border);background:var(--ui-bg);box-shadow:var(--rc-shadow-pop);padding:4px', ...['Talk', 'Workshop', 'Meetup'].map(o_ => D('height:' + (m ? 44 : 32) + 'px;padding:0 8px;display:flex;align-items:center;justify-content:space-between;background:' + (o_ === 'Workshop' ? 'var(--ui-bg-elevated)' : 'transparent'), o_, o_ === 'Workshop' && I('check', 14)))) }) }[t]
|
||||||
|
const spacer = { date: 'height:236px', select: 'height:120px', richtext: '', text: '', number: '', boolean: '' }[t]
|
||||||
|
const stateLbl = { text: 'Typing, close to the limit', richtext: 'Write tab, with counter', number: 'Focused', boolean: 'On', date: 'Date picker open', select: 'Choices open' }[t]
|
||||||
|
const ctlCol = sec('Entry form control', card(S('font-size:12px;color:var(--ui-text-muted)', stateLbl), D('', specCtl, spacer && D(spacer)), D('border-top:1px solid var(--ui-border)'), S('font-size:12px;color:var(--ui-text-muted)', t === 'richtext' ? 'Preview tab' : t === 'boolean' ? 'Off, and not set' : 'Empty'), t === 'richtext' ? Ctl(f, { v: mdSample, tab: 'preview', help: '' }) : t === 'boolean' ? Ctl(f, { v: 'false' }) : Ctl(f, { v: '' })))
|
||||||
|
const cell = (val, lab) => D('display:grid;grid-template-columns:' + (m ? '90px' : '110px') + ' minmax(0,1fr);gap:12px;align-items:center;min-height:40px;padding:0 12px;border-top:1px solid var(--ui-border)', S('font-size:12px;color:var(--ui-text-muted)', lab), Cell(f, val))
|
||||||
|
const cellVals = { text: ['Lime plaster workshop', ''], richtext: ['Two days of hands-on work with lime putty and old stone.', ''], number: [18, null], boolean: [true, null], date: ['21 Nov 2026', ''], select: ['Workshop', ''] }[t]
|
||||||
|
const listCol = sec('List cell', D('background:var(--ui-bg);border:1px solid var(--ui-border)', D('display:grid;grid-template-columns:' + (m ? '90px' : '110px') + ' minmax(0,1fr);gap:12px;height:36px;align-items:center;padding:0 12px;background:var(--ui-bg-elevated);color:var(--ui-text-muted);font-weight:500', S('', 'Value'), S('', L)), D('', cell(cellVals[0], 'Filled'), cell(cellVals[1], 'Empty'))), S('font-size:12px;color:var(--ui-text-muted)', { text: 'Truncated on one line, full text on hover.', richtext: 'First line as plain text, truncated.', number: 'Right-aligned, tabular figures.', boolean: 'Yes or No with an icon. Empty only when the field has no value.', date: 'Day, month and year.', select: 'The chosen option as a small tag.' }[t]))
|
||||||
|
const errCol = sec('Errors by rule', D('background:var(--ui-bg);border:1px solid var(--ui-border)', D('display:grid;grid-template-columns:' + (m ? '64px 38px' : '72px 40px') + ' minmax(0,1fr);gap:10px;height:32px;align-items:center;padding:0 12px;background:var(--ui-bg-elevated);color:var(--ui-text-muted);font-weight:500', S('', 'Rule'), S('', 'HTTP'), S('', 'Message')), ...ERR.map(r => D('display:grid;grid-template-columns:' + (m ? '64px 38px' : '72px 40px') + ' minmax(0,1fr);gap:10px;padding:8px 12px;border-top:1px solid var(--ui-border);align-items:start;line-height:18px', S(mono + 'font-size:12px;color:var(--ui-text-highlighted)', r[0]), S(mono + 'font-size:12px;color:var(--ui-text-muted)', String(r[1] || '–')), S('color:' + (r[1] ? 'var(--ui-error)' : 'var(--ui-text-muted)') + ';text-wrap:pretty', r[2])))))
|
||||||
|
const FW = { text: [['contains', 'Any text'], 'text'], richtext: [['contains', 'Any text'], 'text'], number: [['between'], 'num'], boolean: [['is'], 'bool'], date: [['between'], 'date'], select: [['is one of'], 'sel'] }[t]
|
||||||
|
const fw = FW[1] === 'bool' ? Seg([['any', 'Any'], ['yes', 'Yes'], ['no', 'No']], 'any') : FW[1] === 'sel' ? D('display:flex;gap:6px;flex-wrap:wrap', ...['Talk', 'Workshop', 'Meetup'].map((o_, i) => S('height:28px;padding:0 10px;display:flex;align-items:center;gap:6px;border:1px solid ' + (i === 1 ? 'var(--ui-primary)' : 'var(--ui-border-accented)') + ';background:' + (i === 1 ? 'var(--rc-sand)' : 'var(--ui-bg)') + ';color:' + (i === 1 ? 'var(--rc-on-sand)' : 'var(--ui-text-toned)'), i === 1 && I('check', 12), o_))) : FW[1] === 'num' || FW[1] === 'date' ? D('display:flex;gap:8px;align-items:center', F('', '', { ph: true, phText: FW[1] === 'date' ? 'From' : 'Min', ico: FW[1] === 'date' ? 'calendar' : null }), S('color:var(--ui-text-muted)', 'to'), F('', '', { ph: true, phText: FW[1] === 'date' ? 'Until' : 'Max', ico: FW[1] === 'date' ? 'calendar' : null })) : F('', '', { ph: true, phText: 'Contains…', ico: 'search' })
|
||||||
|
const filtCol = sec('Filter in the entry list', D('border:1px dashed var(--ui-border-accented);padding:14px;display:flex;flex-direction:column;gap:10px', D('display:flex;align-items:center;gap:8px', S('font-weight:500;color:var(--ui-text-highlighted)', L + ': ' + FW[0][0]), Later('Later')), fw, S('font-size:12px;color:var(--ui-text-muted);line-height:18px', 'Filtering on custom fields isn\'t available yet. The list only sorts by ID, created and updated.')))
|
||||||
|
const head = D('display:flex;align-items:center;gap:14px;flex-shrink:0', S('width:44px;height:44px;display:flex;align-items:center;justify-content:center;background:var(--rc-sand);color:var(--rc-on-sand)', I(FT[t][0], 22)), D('flex:1;display:flex;flex-direction:column', S('font:600 ' + (m ? 20 : 24) + 'px/32px \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted)', FT[t][1]), S('color:var(--ui-text-muted)', FT[t][2])), !m && S(mono + 'font-size:12px;color:var(--ui-text-toned)', 'type: "' + t + '"'))
|
||||||
|
const inspCol = !m && sec('Inspector options', card(Insp(f, { one: true })))
|
||||||
|
main = D('position:absolute;inset:0;padding:' + (m ? '16px' : '32px') + ';display:flex;flex-direction:column;gap:' + (m ? 14 : 20) + 'px;overflow:hidden', head, m ? D('display:flex;flex-direction:column;gap:14px', ctlCol, listCol, errCol, filtCol) : D('display:grid;grid-template-columns:400px 440px minmax(0,1fr);gap:24px;align-items:start;flex:1;min-height:0;overflow:hidden', inspCol, D('display:flex;flex-direction:column;gap:20px;min-width:0', ctlCol, listCol), D('display:flex;flex-direction:column;gap:20px;min-width:0', errCol, filtCol)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5 entries
|
||||||
|
const visCols = k === 'cols' ? [EV[0], EV[1], EV[2], EV[3]] : [EV[0], EV[1], EV[2], EV[3]]
|
||||||
|
if (v === 'entries') {
|
||||||
|
shell.nav = 'content'; shell.crumbs = ['Content', 'Events']; shell.mTitle = 'Events'; shell.contentOn = k === 'notfound' ? null : 'events'
|
||||||
|
if (s.many && m) { shell.drawer = true; shell.contentOpen = true }
|
||||||
|
if (k === 'forbidden') main = Center('lock', 'You don\'t have access to Events', 'Your account can\'t open this content right now. If you think this is a mistake, ask an owner or an admin.', B('Go to Home', { k: 'primary', px: 16 }))
|
||||||
|
else if (k === 'notfound') { shell.crumbs = ['Content']; shell.mTitle = 'Content'; main = Center('file-x', 'Content type not found', 'It may have been deleted, or the address may be mistyped.', B('Go to Home', { k: 'primary', px: 16 })) }
|
||||||
|
else {
|
||||||
|
const paged = k === 'paged', total = paged ? 37 : 7, size = paged ? 5 : 25
|
||||||
|
const editB = B('Edit content type', { icon: 'settings-2', k: 'ghost' })
|
||||||
|
const sortB = D('display:flex;align-items:center;gap:8px;height:' + bh + 'px;padding:0 10px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:var(--ui-text-highlighted);white-space:nowrap;' + (m ? 'flex:1' : 'width:220px;flex-shrink:0'), S('color:var(--ui-text-muted)', 'Sort by'), S('font-weight:500;flex:1', 'Last updated'), I('chevron-down', 14, 'color:var(--ui-text-muted)'))
|
||||||
|
const dirB = B(null, { icon: 'arrow-down-wide-narrow', w: bh, px: 0 }), colB = B(m ? null : 'Columns', { icon: 'columns-3', w: m ? 44 : null, px: m ? 0 : 12, ring: false })
|
||||||
|
const toolbar = D('display:flex;align-items:center;gap:8px;flex-shrink:0;position:relative', sortB, dirB, colB, !m && D('flex:1'), !m && editB)
|
||||||
|
let body
|
||||||
|
if (k === 'loading') body = D('background:var(--ui-bg);border:1px solid var(--ui-border);flex:1;min-height:0;overflow:hidden', ...Array.from({ length: m ? 6 : 8 }, (_, i) => D('height:' + (m ? 68 : 48) + 'px;display:flex;align-items:center;gap:20px;padding:0 16px;border-top:' + (i ? '1px solid var(--ui-border)' : 'none'), !m && Skel('32px', 12), Skel(i % 2 ? '30%' : '24%'), !m && Skel('90px', 12), !m && Skel('90px', 12), !m && Skel('50px', 12))))
|
||||||
|
else if (k === 'error') body = Alert('error', 'circle-x', 'Couldn\'t load entries', 'GET /v1/content-type/events/entry failed. Check your connection and try again.', 'Try again')
|
||||||
|
else if (k === 'empty') body = Empty('file-plus', 'No entries yet', 'Use New entry to add the first event. Each entry follows the fields of this content type.', 'New entry')
|
||||||
|
else {
|
||||||
|
const rows = ROWS.slice(0, size > 7 ? 7 : 5)
|
||||||
|
const gc = '48px ' + visCols.map((f, i) => i === 0 ? 'minmax(0,1.6fr)' : f.type === 'boolean' || f.type === 'number' ? '80px' : '120px').join(' ') + ' 110px 150px 130px'
|
||||||
|
const hdrC = (t, o = {}) => S('display:flex;align-items:center;gap:4px;' + (o.r ? 'justify-content:flex-end;' : ''), t, o.sort && I('arrow-down', 12))
|
||||||
|
const tbl = D('background:var(--ui-bg);border:1px solid var(--ui-border)', D('display:grid;grid-template-columns:' + gc + ';gap:12px;height:36px;align-items:center;padding:0 12px;background:var(--ui-bg-elevated);color:var(--ui-text-muted);font-weight:500', hdrC('ID'), ...visCols.map(f => hdrC(f.label, { r: f.type === 'number' })), hdrC('Created'), hdrC('Updated', { sort: true }), hdrC('Updated by')),
|
||||||
|
...rows.map((r, i) => D('display:grid;grid-template-columns:' + gc + ';gap:12px;height:48px;align-items:center;padding:0 12px;border-top:1px solid var(--ui-border);background:' + (i === 0 && false ? 'var(--ui-bg-elevated)' : 'transparent'), S(mono + 'font-size:12px;color:var(--ui-text-muted)', String(r.id)), ...visCols.map((f, j) => D('min-width:0;' + (j === 0 ? 'font-weight:500;color:var(--ui-text-highlighted);display:flex' : 'display:grid'), Cell(f, r[f.key]))), S('color:var(--ui-text-muted);white-space:nowrap', r.c), S('color:var(--ui-text-muted);white-space:nowrap', r.u.split(',')[0]), S('overflow:hidden;text-overflow:ellipsis;white-space:nowrap', r.by))))
|
||||||
|
const cards = D('background:var(--ui-bg);border:1px solid var(--ui-border)', ...ROWS.slice(0, 6).map((r, i) => D('display:flex;align-items:center;gap:8px;padding:12px 4px 12px 16px;border-top:' + (i ? '1px solid var(--ui-border)' : 'none') + ';min-height:68px', D('flex:1;min-width:0;display:flex;flex-direction:column;gap:4px', S('font-weight:500;font-size:15px;line-height:21px;color:var(--ui-text-highlighted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap', r.title), D('display:flex;align-items:center;gap:8px;font-size:12px;color:var(--ui-text-muted)', S('', r.starts_on), r.category && Cell(EV[5], r.category), S('overflow:hidden;text-overflow:ellipsis;white-space:nowrap', '· ' + r.by))), S('width:36px;height:44px;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted)', I('chevron-right', 18)))))
|
||||||
|
const pgB = (c, on, dis) => S('width:' + bh + 'px;height:' + bh + 'px;display:flex;align-items:center;justify-content:center;border:1px solid ' + (on ? 'var(--ui-primary)' : 'var(--ui-border-accented)') + ';background:' + (on ? 'var(--rc-sand)' : 'var(--ui-bg)') + ';color:' + (on ? 'var(--rc-on-sand)' : 'var(--ui-text-highlighted)') + ';opacity:' + (dis ? 0.4 : 1) + ';font-weight:' + (on ? 500 : 400), typeof c === 'string' && c.length > 2 ? I(c, 14) : c)
|
||||||
|
const pager = !paged ? S('color:var(--ui-text-muted)', '7 entries') : m ? D('display:flex;align-items:center;justify-content:space-between;gap:8px', pgB('chevron-left', false, true), S('color:var(--ui-text-muted)', 'Page 1 of 8'), pgB('chevron-right')) : D('display:flex;align-items:center;gap:16px', S('flex:1;color:var(--ui-text-muted)', 'Showing 1–5 of 37'), D('display:flex;align-items:center;gap:8px;height:32px;padding:0 10px;border:1px solid var(--ui-border-accented);background:var(--ui-bg)', '5 per page', I('chevron-down', 14, 'color:var(--ui-text-muted)')), D('display:flex;gap:4px', pgB('chevrons-left', false, true), pgB('chevron-left', false, true), pgB('1', true), pgB('2'), pgB('3'), pgB('…'), pgB('8'), pgB('chevron-right'), pgB('chevrons-right')))
|
||||||
|
body = D('display:flex;flex-direction:column;gap:12px;min-height:0', m ? cards : tbl, pager)
|
||||||
|
}
|
||||||
|
main = Page(pad, Title('Events', 'events · 7 fields', !m && B('New entry', { k: 'primary', icon: 'plus', px: 14, dis: k === 'loading' })), ...(k === 'error' || k === 'empty' || k === 'loading' ? [k !== 'error' ? toolbar : null, body] : [toolbar, body]))
|
||||||
|
if (m && k !== 'error' && k !== 'loading') ov.push(Fab('New entry'))
|
||||||
|
if (k === 'cols') {
|
||||||
|
const items = EV.map((f, i) => [f, i < 4])
|
||||||
|
const lst = D('display:flex;flex-direction:column', ...items.map(([f, on]) => D('display:flex;align-items:center;gap:10px;min-height:' + (m ? 48 : 36) + 'px;padding:0 8px', Chk(on), I(FT[f.type][0], 14, 'color:var(--ui-text-muted)'), S('flex:1', f.label), S(mono + 'font-size:12px;color:var(--ui-text-muted)', f.key))), D('display:flex;align-items:center;gap:10px;min-height:' + (m ? 48 : 36) + 'px;padding:0 8px;color:var(--ui-text-muted)', Chk(false), I('clock', 14), S('', 'Created and updated dates always show')))
|
||||||
|
if (m) ov.push(Sheet({ title: 'Columns', z: 45 }, [S('color:var(--ui-text-muted)', 'Choose up to 6 fields to show. 4 selected.'), lst], [B('Reset', { full: true }), B('Done', { k: 'primary', full: true })]))
|
||||||
|
else ov.push(D('position:absolute;z-index:40;left:' + (224 + 24 + 330) + 'px;top:' + (48 + 24 + 28 + 18 + 16 + 40) + 'px;width:300px;background:var(--ui-bg);border:1px solid var(--ui-border);box-shadow:var(--rc-shadow-pop);padding:8px;display:flex;flex-direction:column;gap:8px', D('display:flex;justify-content:space-between;padding:4px 8px', S('font-weight:600', 'Columns'), S('color:var(--ui-text-muted);font-size:12px', '4 of 6')), lst, D('display:flex;justify-content:flex-end;padding:4px 8px', B('Reset', { h: 28, fs: 12 }))))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6 entry
|
||||||
|
if (v === 'entry') {
|
||||||
|
shell.nav = 'content'; shell.contentOn = 'events'; shell.crumbs = ['Content', 'Events', k === 'new' || k === 'required' ? 'New entry' : 'Lime plaster workshop']; shell.mTitle = k === 'new' || k === 'required' ? 'New entry' : 'Lime plaster workshop'; shell.mLead = 'arrow-left'; shell.mAction = k === 'new' || k === 'required' ? null : 'trash-2'
|
||||||
|
if (k === 'notfound') main = Center('file-x', 'Entry not found', 'It may have been deleted by someone else.', B('Back to Events', { k: 'primary', px: 16 }))
|
||||||
|
else {
|
||||||
|
const isNew = k === 'new' || k === 'required'
|
||||||
|
const val = { title: isNew ? '' : 'Lime plaster workshop', starts_on: isNew ? '' : '21 Nov 2026', ends_on: isNew ? '' : '22 Nov 2026', online: 'false', capacity: isNew ? '' : '18', category: isNew ? '' : 'Workshop', description: isNew ? '' : mdSample }
|
||||||
|
const E = {}
|
||||||
|
if (k === 'dirty' || k === 'saving' || k === 'fail') val.title = 'Lime plaster workshop, two days'
|
||||||
|
if (k === 'required') { E.title = 'Fill in Title.'; E.starts_on = 'Pick a date for Starts on.' }
|
||||||
|
if (k === 'rules') { val.title = 'Lime plaster workshop, two days of hands-on work with the whole team on site and lunch'; E.title = 'Title can be up to 80 characters.'; val.ends_on = '31/11/2026'; E.ends_on = 'Ends on must be a date, like 2026-11-21.'; val.capacity = '6000'; E.capacity = 'Capacity must be at most 5000.'; val.category = 'Seminar'; E.category = 'Choose one of: Talk, Workshop or Meetup.' }
|
||||||
|
if (k === 'unique') { val.title = 'Heat pumps: what the numbers say'; E.title = 'Another entry already uses this Title. Enter a different one.' }
|
||||||
|
const dis = k === 'saving' || k === 'changed'
|
||||||
|
const sk = k === 'dirty' ? 'dirty' : k === 'saving' ? 'saving' : k === 'saved' ? 'saved' : k === 'fail' ? 'fail' : (k === 'required' || k === 'rules' || k === 'unique') ? 'fail' : 'clean'
|
||||||
|
const chip = k === 'required' || k === 'rules' || k === 'unique' ? S('display:flex;align-items:center;gap:6px;font-size:12px;color:var(--ui-error);white-space:nowrap', I('circle-alert', 14), 'Not saved') : isNew ? S('font-size:12px;color:var(--ui-text-muted);white-space:nowrap', 'New entry') : stChip(sk)
|
||||||
|
const canSave = k === 'dirty' || k === 'fail' || k === 'new' || k === 'required' || k === 'rules' || k === 'unique'
|
||||||
|
const acts = D('display:flex;align-items:center;gap:' + (m ? 8 : 12) + 'px', !m && chip, !m && !isNew && B('Delete', { k: 'del', icon: 'trash-2' }), !m && B('Cancel', { dis: dis }), !m && B(k === 'saving' ? 'Saving…' : 'Save', { k: 'primary', icon: k === 'saving' ? 'loader-circle' : null, px: 16, dis: !canSave && k !== 'saving' }))
|
||||||
|
const fieldsUI = EV.map(f => { const vv = val[f.key], e = E[f.key]; const o = { v: vv, err: e, dis, def: isNew && f.key === 'online', focus: k === 'dirty' && f.key === 'title' }; if (f.key === 'title') { const n = (vv || '').length; o.count = n + ' / 80'; o.countErr = n > 80; if (k === 'unique') o.count = n + ' / 80' } if (f.key === 'description') { o.tab = k === 'preview' ? 'preview' : 'write'; o.help = 'Markdown. Switch to Preview to check the result.' } if (f.key === 'capacity') o.help = 'Between 1 and 5000.'; if (f.key === 'title' && !e) o.help = 'Each entry needs a different title.'; return Ctl(f, o) })
|
||||||
|
const grid = (a, b) => D('display:grid;grid-template-columns:' + (m ? '1fr' : '1fr 1fr') + ';gap:16px', a, b)
|
||||||
|
const form = D('background:var(--ui-bg);border:1px solid var(--ui-border);padding:' + (m ? 16 : 24) + 'px;display:flex;flex-direction:column;gap:20px;max-width:760px;opacity:' + (dis ? 0.7 : 1), fieldsUI[0], grid(fieldsUI[1], fieldsUI[2]), grid(fieldsUI[3], fieldsUI[4]), fieldsUI[5], fieldsUI[6], D('border-top:1px solid var(--ui-border);padding-top:16px;display:grid;grid-template-columns:' + (m ? '1fr' : 'auto 1fr') + ';gap:6px 16px;font-size:12px;line-height:18px', S('color:var(--ui-text-muted)', 'Entry'), S('', isNew ? 'Not saved yet' : S(mono, 'events / 42')), S('color:var(--ui-text-muted)', 'Created by'), S('', isNew ? '–' : 'Inès Duarte · 12 Oct 2026, 09:14'), S('color:var(--ui-text-muted)', 'Updated by'), S('', isNew ? '–' : k === 'saved' ? 'Marta Alves · 19 Oct 2026, 11:02' : 'Marta Alves · 15 Oct 2026, 11:02')))
|
||||||
|
const ban = k === 'fail' ? Alert('error', 'circle-x', 'Couldn\'t save the entry', 'Something went wrong on our side. Your changes are still here. Try again in a moment.', 'Try again') : k === 'changed' ? Alert('warning', 'triangle-alert', 'The fields of Events changed', 'A field was removed and a new required field, Room, was added after you opened this form. Reload the form to continue. What you typed here can\'t be saved against the old fields.', 'Reload form') : k === 'unique' ? Alert('error', 'circle-x', 'Not saved', 'Another entry already uses this title. Change it and save again.') : null
|
||||||
|
const bar = m && D('flex-shrink:0;display:flex;align-items:center;gap:8px;padding:12px 16px;border-top:1px solid var(--ui-border);background:var(--ui-bg);position:relative;z-index:1', B('Cancel', { full: true, dis }), B(k === 'saving' ? 'Saving…' : 'Save', { k: 'primary', full: true, icon: k === 'saving' ? 'loader-circle' : null, dis: !canSave && k !== 'saving' }))
|
||||||
|
main = D('flex:1;min-width:0;min-height:0;display:flex;flex-direction:column;position:relative', Page(pad, Title(isNew ? 'New entry' : 'Lime plaster workshop', isNew ? 'Events · Fields marked * are required.' : 'Events · Entry 42', acts), m && D('display:flex;align-items:center;justify-content:space-between', chip), ban, form), bar)
|
||||||
|
if (k === 'delete') ov.push(Shell({ title: 'Delete this entry?', desc: '"Lime plaster workshop" will be deleted from Events. This can\'t be undone.', close: true, cancel: 'Cancel', confirm: 'Delete entry', tone: 'danger' }, []))
|
||||||
|
if (k === 'saved') ov.push(Toast('success', 'circle-check', 'Entry saved', '"Lime plaster workshop" is up to date.'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7 relation (later)
|
||||||
|
if (v === 'rel') {
|
||||||
|
shell.nav = 'schema'
|
||||||
|
if (k === 'builder') {
|
||||||
|
shell.crumbs = ['Schema', 'Events']; shell.mTitle = 'Events'; shell.mLead = 'arrow-left'
|
||||||
|
const shape = (on, a, b, diag) => D('display:flex;flex-direction:column;gap:6px;padding:10px;border:1px solid ' + (on ? 'var(--ui-primary)' : 'var(--ui-border-accented)') + ';background:' + (on ? 'var(--rc-sand)' : 'var(--ui-bg)') + ';color:' + (on ? 'var(--rc-on-sand)' : 'var(--ui-text-highlighted)'), diag, S('font-weight:500', a), S('font-size:12px;line-height:16px;color:' + (on ? 'var(--rc-on-sand)' : 'var(--ui-text-muted)'), b))
|
||||||
|
const sq = (n, c) => D('display:flex;gap:3px', ...Array.from({ length: n }, () => S('width:10px;height:10px;background:' + c)))
|
||||||
|
const dg = (l, r) => D('display:flex;align-items:center;gap:6px', sq(l, 'currentColor'), S('width:20px;height:1px;background:currentColor'), sq(r, 'currentColor'))
|
||||||
|
const sel = (t) => S('display:inline-flex;align-items:center;gap:6px;height:28px;padding:0 8px;margin:0 4px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);font-weight:500;color:var(--ui-text-highlighted)', t, I('chevron-down', 12))
|
||||||
|
const card_ = (t, ...c) => D('flex:1;min-width:0;padding:12px;background:var(--ui-bg);border:1px solid var(--ui-border);display:flex;flex-direction:column;gap:8px', S('font-weight:600;color:var(--ui-text-highlighted)', t), ...c)
|
||||||
|
const chipR = t => S('height:22px;padding:0 8px;display:inline-flex;align-items:center;gap:4px;background:var(--rc-sand);color:var(--rc-on-sand);font-size:12px;font-weight:500', t)
|
||||||
|
const insp = D('display:flex;flex-direction:column;gap:14px;min-width:0', D('display:flex;align-items:center;gap:8px', S('font:600 15px/24px \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted)', 'Field: Venue'), Later()), D('display:grid;grid-template-columns:' + (m ? '1fr' : '1fr 1fr') + ';gap:14px', F('Label', 'Venue', { req: true }), F('Key', 'venue', { mono: true, auto: true })),
|
||||||
|
D('display:flex;flex-direction:column;gap:6px', S('font-weight:500;color:var(--ui-text-highlighted)', 'Type'), D('display:grid;grid-template-columns:repeat(' + (m ? 2 : 4) + ',minmax(0,1fr));gap:8px', ...['text', 'number', 'date', 'select'].map(t => D('display:flex;gap:8px;align-items:center;padding:8px 10px;border:1px solid var(--ui-border-accented);opacity:.6', I(FT[t][0], 16), FT[t][1])), D('display:flex;gap:8px;align-items:center;padding:8px 10px;border:1px solid var(--ui-primary);background:var(--rc-sand);color:var(--rc-on-sand)', I('link', 16), S('font-weight:500', 'Relation'), S('margin-left:auto;font-size:11px', 'Later')))),
|
||||||
|
F('Related content type', 'Venue', { sel: true, help: 'Choose the content type this field points to.' }),
|
||||||
|
D('display:flex;flex-direction:column;gap:6px', S('font-weight:500;color:var(--ui-text-highlighted)', 'How are they connected?'), D('display:grid;grid-template-columns:' + (m ? '1fr' : 'repeat(3,minmax(0,1fr))') + ';gap:8px', shape(false, 'One to one', 'Each Event has one Venue, and each Venue has one Event.', dg(1, 1)), shape(true, 'One to many', 'Each Event has one Venue. A Venue has many Events.', dg(3, 1)), shape(false, 'Many to many', 'Each Event has many Venues, and each Venue has many Events.', dg(2, 2)))),
|
||||||
|
D('display:flex;flex-direction:column;gap:8px;padding:12px;background:var(--ui-bg-elevated)', S('', 'Each Event has', sel('one'), 'Venue'), S('', 'Each Venue has', sel('many'), 'Events', S('color:var(--ui-text-muted)', ' · shown on Venue as the field "Events"'))),
|
||||||
|
D('display:flex;gap:12px;flex-direction:' + (m ? 'column' : 'row'), card_('Event', S('color:var(--ui-text-muted);font-size:12px', 'Venue'), D('display:flex;gap:6px', chipR('Rue Verte hall'))), card_('Venue', S('color:var(--ui-text-muted);font-size:12px', 'Events'), D('display:flex;gap:6px;flex-wrap:wrap', chipR('Open day'), chipR('Lime plaster workshop'), S('font-size:12px;color:var(--ui-text-muted);align-self:center', '+1')))))
|
||||||
|
main = Page(pad, Title('Edit content type', 'Events · Relation field, shown as it will work later.'), D('background:var(--ui-bg);border:1px solid var(--ui-border);padding:16px;overflow:hidden', insp))
|
||||||
|
} else if (k === 'form') {
|
||||||
|
shell.nav = 'content'; shell.crumbs = ['Content', 'Events', 'Lime plaster workshop']; shell.mTitle = 'Lime plaster workshop'; shell.contentOn = 'events'
|
||||||
|
const res = D('position:absolute;left:0;right:0;top:64px;z-index:5;border:1px solid var(--ui-border);background:var(--ui-bg);box-shadow:var(--rc-shadow-pop);padding:4px', ...[['Rue Verte hall', 'Lyon · 120 seats'], ['Rue Verte yard', 'Lyon · outdoor'], ['Rue du Port studio', 'Nantes · 24 seats']].map(([a, b], i) => D('min-height:' + (m ? 48 : 40) + 'px;padding:4px 8px;display:flex;flex-direction:column;justify-content:center;background:' + (i === 0 ? 'var(--ui-bg-elevated)' : 'transparent'), S('font-weight:500', a), S('font-size:12px;color:var(--ui-text-muted)', b))))
|
||||||
|
const chipX = t => S('height:' + (m ? 32 : 24) + 'px;padding:0 4px 0 8px;display:inline-flex;align-items:center;gap:6px;background:var(--rc-sand);color:var(--rc-on-sand);font-weight:500', t, I('x', 14))
|
||||||
|
main = Page(pad, Title('Lime plaster workshop', 'Events · Entry 42', Later('Relation field: later')), D('background:var(--ui-bg);border:1px solid var(--ui-border);padding:' + (m ? 16 : 24) + 'px;display:flex;flex-direction:column;gap:' + (m ? 16 : 20) + 'px;max-width:760px;overflow:visible;flex:1;min-height:0', Ctl(EV[0], { v: 'Lime plaster workshop', count: '21 / 80' }),
|
||||||
|
D('display:flex;flex-direction:column;gap:6px;position:relative', S('font-weight:500;color:var(--ui-text-highlighted)', 'Venue'), F('', 'rue', { focus: true, ico: 'search' }), res, S('height:176px')),
|
||||||
|
D('display:flex;flex-direction:column;gap:6px', S('font-weight:500;color:var(--ui-text-highlighted)', 'Speakers'), D('display:flex;gap:6px;flex-wrap:wrap;align-items:center;min-height:' + ih + 'px;padding:4px 8px;border:1px solid var(--ui-border-accented)', chipX('Inès Duarte'), chipX('Jonas Pereira'), S('color:var(--ui-text-muted);margin-left:4px', 'Search to add')), S('color:var(--ui-text-muted);font-size:12px', 'An event can have many speakers.'))))
|
||||||
|
} else {
|
||||||
|
shell.nav = 'content'; shell.crumbs = ['Content', 'Events']; shell.mTitle = 'Events'; shell.contentOn = 'events'
|
||||||
|
const chipR = t => S('height:20px;padding:0 6px;display:inline-flex;align-items:center;background:var(--rc-sand);color:var(--rc-on-sand);font-size:12px;font-weight:500;white-space:nowrap', t)
|
||||||
|
const gc = '48px minmax(0,1.4fr) 110px 200px 200px'
|
||||||
|
const rr = [[42, 'Lime plaster workshop', '21 Nov 2026', [chipR('Rue Verte hall')], [chipR('Inès Duarte'), chipR('Jonas Pereira'), S('color:var(--ui-text-muted);font-size:12px', '+2')]], [41, 'Heat pumps: what the numbers say', '3 Nov 2026', [S('color:var(--ui-text-muted)', '–')], [chipR('Jonas Pereira')]], [40, 'Open day at the Rue Verte site', '12 Nov 2026', [chipR('Rue Verte yard')], [S('color:var(--ui-text-muted)', '–')]]]
|
||||||
|
main = Page(pad, Title('Events', 'events · 9 fields', Later('Relation columns: later')), m ? D('background:var(--ui-bg);border:1px solid var(--ui-border)', ...rr.map(r => D('padding:12px 16px;border-top:1px solid var(--ui-border);display:flex;flex-direction:column;gap:6px', S('font-weight:500;font-size:15px', r[1]), D('display:flex;gap:6px;align-items:center', S('font-size:12px;color:var(--ui-text-muted)', 'Venue'), ...r[3]), D('display:flex;gap:6px;align-items:center;flex-wrap:wrap', S('font-size:12px;color:var(--ui-text-muted)', 'Speakers'), ...r[4])))) : D('background:var(--ui-bg);border:1px solid var(--ui-border)', D('display:grid;grid-template-columns:' + gc + ';gap:12px;height:36px;align-items:center;padding:0 12px;background:var(--ui-bg-elevated);color:var(--ui-text-muted);font-weight:500', S('', 'ID'), S('', 'Title'), S('', 'Starts on'), S('', 'Venue'), S('', 'Speakers')), ...rr.map(r => D('display:grid;grid-template-columns:' + gc + ';gap:12px;height:48px;align-items:center;padding:0 12px;border-top:1px solid var(--ui-border)', S(mono + 'font-size:12px;color:var(--ui-text-muted)', String(r[0])), S('font-weight:500;color:var(--ui-text-highlighted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap', r[1]), S('', r[2]), D('display:flex;gap:4px;align-items:center', ...r[3]), D('display:flex;gap:4px;align-items:center', ...r[4])))), S('color:var(--ui-text-muted);font-size:12px;line-height:18px', 'One chip for a single relation. Up to two chips and a "+N" for many. Empty shows a dash.'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8 permissions (later)
|
||||||
|
if (v === 'perms') {
|
||||||
|
shell.nav = 'settings'; shell.crumbs = ['Settings', 'Permissions']; shell.mTitle = 'Permissions'; shell.mLead = 'arrow-left'; shell.sideOpen = true
|
||||||
|
const ch = k === 'changed'
|
||||||
|
const rowsC = [['Events', 'events', [1, 1, 1, 1]], ['Recipes', 'recipes', ch ? [1, 1, 1, 0] : [1, 1, 1, 1]], ['Press mentions', 'press-mentions', ch ? [1, 0, 0, 0] : [1, 1, 1, 1]]]
|
||||||
|
const rowsB = [['Articles', [1, 1, 1, 1]], ['Authors', [1, 1, 1, 0]], ['Sites', [1, 0, 0, 0]], ['Media', [1, 1, 1, 1]], ['Users', [0, 0, 0, 0]]]
|
||||||
|
const A4 = ['Read', 'Create', 'Update', 'Delete']
|
||||||
|
const cols = 'minmax(0,1fr) 80px 80px 80px 80px'
|
||||||
|
const cellP = (on, lock, changed) => D('display:flex;align-items:center;justify-content:center;gap:4px;opacity:' + (lock ? 0.55 : 1), Chk(!!on), changed && S('width:6px;height:6px;background:var(--ui-info)'))
|
||||||
|
const desk = D('background:var(--ui-bg);border:1px solid var(--ui-border)', D('display:grid;grid-template-columns:' + cols + ';gap:8px;height:36px;align-items:center;padding:0 12px;background:var(--ui-bg-elevated);color:var(--ui-text-muted);font-weight:500', S('', 'Content type'), ...A4.map(a => S('text-align:center', a))),
|
||||||
|
...rowsC.map(r => D('display:grid;grid-template-columns:' + cols + ';gap:8px;height:44px;align-items:center;padding:0 12px;border-top:1px solid var(--ui-border)', D('display:flex;align-items:center;gap:8px', S('font-weight:500', r[0]), S(mono + 'font-size:12px;color:var(--ui-text-muted)', r[1])), ...r[2].map((x, i) => cellP(x, false, ch && ((r[0] === 'Recipes' && i === 3) || (r[0] === 'Press mentions' && i > 0)))))),
|
||||||
|
D('display:flex;align-items:center;gap:8px;padding:8px 12px;border-top:1px solid var(--ui-border);background:var(--ui-bg-muted);color:var(--ui-text-toned);font-size:12px', I('lock', 14), 'Built-in types. RenewCMS sets these rules.'),
|
||||||
|
...rowsB.map(r => D('display:grid;grid-template-columns:' + cols + ';gap:8px;height:44px;align-items:center;padding:0 12px;border-top:1px solid var(--ui-border)', D('display:flex;align-items:center;gap:8px', I('lock', 14, 'color:var(--ui-text-muted)'), S('font-weight:500', r[0]), Bd('Built-in', 'built')), ...r[1].map(x => cellP(x, true)))))
|
||||||
|
const mob = D('display:flex;flex-direction:column;gap:12px', ...rowsC.map(r => D('background:var(--ui-bg);border:1px solid var(--ui-border);padding:12px 16px;display:flex;flex-direction:column;gap:2px', S('font-weight:600;font-size:15px;color:var(--ui-text-highlighted)', r[0]), ...r[2].map((x, i) => D('display:flex;align-items:center;gap:12px;min-height:44px;border-top:' + (i ? '1px solid var(--ui-border-muted)' : 'none'), Chk(!!x), S('flex:1', A4[i]), ch && ((r[0] === 'Recipes' && i === 3) || (r[0] === 'Press mentions' && i > 0)) && Bd('Changed', 'chg'))))), D('background:var(--ui-bg);border:1px solid var(--ui-border);padding:12px 16px;display:flex;align-items:center;gap:10px;min-height:48px', I('lock', 16, 'color:var(--ui-text-muted)'), S('flex:1', '5 built-in types'), S('color:var(--ui-text-muted);font-size:12px', 'Set by RenewCMS'), I('chevron-right', 14, 'color:var(--ui-text-muted)')))
|
||||||
|
const foot = D('flex-shrink:0;display:flex;align-items:center;gap:8px;padding:12px ' + (m ? 16 : 24) + 'px;border-top:1px solid var(--ui-border);background:var(--ui-bg)', S('flex:1;font-size:12px;color:' + (ch ? 'var(--ui-warning)' : 'var(--ui-text-muted)'), ch ? '4 changes not saved' : 'No changes'), B('Discard', { dis: !ch }), B('Save permissions', { k: 'primary', dis: !ch }))
|
||||||
|
main = D('flex:1;min-width:0;min-height:0;display:flex;flex-direction:column', Page(pad, Title('Permissions', 'What each role can do with each content type.', Later()), Alert('info', 'info', 'Today\'s rules are the starting point', 'Owners and admins edit the schema. Any verified user can read, add, edit and delete entries.'), Seg([['owner', 'Owner'], ['admin', 'Admin'], ['user', 'User']], 'user', m ? '100%' : '320px'), m ? mob : desk, D('display:flex;align-items:center;gap:8px;color:var(--ui-text-toned);font-size:12px;line-height:18px;background:var(--ui-bg-elevated);padding:10px 12px', I('lock', 14), S('', 'Schema (create, edit and delete content types): owners and admins only. Set by RenewCMS.'))), foot)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 9 export / import (later)
|
||||||
|
if (v === 'xport') {
|
||||||
|
Object.assign(shell, { nav: 'schema', crumbs: ['Schema'], mTitle: 'Schema' })
|
||||||
|
main = Page(pad, Title('Schema', 'Content types and the fields they are made of.', B('Import', { icon: 'upload' }), B('Export', { icon: 'download' }), Later(), B('New content type', { k: 'primary', icon: 'plus', px: 14 })), typesTable(true))
|
||||||
|
const rd = (on, t, sub) => D('display:flex;gap:10px;align-items:flex-start;padding:10px 12px;border:1px solid ' + (on ? 'var(--ui-primary)' : 'var(--ui-border-accented)') + ';background:' + (on ? 'var(--rc-sand)' : 'var(--ui-bg)') + ';color:' + (on ? 'var(--rc-on-sand)' : 'var(--ui-text-highlighted)'), S('width:16px;height:16px;margin-top:2px;flex-shrink:0;border:1px solid currentColor;display:flex;align-items:center;justify-content:center', on && S('width:8px;height:8px;background:currentColor')), D('display:flex;flex-direction:column', S('font-weight:500', t), S('font-size:12px;color:' + (on ? 'var(--rc-on-sand)' : 'var(--ui-text-muted)'), sub)))
|
||||||
|
if (k === 'export') ov.push(Shell({ title: 'Export schema', desc: 'Download the fields of your content types as a file. Entries aren\'t included.', close: true, later: true, cancel: 'Cancel', confirm: 'Download file' }, [D('display:flex;flex-direction:column;gap:8px', rd(true, 'All content types', '3 content types'), rd(false, 'One content type', 'Choose which one')), F('File name', 'renewcms-schema.json', { mono: true, dis: true, help: 'Import this file on another RenewCMS site to create the same content types.' })]))
|
||||||
|
else {
|
||||||
|
const ref = k === 'refused'
|
||||||
|
const rowI = (ic, t, text, flag, tag) => D('display:flex;gap:10px;padding:10px 12px;border:1px solid var(--ui-border);background:' + (flag ? 'var(--rc-warning-soft)' : 'var(--ui-bg)'), S('margin-top:2px;display:flex;color:' + (flag ? 'var(--ui-warning)' : 'var(--ui-text-toned)'), I(ic)), D('flex:1;min-width:0;display:flex;flex-direction:column;gap:2px', D('display:flex;align-items:center;gap:8px;flex-wrap:wrap', S('font-weight:600;color:var(--ui-text-highlighted)', t), tag && Bd(tag, 'chg'), flag && Bd(flag, 'warn', 'triangle-alert')), S('color:var(--ui-text-toned);text-wrap:pretty', text)))
|
||||||
|
ov.push(Shell({ title: 'Import schema', desc: 'renewcms-schema.json · 3 content types', w: 600, top: 70, close: true, later: true, cancel: 'Cancel', confirm: ref ? 'Try again' : 'Import schema', cdis: false }, [ref ? Alert('error', 'circle-x', 'Nothing was imported', 'Some entries on this site don\'t fit the imported fields. RenewCMS can\'t tell which entries. The flagged change can cause this.') : S('font-weight:600;font-size:15px;color:var(--ui-text-highlighted)', 'Create 1, change 1, no change 1'), D('display:flex;flex-direction:column;gap:8px', rowI('plus', 'Recipes', 'New content type with 4 fields.', null, 'Create'), rowI('pencil', 'Events', 'Add 2 fields, remove Description. Events has 37 entries on this site.', 'Removes data', 'Change'), rowI('equal', 'Press mentions', 'Same as on this site.', null, null)), S('color:var(--ui-text-muted);font-size:12px;line-height:18px', 'Existing entries are checked against the changes when you import. If any entry doesn\'t fit, nothing is imported.')]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (m && v !== 'gallery' && s.drawer) shell.drawer = true
|
||||||
|
const framed = !bare
|
||||||
|
return { framed, bare, shell, mob: m, main, ov: D('display:contents', ...ov), bw, bh: bhh }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
const N = 'available now', L = 'later'
|
||||||
|
const st = (name, scope, trigger, s, copy, o = {}) => ({ name, scope, trigger, s, copy, ...o })
|
||||||
|
|
||||||
|
export const groups = [
|
||||||
|
{ label: '1 Schema list', title: '1 Schema: list of types', route: '/schema, /schema/:key (built-in)', api: 'GET /v1/content-type', auth: 'Owner or admin', note: 'Sidebar entry "Schema" for owners and admins only. Built-in types are listed with a lock and a Built-in badge, always after the custom ones. They open a read-only view. Sample data: the field counts of built-ins are illustrative.', states: [
|
||||||
|
st('Populated', N, 'An owner or admin opens /schema.', { v: 'types', k: 'ok' }, 'Schema · Content types and the fields they are made of. · New content type · Name · Key · Fields · Last updated · Built-in types are managed by RenewCMS. You can read their fields, but not change them. · Built-in · 3 content types · 5 built-in'),
|
||||||
|
st('Loading', N, 'First load or retry. Skeleton rows appear after 150 ms. New content type is disabled.', { v: 'types', k: 'loading' }, '(skeleton) · New content type (disabled)'),
|
||||||
|
st('Empty: no custom type yet', N, 'GET /v1/content-type returns []. Built-ins are still listed below.', { v: 'types', k: 'empty' }, 'No content types yet · Create a content type to manage your own kind of content, like events or recipes. The built-in types stay as they are. · New content type'),
|
||||||
|
st('Error', N, '500 or network failure on the list request.', { v: 'types', k: 'error' }, 'Couldn\'t load content types · GET /v1/content-type failed. Check your connection and try again. · Try again'),
|
||||||
|
st('Forbidden (403)', N, 'A user who is not owner or admin types /schema. There is no sidebar entry, so the page explains instead of redirecting.', { v: 'types', k: 'forbidden' }, 'You don\'t have access to Schema · Only owners and admins can change content types. You can still add and edit entries from the Content menu. · Go to Home'),
|
||||||
|
st('Built-in type: read-only view', N, 'Click Articles in the list. No edit, no delete, no add field.', { v: 'types', k: 'builtin' }, 'Articles · Built-in · Managed by RenewCMS · Articles is a built-in type. You can read its fields here, but you can\'t edit it, delete it or add fields. · Label · Key · Type · Rules · Entries of built-in types are managed from their own screens, like Articles and Authors.')
|
||||||
|
] },
|
||||||
|
{ label: '2 Schema builder', title: '2 Schema builder', route: '/schema/new, /schema/:key', api: 'POST /v1/content-type, GET/PUT /v1/content-type/{key}', auth: 'Owner or admin', note: 'One screen, three regions: type name and key on top, ordered field list on the left, field inspector on the right, collapsible API preview at the bottom. Mobile: the list is the page, the inspector is a 92% sheet, the API preview is a sheet. Field keys and types of saved fields are locked; fields added since the last save can still choose both.', states: [
|
||||||
|
st('New type', N, '/schema/new. One starter field is added because a type needs at least one.', { v: 'builder', k: 'new' }, 'New content type · Name · For example, Events · Key · events · Used in URLs and the API. Choose it carefully: it can\'t change after you save. · Fields · 1 of 50 · Add field · Field: Title · Label · Key · The name used in the API. Lowercase letters, digits and underscores. · Type · Required · An entry can\'t be saved with this field empty. · Unique values · Default value · Used when an entry is created and this field is left out. · Shortest · Longest · Counted in characters. Entries show a counter. · Save · Discard'),
|
||||||
|
st('Editing an existing type', N, '/schema/events with 37 entries. Key is locked, saved field keys and types are locked.', { v: 'builder', k: 'edit' }, '37 entries use this content type. Changes are checked against them when you save. · Key (lock) · Used in the API, so it can\'t change. · Can\'t change after saving · Saved'),
|
||||||
|
st('Locked key: tooltip', N, 'Hover or focus the locked key.', { v: 'builder', k: 'locked' }, 'The key is used in URLs and the API, so it can\'t change after the type is created.'),
|
||||||
|
st('Unsaved changes', N, 'Two fields added, Capacity changed, Description removed. Rows carry New and Changed badges; removal shows an undo row.', { v: 'builder', k: 'dirty' }, 'Unsaved changes · New · Changed · Description will be removed · Undo · Save · Discard'),
|
||||||
|
st('Field errors', N, 'Checked as the user types, and again on Save. The row is outlined and shows an alert icon. Save is disabled until all are fixed.', { v: 'builder', k: 'ferr' }, '3 problems to fix · Give the field a label. · Lowest value can\'t be above the highest value. · Choices can\'t be blank. · Two choices are the same.'),
|
||||||
|
st('Type-level errors', N, 'New type with no name, a one-letter key and no fields.', { v: 'builder', k: 'terr' }, 'Give the content type a name. · Use 2 to 40 lowercase letters, digits or hyphens. · No fields yet · A content type needs at least one field. · 3 problems to fix'),
|
||||||
|
st('Choice list editor', N, 'A Choice field is selected. Add, remove and reorder; blanks and duplicates are flagged per row.', { v: 'builder', k: 'choices', sheet: true }, 'Choices · 5 of 50 · Add a choice · Add · Add 1 to 50 choices. Each one needs a different name. Removing a choice that entries use will be refused when you save. · Choices can\'t be blank. · Two choices are the same.'),
|
||||||
|
st('Keyboard reorder', N, 'Focus the grip, press Space. Up and Down move, Space drops, Esc cancels. Move up, Move down and Remove buttons are on the selected row.', { v: 'builder', k: 'kb' }, 'Moving Ends on. Position 2 of 7. Press Up or Down to move, Space to drop, Esc to cancel. (live region, polite)'),
|
||||||
|
st('API preview open', N, 'The panel at the bottom. Updates as fields change. Copy JSON copies the sample entry.', { v: 'builder', k: 'api' }, 'API preview · Where entries of this type live · A sample entry. Values come from each field\'s type and default. · Updates as you edit. Fields you add or remove here appear after you save. · Copy JSON · Copied'),
|
||||||
|
st('Saving', N, 'PUT in flight. Inputs dim, Save shows the loader, nothing can be dragged.', { v: 'builder', k: 'saving' }, 'Saving…'),
|
||||||
|
st('Saved', N, '200. A toast confirms, the badges clear.', { v: 'builder', k: 'saved' }, 'Saved at 11:02 · Content type saved · Events now has 8 fields.'),
|
||||||
|
st('Save failed', N, '500 or network failure. Nothing was sent as saved; the edits stay.', { v: 'builder', k: 'fail' }, 'Save failed · Couldn\'t save the content type · Something went wrong on our side. Your changes are still here. Try again in a moment. · Try again'),
|
||||||
|
st('Key already taken', N, '409 content_type_key_taken on POST.', { v: 'builder', k: 'taken' }, 'The key "events" is already used by another content type. Choose a different key.'),
|
||||||
|
st('Schema incompatible', N, '422 schema_incompatible on PUT. The review dialog (group 3) closes into this state. Changes of the kinds that can cause it are marked.', { v: 'builder', k: 'incompat' }, 'Nothing was changed · Some entries don\'t fit the new fields, so the whole change was refused. RenewCMS can\'t tell which entries. The changes that can cause this are marked. · Review changes · Possible cause'),
|
||||||
|
st('Offline', N, 'navigator.onLine is false. Edits are kept; Save is disabled.', { v: 'builder', k: 'offline' }, 'You\'re offline. Changes will be saved when you reconnect. · Waiting for connection')
|
||||||
|
] },
|
||||||
|
{ label: '3 Review and delete', title: '3 Schema change review and delete type', route: '/schema/:key (dialogs)', api: 'PUT /v1/content-type/{key}, DELETE /v1/content-type/{key}', auth: 'Owner or admin', note: 'The review opens on Save when the type already has entries (37 here). It is informational: the server decides compatibility. Destructive changes (removing a field or a choice, tightening a limit, making a field unique or required) carry a warning flag. Per-change entry counts and a full diff are later.', states: [
|
||||||
|
st('Review before saving', N, 'Save on a type that has entries.', { v: 'review', k: 'review' }, 'Review changes to Events · Apply them to save the content type. · Add 2 fields, change 1, remove 1 · Events has 37 entries. Each one is checked against the new fields when you apply. · Add Speaker · Text, optional. · Add Room · Choice, required, default "Main hall". Existing entries get the default, so they still fit. · Change Capacity · Highest value goes from 5000 to 2000. · Entries may not fit · Remove Description · Entries stop returning its values. · Removes data · Flagged changes can be refused if existing entries don\'t fit. If that happens, nothing is changed. · Back to editing · Apply changes'),
|
||||||
|
st('Applying', N, 'PUT in flight. Dialog cannot be dismissed.', { v: 'review', k: 'applying' }, 'Applying…'),
|
||||||
|
st('Refused (schema_incompatible)', N, '422 schema_incompatible. States that nothing changed and names the kind of change that likely caused it, never an entry.', { v: 'review', k: 'refused' }, 'Nothing was changed · RenewCMS refused the changes because some entries don\'t fit the new fields. It doesn\'t say which entries. · Your content type is as it was · No field was added, changed or removed. · The change that can cause this in your edit · Possible cause · This kind of change is refused when an entry is over the new limit, a new required field has no default, a field becomes unique while entries share a value, or a removed choice is still in use. · Discard changes · Back to editing'),
|
||||||
|
st('Review with counts and diff', L, 'Per-change entry counts and a full diff need an API that does not exist yet.', { v: 'review', k: 'later' }, 'Affects 0 entries · Affects 37 entries · Affects 3 entries · Affects 31 entries · View full diff · Per-change counts and diff come later', { notNow: true }),
|
||||||
|
st('Delete type: blocked', N, 'Delete content type on a type with entries. No confirm button.', { v: 'review', k: 'blocked' }, 'You can\'t delete Events yet · A content type can only be deleted when it has no entries. · Events has 37 entries · Delete all of its entries first, then delete the content type. · Close · View entries'),
|
||||||
|
st('Delete type: confirm', N, 'Delete content type on an empty type.', { v: 'review', k: 'confirm' }, 'Delete Recipes? · Recipes has no entries. Its fields and its API routes will be deleted. This can\'t be undone. · Key: recipes · Cancel · Delete content type'),
|
||||||
|
st('Delete type: server fallback (409)', N, '409 content_type_has_entries. An entry was added after the page loaded.', { v: 'review', k: 'fallback' }, 'Recipes now has entries · Someone added an entry after this page loaded, so nothing was deleted. Delete the entries first, then try again. · Close · View entries')
|
||||||
|
] },
|
||||||
|
{ label: '4 Field types', title: '4 Field-type gallery', route: 'reference board', api: '', auth: 'n/a', note: 'One frame per type: inspector options, the entry form control (default and open states), the list cell with its empty state, the error message for each rule, and the filter widget. Filters are later: filtering on custom fields is not in the API. Mobile drops the inspector column (the builder frames show it as a sheet).', states: ['text', 'richtext', 'number', 'boolean', 'date', 'select'].map((t, i) => st(['Text', 'Rich text', 'Number', 'Yes or no', 'Date', 'Choice'][i], N, 'type: "' + t + '"', { v: 'gallery', k: t }, ({ text: 'Text · Short text, like a title or a name · Shortest · Longest · characters · 74 / 80 · Fill in Title. · Title must be text. · Title needs at least 3 characters. · Title can be up to 80 characters. · Another entry already uses this Title. Enter a different one.', richtext: 'Rich text · Long text with formatting, written in Markdown · Write · Preview · Markdown. Switch to Preview to check the result. · 86 / 5000 · Not available for rich text. (Unique)', number: 'Number · Whole or decimal numbers · Lowest value · Highest value · Enter a number · Between 1 and 5000. · Description must be a number. · Capacity must be at least 1. · Capacity must be at most 5000.', boolean: 'Yes or no · An on or off switch · On by default · Yes or no fields have no limits. · Not available for yes or no fields. (Unique) · Choose yes or no for Online. · Online must be yes or no.', date: 'Date · A calendar day, without a time · Earliest date · Latest date · Pick a date · Pick a date for Starts on. · Starts on must be a date, like 2026-11-21. · Starts on can\'t be before 1 Jan 2026. · Starts on can\'t be after 31 Dec 2027.', select: 'Choice · One option from a list you define · Choices · Choose an option · Choose one of: Talk, Workshop or Meetup. · Another entry already uses this choice for Category.' })[t])) },
|
||||||
|
{ label: '5 Content and entry list', title: '5 Content navigation and entry list', route: '/content/:key', api: 'GET /v1/content-type/{key}, GET /v1/content-type/{key}/entry', auth: 'Any verified user', note: 'The sidebar gets a "Content" group, one item per custom type (a default icon: types have no icon in the API). With more than 5 types it shows the first 5 and the selected one, then "Show all N"; long names truncate with the full name in a tooltip. On mobile the group is one collapsed row in the drawer. List: ID, up to 6 chosen field columns (default: first 4), Created, Updated, Updated by. Sort: ID, Created, Updated, either direction (default Updated, newest first). Page size 5, 25, 50, 100 (default 25). No filter or search.', states: [
|
||||||
|
st('Populated', N, '/content/events. 7 entries, one page: no pager, only the count.', { v: 'entries', k: 'ok' }, 'Content · Events · events · 7 fields · New entry · Sort by · Last updated · Columns · Edit content type · ID · Title · Starts on · Ends on · Online · Created · Updated · Updated by · 7 entries'),
|
||||||
|
st('Paginated, 5 per page', N, '/content/events?size=5. 37 entries.', { v: 'entries', k: 'paged' }, 'Showing 1–5 of 37 · 5 per page · Page 1 of 8 (mobile)'),
|
||||||
|
st('Columns menu', N, 'The Columns button. Popover on desktop, sheet on mobile. Created and updated always show.', { v: 'entries', k: 'cols' }, 'Columns · 4 of 6 · Choose up to 6 fields to show. 4 selected. · Created and updated dates always show · Reset · Done'),
|
||||||
|
st('Many types in the sidebar', N, '14 custom types. Long names truncate. Mobile shows the drawer with the group expanded.', { v: 'entries', k: 'ok', many: true }, 'Content · Frequently asked questions and answers (truncated) · Show all 14 · All content types 14 (mobile, collapsed)'),
|
||||||
|
st('Loading', N, 'First load or retry.', { v: 'entries', k: 'loading' }, '(skeleton)'),
|
||||||
|
st('Empty for this type', N, 'Total is 0. The hint points to New entry.', { v: 'entries', k: 'empty' }, 'No entries yet · Use New entry to add the first event. Each entry follows the fields of this content type. · New entry'),
|
||||||
|
st('Error', N, '500 or network failure.', { v: 'entries', k: 'error' }, 'Couldn\'t load entries · GET /v1/content-type/events/entry failed. Check your connection and try again. · Try again'),
|
||||||
|
st('Forbidden (403)', N, 'The API answers 403. Unverified accounts are redirected to /register/pending before this.', { v: 'entries', k: 'forbidden', role: 'user' }, 'You don\'t have access to Events · Your account can\'t open this content right now. If you think this is a mistake, ask an owner or an admin. · Go to Home'),
|
||||||
|
st('Type not found', N, '404 content_type_not_found on /content/unknown.', { v: 'entries', k: 'notfound' }, 'Content type not found · It may have been deleted, or the address may be mistyped. · Go to Home')
|
||||||
|
] },
|
||||||
|
{ label: '6 Entry editor', title: '6 Entry editor', route: '/content/:key/new, /content/:key/:id', api: 'POST /v1/content-type/{key}/entry, GET/PUT/DELETE /v1/content-type/{key}/entry/{id}', auth: 'Any verified user', note: 'The form is generated from the schema, one control per field in schema order. Required fields carry * and the line "Fields marked * are required." Defaults are pre-filled on create and tagged Default. Saving replaces the whole entry.', states: [
|
||||||
|
st('New entry', N, '/content/events/new. Online is pre-filled from its default.', { v: 'entry', k: 'new' }, 'New entry · Events · Fields marked * are required. · Default · Each entry needs a different title. · Between 1 and 5000. · Choose an option · Pick a date · Save · Cancel · New entry · Not saved yet'),
|
||||||
|
st('Editing', N, '/content/events/42. Save is disabled until something changes.', { v: 'entry', k: 'edit' }, 'Lime plaster workshop · Events · Entry 42 · Saved · Delete · Cancel · Save · Created by · Updated by · Inès Duarte · 12 Oct 2026, 09:14 · Marta Alves · 15 Oct 2026, 11:02'),
|
||||||
|
st('Unsaved changes', N, 'Any edit.', { v: 'entry', k: 'dirty' }, 'Unsaved changes · 32 / 80'),
|
||||||
|
st('Markdown preview', N, 'The Preview tab of a rich text field.', { v: 'entry', k: 'preview' }, 'Write · Preview'),
|
||||||
|
st('Saving', N, 'PUT or POST in flight. Form dims.', { v: 'entry', k: 'saving' }, 'Saving…'),
|
||||||
|
st('Saved', N, '200.', { v: 'entry', k: 'saved' }, 'Saved at 11:02 · Entry saved · "Lime plaster workshop" is up to date.'),
|
||||||
|
st('Save failed', N, '500 or network failure. Values stay.', { v: 'entry', k: 'fail' }, 'Save failed · Couldn\'t save the entry · Something went wrong on our side. Your changes are still here. Try again in a moment. · Try again'),
|
||||||
|
st('Required fields empty', N, '422 invalid_entry: title: required, starts_on: required. The first field with an error takes focus.', { v: 'entry', k: 'required' }, 'Fill in Title. · Pick a date for Starts on. · Not saved'),
|
||||||
|
st('Rule errors', N, '422 invalid_entry for max, type, max and options. One rule per field is shown at a time.', { v: 'entry', k: 'rules' }, 'Title can be up to 80 characters. · 91 / 80 · Ends on must be a date, like 2026-11-21. · Capacity must be at most 5000. · Choose one of: Talk, Workshop or Meetup. · Not saved'),
|
||||||
|
st('Duplicate unique value (409)', N, '409 invalid_entry: title: unique.', { v: 'entry', k: 'unique' }, 'Not saved · Another entry already uses this title. Change it and save again. · Another entry already uses this Title. Enter a different one.'),
|
||||||
|
st('Entry not found', N, '404 entry_not_found, for example after a delete elsewhere.', { v: 'entry', k: 'notfound' }, 'Entry not found · It may have been deleted by someone else. · Back to Events'),
|
||||||
|
st('Schema changed since load', N, '422 invalid_entry: room: required, or unknown_field. The form is read-only until reloaded.', { v: 'entry', k: 'changed' }, 'The fields of Events changed · A field was removed and a new required field, Room, was added after you opened this form. Reload the form to continue. What you typed here can\'t be saved against the old fields. · Reload form'),
|
||||||
|
st('Delete entry: confirm', N, 'Delete.', { v: 'entry', k: 'delete' }, 'Delete this entry? · "Lime plaster workshop" will be deleted from Events. This can\'t be undone. · Cancel · Delete entry')
|
||||||
|
] },
|
||||||
|
{ label: '7 Relations', title: '7 Relation field and picker', route: '/schema/:key, /content/:key/:id', api: 'not built', auth: 'Owner or admin (picker), any verified user (form)', note: 'All later. The model reads as two sentences, with a live preview of both sides. Three shapes: one to one, one to many, many to many.', states: [
|
||||||
|
st('Relation in the builder', L, 'A seventh type, Relation, in the type gallery.', { v: 'rel', k: 'builder' }, 'Relation · Related content type · How are they connected? · One to one · Each Event has one Venue, and each Venue has one Event. · One to many · Each Event has one Venue. A Venue has many Events. · Many to many · Each Event has many Venues, and each Venue has many Events. · Each Event has one Venue · Each Venue has many Events · shown on Venue as the field "Events"'),
|
||||||
|
st('Relation in the entry form', L, 'Search and pick; chips for many.', { v: 'rel', k: 'form' }, 'Venue · Speakers · Search to add · An event can have many speakers.'),
|
||||||
|
st('Relation in the list', L, 'One chip, two chips and +N, or a dash.', { v: 'rel', k: 'list' }, 'One chip for a single relation. Up to two chips and a "+N" for many. Empty shows a dash.')
|
||||||
|
] },
|
||||||
|
{ label: '8 Permissions', title: '8 Permissions grid', route: '/settings/permissions', api: 'not built', auth: 'Owner or admin', note: 'All later. Rows are types, columns are actions, one role at a time. Built-in rows are locked. Mobile collapses to one card per type with four checkboxes.', states: [
|
||||||
|
st('Default: today\'s rules', L, 'Role User selected. Everything on, as today.', { v: 'perms', k: 'default' }, 'Permissions · What each role can do with each content type. · Today\'s rules are the starting point · Owners and admins edit the schema. Any verified user can read, add, edit and delete entries. · Owner · Admin · User · Read · Create · Update · Delete · Built-in types. RenewCMS sets these rules. · Schema (create, edit and delete content types): owners and admins only. Set by RenewCMS. · No changes · Discard · Save permissions'),
|
||||||
|
st('Changed, not saved', L, 'Two types restricted for the User role.', { v: 'perms', k: 'changed' }, '4 changes not saved · Changed')
|
||||||
|
] },
|
||||||
|
{ label: '9 Export and import', title: '9 Schema export and import', route: '/schema (dialogs)', api: 'not built', auth: 'Owner or admin', note: 'All later. Dev-to-production flow: export a file, import it elsewhere, preview, same compatibility review as group 3, explicit confirm.', states: [
|
||||||
|
st('Export', L, 'Export on /schema.', { v: 'xport', k: 'export' }, 'Export schema · Download the fields of your content types as a file. Entries aren\'t included. · All content types · 3 content types · One content type · Choose which one · File name · renewcms-schema.json · Import this file on another RenewCMS site to create the same content types. · Cancel · Download file'),
|
||||||
|
st('Import preview', L, 'A file was chosen.', { v: 'xport', k: 'import' }, 'Import schema · renewcms-schema.json · 3 content types · Create 1, change 1, no change 1 · Recipes · New content type with 4 fields. · Events · Add 2 fields, remove Description. Events has 37 entries on this site. · Removes data · Press mentions · Same as on this site. · Existing entries are checked against the changes when you import. If any entry doesn\'t fit, nothing is imported. · Cancel · Import schema'),
|
||||||
|
st('Import refused', L, 'The server answers schema_incompatible.', { v: 'xport', k: 'refused' }, 'Nothing was imported · Some entries on this site don\'t fit the imported fields. RenewCMS can\'t tell which entries. The flagged change can cause this. · Try again')
|
||||||
|
] }
|
||||||
|
]
|
||||||
|
|
||||||
|
export const tables = [
|
||||||
|
{ title: 'Scope: available now or later', tpl: '260px 110px minmax(0,1fr)', cols: ['Feature', 'Status', 'Notes'], rows: [
|
||||||
|
['1 Schema list, built-in rows, read-only view', 'Available now', 'Built-in list is static client data (see Needs backend)'],
|
||||||
|
['2 Schema builder, all states', 'Available now', ''],
|
||||||
|
['Live API preview', 'Available now', 'Built on the client from the field list'],
|
||||||
|
['Type-level and field-level validation', 'Available now', 'Same rules as the server, shown while typing'],
|
||||||
|
['3 Review step and Apply', 'Available now', 'Needs the entry total (see Needs backend)'],
|
||||||
|
['3 Refusal state, delete type, blocked delete', 'Available now', ''],
|
||||||
|
['3 Per-change entry counts, full diff', 'Later', 'The API does not return them'],
|
||||||
|
['4 Field-type gallery', 'Available now', 'Filter widgets inside it are Later'],
|
||||||
|
['5 Content group in the sidebar, entry list', 'Available now', 'No filter or search on custom fields'],
|
||||||
|
['6 Entry editor, all states', 'Available now', ''],
|
||||||
|
['7 Relation field and picker', 'Later', ''],
|
||||||
|
['8 Permissions grid', 'Later', ''],
|
||||||
|
['9 Schema export and import', 'Later', ''],
|
||||||
|
['Filters on custom fields', 'Later', ''],
|
||||||
|
['Publish and draft state for entries', 'Later', 'Not drawn'],
|
||||||
|
['Public (anonymous) read access', 'Later', 'Not drawn'],
|
||||||
|
['Activity log entries for types and entries', 'Later', 'Not drawn']] },
|
||||||
|
{ title: 'Routes', tpl: '190px 280px 200px minmax(0,1fr)', cols: ['Route', 'Screen', 'Auth', 'API'], rows: [
|
||||||
|
['/schema', 'Schema list', 'Owner or admin. Others get the 403 page', 'GET /v1/content-type'],
|
||||||
|
['/schema/new', 'Schema builder, new type', 'Owner or admin', 'POST /v1/content-type'],
|
||||||
|
['/schema/:key', 'Schema builder (custom type) or read-only view (built-in)', 'Owner or admin', 'GET, PUT, DELETE /v1/content-type/{key}'],
|
||||||
|
['/content/:key', 'Entry list', 'Any verified user', 'GET /v1/content-type/{key}, GET /v1/content-type/{key}/entry'],
|
||||||
|
['/content/:key/new', 'New entry', 'Any verified user', 'POST /v1/content-type/{key}/entry'],
|
||||||
|
['/content/:key/:id', 'Entry editor', 'Any verified user', 'GET, PUT, DELETE /v1/content-type/{key}/entry/{id}'],
|
||||||
|
['/settings/permissions (later)', 'Permissions grid', 'Owner or admin', 'Not built'],
|
||||||
|
['/content/:key query', 'page, size (5, 25, 50, 100; default 25), sort (id, created_at, updated_at) and dir (asc, desc); default updated_at desc', 'Any verified user', 'Defaults omitted from the URL. Parameter names sort and dir are an assumption']] },
|
||||||
|
{ title: 'New components and their Nuxt UI mapping', tpl: '210px 110px 330px minmax(0,1fr)', cols: ['Component', 'Status', 'Nuxt UI', 'Why an existing one does not fit'], rows: [
|
||||||
|
['SchemaTable', 'New', 'UTable; below 640 px a list of UCard', 'Same pattern as SitesTable. Built-in rows add the lock and a Built-in badge'],
|
||||||
|
['BuiltInBadge', 'New', 'UBadge outline + lock icon', 'Existing badges have no lock; same size as the Linked badge'],
|
||||||
|
['FieldList', 'New', 'List of UButton rows, useSortable, UBadge', 'No Nuxt UI sortable list. Keyboard: the grip is a button; Space picks up, arrows move, Space drops, Esc cancels. Move up and Move down buttons on the selected row. Same approach as AuthorList'],
|
||||||
|
['FieldInspector', 'New', 'UForm, UFormField, UInput, UInputNumber, UInputDate, USelect, UTextarea, USwitch', 'Composition only. Controls follow the field type'],
|
||||||
|
['FieldTypePicker', 'New', 'URadioGroup variant="card" in a grid, icon in the label', 'Existing radio cards fit; only the icon and the lock state are added'],
|
||||||
|
['KeyField', 'Existing', 'SlugField (UInput + UButton + UBadge)', 'Reused for the type key and field keys. Locked variant already exists'],
|
||||||
|
['ChoicesEditor', 'New', 'UInput + UButton rows, useSortable', 'UInputTags can\'t reorder or flag a blank or duplicate per row'],
|
||||||
|
['ApiPreview', 'New', 'UCollapsible + CodeSnippet + UButton', 'CodeSnippet is reused. Sheet on mobile'],
|
||||||
|
['ChangeReview', 'New', 'UModal (sheet on mobile), UBadge, list rows', 'Dialog pattern unchanged. Rows reuse the UAlert-style row of SuggestionList'],
|
||||||
|
['SchemaRefusal, DeleteTypeDialog', 'New', 'UModal + UAlert', 'Dialog pattern unchanged. Delete uses color="error"'],
|
||||||
|
['ContentNavGroup', 'New', 'UNavigationMenu group "Content" (NavGroup)', 'Adds the 5 + selected cap, "Show all N" and the collapsed mobile row'],
|
||||||
|
['EntryTable', 'New', 'UTable with column visibility', 'Cells per field type (FieldCell). Cards on mobile'],
|
||||||
|
['ColumnPicker', 'New', 'UPopover + UCheckbox list; bottom sheet on mobile', 'UDropdownMenu closes on each pick and has no cap message'],
|
||||||
|
['SortSelect', 'New', 'USelect + UButton icon', 'ID, Created and Updated only'],
|
||||||
|
['FieldControl', 'New', 'UInput, UInputNumber, UInputDate, USelect, USwitch (the single toggle), MarkdownField', 'One switch per field type, generated from the schema'],
|
||||||
|
['MarkdownField', 'New', 'UTextarea + UTabs (Write, Preview) + toolbar of UButton', 'The article editor is a full document editor. This is a compact one'],
|
||||||
|
['FieldCell', 'New', 'text, UBadge outline (choice), icon + text (yes or no)', 'Renders one value per type, with the empty dash'],
|
||||||
|
['EntryForm', 'New', 'UForm generated from the schema', 'Provenance footer, schema-changed alert'],
|
||||||
|
['RelationPicker (later)', 'New', 'URadioGroup card + USelect', 'Sentence preview is text'],
|
||||||
|
['RelationField (later)', 'New', 'UInputMenu multiple', 'Chips and search'],
|
||||||
|
['PermissionsGrid (later)', 'New', 'UTable + UCheckbox; UCard list on mobile', ''],
|
||||||
|
['SchemaImportDialog (later)', 'New', 'UModal + UFileUpload', '']] },
|
||||||
|
{ title: 'Errors to copy', tpl: '300px 70px minmax(0,1fr)', cols: ['Error', 'HTTP', 'Copy and place'], rows: [
|
||||||
|
['content_type_not_found', '404', 'Page: "Content type not found · It may have been deleted, or the address may be mistyped."'],
|
||||||
|
['entry_not_found', '404', 'Page: "Entry not found · It may have been deleted by someone else."'],
|
||||||
|
['content_type_key_taken', '409', 'Under the key: "The key "events" is already used by another content type. Choose a different key."'],
|
||||||
|
['content_type_has_entries', '409', 'Delete dialog: "Recipes now has entries · Someone added an entry after this page loaded, so nothing was deleted. Delete the entries first, then try again."'],
|
||||||
|
['invalid_content_type_key', '400', 'Under the key: "Use 2 to 40 lowercase letters, digits or hyphens."'],
|
||||||
|
['invalid_field', '400', 'On the marked field, per rule (label, key, limits, default, choices). If the client missed one: "Check the fields marked in the list."'],
|
||||||
|
['schema_incompatible', '422', 'Refusal dialog: "Nothing was changed · RenewCMS refused the changes because some entries don\'t fit the new fields. It doesn\'t say which entries."'],
|
||||||
|
['invalid_entry: key: required', '422', '"Fill in Title." (date: "Pick a date for Starts on.", choice: "Choose an option for Category.")'],
|
||||||
|
['invalid_entry: key: type', '422', '"Title must be text." / "must be a number." / "must be yes or no." / "must be a date, like 2026-11-21."'],
|
||||||
|
['invalid_entry: key: min, max', '422', 'Text: "needs at least N characters", "can be up to N characters". Number: "must be at least N", "must be at most N". Date: "can\'t be before D", "can\'t be after D"'],
|
||||||
|
['invalid_entry: key: options', '422', '"Choose one of: Talk, Workshop or Meetup."'],
|
||||||
|
['invalid_entry: key: unique', '409', '"Another entry already uses this Title. Enter a different one." plus the alert "Not saved"'],
|
||||||
|
['invalid_entry: key: unknown_field', '422', 'Alert "The fields of Events changed", button Reload form'],
|
||||||
|
['401', '401', 'Existing: redirect to /login?redirect=…&reason=expired'],
|
||||||
|
['403', '403', 'Unverified: redirect to /register/pending. Wrong role on /schema: "You don\'t have access to Schema"'],
|
||||||
|
['400, 500', '', 'Existing table. Saves: "Couldn\'t save the … Something went wrong on our side. Your changes are still here. Try again in a moment."']] },
|
||||||
|
{ title: 'Needs backend', tpl: '40px minmax(0,1fr)', cols: ['#', 'What the design assumes'], rows: [
|
||||||
|
['1', 'Total number of entries of a type (X-Total-Count or a body field, as on Articles). Used for pagination, "37 entries use this content type" and the blocked delete.'],
|
||||||
|
['2', 'Names of the sort and direction parameters on the entry list (drawn as sort and dir).'],
|
||||||
|
['3', 'Built-in types and their fields. GET /v1/content-type returns custom types only, so the read-only views use static client data, or a new read-only endpoint.'],
|
||||||
|
['4', 'created_by and updated_by are user ids. Any verified user needs their display names, but the user list is not open to all roles. Needs a name in the entry response or a light lookup.'],
|
||||||
|
['5', 'A per-type icon does not exist. The sidebar uses one default icon.'],
|
||||||
|
['6', 'The exact alphabet of the type key ("lowercase slug"). Drawn as letters, digits and hyphens.'],
|
||||||
|
['7', 'Locked field keys and types of saved fields are a client rule: a PUT with a changed key would remove one field and add another. The server does not reject it.'],
|
||||||
|
['8', 'No signal that the schema changed while a form was open. The design relies on 422 invalid_entry (required, unknown_field) on save.'],
|
||||||
|
['9', 'default of false versus no default for a yes or no field: needs null for "no default".'],
|
||||||
|
['10', 'Later: per-change entry counts and diff, relations, permissions, export and import, filters on custom fields, publish and draft, public read, activity log.']] },
|
||||||
|
{ title: 'Decisions to confirm', tpl: '40px minmax(0,1fr)', cols: ['#', 'Decision'], rows: [
|
||||||
|
['1', 'Field keys and types lock per field once saved. Fields added since the last save can still change both.'],
|
||||||
|
['2', 'The review opens only when the type has entries. A type without entries saves directly.'],
|
||||||
|
['3', 'A new type starts with one field (Title), because a type needs at least one field.'],
|
||||||
|
['4', 'Yes or no is the label for the boolean type, Choice for select, Rich text for richtext.'],
|
||||||
|
['5', 'Required fields carry * and the line "Fields marked * are required." Optional fields carry nothing, unlike the "(optional)" tag used in settings forms, because most fields are optional here.'],
|
||||||
|
['6', 'The Schema item sits after Users in the sidebar. The Content group sits under the main items.'],
|
||||||
|
['7', 'The list shows 4 field columns by default and at most 6.']] },
|
||||||
|
{ title: 'Accessibility', tpl: '220px minmax(0,1fr)', cols: ['Topic', 'Rule'], rows: [
|
||||||
|
['Reorder', 'The grip is a button with aria-label "Move Ends on". Space picks up, Up and Down move, Space drops, Esc cancels, with a polite live region ("Moving Ends on. Position 2 of 7."). Move up, Move down and Remove buttons exist on the selected row and in the mobile sheet.'],
|
||||||
|
['Focus order', 'Name, key, Save, Discard, field list (one tab stop, arrows move between rows), inspector top to bottom, API preview. Opening the inspector sheet on mobile moves focus to its title; closing returns it to the row.'],
|
||||||
|
['Focus', '2 px --ui-primary, offset 2 px, as everywhere.'],
|
||||||
|
['Labels and errors', 'Every control has a visible label. Error text is tied to its input with aria-describedby and aria-invalid. Rows with errors also show an icon, not only a red border.'],
|
||||||
|
['Type picker', 'A radio group. The locked state sets aria-disabled and the line "Can\'t change after saving" is read with the group.'],
|
||||||
|
['Counters', 'Character counters are polite live regions on pause, not on every key.'],
|
||||||
|
['Contrast', 'Tokens already computed in foundations.md. Muted text is never placed on sand; selected tiles use --rc-on-sand for their description.'],
|
||||||
|
['Touch', 'Controls are 44 px on mobile. The inspector, columns and API preview are bottom sheets.']] }
|
||||||
|
]
|
||||||
@@ -54,6 +54,19 @@ Glossary pass 2, changes made: activity log lines "Removed N expired invites" an
|
|||||||
| 404 site_not_found (sitemap) | GET /v1/site/{key}/sitemap, unknown key. The body is the code `site_not_found`, shown on Integration in the code style (not rewritten as prose). |
|
| 404 site_not_found (sitemap) | GET /v1/site/{key}/sitemap, unknown key. The body is the code `site_not_found`, shown on Integration in the code style (not rewritten as prose). |
|
||||||
| 400 (sitemap page or size) | "The request cannot be processed due to a mismatch in the format of the body." (same text as the other routes on Integration) |
|
| 400 (sitemap page or size) | "The request cannot be processed due to a mismatch in the format of the body." (same text as the other routes on Integration) |
|
||||||
| 403 owner_only vs article SEO | Integration note under the 403 row: "Site SEO defaults are owner only. Article SEO fields need only the right to edit the article." |
|
| 403 owner_only vs article SEO | Integration note under the 403 row: "Site SEO defaults are owner only. Article SEO fields need only the right to edit the article." |
|
||||||
|
| 404 content_type_not_found | Page: "Content type not found · It may have been deleted, or the address may be mistyped." |
|
||||||
|
| 404 entry_not_found | Page: "Entry not found · It may have been deleted by someone else." |
|
||||||
|
| 409 content_type_key_taken | Under the key: "The key "events" is already used by another content type. Choose a different key." |
|
||||||
|
| 409 content_type_has_entries | Delete dialog: "Recipes now has entries · Someone added an entry after this page loaded, so nothing was deleted. Delete the entries first, then try again." |
|
||||||
|
| 400 invalid_content_type_key | Under the key: "Use 2 to 40 lowercase letters, digits or hyphens." |
|
||||||
|
| 400 invalid_field | On the marked field, per rule (label, key, limits, default, choices). If the client missed one: "Check the fields marked in the list." |
|
||||||
|
| 409 schema_incompatible | Refusal dialog: "Nothing was changed · RenewCMS refused the changes because some entries don't fit the new fields. It doesn't say which entries." |
|
||||||
|
| 422 invalid_entry: key: required | "Fill in Title." (date: "Pick a date for Starts on.", choice: "Choose an option for Category.") |
|
||||||
|
| 422 invalid_entry: key: type | "Title must be text." / "must be a number." / "must be yes or no." / "must be a date, like 2026-11-21." |
|
||||||
|
| 422 invalid_entry: key: min, max | Text: "needs at least N characters", "can be up to N characters". Number: "must be at least N", "must be at most N". Date: "can't be before D", "can't be after D" |
|
||||||
|
| 422 invalid_entry: key: options | "Choose one of: Talk, Workshop or Meetup." |
|
||||||
|
| 409 invalid_entry: key: unique | "Another entry already uses this Title. Enter a different one." plus the alert "Not saved" |
|
||||||
|
| 422 invalid_entry: key: unknown_field | Alert "The fields of Events changed", button Reload form |
|
||||||
| 500 – | "Couldn't save the site. Your changes are still here. Try again in a moment." / "Couldn't save the author. Your changes are still here. Try again in a moment." / "Couldn't create the author" / "The article is unchanged. Try again in a moment." (notice inside the panel or dialog) |
|
| 500 – | "Couldn't save the site. Your changes are still here. Try again in a moment." / "Couldn't save the author. Your changes are still here. Try again in a moment." / "Couldn't create the author" / "The article is unchanged. Try again in a moment." (notice inside the panel or dialog) |
|
||||||
|
|
||||||
## Public image 400 bodies
|
## Public image 400 bodies
|
||||||
@@ -79,3 +92,15 @@ screens/<name>.md, section "Copy per state" (generated from the board source).
|
|||||||
|
|
||||||
## SEO round
|
## SEO round
|
||||||
Frames: Editor: search and sharing (24), Editor: what the frontend gets (6), Sites: SEO defaults (5), Articles: search status (3). Each is drawn desktop and mobile; light and dark follow the theme switch. None is clock-sensitive: dates in the API preview are fixed sample values. Disabled state: all fields at 40% while saving. Validation: slug taken, canonical not absolute, title template without %title%, excerpt over 300. Suggestions never block saving or publishing. Copy per state: screens/editor.md, articles.md, sites.md.
|
Frames: Editor: search and sharing (24), Editor: what the frontend gets (6), Sites: SEO defaults (5), Articles: search status (3). Each is drawn desktop and mobile; light and dark follow the theme switch. None is clock-sensitive: dates in the API preview are fixed sample values. Disabled state: all fields at 40% while saving. Validation: slug taken, canonical not absolute, title template without %title%, excerpt over 300. Suggestions never block saving or publishing. Copy per state: screens/editor.md, articles.md, sites.md.
|
||||||
|
|
||||||
|
|
||||||
|
## Custom content types
|
||||||
|
Groups on `RenewCMS Screens Schema.dc.html`: 1 Schema list (6), 2 Schema builder (16), 3 Review and delete (7), 4 Field types (6), 5 Content and entry list (9), 6 Entry editor (13), 7 Relations (3), 8 Permissions (2), 9 Export and import (3). 65 states, 56 available now and 9 later. Each has a 1440 and a 390 frame; light and dark follow the theme switch. Nothing is clock-sensitive. Tag per state: "Available now" or "Later". Copy per state: screens/schema.md, content.md, entry.md, schema-later.md.
|
||||||
|
|
||||||
|
Roles: owners and admins see Schema and edit types. Any verified user sees the Content group and reads and writes entries. A user who is not owner or admin and opens /schema gets "You don't have access to Schema".
|
||||||
|
|
||||||
|
Keys: a type key is 2 to 40 lowercase letters, digits and hyphens (chosen at creation, never changes). A field key is lowercase letters, digits and underscores, starts with a letter, up to 40 (editable until its field is first saved). The two inputs show their own rule.
|
||||||
|
|
||||||
|
Schema changes with entries: Save opens the review ("Add 2 fields, change 1, remove 1"); Apply sends the PUT; schema_incompatible shows "Nothing was changed" and names the kind of change that likely caused it, never an entry. A type with entries cannot be deleted ("You can't delete Events yet").
|
||||||
|
|
||||||
|
Entry rules, 422 except unique (409): required, type, min, max, options, unique, unknown_field. Messages are per type and drawn in the field-type gallery (group "Field types").
|
||||||
Reference in new issue
Block a user