smoodly 0.0.7 → 0.0.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +224 -4
- package/dist/admin/client-ops.js +1 -0
- package/dist/admin/editor/EditorView.js +64 -8
- package/dist/admin/forms/FieldWidget.d.ts +7 -3
- package/dist/admin/forms/FieldWidget.js +85 -7
- package/dist/admin/forms/list.d.ts +18 -0
- package/dist/admin/forms/list.js +81 -0
- package/dist/admin/index.d.ts +3 -2
- package/dist/admin/index.js +1 -0
- package/dist/admin/ops-impl.js +84 -1
- package/dist/admin/ops.d.ts +22 -1
- package/dist/admin/richtext.d.ts +13 -11
- package/dist/admin/richtext.js +94 -13
- package/dist/admin/serialize.d.ts +15 -0
- package/dist/admin/serialize.js +11 -0
- package/dist/admin/shared-items.d.ts +9 -0
- package/dist/admin/shared-items.js +61 -0
- package/dist/admin/shell/AdminApp.js +8 -2
- package/dist/admin/shell/EntryForm.js +1 -1
- package/dist/admin/shell/SharedForm.d.ts +7 -0
- package/dist/admin/shell/SharedForm.js +146 -0
- package/dist/admin/shell/SharedList.d.ts +6 -0
- package/dist/admin/shell/SharedList.js +62 -0
- package/dist/admin/shell/shared-list.d.ts +14 -0
- package/dist/admin/shell/shared-list.js +17 -0
- package/dist/admin/tree-ops.d.ts +12 -5
- package/dist/admin/tree-ops.js +39 -8
- package/dist/admin/ui/theme.d.ts +1 -1
- package/dist/admin/ui/theme.js +13 -0
- package/dist/admin/validate.d.ts +7 -2
- package/dist/admin/validate.js +43 -13
- package/dist/collections.d.ts +4 -1
- package/dist/config.d.ts +3 -0
- package/dist/config.js +35 -1
- package/dist/entry-store.d.ts +9 -1
- package/dist/entry-store.js +32 -4
- package/dist/fields.d.ts +16 -1
- package/dist/fields.js +49 -4
- package/dist/index.d.ts +8 -1
- package/dist/index.js +7 -1
- package/dist/next/page-renderer.d.ts +4 -0
- package/dist/next/page-renderer.js +14 -1
- package/dist/page.js +5 -1
- package/dist/refs.js +13 -0
- package/dist/resolve.d.ts +3 -0
- package/dist/resolve.js +41 -0
- package/dist/revalidate.d.ts +4 -0
- package/dist/revalidate.js +4 -0
- package/dist/richtext-render.d.ts +3 -0
- package/dist/richtext-render.js +62 -0
- package/dist/shared-id.d.ts +3 -0
- package/dist/shared-id.js +36 -0
- package/dist/shared.d.ts +46 -0
- package/dist/shared.js +36 -0
- package/dist/site.d.ts +10 -0
- package/dist/site.js +28 -1
- package/dist/sql-space.d.ts +5 -1
- package/dist/sql-space.js +9 -10
- package/dist/sql.js +1 -19
- package/dist/supabase-entry-store.d.ts +9 -1
- package/dist/supabase-entry-store.js +60 -7
- package/dist/zones.d.ts +6 -2
- package/dist/zones.js +8 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -174,8 +174,9 @@ Implemented:
|
|
|
174
174
|
for self-host: the anon key now reads published rows straight off the
|
|
175
175
|
base tables (`pages` and `entries` published in any locale, a published
|
|
176
176
|
`page_locales`/`entry_locales` row, the `page_versions`/`entry_versions`
|
|
177
|
-
row a published locale points at
|
|
178
|
-
the generated
|
|
177
|
+
row a published locale points at — shared content included, since it
|
|
178
|
+
is stored as entries, spec 2026-09-07), not only through the generated
|
|
179
|
+
views.
|
|
179
180
|
|
|
180
181
|
- Admin login: `supabaseAdminAuth` + `gateAdminOp` around the ops seam;
|
|
181
182
|
`LoginView` + `useAdminSession` in the shell; `createSmoodlyAdmin({ auth })`.
|
|
@@ -301,8 +302,40 @@ Implemented:
|
|
|
301
302
|
the only way the node gets there — it also covers pages that existed
|
|
302
303
|
before a locked zone was added to their schema.
|
|
303
304
|
|
|
304
|
-
|
|
305
|
-
|
|
305
|
+
- Shared content (spec 2026-09-07): `smoodly.shared({ name, title, section })`
|
|
306
|
+
registers a visual item, `smoodly.shared({ name, title, fields })` an
|
|
307
|
+
object item; one registration materializes as a singleton entry in
|
|
308
|
+
every supported locale. `site.getShared(item, { locale })` and the Next
|
|
309
|
+
glue's `getSmoodlyShared(item, { locale })` fetch it code-side; the
|
|
310
|
+
editor places a visual item in a zone from the "+" picker as a linked
|
|
311
|
+
`{ type: "shared", fields: { item, ref } }` node the resolve pass
|
|
312
|
+
rewrites into the section, and `z.locked(footer)` binds a zone to one.
|
|
313
|
+
Edited on its own admin page under Shared content (`/admin/shared/<name>`),
|
|
314
|
+
with "used on N pages" from `refs`. Wired through the `shared` option
|
|
315
|
+
on the entry store and the site layer.
|
|
316
|
+
|
|
317
|
+
- Lists and editable objects (spec 2026-09-07 list field): `f.list(item)`
|
|
318
|
+
holds any builder but another list and refuses a ref or `.localized()`
|
|
319
|
+
anywhere inside (refs are top-level only; localization is whole-value on
|
|
320
|
+
a list or object). `.min`/`.max` count items. The admin edits an object
|
|
321
|
+
as an indented fieldset and a list as rows — summary, collapse, move up
|
|
322
|
+
and down, remove, "N of max", Add disabled at max — recursively through
|
|
323
|
+
`FieldWidget`, with the row logic as pure functions in `admin/forms/list.ts`.
|
|
324
|
+
`validateFields` recurses with dotted paths (`points.2.heading`) and
|
|
325
|
+
checks list length; the serializer ships a list's `item`.
|
|
326
|
+
|
|
327
|
+
- Richtext renders and round-trips: `RichText` (package root, pure React,
|
|
328
|
+
no wrapper) turns the TipTap document into paragraphs, headings, lists,
|
|
329
|
+
blockquotes, hard breaks and bold/italic/link marks, unknown nodes
|
|
330
|
+
degrading to their children. The interim textarea widget speaks a line
|
|
331
|
+
syntax (`#` headings, `- ` and `1. ` lists, `> ` quotes, blank line
|
|
332
|
+
between paragraphs, hard break between adjacent lines) over the same
|
|
333
|
+
JSON, so seeded structure survives an edit.
|
|
334
|
+
A link mark reaches the page only with a safe scheme (http(s), mailto,
|
|
335
|
+
tel, site-relative).
|
|
336
|
+
|
|
337
|
+
Not yet: the TipTap richtext widget (the textarea's line syntax is the
|
|
338
|
+
interim editor; `RichText` renders), Elements in richtext, drag-and-drop
|
|
306
339
|
reordering (move-up/move-down buttons in the editor, Move up / Move down
|
|
307
340
|
in the Pages list's row menu), undo/redo, sample/placeholder content
|
|
308
341
|
marking, per-locale section visibility conditions (OQ #3 — each locale
|
|
@@ -323,6 +356,193 @@ Both packages move together. From the repo root:
|
|
|
323
356
|
`examples/site` in that build, so the template always matches the example
|
|
324
357
|
site at the tagged commit.
|
|
325
358
|
|
|
359
|
+
## Follow-ups (logged 2026-09-07, starter)
|
|
360
|
+
|
|
361
|
+
- A paired component (`smoodly.section`) accepts only its schema's props, so
|
|
362
|
+
a layout cannot hand the footer the contact item or the header the locale
|
|
363
|
+
links through the paired `Footer`/`Header`; the starter's layouts render
|
|
364
|
+
`FooterView`/`HeaderView` directly with the extra prop. A supported way
|
|
365
|
+
to pass layout-only props through a paired component (or a `children`
|
|
366
|
+
slot) would let the layouts use the registered component.
|
|
367
|
+
- Page views and entry pages receive no `locale`; the starter's views get
|
|
368
|
+
it from the app's strings seam (`next-intl`'s `getLocale`, or a constant
|
|
369
|
+
in the single-language scaffold). Adding `locale` to `PageContext` and
|
|
370
|
+
`EntryPageProps` would remove that dependency and let sections that
|
|
371
|
+
format dates stay synchronous.
|
|
372
|
+
- The content API is published-only, so the Articles index in the editor
|
|
373
|
+
canvas lists published articles while the hero above it is the draft.
|
|
374
|
+
A draft-aware `getEntries` for draft mode is the fix if that confuses.
|
|
375
|
+
- The image widget edits the URL only; `alt` is set by the seed and cannot
|
|
376
|
+
be changed in the admin.
|
|
377
|
+
- A per-locale collection path or fixed slug must name the default locale;
|
|
378
|
+
the installer rewrites the starter's two lines to name exactly the
|
|
379
|
+
chosen locales (`articlesSegments` in `scaffold.ts`), since a path
|
|
380
|
+
claiming a locale the site lacks fails the index-page rule at config
|
|
381
|
+
load. A "fall back to the first declared segment" rule in
|
|
382
|
+
`defineConfig` would make the patch unnecessary.
|
|
383
|
+
- The picker offers a shared item only in zones whose allow list includes
|
|
384
|
+
its section (`allowedShared` in `admin/tree-ops.ts`), so a shared item's
|
|
385
|
+
section is always also offered as an ordinary section. A "placeable
|
|
386
|
+
only" mode (a shared item whose section no zone lists) would let the
|
|
387
|
+
starter keep the quote shared-only.
|
|
388
|
+
- Registry names are one namespace across sections, elements, collections
|
|
389
|
+
and page shapes (`assertUniqueNames`); the starter names its People
|
|
390
|
+
section `peopleSection` and its Articles page `articlesIndex` to stay
|
|
391
|
+
clear of the collections. Namespacing by kind would let a section and a
|
|
392
|
+
collection share a name.
|
|
393
|
+
- The seed's early exit probes `/` alone, and `createPage` writes the path
|
|
394
|
+
row before the tree is published, so a crash between the home record and
|
|
395
|
+
the last page leaves a database every later run reports as "already
|
|
396
|
+
seeded"; recovery is `supabase db reset`. A probe on the last thing the
|
|
397
|
+
seed writes, or a per-step check, would make it resumable.
|
|
398
|
+
- With `fi` as the default locale the seed writes the English content into
|
|
399
|
+
`fi`. Before this fix the index page was seeded at `posts`
|
|
400
|
+
while the collection's `fi` path is `artikkelit`, so `ensureFixedNodes`
|
|
401
|
+
materialized a second, empty Articles page at `artikkelit` and the
|
|
402
|
+
links pointed at `/posts`; the seed now derives the segment from the
|
|
403
|
+
collection's `path` per locale. Left: the starter's copy in a `fi`
|
|
404
|
+
default is still English.
|
|
405
|
+
- Config-level section style defaults (`styles.sections` in `defineConfig`)
|
|
406
|
+
are not filled at render: a node saved without an explicit style emits
|
|
407
|
+
no `data-spacing`/`data-tone`, so a stylesheet cannot key the default
|
|
408
|
+
on the attribute. The starter pads body sections by element instead.
|
|
409
|
+
Filling the resolved defaults in the render pass (as the admin form
|
|
410
|
+
does) would make the attributes reliable (walk, 2026-09-07).
|
|
411
|
+
- A new list item's `select` subfield shows the first option but stores
|
|
412
|
+
nothing (`emptyValue` leaves it undefined), so publish fails with
|
|
413
|
+
`points.3.icon: Required` until the editor re-picks the value that is
|
|
414
|
+
already on screen. Seed the first option, or render a blank option so
|
|
415
|
+
the screen matches the value (walk, 2026-09-07).
|
|
416
|
+
- Publish validates the last saved draft, so a click inside the autosave
|
|
417
|
+
debounce reports fields the editor has just filled as `Required`. Flush
|
|
418
|
+
the pending save before validating, or disable Publish while a save is
|
|
419
|
+
pending (walk, 2026-09-07).
|
|
420
|
+
- The entry form cannot republish a published entry's newer draft: Save
|
|
421
|
+
writes a new draft version, the status control's `setPublished` returns
|
|
422
|
+
early when the status is unchanged, and nothing shows that the draft
|
|
423
|
+
differs from the published version. The workaround unpublishes and
|
|
424
|
+
republishes, which 404s the entry in between. Needs a "Publish changes"
|
|
425
|
+
action and a draft-edits indicator like the page editor's (walk,
|
|
426
|
+
2026-09-07).
|
|
427
|
+
- Versions accumulate (`entry_versions` grew to four rows after one edit)
|
|
428
|
+
but no admin screen lists or restores them (walk, 2026-09-07).
|
|
429
|
+
- A list row's collapsed summary shows the item's first string subfield —
|
|
430
|
+
the header nav rows read `/`, `/posts` — where the label would identify
|
|
431
|
+
the row; `itemSummary` could prefer a `label`/`title`/`heading` key
|
|
432
|
+
(walk, 2026-09-07).
|
|
433
|
+
- New page / New folder ask for the slug through `window.prompt`, which
|
|
434
|
+
browser automation blocks and which cannot validate as you type; an
|
|
435
|
+
inline field would do both (walk, 2026-09-07).
|
|
436
|
+
|
|
437
|
+
## Follow-ups (logged 2026-09-07, list field spec)
|
|
438
|
+
|
|
439
|
+
- Saving the interim richtext textarea drops marks (bold, italic, link);
|
|
440
|
+
flattens a hard break inside a heading, list item or blockquote line to a
|
|
441
|
+
space (a prefixed line cannot carry one, DESIGN.md §3 Richtext); and drops
|
|
442
|
+
anything the line syntax cannot express at all — nested lists,
|
|
443
|
+
multi-paragraph list items (joined with a space instead), and unknown
|
|
444
|
+
nodes such as a horizontal rule. All of it goes away with the TipTap
|
|
445
|
+
widget.
|
|
446
|
+
- Refs inside lists and objects are refused at build time (`f.list`,
|
|
447
|
+
`f.object`). A list of objects each holding a ref (a featured entry with
|
|
448
|
+
its own caption) needs the resolver, the refs index and the admin's
|
|
449
|
+
ref-option loading to address nested paths; add when a site asks.
|
|
450
|
+
- Heading levels 4–6 render but the line syntax writes `####`…`######`
|
|
451
|
+
for them; the spec names 1–3 as the supported set.
|
|
452
|
+
- `emptyValue` for an `image`/`video`/`file` item is `undefined`, so a new
|
|
453
|
+
media row starts empty; a required image item is then a validation
|
|
454
|
+
error until filled, which is the intended nudge but reads as an error
|
|
455
|
+
on a row the editor just added.
|
|
456
|
+
- `RichText` drops the anchor of a link mark whose href is not http(s),
|
|
457
|
+
mailto, tel or site-relative (`/`, `#`, `?`, `.`). The href is normalised
|
|
458
|
+
the way URL parsers do (tab, LF and CR removed) before the scheme test;
|
|
459
|
+
`javascript:`, `data:`, protocol-relative `//host` and the backslash form
|
|
460
|
+
`/\host` are rejected. The admin's link field does not validate on input yet.
|
|
461
|
+
- The list widget has no test for an object item holding a nested list
|
|
462
|
+
subfield.
|
|
463
|
+
- A list item's subtree still uses the row index as its React key, so a list
|
|
464
|
+
of objects each holding a list hands the inner rows another item's collapse
|
|
465
|
+
state when an outer row moves; fix with a stable per-item key when nested
|
|
466
|
+
lists get real use.
|
|
467
|
+
|
|
468
|
+
## Follow-ups (logged 2026-09-07, shared content spec)
|
|
469
|
+
|
|
470
|
+
- `f.shared(() => item)` — a ref field on a section that renders a shared
|
|
471
|
+
item inside the section's own markup (spec 2026-09-07 decision 3).
|
|
472
|
+
- Instance mode: a shared *type* with many editor-created items; today
|
|
473
|
+
the answer is a collection plus a section with `f.ref` (decision 1).
|
|
474
|
+
- Zone-shaped shared items (`smoodly.shared({ zone })`) (decision 2).
|
|
475
|
+
- Standalone live preview on a visual item's admin page — needs a
|
|
476
|
+
preview route in the glue; the page canvas shows placements today (§6).
|
|
477
|
+
- Built-in meta on shared items and on placements (anchor per placement
|
|
478
|
+
is the likely shape).
|
|
479
|
+
- Per-placement overrides (a shared band with a per-page heading) — out
|
|
480
|
+
of scope; use a normal section with `f.ref` fields.
|
|
481
|
+
- `fillLockedZones` for a zone bound to a shared item waits for
|
|
482
|
+
`ops.shared.list()`; a page opened before that resolves fills on the
|
|
483
|
+
next effect run. Not seen in the walk — the example binds no zone, so
|
|
484
|
+
the path was never exercised — but a double fill is unreachable by
|
|
485
|
+
construction: the effect depends on `sharedIds`, `record`, `mutate` and
|
|
486
|
+
`tree === null`, and `fillLockedZones` only touches EMPTY zones.
|
|
487
|
+
`z.locked(item)` still has no example usage.
|
|
488
|
+
- `SharedForm` lists linking pages by id; a title needs `ops.pages.get`
|
|
489
|
+
per page (or a usage op returning titles).
|
|
490
|
+
- `getSmoodlyShared` reads the store per request in draft mode and once
|
|
491
|
+
per (item, locale) otherwise; a layout calling it for several items
|
|
492
|
+
makes one round trip each — batch if it shows.
|
|
493
|
+
- A shared item's row id is now uuid v5 of (space, name), so the primary
|
|
494
|
+
key ends the materialization race — but only for rows created from now
|
|
495
|
+
on: an item materialized before this change keeps its random
|
|
496
|
+
`gen_random_uuid()` id, and a second load would still be able to insert
|
|
497
|
+
a duplicate beside it. Re-materializing such an item (delete the row in
|
|
498
|
+
SQL, reload the admin) moves it onto the derived id.
|
|
499
|
+
- `SharedForm.tsx` is a near-verbatim fork of `EntryForm.tsx`
|
|
500
|
+
(`localeFromUrl`, `writeLocaleToUrl`, `applyResult`, `InfoRow`, the
|
|
501
|
+
ref-options effect) and `SharedList.tsx` is the third copy of the
|
|
502
|
+
remembered-locale block (`EntriesList`, `PagesList`); extract
|
|
503
|
+
`shell/locale-url.ts` and a `useFormLocale` hook when the next form
|
|
504
|
+
arrives.
|
|
505
|
+
- A placement selected in a bound locked zone shows the canvas toolbar's
|
|
506
|
+
"fields only" label though a placement has no page-level fields; the
|
|
507
|
+
inspector notice is the real message.
|
|
508
|
+
- A zone whose policy allows a shared item's section also offers that
|
|
509
|
+
section as an ordinary block, so the picker lists e.g. Testimonials
|
|
510
|
+
twice (once under Shared); decide whether a section used by a shared
|
|
511
|
+
item should be hidden from the ordinary list.
|
|
512
|
+
- A placement of an object item or an unregistered item gates the page's
|
|
513
|
+
publish (the refs edge is emitted without a registry lookup) yet never
|
|
514
|
+
renders; only a hand-edited tree can produce one.
|
|
515
|
+
- `ops.shared.get` runs `ensureSharedItems` and then lists the item
|
|
516
|
+
again; one round trip could go.
|
|
517
|
+
- The example's Testimonials `quotes` richtext is required, so the first
|
|
518
|
+
admin save of the seeded band asks for it; make it optional or seed it.
|
|
519
|
+
- `CollectionSchema.kind` was widened to carry `"shared"`, so a
|
|
520
|
+
`SharedSchema` typechecks where `registry.collections` is expected;
|
|
521
|
+
`defineConfig` should throw on a `kind === "shared"` entry listed there.
|
|
522
|
+
- `ensureSharedItems` runs on every `ops.shared.list()` — which is every
|
|
523
|
+
editor mount, not only the Shared content page — costing one
|
|
524
|
+
`entries.list` per registration per open.
|
|
525
|
+
- `fanOut` resolves every shared registration's row id (one
|
|
526
|
+
`entries.list` each) on every entry write, so it can expire
|
|
527
|
+
`shared:<name>` when something the item references changes; a row-id
|
|
528
|
+
cache would remove the reads but has to survive materialization.
|
|
529
|
+
- Because `fanOut` lists every shared registration, an entry store built
|
|
530
|
+
without `options.shared` now fails on every ordinary entry write with
|
|
531
|
+
"no collection named", not only on `ops.shared.*`; both scaffolds pass
|
|
532
|
+
the same `shared` array to the store and the config, so this bites only
|
|
533
|
+
a hand-wired app.
|
|
534
|
+
- `ops.entries.move` expires entry tags directly and does not `fanOut`,
|
|
535
|
+
so a reorder that changes what a shared item's `refList` renders does
|
|
536
|
+
not expire `shared:<name>`.
|
|
537
|
+
- The package root (`src/index.ts`) must stay free of Node builtins:
|
|
538
|
+
every scaffolded section does `import { f, smoodly } from "smoodly"`,
|
|
539
|
+
and a `"use client"` section's browser build would fail on one.
|
|
540
|
+
`sharedEntryId` (`src/shared-id.ts`) moved off `node:crypto` onto Web
|
|
541
|
+
Crypto for this reason (fix pass, 2026-09-07). Nothing tests the
|
|
542
|
+
invariant itself — a future addition that reaches for `node:fs`,
|
|
543
|
+
`node:crypto`, etc. from a module reachable through the root would
|
|
544
|
+
regress it silently.
|
|
545
|
+
|
|
326
546
|
## Follow-ups (logged 2026-09-06)
|
|
327
547
|
|
|
328
548
|
- Drag-and-drop in the Pages list — the ops it needs exist
|
package/dist/admin/client-ops.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
const GROUPS = {
|
|
2
2
|
pages: ["list", "get", "create", "saveDraft", "publish", "delete", "rename", "move", "setTemplate", "addLocale", "removeLocale"],
|
|
3
3
|
entries: ["list", "get", "create", "save", "setStatus", "delete", "move", "addLocale", "removeLocale"],
|
|
4
|
+
shared: ["list", "get", "save", "setStatus"],
|
|
4
5
|
preview: ["enable", "disable"],
|
|
5
6
|
};
|
|
6
7
|
export function makeClientOps(action, token) {
|
|
@@ -19,11 +19,13 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
19
19
|
// Core code: no next/* imports live here — the bridge is the only piece
|
|
20
20
|
// that knows about Next, and it ships to the site, not the admin.
|
|
21
21
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
22
|
+
import { SHARED_NODE_TYPE } from "../../shared.js";
|
|
22
23
|
import { FieldWidget } from "../forms/FieldWidget.js";
|
|
23
24
|
import { tabsFor } from "../forms/tabs.js";
|
|
24
|
-
import { allowedSections, canInsert, fillLockedZones, findNode, insertNode, moveNode, newNodeId, nodeFromSample, patchNode, removeNode, zoneOf, } from "../tree-ops.js";
|
|
25
|
+
import { allowedSections, allowedShared, canInsert, fillLockedZones, findNode, insertNode, moveNode, newNodeId, nodeFromSample, patchNode, placementNode, removeNode, zoneOf, } from "../tree-ops.js";
|
|
25
26
|
import { useDraftAutosave } from "./autosave.js";
|
|
26
27
|
import { PageSettings } from "./PageSettings.js";
|
|
28
|
+
import { BASE } from "../shell/base.js";
|
|
27
29
|
import { isFixedNode, localeStatusOf, sourceLocaleOf } from "../shell/pages-tree.js";
|
|
28
30
|
import { entryTitle } from "../shell/entries-list.js";
|
|
29
31
|
import { LocaleSwitcher } from "../ui/LocaleSwitcher.js";
|
|
@@ -81,6 +83,9 @@ export function EditorView({ ops, registry, id }) {
|
|
|
81
83
|
const [banner, setBanner] = useState(null);
|
|
82
84
|
const [published, setPublished] = useState(false);
|
|
83
85
|
const [refOptions, setRefOptions] = useState({});
|
|
86
|
+
// Shared item name → row id, loaded once per editor mount: the picker's
|
|
87
|
+
// Shared group and a bound locked zone need the id to place a node.
|
|
88
|
+
const [sharedIds, setSharedIds] = useState(null);
|
|
84
89
|
const [hoverPlus, setHoverPlus] = useState(null);
|
|
85
90
|
const iframeRef = useRef(null);
|
|
86
91
|
const scrollerRef = useRef(null);
|
|
@@ -290,6 +295,12 @@ export function EditorView({ ops, registry, id }) {
|
|
|
290
295
|
const zoneOrder = useMemo(() => Object.keys(template?.zones ?? {}), [template]);
|
|
291
296
|
const policyOf = useCallback((zone) => (zone ? template?.zones[zone] : undefined), [template]);
|
|
292
297
|
const sectionOf = useCallback((type) => registry.sections.find((s) => s.name === type) ?? null, [registry]);
|
|
298
|
+
/** The shared item a placement node places, or null for any other node. */
|
|
299
|
+
const sharedOf = useCallback((node) => {
|
|
300
|
+
if (node.type !== SHARED_NODE_TYPE)
|
|
301
|
+
return null;
|
|
302
|
+
return registry.shared.find((s) => s.name === node.fields?.item) ?? null;
|
|
303
|
+
}, [registry]);
|
|
293
304
|
// ── ref fields inside sections: same option loading as EntryForm ──────
|
|
294
305
|
const refTargets = useMemo(() => {
|
|
295
306
|
const out = {};
|
|
@@ -329,6 +340,27 @@ export function EditorView({ ops, registry, id }) {
|
|
|
329
340
|
}
|
|
330
341
|
return () => { cancelled = true; };
|
|
331
342
|
}, [ops, refTargets, registry, locale]);
|
|
343
|
+
useEffect(() => {
|
|
344
|
+
if (registryRef.current.shared.length === 0) {
|
|
345
|
+
setSharedIds({});
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
let cancelled = false;
|
|
349
|
+
// A failed load leaves the map empty: the picker's Shared group and
|
|
350
|
+
// any zone bound to an item degrade to nothing to place, not a stuck
|
|
351
|
+
// editor — so say why in the console, like the ref options above.
|
|
352
|
+
ops.shared.list()
|
|
353
|
+
.then((r) => {
|
|
354
|
+
if (cancelled)
|
|
355
|
+
return;
|
|
356
|
+
if (!r.ok)
|
|
357
|
+
console.error(r.message);
|
|
358
|
+
setSharedIds(r.ok ? Object.fromEntries(r.data.map((i) => [i.name, i.record.id])) : {});
|
|
359
|
+
})
|
|
360
|
+
.catch((e) => { console.error(e); if (!cancelled)
|
|
361
|
+
setSharedIds({}); });
|
|
362
|
+
return () => { cancelled = true; };
|
|
363
|
+
}, [ops]);
|
|
332
364
|
// ── canvas messaging ─────────────────────────────────────────────────
|
|
333
365
|
const postToCanvas = useCallback((m) => {
|
|
334
366
|
iframeRef.current?.contentWindow?.postMessage(m, "*");
|
|
@@ -386,6 +418,19 @@ export function EditorView({ ops, registry, id }) {
|
|
|
386
418
|
});
|
|
387
419
|
mark();
|
|
388
420
|
}, [mark]);
|
|
421
|
+
// A zone bound to a shared item needs the item's row id (ops.shared.list),
|
|
422
|
+
// which can land after the draft did. Fill then; nothing else changes.
|
|
423
|
+
useEffect(() => {
|
|
424
|
+
if (!tree || !sharedIds || !record)
|
|
425
|
+
return;
|
|
426
|
+
const template = registryRef.current.pages.find((p) => p.name === record.template);
|
|
427
|
+
const filled = fillLockedZones(tree, template?.zones ?? {}, registryRef.current.sections, sharedIds);
|
|
428
|
+
if (filled !== tree)
|
|
429
|
+
mutate(() => filled);
|
|
430
|
+
// `tree` is deliberately not a dependency: this runs when the ids land
|
|
431
|
+
// or a new draft loads, not on every keystroke.
|
|
432
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
433
|
+
}, [sharedIds, record, mutate, tree === null]);
|
|
389
434
|
// ── tree gestures ────────────────────────────────────────────────────
|
|
390
435
|
const nodesIn = useCallback((zone) => tree?.zones[zone] ?? [], [tree]);
|
|
391
436
|
const zoneCanTake = useCallback((zone) => canInsert(policyOf(zone), nodesIn(zone).length), [policyOf, nodesIn]);
|
|
@@ -395,6 +440,15 @@ export function EditorView({ ops, registry, id }) {
|
|
|
395
440
|
setPicker(null);
|
|
396
441
|
select(node.id);
|
|
397
442
|
};
|
|
443
|
+
const doInsertShared = (zone, index, item) => {
|
|
444
|
+
const ref = sharedIds?.[item.name];
|
|
445
|
+
if (!ref)
|
|
446
|
+
return;
|
|
447
|
+
const node = placementNode(item, ref, newNodeId(SHARED_NODE_TYPE));
|
|
448
|
+
mutate((t) => insertNode(t, zone, index, node));
|
|
449
|
+
setPicker(null);
|
|
450
|
+
select(node.id);
|
|
451
|
+
};
|
|
398
452
|
const doDuplicate = (node) => {
|
|
399
453
|
if (!tree)
|
|
400
454
|
return;
|
|
@@ -477,6 +531,7 @@ export function EditorView({ ops, registry, id }) {
|
|
|
477
531
|
}
|
|
478
532
|
const selected = tree && selectedId ? findNode(tree, selectedId) : null;
|
|
479
533
|
const selectedSection = selected ? sectionOf(selected.type) : null;
|
|
534
|
+
const selectedShared = selected ? sharedOf(selected) : null;
|
|
480
535
|
const selZone = tree && selected ? zoneOf(tree, selected.id) : null;
|
|
481
536
|
const selLocked = policyOf(selZone)?.kind === "locked";
|
|
482
537
|
const rectById = new Map(rects.map((r) => [r.id, r]));
|
|
@@ -534,12 +589,12 @@ export function EditorView({ ops, registry, id }) {
|
|
|
534
589
|
width: hoverRect.w, height: hoverRect.h,
|
|
535
590
|
outline: "1.5px solid color-mix(in srgb, var(--selection) 45%, transparent)",
|
|
536
591
|
outlineOffset: -1.5, borderRadius: "var(--radius-sm)",
|
|
537
|
-
} })), selRect && selected && selectedSection && (_jsxs(_Fragment, { children: [_jsx("div", { style: {
|
|
592
|
+
} })), selRect && selected && (selectedSection || selectedShared) && (_jsxs(_Fragment, { children: [_jsx("div", { style: {
|
|
538
593
|
position: "absolute", left: selRect.x, top: selRect.y,
|
|
539
594
|
width: selRect.w, height: selRect.h,
|
|
540
595
|
outline: "var(--outline-selection)", outlineOffset: -1.5,
|
|
541
596
|
borderRadius: "var(--radius-sm)",
|
|
542
|
-
} }), _jsxs("span", { className: "sm-blocktag", style: { position: "absolute", left: selRect.x, top: selRect.y }, children: [selectedSection
|
|
597
|
+
} }), _jsxs("span", { className: "sm-blocktag", style: { position: "absolute", left: selRect.x, top: selRect.y }, children: [selectedSection?.title ?? selectedShared?.title, selectedShared && " · shared", selLocked && " · locked"] }), _jsx("div", { className: "sm-blocktoolbar", style: {
|
|
543
598
|
position: "absolute", left: selRect.x + selRect.w - 8, top: selRect.y + 8,
|
|
544
599
|
transform: "translateX(-100%)", pointerEvents: "auto",
|
|
545
600
|
}, children: selLocked ? (_jsx("span", { className: "sm-mono", style: { fontSize: "var(--text-xs)", color: "#8C94A8", padding: "4px 8px" }, children: "fields only" })) : (_jsxs(_Fragment, { children: [_jsx(IconButton, { label: "Move up", tone: "inverse", onClick: () => doMove(selected.id, -1), children: "\u2191" }), _jsx(IconButton, { label: "Move down", tone: "inverse", onClick: () => doMove(selected.id, 1), children: "\u2193" }), _jsx(IconButton, { label: "Duplicate", tone: "inverse", onClick: () => doDuplicate(selected), children: "\u29C9" }), _jsx(IconButton, { label: "Remove", tone: "inverse", onClick: () => doRemove(selected.id), children: "\u00D7" })] })) })] })), plusButtons.map((p) => {
|
|
@@ -550,11 +605,11 @@ export function EditorView({ ops, registry, id }) {
|
|
|
550
605
|
display: "flex", flexDirection: "column", alignItems: "center", gap: 6,
|
|
551
606
|
pointerEvents: "auto",
|
|
552
607
|
}, children: [_jsx("button", { className: "sm-plus", "aria-label": `Add to ${p.zone}`, onMouseEnter: () => setHoverPlus(key), onMouseLeave: () => setHoverPlus((k) => (k === key ? null : k)), onClick: (e) => setPicker({ zone: p.zone, index: p.index, x: e.clientX - 150, y: e.clientY + 12 }), children: "+" }), hot && _jsxs("span", { className: "sm-tooltip", children: ["Add to ", p.zone] })] })] }, key));
|
|
553
|
-
})] })] }) })] }), _jsxs("aside", { className: "sm-inspector", children: [selected && selectedSection ? (_jsxs(_Fragment, { children: [_jsxs("div", { className: "sm-inspector__head", children: [_jsxs("span", { style: { display: "flex", alignItems: "center", gap: 8, minWidth: 0 }, children: [_jsx("span", { className: "sm-blockglyph" }), _jsx("span", { className: "sm-title", style: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, children: selectedSection
|
|
608
|
+
})] })] }) })] }), _jsxs("aside", { className: "sm-inspector", children: [selected && (selectedSection || selectedShared) ? (_jsxs(_Fragment, { children: [_jsxs("div", { className: "sm-inspector__head", children: [_jsxs("span", { style: { display: "flex", alignItems: "center", gap: 8, minWidth: 0 }, children: [_jsx("span", { className: "sm-blockglyph" }), _jsx("span", { className: "sm-title", style: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, children: selectedSection?.title ?? selectedShared?.title })] }), _jsx(IconButton, { label: "Clear selection", onClick: () => setSelectedId(null), children: "\u00D7" })] }), tabs.length > 0 && (_jsx(Tabs, { tabs: tabs.map((t) => t.tab), value: activeTab, onChange: (t) => setTab(t) }))] })) : (_jsx("div", { className: "sm-inspector__head", children: _jsx("span", { className: "sm-label", children: "Nothing selected \u2014 page settings below" }) })), _jsxs("div", { className: "sm-inspector__body", children: [!selected && record && (_jsxs(_Fragment, { children: [_jsx(PageSettings, { record: record, locale: locale, registry: registry, path: paths[locale], fixed: isFixedNode(record, registry), onRename: onRename, onRemoveLocale: Object.keys(record.locales).length > 1 &&
|
|
554
609
|
!isFixedNode(record, registry) &&
|
|
555
610
|
!(record.parentId === null && record.locales[registry.locales.default]?.slug === registry.homePageSlug)
|
|
556
611
|
? () => { void doRemoveLocale(); }
|
|
557
|
-
: undefined }), _jsx(Divider, { bleed: 14 })] })), selected && selectedSection && entries.map(({ key, ns, descriptor }) => (_jsx(FieldWidget, { fieldKey: key, descriptor: descriptor, value: selected[ns]?.[key], refOptions: refOptionsFor(refOptions, refTargets, selectedSection, ns, key, descriptor), onChange: (value) => doPatch(selected.id, ns, key, value) }, `${selected.id}.${ns}.${key}`))), _jsx(Divider, { bleed: 14 }), _jsx(SectionLabel, { children: "Outline" }), zoneOrder.map((zone) => {
|
|
612
|
+
: undefined }), _jsx(Divider, { bleed: 14 })] })), selected && selectedSection && entries.map(({ key, ns, descriptor }) => (_jsx(FieldWidget, { fieldKey: key, descriptor: descriptor, value: selected[ns]?.[key], refOptions: refOptionsFor(refOptions, refTargets, selectedSection, ns, key, descriptor), onChange: (value) => doPatch(selected.id, ns, key, value) }, `${selected.id}.${ns}.${key}`))), selected && selectedShared && (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 10 }, children: [_jsx("p", { className: "sm-hint", children: "This is shared content. Editing it changes every page it is placed on." }), _jsx("a", { className: "sm-nav-item", href: `${BASE}/shared/${encodeURIComponent(selectedShared.name)}?locale=${encodeURIComponent(locale)}`, children: "Edit shared item \u2192" })] })), _jsx(Divider, { bleed: 14 }), _jsx(SectionLabel, { children: "Outline" }), zoneOrder.map((zone) => {
|
|
558
613
|
const locked = policyOf(zone)?.kind === "locked";
|
|
559
614
|
const nodes = nodesIn(zone);
|
|
560
615
|
return (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 4 }, children: [_jsxs(SectionLabel, { mono: true, children: [zone, locked && " · locked"] }), nodes.length === 0 && (_jsxs("div", { className: cx("sm-outline-row", "sm-outline-row--empty", zoneCanTake(zone) && "sm-outline-row--clickable"), onClick: () => {
|
|
@@ -567,15 +622,16 @@ export function EditorView({ ops, registry, id }) {
|
|
|
567
622
|
const section = sectionOf(node.type);
|
|
568
623
|
const isSelected = node.id === selectedId;
|
|
569
624
|
if (locked) {
|
|
570
|
-
return (_jsxs("div", { className: "sm-outline-row sm-outline-row--locked sm-outline-row--clickable", onClick: () => select(node.id), children: [_jsx("span", { className: "sm-outline-row__label", children: section?.title ?? node.type }), _jsx("span", { style: { fontSize: "var(--text-xs)", fontWeight: 600, letterSpacing: ".06em" }, children: "LOCKED" })] }, node.id));
|
|
625
|
+
return (_jsxs("div", { className: "sm-outline-row sm-outline-row--locked sm-outline-row--clickable", onClick: () => select(node.id), children: [_jsx("span", { className: "sm-outline-row__label", children: section?.title ?? (sharedOf(node) ? `${sharedOf(node).title} · shared` : node.type) }), _jsx("span", { style: { fontSize: "var(--text-xs)", fontWeight: 600, letterSpacing: ".06em" }, children: "LOCKED" })] }, node.id));
|
|
571
626
|
}
|
|
572
|
-
return (_jsxs("div", { onClick: () => select(node.id), className: cx("sm-outline-row", "sm-outline-row--clickable", isSelected && "sm-outline-row--selected"), children: [_jsx("span", { className: "sm-outline-row__label", children: section?.title ?? node.type }), _jsxs("span", { style: { display: "flex", gap: 2, flex: "none" }, children: [_jsx(IconButton, { label: "Move up", size: 22, disabled: i === 0, onClick: (e) => { e.stopPropagation(); doMove(node.id, -1); }, children: "\u2191" }), _jsx(IconButton, { label: "Move down", size: 22, disabled: i === nodes.length - 1, onClick: (e) => { e.stopPropagation(); doMove(node.id, 1); }, children: "\u2193" }), _jsx(IconButton, { label: "Remove", size: 22, onClick: (e) => { e.stopPropagation(); doRemove(node.id); }, children: "\u00D7" })] })] }, node.id));
|
|
627
|
+
return (_jsxs("div", { onClick: () => select(node.id), className: cx("sm-outline-row", "sm-outline-row--clickable", isSelected && "sm-outline-row--selected"), children: [_jsx("span", { className: "sm-outline-row__label", children: section?.title ?? (sharedOf(node) ? `${sharedOf(node).title} · shared` : node.type) }), _jsxs("span", { style: { display: "flex", gap: 2, flex: "none" }, children: [_jsx(IconButton, { label: "Move up", size: 22, disabled: i === 0, onClick: (e) => { e.stopPropagation(); doMove(node.id, -1); }, children: "\u2191" }), _jsx(IconButton, { label: "Move down", size: 22, disabled: i === nodes.length - 1, onClick: (e) => { e.stopPropagation(); doMove(node.id, 1); }, children: "\u2193" }), _jsx(IconButton, { label: "Remove", size: 22, onClick: (e) => { e.stopPropagation(); doRemove(node.id); }, children: "\u00D7" })] })] }, node.id));
|
|
573
628
|
})] }, zone));
|
|
574
629
|
})] })] })] }), picker && (_jsx("div", { style: { position: "fixed", inset: 0, zIndex: 60 }, onClick: () => setPicker(null), children: _jsxs("div", { className: "sm-picker", onClick: (e) => e.stopPropagation(), style: {
|
|
575
630
|
position: "absolute",
|
|
576
631
|
left: Math.max(12, Math.min(picker.x, window.innerWidth - 320)),
|
|
577
632
|
top: Math.min(picker.y, Math.max(12, window.innerHeight - 240)),
|
|
578
|
-
}, children: [_jsxs(SectionLabel, { mono: true, style: { padding: "6px 8px 8px" }, children: ["Add to ", picker.zone] }), allowedSections(policyOf(picker.zone), registry.sections).map((section) => (_jsxs("button", { type: "button", className: "sm-picker__item", onClick: () => doInsert(picker.zone, picker.index, section), children: [_jsx("span", { className: "sm-picker__icon", children: section.icon ?? "▤" }), _jsxs("span", { style: { minWidth: 0 }, children: [_jsx("span", { style: { display: "block", fontWeight: 600 }, children: section.title }), section.description && (_jsx("span", { style: { display: "block", fontSize: "var(--text-sm)", color: "var(--text-muted)" }, children: section.description }))] })] }, section.name))),
|
|
633
|
+
}, children: [_jsxs(SectionLabel, { mono: true, style: { padding: "6px 8px 8px" }, children: ["Add to ", picker.zone] }), allowedSections(policyOf(picker.zone), registry.sections).map((section) => (_jsxs("button", { type: "button", className: "sm-picker__item", onClick: () => doInsert(picker.zone, picker.index, section), children: [_jsx("span", { className: "sm-picker__icon", children: section.icon ?? "▤" }), _jsxs("span", { style: { minWidth: 0 }, children: [_jsx("span", { style: { display: "block", fontWeight: 600 }, children: section.title }), section.description && (_jsx("span", { style: { display: "block", fontSize: "var(--text-sm)", color: "var(--text-muted)" }, children: section.description }))] })] }, section.name))), allowedShared(policyOf(picker.zone), registry.shared, sharedIds ?? {}).length > 0 && (_jsxs(_Fragment, { children: [_jsx(SectionLabel, { mono: true, style: { padding: "8px 8px 4px" }, children: "Shared" }), allowedShared(policyOf(picker.zone), registry.shared, sharedIds ?? {}).map((item) => (_jsxs("button", { type: "button", className: "sm-picker__item", onClick: () => doInsertShared(picker.zone, picker.index, item), children: [_jsx("span", { className: "sm-picker__icon", children: "\u26D3" }), _jsxs("span", { style: { minWidth: 0 }, children: [_jsx("span", { style: { display: "block", fontWeight: 600 }, children: item.title }), _jsx("span", { style: { display: "block", fontSize: "var(--text-sm)", color: "var(--text-muted)" }, children: "Shared \u00B7 edited in one place" })] })] }, `shared-${item.name}`)))] })), allowedSections(policyOf(picker.zone), registry.sections).length === 0 &&
|
|
634
|
+
allowedShared(policyOf(picker.zone), registry.shared, sharedIds ?? {}).length === 0 && (_jsx("p", { style: { color: "var(--text-muted)", padding: 10 }, children: "This zone doesn't accept insertions." }))] }) }))] }));
|
|
579
635
|
}
|
|
580
636
|
/** The selected node's ref field (if any) resolves to its target
|
|
581
637
|
* collection's loaded options — the EntryForm shape, section-scoped. */
|
|
@@ -1,10 +1,14 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type ReactElement } from "react";
|
|
2
2
|
import type { AdminField } from "../serialize.ts";
|
|
3
|
-
export declare function FieldWidget({ descriptor: d, value, onChange,
|
|
3
|
+
export declare function FieldWidget({ descriptor: d, value, onChange, errors, path: pathProp, bare, refOptions, fieldKey, prefix, }: {
|
|
4
4
|
descriptor: AdminField;
|
|
5
5
|
value: unknown;
|
|
6
6
|
onChange(value: unknown): void;
|
|
7
|
-
|
|
7
|
+
errors?: Record<string, string>;
|
|
8
|
+
/** Dotted path of this widget in the form; defaults to `fieldKey`. */
|
|
9
|
+
path?: string;
|
|
10
|
+
/** Render the control alone, no label wrapper — list items. */
|
|
11
|
+
bare?: boolean;
|
|
8
12
|
refOptions?: {
|
|
9
13
|
id: string;
|
|
10
14
|
title: string;
|
|
@@ -1,11 +1,26 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
// One schema descriptor → one labelled control, in the design system's
|
|
4
|
+
// field language: uppercase 9px label, inset control with no border at
|
|
5
|
+
// rest, 1.5px accent border + soft ring on focus (all in ui/theme.tsx).
|
|
6
|
+
// Objects and lists recurse through this same component; the list's
|
|
7
|
+
// logic lives in ./list.ts as pure functions.
|
|
8
|
+
import { useState } from "react";
|
|
3
9
|
import { plainToRichtext, richtextToPlain } from "../richtext.js";
|
|
4
|
-
import { Field, IconButton } from "../ui/primitives.js";
|
|
10
|
+
import { Button, Field, IconButton } from "../ui/primitives.js";
|
|
11
|
+
import { addItem, itemSummary, moveIndex, moveItem, patchObject, removeIndex, removeItem } from "./list.js";
|
|
5
12
|
const label = (key) => key.replace(/([A-Z])/g, " $1").replace(/^./, (c) => c.toUpperCase());
|
|
6
|
-
export function FieldWidget({ descriptor: d, value, onChange,
|
|
13
|
+
export function FieldWidget({ descriptor: d, value, onChange, errors, path: pathProp, bare, refOptions = [], fieldKey, prefix, }) {
|
|
14
|
+
const path = pathProp ?? fieldKey;
|
|
15
|
+
const message = errors?.[path];
|
|
16
|
+
// Unused for non-list types, but hooks must be unconditional — a list's
|
|
17
|
+
// rows key their collapse state to this rather than to array position,
|
|
18
|
+
// so a move or remove doesn't reassign which row looks open.
|
|
19
|
+
const [collapsed, setCollapsed] = useState(() => new Set());
|
|
7
20
|
const optionTitle = (o) => o.status === "draft" ? `${o.title} (draft)` : o.title;
|
|
8
21
|
let control;
|
|
22
|
+
let counter;
|
|
23
|
+
let asDiv = d.type === "refList";
|
|
9
24
|
switch (d.type) {
|
|
10
25
|
case "text":
|
|
11
26
|
case "link":
|
|
@@ -26,7 +41,7 @@ export function FieldWidget({ descriptor: d, value, onChange, error, refOptions
|
|
|
26
41
|
break;
|
|
27
42
|
}
|
|
28
43
|
case "richtext":
|
|
29
|
-
control =
|
|
44
|
+
control = _jsx(RichtextArea, { value: value, onChange: onChange });
|
|
30
45
|
break;
|
|
31
46
|
case "image":
|
|
32
47
|
case "video":
|
|
@@ -44,11 +59,74 @@ export function FieldWidget({ descriptor: d, value, onChange, error, refOptions
|
|
|
44
59
|
control = (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 6 }, children: [ids.map((id, i) => (_jsxs("div", { style: { display: "flex", gap: 6, alignItems: "center" }, children: [_jsx("span", { className: "sm-input", style: { display: "flex", alignItems: "center", flex: 1 }, children: byId.get(id) ? optionTitle(byId.get(id)) : id }), _jsx(IconButton, { label: "Remove", size: 28, onClick: () => onChange(ids.filter((_, j) => j !== i)), children: "\u00D7" })] }, `${id}-${i}`))), _jsxs("select", { className: "sm-select", value: "", onChange: (e) => e.target.value && onChange([...ids, e.target.value]), children: [_jsx("option", { value: "", children: "Add\u2026" }), refOptions.filter((o) => !ids.includes(o.id)).map((o) => (_jsx("option", { value: o.id, children: optionTitle(o) }, o.id)))] })] }));
|
|
45
60
|
break;
|
|
46
61
|
}
|
|
62
|
+
case "object": {
|
|
63
|
+
asDiv = true;
|
|
64
|
+
const obj = (value && typeof value === "object" && !Array.isArray(value) ? value : {});
|
|
65
|
+
control = (_jsx("div", { className: "sm-group", children: Object.entries(d.fields ?? {}).map(([k, sub]) => (_jsx(FieldWidget, { fieldKey: k, descriptor: sub, value: obj[k], errors: errors, path: `${path}.${k}`, onChange: (v) => onChange(patchObject(obj, k, v, d.optional === true)) }, k))) }));
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
case "list": {
|
|
69
|
+
asDiv = true;
|
|
70
|
+
const items = Array.isArray(value) ? value : [];
|
|
71
|
+
const item = d.item ?? { type: "text" };
|
|
72
|
+
const atMax = typeof d.max === "number" && items.length >= d.max;
|
|
73
|
+
if (typeof d.max === "number")
|
|
74
|
+
counter = `${items.length} of ${d.max}`;
|
|
75
|
+
control = (_jsxs("div", { className: "sm-list", children: [items.map((it, i) => (_jsx(ListRow, { index: i, count: items.length, summary: itemSummary(it, i), open: !collapsed.has(i), onToggle: () => setCollapsed((prev) => {
|
|
76
|
+
const next = new Set(prev);
|
|
77
|
+
if (next.has(i))
|
|
78
|
+
next.delete(i);
|
|
79
|
+
else
|
|
80
|
+
next.add(i);
|
|
81
|
+
return next;
|
|
82
|
+
}), onMove: (to) => {
|
|
83
|
+
const moved = moveItem(items, i, to);
|
|
84
|
+
if (moved !== items) {
|
|
85
|
+
onChange(moved);
|
|
86
|
+
setCollapsed((prev) => moveIndex(prev, i, to));
|
|
87
|
+
}
|
|
88
|
+
}, onRemove: () => {
|
|
89
|
+
onChange(removeItem(items, i));
|
|
90
|
+
setCollapsed((prev) => removeIndex(prev, i));
|
|
91
|
+
}, children: _jsx(FieldWidget, { fieldKey: String(i), descriptor: item, value: it, errors: errors, path: `${path}.${i}`, bare: true, onChange: (v) => onChange(items.map((x, j) => (j === i ? v : x))) }) }, i))), _jsx(Button, { variant: "secondary", disabled: atMax, onClick: () => onChange(addItem(items, d)), children: "Add" })] }));
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
47
94
|
default:
|
|
48
95
|
control = _jsx("textarea", { className: "sm-textarea", disabled: true, value: JSON.stringify(value ?? null, null, 2), rows: 2 });
|
|
49
96
|
}
|
|
97
|
+
if (bare) {
|
|
98
|
+
return (_jsxs(_Fragment, { children: [control, message && _jsx("span", { className: "sm-error", children: message })] }));
|
|
99
|
+
}
|
|
50
100
|
// A <label> activates its first labelable descendant, so it may only wrap a
|
|
51
|
-
// single control — around refList's rows
|
|
52
|
-
// press of the first
|
|
53
|
-
return (_jsx(Field, { label: label(fieldKey), error:
|
|
101
|
+
// single control — around refList's rows, an object's fieldset or a list's
|
|
102
|
+
// rows it would turn every click into a press of the first button.
|
|
103
|
+
return (_jsx(Field, { label: label(fieldKey), counter: counter, error: message, asDiv: asDiv, children: control }));
|
|
104
|
+
}
|
|
105
|
+
/** One list item: a header with the summary and the row controls, the
|
|
106
|
+
* item's widget underneath while expanded. Header is a div, not a label.
|
|
107
|
+
* Collapse state lives in the parent `FieldWidget`, keyed to the item's
|
|
108
|
+
* index rather than to `key={i}` position, so a move or remove doesn't
|
|
109
|
+
* hand this row someone else's open/closed state. */
|
|
110
|
+
function ListRow({ index, count, summary, open, onToggle, onMove, onRemove, children }) {
|
|
111
|
+
return (_jsxs("div", { className: "sm-listrow", children: [_jsxs("div", { className: "sm-listrow__head", children: [_jsxs("button", { type: "button", className: "sm-listrow__toggle", "aria-expanded": open, onClick: onToggle, children: [_jsx("span", { "aria-hidden": "true", children: open ? "▾" : "▸" }), _jsx("span", { className: "sm-listrow__summary", children: summary })] }), _jsx(IconButton, { label: "Move up", disabled: index === 0, onClick: () => onMove(index - 1), children: "\u2191" }), _jsx(IconButton, { label: "Move down", disabled: index === count - 1, onClick: () => onMove(index + 1), children: "\u2193" }), _jsx(IconButton, { label: "Remove", onClick: onRemove, children: "\u00D7" })] }), open && _jsx("div", { className: "sm-listrow__body", children: children })] }));
|
|
112
|
+
}
|
|
113
|
+
/** The interim richtext widget owns its text: the line syntax drops a trailing
|
|
114
|
+
* newline on the round trip, so deriving the value from the document each
|
|
115
|
+
* render would snap the caret back after Enter at the end of the text. Re-seeds
|
|
116
|
+
* when the document changes underneath it (load, locale switch). The parent must
|
|
117
|
+
* store the emitted document by reference — a clone or a server copy handed back
|
|
118
|
+
* would re-seed the text and bring the caret snap back. */
|
|
119
|
+
function RichtextArea({ value, onChange }) {
|
|
120
|
+
const [text, setText] = useState(() => richtextToPlain(value));
|
|
121
|
+
const [seen, setSeen] = useState(value);
|
|
122
|
+
if (value !== seen) {
|
|
123
|
+
setSeen(value);
|
|
124
|
+
setText(richtextToPlain(value));
|
|
125
|
+
}
|
|
126
|
+
return (_jsx("textarea", { className: "sm-textarea", rows: 5, value: text, onChange: (e) => {
|
|
127
|
+
const doc = plainToRichtext(e.target.value);
|
|
128
|
+
setText(e.target.value);
|
|
129
|
+
setSeen(doc);
|
|
130
|
+
onChange(doc);
|
|
131
|
+
} }));
|
|
54
132
|
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { AdminField } from "../serialize.ts";
|
|
2
|
+
/** An empty item: an object of its subfields' defaults, else the default. */
|
|
3
|
+
export declare function emptyValue(d: AdminField): unknown;
|
|
4
|
+
export declare function addItem(items: unknown[], list: AdminField): unknown[];
|
|
5
|
+
export declare function removeItem(items: unknown[], index: number): unknown[];
|
|
6
|
+
export declare function moveItem(items: unknown[], from: number, to: number): unknown[];
|
|
7
|
+
/** Permutes a set of item indices (e.g. collapse state) for the same move
|
|
8
|
+
* `moveItem` just applied: the index at `from` becomes `to`; indices strictly
|
|
9
|
+
* between the two shift by one toward `from`; every other index is unchanged. */
|
|
10
|
+
export declare function moveIndex(set: Set<number>, from: number, to: number): Set<number>;
|
|
11
|
+
/** Permutes a set of item indices for a removal at `index`: drops it, and
|
|
12
|
+
* shifts every higher index down by one. */
|
|
13
|
+
export declare function removeIndex(set: Set<number>, index: number): Set<number>;
|
|
14
|
+
/** The row header's text: the value, or its first non-empty string, or "Item N". */
|
|
15
|
+
export declare function itemSummary(value: unknown, index: number): string;
|
|
16
|
+
/** A subfield change on an object value. `collapseEmpty` (an optional
|
|
17
|
+
* object) turns an object with no defined values into `undefined`. */
|
|
18
|
+
export declare function patchObject(obj: Record<string, unknown>, key: string, value: unknown, collapseEmpty: boolean): Record<string, unknown> | undefined;
|