@drawnagency/primitives 0.1.26 → 0.1.28

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 (107) hide show
  1. package/dist/{chunk-UMSFICAC.js → chunk-DKOUFIP6.js} +0 -1
  2. package/dist/{chunk-FSVPD7TW.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/shell/EditorShell.d.ts +1 -1
  25. package/dist/components/shell/EditorShell.d.ts.map +1 -1
  26. package/dist/deploy/index.d.ts +2 -0
  27. package/dist/deploy/index.d.ts.map +1 -0
  28. package/dist/deploy/types.d.ts +12 -0
  29. package/dist/deploy/types.d.ts.map +1 -0
  30. package/dist/hooks/useEditorPublish.d.ts.map +1 -1
  31. package/dist/hooks/useMediaPipeline.d.ts.map +1 -1
  32. package/dist/index.d.ts +2 -0
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +10 -8
  35. package/dist/lib/dexie.d.ts +4 -1
  36. package/dist/lib/dexie.d.ts.map +1 -1
  37. package/dist/lib/dexie.js +319 -0
  38. package/dist/lib/index.js +3 -3
  39. package/dist/lib/nav.d.ts +2 -6
  40. package/dist/lib/nav.d.ts.map +1 -1
  41. package/dist/lib/registry.d.ts +14 -0
  42. package/dist/lib/registry.d.ts.map +1 -1
  43. package/dist/lib/text.d.ts +3 -0
  44. package/dist/lib/text.d.ts.map +1 -0
  45. package/dist/media/index.d.ts +4 -2
  46. package/dist/media/index.d.ts.map +1 -1
  47. package/dist/media/index.js +8 -6
  48. package/dist/media/provider.d.ts +7 -0
  49. package/dist/media/provider.d.ts.map +1 -0
  50. package/dist/media/resolve.d.ts +3 -2
  51. package/dist/media/resolve.d.ts.map +1 -1
  52. package/dist/media/types.d.ts +0 -9
  53. package/dist/media/types.d.ts.map +1 -1
  54. package/dist/schemas/index.js +3 -3
  55. package/dist/schemas/media.d.ts +0 -3
  56. package/dist/schemas/media.d.ts.map +1 -1
  57. package/dist/schemas/site-config.d.ts +1 -3
  58. package/dist/schemas/site-config.d.ts.map +1 -1
  59. package/dist/storage/index.d.ts +2 -0
  60. package/dist/storage/index.d.ts.map +1 -0
  61. package/dist/storage/types.d.ts +21 -0
  62. package/dist/storage/types.d.ts.map +1 -0
  63. package/package.json +5 -1
  64. package/src/components/editor/DragHandle.tsx +1 -1
  65. package/src/components/editor/SectionOrderingModal.tsx +215 -0
  66. package/src/components/editor/SectionWrapper.tsx +3 -1
  67. package/src/components/primitives/EditableRichText.tsx +4 -2
  68. package/src/components/sections/Button/index.tsx +1 -0
  69. package/src/components/sections/Colors/index.tsx +8 -0
  70. package/src/components/sections/DoDontMediaGrid/index.tsx +8 -0
  71. package/src/components/sections/IconList/index.tsx +4 -0
  72. package/src/components/sections/LinkHeading/index.tsx +2 -0
  73. package/src/components/sections/MediaGrid/index.tsx +8 -0
  74. package/src/components/sections/Prose/index.tsx +2 -0
  75. package/src/components/sections/SplitContent/index.tsx +16 -2
  76. package/src/components/sections/SubHeading/index.tsx +2 -0
  77. package/src/components/sections/SubSubHeading/index.tsx +2 -0
  78. package/src/components/sections/ViewRenderer.tsx +3 -1
  79. package/src/components/sections/register-schemas.ts +0 -2
  80. package/src/components/sections/register.ts +0 -2
  81. package/src/components/shell/EditorShell.tsx +41 -9
  82. package/src/deploy/index.ts +1 -0
  83. package/src/deploy/types.ts +12 -0
  84. package/src/hooks/useEditorPublish.ts +18 -43
  85. package/src/hooks/useMediaPipeline.ts +41 -11
  86. package/src/hooks/useResolvedMedia.ts +3 -3
  87. package/src/index.ts +2 -0
  88. package/src/lib/dexie.ts +28 -1
  89. package/src/lib/nav.ts +16 -9
  90. package/src/lib/registry.ts +10 -0
  91. package/src/lib/text.ts +8 -0
  92. package/src/media/index.ts +13 -4
  93. package/src/media/provider.ts +7 -0
  94. package/src/media/resolve.ts +9 -6
  95. package/src/media/types.ts +0 -9
  96. package/src/schemas/media.ts +0 -1
  97. package/src/schemas/site-config.ts +1 -0
  98. package/src/storage/index.ts +1 -0
  99. package/src/storage/types.ts +23 -0
  100. package/dist/components/sections/SplitContent/SplitContentSettings.d.ts +0 -9
  101. package/dist/components/sections/SplitContent/SplitContentSettings.d.ts.map +0 -1
  102. package/dist/media/github.d.ts +0 -3
  103. package/dist/media/github.d.ts.map +0 -1
  104. package/src/components/sections/SplitContent/SplitContentSettings.d.ts +0 -9
  105. package/src/components/sections/SplitContent/SplitContentSettings.d.ts.map +0 -1
  106. package/src/components/sections/SplitContent/SplitContentSettings.tsx +0 -42
  107. package/src/media/github.ts +0 -72
@@ -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>(
@@ -0,0 +1,8 @@
1
+ export function stripHtmlToPlainText(html: string): string {
2
+ return html.replace(/<\/[^>]+>/g, " ").replace(/<[^>]*>/g, "").replace(/\s+/g, " ").trim();
3
+ }
4
+
5
+ export function truncate(text: string, maxLength: number): string {
6
+ if (text.length <= maxLength) return text;
7
+ return text.slice(0, maxLength) + "...";
8
+ }
@@ -1,12 +1,21 @@
1
- import type { MediaAdapter, MediaManifest } from "./types";
2
- import { createGitHubMediaAdapter } from "./github";
1
+ import type { MediaProvider } from "./provider";
3
2
 
4
3
  export * from "./types";
4
+ export * from "./provider";
5
5
  export * from "./utils";
6
6
  export * from "./queue";
7
7
  export * from "./resolve";
8
8
  export { generateVideoPoster } from "./videoPoster";
9
9
 
10
- export function createMediaAdapter(manifest: MediaManifest): MediaAdapter {
11
- return createGitHubMediaAdapter(manifest);
10
+ let _provider: MediaProvider | null = null;
11
+
12
+ export function setMediaProvider(p: MediaProvider): void {
13
+ _provider = p;
14
+ }
15
+
16
+ export function getMediaProvider(): MediaProvider {
17
+ if (!_provider) {
18
+ throw new Error("No MediaProvider registered. Call setMediaProvider() before using getMediaProvider().");
19
+ }
20
+ return _provider;
12
21
  }
@@ -0,0 +1,7 @@
1
+ import type { MediaManifest, MediaItem, ResolvedMedia } from "./types";
2
+
3
+ export interface MediaProvider {
4
+ getManifest(): Promise<MediaManifest>;
5
+ resolve(item: MediaItem, sizes: number[]): ResolvedMedia | null;
6
+ exists(hash: string, manifest: MediaManifest): boolean;
7
+ }
@@ -1,14 +1,16 @@
1
1
  import type { MediaManifest, ResolvedMedia } from "./types";
2
- import { createMediaAdapter } from "./index";
2
+ import type { MediaProvider } from "./provider";
3
3
 
4
4
  export function resolveMedia(
5
5
  item: { imageId?: string; src?: string },
6
6
  manifest: MediaManifest,
7
7
  sizes: number[],
8
+ provider: MediaProvider,
8
9
  ): ResolvedMedia | null {
9
10
  if (!item.imageId) return null;
10
- const adapter = createMediaAdapter(manifest);
11
- return adapter.resolve(item.imageId, sizes);
11
+ const mediaItem = manifest.images[item.imageId];
12
+ if (!mediaItem) return null;
13
+ return provider.resolve(mediaItem, sizes);
12
14
  }
13
15
 
14
16
  /**
@@ -22,9 +24,10 @@ export function resolveManifestReferences(
22
24
  content: unknown,
23
25
  manifest: MediaManifest,
24
26
  sizes: number[],
27
+ provider: MediaProvider,
25
28
  ): unknown {
26
29
  if (Array.isArray(content)) {
27
- return content.map((item) => resolveManifestReferences(item, manifest, sizes));
30
+ return content.map((item) => resolveManifestReferences(item, manifest, sizes, provider));
28
31
  }
29
32
 
30
33
  if (content !== null && typeof content === "object") {
@@ -33,7 +36,7 @@ export function resolveManifestReferences(
33
36
  // If this object has an imageId, resolve it and merge resolved fields
34
37
  if (typeof obj.imageId === "string") {
35
38
  const manifestItem = manifest.images[obj.imageId];
36
- const resolved = resolveMedia({ imageId: obj.imageId }, manifest, sizes);
39
+ const resolved = resolveMedia({ imageId: obj.imageId }, manifest, sizes, provider);
37
40
  const patch: Record<string, unknown> = {
38
41
  alt: manifestItem?.alt ?? "",
39
42
  };
@@ -54,7 +57,7 @@ export function resolveManifestReferences(
54
57
  // Recurse into all values
55
58
  const result: Record<string, unknown> = {};
56
59
  for (const [key, value] of Object.entries(obj)) {
57
- result[key] = resolveManifestReferences(value, manifest, sizes);
60
+ result[key] = resolveManifestReferences(value, manifest, sizes, provider);
58
61
  }
59
62
  return result;
60
63
  }
@@ -47,12 +47,3 @@ export interface ResolvedVideo {
47
47
  }
48
48
 
49
49
  export type ResolvedMedia = ResolvedImage | ResolvedAnimated | ResolvedVideo;
50
-
51
- export interface MediaAdapter {
52
- hash(file: MediaFile): Promise<string>;
53
- exists(hash: string): Promise<boolean>;
54
- process(file: MediaFile, hash: string, sizes: number[]): Promise<MediaItem>;
55
- delete(id: string): Promise<void>;
56
- resolve(id: string, sizes: number[]): ResolvedMedia | null;
57
- list(): Promise<MediaItem[]>;
58
- }
@@ -31,7 +31,6 @@ export const ImageManifestSchema = z.object({
31
31
  export type MediaManifest = z.infer<typeof ImageManifestSchema>;
32
32
 
33
33
  export const MediaConfigSchema = z.object({
34
- adapter: z.enum(["github"]).default("github"),
35
34
  sizes: z.array(z.number()).default([640, 1080, 1920]),
36
35
  maxFileSize: z.number().default(5242880),
37
36
  quality: z.number().min(1).max(100).default(85),
@@ -18,6 +18,7 @@ export const IndexSchema = z.object({
18
18
  siteId: z.string(),
19
19
  order: z.array(z.string()),
20
20
  sections: z.record(z.string(), SectionMetaSchema),
21
+ lastModified: z.string().nullable().optional(),
21
22
  }).refine(
22
23
  (data) => data.order.every((id) => id in data.sections),
23
24
  { message: "All order entries must have a corresponding section in sections" }
@@ -0,0 +1 @@
1
+ export type { StorageProvider, FileWrite } from "./types";
@@ -0,0 +1,23 @@
1
+ export interface StorageProvider {
2
+ writeFiles(
3
+ files: FileWrite[],
4
+ message: string,
5
+ ): Promise<{ version: string }>;
6
+
7
+ readFile(
8
+ path: string,
9
+ options?: { draft?: boolean },
10
+ ): Promise<Uint8Array | null>;
11
+
12
+ promoteDraft(): Promise<{ version: string }>;
13
+
14
+ getVersion(): Promise<string>;
15
+
16
+ listDirectory(path: string, options?: { draft?: boolean }): Promise<string[]>;
17
+ }
18
+
19
+ export type FileWrite = {
20
+ path: string;
21
+ content: string | Uint8Array | null;
22
+ encoding?: "utf-8" | "base64";
23
+ };
@@ -1,9 +0,0 @@
1
- export declare function SplitContentSettings({ imagePosition: initialPos, border: initialBorder, onChange, }: {
2
- imagePosition: "left" | "right";
3
- border: boolean;
4
- onChange: (values: {
5
- imagePosition: "left" | "right";
6
- border: boolean;
7
- }) => void;
8
- }): import("react/jsx-runtime").JSX.Element;
9
- //# sourceMappingURL=SplitContentSettings.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"SplitContentSettings.d.ts","sourceRoot":"","sources":["../../../../src/components/sections/SplitContent/SplitContentSettings.tsx"],"names":[],"mappings":"AAIA,wBAAgB,oBAAoB,CAAC,EACnC,aAAa,EAAE,UAAU,EACzB,MAAM,EAAE,aAAa,EACrB,QAAQ,GACT,EAAE;IACD,aAAa,EAAE,MAAM,GAAG,OAAO,CAAC;IAChC,MAAM,EAAE,OAAO,CAAC;IAChB,QAAQ,EAAE,CAAC,MAAM,EAAE;QAAE,aAAa,EAAE,MAAM,GAAG,OAAO,CAAC;QAAC,MAAM,EAAE,OAAO,CAAA;KAAE,KAAK,IAAI,CAAC;CAClF,2CA6BA"}
@@ -1,3 +0,0 @@
1
- import type { MediaAdapter, MediaManifest } from "./types";
2
- export declare function createGitHubMediaAdapter(manifest: MediaManifest): MediaAdapter;
3
- //# sourceMappingURL=github.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"github.d.ts","sourceRoot":"","sources":["../../src/media/github.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAwB,aAAa,EAAiB,MAAM,SAAS,CAAC;AAGhG,wBAAgB,wBAAwB,CAAC,QAAQ,EAAE,aAAa,GAAG,YAAY,CAoE9E"}
@@ -1,9 +0,0 @@
1
- export declare function SplitContentSettings({ imagePosition: initialPos, border: initialBorder, onChange, }: {
2
- imagePosition: "left" | "right";
3
- border: boolean;
4
- onChange: (values: {
5
- imagePosition: "left" | "right";
6
- border: boolean;
7
- }) => void;
8
- }): import("react/jsx-runtime").JSX.Element;
9
- //# sourceMappingURL=SplitContentSettings.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"SplitContentSettings.d.ts","sourceRoot":"","sources":["SplitContentSettings.tsx"],"names":[],"mappings":"AAIA,wBAAgB,oBAAoB,CAAC,EACnC,aAAa,EAAE,UAAU,EACzB,MAAM,EAAE,aAAa,EACrB,QAAQ,GACT,EAAE;IACD,aAAa,EAAE,MAAM,GAAG,OAAO,CAAC;IAChC,MAAM,EAAE,OAAO,CAAC;IAChB,QAAQ,EAAE,CAAC,MAAM,EAAE;QAAE,aAAa,EAAE,MAAM,GAAG,OAAO,CAAC;QAAC,MAAM,EAAE,OAAO,CAAA;KAAE,KAAK,IAAI,CAAC;CAClF,2CA6BA"}