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.
Files changed (148) hide show
  1. package/README.md +214 -40
  2. package/bin/smoodly.js +6 -0
  3. package/dist/admin/client-ops.js +1 -0
  4. package/dist/admin/editor/EditorView.js +100 -22
  5. package/dist/admin/editor/InsertPicker.d.ts +14 -0
  6. package/dist/admin/editor/InsertPicker.js +37 -7
  7. package/dist/admin/editor/InspectorFields.js +1 -1
  8. package/dist/admin/editor/Outline.js +2 -2
  9. package/dist/admin/editor/PageSettings.js +1 -1
  10. package/dist/admin/editor/SeoSettings.js +1 -1
  11. package/dist/admin/editor/autosave.d.ts +4 -1
  12. package/dist/admin/editor/autosave.js +24 -1
  13. package/dist/admin/editor/global-edit.d.ts +5 -1
  14. package/dist/admin/editor/global-edit.js +15 -5
  15. package/dist/admin/editor/locale-url.d.ts +7 -4
  16. package/dist/admin/editor/locale-url.js +14 -7
  17. package/dist/admin/editor/useGlobalEdit.d.ts +1 -1
  18. package/dist/admin/editor/useGlobalEdit.js +1 -1
  19. package/dist/admin/editor/usePageLocale.js +9 -1
  20. package/dist/admin/forms/CropEditor.d.ts +4 -2
  21. package/dist/admin/forms/CropEditor.js +6 -3
  22. package/dist/admin/forms/DatePicker.d.ts +9 -0
  23. package/dist/admin/forms/DatePicker.js +133 -0
  24. package/dist/admin/forms/FieldWidget.js +11 -5
  25. package/dist/admin/forms/ImagePreview.d.ts +9 -0
  26. package/dist/admin/forms/ImagePreview.js +11 -0
  27. package/dist/admin/forms/MediaWidget.d.ts +8 -2
  28. package/dist/admin/forms/MediaWidget.js +13 -13
  29. package/dist/admin/forms/RichtextElementCard.js +1 -1
  30. package/dist/admin/forms/RichtextImageCard.js +14 -9
  31. package/dist/admin/forms/date-picker.d.ts +44 -0
  32. package/dist/admin/forms/date-picker.js +117 -0
  33. package/dist/admin/forms/elements-context.d.ts +3 -0
  34. package/dist/admin/forms/elements-context.js +7 -2
  35. package/dist/admin/forms/time-zone-context.d.ts +7 -0
  36. package/dist/admin/forms/time-zone-context.js +15 -0
  37. package/dist/admin/forms/useRichtextEditor.js +10 -1
  38. package/dist/admin/next/bridge.d.ts +2 -1
  39. package/dist/admin/next/bridge.js +35 -5
  40. package/dist/admin/next/create-admin.d.ts +4 -0
  41. package/dist/admin/next/create-admin.js +3 -1
  42. package/dist/admin/next/index.d.ts +1 -0
  43. package/dist/admin/ops-impl.js +74 -24
  44. package/dist/admin/ops.d.ts +31 -12
  45. package/dist/admin/serialize.d.ts +2 -0
  46. package/dist/admin/serialize.js +1 -0
  47. package/dist/admin/shell/AdminApp.d.ts +3 -1
  48. package/dist/admin/shell/AdminApp.js +42 -2
  49. package/dist/admin/shell/EntriesList.js +17 -22
  50. package/dist/admin/shell/EntryForm.js +91 -13
  51. package/dist/admin/shell/GlobalForm.js +68 -9
  52. package/dist/admin/shell/GlobalList.js +10 -20
  53. package/dist/admin/shell/IconRail.js +14 -5
  54. package/dist/admin/shell/InviteModal.js +9 -5
  55. package/dist/admin/shell/ListView.js +2 -2
  56. package/dist/admin/shell/MediaScreen.js +11 -1
  57. package/dist/admin/shell/MembersPage.js +33 -15
  58. package/dist/admin/shell/PageDialogs.d.ts +26 -0
  59. package/dist/admin/shell/PageDialogs.js +38 -0
  60. package/dist/admin/shell/PagesList.js +101 -74
  61. package/dist/admin/shell/RouteLink.js +1 -1
  62. package/dist/admin/shell/SchemaNotice.d.ts +4 -0
  63. package/dist/admin/shell/SchemaNotice.js +4 -0
  64. package/dist/admin/shell/SettingsView.js +11 -26
  65. package/dist/admin/shell/live-url.d.ts +4 -3
  66. package/dist/admin/shell/live-url.js +12 -3
  67. package/dist/admin/shell/locale-memory.d.ts +8 -0
  68. package/dist/admin/shell/locale-memory.js +23 -0
  69. package/dist/admin/shell/members-list.d.ts +2 -0
  70. package/dist/admin/shell/members-list.js +5 -0
  71. package/dist/admin/shell/page-dialogs.d.ts +9 -0
  72. package/dist/admin/shell/page-dialogs.js +19 -0
  73. package/dist/admin/shell/presence-context.d.ts +9 -0
  74. package/dist/admin/shell/presence-context.js +35 -0
  75. package/dist/admin/shell/presence-realtime.d.ts +3 -0
  76. package/dist/admin/shell/presence-realtime.js +22 -0
  77. package/dist/admin/shell/presence.d.ts +51 -0
  78. package/dist/admin/shell/presence.js +90 -0
  79. package/dist/admin/shell/router.d.ts +10 -7
  80. package/dist/admin/shell/router.js +18 -9
  81. package/dist/admin/shell/settings.d.ts +0 -6
  82. package/dist/admin/shell/settings.js +4 -5
  83. package/dist/admin/shell/stale.d.ts +28 -0
  84. package/dist/admin/shell/stale.js +17 -0
  85. package/dist/admin/tree-ops.js +3 -0
  86. package/dist/admin/ui/ActionMenu.js +26 -6
  87. package/dist/admin/ui/Combobox.d.ts +5 -0
  88. package/dist/admin/ui/Combobox.js +1 -1
  89. package/dist/admin/ui/ConfirmDialog.d.ts +8 -0
  90. package/dist/admin/ui/ConfirmDialog.js +30 -0
  91. package/dist/admin/ui/Dialog.d.ts +13 -0
  92. package/dist/admin/ui/Dialog.js +54 -0
  93. package/dist/admin/ui/HistoryModal.d.ts +2 -1
  94. package/dist/admin/ui/HistoryModal.js +7 -2
  95. package/dist/admin/ui/LockIcon.d.ts +4 -0
  96. package/dist/admin/ui/LockIcon.js +12 -0
  97. package/dist/admin/ui/PresenceLine.d.ts +6 -0
  98. package/dist/admin/ui/PresenceLine.js +11 -0
  99. package/dist/admin/ui/ResizeHandle.js +4 -4
  100. package/dist/admin/ui/StaleBanner.d.ts +5 -0
  101. package/dist/admin/ui/StaleBanner.js +9 -0
  102. package/dist/admin/ui/confirm.d.ts +35 -0
  103. package/dist/admin/ui/confirm.js +57 -0
  104. package/dist/admin/ui/primitives.d.ts +19 -4
  105. package/dist/admin/ui/primitives.js +12 -5
  106. package/dist/admin/ui/resize.d.ts +7 -4
  107. package/dist/admin/ui/resize.js +7 -4
  108. package/dist/admin/ui/theme.d.ts +1 -1
  109. package/dist/admin/ui/theme.js +373 -204
  110. package/dist/admin/validate.d.ts +4 -0
  111. package/dist/admin/validate.js +28 -0
  112. package/dist/cli/load-collections.d.ts +2 -0
  113. package/dist/cli/load-collections.js +26 -0
  114. package/dist/cli/main.d.ts +13 -0
  115. package/dist/cli/main.js +62 -0
  116. package/dist/cli/migrate.d.ts +23 -0
  117. package/dist/cli/migrate.js +64 -0
  118. package/dist/config.d.ts +4 -0
  119. package/dist/config.js +9 -0
  120. package/dist/draft-token.d.ts +31 -0
  121. package/dist/draft-token.js +41 -0
  122. package/dist/entry-store.d.ts +11 -8
  123. package/dist/entry-store.js +24 -10
  124. package/dist/env.d.ts +9 -0
  125. package/dist/env.js +19 -0
  126. package/dist/fields.d.ts +2 -0
  127. package/dist/fields.js +2 -0
  128. package/dist/index.d.ts +6 -3
  129. package/dist/index.js +4 -2
  130. package/dist/marks.d.ts +3 -0
  131. package/dist/marks.js +3 -0
  132. package/dist/migrations.d.ts +37 -0
  133. package/dist/migrations.js +73 -0
  134. package/dist/schema-version.d.ts +17 -0
  135. package/dist/schema-version.js +32 -0
  136. package/dist/sql-space.js +17 -0
  137. package/dist/sql.js +21 -0
  138. package/dist/store.d.ts +14 -8
  139. package/dist/store.js +30 -8
  140. package/dist/supabase-entry-store.d.ts +15 -5
  141. package/dist/supabase-entry-store.js +87 -22
  142. package/dist/supabase-store.d.ts +18 -5
  143. package/dist/supabase-store.js +83 -20
  144. package/dist/time-zone.d.ts +6 -0
  145. package/dist/time-zone.js +27 -0
  146. package/migrations/20260926120000_smoodly_v1.sql +535 -0
  147. package/migrations/20260926130000_smoodly_v2.sql +26 -0
  148. 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. Rects are in document coordinates: the iframe is sized
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 nodes = all(`[${NODE_ATTR}]`).map((el) => ({ id: el.getAttribute(NODE_ATTR), ...docRect(el) }));
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
- post({ source: "smoodly", kind: "rects", nodes, emptyZones, fields, docH: document.documentElement.scrollHeight });
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 null;
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";
@@ -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
- return ok({ versionId: (await pages.saveDraft(id, locale, tree, by)).id });
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 restored = await entries.discardDraft(collection, id, locale, by);
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))),
@@ -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
- saveDraft(id: string, locale: string, tree: PageTree): Promise<OpResult<{
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
- save(collection: string, id: string, locale: string, fields: Record<string, unknown>): Promise<OpResult<EntryRecord>>;
149
- setStatus(collection: string, id: string, locale: string, status: "draft" | "published"): Promise<OpResult<EntryRecord>>;
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
- save(name: string, locale: string, fields: Record<string, unknown>): Promise<OpResult<EntryRecord>>;
175
- setStatus(name: string, locale: string, status: "draft" | "published"): Promise<OpResult<EntryRecord>>;
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. */
@@ -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
- export declare function AdminApp({ registry, op, auth }: {
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
- export function AdminApp({ registry, op, auth }) {
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
  }