@drawnagency/primitives 0.1.71 → 0.1.72
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-RTYJZAKR.js → chunk-2HZB675H.js} +12 -12
- package/dist/chunk-7SIZ2LV5.js +117 -0
- package/dist/{chunk-2BHPWO7M.js → chunk-BTGHF3SW.js} +158 -32
- package/dist/{chunk-3DCG4DNU.js → chunk-PZJL2S3Z.js} +8 -4
- package/dist/chunk-RBTSUR5V.js +37 -0
- package/dist/{chunk-NR7UB6DZ.js → chunk-SFKYUKVN.js} +1 -1
- package/dist/{chunk-YMVYTYGQ.js → chunk-SWIHUIFT.js} +28 -16
- package/dist/{chunk-6UDYJQL6.js → chunk-TOBPMMRM.js} +1 -1
- package/dist/{chunk-MU3CDERQ.js → chunk-UBK2IZ6G.js} +276 -96
- package/dist/{chunk-WXVKX6PY.js → chunk-VHINNGIF.js} +41 -95
- package/dist/{chunk-KWBM27UR.js → chunk-WCALKWPO.js} +4 -59
- package/dist/{chunk-GKV77AA6.js → chunk-ZRWGV5O6.js} +3 -3
- package/dist/chunk-ZSPCE6MZ.js +70 -0
- package/dist/components/editor/SettingsForm.d.ts.map +1 -1
- package/dist/components/editor/index.js +6 -3
- package/dist/components/primitives/index.js +7 -7
- package/dist/components/sections/Video/VideoBlock.d.ts +31 -0
- package/dist/components/sections/Video/VideoBlock.d.ts.map +1 -0
- package/dist/components/sections/Video/index.d.ts +28 -0
- package/dist/components/sections/Video/index.d.ts.map +1 -0
- package/dist/components/sections/all-sections.d.ts +25 -0
- package/dist/components/sections/all-sections.d.ts.map +1 -1
- package/dist/components/sections/brand-guide.js +7 -7
- package/dist/components/sections/core-sections.d.ts +25 -0
- package/dist/components/sections/core-sections.d.ts.map +1 -1
- package/dist/components/sections/register-schemas.js +13 -12
- package/dist/components/sections/register.js +13 -12
- package/dist/components/shared/Tabs.d.ts +1 -1
- package/dist/components/shared/VideoSourceField.d.ts +8 -0
- package/dist/components/shared/VideoSourceField.d.ts.map +1 -0
- package/dist/components/shell/EditorShell.d.ts.map +1 -1
- package/dist/components/shell/MediaLibraryModal.d.ts +4 -1
- package/dist/components/shell/MediaLibraryModal.d.ts.map +1 -1
- package/dist/hooks/useEditorPublish.d.ts.map +1 -1
- package/dist/hooks/useMediaPipeline.d.ts +9 -1
- package/dist/hooks/useMediaPipeline.d.ts.map +1 -1
- package/dist/index.js +24 -24
- package/dist/lib/index.js +10 -10
- package/dist/lib/registry.d.ts +6 -0
- package/dist/lib/registry.d.ts.map +1 -1
- package/dist/lib/video-url.d.ts +7 -0
- package/dist/lib/video-url.d.ts.map +1 -0
- package/dist/lib/video-url.js +9 -0
- package/dist/media/index.js +2 -2
- package/dist/media/videoPoster.d.ts +1 -0
- package/dist/media/videoPoster.d.ts.map +1 -1
- package/dist/schemas/index.js +4 -4
- package/dist/schemas/link.d.ts.map +1 -1
- package/dist/schemas/link.js +1 -1
- package/dist/schemas/media.d.ts +13 -0
- package/dist/schemas/media.d.ts.map +1 -1
- package/dist/schemas/shared.d.ts.map +1 -1
- package/dist/schemas/site-config.d.ts +1 -0
- package/dist/schemas/site-config.d.ts.map +1 -1
- package/dist/storage/index.d.ts +1 -1
- package/dist/storage/index.d.ts.map +1 -1
- package/dist/storage/types.d.ts +26 -0
- package/dist/storage/types.d.ts.map +1 -1
- package/package.json +5 -1
- package/src/components/editor/SettingsForm.tsx +11 -0
- package/src/components/sections/Video/VideoBlock.tsx +149 -0
- package/src/components/sections/Video/index.tsx +111 -0
- package/src/components/sections/core-sections.ts +2 -0
- package/src/components/shared/Tabs.tsx +2 -2
- package/src/components/shared/VideoSourceField.tsx +144 -0
- package/src/components/shell/EditorShell.tsx +8 -0
- package/src/components/shell/MediaLibraryModal.tsx +47 -1
- package/src/hooks/useEditorPublish.ts +3 -0
- package/src/hooks/useMediaPipeline.ts +188 -25
- package/src/lib/registry.ts +7 -0
- package/src/lib/video-url.ts +39 -0
- package/src/media/videoPoster.ts +2 -2
- package/src/schemas/link.ts +15 -4
- package/src/schemas/media.ts +12 -0
- package/src/schemas/shared.ts +23 -14
- package/src/storage/index.ts +1 -1
- package/src/storage/types.ts +23 -0
- package/dist/chunk-4E66N6UV.js +0 -10
- package/dist/chunk-I5737FJY.js +0 -62
|
@@ -24,6 +24,10 @@ interface UseMediaPipelineInput {
|
|
|
24
24
|
setLocalChangesExist: React.Dispatch<React.SetStateAction<boolean>>;
|
|
25
25
|
setDirtySectionIds: React.Dispatch<React.SetStateAction<Set<string>>>;
|
|
26
26
|
markSectionDirty: (sectionId: string, content: SectionContent) => void;
|
|
27
|
+
// Surfaces upload rejections/failures (oversized files, disallowed types,
|
|
28
|
+
// upload-intent errors, direct-to-bucket PUT failures) to the caller, which
|
|
29
|
+
// decides how to display them (e.g. the media modal's rejection banner).
|
|
30
|
+
onUploadError?: (message: string) => void;
|
|
27
31
|
}
|
|
28
32
|
|
|
29
33
|
export function useMediaPipeline({
|
|
@@ -35,11 +39,16 @@ export function useMediaPipeline({
|
|
|
35
39
|
setLocalChangesExist,
|
|
36
40
|
setDirtySectionIds,
|
|
37
41
|
markSectionDirty,
|
|
42
|
+
onUploadError,
|
|
38
43
|
}: UseMediaPipelineInput) {
|
|
39
44
|
const [processingItems, setProcessingItems] = useState<QueueItem[]>([]);
|
|
40
45
|
const [pendingMediaItems, setPendingMediaItems] = useState<LibraryMediaItem[]>([]);
|
|
41
46
|
const [pendingLocalUrls, setPendingLocalUrls] = useState<Record<string, string>>({});
|
|
42
47
|
const [pendingDeletions, setPendingDeletions] = useState<string[]>([]);
|
|
48
|
+
// Direct-to-bucket large-video PUT progress, keyed by content hash (0..1).
|
|
49
|
+
// Populated by uploadLargeVideo's XHR upload.onprogress, cleared on
|
|
50
|
+
// completion or failure.
|
|
51
|
+
const [uploadProgress, setUploadProgress] = useState<Record<string, number>>({});
|
|
43
52
|
// True when the manifest holds unsaved edits that produce no upload/deletion —
|
|
44
53
|
// e.g. alt text on an already-published image. Without this the save path's
|
|
45
54
|
// hasMediaChanges check (uploads/deletions only) skips the change and the edit
|
|
@@ -52,6 +61,45 @@ export function useMediaPipeline({
|
|
|
52
61
|
const uploadCallbacksRef = useRef<Map<string, (imageId: string) => void>>(new Map());
|
|
53
62
|
const destroyedRef = useRef(false);
|
|
54
63
|
|
|
64
|
+
// Shared persistence path for every pending media item — the processing
|
|
65
|
+
// queue's small-file finalize AND the direct-to-bucket large-video upload
|
|
66
|
+
// both funnel through this so a Dexie write, the pendingMediaItems/
|
|
67
|
+
// pendingLocalUrls state, and cleanup-on-failure can't drift between them.
|
|
68
|
+
const finalizePendingMedia = useCallback(
|
|
69
|
+
async (
|
|
70
|
+
item: LibraryMediaItem,
|
|
71
|
+
localUrls: Record<string, string>,
|
|
72
|
+
blobsMap: Record<string, Blob>,
|
|
73
|
+
): Promise<boolean> => {
|
|
74
|
+
if (destroyedRef.current) return false;
|
|
75
|
+
try {
|
|
76
|
+
// Persist blobs to IndexedDB BEFORE marking the item pending.
|
|
77
|
+
// Previously this was fire-and-forget: a Save firing before the
|
|
78
|
+
// write landed — or a failed write (e.g. storage quota) — left the
|
|
79
|
+
// item promoted into the manifest with no backing file, an orphan
|
|
80
|
+
// that dropped out on the next render and blocked re-adding it.
|
|
81
|
+
await addPendingMediaItem(item, localUrls, blobsMap);
|
|
82
|
+
} catch (err) {
|
|
83
|
+
console.error(`[useMediaPipeline] Failed to persist media "${item.originalName}":`, err);
|
|
84
|
+
for (const url of Object.values(localUrls)) URL.revokeObjectURL(url);
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
if (destroyedRef.current) return false;
|
|
88
|
+
removePendingMediaDeletion(item.id);
|
|
89
|
+
setPendingDeletions((prev) => prev.filter((d) => d !== item.id));
|
|
90
|
+
setPendingMediaItems((prev) => [...prev, item]);
|
|
91
|
+
const displayKey = item.kind === "video"
|
|
92
|
+
? "primary"
|
|
93
|
+
: Object.keys(localUrls).find((k) => k !== "primary" && k !== "poster") ?? "primary";
|
|
94
|
+
if (localUrls[displayKey]) {
|
|
95
|
+
setPendingLocalUrls((prev) => ({ ...prev, [item.id]: localUrls[displayKey] }));
|
|
96
|
+
}
|
|
97
|
+
setLocalChangesExist(true);
|
|
98
|
+
return true;
|
|
99
|
+
},
|
|
100
|
+
[setLocalChangesExist],
|
|
101
|
+
);
|
|
102
|
+
|
|
55
103
|
// --- Processing queue ---
|
|
56
104
|
|
|
57
105
|
const mediaConfigKey = siteConfig
|
|
@@ -103,31 +151,11 @@ export function useMediaPipeline({
|
|
|
103
151
|
size: v.size,
|
|
104
152
|
})),
|
|
105
153
|
alt: "",
|
|
154
|
+
storage: "github",
|
|
106
155
|
};
|
|
107
156
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
// Previously this was fire-and-forget: a Save firing before the
|
|
111
|
-
// write landed — or a failed write (e.g. storage quota) — left the
|
|
112
|
-
// item promoted into the manifest with no backing file, an orphan
|
|
113
|
-
// that dropped out on the next render and blocked re-adding it.
|
|
114
|
-
await addPendingMediaItem(item, localUrls, blobsMap);
|
|
115
|
-
} catch (err) {
|
|
116
|
-
console.error(`[useMediaPipeline] Failed to persist media "${item.originalName}":`, err);
|
|
117
|
-
for (const url of Object.values(localUrls)) URL.revokeObjectURL(url);
|
|
118
|
-
return;
|
|
119
|
-
}
|
|
120
|
-
if (destroyedRef.current) return;
|
|
121
|
-
removePendingMediaDeletion(item.id);
|
|
122
|
-
setPendingDeletions((prev) => prev.filter((d) => d !== item.id));
|
|
123
|
-
setPendingMediaItems((prev) => [...prev, item]);
|
|
124
|
-
const displayKey = kind === "video"
|
|
125
|
-
? "primary"
|
|
126
|
-
: Object.keys(localUrls).find((k) => k !== "primary" && k !== "poster") ?? "primary";
|
|
127
|
-
if (localUrls[displayKey]) {
|
|
128
|
-
setPendingLocalUrls((prev) => ({ ...prev, [item.id]: localUrls[displayKey] }));
|
|
129
|
-
}
|
|
130
|
-
setLocalChangesExist(true);
|
|
157
|
+
const persisted = await finalizePendingMedia(item, localUrls, blobsMap);
|
|
158
|
+
if (!persisted) return;
|
|
131
159
|
|
|
132
160
|
const callback = uploadCallbacksRef.current.get(event.item.hash);
|
|
133
161
|
if (callback) {
|
|
@@ -179,6 +207,121 @@ export function useMediaPipeline({
|
|
|
179
207
|
|
|
180
208
|
// --- Media handlers ---
|
|
181
209
|
|
|
210
|
+
// Direct-to-bucket path for videos over the git-path cap (maxFileSize): mint
|
|
211
|
+
// a signed URL, PUT the original straight to the bucket (with progress),
|
|
212
|
+
// then persist a pending item whose ONLY Dexie blob is the poster — the
|
|
213
|
+
// original never rides the base64 /api/save payload.
|
|
214
|
+
// Every exit path below must (1) never throw out to the caller — so the
|
|
215
|
+
// handleMediaUpload batch loop always advances to the next file — (2) call
|
|
216
|
+
// onUploadError exactly once on failure, and (3) leave no leaked
|
|
217
|
+
// uploadProgress[hash] entry. Each network/decode stage below gets its own
|
|
218
|
+
// try/catch so it can report a stage-specific, actionable message and
|
|
219
|
+
// `return` (never rethrow); the outer try/catch is a last-resort net for
|
|
220
|
+
// anything upstream of those stages (e.g. hashing), and the single outer
|
|
221
|
+
// `finally` is the one place progress gets cleared, regardless of which
|
|
222
|
+
// stage — or none — ran.
|
|
223
|
+
const uploadLargeVideo = useCallback(async (file: File, mediaConfig: SiteConfig["media"]) => {
|
|
224
|
+
let hash: string | undefined;
|
|
225
|
+
try {
|
|
226
|
+
const buffer = await file.arrayBuffer();
|
|
227
|
+
hash = await hashFileBuffer(buffer);
|
|
228
|
+
|
|
229
|
+
const isDeleted = pendingDeletions.includes(hash);
|
|
230
|
+
const existsInManifest = hash in mediaManifest.images;
|
|
231
|
+
const existsInPending = pendingMediaItems.some((i) => i.hash === hash);
|
|
232
|
+
if ((existsInManifest || existsInPending) && !isDeleted) return;
|
|
233
|
+
|
|
234
|
+
if (file.size > mediaConfig.maxAssetSize) {
|
|
235
|
+
onUploadError?.(`${file.name} exceeds the ${Math.round(mediaConfig.maxAssetSize / 1024 / 1024)}MB video limit.`);
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
let uploadUrl: string;
|
|
240
|
+
let publicUrl: string;
|
|
241
|
+
try {
|
|
242
|
+
const intentRes = await fetch("/api/media/upload-intent", {
|
|
243
|
+
method: "POST",
|
|
244
|
+
headers: { "Content-Type": "application/json" },
|
|
245
|
+
body: JSON.stringify({ hash, contentType: file.type, size: file.size }),
|
|
246
|
+
});
|
|
247
|
+
if (!intentRes.ok) {
|
|
248
|
+
const body = await intentRes.json().catch(() => ({}) as { message?: string });
|
|
249
|
+
onUploadError?.(body.message ?? "Video upload is not available on this site.");
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
({ uploadUrl, publicUrl } = await intentRes.json());
|
|
253
|
+
} catch (err) {
|
|
254
|
+
onUploadError?.(`${file.name}: couldn't start the upload — ${(err as Error).message}. Try again.`);
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
try {
|
|
259
|
+
// Direct browser -> bucket PUT with progress (fetch has no upload progress).
|
|
260
|
+
await new Promise<void>((resolvePut, rejectPut) => {
|
|
261
|
+
const xhr = new XMLHttpRequest();
|
|
262
|
+
xhr.open("PUT", uploadUrl);
|
|
263
|
+
xhr.setRequestHeader("Content-Type", file.type);
|
|
264
|
+
xhr.upload.onprogress = (e) => {
|
|
265
|
+
if (e.lengthComputable) setUploadProgress((prev) => ({ ...prev, [hash as string]: e.loaded / e.total }));
|
|
266
|
+
};
|
|
267
|
+
xhr.onload = () => (xhr.status >= 200 && xhr.status < 300 ? resolvePut() : rejectPut(new Error(`upload ${xhr.status}`)));
|
|
268
|
+
xhr.onerror = () => rejectPut(new Error("upload failed"));
|
|
269
|
+
xhr.send(file);
|
|
270
|
+
});
|
|
271
|
+
} catch (err) {
|
|
272
|
+
onUploadError?.(`${file.name}: upload failed — ${(err as Error).message}. Try again.`);
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
try {
|
|
277
|
+
const { posterBlob, width, height, duration } = await generateVideoPoster(file, mediaConfig.quality);
|
|
278
|
+
const sanitizedName = sanitizeMediaName(file.name);
|
|
279
|
+
const item: LibraryMediaItem = {
|
|
280
|
+
id: hash,
|
|
281
|
+
hash,
|
|
282
|
+
kind: "video",
|
|
283
|
+
originalName: sanitizedName,
|
|
284
|
+
width,
|
|
285
|
+
height,
|
|
286
|
+
mimeType: file.type,
|
|
287
|
+
size: file.size,
|
|
288
|
+
folder: `${sanitizedName}-${hash}`,
|
|
289
|
+
variants: [],
|
|
290
|
+
alt: "",
|
|
291
|
+
storage: "assets",
|
|
292
|
+
url: publicUrl,
|
|
293
|
+
// MediaRecorder-sourced webm/mp4 (screen recordings) commonly report
|
|
294
|
+
// duration as Infinity or NaN. JSON.stringify(Infinity) -> null,
|
|
295
|
+
// which ImageManifestSchema's z.number().positive().optional()
|
|
296
|
+
// rejects on the next save — omit the field entirely instead.
|
|
297
|
+
...(Number.isFinite(duration) && duration > 0 ? { duration } : {}),
|
|
298
|
+
};
|
|
299
|
+
// Only the poster blob is persisted — the original lives in the bucket, so
|
|
300
|
+
// save/publish never ships large base64 through the Netlify function.
|
|
301
|
+
const localUrls = { primary: URL.createObjectURL(file), poster: URL.createObjectURL(posterBlob) };
|
|
302
|
+
await finalizePendingMedia(item, localUrls, { poster: posterBlob });
|
|
303
|
+
} catch (err) {
|
|
304
|
+
// The PUT already succeeded, so the original is sitting in the bucket
|
|
305
|
+
// with no pending item pointing at it — an orphan. Task 11's GC prunes
|
|
306
|
+
// unreferenced bucket objects; we don't attempt to delete it here.
|
|
307
|
+
onUploadError?.(`${file.name}: uploaded but couldn't finish processing — try again.`);
|
|
308
|
+
}
|
|
309
|
+
} catch (err) {
|
|
310
|
+
console.error(`[useMediaPipeline] uploadLargeVideo failed unexpectedly for "${file.name}":`, err);
|
|
311
|
+
onUploadError?.(`${file.name}: upload failed unexpectedly. Try again.`);
|
|
312
|
+
} finally {
|
|
313
|
+
if (hash !== undefined) {
|
|
314
|
+
const h = hash;
|
|
315
|
+
setUploadProgress((prev) => {
|
|
316
|
+
if (!(h in prev)) return prev;
|
|
317
|
+
const next = { ...prev };
|
|
318
|
+
delete next[h];
|
|
319
|
+
return next;
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}, [mediaManifest, pendingMediaItems, pendingDeletions, onUploadError, finalizePendingMedia]);
|
|
324
|
+
|
|
182
325
|
const handleMediaUpload = useCallback(async (files: File[]) => {
|
|
183
326
|
if (!siteConfig) return;
|
|
184
327
|
const mediaConfig = siteConfig.media;
|
|
@@ -186,7 +329,26 @@ export function useMediaPipeline({
|
|
|
186
329
|
if (!queue) return;
|
|
187
330
|
|
|
188
331
|
for (const file of files) {
|
|
189
|
-
if (file.size > mediaConfig.maxFileSize)
|
|
332
|
+
if (file.size > mediaConfig.maxFileSize) {
|
|
333
|
+
if (file.type === "video/mp4" || file.type === "video/webm") {
|
|
334
|
+
// uploadLargeVideo already guarantees it never throws (see its own
|
|
335
|
+
// try/catch/finally) and reports failures via onUploadError itself;
|
|
336
|
+
// this catch is defense-in-depth only, so it must NOT call
|
|
337
|
+
// onUploadError again — doing so would double-report.
|
|
338
|
+
try {
|
|
339
|
+
await uploadLargeVideo(file, mediaConfig);
|
|
340
|
+
} catch (err) {
|
|
341
|
+
console.error(`[useMediaPipeline] uploadLargeVideo threw unexpectedly for "${file.name}":`, err);
|
|
342
|
+
}
|
|
343
|
+
} else {
|
|
344
|
+
onUploadError?.(
|
|
345
|
+
file.type.startsWith("video/")
|
|
346
|
+
? `${file.name}: only MP4/WebM videos can be uploaded.`
|
|
347
|
+
: `${file.name} is larger than the ${Math.round(mediaConfig.maxFileSize / 1024 / 1024)}MB media limit.`,
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
190
352
|
|
|
191
353
|
const buffer = await file.arrayBuffer();
|
|
192
354
|
const hash = await hashFileBuffer(buffer);
|
|
@@ -202,7 +364,7 @@ export function useMediaPipeline({
|
|
|
202
364
|
|
|
203
365
|
queue.add({ buffer, originalName: file.name, mimeType: file.type, hash, kind });
|
|
204
366
|
}
|
|
205
|
-
}, [siteConfig, mediaManifest, pendingMediaItems, pendingDeletions]);
|
|
367
|
+
}, [siteConfig, mediaManifest, pendingMediaItems, pendingDeletions, onUploadError, uploadLargeVideo]);
|
|
206
368
|
|
|
207
369
|
const handleMediaDelete = useCallback(async (ids: string[]) => {
|
|
208
370
|
const idSet = new Set(ids);
|
|
@@ -348,6 +510,7 @@ export function useMediaPipeline({
|
|
|
348
510
|
setPendingDeletions,
|
|
349
511
|
manifestDirty,
|
|
350
512
|
setManifestDirty,
|
|
513
|
+
uploadProgress,
|
|
351
514
|
handleMediaUpload,
|
|
352
515
|
handleMediaDelete,
|
|
353
516
|
handleMediaAltChange,
|
package/src/lib/registry.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { ComponentType, ReactNode } from "react";
|
|
|
3
3
|
import type { Audience } from "../schemas/auth";
|
|
4
4
|
import type { LinkValue } from "../schemas/link";
|
|
5
5
|
import type { SectionContent } from "../schemas/sections";
|
|
6
|
+
import type { VideoSource } from "../components/sections/Video/VideoBlock";
|
|
6
7
|
|
|
7
8
|
// --- Settings field types ---
|
|
8
9
|
|
|
@@ -55,6 +56,12 @@ export type SettingsFieldDef =
|
|
|
55
56
|
label: string;
|
|
56
57
|
default: LinkValue;
|
|
57
58
|
target?: "content" | "options";
|
|
59
|
+
}
|
|
60
|
+
| {
|
|
61
|
+
type: "video-source";
|
|
62
|
+
label: string;
|
|
63
|
+
default: VideoSource;
|
|
64
|
+
target?: "content" | "options";
|
|
58
65
|
};
|
|
59
66
|
|
|
60
67
|
export type SettingsSchema = Record<string, SettingsFieldDef>;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export type ParsedVideoUrl = { provider: "youtube" | "vimeo"; id: string };
|
|
2
|
+
|
|
3
|
+
const YT_ID = /^[\w-]{6,20}$/;
|
|
4
|
+
|
|
5
|
+
export function parseVideoUrl(raw: string): ParsedVideoUrl | null {
|
|
6
|
+
let url: URL;
|
|
7
|
+
try {
|
|
8
|
+
url = new URL(raw.trim());
|
|
9
|
+
} catch {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") return null;
|
|
13
|
+
const host = url.hostname.replace(/^www\./, "");
|
|
14
|
+
|
|
15
|
+
if (host === "youtube.com" || host === "m.youtube.com" || host === "youtube-nocookie.com") {
|
|
16
|
+
if (url.pathname === "/watch") {
|
|
17
|
+
const v = url.searchParams.get("v");
|
|
18
|
+
return v && YT_ID.test(v) ? { provider: "youtube", id: v } : null;
|
|
19
|
+
}
|
|
20
|
+
const m = url.pathname.match(/^\/(?:embed|shorts|live)\/([\w-]{6,20})$/);
|
|
21
|
+
return m ? { provider: "youtube", id: m[1] } : null;
|
|
22
|
+
}
|
|
23
|
+
if (host === "youtu.be") {
|
|
24
|
+
const m = url.pathname.match(/^\/([\w-]{6,20})$/);
|
|
25
|
+
return m ? { provider: "youtube", id: m[1] } : null;
|
|
26
|
+
}
|
|
27
|
+
if (host === "vimeo.com" || host === "player.vimeo.com") {
|
|
28
|
+
// Last numeric path segment: /76979871, /video/76979871, /channels/x/76979871
|
|
29
|
+
const m = url.pathname.match(/\/(\d{6,12})$/);
|
|
30
|
+
return m ? { provider: "vimeo", id: m[1] } : null;
|
|
31
|
+
}
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function embedUrl(p: ParsedVideoUrl): string {
|
|
36
|
+
return p.provider === "youtube"
|
|
37
|
+
? `https://www.youtube-nocookie.com/embed/${p.id}`
|
|
38
|
+
: `https://player.vimeo.com/video/${p.id}`;
|
|
39
|
+
}
|
package/src/media/videoPoster.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export function generateVideoPoster(
|
|
2
2
|
blob: Blob,
|
|
3
3
|
quality: number,
|
|
4
|
-
): Promise<{ posterBlob: Blob; width: number; height: number }> {
|
|
4
|
+
): Promise<{ posterBlob: Blob; width: number; height: number; duration: number }> {
|
|
5
5
|
return new Promise((resolve, reject) => {
|
|
6
6
|
const video = document.createElement("video");
|
|
7
7
|
const url = URL.createObjectURL(blob);
|
|
@@ -59,7 +59,7 @@ export function generateVideoPoster(
|
|
|
59
59
|
reject(new Error("Failed to create poster blob"));
|
|
60
60
|
return;
|
|
61
61
|
}
|
|
62
|
-
resolve({ posterBlob, width: w, height: h });
|
|
62
|
+
resolve({ posterBlob, width: w, height: h, duration: video.duration });
|
|
63
63
|
},
|
|
64
64
|
"image/webp",
|
|
65
65
|
quality / 100,
|
package/src/schemas/link.ts
CHANGED
|
@@ -21,12 +21,23 @@ export function isSafeHref(href: string): boolean {
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
export const LinkValueSchema = z.discriminatedUnion("kind", [
|
|
24
|
-
z.object({
|
|
24
|
+
z.object({
|
|
25
|
+
kind: z.literal("external"),
|
|
26
|
+
href: z
|
|
27
|
+
.string()
|
|
28
|
+
.refine(isSafeHref, "Unsafe href scheme")
|
|
29
|
+
.describe("An absolute http(s)/mailto URL, a relative path, or empty (unset). javascript:, data:, and protocol-relative URLs are rejected."),
|
|
30
|
+
target: LinkTargetSchema.describe("Where the link opens: '_self' (same tab) or '_blank' (new tab)."),
|
|
31
|
+
}),
|
|
25
32
|
z.object({
|
|
26
33
|
kind: z.literal("internal"),
|
|
27
|
-
pageId: z.string(),
|
|
28
|
-
anchorSectionId: z
|
|
29
|
-
|
|
34
|
+
pageId: z.string().describe("Id of the page to link to, within the same site."),
|
|
35
|
+
anchorSectionId: z
|
|
36
|
+
.string()
|
|
37
|
+
.nullable()
|
|
38
|
+
.optional()
|
|
39
|
+
.describe("Optional section id on the target page to scroll to; null or omitted links to the page itself."),
|
|
40
|
+
target: LinkTargetSchema.describe("Where the link opens: '_self' (same tab) or '_blank' (new tab)."),
|
|
30
41
|
}),
|
|
31
42
|
]);
|
|
32
43
|
|
package/src/schemas/media.ts
CHANGED
|
@@ -20,6 +20,15 @@ export const MediaItemSchema = z.object({
|
|
|
20
20
|
folder: z.string(),
|
|
21
21
|
variants: z.array(VariantSchema),
|
|
22
22
|
alt: z.string().default(""),
|
|
23
|
+
// Where the original bytes live. "github" = committed to the repo (images,
|
|
24
|
+
// small loops); "assets" = large original in the AssetStore bucket. Legacy
|
|
25
|
+
// manifests have no field — default keeps them on the git path.
|
|
26
|
+
storage: z.enum(["github", "assets"]).default("github"),
|
|
27
|
+
// Public CDN URL for assets-backed originals, baked at upload time so
|
|
28
|
+
// resolution needs no env access in the browser or at SSR.
|
|
29
|
+
url: z.string().optional(),
|
|
30
|
+
// Seconds; videos only. Informational (library UI).
|
|
31
|
+
duration: z.number().positive().optional(),
|
|
23
32
|
});
|
|
24
33
|
|
|
25
34
|
export type MediaItem = z.infer<typeof MediaItemSchema>;
|
|
@@ -34,6 +43,9 @@ export const MediaConfigSchema = z.object({
|
|
|
34
43
|
sizes: z.array(z.number()).default([640, 1080, 1920]),
|
|
35
44
|
maxFileSize: z.number().default(5242880),
|
|
36
45
|
quality: z.number().min(1).max(100).default(85),
|
|
46
|
+
// Per-file cap for bucket-backed video uploads (bytes). The git-path cap is
|
|
47
|
+
// maxFileSize; anything between the two routes to the AssetStore.
|
|
48
|
+
maxAssetSize: z.number().default(209_715_200),
|
|
37
49
|
});
|
|
38
50
|
|
|
39
51
|
export type MediaConfig = z.infer<typeof MediaConfigSchema>;
|
package/src/schemas/shared.ts
CHANGED
|
@@ -33,12 +33,21 @@ export type TextLine = z.infer<typeof TextLineSchema>;
|
|
|
33
33
|
|
|
34
34
|
// --- Media References ---
|
|
35
35
|
const BaseMediaRef = z.object({
|
|
36
|
-
imageId: z.string().default(""),
|
|
37
|
-
caption: z
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
36
|
+
imageId: z.string().default("").describe("Media-library id of the image/video asset."),
|
|
37
|
+
caption: z
|
|
38
|
+
.union([z.string(), z.array(z.string())])
|
|
39
|
+
.optional()
|
|
40
|
+
.describe("Optional caption: a single string, or multiple lines as an array of strings."),
|
|
41
|
+
background: z.string().optional().describe("Optional CSS background color/value shown behind a transparent image."),
|
|
42
|
+
invertFrom: z
|
|
43
|
+
.string()
|
|
44
|
+
.optional()
|
|
45
|
+
.describe("Optional CSS color; the image inverts when the page background matches it (e.g. for dark-mode logo swaps)."),
|
|
46
|
+
border: z.boolean().optional().describe("When true, draw a border around the media."),
|
|
47
|
+
objectFit: z
|
|
48
|
+
.enum(["cover", "contain"])
|
|
49
|
+
.optional()
|
|
50
|
+
.describe("How the image fills its box: 'contain' (fit whole image) or 'cover' (fill and crop)."),
|
|
42
51
|
});
|
|
43
52
|
|
|
44
53
|
const ImageRef = BaseMediaRef.extend({
|
|
@@ -47,22 +56,22 @@ const ImageRef = BaseMediaRef.extend({
|
|
|
47
56
|
|
|
48
57
|
const VideoRef = BaseMediaRef.extend({
|
|
49
58
|
type: z.literal("video"),
|
|
50
|
-
poster: z.string().optional(),
|
|
51
|
-
autoplay: z.boolean().optional(),
|
|
52
|
-
loop: z.boolean().optional(),
|
|
53
|
-
muted: z.boolean().optional(),
|
|
59
|
+
poster: z.string().optional().describe("Optional poster-image URL shown before playback starts."),
|
|
60
|
+
autoplay: z.boolean().optional().describe("When true, the video starts playing automatically."),
|
|
61
|
+
loop: z.boolean().optional().describe("When true, the video repeats when it finishes."),
|
|
62
|
+
muted: z.boolean().optional().describe("When true, the video plays without sound."),
|
|
54
63
|
});
|
|
55
64
|
|
|
56
65
|
const DoDontImageRef = BaseMediaRef.extend({
|
|
57
66
|
type: z.literal("doDontImage"),
|
|
58
|
-
doDont: z.enum(["do", "dont"]),
|
|
67
|
+
doDont: z.enum(["do", "dont"]).describe("Whether this is a 'do' (correct) or 'dont' (incorrect) example."),
|
|
59
68
|
});
|
|
60
69
|
|
|
61
70
|
const LinkedImageRef = BaseMediaRef.extend({
|
|
62
71
|
type: z.literal("linkedImage"),
|
|
63
|
-
href: z.string(),
|
|
64
|
-
target: z.string().optional(),
|
|
65
|
-
linkText: z.string().optional(),
|
|
72
|
+
href: z.string().describe("URL the image links to."),
|
|
73
|
+
target: z.string().optional().describe("Link target, e.g. '_self' or '_blank'."),
|
|
74
|
+
linkText: z.string().optional().describe("Optional accessible link text/label."),
|
|
66
75
|
});
|
|
67
76
|
|
|
68
77
|
export const MediaReferenceSchema = z.discriminatedUnion("type", [
|
package/src/storage/index.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export type { StorageProvider, FileWrite } from "./types";
|
|
1
|
+
export type { StorageProvider, FileWrite, AssetStore } from "./types";
|
|
2
2
|
export { StorageConflictError } from "./types";
|
package/src/storage/types.ts
CHANGED
|
@@ -38,3 +38,26 @@ export type FileWrite = {
|
|
|
38
38
|
content: string | Uint8Array | null;
|
|
39
39
|
encoding?: "utf-8" | "base64";
|
|
40
40
|
};
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Optional large-asset store (Supabase Storage bucket). Holds video originals
|
|
44
|
+
* too big for the git path; deliberately generic so future large kinds (zips)
|
|
45
|
+
* can slot in. All paths are SITE-RELATIVE ("media/{hash}/original.mp4") —
|
|
46
|
+
* implementations prepend the tenant prefix ("sites/{siteId}/") themselves so
|
|
47
|
+
* a caller can never address another tenant's objects.
|
|
48
|
+
*/
|
|
49
|
+
export interface AssetStore {
|
|
50
|
+
/** Mint a signed direct-upload URL for the browser (PUT). */
|
|
51
|
+
createUploadUrl(
|
|
52
|
+
path: string,
|
|
53
|
+
opts: { contentType: string },
|
|
54
|
+
): Promise<{ uploadUrl: string; path: string; publicUrl: string }>;
|
|
55
|
+
|
|
56
|
+
/** Deterministic public CDN URL for an object (no network call). */
|
|
57
|
+
getPublicUrl(path: string): string;
|
|
58
|
+
|
|
59
|
+
delete(paths: string[]): Promise<void>;
|
|
60
|
+
|
|
61
|
+
/** Recursive listing under a site-relative prefix. */
|
|
62
|
+
list(prefix: string): Promise<{ path: string; size: number; updatedAt: string }[]>;
|
|
63
|
+
}
|
package/dist/chunk-4E66N6UV.js
DELETED
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
// src/components/shared/PagesContext.tsx
|
|
2
|
-
import { createContext, useContext } from "react";
|
|
3
|
-
var PagesContext = createContext({ pages: [], getPageHeadings: () => [] });
|
|
4
|
-
function usePages() {
|
|
5
|
-
return useContext(PagesContext);
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
export {
|
|
9
|
-
usePages
|
|
10
|
-
};
|
package/dist/chunk-I5737FJY.js
DELETED
|
@@ -1,62 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
cn
|
|
3
|
-
} from "./chunk-7EOSDFS3.js";
|
|
4
|
-
|
|
5
|
-
// src/components/shared/icons.tsx
|
|
6
|
-
import { GripVertical, Plus, Trash2, X, Settings } from "lucide-react";
|
|
7
|
-
import { GripVertical as GripVertical2, Plus as Plus2, Trash2 as Trash22, X as X2, Settings as Settings2 } from "lucide-react";
|
|
8
|
-
import { jsx } from "react/jsx-runtime";
|
|
9
|
-
function DragHandle({ size = 16 }) {
|
|
10
|
-
return /* @__PURE__ */ jsx(GripVertical, { size });
|
|
11
|
-
}
|
|
12
|
-
function AddIcon({ size = 14 }) {
|
|
13
|
-
return /* @__PURE__ */ jsx(Plus, { size });
|
|
14
|
-
}
|
|
15
|
-
function DeleteIcon({ size = 14 }) {
|
|
16
|
-
return /* @__PURE__ */ jsx(Trash2, { size });
|
|
17
|
-
}
|
|
18
|
-
function SettingsIcon({ size = 14 }) {
|
|
19
|
-
return /* @__PURE__ */ jsx(Settings, { size });
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
// src/components/shared/IconButton.tsx
|
|
23
|
-
import { forwardRef } from "react";
|
|
24
|
-
import { jsx as jsx2 } from "react/jsx-runtime";
|
|
25
|
-
var sizeClasses = {
|
|
26
|
-
sm: "h-chrome-sm w-chrome-sm",
|
|
27
|
-
md: "h-chrome-md w-chrome-md",
|
|
28
|
-
lg: "h-chrome-lg w-chrome-lg"
|
|
29
|
-
};
|
|
30
|
-
var intentClasses = {
|
|
31
|
-
default: "text-base-contrast-light hover:text-base-contrast",
|
|
32
|
-
destructive: "text-destructive hover:text-destructive-hover",
|
|
33
|
-
primary: "text-primary/60 hover:text-primary"
|
|
34
|
-
};
|
|
35
|
-
var IconButton = forwardRef(
|
|
36
|
-
function IconButton2({ icon, label, size = "md", intent = "default", className, ...rest }, ref) {
|
|
37
|
-
return /* @__PURE__ */ jsx2(
|
|
38
|
-
"button",
|
|
39
|
-
{
|
|
40
|
-
ref,
|
|
41
|
-
type: "button",
|
|
42
|
-
className: cn(
|
|
43
|
-
"cursor-pointer flex items-center justify-center rounded transition-colors",
|
|
44
|
-
sizeClasses[size],
|
|
45
|
-
intentClasses[intent],
|
|
46
|
-
className
|
|
47
|
-
),
|
|
48
|
-
"aria-label": label,
|
|
49
|
-
...rest,
|
|
50
|
-
children: icon
|
|
51
|
-
}
|
|
52
|
-
);
|
|
53
|
-
}
|
|
54
|
-
);
|
|
55
|
-
|
|
56
|
-
export {
|
|
57
|
-
DragHandle,
|
|
58
|
-
AddIcon,
|
|
59
|
-
DeleteIcon,
|
|
60
|
-
SettingsIcon,
|
|
61
|
-
IconButton
|
|
62
|
-
};
|