smoodly 0.0.24 → 0.0.25
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 +182 -40
- package/bin/smoodly.js +6 -0
- package/dist/admin/client-ops.js +1 -0
- package/dist/admin/editor/EditorView.js +20 -6
- 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/locale-url.d.ts +7 -4
- package/dist/admin/editor/locale-url.js +14 -7
- 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/ops-impl.js +4 -0
- package/dist/admin/ops.d.ts +9 -0
- package/dist/admin/serialize.d.ts +2 -0
- package/dist/admin/serialize.js +1 -0
- package/dist/admin/shell/AdminApp.js +23 -1
- package/dist/admin/shell/EntriesList.js +12 -21
- package/dist/admin/shell/EntryForm.js +48 -7
- package/dist/admin/shell/GlobalForm.js +22 -2
- package/dist/admin/shell/GlobalList.js +4 -18
- 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 +96 -73
- 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/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/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/ResizeHandle.js +4 -4
- 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 +11 -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 +368 -203
- 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/fields.d.ts +2 -0
- package/dist/fields.js +2 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- 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 +6 -0
- package/dist/sql.js +10 -0
- package/dist/store.d.ts +3 -0
- package/dist/store.js +4 -0
- package/dist/supabase-store.d.ts +3 -0
- package/dist/supabase-store.js +23 -0
- package/dist/time-zone.d.ts +6 -0
- package/dist/time-zone.js +27 -0
- package/migrations/20260926120000_smoodly_v1.sql +535 -0
- package/package.json +13 -3
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
// The site's time zone from the registry (spec 2026-09-25 date pickers
|
|
4
|
+
// §5), for the datetime widget. Undefined = none configured: the widget
|
|
5
|
+
// falls back to the browser's zone and says so. Defaults to undefined so
|
|
6
|
+
// a form rendered without the shell (tests, a bare page) still works.
|
|
7
|
+
import { createContext, useContext } from "react";
|
|
8
|
+
const Ctx = createContext(undefined);
|
|
9
|
+
export function TimeZoneProvider({ zone, children }) {
|
|
10
|
+
return _jsx(Ctx.Provider, { value: zone, children: children });
|
|
11
|
+
}
|
|
12
|
+
/** The configured zone, or undefined. */
|
|
13
|
+
export function useTimeZone() {
|
|
14
|
+
return useContext(Ctx);
|
|
15
|
+
}
|
|
@@ -28,7 +28,16 @@ const EMPTY = { type: "doc", content: [{ type: "paragraph" }] };
|
|
|
28
28
|
const LEVELS = [1, 2, 3, 4, 5, 6];
|
|
29
29
|
const isDoc = (v) => !!v && typeof v === "object" && v.type === "doc";
|
|
30
30
|
export function useRichtextEditor({ value, toolset, onChange, contentClass, onEscape }) {
|
|
31
|
-
|
|
31
|
+
// Keyed by content, not identity: the registry crosses the server → client
|
|
32
|
+
// boundary and is re-serialized whenever the route's server tree renders
|
|
33
|
+
// again — every autosave does that, since the op is a Server Action that
|
|
34
|
+
// expires the page's cache tag — so `toolset` arrives as a new object with
|
|
35
|
+
// the same content. On identity TipTap rebuilt the editor after each save,
|
|
36
|
+
// dropping its undo history and the focus (found 2026-09-26: cmd-Z did
|
|
37
|
+
// nothing once the first autosave had landed). The elements list is made
|
|
38
|
+
// stable the same way in ElementsProvider.
|
|
39
|
+
const toolsetKey = JSON.stringify(toolset ?? null);
|
|
40
|
+
const allowed = useMemo(() => allowedOf(toolset), [toolsetKey]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
32
41
|
const elements = useElements();
|
|
33
42
|
const assets = useAssets();
|
|
34
43
|
const items = useMemo(() => insertItems(allowed, elements), [allowed, elements]);
|
|
@@ -1 +1,2 @@
|
|
|
1
|
-
|
|
1
|
+
import { type ReactElement } from "react";
|
|
2
|
+
export declare function EditorBridge(): ReactElement;
|
|
@@ -1,16 +1,32 @@
|
|
|
1
1
|
"use client";
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
3
|
// Runs INSIDE the draft-route iframe (glue adds it only when draft mode +
|
|
3
4
|
// the editor param are on). Reports geometry — section nodes, empty zones
|
|
4
5
|
// and, since spec 2026-09-10, every marked field with its resolved path —
|
|
5
6
|
// forwards select/hover with the field under the pointer, executes
|
|
6
7
|
// refresh/scroll, and says when a render has committed. The admin overlay
|
|
7
|
-
// draws all chrome
|
|
8
|
+
// draws all chrome — bar one rule this component renders: a section that
|
|
9
|
+
// renders nothing (a fresh custom section, an all-optional section left
|
|
10
|
+
// empty) is stamped blank and takes a min-height, so the editor can see
|
|
11
|
+
// and select it; the live site never mounts the bridge, so it keeps its
|
|
12
|
+
// true height. Rects are in document coordinates: the iframe is sized
|
|
8
13
|
// to the full document height by the editor, so client rects + scroll
|
|
9
14
|
// offsets map 1:1 onto the overlay.
|
|
10
15
|
import { useEffect } from "react";
|
|
11
16
|
import { useRouter } from "next/navigation";
|
|
12
|
-
import { EDITOR_ATTR, FIELD_ATTR, NODE_ATTR, ZONE_ATTR, resolveMarkPath } from "../../marks.js";
|
|
17
|
+
import { BLANK_ATTR, EDITOR_ATTR, FIELD_ATTR, NODE_ATTR, ZONE_ATTR, resolveMarkPath } from "../../marks.js";
|
|
13
18
|
const post = (m) => window.parent.postMessage(m, "*");
|
|
19
|
+
/** The height of an md-spaced section with nothing inside (3.5rem + 3.5rem). */
|
|
20
|
+
// The editor sizes the iframe to the reported height; the document's own
|
|
21
|
+
// viewport must never scroll (2026-09-26): a fractional content height
|
|
22
|
+
// (4595.3px in a 4595px frame) left it scrollable by a hair, and a
|
|
23
|
+
// trackpad scroll over the canvas showed the frame's overlay scrollbar.
|
|
24
|
+
// Hidden overflow drops no layout — programmatic scrollIntoView still
|
|
25
|
+
// works, and the live site never mounts this.
|
|
26
|
+
const BRIDGE_CSS = `html{overflow:hidden}[${NODE_ATTR}][${BLANK_ATTR}]{min-height:7rem}`;
|
|
27
|
+
const MEDIA = "img,picture,video,audio,svg,canvas,iframe,object";
|
|
28
|
+
/** No visible text (innerText skips <style> and hidden content) and no media. */
|
|
29
|
+
const isBlank = (el) => el.innerText.trim() === "" && el.querySelector(MEDIA) === null;
|
|
14
30
|
export function EditorBridge() {
|
|
15
31
|
const router = useRouter();
|
|
16
32
|
// Every commit of this component — the mount, then each RSC refresh,
|
|
@@ -28,7 +44,19 @@ export function EditorBridge() {
|
|
|
28
44
|
const report = () => {
|
|
29
45
|
// Array.from, not spread: the package's lib set is ES2020 + DOM
|
|
30
46
|
// (no DOM.Iterable), so a NodeList isn't statically iterable here.
|
|
31
|
-
const
|
|
47
|
+
const roots = all(`[${NODE_ATTR}]`);
|
|
48
|
+
// Stamp before measuring, so the min-height is in the rects; only on a
|
|
49
|
+
// change, so the attribute mutation this causes converges instead of looping.
|
|
50
|
+
for (const el of roots) {
|
|
51
|
+
const blank = isBlank(el);
|
|
52
|
+
if (blank !== el.hasAttribute(BLANK_ATTR)) {
|
|
53
|
+
if (blank)
|
|
54
|
+
el.setAttribute(BLANK_ATTR, "");
|
|
55
|
+
else
|
|
56
|
+
el.removeAttribute(BLANK_ATTR);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const nodes = roots.map((el) => ({ id: el.getAttribute(NODE_ATTR), ...docRect(el) }));
|
|
32
60
|
const emptyZones = all(`[${ZONE_ATTR}]`).map((el) => ({ zone: el.getAttribute(ZONE_ATTR), ...docRect(el) }));
|
|
33
61
|
const fields = [];
|
|
34
62
|
for (const el of all(`[${FIELD_ATTR}]`)) {
|
|
@@ -39,7 +67,9 @@ export function EditorBridge() {
|
|
|
39
67
|
if (mark)
|
|
40
68
|
fields.push({ ...mark, ...docRect(el) });
|
|
41
69
|
}
|
|
42
|
-
|
|
70
|
+
// Rounded up from the fractional box, so the frame is never a hair too short.
|
|
71
|
+
const docH = Math.ceil(Math.max(document.documentElement.scrollHeight, document.documentElement.getBoundingClientRect().height));
|
|
72
|
+
post({ source: "smoodly", kind: "rects", nodes, emptyZones, fields, docH });
|
|
43
73
|
};
|
|
44
74
|
const raf = () => requestAnimationFrame(report);
|
|
45
75
|
const ro = new ResizeObserver(raf);
|
|
@@ -93,5 +123,5 @@ export function EditorBridge() {
|
|
|
93
123
|
window.removeEventListener("message", onMessage);
|
|
94
124
|
};
|
|
95
125
|
}, [router]);
|
|
96
|
-
return
|
|
126
|
+
return _jsx("style", { "data-smoodly-bridge": "", children: BRIDGE_CSS });
|
|
97
127
|
}
|
package/dist/admin/ops-impl.js
CHANGED
|
@@ -11,6 +11,7 @@ import { CUSTOM_NODE_TYPE, customSchema } from "../custom/index.js";
|
|
|
11
11
|
import { SETTINGS_KEY, CSS_KEY } from "../global.js";
|
|
12
12
|
import { withDefaults } from "../field-defaults.js";
|
|
13
13
|
import { elementSchemas } from "../richtext-walk.js";
|
|
14
|
+
import { SCHEMA_VERSION } from "../schema-version.js";
|
|
14
15
|
const ok = (data) => ({ ok: true, data });
|
|
15
16
|
/** A history row without its content (spec 2026-09-13 history §6). */
|
|
16
17
|
const summary = ({ id, savedAt }) => ({ id, savedAt });
|
|
@@ -926,6 +927,9 @@ export function createAdminOps(deps) {
|
|
|
926
927
|
return ok(null);
|
|
927
928
|
}),
|
|
928
929
|
},
|
|
930
|
+
schema: {
|
|
931
|
+
status: () => guard(async () => ok({ database: await pages.schemaVersion(), required: SCHEMA_VERSION })),
|
|
932
|
+
},
|
|
929
933
|
preview: {
|
|
930
934
|
enable: () => guard(async () => (await effects?.draft?.enable(), ok(null))),
|
|
931
935
|
disable: () => guard(async () => (await effects?.draft?.disable(), ok(null))),
|
package/dist/admin/ops.d.ts
CHANGED
|
@@ -98,6 +98,11 @@ export type MembersListResult = {
|
|
|
98
98
|
members: MemberRecord[];
|
|
99
99
|
consoleUrl: string | null;
|
|
100
100
|
};
|
|
101
|
+
/** `database` null = no smoodly_meta table (older than v1). Behind blocks the shell; ahead is silent (spec 2026-09-26 §5). */
|
|
102
|
+
export type SchemaStatus = {
|
|
103
|
+
database: number | null;
|
|
104
|
+
required: number;
|
|
105
|
+
};
|
|
101
106
|
export type AdminOps = {
|
|
102
107
|
pages: {
|
|
103
108
|
/** Lists every node, materializing fixed pages and collection mounts (in every supported locale) first. */
|
|
@@ -210,6 +215,10 @@ export type AdminOps = {
|
|
|
210
215
|
/** Admins only, self-host only; the same two guards. The Auth account stays. */
|
|
211
216
|
remove(userId: string): Promise<OpResult<null>>;
|
|
212
217
|
};
|
|
218
|
+
schema: {
|
|
219
|
+
/** Every member. The shell calls it once on mount and blocks when behind. */
|
|
220
|
+
status(): Promise<OpResult<SchemaStatus>>;
|
|
221
|
+
};
|
|
213
222
|
preview: {
|
|
214
223
|
enable(): Promise<OpResult<null>>;
|
|
215
224
|
disable(): Promise<OpResult<null>>;
|
|
@@ -74,6 +74,8 @@ export type AdminRegistry = {
|
|
|
74
74
|
default: string;
|
|
75
75
|
supported: string[];
|
|
76
76
|
};
|
|
77
|
+
/** The site's time zone when configured (spec 2026-09-25 date pickers §5); the datetime widget edits in it. */
|
|
78
|
+
timeZone?: string;
|
|
77
79
|
/** The project's custom-CSS flag (spec 2026-09-14 §4). */
|
|
78
80
|
css: boolean;
|
|
79
81
|
/** Which slug the site root serves. */
|
package/dist/admin/serialize.js
CHANGED
|
@@ -104,6 +104,7 @@ export function serializeAdminConfig(config) {
|
|
|
104
104
|
},
|
|
105
105
|
css: config.css,
|
|
106
106
|
locales: config.locales,
|
|
107
|
+
...(config.timeZone !== undefined ? { timeZone: config.timeZone } : {}),
|
|
107
108
|
homePageSlug: config.homePageSlug,
|
|
108
109
|
pageDepth: config.pageDepth,
|
|
109
110
|
media: { maxSize: config.media.maxSize, accept: [...config.media.accept] },
|
|
@@ -24,6 +24,7 @@ import { LoginView } from "./LoginView.js";
|
|
|
24
24
|
import { SetPasswordView } from "./SetPasswordView.js";
|
|
25
25
|
import { useAdminSession } from "./session.js";
|
|
26
26
|
import { IconRail } from "./IconRail.js";
|
|
27
|
+
import { ConfirmHost } from "../ui/ConfirmDialog.js";
|
|
27
28
|
import { useRoute } from "./router.js";
|
|
28
29
|
import { AdminPathProvider } from "./base.js";
|
|
29
30
|
import { mountMismatch } from "../../admin-path.js";
|
|
@@ -33,6 +34,9 @@ import { MediaScreen } from "./MediaScreen.js";
|
|
|
33
34
|
import { SettingsView } from "./SettingsView.js";
|
|
34
35
|
import { AssetsProvider } from "../forms/assets-context.js";
|
|
35
36
|
import { ElementsProvider } from "../forms/elements-context.js";
|
|
37
|
+
import { TimeZoneProvider } from "../forms/time-zone-context.js";
|
|
38
|
+
import { SchemaNotice } from "./SchemaNotice.js";
|
|
39
|
+
import { schemaBehind } from "../../schema-version.js";
|
|
36
40
|
export function AdminApp({ registry, op, auth }) {
|
|
37
41
|
const session = useAdminSession(auth);
|
|
38
42
|
const [ops] = useState(() => makeClientOps(op, session.token));
|
|
@@ -57,6 +61,22 @@ export function AdminApp({ registry, op, auth }) {
|
|
|
57
61
|
setRole("editor"); });
|
|
58
62
|
return () => { live = false; };
|
|
59
63
|
}, [signedIn, ops]);
|
|
64
|
+
// The one version check (spec 2026-09-26 §5): behind blocks the shell,
|
|
65
|
+
// equal or ahead renders as usual. Unknown until it answers; a failed
|
|
66
|
+
// call renders as usual too — the notice is for a known-behind schema.
|
|
67
|
+
const [schema, setSchema] = useState(null);
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
if (!signedIn) {
|
|
70
|
+
setSchema(null);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
let live = true;
|
|
74
|
+
ops.schema.status()
|
|
75
|
+
.then((r) => { if (live && r.ok)
|
|
76
|
+
setSchema(r.data); })
|
|
77
|
+
.catch(() => { });
|
|
78
|
+
return () => { live = false; };
|
|
79
|
+
}, [signedIn, ops]);
|
|
60
80
|
const userId = session.state.status === "signed_in" ? session.state.session.user.id : "";
|
|
61
81
|
if (session.state.status === "loading") {
|
|
62
82
|
return (_jsx("div", { style: { height: "100vh", display: "grid", placeItems: "center", color: "var(--text-muted)" }, children: "Loading\u2026" }));
|
|
@@ -70,6 +90,8 @@ export function AdminApp({ registry, op, auth }) {
|
|
|
70
90
|
// An invite or reset link signed the person in: Set password before the shell (members management §6).
|
|
71
91
|
if (session.arrival)
|
|
72
92
|
return _jsx(SetPasswordView, { client: session.client, mode: session.arrival, onDone: session.clearArrival });
|
|
93
|
+
if (schema && schemaBehind(schema.database, schema.required))
|
|
94
|
+
return _jsx(SchemaNotice, { status: schema });
|
|
73
95
|
let main;
|
|
74
96
|
if (head === "pages" && !tail)
|
|
75
97
|
main = _jsx(PagesList, { ops: ops, registry: registry });
|
|
@@ -89,5 +111,5 @@ export function AdminApp({ registry, op, auth }) {
|
|
|
89
111
|
main = _jsx(MediaScreen, {});
|
|
90
112
|
else
|
|
91
113
|
main = _jsx(ErrorPane, { message: "Not found." });
|
|
92
|
-
return (_jsx(AdminPathProvider, { value: registry.adminPath, children: _jsx(ElementsProvider, { elements: registry.elements, children: _jsx(AssetsProvider, { ops: ops, media: registry.media, children: _jsxs("div", { className: "sm-shell", children: [_jsx(IconRail, { registry: registry, active: head, email: email, settings: role !== null && can(role, "settings"), onSignOut: () => session.client.auth.signOut() }), _jsx("main", { className: "sm-main", children: main }, `${head}/${tail ?? ""}`)] }) }) }) }));
|
|
114
|
+
return (_jsx(AdminPathProvider, { value: registry.adminPath, children: _jsx(ElementsProvider, { elements: registry.elements, children: _jsx(TimeZoneProvider, { zone: registry.timeZone, children: _jsxs(AssetsProvider, { ops: ops, media: registry.media, children: [_jsxs("div", { className: "sm-shell", children: [_jsx(IconRail, { registry: registry, active: head, email: email, settings: role !== null && can(role, "settings"), onSignOut: () => session.client.auth.signOut() }), _jsx("main", { className: "sm-main", children: main }, `${head}/${tail ?? ""}`)] }), _jsx(ConfirmHost, {})] }) }) }) }));
|
|
93
115
|
}
|
|
@@ -14,34 +14,19 @@ import { relativeTime } from "../ui/format.js";
|
|
|
14
14
|
import { useSortable } from "../ui/useSortable.js";
|
|
15
15
|
import { useAdminHref } from "./base.js";
|
|
16
16
|
import { navigate } from "./router.js";
|
|
17
|
+
import { confirm } from "../ui/confirm.js";
|
|
17
18
|
import { ErrorPane } from "./ErrorPane.js";
|
|
18
|
-
|
|
19
|
-
/** The selected locale survives a reload within the session; a stored
|
|
20
|
-
* value that is no longer supported falls back to the default. */
|
|
21
|
-
function rememberedLocale(registry) {
|
|
22
|
-
try {
|
|
23
|
-
const stored = window.sessionStorage.getItem(LOCALE_KEY);
|
|
24
|
-
if (stored && registry.locales.supported.includes(stored))
|
|
25
|
-
return stored;
|
|
26
|
-
}
|
|
27
|
-
catch {
|
|
28
|
-
// storage unavailable — the default is fine
|
|
29
|
-
}
|
|
30
|
-
return registry.locales.default;
|
|
31
|
-
}
|
|
19
|
+
import { rememberLocale, rememberedLocale } from "./locale-memory.js";
|
|
32
20
|
export function EntriesList({ ops, registry, collection }) {
|
|
33
21
|
const adminHref = useAdminHref();
|
|
34
22
|
const meta = registry.collections.find((c) => c.name === collection);
|
|
35
23
|
const [records, setRecords] = useState(null);
|
|
36
24
|
const [error, setError] = useState(null);
|
|
37
|
-
const [locale, setLocale] = useState(() => rememberedLocale(registry));
|
|
25
|
+
const [locale, setLocale] = useState(() => rememberedLocale(registry.locales));
|
|
38
26
|
const multilingual = registry.locales.supported.length > 1;
|
|
39
27
|
const pickLocale = (next) => {
|
|
40
28
|
setLocale(next);
|
|
41
|
-
|
|
42
|
-
window.sessionStorage.setItem(LOCALE_KEY, next);
|
|
43
|
-
}
|
|
44
|
-
catch { /* see rememberedLocale */ }
|
|
29
|
+
rememberLocale(next);
|
|
45
30
|
};
|
|
46
31
|
const load = useCallback(() => ops.entries.list(collection)
|
|
47
32
|
.then((r) => (r.ok ? setRecords(r.data) : setError(r.message)))
|
|
@@ -75,7 +60,7 @@ export function EntriesList({ ops, registry, collection }) {
|
|
|
75
60
|
if (error)
|
|
76
61
|
return _jsx(ErrorPane, { message: error });
|
|
77
62
|
const open = (id) => {
|
|
78
|
-
navigate(adminHref(`/${collection}/${encodeURIComponent(id)}?locale=${encodeURIComponent(locale)}`));
|
|
63
|
+
void navigate(adminHref(`/${collection}/${encodeURIComponent(id)}?locale=${encodeURIComponent(locale)}`));
|
|
79
64
|
};
|
|
80
65
|
/** "+ New entry" creates an empty draft in the locale on screen and opens it (spec 2026-09-14 §4). */
|
|
81
66
|
const createEntry = async () => {
|
|
@@ -97,7 +82,13 @@ export function EntriesList({ ops, registry, collection }) {
|
|
|
97
82
|
if (!record)
|
|
98
83
|
return;
|
|
99
84
|
const from = entrySourceLocale(record, registry);
|
|
100
|
-
|
|
85
|
+
const ok = await confirm({
|
|
86
|
+
title: `Add this entry to ${locale}?`,
|
|
87
|
+
body: `Its ${from} fields are copied as the first ${locale} draft. Nothing is published until you publish it.`,
|
|
88
|
+
confirmLabel: `Add to ${locale}`,
|
|
89
|
+
subject: { name: row.title },
|
|
90
|
+
});
|
|
91
|
+
if (!ok)
|
|
101
92
|
return;
|
|
102
93
|
let result;
|
|
103
94
|
try {
|
|
@@ -15,7 +15,10 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
|
|
|
15
15
|
// publish control. A draft may be incomplete; publish validates it and
|
|
16
16
|
// lists the fields in an amber banner. The safe-delete guard's message
|
|
17
17
|
// renders verbatim — it is product surface.
|
|
18
|
+
import { Eye } from "lucide-react";
|
|
18
19
|
import { useEffect, useMemo, useRef, useState } from "react";
|
|
20
|
+
import { stillFailing, validateFields } from "../validate.js";
|
|
21
|
+
import { elementSchemas } from "../../richtext-walk.js";
|
|
19
22
|
import { FieldWidget } from "../forms/FieldWidget.js";
|
|
20
23
|
import { revealField } from "../forms/reveal.js";
|
|
21
24
|
import { useRefOptions } from "../forms/useRefOptions.js";
|
|
@@ -32,7 +35,8 @@ import { entrySourceLocale, entryTitle, localizedFieldKeys } from "./entries-lis
|
|
|
32
35
|
import { useAdminHref } from "./base.js";
|
|
33
36
|
import { localeFromUrl, writeLocaleToUrl } from "../editor/locale-url.js";
|
|
34
37
|
import { navigate, useLeaveGuard } from "./router.js";
|
|
35
|
-
import {
|
|
38
|
+
import { LIVE_URL_CONFIRM, liveUrlChanges } from "./live-url.js";
|
|
39
|
+
import { confirm, DISCARD_CONFIRM } from "../ui/confirm.js";
|
|
36
40
|
/** A stable empty map for the hooks that run before the "no such collection" return. */
|
|
37
41
|
const NO_FIELDS = {};
|
|
38
42
|
export function EntryForm({ ops, registry, collection, id }) {
|
|
@@ -73,7 +77,9 @@ export function EntryForm({ ops, registry, collection, id }) {
|
|
|
73
77
|
const stored = row?.slug ?? null;
|
|
74
78
|
const next = typeof value.slug === "string" ? value.slug : null;
|
|
75
79
|
if (next !== confirmedSlug.current && liveUrlChanges({ status: row?.status, from: stored, to: next })) {
|
|
76
|
-
|
|
80
|
+
const current = recordRef.current;
|
|
81
|
+
const subject = current && meta ? { name: entryTitle(current, meta, registry, locale), path: slugPrefix && stored ? `${slugPrefix}${stored}` : null } : undefined;
|
|
82
|
+
if (await confirm({ ...LIVE_URL_CONFIRM, subject }))
|
|
77
83
|
confirmedSlug.current = next;
|
|
78
84
|
else {
|
|
79
85
|
value = { ...value, slug: stored };
|
|
@@ -127,6 +133,22 @@ export function EntryForm({ ops, registry, collection, id }) {
|
|
|
127
133
|
return _jsx("p", { style: { padding: 32 }, children: "No such collection." });
|
|
128
134
|
const row = record?.locales[locale];
|
|
129
135
|
const status = row ? localeStatusOf(row) : null;
|
|
136
|
+
// A refused publish's field errors clear one by one as the fields are fixed: every change revalidates
|
|
137
|
+
// (the same rules the op applies) and keeps only what still fails; the notice's list shrinks with them.
|
|
138
|
+
const elementDescs = useMemo(() => elementSchemas(registry.elements), [registry]);
|
|
139
|
+
const hasFieldErrors = Object.keys(fieldErrors).length > 0;
|
|
140
|
+
useEffect(() => {
|
|
141
|
+
if (!hasFieldErrors || !fields || !meta)
|
|
142
|
+
return;
|
|
143
|
+
const fresh = validateFields(fields, meta.fields, elementDescs);
|
|
144
|
+
setFieldErrors((prev) => stillFailing(prev, fresh));
|
|
145
|
+
setNotice((n) => {
|
|
146
|
+
if (!n?.fields)
|
|
147
|
+
return n;
|
|
148
|
+
const left = n.fields.filter((f) => fresh.some((e) => e.field === f.field));
|
|
149
|
+
return left.length === 0 ? null : left.length === n.fields.length ? n : { ...n, fields: left };
|
|
150
|
+
});
|
|
151
|
+
}, [fields, hasFieldErrors, meta, elementDescs]);
|
|
130
152
|
/** An op's failure onto the screen: amber with the fields for a validation result, red otherwise. */
|
|
131
153
|
const fail = (r) => {
|
|
132
154
|
if (r.code === "validation" && r.fields && r.fields.length > 0) {
|
|
@@ -180,7 +202,7 @@ export function EntryForm({ ops, registry, collection, id }) {
|
|
|
180
202
|
};
|
|
181
203
|
/** Discard (spec 2026-09-13 publish flow §4): confirm, flush so a late save cannot re-dirty the row, restore, reload. */
|
|
182
204
|
const discard = async () => {
|
|
183
|
-
if (!
|
|
205
|
+
if (!(await confirm({ ...DISCARD_CONFIRM, subject: entrySubject() })))
|
|
184
206
|
return;
|
|
185
207
|
setNotice(null);
|
|
186
208
|
setBusy("discard");
|
|
@@ -290,7 +312,14 @@ export function EntryForm({ ops, registry, collection, id }) {
|
|
|
290
312
|
const remaining = Object.keys(record.locales).filter((l) => l !== locale);
|
|
291
313
|
if (remaining.length === 0)
|
|
292
314
|
return;
|
|
293
|
-
|
|
315
|
+
const ok = await confirm({
|
|
316
|
+
title: `Remove this entry from ${locale}?`,
|
|
317
|
+
body: `Its ${locale} fields and history are deleted. Other languages are not affected.`,
|
|
318
|
+
confirmLabel: `Remove from ${locale}`,
|
|
319
|
+
destructive: true,
|
|
320
|
+
subject: entrySubject(),
|
|
321
|
+
});
|
|
322
|
+
if (!ok)
|
|
294
323
|
return;
|
|
295
324
|
let result;
|
|
296
325
|
try {
|
|
@@ -307,11 +336,18 @@ export function EntryForm({ ops, registry, collection, id }) {
|
|
|
307
336
|
goToLocale(remaining.includes(registry.locales.default) ? registry.locales.default : remaining[0]);
|
|
308
337
|
};
|
|
309
338
|
const destroy = async () => {
|
|
310
|
-
|
|
339
|
+
const ok = await confirm({
|
|
340
|
+
title: "Delete this entry?",
|
|
341
|
+
body: `The entry${multilingual ? " in every language" : ""} and its history are removed. Anything referencing it loses the reference. This cannot be undone.`,
|
|
342
|
+
confirmLabel: "Delete entry",
|
|
343
|
+
destructive: true,
|
|
344
|
+
subject: entrySubject(),
|
|
345
|
+
});
|
|
346
|
+
if (!ok)
|
|
311
347
|
return;
|
|
312
348
|
const r = await ops.entries.delete(collection, id);
|
|
313
349
|
if (r.ok)
|
|
314
|
-
navigate(adminHref(`/${collection}`), { force: true });
|
|
350
|
+
void navigate(adminHref(`/${collection}`), { force: true });
|
|
315
351
|
else
|
|
316
352
|
fail(r);
|
|
317
353
|
};
|
|
@@ -319,6 +355,11 @@ export function EntryForm({ ops, registry, collection, id }) {
|
|
|
319
355
|
// The mount's path in the ACTIVE locale prefixes the slug; no path, no prefix.
|
|
320
356
|
const base = meta.path?.[locale];
|
|
321
357
|
const slugPrefix = base ? `/${base}/` : undefined;
|
|
358
|
+
/** The confirm dialogs' context chip: the entry's title and, on a routed collection, its URL. */
|
|
359
|
+
const entrySubject = () => {
|
|
360
|
+
const slug = record?.locales[locale]?.slug;
|
|
361
|
+
return record ? { name: heading, path: slugPrefix && slug ? `${slugPrefix}${slug}` : null } : undefined;
|
|
362
|
+
};
|
|
322
363
|
const present = record ? registry.locales.supported.filter((l) => record.locales[l]) : [locale];
|
|
323
364
|
const widget = ([key, d]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: d, value: fields?.[key], errors: fieldErrors, refOptions: refTargets[key] ? refOptions[refTargets[key]] : undefined, suggestions: tagSuggestions[key], prefix: d.type === "slug" ? slugPrefix : undefined, mark: multilingual && !localeKeys.includes(key) ? _jsx(UnlocalizedMark, {}) : undefined, onChange: (value) => {
|
|
324
365
|
setFields((f) => ({ ...(f ?? {}), [key]: value }));
|
|
@@ -333,6 +374,6 @@ export function EntryForm({ ops, registry, collection, id }) {
|
|
|
333
374
|
trailing: multilingual && (_jsx(LocaleSwitcher, { registry: registry, present: present, parentLocales: parentLocales, value: locale, adding: adding, onSwitch: (l) => { void switchLocale(l); }, onAdd: (l) => void doAddLocale(l) })),
|
|
334
375
|
},
|
|
335
376
|
{ label: heading },
|
|
336
|
-
] }) }), right: _jsxs(_Fragment, { children: [(touched || saveState === "error") && _jsx(SaveIndicator, { state: saveState }), urls[locale] && (_jsx(LinkButton, { href: urls[locale], target: "_blank", rel: "noreferrer", title: "
|
|
377
|
+
] }) }), right: _jsxs(_Fragment, { children: [(touched || saveState === "error") && _jsx(SaveIndicator, { state: saveState }), urls[locale] && (_jsx(LinkButton, { icon: true, href: urls[locale], target: "_blank", rel: "noreferrer", "aria-label": "View live", title: "View live \u2014 opens the site route. While the admin's draft-mode cookie is on, this shows the DRAFT \u2014 open it in a private window to see what visitors see.", children: _jsx(Eye, { size: 16, strokeWidth: 1.5, "aria-hidden": "true" }) })), _jsx(PublishControl, { status: status, pending: dirty || saveState !== "saved", busy: busy, flash: published, disabled: !row, onPublish: () => { void publish(); }, onUnpublish: () => { void unpublish(); }, onDiscard: () => { void discard(); }, onHistory: () => { void openHistory(); } })] }) }), history && row && (_jsx(HistoryModal, { rows: history, draftId: row.draftVersionId, onSelect: (v) => { void loadSnapshot(v); }, onClose: () => setHistory(null), onRestore: restore, render: (versionId) => snapshot?.id === versionId ? (_jsx("fieldset", { disabled: true, className: "sm-history__form", children: Object.entries(meta.fields).map(([key, d]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: d, value: snapshot.fields[key], refOptions: refTargets[key] ? refOptions[refTargets[key]] : undefined, prefix: d.type === "slug" ? slugPrefix : undefined, onChange: () => { } }, key))) })) : _jsx("p", { className: "sm-hint", style: { padding: 16 }, children: "Loading\u2026" }) })), _jsxs("div", { className: "sm-editor", children: [_jsxs("div", { className: "sm-form", ref: formRef, children: [notice && (_jsxs(Banner, { tone: notice.tone, onDismiss: () => setNotice(null), children: [_jsx("p", { style: { margin: 0 }, children: notice.text }), _jsx(FieldErrorList, { fields: notice.fields ?? [], onReveal: (path) => { if (formRef.current)
|
|
337
378
|
revealField(formRef.current, path); } })] })), absent ? (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 12 }, children: [_jsxs("p", { children: ["This entry doesn't exist in ", locale, " yet. Adding it copies the ", entrySourceLocale(record, registry), " fields as the first ", locale, " draft."] }), _jsxs("div", { style: { display: "flex", gap: 8 }, children: [_jsxs(Button, { onClick: () => void doAddLocale(locale), disabled: adding || !parentLocales.includes(locale), title: parentLocales.includes(locale) ? undefined : `add ${locale} to the parent first`, children: ["Add ", locale] }), _jsxs(Button, { variant: "secondary", onClick: () => { void switchLocale(entrySourceLocale(record, registry)); }, children: ["Back to ", entrySourceLocale(record, registry)] })] })] })) : fields !== null ? (Object.entries(meta.fields).map(widget)) : !notice && _jsx("p", { style: { color: "var(--text-muted)" }, children: "Loading\u2026" })] }), _jsx(ResizeHandle, {}), _jsxs("aside", { className: "sm-inspector", children: [record && row && (_jsxs("div", { className: "sm-card", children: [_jsx(SectionLabel, { children: "Document info" }), _jsx(InfoRow, { label: "Created", value: relativeTime(record.createdAt) }), _jsx(InfoRow, { label: "Updated", value: updatedLine(row.updatedAt, row.updatedBy) }), multilingual && _jsx(InfoRow, { label: "Languages", value: Object.keys(record.locales).join(", ") }), _jsx(InfoRow, { label: "ID", value: id, mono: true })] })), record && (_jsxs("div", { className: "sm-card", children: [multilingual && row && Object.keys(record.locales).length > 1 && (_jsxs(Button, { variant: "secondary", onClick: () => void doRemoveLocale(), children: ["Remove from ", locale] })), _jsx(Button, { variant: "danger", onClick: destroy, children: "Delete" })] }))] })] })] }));
|
|
338
379
|
}
|
|
@@ -12,6 +12,8 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
|
|
|
12
12
|
// validates the draft and lists the fields in an amber banner. "Used on
|
|
13
13
|
// N pages" is the refs index's answer.
|
|
14
14
|
import { useEffect, useMemo, useRef, useState } from "react";
|
|
15
|
+
import { stillFailing, validateNode } from "../validate.js";
|
|
16
|
+
import { elementSchemas } from "../../richtext-walk.js";
|
|
15
17
|
import { CSS_KEY, SETTINGS_KEY, STYLES_KEY } from "../../global.js";
|
|
16
18
|
import { tabsFor } from "../forms/tabs.js";
|
|
17
19
|
import { revealField } from "../forms/reveal.js";
|
|
@@ -28,6 +30,7 @@ import { useAdminHref } from "./base.js";
|
|
|
28
30
|
import { GlobalFields } from "./GlobalFields.js";
|
|
29
31
|
import { localeFromUrl, writeLocaleToUrl } from "../editor/locale-url.js";
|
|
30
32
|
import { useLeaveGuard } from "./router.js";
|
|
33
|
+
import { confirm, DISCARD_CONFIRM } from "../ui/confirm.js";
|
|
31
34
|
export function GlobalForm({ ops, registry, name }) {
|
|
32
35
|
const adminHref = useAdminHref();
|
|
33
36
|
const item = registry.globals.find((s) => s.name === name);
|
|
@@ -101,6 +104,23 @@ export function GlobalForm({ ops, registry, name }) {
|
|
|
101
104
|
else
|
|
102
105
|
setNotice({ tone: "error", text: r.message });
|
|
103
106
|
};
|
|
107
|
+
// A refused publish's field errors clear as the fields are fixed: every change revalidates the way the
|
|
108
|
+
// op does (a visual item like a node: fields, `$settings` under `settings.`, `$css`) and keeps what still fails.
|
|
109
|
+
const elementDescs = useMemo(() => elementSchemas(registry.elements), [registry]);
|
|
110
|
+
const hasFieldErrors = Object.keys(fieldErrors).length > 0;
|
|
111
|
+
useEffect(() => {
|
|
112
|
+
if (!hasFieldErrors || !fields || !item)
|
|
113
|
+
return;
|
|
114
|
+
const { [SETTINGS_KEY]: settings, [CSS_KEY]: css, ...values } = fields;
|
|
115
|
+
const fresh = validateNode({ fields: values, settings: settings !== null && typeof settings === "object" ? settings : {}, css }, { fields: item.fields, settings: item.section !== undefined ? item.settings : undefined }, elementDescs);
|
|
116
|
+
setFieldErrors((prev) => stillFailing(prev, fresh));
|
|
117
|
+
setNotice((n) => {
|
|
118
|
+
if (!n?.fields)
|
|
119
|
+
return n;
|
|
120
|
+
const left = n.fields.filter((f) => fresh.some((e) => e.field === f.field));
|
|
121
|
+
return left.length === 0 ? null : left.length === n.fields.length ? n : { ...n, fields: left };
|
|
122
|
+
});
|
|
123
|
+
}, [fields, hasFieldErrors, item, elementDescs]);
|
|
104
124
|
const publish = async () => {
|
|
105
125
|
setNotice(null);
|
|
106
126
|
setBusy("publish");
|
|
@@ -143,7 +163,7 @@ export function GlobalForm({ ops, registry, name }) {
|
|
|
143
163
|
fail(r);
|
|
144
164
|
};
|
|
145
165
|
const discard = async () => {
|
|
146
|
-
if (!
|
|
166
|
+
if (!(await confirm({ ...DISCARD_CONFIRM, subject: { name: item.title } })))
|
|
147
167
|
return;
|
|
148
168
|
setNotice(null);
|
|
149
169
|
setBusy("discard");
|
|
@@ -235,7 +255,7 @@ export function GlobalForm({ ops, registry, name }) {
|
|
|
235
255
|
};
|
|
236
256
|
return (_jsxs(_Fragment, { children: [_jsx(TopBar, { left: _jsx(_Fragment, { children: _jsx(Breadcrumb, { backHref: adminHref("/globals"), items: [
|
|
237
257
|
{
|
|
238
|
-
label: "
|
|
258
|
+
label: "Site", href: adminHref("/globals"),
|
|
239
259
|
trailing: multilingual && (_jsx(LocaleSwitcher, { registry: registry, present: registry.locales.supported, parentLocales: [], value: locale, adding: false, onSwitch: (l) => { void switchLocale(l); }, onAdd: () => undefined })),
|
|
240
260
|
},
|
|
241
261
|
{ label: item.title },
|
|
@@ -14,30 +14,16 @@ import { LocalePicker } from "../ui/LocaleSwitcher.js";
|
|
|
14
14
|
import { relativeTime } from "../ui/format.js";
|
|
15
15
|
import { useAdminHref } from "./base.js";
|
|
16
16
|
import { ErrorPane } from "./ErrorPane.js";
|
|
17
|
-
|
|
18
|
-
function rememberedLocale(registry) {
|
|
19
|
-
try {
|
|
20
|
-
const stored = window.sessionStorage.getItem(LOCALE_KEY);
|
|
21
|
-
if (stored && registry.locales.supported.includes(stored))
|
|
22
|
-
return stored;
|
|
23
|
-
}
|
|
24
|
-
catch {
|
|
25
|
-
// storage unavailable — the default is fine
|
|
26
|
-
}
|
|
27
|
-
return registry.locales.default;
|
|
28
|
-
}
|
|
17
|
+
import { rememberLocale, rememberedLocale } from "./locale-memory.js";
|
|
29
18
|
export function GlobalList({ ops, registry }) {
|
|
30
19
|
const adminHref = useAdminHref();
|
|
31
20
|
const [items, setItems] = useState(null);
|
|
32
21
|
const [error, setError] = useState(null);
|
|
33
|
-
const [locale, setLocale] = useState(() => rememberedLocale(registry));
|
|
22
|
+
const [locale, setLocale] = useState(() => rememberedLocale(registry.locales));
|
|
34
23
|
const multilingual = registry.locales.supported.length > 1;
|
|
35
24
|
const pickLocale = (next) => {
|
|
36
25
|
setLocale(next);
|
|
37
|
-
|
|
38
|
-
window.sessionStorage.setItem(LOCALE_KEY, next);
|
|
39
|
-
}
|
|
40
|
-
catch { /* see rememberedLocale */ }
|
|
26
|
+
rememberLocale(next);
|
|
41
27
|
};
|
|
42
28
|
const load = useCallback(() => {
|
|
43
29
|
ops.globals.list()
|
|
@@ -48,7 +34,7 @@ export function GlobalList({ ops, registry }) {
|
|
|
48
34
|
if (error)
|
|
49
35
|
return _jsx(ErrorPane, { message: error });
|
|
50
36
|
const rows = items ? globalRows(items, registry, locale) : [];
|
|
51
|
-
return (_jsx(ListView, { title: "
|
|
37
|
+
return (_jsx(ListView, { title: "Site", titleTrailing: multilingual && _jsx(LocalePicker, { locales: registry.locales.supported, value: locale, onChange: pickLocale }), columns: [
|
|
52
38
|
{ label: "Title", width: "minmax(160px,1fr)" },
|
|
53
39
|
{ label: "Kind", width: "90px" },
|
|
54
40
|
{ label: "Updated", width: "minmax(110px,160px)" },
|
|
@@ -26,9 +26,11 @@ import { RouteLink } from "./RouteLink.js";
|
|
|
26
26
|
/** Lucide at the rail's size and stroke (the tile's CSS restates both). */
|
|
27
27
|
const ICON = { size: 18, strokeWidth: 2, "aria-hidden": true };
|
|
28
28
|
/** The app icon, inlined: a package can't ship a file into the host app's
|
|
29
|
-
* /public.
|
|
29
|
+
* /public. design_handoff_smoodly_neutral/assets/smoodly-logo.svg — the
|
|
30
|
+
* outer and inner shapes take --logo-outer / --logo-inner (white over
|
|
31
|
+
* navy on the frame), so a look decides its colours. */
|
|
30
32
|
function AppIcon() {
|
|
31
|
-
return (_jsxs("svg", { className: "sm-rail__logo", viewBox: "0 0
|
|
33
|
+
return (_jsxs("svg", { className: "sm-rail__logo", viewBox: "0 0 28 27", fill: "none", xmlns: "http://www.w3.org/2000/svg", role: "img", "aria-label": "Smoodly", children: [_jsx("path", { style: { fill: "var(--logo-outer)" }, d: "M0 16.1859C0 7.6335 7.88106 0 16.5324 0H16.6037C23.429 0 28 4.59428 28 10.8141C28 19.3666 20.2618 27 11.3279 27C4.43425 27 0 22.2645 0 16.1859Z" }), _jsx("path", { style: { fill: "var(--logo-inner)" }, d: "M7 14.7932C7 10.6754 10.6591 7 14.6757 7H14.7089C17.8777 7 20 9.21206 20 12.2068C20 16.3247 16.4072 20 12.2594 20C9.05876 20 7 17.7199 7 14.7932Z" })] }));
|
|
32
34
|
}
|
|
33
35
|
export function IconRail({ registry, active, email, settings = true, onSignOut }) {
|
|
34
36
|
const adminHref = useAdminHref();
|
|
@@ -44,14 +46,21 @@ export function IconRail({ registry, active, email, settings = true, onSignOut }
|
|
|
44
46
|
const close = () => setOpen(null);
|
|
45
47
|
const hoverLeave = () => setHovered(false);
|
|
46
48
|
// A click open moves focus into the menu; a pointer down outside closes it.
|
|
49
|
+
// Outside means outside the menu and its trigger — the rest of the rail
|
|
50
|
+
// counts too (2026-09-26); the trigger is left to its own toggle.
|
|
47
51
|
useEffect(() => {
|
|
48
52
|
if (!open || !root.current)
|
|
49
53
|
return;
|
|
50
54
|
if (!hovered)
|
|
51
55
|
root.current.querySelector(`#${CSS.escape(menuId(open))} [role=menuitem]`)?.focus();
|
|
52
56
|
const onPointerDown = (e) => {
|
|
53
|
-
if (e.target instanceof Node
|
|
54
|
-
|
|
57
|
+
if (!(e.target instanceof Node))
|
|
58
|
+
return;
|
|
59
|
+
const menu = root.current?.querySelector(`#${CSS.escape(menuId(open))}`);
|
|
60
|
+
const trigger = root.current?.querySelector(`[aria-controls="${menuId(open)}"]`);
|
|
61
|
+
if (menu?.contains(e.target) || trigger?.contains(e.target))
|
|
62
|
+
return;
|
|
63
|
+
close();
|
|
55
64
|
};
|
|
56
65
|
document.addEventListener("pointerdown", onPointerDown);
|
|
57
66
|
return () => document.removeEventListener("pointerdown", onPointerDown);
|
|
@@ -79,5 +88,5 @@ export function IconRail({ registry, active, email, settings = true, onSignOut }
|
|
|
79
88
|
const collectionsOpen = open === "collections" || (hovered && open === null);
|
|
80
89
|
const name = email?.split("@")[0] ?? "Editor";
|
|
81
90
|
const initials = name.slice(0, 2).toUpperCase();
|
|
82
|
-
return (_jsxs("nav", { className: "sm-rail", "aria-label": "Primary", ref: root, onKeyDown: onKeyDown, children: [_jsx(AppIcon, {}), _jsxs("ul", { className: "sm-rail__list", children: [link("pages", "Pages", _jsx(PanelTop, { ...ICON }), adminHref("/pages")), collections.length > 0 && (_jsxs("li", { className: "sm-rail__item", onMouseEnter: () => setHovered(true), onMouseLeave: hoverLeave, children: [_jsxs("button", { type: "button", className: "sm-rail__btn", "aria-current": collectionActive ? "page" : undefined, "aria-haspopup": "menu", "aria-expanded": collectionsOpen, "aria-controls": menuId("collections"), onClick: () => toggle("collections"), children: [_jsx("span", { className: "sm-rail__tile", children: _jsx(LibraryBig, { ...ICON }) }), _jsx("span", { className: "sm-rail__label", children: "Collections" })] }), _jsx("ul", { className: "sm-rail__menu sm-rail__menu--collections", role: "menu", id: menuId("collections"), "data-open": collectionsOpen, children: collections.map((c) => (_jsx("li", { role: "none", children: _jsx(RouteLink, { role: "menuitem", className: "sm-rail__menu-item", href: adminHref(`/${c.name}`), "aria-current": c.name === active ? "page" : undefined, onClick: () => { close(); hoverLeave(); }, children: _jsx("span", { children: c.title }) }) }, c.name))) })] })), link("media", "Media", _jsx(Image, { ...ICON }), adminHref("/media")), registry.globals.length > 0 && link("globals", "
|
|
91
|
+
return (_jsxs("nav", { className: "sm-rail", "aria-label": "Primary", ref: root, onKeyDown: onKeyDown, children: [_jsx(AppIcon, {}), _jsxs("ul", { className: "sm-rail__list", children: [link("pages", "Pages", _jsx(PanelTop, { ...ICON }), adminHref("/pages")), collections.length > 0 && (_jsxs("li", { className: "sm-rail__item", onMouseEnter: () => setHovered(true), onMouseLeave: hoverLeave, children: [_jsxs("button", { type: "button", className: "sm-rail__btn", "aria-current": collectionActive ? "page" : undefined, "aria-haspopup": "menu", "aria-expanded": collectionsOpen, "aria-controls": menuId("collections"), onClick: () => toggle("collections"), children: [_jsx("span", { className: "sm-rail__tile", children: _jsx(LibraryBig, { ...ICON }) }), _jsx("span", { className: "sm-rail__label", children: "Collections" })] }), _jsx("ul", { className: "sm-rail__menu sm-rail__menu--collections", role: "menu", id: menuId("collections"), "data-open": collectionsOpen, children: collections.map((c) => (_jsx("li", { role: "none", children: _jsx(RouteLink, { role: "menuitem", className: "sm-rail__menu-item", href: adminHref(`/${c.name}`), "aria-current": c.name === active ? "page" : undefined, onClick: () => { close(); hoverLeave(); }, children: _jsx("span", { children: c.title }) }) }, c.name))) })] })), link("media", "Media", _jsx(Image, { ...ICON }), adminHref("/media")), registry.globals.length > 0 && link("globals", "Site", _jsx(Globe, { ...ICON }), adminHref("/globals")), settings && link("settings", "Settings", _jsx(Settings, { ...ICON }), adminHref("/settings"))] }), _jsxs("div", { className: "sm-rail__item sm-rail__foot", children: [_jsx("button", { type: "button", className: "sm-rail__user", title: name, "aria-haspopup": "menu", "aria-expanded": open === "user", "aria-controls": menuId("user"), onClick: () => toggle("user"), children: initials }), _jsxs("ul", { className: "sm-rail__menu sm-rail__menu--up", role: "menu", id: menuId("user"), "data-open": open === "user", children: [_jsx("li", { className: "sm-rail__menu-title", role: "presentation", children: name }), _jsx("li", { role: "none", children: _jsx(ThemeToggle, {}) }), _jsx("li", { role: "none", children: _jsx("button", { type: "button", role: "menuitem", className: "sm-rail__menu-item", onClick: () => { close(); onSignOut(); }, children: "Sign out" }) })] })] })] }));
|
|
83
92
|
}
|
|
@@ -2,13 +2,15 @@
|
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
// packages/smoodly/src/admin/shell/InviteModal.tsx
|
|
4
4
|
// Invite a member (spec 2026-09-25 members management §4): an email, a
|
|
5
|
-
// role, Send. The
|
|
6
|
-
//
|
|
5
|
+
// role, Send. The role is a radio list with a line under each option
|
|
6
|
+
// saying what it may do — the one place the difference is explained in
|
|
7
|
+
// the admin (the Roles page was dropped 2026-09-25). The op's error
|
|
8
|
+
// shows inside; success closes it and the page reports what happened.
|
|
7
9
|
import { useState } from "react";
|
|
8
10
|
import { MEMBER_ROLES } from "../../permissions.js";
|
|
9
11
|
import { Modal } from "../ui/Modal.js";
|
|
10
|
-
import { Banner, Button, Field
|
|
11
|
-
import { roleLabel } from "./members-list.js";
|
|
12
|
+
import { Banner, Button, Field } from "../ui/primitives.js";
|
|
13
|
+
import { ROLE_DESCRIPTION, roleLabel } from "./members-list.js";
|
|
12
14
|
export function InviteModal({ onClose, onInvite }) {
|
|
13
15
|
const [email, setEmail] = useState("");
|
|
14
16
|
const [role, setRole] = useState("editor");
|
|
@@ -20,5 +22,7 @@ export function InviteModal({ onClose, onInvite }) {
|
|
|
20
22
|
setError(await onInvite({ email: email.trim(), role }));
|
|
21
23
|
setBusy(false);
|
|
22
24
|
};
|
|
23
|
-
|
|
25
|
+
// Editor first: the default, and the one most invites want.
|
|
26
|
+
const roles = [...MEMBER_ROLES].reverse();
|
|
27
|
+
return (_jsx(Modal, { title: "Invite a member", width: 420, onClose: onClose, children: _jsxs("form", { onSubmit: submit, style: { display: "flex", flexDirection: "column", gap: 14 }, children: [_jsx(Field, { label: "Email", children: _jsx("input", { className: "sm-input", type: "email", required: true, autoFocus: true, value: email, onChange: (e) => setEmail(e.target.value) }) }), _jsx(Field, { label: "Role", asDiv: true, children: _jsx("div", { className: "sm-choices", role: "radiogroup", children: roles.map((r) => (_jsxs("label", { className: "sm-choice sm-choice--desc", children: [_jsx("input", { type: "radio", name: "role", value: r, checked: role === r, onChange: () => setRole(r) }), _jsxs("span", { children: [_jsx("span", { className: "sm-choice__label", children: roleLabel(r) }), _jsx("span", { className: "sm-choice__desc", children: ROLE_DESCRIPTION[r] })] })] }, r))) }) }), error && _jsx(Banner, { tone: "error", children: error }), _jsxs("div", { style: { display: "flex", justifyContent: "flex-end", gap: 8 }, children: [_jsx(Button, { variant: "secondary", onClick: onClose, children: "Cancel" }), _jsx("button", { type: "submit", className: "sm-btn sm-btn--primary", disabled: busy, children: busy ? "Sending…" : "Send invite" })] })] }) }));
|
|
24
28
|
}
|