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
@@ -14,34 +14,23 @@ import { relativeTime } from "../ui/format.js";
14
14
  import { useSortable } from "../ui/useSortable.js";
15
15
  import { useAdminHref } from "./base.js";
16
16
  import { navigate } from "./router.js";
17
+ import { confirm } from "../ui/confirm.js";
17
18
  import { ErrorPane } from "./ErrorPane.js";
18
- const LOCALE_KEY = "smoodly.entries.locale";
19
- /** The selected locale survives a reload within the session; a stored
20
- * value that is no longer supported falls back to the default. */
21
- function rememberedLocale(registry) {
22
- try {
23
- const stored = window.sessionStorage.getItem(LOCALE_KEY);
24
- if (stored && registry.locales.supported.includes(stored))
25
- return stored;
26
- }
27
- catch {
28
- // storage unavailable — the default is fine
29
- }
30
- return registry.locales.default;
31
- }
19
+ import { rememberLocale, rememberedLocale } from "./locale-memory.js";
20
+ import { usePresenceState } from "./presence-context.js";
21
+ import { editorsOf } from "./presence.js";
22
+ import { LockIcon } from "../ui/LockIcon.js";
32
23
  export function EntriesList({ ops, registry, collection }) {
33
24
  const adminHref = useAdminHref();
34
25
  const meta = registry.collections.find((c) => c.name === collection);
35
26
  const [records, setRecords] = useState(null);
36
27
  const [error, setError] = useState(null);
37
- const [locale, setLocale] = useState(() => rememberedLocale(registry));
28
+ const [locale, setLocale] = useState(() => rememberedLocale(registry.locales));
29
+ const { others } = usePresenceState();
38
30
  const multilingual = registry.locales.supported.length > 1;
39
31
  const pickLocale = (next) => {
40
32
  setLocale(next);
41
- try {
42
- window.sessionStorage.setItem(LOCALE_KEY, next);
43
- }
44
- catch { /* see rememberedLocale */ }
33
+ rememberLocale(next);
45
34
  };
46
35
  const load = useCallback(() => ops.entries.list(collection)
47
36
  .then((r) => (r.ok ? setRecords(r.data) : setError(r.message)))
@@ -75,7 +64,7 @@ export function EntriesList({ ops, registry, collection }) {
75
64
  if (error)
76
65
  return _jsx(ErrorPane, { message: error });
77
66
  const open = (id) => {
78
- navigate(adminHref(`/${collection}/${encodeURIComponent(id)}?locale=${encodeURIComponent(locale)}`));
67
+ void navigate(adminHref(`/${collection}/${encodeURIComponent(id)}?locale=${encodeURIComponent(locale)}`));
79
68
  };
80
69
  /** "+ New entry" creates an empty draft in the locale on screen and opens it (spec 2026-09-14 §4). */
81
70
  const createEntry = async () => {
@@ -97,7 +86,13 @@ export function EntriesList({ ops, registry, collection }) {
97
86
  if (!record)
98
87
  return;
99
88
  const from = entrySourceLocale(record, registry);
100
- if (!window.confirm(`Add "${row.title}" to ${locale}? Its ${from} fields are copied as the first ${locale} draft.`))
89
+ const ok = await confirm({
90
+ title: `Add this entry to ${locale}?`,
91
+ body: `Its ${from} fields are copied as the first ${locale} draft. Nothing is published until you publish it.`,
92
+ confirmLabel: `Add to ${locale}`,
93
+ subject: { name: row.title },
94
+ });
95
+ if (!ok)
101
96
  return;
102
97
  let result;
103
98
  try {
@@ -122,7 +117,7 @@ export function EntriesList({ ops, registry, collection }) {
122
117
  key: row.id,
123
118
  onClick: () => (row.present ? open(row.id) : void addLocale(row)),
124
119
  cells: [
125
- _jsx("span", { className: "sm-cell sm-cell--title", style: dim, children: row.title }, "t"),
120
+ _jsxs("span", { className: "sm-cell sm-cell--title", style: dim, children: [row.title, _jsx(LockIcon, { names: editorsOf(others, { kind: "entry", id: row.id, locale }) })] }, "t"),
126
121
  row.present
127
122
  ? _jsx("span", { className: "sm-cell sm-cell--muted", children: relativeTime(row.updatedAt) }, "u")
128
123
  : _jsxs("span", { className: "sm-cell sm-cell--muted", children: ["not in ", locale, " \u2014 click to add"] }, "u"),
@@ -15,7 +15,10 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
15
15
  // publish control. A draft may be incomplete; publish validates it and
16
16
  // lists the fields in an amber banner. The safe-delete guard's message
17
17
  // renders verbatim — it is product surface.
18
+ import { Eye } from "lucide-react";
18
19
  import { useEffect, useMemo, useRef, useState } from "react";
20
+ import { stillFailing, validateFields } from "../validate.js";
21
+ import { elementSchemas } from "../../richtext-walk.js";
19
22
  import { FieldWidget } from "../forms/FieldWidget.js";
20
23
  import { revealField } from "../forms/reveal.js";
21
24
  import { useRefOptions } from "../forms/useRefOptions.js";
@@ -32,7 +35,14 @@ import { entrySourceLocale, entryTitle, localizedFieldKeys } from "./entries-lis
32
35
  import { useAdminHref } from "./base.js";
33
36
  import { localeFromUrl, writeLocaleToUrl } from "../editor/locale-url.js";
34
37
  import { navigate, useLeaveGuard } from "./router.js";
35
- import { LIVE_URL_MESSAGE, liveUrlChanges } from "./live-url.js";
38
+ import { LIVE_URL_CONFIRM, liveUrlChanges } from "./live-url.js";
39
+ import { confirm, DISCARD_CONFIRM } from "../ui/confirm.js";
40
+ import { draftToken } from "../../draft-token.js";
41
+ import { isStale, staleInfoOf } from "./stale.js";
42
+ import { StaleBanner } from "../ui/StaleBanner.js";
43
+ import { usePresence, usePresenceState, useSavedElsewhere, useTrackPresence } from "./presence-context.js";
44
+ import { editorsOf } from "./presence.js";
45
+ import { PresenceLine } from "../ui/PresenceLine.js";
36
46
  /** A stable empty map for the hooks that run before the "no such collection" return. */
37
47
  const NO_FIELDS = {};
38
48
  export function EntryForm({ ops, registry, collection, id }) {
@@ -57,6 +67,7 @@ export function EntryForm({ ops, registry, collection, id }) {
57
67
  const [published, setPublished] = useState(false);
58
68
  const [history, setHistory] = useState(null);
59
69
  const [snapshot, setSnapshot] = useState(null);
70
+ const [stale, setStale] = useState(null);
60
71
  const formRef = useRef(null);
61
72
  const fieldsRef = useRef(fields);
62
73
  fieldsRef.current = fields;
@@ -65,7 +76,14 @@ export function EntryForm({ ops, registry, collection, id }) {
65
76
  /** The slug the editor already confirmed as a live-URL change, so the next autosave does not ask again. */
66
77
  const confirmedSlug = useRef(null);
67
78
  // The page editor's controller: debounced, retrying, flushable (spec 2026-09-14 §3).
68
- const { saveState, mark, flush } = useDraftAutosave({
79
+ /** The token of the row on screen — what every guarded op sends (spec 2026-09-26 concurrency §1). */
80
+ const token = () => { const row = recordRef.current?.locales[locale]; return row ? draftToken(row) : null; };
81
+ // Presence (spec 2026-09-26 concurrency §5): this tab on this row, the others on it, every ack announced.
82
+ const target = { kind: "entry", id, locale };
83
+ useTrackPresence(target);
84
+ const { others } = usePresenceState();
85
+ const presence = usePresence();
86
+ const { saveState, mark, flush, reset } = useDraftAutosave({
69
87
  read: () => fieldsRef.current,
70
88
  persist: async (value) => {
71
89
  // A published entry's slug is a live URL: one confirm at the save, Cancel restores it (members management §5).
@@ -73,16 +91,22 @@ export function EntryForm({ ops, registry, collection, id }) {
73
91
  const stored = row?.slug ?? null;
74
92
  const next = typeof value.slug === "string" ? value.slug : null;
75
93
  if (next !== confirmedSlug.current && liveUrlChanges({ status: row?.status, from: stored, to: next })) {
76
- if (window.confirm(LIVE_URL_MESSAGE))
94
+ const current = recordRef.current;
95
+ const subject = current && meta ? { name: entryTitle(current, meta, registry, locale), path: slugPrefix && stored ? `${slugPrefix}${stored}` : null } : undefined;
96
+ if (await confirm({ ...LIVE_URL_CONFIRM, subject }))
77
97
  confirmedSlug.current = next;
78
98
  else {
79
99
  value = { ...value, slug: stored };
80
100
  setFields((f) => ({ ...(f ?? {}), slug: stored }));
81
101
  }
82
102
  }
83
- const r = await ops.entries.save(collection, id, locale, value);
84
- if (r.ok)
103
+ const r = await ops.entries.save(collection, id, locale, value, token());
104
+ if (r.ok) {
85
105
  setRecord(r.data);
106
+ presence?.announce(target, r.data.locales[locale]?.draftSavedAt ?? null);
107
+ }
108
+ else if (isStale(r))
109
+ setStale(staleInfoOf(r));
86
110
  return r;
87
111
  },
88
112
  onSaved: () => setDirty(false),
@@ -107,6 +131,7 @@ export function EntryForm({ ops, registry, collection, id }) {
107
131
  setFields(null);
108
132
  setFieldErrors({});
109
133
  setDirty(false);
134
+ setStale(null);
110
135
  setTouched(false);
111
136
  ops.entries.get(collection, id, locale).then((r) => {
112
137
  if (cancelled)
@@ -127,8 +152,28 @@ export function EntryForm({ ops, registry, collection, id }) {
127
152
  return _jsx("p", { style: { padding: 32 }, children: "No such collection." });
128
153
  const row = record?.locales[locale];
129
154
  const status = row ? localeStatusOf(row) : null;
155
+ // A refused publish's field errors clear one by one as the fields are fixed: every change revalidates
156
+ // (the same rules the op applies) and keeps only what still fails; the notice's list shrinks with them.
157
+ const elementDescs = useMemo(() => elementSchemas(registry.elements), [registry]);
158
+ const hasFieldErrors = Object.keys(fieldErrors).length > 0;
159
+ useEffect(() => {
160
+ if (!hasFieldErrors || !fields || !meta)
161
+ return;
162
+ const fresh = validateFields(fields, meta.fields, elementDescs);
163
+ setFieldErrors((prev) => stillFailing(prev, fresh));
164
+ setNotice((n) => {
165
+ if (!n?.fields)
166
+ return n;
167
+ const left = n.fields.filter((f) => fresh.some((e) => e.field === f.field));
168
+ return left.length === 0 ? null : left.length === n.fields.length ? n : { ...n, fields: left };
169
+ });
170
+ }, [fields, hasFieldErrors, meta, elementDescs]);
130
171
  /** An op's failure onto the screen: amber with the fields for a validation result, red otherwise. */
131
172
  const fail = (r) => {
173
+ if (isStale(r)) {
174
+ setStale(staleInfoOf(r));
175
+ return;
176
+ }
132
177
  if (r.code === "validation" && r.fields && r.fields.length > 0) {
133
178
  setFieldErrors(Object.fromEntries(r.fields.map((f) => [f.field, f.message])));
134
179
  setNotice({ tone: "warn", text: r.message, fields: r.fields });
@@ -136,6 +181,17 @@ export function EntryForm({ ops, registry, collection, id }) {
136
181
  else
137
182
  setNotice({ tone: "error", text: r.message });
138
183
  };
184
+ /** Load their version (spec 2026-09-26 concurrency §4): drop the frozen state and re-get through the ops seam. */
185
+ const loadTheirs = () => { setStale(null); reset(); setReloadKey((k) => k + 1); };
186
+ // Someone else saved this row (§5): clean → their version silently; unsaved edits → the freeze at once.
187
+ useSavedElsewhere(target, (event) => {
188
+ if (stale)
189
+ return;
190
+ if (dirty || saveState !== "saved")
191
+ setStale({ by: event.email, at: Date.now() });
192
+ else
193
+ setReloadKey((k) => k + 1);
194
+ });
139
195
  /** Publish what is on screen: the pending edit is flushed first, and a failed flush cancels the publish. */
140
196
  const publish = async () => {
141
197
  setNotice(null);
@@ -147,7 +203,7 @@ export function EntryForm({ ops, registry, collection, id }) {
147
203
  }
148
204
  let r;
149
205
  try {
150
- r = await ops.entries.setStatus(collection, id, locale, "published");
206
+ r = await ops.entries.setStatus(collection, id, locale, "published", token());
151
207
  }
152
208
  catch {
153
209
  r = { ok: false, code: "internal", message: "Couldn't reach the server." };
@@ -158,6 +214,7 @@ export function EntryForm({ ops, registry, collection, id }) {
158
214
  return;
159
215
  }
160
216
  setRecord(r.data);
217
+ presence?.announce(target, r.data.locales[locale]?.draftSavedAt ?? null);
161
218
  setFieldErrors({});
162
219
  setPublished(true);
163
220
  setTimeout(() => setPublished(false), 1600);
@@ -180,7 +237,7 @@ export function EntryForm({ ops, registry, collection, id }) {
180
237
  };
181
238
  /** Discard (spec 2026-09-13 publish flow §4): confirm, flush so a late save cannot re-dirty the row, restore, reload. */
182
239
  const discard = async () => {
183
- if (!window.confirm("Discard your changes and restore the published version?"))
240
+ if (!(await confirm({ ...DISCARD_CONFIRM, subject: entrySubject() })))
184
241
  return;
185
242
  setNotice(null);
186
243
  setBusy("discard");
@@ -191,7 +248,7 @@ export function EntryForm({ ops, registry, collection, id }) {
191
248
  }
192
249
  let r;
193
250
  try {
194
- r = await ops.entries.discardDraft(collection, id, locale);
251
+ r = await ops.entries.discardDraft(collection, id, locale, token());
195
252
  }
196
253
  catch {
197
254
  r = { ok: false, code: "internal", message: "Couldn't reach the server." };
@@ -202,6 +259,7 @@ export function EntryForm({ ops, registry, collection, id }) {
202
259
  return;
203
260
  }
204
261
  setRecord(r.data);
262
+ presence?.announce(target, r.data.locales[locale]?.draftSavedAt ?? null);
205
263
  setReloadKey((k) => k + 1);
206
264
  setNotice({ tone: "ok", text: "Changes discarded." });
207
265
  };
@@ -229,12 +287,13 @@ export function EntryForm({ ops, registry, collection, id }) {
229
287
  fail(r);
230
288
  };
231
289
  const restore = async (versionId) => {
232
- const r = await ops.entries.restore(collection, id, locale, versionId);
290
+ const r = await ops.entries.restore(collection, id, locale, versionId, token());
233
291
  if (!r.ok) {
234
292
  fail(r);
235
293
  return false;
236
294
  }
237
295
  setRecord(r.data);
296
+ presence?.announce(target, r.data.locales[locale]?.draftSavedAt ?? null);
238
297
  setReloadKey((k) => k + 1);
239
298
  setNotice({ tone: "ok", text: "Version restored." });
240
299
  return true;
@@ -290,7 +349,14 @@ export function EntryForm({ ops, registry, collection, id }) {
290
349
  const remaining = Object.keys(record.locales).filter((l) => l !== locale);
291
350
  if (remaining.length === 0)
292
351
  return;
293
- if (!window.confirm(`Remove this entry from ${locale}? Its ${locale} fields and history are deleted.`))
352
+ const ok = await confirm({
353
+ title: `Remove this entry from ${locale}?`,
354
+ body: `Its ${locale} fields and history are deleted. Other languages are not affected.`,
355
+ confirmLabel: `Remove from ${locale}`,
356
+ destructive: true,
357
+ subject: entrySubject(),
358
+ });
359
+ if (!ok)
294
360
  return;
295
361
  let result;
296
362
  try {
@@ -307,11 +373,18 @@ export function EntryForm({ ops, registry, collection, id }) {
307
373
  goToLocale(remaining.includes(registry.locales.default) ? registry.locales.default : remaining[0]);
308
374
  };
309
375
  const destroy = async () => {
310
- if (!window.confirm(multilingual ? "Delete this entry in every language?" : "Delete this entry?"))
376
+ const ok = await confirm({
377
+ title: "Delete this entry?",
378
+ body: `The entry${multilingual ? " in every language" : ""} and its history are removed. Anything referencing it loses the reference. This cannot be undone.`,
379
+ confirmLabel: "Delete entry",
380
+ destructive: true,
381
+ subject: entrySubject(),
382
+ });
383
+ if (!ok)
311
384
  return;
312
385
  const r = await ops.entries.delete(collection, id);
313
386
  if (r.ok)
314
- navigate(adminHref(`/${collection}`), { force: true });
387
+ void navigate(adminHref(`/${collection}`), { force: true });
315
388
  else
316
389
  fail(r);
317
390
  };
@@ -319,6 +392,11 @@ export function EntryForm({ ops, registry, collection, id }) {
319
392
  // The mount's path in the ACTIVE locale prefixes the slug; no path, no prefix.
320
393
  const base = meta.path?.[locale];
321
394
  const slugPrefix = base ? `/${base}/` : undefined;
395
+ /** The confirm dialogs' context chip: the entry's title and, on a routed collection, its URL. */
396
+ const entrySubject = () => {
397
+ const slug = record?.locales[locale]?.slug;
398
+ return record ? { name: heading, path: slugPrefix && slug ? `${slugPrefix}${slug}` : null } : undefined;
399
+ };
322
400
  const present = record ? registry.locales.supported.filter((l) => record.locales[l]) : [locale];
323
401
  const widget = ([key, d]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: d, value: fields?.[key], errors: fieldErrors, refOptions: refTargets[key] ? refOptions[refTargets[key]] : undefined, suggestions: tagSuggestions[key], prefix: d.type === "slug" ? slugPrefix : undefined, mark: multilingual && !localeKeys.includes(key) ? _jsx(UnlocalizedMark, {}) : undefined, onChange: (value) => {
324
402
  setFields((f) => ({ ...(f ?? {}), [key]: value }));
@@ -333,6 +411,6 @@ export function EntryForm({ ops, registry, collection, id }) {
333
411
  trailing: multilingual && (_jsx(LocaleSwitcher, { registry: registry, present: present, parentLocales: parentLocales, value: locale, adding: adding, onSwitch: (l) => { void switchLocale(l); }, onAdd: (l) => void doAddLocale(l) })),
334
412
  },
335
413
  { label: heading },
336
- ] }) }), right: _jsxs(_Fragment, { children: [(touched || saveState === "error") && _jsx(SaveIndicator, { state: saveState }), urls[locale] && (_jsx(LinkButton, { href: urls[locale], target: "_blank", rel: "noreferrer", title: "Opens the site route. While the admin's draft-mode cookie is on, this shows the DRAFT \u2014 open it in a private window to see what visitors see.", children: "View live" })), _jsx(PublishControl, { status: status, pending: dirty || saveState !== "saved", busy: busy, flash: published, disabled: !row, onPublish: () => { void publish(); }, onUnpublish: () => { void unpublish(); }, onDiscard: () => { void discard(); }, onHistory: () => { void openHistory(); } })] }) }), history && row && (_jsx(HistoryModal, { rows: history, draftId: row.draftVersionId, onSelect: (v) => { void loadSnapshot(v); }, onClose: () => setHistory(null), onRestore: restore, render: (versionId) => snapshot?.id === versionId ? (_jsx("fieldset", { disabled: true, className: "sm-history__form", children: Object.entries(meta.fields).map(([key, d]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: d, value: snapshot.fields[key], refOptions: refTargets[key] ? refOptions[refTargets[key]] : undefined, prefix: d.type === "slug" ? slugPrefix : undefined, onChange: () => { } }, key))) })) : _jsx("p", { className: "sm-hint", style: { padding: 16 }, children: "Loading\u2026" }) })), _jsxs("div", { className: "sm-editor", children: [_jsxs("div", { className: "sm-form", ref: formRef, children: [notice && (_jsxs(Banner, { tone: notice.tone, onDismiss: () => setNotice(null), children: [_jsx("p", { style: { margin: 0 }, children: notice.text }), _jsx(FieldErrorList, { fields: notice.fields ?? [], onReveal: (path) => { if (formRef.current)
414
+ ] }) }), right: _jsxs(_Fragment, { children: [(touched || saveState === "error") && _jsx(SaveIndicator, { state: saveState }), urls[locale] && (_jsx(LinkButton, { icon: true, href: urls[locale], target: "_blank", rel: "noreferrer", "aria-label": "View live", title: "View live \u2014 opens the site route. While the admin's draft-mode cookie is on, this shows the DRAFT \u2014 open it in a private window to see what visitors see.", children: _jsx(Eye, { size: 16, strokeWidth: 1.5, "aria-hidden": "true" }) })), _jsx(PublishControl, { status: status, pending: dirty || saveState !== "saved", busy: busy, flash: published, disabled: !row, onPublish: () => { void publish(); }, onUnpublish: () => { void unpublish(); }, onDiscard: () => { void discard(); }, onHistory: () => { void openHistory(); } })] }) }), history && row && (_jsx(HistoryModal, { rows: history, draftId: row.draftVersionId, onSelect: (v) => { void loadSnapshot(v); }, onClose: () => setHistory(null), onRestore: restore, render: (versionId) => snapshot?.id === versionId ? (_jsx("fieldset", { disabled: true, className: "sm-history__form", children: Object.entries(meta.fields).map(([key, d]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: d, value: snapshot.fields[key], refOptions: refTargets[key] ? refOptions[refTargets[key]] : undefined, prefix: d.type === "slug" ? slugPrefix : undefined, onChange: () => { } }, key))) })) : _jsx("p", { className: "sm-hint", style: { padding: 16 }, children: "Loading\u2026" }) })), _jsx(PresenceLine, { names: editorsOf(others, target), noun: "entry" }), stale && _jsx(StaleBanner, { info: stale, onReload: loadTheirs }), _jsxs("div", { className: "sm-editor", inert: stale ? true : undefined, children: [_jsxs("div", { className: "sm-form", ref: formRef, children: [notice && (_jsxs(Banner, { tone: notice.tone, onDismiss: () => setNotice(null), children: [_jsx("p", { style: { margin: 0 }, children: notice.text }), _jsx(FieldErrorList, { fields: notice.fields ?? [], onReveal: (path) => { if (formRef.current)
337
415
  revealField(formRef.current, path); } })] })), absent ? (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 12 }, children: [_jsxs("p", { children: ["This entry doesn't exist in ", locale, " yet. Adding it copies the ", entrySourceLocale(record, registry), " fields as the first ", locale, " draft."] }), _jsxs("div", { style: { display: "flex", gap: 8 }, children: [_jsxs(Button, { onClick: () => void doAddLocale(locale), disabled: adding || !parentLocales.includes(locale), title: parentLocales.includes(locale) ? undefined : `add ${locale} to the parent first`, children: ["Add ", locale] }), _jsxs(Button, { variant: "secondary", onClick: () => { void switchLocale(entrySourceLocale(record, registry)); }, children: ["Back to ", entrySourceLocale(record, registry)] })] })] })) : fields !== null ? (Object.entries(meta.fields).map(widget)) : !notice && _jsx("p", { style: { color: "var(--text-muted)" }, children: "Loading\u2026" })] }), _jsx(ResizeHandle, {}), _jsxs("aside", { className: "sm-inspector", children: [record && row && (_jsxs("div", { className: "sm-card", children: [_jsx(SectionLabel, { children: "Document info" }), _jsx(InfoRow, { label: "Created", value: relativeTime(record.createdAt) }), _jsx(InfoRow, { label: "Updated", value: updatedLine(row.updatedAt, row.updatedBy) }), multilingual && _jsx(InfoRow, { label: "Languages", value: Object.keys(record.locales).join(", ") }), _jsx(InfoRow, { label: "ID", value: id, mono: true })] })), record && (_jsxs("div", { className: "sm-card", children: [multilingual && row && Object.keys(record.locales).length > 1 && (_jsxs(Button, { variant: "secondary", onClick: () => void doRemoveLocale(), children: ["Remove from ", locale] })), _jsx(Button, { variant: "danger", onClick: destroy, children: "Delete" })] }))] })] })] }));
338
416
  }
@@ -12,6 +12,8 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
12
12
  // validates the draft and lists the fields in an amber banner. "Used on
13
13
  // N pages" is the refs index's answer.
14
14
  import { useEffect, useMemo, useRef, useState } from "react";
15
+ import { stillFailing, validateNode } from "../validate.js";
16
+ import { elementSchemas } from "../../richtext-walk.js";
15
17
  import { CSS_KEY, SETTINGS_KEY, STYLES_KEY } from "../../global.js";
16
18
  import { tabsFor } from "../forms/tabs.js";
17
19
  import { revealField } from "../forms/reveal.js";
@@ -28,6 +30,13 @@ import { useAdminHref } from "./base.js";
28
30
  import { GlobalFields } from "./GlobalFields.js";
29
31
  import { localeFromUrl, writeLocaleToUrl } from "../editor/locale-url.js";
30
32
  import { useLeaveGuard } from "./router.js";
33
+ import { confirm, DISCARD_CONFIRM } from "../ui/confirm.js";
34
+ import { draftToken } from "../../draft-token.js";
35
+ import { isStale, staleInfoOf } from "./stale.js";
36
+ import { StaleBanner } from "../ui/StaleBanner.js";
37
+ import { usePresence, usePresenceState, useSavedElsewhere, useTrackPresence } from "./presence-context.js";
38
+ import { editorsOf } from "./presence.js";
39
+ import { PresenceLine } from "../ui/PresenceLine.js";
31
40
  export function GlobalForm({ ops, registry, name }) {
32
41
  const adminHref = useAdminHref();
33
42
  const item = registry.globals.find((s) => s.name === name);
@@ -45,15 +54,29 @@ export function GlobalForm({ ops, registry, name }) {
45
54
  const [reloadKey, setReloadKey] = useState(0);
46
55
  const [history, setHistory] = useState(null);
47
56
  const [snapshot, setSnapshot] = useState(null);
57
+ const [stale, setStale] = useState(null);
48
58
  const formRef = useRef(null);
49
59
  const fieldsRef = useRef(fields);
50
60
  fieldsRef.current = fields;
51
- const { saveState, mark, flush } = useDraftAutosave({
61
+ const recordRef = useRef(record);
62
+ recordRef.current = record;
63
+ /** The token of the row on screen — what every guarded op sends (spec 2026-09-26 concurrency §1). */
64
+ const token = () => { const row = recordRef.current?.locales[locale]; return row ? draftToken(row) : null; };
65
+ // Presence (spec 2026-09-26 concurrency §5): this tab on this item, the others on it, every ack announced.
66
+ const target = { kind: "global", id: name, locale };
67
+ useTrackPresence(target);
68
+ const { others } = usePresenceState();
69
+ const presence = usePresence();
70
+ const { saveState, mark, flush, reset } = useDraftAutosave({
52
71
  read: () => fieldsRef.current,
53
72
  persist: async (value) => {
54
- const r = await ops.globals.save(name, locale, value);
55
- if (r.ok)
73
+ const r = await ops.globals.save(name, locale, value, token());
74
+ if (r.ok) {
56
75
  setRecord(r.data);
76
+ presence?.announce(target, r.data.locales[locale]?.draftSavedAt ?? null);
77
+ }
78
+ else if (isStale(r))
79
+ setStale(staleInfoOf(r));
57
80
  return r;
58
81
  },
59
82
  onSaved: () => setDirty(false),
@@ -74,6 +97,7 @@ export function GlobalForm({ ops, registry, name }) {
74
97
  setFields(null);
75
98
  setFieldErrors({});
76
99
  setDirty(false);
100
+ setStale(null);
77
101
  setTouched(false);
78
102
  ops.globals.get(name, locale).then((r) => {
79
103
  if (cancelled)
@@ -94,6 +118,10 @@ export function GlobalForm({ ops, registry, name }) {
94
118
  const row = record?.locales[locale];
95
119
  const status = row ? localeStatusOf(row) : null;
96
120
  const fail = (r) => {
121
+ if (isStale(r)) {
122
+ setStale(staleInfoOf(r));
123
+ return;
124
+ }
97
125
  if (r.code === "validation" && r.fields && r.fields.length > 0) {
98
126
  setFieldErrors(Object.fromEntries(r.fields.map((f) => [f.field, f.message])));
99
127
  setNotice({ tone: "warn", text: r.message, fields: r.fields });
@@ -101,6 +129,23 @@ export function GlobalForm({ ops, registry, name }) {
101
129
  else
102
130
  setNotice({ tone: "error", text: r.message });
103
131
  };
132
+ // A refused publish's field errors clear as the fields are fixed: every change revalidates the way the
133
+ // op does (a visual item like a node: fields, `$settings` under `settings.`, `$css`) and keeps what still fails.
134
+ const elementDescs = useMemo(() => elementSchemas(registry.elements), [registry]);
135
+ const hasFieldErrors = Object.keys(fieldErrors).length > 0;
136
+ useEffect(() => {
137
+ if (!hasFieldErrors || !fields || !item)
138
+ return;
139
+ const { [SETTINGS_KEY]: settings, [CSS_KEY]: css, ...values } = fields;
140
+ const fresh = validateNode({ fields: values, settings: settings !== null && typeof settings === "object" ? settings : {}, css }, { fields: item.fields, settings: item.section !== undefined ? item.settings : undefined }, elementDescs);
141
+ setFieldErrors((prev) => stillFailing(prev, fresh));
142
+ setNotice((n) => {
143
+ if (!n?.fields)
144
+ return n;
145
+ const left = n.fields.filter((f) => fresh.some((e) => e.field === f.field));
146
+ return left.length === 0 ? null : left.length === n.fields.length ? n : { ...n, fields: left };
147
+ });
148
+ }, [fields, hasFieldErrors, item, elementDescs]);
104
149
  const publish = async () => {
105
150
  setNotice(null);
106
151
  setBusy("publish");
@@ -111,7 +156,7 @@ export function GlobalForm({ ops, registry, name }) {
111
156
  }
112
157
  let r;
113
158
  try {
114
- r = await ops.globals.setStatus(name, locale, "published");
159
+ r = await ops.globals.setStatus(name, locale, "published", token());
115
160
  }
116
161
  catch {
117
162
  r = { ok: false, code: "internal", message: "Couldn't reach the server." };
@@ -122,6 +167,7 @@ export function GlobalForm({ ops, registry, name }) {
122
167
  return;
123
168
  }
124
169
  setRecord(r.data);
170
+ presence?.announce(target, r.data.locales[locale]?.draftSavedAt ?? null);
125
171
  setFieldErrors({});
126
172
  setPublished(true);
127
173
  setTimeout(() => setPublished(false), 1600);
@@ -143,7 +189,7 @@ export function GlobalForm({ ops, registry, name }) {
143
189
  fail(r);
144
190
  };
145
191
  const discard = async () => {
146
- if (!window.confirm("Discard your changes and restore the published version?"))
192
+ if (!(await confirm({ ...DISCARD_CONFIRM, subject: { name: item.title } })))
147
193
  return;
148
194
  setNotice(null);
149
195
  setBusy("discard");
@@ -154,7 +200,7 @@ export function GlobalForm({ ops, registry, name }) {
154
200
  }
155
201
  let r;
156
202
  try {
157
- r = await ops.globals.discardDraft(name, locale);
203
+ r = await ops.globals.discardDraft(name, locale, token());
158
204
  }
159
205
  catch {
160
206
  r = { ok: false, code: "internal", message: "Couldn't reach the server." };
@@ -165,6 +211,7 @@ export function GlobalForm({ ops, registry, name }) {
165
211
  return;
166
212
  }
167
213
  setRecord(r.data);
214
+ presence?.announce(target, r.data.locales[locale]?.draftSavedAt ?? null);
168
215
  setReloadKey((k) => k + 1);
169
216
  setNotice({ tone: "ok", text: "Changes discarded." });
170
217
  };
@@ -190,13 +237,25 @@ export function GlobalForm({ ops, registry, name }) {
190
237
  else
191
238
  fail(r);
192
239
  };
240
+ /** Load their version (spec 2026-09-26 concurrency §4): drop the frozen state and re-get through the ops seam. */
241
+ const loadTheirs = () => { setStale(null); reset(); setReloadKey((k) => k + 1); };
242
+ // Someone else saved this item (§5): clean → their version silently; unsaved edits → the freeze at once.
243
+ useSavedElsewhere(target, (event) => {
244
+ if (stale)
245
+ return;
246
+ if (dirty || saveState !== "saved")
247
+ setStale({ by: event.email, at: Date.now() });
248
+ else
249
+ setReloadKey((k) => k + 1);
250
+ });
193
251
  const restore = async (versionId) => {
194
- const r = await ops.globals.restore(name, locale, versionId);
252
+ const r = await ops.globals.restore(name, locale, versionId, token());
195
253
  if (!r.ok) {
196
254
  fail(r);
197
255
  return false;
198
256
  }
199
257
  setRecord(r.data);
258
+ presence?.announce(target, r.data.locales[locale]?.draftSavedAt ?? null);
200
259
  setReloadKey((k) => k + 1);
201
260
  setNotice({ tone: "ok", text: "Version restored." });
202
261
  return true;
@@ -235,10 +294,10 @@ export function GlobalForm({ ops, registry, name }) {
235
294
  };
236
295
  return (_jsxs(_Fragment, { children: [_jsx(TopBar, { left: _jsx(_Fragment, { children: _jsx(Breadcrumb, { backHref: adminHref("/globals"), items: [
237
296
  {
238
- label: "Globals", href: adminHref("/globals"),
297
+ label: "Site", href: adminHref("/globals"),
239
298
  trailing: multilingual && (_jsx(LocaleSwitcher, { registry: registry, present: registry.locales.supported, parentLocales: [], value: locale, adding: false, onSwitch: (l) => { void switchLocale(l); }, onAdd: () => undefined })),
240
299
  },
241
300
  { label: item.title },
242
- ] }) }), right: _jsxs(_Fragment, { children: [(touched || saveState === "error") && _jsx(SaveIndicator, { state: saveState }), _jsx(PublishControl, { status: status, pending: dirty || saveState !== "saved", busy: busy, flash: published, disabled: !row, onPublish: () => { void publish(); }, onUnpublish: () => { void unpublish(); }, onDiscard: () => { void discard(); }, onHistory: () => { void openHistory(); } })] }) }), history && row && (_jsx(HistoryModal, { rows: history, draftId: row.draftVersionId, onSelect: (v) => { void loadSnapshot(v); }, onClose: () => setHistory(null), onRestore: restore, render: (versionId) => snapshot?.id === versionId ? (_jsxs("fieldset", { disabled: true, className: "sm-history__form", children: [_jsx(GlobalFields, { item: item, groups: contentGroups, fields: snapshot.fields, refOptions: refOptions, multilingual: multilingual, onChange: () => { } }), settingGroups.length > 0 && (_jsxs(_Fragment, { children: [_jsx(SectionLabel, { children: "Settings" }), _jsx(GlobalFields, { item: item, groups: settingGroups, fields: snapshot.fields, refOptions: refOptions, multilingual: multilingual, onChange: () => { } })] }))] })) : _jsx("p", { className: "sm-hint", style: { padding: 16 }, children: "Loading\u2026" }) })), _jsxs("div", { className: "sm-editor", children: [_jsxs("div", { className: "sm-form", ref: formRef, children: [notice && (_jsxs(Banner, { tone: notice.tone, onDismiss: () => setNotice(null), children: [_jsx("p", { style: { margin: 0 }, children: notice.text }), _jsx(FieldErrorList, { fields: notice.fields ?? [], onReveal: (path) => { if (formRef.current)
301
+ ] }) }), right: _jsxs(_Fragment, { children: [(touched || saveState === "error") && _jsx(SaveIndicator, { state: saveState }), _jsx(PublishControl, { status: status, pending: dirty || saveState !== "saved", busy: busy, flash: published, disabled: !row, onPublish: () => { void publish(); }, onUnpublish: () => { void unpublish(); }, onDiscard: () => { void discard(); }, onHistory: () => { void openHistory(); } })] }) }), history && row && (_jsx(HistoryModal, { rows: history, draftId: row.draftVersionId, onSelect: (v) => { void loadSnapshot(v); }, onClose: () => setHistory(null), onRestore: restore, render: (versionId) => snapshot?.id === versionId ? (_jsxs("fieldset", { disabled: true, className: "sm-history__form", children: [_jsx(GlobalFields, { item: item, groups: contentGroups, fields: snapshot.fields, refOptions: refOptions, multilingual: multilingual, onChange: () => { } }), settingGroups.length > 0 && (_jsxs(_Fragment, { children: [_jsx(SectionLabel, { children: "Settings" }), _jsx(GlobalFields, { item: item, groups: settingGroups, fields: snapshot.fields, refOptions: refOptions, multilingual: multilingual, onChange: () => { } })] }))] })) : _jsx("p", { className: "sm-hint", style: { padding: 16 }, children: "Loading\u2026" }) })), _jsx(PresenceLine, { names: editorsOf(others, target), noun: "item" }), stale && _jsx(StaleBanner, { info: stale, onReload: loadTheirs }), _jsxs("div", { className: "sm-editor", inert: stale ? true : undefined, children: [_jsxs("div", { className: "sm-form", ref: formRef, children: [notice && (_jsxs(Banner, { tone: notice.tone, onDismiss: () => setNotice(null), children: [_jsx("p", { style: { margin: 0 }, children: notice.text }), _jsx(FieldErrorList, { fields: notice.fields ?? [], onReveal: (path) => { if (formRef.current)
243
302
  revealField(formRef.current, path); } })] })), record && (_jsxs("p", { className: "sm-hint", children: [pages.size === 0 ? "Not placed on any page yet." : `Used on ${pages.size} page${pages.size === 1 ? "" : "s"}: `, [...pages].map((id, i) => (_jsxs("span", { children: [i > 0 && ", ", _jsx("a", { href: adminHref(`/pages/${encodeURIComponent(id)}?locale=${encodeURIComponent(locale)}`), children: id })] }, id)))] })), fields !== null ? (_jsxs(_Fragment, { children: [_jsx(GlobalFields, { item: item, groups: contentGroups, fields: fields, errors: fieldErrors, refOptions: refOptions, multilingual: multilingual, onChange: onChange }), settingGroups.length > 0 && (_jsxs(_Fragment, { children: [_jsx(SectionLabel, { children: "Settings" }), _jsx(GlobalFields, { item: item, groups: settingGroups, fields: fields, errors: fieldErrors, refOptions: refOptions, multilingual: multilingual, onChange: onChange })] }))] })) : !notice && _jsx("p", { style: { color: "var(--text-muted)" }, children: "Loading\u2026" })] }), _jsx(ResizeHandle, {}), _jsx("aside", { className: "sm-inspector", children: record && row && (_jsxs("div", { className: "sm-card", children: [_jsx(SectionLabel, { children: "Document info" }), _jsx(InfoRow, { label: "Kind", value: item.section !== undefined ? `Visual · ${item.section}` : "Fields" }), _jsx(InfoRow, { label: "Updated", value: updatedLine(row.updatedAt, row.updatedBy) }), _jsx(InfoRow, { label: "ID", value: record.id, mono: true })] })) })] })] }));
244
303
  }
@@ -1,5 +1,5 @@
1
1
  "use client";
2
- import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  // The Globals list (global content, spec 2026-09-07 §6; renamed in the
4
4
  // UI 2026-09-09, the route is /globals): every registered item is
5
5
  // a row, edited on its own page. Nothing here is created or deleted by
@@ -14,30 +14,20 @@ import { LocalePicker } from "../ui/LocaleSwitcher.js";
14
14
  import { relativeTime } from "../ui/format.js";
15
15
  import { useAdminHref } from "./base.js";
16
16
  import { ErrorPane } from "./ErrorPane.js";
17
- const LOCALE_KEY = "smoodly.entries.locale"; // the same choice the collection lists remember
18
- function rememberedLocale(registry) {
19
- try {
20
- const stored = window.sessionStorage.getItem(LOCALE_KEY);
21
- if (stored && registry.locales.supported.includes(stored))
22
- return stored;
23
- }
24
- catch {
25
- // storage unavailable — the default is fine
26
- }
27
- return registry.locales.default;
28
- }
17
+ import { rememberLocale, rememberedLocale } from "./locale-memory.js";
18
+ import { usePresenceState } from "./presence-context.js";
19
+ import { editorsOf } from "./presence.js";
20
+ import { LockIcon } from "../ui/LockIcon.js";
29
21
  export function GlobalList({ ops, registry }) {
30
22
  const adminHref = useAdminHref();
31
23
  const [items, setItems] = useState(null);
32
24
  const [error, setError] = useState(null);
33
- const [locale, setLocale] = useState(() => rememberedLocale(registry));
25
+ const [locale, setLocale] = useState(() => rememberedLocale(registry.locales));
26
+ const { others } = usePresenceState();
34
27
  const multilingual = registry.locales.supported.length > 1;
35
28
  const pickLocale = (next) => {
36
29
  setLocale(next);
37
- try {
38
- window.sessionStorage.setItem(LOCALE_KEY, next);
39
- }
40
- catch { /* see rememberedLocale */ }
30
+ rememberLocale(next);
41
31
  };
42
32
  const load = useCallback(() => {
43
33
  ops.globals.list()
@@ -48,7 +38,7 @@ export function GlobalList({ ops, registry }) {
48
38
  if (error)
49
39
  return _jsx(ErrorPane, { message: error });
50
40
  const rows = items ? globalRows(items, registry, locale) : [];
51
- return (_jsx(ListView, { title: "Globals", titleTrailing: multilingual && _jsx(LocalePicker, { locales: registry.locales.supported, value: locale, onChange: pickLocale }), columns: [
41
+ return (_jsx(ListView, { title: "Site", titleTrailing: multilingual && _jsx(LocalePicker, { locales: registry.locales.supported, value: locale, onChange: pickLocale }), columns: [
52
42
  { label: "Title", width: "minmax(160px,1fr)" },
53
43
  { label: "Kind", width: "90px" },
54
44
  { label: "Updated", width: "minmax(110px,160px)" },
@@ -57,7 +47,7 @@ export function GlobalList({ ops, registry }) {
57
47
  key: row.name,
58
48
  href: adminHref(`/globals/${encodeURIComponent(row.name)}?locale=${encodeURIComponent(locale)}`),
59
49
  cells: [
60
- _jsx("span", { className: "sm-cell sm-cell--title", children: row.title }, "t"),
50
+ _jsxs("span", { className: "sm-cell sm-cell--title", children: [row.title, _jsx(LockIcon, { names: editorsOf(others, { kind: "global", id: row.name, locale }) })] }, "t"),
61
51
  _jsx("span", { className: "sm-cell sm-cell--muted", children: row.flavour }, "k"),
62
52
  _jsx("span", { className: "sm-cell sm-cell--muted", children: relativeTime(row.updatedAt) }, "u"),
63
53
  _jsx(StatusBadge, { status: statusLabel(row.status) }, "s"),
@@ -26,9 +26,11 @@ import { RouteLink } from "./RouteLink.js";
26
26
  /** Lucide at the rail's size and stroke (the tile's CSS restates both). */
27
27
  const ICON = { size: 18, strokeWidth: 2, "aria-hidden": true };
28
28
  /** The app icon, inlined: a package can't ship a file into the host app's
29
- * /public. Brand blue on white in both themes, so no --logo-filter. */
29
+ * /public. design_handoff_smoodly_neutral/assets/smoodly-logo.svg — the
30
+ * outer and inner shapes take --logo-outer / --logo-inner (white over
31
+ * navy on the frame), so a look decides its colours. */
30
32
  function AppIcon() {
31
- return (_jsxs("svg", { className: "sm-rail__logo", viewBox: "0 0 89 86", fill: "none", xmlns: "http://www.w3.org/2000/svg", role: "img", "aria-label": "Smoodly", children: [_jsx("path", { fill: "#0038B1", d: "M0 51.5552C0 24.3141 25.0505 0 52.5493 0H52.7761C74.4707 0 89 14.6336 89 34.445C89 61.6862 64.4034 86 36.0066 86C14.0946 86 0 70.9165 0 51.5552Z" }), _jsx("path", { fill: "#FFFFFF", d: "M23.25 46.1188C23.25 33.0027 34.8806 21.2959 47.6479 21.2959H47.7532C57.8257 21.2959 64.5714 28.3417 64.5714 37.8805C64.5714 50.9966 53.1516 62.7033 39.9674 62.7033C29.7939 62.7033 23.25 55.4409 23.25 46.1188Z" })] }));
33
+ return (_jsxs("svg", { className: "sm-rail__logo", viewBox: "0 0 28 27", fill: "none", xmlns: "http://www.w3.org/2000/svg", role: "img", "aria-label": "Smoodly", children: [_jsx("path", { style: { fill: "var(--logo-outer)" }, d: "M0 16.1859C0 7.6335 7.88106 0 16.5324 0H16.6037C23.429 0 28 4.59428 28 10.8141C28 19.3666 20.2618 27 11.3279 27C4.43425 27 0 22.2645 0 16.1859Z" }), _jsx("path", { style: { fill: "var(--logo-inner)" }, d: "M7 14.7932C7 10.6754 10.6591 7 14.6757 7H14.7089C17.8777 7 20 9.21206 20 12.2068C20 16.3247 16.4072 20 12.2594 20C9.05876 20 7 17.7199 7 14.7932Z" })] }));
32
34
  }
33
35
  export function IconRail({ registry, active, email, settings = true, onSignOut }) {
34
36
  const adminHref = useAdminHref();
@@ -44,14 +46,21 @@ export function IconRail({ registry, active, email, settings = true, onSignOut }
44
46
  const close = () => setOpen(null);
45
47
  const hoverLeave = () => setHovered(false);
46
48
  // A click open moves focus into the menu; a pointer down outside closes it.
49
+ // Outside means outside the menu and its trigger — the rest of the rail
50
+ // counts too (2026-09-26); the trigger is left to its own toggle.
47
51
  useEffect(() => {
48
52
  if (!open || !root.current)
49
53
  return;
50
54
  if (!hovered)
51
55
  root.current.querySelector(`#${CSS.escape(menuId(open))} [role=menuitem]`)?.focus();
52
56
  const onPointerDown = (e) => {
53
- if (e.target instanceof Node && !root.current?.contains(e.target))
54
- close();
57
+ if (!(e.target instanceof Node))
58
+ return;
59
+ const menu = root.current?.querySelector(`#${CSS.escape(menuId(open))}`);
60
+ const trigger = root.current?.querySelector(`[aria-controls="${menuId(open)}"]`);
61
+ if (menu?.contains(e.target) || trigger?.contains(e.target))
62
+ return;
63
+ close();
55
64
  };
56
65
  document.addEventListener("pointerdown", onPointerDown);
57
66
  return () => document.removeEventListener("pointerdown", onPointerDown);
@@ -79,5 +88,5 @@ export function IconRail({ registry, active, email, settings = true, onSignOut }
79
88
  const collectionsOpen = open === "collections" || (hovered && open === null);
80
89
  const name = email?.split("@")[0] ?? "Editor";
81
90
  const initials = name.slice(0, 2).toUpperCase();
82
- return (_jsxs("nav", { className: "sm-rail", "aria-label": "Primary", ref: root, onKeyDown: onKeyDown, children: [_jsx(AppIcon, {}), _jsxs("ul", { className: "sm-rail__list", children: [link("pages", "Pages", _jsx(PanelTop, { ...ICON }), adminHref("/pages")), collections.length > 0 && (_jsxs("li", { className: "sm-rail__item", onMouseEnter: () => setHovered(true), onMouseLeave: hoverLeave, children: [_jsxs("button", { type: "button", className: "sm-rail__btn", "aria-current": collectionActive ? "page" : undefined, "aria-haspopup": "menu", "aria-expanded": collectionsOpen, "aria-controls": menuId("collections"), onClick: () => toggle("collections"), children: [_jsx("span", { className: "sm-rail__tile", children: _jsx(LibraryBig, { ...ICON }) }), _jsx("span", { className: "sm-rail__label", children: "Collections" })] }), _jsx("ul", { className: "sm-rail__menu sm-rail__menu--collections", role: "menu", id: menuId("collections"), "data-open": collectionsOpen, children: collections.map((c) => (_jsx("li", { role: "none", children: _jsx(RouteLink, { role: "menuitem", className: "sm-rail__menu-item", href: adminHref(`/${c.name}`), "aria-current": c.name === active ? "page" : undefined, onClick: () => { close(); hoverLeave(); }, children: _jsx("span", { children: c.title }) }) }, c.name))) })] })), link("media", "Media", _jsx(Image, { ...ICON }), adminHref("/media")), registry.globals.length > 0 && link("globals", "Globals", _jsx(Globe, { ...ICON }), adminHref("/globals")), settings && link("settings", "Settings", _jsx(Settings, { ...ICON }), adminHref("/settings"))] }), _jsxs("div", { className: "sm-rail__item sm-rail__foot", children: [_jsx("button", { type: "button", className: "sm-rail__user", title: name, "aria-haspopup": "menu", "aria-expanded": open === "user", "aria-controls": menuId("user"), onClick: () => toggle("user"), children: initials }), _jsxs("ul", { className: "sm-rail__menu sm-rail__menu--up", role: "menu", id: menuId("user"), "data-open": open === "user", children: [_jsx("li", { className: "sm-rail__menu-title", role: "presentation", children: name }), _jsx("li", { role: "none", children: _jsx(ThemeToggle, {}) }), _jsx("li", { role: "none", children: _jsx("button", { type: "button", role: "menuitem", className: "sm-rail__menu-item", onClick: () => { close(); onSignOut(); }, children: "Sign out" }) })] })] })] }));
91
+ return (_jsxs("nav", { className: "sm-rail", "aria-label": "Primary", ref: root, onKeyDown: onKeyDown, children: [_jsx(AppIcon, {}), _jsxs("ul", { className: "sm-rail__list", children: [link("pages", "Pages", _jsx(PanelTop, { ...ICON }), adminHref("/pages")), collections.length > 0 && (_jsxs("li", { className: "sm-rail__item", onMouseEnter: () => setHovered(true), onMouseLeave: hoverLeave, children: [_jsxs("button", { type: "button", className: "sm-rail__btn", "aria-current": collectionActive ? "page" : undefined, "aria-haspopup": "menu", "aria-expanded": collectionsOpen, "aria-controls": menuId("collections"), onClick: () => toggle("collections"), children: [_jsx("span", { className: "sm-rail__tile", children: _jsx(LibraryBig, { ...ICON }) }), _jsx("span", { className: "sm-rail__label", children: "Collections" })] }), _jsx("ul", { className: "sm-rail__menu sm-rail__menu--collections", role: "menu", id: menuId("collections"), "data-open": collectionsOpen, children: collections.map((c) => (_jsx("li", { role: "none", children: _jsx(RouteLink, { role: "menuitem", className: "sm-rail__menu-item", href: adminHref(`/${c.name}`), "aria-current": c.name === active ? "page" : undefined, onClick: () => { close(); hoverLeave(); }, children: _jsx("span", { children: c.title }) }) }, c.name))) })] })), link("media", "Media", _jsx(Image, { ...ICON }), adminHref("/media")), registry.globals.length > 0 && link("globals", "Site", _jsx(Globe, { ...ICON }), adminHref("/globals")), settings && link("settings", "Settings", _jsx(Settings, { ...ICON }), adminHref("/settings"))] }), _jsxs("div", { className: "sm-rail__item sm-rail__foot", children: [_jsx("button", { type: "button", className: "sm-rail__user", title: name, "aria-haspopup": "menu", "aria-expanded": open === "user", "aria-controls": menuId("user"), onClick: () => toggle("user"), children: initials }), _jsxs("ul", { className: "sm-rail__menu sm-rail__menu--up", role: "menu", id: menuId("user"), "data-open": open === "user", children: [_jsx("li", { className: "sm-rail__menu-title", role: "presentation", children: name }), _jsx("li", { role: "none", children: _jsx(ThemeToggle, {}) }), _jsx("li", { role: "none", children: _jsx("button", { type: "button", role: "menuitem", className: "sm-rail__menu-item", onClick: () => { close(); onSignOut(); }, children: "Sign out" }) })] })] })] }));
83
92
  }