smoodly 0.0.18 → 0.0.20
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 +33 -1
- package/dist/admin/client-ops.js +1 -1
- package/dist/admin/editor/EditorView.js +17 -30
- package/dist/admin/editor/GlobalInspector.d.ts +1 -1
- package/dist/admin/editor/GlobalInspector.js +3 -2
- 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 +33 -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/Combobox.d.ts +30 -0
- package/dist/admin/ui/Combobox.js +89 -0
- package/dist/admin/ui/combo-box.d.ts +16 -0
- package/dist/admin/ui/combo-box.js +21 -0
- package/dist/admin/ui/theme.js +36 -0
- package/dist/admin/validate.js +39 -0
- package/dist/admin-path.d.ts +17 -0
- package/dist/admin-path.js +32 -0
- package/dist/config.d.ts +10 -1
- package/dist/config.js +14 -3
- 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 +1 -0
- package/dist/index.js +1 -0
- package/dist/page.js +1 -1
- 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
|
@@ -495,6 +495,38 @@ 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 robots `Disallow` for the admin path from `next/meta`.
|
|
501
|
+
|
|
502
|
+
## Follow-ups (logged 2026-09-17, reference picker and tags spec)
|
|
503
|
+
|
|
504
|
+
- A `Create…` button in the reference picker: create the target entry
|
|
505
|
+
without leaving the form. Needs its own answer for modal vs navigation
|
|
506
|
+
and for the parent form's pending autosave.
|
|
507
|
+
- Server search for ref candidates: `useRefOptions` loads the whole
|
|
508
|
+
target collection once per locale. When a collection outgrows that,
|
|
509
|
+
the hook changes (a search op, plus "titles for these ids" for the
|
|
510
|
+
picked rows); `RefPicker` takes options as a prop and does not.
|
|
511
|
+
- Thumbnails in picker rows: collections have no preview-image
|
|
512
|
+
convention (`titleField` only).
|
|
513
|
+
- `entries.distinct` flattens in JS over `entries.list`; an RPC over
|
|
514
|
+
`jsonb_array_elements` when a collection outgrows it.
|
|
515
|
+
- Tags keep insertion order; no drag reorder.
|
|
516
|
+
- Tag suggestions exist on collection entry forms only, and only for
|
|
517
|
+
top-level `f.tags()` fields: a tags field inside an object, on a
|
|
518
|
+
section, a global or a page is a plain chip input.
|
|
519
|
+
- The picker's "open" link goes to `/admin/<collection>/<id>` without
|
|
520
|
+
the edited locale; the form opens in the URL's or the default locale.
|
|
521
|
+
- The Combobox list flips upwards when it does not fit under the input
|
|
522
|
+
(`opensUp`, measured against the nearest clipping ancestor); it does
|
|
523
|
+
not yet shrink when neither side has room for it.
|
|
524
|
+
- `RefOption.status` is the three-way `LocaleStatus` now; the old
|
|
525
|
+
"(draft)" suffix in option titles is gone with the native select.
|
|
526
|
+
- A `refList` on a global item's `settings` still loads no options in
|
|
527
|
+
the Globals form or the page editor: both collect targets from the
|
|
528
|
+
item's `fields` only.
|
|
529
|
+
|
|
498
530
|
## Follow-ups (logged 2026-09-15, locale alternates spec)
|
|
499
531
|
|
|
500
532
|
- A layout-only language switch, if a real site needs one: a
|
|
@@ -1619,4 +1651,4 @@ the toolbar instead of pushing the content.
|
|
|
1619
1651
|
- `serialize.ts` structural cast duplicates `PageSchema`; tighten `Registered.schema` so the cast goes.
|
|
1620
1652
|
- Catch-all `templates` map should filter to open registrations to state its intent (unreachable today).
|
|
1621
1653
|
- 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)
|
|
1654
|
+
- `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"],
|
|
@@ -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 },
|
|
@@ -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 {
|
|
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:
|
|
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>;
|
|
@@ -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
|
+
}
|