@devultur/convex 1.4.3 → 1.5.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
@@ -24,39 +24,34 @@ import {
24
24
  import {
25
25
  describeDevulturError
26
26
  } from "./chunk-YYOZ3PDH.js";
27
+ import {
28
+ DevulturApiContext,
29
+ DevulturCapabilityError,
30
+ DevulturContext,
31
+ DevulturProgressContext,
32
+ useDevultur,
33
+ useDevulturApi,
34
+ useDevulturOptional,
35
+ useDevulturProgress,
36
+ useMediaUrl,
37
+ useRequestTokenWhen,
38
+ useVideoProps
39
+ } from "./chunk-BDEO67OT.js";
27
40
 
28
41
  // src/react.tsx
29
- import React22 from "react";
42
+ import React26 from "react";
43
+ import { mediaPath as mediaPath5 } from "@devultur/core";
44
+ import { uploadFileWithProgress as uploadFileWithProgress2 } from "@devultur/react";
45
+ import { useAction as useAction3, useConvex, useMutation } from "convex/react";
46
+
47
+ // src/media-session.ts
48
+ import React2 from "react";
30
49
  import {
31
- buildMediaUrl as buildMediaUrl2,
32
- CaptionLocaleLabel,
33
- MediaStatus,
34
- mediaCaptionPath as mediaCaptionPath2,
35
- mediaPath as mediaPath3,
36
- mediaUrl as mediaUrl4,
37
- visibilityFromKey
50
+ buildMediaUrl,
51
+ mediaCaptionPath,
52
+ mediaUrl,
53
+ SESSION_HEADER
38
54
  } from "@devultur/core";
39
- import {
40
- UploadHttpError,
41
- uploadFileWithProgress
42
- } from "@devultur/react";
43
- import { useAction as useAction2, useConvex, useMutation, useQuery } from "convex/react";
44
-
45
- // src/capability-error.ts
46
- var DevulturCapabilityError = class extends Error {
47
- code = "capability_unavailable";
48
- capability;
49
- constructor(capability) {
50
- super(
51
- `Devultur: \`${capability}\` isn't available under <DevulturViewerProvider>. Use <DevulturProvider api> with the actions it needs.`
52
- );
53
- this.name = "DevulturCapabilityError";
54
- this.capability = capability;
55
- }
56
- };
57
-
58
- // src/media-fetch.ts
59
- import { SESSION_HEADER } from "@devultur/core";
60
55
 
61
56
  // src/image-recovery.ts
62
57
  async function isSessionMismatch(response) {
@@ -68,104 +63,6 @@ async function isSessionMismatch(response) {
68
63
  }
69
64
  }
70
65
 
71
- // src/media-fetch.ts
72
- var MediaFetchError = class extends Error {
73
- code;
74
- /** HTTP status, for `http_error` when the delivery answered. */
75
- status;
76
- /** The code's extra fields, e.g. `page_cap`'s `{ shown, total }`. */
77
- details;
78
- constructor(code, message, status, options) {
79
- super(message, options);
80
- this.name = "MediaFetchError";
81
- this.code = code;
82
- this.status = status;
83
- this.details = options?.details;
84
- }
85
- };
86
- function networkError(cause) {
87
- return new MediaFetchError("http_error", "Media fetch failed: no response", void 0, { cause });
88
- }
89
- function pageCapError(shown, total) {
90
- const message = `Showing the first ${shown} of ${total} pages`;
91
- return new MediaFetchError("page_cap", message, void 0, { details: { shown, total } });
92
- }
93
- function renderFailedError(cause) {
94
- return new MediaFetchError("render_failed", "The media couldn't be rendered", void 0, { cause });
95
- }
96
- function tokenUnavailableError(cause) {
97
- return new MediaFetchError("token_unavailable", "No viewer token: its issuance failed", void 0, { cause });
98
- }
99
- async function fetchMedia(request) {
100
- const { buildUrl, token, sessionSecret, reissueToken, signal, isPrivate = true, canReissue = true } = request;
101
- const headers = isPrivate && sessionSecret ? { [SESSION_HEADER]: sessionSecret } : void 0;
102
- const response = await fetch(buildUrl(isPrivate ? token ?? void 0 : void 0), { headers, signal });
103
- if (!isPrivate || !canReissue || response.status !== 401) return response;
104
- if (!await isSessionMismatch(response)) return response;
105
- request.onReissue?.();
106
- const freshToken = await reissueToken();
107
- if (!freshToken) return response;
108
- return fetch(buildUrl(freshToken), { headers, signal });
109
- }
110
- async function loadMedia(request) {
111
- const response = await fetchMedia(request);
112
- if (!response.ok) {
113
- throw new MediaFetchError("http_error", `Media fetch failed: ${response.status}`, response.status);
114
- }
115
- const trusted = trustedContentType(request.contentType, response.headers.get("content-type"));
116
- if (!trusted.ok) {
117
- await response.body?.cancel();
118
- throw new MediaFetchError(trusted.reason, `Refusing to preview: ${trusted.reason}`);
119
- }
120
- const bytes = await readMediaBody(response, request.maxBytes);
121
- return { bytes, contentType: trusted.contentType };
122
- }
123
- function tooLarge(maxBytes) {
124
- return new MediaFetchError("too_large", `Media is larger than ${maxBytes} bytes`);
125
- }
126
- async function readMediaBody(response, maxBytes) {
127
- if (maxBytes === void 0) return new Uint8Array(await response.arrayBuffer());
128
- const declaredBytes = Number(response.headers.get("content-length") ?? Number.NaN);
129
- if (declaredBytes > maxBytes) {
130
- await response.body?.cancel();
131
- throw tooLarge(maxBytes);
132
- }
133
- if (!response.body) return new Uint8Array(0);
134
- return readCapped(response.body, maxBytes);
135
- }
136
- async function readCapped(body, maxBytes) {
137
- const reader = body.getReader();
138
- const chunks = [];
139
- let byteCount = 0;
140
- for (; ; ) {
141
- const { done, value } = await reader.read();
142
- if (done) break;
143
- byteCount += value.byteLength;
144
- if (byteCount > maxBytes) {
145
- await reader.cancel();
146
- throw tooLarge(maxBytes);
147
- }
148
- chunks.push(value);
149
- }
150
- const bytes = new Uint8Array(byteCount);
151
- let offset = 0;
152
- for (const chunk of chunks) {
153
- bytes.set(chunk, offset);
154
- offset += chunk.byteLength;
155
- }
156
- return bytes;
157
- }
158
-
159
- // src/media-session.ts
160
- import React2 from "react";
161
- import {
162
- buildMediaUrl,
163
- mediaCaptionPath,
164
- mediaUrl,
165
- SESSION_HEADER as SESSION_HEADER2
166
- } from "@devultur/core";
167
- import { useAction } from "convex/react";
168
-
169
66
  // src/viewer-token.ts
170
67
  import React from "react";
171
68
  import { DEFAULT_TOKEN_EXPIRY_SECONDS } from "@devultur/core";
@@ -193,21 +90,25 @@ function sortKeys(json) {
193
90
  function tokenArgsKey(tokenArgs) {
194
91
  return JSON.stringify(sortKeys(convexToJson(tokenArgs ?? {})));
195
92
  }
196
- function useViewerToken({ issueViewerToken, sessionSecret, tokenArgs }) {
93
+ function useViewerToken({ issueViewerToken, sessionSecret, tokenArgs, isLazy = false }) {
197
94
  const argsKey = tokenArgsKey(tokenArgs);
198
95
  const stableArgs = React.useMemo(() => tokenArgs ?? {}, [argsKey]);
199
96
  const [issued, setIssued] = React.useState(null);
200
97
  const [attempt, setAttempt] = React.useState(null);
98
+ const [isRequested, setIsRequested] = React.useState(!isLazy);
201
99
  const token = issued?.argsKey === argsKey ? issued.token : null;
202
100
  const currentAttempt = attempt?.argsKey === argsKey ? attempt : null;
203
101
  const currentArgsKeyRef = React.useRef(argsKey);
204
102
  const refreshTimerRef = React.useRef(null);
205
103
  const inFlightRef = React.useRef(null);
104
+ const isMountedRef = React.useRef(true);
206
105
  React.useEffect(() => {
207
106
  currentArgsKeyRef.current = argsKey;
208
107
  }, [argsKey]);
209
108
  React.useEffect(() => {
109
+ isMountedRef.current = true;
210
110
  return () => {
111
+ isMountedRef.current = false;
211
112
  if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current);
212
113
  };
213
114
  }, []);
@@ -216,7 +117,7 @@ function useViewerToken({ issueViewerToken, sessionSecret, tokenArgs }) {
216
117
  request().catch((error) => console.warn("Devultur: could not renew the viewer token", error));
217
118
  };
218
119
  const result = await issueViewerToken({ ...stableArgs, sessionSecret });
219
- if (currentArgsKeyRef.current !== argsKey) return null;
120
+ if (!isMountedRef.current || currentArgsKeyRef.current !== argsKey) return null;
220
121
  const { tokenValue, ttlMs } = extractTokenResult(result);
221
122
  setIssued({ token: tokenValue, argsKey });
222
123
  setAttempt(null);
@@ -241,10 +142,11 @@ function useViewerToken({ issueViewerToken, sessionSecret, tokenArgs }) {
241
142
  }, [request, argsKey]);
242
143
  const hasAttempt = currentAttempt !== null;
243
144
  React.useEffect(() => {
244
- if (token || hasAttempt) return;
145
+ if (!isRequested || token || hasAttempt) return;
245
146
  issueOnce().catch(() => {
246
147
  });
247
- }, [token, hasAttempt, issueOnce]);
148
+ }, [isRequested, token, hasAttempt, issueOnce]);
149
+ const requestToken = React.useCallback(() => setIsRequested(true), []);
248
150
  const reissueToken = React.useCallback(
249
151
  () => issueOnce().catch(() => {
250
152
  setIssued((current) => current?.argsKey === argsKey ? null : current);
@@ -252,27 +154,37 @@ function useViewerToken({ issueViewerToken, sessionSecret, tokenArgs }) {
252
154
  }),
253
155
  [issueOnce, argsKey]
254
156
  );
255
- const retryToken = React.useCallback(() => issueOnce().catch(() => null), [issueOnce]);
157
+ const retryToken = React.useCallback(() => {
158
+ setIsRequested(true);
159
+ return issueOnce().catch(() => null);
160
+ }, [issueOnce]);
256
161
  const tokenStatus = token ? "ready" : currentAttempt?.status ?? "idle";
257
162
  const tokenError = !token && currentAttempt?.status === "error" ? currentAttempt.error : null;
258
- return { token, tokenStatus, tokenError, reissueToken, retryToken };
163
+ return { token, tokenStatus, tokenError, reissueToken, retryToken, requestToken };
259
164
  }
260
165
 
261
166
  // src/media-session.ts
262
- function useMediaSession({ issueViewerToken, tokenArgs, baseUrl }) {
167
+ function useMediaSession({ issueViewerToken, tokenArgs, baseUrl, isLazy }) {
263
168
  const [sessionSecret] = React2.useState(() => typeof crypto !== "undefined" ? crypto.randomUUID() : "");
264
- const issueViewerTokenAction = useAction(issueViewerToken);
265
- const { token, tokenStatus, tokenError, reissueToken, retryToken } = useViewerToken({
266
- issueViewerToken: issueViewerTokenAction,
169
+ const { token, tokenStatus, tokenError, reissueToken, retryToken, requestToken } = useViewerToken({
170
+ issueViewerToken,
267
171
  sessionSecret,
268
- tokenArgs
172
+ tokenArgs,
173
+ isLazy
269
174
  });
175
+ const hasRequestedRef = React2.useRef(!isLazy);
176
+ const requestTokenAfterRender = React2.useCallback(() => {
177
+ if (hasRequestedRef.current) return;
178
+ hasRequestedRef.current = true;
179
+ queueMicrotask(requestToken);
180
+ }, [requestToken]);
270
181
  const url = React2.useCallback(
271
182
  (media, options) => {
272
183
  if (!baseUrl) throw new Error("Devultur `url()` requires `baseUrl` on <DevulturProvider>");
184
+ if (media.visibility === "private" && !token) requestTokenAfterRender();
273
185
  return mediaUrl(baseUrl, media, { ...options, token: token ?? void 0 });
274
186
  },
275
- [baseUrl, token]
187
+ [baseUrl, token, requestTokenAfterRender]
276
188
  );
277
189
  const getCaptionsText = React2.useCallback(
278
190
  async (media, locale) => {
@@ -280,10 +192,11 @@ function useMediaSession({ issueViewerToken, tokenArgs, baseUrl }) {
280
192
  const path = mediaCaptionPath(media, locale);
281
193
  if (!path) throw new Error("getCaptionsText: the reference is not a video");
282
194
  const isPrivate = media.visibility === "private";
283
- const fetchWith = (viewerToken) => fetch(buildMediaUrl(baseUrl, path, { token: viewerToken }), {
284
- headers: sessionSecret ? { [SESSION_HEADER2]: sessionSecret } : void 0
195
+ const fetchWith = (viewerToken2) => fetch(buildMediaUrl(baseUrl, path, { token: viewerToken2 }), {
196
+ headers: sessionSecret ? { [SESSION_HEADER]: sessionSecret } : void 0
285
197
  });
286
- let response = await fetchWith(isPrivate ? token ?? void 0 : void 0);
198
+ const viewerToken = isPrivate ? token ?? await retryToken() : null;
199
+ let response = await fetchWith(viewerToken ?? void 0);
287
200
  if (isPrivate && response.status === 401 && await isSessionMismatch(response)) {
288
201
  const fresh = await reissueToken();
289
202
  if (fresh) response = await fetchWith(fresh);
@@ -291,7 +204,7 @@ function useMediaSession({ issueViewerToken, tokenArgs, baseUrl }) {
291
204
  if (!response.ok) throw new Error(`Captions fetch failed: ${response.status}`);
292
205
  return response.text();
293
206
  },
294
- [baseUrl, token, reissueToken, sessionSecret]
207
+ [baseUrl, token, reissueToken, retryToken, sessionSecret]
295
208
  );
296
209
  return React2.useMemo(
297
210
  () => ({
@@ -299,137 +212,424 @@ function useMediaSession({ issueViewerToken, tokenArgs, baseUrl }) {
299
212
  tokenStatus,
300
213
  tokenError,
301
214
  retryToken,
215
+ requestToken,
302
216
  baseUrl: baseUrl ?? null,
303
217
  sessionSecret,
304
218
  url,
305
219
  getCaptionsText,
306
220
  reissueToken
307
221
  }),
308
- [token, tokenStatus, tokenError, retryToken, baseUrl, sessionSecret, url, getCaptionsText, reissueToken]
222
+ [
223
+ token,
224
+ tokenStatus,
225
+ tokenError,
226
+ retryToken,
227
+ requestToken,
228
+ baseUrl,
229
+ sessionSecret,
230
+ url,
231
+ getCaptionsText,
232
+ reissueToken
233
+ ]
309
234
  );
310
235
  }
311
236
 
312
- // src/media-upload-derive.ts
313
- function deriveUploadState(local, videoState, retry) {
314
- const { phase, percent, file, failure, media, processRequested } = local;
315
- if (phase === "uploading") return { phase: "uploading", percent, file };
316
- if (phase === "upload-failed" && failure) {
317
- return {
318
- phase: "failed",
319
- stage: "upload",
320
- reason: failure.reason,
321
- retriable: failure.retriable,
322
- message: failure.message,
323
- retry
324
- };
237
+ // src/upload-hints.ts
238
+ async function confirmUploadBestEffort(confirmUpload, key, file, hints) {
239
+ try {
240
+ await confirmUpload({ key, filename: file.name, ...hints });
241
+ } catch (error) {
242
+ console.warn("Devultur: could not record the upload; it is recorded later", error);
325
243
  }
326
- if (phase === "idle" || !media) return { phase: "idle" };
327
- if (media.kind !== "video" || !processRequested) return { phase: "ready", media };
328
- switch (videoState.phase) {
329
- case "processing":
330
- return { phase: "processing", media, step: videoState.step, percent: videoState.percent };
331
- case "ready":
332
- return {
333
- phase: "ready",
334
- media,
335
- playlist: videoState.playlist,
336
- duration: videoState.duration,
337
- captions: videoState.captions,
338
- playerProps: videoState.playerProps
339
- };
340
- case "failed":
341
- return {
342
- phase: "failed",
343
- stage: "transcode",
344
- reason: videoState.reason,
345
- retriable: videoState.retriable,
346
- message: videoState.message,
347
- retry
348
- };
349
- default:
350
- return { phase: "queued", media };
244
+ }
245
+ async function readImageDimensions(file) {
246
+ if (typeof createImageBitmap !== "function" || !file.type.startsWith("image/")) return {};
247
+ try {
248
+ const bitmap = await createImageBitmap(file);
249
+ const dimensions = { width: bitmap.width, height: bitmap.height };
250
+ bitmap.close();
251
+ return dimensions;
252
+ } catch {
253
+ return {};
351
254
  }
352
255
  }
353
256
 
354
257
  // src/crop-upload.tsx
355
- import React3 from "react";
258
+ import React5 from "react";
356
259
  import { ImageCropper } from "@devultur/react";
357
- import { jsx, jsxs } from "react/jsx-runtime";
358
- var DEFAULT_LABELS = {
359
- zoom: "Zoom",
360
- confirm: "Crop and upload",
361
- processing: "Processing\u2026",
362
- cancel: "Cancel"
363
- };
364
- function CropUpload({
365
- aspectRatio,
366
- visibility,
367
- outputFormat,
368
- maxWidth,
369
- minSourceWidth,
370
- replaces,
371
- onComplete,
372
- onReject,
373
- accept = ["image/*"],
374
- cropperClassName,
375
- labels,
376
- children
377
- }) {
378
- const text = { ...DEFAULT_LABELS, ...labels };
379
- const [pendingFile, setPendingFile] = React3.useState(null);
380
- const inputRef = React3.useRef(null);
381
- const { upload, state, cancel, reset } = useMediaUpload({ visibility, replaces, onComplete });
382
- const pickFile = React3.useCallback(() => inputRef.current?.click(), []);
383
- const handleCrop = React3.useCallback(
384
- (baked) => {
385
- setPendingFile(null);
386
- upload(baked);
387
- },
388
- [upload]
389
- );
390
- const resetAll = React3.useCallback(() => {
391
- setPendingFile(null);
392
- reset();
393
- }, [reset]);
394
- return /* @__PURE__ */ jsxs("div", { children: [
395
- /* @__PURE__ */ jsx(
396
- "input",
397
- {
398
- ref: inputRef,
399
- type: "file",
400
- accept: accept.join(","),
401
- hidden: true,
402
- onChange: (event) => {
403
- const file = event.target.files?.[0];
404
- if (file) setPendingFile(file);
405
- event.target.value = "";
406
- }
407
- }
408
- ),
409
- pendingFile && /* @__PURE__ */ jsx(
410
- ImageCropper,
411
- {
412
- file: pendingFile,
413
- aspectRatio,
414
- outputFormat,
415
- maxWidth,
416
- minSourceWidth,
417
- onCrop: handleCrop,
418
- onReject,
419
- className: cropperClassName,
420
- children: ({ zoom, setZoom, confirm, isBaking }) => /* @__PURE__ */ jsxs("div", { className: "dv-crop-controls", children: [
421
- /* @__PURE__ */ jsx(
422
- "input",
423
- {
424
- type: "range",
425
- min: 1,
426
- max: 3,
427
- step: 0.01,
428
- value: zoom,
429
- onChange: (event) => setZoom(Number(event.target.value)),
430
- "aria-label": text.zoom
431
- }
432
- ),
260
+
261
+ // src/use-media-upload.ts
262
+ import React4 from "react";
263
+ import { mediaPath as mediaPath2 } from "@devultur/core";
264
+ import { UploadHttpError, uploadFileWithProgress } from "@devultur/react";
265
+ import { useAction } from "convex/react";
266
+
267
+ // src/media-hooks.ts
268
+ import React3 from "react";
269
+ import {
270
+ buildMediaUrl as buildMediaUrl2,
271
+ CaptionLocaleLabel,
272
+ MediaStatus,
273
+ mediaCaptionPath as mediaCaptionPath2,
274
+ mediaPath
275
+ } from "@devultur/core";
276
+ import { useQuery } from "convex/react";
277
+ function useDevulturMedia(key) {
278
+ const apiCtx = React3.use(DevulturApiContext);
279
+ if (!apiCtx?.getMedia) {
280
+ throw new Error("useDevulturMedia requires getMedia in DevulturProvider api");
281
+ }
282
+ const record = useQuery(apiCtx.getMedia, key ? { key } : "skip");
283
+ if (!record) {
284
+ return {
285
+ status: MediaStatus.IDLE,
286
+ progress: null,
287
+ error: null,
288
+ failureReason: null,
289
+ retriable: null,
290
+ isQueued: false,
291
+ isTranscoding: false,
292
+ isCaptioning: false,
293
+ isReady: false,
294
+ isFailed: false,
295
+ playlistUrl: null,
296
+ thumbnailKey: null,
297
+ captionLocales: [],
298
+ duration: null,
299
+ isLoading: record === void 0
300
+ };
301
+ }
302
+ const status = record.transcodeStatus ?? MediaStatus.IDLE;
303
+ const isReady = status === MediaStatus.READY;
304
+ const isFailed = status === MediaStatus.FAILED;
305
+ const progress = record.transcodeProgress ? {
306
+ current: record.transcodeProgress.current,
307
+ total: record.transcodeProgress.total,
308
+ percent: record.transcodeProgress.total > 0 ? Math.round(
309
+ record.transcodeProgress.current / record.transcodeProgress.total * 100
310
+ ) : 0,
311
+ phase: record.transcodeProgress.phase
312
+ } : null;
313
+ return {
314
+ status,
315
+ progress,
316
+ error: record.transcodeError ?? null,
317
+ failureReason: isFailed ? record.failureReason ?? "internal" : null,
318
+ retriable: isFailed ? record.retriable ?? true : null,
319
+ isQueued: status === MediaStatus.QUEUED,
320
+ isTranscoding: status === MediaStatus.TRANSCODING,
321
+ isCaptioning: status === MediaStatus.CAPTIONING,
322
+ isReady,
323
+ isFailed,
324
+ playlistUrl: record.playlistUrl ?? null,
325
+ thumbnailKey: record.thumbnailKey ?? null,
326
+ captionLocales: record.captionLocales ?? [],
327
+ duration: record.duration ?? null,
328
+ isLoading: false
329
+ };
330
+ }
331
+ function useVideoState(media, options) {
332
+ const devultur = useDevultur();
333
+ const { token, baseUrl, sessionSecret } = devultur;
334
+ useRequestTokenWhen(devultur, media?.kind === "video" && media.visibility === "private");
335
+ const status = useDevulturMedia(media?.kind === "video" ? media.id : null);
336
+ const locales = options?.locales;
337
+ return React3.useMemo(() => {
338
+ if (media?.kind !== "video") return { phase: "idle" };
339
+ if (status.isFailed) {
340
+ return {
341
+ phase: "failed",
342
+ reason: status.failureReason ?? "internal",
343
+ message: status.error ?? "Video processing failed",
344
+ retriable: status.retriable ?? true
345
+ };
346
+ }
347
+ if (status.isReady) {
348
+ if (!baseUrl) throw new Error("useVideoState requires `baseUrl` on <DevulturProvider>");
349
+ const viewerToken = media.visibility === "private" ? token ?? void 0 : void 0;
350
+ const playlist = buildMediaUrl2(baseUrl, mediaPath(media), { token: viewerToken });
351
+ const captions = (locales ?? status.captionLocales).flatMap((locale) => {
352
+ const path = mediaCaptionPath2(media, locale);
353
+ if (!path) return [];
354
+ const label = CaptionLocaleLabel[locale] ?? locale;
355
+ return [{ locale, label, src: buildMediaUrl2(baseUrl, path, { token: viewerToken }) }];
356
+ });
357
+ return {
358
+ phase: "ready",
359
+ media,
360
+ playlist,
361
+ duration: status.duration ?? 0,
362
+ captions,
363
+ playerProps: {
364
+ src: playlist,
365
+ token: viewerToken,
366
+ captions,
367
+ sessionSecret: media.visibility === "private" ? sessionSecret : void 0
368
+ }
369
+ };
370
+ }
371
+ if (status.isQueued) return { phase: "queued" };
372
+ if (status.isTranscoding || status.isCaptioning) {
373
+ const step = status.progress?.phase ?? (status.isCaptioning ? "captioning" : "encoding");
374
+ return { phase: "processing", step, percent: status.progress?.percent ?? 0 };
375
+ }
376
+ return { phase: "idle" };
377
+ }, [media, status, baseUrl, token, locales, sessionSecret]);
378
+ }
379
+
380
+ // src/media-upload-derive.ts
381
+ function deriveUploadState(local, videoState, retry) {
382
+ const { phase, percent, file, failure, media, processRequested } = local;
383
+ if (phase === "uploading") return { phase: "uploading", percent, file };
384
+ if (phase === "upload-failed" && failure) {
385
+ return {
386
+ phase: "failed",
387
+ stage: "upload",
388
+ reason: failure.reason,
389
+ retriable: failure.retriable,
390
+ message: failure.message,
391
+ retry
392
+ };
393
+ }
394
+ if (phase === "idle" || !media) return { phase: "idle" };
395
+ if (media.kind !== "video" || !processRequested) return { phase: "ready", media };
396
+ switch (videoState.phase) {
397
+ case "processing":
398
+ return { phase: "processing", media, step: videoState.step, percent: videoState.percent };
399
+ case "ready":
400
+ return {
401
+ phase: "ready",
402
+ media,
403
+ playlist: videoState.playlist,
404
+ duration: videoState.duration,
405
+ captions: videoState.captions,
406
+ playerProps: videoState.playerProps
407
+ };
408
+ case "failed":
409
+ return {
410
+ phase: "failed",
411
+ stage: "transcode",
412
+ reason: videoState.reason,
413
+ retriable: videoState.retriable,
414
+ message: videoState.message,
415
+ retry
416
+ };
417
+ default:
418
+ return { phase: "queued", media };
419
+ }
420
+ }
421
+
422
+ // src/use-media-upload.ts
423
+ function useMediaUpload(options) {
424
+ const { visibility, onComplete, onError, replaces, process, locales } = options ?? {};
425
+ const api = useDevulturApi();
426
+ const createUploadUrlAction = useAction(api.createUploadUrl);
427
+ const deleteMediaAction = useAction(api.deleteMedia);
428
+ const confirmUploadAction = useAction(api.confirmUpload ?? api.createUploadUrl);
429
+ const processVideoAction = useAction(api.processVideo ?? api.createUploadUrl);
430
+ const [phase, setPhase] = React4.useState("idle");
431
+ const [percent, setPercent] = React4.useState(0);
432
+ const [currentFile, setCurrentFile] = React4.useState(null);
433
+ const [media, setMedia] = React4.useState(null);
434
+ const [uploadFailure, setUploadFailure] = React4.useState(null);
435
+ const [processRequested, setProcessRequested] = React4.useState(false);
436
+ const abortRef = React4.useRef(null);
437
+ const lastFileRef = React4.useRef(null);
438
+ const lastKeyRef = React4.useRef(null);
439
+ const videoState = useVideoState(media, { locales });
440
+ const triggerProcess = React4.useCallback(
441
+ (key) => {
442
+ if (!api.processVideo) {
443
+ throw new Error("useMediaUpload: processing a video requires `processVideo` in the <DevulturProvider> api");
444
+ }
445
+ lastKeyRef.current = key;
446
+ setProcessRequested(true);
447
+ processVideoAction({ key }).catch((err) => {
448
+ console.error("useMediaUpload: could not request the transcode", err);
449
+ });
450
+ },
451
+ [api.processVideo, processVideoAction]
452
+ );
453
+ const runUpload = React4.useCallback(
454
+ async (file) => {
455
+ const controller = new AbortController();
456
+ abortRef.current = controller;
457
+ lastFileRef.current = file;
458
+ setUploadFailure(null);
459
+ setMedia(null);
460
+ setProcessRequested(false);
461
+ setCurrentFile(file);
462
+ setPercent(0);
463
+ setPhase("uploading");
464
+ const fail = (reason, message) => {
465
+ const failure = { stage: "upload", reason, retriable: true, message };
466
+ setUploadFailure(failure);
467
+ setPhase("upload-failed");
468
+ onError?.(failure);
469
+ return null;
470
+ };
471
+ let result;
472
+ try {
473
+ result = await createUploadUrlAction({
474
+ filename: file.name,
475
+ contentType: file.type,
476
+ visibility
477
+ });
478
+ } catch (err) {
479
+ return fail("rejected", err instanceof Error ? err.message : "Could not start the upload");
480
+ }
481
+ const dimensions = await readImageDimensions(file);
482
+ try {
483
+ await uploadFileWithProgress(result.url, file, { onProgress: setPercent, signal: controller.signal });
484
+ } catch (err) {
485
+ if (err instanceof DOMException && err.name === "AbortError") {
486
+ setPhase("idle");
487
+ setCurrentFile(null);
488
+ setPercent(0);
489
+ return null;
490
+ }
491
+ const rejected = err instanceof UploadHttpError && (err.status === 413 || err.status === 415);
492
+ return fail(rejected ? "rejected" : "network", err instanceof Error ? err.message : "The upload failed");
493
+ }
494
+ if (api.confirmUpload) await confirmUploadBestEffort(confirmUploadAction, result.key, file, dimensions);
495
+ const uploaded = {
496
+ id: result.id,
497
+ kind: result.kind,
498
+ visibility: result.visibility,
499
+ filename: file.name,
500
+ key: result.key,
501
+ ...dimensions
502
+ };
503
+ setMedia(uploaded);
504
+ setPhase("uploaded");
505
+ if (replaces) deleteMediaAction({ key: mediaPath2(replaces) }).catch(() => {
506
+ });
507
+ onComplete?.(uploaded);
508
+ if ((process ?? true) && uploaded.kind === "video") triggerProcess(result.key);
509
+ return uploaded;
510
+ },
511
+ [
512
+ createUploadUrlAction,
513
+ confirmUploadAction,
514
+ api.confirmUpload,
515
+ deleteMediaAction,
516
+ triggerProcess,
517
+ visibility,
518
+ replaces,
519
+ process,
520
+ onComplete,
521
+ onError
522
+ ]
523
+ );
524
+ const cancel = React4.useCallback(() => {
525
+ abortRef.current?.abort();
526
+ }, []);
527
+ const reset = React4.useCallback(() => {
528
+ abortRef.current?.abort();
529
+ setPhase("idle");
530
+ setPercent(0);
531
+ setCurrentFile(null);
532
+ setMedia(null);
533
+ setUploadFailure(null);
534
+ setProcessRequested(false);
535
+ }, []);
536
+ const retry = React4.useCallback(() => {
537
+ if (phase === "upload-failed" && lastFileRef.current) {
538
+ runUpload(lastFileRef.current);
539
+ return;
540
+ }
541
+ if (lastKeyRef.current) triggerProcess(lastKeyRef.current);
542
+ }, [phase, runUpload, triggerProcess]);
543
+ React4.useEffect(() => () => abortRef.current?.abort(), []);
544
+ const state = React4.useMemo(
545
+ () => deriveUploadState(
546
+ { phase, percent, file: currentFile, failure: uploadFailure, media, processRequested },
547
+ videoState,
548
+ retry
549
+ ),
550
+ [phase, percent, currentFile, uploadFailure, media, processRequested, videoState, retry]
551
+ );
552
+ const upload = React4.useCallback((file) => runUpload(file), [runUpload]);
553
+ return { upload, state, cancel, reset };
554
+ }
555
+
556
+ // src/crop-upload.tsx
557
+ import { jsx, jsxs } from "react/jsx-runtime";
558
+ var DEFAULT_LABELS = {
559
+ zoom: "Zoom",
560
+ confirm: "Crop and upload",
561
+ processing: "Processing\u2026",
562
+ cancel: "Cancel"
563
+ };
564
+ function CropUpload({
565
+ aspectRatio,
566
+ visibility,
567
+ outputFormat,
568
+ maxWidth,
569
+ minSourceWidth,
570
+ replaces,
571
+ onComplete,
572
+ onReject,
573
+ accept = ["image/*"],
574
+ cropperClassName,
575
+ labels,
576
+ children
577
+ }) {
578
+ const text = { ...DEFAULT_LABELS, ...labels };
579
+ const [pendingFile, setPendingFile] = React5.useState(null);
580
+ const inputRef = React5.useRef(null);
581
+ const { upload, state, cancel, reset } = useMediaUpload({ visibility, replaces, onComplete });
582
+ const pickFile = React5.useCallback(() => inputRef.current?.click(), []);
583
+ const handleCrop = React5.useCallback(
584
+ (baked) => {
585
+ setPendingFile(null);
586
+ upload(baked);
587
+ },
588
+ [upload]
589
+ );
590
+ const resetAll = React5.useCallback(() => {
591
+ setPendingFile(null);
592
+ reset();
593
+ }, [reset]);
594
+ return /* @__PURE__ */ jsxs("div", { children: [
595
+ /* @__PURE__ */ jsx(
596
+ "input",
597
+ {
598
+ ref: inputRef,
599
+ type: "file",
600
+ accept: accept.join(","),
601
+ hidden: true,
602
+ onChange: (event) => {
603
+ const file = event.target.files?.[0];
604
+ if (file) setPendingFile(file);
605
+ event.target.value = "";
606
+ }
607
+ }
608
+ ),
609
+ pendingFile && /* @__PURE__ */ jsx(
610
+ ImageCropper,
611
+ {
612
+ file: pendingFile,
613
+ aspectRatio,
614
+ outputFormat,
615
+ maxWidth,
616
+ minSourceWidth,
617
+ onCrop: handleCrop,
618
+ onReject,
619
+ className: cropperClassName,
620
+ children: ({ zoom, setZoom, confirm, isBaking }) => /* @__PURE__ */ jsxs("div", { className: "dv-crop-controls", children: [
621
+ /* @__PURE__ */ jsx(
622
+ "input",
623
+ {
624
+ type: "range",
625
+ min: 1,
626
+ max: 3,
627
+ step: 0.01,
628
+ value: zoom,
629
+ onChange: (event) => setZoom(Number(event.target.value)),
630
+ "aria-label": text.zoom
631
+ }
632
+ ),
433
633
  /* @__PURE__ */ jsx("button", { type: "button", onClick: confirm, disabled: isBaking, children: isBaking ? text.processing : text.confirm }),
434
634
  /* @__PURE__ */ jsx("button", { type: "button", onClick: () => setPendingFile(null), children: text.cancel })
435
635
  ] })
@@ -439,51 +639,182 @@ function CropUpload({
439
639
  ] });
440
640
  }
441
641
 
442
- // src/document-renderers.tsx
443
- import React4 from "react";
444
-
445
- // src/attachment-icons.tsx
446
- import { jsx as jsx2 } from "react/jsx-runtime";
447
- var SHEET = "M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z M14 3v5h5";
448
- var ICON_PATHS = {
449
- file: SHEET,
450
- text: `${SHEET} M9 13h6 M9 17h6`,
451
- pdf: `${SHEET} M8.5 13.5h2a1 1 0 0 1 0 2h-2v-2 M8.5 15.5V17 M13.5 13.5V17h1a1.5 1.5 0 0 0 0-3.5z`,
452
- image: "M5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z M21 15l-5-5L5 21 M8.5 10a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3z",
453
- video: "M4 5h16a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2z M10 9l5 3-5 3z",
454
- audio: "M9 18V5l12-2v13 M6 21a3 3 0 1 0 0-6 3 3 0 0 0 0 6z M18 19a3 3 0 1 0 0-6 3 3 0 0 0 0 6z"
455
- };
456
- function AttachmentIcon({ kind }) {
457
- return /* @__PURE__ */ jsx2(
458
- "svg",
459
- {
460
- "aria-hidden": "true",
461
- focusable: "false",
462
- width: "24",
463
- height: "24",
464
- viewBox: "0 0 24 24",
465
- fill: "none",
466
- stroke: "currentColor",
467
- strokeWidth: "1.5",
468
- strokeLinecap: "round",
469
- strokeLinejoin: "round",
470
- "data-part": "icon",
471
- "data-kind": kind,
472
- children: /* @__PURE__ */ jsx2("path", { d: ICON_PATHS[kind] })
473
- }
642
+ // src/document-renderers.tsx
643
+ import React7 from "react";
644
+
645
+ // src/attachment-icons.tsx
646
+ import { jsx as jsx2 } from "react/jsx-runtime";
647
+ var SHEET = "M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z M14 3v5h5";
648
+ var ICON_PATHS = {
649
+ file: SHEET,
650
+ text: `${SHEET} M9 13h6 M9 17h6`,
651
+ pdf: `${SHEET} M8.5 13.5h2a1 1 0 0 1 0 2h-2v-2 M8.5 15.5V17 M13.5 13.5V17h1a1.5 1.5 0 0 0 0-3.5z`,
652
+ image: "M5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z M21 15l-5-5L5 21 M8.5 10a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3z",
653
+ video: "M4 5h16a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2z M10 9l5 3-5 3z",
654
+ audio: "M9 18V5l12-2v13 M6 21a3 3 0 1 0 0-6 3 3 0 0 0 0 6z M18 19a3 3 0 1 0 0-6 3 3 0 0 0 0 6z"
655
+ };
656
+ function AttachmentIcon({ kind }) {
657
+ return /* @__PURE__ */ jsx2(
658
+ "svg",
659
+ {
660
+ "aria-hidden": "true",
661
+ focusable: "false",
662
+ width: "24",
663
+ height: "24",
664
+ viewBox: "0 0 24 24",
665
+ fill: "none",
666
+ stroke: "currentColor",
667
+ strokeWidth: "1.5",
668
+ strokeLinecap: "round",
669
+ strokeLinejoin: "round",
670
+ "data-part": "icon",
671
+ "data-kind": kind,
672
+ children: /* @__PURE__ */ jsx2("path", { d: ICON_PATHS[kind] })
673
+ }
674
+ );
675
+ }
676
+
677
+ // src/download-link.tsx
678
+ import React6 from "react";
679
+ import { mediaUrl as mediaUrl2 } from "@devultur/core";
680
+
681
+ // src/media-fetch.ts
682
+ import { SESSION_HEADER as SESSION_HEADER2 } from "@devultur/core";
683
+ var MediaFetchError = class extends Error {
684
+ code;
685
+ /** HTTP status, for `http_error` when the delivery answered. */
686
+ status;
687
+ /** The code's extra fields, e.g. `page_cap`'s `{ shown, total }`. */
688
+ details;
689
+ constructor(code, message, status, options) {
690
+ super(message, options);
691
+ this.name = "MediaFetchError";
692
+ this.code = code;
693
+ this.status = status;
694
+ this.details = options?.details;
695
+ }
696
+ };
697
+ function networkError(cause) {
698
+ return new MediaFetchError("http_error", "Media fetch failed: no response", void 0, { cause });
699
+ }
700
+ function pageCapError(shown, total) {
701
+ const message = `Showing the first ${shown} of ${total} pages`;
702
+ return new MediaFetchError("page_cap", message, void 0, { details: { shown, total } });
703
+ }
704
+ function renderFailedError(cause) {
705
+ return new MediaFetchError("render_failed", "The media couldn't be rendered", void 0, { cause });
706
+ }
707
+ function tokenUnavailableError(cause) {
708
+ return new MediaFetchError("token_unavailable", "No viewer token: its issuance failed", void 0, { cause });
709
+ }
710
+ async function fetchMedia(request) {
711
+ const { buildUrl, token, sessionSecret, reissueToken, signal, isPrivate = true, canReissue = true } = request;
712
+ const headers = isPrivate && sessionSecret ? { [SESSION_HEADER2]: sessionSecret } : void 0;
713
+ const response = await fetch(buildUrl(isPrivate ? token ?? void 0 : void 0), { headers, signal });
714
+ if (!isPrivate || !canReissue || response.status !== 401) return response;
715
+ if (!await isSessionMismatch(response)) return response;
716
+ request.onReissue?.();
717
+ const freshToken = await reissueToken();
718
+ if (!freshToken) return response;
719
+ return fetch(buildUrl(freshToken), { headers, signal });
720
+ }
721
+ async function loadMedia(request) {
722
+ const response = await fetchMedia(request);
723
+ if (!response.ok) {
724
+ throw new MediaFetchError("http_error", `Media fetch failed: ${response.status}`, response.status);
725
+ }
726
+ const trusted = trustedContentType(request.contentType, response.headers.get("content-type"));
727
+ if (!trusted.ok) {
728
+ await response.body?.cancel();
729
+ throw new MediaFetchError(trusted.reason, `Refusing to preview: ${trusted.reason}`);
730
+ }
731
+ const bytes = await readMediaBody(response, request.maxBytes);
732
+ return { bytes, contentType: trusted.contentType };
733
+ }
734
+ function tooLarge(maxBytes) {
735
+ return new MediaFetchError("too_large", `Media is larger than ${maxBytes} bytes`);
736
+ }
737
+ async function readMediaBody(response, maxBytes) {
738
+ if (maxBytes === void 0) return new Uint8Array(await response.arrayBuffer());
739
+ const declaredBytes = Number(response.headers.get("content-length") ?? Number.NaN);
740
+ if (declaredBytes > maxBytes) {
741
+ await response.body?.cancel();
742
+ throw tooLarge(maxBytes);
743
+ }
744
+ if (!response.body) return new Uint8Array(0);
745
+ return readCapped(response.body, maxBytes);
746
+ }
747
+ async function readCapped(body, maxBytes) {
748
+ const reader = body.getReader();
749
+ const chunks = [];
750
+ let byteCount = 0;
751
+ for (; ; ) {
752
+ const { done, value } = await reader.read();
753
+ if (done) break;
754
+ byteCount += value.byteLength;
755
+ if (byteCount > maxBytes) {
756
+ await reader.cancel();
757
+ throw tooLarge(maxBytes);
758
+ }
759
+ chunks.push(value);
760
+ }
761
+ const bytes = new Uint8Array(byteCount);
762
+ let offset = 0;
763
+ for (const chunk of chunks) {
764
+ bytes.set(chunk, offset);
765
+ offset += chunk.byteLength;
766
+ }
767
+ return bytes;
768
+ }
769
+
770
+ // src/download-link.tsx
771
+ import { jsx as jsx3 } from "react/jsx-runtime";
772
+ function DownloadLink({ media, filename, onDownloadError, children, ...anchorProps }) {
773
+ const devultur = useDevultur();
774
+ const { url, token, tokenStatus, tokenError, baseUrl, sessionSecret, reissueToken } = devultur;
775
+ useRequestTokenWhen(devultur, media?.visibility === "private");
776
+ const handleClick = React6.useCallback(
777
+ async (event) => {
778
+ if (media?.visibility !== "private" || !baseUrl) return;
779
+ event.preventDefault();
780
+ if (tokenStatus === "error") {
781
+ onDownloadError?.(tokenUnavailableError(tokenError));
782
+ return;
783
+ }
784
+ const response = await fetchMedia({
785
+ buildUrl: (viewerToken) => mediaUrl2(baseUrl, media, { download: filename ?? true, token: viewerToken }),
786
+ token,
787
+ sessionSecret,
788
+ reissueToken
789
+ });
790
+ if (!response.ok) {
791
+ const status = response.status;
792
+ onDownloadError?.(new MediaFetchError("http_error", `Media fetch failed: ${status}`, status));
793
+ return;
794
+ }
795
+ const objectUrl = URL.createObjectURL(await response.blob());
796
+ const anchor = document.createElement("a");
797
+ anchor.href = objectUrl;
798
+ anchor.download = filename ?? media.filename ?? "download";
799
+ anchor.click();
800
+ URL.revokeObjectURL(objectUrl);
801
+ },
802
+ [media, baseUrl, token, tokenStatus, tokenError, filename, sessionSecret, reissueToken, onDownloadError]
474
803
  );
804
+ if (!media) return null;
805
+ return /* @__PURE__ */ jsx3("a", { href: url(media, { download: filename ?? true }), download: true, ...anchorProps, onClick: handleClick, children });
475
806
  }
476
807
 
477
808
  // src/document-renderers.tsx
478
- import { jsx as jsx3, jsxs as jsxs2 } from "react/jsx-runtime";
809
+ import { jsx as jsx4, jsxs as jsxs2 } from "react/jsx-runtime";
479
810
  function DocumentLink({ media, download = false, children, labels, ...anchorProps }) {
480
811
  const { url } = useDevultur();
481
812
  if (!media) return null;
482
813
  const text = children ?? attachmentName(media, mergeAttachmentLabels(labels).untitled);
483
814
  if (download || media.visibility === "private") {
484
- return /* @__PURE__ */ jsx3(DownloadLink, { media, ...anchorProps, children: text });
815
+ return /* @__PURE__ */ jsx4(DownloadLink, { media, ...anchorProps, children: text });
485
816
  }
486
- return /* @__PURE__ */ jsx3("a", { href: url(media), ...anchorProps, children: text });
817
+ return /* @__PURE__ */ jsx4("a", { href: url(media), ...anchorProps, children: text });
487
818
  }
488
819
  function AttachmentCard({
489
820
  media,
@@ -495,26 +826,26 @@ function AttachmentCard({
495
826
  labels,
496
827
  ...articleProps
497
828
  }) {
498
- const headingId = React4.useId();
829
+ const headingId = React7.useId();
499
830
  if (!media) return null;
500
831
  const text = mergeAttachmentLabels(labels);
501
832
  const kind = attachmentKindOf(media, contentType);
502
833
  const details = attachmentDetails(media, kind, text, locale ?? text.locale);
503
834
  const Heading = `h${headingLevel}`;
504
835
  return /* @__PURE__ */ jsxs2("article", { "aria-labelledby": headingId, "data-kind": kind, ...articleProps, children: [
505
- preview ?? /* @__PURE__ */ jsx3(AttachmentIcon, { kind }),
506
- /* @__PURE__ */ jsx3(Heading, { id: headingId, "data-part": "name", children: attachmentName(media, text.untitled) }),
507
- /* @__PURE__ */ jsx3("p", { "data-part": "details", children: details.join(" \xB7 ") }),
836
+ preview ?? /* @__PURE__ */ jsx4(AttachmentIcon, { kind }),
837
+ /* @__PURE__ */ jsx4(Heading, { id: headingId, "data-part": "name", children: attachmentName(media, text.untitled) }),
838
+ /* @__PURE__ */ jsx4("p", { "data-part": "details", children: details.join(" \xB7 ") }),
508
839
  /* @__PURE__ */ jsxs2("div", { "data-part": "actions", children: [
509
- /* @__PURE__ */ jsx3(DownloadLink, { media, "aria-describedby": headingId, "data-part": "download", children: text.download }),
510
- onOpen && /* @__PURE__ */ jsx3("button", { type: "button", onClick: () => onOpen(media), "aria-describedby": headingId, "data-part": "open", children: text.open })
840
+ /* @__PURE__ */ jsx4(DownloadLink, { media, "aria-describedby": headingId, "data-part": "download", children: text.download }),
841
+ onOpen && /* @__PURE__ */ jsx4("button", { type: "button", onClick: () => onOpen(media), "aria-describedby": headingId, "data-part": "open", children: text.open })
511
842
  ] })
512
843
  ] });
513
844
  }
514
845
 
515
846
  // src/image.tsx
516
- import React5 from "react";
517
- import { mediaUrl as mediaUrl2 } from "@devultur/core";
847
+ import React8 from "react";
848
+ import { mediaUrl as mediaUrl3 } from "@devultur/core";
518
849
 
519
850
  // src/responsive-image.ts
520
851
  function normalizeWidths(widths) {
@@ -558,7 +889,7 @@ function renderedWidthFromSizes(sizes) {
558
889
  }
559
890
 
560
891
  // src/image.tsx
561
- import { Fragment, jsx as jsx4 } from "react/jsx-runtime";
892
+ import { Fragment, jsx as jsx5 } from "react/jsx-runtime";
562
893
  function Image({
563
894
  media,
564
895
  width,
@@ -605,14 +936,14 @@ function Image({
605
936
  } else {
606
937
  src = url(media, transforms);
607
938
  }
608
- if (!src) return /* @__PURE__ */ jsx4(Fragment, { children: fallback });
939
+ if (!src) return /* @__PURE__ */ jsx5(Fragment, { children: fallback });
609
940
  if (render) {
610
- return React5.cloneElement(render, {
941
+ return React8.cloneElement(render, {
611
942
  style: { ...render.props.style, backgroundImage: `url("${src}")` }
612
943
  });
613
944
  }
614
945
  const lazyProps = responsiveWidths ? { loading: "lazy", decoding: "async" } : {};
615
- return /* @__PURE__ */ jsx4(
946
+ return /* @__PURE__ */ jsx5(
616
947
  "img",
617
948
  {
618
949
  src,
@@ -627,26 +958,28 @@ function Image({
627
958
  );
628
959
  }
629
960
  function usePrivateImage(media, transforms, onLoadError) {
630
- const { token, tokenStatus, tokenError, baseUrl, reissueToken, sessionSecret } = useDevultur();
631
- const [isFailed, setIsFailed] = React5.useState(false);
632
- const [blobSrc, setBlobSrc] = React5.useState(null);
633
- const sessionRef = React5.useRef({ token, reissueToken, onLoadError });
634
- React5.useEffect(() => {
961
+ const devultur = useDevultur();
962
+ const { token, tokenStatus, tokenError, baseUrl, reissueToken, sessionSecret } = devultur;
963
+ const [isFailed, setIsFailed] = React8.useState(false);
964
+ const [blobSrc, setBlobSrc] = React8.useState(null);
965
+ const sessionRef = React8.useRef({ token, reissueToken, onLoadError });
966
+ React8.useEffect(() => {
635
967
  sessionRef.current = { token, reissueToken, onLoadError };
636
968
  });
637
969
  const isPrivate = media?.visibility === "private";
970
+ useRequestTokenWhen(devultur, isPrivate);
638
971
  const hasToken = token !== null;
639
972
  const isTokenUnavailable = isPrivate && tokenStatus === "error";
640
973
  const { width, height, fit, format, quality } = transforms;
641
- React5.useEffect(() => {
974
+ React8.useEffect(() => {
642
975
  setIsFailed(false);
643
976
  setBlobSrc(null);
644
977
  }, [media]);
645
- React5.useEffect(() => {
978
+ React8.useEffect(() => {
646
979
  if (!isTokenUnavailable || !media) return;
647
980
  sessionRef.current.onLoadError?.(tokenUnavailableError(tokenError));
648
981
  }, [isTokenUnavailable, media, tokenError]);
649
- React5.useEffect(() => {
982
+ React8.useEffect(() => {
650
983
  if (!isPrivate || isFailed || !media || !hasToken) return;
651
984
  let cancelled = false;
652
985
  let objectUrl = null;
@@ -660,7 +993,7 @@ function usePrivateImage(media, transforms, onLoadError) {
660
993
  (async () => {
661
994
  try {
662
995
  const response = await fetchMedia({
663
- buildUrl: (viewerToken) => mediaUrl2(baseUrl, media, { ...fetchTransforms, token: viewerToken }),
996
+ buildUrl: (viewerToken) => mediaUrl3(baseUrl, media, { ...fetchTransforms, token: viewerToken }),
664
997
  token: sessionRef.current.token,
665
998
  sessionSecret,
666
999
  reissueToken: sessionRef.current.reissueToken
@@ -685,41 +1018,42 @@ function usePrivateImage(media, transforms, onLoadError) {
685
1018
  if (objectUrl) URL.revokeObjectURL(objectUrl);
686
1019
  };
687
1020
  }, [isPrivate, isFailed, media, hasToken, baseUrl, sessionSecret, width, height, fit, format, quality]);
688
- const handleError = React5.useCallback(() => setIsFailed(true), []);
1021
+ const handleError = React8.useCallback(() => setIsFailed(true), []);
689
1022
  return { blobSrc, isFailed: isFailed || isTokenUnavailable, handleError };
690
1023
  }
691
1024
 
692
1025
  // src/media-library.tsx
693
- import React14 from "react";
1026
+ import React17 from "react";
694
1027
 
695
1028
  // src/media-library-item.tsx
696
- import React11 from "react";
1029
+ import React14 from "react";
697
1030
 
698
1031
  // src/pdf-thumbnail.tsx
699
- import React10 from "react";
1032
+ import React13 from "react";
700
1033
 
701
1034
  // src/media-resource.ts
702
- import React6 from "react";
703
- import { mediaPath, mediaUrl as mediaUrl3 } from "@devultur/core";
1035
+ import React9 from "react";
1036
+ import { mediaPath as mediaPath3, mediaUrl as mediaUrl4 } from "@devultur/core";
704
1037
  var IDLE = { value: null, contentType: null, isLoading: false, error: null };
705
1038
  var LOADING = { value: null, contentType: null, isLoading: true, error: null };
706
1039
  function useMediaResource(media, options) {
707
1040
  const devultur = useDevultur();
708
1041
  const { isEnabled, maxBytes, contentType } = options;
709
1042
  const isPrivate = media?.visibility === "private";
1043
+ useRequestTokenWhen(devultur, isPrivate && isEnabled);
710
1044
  const isReady = !isPrivate || devultur.token !== null;
711
1045
  const isTokenUnavailable = isPrivate && devultur.tokenStatus === "error";
712
- const tokenFailure = React6.useMemo(
1046
+ const tokenFailure = React9.useMemo(
713
1047
  () => isTokenUnavailable ? tokenUnavailableError(devultur.tokenError) : null,
714
1048
  [isTokenUnavailable, devultur.tokenError]
715
1049
  );
716
- const requestKey = media && isEnabled ? [devultur.baseUrl, media.visibility, mediaPath(media), contentType ?? "", maxBytes ?? ""].join("|") : null;
717
- const [settled, setSettled] = React6.useState(null);
718
- const inputsRef = React6.useRef({ media, devultur, options });
719
- React6.useEffect(() => {
1050
+ const requestKey = media && isEnabled ? [devultur.baseUrl, media.visibility, mediaPath3(media), contentType ?? "", maxBytes ?? ""].join("|") : null;
1051
+ const [settled, setSettled] = React9.useState(null);
1052
+ const inputsRef = React9.useRef({ media, devultur, options });
1053
+ React9.useEffect(() => {
720
1054
  inputsRef.current = { media, devultur, options };
721
1055
  });
722
- React6.useEffect(() => {
1056
+ React9.useEffect(() => {
723
1057
  if (!requestKey || !isReady) return;
724
1058
  const { media: target, devultur: context, options: current } = inputsRef.current;
725
1059
  if (!target || !context.baseUrl) return;
@@ -727,7 +1061,7 @@ function useMediaResource(media, options) {
727
1061
  const controller = new AbortController();
728
1062
  let value = null;
729
1063
  loadMedia({
730
- buildUrl: (viewerToken) => mediaUrl3(baseUrl, target, { token: viewerToken }),
1064
+ buildUrl: (viewerToken) => mediaUrl4(baseUrl, target, { token: viewerToken }),
731
1065
  token: context.token,
732
1066
  sessionSecret: context.sessionSecret,
733
1067
  reissueToken: context.reissueToken,
@@ -757,7 +1091,7 @@ function useMediaResource(media, options) {
757
1091
  }
758
1092
 
759
1093
  // src/pdf-context.tsx
760
- import React7 from "react";
1094
+ import React10 from "react";
761
1095
 
762
1096
  // src/preview-labels.ts
763
1097
  var ERROR_TEXT = {
@@ -792,16 +1126,16 @@ function mergePreviewLabels(...sources) {
792
1126
  }
793
1127
 
794
1128
  // src/pdf-context.tsx
795
- import { jsx as jsx5 } from "react/jsx-runtime";
796
- var PdfPreviewContext = /* @__PURE__ */ React7.createContext({});
1129
+ import { jsx as jsx6 } from "react/jsx-runtime";
1130
+ var PdfPreviewContext = /* @__PURE__ */ React10.createContext({});
797
1131
  function PdfPreviewProvider({
798
1132
  pdfjs,
799
1133
  workerSrc,
800
1134
  labels,
801
1135
  children
802
1136
  }) {
803
- const config = React7.useMemo(() => ({ pdfjs, workerSrc, labels }), [pdfjs, workerSrc, labels]);
804
- return /* @__PURE__ */ jsx5(PdfPreviewContext.Provider, { value: config, children });
1137
+ const config = React10.useMemo(() => ({ pdfjs, workerSrc, labels }), [pdfjs, workerSrc, labels]);
1138
+ return /* @__PURE__ */ jsx6(PdfPreviewContext.Provider, { value: config, children });
805
1139
  }
806
1140
  var loadedModules = /* @__PURE__ */ new WeakMap();
807
1141
  function loadOnce(loader) {
@@ -812,9 +1146,9 @@ function loadOnce(loader) {
812
1146
  return loading;
813
1147
  }
814
1148
  function usePdfjs(workerSrcOverride) {
815
- const { pdfjs: loader, workerSrc } = React7.useContext(PdfPreviewContext);
816
- const [loaded, setLoaded] = React7.useState(null);
817
- React7.useEffect(() => {
1149
+ const { pdfjs: loader, workerSrc } = React10.useContext(PdfPreviewContext);
1150
+ const [loaded, setLoaded] = React10.useState(null);
1151
+ React10.useEffect(() => {
818
1152
  if (!loader) return;
819
1153
  let isCurrent = true;
820
1154
  loadOnce(loader).then((pdfjs) => {
@@ -830,8 +1164,8 @@ function usePdfjs(workerSrcOverride) {
830
1164
  return { status: "ready", pdfjs: loaded.pdfjs, workerSrc: workerSrcOverride ?? workerSrc };
831
1165
  }
832
1166
  function usePdfPageCounter() {
833
- const { pdfjs: loader, workerSrc } = React7.useContext(PdfPreviewContext);
834
- return React7.useMemo(() => {
1167
+ const { pdfjs: loader, workerSrc } = React10.useContext(PdfPreviewContext);
1168
+ return React10.useMemo(() => {
835
1169
  if (!loader) return null;
836
1170
  return async (file) => {
837
1171
  const pdfjs = await loadOnce(loader);
@@ -841,15 +1175,15 @@ function usePdfPageCounter() {
841
1175
  }, [loader, workerSrc]);
842
1176
  }
843
1177
  function usePreviewLabels(labels) {
844
- const providerLabels = React7.useContext(PdfPreviewContext).labels;
1178
+ const providerLabels = React10.useContext(PdfPreviewContext).labels;
845
1179
  return mergePreviewLabels(providerLabels, labels);
846
1180
  }
847
1181
 
848
1182
  // src/pdf-hooks.ts
849
- import React8 from "react";
1183
+ import React11 from "react";
850
1184
  function useIsVisible(ref, rootMargin = "200px") {
851
- const [isVisible, setIsVisible] = React8.useState(false);
852
- React8.useEffect(() => {
1185
+ const [isVisible, setIsVisible] = React11.useState(false);
1186
+ React11.useEffect(() => {
853
1187
  const element = ref.current;
854
1188
  if (isVisible || !element) return;
855
1189
  if (typeof IntersectionObserver === "undefined") {
@@ -870,12 +1204,12 @@ function useIsVisible(ref, rootMargin = "200px") {
870
1204
  return isVisible;
871
1205
  }
872
1206
  function usePdfDocument({ pdfjs, bytes, workerSrc }) {
873
- const [state, setState] = React8.useState({
1207
+ const [state, setState] = React11.useState({
874
1208
  bytes: null,
875
1209
  document: null,
876
1210
  error: null
877
1211
  });
878
- React8.useEffect(() => {
1212
+ React11.useEffect(() => {
879
1213
  let isCurrent = true;
880
1214
  const loadingTask = openPdf(pdfjs, bytes, workerSrc);
881
1215
  loadingTask.promise.then((document2) => {
@@ -894,8 +1228,8 @@ function usePdfDocument({ pdfjs, bytes, workerSrc }) {
894
1228
  }
895
1229
  function usePageRender(options) {
896
1230
  const { document: document2, pageNumber, canvasRef, width, isEnabled, shouldExtractText = false } = options;
897
- const [state, setState] = React8.useState({ isRendered: false, text: "" });
898
- React8.useEffect(() => {
1231
+ const [state, setState] = React11.useState({ isRendered: false, text: "" });
1232
+ React11.useEffect(() => {
899
1233
  const canvas = canvasRef.current;
900
1234
  if (!document2 || !isEnabled || !canvas) return;
901
1235
  let isCurrent = true;
@@ -925,8 +1259,8 @@ function usePageRender(options) {
925
1259
  }
926
1260
 
927
1261
  // src/pdf-preview.tsx
928
- import React9 from "react";
929
- import { Fragment as Fragment2, jsx as jsx6, jsxs as jsxs3 } from "react/jsx-runtime";
1262
+ import React12 from "react";
1263
+ import { Fragment as Fragment2, jsx as jsx7, jsxs as jsxs3 } from "react/jsx-runtime";
930
1264
  var VISUALLY_HIDDEN = {
931
1265
  position: "absolute",
932
1266
  width: 1,
@@ -949,11 +1283,11 @@ function PdfPreview(props) {
949
1283
  contentType: expectedType,
950
1284
  decode: keepBytes
951
1285
  });
952
- if (error) return /* @__PURE__ */ jsx6(Fragment2, { children: renderFallback(error) });
953
- if (!bytes || pdfjsState.status === "loading") return /* @__PURE__ */ jsx6(Fragment2, { children: loading });
1286
+ if (error) return /* @__PURE__ */ jsx7(Fragment2, { children: renderFallback(error) });
1287
+ if (!bytes || pdfjsState.status === "loading") return /* @__PURE__ */ jsx7(Fragment2, { children: loading });
954
1288
  const label = title ?? media.filename ?? labels.untitledPdf;
955
- if (pdfjsState.status === "unavailable") return /* @__PURE__ */ jsx6(PdfFrame, { bytes, label, className });
956
- return /* @__PURE__ */ jsx6(
1289
+ if (pdfjsState.status === "unavailable") return /* @__PURE__ */ jsx7(PdfFrame, { bytes, label, className });
1290
+ return /* @__PURE__ */ jsx7(
957
1291
  PdfCanvasDocument,
958
1292
  {
959
1293
  pdfjs: pdfjsState.pdfjs,
@@ -971,13 +1305,13 @@ function PdfPreview(props) {
971
1305
  function PdfCanvasDocument(props) {
972
1306
  const { pdfjs, workerSrc, bytes, label, className, renderFallback, loading, labels, maxPages } = props;
973
1307
  const { document: document2, error } = usePdfDocument({ pdfjs, bytes, workerSrc });
974
- const containerRef = React9.useRef(null);
975
- const [width, setWidth] = React9.useState(null);
976
- React9.useLayoutEffect(() => {
1308
+ const containerRef = React12.useRef(null);
1309
+ const [width, setWidth] = React12.useState(null);
1310
+ React12.useLayoutEffect(() => {
977
1311
  if (document2) setWidth(containerRef.current?.clientWidth ?? 0);
978
1312
  }, [document2]);
979
- if (error) return /* @__PURE__ */ jsx6(Fragment2, { children: renderFallback(renderFailedError(error)) });
980
- if (!document2) return /* @__PURE__ */ jsx6(Fragment2, { children: loading });
1313
+ if (error) return /* @__PURE__ */ jsx7(Fragment2, { children: renderFallback(renderFailedError(error)) });
1314
+ if (!document2) return /* @__PURE__ */ jsx7(Fragment2, { children: loading });
981
1315
  const shownPageCount = Math.min(document2.numPages, Math.max(1, maxPages));
982
1316
  const isTruncated = shownPageCount < document2.numPages;
983
1317
  const pageNumbers = Array.from({ length: shownPageCount }, (_, index) => index + 1);
@@ -989,7 +1323,7 @@ function PdfCanvasDocument(props) {
989
1323
  "aria-label": labels.document(label, document2.numPages),
990
1324
  className,
991
1325
  children: [
992
- width !== null && pageNumbers.map((pageNumber) => /* @__PURE__ */ jsx6(
1326
+ width !== null && pageNumbers.map((pageNumber) => /* @__PURE__ */ jsx7(
993
1327
  PdfPageView,
994
1328
  {
995
1329
  document: document2,
@@ -1009,8 +1343,8 @@ function PdfCanvasDocument(props) {
1009
1343
  );
1010
1344
  }
1011
1345
  function PdfPageView({ document: document2, pageNumber, width, label }) {
1012
- const pageRef = React9.useRef(null);
1013
- const canvasRef = React9.useRef(null);
1346
+ const pageRef = React12.useRef(null);
1347
+ const canvasRef = React12.useRef(null);
1014
1348
  const isVisible = useIsVisible(pageRef);
1015
1349
  const { isRendered, text } = usePageRender({
1016
1350
  document: document2,
@@ -1022,28 +1356,28 @@ function PdfPageView({ document: document2, pageNumber, width, label }) {
1022
1356
  });
1023
1357
  const placeholderStyle = isRendered ? void 0 : { aspectRatio: "1 / 1.4142", maxWidth: "100%" };
1024
1358
  return /* @__PURE__ */ jsxs3("section", { ref: pageRef, "aria-label": label, style: placeholderStyle, children: [
1025
- /* @__PURE__ */ jsx6("canvas", { ref: canvasRef, style: { display: "block", maxWidth: "100%" } }),
1026
- text && /* @__PURE__ */ jsx6("p", { style: VISUALLY_HIDDEN, children: text })
1359
+ /* @__PURE__ */ jsx7("canvas", { ref: canvasRef, style: { display: "block", maxWidth: "100%" } }),
1360
+ text && /* @__PURE__ */ jsx7("p", { style: VISUALLY_HIDDEN, children: text })
1027
1361
  ] });
1028
1362
  }
1029
1363
  function PdfFrame({ bytes, label, className }) {
1030
- const [objectUrl, setObjectUrl] = React9.useState(null);
1031
- React9.useEffect(() => {
1364
+ const [objectUrl, setObjectUrl] = React12.useState(null);
1365
+ React12.useEffect(() => {
1032
1366
  const url = URL.createObjectURL(new Blob([bytes], { type: "application/pdf" }));
1033
1367
  setObjectUrl(url);
1034
1368
  return () => URL.revokeObjectURL(url);
1035
1369
  }, [bytes]);
1036
1370
  if (!objectUrl) return null;
1037
- return /* @__PURE__ */ jsx6("iframe", { src: objectUrl, title: label, referrerPolicy: "no-referrer", className });
1371
+ return /* @__PURE__ */ jsx7("iframe", { src: objectUrl, title: label, referrerPolicy: "no-referrer", className });
1038
1372
  }
1039
1373
 
1040
1374
  // src/pdf-thumbnail.tsx
1041
- import { Fragment as Fragment3, jsx as jsx7, jsxs as jsxs4 } from "react/jsx-runtime";
1375
+ import { Fragment as Fragment3, jsx as jsx8, jsxs as jsxs4 } from "react/jsx-runtime";
1042
1376
  var DEFAULT_THUMBNAIL_WIDTH = 96;
1043
1377
  var DEFAULT_THUMBNAIL_MAX_BYTES = 50 * 1024 * 1024;
1044
1378
  function PdfThumbnail(props) {
1045
1379
  const { media, contentType, width = DEFAULT_THUMBNAIL_WIDTH, fallback = null, loading = null } = props;
1046
- const containerRef = React10.useRef(null);
1380
+ const containerRef = React13.useRef(null);
1047
1381
  const isVisible = useIsVisible(containerRef);
1048
1382
  const pdfjsState = usePdfjs(props.pdfWorkerSrc);
1049
1383
  const labels = usePreviewLabels(props.labels);
@@ -1060,7 +1394,7 @@ function PdfThumbnail(props) {
1060
1394
  const content = () => {
1061
1395
  if (isUnavailable) return fallback;
1062
1396
  if (!bytes || pdfjsState.status !== "ready") return loading;
1063
- return /* @__PURE__ */ jsx7(
1397
+ return /* @__PURE__ */ jsx8(
1064
1398
  ThumbnailCanvas,
1065
1399
  {
1066
1400
  pdfjs: pdfjsState.pdfjs,
@@ -1073,21 +1407,21 @@ function PdfThumbnail(props) {
1073
1407
  }
1074
1408
  );
1075
1409
  };
1076
- return /* @__PURE__ */ jsx7("div", { ref: containerRef, className: props.className, style: { width }, children: content() });
1410
+ return /* @__PURE__ */ jsx8("div", { ref: containerRef, className: props.className, style: { width }, children: content() });
1077
1411
  }
1078
1412
  function ThumbnailCanvas({ pdfjs, workerSrc, bytes, width, label, fallback, loading }) {
1079
- const canvasRef = React10.useRef(null);
1413
+ const canvasRef = React13.useRef(null);
1080
1414
  const { document: document2, error } = usePdfDocument({ pdfjs, bytes, workerSrc });
1081
1415
  const { isRendered } = usePageRender({ document: document2, pageNumber: 1, canvasRef, width, isEnabled: true });
1082
- if (error) return /* @__PURE__ */ jsx7(Fragment3, { children: fallback });
1416
+ if (error) return /* @__PURE__ */ jsx8(Fragment3, { children: fallback });
1083
1417
  return /* @__PURE__ */ jsxs4(Fragment3, { children: [
1084
1418
  !isRendered && loading,
1085
- /* @__PURE__ */ jsx7("canvas", { ref: canvasRef, role: "img", "aria-label": label, style: { display: "block" } })
1419
+ /* @__PURE__ */ jsx8("canvas", { ref: canvasRef, role: "img", "aria-label": label, style: { display: "block" } })
1086
1420
  ] });
1087
1421
  }
1088
1422
 
1089
1423
  // src/media-library-item.tsx
1090
- import { Fragment as Fragment4, jsx as jsx8, jsxs as jsxs5 } from "react/jsx-runtime";
1424
+ import { Fragment as Fragment4, jsx as jsx9, jsxs as jsxs5 } from "react/jsx-runtime";
1091
1425
  var THUMBNAIL_WIDTH = 160;
1092
1426
  var THUMBNAIL_WIDTHS = [THUMBNAIL_WIDTH, THUMBNAIL_WIDTH * 2];
1093
1427
  function partClass(part, classNames) {
@@ -1107,10 +1441,10 @@ function kindOf(record) {
1107
1441
  }
1108
1442
  function Thumbnail({ record, media, pdfThumbnails }) {
1109
1443
  const kind = kindOf(record);
1110
- const icon = /* @__PURE__ */ jsx8(AttachmentIcon, { kind });
1444
+ const icon = /* @__PURE__ */ jsx9(AttachmentIcon, { kind });
1111
1445
  if (!media) return icon;
1112
1446
  if (record.type === "image") {
1113
- return /* @__PURE__ */ jsx8(
1447
+ return /* @__PURE__ */ jsx9(
1114
1448
  Image,
1115
1449
  {
1116
1450
  media,
@@ -1125,14 +1459,14 @@ function Thumbnail({ record, media, pdfThumbnails }) {
1125
1459
  );
1126
1460
  }
1127
1461
  if (kind !== "pdf" || !pdfThumbnails) return icon;
1128
- return /* @__PURE__ */ jsx8(PdfThumbnail, { media, contentType: record.contentType, width: THUMBNAIL_WIDTH, fallback: icon });
1462
+ return /* @__PURE__ */ jsx9(PdfThumbnail, { media, contentType: record.contentType, width: THUMBNAIL_WIDTH, fallback: icon });
1129
1463
  }
1130
1464
  function DefaultContent({ record, media, pdfThumbnails, labels, classNames }) {
1131
1465
  const name = record.title ?? record.filename;
1132
1466
  return /* @__PURE__ */ jsxs5(Fragment4, { children: [
1133
- /* @__PURE__ */ jsx8("span", { className: partClass("thumbnail", classNames), children: /* @__PURE__ */ jsx8(Thumbnail, { record, media, pdfThumbnails }) }),
1134
- /* @__PURE__ */ jsx8("span", { className: partClass("name", classNames), children: name }),
1135
- record.sizeBytes > 0 && /* @__PURE__ */ jsx8("span", { className: partClass("size", classNames), children: labels.size(record.sizeBytes) })
1467
+ /* @__PURE__ */ jsx9("span", { className: partClass("thumbnail", classNames), children: /* @__PURE__ */ jsx9(Thumbnail, { record, media, pdfThumbnails }) }),
1468
+ /* @__PURE__ */ jsx9("span", { className: partClass("name", classNames), children: name }),
1469
+ record.sizeBytes > 0 && /* @__PURE__ */ jsx9("span", { className: partClass("size", classNames), children: labels.size(record.sizeBytes) })
1136
1470
  ] });
1137
1471
  }
1138
1472
  var ITEM_STYLE = {
@@ -1144,13 +1478,13 @@ var ITEM_STYLE = {
1144
1478
  };
1145
1479
  function MediaLibraryItem(props) {
1146
1480
  const { ref, record, isSelected, isActive, tabIndex, classNames, renderItem, onChoose, onFocus } = props;
1147
- const media = React11.useMemo(() => mediaFromRecord(record), [record]);
1481
+ const media = React14.useMemo(() => mediaFromRecord(record), [record]);
1148
1482
  const handleKeyDown = (event) => {
1149
1483
  if (event.key !== "Enter" && event.key !== " ") return;
1150
1484
  event.preventDefault();
1151
1485
  onChoose();
1152
1486
  };
1153
- return /* @__PURE__ */ jsx8(
1487
+ return /* @__PURE__ */ jsx9(
1154
1488
  "div",
1155
1489
  {
1156
1490
  ref,
@@ -1167,7 +1501,7 @@ function MediaLibraryItem(props) {
1167
1501
  onClick: onChoose,
1168
1502
  onKeyDown: handleKeyDown,
1169
1503
  onFocus,
1170
- children: renderItem ? renderItem(record, { media, isSelected, isActive }) : /* @__PURE__ */ jsx8(
1504
+ children: renderItem ? renderItem(record, { media, isSelected, isActive }) : /* @__PURE__ */ jsx9(
1171
1505
  DefaultContent,
1172
1506
  {
1173
1507
  record,
@@ -1207,13 +1541,13 @@ function mergeMediaLibraryLabels(labels) {
1207
1541
  }
1208
1542
 
1209
1543
  // src/use-media-library.ts
1210
- import React12 from "react";
1544
+ import React15 from "react";
1211
1545
  import { usePaginatedQuery } from "convex/react";
1212
1546
  var SEARCH_DEBOUNCE_MS = 250;
1213
1547
  var DEFAULT_PAGE_SIZE = 24;
1214
1548
  function useDebouncedSearch(value, delayMs) {
1215
- const [debounced, setDebounced] = React12.useState(value);
1216
- React12.useEffect(() => {
1549
+ const [debounced, setDebounced] = React15.useState(value);
1550
+ React15.useEffect(() => {
1217
1551
  if (value === "") {
1218
1552
  setDebounced("");
1219
1553
  return;
@@ -1238,7 +1572,7 @@ function useMediaLibrary(options = {}) {
1238
1572
  const args = isSearching ? { search, ...typeArg } : typeArg;
1239
1573
  const result = usePaginatedQuery(reference, args, { initialNumItems: pageSize });
1240
1574
  const resultLoadMore = result.loadMore;
1241
- const loadMore = React12.useCallback(
1575
+ const loadMore = React15.useCallback(
1242
1576
  (numItems) => resultLoadMore(numItems ?? pageSize),
1243
1577
  [resultLoadMore, pageSize]
1244
1578
  );
@@ -1253,7 +1587,7 @@ function useMediaLibrary(options = {}) {
1253
1587
  }
1254
1588
 
1255
1589
  // src/use-roving-focus.ts
1256
- import React13 from "react";
1590
+ import React16 from "react";
1257
1591
  function nextIndex(key, current, count, columns) {
1258
1592
  const last = count - 1;
1259
1593
  const clamp = (index) => Math.min(Math.max(index, 0), last);
@@ -1281,16 +1615,16 @@ function columnCount(items) {
1281
1615
  return Math.max(inFirstRow.length, 1);
1282
1616
  }
1283
1617
  function useRovingFocus(count) {
1284
- const [activeIndex, setActiveIndex] = React13.useState(0);
1285
- const itemsRef = React13.useRef([]);
1618
+ const [activeIndex, setActiveIndex] = React16.useState(0);
1619
+ const itemsRef = React16.useRef([]);
1286
1620
  const currentIndex = Math.min(activeIndex, Math.max(count - 1, 0));
1287
- const itemRef = React13.useCallback(
1621
+ const itemRef = React16.useCallback(
1288
1622
  (index) => (element) => {
1289
1623
  itemsRef.current[index] = element;
1290
1624
  },
1291
1625
  []
1292
1626
  );
1293
- const handleKeyDown = React13.useCallback(
1627
+ const handleKeyDown = React16.useCallback(
1294
1628
  (event) => {
1295
1629
  if (count === 0) return;
1296
1630
  const items = itemsRef.current.slice(0, count);
@@ -1307,7 +1641,7 @@ function useRovingFocus(count) {
1307
1641
  }
1308
1642
 
1309
1643
  // src/media-library.tsx
1310
- import { Fragment as Fragment5, jsx as jsx9, jsxs as jsxs6 } from "react/jsx-runtime";
1644
+ import { Fragment as Fragment5, jsx as jsx10, jsxs as jsxs6 } from "react/jsx-runtime";
1311
1645
  var MEDIA_TYPES = ["image", "video", "file"];
1312
1646
  var ROOT_STYLE = {
1313
1647
  display: "flex",
@@ -1322,16 +1656,16 @@ var LIST_STYLE = {
1322
1656
  padding: 0
1323
1657
  };
1324
1658
  function MediaLibrary(props) {
1325
- const labels = React14.useMemo(() => mergeMediaLibraryLabels(props.labels), [props.labels]);
1659
+ const labels = React17.useMemo(() => mergeMediaLibraryLabels(props.labels), [props.labels]);
1326
1660
  const classNames = { ...props.classNames, root: props.className ?? props.classNames?.root };
1327
- const [search, setSearch] = React14.useState("");
1328
- const [chosenType, setChosenType] = React14.useState("");
1329
- const [selectedKey, setSelectedKey] = React14.useState(null);
1661
+ const [search, setSearch] = React17.useState("");
1662
+ const [chosenType, setChosenType] = React17.useState("");
1663
+ const [selectedKey, setSelectedKey] = React17.useState(null);
1330
1664
  const type = props.type ?? (chosenType === "" ? void 0 : chosenType);
1331
1665
  const { onSelect } = props;
1332
1666
  const isSelectable = props.selectable ?? props.selectedKeys !== void 0;
1333
1667
  const selectedKeys = props.selectedKeys ?? (selectedKey === null ? [] : [selectedKey]);
1334
- const handleChoose = React14.useCallback(
1668
+ const handleChoose = React17.useCallback(
1335
1669
  (record) => {
1336
1670
  if (isSelectable) setSelectedKey(record.key);
1337
1671
  onSelect(record, mediaFromRecord(record));
@@ -1339,7 +1673,7 @@ function MediaLibrary(props) {
1339
1673
  [isSelectable, onSelect]
1340
1674
  );
1341
1675
  return /* @__PURE__ */ jsxs6("div", { className: partClass("root", classNames), style: ROOT_STYLE, children: [
1342
- /* @__PURE__ */ jsx9(
1676
+ /* @__PURE__ */ jsx10(
1343
1677
  "input",
1344
1678
  {
1345
1679
  type: "search",
@@ -1358,20 +1692,20 @@ function MediaLibrary(props) {
1358
1692
  value: chosenType,
1359
1693
  onChange: (event) => setChosenType(event.target.value),
1360
1694
  children: [
1361
- /* @__PURE__ */ jsx9("option", { value: "", children: labels.allTypes }),
1362
- MEDIA_TYPES.map((mediaType) => /* @__PURE__ */ jsx9("option", { value: mediaType, children: labels.types[mediaType] }, mediaType))
1695
+ /* @__PURE__ */ jsx10("option", { value: "", children: labels.allTypes }),
1696
+ MEDIA_TYPES.map((mediaType) => /* @__PURE__ */ jsx10("option", { value: mediaType, children: labels.types[mediaType] }, mediaType))
1363
1697
  ]
1364
1698
  }
1365
1699
  ),
1366
- /* @__PURE__ */ jsx9(
1700
+ /* @__PURE__ */ jsx10(
1367
1701
  LibraryErrorBoundary,
1368
1702
  {
1369
1703
  resetKey: `${type ?? ""}\0${search}`,
1370
1704
  renderError: (error, retry) => /* @__PURE__ */ jsxs6("div", { role: "alert", className: partClass("status", classNames), children: [
1371
- /* @__PURE__ */ jsx9("p", { children: labels.error(error) }),
1372
- /* @__PURE__ */ jsx9("button", { type: "button", className: partClass("more", classNames), onClick: retry, children: labels.retry })
1705
+ /* @__PURE__ */ jsx10("p", { children: labels.error(error) }),
1706
+ /* @__PURE__ */ jsx10("button", { type: "button", className: partClass("more", classNames), onClick: retry, children: labels.retry })
1373
1707
  ] }),
1374
- children: /* @__PURE__ */ jsx9(
1708
+ children: /* @__PURE__ */ jsx10(
1375
1709
  LibraryResults,
1376
1710
  {
1377
1711
  ...props,
@@ -1394,15 +1728,15 @@ function LibraryResults(props) {
1394
1728
  const items = props.filter ? library.items.filter(props.filter) : library.items;
1395
1729
  const roving = useRovingFocus(items.length);
1396
1730
  if (library.status === "LoadingFirstPage") {
1397
- return /* @__PURE__ */ jsx9("p", { role: "status", className: partClass("status", classNames), children: labels.loading });
1731
+ return /* @__PURE__ */ jsx10("p", { role: "status", className: partClass("status", classNames), children: labels.loading });
1398
1732
  }
1399
1733
  if (items.length === 0) {
1400
- return /* @__PURE__ */ jsx9("p", { role: "status", className: partClass("status", classNames), children: library.search ? labels.noResults(library.search) : labels.empty });
1734
+ return /* @__PURE__ */ jsx10("p", { role: "status", className: partClass("status", classNames), children: library.search ? labels.noResults(library.search) : labels.empty });
1401
1735
  }
1402
1736
  const isLoadingMore = library.status === "LoadingMore";
1403
1737
  const canLoadMore = library.status === "CanLoadMore" || isLoadingMore;
1404
1738
  return /* @__PURE__ */ jsxs6(Fragment5, { children: [
1405
- /* @__PURE__ */ jsx9(
1739
+ /* @__PURE__ */ jsx10(
1406
1740
  "div",
1407
1741
  {
1408
1742
  role: "listbox",
@@ -1412,7 +1746,7 @@ function LibraryResults(props) {
1412
1746
  className: partClass("list", classNames),
1413
1747
  style: LIST_STYLE,
1414
1748
  onKeyDown: roving.handleKeyDown,
1415
- children: items.map((record, index) => /* @__PURE__ */ jsx9(
1749
+ children: items.map((record, index) => /* @__PURE__ */ jsx10(
1416
1750
  MediaLibraryItem,
1417
1751
  {
1418
1752
  ref: roving.itemRef(index),
@@ -1431,7 +1765,7 @@ function LibraryResults(props) {
1431
1765
  ))
1432
1766
  }
1433
1767
  ),
1434
- canLoadMore && /* @__PURE__ */ jsx9(
1768
+ canLoadMore && /* @__PURE__ */ jsx10(
1435
1769
  "button",
1436
1770
  {
1437
1771
  type: "button",
@@ -1443,7 +1777,7 @@ function LibraryResults(props) {
1443
1777
  )
1444
1778
  ] });
1445
1779
  }
1446
- var LibraryErrorBoundary = class extends React14.Component {
1780
+ var LibraryErrorBoundary = class extends React17.Component {
1447
1781
  state = { hasError: false, error: null };
1448
1782
  static getDerivedStateFromError(error) {
1449
1783
  return { hasError: true, error };
@@ -1485,7 +1819,7 @@ function useMediaObjectUrl(media, options) {
1485
1819
  }
1486
1820
 
1487
1821
  // src/media-picker.tsx
1488
- import React21 from "react";
1822
+ import React24 from "react";
1489
1823
 
1490
1824
  // src/media-picker-accept.ts
1491
1825
  function matchesAccept(candidate, accept) {
@@ -1525,10 +1859,10 @@ function recordMatchesAccept(record, accept) {
1525
1859
  }
1526
1860
 
1527
1861
  // src/media-picker-context.ts
1528
- import React15 from "react";
1529
- var MediaPickerContext = /* @__PURE__ */ React15.createContext(null);
1862
+ import React18 from "react";
1863
+ var MediaPickerContext = /* @__PURE__ */ React18.createContext(null);
1530
1864
  function useMediaPicker() {
1531
- const picker = React15.use(MediaPickerContext);
1865
+ const picker = React18.use(MediaPickerContext);
1532
1866
  if (!picker) throw new Error("useMediaPicker must be used within <MediaPicker>");
1533
1867
  return picker;
1534
1868
  }
@@ -1569,17 +1903,17 @@ function mergeMediaPickerLabels(labels) {
1569
1903
  }
1570
1904
 
1571
1905
  // src/media-picker-library.tsx
1572
- import React16 from "react";
1573
- import { jsx as jsx10 } from "react/jsx-runtime";
1906
+ import React19 from "react";
1907
+ import { jsx as jsx11 } from "react/jsx-runtime";
1574
1908
  function MediaPickerLibrary(props) {
1575
1909
  const picker = useMediaPicker();
1576
1910
  const { accept, toggle } = picker;
1577
1911
  const selectedKeys = picker.selected.map((media) => media.id);
1578
- const filter = React16.useMemo(
1912
+ const filter = React19.useMemo(
1579
1913
  () => accept ? (record) => recordMatchesAccept(record, accept) : void 0,
1580
1914
  [accept]
1581
1915
  );
1582
- return /* @__PURE__ */ jsx10(
1916
+ return /* @__PURE__ */ jsx11(
1583
1917
  MediaLibrary,
1584
1918
  {
1585
1919
  ...props,
@@ -1596,15 +1930,15 @@ function MediaPickerLibrary(props) {
1596
1930
  }
1597
1931
 
1598
1932
  // src/media-picker-selection.tsx
1599
- import React18 from "react";
1933
+ import React21 from "react";
1600
1934
 
1601
1935
  // src/preview.tsx
1602
- import { mediaPath as mediaPath2 } from "@devultur/core";
1936
+ import { mediaPath as mediaPath4 } from "@devultur/core";
1603
1937
 
1604
1938
  // src/video-preview.tsx
1605
- import React17 from "react";
1939
+ import React20 from "react";
1606
1940
  import { VideoPlayer } from "@devultur/react";
1607
- import { Fragment as Fragment6, jsx as jsx11 } from "react/jsx-runtime";
1941
+ import { Fragment as Fragment6, jsx as jsx12 } from "react/jsx-runtime";
1608
1942
  var UNAUTHORIZED = 401;
1609
1943
  var HLS_NETWORK_FAILURE = /Load(Error|TimeOut)$/;
1610
1944
  function fetchErrorOf(error) {
@@ -1616,13 +1950,13 @@ function fetchErrorOf(error) {
1616
1950
  if (HLS_NETWORK_FAILURE.test(error.details)) return networkError(error);
1617
1951
  return renderFailedError(error);
1618
1952
  }
1619
- function VideoPreview({ media, renderFallback, className }) {
1620
- const { tokenStatus, tokenError, reissueToken } = useDevultur();
1953
+ function VideoPreview({ media, renderFallback, loading, metrics, className }) {
1954
+ const { token, tokenStatus, tokenError, reissueToken } = useDevultur();
1621
1955
  const videoProps = useVideoProps(media);
1622
- const [failure, setFailure] = React17.useState(null);
1623
- const hasReissuedRef = React17.useRef(false);
1956
+ const [failure, setFailure] = React20.useState(null);
1957
+ const hasReissuedRef = React20.useRef(false);
1624
1958
  const playerToken = videoProps?.token;
1625
- const handleError = React17.useCallback(
1959
+ const handleError = React20.useCallback(
1626
1960
  (error) => {
1627
1961
  const isFirstMismatch = error.code === "session_mismatch" && !hasReissuedRef.current;
1628
1962
  if (isFirstMismatch) {
@@ -1635,52 +1969,54 @@ function VideoPreview({ media, renderFallback, className }) {
1635
1969
  [reissueToken, playerToken]
1636
1970
  );
1637
1971
  if (!videoProps) return null;
1638
- if (media.visibility === "private" && tokenStatus === "error") {
1639
- return /* @__PURE__ */ jsx11(Fragment6, { children: renderFallback(tokenUnavailableError(tokenError)) });
1640
- }
1641
- if (failure && failure.token === playerToken) return /* @__PURE__ */ jsx11(Fragment6, { children: renderFallback(failure.error) });
1642
- return /* @__PURE__ */ jsx11(VideoPlayer, { ...videoProps, className, onError: handleError });
1972
+ const isPrivate = media.visibility === "private";
1973
+ if (isPrivate && tokenStatus === "error") return /* @__PURE__ */ jsx12(Fragment6, { children: renderFallback(tokenUnavailableError(tokenError)) });
1974
+ if (isPrivate && !token) return /* @__PURE__ */ jsx12(Fragment6, { children: loading });
1975
+ if (failure && failure.token === playerToken) return /* @__PURE__ */ jsx12(Fragment6, { children: renderFallback(failure.error) });
1976
+ return /* @__PURE__ */ jsx12(VideoPlayer, { ...videoProps, className, metrics, onError: handleError });
1643
1977
  }
1644
1978
 
1645
1979
  // src/preview.tsx
1646
- import { Fragment as Fragment7, jsx as jsx12 } from "react/jsx-runtime";
1980
+ import { Fragment as Fragment7, jsx as jsx13 } from "react/jsx-runtime";
1647
1981
  var DEFAULT_MAX_TEXT_BYTES = 1024 * 1024;
1648
1982
  var DEFAULT_MAX_BYTES = 50 * 1024 * 1024;
1649
- function Preview({ media, contentType, fallback, loading, labels, ...viewProps }) {
1983
+ function Preview({ media, contentType, fallback, loading, labels, metrics, ...viewProps }) {
1650
1984
  const text = usePreviewLabels(labels);
1651
1985
  if (!media) return null;
1652
1986
  const renderFallback = (error) => {
1653
1987
  const fetchError = error instanceof MediaFetchError ? error : networkError(error);
1654
1988
  if (typeof fallback === "function") return fallback(fetchError);
1655
1989
  if (fallback !== void 0) return fallback;
1656
- return /* @__PURE__ */ jsx12(DefaultFallback, { media, error: fetchError, labels: text });
1990
+ return /* @__PURE__ */ jsx13(DefaultFallback, { media, error: fetchError, labels: text });
1657
1991
  };
1992
+ const loadingNode = loading === void 0 ? /* @__PURE__ */ jsx13(LoadingStatus, { text: text.loading }) : loading;
1658
1993
  if (media.kind === "video") {
1659
- return /* @__PURE__ */ jsx12(
1994
+ return /* @__PURE__ */ jsx13(
1660
1995
  VideoPreview,
1661
1996
  {
1662
1997
  media,
1663
1998
  renderFallback,
1999
+ loading: loadingNode,
2000
+ metrics,
1664
2001
  className: viewProps.className
1665
2002
  },
1666
- mediaPath2(media)
2003
+ mediaPath4(media)
1667
2004
  );
1668
2005
  }
1669
2006
  const expectedType = expectedContentType(media, contentType);
1670
2007
  const kind = previewKindOf(expectedType);
1671
- if (!expectedType || !kind) return /* @__PURE__ */ jsx12(Fragment7, { children: renderFallback(unsupportedType(expectedType)) });
1672
- const loadingNode = loading === void 0 ? /* @__PURE__ */ jsx12(LoadingStatus, { text: text.loading }) : loading;
2008
+ if (!expectedType || !kind) return /* @__PURE__ */ jsx13(Fragment7, { children: renderFallback(unsupportedType(expectedType)) });
1673
2009
  const props = { ...viewProps, media, expectedType, renderFallback, loading: loadingNode, labels: text };
1674
- if (kind === "text") return /* @__PURE__ */ jsx12(TextPreview, { ...props });
1675
- if (kind === "pdf") return /* @__PURE__ */ jsx12(PdfPreview, { ...props, maxBytes: props.maxBytes ?? DEFAULT_MAX_BYTES });
1676
- return /* @__PURE__ */ jsx12(BinaryPreview, { ...props, kind });
2010
+ if (kind === "text") return /* @__PURE__ */ jsx13(TextPreview, { ...props });
2011
+ if (kind === "pdf") return /* @__PURE__ */ jsx13(PdfPreview, { ...props, maxBytes: props.maxBytes ?? DEFAULT_MAX_BYTES });
2012
+ return /* @__PURE__ */ jsx13(BinaryPreview, { ...props, kind });
1677
2013
  }
1678
2014
  var unsupportedType = (contentType) => new MediaFetchError("unsupported_type", `Can't preview ${contentType ?? "an unknown type"} inline`);
1679
2015
  function DefaultFallback({ media, error, labels }) {
1680
- return /* @__PURE__ */ jsx12(DownloadLink, { media, title: labels.error(error.code), children: media.filename ?? labels.download });
2016
+ return /* @__PURE__ */ jsx13(DownloadLink, { media, title: labels.error(error.code), children: media.filename ?? labels.download });
1681
2017
  }
1682
2018
  function LoadingStatus({ text }) {
1683
- return /* @__PURE__ */ jsx12("span", { role: "status", style: VISUALLY_HIDDEN, children: text });
2019
+ return /* @__PURE__ */ jsx13("span", { role: "status", style: VISUALLY_HIDDEN, children: text });
1684
2020
  }
1685
2021
  function BinaryPreview({
1686
2022
  media,
@@ -1697,13 +2033,13 @@ function BinaryPreview({
1697
2033
  const isPublicImage = kind === "image" && media.visibility === "public";
1698
2034
  const verified = useMediaObjectUrl(media, { enabled: !isPublicImage, contentType: expectedType, maxBytes });
1699
2035
  const objectUrl = isPublicImage ? url(media) : verified.objectUrl;
1700
- if (verified.error) return /* @__PURE__ */ jsx12(Fragment7, { children: renderFallback(verified.error) });
1701
- if (!objectUrl) return /* @__PURE__ */ jsx12(Fragment7, { children: loading });
2036
+ if (verified.error) return /* @__PURE__ */ jsx13(Fragment7, { children: renderFallback(verified.error) });
2037
+ if (!objectUrl) return /* @__PURE__ */ jsx13(Fragment7, { children: loading });
1702
2038
  const label = title ?? media.filename ?? labels.untitledMedia;
1703
2039
  if (kind === "image") {
1704
- return /* @__PURE__ */ jsx12("img", { src: objectUrl, alt: label, width: media.width, height: media.height, className });
2040
+ return /* @__PURE__ */ jsx13("img", { src: objectUrl, alt: label, width: media.width, height: media.height, className });
1705
2041
  }
1706
- return /* @__PURE__ */ jsx12("audio", { src: objectUrl, controls: true, "aria-label": label, className });
2042
+ return /* @__PURE__ */ jsx13("audio", { src: objectUrl, controls: true, "aria-label": label, className });
1707
2043
  }
1708
2044
  var decodeText = (bytes) => new TextDecoder().decode(bytes);
1709
2045
  function TextPreview({
@@ -1721,19 +2057,19 @@ function TextPreview({
1721
2057
  contentType: expectedType,
1722
2058
  decode: decodeText
1723
2059
  });
1724
- if (error) return /* @__PURE__ */ jsx12(Fragment7, { children: renderFallback(error) });
1725
- if (text === null) return /* @__PURE__ */ jsx12(Fragment7, { children: loading });
1726
- return /* @__PURE__ */ jsx12("pre", { title, className, children: text });
2060
+ if (error) return /* @__PURE__ */ jsx13(Fragment7, { children: renderFallback(error) });
2061
+ if (text === null) return /* @__PURE__ */ jsx13(Fragment7, { children: loading });
2062
+ return /* @__PURE__ */ jsx13("pre", { title, className, children: text });
1727
2063
  }
1728
2064
 
1729
2065
  // src/media-picker-selection.tsx
1730
- import { jsx as jsx13, jsxs as jsxs7 } from "react/jsx-runtime";
2066
+ import { jsx as jsx14, jsxs as jsxs7 } from "react/jsx-runtime";
1731
2067
  var THUMBNAIL_WIDTH2 = 96;
1732
2068
  function MediaPickerSelection({ renderItem, headingLevel = 3, className }) {
1733
2069
  const picker = useMediaPicker();
1734
2070
  const { labels, selected } = picker;
1735
- const headingId = React18.useId();
1736
- const [previewedId, setPreviewedId] = React18.useState(null);
2071
+ const headingId = React21.useId();
2072
+ const [previewedId, setPreviewedId] = React21.useState(null);
1737
2073
  const Heading = `h${headingLevel}`;
1738
2074
  const cardHeadingLevel = headingLevel + 1;
1739
2075
  const regionClass = className ? `devultur-media-picker__selection ${className}` : "devultur-media-picker__selection";
@@ -1746,21 +2082,21 @@ function MediaPickerSelection({ renderItem, headingLevel = 3, className }) {
1746
2082
  "aria-labelledby": headingId,
1747
2083
  className: regionClass,
1748
2084
  children: [
1749
- /* @__PURE__ */ jsx13(Heading, { id: headingId, children: labels.selection }),
1750
- selected.length === 0 && /* @__PURE__ */ jsx13("p", { "data-part": "empty", children: labels.empty }),
1751
- selected.length > 0 && /* @__PURE__ */ jsx13("ul", { "data-part": "items", children: selected.map((media) => /* @__PURE__ */ jsxs7("li", { "data-kind": media.kind, children: [
1752
- renderItem ? renderItem(media) : /* @__PURE__ */ jsx13(
2085
+ /* @__PURE__ */ jsx14(Heading, { id: headingId, children: labels.selection }),
2086
+ selected.length === 0 && /* @__PURE__ */ jsx14("p", { "data-part": "empty", children: labels.empty }),
2087
+ selected.length > 0 && /* @__PURE__ */ jsx14("ul", { "data-part": "items", children: selected.map((media) => /* @__PURE__ */ jsxs7("li", { "data-kind": media.kind, children: [
2088
+ renderItem ? renderItem(media) : /* @__PURE__ */ jsx14(
1753
2089
  AttachmentCard,
1754
2090
  {
1755
2091
  media,
1756
2092
  headingLevel: cardHeadingLevel,
1757
2093
  labels: picker.attachmentLabels,
1758
- preview: media.kind === "image" ? /* @__PURE__ */ jsx13(Thumbnail2, { media }) : void 0,
2094
+ preview: media.kind === "image" ? /* @__PURE__ */ jsx14(Thumbnail2, { media }) : void 0,
1759
2095
  onOpen: handleOpen
1760
2096
  }
1761
2097
  ),
1762
- previewedId === media.id && /* @__PURE__ */ jsx13(Preview, { media, title: mediaName(media) }),
1763
- /* @__PURE__ */ jsx13(
2098
+ previewedId === media.id && /* @__PURE__ */ jsx14(Preview, { media, title: mediaName(media) }),
2099
+ /* @__PURE__ */ jsx14(
1764
2100
  "button",
1765
2101
  {
1766
2102
  type: "button",
@@ -1770,13 +2106,13 @@ function MediaPickerSelection({ renderItem, headingLevel = 3, className }) {
1770
2106
  }
1771
2107
  )
1772
2108
  ] }, media.id)) }),
1773
- /* @__PURE__ */ jsx13(SelectionActions, {})
2109
+ /* @__PURE__ */ jsx14(SelectionActions, {})
1774
2110
  ]
1775
2111
  }
1776
2112
  );
1777
2113
  }
1778
2114
  function Thumbnail2({ media }) {
1779
- return /* @__PURE__ */ jsx13(
2115
+ return /* @__PURE__ */ jsx14(
1780
2116
  Image,
1781
2117
  {
1782
2118
  media,
@@ -1793,18 +2129,18 @@ function SelectionActions() {
1793
2129
  const { labels } = picker;
1794
2130
  if (picker.selected.length === 0) return null;
1795
2131
  return /* @__PURE__ */ jsxs7("div", { "data-part": "actions", children: [
1796
- !picker.multiple && /* @__PURE__ */ jsx13("button", { type: "button", "data-part": "replace", onClick: picker.openFileDialog, children: labels.replace }),
1797
- /* @__PURE__ */ jsx13("button", { type: "button", "data-part": "clear", onClick: picker.clear, children: labels.clear })
2132
+ !picker.multiple && /* @__PURE__ */ jsx14("button", { type: "button", "data-part": "replace", onClick: picker.openFileDialog, children: labels.replace }),
2133
+ /* @__PURE__ */ jsx14("button", { type: "button", "data-part": "clear", onClick: picker.clear, children: labels.clear })
1798
2134
  ] });
1799
2135
  }
1800
2136
 
1801
2137
  // src/media-picker-upload.tsx
1802
- import React19 from "react";
1803
- import { jsx as jsx14, jsxs as jsxs8 } from "react/jsx-runtime";
2138
+ import React22 from "react";
2139
+ import { jsx as jsx15, jsxs as jsxs8 } from "react/jsx-runtime";
1804
2140
  function MediaPickerUpload({ className }) {
1805
2141
  const picker = useMediaPicker();
1806
2142
  const { labels, multiple } = picker;
1807
- const [isDragging, setIsDragging] = React19.useState(false);
2143
+ const [isDragging, setIsDragging] = React22.useState(false);
1808
2144
  const handleDragOver = (event) => {
1809
2145
  event.preventDefault();
1810
2146
  setIsDragging(true);
@@ -1831,9 +2167,9 @@ function MediaPickerUpload({ className }) {
1831
2167
  onDragLeave: handleDragLeave,
1832
2168
  onDrop: handleDrop,
1833
2169
  children: [
1834
- /* @__PURE__ */ jsx14("button", { type: "button", "data-part": "upload", onClick: picker.openFileDialog, children: labels.upload(multiple) }),
1835
- /* @__PURE__ */ jsx14("span", { "data-part": "drop-hint", children: labels.dropHint(multiple) }),
1836
- picker.uploads.length > 0 && /* @__PURE__ */ jsx14("ul", { "aria-label": labels.uploads, "data-part": "uploads", children: picker.uploads.map((upload) => /* @__PURE__ */ jsx14(UploadRow, { upload }, upload.id)) })
2170
+ /* @__PURE__ */ jsx15("button", { type: "button", "data-part": "upload", onClick: picker.openFileDialog, children: labels.upload(multiple) }),
2171
+ /* @__PURE__ */ jsx15("span", { "data-part": "drop-hint", children: labels.dropHint(multiple) }),
2172
+ picker.uploads.length > 0 && /* @__PURE__ */ jsx15("ul", { "aria-label": labels.uploads, "data-part": "uploads", children: picker.uploads.map((upload) => /* @__PURE__ */ jsx15(UploadRow, { upload }, upload.id)) })
1837
2173
  ]
1838
2174
  }
1839
2175
  )
@@ -1845,23 +2181,23 @@ function UploadRow({ upload }) {
1845
2181
  const filename = upload.file.name;
1846
2182
  if (upload.status === "failed") {
1847
2183
  return /* @__PURE__ */ jsxs8("li", { "data-status": "failed", children: [
1848
- /* @__PURE__ */ jsx14("span", { "data-part": "filename", children: filename }),
1849
- /* @__PURE__ */ jsx14("span", { role: "alert", "data-part": "error", children: upload.error }),
1850
- upload.canRetry && /* @__PURE__ */ jsx14("button", { type: "button", onClick: () => picker.retry(upload.id), children: labels.retry }),
1851
- /* @__PURE__ */ jsx14("button", { type: "button", onClick: () => picker.dismiss(upload.id), children: labels.dismiss })
2184
+ /* @__PURE__ */ jsx15("span", { "data-part": "filename", children: filename }),
2185
+ /* @__PURE__ */ jsx15("span", { role: "alert", "data-part": "error", children: upload.error }),
2186
+ upload.canRetry && /* @__PURE__ */ jsx15("button", { type: "button", onClick: () => picker.retry(upload.id), children: labels.retry }),
2187
+ /* @__PURE__ */ jsx15("button", { type: "button", onClick: () => picker.dismiss(upload.id), children: labels.dismiss })
1852
2188
  ] });
1853
2189
  }
1854
2190
  const percent = Math.round(upload.fraction * 100);
1855
2191
  return /* @__PURE__ */ jsxs8("li", { "data-status": "uploading", children: [
1856
- /* @__PURE__ */ jsx14("span", { "data-part": "filename", children: filename }),
1857
- /* @__PURE__ */ jsx14("progress", { max: 100, value: percent, "aria-label": labels.progress(filename) }),
1858
- /* @__PURE__ */ jsx14("span", { "data-part": "percent", children: labels.percent(percent) }),
1859
- /* @__PURE__ */ jsx14("button", { type: "button", "aria-label": labels.cancelUpload(filename), onClick: () => picker.cancel(upload.id), children: labels.cancel })
2192
+ /* @__PURE__ */ jsx15("span", { "data-part": "filename", children: filename }),
2193
+ /* @__PURE__ */ jsx15("progress", { max: 100, value: percent, "aria-label": labels.progress(filename) }),
2194
+ /* @__PURE__ */ jsx15("span", { "data-part": "percent", children: labels.percent(percent) }),
2195
+ /* @__PURE__ */ jsx15("button", { type: "button", "aria-label": labels.cancelUpload(filename), onClick: () => picker.cancel(upload.id), children: labels.cancel })
1860
2196
  ] });
1861
2197
  }
1862
2198
 
1863
2199
  // src/use-media-picker-uploads.ts
1864
- import React20 from "react";
2200
+ import React23 from "react";
1865
2201
  var isAbortError = (error) => typeof error === "object" && error !== null && error.name === "AbortError";
1866
2202
  var isPdf = (file) => file.type === "application/pdf" || file.name.toLowerCase().endsWith(".pdf");
1867
2203
  var nextUploadId = 0;
@@ -1872,18 +2208,18 @@ async function pageCountOf(file, countPdfPages) {
1872
2208
  }
1873
2209
  function useMediaPickerUploads(options) {
1874
2210
  const { upload } = useDevultur();
1875
- const [uploads, setUploads] = React20.useState([]);
1876
- const controllers = React20.useRef(/* @__PURE__ */ new Map());
1877
- const optionsRef = React20.useRef(options);
2211
+ const [uploads, setUploads] = React23.useState([]);
2212
+ const controllers = React23.useRef(/* @__PURE__ */ new Map());
2213
+ const optionsRef = React23.useRef(options);
1878
2214
  optionsRef.current = options;
1879
- const patch = React20.useCallback((id, changes) => {
2215
+ const patch = React23.useCallback((id, changes) => {
1880
2216
  setUploads((current) => current.map((entry) => entry.id === id ? { ...entry, ...changes } : entry));
1881
2217
  }, []);
1882
- const drop = React20.useCallback((id) => {
2218
+ const drop = React23.useCallback((id) => {
1883
2219
  controllers.current.delete(id);
1884
2220
  setUploads((current) => current.filter((entry) => entry.id !== id));
1885
2221
  }, []);
1886
- const run = React20.useCallback(
2222
+ const run = React23.useCallback(
1887
2223
  async (id, file) => {
1888
2224
  const { labels, announce } = optionsRef.current;
1889
2225
  const controller = new AbortController();
@@ -1913,7 +2249,7 @@ function useMediaPickerUploads(options) {
1913
2249
  },
1914
2250
  [upload, patch, drop]
1915
2251
  );
1916
- const start = React20.useCallback(
2252
+ const start = React23.useCallback(
1917
2253
  (files) => {
1918
2254
  const { accept, multiple, labels } = optionsRef.current;
1919
2255
  const chosen = multiple ? [...files] : [...files].slice(0, 1);
@@ -1935,8 +2271,8 @@ function useMediaPickerUploads(options) {
1935
2271
  },
1936
2272
  [run]
1937
2273
  );
1938
- const cancel = React20.useCallback((id) => controllers.current.get(id)?.abort(), []);
1939
- const retry = React20.useCallback(
2274
+ const cancel = React23.useCallback((id) => controllers.current.get(id)?.abort(), []);
2275
+ const retry = React23.useCallback(
1940
2276
  (id) => {
1941
2277
  const entry = uploads.find((candidate) => candidate.id === id);
1942
2278
  if (!entry?.canRetry) return;
@@ -1944,7 +2280,7 @@ function useMediaPickerUploads(options) {
1944
2280
  },
1945
2281
  [uploads, run]
1946
2282
  );
1947
- React20.useEffect(() => {
2283
+ React23.useEffect(() => {
1948
2284
  const active = controllers.current;
1949
2285
  return () => {
1950
2286
  for (const controller of active.values()) controller.abort();
@@ -1954,7 +2290,7 @@ function useMediaPickerUploads(options) {
1954
2290
  }
1955
2291
 
1956
2292
  // src/media-picker.tsx
1957
- import { Fragment as Fragment8, jsx as jsx15, jsxs as jsxs9 } from "react/jsx-runtime";
2293
+ import { Fragment as Fragment8, jsx as jsx16, jsxs as jsxs9 } from "react/jsx-runtime";
1958
2294
  var EMPTY = [];
1959
2295
  function selectionOf(props) {
1960
2296
  if (props.value === void 0) return void 0;
@@ -1971,22 +2307,22 @@ function emit(props, next) {
1971
2307
  var isSame = (a, b) => a.id === b.id;
1972
2308
  function MediaPickerRoot(props) {
1973
2309
  const isMultiple = props.multiple ?? false;
1974
- const labels = React21.useMemo(() => mergeMediaPickerLabels(props.labels), [props.labels]);
2310
+ const labels = React24.useMemo(() => mergeMediaPickerLabels(props.labels), [props.labels]);
1975
2311
  const accept = uploadAcceptOf(props.accept, props.type);
1976
2312
  const libraryType = props.type ?? mediaTypeOfAccept(props.accept);
1977
2313
  const countPdfPages = usePdfPageCounter();
1978
- const [announcement, setAnnouncement] = React21.useState("");
1979
- const inputRef = React21.useRef(null);
1980
- const selectionRef = React21.useRef(null);
1981
- const [ownSelection, setOwnSelection] = React21.useState(EMPTY);
2314
+ const [announcement, setAnnouncement] = React24.useState("");
2315
+ const inputRef = React24.useRef(null);
2316
+ const selectionRef = React24.useRef(null);
2317
+ const [ownSelection, setOwnSelection] = React24.useState(EMPTY);
1982
2318
  const controlledSelection = selectionOf(props);
1983
2319
  const selected = controlledSelection ?? ownSelection;
1984
- const selectedRef = React21.useRef(selected);
2320
+ const selectedRef = React24.useRef(selected);
1985
2321
  selectedRef.current = selected;
1986
- const propsRef = React21.useRef(props);
2322
+ const propsRef = React24.useRef(props);
1987
2323
  propsRef.current = props;
1988
2324
  const isControlled = controlledSelection !== void 0;
1989
- const commit = React21.useCallback(
2325
+ const commit = React24.useCallback(
1990
2326
  (next) => {
1991
2327
  selectedRef.current = next;
1992
2328
  if (!isControlled) setOwnSelection(next);
@@ -1994,15 +2330,15 @@ function MediaPickerRoot(props) {
1994
2330
  },
1995
2331
  [isControlled]
1996
2332
  );
1997
- const announce = React21.useCallback((message) => setAnnouncement(message), []);
1998
- const select = React21.useCallback(
2333
+ const announce = React24.useCallback((message) => setAnnouncement(message), []);
2334
+ const select = React24.useCallback(
1999
2335
  (media) => {
2000
2336
  const current = selectedRef.current.filter((item) => !isSame(item, media));
2001
2337
  commit(isMultiple ? [...current, media] : [media]);
2002
2338
  },
2003
2339
  [commit, isMultiple]
2004
2340
  );
2005
- const remove = React21.useCallback(
2341
+ const remove = React24.useCallback(
2006
2342
  (media) => {
2007
2343
  commit(selectedRef.current.filter((item) => !isSame(item, media)));
2008
2344
  announce(labels.removed(mediaName(media)));
@@ -2010,7 +2346,7 @@ function MediaPickerRoot(props) {
2010
2346
  },
2011
2347
  [commit, announce, labels]
2012
2348
  );
2013
- const toggle = React21.useCallback(
2349
+ const toggle = React24.useCallback(
2014
2350
  (media) => {
2015
2351
  const isSelected = selectedRef.current.some((item) => isSame(item, media));
2016
2352
  if (isMultiple && isSelected) {
@@ -2022,12 +2358,12 @@ function MediaPickerRoot(props) {
2022
2358
  },
2023
2359
  [isMultiple, remove, select, announce, labels]
2024
2360
  );
2025
- const clear = React21.useCallback(() => {
2361
+ const clear = React24.useCallback(() => {
2026
2362
  commit([]);
2027
2363
  announce(labels.cleared);
2028
2364
  selectionRef.current?.focus();
2029
2365
  }, [commit, announce, labels]);
2030
- const handleUploaded = React21.useCallback(
2366
+ const handleUploaded = React24.useCallback(
2031
2367
  (media) => {
2032
2368
  select(media);
2033
2369
  selectionRef.current?.focus();
@@ -2065,13 +2401,13 @@ function MediaPickerRoot(props) {
2065
2401
  selectionRef
2066
2402
  };
2067
2403
  const content = typeof props.children === "function" ? props.children(picker) : props.children ?? /* @__PURE__ */ jsxs9(Fragment8, { children: [
2068
- /* @__PURE__ */ jsx15(MediaPickerUpload, {}),
2069
- /* @__PURE__ */ jsx15(MediaPickerLibrary, {}),
2070
- /* @__PURE__ */ jsx15(MediaPickerSelection, {})
2404
+ /* @__PURE__ */ jsx16(MediaPickerUpload, {}),
2405
+ /* @__PURE__ */ jsx16(MediaPickerLibrary, {}),
2406
+ /* @__PURE__ */ jsx16(MediaPickerSelection, {})
2071
2407
  ] });
2072
2408
  const rootClass = props.className ? `devultur-media-picker ${props.className}` : "devultur-media-picker";
2073
- return /* @__PURE__ */ jsx15(MediaPickerContext, { value: picker, children: /* @__PURE__ */ jsxs9("div", { className: rootClass, children: [
2074
- /* @__PURE__ */ jsx15(
2409
+ return /* @__PURE__ */ jsx16(MediaPickerContext, { value: picker, children: /* @__PURE__ */ jsxs9("div", { className: rootClass, children: [
2410
+ /* @__PURE__ */ jsx16(
2075
2411
  "input",
2076
2412
  {
2077
2413
  ref: inputRef,
@@ -2086,7 +2422,7 @@ function MediaPickerRoot(props) {
2086
2422
  }
2087
2423
  ),
2088
2424
  content,
2089
- /* @__PURE__ */ jsx15("div", { role: "status", "aria-live": "polite", style: VISUALLY_HIDDEN, children: announcement })
2425
+ /* @__PURE__ */ jsx16("div", { role: "status", "aria-live": "polite", style: VISUALLY_HIDDEN, children: announcement })
2090
2426
  ] }) });
2091
2427
  }
2092
2428
  var MediaPicker = /* @__PURE__ */ Object.assign(MediaPickerRoot, {
@@ -2096,25 +2432,58 @@ var MediaPicker = /* @__PURE__ */ Object.assign(MediaPickerRoot, {
2096
2432
  Selection: MediaPickerSelection
2097
2433
  });
2098
2434
 
2435
+ // src/viewer-provider.tsx
2436
+ import React25 from "react";
2437
+ import { useAction as useAction2 } from "convex/react";
2438
+ import { jsx as jsx17 } from "react/jsx-runtime";
2439
+ var VIEWER_ACTIONS = {
2440
+ uploadUrl: () => Promise.reject(new DevulturCapabilityError("uploadUrl")),
2441
+ upload: () => Promise.reject(new DevulturCapabilityError("upload")),
2442
+ deleteMedia: () => {
2443
+ throw new DevulturCapabilityError("deleteMedia");
2444
+ }
2445
+ };
2446
+ var NO_TOKEN_ACTION = () => Promise.reject(new Error("<DevulturViewerProvider> has no `issueViewerToken`: private media can't be shown"));
2447
+ function DevulturViewerProvider({ issueViewerToken, ...props }) {
2448
+ if (!issueViewerToken) return /* @__PURE__ */ jsx17(ViewerSession, { ...props, issueViewerToken: NO_TOKEN_ACTION, lazyToken: true });
2449
+ return /* @__PURE__ */ jsx17(ConvexViewerSession, { ...props, issueViewerToken });
2450
+ }
2451
+ function ConvexViewerSession({
2452
+ issueViewerToken,
2453
+ ...props
2454
+ }) {
2455
+ const issueViewerTokenAction = useAction2(issueViewerToken);
2456
+ return /* @__PURE__ */ jsx17(ViewerSession, { issueViewerToken: issueViewerTokenAction, ...props });
2457
+ }
2458
+ function ViewerSession({
2459
+ baseUrl,
2460
+ issueViewerToken,
2461
+ tokenArgs,
2462
+ lazyToken = false,
2463
+ children
2464
+ }) {
2465
+ const session = useMediaSession({ issueViewerToken, tokenArgs, baseUrl, isLazy: lazyToken });
2466
+ const devulturValue = React25.useMemo(() => ({ ...session, ...VIEWER_ACTIONS }), [session]);
2467
+ return /* @__PURE__ */ jsx17(DevulturContext, { value: devulturValue, children });
2468
+ }
2469
+
2099
2470
  // src/react.tsx
2100
- import { jsx as jsx16 } from "react/jsx-runtime";
2101
- var DevulturContext = /* @__PURE__ */ React22.createContext(null);
2102
- var DevulturApiContext = /* @__PURE__ */ React22.createContext(null);
2103
- var ProgressContext = /* @__PURE__ */ React22.createContext(null);
2471
+ import { jsx as jsx18 } from "react/jsx-runtime";
2104
2472
  function DevulturProvider({ api, baseUrl, tokenArgs, issueViewerToken, children }) {
2105
- const session = useMediaSession({ issueViewerToken: issueViewerToken ?? api.issueViewerToken, tokenArgs, baseUrl });
2106
- const createUploadUrlAction = useAction2(api.createUploadUrl);
2107
- const deleteMediaAction = useAction2(api.deleteMedia);
2108
- const confirmUploadAction = useAction2(api.confirmUpload ?? api.createUploadUrl);
2109
- const deleteVideoAction = useAction2(api.deleteVideo ?? api.deleteMedia);
2110
- const uploadUrl = React22.useCallback(
2473
+ const issueViewerTokenAction = useAction3(issueViewerToken ?? api.issueViewerToken);
2474
+ const session = useMediaSession({ issueViewerToken: issueViewerTokenAction, tokenArgs, baseUrl });
2475
+ const createUploadUrlAction = useAction3(api.createUploadUrl);
2476
+ const deleteMediaAction = useAction3(api.deleteMedia);
2477
+ const confirmUploadAction = useAction3(api.confirmUpload ?? api.createUploadUrl);
2478
+ const deleteVideoAction = useAction3(api.deleteVideo ?? api.deleteMedia);
2479
+ const uploadUrl = React26.useCallback(
2111
2480
  async (file) => {
2112
2481
  const result = await createUploadUrlAction({ filename: file.name, contentType: file.type });
2113
2482
  return { url: result.url, key: result.key };
2114
2483
  },
2115
2484
  [createUploadUrlAction]
2116
2485
  );
2117
- const upload = React22.useCallback(
2486
+ const upload = React26.useCallback(
2118
2487
  async (file, options) => {
2119
2488
  const dimensions = await readImageDimensions(file);
2120
2489
  options?.signal?.throwIfAborted();
@@ -2123,7 +2492,7 @@ function DevulturProvider({ api, baseUrl, tokenArgs, issueViewerToken, children
2123
2492
  contentType: file.type,
2124
2493
  visibility: options?.visibility
2125
2494
  });
2126
- await uploadFileWithProgress(target.url, file, { onBytes: options?.onProgress, signal: options?.signal });
2495
+ await uploadFileWithProgress2(target.url, file, { onBytes: options?.onProgress, signal: options?.signal });
2127
2496
  if (api.confirmUpload) {
2128
2497
  const pageCount = options?.pageCount;
2129
2498
  const hints = { ...dimensions, ...pageCount !== void 0 ? { pageCount } : {} };
@@ -2139,432 +2508,89 @@ function DevulturProvider({ api, baseUrl, tokenArgs, issueViewerToken, children
2139
2508
  };
2140
2509
  },
2141
2510
  [createUploadUrlAction, confirmUploadAction, api.confirmUpload]
2142
- );
2143
- const deleteMedia = React22.useCallback(
2144
- (media) => {
2145
- if (media.kind === "video" && api.deleteVideo) {
2146
- deleteVideoAction({ videoId: media.id, visibility: media.visibility }).catch(console.error);
2147
- return;
2148
- }
2149
- deleteMediaAction({ key: mediaPath3(media) }).catch(console.error);
2150
- },
2151
- [deleteMediaAction, deleteVideoAction, api.deleteVideo]
2152
- );
2153
- const devulturValue = React22.useMemo(
2154
- () => ({ ...session, uploadUrl, upload, deleteMedia }),
2155
- [session, uploadUrl, upload, deleteMedia]
2156
- );
2157
- let content = /* @__PURE__ */ jsx16(DevulturApiContext, { value: api, children: /* @__PURE__ */ jsx16(DevulturContext, { value: devulturValue, children }) });
2158
- if (api.saveProgress && api.loadProgress && api.markComplete && api.resetProgress) {
2159
- content = /* @__PURE__ */ jsx16(
2160
- ProgressBridge,
2161
- {
2162
- save: api.saveProgress,
2163
- load: api.loadProgress,
2164
- markComplete: api.markComplete,
2165
- reset: api.resetProgress,
2166
- children: content
2167
- }
2168
- );
2169
- }
2170
- return content;
2171
- }
2172
- function ProgressBridge({
2173
- save,
2174
- load,
2175
- markComplete,
2176
- reset,
2177
- children
2178
- }) {
2179
- const convex = useConvex();
2180
- const saveMutation = useMutation(save);
2181
- const markCompleteMutation = useMutation(markComplete);
2182
- const resetMutation = useMutation(reset);
2183
- const onSave = React22.useCallback(
2184
- async (videoId, position) => {
2185
- await saveMutation({ videoId, position });
2186
- },
2187
- [saveMutation]
2188
- );
2189
- const onLoad = React22.useCallback(
2190
- async (videoId) => {
2191
- const result = await convex.query(load, { videoId });
2192
- if (!result) return null;
2193
- return {
2194
- lastPosition: result.lastPosition,
2195
- completedAt: result.completedAt ? new Date(result.completedAt) : null
2196
- };
2197
- },
2198
- [convex, load]
2199
- );
2200
- const onMarkComplete = React22.useCallback(
2201
- async (videoId) => {
2202
- await markCompleteMutation({ videoId });
2203
- },
2204
- [markCompleteMutation]
2205
- );
2206
- const onReset = React22.useCallback(
2207
- async (videoId) => {
2208
- await resetMutation({ videoId });
2209
- },
2210
- [resetMutation]
2211
- );
2212
- const value = React22.useMemo(
2213
- () => ({ save: onSave, load: onLoad, markComplete: onMarkComplete, reset: onReset }),
2214
- [onSave, onLoad, onMarkComplete, onReset]
2215
- );
2216
- return /* @__PURE__ */ jsx16(ProgressContext, { value, children });
2217
- }
2218
- function useDevultur() {
2219
- const context = React22.use(DevulturContext);
2220
- if (!context) throw new Error("useDevultur must be used within <DevulturProvider> or <DevulturViewerProvider>");
2221
- return context;
2222
- }
2223
- function useDevulturApi() {
2224
- const api = React22.use(DevulturApiContext);
2225
- if (api) return api;
2226
- if (React22.use(DevulturContext)) throw new DevulturCapabilityError("api");
2227
- throw new Error("useDevulturApi must be used within <DevulturProvider>");
2228
- }
2229
- var VIEWER_ACTIONS = {
2230
- uploadUrl: () => Promise.reject(new DevulturCapabilityError("uploadUrl")),
2231
- upload: () => Promise.reject(new DevulturCapabilityError("upload")),
2232
- deleteMedia: () => {
2233
- throw new DevulturCapabilityError("deleteMedia");
2234
- }
2235
- };
2236
- function DevulturViewerProvider({
2237
- baseUrl,
2238
- issueViewerToken,
2239
- tokenArgs,
2240
- children
2241
- }) {
2242
- const session = useMediaSession({ issueViewerToken, tokenArgs, baseUrl });
2243
- const devulturValue = React22.useMemo(() => ({ ...session, ...VIEWER_ACTIONS }), [session]);
2244
- return /* @__PURE__ */ jsx16(DevulturContext, { value: devulturValue, children });
2245
- }
2246
- function useDevulturProgress() {
2247
- const context = React22.use(ProgressContext);
2248
- if (!context) throw new Error("useDevulturProgress requires progress API in DevulturProvider");
2249
- return context;
2250
- }
2251
- function useMediaUrl(key, options) {
2252
- const { token, baseUrl } = useDevultur();
2253
- return React22.useMemo(() => {
2254
- if (!key) return null;
2255
- if (!baseUrl) throw new Error("useMediaUrl requires `baseUrl` on <DevulturProvider>");
2256
- const isPublic = visibilityFromKey(key) === "public";
2257
- return buildMediaUrl2(baseUrl, key, { ...options, token: isPublic ? void 0 : token ?? void 0 });
2258
- }, [key, baseUrl, token, options]);
2259
- }
2260
- async function confirmUploadBestEffort(confirmUpload, key, file, hints) {
2261
- try {
2262
- await confirmUpload({ key, filename: file.name, ...hints });
2263
- } catch (error) {
2264
- console.warn("Devultur: could not record the upload; it is recorded later", error);
2265
- }
2266
- }
2267
- async function readImageDimensions(file) {
2268
- if (typeof createImageBitmap !== "function" || !file.type.startsWith("image/")) return {};
2269
- try {
2270
- const bitmap = await createImageBitmap(file);
2271
- const dimensions = { width: bitmap.width, height: bitmap.height };
2272
- bitmap.close();
2273
- return dimensions;
2274
- } catch {
2275
- return {};
2276
- }
2277
- }
2278
- function useVideoProps(media, options) {
2279
- const { token, baseUrl, sessionSecret } = useDevultur();
2280
- const locales = options?.locales;
2281
- return React22.useMemo(() => {
2282
- if (media?.kind !== "video") return null;
2283
- if (!baseUrl) throw new Error("useVideoProps requires `baseUrl` on <DevulturProvider>");
2284
- const viewerToken = media.visibility === "private" ? token ?? void 0 : void 0;
2285
- const captions = (locales ?? []).flatMap((locale) => {
2286
- const path = mediaCaptionPath2(media, locale);
2287
- if (!path) return [];
2288
- const label = CaptionLocaleLabel[locale] ?? locale;
2289
- return [{ locale, label, src: buildMediaUrl2(baseUrl, path, { token: viewerToken }) }];
2290
- });
2291
- return {
2292
- src: buildMediaUrl2(baseUrl, mediaPath3(media), { token: viewerToken }),
2293
- token: viewerToken,
2294
- captions,
2295
- sessionSecret: media.visibility === "private" ? sessionSecret : void 0
2296
- };
2297
- }, [media, baseUrl, token, locales, sessionSecret]);
2298
- }
2299
- function DownloadLink({ media, filename, onDownloadError, children, ...anchorProps }) {
2300
- const { url, token, tokenStatus, tokenError, baseUrl, sessionSecret, reissueToken } = useDevultur();
2301
- const handleClick = React22.useCallback(
2302
- async (event) => {
2303
- if (media?.visibility !== "private" || !baseUrl) return;
2304
- event.preventDefault();
2305
- if (tokenStatus === "error") {
2306
- onDownloadError?.(tokenUnavailableError(tokenError));
2307
- return;
2308
- }
2309
- const response = await fetchMedia({
2310
- buildUrl: (viewerToken) => mediaUrl4(baseUrl, media, { download: filename ?? true, token: viewerToken }),
2311
- token,
2312
- sessionSecret,
2313
- reissueToken
2314
- });
2315
- if (!response.ok) {
2316
- const status = response.status;
2317
- onDownloadError?.(new MediaFetchError("http_error", `Media fetch failed: ${status}`, status));
2511
+ );
2512
+ const deleteMedia = React26.useCallback(
2513
+ (media) => {
2514
+ if (media.kind === "video" && api.deleteVideo) {
2515
+ deleteVideoAction({ videoId: media.id, visibility: media.visibility }).catch(console.error);
2318
2516
  return;
2319
2517
  }
2320
- const objectUrl = URL.createObjectURL(await response.blob());
2321
- const anchor = document.createElement("a");
2322
- anchor.href = objectUrl;
2323
- anchor.download = filename ?? media.filename ?? "download";
2324
- anchor.click();
2325
- URL.revokeObjectURL(objectUrl);
2518
+ deleteMediaAction({ key: mediaPath5(media) }).catch(console.error);
2326
2519
  },
2327
- [media, baseUrl, token, tokenStatus, tokenError, filename, sessionSecret, reissueToken, onDownloadError]
2520
+ [deleteMediaAction, deleteVideoAction, api.deleteVideo]
2328
2521
  );
2329
- if (!media) return null;
2330
- return /* @__PURE__ */ jsx16("a", { href: url(media, { download: filename ?? true }), download: true, ...anchorProps, onClick: handleClick, children });
2331
- }
2332
- function useDevulturMedia(key) {
2333
- const apiCtx = React22.use(DevulturApiContext);
2334
- if (!apiCtx?.getMedia) {
2335
- throw new Error("useDevulturMedia requires getMedia in DevulturProvider api");
2336
- }
2337
- const record = useQuery(apiCtx.getMedia, key ? { key } : "skip");
2338
- if (!record) {
2339
- return {
2340
- status: MediaStatus.IDLE,
2341
- progress: null,
2342
- error: null,
2343
- failureReason: null,
2344
- retriable: null,
2345
- isQueued: false,
2346
- isTranscoding: false,
2347
- isCaptioning: false,
2348
- isReady: false,
2349
- isFailed: false,
2350
- playlistUrl: null,
2351
- thumbnailKey: null,
2352
- captionLocales: [],
2353
- duration: null,
2354
- isLoading: record === void 0
2355
- };
2522
+ const devulturValue = React26.useMemo(
2523
+ () => ({ ...session, uploadUrl, upload, deleteMedia }),
2524
+ [session, uploadUrl, upload, deleteMedia]
2525
+ );
2526
+ let content = /* @__PURE__ */ jsx18(DevulturApiContext, { value: api, children: /* @__PURE__ */ jsx18(DevulturContext, { value: devulturValue, children }) });
2527
+ if (api.saveProgress && api.loadProgress && api.markComplete && api.resetProgress) {
2528
+ content = /* @__PURE__ */ jsx18(
2529
+ ProgressBridge,
2530
+ {
2531
+ save: api.saveProgress,
2532
+ load: api.loadProgress,
2533
+ markComplete: api.markComplete,
2534
+ reset: api.resetProgress,
2535
+ children: content
2536
+ }
2537
+ );
2356
2538
  }
2357
- const status = record.transcodeStatus ?? MediaStatus.IDLE;
2358
- const isReady = status === MediaStatus.READY;
2359
- const isFailed = status === MediaStatus.FAILED;
2360
- const progress = record.transcodeProgress ? {
2361
- current: record.transcodeProgress.current,
2362
- total: record.transcodeProgress.total,
2363
- percent: record.transcodeProgress.total > 0 ? Math.round(
2364
- record.transcodeProgress.current / record.transcodeProgress.total * 100
2365
- ) : 0,
2366
- phase: record.transcodeProgress.phase
2367
- } : null;
2368
- return {
2369
- status,
2370
- progress,
2371
- error: record.transcodeError ?? null,
2372
- failureReason: isFailed ? record.failureReason ?? "internal" : null,
2373
- retriable: isFailed ? record.retriable ?? true : null,
2374
- isQueued: status === MediaStatus.QUEUED,
2375
- isTranscoding: status === MediaStatus.TRANSCODING,
2376
- isCaptioning: status === MediaStatus.CAPTIONING,
2377
- isReady,
2378
- isFailed,
2379
- playlistUrl: record.playlistUrl ?? null,
2380
- thumbnailKey: record.thumbnailKey ?? null,
2381
- captionLocales: record.captionLocales ?? [],
2382
- duration: record.duration ?? null,
2383
- isLoading: false
2384
- };
2539
+ return content;
2385
2540
  }
2386
- function useVideoState(media, options) {
2387
- const { token, baseUrl, sessionSecret } = useDevultur();
2388
- const status = useDevulturMedia(media?.kind === "video" ? media.id : null);
2389
- const locales = options?.locales;
2390
- return React22.useMemo(() => {
2391
- if (media?.kind !== "video") return { phase: "idle" };
2392
- if (status.isFailed) {
2393
- return {
2394
- phase: "failed",
2395
- reason: status.failureReason ?? "internal",
2396
- message: status.error ?? "Video processing failed",
2397
- retriable: status.retriable ?? true
2398
- };
2399
- }
2400
- if (status.isReady) {
2401
- if (!baseUrl) throw new Error("useVideoState requires `baseUrl` on <DevulturProvider>");
2402
- const viewerToken = media.visibility === "private" ? token ?? void 0 : void 0;
2403
- const playlist = buildMediaUrl2(baseUrl, mediaPath3(media), { token: viewerToken });
2404
- const captions = (locales ?? status.captionLocales).flatMap((locale) => {
2405
- const path = mediaCaptionPath2(media, locale);
2406
- if (!path) return [];
2407
- const label = CaptionLocaleLabel[locale] ?? locale;
2408
- return [{ locale, label, src: buildMediaUrl2(baseUrl, path, { token: viewerToken }) }];
2409
- });
2541
+ function ProgressBridge({
2542
+ save,
2543
+ load,
2544
+ markComplete,
2545
+ reset,
2546
+ children
2547
+ }) {
2548
+ const convex = useConvex();
2549
+ const saveMutation = useMutation(save);
2550
+ const markCompleteMutation = useMutation(markComplete);
2551
+ const resetMutation = useMutation(reset);
2552
+ const onSave = React26.useCallback(
2553
+ async (videoId, position) => {
2554
+ await saveMutation({ videoId, position });
2555
+ },
2556
+ [saveMutation]
2557
+ );
2558
+ const onLoad = React26.useCallback(
2559
+ async (videoId) => {
2560
+ const result = await convex.query(load, { videoId });
2561
+ if (!result) return null;
2410
2562
  return {
2411
- phase: "ready",
2412
- media,
2413
- playlist,
2414
- duration: status.duration ?? 0,
2415
- captions,
2416
- playerProps: {
2417
- src: playlist,
2418
- token: viewerToken,
2419
- captions,
2420
- sessionSecret: media.visibility === "private" ? sessionSecret : void 0
2421
- }
2563
+ lastPosition: result.lastPosition,
2564
+ completedAt: result.completedAt ? new Date(result.completedAt) : null
2422
2565
  };
2423
- }
2424
- if (status.isQueued) return { phase: "queued" };
2425
- if (status.isTranscoding || status.isCaptioning) {
2426
- const step = status.progress?.phase ?? (status.isCaptioning ? "captioning" : "encoding");
2427
- return { phase: "processing", step, percent: status.progress?.percent ?? 0 };
2428
- }
2429
- return { phase: "idle" };
2430
- }, [media, status, baseUrl, token, locales, sessionSecret]);
2431
- }
2432
- function useMediaUpload(options) {
2433
- const { visibility, onComplete, onError, replaces, process, locales } = options ?? {};
2434
- const api = useDevulturApi();
2435
- const createUploadUrlAction = useAction2(api.createUploadUrl);
2436
- const deleteMediaAction = useAction2(api.deleteMedia);
2437
- const confirmUploadAction = useAction2(api.confirmUpload ?? api.createUploadUrl);
2438
- const processVideoAction = useAction2(api.processVideo ?? api.createUploadUrl);
2439
- const [phase, setPhase] = React22.useState("idle");
2440
- const [percent, setPercent] = React22.useState(0);
2441
- const [currentFile, setCurrentFile] = React22.useState(null);
2442
- const [media, setMedia] = React22.useState(null);
2443
- const [uploadFailure, setUploadFailure] = React22.useState(null);
2444
- const [processRequested, setProcessRequested] = React22.useState(false);
2445
- const abortRef = React22.useRef(null);
2446
- const lastFileRef = React22.useRef(null);
2447
- const lastKeyRef = React22.useRef(null);
2448
- const videoState = useVideoState(media, { locales });
2449
- const triggerProcess = React22.useCallback(
2450
- (key) => {
2451
- if (!api.processVideo) {
2452
- throw new Error("useMediaUpload: processing a video requires `processVideo` in the <DevulturProvider> api");
2453
- }
2454
- lastKeyRef.current = key;
2455
- setProcessRequested(true);
2456
- processVideoAction({ key }).catch((err) => {
2457
- console.error("useMediaUpload: could not request the transcode", err);
2458
- });
2459
2566
  },
2460
- [api.processVideo, processVideoAction]
2567
+ [convex, load]
2461
2568
  );
2462
- const runUpload = React22.useCallback(
2463
- async (file) => {
2464
- const controller = new AbortController();
2465
- abortRef.current = controller;
2466
- lastFileRef.current = file;
2467
- setUploadFailure(null);
2468
- setMedia(null);
2469
- setProcessRequested(false);
2470
- setCurrentFile(file);
2471
- setPercent(0);
2472
- setPhase("uploading");
2473
- const fail = (reason, message) => {
2474
- const failure = { stage: "upload", reason, retriable: true, message };
2475
- setUploadFailure(failure);
2476
- setPhase("upload-failed");
2477
- onError?.(failure);
2478
- return null;
2479
- };
2480
- let result;
2481
- try {
2482
- result = await createUploadUrlAction({
2483
- filename: file.name,
2484
- contentType: file.type,
2485
- visibility
2486
- });
2487
- } catch (err) {
2488
- return fail("rejected", err instanceof Error ? err.message : "Could not start the upload");
2489
- }
2490
- const dimensions = await readImageDimensions(file);
2491
- try {
2492
- await uploadFileWithProgress(result.url, file, { onProgress: setPercent, signal: controller.signal });
2493
- } catch (err) {
2494
- if (err instanceof DOMException && err.name === "AbortError") {
2495
- setPhase("idle");
2496
- setCurrentFile(null);
2497
- setPercent(0);
2498
- return null;
2499
- }
2500
- const rejected = err instanceof UploadHttpError && (err.status === 413 || err.status === 415);
2501
- return fail(rejected ? "rejected" : "network", err instanceof Error ? err.message : "The upload failed");
2502
- }
2503
- if (api.confirmUpload) await confirmUploadBestEffort(confirmUploadAction, result.key, file, dimensions);
2504
- const uploaded = {
2505
- id: result.id,
2506
- kind: result.kind,
2507
- visibility: result.visibility,
2508
- filename: file.name,
2509
- key: result.key,
2510
- ...dimensions
2511
- };
2512
- setMedia(uploaded);
2513
- setPhase("uploaded");
2514
- if (replaces) deleteMediaAction({ key: mediaPath3(replaces) }).catch(() => {
2515
- });
2516
- onComplete?.(uploaded);
2517
- if ((process ?? true) && uploaded.kind === "video") triggerProcess(result.key);
2518
- return uploaded;
2569
+ const onMarkComplete = React26.useCallback(
2570
+ async (videoId) => {
2571
+ await markCompleteMutation({ videoId });
2519
2572
  },
2520
- [
2521
- createUploadUrlAction,
2522
- confirmUploadAction,
2523
- api.confirmUpload,
2524
- deleteMediaAction,
2525
- triggerProcess,
2526
- visibility,
2527
- replaces,
2528
- process,
2529
- onComplete,
2530
- onError
2531
- ]
2573
+ [markCompleteMutation]
2532
2574
  );
2533
- const cancel = React22.useCallback(() => {
2534
- abortRef.current?.abort();
2535
- }, []);
2536
- const reset = React22.useCallback(() => {
2537
- abortRef.current?.abort();
2538
- setPhase("idle");
2539
- setPercent(0);
2540
- setCurrentFile(null);
2541
- setMedia(null);
2542
- setUploadFailure(null);
2543
- setProcessRequested(false);
2544
- }, []);
2545
- const retry = React22.useCallback(() => {
2546
- if (phase === "upload-failed" && lastFileRef.current) {
2547
- runUpload(lastFileRef.current);
2548
- return;
2549
- }
2550
- if (lastKeyRef.current) triggerProcess(lastKeyRef.current);
2551
- }, [phase, runUpload, triggerProcess]);
2552
- React22.useEffect(() => () => abortRef.current?.abort(), []);
2553
- const state = React22.useMemo(
2554
- () => deriveUploadState(
2555
- { phase, percent, file: currentFile, failure: uploadFailure, media, processRequested },
2556
- videoState,
2557
- retry
2558
- ),
2559
- [phase, percent, currentFile, uploadFailure, media, processRequested, videoState, retry]
2575
+ const onReset = React26.useCallback(
2576
+ async (videoId) => {
2577
+ await resetMutation({ videoId });
2578
+ },
2579
+ [resetMutation]
2560
2580
  );
2561
- const upload = React22.useCallback((file) => runUpload(file), [runUpload]);
2562
- return { upload, state, cancel, reset };
2581
+ const value = React26.useMemo(
2582
+ () => ({ save: onSave, load: onLoad, markComplete: onMarkComplete, reset: onReset }),
2583
+ [onSave, onLoad, onMarkComplete, onReset]
2584
+ );
2585
+ return /* @__PURE__ */ jsx18(DevulturProgressContext, { value, children });
2563
2586
  }
2564
2587
  export {
2565
2588
  AttachmentCard,
2566
2589
  CropUpload,
2590
+ DevulturApiContext,
2567
2591
  DevulturCapabilityError,
2592
+ DevulturContext,
2593
+ DevulturProgressContext,
2568
2594
  DevulturProvider,
2569
2595
  DevulturViewerProvider,
2570
2596
  DocumentLink,
@@ -2586,6 +2612,7 @@ export {
2586
2612
  useDevultur,
2587
2613
  useDevulturApi,
2588
2614
  useDevulturMedia,
2615
+ useDevulturOptional,
2589
2616
  useDevulturProgress,
2590
2617
  useMediaLibrary,
2591
2618
  useMediaObjectUrl,