smoodly 0.0.24 → 0.0.26
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 +214 -40
- package/bin/smoodly.js +6 -0
- package/dist/admin/client-ops.js +1 -0
- package/dist/admin/editor/EditorView.js +100 -22
- package/dist/admin/editor/InsertPicker.d.ts +14 -0
- package/dist/admin/editor/InsertPicker.js +37 -7
- package/dist/admin/editor/InspectorFields.js +1 -1
- package/dist/admin/editor/Outline.js +2 -2
- package/dist/admin/editor/PageSettings.js +1 -1
- package/dist/admin/editor/SeoSettings.js +1 -1
- package/dist/admin/editor/autosave.d.ts +4 -1
- package/dist/admin/editor/autosave.js +24 -1
- package/dist/admin/editor/global-edit.d.ts +5 -1
- package/dist/admin/editor/global-edit.js +15 -5
- package/dist/admin/editor/locale-url.d.ts +7 -4
- package/dist/admin/editor/locale-url.js +14 -7
- package/dist/admin/editor/useGlobalEdit.d.ts +1 -1
- package/dist/admin/editor/useGlobalEdit.js +1 -1
- package/dist/admin/editor/usePageLocale.js +9 -1
- package/dist/admin/forms/CropEditor.d.ts +4 -2
- package/dist/admin/forms/CropEditor.js +6 -3
- package/dist/admin/forms/DatePicker.d.ts +9 -0
- package/dist/admin/forms/DatePicker.js +133 -0
- package/dist/admin/forms/FieldWidget.js +11 -5
- package/dist/admin/forms/ImagePreview.d.ts +9 -0
- package/dist/admin/forms/ImagePreview.js +11 -0
- package/dist/admin/forms/MediaWidget.d.ts +8 -2
- package/dist/admin/forms/MediaWidget.js +13 -13
- package/dist/admin/forms/RichtextElementCard.js +1 -1
- package/dist/admin/forms/RichtextImageCard.js +14 -9
- package/dist/admin/forms/date-picker.d.ts +44 -0
- package/dist/admin/forms/date-picker.js +117 -0
- package/dist/admin/forms/elements-context.d.ts +3 -0
- package/dist/admin/forms/elements-context.js +7 -2
- package/dist/admin/forms/time-zone-context.d.ts +7 -0
- package/dist/admin/forms/time-zone-context.js +15 -0
- package/dist/admin/forms/useRichtextEditor.js +10 -1
- package/dist/admin/next/bridge.d.ts +2 -1
- package/dist/admin/next/bridge.js +35 -5
- package/dist/admin/next/create-admin.d.ts +4 -0
- package/dist/admin/next/create-admin.js +3 -1
- package/dist/admin/next/index.d.ts +1 -0
- package/dist/admin/ops-impl.js +74 -24
- package/dist/admin/ops.d.ts +31 -12
- package/dist/admin/serialize.d.ts +2 -0
- package/dist/admin/serialize.js +1 -0
- package/dist/admin/shell/AdminApp.d.ts +3 -1
- package/dist/admin/shell/AdminApp.js +42 -2
- package/dist/admin/shell/EntriesList.js +17 -22
- package/dist/admin/shell/EntryForm.js +91 -13
- package/dist/admin/shell/GlobalForm.js +68 -9
- package/dist/admin/shell/GlobalList.js +10 -20
- package/dist/admin/shell/IconRail.js +14 -5
- package/dist/admin/shell/InviteModal.js +9 -5
- package/dist/admin/shell/ListView.js +2 -2
- package/dist/admin/shell/MediaScreen.js +11 -1
- package/dist/admin/shell/MembersPage.js +33 -15
- package/dist/admin/shell/PageDialogs.d.ts +26 -0
- package/dist/admin/shell/PageDialogs.js +38 -0
- package/dist/admin/shell/PagesList.js +101 -74
- package/dist/admin/shell/RouteLink.js +1 -1
- package/dist/admin/shell/SchemaNotice.d.ts +4 -0
- package/dist/admin/shell/SchemaNotice.js +4 -0
- package/dist/admin/shell/SettingsView.js +11 -26
- package/dist/admin/shell/live-url.d.ts +4 -3
- package/dist/admin/shell/live-url.js +12 -3
- package/dist/admin/shell/locale-memory.d.ts +8 -0
- package/dist/admin/shell/locale-memory.js +23 -0
- package/dist/admin/shell/members-list.d.ts +2 -0
- package/dist/admin/shell/members-list.js +5 -0
- package/dist/admin/shell/page-dialogs.d.ts +9 -0
- package/dist/admin/shell/page-dialogs.js +19 -0
- package/dist/admin/shell/presence-context.d.ts +9 -0
- package/dist/admin/shell/presence-context.js +35 -0
- package/dist/admin/shell/presence-realtime.d.ts +3 -0
- package/dist/admin/shell/presence-realtime.js +22 -0
- package/dist/admin/shell/presence.d.ts +51 -0
- package/dist/admin/shell/presence.js +90 -0
- package/dist/admin/shell/router.d.ts +10 -7
- package/dist/admin/shell/router.js +18 -9
- package/dist/admin/shell/settings.d.ts +0 -6
- package/dist/admin/shell/settings.js +4 -5
- package/dist/admin/shell/stale.d.ts +28 -0
- package/dist/admin/shell/stale.js +17 -0
- package/dist/admin/tree-ops.js +3 -0
- package/dist/admin/ui/ActionMenu.js +26 -6
- package/dist/admin/ui/Combobox.d.ts +5 -0
- package/dist/admin/ui/Combobox.js +1 -1
- package/dist/admin/ui/ConfirmDialog.d.ts +8 -0
- package/dist/admin/ui/ConfirmDialog.js +30 -0
- package/dist/admin/ui/Dialog.d.ts +13 -0
- package/dist/admin/ui/Dialog.js +54 -0
- package/dist/admin/ui/HistoryModal.d.ts +2 -1
- package/dist/admin/ui/HistoryModal.js +7 -2
- package/dist/admin/ui/LockIcon.d.ts +4 -0
- package/dist/admin/ui/LockIcon.js +12 -0
- package/dist/admin/ui/PresenceLine.d.ts +6 -0
- package/dist/admin/ui/PresenceLine.js +11 -0
- package/dist/admin/ui/ResizeHandle.js +4 -4
- package/dist/admin/ui/StaleBanner.d.ts +5 -0
- package/dist/admin/ui/StaleBanner.js +9 -0
- package/dist/admin/ui/confirm.d.ts +35 -0
- package/dist/admin/ui/confirm.js +57 -0
- package/dist/admin/ui/primitives.d.ts +19 -4
- package/dist/admin/ui/primitives.js +12 -5
- package/dist/admin/ui/resize.d.ts +7 -4
- package/dist/admin/ui/resize.js +7 -4
- package/dist/admin/ui/theme.d.ts +1 -1
- package/dist/admin/ui/theme.js +373 -204
- package/dist/admin/validate.d.ts +4 -0
- package/dist/admin/validate.js +28 -0
- package/dist/cli/load-collections.d.ts +2 -0
- package/dist/cli/load-collections.js +26 -0
- package/dist/cli/main.d.ts +13 -0
- package/dist/cli/main.js +62 -0
- package/dist/cli/migrate.d.ts +23 -0
- package/dist/cli/migrate.js +64 -0
- package/dist/config.d.ts +4 -0
- package/dist/config.js +9 -0
- package/dist/draft-token.d.ts +31 -0
- package/dist/draft-token.js +41 -0
- package/dist/entry-store.d.ts +11 -8
- package/dist/entry-store.js +24 -10
- package/dist/env.d.ts +9 -0
- package/dist/env.js +19 -0
- package/dist/fields.d.ts +2 -0
- package/dist/fields.js +2 -0
- package/dist/index.d.ts +6 -3
- package/dist/index.js +4 -2
- package/dist/marks.d.ts +3 -0
- package/dist/marks.js +3 -0
- package/dist/migrations.d.ts +37 -0
- package/dist/migrations.js +73 -0
- package/dist/schema-version.d.ts +17 -0
- package/dist/schema-version.js +32 -0
- package/dist/sql-space.js +17 -0
- package/dist/sql.js +21 -0
- package/dist/store.d.ts +14 -8
- package/dist/store.js +30 -8
- package/dist/supabase-entry-store.d.ts +15 -5
- package/dist/supabase-entry-store.js +87 -22
- package/dist/supabase-store.d.ts +18 -5
- package/dist/supabase-store.js +83 -20
- package/dist/time-zone.d.ts +6 -0
- package/dist/time-zone.js +27 -0
- package/migrations/20260926120000_smoodly_v1.sql +535 -0
- package/migrations/20260926130000_smoodly_v2.sql +26 -0
- package/package.json +13 -3
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
// The dialog card (design_handoff_smoodly_neutral/components/ConfirmModal.html):
|
|
4
|
+
// a scrim over the whole admin, a 440px card with a title row and a close
|
|
5
|
+
// button, whatever the dialog puts inside, then its actions. Escape and
|
|
6
|
+
// the scrim close it; Tab cycles inside; focus returns to where it was.
|
|
7
|
+
// The confirm question (ConfirmDialog) and the page form dialogs
|
|
8
|
+
// (shell/PageDialogs) are built on it. Portalled into the admin wrapper
|
|
9
|
+
// so it covers the frame and any modal underneath; static rendering (no
|
|
10
|
+
// document) keeps it inline.
|
|
11
|
+
import { useEffect, useId, useRef } from "react";
|
|
12
|
+
import { createPortal } from "react-dom";
|
|
13
|
+
import { cx } from "./primitives.js";
|
|
14
|
+
function adminRoot() {
|
|
15
|
+
if (typeof document === "undefined")
|
|
16
|
+
return null;
|
|
17
|
+
return document.querySelector(".sm-admin") ?? document.body;
|
|
18
|
+
}
|
|
19
|
+
export function DialogCard({ title, role = "dialog", describedBy, onClose, actions, className, children }) {
|
|
20
|
+
const panel = useRef(null);
|
|
21
|
+
const uid = useId();
|
|
22
|
+
const titleId = `sm-dialog-title${uid}`;
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
const before = typeof document !== "undefined" ? document.activeElement : null;
|
|
25
|
+
const onKey = (e) => { if (e.key === "Escape") {
|
|
26
|
+
e.stopPropagation();
|
|
27
|
+
onClose();
|
|
28
|
+
} };
|
|
29
|
+
window.addEventListener("keydown", onKey, true);
|
|
30
|
+
return () => {
|
|
31
|
+
window.removeEventListener("keydown", onKey, true);
|
|
32
|
+
before?.focus?.();
|
|
33
|
+
};
|
|
34
|
+
}, [onClose]);
|
|
35
|
+
const trap = (e) => {
|
|
36
|
+
if (e.key !== "Tab" || !panel.current)
|
|
37
|
+
return;
|
|
38
|
+
const focusable = Array.from(panel.current.querySelectorAll("button:not(:disabled),input:not(:disabled),select:not(:disabled),textarea:not(:disabled)"));
|
|
39
|
+
if (focusable.length === 0)
|
|
40
|
+
return;
|
|
41
|
+
const first = focusable[0], last = focusable[focusable.length - 1];
|
|
42
|
+
if (e.shiftKey && document.activeElement === first) {
|
|
43
|
+
e.preventDefault();
|
|
44
|
+
last.focus();
|
|
45
|
+
}
|
|
46
|
+
else if (!e.shiftKey && document.activeElement === last) {
|
|
47
|
+
e.preventDefault();
|
|
48
|
+
first.focus();
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
const dialog = (_jsx("div", { className: "sm-scrim", onClick: onClose, children: _jsxs("div", { className: cx("sm-dialog", className), role: role, "aria-modal": "true", "aria-labelledby": titleId, "aria-describedby": describedBy, ref: panel, onClick: (e) => e.stopPropagation(), onKeyDown: trap, children: [_jsx("div", { className: "sm-dialog__head", children: _jsxs("div", { className: "sm-dialog__titlerow", children: [_jsx("h2", { className: "sm-dialog__title", id: titleId, children: title }), _jsx("button", { type: "button", className: "sm-dialog__close", "aria-label": "Close", onClick: onClose, children: "\u00D7" })] }) }), children, _jsx("div", { className: "sm-dialog__actions", children: actions })] }) }));
|
|
52
|
+
const root = adminRoot();
|
|
53
|
+
return root ? createPortal(dialog, root) : dialog;
|
|
54
|
+
}
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { type ReactElement, type ReactNode } from "react";
|
|
2
2
|
import type { PageLocale } from "../../store.ts";
|
|
3
3
|
import type { VersionSummary } from "../ops.ts";
|
|
4
|
+
import { type ConfirmOptions } from "./confirm.ts";
|
|
4
5
|
export type HistoryTag = "Draft" | "Live" | null;
|
|
5
6
|
export type HistoryRow = VersionSummary & {
|
|
6
7
|
tag: HistoryTag;
|
|
7
8
|
};
|
|
8
|
-
export declare const RESTORE_CONFIRM
|
|
9
|
+
export declare const RESTORE_CONFIRM: ConfirmOptions;
|
|
9
10
|
/** The rows for a locale's versions: the unpublished draft is Draft, the
|
|
10
11
|
* row the site serves Live, an older publish carries nothing. */
|
|
11
12
|
export declare function historyRows(versions: VersionSummary[], row: Pick<PageLocale, "status" | "draftVersionId" | "publishedVersionId">): HistoryRow[];
|
|
@@ -9,8 +9,13 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
9
9
|
import { useEffect, useState } from "react";
|
|
10
10
|
import { versionTime } from "./format.js";
|
|
11
11
|
import { Modal } from "./Modal.js";
|
|
12
|
+
import { confirm } from "./confirm.js";
|
|
12
13
|
import { Button, cx } from "./primitives.js";
|
|
13
|
-
export const RESTORE_CONFIRM =
|
|
14
|
+
export const RESTORE_CONFIRM = {
|
|
15
|
+
title: "Restore this version?",
|
|
16
|
+
body: "The current draft is replaced by this version. Nothing goes live until you publish.",
|
|
17
|
+
confirmLabel: "Restore",
|
|
18
|
+
};
|
|
14
19
|
/** The rows for a locale's versions: the unpublished draft is Draft, the
|
|
15
20
|
* row the site serves Live, an older publish carries nothing. */
|
|
16
21
|
export function historyRows(versions, row) {
|
|
@@ -28,7 +33,7 @@ export function HistoryModal({ rows, draftId, render, onSelect, onRestore, onClo
|
|
|
28
33
|
useEffect(() => { if (selected)
|
|
29
34
|
onSelect?.(selected); }, [selected]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
30
35
|
const restore = async () => {
|
|
31
|
-
if (!selected || !
|
|
36
|
+
if (!selected || !(await confirm(RESTORE_CONFIRM)))
|
|
32
37
|
return;
|
|
33
38
|
setBusy(true);
|
|
34
39
|
const landed = await onRestore(selected);
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
// The lock after a list row's title (spec 2026-09-26 concurrency §5): someone else
|
|
4
|
+
// has this target open in this locale. A tooltip names them.
|
|
5
|
+
import { Lock } from "lucide-react";
|
|
6
|
+
export const lockLabel = (names) => names.length === 1 ? `${names[0]} is editing` : `${names.join(", ")} are editing`;
|
|
7
|
+
export function LockIcon({ names }) {
|
|
8
|
+
if (names.length === 0)
|
|
9
|
+
return null;
|
|
10
|
+
const label = lockLabel(names);
|
|
11
|
+
return (_jsx("span", { className: "sm-lock", role: "img", "aria-label": label, title: label, children: _jsx(Lock, { size: 14, strokeWidth: 1.5, "aria-hidden": "true" }) }));
|
|
12
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export type PresenceNoun = "page" | "entry" | "item";
|
|
2
|
+
export declare function presenceLineText(names: string[], noun: PresenceNoun): string;
|
|
3
|
+
export declare function PresenceLine({ names, noun }: {
|
|
4
|
+
names: string[];
|
|
5
|
+
noun: PresenceNoun;
|
|
6
|
+
}): import("react").JSX.Element | null;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
export function presenceLineText(names, noun) {
|
|
4
|
+
const who = names.length === 1 ? names[0] : `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`;
|
|
5
|
+
return `${who} ${names.length === 1 ? "is" : "are"} also editing this ${noun}.`;
|
|
6
|
+
}
|
|
7
|
+
export function PresenceLine({ names, noun }) {
|
|
8
|
+
if (names.length === 0)
|
|
9
|
+
return null;
|
|
10
|
+
return _jsx("p", { className: "sm-presence", role: "status", children: presenceLineText(names, noun) });
|
|
11
|
+
}
|
|
@@ -52,7 +52,7 @@ export function ResizeHandle() {
|
|
|
52
52
|
const { admin, grid } = parts(el);
|
|
53
53
|
if (!admin || !grid)
|
|
54
54
|
return;
|
|
55
|
-
const next = clampInspectorW(px, grid.clientWidth);
|
|
55
|
+
const next = clampInspectorW(px, grid.clientWidth, el.offsetWidth);
|
|
56
56
|
apply(admin, next);
|
|
57
57
|
remember(next);
|
|
58
58
|
setNow(next);
|
|
@@ -76,7 +76,7 @@ export function ResizeHandle() {
|
|
|
76
76
|
return;
|
|
77
77
|
e.preventDefault();
|
|
78
78
|
e.currentTarget.setPointerCapture(e.pointerId);
|
|
79
|
-
drag.current = { id: e.pointerId, startX: e.clientX, startW: aside.offsetWidth, rowW: grid.clientWidth, last: aside.offsetWidth };
|
|
79
|
+
drag.current = { id: e.pointerId, startX: e.clientX, startW: aside.offsetWidth, rowW: grid.clientWidth, handleW: e.currentTarget.offsetWidth, last: aside.offsetWidth };
|
|
80
80
|
admin.classList.add("sm-admin--resizing");
|
|
81
81
|
};
|
|
82
82
|
const onPointerMove = (e) => {
|
|
@@ -87,7 +87,7 @@ export function ResizeHandle() {
|
|
|
87
87
|
if (!admin)
|
|
88
88
|
return;
|
|
89
89
|
// The handle sits on the inspector's left edge: moving left widens it.
|
|
90
|
-
d.last = clampInspectorW(d.startW - (e.clientX - d.startX), d.rowW);
|
|
90
|
+
d.last = clampInspectorW(d.startW - (e.clientX - d.startX), d.rowW, d.handleW);
|
|
91
91
|
apply(admin, d.last);
|
|
92
92
|
};
|
|
93
93
|
// Also fires from releasePointerCapture below, when drag.current is already null.
|
|
@@ -116,7 +116,7 @@ export function ResizeHandle() {
|
|
|
116
116
|
else if (e.key === "Home")
|
|
117
117
|
next = MIN_INSPECTOR_W;
|
|
118
118
|
else if (e.key === "End")
|
|
119
|
-
next = maxInspectorW(grid.clientWidth);
|
|
119
|
+
next = maxInspectorW(grid.clientWidth, e.currentTarget.offsetWidth);
|
|
120
120
|
if (next === null)
|
|
121
121
|
return;
|
|
122
122
|
e.preventDefault();
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
// The freeze notice under the top bar (spec 2026-09-26 concurrency §4): the fact,
|
|
4
|
+
// what a reload costs, and the one button. The view puts everything below it inert.
|
|
5
|
+
import { LOAD_THEIRS, STALE_DETAIL, staleText } from "../shell/stale.js";
|
|
6
|
+
import { Banner, Button } from "./primitives.js";
|
|
7
|
+
export function StaleBanner({ info, onReload }) {
|
|
8
|
+
return (_jsx("div", { className: "sm-stale", "data-testid": "stale-banner", children: _jsxs(Banner, { tone: "error", children: [_jsx("p", { style: { margin: 0 }, children: staleText(info) }), _jsx("p", { style: { margin: "4px 0 10px", color: "inherit", opacity: 0.85 }, children: STALE_DETAIL }), _jsx(Button, { onClick: onReload, children: LOAD_THEIRS })] }) }));
|
|
9
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export type ConfirmSubject = {
|
|
2
|
+
/** What the action touches — a page title, an entry title, an email. Truncates. */
|
|
3
|
+
name: string;
|
|
4
|
+
/** Its URL when it has one; never truncates. */
|
|
5
|
+
path?: string | null;
|
|
6
|
+
};
|
|
7
|
+
export type ConfirmOptions = {
|
|
8
|
+
/** The question, as a question: "Delete this page?" */
|
|
9
|
+
title: string;
|
|
10
|
+
/** The consequence in one or two plain sentences. */
|
|
11
|
+
body?: string;
|
|
12
|
+
/** The action verb on the primary button: "Delete page", never "OK". */
|
|
13
|
+
confirmLabel: string;
|
|
14
|
+
/** "Cancel" unless the action needs another word. */
|
|
15
|
+
cancelLabel?: string;
|
|
16
|
+
/** A destructive action: red primary, and focus lands on Cancel. */
|
|
17
|
+
destructive?: boolean;
|
|
18
|
+
/** The context chip repeating the subject; omit when there is no single subject. */
|
|
19
|
+
subject?: ConfirmSubject;
|
|
20
|
+
};
|
|
21
|
+
export type ConfirmRequest = {
|
|
22
|
+
id: number;
|
|
23
|
+
options: ConfirmOptions;
|
|
24
|
+
answer(yes: boolean): void;
|
|
25
|
+
};
|
|
26
|
+
/** The Discard changes question, shared by the page editor and the entry and global forms (publish flow §4). */
|
|
27
|
+
export declare const DISCARD_CONFIRM: Omit<ConfirmOptions, "subject">;
|
|
28
|
+
/** Ask; resolves with the answer once the dialog closes. */
|
|
29
|
+
export declare function confirm(options: ConfirmOptions): Promise<boolean>;
|
|
30
|
+
/** The question being asked, if any — the head of the queue. */
|
|
31
|
+
export declare const pendingConfirm: () => ConfirmRequest | null;
|
|
32
|
+
/** For useSyncExternalStore: fires on every queue change. */
|
|
33
|
+
export declare function subscribeConfirm(fn: () => void): () => void;
|
|
34
|
+
/** Test seam: drop every pending question unanswered. */
|
|
35
|
+
export declare function resetConfirms(): void;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// The admin's one "are you sure" (design_handoff_smoodly_neutral/screens/
|
|
2
|
+
// ConfirmModal.md): a caller awaits `confirm(...)` and gets a boolean, the
|
|
3
|
+
// way it awaited window.confirm — but the question is answered in
|
|
4
|
+
// Smoodly's own dialog, rendered by <ConfirmHost/> (ConfirmDialog.tsx)
|
|
5
|
+
// from this store. Framework-free on purpose: the router (shell/router.ts)
|
|
6
|
+
// asks from outside React.
|
|
7
|
+
//
|
|
8
|
+
// One question at a time; a second request waits for the first answer.
|
|
9
|
+
// Without a mounted host (a test, a stray render outside the shell) the
|
|
10
|
+
// question falls back to window.confirm so no caller can hang.
|
|
11
|
+
/** The Discard changes question, shared by the page editor and the entry and global forms (publish flow §4). */
|
|
12
|
+
export const DISCARD_CONFIRM = {
|
|
13
|
+
title: "Discard unsaved changes?",
|
|
14
|
+
body: "The draft goes back to the published version. Every edit since the last publish is lost.",
|
|
15
|
+
confirmLabel: "Discard changes",
|
|
16
|
+
destructive: true,
|
|
17
|
+
};
|
|
18
|
+
const listeners = new Set();
|
|
19
|
+
let queue = [];
|
|
20
|
+
let seq = 0;
|
|
21
|
+
const notify = () => { for (const fn of listeners)
|
|
22
|
+
fn(); };
|
|
23
|
+
/** Ask; resolves with the answer once the dialog closes. */
|
|
24
|
+
export function confirm(options) {
|
|
25
|
+
if (listeners.size === 0) {
|
|
26
|
+
const text = [options.title, options.body].filter(Boolean).join("\n\n");
|
|
27
|
+
return Promise.resolve(typeof window !== "undefined" && typeof window.confirm === "function" ? window.confirm(text) : false);
|
|
28
|
+
}
|
|
29
|
+
return new Promise((resolve) => {
|
|
30
|
+
const id = ++seq;
|
|
31
|
+
const request = {
|
|
32
|
+
id,
|
|
33
|
+
options,
|
|
34
|
+
answer(yes) {
|
|
35
|
+
if (!queue.some((r) => r.id === id))
|
|
36
|
+
return; // answered already
|
|
37
|
+
queue = queue.filter((r) => r.id !== id);
|
|
38
|
+
notify();
|
|
39
|
+
resolve(yes);
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
queue = [...queue, request];
|
|
43
|
+
notify();
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
/** The question being asked, if any — the head of the queue. */
|
|
47
|
+
export const pendingConfirm = () => queue[0] ?? null;
|
|
48
|
+
/** For useSyncExternalStore: fires on every queue change. */
|
|
49
|
+
export function subscribeConfirm(fn) {
|
|
50
|
+
listeners.add(fn);
|
|
51
|
+
return () => { listeners.delete(fn); };
|
|
52
|
+
}
|
|
53
|
+
/** Test seam: drop every pending question unanswered. */
|
|
54
|
+
export function resetConfirms() {
|
|
55
|
+
queue = [];
|
|
56
|
+
notify();
|
|
57
|
+
}
|
|
@@ -13,13 +13,16 @@ export declare function Logo({ height }: {
|
|
|
13
13
|
* is right on the very first paint with no state and no hydration mismatch. */
|
|
14
14
|
export declare function ThemeToggle(): import("react").JSX.Element;
|
|
15
15
|
type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
|
|
16
|
-
|
|
16
|
+
/** `danger` is the quiet red-text button; `destructive` the filled red primary of a confirm dialog. */
|
|
17
|
+
variant?: "primary" | "secondary" | "ghost" | "danger" | "destructive" | "ok";
|
|
17
18
|
size?: "sm" | "md" | "lg";
|
|
18
19
|
};
|
|
19
20
|
export declare function Button({ variant, size, className, ...rest }: ButtonProps): import("react").JSX.Element;
|
|
20
21
|
/** The same shape as Button, for navigations that must stay real links. */
|
|
21
|
-
export declare function LinkButton({ variant, children, ...rest }: {
|
|
22
|
+
export declare function LinkButton({ variant, icon, children, ...rest }: {
|
|
22
23
|
variant?: ButtonProps["variant"];
|
|
24
|
+
/** An icon-only pill (a square the button's height); give it an aria-label. */
|
|
25
|
+
icon?: boolean;
|
|
23
26
|
children: ReactNode;
|
|
24
27
|
} & AnchorHTMLAttributes<HTMLAnchorElement>): import("react").JSX.Element;
|
|
25
28
|
export declare function IconButton({ label, tone, size, children, ...rest }: {
|
|
@@ -102,6 +105,17 @@ export declare function Segmented<T extends string>({ options, value, onChange,
|
|
|
102
105
|
className?: string;
|
|
103
106
|
} | undefined;
|
|
104
107
|
}): import("react").JSX.Element;
|
|
108
|
+
/** A pill row (the f.radio look, spec 2026-09-14 §5): no track, the chosen pill filled in the
|
|
109
|
+
* soft accent. For a small choice inside a table row, where a segmented control reads as tabs. */
|
|
110
|
+
export declare function Pills<T extends string>({ options, value, onChange, disabled, title, label }: {
|
|
111
|
+
options: readonly T[];
|
|
112
|
+
value: T;
|
|
113
|
+
onChange(v: T): void;
|
|
114
|
+
disabled?: boolean;
|
|
115
|
+
/** Why the row is off, on every pill. */
|
|
116
|
+
title?: string;
|
|
117
|
+
label?(o: T): ReactNode;
|
|
118
|
+
}): import("react").JSX.Element;
|
|
105
119
|
export declare function StatusBadge({ status }: {
|
|
106
120
|
status: string;
|
|
107
121
|
}): import("react").JSX.Element;
|
|
@@ -109,10 +123,11 @@ export declare function StatusBadge({ status }: {
|
|
|
109
123
|
export declare function SaveIndicator({ state }: {
|
|
110
124
|
state: SaveState;
|
|
111
125
|
}): import("react").JSX.Element;
|
|
112
|
-
|
|
126
|
+
/** `caps`: the overline style — small, uppercase, tracked (2026-09-26; the mono face before). */
|
|
127
|
+
export declare function SectionLabel({ children, action, caps, style }: {
|
|
113
128
|
children: ReactNode;
|
|
114
129
|
action?: ReactNode;
|
|
115
|
-
|
|
130
|
+
caps?: boolean;
|
|
116
131
|
style?: CSSProperties;
|
|
117
132
|
}): import("react").JSX.Element;
|
|
118
133
|
export declare function Divider({ bleed }: {
|
|
@@ -45,8 +45,8 @@ export function Button({ variant = "primary", size = "md", className, ...rest })
|
|
|
45
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
|
-
export function LinkButton({ variant = "secondary", children, ...rest }) {
|
|
49
|
-
return _jsx("a", { className: cx("sm-btn", `sm-btn--${variant}
|
|
48
|
+
export function LinkButton({ variant = "secondary", icon = false, children, ...rest }) {
|
|
49
|
+
return _jsx("a", { className: cx("sm-btn", `sm-btn--${variant}`, icon && "sm-btn--icon"), ...rest, children: children });
|
|
50
50
|
}
|
|
51
51
|
export function IconButton({ label, tone = "default", size = 24, children, ...rest }) {
|
|
52
52
|
return (_jsx("button", { type: "button", "aria-label": label, title: label, className: cx("sm-iconbtn", tone === "inverse" && "sm-iconbtn--inverse"), style: { width: size, height: size }, ...rest, children: children }));
|
|
@@ -105,6 +105,11 @@ export function Segmented({ options, value, onChange, stretch, disabled, label,
|
|
|
105
105
|
return (_jsx("button", { type: "button", className: cx("sm-seg__opt", extra?.className), "aria-pressed": o === value, disabled: disabled || extra?.disabled, title: extra?.title, onClick: () => onChange(o), children: label ? label(o) : o }, o));
|
|
106
106
|
}) }));
|
|
107
107
|
}
|
|
108
|
+
/** A pill row (the f.radio look, spec 2026-09-14 §5): no track, the chosen pill filled in the
|
|
109
|
+
* soft accent. For a small choice inside a table row, where a segmented control reads as tabs. */
|
|
110
|
+
export function Pills({ options, value, onChange, disabled, title, label }) {
|
|
111
|
+
return (_jsx("div", { className: cx("sm-pills", disabled && "sm-pills--disabled"), role: "radiogroup", children: options.map((o) => (_jsx("button", { type: "button", className: "sm-pill", "aria-pressed": o === value, disabled: disabled, title: title, onClick: () => onChange(o), children: label ? label(o) : o }, o))) }));
|
|
112
|
+
}
|
|
108
113
|
/* ── data ────────────────────────────────────────────────────────────── */
|
|
109
114
|
// Keyed by the product labels of ui/status-label.ts (lowercased by StatusBadge).
|
|
110
115
|
const STATUS_TONE = {
|
|
@@ -124,12 +129,14 @@ export function SaveIndicator({ state }) {
|
|
|
124
129
|
saved: { label: "Saved", dot: "var(--status-saved-dot)", color: "var(--text-muted)" },
|
|
125
130
|
saving: { label: "Saving", dot: "var(--warn)", color: "var(--text-muted)" },
|
|
126
131
|
error: { label: "Save failed — retrying", dot: "var(--danger)", color: "var(--danger)" },
|
|
132
|
+
stale: { label: "Not saved — newer version exists", dot: "var(--danger)", color: "var(--danger)" },
|
|
127
133
|
};
|
|
128
134
|
const { label, dot, color } = map[state];
|
|
129
|
-
return (_jsxs("span", { className: "sm-savestate", style: { color }, title: label, children: [_jsx("span", { className: "sm-dot", style: { background: dot } }), label] }));
|
|
135
|
+
return (_jsxs("span", { className: "sm-savestate", "data-testid": "save-status", style: { color }, title: label, children: [_jsx("span", { className: "sm-dot", style: { background: dot } }), label] }));
|
|
130
136
|
}
|
|
131
|
-
|
|
132
|
-
|
|
137
|
+
/** `caps`: the overline style — small, uppercase, tracked (2026-09-26; the mono face before). */
|
|
138
|
+
export function SectionLabel({ children, action, caps, style }) {
|
|
139
|
+
return (_jsxs("div", { className: cx("sm-sectionlabel", caps && "sm-sectionlabel--caps"), style: style, children: [_jsx("span", { className: "sm-label", children: children }), action && _jsx("span", { style: { color: "var(--accent)", fontWeight: 600, fontSize: "var(--text-sm)" }, children: action })] }));
|
|
133
140
|
}
|
|
134
141
|
export function Divider({ bleed = 0 }) {
|
|
135
142
|
return _jsx("div", { className: "sm-divider", style: { margin: `0 -${bleed}px` } });
|
|
@@ -6,10 +6,13 @@ export declare const MIN_INSPECTOR_W = 260;
|
|
|
6
6
|
export declare const DEFAULT_INSPECTOR_W = 360;
|
|
7
7
|
/** One arrow-key press. */
|
|
8
8
|
export declare const RESIZE_STEP = 16;
|
|
9
|
-
/**
|
|
10
|
-
export declare const
|
|
11
|
-
/**
|
|
12
|
-
|
|
9
|
+
/** What the canvas keeps however far the inspector is dragged (2026-09-26, up from half the row; 340 for an hour). */
|
|
10
|
+
export declare const MIN_CANVAS_W = 440;
|
|
11
|
+
/** The editor row (canvas + handle + inspector) less the handle and the
|
|
12
|
+
* canvas minimum, never below the inspector minimum. */
|
|
13
|
+
export declare const maxInspectorW: (rowWidth: number, handleWidth?: number) => number;
|
|
14
|
+
/** An integer inside [MIN, the row less the handle and the canvas minimum]. */
|
|
15
|
+
export declare const clampInspectorW: (px: number, rowWidth: number, handleWidth?: number) => number;
|
|
13
16
|
/** A stored value is trusted only as a plain integer ≥ MIN; anything else is
|
|
14
17
|
* ignored (never clamped or rewritten). The boot script in theme.tsx repeats
|
|
15
18
|
* this test inline — keep the two in step. */
|
package/dist/admin/ui/resize.js
CHANGED
|
@@ -8,10 +8,13 @@ export const MIN_INSPECTOR_W = 260;
|
|
|
8
8
|
export const DEFAULT_INSPECTOR_W = 360;
|
|
9
9
|
/** One arrow-key press. */
|
|
10
10
|
export const RESIZE_STEP = 16;
|
|
11
|
-
/**
|
|
12
|
-
export const
|
|
13
|
-
/**
|
|
14
|
-
|
|
11
|
+
/** What the canvas keeps however far the inspector is dragged (2026-09-26, up from half the row; 340 for an hour). */
|
|
12
|
+
export const MIN_CANVAS_W = 440;
|
|
13
|
+
/** The editor row (canvas + handle + inspector) less the handle and the
|
|
14
|
+
* canvas minimum, never below the inspector minimum. */
|
|
15
|
+
export const maxInspectorW = (rowWidth, handleWidth = 0) => Math.max(MIN_INSPECTOR_W, Math.floor(rowWidth - handleWidth - MIN_CANVAS_W));
|
|
16
|
+
/** An integer inside [MIN, the row less the handle and the canvas minimum]. */
|
|
17
|
+
export const clampInspectorW = (px, rowWidth, handleWidth = 0) => Math.min(maxInspectorW(rowWidth, handleWidth), Math.max(MIN_INSPECTOR_W, Math.round(px)));
|
|
15
18
|
/** A stored value is trusted only as a plain integer ≥ MIN; anything else is
|
|
16
19
|
* ignored (never clamped or rewritten). The boot script in theme.tsx repeats
|
|
17
20
|
* this test inline — keep the two in step. */
|
package/dist/admin/ui/theme.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export declare const ADMIN_CSS: string;
|
|
2
2
|
/** Where the theme toggle's choice is remembered, browser-side only. */
|
|
3
3
|
export declare const THEME_KEY = "smoodly-theme";
|
|
4
|
-
/**
|
|
4
|
+
/** Which look the developer is comparing (see the warm block): "warm" applies it, anything else is the default. */
|
|
5
5
|
export declare const LOOK_KEY = "smoodly-look";
|
|
6
6
|
/** One <style> element and the theme boot, rendered once by AdminLayout. */
|
|
7
7
|
export declare function AdminStyles(): import("react").JSX.Element;
|