smoodly 0.0.51 → 0.0.52

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 (285) hide show
  1. package/README.md +14 -13
  2. package/dist/admin/auth-config.d.ts +6 -5
  3. package/dist/admin/auth.d.ts +3 -11
  4. package/dist/admin/auth.js +1 -30
  5. package/dist/admin/browser/fetch-op.js +3 -2
  6. package/dist/admin/browser/main.js +6 -2
  7. package/dist/admin/client-ops.d.ts +5 -4
  8. package/dist/admin/client-ops.js +14 -11
  9. package/dist/admin/delivery-mode.d.ts +1 -1
  10. package/dist/admin/delivery-mode.js +2 -2
  11. package/dist/admin/drift.d.ts +0 -2
  12. package/dist/admin/drift.js +1 -1
  13. package/dist/admin/editor/EditorView.d.ts +0 -3
  14. package/dist/admin/editor/EditorView.js +11 -16
  15. package/dist/admin/editor/FloatingToolbar.d.ts +0 -1
  16. package/dist/admin/editor/FloatingToolbar.js +1 -1
  17. package/dist/admin/editor/SeoSettings.d.ts +0 -2
  18. package/dist/admin/editor/SeoSettings.js +2 -2
  19. package/dist/admin/editor/canvas-address.d.ts +8 -0
  20. package/dist/admin/editor/canvas-address.js +18 -2
  21. package/dist/admin/editor/canvas-diagnosis.d.ts +2 -0
  22. package/dist/admin/editor/canvas-diagnosis.js +3 -0
  23. package/dist/admin/editor/inline-dom.d.ts +0 -4
  24. package/dist/admin/editor/inline-dom.js +1 -1
  25. package/dist/admin/editor/preview-grant.d.ts +6 -0
  26. package/dist/admin/editor/preview-grant.js +25 -0
  27. package/dist/admin/editor/useCanvasDiagnosis.js +2 -2
  28. package/dist/admin/files.generated.js +1 -1
  29. package/dist/admin/fixed-nodes.d.ts +0 -6
  30. package/dist/admin/fixed-nodes.js +1 -81
  31. package/dist/admin/forms/CropEditor.d.ts +4 -2
  32. package/dist/admin/forms/CropEditor.js +58 -5
  33. package/dist/admin/forms/ImagePreview.d.ts +3 -1
  34. package/dist/admin/forms/ImagePreview.js +9 -3
  35. package/dist/admin/forms/MediaGrid.js +2 -1
  36. package/dist/admin/forms/MediaPicker.js +9 -6
  37. package/dist/admin/forms/MediaWidget.js +11 -9
  38. package/dist/admin/forms/assets-context.d.ts +7 -3
  39. package/dist/admin/forms/assets-context.js +5 -3
  40. package/dist/admin/forms/crop-editor.d.ts +24 -2
  41. package/dist/admin/forms/crop-editor.js +36 -2
  42. package/dist/admin/forms/date-picker.d.ts +0 -1
  43. package/dist/admin/forms/date-picker.js +1 -1
  44. package/dist/admin/forms/tabs.d.ts +0 -1
  45. package/dist/admin/forms/tabs.js +1 -1
  46. package/dist/admin/forms/upload.d.ts +18 -11
  47. package/dist/admin/forms/upload.js +62 -24
  48. package/dist/admin/global-items.d.ts +0 -3
  49. package/dist/admin/global-items.js +0 -54
  50. package/dist/admin/handler.d.ts +22 -28
  51. package/dist/admin/handler.js +146 -47
  52. package/dist/admin/health.d.ts +0 -4
  53. package/dist/admin/index.d.ts +5 -8
  54. package/dist/admin/index.js +3 -6
  55. package/dist/admin/ops.d.ts +36 -22
  56. package/dist/admin/page-data.d.ts +14 -6
  57. package/dist/admin/page-data.js +1 -1
  58. package/dist/admin/session-flow.d.ts +43 -0
  59. package/dist/admin/session-flow.js +94 -0
  60. package/dist/admin/shell/AdminApp.d.ts +8 -3
  61. package/dist/admin/shell/AdminApp.js +37 -58
  62. package/dist/admin/shell/ContentHealthPage.js +1 -3
  63. package/dist/admin/shell/GlobalFields.d.ts +0 -3
  64. package/dist/admin/shell/GlobalFields.js +1 -1
  65. package/dist/admin/shell/IconRail.js +5 -4
  66. package/dist/admin/shell/MediaScreen.js +9 -6
  67. package/dist/admin/shell/MembersPage.d.ts +1 -2
  68. package/dist/admin/shell/MembersPage.js +19 -90
  69. package/dist/admin/shell/SettingsView.d.ts +1 -2
  70. package/dist/admin/shell/SettingsView.js +2 -2
  71. package/dist/admin/shell/SignedOutView.d.ts +5 -0
  72. package/dist/admin/shell/SignedOutView.js +9 -0
  73. package/dist/admin/shell/console-links.d.ts +5 -0
  74. package/dist/admin/shell/console-links.js +11 -0
  75. package/dist/admin/shell/members-list.d.ts +1 -14
  76. package/dist/admin/shell/members-list.js +2 -23
  77. package/dist/admin/shell/pages-tree.d.ts +0 -2
  78. package/dist/admin/shell/pages-tree.js +1 -1
  79. package/dist/admin/shell/presence-socket.d.ts +10 -0
  80. package/dist/admin/shell/presence-socket.js +191 -0
  81. package/dist/admin/shell/presence.d.ts +6 -18
  82. package/dist/admin/shell/presence.js +4 -11
  83. package/dist/admin/shell/session.d.ts +20 -12
  84. package/dist/admin/shell/session.js +113 -47
  85. package/dist/admin/ui/LocaleSwitcher.d.ts +0 -4
  86. package/dist/admin/ui/LocaleSwitcher.js +2 -2
  87. package/dist/admin/ui/primitives.d.ts +4 -14
  88. package/dist/admin/ui/primitives.js +5 -8
  89. package/dist/admin/ui/theme.js +13 -2
  90. package/dist/admin/validate.js +10 -1
  91. package/dist/api-client.d.ts +26 -0
  92. package/dist/api-client.js +107 -0
  93. package/dist/assemble.d.ts +4 -41
  94. package/dist/assemble.js +4 -302
  95. package/dist/asset-store.d.ts +28 -30
  96. package/dist/asset-store.js +26 -121
  97. package/dist/astro/components/Head.astro +4 -1
  98. package/dist/astro/components/Image.astro +12 -17
  99. package/dist/astro/components/Video.astro +5 -2
  100. package/dist/astro/head.d.ts +3 -0
  101. package/dist/astro/head.js +24 -0
  102. package/dist/astro/index.js +54 -25
  103. package/dist/astro/locale-paths.d.ts +5 -2
  104. package/dist/astro/locale-paths.js +10 -2
  105. package/dist/astro/routes/admin.js +9 -7
  106. package/dist/astro/runtime.d.ts +2 -1
  107. package/dist/astro/runtime.js +4 -3
  108. package/dist/astro/server.d.ts +7 -11
  109. package/dist/astro/server.js +22 -28
  110. package/dist/astro/shell.d.ts +2 -2
  111. package/dist/astro/static-media-node.d.ts +4 -0
  112. package/dist/astro/static-media-node.js +25 -0
  113. package/dist/astro/static-media.d.ts +17 -0
  114. package/dist/astro/static-media.js +55 -0
  115. package/dist/astro/static.d.ts +5 -10
  116. package/dist/astro/static.js +13 -29
  117. package/dist/canvas-marker.d.ts +14 -0
  118. package/dist/canvas-marker.js +36 -0
  119. package/dist/cli/api.d.ts +14 -0
  120. package/dist/cli/api.js +48 -0
  121. package/dist/cli/app-env.d.ts +2 -0
  122. package/dist/cli/app-env.js +20 -0
  123. package/dist/cli/export.d.ts +16 -2
  124. package/dist/cli/export.js +52 -35
  125. package/dist/cli/import.d.ts +7 -11
  126. package/dist/cli/import.js +50 -54
  127. package/dist/cli/load-config.d.ts +0 -1
  128. package/dist/cli/load-config.js +1 -1
  129. package/dist/cli/main.d.ts +4 -12
  130. package/dist/cli/main.js +26 -72
  131. package/dist/cli/push.d.ts +6 -0
  132. package/dist/cli/push.js +31 -0
  133. package/dist/collections.d.ts +0 -1
  134. package/dist/collections.js +1 -1
  135. package/dist/config.d.ts +20 -5
  136. package/dist/config.js +18 -1
  137. package/dist/content/content-api.d.ts +22 -23
  138. package/dist/content/content-api.js +46 -56
  139. package/dist/content/mime.d.ts +1 -0
  140. package/dist/content/mime.js +6 -0
  141. package/dist/deploy-store.d.ts +19 -0
  142. package/dist/deploy-store.js +1 -0
  143. package/dist/entry-store.d.ts +3 -89
  144. package/dist/entry-store.js +2 -550
  145. package/dist/env.d.ts +27 -20
  146. package/dist/env.js +38 -50
  147. package/dist/fields.d.ts +11 -3
  148. package/dist/fields.js +33 -2
  149. package/dist/global-id.d.ts +2 -0
  150. package/dist/global-id.js +4 -2
  151. package/dist/image/SmoodlyImage.d.ts +9 -5
  152. package/dist/image/SmoodlyImage.js +29 -15
  153. package/dist/image/SmoodlyVideo.d.ts +1 -1
  154. package/dist/image/SmoodlyVideo.js +3 -2
  155. package/dist/image/index.d.ts +2 -3
  156. package/dist/image/index.js +0 -2
  157. package/dist/image/markup.d.ts +53 -0
  158. package/dist/image/markup.js +111 -0
  159. package/dist/index.d.ts +12 -22
  160. package/dist/index.js +7 -20
  161. package/dist/media-cut.d.ts +44 -0
  162. package/dist/media-cut.js +70 -0
  163. package/dist/media.d.ts +57 -3
  164. package/dist/media.js +125 -15
  165. package/dist/member-store.d.ts +3 -22
  166. package/dist/member-store.js +1 -53
  167. package/dist/next/admin-route.d.ts +1 -1
  168. package/dist/next/admin-route.js +5 -3
  169. package/dist/next/meta.js +2 -3
  170. package/dist/next/page-renderer.d.ts +2 -2
  171. package/dist/next/page-renderer.js +22 -11
  172. package/dist/next/queries.d.ts +2 -2
  173. package/dist/path-index.d.ts +0 -24
  174. package/dist/path-index.js +0 -98
  175. package/dist/push.d.ts +30 -0
  176. package/dist/push.js +52 -0
  177. package/dist/read-counter.d.ts +2 -0
  178. package/dist/read-counter.js +6 -0
  179. package/dist/reads.d.ts +7 -7
  180. package/dist/reads.js +3 -0
  181. package/dist/registry-json.d.ts +11 -0
  182. package/dist/registry-json.js +81 -0
  183. package/dist/render.d.ts +0 -1
  184. package/dist/render.js +1 -1
  185. package/dist/resolve.d.ts +12 -6
  186. package/dist/resolve.js +11 -8
  187. package/dist/revalidate.d.ts +8 -1
  188. package/dist/revalidate.js +8 -1
  189. package/dist/richtext/index.d.ts +1 -1
  190. package/dist/section-wrapper.d.ts +1 -1
  191. package/dist/section-wrapper.js +0 -9
  192. package/dist/signed-message.d.ts +26 -0
  193. package/dist/signed-message.js +103 -0
  194. package/dist/site.d.ts +59 -18
  195. package/dist/site.js +21 -252
  196. package/dist/space-transfer.d.ts +5 -2
  197. package/dist/space-transfer.js +17 -5
  198. package/dist/store.d.ts +2 -80
  199. package/dist/store.js +3 -489
  200. package/dist/toolset.d.ts +2 -1
  201. package/dist/toolset.js +1 -1
  202. package/dist/version.d.ts +1 -1
  203. package/dist/version.js +1 -1
  204. package/package.json +4 -15
  205. package/dist/admin/ops-impl.d.ts +0 -51
  206. package/dist/admin/ops-impl.js +0 -1403
  207. package/dist/admin/shell/InviteModal.d.ts +0 -6
  208. package/dist/admin/shell/InviteModal.js +0 -28
  209. package/dist/admin/shell/LoginView.d.ts +0 -5
  210. package/dist/admin/shell/LoginView.js +0 -80
  211. package/dist/admin/shell/SchemaNotice.d.ts +0 -4
  212. package/dist/admin/shell/SchemaNotice.js +0 -4
  213. package/dist/admin/shell/SetPasswordView.d.ts +0 -7
  214. package/dist/admin/shell/SetPasswordView.js +0 -41
  215. package/dist/admin/shell/presence-realtime.d.ts +0 -3
  216. package/dist/admin/shell/presence-realtime.js +0 -32
  217. package/dist/admin/supabase-auth.d.ts +0 -7
  218. package/dist/admin/supabase-auth.js +0 -30
  219. package/dist/astro/deploy-deps.d.ts +0 -10
  220. package/dist/astro/deploy-deps.js +0 -8
  221. package/dist/astro/image.d.ts +0 -27
  222. package/dist/astro/image.js +0 -29
  223. package/dist/bind.d.ts +0 -19
  224. package/dist/bind.js +0 -34
  225. package/dist/cli/add-admin.d.ts +0 -11
  226. package/dist/cli/add-admin.js +0 -25
  227. package/dist/cli/load-collections.d.ts +0 -2
  228. package/dist/cli/load-collections.js +0 -27
  229. package/dist/cli/migrate.d.ts +0 -43
  230. package/dist/cli/migrate.js +0 -138
  231. package/dist/cli/rebuild.d.ts +0 -23
  232. package/dist/cli/rebuild.js +0 -22
  233. package/dist/cli/space-io.d.ts +0 -41
  234. package/dist/cli/space-io.js +0 -84
  235. package/dist/client.d.ts +0 -11
  236. package/dist/client.js +0 -54
  237. package/dist/current-space.d.ts +0 -4
  238. package/dist/current-space.js +0 -9
  239. package/dist/document-stamp.d.ts +0 -7
  240. package/dist/document-stamp.js +0 -48
  241. package/dist/image/loaders.d.ts +0 -15
  242. package/dist/image/loaders.js +0 -5
  243. package/dist/image/next-config.d.ts +0 -18
  244. package/dist/image/next-config.js +0 -32
  245. package/dist/image/remote-pattern.d.ts +0 -1
  246. package/dist/image/remote-pattern.js +0 -5
  247. package/dist/migrations.d.ts +0 -37
  248. package/dist/migrations.js +0 -73
  249. package/dist/project.d.ts +0 -88
  250. package/dist/project.js +0 -231
  251. package/dist/published.d.ts +0 -142
  252. package/dist/published.js +0 -164
  253. package/dist/ref-index.d.ts +0 -19
  254. package/dist/ref-index.js +0 -0
  255. package/dist/schema-read.d.ts +0 -23
  256. package/dist/schema-read.js +0 -50
  257. package/dist/schema-version.d.ts +0 -19
  258. package/dist/schema-version.js +0 -34
  259. package/dist/sql-space.d.ts +0 -44
  260. package/dist/sql-space.js +0 -289
  261. package/dist/sql.d.ts +0 -15
  262. package/dist/sql.js +0 -556
  263. package/dist/supabase-asset-store.d.ts +0 -21
  264. package/dist/supabase-asset-store.js +0 -170
  265. package/dist/supabase-entry-store.d.ts +0 -92
  266. package/dist/supabase-entry-store.js +0 -824
  267. package/dist/supabase-member-store.d.ts +0 -21
  268. package/dist/supabase-member-store.js +0 -96
  269. package/dist/supabase-path-index.d.ts +0 -30
  270. package/dist/supabase-path-index.js +0 -138
  271. package/dist/supabase-published-store.d.ts +0 -56
  272. package/dist/supabase-published-store.js +0 -314
  273. package/dist/supabase-store.d.ts +0 -103
  274. package/dist/supabase-store.js +0 -678
  275. package/migrations/20260926120000_smoodly_v1.sql +0 -535
  276. package/migrations/20260926130000_smoodly_v2.sql +0 -26
  277. package/migrations/20260926140000_smoodly_v3.sql +0 -38
  278. package/migrations/20260926150000_smoodly_v4.sql +0 -41
  279. package/migrations/20260929120000_smoodly_v5.sql +0 -56
  280. package/migrations/20260929135523_smoodly_v6.sql +0 -200
  281. package/migrations/20260930040956_smoodly_v7.sql +0 -84
  282. package/migrations/20261007120000_smoodly_v8.sql +0 -154
  283. package/migrations/20261007200000_smoodly_v9.sql +0 -30
  284. package/migrations/20261008120000_smoodly_v10.sql +0 -47
  285. package/migrations/20261009120000_smoodly_v11.sql +0 -49
@@ -8,8 +8,9 @@ import { useCallback, useEffect, useState } from "react";
8
8
  import { useAssets } from "../forms/assets-context.js";
9
9
  import { uploadFile } from "../forms/upload.js";
10
10
  import { MediaGrid } from "../forms/MediaGrid.js";
11
- import { Banner, Button, Field, Segmented, TopBar } from "../ui/primitives.js";
11
+ import { Banner, Button, Field, Segmented, TopBar, UploadProgress } from "../ui/primitives.js";
12
12
  import { confirm } from "../ui/confirm.js";
13
+ import { adminImageSrc } from "../forms/crop-editor.js";
13
14
  import { RouteLink } from "./RouteLink.js";
14
15
  import { KIND_FILTERS, acceptFor, formatBytes, kindOfFilter, usageLabel } from "./media-list.js";
15
16
  const META = ["title", "alt", "caption", "credit"];
@@ -23,7 +24,9 @@ export function MediaScreen({ initialItems = [], initialSelected = null, initial
23
24
  const [meta, setMeta] = useState({
24
25
  title: initialSelected?.title ?? "", alt: initialSelected?.alt ?? "", caption: initialSelected?.caption ?? "", credit: initialSelected?.credit ?? "",
25
26
  });
26
- const [busy, setBusy] = useState(false);
27
+ /** The fraction sent while a file uploads; null when none is. */
28
+ const [progress, setProgress] = useState(null);
29
+ const busy = progress !== null;
27
30
  const [error, setError] = useState(null);
28
31
  const load = useCallback(() => {
29
32
  assets.ops.list({ kind: kindOfFilter(filter), search: search || undefined })
@@ -58,10 +61,10 @@ export function MediaScreen({ initialItems = [], initialSelected = null, initial
58
61
  const file = files?.[0];
59
62
  if (!file)
60
63
  return;
61
- setBusy(true);
64
+ setProgress(0);
62
65
  setError(null);
63
- const r = await uploadFile(assets.ops, file, assets.upload);
64
- setBusy(false);
66
+ const r = await uploadFile(assets.upload.send, file, assets.upload.measure, setProgress);
67
+ setProgress(null);
65
68
  if (!r.ok) {
66
69
  setError(r.message);
67
70
  return;
@@ -90,7 +93,7 @@ export function MediaScreen({ initialItems = [], initialSelected = null, initial
90
93
  setSelected(null);
91
94
  load();
92
95
  };
93
- return (_jsxs(_Fragment, { children: [_jsx(TopBar, { left: _jsxs(_Fragment, { children: [_jsx("span", { className: "sm-title", children: "Media" }), _jsx("span", { className: "sm-count", children: items.length })] }), right: _jsxs("div", { className: "sm-picker-bar", children: [_jsx("input", { className: "sm-input", placeholder: "Search media", value: search, onChange: (e) => setSearch(e.target.value), style: { width: 220 } }), _jsx(Segmented, { options: KIND_FILTERS, value: filter, onChange: setFilter }), _jsxs("label", { className: "sm-btn sm-btn--primary", children: [busy ? "Uploading…" : "Upload", _jsx("input", { type: "file", accept: acceptFor(["image", "video", "file"], assets.media.accept), hidden: true, disabled: busy, onChange: (e) => { void onFiles(e.target.files); } })] })] }) }), error && _jsx(Banner, { tone: "error", onDismiss: () => setError(null), children: error }), _jsxs("div", { className: "sm-mediascreen", style: selected ? undefined : { gridTemplateColumns: "1fr" }, children: [_jsx("div", { className: "sm-mediascreen__grid", children: _jsx(MediaGrid, { items: items, selectedId: selected?.id, onSelect: (a) => setSelected(a.id === selected?.id ? null : a) }) }), selected && (_jsxs("aside", { className: "sm-mediascreen__inspector", children: [_jsx("div", { className: "sm-media__thumb", style: { aspectRatio: selected.width && selected.height ? `${selected.width} / ${selected.height}` : "4 / 3" }, children: selected.kind === "image" && _jsx("img", { src: selected.url, alt: "", style: { left: 0, top: 0, width: "100%", height: "100%" } }) }), META.map((key) => (_jsx(Field, { label: key === "alt" ? "Alt text" : key[0].toUpperCase() + key.slice(1), children: _jsx("input", { className: "sm-input", value: meta[key], onChange: (e) => setMeta((m) => ({ ...m, [key]: e.target.value })), onBlur: () => { void saveMeta(key); } }) }, key))), _jsxs("div", { className: "sm-facts", children: [_jsx("span", { children: "File" }), _jsx("span", { children: selected.filename }), _jsx("span", { children: "Type" }), _jsx("span", { children: selected.mime }), _jsx("span", { children: "Size" }), _jsx("span", { children: formatBytes(selected.size) }), selected.width && selected.height && _jsxs(_Fragment, { children: [_jsx("span", { children: "Dimensions" }), _jsxs("span", { children: [selected.width, "\u00D7", selected.height] })] }), selected.duration !== null && _jsxs(_Fragment, { children: [_jsx("span", { children: "Duration" }), _jsxs("span", { children: [Math.round(selected.duration), " s"] })] })] }), _jsxs("div", { className: "sm-usage", children: [_jsx("span", { className: "sm-label", children: usageLabel(usage.length) }), usage.map((u, i) => u.href
96
+ return (_jsxs(_Fragment, { children: [_jsx(TopBar, { left: _jsxs(_Fragment, { children: [_jsx("span", { className: "sm-title", children: "Media" }), _jsx("span", { className: "sm-count", children: items.length })] }), right: _jsxs("div", { className: "sm-picker-bar", children: [_jsx("input", { className: "sm-input", placeholder: "Search media", value: search, onChange: (e) => setSearch(e.target.value), style: { width: 220 } }), _jsx(Segmented, { options: KIND_FILTERS, value: filter, onChange: setFilter }), _jsxs("label", { className: "sm-btn sm-btn--primary", children: [busy ? "Uploading…" : "Upload", _jsx("input", { type: "file", accept: acceptFor(["image", "video", "file"], assets.media.accept), hidden: true, disabled: busy, onChange: (e) => { void onFiles(e.target.files); } })] })] }) }), error && _jsx(Banner, { tone: "error", onDismiss: () => setError(null), children: error }), progress !== null && _jsx("div", { className: "sm-mediascreen__upload", children: _jsx(UploadProgress, { fraction: progress }) }), _jsxs("div", { className: "sm-mediascreen", style: selected ? undefined : { gridTemplateColumns: "1fr" }, children: [_jsx("div", { className: "sm-mediascreen__grid", children: _jsx(MediaGrid, { items: items, selectedId: selected?.id, onSelect: (a) => setSelected(a.id === selected?.id ? null : a) }) }), selected && (_jsxs("aside", { className: "sm-mediascreen__inspector", children: [_jsx("div", { className: "sm-media__thumb", style: { aspectRatio: selected.width && selected.height ? `${selected.width} / ${selected.height}` : "4 / 3" }, children: selected.kind === "image" && _jsx("img", { src: adminImageSrc(selected, 640), alt: "", style: { left: 0, top: 0, width: "100%", height: "100%" } }) }), META.map((key) => (_jsx(Field, { label: key === "alt" ? "Alt text" : key[0].toUpperCase() + key.slice(1), children: _jsx("input", { className: "sm-input", value: meta[key], onChange: (e) => setMeta((m) => ({ ...m, [key]: e.target.value })), onBlur: () => { void saveMeta(key); } }) }, key))), _jsxs("div", { className: "sm-facts", children: [_jsx("span", { children: "File" }), _jsx("span", { children: selected.filename }), _jsx("span", { children: "Type" }), _jsx("span", { children: selected.mime }), _jsx("span", { children: "Size" }), _jsx("span", { children: formatBytes(selected.size) }), selected.width && selected.height && _jsxs(_Fragment, { children: [_jsx("span", { children: "Dimensions" }), _jsxs("span", { children: [selected.width, "\u00D7", selected.height] })] }), selected.duration !== null && _jsxs(_Fragment, { children: [_jsx("span", { children: "Duration" }), _jsxs("span", { children: [Math.round(selected.duration), " s"] })] })] }), _jsxs("div", { className: "sm-usage", children: [_jsx("span", { className: "sm-label", children: usageLabel(usage.length) }), usage.map((u, i) => u.href
94
97
  ? _jsxs(RouteLink, { href: u.href, children: [u.title, " \u00B7 ", u.sourceField] }, `${u.sourceId}-${i}`)
95
98
  : _jsxs("span", { children: [u.title, " \u00B7 ", u.sourceField] }, `${u.sourceId}-${i}`))] }), _jsx(Button, { variant: "secondary", disabled: usage.length > 0, title: usage.length > 0 ? "Remove it from those places first." : undefined, onClick: () => { void remove(); }, children: "Delete" })] }))] })] }));
96
99
  }
@@ -1,6 +1,5 @@
1
1
  import type { AdminOps, MembersListResult } from "../ops.ts";
2
- export declare function MembersPage({ ops, userId, initial }: {
2
+ export declare function MembersPage({ ops, initial }: {
3
3
  ops: AdminOps;
4
- userId: string;
5
4
  initial?: MembersListResult | null;
6
5
  }): import("react").JSX.Element;
@@ -1,101 +1,30 @@
1
1
  "use client";
2
- import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  // packages/smoodly/src/admin/shell/MembersPage.tsx
4
- // The Members page (spec 2026-09-25 §7; management §4): the admission
5
- // table with Invite, the role as a badge and a row menu with Change role
6
- // (to the other role, after a confirm) and Remove member (2026-09-26; the
7
- // in-place pills before) — all off when the control plane manages the
8
- // space (the console URL banner stays). Own row and the last admin render
9
- // their menu disabled with the guard's reason, so the rule is visible
10
- // before it is hit.
4
+ // The Members page (spec 2026-09-25 §7; spec 2026-10-16 sign-in and the dashboard §6): a read-only list of the space's
5
+ // members with their roles, and "Manage members at smoodly.io" opening the project's Members tab. Every space is
6
+ // managed at smoodly.io, so the admin has no invite, role change or removal of its own.
11
7
  import { useEffect, useState } from "react";
12
- import { ActionMenu } from "../ui/ActionMenu.js";
13
- import { Banner, Button, StatusBadge } from "../ui/primitives.js";
14
- import { confirm } from "../ui/confirm.js";
8
+ import { Banner, StatusBadge } from "../ui/primitives.js";
9
+ import { consoleLinks } from "./console-links.js";
15
10
  import { ErrorPane } from "./ErrorPane.js";
16
- import { InviteModal } from "./InviteModal.js";
17
11
  import { ListView } from "./ListView.js";
18
- import { GUARD_TITLE, ROLE_DESCRIPTION, memberRows, roleLabel } from "./members-list.js";
19
- export function MembersPage({ ops, userId, initial = null }) {
12
+ import { memberRows } from "./members-list.js";
13
+ export function MembersPage({ ops, initial = null }) {
20
14
  const [data, setData] = useState(initial);
21
15
  const [error, setError] = useState(null);
22
- const [notice, setNotice] = useState(null);
23
- const [inviting, setInviting] = useState(false);
24
- const load = () => ops.members.list()
25
- .then((r) => (r.ok ? setData(r.data) : setError(r.message)))
26
- .catch(() => setError("Couldn't reach the server."));
27
- useEffect(() => { if (!initial)
28
- void load(); }, [ops, initial]); // eslint-disable-line react-hooks/exhaustive-deps
29
- const invite = async (input) => {
30
- // The admin's own origin and mount: /settings/… stripped, so the invite lands on the shell.
31
- const redirectTo = window.location.origin + window.location.pathname.replace(/\/settings(\/.*)?$/, "");
32
- const r = await ops.members.invite({ ...input, redirectTo });
33
- if (!r.ok)
34
- return r.message;
35
- setInviting(false);
36
- setNotice({
37
- tone: "ok",
38
- text: r.data.invited ? `Invite sent to ${input.email}.` : `${input.email} added — they can sign in with their existing account.`,
39
- });
40
- void load();
41
- return null;
42
- };
43
- /** Two roles: the change is to the other one, confirmed with what it may do. */
44
- const changeRole = async (row) => {
45
- const role = row.value === "admin" ? "editor" : "admin";
46
- const ok = await confirm({
47
- title: `Change role to ${roleLabel(role).toLowerCase()}?`,
48
- body: `${roleLabel(role)}: ${ROLE_DESCRIPTION[role]}`,
49
- confirmLabel: "Change role",
50
- subject: { name: row.email },
51
- });
52
- if (!ok)
16
+ useEffect(() => {
17
+ if (initial)
53
18
  return;
54
- const r = await ops.members.setRole(row.key, role);
55
- if (!r.ok) {
56
- setNotice({ tone: "error", text: r.message });
57
- return;
58
- }
59
- setData((d) => d && { ...d, members: d.members.map((m) => (m.userId === row.key ? r.data : m)) });
60
- };
61
- const remove = async (row) => {
62
- const ok = await confirm({
63
- title: "Remove this member?",
64
- body: "They keep their account and lose access to this site's admin. You can invite them again later.",
65
- confirmLabel: "Remove member",
66
- destructive: true,
67
- subject: { name: row.email },
68
- });
69
- if (!ok)
70
- return;
71
- const r = await ops.members.remove(row.key);
72
- if (!r.ok) {
73
- setNotice({ tone: "error", text: r.message });
74
- return;
75
- }
76
- void load();
77
- };
19
+ ops.members.list()
20
+ .then((r) => (r.ok ? setData(r.data) : setError(r.message)))
21
+ .catch(() => setError("Couldn't reach the server."));
22
+ }, [ops, initial]);
78
23
  if (error)
79
24
  return _jsx(ErrorPane, { message: error });
80
- const managed = Boolean(data?.consoleUrl);
81
- const rows = data ? memberRows(data.members, { me: userId }) : [];
82
- const guardTitle = (r) => (r.own ? GUARD_TITLE.own : r.lastAdmin ? GUARD_TITLE.lastAdmin : undefined);
83
- return (_jsxs(_Fragment, { children: [_jsx(ListView, { title: "Members", actions: !managed && data && _jsx(Button, { onClick: () => setInviting(true), children: "Invite" }), notice: _jsxs(_Fragment, { children: [data?.consoleUrl && (_jsxs(Banner, { tone: "ok", children: ["Members are managed at smoodly.io.", " ", _jsx("a", { href: data.consoleUrl, target: "_blank", rel: "noreferrer", children: "Open the console" })] })), notice && _jsx(Banner, { tone: notice.tone, onDismiss: () => setNotice(null), children: notice.text })] }), columns: [
84
- { label: "Email", width: "minmax(200px,1fr)" },
85
- { label: "Role", width: "120px" },
86
- ...(managed ? [] : [{ label: "", width: "48px" }]),
87
- ], rows: rows.map((r) => ({
88
- key: r.key,
89
- cells: [
90
- r.fallback ? _jsx("span", { style: { color: "var(--text-muted)" }, children: r.email }) : r.email,
91
- _jsx(StatusBadge, { status: r.role }),
92
- ...(managed ? [] : [
93
- // ActionMenu has no title of its own when disabled: the span carries the guard's reason.
94
- _jsx("span", { title: guardTitle(r), children: _jsx(ActionMenu, { label: "Member actions", small: true, disabled: r.own || r.lastAdmin, items: [
95
- { label: "Change role", run: () => void changeRole(r) },
96
- { label: "Remove member", danger: true, run: () => void remove(r) },
97
- ] }) }),
98
- ]),
99
- ],
100
- })), empty: "No members yet" }), inviting && _jsx(InviteModal, { onClose: () => setInviting(false), onInvite: invite })] }));
25
+ const rows = data ? memberRows(data.members) : [];
26
+ return (_jsx(ListView, { title: "Members", notice: _jsxs(Banner, { tone: "ok", children: ["Members are managed at smoodly.io.", " ", _jsx("a", { href: consoleLinks().members, target: "_blank", rel: "noreferrer", children: "Manage members at smoodly.io" })] }), columns: [{ label: "Email", width: "minmax(200px,1fr)" }, { label: "Role", width: "120px" }], rows: rows.map((r) => ({
27
+ key: r.key,
28
+ cells: [r.fallback ? _jsx("span", { style: { color: "var(--text-muted)" }, children: r.email }) : r.email, _jsx(StatusBadge, { status: r.role })],
29
+ })), empty: "No members yet" }));
101
30
  }
@@ -1,11 +1,10 @@
1
1
  import type { MemberRole } from "../../permissions.ts";
2
2
  import type { AdminOps } from "../ops.ts";
3
3
  import type { AdminRegistry } from "../serialize.ts";
4
- export declare function SettingsView({ ops, registry, tail, role, userId }: {
4
+ export declare function SettingsView({ ops, registry, tail, role }: {
5
5
  ops: AdminOps;
6
6
  registry: AdminRegistry;
7
7
  tail: string | undefined;
8
8
  /** The caller's role from `members.me`; null while it loads. */
9
9
  role: MemberRole | null;
10
- userId: string;
11
10
  }): import("react").JSX.Element;
@@ -20,7 +20,7 @@ import { RedirectsPage } from "./RedirectsPage.js";
20
20
  import { RouteLink } from "./RouteLink.js";
21
21
  import { navigate } from "./router.js";
22
22
  import { defaultSettingsPage, settingsPage, visibleSettingsPages } from "./settings.js";
23
- export function SettingsView({ ops, registry, tail, role, userId }) {
23
+ export function SettingsView({ ops, registry, tail, role }) {
24
24
  const adminHref = useAdminHref();
25
25
  const page = settingsPage(tail);
26
26
  useEffect(() => {
@@ -36,7 +36,7 @@ export function SettingsView({ ops, registry, tail, role, userId }) {
36
36
  return _jsx(ErrorPane, { message: "Not found." });
37
37
  if (!visible.some((p) => p.id === page))
38
38
  return _jsx(ErrorPane, { message: "Settings are for admins." });
39
- return (_jsxs("div", { className: "sm-settings", children: [_jsx("nav", { className: "sm-settings__nav", "aria-label": "Settings", children: visible.map((p) => (_jsx(RouteLink, { className: "sm-settings__link", href: adminHref(`/settings/${p.id}`), "aria-current": p.id === page ? "page" : undefined, children: p.title }, p.id))) }), _jsx("section", { className: "sm-settings__page", children: page === "members" ? _jsx(MembersPage, { ops: ops, userId: userId })
39
+ return (_jsxs("div", { className: "sm-settings", children: [_jsx("nav", { className: "sm-settings__nav", "aria-label": "Settings", children: visible.map((p) => (_jsx(RouteLink, { className: "sm-settings__link", href: adminHref(`/settings/${p.id}`), "aria-current": p.id === page ? "page" : undefined, children: p.title }, p.id))) }), _jsx("section", { className: "sm-settings__page", children: page === "members" ? _jsx(MembersPage, { ops: ops })
40
40
  : page === "redirects" ? _jsx(RedirectsPage, { ops: ops, registry: registry })
41
41
  : _jsx(ContentHealthPage, { ops: ops, registry: registry }) })] }));
42
42
  }
@@ -0,0 +1,5 @@
1
+ export declare function SignedOutView({ host, message, onSignIn }: {
2
+ host: string;
3
+ message?: string;
4
+ onSignIn: () => void;
5
+ }): import("react").JSX.Element;
@@ -0,0 +1,9 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ // After Sign out, or a sign-in the space refused (spec 2026-10-16 sign-in and the dashboard §2, §6): a page, never a
4
+ // redirect, since a redirect would sign the person straight back in while their smoodly.io session lives. Sign in
5
+ // sends them to sign in again.
6
+ import { Button, Logo } from "../ui/primitives.js";
7
+ export function SignedOutView({ host, message, onSignIn }) {
8
+ return (_jsx("div", { style: { height: "100vh", display: "grid", placeItems: "center", padding: 24 }, children: _jsxs("div", { style: { maxWidth: 380, display: "grid", gap: 16, justifyItems: "center", textAlign: "center" }, children: [_jsx("p", { style: { margin: 0, fontSize: 16 }, children: message ? message.replace(/^smoodly:\s*/, "") : `You're signed out of ${host}.` }), _jsx(Button, { onClick: onSignIn, children: "Sign in" }), _jsx("span", { style: { color: "var(--text-muted)" }, children: _jsx(Logo, { height: 12 }) })] }) }));
9
+ }
@@ -0,0 +1,5 @@
1
+ export declare function setConsole(url: string, spaceId: string): void;
2
+ export declare const consoleLinks: () => {
3
+ sites: string;
4
+ members: string;
5
+ };
@@ -0,0 +1,11 @@
1
+ // Where the admin points at smoodly.io (spec 2026-10-16 sign-in and the dashboard §5, §6): the account menu's "Your
2
+ // sites at smoodly.io" and the Members page's "Manage members at smoodly.io", deep links into the dashboard the
3
+ // service names (`GET /v1/auth`'s consoleUrl). Set once by the entry from the page's data.
4
+ let console = { url: "https://smoodly.io", spaceId: "" };
5
+ export function setConsole(url, spaceId) {
6
+ console = { url: url.replace(/\/+$/, ""), spaceId };
7
+ }
8
+ export const consoleLinks = () => ({
9
+ sites: `${console.url}/projects`,
10
+ members: `${console.url}/spaces/${encodeURIComponent(console.spaceId)}/members`,
11
+ });
@@ -1,23 +1,10 @@
1
1
  import type { MemberRecord } from "../../member-store.ts";
2
- import { type MemberRole } from "../../permissions.ts";
3
2
  export type MemberRow = {
4
3
  key: string;
5
4
  email: string;
6
5
  fallback: boolean;
7
6
  /** Display label. */
8
7
  role: string;
9
- /** The normalized role, what the control holds. */
10
- value: MemberRole;
11
- own: boolean;
12
- lastAdmin: boolean;
13
8
  };
14
9
  export declare const roleLabel: (role: string) => string;
15
- /** What each role may do, in one line — the invite modal's radio descriptions (permissions.ts decides). */
16
- export declare const ROLE_DESCRIPTION: Record<MemberRole, string>;
17
- export declare function memberRows(members: MemberRecord[], opts: {
18
- me: string;
19
- }): MemberRow[];
20
- export declare const GUARD_TITLE: {
21
- readonly own: "You cannot change your own membership.";
22
- readonly lastAdmin: "This site needs at least one admin.";
23
- };
10
+ export declare function memberRows(members: MemberRecord[]): MemberRow[];
@@ -1,26 +1,5 @@
1
1
  import { normalizeRole } from "../../permissions.js";
2
2
  export const roleLabel = (role) => (role ? role[0].toUpperCase() + role.slice(1) : "");
3
- /** What each role may do, in one line — the invite modal's radio descriptions (permissions.ts decides). */
4
- export const ROLE_DESCRIPTION = {
5
- editor: "Edits, publishes and deletes content and media, and manages redirects.",
6
- admin: "Everything an editor can, and Settings: invites, re-roles and removes members.",
7
- };
8
- export function memberRows(members, opts) {
9
- const admins = members.filter((m) => normalizeRole(m.role) === "admin").length;
10
- return members.map((m) => {
11
- const value = normalizeRole(m.role);
12
- return {
13
- key: m.userId,
14
- email: m.email ?? m.userId,
15
- fallback: m.email === null,
16
- role: roleLabel(value),
17
- value,
18
- own: m.userId === opts.me,
19
- lastAdmin: value === "admin" && admins <= 1,
20
- };
21
- });
3
+ export function memberRows(members) {
4
+ return members.map((m) => ({ key: m.userId, email: m.email ?? m.userId, fallback: m.email === null, role: roleLabel(normalizeRole(m.role)) }));
22
5
  }
23
- export const GUARD_TITLE = {
24
- own: "You cannot change your own membership.",
25
- lastAdmin: "This site needs at least one admin.",
26
- };
@@ -66,8 +66,6 @@ export type TreeProjection = Projection;
66
66
  * them any deeper slot, the move op refuses any other parent. A record
67
67
  * no longer in code does not move at all: deleting it is all that is open. */
68
68
  export declare const canDragRow: (row: PagesTreeRow) => boolean;
69
- /** Whether a row may only reorder among the root siblings. */
70
- export declare const rootOnly: (row: PagesTreeRow) => boolean;
71
69
  /** Levels below a node: 0 for a leaf. From the records, because the
72
70
  * dragged node's rows are collapsed away during the drag. */
73
71
  export declare function subtreeHeight(records: {
@@ -135,7 +135,7 @@ export function pagesTreeRows(records, registry, locale, collapsed) {
135
135
  * no longer in code does not move at all: deleting it is all that is open. */
136
136
  export const canDragRow = (row) => !row.orphan;
137
137
  /** Whether a row may only reorder among the root siblings. */
138
- export const rootOnly = (row) => row.fixed || row.isHome;
138
+ const rootOnly = (row) => row.fixed || row.isHome;
139
139
  /** Levels below a node: 0 for a leaf. From the records, because the
140
140
  * dragged node's rows are collapsed away during the drag. */
141
141
  export function subtreeHeight(records, id) {
@@ -0,0 +1,10 @@
1
+ import type { PresenceConfig } from "../../env.ts";
2
+ import { type PresenceChannel } from "./presence.ts";
3
+ /** What the channel asks of the admin's session: the current pass, and a renewal (true when a fresh pass is in hand). */
4
+ export type SocketSession = {
5
+ pass: () => string | null;
6
+ renew: () => Promise<boolean>;
7
+ fetch?: typeof fetch;
8
+ };
9
+ /** `email` is the caller's shape: the object knows the editor from the verified ticket. */
10
+ export declare function connectSocket(config: PresenceConfig, _email: string, session: SocketSession, make?: (url: string) => WebSocket): PresenceChannel;
@@ -0,0 +1,191 @@
1
+ "use client";
2
+ import { isTarget } from "./presence.js";
3
+ const PING_MS = 15000;
4
+ const RETRY_MS = [1000, 2000, 4000, 8000, 15000, 30000];
5
+ /** After this many closes without a sync the service is taken to refuse (a misconfigured deployment): no more tries
6
+ * until the next renewal. */
7
+ const MAX_RETRIES = RETRY_MS.length;
8
+ /** The object's close codes: the pass expired or the session ended (renew, then reconnect); the session signed out and
9
+ * the account removed (both final). */
10
+ const PASS_ENDED = 4001, SIGNED_OUT = 4002, REMOVED = 4003;
11
+ const OPEN = 1;
12
+ /** `email` is the caller's shape: the object knows the editor from the verified ticket. */
13
+ export function connectSocket(config, _email, session, make = (url) => new WebSocket(url)) {
14
+ const api = config.apiUrl.replace(/\/+$/, "");
15
+ const space = encodeURIComponent(config.spaceId);
16
+ const address = (ticket) => `${api.replace(/^http/, "ws")}/v1/spaces/${space}/admin/presence?ticket=${encodeURIComponent(ticket)}`;
17
+ /** A ticket for the current pass; null when the service refuses it (an expired pass, an ended session). Throws when
18
+ * the service cannot be reached. */
19
+ const ticketFor = async (pass) => {
20
+ const res = await (session.fetch ?? fetch)(`${api}/v1/spaces/${space}/admin/presence/ticket`, { method: "POST", headers: { authorization: `Bearer ${pass}` } });
21
+ const body = (await res.json().catch(() => null));
22
+ return res.ok && body?.ok && typeof body.data?.ticket === "string" ? body.data.ticket : null;
23
+ };
24
+ let socket = null;
25
+ /** Bumped by every open and by unsubscribe: an answer that arrives for an older attempt is dropped. */
26
+ let attempt = 0;
27
+ let target = null;
28
+ let onStatus = null;
29
+ let state = {};
30
+ let retry = null;
31
+ let failures = 0;
32
+ let stopped = false;
33
+ const syncListeners = [];
34
+ const broadcastListeners = {};
35
+ const send = (m) => { if (socket && socket.readyState === OPEN)
36
+ socket.send(JSON.stringify(m)); };
37
+ const cancelRetry = () => { if (retry) {
38
+ clearTimeout(retry);
39
+ retry = null;
40
+ } };
41
+ /** Ends a socket without reporting it. */
42
+ const end = (ws) => { try {
43
+ ws?.close(1000);
44
+ }
45
+ catch { /* already gone */ } };
46
+ /** A connection that never opened (no ticket, the service unreachable): CLOSED and the backoff, as a drop. */
47
+ const failedBeforeOpen = (previous) => {
48
+ end(previous);
49
+ state = {};
50
+ onStatus?.("CLOSED");
51
+ scheduleRetry();
52
+ };
53
+ const scheduleRetry = () => {
54
+ if (stopped || failures >= MAX_RETRIES)
55
+ return;
56
+ const delay = RETRY_MS[Math.min(failures, RETRY_MS.length - 1)];
57
+ failures++;
58
+ retry = setTimeout(() => { retry = null; if (!stopped && !socket)
59
+ open(); }, delay);
60
+ };
61
+ /** Asks a ticket with the current pass, renewing once when it is refused, then opens the socket. `previous` (a
62
+ * renewal's old socket) stays until the new one is open, so the others never see the editor leave and come back. */
63
+ const open = (previous = null, renewed = false) => {
64
+ const mine = ++attempt;
65
+ const pass = session.pass();
66
+ const asked = pass ? ticketFor(pass) : Promise.resolve(null);
67
+ void asked.then(async (ticket) => {
68
+ if (stopped || mine !== attempt)
69
+ return;
70
+ if (ticket) {
71
+ connect(ticket, previous);
72
+ return;
73
+ }
74
+ if (!renewed && (await session.renew().catch(() => false)) && !stopped && mine === attempt) {
75
+ open(previous, true);
76
+ return;
77
+ }
78
+ if (!stopped && mine === attempt) {
79
+ end(previous);
80
+ state = {};
81
+ onStatus?.("CLOSED");
82
+ }
83
+ }, () => { if (!stopped && mine === attempt)
84
+ failedBeforeOpen(previous); });
85
+ };
86
+ const connect = (ticket, previous) => {
87
+ let ws;
88
+ try {
89
+ ws = make(address(ticket));
90
+ }
91
+ catch {
92
+ // A constructor refusal (mixed content, a bad address) does not fix itself: presence is off, the admin is not.
93
+ socket = null;
94
+ end(previous);
95
+ onStatus?.("CHANNEL_ERROR");
96
+ return;
97
+ }
98
+ socket = ws;
99
+ let ping = null;
100
+ ws.addEventListener("open", () => {
101
+ if (socket !== ws)
102
+ return;
103
+ end(previous);
104
+ previous = null;
105
+ onStatus?.("SUBSCRIBED");
106
+ if (target)
107
+ send({ type: "track", target });
108
+ ping = setInterval(() => { if (socket === ws)
109
+ send({ type: "ping" }); }, PING_MS);
110
+ });
111
+ ws.addEventListener("message", (e) => {
112
+ if (socket !== ws)
113
+ return;
114
+ let m;
115
+ try {
116
+ m = JSON.parse(String(e.data));
117
+ }
118
+ catch {
119
+ return;
120
+ }
121
+ if (m.type === "sync" && Array.isArray(m.others)) {
122
+ // A sync proves the connection: the backoff starts over only now, not on a socket closed right after opening.
123
+ failures = 0;
124
+ const next = {};
125
+ for (const o of m.others) {
126
+ const email = o.email;
127
+ if (isTarget(o) && typeof email === "string")
128
+ (next[email] ?? (next[email] = [])).push({ kind: o.kind, id: o.id, locale: o.locale });
129
+ }
130
+ state = next;
131
+ for (const l of syncListeners)
132
+ l();
133
+ }
134
+ else if (m.type === "saved") {
135
+ const { type: _type, ...payload } = m;
136
+ for (const l of broadcastListeners.saved ?? [])
137
+ l(payload);
138
+ }
139
+ });
140
+ ws.addEventListener("close", (e) => {
141
+ if (ping) {
142
+ clearInterval(ping);
143
+ ping = null;
144
+ }
145
+ if (socket !== ws || stopped)
146
+ return;
147
+ socket = null;
148
+ end(previous);
149
+ previous = null;
150
+ state = {};
151
+ onStatus?.("CLOSED");
152
+ const code = e.code;
153
+ if (code === REMOVED || code === SIGNED_OUT)
154
+ return;
155
+ // The pass expired or the session ended: renew, then reconnect with a ticket from the fresh pass.
156
+ if (code === PASS_ENDED) {
157
+ if (failures >= MAX_RETRIES)
158
+ return;
159
+ failures++;
160
+ void session.renew().catch(() => false).then((ok) => { if (ok && !stopped && !socket)
161
+ open(); });
162
+ return;
163
+ }
164
+ scheduleRetry();
165
+ });
166
+ };
167
+ return {
168
+ subscribe(cb) { onStatus = cb; open(); },
169
+ async setAuth() {
170
+ if (stopped || !onStatus)
171
+ return;
172
+ cancelRetry();
173
+ failures = 0;
174
+ const old = socket;
175
+ socket = null;
176
+ open(old);
177
+ },
178
+ async track(s) { target = isTarget(s) ? { kind: s.kind, id: s.id, locale: s.locale } : null; send(target ? { type: "track", target } : { type: "untrack" }); },
179
+ async untrack() { target = null; send({ type: "untrack" }); },
180
+ async send(message) {
181
+ const p = message.payload, savedAt = p.savedAt;
182
+ if (message.event !== "saved" || !isTarget(p))
183
+ return;
184
+ send({ type: "saved", target: { kind: p.kind, id: p.id, locale: p.locale }, savedAt: typeof savedAt === "string" ? savedAt : null });
185
+ },
186
+ onSync(l) { syncListeners.push(l); },
187
+ presenceState: () => state,
188
+ onBroadcast(event, l) { (broadcastListeners[event] ?? (broadcastListeners[event] = [])).push(l); },
189
+ async unsubscribe() { stopped = true; attempt++; cancelRetry(); const old = socket; socket = null; end(old); },
190
+ };
191
+ }
@@ -15,10 +15,11 @@ export type PresenceState = {
15
15
  status: PresenceStatus;
16
16
  others: PresenceEntry[];
17
17
  };
18
- /** The slice of a Realtime channel presence uses. */
18
+ /** The channel presence uses: once a Realtime channel's slice, now the service's socket (presence-socket.ts). */
19
19
  export type PresenceChannel = {
20
20
  subscribe(onStatus: (status: string) => void): void;
21
- /** A session token for the socket (spec 2026-09-26 security pass §1): the channel policies admit by auth.uid(). */
21
+ /** The session renewed (its new pass): reconnect with a fresh ticket. The space's object admits a live session of a
22
+ * member only. */
22
23
  setAuth(token: string): Promise<unknown>;
23
24
  track(state: Record<string, unknown>): Promise<unknown>;
24
25
  untrack(): Promise<unknown>;
@@ -39,31 +40,18 @@ export type Presence = {
39
40
  track(target: PresenceTarget | null): void;
40
41
  /** After a guarded op acked: tell the others. */
41
42
  announce(target: PresenceTarget, savedAt: string | null): void;
42
- /** The session token was refreshed: re-authenticate the socket so the channel outlives the old token. No-op unless joined. */
43
+ /** The session renewed: reconnect the socket with a fresh ticket so the channel outlives the old pass. No-op unless joined. */
43
44
  setAuth(token: string): void;
44
45
  onSaved(listener: (event: SavedEvent) => void): () => void;
45
46
  getState(): PresenceState;
46
47
  subscribe(listener: () => void): () => void;
47
48
  dispose(): void;
48
49
  };
49
- export declare const shortName: (email: string) => string;
50
50
  /** The short names editing `target`, other than me, sorted and deduped. */
51
51
  export declare function editorsOf(others: PresenceEntry[], target: PresenceTarget): string[];
52
+ /** A target as the channel carries it: one of the three kinds, a string id and locale. The service's object reads it too. */
53
+ export declare const isTarget: (v: unknown) => v is PresenceTarget;
52
54
  export declare function createPresence(opts: {
53
55
  email: string;
54
56
  connect: () => PresenceChannel;
55
57
  }): Presence;
56
- /** What the shell needs from the auth client to follow refreshes — supabase-js's `auth` satisfies it. */
57
- export type RefreshSource = {
58
- onAuthStateChange(cb: (event: string, session: {
59
- access_token: string;
60
- } | null) => void): {
61
- data: {
62
- subscription: {
63
- unsubscribe(): void;
64
- };
65
- };
66
- };
67
- };
68
- /** Re-send every refreshed session token to the socket (spec 2026-09-26 security pass §1). Returns the stop function. */
69
- export declare function followTokenRefresh(auth: RefreshSource, presence: Pick<Presence, "setAuth">): () => void;
@@ -1,15 +1,16 @@
1
1
  // Editing presence (spec 2026-09-26 concurrency §5): who has what open, and a
2
2
  // "saved" announcement, over ONE channel per space. Framework-free: the channel
3
- // is injected (presence-realtime.ts builds the supabase-js one; tests fake it),
3
+ // is injected (presence-socket.ts builds the one over the service's socket; tests fake it),
4
4
  // the React glue is presence-context.tsx. Never awaited by anything — a failed
5
5
  // join leaves the admin exactly as without presence.
6
6
  const sameTarget = (a, b) => a.kind === b.kind && a.id === b.id && a.locale === b.locale;
7
- export const shortName = (email) => email.split("@")[0];
7
+ const shortName = (email) => email.split("@")[0];
8
8
  /** The short names editing `target`, other than me, sorted and deduped. */
9
9
  export function editorsOf(others, target) {
10
10
  return [...new Set(others.filter((o) => sameTarget(o, target)).map((o) => shortName(o.email)))].sort();
11
11
  }
12
- const isTarget = (v) => typeof v === "object" && v !== null
12
+ /** A target as the channel carries it: one of the three kinds, a string id and locale. The service's object reads it too. */
13
+ export const isTarget = (v) => typeof v === "object" && v !== null
13
14
  && ["page", "entry", "global"].includes(v.kind)
14
15
  && typeof v.id === "string" && typeof v.locale === "string";
15
16
  export function createPresence(opts) {
@@ -93,11 +94,3 @@ export function createPresence(opts) {
93
94
  },
94
95
  };
95
96
  }
96
- /** Re-send every refreshed session token to the socket (spec 2026-09-26 security pass §1). Returns the stop function. */
97
- export function followTokenRefresh(auth, presence) {
98
- const { data } = auth.onAuthStateChange((event, session) => {
99
- if (event === "TOKEN_REFRESHED" && session)
100
- presence.setAuth(session.access_token);
101
- });
102
- return () => data.subscription.unsubscribe();
103
- }