@drawnagency/primitives 0.1.76 → 0.1.77
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/dist/chunk-2G2S36SY.js +13 -0
- package/dist/{chunk-7T2WQSWF.js → chunk-63BQQNZO.js} +9 -9
- package/dist/chunk-REM2NH7K.js +44 -0
- package/dist/{chunk-SWEVWC5K.js → chunk-RO4E5UJI.js} +21 -21
- package/dist/{chunk-NKVZGPR6.js → chunk-YP7IHMC3.js} +1 -1
- package/dist/collab/provider-registry.d.ts +4 -0
- package/dist/collab/provider-registry.d.ts.map +1 -0
- package/dist/collab/provider-registry.js +9 -0
- package/dist/collab/rebase.d.ts +37 -0
- package/dist/collab/rebase.d.ts.map +1 -0
- package/dist/collab/rebase.js +64 -0
- package/dist/collab/recovery.d.ts +89 -0
- package/dist/collab/recovery.d.ts.map +1 -0
- package/dist/collab/types.d.ts +93 -0
- package/dist/collab/types.d.ts.map +1 -0
- package/dist/collab/types.js +19 -0
- package/dist/components/editor/AudienceIndicator.d.ts +3 -1
- package/dist/components/editor/AudienceIndicator.d.ts.map +1 -1
- package/dist/components/editor/ConflictDialog.d.ts +14 -0
- package/dist/components/editor/ConflictDialog.d.ts.map +1 -0
- package/dist/components/editor/IndicatorPill.d.ts +4 -1
- package/dist/components/editor/IndicatorPill.d.ts.map +1 -1
- package/dist/components/editor/LockBadge.d.ts +7 -0
- package/dist/components/editor/LockBadge.d.ts.map +1 -0
- package/dist/components/editor/PresenceAvatars.d.ts +9 -0
- package/dist/components/editor/PresenceAvatars.d.ts.map +1 -0
- package/dist/components/editor/SectionWrapper.d.ts +1 -1
- package/dist/components/editor/SectionWrapper.d.ts.map +1 -1
- package/dist/components/editor/StatusIndicator.d.ts +3 -1
- package/dist/components/editor/StatusIndicator.d.ts.map +1 -1
- package/dist/components/editor/index.js +92 -44
- package/dist/components/primitives/index.js +3 -3
- package/dist/components/sections/brand-guide.js +3 -3
- package/dist/components/sections/register-schemas.js +9 -9
- package/dist/components/sections/register.js +9 -9
- package/dist/components/shell/EditorShell.d.ts +3 -1
- package/dist/components/shell/EditorShell.d.ts.map +1 -1
- package/dist/hooks/useCollabSession.d.ts +23 -0
- package/dist/hooks/useCollabSession.d.ts.map +1 -0
- package/dist/hooks/useEditorPersistence.d.ts +21 -1
- package/dist/hooks/useEditorPersistence.d.ts.map +1 -1
- package/dist/hooks/useEditorPublish.d.ts +39 -2
- package/dist/hooks/useEditorPublish.d.ts.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +44 -22
- package/dist/lib/dexie.d.ts +12 -0
- package/dist/lib/dexie.d.ts.map +1 -1
- package/dist/lib/dexie.js +27 -0
- package/dist/lib/index.js +12 -12
- package/dist/lib/registry.d.ts +10 -0
- package/dist/lib/registry.d.ts.map +1 -1
- package/dist/schemas/index.js +1 -1
- package/dist/types/database.d.ts +70 -0
- package/dist/types/database.d.ts.map +1 -1
- package/package.json +13 -1
- package/src/collab/provider-registry.ts +14 -0
- package/src/collab/rebase.ts +140 -0
- package/src/collab/recovery.ts +149 -0
- package/src/collab/types.ts +96 -0
- package/src/components/editor/AudienceIndicator.tsx +4 -1
- package/src/components/editor/ConflictDialog.tsx +61 -0
- package/src/components/editor/IndicatorPill.tsx +12 -6
- package/src/components/editor/LockBadge.tsx +31 -0
- package/src/components/editor/PresenceAvatars.tsx +53 -0
- package/src/components/editor/SectionWrapper.tsx +60 -29
- package/src/components/editor/StatusIndicator.tsx +4 -0
- package/src/components/shell/EditorShell.tsx +561 -18
- package/src/hooks/useCollabSession.ts +220 -0
- package/src/hooks/useEditorPersistence.ts +104 -1
- package/src/hooks/useEditorPublish.ts +205 -63
- package/src/index.ts +2 -0
- package/src/lib/dexie.ts +44 -0
- package/src/lib/registry.ts +8 -0
- package/src/types/database.ts +55 -0
- package/dist/{chunk-Q3T23HVW.js → chunk-B2DLHZKC.js} +4 -4
- package/dist/{chunk-T7MP7ANP.js → chunk-UUCOJL7O.js} +0 -0
- package/dist/{chunk-ZRWGV5O6.js → chunk-XFDEUK37.js} +3 -3
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// rebase.ts — pure-function rebase merge: fold a fresh remote (saved-branch)
|
|
2
|
+
// content snapshot under local unsaved work. Consumed by the 409 auto-recovery
|
|
3
|
+
// path and the live save-sync path (both in core); no I/O here.
|
|
4
|
+
import type { LoadedSection } from "../lib/loader";
|
|
5
|
+
import type { SiteIndex } from "../schemas/site-config";
|
|
6
|
+
|
|
7
|
+
export interface RebaseArgs {
|
|
8
|
+
localSections: LoadedSection[];
|
|
9
|
+
localIndex: SiteIndex;
|
|
10
|
+
localIndexDirty: boolean;
|
|
11
|
+
dirtySectionIds: Set<string>;
|
|
12
|
+
/** Section ids created locally since the last clean baseline (i.e. dirty ids
|
|
13
|
+
* absent from the last-loaded saved-branch index). Callers derive this from
|
|
14
|
+
* the cached clean baseline (Dexie contentCache index), NOT from remoteIndex —
|
|
15
|
+
* remote absence is exactly what this field disambiguates: without it, a
|
|
16
|
+
* locally-added unsaved section is indistinguishable from a section deleted
|
|
17
|
+
* remotely while dirty (both dirty, present locally, absent remotely). */
|
|
18
|
+
locallyAddedIds: Set<string>;
|
|
19
|
+
locallyDeletedIds: Set<string>;
|
|
20
|
+
remoteSections: LoadedSection[];
|
|
21
|
+
remoteIndex: SiteIndex;
|
|
22
|
+
// Sections the remote save changed (from SavedMessage, or a diff on 409
|
|
23
|
+
// recovery). Remote deletions of dirty sections are detected directly from
|
|
24
|
+
// remoteIndex, so callers need not fold deletedSectionIds in — but doing so
|
|
25
|
+
// is harmless.
|
|
26
|
+
remoteChangedIds: Set<string>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface RebaseResult {
|
|
30
|
+
sections: LoadedSection[];
|
|
31
|
+
index: SiteIndex;
|
|
32
|
+
// dirty locally AND changed (or deleted) remotely — needs user choice.
|
|
33
|
+
// Never contains locally-added unsaved ids (see locallyAddedIds) or
|
|
34
|
+
// locally-deleted ids.
|
|
35
|
+
conflictIds: string[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Merge a fresh remote (saved-branch) snapshot under local unsaved work.
|
|
39
|
+
* Remote wins everywhere the local editor has no unsaved edits; dirty local
|
|
40
|
+
* sections are kept verbatim; overlaps are reported as conflicts for the
|
|
41
|
+
* caller's keep-mine/take-theirs dialog. Implements the 5 merge rules from
|
|
42
|
+
* the collaboration spec ("conflict-free by construction under lock
|
|
43
|
+
* discipline").
|
|
44
|
+
*
|
|
45
|
+
* Invariants: inputs are never mutated (the returned index is a fresh
|
|
46
|
+
* structure in both branches), and the result partitions: every section in
|
|
47
|
+
* `sections` is reachable from `index`; a dirty section deleted remotely is
|
|
48
|
+
* kept, re-linked into the index, and reported in `conflictIds`. */
|
|
49
|
+
export function rebaseContent(args: RebaseArgs): RebaseResult {
|
|
50
|
+
const localById = new Map(args.localSections.map((s) => [s.section.id, s]));
|
|
51
|
+
const remoteIds = new Set(args.remoteSections.map((s) => s.section.id));
|
|
52
|
+
|
|
53
|
+
// Rules 1-3: remote baseline (in remote order), overlay local-dirty
|
|
54
|
+
// sections, drop locally-deleted ids. The append loop also skips locally
|
|
55
|
+
// deleted ids — a section edited THEN deleted locally must stay deleted.
|
|
56
|
+
const merged: LoadedSection[] = args.remoteSections
|
|
57
|
+
.filter((s) => !args.locallyDeletedIds.has(s.section.id))
|
|
58
|
+
.map((s) => (args.dirtySectionIds.has(s.section.id) ? (localById.get(s.section.id) ?? s) : s));
|
|
59
|
+
for (const id of args.dirtySectionIds) {
|
|
60
|
+
if (remoteIds.has(id) || args.locallyDeletedIds.has(id)) continue;
|
|
61
|
+
const local = localById.get(id);
|
|
62
|
+
if (local) merged.push(local); // locally-added, or dirty survivor of a remote deletion
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Rule 4: conflicts = dirty ∩ remote-changed, PLUS dirty survivors of a
|
|
66
|
+
// remote deletion (present locally, absent from remoteIndex) — detected
|
|
67
|
+
// here directly; the caller is not trusted to fold remote deletions into
|
|
68
|
+
// remoteChangedIds. Locally-added ids are excluded from the deletion
|
|
69
|
+
// backstop: they are absent remotely because they were never saved, not
|
|
70
|
+
// because anyone deleted them.
|
|
71
|
+
const conflictSet = new Set<string>();
|
|
72
|
+
for (const id of args.dirtySectionIds) {
|
|
73
|
+
if (args.locallyDeletedIds.has(id)) continue; // dropped above; nothing to resolve
|
|
74
|
+
if (args.remoteChangedIds.has(id)) {
|
|
75
|
+
conflictSet.add(id);
|
|
76
|
+
} else if (localById.has(id) && !(id in args.remoteIndex.sections) && !args.locallyAddedIds.has(id)) {
|
|
77
|
+
conflictSet.add(id); // deleted remotely while dirty locally
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const conflictIds = [...conflictSet].sort();
|
|
81
|
+
|
|
82
|
+
// Rule 5: index merge. Both branches build a fresh structure (fresh page
|
|
83
|
+
// objects, fresh order arrays, fresh sections map) so the invariant repair
|
|
84
|
+
// below never mutates an input.
|
|
85
|
+
let index: SiteIndex;
|
|
86
|
+
if (!args.localIndexDirty) {
|
|
87
|
+
index = {
|
|
88
|
+
...args.remoteIndex,
|
|
89
|
+
pages: args.remoteIndex.pages.map((p) => ({ ...p, order: [...p.order] })),
|
|
90
|
+
sections: { ...args.remoteIndex.sections },
|
|
91
|
+
};
|
|
92
|
+
} else {
|
|
93
|
+
const localOrderIds = new Set(args.localIndex.pages.flatMap((p) => p.order));
|
|
94
|
+
|
|
95
|
+
// Local wins: drop ids that are absent from both the remote index and
|
|
96
|
+
// the local dirty/added set (deleted remotely, not something we're
|
|
97
|
+
// actively editing).
|
|
98
|
+
const pages = args.localIndex.pages.map((p) => ({
|
|
99
|
+
...p,
|
|
100
|
+
order: p.order.filter((id) => id in args.remoteIndex.sections || args.dirtySectionIds.has(id)),
|
|
101
|
+
}));
|
|
102
|
+
const sections: SiteIndex["sections"] = {};
|
|
103
|
+
for (const [id, meta] of Object.entries(args.localIndex.sections)) {
|
|
104
|
+
if (id in args.remoteIndex.sections || args.dirtySectionIds.has(id)) sections[id] = meta;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Reconcile: append remote-new ids (present remotely, absent locally) to
|
|
108
|
+
// the local page occupying the same page id remotely; fall back to the
|
|
109
|
+
// first local page if that page id doesn't exist locally. Never creates
|
|
110
|
+
// a new page.
|
|
111
|
+
for (const remotePage of args.remoteIndex.pages) {
|
|
112
|
+
for (const id of remotePage.order) {
|
|
113
|
+
if (localOrderIds.has(id) || args.locallyDeletedIds.has(id)) continue;
|
|
114
|
+
const target = pages.find((p) => p.id === remotePage.id) ?? pages[0];
|
|
115
|
+
if (target && !target.order.includes(id)) target.order.push(id);
|
|
116
|
+
sections[id] = args.remoteIndex.sections[id];
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
index = { ...args.localIndex, pages, sections };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Partition invariant (unconditional): every id in `merged` must be
|
|
124
|
+
// reachable from `index`. The only ids that can be missing are dirty
|
|
125
|
+
// survivors of a remote deletion — re-link each where the local index had
|
|
126
|
+
// it (else the first page), with its local index meta, falling back to the
|
|
127
|
+
// validated meta the LoadedSection itself carries.
|
|
128
|
+
for (const s of merged) {
|
|
129
|
+
const id = s.section.id;
|
|
130
|
+
if (id in index.sections) continue;
|
|
131
|
+
const meta = args.localIndex.sections[id] ?? s.meta;
|
|
132
|
+
const localPage = args.localIndex.pages.find((p) => p.order.includes(id));
|
|
133
|
+
const target = (localPage && index.pages.find((p) => p.id === localPage.id)) ?? index.pages[0];
|
|
134
|
+
if (!target) continue;
|
|
135
|
+
if (!target.order.includes(id)) target.order.push(id);
|
|
136
|
+
index.sections[id] = meta;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return { sections: merged, index, conflictIds };
|
|
140
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// recovery.ts — pure functions for the 409 auto-recovery path. The EditorShell
|
|
2
|
+
// keeps only IO glue (fetch, Dexie, setState); everything decision-shaped lives
|
|
3
|
+
// here so it can be unit-tested directly with injected data.
|
|
4
|
+
import type { LoadedSection } from "../lib/loader";
|
|
5
|
+
import type { Section, SectionContent } from "../schemas/sections";
|
|
6
|
+
import type { SiteIndex } from "../schemas/site-config";
|
|
7
|
+
|
|
8
|
+
/** The last CLEAN saved-branch snapshot (no local dirty overlays folded in).
|
|
9
|
+
* Lifecycle: primed by loadContent, re-primed with the committed snapshot on
|
|
10
|
+
* every successful save, and re-primed with the fetched remote snapshot on
|
|
11
|
+
* every recovery — so it is always the editor's last agreed-with-server state. */
|
|
12
|
+
export interface RecoveryBaseline {
|
|
13
|
+
sections: LoadedSection[];
|
|
14
|
+
index: SiteIndex;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Derive rebaseContent's remoteChangedIds + locallyAddedIds from a clean
|
|
18
|
+
* baseline and the freshly fetched remote snapshot.
|
|
19
|
+
*
|
|
20
|
+
* - remoteChangedIds: sections whose content differs between the clean baseline
|
|
21
|
+
* and the fresh remote. The baseline MUST be clean — diffing against the dirty
|
|
22
|
+
* in-memory view would flag every locally-edited section as remote-changed,
|
|
23
|
+
* turning every dirty section into a false conflict.
|
|
24
|
+
* - locallyAddedIds: dirty ids absent from the clean baseline INDEX — sections
|
|
25
|
+
* created locally and never saved. This is what lets rebase distinguish a
|
|
26
|
+
* local add from a section deleted remotely while dirty.
|
|
27
|
+
*
|
|
28
|
+
* Degraded mode (baseline === null — should be unreachable, since every load,
|
|
29
|
+
* save, and recovery re-primes the cache): fail SAFE, not silent. Every dirty
|
|
30
|
+
* section that still exists remotely is flagged remote-changed (forcing the
|
|
31
|
+
* keep-mine/take-theirs dialog instead of a silent local-wins merge), and every
|
|
32
|
+
* dirty section absent remotely is treated as locally added (kept — a true
|
|
33
|
+
* remote-deletion-while-dirty resurrects instead of conflicting, which loses
|
|
34
|
+
* nothing). This is deliberately NOT derived from the published mainIndex: a
|
|
35
|
+
* main-based baseline would silently local-win every dirty section that already
|
|
36
|
+
* exists on main — the exact lost-update this path exists to prevent. */
|
|
37
|
+
export function deriveRecoveryInputs({ baseline, remote, dirtyRows, deletedIds }: {
|
|
38
|
+
baseline: RecoveryBaseline | null;
|
|
39
|
+
remote: { sections: LoadedSection[]; index: SiteIndex };
|
|
40
|
+
dirtyRows: { sectionId: string }[];
|
|
41
|
+
deletedIds: Set<string>;
|
|
42
|
+
}): { remoteChangedIds: Set<string>; locallyAddedIds: Set<string> } {
|
|
43
|
+
// Locally deleted ids are dropped by rebase regardless; keep them out of both
|
|
44
|
+
// derived sets so a delete never masquerades as an add or a conflict.
|
|
45
|
+
const dirtyIds = new Set(dirtyRows.map((r) => r.sectionId).filter((id) => !deletedIds.has(id)));
|
|
46
|
+
|
|
47
|
+
if (!baseline) {
|
|
48
|
+
return {
|
|
49
|
+
remoteChangedIds: new Set([...dirtyIds].filter((id) => id in remote.index.sections)),
|
|
50
|
+
locallyAddedIds: new Set([...dirtyIds].filter((id) => !(id in remote.index.sections))),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const baselineById = new Map(baseline.sections.map((s) => [s.section.id, JSON.stringify(s.section)]));
|
|
55
|
+
const remoteChangedIds = new Set(
|
|
56
|
+
remote.sections
|
|
57
|
+
.filter((s) => baselineById.has(s.section.id) && baselineById.get(s.section.id) !== JSON.stringify(s.section))
|
|
58
|
+
.map((s) => s.section.id),
|
|
59
|
+
);
|
|
60
|
+
// Safety: a dirty id that exists remotely but was never in the baseline means
|
|
61
|
+
// both sides created the same id independently (id collision). Flag it so the
|
|
62
|
+
// overlap goes to the dialog instead of silently keeping one side.
|
|
63
|
+
for (const id of dirtyIds) {
|
|
64
|
+
if (id in remote.index.sections && !(id in baseline.index.sections)) remoteChangedIds.add(id);
|
|
65
|
+
}
|
|
66
|
+
const locallyAddedIds = new Set([...dirtyIds].filter((id) => !(id in baseline.index.sections)));
|
|
67
|
+
return { remoteChangedIds, locallyAddedIds };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Apply the user's keep-mine/take-theirs choices to a rebase result.
|
|
71
|
+
* Pure — inputs are never mutated. For every conflict the LOCAL copy is
|
|
72
|
+
* returned in `preserved` (the caller snapshots it via preserveEdit so no
|
|
73
|
+
* choice is destructive). "theirs" swaps in the remote section — or, when the
|
|
74
|
+
* remote side DELETED it, removes the section AND strips its id from the index
|
|
75
|
+
* (pages' order + sections map); leaving the dangling index ref would make the
|
|
76
|
+
* server's dangling-ref gate reject every later save. `droppedDirtyIds` lists
|
|
77
|
+
* the "theirs" ids whose local dirty rows must be deleted so the retry doesn't
|
|
78
|
+
* re-send the overridden local content. */
|
|
79
|
+
export function applyConflictChoices({ merged, remoteSections, choices }: {
|
|
80
|
+
merged: { sections: LoadedSection[]; index: SiteIndex };
|
|
81
|
+
remoteSections: LoadedSection[];
|
|
82
|
+
choices: Record<string, "mine" | "theirs">;
|
|
83
|
+
}): {
|
|
84
|
+
sections: LoadedSection[];
|
|
85
|
+
index: SiteIndex;
|
|
86
|
+
droppedDirtyIds: string[];
|
|
87
|
+
preserved: { sectionId: string; section: Section }[];
|
|
88
|
+
} {
|
|
89
|
+
let sections = [...merged.sections];
|
|
90
|
+
const index: SiteIndex = {
|
|
91
|
+
...merged.index,
|
|
92
|
+
pages: merged.index.pages.map((p) => ({ ...p, order: [...p.order] })),
|
|
93
|
+
sections: { ...merged.index.sections },
|
|
94
|
+
};
|
|
95
|
+
const droppedDirtyIds: string[] = [];
|
|
96
|
+
const preserved: { sectionId: string; section: Section }[] = [];
|
|
97
|
+
const remoteById = new Map(remoteSections.map((s) => [s.section.id, s]));
|
|
98
|
+
|
|
99
|
+
for (const [id, choice] of Object.entries(choices)) {
|
|
100
|
+
const local = sections.find((s) => s.section.id === id);
|
|
101
|
+
if (local) preserved.push({ sectionId: id, section: local.section });
|
|
102
|
+
if (choice !== "theirs") continue;
|
|
103
|
+
droppedDirtyIds.push(id);
|
|
104
|
+
const remote = remoteById.get(id);
|
|
105
|
+
if (remote) {
|
|
106
|
+
sections = sections.map((s) => (s.section.id === id ? remote : s));
|
|
107
|
+
} else {
|
|
108
|
+
// Remote deletion won: drop the section and every index reference to it.
|
|
109
|
+
sections = sections.filter((s) => s.section.id !== id);
|
|
110
|
+
for (const page of index.pages) page.order = page.order.filter((x) => x !== id);
|
|
111
|
+
delete index.sections[id];
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return { sections, index, droppedDirtyIds, preserved };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Build the COMMITTED post-save snapshot for re-priming the clean baseline.
|
|
119
|
+
* The committed content is exactly what performSave sent (savedRows), NOT the
|
|
120
|
+
* current view state — edits typed while the request was in flight must not
|
|
121
|
+
* leak into the "clean" baseline (they are still dirty rows and would otherwise
|
|
122
|
+
* read as remote changes on the next recovery). Base = the previous clean
|
|
123
|
+
* baseline when available (post-recovery it is the fetched remote snapshot);
|
|
124
|
+
* fallback = the loaded sections captured at save start. Ids absent from the
|
|
125
|
+
* sent index (deleted this save) are dropped; saved ids absent from the base
|
|
126
|
+
* (locally added) are appended with their meta from the sent index. */
|
|
127
|
+
export function buildCommittedBaseline({ baselineSections, fallbackSections, savedRows, sentIndex }: {
|
|
128
|
+
baselineSections: LoadedSection[] | null;
|
|
129
|
+
fallbackSections: LoadedSection[];
|
|
130
|
+
savedRows: { sectionId: string; content: SectionContent }[];
|
|
131
|
+
sentIndex: SiteIndex;
|
|
132
|
+
}): LoadedSection[] {
|
|
133
|
+
const base = baselineSections ?? fallbackSections;
|
|
134
|
+
const sentById = new Map(savedRows.map((r) => [r.sectionId, r.content]));
|
|
135
|
+
|
|
136
|
+
const out = base
|
|
137
|
+
.filter((s) => s.section.id in sentIndex.sections)
|
|
138
|
+
.map((s) => {
|
|
139
|
+
const sent = sentById.get(s.section.id);
|
|
140
|
+
return sent ? { ...s, section: { ...s.section, ...sent } } : s;
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
const present = new Set(out.map((s) => s.section.id));
|
|
144
|
+
for (const [id, content] of sentById) {
|
|
145
|
+
if (present.has(id) || !(id in sentIndex.sections)) continue;
|
|
146
|
+
out.push({ section: { id, ...content }, meta: sentIndex.sections[id] });
|
|
147
|
+
}
|
|
148
|
+
return out;
|
|
149
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// types.ts — the whole contract. No Supabase imports anywhere in primitives.
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
|
|
4
|
+
export const HEARTBEAT_INTERVAL_MS = 15_000;
|
|
5
|
+
export const LOCK_STALE_MS = 45_000;
|
|
6
|
+
|
|
7
|
+
export const CollabPeerSchema = z.object({
|
|
8
|
+
userId: z.string(),
|
|
9
|
+
email: z.string(),
|
|
10
|
+
focusSectionId: z.string().nullable(),
|
|
11
|
+
heldSectionIds: z.array(z.string()),
|
|
12
|
+
});
|
|
13
|
+
export type CollabPeer = z.infer<typeof CollabPeerSchema>;
|
|
14
|
+
|
|
15
|
+
export const SectionUpdateMessageSchema = z.object({
|
|
16
|
+
v: z.literal(1),
|
|
17
|
+
sectionId: z.string(),
|
|
18
|
+
content: z.unknown(),
|
|
19
|
+
userId: z.string(),
|
|
20
|
+
});
|
|
21
|
+
export type SectionUpdateMessage = z.infer<typeof SectionUpdateMessageSchema>;
|
|
22
|
+
|
|
23
|
+
export const IndexUpdateMessageSchema = z.object({
|
|
24
|
+
v: z.literal(1),
|
|
25
|
+
index: z.unknown(),
|
|
26
|
+
userId: z.string(),
|
|
27
|
+
});
|
|
28
|
+
export type IndexUpdateMessage = z.infer<typeof IndexUpdateMessageSchema>;
|
|
29
|
+
|
|
30
|
+
export const TakeoverMessageSchema = z.object({
|
|
31
|
+
v: z.literal(1),
|
|
32
|
+
sectionId: z.string(),
|
|
33
|
+
byUserId: z.string(),
|
|
34
|
+
byEmail: z.string(),
|
|
35
|
+
});
|
|
36
|
+
export type TakeoverMessage = z.infer<typeof TakeoverMessageSchema>;
|
|
37
|
+
|
|
38
|
+
export const SavedMessageSchema = z.object({
|
|
39
|
+
v: z.literal(1),
|
|
40
|
+
newSha: z.string(),
|
|
41
|
+
changedSectionIds: z.array(z.string()),
|
|
42
|
+
deletedSectionIds: z.array(z.string()),
|
|
43
|
+
source: z.enum(["editor", "api"]),
|
|
44
|
+
});
|
|
45
|
+
export type SavedMessage = z.infer<typeof SavedMessageSchema>;
|
|
46
|
+
|
|
47
|
+
export type CollabStatus = "connecting" | "connected" | "offline";
|
|
48
|
+
|
|
49
|
+
/** Opaque server-minted connection blob. Provider-specific keys ride along;
|
|
50
|
+
* primitives only reads the three identity fields. */
|
|
51
|
+
export interface CollabBootstrap {
|
|
52
|
+
siteId: string;
|
|
53
|
+
userId: string;
|
|
54
|
+
email: string;
|
|
55
|
+
[key: string]: unknown;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface CollabEvents {
|
|
59
|
+
onPeers(peers: CollabPeer[]): void;
|
|
60
|
+
onSectionUpdate(msg: SectionUpdateMessage): void;
|
|
61
|
+
onIndexUpdate(msg: IndexUpdateMessage): void;
|
|
62
|
+
onTakeover(msg: TakeoverMessage): void;
|
|
63
|
+
onSaved(msg: SavedMessage): void;
|
|
64
|
+
onStatus(status: CollabStatus): void;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** A live connection to one site channel. The session owns the held-lock set
|
|
68
|
+
* (heartbeats them every HEARTBEAT_INTERVAL_MS and includes them in its
|
|
69
|
+
* presence payload). acquireLock({steal:true}) also emits the takeover
|
|
70
|
+
* broadcast on success. */
|
|
71
|
+
export interface CollabSession {
|
|
72
|
+
setFocus(sectionId: string | null): void;
|
|
73
|
+
acquireLock(sectionId: string, opts?: { steal?: boolean }): Promise<boolean>;
|
|
74
|
+
releaseLocks(sectionIds: string[]): Promise<void>;
|
|
75
|
+
broadcastSectionUpdate(sectionId: string, content: unknown): void;
|
|
76
|
+
broadcastIndexUpdate(index: unknown): void;
|
|
77
|
+
disconnect(): Promise<void>;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface SavedBroadcast {
|
|
81
|
+
newSha: string;
|
|
82
|
+
changedSectionIds: string[];
|
|
83
|
+
deletedSectionIds: string[];
|
|
84
|
+
source: "editor" | "api";
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface CollabProvider {
|
|
88
|
+
/** Browser: join the site channel. Resolves null when collaboration is
|
|
89
|
+
* unavailable (no session, join refused) — caller runs solo-mode. */
|
|
90
|
+
connect(bootstrap: CollabBootstrap, events: CollabEvents): Promise<CollabSession | null>;
|
|
91
|
+
/** Server: mint the browser bootstrap for an authenticated editor session,
|
|
92
|
+
* or null when the provider can't support collab (e.g. missing env). */
|
|
93
|
+
getBootstrap(args: { siteId: string; userId: string; email: string }): CollabBootstrap | null;
|
|
94
|
+
/** Server: post-commit fan-out of a successful save to all connected editors. */
|
|
95
|
+
broadcastSaved(siteId: string, event: SavedBroadcast): Promise<void>;
|
|
96
|
+
}
|
|
@@ -8,9 +8,11 @@ interface AudienceIndicatorProps {
|
|
|
8
8
|
access: string[];
|
|
9
9
|
audiences: Audience[];
|
|
10
10
|
onChange: (access: string[]) => void;
|
|
11
|
+
/** Display-only mode — the pill still shows audiences but the picker never opens. */
|
|
12
|
+
disabled?: boolean;
|
|
11
13
|
}
|
|
12
14
|
|
|
13
|
-
export function AudienceIndicator({ access, audiences, onChange }: AudienceIndicatorProps) {
|
|
15
|
+
export function AudienceIndicator({ access, audiences, onChange, disabled }: AudienceIndicatorProps) {
|
|
14
16
|
const selected = audiences.filter((a) => access.includes(a.name));
|
|
15
17
|
const visibleDots = selected.slice(0, 3);
|
|
16
18
|
|
|
@@ -35,6 +37,7 @@ export function AudienceIndicator({ access, audiences, onChange }: AudienceIndic
|
|
|
35
37
|
dots={pillDots}
|
|
36
38
|
label={labelText}
|
|
37
39
|
buttonClassName={selected.length === 0 ? "text-base-contrast-light" : undefined}
|
|
40
|
+
disabled={disabled}
|
|
38
41
|
clickContent={
|
|
39
42
|
audiences.length === 0 ? (
|
|
40
43
|
<div className="px-3 py-2 text-xs text-base-contrast-light whitespace-nowrap">
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { useRef, useState } from "react";
|
|
2
|
+
import { Button } from "../shared/Button";
|
|
3
|
+
import { useFocusTrap } from "../../hooks/useFocusTrap";
|
|
4
|
+
|
|
5
|
+
export interface ConflictItem {
|
|
6
|
+
sectionId: string;
|
|
7
|
+
label: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Presented on 409 recovery when a section the editor changed was ALSO changed
|
|
11
|
+
* remotely. The user picks which side wins per section; the losing local copy
|
|
12
|
+
* is always snapshotted to Dexie either way (stored, not user-recoverable via
|
|
13
|
+
* the UI). Defaults every conflict to "mine" so a blind Continue never silently
|
|
14
|
+
* drops local work. */
|
|
15
|
+
export function ConflictDialog({
|
|
16
|
+
conflicts,
|
|
17
|
+
onResolve,
|
|
18
|
+
}: {
|
|
19
|
+
conflicts: ConflictItem[];
|
|
20
|
+
onResolve: (choices: Record<string, "mine" | "theirs">) => void;
|
|
21
|
+
}) {
|
|
22
|
+
const [choices, setChoices] = useState<Record<string, "mine" | "theirs">>(
|
|
23
|
+
Object.fromEntries(conflicts.map((c) => [c.sectionId, "mine" as const])),
|
|
24
|
+
);
|
|
25
|
+
const panelRef = useRef<HTMLDivElement>(null);
|
|
26
|
+
useFocusTrap(panelRef, true);
|
|
27
|
+
return (
|
|
28
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" role="dialog" aria-modal="true">
|
|
29
|
+
<div ref={panelRef} className="max-w-md rounded-lg bg-base p-6 shadow-xl">
|
|
30
|
+
<h2 className="mb-2 text-lg font-semibold text-base-contrast">Section changed by someone else</h2>
|
|
31
|
+
<p className="mb-4 text-sm text-base-contrast-light">
|
|
32
|
+
These sections were changed remotely while you edited them. Choose which version to keep — the other is safely stored locally.
|
|
33
|
+
</p>
|
|
34
|
+
<ul className="mb-4 space-y-2">
|
|
35
|
+
{conflicts.map((c) => (
|
|
36
|
+
<li key={c.sectionId} className="flex items-center justify-between gap-4 text-sm">
|
|
37
|
+
<span className="font-medium text-base-contrast">{c.label}</span>
|
|
38
|
+
<span className="flex gap-1">
|
|
39
|
+
{(["mine", "theirs"] as const).map((side) => (
|
|
40
|
+
<button
|
|
41
|
+
key={side}
|
|
42
|
+
type="button"
|
|
43
|
+
onClick={() => setChoices((prev) => ({ ...prev, [c.sectionId]: side }))}
|
|
44
|
+
className={choices[c.sectionId] === side
|
|
45
|
+
? "rounded bg-primary px-2 py-1 text-primary-contrast"
|
|
46
|
+
: "rounded border border-base-200 px-2 py-1 text-base-contrast hover:bg-base-accent"}
|
|
47
|
+
>
|
|
48
|
+
{side === "mine" ? "Keep mine" : "Take theirs"}
|
|
49
|
+
</button>
|
|
50
|
+
))}
|
|
51
|
+
</span>
|
|
52
|
+
</li>
|
|
53
|
+
))}
|
|
54
|
+
</ul>
|
|
55
|
+
<div className="flex justify-end">
|
|
56
|
+
<Button variant="primary" size="md" onClick={() => onResolve(choices)}>Continue</Button>
|
|
57
|
+
</div>
|
|
58
|
+
</div>
|
|
59
|
+
</div>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
@@ -16,6 +16,9 @@ interface IndicatorPillProps {
|
|
|
16
16
|
className?: string;
|
|
17
17
|
buttonClassName?: string;
|
|
18
18
|
ariaLabel?: string;
|
|
19
|
+
/** Display-only mode: dots/label still render but the popovers never open
|
|
20
|
+
* (e.g. while another editor holds the section lock). */
|
|
21
|
+
disabled?: boolean;
|
|
19
22
|
}
|
|
20
23
|
|
|
21
24
|
export function IndicatorPill({
|
|
@@ -26,6 +29,7 @@ export function IndicatorPill({
|
|
|
26
29
|
className,
|
|
27
30
|
buttonClassName,
|
|
28
31
|
ariaLabel,
|
|
32
|
+
disabled,
|
|
29
33
|
}: IndicatorPillProps) {
|
|
30
34
|
const [hoverOpen, setHoverOpen] = useState(false);
|
|
31
35
|
const [clickOpen, setClickOpen] = useState(false);
|
|
@@ -41,7 +45,7 @@ export function IndicatorPill({
|
|
|
41
45
|
|
|
42
46
|
function handleMouseEnter() {
|
|
43
47
|
clearHoverTimeout();
|
|
44
|
-
if (!clickOpen && hoverContent) {
|
|
48
|
+
if (!disabled && !clickOpen && hoverContent) {
|
|
45
49
|
setHoverOpen(true);
|
|
46
50
|
}
|
|
47
51
|
}
|
|
@@ -54,7 +58,7 @@ export function IndicatorPill({
|
|
|
54
58
|
}
|
|
55
59
|
|
|
56
60
|
function handleClick() {
|
|
57
|
-
if (!clickContent) return;
|
|
61
|
+
if (disabled || !clickContent) return;
|
|
58
62
|
setHoverOpen(false);
|
|
59
63
|
clearHoverTimeout();
|
|
60
64
|
setClickOpen((v) => !v);
|
|
@@ -72,15 +76,17 @@ export function IndicatorPill({
|
|
|
72
76
|
onClick={handleClick}
|
|
73
77
|
onMouseEnter={handleMouseEnter}
|
|
74
78
|
onMouseLeave={handleMouseLeave}
|
|
75
|
-
aria-haspopup={clickContent ? "true" : undefined}
|
|
76
|
-
aria-expanded={clickContent ? clickOpen : undefined}
|
|
79
|
+
aria-haspopup={!disabled && clickContent ? "true" : undefined}
|
|
80
|
+
aria-expanded={!disabled && clickContent ? clickOpen : undefined}
|
|
81
|
+
aria-disabled={disabled || undefined}
|
|
77
82
|
aria-label={ariaLabel}
|
|
78
83
|
className={cn(
|
|
79
84
|
// align-middle keeps the inline-flex pill centred in its line box regardless of
|
|
80
85
|
// whether it leads with a status/audience dot — without it, the dot-less variant's
|
|
81
86
|
// text baseline sits ~2px lower than the dotted variant, misaligning the two pills.
|
|
82
|
-
"
|
|
83
|
-
"border border-base-200
|
|
87
|
+
"inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-xs font-medium align-middle",
|
|
88
|
+
"border border-base-200",
|
|
89
|
+
disabled ? "cursor-default opacity-60" : "cursor-pointer hover:bg-base-accent",
|
|
84
90
|
buttonClassName,
|
|
85
91
|
)}
|
|
86
92
|
>
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { Lock } from "lucide-react";
|
|
2
|
+
import { cn } from "../../lib/cn";
|
|
3
|
+
|
|
4
|
+
interface LockBadgeProps {
|
|
5
|
+
email: string;
|
|
6
|
+
onTakeover?: () => void;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// Reuses the "modified" status tokens (an amber pair defined in base.css) so the
|
|
10
|
+
// lock badge sits in the same visual family as the other editor status pills
|
|
11
|
+
// rather than introducing raw amber-* literals.
|
|
12
|
+
export function LockBadge({ email, onTakeover }: LockBadgeProps) {
|
|
13
|
+
return (
|
|
14
|
+
<span className="pointer-events-auto inline-flex items-center gap-1.5 rounded-full bg-status-modified-bg px-2 py-0.5 text-xs font-medium text-status-modified-text align-middle">
|
|
15
|
+
<Lock size={12} strokeWidth={2.5} aria-hidden="true" />
|
|
16
|
+
<span className="whitespace-nowrap">{email} is editing</span>
|
|
17
|
+
{onTakeover && (
|
|
18
|
+
<button
|
|
19
|
+
type="button"
|
|
20
|
+
onClick={onTakeover}
|
|
21
|
+
className={cn(
|
|
22
|
+
"cursor-pointer rounded px-1.5 py-0.5 font-semibold",
|
|
23
|
+
"bg-status-modified-text/10 hover:bg-status-modified-text/20",
|
|
24
|
+
)}
|
|
25
|
+
>
|
|
26
|
+
Take over
|
|
27
|
+
</button>
|
|
28
|
+
)}
|
|
29
|
+
</span>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { CollabPeer, CollabStatus } from "../../collab/types";
|
|
2
|
+
import { cn } from "../../lib/cn";
|
|
3
|
+
|
|
4
|
+
// Vivid -600 fills read as distinct badge colors and keep white text legible
|
|
5
|
+
// in both light and dark editor chrome.
|
|
6
|
+
const AVATAR_COLORS = [
|
|
7
|
+
"bg-sky-600",
|
|
8
|
+
"bg-emerald-600",
|
|
9
|
+
"bg-amber-600",
|
|
10
|
+
"bg-fuchsia-600",
|
|
11
|
+
"bg-rose-600",
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
function initials(email: string): string {
|
|
15
|
+
return email.slice(0, 2).toUpperCase();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function colorFor(userId: string): string {
|
|
19
|
+
let h = 0;
|
|
20
|
+
for (const c of userId) h = (h * 31 + c.charCodeAt(0)) >>> 0;
|
|
21
|
+
return AVATAR_COLORS[h % AVATAR_COLORS.length];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface PresenceAvatarsProps {
|
|
25
|
+
peers: CollabPeer[];
|
|
26
|
+
selfUserId: string | null;
|
|
27
|
+
status: CollabStatus;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function PresenceAvatars({ peers, selfUserId, status }: PresenceAvatarsProps) {
|
|
31
|
+
const others = peers.filter((p) => p.userId !== selfUserId);
|
|
32
|
+
if (status !== "connected" || others.length === 0) return null;
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<div className="flex -space-x-2" aria-label="Editors online">
|
|
36
|
+
{others.map((p) => (
|
|
37
|
+
<span
|
|
38
|
+
key={p.userId}
|
|
39
|
+
title={p.focusSectionId ? `${p.email} — editing` : p.email}
|
|
40
|
+
className={cn(
|
|
41
|
+
// border-base-accent matches the toolbar surface behind the row in
|
|
42
|
+
// both themes (white in light, dark panel in dark), giving a clean
|
|
43
|
+
// ring where the avatars overlap.
|
|
44
|
+
"flex h-7 w-7 items-center justify-center rounded-full border-2 border-base-accent text-xs font-semibold text-white",
|
|
45
|
+
colorFor(p.userId),
|
|
46
|
+
)}
|
|
47
|
+
>
|
|
48
|
+
{initials(p.email)}
|
|
49
|
+
</span>
|
|
50
|
+
))}
|
|
51
|
+
</div>
|
|
52
|
+
);
|
|
53
|
+
}
|