@drawnagency/primitives 0.1.93 → 0.1.95
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-UFOWXOG6.js → chunk-ABV7OZB3.js} +6 -0
- package/dist/{chunk-LQQY2RW6.js → chunk-DJAJRBPD.js} +1 -0
- package/dist/{chunk-677NTHAZ.js → chunk-PTOLUMGV.js} +3 -0
- package/dist/{chunk-RBKBNQGB.js → chunk-QQBBYQYD.js} +7 -6
- package/dist/{chunk-YI2HUFE3.js → chunk-RQPHPAT2.js} +1 -1
- package/dist/chunk-VAOW4WXQ.js +15 -0
- package/dist/{chunk-XYFZ57AS.js → chunk-YUUUCDXN.js} +59 -5
- package/dist/{chunk-HLKAQLTV.js → chunk-ZKGMLLUQ.js} +1 -1
- package/dist/components/brandguide/Colors.d.ts +2 -2
- package/dist/components/brandguide/Colors.d.ts.map +1 -1
- package/dist/components/brandguide/ColorsEditable.d.ts +2 -2
- package/dist/components/brandguide/ColorsEditable.d.ts.map +1 -1
- package/dist/components/editor/SectionWrapper.d.ts.map +1 -1
- package/dist/components/editor/SettingsForm.d.ts.map +1 -1
- package/dist/components/editor/index.js +4 -3
- package/dist/components/primitives/ResolvedMedia.d.ts.map +1 -1
- package/dist/components/primitives/index.js +2 -2
- package/dist/components/sections/Container/Container.d.ts +2 -2
- package/dist/components/sections/Container/Container.d.ts.map +1 -1
- package/dist/components/sections/Video/VideoBlock.d.ts.map +1 -1
- package/dist/components/sections/brand-guide.js +2 -2
- package/dist/components/sections/register-schemas.js +5 -5
- package/dist/components/sections/register.js +5 -5
- package/dist/components/shared/HistoryChangesModal.d.ts +39 -0
- package/dist/components/shared/HistoryChangesModal.d.ts.map +1 -0
- package/dist/components/shared/HistoryPopover.d.ts +3 -1
- package/dist/components/shared/HistoryPopover.d.ts.map +1 -1
- package/dist/components/shared/Navigation.d.ts.map +1 -1
- package/dist/components/shared/Popover.d.ts +10 -1
- package/dist/components/shared/Popover.d.ts.map +1 -1
- package/dist/components/shell/EditorModal.d.ts +6 -1
- package/dist/components/shell/EditorModal.d.ts.map +1 -1
- package/dist/components/shell/EditorModalContext.d.ts +3 -2
- package/dist/components/shell/EditorModalContext.d.ts.map +1 -1
- package/dist/components/shell/EditorShell.d.ts.map +1 -1
- package/dist/hooks/useEditorPublish.d.ts +5 -1
- package/dist/hooks/useEditorPublish.d.ts.map +1 -1
- package/dist/hooks/useMediaPipeline.d.ts.map +1 -1
- package/dist/index.js +9 -12
- package/dist/lib/events.d.ts +18 -0
- package/dist/lib/events.d.ts.map +1 -1
- package/dist/lib/history-buckets.d.ts +30 -0
- package/dist/lib/history-buckets.d.ts.map +1 -0
- package/dist/lib/index.d.ts +1 -1
- package/dist/lib/index.d.ts.map +1 -1
- package/dist/lib/index.js +4 -2
- package/dist/lib/media-folders.d.ts +13 -0
- package/dist/lib/media-folders.d.ts.map +1 -1
- package/dist/lib/media-folders.js +172 -0
- package/dist/lib/registry.d.ts +7 -1
- package/dist/lib/registry.d.ts.map +1 -1
- package/dist/lib/theme-vars.d.ts +19 -4
- package/dist/lib/theme-vars.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 -0
- package/dist/storage/types.d.ts +14 -0
- package/dist/storage/types.d.ts.map +1 -1
- package/package.json +9 -1
- package/src/components/brandguide/Colors.tsx +2 -1
- package/src/components/brandguide/ColorsEditable.tsx +2 -1
- package/src/components/editor/SectionWrapper.tsx +1 -0
- package/src/components/editor/SettingsForm.tsx +5 -2
- package/src/components/primitives/ResolvedMedia.tsx +6 -0
- package/src/components/sections/Container/Container.tsx +5 -4
- package/src/components/sections/Video/VideoBlock.tsx +9 -1
- package/src/components/shared/HistoryChangesModal.tsx +256 -0
- package/src/components/shared/HistoryPopover.tsx +255 -76
- package/src/components/shared/Navigation.tsx +30 -9
- package/src/components/shared/Popover.tsx +95 -2
- package/src/components/shell/EditorModal.tsx +26 -4
- package/src/components/shell/EditorModalContext.tsx +5 -4
- package/src/components/shell/EditorShell.tsx +51 -2
- package/src/components/shell/SiteSettingsModal.tsx +1 -1
- package/src/hooks/useEditorPublish.ts +15 -6
- package/src/hooks/useMediaPipeline.ts +4 -0
- package/src/lib/events.ts +11 -0
- package/src/lib/history-buckets.ts +190 -0
- package/src/lib/index.ts +1 -1
- package/src/lib/media-folders.ts +16 -0
- package/src/lib/registry.ts +9 -1
- package/src/lib/theme-vars.ts +25 -4
- package/src/storage/index.ts +1 -1
- package/src/storage/types.ts +15 -0
|
@@ -1,104 +1,283 @@
|
|
|
1
|
-
import { useState, useCallback, useEffect } from "react";
|
|
1
|
+
import { useState, useCallback, useEffect, useMemo, lazy, Suspense } from "react";
|
|
2
2
|
import { cn } from "../../lib/cn";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
3
|
+
import { historyRestoreEvent, historyRestoreFailedEvent } from "../../lib/events";
|
|
4
|
+
import {
|
|
5
|
+
buildHistoryTree, maxNodeCount,
|
|
6
|
+
type HistoryCommit, type HistoryGroup, type HistoryNode,
|
|
7
|
+
} from "../../lib/history-buckets";
|
|
8
|
+
// Lazy: Navigation is a client:load island on VIEWER pages too, and this pulls
|
|
9
|
+
// in EditorModal. The modal only renders behind the edit-mode gate, so those
|
|
10
|
+
// bytes shouldn't sit in every viewer's bundle.
|
|
11
|
+
const HistoryChangesModal = lazy(() =>
|
|
12
|
+
import("./HistoryChangesModal").then((m) => ({ default: m.HistoryChangesModal })));
|
|
11
13
|
|
|
12
14
|
interface HistoryPopoverProps {
|
|
13
15
|
onSelectCommit: (sha: string, date: string) => void;
|
|
16
|
+
/** Editor holds unsaved local changes; restoring would discard them. */
|
|
17
|
+
hasUnsavedChanges?: boolean;
|
|
14
18
|
}
|
|
15
19
|
|
|
16
|
-
|
|
17
|
-
|
|
20
|
+
const PER_PAGE = 100;
|
|
21
|
+
const MAX_PAGES = 10; // 1000-commit cap
|
|
22
|
+
|
|
23
|
+
/** One entry per zoom level. The root level has no node. */
|
|
24
|
+
interface Frame {
|
|
25
|
+
label: string;
|
|
26
|
+
node: HistoryNode | null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const Chevron = () => (
|
|
30
|
+
<svg width="9" height="9" viewBox="0 0 10 10" fill="none" stroke="currentColor"
|
|
31
|
+
strokeWidth="1.7" aria-hidden="true">
|
|
32
|
+
<path d="M3.5 1.5L7 5l-3.5 3.5" />
|
|
33
|
+
</svg>
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
const BackArrow = () => (
|
|
37
|
+
<svg width="11" height="11" viewBox="0 0 10 10" fill="none" stroke="currentColor"
|
|
38
|
+
strokeWidth="1.7" aria-hidden="true">
|
|
39
|
+
<path d="M6.5 1.5L3 5l3.5 3.5" />
|
|
40
|
+
</svg>
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
/** Browse-to-this-version: a pointer cursor — "go look at the site as it was". */
|
|
44
|
+
const BrowseCursor = () => (
|
|
45
|
+
<svg width="13" height="13" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
|
|
46
|
+
<path d="M3.6 1.9a.4.4 0 0 1 .66-.3l8 6.7a.4.4 0 0 1-.23.71l-3.4.2 1.94 3.5a.4.4 0 0 1-.17.54l-1.4.75a.4.4 0 0 1-.54-.18l-1.9-3.55-2.3 2.2a.4.4 0 0 1-.68-.29z" />
|
|
47
|
+
</svg>
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
function Volume({ count, max }: { count: number; max: number }) {
|
|
51
|
+
// sqrt so a 200-commit year doesn't flatten every other bar to nothing.
|
|
52
|
+
const pct = count > 0 ? Math.round(Math.min(1, Math.sqrt(count / max)) * 100) : 0;
|
|
53
|
+
return (
|
|
54
|
+
<span className="flex items-center gap-1.5" aria-hidden="true">
|
|
55
|
+
<span className="block h-[3px] w-9 rounded-sm bg-base-accent">
|
|
56
|
+
<span className="block h-[3px] rounded-sm bg-primary" style={{ width: `${pct}%` }} />
|
|
57
|
+
</span>
|
|
58
|
+
<span className="min-w-[20px] text-right text-[10.5px] tabular-nums text-base-contrast-light">
|
|
59
|
+
{count}
|
|
60
|
+
</span>
|
|
61
|
+
</span>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function HistoryPopover({ onSelectCommit, hasUnsavedChanges = false }: HistoryPopoverProps) {
|
|
66
|
+
const [commits, setCommits] = useState<HistoryCommit[]>([]);
|
|
18
67
|
const [loading, setLoading] = useState(true);
|
|
19
|
-
const [loadingMore, setLoadingMore] = useState(false);
|
|
20
|
-
const [page, setPage] = useState(1);
|
|
21
|
-
const [hasMore, setHasMore] = useState(true);
|
|
22
68
|
const [error, setError] = useState<string | null>(null);
|
|
69
|
+
const [stack, setStack] = useState<Frame[]>([{ label: "History", node: null }]);
|
|
70
|
+
const [info, setInfo] = useState<HistoryNode | null>(null);
|
|
71
|
+
// Restore ends by reloading the page, so this only has to hold long enough to
|
|
72
|
+
// block a second dispatch during the load + save window.
|
|
73
|
+
const [restoring, setRestoring] = useState(false);
|
|
74
|
+
const [restoreError, setRestoreError] = useState<string | null>(null);
|
|
23
75
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
try {
|
|
30
|
-
const res = await fetch(`/api/history?page=${pageNum}&per_page=20`);
|
|
31
|
-
if (!res.ok) throw new Error(`Failed to load history: ${res.status}`);
|
|
32
|
-
const data = await res.json();
|
|
33
|
-
const items = data.items as CommitItem[];
|
|
34
|
-
setCommits((prev) => isFirst ? items : [...prev, ...items]);
|
|
35
|
-
setHasMore(items.length === 20);
|
|
36
|
-
setPage(pageNum);
|
|
37
|
-
} catch (err) {
|
|
38
|
-
setError(err instanceof Error ? err.message : "Failed to load history");
|
|
39
|
-
} finally {
|
|
40
|
-
setLoading(false);
|
|
41
|
-
setLoadingMore(false);
|
|
42
|
-
}
|
|
43
|
-
}, []);
|
|
76
|
+
// A successful restore reloads, so only failures ever come back.
|
|
77
|
+
useEffect(() => historyRestoreFailedEvent.listen(({ message }) => {
|
|
78
|
+
setRestoring(false);
|
|
79
|
+
setRestoreError(message);
|
|
80
|
+
}), []);
|
|
44
81
|
|
|
45
82
|
useEffect(() => {
|
|
46
|
-
|
|
47
|
-
|
|
83
|
+
let live = true;
|
|
84
|
+
(async () => {
|
|
85
|
+
const all: HistoryCommit[] = [];
|
|
86
|
+
try {
|
|
87
|
+
for (let page = 1; page <= MAX_PAGES; page++) {
|
|
88
|
+
const res = await fetch(`/api/history?page=${page}&per_page=${PER_PAGE}`);
|
|
89
|
+
if (!res.ok) throw new Error(`Failed to load history: ${res.status}`);
|
|
90
|
+
const data = (await res.json()) as { items: HistoryCommit[] };
|
|
91
|
+
all.push(...data.items);
|
|
92
|
+
if (data.items.length < PER_PAGE) break;
|
|
93
|
+
}
|
|
94
|
+
if (live) setCommits(all);
|
|
95
|
+
} catch (err) {
|
|
96
|
+
if (live) setError(err instanceof Error ? err.message : "Failed to load history");
|
|
97
|
+
} finally {
|
|
98
|
+
if (live) setLoading(false);
|
|
99
|
+
}
|
|
100
|
+
})();
|
|
101
|
+
return () => { live = false; };
|
|
102
|
+
}, []);
|
|
103
|
+
|
|
104
|
+
const groups = useMemo(() => buildHistoryTree(commits, new Date()), [commits]);
|
|
105
|
+
const max = useMemo(() => maxNodeCount(groups), [groups]);
|
|
106
|
+
|
|
107
|
+
const dateOf = useCallback(
|
|
108
|
+
(sha: string | null) => commits.find((c) => c.sha === sha)?.date ?? "",
|
|
109
|
+
[commits],
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
const browse = useCallback((node: HistoryNode) => {
|
|
113
|
+
if (!node.newestSha) return;
|
|
114
|
+
onSelectCommit(node.newestSha, dateOf(node.newestSha));
|
|
115
|
+
}, [onSelectCommit, dateOf]);
|
|
116
|
+
|
|
117
|
+
const zoomTo = useCallback((depth: number) => {
|
|
118
|
+
setStack((s) => s.slice(0, depth + 1));
|
|
119
|
+
}, []);
|
|
120
|
+
|
|
121
|
+
const current = stack[stack.length - 1];
|
|
122
|
+
const atRoot = stack.length === 1;
|
|
48
123
|
|
|
49
124
|
if (loading) {
|
|
50
|
-
return
|
|
51
|
-
<div className="p-4 text-center text-xs text-base-contrast-light">
|
|
52
|
-
Loading history...
|
|
53
|
-
</div>
|
|
54
|
-
);
|
|
125
|
+
return <div className="p-4 text-center text-xs text-base-contrast-light">Loading history…</div>;
|
|
55
126
|
}
|
|
56
|
-
|
|
57
127
|
if (error) {
|
|
58
|
-
return
|
|
59
|
-
<div className="p-4 text-center text-xs text-red-600">
|
|
60
|
-
{error}
|
|
61
|
-
</div>
|
|
62
|
-
);
|
|
128
|
+
return <div className="p-4 text-center text-xs text-red-600">{error}</div>;
|
|
63
129
|
}
|
|
64
|
-
|
|
65
130
|
if (commits.length === 0) {
|
|
66
|
-
return
|
|
67
|
-
<div className="p-4 text-center text-xs text-base-contrast-light">
|
|
68
|
-
No history found
|
|
69
|
-
</div>
|
|
70
|
-
);
|
|
131
|
+
return <div className="p-4 text-center text-xs text-base-contrast-light">No history found</div>;
|
|
71
132
|
}
|
|
72
133
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
134
|
+
// Empty buckets are omitted by buildHistoryTree, so every node here has edits.
|
|
135
|
+
const row = (node: HistoryNode) => {
|
|
136
|
+
const expandable = node.children.length > 0;
|
|
137
|
+
const full = node.sublabel ? `${node.label} ${node.sublabel}` : node.label;
|
|
138
|
+
return (
|
|
139
|
+
<li key={node.id} className="group flex items-center pr-1.5 hover:bg-base-accent">
|
|
140
|
+
<span className="flex w-6 shrink-0 items-center justify-center self-stretch">
|
|
141
|
+
{expandable ? (
|
|
78
142
|
<button
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
)}
|
|
143
|
+
type="button"
|
|
144
|
+
aria-label={`Open ${node.label}`}
|
|
145
|
+
onClick={() => setStack((s) => [...s, { label: node.label, node }])}
|
|
146
|
+
className="flex h-[22px] w-5 cursor-pointer items-center justify-center rounded text-base-contrast-light hover:bg-base-accent hover:text-primary"
|
|
84
147
|
>
|
|
85
|
-
|
|
148
|
+
<Chevron />
|
|
86
149
|
</button>
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
150
|
+
) : (
|
|
151
|
+
<span aria-hidden="true" className="text-[9px] text-base-contrast-light">·</span>
|
|
152
|
+
)}
|
|
153
|
+
</span>
|
|
154
|
+
|
|
155
|
+
{/* Primary action is "what changed here?" — the summary is what an editor
|
|
156
|
+
is usually after; browsing is the deliberate second step. The edit
|
|
157
|
+
count rides in the label because the volume bar is decorative and
|
|
158
|
+
hidden from screen readers. */}
|
|
159
|
+
<button
|
|
160
|
+
type="button"
|
|
161
|
+
aria-label={`${full} — ${node.count} edit${node.count === 1 ? "" : "s"} — what changed`}
|
|
162
|
+
onClick={() => setInfo(node)}
|
|
163
|
+
className="min-w-0 flex-1 cursor-pointer truncate py-1.5 text-left text-xs text-base-contrast"
|
|
164
|
+
>
|
|
165
|
+
{node.label}
|
|
166
|
+
{node.sublabel && (
|
|
167
|
+
<span className="ml-1.5 text-[11px] text-base-contrast-light">{node.sublabel}</span>
|
|
168
|
+
)}
|
|
169
|
+
</button>
|
|
170
|
+
|
|
171
|
+
<Volume count={node.count} max={max} />
|
|
172
|
+
|
|
173
|
+
{node.newestSha && (
|
|
174
|
+
<button
|
|
175
|
+
type="button"
|
|
176
|
+
aria-label={`Browse ${full}`}
|
|
177
|
+
onClick={() => browse(node)}
|
|
178
|
+
// focus-visible as well as group-hover: keyboard users never trigger
|
|
179
|
+
// a hover, so without it this control is invisible to them.
|
|
180
|
+
className="ml-1.5 flex h-5 w-5 shrink-0 cursor-pointer items-center justify-center rounded text-base-contrast-light opacity-50 hover:bg-base-accent hover:text-primary focus-visible:opacity-100 group-hover:opacity-100"
|
|
97
181
|
>
|
|
98
|
-
|
|
99
|
-
</
|
|
182
|
+
<BrowseCursor />
|
|
183
|
+
</button>
|
|
184
|
+
)}
|
|
185
|
+
</li>
|
|
186
|
+
);
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
return (
|
|
190
|
+
<div className="flex h-full flex-col overflow-hidden">
|
|
191
|
+
{!atRoot && current.node && (
|
|
192
|
+
<div className="sticky top-0 z-10 flex items-center gap-1.5 border-b border-base-200 bg-base-accent/40 px-1.5 py-1.5">
|
|
193
|
+
<button
|
|
194
|
+
type="button"
|
|
195
|
+
aria-label="Back"
|
|
196
|
+
onClick={() => zoomTo(stack.length - 2)}
|
|
197
|
+
className="flex h-5 w-5 shrink-0 cursor-pointer items-center justify-center rounded text-base-contrast-light hover:bg-base-accent hover:text-base-contrast"
|
|
198
|
+
>
|
|
199
|
+
<BackArrow />
|
|
200
|
+
</button>
|
|
201
|
+
|
|
202
|
+
<nav
|
|
203
|
+
aria-label="History trail"
|
|
204
|
+
className="flex min-w-0 flex-1 items-center gap-1 overflow-hidden text-[11.5px] whitespace-nowrap"
|
|
205
|
+
>
|
|
206
|
+
{stack.map((f, i) =>
|
|
207
|
+
i === stack.length - 1 ? (
|
|
208
|
+
<span key={`${f.label}-${i}`} className="truncate font-semibold text-base-contrast">
|
|
209
|
+
{f.label}
|
|
210
|
+
</span>
|
|
211
|
+
) : (
|
|
212
|
+
<span key={`${f.label}-${i}`} className="flex shrink-0 items-center gap-1">
|
|
213
|
+
<button
|
|
214
|
+
type="button"
|
|
215
|
+
onClick={() => zoomTo(i)}
|
|
216
|
+
className="cursor-pointer rounded px-0.5 text-primary hover:bg-base-accent"
|
|
217
|
+
>
|
|
218
|
+
{f.label}
|
|
219
|
+
</button>
|
|
220
|
+
<span aria-hidden="true" className="text-base-contrast-light">›</span>
|
|
221
|
+
</span>
|
|
222
|
+
),
|
|
223
|
+
)}
|
|
224
|
+
</nav>
|
|
225
|
+
|
|
226
|
+
{/* Volume only. Acting on the level you're inside means going back a
|
|
227
|
+
step and using its row — the bar stays a "where am I", not a
|
|
228
|
+
second set of controls competing with the rows below it. */}
|
|
229
|
+
<span className="shrink-0">
|
|
230
|
+
<Volume count={current.node.count} max={max} />
|
|
231
|
+
</span>
|
|
100
232
|
</div>
|
|
101
233
|
)}
|
|
234
|
+
|
|
235
|
+
<div className="flex-1 overflow-y-auto py-1">
|
|
236
|
+
{atRoot ? (
|
|
237
|
+
groups.map((g: HistoryGroup) => (
|
|
238
|
+
<section key={g.id} role="group" aria-label={g.label}>
|
|
239
|
+
<h3 className="px-3 pt-2.5 pb-1 text-[9.5px] font-medium tracking-[0.07em] text-base-contrast-light uppercase">
|
|
240
|
+
{g.label}
|
|
241
|
+
</h3>
|
|
242
|
+
{g.nodes.length === 0 ? (
|
|
243
|
+
<p className="px-3 pb-1 text-[11px] text-base-contrast-light">No edits</p>
|
|
244
|
+
) : (
|
|
245
|
+
<ul className="list-none p-0">{g.nodes.map(row)}</ul>
|
|
246
|
+
)}
|
|
247
|
+
</section>
|
|
248
|
+
))
|
|
249
|
+
) : (
|
|
250
|
+
<ul className="list-none p-0">{(current.node?.children ?? []).map(row)}</ul>
|
|
251
|
+
)}
|
|
252
|
+
</div>
|
|
253
|
+
|
|
254
|
+
{info && (
|
|
255
|
+
<Suspense fallback={null}>
|
|
256
|
+
<HistoryChangesModal
|
|
257
|
+
isOpen
|
|
258
|
+
onClose={() => { setInfo(null); setRestoreError(null); }}
|
|
259
|
+
title={info.sublabel ? `${info.label} · ${info.sublabel}` : info.label}
|
|
260
|
+
base={info.baseSha}
|
|
261
|
+
head={info.newestSha ?? ""}
|
|
262
|
+
commitCount={info.count}
|
|
263
|
+
hasUnsavedChanges={hasUnsavedChanges}
|
|
264
|
+
isRestoring={restoring}
|
|
265
|
+
restoreError={restoreError}
|
|
266
|
+
onBrowse={() => { browse(info); setInfo(null); }}
|
|
267
|
+
onRestore={() => {
|
|
268
|
+
// Block a second dispatch while the first is still loading and
|
|
269
|
+
// saving — that would be two racing writes to the same content.
|
|
270
|
+
if (restoring || !info.newestSha) return;
|
|
271
|
+
setRestoreError(null);
|
|
272
|
+
setRestoring(true);
|
|
273
|
+
historyRestoreEvent.dispatch({
|
|
274
|
+
sha: info.newestSha,
|
|
275
|
+
date: dateOf(info.newestSha),
|
|
276
|
+
});
|
|
277
|
+
}}
|
|
278
|
+
/>
|
|
279
|
+
</Suspense>
|
|
280
|
+
)}
|
|
102
281
|
</div>
|
|
103
282
|
);
|
|
104
283
|
}
|
|
@@ -3,7 +3,7 @@ import { ChevronRight } from "lucide-react";
|
|
|
3
3
|
import { cn } from "../../lib/cn";
|
|
4
4
|
import { Toggle } from "./Toggle";
|
|
5
5
|
import type { SiteNav, NavItem } from "../../lib/nav";
|
|
6
|
-
import { editModeEvent, siteNavChangeEvent, pageSelectEvent, darkModeEvent, siteNameChangeEvent, historySelectEvent } from "../../lib/events";
|
|
6
|
+
import { editModeEvent, siteNavChangeEvent, pageSelectEvent, darkModeEvent, siteNameChangeEvent, historySelectEvent, editorDirtyEvent } from "../../lib/events";
|
|
7
7
|
import { useActiveHeadings } from "../../hooks/useActiveHeadings";
|
|
8
8
|
import { formatDate } from "../../lib/timestamp";
|
|
9
9
|
import { Popover } from "./Popover";
|
|
@@ -35,21 +35,28 @@ export default function Navigation({ siteNav: initialNav, siteName, darkMode, la
|
|
|
35
35
|
const bp = BP_CLASSES[desktopBreakpoint];
|
|
36
36
|
const [isOpen, setIsOpen] = useState(false);
|
|
37
37
|
const [isEditMode, setIsEditMode] = useState(false);
|
|
38
|
+
// Published by EditorShell (a separate island) so the history navigator can
|
|
39
|
+
// warn that restoring discards unsaved drafts.
|
|
40
|
+
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
|
38
41
|
const [currentDarkMode, setCurrentDarkMode] = useState(darkMode);
|
|
39
42
|
const [currentSiteName, setCurrentSiteName] = useState(siteName);
|
|
40
43
|
const [isDark, setIsDark] = useState(false);
|
|
41
44
|
const [siteNav, setSiteNav] = useState<SiteNav>(initialNav);
|
|
42
45
|
const [showHistory, setShowHistory] = useState(false);
|
|
43
46
|
const [openGroups, setOpenGroups] = useState<Record<string, boolean>>({});
|
|
44
|
-
|
|
47
|
+
// Anchors the history panel to the "Last updated" block rather than the button
|
|
48
|
+
// inside it: the block's top edge is the divider the panel sits above, and its
|
|
49
|
+
// mx-4 matches the nav links' px-4, so the panel lines up with them.
|
|
50
|
+
const historyAnchorRef = useRef<HTMLDivElement>(null);
|
|
45
51
|
|
|
46
52
|
useEffect(() => {
|
|
47
53
|
setIsEditMode(window.location.pathname.startsWith("/edit"));
|
|
48
54
|
const unlistenEdit = editModeEvent.listen(({ isEditMode }) => setIsEditMode(isEditMode));
|
|
55
|
+
const unlistenDirty = editorDirtyEvent.listen((d) => setHasUnsavedChanges(d.hasUnsavedChanges));
|
|
49
56
|
const unlistenNav = siteNavChangeEvent.listen((n) => setSiteNav(n));
|
|
50
57
|
const unlistenDark = darkModeEvent.listen((mode) => setCurrentDarkMode(mode as typeof darkMode));
|
|
51
58
|
const unlistenName = siteNameChangeEvent.listen((name) => setCurrentSiteName(name));
|
|
52
|
-
return () => { unlistenEdit(); unlistenNav(); unlistenDark(); unlistenName(); };
|
|
59
|
+
return () => { unlistenEdit(); unlistenDirty(); unlistenNav(); unlistenDark(); unlistenName(); };
|
|
53
60
|
}, []);
|
|
54
61
|
|
|
55
62
|
useEffect(() => {
|
|
@@ -261,16 +268,30 @@ export default function Navigation({ siteNav: initialNav, siteName, darkMode, la
|
|
|
261
268
|
</div>
|
|
262
269
|
)}
|
|
263
270
|
{lastUpdated && (
|
|
264
|
-
<div className={cn(currentDarkMode !== "optional" && "border-t border-base-200", "mx-4 py-4 text-center")}>
|
|
271
|
+
<div ref={historyAnchorRef} className={cn(currentDarkMode !== "optional" && "border-t border-base-200", "mx-4 py-4 text-center")}>
|
|
265
272
|
{isEditMode ? (
|
|
266
273
|
<div className="relative">
|
|
267
|
-
<button
|
|
268
|
-
|
|
274
|
+
<button onClick={() => setShowHistory((p) => !p)}
|
|
275
|
+
aria-label="View history" aria-expanded={showHistory}
|
|
276
|
+
className="inline-flex cursor-pointer items-center gap-1 text-xs text-base-contrast-light transition-colors hover:text-primary">
|
|
269
277
|
Last updated {formatDate(lastUpdated)}
|
|
278
|
+
{/* The panel opens upward, so the caret points up when closed
|
|
279
|
+
and flips when open — the affordance that this is a toggle
|
|
280
|
+
rather than a label. */}
|
|
281
|
+
<svg width="9" height="9" viewBox="0 0 10 10" fill="none" stroke="currentColor"
|
|
282
|
+
strokeWidth="1.7" aria-hidden="true"
|
|
283
|
+
className={cn("transition-transform", showHistory && "rotate-180")}>
|
|
284
|
+
<path d="M1.5 6.5L5 3l3.5 3.5" strokeLinecap="round" strokeLinejoin="round" />
|
|
285
|
+
</svg>
|
|
270
286
|
</button>
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
287
|
+
{/* Portalled: the sidebar sets overflow-y-auto, which computes
|
|
288
|
+
overflow-x to auto, so a panel wider than the 16rem sidebar
|
|
289
|
+
would be clipped. placement="top" opens it upward from the
|
|
290
|
+
anchor block, landing it just above the divider. */}
|
|
291
|
+
<Popover isOpen={showHistory} onClose={() => setShowHistory(false)} anchorRef={historyAnchorRef}
|
|
292
|
+
portal placement="top"
|
|
293
|
+
className="w-[22rem] h-[min(34rem,calc(100vh-7rem))] overflow-hidden">
|
|
294
|
+
<HistoryPopover hasUnsavedChanges={hasUnsavedChanges} onSelectCommit={(sha, date) => { setShowHistory(false); historySelectEvent.dispatch({ sha, date }); }} />
|
|
274
295
|
</Popover>
|
|
275
296
|
</div>
|
|
276
297
|
) : (
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useEffect, useLayoutEffect, useRef, useState, type ReactNode, type RefObject } from "react";
|
|
2
|
+
import { createPortal } from "react-dom";
|
|
2
3
|
import { useFocusTrap } from "../../hooks/useFocusTrap";
|
|
3
4
|
import { cn } from "../../lib/cn";
|
|
4
5
|
|
|
@@ -9,6 +10,15 @@ interface PopoverProps {
|
|
|
9
10
|
children: ReactNode;
|
|
10
11
|
align?: "start" | "end" | "auto";
|
|
11
12
|
className?: string;
|
|
13
|
+
/**
|
|
14
|
+
* Render into document.body with fixed positioning. Required when the anchor
|
|
15
|
+
* sits inside a scroll container: the nav sidebar sets `overflow-y-auto`,
|
|
16
|
+
* which per CSS computes `overflow-x` to `auto`, so an absolutely-positioned
|
|
17
|
+
* panel wider than the sidebar is clipped and adds a horizontal scrollbar.
|
|
18
|
+
*/
|
|
19
|
+
portal?: boolean;
|
|
20
|
+
/** Which side of the anchor to open toward. Only used with `portal`. */
|
|
21
|
+
placement?: "bottom" | "top";
|
|
12
22
|
}
|
|
13
23
|
|
|
14
24
|
export function Popover({
|
|
@@ -18,13 +28,62 @@ export function Popover({
|
|
|
18
28
|
children,
|
|
19
29
|
align = "auto",
|
|
20
30
|
className,
|
|
31
|
+
portal = false,
|
|
32
|
+
placement = "bottom",
|
|
21
33
|
}: PopoverProps) {
|
|
22
34
|
const panelRef = useRef<HTMLDivElement>(null);
|
|
23
35
|
const [resolvedAlign, setResolvedAlign] = useState<"start" | "end">("start");
|
|
36
|
+
const [fixedPos, setFixedPos] = useState<{ left: number; top?: number; bottom?: number } | null>(null);
|
|
24
37
|
|
|
25
|
-
|
|
38
|
+
// In portal mode the panel isn't rendered until it's been measured, so the
|
|
39
|
+
// trap must activate on that second commit — useFocusTrap bails on a null ref
|
|
40
|
+
// and only re-runs when `active` changes.
|
|
41
|
+
useFocusTrap(panelRef, isOpen && (!portal || fixedPos !== null));
|
|
26
42
|
|
|
43
|
+
// Track the anchor while portalled: the panel is out of normal flow, so it
|
|
44
|
+
// has to be repositioned on resize and on any ancestor's scroll.
|
|
27
45
|
useLayoutEffect(() => {
|
|
46
|
+
if (!portal) return;
|
|
47
|
+
// Drop the stale position on close so the next open re-measures — the
|
|
48
|
+
// anchor can move while the popover is shut (resize, nav scroll), and a
|
|
49
|
+
// retained position would also mount the panel before it's been re-measured.
|
|
50
|
+
if (!isOpen) {
|
|
51
|
+
setFixedPos(null);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const anchor = anchorRef.current;
|
|
55
|
+
if (!anchor) return;
|
|
56
|
+
const update = () => {
|
|
57
|
+
const r = anchor.getBoundingClientRect();
|
|
58
|
+
const next = placement === "top"
|
|
59
|
+
? { left: r.left, bottom: window.innerHeight - r.top + 4 }
|
|
60
|
+
: { left: r.left, top: r.bottom + 4 };
|
|
61
|
+
// The capture-phase scroll listener also fires for scrolling *inside* the
|
|
62
|
+
// panel, so bail when nothing moved rather than re-rendering the subtree.
|
|
63
|
+
setFixedPos((prev) =>
|
|
64
|
+
prev && prev.left === next.left && prev.top === next.top && prev.bottom === next.bottom
|
|
65
|
+
? prev
|
|
66
|
+
: next,
|
|
67
|
+
);
|
|
68
|
+
};
|
|
69
|
+
update();
|
|
70
|
+
window.addEventListener("resize", update);
|
|
71
|
+
window.addEventListener("scroll", update, true);
|
|
72
|
+
return () => {
|
|
73
|
+
window.removeEventListener("resize", update);
|
|
74
|
+
window.removeEventListener("scroll", update, true);
|
|
75
|
+
};
|
|
76
|
+
}, [isOpen, portal, placement, anchorRef]);
|
|
77
|
+
|
|
78
|
+
useLayoutEffect(() => {
|
|
79
|
+
// Alignment is only consulted by the non-portal return below. In portal
|
|
80
|
+
// mode this effect is not merely useless but harmful: it writes directly to
|
|
81
|
+
// panel.style.left to measure, and on a reopen (when the panel mounts on the
|
|
82
|
+
// first commit because fixedPos is still populated) that wipes the inline
|
|
83
|
+
// left React set. The follow-up measure computes the same value, so React's
|
|
84
|
+
// per-key style diff sees no change and never rewrites it — leaving the
|
|
85
|
+
// panel pinned to the viewport edge.
|
|
86
|
+
if (portal) return;
|
|
28
87
|
if (!isOpen || align !== "auto") {
|
|
29
88
|
if (!isOpen) setResolvedAlign("start");
|
|
30
89
|
return;
|
|
@@ -46,12 +105,22 @@ export function Popover({
|
|
|
46
105
|
useEffect(() => {
|
|
47
106
|
if (!isOpen) return;
|
|
48
107
|
function onKeyDown(e: KeyboardEvent) {
|
|
49
|
-
if (e.key
|
|
108
|
+
if (e.key !== "Escape") return;
|
|
109
|
+
// A modal opened from inside this popover also closes on Escape. Without
|
|
110
|
+
// this the one keypress dismisses both, losing the navigator's zoom stack
|
|
111
|
+
// — inconsistent with the X button, and with the mousedown exemption
|
|
112
|
+
// below that exists for exactly this stacking.
|
|
113
|
+
if (document.querySelector("[data-modal-layer]")) return;
|
|
114
|
+
onClose();
|
|
50
115
|
}
|
|
51
116
|
function onMouseDown(e: MouseEvent) {
|
|
52
117
|
const target = e.target as Node;
|
|
53
118
|
if (panelRef.current?.contains(target)) return;
|
|
54
119
|
if (anchorRef.current?.contains(target)) return;
|
|
120
|
+
// A modal opened from inside this popover portals to <body>, so it is
|
|
121
|
+
// "outside" by DOM containment. Closing on its mousedowns would unmount
|
|
122
|
+
// the modal along with the popover, and would break text selection in it.
|
|
123
|
+
if (target instanceof Element && target.closest("[data-modal-layer]")) return;
|
|
55
124
|
onClose();
|
|
56
125
|
}
|
|
57
126
|
document.addEventListener("keydown", onKeyDown);
|
|
@@ -66,6 +135,30 @@ export function Popover({
|
|
|
66
135
|
|
|
67
136
|
const effectiveAlign = align === "auto" ? resolvedAlign : align;
|
|
68
137
|
|
|
138
|
+
if (portal) {
|
|
139
|
+
// Render only once measured. The layout effect above runs after mutation
|
|
140
|
+
// but before paint, so this costs no visible delay — and it avoids painting
|
|
141
|
+
// the panel at the top-left corner for a frame before it's positioned.
|
|
142
|
+
if (!fixedPos) return null;
|
|
143
|
+
return createPortal(
|
|
144
|
+
<div
|
|
145
|
+
ref={panelRef}
|
|
146
|
+
role="dialog"
|
|
147
|
+
aria-modal="false"
|
|
148
|
+
style={{
|
|
149
|
+
position: "fixed",
|
|
150
|
+
left: `${fixedPos.left}px`,
|
|
151
|
+
...(fixedPos.top !== undefined ? { top: `${fixedPos.top}px` } : {}),
|
|
152
|
+
...(fixedPos.bottom !== undefined ? { bottom: `${fixedPos.bottom}px` } : {}),
|
|
153
|
+
}}
|
|
154
|
+
className={cn("z-50 rounded-md border border-base-200 bg-base shadow-lg", className)}
|
|
155
|
+
>
|
|
156
|
+
{children}
|
|
157
|
+
</div>,
|
|
158
|
+
document.body,
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
69
162
|
return (
|
|
70
163
|
<div
|
|
71
164
|
ref={panelRef}
|
|
@@ -12,10 +12,15 @@ interface EditorModalProps {
|
|
|
12
12
|
blocking?: boolean;
|
|
13
13
|
size?: "standard" | "large" | "settings";
|
|
14
14
|
noPadding?: boolean;
|
|
15
|
+
/**
|
|
16
|
+
* The content is a tab set: lock the panel to one height per size so switching
|
|
17
|
+
* tabs never resizes the dialog. Tab panels scroll internally instead.
|
|
18
|
+
*/
|
|
19
|
+
tabbed?: boolean;
|
|
15
20
|
children: ReactNode;
|
|
16
21
|
}
|
|
17
22
|
|
|
18
|
-
export function EditorModal({ isOpen, onClose, title, blocking = false, size = "standard", noPadding = false, children }: EditorModalProps) {
|
|
23
|
+
export function EditorModal({ isOpen, onClose, title, blocking = false, size = "standard", noPadding = false, tabbed = false, children }: EditorModalProps) {
|
|
19
24
|
const panelRef = useRef<HTMLDivElement>(null);
|
|
20
25
|
const titleId = useId();
|
|
21
26
|
|
|
@@ -44,6 +49,12 @@ export function EditorModal({ isOpen, onClose, title, blocking = false, size = "
|
|
|
44
49
|
return createPortal(
|
|
45
50
|
<div
|
|
46
51
|
data-testid="modal-backdrop"
|
|
52
|
+
// Marks the whole modal layer (backdrop + panel) so surfaces underneath —
|
|
53
|
+
// notably Popover's outside-click handler — can tell "a dialog above me is
|
|
54
|
+
// being used" from "the user clicked away". Without it, any mousedown in a
|
|
55
|
+
// modal opened from a popover would dismiss the popover and take the modal
|
|
56
|
+
// with it, and text selection inside the modal would be impossible.
|
|
57
|
+
data-modal-layer=""
|
|
47
58
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
|
|
48
59
|
onMouseDown={
|
|
49
60
|
blocking
|
|
@@ -63,8 +74,16 @@ export function EditorModal({ isOpen, onClose, title, blocking = false, size = "
|
|
|
63
74
|
size === "large"
|
|
64
75
|
? "max-w-[1400px] max-h-[900px] h-[90vh]"
|
|
65
76
|
: size === "settings"
|
|
66
|
-
?
|
|
67
|
-
|
|
77
|
+
? cn(
|
|
78
|
+
"max-w-lg",
|
|
79
|
+
tabbed
|
|
80
|
+
? "h-[min(48rem,calc(100vh-4rem))]"
|
|
81
|
+
: "min-h-[min(32rem,calc(100vh-4rem))] max-h-[calc(100vh-4rem)]",
|
|
82
|
+
)
|
|
83
|
+
: cn(
|
|
84
|
+
"max-w-md",
|
|
85
|
+
tabbed ? "h-[min(32rem,calc(100vh-4rem))]" : "max-h-[calc(100vh-4rem)]",
|
|
86
|
+
),
|
|
68
87
|
)}
|
|
69
88
|
>
|
|
70
89
|
<div className="flex shrink-0 items-center justify-between border-b border-base-200 px-6 py-4">
|
|
@@ -81,7 +100,10 @@ export function EditorModal({ isOpen, onClose, title, blocking = false, size = "
|
|
|
81
100
|
</div>
|
|
82
101
|
<div className={cn(
|
|
83
102
|
!noPadding && "px-6 py-4",
|
|
84
|
-
"flex-1
|
|
103
|
+
"flex-1",
|
|
104
|
+
// Tabbed: the body itself never scrolls, so the tab bar stays pinned
|
|
105
|
+
// and each panel scrolls within the locked height.
|
|
106
|
+
tabbed ? "flex min-h-0 flex-col overflow-hidden" : "overflow-y-auto",
|
|
85
107
|
size === "settings" && "flex flex-col",
|
|
86
108
|
)}>
|
|
87
109
|
{children}
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { createContext, useContext, useState, useCallback, type ReactNode } from "react";
|
|
2
|
+
import type { ModalOptions, OpenModal } from "../../lib/registry";
|
|
2
3
|
|
|
3
|
-
interface ModalState {
|
|
4
|
+
interface ModalState extends ModalOptions {
|
|
4
5
|
title: string;
|
|
5
6
|
content: ReactNode;
|
|
6
7
|
}
|
|
7
8
|
|
|
8
9
|
interface EditorModalContextValue {
|
|
9
10
|
modalState: ModalState | null;
|
|
10
|
-
openModal:
|
|
11
|
+
openModal: OpenModal;
|
|
11
12
|
closeModal: () => void;
|
|
12
13
|
}
|
|
13
14
|
|
|
@@ -16,8 +17,8 @@ const EditorModalContext = createContext<EditorModalContextValue | null>(null);
|
|
|
16
17
|
export function EditorModalProvider({ children }: { children: ReactNode }) {
|
|
17
18
|
const [modalState, setModalState] = useState<ModalState | null>(null);
|
|
18
19
|
|
|
19
|
-
const openModal = useCallback((title: string, content: ReactNode) => {
|
|
20
|
-
setModalState({ title, content });
|
|
20
|
+
const openModal = useCallback((title: string, content: ReactNode, options?: ModalOptions) => {
|
|
21
|
+
setModalState({ title, content, ...options });
|
|
21
22
|
}, []);
|
|
22
23
|
|
|
23
24
|
const closeModal = useCallback(() => {
|