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.
Files changed (64) hide show
  1. package/README.md +224 -4
  2. package/dist/admin/client-ops.js +1 -0
  3. package/dist/admin/editor/EditorView.js +64 -8
  4. package/dist/admin/forms/FieldWidget.d.ts +7 -3
  5. package/dist/admin/forms/FieldWidget.js +85 -7
  6. package/dist/admin/forms/list.d.ts +18 -0
  7. package/dist/admin/forms/list.js +81 -0
  8. package/dist/admin/index.d.ts +3 -2
  9. package/dist/admin/index.js +1 -0
  10. package/dist/admin/ops-impl.js +84 -1
  11. package/dist/admin/ops.d.ts +22 -1
  12. package/dist/admin/richtext.d.ts +13 -11
  13. package/dist/admin/richtext.js +94 -13
  14. package/dist/admin/serialize.d.ts +15 -0
  15. package/dist/admin/serialize.js +11 -0
  16. package/dist/admin/shared-items.d.ts +9 -0
  17. package/dist/admin/shared-items.js +61 -0
  18. package/dist/admin/shell/AdminApp.js +8 -2
  19. package/dist/admin/shell/EntryForm.js +1 -1
  20. package/dist/admin/shell/SharedForm.d.ts +7 -0
  21. package/dist/admin/shell/SharedForm.js +146 -0
  22. package/dist/admin/shell/SharedList.d.ts +6 -0
  23. package/dist/admin/shell/SharedList.js +62 -0
  24. package/dist/admin/shell/shared-list.d.ts +14 -0
  25. package/dist/admin/shell/shared-list.js +17 -0
  26. package/dist/admin/tree-ops.d.ts +12 -5
  27. package/dist/admin/tree-ops.js +39 -8
  28. package/dist/admin/ui/theme.d.ts +1 -1
  29. package/dist/admin/ui/theme.js +13 -0
  30. package/dist/admin/validate.d.ts +7 -2
  31. package/dist/admin/validate.js +43 -13
  32. package/dist/collections.d.ts +4 -1
  33. package/dist/config.d.ts +3 -0
  34. package/dist/config.js +35 -1
  35. package/dist/entry-store.d.ts +9 -1
  36. package/dist/entry-store.js +32 -4
  37. package/dist/fields.d.ts +16 -1
  38. package/dist/fields.js +49 -4
  39. package/dist/index.d.ts +8 -1
  40. package/dist/index.js +7 -1
  41. package/dist/next/page-renderer.d.ts +4 -0
  42. package/dist/next/page-renderer.js +14 -1
  43. package/dist/page.js +5 -1
  44. package/dist/refs.js +13 -0
  45. package/dist/resolve.d.ts +3 -0
  46. package/dist/resolve.js +41 -0
  47. package/dist/revalidate.d.ts +4 -0
  48. package/dist/revalidate.js +4 -0
  49. package/dist/richtext-render.d.ts +3 -0
  50. package/dist/richtext-render.js +62 -0
  51. package/dist/shared-id.d.ts +3 -0
  52. package/dist/shared-id.js +36 -0
  53. package/dist/shared.d.ts +46 -0
  54. package/dist/shared.js +36 -0
  55. package/dist/site.d.ts +10 -0
  56. package/dist/site.js +28 -1
  57. package/dist/sql-space.d.ts +5 -1
  58. package/dist/sql-space.js +9 -10
  59. package/dist/sql.js +1 -19
  60. package/dist/supabase-entry-store.d.ts +9 -1
  61. package/dist/supabase-entry-store.js +60 -7
  62. package/dist/zones.d.ts +6 -2
  63. package/dist/zones.js +8 -2
  64. 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, `global_sections`), not only through
178
- the generated views.
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
- Not yet: richtext components (TipTap; `plainToRichtext`/
305
- `richtextToPlain` exist but no rich editor widget), drag-and-drop
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
@@ -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.title, selLocked && " · locked"] }), _jsx("div", { className: "sm-blocktoolbar", style: {
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.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 &&
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))), allowedSections(policyOf(picker.zone), registry.sections).length === 0 && (_jsx("p", { style: { color: "var(--text-muted)", padding: 10 }, children: "This zone doesn't accept insertions." }))] }) }))] }));
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 { ReactElement } from "react";
1
+ import { type ReactElement } from "react";
2
2
  import type { AdminField } from "../serialize.ts";
3
- export declare function FieldWidget({ descriptor: d, value, onChange, error, refOptions, fieldKey, prefix, }: {
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
- error?: string;
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, error, refOptions = [], fieldKey, prefix, }) {
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 = (_jsx("textarea", { className: "sm-textarea", rows: 5, value: richtextToPlain(value), onChange: (e) => onChange(plainToRichtext(e.target.value)) }));
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 it would turn every click into a
52
- // press of the first row's remove button.
53
- return (_jsx(Field, { label: label(fieldKey), error: error, asDiv: d.type === "refList", children: control }));
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;