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
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export type Route = {
|
|
2
|
+
pathname: string;
|
|
2
3
|
segments: string[];
|
|
3
4
|
search: string;
|
|
4
5
|
};
|
|
@@ -29,9 +30,9 @@ export declare function navigate(href: string, opts?: {
|
|
|
29
30
|
replace?: boolean;
|
|
30
31
|
force?: boolean;
|
|
31
32
|
}): boolean;
|
|
32
|
-
/** The current route
|
|
33
|
-
* popstate and after navigate(). */
|
|
34
|
-
export declare function useRoute(): Route;
|
|
33
|
+
/** The current route under `base`: empty until mount, then the URL,
|
|
34
|
+
* re-read on popstate and after navigate(). */
|
|
35
|
+
export declare function useRoute(base: string): Route;
|
|
35
36
|
/** Register the calling view as dirty: navigate() asks before leaving,
|
|
36
37
|
* and beforeunload covers reload, closing the tab and a real link out. */
|
|
37
38
|
export declare function useLeaveGuard(dirty: boolean): void;
|
|
@@ -1,20 +1,20 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
// packages/smoodly/src/admin/shell/router.ts
|
|
3
3
|
// The admin's own router (spec 2026-09-08 client routing): the route is
|
|
4
|
-
// the URL under
|
|
5
|
-
// the shell stays mounted and a click is a state change,
|
|
6
|
-
// load. The URL is the only source: the shell's first render is the
|
|
4
|
+
// the URL under the admin path, held in React state and moved with
|
|
5
|
+
// pushState, so the shell stays mounted and a click is a state change,
|
|
6
|
+
// not a document load. The URL is the only source: the shell's first render is the
|
|
7
7
|
// session gate, so no view exists before the mount effect has read it.
|
|
8
8
|
// Next's router is not involved — native pushState updates its
|
|
9
9
|
// usePathname without a refetch — and the admin core stays
|
|
10
10
|
// framework-agnostic.
|
|
11
11
|
import { useEffect, useState } from "react";
|
|
12
|
-
import {
|
|
12
|
+
import { DEFAULT_ADMIN_PATH } from "../../admin-path.js";
|
|
13
13
|
/** The segments under `base`: "/admin/articles/x" → ["articles", "x"];
|
|
14
14
|
* the mount itself, or a pathname outside it, → []. Segments stay as
|
|
15
15
|
* written; the shell decodes the ones it uses, as it does for the
|
|
16
16
|
* catch-all's params. */
|
|
17
|
-
export function segmentsOf(pathname, base =
|
|
17
|
+
export function segmentsOf(pathname, base = DEFAULT_ADMIN_PATH) {
|
|
18
18
|
if (pathname !== base && !pathname.startsWith(`${base}/`))
|
|
19
19
|
return [];
|
|
20
20
|
return pathname.slice(base.length).split("/").filter((s) => s !== "");
|
|
@@ -40,10 +40,13 @@ export function mayLeave(guards, confirm) {
|
|
|
40
40
|
// ── the live router ──────────────────────────────────────────────────
|
|
41
41
|
const guards = new Set();
|
|
42
42
|
const listeners = new Set();
|
|
43
|
-
function currentRoute() {
|
|
44
|
-
|
|
43
|
+
function currentRoute(base) {
|
|
44
|
+
const { pathname, search } = window.location;
|
|
45
|
+
return { pathname, segments: segmentsOf(pathname, base), search };
|
|
45
46
|
}
|
|
46
|
-
|
|
47
|
+
// The segments derive from the pathname and a base that never changes
|
|
48
|
+
// under a mounted shell, so two strings decide sameness.
|
|
49
|
+
const sameRoute = (a, b) => a.pathname === b.pathname && a.search === b.search;
|
|
47
50
|
/** Move to `href` in place; true when the move happened. A push asks the
|
|
48
51
|
* leave guard; a replace never leaves the view, so it does not ask.
|
|
49
52
|
* `force` skips the guard for a view that has just saved or deleted its
|
|
@@ -56,17 +59,17 @@ export function navigate(href, opts = {}) {
|
|
|
56
59
|
listener();
|
|
57
60
|
return true;
|
|
58
61
|
}
|
|
59
|
-
/** The current route
|
|
60
|
-
* popstate and after navigate(). */
|
|
61
|
-
export function useRoute() {
|
|
62
|
-
const [route, setRoute] = useState({ segments: [], search: "" });
|
|
62
|
+
/** The current route under `base`: empty until mount, then the URL,
|
|
63
|
+
* re-read on popstate and after navigate(). */
|
|
64
|
+
export function useRoute(base) {
|
|
65
|
+
const [route, setRoute] = useState({ pathname: "", segments: [], search: "" });
|
|
63
66
|
useEffect(() => {
|
|
64
|
-
const sync = () => setRoute((prev) => { const next = currentRoute(); return sameRoute(prev, next) ? prev : next; });
|
|
67
|
+
const sync = () => setRoute((prev) => { const next = currentRoute(base); return sameRoute(prev, next) ? prev : next; });
|
|
65
68
|
sync();
|
|
66
69
|
listeners.add(sync);
|
|
67
70
|
window.addEventListener("popstate", sync);
|
|
68
71
|
return () => { listeners.delete(sync); window.removeEventListener("popstate", sync); };
|
|
69
|
-
}, []);
|
|
72
|
+
}, [base]);
|
|
70
73
|
return route;
|
|
71
74
|
}
|
|
72
75
|
/** Register the calling view as dirty: navigate() asks before leaving,
|
|
@@ -4,8 +4,10 @@ export type ActionMenuItem = {
|
|
|
4
4
|
disabled?: boolean;
|
|
5
5
|
danger?: boolean;
|
|
6
6
|
};
|
|
7
|
-
export declare function ActionMenu({ label, items, disabled }: {
|
|
7
|
+
export declare function ActionMenu({ label, items, disabled, small }: {
|
|
8
8
|
label: string;
|
|
9
9
|
items: ActionMenuItem[];
|
|
10
10
|
disabled?: boolean;
|
|
11
|
+
/** The smaller round button, next to a small Publish. */
|
|
12
|
+
small?: boolean;
|
|
11
13
|
}): import("react").JSX.Element;
|
|
@@ -4,10 +4,11 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
4
4
|
// publish flow §2). Items are always rendered and disabled when they do
|
|
5
5
|
// not apply — the layout never shifts and the editor learns what exists.
|
|
6
6
|
// The popover is the Pages list's row menu shape (`.sm-picker`), anchored
|
|
7
|
-
// under the button instead of at the pointer
|
|
7
|
+
// under the button instead of at the pointer, hanging from the button's
|
|
8
|
+
// right edge (every control sits at its bar's or card's right).
|
|
8
9
|
import { useEffect, useRef, useState } from "react";
|
|
9
10
|
import { cx } from "./primitives.js";
|
|
10
|
-
export function ActionMenu({ label, items, disabled }) {
|
|
11
|
+
export function ActionMenu({ label, items, disabled, small = false }) {
|
|
11
12
|
const [open, setOpen] = useState(false);
|
|
12
13
|
const ref = useRef(null);
|
|
13
14
|
useEffect(() => {
|
|
@@ -21,5 +22,5 @@ export function ActionMenu({ label, items, disabled }) {
|
|
|
21
22
|
document.addEventListener("keydown", onKey);
|
|
22
23
|
return () => { document.removeEventListener("pointerdown", onDown); document.removeEventListener("keydown", onKey); };
|
|
23
24
|
}, [open]);
|
|
24
|
-
return (_jsxs("div", { ref: ref, style: { position: "relative", flex: "none" }, children: [_jsx("button", { type: "button", className: "sm-dotmenu", "aria-label": label, title: label, "aria-haspopup": "menu", "aria-expanded": open, disabled: disabled, onClick: () => setOpen((o) => !o), children: "\u00B7\u00B7\u00B7" }), open && (_jsx("div", { role: "menu", className: "sm-picker sm-picker--menu", children: items.map((item) => (_jsx("button", { type: "button", role: "menuitem", className: cx("sm-picker__item", item.danger && "sm-picker__item--danger"), disabled: item.disabled, onClick: () => { setOpen(false); item.run(); }, children: item.label }, item.label))) }))] }));
|
|
25
|
+
return (_jsxs("div", { ref: ref, style: { position: "relative", flex: "none" }, children: [_jsx("button", { type: "button", className: cx("sm-dotmenu", small && "sm-dotmenu--sm"), "aria-label": label, title: label, "aria-haspopup": "menu", "aria-expanded": open, disabled: disabled, onClick: () => setOpen((o) => !o), children: "\u00B7\u00B7\u00B7" }), open && (_jsx("div", { role: "menu", className: "sm-picker sm-picker--menu", children: items.map((item) => (_jsx("button", { type: "button", role: "menuitem", className: cx("sm-picker__item", item.danger && "sm-picker__item--danger"), disabled: item.disabled, onClick: () => { setOpen(false); item.run(); }, children: item.label }, item.label))) }))] }));
|
|
25
26
|
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { type KeyboardEvent, type ReactNode } from "react";
|
|
2
|
+
import { type ComboOption } from "./combo-box.ts";
|
|
3
|
+
export type ComboboxProps = {
|
|
4
|
+
options: ComboOption[];
|
|
5
|
+
onPick(value: string): void;
|
|
6
|
+
/** Accessible name of the input. */
|
|
7
|
+
label: string;
|
|
8
|
+
placeholder?: string;
|
|
9
|
+
disabled?: boolean;
|
|
10
|
+
/** Stay open with the query cleared after a pick (multi-select). */
|
|
11
|
+
keepOpen?: boolean;
|
|
12
|
+
/** Shown when there are no options at all; "No matches" covers a filter that leaves nothing. */
|
|
13
|
+
emptyText?: string;
|
|
14
|
+
renderOption?(o: ComboOption): ReactNode;
|
|
15
|
+
/** Free text: Enter with no active row submits the query, and no row starts active. */
|
|
16
|
+
onSubmit?(query: string): void;
|
|
17
|
+
/** With `onSubmit`: a last row, `Create "query"`, unless an option's label equals the query. */
|
|
18
|
+
createRow?: boolean;
|
|
19
|
+
/** Every query change passes through; the return value is the query kept. */
|
|
20
|
+
onQueryChange?(next: string): string;
|
|
21
|
+
/** Keys the owner wants first; return true when handled. */
|
|
22
|
+
onInputKey?(e: KeyboardEvent<HTMLInputElement>, query: string): boolean;
|
|
23
|
+
/** Escape, or focus leaving the control; `query` is what was typed and not picked. */
|
|
24
|
+
onClose?(query: string, reason: "escape" | "blur"): void;
|
|
25
|
+
autoFocus?: boolean;
|
|
26
|
+
/** Start open: the tags input, and static-markup tests. */
|
|
27
|
+
initialOpen?: boolean;
|
|
28
|
+
className?: string;
|
|
29
|
+
};
|
|
30
|
+
export declare function Combobox({ options, onPick, label, placeholder, disabled, keepOpen, emptyText, renderOption, onSubmit, createRow, onQueryChange, onInputKey, onClose, autoFocus, initialOpen, className, }: ComboboxProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
// A text input with a listbox under it (spec 2026-09-17 §1): the reference
|
|
4
|
+
// picker and the tags input are both this. It knows nothing about entries
|
|
5
|
+
// or tags — options in, a pick out. Decisions live in ./combo-box.ts.
|
|
6
|
+
// Rows keep the input focused (mousedown is swallowed), so "focus left the
|
|
7
|
+
// control" is the one close signal besides Escape.
|
|
8
|
+
import { useId, useLayoutEffect, useRef, useState } from "react";
|
|
9
|
+
import { ChevronDown } from "lucide-react";
|
|
10
|
+
import { cx } from "./primitives.js";
|
|
11
|
+
import { filterOptions, opensUp, showsCreate, stepActive } from "./combo-box.js";
|
|
12
|
+
const keepFocus = (e) => e.preventDefault();
|
|
13
|
+
/** The vertical band the list may occupy: the nearest clipping ancestor's box, cut to the viewport. */
|
|
14
|
+
function clipBand(el) {
|
|
15
|
+
let top = 0;
|
|
16
|
+
let bottom = window.innerHeight;
|
|
17
|
+
for (let p = el.parentElement; p; p = p.parentElement) {
|
|
18
|
+
if (/(auto|scroll|hidden|clip)/.test(getComputedStyle(p).overflowY)) {
|
|
19
|
+
const r = p.getBoundingClientRect();
|
|
20
|
+
top = Math.max(top, r.top);
|
|
21
|
+
bottom = Math.min(bottom, r.bottom);
|
|
22
|
+
break;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return { top, bottom };
|
|
26
|
+
}
|
|
27
|
+
export function Combobox({ options, onPick, label, placeholder, disabled, keepOpen, emptyText, renderOption, onSubmit, createRow, onQueryChange, onInputKey, onClose, autoFocus, initialOpen = false, className, }) {
|
|
28
|
+
const rest = onSubmit ? -1 : 0; // the active row a fresh query starts from
|
|
29
|
+
const [query, setQuery] = useState("");
|
|
30
|
+
const [open, setOpen] = useState(initialOpen);
|
|
31
|
+
const [active, setActive] = useState(rest);
|
|
32
|
+
const root = useRef(null);
|
|
33
|
+
const list = useRef(null);
|
|
34
|
+
const [up, setUp] = useState(false);
|
|
35
|
+
const id = useId();
|
|
36
|
+
const shown = filterOptions(options, query);
|
|
37
|
+
const create = onSubmit !== undefined && createRow === true && showsCreate(options, query);
|
|
38
|
+
const count = shown.length + (create ? 1 : 0);
|
|
39
|
+
// Free text with nothing to offer shows no popover; a picker says why it is empty.
|
|
40
|
+
const popover = open && !disabled && (count > 0 || !onSubmit);
|
|
41
|
+
// A field at the bottom of a scrolling form has no room under it: the list hangs above instead.
|
|
42
|
+
useLayoutEffect(() => {
|
|
43
|
+
if (!popover || !root.current || !list.current)
|
|
44
|
+
return;
|
|
45
|
+
const box = root.current.getBoundingClientRect();
|
|
46
|
+
const band = clipBand(root.current);
|
|
47
|
+
setUp(opensUp({ below: band.bottom - box.bottom - 4, above: box.top - band.top - 4, height: list.current.offsetHeight }));
|
|
48
|
+
}, [popover, count]);
|
|
49
|
+
const reset = () => { setQuery(""); setActive(rest); };
|
|
50
|
+
const close = (reason) => {
|
|
51
|
+
setOpen(false);
|
|
52
|
+
onClose?.(query, reason);
|
|
53
|
+
reset();
|
|
54
|
+
};
|
|
55
|
+
const pick = (i) => {
|
|
56
|
+
if (i < shown.length)
|
|
57
|
+
onPick(shown[i].value);
|
|
58
|
+
else
|
|
59
|
+
onSubmit?.(query);
|
|
60
|
+
reset();
|
|
61
|
+
if (!keepOpen)
|
|
62
|
+
setOpen(false);
|
|
63
|
+
};
|
|
64
|
+
const onKeyDown = (e) => {
|
|
65
|
+
if (onInputKey?.(e, query))
|
|
66
|
+
return;
|
|
67
|
+
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
|
68
|
+
e.preventDefault();
|
|
69
|
+
setOpen(true);
|
|
70
|
+
setActive((a) => stepActive(a, count, e.key === "ArrowDown" ? 1 : -1));
|
|
71
|
+
}
|
|
72
|
+
else if (e.key === "Enter") {
|
|
73
|
+
e.preventDefault();
|
|
74
|
+
if (open && active >= 0 && active < count)
|
|
75
|
+
pick(active);
|
|
76
|
+
else if (onSubmit && query.trim() !== "") {
|
|
77
|
+
onSubmit(query);
|
|
78
|
+
reset();
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
else if (e.key === "Escape") {
|
|
82
|
+
if (open)
|
|
83
|
+
e.stopPropagation(); // a modal behind it stays open
|
|
84
|
+
close("escape");
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
return (_jsxs("div", { ref: root, className: cx("sm-combo", className), children: [_jsx("input", { className: "sm-input", role: "combobox", "aria-label": label, "aria-autocomplete": "list", "aria-expanded": popover, "aria-controls": `${id}-list`, "aria-activedescendant": popover && active >= 0 ? `${id}-opt-${active}` : undefined, placeholder: placeholder, disabled: disabled, autoFocus: autoFocus, value: query, onFocus: () => setOpen(true), onClick: () => setOpen(true), onChange: (e) => { setQuery(onQueryChange ? onQueryChange(e.target.value) : e.target.value); setActive(rest); setOpen(true); }, onKeyDown: onKeyDown, onBlur: (e) => { if (open && !root.current?.contains(e.relatedTarget))
|
|
88
|
+
close("blur"); } }), _jsx(ChevronDown, { className: "sm-combo__chevron", size: 14, strokeWidth: 1.75, "aria-hidden": "true" }), popover && (_jsxs("div", { ref: list, id: `${id}-list`, role: "listbox", "aria-label": label, className: cx("sm-picker sm-combo__list", up && "sm-combo__list--up"), children: [count === 0 && _jsx("div", { className: "sm-combo__empty", children: options.length === 0 ? emptyText ?? "No matches" : "No matches" }), shown.map((o, i) => (_jsx("button", { id: `${id}-opt-${i}`, type: "button", role: "option", "aria-selected": i === active, className: cx("sm-picker__item", i === active && "sm-picker__item--active"), onMouseDown: keepFocus, onClick: () => pick(i), children: renderOption ? renderOption(o) : o.label }, o.value))), create && (_jsxs("button", { id: `${id}-opt-${shown.length}`, type: "button", role: "option", "aria-selected": active === shown.length, className: cx("sm-picker__item", active === shown.length && "sm-picker__item--active"), onMouseDown: keepFocus, onClick: () => pick(shown.length), children: ["Create \u201C", query.trim(), "\u201D"] }))] }))] }));
|
|
89
|
+
}
|
|
@@ -13,7 +13,7 @@ export declare function publishMenu(opts: {
|
|
|
13
13
|
onUnpublish(): void;
|
|
14
14
|
onHistory?(): void;
|
|
15
15
|
}): ActionMenuItem[];
|
|
16
|
-
export declare function PublishControl({ status, pending, busy, flash, disabled, onPublish, onDiscard, onUnpublish, onHistory, }: {
|
|
16
|
+
export declare function PublishControl({ status, pending, busy, flash, disabled, compact, onPublish, onDiscard, onUnpublish, onHistory, }: {
|
|
17
17
|
/** The locale's stored state; null while the document loads or before it exists. */
|
|
18
18
|
status: LocaleStatus | null;
|
|
19
19
|
/** The editor holds a change the store has not confirmed (dirty, or a save pending / in flight / failed). */
|
|
@@ -28,4 +28,6 @@ export declare function PublishControl({ status, pending, busy, flash, disabled,
|
|
|
28
28
|
onUnpublish(): void;
|
|
29
29
|
/** Opens History (spec 2026-09-13 history §5). Absent = no History item (the canvas card). */
|
|
30
30
|
onHistory?(): void;
|
|
31
|
+
/** The small button and menu — the global inspector's control, so it never reads as the page's Publish. */
|
|
32
|
+
compact?: boolean;
|
|
31
33
|
}): import("react").JSX.Element;
|
|
@@ -16,9 +16,9 @@ export function publishMenu(opts) {
|
|
|
16
16
|
{ label: "Unpublish", disabled: !opts.can.unpublish, run: opts.onUnpublish },
|
|
17
17
|
];
|
|
18
18
|
}
|
|
19
|
-
export function PublishControl({ status, pending = false, busy = null, flash = false, disabled = false, onPublish, onDiscard, onUnpublish, onHistory, }) {
|
|
19
|
+
export function PublishControl({ status, pending = false, busy = null, flash = false, disabled = false, compact = false, onPublish, onDiscard, onUnpublish, onHistory, }) {
|
|
20
20
|
const can = publishActions(status, pending);
|
|
21
21
|
const inert = disabled || busy !== null;
|
|
22
22
|
const word = busy ? BUSY_WORD[busy] : flash ? "Published ✓" : "Publish";
|
|
23
|
-
return (_jsxs("span", { style: { display: "inline-flex", alignItems: "center", gap: 8 }, children: [status && _jsx(StatusBadge, { status: statusLabel(status) }), _jsx(Button, { variant: flash && !busy ? "ok" : "primary", disabled: inert || !can.publish, onClick: onPublish, children: word }), _jsx(ActionMenu, { label: "More actions", disabled: inert || status === null, items: publishMenu({ can, onDiscard, onUnpublish, onHistory }) })] }));
|
|
23
|
+
return (_jsxs("span", { style: { display: "inline-flex", alignItems: "center", gap: 8 }, children: [status && _jsx(StatusBadge, { status: statusLabel(status) }), _jsx(Button, { variant: flash && !busy ? "ok" : "primary", size: compact ? "sm" : "md", disabled: inert || !can.publish, onClick: onPublish, children: word }), _jsx(ActionMenu, { label: "More actions", disabled: inert || status === null, small: compact, items: publishMenu({ can, onDiscard, onUnpublish, onHistory }) })] }));
|
|
24
24
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export type ComboOption = {
|
|
2
|
+
value: string;
|
|
3
|
+
label: string;
|
|
4
|
+
};
|
|
5
|
+
/** A case-insensitive substring match on the label; an empty query keeps every option. */
|
|
6
|
+
export declare function filterOptions(options: ComboOption[], query: string): ComboOption[];
|
|
7
|
+
/** Whether `Create "query"` is offered: something is typed and no label equals it. */
|
|
8
|
+
export declare function showsCreate(options: ComboOption[], query: string): boolean;
|
|
9
|
+
/** The next active row, wrapping. -1 is "no row" (free text): ↓ enters at the top, ↑ at the bottom. */
|
|
10
|
+
export declare function stepActive(active: number, count: number, dir: 1 | -1): number;
|
|
11
|
+
/** Whether the list hangs above the input: only when it does not fit below and there is more room above. */
|
|
12
|
+
export declare const opensUp: (room: {
|
|
13
|
+
below: number;
|
|
14
|
+
above: number;
|
|
15
|
+
height: number;
|
|
16
|
+
}) => boolean;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
const fold = (s) => s.trim().toLocaleLowerCase();
|
|
2
|
+
/** A case-insensitive substring match on the label; an empty query keeps every option. */
|
|
3
|
+
export function filterOptions(options, query) {
|
|
4
|
+
const q = fold(query);
|
|
5
|
+
return q === "" ? options : options.filter((o) => o.label.toLocaleLowerCase().includes(q));
|
|
6
|
+
}
|
|
7
|
+
/** Whether `Create "query"` is offered: something is typed and no label equals it. */
|
|
8
|
+
export function showsCreate(options, query) {
|
|
9
|
+
const q = fold(query);
|
|
10
|
+
return q !== "" && !options.some((o) => fold(o.label) === q);
|
|
11
|
+
}
|
|
12
|
+
/** The next active row, wrapping. -1 is "no row" (free text): ↓ enters at the top, ↑ at the bottom. */
|
|
13
|
+
export function stepActive(active, count, dir) {
|
|
14
|
+
if (count === 0)
|
|
15
|
+
return -1;
|
|
16
|
+
if (active < 0)
|
|
17
|
+
return dir === 1 ? 0 : count - 1;
|
|
18
|
+
return (active + dir + count) % count;
|
|
19
|
+
}
|
|
20
|
+
/** Whether the list hangs above the input: only when it does not fit below and there is more room above. */
|
|
21
|
+
export const opensUp = (room) => room.height > room.below && room.above > room.below;
|
|
@@ -14,7 +14,7 @@ export declare function Logo({ height }: {
|
|
|
14
14
|
export declare function ThemeToggle(): import("react").JSX.Element;
|
|
15
15
|
type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
|
|
16
16
|
variant?: "primary" | "secondary" | "ghost" | "danger" | "ok";
|
|
17
|
-
size?: "md" | "lg";
|
|
17
|
+
size?: "sm" | "md" | "lg";
|
|
18
18
|
};
|
|
19
19
|
export declare function Button({ variant, size, className, ...rest }: ButtonProps): import("react").JSX.Element;
|
|
20
20
|
/** The same shape as Button, for navigations that must stay real links. */
|
|
@@ -42,7 +42,7 @@ export function ThemeToggle() {
|
|
|
42
42
|
return (_jsxs("button", { type: "button", role: "menuitem", className: "sm-rail__menu-item", onClick: toggle, children: [_jsxs("span", { className: "sm-when-light", children: [_jsx(Moon, { ...ICON }), "Dark mode"] }), _jsxs("span", { className: "sm-when-dark", children: [_jsx(Sun, { ...ICON }), "Light mode"] })] }));
|
|
43
43
|
}
|
|
44
44
|
export function Button({ variant = "primary", size = "md", className, ...rest }) {
|
|
45
|
-
return (_jsx("button", { type: "button", className: cx("sm-btn", `sm-btn--${variant}`, size === "lg" && "sm-btn--lg", className), ...rest }));
|
|
45
|
+
return (_jsx("button", { type: "button", className: cx("sm-btn", `sm-btn--${variant}`, size === "lg" && "sm-btn--lg", size === "sm" && "sm-btn--sm", className), ...rest }));
|
|
46
46
|
}
|
|
47
47
|
/** The same shape as Button, for navigations that must stay real links. */
|
|
48
48
|
export function LinkButton({ variant = "secondary", children, ...rest }) {
|
package/dist/admin/ui/theme.js
CHANGED
|
@@ -96,13 +96,13 @@ const SCALE = `
|
|
|
96
96
|
--title-font:var(--font-sans); --title-size:18px; --title-weight:600; --title-tracking:-0.01em;
|
|
97
97
|
--doc-title-size:30px;
|
|
98
98
|
--blocktag-font:var(--font-sans); --blocktag-size:11px; --blocktag-transform:none; --blocktag-tracking:0;
|
|
99
|
-
--blocktag-radius:var(--radius-round); --blocktag-shift:translate(16px,-50%);
|
|
99
|
+
--blocktag-radius:var(--radius-round); --blocktag-shift:translate(16px,-50%); --blocktag-shift-inside:translate(16px,8px);
|
|
100
100
|
--badge-h:24px; --badge-dot:inline-block;
|
|
101
101
|
|
|
102
102
|
--radius-xs:2px; --radius-sm:8px; --radius-md:10px; --radius-lg:12px;
|
|
103
103
|
--radius-card:14px; --radius-card-lg:16px; --radius-btn:999px; --radius-round:999px;
|
|
104
104
|
--page-radius:4px 4px 0 0; --selection-radius:4px;
|
|
105
|
-
--control-h:40px; --control-h-sm:34px; --control-h-lg:48px; --button-h:36px; --row-h:48px; --topbar-h:64px;
|
|
105
|
+
--control-h:40px; --control-h-sm:34px; --control-h-lg:48px; --button-h:36px; --button-h-sm:28px; --row-h:48px; --topbar-h:64px;
|
|
106
106
|
--outline-row-h:36px; --seg-pad-y:7px; --tabs-pad:3px;
|
|
107
107
|
--plus-h:32px; --plus-pad:0 14px 0 6px; --plus-label:inline;
|
|
108
108
|
--nav-w:72px; --inspector-w:360px; --gutter:20px; --frame-pad:24px;
|
|
@@ -202,11 +202,11 @@ const TECHNICAL_SHAPE = `
|
|
|
202
202
|
--title-font:var(--font-sans); --title-size:var(--text-xl); --title-weight:600; --title-tracking:0;
|
|
203
203
|
--doc-title-size:var(--text-2xl);
|
|
204
204
|
--blocktag-font:var(--font-mono); --blocktag-size:var(--text-2xs); --blocktag-transform:uppercase; --blocktag-tracking:var(--tracking-tag);
|
|
205
|
-
--blocktag-radius:var(--radius-sm) 0 var(--radius-sm) 0; --blocktag-shift:none;
|
|
205
|
+
--blocktag-radius:var(--radius-sm) 0 var(--radius-sm) 0; --blocktag-shift:none; --blocktag-shift-inside:none;
|
|
206
206
|
--badge-h:20px; --badge-dot:none;
|
|
207
207
|
--radius-lg:var(--radius-md); --radius-card:var(--radius-md); --radius-card-lg:var(--radius-md); --radius-btn:var(--radius-md);
|
|
208
208
|
--page-radius:var(--radius-md); --selection-radius:var(--radius-sm);
|
|
209
|
-
--control-h-sm:24px; --button-h:var(--control-h);
|
|
209
|
+
--control-h-sm:24px; --button-h:var(--control-h); --button-h-sm:24px;
|
|
210
210
|
--outline-row-h:var(--control-h); --seg-pad-y:3px; --tabs-pad:0 14px;
|
|
211
211
|
--plus-h:24px; --plus-pad:0; --plus-label:none;
|
|
212
212
|
--gutter:0px; --frame-pad:0px;
|
|
@@ -377,6 +377,7 @@ body:has(.sm-admin){margin:0}
|
|
|
377
377
|
text-decoration:none;
|
|
378
378
|
}
|
|
379
379
|
.sm-btn--lg{height:var(--control-h-lg);padding:0 20px}
|
|
380
|
+
.sm-btn--sm,.sm-btn--sm.sm-btn--primary,.sm-btn--sm.sm-btn--ok{height:var(--button-h-sm);padding:0 12px;font-size:var(--text-sm)}
|
|
380
381
|
.sm-btn--primary{background:var(--accent);color:var(--on-accent);font-weight:600;padding:0 18px}
|
|
381
382
|
.sm-btn--primary:hover{background:var(--accent-hover);color:var(--on-accent)}
|
|
382
383
|
.sm-btn--secondary{background:var(--btn-secondary-bg);color:var(--text)}
|
|
@@ -635,6 +636,7 @@ body:has(.sm-admin){margin:0}
|
|
|
635
636
|
.sm-dotmenu:hover{background:var(--btn-secondary-hover);color:var(--text)}
|
|
636
637
|
.sm-dotmenu:disabled{opacity:.4;cursor:default}
|
|
637
638
|
.sm-dotmenu:disabled:hover{background:var(--btn-secondary-bg);color:var(--text-muted)}
|
|
639
|
+
.sm-dotmenu--sm{width:var(--button-h-sm);height:var(--button-h-sm);font-size:var(--text-sm)}
|
|
638
640
|
.sm-picker--menu{position:absolute;top:calc(100% + 6px);right:0;width:220px;z-index:60}
|
|
639
641
|
|
|
640
642
|
/* Status pill: a dot before the word in the warm look (none in the technical). */
|
|
@@ -794,6 +796,7 @@ body:has(.sm-admin){margin:0}
|
|
|
794
796
|
border-radius:var(--blocktag-radius);white-space:nowrap;
|
|
795
797
|
}
|
|
796
798
|
.sm-blocktag--global{background:var(--selection-global)}
|
|
799
|
+
.sm-blocktag--inside{transform:var(--blocktag-shift-inside)}
|
|
797
800
|
.sm-blocktoolbar{
|
|
798
801
|
display:flex;gap:2px;padding:3px;background:var(--toolbar-bg);border-radius:var(--radius-btn);
|
|
799
802
|
box-shadow:var(--shadow-float);
|
|
@@ -855,6 +858,42 @@ body:has(.sm-admin){margin:0}
|
|
|
855
858
|
width:26px;height:26px;flex:none;border-radius:var(--radius-sm);background:var(--accent-soft);
|
|
856
859
|
color:var(--accent);display:grid;place-items:center;font-size:13px;
|
|
857
860
|
}
|
|
861
|
+
/* Combobox (spec 2026-09-17 §1): the input owns the width, the list hangs under it. */
|
|
862
|
+
.sm-combo{position:relative}
|
|
863
|
+
.sm-combo .sm-input{width:100%;padding-right:30px}
|
|
864
|
+
.sm-combo__chevron{position:absolute;right:10px;top:50%;transform:translateY(-50%);pointer-events:none;color:var(--text-muted)}
|
|
865
|
+
.sm-combo__list{position:absolute;top:calc(100% + 4px);left:0;right:0;width:auto;max-height:280px;overflow-y:auto;z-index:60}
|
|
866
|
+
.sm-combo__list--up{top:auto;bottom:calc(100% + 4px)}
|
|
867
|
+
.sm-combo__empty{padding:8px 10px;color:var(--text-muted);font-size:var(--text-sm)}
|
|
868
|
+
/* Reference picker (spec 2026-09-17 §2): the picked entries under the search input. */
|
|
869
|
+
.sm-refpicker{display:flex;flex-direction:column;gap:6px;min-width:0}
|
|
870
|
+
.sm-refpicker__rows{display:flex;flex-direction:column;gap:4px}
|
|
871
|
+
.sm-refpicker__title{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
872
|
+
.sm-refrow{
|
|
873
|
+
display:flex;align-items:center;gap:6px;height:var(--control-h-sm);padding:0 6px 0 10px;
|
|
874
|
+
border:1px solid var(--line);border-radius:var(--radius-md);background:var(--surface);min-width:0;
|
|
875
|
+
}
|
|
876
|
+
.sm-refrow:has(.sm-grip){padding-left:6px}
|
|
877
|
+
.sm-refrow:hover .sm-grip{opacity:1}
|
|
878
|
+
.sm-refrow--muted .sm-refpicker__title{color:var(--text-muted)}
|
|
879
|
+
.sm-refrow__note{font-size:var(--text-sm)}
|
|
880
|
+
.sm-refrow a.sm-iconbtn{text-decoration:none}
|
|
881
|
+
/* Tags (spec 2026-09-17 §5): chips that wrap, a dashed + Add, the inline input in its place. */
|
|
882
|
+
.sm-tags{display:flex;flex-wrap:wrap;gap:6px;align-items:center}
|
|
883
|
+
.sm-tag{
|
|
884
|
+
display:inline-flex;align-items:center;gap:6px;height:var(--control-h-sm);padding:0 10px;border:0;
|
|
885
|
+
border-radius:var(--radius-sm);background:var(--surface-inset);color:var(--text);font:inherit;font-size:var(--text-sm);white-space:nowrap;
|
|
886
|
+
transition:background .2s;
|
|
887
|
+
}
|
|
888
|
+
.sm-tag--flash{background:var(--accent-soft)}
|
|
889
|
+
.sm-tag__x{border:0;background:transparent;padding:0;color:var(--text-muted);cursor:pointer;font:inherit;line-height:1}
|
|
890
|
+
.sm-tag__x:hover{color:var(--text)}
|
|
891
|
+
.sm-tag--add{background:transparent;border:1px dashed var(--line);color:var(--text-muted);cursor:pointer}
|
|
892
|
+
.sm-tag--add:hover{color:var(--text);border-color:var(--text-muted)}
|
|
893
|
+
.sm-combo--tag{width:180px}
|
|
894
|
+
.sm-combo--tag .sm-input{height:var(--control-h-sm);padding-right:10px}
|
|
895
|
+
.sm-combo--tag .sm-combo__chevron{display:none}
|
|
896
|
+
.sm-combo--tag .sm-combo__list{right:auto;width:240px}
|
|
858
897
|
.sm-dashed{
|
|
859
898
|
border:1px dashed var(--line-dashed);border-radius:var(--radius-lg);
|
|
860
899
|
display:flex;align-items:center;justify-content:center;gap:8px;color:var(--text-subtle);
|
package/dist/admin/validate.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import { optionValues } from "../fields.js";
|
|
9
9
|
import { isHidden } from "../conditions.js";
|
|
10
10
|
import { cssProblem } from "../css.js";
|
|
11
|
+
import { normalizeTag, sameTag } from "../tags.js";
|
|
11
12
|
import { hasContent } from "./richtext-doc.js";
|
|
12
13
|
import { ELEMENT_NODE, richtextBlocks } from "../richtext-walk.js";
|
|
13
14
|
import { rowsProblems } from "../custom/rows.js";
|
|
@@ -89,6 +90,44 @@ function validateValue(path, value, d, errors, elements, scope) {
|
|
|
89
90
|
}
|
|
90
91
|
return;
|
|
91
92
|
}
|
|
93
|
+
// Refs (spec 2026-09-17 §3): the shape and the count. Whether a target exists
|
|
94
|
+
// and is published is publish integrity's question (store.ts), not this one's.
|
|
95
|
+
if (d.type === "ref") {
|
|
96
|
+
if (typeof value !== "string")
|
|
97
|
+
errors.push({ field: path, message: "Must be an entry." });
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
if (d.type === "refList") {
|
|
101
|
+
if (!Array.isArray(value) || value.some((v) => typeof v !== "string" || v === "")) {
|
|
102
|
+
errors.push({ field: path, message: "Must be a list of entries." });
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if (typeof d.min === "number" && value.length < d.min)
|
|
106
|
+
errors.push({ field: path, message: `At least ${plural(d.min, "item")}.` });
|
|
107
|
+
if (typeof d.max === "number" && value.length > d.max)
|
|
108
|
+
errors.push({ field: path, message: `At most ${plural(d.max, "item")}.` });
|
|
109
|
+
// The refs index keys on (source, field, target): a repeat renders twice and indexes once.
|
|
110
|
+
if (new Set(value).size !== value.length)
|
|
111
|
+
errors.push({ field: path, message: "The same entry is listed twice." });
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (d.type === "tags") {
|
|
115
|
+
if (!Array.isArray(value)) {
|
|
116
|
+
errors.push({ field: path, message: "Must be a list of tags." });
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (typeof d.min === "number" && value.length < d.min)
|
|
120
|
+
errors.push({ field: path, message: `At least ${plural(d.min, "item")}.` });
|
|
121
|
+
if (typeof d.max === "number" && value.length > d.max)
|
|
122
|
+
errors.push({ field: path, message: `At most ${plural(d.max, "item")}.` });
|
|
123
|
+
value.forEach((v, i) => {
|
|
124
|
+
if (typeof v !== "string" || v === "" || normalizeTag(v) !== v)
|
|
125
|
+
errors.push({ field: `${path}.${i}`, message: "Must be a trimmed, non-empty tag." });
|
|
126
|
+
else if (value.slice(0, i).some((u) => typeof u === "string" && sameTag(u, v)))
|
|
127
|
+
errors.push({ field: `${path}.${i}`, message: "Duplicate tag." });
|
|
128
|
+
});
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
92
131
|
if (d.type === "checkboxes") {
|
|
93
132
|
if (!Array.isArray(value)) {
|
|
94
133
|
errors.push({ field: path, message: "Must be a list." });
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export declare const DEFAULT_ADMIN_PATH = "/admin";
|
|
2
|
+
/** One or more `/segment`s: no trailing slash, never "/". */
|
|
3
|
+
export declare function assertAdminPath(path: unknown): asserts path is string;
|
|
4
|
+
/** `path` is "" or starts with "/": adminHref("/studio", "/pages") → "/studio/pages". */
|
|
5
|
+
export declare const adminHref: (base: string, path: string) => string;
|
|
6
|
+
/** The message for a shell mounted outside its base — the route folder
|
|
7
|
+
* and `admin.path` disagree (spec §4). Null inside the base, and before
|
|
8
|
+
* mount, when the pathname is still "". */
|
|
9
|
+
export declare function mountMismatch(pathname: string, base: string): string | null;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// packages/smoodly/src/admin-path.ts
|
|
2
|
+
// The admin's mount path (spec 2026-09-17): "/admin" unless the config
|
|
3
|
+
// moves it. Pure and framework-free — the config, the ops and the shell
|
|
4
|
+
// all read it from here.
|
|
5
|
+
import { isSegment } from "./paths.js";
|
|
6
|
+
export const DEFAULT_ADMIN_PATH = "/admin";
|
|
7
|
+
/** One or more `/segment`s: no trailing slash, never "/". */
|
|
8
|
+
export function assertAdminPath(path) {
|
|
9
|
+
const ok = typeof path === "string" && path.startsWith("/") && path.slice(1).split("/").every(isSegment);
|
|
10
|
+
if (!ok) {
|
|
11
|
+
throw new Error(`smoodly: admin.path "${String(path)}" is not a valid path — use one or more /segments of lowercase letters, digits and single hyphens, like "/admin" or "/cms/admin".`);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
/** `path` is "" or starts with "/": adminHref("/studio", "/pages") → "/studio/pages". */
|
|
15
|
+
export const adminHref = (base, path) => `${base}${path}`;
|
|
16
|
+
/** The message for a shell mounted outside its base — the route folder
|
|
17
|
+
* and `admin.path` disagree (spec §4). Null inside the base, and before
|
|
18
|
+
* mount, when the pathname is still "". */
|
|
19
|
+
export function mountMismatch(pathname, base) {
|
|
20
|
+
if (pathname === "" || pathname === base || pathname.startsWith(`${base}/`))
|
|
21
|
+
return null;
|
|
22
|
+
const suggestion = `/${pathname.split("/").filter((s) => s !== "")[0] ?? ""}`;
|
|
23
|
+
return `The admin is open at ${pathname}, but admin.path in the Smoodly config is ${base}. Set admin: { path: "${suggestion}" } in the config, or move the route folder back.`;
|
|
24
|
+
}
|
package/dist/config.d.ts
CHANGED
|
@@ -57,8 +57,13 @@ export type SmoodlyConfig = {
|
|
|
57
57
|
maxSize?: number;
|
|
58
58
|
accept?: string[];
|
|
59
59
|
};
|
|
60
|
+
/** Where the admin is mounted (spec 2026-09-17): must match the route
|
|
61
|
+
* folder under app/(smoodly)/. Default "/admin". */
|
|
62
|
+
admin?: {
|
|
63
|
+
path?: string;
|
|
64
|
+
};
|
|
60
65
|
};
|
|
61
|
-
export type ResolvedConfig = Omit<SmoodlyConfig, "styles" | "custom"> & {
|
|
66
|
+
export type ResolvedConfig = Omit<SmoodlyConfig, "styles" | "custom" | "admin"> & {
|
|
62
67
|
homePageSlug: string;
|
|
63
68
|
/** How deep editors may nest pages in the tree. Default 1 (flat). */
|
|
64
69
|
pageDepth: number;
|
|
@@ -74,6 +79,10 @@ export type ResolvedConfig = Omit<SmoodlyConfig, "styles" | "custom"> & {
|
|
|
74
79
|
maxSize: number;
|
|
75
80
|
accept: string[];
|
|
76
81
|
};
|
|
82
|
+
/** The admin's mount path, always present. */
|
|
83
|
+
admin: {
|
|
84
|
+
path: string;
|
|
85
|
+
};
|
|
77
86
|
};
|
|
78
87
|
/** `global`: the placement node type and an admin route; `asset`: the refs
|
|
79
88
|
* target of every media edge; `media`: the admin's Media screen route. */
|
package/dist/config.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { assertSegment, fixedPageSegments, perLocaleSegments } from "./paths.js";
|
|
2
2
|
import { DEFAULT_MEDIA } from "./media.js";
|
|
3
|
+
import { DEFAULT_ADMIN_PATH, assertAdminPath } from "./admin-path.js";
|
|
3
4
|
import { resolveStyleValues, styleDescriptors } from "./styles.js";
|
|
4
5
|
import { resolveCustom } from "./custom/index.js";
|
|
5
6
|
/** `global`: the placement node type and an admin route; `asset`: the refs
|
|
@@ -195,11 +196,14 @@ export function defineConfig(config) {
|
|
|
195
196
|
if (!Number.isInteger(media.maxSize) || media.maxSize <= 0) {
|
|
196
197
|
throw new Error("smoodly: media.maxSize must be a positive integer of bytes.");
|
|
197
198
|
}
|
|
199
|
+
const adminPath = config.admin?.path ?? DEFAULT_ADMIN_PATH;
|
|
200
|
+
assertAdminPath(adminPath);
|
|
198
201
|
return {
|
|
199
202
|
...config,
|
|
200
203
|
homePageSlug,
|
|
201
204
|
pageDepth: depth,
|
|
202
205
|
media,
|
|
206
|
+
admin: { path: adminPath },
|
|
203
207
|
styles,
|
|
204
208
|
css: config.css === true,
|
|
205
209
|
custom,
|
package/dist/entry-page.d.ts
CHANGED
|
@@ -1,14 +1,19 @@
|
|
|
1
1
|
import type { ComponentType, ReactElement } from "react";
|
|
2
2
|
import type { CollectionSchema } from "./collections.ts";
|
|
3
3
|
import type { EntryRecord } from "./entry-store.ts";
|
|
4
|
+
import type { LocaleAlternate } from "./paths.ts";
|
|
4
5
|
export type EntryPageSchema = {
|
|
5
6
|
kind: "entryPage";
|
|
6
7
|
name: string;
|
|
7
8
|
collection: string;
|
|
8
9
|
};
|
|
10
|
+
/** What an entry page renders: the entry in the render locale, its path
|
|
11
|
+
* there, and its address in every supported locale (spec 2026-09-15 §1). */
|
|
9
12
|
export type EntryPageProps = {
|
|
10
13
|
entry: EntryRecord;
|
|
11
14
|
path: string;
|
|
15
|
+
locale: string;
|
|
16
|
+
locales: Record<string, LocaleAlternate | null>;
|
|
12
17
|
};
|
|
13
18
|
export type PairedEntryPage = ((props: EntryPageProps) => ReactElement) & {
|
|
14
19
|
schema: EntryPageSchema;
|
package/dist/entry-page.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
export function entryPage(collection, View) {
|
|
3
|
-
const Component = ((props) => _jsx(View, {
|
|
3
|
+
const Component = ((props) => _jsx(View, { ...props }));
|
|
4
4
|
Component.schema = { kind: "entryPage", name: `entry:${collection.name}`, collection: collection.name };
|
|
5
5
|
return Component;
|
|
6
6
|
}
|
package/dist/entry-store.js
CHANGED
|
@@ -19,7 +19,7 @@ import { assertDepth, assertSupportedLocale, chainOf, entryPathRows, slugOf, uns
|
|
|
19
19
|
/** The field types whose value is an array: a scalar `where` on one is
|
|
20
20
|
* containment, and any-of on one is refused by the site layer (both
|
|
21
21
|
* adapters read the text form of the whole array, never its elements). */
|
|
22
|
-
export const ARRAY_FIELD_TYPES = new Set(["checkboxes", "refList", "list"]);
|
|
22
|
+
export const ARRAY_FIELD_TYPES = new Set(["checkboxes", "refList", "list", "tags"]);
|
|
23
23
|
/** PostgREST's `->>`: a scalar's text, an array's or object's JSON text (Postgres
|
|
24
24
|
* spaces an object's text differently, but no scalar equals either form). */
|
|
25
25
|
const textForm = (value) => (value !== null && typeof value === "object" ? JSON.stringify(value) : String(value));
|
package/dist/fields.d.ts
CHANGED
|
@@ -94,6 +94,9 @@ export declare const f: {
|
|
|
94
94
|
radio: <const O extends readonly OptionInput[]>(options: O) => FieldBuilder<OptionValue<O[number]>, {}>;
|
|
95
95
|
/** Several values from a list, stored as an array, shown as a checkbox list. */
|
|
96
96
|
checkboxes: <const O extends readonly OptionInput[]>(options: O) => FieldBuilder<OptionValue<O[number]>[], {}>;
|
|
97
|
+
/** Short labels edited as chips, stored as a string array: trimmed, no
|
|
98
|
+
* case-insensitive duplicates (spec 2026-09-17 §4). `.min`/`.max` count tags. */
|
|
99
|
+
tags: () => FieldBuilder<string[], {}>;
|
|
97
100
|
object: <S extends Record<string, FieldBuilder<any, any>>>(shape: S & { [K in keyof S]: NotLocalized<S[K]>; }) => FieldBuilder<{ [K in keyof S]: ValueOf<S[K]>; }, {}>;
|
|
98
101
|
/** A repeating item: any builder, but no list at any depth inside the item
|
|
99
102
|
* — a list in an object in a list is still a nested list on screen, and a
|