@drawnagency/primitives 0.1.96 → 0.1.98
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-CI624HRS.js → chunk-26ALJO4K.js} +1 -1
- package/dist/{chunk-SOILPZ7U.js → chunk-3COSXTOY.js} +1 -1
- package/dist/{chunk-RHWWQV3J.js → chunk-BGHWUJFV.js} +1 -0
- package/dist/{chunk-RQPHPAT2.js → chunk-EWNDBKYK.js} +2 -2
- package/dist/{chunk-3HJDMFTB.js → chunk-FOY5TF7D.js} +1 -1
- package/dist/{chunk-YUUUCDXN.js → chunk-GYCBWGVW.js} +1 -1
- package/dist/chunk-HJSE6SK5.js +27 -0
- package/dist/{chunk-CSVGCAIH.js → chunk-HKHEAH5F.js} +7 -7
- package/dist/{chunk-IFCQWC3G.js → chunk-KP4DWTPR.js} +3 -3
- package/dist/{chunk-DJAJRBPD.js → chunk-MUTHF2GT.js} +2 -2
- package/dist/{chunk-CS2ECR3U.js → chunk-NA5EJM6Z.js} +24 -6
- package/dist/chunk-TGNS7D4F.js +200 -0
- package/dist/collab/rebase.d.ts +20 -0
- package/dist/collab/rebase.d.ts.map +1 -1
- package/dist/collab/rebase.js +17 -0
- package/dist/components/editor/DropEdgeIndicator.d.ts +6 -1
- package/dist/components/editor/DropEdgeIndicator.d.ts.map +1 -1
- package/dist/components/editor/NavGroupRow.d.ts +21 -0
- package/dist/components/editor/NavGroupRow.d.ts.map +1 -0
- package/dist/components/editor/PagesModal.d.ts +7 -1
- package/dist/components/editor/PagesModal.d.ts.map +1 -1
- package/dist/components/editor/index.js +5 -5
- package/dist/components/primitives/index.js +7 -7
- package/dist/components/sections/brand-guide.js +6 -6
- package/dist/components/sections/register-schemas.js +10 -10
- package/dist/components/sections/register.js +10 -10
- package/dist/components/shared/Navigation.d.ts.map +1 -1
- package/dist/components/shell/EditorShell.d.ts.map +1 -1
- package/dist/hooks/useEditorPersistence.d.ts +7 -0
- package/dist/hooks/useEditorPersistence.d.ts.map +1 -1
- package/dist/hooks/useSortableNavRow.d.ts +27 -0
- package/dist/hooks/useSortableNavRow.d.ts.map +1 -0
- package/dist/index.js +26 -8
- package/dist/lib/dexie.d.ts.map +1 -1
- package/dist/lib/dexie.js +9 -2
- package/dist/lib/index-ops.d.ts +138 -2
- package/dist/lib/index-ops.d.ts.map +1 -1
- package/dist/lib/index-ops.js +176 -8
- package/dist/lib/index.d.ts +1 -0
- package/dist/lib/index.d.ts.map +1 -1
- package/dist/lib/index.js +17 -6
- package/dist/lib/loader.d.ts +2 -2
- package/dist/lib/loader.d.ts.map +1 -1
- package/dist/lib/nav-tree.d.ts +63 -0
- package/dist/lib/nav-tree.d.ts.map +1 -0
- package/dist/lib/nav.d.ts +13 -2
- package/dist/lib/nav.d.ts.map +1 -1
- package/dist/lib/pages.d.ts +0 -1
- package/dist/lib/pages.d.ts.map +1 -1
- package/dist/lib/reorder.d.ts +24 -0
- package/dist/lib/reorder.d.ts.map +1 -1
- package/dist/media/index.js +3 -3
- package/dist/media/provider.d.ts +0 -1
- package/dist/media/provider.d.ts.map +1 -1
- package/dist/schemas/index.d.ts +1 -0
- package/dist/schemas/index.d.ts.map +1 -1
- package/dist/schemas/index.js +10 -2
- package/dist/schemas/nav.d.ts +40 -0
- package/dist/schemas/nav.d.ts.map +1 -0
- package/dist/schemas/site-config.d.ts +12 -1
- package/dist/schemas/site-config.d.ts.map +1 -1
- package/dist/types/database.d.ts +0 -3
- package/dist/types/database.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/collab/rebase.ts +35 -0
- package/src/components/editor/DropEdgeIndicator.tsx +15 -8
- package/src/components/editor/NavGroupRow.tsx +126 -0
- package/src/components/editor/PagesModal.tsx +150 -29
- package/src/components/shared/Navigation.tsx +51 -13
- package/src/components/shell/EditorShell.tsx +67 -18
- package/src/hooks/useEditorPersistence.ts +14 -0
- package/src/hooks/useSortableNavRow.ts +139 -0
- package/src/lib/dexie.ts +12 -2
- package/src/lib/index-ops.ts +261 -7
- package/src/lib/index.ts +1 -0
- package/src/lib/loader.ts +31 -4
- package/src/lib/nav-tree.ts +192 -0
- package/src/lib/nav.ts +43 -10
- package/src/lib/pages.ts +14 -11
- package/src/lib/reorder.ts +56 -0
- package/src/media/provider.ts +0 -1
- package/src/schemas/index.ts +1 -0
- package/src/schemas/nav.ts +36 -0
- package/src/schemas/site-config.ts +12 -2
- package/src/types/database.ts +0 -3
- package/dist/chunk-YBVK254P.js +0 -14
- /package/dist/{chunk-53DKVAB6.js → chunk-72CG6AA2.js} +0 -0
|
@@ -11,8 +11,10 @@ import { Input } from "../shared/Input";
|
|
|
11
11
|
import { Toggle } from "../shared/Toggle";
|
|
12
12
|
import { AudienceIndicator } from "./AudienceIndicator";
|
|
13
13
|
import { DragHandle } from "./DragHandle";
|
|
14
|
-
import {
|
|
14
|
+
import { useSortableNavRow } from "../../hooks/useSortableNavRow";
|
|
15
15
|
import { DropEdgeIndicator } from "./DropEdgeIndicator";
|
|
16
|
+
import { navEntries, type NavLocation } from "../../lib/nav-tree";
|
|
17
|
+
import { NavGroupRow } from "./NavGroupRow";
|
|
16
18
|
import { slugifyPageSlug, uniquePageSlug, pageDisplayTitle } from "../../lib/pages";
|
|
17
19
|
import { pluralize } from "../../lib/text";
|
|
18
20
|
import { RESERVED_SLUGS } from "../../schemas/site-config";
|
|
@@ -21,9 +23,14 @@ import { cn } from "../../lib/cn";
|
|
|
21
23
|
export interface PagesModalProps {
|
|
22
24
|
index: SiteIndex;
|
|
23
25
|
audiences: Audience[];
|
|
24
|
-
onReorder: (fromIndex: number, toIndex: number) => void;
|
|
25
26
|
/** Creates the page and returns its id so the modal can expand the new row. */
|
|
26
27
|
onAddPage: () => string;
|
|
28
|
+
/** Creates the group and returns its id so the modal can open its settings. */
|
|
29
|
+
onAddGroup: () => string;
|
|
30
|
+
onRenameGroup: (groupId: string, label: string) => void;
|
|
31
|
+
onSetGroupStartCollapsed: (groupId: string, v: boolean) => void;
|
|
32
|
+
onDeleteGroup: (groupId: string) => void;
|
|
33
|
+
onMoveNavEntry: (from: NavLocation, to: NavLocation) => void;
|
|
27
34
|
onSetHome: (pageId: string) => void;
|
|
28
35
|
onSetArchived: (pageId: string, archived: boolean) => void;
|
|
29
36
|
onSetFields: (pageId: string, patch: Partial<Pick<Page, "title" | "slug" | "showInNav">>) => void;
|
|
@@ -39,13 +46,20 @@ export function PagesModal(props: PagesModalProps) {
|
|
|
39
46
|
// The page created via "+ Add page" this session: its title gets auto-focus and
|
|
40
47
|
// its slug follows the title until the user edits the slug by hand.
|
|
41
48
|
const [newPageId, setNewPageId] = useState<string | null>(null);
|
|
49
|
+
// The group created via "+ Add group" this session: its settings panel opens
|
|
50
|
+
// with the label input focused, mirroring newPageId for pages.
|
|
51
|
+
const [newGroupId, setNewGroupId] = useState<string | null>(null);
|
|
52
|
+
// The group currently being dragged. Its members unmount for the duration, so
|
|
53
|
+
// the drag reads as moving the whole block rather than just the header.
|
|
54
|
+
// Groups are otherwise always expanded — there is no editor-side collapse, so
|
|
55
|
+
// no collapsed-group rescue (spring-load, reveal-on-drop) is needed.
|
|
56
|
+
const [draggingGroupId, setDraggingGroupId] = useState<string | null>(null);
|
|
42
57
|
|
|
43
58
|
if (deletingId) {
|
|
44
59
|
const page = index.pages.find((p) => p.id === deletingId);
|
|
45
60
|
if (page) return <DeleteConfirm page={page} onCancel={() => setDeletingId(null)} onConfirm={() => { props.onConfirmDelete(page.id); setDeletingId(null); }} />;
|
|
46
61
|
}
|
|
47
62
|
|
|
48
|
-
const live = index.pages.filter((p) => p.status === "live");
|
|
49
63
|
const archived = index.pages.filter((p) => p.status === "archived");
|
|
50
64
|
|
|
51
65
|
// Collapsing the new row — or expanding any other — ends its "new page"
|
|
@@ -56,7 +70,9 @@ export function PagesModal(props: PagesModalProps) {
|
|
|
56
70
|
if (newPageId !== null && next !== newPageId) setNewPageId(null);
|
|
57
71
|
};
|
|
58
72
|
|
|
59
|
-
|
|
73
|
+
// `location` is optional: the archived flat block renders inert rows with no
|
|
74
|
+
// nav position, exactly as it did when draggability keyed off `dragIndex`.
|
|
75
|
+
const renderPage = (page: Page, location?: NavLocation, entryIndex?: number) => (
|
|
60
76
|
<PageRow
|
|
61
77
|
key={page.id}
|
|
62
78
|
page={page}
|
|
@@ -64,8 +80,9 @@ export function PagesModal(props: PagesModalProps) {
|
|
|
64
80
|
expanded={expandedId === page.id}
|
|
65
81
|
isNew={newPageId === page.id}
|
|
66
82
|
otherSlugs={index.pages.filter((p) => p.id !== page.id).map((p) => p.slug)}
|
|
67
|
-
|
|
68
|
-
|
|
83
|
+
location={location}
|
|
84
|
+
entryIndex={entryIndex}
|
|
85
|
+
onMove={props.onMoveNavEntry}
|
|
69
86
|
onToggleExpand={() => toggleExpand(page.id)}
|
|
70
87
|
onSetHome={() => props.onSetHome(page.id)}
|
|
71
88
|
onSetArchived={(v) => {
|
|
@@ -86,20 +103,78 @@ export function PagesModal(props: PagesModalProps) {
|
|
|
86
103
|
return (
|
|
87
104
|
<div className="flex flex-col gap-2 overflow-y-auto">
|
|
88
105
|
<div className="flex flex-col gap-2">
|
|
89
|
-
{
|
|
106
|
+
{navEntries(index).map((entry, entryIndex) => {
|
|
107
|
+
if (entry.kind === "page") {
|
|
108
|
+
const page = index.pages.find((p) => p.id === entry.pageId);
|
|
109
|
+
if (!page || page.status !== "live") return null;
|
|
110
|
+
return renderPage(page, { groupId: null, index: entryIndex }, entryIndex);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// CRITICAL: carry the ORIGINAL pageIds index (`pi`) through the filter.
|
|
114
|
+
// moveNavEntry splices `group.pageIds`, which retains ARCHIVED member
|
|
115
|
+
// ids — archiving only flips `status`, and reconcileNav keeps any id
|
|
116
|
+
// that still exists in pages[]. Renumbering members 0..n over the
|
|
117
|
+
// live-filtered list would make the visible row for a member claim the
|
|
118
|
+
// archived member's slot, so dragging it would move the wrong page.
|
|
119
|
+
const members = entry.pageIds
|
|
120
|
+
.map((id, pi) => ({ page: index.pages.find((p) => p.id === id), pi }))
|
|
121
|
+
.filter((m): m is { page: Page; pi: number } => !!m.page && m.page.status === "live");
|
|
122
|
+
const expanded = draggingGroupId !== entry.id;
|
|
123
|
+
|
|
124
|
+
return (
|
|
125
|
+
<div key={entry.id} className="flex flex-col gap-2">
|
|
126
|
+
<NavGroupRow
|
|
127
|
+
group={entry}
|
|
128
|
+
entryIndex={entryIndex}
|
|
129
|
+
memberCount={members.length}
|
|
130
|
+
autoOpenSettings={newGroupId === entry.id}
|
|
131
|
+
onRename={(label) => props.onRenameGroup(entry.id, label)}
|
|
132
|
+
onSetStartCollapsed={(v) => props.onSetGroupStartCollapsed(entry.id, v)}
|
|
133
|
+
onDelete={() => props.onDeleteGroup(entry.id)}
|
|
134
|
+
onMove={props.onMoveNavEntry}
|
|
135
|
+
onDragStateChange={(dragging) => setDraggingGroupId(dragging ? entry.id : null)}
|
|
136
|
+
/>
|
|
137
|
+
{expanded && (
|
|
138
|
+
<div data-testid={`nav-group-members-${entry.id}`} className="ml-6 flex flex-col gap-2">
|
|
139
|
+
{members.length === 0
|
|
140
|
+
? <EmptyGroupDropzone groupId={entry.id} onMove={props.onMoveNavEntry} />
|
|
141
|
+
: members.map(({ page, pi }) => renderPage(page, { groupId: entry.id, index: pi }, entryIndex))}
|
|
142
|
+
</div>
|
|
143
|
+
)}
|
|
144
|
+
</div>
|
|
145
|
+
);
|
|
146
|
+
})}
|
|
147
|
+
</div>
|
|
148
|
+
|
|
149
|
+
{/* The only target for "after the last entry" when that entry is a group:
|
|
150
|
+
every edge of a trailing group resolves to a position inside it. */}
|
|
151
|
+
<TailDropzone entryCount={navEntries(index).length} onMove={props.onMoveNavEntry} />
|
|
152
|
+
|
|
153
|
+
<div className="mt-2 flex gap-2">
|
|
154
|
+
<Button
|
|
155
|
+
variant="secondary"
|
|
156
|
+
size="md"
|
|
157
|
+
onClick={() => {
|
|
158
|
+
const id = props.onAddPage();
|
|
159
|
+
setNewPageId(id);
|
|
160
|
+
setExpandedId(id);
|
|
161
|
+
}}
|
|
162
|
+
>
|
|
163
|
+
+ Add page
|
|
164
|
+
</Button>
|
|
165
|
+
<Button
|
|
166
|
+
variant="secondary"
|
|
167
|
+
size="md"
|
|
168
|
+
onClick={() => {
|
|
169
|
+
// Keep the returned id — it drives autoOpenSettings so the new
|
|
170
|
+
// group lands with its label input focused, mirroring + Add page.
|
|
171
|
+
const id = props.onAddGroup();
|
|
172
|
+
setNewGroupId(id);
|
|
173
|
+
}}
|
|
174
|
+
>
|
|
175
|
+
+ Add group
|
|
176
|
+
</Button>
|
|
90
177
|
</div>
|
|
91
|
-
<Button
|
|
92
|
-
variant="secondary"
|
|
93
|
-
size="md"
|
|
94
|
-
onClick={() => {
|
|
95
|
-
const id = props.onAddPage();
|
|
96
|
-
setNewPageId(id);
|
|
97
|
-
setExpandedId(id);
|
|
98
|
-
}}
|
|
99
|
-
className="mt-2 self-start"
|
|
100
|
-
>
|
|
101
|
-
+ Add page
|
|
102
|
-
</Button>
|
|
103
178
|
{archived.length > 0 && (
|
|
104
179
|
<div className="mt-4 border-t border-base-200 pt-3">
|
|
105
180
|
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-base-contrast/70">Archive</p>
|
|
@@ -111,23 +186,22 @@ export function PagesModal(props: PagesModalProps) {
|
|
|
111
186
|
}
|
|
112
187
|
|
|
113
188
|
function PageRow({
|
|
114
|
-
page, audiences, expanded, isNew, otherSlugs,
|
|
189
|
+
page, audiences, expanded, isNew, otherSlugs, location, entryIndex, onMove, onToggleExpand, onSetHome, onSetArchived, onSetFields, onSetAudience, onSetDocument, onRequestDelete,
|
|
115
190
|
}: {
|
|
116
191
|
page: Page; audiences: Audience[]; expanded: boolean;
|
|
117
192
|
isNew: boolean; otherSlugs: string[];
|
|
118
|
-
|
|
193
|
+
location?: NavLocation; entryIndex?: number;
|
|
194
|
+
onMove: (from: NavLocation, to: NavLocation) => void;
|
|
119
195
|
onToggleExpand: () => void; onSetHome: () => void; onSetArchived: (v: boolean) => void;
|
|
120
196
|
onSetFields: (patch: Partial<Pick<Page, "title" | "slug" | "showInNav">>) => void;
|
|
121
197
|
onSetAudience: (access: string[]) => void;
|
|
122
198
|
onSetDocument: (patch: Partial<DocumentOptions>) => void;
|
|
123
199
|
onRequestDelete: () => void;
|
|
124
200
|
}) {
|
|
125
|
-
const draggable =
|
|
126
|
-
const { rowRef, handleRef, isDragging, closestEdge } =
|
|
127
|
-
index:
|
|
128
|
-
|
|
129
|
-
onReorder,
|
|
130
|
-
enabled: draggable,
|
|
201
|
+
const draggable = location !== undefined;
|
|
202
|
+
const { rowRef, handleRef, isDragging, closestEdge } = useSortableNavRow<HTMLDivElement, HTMLButtonElement>({
|
|
203
|
+
row: { kind: "page", pageId: page.id, groupId: location?.groupId ?? null, index: location?.index ?? 0 },
|
|
204
|
+
entryIndex: entryIndex ?? 0, kind: "page", onMove, enabled: draggable,
|
|
131
205
|
});
|
|
132
206
|
|
|
133
207
|
// The slug input edits a local draft; only valid values commit to the index,
|
|
@@ -205,21 +279,31 @@ function PageRow({
|
|
|
205
279
|
return (
|
|
206
280
|
<div
|
|
207
281
|
ref={rowRef}
|
|
282
|
+
// Drag-and-drop has no jsdom coverage (atlaskit does not run there), so the
|
|
283
|
+
// Playwright pass in e2e/page-groups.spec.ts is its only automated check —
|
|
284
|
+
// and it needs a row anchor that outlives styling changes. Same reason for
|
|
285
|
+
// the group, member-list and dropzone testids below.
|
|
286
|
+
data-testid={`nav-row-page-${page.id}`}
|
|
287
|
+
data-nav-group={location?.groupId ?? ""}
|
|
208
288
|
className={cn("relative flex items-start gap-1", isDragging && "opacity-50")}
|
|
209
289
|
>
|
|
210
|
-
<DropEdgeIndicator edge={closestEdge} />
|
|
290
|
+
<DropEdgeIndicator edge={closestEdge} inset={location?.groupId != null} />
|
|
211
291
|
{draggable && (
|
|
212
292
|
<div className="flex shrink-0 items-center py-2 [&_[role=tooltip]]:hidden">
|
|
213
293
|
<DragHandle ref={handleRef} />
|
|
214
294
|
</div>
|
|
215
295
|
)}
|
|
216
296
|
<div className={cn("min-w-0 flex-1 rounded-md border border-base-200 bg-base", isDragging && "outline outline-2 outline-primary/50")}>
|
|
297
|
+
{/* w-full, not flex-1: the card wrapper is a block, so flex-1 is inert
|
|
298
|
+
here. A button's `width: auto` is fit-content (form controls size
|
|
299
|
+
intrinsically), which shrink-wraps the hover target and caret to the
|
|
300
|
+
text instead of spanning the row. */}
|
|
217
301
|
<button
|
|
218
302
|
type="button"
|
|
219
303
|
aria-label={`Edit settings for ${pageDisplayTitle(page.title)}`}
|
|
220
304
|
aria-expanded={expanded}
|
|
221
305
|
onClick={handleHeaderClick}
|
|
222
|
-
className="flex min-w-0
|
|
306
|
+
className="flex w-full min-w-0 cursor-pointer items-center gap-2 rounded py-2 pr-3 pl-4 text-left hover:bg-base-accent"
|
|
223
307
|
>
|
|
224
308
|
<div className="min-w-0 flex-1">
|
|
225
309
|
<div className="flex items-center gap-2">
|
|
@@ -319,6 +403,43 @@ function PageRow({
|
|
|
319
403
|
);
|
|
320
404
|
}
|
|
321
405
|
|
|
406
|
+
function TailDropzone({ entryCount, onMove }: { entryCount: number; onMove: (from: NavLocation, to: NavLocation) => void }) {
|
|
407
|
+
const { rowRef, isDropTarget } = useSortableNavRow<HTMLDivElement, HTMLButtonElement>({
|
|
408
|
+
// entryIndex IS the top-level entry count for a tail row — see
|
|
409
|
+
// computeNavDropTarget's tail branch.
|
|
410
|
+
row: { kind: "tail" }, entryIndex: entryCount, kind: "page", onMove,
|
|
411
|
+
});
|
|
412
|
+
return (
|
|
413
|
+
<div
|
|
414
|
+
ref={rowRef}
|
|
415
|
+
aria-hidden="true"
|
|
416
|
+
data-testid="nav-tail-dropzone"
|
|
417
|
+
className={cn(
|
|
418
|
+
"h-6 rounded-md border border-dashed transition-colors",
|
|
419
|
+
isDropTarget ? "border-primary" : "border-transparent",
|
|
420
|
+
)}
|
|
421
|
+
/>
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function EmptyGroupDropzone({ groupId, onMove }: { groupId: string; onMove: (from: NavLocation, to: NavLocation) => void }) {
|
|
426
|
+
const { rowRef, isDropTarget } = useSortableNavRow<HTMLDivElement, HTMLButtonElement>({
|
|
427
|
+
row: { kind: "empty-group", groupId }, entryIndex: 0, kind: "page", onMove,
|
|
428
|
+
});
|
|
429
|
+
return (
|
|
430
|
+
<div
|
|
431
|
+
ref={rowRef}
|
|
432
|
+
data-testid={`nav-empty-group-${groupId}`}
|
|
433
|
+
className={cn(
|
|
434
|
+
"rounded-md border border-dashed px-3 py-4 text-center text-xs transition-colors",
|
|
435
|
+
isDropTarget ? "border-primary text-primary" : "border-base-200 text-base-contrast/60",
|
|
436
|
+
)}
|
|
437
|
+
>
|
|
438
|
+
Drag pages here
|
|
439
|
+
</div>
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
|
|
322
443
|
function DeleteConfirm({ page, onCancel, onConfirm }: { page: Page; onCancel: () => void; onConfirm: () => void }) {
|
|
323
444
|
const [typed, setTyped] = useState("");
|
|
324
445
|
const n = page.order.length;
|
|
@@ -2,7 +2,8 @@ import { useState, useCallback, useEffect, useRef } from "react";
|
|
|
2
2
|
import { ChevronRight } from "lucide-react";
|
|
3
3
|
import { cn } from "../../lib/cn";
|
|
4
4
|
import { Toggle } from "./Toggle";
|
|
5
|
-
import type { SiteNav, NavItem } from "../../lib/nav";
|
|
5
|
+
import type { SiteNav, NavItem, GroupNavItem, PageNavItem } from "../../lib/nav";
|
|
6
|
+
import { groupDisplayLabel } from "../../lib/nav-tree";
|
|
6
7
|
import { editModeEvent, siteNavChangeEvent, pageSelectEvent, darkModeEvent, siteNameChangeEvent, historySelectEvent, editorDirtyEvent } from "../../lib/events";
|
|
7
8
|
import { useActiveHeadings } from "../../hooks/useActiveHeadings";
|
|
8
9
|
import { formatDate } from "../../lib/timestamp";
|
|
@@ -43,6 +44,9 @@ export default function Navigation({ siteNav: initialNav, siteName, darkMode, la
|
|
|
43
44
|
const [isDark, setIsDark] = useState(false);
|
|
44
45
|
const [siteNav, setSiteNav] = useState<SiteNav>(initialNav);
|
|
45
46
|
const [showHistory, setShowHistory] = useState(false);
|
|
47
|
+
// Keyed by group id for authored groups and by label for the derived editor
|
|
48
|
+
// buckets ("Hidden from Menu", "Archive"). Generated ids never collide with
|
|
49
|
+
// those two literals, so one record safely serves both.
|
|
46
50
|
const [openGroups, setOpenGroups] = useState<Record<string, boolean>>({});
|
|
47
51
|
// Anchors the history panel to the "Last updated" block rather than the button
|
|
48
52
|
// inside it: the block's top edge is the divider the panel sits above, and its
|
|
@@ -77,8 +81,9 @@ export default function Navigation({ siteNav: initialNav, siteName, darkMode, la
|
|
|
77
81
|
|
|
78
82
|
// The active page can live in any tier (editors navigate to hidden and
|
|
79
83
|
// archived pages via the Pages modal).
|
|
84
|
+
const treePages = siteNav.entries.flatMap((e) => (e.kind === "group" ? e.pages : [e]));
|
|
80
85
|
const activePage =
|
|
81
|
-
[...
|
|
86
|
+
[...treePages, ...siteNav.hidden, ...siteNav.archive].find((p) => p.isActive) ?? null;
|
|
82
87
|
const headings: NavItem[] = activePage?.headings ?? [];
|
|
83
88
|
|
|
84
89
|
const parentIds = headings.map((h) => h.href.replace("#", ""));
|
|
@@ -178,7 +183,7 @@ export default function Navigation({ siteNav: initialNav, siteName, darkMode, la
|
|
|
178
183
|
// a same-island handler is supplied; otherwise a real <a> for the viewer.
|
|
179
184
|
const asButton = isEditMode || !!onPageSelect;
|
|
180
185
|
|
|
181
|
-
const renderPage = (page:
|
|
186
|
+
const renderPage = (page: PageNavItem) => (
|
|
182
187
|
<li key={page.id}>
|
|
183
188
|
{asButton ? (
|
|
184
189
|
<button
|
|
@@ -201,23 +206,54 @@ export default function Navigation({ siteNav: initialNav, siteName, darkMode, la
|
|
|
201
206
|
</li>
|
|
202
207
|
);
|
|
203
208
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
+
// The one collapsible-section body, shared by authored groups and the derived
|
|
210
|
+
// editor buckets so the two can never drift apart visually. Callers supply the
|
|
211
|
+
// wrapper element, because authored groups are interleaved <li>s inside the
|
|
212
|
+
// main nav list while the buckets are their own blocks below it.
|
|
213
|
+
const renderGroupBody = (key: string, label: string, pages: PageNavItem[], defaultOpen: boolean) => {
|
|
214
|
+
const isOpen = openGroups[key] ?? defaultOpen;
|
|
209
215
|
return (
|
|
210
|
-
|
|
216
|
+
<>
|
|
211
217
|
<button
|
|
212
218
|
type="button"
|
|
213
219
|
aria-expanded={isOpen}
|
|
214
|
-
onClick={() => setOpenGroups((g) => ({ ...g, [
|
|
220
|
+
onClick={() => setOpenGroups((g) => ({ ...g, [key]: !isOpen }))}
|
|
215
221
|
className="flex w-full cursor-pointer items-center justify-between rounded-inner px-3 py-1 text-xs font-semibold uppercase tracking-wide text-base-contrast/70 transition-colors hover:text-primary"
|
|
216
222
|
>
|
|
217
|
-
<span>{
|
|
223
|
+
<span>{label}</span>
|
|
218
224
|
<ChevronRight size={14} aria-hidden="true" className={cn("transition-transform", isOpen && "rotate-90")} />
|
|
219
225
|
</button>
|
|
220
226
|
{isOpen && <ul className="space-y-1">{pages.map(renderPage)}</ul>}
|
|
227
|
+
</>
|
|
228
|
+
);
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
const renderAuthoredGroup = (group: GroupNavItem) => (
|
|
232
|
+
// No mx-4: the enclosing list is already padded, unlike the bucket blocks.
|
|
233
|
+
<li key={group.id} className="mt-2 border-t border-base-200 pt-2">
|
|
234
|
+
{renderGroupBody(
|
|
235
|
+
group.id,
|
|
236
|
+
groupDisplayLabel(group.label),
|
|
237
|
+
group.pages,
|
|
238
|
+
// Force-open when the group holds the active page, so the current
|
|
239
|
+
// location and its heading tree are never hidden behind a collapsed
|
|
240
|
+
// label. Otherwise the authored startCollapsed default applies.
|
|
241
|
+
group.pages.some((p) => p.isActive) || !group.startCollapsed,
|
|
242
|
+
)}
|
|
243
|
+
</li>
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
// `showCount` is Archive-only: it is the one bucket where how much is in there
|
|
247
|
+
// is the point. Every other section header — Hidden from Menu and authored
|
|
248
|
+
// groups alike — reads as a plain label.
|
|
249
|
+
const renderPageGroup = (label: string, pages: PageNavItem[], showCount = false) => {
|
|
250
|
+
// Unlike an authored group, an empty bucket is pure noise — there is no
|
|
251
|
+
// user-created thing to keep on screen.
|
|
252
|
+
if (pages.length === 0) return null;
|
|
253
|
+
return (
|
|
254
|
+
<div className="mx-4 mt-2 border-t border-base-200 pt-2">
|
|
255
|
+
{/* Closed by default; auto-open when it holds the active page. */}
|
|
256
|
+
{renderGroupBody(label, showCount ? `${label} (${pages.length})` : label, pages, pages.some((p) => p.isActive))}
|
|
221
257
|
</div>
|
|
222
258
|
);
|
|
223
259
|
};
|
|
@@ -251,11 +287,13 @@ export default function Navigation({ siteNav: initialNav, siteName, darkMode, la
|
|
|
251
287
|
</div>
|
|
252
288
|
|
|
253
289
|
<ul className="space-y-1 px-4 py-2">
|
|
254
|
-
{siteNav.
|
|
290
|
+
{siteNav.entries.map((entry) =>
|
|
291
|
+
entry.kind === "group" ? renderAuthoredGroup(entry) : renderPage(entry),
|
|
292
|
+
)}
|
|
255
293
|
</ul>
|
|
256
294
|
|
|
257
295
|
{isEditMode && renderPageGroup("Hidden from Menu", siteNav.hidden)}
|
|
258
|
-
{isEditMode && renderPageGroup("Archive", siteNav.archive)}
|
|
296
|
+
{isEditMode && renderPageGroup("Archive", siteNav.archive, true)}
|
|
259
297
|
|
|
260
298
|
<div className="mt-auto">
|
|
261
299
|
{currentDarkMode === "optional" && (
|
|
@@ -18,7 +18,7 @@ import { getMediaProvider } from "../../media";
|
|
|
18
18
|
// Document mode toggle wiring needs the patch shape it edits (mirrors the same
|
|
19
19
|
// sanctioned import in schemas/site-config.ts and lib/pages.ts).
|
|
20
20
|
import type { DocumentOptions } from "../../document/engine/settings";
|
|
21
|
-
import { SiteConfigSchema
|
|
21
|
+
import { SiteConfigSchema } from "../../schemas/site-config";
|
|
22
22
|
import { ensureSanitizer } from "../../lib/sanitize";
|
|
23
23
|
import { resolveChatbotName } from "../../lib/chatbot";
|
|
24
24
|
import { EditorProvider, useEditorContext, type HistoryState } from "./EditorContext";
|
|
@@ -75,7 +75,7 @@ import {
|
|
|
75
75
|
deleteSectionRows,
|
|
76
76
|
preserveEdit,
|
|
77
77
|
} from "../../lib/dexie";
|
|
78
|
-
import { rebaseContent,
|
|
78
|
+
import { rebaseContent, applyRemoteIndexUpdate } from "../../collab/rebase";
|
|
79
79
|
import { deriveRecoveryInputs, applyConflictChoices } from "../../collab/recovery";
|
|
80
80
|
import { getCollabProvider } from "../../collab/provider-registry";
|
|
81
81
|
import type { CollabBootstrap, CollabPeer, CollabStatus, SavedMessage, TakeoverMessage } from "../../collab/types";
|
|
@@ -89,11 +89,12 @@ import { useBuildStatus } from "../../hooks/useBuildStatus";
|
|
|
89
89
|
import { useMediaPipeline } from "../../hooks/useMediaPipeline";
|
|
90
90
|
import { formatTimestamp } from "../../lib/timestamp";
|
|
91
91
|
import { generateSiteNav } from "../../lib/nav";
|
|
92
|
+
import { pagesInNavOrder, moveNavEntry, addGroup, renameGroup, setGroupStartCollapsed, deleteGroup, type NavLocation } from "../../lib/nav-tree";
|
|
92
93
|
import { siteNavChangeEvent, pageSelectEvent, darkModeEvent, siteNameChangeEvent, historySelectEvent, historyRestoreEvent, historyRestoreFailedEvent, editorDirtyEvent } from "../../lib/events";
|
|
93
94
|
import {
|
|
94
95
|
homePage, pageById, pageBySlug, pageDisplayTitle, addSectionToPage, removeSectionFromPages,
|
|
95
96
|
reorderSectionInPage, audiencePasses,
|
|
96
|
-
setHomePage, setPageArchived, setPageFields, setPageAudience, setPageDocument, deletePage, addPage,
|
|
97
|
+
setHomePage, setPageArchived, setPageFields, setPageAudience, setPageDocument, deletePage, addPage,
|
|
97
98
|
moveSection as moveSectionReducer,
|
|
98
99
|
} from "../../lib/pages";
|
|
99
100
|
import { PagesContext } from "../shared/PagesContext";
|
|
@@ -195,6 +196,13 @@ export default function EditorShell({
|
|
|
195
196
|
const [mediaModalMode, setMediaModalMode] = useState<"select" | "manage">("manage");
|
|
196
197
|
const [mediaSelectCallback, setMediaSelectCallback] = useState<((id: string) => void) | null>(null);
|
|
197
198
|
const [deletedSections, setDeletedSections] = useState<Set<string>>(new Set());
|
|
199
|
+
// Ref mirror so the debounced Dexie flush can persist the deletion set. Without
|
|
200
|
+
// it the snapshot records the index minus the deleted sections but not the
|
|
201
|
+
// INTENT, and the restore path has to re-derive deletions by diffing the stale
|
|
202
|
+
// snapshot against a fresh remote read — which mistakes anything a third party
|
|
203
|
+
// (the MCP connector, a peer editor) added since for a local deletion.
|
|
204
|
+
const deletedSectionsRef = useRef<Set<string>>(deletedSections);
|
|
205
|
+
useEffect(() => { deletedSectionsRef.current = deletedSections; }, [deletedSections]);
|
|
198
206
|
const [pendingDeleteSectionId, setPendingDeleteSectionId] = useState<string | null>(null);
|
|
199
207
|
const [savedSha, setSavedSha] = useState<string | null>(null);
|
|
200
208
|
const [mainSha, setMainSha] = useState<string | null>(null);
|
|
@@ -318,7 +326,7 @@ export default function EditorShell({
|
|
|
318
326
|
[],
|
|
319
327
|
);
|
|
320
328
|
|
|
321
|
-
const persistence = useEditorPersistence(siteIndexRef, { onFlush });
|
|
329
|
+
const persistence = useEditorPersistence(siteIndexRef, { onFlush, deletedSectionsRef });
|
|
322
330
|
|
|
323
331
|
const sectionsById = useMemo(() => {
|
|
324
332
|
const map: Record<string, LoadedSection> = {};
|
|
@@ -678,8 +686,6 @@ export default function EditorShell({
|
|
|
678
686
|
// the overlap — don't clobber local structure with the peer's. Durable
|
|
679
687
|
// signal (survives the flush debounce), not isIndexDirty().
|
|
680
688
|
if (persistence.hasUnsavedIndex()) return;
|
|
681
|
-
const parsed = IndexSchema.safeParse(msg.index);
|
|
682
|
-
if (!parsed.success) return;
|
|
683
689
|
// Withhold ids our current index doesn't know before applying the peer's:
|
|
684
690
|
// (a) the peer's unsaved ADD — its file isn't on the branch yet, so letting
|
|
685
691
|
// it into siteIndexRef would poison OUR next save with a dangling index ref
|
|
@@ -689,7 +695,11 @@ export default function EditorShell({
|
|
|
689
695
|
// loaded section bodies: a live section whose body the loader skipped
|
|
690
696
|
// (schema/registry miss, depth trip) is still indexed and must survive —
|
|
691
697
|
// guaranteed because we only reach here with no unsaved index work.
|
|
692
|
-
|
|
698
|
+
// Parses, preserves the peer's nav across IndexSchema's key strip, and
|
|
699
|
+
// prunes — see applyRemoteIndexUpdate. Null means the payload wasn't a
|
|
700
|
+
// valid index.
|
|
701
|
+
const pruned = applyRemoteIndexUpdate(msg.index, siteIndexRef.current);
|
|
702
|
+
if (!pruned) return;
|
|
693
703
|
setSiteIndex(pruned);
|
|
694
704
|
siteIndexRef.current = pruned;
|
|
695
705
|
},
|
|
@@ -1086,15 +1096,27 @@ export default function EditorShell({
|
|
|
1086
1096
|
}).filter((s): s is LoadedSection => s !== null),
|
|
1087
1097
|
);
|
|
1088
1098
|
setSiteIndex(restoredIndex);
|
|
1089
|
-
//
|
|
1090
|
-
//
|
|
1091
|
-
//
|
|
1092
|
-
//
|
|
1099
|
+
// Pending deletions come from the snapshot's own persisted intent, and are
|
|
1100
|
+
// intersected with what the branch still has so an already-applied deletion
|
|
1101
|
+
// can't be replayed.
|
|
1102
|
+
//
|
|
1103
|
+
// They are deliberately NOT re-derived by diffing the restored index against
|
|
1104
|
+
// a fresh remote read. That inference cannot distinguish "this editor deleted
|
|
1105
|
+
// it" from "someone else added it after this snapshot was taken" — every
|
|
1106
|
+
// section the MCP connector or a peer created in the meantime is absent from
|
|
1107
|
+
// the stale local index, so it would be queued for deletion and destroyed by
|
|
1108
|
+
// the next save. Deleting real content is far worse than the failure mode the
|
|
1109
|
+
// re-derivation guarded against (a section's file left orphaned when the
|
|
1110
|
+
// snapshot predates persisted intent, which validate_site reports and
|
|
1111
|
+
// pruneOrphans cleans up).
|
|
1093
1112
|
const restoredIds = new Set(restoredIndex.pages.flatMap((p) => p.order));
|
|
1094
|
-
|
|
1095
|
-
|
|
1113
|
+
setDeletedSections(
|
|
1114
|
+
new Set(
|
|
1115
|
+
restored.deletedSections.filter(
|
|
1116
|
+
(id) => remoteSectionIdsRef.current.has(id) && !restoredIds.has(id),
|
|
1117
|
+
),
|
|
1118
|
+
),
|
|
1096
1119
|
);
|
|
1097
|
-
setDeletedSections(rediscoveredDeletions);
|
|
1098
1120
|
// A restored Dexie siteIndex row IS unsaved structural work from the
|
|
1099
1121
|
// prior session — prime the durable signal even though no markIndexDirty
|
|
1100
1122
|
// ran this session.
|
|
@@ -1292,8 +1314,31 @@ export default function EditorShell({
|
|
|
1292
1314
|
return id;
|
|
1293
1315
|
}, [persistence]);
|
|
1294
1316
|
|
|
1295
|
-
const
|
|
1296
|
-
setSiteIndex((prev) =>
|
|
1317
|
+
const handleMoveNavEntry = useCallback((from: NavLocation, to: NavLocation) => {
|
|
1318
|
+
setSiteIndex((prev) => moveNavEntry(prev, from, to));
|
|
1319
|
+
persistence.markIndexDirty(); setLocalChangesExist(true);
|
|
1320
|
+
}, [persistence]);
|
|
1321
|
+
|
|
1322
|
+
const handleAddGroup = useCallback(() => {
|
|
1323
|
+
// Mirror handleAddPage's id scheme, including its non-crypto fallback.
|
|
1324
|
+
const id = typeof crypto !== "undefined" && crypto.randomUUID ? `grp_${crypto.randomUUID()}` : `grp_${Date.now()}`;
|
|
1325
|
+
setSiteIndex((prev) => addGroup(prev, id));
|
|
1326
|
+
persistence.markIndexDirty(); setLocalChangesExist(true);
|
|
1327
|
+
return id;
|
|
1328
|
+
}, [persistence]);
|
|
1329
|
+
|
|
1330
|
+
const handleRenameGroup = useCallback((groupId: string, label: string) => {
|
|
1331
|
+
setSiteIndex((prev) => renameGroup(prev, groupId, label));
|
|
1332
|
+
persistence.markIndexDirty(); setLocalChangesExist(true);
|
|
1333
|
+
}, [persistence]);
|
|
1334
|
+
|
|
1335
|
+
const handleSetGroupStartCollapsed = useCallback((groupId: string, v: boolean) => {
|
|
1336
|
+
setSiteIndex((prev) => setGroupStartCollapsed(prev, groupId, v));
|
|
1337
|
+
persistence.markIndexDirty(); setLocalChangesExist(true);
|
|
1338
|
+
}, [persistence]);
|
|
1339
|
+
|
|
1340
|
+
const handleDeleteGroup = useCallback((groupId: string) => {
|
|
1341
|
+
setSiteIndex((prev) => deleteGroup(prev, groupId));
|
|
1297
1342
|
persistence.markIndexDirty(); setLocalChangesExist(true);
|
|
1298
1343
|
}, [persistence]);
|
|
1299
1344
|
|
|
@@ -1826,8 +1871,12 @@ export default function EditorShell({
|
|
|
1826
1871
|
<PagesModal
|
|
1827
1872
|
index={siteIndex}
|
|
1828
1873
|
audiences={audiences}
|
|
1829
|
-
onReorder={handleReorderPages}
|
|
1830
1874
|
onAddPage={handleAddPage}
|
|
1875
|
+
onAddGroup={handleAddGroup}
|
|
1876
|
+
onRenameGroup={handleRenameGroup}
|
|
1877
|
+
onSetGroupStartCollapsed={handleSetGroupStartCollapsed}
|
|
1878
|
+
onDeleteGroup={handleDeleteGroup}
|
|
1879
|
+
onMoveNavEntry={handleMoveNavEntry}
|
|
1831
1880
|
onSetHome={handleSetHome}
|
|
1832
1881
|
onSetArchived={handleSetArchived}
|
|
1833
1882
|
onSetFields={handleSetPageFields}
|
|
@@ -1839,7 +1888,7 @@ export default function EditorShell({
|
|
|
1839
1888
|
<EditorModal isOpen={movingSectionId !== null} onClose={() => setMovingSectionId(null)} title="Move to page">
|
|
1840
1889
|
{movingSectionId && resolvedActivePage && (
|
|
1841
1890
|
<MoveSectionModal
|
|
1842
|
-
pages={siteIndex.
|
|
1891
|
+
pages={pagesInNavOrder(siteIndex).map((p) => ({ id: p.id, title: pageDisplayTitle(p.title) }))}
|
|
1843
1892
|
currentPageId={resolvedActivePage.id}
|
|
1844
1893
|
onMove={(destId, position) => handleMoveSection(movingSectionId, destId, position)}
|
|
1845
1894
|
onCancel={() => setMovingSectionId(null)}
|
|
@@ -34,6 +34,13 @@ interface PersistenceOptions {
|
|
|
34
34
|
* updates + index updates to peers from here. Stored in a ref so its identity
|
|
35
35
|
* can churn without recreating the debounce machinery. */
|
|
36
36
|
onFlush?: (entries: { sectionId: string; content: SectionContent }[], indexFlushed: boolean) => void;
|
|
37
|
+
/** Ref mirror of the shell's pending-deletion set, read at flush time and
|
|
38
|
+
* persisted alongside the index snapshot. Deletion is INTENT that cannot be
|
|
39
|
+
* inferred back from the snapshot: the restore path would otherwise diff the
|
|
40
|
+
* stale local index against a fresh remote read and treat sections a third
|
|
41
|
+
* party added in the meantime (MCP connector, peer editor) as local
|
|
42
|
+
* deletions — then delete them on the next save. */
|
|
43
|
+
deletedSectionsRef?: React.RefObject<Set<string>>;
|
|
37
44
|
}
|
|
38
45
|
|
|
39
46
|
export function useEditorPersistence(
|
|
@@ -52,6 +59,8 @@ export function useEditorPersistence(
|
|
|
52
59
|
const flushTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
53
60
|
const onFlushRef = useRef(options?.onFlush);
|
|
54
61
|
useEffect(() => { onFlushRef.current = options?.onFlush; });
|
|
62
|
+
const deletedSectionsRef = useRef(options?.deletedSectionsRef);
|
|
63
|
+
useEffect(() => { deletedSectionsRef.current = options?.deletedSectionsRef; });
|
|
55
64
|
|
|
56
65
|
const flushToDexie = useCallback(async () => {
|
|
57
66
|
const s = state.current;
|
|
@@ -68,9 +77,14 @@ export function useEditorPersistence(
|
|
|
68
77
|
s.indexDirty = false;
|
|
69
78
|
|
|
70
79
|
try {
|
|
80
|
+
// The deletion set is cumulative in shell state (cleared only on save or
|
|
81
|
+
// discard), so every flush rewrites the whole current set — a later flush
|
|
82
|
+
// can never narrow an earlier record.
|
|
83
|
+
const pendingDeletions = deletedSectionsRef.current?.current;
|
|
71
84
|
await persistAll(
|
|
72
85
|
entries,
|
|
73
86
|
wasIndexDirty ? siteIndexRef.current : undefined,
|
|
87
|
+
pendingDeletions && pendingDeletions.size > 0 ? [...pendingDeletions] : undefined,
|
|
74
88
|
);
|
|
75
89
|
// Fire only after a successful persist — broadcasting an edit that failed
|
|
76
90
|
// to persist would let peers diverge from what this editor can recover.
|