@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.
Files changed (50) hide show
  1. package/dist/{chunk-32H6Q6CX.js → chunk-2YYC2VJY.js} +1 -1
  2. package/dist/{chunk-XQXZHDNR.js → chunk-PHCEJP7I.js} +1 -1
  3. package/dist/{chunk-6SK5BLG3.js → chunk-Q7OKHD6I.js} +1 -1
  4. package/dist/components/editor/SectionWrapper.d.ts +1 -1
  5. package/dist/components/editor/SectionWrapper.d.ts.map +1 -1
  6. package/dist/components/editor/StatusDots.d.ts +25 -0
  7. package/dist/components/editor/StatusDots.d.ts.map +1 -0
  8. package/dist/components/editor/StatusPicker.d.ts +1 -1
  9. package/dist/components/editor/StatusPicker.d.ts.map +1 -1
  10. package/dist/components/editor/index.d.ts +1 -0
  11. package/dist/components/editor/index.d.ts.map +1 -1
  12. package/dist/components/shared/SegmentedControl.d.ts +13 -0
  13. package/dist/components/shared/SegmentedControl.d.ts.map +1 -0
  14. package/dist/components/shared/SplitButton.d.ts +17 -0
  15. package/dist/components/shared/SplitButton.d.ts.map +1 -0
  16. package/dist/components/shell/EditorContext.d.ts +2 -0
  17. package/dist/components/shell/EditorContext.d.ts.map +1 -1
  18. package/dist/components/shell/EditorShell.d.ts.map +1 -1
  19. package/dist/hooks/index.d.ts +1 -0
  20. package/dist/hooks/index.d.ts.map +1 -1
  21. package/dist/hooks/useContentLifecycle.d.ts +13 -0
  22. package/dist/hooks/useContentLifecycle.d.ts.map +1 -0
  23. package/dist/hooks/useEditorPublish.d.ts +5 -1
  24. package/dist/hooks/useEditorPublish.d.ts.map +1 -1
  25. package/dist/index.js +3 -3
  26. package/dist/lib/dexie.d.ts +8 -1
  27. package/dist/lib/dexie.d.ts.map +1 -1
  28. package/dist/lib/index.js +2 -2
  29. package/dist/lib/registry.d.ts +6 -1
  30. package/dist/lib/registry.d.ts.map +1 -1
  31. package/dist/schemas/index.js +2 -2
  32. package/dist/schemas/site-config.d.ts +2 -2
  33. package/package.json +1 -1
  34. package/src/components/editor/SectionWrapper.tsx +44 -2
  35. package/src/components/editor/StatusBadge.tsx +2 -2
  36. package/src/components/editor/StatusDots.tsx +131 -0
  37. package/src/components/editor/StatusPicker.tsx +6 -6
  38. package/src/components/editor/index.ts +1 -0
  39. package/src/components/sections/SectionLayout.tsx +1 -1
  40. package/src/components/shared/Navigation.tsx +3 -3
  41. package/src/components/shared/SegmentedControl.tsx +43 -0
  42. package/src/components/shared/SplitButton.tsx +97 -0
  43. package/src/components/shell/EditorContext.tsx +5 -1
  44. package/src/components/shell/EditorShell.tsx +157 -52
  45. package/src/hooks/index.ts +1 -0
  46. package/src/hooks/useContentLifecycle.ts +34 -0
  47. package/src/hooks/useEditorPublish.ts +230 -66
  48. package/src/lib/dexie.ts +43 -2
  49. package/src/lib/registry.ts +6 -1
  50. 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" | "published" | "archived";
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", "published", "archived"];
15
+ const STATUSES: Status[] = ["draft", "live", "archived"];
16
16
 
17
17
  const statusClasses: Record<Status, string> = {
18
- published: "bg-status-published-bg text-status-published-text",
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
- published: "bg-status-published-bg",
25
- draft: "bg-status-draft-bg",
26
- archived: "bg-status-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) {
@@ -5,3 +5,4 @@ export { SettingsButton } from "./SettingsButton";
5
5
  export { SettingsForm } from "./SettingsForm";
6
6
  export { StatusBadge } from "./StatusBadge";
7
7
  export { SectionWrapper } from "./SectionWrapper";
8
+ export { StatusDots, deriveStatusDots } from "./StatusDots";
@@ -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 !== "published";
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 !== "published" && "opacity-50",
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 !== "published" && "opacity-50",
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 !== "published" && "opacity-50",
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
- setSections((prev) => prev.filter((s) => s.section.id !== sectionId));
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
- [persistence],
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" | "published" | "archived") => {
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" | "published" | "archived") => void;
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, closeModal } = useEditorModal();
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
- {sections.map(({ section, meta }, index) => {
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 === sections.length - 1}
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 === sections.length && (
841
+ {editingEnabled && pendingInsertIndex === displaySections.length && (
772
842
  <SectionSkeleton
773
843
  types={typeOptions}
774
844
  onSelect={(type) => {
775
- onAddSection(sections.length, type);
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" ? "text-green-600" : "text-red-600",
905
+ publishFeedback === "Published" || publishFeedback === "Saved"
906
+ ? "text-green-600"
907
+ : "text-red-600",
830
908
  )}>
831
909
  {publishFeedback}
832
910
  </span>
833
911
  )}
834
- {localChangesExist && (
835
- <Button
836
- type="button"
837
- variant="primary"
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
- Publish
843
- </Button>
927
+ options={[]}
928
+ />
844
929
  )}
845
- {localChangesExist && (
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"