@drawnagency/primitives 0.1.25 → 0.1.27

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 (118) hide show
  1. package/dist/{chunk-UMSFICAC.js → chunk-DKOUFIP6.js} +0 -1
  2. package/dist/{chunk-KX7NRYQD.js → chunk-HXXZBTPF.js} +12 -5
  3. package/dist/{chunk-IP6ODLXX.js → chunk-JHSYLVKI.js} +19 -84
  4. package/dist/{chunk-P24YUT3O.js → chunk-MNK7XA6S.js} +1 -1
  5. package/dist/{chunk-EAEX6DS7.js → chunk-V43WVSVS.js} +3 -2
  6. package/dist/components/editor/SectionOrderingModal.d.ts +10 -0
  7. package/dist/components/editor/SectionOrderingModal.d.ts.map +1 -0
  8. package/dist/components/editor/SectionWrapper.d.ts.map +1 -1
  9. package/dist/components/primitives/EditableRichText.d.ts.map +1 -1
  10. package/dist/components/sections/Button/index.d.ts.map +1 -1
  11. package/dist/components/sections/Colors/index.d.ts.map +1 -1
  12. package/dist/components/sections/DoDontMediaGrid/index.d.ts.map +1 -1
  13. package/dist/components/sections/IconList/index.d.ts.map +1 -1
  14. package/dist/components/sections/LinkHeading/index.d.ts.map +1 -1
  15. package/dist/components/sections/MediaGrid/index.d.ts.map +1 -1
  16. package/dist/components/sections/Prose/index.d.ts.map +1 -1
  17. package/dist/components/sections/SplitContent/index.d.ts.map +1 -1
  18. package/dist/components/sections/SubHeading/index.d.ts.map +1 -1
  19. package/dist/components/sections/SubSubHeading/index.d.ts.map +1 -1
  20. package/dist/components/sections/ViewRenderer.d.ts +0 -1
  21. package/dist/components/sections/ViewRenderer.d.ts.map +1 -1
  22. package/dist/components/sections/register-schemas.d.ts.map +1 -1
  23. package/dist/components/sections/register.d.ts.map +1 -1
  24. package/dist/components/shared/HistoryPopover.d.ts.map +1 -1
  25. package/dist/components/shared/Navigation.d.ts.map +1 -1
  26. package/dist/components/shell/EditorShell.d.ts +0 -1
  27. package/dist/components/shell/EditorShell.d.ts.map +1 -1
  28. package/dist/components/shell/HistoryToolbar.d.ts.map +1 -1
  29. package/dist/components/shell/RestoreModal.d.ts.map +1 -1
  30. package/dist/deploy/index.d.ts +2 -0
  31. package/dist/deploy/index.d.ts.map +1 -0
  32. package/dist/deploy/types.d.ts +12 -0
  33. package/dist/deploy/types.d.ts.map +1 -0
  34. package/dist/hooks/useEditorPublish.d.ts.map +1 -1
  35. package/dist/hooks/useMediaPipeline.d.ts.map +1 -1
  36. package/dist/index.d.ts +2 -0
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +10 -8
  39. package/dist/lib/dexie.d.ts +4 -1
  40. package/dist/lib/dexie.d.ts.map +1 -1
  41. package/dist/lib/dexie.js +319 -0
  42. package/dist/lib/index.js +3 -3
  43. package/dist/lib/nav.d.ts +2 -6
  44. package/dist/lib/nav.d.ts.map +1 -1
  45. package/dist/lib/registry.d.ts +14 -0
  46. package/dist/lib/registry.d.ts.map +1 -1
  47. package/dist/lib/text.d.ts +3 -0
  48. package/dist/lib/text.d.ts.map +1 -0
  49. package/dist/lib/timestamp.d.ts +2 -0
  50. package/dist/lib/timestamp.d.ts.map +1 -1
  51. package/dist/media/index.d.ts +4 -2
  52. package/dist/media/index.d.ts.map +1 -1
  53. package/dist/media/index.js +8 -6
  54. package/dist/media/provider.d.ts +7 -0
  55. package/dist/media/provider.d.ts.map +1 -0
  56. package/dist/media/resolve.d.ts +3 -2
  57. package/dist/media/resolve.d.ts.map +1 -1
  58. package/dist/media/types.d.ts +0 -9
  59. package/dist/media/types.d.ts.map +1 -1
  60. package/dist/schemas/index.js +3 -3
  61. package/dist/schemas/media.d.ts +0 -3
  62. package/dist/schemas/media.d.ts.map +1 -1
  63. package/dist/schemas/site-config.d.ts +1 -3
  64. package/dist/schemas/site-config.d.ts.map +1 -1
  65. package/dist/storage/index.d.ts +2 -0
  66. package/dist/storage/index.d.ts.map +1 -0
  67. package/dist/storage/types.d.ts +21 -0
  68. package/dist/storage/types.d.ts.map +1 -0
  69. package/package.json +5 -1
  70. package/src/components/editor/DragHandle.tsx +1 -1
  71. package/src/components/editor/SectionOrderingModal.tsx +215 -0
  72. package/src/components/editor/SectionWrapper.tsx +3 -1
  73. package/src/components/primitives/EditableRichText.tsx +4 -2
  74. package/src/components/sections/Button/index.tsx +1 -0
  75. package/src/components/sections/Colors/index.tsx +8 -0
  76. package/src/components/sections/DoDontMediaGrid/index.tsx +8 -0
  77. package/src/components/sections/IconList/index.tsx +4 -0
  78. package/src/components/sections/LinkHeading/index.tsx +2 -0
  79. package/src/components/sections/MediaGrid/index.tsx +8 -0
  80. package/src/components/sections/Prose/index.tsx +2 -0
  81. package/src/components/sections/SplitContent/index.tsx +16 -2
  82. package/src/components/sections/SubHeading/index.tsx +2 -0
  83. package/src/components/sections/SubSubHeading/index.tsx +2 -0
  84. package/src/components/sections/ViewRenderer.tsx +3 -1
  85. package/src/components/sections/register-schemas.ts +0 -2
  86. package/src/components/sections/register.ts +0 -2
  87. package/src/components/shared/HistoryPopover.tsx +2 -33
  88. package/src/components/shared/Navigation.tsx +2 -5
  89. package/src/components/shell/EditorShell.tsx +40 -9
  90. package/src/components/shell/HistoryToolbar.tsx +2 -7
  91. package/src/components/shell/RestoreModal.tsx +2 -7
  92. package/src/deploy/index.ts +1 -0
  93. package/src/deploy/types.ts +12 -0
  94. package/src/hooks/useEditorPublish.ts +18 -43
  95. package/src/hooks/useMediaPipeline.ts +41 -11
  96. package/src/hooks/useResolvedMedia.ts +3 -3
  97. package/src/index.ts +2 -0
  98. package/src/lib/dexie.ts +28 -1
  99. package/src/lib/nav.ts +16 -9
  100. package/src/lib/registry.ts +10 -0
  101. package/src/lib/text.ts +8 -0
  102. package/src/lib/timestamp.ts +23 -0
  103. package/src/media/index.ts +13 -4
  104. package/src/media/provider.ts +7 -0
  105. package/src/media/resolve.ts +9 -6
  106. package/src/media/types.ts +0 -9
  107. package/src/schemas/media.ts +0 -1
  108. package/src/schemas/site-config.ts +1 -0
  109. package/src/storage/index.ts +1 -0
  110. package/src/storage/types.ts +23 -0
  111. package/dist/components/sections/SplitContent/SplitContentSettings.d.ts +0 -9
  112. package/dist/components/sections/SplitContent/SplitContentSettings.d.ts.map +0 -1
  113. package/dist/media/github.d.ts +0 -3
  114. package/dist/media/github.d.ts.map +0 -1
  115. package/src/components/sections/SplitContent/SplitContentSettings.d.ts +0 -9
  116. package/src/components/sections/SplitContent/SplitContentSettings.d.ts.map +0 -1
  117. package/src/components/sections/SplitContent/SplitContentSettings.tsx +0 -42
  118. package/src/media/github.ts +0 -72
@@ -16,9 +16,12 @@ import { MediaLibraryModal } from "./MediaLibraryModal";
16
16
  import { MediaLibraryContext } from "./MediaLibraryContext";
17
17
  import { ProcessingIndicator } from "./ProcessingIndicator";
18
18
  import { SectionSkeleton } from "./SectionSkeleton";
19
- import "../sections/register";
19
+ import { ensureSectionsRegistered } from "../sections/register";
20
20
  import { getSection, getAllSections } from "../../lib/registry";
21
+
22
+ ensureSectionsRegistered();
21
23
  import { SectionWrapper } from "../editor/SectionWrapper";
24
+ import { SectionOrderingModal } from "../editor/SectionOrderingModal";
22
25
  import { SectionLayout } from "../sections/SectionLayout";
23
26
  import {
24
27
  initEditorStore,
@@ -30,7 +33,7 @@ import {
30
33
  persistMediaManifest,
31
34
  getMediaManifest,
32
35
  getPendingMediaItems,
33
- getPendingMediaLocalUrls,
36
+ getPendingMediaBlobs,
34
37
  getPendingMediaDeletions,
35
38
  } from "../../lib/dexie";
36
39
  import { useEditorPersistence } from "../../hooks/useEditorPersistence";
@@ -47,7 +50,7 @@ import { SplitButton } from "../shared/SplitButton";
47
50
  import { IconButton } from "../shared/IconButton";
48
51
  import { SegmentedControl } from "../shared/SegmentedControl";
49
52
  import { SettingsIcon } from "../shared/icons";
50
- import { ImageIcon, X } from "lucide-react";
53
+ import { ImageIcon, ListOrderedIcon, X } from "lucide-react";
51
54
  import { ErrorBoundary } from "../shared/ErrorBoundary";
52
55
  import { HistoryToolbar } from "./HistoryToolbar";
53
56
  import { RestoreModal } from "./RestoreModal";
@@ -110,6 +113,7 @@ export default function EditorShell({
110
113
  const [viewSections, setViewSections] = useState<LoadedSection[] | null>(null);
111
114
  const [isLoadingViewContent, setIsLoadingViewContent] = useState(false);
112
115
  const [showRestoreModal, setShowRestoreModal] = useState(false);
116
+ const [showOrderingModal, setShowOrderingModal] = useState(false);
113
117
  const [isRestoring, setIsRestoring] = useState(false);
114
118
 
115
119
  const siteIndexRef = useRef<SiteIndex>({ siteId, order: [], sections: {} });
@@ -149,6 +153,9 @@ export default function EditorShell({
149
153
  pendingMediaItems: mediaPipeline.pendingMediaItems,
150
154
  pendingMediaDeletions: mediaPipeline.pendingDeletions,
151
155
  onMediaPublished: (publishedItems, publishedDeletions) => {
156
+ for (const url of Object.values(mediaPipeline.pendingLocalUrls)) {
157
+ URL.revokeObjectURL(url);
158
+ }
152
159
  mediaPipeline.setPendingMediaItems([]);
153
160
  mediaPipeline.setPendingLocalUrls({});
154
161
  mediaPipeline.setPendingDeletions([]);
@@ -265,16 +272,16 @@ export default function EditorShell({
265
272
  siteIndexRef.current = loadedIndex;
266
273
  applySiteConfigPreview(loadedConfig);
267
274
 
268
- // Load pending media from Dexie
275
+ // Load pending media from Dexie — recreate blob URLs from stored blobs
269
276
  const savedPendingItems = await getPendingMediaItems();
270
277
  if (!cancelled && savedPendingItems.length > 0) {
271
278
  mediaPipeline.setPendingMediaItems(savedPendingItems);
272
279
  const urlMap: Record<string, string> = {};
273
280
  for (const pi of savedPendingItems) {
274
- const urls = await getPendingMediaLocalUrls(pi.id);
275
- if (urls) {
276
- const smallestKey = Object.keys(urls).find((k) => k !== "primary") ?? "primary";
277
- if (urls[smallestKey]) urlMap[pi.id] = urls[smallestKey];
281
+ const blobs = await getPendingMediaBlobs(pi.id);
282
+ if (blobs) {
283
+ const displayKey = Object.keys(blobs).find((k) => k !== "primary" && k !== "poster") ?? "primary";
284
+ if (blobs[displayKey]) urlMap[pi.id] = URL.createObjectURL(blobs[displayKey]);
278
285
  }
279
286
  }
280
287
  if (!cancelled) mediaPipeline.setPendingLocalUrls(urlMap);
@@ -352,6 +359,9 @@ export default function EditorShell({
352
359
  await discardLocalChanges();
353
360
  setLocalChangesExist(false);
354
361
  setDirtySectionIds(new Set());
362
+ for (const url of Object.values(mediaPipeline.pendingLocalUrls)) {
363
+ URL.revokeObjectURL(url);
364
+ }
355
365
  mediaPipeline.setPendingMediaItems([]);
356
366
  mediaPipeline.setPendingLocalUrls({});
357
367
  mediaPipeline.setPendingDeletions([]);
@@ -534,7 +544,6 @@ export default function EditorShell({
534
544
  })),
535
545
  siteIndex: historyContent.index,
536
546
  siteConfig: historyContent.siteConfig,
537
- targetBranch: "main",
538
547
  };
539
548
 
540
549
  const response = await fetch("/api/save", {
@@ -626,6 +635,7 @@ export default function EditorShell({
626
635
  buildElapsed={buildStatus.elapsedSeconds}
627
636
  onBuildDismiss={buildStatus.dismiss}
628
637
  onRestoreClick={() => setShowRestoreModal(true)}
638
+ onOrderingClick={() => setShowOrderingModal(true)}
629
639
  />
630
640
 
631
641
  <HistoryOrEditorContent sections={sections}>
@@ -704,6 +714,18 @@ export default function EditorShell({
704
714
  maxFileSize={siteConfig?.media.maxFileSize}
705
715
  />
706
716
  </EditorModal>
717
+ <EditorModal
718
+ isOpen={showOrderingModal}
719
+ onClose={() => setShowOrderingModal(false)}
720
+ title="Reorder Sections"
721
+ size="settings"
722
+ >
723
+ <SectionOrderingModal
724
+ sections={sections}
725
+ mediaManifest={mediaManifest}
726
+ onReorder={onReorderSections}
727
+ />
728
+ </EditorModal>
707
729
  <RestoreHandler
708
730
  showRestoreModal={showRestoreModal}
709
731
  setShowRestoreModal={setShowRestoreModal}
@@ -1097,6 +1119,7 @@ function EditorToolbar({
1097
1119
  buildElapsed,
1098
1120
  onBuildDismiss,
1099
1121
  onRestoreClick,
1122
+ onOrderingClick,
1100
1123
  }: {
1101
1124
  buttonState: "synced" | "publish" | "saveAndPublish";
1102
1125
  localChangesExist: boolean;
@@ -1113,6 +1136,7 @@ function EditorToolbar({
1113
1136
  buildElapsed: number;
1114
1137
  onBuildDismiss: () => void;
1115
1138
  onRestoreClick: () => void;
1139
+ onOrderingClick: () => void;
1116
1140
  }) {
1117
1141
  const { isEditMode, viewBranch, setViewBranch, toggleEditMode, historyState, setHistoryState } = useEditorContext();
1118
1142
 
@@ -1177,6 +1201,13 @@ function EditorToolbar({
1177
1201
  </div>
1178
1202
  <div className="flex items-center justify-end gap-2">
1179
1203
  <ProcessingIndicator items={processingItems} />
1204
+ <IconButton
1205
+ icon={<ListOrderedIcon size={16} />}
1206
+ label="Reorder sections"
1207
+ size="md"
1208
+ onClick={onOrderingClick}
1209
+ className="border border-base-200 bg-base-accent"
1210
+ />
1180
1211
  <IconButton
1181
1212
  icon={<ImageIcon size={16} />}
1182
1213
  label="Media library"
@@ -1,3 +1,4 @@
1
+ import { formatDateTime } from "../../lib/timestamp";
1
2
  import { Button } from "../shared/Button";
2
3
 
3
4
  interface HistoryToolbarProps {
@@ -7,12 +8,6 @@ interface HistoryToolbarProps {
7
8
  }
8
9
 
9
10
  export function HistoryToolbar({ date, onBackToCurrent, onRestore }: HistoryToolbarProps) {
10
- const formattedDate = new Date(date).toLocaleDateString("en-US", {
11
- month: "short",
12
- day: "numeric",
13
- year: "numeric",
14
- });
15
-
16
11
  return (
17
12
  <div className="fixed top-0 right-0 left-0 z-50 border-b border-base-200 bg-base">
18
13
  <div className="mx-auto max-w-screen-xl grid grid-cols-3 items-center px-4 py-2">
@@ -23,7 +18,7 @@ export function HistoryToolbar({ date, onBackToCurrent, onRestore }: HistoryTool
23
18
  </div>
24
19
  <div className="flex items-center justify-center">
25
20
  <span className="text-xs font-medium text-base-contrast-light">
26
- Viewing {formattedDate}
21
+ Viewing {formatDateTime(date)}
27
22
  </span>
28
23
  </div>
29
24
  <div className="flex items-center justify-end">
@@ -1,3 +1,4 @@
1
+ import { formatDateTime } from "../../lib/timestamp";
1
2
  import { EditorModal } from "./EditorModal";
2
3
  import { Button } from "../shared/Button";
3
4
 
@@ -10,16 +11,10 @@ interface RestoreModalProps {
10
11
  }
11
12
 
12
13
  export function RestoreModal({ isOpen, onClose, date, onConfirm, isRestoring }: RestoreModalProps) {
13
- const formattedDate = new Date(date).toLocaleDateString("en-US", {
14
- month: "short",
15
- day: "numeric",
16
- year: "numeric",
17
- });
18
-
19
14
  return (
20
15
  <EditorModal isOpen={isOpen} onClose={onClose} title="Restore this version?">
21
16
  <p className="mb-4 text-sm text-base-contrast-light">
22
- This will publish the content from {formattedDate} as a new update.
17
+ This will publish the content from {formatDateTime(date)} as a new update.
23
18
  Your current content will still be available in the history.
24
19
  </p>
25
20
  <div className="flex justify-end gap-3">
@@ -0,0 +1 @@
1
+ export type { DeployStatus, DeployStatusProvider } from "./types";
@@ -0,0 +1,12 @@
1
+ export interface DeployStatus {
2
+ deployId: string;
3
+ state: "building" | "ready" | "error";
4
+ deployUrl: string;
5
+ commitSha: string | null;
6
+ updatedAt: string;
7
+ }
8
+
9
+ export interface DeployStatusProvider {
10
+ get(siteId: string): Promise<DeployStatus | null>;
11
+ upsert(siteId: string, data: Omit<DeployStatus, "updatedAt">): Promise<void>;
12
+ }
@@ -2,7 +2,7 @@ import { useState, useCallback, useRef, useEffect } from "react";
2
2
  import type { SiteIndex, SiteConfig } from "../schemas/site-config";
3
3
  import type { LoadedSection } from "../lib/loader";
4
4
  import type { MediaManifest, MediaItem } from "../media/types";
5
- import { getDirtySections, hasLocalChanges, discardLocalChanges, cacheContent, getPendingMediaLocalUrls, clearPendingMedia } from "../lib/dexie";
5
+ import { getDirtySections, hasLocalChanges, discardLocalChanges, cacheContent, getPendingMediaBlobs, clearPendingMedia } from "../lib/dexie";
6
6
 
7
7
  function blobToBase64(blob: Blob): Promise<string> {
8
8
  return new Promise((resolve, reject) => {
@@ -36,7 +36,6 @@ interface PublishDeps {
36
36
 
37
37
  interface GatheredMedia {
38
38
  mediaUploads: { item: MediaItem; blobs: { path: string; base64: string }[] }[];
39
- blobUrlsToRevoke: string[];
40
39
  updatedManifest: MediaManifest | undefined;
41
40
  hasMediaChanges: boolean;
42
41
  }
@@ -79,44 +78,31 @@ export function useEditorPublish({
79
78
  async function gatherMediaPayload(): Promise<GatheredMedia> {
80
79
  const hasMediaChanges = pendingMediaItems.length > 0 || pendingMediaDeletions.length > 0;
81
80
  const mediaUploads: { item: MediaItem; blobs: { path: string; base64: string }[] }[] = [];
82
- const blobUrlsToRevoke: string[] = [];
83
81
 
84
- for (const item of pendingMediaItems) {
85
- const localUrls = await getPendingMediaLocalUrls(item.id);
86
- if (!localUrls) continue;
82
+ const deletionSet = new Set(pendingMediaDeletions);
83
+ const itemsToUpload = pendingMediaItems.filter((i) => !deletionSet.has(i.id));
87
84
 
88
- for (const url of Object.values(localUrls)) {
89
- blobUrlsToRevoke.push(url);
90
- }
85
+ for (const item of itemsToUpload) {
86
+ const storedBlobs = await getPendingMediaBlobs(item.id);
87
+ if (!storedBlobs) continue;
91
88
 
92
- const blobs: { path: string; base64: string }[] = [];
93
- const failedBlobFetches: string[] = [];
89
+ const blobPayloads: { path: string; base64: string }[] = [];
94
90
  const mimeExt: Record<string, string> = {
95
91
  "image/gif": "gif", "image/apng": "apng", "video/mp4": "mp4", "video/webm": "webm",
96
92
  };
97
- for (const [key, url] of Object.entries(localUrls)) {
93
+ for (const [key, blob] of Object.entries(storedBlobs)) {
98
94
  if (key === "primary" && item.kind === "image") continue;
99
- try {
100
- const resp = await fetch(url);
101
- const blob = await resp.blob();
102
- const base64 = await blobToBase64(blob);
103
- if (key === "primary") {
104
- const ext = mimeExt[item.mimeType] ?? "bin";
105
- blobs.push({ path: `assets/images/${item.folder}/original.${ext}`, base64 });
106
- } else {
107
- blobs.push({ path: `assets/images/${item.folder}/${key}.webp`, base64 });
108
- }
109
- } catch {
110
- failedBlobFetches.push(`${item.id}/${key}`);
95
+ const base64 = await blobToBase64(blob);
96
+ if (key === "primary") {
97
+ const ext = mimeExt[item.mimeType] ?? "bin";
98
+ blobPayloads.push({ path: `assets/images/${item.folder}/original.${ext}`, base64 });
99
+ } else {
100
+ blobPayloads.push({ path: `assets/images/${item.folder}/${key}.webp`, base64 });
111
101
  }
112
102
  }
113
103
 
114
- if (failedBlobFetches.length > 0) {
115
- throw new Error(`Media upload failed: could not read blob data for ${failedBlobFetches.join(", ")}`);
116
- }
117
-
118
- if (blobs.length > 0) {
119
- mediaUploads.push({ item, blobs });
104
+ if (blobPayloads.length > 0) {
105
+ mediaUploads.push({ item, blobs: blobPayloads });
120
106
  }
121
107
  }
122
108
 
@@ -132,7 +118,7 @@ export function useEditorPublish({
132
118
  updatedManifest = { images };
133
119
  }
134
120
 
135
- return { mediaUploads, blobUrlsToRevoke, updatedManifest, hasMediaChanges };
121
+ return { mediaUploads, updatedManifest, hasMediaChanges };
136
122
  }
137
123
 
138
124
  const handleSave = useCallback(async () => {
@@ -154,7 +140,7 @@ export function useEditorPublish({
154
140
  }
155
141
 
156
142
  const dirty = await getDirtySections();
157
- const { mediaUploads, blobUrlsToRevoke, updatedManifest, hasMediaChanges: mediaChanged } = await gatherMediaPayload();
143
+ const { mediaUploads, updatedManifest, hasMediaChanges: mediaChanged } = await gatherMediaPayload();
158
144
 
159
145
  // Build a filtered siteIndex if there are deletions
160
146
  let siteIndex = siteIndexRef.current;
@@ -175,7 +161,6 @@ export function useEditorPublish({
175
161
  method: "POST",
176
162
  headers: { "Content-Type": "application/json" },
177
163
  body: JSON.stringify({
178
- targetBranch: "saved",
179
164
  sections: dirty.map(({ sectionId, content }) => ({
180
165
  id: sectionId,
181
166
  content,
@@ -209,9 +194,6 @@ export function useEditorPublish({
209
194
  onSuccess();
210
195
  onMediaPublished(pendingMediaItems, pendingMediaDeletions);
211
196
  onShasUpdated(sha, null);
212
- for (const url of blobUrlsToRevoke) {
213
- URL.revokeObjectURL(url);
214
- }
215
197
 
216
198
  showFeedback("Saved", 3000);
217
199
  } catch (error) {
@@ -264,12 +246,9 @@ export function useEditorPublish({
264
246
  const hasDeletedSections = (deletedSectionIds?.length ?? 0) > 0;
265
247
  const hasLocalEdits = hasChanges || isConfigDirty() || hasMediaChanges || hasDeletedSections;
266
248
 
267
- let blobUrlsToRevoke: string[] = [];
268
-
269
249
  if (hasLocalEdits) {
270
250
  const dirty = await getDirtySections();
271
251
  const gathered = await gatherMediaPayload();
272
- blobUrlsToRevoke = gathered.blobUrlsToRevoke;
273
252
 
274
253
  // Build a filtered siteIndex if there are deletions
275
254
  let siteIndex = siteIndexRef.current;
@@ -290,7 +269,6 @@ export function useEditorPublish({
290
269
  method: "POST",
291
270
  headers: { "Content-Type": "application/json" },
292
271
  body: JSON.stringify({
293
- targetBranch: "saved",
294
272
  sections: dirty.map(({ sectionId, content }) => ({
295
273
  id: sectionId,
296
274
  content,
@@ -338,9 +316,6 @@ export function useEditorPublish({
338
316
  clearConfigDirty();
339
317
  onSuccess();
340
318
  onMediaPublished(pendingMediaItems, pendingMediaDeletions);
341
- for (const url of blobUrlsToRevoke) {
342
- URL.revokeObjectURL(url);
343
- }
344
319
  }
345
320
 
346
321
  onShasUpdated(null, sha);
@@ -8,7 +8,9 @@ import type { LoadedSection } from "../lib/loader";
8
8
  import type { MediaLibraryContextValue } from "../components/shell/MediaLibraryContext";
9
9
  import {
10
10
  addPendingMediaItem,
11
+ removePendingMediaItem,
11
12
  markPendingMediaDeleted,
13
+ removePendingMediaDeletion,
12
14
  } from "../lib/dexie";
13
15
  import { generateVideoPoster } from "../media/videoPoster";
14
16
 
@@ -87,6 +89,7 @@ export function useMediaPipeline({
87
89
 
88
90
  const finalize = (
89
91
  localUrls: Record<string, string>,
92
+ blobsMap: Record<string, Blob>,
90
93
  width: number,
91
94
  height: number,
92
95
  ) => {
@@ -108,7 +111,9 @@ export function useMediaPipeline({
108
111
  alt: "",
109
112
  };
110
113
 
111
- addPendingMediaItem(item, localUrls);
114
+ addPendingMediaItem(item, localUrls, blobsMap);
115
+ removePendingMediaDeletion(item.id);
116
+ setPendingDeletions((prev) => prev.filter((d) => d !== item.id));
112
117
  setPendingMediaItems((prev) => [...prev, item]);
113
118
  const displayKey = kind === "video"
114
119
  ? "primary"
@@ -126,28 +131,33 @@ export function useMediaPipeline({
126
131
  };
127
132
 
128
133
  const localUrls: Record<string, string> = {};
134
+ const blobsMap: Record<string, Blob> = {};
129
135
  for (const v of result.variants) {
136
+ blobsMap[String(v.width)] = v.blob;
130
137
  localUrls[String(v.width)] = URL.createObjectURL(v.blob);
131
138
  }
132
139
  if (result.primaryBlob) {
140
+ blobsMap["primary"] = result.primaryBlob;
133
141
  localUrls["primary"] = URL.createObjectURL(result.primaryBlob);
134
142
  }
135
143
 
136
144
  if (kind === "video") {
137
145
  generateVideoPoster(result.primaryBlob, mediaConfig.quality).then(
138
146
  ({ posterBlob, width, height }) => {
147
+ blobsMap["poster"] = posterBlob;
139
148
  localUrls["poster"] = URL.createObjectURL(posterBlob);
140
- finalize(localUrls, width, height);
149
+ finalize(localUrls, blobsMap, width, height);
141
150
  },
142
151
  () => {
143
- finalize(localUrls, result.width, result.height);
152
+ finalize(localUrls, blobsMap, result.width, result.height);
144
153
  },
145
154
  );
146
155
  } else {
147
156
  if (result.posterBlob) {
157
+ blobsMap["poster"] = result.posterBlob;
148
158
  localUrls["poster"] = URL.createObjectURL(result.posterBlob);
149
159
  }
150
- finalize(localUrls, result.width, result.height);
160
+ finalize(localUrls, blobsMap, result.width, result.height);
151
161
  }
152
162
  }
153
163
  },
@@ -171,9 +181,10 @@ export function useMediaPipeline({
171
181
  const buffer = await file.arrayBuffer();
172
182
  const hash = await hashFileBuffer(buffer);
173
183
 
184
+ const isDeleted = pendingDeletions.includes(hash);
174
185
  const existsInManifest = hash in mediaManifest.images;
175
186
  const existsInPending = pendingMediaItems.some((i) => i.hash === hash);
176
- if (existsInManifest || existsInPending) continue;
187
+ if ((existsInManifest || existsInPending) && !isDeleted) continue;
177
188
 
178
189
  let kind: "image" | "animated" | "video" = "image";
179
190
  if (file.type === "image/gif" || file.type === "image/apng") kind = "animated";
@@ -181,15 +192,33 @@ export function useMediaPipeline({
181
192
 
182
193
  queue.add({ buffer, originalName: file.name, mimeType: file.type, hash, kind });
183
194
  }
184
- }, [siteConfig, mediaManifest, pendingMediaItems]);
195
+ }, [siteConfig, mediaManifest, pendingMediaItems, pendingDeletions]);
185
196
 
186
197
  const handleMediaDelete = useCallback(async (ids: string[]) => {
198
+ const idSet = new Set(ids);
199
+ const pendingIds = new Set(pendingMediaItems.filter((i) => idSet.has(i.id)).map((i) => i.id));
200
+
187
201
  for (const id of ids) {
188
- await markPendingMediaDeleted(id);
202
+ if (pendingIds.has(id)) {
203
+ await removePendingMediaItem(id);
204
+ } else {
205
+ await markPendingMediaDeleted(id);
206
+ }
189
207
  }
208
+
209
+ setPendingMediaItems((prev) => prev.filter((i) => !idSet.has(i.id)));
210
+ setPendingLocalUrls((prev) => {
211
+ const next = { ...prev };
212
+ for (const id of ids) {
213
+ if (next[id]) {
214
+ URL.revokeObjectURL(next[id]);
215
+ delete next[id];
216
+ }
217
+ }
218
+ return next;
219
+ });
190
220
  setPendingDeletions((prev) => [...prev, ...ids]);
191
221
 
192
- const idSet = new Set(ids);
193
222
  setSections((prev) =>
194
223
  prev.map((loaded) => {
195
224
  const json = JSON.stringify(loaded.section);
@@ -204,7 +233,7 @@ export function useMediaPipeline({
204
233
  );
205
234
 
206
235
  setLocalChangesExist(true);
207
- }, [markSectionDirty, setSections, setDirtySectionIds, setLocalChangesExist]);
236
+ }, [pendingMediaItems, markSectionDirty, setSections, setDirtySectionIds, setLocalChangesExist]);
208
237
 
209
238
  const handleMediaAltChange = useCallback((id: string, alt: string) => {
210
239
  setMediaManifest((prev) => {
@@ -247,10 +276,11 @@ export function useMediaPipeline({
247
276
  const buffer = await file.arrayBuffer();
248
277
  const hash = await hashFileBuffer(buffer);
249
278
 
279
+ const isDeleted = pendingDeletions.includes(hash);
250
280
  const existsInManifest = hash in mediaManifest.images;
251
281
  const existsInPending = pendingMediaItems.some((i) => i.hash === hash);
252
282
 
253
- if (existsInManifest || existsInPending) {
283
+ if ((existsInManifest || existsInPending) && !isDeleted) {
254
284
  if (onComplete) onComplete(hash);
255
285
  return;
256
286
  }
@@ -268,7 +298,7 @@ export function useMediaPipeline({
268
298
  console.error(`[useMediaPipeline] Failed to enqueue file "${file.name}":`, err);
269
299
  }
270
300
  })();
271
- }, [siteConfig, mediaManifest, pendingMediaItems]);
301
+ }, [siteConfig, mediaManifest, pendingMediaItems, pendingDeletions]);
272
302
 
273
303
  // --- Context value ---
274
304
 
@@ -1,6 +1,6 @@
1
1
  import { useMemo } from "react";
2
2
  import { useMediaLibrary } from "../components/shell/MediaLibraryContext";
3
- import { createMediaAdapter } from "../media";
3
+ import { getMediaProvider } from "../media";
4
4
 
5
5
  export interface ResolvedMediaResult {
6
6
  src: string | undefined;
@@ -33,8 +33,8 @@ export function useResolvedMedia(imageId: string | undefined): ResolvedMediaResu
33
33
  }
34
34
 
35
35
  if (manifestItem) {
36
- const adapter = createMediaAdapter(manifest);
37
- const resolved = adapter.resolve(imageId, sizes);
36
+ const provider = getMediaProvider();
37
+ const resolved = provider.resolve(manifestItem, sizes);
38
38
  if (resolved && resolved.tag === "img") {
39
39
  return {
40
40
  src: resolved.src,
package/src/index.ts CHANGED
@@ -2,3 +2,5 @@ export * from "./schemas";
2
2
  export * from "./lib";
3
3
  export * from "./auth";
4
4
  export * from "./media";
5
+ export * from "./deploy";
6
+ export * from "./storage";
package/src/lib/dexie.ts CHANGED
@@ -52,6 +52,7 @@ interface PendingMediaRow {
52
52
  id: string;
53
53
  item: MediaItem;
54
54
  localUrls: Record<string, string>;
55
+ blobs: Record<string, Blob>;
55
56
  updatedAt: string;
56
57
  }
57
58
 
@@ -110,6 +111,18 @@ class EditorDatabase extends Dexie {
110
111
  pendingMedia: "id",
111
112
  pendingMediaDeletions: "id",
112
113
  });
114
+ this.version(6).stores({
115
+ sections: "sectionId",
116
+ siteIndex: "key",
117
+ meta: "key",
118
+ siteConfig: "key",
119
+ contentCache: "key",
120
+ mediaManifest: "key",
121
+ pendingMedia: "id",
122
+ pendingMediaDeletions: "id",
123
+ }).upgrade((tx) => {
124
+ return tx.table("pendingMedia").clear();
125
+ });
113
126
  }
114
127
  }
115
128
 
@@ -354,9 +367,10 @@ export async function getMediaManifest(): Promise<MediaManifest | null> {
354
367
  export async function addPendingMediaItem(
355
368
  item: MediaItem,
356
369
  localUrls: Record<string, string> = {},
370
+ blobs: Record<string, Blob> = {},
357
371
  ): Promise<void> {
358
372
  const now = new Date().toISOString();
359
- await getDb().pendingMedia.put({ id: item.id, item, localUrls, updatedAt: now });
373
+ await getDb().pendingMedia.put({ id: item.id, item, localUrls, blobs, updatedAt: now });
360
374
  }
361
375
 
362
376
  export async function getPendingMediaItems(): Promise<MediaItem[]> {
@@ -369,11 +383,24 @@ export async function getPendingMediaLocalUrls(id: string): Promise<Record<strin
369
383
  return row?.localUrls ?? null;
370
384
  }
371
385
 
386
+ export async function getPendingMediaBlobs(id: string): Promise<Record<string, Blob> | null> {
387
+ const row = await getDb().pendingMedia.get(id);
388
+ return row?.blobs ?? null;
389
+ }
390
+
391
+ export async function removePendingMediaItem(id: string): Promise<void> {
392
+ await getDb().pendingMedia.delete(id);
393
+ }
394
+
372
395
  export async function markPendingMediaDeleted(id: string): Promise<void> {
373
396
  const now = new Date().toISOString();
374
397
  await getDb().pendingMediaDeletions.put({ id, deletedAt: now });
375
398
  }
376
399
 
400
+ export async function removePendingMediaDeletion(id: string): Promise<void> {
401
+ await getDb().pendingMediaDeletions.delete(id);
402
+ }
403
+
377
404
  export async function getPendingMediaDeletions(): Promise<string[]> {
378
405
  const rows = await getDb().pendingMediaDeletions.toArray();
379
406
  return rows.map((r) => r.id);
package/src/lib/nav.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { LoadedSection } from "./loader";
2
+ import { getSection, type SectionRegistry } from "./registry";
2
3
 
3
4
  export interface NavItem {
4
5
  href: string;
@@ -15,21 +16,27 @@ export function toSectionId(text: string): string {
15
16
  .replace(/\s+/g, "-");
16
17
  }
17
18
 
18
- /**
19
- * Generate a three-tier navigation structure from sections.
20
- * link_heading → top-level, sub_heading → second-level, sub_sub_heading → third-level.
21
- * Non-heading sections are skipped.
22
- */
23
- export function generateNavLinks(sections: LoadedSection[]): NavItem[] {
19
+ export function generateNavLinks(
20
+ sections: LoadedSection[],
21
+ registry?: SectionRegistry,
22
+ ): NavItem[] {
24
23
  const nav: NavItem[] = [];
25
24
  let currentParent: NavItem | null = null;
26
25
  let currentChild: NavItem | null = null;
27
26
 
27
+ const lookupRole = (type: string) => {
28
+ const def = registry ? registry.getSection(type) : getSection(type);
29
+ return def?.navRole;
30
+ };
31
+
28
32
  for (const { section, meta } of sections) {
29
33
  const content = section.content as { heading?: string; excludeFromNav?: boolean };
30
34
  if (!content.heading) continue;
31
35
 
32
- if (section.type === "link_heading") {
36
+ const role = lookupRole(section.type);
37
+ if (!role) continue;
38
+
39
+ if (role === "h1") {
33
40
  currentParent = {
34
41
  href: `#${toSectionId(content.heading)}`,
35
42
  label: content.heading,
@@ -38,7 +45,7 @@ export function generateNavLinks(sections: LoadedSection[]): NavItem[] {
38
45
  };
39
46
  currentChild = null;
40
47
  nav.push(currentParent);
41
- } else if (section.type === "sub_heading") {
48
+ } else if (role === "h2") {
42
49
  if (content.excludeFromNav) continue;
43
50
  if (!currentParent) continue;
44
51
  currentChild = {
@@ -48,7 +55,7 @@ export function generateNavLinks(sections: LoadedSection[]): NavItem[] {
48
55
  children: [],
49
56
  };
50
57
  currentParent.children.push(currentChild);
51
- } else if (section.type === "sub_sub_heading") {
58
+ } else if (role === "h3") {
52
59
  if (content.excludeFromNav) continue;
53
60
  if (!currentChild) continue;
54
61
  currentChild.children.push({
@@ -46,6 +46,10 @@ export type SettingsFieldDef =
46
46
 
47
47
  export type SettingsSchema = Record<string, SettingsFieldDef>;
48
48
 
49
+ export type Thumbnail =
50
+ | { type: "image"; src: string; alt?: string }
51
+ | { type: "color"; value: string };
52
+
49
53
  // --- Component prop types ---
50
54
 
51
55
  // Custom settings forms receive their fields as individual spread props plus onChange.
@@ -99,6 +103,9 @@ export interface SectionDefinition<T = unknown> {
99
103
  settings?: SettingsSchema;
100
104
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
101
105
  settingsForm?: ComponentType<any>;
106
+ getLabel?(content: T): string;
107
+ getThumbnails?(content: T): Thumbnail[];
108
+ navRole?: "h1" | "h2" | "h3";
102
109
  }
103
110
 
104
111
  // --- defineSection ---
@@ -114,6 +121,9 @@ type DefineSectionInput<S extends ZodType> = {
114
121
  settings?: SettingsSchema;
115
122
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
116
123
  settingsForm?: ComponentType<any>;
124
+ getLabel?(content: z.infer<S>): string;
125
+ getThumbnails?(content: z.infer<S>): Thumbnail[];
126
+ navRole?: "h1" | "h2" | "h3";
117
127
  };
118
128
 
119
129
  export function defineSection<S extends ZodType>(