smoodly 0.0.25 → 0.0.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -0
- package/dist/admin/editor/EditorView.js +81 -17
- package/dist/admin/editor/autosave.d.ts +4 -1
- package/dist/admin/editor/autosave.js +24 -1
- package/dist/admin/editor/global-edit.d.ts +5 -1
- package/dist/admin/editor/global-edit.js +15 -5
- package/dist/admin/editor/useGlobalEdit.d.ts +1 -1
- package/dist/admin/editor/useGlobalEdit.js +1 -1
- package/dist/admin/next/create-admin.d.ts +4 -0
- package/dist/admin/next/create-admin.js +3 -1
- package/dist/admin/next/index.d.ts +1 -0
- package/dist/admin/ops-impl.js +70 -24
- package/dist/admin/ops.d.ts +22 -12
- package/dist/admin/shell/AdminApp.d.ts +3 -1
- package/dist/admin/shell/AdminApp.js +20 -2
- package/dist/admin/shell/EntriesList.js +5 -1
- package/dist/admin/shell/EntryForm.js +44 -7
- package/dist/admin/shell/GlobalForm.js +46 -7
- package/dist/admin/shell/GlobalList.js +6 -2
- package/dist/admin/shell/PagesList.js +5 -1
- package/dist/admin/shell/presence-context.d.ts +9 -0
- package/dist/admin/shell/presence-context.js +35 -0
- package/dist/admin/shell/presence-realtime.d.ts +3 -0
- package/dist/admin/shell/presence-realtime.js +22 -0
- package/dist/admin/shell/presence.d.ts +51 -0
- package/dist/admin/shell/presence.js +90 -0
- package/dist/admin/shell/stale.d.ts +28 -0
- package/dist/admin/shell/stale.js +17 -0
- package/dist/admin/ui/LockIcon.d.ts +4 -0
- package/dist/admin/ui/LockIcon.js +12 -0
- package/dist/admin/ui/PresenceLine.d.ts +6 -0
- package/dist/admin/ui/PresenceLine.js +11 -0
- package/dist/admin/ui/StaleBanner.d.ts +5 -0
- package/dist/admin/ui/StaleBanner.js +9 -0
- package/dist/admin/ui/primitives.js +1 -0
- package/dist/admin/ui/theme.js +4 -0
- package/dist/draft-token.d.ts +31 -0
- package/dist/draft-token.js +41 -0
- package/dist/entry-store.d.ts +11 -8
- package/dist/entry-store.js +24 -10
- package/dist/env.d.ts +9 -0
- package/dist/env.js +19 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.js +2 -1
- package/dist/schema-version.d.ts +1 -1
- package/dist/schema-version.js +1 -1
- package/dist/sql-space.js +11 -0
- package/dist/sql.js +11 -0
- package/dist/store.d.ts +11 -8
- package/dist/store.js +26 -8
- package/dist/supabase-entry-store.d.ts +15 -5
- package/dist/supabase-entry-store.js +87 -22
- package/dist/supabase-store.d.ts +15 -5
- package/dist/supabase-store.js +60 -20
- package/migrations/20260926130000_smoodly_v2.sql +26 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -468,6 +468,15 @@ Implemented:
|
|
|
468
468
|
The media and refs walks descend into documents against the element
|
|
469
469
|
schemas (`elements` beside `sections` in the store, resolve and site
|
|
470
470
|
options).
|
|
471
|
+
- The token guard and presence (spec 2026-09-26 concurrency): every
|
|
472
|
+
draft write carries `{ versionId, savedAt }` from the locale row's
|
|
473
|
+
`draftSavedAt` and is refused as `conflict` + `stale` when the draft
|
|
474
|
+
moved (the write itself is conditional in Supabase); the admin freezes
|
|
475
|
+
under a notice naming who saved and when and reloads through "Load
|
|
476
|
+
their version"; one private Realtime channel per space shows who edits
|
|
477
|
+
what (a lock in the lists, a line under the top bar) and announces
|
|
478
|
+
saves so a clean view refreshes and a dirty one freezes early. Schema
|
|
479
|
+
version 2 adds the draft-pointer foreign keys and the channel policies.
|
|
471
480
|
|
|
472
481
|
Not yet: canvas drag-and-drop (list fields, the outline, manual
|
|
473
482
|
collections and the pages tree drag since 2026-09-09 — spec
|
|
@@ -495,6 +504,29 @@ after both are on the registry. From the repo root:
|
|
|
495
504
|
`prepublishOnly` rebuilds each package. The Claude Code skill
|
|
496
505
|
`.claude/skills/releasing-smoodly` carries the same order with its checks.
|
|
497
506
|
|
|
507
|
+
## Follow-ups (logged 2026-09-26, concurrency spec)
|
|
508
|
+
|
|
509
|
+
- A hard lock with takeover, if a real team asks: presence has the facts.
|
|
510
|
+
- Presence on the members and media screens; today only pages, entries and
|
|
511
|
+
global items track.
|
|
512
|
+
- Postgres Changes for live list refresh; the lists still load on navigation.
|
|
513
|
+
- Field-level merging of two editors' changes; the guard refuses whole documents.
|
|
514
|
+
- Publish, discard and restore compare the token, then move the pointer under a
|
|
515
|
+
pointer filter: a save that rewrites the same draft row in place between the
|
|
516
|
+
two is published unseen (one round trip wide). A SQL function would close it.
|
|
517
|
+
- Publish leaves the token unchanged (the pointer and its saved time do not move),
|
|
518
|
+
so a publish with a token from before another person's publish of the same
|
|
519
|
+
draft succeeds — the content is the same. Recorded, not a bug.
|
|
520
|
+
- The read client over the read key (DESIGN.md OQ 31) can reuse `publicContent()`.
|
|
521
|
+
- A dropped socket clears presence on Realtime's timeout (tens of seconds); the
|
|
522
|
+
lock outlives a closed tab by that much.
|
|
523
|
+
- The live suites wipe `members` and the content tables; a pane walk after
|
|
524
|
+
them needs `npm run reseed` (or `scripts/demo-editor.ts`) first.
|
|
525
|
+
- `e2e/helpers.ts`'s `expectSaved` passes at once when the indicator still
|
|
526
|
+
reads "Saved" from before the edit (the debounce is 800 ms), so a step that
|
|
527
|
+
needs the save landed can race it; `concurrency.spec.ts` waits for the
|
|
528
|
+
save's server response instead. Fold that into the helper for every spec.
|
|
529
|
+
|
|
498
530
|
## Follow-ups (logged 2026-09-26, browser suite)
|
|
499
531
|
|
|
500
532
|
- Drag and drop has no browser test (spec §4): the outline, the pages
|
|
@@ -60,6 +60,12 @@ import { ResizeHandle } from "../ui/ResizeHandle.js";
|
|
|
60
60
|
import { PublishControl } from "../ui/PublishControl.js";
|
|
61
61
|
import { confirm, DISCARD_CONFIRM } from "../ui/confirm.js";
|
|
62
62
|
import { HistoryModal, historyRows } from "../ui/HistoryModal.js";
|
|
63
|
+
import { draftToken } from "../../draft-token.js";
|
|
64
|
+
import { isStale, staleInfoOf } from "../shell/stale.js";
|
|
65
|
+
import { StaleBanner } from "../ui/StaleBanner.js";
|
|
66
|
+
import { usePresence, usePresenceState, useSavedElsewhere, useTrackPresence } from "../shell/presence-context.js";
|
|
67
|
+
import { editorsOf } from "../shell/presence.js";
|
|
68
|
+
import { PresenceLine } from "../ui/PresenceLine.js";
|
|
63
69
|
import { Banner as BannerBox, Breadcrumb, Button, FieldErrorList, IconButton, LinkButton, SaveIndicator, Tabs, TopBar, } from "../ui/primitives.js";
|
|
64
70
|
export const withEditorParam = (url) => (url.includes("?") ? `${url}&smoodly-editor=1` : `${url}?smoodly-editor=1`);
|
|
65
71
|
/** The site URL of a stored version — History's iframe (spec 2026-09-13 history §5); no editor parameter, so no bridge. */
|
|
@@ -67,6 +73,10 @@ export const withVersionParam = (url, versionId) => `${url}${url.includes("?") ?
|
|
|
67
73
|
export function EditorView({ ops, registry, id }) {
|
|
68
74
|
const adminHref = useAdminHref();
|
|
69
75
|
const [record, setRecord] = useState(null);
|
|
76
|
+
const recordRef = useRef(record);
|
|
77
|
+
recordRef.current = record;
|
|
78
|
+
/** The guard's refusal (spec 2026-09-26 concurrency §4): the view freezes under the banner until Load their version. */
|
|
79
|
+
const [stale, setStale] = useState(null);
|
|
70
80
|
const [tree, setTree] = useState(null);
|
|
71
81
|
const [loadError, setLoadError] = useState(null);
|
|
72
82
|
const [ready, setReady] = useState(false);
|
|
@@ -77,6 +87,8 @@ export function EditorView({ ops, registry, id }) {
|
|
|
77
87
|
// The locales the parent exists in — "Add locale" offers only these.
|
|
78
88
|
const [parentLocales, setParentLocales] = useState([]);
|
|
79
89
|
const [selectedId, setSelectedId] = useState(null);
|
|
90
|
+
/** A reload after someone else's save (spec 2026-09-26 concurrency §5) keeps what is selected; every other reload clears it. */
|
|
91
|
+
const keepSelectionRef = useRef(false);
|
|
80
92
|
const [hoverId, setHoverId] = useState(null);
|
|
81
93
|
const [hoverField, setHoverField] = useState(null);
|
|
82
94
|
const [revealReq, setRevealReq] = useState(null);
|
|
@@ -112,21 +124,26 @@ export function EditorView({ ops, registry, id }) {
|
|
|
112
124
|
// (Read once: localeFromUrl also remembers the locale.)
|
|
113
125
|
const [openedLocale] = useState(() => localeFromUrl(registry));
|
|
114
126
|
const treeLocaleRef = useRef(openedLocale);
|
|
127
|
+
/** The token of the row the tree belongs to — what every guarded op sends (spec 2026-09-26 concurrency §1). */
|
|
128
|
+
const token = () => { const row = recordRef.current?.locales[treeLocaleRef.current]; return row ? draftToken(row) : null; };
|
|
115
129
|
// Every edit marks the draft dirty; a debounced saveDraft persists, with
|
|
116
130
|
// retry on failure, and the bridge refreshes the iframe on the save ack —
|
|
117
131
|
// now, or once an inline session ends (spec 2026-09-10 §7).
|
|
118
|
-
const { saveState, mark, flush } = useDraftAutosave({
|
|
132
|
+
const { saveState, mark, flush, reset } = useDraftAutosave({
|
|
119
133
|
read: () => treeRef.current,
|
|
120
134
|
persist: async (t) => {
|
|
121
135
|
const target = treeLocaleRef.current;
|
|
122
|
-
const r = await ops.pages.saveDraft(id, target, t);
|
|
136
|
+
const r = await ops.pages.saveDraft(id, target, t, token());
|
|
123
137
|
if (r.ok) {
|
|
124
|
-
// The badge and Publish follow each save without a re-get: saveDraft answers with the version id.
|
|
138
|
+
// The badge and Publish follow each save without a re-get: saveDraft answers with the version id and the new token.
|
|
125
139
|
setRecord((rec) => rec?.locales[target]
|
|
126
|
-
? { ...rec, locales: { ...rec.locales, [target]: { ...rec.locales[target], draftVersionId: r.data.versionId } } }
|
|
140
|
+
? { ...rec, locales: { ...rec.locales, [target]: { ...rec.locales[target], draftVersionId: r.data.versionId, draftSavedAt: r.data.draft?.savedAt ?? null } } }
|
|
127
141
|
: rec);
|
|
128
142
|
setDirty(false);
|
|
143
|
+
presence?.announce(pageTarget(target), r.data.draft?.savedAt ?? null);
|
|
129
144
|
}
|
|
145
|
+
else if (isStale(r))
|
|
146
|
+
setStale(staleInfoOf(r));
|
|
130
147
|
return r;
|
|
131
148
|
},
|
|
132
149
|
onSaved: () => inlineRef.current?.onSaved(),
|
|
@@ -134,7 +151,7 @@ export function EditorView({ ops, registry, id }) {
|
|
|
134
151
|
// Edits to a global block go to the global entry through their own
|
|
135
152
|
// autosave (spec 2026-09-13 §4); the save ack refreshes the canvas
|
|
136
153
|
// through the same inline-session deferral as the page's.
|
|
137
|
-
const globalEdit = useGlobalEdit({ ops, onSaved: () => { setGlobalDirty(false); inlineRef.current?.onSaved(); } });
|
|
154
|
+
const globalEdit = useGlobalEdit({ ops, onSaved: (name) => { setGlobalDirty(false); inlineRef.current?.onSaved(); presence?.announce({ kind: "global", id: name, locale }, null); } });
|
|
138
155
|
const patchGlobal = (name, path, value) => {
|
|
139
156
|
globalEdit.patch(name, path, value);
|
|
140
157
|
setGlobalDirty(true);
|
|
@@ -148,6 +165,12 @@ export function EditorView({ ops, registry, id }) {
|
|
|
148
165
|
clearBanner: () => setBanner(null),
|
|
149
166
|
reload: () => setReloadKey((k) => k + 1),
|
|
150
167
|
});
|
|
168
|
+
// Presence (spec 2026-09-26 concurrency §5): tracked once the record exists, the others on it, every ack announced.
|
|
169
|
+
const target = record ? { kind: "page", id, locale } : null;
|
|
170
|
+
useTrackPresence(target);
|
|
171
|
+
const { others } = usePresenceState();
|
|
172
|
+
const presence = usePresence();
|
|
173
|
+
const pageTarget = (forLocale) => ({ kind: "page", id, locale: forLocale });
|
|
151
174
|
// The URL that serves this page, per locale, from the store's paths
|
|
152
175
|
// index — the label is the CMS path, the canvas and "View live" take
|
|
153
176
|
// the site URL (href applied server-side by pages.get).
|
|
@@ -177,11 +200,18 @@ export function EditorView({ ops, registry, id }) {
|
|
|
177
200
|
setReady(false);
|
|
178
201
|
setTree(null);
|
|
179
202
|
treeRef.current = null;
|
|
180
|
-
|
|
181
|
-
|
|
203
|
+
const keepSelection = keepSelectionRef.current;
|
|
204
|
+
keepSelectionRef.current = false;
|
|
205
|
+
if (!keepSelection) {
|
|
206
|
+
setSelectedId(null);
|
|
207
|
+
setHoverId(null);
|
|
208
|
+
}
|
|
182
209
|
setFolder(false);
|
|
183
210
|
globalEdit.reset(); // a new locale or a reload is a new canvas; the switch flushed first
|
|
211
|
+
if (keepSelection && selectedItemName)
|
|
212
|
+
void globalEdit.load(selectedItemName, locale); // the kept selection is a global item: load it again
|
|
184
213
|
setDirty(false);
|
|
214
|
+
setStale(null);
|
|
185
215
|
setTouched(false);
|
|
186
216
|
setGlobalDirty(false);
|
|
187
217
|
setGlobalTouched(false);
|
|
@@ -350,6 +380,24 @@ export function EditorView({ ops, registry, id }) {
|
|
|
350
380
|
if (globalEdit.loadError)
|
|
351
381
|
setBanner({ text: globalEdit.loadError });
|
|
352
382
|
}, [globalEdit.loadError]);
|
|
383
|
+
// A global item's autosave refused by the guard freezes the whole editor too (§4).
|
|
384
|
+
useEffect(() => {
|
|
385
|
+
if (globalEdit.stale)
|
|
386
|
+
setStale(globalEdit.stale);
|
|
387
|
+
}, [globalEdit.stale]);
|
|
388
|
+
/** Load their version (§4): drop the frozen state; the load effect resets the tree, the globals and the flags. */
|
|
389
|
+
const loadTheirs = () => { setStale(null); reset(); setReloadKey((k) => k + 1); };
|
|
390
|
+
// Someone else saved this page (§5): clean → their version silently; unsaved edits anywhere → the freeze at once.
|
|
391
|
+
useSavedElsewhere(target, (event) => {
|
|
392
|
+
if (stale)
|
|
393
|
+
return;
|
|
394
|
+
if (dirty || globalDirty || saveState !== "saved" || globalEdit.saveState !== "saved")
|
|
395
|
+
setStale({ by: event.email, at: Date.now() });
|
|
396
|
+
else {
|
|
397
|
+
keepSelectionRef.current = true;
|
|
398
|
+
setReloadKey((k) => k + 1);
|
|
399
|
+
}
|
|
400
|
+
});
|
|
353
401
|
// Selecting from the outline must reveal the block. The canvas column is
|
|
354
402
|
// the scroller now (the iframe has no scrollbar of its own — it is sized to
|
|
355
403
|
// the full document), so scroll it to the rect the bridge reported.
|
|
@@ -541,7 +589,7 @@ export function EditorView({ ops, registry, id }) {
|
|
|
541
589
|
}
|
|
542
590
|
let result;
|
|
543
591
|
try {
|
|
544
|
-
result = await ops.pages.publish(id, locale);
|
|
592
|
+
result = await ops.pages.publish(id, locale, token());
|
|
545
593
|
}
|
|
546
594
|
catch (e) {
|
|
547
595
|
console.error(e);
|
|
@@ -552,9 +600,13 @@ export function EditorView({ ops, registry, id }) {
|
|
|
552
600
|
setPageBusy(null);
|
|
553
601
|
if (result.ok) {
|
|
554
602
|
setRecord(result.data);
|
|
603
|
+
presence?.announce(pageTarget(locale), result.data.locales[locale]?.draftSavedAt ?? null);
|
|
555
604
|
setPublished(true);
|
|
556
605
|
setTimeout(() => setPublished(false), 1600);
|
|
557
606
|
}
|
|
607
|
+
else if (isStale(result)) {
|
|
608
|
+
setStale(staleInfoOf(result));
|
|
609
|
+
}
|
|
558
610
|
else {
|
|
559
611
|
setBanner({ text: result.message, fields: result.fields });
|
|
560
612
|
}
|
|
@@ -573,17 +625,21 @@ export function EditorView({ ops, registry, id }) {
|
|
|
573
625
|
}
|
|
574
626
|
let result;
|
|
575
627
|
try {
|
|
576
|
-
result = await ops.pages.discardDraft(id, locale);
|
|
628
|
+
result = await ops.pages.discardDraft(id, locale, token());
|
|
577
629
|
}
|
|
578
630
|
catch {
|
|
579
631
|
result = { ok: false, code: "internal", message: "Couldn't reach the server." };
|
|
580
632
|
}
|
|
581
633
|
setPageBusy(null);
|
|
582
634
|
if (!result.ok) {
|
|
583
|
-
|
|
635
|
+
if (isStale(result))
|
|
636
|
+
setStale(staleInfoOf(result));
|
|
637
|
+
else
|
|
638
|
+
setBanner({ text: result.message });
|
|
584
639
|
return;
|
|
585
640
|
}
|
|
586
641
|
setRecord(result.data);
|
|
642
|
+
presence?.announce(pageTarget(locale), result.data.locales[locale]?.draftSavedAt ?? null);
|
|
587
643
|
setReloadKey((k) => k + 1); // the tree and the canvas come back from the store
|
|
588
644
|
};
|
|
589
645
|
/** Unpublish (§5): the locale goes off the site; the draft stays for a later Publish. */
|
|
@@ -631,16 +687,20 @@ export function EditorView({ ops, registry, id }) {
|
|
|
631
687
|
const doRestore = async (versionId) => {
|
|
632
688
|
let result;
|
|
633
689
|
try {
|
|
634
|
-
result = await ops.pages.restore(id, locale, versionId);
|
|
690
|
+
result = await ops.pages.restore(id, locale, versionId, token());
|
|
635
691
|
}
|
|
636
692
|
catch {
|
|
637
693
|
result = { ok: false, code: "internal", message: "Couldn't reach the server." };
|
|
638
694
|
}
|
|
639
695
|
if (!result.ok) {
|
|
640
|
-
|
|
696
|
+
if (isStale(result))
|
|
697
|
+
setStale(staleInfoOf(result));
|
|
698
|
+
else
|
|
699
|
+
setBanner({ text: result.message });
|
|
641
700
|
return false;
|
|
642
701
|
}
|
|
643
702
|
setRecord(result.data);
|
|
703
|
+
presence?.announce(pageTarget(locale), result.data.locales[locale]?.draftSavedAt ?? null);
|
|
644
704
|
setReloadKey((k) => k + 1);
|
|
645
705
|
return true;
|
|
646
706
|
};
|
|
@@ -650,8 +710,8 @@ export function EditorView({ ops, registry, id }) {
|
|
|
650
710
|
setGlobalBusy("publish");
|
|
651
711
|
const r = await globalEdit.setStatus(name, "published");
|
|
652
712
|
setGlobalBusy(null);
|
|
653
|
-
if (!r.ok)
|
|
654
|
-
setBanner({ text: r.message, fields: r.fields });
|
|
713
|
+
if (!r.ok && !isStale(r))
|
|
714
|
+
setBanner({ text: r.message, fields: r.fields }); // a stale refusal arrives through globalEdit.stale
|
|
655
715
|
};
|
|
656
716
|
const doUnpublishGlobal = async (name) => {
|
|
657
717
|
setBanner(null);
|
|
@@ -673,15 +733,19 @@ export function EditorView({ ops, registry, id }) {
|
|
|
673
733
|
return;
|
|
674
734
|
}
|
|
675
735
|
let r;
|
|
736
|
+
const itemRow = globalEdit.items[name]?.record.locales[locale];
|
|
676
737
|
try {
|
|
677
|
-
r = await ops.globals.discardDraft(name, locale);
|
|
738
|
+
r = await ops.globals.discardDraft(name, locale, itemRow ? draftToken(itemRow) : null);
|
|
678
739
|
}
|
|
679
740
|
catch {
|
|
680
741
|
r = { ok: false, code: "internal", message: "Couldn't reach the server." };
|
|
681
742
|
}
|
|
682
743
|
setGlobalBusy(null);
|
|
683
744
|
if (!r.ok) {
|
|
684
|
-
|
|
745
|
+
if (isStale(r))
|
|
746
|
+
setStale(staleInfoOf(r));
|
|
747
|
+
else
|
|
748
|
+
setBanner({ text: r.message });
|
|
685
749
|
return;
|
|
686
750
|
}
|
|
687
751
|
// The item reloads from the store; the canvas refreshes through the same path as a save ack.
|
|
@@ -765,7 +829,7 @@ export function EditorView({ ops, registry, id }) {
|
|
|
765
829
|
trailing: registry.locales.supported.length > 1 && (_jsx(LocaleSwitcher, { registry: registry, present: record ? registry.locales.supported.filter((l) => record.locales[l]) : [locale], parentLocales: parentLocales, value: locale, adding: adding, onSwitch: (l) => { void switchLocale(l); }, onAdd: (l) => { void doAddLocale(l); } })),
|
|
766
830
|
},
|
|
767
831
|
{ label },
|
|
768
|
-
] }) }), right: _jsxs(_Fragment, { children: [(touched || saveState === "error") && _jsx(SaveIndicator, { state: saveState }), ready && (_jsx(LinkButton, { icon: true, href: siteUrl, 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: record?.locales[locale] ? localeStatusOf(record.locales[locale]) : null, pending: dirty || saveState !== "saved", busy: pageBusy, flash: published, onPublish: () => { void doPublish(); }, onDiscard: () => { void doDiscard(); }, onUnpublish: () => { void doUnpublish(); }, onHistory: () => { void doHistory(); } })] }) }), history && record?.locales[locale] && (_jsx(HistoryModal, { rows: history, draftId: record.locales[locale].draftVersionId, onClose: () => setHistory(null), onRestore: doRestore, render: (versionId) => _jsx("iframe", { className: "sm-history__frame", title: "Version preview", src: withVersionParam(siteUrl, versionId) }) })), _jsxs("div", { className: "sm-editor", children: [_jsxs("div", { className: "sm-canvas", children: [(banner || geometry === "missing") && (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 8, padding: "0 20px 10px" }, children: [banner && (_jsxs(BannerBox, { tone: banner.fields && banner.fields.length > 0 ? "warn" : "error", onDismiss: () => setBanner(null), children: [_jsx("p", { children: banner.text }), _jsx(FieldErrorList, { fields: banner.fields ?? [] })] })), geometry === "missing" && (_jsx(BannerBox, { tone: "warn", children: "Selection unavailable \u2014 the page didn't report geometry. The outline still works." }))] })), _jsx("div", { className: "sm-canvas__scroll", ref: scrollerRef, onClick: (e) => { if (e.target === e.currentTarget)
|
|
832
|
+
] }) }), right: _jsxs(_Fragment, { children: [(touched || saveState === "error") && _jsx(SaveIndicator, { state: saveState }), ready && (_jsx(LinkButton, { icon: true, href: siteUrl, 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: record?.locales[locale] ? localeStatusOf(record.locales[locale]) : null, pending: dirty || saveState !== "saved", busy: pageBusy, flash: published, onPublish: () => { void doPublish(); }, onDiscard: () => { void doDiscard(); }, onUnpublish: () => { void doUnpublish(); }, onHistory: () => { void doHistory(); } })] }) }), target && _jsx(PresenceLine, { names: editorsOf(others, target), noun: "page" }), stale && _jsx(StaleBanner, { info: stale, onReload: loadTheirs }), history && record?.locales[locale] && (_jsx(HistoryModal, { rows: history, draftId: record.locales[locale].draftVersionId, onClose: () => setHistory(null), onRestore: doRestore, render: (versionId) => _jsx("iframe", { className: "sm-history__frame", title: "Version preview", src: withVersionParam(siteUrl, versionId) }) })), _jsxs("div", { className: "sm-editor", inert: stale ? true : undefined, children: [_jsxs("div", { className: "sm-canvas", children: [(banner || geometry === "missing") && (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 8, padding: "0 20px 10px" }, children: [banner && (_jsxs(BannerBox, { tone: banner.fields && banner.fields.length > 0 ? "warn" : "error", onDismiss: () => setBanner(null), children: [_jsx("p", { children: banner.text }), _jsx(FieldErrorList, { fields: banner.fields ?? [] })] })), geometry === "missing" && (_jsx(BannerBox, { tone: "warn", children: "Selection unavailable \u2014 the page didn't report geometry. The outline still works." }))] })), _jsx("div", { className: "sm-canvas__scroll", ref: scrollerRef, onClick: (e) => { if (e.target === e.currentTarget)
|
|
769
833
|
setSelectedId(null); }, children: _jsxs("div", { className: "sm-page", style: { overflow: "hidden" }, children: [iframeSrc ? (_jsx("iframe", { ref: iframeRef, src: iframeSrc, title: "Page canvas", style: { width: "100%", height: docH, border: 0, display: "block" } })) : (_jsx("div", { className: "sm-canvas__loading", children: "Loading canvas\u2026" })), _jsx(CanvasOverlay, { emptyZones: emptyZones, hoverRect: hoverRect, hoverGlobal: hoverGlobal, selection: selection, fieldHover: fieldHover, editing: editing, plusButtons: plusButtons, onPlus: (p, x, y) => setPicker({ zone: p.zone, index: p.index, x: x - 150, y: y + 12 }), onMove: doMove, onDuplicate: doDuplicate, onRemove: doRemove }), inline.richtext && (() => {
|
|
770
834
|
const { session } = inline.richtext;
|
|
771
835
|
const item = globalItemOf(session.node);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { OpResult } from "../ops.ts";
|
|
2
|
-
export type SaveState = "saved" | "saving" | "error";
|
|
2
|
+
export type SaveState = "saved" | "saving" | "error" | "stale";
|
|
3
3
|
export type DraftAutosave = {
|
|
4
4
|
/** Record a local change; a debounced save follows. */
|
|
5
5
|
mark(): void;
|
|
@@ -11,6 +11,8 @@ export type DraftAutosave = {
|
|
|
11
11
|
subscribe(listener: () => void): () => void;
|
|
12
12
|
/** Cancel pending timers; call on unmount. */
|
|
13
13
|
dispose(): void;
|
|
14
|
+
/** Leave `stale` after the record was reloaded: timers cleared, the reloaded value counts as saved. */
|
|
15
|
+
reset(): void;
|
|
14
16
|
};
|
|
15
17
|
export declare function createDraftAutosave<T>(opts: {
|
|
16
18
|
read: () => T | null;
|
|
@@ -28,4 +30,5 @@ export declare function useDraftAutosave<T>(opts: DraftAutosaveOptions<T>): {
|
|
|
28
30
|
saveState: SaveState;
|
|
29
31
|
mark: () => void;
|
|
30
32
|
flush: () => Promise<boolean>;
|
|
33
|
+
reset: () => void;
|
|
31
34
|
};
|
|
@@ -46,6 +46,12 @@ export function createDraftAutosave(opts) {
|
|
|
46
46
|
opts.onSaved?.();
|
|
47
47
|
return true;
|
|
48
48
|
}
|
|
49
|
+
else if (result.code === "conflict" && result.stale) {
|
|
50
|
+
// The guard refused (spec 2026-09-26 concurrency §4): terminal, never retried —
|
|
51
|
+
// the view freezes and only a reload (reset) brings the controller back.
|
|
52
|
+
setState("stale");
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
49
55
|
else {
|
|
50
56
|
// Keep local state; retry with a doubling, capped delay.
|
|
51
57
|
setState("error");
|
|
@@ -62,12 +68,16 @@ export function createDraftAutosave(opts) {
|
|
|
62
68
|
return () => { listeners.delete(listener); };
|
|
63
69
|
},
|
|
64
70
|
mark() {
|
|
71
|
+
if (state === "stale")
|
|
72
|
+
return;
|
|
65
73
|
revision += 1;
|
|
66
74
|
if (debounceTimer)
|
|
67
75
|
clearTimeout(debounceTimer);
|
|
68
76
|
debounceTimer = setTimeout(() => { debounceTimer = null; void save(); }, debounceMs);
|
|
69
77
|
},
|
|
70
78
|
flush() {
|
|
79
|
+
if (state === "stale")
|
|
80
|
+
return Promise.resolve(false);
|
|
71
81
|
if (revision === savedRevision)
|
|
72
82
|
return Promise.resolve(true);
|
|
73
83
|
if (debounceTimer) {
|
|
@@ -86,6 +96,19 @@ export function createDraftAutosave(opts) {
|
|
|
86
96
|
retryTimer = null;
|
|
87
97
|
}
|
|
88
98
|
},
|
|
99
|
+
reset() {
|
|
100
|
+
if (debounceTimer) {
|
|
101
|
+
clearTimeout(debounceTimer);
|
|
102
|
+
debounceTimer = null;
|
|
103
|
+
}
|
|
104
|
+
if (retryTimer) {
|
|
105
|
+
clearTimeout(retryTimer);
|
|
106
|
+
retryTimer = null;
|
|
107
|
+
}
|
|
108
|
+
savedRevision = revision;
|
|
109
|
+
retryDelay = retryBaseMs;
|
|
110
|
+
setState("saved");
|
|
111
|
+
},
|
|
89
112
|
};
|
|
90
113
|
}
|
|
91
114
|
/** React glue: one controller per mount, always calling the latest
|
|
@@ -104,5 +127,5 @@ export function useDraftAutosave(opts) {
|
|
|
104
127
|
}));
|
|
105
128
|
useEffect(() => () => controller.dispose(), [controller]);
|
|
106
129
|
const saveState = useSyncExternalStore(controller.subscribe, controller.getState, controller.getState);
|
|
107
|
-
return { saveState, mark: controller.mark, flush: controller.flush };
|
|
130
|
+
return { saveState, mark: controller.mark, flush: controller.flush, reset: controller.reset };
|
|
108
131
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { EntryRecord, EntryUsage } from "../../entry-store.ts";
|
|
2
2
|
import type { AdminOps, OpResult } from "../ops.ts";
|
|
3
3
|
import { type SaveState } from "./autosave.ts";
|
|
4
|
+
import { type StaleInfo } from "../shell/stale.ts";
|
|
4
5
|
export type GlobalEditItem = {
|
|
5
6
|
name: string;
|
|
6
7
|
locale: string;
|
|
@@ -15,6 +16,8 @@ export type GlobalEditState = {
|
|
|
15
16
|
items: Record<string, GlobalEditItem>;
|
|
16
17
|
saveState: SaveState;
|
|
17
18
|
loadError: string | null;
|
|
19
|
+
/** The guard's refusal (spec 2026-09-26 concurrency §4): set once, cleared only by reset(). */
|
|
20
|
+
stale: StaleInfo | null;
|
|
18
21
|
};
|
|
19
22
|
export type GlobalEdit = {
|
|
20
23
|
getState(): GlobalEditState;
|
|
@@ -32,7 +35,8 @@ export type GlobalEdit = {
|
|
|
32
35
|
};
|
|
33
36
|
export declare function createGlobalEdit(opts: {
|
|
34
37
|
ops: Pick<AdminOps, "globals">;
|
|
35
|
-
|
|
38
|
+
/** Per saved item, with its name (presence announces each; spec 2026-09-26 concurrency §5). */
|
|
39
|
+
onSaved?: (name: string) => void;
|
|
36
40
|
debounceMs?: number;
|
|
37
41
|
retryBaseMs?: number;
|
|
38
42
|
retryCapMs?: number;
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { setAtPath } from "../field-paths.js";
|
|
2
2
|
import { createDraftAutosave } from "./autosave.js";
|
|
3
|
+
import { draftToken } from "../../draft-token.js";
|
|
4
|
+
import { isStale, staleInfoOf } from "../shell/stale.js";
|
|
3
5
|
export function createGlobalEdit(opts) {
|
|
4
6
|
let items = {};
|
|
5
7
|
let loadError = null;
|
|
8
|
+
let stale = null;
|
|
6
9
|
let generation = 0;
|
|
7
10
|
const loading = new Set();
|
|
8
11
|
const rev = new Map();
|
|
@@ -20,7 +23,7 @@ export function createGlobalEdit(opts) {
|
|
|
20
23
|
const at = rev.get(item.name) ?? 0;
|
|
21
24
|
let r;
|
|
22
25
|
try {
|
|
23
|
-
r = await opts.ops.globals.save(item.name, item.locale, item.fields);
|
|
26
|
+
r = await opts.ops.globals.save(item.name, item.locale, item.fields, draftToken(item.record.locales[item.locale]));
|
|
24
27
|
}
|
|
25
28
|
catch {
|
|
26
29
|
r = { ok: false, code: "internal", message: "Couldn't reach the server." };
|
|
@@ -30,8 +33,11 @@ export function createGlobalEdit(opts) {
|
|
|
30
33
|
savedRev.set(item.name, at);
|
|
31
34
|
if (current)
|
|
32
35
|
items = { ...items, [item.name]: { ...current, record: r.data, errors: {} } };
|
|
36
|
+
opts.onSaved?.(item.name);
|
|
33
37
|
}
|
|
34
38
|
else {
|
|
39
|
+
if (isStale(r))
|
|
40
|
+
stale = staleInfoOf(r);
|
|
35
41
|
failed ?? (failed = r);
|
|
36
42
|
if (current) {
|
|
37
43
|
items = { ...items, [item.name]: { ...current, errors: Object.fromEntries((r.fields ?? []).map((f) => [f.field, f.message])) } };
|
|
@@ -41,13 +47,12 @@ export function createGlobalEdit(opts) {
|
|
|
41
47
|
emit();
|
|
42
48
|
return failed ?? { ok: true, data: null };
|
|
43
49
|
},
|
|
44
|
-
onSaved: opts.onSaved,
|
|
45
50
|
debounceMs: opts.debounceMs,
|
|
46
51
|
retryBaseMs: opts.retryBaseMs,
|
|
47
52
|
retryCapMs: opts.retryCapMs,
|
|
48
53
|
});
|
|
49
54
|
autosave.subscribe(emit);
|
|
50
|
-
const getState = () => (snapshot ?? (snapshot = { items, saveState: autosave.getState(), loadError }));
|
|
55
|
+
const getState = () => (snapshot ?? (snapshot = { items, saveState: autosave.getState(), loadError, stale }));
|
|
51
56
|
return {
|
|
52
57
|
getState,
|
|
53
58
|
subscribe(listener) {
|
|
@@ -98,7 +103,7 @@ export function createGlobalEdit(opts) {
|
|
|
98
103
|
}
|
|
99
104
|
let r;
|
|
100
105
|
try {
|
|
101
|
-
r = await opts.ops.globals.setStatus(name, item.locale, status);
|
|
106
|
+
r = await opts.ops.globals.setStatus(name, item.locale, status, draftToken(item.record.locales[item.locale]));
|
|
102
107
|
}
|
|
103
108
|
catch {
|
|
104
109
|
r = { ok: false, code: "internal", message: "Couldn't reach the server." };
|
|
@@ -107,6 +112,10 @@ export function createGlobalEdit(opts) {
|
|
|
107
112
|
items = { ...items, [name]: { ...items[name], record: r.data } };
|
|
108
113
|
emit();
|
|
109
114
|
}
|
|
115
|
+
else if (isStale(r)) {
|
|
116
|
+
stale = staleInfoOf(r);
|
|
117
|
+
emit();
|
|
118
|
+
}
|
|
110
119
|
return r;
|
|
111
120
|
},
|
|
112
121
|
reset() {
|
|
@@ -116,7 +125,8 @@ export function createGlobalEdit(opts) {
|
|
|
116
125
|
savedRev.clear();
|
|
117
126
|
loading.clear();
|
|
118
127
|
loadError = null;
|
|
119
|
-
|
|
128
|
+
stale = null;
|
|
129
|
+
autosave.reset(); // timers cleared, and a stale controller comes back for the new canvas
|
|
120
130
|
emit();
|
|
121
131
|
},
|
|
122
132
|
dispose: autosave.dispose,
|
|
@@ -2,5 +2,5 @@ import type { AdminOps } from "../ops.ts";
|
|
|
2
2
|
import { type GlobalEdit, type GlobalEditState } from "./global-edit.ts";
|
|
3
3
|
export declare function useGlobalEdit({ ops, onSaved }: {
|
|
4
4
|
ops: Pick<AdminOps, "globals">;
|
|
5
|
-
onSaved?: () => void;
|
|
5
|
+
onSaved?: (name: string) => void;
|
|
6
6
|
}): GlobalEditState & Pick<GlobalEdit, "load" | "patch" | "flush" | "setStatus" | "reset">;
|
|
@@ -7,7 +7,7 @@ import { createGlobalEdit } from "./global-edit.js";
|
|
|
7
7
|
export function useGlobalEdit({ ops, onSaved }) {
|
|
8
8
|
const latest = useRef(onSaved);
|
|
9
9
|
latest.current = onSaved;
|
|
10
|
-
const [controller] = useState(() => createGlobalEdit({ ops, onSaved: () => latest.current?.() }));
|
|
10
|
+
const [controller] = useState(() => createGlobalEdit({ ops, onSaved: (name) => latest.current?.(name) }));
|
|
11
11
|
useEffect(() => () => { void controller.flush(); controller.dispose(); }, [controller]);
|
|
12
12
|
const state = useSyncExternalStore(controller.subscribe, controller.getState, controller.getState);
|
|
13
13
|
return {
|
|
@@ -2,10 +2,14 @@ import type { ReactNode } from "react";
|
|
|
2
2
|
import type { ResolvedConfig } from "../../config.ts";
|
|
3
3
|
import type { AdminOpAction } from "../ops.ts";
|
|
4
4
|
import type { AuthConfig } from "../shell/session.ts";
|
|
5
|
+
import type { ContentConfig } from "../../env.ts";
|
|
6
|
+
/** `content` (optional) is the content project's public config for editing presence (spec 2026-09-26
|
|
7
|
+
* concurrency §5); absent — an app wired before it — presence is off and everything else works. */
|
|
5
8
|
export declare function createSmoodlyAdmin(input: {
|
|
6
9
|
config: ResolvedConfig;
|
|
7
10
|
op: AdminOpAction;
|
|
8
11
|
auth: () => AuthConfig;
|
|
12
|
+
content?: () => ContentConfig;
|
|
9
13
|
}): {
|
|
10
14
|
AdminPage: () => null;
|
|
11
15
|
AdminLayout: ({ children }: {
|
|
@@ -2,6 +2,8 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
import { serializeAdminConfig } from "../serialize.js";
|
|
3
3
|
import { AdminApp } from "../shell/AdminApp.js";
|
|
4
4
|
import { AdminStyles } from "../ui/theme.js";
|
|
5
|
+
/** `content` (optional) is the content project's public config for editing presence (spec 2026-09-26
|
|
6
|
+
* concurrency §5); absent — an app wired before it — presence is off and everything else works. */
|
|
5
7
|
export function createSmoodlyAdmin(input) {
|
|
6
8
|
const registry = serializeAdminConfig(input.config);
|
|
7
9
|
/** The catch-all page only makes every /admin URL exist. The shell is
|
|
@@ -22,7 +24,7 @@ export function createSmoodlyAdmin(input) {
|
|
|
22
24
|
* on this element before React hydrates, on purpose (no white flash
|
|
23
25
|
* for a dark-mode editor), so the attribute differs from the server's. */
|
|
24
26
|
function AdminLayout({ children }) {
|
|
25
|
-
return (_jsxs("div", { className: "sm-admin", suppressHydrationWarning: true, children: [_jsx(AdminStyles, {}), _jsx(AdminApp, { registry: registry, op: input.op, auth: input.auth() }), children] }));
|
|
27
|
+
return (_jsxs("div", { className: "sm-admin", suppressHydrationWarning: true, children: [_jsx(AdminStyles, {}), _jsx(AdminApp, { registry: registry, op: input.op, auth: input.auth(), content: input.content?.() }), children] }));
|
|
26
28
|
}
|
|
27
29
|
return { AdminPage, AdminLayout };
|
|
28
30
|
}
|
|
@@ -4,5 +4,6 @@ export type { AdminOpCall, AdminOpAction, AdminOps, OpResult, MembersListResult
|
|
|
4
4
|
export type { InviteInput, InviteResult, MemberRecord } from "../../member-store.ts";
|
|
5
5
|
export type { MemberRole } from "../../permissions.ts";
|
|
6
6
|
export type { AuthConfig } from "../shell/session.ts";
|
|
7
|
+
export type { ContentConfig } from "../../env.ts";
|
|
7
8
|
export type { AdminAuth } from "../auth.ts";
|
|
8
9
|
export { supabaseAdminAuth } from "../supabase-auth.ts";
|