smoodly 0.0.7 → 0.0.8

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 (47) hide show
  1. package/README.md +88 -2
  2. package/dist/admin/client-ops.js +1 -0
  3. package/dist/admin/editor/EditorView.js +64 -8
  4. package/dist/admin/index.d.ts +3 -2
  5. package/dist/admin/index.js +1 -0
  6. package/dist/admin/ops-impl.js +84 -1
  7. package/dist/admin/ops.d.ts +22 -1
  8. package/dist/admin/serialize.d.ts +11 -0
  9. package/dist/admin/serialize.js +8 -0
  10. package/dist/admin/shared-items.d.ts +9 -0
  11. package/dist/admin/shared-items.js +61 -0
  12. package/dist/admin/shell/AdminApp.js +8 -2
  13. package/dist/admin/shell/SharedForm.d.ts +7 -0
  14. package/dist/admin/shell/SharedForm.js +146 -0
  15. package/dist/admin/shell/SharedList.d.ts +6 -0
  16. package/dist/admin/shell/SharedList.js +62 -0
  17. package/dist/admin/shell/shared-list.d.ts +14 -0
  18. package/dist/admin/shell/shared-list.js +17 -0
  19. package/dist/admin/tree-ops.d.ts +12 -5
  20. package/dist/admin/tree-ops.js +39 -8
  21. package/dist/collections.d.ts +4 -1
  22. package/dist/config.d.ts +3 -0
  23. package/dist/config.js +35 -1
  24. package/dist/entry-store.d.ts +9 -1
  25. package/dist/entry-store.js +25 -3
  26. package/dist/index.d.ts +5 -1
  27. package/dist/index.js +4 -1
  28. package/dist/next/page-renderer.d.ts +4 -0
  29. package/dist/next/page-renderer.js +14 -1
  30. package/dist/page.js +5 -1
  31. package/dist/refs.js +8 -0
  32. package/dist/resolve.d.ts +3 -0
  33. package/dist/resolve.js +38 -0
  34. package/dist/revalidate.d.ts +4 -0
  35. package/dist/revalidate.js +4 -0
  36. package/dist/shared.d.ts +46 -0
  37. package/dist/shared.js +36 -0
  38. package/dist/site.d.ts +10 -0
  39. package/dist/site.js +28 -1
  40. package/dist/sql-space.d.ts +1 -1
  41. package/dist/sql-space.js +4 -9
  42. package/dist/sql.js +0 -18
  43. package/dist/supabase-entry-store.d.ts +1 -1
  44. package/dist/supabase-entry-store.js +23 -5
  45. package/dist/zones.d.ts +6 -2
  46. package/dist/zones.js +8 -2
  47. 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,6 +302,18 @@ 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
 
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
+
304
317
  Not yet: richtext components (TipTap; `plainToRichtext`/
305
318
  `richtextToPlain` exist but no rich editor widget), drag-and-drop
306
319
  reordering (move-up/move-down buttons in the editor, Move up / Move down
@@ -323,6 +336,79 @@ Both packages move together. From the repo root:
323
336
  `examples/site` in that build, so the template always matches the example
324
337
  site at the tagged commit.
325
338
 
339
+ ## Follow-ups (logged 2026-09-07, shared content spec)
340
+
341
+ - `f.shared(() => item)` — a ref field on a section that renders a shared
342
+ item inside the section's own markup (spec 2026-09-07 decision 3).
343
+ - Instance mode: a shared *type* with many editor-created items; today
344
+ the answer is a collection plus a section with `f.ref` (decision 1).
345
+ - Zone-shaped shared items (`smoodly.shared({ zone })`) (decision 2).
346
+ - Standalone live preview on a visual item's admin page — needs a
347
+ preview route in the glue; the page canvas shows placements today (§6).
348
+ - Built-in meta on shared items and on placements (anchor per placement
349
+ is the likely shape).
350
+ - Per-placement overrides (a shared band with a per-page heading) — out
351
+ of scope; use a normal section with `f.ref` fields.
352
+ - `fillLockedZones` for a zone bound to a shared item waits for
353
+ `ops.shared.list()`; a page opened before that resolves fills on the
354
+ next effect run. Not seen in the walk — the example binds no zone, so
355
+ the path was never exercised — but a double fill is unreachable by
356
+ construction: the effect depends on `sharedIds`, `record`, `mutate` and
357
+ `tree === null`, and `fillLockedZones` only touches EMPTY zones.
358
+ `z.locked(item)` still has no example usage.
359
+ - `SharedForm` lists linking pages by id; a title needs `ops.pages.get`
360
+ per page (or a usage op returning titles).
361
+ - `getSmoodlyShared` reads the store per request in draft mode and once
362
+ per (item, locale) otherwise; a layout calling it for several items
363
+ makes one round trip each — batch if it shows.
364
+ - The Supabase entry store's singleton check is SELECT-then-INSERT with
365
+ no unique index; two admin loads racing on an item's first
366
+ materialization can create two rows. `ensureSharedItems` reads the
367
+ first row either way, and the duplicate cannot be removed through the
368
+ API — `delete` refuses every shared row. Two ways to make the race
369
+ impossible: a deterministic row id (uuid v5 of the item name, so the
370
+ second insert fails on the primary key — no schema change), or a
371
+ per-item partial unique index emitted by the SQL generator the way
372
+ `collectionSQL` emits the rest.
373
+ - `SharedForm.tsx` is a near-verbatim fork of `EntryForm.tsx`
374
+ (`localeFromUrl`, `writeLocaleToUrl`, `applyResult`, `InfoRow`, the
375
+ ref-options effect) and `SharedList.tsx` is the third copy of the
376
+ remembered-locale block (`EntriesList`, `PagesList`); extract
377
+ `shell/locale-url.ts` and a `useFormLocale` hook when the next form
378
+ arrives.
379
+ - A placement selected in a bound locked zone shows the canvas toolbar's
380
+ "fields only" label though a placement has no page-level fields; the
381
+ inspector notice is the real message.
382
+ - A zone whose policy allows a shared item's section also offers that
383
+ section as an ordinary block, so the picker lists e.g. Testimonials
384
+ twice (once under Shared); decide whether a section used by a shared
385
+ item should be hidden from the ordinary list.
386
+ - A placement of an object item or an unregistered item gates the page's
387
+ publish (the refs edge is emitted without a registry lookup) yet never
388
+ renders; only a hand-edited tree can produce one.
389
+ - `ops.shared.get` runs `ensureSharedItems` and then lists the item
390
+ again; one round trip could go.
391
+ - The example's Testimonials `quotes` richtext is required, so the first
392
+ admin save of the seeded band asks for it; make it optional or seed it.
393
+ - `CollectionSchema.kind` was widened to carry `"shared"`, so a
394
+ `SharedSchema` typechecks where `registry.collections` is expected;
395
+ `defineConfig` should throw on a `kind === "shared"` entry listed there.
396
+ - `ensureSharedItems` runs on every `ops.shared.list()` — which is every
397
+ editor mount, not only the Shared content page — costing one
398
+ `entries.list` per registration per open.
399
+ - `fanOut` resolves every shared registration's row id (one
400
+ `entries.list` each) on every entry write, so it can expire
401
+ `shared:<name>` when something the item references changes; a row-id
402
+ cache would remove the reads but has to survive materialization.
403
+ - Because `fanOut` lists every shared registration, an entry store built
404
+ without `options.shared` now fails on every ordinary entry write with
405
+ "no collection named", not only on `ops.shared.*`; both scaffolds pass
406
+ the same `shared` array to the store and the config, so this bites only
407
+ a hand-wired app.
408
+ - `ops.entries.move` expires entry tags directly and does not `fanOut`,
409
+ so a reorder that changes what a shared item's `refList` renders does
410
+ not expire `shared:<name>`.
411
+
326
412
  ## Follow-ups (logged 2026-09-06)
327
413
 
328
414
  - 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,11 +1,12 @@
1
- export type { AdminOps, AdminOpCall, AdminOpAction, OpResult, OpError, PageCreateInput, PageGetResult } from "./ops.ts";
1
+ export type { AdminOps, AdminOpCall, AdminOpAction, OpResult, OpError, PageCreateInput, PageGetResult, SharedListItem, SharedGetResult } from "./ops.ts";
2
2
  export { createAdminOps, runAdminOp, type AdminEffects } from "./ops-impl.ts";
3
3
  export { fixedNodeSpecs, fixedNodeOf, ensureFixedNodes, type FixedNode } from "./fixed-nodes.ts";
4
+ export { ensureSharedItems, initialSharedFields } from "./shared-items.ts";
4
5
  export { makeClientOps } from "./client-ops.ts";
5
6
  export { gateAdminOp, type AdminAuth } from "./auth.ts";
6
7
  export { supabaseAdminAuth } from "./supabase-auth.ts";
7
8
  export { serializeAdminConfig } from "./serialize.ts";
8
- export type { AdminRegistry, AdminSection, AdminCollection, AdminPageTemplate, AdminField } from "./serialize.ts";
9
+ export type { AdminRegistry, AdminSection, AdminCollection, AdminPageTemplate, AdminShared, AdminField } from "./serialize.ts";
9
10
  export * from "./tree-ops.ts";
10
11
  export { validateFields, type FieldError } from "./validate.ts";
11
12
  export { plainToRichtext, richtextToPlain } from "./richtext.ts";
@@ -1,5 +1,6 @@
1
1
  export { createAdminOps, runAdminOp } from "./ops-impl.js";
2
2
  export { fixedNodeSpecs, fixedNodeOf, ensureFixedNodes } from "./fixed-nodes.js";
3
+ export { ensureSharedItems, initialSharedFields } from "./shared-items.js";
3
4
  export { makeClientOps } from "./client-ops.js";
4
5
  export { gateAdminOp } from "./auth.js";
5
6
  export { supabaseAdminAuth } from "./supabase-auth.js";
@@ -1,6 +1,7 @@
1
1
  import { entryFieldsIn } from "../entry-store.js";
2
- import { affectedTargets, entryTag, pageTag } from "../revalidate.js";
2
+ import { affectedTargets, entryTag, pageTag, sharedTag } from "../revalidate.js";
3
3
  import { ensureFixedNodes, fixedNodeOf } from "./fixed-nodes.js";
4
+ import { ensureSharedItems } from "./shared-items.js";
4
5
  import { validateFields } from "./validate.js";
5
6
  const ok = (data) => ({ ok: true, data });
6
7
  const err = (code, message, fields) => ({ ok: false, code, message, ...(fields ? { fields } : {}) });
@@ -43,6 +44,15 @@ const classify = (e) => {
43
44
  }
44
45
  if (message.includes("does not exist in locale"))
45
46
  return err("not_found", message);
47
+ if (message.includes("cannot take a slug"))
48
+ return err("validation", message);
49
+ // Ahead of the generic "already exists" check below: the singleton
50
+ // refusal's message ("...already exists — it is a singleton.") contains
51
+ // "already exists" as a substring, so it must classify here first or it
52
+ // would be misread as a plain conflict.
53
+ if (message.includes("is owned by code") || message.includes("it is a singleton") || message.includes("exists in every locale")) {
54
+ return err("blocked", message);
55
+ }
46
56
  if (message.includes("already exists"))
47
57
  return err("conflict", message);
48
58
  if (message.includes("is referenced by"))
@@ -75,6 +85,9 @@ export function createAdminOps(deps) {
75
85
  const href = deps.href ?? ((path) => path);
76
86
  const collectionFields = (name) => config.registry.collections?.find((c) => c.name === name)?.fields ?? null;
77
87
  const sectionFields = (type) => config.registry.sections?.find((s) => s.schema.name === type)?.schema;
88
+ const sharedItem = (name) => config.registry.shared?.find((s) => s.name === name) ?? null;
89
+ /** One row per registration; null when materialization could not create it. */
90
+ const sharedRecord = async (name) => (await entries.list(name))[0] ?? null;
78
91
  const fanOut = async (entryId) => {
79
92
  if (!effects?.revalidate)
80
93
  return;
@@ -83,6 +96,24 @@ export function createAdminOps(deps) {
83
96
  effects.revalidate(entryTag(id));
84
97
  for (const id of pageIds)
85
98
  effects.revalidate(pageTag(id));
99
+ // A shared item's row is an entry like any other: when the item
100
+ // itself changes OR when something it REFERENCES does, the unit
101
+ // `getSmoodlyShared` caches under `shared:<name>` is stale too — and
102
+ // that unit is a layout, on every page. `affectedTargets` returns row
103
+ // ids, so resolve name → row id here. Once per fan-out, not memoized
104
+ // per ops instance: a row can be materialized between two calls, and
105
+ // an entry write is rare enough that one `entries.list` per
106
+ // registration beats reasoning about a stale cache. Last, so the
107
+ // existing entry-then-page order is untouched.
108
+ const registered = config.registry.shared ?? [];
109
+ if (registered.length === 0)
110
+ return;
111
+ const touched = new Set(entryIds);
112
+ for (const item of registered) {
113
+ const row = (await entries.list(item.name))[0];
114
+ if (row && touched.has(row.id))
115
+ effects.revalidate(sharedTag(item.name));
116
+ }
86
117
  };
87
118
  /** Expire the cached units a tree change touched, in order, once each. */
88
119
  const expirePages = (ids) => {
@@ -404,6 +435,58 @@ export function createAdminOps(deps) {
404
435
  return ok(entry);
405
436
  }),
406
437
  },
438
+ shared: {
439
+ list: () => guard(async () => {
440
+ const records = await ensureSharedItems(entries, config);
441
+ return ok((config.registry.shared ?? []).flatMap((s) => (records[s.name] ? [{ name: s.name, record: records[s.name] }] : [])));
442
+ }),
443
+ get: (name, locale) => guard(async () => {
444
+ const unsupported = supportedLocale(locale);
445
+ if (unsupported)
446
+ return unsupported;
447
+ const item = sharedItem(name);
448
+ if (!item)
449
+ return err("not_found", `smoodly: no shared item named "${name}".`);
450
+ await ensureSharedItems(entries, config);
451
+ const record = await sharedRecord(name);
452
+ if (!record?.locales[locale])
453
+ return err("not_found", `smoodly: shared item "${name}" does not exist in locale "${locale}".`);
454
+ const usage = await entries.referencesTo(name, record.id);
455
+ return ok({ name, record, locale, fields: entryFieldsIn(record, locale), usage });
456
+ }),
457
+ save: (name, locale, fields) => guard(async () => {
458
+ const unsupported = supportedLocale(locale);
459
+ if (unsupported)
460
+ return unsupported;
461
+ const item = sharedItem(name);
462
+ if (!item)
463
+ return err("not_found", `smoodly: no shared item named "${name}".`);
464
+ const fieldErrors = validateFields(fields, item.fields);
465
+ if (fieldErrors.length > 0)
466
+ return err("validation", "Some fields need attention.", fieldErrors);
467
+ const record = await sharedRecord(name);
468
+ if (!record)
469
+ return err("not_found", `smoodly: shared item "${name}" is not materialized — open Shared content first.`);
470
+ const saved = await entries.update(name, record.id, locale, fields);
471
+ // fanOut expires `shared:<name>` itself — this row IS the item's.
472
+ await fanOut(record.id);
473
+ return ok(saved);
474
+ }),
475
+ setStatus: (name, locale, status) => guard(async () => {
476
+ const unsupported = supportedLocale(locale);
477
+ if (unsupported)
478
+ return unsupported;
479
+ if (!sharedItem(name))
480
+ return err("not_found", `smoodly: no shared item named "${name}".`);
481
+ const record = await sharedRecord(name);
482
+ if (!record)
483
+ return err("not_found", `smoodly: shared item "${name}" is not materialized — open Shared content first.`);
484
+ const changed = await entries.setStatus(name, record.id, locale, status);
485
+ // see save: the fan-out carries the item's own tag.
486
+ await fanOut(record.id);
487
+ return ok(changed);
488
+ }),
489
+ },
407
490
  preview: {
408
491
  enable: () => guard(async () => (await effects?.draft?.enable(), ok(null))),
409
492
  disable: () => guard(async () => (await effects?.draft?.disable(), ok(null))),
@@ -1,6 +1,6 @@
1
1
  import type { PageRecord } from "../store.ts";
2
2
  import type { PageTree } from "../render.tsx";
3
- import type { EntryRecord } from "../entry-store.ts";
3
+ import type { EntryRecord, EntryUsage } from "../entry-store.ts";
4
4
  import type { FieldError } from "./validate.ts";
5
5
  export type OpError = {
6
6
  ok: false;
@@ -56,6 +56,20 @@ export type EntryGetResult = {
56
56
  /** The locales the PARENT exists in (every supported locale for a root) — the form disables the rest with a reason. */
57
57
  parentLocales: string[];
58
58
  };
59
+ export type SharedListItem = {
60
+ name: string;
61
+ record: EntryRecord;
62
+ };
63
+ export type SharedGetResult = {
64
+ name: string;
65
+ /** The node with ALL its locale rows (every supported one, by construction). */
66
+ record: EntryRecord;
67
+ locale: string;
68
+ /** That locale's merged draft fields — a visual item's styles under `$styles`. */
69
+ fields: Record<string, unknown>;
70
+ /** Who links it: "used on N pages". */
71
+ usage: EntryUsage[];
72
+ };
59
73
  export type AdminOps = {
60
74
  pages: {
61
75
  /** Lists every node, materializing fixed pages and collection mounts (in every supported locale) first. */
@@ -105,6 +119,13 @@ export type AdminOps = {
105
119
  /** Deletes the locale's row, versions and paths; fans out like a save. */
106
120
  removeLocale(collection: string, id: string, locale: string): Promise<OpResult<EntryRecord>>;
107
121
  };
122
+ shared: {
123
+ /** Materializes every registered item in every supported locale, then lists them in registry order. */
124
+ list(): Promise<OpResult<SharedListItem[]>>;
125
+ get(name: string, locale: string): Promise<OpResult<SharedGetResult>>;
126
+ save(name: string, locale: string, fields: Record<string, unknown>): Promise<OpResult<EntryRecord>>;
127
+ setStatus(name: string, locale: string, status: "draft" | "published"): Promise<OpResult<EntryRecord>>;
128
+ };
108
129
  preview: {
109
130
  enable(): Promise<OpResult<null>>;
110
131
  disable(): Promise<OpResult<null>>;
@@ -33,10 +33,21 @@ export type AdminPageTemplate = {
33
33
  slugs?: Record<string, string>;
34
34
  zones: Record<string, ZonePolicy>;
35
35
  };
36
+ export type AdminShared = {
37
+ name: string;
38
+ title: string;
39
+ /** The section a visual item renders through; absent = an object item. */
40
+ section?: string;
41
+ versions: boolean;
42
+ fields: Record<string, AdminField>;
43
+ /** Resolved (the config's standard set merged in) for a visual item; empty for an object item. */
44
+ styles: Record<string, AdminField>;
45
+ };
36
46
  export type AdminRegistry = {
37
47
  sections: AdminSection[];
38
48
  collections: AdminCollection[];
39
49
  pages: AdminPageTemplate[];
50
+ shared: AdminShared[];
40
51
  locales: {
41
52
  default: string;
42
53
  supported: string[];
@@ -53,6 +53,14 @@ export function serializeAdminConfig(config) {
53
53
  zones: p.zones ?? {},
54
54
  };
55
55
  }),
56
+ shared: (config.registry.shared ?? []).map((s) => ({
57
+ name: s.name,
58
+ title: s.title,
59
+ ...(s.section !== undefined ? { section: s.section } : {}),
60
+ versions: s.versions === true,
61
+ fields: serializeMap(s.fields),
62
+ styles: serializeMap(s.section !== undefined ? config.resolvedStyles(s.section) : {}),
63
+ })),
56
64
  locales: config.locales,
57
65
  homePageSlug: config.homePageSlug,
58
66
  pageDepth: config.pageDepth,
@@ -0,0 +1,9 @@
1
+ import type { ResolvedConfig } from "../config.ts";
2
+ import type { Descriptor } from "../fields.ts";
3
+ import type { EntryRecord, EntryStore } from "../entry-store.ts";
4
+ import { type SharedSchema } from "../shared.ts";
5
+ /** The merged fields a fresh item starts from: sampled field values, and
6
+ * for a visual item `$styles` = the resolved style defaults with the
7
+ * sample's style values on top. Unsampled fields stay absent. */
8
+ export declare function initialSharedFields(item: SharedSchema, resolvedStyles: Record<string, Descriptor>): Record<string, unknown>;
9
+ export declare function ensureSharedItems(entries: EntryStore, config: ResolvedConfig): Promise<Record<string, EntryRecord>>;
@@ -0,0 +1,61 @@
1
+ import { STYLES_KEY } from "../shared.js";
2
+ /** The merged fields a fresh item starts from: sampled field values, and
3
+ * for a visual item `$styles` = the resolved style defaults with the
4
+ * sample's style values on top. Unsampled fields stay absent. */
5
+ export function initialSharedFields(item, resolvedStyles) {
6
+ const out = {};
7
+ for (const key of Object.keys(item.fields)) {
8
+ const sampled = item.sample?.[key];
9
+ if (sampled !== undefined)
10
+ out[key] = sampled;
11
+ }
12
+ if (item.section !== undefined) {
13
+ const styles = {};
14
+ for (const [key, d] of Object.entries(resolvedStyles)) {
15
+ const value = item.sample?.[key] ?? d.default;
16
+ if (value !== undefined)
17
+ styles[key] = value;
18
+ }
19
+ out[STYLES_KEY] = styles;
20
+ }
21
+ return out;
22
+ }
23
+ export async function ensureSharedItems(entries, config) {
24
+ const { default: def, supported } = config.locales;
25
+ const out = {};
26
+ for (const item of config.registry.shared ?? []) {
27
+ let [record] = await entries.list(item.name);
28
+ if (!record) {
29
+ try {
30
+ const styles = item.section !== undefined ? config.resolvedStyles(item.section) : {};
31
+ record = await entries.create(item.name, initialSharedFields(item, styles), { locale: def });
32
+ }
33
+ catch (e) {
34
+ const message = e instanceof Error ? e.message : String(e);
35
+ // Another admin load won the create: read theirs. Anything else is
36
+ // worth saying out loud, and must not take the other items down.
37
+ if (message.includes("it is a singleton"))
38
+ [record] = await entries.list(item.name);
39
+ else
40
+ console.warn(`smoodly: could not materialize shared item "${item.name}": ${message}`);
41
+ if (!record)
42
+ continue;
43
+ }
44
+ }
45
+ for (const locale of supported) {
46
+ if (record.locales[locale])
47
+ continue;
48
+ try {
49
+ record = await entries.addLocale(item.name, record.id, locale, { from: def });
50
+ }
51
+ catch (e) {
52
+ const message = e instanceof Error ? e.message : String(e);
53
+ if (!message.includes("already exists in locale")) {
54
+ console.warn(`smoodly: could not materialize shared item "${item.name}" in "${locale}": ${message}`);
55
+ }
56
+ }
57
+ }
58
+ out[item.name] = record;
59
+ }
60
+ return out;
61
+ }
@@ -14,6 +14,8 @@ import { makeClientOps } from "../client-ops.js";
14
14
  import { PagesList } from "./PagesList.js";
15
15
  import { EntriesList } from "./EntriesList.js";
16
16
  import { EntryForm } from "./EntryForm.js";
17
+ import { SharedList } from "./SharedList.js";
18
+ import { SharedForm } from "./SharedForm.js";
17
19
  import { EditorView } from "../editor/EditorView.js";
18
20
  import { LoginView } from "./LoginView.js";
19
21
  import { useAdminSession } from "./session.js";
@@ -22,12 +24,12 @@ import { BASE } from "./base.js";
22
24
  import { ErrorPane } from "./ErrorPane.js";
23
25
  /** Nav items the registry can't produce yet: shown, disabled, so the shape
24
26
  * of the product is visible — the same set the design system's SideNav has. */
25
- const PLANNED = ["Shared Content", "Media", "Settings"];
27
+ const PLANNED = ["Media", "Settings"];
26
28
  function Nav({ registry, active, email, onSignOut }) {
27
29
  const item = (href, label, key) => (_jsx("a", { className: "sm-nav-item", href: href ?? undefined, "aria-disabled": href ? undefined : true, "aria-current": href && key === active ? "page" : undefined, children: label }, key));
28
30
  const name = email?.split("@")[0] ?? "Editor";
29
31
  const initials = name.slice(0, 2).toUpperCase();
30
- return (_jsxs("nav", { className: "sm-nav", children: [_jsx(Logo, {}), _jsxs("div", { className: "sm-nav__list", children: [item(`${BASE}/pages`, "Pages", "pages"), registry.collections.map((c) => item(`${BASE}/${c.name}`, c.title, c.name)), PLANNED.map((label) => item(null, label, label))] }), _jsxs("div", { className: "sm-nav__foot", children: [_jsx(ThemeToggle, {}), _jsxs("div", { className: "sm-nav__user", children: [_jsx("span", { className: "sm-avatar", children: initials }), _jsxs("span", { style: { display: "flex", flexDirection: "column", minWidth: 0 }, children: [_jsx("span", { style: { fontWeight: 500, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, children: name }), _jsx("span", { style: { fontSize: "var(--text-sm)", color: "var(--text-muted)" }, children: "Editor" })] })] }), _jsx("button", { className: "sm-nav-item", onClick: onSignOut, style: { color: "var(--text-muted)" }, children: "Sign out" })] })] }));
32
+ return (_jsxs("nav", { className: "sm-nav", children: [_jsx(Logo, {}), _jsxs("div", { className: "sm-nav__list", children: [item(`${BASE}/pages`, "Pages", "pages"), registry.collections.map((c) => item(`${BASE}/${c.name}`, c.title, c.name)), registry.shared.length > 0 && item(`${BASE}/shared`, "Shared content", "shared"), PLANNED.map((label) => item(null, label, label))] }), _jsxs("div", { className: "sm-nav__foot", children: [_jsx(ThemeToggle, {}), _jsxs("div", { className: "sm-nav__user", children: [_jsx("span", { className: "sm-avatar", children: initials }), _jsxs("span", { style: { display: "flex", flexDirection: "column", minWidth: 0 }, children: [_jsx("span", { style: { fontWeight: 500, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, children: name }), _jsx("span", { style: { fontSize: "var(--text-sm)", color: "var(--text-muted)" }, children: "Editor" })] })] }), _jsx("button", { className: "sm-nav-item", onClick: onSignOut, style: { color: "var(--text-muted)" }, children: "Sign out" })] })] }));
31
33
  }
32
34
  export function AdminApp({ registry, segments, op, auth }) {
33
35
  const session = useAdminSession(auth);
@@ -49,6 +51,10 @@ export function AdminApp({ registry, segments, op, auth }) {
49
51
  main = _jsx(EntriesList, { ops: ops, registry: registry, collection: head });
50
52
  else if (isCollection && tail)
51
53
  main = _jsx(EntryForm, { ops: ops, registry: registry, collection: head, id: tail === "new" ? "new" : decodeURIComponent(tail) });
54
+ else if (head === "shared" && !tail)
55
+ main = _jsx(SharedList, { ops: ops, registry: registry });
56
+ else if (head === "shared" && tail)
57
+ main = _jsx(SharedForm, { ops: ops, registry: registry, name: decodeURIComponent(tail) });
52
58
  else
53
59
  main = _jsx(ErrorPane, { message: "Not found." });
54
60
  return (_jsxs("div", { className: "sm-shell", children: [_jsx(Nav, { registry: registry, active: head, email: email, onSignOut: () => session.client.auth.signOut() }), _jsx("main", { className: "sm-main", children: main })] }));