@drawnagency/primitives 0.1.94 → 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-677NTHAZ.js → chunk-PTOLUMGV.js} +3 -0
- package/dist/{chunk-GDLH3LVH.js → chunk-QQBBYQYD.js} +3 -2
- package/dist/{chunk-XYFZ57AS.js → chunk-YUUUCDXN.js} +59 -5
- 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/sections/Container/Container.d.ts +2 -2
- package/dist/components/sections/Container/Container.d.ts.map +1 -1
- package/dist/components/sections/register-schemas.js +2 -2
- package/dist/components/sections/register.js +2 -2
- 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/index.js +8 -6
- 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/package.json +5 -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/sections/Container/Container.tsx +5 -4
- 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/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
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import { EditorModal } from "../shell/EditorModal";
|
|
3
|
+
import { Button } from "./Button";
|
|
4
|
+
|
|
5
|
+
export interface ChangedSection {
|
|
6
|
+
id: string;
|
|
7
|
+
/** The section's heading, or null when it has none / wasn't resolved. */
|
|
8
|
+
title: string | null;
|
|
9
|
+
type: string;
|
|
10
|
+
page: string | null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** "icon_list" → "Icon list". */
|
|
14
|
+
function humanizeType(type: string) {
|
|
15
|
+
const spaced = type.replace(/[-_]/g, " ");
|
|
16
|
+
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Section ids are UUIDs, and plenty of section types (media, spacer) have no
|
|
21
|
+
* heading to show. Fall back to the type name alone — the page shown alongside
|
|
22
|
+
* it carries enough context, and an id fragment reads as noise. Two headless
|
|
23
|
+
* sections of the same type on one page will look alike; that's accepted.
|
|
24
|
+
*/
|
|
25
|
+
function displayName(s: ChangedSection) {
|
|
26
|
+
return s.title ?? humanizeType(s.type);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface ChangeSummary {
|
|
30
|
+
edited: ChangedSection[];
|
|
31
|
+
added: ChangedSection[];
|
|
32
|
+
removed: ChangedSection[];
|
|
33
|
+
structure: string[];
|
|
34
|
+
/** Too many sections changed to resolve real titles — ids shown instead. */
|
|
35
|
+
truncated: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface HistoryChangesModalProps {
|
|
39
|
+
isOpen: boolean;
|
|
40
|
+
onClose: () => void;
|
|
41
|
+
/** Node label, e.g. "Friday · Jul 24" or "Edit at 4:47 PM". */
|
|
42
|
+
title: string;
|
|
43
|
+
base: string | null;
|
|
44
|
+
head: string;
|
|
45
|
+
commitCount: number;
|
|
46
|
+
onBrowse: () => void;
|
|
47
|
+
onRestore: () => void;
|
|
48
|
+
/**
|
|
49
|
+
* True when the editor holds unsaved local changes. Restoring discards them,
|
|
50
|
+
* and unlike published content they are NOT recoverable from history — so the
|
|
51
|
+
* confirmation has to say so rather than reassure.
|
|
52
|
+
*/
|
|
53
|
+
hasUnsavedChanges?: boolean;
|
|
54
|
+
/** Disables the restore controls while a restore is in flight. */
|
|
55
|
+
isRestoring?: boolean;
|
|
56
|
+
/** Set when a dispatched restore came back failed. */
|
|
57
|
+
restoreError?: string | null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Commit ranges are immutable, so a fetched summary stays valid for the session.
|
|
61
|
+
const cache = new Map<string, ChangeSummary>();
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Bump whenever the response shape or the meaning of a field changes.
|
|
65
|
+
*
|
|
66
|
+
* The endpoint sends `immutable` with a one-year max-age — correct for the
|
|
67
|
+
* *content* of a commit range, which genuinely never changes, but it means a
|
|
68
|
+
* browser that cached an older shape would keep serving it and no reload could
|
|
69
|
+
* dislodge it. Versioning the URL makes a shape change a different cache entry.
|
|
70
|
+
* v2: `title` became nullable (was the section id, which is a UUID).
|
|
71
|
+
*/
|
|
72
|
+
const API_VERSION = 2;
|
|
73
|
+
|
|
74
|
+
function queryFor(head: string, base: string | null) {
|
|
75
|
+
const q = new URLSearchParams({ v: String(API_VERSION), head });
|
|
76
|
+
if (base) q.set("base", base);
|
|
77
|
+
return `/api/history/changes?${q.toString()}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function Group({ label, items, sign }: { label: string; items: ChangedSection[]; sign: string }) {
|
|
81
|
+
if (items.length === 0) return null;
|
|
82
|
+
return (
|
|
83
|
+
<>
|
|
84
|
+
<p className="mb-1.5 text-[10px] font-medium tracking-wider text-base-contrast-light uppercase">
|
|
85
|
+
{label} ({items.length})
|
|
86
|
+
</p>
|
|
87
|
+
<ul className="mb-4 list-none p-0">
|
|
88
|
+
{items.map((s) => (
|
|
89
|
+
<li
|
|
90
|
+
key={s.id}
|
|
91
|
+
className="flex items-baseline gap-2 border-b border-base-200 py-1 last:border-b-0"
|
|
92
|
+
>
|
|
93
|
+
<span aria-hidden="true" className="w-3 shrink-0 text-center text-xs font-bold text-base-contrast-light">
|
|
94
|
+
{sign}
|
|
95
|
+
</span>
|
|
96
|
+
<span className="text-sm text-base-contrast">{displayName(s)}</span>
|
|
97
|
+
{s.page && <span className="text-xs text-base-contrast-light">· {s.page}</span>}
|
|
98
|
+
<span className="ml-auto shrink-0 pl-2 text-xs text-base-contrast-light">{s.type}</span>
|
|
99
|
+
</li>
|
|
100
|
+
))}
|
|
101
|
+
</ul>
|
|
102
|
+
</>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function HistoryChangesModal({
|
|
107
|
+
isOpen, onClose, title, base, head, commitCount, onBrowse, onRestore,
|
|
108
|
+
hasUnsavedChanges = false, isRestoring = false, restoreError = null,
|
|
109
|
+
}: HistoryChangesModalProps) {
|
|
110
|
+
const [summary, setSummary] = useState<ChangeSummary | null>(null);
|
|
111
|
+
const [error, setError] = useState(false);
|
|
112
|
+
const [confirmingRestore, setConfirmingRestore] = useState(false);
|
|
113
|
+
|
|
114
|
+
// Reopening on a different range must not inherit the previous confirm state.
|
|
115
|
+
useEffect(() => {
|
|
116
|
+
if (!isOpen) setConfirmingRestore(false);
|
|
117
|
+
}, [isOpen]);
|
|
118
|
+
|
|
119
|
+
useEffect(() => {
|
|
120
|
+
if (!isOpen) return;
|
|
121
|
+
const url = queryFor(head, base);
|
|
122
|
+
const hit = cache.get(url);
|
|
123
|
+
if (hit) {
|
|
124
|
+
setSummary(hit);
|
|
125
|
+
setError(false);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
let live = true;
|
|
129
|
+
setSummary(null);
|
|
130
|
+
setError(false);
|
|
131
|
+
(async () => {
|
|
132
|
+
try {
|
|
133
|
+
const res = await fetch(url);
|
|
134
|
+
if (!res.ok) throw new Error(String(res.status));
|
|
135
|
+
const data = (await res.json()) as ChangeSummary;
|
|
136
|
+
cache.set(url, data);
|
|
137
|
+
if (live) setSummary(data);
|
|
138
|
+
} catch {
|
|
139
|
+
if (live) setError(true);
|
|
140
|
+
}
|
|
141
|
+
})();
|
|
142
|
+
return () => { live = false; };
|
|
143
|
+
}, [isOpen, head, base]);
|
|
144
|
+
|
|
145
|
+
const total = summary
|
|
146
|
+
? summary.edited.length + summary.added.length + summary.removed.length
|
|
147
|
+
: 0;
|
|
148
|
+
|
|
149
|
+
return (
|
|
150
|
+
// `tabbed` locks the dialog to one height so it doesn't jump when the
|
|
151
|
+
// summary arrives, and gives an overflow-hidden flex column — which is what
|
|
152
|
+
// lets the body scroll under a pinned footer. `noPadding` because the
|
|
153
|
+
// scrolling region owns its own padding.
|
|
154
|
+
<EditorModal
|
|
155
|
+
isOpen={isOpen}
|
|
156
|
+
onClose={onClose}
|
|
157
|
+
title={confirmingRestore ? "Restore this version?" : title}
|
|
158
|
+
size="standard"
|
|
159
|
+
tabbed
|
|
160
|
+
noPadding
|
|
161
|
+
>
|
|
162
|
+
<div className="flex min-h-0 flex-1 flex-col">
|
|
163
|
+
<div className="flex-1 overflow-y-auto px-6 py-4">
|
|
164
|
+
{confirmingRestore ? (
|
|
165
|
+
<>
|
|
166
|
+
<p className="text-sm text-base-contrast-light">
|
|
167
|
+
This will publish the content from this version as a new update.
|
|
168
|
+
Your currently published content stays available in the history.
|
|
169
|
+
</p>
|
|
170
|
+
{restoreError && (
|
|
171
|
+
<p role="alert" className="mt-3 text-sm font-medium text-red-600">
|
|
172
|
+
{restoreError} Nothing was published — you can try again.
|
|
173
|
+
</p>
|
|
174
|
+
)}
|
|
175
|
+
{hasUnsavedChanges && (
|
|
176
|
+
<p className="mt-3 text-sm font-medium text-red-600">
|
|
177
|
+
You have unsaved changes. Restoring discards them, and unlike
|
|
178
|
+
published content they cannot be recovered from history.
|
|
179
|
+
</p>
|
|
180
|
+
)}
|
|
181
|
+
</>
|
|
182
|
+
) : (
|
|
183
|
+
<>
|
|
184
|
+
{error && (
|
|
185
|
+
<p className="py-6 text-center text-sm text-base-contrast-light">
|
|
186
|
+
Couldn’t load the change summary.
|
|
187
|
+
</p>
|
|
188
|
+
)}
|
|
189
|
+
|
|
190
|
+
{!error && !summary && (
|
|
191
|
+
<p className="py-6 text-center text-sm text-base-contrast-light">
|
|
192
|
+
Loading changes…
|
|
193
|
+
</p>
|
|
194
|
+
)}
|
|
195
|
+
|
|
196
|
+
{summary && (
|
|
197
|
+
<>
|
|
198
|
+
<p className="mb-4 text-xs text-base-contrast-light">
|
|
199
|
+
{commitCount === 1 ? "Single edit" : `${commitCount} edits`}
|
|
200
|
+
{" · "}
|
|
201
|
+
{total} section{total === 1 ? "" : "s"} affected
|
|
202
|
+
</p>
|
|
203
|
+
|
|
204
|
+
<Group label="Edited" items={summary.edited} sign="~" />
|
|
205
|
+
<Group label="Added" items={summary.added} sign="+" />
|
|
206
|
+
<Group label="Removed" items={summary.removed} sign="−" />
|
|
207
|
+
|
|
208
|
+
{summary.structure.length > 0 && (
|
|
209
|
+
<>
|
|
210
|
+
<p className="mb-1.5 text-[10px] font-medium tracking-wider text-base-contrast-light uppercase">
|
|
211
|
+
Page structure
|
|
212
|
+
</p>
|
|
213
|
+
<ul className="mb-4 list-none p-0">
|
|
214
|
+
{summary.structure.map((s) => (
|
|
215
|
+
<li key={s} className="py-1 text-sm text-base-contrast">{s}</li>
|
|
216
|
+
))}
|
|
217
|
+
</ul>
|
|
218
|
+
</>
|
|
219
|
+
)}
|
|
220
|
+
|
|
221
|
+
{summary.truncated && (
|
|
222
|
+
<p className="mb-4 text-xs text-base-contrast-light">
|
|
223
|
+
Too many sections changed to resolve names — showing section
|
|
224
|
+
types instead.
|
|
225
|
+
</p>
|
|
226
|
+
)}
|
|
227
|
+
</>
|
|
228
|
+
)}
|
|
229
|
+
</>
|
|
230
|
+
)}
|
|
231
|
+
</div>
|
|
232
|
+
|
|
233
|
+
<div className="flex shrink-0 justify-end gap-2 border-t border-base-200 px-6 py-3">
|
|
234
|
+
{confirmingRestore ? (
|
|
235
|
+
<>
|
|
236
|
+
<Button variant="secondary" size="sm" disabled={isRestoring}
|
|
237
|
+
onClick={() => setConfirmingRestore(false)}>
|
|
238
|
+
Cancel
|
|
239
|
+
</Button>
|
|
240
|
+
<Button variant="destructive" size="sm" disabled={isRestoring} onClick={onRestore}>
|
|
241
|
+
{isRestoring ? "Restoring…" : "Restore"}
|
|
242
|
+
</Button>
|
|
243
|
+
</>
|
|
244
|
+
) : (
|
|
245
|
+
<>
|
|
246
|
+
<Button variant="destructive" size="sm" onClick={() => setConfirmingRestore(true)}>
|
|
247
|
+
Restore…
|
|
248
|
+
</Button>
|
|
249
|
+
<Button size="sm" onClick={onBrowse}>Browse this version</Button>
|
|
250
|
+
</>
|
|
251
|
+
)}
|
|
252
|
+
</div>
|
|
253
|
+
</div>
|
|
254
|
+
</EditorModal>
|
|
255
|
+
);
|
|
256
|
+
}
|
|
@@ -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
|
}
|