@drawnagency/primitives 0.1.38 → 0.1.40

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 (56) hide show
  1. package/dist/{chunk-OUFUUBZ4.js → chunk-62OWSJ7V.js} +1 -1
  2. package/dist/{chunk-V43WVSVS.js → chunk-A4RARGF2.js} +6 -2
  3. package/dist/{chunk-C2MVDXD7.js → chunk-BU52OBPW.js} +27 -8
  4. package/dist/{chunk-VCZBZMXU.js → chunk-VY67DS3O.js} +36 -10
  5. package/dist/components/primitives/MediaSettingsForms.d.ts +6 -2
  6. package/dist/components/primitives/MediaSettingsForms.d.ts.map +1 -1
  7. package/dist/components/primitives/ResolvedMedia.d.ts +2 -1
  8. package/dist/components/primitives/ResolvedMedia.d.ts.map +1 -1
  9. package/dist/components/shell/EditorShell.d.ts.map +1 -1
  10. package/dist/components/shell/MediaLibraryModal.d.ts.map +1 -1
  11. package/dist/components/shell/SiteSettingsDisplay.d.ts.map +1 -1
  12. package/dist/hooks/useBuildStatus.d.ts.map +1 -1
  13. package/dist/hooks/useEditorPersistence.d.ts.map +1 -1
  14. package/dist/hooks/useEditorPublish.d.ts.map +1 -1
  15. package/dist/hooks/useMediaPipeline.d.ts.map +1 -1
  16. package/dist/index.js +8 -4
  17. package/dist/lib/dexie.d.ts.map +1 -1
  18. package/dist/lib/dexie.js +1 -2
  19. package/dist/lib/index.d.ts +1 -1
  20. package/dist/lib/index.d.ts.map +1 -1
  21. package/dist/lib/index.js +4 -2
  22. package/dist/lib/loader.d.ts.map +1 -1
  23. package/dist/lib/nav.d.ts.map +1 -1
  24. package/dist/lib/sanitize.d.ts +10 -0
  25. package/dist/lib/sanitize.d.ts.map +1 -1
  26. package/dist/lib/text.d.ts.map +1 -1
  27. package/dist/media/index.js +3 -1
  28. package/dist/media/queue.d.ts.map +1 -1
  29. package/dist/media/utils.d.ts +1 -0
  30. package/dist/media/utils.d.ts.map +1 -1
  31. package/dist/media/videoPoster.d.ts.map +1 -1
  32. package/dist/schemas/index.js +2 -2
  33. package/dist/schemas/site-config.d.ts +1 -0
  34. package/dist/schemas/site-config.d.ts.map +1 -1
  35. package/package.json +1 -1
  36. package/src/components/primitives/MediaSettingsForms.tsx +25 -6
  37. package/src/components/primitives/ResolvedMedia.tsx +8 -1
  38. package/src/components/sections/MediaGrid/MediaGrid.tsx +3 -0
  39. package/src/components/shell/EditorShell.tsx +3 -0
  40. package/src/components/shell/MediaLibraryModal.tsx +2 -5
  41. package/src/components/shell/SiteSettingsDisplay.tsx +7 -0
  42. package/src/components/shell/SiteSettingsModal.tsx +1 -1
  43. package/src/hooks/useBuildStatus.ts +13 -3
  44. package/src/hooks/useEditorPersistence.ts +19 -6
  45. package/src/hooks/useEditorPublish.ts +23 -6
  46. package/src/hooks/useMediaPipeline.ts +21 -6
  47. package/src/lib/dexie.ts +1 -2
  48. package/src/lib/index.ts +1 -1
  49. package/src/lib/loader.ts +3 -2
  50. package/src/lib/nav.ts +15 -3
  51. package/src/lib/sanitize.ts +22 -3
  52. package/src/lib/text.ts +5 -1
  53. package/src/media/queue.ts +3 -1
  54. package/src/media/utils.ts +8 -0
  55. package/src/media/videoPoster.ts +31 -10
  56. package/src/schemas/site-config.ts +7 -2
@@ -7,14 +7,17 @@ import { Select } from "../shared/Select";
7
7
  export function ImageSettingsForm({
8
8
  border,
9
9
  objectFit,
10
+ invertFrom: initialInvertFrom,
10
11
  onChange,
11
12
  }: {
12
13
  border?: boolean;
13
14
  objectFit?: "cover" | "contain";
14
- onChange: (update: { border?: boolean; objectFit?: "cover" | "contain" }) => void;
15
+ invertFrom?: string;
16
+ onChange: (update: { border?: boolean; objectFit?: "cover" | "contain"; invertFrom?: string }) => void;
15
17
  }) {
16
18
  const [itemBorder, setItemBorder] = useState(border);
17
19
  const [fit, setFit] = useState(objectFit);
20
+ const [invertFrom, setInvertFrom] = useState(initialInvertFrom ?? "");
18
21
 
19
22
  return (
20
23
  <div className="space-y-4">
@@ -24,7 +27,7 @@ export function ImageSettingsForm({
24
27
  onChange={(v) => {
25
28
  const val = (v || undefined) as "cover" | "contain" | undefined;
26
29
  setFit(val);
27
- onChange({ border: itemBorder, objectFit: val });
30
+ onChange({ border: itemBorder, objectFit: val, invertFrom: invertFrom || undefined });
28
31
  }}
29
32
  options={[
30
33
  { value: "", label: "Default (inherit from grid)" },
@@ -32,12 +35,25 @@ export function ImageSettingsForm({
32
35
  { value: "cover", label: "Crop to fill" },
33
36
  ]}
34
37
  />
38
+ <Select
39
+ label="Invert colors"
40
+ value={invertFrom}
41
+ onChange={(v) => {
42
+ setInvertFrom(v);
43
+ onChange({ border: itemBorder, objectFit: fit, invertFrom: v || undefined });
44
+ }}
45
+ options={[
46
+ { value: "", label: "None" },
47
+ { value: "light", label: "Invert on light theme" },
48
+ { value: "dark", label: "Invert on dark theme" },
49
+ ]}
50
+ />
35
51
  <Checkbox
36
52
  checked={itemBorder ?? false}
37
53
  onChange={(v) => {
38
54
  const val = v || undefined;
39
55
  setItemBorder(val);
40
- onChange({ border: val, objectFit: fit });
56
+ onChange({ border: val, objectFit: fit, invertFrom: invertFrom || undefined });
41
57
  }}
42
58
  label="Override border settings"
43
59
  />
@@ -48,18 +64,20 @@ export function ImageSettingsForm({
48
64
  export function DoDontImageSettingsForm({
49
65
  border,
50
66
  objectFit,
67
+ invertFrom,
51
68
  doDont: initialDoDont,
52
69
  onChange,
53
70
  }: {
54
71
  border?: boolean;
55
72
  objectFit?: "cover" | "contain";
73
+ invertFrom?: string;
56
74
  doDont: "do" | "dont";
57
- onChange: (update: { border?: boolean; objectFit?: "cover" | "contain"; doDont: "do" | "dont" }) => void;
75
+ onChange: (update: { border?: boolean; objectFit?: "cover" | "contain"; invertFrom?: string; doDont: "do" | "dont" }) => void;
58
76
  }) {
59
77
  const [doDont, setDoDont] = useState<"do" | "dont">(initialDoDont);
60
- const latestRef = useRef<{ border?: boolean; objectFit?: "cover" | "contain" }>({ border, objectFit });
78
+ const latestRef = useRef<{ border?: boolean; objectFit?: "cover" | "contain"; invertFrom?: string }>({ border, objectFit, invertFrom });
61
79
 
62
- const handleBaseChange = (updated: { border?: boolean; objectFit?: "cover" | "contain" }) => {
80
+ const handleBaseChange = (updated: { border?: boolean; objectFit?: "cover" | "contain"; invertFrom?: string }) => {
63
81
  latestRef.current = updated;
64
82
  onChange({ ...updated, doDont });
65
83
  };
@@ -69,6 +87,7 @@ export function DoDontImageSettingsForm({
69
87
  <ImageSettingsForm
70
88
  border={border}
71
89
  objectFit={objectFit}
90
+ invertFrom={invertFrom}
72
91
  onChange={handleBaseChange}
73
92
  />
74
93
  <hr className="border-base-200" />
@@ -9,6 +9,7 @@ interface ResolvedMediaProps {
9
9
  alt?: string;
10
10
  className?: string;
11
11
  imgClassName?: string;
12
+ invertFrom?: string;
12
13
  }
13
14
 
14
15
  export function ResolvedMedia({
@@ -18,6 +19,7 @@ export function ResolvedMedia({
18
19
  alt: propAlt,
19
20
  className,
20
21
  imgClassName,
22
+ invertFrom,
21
23
  }: ResolvedMediaProps) {
22
24
  const resolved = useResolvedMedia(imageId);
23
25
  const src = propSrc || resolved.src;
@@ -26,7 +28,12 @@ export function ResolvedMedia({
26
28
  const alt = propAlt ?? resolved.alt;
27
29
  const kind = resolved.kind;
28
30
 
29
- const mediaClass = cn("h-full w-full", imgClassName);
31
+ const invertClass =
32
+ invertFrom === "light" ? "invert dark:invert-0" :
33
+ invertFrom === "dark" ? "dark:invert" :
34
+ undefined;
35
+
36
+ const mediaClass = cn("h-full w-full", imgClassName, invertClass);
30
37
 
31
38
  return (
32
39
  <div className={className}>
@@ -77,6 +77,7 @@ function MediaGridEditable({ media, columns, square, border, crop, showCaptions,
77
77
  <DoDontImageSettingsForm
78
78
  border={item.border}
79
79
  objectFit={item.objectFit}
80
+ invertFrom={item.invertFrom}
80
81
  doDont={item.doDont}
81
82
  onChange={(updated) => {
82
83
  const newMedia = media.map((m, i) => i === index ? { ...m, ...updated } : m);
@@ -90,6 +91,7 @@ function MediaGridEditable({ media, columns, square, border, crop, showCaptions,
90
91
  <ImageSettingsForm
91
92
  border={item.border}
92
93
  objectFit={item.objectFit}
94
+ invertFrom={item.invertFrom}
93
95
  onChange={(updated) => {
94
96
  const newMedia = media.map((m, i) => i === index ? { ...m, ...updated } : m);
95
97
  onChange({ type: sectionType, content: { columns, media: newMedia }, ...opts } as SectionContent);
@@ -173,6 +175,7 @@ function MediaGridItem({
173
175
  alt={itemAny.alt as string | undefined}
174
176
  className="h-full w-full"
175
177
  imgClassName={fitClass}
178
+ invertFrom={item.invertFrom}
176
179
  />
177
180
  );
178
181
 
@@ -8,6 +8,7 @@ import type { Audience } from "../../auth/types";
8
8
  import type { MediaManifest } from "../../media/types";
9
9
  import type { QueueItem } from "../../media/queue";
10
10
  import { SiteConfigSchema } from "../../schemas/site-config";
11
+ import { ensureSanitizer } from "../../lib/sanitize";
11
12
  import { EditorProvider, useEditorContext } from "./EditorContext";
12
13
  import { EditorModalProvider, useEditorModal } from "./EditorModalContext";
13
14
  import { EditorModal } from "./EditorModal";
@@ -112,6 +113,7 @@ export default function EditorShell({
112
113
  const siteIndexRef = useRef<SiteIndex>({ siteId, order: [], sections: {} });
113
114
  const fontLinkRef = useRef<HTMLLinkElement | null>(null);
114
115
  useEffect(() => { siteIndexRef.current = siteIndex; }, [siteIndex]);
116
+ useEffect(() => { void ensureSanitizer(); }, []);
115
117
 
116
118
  const persistence = useEditorPersistence(siteIndexRef);
117
119
 
@@ -188,6 +190,7 @@ export default function EditorShell({
188
190
  root.style.setProperty("--color-primary-contrast", config.primaryContrast);
189
191
  root.style.setProperty("--font-heading", `${config.headingFont}, system-ui, sans-serif`);
190
192
  root.style.setProperty("--font-body", `${config.bodyFont}, system-ui, sans-serif`);
193
+ root.style.setProperty("--heading-text-transform", config.uppercaseHeadings ? "uppercase" : "none");
191
194
 
192
195
  if (config.googleFontsUrl) {
193
196
  if (fontLinkRef.current?.href !== config.googleFontsUrl) {
@@ -4,7 +4,7 @@ import { cn } from "../../lib/cn";
4
4
  import { Button } from "../shared/Button";
5
5
  import { Select } from "../shared/Select";
6
6
  import type { MediaItem, MediaKind } from "../../media/types";
7
- import { displayFilenameExt, mimeToExt } from "../../media/utils";
7
+ import { displayFilename, mimeToExt } from "../../media/utils";
8
8
 
9
9
  export interface MediaLibraryModalProps {
10
10
  mode: "select" | "manage";
@@ -39,9 +39,6 @@ function thumbnailSrc(item: MediaItem, localUrls: Record<string, string>): strin
39
39
  return `/api/media/${item.id}/poster.webp`;
40
40
  }
41
41
 
42
- function displayFilename(item: MediaItem): string {
43
- return `${item.originalName}${displayFilenameExt(item.mimeType)}`;
44
- }
45
42
 
46
43
  function UploadZone({ onUpload }: { onUpload: (files: File[]) => void }) {
47
44
  const [dragging, setDragging] = useState(false);
@@ -342,7 +339,7 @@ export function MediaLibraryModal({
342
339
  {/* Filename overlay — bottom, visible on hover */}
343
340
  <div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/60 to-transparent px-2 pb-1.5 pt-4 opacity-0 transition-opacity group-hover:opacity-100">
344
341
  <p className="truncate text-[11px] text-white">
345
- {displayFilename(item)}
342
+ {displayFilename(item.originalName, item.mimeType)}
346
343
  </p>
347
344
  </div>
348
345
  </div>
@@ -1,3 +1,4 @@
1
+ import { Checkbox } from "../shared/Checkbox";
1
2
  import { ColorPicker } from "../shared/ColorPicker";
2
3
  import { FontPicker } from "../shared/FontPicker";
3
4
  import { Input } from "../shared/Input";
@@ -68,6 +69,12 @@ export function SiteSettingsDisplay({ siteConfig, onChange }: Props) {
68
69
  onChange={(family) => handleFontChange("headingFont", family)}
69
70
  />
70
71
 
72
+ <Checkbox
73
+ checked={siteConfig.uppercaseHeadings}
74
+ onChange={(v) => update({ uppercaseHeadings: v })}
75
+ label="Uppercase headings"
76
+ />
77
+
71
78
  <FontPicker
72
79
  label="Body font"
73
80
  value={siteConfig.bodyFont}
@@ -29,7 +29,7 @@ type TabId = "users" | "viewer-access" | "display";
29
29
 
30
30
  export function SiteSettingsModal({ isOpen, onClose, siteConfig, onSiteConfigChange, onAudiencesChange, capabilities, currentUser }: Props) {
31
31
  const tabs: { id: TabId; label: string; show: boolean }[] = [
32
- { id: "users", label: "Users", show: capabilities.userManagement },
32
+ { id: "users", label: "Users", show: capabilities.userManagement && currentUser?.role === "owner" },
33
33
  { id: "viewer-access", label: "Viewer Access", show: true },
34
34
  { id: "display", label: "Display", show: true },
35
35
  ];
@@ -25,8 +25,10 @@ export function useBuildStatus(): BuildStatusResult {
25
25
  const [elapsedSeconds, setElapsedSeconds] = useState(0);
26
26
  const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
27
27
  const clearRef = useRef<ReturnType<typeof setTimeout> | null>(null);
28
+ const fadeRef = useRef<ReturnType<typeof setTimeout> | null>(null);
28
29
  const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
29
30
  const isPolling = useRef(false);
31
+ const mountedRef = useRef(true);
30
32
 
31
33
  const stopTimer = useCallback(() => {
32
34
  if (timerRef.current) { clearInterval(timerRef.current); timerRef.current = null; }
@@ -61,6 +63,8 @@ export function useBuildStatus(): BuildStatusResult {
61
63
 
62
64
  const handleStatusUpdate = useCallback(
63
65
  (data: BuildStatusResponse | null, isInitialLoad: boolean) => {
66
+ if (!mountedRef.current) return;
67
+
64
68
  if (!data) {
65
69
  if (isInitialLoad) {
66
70
  setState("idle");
@@ -80,13 +84,17 @@ export function useBuildStatus(): BuildStatusResult {
80
84
  stopTimer();
81
85
  if (data.state === "ready") {
82
86
  clearRef.current = setTimeout(() => {
87
+ if (!mountedRef.current) return;
83
88
  setState("fading");
84
- clearRef.current = setTimeout(() => setState("idle"), FADE_DURATION);
89
+ fadeRef.current = setTimeout(() => {
90
+ if (!mountedRef.current) return;
91
+ setState("idle");
92
+ }, FADE_DURATION);
85
93
  }, AUTO_CLEAR_DELAY);
86
94
  }
87
95
  }
88
96
  },
89
- [stopPolling],
97
+ [stopPolling, stopTimer],
90
98
  );
91
99
 
92
100
  const startPolling = useCallback(() => {
@@ -117,11 +125,13 @@ export function useBuildStatus(): BuildStatusResult {
117
125
 
118
126
  return () => {
119
127
  cancelled = true;
128
+ mountedRef.current = false;
120
129
  stopPolling();
121
130
  stopTimer();
122
131
  if (clearRef.current) clearTimeout(clearRef.current);
132
+ if (fadeRef.current) clearTimeout(fadeRef.current);
123
133
  };
124
- }, [fetchStatus, handleStatusUpdate, startPolling, stopPolling]);
134
+ }, [fetchStatus, handleStatusUpdate, startPolling, stopPolling, stopTimer]);
125
135
 
126
136
  const startTracking = useCallback(() => {
127
137
  if (clearRef.current) { clearTimeout(clearRef.current); clearRef.current = null; }
@@ -25,15 +25,27 @@ export function useEditorPersistence(siteIndexRef: React.RefObject<SiteIndex>) {
25
25
  sectionId,
26
26
  content,
27
27
  }));
28
+ const wasIndexDirty = s.indexDirty;
28
29
 
30
+ // Clear optimistically so new edits during the await go into a fresh set
29
31
  s.pendingSections = new Map();
30
- const wasIndexDirty = s.indexDirty;
31
32
  s.indexDirty = false;
32
33
 
33
- await persistAll(
34
- entries,
35
- wasIndexDirty ? siteIndexRef.current : undefined,
36
- );
34
+ try {
35
+ await persistAll(
36
+ entries,
37
+ wasIndexDirty ? siteIndexRef.current : undefined,
38
+ );
39
+ } catch (err) {
40
+ // Restore: merge back any entries that weren't re-dirtied during the await
41
+ for (const { sectionId, content } of entries) {
42
+ if (!s.pendingSections.has(sectionId)) {
43
+ s.pendingSections.set(sectionId, content);
44
+ }
45
+ }
46
+ if (wasIndexDirty) s.indexDirty = true;
47
+ console.error("Failed to flush to Dexie:", err);
48
+ }
37
49
  }, [siteIndexRef]);
38
50
 
39
51
  const scheduleFlush = useCallback(() => {
@@ -44,8 +56,9 @@ export function useEditorPersistence(siteIndexRef: React.RefObject<SiteIndex>) {
44
56
  useEffect(() => {
45
57
  return () => {
46
58
  if (flushTimerRef.current) clearTimeout(flushTimerRef.current);
59
+ void flushToDexie();
47
60
  };
48
- }, []);
61
+ }, [flushToDexie]);
49
62
 
50
63
  const markSectionDirty = useCallback(
51
64
  (sectionId: string, content: SectionContent) => {
@@ -62,6 +62,7 @@ export function useEditorPublish({
62
62
  const [publishAction, setPublishAction] = useState<PublishAction>("idle");
63
63
  const [publishFeedback, setPublishFeedback] = useState<string | null>(null);
64
64
  const feedbackTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
65
+ const inFlightRef = useRef(false);
65
66
 
66
67
  useEffect(() => {
67
68
  return () => {
@@ -184,11 +185,16 @@ export function useEditorPublish({
184
185
  throw new Error(errorBody.error || "Save failed");
185
186
  }
186
187
 
187
- return response.json();
188
+ const responseData = await response.json().catch(() => null);
189
+ if (!responseData?.sha) {
190
+ throw new Error("Save response missing SHA");
191
+ }
192
+ return responseData;
188
193
  }
189
194
 
190
195
  const handleSave = useCallback(async () => {
191
- if (!siteConfig) return;
196
+ if (!siteConfig || inFlightRef.current) return;
197
+ inFlightRef.current = true;
192
198
 
193
199
  setPublishAction("saving");
194
200
  setPublishFeedback(null);
@@ -222,9 +228,10 @@ export function useEditorPublish({
222
228
  console.error("Save failed:", error);
223
229
  showFeedback("Save failed", 5000);
224
230
  } finally {
231
+ inFlightRef.current = false;
225
232
  setPublishAction("idle");
226
233
  }
227
- }, [flushNow, cancelPendingFlush, isConfigDirty, clearConfigDirty, siteIndexRef, siteConfig, sections, deletedSectionIds, onSuccess, mediaManifest, pendingMediaItems, pendingMediaDeletions, onMediaPublished, onShasUpdated, showFeedback]);
234
+ }, [flushNow, cancelPendingFlush, isConfigDirty, clearConfigDirty, siteIndexRef, siteConfig, deletedSectionIds, onSuccess, mediaManifest, pendingMediaItems, pendingMediaDeletions, onMediaPublished, onShasUpdated, showFeedback]);
228
235
 
229
236
  const handlePublish = useCallback(async () => {
230
237
  setPublishAction("publishing");
@@ -241,7 +248,11 @@ export function useEditorPublish({
241
248
  throw new Error(errorBody.error || "Publish failed");
242
249
  }
243
250
 
244
- const { sha } = await response.json();
251
+ const responseData = await response.json().catch(() => null);
252
+ if (!responseData?.sha) {
253
+ throw new Error("Publish response missing SHA");
254
+ }
255
+ const { sha } = responseData;
245
256
 
246
257
  onShasUpdated(null, sha);
247
258
  onPublishComplete?.();
@@ -254,7 +265,8 @@ export function useEditorPublish({
254
265
  }, [onShasUpdated, showFeedback, onPublishComplete]);
255
266
 
256
267
  const handleSaveAndPublish = useCallback(async () => {
257
- if (!siteConfig) return;
268
+ if (!siteConfig || inFlightRef.current) return;
269
+ inFlightRef.current = true;
258
270
 
259
271
  setPublishAction("saving");
260
272
  setPublishFeedback(null);
@@ -287,7 +299,11 @@ export function useEditorPublish({
287
299
  throw new Error(errorBody.error || "Publish failed");
288
300
  }
289
301
 
290
- const { sha } = await publishResponse.json();
302
+ const publishData = await publishResponse.json().catch(() => null);
303
+ if (!publishData?.sha) {
304
+ throw new Error("Publish response missing SHA");
305
+ }
306
+ const { sha } = publishData;
291
307
 
292
308
  if (hasLocalEdits) {
293
309
  await discardLocalChanges();
@@ -304,6 +320,7 @@ export function useEditorPublish({
304
320
  console.error("Publish failed:", error);
305
321
  showFeedback("Publish failed", 5000);
306
322
  } finally {
323
+ inFlightRef.current = false;
307
324
  setPublishAction("idle");
308
325
  }
309
326
  }, [flushNow, cancelPendingFlush, isConfigDirty, clearConfigDirty, siteIndexRef, siteConfig, sections, deletedSectionIds, onSuccess, mediaManifest, pendingMediaItems, pendingMediaDeletions, onMediaPublished, onShasUpdated, showFeedback, onPublishComplete]);
@@ -59,6 +59,7 @@ export function useMediaPipeline({
59
59
 
60
60
  const queueRef = useRef<ProcessingQueue | null>(null);
61
61
  const uploadCallbacksRef = useRef<Map<string, (imageId: string) => void>>(new Map());
62
+ const destroyedRef = useRef(false);
62
63
 
63
64
  // --- Processing queue ---
64
65
 
@@ -68,6 +69,7 @@ export function useMediaPipeline({
68
69
 
69
70
  useEffect(() => {
70
71
  if (!siteConfig) return;
72
+ destroyedRef.current = false;
71
73
  const mediaConfig = siteConfig.media;
72
74
  const queue = new ProcessingQueue({
73
75
  sizes: mediaConfig.sizes,
@@ -93,6 +95,7 @@ export function useMediaPipeline({
93
95
  width: number,
94
96
  height: number,
95
97
  ) => {
98
+ if (destroyedRef.current) return;
96
99
  const item: LibraryMediaItem = {
97
100
  id: event.item.hash,
98
101
  hash: event.item.hash,
@@ -163,7 +166,11 @@ export function useMediaPipeline({
163
166
  },
164
167
  });
165
168
  queueRef.current = queue;
166
- return () => queue.destroy();
169
+ return () => {
170
+ destroyedRef.current = true;
171
+ queue.destroy();
172
+ uploadCallbacksRef.current = new Map();
173
+ };
167
174
  // eslint-disable-next-line react-hooks/exhaustive-deps -- rebuild only when processing params change, not on every siteConfig reference change
168
175
  }, [mediaConfigKey, setLocalChangesExist]);
169
176
 
@@ -198,14 +205,20 @@ export function useMediaPipeline({
198
205
  const idSet = new Set(ids);
199
206
  const pendingIds = new Set(pendingMediaItems.filter((i) => idSet.has(i.id)).map((i) => i.id));
200
207
 
201
- for (const id of ids) {
202
- if (pendingIds.has(id)) {
203
- await removePendingMediaItem(id);
204
- } else {
205
- await markPendingMediaDeleted(id);
208
+ try {
209
+ for (const id of ids) {
210
+ if (pendingIds.has(id)) {
211
+ await removePendingMediaItem(id);
212
+ } else {
213
+ await markPendingMediaDeleted(id);
214
+ }
206
215
  }
216
+ } catch (err) {
217
+ console.error("Failed to delete media:", err);
218
+ throw err;
207
219
  }
208
220
 
221
+ // Only update React state after all Dexie writes succeed
209
222
  setPendingMediaItems((prev) => prev.filter((i) => !idSet.has(i.id)));
210
223
  setPendingLocalUrls((prev) => {
211
224
  const next = { ...prev };
@@ -271,9 +284,11 @@ export function useMediaPipeline({
271
284
  const mediaConfig = siteConfig.media;
272
285
  if (file.size > mediaConfig.maxFileSize) return;
273
286
 
287
+ const currentQueue = queueRef.current;
274
288
  (async () => {
275
289
  try {
276
290
  const buffer = await file.arrayBuffer();
291
+ if (queueRef.current !== currentQueue) return; // queue was rebuilt, skip
277
292
  const hash = await hashFileBuffer(buffer);
278
293
 
279
294
  const isDeleted = pendingDeletions.includes(hash);
package/src/lib/dexie.ts CHANGED
@@ -184,14 +184,13 @@ export async function restoreLocalChanges(): Promise<{
184
184
 
185
185
  export async function discardLocalChanges(): Promise<void> {
186
186
  const database = getDb();
187
- await database.transaction("rw", [database.sections, database.siteIndex, database.meta, database.siteConfig, database.pendingMedia, database.pendingMediaDeletions, database.mediaManifest], async () => {
187
+ await database.transaction("rw", [database.sections, database.siteIndex, database.meta, database.siteConfig, database.pendingMedia, database.pendingMediaDeletions], async () => {
188
188
  await database.sections.clear();
189
189
  await database.siteIndex.clear();
190
190
  await database.meta.clear();
191
191
  await database.siteConfig.clear();
192
192
  await database.pendingMedia.clear();
193
193
  await database.pendingMediaDeletions.clear();
194
- await database.mediaManifest.clear();
195
194
  });
196
195
  }
197
196
 
package/src/lib/index.ts CHANGED
@@ -2,7 +2,7 @@ export { env } from "./env";
2
2
  export { cn } from "./cn";
3
3
  export { generateNavLinks, toSectionId, type NavItem } from "./nav";
4
4
  export { deriveContrast } from "./contrast";
5
- export { sanitizeHtml } from "./sanitize";
5
+ export { sanitizeHtml, ensureSanitizer } from "./sanitize";
6
6
  export { gridColsClass } from "./grid";
7
7
  export { getIcon, curatedIcons, type IconEntry } from "./icons";
8
8
  export { buildGoogleFontsUrl } from "./google-fonts";
package/src/lib/loader.ts CHANGED
@@ -24,6 +24,7 @@ export function mergeSiteContent(
24
24
  const sections: LoadedSection[] = [];
25
25
 
26
26
  const canValidate = getAllSchemas().length >= 2;
27
+ const schema = canValidate ? getSectionSchema() : null;
27
28
 
28
29
  for (const id of index.order) {
29
30
  const raw = sectionFiles[id];
@@ -31,8 +32,8 @@ export function mergeSiteContent(
31
32
  console.warn(`Section file missing for id: ${id}, skipping`);
32
33
  continue;
33
34
  }
34
- if (canValidate) {
35
- const result = getSectionSchema().safeParse(raw);
35
+ if (canValidate && schema) {
36
+ const result = schema.safeParse(raw);
36
37
  if (!result.success) {
37
38
  const type = (raw as Record<string, unknown>).type ?? "unknown";
38
39
  console.warn(`Skipping section "${id}" (type: ${type}): invalid schema`);
package/src/lib/nav.ts CHANGED
@@ -9,11 +9,19 @@ export interface NavItem {
9
9
  }
10
10
 
11
11
  export function toSectionId(text: string): string {
12
- return text
12
+ const slug = text
13
+ .normalize("NFKD")
14
+ .replace(/[̀-ͯ]/g, "")
13
15
  .toLowerCase()
14
16
  .replace(/[^\w\s-]/g, "")
15
- .trim()
16
- .replace(/\s+/g, "-");
17
+ .replace(/\s+/g, "-")
18
+ .replace(/^-+|-+$/g, "");
19
+ if (slug) return slug;
20
+ let hash = 0;
21
+ for (let i = 0; i < text.length; i++) {
22
+ hash = ((hash << 5) - hash + text.charCodeAt(i)) | 0;
23
+ }
24
+ return `section-${Math.abs(hash).toString(36)}`;
17
25
  }
18
26
 
19
27
  export function generateNavLinks(
@@ -37,6 +45,10 @@ export function generateNavLinks(
37
45
  if (!role) continue;
38
46
 
39
47
  if (role === "h1") {
48
+ if (content.excludeFromNav) {
49
+ currentParent = null;
50
+ continue;
51
+ }
40
52
  currentParent = {
41
53
  href: `#${toSectionId(content.heading)}`,
42
54
  label: content.heading,
@@ -1,10 +1,29 @@
1
- let purifier: { sanitize: (html: string) => string } | undefined;
1
+ let purifierPromise: Promise<typeof import("dompurify")> | null = null;
2
+ let purifier: ((html: string) => string) | null = null;
2
3
 
3
4
  if (typeof window !== "undefined") {
4
- import("dompurify").then((m) => { purifier = m.default; });
5
+ purifierPromise = import("dompurify").then((mod) => {
6
+ const DOMPurify = mod.default ?? mod;
7
+ purifier = (html: string) => DOMPurify.sanitize(html);
8
+ return mod;
9
+ });
5
10
  }
6
11
 
12
+ /**
13
+ * Synchronous sanitizer — returns sanitized HTML if DOMPurify has loaded,
14
+ * otherwise returns raw HTML. Call `ensureSanitizer()` during component mount
15
+ * to guarantee the purifier is ready before first render.
16
+ */
7
17
  export function sanitizeHtml(html: string): string {
8
18
  if (!html) return "";
9
- return purifier ? purifier.sanitize(html) : html;
19
+ return purifier ? purifier(html) : html;
20
+ }
21
+
22
+ /**
23
+ * Await this during component mount (e.g. useEffect) to guarantee
24
+ * DOMPurify is loaded before `sanitizeHtml` is called.
25
+ */
26
+ export async function ensureSanitizer(): Promise<void> {
27
+ if (typeof window === "undefined") return;
28
+ if (!purifier && purifierPromise) await purifierPromise;
10
29
  }
package/src/lib/text.ts CHANGED
@@ -1,5 +1,9 @@
1
1
  export function stripHtmlToPlainText(html: string): string {
2
- return html.replace(/<\/[^>]+>/g, " ").replace(/<[^>]*>/g, "").replace(/\s+/g, " ").trim();
2
+ return html
3
+ .replace(/<(script|style)\b[^>]*>[\s\S]*?<\/\1>/gi, "")
4
+ .replace(/<[^>]+>/g, " ")
5
+ .replace(/\s+/g, " ")
6
+ .trim();
3
7
  }
4
8
 
5
9
  export function truncate(text: string, maxLength: number): string {
@@ -156,7 +156,9 @@ export class ProcessingQueue {
156
156
  }
157
157
 
158
158
  destroy(): void {
159
- for (const [id, worker] of this.activeWorkers) {
159
+ for (const [id, worker] of Array.from(this.activeWorkers)) {
160
+ worker.onmessage = null;
161
+ worker.onerror = null;
160
162
  worker.terminate();
161
163
  this.activeWorkers.delete(id);
162
164
  }
@@ -46,3 +46,11 @@ export function displayFilenameExt(mime: string): string {
46
46
  const ext = MIME_TO_EXT[mime];
47
47
  return ext ? `.${ext}` : "";
48
48
  }
49
+
50
+ export function displayFilename(originalName: string, mimeType: string): string {
51
+ const ext = MIME_TO_EXT[mimeType];
52
+ if (!ext) return originalName;
53
+ const suffix = `.${ext}`;
54
+ if (originalName.endsWith(suffix)) return originalName;
55
+ return `${originalName}${suffix}`;
56
+ }