@drawnagency/primitives 0.1.11 → 0.1.13

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 (71) hide show
  1. package/dist/{chunk-6SK5BLG3.js → chunk-46QI4FDZ.js} +1 -1
  2. package/dist/{chunk-XQXZHDNR.js → chunk-EAEX6DS7.js} +4 -1
  3. package/dist/{chunk-32H6Q6CX.js → chunk-P24YUT3O.js} +1 -1
  4. package/dist/components/editor/AudienceIndicator.d.ts +9 -0
  5. package/dist/components/editor/AudienceIndicator.d.ts.map +1 -0
  6. package/dist/components/editor/IndicatorPill.d.ts +18 -0
  7. package/dist/components/editor/IndicatorPill.d.ts.map +1 -0
  8. package/dist/components/editor/SectionWrapper.d.ts +1 -1
  9. package/dist/components/editor/SectionWrapper.d.ts.map +1 -1
  10. package/dist/components/editor/StatusIndicator.d.ts +29 -0
  11. package/dist/components/editor/StatusIndicator.d.ts.map +1 -0
  12. package/dist/components/editor/index.d.ts +3 -1
  13. package/dist/components/editor/index.d.ts.map +1 -1
  14. package/dist/components/shared/Popover.d.ts +1 -1
  15. package/dist/components/shared/Popover.d.ts.map +1 -1
  16. package/dist/components/shared/SegmentedControl.d.ts +13 -0
  17. package/dist/components/shared/SegmentedControl.d.ts.map +1 -0
  18. package/dist/components/shared/SplitButton.d.ts +17 -0
  19. package/dist/components/shared/SplitButton.d.ts.map +1 -0
  20. package/dist/components/shell/BuildStatusIndicator.d.ts +9 -0
  21. package/dist/components/shell/BuildStatusIndicator.d.ts.map +1 -0
  22. package/dist/components/shell/EditorContext.d.ts +2 -0
  23. package/dist/components/shell/EditorContext.d.ts.map +1 -1
  24. package/dist/components/shell/EditorShell.d.ts.map +1 -1
  25. package/dist/hooks/index.d.ts +2 -0
  26. package/dist/hooks/index.d.ts.map +1 -1
  27. package/dist/hooks/useBuildStatus.d.ts +11 -0
  28. package/dist/hooks/useBuildStatus.d.ts.map +1 -0
  29. package/dist/hooks/useContentLifecycle.d.ts +13 -0
  30. package/dist/hooks/useContentLifecycle.d.ts.map +1 -0
  31. package/dist/hooks/useEditorPublish.d.ts +6 -1
  32. package/dist/hooks/useEditorPublish.d.ts.map +1 -1
  33. package/dist/index.js +3 -3
  34. package/dist/lib/dexie.d.ts +8 -1
  35. package/dist/lib/dexie.d.ts.map +1 -1
  36. package/dist/lib/index.js +2 -2
  37. package/dist/lib/registry.d.ts +6 -1
  38. package/dist/lib/registry.d.ts.map +1 -1
  39. package/dist/schemas/index.js +2 -2
  40. package/dist/schemas/site-config.d.ts +8 -6
  41. package/dist/schemas/site-config.d.ts.map +1 -1
  42. package/package.json +1 -1
  43. package/src/components/editor/{AudiencePicker.tsx → AudienceIndicator.tsx} +25 -49
  44. package/src/components/editor/IndicatorPill.tsx +119 -0
  45. package/src/components/editor/SectionWrapper.tsx +59 -13
  46. package/src/components/editor/StatusIndicator.tsx +148 -0
  47. package/src/components/editor/index.ts +3 -1
  48. package/src/components/sections/SectionLayout.tsx +1 -1
  49. package/src/components/shared/Navigation.tsx +3 -3
  50. package/src/components/shared/Popover.tsx +26 -4
  51. package/src/components/shared/PopoverItem.tsx +1 -1
  52. package/src/components/shared/SegmentedControl.tsx +43 -0
  53. package/src/components/shared/SplitButton.tsx +97 -0
  54. package/src/components/shell/BuildStatusIndicator.tsx +67 -0
  55. package/src/components/shell/EditorContext.tsx +5 -1
  56. package/src/components/shell/EditorShell.tsx +180 -52
  57. package/src/hooks/index.ts +2 -0
  58. package/src/hooks/useBuildStatus.ts +139 -0
  59. package/src/hooks/useContentLifecycle.ts +34 -0
  60. package/src/hooks/useEditorPublish.ts +234 -66
  61. package/src/lib/dexie.ts +43 -2
  62. package/src/lib/registry.ts +6 -1
  63. package/src/schemas/site-config.ts +5 -1
  64. package/dist/components/editor/AudiencePicker.d.ts +0 -9
  65. package/dist/components/editor/AudiencePicker.d.ts.map +0 -1
  66. package/dist/components/editor/StatusBadge.d.ts +0 -7
  67. package/dist/components/editor/StatusBadge.d.ts.map +0 -1
  68. package/dist/components/editor/StatusPicker.d.ts +0 -9
  69. package/dist/components/editor/StatusPicker.d.ts.map +0 -1
  70. package/src/components/editor/StatusBadge.tsx +0 -30
  71. package/src/components/editor/StatusPicker.tsx +0 -86
@@ -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} className="w-full">
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="font-medium text-base-contrast">{option.label}</span>
88
+ </PopoverItem>
89
+ </li>
90
+ ))}
91
+ </ul>
92
+ </Popover>
93
+ </>
94
+ )}
95
+ </div>
96
+ );
97
+ }
@@ -0,0 +1,67 @@
1
+ import { cn } from "../../lib/cn";
2
+ import { X } from "lucide-react";
3
+
4
+ interface BuildStatusIndicatorProps {
5
+ state: "idle" | "building" | "ready" | "error";
6
+ deployUrl: string | null;
7
+ visible: boolean;
8
+ onDismiss: () => void;
9
+ }
10
+
11
+ const stateConfig = {
12
+ building: {
13
+ label: "Deploying...",
14
+ className: "border-orange-500 text-orange-600 hover:bg-orange-50",
15
+ dotClassName: "bg-orange-500 animate-pulse",
16
+ },
17
+ ready: {
18
+ label: "Live",
19
+ className: "border-green-600 text-green-600 hover:bg-green-50",
20
+ dotClassName: "bg-green-600",
21
+ },
22
+ error: {
23
+ label: "Deploy failed",
24
+ className: "border-red-600 text-red-600 hover:bg-red-50",
25
+ dotClassName: "bg-red-600",
26
+ },
27
+ } as const;
28
+
29
+ export function BuildStatusIndicator({
30
+ state,
31
+ deployUrl,
32
+ visible,
33
+ onDismiss,
34
+ }: BuildStatusIndicatorProps) {
35
+ if (!visible || state === "idle") return null;
36
+
37
+ const config = stateConfig[state];
38
+
39
+ return (
40
+ <span className="inline-flex items-center gap-0">
41
+ <a
42
+ href={deployUrl ?? "#"}
43
+ target="_blank"
44
+ rel="noopener noreferrer"
45
+ aria-label={config.label}
46
+ className={cn(
47
+ "inline-flex items-center gap-1.5 rounded-l px-3 py-1.5 text-xs font-medium border transition-colors",
48
+ config.className,
49
+ )}
50
+ >
51
+ <span className={cn("h-1.5 w-1.5 rounded-full", config.dotClassName)} />
52
+ {config.label}
53
+ </a>
54
+ <button
55
+ type="button"
56
+ onClick={onDismiss}
57
+ aria-label="Dismiss build status"
58
+ className={cn(
59
+ "inline-flex items-center rounded-r border border-l-0 px-1.5 py-1.5 transition-colors cursor-pointer",
60
+ config.className,
61
+ )}
62
+ >
63
+ <X size={12} />
64
+ </button>
65
+ </span>
66
+ );
67
+ }
@@ -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,18 @@ 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";
39
+ import { useBuildStatus } from "../../hooks/useBuildStatus";
40
+ import { BuildStatusIndicator } from "./BuildStatusIndicator";
38
41
  import { useMediaPipeline } from "../../hooks/useMediaPipeline";
39
42
  import { formatTimestamp } from "../../lib/timestamp";
40
43
  import { generateNavLinks } from "../../lib/nav";
41
44
  import { navChangeEvent, darkModeEvent } from "../../lib/events";
42
45
  import { cn } from "../../lib/cn";
43
46
  import { Button } from "../shared/Button";
47
+ import { SplitButton } from "../shared/SplitButton";
44
48
  import { IconButton } from "../shared/IconButton";
49
+ import { SegmentedControl } from "../shared/SegmentedControl";
45
50
  import { SettingsIcon } from "../shared/icons";
46
51
  import { ImageIcon } from "lucide-react";
47
52
  import { ErrorBoundary } from "../shared/ErrorBoundary";
@@ -95,6 +100,13 @@ export default function EditorShell({
95
100
  const [showMediaLibrary, setShowMediaLibrary] = useState(false);
96
101
  const [mediaModalMode, setMediaModalMode] = useState<"select" | "manage">("manage");
97
102
  const [mediaSelectCallback, setMediaSelectCallback] = useState<((id: string) => void) | null>(null);
103
+ const [deletedSections, setDeletedSections] = useState<Set<string>>(new Set());
104
+ const [savedSha, setSavedSha] = useState<string | null>(null);
105
+ const [mainSha, setMainSha] = useState<string | null>(null);
106
+ const [changedSectionIds, setChangedSectionIds] = useState<Set<string>>(new Set());
107
+ const [mainIndex, setMainIndex] = useState<SiteIndex | null>(null);
108
+ const [viewSections, setViewSections] = useState<LoadedSection[] | null>(null);
109
+ const [isLoadingViewContent, setIsLoadingViewContent] = useState(false);
98
110
 
99
111
  const siteIndexRef = useRef<SiteIndex>({ siteId, order: [], sections: {} });
100
112
  const fontLinkRef = useRef<HTMLLinkElement | null>(null);
@@ -113,7 +125,9 @@ export default function EditorShell({
113
125
  markSectionDirty: persistence.markSectionDirty,
114
126
  });
115
127
 
116
- const { isPublishing, publishFeedback, handlePublish } = useEditorPublish({
128
+ const buildStatus = useBuildStatus();
129
+
130
+ const { isPublishing, publishFeedback, handleSave, handlePublish, handleSaveAndPublish } = useEditorPublish({
117
131
  flushNow: persistence.flushNow,
118
132
  cancelPendingFlush: persistence.cancelPendingFlush,
119
133
  isConfigDirty: persistence.isConfigDirty,
@@ -121,8 +135,10 @@ export default function EditorShell({
121
135
  siteIndexRef,
122
136
  siteConfig,
123
137
  sections,
138
+ deletedSectionIds: Array.from(deletedSections),
124
139
  onSuccess: () => {
125
140
  setDirtySectionIds(new Set());
141
+ setDeletedSections(new Set());
126
142
  setLocalChangesExist(false);
127
143
  },
128
144
  mediaManifest,
@@ -143,6 +159,17 @@ export default function EditorShell({
143
159
  return { images };
144
160
  });
145
161
  },
162
+ onShasUpdated: (newSavedSha, newMainSha) => {
163
+ setSavedSha(newSavedSha);
164
+ setMainSha((prev) => newMainSha ?? prev);
165
+ },
166
+ onPublishComplete: buildStatus.startTracking,
167
+ });
168
+
169
+ const { buttonState } = useContentLifecycle({
170
+ savedSha,
171
+ mainSha,
172
+ hasLocalChanges: localChangesExist,
146
173
  });
147
174
 
148
175
  useEffect(() => {
@@ -208,7 +235,7 @@ export default function EditorShell({
208
235
  const savedManifest = await getMediaManifest();
209
236
  if (savedManifest) loadedManifest = savedManifest;
210
237
  } else {
211
- const response = await fetch("/api/content");
238
+ const response = await fetch("/api/content?branch=saved");
212
239
  if (!response.ok) throw new Error(`Failed to load content: ${response.status}`);
213
240
  const data = await response.json();
214
241
  loadedSections = data.sections;
@@ -219,6 +246,12 @@ export default function EditorShell({
219
246
  loadedManifest = data.mediaManifest as MediaManifest;
220
247
  await persistMediaManifest(loadedManifest);
221
248
  }
249
+ setSavedSha(data.savedBranchSha ?? null);
250
+ setMainSha(data.sha);
251
+ setChangedSectionIds(new Set(data.changedSectionIds ?? []));
252
+ if (data.mainIndex) {
253
+ setMainIndex(data.mainIndex);
254
+ }
222
255
  }
223
256
 
224
257
  if (cancelled) return;
@@ -399,17 +432,7 @@ export default function EditorShell({
399
432
 
400
433
  const onDeleteSection = useCallback(
401
434
  (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);
435
+ setDeletedSections((prev) => new Set(prev).add(sectionId));
413
436
  setLocalChangesExist(true);
414
437
  setDirtySectionIds((prev) => {
415
438
  const next = new Set(prev);
@@ -417,9 +440,17 @@ export default function EditorShell({
417
440
  return next;
418
441
  });
419
442
  },
420
- [persistence],
443
+ [],
421
444
  );
422
445
 
446
+ const handleUndoDelete = useCallback((sectionId: string) => {
447
+ setDeletedSections((prev) => {
448
+ const next = new Set(prev);
449
+ next.delete(sectionId);
450
+ return next;
451
+ });
452
+ }, []);
453
+
423
454
  const onReorderSections = useCallback(
424
455
  (fromIndex: number, toIndex: number) => {
425
456
  setSections((prev) => {
@@ -461,7 +492,7 @@ export default function EditorShell({
461
492
  );
462
493
 
463
494
  const onStatusChange = useCallback(
464
- (sectionId: string, status: "draft" | "published" | "archived") => {
495
+ (sectionId: string, status: "draft" | "live" | "archived") => {
465
496
  setSections((prev) =>
466
497
  prev.map((loaded) =>
467
498
  loaded.section.id === sectionId
@@ -526,6 +557,10 @@ export default function EditorShell({
526
557
 
527
558
  return (
528
559
  <EditorProvider>
560
+ <ViewBranchWatcher
561
+ setViewSections={setViewSections}
562
+ setIsLoadingViewContent={setIsLoadingViewContent}
563
+ />
529
564
  <EditorModalProvider>
530
565
  <MediaLibraryContext.Provider value={{
531
566
  ...mediaPipeline.contextValue,
@@ -537,10 +572,13 @@ export default function EditorShell({
537
572
  }}>
538
573
  <div className="editor-shell relative">
539
574
  <EditorToolbar
575
+ buttonState={buttonState}
540
576
  localChangesExist={localChangesExist}
541
577
  isPublishing={isPublishing}
542
578
  publishFeedback={publishFeedback}
579
+ onSave={handleSave}
543
580
  onPublish={handlePublish}
581
+ onSaveAndPublish={handleSaveAndPublish}
544
582
  onDiscardClick={() => setShowDiscardConfirm(true)}
545
583
  onSettingsClick={() => setShowSiteSettings(true)}
546
584
  onMediaClick={() => {
@@ -548,19 +586,28 @@ export default function EditorShell({
548
586
  setShowMediaLibrary(true);
549
587
  }}
550
588
  processingItems={mediaPipeline.processingItems}
589
+ buildState={buildStatus.state}
590
+ buildDeployUrl={buildStatus.deployUrl}
591
+ buildVisible={buildStatus.visible}
592
+ onBuildDismiss={buildStatus.dismiss}
551
593
  />
552
594
 
553
595
  <EditorContent
554
596
  sections={sections}
555
597
  audiences={audiences}
556
598
  dirtySectionIds={dirtySectionIds}
599
+ deletedSections={deletedSections}
557
600
  isPublishing={isPublishing}
558
601
  onSectionChange={onSectionChange}
559
602
  onAddSection={onAddSection}
560
603
  onDeleteSection={onDeleteSection}
604
+ onUndoDelete={handleUndoDelete}
561
605
  onReorderSections={onReorderSections}
562
606
  onAccessChange={onAccessChange}
563
607
  onStatusChange={onStatusChange}
608
+ mainIndex={mainIndex}
609
+ changedSectionIds={changedSectionIds}
610
+ viewSections={viewSections}
564
611
  />
565
612
  <GlobalModal />
566
613
  <SiteSettingsModal
@@ -626,36 +673,82 @@ export default function EditorShell({
626
673
  );
627
674
  }
628
675
 
676
+ function ViewBranchWatcher({
677
+ setViewSections,
678
+ setIsLoadingViewContent,
679
+ }: {
680
+ setViewSections: (sections: LoadedSection[] | null) => void;
681
+ setIsLoadingViewContent: (loading: boolean) => void;
682
+ }) {
683
+ const { isEditMode, viewBranch } = useEditorContext();
684
+
685
+ useEffect(() => {
686
+ if (isEditMode) {
687
+ setViewSections(null);
688
+ return;
689
+ }
690
+
691
+ const branch = viewBranch === "live" ? "main" : "saved";
692
+ setIsLoadingViewContent(true);
693
+
694
+ fetch(`/api/content?branch=${branch}`)
695
+ .then((res) => res.json())
696
+ .then((data) => {
697
+ setViewSections(data.sections);
698
+ })
699
+ .catch(console.error)
700
+ .finally(() => setIsLoadingViewContent(false));
701
+ }, [viewBranch, isEditMode]);
702
+
703
+ return null;
704
+ }
705
+
629
706
  function EditorContent({
630
707
  sections,
631
708
  audiences,
632
709
  dirtySectionIds,
710
+ deletedSections,
633
711
  isPublishing,
634
712
  onSectionChange,
635
713
  onAddSection,
636
714
  onDeleteSection,
715
+ onUndoDelete,
637
716
  onReorderSections,
638
717
  onAccessChange,
639
718
  onStatusChange,
719
+ mainIndex,
720
+ changedSectionIds,
721
+ viewSections,
640
722
  }: {
641
723
  sections: LoadedSection[];
642
724
  audiences: Audience[];
643
725
  dirtySectionIds: Set<string>;
726
+ deletedSections: Set<string>;
644
727
  isPublishing: boolean;
645
728
  onSectionChange: (sectionId: string, content: SectionContent) => void;
646
729
  onAddSection: (insertIndex: number, type: string) => void;
647
730
  onDeleteSection: (sectionId: string) => void;
731
+ onUndoDelete: (sectionId: string) => void;
648
732
  onReorderSections: (fromIndex: number, toIndex: number) => void;
649
733
  onAccessChange: (sectionId: string, access: string[]) => void;
650
- onStatusChange: (sectionId: string, status: "draft" | "published" | "archived") => void;
734
+ onStatusChange: (sectionId: string, status: "draft" | "live" | "archived") => void;
735
+ mainIndex: SiteIndex | null;
736
+ changedSectionIds: Set<string>;
737
+ viewSections: LoadedSection[] | null;
651
738
  }) {
652
- const { isEditMode } = useEditorContext();
653
- const { openModal, closeModal } = useEditorModal();
739
+ const { isEditMode, viewBranch } = useEditorContext();
740
+ const { openModal } = useEditorModal();
654
741
  const [pendingInsertIndex, setPendingInsertIndex] = useState<number | null>(null);
655
742
  const dismissPendingInsert = useCallback(() => setPendingInsertIndex(null), []);
656
743
 
657
744
  const editingEnabled = isEditMode && !isPublishing;
658
745
 
746
+ // In view mode, render viewSections (fetched from the selected branch).
747
+ // When viewBranch is "live", additionally filter to only live sections.
748
+ const displaySections = !isEditMode && viewSections !== null
749
+ ? (viewBranch === "live" ? viewSections.filter((s) => s.meta.status === "live") : viewSections)
750
+ : sections;
751
+
659
752
  useEffect(() => {
660
753
  return monitorForElements({
661
754
  onDragStart: ({ source }) => {
@@ -668,7 +761,7 @@ function EditorContent({
668
761
 
669
762
  return (
670
763
  <div>
671
- {sections.map(({ section, meta }, index) => {
764
+ {displaySections.map(({ section, meta }, index) => {
672
765
  const definition = getSection(section.type);
673
766
  if (!definition) {
674
767
  return (
@@ -704,14 +797,19 @@ function EditorContent({
704
797
  status={meta.status}
705
798
  dirty={dirtySectionIds.has(section.id)}
706
799
  index={index}
707
- isLast={index === sections.length - 1}
800
+ isLast={index === displaySections.length - 1}
708
801
  definition={definition}
802
+ mainStatus={mainIndex?.sections[section.id]?.status ?? null}
803
+ contentDiffersFromMain={changedSectionIds.has(section.id) || dirtySectionIds.has(section.id)}
804
+ isLocalOnly={dirtySectionIds.has(section.id) && !changedSectionIds.has(section.id)}
709
805
  options={{
710
806
  ...(section.content as Record<string, unknown>),
711
807
  ...("options" in section ? (section.options as Record<string, unknown>) : {}),
712
808
  }}
713
809
  audiences={audiences}
714
810
  access={meta.access}
811
+ isDeleted={deletedSections.has(section.id)}
812
+ onUndoDelete={() => onUndoDelete(section.id)}
715
813
  onAccessChange={editingEnabled ? (a) => onAccessChange(section.id, a) : undefined}
716
814
  onStatusChange={editingEnabled ? (s) => onStatusChange(section.id, s) : undefined}
717
815
  onSectionChange={editingEnabled ? (settingsResult) => {
@@ -733,26 +831,7 @@ function EditorContent({
733
831
  } : undefined}
734
832
  onReorder={editingEnabled ? onReorderSections : undefined}
735
833
  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}
834
+ onDelete={editingEnabled ? () => onDeleteSection(section.id) : undefined}
756
835
  >
757
836
  <Component
758
837
  content={section}
@@ -768,11 +847,11 @@ function EditorContent({
768
847
  );
769
848
  })}
770
849
 
771
- {editingEnabled && pendingInsertIndex === sections.length && (
850
+ {editingEnabled && pendingInsertIndex === displaySections.length && (
772
851
  <SectionSkeleton
773
852
  types={typeOptions}
774
853
  onSelect={(type) => {
775
- onAddSection(sections.length, type);
854
+ onAddSection(displaySections.length, type);
776
855
  setPendingInsertIndex(null);
777
856
  }}
778
857
  onDismiss={dismissPendingInsert}
@@ -798,25 +877,39 @@ function GlobalModal() {
798
877
  }
799
878
 
800
879
  function EditorToolbar({
880
+ buttonState,
801
881
  localChangesExist,
802
882
  isPublishing,
803
883
  publishFeedback,
884
+ onSave,
804
885
  onPublish,
886
+ onSaveAndPublish,
805
887
  onDiscardClick,
806
888
  onSettingsClick,
807
889
  onMediaClick,
808
890
  processingItems,
891
+ buildState,
892
+ buildDeployUrl,
893
+ buildVisible,
894
+ onBuildDismiss,
809
895
  }: {
896
+ buttonState: "synced" | "publish" | "saveAndPublish";
810
897
  localChangesExist: boolean;
811
898
  isPublishing: boolean;
812
899
  publishFeedback: string | null;
900
+ onSave: () => void;
813
901
  onPublish: () => void;
902
+ onSaveAndPublish: () => void;
814
903
  onDiscardClick: () => void;
815
904
  onSettingsClick: () => void;
816
905
  onMediaClick: () => void;
817
906
  processingItems: QueueItem[];
907
+ buildState: "idle" | "building" | "ready" | "error";
908
+ buildDeployUrl: string | null;
909
+ buildVisible: boolean;
910
+ onBuildDismiss: () => void;
818
911
  }) {
819
- const { isEditMode, toggleEditMode } = useEditorContext();
912
+ const { isEditMode, viewBranch, setViewBranch, toggleEditMode } = useEditorContext();
820
913
 
821
914
  return (
822
915
  <>
@@ -826,23 +919,40 @@ function EditorToolbar({
826
919
  {publishFeedback && (
827
920
  <span className={cn(
828
921
  "text-xs font-medium",
829
- publishFeedback === "Published" ? "text-green-600" : "text-red-600",
922
+ publishFeedback === "Published" || publishFeedback === "Saved"
923
+ ? "text-green-600"
924
+ : "text-red-600",
830
925
  )}>
831
926
  {publishFeedback}
832
927
  </span>
833
928
  )}
834
- {localChangesExist && (
835
- <Button
836
- type="button"
837
- variant="primary"
929
+ {buttonState === "saveAndPublish" && (
930
+ <SplitButton
931
+ label="Save & Publish"
932
+ onClick={onSaveAndPublish}
933
+ isLoading={isPublishing}
934
+ loadingLabel="Publishing..."
935
+ options={[{ label: "Save", onClick: onSave }]}
936
+ />
937
+ )}
938
+ {buttonState === "publish" && (
939
+ <SplitButton
940
+ label="Publish"
838
941
  onClick={onPublish}
839
942
  isLoading={isPublishing}
840
943
  loadingLabel="Publishing..."
841
- >
842
- Publish
843
- </Button>
944
+ options={[]}
945
+ />
946
+ )}
947
+ {buttonState === "synced" && (
948
+ <SplitButton
949
+ label="Everything up-to-date"
950
+ onClick={() => {}}
951
+ disabled
952
+ options={[]}
953
+ />
844
954
  )}
845
- {localChangesExist && (
955
+ {localChangesExist && !isPublishing && (
846
956
  <Button
847
957
  type="button"
848
958
  variant="destructive"
@@ -852,6 +962,12 @@ function EditorToolbar({
852
962
  Discard Changes
853
963
  </Button>
854
964
  )}
965
+ <BuildStatusIndicator
966
+ state={buildState}
967
+ deployUrl={buildDeployUrl}
968
+ visible={buildVisible}
969
+ onDismiss={onBuildDismiss}
970
+ />
855
971
  </div>
856
972
  <div className="flex items-center gap-2">
857
973
  <ProcessingIndicator items={processingItems} />
@@ -873,6 +989,18 @@ function EditorToolbar({
873
989
  </div>
874
990
  </div>
875
991
  )}
992
+ {!isEditMode && (
993
+ <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">
994
+ <SegmentedControl
995
+ options={[
996
+ { value: "saved", label: "Draft" },
997
+ { value: "live", label: "Live" },
998
+ ]}
999
+ value={viewBranch}
1000
+ onChange={(v) => setViewBranch(v as "saved" | "live")}
1001
+ />
1002
+ </div>
1003
+ )}
876
1004
  <button
877
1005
  onClick={toggleEditMode}
878
1006
  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"
@@ -1,4 +1,6 @@
1
1
  export { useActiveHeadings } from "./useActiveHeadings";
2
+ export { useBuildStatus } from "./useBuildStatus";
3
+ export { useContentLifecycle } from "./useContentLifecycle";
2
4
  export { useEditorPersistence } from "./useEditorPersistence";
3
5
  export { useEditorPublish } from "./useEditorPublish";
4
6
  export { useFocusTrap } from "./useFocusTrap";