@drawnagency/primitives 0.1.94 → 0.1.96
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-CS2ECR3U.js} +9 -3
- package/dist/{chunk-GDLH3LVH.js → chunk-CSVGCAIH.js} +7 -6
- package/dist/chunk-EUSWEVOA.js +29 -0
- package/dist/{chunk-677NTHAZ.js → chunk-PTOLUMGV.js} +3 -0
- 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/primitives/index.js +1 -1
- 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/brand-guide.js +2 -2
- package/dist/components/sections/register-schemas.js +4 -4
- package/dist/components/sections/register.js +4 -4
- 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 +45 -37
- 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-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 -1
- package/dist/lib/index.d.ts.map +1 -1
- package/dist/lib/index.js +6 -4
- 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/schemas/index.js +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 +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/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-ops.ts +309 -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 +55 -0
- package/dist/chunk-VAOW4WXQ.js +0 -15
- package/dist/{chunk-ZKGMLLUQ.js → chunk-IFCQWC3G.js} +3 -3
|
@@ -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(() => {
|
|
@@ -37,7 +37,8 @@ import { SectionTypePicker } from "./SectionTypePicker";
|
|
|
37
37
|
import { ensureSectionsRegistered } from "../sections/register";
|
|
38
38
|
import { getSection, getAllSections, getSchema } from "../../lib/registry";
|
|
39
39
|
import { cornerRadiusValue } from "../../lib/corner-radius";
|
|
40
|
-
import { primaryVarValue, accentVarValue } from "../../lib/theme-vars";
|
|
40
|
+
import { primaryVarValue, accentVarValue, onPrimaryVarValue } from "../../lib/theme-vars";
|
|
41
|
+
import { rebaseMediaManifest } from "../../lib/media-folders";
|
|
41
42
|
import { childInsertableTypes, insertChildAt } from "../../lib/container-ops";
|
|
42
43
|
import { buildInsertMenuOptions, resolveInsertedContent } from "../../lib/insert-menu";
|
|
43
44
|
|
|
@@ -88,7 +89,7 @@ import { useBuildStatus } from "../../hooks/useBuildStatus";
|
|
|
88
89
|
import { useMediaPipeline } from "../../hooks/useMediaPipeline";
|
|
89
90
|
import { formatTimestamp } from "../../lib/timestamp";
|
|
90
91
|
import { generateSiteNav } from "../../lib/nav";
|
|
91
|
-
import { siteNavChangeEvent, pageSelectEvent, darkModeEvent, siteNameChangeEvent, historySelectEvent } from "../../lib/events";
|
|
92
|
+
import { siteNavChangeEvent, pageSelectEvent, darkModeEvent, siteNameChangeEvent, historySelectEvent, historyRestoreEvent, historyRestoreFailedEvent, editorDirtyEvent } from "../../lib/events";
|
|
92
93
|
import {
|
|
93
94
|
homePage, pageById, pageBySlug, pageDisplayTitle, addSectionToPage, removeSectionFromPages,
|
|
94
95
|
reorderSectionInPage, audiencePasses,
|
|
@@ -260,6 +261,10 @@ export default function EditorShell({
|
|
|
260
261
|
// writes onto the latest config instead of a stale closure.
|
|
261
262
|
const siteConfigRef = useRef<SiteConfig | null>(siteConfig);
|
|
262
263
|
useEffect(() => { siteConfigRef.current = siteConfig; }, [siteConfig]);
|
|
264
|
+
// Mirrors mediaManifest so recoverConflict can rebase it synchronously and the
|
|
265
|
+
// immediate 409 retry assembles the saved manifest against the rebased copy.
|
|
266
|
+
const mediaManifestRef = useRef<MediaManifest>(mediaManifest);
|
|
267
|
+
useEffect(() => { mediaManifestRef.current = mediaManifest; }, [mediaManifest]);
|
|
263
268
|
// Guards the debounced preview-image composition effect (below): skips
|
|
264
269
|
// recomposing on the very first run when a preview already exists, and
|
|
265
270
|
// dedupes concurrent runs against the same composition signature.
|
|
@@ -487,6 +492,11 @@ export default function EditorShell({
|
|
|
487
492
|
if (applied.droppedDirtyIds.length > 0) await deleteSectionRows(applied.droppedDirtyIds);
|
|
488
493
|
}
|
|
489
494
|
|
|
495
|
+
const rebasedManifest = remote.mediaManifest
|
|
496
|
+
? rebaseMediaManifest(mediaManifestRef.current, remote.mediaManifest)
|
|
497
|
+
: null;
|
|
498
|
+
if (rebasedManifest) setMediaManifest(rebasedManifest);
|
|
499
|
+
|
|
490
500
|
setSections(mergedSections);
|
|
491
501
|
setSiteIndex(mergedIndex);
|
|
492
502
|
setSavedSha(remote.savedBranchSha);
|
|
@@ -497,6 +507,7 @@ export default function EditorShell({
|
|
|
497
507
|
// (and onBaselineCommitted reads the diff refs right after it), before
|
|
498
508
|
// React flushes the setState calls above into their mirror effects.
|
|
499
509
|
savedShaRef.current = remote.savedBranchSha;
|
|
510
|
+
if (rebasedManifest) mediaManifestRef.current = rebasedManifest;
|
|
500
511
|
sectionsRef.current = mergedSections;
|
|
501
512
|
siteIndexRef.current = mergedIndex;
|
|
502
513
|
changedSectionIdsRef.current = new Set(remote.changedSectionIds);
|
|
@@ -581,6 +592,11 @@ export default function EditorShell({
|
|
|
581
592
|
});
|
|
582
593
|
|
|
583
594
|
// The rebased result goes into React state (live view)...
|
|
595
|
+
if (remote.mediaManifest) {
|
|
596
|
+
const rebasedManifest = rebaseMediaManifest(mediaManifestRef.current, remote.mediaManifest);
|
|
597
|
+
setMediaManifest(rebasedManifest);
|
|
598
|
+
mediaManifestRef.current = rebasedManifest;
|
|
599
|
+
}
|
|
584
600
|
setSections(result.sections);
|
|
585
601
|
setSiteIndex(result.index);
|
|
586
602
|
setSavedSha(remote.savedBranchSha);
|
|
@@ -778,6 +794,7 @@ export default function EditorShell({
|
|
|
778
794
|
focusedSectionIdRef.current = null;
|
|
779
795
|
},
|
|
780
796
|
mediaManifest,
|
|
797
|
+
mediaManifestRef,
|
|
781
798
|
manifestDirty: mediaPipeline.manifestDirty,
|
|
782
799
|
clearManifestDirty: () => mediaPipeline.setManifestDirty(false),
|
|
783
800
|
pendingMediaItems: mediaPipeline.pendingMediaItems,
|
|
@@ -890,6 +907,7 @@ export default function EditorShell({
|
|
|
890
907
|
const accentVar = accentVarValue(config);
|
|
891
908
|
root.style.setProperty("--color-primary", primaryVar);
|
|
892
909
|
root.style.setProperty("--color-primary-contrast", accentVar);
|
|
910
|
+
root.style.setProperty("--color-on-primary", onPrimaryVarValue(config));
|
|
893
911
|
root.style.setProperty("--font-heading", `${config.headingFont}, system-ui, sans-serif`);
|
|
894
912
|
root.style.setProperty("--font-body", `${config.bodyFont}, system-ui, sans-serif`);
|
|
895
913
|
root.style.setProperty("--heading-text-transform", config.uppercaseHeadings ? "uppercase" : "none");
|
|
@@ -1456,12 +1474,42 @@ export default function EditorShell({
|
|
|
1456
1474
|
if (!response.ok) throw new Error(`Restore failed: ${response.status}`);
|
|
1457
1475
|
await discardLocalChanges();
|
|
1458
1476
|
window.location.reload();
|
|
1477
|
+
return true;
|
|
1459
1478
|
} catch (err) {
|
|
1460
1479
|
console.error("Failed to restore:", err);
|
|
1461
1480
|
setIsRestoring(false);
|
|
1481
|
+
return false;
|
|
1462
1482
|
}
|
|
1463
1483
|
}, []);
|
|
1464
1484
|
|
|
1485
|
+
// Restore requested straight from the history navigator. The navigator has
|
|
1486
|
+
// already confirmed with the user, so this loads the content at that sha and
|
|
1487
|
+
// runs the same restore path the HistoryToolbar uses.
|
|
1488
|
+
useEffect(() => {
|
|
1489
|
+
return historyRestoreEvent.listen(async ({ sha }) => {
|
|
1490
|
+
try {
|
|
1491
|
+
const res = await fetch(`/api/content/history?sha=${sha}`);
|
|
1492
|
+
if (!res.ok) throw new Error(`Failed to load version: ${res.status}`);
|
|
1493
|
+
const data = await res.json();
|
|
1494
|
+
const ok = await handleHistoryRestore({
|
|
1495
|
+
sections: data.sections,
|
|
1496
|
+
index: data.index,
|
|
1497
|
+
siteConfig: data.siteConfig,
|
|
1498
|
+
});
|
|
1499
|
+
// On success the page reloads, so only a failure ever reaches here.
|
|
1500
|
+
if (!ok) historyRestoreFailedEvent.dispatch({ message: "Couldn't publish the restore." });
|
|
1501
|
+
} catch (err) {
|
|
1502
|
+
console.error("Failed to restore from history:", err);
|
|
1503
|
+
historyRestoreFailedEvent.dispatch({ message: "Couldn't load that version." });
|
|
1504
|
+
}
|
|
1505
|
+
});
|
|
1506
|
+
}, [handleHistoryRestore]);
|
|
1507
|
+
|
|
1508
|
+
// Publish unsaved-draft state to the other islands (see editorDirtyEvent).
|
|
1509
|
+
useEffect(() => {
|
|
1510
|
+
editorDirtyEvent.dispatch({ hasUnsavedChanges: dirtySectionIds.size > 0 });
|
|
1511
|
+
}, [dirtySectionIds]);
|
|
1512
|
+
|
|
1465
1513
|
// --- Render ---
|
|
1466
1514
|
|
|
1467
1515
|
if (shellState.phase === "loading-content") {
|
|
@@ -2269,6 +2317,7 @@ function GlobalModal() {
|
|
|
2269
2317
|
isOpen={modalState !== null}
|
|
2270
2318
|
onClose={closeModal}
|
|
2271
2319
|
title={modalState?.title ?? ""}
|
|
2320
|
+
tabbed={modalState?.tabbed}
|
|
2272
2321
|
>
|
|
2273
2322
|
{modalState?.content}
|
|
2274
2323
|
</EditorModal>
|
|
@@ -106,7 +106,7 @@ export function SiteSettingsModal({ isOpen, onClose, siteConfig, onSiteConfigCha
|
|
|
106
106
|
const tabItems = allTabs.filter((t) => t.show).map(({ id, label, content }) => ({ id, label, content }));
|
|
107
107
|
|
|
108
108
|
return (
|
|
109
|
-
<EditorModal isOpen={isOpen} onClose={onClose} title="Site Settings" size="settings" noPadding>
|
|
109
|
+
<EditorModal isOpen={isOpen} onClose={onClose} title="Site Settings" size="settings" noPadding tabbed>
|
|
110
110
|
<div className="flex flex-1 flex-col overflow-hidden">
|
|
111
111
|
<Tabs tabs={tabItems} />
|
|
112
112
|
|
|
@@ -70,6 +70,10 @@ interface PublishDeps {
|
|
|
70
70
|
* clears only those (a deletion scheduled mid-save must stay scheduled). */
|
|
71
71
|
onSuccess: (savedSectionIds: string[], meta: { editToken: number; sentDeletedIds: string[] }) => void;
|
|
72
72
|
mediaManifest: MediaManifest;
|
|
73
|
+
/** Optional ref mirror of `mediaManifest`, read at save time. recoverConflict
|
|
74
|
+
* refreshes it synchronously, so the immediate 409 retry assembles against
|
|
75
|
+
* the rebased manifest instead of this render's pre-conflict copy. */
|
|
76
|
+
mediaManifestRef?: React.RefObject<MediaManifest>;
|
|
73
77
|
manifestDirty: boolean;
|
|
74
78
|
clearManifestDirty: () => void;
|
|
75
79
|
pendingMediaItems: MediaItem[];
|
|
@@ -126,6 +130,7 @@ export function useEditorPublish({
|
|
|
126
130
|
deletedSectionIds,
|
|
127
131
|
onSuccess,
|
|
128
132
|
mediaManifest,
|
|
133
|
+
mediaManifestRef,
|
|
129
134
|
manifestDirty,
|
|
130
135
|
clearManifestDirty,
|
|
131
136
|
pendingMediaItems,
|
|
@@ -201,11 +206,12 @@ export function useEditorPublish({
|
|
|
201
206
|
if (hasMediaChanges) {
|
|
202
207
|
// Preserves folders (and any future manifest keys) and heals dangling
|
|
203
208
|
// folder refs — never rebuild the manifest as a bare { images }.
|
|
204
|
-
//
|
|
205
|
-
//
|
|
206
|
-
//
|
|
207
|
-
//
|
|
208
|
-
//
|
|
209
|
+
// The assembly starts from THIS editor's view, so what we send can be
|
|
210
|
+
// missing entries another writer added since we loaded. That is no longer
|
|
211
|
+
// destructive: applyContentWrite folds this into the manifest already on
|
|
212
|
+
// the branch, so a save can only add, update, or explicitly delete. The
|
|
213
|
+
// client side of the same story is rebaseMediaManifest, which refreshes
|
|
214
|
+
// our view on 409 recovery and on a peer's saved-event.
|
|
209
215
|
updatedManifest = assembleSavedManifest(
|
|
210
216
|
manifest,
|
|
211
217
|
mediaUploads.map((u) => u.item),
|
|
@@ -390,7 +396,10 @@ export function useEditorPublish({
|
|
|
390
396
|
return performSave({
|
|
391
397
|
siteConfig: siteConfig!,
|
|
392
398
|
siteIndexRef, deletedSectionIds, isConfigDirty,
|
|
393
|
-
pendingMediaItems, pendingMediaDeletions,
|
|
399
|
+
pendingMediaItems, pendingMediaDeletions, manifestDirty,
|
|
400
|
+
// Read through the ref (when the shell supplies one) so the 409 retry
|
|
401
|
+
// below sees recoverConflict's rebased manifest, not this render's copy.
|
|
402
|
+
mediaManifest: mediaManifestRef?.current ?? mediaManifest,
|
|
394
403
|
saveChunkBudget,
|
|
395
404
|
});
|
|
396
405
|
}
|
package/src/lib/events.ts
CHANGED
|
@@ -23,5 +23,16 @@ export const navChangeEvent = createEvent<NavItem[]>("sitenavchange");
|
|
|
23
23
|
export const darkModeEvent = createEvent<string>("sitedarkmode");
|
|
24
24
|
export const siteNameChangeEvent = createEvent<string>("sitenamechange");
|
|
25
25
|
export const historySelectEvent = createEvent<{ sha: string; date: string }>("history-select");
|
|
26
|
+
/** Restore straight from the history navigator, skipping the browse-then-restore
|
|
27
|
+
* detour. The shell loads the content at `sha` and runs the same restore path
|
|
28
|
+
* HistoryToolbar uses. Confirmation happens before this is dispatched. */
|
|
29
|
+
export const historyRestoreEvent = createEvent<{ sha: string; date: string }>("history-restore");
|
|
30
|
+
/** Restore failed. The navigator is a separate island from the shell that does
|
|
31
|
+
* the writing, so without this the failure is invisible and the UI sits on
|
|
32
|
+
* "Restoring…" forever. */
|
|
33
|
+
export const historyRestoreFailedEvent = createEvent<{ message: string }>("history-restore-failed");
|
|
34
|
+
/** Unsaved-draft state, published by the shell for other islands (the history
|
|
35
|
+
* navigator warns before restoring, which discards drafts irrecoverably). */
|
|
36
|
+
export const editorDirtyEvent = createEvent<{ hasUnsavedChanges: boolean }>("editor-dirty");
|
|
26
37
|
export const siteNavChangeEvent = createEvent<SiteNav>("sitenavchange-v2");
|
|
27
38
|
export const pageSelectEvent = createEvent<string>("pageselect");
|