smoodly 0.0.15 → 0.0.17
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 +238 -36
- package/dist/admin/auth.d.ts +7 -4
- package/dist/admin/auth.js +1 -1
- package/dist/admin/client-ops.js +3 -3
- package/dist/admin/custom-section.d.ts +10 -0
- package/dist/admin/custom-section.js +10 -0
- package/dist/admin/editor/CanvasOverlay.d.ts +6 -2
- package/dist/admin/editor/CanvasOverlay.js +6 -6
- package/dist/admin/editor/EditorView.d.ts +2 -0
- package/dist/admin/editor/EditorView.js +322 -62
- package/dist/admin/editor/GlobalInspector.d.ts +24 -0
- package/dist/admin/editor/GlobalInspector.js +13 -0
- package/dist/admin/editor/InsertPicker.d.ts +6 -5
- package/dist/admin/editor/InsertPicker.js +6 -4
- package/dist/admin/editor/InspectorFields.d.ts +21 -0
- package/dist/admin/editor/InspectorFields.js +22 -0
- package/dist/admin/editor/Outline.d.ts +0 -1
- package/dist/admin/editor/Outline.js +3 -3
- package/dist/admin/editor/PageSettings.js +3 -2
- package/dist/admin/editor/global-edit.d.ts +39 -0
- package/dist/admin/editor/global-edit.js +124 -0
- package/dist/admin/editor/useGlobalEdit.d.ts +6 -0
- package/dist/admin/editor/useGlobalEdit.js +18 -0
- package/dist/admin/forms/FieldWidget.d.ts +14 -1
- package/dist/admin/forms/FieldWidget.js +38 -11
- package/dist/admin/forms/ListField.d.ts +9 -1
- package/dist/admin/forms/ListField.js +16 -8
- package/dist/admin/forms/RichtextElementCard.js +11 -5
- package/dist/admin/forms/RowsWidget.d.ts +13 -0
- package/dist/admin/forms/RowsWidget.js +26 -0
- package/dist/admin/forms/SpanBar.d.ts +5 -0
- package/dist/admin/forms/SpanBar.js +37 -0
- package/dist/admin/forms/richtext-insert.d.ts +2 -2
- package/dist/admin/forms/richtext-insert.js +3 -3
- package/dist/admin/forms/richtext-nodes.js +1 -1
- package/dist/admin/forms/tabs.d.ts +20 -8
- package/dist/admin/forms/tabs.js +19 -23
- package/dist/admin/forms/useRichtextEditor.js +8 -1
- package/dist/admin/global-items.d.ts +9 -0
- package/dist/admin/{shared-items.js → global-items.js} +29 -14
- package/dist/admin/index.d.ts +3 -3
- package/dist/admin/index.js +1 -1
- package/dist/admin/next/op-handler.js +4 -3
- package/dist/admin/ops-impl.d.ts +3 -0
- package/dist/admin/ops-impl.js +242 -56
- package/dist/admin/ops.d.ts +38 -6
- package/dist/admin/serialize.d.ts +18 -7
- package/dist/admin/serialize.js +27 -5
- package/dist/admin/shell/AdminApp.js +7 -7
- package/dist/admin/shell/EntriesList.js +19 -3
- package/dist/admin/shell/EntryForm.js +183 -77
- package/dist/admin/shell/GlobalFields.d.ts +24 -0
- package/dist/admin/shell/GlobalFields.js +19 -0
- package/dist/admin/shell/{SharedForm.d.ts → GlobalForm.d.ts} +1 -1
- package/dist/admin/shell/GlobalForm.js +258 -0
- package/dist/admin/shell/{SharedList.d.ts → GlobalList.d.ts} +1 -1
- package/dist/admin/shell/{SharedList.js → GlobalList.js} +12 -11
- package/dist/admin/shell/IconRail.js +14 -7
- package/dist/admin/shell/PagesList.js +4 -8
- package/dist/admin/shell/entries-list.d.ts +2 -2
- package/dist/admin/shell/entries-list.js +3 -3
- package/dist/admin/shell/{shared-list.d.ts → global-list.d.ts} +3 -3
- package/dist/admin/shell/{shared-list.js → global-list.js} +2 -2
- package/dist/admin/supabase-auth.js +1 -1
- package/dist/admin/tree-ops.d.ts +10 -8
- package/dist/admin/tree-ops.js +28 -16
- package/dist/admin/ui/ActionMenu.d.ts +11 -0
- package/dist/admin/ui/ActionMenu.js +25 -0
- package/dist/admin/ui/HistoryModal.d.ts +23 -0
- package/dist/admin/ui/HistoryModal.js +40 -0
- package/dist/admin/ui/Modal.d.ts +5 -1
- package/dist/admin/ui/Modal.js +3 -3
- package/dist/admin/ui/PublishControl.d.ts +31 -0
- package/dist/admin/ui/PublishControl.js +24 -0
- package/dist/admin/ui/format.d.ts +4 -0
- package/dist/admin/ui/format.js +9 -0
- package/dist/admin/ui/primitives.d.ts +16 -1
- package/dist/admin/ui/primitives.js +20 -4
- package/dist/admin/ui/status-label.d.ts +10 -0
- package/dist/admin/ui/status-label.js +14 -0
- package/dist/admin/ui/theme.js +93 -13
- package/dist/admin/validate.d.ts +27 -2
- package/dist/admin/validate.js +47 -10
- package/dist/collections.d.ts +3 -9
- package/dist/collections.js +1 -2
- package/dist/conditions.d.ts +33 -0
- package/dist/conditions.js +79 -0
- package/dist/config.d.ts +26 -19
- package/dist/config.js +26 -25
- package/dist/css.d.ts +7 -0
- package/dist/css.js +57 -0
- package/dist/custom/CustomSection.d.ts +26 -0
- package/dist/custom/CustomSection.js +20 -0
- package/dist/custom/css.d.ts +3 -0
- package/dist/custom/css.js +28 -0
- package/dist/custom/index.d.ts +26 -0
- package/dist/custom/index.js +16 -0
- package/dist/custom/rows.d.ts +28 -0
- package/dist/custom/rows.js +129 -0
- package/dist/custom/schema.d.ts +32 -0
- package/dist/custom/schema.js +39 -0
- package/dist/entry-store.d.ts +42 -17
- package/dist/entry-store.js +107 -40
- package/dist/fields.d.ts +5 -2
- package/dist/fields.js +4 -2
- package/dist/{shared-id.d.ts → global-id.d.ts} +1 -1
- package/dist/{shared-id.js → global-id.js} +4 -4
- package/dist/global.d.ts +57 -0
- package/dist/global.js +51 -0
- package/dist/index.d.ts +28 -18
- package/dist/index.js +10 -5
- package/dist/media.js +1 -1
- package/dist/next/index.d.ts +1 -1
- package/dist/next/page-renderer.d.ts +4 -2
- package/dist/next/page-renderer.js +17 -13
- package/dist/next/queries.d.ts +6 -6
- package/dist/next/queries.js +7 -7
- package/dist/page.js +2 -2
- package/dist/pairing.d.ts +13 -7
- package/dist/pairing.js +4 -4
- package/dist/refs.d.ts +2 -2
- package/dist/refs.js +4 -4
- package/dist/render.d.ts +13 -1
- package/dist/render.js +10 -5
- package/dist/resolve.d.ts +11 -6
- package/dist/resolve.js +22 -12
- package/dist/revalidate.d.ts +4 -4
- package/dist/revalidate.js +4 -4
- package/dist/richtext-render.js +2 -2
- package/dist/richtext-walk.d.ts +5 -5
- package/dist/richtext-walk.js +4 -4
- package/dist/schema.d.ts +15 -10
- package/dist/schema.js +19 -7
- package/dist/section-wrapper.d.ts +18 -6
- package/dist/section-wrapper.js +24 -17
- package/dist/site.d.ts +17 -5
- package/dist/site.js +36 -12
- package/dist/sql-space.js +1 -1
- package/dist/sql.js +19 -16
- package/dist/store.d.ts +45 -13
- package/dist/store.js +88 -12
- package/dist/styles.d.ts +27 -0
- package/dist/styles.js +60 -0
- package/dist/supabase-entry-store.d.ts +11 -6
- package/dist/supabase-entry-store.js +115 -39
- package/dist/supabase-store.d.ts +13 -6
- package/dist/supabase-store.js +90 -20
- package/dist/zones.d.ts +9 -7
- package/dist/zones.js +5 -5
- package/package.json +1 -1
- package/dist/admin/shared-items.d.ts +0 -9
- package/dist/admin/shell/SharedForm.js +0 -134
- package/dist/shared.d.ts +0 -46
- package/dist/shared.js +0 -36
package/dist/admin/tree-ops.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { GLOBAL_NODE_TYPE } from "../global.js";
|
|
2
2
|
import { setAtPath } from "./field-paths.js";
|
|
3
3
|
export function zoneOf(tree, id) {
|
|
4
4
|
for (const [zone, nodes] of Object.entries(tree.zones)) {
|
|
@@ -64,6 +64,18 @@ export function patchNode(tree, id, ns, key, value) {
|
|
|
64
64
|
return tree;
|
|
65
65
|
return withZone(tree, zone, tree.zones[zone].map((n) => (n.id === id ? { ...n, [ns]: { ...(n[ns] ?? {}), [key]: value } } : n)));
|
|
66
66
|
}
|
|
67
|
+
/** The node's custom CSS (spec 2026-09-14 §4); undefined or "" removes the key. */
|
|
68
|
+
export function patchNodeCss(tree, id, css) {
|
|
69
|
+
const zone = zoneOf(tree, id);
|
|
70
|
+
if (!zone)
|
|
71
|
+
return tree;
|
|
72
|
+
return withZone(tree, zone, tree.zones[zone].map((n) => {
|
|
73
|
+
if (n.id !== id)
|
|
74
|
+
return n;
|
|
75
|
+
const { css: _old, ...rest } = n;
|
|
76
|
+
return css === undefined || css === "" ? rest : { ...rest, css };
|
|
77
|
+
}));
|
|
78
|
+
}
|
|
67
79
|
/** patchNode for a dotted path inside `fields` (spec 2026-09-10 §1):
|
|
68
80
|
* `points.2.heading` sets one item's heading and copies only the spine. */
|
|
69
81
|
export function patchNodePath(tree, id, path, value) {
|
|
@@ -89,11 +101,11 @@ export function allowedSections(policy, all) {
|
|
|
89
101
|
}
|
|
90
102
|
/** The visual items the picker offers in a zone: materialized (an id is
|
|
91
103
|
* known) and rendering a section the zone's policy allows. */
|
|
92
|
-
export function
|
|
104
|
+
export function allowedGlobals(policy, globals, globalIds) {
|
|
93
105
|
if (!policy || policy.kind === "locked")
|
|
94
106
|
return [];
|
|
95
|
-
return
|
|
96
|
-
if (s.section === undefined ||
|
|
107
|
+
return globals.filter((s) => {
|
|
108
|
+
if (s.section === undefined || globalIds[s.name] === undefined)
|
|
97
109
|
return false;
|
|
98
110
|
return policy.allow === "*" || policy.allow.includes(s.section);
|
|
99
111
|
});
|
|
@@ -101,7 +113,7 @@ export function allowedShared(policy, shared, sharedIds) {
|
|
|
101
113
|
/** A page places a visual item as this node (spec 2026-09-07 §6): `item`
|
|
102
114
|
* names the registration, `ref` is the item's row id. */
|
|
103
115
|
export function placementNode(item, ref, id) {
|
|
104
|
-
return { id, type:
|
|
116
|
+
return { id, type: GLOBAL_NODE_TYPE, fields: { item: item.name, ref } };
|
|
105
117
|
}
|
|
106
118
|
/** The empty value a fresh node's field starts at, by descriptor type.
|
|
107
119
|
* Shape matters: a list-valued field seeded with "" is a type lie that
|
|
@@ -111,10 +123,10 @@ const EMPTY_BY_TYPE = { refList: [], object: {} };
|
|
|
111
123
|
* broken-image icon or a stray zero, worse than nothing at all. With no
|
|
112
124
|
* sample, leave the key out of `fields` entirely rather than seed a lie. */
|
|
113
125
|
const ABSENT_WHEN_UNSAMPLED = new Set(["image", "video", "file", "number"]);
|
|
114
|
-
/** Also fills an element node (an `AdminElement` is a section shape plus contexts). */
|
|
126
|
+
/** Also fills an element node (an `AdminElement` is a section shape plus contexts). Never seeds `styles` or `css`: Default stores nothing (spec 2026-09-14 §2). */
|
|
115
127
|
export function nodeFromSample(section, id) {
|
|
116
128
|
const fields = {};
|
|
117
|
-
const
|
|
129
|
+
const settings = {};
|
|
118
130
|
for (const [key, d] of Object.entries(section.fields)) {
|
|
119
131
|
const sampled = section.sample?.[key] ?? d.default ?? (d.type === "boolean" ? false : undefined);
|
|
120
132
|
if (sampled !== undefined)
|
|
@@ -124,20 +136,20 @@ export function nodeFromSample(section, id) {
|
|
|
124
136
|
else
|
|
125
137
|
fields[key] = EMPTY_BY_TYPE[d.type] ?? "";
|
|
126
138
|
}
|
|
127
|
-
for (const [key, d] of Object.entries(section.
|
|
139
|
+
for (const [key, d] of Object.entries(section.settings)) {
|
|
128
140
|
const v = section.sample?.[key] ?? d.default;
|
|
129
141
|
if (v !== undefined)
|
|
130
|
-
|
|
142
|
+
settings[key] = v;
|
|
131
143
|
}
|
|
132
|
-
return { id, type: section.name, fields,
|
|
144
|
+
return { id, type: section.name, fields, settings };
|
|
133
145
|
}
|
|
134
146
|
/** A locked zone holds exactly one node the editor can never insert or
|
|
135
147
|
* remove — so someone has to put it there. This does, from the component's
|
|
136
|
-
* sample (or, for a zone bound to a
|
|
148
|
+
* sample (or, for a zone bound to a global item, a placement), for every
|
|
137
149
|
* locked zone that is empty or missing. Runs when the editor loads a draft,
|
|
138
150
|
* which also covers pages that predate a locked zone being added to their
|
|
139
151
|
* schema. Returns the same tree when nothing changed. */
|
|
140
|
-
export function fillLockedZones(tree, policies, sections,
|
|
152
|
+
export function fillLockedZones(tree, policies, sections, globalIds = {}) {
|
|
141
153
|
let next = null;
|
|
142
154
|
for (const [zone, policy] of Object.entries(policies)) {
|
|
143
155
|
if (policy.kind !== "locked")
|
|
@@ -145,14 +157,14 @@ export function fillLockedZones(tree, policies, sections, sharedIds = {}) {
|
|
|
145
157
|
if ((tree.zones[zone] ?? []).length > 0)
|
|
146
158
|
continue;
|
|
147
159
|
let node = null;
|
|
148
|
-
if (policy.
|
|
149
|
-
// Bound to a
|
|
160
|
+
if (policy.global !== undefined) {
|
|
161
|
+
// Bound to a global item (spec 2026-09-07 §4): a placement, once
|
|
150
162
|
// the editor has loaded the item's row id; until then leave the
|
|
151
163
|
// zone empty rather than seed a plain section there.
|
|
152
|
-
const ref =
|
|
164
|
+
const ref = globalIds[policy.global];
|
|
153
165
|
if (ref === undefined)
|
|
154
166
|
continue;
|
|
155
|
-
node = placementNode({ name: policy.
|
|
167
|
+
node = placementNode({ name: policy.global }, ref, newNodeId(GLOBAL_NODE_TYPE));
|
|
156
168
|
}
|
|
157
169
|
else {
|
|
158
170
|
const section = sections.find((s) => s.name === policy.component);
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export type ActionMenuItem = {
|
|
2
|
+
label: string;
|
|
3
|
+
run(): void;
|
|
4
|
+
disabled?: boolean;
|
|
5
|
+
danger?: boolean;
|
|
6
|
+
};
|
|
7
|
+
export declare function ActionMenu({ label, items, disabled }: {
|
|
8
|
+
label: string;
|
|
9
|
+
items: ActionMenuItem[];
|
|
10
|
+
disabled?: boolean;
|
|
11
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
// A round three-dot button and its popover of actions (spec 2026-09-13
|
|
4
|
+
// publish flow §2). Items are always rendered and disabled when they do
|
|
5
|
+
// not apply — the layout never shifts and the editor learns what exists.
|
|
6
|
+
// The popover is the Pages list's row menu shape (`.sm-picker`), anchored
|
|
7
|
+
// under the button instead of at the pointer.
|
|
8
|
+
import { useEffect, useRef, useState } from "react";
|
|
9
|
+
import { cx } from "./primitives.js";
|
|
10
|
+
export function ActionMenu({ label, items, disabled }) {
|
|
11
|
+
const [open, setOpen] = useState(false);
|
|
12
|
+
const ref = useRef(null);
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
if (!open)
|
|
15
|
+
return;
|
|
16
|
+
const onDown = (e) => { if (!ref.current?.contains(e.target))
|
|
17
|
+
setOpen(false); };
|
|
18
|
+
const onKey = (e) => { if (e.key === "Escape")
|
|
19
|
+
setOpen(false); };
|
|
20
|
+
document.addEventListener("pointerdown", onDown);
|
|
21
|
+
document.addEventListener("keydown", onKey);
|
|
22
|
+
return () => { document.removeEventListener("pointerdown", onDown); document.removeEventListener("keydown", onKey); };
|
|
23
|
+
}, [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
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type ReactElement, type ReactNode } from "react";
|
|
2
|
+
import type { PageLocale } from "../../store.ts";
|
|
3
|
+
import type { VersionSummary } from "../ops.ts";
|
|
4
|
+
export type HistoryTag = "Draft" | "Live" | null;
|
|
5
|
+
export type HistoryRow = VersionSummary & {
|
|
6
|
+
tag: HistoryTag;
|
|
7
|
+
};
|
|
8
|
+
export declare const RESTORE_CONFIRM = "Restore this version? The current draft will be replaced. Nothing goes live until you publish.";
|
|
9
|
+
/** The rows for a locale's versions: the unpublished draft is Draft, the
|
|
10
|
+
* row the site serves Live, an older publish carries nothing. */
|
|
11
|
+
export declare function historyRows(versions: VersionSummary[], row: Pick<PageLocale, "status" | "draftVersionId" | "publishedVersionId">): HistoryRow[];
|
|
12
|
+
export declare function HistoryModal({ rows, draftId, render, onSelect, onRestore, onClose }: {
|
|
13
|
+
rows: HistoryRow[];
|
|
14
|
+
/** The current draft's version id: Restore is disabled on it. */
|
|
15
|
+
draftId: string | null;
|
|
16
|
+
/** The main area for the selected version. */
|
|
17
|
+
render(versionId: string): ReactNode;
|
|
18
|
+
/** The selection changed — fired once for the initial row too; the entry screens load the snapshot here. */
|
|
19
|
+
onSelect?(versionId: string): void;
|
|
20
|
+
/** Resolves true when the restore landed; the modal then closes itself. */
|
|
21
|
+
onRestore(versionId: string): Promise<boolean>;
|
|
22
|
+
onClose(): void;
|
|
23
|
+
}): ReactElement;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
// The History modal (spec 2026-09-13 history §5): the locale's versions on
|
|
4
|
+
// the left — newest first, the unpublished draft tagged Draft and the row
|
|
5
|
+
// the site serves Live — and the selected version on the right, rendered
|
|
6
|
+
// by the screen that opened it (an iframe of the site's draft route for a
|
|
7
|
+
// page, a disabled form for an entry or a global item). Restore confirms,
|
|
8
|
+
// hands the id to the screen, and closes once the restore landed.
|
|
9
|
+
import { useEffect, useState } from "react";
|
|
10
|
+
import { versionTime } from "./format.js";
|
|
11
|
+
import { Modal } from "./Modal.js";
|
|
12
|
+
import { Button, cx } from "./primitives.js";
|
|
13
|
+
export const RESTORE_CONFIRM = "Restore this version? The current draft will be replaced. Nothing goes live until you publish.";
|
|
14
|
+
/** The rows for a locale's versions: the unpublished draft is Draft, the
|
|
15
|
+
* row the site serves Live, an older publish carries nothing. */
|
|
16
|
+
export function historyRows(versions, row) {
|
|
17
|
+
const live = row.status === "published";
|
|
18
|
+
return versions.map((v) => ({
|
|
19
|
+
...v,
|
|
20
|
+
tag: v.id === row.draftVersionId && (v.id !== row.publishedVersionId || !live) ? "Draft"
|
|
21
|
+
: v.id === row.publishedVersionId && live ? "Live"
|
|
22
|
+
: null,
|
|
23
|
+
}));
|
|
24
|
+
}
|
|
25
|
+
export function HistoryModal({ rows, draftId, render, onSelect, onRestore, onClose }) {
|
|
26
|
+
const [selected, setSelected] = useState(rows[0]?.id ?? null);
|
|
27
|
+
const [busy, setBusy] = useState(false);
|
|
28
|
+
useEffect(() => { if (selected)
|
|
29
|
+
onSelect?.(selected); }, [selected]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
30
|
+
const restore = async () => {
|
|
31
|
+
if (!selected || !window.confirm(RESTORE_CONFIRM))
|
|
32
|
+
return;
|
|
33
|
+
setBusy(true);
|
|
34
|
+
const landed = await onRestore(selected);
|
|
35
|
+
setBusy(false);
|
|
36
|
+
if (landed)
|
|
37
|
+
onClose();
|
|
38
|
+
};
|
|
39
|
+
return (_jsx(Modal, { title: "History", width: 1200, height: "85vh", flush: true, onClose: onClose, footer: _jsxs(_Fragment, { children: [_jsx(Button, { variant: "secondary", onClick: onClose, children: "Close" }), _jsx(Button, { disabled: busy || !selected || selected === draftId, onClick: () => { void restore(); }, children: busy ? "Restoring…" : "Restore" })] }), children: _jsxs("div", { className: "sm-history", children: [_jsxs("div", { className: "sm-history__list", role: "listbox", "aria-label": "Versions", children: [rows.length === 0 && _jsx("span", { className: "sm-hint", style: { padding: 12 }, children: "No versions yet." }), rows.map((r) => (_jsxs("button", { type: "button", role: "option", "aria-selected": r.id === selected, className: cx("sm-history__row", r.id === selected && "sm-history__row--active"), onClick: () => setSelected(r.id), children: [_jsx("span", { children: versionTime(r.savedAt) }), r.tag && _jsx("span", { className: cx("sm-history__tag", r.tag === "Live" && "sm-history__tag--live"), children: r.tag })] }, r.id)))] }), _jsx("div", { className: "sm-history__main", children: selected ? render(selected) : null })] }) }));
|
|
40
|
+
}
|
package/dist/admin/ui/Modal.d.ts
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { type ReactElement, type ReactNode } from "react";
|
|
2
|
-
export declare function Modal({ title, width, onClose, children, footer }: {
|
|
2
|
+
export declare function Modal({ title, width, height, flush, onClose, children, footer }: {
|
|
3
3
|
title: string;
|
|
4
4
|
width?: number;
|
|
5
|
+
/** A fixed panel height (e.g. `"85vh"`) for a modal whose body must fill it; default: the content's. */
|
|
6
|
+
height?: number | string;
|
|
7
|
+
/** Drops the body padding for a modal whose body is a two-pane layout (History). */
|
|
8
|
+
flush?: boolean;
|
|
5
9
|
onClose(): void;
|
|
6
10
|
children: ReactNode;
|
|
7
11
|
footer?: ReactNode;
|
package/dist/admin/ui/Modal.js
CHANGED
|
@@ -8,13 +8,13 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
8
8
|
// overflow the same way. Static rendering (no document) keeps it inline.
|
|
9
9
|
import { useEffect, useState } from "react";
|
|
10
10
|
import { createPortal } from "react-dom";
|
|
11
|
-
import { IconButton } from "./primitives.js";
|
|
11
|
+
import { cx, IconButton } from "./primitives.js";
|
|
12
12
|
function adminRoot() {
|
|
13
13
|
if (typeof document === "undefined")
|
|
14
14
|
return null;
|
|
15
15
|
return document.querySelector(".sm-admin") ?? document.body;
|
|
16
16
|
}
|
|
17
|
-
export function Modal({ title, width = 720, onClose, children, footer }) {
|
|
17
|
+
export function Modal({ title, width = 720, height, flush = false, onClose, children, footer }) {
|
|
18
18
|
const [root] = useState(adminRoot);
|
|
19
19
|
useEffect(() => {
|
|
20
20
|
const onKey = (e) => { if (e.key === "Escape")
|
|
@@ -22,6 +22,6 @@ export function Modal({ title, width = 720, onClose, children, footer }) {
|
|
|
22
22
|
window.addEventListener("keydown", onKey);
|
|
23
23
|
return () => window.removeEventListener("keydown", onKey);
|
|
24
24
|
}, [onClose]);
|
|
25
|
-
const modal = (_jsx("div", { className: "sm-modal", onClick: onClose, children: _jsxs("div", { className: "sm-modal__panel", role: "dialog", "aria-label": title, style: { width }, onClick: (e) => e.stopPropagation(), children: [_jsxs("div", { className: "sm-modal__head", children: [_jsx("span", { className: "sm-title", children: title }), _jsx(IconButton, { label: "Close", onClick: onClose, children: "\u00D7" })] }), _jsx("div", { className: "sm-modal__body", children: children }), footer && _jsx("div", { className: "sm-modal__foot", children: footer })] }) }));
|
|
25
|
+
const modal = (_jsx("div", { className: "sm-modal", onClick: onClose, children: _jsxs("div", { className: "sm-modal__panel", role: "dialog", "aria-label": title, style: { width, ...(height !== undefined ? { height } : {}) }, onClick: (e) => e.stopPropagation(), children: [_jsxs("div", { className: "sm-modal__head", children: [_jsx("span", { className: "sm-title", children: title }), _jsx(IconButton, { label: "Close", onClick: onClose, children: "\u00D7" })] }), _jsx("div", { className: cx("sm-modal__body", flush && "sm-modal__body--flush"), children: children }), footer && _jsx("div", { className: "sm-modal__foot", children: footer })] }) }));
|
|
26
26
|
return root ? createPortal(modal, root) : modal;
|
|
27
27
|
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { LocaleStatus } from "../shell/pages-tree.ts";
|
|
2
|
+
import { type ActionMenuItem } from "./ActionMenu.tsx";
|
|
3
|
+
export type PublishBusy = "publish" | "discard" | "unpublish" | null;
|
|
4
|
+
/** The three-dot menu's items: History first when the screen offers it (spec 2026-09-13 history §5;
|
|
5
|
+
* every collection since 2026-09-14), then the two publish-flow actions — always rendered,
|
|
6
|
+
* disabled when they do not apply. */
|
|
7
|
+
export declare function publishMenu(opts: {
|
|
8
|
+
can: {
|
|
9
|
+
discard: boolean;
|
|
10
|
+
unpublish: boolean;
|
|
11
|
+
};
|
|
12
|
+
onDiscard(): void;
|
|
13
|
+
onUnpublish(): void;
|
|
14
|
+
onHistory?(): void;
|
|
15
|
+
}): ActionMenuItem[];
|
|
16
|
+
export declare function PublishControl({ status, pending, busy, flash, disabled, onPublish, onDiscard, onUnpublish, onHistory, }: {
|
|
17
|
+
/** The locale's stored state; null while the document loads or before it exists. */
|
|
18
|
+
status: LocaleStatus | null;
|
|
19
|
+
/** The editor holds a change the store has not confirmed (dirty, or a save pending / in flight / failed). */
|
|
20
|
+
pending?: boolean;
|
|
21
|
+
busy?: PublishBusy;
|
|
22
|
+
/** The moment after a publish: the button reads "Published ✓". */
|
|
23
|
+
flash?: boolean;
|
|
24
|
+
/** No document to act on (loading, a new unsaved entry). */
|
|
25
|
+
disabled?: boolean;
|
|
26
|
+
onPublish(): void;
|
|
27
|
+
onDiscard(): void;
|
|
28
|
+
onUnpublish(): void;
|
|
29
|
+
/** Opens History (spec 2026-09-13 history §5). Absent = no History item (the canvas card). */
|
|
30
|
+
onHistory?(): void;
|
|
31
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { ActionMenu } from "./ActionMenu.js";
|
|
4
|
+
import { Button, StatusBadge } from "./primitives.js";
|
|
5
|
+
import { publishActions, statusLabel } from "./status-label.js";
|
|
6
|
+
const BUSY_WORD = {
|
|
7
|
+
publish: "Publishing…", discard: "Discarding…", unpublish: "Unpublishing…",
|
|
8
|
+
};
|
|
9
|
+
/** The three-dot menu's items: History first when the screen offers it (spec 2026-09-13 history §5;
|
|
10
|
+
* every collection since 2026-09-14), then the two publish-flow actions — always rendered,
|
|
11
|
+
* disabled when they do not apply. */
|
|
12
|
+
export function publishMenu(opts) {
|
|
13
|
+
return [
|
|
14
|
+
...(opts.onHistory ? [{ label: "History", run: opts.onHistory }] : []),
|
|
15
|
+
{ label: "Discard changes", danger: true, disabled: !opts.can.discard, run: opts.onDiscard },
|
|
16
|
+
{ label: "Unpublish", disabled: !opts.can.unpublish, run: opts.onUnpublish },
|
|
17
|
+
];
|
|
18
|
+
}
|
|
19
|
+
export function PublishControl({ status, pending = false, busy = null, flash = false, disabled = false, onPublish, onDiscard, onUnpublish, onHistory, }) {
|
|
20
|
+
const can = publishActions(status, pending);
|
|
21
|
+
const inert = disabled || busy !== null;
|
|
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 }) })] }));
|
|
24
|
+
}
|
|
@@ -1 +1,5 @@
|
|
|
1
1
|
export declare function relativeTime(ms: number, now?: number): string;
|
|
2
|
+
/** A version's saved time: an absolute date and time — History compares moments, not distances. */
|
|
3
|
+
export declare function versionTime(ms: number): string;
|
|
4
|
+
/** The "Updated" info line: the relative time, then who, when the row knows (spec 2026-09-13 history §2). */
|
|
5
|
+
export declare function updatedLine(updatedAt: number, updatedBy: string | null, now?: number): string;
|
package/dist/admin/ui/format.js
CHANGED
|
@@ -23,3 +23,12 @@ export function relativeTime(ms, now = Date.now()) {
|
|
|
23
23
|
month: "short", day: "numeric", ...(sameYear ? {} : { year: "numeric" }),
|
|
24
24
|
});
|
|
25
25
|
}
|
|
26
|
+
/** A version's saved time: an absolute date and time — History compares moments, not distances. */
|
|
27
|
+
export function versionTime(ms) {
|
|
28
|
+
return new Date(ms).toLocaleString(undefined, { day: "numeric", month: "short", year: "numeric", hour: "2-digit", minute: "2-digit" });
|
|
29
|
+
}
|
|
30
|
+
/** The "Updated" info line: the relative time, then who, when the row knows (spec 2026-09-13 history §2). */
|
|
31
|
+
export function updatedLine(updatedAt, updatedBy, now = Date.now()) {
|
|
32
|
+
const when = relativeTime(updatedAt, now);
|
|
33
|
+
return updatedBy ? `${when} · ${updatedBy}` : when;
|
|
34
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { AnchorHTMLAttributes, ButtonHTMLAttributes, CSSProperties, ReactNode } from "react";
|
|
2
2
|
import type { SaveState } from "../editor/autosave.ts";
|
|
3
|
+
import type { FieldError } from "../validate.ts";
|
|
3
4
|
/** The wordmark, inlined: a package can't ship a file into the host app's
|
|
4
5
|
* /public. Its colour is the --logo-color token (accent in the warm look). */
|
|
5
6
|
export declare function Logo({ height }: {
|
|
@@ -43,11 +44,13 @@ export declare function Grip(props: ButtonHTMLAttributes<HTMLButtonElement>): im
|
|
|
43
44
|
/** Label + optional counter above a control — the handoff's Input/Textarea head.
|
|
44
45
|
* `asDiv` for groups holding more than one control: an implicit <label>
|
|
45
46
|
* activates its first labelable descendant, so wrapping several is a trap. */
|
|
46
|
-
export declare function Field({ label, mark, counter, hint, error, asDiv, children, style, className, path }: {
|
|
47
|
+
export declare function Field({ label, mark, counter, action, hint, error, asDiv, children, style, className, path }: {
|
|
47
48
|
label?: ReactNode;
|
|
48
49
|
/** A small glyph after the label — the field's "same in every language" mark. */
|
|
49
50
|
mark?: ReactNode;
|
|
50
51
|
counter?: ReactNode;
|
|
52
|
+
/** A small text action at the head's right end, after the counter (a list's Collapse all). */
|
|
53
|
+
action?: ReactNode;
|
|
51
54
|
hint?: ReactNode;
|
|
52
55
|
error?: ReactNode;
|
|
53
56
|
asDiv?: boolean;
|
|
@@ -115,10 +118,22 @@ export declare function SectionLabel({ children, action, mono, style }: {
|
|
|
115
118
|
export declare function Divider({ bleed }: {
|
|
116
119
|
bleed?: number;
|
|
117
120
|
}): import("react").JSX.Element;
|
|
121
|
+
/** The lines under a publish-validation banner: one per field, each focusing its widget
|
|
122
|
+
* when the screen can reveal it (spec 2026-09-14 §2). */
|
|
123
|
+
export declare function FieldErrorList({ fields, onReveal }: {
|
|
124
|
+
fields: FieldError[];
|
|
125
|
+
onReveal?(path: string): void;
|
|
126
|
+
}): import("react").JSX.Element | null;
|
|
118
127
|
export declare function Banner({ tone, children, onDismiss }: {
|
|
119
128
|
tone: "ok" | "error" | "warn";
|
|
120
129
|
children: ReactNode;
|
|
121
130
|
onDismiss?: () => void;
|
|
122
131
|
}): import("react").JSX.Element;
|
|
123
132
|
export declare function cx(...parts: (string | false | null | undefined)[]): string;
|
|
133
|
+
/** One line of a form's info block: a muted label, the value right-aligned and clipped. */
|
|
134
|
+
export declare function InfoRow({ label, value, mono }: {
|
|
135
|
+
label: string;
|
|
136
|
+
value: string;
|
|
137
|
+
mono?: boolean;
|
|
138
|
+
}): import("react").JSX.Element;
|
|
124
139
|
export {};
|
|
@@ -66,9 +66,9 @@ export function Grip(props) {
|
|
|
66
66
|
/** Label + optional counter above a control — the handoff's Input/Textarea head.
|
|
67
67
|
* `asDiv` for groups holding more than one control: an implicit <label>
|
|
68
68
|
* activates its first labelable descendant, so wrapping several is a trap. */
|
|
69
|
-
export function Field({ label, mark, counter, hint, error, asDiv, children, style, className, path }) {
|
|
69
|
+
export function Field({ label, mark, counter, action, hint, error, asDiv, children, style, className, path }) {
|
|
70
70
|
const Tag = asDiv ? "div" : "label";
|
|
71
|
-
return (_jsxs(Tag, { className: cx("sm-field", className), style: style, "data-sm-path": path, children: [(label || counter) && (_jsxs("span", { className: "sm-field__head", children: [_jsxs("span", { className: "sm-label", children: [label, mark] }), counter && _jsx("span", { className: "sm-counter", children: counter })] })), children, hint && _jsx("span", { className: "sm-hint", children: hint }), error && _jsx("span", { className: "sm-error", children: error })] }));
|
|
71
|
+
return (_jsxs(Tag, { className: cx("sm-field", className), style: style, "data-sm-path": path, children: [(label || counter || action) && (_jsxs("span", { className: "sm-field__head", children: [_jsxs("span", { className: "sm-label", children: [label, mark] }), (counter || action) && (_jsxs("span", { className: "sm-field__aside", children: [counter && _jsx("span", { className: "sm-counter", children: counter }), action] }))] })), children, hint && _jsx("span", { className: "sm-hint", children: hint }), error && _jsx("span", { className: "sm-error", children: error })] }));
|
|
72
72
|
}
|
|
73
73
|
/** The mark beside a field that is NOT `.localized()`: a globe-off, so a
|
|
74
74
|
* multilingual form needs no "shared across languages" grouping. */
|
|
@@ -106,11 +106,11 @@ export function Segmented({ options, value, onChange, stretch, disabled, label,
|
|
|
106
106
|
}) }));
|
|
107
107
|
}
|
|
108
108
|
/* ── data ────────────────────────────────────────────────────────────── */
|
|
109
|
+
// Keyed by the product labels of ui/status-label.ts (lowercased by StatusBadge).
|
|
109
110
|
const STATUS_TONE = {
|
|
110
111
|
published: "published",
|
|
112
|
+
"unpublished changes": "changes",
|
|
111
113
|
draft: "draft",
|
|
112
|
-
"draft edits": "scheduled",
|
|
113
|
-
scheduled: "scheduled",
|
|
114
114
|
};
|
|
115
115
|
export function StatusBadge({ status }) {
|
|
116
116
|
const tone = STATUS_TONE[status.toLowerCase()] ?? "draft";
|
|
@@ -134,9 +134,25 @@ export function SectionLabel({ children, action, mono, style }) {
|
|
|
134
134
|
export function Divider({ bleed = 0 }) {
|
|
135
135
|
return _jsx("div", { className: "sm-divider", style: { margin: `0 -${bleed}px` } });
|
|
136
136
|
}
|
|
137
|
+
/** The lines under a publish-validation banner: one per field, each focusing its widget
|
|
138
|
+
* when the screen can reveal it (spec 2026-09-14 §2). */
|
|
139
|
+
export function FieldErrorList({ fields, onReveal }) {
|
|
140
|
+
if (fields.length === 0)
|
|
141
|
+
return null;
|
|
142
|
+
return (_jsx("ul", { className: "sm-fielderr", children: fields.map((f) => (_jsxs("li", { children: [onReveal
|
|
143
|
+
? _jsx("button", { type: "button", className: "sm-fielderr__field", onClick: () => onReveal(f.field), children: f.field })
|
|
144
|
+
: f.field, ": ", f.message] }, f.field))) }));
|
|
145
|
+
}
|
|
137
146
|
export function Banner({ tone, children, onDismiss }) {
|
|
138
147
|
return (_jsxs("div", { className: `sm-banner sm-banner--${tone}`, role: tone === "error" ? "alert" : undefined, children: [_jsx("div", { style: { flex: 1, minWidth: 0 }, children: children }), onDismiss && (_jsx("button", { type: "button", onClick: onDismiss, "aria-label": "Dismiss", style: { background: "transparent", border: 0, color: "inherit", cursor: "pointer", font: "inherit", padding: 0 }, children: "\u00D7" }))] }));
|
|
139
148
|
}
|
|
140
149
|
export function cx(...parts) {
|
|
141
150
|
return parts.filter(Boolean).join(" ");
|
|
142
151
|
}
|
|
152
|
+
/** One line of a form's info block: a muted label, the value right-aligned and clipped. */
|
|
153
|
+
export function InfoRow({ label, value, mono }) {
|
|
154
|
+
return (_jsxs("div", { style: { display: "flex", justifyContent: "space-between", gap: 10, fontSize: "var(--text-sm)", color: "var(--text-muted)" }, children: [_jsx("span", { children: label }), _jsx("span", { style: {
|
|
155
|
+
color: "var(--text)", fontFamily: mono ? "var(--font-mono)" : undefined,
|
|
156
|
+
overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap",
|
|
157
|
+
}, children: value })] }));
|
|
158
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { LocaleStatus } from "../shell/pages-tree.ts";
|
|
2
|
+
export type StatusLabel = "Published" | "Unpublished changes" | "Draft";
|
|
3
|
+
export declare function statusLabel(status: LocaleStatus): StatusLabel;
|
|
4
|
+
/** Which of the three actions apply (spec §2). `pending`: the editor holds a change
|
|
5
|
+
* the store has not confirmed yet — Publish wakes on the first keystroke, not on the ack. */
|
|
6
|
+
export declare function publishActions(status: LocaleStatus | null, pending: boolean): {
|
|
7
|
+
publish: boolean;
|
|
8
|
+
discard: boolean;
|
|
9
|
+
unpublish: boolean;
|
|
10
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export function statusLabel(status) {
|
|
2
|
+
return status === "published" ? "Published" : status === "draft edits" ? "Unpublished changes" : "Draft";
|
|
3
|
+
}
|
|
4
|
+
/** Which of the three actions apply (spec §2). `pending`: the editor holds a change
|
|
5
|
+
* the store has not confirmed yet — Publish wakes on the first keystroke, not on the ack. */
|
|
6
|
+
export function publishActions(status, pending) {
|
|
7
|
+
if (status === null)
|
|
8
|
+
return { publish: false, discard: false, unpublish: false };
|
|
9
|
+
return {
|
|
10
|
+
publish: status !== "published" || pending,
|
|
11
|
+
discard: status === "draft edits",
|
|
12
|
+
unpublish: status === "published" || status === "draft edits",
|
|
13
|
+
};
|
|
14
|
+
}
|