@devultur/convex 1.3.2 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +426 -9
  2. package/dist/bin/init.js +44 -23
  3. package/dist/chunk-EX3W77VY.js +245 -0
  4. package/dist/chunk-HOFTKJWT.js +58 -0
  5. package/dist/chunk-KAIVI7RB.js +21 -0
  6. package/dist/chunk-QBHIZBOQ.js +139 -0
  7. package/dist/{chunk-GKLFRSGI.js → chunk-YIPZZDGD.js} +8 -1
  8. package/dist/component/_generated/api.d.ts +6 -0
  9. package/dist/component/_generated/api.d.ts.map +1 -1
  10. package/dist/component/_generated/api.js.map +1 -1
  11. package/dist/component/_generated/component.d.ts +27 -0
  12. package/dist/component/_generated/component.d.ts.map +1 -1
  13. package/dist/component/library.d.ts +93 -0
  14. package/dist/component/library.d.ts.map +1 -0
  15. package/dist/component/library.js +74 -0
  16. package/dist/component/library.js.map +1 -0
  17. package/dist/component/mediaMeta.d.ts +46 -0
  18. package/dist/component/mediaMeta.d.ts.map +1 -0
  19. package/dist/component/mediaMeta.js +82 -0
  20. package/dist/component/mediaMeta.js.map +1 -0
  21. package/dist/component/metadata.d.ts +59 -0
  22. package/dist/component/metadata.d.ts.map +1 -0
  23. package/dist/component/metadata.js +32 -0
  24. package/dist/component/metadata.js.map +1 -0
  25. package/dist/component/public.d.ts +17 -1
  26. package/dist/component/public.d.ts.map +1 -1
  27. package/dist/component/public.js +20 -11
  28. package/dist/component/public.js.map +1 -1
  29. package/dist/component/schema.d.ts +17 -2
  30. package/dist/component/schema.d.ts.map +1 -1
  31. package/dist/component/schema.js +13 -1
  32. package/dist/component/schema.js.map +1 -1
  33. package/dist/component/uploads.d.ts +13 -0
  34. package/dist/component/uploads.d.ts.map +1 -1
  35. package/dist/component/uploads.js +24 -5
  36. package/dist/component/uploads.js.map +1 -1
  37. package/dist/error-labels-BfqcFh2R.d.ts +19 -0
  38. package/dist/index.d.ts +204 -93
  39. package/dist/index.js +200 -128
  40. package/dist/locales/es.d.ts +11 -0
  41. package/dist/locales/es.js +162 -0
  42. package/dist/media-picker-labels-CiVcNUZo.d.ts +114 -0
  43. package/dist/{pdf-render-CiCK6V1X.d.ts → pdf-render-BwJGagvz.d.ts} +1 -1
  44. package/dist/pdfjs.d.ts +13 -2
  45. package/dist/pdfjs.js +9 -0
  46. package/dist/react.d.ts +441 -66
  47. package/dist/react.js +1663 -550
  48. package/dist/types-LrusGVDf.d.ts +62 -0
  49. package/dist/validators.d.ts +17 -5
  50. package/dist/validators.js +1 -1
  51. package/package.json +8 -4
package/dist/react.js CHANGED
@@ -1,22 +1,62 @@
1
1
  "use client";
2
+ import {
3
+ mediaFromRecord
4
+ } from "./chunk-KAIVI7RB.js";
5
+ import {
6
+ countPages,
7
+ isRenderCancelled,
8
+ openPdf,
9
+ pageText,
10
+ renderPage
11
+ } from "./chunk-HOFTKJWT.js";
12
+ import {
13
+ attachmentDetails,
14
+ attachmentKindOf,
15
+ attachmentName,
16
+ defaultAttachmentLabels,
17
+ expectedContentType,
18
+ formatDuration,
19
+ formatFileSize,
20
+ mergeAttachmentLabels,
21
+ previewKindOf,
22
+ trustedContentType
23
+ } from "./chunk-QBHIZBOQ.js";
24
+ import {
25
+ describeDevulturError
26
+ } from "./chunk-EX3W77VY.js";
2
27
 
3
28
  // src/react.tsx
4
- import React8 from "react";
29
+ import React22 from "react";
5
30
  import {
6
- buildMediaUrl,
31
+ buildMediaUrl as buildMediaUrl2,
7
32
  CaptionLocaleLabel,
8
33
  MediaStatus,
9
- mediaCaptionPath,
10
- mediaPath as mediaPath2,
11
- mediaUrl as mediaUrl2,
12
- SESSION_HEADER as SESSION_HEADER2,
34
+ mediaCaptionPath as mediaCaptionPath2,
35
+ mediaPath as mediaPath3,
36
+ mediaUrl as mediaUrl4,
13
37
  visibilityFromKey
14
38
  } from "@devultur/core";
15
39
  import {
16
40
  UploadHttpError,
17
41
  uploadFileWithProgress
18
42
  } from "@devultur/react";
19
- import { useAction, useConvex, useMutation, useQuery } from "convex/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";
20
60
 
21
61
  // src/image-recovery.ts
22
62
  async function isSessionMismatch(response) {
@@ -28,89 +68,21 @@ async function isSessionMismatch(response) {
28
68
  }
29
69
  }
30
70
 
31
- // src/media-fetch.ts
32
- import { SESSION_HEADER } from "@devultur/core";
33
-
34
- // src/preview-type.ts
35
- var PREVIEW_KIND_BY_TYPE = /* @__PURE__ */ new Map([
36
- ["image/png", "image"],
37
- ["image/jpeg", "image"],
38
- ["image/webp", "image"],
39
- ["image/gif", "image"],
40
- ["image/avif", "image"],
41
- ["application/pdf", "pdf"],
42
- ["audio/mpeg", "audio"],
43
- ["audio/mp4", "audio"],
44
- ["audio/aac", "audio"],
45
- ["audio/ogg", "audio"],
46
- ["audio/wav", "audio"],
47
- ["audio/webm", "audio"],
48
- ["audio/flac", "audio"],
49
- ["text/plain", "text"],
50
- ["text/csv", "text"],
51
- ["application/json", "text"]
52
- ]);
53
- var TYPE_BY_EXTENSION = /* @__PURE__ */ new Map([
54
- ["png", "image/png"],
55
- ["jpg", "image/jpeg"],
56
- ["jpeg", "image/jpeg"],
57
- ["webp", "image/webp"],
58
- ["gif", "image/gif"],
59
- ["avif", "image/avif"],
60
- ["pdf", "application/pdf"],
61
- ["mp3", "audio/mpeg"],
62
- ["m4a", "audio/mp4"],
63
- ["aac", "audio/aac"],
64
- ["ogg", "audio/ogg"],
65
- ["oga", "audio/ogg"],
66
- ["opus", "audio/ogg"],
67
- ["wav", "audio/wav"],
68
- ["weba", "audio/webm"],
69
- ["flac", "audio/flac"],
70
- ["txt", "text/plain"],
71
- ["csv", "text/csv"],
72
- ["json", "application/json"]
73
- ]);
74
- var OPAQUE_CONTENT_TYPE = "application/octet-stream";
75
- function normalizeContentType(value) {
76
- const essence = value?.split(";")[0]?.trim().toLowerCase();
77
- return essence || null;
78
- }
79
- function previewKindOf(contentType) {
80
- const normalized = normalizeContentType(contentType);
81
- if (!normalized) return null;
82
- return PREVIEW_KIND_BY_TYPE.get(normalized) ?? null;
83
- }
84
- function expectedContentType(media, declaredType) {
85
- if (declaredType) return normalizeContentType(declaredType);
86
- const extension = media.filename?.split(".").pop()?.toLowerCase();
87
- if (!extension || extension === media.filename?.toLowerCase()) return null;
88
- return TYPE_BY_EXTENSION.get(extension) ?? null;
89
- }
90
- function trustedContentType(expectedType, responseType) {
91
- const receivedKind = previewKindOf(responseType);
92
- if (!expectedType) {
93
- const contentType = receivedKind ? normalizeContentType(responseType) : null;
94
- return { ok: true, contentType: contentType ?? OPAQUE_CONTENT_TYPE };
95
- }
96
- const expectedKind = previewKindOf(expectedType);
97
- if (!expectedKind) return { ok: false, reason: "unsupported_type" };
98
- if (receivedKind !== expectedKind) return { ok: false, reason: "type_mismatch" };
99
- return { ok: true, contentType: normalizeContentType(expectedType) ?? OPAQUE_CONTENT_TYPE };
100
- }
101
-
102
71
  // src/media-fetch.ts
103
72
  var MediaFetchError = class extends Error {
104
73
  code;
105
74
  /** HTTP status, for `http_error`. */
106
75
  status;
107
- constructor(code, message, status) {
108
- super(message);
76
+ constructor(code, message, status, options) {
77
+ super(message, options);
109
78
  this.name = "MediaFetchError";
110
79
  this.code = code;
111
80
  this.status = status;
112
81
  }
113
82
  };
83
+ function tokenUnavailableError(cause) {
84
+ return new MediaFetchError("token_unavailable", "No viewer token: its issuance failed", void 0, { cause });
85
+ }
114
86
  async function fetchMedia(request) {
115
87
  const { buildUrl, token, sessionSecret, reissueToken, signal, isPrivate = true, canReissue = true } = request;
116
88
  const headers = isPrivate && sessionSecret ? { [SESSION_HEADER]: sessionSecret } : void 0;
@@ -171,47 +143,15 @@ async function readCapped(body, maxBytes) {
171
143
  return bytes;
172
144
  }
173
145
 
174
- // src/media-upload-derive.ts
175
- function deriveUploadState(local, videoState, retry) {
176
- const { phase, percent, file, failure, media, processRequested } = local;
177
- if (phase === "uploading") return { phase: "uploading", percent, file };
178
- if (phase === "upload-failed" && failure) {
179
- return {
180
- phase: "failed",
181
- stage: "upload",
182
- reason: failure.reason,
183
- retriable: failure.retriable,
184
- message: failure.message,
185
- retry
186
- };
187
- }
188
- if (phase === "idle" || !media) return { phase: "idle" };
189
- if (media.kind !== "video" || !processRequested) return { phase: "ready", media };
190
- switch (videoState.phase) {
191
- case "processing":
192
- return { phase: "processing", media, step: videoState.step, percent: videoState.percent };
193
- case "ready":
194
- return {
195
- phase: "ready",
196
- media,
197
- playlist: videoState.playlist,
198
- duration: videoState.duration,
199
- captions: videoState.captions,
200
- playerProps: videoState.playerProps
201
- };
202
- case "failed":
203
- return {
204
- phase: "failed",
205
- stage: "transcode",
206
- reason: videoState.reason,
207
- retriable: videoState.retriable,
208
- message: videoState.message,
209
- retry
210
- };
211
- default:
212
- return { phase: "queued", media };
213
- }
214
- }
146
+ // src/media-session.ts
147
+ import React2 from "react";
148
+ import {
149
+ buildMediaUrl,
150
+ mediaCaptionPath,
151
+ mediaUrl,
152
+ SESSION_HEADER as SESSION_HEADER2
153
+ } from "@devultur/core";
154
+ import { useAction } from "convex/react";
215
155
 
216
156
  // src/viewer-token.ts
217
157
  import React from "react";
@@ -221,10 +161,15 @@ var REFRESH_BUFFER_MS = 5 * 60 * 1e3;
221
161
  var MIN_REFRESH_MS = 6e4;
222
162
  var DEFAULT_TTL_MS = DEFAULT_TOKEN_EXPIRY_SECONDS * 1e3;
223
163
  function extractTokenResult(result) {
224
- const tokenValue = typeof result === "string" ? result : result.token;
225
- const ttlMs = typeof result === "string" ? DEFAULT_TTL_MS : result.expiresIn * 1e3;
164
+ const tokenValue = typeof result === "string" ? result : result?.token;
165
+ if (typeof tokenValue !== "string" || tokenValue === "") {
166
+ throw new Error("The viewer token action returned no token");
167
+ }
168
+ const expiresIn = typeof result === "object" ? result?.expiresIn : void 0;
169
+ const ttlMs = typeof expiresIn === "number" ? expiresIn * 1e3 : DEFAULT_TTL_MS;
226
170
  return { tokenValue, ttlMs };
227
171
  }
172
+ var asError = (error) => error instanceof Error ? error : new Error(String(error));
228
173
  function sortKeys(json) {
229
174
  if (Array.isArray(json)) return json.map(sortKeys);
230
175
  if (json === null || typeof json !== "object") return json;
@@ -239,7 +184,9 @@ function useViewerToken({ issueViewerToken, sessionSecret, tokenArgs }) {
239
184
  const argsKey = tokenArgsKey(tokenArgs);
240
185
  const stableArgs = React.useMemo(() => tokenArgs ?? {}, [argsKey]);
241
186
  const [issued, setIssued] = React.useState(null);
187
+ const [attempt, setAttempt] = React.useState(null);
242
188
  const token = issued?.argsKey === argsKey ? issued.token : null;
189
+ const currentAttempt = attempt?.argsKey === argsKey ? attempt : null;
243
190
  const currentArgsKeyRef = React.useRef(argsKey);
244
191
  const refreshTimerRef = React.useRef(null);
245
192
  const inFlightRef = React.useRef(null);
@@ -251,21 +198,27 @@ function useViewerToken({ issueViewerToken, sessionSecret, tokenArgs }) {
251
198
  if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current);
252
199
  };
253
200
  }, []);
254
- const request = React.useCallback(() => {
255
- const renew = () => void request();
256
- return issueViewerToken({ ...stableArgs, sessionSecret }).then((result) => {
257
- if (currentArgsKeyRef.current !== argsKey) return null;
258
- const { tokenValue, ttlMs } = extractTokenResult(result);
259
- setIssued({ token: tokenValue, argsKey });
260
- if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current);
261
- refreshTimerRef.current = setTimeout(renew, Math.max(ttlMs - REFRESH_BUFFER_MS, MIN_REFRESH_MS));
262
- return tokenValue;
263
- });
201
+ const request = React.useCallback(async () => {
202
+ const renew = () => {
203
+ request().catch((error) => console.warn("Devultur: could not renew the viewer token", error));
204
+ };
205
+ const result = await issueViewerToken({ ...stableArgs, sessionSecret });
206
+ if (currentArgsKeyRef.current !== argsKey) return null;
207
+ const { tokenValue, ttlMs } = extractTokenResult(result);
208
+ setIssued({ token: tokenValue, argsKey });
209
+ setAttempt(null);
210
+ if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current);
211
+ refreshTimerRef.current = setTimeout(renew, Math.max(ttlMs - REFRESH_BUFFER_MS, MIN_REFRESH_MS));
212
+ return tokenValue;
264
213
  }, [issueViewerToken, stableArgs, argsKey, sessionSecret]);
265
214
  const issueOnce = React.useCallback(() => {
266
215
  const inFlight = inFlightRef.current;
267
216
  if (inFlight?.argsKey === argsKey) return inFlight.promise;
268
- const promise = request();
217
+ setAttempt({ argsKey, status: "loading" });
218
+ const promise = request().catch((error) => {
219
+ if (currentArgsKeyRef.current === argsKey) setAttempt({ argsKey, status: "error", error: asError(error) });
220
+ throw error;
221
+ });
269
222
  const settle = () => {
270
223
  if (inFlightRef.current?.promise === promise) inFlightRef.current = null;
271
224
  };
@@ -273,16 +226,120 @@ function useViewerToken({ issueViewerToken, sessionSecret, tokenArgs }) {
273
226
  inFlightRef.current = { argsKey, promise };
274
227
  return promise;
275
228
  }, [request, argsKey]);
229
+ const hasAttempt = currentAttempt !== null;
276
230
  React.useEffect(() => {
277
- if (token) return;
278
- issueOnce();
279
- }, [token, issueOnce]);
280
- const reissueToken = React.useCallback(() => issueOnce().catch(() => null), [issueOnce]);
281
- return { token, reissueToken };
231
+ if (token || hasAttempt) return;
232
+ issueOnce().catch(() => {
233
+ });
234
+ }, [token, hasAttempt, issueOnce]);
235
+ const reissueToken = React.useCallback(
236
+ () => issueOnce().catch(() => {
237
+ setIssued((current) => current?.argsKey === argsKey ? null : current);
238
+ return null;
239
+ }),
240
+ [issueOnce, argsKey]
241
+ );
242
+ const retryToken = React.useCallback(() => issueOnce().catch(() => null), [issueOnce]);
243
+ const tokenStatus = token ? "ready" : currentAttempt?.status ?? "idle";
244
+ const tokenError = !token && currentAttempt?.status === "error" ? currentAttempt.error : null;
245
+ return { token, tokenStatus, tokenError, reissueToken, retryToken };
246
+ }
247
+
248
+ // src/media-session.ts
249
+ function useMediaSession({ issueViewerToken, tokenArgs, baseUrl }) {
250
+ const [sessionSecret] = React2.useState(() => typeof crypto !== "undefined" ? crypto.randomUUID() : "");
251
+ const issueViewerTokenAction = useAction(issueViewerToken);
252
+ const { token, tokenStatus, tokenError, reissueToken, retryToken } = useViewerToken({
253
+ issueViewerToken: issueViewerTokenAction,
254
+ sessionSecret,
255
+ tokenArgs
256
+ });
257
+ const url = React2.useCallback(
258
+ (media, options) => {
259
+ if (!baseUrl) throw new Error("Devultur `url()` requires `baseUrl` on <DevulturProvider>");
260
+ return mediaUrl(baseUrl, media, { ...options, token: token ?? void 0 });
261
+ },
262
+ [baseUrl, token]
263
+ );
264
+ const getCaptionsText = React2.useCallback(
265
+ async (media, locale) => {
266
+ if (!baseUrl) throw new Error("Devultur `getCaptionsText()` requires `baseUrl` on <DevulturProvider>");
267
+ const path = mediaCaptionPath(media, locale);
268
+ if (!path) throw new Error("getCaptionsText: the reference is not a video");
269
+ const isPrivate = media.visibility === "private";
270
+ const fetchWith = (viewerToken) => fetch(buildMediaUrl(baseUrl, path, { token: viewerToken }), {
271
+ headers: sessionSecret ? { [SESSION_HEADER2]: sessionSecret } : void 0
272
+ });
273
+ let response = await fetchWith(isPrivate ? token ?? void 0 : void 0);
274
+ if (isPrivate && response.status === 401 && await isSessionMismatch(response)) {
275
+ const fresh = await reissueToken();
276
+ if (fresh) response = await fetchWith(fresh);
277
+ }
278
+ if (!response.ok) throw new Error(`Captions fetch failed: ${response.status}`);
279
+ return response.text();
280
+ },
281
+ [baseUrl, token, reissueToken, sessionSecret]
282
+ );
283
+ return React2.useMemo(
284
+ () => ({
285
+ token,
286
+ tokenStatus,
287
+ tokenError,
288
+ retryToken,
289
+ baseUrl: baseUrl ?? null,
290
+ sessionSecret,
291
+ url,
292
+ getCaptionsText,
293
+ reissueToken
294
+ }),
295
+ [token, tokenStatus, tokenError, retryToken, baseUrl, sessionSecret, url, getCaptionsText, reissueToken]
296
+ );
297
+ }
298
+
299
+ // src/media-upload-derive.ts
300
+ function deriveUploadState(local, videoState, retry) {
301
+ const { phase, percent, file, failure, media, processRequested } = local;
302
+ if (phase === "uploading") return { phase: "uploading", percent, file };
303
+ if (phase === "upload-failed" && failure) {
304
+ return {
305
+ phase: "failed",
306
+ stage: "upload",
307
+ reason: failure.reason,
308
+ retriable: failure.retriable,
309
+ message: failure.message,
310
+ retry
311
+ };
312
+ }
313
+ if (phase === "idle" || !media) return { phase: "idle" };
314
+ if (media.kind !== "video" || !processRequested) return { phase: "ready", media };
315
+ switch (videoState.phase) {
316
+ case "processing":
317
+ return { phase: "processing", media, step: videoState.step, percent: videoState.percent };
318
+ case "ready":
319
+ return {
320
+ phase: "ready",
321
+ media,
322
+ playlist: videoState.playlist,
323
+ duration: videoState.duration,
324
+ captions: videoState.captions,
325
+ playerProps: videoState.playerProps
326
+ };
327
+ case "failed":
328
+ return {
329
+ phase: "failed",
330
+ stage: "transcode",
331
+ reason: videoState.reason,
332
+ retriable: videoState.retriable,
333
+ message: videoState.message,
334
+ retry
335
+ };
336
+ default:
337
+ return { phase: "queued", media };
338
+ }
282
339
  }
283
340
 
284
341
  // src/crop-upload.tsx
285
- import React2 from "react";
342
+ import React3 from "react";
286
343
  import { ImageCropper } from "@devultur/react";
287
344
  import { jsx, jsxs } from "react/jsx-runtime";
288
345
  var DEFAULT_LABELS = {
@@ -306,18 +363,18 @@ function CropUpload({
306
363
  children
307
364
  }) {
308
365
  const text = { ...DEFAULT_LABELS, ...labels };
309
- const [pendingFile, setPendingFile] = React2.useState(null);
310
- const inputRef = React2.useRef(null);
366
+ const [pendingFile, setPendingFile] = React3.useState(null);
367
+ const inputRef = React3.useRef(null);
311
368
  const { upload, state, cancel, reset } = useMediaUpload({ visibility, replaces, onComplete });
312
- const pickFile = React2.useCallback(() => inputRef.current?.click(), []);
313
- const handleCrop = React2.useCallback(
369
+ const pickFile = React3.useCallback(() => inputRef.current?.click(), []);
370
+ const handleCrop = React3.useCallback(
314
371
  (baked) => {
315
372
  setPendingFile(null);
316
373
  upload(baked);
317
374
  },
318
375
  [upload]
319
376
  );
320
- const resetAll = React2.useCallback(() => {
377
+ const resetAll = React3.useCallback(() => {
321
378
  setPendingFile(null);
322
379
  reset();
323
380
  }, [reset]);
@@ -369,104 +426,346 @@ function CropUpload({
369
426
  ] });
370
427
  }
371
428
 
372
- // src/media-resource.ts
373
- import React3 from "react";
374
- import { mediaPath, mediaUrl } from "@devultur/core";
375
- var IDLE = { value: null, contentType: null, isLoading: false, error: null };
376
- var LOADING = { value: null, contentType: null, isLoading: true, error: null };
377
- function useMediaResource(media, options) {
378
- const devultur = useDevultur();
379
- const { isEnabled, maxBytes, contentType } = options;
380
- const isPrivate = media?.visibility === "private";
381
- const isReady = !isPrivate || devultur.token !== null;
382
- const requestKey = media && isEnabled ? [devultur.baseUrl, media.visibility, mediaPath(media), contentType ?? "", maxBytes ?? ""].join("|") : null;
383
- const [settled, setSettled] = React3.useState(null);
384
- const inputsRef = React3.useRef({ media, devultur, options });
385
- React3.useEffect(() => {
386
- inputsRef.current = { media, devultur, options };
387
- });
388
- React3.useEffect(() => {
389
- if (!requestKey || !isReady) return;
390
- const { media: target, devultur: context, options: current } = inputsRef.current;
391
- if (!target || !context.baseUrl) return;
392
- const baseUrl = context.baseUrl;
393
- const controller = new AbortController();
394
- let value = null;
395
- loadMedia({
396
- buildUrl: (viewerToken) => mediaUrl(baseUrl, target, { token: viewerToken }),
397
- token: context.token,
398
- sessionSecret: context.sessionSecret,
399
- reissueToken: context.reissueToken,
400
- isPrivate: target.visibility === "private",
401
- signal: controller.signal,
402
- maxBytes: current.maxBytes,
403
- contentType: current.contentType
404
- }).then((loaded) => {
405
- if (controller.signal.aborted) return;
406
- value = current.decode(loaded.bytes, loaded.contentType);
407
- setSettled({ requestKey, value, contentType: loaded.contentType, isLoading: false, error: null });
408
- }).catch((error) => {
409
- if (controller.signal.aborted) return;
410
- const failure = error instanceof Error ? error : new Error(String(error));
411
- setSettled({ requestKey, value: null, contentType: null, isLoading: false, error: failure });
412
- });
413
- return () => {
414
- controller.abort();
415
- if (value !== null) current.release?.(value);
416
- };
417
- }, [requestKey, isReady]);
418
- if (!requestKey) return IDLE;
419
- if (!devultur.baseUrl) throw new Error("Devultur media previews require `baseUrl` on <DevulturProvider>");
420
- if (settled?.requestKey !== requestKey) return LOADING;
421
- return { value: settled.value, contentType: settled.contentType, isLoading: false, error: settled.error };
429
+ // src/document-renderers.tsx
430
+ import React4 from "react";
431
+
432
+ // src/attachment-icons.tsx
433
+ import { jsx as jsx2 } from "react/jsx-runtime";
434
+ var SHEET = "M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z M14 3v5h5";
435
+ var ICON_PATHS = {
436
+ file: SHEET,
437
+ text: `${SHEET} M9 13h6 M9 17h6`,
438
+ 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`,
439
+ 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",
440
+ 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",
441
+ 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"
442
+ };
443
+ function AttachmentIcon({ kind }) {
444
+ return /* @__PURE__ */ jsx2(
445
+ "svg",
446
+ {
447
+ "aria-hidden": "true",
448
+ focusable: "false",
449
+ width: "24",
450
+ height: "24",
451
+ viewBox: "0 0 24 24",
452
+ fill: "none",
453
+ stroke: "currentColor",
454
+ strokeWidth: "1.5",
455
+ strokeLinecap: "round",
456
+ strokeLinejoin: "round",
457
+ "data-part": "icon",
458
+ "data-kind": kind,
459
+ children: /* @__PURE__ */ jsx2("path", { d: ICON_PATHS[kind] })
460
+ }
461
+ );
422
462
  }
423
463
 
424
- // src/media-object-url.ts
425
- var createObjectUrl = (bytes, contentType) => URL.createObjectURL(new Blob([bytes], { type: contentType }));
426
- var revokeObjectUrl = (objectUrl) => URL.revokeObjectURL(objectUrl);
427
- function useMediaObjectUrl(media, options) {
464
+ // src/document-renderers.tsx
465
+ import { jsx as jsx3, jsxs as jsxs2 } from "react/jsx-runtime";
466
+ function DocumentLink({ media, download = false, children, labels, ...anchorProps }) {
428
467
  const { url } = useDevultur();
429
- const isEnabled = (options?.enabled ?? true) && Boolean(media);
430
- const isUnverifiedPublic = media?.visibility === "public" && !options?.contentType;
431
- const resource = useMediaResource(media, {
432
- isEnabled: isEnabled && !isUnverifiedPublic,
433
- maxBytes: options?.maxBytes,
434
- contentType: options?.contentType,
435
- decode: createObjectUrl,
436
- release: revokeObjectUrl
437
- });
438
- if (media && isEnabled && isUnverifiedPublic) {
439
- return { objectUrl: url(media), contentType: null, isLoading: false, error: null };
468
+ if (!media) return null;
469
+ const text = children ?? attachmentName(media, mergeAttachmentLabels(labels).untitled);
470
+ if (download || media.visibility === "private") {
471
+ return /* @__PURE__ */ jsx3(DownloadLink, { media, ...anchorProps, children: text });
440
472
  }
441
- return {
442
- objectUrl: resource.value,
443
- contentType: resource.contentType,
444
- isLoading: resource.isLoading,
445
- error: resource.error
446
- };
473
+ return /* @__PURE__ */ jsx3("a", { href: url(media), ...anchorProps, children: text });
474
+ }
475
+ function AttachmentCard({
476
+ media,
477
+ contentType,
478
+ onOpen,
479
+ preview,
480
+ headingLevel = 3,
481
+ locale,
482
+ labels,
483
+ ...articleProps
484
+ }) {
485
+ const headingId = React4.useId();
486
+ if (!media) return null;
487
+ const text = mergeAttachmentLabels(labels);
488
+ const kind = attachmentKindOf(media, contentType);
489
+ const details = attachmentDetails(media, kind, text, locale ?? text.locale);
490
+ const Heading = `h${headingLevel}`;
491
+ return /* @__PURE__ */ jsxs2("article", { "aria-labelledby": headingId, "data-kind": kind, ...articleProps, children: [
492
+ preview ?? /* @__PURE__ */ jsx3(AttachmentIcon, { kind }),
493
+ /* @__PURE__ */ jsx3(Heading, { id: headingId, "data-part": "name", children: attachmentName(media, text.untitled) }),
494
+ /* @__PURE__ */ jsx3("p", { "data-part": "details", children: details.join(" \xB7 ") }),
495
+ /* @__PURE__ */ jsxs2("div", { "data-part": "actions", children: [
496
+ /* @__PURE__ */ jsx3(DownloadLink, { media, "aria-describedby": headingId, "data-part": "download", children: text.download }),
497
+ onOpen && /* @__PURE__ */ jsx3("button", { type: "button", onClick: () => onOpen(media), "aria-describedby": headingId, "data-part": "open", children: text.open })
498
+ ] })
499
+ ] });
447
500
  }
448
501
 
449
- // src/pdf-context.tsx
450
- import React4 from "react";
502
+ // src/image.tsx
503
+ import React5 from "react";
504
+ import { mediaUrl as mediaUrl2 } from "@devultur/core";
451
505
 
452
- // src/preview-labels.ts
453
- var ERROR_TEXT = {
454
- http_error: "The file couldn't be loaded.",
455
- too_large: "The file is too large to preview.",
456
- unsupported_type: "This file type can't be previewed.",
457
- type_mismatch: "The file's content doesn't match its type."
458
- };
459
- var defaultPreviewLabels = {
460
- document: (title, pageCount) => `${title}, ${pageCount === 1 ? "1 page" : `${pageCount} pages`}`,
461
- page: (page, pageCount) => `Page ${page} of ${pageCount}`,
462
- loading: "Loading\u2026",
463
- pageCap: (shown, total) => `Showing the first ${shown} of ${total} pages.`,
464
- error: (code) => ERROR_TEXT[code],
465
- thumbnail: (title) => title,
466
- untitledPdf: "PDF",
467
- untitledMedia: "Preview",
468
- download: "Download"
469
- };
506
+ // src/responsive-image.ts
507
+ function normalizeWidths(widths) {
508
+ const validWidths = widths.filter((width) => Number.isInteger(width) && width > 0);
509
+ return [...new Set(validWidths)].sort((a, b) => a - b);
510
+ }
511
+ function buildSrcSet(widths, urlForWidth) {
512
+ return widths.map((width) => `${urlForWidth(width)} ${width}w`).join(", ");
513
+ }
514
+ function scaledHeight(targetWidth, width, height) {
515
+ if (!width || !height) return void 0;
516
+ return Math.round(targetWidth * height / width);
517
+ }
518
+ function pickWidth(widths, renderedWidth) {
519
+ const ascending = normalizeWidths(widths);
520
+ if (renderedWidth === null) return ascending.at(-1);
521
+ const fitting = ascending.filter((width) => width <= renderedWidth * 2);
522
+ return fitting.at(-1) ?? ascending[0];
523
+ }
524
+ var LENGTH = /^(\d+(?:\.\d+)?)(px|vw)$/;
525
+ function lengthInPixels(length, viewportWidth) {
526
+ const match = LENGTH.exec(length.trim());
527
+ if (!match) return null;
528
+ const amount = Number(match[1]);
529
+ return match[2] === "vw" ? amount * viewportWidth / 100 : amount;
530
+ }
531
+ function matchesMedia(condition) {
532
+ if (typeof window.matchMedia !== "function") return false;
533
+ return window.matchMedia(condition).matches;
534
+ }
535
+ function renderedWidthFromSizes(sizes) {
536
+ if (!sizes || typeof window === "undefined") return null;
537
+ const entries = sizes.split(",").map((entry) => entry.trim());
538
+ for (const entry of entries) {
539
+ const lengthStart = entry.lastIndexOf(" ") + 1;
540
+ const condition = entry.slice(0, lengthStart).trim();
541
+ if (condition && !matchesMedia(condition)) continue;
542
+ return lengthInPixels(entry.slice(lengthStart), window.innerWidth);
543
+ }
544
+ return null;
545
+ }
546
+
547
+ // src/image.tsx
548
+ import { Fragment, jsx as jsx4 } from "react/jsx-runtime";
549
+ function Image({
550
+ media,
551
+ width,
552
+ height,
553
+ fit,
554
+ format,
555
+ quality,
556
+ widths,
557
+ sizes,
558
+ fallback = null,
559
+ externalFallback,
560
+ render,
561
+ alt,
562
+ onLoadError,
563
+ ...imgProps
564
+ }) {
565
+ const { url } = useDevultur();
566
+ const isPrivate = media?.visibility === "private";
567
+ const responsiveWidths = widths?.length ? normalizeWidths(widths) : null;
568
+ const variantOf = (variantWidth) => ({
569
+ width: variantWidth,
570
+ height: scaledHeight(variantWidth, width, height),
571
+ fit,
572
+ format,
573
+ quality
574
+ });
575
+ let transforms = { width, height, fit, format, quality };
576
+ if (responsiveWidths && isPrivate) {
577
+ const pickedWidth = pickWidth(responsiveWidths, renderedWidthFromSizes(sizes) ?? width ?? null);
578
+ if (pickedWidth) transforms = variantOf(pickedWidth);
579
+ }
580
+ const { blobSrc, isFailed, handleError } = usePrivateImage(media, transforms, onLoadError);
581
+ let src;
582
+ let responsiveProps = {};
583
+ if (isFailed || !media) {
584
+ src = externalFallback ?? null;
585
+ } else if (isPrivate) {
586
+ src = blobSrc;
587
+ } else if (responsiveWidths) {
588
+ const largestWidth = responsiveWidths.at(-1);
589
+ src = url(media, variantOf(largestWidth));
590
+ const srcSet = buildSrcSet(responsiveWidths, (variantWidth) => url(media, variantOf(variantWidth)));
591
+ responsiveProps = { srcSet, sizes };
592
+ } else {
593
+ src = url(media, transforms);
594
+ }
595
+ if (!src) return /* @__PURE__ */ jsx4(Fragment, { children: fallback });
596
+ if (render) {
597
+ return React5.cloneElement(render, {
598
+ style: { ...render.props.style, backgroundImage: `url("${src}")` }
599
+ });
600
+ }
601
+ const lazyProps = responsiveWidths ? { loading: "lazy", decoding: "async" } : {};
602
+ return /* @__PURE__ */ jsx4(
603
+ "img",
604
+ {
605
+ src,
606
+ alt: alt ?? media?.alt,
607
+ width: width ?? media?.width,
608
+ height: height ?? media?.height,
609
+ onError: handleError,
610
+ ...lazyProps,
611
+ ...responsiveProps,
612
+ ...imgProps
613
+ }
614
+ );
615
+ }
616
+ function usePrivateImage(media, transforms, onLoadError) {
617
+ const { token, tokenStatus, tokenError, baseUrl, reissueToken, sessionSecret } = useDevultur();
618
+ const [isFailed, setIsFailed] = React5.useState(false);
619
+ const [blobSrc, setBlobSrc] = React5.useState(null);
620
+ const sessionRef = React5.useRef({ token, reissueToken, onLoadError });
621
+ React5.useEffect(() => {
622
+ sessionRef.current = { token, reissueToken, onLoadError };
623
+ });
624
+ const isPrivate = media?.visibility === "private";
625
+ const hasToken = token !== null;
626
+ const isTokenUnavailable = isPrivate && tokenStatus === "error";
627
+ const { width, height, fit, format, quality } = transforms;
628
+ React5.useEffect(() => {
629
+ setIsFailed(false);
630
+ setBlobSrc(null);
631
+ }, [media]);
632
+ React5.useEffect(() => {
633
+ if (!isTokenUnavailable || !media) return;
634
+ sessionRef.current.onLoadError?.(tokenUnavailableError(tokenError));
635
+ }, [isTokenUnavailable, media, tokenError]);
636
+ React5.useEffect(() => {
637
+ if (!isPrivate || isFailed || !media || !hasToken) return;
638
+ let cancelled = false;
639
+ let objectUrl = null;
640
+ if (!baseUrl) throw new Error("Devultur `url()` requires `baseUrl` on <DevulturProvider>");
641
+ const fetchTransforms = { width, height, fit, format, quality };
642
+ const fail = (error) => {
643
+ if (cancelled) return;
644
+ setIsFailed(true);
645
+ sessionRef.current.onLoadError?.(error);
646
+ };
647
+ (async () => {
648
+ try {
649
+ const response = await fetchMedia({
650
+ buildUrl: (viewerToken) => mediaUrl2(baseUrl, media, { ...fetchTransforms, token: viewerToken }),
651
+ token: sessionRef.current.token,
652
+ sessionSecret,
653
+ reissueToken: sessionRef.current.reissueToken
654
+ });
655
+ if (!response.ok) {
656
+ const status = response.status;
657
+ fail(new MediaFetchError("http_error", `Media fetch failed: ${status}`, status));
658
+ return;
659
+ }
660
+ objectUrl = URL.createObjectURL(await response.blob());
661
+ if (cancelled) {
662
+ URL.revokeObjectURL(objectUrl);
663
+ return;
664
+ }
665
+ setBlobSrc(objectUrl);
666
+ } catch (error) {
667
+ fail(error instanceof Error ? error : new Error(String(error)));
668
+ }
669
+ })();
670
+ return () => {
671
+ cancelled = true;
672
+ if (objectUrl) URL.revokeObjectURL(objectUrl);
673
+ };
674
+ }, [isPrivate, isFailed, media, hasToken, baseUrl, sessionSecret, width, height, fit, format, quality]);
675
+ const handleError = React5.useCallback(() => setIsFailed(true), []);
676
+ return { blobSrc, isFailed: isFailed || isTokenUnavailable, handleError };
677
+ }
678
+
679
+ // src/media-library.tsx
680
+ import React14 from "react";
681
+
682
+ // src/media-library-item.tsx
683
+ import React11 from "react";
684
+
685
+ // src/pdf-thumbnail.tsx
686
+ import React10 from "react";
687
+
688
+ // src/media-resource.ts
689
+ import React6 from "react";
690
+ import { mediaPath, mediaUrl as mediaUrl3 } from "@devultur/core";
691
+ var IDLE = { value: null, contentType: null, isLoading: false, error: null };
692
+ var LOADING = { value: null, contentType: null, isLoading: true, error: null };
693
+ function useMediaResource(media, options) {
694
+ const devultur = useDevultur();
695
+ const { isEnabled, maxBytes, contentType } = options;
696
+ const isPrivate = media?.visibility === "private";
697
+ const isReady = !isPrivate || devultur.token !== null;
698
+ const isTokenUnavailable = isPrivate && devultur.tokenStatus === "error";
699
+ const tokenFailure = React6.useMemo(
700
+ () => isTokenUnavailable ? tokenUnavailableError(devultur.tokenError) : null,
701
+ [isTokenUnavailable, devultur.tokenError]
702
+ );
703
+ const requestKey = media && isEnabled ? [devultur.baseUrl, media.visibility, mediaPath(media), contentType ?? "", maxBytes ?? ""].join("|") : null;
704
+ const [settled, setSettled] = React6.useState(null);
705
+ const inputsRef = React6.useRef({ media, devultur, options });
706
+ React6.useEffect(() => {
707
+ inputsRef.current = { media, devultur, options };
708
+ });
709
+ React6.useEffect(() => {
710
+ if (!requestKey || !isReady) return;
711
+ const { media: target, devultur: context, options: current } = inputsRef.current;
712
+ if (!target || !context.baseUrl) return;
713
+ const baseUrl = context.baseUrl;
714
+ const controller = new AbortController();
715
+ let value = null;
716
+ loadMedia({
717
+ buildUrl: (viewerToken) => mediaUrl3(baseUrl, target, { token: viewerToken }),
718
+ token: context.token,
719
+ sessionSecret: context.sessionSecret,
720
+ reissueToken: context.reissueToken,
721
+ isPrivate: target.visibility === "private",
722
+ signal: controller.signal,
723
+ maxBytes: current.maxBytes,
724
+ contentType: current.contentType
725
+ }).then((loaded) => {
726
+ if (controller.signal.aborted) return;
727
+ value = current.decode(loaded.bytes, loaded.contentType);
728
+ setSettled({ requestKey, value, contentType: loaded.contentType, isLoading: false, error: null });
729
+ }).catch((error) => {
730
+ if (controller.signal.aborted) return;
731
+ const failure = error instanceof Error ? error : new Error(String(error));
732
+ setSettled({ requestKey, value: null, contentType: null, isLoading: false, error: failure });
733
+ });
734
+ return () => {
735
+ controller.abort();
736
+ if (value !== null) current.release?.(value);
737
+ };
738
+ }, [requestKey, isReady]);
739
+ if (!requestKey) return IDLE;
740
+ if (!devultur.baseUrl) throw new Error("Devultur media previews require `baseUrl` on <DevulturProvider>");
741
+ if (tokenFailure) return { value: null, contentType: null, isLoading: false, error: tokenFailure };
742
+ if (settled?.requestKey !== requestKey) return LOADING;
743
+ return { value: settled.value, contentType: settled.contentType, isLoading: false, error: settled.error };
744
+ }
745
+
746
+ // src/pdf-context.tsx
747
+ import React7 from "react";
748
+
749
+ // src/preview-labels.ts
750
+ var ERROR_TEXT = {
751
+ http_error: "The file couldn't be loaded.",
752
+ too_large: "The file is too large to preview.",
753
+ unsupported_type: "This file type can't be previewed.",
754
+ type_mismatch: "The file's content doesn't match its type.",
755
+ token_unavailable: "Access to this file couldn't be granted. Try again.",
756
+ session_mismatch: "Your session changed. Reload the page."
757
+ };
758
+ var defaultPreviewLabels = {
759
+ document: (title, pageCount) => `${title}, ${pageCount === 1 ? "1 page" : `${pageCount} pages`}`,
760
+ page: (page, pageCount) => `Page ${page} of ${pageCount}`,
761
+ loading: "Loading\u2026",
762
+ pageCap: (shown, total) => `Showing the first ${shown} of ${total} pages.`,
763
+ error: (code) => ERROR_TEXT[code],
764
+ thumbnail: (title) => title,
765
+ untitledPdf: "PDF",
766
+ untitledMedia: "Preview",
767
+ download: "Download"
768
+ };
470
769
  function mergePreviewLabels(...sources) {
471
770
  const merged = { ...defaultPreviewLabels };
472
771
  for (const source of sources) {
@@ -478,16 +777,16 @@ function mergePreviewLabels(...sources) {
478
777
  }
479
778
 
480
779
  // src/pdf-context.tsx
481
- import { jsx as jsx2 } from "react/jsx-runtime";
482
- var PdfPreviewContext = React4.createContext({});
780
+ import { jsx as jsx5 } from "react/jsx-runtime";
781
+ var PdfPreviewContext = React7.createContext({});
483
782
  function PdfPreviewProvider({
484
783
  pdfjs,
485
784
  workerSrc,
486
785
  labels,
487
786
  children
488
787
  }) {
489
- const config = React4.useMemo(() => ({ pdfjs, workerSrc, labels }), [pdfjs, workerSrc, labels]);
490
- return /* @__PURE__ */ jsx2(PdfPreviewContext.Provider, { value: config, children });
788
+ const config = React7.useMemo(() => ({ pdfjs, workerSrc, labels }), [pdfjs, workerSrc, labels]);
789
+ return /* @__PURE__ */ jsx5(PdfPreviewContext.Provider, { value: config, children });
491
790
  }
492
791
  var loadedModules = /* @__PURE__ */ new WeakMap();
493
792
  function loadOnce(loader) {
@@ -498,9 +797,9 @@ function loadOnce(loader) {
498
797
  return loading;
499
798
  }
500
799
  function usePdfjs(workerSrcOverride) {
501
- const { pdfjs: loader, workerSrc } = React4.useContext(PdfPreviewContext);
502
- const [loaded, setLoaded] = React4.useState(null);
503
- React4.useEffect(() => {
800
+ const { pdfjs: loader, workerSrc } = React7.useContext(PdfPreviewContext);
801
+ const [loaded, setLoaded] = React7.useState(null);
802
+ React7.useEffect(() => {
504
803
  if (!loader) return;
505
804
  let isCurrent = true;
506
805
  loadOnce(loader).then((pdfjs) => {
@@ -515,61 +814,27 @@ function usePdfjs(workerSrcOverride) {
515
814
  if (!loaded.pdfjs) return { status: "unavailable" };
516
815
  return { status: "ready", pdfjs: loaded.pdfjs, workerSrc: workerSrcOverride ?? workerSrc };
517
816
  }
817
+ function usePdfPageCounter() {
818
+ const { pdfjs: loader, workerSrc } = React7.useContext(PdfPreviewContext);
819
+ return React7.useMemo(() => {
820
+ if (!loader) return null;
821
+ return async (file) => {
822
+ const pdfjs = await loadOnce(loader);
823
+ if (!pdfjs) return null;
824
+ return countPages(pdfjs, file, workerSrc);
825
+ };
826
+ }, [loader, workerSrc]);
827
+ }
518
828
  function usePreviewLabels(labels) {
519
- const providerLabels = React4.useContext(PdfPreviewContext).labels;
829
+ const providerLabels = React7.useContext(PdfPreviewContext).labels;
520
830
  return mergePreviewLabels(providerLabels, labels);
521
831
  }
522
832
 
523
- // src/pdf-thumbnail.tsx
524
- import React7 from "react";
525
-
526
- // src/pdf-hooks.ts
527
- import React5 from "react";
528
-
529
- // src/pdf-render.ts
530
- var MOBILE_MAX_CANVAS_PIXELS = 5242880;
531
- var DESKTOP_MAX_CANVAS_PIXELS = 2 ** 25;
532
- function openPdf(pdfjs, bytes, workerSrc) {
533
- if (workerSrc) pdfjs.GlobalWorkerOptions.workerSrc = workerSrc;
534
- const source = { data: bytes.slice(), enableScripting: false, isEvalSupported: false };
535
- return pdfjs.getDocument(source);
536
- }
537
- function isMobileDevice() {
538
- if (typeof navigator === "undefined") return false;
539
- return /Android|iPhone|iPad|iPod/.test(navigator.userAgent) || navigator.maxTouchPoints > 1;
540
- }
541
- function maxCanvasPixels() {
542
- return isMobileDevice() ? MOBILE_MAX_CANVAS_PIXELS : DESKTOP_MAX_CANVAS_PIXELS;
543
- }
544
- function outputScale(cssWidth, cssHeight, maxPixels) {
545
- const devicePixelRatio = typeof window !== "undefined" && window.devicePixelRatio || 1;
546
- const cssPixels = cssWidth * cssHeight;
547
- if (cssPixels <= 0) return devicePixelRatio;
548
- if (cssPixels * devicePixelRatio ** 2 <= maxPixels) return devicePixelRatio;
549
- return Math.sqrt(maxPixels / cssPixels);
550
- }
551
- function renderPage(page, canvas, cssWidth) {
552
- const naturalViewport = page.getViewport({ scale: 1 });
553
- const cssScale = cssWidth > 0 ? cssWidth / naturalViewport.width : 1;
554
- const cssViewport = page.getViewport({ scale: cssScale });
555
- const scale = outputScale(cssViewport.width, cssViewport.height, maxCanvasPixels());
556
- const viewport = page.getViewport({ scale: cssScale * scale });
557
- canvas.width = Math.floor(viewport.width);
558
- canvas.height = Math.floor(viewport.height);
559
- canvas.style.width = `${Math.floor(cssViewport.width)}px`;
560
- canvas.style.height = `${Math.floor(cssViewport.height)}px`;
561
- return page.render({ canvas, viewport });
562
- }
563
- async function pageText(page) {
564
- const { items } = await page.getTextContent();
565
- return items.map((item) => "str" in item && typeof item.str === "string" ? item.str : "").join(" ").replace(/\s+/g, " ").trim();
566
- }
567
- var isRenderCancelled = (error) => error instanceof Error && error.name === "RenderingCancelledException";
568
-
569
833
  // src/pdf-hooks.ts
834
+ import React8 from "react";
570
835
  function useIsVisible(ref, rootMargin = "200px") {
571
- const [isVisible, setIsVisible] = React5.useState(false);
572
- React5.useEffect(() => {
836
+ const [isVisible, setIsVisible] = React8.useState(false);
837
+ React8.useEffect(() => {
573
838
  const element = ref.current;
574
839
  if (isVisible || !element) return;
575
840
  if (typeof IntersectionObserver === "undefined") {
@@ -590,12 +855,12 @@ function useIsVisible(ref, rootMargin = "200px") {
590
855
  return isVisible;
591
856
  }
592
857
  function usePdfDocument({ pdfjs, bytes, workerSrc }) {
593
- const [state, setState] = React5.useState({
858
+ const [state, setState] = React8.useState({
594
859
  bytes: null,
595
860
  document: null,
596
861
  error: null
597
862
  });
598
- React5.useEffect(() => {
863
+ React8.useEffect(() => {
599
864
  let isCurrent = true;
600
865
  const loadingTask = openPdf(pdfjs, bytes, workerSrc);
601
866
  loadingTask.promise.then((document2) => {
@@ -614,8 +879,8 @@ function usePdfDocument({ pdfjs, bytes, workerSrc }) {
614
879
  }
615
880
  function usePageRender(options) {
616
881
  const { document: document2, pageNumber, canvasRef, width, isEnabled, shouldExtractText = false } = options;
617
- const [state, setState] = React5.useState({ isRendered: false, text: "" });
618
- React5.useEffect(() => {
882
+ const [state, setState] = React8.useState({ isRendered: false, text: "" });
883
+ React8.useEffect(() => {
619
884
  const canvas = canvasRef.current;
620
885
  if (!document2 || !isEnabled || !canvas) return;
621
886
  let isCurrent = true;
@@ -645,8 +910,8 @@ function usePageRender(options) {
645
910
  }
646
911
 
647
912
  // src/pdf-preview.tsx
648
- import React6 from "react";
649
- import { Fragment, jsx as jsx3, jsxs as jsxs2 } from "react/jsx-runtime";
913
+ import React9 from "react";
914
+ import { Fragment as Fragment2, jsx as jsx6, jsxs as jsxs3 } from "react/jsx-runtime";
650
915
  var VISUALLY_HIDDEN = {
651
916
  position: "absolute",
652
917
  width: 1,
@@ -669,11 +934,11 @@ function PdfPreview(props) {
669
934
  contentType: expectedType,
670
935
  decode: keepBytes
671
936
  });
672
- if (error) return /* @__PURE__ */ jsx3(Fragment, { children: renderFallback(error) });
673
- if (!bytes || pdfjsState.status === "loading") return /* @__PURE__ */ jsx3(Fragment, { children: loading });
937
+ if (error) return /* @__PURE__ */ jsx6(Fragment2, { children: renderFallback(error) });
938
+ if (!bytes || pdfjsState.status === "loading") return /* @__PURE__ */ jsx6(Fragment2, { children: loading });
674
939
  const label = title ?? media.filename ?? labels.untitledPdf;
675
- if (pdfjsState.status === "unavailable") return /* @__PURE__ */ jsx3(PdfFrame, { bytes, label, className });
676
- return /* @__PURE__ */ jsx3(
940
+ if (pdfjsState.status === "unavailable") return /* @__PURE__ */ jsx6(PdfFrame, { bytes, label, className });
941
+ return /* @__PURE__ */ jsx6(
677
942
  PdfCanvasDocument,
678
943
  {
679
944
  pdfjs: pdfjsState.pdfjs,
@@ -691,17 +956,17 @@ function PdfPreview(props) {
691
956
  function PdfCanvasDocument(props) {
692
957
  const { pdfjs, workerSrc, bytes, label, className, renderFallback, loading, labels, maxPages } = props;
693
958
  const { document: document2, error } = usePdfDocument({ pdfjs, bytes, workerSrc });
694
- const containerRef = React6.useRef(null);
695
- const [width, setWidth] = React6.useState(null);
696
- React6.useLayoutEffect(() => {
959
+ const containerRef = React9.useRef(null);
960
+ const [width, setWidth] = React9.useState(null);
961
+ React9.useLayoutEffect(() => {
697
962
  if (document2) setWidth(containerRef.current?.clientWidth ?? 0);
698
963
  }, [document2]);
699
- if (error) return /* @__PURE__ */ jsx3(Fragment, { children: renderFallback(error) });
700
- if (!document2) return /* @__PURE__ */ jsx3(Fragment, { children: loading });
964
+ if (error) return /* @__PURE__ */ jsx6(Fragment2, { children: renderFallback(error) });
965
+ if (!document2) return /* @__PURE__ */ jsx6(Fragment2, { children: loading });
701
966
  const shownPageCount = Math.min(document2.numPages, Math.max(1, maxPages));
702
967
  const isTruncated = shownPageCount < document2.numPages;
703
968
  const pageNumbers = Array.from({ length: shownPageCount }, (_, index) => index + 1);
704
- return /* @__PURE__ */ jsxs2(
969
+ return /* @__PURE__ */ jsxs3(
705
970
  "div",
706
971
  {
707
972
  ref: containerRef,
@@ -709,7 +974,7 @@ function PdfCanvasDocument(props) {
709
974
  "aria-label": labels.document(label, document2.numPages),
710
975
  className,
711
976
  children: [
712
- width !== null && pageNumbers.map((pageNumber) => /* @__PURE__ */ jsx3(
977
+ width !== null && pageNumbers.map((pageNumber) => /* @__PURE__ */ jsx6(
713
978
  PdfPageView,
714
979
  {
715
980
  document: document2,
@@ -719,7 +984,7 @@ function PdfCanvasDocument(props) {
719
984
  },
720
985
  pageNumber
721
986
  )),
722
- isTruncated && /* @__PURE__ */ jsxs2("p", { children: [
987
+ isTruncated && /* @__PURE__ */ jsxs3("p", { children: [
723
988
  labels.pageCap(shownPageCount, document2.numPages),
724
989
  " ",
725
990
  renderFallback(null)
@@ -729,8 +994,8 @@ function PdfCanvasDocument(props) {
729
994
  );
730
995
  }
731
996
  function PdfPageView({ document: document2, pageNumber, width, label }) {
732
- const pageRef = React6.useRef(null);
733
- const canvasRef = React6.useRef(null);
997
+ const pageRef = React9.useRef(null);
998
+ const canvasRef = React9.useRef(null);
734
999
  const isVisible = useIsVisible(pageRef);
735
1000
  const { isRendered, text } = usePageRender({
736
1001
  document: document2,
@@ -741,46 +1006,46 @@ function PdfPageView({ document: document2, pageNumber, width, label }) {
741
1006
  shouldExtractText: true
742
1007
  });
743
1008
  const placeholderStyle = isRendered ? void 0 : { aspectRatio: "1 / 1.4142", maxWidth: "100%" };
744
- return /* @__PURE__ */ jsxs2("section", { ref: pageRef, "aria-label": label, style: placeholderStyle, children: [
745
- /* @__PURE__ */ jsx3("canvas", { ref: canvasRef, style: { display: "block", maxWidth: "100%" } }),
746
- text && /* @__PURE__ */ jsx3("p", { style: VISUALLY_HIDDEN, children: text })
1009
+ return /* @__PURE__ */ jsxs3("section", { ref: pageRef, "aria-label": label, style: placeholderStyle, children: [
1010
+ /* @__PURE__ */ jsx6("canvas", { ref: canvasRef, style: { display: "block", maxWidth: "100%" } }),
1011
+ text && /* @__PURE__ */ jsx6("p", { style: VISUALLY_HIDDEN, children: text })
747
1012
  ] });
748
1013
  }
749
1014
  function PdfFrame({ bytes, label, className }) {
750
- const [objectUrl, setObjectUrl] = React6.useState(null);
751
- React6.useEffect(() => {
1015
+ const [objectUrl, setObjectUrl] = React9.useState(null);
1016
+ React9.useEffect(() => {
752
1017
  const url = URL.createObjectURL(new Blob([bytes], { type: "application/pdf" }));
753
1018
  setObjectUrl(url);
754
1019
  return () => URL.revokeObjectURL(url);
755
1020
  }, [bytes]);
756
1021
  if (!objectUrl) return null;
757
- return /* @__PURE__ */ jsx3("iframe", { src: objectUrl, title: label, referrerPolicy: "no-referrer", className });
1022
+ return /* @__PURE__ */ jsx6("iframe", { src: objectUrl, title: label, referrerPolicy: "no-referrer", className });
758
1023
  }
759
1024
 
760
1025
  // src/pdf-thumbnail.tsx
761
- import { Fragment as Fragment2, jsx as jsx4, jsxs as jsxs3 } from "react/jsx-runtime";
1026
+ import { Fragment as Fragment3, jsx as jsx7, jsxs as jsxs4 } from "react/jsx-runtime";
762
1027
  var DEFAULT_THUMBNAIL_WIDTH = 96;
763
1028
  var DEFAULT_THUMBNAIL_MAX_BYTES = 50 * 1024 * 1024;
764
1029
  function PdfThumbnail(props) {
765
1030
  const { media, contentType, width = DEFAULT_THUMBNAIL_WIDTH, fallback = null, loading = null } = props;
766
- const containerRef = React7.useRef(null);
1031
+ const containerRef = React10.useRef(null);
767
1032
  const isVisible = useIsVisible(containerRef);
768
1033
  const pdfjsState = usePdfjs(props.pdfWorkerSrc);
769
1034
  const labels = usePreviewLabels(props.labels);
770
1035
  const expectedType = media ? expectedContentType(media, contentType) : null;
771
- const isPdf = previewKindOf(expectedType) === "pdf";
1036
+ const isPdf2 = previewKindOf(expectedType) === "pdf";
772
1037
  const { value: bytes, error } = useMediaResource(media, {
773
- isEnabled: isVisible && isPdf && pdfjsState.status === "ready",
1038
+ isEnabled: isVisible && isPdf2 && pdfjsState.status === "ready",
774
1039
  maxBytes: props.maxBytes ?? DEFAULT_THUMBNAIL_MAX_BYTES,
775
1040
  contentType: expectedType ?? void 0,
776
1041
  decode: keepBytes
777
1042
  });
778
1043
  if (!media) return null;
779
- const isUnavailable = !isPdf || pdfjsState.status === "unavailable" || error !== null;
1044
+ const isUnavailable = !isPdf2 || pdfjsState.status === "unavailable" || error !== null;
780
1045
  const content = () => {
781
1046
  if (isUnavailable) return fallback;
782
1047
  if (!bytes || pdfjsState.status !== "ready") return loading;
783
- return /* @__PURE__ */ jsx4(
1048
+ return /* @__PURE__ */ jsx7(
784
1049
  ThumbnailCanvas,
785
1050
  {
786
1051
  pdfjs: pdfjsState.pdfjs,
@@ -793,54 +1058,612 @@ function PdfThumbnail(props) {
793
1058
  }
794
1059
  );
795
1060
  };
796
- return /* @__PURE__ */ jsx4("div", { ref: containerRef, className: props.className, style: { width }, children: content() });
1061
+ return /* @__PURE__ */ jsx7("div", { ref: containerRef, className: props.className, style: { width }, children: content() });
797
1062
  }
798
1063
  function ThumbnailCanvas({ pdfjs, workerSrc, bytes, width, label, fallback, loading }) {
799
- const canvasRef = React7.useRef(null);
1064
+ const canvasRef = React10.useRef(null);
800
1065
  const { document: document2, error } = usePdfDocument({ pdfjs, bytes, workerSrc });
801
1066
  const { isRendered } = usePageRender({ document: document2, pageNumber: 1, canvasRef, width, isEnabled: true });
802
- if (error) return /* @__PURE__ */ jsx4(Fragment2, { children: fallback });
803
- return /* @__PURE__ */ jsxs3(Fragment2, { children: [
1067
+ if (error) return /* @__PURE__ */ jsx7(Fragment3, { children: fallback });
1068
+ return /* @__PURE__ */ jsxs4(Fragment3, { children: [
804
1069
  !isRendered && loading,
805
- /* @__PURE__ */ jsx4("canvas", { ref: canvasRef, role: "img", "aria-label": label, style: { display: "block" } })
1070
+ /* @__PURE__ */ jsx7("canvas", { ref: canvasRef, role: "img", "aria-label": label, style: { display: "block" } })
1071
+ ] });
1072
+ }
1073
+
1074
+ // src/media-library-item.tsx
1075
+ import { Fragment as Fragment4, jsx as jsx8, jsxs as jsxs5 } from "react/jsx-runtime";
1076
+ var THUMBNAIL_WIDTH = 160;
1077
+ var THUMBNAIL_WIDTHS = [THUMBNAIL_WIDTH, THUMBNAIL_WIDTH * 2];
1078
+ function partClass(part, classNames) {
1079
+ const own = part === "root" ? "devultur-media-library" : `devultur-media-library__${part}`;
1080
+ const extra = classNames[part];
1081
+ return extra ? `${own} ${extra}` : own;
1082
+ }
1083
+ var THUMBNAIL_STYLE = {
1084
+ display: "block",
1085
+ width: "100%",
1086
+ height: "var(--devultur-library-thumbnail-height, 120px)",
1087
+ objectFit: "cover"
1088
+ };
1089
+ function kindOf(record) {
1090
+ if (record.type !== "file") return record.type;
1091
+ return previewKindOf(record.contentType) ?? "file";
1092
+ }
1093
+ function Thumbnail({ record, media, pdfThumbnails }) {
1094
+ const kind = kindOf(record);
1095
+ const icon = /* @__PURE__ */ jsx8(AttachmentIcon, { kind });
1096
+ if (!media) return icon;
1097
+ if (record.type === "image") {
1098
+ return /* @__PURE__ */ jsx8(
1099
+ Image,
1100
+ {
1101
+ media,
1102
+ width: THUMBNAIL_WIDTH,
1103
+ widths: THUMBNAIL_WIDTHS,
1104
+ sizes: `${THUMBNAIL_WIDTH}px`,
1105
+ format: "auto",
1106
+ alt: "",
1107
+ style: THUMBNAIL_STYLE,
1108
+ fallback: icon
1109
+ }
1110
+ );
1111
+ }
1112
+ if (kind !== "pdf" || !pdfThumbnails) return icon;
1113
+ return /* @__PURE__ */ jsx8(PdfThumbnail, { media, contentType: record.contentType, width: THUMBNAIL_WIDTH, fallback: icon });
1114
+ }
1115
+ function DefaultContent({ record, media, pdfThumbnails, labels, classNames }) {
1116
+ const name = record.title ?? record.filename;
1117
+ return /* @__PURE__ */ jsxs5(Fragment4, { children: [
1118
+ /* @__PURE__ */ jsx8("span", { className: partClass("thumbnail", classNames), children: /* @__PURE__ */ jsx8(Thumbnail, { record, media, pdfThumbnails }) }),
1119
+ /* @__PURE__ */ jsx8("span", { className: partClass("name", classNames), children: name }),
1120
+ record.sizeBytes > 0 && /* @__PURE__ */ jsx8("span", { className: partClass("size", classNames), children: labels.size(record.sizeBytes) })
806
1121
  ] });
807
1122
  }
1123
+ var ITEM_STYLE = {
1124
+ display: "flex",
1125
+ flexDirection: "column",
1126
+ gap: "var(--devultur-library-item-gap, 0.25rem)",
1127
+ cursor: "pointer",
1128
+ overflowWrap: "anywhere"
1129
+ };
1130
+ function MediaLibraryItem(props) {
1131
+ const { ref, record, isSelected, isActive, tabIndex, classNames, renderItem, onChoose, onFocus } = props;
1132
+ const media = React11.useMemo(() => mediaFromRecord(record), [record]);
1133
+ const handleKeyDown = (event) => {
1134
+ if (event.key !== "Enter" && event.key !== " ") return;
1135
+ event.preventDefault();
1136
+ onChoose();
1137
+ };
1138
+ return /* @__PURE__ */ jsx8(
1139
+ "div",
1140
+ {
1141
+ ref,
1142
+ role: "option",
1143
+ "aria-selected": isSelected,
1144
+ tabIndex,
1145
+ "data-selected": isSelected || void 0,
1146
+ "data-type": record.type,
1147
+ className: partClass("item", classNames),
1148
+ style: {
1149
+ ...ITEM_STYLE,
1150
+ boxShadow: isSelected ? "0 0 0 2px var(--devultur-library-accent, currentColor)" : void 0
1151
+ },
1152
+ onClick: onChoose,
1153
+ onKeyDown: handleKeyDown,
1154
+ onFocus,
1155
+ children: renderItem ? renderItem(record, { media, isSelected, isActive }) : /* @__PURE__ */ jsx8(
1156
+ DefaultContent,
1157
+ {
1158
+ record,
1159
+ media,
1160
+ pdfThumbnails: props.pdfThumbnails,
1161
+ labels: props.labels,
1162
+ classNames
1163
+ }
1164
+ )
1165
+ }
1166
+ );
1167
+ }
1168
+
1169
+ // src/media-library-labels.ts
1170
+ var defaultMediaLibraryLabels = {
1171
+ search: "Search media",
1172
+ searchPlaceholder: "Search by title or filename",
1173
+ typeFilter: "Type",
1174
+ allTypes: "All types",
1175
+ types: { image: "Images", video: "Videos", file: "Files" },
1176
+ results: "Media",
1177
+ loading: "Loading media\u2026",
1178
+ empty: "No media yet.",
1179
+ noResults: (search) => `Nothing matches \u201C${search}\u201D.`,
1180
+ loadMore: "Load more",
1181
+ loadingMore: "Loading\u2026",
1182
+ error: (error) => describeDevulturError(error) ?? "The media couldn't be loaded.",
1183
+ retry: "Try again",
1184
+ size: (bytes) => formatFileSize(bytes, "en")
1185
+ };
1186
+ function mergeMediaLibraryLabels(labels) {
1187
+ const merged = { ...defaultMediaLibraryLabels };
1188
+ for (const [key, value] of Object.entries(labels ?? {})) {
1189
+ if (value !== void 0) merged[key] = value;
1190
+ }
1191
+ return merged;
1192
+ }
1193
+
1194
+ // src/use-media-library.ts
1195
+ import React12 from "react";
1196
+ import { usePaginatedQuery } from "convex/react";
1197
+ var SEARCH_DEBOUNCE_MS = 250;
1198
+ var DEFAULT_PAGE_SIZE = 24;
1199
+ function useDebouncedSearch(value, delayMs) {
1200
+ const [debounced, setDebounced] = React12.useState(value);
1201
+ React12.useEffect(() => {
1202
+ if (value === "") {
1203
+ setDebounced("");
1204
+ return;
1205
+ }
1206
+ const timer = setTimeout(() => setDebounced(value), delayMs);
1207
+ return () => clearTimeout(timer);
1208
+ }, [value, delayMs]);
1209
+ return value === "" ? "" : debounced;
1210
+ }
1211
+ function useMediaLibrary(options = {}) {
1212
+ const api = useDevulturApi();
1213
+ const pageSize = options.pageSize ?? DEFAULT_PAGE_SIZE;
1214
+ const requestedSearch = (options.search ?? "").trim();
1215
+ const search = useDebouncedSearch(requestedSearch, SEARCH_DEBOUNCE_MS);
1216
+ const isSearching = search !== "";
1217
+ const typeArg = options.type ? { type: options.type } : {};
1218
+ const reference = isSearching ? api.searchMedia : api.listMediaPage;
1219
+ if (!reference) {
1220
+ const missing = isSearching ? "searchMedia" : "listMediaPage";
1221
+ throw new Error(`useMediaLibrary requires ${missing} in DevulturProvider api`);
1222
+ }
1223
+ const args = isSearching ? { search, ...typeArg } : typeArg;
1224
+ const result = usePaginatedQuery(reference, args, { initialNumItems: pageSize });
1225
+ const resultLoadMore = result.loadMore;
1226
+ const loadMore = React12.useCallback(
1227
+ (numItems) => resultLoadMore(numItems ?? pageSize),
1228
+ [resultLoadMore, pageSize]
1229
+ );
1230
+ return {
1231
+ items: result.results,
1232
+ status: result.status,
1233
+ loadMore,
1234
+ isLoading: result.isLoading,
1235
+ search,
1236
+ isSearchPending: requestedSearch !== search
1237
+ };
1238
+ }
1239
+
1240
+ // src/use-roving-focus.ts
1241
+ import React13 from "react";
1242
+ function nextIndex(key, current, count, columns) {
1243
+ const last = count - 1;
1244
+ const clamp = (index) => Math.min(Math.max(index, 0), last);
1245
+ switch (key) {
1246
+ case "ArrowRight":
1247
+ return clamp(current + 1);
1248
+ case "ArrowLeft":
1249
+ return clamp(current - 1);
1250
+ case "ArrowDown":
1251
+ return current + columns > last ? current : current + columns;
1252
+ case "ArrowUp":
1253
+ return current - columns < 0 ? current : current - columns;
1254
+ case "Home":
1255
+ return 0;
1256
+ case "End":
1257
+ return last;
1258
+ default:
1259
+ return null;
1260
+ }
1261
+ }
1262
+ function columnCount(items) {
1263
+ const firstTop = items[0]?.getBoundingClientRect();
1264
+ if (!firstTop || firstTop.height === 0) return 1;
1265
+ const inFirstRow = items.filter((item) => item?.getBoundingClientRect().top === firstTop.top);
1266
+ return Math.max(inFirstRow.length, 1);
1267
+ }
1268
+ function useRovingFocus(count) {
1269
+ const [activeIndex, setActiveIndex] = React13.useState(0);
1270
+ const itemsRef = React13.useRef([]);
1271
+ const currentIndex = Math.min(activeIndex, Math.max(count - 1, 0));
1272
+ const itemRef = React13.useCallback(
1273
+ (index) => (element) => {
1274
+ itemsRef.current[index] = element;
1275
+ },
1276
+ []
1277
+ );
1278
+ const handleKeyDown = React13.useCallback(
1279
+ (event) => {
1280
+ if (count === 0) return;
1281
+ const items = itemsRef.current.slice(0, count);
1282
+ const target = nextIndex(event.key, currentIndex, count, columnCount(items));
1283
+ if (target === null) return;
1284
+ event.preventDefault();
1285
+ setActiveIndex(target);
1286
+ items[target]?.focus();
1287
+ },
1288
+ [count, currentIndex]
1289
+ );
1290
+ const tabIndex = (index) => index === currentIndex ? 0 : -1;
1291
+ return { activeIndex: currentIndex, setActiveIndex, itemRef, handleKeyDown, tabIndex };
1292
+ }
1293
+
1294
+ // src/media-library.tsx
1295
+ import { Fragment as Fragment5, jsx as jsx9, jsxs as jsxs6 } from "react/jsx-runtime";
1296
+ var MEDIA_TYPES = ["image", "video", "file"];
1297
+ var ROOT_STYLE = {
1298
+ display: "flex",
1299
+ flexDirection: "column",
1300
+ gap: "var(--devultur-library-gap, 0.75rem)"
1301
+ };
1302
+ var LIST_STYLE = {
1303
+ display: "grid",
1304
+ gridTemplateColumns: "repeat(auto-fill, minmax(var(--devultur-library-item-width, 160px), 1fr))",
1305
+ gap: "var(--devultur-library-gap, 0.75rem)",
1306
+ margin: 0,
1307
+ padding: 0
1308
+ };
1309
+ function MediaLibrary(props) {
1310
+ const labels = React14.useMemo(() => mergeMediaLibraryLabels(props.labels), [props.labels]);
1311
+ const classNames = { ...props.classNames, root: props.className ?? props.classNames?.root };
1312
+ const [search, setSearch] = React14.useState("");
1313
+ const [chosenType, setChosenType] = React14.useState("");
1314
+ const [selectedKey, setSelectedKey] = React14.useState(null);
1315
+ const type = props.type ?? (chosenType === "" ? void 0 : chosenType);
1316
+ const { onSelect } = props;
1317
+ const isSelectable = props.selectable ?? props.selectedKeys !== void 0;
1318
+ const selectedKeys = props.selectedKeys ?? (selectedKey === null ? [] : [selectedKey]);
1319
+ const handleChoose = React14.useCallback(
1320
+ (record) => {
1321
+ if (isSelectable) setSelectedKey(record.key);
1322
+ onSelect(record, mediaFromRecord(record));
1323
+ },
1324
+ [isSelectable, onSelect]
1325
+ );
1326
+ return /* @__PURE__ */ jsxs6("div", { className: partClass("root", classNames), style: ROOT_STYLE, children: [
1327
+ /* @__PURE__ */ jsx9(
1328
+ "input",
1329
+ {
1330
+ type: "search",
1331
+ className: partClass("search", classNames),
1332
+ "aria-label": labels.search,
1333
+ placeholder: labels.searchPlaceholder,
1334
+ value: search,
1335
+ onChange: (event) => setSearch(event.target.value)
1336
+ }
1337
+ ),
1338
+ !props.type && /* @__PURE__ */ jsxs6(
1339
+ "select",
1340
+ {
1341
+ className: partClass("filter", classNames),
1342
+ "aria-label": labels.typeFilter,
1343
+ value: chosenType,
1344
+ onChange: (event) => setChosenType(event.target.value),
1345
+ children: [
1346
+ /* @__PURE__ */ jsx9("option", { value: "", children: labels.allTypes }),
1347
+ MEDIA_TYPES.map((mediaType) => /* @__PURE__ */ jsx9("option", { value: mediaType, children: labels.types[mediaType] }, mediaType))
1348
+ ]
1349
+ }
1350
+ ),
1351
+ /* @__PURE__ */ jsx9(
1352
+ LibraryErrorBoundary,
1353
+ {
1354
+ resetKey: `${type ?? ""}\0${search}`,
1355
+ renderError: (error, retry) => /* @__PURE__ */ jsxs6("div", { role: "alert", className: partClass("status", classNames), children: [
1356
+ /* @__PURE__ */ jsx9("p", { children: labels.error(error) }),
1357
+ /* @__PURE__ */ jsx9("button", { type: "button", className: partClass("more", classNames), onClick: retry, children: labels.retry })
1358
+ ] }),
1359
+ children: /* @__PURE__ */ jsx9(
1360
+ LibraryResults,
1361
+ {
1362
+ ...props,
1363
+ type,
1364
+ search,
1365
+ labels,
1366
+ classNames,
1367
+ isSelectable,
1368
+ selectedKeys,
1369
+ onChoose: handleChoose
1370
+ }
1371
+ )
1372
+ }
1373
+ )
1374
+ ] });
1375
+ }
1376
+ function LibraryResults(props) {
1377
+ const { labels, classNames } = props;
1378
+ const library = useMediaLibrary({ type: props.type, search: props.search, pageSize: props.pageSize });
1379
+ const items = props.filter ? library.items.filter(props.filter) : library.items;
1380
+ const roving = useRovingFocus(items.length);
1381
+ if (library.status === "LoadingFirstPage") {
1382
+ return /* @__PURE__ */ jsx9("p", { role: "status", className: partClass("status", classNames), children: labels.loading });
1383
+ }
1384
+ if (items.length === 0) {
1385
+ return /* @__PURE__ */ jsx9("p", { role: "status", className: partClass("status", classNames), children: library.search ? labels.noResults(library.search) : labels.empty });
1386
+ }
1387
+ const isLoadingMore = library.status === "LoadingMore";
1388
+ const canLoadMore = library.status === "CanLoadMore" || isLoadingMore;
1389
+ return /* @__PURE__ */ jsxs6(Fragment5, { children: [
1390
+ /* @__PURE__ */ jsx9(
1391
+ "div",
1392
+ {
1393
+ role: "listbox",
1394
+ "aria-label": labels.results,
1395
+ "aria-busy": isLoadingMore || void 0,
1396
+ "aria-multiselectable": props.multiple || void 0,
1397
+ className: partClass("list", classNames),
1398
+ style: LIST_STYLE,
1399
+ onKeyDown: roving.handleKeyDown,
1400
+ children: items.map((record, index) => /* @__PURE__ */ jsx9(
1401
+ MediaLibraryItem,
1402
+ {
1403
+ ref: roving.itemRef(index),
1404
+ record,
1405
+ isSelected: props.isSelectable && props.selectedKeys.includes(record.key),
1406
+ isActive: index === roving.activeIndex,
1407
+ tabIndex: roving.tabIndex(index),
1408
+ pdfThumbnails: props.pdfThumbnails ?? false,
1409
+ labels,
1410
+ classNames,
1411
+ renderItem: props.renderItem,
1412
+ onChoose: () => props.onChoose(record),
1413
+ onFocus: () => roving.setActiveIndex(index)
1414
+ },
1415
+ record.key
1416
+ ))
1417
+ }
1418
+ ),
1419
+ canLoadMore && /* @__PURE__ */ jsx9(
1420
+ "button",
1421
+ {
1422
+ type: "button",
1423
+ className: partClass("more", classNames),
1424
+ disabled: isLoadingMore,
1425
+ onClick: () => library.loadMore(),
1426
+ children: isLoadingMore ? labels.loadingMore : labels.loadMore
1427
+ }
1428
+ )
1429
+ ] });
1430
+ }
1431
+ var LibraryErrorBoundary = class extends React14.Component {
1432
+ state = { hasError: false, error: null };
1433
+ static getDerivedStateFromError(error) {
1434
+ return { hasError: true, error };
1435
+ }
1436
+ componentDidUpdate(previous) {
1437
+ if (previous.resetKey === this.props.resetKey || !this.state.hasError) return;
1438
+ this.setState({ hasError: false, error: null });
1439
+ }
1440
+ retry = () => this.setState({ hasError: false, error: null });
1441
+ render() {
1442
+ if (this.state.hasError) return this.props.renderError(this.state.error, this.retry);
1443
+ return this.props.children;
1444
+ }
1445
+ };
1446
+
1447
+ // src/media-object-url.ts
1448
+ var createObjectUrl = (bytes, contentType) => URL.createObjectURL(new Blob([bytes], { type: contentType }));
1449
+ var revokeObjectUrl = (objectUrl) => URL.revokeObjectURL(objectUrl);
1450
+ function useMediaObjectUrl(media, options) {
1451
+ const { url } = useDevultur();
1452
+ const isEnabled = (options?.enabled ?? true) && Boolean(media);
1453
+ const isUnverifiedPublic = media?.visibility === "public" && !options?.contentType;
1454
+ const resource = useMediaResource(media, {
1455
+ isEnabled: isEnabled && !isUnverifiedPublic,
1456
+ maxBytes: options?.maxBytes,
1457
+ contentType: options?.contentType,
1458
+ decode: createObjectUrl,
1459
+ release: revokeObjectUrl
1460
+ });
1461
+ if (media && isEnabled && isUnverifiedPublic) {
1462
+ return { objectUrl: url(media), contentType: null, isLoading: false, error: null };
1463
+ }
1464
+ return {
1465
+ objectUrl: resource.value,
1466
+ contentType: resource.contentType,
1467
+ isLoading: resource.isLoading,
1468
+ error: resource.error
1469
+ };
1470
+ }
1471
+
1472
+ // src/media-picker.tsx
1473
+ import React21 from "react";
1474
+
1475
+ // src/media-picker-accept.ts
1476
+ function matchesAccept(candidate, accept) {
1477
+ if (!accept || accept.length === 0) return true;
1478
+ const type = candidate.type.toLowerCase();
1479
+ const name = candidate.name.toLowerCase();
1480
+ return accept.some((rawPattern) => {
1481
+ const pattern = rawPattern.trim().toLowerCase();
1482
+ if (pattern.startsWith(".")) return name.endsWith(pattern);
1483
+ if (pattern.endsWith("/*")) return type.startsWith(pattern.slice(0, -1));
1484
+ return type === pattern;
1485
+ });
1486
+ }
1487
+ function mediaTypeOfPattern(pattern) {
1488
+ const normalized = pattern.trim().toLowerCase();
1489
+ if (normalized.startsWith("image/")) return "image";
1490
+ if (normalized.startsWith("video/")) return "video";
1491
+ return "file";
1492
+ }
1493
+ function mediaTypeOfAccept(accept) {
1494
+ if (!accept || accept.length === 0) return void 0;
1495
+ const types = new Set(accept.map(mediaTypeOfPattern));
1496
+ if (types.size !== 1) return void 0;
1497
+ return [...types][0];
1498
+ }
1499
+ var ACCEPT_OF_TYPE = {
1500
+ image: ["image/*"],
1501
+ video: ["video/*"],
1502
+ file: void 0
1503
+ };
1504
+ function uploadAcceptOf(accept, type) {
1505
+ if (accept && accept.length > 0) return accept;
1506
+ return type ? ACCEPT_OF_TYPE[type] : void 0;
1507
+ }
1508
+ function recordMatchesAccept(record, accept) {
1509
+ return matchesAccept({ name: record.filename, type: record.contentType }, accept);
1510
+ }
1511
+
1512
+ // src/media-picker-context.ts
1513
+ import React15 from "react";
1514
+ var MediaPickerContext = React15.createContext(null);
1515
+ function useMediaPicker() {
1516
+ const picker = React15.use(MediaPickerContext);
1517
+ if (!picker) throw new Error("useMediaPicker must be used within <MediaPicker>");
1518
+ return picker;
1519
+ }
1520
+ var mediaName = (media) => media.title ?? media.filename ?? media.id;
1521
+
1522
+ // src/media-picker-labels.ts
1523
+ var defaultMediaPickerLabels = {
1524
+ upload: (multiple) => multiple ? "Upload files" : "Upload a file",
1525
+ dropHint: (multiple) => multiple ? "or drop files here" : "or drop a file here",
1526
+ uploads: "Uploads",
1527
+ progress: (filename) => `Uploading ${filename}`,
1528
+ percent: (percent) => `${percent}%`,
1529
+ uploading: (filename) => `Uploading ${filename}.`,
1530
+ uploaded: (filename) => `${filename} uploaded and selected.`,
1531
+ cancel: "Cancel",
1532
+ cancelUpload: (filename) => `Cancel uploading ${filename}`,
1533
+ cancelled: (filename) => `Upload of ${filename} cancelled.`,
1534
+ notAccepted: (filename) => `${filename} isn't a file type you can pick here.`,
1535
+ uploadError: (error) => describeDevulturError(error) ?? "The upload failed. Try again.",
1536
+ retry: "Try again",
1537
+ dismiss: "Dismiss",
1538
+ selection: "Selected media",
1539
+ empty: "Nothing selected.",
1540
+ remove: "Remove",
1541
+ removeItem: (name) => `Remove ${name}`,
1542
+ clear: "Clear",
1543
+ replace: "Replace",
1544
+ selected: (name) => `${name} selected.`,
1545
+ removed: (name) => `${name} removed.`,
1546
+ cleared: "Selection cleared."
1547
+ };
1548
+ function mergeMediaPickerLabels(labels) {
1549
+ const merged = { ...defaultMediaPickerLabels };
1550
+ for (const [key, value] of Object.entries(labels ?? {})) {
1551
+ if (value !== void 0) merged[key] = value;
1552
+ }
1553
+ return merged;
1554
+ }
1555
+
1556
+ // src/media-picker-library.tsx
1557
+ import React16 from "react";
1558
+ import { jsx as jsx10 } from "react/jsx-runtime";
1559
+ function MediaPickerLibrary(props) {
1560
+ const picker = useMediaPicker();
1561
+ const { accept, toggle } = picker;
1562
+ const selectedKeys = picker.selected.map((media) => media.id);
1563
+ const filter = React16.useMemo(
1564
+ () => accept ? (record) => recordMatchesAccept(record, accept) : void 0,
1565
+ [accept]
1566
+ );
1567
+ return /* @__PURE__ */ jsx10(
1568
+ MediaLibrary,
1569
+ {
1570
+ ...props,
1571
+ type: picker.libraryType,
1572
+ labels: picker.libraryLabels,
1573
+ selectedKeys,
1574
+ multiple: picker.multiple,
1575
+ filter,
1576
+ onSelect: (_record, media) => {
1577
+ if (media) toggle(media);
1578
+ }
1579
+ }
1580
+ );
1581
+ }
1582
+
1583
+ // src/media-picker-selection.tsx
1584
+ import React18 from "react";
808
1585
 
809
1586
  // src/preview.tsx
1587
+ import { mediaPath as mediaPath2 } from "@devultur/core";
1588
+
1589
+ // src/video-preview.tsx
1590
+ import React17 from "react";
810
1591
  import { VideoPlayer } from "@devultur/react";
811
- import { Fragment as Fragment3, jsx as jsx5 } from "react/jsx-runtime";
1592
+ import { Fragment as Fragment6, jsx as jsx11 } from "react/jsx-runtime";
1593
+ var UNAUTHORIZED = 401;
1594
+ function fetchErrorOf(error) {
1595
+ if (error.status === UNAUTHORIZED && error.code === "session_mismatch") {
1596
+ return new MediaFetchError("session_mismatch", "The delivery refused this session's token", UNAUTHORIZED);
1597
+ }
1598
+ if (error.status === UNAUTHORIZED) return tokenUnavailableError();
1599
+ if (error.status) return new MediaFetchError("http_error", `Video fetch failed: ${error.status}`, error.status);
1600
+ return null;
1601
+ }
1602
+ function VideoPreview({ media, renderFallback, className }) {
1603
+ const { tokenStatus, tokenError, reissueToken } = useDevultur();
1604
+ const videoProps = useVideoProps(media);
1605
+ const [failure, setFailure] = React17.useState(null);
1606
+ const hasReissuedRef = React17.useRef(false);
1607
+ const playerToken = videoProps?.token;
1608
+ const handleError = React17.useCallback(
1609
+ (error) => {
1610
+ const isFirstMismatch = error.code === "session_mismatch" && !hasReissuedRef.current;
1611
+ if (isFirstMismatch) {
1612
+ hasReissuedRef.current = true;
1613
+ void reissueToken();
1614
+ return;
1615
+ }
1616
+ setFailure({ token: playerToken, error: fetchErrorOf(error) });
1617
+ },
1618
+ [reissueToken, playerToken]
1619
+ );
1620
+ if (!videoProps) return null;
1621
+ if (media.visibility === "private" && tokenStatus === "error") {
1622
+ return /* @__PURE__ */ jsx11(Fragment6, { children: renderFallback(tokenUnavailableError(tokenError)) });
1623
+ }
1624
+ if (failure && failure.token === playerToken) return /* @__PURE__ */ jsx11(Fragment6, { children: renderFallback(failure.error) });
1625
+ return /* @__PURE__ */ jsx11(VideoPlayer, { ...videoProps, className, onError: handleError });
1626
+ }
1627
+
1628
+ // src/preview.tsx
1629
+ import { Fragment as Fragment7, jsx as jsx12 } from "react/jsx-runtime";
812
1630
  var DEFAULT_MAX_TEXT_BYTES = 1024 * 1024;
813
1631
  var DEFAULT_MAX_BYTES = 50 * 1024 * 1024;
814
1632
  function Preview({ media, contentType, fallback, loading, labels, ...viewProps }) {
815
1633
  const text = usePreviewLabels(labels);
816
1634
  if (!media) return null;
817
- if (media.kind === "video") return /* @__PURE__ */ jsx5(VideoPreview, { media, className: viewProps.className });
818
1635
  const renderFallback = (error) => {
819
1636
  const fetchError = error instanceof MediaFetchError ? error : null;
820
1637
  if (typeof fallback === "function") return fallback(fetchError);
821
1638
  if (fallback !== void 0) return fallback;
822
- return /* @__PURE__ */ jsx5(DefaultFallback, { media, error: fetchError, labels: text });
1639
+ return /* @__PURE__ */ jsx12(DefaultFallback, { media, error: fetchError, labels: text });
823
1640
  };
1641
+ if (media.kind === "video") {
1642
+ return /* @__PURE__ */ jsx12(
1643
+ VideoPreview,
1644
+ {
1645
+ media,
1646
+ renderFallback,
1647
+ className: viewProps.className
1648
+ },
1649
+ mediaPath2(media)
1650
+ );
1651
+ }
824
1652
  const expectedType = expectedContentType(media, contentType);
825
1653
  const kind = previewKindOf(expectedType);
826
- if (!expectedType || !kind) return /* @__PURE__ */ jsx5(Fragment3, { children: renderFallback(unsupportedType(expectedType)) });
827
- const loadingNode = loading === void 0 ? /* @__PURE__ */ jsx5(LoadingStatus, { text: text.loading }) : loading;
1654
+ if (!expectedType || !kind) return /* @__PURE__ */ jsx12(Fragment7, { children: renderFallback(unsupportedType(expectedType)) });
1655
+ const loadingNode = loading === void 0 ? /* @__PURE__ */ jsx12(LoadingStatus, { text: text.loading }) : loading;
828
1656
  const props = { ...viewProps, media, expectedType, renderFallback, loading: loadingNode, labels: text };
829
- if (kind === "text") return /* @__PURE__ */ jsx5(TextPreview, { ...props });
830
- if (kind === "pdf") return /* @__PURE__ */ jsx5(PdfPreview, { ...props, maxBytes: props.maxBytes ?? DEFAULT_MAX_BYTES });
831
- return /* @__PURE__ */ jsx5(BinaryPreview, { ...props, kind });
1657
+ if (kind === "text") return /* @__PURE__ */ jsx12(TextPreview, { ...props });
1658
+ if (kind === "pdf") return /* @__PURE__ */ jsx12(PdfPreview, { ...props, maxBytes: props.maxBytes ?? DEFAULT_MAX_BYTES });
1659
+ return /* @__PURE__ */ jsx12(BinaryPreview, { ...props, kind });
832
1660
  }
833
1661
  var unsupportedType = (contentType) => new MediaFetchError("unsupported_type", `Can't preview ${contentType ?? "an unknown type"} inline`);
834
1662
  function DefaultFallback({ media, error, labels }) {
835
- return /* @__PURE__ */ jsx5(DownloadLink, { media, title: error ? labels.error(error.code) : void 0, children: media.filename ?? labels.download });
1663
+ return /* @__PURE__ */ jsx12(DownloadLink, { media, title: error ? labels.error(error.code) : void 0, children: media.filename ?? labels.download });
836
1664
  }
837
1665
  function LoadingStatus({ text }) {
838
- return /* @__PURE__ */ jsx5("span", { role: "status", style: VISUALLY_HIDDEN, children: text });
839
- }
840
- function VideoPreview({ media, className }) {
841
- const videoProps = useVideoProps(media);
842
- if (!videoProps) return null;
843
- return /* @__PURE__ */ jsx5(VideoPlayer, { ...videoProps, className });
1666
+ return /* @__PURE__ */ jsx12("span", { role: "status", style: VISUALLY_HIDDEN, children: text });
844
1667
  }
845
1668
  function BinaryPreview({
846
1669
  media,
@@ -857,13 +1680,13 @@ function BinaryPreview({
857
1680
  const isPublicImage = kind === "image" && media.visibility === "public";
858
1681
  const verified = useMediaObjectUrl(media, { enabled: !isPublicImage, contentType: expectedType, maxBytes });
859
1682
  const objectUrl = isPublicImage ? url(media) : verified.objectUrl;
860
- if (verified.error) return /* @__PURE__ */ jsx5(Fragment3, { children: renderFallback(verified.error) });
861
- if (!objectUrl) return /* @__PURE__ */ jsx5(Fragment3, { children: loading });
1683
+ if (verified.error) return /* @__PURE__ */ jsx12(Fragment7, { children: renderFallback(verified.error) });
1684
+ if (!objectUrl) return /* @__PURE__ */ jsx12(Fragment7, { children: loading });
862
1685
  const label = title ?? media.filename ?? labels.untitledMedia;
863
1686
  if (kind === "image") {
864
- return /* @__PURE__ */ jsx5("img", { src: objectUrl, alt: label, width: media.width, height: media.height, className });
1687
+ return /* @__PURE__ */ jsx12("img", { src: objectUrl, alt: label, width: media.width, height: media.height, className });
865
1688
  }
866
- return /* @__PURE__ */ jsx5("audio", { src: objectUrl, controls: true, "aria-label": label, className });
1689
+ return /* @__PURE__ */ jsx12("audio", { src: objectUrl, controls: true, "aria-label": label, className });
867
1690
  }
868
1691
  var decodeText = (bytes) => new TextDecoder().decode(bytes);
869
1692
  function TextPreview({
@@ -881,45 +1704,411 @@ function TextPreview({
881
1704
  contentType: expectedType,
882
1705
  decode: decodeText
883
1706
  });
884
- if (error) return /* @__PURE__ */ jsx5(Fragment3, { children: renderFallback(error) });
885
- if (text === null) return /* @__PURE__ */ jsx5(Fragment3, { children: loading });
886
- return /* @__PURE__ */ jsx5("pre", { title, className, children: text });
1707
+ if (error) return /* @__PURE__ */ jsx12(Fragment7, { children: renderFallback(error) });
1708
+ if (text === null) return /* @__PURE__ */ jsx12(Fragment7, { children: loading });
1709
+ return /* @__PURE__ */ jsx12("pre", { title, className, children: text });
1710
+ }
1711
+
1712
+ // src/media-picker-selection.tsx
1713
+ import { jsx as jsx13, jsxs as jsxs7 } from "react/jsx-runtime";
1714
+ var THUMBNAIL_WIDTH2 = 96;
1715
+ function MediaPickerSelection({ renderItem, headingLevel = 3, className }) {
1716
+ const picker = useMediaPicker();
1717
+ const { labels, selected } = picker;
1718
+ const headingId = React18.useId();
1719
+ const [previewedId, setPreviewedId] = React18.useState(null);
1720
+ const Heading = `h${headingLevel}`;
1721
+ const cardHeadingLevel = headingLevel + 1;
1722
+ const regionClass = className ? `devultur-media-picker__selection ${className}` : "devultur-media-picker__selection";
1723
+ const handleOpen = (media) => setPreviewedId((current) => current === media.id ? null : media.id);
1724
+ return /* @__PURE__ */ jsxs7(
1725
+ "section",
1726
+ {
1727
+ ref: picker.selectionRef,
1728
+ tabIndex: -1,
1729
+ "aria-labelledby": headingId,
1730
+ className: regionClass,
1731
+ children: [
1732
+ /* @__PURE__ */ jsx13(Heading, { id: headingId, children: labels.selection }),
1733
+ selected.length === 0 && /* @__PURE__ */ jsx13("p", { "data-part": "empty", children: labels.empty }),
1734
+ selected.length > 0 && /* @__PURE__ */ jsx13("ul", { "data-part": "items", children: selected.map((media) => /* @__PURE__ */ jsxs7("li", { "data-kind": media.kind, children: [
1735
+ renderItem ? renderItem(media) : /* @__PURE__ */ jsx13(
1736
+ AttachmentCard,
1737
+ {
1738
+ media,
1739
+ headingLevel: cardHeadingLevel,
1740
+ labels: picker.attachmentLabels,
1741
+ preview: media.kind === "image" ? /* @__PURE__ */ jsx13(Thumbnail2, { media }) : void 0,
1742
+ onOpen: handleOpen
1743
+ }
1744
+ ),
1745
+ previewedId === media.id && /* @__PURE__ */ jsx13(Preview, { media, title: mediaName(media) }),
1746
+ /* @__PURE__ */ jsx13(
1747
+ "button",
1748
+ {
1749
+ type: "button",
1750
+ "aria-label": labels.removeItem(mediaName(media)),
1751
+ onClick: () => picker.remove(media),
1752
+ children: labels.remove
1753
+ }
1754
+ )
1755
+ ] }, media.id)) }),
1756
+ /* @__PURE__ */ jsx13(SelectionActions, {})
1757
+ ]
1758
+ }
1759
+ );
1760
+ }
1761
+ function Thumbnail2({ media }) {
1762
+ return /* @__PURE__ */ jsx13(
1763
+ Image,
1764
+ {
1765
+ media,
1766
+ width: THUMBNAIL_WIDTH2,
1767
+ widths: [THUMBNAIL_WIDTH2, THUMBNAIL_WIDTH2 * 2],
1768
+ sizes: `${THUMBNAIL_WIDTH2}px`,
1769
+ format: "auto",
1770
+ alt: ""
1771
+ }
1772
+ );
1773
+ }
1774
+ function SelectionActions() {
1775
+ const picker = useMediaPicker();
1776
+ const { labels } = picker;
1777
+ if (picker.selected.length === 0) return null;
1778
+ return /* @__PURE__ */ jsxs7("div", { "data-part": "actions", children: [
1779
+ !picker.multiple && /* @__PURE__ */ jsx13("button", { type: "button", "data-part": "replace", onClick: picker.openFileDialog, children: labels.replace }),
1780
+ /* @__PURE__ */ jsx13("button", { type: "button", "data-part": "clear", onClick: picker.clear, children: labels.clear })
1781
+ ] });
1782
+ }
1783
+
1784
+ // src/media-picker-upload.tsx
1785
+ import React19 from "react";
1786
+ import { jsx as jsx14, jsxs as jsxs8 } from "react/jsx-runtime";
1787
+ function MediaPickerUpload({ className }) {
1788
+ const picker = useMediaPicker();
1789
+ const { labels, multiple } = picker;
1790
+ const [isDragging, setIsDragging] = React19.useState(false);
1791
+ const handleDragOver = (event) => {
1792
+ event.preventDefault();
1793
+ setIsDragging(true);
1794
+ };
1795
+ const handleDragLeave = (event) => {
1796
+ if (event.currentTarget.contains(event.relatedTarget)) return;
1797
+ setIsDragging(false);
1798
+ };
1799
+ const handleDrop = (event) => {
1800
+ event.preventDefault();
1801
+ setIsDragging(false);
1802
+ const files = event.dataTransfer.files;
1803
+ if (files.length > 0) picker.start(files);
1804
+ };
1805
+ const zoneClass = className ? `devultur-media-picker__upload ${className}` : "devultur-media-picker__upload";
1806
+ return (
1807
+ // biome-ignore lint/a11y/noStaticElementInteractions: dropping is a pointer shortcut; the button is the keyboard path.
1808
+ /* @__PURE__ */ jsxs8(
1809
+ "div",
1810
+ {
1811
+ className: zoneClass,
1812
+ "data-dragging": isDragging || void 0,
1813
+ onDragOver: handleDragOver,
1814
+ onDragLeave: handleDragLeave,
1815
+ onDrop: handleDrop,
1816
+ children: [
1817
+ /* @__PURE__ */ jsx14("button", { type: "button", "data-part": "upload", onClick: picker.openFileDialog, children: labels.upload(multiple) }),
1818
+ /* @__PURE__ */ jsx14("span", { "data-part": "drop-hint", children: labels.dropHint(multiple) }),
1819
+ 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)) })
1820
+ ]
1821
+ }
1822
+ )
1823
+ );
1824
+ }
1825
+ function UploadRow({ upload }) {
1826
+ const picker = useMediaPicker();
1827
+ const { labels } = picker;
1828
+ const filename = upload.file.name;
1829
+ if (upload.status === "failed") {
1830
+ return /* @__PURE__ */ jsxs8("li", { "data-status": "failed", children: [
1831
+ /* @__PURE__ */ jsx14("span", { "data-part": "filename", children: filename }),
1832
+ /* @__PURE__ */ jsx14("span", { role: "alert", "data-part": "error", children: upload.error }),
1833
+ upload.canRetry && /* @__PURE__ */ jsx14("button", { type: "button", onClick: () => picker.retry(upload.id), children: labels.retry }),
1834
+ /* @__PURE__ */ jsx14("button", { type: "button", onClick: () => picker.dismiss(upload.id), children: labels.dismiss })
1835
+ ] });
1836
+ }
1837
+ const percent = Math.round(upload.fraction * 100);
1838
+ return /* @__PURE__ */ jsxs8("li", { "data-status": "uploading", children: [
1839
+ /* @__PURE__ */ jsx14("span", { "data-part": "filename", children: filename }),
1840
+ /* @__PURE__ */ jsx14("progress", { max: 100, value: percent, "aria-label": labels.progress(filename) }),
1841
+ /* @__PURE__ */ jsx14("span", { "data-part": "percent", children: labels.percent(percent) }),
1842
+ /* @__PURE__ */ jsx14("button", { type: "button", "aria-label": labels.cancelUpload(filename), onClick: () => picker.cancel(upload.id), children: labels.cancel })
1843
+ ] });
1844
+ }
1845
+
1846
+ // src/use-media-picker-uploads.ts
1847
+ import React20 from "react";
1848
+ var isAbortError = (error) => typeof error === "object" && error !== null && error.name === "AbortError";
1849
+ var isPdf = (file) => file.type === "application/pdf" || file.name.toLowerCase().endsWith(".pdf");
1850
+ var nextUploadId = 0;
1851
+ async function pageCountOf(file, countPdfPages) {
1852
+ if (!countPdfPages || !isPdf(file)) return void 0;
1853
+ const pageCount = await countPdfPages(file).catch(() => null);
1854
+ return pageCount ?? void 0;
1855
+ }
1856
+ function useMediaPickerUploads(options) {
1857
+ const { upload } = useDevultur();
1858
+ const [uploads, setUploads] = React20.useState([]);
1859
+ const controllers = React20.useRef(/* @__PURE__ */ new Map());
1860
+ const optionsRef = React20.useRef(options);
1861
+ optionsRef.current = options;
1862
+ const patch = React20.useCallback((id, changes) => {
1863
+ setUploads((current) => current.map((entry) => entry.id === id ? { ...entry, ...changes } : entry));
1864
+ }, []);
1865
+ const drop = React20.useCallback((id) => {
1866
+ controllers.current.delete(id);
1867
+ setUploads((current) => current.filter((entry) => entry.id !== id));
1868
+ }, []);
1869
+ const run = React20.useCallback(
1870
+ async (id, file) => {
1871
+ const { labels, announce } = optionsRef.current;
1872
+ const controller = new AbortController();
1873
+ controllers.current.set(id, controller);
1874
+ patch(id, { status: "uploading", fraction: 0, error: null });
1875
+ announce(labels.uploading(file.name));
1876
+ try {
1877
+ const pageCount = await pageCountOf(file, optionsRef.current.countPdfPages);
1878
+ const uploaded = await upload(file, {
1879
+ pageCount,
1880
+ signal: controller.signal,
1881
+ onProgress: ({ fraction }) => patch(id, { fraction })
1882
+ });
1883
+ drop(id);
1884
+ const media = { ...uploaded, sizeBytes: file.size, ...pageCount ? { pageCount } : {} };
1885
+ optionsRef.current.onUploaded(media);
1886
+ announce(labels.uploaded(file.name));
1887
+ } catch (error) {
1888
+ if (isAbortError(error)) {
1889
+ drop(id);
1890
+ announce(labels.cancelled(file.name));
1891
+ return;
1892
+ }
1893
+ controllers.current.delete(id);
1894
+ patch(id, { status: "failed", error: labels.uploadError(error), canRetry: true });
1895
+ }
1896
+ },
1897
+ [upload, patch, drop]
1898
+ );
1899
+ const start = React20.useCallback(
1900
+ (files) => {
1901
+ const { accept, multiple, labels } = optionsRef.current;
1902
+ const chosen = multiple ? [...files] : [...files].slice(0, 1);
1903
+ const entries = chosen.map((file) => {
1904
+ const isAccepted = matchesAccept(file, accept);
1905
+ return {
1906
+ id: `upload-${nextUploadId++}`,
1907
+ file,
1908
+ fraction: 0,
1909
+ status: isAccepted ? "uploading" : "failed",
1910
+ error: isAccepted ? null : labels.notAccepted(file.name),
1911
+ canRetry: false
1912
+ };
1913
+ });
1914
+ setUploads((current) => [...current, ...entries]);
1915
+ for (const entry of entries) {
1916
+ if (entry.status === "uploading") run(entry.id, entry.file);
1917
+ }
1918
+ },
1919
+ [run]
1920
+ );
1921
+ const cancel = React20.useCallback((id) => controllers.current.get(id)?.abort(), []);
1922
+ const retry = React20.useCallback(
1923
+ (id) => {
1924
+ const entry = uploads.find((candidate) => candidate.id === id);
1925
+ if (!entry?.canRetry) return;
1926
+ run(id, entry.file);
1927
+ },
1928
+ [uploads, run]
1929
+ );
1930
+ React20.useEffect(() => {
1931
+ const active = controllers.current;
1932
+ return () => {
1933
+ for (const controller of active.values()) controller.abort();
1934
+ };
1935
+ }, []);
1936
+ return { uploads, start, cancel, retry, dismiss: drop };
887
1937
  }
888
1938
 
1939
+ // src/media-picker.tsx
1940
+ import { Fragment as Fragment8, jsx as jsx15, jsxs as jsxs9 } from "react/jsx-runtime";
1941
+ var EMPTY = [];
1942
+ function selectionOf(props) {
1943
+ if (props.value === void 0) return void 0;
1944
+ if (props.multiple) return props.value;
1945
+ return props.value ? [props.value] : EMPTY;
1946
+ }
1947
+ function emit(props, next) {
1948
+ if (props.multiple) {
1949
+ props.onChange(next);
1950
+ return;
1951
+ }
1952
+ props.onChange(next[0] ?? null);
1953
+ }
1954
+ var isSame = (a, b) => a.id === b.id;
1955
+ function MediaPicker(props) {
1956
+ const isMultiple = props.multiple ?? false;
1957
+ const labels = React21.useMemo(() => mergeMediaPickerLabels(props.labels), [props.labels]);
1958
+ const accept = uploadAcceptOf(props.accept, props.type);
1959
+ const libraryType = props.type ?? mediaTypeOfAccept(props.accept);
1960
+ const countPdfPages = usePdfPageCounter();
1961
+ const [announcement, setAnnouncement] = React21.useState("");
1962
+ const inputRef = React21.useRef(null);
1963
+ const selectionRef = React21.useRef(null);
1964
+ const [ownSelection, setOwnSelection] = React21.useState(EMPTY);
1965
+ const controlledSelection = selectionOf(props);
1966
+ const selected = controlledSelection ?? ownSelection;
1967
+ const selectedRef = React21.useRef(selected);
1968
+ selectedRef.current = selected;
1969
+ const propsRef = React21.useRef(props);
1970
+ propsRef.current = props;
1971
+ const isControlled = controlledSelection !== void 0;
1972
+ const commit = React21.useCallback(
1973
+ (next) => {
1974
+ selectedRef.current = next;
1975
+ if (!isControlled) setOwnSelection(next);
1976
+ emit(propsRef.current, next);
1977
+ },
1978
+ [isControlled]
1979
+ );
1980
+ const announce = React21.useCallback((message) => setAnnouncement(message), []);
1981
+ const select = React21.useCallback(
1982
+ (media) => {
1983
+ const current = selectedRef.current.filter((item) => !isSame(item, media));
1984
+ commit(isMultiple ? [...current, media] : [media]);
1985
+ },
1986
+ [commit, isMultiple]
1987
+ );
1988
+ const remove = React21.useCallback(
1989
+ (media) => {
1990
+ commit(selectedRef.current.filter((item) => !isSame(item, media)));
1991
+ announce(labels.removed(mediaName(media)));
1992
+ selectionRef.current?.focus();
1993
+ },
1994
+ [commit, announce, labels]
1995
+ );
1996
+ const toggle = React21.useCallback(
1997
+ (media) => {
1998
+ const isSelected = selectedRef.current.some((item) => isSame(item, media));
1999
+ if (isMultiple && isSelected) {
2000
+ remove(media);
2001
+ return;
2002
+ }
2003
+ select(media);
2004
+ announce(labels.selected(mediaName(media)));
2005
+ },
2006
+ [isMultiple, remove, select, announce, labels]
2007
+ );
2008
+ const clear = React21.useCallback(() => {
2009
+ commit([]);
2010
+ announce(labels.cleared);
2011
+ selectionRef.current?.focus();
2012
+ }, [commit, announce, labels]);
2013
+ const handleUploaded = React21.useCallback(
2014
+ (media) => {
2015
+ select(media);
2016
+ selectionRef.current?.focus();
2017
+ },
2018
+ [select]
2019
+ );
2020
+ const uploads = useMediaPickerUploads({
2021
+ accept,
2022
+ multiple: isMultiple,
2023
+ labels,
2024
+ countPdfPages,
2025
+ onUploaded: handleUploaded,
2026
+ announce
2027
+ });
2028
+ const handleFileInput = (event) => {
2029
+ const files = event.target.files;
2030
+ if (files && files.length > 0) uploads.start(files);
2031
+ event.target.value = "";
2032
+ };
2033
+ const picker = {
2034
+ ...uploads,
2035
+ selected,
2036
+ multiple: isMultiple,
2037
+ accept,
2038
+ libraryType,
2039
+ labels,
2040
+ libraryLabels: props.libraryLabels,
2041
+ attachmentLabels: props.attachmentLabels,
2042
+ select,
2043
+ toggle,
2044
+ remove,
2045
+ clear,
2046
+ openFileDialog: () => inputRef.current?.click(),
2047
+ announce,
2048
+ selectionRef
2049
+ };
2050
+ const content = typeof props.children === "function" ? props.children(picker) : props.children ?? /* @__PURE__ */ jsxs9(Fragment8, { children: [
2051
+ /* @__PURE__ */ jsx15(MediaPickerUpload, {}),
2052
+ /* @__PURE__ */ jsx15(MediaPickerLibrary, {}),
2053
+ /* @__PURE__ */ jsx15(MediaPickerSelection, {})
2054
+ ] });
2055
+ const rootClass = props.className ? `devultur-media-picker ${props.className}` : "devultur-media-picker";
2056
+ return /* @__PURE__ */ jsx15(MediaPickerContext, { value: picker, children: /* @__PURE__ */ jsxs9("div", { className: rootClass, children: [
2057
+ /* @__PURE__ */ jsx15(
2058
+ "input",
2059
+ {
2060
+ ref: inputRef,
2061
+ type: "file",
2062
+ accept: accept?.join(","),
2063
+ multiple: isMultiple,
2064
+ tabIndex: -1,
2065
+ "aria-hidden": true,
2066
+ style: { display: "none" },
2067
+ onChange: handleFileInput,
2068
+ "data-part": "input"
2069
+ }
2070
+ ),
2071
+ content,
2072
+ /* @__PURE__ */ jsx15("div", { role: "status", "aria-live": "polite", style: VISUALLY_HIDDEN, children: announcement })
2073
+ ] }) });
2074
+ }
2075
+ MediaPicker.Upload = MediaPickerUpload;
2076
+ MediaPicker.Library = MediaPickerLibrary;
2077
+ MediaPicker.Selection = MediaPickerSelection;
2078
+
889
2079
  // src/react.tsx
890
- import { Fragment as Fragment4, jsx as jsx6 } from "react/jsx-runtime";
891
- var DevulturContext = React8.createContext(null);
892
- var DevulturApiContext = React8.createContext(null);
893
- var ProgressContext = React8.createContext(null);
2080
+ import { jsx as jsx16 } from "react/jsx-runtime";
2081
+ var DevulturContext = React22.createContext(null);
2082
+ var DevulturApiContext = React22.createContext(null);
2083
+ var ProgressContext = React22.createContext(null);
894
2084
  function DevulturProvider({ api, baseUrl, tokenArgs, issueViewerToken, children }) {
895
- const [sessionSecret] = React8.useState(() => typeof crypto !== "undefined" ? crypto.randomUUID() : "");
896
- const issueViewerTokenAction = useAction(issueViewerToken ?? api.issueViewerToken);
897
- const { token, reissueToken } = useViewerToken({
898
- issueViewerToken: issueViewerTokenAction,
899
- sessionSecret,
900
- tokenArgs
901
- });
902
- const createUploadUrlAction = useAction(api.createUploadUrl);
903
- const deleteMediaAction = useAction(api.deleteMedia);
904
- const confirmUploadAction = useAction(api.confirmUpload ?? api.createUploadUrl);
905
- const deleteVideoAction = useAction(api.deleteVideo ?? api.deleteMedia);
906
- const uploadUrl = React8.useCallback(
2085
+ const session = useMediaSession({ issueViewerToken: issueViewerToken ?? api.issueViewerToken, tokenArgs, baseUrl });
2086
+ const createUploadUrlAction = useAction2(api.createUploadUrl);
2087
+ const deleteMediaAction = useAction2(api.deleteMedia);
2088
+ const confirmUploadAction = useAction2(api.confirmUpload ?? api.createUploadUrl);
2089
+ const deleteVideoAction = useAction2(api.deleteVideo ?? api.deleteMedia);
2090
+ const uploadUrl = React22.useCallback(
907
2091
  async (file) => {
908
2092
  const result = await createUploadUrlAction({ filename: file.name, contentType: file.type });
909
2093
  return { url: result.url, key: result.key };
910
2094
  },
911
2095
  [createUploadUrlAction]
912
2096
  );
913
- const upload = React8.useCallback(
2097
+ const upload = React22.useCallback(
914
2098
  async (file, options) => {
915
2099
  const dimensions = await readImageDimensions(file);
2100
+ options?.signal?.throwIfAborted();
916
2101
  const target = await createUploadUrlAction({
917
2102
  filename: file.name,
918
2103
  contentType: file.type,
919
2104
  visibility: options?.visibility
920
2105
  });
921
2106
  await uploadFileWithProgress(target.url, file, { onBytes: options?.onProgress, signal: options?.signal });
922
- if (api.confirmUpload) await confirmUploadBestEffort(confirmUploadAction, target.key, file, dimensions);
2107
+ if (api.confirmUpload) {
2108
+ const pageCount = options?.pageCount;
2109
+ const hints = { ...dimensions, ...pageCount !== void 0 ? { pageCount } : {} };
2110
+ await confirmUploadBestEffort(confirmUploadAction, target.key, file, hints);
2111
+ }
923
2112
  return {
924
2113
  id: target.id,
925
2114
  kind: target.kind,
@@ -931,59 +2120,23 @@ function DevulturProvider({ api, baseUrl, tokenArgs, issueViewerToken, children
931
2120
  },
932
2121
  [createUploadUrlAction, confirmUploadAction, api.confirmUpload]
933
2122
  );
934
- const url = React8.useCallback(
935
- (media, options) => {
936
- if (!baseUrl) throw new Error("Devultur `url()` requires `baseUrl` on <DevulturProvider>");
937
- return mediaUrl2(baseUrl, media, { ...options, token: token ?? void 0 });
938
- },
939
- [baseUrl, token]
940
- );
941
- const getCaptionsText = React8.useCallback(
942
- async (media, locale) => {
943
- if (!baseUrl) throw new Error("Devultur `getCaptionsText()` requires `baseUrl` on <DevulturProvider>");
944
- const path = mediaCaptionPath(media, locale);
945
- if (!path) throw new Error("getCaptionsText: the reference is not a video");
946
- const isPrivate = media.visibility === "private";
947
- const fetchWith = (viewerToken) => fetch(buildMediaUrl(baseUrl, path, { token: viewerToken }), {
948
- headers: sessionSecret ? { [SESSION_HEADER2]: sessionSecret } : void 0
949
- });
950
- let response = await fetchWith(isPrivate ? token ?? void 0 : void 0);
951
- if (isPrivate && response.status === 401 && await isSessionMismatch(response)) {
952
- const fresh = await reissueToken();
953
- if (fresh) response = await fetchWith(fresh);
954
- }
955
- if (!response.ok) throw new Error(`Captions fetch failed: ${response.status}`);
956
- return response.text();
957
- },
958
- [baseUrl, token, reissueToken, sessionSecret]
959
- );
960
- const deleteMedia = React8.useCallback(
2123
+ const deleteMedia = React22.useCallback(
961
2124
  (media) => {
962
2125
  if (media.kind === "video" && api.deleteVideo) {
963
2126
  deleteVideoAction({ videoId: media.id, visibility: media.visibility }).catch(console.error);
964
2127
  return;
965
2128
  }
966
- deleteMediaAction({ key: mediaPath2(media) }).catch(console.error);
2129
+ deleteMediaAction({ key: mediaPath3(media) }).catch(console.error);
967
2130
  },
968
2131
  [deleteMediaAction, deleteVideoAction, api.deleteVideo]
969
2132
  );
970
- const devulturValue = React8.useMemo(
971
- () => ({
972
- token,
973
- baseUrl: baseUrl ?? null,
974
- sessionSecret,
975
- uploadUrl,
976
- upload,
977
- url,
978
- getCaptionsText,
979
- deleteMedia,
980
- reissueToken
981
- }),
982
- [token, baseUrl, sessionSecret, uploadUrl, upload, url, getCaptionsText, deleteMedia, reissueToken]
2133
+ const devulturValue = React22.useMemo(
2134
+ () => ({ ...session, uploadUrl, upload, deleteMedia }),
2135
+ [session, uploadUrl, upload, deleteMedia]
983
2136
  );
984
- let content = /* @__PURE__ */ jsx6(DevulturApiContext, { value: api, children: /* @__PURE__ */ jsx6(DevulturContext, { value: devulturValue, children }) });
2137
+ let content = /* @__PURE__ */ jsx16(DevulturApiContext, { value: api, children: /* @__PURE__ */ jsx16(DevulturContext, { value: devulturValue, children }) });
985
2138
  if (api.saveProgress && api.loadProgress && api.markComplete && api.resetProgress) {
986
- content = /* @__PURE__ */ jsx6(
2139
+ content = /* @__PURE__ */ jsx16(
987
2140
  ProgressBridge,
988
2141
  {
989
2142
  save: api.saveProgress,
@@ -1007,13 +2160,13 @@ function ProgressBridge({
1007
2160
  const saveMutation = useMutation(save);
1008
2161
  const markCompleteMutation = useMutation(markComplete);
1009
2162
  const resetMutation = useMutation(reset);
1010
- const onSave = React8.useCallback(
2163
+ const onSave = React22.useCallback(
1011
2164
  async (videoId, position) => {
1012
2165
  await saveMutation({ videoId, position });
1013
2166
  },
1014
2167
  [saveMutation]
1015
2168
  );
1016
- const onLoad = React8.useCallback(
2169
+ const onLoad = React22.useCallback(
1017
2170
  async (videoId) => {
1018
2171
  const result = await convex.query(load, { videoId });
1019
2172
  if (!result) return null;
@@ -1024,46 +2177,69 @@ function ProgressBridge({
1024
2177
  },
1025
2178
  [convex, load]
1026
2179
  );
1027
- const onMarkComplete = React8.useCallback(
2180
+ const onMarkComplete = React22.useCallback(
1028
2181
  async (videoId) => {
1029
2182
  await markCompleteMutation({ videoId });
1030
2183
  },
1031
2184
  [markCompleteMutation]
1032
2185
  );
1033
- const onReset = React8.useCallback(
2186
+ const onReset = React22.useCallback(
1034
2187
  async (videoId) => {
1035
2188
  await resetMutation({ videoId });
1036
2189
  },
1037
2190
  [resetMutation]
1038
2191
  );
1039
- const value = React8.useMemo(
2192
+ const value = React22.useMemo(
1040
2193
  () => ({ save: onSave, load: onLoad, markComplete: onMarkComplete, reset: onReset }),
1041
2194
  [onSave, onLoad, onMarkComplete, onReset]
1042
2195
  );
1043
- return /* @__PURE__ */ jsx6(ProgressContext, { value, children });
2196
+ return /* @__PURE__ */ jsx16(ProgressContext, { value, children });
1044
2197
  }
1045
2198
  function useDevultur() {
1046
- const context = React8.use(DevulturContext);
1047
- if (!context) throw new Error("useDevultur must be used within <DevulturProvider>");
2199
+ const context = React22.use(DevulturContext);
2200
+ if (!context) throw new Error("useDevultur must be used within <DevulturProvider> or <DevulturViewerProvider>");
1048
2201
  return context;
1049
2202
  }
2203
+ function useDevulturApi() {
2204
+ const api = React22.use(DevulturApiContext);
2205
+ if (api) return api;
2206
+ if (React22.use(DevulturContext)) throw new DevulturCapabilityError("api");
2207
+ throw new Error("useDevulturApi must be used within <DevulturProvider>");
2208
+ }
2209
+ var VIEWER_ACTIONS = {
2210
+ uploadUrl: () => Promise.reject(new DevulturCapabilityError("uploadUrl")),
2211
+ upload: () => Promise.reject(new DevulturCapabilityError("upload")),
2212
+ deleteMedia: () => {
2213
+ throw new DevulturCapabilityError("deleteMedia");
2214
+ }
2215
+ };
2216
+ function DevulturViewerProvider({
2217
+ baseUrl,
2218
+ issueViewerToken,
2219
+ tokenArgs,
2220
+ children
2221
+ }) {
2222
+ const session = useMediaSession({ issueViewerToken, tokenArgs, baseUrl });
2223
+ const devulturValue = React22.useMemo(() => ({ ...session, ...VIEWER_ACTIONS }), [session]);
2224
+ return /* @__PURE__ */ jsx16(DevulturContext, { value: devulturValue, children });
2225
+ }
1050
2226
  function useDevulturProgress() {
1051
- const context = React8.use(ProgressContext);
2227
+ const context = React22.use(ProgressContext);
1052
2228
  if (!context) throw new Error("useDevulturProgress requires progress API in DevulturProvider");
1053
2229
  return context;
1054
2230
  }
1055
2231
  function useMediaUrl(key, options) {
1056
2232
  const { token, baseUrl } = useDevultur();
1057
- return React8.useMemo(() => {
2233
+ return React22.useMemo(() => {
1058
2234
  if (!key) return null;
1059
2235
  if (!baseUrl) throw new Error("useMediaUrl requires `baseUrl` on <DevulturProvider>");
1060
2236
  const isPublic = visibilityFromKey(key) === "public";
1061
- return buildMediaUrl(baseUrl, key, { ...options, token: isPublic ? void 0 : token ?? void 0 });
2237
+ return buildMediaUrl2(baseUrl, key, { ...options, token: isPublic ? void 0 : token ?? void 0 });
1062
2238
  }, [key, baseUrl, token, options]);
1063
2239
  }
1064
- async function confirmUploadBestEffort(confirmUpload, key, file, dimensions) {
2240
+ async function confirmUploadBestEffort(confirmUpload, key, file, hints) {
1065
2241
  try {
1066
- await confirmUpload({ key, filename: file.name, ...dimensions });
2242
+ await confirmUpload({ key, filename: file.name, ...hints });
1067
2243
  } catch (error) {
1068
2244
  console.warn("Devultur: could not record the upload; it is recorded later", error);
1069
2245
  }
@@ -1082,122 +2258,45 @@ async function readImageDimensions(file) {
1082
2258
  function useVideoProps(media, options) {
1083
2259
  const { token, baseUrl, sessionSecret } = useDevultur();
1084
2260
  const locales = options?.locales;
1085
- return React8.useMemo(() => {
2261
+ return React22.useMemo(() => {
1086
2262
  if (media?.kind !== "video") return null;
1087
2263
  if (!baseUrl) throw new Error("useVideoProps requires `baseUrl` on <DevulturProvider>");
1088
2264
  const viewerToken = media.visibility === "private" ? token ?? void 0 : void 0;
1089
2265
  const captions = (locales ?? []).flatMap((locale) => {
1090
- const path = mediaCaptionPath(media, locale);
2266
+ const path = mediaCaptionPath2(media, locale);
1091
2267
  if (!path) return [];
1092
2268
  const label = CaptionLocaleLabel[locale] ?? locale;
1093
- return [{ locale, label, src: buildMediaUrl(baseUrl, path, { token: viewerToken }) }];
2269
+ return [{ locale, label, src: buildMediaUrl2(baseUrl, path, { token: viewerToken }) }];
1094
2270
  });
1095
2271
  return {
1096
- src: buildMediaUrl(baseUrl, mediaPath2(media), { token: viewerToken }),
2272
+ src: buildMediaUrl2(baseUrl, mediaPath3(media), { token: viewerToken }),
1097
2273
  token: viewerToken,
1098
2274
  captions,
1099
2275
  sessionSecret: media.visibility === "private" ? sessionSecret : void 0
1100
2276
  };
1101
2277
  }, [media, baseUrl, token, locales, sessionSecret]);
1102
2278
  }
1103
- function Image({
1104
- media,
1105
- width,
1106
- height,
1107
- fit,
1108
- format,
1109
- quality,
1110
- fallback = null,
1111
- externalFallback,
1112
- render,
1113
- alt,
1114
- ...imgProps
1115
- }) {
1116
- const { url, token, baseUrl, reissueToken, sessionSecret } = useDevultur();
1117
- const [failed, setFailed] = React8.useState(false);
1118
- const [blobSrc, setBlobSrc] = React8.useState(null);
1119
- const sessionRef = React8.useRef({ token, reissueToken });
1120
- React8.useEffect(() => {
1121
- sessionRef.current = { token, reissueToken };
1122
- });
1123
- const isPrivate = media?.visibility === "private";
1124
- const hasToken = token !== null;
1125
- React8.useEffect(() => {
1126
- setFailed(false);
1127
- setBlobSrc(null);
1128
- }, [media]);
1129
- React8.useEffect(() => {
1130
- if (!isPrivate || failed || !media || !hasToken) return;
1131
- let cancelled = false;
1132
- let objectUrl = null;
1133
- if (!baseUrl) throw new Error("Devultur `url()` requires `baseUrl` on <DevulturProvider>");
1134
- const transforms = { width, height, fit, format, quality };
1135
- (async () => {
1136
- try {
1137
- const response = await fetchMedia({
1138
- buildUrl: (viewerToken) => mediaUrl2(baseUrl, media, { ...transforms, token: viewerToken }),
1139
- token: sessionRef.current.token,
1140
- sessionSecret,
1141
- reissueToken: sessionRef.current.reissueToken
1142
- });
1143
- if (response.ok) {
1144
- objectUrl = URL.createObjectURL(await response.blob());
1145
- if (cancelled) {
1146
- URL.revokeObjectURL(objectUrl);
1147
- return;
1148
- }
1149
- setBlobSrc(objectUrl);
1150
- return;
1151
- }
1152
- } catch {
1153
- }
1154
- if (!cancelled) setFailed(true);
1155
- })();
1156
- return () => {
1157
- cancelled = true;
1158
- if (objectUrl) URL.revokeObjectURL(objectUrl);
1159
- };
1160
- }, [isPrivate, failed, media, hasToken, baseUrl, sessionSecret, width, height, fit, format, quality]);
1161
- const handleError = React8.useCallback(() => setFailed(true), []);
1162
- let src;
1163
- if (failed || !media) {
1164
- src = externalFallback ?? null;
1165
- } else if (isPrivate) {
1166
- src = blobSrc;
1167
- } else {
1168
- src = url(media, { width, height, fit, format, quality });
1169
- }
1170
- if (!src) return /* @__PURE__ */ jsx6(Fragment4, { children: fallback });
1171
- if (render) {
1172
- return React8.cloneElement(render, {
1173
- style: { ...render.props.style, backgroundImage: `url("${src}")` }
1174
- });
1175
- }
1176
- return /* @__PURE__ */ jsx6(
1177
- "img",
1178
- {
1179
- src,
1180
- alt,
1181
- width: width ?? media?.width,
1182
- height: height ?? media?.height,
1183
- onError: handleError,
1184
- ...imgProps
1185
- }
1186
- );
1187
- }
1188
- function DownloadLink({ media, filename, children, ...anchorProps }) {
1189
- const { url, token, baseUrl, sessionSecret, reissueToken } = useDevultur();
1190
- const handleClick = React8.useCallback(
2279
+ function DownloadLink({ media, filename, onDownloadError, children, ...anchorProps }) {
2280
+ const { url, token, tokenStatus, tokenError, baseUrl, sessionSecret, reissueToken } = useDevultur();
2281
+ const handleClick = React22.useCallback(
1191
2282
  async (event) => {
1192
2283
  if (media?.visibility !== "private" || !baseUrl) return;
1193
2284
  event.preventDefault();
2285
+ if (tokenStatus === "error") {
2286
+ onDownloadError?.(tokenUnavailableError(tokenError));
2287
+ return;
2288
+ }
1194
2289
  const response = await fetchMedia({
1195
- buildUrl: (viewerToken) => mediaUrl2(baseUrl, media, { download: filename ?? true, token: viewerToken }),
2290
+ buildUrl: (viewerToken) => mediaUrl4(baseUrl, media, { download: filename ?? true, token: viewerToken }),
1196
2291
  token,
1197
2292
  sessionSecret,
1198
2293
  reissueToken
1199
2294
  });
1200
- if (!response.ok) return;
2295
+ if (!response.ok) {
2296
+ const status = response.status;
2297
+ onDownloadError?.(new MediaFetchError("http_error", `Media fetch failed: ${status}`, status));
2298
+ return;
2299
+ }
1201
2300
  const objectUrl = URL.createObjectURL(await response.blob());
1202
2301
  const anchor = document.createElement("a");
1203
2302
  anchor.href = objectUrl;
@@ -1205,13 +2304,13 @@ function DownloadLink({ media, filename, children, ...anchorProps }) {
1205
2304
  anchor.click();
1206
2305
  URL.revokeObjectURL(objectUrl);
1207
2306
  },
1208
- [media, baseUrl, token, filename, sessionSecret, reissueToken]
2307
+ [media, baseUrl, token, tokenStatus, tokenError, filename, sessionSecret, reissueToken, onDownloadError]
1209
2308
  );
1210
2309
  if (!media) return null;
1211
- return /* @__PURE__ */ jsx6("a", { href: url(media, { download: filename ?? true }), download: true, ...anchorProps, onClick: handleClick, children });
2310
+ return /* @__PURE__ */ jsx16("a", { href: url(media, { download: filename ?? true }), download: true, ...anchorProps, onClick: handleClick, children });
1212
2311
  }
1213
2312
  function useDevulturMedia(key) {
1214
- const apiCtx = React8.use(DevulturApiContext);
2313
+ const apiCtx = React22.use(DevulturApiContext);
1215
2314
  if (!apiCtx?.getMedia) {
1216
2315
  throw new Error("useDevulturMedia requires getMedia in DevulturProvider api");
1217
2316
  }
@@ -1268,7 +2367,7 @@ function useVideoState(media, options) {
1268
2367
  const { token, baseUrl, sessionSecret } = useDevultur();
1269
2368
  const status = useDevulturMedia(media?.kind === "video" ? media.id : null);
1270
2369
  const locales = options?.locales;
1271
- return React8.useMemo(() => {
2370
+ return React22.useMemo(() => {
1272
2371
  if (media?.kind !== "video") return { phase: "idle" };
1273
2372
  if (status.isFailed) {
1274
2373
  return {
@@ -1281,12 +2380,12 @@ function useVideoState(media, options) {
1281
2380
  if (status.isReady) {
1282
2381
  if (!baseUrl) throw new Error("useVideoState requires `baseUrl` on <DevulturProvider>");
1283
2382
  const viewerToken = media.visibility === "private" ? token ?? void 0 : void 0;
1284
- const playlist = buildMediaUrl(baseUrl, mediaPath2(media), { token: viewerToken });
2383
+ const playlist = buildMediaUrl2(baseUrl, mediaPath3(media), { token: viewerToken });
1285
2384
  const captions = (locales ?? status.captionLocales).flatMap((locale) => {
1286
- const path = mediaCaptionPath(media, locale);
2385
+ const path = mediaCaptionPath2(media, locale);
1287
2386
  if (!path) return [];
1288
2387
  const label = CaptionLocaleLabel[locale] ?? locale;
1289
- return [{ locale, label, src: buildMediaUrl(baseUrl, path, { token: viewerToken }) }];
2388
+ return [{ locale, label, src: buildMediaUrl2(baseUrl, path, { token: viewerToken }) }];
1290
2389
  });
1291
2390
  return {
1292
2391
  phase: "ready",
@@ -1312,23 +2411,22 @@ function useVideoState(media, options) {
1312
2411
  }
1313
2412
  function useMediaUpload(options) {
1314
2413
  const { visibility, onComplete, onError, replaces, process, locales } = options ?? {};
1315
- const api = React8.use(DevulturApiContext);
1316
- if (!api) throw new Error("useMediaUpload must be used within <DevulturProvider>");
1317
- const createUploadUrlAction = useAction(api.createUploadUrl);
1318
- const deleteMediaAction = useAction(api.deleteMedia);
1319
- const confirmUploadAction = useAction(api.confirmUpload ?? api.createUploadUrl);
1320
- const processVideoAction = useAction(api.processVideo ?? api.createUploadUrl);
1321
- const [phase, setPhase] = React8.useState("idle");
1322
- const [percent, setPercent] = React8.useState(0);
1323
- const [currentFile, setCurrentFile] = React8.useState(null);
1324
- const [media, setMedia] = React8.useState(null);
1325
- const [uploadFailure, setUploadFailure] = React8.useState(null);
1326
- const [processRequested, setProcessRequested] = React8.useState(false);
1327
- const abortRef = React8.useRef(null);
1328
- const lastFileRef = React8.useRef(null);
1329
- const lastKeyRef = React8.useRef(null);
2414
+ const api = useDevulturApi();
2415
+ const createUploadUrlAction = useAction2(api.createUploadUrl);
2416
+ const deleteMediaAction = useAction2(api.deleteMedia);
2417
+ const confirmUploadAction = useAction2(api.confirmUpload ?? api.createUploadUrl);
2418
+ const processVideoAction = useAction2(api.processVideo ?? api.createUploadUrl);
2419
+ const [phase, setPhase] = React22.useState("idle");
2420
+ const [percent, setPercent] = React22.useState(0);
2421
+ const [currentFile, setCurrentFile] = React22.useState(null);
2422
+ const [media, setMedia] = React22.useState(null);
2423
+ const [uploadFailure, setUploadFailure] = React22.useState(null);
2424
+ const [processRequested, setProcessRequested] = React22.useState(false);
2425
+ const abortRef = React22.useRef(null);
2426
+ const lastFileRef = React22.useRef(null);
2427
+ const lastKeyRef = React22.useRef(null);
1330
2428
  const videoState = useVideoState(media, { locales });
1331
- const triggerProcess = React8.useCallback(
2429
+ const triggerProcess = React22.useCallback(
1332
2430
  (key) => {
1333
2431
  if (!api.processVideo) {
1334
2432
  throw new Error("useMediaUpload: processing a video requires `processVideo` in the <DevulturProvider> api");
@@ -1341,7 +2439,7 @@ function useMediaUpload(options) {
1341
2439
  },
1342
2440
  [api.processVideo, processVideoAction]
1343
2441
  );
1344
- const runUpload = React8.useCallback(
2442
+ const runUpload = React22.useCallback(
1345
2443
  async (file) => {
1346
2444
  const controller = new AbortController();
1347
2445
  abortRef.current = controller;
@@ -1393,7 +2491,7 @@ function useMediaUpload(options) {
1393
2491
  };
1394
2492
  setMedia(uploaded);
1395
2493
  setPhase("uploaded");
1396
- if (replaces) deleteMediaAction({ key: mediaPath2(replaces) }).catch(() => {
2494
+ if (replaces) deleteMediaAction({ key: mediaPath3(replaces) }).catch(() => {
1397
2495
  });
1398
2496
  onComplete?.(uploaded);
1399
2497
  if ((process ?? true) && uploaded.kind === "video") triggerProcess(result.key);
@@ -1412,10 +2510,10 @@ function useMediaUpload(options) {
1412
2510
  onError
1413
2511
  ]
1414
2512
  );
1415
- const cancel = React8.useCallback(() => {
2513
+ const cancel = React22.useCallback(() => {
1416
2514
  abortRef.current?.abort();
1417
2515
  }, []);
1418
- const reset = React8.useCallback(() => {
2516
+ const reset = React22.useCallback(() => {
1419
2517
  abortRef.current?.abort();
1420
2518
  setPhase("idle");
1421
2519
  setPercent(0);
@@ -1424,15 +2522,15 @@ function useMediaUpload(options) {
1424
2522
  setUploadFailure(null);
1425
2523
  setProcessRequested(false);
1426
2524
  }, []);
1427
- const retry = React8.useCallback(() => {
2525
+ const retry = React22.useCallback(() => {
1428
2526
  if (phase === "upload-failed" && lastFileRef.current) {
1429
2527
  runUpload(lastFileRef.current);
1430
2528
  return;
1431
2529
  }
1432
2530
  if (lastKeyRef.current) triggerProcess(lastKeyRef.current);
1433
2531
  }, [phase, runUpload, triggerProcess]);
1434
- React8.useEffect(() => () => abortRef.current?.abort(), []);
1435
- const state = React8.useMemo(
2532
+ React22.useEffect(() => () => abortRef.current?.abort(), []);
2533
+ const state = React22.useMemo(
1436
2534
  () => deriveUploadState(
1437
2535
  { phase, percent, file: currentFile, failure: uploadFailure, media, processRequested },
1438
2536
  videoState,
@@ -1440,23 +2538,38 @@ function useMediaUpload(options) {
1440
2538
  ),
1441
2539
  [phase, percent, currentFile, uploadFailure, media, processRequested, videoState, retry]
1442
2540
  );
1443
- const upload = React8.useCallback((file) => runUpload(file), [runUpload]);
2541
+ const upload = React22.useCallback((file) => runUpload(file), [runUpload]);
1444
2542
  return { upload, state, cancel, reset };
1445
2543
  }
1446
2544
  export {
2545
+ AttachmentCard,
1447
2546
  CropUpload,
2547
+ DevulturCapabilityError,
1448
2548
  DevulturProvider,
2549
+ DevulturViewerProvider,
2550
+ DocumentLink,
1449
2551
  DownloadLink,
1450
2552
  Image,
1451
2553
  MediaFetchError,
2554
+ MediaLibrary,
2555
+ MediaPicker,
1452
2556
  PdfPreviewProvider,
1453
2557
  PdfThumbnail,
1454
2558
  Preview,
2559
+ defaultAttachmentLabels,
2560
+ defaultMediaLibraryLabels,
2561
+ defaultMediaPickerLabels,
1455
2562
  defaultPreviewLabels,
2563
+ formatDuration,
2564
+ formatFileSize,
2565
+ matchesAccept,
1456
2566
  useDevultur,
2567
+ useDevulturApi,
1457
2568
  useDevulturMedia,
1458
2569
  useDevulturProgress,
2570
+ useMediaLibrary,
1459
2571
  useMediaObjectUrl,
2572
+ useMediaPicker,
1460
2573
  useMediaUpload,
1461
2574
  useMediaUrl,
1462
2575
  useVideoProps,