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
|
@@ -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
|
}
|
|
@@ -2,10 +2,14 @@ import type { ReactNode } from "react";
|
|
|
2
2
|
import type { ResolvedConfig } from "../../config.ts";
|
|
3
3
|
import type { AdminOpAction } from "../ops.ts";
|
|
4
4
|
import type { AuthConfig } from "../shell/session.ts";
|
|
5
|
+
import type { ContentConfig } from "../../env.ts";
|
|
6
|
+
/** `content` (optional) is the content project's public config for editing presence (spec 2026-09-26
|
|
7
|
+
* concurrency §5); absent — an app wired before it — presence is off and everything else works. */
|
|
5
8
|
export declare function createSmoodlyAdmin(input: {
|
|
6
9
|
config: ResolvedConfig;
|
|
7
10
|
op: AdminOpAction;
|
|
8
11
|
auth: () => AuthConfig;
|
|
12
|
+
content?: () => ContentConfig;
|
|
9
13
|
}): {
|
|
10
14
|
AdminPage: () => null;
|
|
11
15
|
AdminLayout: ({ children }: {
|
|
@@ -2,6 +2,8 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
import { serializeAdminConfig } from "../serialize.js";
|
|
3
3
|
import { AdminApp } from "../shell/AdminApp.js";
|
|
4
4
|
import { AdminStyles } from "../ui/theme.js";
|
|
5
|
+
/** `content` (optional) is the content project's public config for editing presence (spec 2026-09-26
|
|
6
|
+
* concurrency §5); absent — an app wired before it — presence is off and everything else works. */
|
|
5
7
|
export function createSmoodlyAdmin(input) {
|
|
6
8
|
const registry = serializeAdminConfig(input.config);
|
|
7
9
|
/** The catch-all page only makes every /admin URL exist. The shell is
|
|
@@ -22,7 +24,7 @@ export function createSmoodlyAdmin(input) {
|
|
|
22
24
|
* on this element before React hydrates, on purpose (no white flash
|
|
23
25
|
* for a dark-mode editor), so the attribute differs from the server's. */
|
|
24
26
|
function AdminLayout({ children }) {
|
|
25
|
-
return (_jsxs("div", { className: "sm-admin", suppressHydrationWarning: true, children: [_jsx(AdminStyles, {}), _jsx(AdminApp, { registry: registry, op: input.op, auth: input.auth() }), children] }));
|
|
27
|
+
return (_jsxs("div", { className: "sm-admin", suppressHydrationWarning: true, children: [_jsx(AdminStyles, {}), _jsx(AdminApp, { registry: registry, op: input.op, auth: input.auth(), content: input.content?.() }), children] }));
|
|
26
28
|
}
|
|
27
29
|
return { AdminPage, AdminLayout };
|
|
28
30
|
}
|
|
@@ -4,5 +4,6 @@ export type { AdminOpCall, AdminOpAction, AdminOps, OpResult, MembersListResult
|
|
|
4
4
|
export type { InviteInput, InviteResult, MemberRecord } from "../../member-store.ts";
|
|
5
5
|
export type { MemberRole } from "../../permissions.ts";
|
|
6
6
|
export type { AuthConfig } from "../shell/session.ts";
|
|
7
|
+
export type { ContentConfig } from "../../env.ts";
|
|
7
8
|
export type { AdminAuth } from "../auth.ts";
|
|
8
9
|
export { supabaseAdminAuth } from "../supabase-auth.ts";
|
package/dist/admin/ops-impl.js
CHANGED
|
@@ -11,10 +11,16 @@ 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";
|
|
15
|
+
import { draftToken, isDraftToken, StaleDraftError } from "../draft-token.js";
|
|
14
16
|
const ok = (data) => ({ ok: true, data });
|
|
15
17
|
/** A history row without its content (spec 2026-09-13 history §6). */
|
|
16
18
|
const summary = ({ id, savedAt }) => ({ id, savedAt });
|
|
17
19
|
const err = (code, message, fields) => ({ ok: false, code, message, ...(fields ? { fields } : {}) });
|
|
20
|
+
/** The client's token is untrusted data: undefined skips, null is "no draft", the shape is the token, anything else is refused. */
|
|
21
|
+
const BAD_TOKEN = Symbol("bad token");
|
|
22
|
+
const tokenArg = (v) => v === undefined ? undefined : v === null ? null : isDraftToken(v) ? v : BAD_TOKEN;
|
|
23
|
+
const badToken = () => err("validation", "smoodly: malformed draft token.");
|
|
18
24
|
/** Own-property, non-array plain object — same shape guard the tree checks
|
|
19
25
|
* below and `runAdminOp`'s envelope check both lean on. */
|
|
20
26
|
const isPlainObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
@@ -41,6 +47,9 @@ function isPageTree(tree) {
|
|
|
41
47
|
}
|
|
42
48
|
/** Store throws are stringly-typed; classify the known guards, default to internal. */
|
|
43
49
|
const classify = (e) => {
|
|
50
|
+
// The guard's refusal (spec 2026-09-26 concurrency §2): typed, so it never depends on message matching.
|
|
51
|
+
if (e instanceof StaleDraftError)
|
|
52
|
+
return { ok: false, code: "conflict", stale: true, by: e.by, at: e.at, message: e.message };
|
|
44
53
|
const message = e instanceof Error ? e.message : String(e);
|
|
45
54
|
if (message.includes("already exists in locale"))
|
|
46
55
|
return err("conflict", message);
|
|
@@ -323,20 +332,28 @@ export function createAdminOps(deps) {
|
|
|
323
332
|
expireTree();
|
|
324
333
|
return ok(created);
|
|
325
334
|
}),
|
|
326
|
-
saveDraft: (id, locale, tree) => guard(async () => {
|
|
335
|
+
saveDraft: (id, locale, tree, expected) => guard(async () => {
|
|
327
336
|
const unsupported = supportedLocale(locale);
|
|
328
337
|
if (unsupported)
|
|
329
338
|
return unsupported;
|
|
330
339
|
if (!isPageTree(tree))
|
|
331
340
|
return err("validation", "smoodly: malformed page tree.");
|
|
341
|
+
const token = tokenArg(expected);
|
|
342
|
+
if (token === BAD_TOKEN)
|
|
343
|
+
return badToken();
|
|
332
344
|
if (!(await pages.getPage(id)))
|
|
333
345
|
return err("not_found", `smoodly: no page "${id}".`);
|
|
334
|
-
|
|
346
|
+
const version = await pages.saveDraft(id, locale, tree, by, token);
|
|
347
|
+
const after = await pages.getPage(id);
|
|
348
|
+
return ok({ versionId: version.id, draft: after?.locales[locale] ? draftToken(after.locales[locale]) : null });
|
|
335
349
|
}),
|
|
336
|
-
publish: (id, locale) => guard(async () => {
|
|
350
|
+
publish: (id, locale, expected) => guard(async () => {
|
|
337
351
|
const unsupported = supportedLocale(locale);
|
|
338
352
|
if (unsupported)
|
|
339
353
|
return unsupported;
|
|
354
|
+
const token = tokenArg(expected);
|
|
355
|
+
if (token === BAD_TOKEN)
|
|
356
|
+
return badToken();
|
|
340
357
|
const record = await pages.getPage(id);
|
|
341
358
|
if (!record)
|
|
342
359
|
return err("not_found", `smoodly: no page "${id}".`);
|
|
@@ -372,7 +389,7 @@ export function createAdminOps(deps) {
|
|
|
372
389
|
if (fieldErrors.length > 0) {
|
|
373
390
|
return err("validation", `smoodly: cannot publish "${row.slug}" — fix the listed fields first.`, fieldErrors);
|
|
374
391
|
}
|
|
375
|
-
const published = await pages.publish(id, locale, by);
|
|
392
|
+
const published = await pages.publish(id, locale, by, token);
|
|
376
393
|
// The parent's cached unit lists its published children.
|
|
377
394
|
expirePages([published.id, published.parentId]);
|
|
378
395
|
expireTree();
|
|
@@ -397,10 +414,13 @@ export function createAdminOps(deps) {
|
|
|
397
414
|
expireTree();
|
|
398
415
|
return ok(off);
|
|
399
416
|
}),
|
|
400
|
-
discardDraft: (id, locale) => guard(async () => {
|
|
417
|
+
discardDraft: (id, locale, expected) => guard(async () => {
|
|
401
418
|
const unsupported = supportedLocale(locale);
|
|
402
419
|
if (unsupported)
|
|
403
420
|
return unsupported;
|
|
421
|
+
const token = tokenArg(expected);
|
|
422
|
+
if (token === BAD_TOKEN)
|
|
423
|
+
return badToken();
|
|
404
424
|
const record = await pages.getPage(id);
|
|
405
425
|
if (!record)
|
|
406
426
|
return err("not_found", `smoodly: no page "${id}".`);
|
|
@@ -410,7 +430,7 @@ export function createAdminOps(deps) {
|
|
|
410
430
|
if (!row.publishedVersionId) {
|
|
411
431
|
return err("blocked", `smoodly: "${nameOf(record)}" has nothing published in ${locale} — there is no version to restore.`);
|
|
412
432
|
}
|
|
413
|
-
const restored = await pages.discardDraft(id, locale, by);
|
|
433
|
+
const restored = await pages.discardDraft(id, locale, by, token);
|
|
414
434
|
expirePages([restored.id]); // the draft-mode render of this page changed
|
|
415
435
|
return ok(restored);
|
|
416
436
|
}),
|
|
@@ -422,10 +442,13 @@ export function createAdminOps(deps) {
|
|
|
422
442
|
return err("not_found", `smoodly: no page "${id}".`);
|
|
423
443
|
return ok((await pages.listVersions(id, locale)).map(summary));
|
|
424
444
|
}),
|
|
425
|
-
restore: (id, locale, versionId) => guard(async () => {
|
|
445
|
+
restore: (id, locale, versionId, expected) => guard(async () => {
|
|
426
446
|
const unsupported = supportedLocale(locale);
|
|
427
447
|
if (unsupported)
|
|
428
448
|
return unsupported;
|
|
449
|
+
const token = tokenArg(expected);
|
|
450
|
+
if (token === BAD_TOKEN)
|
|
451
|
+
return badToken();
|
|
429
452
|
const record = await pages.getPage(id);
|
|
430
453
|
if (!record)
|
|
431
454
|
return err("not_found", `smoodly: no page "${id}".`);
|
|
@@ -437,7 +460,7 @@ export function createAdminOps(deps) {
|
|
|
437
460
|
if (!version || version.pageId !== id || version.locale !== locale) {
|
|
438
461
|
return err("not_found", `smoodly: no version "${String(versionId)}" of this page in ${locale}.`);
|
|
439
462
|
}
|
|
440
|
-
const restored = await pages.restore(id, locale, versionId, by);
|
|
463
|
+
const restored = await pages.restore(id, locale, versionId, by, token);
|
|
441
464
|
expirePages([restored.id]); // the draft-mode render of this page changed
|
|
442
465
|
return ok(restored);
|
|
443
466
|
}),
|
|
@@ -597,22 +620,28 @@ export function createAdminOps(deps) {
|
|
|
597
620
|
expireCollection(collection);
|
|
598
621
|
return ok(created);
|
|
599
622
|
}),
|
|
600
|
-
save: (collection, id, locale, fields) => guard(async () => {
|
|
623
|
+
save: (collection, id, locale, fields, expected) => guard(async () => {
|
|
601
624
|
const unsupported = supportedLocale(locale);
|
|
602
625
|
if (unsupported)
|
|
603
626
|
return unsupported;
|
|
627
|
+
const token = tokenArg(expected);
|
|
628
|
+
if (token === BAD_TOKEN)
|
|
629
|
+
return badToken();
|
|
604
630
|
const descriptors = collectionFields(collection);
|
|
605
631
|
if (!descriptors)
|
|
606
632
|
return err("not_found", `smoodly: no collection named "${collection}".`);
|
|
607
|
-
const entry = await entries.update(collection, id, locale, fields, by);
|
|
633
|
+
const entry = await entries.update(collection, id, locale, fields, by, token);
|
|
608
634
|
await fanOut(id);
|
|
609
635
|
expireCollection(collection);
|
|
610
636
|
return ok(entry);
|
|
611
637
|
}),
|
|
612
|
-
setStatus: (collection, id, locale, status) => guard(async () => {
|
|
638
|
+
setStatus: (collection, id, locale, status, expected) => guard(async () => {
|
|
613
639
|
const unsupported = supportedLocale(locale);
|
|
614
640
|
if (unsupported)
|
|
615
641
|
return unsupported;
|
|
642
|
+
const token = tokenArg(expected);
|
|
643
|
+
if (token === BAD_TOKEN)
|
|
644
|
+
return badToken();
|
|
616
645
|
if (status === "published") {
|
|
617
646
|
// A draft may be incomplete; what goes live may not (spec 2026-09-14 §2).
|
|
618
647
|
const descriptors = collectionFields(collection);
|
|
@@ -627,16 +656,19 @@ export function createAdminOps(deps) {
|
|
|
627
656
|
if (fieldErrors.length > 0)
|
|
628
657
|
return err("validation", "Some fields need attention.", fieldErrors);
|
|
629
658
|
}
|
|
630
|
-
const entry = await entries.setStatus(collection, id, locale, status, by);
|
|
659
|
+
const entry = await entries.setStatus(collection, id, locale, status, by, token);
|
|
631
660
|
await fanOut(id);
|
|
632
661
|
expireCollection(collection);
|
|
633
662
|
return ok(entry);
|
|
634
663
|
}),
|
|
635
|
-
discardDraft: (collection, id, locale) => guard(async () => {
|
|
664
|
+
discardDraft: (collection, id, locale, expected) => guard(async () => {
|
|
636
665
|
const unsupported = supportedLocale(locale);
|
|
637
666
|
if (unsupported)
|
|
638
667
|
return unsupported;
|
|
639
|
-
const
|
|
668
|
+
const token = tokenArg(expected);
|
|
669
|
+
if (token === BAD_TOKEN)
|
|
670
|
+
return badToken();
|
|
671
|
+
const restored = await entries.discardDraft(collection, id, locale, by, token);
|
|
640
672
|
await fanOut(id);
|
|
641
673
|
expireCollection(collection);
|
|
642
674
|
return ok(restored);
|
|
@@ -656,15 +688,18 @@ export function createAdminOps(deps) {
|
|
|
656
688
|
}
|
|
657
689
|
return ok({ ...summary(version), fields: version.fields });
|
|
658
690
|
}),
|
|
659
|
-
restore: (collection, id, locale, versionId) => guard(async () => {
|
|
691
|
+
restore: (collection, id, locale, versionId, expected) => guard(async () => {
|
|
660
692
|
const unsupported = supportedLocale(locale);
|
|
661
693
|
if (unsupported)
|
|
662
694
|
return unsupported;
|
|
695
|
+
const token = tokenArg(expected);
|
|
696
|
+
if (token === BAD_TOKEN)
|
|
697
|
+
return badToken();
|
|
663
698
|
const version = typeof versionId === "string" ? await entries.getVersion(collection, versionId) : null;
|
|
664
699
|
if (!version || version.entryId !== id || version.locale !== locale) {
|
|
665
700
|
return err("not_found", `smoodly: no version "${String(versionId)}" of entry "${id}" in "${collection}", locale ${locale}.`);
|
|
666
701
|
}
|
|
667
|
-
const restored = await entries.restore(collection, id, locale, versionId, by);
|
|
702
|
+
const restored = await entries.restore(collection, id, locale, versionId, by, token);
|
|
668
703
|
await fanOut(id);
|
|
669
704
|
expireCollection(collection);
|
|
670
705
|
return ok(restored);
|
|
@@ -719,25 +754,31 @@ export function createAdminOps(deps) {
|
|
|
719
754
|
const usage = await entries.referencesTo(name, record.id);
|
|
720
755
|
return ok({ name, record, locale, fields: entryFieldsIn(record, locale), usage });
|
|
721
756
|
}),
|
|
722
|
-
save: (name, locale, fields) => guard(async () => {
|
|
757
|
+
save: (name, locale, fields, expected) => guard(async () => {
|
|
723
758
|
const unsupported = supportedLocale(locale);
|
|
724
759
|
if (unsupported)
|
|
725
760
|
return unsupported;
|
|
761
|
+
const token = tokenArg(expected);
|
|
762
|
+
if (token === BAD_TOKEN)
|
|
763
|
+
return badToken();
|
|
726
764
|
const item = globalItem(name);
|
|
727
765
|
if (!item)
|
|
728
766
|
return err("not_found", `smoodly: no global item named "${name}".`);
|
|
729
767
|
const record = await globalRecord(name);
|
|
730
768
|
if (!record)
|
|
731
769
|
return err("not_found", `smoodly: global item "${name}" is not materialized — open Globals first.`);
|
|
732
|
-
const saved = await entries.update(name, record.id, locale, fields, by);
|
|
770
|
+
const saved = await entries.update(name, record.id, locale, fields, by, token);
|
|
733
771
|
// fanOut expires `global:<name>` itself — this row IS the item's.
|
|
734
772
|
await fanOut(record.id);
|
|
735
773
|
return ok(saved);
|
|
736
774
|
}),
|
|
737
|
-
setStatus: (name, locale, status) => guard(async () => {
|
|
775
|
+
setStatus: (name, locale, status, expected) => guard(async () => {
|
|
738
776
|
const unsupported = supportedLocale(locale);
|
|
739
777
|
if (unsupported)
|
|
740
778
|
return unsupported;
|
|
779
|
+
const token = tokenArg(expected);
|
|
780
|
+
if (token === BAD_TOKEN)
|
|
781
|
+
return badToken();
|
|
741
782
|
const item = globalItem(name);
|
|
742
783
|
if (!item)
|
|
743
784
|
return err("not_found", `smoodly: no global item named "${name}".`);
|
|
@@ -756,21 +797,24 @@ export function createAdminOps(deps) {
|
|
|
756
797
|
if (fieldErrors.length > 0)
|
|
757
798
|
return err("validation", "Some fields need attention.", fieldErrors);
|
|
758
799
|
}
|
|
759
|
-
const changed = await entries.setStatus(name, record.id, locale, status, by);
|
|
800
|
+
const changed = await entries.setStatus(name, record.id, locale, status, by, token);
|
|
760
801
|
// see save: the fan-out carries the item's own tag.
|
|
761
802
|
await fanOut(record.id);
|
|
762
803
|
return ok(changed);
|
|
763
804
|
}),
|
|
764
|
-
discardDraft: (name, locale) => guard(async () => {
|
|
805
|
+
discardDraft: (name, locale, expected) => guard(async () => {
|
|
765
806
|
const unsupported = supportedLocale(locale);
|
|
766
807
|
if (unsupported)
|
|
767
808
|
return unsupported;
|
|
809
|
+
const token = tokenArg(expected);
|
|
810
|
+
if (token === BAD_TOKEN)
|
|
811
|
+
return badToken();
|
|
768
812
|
if (!globalItem(name))
|
|
769
813
|
return err("not_found", `smoodly: no global item named "${name}".`);
|
|
770
814
|
const record = await globalRecord(name);
|
|
771
815
|
if (!record)
|
|
772
816
|
return err("not_found", `smoodly: global item "${name}" is not materialized — open Globals first.`);
|
|
773
|
-
const restored = await entries.discardDraft(name, record.id, locale, by);
|
|
817
|
+
const restored = await entries.discardDraft(name, record.id, locale, by, token);
|
|
774
818
|
await fanOut(record.id);
|
|
775
819
|
return ok(restored);
|
|
776
820
|
}),
|
|
@@ -797,10 +841,13 @@ export function createAdminOps(deps) {
|
|
|
797
841
|
}
|
|
798
842
|
return ok({ ...summary(version), fields: version.fields });
|
|
799
843
|
}),
|
|
800
|
-
restore: (name, locale, versionId) => guard(async () => {
|
|
844
|
+
restore: (name, locale, versionId, expected) => guard(async () => {
|
|
801
845
|
const unsupported = supportedLocale(locale);
|
|
802
846
|
if (unsupported)
|
|
803
847
|
return unsupported;
|
|
848
|
+
const token = tokenArg(expected);
|
|
849
|
+
if (token === BAD_TOKEN)
|
|
850
|
+
return badToken();
|
|
804
851
|
if (!globalItem(name))
|
|
805
852
|
return err("not_found", `smoodly: no global item named "${name}".`);
|
|
806
853
|
const record = await globalRecord(name);
|
|
@@ -810,7 +857,7 @@ export function createAdminOps(deps) {
|
|
|
810
857
|
if (!version || version.entryId !== record.id || version.locale !== locale) {
|
|
811
858
|
return err("not_found", `smoodly: no version "${String(versionId)}" of global item "${name}" in ${locale}.`);
|
|
812
859
|
}
|
|
813
|
-
const restored = await entries.restore(name, record.id, locale, versionId, by);
|
|
860
|
+
const restored = await entries.restore(name, record.id, locale, versionId, by, token);
|
|
814
861
|
await fanOut(record.id);
|
|
815
862
|
return ok(restored);
|
|
816
863
|
}),
|
|
@@ -926,6 +973,9 @@ export function createAdminOps(deps) {
|
|
|
926
973
|
return ok(null);
|
|
927
974
|
}),
|
|
928
975
|
},
|
|
976
|
+
schema: {
|
|
977
|
+
status: () => guard(async () => ok({ database: await pages.schemaVersion(), required: SCHEMA_VERSION })),
|
|
978
|
+
},
|
|
929
979
|
preview: {
|
|
930
980
|
enable: () => guard(async () => (await effects?.draft?.enable(), ok(null))),
|
|
931
981
|
disable: () => guard(async () => (await effects?.draft?.disable(), ok(null))),
|
package/dist/admin/ops.d.ts
CHANGED
|
@@ -6,11 +6,17 @@ import type { AssetPage, AssetQuery, FinishInput, UploadTicket } from "../asset-
|
|
|
6
6
|
import type { InviteInput, InviteResult, MemberRecord } from "../member-store.ts";
|
|
7
7
|
import type { MemberRole } from "../permissions.ts";
|
|
8
8
|
import type { FieldError } from "./validate.ts";
|
|
9
|
+
import type { DraftToken } from "../draft-token.ts";
|
|
9
10
|
export type OpError = {
|
|
10
11
|
ok: false;
|
|
11
12
|
code: "not_found" | "conflict" | "blocked" | "validation" | "internal" | "unauthorized";
|
|
12
13
|
message: string;
|
|
13
14
|
fields?: FieldError[];
|
|
15
|
+
/** A `conflict` from the token guard (spec 2026-09-26 concurrency §2): the draft moved since the client loaded it. */
|
|
16
|
+
stale?: true;
|
|
17
|
+
/** Who wrote it last and when — the freeze notice's two facts. */
|
|
18
|
+
by?: string | null;
|
|
19
|
+
at?: number;
|
|
14
20
|
};
|
|
15
21
|
export type OpResult<T> = {
|
|
16
22
|
ok: true;
|
|
@@ -98,6 +104,11 @@ export type MembersListResult = {
|
|
|
98
104
|
members: MemberRecord[];
|
|
99
105
|
consoleUrl: string | null;
|
|
100
106
|
};
|
|
107
|
+
/** `database` null = no smoodly_meta table (older than v1). Behind blocks the shell; ahead is silent (spec 2026-09-26 §5). */
|
|
108
|
+
export type SchemaStatus = {
|
|
109
|
+
database: number | null;
|
|
110
|
+
required: number;
|
|
111
|
+
};
|
|
101
112
|
export type AdminOps = {
|
|
102
113
|
pages: {
|
|
103
114
|
/** Lists every node, materializing fixed pages and collection mounts (in every supported locale) first. */
|
|
@@ -105,14 +116,16 @@ export type AdminOps = {
|
|
|
105
116
|
/** not_found only when the page is missing; an absent locale comes back with `draft: null`. */
|
|
106
117
|
get(id: string, locale: string): Promise<OpResult<PageGetResult>>;
|
|
107
118
|
create(input: PageCreateInput): Promise<OpResult<PageRecord>>;
|
|
108
|
-
|
|
119
|
+
/** `expected` is the token the client last saw (spec 2026-09-26 concurrency §1); `undefined` skips the check — the admin always sends one. */
|
|
120
|
+
saveDraft(id: string, locale: string, tree: PageTree, expected?: DraftToken | null): Promise<OpResult<{
|
|
109
121
|
versionId: string;
|
|
122
|
+
draft: DraftToken | null;
|
|
110
123
|
}>>;
|
|
111
|
-
publish(id: string, locale: string): Promise<OpResult<PageRecord>>;
|
|
124
|
+
publish(id: string, locale: string, expected?: DraftToken | null): Promise<OpResult<PageRecord>>;
|
|
112
125
|
/** Takes the locale off the site; status draft, pointers kept. Never the site root or a fixed node (spec 2026-09-13 publish flow §5). */
|
|
113
126
|
unpublish(id: string, locale: string): Promise<OpResult<PageRecord>>;
|
|
114
127
|
/** The draft back to the published version (§4); `blocked` when nothing is published in the locale. */
|
|
115
|
-
discardDraft(id: string, locale: string): Promise<OpResult<PageRecord>>;
|
|
128
|
+
discardDraft(id: string, locale: string, expected?: DraftToken | null): Promise<OpResult<PageRecord>>;
|
|
116
129
|
delete(id: string): Promise<OpResult<null>>;
|
|
117
130
|
rename(id: string, patch: {
|
|
118
131
|
locale: string;
|
|
@@ -134,7 +147,7 @@ export type AdminOps = {
|
|
|
134
147
|
/** The locale's versions newest first, without their trees. */
|
|
135
148
|
listVersions(id: string, locale: string): Promise<OpResult<VersionSummary[]>>;
|
|
136
149
|
/** The version's tree becomes the draft (spec 2026-09-13 history §6); `not_found` for a version of another page or locale, `blocked` on a folder. */
|
|
137
|
-
restore(id: string, locale: string, versionId: string): Promise<OpResult<PageRecord>>;
|
|
150
|
+
restore(id: string, locale: string, versionId: string, expected?: DraftToken | null): Promise<OpResult<PageRecord>>;
|
|
138
151
|
};
|
|
139
152
|
entries: {
|
|
140
153
|
list(collection: string): Promise<OpResult<EntryRecord[]>>;
|
|
@@ -145,10 +158,11 @@ export type AdminOps = {
|
|
|
145
158
|
/** not_found only when the entry is missing; an absent locale comes back with `fields: null`. */
|
|
146
159
|
get(collection: string, id: string, locale: string): Promise<OpResult<EntryGetResult>>;
|
|
147
160
|
create(collection: string, input: EntryCreateInput): Promise<OpResult<EntryRecord>>;
|
|
148
|
-
|
|
149
|
-
|
|
161
|
+
/** `expected` as on pages: the token the client last saw; `undefined` skips the check. */
|
|
162
|
+
save(collection: string, id: string, locale: string, fields: Record<string, unknown>, expected?: DraftToken | null): Promise<OpResult<EntryRecord>>;
|
|
163
|
+
setStatus(collection: string, id: string, locale: string, status: "draft" | "published", expected?: DraftToken | null): Promise<OpResult<EntryRecord>>;
|
|
150
164
|
/** The live row back to the published snapshot (§4); `blocked` when the locale has no published version. */
|
|
151
|
-
discardDraft(collection: string, id: string, locale: string): Promise<OpResult<EntryRecord>>;
|
|
165
|
+
discardDraft(collection: string, id: string, locale: string, expected?: DraftToken | null): Promise<OpResult<EntryRecord>>;
|
|
152
166
|
delete(collection: string, id: string): Promise<OpResult<null>>;
|
|
153
167
|
move(collection: string, id: string, to: {
|
|
154
168
|
parentId: string | null;
|
|
@@ -165,19 +179,20 @@ export type AdminOps = {
|
|
|
165
179
|
/** One snapshot's fields; `not_found` unless it belongs to `id`. */
|
|
166
180
|
getVersion(collection: string, id: string, versionId: string): Promise<OpResult<EntryVersionResult>>;
|
|
167
181
|
/** The snapshot becomes the draft (§6); `not_found` for a foreign version. Fans out like a save. */
|
|
168
|
-
restore(collection: string, id: string, locale: string, versionId: string): Promise<OpResult<EntryRecord>>;
|
|
182
|
+
restore(collection: string, id: string, locale: string, versionId: string, expected?: DraftToken | null): Promise<OpResult<EntryRecord>>;
|
|
169
183
|
};
|
|
170
184
|
globals: {
|
|
171
185
|
/** Materializes every registered item in every supported locale, then lists them in registry order. */
|
|
172
186
|
list(): Promise<OpResult<GlobalListItem[]>>;
|
|
173
187
|
get(name: string, locale: string): Promise<OpResult<GlobalGetResult>>;
|
|
174
|
-
|
|
175
|
-
|
|
188
|
+
/** `expected` as on pages: the token the client last saw; `undefined` skips the check. */
|
|
189
|
+
save(name: string, locale: string, fields: Record<string, unknown>, expected?: DraftToken | null): Promise<OpResult<EntryRecord>>;
|
|
190
|
+
setStatus(name: string, locale: string, status: "draft" | "published", expected?: DraftToken | null): Promise<OpResult<EntryRecord>>;
|
|
176
191
|
/** The item's live row back to its published snapshot (§4); `blocked` before the first publish. */
|
|
177
|
-
discardDraft(name: string, locale: string): Promise<OpResult<EntryRecord>>;
|
|
192
|
+
discardDraft(name: string, locale: string, expected?: DraftToken | null): Promise<OpResult<EntryRecord>>;
|
|
178
193
|
listVersions(name: string, locale: string): Promise<OpResult<VersionSummary[]>>;
|
|
179
194
|
getVersion(name: string, versionId: string): Promise<OpResult<EntryVersionResult>>;
|
|
180
|
-
restore(name: string, locale: string, versionId: string): Promise<OpResult<EntryRecord>>;
|
|
195
|
+
restore(name: string, locale: string, versionId: string, expected?: DraftToken | null): Promise<OpResult<EntryRecord>>;
|
|
181
196
|
};
|
|
182
197
|
assets: {
|
|
183
198
|
/** Ready assets, newest first, paged by cursor; `kind` and `search` filter. */
|
|
@@ -210,6 +225,10 @@ export type AdminOps = {
|
|
|
210
225
|
/** Admins only, self-host only; the same two guards. The Auth account stays. */
|
|
211
226
|
remove(userId: string): Promise<OpResult<null>>;
|
|
212
227
|
};
|
|
228
|
+
schema: {
|
|
229
|
+
/** Every member. The shell calls it once on mount and blocks when behind. */
|
|
230
|
+
status(): Promise<OpResult<SchemaStatus>>;
|
|
231
|
+
};
|
|
213
232
|
preview: {
|
|
214
233
|
enable(): Promise<OpResult<null>>;
|
|
215
234
|
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] },
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import type { AdminOpAction } from "../ops.ts";
|
|
2
2
|
import type { AdminRegistry } from "../serialize.ts";
|
|
3
3
|
import { type AuthConfig } from "./session.ts";
|
|
4
|
-
|
|
4
|
+
import type { ContentConfig } from "../../env.ts";
|
|
5
|
+
export declare function AdminApp({ registry, op, auth, content }: {
|
|
5
6
|
registry: AdminRegistry;
|
|
6
7
|
op: AdminOpAction;
|
|
7
8
|
auth: AuthConfig;
|
|
9
|
+
content?: ContentConfig;
|
|
8
10
|
}): import("react").JSX.Element;
|
|
@@ -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,7 +34,13 @@ 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";
|
|
36
|
-
|
|
37
|
+
import { TimeZoneProvider } from "../forms/time-zone-context.js";
|
|
38
|
+
import { SchemaNotice } from "./SchemaNotice.js";
|
|
39
|
+
import { schemaBehind } from "../../schema-version.js";
|
|
40
|
+
import { createPresence } from "./presence.js";
|
|
41
|
+
import { connectRealtime } from "./presence-realtime.js";
|
|
42
|
+
import { PresenceProvider } from "./presence-context.js";
|
|
43
|
+
export function AdminApp({ registry, op, auth, content }) {
|
|
37
44
|
const session = useAdminSession(auth);
|
|
38
45
|
const [ops] = useState(() => makeClientOps(op, session.token));
|
|
39
46
|
const route = useRoute(registry.adminPath);
|
|
@@ -57,7 +64,38 @@ export function AdminApp({ registry, op, auth }) {
|
|
|
57
64
|
setRole("editor"); });
|
|
58
65
|
return () => { live = false; };
|
|
59
66
|
}, [signedIn, ops]);
|
|
67
|
+
// The one version check (spec 2026-09-26 §5): behind blocks the shell,
|
|
68
|
+
// equal or ahead renders as usual. Unknown until it answers; a failed
|
|
69
|
+
// call renders as usual too — the notice is for a known-behind schema.
|
|
70
|
+
const [schema, setSchema] = useState(null);
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
if (!signedIn) {
|
|
73
|
+
setSchema(null);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
let live = true;
|
|
77
|
+
ops.schema.status()
|
|
78
|
+
.then((r) => { if (live && r.ok)
|
|
79
|
+
setSchema(r.data); })
|
|
80
|
+
.catch(() => { });
|
|
81
|
+
return () => { live = false; };
|
|
82
|
+
}, [signedIn, ops]);
|
|
60
83
|
const userId = session.state.status === "signed_in" ? session.state.session.user.id : "";
|
|
84
|
+
// Presence (spec 2026-09-26 concurrency §5): one channel per signed-in session,
|
|
85
|
+
// joined with the content project's public config; absent config = presence off.
|
|
86
|
+
const [presence, setPresence] = useState(null);
|
|
87
|
+
const contentUrl = content?.url, contentKey = content?.key, contentSpace = content?.spaceId;
|
|
88
|
+
useEffect(() => {
|
|
89
|
+
if (!signedIn || !contentUrl || !contentKey || !contentSpace || !email) {
|
|
90
|
+
setPresence(null);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
const config = { url: contentUrl, key: contentKey, spaceId: contentSpace };
|
|
94
|
+
const p = createPresence({ email, connect: () => connectRealtime(config, email) });
|
|
95
|
+
p.join();
|
|
96
|
+
setPresence(p);
|
|
97
|
+
return () => { p.dispose(); setPresence(null); };
|
|
98
|
+
}, [signedIn, contentUrl, contentKey, contentSpace, email]);
|
|
61
99
|
if (session.state.status === "loading") {
|
|
62
100
|
return (_jsx("div", { style: { height: "100vh", display: "grid", placeItems: "center", color: "var(--text-muted)" }, children: "Loading\u2026" }));
|
|
63
101
|
}
|
|
@@ -70,6 +108,8 @@ export function AdminApp({ registry, op, auth }) {
|
|
|
70
108
|
// An invite or reset link signed the person in: Set password before the shell (members management §6).
|
|
71
109
|
if (session.arrival)
|
|
72
110
|
return _jsx(SetPasswordView, { client: session.client, mode: session.arrival, onDone: session.clearArrival });
|
|
111
|
+
if (schema && schemaBehind(schema.database, schema.required))
|
|
112
|
+
return _jsx(SchemaNotice, { status: schema });
|
|
73
113
|
let main;
|
|
74
114
|
if (head === "pages" && !tail)
|
|
75
115
|
main = _jsx(PagesList, { ops: ops, registry: registry });
|
|
@@ -89,5 +129,5 @@ export function AdminApp({ registry, op, auth }) {
|
|
|
89
129
|
main = _jsx(MediaScreen, {});
|
|
90
130
|
else
|
|
91
131
|
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 ?? ""}`)] }) }) }) }));
|
|
132
|
+
return (_jsx(AdminPathProvider, { value: registry.adminPath, children: _jsx(PresenceProvider, { value: presence, 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
133
|
}
|