@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.
- package/dist/{chunk-OUFUUBZ4.js → chunk-62OWSJ7V.js} +1 -1
- package/dist/{chunk-V43WVSVS.js → chunk-A4RARGF2.js} +6 -2
- package/dist/{chunk-C2MVDXD7.js → chunk-BU52OBPW.js} +27 -8
- package/dist/{chunk-VCZBZMXU.js → chunk-VY67DS3O.js} +36 -10
- package/dist/components/primitives/MediaSettingsForms.d.ts +6 -2
- package/dist/components/primitives/MediaSettingsForms.d.ts.map +1 -1
- package/dist/components/primitives/ResolvedMedia.d.ts +2 -1
- package/dist/components/primitives/ResolvedMedia.d.ts.map +1 -1
- package/dist/components/shell/EditorShell.d.ts.map +1 -1
- package/dist/components/shell/MediaLibraryModal.d.ts.map +1 -1
- package/dist/components/shell/SiteSettingsDisplay.d.ts.map +1 -1
- package/dist/hooks/useBuildStatus.d.ts.map +1 -1
- package/dist/hooks/useEditorPersistence.d.ts.map +1 -1
- package/dist/hooks/useEditorPublish.d.ts.map +1 -1
- package/dist/hooks/useMediaPipeline.d.ts.map +1 -1
- package/dist/index.js +8 -4
- package/dist/lib/dexie.d.ts.map +1 -1
- package/dist/lib/dexie.js +1 -2
- package/dist/lib/index.d.ts +1 -1
- package/dist/lib/index.d.ts.map +1 -1
- package/dist/lib/index.js +4 -2
- package/dist/lib/loader.d.ts.map +1 -1
- package/dist/lib/nav.d.ts.map +1 -1
- package/dist/lib/sanitize.d.ts +10 -0
- package/dist/lib/sanitize.d.ts.map +1 -1
- package/dist/lib/text.d.ts.map +1 -1
- package/dist/media/index.js +3 -1
- package/dist/media/queue.d.ts.map +1 -1
- package/dist/media/utils.d.ts +1 -0
- package/dist/media/utils.d.ts.map +1 -1
- package/dist/media/videoPoster.d.ts.map +1 -1
- package/dist/schemas/index.js +2 -2
- package/dist/schemas/site-config.d.ts +1 -0
- package/dist/schemas/site-config.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/primitives/MediaSettingsForms.tsx +25 -6
- package/src/components/primitives/ResolvedMedia.tsx +8 -1
- package/src/components/sections/MediaGrid/MediaGrid.tsx +3 -0
- package/src/components/shell/EditorShell.tsx +3 -0
- package/src/components/shell/MediaLibraryModal.tsx +2 -5
- package/src/components/shell/SiteSettingsDisplay.tsx +7 -0
- package/src/components/shell/SiteSettingsModal.tsx +1 -1
- package/src/hooks/useBuildStatus.ts +13 -3
- package/src/hooks/useEditorPersistence.ts +19 -6
- package/src/hooks/useEditorPublish.ts +23 -6
- package/src/hooks/useMediaPipeline.ts +21 -6
- package/src/lib/dexie.ts +1 -2
- package/src/lib/index.ts +1 -1
- package/src/lib/loader.ts +3 -2
- package/src/lib/nav.ts +15 -3
- package/src/lib/sanitize.ts +22 -3
- package/src/lib/text.ts +5 -1
- package/src/media/queue.ts +3 -1
- package/src/media/utils.ts +8 -0
- package/src/media/videoPoster.ts +31 -10
- 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
|
-
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
|
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 () =>
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
12
|
+
const slug = text
|
|
13
|
+
.normalize("NFKD")
|
|
14
|
+
.replace(/[̀-ͯ]/g, "")
|
|
13
15
|
.toLowerCase()
|
|
14
16
|
.replace(/[^\w\s-]/g, "")
|
|
15
|
-
.
|
|
16
|
-
.replace(
|
|
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,
|
package/src/lib/sanitize.ts
CHANGED
|
@@ -1,10 +1,29 @@
|
|
|
1
|
-
let
|
|
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((
|
|
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
|
|
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
|
|
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 {
|
package/src/media/queue.ts
CHANGED
|
@@ -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
|
}
|
package/src/media/utils.ts
CHANGED
|
@@ -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
|
+
}
|