smoodly 0.0.17 → 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.
- package/README.md +56 -6
- package/dist/admin/client-ops.js +1 -1
- package/dist/admin/editor/CanvasOverlay.js +4 -2
- package/dist/admin/editor/EditorView.js +19 -32
- package/dist/admin/editor/GlobalInspector.d.ts +5 -2
- package/dist/admin/editor/GlobalInspector.js +4 -3
- package/dist/admin/editor/NoticePane.js +3 -1
- package/dist/admin/forms/FieldWidget.d.ts +6 -6
- package/dist/admin/forms/FieldWidget.js +22 -10
- package/dist/admin/forms/RefPicker.d.ts +11 -0
- package/dist/admin/forms/RefPicker.js +30 -0
- package/dist/admin/forms/TagsField.d.ts +10 -0
- package/dist/admin/forms/TagsField.js +38 -0
- package/dist/admin/forms/ref-picker.d.ts +25 -0
- package/dist/admin/forms/ref-picker.js +30 -0
- package/dist/admin/forms/useRefOptions.d.ts +7 -0
- package/dist/admin/forms/useRefOptions.js +36 -0
- package/dist/admin/forms/useTagSuggestions.d.ts +5 -0
- package/dist/admin/forms/useTagSuggestions.js +22 -0
- package/dist/admin/ops-impl.js +23 -4
- package/dist/admin/ops.d.ts +4 -0
- package/dist/admin/serialize.d.ts +2 -0
- package/dist/admin/serialize.js +1 -0
- package/dist/admin/shell/AdminApp.js +8 -2
- package/dist/admin/shell/EntriesList.js +3 -2
- package/dist/admin/shell/EntryForm.js +14 -28
- package/dist/admin/shell/ErrorPane.d.ts +4 -2
- package/dist/admin/shell/ErrorPane.js +6 -4
- package/dist/admin/shell/GlobalFields.d.ts +3 -6
- package/dist/admin/shell/GlobalFields.js +1 -1
- package/dist/admin/shell/GlobalForm.js +8 -22
- package/dist/admin/shell/GlobalList.js +3 -2
- package/dist/admin/shell/IconRail.js +3 -2
- package/dist/admin/shell/PagesList.js +4 -3
- package/dist/admin/shell/base.d.ts +4 -1
- package/dist/admin/shell/base.js +15 -3
- package/dist/admin/shell/router.d.ts +4 -3
- package/dist/admin/shell/router.js +17 -14
- package/dist/admin/ui/ActionMenu.d.ts +3 -1
- package/dist/admin/ui/ActionMenu.js +4 -3
- package/dist/admin/ui/Combobox.d.ts +30 -0
- package/dist/admin/ui/Combobox.js +89 -0
- package/dist/admin/ui/PublishControl.d.ts +3 -1
- package/dist/admin/ui/PublishControl.js +2 -2
- package/dist/admin/ui/combo-box.d.ts +16 -0
- package/dist/admin/ui/combo-box.js +21 -0
- package/dist/admin/ui/primitives.d.ts +1 -1
- package/dist/admin/ui/primitives.js +1 -1
- package/dist/admin/ui/theme.js +43 -4
- package/dist/admin/validate.js +39 -0
- package/dist/admin-path.d.ts +9 -0
- package/dist/admin-path.js +24 -0
- package/dist/config.d.ts +10 -1
- package/dist/config.js +4 -0
- package/dist/entry-page.d.ts +5 -0
- package/dist/entry-page.js +1 -1
- package/dist/entry-store.js +1 -1
- package/dist/fields.d.ts +3 -0
- package/dist/fields.js +5 -0
- package/dist/index.d.ts +4 -3
- package/dist/index.js +2 -1
- package/dist/next/page-renderer.js +7 -3
- package/dist/page.js +1 -1
- package/dist/paths.d.ts +21 -0
- package/dist/paths.js +25 -0
- package/dist/render.d.ts +10 -0
- package/dist/render.js +2 -1
- package/dist/site.d.ts +14 -9
- package/dist/site.js +31 -18
- package/dist/supabase-entry-store.js +1 -1
- package/dist/tags.d.ts +19 -0
- package/dist/tags.js +50 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -336,9 +336,13 @@ Implemented:
|
|
|
336
336
|
fetches the record and its tree (draft or published), resolves refs,
|
|
337
337
|
reads the locale's own rows, and returns a `SitePage` — `record`,
|
|
338
338
|
`template`, `tree`, localized `title`, `path`, `ancestors`, `children`,
|
|
339
|
-
and `
|
|
340
|
-
locale
|
|
341
|
-
|
|
339
|
+
`locale` and `locales` — the page's `{ slug, path }` in every supported
|
|
340
|
+
locale, null where it is absent or, outside draft mode, unpublished
|
|
341
|
+
(spec 2026-09-15) — or a `SiteEntry` with the locale's merged fields (a
|
|
342
|
+
versioned collection's published snapshot on the public path) resolved,
|
|
343
|
+
`locale`, and the same `locales` map (an entry's slug and path are null
|
|
344
|
+
where its row claims no URL). `getEntries` items and `getEntry` carry
|
|
345
|
+
`locale` and `locales` too.
|
|
342
346
|
`resolve(locale, path, { draft? })`
|
|
343
347
|
is the two in sequence — `locate` then `load` — for a caller that just
|
|
344
348
|
wants the hit. A folder (null template), an
|
|
@@ -372,13 +376,14 @@ Implemented:
|
|
|
372
376
|
(`{ id, title, path, url, ancestors, children }`, every ancestor and
|
|
373
377
|
child carrying `url` beside its locale-relative `path`, both put through
|
|
374
378
|
the `href` seam) beside `zones`; a collection
|
|
375
|
-
entry rendered by its `smoodly.entryPage` as `{ entry, path }`; a folder,
|
|
379
|
+
entry rendered by its `smoodly.entryPage` as `{ entry, path, locale, locales }`; a folder,
|
|
376
380
|
an absent locale or an unregistered template a 404. The published read is
|
|
377
381
|
cached per (locale, path) and tagged `page:<id>` / `entry:<id>` — the
|
|
378
382
|
`locate` hit runs outside the cache, because the tag needs the id first.
|
|
379
383
|
Draft mode reads drafts uncached, and the editor param mounts
|
|
380
384
|
`EditorBridge`. Metadata is the tree's `meta` plus
|
|
381
|
-
`alternates.languages`,
|
|
385
|
+
`alternates.languages`, the locales with a URL in the hit's `locales` map
|
|
386
|
+
through the injectable `href(path, locale)`
|
|
382
387
|
seam (identity by default, `getPathname` with next-intl) and emitted only
|
|
383
388
|
for a multi-locale site. `renderSmoodlyPage(Paired, …)` and
|
|
384
389
|
`smoodlyPageMetadata` remain as the escape hatch for a FIXED page
|
|
@@ -490,6 +495,51 @@ after both are on the registry. From the repo root:
|
|
|
490
495
|
`prepublishOnly` rebuilds each package. The Claude Code skill
|
|
491
496
|
`.claude/skills/releasing-smoodly` carries the same order with its checks.
|
|
492
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
|
+
|
|
533
|
+
## Follow-ups (logged 2026-09-15, locale alternates spec)
|
|
534
|
+
|
|
535
|
+
- A layout-only language switch, if a real site needs one: a
|
|
536
|
+
`q().getAlternates(path)` over the `paths` index plus a proxy header
|
|
537
|
+
carrying the request path. Not built until asked for — the starters
|
|
538
|
+
render the switch from the page (`components/PageFrame.tsx`,
|
|
539
|
+
`components/LocaleNav.tsx`).
|
|
540
|
+
- The walk covered the Lorem example only; blank-intl is typechecked, not
|
|
541
|
+
walked. Walk it on the next blank-intl install.
|
|
542
|
+
|
|
493
543
|
## Follow-ups (logged 2026-09-15, custom section spec §6, plan B)
|
|
494
544
|
|
|
495
545
|
- Inline editing of a column on the canvas is OFF (2026-09-15, after the
|
|
@@ -1604,4 +1654,4 @@ the toolbar instead of pushing the content.
|
|
|
1604
1654
|
- `serialize.ts` structural cast duplicates `PageSchema`; tighten `Registered.schema` so the cast goes.
|
|
1605
1655
|
- Catch-all `templates` map should filter to open registrations to state its intent (unreachable today).
|
|
1606
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.
|
|
1607
|
-
- `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)
|
|
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)
|
package/dist/admin/client-ops.js
CHANGED
|
@@ -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"],
|
|
@@ -6,7 +6,9 @@ import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-run
|
|
|
6
6
|
// Positions come from the geometry the bridge reported; the layer
|
|
7
7
|
// itself never touches the iframe.
|
|
8
8
|
import { useState } from "react";
|
|
9
|
-
import { IconButton } from "../ui/primitives.js";
|
|
9
|
+
import { cx, IconButton } from "../ui/primitives.js";
|
|
10
|
+
/** Pixels above a block the riding pill needs (half its height); less and it moves inside. */
|
|
11
|
+
const TAG_CLEARANCE = 14;
|
|
10
12
|
export function CanvasOverlay({ emptyZones, hoverRect, hoverGlobal, selection, plusButtons, fieldHover, editing, onPlus, onMove, onDuplicate, onRemove, }) {
|
|
11
13
|
const [hoverPlus, setHoverPlus] = useState(null);
|
|
12
14
|
return (_jsxs("div", { style: { position: "absolute", inset: 0, pointerEvents: "none" }, children: [emptyZones.map((z) => (_jsxs("div", { className: "sm-dashed", style: {
|
|
@@ -22,7 +24,7 @@ export function CanvasOverlay({ emptyZones, hoverRect, hoverGlobal, selection, p
|
|
|
22
24
|
width: selection.rect.w, height: selection.rect.h,
|
|
23
25
|
outline: selection.global ? "var(--outline-selection-global)" : "var(--outline-selection)", outlineOffset: -1.5,
|
|
24
26
|
borderRadius: "var(--selection-radius)",
|
|
25
|
-
} }), _jsxs("span", { className: selection.global
|
|
27
|
+
} }), _jsxs("span", { className: cx("sm-blocktag", selection.global && "sm-blocktag--global", selection.rect.y < TAG_CLEARANCE && "sm-blocktag--inside"), style: { position: "absolute", left: selection.rect.x, top: selection.rect.y }, children: [selection.title, selection.global && " · global", selection.locked && " · locked"] }), !selection.layout && (_jsx("div", { className: "sm-blocktoolbar", style: {
|
|
26
28
|
position: "absolute", left: selection.rect.x + selection.rect.w - 8, top: selection.rect.y + 8,
|
|
27
29
|
transform: "translateX(-100%)", pointerEvents: "auto",
|
|
28
30
|
}, children: selection.locked ? (_jsx("span", { className: "sm-blocktoolbar__note", children: "fields only" })) : (_jsxs(_Fragment, { children: [_jsx(IconButton, { label: "Move up", tone: "inverse", onClick: () => onMove(selection.node.id, -1), children: "\u2191" }), _jsx(IconButton, { label: "Move down", tone: "inverse", onClick: () => onMove(selection.node.id, 1), children: "\u2193" }), _jsx(IconButton, { label: "Duplicate", tone: "inverse", onClick: () => onDuplicate(selection.node), children: "\u29C9" }), _jsx(IconButton, { label: "Remove", tone: "inverse", onClick: () => onRemove(selection.node.id), children: "\u00D7" })] })) }))] })), plusButtons.map((p) => {
|
|
@@ -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 {
|
|
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
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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
|
|
330
|
-
}, [
|
|
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:
|
|
749
|
+
return (_jsxs(_Fragment, { children: [_jsx(TopBar, { left: _jsx(_Fragment, { children: _jsx(Breadcrumb, { backHref: adminHref("/pages"), items: [
|
|
763
750
|
{
|
|
764
|
-
label: "Pages", href:
|
|
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 },
|
|
@@ -780,7 +767,7 @@ export function EditorView({ ops, registry, id }) {
|
|
|
780
767
|
else
|
|
781
768
|
mutate((t) => patchNodePath(t, session.node, session.path, doc));
|
|
782
769
|
}, onEscape: inline.end }));
|
|
783
|
-
})()] }) })] }), _jsx(ResizeHandle, {}), _jsxs("aside", { className: "sm-inspector", ref: inspectorRef, children: [_jsxs("div", { className: "sm-card", children: [selectedSection || selectedItem ? (_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 ?? (selectedItem ? `${selectedItem.title} · global` : null) }), selectedItem && (globalTouched || globalEdit.saveState === "error") && _jsx(SaveIndicator, { state: globalEdit.saveState })] }), _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) }))] })) : record ? (_jsx(PageInspector, { record: record, locale: locale, registry: registry, tree: tree, path: paths[locale], fixed: isFixedNode(record, registry), onRename: onRename, onPatch: doPatchPage, onRemoveLocale: Object.keys(record.locales).length > 1 &&
|
|
770
|
+
})()] }) })] }), _jsx(ResizeHandle, {}), _jsxs("aside", { className: "sm-inspector", ref: inspectorRef, children: [_jsxs("div", { className: "sm-card", children: [selectedSection || selectedItem ? (_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 ?? (selectedItem ? `${selectedItem.title} · global` : null) }), selectedItem && (globalTouched || globalEdit.saveState === "error") && _jsx(SaveIndicator, { state: globalEdit.saveState })] }), _jsx(IconButton, { label: "Clear selection", onClick: () => setSelectedId(null), children: "\u00D7" })] }), tabs.length > 0 && selectedSection && (_jsx(Tabs, { tabs: tabs.map((t) => t.tab), value: activeTab, onChange: (t) => setTab(t) }))] })) : record ? (_jsx(PageInspector, { record: record, locale: locale, registry: registry, tree: tree, path: paths[locale], fixed: isFixedNode(record, registry), onRename: onRename, onPatch: doPatchPage, onRemoveLocale: Object.keys(record.locales).length > 1 &&
|
|
784
771
|
!isFixedNode(record, registry) &&
|
|
785
772
|
!(record.parentId === null && record.locales[registry.locales.default]?.slug === registry.homePageSlug)
|
|
786
773
|
? () => { void doRemoveLocale(); }
|
|
@@ -792,7 +779,7 @@ export function EditorView({ ops, registry, id }) {
|
|
|
792
779
|
doPatch(selected.id, ns, key, value);
|
|
793
780
|
if (ns === "fields")
|
|
794
781
|
syncTexts(selected.id, selectedSection, { ...selected.fields, [key]: value });
|
|
795
|
-
} }, `${selected.id}.${ns}.${key}`)) })), selectedItem && selectedId && (_jsx(GlobalInspector, { item: selectedItem, edit: globalEdit.items[selectedItem.name] ?? null, locale: locale, groups: activeGroups, multilingual: registry.locales.supported.length > 1, refOptions: refOptions, pending: globalDirty || globalEdit.saveState !== "saved", busy: globalBusy, inlinePath: inline.session?.node === selectedId ? inline.session.path : undefined, onEditHere: () => { const p = inline.session?.path; inline.end(); if (p)
|
|
782
|
+
} }, `${selected.id}.${ns}.${key}`)) })), selectedItem && selectedId && (_jsx(GlobalInspector, { item: selectedItem, edit: globalEdit.items[selectedItem.name] ?? null, locale: locale, groups: activeGroups, multilingual: registry.locales.supported.length > 1, refOptions: refOptions, tabs: tabs.length > 0 ? _jsx(Tabs, { tabs: tabs.map((t) => t.tab), value: activeTab, onChange: (t) => setTab(t) }) : undefined, pending: globalDirty || globalEdit.saveState !== "saved", busy: globalBusy, inlinePath: inline.session?.node === selectedId ? inline.session.path : undefined, onEditHere: () => { const p = inline.session?.path; inline.end(); if (p)
|
|
796
783
|
reveal(p, selectedItem); }, onChange: (ns, key, value) => {
|
|
797
784
|
patchGlobal(selectedItem.name, globalPath(ns, key), value);
|
|
798
785
|
if (ns === "fields") {
|
|
@@ -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
|
}
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
1
2
|
import type { AdminGlobal } from "../serialize.ts";
|
|
2
3
|
import type { Group } from "../forms/tabs.ts";
|
|
3
4
|
import type { GlobalEditItem } from "./global-edit.ts";
|
|
4
5
|
import { type RefOption } from "../shell/GlobalFields.tsx";
|
|
5
6
|
import { type PublishBusy } from "../ui/PublishControl.tsx";
|
|
6
|
-
export declare function GlobalInspector({ item, edit, locale, groups, multilingual, refOptions, inlinePath, onEditHere, onChange, pending, busy, onPublish, onDiscard, onUnpublish, }: {
|
|
7
|
+
export declare function GlobalInspector({ item, edit, locale, groups, multilingual, refOptions, inlinePath, onEditHere, onChange, pending, busy, onPublish, onDiscard, onUnpublish, tabs, }: {
|
|
7
8
|
item: AdminGlobal;
|
|
8
9
|
/** null while the item loads. */
|
|
9
10
|
edit: GlobalEditItem | null;
|
|
@@ -11,7 +12,7 @@ export declare function GlobalInspector({ item, edit, locale, groups, multilingu
|
|
|
11
12
|
/** The active tab's groups (spec 2026-09-14 §5). */
|
|
12
13
|
groups: Group[];
|
|
13
14
|
multilingual: boolean;
|
|
14
|
-
refOptions: Record<string, RefOption[]>;
|
|
15
|
+
refOptions: Record<string, RefOption[] | undefined>;
|
|
15
16
|
inlinePath?: string;
|
|
16
17
|
onEditHere?(): void;
|
|
17
18
|
onChange(ns: "fields" | "settings" | "styles" | "css", key: string, value: unknown): void;
|
|
@@ -21,4 +22,6 @@ export declare function GlobalInspector({ item, edit, locale, groups, multilingu
|
|
|
21
22
|
onPublish(): void;
|
|
22
23
|
onDiscard(): void;
|
|
23
24
|
onUnpublish(): void;
|
|
25
|
+
/** The Content / Settings tabs, rendered between the control and the fields. */
|
|
26
|
+
tabs?: ReactNode;
|
|
24
27
|
}): import("react").JSX.Element;
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
-
import {
|
|
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
|
-
export function GlobalInspector({ item, edit, locale, groups, multilingual, refOptions, inlinePath, onEditHere, onChange, pending, busy, onPublish, onDiscard, onUnpublish, }) {
|
|
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." }),
|
|
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
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
97
|
-
const
|
|
98
|
-
|
|
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>;
|