@devultur/convex 1.2.0 → 1.3.1

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/react.js CHANGED
@@ -1,16 +1,16 @@
1
1
  "use client";
2
2
 
3
3
  // src/react.tsx
4
- import React2 from "react";
4
+ import React7 from "react";
5
5
  import {
6
6
  buildMediaUrl,
7
7
  CaptionLocaleLabel,
8
8
  DEFAULT_TOKEN_EXPIRY_SECONDS,
9
9
  MediaStatus,
10
10
  mediaCaptionPath,
11
- mediaPath,
12
- mediaUrl,
13
- SESSION_HEADER,
11
+ mediaPath as mediaPath2,
12
+ mediaUrl as mediaUrl2,
13
+ SESSION_HEADER as SESSION_HEADER2,
14
14
  visibilityFromKey
15
15
  } from "@devultur/core";
16
16
  import {
@@ -29,6 +29,149 @@ async function isSessionMismatch(response) {
29
29
  }
30
30
  }
31
31
 
32
+ // src/media-fetch.ts
33
+ import { SESSION_HEADER } from "@devultur/core";
34
+
35
+ // src/preview-type.ts
36
+ var PREVIEW_KIND_BY_TYPE = /* @__PURE__ */ new Map([
37
+ ["image/png", "image"],
38
+ ["image/jpeg", "image"],
39
+ ["image/webp", "image"],
40
+ ["image/gif", "image"],
41
+ ["image/avif", "image"],
42
+ ["application/pdf", "pdf"],
43
+ ["audio/mpeg", "audio"],
44
+ ["audio/mp4", "audio"],
45
+ ["audio/aac", "audio"],
46
+ ["audio/ogg", "audio"],
47
+ ["audio/wav", "audio"],
48
+ ["audio/webm", "audio"],
49
+ ["audio/flac", "audio"],
50
+ ["text/plain", "text"],
51
+ ["text/csv", "text"],
52
+ ["application/json", "text"]
53
+ ]);
54
+ var TYPE_BY_EXTENSION = /* @__PURE__ */ new Map([
55
+ ["png", "image/png"],
56
+ ["jpg", "image/jpeg"],
57
+ ["jpeg", "image/jpeg"],
58
+ ["webp", "image/webp"],
59
+ ["gif", "image/gif"],
60
+ ["avif", "image/avif"],
61
+ ["pdf", "application/pdf"],
62
+ ["mp3", "audio/mpeg"],
63
+ ["m4a", "audio/mp4"],
64
+ ["aac", "audio/aac"],
65
+ ["ogg", "audio/ogg"],
66
+ ["oga", "audio/ogg"],
67
+ ["opus", "audio/ogg"],
68
+ ["wav", "audio/wav"],
69
+ ["weba", "audio/webm"],
70
+ ["flac", "audio/flac"],
71
+ ["txt", "text/plain"],
72
+ ["csv", "text/csv"],
73
+ ["json", "application/json"]
74
+ ]);
75
+ var OPAQUE_CONTENT_TYPE = "application/octet-stream";
76
+ function normalizeContentType(value) {
77
+ const essence = value?.split(";")[0]?.trim().toLowerCase();
78
+ return essence || null;
79
+ }
80
+ function previewKindOf(contentType) {
81
+ const normalized = normalizeContentType(contentType);
82
+ if (!normalized) return null;
83
+ return PREVIEW_KIND_BY_TYPE.get(normalized) ?? null;
84
+ }
85
+ function expectedContentType(media, declaredType) {
86
+ if (declaredType) return normalizeContentType(declaredType);
87
+ const extension = media.filename?.split(".").pop()?.toLowerCase();
88
+ if (!extension || extension === media.filename?.toLowerCase()) return null;
89
+ return TYPE_BY_EXTENSION.get(extension) ?? null;
90
+ }
91
+ function trustedContentType(expectedType, responseType) {
92
+ const receivedKind = previewKindOf(responseType);
93
+ if (!expectedType) {
94
+ const contentType = receivedKind ? normalizeContentType(responseType) : null;
95
+ return { ok: true, contentType: contentType ?? OPAQUE_CONTENT_TYPE };
96
+ }
97
+ const expectedKind = previewKindOf(expectedType);
98
+ if (!expectedKind) return { ok: false, reason: "unsupported_type" };
99
+ if (receivedKind !== expectedKind) return { ok: false, reason: "type_mismatch" };
100
+ return { ok: true, contentType: normalizeContentType(expectedType) ?? OPAQUE_CONTENT_TYPE };
101
+ }
102
+
103
+ // src/media-fetch.ts
104
+ var MediaFetchError = class extends Error {
105
+ code;
106
+ /** HTTP status, for `http_error`. */
107
+ status;
108
+ constructor(code, message, status) {
109
+ super(message);
110
+ this.name = "MediaFetchError";
111
+ this.code = code;
112
+ this.status = status;
113
+ }
114
+ };
115
+ async function fetchMedia(request) {
116
+ const { buildUrl, token, sessionSecret, reissueToken, signal, isPrivate = true, canReissue = true } = request;
117
+ const headers = isPrivate && sessionSecret ? { [SESSION_HEADER]: sessionSecret } : void 0;
118
+ const response = await fetch(buildUrl(isPrivate ? token ?? void 0 : void 0), { headers, signal });
119
+ if (!isPrivate || !canReissue || response.status !== 401) return response;
120
+ if (!await isSessionMismatch(response)) return response;
121
+ request.onReissue?.();
122
+ const freshToken = await reissueToken();
123
+ if (!freshToken) return response;
124
+ return fetch(buildUrl(freshToken), { headers, signal });
125
+ }
126
+ async function loadMedia(request) {
127
+ const response = await fetchMedia(request);
128
+ if (!response.ok) {
129
+ throw new MediaFetchError("http_error", `Media fetch failed: ${response.status}`, response.status);
130
+ }
131
+ const trusted = trustedContentType(request.contentType, response.headers.get("content-type"));
132
+ if (!trusted.ok) {
133
+ await response.body?.cancel();
134
+ throw new MediaFetchError(trusted.reason, `Refusing to preview: ${trusted.reason}`);
135
+ }
136
+ const bytes = await readMediaBody(response, request.maxBytes);
137
+ return { bytes, contentType: trusted.contentType };
138
+ }
139
+ function tooLarge(maxBytes) {
140
+ return new MediaFetchError("too_large", `Media is larger than ${maxBytes} bytes`);
141
+ }
142
+ async function readMediaBody(response, maxBytes) {
143
+ if (maxBytes === void 0) return new Uint8Array(await response.arrayBuffer());
144
+ const declaredBytes = Number(response.headers.get("content-length") ?? Number.NaN);
145
+ if (declaredBytes > maxBytes) {
146
+ await response.body?.cancel();
147
+ throw tooLarge(maxBytes);
148
+ }
149
+ if (!response.body) return new Uint8Array(0);
150
+ return readCapped(response.body, maxBytes);
151
+ }
152
+ async function readCapped(body, maxBytes) {
153
+ const reader = body.getReader();
154
+ const chunks = [];
155
+ let byteCount = 0;
156
+ for (; ; ) {
157
+ const { done, value } = await reader.read();
158
+ if (done) break;
159
+ byteCount += value.byteLength;
160
+ if (byteCount > maxBytes) {
161
+ await reader.cancel();
162
+ throw tooLarge(maxBytes);
163
+ }
164
+ chunks.push(value);
165
+ }
166
+ const bytes = new Uint8Array(byteCount);
167
+ let offset = 0;
168
+ for (const chunk of chunks) {
169
+ bytes.set(chunk, offset);
170
+ offset += chunk.byteLength;
171
+ }
172
+ return bytes;
173
+ }
174
+
32
175
  // src/media-upload-derive.ts
33
176
  function deriveUploadState(local, videoState, retry) {
34
177
  const { phase, percent, file, failure, media, processRequested } = local;
@@ -159,11 +302,528 @@ function CropUpload({
159
302
  ] });
160
303
  }
161
304
 
305
+ // src/media-resource.ts
306
+ import React2 from "react";
307
+ import { mediaPath, mediaUrl } from "@devultur/core";
308
+ var IDLE = { value: null, contentType: null, isLoading: false, error: null };
309
+ var LOADING = { value: null, contentType: null, isLoading: true, error: null };
310
+ function useMediaResource(media, options) {
311
+ const devultur = useDevultur();
312
+ const { isEnabled, maxBytes, contentType } = options;
313
+ const isPrivate = media?.visibility === "private";
314
+ const isReady = !isPrivate || devultur.token !== null;
315
+ const requestKey = media && isEnabled ? [devultur.baseUrl, media.visibility, mediaPath(media), contentType ?? "", maxBytes ?? ""].join("|") : null;
316
+ const [settled, setSettled] = React2.useState(null);
317
+ const inputsRef = React2.useRef({ media, devultur, options });
318
+ React2.useEffect(() => {
319
+ inputsRef.current = { media, devultur, options };
320
+ });
321
+ React2.useEffect(() => {
322
+ if (!requestKey || !isReady) return;
323
+ const { media: target, devultur: context, options: current } = inputsRef.current;
324
+ if (!target || !context.baseUrl) return;
325
+ const baseUrl = context.baseUrl;
326
+ const controller = new AbortController();
327
+ let value = null;
328
+ loadMedia({
329
+ buildUrl: (viewerToken) => mediaUrl(baseUrl, target, { token: viewerToken }),
330
+ token: context.token,
331
+ sessionSecret: context.sessionSecret,
332
+ reissueToken: context.reissueToken,
333
+ isPrivate: target.visibility === "private",
334
+ signal: controller.signal,
335
+ maxBytes: current.maxBytes,
336
+ contentType: current.contentType
337
+ }).then((loaded) => {
338
+ if (controller.signal.aborted) return;
339
+ value = current.decode(loaded.bytes, loaded.contentType);
340
+ setSettled({ requestKey, value, contentType: loaded.contentType, isLoading: false, error: null });
341
+ }).catch((error) => {
342
+ if (controller.signal.aborted) return;
343
+ const failure = error instanceof Error ? error : new Error(String(error));
344
+ setSettled({ requestKey, value: null, contentType: null, isLoading: false, error: failure });
345
+ });
346
+ return () => {
347
+ controller.abort();
348
+ if (value !== null) current.release?.(value);
349
+ };
350
+ }, [requestKey, isReady]);
351
+ if (!requestKey) return IDLE;
352
+ if (!devultur.baseUrl) throw new Error("Devultur media previews require `baseUrl` on <DevulturProvider>");
353
+ if (settled?.requestKey !== requestKey) return LOADING;
354
+ return { value: settled.value, contentType: settled.contentType, isLoading: false, error: settled.error };
355
+ }
356
+
357
+ // src/media-object-url.ts
358
+ var createObjectUrl = (bytes, contentType) => URL.createObjectURL(new Blob([bytes], { type: contentType }));
359
+ var revokeObjectUrl = (objectUrl) => URL.revokeObjectURL(objectUrl);
360
+ function useMediaObjectUrl(media, options) {
361
+ const { url } = useDevultur();
362
+ const isEnabled = (options?.enabled ?? true) && Boolean(media);
363
+ const isUnverifiedPublic = media?.visibility === "public" && !options?.contentType;
364
+ const resource = useMediaResource(media, {
365
+ isEnabled: isEnabled && !isUnverifiedPublic,
366
+ maxBytes: options?.maxBytes,
367
+ contentType: options?.contentType,
368
+ decode: createObjectUrl,
369
+ release: revokeObjectUrl
370
+ });
371
+ if (media && isEnabled && isUnverifiedPublic) {
372
+ return { objectUrl: url(media), contentType: null, isLoading: false, error: null };
373
+ }
374
+ return {
375
+ objectUrl: resource.value,
376
+ contentType: resource.contentType,
377
+ isLoading: resource.isLoading,
378
+ error: resource.error
379
+ };
380
+ }
381
+
382
+ // src/pdf-context.tsx
383
+ import React3 from "react";
384
+
385
+ // src/preview-labels.ts
386
+ var ERROR_TEXT = {
387
+ http_error: "The file couldn't be loaded.",
388
+ too_large: "The file is too large to preview.",
389
+ unsupported_type: "This file type can't be previewed.",
390
+ type_mismatch: "The file's content doesn't match its type."
391
+ };
392
+ var defaultPreviewLabels = {
393
+ document: (title, pageCount) => `${title}, ${pageCount === 1 ? "1 page" : `${pageCount} pages`}`,
394
+ page: (page, pageCount) => `Page ${page} of ${pageCount}`,
395
+ loading: "Loading\u2026",
396
+ pageCap: (shown, total) => `Showing the first ${shown} of ${total} pages.`,
397
+ error: (code) => ERROR_TEXT[code],
398
+ thumbnail: (title) => title,
399
+ untitledPdf: "PDF",
400
+ untitledMedia: "Preview",
401
+ download: "Download"
402
+ };
403
+ function mergePreviewLabels(...sources) {
404
+ const merged = { ...defaultPreviewLabels };
405
+ for (const source of sources) {
406
+ for (const [key, value] of Object.entries(source ?? {})) {
407
+ if (value !== void 0) merged[key] = value;
408
+ }
409
+ }
410
+ return merged;
411
+ }
412
+
413
+ // src/pdf-context.tsx
414
+ import { jsx as jsx2 } from "react/jsx-runtime";
415
+ var PdfPreviewContext = React3.createContext({});
416
+ function PdfPreviewProvider({
417
+ pdfjs,
418
+ workerSrc,
419
+ labels,
420
+ children
421
+ }) {
422
+ const config = React3.useMemo(() => ({ pdfjs, workerSrc, labels }), [pdfjs, workerSrc, labels]);
423
+ return /* @__PURE__ */ jsx2(PdfPreviewContext.Provider, { value: config, children });
424
+ }
425
+ var loadedModules = /* @__PURE__ */ new WeakMap();
426
+ function loadOnce(loader) {
427
+ const cached = loadedModules.get(loader);
428
+ if (cached) return cached;
429
+ const loading = loader().catch(() => null);
430
+ loadedModules.set(loader, loading);
431
+ return loading;
432
+ }
433
+ function usePdfjs(workerSrcOverride) {
434
+ const { pdfjs: loader, workerSrc } = React3.useContext(PdfPreviewContext);
435
+ const [loaded, setLoaded] = React3.useState(null);
436
+ React3.useEffect(() => {
437
+ if (!loader) return;
438
+ let isCurrent = true;
439
+ loadOnce(loader).then((pdfjs) => {
440
+ if (isCurrent) setLoaded({ loader, pdfjs });
441
+ });
442
+ return () => {
443
+ isCurrent = false;
444
+ };
445
+ }, [loader]);
446
+ if (!loader) return { status: "unavailable" };
447
+ if (loaded?.loader !== loader) return { status: "loading" };
448
+ if (!loaded.pdfjs) return { status: "unavailable" };
449
+ return { status: "ready", pdfjs: loaded.pdfjs, workerSrc: workerSrcOverride ?? workerSrc };
450
+ }
451
+ function usePreviewLabels(labels) {
452
+ const providerLabels = React3.useContext(PdfPreviewContext).labels;
453
+ return mergePreviewLabels(providerLabels, labels);
454
+ }
455
+
456
+ // src/pdf-thumbnail.tsx
457
+ import React6 from "react";
458
+
459
+ // src/pdf-hooks.ts
460
+ import React4 from "react";
461
+
462
+ // src/pdf-render.ts
463
+ var MOBILE_MAX_CANVAS_PIXELS = 5242880;
464
+ var DESKTOP_MAX_CANVAS_PIXELS = 2 ** 25;
465
+ function openPdf(pdfjs, bytes, workerSrc) {
466
+ if (workerSrc) pdfjs.GlobalWorkerOptions.workerSrc = workerSrc;
467
+ const source = { data: bytes.slice(), enableScripting: false, isEvalSupported: false };
468
+ return pdfjs.getDocument(source);
469
+ }
470
+ function isMobileDevice() {
471
+ if (typeof navigator === "undefined") return false;
472
+ return /Android|iPhone|iPad|iPod/.test(navigator.userAgent) || navigator.maxTouchPoints > 1;
473
+ }
474
+ function maxCanvasPixels() {
475
+ return isMobileDevice() ? MOBILE_MAX_CANVAS_PIXELS : DESKTOP_MAX_CANVAS_PIXELS;
476
+ }
477
+ function outputScale(cssWidth, cssHeight, maxPixels) {
478
+ const devicePixelRatio = typeof window !== "undefined" && window.devicePixelRatio || 1;
479
+ const cssPixels = cssWidth * cssHeight;
480
+ if (cssPixels <= 0) return devicePixelRatio;
481
+ if (cssPixels * devicePixelRatio ** 2 <= maxPixels) return devicePixelRatio;
482
+ return Math.sqrt(maxPixels / cssPixels);
483
+ }
484
+ function renderPage(page, canvas, cssWidth) {
485
+ const naturalViewport = page.getViewport({ scale: 1 });
486
+ const cssScale = cssWidth > 0 ? cssWidth / naturalViewport.width : 1;
487
+ const cssViewport = page.getViewport({ scale: cssScale });
488
+ const scale = outputScale(cssViewport.width, cssViewport.height, maxCanvasPixels());
489
+ const viewport = page.getViewport({ scale: cssScale * scale });
490
+ canvas.width = Math.floor(viewport.width);
491
+ canvas.height = Math.floor(viewport.height);
492
+ canvas.style.width = `${Math.floor(cssViewport.width)}px`;
493
+ canvas.style.height = `${Math.floor(cssViewport.height)}px`;
494
+ return page.render({ canvas, viewport });
495
+ }
496
+ async function pageText(page) {
497
+ const { items } = await page.getTextContent();
498
+ return items.map((item) => "str" in item && typeof item.str === "string" ? item.str : "").join(" ").replace(/\s+/g, " ").trim();
499
+ }
500
+ var isRenderCancelled = (error) => error instanceof Error && error.name === "RenderingCancelledException";
501
+
502
+ // src/pdf-hooks.ts
503
+ function useIsVisible(ref, rootMargin = "200px") {
504
+ const [isVisible, setIsVisible] = React4.useState(false);
505
+ React4.useEffect(() => {
506
+ const element = ref.current;
507
+ if (isVisible || !element) return;
508
+ if (typeof IntersectionObserver === "undefined") {
509
+ setIsVisible(true);
510
+ return;
511
+ }
512
+ const observer = new IntersectionObserver(
513
+ (entries) => {
514
+ if (!entries.some((entry) => entry.isIntersecting)) return;
515
+ setIsVisible(true);
516
+ observer.disconnect();
517
+ },
518
+ { rootMargin }
519
+ );
520
+ observer.observe(element);
521
+ return () => observer.disconnect();
522
+ }, [ref, rootMargin, isVisible]);
523
+ return isVisible;
524
+ }
525
+ function usePdfDocument({ pdfjs, bytes, workerSrc }) {
526
+ const [state, setState] = React4.useState({
527
+ bytes: null,
528
+ document: null,
529
+ error: null
530
+ });
531
+ React4.useEffect(() => {
532
+ let isCurrent = true;
533
+ const loadingTask = openPdf(pdfjs, bytes, workerSrc);
534
+ loadingTask.promise.then((document2) => {
535
+ if (isCurrent) setState({ bytes, document: document2, error: null });
536
+ }).catch((error) => {
537
+ if (!isCurrent) return;
538
+ setState({ bytes, document: null, error: error instanceof Error ? error : new Error(String(error)) });
539
+ });
540
+ return () => {
541
+ isCurrent = false;
542
+ void loadingTask.destroy();
543
+ };
544
+ }, [pdfjs, bytes, workerSrc]);
545
+ if (state.bytes !== bytes) return { document: null, error: null };
546
+ return { document: state.document, error: state.error };
547
+ }
548
+ function usePageRender(options) {
549
+ const { document: document2, pageNumber, canvasRef, width, isEnabled, shouldExtractText = false } = options;
550
+ const [state, setState] = React4.useState({ isRendered: false, text: "" });
551
+ React4.useEffect(() => {
552
+ const canvas = canvasRef.current;
553
+ if (!document2 || !isEnabled || !canvas) return;
554
+ let isCurrent = true;
555
+ let page = null;
556
+ let renderTask = null;
557
+ const draw = async () => {
558
+ page = await document2.getPage(pageNumber);
559
+ if (!isCurrent) return;
560
+ renderTask = renderPage(page, canvas, width);
561
+ await renderTask.promise;
562
+ if (isCurrent) setState((previous) => ({ ...previous, isRendered: true }));
563
+ if (!shouldExtractText) return;
564
+ const text = await pageText(page);
565
+ if (isCurrent) setState((previous) => ({ ...previous, text }));
566
+ };
567
+ draw().catch((error) => {
568
+ if (isRenderCancelled(error) || !isCurrent) return;
569
+ console.warn(`[devultur] PDF page ${pageNumber} failed to render`, error);
570
+ });
571
+ return () => {
572
+ isCurrent = false;
573
+ renderTask?.cancel();
574
+ page?.cleanup();
575
+ };
576
+ }, [document2, pageNumber, canvasRef, width, isEnabled, shouldExtractText]);
577
+ return state;
578
+ }
579
+
580
+ // src/pdf-preview.tsx
581
+ import React5 from "react";
582
+ import { Fragment, jsx as jsx3, jsxs as jsxs2 } from "react/jsx-runtime";
583
+ var VISUALLY_HIDDEN = {
584
+ position: "absolute",
585
+ width: 1,
586
+ height: 1,
587
+ margin: -1,
588
+ padding: 0,
589
+ overflow: "hidden",
590
+ clip: "rect(0 0 0 0)",
591
+ whiteSpace: "nowrap",
592
+ border: 0
593
+ };
594
+ var DEFAULT_MAX_PAGES = 500;
595
+ var keepBytes = (bytes) => bytes;
596
+ function PdfPreview(props) {
597
+ const { media, expectedType, maxBytes, renderFallback, loading, labels, title, className, pdfWorkerSrc } = props;
598
+ const pdfjsState = usePdfjs(pdfWorkerSrc);
599
+ const { value: bytes, error } = useMediaResource(media, {
600
+ isEnabled: true,
601
+ maxBytes,
602
+ contentType: expectedType,
603
+ decode: keepBytes
604
+ });
605
+ if (error) return /* @__PURE__ */ jsx3(Fragment, { children: renderFallback(error) });
606
+ if (!bytes || pdfjsState.status === "loading") return /* @__PURE__ */ jsx3(Fragment, { children: loading });
607
+ const label = title ?? media.filename ?? labels.untitledPdf;
608
+ if (pdfjsState.status === "unavailable") return /* @__PURE__ */ jsx3(PdfFrame, { bytes, label, className });
609
+ return /* @__PURE__ */ jsx3(
610
+ PdfCanvasDocument,
611
+ {
612
+ pdfjs: pdfjsState.pdfjs,
613
+ workerSrc: pdfjsState.workerSrc,
614
+ bytes,
615
+ label,
616
+ className,
617
+ renderFallback,
618
+ loading,
619
+ labels,
620
+ maxPages: props.maxPages ?? DEFAULT_MAX_PAGES
621
+ }
622
+ );
623
+ }
624
+ function PdfCanvasDocument(props) {
625
+ const { pdfjs, workerSrc, bytes, label, className, renderFallback, loading, labels, maxPages } = props;
626
+ const { document: document2, error } = usePdfDocument({ pdfjs, bytes, workerSrc });
627
+ const containerRef = React5.useRef(null);
628
+ const [width, setWidth] = React5.useState(null);
629
+ React5.useLayoutEffect(() => {
630
+ if (document2) setWidth(containerRef.current?.clientWidth ?? 0);
631
+ }, [document2]);
632
+ if (error) return /* @__PURE__ */ jsx3(Fragment, { children: renderFallback(error) });
633
+ if (!document2) return /* @__PURE__ */ jsx3(Fragment, { children: loading });
634
+ const shownPageCount = Math.min(document2.numPages, Math.max(1, maxPages));
635
+ const isTruncated = shownPageCount < document2.numPages;
636
+ const pageNumbers = Array.from({ length: shownPageCount }, (_, index) => index + 1);
637
+ return /* @__PURE__ */ jsxs2(
638
+ "div",
639
+ {
640
+ ref: containerRef,
641
+ role: "document",
642
+ "aria-label": labels.document(label, document2.numPages),
643
+ className,
644
+ children: [
645
+ width !== null && pageNumbers.map((pageNumber) => /* @__PURE__ */ jsx3(
646
+ PdfPageView,
647
+ {
648
+ document: document2,
649
+ pageNumber,
650
+ width,
651
+ label: labels.page(pageNumber, document2.numPages)
652
+ },
653
+ pageNumber
654
+ )),
655
+ isTruncated && /* @__PURE__ */ jsxs2("p", { children: [
656
+ labels.pageCap(shownPageCount, document2.numPages),
657
+ " ",
658
+ renderFallback(null)
659
+ ] })
660
+ ]
661
+ }
662
+ );
663
+ }
664
+ function PdfPageView({ document: document2, pageNumber, width, label }) {
665
+ const pageRef = React5.useRef(null);
666
+ const canvasRef = React5.useRef(null);
667
+ const isVisible = useIsVisible(pageRef);
668
+ const { isRendered, text } = usePageRender({
669
+ document: document2,
670
+ pageNumber,
671
+ canvasRef,
672
+ width,
673
+ isEnabled: isVisible,
674
+ shouldExtractText: true
675
+ });
676
+ const placeholderStyle = isRendered ? void 0 : { aspectRatio: "1 / 1.4142", maxWidth: "100%" };
677
+ return /* @__PURE__ */ jsxs2("section", { ref: pageRef, "aria-label": label, style: placeholderStyle, children: [
678
+ /* @__PURE__ */ jsx3("canvas", { ref: canvasRef, style: { display: "block", maxWidth: "100%" } }),
679
+ text && /* @__PURE__ */ jsx3("p", { style: VISUALLY_HIDDEN, children: text })
680
+ ] });
681
+ }
682
+ function PdfFrame({ bytes, label, className }) {
683
+ const [objectUrl, setObjectUrl] = React5.useState(null);
684
+ React5.useEffect(() => {
685
+ const url = URL.createObjectURL(new Blob([bytes], { type: "application/pdf" }));
686
+ setObjectUrl(url);
687
+ return () => URL.revokeObjectURL(url);
688
+ }, [bytes]);
689
+ if (!objectUrl) return null;
690
+ return /* @__PURE__ */ jsx3("iframe", { src: objectUrl, title: label, referrerPolicy: "no-referrer", className });
691
+ }
692
+
693
+ // src/pdf-thumbnail.tsx
694
+ import { Fragment as Fragment2, jsx as jsx4, jsxs as jsxs3 } from "react/jsx-runtime";
695
+ var DEFAULT_THUMBNAIL_WIDTH = 96;
696
+ var DEFAULT_THUMBNAIL_MAX_BYTES = 50 * 1024 * 1024;
697
+ function PdfThumbnail(props) {
698
+ const { media, contentType, width = DEFAULT_THUMBNAIL_WIDTH, fallback = null, loading = null } = props;
699
+ const containerRef = React6.useRef(null);
700
+ const isVisible = useIsVisible(containerRef);
701
+ const pdfjsState = usePdfjs(props.pdfWorkerSrc);
702
+ const labels = usePreviewLabels(props.labels);
703
+ const expectedType = media ? expectedContentType(media, contentType) : null;
704
+ const isPdf = previewKindOf(expectedType) === "pdf";
705
+ const { value: bytes, error } = useMediaResource(media, {
706
+ isEnabled: isVisible && isPdf && pdfjsState.status === "ready",
707
+ maxBytes: props.maxBytes ?? DEFAULT_THUMBNAIL_MAX_BYTES,
708
+ contentType: expectedType ?? void 0,
709
+ decode: keepBytes
710
+ });
711
+ if (!media) return null;
712
+ const isUnavailable = !isPdf || pdfjsState.status === "unavailable" || error !== null;
713
+ const content = () => {
714
+ if (isUnavailable) return fallback;
715
+ if (!bytes || pdfjsState.status !== "ready") return loading;
716
+ return /* @__PURE__ */ jsx4(
717
+ ThumbnailCanvas,
718
+ {
719
+ pdfjs: pdfjsState.pdfjs,
720
+ workerSrc: pdfjsState.workerSrc,
721
+ bytes,
722
+ width,
723
+ label: labels.thumbnail(props.title ?? media.filename ?? labels.untitledPdf),
724
+ fallback,
725
+ loading
726
+ }
727
+ );
728
+ };
729
+ return /* @__PURE__ */ jsx4("div", { ref: containerRef, className: props.className, style: { width }, children: content() });
730
+ }
731
+ function ThumbnailCanvas({ pdfjs, workerSrc, bytes, width, label, fallback, loading }) {
732
+ const canvasRef = React6.useRef(null);
733
+ const { document: document2, error } = usePdfDocument({ pdfjs, bytes, workerSrc });
734
+ const { isRendered } = usePageRender({ document: document2, pageNumber: 1, canvasRef, width, isEnabled: true });
735
+ if (error) return /* @__PURE__ */ jsx4(Fragment2, { children: fallback });
736
+ return /* @__PURE__ */ jsxs3(Fragment2, { children: [
737
+ !isRendered && loading,
738
+ /* @__PURE__ */ jsx4("canvas", { ref: canvasRef, role: "img", "aria-label": label, style: { display: "block" } })
739
+ ] });
740
+ }
741
+
742
+ // src/preview.tsx
743
+ import { VideoPlayer } from "@devultur/react";
744
+ import { Fragment as Fragment3, jsx as jsx5 } from "react/jsx-runtime";
745
+ var DEFAULT_MAX_TEXT_BYTES = 1024 * 1024;
746
+ var DEFAULT_MAX_BYTES = 50 * 1024 * 1024;
747
+ function Preview({ media, contentType, fallback, loading, labels, ...viewProps }) {
748
+ const text = usePreviewLabels(labels);
749
+ if (!media) return null;
750
+ if (media.kind === "video") return /* @__PURE__ */ jsx5(VideoPreview, { media, className: viewProps.className });
751
+ const renderFallback = (error) => {
752
+ const fetchError = error instanceof MediaFetchError ? error : null;
753
+ if (typeof fallback === "function") return fallback(fetchError);
754
+ if (fallback !== void 0) return fallback;
755
+ return /* @__PURE__ */ jsx5(DefaultFallback, { media, error: fetchError, labels: text });
756
+ };
757
+ const expectedType = expectedContentType(media, contentType);
758
+ const kind = previewKindOf(expectedType);
759
+ if (!expectedType || !kind) return /* @__PURE__ */ jsx5(Fragment3, { children: renderFallback(unsupportedType(expectedType)) });
760
+ const loadingNode = loading === void 0 ? /* @__PURE__ */ jsx5(LoadingStatus, { text: text.loading }) : loading;
761
+ const props = { ...viewProps, media, expectedType, renderFallback, loading: loadingNode, labels: text };
762
+ if (kind === "text") return /* @__PURE__ */ jsx5(TextPreview, { ...props });
763
+ if (kind === "pdf") return /* @__PURE__ */ jsx5(PdfPreview, { ...props, maxBytes: props.maxBytes ?? DEFAULT_MAX_BYTES });
764
+ return /* @__PURE__ */ jsx5(BinaryPreview, { ...props, kind });
765
+ }
766
+ var unsupportedType = (contentType) => new MediaFetchError("unsupported_type", `Can't preview ${contentType ?? "an unknown type"} inline`);
767
+ function DefaultFallback({ media, error, labels }) {
768
+ return /* @__PURE__ */ jsx5(DownloadLink, { media, title: error ? labels.error(error.code) : void 0, children: media.filename ?? labels.download });
769
+ }
770
+ function LoadingStatus({ text }) {
771
+ return /* @__PURE__ */ jsx5("span", { role: "status", style: VISUALLY_HIDDEN, children: text });
772
+ }
773
+ function VideoPreview({ media, className }) {
774
+ const videoProps = useVideoProps(media);
775
+ if (!videoProps) return null;
776
+ return /* @__PURE__ */ jsx5(VideoPlayer, { ...videoProps, className });
777
+ }
778
+ function BinaryPreview({
779
+ media,
780
+ kind,
781
+ expectedType,
782
+ maxBytes = DEFAULT_MAX_BYTES,
783
+ renderFallback,
784
+ loading,
785
+ labels,
786
+ title,
787
+ className
788
+ }) {
789
+ const { url } = useDevultur();
790
+ const isPublicImage = kind === "image" && media.visibility === "public";
791
+ const verified = useMediaObjectUrl(media, { enabled: !isPublicImage, contentType: expectedType, maxBytes });
792
+ const objectUrl = isPublicImage ? url(media) : verified.objectUrl;
793
+ if (verified.error) return /* @__PURE__ */ jsx5(Fragment3, { children: renderFallback(verified.error) });
794
+ if (!objectUrl) return /* @__PURE__ */ jsx5(Fragment3, { children: loading });
795
+ const label = title ?? media.filename ?? labels.untitledMedia;
796
+ if (kind === "image") {
797
+ return /* @__PURE__ */ jsx5("img", { src: objectUrl, alt: label, width: media.width, height: media.height, className });
798
+ }
799
+ return /* @__PURE__ */ jsx5("audio", { src: objectUrl, controls: true, "aria-label": label, className });
800
+ }
801
+ var decodeText = (bytes) => new TextDecoder().decode(bytes);
802
+ function TextPreview({
803
+ media,
804
+ expectedType,
805
+ maxTextBytes = DEFAULT_MAX_TEXT_BYTES,
806
+ renderFallback,
807
+ loading,
808
+ title,
809
+ className
810
+ }) {
811
+ const { value: text, error } = useMediaResource(media, {
812
+ isEnabled: true,
813
+ maxBytes: maxTextBytes,
814
+ contentType: expectedType,
815
+ decode: decodeText
816
+ });
817
+ if (error) return /* @__PURE__ */ jsx5(Fragment3, { children: renderFallback(error) });
818
+ if (text === null) return /* @__PURE__ */ jsx5(Fragment3, { children: loading });
819
+ return /* @__PURE__ */ jsx5("pre", { title, className, children: text });
820
+ }
821
+
162
822
  // src/react.tsx
163
- import { Fragment, jsx as jsx2 } from "react/jsx-runtime";
164
- var DevulturContext = React2.createContext(null);
165
- var DevulturApiContext = React2.createContext(null);
166
- var ProgressContext = React2.createContext(null);
823
+ import { Fragment as Fragment4, jsx as jsx6 } from "react/jsx-runtime";
824
+ var DevulturContext = React7.createContext(null);
825
+ var DevulturApiContext = React7.createContext(null);
826
+ var ProgressContext = React7.createContext(null);
167
827
  var REFRESH_BUFFER_MS = 5 * 60 * 1e3;
168
828
  var MIN_REFRESH_MS = 6e4;
169
829
  var DEFAULT_TTL_MS = DEFAULT_TOKEN_EXPIRY_SECONDS * 1e3;
@@ -173,16 +833,16 @@ function extractTokenResult(result) {
173
833
  return { tokenValue, ttlMs };
174
834
  }
175
835
  function DevulturProvider({ api, baseUrl, children }) {
176
- const [token, setToken] = React2.useState(null);
177
- const [sessionSecret] = React2.useState(() => typeof crypto !== "undefined" ? crypto.randomUUID() : "");
836
+ const [token, setToken] = React7.useState(null);
837
+ const [sessionSecret] = React7.useState(() => typeof crypto !== "undefined" ? crypto.randomUUID() : "");
178
838
  const issueViewerToken = useAction(api.issueViewerToken);
179
839
  const createUploadUrlAction = useAction(api.createUploadUrl);
180
840
  const deleteMediaAction = useAction(api.deleteMedia);
181
841
  const confirmUploadAction = useAction(api.confirmUpload ?? api.createUploadUrl);
182
842
  const deleteVideoAction = useAction(api.deleteVideo ?? api.deleteMedia);
183
- const refreshTimerRef = React2.useRef(null);
184
- const reissueInFlightRef = React2.useRef(null);
185
- const scheduleRefresh = React2.useCallback(
843
+ const refreshTimerRef = React7.useRef(null);
844
+ const reissueInFlightRef = React7.useRef(null);
845
+ const scheduleRefresh = React7.useCallback(
186
846
  (ttlMs) => {
187
847
  if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current);
188
848
  const refreshIn = Math.max(ttlMs - REFRESH_BUFFER_MS, MIN_REFRESH_MS);
@@ -196,7 +856,7 @@ function DevulturProvider({ api, baseUrl, children }) {
196
856
  },
197
857
  [issueViewerToken, sessionSecret]
198
858
  );
199
- React2.useEffect(() => {
859
+ React7.useEffect(() => {
200
860
  if (token) return;
201
861
  issueViewerToken({ sessionSecret }).then((result) => {
202
862
  const { tokenValue, ttlMs } = extractTokenResult(result);
@@ -204,7 +864,7 @@ function DevulturProvider({ api, baseUrl, children }) {
204
864
  scheduleRefresh(ttlMs);
205
865
  });
206
866
  }, [token, issueViewerToken, scheduleRefresh, sessionSecret]);
207
- const reissueToken = React2.useCallback(() => {
867
+ const reissueToken = React7.useCallback(() => {
208
868
  if (reissueInFlightRef.current) return reissueInFlightRef.current;
209
869
  const pending = issueViewerToken({ sessionSecret }).then((result) => {
210
870
  const { tokenValue, ttlMs } = extractTokenResult(result);
@@ -219,19 +879,19 @@ function DevulturProvider({ api, baseUrl, children }) {
219
879
  reissueInFlightRef.current = pending;
220
880
  return pending;
221
881
  }, [issueViewerToken, scheduleRefresh, sessionSecret]);
222
- React2.useEffect(() => {
882
+ React7.useEffect(() => {
223
883
  return () => {
224
884
  if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current);
225
885
  };
226
886
  }, []);
227
- const uploadUrl = React2.useCallback(
887
+ const uploadUrl = React7.useCallback(
228
888
  async (file) => {
229
889
  const result = await createUploadUrlAction({ filename: file.name, contentType: file.type });
230
890
  return { url: result.url, key: result.key };
231
891
  },
232
892
  [createUploadUrlAction]
233
893
  );
234
- const upload = React2.useCallback(
894
+ const upload = React7.useCallback(
235
895
  async (file, options) => {
236
896
  const dimensions = await readImageDimensions(file);
237
897
  const target = await createUploadUrlAction({
@@ -252,21 +912,21 @@ function DevulturProvider({ api, baseUrl, children }) {
252
912
  },
253
913
  [createUploadUrlAction, confirmUploadAction, api.confirmUpload]
254
914
  );
255
- const url = React2.useCallback(
915
+ const url = React7.useCallback(
256
916
  (media, options) => {
257
917
  if (!baseUrl) throw new Error("Devultur `url()` requires `baseUrl` on <DevulturProvider>");
258
- return mediaUrl(baseUrl, media, { ...options, token: token ?? void 0 });
918
+ return mediaUrl2(baseUrl, media, { ...options, token: token ?? void 0 });
259
919
  },
260
920
  [baseUrl, token]
261
921
  );
262
- const getCaptionsText = React2.useCallback(
922
+ const getCaptionsText = React7.useCallback(
263
923
  async (media, locale) => {
264
924
  if (!baseUrl) throw new Error("Devultur `getCaptionsText()` requires `baseUrl` on <DevulturProvider>");
265
925
  const path = mediaCaptionPath(media, locale);
266
926
  if (!path) throw new Error("getCaptionsText: the reference is not a video");
267
927
  const isPrivate = media.visibility === "private";
268
928
  const fetchWith = (viewerToken) => fetch(buildMediaUrl(baseUrl, path, { token: viewerToken }), {
269
- headers: sessionSecret ? { [SESSION_HEADER]: sessionSecret } : void 0
929
+ headers: sessionSecret ? { [SESSION_HEADER2]: sessionSecret } : void 0
270
930
  });
271
931
  let response = await fetchWith(isPrivate ? token ?? void 0 : void 0);
272
932
  if (isPrivate && response.status === 401 && await isSessionMismatch(response)) {
@@ -278,17 +938,17 @@ function DevulturProvider({ api, baseUrl, children }) {
278
938
  },
279
939
  [baseUrl, token, reissueToken, sessionSecret]
280
940
  );
281
- const deleteMedia = React2.useCallback(
941
+ const deleteMedia = React7.useCallback(
282
942
  (media) => {
283
943
  if (media.kind === "video" && api.deleteVideo) {
284
944
  deleteVideoAction({ videoId: media.id, visibility: media.visibility }).catch(console.error);
285
945
  return;
286
946
  }
287
- deleteMediaAction({ key: mediaPath(media) }).catch(console.error);
947
+ deleteMediaAction({ key: mediaPath2(media) }).catch(console.error);
288
948
  },
289
949
  [deleteMediaAction, deleteVideoAction, api.deleteVideo]
290
950
  );
291
- const devulturValue = React2.useMemo(
951
+ const devulturValue = React7.useMemo(
292
952
  () => ({
293
953
  token,
294
954
  baseUrl: baseUrl ?? null,
@@ -302,9 +962,9 @@ function DevulturProvider({ api, baseUrl, children }) {
302
962
  }),
303
963
  [token, baseUrl, sessionSecret, uploadUrl, upload, url, getCaptionsText, deleteMedia, reissueToken]
304
964
  );
305
- let content = /* @__PURE__ */ jsx2(DevulturApiContext, { value: api, children: /* @__PURE__ */ jsx2(DevulturContext, { value: devulturValue, children }) });
965
+ let content = /* @__PURE__ */ jsx6(DevulturApiContext, { value: api, children: /* @__PURE__ */ jsx6(DevulturContext, { value: devulturValue, children }) });
306
966
  if (api.saveProgress && api.loadProgress && api.markComplete && api.resetProgress) {
307
- content = /* @__PURE__ */ jsx2(
967
+ content = /* @__PURE__ */ jsx6(
308
968
  ProgressBridge,
309
969
  {
310
970
  save: api.saveProgress,
@@ -328,13 +988,13 @@ function ProgressBridge({
328
988
  const saveMutation = useMutation(save);
329
989
  const markCompleteMutation = useMutation(markComplete);
330
990
  const resetMutation = useMutation(reset);
331
- const onSave = React2.useCallback(
991
+ const onSave = React7.useCallback(
332
992
  async (videoId, position) => {
333
993
  await saveMutation({ videoId, position });
334
994
  },
335
995
  [saveMutation]
336
996
  );
337
- const onLoad = React2.useCallback(
997
+ const onLoad = React7.useCallback(
338
998
  async (videoId) => {
339
999
  const result = await convex.query(load, { videoId });
340
1000
  if (!result) return null;
@@ -345,37 +1005,37 @@ function ProgressBridge({
345
1005
  },
346
1006
  [convex, load]
347
1007
  );
348
- const onMarkComplete = React2.useCallback(
1008
+ const onMarkComplete = React7.useCallback(
349
1009
  async (videoId) => {
350
1010
  await markCompleteMutation({ videoId });
351
1011
  },
352
1012
  [markCompleteMutation]
353
1013
  );
354
- const onReset = React2.useCallback(
1014
+ const onReset = React7.useCallback(
355
1015
  async (videoId) => {
356
1016
  await resetMutation({ videoId });
357
1017
  },
358
1018
  [resetMutation]
359
1019
  );
360
- const value = React2.useMemo(
1020
+ const value = React7.useMemo(
361
1021
  () => ({ save: onSave, load: onLoad, markComplete: onMarkComplete, reset: onReset }),
362
1022
  [onSave, onLoad, onMarkComplete, onReset]
363
1023
  );
364
- return /* @__PURE__ */ jsx2(ProgressContext, { value, children });
1024
+ return /* @__PURE__ */ jsx6(ProgressContext, { value, children });
365
1025
  }
366
1026
  function useDevultur() {
367
- const context = React2.use(DevulturContext);
1027
+ const context = React7.use(DevulturContext);
368
1028
  if (!context) throw new Error("useDevultur must be used within <DevulturProvider>");
369
1029
  return context;
370
1030
  }
371
1031
  function useDevulturProgress() {
372
- const context = React2.use(ProgressContext);
1032
+ const context = React7.use(ProgressContext);
373
1033
  if (!context) throw new Error("useDevulturProgress requires progress API in DevulturProvider");
374
1034
  return context;
375
1035
  }
376
1036
  function useMediaUrl(key, options) {
377
1037
  const { token, baseUrl } = useDevultur();
378
- return React2.useMemo(() => {
1038
+ return React7.useMemo(() => {
379
1039
  if (!key) return null;
380
1040
  if (!baseUrl) throw new Error("useMediaUrl requires `baseUrl` on <DevulturProvider>");
381
1041
  const isPublic = visibilityFromKey(key) === "public";
@@ -403,7 +1063,7 @@ async function readImageDimensions(file) {
403
1063
  function useVideoProps(media, options) {
404
1064
  const { token, baseUrl, sessionSecret } = useDevultur();
405
1065
  const locales = options?.locales;
406
- return React2.useMemo(() => {
1066
+ return React7.useMemo(() => {
407
1067
  if (media?.kind !== "video") return null;
408
1068
  if (!baseUrl) throw new Error("useVideoProps requires `baseUrl` on <DevulturProvider>");
409
1069
  const viewerToken = media.visibility === "private" ? token ?? void 0 : void 0;
@@ -414,7 +1074,7 @@ function useVideoProps(media, options) {
414
1074
  return [{ locale, label, src: buildMediaUrl(baseUrl, path, { token: viewerToken }) }];
415
1075
  });
416
1076
  return {
417
- src: buildMediaUrl(baseUrl, mediaPath(media), { token: viewerToken }),
1077
+ src: buildMediaUrl(baseUrl, mediaPath2(media), { token: viewerToken }),
418
1078
  token: viewerToken,
419
1079
  captions,
420
1080
  sessionSecret: media.visibility === "private" ? sessionSecret : void 0
@@ -434,25 +1094,32 @@ function Image({
434
1094
  alt,
435
1095
  ...imgProps
436
1096
  }) {
437
- const { url, reissueToken, sessionSecret } = useDevultur();
438
- const [failed, setFailed] = React2.useState(false);
439
- const [blobSrc, setBlobSrc] = React2.useState(null);
440
- const retriedRef = React2.useRef(false);
1097
+ const { url, token, baseUrl, reissueToken, sessionSecret } = useDevultur();
1098
+ const [failed, setFailed] = React7.useState(false);
1099
+ const [blobSrc, setBlobSrc] = React7.useState(null);
1100
+ const sessionRef = React7.useRef({ token, reissueToken });
1101
+ React7.useEffect(() => {
1102
+ sessionRef.current = { token, reissueToken };
1103
+ });
441
1104
  const isPrivate = media?.visibility === "private";
442
- React2.useEffect(() => {
443
- retriedRef.current = false;
1105
+ const hasToken = token !== null;
1106
+ React7.useEffect(() => {
444
1107
  setFailed(false);
445
1108
  setBlobSrc(null);
446
1109
  }, [media]);
447
- React2.useEffect(() => {
448
- if (!isPrivate || failed || !media) return;
1110
+ React7.useEffect(() => {
1111
+ if (!isPrivate || failed || !media || !hasToken) return;
449
1112
  let cancelled = false;
450
1113
  let objectUrl = null;
451
- const targetUrl = url(media, { width, height, fit, format, quality });
1114
+ if (!baseUrl) throw new Error("Devultur `url()` requires `baseUrl` on <DevulturProvider>");
1115
+ const transforms = { width, height, fit, format, quality };
452
1116
  (async () => {
453
1117
  try {
454
- const response = await fetch(targetUrl, {
455
- headers: sessionSecret ? { [SESSION_HEADER]: sessionSecret } : void 0
1118
+ const response = await fetchMedia({
1119
+ buildUrl: (viewerToken) => mediaUrl2(baseUrl, media, { ...transforms, token: viewerToken }),
1120
+ token: sessionRef.current.token,
1121
+ sessionSecret,
1122
+ reissueToken: sessionRef.current.reissueToken
456
1123
  });
457
1124
  if (response.ok) {
458
1125
  objectUrl = URL.createObjectURL(await response.blob());
@@ -463,11 +1130,6 @@ function Image({
463
1130
  setBlobSrc(objectUrl);
464
1131
  return;
465
1132
  }
466
- if (response.status === 401 && !retriedRef.current && await isSessionMismatch(response)) {
467
- retriedRef.current = true;
468
- await reissueToken();
469
- return;
470
- }
471
1133
  } catch {
472
1134
  }
473
1135
  if (!cancelled) setFailed(true);
@@ -476,8 +1138,8 @@ function Image({
476
1138
  cancelled = true;
477
1139
  if (objectUrl) URL.revokeObjectURL(objectUrl);
478
1140
  };
479
- }, [isPrivate, failed, media, url, sessionSecret, reissueToken, width, height, fit, format, quality]);
480
- const handleError = React2.useCallback(() => setFailed(true), []);
1141
+ }, [isPrivate, failed, media, hasToken, baseUrl, sessionSecret, width, height, fit, format, quality]);
1142
+ const handleError = React7.useCallback(() => setFailed(true), []);
481
1143
  let src;
482
1144
  if (failed || !media) {
483
1145
  src = externalFallback ?? null;
@@ -486,13 +1148,13 @@ function Image({
486
1148
  } else {
487
1149
  src = url(media, { width, height, fit, format, quality });
488
1150
  }
489
- if (!src) return /* @__PURE__ */ jsx2(Fragment, { children: fallback });
1151
+ if (!src) return /* @__PURE__ */ jsx6(Fragment4, { children: fallback });
490
1152
  if (render) {
491
- return React2.cloneElement(render, {
1153
+ return React7.cloneElement(render, {
492
1154
  style: { ...render.props.style, backgroundImage: `url("${src}")` }
493
1155
  });
494
1156
  }
495
- return /* @__PURE__ */ jsx2(
1157
+ return /* @__PURE__ */ jsx6(
496
1158
  "img",
497
1159
  {
498
1160
  src,
@@ -506,17 +1168,16 @@ function Image({
506
1168
  }
507
1169
  function DownloadLink({ media, filename, children, ...anchorProps }) {
508
1170
  const { url, token, baseUrl, sessionSecret, reissueToken } = useDevultur();
509
- const handleClick = React2.useCallback(
1171
+ const handleClick = React7.useCallback(
510
1172
  async (event) => {
511
1173
  if (media?.visibility !== "private" || !baseUrl) return;
512
1174
  event.preventDefault();
513
- const headers = sessionSecret ? { [SESSION_HEADER]: sessionSecret } : void 0;
514
- const buildUrl = (viewerToken) => mediaUrl(baseUrl, media, { download: filename ?? true, token: viewerToken });
515
- let response = await fetch(buildUrl(token ?? void 0), { headers });
516
- if (response.status === 401 && await isSessionMismatch(response)) {
517
- const fresh = await reissueToken();
518
- if (fresh) response = await fetch(buildUrl(fresh), { headers });
519
- }
1175
+ const response = await fetchMedia({
1176
+ buildUrl: (viewerToken) => mediaUrl2(baseUrl, media, { download: filename ?? true, token: viewerToken }),
1177
+ token,
1178
+ sessionSecret,
1179
+ reissueToken
1180
+ });
520
1181
  if (!response.ok) return;
521
1182
  const objectUrl = URL.createObjectURL(await response.blob());
522
1183
  const anchor = document.createElement("a");
@@ -528,10 +1189,10 @@ function DownloadLink({ media, filename, children, ...anchorProps }) {
528
1189
  [media, baseUrl, token, filename, sessionSecret, reissueToken]
529
1190
  );
530
1191
  if (!media) return null;
531
- return /* @__PURE__ */ jsx2("a", { href: url(media, { download: filename ?? true }), download: true, ...anchorProps, onClick: handleClick, children });
1192
+ return /* @__PURE__ */ jsx6("a", { href: url(media, { download: filename ?? true }), download: true, ...anchorProps, onClick: handleClick, children });
532
1193
  }
533
1194
  function useDevulturMedia(key) {
534
- const apiCtx = React2.use(DevulturApiContext);
1195
+ const apiCtx = React7.use(DevulturApiContext);
535
1196
  if (!apiCtx?.getMedia) {
536
1197
  throw new Error("useDevulturMedia requires getMedia in DevulturProvider api");
537
1198
  }
@@ -588,7 +1249,7 @@ function useVideoState(media, options) {
588
1249
  const { token, baseUrl, sessionSecret } = useDevultur();
589
1250
  const status = useDevulturMedia(media?.kind === "video" ? media.id : null);
590
1251
  const locales = options?.locales;
591
- return React2.useMemo(() => {
1252
+ return React7.useMemo(() => {
592
1253
  if (media?.kind !== "video") return { phase: "idle" };
593
1254
  if (status.isFailed) {
594
1255
  return {
@@ -601,7 +1262,7 @@ function useVideoState(media, options) {
601
1262
  if (status.isReady) {
602
1263
  if (!baseUrl) throw new Error("useVideoState requires `baseUrl` on <DevulturProvider>");
603
1264
  const viewerToken = media.visibility === "private" ? token ?? void 0 : void 0;
604
- const playlist = buildMediaUrl(baseUrl, mediaPath(media), { token: viewerToken });
1265
+ const playlist = buildMediaUrl(baseUrl, mediaPath2(media), { token: viewerToken });
605
1266
  const captions = (locales ?? status.captionLocales).flatMap((locale) => {
606
1267
  const path = mediaCaptionPath(media, locale);
607
1268
  if (!path) return [];
@@ -632,23 +1293,23 @@ function useVideoState(media, options) {
632
1293
  }
633
1294
  function useMediaUpload(options) {
634
1295
  const { visibility, onComplete, onError, replaces, process, locales } = options ?? {};
635
- const api = React2.use(DevulturApiContext);
1296
+ const api = React7.use(DevulturApiContext);
636
1297
  if (!api) throw new Error("useMediaUpload must be used within <DevulturProvider>");
637
1298
  const createUploadUrlAction = useAction(api.createUploadUrl);
638
1299
  const deleteMediaAction = useAction(api.deleteMedia);
639
1300
  const confirmUploadAction = useAction(api.confirmUpload ?? api.createUploadUrl);
640
1301
  const processVideoAction = useAction(api.processVideo ?? api.createUploadUrl);
641
- const [phase, setPhase] = React2.useState("idle");
642
- const [percent, setPercent] = React2.useState(0);
643
- const [currentFile, setCurrentFile] = React2.useState(null);
644
- const [media, setMedia] = React2.useState(null);
645
- const [uploadFailure, setUploadFailure] = React2.useState(null);
646
- const [processRequested, setProcessRequested] = React2.useState(false);
647
- const abortRef = React2.useRef(null);
648
- const lastFileRef = React2.useRef(null);
649
- const lastKeyRef = React2.useRef(null);
1302
+ const [phase, setPhase] = React7.useState("idle");
1303
+ const [percent, setPercent] = React7.useState(0);
1304
+ const [currentFile, setCurrentFile] = React7.useState(null);
1305
+ const [media, setMedia] = React7.useState(null);
1306
+ const [uploadFailure, setUploadFailure] = React7.useState(null);
1307
+ const [processRequested, setProcessRequested] = React7.useState(false);
1308
+ const abortRef = React7.useRef(null);
1309
+ const lastFileRef = React7.useRef(null);
1310
+ const lastKeyRef = React7.useRef(null);
650
1311
  const videoState = useVideoState(media, { locales });
651
- const triggerProcess = React2.useCallback(
1312
+ const triggerProcess = React7.useCallback(
652
1313
  (key) => {
653
1314
  if (!api.processVideo) {
654
1315
  throw new Error("useMediaUpload: processing a video requires `processVideo` in the <DevulturProvider> api");
@@ -661,7 +1322,7 @@ function useMediaUpload(options) {
661
1322
  },
662
1323
  [api.processVideo, processVideoAction]
663
1324
  );
664
- const runUpload = React2.useCallback(
1325
+ const runUpload = React7.useCallback(
665
1326
  async (file) => {
666
1327
  const controller = new AbortController();
667
1328
  abortRef.current = controller;
@@ -713,7 +1374,7 @@ function useMediaUpload(options) {
713
1374
  };
714
1375
  setMedia(uploaded);
715
1376
  setPhase("uploaded");
716
- if (replaces) deleteMediaAction({ key: mediaPath(replaces) }).catch(() => {
1377
+ if (replaces) deleteMediaAction({ key: mediaPath2(replaces) }).catch(() => {
717
1378
  });
718
1379
  onComplete?.(uploaded);
719
1380
  if ((process ?? true) && uploaded.kind === "video") triggerProcess(result.key);
@@ -732,10 +1393,10 @@ function useMediaUpload(options) {
732
1393
  onError
733
1394
  ]
734
1395
  );
735
- const cancel = React2.useCallback(() => {
1396
+ const cancel = React7.useCallback(() => {
736
1397
  abortRef.current?.abort();
737
1398
  }, []);
738
- const reset = React2.useCallback(() => {
1399
+ const reset = React7.useCallback(() => {
739
1400
  abortRef.current?.abort();
740
1401
  setPhase("idle");
741
1402
  setPercent(0);
@@ -744,15 +1405,15 @@ function useMediaUpload(options) {
744
1405
  setUploadFailure(null);
745
1406
  setProcessRequested(false);
746
1407
  }, []);
747
- const retry = React2.useCallback(() => {
1408
+ const retry = React7.useCallback(() => {
748
1409
  if (phase === "upload-failed" && lastFileRef.current) {
749
1410
  runUpload(lastFileRef.current);
750
1411
  return;
751
1412
  }
752
1413
  if (lastKeyRef.current) triggerProcess(lastKeyRef.current);
753
1414
  }, [phase, runUpload, triggerProcess]);
754
- React2.useEffect(() => () => abortRef.current?.abort(), []);
755
- const state = React2.useMemo(
1415
+ React7.useEffect(() => () => abortRef.current?.abort(), []);
1416
+ const state = React7.useMemo(
756
1417
  () => deriveUploadState(
757
1418
  { phase, percent, file: currentFile, failure: uploadFailure, media, processRequested },
758
1419
  videoState,
@@ -760,7 +1421,7 @@ function useMediaUpload(options) {
760
1421
  ),
761
1422
  [phase, percent, currentFile, uploadFailure, media, processRequested, videoState, retry]
762
1423
  );
763
- const upload = React2.useCallback((file) => runUpload(file), [runUpload]);
1424
+ const upload = React7.useCallback((file) => runUpload(file), [runUpload]);
764
1425
  return { upload, state, cancel, reset };
765
1426
  }
766
1427
  export {
@@ -768,9 +1429,15 @@ export {
768
1429
  DevulturProvider,
769
1430
  DownloadLink,
770
1431
  Image,
1432
+ MediaFetchError,
1433
+ PdfPreviewProvider,
1434
+ PdfThumbnail,
1435
+ Preview,
1436
+ defaultPreviewLabels,
771
1437
  useDevultur,
772
1438
  useDevulturMedia,
773
1439
  useDevulturProgress,
1440
+ useMediaObjectUrl,
774
1441
  useMediaUpload,
775
1442
  useMediaUrl,
776
1443
  useVideoProps,