@drawnagency/primitives 0.1.95 → 0.1.97

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/dist/{chunk-ZKGMLLUQ.js → chunk-5BFPJWIO.js} +5 -5
  2. package/dist/chunk-EUSWEVOA.js +29 -0
  3. package/dist/{chunk-CI624HRS.js → chunk-F2337B35.js} +1 -1
  4. package/dist/{chunk-QQBBYQYD.js → chunk-FOEZNEF5.js} +9 -9
  5. package/dist/chunk-HJSE6SK5.js +27 -0
  6. package/dist/{chunk-YUUUCDXN.js → chunk-II3X7GLJ.js} +1 -1
  7. package/dist/{chunk-DJAJRBPD.js → chunk-KPP2CFW6.js} +2 -2
  8. package/dist/{chunk-ABV7OZB3.js → chunk-QEHPGA5N.js} +26 -8
  9. package/dist/{chunk-SOILPZ7U.js → chunk-S4L3ZFKM.js} +1 -1
  10. package/dist/{chunk-RQPHPAT2.js → chunk-YDXBJR74.js} +2 -2
  11. package/dist/chunk-ZD6XGRQM.js +85 -0
  12. package/dist/collab/rebase.d.ts +20 -0
  13. package/dist/collab/rebase.d.ts.map +1 -1
  14. package/dist/collab/rebase.js +17 -0
  15. package/dist/components/editor/DropEdgeIndicator.d.ts +6 -1
  16. package/dist/components/editor/DropEdgeIndicator.d.ts.map +1 -1
  17. package/dist/components/editor/NavGroupRow.d.ts +21 -0
  18. package/dist/components/editor/NavGroupRow.d.ts.map +1 -0
  19. package/dist/components/editor/PagesModal.d.ts +7 -1
  20. package/dist/components/editor/PagesModal.d.ts.map +1 -1
  21. package/dist/components/editor/index.js +4 -4
  22. package/dist/components/primitives/index.js +7 -7
  23. package/dist/components/sections/brand-guide.js +6 -6
  24. package/dist/components/sections/register-schemas.js +10 -10
  25. package/dist/components/sections/register.js +10 -10
  26. package/dist/components/shared/Navigation.d.ts.map +1 -1
  27. package/dist/components/shell/EditorShell.d.ts.map +1 -1
  28. package/dist/hooks/useSortableNavRow.d.ts +27 -0
  29. package/dist/hooks/useSortableNavRow.d.ts.map +1 -0
  30. package/dist/index.js +60 -38
  31. package/dist/lib/dexie.d.ts.map +1 -1
  32. package/dist/lib/dexie.js +3 -1
  33. package/dist/lib/index-ops.d.ts +77 -0
  34. package/dist/lib/index-ops.d.ts.map +1 -0
  35. package/dist/lib/index-ops.js +223 -0
  36. package/dist/lib/index.d.ts +1 -0
  37. package/dist/lib/index.d.ts.map +1 -1
  38. package/dist/lib/index.js +17 -6
  39. package/dist/lib/loader.d.ts +2 -2
  40. package/dist/lib/loader.d.ts.map +1 -1
  41. package/dist/lib/nav-tree.d.ts +63 -0
  42. package/dist/lib/nav-tree.d.ts.map +1 -0
  43. package/dist/lib/nav.d.ts +13 -2
  44. package/dist/lib/nav.d.ts.map +1 -1
  45. package/dist/lib/pages.d.ts +0 -1
  46. package/dist/lib/pages.d.ts.map +1 -1
  47. package/dist/lib/reorder.d.ts +24 -0
  48. package/dist/lib/reorder.d.ts.map +1 -1
  49. package/dist/media/index.js +2 -2
  50. package/dist/schemas/index.d.ts +1 -0
  51. package/dist/schemas/index.d.ts.map +1 -1
  52. package/dist/schemas/index.js +8 -2
  53. package/dist/schemas/nav.d.ts +40 -0
  54. package/dist/schemas/nav.d.ts.map +1 -0
  55. package/dist/schemas/site-config.d.ts +11 -1
  56. package/dist/schemas/site-config.d.ts.map +1 -1
  57. package/dist/storage/index.d.ts +1 -1
  58. package/dist/storage/index.d.ts.map +1 -1
  59. package/dist/storage/index.js +9 -3
  60. package/dist/storage/types.d.ts +44 -0
  61. package/dist/storage/types.d.ts.map +1 -1
  62. package/package.json +5 -1
  63. package/src/collab/rebase.ts +35 -0
  64. package/src/components/editor/DropEdgeIndicator.tsx +15 -8
  65. package/src/components/editor/NavGroupRow.tsx +126 -0
  66. package/src/components/editor/PagesModal.tsx +150 -29
  67. package/src/components/shared/Navigation.tsx +51 -13
  68. package/src/components/shell/EditorShell.tsx +40 -10
  69. package/src/hooks/useSortableNavRow.ts +139 -0
  70. package/src/lib/dexie.ts +6 -1
  71. package/src/lib/index-ops.ts +309 -0
  72. package/src/lib/index.ts +1 -0
  73. package/src/lib/loader.ts +31 -4
  74. package/src/lib/nav-tree.ts +192 -0
  75. package/src/lib/nav.ts +43 -10
  76. package/src/lib/pages.ts +14 -11
  77. package/src/lib/reorder.ts +56 -0
  78. package/src/schemas/index.ts +1 -0
  79. package/src/schemas/nav.ts +36 -0
  80. package/src/schemas/site-config.ts +9 -1
  81. package/src/storage/index.ts +1 -1
  82. package/src/storage/types.ts +55 -0
  83. package/dist/chunk-VAOW4WXQ.js +0 -15
  84. package/dist/chunk-YBVK254P.js +0 -14
  85. /package/dist/{chunk-53DKVAB6.js → chunk-72CG6AA2.js} +0 -0
  86. /package/dist/{chunk-3HJDMFTB.js → chunk-XYCCXKVC.js} +0 -0
@@ -0,0 +1,126 @@
1
+ import { useEffect, useState } from "react";
2
+ import { ChevronDown } from "lucide-react";
3
+ import type { NavGroupEntry } from "../../schemas/nav";
4
+ import type { NavLocation } from "../../lib/nav-tree";
5
+ import { groupDisplayLabel } from "../../lib/nav-tree";
6
+ import { pluralize } from "../../lib/text";
7
+ import { cn } from "../../lib/cn";
8
+ import { Button } from "../shared/Button";
9
+ import { Input } from "../shared/Input";
10
+ import { Toggle } from "../shared/Toggle";
11
+ import { DragHandle } from "./DragHandle";
12
+ import { DropEdgeIndicator } from "./DropEdgeIndicator";
13
+ import { useSortableNavRow } from "../../hooks/useSortableNavRow";
14
+
15
+ interface Props {
16
+ group: NavGroupEntry;
17
+ entryIndex: number;
18
+ memberCount: number;
19
+ /** Opens the settings panel on mount. The `+ Add group` flow sets this so a
20
+ * brand-new group lands with its label input focused. */
21
+ autoOpenSettings?: boolean;
22
+ onRename: (label: string) => void;
23
+ onSetStartCollapsed: (v: boolean) => void;
24
+ onDelete: () => void;
25
+ onMove: (from: NavLocation, to: NavLocation) => void;
26
+ /** Fires when THIS group starts/stops being dragged, so the modal can collapse
27
+ * it for the duration. Without that, its members stay rendered in place and
28
+ * the drag reads as moving only the header. */
29
+ onDragStateChange?: (dragging: boolean) => void;
30
+ }
31
+
32
+ export function NavGroupRow({
33
+ group, entryIndex, memberCount, autoOpenSettings = false,
34
+ onRename, onSetStartCollapsed, onDelete, onMove, onDragStateChange,
35
+ }: Props) {
36
+ const [settingsOpen, setSettingsOpen] = useState(autoOpenSettings);
37
+ const [confirmingDelete, setConfirmingDelete] = useState(false);
38
+ const [showError, setShowError] = useState(false);
39
+
40
+ const { rowRef, handleRef, isDragging, closestEdge } = useSortableNavRow<HTMLDivElement, HTMLButtonElement>({
41
+ row: { kind: "group", groupId: group.id, index: entryIndex },
42
+ entryIndex, kind: "group", onMove,
43
+ });
44
+
45
+ // Surface the hook's drag state upward so the modal can collapse this group
46
+ // while it is the one being dragged.
47
+ useEffect(() => { onDragStateChange?.(isDragging); }, [isDragging, onDragStateChange]);
48
+
49
+ const label = groupDisplayLabel(group.label);
50
+ const labelError = group.label.trim() === "" ? "Label is required." : null;
51
+
52
+ // A blank label is viewer-visible with no URL to disambiguate it, so the
53
+ // panel refuses to close until one is typed — mirrors PageRow's title guard.
54
+ const handleHeaderClick = () => {
55
+ if (settingsOpen && labelError) { setShowError(true); return; }
56
+ setSettingsOpen((v) => !v);
57
+ setShowError(false);
58
+ };
59
+
60
+ return (
61
+ <div ref={rowRef} data-testid={`nav-row-group-${group.id}`} className={cn("relative flex items-start gap-1", isDragging && "opacity-50")}>
62
+ <DropEdgeIndicator edge={closestEdge} />
63
+ <div className="flex shrink-0 items-center py-2 [&_[role=tooltip]]:hidden">
64
+ <DragHandle ref={handleRef} />
65
+ </div>
66
+ <div className="min-w-0 flex-1 rounded-md border border-base-200 bg-base">
67
+ <div className="flex items-center">
68
+ {/* pl-4 matches PageRow's header, so group and page labels align now
69
+ that no caret occupies the leading slot. */}
70
+ <button
71
+ type="button"
72
+ aria-label={`Group settings for ${label}`}
73
+ aria-expanded={settingsOpen}
74
+ onClick={handleHeaderClick}
75
+ className="flex min-w-0 flex-1 cursor-pointer items-center gap-2 py-2 pr-3 pl-4 text-left hover:bg-base-accent"
76
+ >
77
+ <span className={cn("truncate text-sm font-medium text-base-contrast", !group.label && "italic text-base-contrast/60")}>
78
+ {label}
79
+ </span>
80
+ <span className="ml-auto shrink-0 text-xs text-base-contrast/60">{pluralize(memberCount, "page")}</span>
81
+ <ChevronDown size={16} aria-hidden="true" className={cn("shrink-0 text-base-contrast/50 transition-transform", settingsOpen && "rotate-180")} />
82
+ </button>
83
+ </div>
84
+
85
+ {settingsOpen && (
86
+ <div className="flex flex-col gap-3 border-t border-base-200 px-3 py-3">
87
+ <Input
88
+ label="Label"
89
+ value={group.label}
90
+ placeholder="Group name"
91
+ autoFocus
92
+ error={showError && labelError ? labelError : undefined}
93
+ onChange={onRename}
94
+ />
95
+ <div className="flex items-center justify-between gap-4 rounded-inner border border-base-200 px-3 py-2.5">
96
+ <div className="min-w-0">
97
+ <div className="text-sm text-base-contrast">Start collapsed</div>
98
+ <div className="text-xs text-base-contrast/70">Viewers see this group closed until they open it.</div>
99
+ </div>
100
+ <Toggle label="Start collapsed" checked={group.startCollapsed} onChange={onSetStartCollapsed} />
101
+ </div>
102
+
103
+ {confirmingDelete ? (
104
+ <div className="flex flex-col gap-2 rounded-inner border border-base-200 p-3">
105
+ <p className="text-sm text-base-contrast">
106
+ Delete this group? {pluralize(memberCount, "page")} will move to the top level.
107
+ </p>
108
+ <div className="flex justify-end gap-2">
109
+ <Button variant="secondary" size="sm" onClick={() => setConfirmingDelete(false)}>Cancel</Button>
110
+ <Button variant="destructive" size="sm" aria-label="Confirm delete" onClick={onDelete}>Delete</Button>
111
+ </div>
112
+ </div>
113
+ ) : (
114
+ <Button
115
+ variant="destructive" size="sm" aria-label="Delete group" className="self-start"
116
+ onClick={() => (memberCount === 0 ? onDelete() : setConfirmingDelete(true))}
117
+ >
118
+ Delete group
119
+ </Button>
120
+ )}
121
+ </div>
122
+ )}
123
+ </div>
124
+ </div>
125
+ );
126
+ }
@@ -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 { useSortableRow } from "../../hooks/useSortableRow";
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
- const renderPage = (page: Page, dragIndex?: number) => (
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
- dragIndex={dragIndex}
68
- onReorder={props.onReorder}
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
- {live.map((page) => renderPage(page, index.pages.indexOf(page)))}
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, dragIndex, onReorder, onToggleExpand, onSetHome, onSetArchived, onSetFields, onSetAudience, onSetDocument, onRequestDelete,
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
- dragIndex?: number; onReorder: (fromIndex: number, toIndex: number) => void;
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 = dragIndex !== undefined;
126
- const { rowRef, handleRef, isDragging, closestEdge } = useSortableRow<HTMLDivElement, HTMLButtonElement>({
127
- index: dragIndex ?? 0,
128
- dragType: "page-ordering-row",
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 flex-1 cursor-pointer items-center gap-2 rounded py-2 pr-3 pl-4 text-left hover:bg-base-accent"
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
- [...siteNav.pages, ...siteNav.hidden, ...siteNav.archive].find((p) => p.isActive) ?? null;
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: SiteNav["pages"][number]) => (
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
- const renderPageGroup = (label: string, pages: SiteNav["pages"]) => {
205
- if (pages.length === 0) return null;
206
- // Closed by default; auto-open when it holds the active page so the
207
- // editor's current location (and its heading tree) stays visible.
208
- const isOpen = openGroups[label] ?? pages.some((p) => p.isActive);
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
- <div className="mx-4 mt-2 border-t border-base-200 pt-2">
216
+ <>
211
217
  <button
212
218
  type="button"
213
219
  aria-expanded={isOpen}
214
- onClick={() => setOpenGroups((g) => ({ ...g, [label]: !isOpen }))}
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>{`${label} (${pages.length})`}</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.pages.map(renderPage)}
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, IndexSchema } from "../../schemas/site-config";
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, reconcileRemoteIndex } from "../../collab/rebase";
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, reorderPages,
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";
@@ -678,8 +679,6 @@ export default function EditorShell({
678
679
  // the overlap — don't clobber local structure with the peer's. Durable
679
680
  // signal (survives the flush debounce), not isIndexDirty().
680
681
  if (persistence.hasUnsavedIndex()) return;
681
- const parsed = IndexSchema.safeParse(msg.index);
682
- if (!parsed.success) return;
683
682
  // Withhold ids our current index doesn't know before applying the peer's:
684
683
  // (a) the peer's unsaved ADD — its file isn't on the branch yet, so letting
685
684
  // it into siteIndexRef would poison OUR next save with a dangling index ref
@@ -689,7 +688,11 @@ export default function EditorShell({
689
688
  // loaded section bodies: a live section whose body the loader skipped
690
689
  // (schema/registry miss, depth trip) is still indexed and must survive —
691
690
  // guaranteed because we only reach here with no unsaved index work.
692
- const pruned = reconcileRemoteIndex(parsed.data, siteIndexRef.current);
691
+ // Parses, preserves the peer's nav across IndexSchema's key strip, and
692
+ // prunes — see applyRemoteIndexUpdate. Null means the payload wasn't a
693
+ // valid index.
694
+ const pruned = applyRemoteIndexUpdate(msg.index, siteIndexRef.current);
695
+ if (!pruned) return;
693
696
  setSiteIndex(pruned);
694
697
  siteIndexRef.current = pruned;
695
698
  },
@@ -1292,8 +1295,31 @@ export default function EditorShell({
1292
1295
  return id;
1293
1296
  }, [persistence]);
1294
1297
 
1295
- const handleReorderPages = useCallback((from: number, to: number) => {
1296
- setSiteIndex((prev) => reorderPages(prev, from, to));
1298
+ const handleMoveNavEntry = useCallback((from: NavLocation, to: NavLocation) => {
1299
+ setSiteIndex((prev) => moveNavEntry(prev, from, to));
1300
+ persistence.markIndexDirty(); setLocalChangesExist(true);
1301
+ }, [persistence]);
1302
+
1303
+ const handleAddGroup = useCallback(() => {
1304
+ // Mirror handleAddPage's id scheme, including its non-crypto fallback.
1305
+ const id = typeof crypto !== "undefined" && crypto.randomUUID ? `grp_${crypto.randomUUID()}` : `grp_${Date.now()}`;
1306
+ setSiteIndex((prev) => addGroup(prev, id));
1307
+ persistence.markIndexDirty(); setLocalChangesExist(true);
1308
+ return id;
1309
+ }, [persistence]);
1310
+
1311
+ const handleRenameGroup = useCallback((groupId: string, label: string) => {
1312
+ setSiteIndex((prev) => renameGroup(prev, groupId, label));
1313
+ persistence.markIndexDirty(); setLocalChangesExist(true);
1314
+ }, [persistence]);
1315
+
1316
+ const handleSetGroupStartCollapsed = useCallback((groupId: string, v: boolean) => {
1317
+ setSiteIndex((prev) => setGroupStartCollapsed(prev, groupId, v));
1318
+ persistence.markIndexDirty(); setLocalChangesExist(true);
1319
+ }, [persistence]);
1320
+
1321
+ const handleDeleteGroup = useCallback((groupId: string) => {
1322
+ setSiteIndex((prev) => deleteGroup(prev, groupId));
1297
1323
  persistence.markIndexDirty(); setLocalChangesExist(true);
1298
1324
  }, [persistence]);
1299
1325
 
@@ -1826,8 +1852,12 @@ export default function EditorShell({
1826
1852
  <PagesModal
1827
1853
  index={siteIndex}
1828
1854
  audiences={audiences}
1829
- onReorder={handleReorderPages}
1830
1855
  onAddPage={handleAddPage}
1856
+ onAddGroup={handleAddGroup}
1857
+ onRenameGroup={handleRenameGroup}
1858
+ onSetGroupStartCollapsed={handleSetGroupStartCollapsed}
1859
+ onDeleteGroup={handleDeleteGroup}
1860
+ onMoveNavEntry={handleMoveNavEntry}
1831
1861
  onSetHome={handleSetHome}
1832
1862
  onSetArchived={handleSetArchived}
1833
1863
  onSetFields={handleSetPageFields}
@@ -1839,7 +1869,7 @@ export default function EditorShell({
1839
1869
  <EditorModal isOpen={movingSectionId !== null} onClose={() => setMovingSectionId(null)} title="Move to page">
1840
1870
  {movingSectionId && resolvedActivePage && (
1841
1871
  <MoveSectionModal
1842
- pages={siteIndex.pages.map((p) => ({ id: p.id, title: pageDisplayTitle(p.title) }))}
1872
+ pages={pagesInNavOrder(siteIndex).map((p) => ({ id: p.id, title: pageDisplayTitle(p.title) }))}
1843
1873
  currentPageId={resolvedActivePage.id}
1844
1874
  onMove={(destId, position) => handleMoveSection(movingSectionId, destId, position)}
1845
1875
  onCancel={() => setMovingSectionId(null)}