@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,139 @@
1
+ import { useState, useCallback, useEffect, useRef } from "react";
2
+
3
+ type BuildState = "idle" | "building" | "ready" | "error";
4
+
5
+ interface BuildStatusResponse {
6
+ state: "building" | "ready" | "error";
7
+ deployUrl: string;
8
+ commitSha: string | null;
9
+ updatedAt: string;
10
+ }
11
+
12
+ interface BuildStatusResult {
13
+ state: BuildState;
14
+ deployUrl: string | null;
15
+ visible: boolean;
16
+ dismiss: () => void;
17
+ startTracking: () => void;
18
+ }
19
+
20
+ const POLL_INTERVAL = 5000;
21
+ const AUTO_DISMISS_DELAY = 10000;
22
+
23
+ export function useBuildStatus(): BuildStatusResult {
24
+ const [state, setState] = useState<BuildState>("idle");
25
+ const [deployUrl, setDeployUrl] = useState<string | null>(null);
26
+ const [dismissed, setDismissed] = useState(false);
27
+ const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
28
+ const dismissRef = useRef<ReturnType<typeof setTimeout> | null>(null);
29
+ const isPolling = useRef(false);
30
+
31
+ const stopPolling = useCallback(() => {
32
+ if (pollRef.current) {
33
+ clearInterval(pollRef.current);
34
+ pollRef.current = null;
35
+ }
36
+ isPolling.current = false;
37
+ }, []);
38
+
39
+ const fetchStatus = useCallback(async (): Promise<BuildStatusResponse | null> => {
40
+ try {
41
+ const res = await fetch("/api/build-status");
42
+ if (!res.ok) return null;
43
+ const data = await res.json();
44
+ const validStates = ["building", "ready", "error"];
45
+ if (!data || !validStates.includes(data.state)) return null;
46
+ return data as BuildStatusResponse;
47
+ } catch {
48
+ return null;
49
+ }
50
+ }, []);
51
+
52
+ const handleStatusUpdate = useCallback(
53
+ (data: BuildStatusResponse | null, isInitialLoad: boolean) => {
54
+ if (!data) {
55
+ if (isInitialLoad) {
56
+ setState("idle");
57
+ }
58
+ return;
59
+ }
60
+
61
+ if (isInitialLoad && data.state === "ready") {
62
+ setState("idle");
63
+ return;
64
+ }
65
+
66
+ setState(data.state);
67
+ setDeployUrl(data.deployUrl);
68
+ setDismissed(false);
69
+
70
+ if (data.state === "ready" || data.state === "error") {
71
+ stopPolling();
72
+
73
+ if (data.state === "ready") {
74
+ dismissRef.current = setTimeout(() => {
75
+ setDismissed(true);
76
+ }, AUTO_DISMISS_DELAY);
77
+ }
78
+ }
79
+ },
80
+ [stopPolling],
81
+ );
82
+
83
+ const startPolling = useCallback(() => {
84
+ if (isPolling.current) return;
85
+ isPolling.current = true;
86
+
87
+ pollRef.current = setInterval(async () => {
88
+ const data = await fetchStatus();
89
+ handleStatusUpdate(data, false);
90
+ }, POLL_INTERVAL);
91
+ }, [fetchStatus, handleStatusUpdate]);
92
+
93
+ // Initial fetch on mount
94
+ useEffect(() => {
95
+ let cancelled = false;
96
+
97
+ async function check() {
98
+ const data = await fetchStatus();
99
+ if (cancelled) return;
100
+ handleStatusUpdate(data, true);
101
+
102
+ if (data && data.state === "building") {
103
+ startPolling();
104
+ }
105
+ }
106
+
107
+ check();
108
+
109
+ return () => {
110
+ cancelled = true;
111
+ stopPolling();
112
+ if (dismissRef.current) clearTimeout(dismissRef.current);
113
+ };
114
+ }, [fetchStatus, handleStatusUpdate, startPolling, stopPolling]);
115
+
116
+ const dismiss = useCallback(() => {
117
+ setDismissed(true);
118
+ stopPolling();
119
+ if (dismissRef.current) {
120
+ clearTimeout(dismissRef.current);
121
+ dismissRef.current = null;
122
+ }
123
+ }, [stopPolling]);
124
+
125
+ const startTracking = useCallback(() => {
126
+ setState("building");
127
+ setDeployUrl(null);
128
+ setDismissed(false);
129
+ if (dismissRef.current) {
130
+ clearTimeout(dismissRef.current);
131
+ dismissRef.current = null;
132
+ }
133
+ startPolling();
134
+ }, [startPolling]);
135
+
136
+ const visible = state !== "idle" && !dismissed;
137
+
138
+ return { state, deployUrl, visible, dismiss, startTracking };
139
+ }
@@ -0,0 +1,34 @@
1
+ import { useMemo } from "react";
2
+
3
+ type ButtonState = "synced" | "publish" | "saveAndPublish";
4
+
5
+ interface ContentLifecycleInput {
6
+ savedSha: string | null;
7
+ mainSha: string | null;
8
+ hasLocalChanges: boolean;
9
+ }
10
+
11
+ interface ContentLifecycleResult {
12
+ buttonState: ButtonState;
13
+ hasSavedChanges: boolean;
14
+ }
15
+
16
+ export function useContentLifecycle({
17
+ savedSha,
18
+ mainSha,
19
+ hasLocalChanges,
20
+ }: ContentLifecycleInput): ContentLifecycleResult {
21
+ return useMemo(() => {
22
+ const hasSavedChanges = savedSha !== null && savedSha !== mainSha;
23
+
24
+ if (hasLocalChanges) {
25
+ return { buttonState: "saveAndPublish" as const, hasSavedChanges };
26
+ }
27
+
28
+ if (hasSavedChanges) {
29
+ return { buttonState: "publish" as const, hasSavedChanges };
30
+ }
31
+
32
+ return { buttonState: "synced" as const, hasSavedChanges: false };
33
+ }, [savedSha, mainSha, hasLocalChanges]);
34
+ }
@@ -24,11 +24,21 @@ interface PublishDeps {
24
24
  siteIndexRef: React.RefObject<SiteIndex>;
25
25
  siteConfig: SiteConfig | null;
26
26
  sections: LoadedSection[];
27
+ deletedSectionIds?: string[];
27
28
  onSuccess: () => void;
28
29
  mediaManifest: MediaManifest;
29
30
  pendingMediaItems: MediaItem[];
30
31
  pendingMediaDeletions: string[];
31
32
  onMediaPublished: (publishedItems: MediaItem[], publishedDeletions: string[]) => void;
33
+ onShasUpdated: (savedSha: string | null, mainSha: string | null) => void;
34
+ onPublishComplete?: () => void;
35
+ }
36
+
37
+ interface GatheredMedia {
38
+ mediaUploads: { item: MediaItem; blobs: { path: string; base64: string }[] }[];
39
+ blobUrlsToRevoke: string[];
40
+ updatedManifest: MediaManifest | undefined;
41
+ hasMediaChanges: boolean;
32
42
  }
33
43
 
34
44
  export function useEditorPublish({
@@ -39,11 +49,14 @@ export function useEditorPublish({
39
49
  siteIndexRef,
40
50
  siteConfig,
41
51
  sections,
52
+ deletedSectionIds,
42
53
  onSuccess,
43
54
  mediaManifest,
44
55
  pendingMediaItems,
45
56
  pendingMediaDeletions,
46
57
  onMediaPublished,
58
+ onShasUpdated,
59
+ onPublishComplete,
47
60
  }: PublishDeps) {
48
61
  const [isPublishing, setIsPublishing] = useState(false);
49
62
  const [publishFeedback, setPublishFeedback] = useState<string | null>(null);
@@ -55,7 +68,72 @@ export function useEditorPublish({
55
68
  };
56
69
  }, []);
57
70
 
58
- const handlePublish = useCallback(async () => {
71
+ const showFeedback = useCallback((message: string, duration: number) => {
72
+ setPublishFeedback(message);
73
+ if (feedbackTimerRef.current) clearTimeout(feedbackTimerRef.current);
74
+ feedbackTimerRef.current = setTimeout(() => setPublishFeedback(null), duration);
75
+ }, []);
76
+
77
+ async function gatherMediaPayload(): Promise<GatheredMedia> {
78
+ const hasMediaChanges = pendingMediaItems.length > 0 || pendingMediaDeletions.length > 0;
79
+ const mediaUploads: { item: MediaItem; blobs: { path: string; base64: string }[] }[] = [];
80
+ const blobUrlsToRevoke: string[] = [];
81
+
82
+ for (const item of pendingMediaItems) {
83
+ const localUrls = await getPendingMediaLocalUrls(item.id);
84
+ if (!localUrls) continue;
85
+
86
+ for (const url of Object.values(localUrls)) {
87
+ blobUrlsToRevoke.push(url);
88
+ }
89
+
90
+ const blobs: { path: string; base64: string }[] = [];
91
+ const failedBlobFetches: string[] = [];
92
+ const mimeExt: Record<string, string> = {
93
+ "image/gif": "gif", "image/apng": "apng", "video/mp4": "mp4", "video/webm": "webm",
94
+ };
95
+ for (const [key, url] of Object.entries(localUrls)) {
96
+ if (key === "primary" && item.kind === "image") continue;
97
+ try {
98
+ const resp = await fetch(url);
99
+ const blob = await resp.blob();
100
+ const base64 = await blobToBase64(blob);
101
+ if (key === "primary") {
102
+ const ext = mimeExt[item.mimeType] ?? "bin";
103
+ blobs.push({ path: `assets/images/${item.folder}/original.${ext}`, base64 });
104
+ } else {
105
+ blobs.push({ path: `assets/images/${item.folder}/${key}.webp`, base64 });
106
+ }
107
+ } catch {
108
+ failedBlobFetches.push(`${item.id}/${key}`);
109
+ }
110
+ }
111
+
112
+ if (failedBlobFetches.length > 0) {
113
+ throw new Error(`Media upload failed: could not read blob data for ${failedBlobFetches.join(", ")}`);
114
+ }
115
+
116
+ if (blobs.length > 0) {
117
+ mediaUploads.push({ item, blobs });
118
+ }
119
+ }
120
+
121
+ let updatedManifest: MediaManifest | undefined;
122
+ if (hasMediaChanges) {
123
+ const images = { ...mediaManifest.images };
124
+ for (const upload of mediaUploads) {
125
+ images[upload.item.id] = upload.item;
126
+ }
127
+ for (const id of pendingMediaDeletions) {
128
+ delete images[id];
129
+ }
130
+ updatedManifest = { images };
131
+ }
132
+
133
+ return { mediaUploads, blobUrlsToRevoke, updatedManifest, hasMediaChanges };
134
+ }
135
+
136
+ const handleSave = useCallback(async () => {
59
137
  if (!siteConfig) return;
60
138
 
61
139
  setIsPublishing(true);
@@ -67,80 +145,43 @@ export function useEditorPublish({
67
145
 
68
146
  const hasChanges = await hasLocalChanges();
69
147
  const hasMediaChanges = pendingMediaItems.length > 0 || pendingMediaDeletions.length > 0;
70
- if (!hasChanges && !isConfigDirty() && !hasMediaChanges) {
148
+ const hasDeletedSections = (deletedSectionIds?.length ?? 0) > 0;
149
+ if (!hasChanges && !isConfigDirty() && !hasMediaChanges && !hasDeletedSections) {
71
150
  setIsPublishing(false);
72
151
  return;
73
152
  }
74
153
 
75
154
  const dirty = await getDirtySections();
155
+ const { mediaUploads, blobUrlsToRevoke, updatedManifest, hasMediaChanges: mediaChanged } = await gatherMediaPayload();
76
156
 
77
- // Gather pending media for the commit
78
- const mediaUploads: { item: MediaItem; blobs: { path: string; base64: string }[] }[] = [];
79
- const blobUrlsToRevoke: string[] = [];
80
-
81
- for (const item of pendingMediaItems) {
82
- const localUrls = await getPendingMediaLocalUrls(item.id);
83
- if (!localUrls) continue;
84
-
85
- for (const url of Object.values(localUrls)) {
86
- blobUrlsToRevoke.push(url);
157
+ // Build a filtered siteIndex if there are deletions
158
+ let siteIndex = siteIndexRef.current;
159
+ if (deletedSectionIds?.length) {
160
+ const deleteSet = new Set(deletedSectionIds);
161
+ const filteredSections = { ...siteIndex.sections };
162
+ for (const id of deletedSectionIds) {
163
+ delete filteredSections[id];
87
164
  }
88
-
89
- const blobs: { path: string; base64: string }[] = [];
90
- const failedBlobFetches: string[] = [];
91
- const mimeExt: Record<string, string> = {
92
- "image/gif": "gif", "image/apng": "apng", "video/mp4": "mp4", "video/webm": "webm",
165
+ siteIndex = {
166
+ ...siteIndex,
167
+ order: siteIndex.order.filter((id) => !deleteSet.has(id)),
168
+ sections: filteredSections,
93
169
  };
94
- for (const [key, url] of Object.entries(localUrls)) {
95
- if (key === "primary" && item.kind === "image") continue;
96
- try {
97
- const resp = await fetch(url);
98
- const blob = await resp.blob();
99
- const base64 = await blobToBase64(blob);
100
- if (key === "primary") {
101
- const ext = mimeExt[item.mimeType] ?? "bin";
102
- blobs.push({ path: `assets/images/${item.folder}/original.${ext}`, base64 });
103
- } else {
104
- blobs.push({ path: `assets/images/${item.folder}/${key}.webp`, base64 });
105
- }
106
- } catch {
107
- failedBlobFetches.push(`${item.id}/${key}`);
108
- }
109
- }
110
-
111
- if (failedBlobFetches.length > 0) {
112
- throw new Error(`Media upload failed: could not read blob data for ${failedBlobFetches.join(", ")}`);
113
- }
114
-
115
- if (blobs.length > 0) {
116
- mediaUploads.push({ item, blobs });
117
- }
118
- }
119
-
120
- // Build updated manifest
121
- let updatedManifest: MediaManifest | undefined;
122
- if (hasMediaChanges) {
123
- const images = { ...mediaManifest.images };
124
- for (const upload of mediaUploads) {
125
- images[upload.item.id] = upload.item;
126
- }
127
- for (const id of pendingMediaDeletions) {
128
- delete images[id];
129
- }
130
- updatedManifest = { images };
131
170
  }
132
171
 
133
172
  const response = await fetch("/api/save", {
134
173
  method: "POST",
135
174
  headers: { "Content-Type": "application/json" },
136
175
  body: JSON.stringify({
176
+ targetBranch: "saved",
137
177
  sections: dirty.map(({ sectionId, content }) => ({
138
178
  id: sectionId,
139
179
  content,
140
180
  })),
141
- siteIndex: siteIndexRef.current,
181
+ siteIndex,
182
+ ...(deletedSectionIds?.length ? { deletedSectionIds } : {}),
142
183
  ...(isConfigDirty() ? { siteConfig } : {}),
143
- ...(hasMediaChanges ? {
184
+ ...(mediaChanged ? {
144
185
  media: {
145
186
  uploads: mediaUploads.map(({ item, blobs }) => ({ item, blobs })),
146
187
  deletions: pendingMediaDeletions.map((id) => ({
@@ -155,33 +196,160 @@ export function useEditorPublish({
155
196
 
156
197
  if (!response.ok) {
157
198
  const errorBody = await response.json().catch(() => ({}));
158
- throw new Error(errorBody.error || "Publish failed");
199
+ throw new Error(errorBody.error || "Save failed");
159
200
  }
160
201
 
161
202
  const { sha } = await response.json();
162
203
 
163
204
  await discardLocalChanges();
164
205
  await clearPendingMedia();
165
- await cacheContent(sha, sections, siteIndexRef.current, siteConfig);
166
206
  clearConfigDirty();
167
207
  onSuccess();
168
208
  onMediaPublished(pendingMediaItems, pendingMediaDeletions);
209
+ onShasUpdated(sha, null);
169
210
  for (const url of blobUrlsToRevoke) {
170
211
  URL.revokeObjectURL(url);
171
212
  }
172
213
 
173
- setPublishFeedback("Published");
174
- if (feedbackTimerRef.current) clearTimeout(feedbackTimerRef.current);
175
- feedbackTimerRef.current = setTimeout(() => setPublishFeedback(null), 3000);
214
+ showFeedback("Saved", 3000);
215
+ } catch (error) {
216
+ console.error("Save failed:", error);
217
+ showFeedback("Save failed", 5000);
218
+ } finally {
219
+ setIsPublishing(false);
220
+ }
221
+ }, [flushNow, cancelPendingFlush, isConfigDirty, clearConfigDirty, siteIndexRef, siteConfig, sections, deletedSectionIds, onSuccess, mediaManifest, pendingMediaItems, pendingMediaDeletions, onMediaPublished, onShasUpdated, showFeedback]);
222
+
223
+ const handlePublish = useCallback(async () => {
224
+ setIsPublishing(true);
225
+ setPublishFeedback(null);
226
+
227
+ try {
228
+ const response = await fetch("/api/publish", {
229
+ method: "POST",
230
+ headers: { "Content-Type": "application/json" },
231
+ });
232
+
233
+ if (!response.ok) {
234
+ const errorBody = await response.json().catch(() => ({}));
235
+ throw new Error(errorBody.error || "Publish failed");
236
+ }
237
+
238
+ const { sha } = await response.json();
239
+
240
+ onShasUpdated(null, sha);
241
+ showFeedback("Published", 3000);
242
+ onPublishComplete?.();
176
243
  } catch (error) {
177
244
  console.error("Publish failed:", error);
178
- setPublishFeedback("Publish failed");
179
- if (feedbackTimerRef.current) clearTimeout(feedbackTimerRef.current);
180
- feedbackTimerRef.current = setTimeout(() => setPublishFeedback(null), 5000);
245
+ showFeedback("Publish failed", 5000);
246
+ } finally {
247
+ setIsPublishing(false);
248
+ }
249
+ }, [onShasUpdated, showFeedback, onPublishComplete]);
250
+
251
+ const handleSaveAndPublish = useCallback(async () => {
252
+ if (!siteConfig) return;
253
+
254
+ setIsPublishing(true);
255
+ setPublishFeedback(null);
256
+
257
+ try {
258
+ cancelPendingFlush();
259
+ await flushNow();
260
+
261
+ const hasChanges = await hasLocalChanges();
262
+ const hasMediaChanges = pendingMediaItems.length > 0 || pendingMediaDeletions.length > 0;
263
+ const hasDeletedSections = (deletedSectionIds?.length ?? 0) > 0;
264
+ const hasLocalEdits = hasChanges || isConfigDirty() || hasMediaChanges || hasDeletedSections;
265
+
266
+ let blobUrlsToRevoke: string[] = [];
267
+
268
+ if (hasLocalEdits) {
269
+ const dirty = await getDirtySections();
270
+ const gathered = await gatherMediaPayload();
271
+ blobUrlsToRevoke = gathered.blobUrlsToRevoke;
272
+
273
+ // Build a filtered siteIndex if there are deletions
274
+ let siteIndex = siteIndexRef.current;
275
+ if (deletedSectionIds?.length) {
276
+ const deleteSet = new Set(deletedSectionIds);
277
+ const filteredSections = { ...siteIndex.sections };
278
+ for (const id of deletedSectionIds) {
279
+ delete filteredSections[id];
280
+ }
281
+ siteIndex = {
282
+ ...siteIndex,
283
+ order: siteIndex.order.filter((id) => !deleteSet.has(id)),
284
+ sections: filteredSections,
285
+ };
286
+ }
287
+
288
+ const saveResponse = await fetch("/api/save", {
289
+ method: "POST",
290
+ headers: { "Content-Type": "application/json" },
291
+ body: JSON.stringify({
292
+ targetBranch: "saved",
293
+ sections: dirty.map(({ sectionId, content }) => ({
294
+ id: sectionId,
295
+ content,
296
+ })),
297
+ siteIndex,
298
+ ...(deletedSectionIds?.length ? { deletedSectionIds } : {}),
299
+ ...(isConfigDirty() ? { siteConfig } : {}),
300
+ ...(gathered.hasMediaChanges ? {
301
+ media: {
302
+ uploads: gathered.mediaUploads.map(({ item, blobs }) => ({ item, blobs })),
303
+ deletions: pendingMediaDeletions.map((id) => ({
304
+ id,
305
+ folder: mediaManifest.images[id]?.folder,
306
+ })).filter((d) => d.folder),
307
+ manifest: gathered.updatedManifest,
308
+ },
309
+ } : {}),
310
+ }),
311
+ });
312
+
313
+ if (!saveResponse.ok) {
314
+ const errorBody = await saveResponse.json().catch(() => ({}));
315
+ throw new Error(errorBody.error || "Save failed");
316
+ }
317
+ }
318
+
319
+ const publishResponse = await fetch("/api/publish", {
320
+ method: "POST",
321
+ headers: { "Content-Type": "application/json" },
322
+ });
323
+
324
+ if (!publishResponse.ok) {
325
+ const errorBody = await publishResponse.json().catch(() => ({}));
326
+ throw new Error(errorBody.error || "Publish failed");
327
+ }
328
+
329
+ const { sha } = await publishResponse.json();
330
+
331
+ if (hasLocalEdits) {
332
+ await discardLocalChanges();
333
+ await clearPendingMedia();
334
+ await cacheContent(sha, sections, siteIndexRef.current, siteConfig);
335
+ clearConfigDirty();
336
+ onSuccess();
337
+ onMediaPublished(pendingMediaItems, pendingMediaDeletions);
338
+ for (const url of blobUrlsToRevoke) {
339
+ URL.revokeObjectURL(url);
340
+ }
341
+ }
342
+
343
+ onShasUpdated(null, sha);
344
+ showFeedback("Published", 3000);
345
+ onPublishComplete?.();
346
+ } catch (error) {
347
+ console.error("Publish failed:", error);
348
+ showFeedback("Publish failed", 5000);
181
349
  } finally {
182
350
  setIsPublishing(false);
183
351
  }
184
- }, [flushNow, cancelPendingFlush, isConfigDirty, clearConfigDirty, siteIndexRef, siteConfig, sections, onSuccess, mediaManifest, pendingMediaItems, pendingMediaDeletions, onMediaPublished]);
352
+ }, [flushNow, cancelPendingFlush, isConfigDirty, clearConfigDirty, siteIndexRef, siteConfig, sections, deletedSectionIds, onSuccess, mediaManifest, pendingMediaItems, pendingMediaDeletions, onMediaPublished, onShasUpdated, showFeedback, onPublishComplete]);
185
353
 
186
- return { isPublishing, publishFeedback, handlePublish };
354
+ return { isPublishing, publishFeedback, handleSave, handlePublish, handleSaveAndPublish };
187
355
  }
package/src/lib/dexie.ts CHANGED
@@ -15,12 +15,14 @@ interface SiteIndexRow {
15
15
  key: string;
16
16
  order: string[];
17
17
  sections: Record<string, SectionMeta>;
18
+ deletedSections: string[];
18
19
  updatedAt: string;
19
20
  }
20
21
 
21
22
  interface MetaRow {
22
23
  key: string;
23
24
  lastSavedSha: string | null;
25
+ mainSha: string | null;
24
26
  lastSavedAt: string | null;
25
27
  siteId: string;
26
28
  }
@@ -98,6 +100,16 @@ class EditorDatabase extends Dexie {
98
100
  pendingMedia: "id",
99
101
  pendingMediaDeletions: "id",
100
102
  });
103
+ this.version(5).stores({
104
+ sections: "sectionId",
105
+ siteIndex: "key",
106
+ meta: "key",
107
+ siteConfig: "key",
108
+ contentCache: "key",
109
+ mediaManifest: "key",
110
+ pendingMedia: "id",
111
+ pendingMediaDeletions: "id",
112
+ });
101
113
  }
102
114
  }
103
115
 
@@ -132,6 +144,7 @@ export async function restoreLocalChanges(): Promise<{
132
144
  sections: Record<string, SectionContent>;
133
145
  siteIndex?: SiteIndex;
134
146
  siteConfig?: Record<string, unknown>;
147
+ deletedSections: string[];
135
148
  }> {
136
149
  const sectionRows = await getDb().sections.toArray();
137
150
  const sections: Record<string, SectionContent> = {};
@@ -149,10 +162,11 @@ export async function restoreLocalChanges(): Promise<{
149
162
  sections,
150
163
  siteIndex: { siteId, order: indexRow.order, sections: indexRow.sections },
151
164
  siteConfig: configRow?.config,
165
+ deletedSections: indexRow.deletedSections ?? [],
152
166
  };
153
167
  }
154
168
 
155
- return { sections, siteIndex: undefined, siteConfig: configRow?.config };
169
+ return { sections, siteIndex: undefined, siteConfig: configRow?.config, deletedSections: [] };
156
170
  }
157
171
 
158
172
  export async function discardLocalChanges(): Promise<void> {
@@ -168,7 +182,7 @@ export async function discardLocalChanges(): Promise<void> {
168
182
  });
169
183
  }
170
184
 
171
- export async function persistSiteIndex(index: SiteIndex): Promise<void> {
185
+ export async function persistSiteIndex(index: SiteIndex, deletedSections: string[] = []): Promise<void> {
172
186
  const now = new Date().toISOString();
173
187
  const database = getDb();
174
188
  await database.transaction("rw", [database.siteIndex, database.meta], async () => {
@@ -176,11 +190,13 @@ export async function persistSiteIndex(index: SiteIndex): Promise<void> {
176
190
  key: "current",
177
191
  order: index.order,
178
192
  sections: index.sections,
193
+ deletedSections,
179
194
  updatedAt: now,
180
195
  });
181
196
  await database.meta.put({
182
197
  key: "current",
183
198
  lastSavedSha: null,
199
+ mainSha: null,
184
200
  lastSavedAt: null,
185
201
  siteId: index.siteId,
186
202
  });
@@ -246,11 +262,13 @@ export async function persistAll(
246
262
  key: "current",
247
263
  order: siteIndex.order,
248
264
  sections: siteIndex.sections,
265
+ deletedSections: [],
249
266
  updatedAt: now,
250
267
  });
251
268
  await database.meta.put({
252
269
  key: "current",
253
270
  lastSavedSha: null,
271
+ mainSha: null,
254
272
  lastSavedAt: null,
255
273
  siteId: siteIndex.siteId,
256
274
  });
@@ -267,6 +285,29 @@ export async function persistAll(
267
285
  );
268
286
  }
269
287
 
288
+ export async function updateBranchShas(savedSha: string | null, mainSha: string | null): Promise<void> {
289
+ const database = getDb();
290
+ const existing = await database.meta.get("current");
291
+ if (!existing) return;
292
+ await database.meta.put({
293
+ ...existing,
294
+ lastSavedSha: savedSha,
295
+ mainSha,
296
+ lastSavedAt: new Date().toISOString(),
297
+ });
298
+ }
299
+
300
+ export async function getBranchShas(): Promise<{ savedSha: string | null; mainSha: string | null } | null> {
301
+ const row = await getDb().meta.get("current");
302
+ if (!row) return null;
303
+ return { savedSha: row.lastSavedSha, mainSha: row.mainSha ?? null };
304
+ }
305
+
306
+ export async function getDeletedSections(): Promise<string[]> {
307
+ const row = await getDb().siteIndex.get("current");
308
+ return row?.deletedSections ?? [];
309
+ }
310
+
270
311
  export async function cacheContent(
271
312
  sha: string,
272
313
  sections: LoadedSection[],
@@ -73,11 +73,16 @@ export interface WrapperProps {
73
73
  audiences: Audience[];
74
74
  access: string[];
75
75
  onAccessChange?: (access: string[]) => void;
76
- onStatusChange?: (status: "draft" | "published" | "archived") => void;
76
+ onStatusChange?: (status: "draft" | "live" | "archived") => void;
77
77
  onSectionChange?: (options: Record<string, unknown>) => void;
78
78
  onReorder?: (fromIndex: number, toIndex: number) => void;
79
79
  onRequestInsert?: (index: number) => void;
80
80
  onDelete?: () => void;
81
+ isDeleted?: boolean;
82
+ onUndoDelete?: () => void;
83
+ mainStatus?: string | null;
84
+ contentDiffersFromMain?: boolean;
85
+ isLocalOnly?: boolean;
81
86
  children: React.ReactNode;
82
87
  }
83
88