@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.
- package/dist/{chunk-6SK5BLG3.js → chunk-46QI4FDZ.js} +1 -1
- package/dist/{chunk-XQXZHDNR.js → chunk-EAEX6DS7.js} +4 -1
- package/dist/{chunk-32H6Q6CX.js → chunk-P24YUT3O.js} +1 -1
- package/dist/components/editor/AudienceIndicator.d.ts +9 -0
- package/dist/components/editor/AudienceIndicator.d.ts.map +1 -0
- package/dist/components/editor/IndicatorPill.d.ts +18 -0
- package/dist/components/editor/IndicatorPill.d.ts.map +1 -0
- package/dist/components/editor/SectionWrapper.d.ts +1 -1
- package/dist/components/editor/SectionWrapper.d.ts.map +1 -1
- package/dist/components/editor/StatusIndicator.d.ts +29 -0
- package/dist/components/editor/StatusIndicator.d.ts.map +1 -0
- package/dist/components/editor/index.d.ts +3 -1
- package/dist/components/editor/index.d.ts.map +1 -1
- package/dist/components/shared/Popover.d.ts +1 -1
- package/dist/components/shared/Popover.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/BuildStatusIndicator.d.ts +9 -0
- package/dist/components/shell/BuildStatusIndicator.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 +2 -0
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/useBuildStatus.d.ts +11 -0
- package/dist/hooks/useBuildStatus.d.ts.map +1 -0
- package/dist/hooks/useContentLifecycle.d.ts +13 -0
- package/dist/hooks/useContentLifecycle.d.ts.map +1 -0
- package/dist/hooks/useEditorPublish.d.ts +6 -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 +8 -6
- package/dist/schemas/site-config.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/editor/{AudiencePicker.tsx → AudienceIndicator.tsx} +25 -49
- package/src/components/editor/IndicatorPill.tsx +119 -0
- package/src/components/editor/SectionWrapper.tsx +59 -13
- package/src/components/editor/StatusIndicator.tsx +148 -0
- package/src/components/editor/index.ts +3 -1
- package/src/components/sections/SectionLayout.tsx +1 -1
- package/src/components/shared/Navigation.tsx +3 -3
- package/src/components/shared/Popover.tsx +26 -4
- package/src/components/shared/PopoverItem.tsx +1 -1
- package/src/components/shared/SegmentedControl.tsx +43 -0
- package/src/components/shared/SplitButton.tsx +97 -0
- package/src/components/shell/BuildStatusIndicator.tsx +67 -0
- package/src/components/shell/EditorContext.tsx +5 -1
- package/src/components/shell/EditorShell.tsx +180 -52
- package/src/hooks/index.ts +2 -0
- package/src/hooks/useBuildStatus.ts +139 -0
- package/src/hooks/useContentLifecycle.ts +34 -0
- package/src/hooks/useEditorPublish.ts +234 -66
- package/src/lib/dexie.ts +43 -2
- package/src/lib/registry.ts +6 -1
- package/src/schemas/site-config.ts +5 -1
- package/dist/components/editor/AudiencePicker.d.ts +0 -9
- package/dist/components/editor/AudiencePicker.d.ts.map +0 -1
- package/dist/components/editor/StatusBadge.d.ts +0 -7
- package/dist/components/editor/StatusBadge.d.ts.map +0 -1
- package/dist/components/editor/StatusPicker.d.ts +0 -9
- package/dist/components/editor/StatusPicker.d.ts.map +0 -1
- package/src/components/editor/StatusBadge.tsx +0 -30
- 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
|
|
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
|
-
|
|
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
|
-
//
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
const
|
|
83
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
|
181
|
+
siteIndex,
|
|
182
|
+
...(deletedSectionIds?.length ? { deletedSectionIds } : {}),
|
|
142
183
|
...(isConfigDirty() ? { siteConfig } : {}),
|
|
143
|
-
...(
|
|
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 || "
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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[],
|
package/src/lib/registry.ts
CHANGED
|
@@ -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" | "
|
|
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
|
|