smoodly 0.0.18 → 0.0.19

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 (57) hide show
  1. package/README.md +36 -1
  2. package/dist/admin/client-ops.js +1 -1
  3. package/dist/admin/editor/EditorView.js +17 -30
  4. package/dist/admin/editor/GlobalInspector.d.ts +1 -1
  5. package/dist/admin/editor/GlobalInspector.js +3 -2
  6. package/dist/admin/editor/NoticePane.js +3 -1
  7. package/dist/admin/forms/FieldWidget.d.ts +6 -6
  8. package/dist/admin/forms/FieldWidget.js +22 -10
  9. package/dist/admin/forms/RefPicker.d.ts +11 -0
  10. package/dist/admin/forms/RefPicker.js +30 -0
  11. package/dist/admin/forms/TagsField.d.ts +10 -0
  12. package/dist/admin/forms/TagsField.js +38 -0
  13. package/dist/admin/forms/ref-picker.d.ts +25 -0
  14. package/dist/admin/forms/ref-picker.js +30 -0
  15. package/dist/admin/forms/useRefOptions.d.ts +7 -0
  16. package/dist/admin/forms/useRefOptions.js +36 -0
  17. package/dist/admin/forms/useTagSuggestions.d.ts +5 -0
  18. package/dist/admin/forms/useTagSuggestions.js +22 -0
  19. package/dist/admin/ops-impl.js +23 -4
  20. package/dist/admin/ops.d.ts +4 -0
  21. package/dist/admin/serialize.d.ts +2 -0
  22. package/dist/admin/serialize.js +1 -0
  23. package/dist/admin/shell/AdminApp.js +8 -2
  24. package/dist/admin/shell/EntriesList.js +3 -2
  25. package/dist/admin/shell/EntryForm.js +14 -28
  26. package/dist/admin/shell/ErrorPane.d.ts +4 -2
  27. package/dist/admin/shell/ErrorPane.js +6 -4
  28. package/dist/admin/shell/GlobalFields.d.ts +3 -6
  29. package/dist/admin/shell/GlobalFields.js +1 -1
  30. package/dist/admin/shell/GlobalForm.js +8 -22
  31. package/dist/admin/shell/GlobalList.js +3 -2
  32. package/dist/admin/shell/IconRail.js +3 -2
  33. package/dist/admin/shell/PagesList.js +4 -3
  34. package/dist/admin/shell/base.d.ts +4 -1
  35. package/dist/admin/shell/base.js +15 -3
  36. package/dist/admin/shell/router.d.ts +4 -3
  37. package/dist/admin/shell/router.js +17 -14
  38. package/dist/admin/ui/Combobox.d.ts +30 -0
  39. package/dist/admin/ui/Combobox.js +89 -0
  40. package/dist/admin/ui/combo-box.d.ts +16 -0
  41. package/dist/admin/ui/combo-box.js +21 -0
  42. package/dist/admin/ui/theme.js +36 -0
  43. package/dist/admin/validate.js +39 -0
  44. package/dist/admin-path.d.ts +9 -0
  45. package/dist/admin-path.js +24 -0
  46. package/dist/config.d.ts +10 -1
  47. package/dist/config.js +4 -0
  48. package/dist/entry-store.js +1 -1
  49. package/dist/fields.d.ts +3 -0
  50. package/dist/fields.js +5 -0
  51. package/dist/index.d.ts +1 -0
  52. package/dist/index.js +1 -0
  53. package/dist/page.js +1 -1
  54. package/dist/supabase-entry-store.js +1 -1
  55. package/dist/tags.d.ts +19 -0
  56. package/dist/tags.js +50 -0
  57. package/package.json +1 -1
package/README.md CHANGED
@@ -495,6 +495,41 @@ after both are on the registry. From the repo root:
495
495
  `prepublishOnly` rebuilds each package. The Claude Code skill
496
496
  `.claude/skills/releasing-smoodly` carries the same order with its checks.
497
497
 
498
+ ## Follow-ups (logged 2026-09-17, admin path spec)
499
+
500
+ - A root page slugged like the admin path (or `api`) is unreachable:
501
+ Next's static segment wins over the site catch-all. Refuse it in the
502
+ slug check once `config.admin.path` exists.
503
+ - A robots `Disallow` for the admin path from `next/meta`.
504
+
505
+ ## Follow-ups (logged 2026-09-17, reference picker and tags spec)
506
+
507
+ - A `Create…` button in the reference picker: create the target entry
508
+ without leaving the form. Needs its own answer for modal vs navigation
509
+ and for the parent form's pending autosave.
510
+ - Server search for ref candidates: `useRefOptions` loads the whole
511
+ target collection once per locale. When a collection outgrows that,
512
+ the hook changes (a search op, plus "titles for these ids" for the
513
+ picked rows); `RefPicker` takes options as a prop and does not.
514
+ - Thumbnails in picker rows: collections have no preview-image
515
+ convention (`titleField` only).
516
+ - `entries.distinct` flattens in JS over `entries.list`; an RPC over
517
+ `jsonb_array_elements` when a collection outgrows it.
518
+ - Tags keep insertion order; no drag reorder.
519
+ - Tag suggestions exist on collection entry forms only, and only for
520
+ top-level `f.tags()` fields: a tags field inside an object, on a
521
+ section, a global or a page is a plain chip input.
522
+ - The picker's "open" link goes to `/admin/<collection>/<id>` without
523
+ the edited locale; the form opens in the URL's or the default locale.
524
+ - The Combobox list flips upwards when it does not fit under the input
525
+ (`opensUp`, measured against the nearest clipping ancestor); it does
526
+ not yet shrink when neither side has room for it.
527
+ - `RefOption.status` is the three-way `LocaleStatus` now; the old
528
+ "(draft)" suffix in option titles is gone with the native select.
529
+ - A `refList` on a global item's `settings` still loads no options in
530
+ the Globals form or the page editor: both collect targets from the
531
+ item's `fields` only.
532
+
498
533
  ## Follow-ups (logged 2026-09-15, locale alternates spec)
499
534
 
500
535
  - A layout-only language switch, if a real site needs one: a
@@ -1619,4 +1654,4 @@ the toolbar instead of pushing the content.
1619
1654
  - `serialize.ts` structural cast duplicates `PageSchema`; tighten `Registered.schema` so the cast goes.
1620
1655
  - Catch-all `templates` map should filter to open registrations to state its intent (unreachable today).
1621
1656
  - The no-database build is the real proof that routes never prerender against the stores; the unit test only stubs `next/server`. Script it (`SUPABASE_URL=http://127.0.0.1:9 npx next build` in `examples/site`, expect `ƒ /`) and add it to CI, which today runs only the package's tests and typecheck — so a regression is caught when Next changes `connection()` semantics.
1622
- - `EditorView` split (autosave extracted 2026-09-03; the rest 2026-09-08, before the TipTap widget): `usePageLocale` (switch/add/remove, over `locale-url.ts`), `useCanvasBridge` (geometry, select/hover, `postToCanvas`, the geometry timeout), `CanvasOverlay` over `overlay-geometry.ts` (`plusButtonsFor`, pure and tested), `InsertPicker`, `Outline`, `NoticePane`. `EditorView.tsx` is ~660 lines of load, gestures, publish and layout. Remaining seam: the inspector's field form (`FieldWidget` rows plus the tabs) stays put until inline editing lands (DESIGN.md §3 puts TipTap in the canvas, not the sidebar); the ref-option loading (`refTargets` + the `ops.entries.list` effect) duplicates `EntryForm`'s and could be one hook. (logged 2026-09-06, updated 2026-09-08)
1657
+ - `EditorView` split (autosave extracted 2026-09-03; the rest 2026-09-08, before the TipTap widget): `usePageLocale` (switch/add/remove, over `locale-url.ts`), `useCanvasBridge` (geometry, select/hover, `postToCanvas`, the geometry timeout), `CanvasOverlay` over `overlay-geometry.ts` (`plusButtonsFor`, pure and tested), `InsertPicker`, `Outline`, `NoticePane`. `EditorView.tsx` is ~660 lines of load, gestures, publish and layout. Remaining seam: the inspector's field form (`FieldWidget` rows plus the tabs) stays put until inline editing lands (DESIGN.md §3 puts TipTap in the canvas, not the sidebar). The ref-option loading became `forms/useRefOptions.ts` (2026-09-17). (logged 2026-09-06, updated 2026-09-08)
@@ -1,6 +1,6 @@
1
1
  const GROUPS = {
2
2
  pages: ["list", "get", "create", "saveDraft", "publish", "unpublish", "discardDraft", "delete", "rename", "move", "setTemplate", "addLocale", "removeLocale", "listVersions", "restore"],
3
- entries: ["list", "get", "create", "save", "setStatus", "discardDraft", "delete", "move", "addLocale", "removeLocale", "listVersions", "getVersion", "restore"],
3
+ entries: ["list", "distinct", "get", "create", "save", "setStatus", "discardDraft", "delete", "move", "addLocale", "removeLocale", "listVersions", "getVersion", "restore"],
4
4
  globals: ["list", "get", "save", "setStatus", "discardDraft", "listVersions", "getVersion", "restore"],
5
5
  assets: ["list", "get", "beginUpload", "finishUpload", "update", "delete", "usage"],
6
6
  preview: ["enable", "disable"],
@@ -32,6 +32,7 @@ import { CSS_KEY, GLOBAL_NODE_TYPE, SETTINGS_KEY, STYLES_KEY, globalNameOf } fro
32
32
  import { descriptorAt, getAtPath } from "../field-paths.js";
33
33
  import { FieldWidget } from "../forms/FieldWidget.js";
34
34
  import { revealField } from "../forms/reveal.js";
35
+ import { useRefOptions } from "../forms/useRefOptions.js";
35
36
  import { tabOf, tabsFor } from "../forms/tabs.js";
36
37
  import { InspectorFields, valueOf } from "./InspectorFields.js";
37
38
  import { allowedSections, canInsert, fillLockedZones, findNode, insertNode, moveNode, moveNodeTo, newNodeId, nodeFromSample, patchNode, patchNodeCss, patchNodePath, patchPage, placementNode, removeNode, zoneOf, } from "../tree-ops.js";
@@ -51,9 +52,8 @@ import { useCanvasBridge } from "./useCanvasBridge.js";
51
52
  import { useInlineSession } from "./useInlineSession.js";
52
53
  import { usePageLocale } from "./usePageLocale.js";
53
54
  import { useGlobalEdit } from "./useGlobalEdit.js";
54
- import { BASE } from "../shell/base.js";
55
+ import { useAdminHref } from "../shell/base.js";
55
56
  import { isFixedNode, localeStatusOf, sourceLocaleOf } from "../shell/pages-tree.js";
56
- import { entryTitle } from "../shell/entries-list.js";
57
57
  import { LocaleSwitcher } from "../ui/LocaleSwitcher.js";
58
58
  import { ResizeHandle } from "../ui/ResizeHandle.js";
59
59
  import { PublishControl } from "../ui/PublishControl.js";
@@ -63,6 +63,7 @@ export const withEditorParam = (url) => (url.includes("?") ? `${url}&smoodly-edi
63
63
  /** The site URL of a stored version — History's iframe (spec 2026-09-13 history §5); no editor parameter, so no bridge. */
64
64
  export const withVersionParam = (url, versionId) => `${url}${url.includes("?") ? "&" : "?"}smoodly-version=${encodeURIComponent(versionId)}`;
65
65
  export function EditorView({ ops, registry, id }) {
66
+ const adminHref = useAdminHref();
66
67
  const [record, setRecord] = useState(null);
67
68
  const [tree, setTree] = useState(null);
68
69
  const [loadError, setLoadError] = useState(null);
@@ -96,7 +97,6 @@ export function EditorView({ ops, registry, id }) {
96
97
  /** A global edit since the item's last save ack; and any global edit on this canvas yet. */
97
98
  const [globalDirty, setGlobalDirty] = useState(false);
98
99
  const [globalTouched, setGlobalTouched] = useState(false);
99
- const [refOptions, setRefOptions] = useState({});
100
100
  // Global item name → row id, loaded once per editor mount: the picker's
101
101
  // Global group and a bound locked zone need the id to place a node.
102
102
  const [globalIds, setGlobalIds] = useState(null);
@@ -303,31 +303,18 @@ export function EditorView({ ops, registry, id }) {
303
303
  }
304
304
  return out;
305
305
  }, [registry]);
306
- useEffect(() => {
307
- let cancelled = false;
308
- for (const target of new Set(Object.values(refTargets))) {
309
- const targetMeta = registry.collections.find((c) => c.name === target);
310
- ops.entries.list(target).then((r) => {
311
- // A list issued for the previous locale must not overwrite this one's.
312
- if (cancelled || !r.ok)
313
- return;
314
- const options = r.data
315
- // only entries present in the locale being edited can be referenced there
316
- .filter((e) => e.locales[locale])
317
- .map((e) => ({
318
- id: e.id,
319
- title: targetMeta ? entryTitle(e, targetMeta, registry, locale) : e.id,
320
- status: e.locales[locale].status,
321
- }));
322
- setRefOptions((prev) => ({ ...prev, [target]: options }));
323
- }).catch((e) => {
324
- // A rejected transport just leaves this target's options empty —
325
- // ref fields degrade to "nothing to pick from," not a stuck load.
326
- console.error(e);
327
- });
306
+ // A global item edited from the canvas picks refs too: its targets load beside the sections'.
307
+ const refTargetList = useMemo(() => {
308
+ const out = Object.values(refTargets);
309
+ for (const g of registry.globals) {
310
+ for (const d of Object.values(g.fields ?? {})) {
311
+ if ((d.type === "ref" || d.type === "refList") && typeof d.target === "string")
312
+ out.push(d.target);
313
+ }
328
314
  }
329
- return () => { cancelled = true; };
330
- }, [ops, refTargets, registry, locale]);
315
+ return out;
316
+ }, [refTargets, registry]);
317
+ const refOptions = useRefOptions(ops, registry, locale, refTargetList);
331
318
  useEffect(() => {
332
319
  if (registryRef.current.globals.length === 0) {
333
320
  setGlobalIds({});
@@ -759,9 +746,9 @@ export function EditorView({ ops, registry, id }) {
759
746
  const tabs = selectedSection ? tabsFor(selectedSection) : selectedItem ? tabsFor(selectedItem) : [];
760
747
  const activeTab = tabs.some((t) => t.tab === tab) ? tab : tabs[0]?.tab;
761
748
  const activeGroups = tabs.find((t) => t.tab === activeTab)?.groups ?? [];
762
- return (_jsxs(_Fragment, { children: [_jsx(TopBar, { left: _jsx(_Fragment, { children: _jsx(Breadcrumb, { backHref: `${BASE}/pages`, items: [
749
+ return (_jsxs(_Fragment, { children: [_jsx(TopBar, { left: _jsx(_Fragment, { children: _jsx(Breadcrumb, { backHref: adminHref("/pages"), items: [
763
750
  {
764
- label: "Pages", href: `${BASE}/pages`,
751
+ label: "Pages", href: adminHref("/pages"),
765
752
  trailing: registry.locales.supported.length > 1 && (_jsx(LocaleSwitcher, { registry: registry, present: record ? registry.locales.supported.filter((l) => record.locales[l]) : [locale], parentLocales: parentLocales, value: locale, adding: adding, onSwitch: (l) => { void switchLocale(l); }, onAdd: (l) => { void doAddLocale(l); } })),
766
753
  },
767
754
  { label },
@@ -825,5 +812,5 @@ function refOptionsFor(loaded, targets, section, ns, key, descriptor) {
825
812
  if (descriptor.type !== "ref" && descriptor.type !== "refList")
826
813
  return undefined;
827
814
  const target = targets[`${section.name}.${ns}.${key}`];
828
- return target ? loaded[target] ?? [] : [];
815
+ return target ? loaded[target] : [];
829
816
  }
@@ -12,7 +12,7 @@ export declare function GlobalInspector({ item, edit, locale, groups, multilingu
12
12
  /** The active tab's groups (spec 2026-09-14 §5). */
13
13
  groups: Group[];
14
14
  multilingual: boolean;
15
- refOptions: Record<string, RefOption[]>;
15
+ refOptions: Record<string, RefOption[] | undefined>;
16
16
  inlinePath?: string;
17
17
  onEditHere?(): void;
18
18
  onChange(ns: "fields" | "settings" | "styles" | "css", key: string, value: unknown): void;
@@ -1,13 +1,14 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { BASE } from "../shell/base.js";
3
+ import { useAdminHref } from "../shell/base.js";
4
4
  import { localeStatusOf } from "../shell/pages-tree.js";
5
5
  import { GlobalFields } from "../shell/GlobalFields.js";
6
6
  import { PublishControl } from "../ui/PublishControl.js";
7
7
  export function GlobalInspector({ item, edit, locale, groups, multilingual, refOptions, inlinePath, onEditHere, onChange, pending, busy, onPublish, onDiscard, onUnpublish, tabs, }) {
8
+ const adminHref = useAdminHref();
8
9
  const row = edit?.record.locales[locale];
9
10
  // The same three states the global list shows; the control decides what applies.
10
11
  const status = row ? localeStatusOf(row) : "draft";
11
12
  const pages = edit ? [...new Set(edit.usage.filter((u) => u.sourceKind === "page").map((u) => u.sourceId))] : [];
12
- return (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 12 }, children: [_jsx("p", { className: "sm-hint", children: "Global content \u2014 changes every page it is used on." }), pages.length > 0 && (_jsxs("p", { className: "sm-hint", children: [`Used on ${pages.length} page${pages.length === 1 ? "" : "s"}: `, pages.map((id, i) => (_jsxs("span", { children: [i > 0 && ", ", _jsx("a", { href: `${BASE}/pages/${encodeURIComponent(id)}?locale=${encodeURIComponent(locale)}`, children: id })] }, id)))] })), _jsx("div", { style: { display: "flex", alignItems: "center", justifyContent: "flex-end", gap: 8, flexWrap: "wrap" }, children: _jsx(PublishControl, { status: row ? status : null, pending: pending, busy: busy, disabled: !edit, compact: true, onPublish: onPublish, onDiscard: onDiscard, onUnpublish: onUnpublish }) }), tabs, edit ? (_jsx(GlobalFields, { item: item, groups: groups, fields: edit.fields, errors: edit.errors, refOptions: refOptions, multilingual: multilingual, inlinePath: inlinePath, onEditHere: onEditHere, onChange: onChange })) : (_jsx("p", { style: { color: "var(--text-muted)" }, children: "Loading\u2026" }))] }));
13
+ return (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 12 }, children: [_jsx("p", { className: "sm-hint", children: "Global content \u2014 changes every page it is used on." }), pages.length > 0 && (_jsxs("p", { className: "sm-hint", children: [`Used on ${pages.length} page${pages.length === 1 ? "" : "s"}: `, pages.map((id, i) => (_jsxs("span", { children: [i > 0 && ", ", _jsx("a", { href: adminHref(`/pages/${encodeURIComponent(id)}?locale=${encodeURIComponent(locale)}`), children: id })] }, id)))] })), _jsx("div", { style: { display: "flex", alignItems: "center", justifyContent: "flex-end", gap: 8, flexWrap: "wrap" }, children: _jsx(PublishControl, { status: row ? status : null, pending: pending, busy: busy, disabled: !edit, compact: true, onPublish: onPublish, onDiscard: onDiscard, onUnpublish: onUnpublish }) }), tabs, edit ? (_jsx(GlobalFields, { item: item, groups: groups, fields: edit.fields, errors: edit.errors, refOptions: refOptions, multilingual: multilingual, inlinePath: inlinePath, onEditHere: onEditHere, onChange: onChange })) : (_jsx("p", { style: { color: "var(--text-muted)" }, children: "Loading\u2026" }))] }));
13
14
  }
@@ -1,4 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useAdminHref } from "../shell/base.js";
2
3
  export function NoticePane({ children }) {
3
- return (_jsx("div", { style: { display: "grid", placeItems: "center", flex: 1, padding: 40, background: "var(--bg)" }, children: _jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 12, maxWidth: 420 }, children: [children, _jsx("a", { href: "/admin/pages", children: "\u2190 Back to pages" })] }) }));
4
+ const adminHref = useAdminHref();
5
+ return (_jsx("div", { style: { display: "grid", placeItems: "center", flex: 1, padding: 40, background: "var(--bg)" }, children: _jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 12, maxWidth: 420 }, children: [children, _jsx("a", { href: adminHref("/pages"), children: "\u2190 Back to pages" })] }) }));
4
6
  }
@@ -1,11 +1,12 @@
1
1
  import type { ReactElement, ReactNode } from "react";
2
2
  import type { AdminField } from "../serialize.ts";
3
+ import type { RefOption } from "./ref-picker.ts";
3
4
  /** The two namespaces a `fields.` / `settings.` condition reads (spec 2026-09-14 §12). */
4
5
  export type WidgetScope = {
5
6
  fields?: Record<string, unknown>;
6
7
  settings?: Record<string, unknown>;
7
8
  };
8
- export declare function FieldWidget({ descriptor: d, value: valueProp, onChange, errors, path: pathProp, bare, refOptions, fieldKey, prefix, mark, inlinePath, onEditHere, scope, emptyLabel, }: {
9
+ export declare function FieldWidget({ descriptor: d, value: valueProp, onChange, errors, path: pathProp, bare, refOptions, suggestions, fieldKey, prefix, mark, inlinePath, onEditHere, scope, emptyLabel, }: {
9
10
  descriptor: AdminField;
10
11
  value: unknown;
11
12
  onChange(value: unknown): void;
@@ -14,11 +15,10 @@ export declare function FieldWidget({ descriptor: d, value: valueProp, onChange,
14
15
  path?: string;
15
16
  /** Render the control alone, no label wrapper — list items. */
16
17
  bare?: boolean;
17
- refOptions?: {
18
- id: string;
19
- title: string;
20
- status: string;
21
- }[];
18
+ /** Every entry of a ref field's target; undefined while loading. */
19
+ refOptions?: RefOption[];
20
+ /** Tags the collection's other entries use (an entry form's tags fields only). */
21
+ suggestions?: string[];
22
22
  fieldKey: string;
23
23
  prefix?: string;
24
24
  /** A glyph after the label — the forms' "same in every language" mark. */
@@ -3,20 +3,23 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
3
3
  import { RichtextWidget } from "./RichtextWidget.js";
4
4
  import { MediaWidget } from "./MediaWidget.js";
5
5
  import { ListField } from "./ListField.js";
6
+ import { RefPicker } from "./RefPicker.js";
7
+ import { TagsField } from "./TagsField.js";
6
8
  import { RowsWidget } from "./RowsWidget.js";
7
9
  import { optionsOf } from "../../fields.js";
8
10
  import { isHidden } from "../../conditions.js";
9
11
  import { cssProblem } from "../../css.js";
10
- import { Button, Field, IconButton, Switch } from "../ui/primitives.js";
12
+ import { Button, Field, Switch } from "../ui/primitives.js";
11
13
  import { patchObject } from "./list.js";
12
14
  /** A key as a caption, sentence case: `spacingTop` → "Spacing top", `css` → "Custom CSS". */
13
15
  const label = (key) => key === "css" ? "Custom CSS" : key.replace(/([A-Z])/g, (c) => ` ${c.toLowerCase()}`).replace(/^./, (c) => c.toUpperCase());
14
- export function FieldWidget({ descriptor: d, value: valueProp, onChange, errors, path: pathProp, bare, refOptions = [], fieldKey, prefix, mark, inlinePath, onEditHere, scope, emptyLabel, }) {
16
+ export function FieldWidget({ descriptor: d, value: valueProp, onChange, errors, path: pathProp, bare, refOptions, suggestions, fieldKey, prefix, mark, inlinePath, onEditHere, scope, emptyLabel, }) {
15
17
  const path = pathProp ?? fieldKey;
16
- const message = errors?.[path];
18
+ // A tags field has no per-chip slot: the first item error shows under the field.
19
+ const below = d.type === "tags" && errors ? Object.keys(errors).find((k) => k.startsWith(`${path}.`)) : undefined;
20
+ const message = errors?.[path] ?? (below ? errors?.[below] : undefined);
17
21
  // The descriptor's default shows while the value is absent (spec 2026-09-10 page fields §1).
18
22
  const value = valueProp === undefined && d.default !== undefined ? d.default : valueProp;
19
- const optionTitle = (o) => o.status === "draft" ? `${o.title} (draft)` : o.title;
20
23
  // The custom section's rows (spec 2026-09-14 §6): the row cards instead of the generic list.
21
24
  if (d.widget === "rows") {
22
25
  const rows = _jsx(RowsWidget, { descriptor: d, value: value, onChange: onChange, errors: errors, path: path, inlinePath: inlinePath, onEditHere: onEditHere });
@@ -24,7 +27,7 @@ export function FieldWidget({ descriptor: d, value: valueProp, onChange, errors,
24
27
  }
25
28
  let control;
26
29
  let counter;
27
- let asDiv = d.type === "refList";
30
+ let asDiv = false;
28
31
  switch (d.type) {
29
32
  case "text":
30
33
  case "link":
@@ -76,6 +79,14 @@ export function FieldWidget({ descriptor: d, value: valueProp, onChange, errors,
76
79
  control = (_jsx("div", { className: "sm-choices", children: all.map((o) => (_jsxs("label", { className: "sm-choice", children: [_jsx("input", { type: "checkbox", checked: chosen.includes(o.value), onChange: (e) => toggle(o.value, e.target.checked) }), o.label] }, o.value))) }));
77
80
  break;
78
81
  }
82
+ case "tags": {
83
+ asDiv = true; // chips carry buttons
84
+ const max = typeof d.max === "number" ? d.max : undefined;
85
+ if (max !== undefined)
86
+ counter = `${Array.isArray(value) ? value.length : 0} of ${max}`;
87
+ control = _jsx(TagsField, { value: value, onChange: onChange, max: max, suggestions: suggestions, label: label(fieldKey) });
88
+ break;
89
+ }
79
90
  case "richtext":
80
91
  // The toolbar holds buttons, so this control can never sit in a <label>.
81
92
  asDiv = true;
@@ -90,12 +101,13 @@ export function FieldWidget({ descriptor: d, value: valueProp, onChange, errors,
90
101
  control = _jsx(MediaWidget, { descriptor: d, value: value, onChange: onChange });
91
102
  break;
92
103
  case "ref":
93
- control = (_jsxs("select", { className: "sm-select", value: String(value ?? ""), onChange: (e) => onChange(e.target.value || undefined), children: [_jsx("option", { value: "", children: "\u2014" }), refOptions.map((o) => _jsx("option", { value: o.id, children: optionTitle(o) }, o.id))] }));
94
- break;
95
104
  case "refList": {
96
- const ids = Array.isArray(value) ? value : [];
97
- const byId = new Map(refOptions.map((o) => [o.id, o]));
98
- 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)))] })] }));
105
+ asDiv = true; // rows of buttons: never inside one implicit label
106
+ const multiple = d.type === "refList";
107
+ const max = typeof d.max === "number" ? d.max : undefined;
108
+ if (multiple && max !== undefined)
109
+ counter = `${Array.isArray(value) ? value.length : 0} of ${max}`;
110
+ control = _jsx(RefPicker, { value: value, onChange: onChange, options: refOptions, multiple: multiple, max: max, target: typeof d.target === "string" ? d.target : "" });
99
111
  break;
100
112
  }
101
113
  case "object": {
@@ -0,0 +1,11 @@
1
+ import { type RefOption } from "./ref-picker.ts";
2
+ export declare function RefPicker({ value, onChange, options, multiple, max, target }: {
3
+ value: unknown;
4
+ onChange(value: unknown): void;
5
+ /** Every entry of the target collection; undefined while loading. */
6
+ options: RefOption[] | undefined;
7
+ multiple: boolean;
8
+ max?: number;
9
+ /** The target collection's name: the rows link to its admin form. */
10
+ target: string;
11
+ }): import("react").JSX.Element;
@@ -0,0 +1,30 @@
1
+ "use client";
2
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
+ // One widget for f.ref and f.refList (spec 2026-09-17 §2): a search input,
4
+ // the picked entries under it. A refList's rows drag like a list's.
5
+ import { ExternalLink } from "lucide-react";
6
+ import { Combobox } from "../ui/Combobox.js";
7
+ import { Grip, IconButton, cx } from "../ui/primitives.js";
8
+ import { statusLabel } from "../ui/status-label.js";
9
+ import { useSortable } from "../ui/useSortable.js";
10
+ import { useAdminHref } from "../shell/base.js";
11
+ import { moveItem } from "./list.js";
12
+ import { pickRef, refCandidates, refIds, refRows, refValue, statusTone } from "./ref-picker.js";
13
+ const titleCase = (name) => name.replace(/^./, (c) => c.toUpperCase());
14
+ function StatusDot({ status }) {
15
+ if (status === "")
16
+ return null;
17
+ return _jsx("span", { className: "sm-dot", title: statusLabel(status), style: { background: `var(--status-${statusTone(status)}-fg)` } });
18
+ }
19
+ export function RefPicker({ value, onChange, options, multiple, max, target }) {
20
+ const adminHref = useAdminHref();
21
+ const ids = refIds(value, multiple);
22
+ const rows = refRows(ids, options);
23
+ const candidates = refCandidates(ids, options);
24
+ const byId = new Map(candidates.map((o) => [o.id, o]));
25
+ const atMax = multiple && typeof max === "number" && ids.length >= max;
26
+ const set = (next) => onChange(refValue(next, multiple));
27
+ const sortable = useSortable({ count: multiple ? rows.length : 0, onDrop: (from, to) => set(moveItem(ids, from, to)) });
28
+ const present = (options ?? []).filter((o) => !o.absent).length;
29
+ return (_jsxs("div", { className: "sm-refpicker", children: [_jsx(Combobox, { label: `Search ${titleCase(target)}`, keepOpen: multiple, options: candidates.map((o) => ({ value: o.id, label: o.title })), disabled: options === undefined || atMax, placeholder: atMax ? `Maximum ${max}` : options === undefined ? "Loading…" : "Type to search", emptyText: present === 0 ? `No entries in ${titleCase(target)} yet` : "Every entry is already picked", renderOption: (o) => (_jsxs(_Fragment, { children: [_jsx("span", { className: "sm-refpicker__title", children: o.label }), _jsx(StatusDot, { status: byId.get(o.value)?.status ?? "" })] })), onPick: (id) => set(pickRef(ids, id, multiple, max)) }), rows.length > 0 && (_jsx("div", { className: "sm-refpicker__rows", ref: sortable.containerRef, children: rows.map((r, i) => (_jsxs("div", { style: multiple ? sortable.rowStyle(i) : undefined, className: cx("sm-refrow", r.state !== "ok" && "sm-refrow--muted", multiple && sortable.rowClass(i)), children: [multiple && _jsx(Grip, { ...sortable.handleProps(i) }), _jsxs("span", { className: "sm-refpicker__title", children: [r.title, r.state === "absent" && _jsx("span", { className: "sm-refrow__note", children: " \u00B7 Not available in this locale" })] }), _jsx(StatusDot, { status: r.status }), (r.state === "ok" || r.state === "absent") && (_jsx("a", { className: "sm-iconbtn", href: adminHref(`/${target}/${r.id}`), target: "_blank", rel: "noreferrer", "aria-label": "Open entry", title: "Open entry", children: _jsx(ExternalLink, { size: 13, strokeWidth: 1.75, "aria-hidden": "true" }) })), _jsx(IconButton, { label: "Remove", onClick: () => set(ids.filter((_, j) => j !== i)), children: "\u00D7" })] }, `${r.id}-${i}`))) }))] }));
30
+ }
@@ -0,0 +1,10 @@
1
+ export declare function TagsField({ value, onChange, max, suggestions, label, initialAdding }: {
2
+ value: unknown;
3
+ onChange(value: string[]): void;
4
+ max?: number;
5
+ /** Tags the collection's other entries use; absent outside a collection form — then no list opens. */
6
+ suggestions?: string[];
7
+ label: string;
8
+ /** Start with the input open (static-markup tests). */
9
+ initialAdding?: boolean;
10
+ }): import("react").JSX.Element;
@@ -0,0 +1,38 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ // f.tags() as chips (spec 2026-09-17 §5): a × per chip, a dashed "+ Add"
4
+ // that becomes an inline Combobox. Enter or a comma commits and keeps the
5
+ // input open, Escape closes, leaving commits what was typed, Backspace on
6
+ // an empty input removes the last chip. Every commit goes through addTags.
7
+ import { useEffect, useRef, useState } from "react";
8
+ import { addTags, sameTag } from "../../tags.js";
9
+ import { Combobox } from "../ui/Combobox.js";
10
+ import { cx } from "../ui/primitives.js";
11
+ export function TagsField({ value, onChange, max, suggestions, label, initialAdding = false }) {
12
+ const tags = Array.isArray(value) ? value.filter((v) => typeof v === "string") : [];
13
+ const [adding, setAdding] = useState(initialAdding);
14
+ const [flash, setFlash] = useState(null);
15
+ const timer = useRef(null);
16
+ useEffect(() => () => { if (timer.current)
17
+ clearTimeout(timer.current); }, []);
18
+ const atMax = typeof max === "number" && tags.length >= max;
19
+ const commit = (input) => {
20
+ const r = addTags(tags, input, { max, known: suggestions });
21
+ if (r.tags.length !== tags.length)
22
+ onChange(r.tags);
23
+ if (r.duplicate !== null) {
24
+ setFlash(r.duplicate);
25
+ if (timer.current)
26
+ clearTimeout(timer.current);
27
+ timer.current = setTimeout(() => setFlash(null), 600);
28
+ }
29
+ };
30
+ return (_jsxs("div", { className: "sm-tags", children: [tags.map((t, i) => (_jsxs("span", { className: cx("sm-tag", flash === t && "sm-tag--flash"), children: [t, _jsx("button", { type: "button", className: "sm-tag__x", "aria-label": `Remove ${t}`, onClick: () => onChange(tags.filter((_, j) => j !== i)), children: "\u00D7" })] }, t))), adding && !atMax && (_jsx(Combobox, { className: "sm-combo--tag", label: `Add to ${label}`, placeholder: "Add a tag", autoFocus: true, keepOpen: true, initialOpen: true, options: (suggestions ?? []).filter((s) => !tags.some((t) => sameTag(t, s))).map((s) => ({ value: s, label: s })), onPick: commit, onSubmit: commit, createRow: suggestions !== undefined, onQueryChange: (next) => { if (!next.includes(","))
31
+ return next; commit(next); return ""; }, onInputKey: (e, query) => {
32
+ if (e.key !== "Backspace" || query !== "" || tags.length === 0)
33
+ return false;
34
+ onChange(tags.slice(0, -1));
35
+ return true;
36
+ }, onClose: (query, reason) => { if (reason === "blur" && query.trim() !== "")
37
+ commit(query); setAdding(false); } })), !adding && !atMax && _jsx("button", { type: "button", className: "sm-tag sm-tag--add", onClick: () => setAdding(true), children: "+ Add" })] }));
38
+ }
@@ -0,0 +1,25 @@
1
+ /** One entry of the target collection. `status` is its LocaleStatus in the edited
2
+ * locale; `absent` marks an entry with no row there — never offered, but a picked
3
+ * one is named instead of reading as missing. */
4
+ export type RefOption = {
5
+ id: string;
6
+ title: string;
7
+ status: string;
8
+ absent?: true;
9
+ };
10
+ export type RefRow = {
11
+ id: string;
12
+ title: string;
13
+ status: string;
14
+ state: "ok" | "absent" | "missing" | "loading";
15
+ };
16
+ export declare const refIds: (value: unknown, multiple: boolean) => string[];
17
+ export declare const refValue: (ids: string[], multiple: boolean) => string | string[] | undefined;
18
+ /** A pick: a single ref is replaced, a list grows — never past `max`, never twice the same entry. */
19
+ export declare function pickRef(ids: string[], id: string, multiple: boolean, max?: number): string[];
20
+ /** What the dropdown offers: present in the edited locale, not picked yet. */
21
+ export declare const refCandidates: (ids: string[], options: RefOption[] | undefined) => RefOption[];
22
+ /** The selected rows under the input. `options` undefined = still loading. */
23
+ export declare function refRows(ids: string[], options: RefOption[] | undefined): RefRow[];
24
+ /** The badge tone of a LocaleStatus (ui/primitives.tsx's STATUS_TONE, keyed by the stored status). */
25
+ export declare const statusTone: (status: string) => "published" | "changes" | "draft";
@@ -0,0 +1,30 @@
1
+ // The reference picker's value logic as pure functions (spec 2026-09-17 §2):
2
+ // one widget for `ref` (an id) and `refList` (an id array). RefPicker renders.
3
+ export const refIds = (value, multiple) => multiple
4
+ ? (Array.isArray(value) ? value.filter((v) => typeof v === "string") : [])
5
+ : (typeof value === "string" && value !== "" ? [value] : []);
6
+ export const refValue = (ids, multiple) => multiple ? ids : ids[0];
7
+ /** A pick: a single ref is replaced, a list grows — never past `max`, never twice the same entry. */
8
+ export function pickRef(ids, id, multiple, max) {
9
+ if (!multiple)
10
+ return [id];
11
+ if (ids.includes(id) || (typeof max === "number" && ids.length >= max))
12
+ return ids;
13
+ return [...ids, id];
14
+ }
15
+ /** What the dropdown offers: present in the edited locale, not picked yet. */
16
+ export const refCandidates = (ids, options) => (options ?? []).filter((o) => !o.absent && !ids.includes(o.id));
17
+ /** The selected rows under the input. `options` undefined = still loading. */
18
+ export function refRows(ids, options) {
19
+ if (options === undefined)
20
+ return ids.map((id) => ({ id, title: "…", status: "", state: "loading" }));
21
+ const byId = new Map(options.map((o) => [o.id, o]));
22
+ return ids.map((id) => {
23
+ const o = byId.get(id);
24
+ if (!o)
25
+ return { id, title: "Missing entry", status: "", state: "missing" };
26
+ return { id, title: o.title, status: o.status, state: o.absent ? "absent" : "ok" };
27
+ });
28
+ }
29
+ /** The badge tone of a LocaleStatus (ui/primitives.tsx's STATUS_TONE, keyed by the stored status). */
30
+ export const statusTone = (status) => status === "published" ? "published" : status === "draft edits" ? "changes" : "draft";
@@ -0,0 +1,7 @@
1
+ import type { EntryRecord } from "../../entry-store.ts";
2
+ import type { AdminOps } from "../ops.ts";
3
+ import type { AdminCollection, AdminRegistry } from "../serialize.ts";
4
+ import type { RefOption } from "./ref-picker.ts";
5
+ export declare function refOptionsOf(records: EntryRecord[], meta: AdminCollection | undefined, registry: AdminRegistry, locale: string): RefOption[];
6
+ /** Options by target collection; a target is `undefined` until its list arrives. */
7
+ export declare function useRefOptions(ops: AdminOps, registry: AdminRegistry, locale: string, targets: readonly string[]): Record<string, RefOption[] | undefined>;
@@ -0,0 +1,36 @@
1
+ "use client";
2
+ // Ref candidates for a form (spec 2026-09-17 §2): every entry of each target
3
+ // collection, loaded once per locale and filtered in the browser. One hook
4
+ // for the entry form, the Globals form and the page editor — the picker
5
+ // takes options as a prop, so a move to server search changes this file only.
6
+ import { useEffect, useState } from "react";
7
+ import { entryTitle } from "../shell/entries-list.js";
8
+ import { localeStatusOf } from "../shell/pages-tree.js";
9
+ export function refOptionsOf(records, meta, registry, locale) {
10
+ return records.map((e) => {
11
+ const row = e.locales[locale];
12
+ const title = meta ? entryTitle(e, meta, registry, locale) : e.id;
13
+ return row ? { id: e.id, title, status: localeStatusOf(row) } : { id: e.id, title, status: "", absent: true };
14
+ });
15
+ }
16
+ /** Options by target collection; a target is `undefined` until its list arrives. */
17
+ export function useRefOptions(ops, registry, locale, targets) {
18
+ const [loaded, setLoaded] = useState({});
19
+ const key = [...new Set(targets)].sort().join("\n");
20
+ useEffect(() => {
21
+ let cancelled = false;
22
+ setLoaded({}); // the previous locale's options must not show under this one
23
+ for (const target of key === "" ? [] : key.split("\n")) {
24
+ const meta = registry.collections.find((c) => c.name === target);
25
+ ops.entries.list(target).then((r) => {
26
+ if (cancelled)
27
+ return;
28
+ // A failed list degrades to "nothing to pick from", not a stuck load.
29
+ setLoaded((prev) => ({ ...prev, [target]: r.ok ? refOptionsOf(r.data, meta, registry, locale) : [] }));
30
+ }).catch((e) => { console.error(e); if (!cancelled)
31
+ setLoaded((prev) => ({ ...prev, [target]: [] })); });
32
+ }
33
+ return () => { cancelled = true; };
34
+ }, [ops, registry, locale, key]);
35
+ return loaded;
36
+ }
@@ -0,0 +1,5 @@
1
+ import type { AdminOps } from "../ops.ts";
2
+ import type { AdminField } from "../serialize.ts";
3
+ /** The top-level tags fields — the op reads top-level keys only. */
4
+ export declare const tagFieldKeys: (fields: Record<string, AdminField>) => string[];
5
+ export declare function useTagSuggestions(ops: AdminOps, collection: string, fields: Record<string, AdminField>, locale: string): Record<string, string[] | undefined>;
@@ -0,0 +1,22 @@
1
+ "use client";
2
+ // Suggestions for a collection form's f.tags() fields (spec 2026-09-17 §6):
3
+ // one ops.entries.distinct per top-level tags field, once per locale.
4
+ import { useEffect, useState } from "react";
5
+ /** The top-level tags fields — the op reads top-level keys only. */
6
+ export const tagFieldKeys = (fields) => Object.entries(fields).filter(([, d]) => d.type === "tags").map(([key]) => key);
7
+ export function useTagSuggestions(ops, collection, fields, locale) {
8
+ const [loaded, setLoaded] = useState({});
9
+ const key = tagFieldKeys(fields).join("\n");
10
+ useEffect(() => {
11
+ let cancelled = false;
12
+ setLoaded({});
13
+ for (const field of key === "" ? [] : key.split("\n")) {
14
+ ops.entries.distinct(collection, field, locale).then((r) => {
15
+ if (!cancelled)
16
+ setLoaded((prev) => ({ ...prev, [field]: r.ok ? r.data : [] }));
17
+ }).catch(console.error);
18
+ }
19
+ return () => { cancelled = true; };
20
+ }, [ops, collection, locale, key]);
21
+ return loaded;
22
+ }
@@ -1,6 +1,7 @@
1
1
  import { entryFieldsIn, entryTitleIn } from "../entry-store.js";
2
+ import { distinctTags } from "../tags.js";
2
3
  import { ASSET_COLLECTION } from "../media.js";
3
- import { BASE } from "./shell/base.js";
4
+ import { adminHref } from "../admin-path.js";
4
5
  import { affectedTargets, collectionTag, entryTag, pageTag, pagesTreeTag, globalTag } from "../revalidate.js";
5
6
  import { ensureFixedNodes, fixedNodeOf } from "./fixed-nodes.js";
6
7
  import { ensureGlobalItems } from "./global-items.js";
@@ -207,16 +208,16 @@ export function createAdminOps(deps) {
207
208
  const record = await pages.getPage(sourceId);
208
209
  if (!record)
209
210
  return { title: sourceId, href: null };
210
- return { title: record.locales[def]?.title ?? Object.values(record.locales)[0]?.title ?? sourceId, href: `${BASE}/pages/${sourceId}` };
211
+ return { title: record.locales[def]?.title ?? Object.values(record.locales)[0]?.title ?? sourceId, href: adminHref(config.admin.path, `/pages/${sourceId}`) };
211
212
  }
212
213
  for (const item of config.registry.globals ?? []) {
213
214
  if (await entries.get(item.name, sourceId))
214
- return { title: item.title, href: `${BASE}/globals/${encodeURIComponent(item.name)}` };
215
+ return { title: item.title, href: adminHref(config.admin.path, `/globals/${encodeURIComponent(item.name)}`) };
215
216
  }
216
217
  for (const c of config.registry.collections ?? []) {
217
218
  const record = await entries.get(c.name, sourceId);
218
219
  if (record)
219
- return { title: entryTitleIn(record, def, c), href: `${BASE}/${c.name}/${sourceId}` };
220
+ return { title: entryTitleIn(record, def, c), href: adminHref(config.admin.path, `/${c.name}/${sourceId}`) };
220
221
  }
221
222
  return { title: sourceId, href: null };
222
223
  };
@@ -519,6 +520,24 @@ export function createAdminOps(deps) {
519
520
  },
520
521
  entries: {
521
522
  list: (collection) => guard(async () => ok(await entries.list(collection))),
523
+ // In JS over the list the admin already reads: PostgREST has no DISTINCT over
524
+ // jsonb_array_elements without an RPC (spec 2026-09-17 §6; the RPC is in the register).
525
+ distinct: (collection, field, locale) => guard(async () => {
526
+ const unsupported = supportedLocale(locale);
527
+ if (unsupported)
528
+ return unsupported;
529
+ const descriptors = collectionFields(collection);
530
+ if (!descriptors)
531
+ return err("not_found", `smoodly: no collection named "${collection}".`);
532
+ const d = descriptors[field];
533
+ if (d?.type !== "tags")
534
+ return err("validation", `smoodly: "${field}" is not a tags field of "${collection}".`);
535
+ const records = await entries.list(collection);
536
+ const values = d.localized === true
537
+ ? records.flatMap((r) => (r.locales[locale] ? [r.locales[locale].fields[field]] : []))
538
+ : records.map((r) => r.fields[field]);
539
+ return ok(distinctTags(values));
540
+ }),
522
541
  get: (collection, id, locale) => guard(async () => {
523
542
  const unsupported = supportedLocale(locale);
524
543
  if (unsupported)
@@ -131,6 +131,10 @@ export type AdminOps = {
131
131
  };
132
132
  entries: {
133
133
  list(collection: string): Promise<OpResult<EntryRecord[]>>;
134
+ /** The distinct tags one top-level `f.tags()` field holds across the collection's working copies
135
+ * (drafts included), for the chip widget's suggestions (spec 2026-09-17 §6). A localized field
136
+ * reads `locale`'s rows; a shared one reads every entry. `validation` for any other field. */
137
+ distinct(collection: string, field: string, locale: string): Promise<OpResult<string[]>>;
134
138
  /** not_found only when the entry is missing; an absent locale comes back with `fields: null`. */
135
139
  get(collection: string, id: string, locale: string): Promise<OpResult<EntryGetResult>>;
136
140
  create(collection: string, input: EntryCreateInput): Promise<OpResult<EntryRecord>>;
@@ -85,6 +85,8 @@ export type AdminRegistry = {
85
85
  maxSize: number;
86
86
  accept: string[];
87
87
  };
88
+ /** The admin's mount path: every link and the client router sit under it (spec 2026-09-17 §3). */
89
+ adminPath: string;
88
90
  };
89
91
  /** A registered section's settings descriptors — what a visual global's form shows (spec 2026-09-14 §3). */
90
92
  export declare function sectionSettings(config: ResolvedConfig, name: string): Record<string, Descriptor>;