@drawnagency/primitives 0.1.11 → 0.1.12
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-32H6Q6CX.js → chunk-2YYC2VJY.js} +1 -1
- package/dist/{chunk-XQXZHDNR.js → chunk-PHCEJP7I.js} +1 -1
- package/dist/{chunk-6SK5BLG3.js → chunk-Q7OKHD6I.js} +1 -1
- package/dist/components/editor/SectionWrapper.d.ts +1 -1
- package/dist/components/editor/SectionWrapper.d.ts.map +1 -1
- package/dist/components/editor/StatusDots.d.ts +25 -0
- package/dist/components/editor/StatusDots.d.ts.map +1 -0
- package/dist/components/editor/StatusPicker.d.ts +1 -1
- package/dist/components/editor/StatusPicker.d.ts.map +1 -1
- package/dist/components/editor/index.d.ts +1 -0
- package/dist/components/editor/index.d.ts.map +1 -1
- package/dist/components/shared/SegmentedControl.d.ts +13 -0
- package/dist/components/shared/SegmentedControl.d.ts.map +1 -0
- package/dist/components/shared/SplitButton.d.ts +17 -0
- package/dist/components/shared/SplitButton.d.ts.map +1 -0
- package/dist/components/shell/EditorContext.d.ts +2 -0
- package/dist/components/shell/EditorContext.d.ts.map +1 -1
- package/dist/components/shell/EditorShell.d.ts.map +1 -1
- package/dist/hooks/index.d.ts +1 -0
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/useContentLifecycle.d.ts +13 -0
- package/dist/hooks/useContentLifecycle.d.ts.map +1 -0
- package/dist/hooks/useEditorPublish.d.ts +5 -1
- package/dist/hooks/useEditorPublish.d.ts.map +1 -1
- package/dist/index.js +3 -3
- package/dist/lib/dexie.d.ts +8 -1
- package/dist/lib/dexie.d.ts.map +1 -1
- package/dist/lib/index.js +2 -2
- package/dist/lib/registry.d.ts +6 -1
- package/dist/lib/registry.d.ts.map +1 -1
- package/dist/schemas/index.js +2 -2
- package/dist/schemas/site-config.d.ts +2 -2
- package/package.json +1 -1
- package/src/components/editor/SectionWrapper.tsx +44 -2
- package/src/components/editor/StatusBadge.tsx +2 -2
- package/src/components/editor/StatusDots.tsx +131 -0
- package/src/components/editor/StatusPicker.tsx +6 -6
- package/src/components/editor/index.ts +1 -0
- package/src/components/sections/SectionLayout.tsx +1 -1
- package/src/components/shared/Navigation.tsx +3 -3
- package/src/components/shared/SegmentedControl.tsx +43 -0
- package/src/components/shared/SplitButton.tsx +97 -0
- package/src/components/shell/EditorContext.tsx +5 -1
- package/src/components/shell/EditorShell.tsx +157 -52
- package/src/hooks/index.ts +1 -0
- package/src/hooks/useContentLifecycle.ts +34 -0
- package/src/hooks/useEditorPublish.ts +230 -66
- package/src/lib/dexie.ts +43 -2
- package/src/lib/registry.ts +6 -1
- package/src/schemas/site-config.ts +1 -1
|
@@ -4,7 +4,7 @@ import { cn } from "../../lib/cn";
|
|
|
4
4
|
import { Popover } from "../shared/Popover";
|
|
5
5
|
import { PopoverItem } from "../shared/PopoverItem";
|
|
6
6
|
|
|
7
|
-
type Status = "draft" | "
|
|
7
|
+
type Status = "draft" | "live" | "archived";
|
|
8
8
|
|
|
9
9
|
interface Props {
|
|
10
10
|
status: Status;
|
|
@@ -12,18 +12,18 @@ interface Props {
|
|
|
12
12
|
onChange: (status: Status) => void;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
const STATUSES: Status[] = ["draft", "
|
|
15
|
+
const STATUSES: Status[] = ["draft", "live", "archived"];
|
|
16
16
|
|
|
17
17
|
const statusClasses: Record<Status, string> = {
|
|
18
|
-
|
|
18
|
+
live: "bg-status-live-bg text-status-live-text",
|
|
19
19
|
draft: "bg-status-draft-bg text-status-draft-text",
|
|
20
20
|
archived: "bg-status-archived-bg text-status-archived-text",
|
|
21
21
|
};
|
|
22
22
|
|
|
23
23
|
const dotClasses: Record<Status, string> = {
|
|
24
|
-
|
|
25
|
-
draft: "bg-
|
|
26
|
-
archived: "bg-
|
|
24
|
+
live: "bg-green-500",
|
|
25
|
+
draft: "bg-gray-400",
|
|
26
|
+
archived: "bg-white",
|
|
27
27
|
};
|
|
28
28
|
|
|
29
29
|
export function StatusPicker({ status, dirty, onChange }: Props) {
|
|
@@ -19,7 +19,7 @@ interface Props {
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
export function SectionLayout({ type, status, dimNonPublished, children }: Props) {
|
|
22
|
-
const isDimmed = dimNonPublished && status && status !== "
|
|
22
|
+
const isDimmed = dimNonPublished && status && status !== "live";
|
|
23
23
|
|
|
24
24
|
return (
|
|
25
25
|
<div className={cn("section-wrapper group", getSectionCategory(type), isDimmed && "opacity-50")}>
|
|
@@ -121,7 +121,7 @@ export default function Navigation({ navLinks: initialNavLinks, siteName, darkMo
|
|
|
121
121
|
className={cn(
|
|
122
122
|
"cursor-pointer w-full rounded px-3 py-2 text-left text-sm font-bold transition-colors",
|
|
123
123
|
isActiveParent ? "text-primary" : "text-base-contrast hover:text-primary",
|
|
124
|
-
!isEditMode && parent.status && parent.status !== "
|
|
124
|
+
!isEditMode && parent.status && parent.status !== "live" && "opacity-50",
|
|
125
125
|
)}
|
|
126
126
|
>
|
|
127
127
|
{parent.label}
|
|
@@ -140,7 +140,7 @@ export default function Navigation({ navLinks: initialNavLinks, siteName, darkMo
|
|
|
140
140
|
className={cn(
|
|
141
141
|
"cursor-pointer w-full rounded px-2 py-1 text-left text-sm transition-colors",
|
|
142
142
|
isActiveChild ? "font-bold text-primary" : "text-base-contrast-light hover:text-primary",
|
|
143
|
-
!isEditMode && child.status && child.status !== "
|
|
143
|
+
!isEditMode && child.status && child.status !== "live" && "opacity-50",
|
|
144
144
|
)}
|
|
145
145
|
>
|
|
146
146
|
{child.label}
|
|
@@ -157,7 +157,7 @@ export default function Navigation({ navLinks: initialNavLinks, siteName, darkMo
|
|
|
157
157
|
className={cn(
|
|
158
158
|
"cursor-pointer block w-full px-2 py-1 text-left text-xs transition-colors",
|
|
159
159
|
activeGrandchildId === gid ? "font-bold text-primary" : "text-base-contrast-light hover:text-primary",
|
|
160
|
-
!isEditMode && gc.status && gc.status !== "
|
|
160
|
+
!isEditMode && gc.status && gc.status !== "live" && "opacity-50",
|
|
161
161
|
)}
|
|
162
162
|
>
|
|
163
163
|
{gc.label}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { cn } from "../../lib/cn";
|
|
2
|
+
|
|
3
|
+
export interface SegmentedOption {
|
|
4
|
+
value: string;
|
|
5
|
+
label: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
interface SegmentedControlProps {
|
|
9
|
+
options: SegmentedOption[];
|
|
10
|
+
value: string;
|
|
11
|
+
onChange: (value: string) => void;
|
|
12
|
+
className?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function SegmentedControl({ options, value, onChange, className }: SegmentedControlProps) {
|
|
16
|
+
return (
|
|
17
|
+
<div role="radiogroup" className={cn("inline-flex rounded border border-base-200 bg-base-accent", className)}>
|
|
18
|
+
{options.map((option) => {
|
|
19
|
+
const selected = option.value === value;
|
|
20
|
+
return (
|
|
21
|
+
<button
|
|
22
|
+
key={option.value}
|
|
23
|
+
type="button"
|
|
24
|
+
role="radio"
|
|
25
|
+
aria-checked={selected}
|
|
26
|
+
aria-label={option.label}
|
|
27
|
+
onClick={() => {
|
|
28
|
+
if (!selected) onChange(option.value);
|
|
29
|
+
}}
|
|
30
|
+
className={cn(
|
|
31
|
+
"cursor-pointer px-3 py-1 text-xs font-medium transition-colors first:rounded-l last:rounded-r",
|
|
32
|
+
selected
|
|
33
|
+
? "bg-base-contrast text-base-accent"
|
|
34
|
+
: "text-base-contrast-light hover:text-base-contrast",
|
|
35
|
+
)}
|
|
36
|
+
>
|
|
37
|
+
{option.label}
|
|
38
|
+
</button>
|
|
39
|
+
);
|
|
40
|
+
})}
|
|
41
|
+
</div>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { useRef, useState } from "react";
|
|
2
|
+
import { ChevronDown } from "lucide-react";
|
|
3
|
+
import { cn } from "../../lib/cn";
|
|
4
|
+
import { Popover } from "./Popover";
|
|
5
|
+
import { PopoverItem } from "./PopoverItem";
|
|
6
|
+
|
|
7
|
+
export interface SplitButtonOption {
|
|
8
|
+
label: string;
|
|
9
|
+
onClick: () => void;
|
|
10
|
+
disabled?: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface SplitButtonProps {
|
|
14
|
+
label: string;
|
|
15
|
+
onClick: () => void;
|
|
16
|
+
options: SplitButtonOption[];
|
|
17
|
+
disabled?: boolean;
|
|
18
|
+
isLoading?: boolean;
|
|
19
|
+
loadingLabel?: string;
|
|
20
|
+
className?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function SplitButton({
|
|
24
|
+
label,
|
|
25
|
+
onClick,
|
|
26
|
+
options,
|
|
27
|
+
disabled = false,
|
|
28
|
+
isLoading = false,
|
|
29
|
+
loadingLabel,
|
|
30
|
+
className,
|
|
31
|
+
}: SplitButtonProps) {
|
|
32
|
+
const [open, setOpen] = useState(false);
|
|
33
|
+
const chevronRef = useRef<HTMLButtonElement>(null);
|
|
34
|
+
const resolvedDisabled = disabled || isLoading;
|
|
35
|
+
const displayLabel = isLoading && loadingLabel ? loadingLabel : label;
|
|
36
|
+
const hasOptions = options.length > 0 && !resolvedDisabled;
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<div className={cn("relative inline-flex", className)}>
|
|
40
|
+
<button
|
|
41
|
+
type="button"
|
|
42
|
+
onClick={onClick}
|
|
43
|
+
disabled={resolvedDisabled}
|
|
44
|
+
className={cn(
|
|
45
|
+
"inline-flex items-center px-4 py-1.5 text-xs font-medium transition-colors",
|
|
46
|
+
"bg-base-contrast text-base-accent",
|
|
47
|
+
hasOptions ? "rounded-l" : "rounded",
|
|
48
|
+
resolvedDisabled ? "cursor-not-allowed opacity-50" : "cursor-pointer hover:bg-base-contrast/90",
|
|
49
|
+
)}
|
|
50
|
+
aria-label={displayLabel}
|
|
51
|
+
>
|
|
52
|
+
{displayLabel}
|
|
53
|
+
</button>
|
|
54
|
+
{hasOptions && (
|
|
55
|
+
<>
|
|
56
|
+
<button
|
|
57
|
+
ref={chevronRef}
|
|
58
|
+
type="button"
|
|
59
|
+
onClick={() => setOpen((v) => !v)}
|
|
60
|
+
disabled={resolvedDisabled}
|
|
61
|
+
className={cn(
|
|
62
|
+
"inline-flex items-center border-l border-base-accent/20 px-2 py-1.5 transition-colors",
|
|
63
|
+
"bg-base-contrast text-base-accent rounded-r",
|
|
64
|
+
resolvedDisabled ? "cursor-not-allowed opacity-50" : "cursor-pointer hover:bg-base-contrast/90",
|
|
65
|
+
)}
|
|
66
|
+
aria-label="More actions"
|
|
67
|
+
aria-haspopup="true"
|
|
68
|
+
aria-expanded={open}
|
|
69
|
+
>
|
|
70
|
+
<ChevronDown size={14} />
|
|
71
|
+
</button>
|
|
72
|
+
<Popover isOpen={open} onClose={() => setOpen(false)} anchorRef={chevronRef} align="end" className="w-44">
|
|
73
|
+
<ul role="menu" className="py-1">
|
|
74
|
+
{options.map((option) => (
|
|
75
|
+
<li key={option.label}>
|
|
76
|
+
<PopoverItem
|
|
77
|
+
role="menuitem"
|
|
78
|
+
aria-disabled={option.disabled}
|
|
79
|
+
onClick={() => {
|
|
80
|
+
if (!option.disabled) {
|
|
81
|
+
option.onClick();
|
|
82
|
+
setOpen(false);
|
|
83
|
+
}
|
|
84
|
+
}}
|
|
85
|
+
className={cn(option.disabled && "opacity-50 cursor-not-allowed")}
|
|
86
|
+
>
|
|
87
|
+
<span className="text-sm font-medium text-base-contrast">{option.label}</span>
|
|
88
|
+
</PopoverItem>
|
|
89
|
+
</li>
|
|
90
|
+
))}
|
|
91
|
+
</ul>
|
|
92
|
+
</Popover>
|
|
93
|
+
</>
|
|
94
|
+
)}
|
|
95
|
+
</div>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
@@ -4,8 +4,10 @@ import { editModeEvent } from "../../lib/events";
|
|
|
4
4
|
interface EditorContextValue {
|
|
5
5
|
isEditMode: boolean;
|
|
6
6
|
showAllChrome: boolean;
|
|
7
|
+
viewBranch: "saved" | "live";
|
|
7
8
|
toggleEditMode: () => void;
|
|
8
9
|
toggleShowAllChrome: () => void;
|
|
10
|
+
setViewBranch: (branch: "saved" | "live") => void;
|
|
9
11
|
}
|
|
10
12
|
|
|
11
13
|
const EditorContext = createContext<EditorContextValue | null>(null);
|
|
@@ -17,6 +19,7 @@ interface EditorProviderProps {
|
|
|
17
19
|
export function EditorProvider({ children }: EditorProviderProps) {
|
|
18
20
|
const [isEditMode, setIsEditMode] = useState(true);
|
|
19
21
|
const [showAllChrome, setShowAllChrome] = useState(false);
|
|
22
|
+
const [viewBranch, setViewBranchState] = useState<"saved" | "live">("saved");
|
|
20
23
|
|
|
21
24
|
const toggleEditMode = useCallback(() => {
|
|
22
25
|
setIsEditMode((prev) => {
|
|
@@ -27,9 +30,10 @@ export function EditorProvider({ children }: EditorProviderProps) {
|
|
|
27
30
|
}, []);
|
|
28
31
|
|
|
29
32
|
const toggleShowAllChrome = useCallback(() => setShowAllChrome((prev) => !prev), []);
|
|
33
|
+
const setViewBranch = useCallback((b: "saved" | "live") => setViewBranchState(b), []);
|
|
30
34
|
|
|
31
35
|
return (
|
|
32
|
-
<EditorContext.Provider value={{ isEditMode, showAllChrome, toggleEditMode, toggleShowAllChrome }}>
|
|
36
|
+
<EditorContext.Provider value={{ isEditMode, showAllChrome, viewBranch, toggleEditMode, toggleShowAllChrome, setViewBranch }}>
|
|
33
37
|
{children}
|
|
34
38
|
</EditorContext.Provider>
|
|
35
39
|
);
|
|
@@ -35,13 +35,16 @@ import {
|
|
|
35
35
|
} from "../../lib/dexie";
|
|
36
36
|
import { useEditorPersistence } from "../../hooks/useEditorPersistence";
|
|
37
37
|
import { useEditorPublish } from "../../hooks/useEditorPublish";
|
|
38
|
+
import { useContentLifecycle } from "../../hooks/useContentLifecycle";
|
|
38
39
|
import { useMediaPipeline } from "../../hooks/useMediaPipeline";
|
|
39
40
|
import { formatTimestamp } from "../../lib/timestamp";
|
|
40
41
|
import { generateNavLinks } from "../../lib/nav";
|
|
41
42
|
import { navChangeEvent, darkModeEvent } from "../../lib/events";
|
|
42
43
|
import { cn } from "../../lib/cn";
|
|
43
44
|
import { Button } from "../shared/Button";
|
|
45
|
+
import { SplitButton } from "../shared/SplitButton";
|
|
44
46
|
import { IconButton } from "../shared/IconButton";
|
|
47
|
+
import { SegmentedControl } from "../shared/SegmentedControl";
|
|
45
48
|
import { SettingsIcon } from "../shared/icons";
|
|
46
49
|
import { ImageIcon } from "lucide-react";
|
|
47
50
|
import { ErrorBoundary } from "../shared/ErrorBoundary";
|
|
@@ -95,6 +98,13 @@ export default function EditorShell({
|
|
|
95
98
|
const [showMediaLibrary, setShowMediaLibrary] = useState(false);
|
|
96
99
|
const [mediaModalMode, setMediaModalMode] = useState<"select" | "manage">("manage");
|
|
97
100
|
const [mediaSelectCallback, setMediaSelectCallback] = useState<((id: string) => void) | null>(null);
|
|
101
|
+
const [deletedSections, setDeletedSections] = useState<Set<string>>(new Set());
|
|
102
|
+
const [savedSha, setSavedSha] = useState<string | null>(null);
|
|
103
|
+
const [mainSha, setMainSha] = useState<string | null>(null);
|
|
104
|
+
const [changedSectionIds, setChangedSectionIds] = useState<Set<string>>(new Set());
|
|
105
|
+
const [mainIndex, setMainIndex] = useState<SiteIndex | null>(null);
|
|
106
|
+
const [viewSections, setViewSections] = useState<LoadedSection[] | null>(null);
|
|
107
|
+
const [isLoadingViewContent, setIsLoadingViewContent] = useState(false);
|
|
98
108
|
|
|
99
109
|
const siteIndexRef = useRef<SiteIndex>({ siteId, order: [], sections: {} });
|
|
100
110
|
const fontLinkRef = useRef<HTMLLinkElement | null>(null);
|
|
@@ -113,7 +123,7 @@ export default function EditorShell({
|
|
|
113
123
|
markSectionDirty: persistence.markSectionDirty,
|
|
114
124
|
});
|
|
115
125
|
|
|
116
|
-
const { isPublishing, publishFeedback, handlePublish } = useEditorPublish({
|
|
126
|
+
const { isPublishing, publishFeedback, handleSave, handlePublish, handleSaveAndPublish } = useEditorPublish({
|
|
117
127
|
flushNow: persistence.flushNow,
|
|
118
128
|
cancelPendingFlush: persistence.cancelPendingFlush,
|
|
119
129
|
isConfigDirty: persistence.isConfigDirty,
|
|
@@ -121,8 +131,10 @@ export default function EditorShell({
|
|
|
121
131
|
siteIndexRef,
|
|
122
132
|
siteConfig,
|
|
123
133
|
sections,
|
|
134
|
+
deletedSectionIds: Array.from(deletedSections),
|
|
124
135
|
onSuccess: () => {
|
|
125
136
|
setDirtySectionIds(new Set());
|
|
137
|
+
setDeletedSections(new Set());
|
|
126
138
|
setLocalChangesExist(false);
|
|
127
139
|
},
|
|
128
140
|
mediaManifest,
|
|
@@ -143,6 +155,16 @@ export default function EditorShell({
|
|
|
143
155
|
return { images };
|
|
144
156
|
});
|
|
145
157
|
},
|
|
158
|
+
onShasUpdated: (newSavedSha, newMainSha) => {
|
|
159
|
+
setSavedSha(newSavedSha);
|
|
160
|
+
setMainSha((prev) => newMainSha ?? prev);
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
const { buttonState } = useContentLifecycle({
|
|
165
|
+
savedSha,
|
|
166
|
+
mainSha,
|
|
167
|
+
hasLocalChanges: localChangesExist,
|
|
146
168
|
});
|
|
147
169
|
|
|
148
170
|
useEffect(() => {
|
|
@@ -208,7 +230,7 @@ export default function EditorShell({
|
|
|
208
230
|
const savedManifest = await getMediaManifest();
|
|
209
231
|
if (savedManifest) loadedManifest = savedManifest;
|
|
210
232
|
} else {
|
|
211
|
-
const response = await fetch("/api/content");
|
|
233
|
+
const response = await fetch("/api/content?branch=saved");
|
|
212
234
|
if (!response.ok) throw new Error(`Failed to load content: ${response.status}`);
|
|
213
235
|
const data = await response.json();
|
|
214
236
|
loadedSections = data.sections;
|
|
@@ -219,6 +241,12 @@ export default function EditorShell({
|
|
|
219
241
|
loadedManifest = data.mediaManifest as MediaManifest;
|
|
220
242
|
await persistMediaManifest(loadedManifest);
|
|
221
243
|
}
|
|
244
|
+
setSavedSha(data.savedBranchSha ?? null);
|
|
245
|
+
setMainSha(data.sha);
|
|
246
|
+
setChangedSectionIds(new Set(data.changedSectionIds ?? []));
|
|
247
|
+
if (data.mainIndex) {
|
|
248
|
+
setMainIndex(data.mainIndex);
|
|
249
|
+
}
|
|
222
250
|
}
|
|
223
251
|
|
|
224
252
|
if (cancelled) return;
|
|
@@ -399,17 +427,7 @@ export default function EditorShell({
|
|
|
399
427
|
|
|
400
428
|
const onDeleteSection = useCallback(
|
|
401
429
|
(sectionId: string) => {
|
|
402
|
-
|
|
403
|
-
setSiteIndex((prev) => {
|
|
404
|
-
const { [sectionId]: _, ...remainingSections } = prev.sections;
|
|
405
|
-
return {
|
|
406
|
-
...prev,
|
|
407
|
-
order: prev.order.filter((id) => id !== sectionId),
|
|
408
|
-
sections: remainingSections,
|
|
409
|
-
};
|
|
410
|
-
});
|
|
411
|
-
|
|
412
|
-
persistence.markSectionDeleted(sectionId);
|
|
430
|
+
setDeletedSections((prev) => new Set(prev).add(sectionId));
|
|
413
431
|
setLocalChangesExist(true);
|
|
414
432
|
setDirtySectionIds((prev) => {
|
|
415
433
|
const next = new Set(prev);
|
|
@@ -417,9 +435,17 @@ export default function EditorShell({
|
|
|
417
435
|
return next;
|
|
418
436
|
});
|
|
419
437
|
},
|
|
420
|
-
[
|
|
438
|
+
[],
|
|
421
439
|
);
|
|
422
440
|
|
|
441
|
+
const handleUndoDelete = useCallback((sectionId: string) => {
|
|
442
|
+
setDeletedSections((prev) => {
|
|
443
|
+
const next = new Set(prev);
|
|
444
|
+
next.delete(sectionId);
|
|
445
|
+
return next;
|
|
446
|
+
});
|
|
447
|
+
}, []);
|
|
448
|
+
|
|
423
449
|
const onReorderSections = useCallback(
|
|
424
450
|
(fromIndex: number, toIndex: number) => {
|
|
425
451
|
setSections((prev) => {
|
|
@@ -461,7 +487,7 @@ export default function EditorShell({
|
|
|
461
487
|
);
|
|
462
488
|
|
|
463
489
|
const onStatusChange = useCallback(
|
|
464
|
-
(sectionId: string, status: "draft" | "
|
|
490
|
+
(sectionId: string, status: "draft" | "live" | "archived") => {
|
|
465
491
|
setSections((prev) =>
|
|
466
492
|
prev.map((loaded) =>
|
|
467
493
|
loaded.section.id === sectionId
|
|
@@ -526,6 +552,10 @@ export default function EditorShell({
|
|
|
526
552
|
|
|
527
553
|
return (
|
|
528
554
|
<EditorProvider>
|
|
555
|
+
<ViewBranchWatcher
|
|
556
|
+
setViewSections={setViewSections}
|
|
557
|
+
setIsLoadingViewContent={setIsLoadingViewContent}
|
|
558
|
+
/>
|
|
529
559
|
<EditorModalProvider>
|
|
530
560
|
<MediaLibraryContext.Provider value={{
|
|
531
561
|
...mediaPipeline.contextValue,
|
|
@@ -537,10 +567,13 @@ export default function EditorShell({
|
|
|
537
567
|
}}>
|
|
538
568
|
<div className="editor-shell relative">
|
|
539
569
|
<EditorToolbar
|
|
570
|
+
buttonState={buttonState}
|
|
540
571
|
localChangesExist={localChangesExist}
|
|
541
572
|
isPublishing={isPublishing}
|
|
542
573
|
publishFeedback={publishFeedback}
|
|
574
|
+
onSave={handleSave}
|
|
543
575
|
onPublish={handlePublish}
|
|
576
|
+
onSaveAndPublish={handleSaveAndPublish}
|
|
544
577
|
onDiscardClick={() => setShowDiscardConfirm(true)}
|
|
545
578
|
onSettingsClick={() => setShowSiteSettings(true)}
|
|
546
579
|
onMediaClick={() => {
|
|
@@ -554,13 +587,18 @@ export default function EditorShell({
|
|
|
554
587
|
sections={sections}
|
|
555
588
|
audiences={audiences}
|
|
556
589
|
dirtySectionIds={dirtySectionIds}
|
|
590
|
+
deletedSections={deletedSections}
|
|
557
591
|
isPublishing={isPublishing}
|
|
558
592
|
onSectionChange={onSectionChange}
|
|
559
593
|
onAddSection={onAddSection}
|
|
560
594
|
onDeleteSection={onDeleteSection}
|
|
595
|
+
onUndoDelete={handleUndoDelete}
|
|
561
596
|
onReorderSections={onReorderSections}
|
|
562
597
|
onAccessChange={onAccessChange}
|
|
563
598
|
onStatusChange={onStatusChange}
|
|
599
|
+
mainIndex={mainIndex}
|
|
600
|
+
changedSectionIds={changedSectionIds}
|
|
601
|
+
viewSections={viewSections}
|
|
564
602
|
/>
|
|
565
603
|
<GlobalModal />
|
|
566
604
|
<SiteSettingsModal
|
|
@@ -626,36 +664,82 @@ export default function EditorShell({
|
|
|
626
664
|
);
|
|
627
665
|
}
|
|
628
666
|
|
|
667
|
+
function ViewBranchWatcher({
|
|
668
|
+
setViewSections,
|
|
669
|
+
setIsLoadingViewContent,
|
|
670
|
+
}: {
|
|
671
|
+
setViewSections: (sections: LoadedSection[] | null) => void;
|
|
672
|
+
setIsLoadingViewContent: (loading: boolean) => void;
|
|
673
|
+
}) {
|
|
674
|
+
const { isEditMode, viewBranch } = useEditorContext();
|
|
675
|
+
|
|
676
|
+
useEffect(() => {
|
|
677
|
+
if (isEditMode) {
|
|
678
|
+
setViewSections(null);
|
|
679
|
+
return;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
const branch = viewBranch === "live" ? "main" : "saved";
|
|
683
|
+
setIsLoadingViewContent(true);
|
|
684
|
+
|
|
685
|
+
fetch(`/api/content?branch=${branch}`)
|
|
686
|
+
.then((res) => res.json())
|
|
687
|
+
.then((data) => {
|
|
688
|
+
setViewSections(data.sections);
|
|
689
|
+
})
|
|
690
|
+
.catch(console.error)
|
|
691
|
+
.finally(() => setIsLoadingViewContent(false));
|
|
692
|
+
}, [viewBranch, isEditMode]);
|
|
693
|
+
|
|
694
|
+
return null;
|
|
695
|
+
}
|
|
696
|
+
|
|
629
697
|
function EditorContent({
|
|
630
698
|
sections,
|
|
631
699
|
audiences,
|
|
632
700
|
dirtySectionIds,
|
|
701
|
+
deletedSections,
|
|
633
702
|
isPublishing,
|
|
634
703
|
onSectionChange,
|
|
635
704
|
onAddSection,
|
|
636
705
|
onDeleteSection,
|
|
706
|
+
onUndoDelete,
|
|
637
707
|
onReorderSections,
|
|
638
708
|
onAccessChange,
|
|
639
709
|
onStatusChange,
|
|
710
|
+
mainIndex,
|
|
711
|
+
changedSectionIds,
|
|
712
|
+
viewSections,
|
|
640
713
|
}: {
|
|
641
714
|
sections: LoadedSection[];
|
|
642
715
|
audiences: Audience[];
|
|
643
716
|
dirtySectionIds: Set<string>;
|
|
717
|
+
deletedSections: Set<string>;
|
|
644
718
|
isPublishing: boolean;
|
|
645
719
|
onSectionChange: (sectionId: string, content: SectionContent) => void;
|
|
646
720
|
onAddSection: (insertIndex: number, type: string) => void;
|
|
647
721
|
onDeleteSection: (sectionId: string) => void;
|
|
722
|
+
onUndoDelete: (sectionId: string) => void;
|
|
648
723
|
onReorderSections: (fromIndex: number, toIndex: number) => void;
|
|
649
724
|
onAccessChange: (sectionId: string, access: string[]) => void;
|
|
650
|
-
onStatusChange: (sectionId: string, status: "draft" | "
|
|
725
|
+
onStatusChange: (sectionId: string, status: "draft" | "live" | "archived") => void;
|
|
726
|
+
mainIndex: SiteIndex | null;
|
|
727
|
+
changedSectionIds: Set<string>;
|
|
728
|
+
viewSections: LoadedSection[] | null;
|
|
651
729
|
}) {
|
|
652
|
-
const { isEditMode } = useEditorContext();
|
|
653
|
-
const { openModal
|
|
730
|
+
const { isEditMode, viewBranch } = useEditorContext();
|
|
731
|
+
const { openModal } = useEditorModal();
|
|
654
732
|
const [pendingInsertIndex, setPendingInsertIndex] = useState<number | null>(null);
|
|
655
733
|
const dismissPendingInsert = useCallback(() => setPendingInsertIndex(null), []);
|
|
656
734
|
|
|
657
735
|
const editingEnabled = isEditMode && !isPublishing;
|
|
658
736
|
|
|
737
|
+
// In view mode, render viewSections (fetched from the selected branch).
|
|
738
|
+
// When viewBranch is "live", additionally filter to only live sections.
|
|
739
|
+
const displaySections = !isEditMode && viewSections !== null
|
|
740
|
+
? (viewBranch === "live" ? viewSections.filter((s) => s.meta.status === "live") : viewSections)
|
|
741
|
+
: sections;
|
|
742
|
+
|
|
659
743
|
useEffect(() => {
|
|
660
744
|
return monitorForElements({
|
|
661
745
|
onDragStart: ({ source }) => {
|
|
@@ -668,7 +752,7 @@ function EditorContent({
|
|
|
668
752
|
|
|
669
753
|
return (
|
|
670
754
|
<div>
|
|
671
|
-
{
|
|
755
|
+
{displaySections.map(({ section, meta }, index) => {
|
|
672
756
|
const definition = getSection(section.type);
|
|
673
757
|
if (!definition) {
|
|
674
758
|
return (
|
|
@@ -704,14 +788,19 @@ function EditorContent({
|
|
|
704
788
|
status={meta.status}
|
|
705
789
|
dirty={dirtySectionIds.has(section.id)}
|
|
706
790
|
index={index}
|
|
707
|
-
isLast={index ===
|
|
791
|
+
isLast={index === displaySections.length - 1}
|
|
708
792
|
definition={definition}
|
|
793
|
+
mainStatus={mainIndex?.sections[section.id]?.status ?? null}
|
|
794
|
+
contentDiffersFromMain={changedSectionIds.has(section.id) || dirtySectionIds.has(section.id)}
|
|
795
|
+
isLocalOnly={dirtySectionIds.has(section.id) && !changedSectionIds.has(section.id)}
|
|
709
796
|
options={{
|
|
710
797
|
...(section.content as Record<string, unknown>),
|
|
711
798
|
...("options" in section ? (section.options as Record<string, unknown>) : {}),
|
|
712
799
|
}}
|
|
713
800
|
audiences={audiences}
|
|
714
801
|
access={meta.access}
|
|
802
|
+
isDeleted={deletedSections.has(section.id)}
|
|
803
|
+
onUndoDelete={() => onUndoDelete(section.id)}
|
|
715
804
|
onAccessChange={editingEnabled ? (a) => onAccessChange(section.id, a) : undefined}
|
|
716
805
|
onStatusChange={editingEnabled ? (s) => onStatusChange(section.id, s) : undefined}
|
|
717
806
|
onSectionChange={editingEnabled ? (settingsResult) => {
|
|
@@ -733,26 +822,7 @@ function EditorContent({
|
|
|
733
822
|
} : undefined}
|
|
734
823
|
onReorder={editingEnabled ? onReorderSections : undefined}
|
|
735
824
|
onRequestInsert={editingEnabled ? (i) => setPendingInsertIndex(i) : undefined}
|
|
736
|
-
onDelete={editingEnabled ? () =>
|
|
737
|
-
openModal("Delete Section", (
|
|
738
|
-
<div>
|
|
739
|
-
<p className="mb-4 text-sm text-base-contrast-light">
|
|
740
|
-
Delete this <strong>{definition.label}</strong> section? This cannot be undone.
|
|
741
|
-
</p>
|
|
742
|
-
<div className="flex justify-end gap-3">
|
|
743
|
-
<Button variant="secondary" size="md" onClick={() => closeModal()}>
|
|
744
|
-
Cancel
|
|
745
|
-
</Button>
|
|
746
|
-
<Button variant="destructive" size="md" onClick={() => {
|
|
747
|
-
onDeleteSection(section.id);
|
|
748
|
-
closeModal();
|
|
749
|
-
}}>
|
|
750
|
-
Confirm
|
|
751
|
-
</Button>
|
|
752
|
-
</div>
|
|
753
|
-
</div>
|
|
754
|
-
));
|
|
755
|
-
} : undefined}
|
|
825
|
+
onDelete={editingEnabled ? () => onDeleteSection(section.id) : undefined}
|
|
756
826
|
>
|
|
757
827
|
<Component
|
|
758
828
|
content={section}
|
|
@@ -768,11 +838,11 @@ function EditorContent({
|
|
|
768
838
|
);
|
|
769
839
|
})}
|
|
770
840
|
|
|
771
|
-
{editingEnabled && pendingInsertIndex ===
|
|
841
|
+
{editingEnabled && pendingInsertIndex === displaySections.length && (
|
|
772
842
|
<SectionSkeleton
|
|
773
843
|
types={typeOptions}
|
|
774
844
|
onSelect={(type) => {
|
|
775
|
-
onAddSection(
|
|
845
|
+
onAddSection(displaySections.length, type);
|
|
776
846
|
setPendingInsertIndex(null);
|
|
777
847
|
}}
|
|
778
848
|
onDismiss={dismissPendingInsert}
|
|
@@ -798,25 +868,31 @@ function GlobalModal() {
|
|
|
798
868
|
}
|
|
799
869
|
|
|
800
870
|
function EditorToolbar({
|
|
871
|
+
buttonState,
|
|
801
872
|
localChangesExist,
|
|
802
873
|
isPublishing,
|
|
803
874
|
publishFeedback,
|
|
875
|
+
onSave,
|
|
804
876
|
onPublish,
|
|
877
|
+
onSaveAndPublish,
|
|
805
878
|
onDiscardClick,
|
|
806
879
|
onSettingsClick,
|
|
807
880
|
onMediaClick,
|
|
808
881
|
processingItems,
|
|
809
882
|
}: {
|
|
883
|
+
buttonState: "synced" | "publish" | "saveAndPublish";
|
|
810
884
|
localChangesExist: boolean;
|
|
811
885
|
isPublishing: boolean;
|
|
812
886
|
publishFeedback: string | null;
|
|
887
|
+
onSave: () => void;
|
|
813
888
|
onPublish: () => void;
|
|
889
|
+
onSaveAndPublish: () => void;
|
|
814
890
|
onDiscardClick: () => void;
|
|
815
891
|
onSettingsClick: () => void;
|
|
816
892
|
onMediaClick: () => void;
|
|
817
893
|
processingItems: QueueItem[];
|
|
818
894
|
}) {
|
|
819
|
-
const { isEditMode, toggleEditMode } = useEditorContext();
|
|
895
|
+
const { isEditMode, viewBranch, setViewBranch, toggleEditMode } = useEditorContext();
|
|
820
896
|
|
|
821
897
|
return (
|
|
822
898
|
<>
|
|
@@ -826,23 +902,40 @@ function EditorToolbar({
|
|
|
826
902
|
{publishFeedback && (
|
|
827
903
|
<span className={cn(
|
|
828
904
|
"text-xs font-medium",
|
|
829
|
-
publishFeedback === "Published"
|
|
905
|
+
publishFeedback === "Published" || publishFeedback === "Saved"
|
|
906
|
+
? "text-green-600"
|
|
907
|
+
: "text-red-600",
|
|
830
908
|
)}>
|
|
831
909
|
{publishFeedback}
|
|
832
910
|
</span>
|
|
833
911
|
)}
|
|
834
|
-
{
|
|
835
|
-
<
|
|
836
|
-
|
|
837
|
-
|
|
912
|
+
{buttonState === "saveAndPublish" && (
|
|
913
|
+
<SplitButton
|
|
914
|
+
label="Save & Publish"
|
|
915
|
+
onClick={onSaveAndPublish}
|
|
916
|
+
isLoading={isPublishing}
|
|
917
|
+
loadingLabel="Publishing..."
|
|
918
|
+
options={[{ label: "Save", onClick: onSave }]}
|
|
919
|
+
/>
|
|
920
|
+
)}
|
|
921
|
+
{buttonState === "publish" && (
|
|
922
|
+
<SplitButton
|
|
923
|
+
label="Publish"
|
|
838
924
|
onClick={onPublish}
|
|
839
925
|
isLoading={isPublishing}
|
|
840
926
|
loadingLabel="Publishing..."
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
</Button>
|
|
927
|
+
options={[]}
|
|
928
|
+
/>
|
|
844
929
|
)}
|
|
845
|
-
{
|
|
930
|
+
{buttonState === "synced" && (
|
|
931
|
+
<SplitButton
|
|
932
|
+
label="Everything up-to-date"
|
|
933
|
+
onClick={() => {}}
|
|
934
|
+
disabled
|
|
935
|
+
options={[]}
|
|
936
|
+
/>
|
|
937
|
+
)}
|
|
938
|
+
{localChangesExist && !isPublishing && (
|
|
846
939
|
<Button
|
|
847
940
|
type="button"
|
|
848
941
|
variant="destructive"
|
|
@@ -873,6 +966,18 @@ function EditorToolbar({
|
|
|
873
966
|
</div>
|
|
874
967
|
</div>
|
|
875
968
|
)}
|
|
969
|
+
{!isEditMode && (
|
|
970
|
+
<div className="fixed top-0 right-0 left-0 z-50 flex items-center justify-center border-b border-base-200 bg-base px-4 py-2">
|
|
971
|
+
<SegmentedControl
|
|
972
|
+
options={[
|
|
973
|
+
{ value: "saved", label: "Draft" },
|
|
974
|
+
{ value: "live", label: "Live" },
|
|
975
|
+
]}
|
|
976
|
+
value={viewBranch}
|
|
977
|
+
onChange={(v) => setViewBranch(v as "saved" | "live")}
|
|
978
|
+
/>
|
|
979
|
+
</div>
|
|
980
|
+
)}
|
|
876
981
|
<button
|
|
877
982
|
onClick={toggleEditMode}
|
|
878
983
|
className="cursor-pointer fixed bottom-4 right-4 z-50 flex h-10 w-10 items-center justify-center rounded-full bg-primary text-primary-contrast shadow-lg hover:opacity-90 transition-opacity"
|