@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.
- package/dist/{chunk-ZKGMLLUQ.js → chunk-5BFPJWIO.js} +5 -5
- package/dist/chunk-EUSWEVOA.js +29 -0
- package/dist/{chunk-CI624HRS.js → chunk-F2337B35.js} +1 -1
- package/dist/{chunk-QQBBYQYD.js → chunk-FOEZNEF5.js} +9 -9
- package/dist/chunk-HJSE6SK5.js +27 -0
- package/dist/{chunk-YUUUCDXN.js → chunk-II3X7GLJ.js} +1 -1
- package/dist/{chunk-DJAJRBPD.js → chunk-KPP2CFW6.js} +2 -2
- package/dist/{chunk-ABV7OZB3.js → chunk-QEHPGA5N.js} +26 -8
- package/dist/{chunk-SOILPZ7U.js → chunk-S4L3ZFKM.js} +1 -1
- package/dist/{chunk-RQPHPAT2.js → chunk-YDXBJR74.js} +2 -2
- package/dist/chunk-ZD6XGRQM.js +85 -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 +4 -4
- 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/useSortableNavRow.d.ts +27 -0
- package/dist/hooks/useSortableNavRow.d.ts.map +1 -0
- package/dist/index.js +60 -38
- package/dist/lib/dexie.d.ts.map +1 -1
- package/dist/lib/dexie.js +3 -1
- package/dist/lib/index-ops.d.ts +77 -0
- package/dist/lib/index-ops.d.ts.map +1 -0
- package/dist/lib/index-ops.js +223 -0
- 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 +2 -2
- package/dist/schemas/index.d.ts +1 -0
- package/dist/schemas/index.d.ts.map +1 -1
- package/dist/schemas/index.js +8 -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 +11 -1
- package/dist/schemas/site-config.d.ts.map +1 -1
- package/dist/storage/index.d.ts +1 -1
- package/dist/storage/index.d.ts.map +1 -1
- package/dist/storage/index.js +9 -3
- package/dist/storage/types.d.ts +44 -0
- package/dist/storage/types.d.ts.map +1 -1
- package/package.json +5 -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 +40 -10
- package/src/hooks/useSortableNavRow.ts +139 -0
- package/src/lib/dexie.ts +6 -1
- package/src/lib/index-ops.ts +309 -0
- 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/schemas/index.ts +1 -0
- package/src/schemas/nav.ts +36 -0
- package/src/schemas/site-config.ts +9 -1
- package/src/storage/index.ts +1 -1
- package/src/storage/types.ts +55 -0
- package/dist/chunk-VAOW4WXQ.js +0 -15
- package/dist/chunk-YBVK254P.js +0 -14
- /package/dist/{chunk-53DKVAB6.js → chunk-72CG6AA2.js} +0 -0
- /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 {
|
|
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";
|
|
@@ -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
|
-
|
|
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
|
|
1296
|
-
setSiteIndex((prev) =>
|
|
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.
|
|
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)}
|