@devultur/convex 1.3.1 → 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 +462 -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 +462 -64
  47. package/dist/react.js +1625 -493
  48. package/dist/types-LrusGVDf.d.ts +62 -0
  49. package/dist/validators.d.ts +13 -1
  50. package/dist/validators.js +1 -1
  51. package/package.json +8 -4
package/dist/react.js CHANGED
@@ -1,23 +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 React7 from "react";
29
+ import React22 from "react";
5
30
  import {
6
- buildMediaUrl,
31
+ buildMediaUrl as buildMediaUrl2,
7
32
  CaptionLocaleLabel,
8
- DEFAULT_TOKEN_EXPIRY_SECONDS,
9
33
  MediaStatus,
10
- mediaCaptionPath,
11
- mediaPath as mediaPath2,
12
- mediaUrl as mediaUrl2,
13
- SESSION_HEADER as SESSION_HEADER2,
34
+ mediaCaptionPath as mediaCaptionPath2,
35
+ mediaPath as mediaPath3,
36
+ mediaUrl as mediaUrl4,
14
37
  visibilityFromKey
15
38
  } from "@devultur/core";
16
39
  import {
17
40
  UploadHttpError,
18
41
  uploadFileWithProgress
19
42
  } from "@devultur/react";
20
- 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";
21
60
 
22
61
  // src/image-recovery.ts
23
62
  async function isSessionMismatch(response) {
@@ -29,89 +68,21 @@ async function isSessionMismatch(response) {
29
68
  }
30
69
  }
31
70
 
32
- // src/media-fetch.ts
33
- import { SESSION_HEADER } from "@devultur/core";
34
-
35
- // src/preview-type.ts
36
- var PREVIEW_KIND_BY_TYPE = /* @__PURE__ */ new Map([
37
- ["image/png", "image"],
38
- ["image/jpeg", "image"],
39
- ["image/webp", "image"],
40
- ["image/gif", "image"],
41
- ["image/avif", "image"],
42
- ["application/pdf", "pdf"],
43
- ["audio/mpeg", "audio"],
44
- ["audio/mp4", "audio"],
45
- ["audio/aac", "audio"],
46
- ["audio/ogg", "audio"],
47
- ["audio/wav", "audio"],
48
- ["audio/webm", "audio"],
49
- ["audio/flac", "audio"],
50
- ["text/plain", "text"],
51
- ["text/csv", "text"],
52
- ["application/json", "text"]
53
- ]);
54
- var TYPE_BY_EXTENSION = /* @__PURE__ */ new Map([
55
- ["png", "image/png"],
56
- ["jpg", "image/jpeg"],
57
- ["jpeg", "image/jpeg"],
58
- ["webp", "image/webp"],
59
- ["gif", "image/gif"],
60
- ["avif", "image/avif"],
61
- ["pdf", "application/pdf"],
62
- ["mp3", "audio/mpeg"],
63
- ["m4a", "audio/mp4"],
64
- ["aac", "audio/aac"],
65
- ["ogg", "audio/ogg"],
66
- ["oga", "audio/ogg"],
67
- ["opus", "audio/ogg"],
68
- ["wav", "audio/wav"],
69
- ["weba", "audio/webm"],
70
- ["flac", "audio/flac"],
71
- ["txt", "text/plain"],
72
- ["csv", "text/csv"],
73
- ["json", "application/json"]
74
- ]);
75
- var OPAQUE_CONTENT_TYPE = "application/octet-stream";
76
- function normalizeContentType(value) {
77
- const essence = value?.split(";")[0]?.trim().toLowerCase();
78
- return essence || null;
79
- }
80
- function previewKindOf(contentType) {
81
- const normalized = normalizeContentType(contentType);
82
- if (!normalized) return null;
83
- return PREVIEW_KIND_BY_TYPE.get(normalized) ?? null;
84
- }
85
- function expectedContentType(media, declaredType) {
86
- if (declaredType) return normalizeContentType(declaredType);
87
- const extension = media.filename?.split(".").pop()?.toLowerCase();
88
- if (!extension || extension === media.filename?.toLowerCase()) return null;
89
- return TYPE_BY_EXTENSION.get(extension) ?? null;
90
- }
91
- function trustedContentType(expectedType, responseType) {
92
- const receivedKind = previewKindOf(responseType);
93
- if (!expectedType) {
94
- const contentType = receivedKind ? normalizeContentType(responseType) : null;
95
- return { ok: true, contentType: contentType ?? OPAQUE_CONTENT_TYPE };
96
- }
97
- const expectedKind = previewKindOf(expectedType);
98
- if (!expectedKind) return { ok: false, reason: "unsupported_type" };
99
- if (receivedKind !== expectedKind) return { ok: false, reason: "type_mismatch" };
100
- return { ok: true, contentType: normalizeContentType(expectedType) ?? OPAQUE_CONTENT_TYPE };
101
- }
102
-
103
71
  // src/media-fetch.ts
104
72
  var MediaFetchError = class extends Error {
105
73
  code;
106
74
  /** HTTP status, for `http_error`. */
107
75
  status;
108
- constructor(code, message, status) {
109
- super(message);
76
+ constructor(code, message, status, options) {
77
+ super(message, options);
110
78
  this.name = "MediaFetchError";
111
79
  this.code = code;
112
80
  this.status = status;
113
81
  }
114
82
  };
83
+ function tokenUnavailableError(cause) {
84
+ return new MediaFetchError("token_unavailable", "No viewer token: its issuance failed", void 0, { cause });
85
+ }
115
86
  async function fetchMedia(request) {
116
87
  const { buildUrl, token, sessionSecret, reissueToken, signal, isPrivate = true, canReissue = true } = request;
117
88
  const headers = isPrivate && sessionSecret ? { [SESSION_HEADER]: sessionSecret } : void 0;
@@ -172,6 +143,159 @@ async function readCapped(body, maxBytes) {
172
143
  return bytes;
173
144
  }
174
145
 
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";
155
+
156
+ // src/viewer-token.ts
157
+ import React from "react";
158
+ import { DEFAULT_TOKEN_EXPIRY_SECONDS } from "@devultur/core";
159
+ import { convexToJson } from "convex/values";
160
+ var REFRESH_BUFFER_MS = 5 * 60 * 1e3;
161
+ var MIN_REFRESH_MS = 6e4;
162
+ var DEFAULT_TTL_MS = DEFAULT_TOKEN_EXPIRY_SECONDS * 1e3;
163
+ function extractTokenResult(result) {
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;
170
+ return { tokenValue, ttlMs };
171
+ }
172
+ var asError = (error) => error instanceof Error ? error : new Error(String(error));
173
+ function sortKeys(json) {
174
+ if (Array.isArray(json)) return json.map(sortKeys);
175
+ if (json === null || typeof json !== "object") return json;
176
+ return Object.fromEntries(
177
+ Object.keys(json).sort().map((name) => [name, sortKeys(json[name])])
178
+ );
179
+ }
180
+ function tokenArgsKey(tokenArgs) {
181
+ return JSON.stringify(sortKeys(convexToJson(tokenArgs ?? {})));
182
+ }
183
+ function useViewerToken({ issueViewerToken, sessionSecret, tokenArgs }) {
184
+ const argsKey = tokenArgsKey(tokenArgs);
185
+ const stableArgs = React.useMemo(() => tokenArgs ?? {}, [argsKey]);
186
+ const [issued, setIssued] = React.useState(null);
187
+ const [attempt, setAttempt] = React.useState(null);
188
+ const token = issued?.argsKey === argsKey ? issued.token : null;
189
+ const currentAttempt = attempt?.argsKey === argsKey ? attempt : null;
190
+ const currentArgsKeyRef = React.useRef(argsKey);
191
+ const refreshTimerRef = React.useRef(null);
192
+ const inFlightRef = React.useRef(null);
193
+ React.useEffect(() => {
194
+ currentArgsKeyRef.current = argsKey;
195
+ }, [argsKey]);
196
+ React.useEffect(() => {
197
+ return () => {
198
+ if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current);
199
+ };
200
+ }, []);
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;
213
+ }, [issueViewerToken, stableArgs, argsKey, sessionSecret]);
214
+ const issueOnce = React.useCallback(() => {
215
+ const inFlight = inFlightRef.current;
216
+ if (inFlight?.argsKey === argsKey) return inFlight.promise;
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
+ });
222
+ const settle = () => {
223
+ if (inFlightRef.current?.promise === promise) inFlightRef.current = null;
224
+ };
225
+ promise.then(settle, settle);
226
+ inFlightRef.current = { argsKey, promise };
227
+ return promise;
228
+ }, [request, argsKey]);
229
+ const hasAttempt = currentAttempt !== null;
230
+ React.useEffect(() => {
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
+
175
299
  // src/media-upload-derive.ts
176
300
  function deriveUploadState(local, videoState, retry) {
177
301
  const { phase, percent, file, failure, media, processRequested } = local;
@@ -215,7 +339,7 @@ function deriveUploadState(local, videoState, retry) {
215
339
  }
216
340
 
217
341
  // src/crop-upload.tsx
218
- import React from "react";
342
+ import React3 from "react";
219
343
  import { ImageCropper } from "@devultur/react";
220
344
  import { jsx, jsxs } from "react/jsx-runtime";
221
345
  var DEFAULT_LABELS = {
@@ -239,18 +363,18 @@ function CropUpload({
239
363
  children
240
364
  }) {
241
365
  const text = { ...DEFAULT_LABELS, ...labels };
242
- const [pendingFile, setPendingFile] = React.useState(null);
243
- const inputRef = React.useRef(null);
366
+ const [pendingFile, setPendingFile] = React3.useState(null);
367
+ const inputRef = React3.useRef(null);
244
368
  const { upload, state, cancel, reset } = useMediaUpload({ visibility, replaces, onComplete });
245
- const pickFile = React.useCallback(() => inputRef.current?.click(), []);
246
- const handleCrop = React.useCallback(
369
+ const pickFile = React3.useCallback(() => inputRef.current?.click(), []);
370
+ const handleCrop = React3.useCallback(
247
371
  (baked) => {
248
372
  setPendingFile(null);
249
373
  upload(baked);
250
374
  },
251
375
  [upload]
252
376
  );
253
- const resetAll = React.useCallback(() => {
377
+ const resetAll = React3.useCallback(() => {
254
378
  setPendingFile(null);
255
379
  reset();
256
380
  }, [reset]);
@@ -302,9 +426,268 @@ function CropUpload({
302
426
  ] });
303
427
  }
304
428
 
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
+ );
462
+ }
463
+
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 }) {
467
+ const { url } = useDevultur();
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 });
472
+ }
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
+ ] });
500
+ }
501
+
502
+ // src/image.tsx
503
+ import React5 from "react";
504
+ import { mediaUrl as mediaUrl2 } from "@devultur/core";
505
+
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
+
305
688
  // src/media-resource.ts
306
- import React2 from "react";
307
- import { mediaPath, mediaUrl } from "@devultur/core";
689
+ import React6 from "react";
690
+ import { mediaPath, mediaUrl as mediaUrl3 } from "@devultur/core";
308
691
  var IDLE = { value: null, contentType: null, isLoading: false, error: null };
309
692
  var LOADING = { value: null, contentType: null, isLoading: true, error: null };
310
693
  function useMediaResource(media, options) {
@@ -312,13 +695,18 @@ function useMediaResource(media, options) {
312
695
  const { isEnabled, maxBytes, contentType } = options;
313
696
  const isPrivate = media?.visibility === "private";
314
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
+ );
315
703
  const requestKey = media && isEnabled ? [devultur.baseUrl, media.visibility, mediaPath(media), contentType ?? "", maxBytes ?? ""].join("|") : null;
316
- const [settled, setSettled] = React2.useState(null);
317
- const inputsRef = React2.useRef({ media, devultur, options });
318
- React2.useEffect(() => {
704
+ const [settled, setSettled] = React6.useState(null);
705
+ const inputsRef = React6.useRef({ media, devultur, options });
706
+ React6.useEffect(() => {
319
707
  inputsRef.current = { media, devultur, options };
320
708
  });
321
- React2.useEffect(() => {
709
+ React6.useEffect(() => {
322
710
  if (!requestKey || !isReady) return;
323
711
  const { media: target, devultur: context, options: current } = inputsRef.current;
324
712
  if (!target || !context.baseUrl) return;
@@ -326,7 +714,7 @@ function useMediaResource(media, options) {
326
714
  const controller = new AbortController();
327
715
  let value = null;
328
716
  loadMedia({
329
- buildUrl: (viewerToken) => mediaUrl(baseUrl, target, { token: viewerToken }),
717
+ buildUrl: (viewerToken) => mediaUrl3(baseUrl, target, { token: viewerToken }),
330
718
  token: context.token,
331
719
  sessionSecret: context.sessionSecret,
332
720
  reissueToken: context.reissueToken,
@@ -350,44 +738,22 @@ function useMediaResource(media, options) {
350
738
  }, [requestKey, isReady]);
351
739
  if (!requestKey) return IDLE;
352
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 };
353
742
  if (settled?.requestKey !== requestKey) return LOADING;
354
743
  return { value: settled.value, contentType: settled.contentType, isLoading: false, error: settled.error };
355
744
  }
356
745
 
357
- // src/media-object-url.ts
358
- var createObjectUrl = (bytes, contentType) => URL.createObjectURL(new Blob([bytes], { type: contentType }));
359
- var revokeObjectUrl = (objectUrl) => URL.revokeObjectURL(objectUrl);
360
- function useMediaObjectUrl(media, options) {
361
- const { url } = useDevultur();
362
- const isEnabled = (options?.enabled ?? true) && Boolean(media);
363
- const isUnverifiedPublic = media?.visibility === "public" && !options?.contentType;
364
- const resource = useMediaResource(media, {
365
- isEnabled: isEnabled && !isUnverifiedPublic,
366
- maxBytes: options?.maxBytes,
367
- contentType: options?.contentType,
368
- decode: createObjectUrl,
369
- release: revokeObjectUrl
370
- });
371
- if (media && isEnabled && isUnverifiedPublic) {
372
- return { objectUrl: url(media), contentType: null, isLoading: false, error: null };
373
- }
374
- return {
375
- objectUrl: resource.value,
376
- contentType: resource.contentType,
377
- isLoading: resource.isLoading,
378
- error: resource.error
379
- };
380
- }
381
-
382
746
  // src/pdf-context.tsx
383
- import React3 from "react";
747
+ import React7 from "react";
384
748
 
385
749
  // src/preview-labels.ts
386
750
  var ERROR_TEXT = {
387
751
  http_error: "The file couldn't be loaded.",
388
752
  too_large: "The file is too large to preview.",
389
753
  unsupported_type: "This file type can't be previewed.",
390
- type_mismatch: "The file's content doesn't match its type."
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."
391
757
  };
392
758
  var defaultPreviewLabels = {
393
759
  document: (title, pageCount) => `${title}, ${pageCount === 1 ? "1 page" : `${pageCount} pages`}`,
@@ -411,16 +777,16 @@ function mergePreviewLabels(...sources) {
411
777
  }
412
778
 
413
779
  // src/pdf-context.tsx
414
- import { jsx as jsx2 } from "react/jsx-runtime";
415
- var PdfPreviewContext = React3.createContext({});
780
+ import { jsx as jsx5 } from "react/jsx-runtime";
781
+ var PdfPreviewContext = React7.createContext({});
416
782
  function PdfPreviewProvider({
417
783
  pdfjs,
418
784
  workerSrc,
419
785
  labels,
420
786
  children
421
787
  }) {
422
- const config = React3.useMemo(() => ({ pdfjs, workerSrc, labels }), [pdfjs, workerSrc, labels]);
423
- 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 });
424
790
  }
425
791
  var loadedModules = /* @__PURE__ */ new WeakMap();
426
792
  function loadOnce(loader) {
@@ -431,9 +797,9 @@ function loadOnce(loader) {
431
797
  return loading;
432
798
  }
433
799
  function usePdfjs(workerSrcOverride) {
434
- const { pdfjs: loader, workerSrc } = React3.useContext(PdfPreviewContext);
435
- const [loaded, setLoaded] = React3.useState(null);
436
- React3.useEffect(() => {
800
+ const { pdfjs: loader, workerSrc } = React7.useContext(PdfPreviewContext);
801
+ const [loaded, setLoaded] = React7.useState(null);
802
+ React7.useEffect(() => {
437
803
  if (!loader) return;
438
804
  let isCurrent = true;
439
805
  loadOnce(loader).then((pdfjs) => {
@@ -448,61 +814,27 @@ function usePdfjs(workerSrcOverride) {
448
814
  if (!loaded.pdfjs) return { status: "unavailable" };
449
815
  return { status: "ready", pdfjs: loaded.pdfjs, workerSrc: workerSrcOverride ?? workerSrc };
450
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
+ }
451
828
  function usePreviewLabels(labels) {
452
- const providerLabels = React3.useContext(PdfPreviewContext).labels;
829
+ const providerLabels = React7.useContext(PdfPreviewContext).labels;
453
830
  return mergePreviewLabels(providerLabels, labels);
454
831
  }
455
832
 
456
- // src/pdf-thumbnail.tsx
457
- import React6 from "react";
458
-
459
- // src/pdf-hooks.ts
460
- import React4 from "react";
461
-
462
- // src/pdf-render.ts
463
- var MOBILE_MAX_CANVAS_PIXELS = 5242880;
464
- var DESKTOP_MAX_CANVAS_PIXELS = 2 ** 25;
465
- function openPdf(pdfjs, bytes, workerSrc) {
466
- if (workerSrc) pdfjs.GlobalWorkerOptions.workerSrc = workerSrc;
467
- const source = { data: bytes.slice(), enableScripting: false, isEvalSupported: false };
468
- return pdfjs.getDocument(source);
469
- }
470
- function isMobileDevice() {
471
- if (typeof navigator === "undefined") return false;
472
- return /Android|iPhone|iPad|iPod/.test(navigator.userAgent) || navigator.maxTouchPoints > 1;
473
- }
474
- function maxCanvasPixels() {
475
- return isMobileDevice() ? MOBILE_MAX_CANVAS_PIXELS : DESKTOP_MAX_CANVAS_PIXELS;
476
- }
477
- function outputScale(cssWidth, cssHeight, maxPixels) {
478
- const devicePixelRatio = typeof window !== "undefined" && window.devicePixelRatio || 1;
479
- const cssPixels = cssWidth * cssHeight;
480
- if (cssPixels <= 0) return devicePixelRatio;
481
- if (cssPixels * devicePixelRatio ** 2 <= maxPixels) return devicePixelRatio;
482
- return Math.sqrt(maxPixels / cssPixels);
483
- }
484
- function renderPage(page, canvas, cssWidth) {
485
- const naturalViewport = page.getViewport({ scale: 1 });
486
- const cssScale = cssWidth > 0 ? cssWidth / naturalViewport.width : 1;
487
- const cssViewport = page.getViewport({ scale: cssScale });
488
- const scale = outputScale(cssViewport.width, cssViewport.height, maxCanvasPixels());
489
- const viewport = page.getViewport({ scale: cssScale * scale });
490
- canvas.width = Math.floor(viewport.width);
491
- canvas.height = Math.floor(viewport.height);
492
- canvas.style.width = `${Math.floor(cssViewport.width)}px`;
493
- canvas.style.height = `${Math.floor(cssViewport.height)}px`;
494
- return page.render({ canvas, viewport });
495
- }
496
- async function pageText(page) {
497
- const { items } = await page.getTextContent();
498
- return items.map((item) => "str" in item && typeof item.str === "string" ? item.str : "").join(" ").replace(/\s+/g, " ").trim();
499
- }
500
- var isRenderCancelled = (error) => error instanceof Error && error.name === "RenderingCancelledException";
501
-
502
833
  // src/pdf-hooks.ts
834
+ import React8 from "react";
503
835
  function useIsVisible(ref, rootMargin = "200px") {
504
- const [isVisible, setIsVisible] = React4.useState(false);
505
- React4.useEffect(() => {
836
+ const [isVisible, setIsVisible] = React8.useState(false);
837
+ React8.useEffect(() => {
506
838
  const element = ref.current;
507
839
  if (isVisible || !element) return;
508
840
  if (typeof IntersectionObserver === "undefined") {
@@ -523,12 +855,12 @@ function useIsVisible(ref, rootMargin = "200px") {
523
855
  return isVisible;
524
856
  }
525
857
  function usePdfDocument({ pdfjs, bytes, workerSrc }) {
526
- const [state, setState] = React4.useState({
858
+ const [state, setState] = React8.useState({
527
859
  bytes: null,
528
860
  document: null,
529
861
  error: null
530
862
  });
531
- React4.useEffect(() => {
863
+ React8.useEffect(() => {
532
864
  let isCurrent = true;
533
865
  const loadingTask = openPdf(pdfjs, bytes, workerSrc);
534
866
  loadingTask.promise.then((document2) => {
@@ -547,8 +879,8 @@ function usePdfDocument({ pdfjs, bytes, workerSrc }) {
547
879
  }
548
880
  function usePageRender(options) {
549
881
  const { document: document2, pageNumber, canvasRef, width, isEnabled, shouldExtractText = false } = options;
550
- const [state, setState] = React4.useState({ isRendered: false, text: "" });
551
- React4.useEffect(() => {
882
+ const [state, setState] = React8.useState({ isRendered: false, text: "" });
883
+ React8.useEffect(() => {
552
884
  const canvas = canvasRef.current;
553
885
  if (!document2 || !isEnabled || !canvas) return;
554
886
  let isCurrent = true;
@@ -578,8 +910,8 @@ function usePageRender(options) {
578
910
  }
579
911
 
580
912
  // src/pdf-preview.tsx
581
- import React5 from "react";
582
- 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";
583
915
  var VISUALLY_HIDDEN = {
584
916
  position: "absolute",
585
917
  width: 1,
@@ -602,11 +934,11 @@ function PdfPreview(props) {
602
934
  contentType: expectedType,
603
935
  decode: keepBytes
604
936
  });
605
- if (error) return /* @__PURE__ */ jsx3(Fragment, { children: renderFallback(error) });
606
- 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 });
607
939
  const label = title ?? media.filename ?? labels.untitledPdf;
608
- if (pdfjsState.status === "unavailable") return /* @__PURE__ */ jsx3(PdfFrame, { bytes, label, className });
609
- return /* @__PURE__ */ jsx3(
940
+ if (pdfjsState.status === "unavailable") return /* @__PURE__ */ jsx6(PdfFrame, { bytes, label, className });
941
+ return /* @__PURE__ */ jsx6(
610
942
  PdfCanvasDocument,
611
943
  {
612
944
  pdfjs: pdfjsState.pdfjs,
@@ -624,17 +956,17 @@ function PdfPreview(props) {
624
956
  function PdfCanvasDocument(props) {
625
957
  const { pdfjs, workerSrc, bytes, label, className, renderFallback, loading, labels, maxPages } = props;
626
958
  const { document: document2, error } = usePdfDocument({ pdfjs, bytes, workerSrc });
627
- const containerRef = React5.useRef(null);
628
- const [width, setWidth] = React5.useState(null);
629
- React5.useLayoutEffect(() => {
959
+ const containerRef = React9.useRef(null);
960
+ const [width, setWidth] = React9.useState(null);
961
+ React9.useLayoutEffect(() => {
630
962
  if (document2) setWidth(containerRef.current?.clientWidth ?? 0);
631
963
  }, [document2]);
632
- if (error) return /* @__PURE__ */ jsx3(Fragment, { children: renderFallback(error) });
633
- 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 });
634
966
  const shownPageCount = Math.min(document2.numPages, Math.max(1, maxPages));
635
967
  const isTruncated = shownPageCount < document2.numPages;
636
968
  const pageNumbers = Array.from({ length: shownPageCount }, (_, index) => index + 1);
637
- return /* @__PURE__ */ jsxs2(
969
+ return /* @__PURE__ */ jsxs3(
638
970
  "div",
639
971
  {
640
972
  ref: containerRef,
@@ -642,7 +974,7 @@ function PdfCanvasDocument(props) {
642
974
  "aria-label": labels.document(label, document2.numPages),
643
975
  className,
644
976
  children: [
645
- width !== null && pageNumbers.map((pageNumber) => /* @__PURE__ */ jsx3(
977
+ width !== null && pageNumbers.map((pageNumber) => /* @__PURE__ */ jsx6(
646
978
  PdfPageView,
647
979
  {
648
980
  document: document2,
@@ -652,7 +984,7 @@ function PdfCanvasDocument(props) {
652
984
  },
653
985
  pageNumber
654
986
  )),
655
- isTruncated && /* @__PURE__ */ jsxs2("p", { children: [
987
+ isTruncated && /* @__PURE__ */ jsxs3("p", { children: [
656
988
  labels.pageCap(shownPageCount, document2.numPages),
657
989
  " ",
658
990
  renderFallback(null)
@@ -662,8 +994,8 @@ function PdfCanvasDocument(props) {
662
994
  );
663
995
  }
664
996
  function PdfPageView({ document: document2, pageNumber, width, label }) {
665
- const pageRef = React5.useRef(null);
666
- const canvasRef = React5.useRef(null);
997
+ const pageRef = React9.useRef(null);
998
+ const canvasRef = React9.useRef(null);
667
999
  const isVisible = useIsVisible(pageRef);
668
1000
  const { isRendered, text } = usePageRender({
669
1001
  document: document2,
@@ -674,46 +1006,46 @@ function PdfPageView({ document: document2, pageNumber, width, label }) {
674
1006
  shouldExtractText: true
675
1007
  });
676
1008
  const placeholderStyle = isRendered ? void 0 : { aspectRatio: "1 / 1.4142", maxWidth: "100%" };
677
- return /* @__PURE__ */ jsxs2("section", { ref: pageRef, "aria-label": label, style: placeholderStyle, children: [
678
- /* @__PURE__ */ jsx3("canvas", { ref: canvasRef, style: { display: "block", maxWidth: "100%" } }),
679
- text && /* @__PURE__ */ jsx3("p", { style: VISUALLY_HIDDEN, children: text })
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 })
680
1012
  ] });
681
1013
  }
682
1014
  function PdfFrame({ bytes, label, className }) {
683
- const [objectUrl, setObjectUrl] = React5.useState(null);
684
- React5.useEffect(() => {
1015
+ const [objectUrl, setObjectUrl] = React9.useState(null);
1016
+ React9.useEffect(() => {
685
1017
  const url = URL.createObjectURL(new Blob([bytes], { type: "application/pdf" }));
686
1018
  setObjectUrl(url);
687
1019
  return () => URL.revokeObjectURL(url);
688
1020
  }, [bytes]);
689
1021
  if (!objectUrl) return null;
690
- 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 });
691
1023
  }
692
1024
 
693
1025
  // src/pdf-thumbnail.tsx
694
- 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";
695
1027
  var DEFAULT_THUMBNAIL_WIDTH = 96;
696
1028
  var DEFAULT_THUMBNAIL_MAX_BYTES = 50 * 1024 * 1024;
697
1029
  function PdfThumbnail(props) {
698
1030
  const { media, contentType, width = DEFAULT_THUMBNAIL_WIDTH, fallback = null, loading = null } = props;
699
- const containerRef = React6.useRef(null);
1031
+ const containerRef = React10.useRef(null);
700
1032
  const isVisible = useIsVisible(containerRef);
701
1033
  const pdfjsState = usePdfjs(props.pdfWorkerSrc);
702
1034
  const labels = usePreviewLabels(props.labels);
703
1035
  const expectedType = media ? expectedContentType(media, contentType) : null;
704
- const isPdf = previewKindOf(expectedType) === "pdf";
1036
+ const isPdf2 = previewKindOf(expectedType) === "pdf";
705
1037
  const { value: bytes, error } = useMediaResource(media, {
706
- isEnabled: isVisible && isPdf && pdfjsState.status === "ready",
1038
+ isEnabled: isVisible && isPdf2 && pdfjsState.status === "ready",
707
1039
  maxBytes: props.maxBytes ?? DEFAULT_THUMBNAIL_MAX_BYTES,
708
1040
  contentType: expectedType ?? void 0,
709
1041
  decode: keepBytes
710
1042
  });
711
1043
  if (!media) return null;
712
- const isUnavailable = !isPdf || pdfjsState.status === "unavailable" || error !== null;
1044
+ const isUnavailable = !isPdf2 || pdfjsState.status === "unavailable" || error !== null;
713
1045
  const content = () => {
714
1046
  if (isUnavailable) return fallback;
715
1047
  if (!bytes || pdfjsState.status !== "ready") return loading;
716
- return /* @__PURE__ */ jsx4(
1048
+ return /* @__PURE__ */ jsx7(
717
1049
  ThumbnailCanvas,
718
1050
  {
719
1051
  pdfjs: pdfjsState.pdfjs,
@@ -726,54 +1058,612 @@ function PdfThumbnail(props) {
726
1058
  }
727
1059
  );
728
1060
  };
729
- 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() });
730
1062
  }
731
1063
  function ThumbnailCanvas({ pdfjs, workerSrc, bytes, width, label, fallback, loading }) {
732
- const canvasRef = React6.useRef(null);
1064
+ const canvasRef = React10.useRef(null);
733
1065
  const { document: document2, error } = usePdfDocument({ pdfjs, bytes, workerSrc });
734
1066
  const { isRendered } = usePageRender({ document: document2, pageNumber: 1, canvasRef, width, isEnabled: true });
735
- if (error) return /* @__PURE__ */ jsx4(Fragment2, { children: fallback });
736
- return /* @__PURE__ */ jsxs3(Fragment2, { children: [
1067
+ if (error) return /* @__PURE__ */ jsx7(Fragment3, { children: fallback });
1068
+ return /* @__PURE__ */ jsxs4(Fragment3, { children: [
737
1069
  !isRendered && loading,
738
- /* @__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" } })
739
1071
  ] });
740
1072
  }
741
1073
 
742
- // src/preview.tsx
743
- import { VideoPlayer } from "@devultur/react";
744
- import { Fragment as Fragment3, jsx as jsx5 } from "react/jsx-runtime";
745
- var DEFAULT_MAX_TEXT_BYTES = 1024 * 1024;
746
- var DEFAULT_MAX_BYTES = 50 * 1024 * 1024;
747
- function Preview({ media, contentType, fallback, loading, labels, ...viewProps }) {
748
- const text = usePreviewLabels(labels);
749
- if (!media) return null;
750
- if (media.kind === "video") return /* @__PURE__ */ jsx5(VideoPreview, { media, className: viewProps.className });
751
- const renderFallback = (error) => {
752
- const fetchError = error instanceof MediaFetchError ? error : null;
753
- if (typeof fallback === "function") return fallback(fetchError);
754
- if (fallback !== void 0) return fallback;
755
- return /* @__PURE__ */ jsx5(DefaultFallback, { media, error: fetchError, labels: text });
756
- };
757
- const expectedType = expectedContentType(media, contentType);
758
- const kind = previewKindOf(expectedType);
759
- if (!expectedType || !kind) return /* @__PURE__ */ jsx5(Fragment3, { children: renderFallback(unsupportedType(expectedType)) });
760
- const loadingNode = loading === void 0 ? /* @__PURE__ */ jsx5(LoadingStatus, { text: text.loading }) : loading;
761
- const props = { ...viewProps, media, expectedType, renderFallback, loading: loadingNode, labels: text };
762
- if (kind === "text") return /* @__PURE__ */ jsx5(TextPreview, { ...props });
763
- if (kind === "pdf") return /* @__PURE__ */ jsx5(PdfPreview, { ...props, maxBytes: props.maxBytes ?? DEFAULT_MAX_BYTES });
764
- return /* @__PURE__ */ jsx5(BinaryPreview, { ...props, kind });
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;
765
1082
  }
766
- var unsupportedType = (contentType) => new MediaFetchError("unsupported_type", `Can't preview ${contentType ?? "an unknown type"} inline`);
767
- function DefaultFallback({ media, error, labels }) {
768
- return /* @__PURE__ */ jsx5(DownloadLink, { media, title: error ? labels.error(error.code) : void 0, children: media.filename ?? labels.download });
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";
769
1092
  }
770
- function LoadingStatus({ text }) {
771
- return /* @__PURE__ */ jsx5("span", { role: "status", style: VISUALLY_HIDDEN, children: text });
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) })
1121
+ ] });
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
+ ] });
772
1375
  }
773
- function VideoPreview({ media, className }) {
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";
1585
+
1586
+ // src/preview.tsx
1587
+ import { mediaPath as mediaPath2 } from "@devultur/core";
1588
+
1589
+ // src/video-preview.tsx
1590
+ import React17 from "react";
1591
+ import { VideoPlayer } from "@devultur/react";
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();
774
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
+ );
775
1620
  if (!videoProps) return null;
776
- return /* @__PURE__ */ jsx5(VideoPlayer, { ...videoProps, className });
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";
1630
+ var DEFAULT_MAX_TEXT_BYTES = 1024 * 1024;
1631
+ var DEFAULT_MAX_BYTES = 50 * 1024 * 1024;
1632
+ function Preview({ media, contentType, fallback, loading, labels, ...viewProps }) {
1633
+ const text = usePreviewLabels(labels);
1634
+ if (!media) return null;
1635
+ const renderFallback = (error) => {
1636
+ const fetchError = error instanceof MediaFetchError ? error : null;
1637
+ if (typeof fallback === "function") return fallback(fetchError);
1638
+ if (fallback !== void 0) return fallback;
1639
+ return /* @__PURE__ */ jsx12(DefaultFallback, { media, error: fetchError, labels: text });
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
+ }
1652
+ const expectedType = expectedContentType(media, contentType);
1653
+ const kind = previewKindOf(expectedType);
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;
1656
+ const props = { ...viewProps, media, expectedType, renderFallback, loading: loadingNode, labels: text };
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 });
1660
+ }
1661
+ var unsupportedType = (contentType) => new MediaFetchError("unsupported_type", `Can't preview ${contentType ?? "an unknown type"} inline`);
1662
+ function DefaultFallback({ media, error, labels }) {
1663
+ return /* @__PURE__ */ jsx12(DownloadLink, { media, title: error ? labels.error(error.code) : void 0, children: media.filename ?? labels.download });
1664
+ }
1665
+ function LoadingStatus({ text }) {
1666
+ return /* @__PURE__ */ jsx12("span", { role: "status", style: VISUALLY_HIDDEN, children: text });
777
1667
  }
778
1668
  function BinaryPreview({
779
1669
  media,
@@ -790,13 +1680,13 @@ function BinaryPreview({
790
1680
  const isPublicImage = kind === "image" && media.visibility === "public";
791
1681
  const verified = useMediaObjectUrl(media, { enabled: !isPublicImage, contentType: expectedType, maxBytes });
792
1682
  const objectUrl = isPublicImage ? url(media) : verified.objectUrl;
793
- if (verified.error) return /* @__PURE__ */ jsx5(Fragment3, { children: renderFallback(verified.error) });
794
- if (!objectUrl) return /* @__PURE__ */ jsx5(Fragment3, { children: loading });
1683
+ if (verified.error) return /* @__PURE__ */ jsx12(Fragment7, { children: renderFallback(verified.error) });
1684
+ if (!objectUrl) return /* @__PURE__ */ jsx12(Fragment7, { children: loading });
795
1685
  const label = title ?? media.filename ?? labels.untitledMedia;
796
1686
  if (kind === "image") {
797
- 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 });
798
1688
  }
799
- 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 });
800
1690
  }
801
1691
  var decodeText = (bytes) => new TextDecoder().decode(bytes);
802
1692
  function TextPreview({
@@ -814,93 +1704,411 @@ function TextPreview({
814
1704
  contentType: expectedType,
815
1705
  decode: decodeText
816
1706
  });
817
- if (error) return /* @__PURE__ */ jsx5(Fragment3, { children: renderFallback(error) });
818
- if (text === null) return /* @__PURE__ */ jsx5(Fragment3, { children: loading });
819
- return /* @__PURE__ */ jsx5("pre", { title, className, children: text });
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 });
820
1710
  }
821
1711
 
822
- // src/react.tsx
823
- import { Fragment as Fragment4, jsx as jsx6 } from "react/jsx-runtime";
824
- var DevulturContext = React7.createContext(null);
825
- var DevulturApiContext = React7.createContext(null);
826
- var ProgressContext = React7.createContext(null);
827
- var REFRESH_BUFFER_MS = 5 * 60 * 1e3;
828
- var MIN_REFRESH_MS = 6e4;
829
- var DEFAULT_TTL_MS = DEFAULT_TOKEN_EXPIRY_SECONDS * 1e3;
830
- function extractTokenResult(result) {
831
- const tokenValue = typeof result === "string" ? result : result.token;
832
- const ttlMs = typeof result === "string" ? DEFAULT_TTL_MS : result.expiresIn * 1e3;
833
- return { tokenValue, ttlMs };
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
+ );
834
1760
  }
835
- function DevulturProvider({ api, baseUrl, children }) {
836
- const [token, setToken] = React7.useState(null);
837
- const [sessionSecret] = React7.useState(() => typeof crypto !== "undefined" ? crypto.randomUUID() : "");
838
- const issueViewerToken = useAction(api.issueViewerToken);
839
- const createUploadUrlAction = useAction(api.createUploadUrl);
840
- const deleteMediaAction = useAction(api.deleteMedia);
841
- const confirmUploadAction = useAction(api.confirmUpload ?? api.createUploadUrl);
842
- const deleteVideoAction = useAction(api.deleteVideo ?? api.deleteMedia);
843
- const refreshTimerRef = React7.useRef(null);
844
- const reissueInFlightRef = React7.useRef(null);
845
- const scheduleRefresh = React7.useCallback(
846
- (ttlMs) => {
847
- if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current);
848
- const refreshIn = Math.max(ttlMs - REFRESH_BUFFER_MS, MIN_REFRESH_MS);
849
- refreshTimerRef.current = setTimeout(() => {
850
- issueViewerToken({ sessionSecret }).then((result) => {
851
- const { tokenValue, ttlMs: nextTtl } = extractTokenResult(result);
852
- setToken(tokenValue);
853
- scheduleRefresh(nextTtl);
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 })
854
1882
  });
855
- }, refreshIn);
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
+ }
856
1896
  },
857
- [issueViewerToken, sessionSecret]
1897
+ [upload, patch, drop]
858
1898
  );
859
- React7.useEffect(() => {
860
- if (token) return;
861
- issueViewerToken({ sessionSecret }).then((result) => {
862
- const { tokenValue, ttlMs } = extractTokenResult(result);
863
- setToken(tokenValue);
864
- scheduleRefresh(ttlMs);
865
- });
866
- }, [token, issueViewerToken, scheduleRefresh, sessionSecret]);
867
- const reissueToken = React7.useCallback(() => {
868
- if (reissueInFlightRef.current) return reissueInFlightRef.current;
869
- const pending = issueViewerToken({ sessionSecret }).then((result) => {
870
- const { tokenValue, ttlMs } = extractTokenResult(result);
871
- setToken(tokenValue);
872
- scheduleRefresh(ttlMs);
873
- reissueInFlightRef.current = null;
874
- return tokenValue;
875
- }).catch(() => {
876
- reissueInFlightRef.current = null;
877
- return null;
878
- });
879
- reissueInFlightRef.current = pending;
880
- return pending;
881
- }, [issueViewerToken, scheduleRefresh, sessionSecret]);
882
- React7.useEffect(() => {
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;
883
1932
  return () => {
884
- if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current);
1933
+ for (const controller of active.values()) controller.abort();
885
1934
  };
886
1935
  }, []);
887
- const uploadUrl = React7.useCallback(
1936
+ return { uploads, start, cancel, retry, dismiss: drop };
1937
+ }
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
+
2079
+ // src/react.tsx
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);
2084
+ function DevulturProvider({ api, baseUrl, tokenArgs, issueViewerToken, children }) {
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(
888
2091
  async (file) => {
889
2092
  const result = await createUploadUrlAction({ filename: file.name, contentType: file.type });
890
2093
  return { url: result.url, key: result.key };
891
2094
  },
892
2095
  [createUploadUrlAction]
893
2096
  );
894
- const upload = React7.useCallback(
2097
+ const upload = React22.useCallback(
895
2098
  async (file, options) => {
896
2099
  const dimensions = await readImageDimensions(file);
2100
+ options?.signal?.throwIfAborted();
897
2101
  const target = await createUploadUrlAction({
898
2102
  filename: file.name,
899
2103
  contentType: file.type,
900
2104
  visibility: options?.visibility
901
2105
  });
902
2106
  await uploadFileWithProgress(target.url, file, { onBytes: options?.onProgress, signal: options?.signal });
903
- 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
+ }
904
2112
  return {
905
2113
  id: target.id,
906
2114
  kind: target.kind,
@@ -912,59 +2120,23 @@ function DevulturProvider({ api, baseUrl, children }) {
912
2120
  },
913
2121
  [createUploadUrlAction, confirmUploadAction, api.confirmUpload]
914
2122
  );
915
- const url = React7.useCallback(
916
- (media, options) => {
917
- if (!baseUrl) throw new Error("Devultur `url()` requires `baseUrl` on <DevulturProvider>");
918
- return mediaUrl2(baseUrl, media, { ...options, token: token ?? void 0 });
919
- },
920
- [baseUrl, token]
921
- );
922
- const getCaptionsText = React7.useCallback(
923
- async (media, locale) => {
924
- if (!baseUrl) throw new Error("Devultur `getCaptionsText()` requires `baseUrl` on <DevulturProvider>");
925
- const path = mediaCaptionPath(media, locale);
926
- if (!path) throw new Error("getCaptionsText: the reference is not a video");
927
- const isPrivate = media.visibility === "private";
928
- const fetchWith = (viewerToken) => fetch(buildMediaUrl(baseUrl, path, { token: viewerToken }), {
929
- headers: sessionSecret ? { [SESSION_HEADER2]: sessionSecret } : void 0
930
- });
931
- let response = await fetchWith(isPrivate ? token ?? void 0 : void 0);
932
- if (isPrivate && response.status === 401 && await isSessionMismatch(response)) {
933
- const fresh = await reissueToken();
934
- if (fresh) response = await fetchWith(fresh);
935
- }
936
- if (!response.ok) throw new Error(`Captions fetch failed: ${response.status}`);
937
- return response.text();
938
- },
939
- [baseUrl, token, reissueToken, sessionSecret]
940
- );
941
- const deleteMedia = React7.useCallback(
2123
+ const deleteMedia = React22.useCallback(
942
2124
  (media) => {
943
2125
  if (media.kind === "video" && api.deleteVideo) {
944
2126
  deleteVideoAction({ videoId: media.id, visibility: media.visibility }).catch(console.error);
945
2127
  return;
946
2128
  }
947
- deleteMediaAction({ key: mediaPath2(media) }).catch(console.error);
2129
+ deleteMediaAction({ key: mediaPath3(media) }).catch(console.error);
948
2130
  },
949
2131
  [deleteMediaAction, deleteVideoAction, api.deleteVideo]
950
2132
  );
951
- const devulturValue = React7.useMemo(
952
- () => ({
953
- token,
954
- baseUrl: baseUrl ?? null,
955
- sessionSecret,
956
- uploadUrl,
957
- upload,
958
- url,
959
- getCaptionsText,
960
- deleteMedia,
961
- reissueToken
962
- }),
963
- [token, baseUrl, sessionSecret, uploadUrl, upload, url, getCaptionsText, deleteMedia, reissueToken]
2133
+ const devulturValue = React22.useMemo(
2134
+ () => ({ ...session, uploadUrl, upload, deleteMedia }),
2135
+ [session, uploadUrl, upload, deleteMedia]
964
2136
  );
965
- 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 }) });
966
2138
  if (api.saveProgress && api.loadProgress && api.markComplete && api.resetProgress) {
967
- content = /* @__PURE__ */ jsx6(
2139
+ content = /* @__PURE__ */ jsx16(
968
2140
  ProgressBridge,
969
2141
  {
970
2142
  save: api.saveProgress,
@@ -988,13 +2160,13 @@ function ProgressBridge({
988
2160
  const saveMutation = useMutation(save);
989
2161
  const markCompleteMutation = useMutation(markComplete);
990
2162
  const resetMutation = useMutation(reset);
991
- const onSave = React7.useCallback(
2163
+ const onSave = React22.useCallback(
992
2164
  async (videoId, position) => {
993
2165
  await saveMutation({ videoId, position });
994
2166
  },
995
2167
  [saveMutation]
996
2168
  );
997
- const onLoad = React7.useCallback(
2169
+ const onLoad = React22.useCallback(
998
2170
  async (videoId) => {
999
2171
  const result = await convex.query(load, { videoId });
1000
2172
  if (!result) return null;
@@ -1005,46 +2177,69 @@ function ProgressBridge({
1005
2177
  },
1006
2178
  [convex, load]
1007
2179
  );
1008
- const onMarkComplete = React7.useCallback(
2180
+ const onMarkComplete = React22.useCallback(
1009
2181
  async (videoId) => {
1010
2182
  await markCompleteMutation({ videoId });
1011
2183
  },
1012
2184
  [markCompleteMutation]
1013
2185
  );
1014
- const onReset = React7.useCallback(
2186
+ const onReset = React22.useCallback(
1015
2187
  async (videoId) => {
1016
2188
  await resetMutation({ videoId });
1017
2189
  },
1018
2190
  [resetMutation]
1019
2191
  );
1020
- const value = React7.useMemo(
2192
+ const value = React22.useMemo(
1021
2193
  () => ({ save: onSave, load: onLoad, markComplete: onMarkComplete, reset: onReset }),
1022
2194
  [onSave, onLoad, onMarkComplete, onReset]
1023
2195
  );
1024
- return /* @__PURE__ */ jsx6(ProgressContext, { value, children });
2196
+ return /* @__PURE__ */ jsx16(ProgressContext, { value, children });
1025
2197
  }
1026
2198
  function useDevultur() {
1027
- const context = React7.use(DevulturContext);
1028
- 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>");
1029
2201
  return context;
1030
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
+ }
1031
2226
  function useDevulturProgress() {
1032
- const context = React7.use(ProgressContext);
2227
+ const context = React22.use(ProgressContext);
1033
2228
  if (!context) throw new Error("useDevulturProgress requires progress API in DevulturProvider");
1034
2229
  return context;
1035
2230
  }
1036
2231
  function useMediaUrl(key, options) {
1037
2232
  const { token, baseUrl } = useDevultur();
1038
- return React7.useMemo(() => {
2233
+ return React22.useMemo(() => {
1039
2234
  if (!key) return null;
1040
2235
  if (!baseUrl) throw new Error("useMediaUrl requires `baseUrl` on <DevulturProvider>");
1041
2236
  const isPublic = visibilityFromKey(key) === "public";
1042
- 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 });
1043
2238
  }, [key, baseUrl, token, options]);
1044
2239
  }
1045
- async function confirmUploadBestEffort(confirmUpload, key, file, dimensions) {
2240
+ async function confirmUploadBestEffort(confirmUpload, key, file, hints) {
1046
2241
  try {
1047
- await confirmUpload({ key, filename: file.name, ...dimensions });
2242
+ await confirmUpload({ key, filename: file.name, ...hints });
1048
2243
  } catch (error) {
1049
2244
  console.warn("Devultur: could not record the upload; it is recorded later", error);
1050
2245
  }
@@ -1063,122 +2258,45 @@ async function readImageDimensions(file) {
1063
2258
  function useVideoProps(media, options) {
1064
2259
  const { token, baseUrl, sessionSecret } = useDevultur();
1065
2260
  const locales = options?.locales;
1066
- return React7.useMemo(() => {
2261
+ return React22.useMemo(() => {
1067
2262
  if (media?.kind !== "video") return null;
1068
2263
  if (!baseUrl) throw new Error("useVideoProps requires `baseUrl` on <DevulturProvider>");
1069
2264
  const viewerToken = media.visibility === "private" ? token ?? void 0 : void 0;
1070
2265
  const captions = (locales ?? []).flatMap((locale) => {
1071
- const path = mediaCaptionPath(media, locale);
2266
+ const path = mediaCaptionPath2(media, locale);
1072
2267
  if (!path) return [];
1073
2268
  const label = CaptionLocaleLabel[locale] ?? locale;
1074
- return [{ locale, label, src: buildMediaUrl(baseUrl, path, { token: viewerToken }) }];
2269
+ return [{ locale, label, src: buildMediaUrl2(baseUrl, path, { token: viewerToken }) }];
1075
2270
  });
1076
2271
  return {
1077
- src: buildMediaUrl(baseUrl, mediaPath2(media), { token: viewerToken }),
2272
+ src: buildMediaUrl2(baseUrl, mediaPath3(media), { token: viewerToken }),
1078
2273
  token: viewerToken,
1079
2274
  captions,
1080
2275
  sessionSecret: media.visibility === "private" ? sessionSecret : void 0
1081
2276
  };
1082
2277
  }, [media, baseUrl, token, locales, sessionSecret]);
1083
2278
  }
1084
- function Image({
1085
- media,
1086
- width,
1087
- height,
1088
- fit,
1089
- format,
1090
- quality,
1091
- fallback = null,
1092
- externalFallback,
1093
- render,
1094
- alt,
1095
- ...imgProps
1096
- }) {
1097
- const { url, token, baseUrl, reissueToken, sessionSecret } = useDevultur();
1098
- const [failed, setFailed] = React7.useState(false);
1099
- const [blobSrc, setBlobSrc] = React7.useState(null);
1100
- const sessionRef = React7.useRef({ token, reissueToken });
1101
- React7.useEffect(() => {
1102
- sessionRef.current = { token, reissueToken };
1103
- });
1104
- const isPrivate = media?.visibility === "private";
1105
- const hasToken = token !== null;
1106
- React7.useEffect(() => {
1107
- setFailed(false);
1108
- setBlobSrc(null);
1109
- }, [media]);
1110
- React7.useEffect(() => {
1111
- if (!isPrivate || failed || !media || !hasToken) return;
1112
- let cancelled = false;
1113
- let objectUrl = null;
1114
- if (!baseUrl) throw new Error("Devultur `url()` requires `baseUrl` on <DevulturProvider>");
1115
- const transforms = { width, height, fit, format, quality };
1116
- (async () => {
1117
- try {
1118
- const response = await fetchMedia({
1119
- buildUrl: (viewerToken) => mediaUrl2(baseUrl, media, { ...transforms, token: viewerToken }),
1120
- token: sessionRef.current.token,
1121
- sessionSecret,
1122
- reissueToken: sessionRef.current.reissueToken
1123
- });
1124
- if (response.ok) {
1125
- objectUrl = URL.createObjectURL(await response.blob());
1126
- if (cancelled) {
1127
- URL.revokeObjectURL(objectUrl);
1128
- return;
1129
- }
1130
- setBlobSrc(objectUrl);
1131
- return;
1132
- }
1133
- } catch {
1134
- }
1135
- if (!cancelled) setFailed(true);
1136
- })();
1137
- return () => {
1138
- cancelled = true;
1139
- if (objectUrl) URL.revokeObjectURL(objectUrl);
1140
- };
1141
- }, [isPrivate, failed, media, hasToken, baseUrl, sessionSecret, width, height, fit, format, quality]);
1142
- const handleError = React7.useCallback(() => setFailed(true), []);
1143
- let src;
1144
- if (failed || !media) {
1145
- src = externalFallback ?? null;
1146
- } else if (isPrivate) {
1147
- src = blobSrc;
1148
- } else {
1149
- src = url(media, { width, height, fit, format, quality });
1150
- }
1151
- if (!src) return /* @__PURE__ */ jsx6(Fragment4, { children: fallback });
1152
- if (render) {
1153
- return React7.cloneElement(render, {
1154
- style: { ...render.props.style, backgroundImage: `url("${src}")` }
1155
- });
1156
- }
1157
- return /* @__PURE__ */ jsx6(
1158
- "img",
1159
- {
1160
- src,
1161
- alt,
1162
- width: width ?? media?.width,
1163
- height: height ?? media?.height,
1164
- onError: handleError,
1165
- ...imgProps
1166
- }
1167
- );
1168
- }
1169
- function DownloadLink({ media, filename, children, ...anchorProps }) {
1170
- const { url, token, baseUrl, sessionSecret, reissueToken } = useDevultur();
1171
- const handleClick = React7.useCallback(
2279
+ function DownloadLink({ media, filename, onDownloadError, children, ...anchorProps }) {
2280
+ const { url, token, tokenStatus, tokenError, baseUrl, sessionSecret, reissueToken } = useDevultur();
2281
+ const handleClick = React22.useCallback(
1172
2282
  async (event) => {
1173
2283
  if (media?.visibility !== "private" || !baseUrl) return;
1174
2284
  event.preventDefault();
2285
+ if (tokenStatus === "error") {
2286
+ onDownloadError?.(tokenUnavailableError(tokenError));
2287
+ return;
2288
+ }
1175
2289
  const response = await fetchMedia({
1176
- buildUrl: (viewerToken) => mediaUrl2(baseUrl, media, { download: filename ?? true, token: viewerToken }),
2290
+ buildUrl: (viewerToken) => mediaUrl4(baseUrl, media, { download: filename ?? true, token: viewerToken }),
1177
2291
  token,
1178
2292
  sessionSecret,
1179
2293
  reissueToken
1180
2294
  });
1181
- 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
+ }
1182
2300
  const objectUrl = URL.createObjectURL(await response.blob());
1183
2301
  const anchor = document.createElement("a");
1184
2302
  anchor.href = objectUrl;
@@ -1186,13 +2304,13 @@ function DownloadLink({ media, filename, children, ...anchorProps }) {
1186
2304
  anchor.click();
1187
2305
  URL.revokeObjectURL(objectUrl);
1188
2306
  },
1189
- [media, baseUrl, token, filename, sessionSecret, reissueToken]
2307
+ [media, baseUrl, token, tokenStatus, tokenError, filename, sessionSecret, reissueToken, onDownloadError]
1190
2308
  );
1191
2309
  if (!media) return null;
1192
- 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 });
1193
2311
  }
1194
2312
  function useDevulturMedia(key) {
1195
- const apiCtx = React7.use(DevulturApiContext);
2313
+ const apiCtx = React22.use(DevulturApiContext);
1196
2314
  if (!apiCtx?.getMedia) {
1197
2315
  throw new Error("useDevulturMedia requires getMedia in DevulturProvider api");
1198
2316
  }
@@ -1249,7 +2367,7 @@ function useVideoState(media, options) {
1249
2367
  const { token, baseUrl, sessionSecret } = useDevultur();
1250
2368
  const status = useDevulturMedia(media?.kind === "video" ? media.id : null);
1251
2369
  const locales = options?.locales;
1252
- return React7.useMemo(() => {
2370
+ return React22.useMemo(() => {
1253
2371
  if (media?.kind !== "video") return { phase: "idle" };
1254
2372
  if (status.isFailed) {
1255
2373
  return {
@@ -1262,12 +2380,12 @@ function useVideoState(media, options) {
1262
2380
  if (status.isReady) {
1263
2381
  if (!baseUrl) throw new Error("useVideoState requires `baseUrl` on <DevulturProvider>");
1264
2382
  const viewerToken = media.visibility === "private" ? token ?? void 0 : void 0;
1265
- const playlist = buildMediaUrl(baseUrl, mediaPath2(media), { token: viewerToken });
2383
+ const playlist = buildMediaUrl2(baseUrl, mediaPath3(media), { token: viewerToken });
1266
2384
  const captions = (locales ?? status.captionLocales).flatMap((locale) => {
1267
- const path = mediaCaptionPath(media, locale);
2385
+ const path = mediaCaptionPath2(media, locale);
1268
2386
  if (!path) return [];
1269
2387
  const label = CaptionLocaleLabel[locale] ?? locale;
1270
- return [{ locale, label, src: buildMediaUrl(baseUrl, path, { token: viewerToken }) }];
2388
+ return [{ locale, label, src: buildMediaUrl2(baseUrl, path, { token: viewerToken }) }];
1271
2389
  });
1272
2390
  return {
1273
2391
  phase: "ready",
@@ -1293,23 +2411,22 @@ function useVideoState(media, options) {
1293
2411
  }
1294
2412
  function useMediaUpload(options) {
1295
2413
  const { visibility, onComplete, onError, replaces, process, locales } = options ?? {};
1296
- const api = React7.use(DevulturApiContext);
1297
- if (!api) throw new Error("useMediaUpload must be used within <DevulturProvider>");
1298
- const createUploadUrlAction = useAction(api.createUploadUrl);
1299
- const deleteMediaAction = useAction(api.deleteMedia);
1300
- const confirmUploadAction = useAction(api.confirmUpload ?? api.createUploadUrl);
1301
- const processVideoAction = useAction(api.processVideo ?? api.createUploadUrl);
1302
- const [phase, setPhase] = React7.useState("idle");
1303
- const [percent, setPercent] = React7.useState(0);
1304
- const [currentFile, setCurrentFile] = React7.useState(null);
1305
- const [media, setMedia] = React7.useState(null);
1306
- const [uploadFailure, setUploadFailure] = React7.useState(null);
1307
- const [processRequested, setProcessRequested] = React7.useState(false);
1308
- const abortRef = React7.useRef(null);
1309
- const lastFileRef = React7.useRef(null);
1310
- const lastKeyRef = React7.useRef(null);
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);
1311
2428
  const videoState = useVideoState(media, { locales });
1312
- const triggerProcess = React7.useCallback(
2429
+ const triggerProcess = React22.useCallback(
1313
2430
  (key) => {
1314
2431
  if (!api.processVideo) {
1315
2432
  throw new Error("useMediaUpload: processing a video requires `processVideo` in the <DevulturProvider> api");
@@ -1322,7 +2439,7 @@ function useMediaUpload(options) {
1322
2439
  },
1323
2440
  [api.processVideo, processVideoAction]
1324
2441
  );
1325
- const runUpload = React7.useCallback(
2442
+ const runUpload = React22.useCallback(
1326
2443
  async (file) => {
1327
2444
  const controller = new AbortController();
1328
2445
  abortRef.current = controller;
@@ -1374,7 +2491,7 @@ function useMediaUpload(options) {
1374
2491
  };
1375
2492
  setMedia(uploaded);
1376
2493
  setPhase("uploaded");
1377
- if (replaces) deleteMediaAction({ key: mediaPath2(replaces) }).catch(() => {
2494
+ if (replaces) deleteMediaAction({ key: mediaPath3(replaces) }).catch(() => {
1378
2495
  });
1379
2496
  onComplete?.(uploaded);
1380
2497
  if ((process ?? true) && uploaded.kind === "video") triggerProcess(result.key);
@@ -1393,10 +2510,10 @@ function useMediaUpload(options) {
1393
2510
  onError
1394
2511
  ]
1395
2512
  );
1396
- const cancel = React7.useCallback(() => {
2513
+ const cancel = React22.useCallback(() => {
1397
2514
  abortRef.current?.abort();
1398
2515
  }, []);
1399
- const reset = React7.useCallback(() => {
2516
+ const reset = React22.useCallback(() => {
1400
2517
  abortRef.current?.abort();
1401
2518
  setPhase("idle");
1402
2519
  setPercent(0);
@@ -1405,15 +2522,15 @@ function useMediaUpload(options) {
1405
2522
  setUploadFailure(null);
1406
2523
  setProcessRequested(false);
1407
2524
  }, []);
1408
- const retry = React7.useCallback(() => {
2525
+ const retry = React22.useCallback(() => {
1409
2526
  if (phase === "upload-failed" && lastFileRef.current) {
1410
2527
  runUpload(lastFileRef.current);
1411
2528
  return;
1412
2529
  }
1413
2530
  if (lastKeyRef.current) triggerProcess(lastKeyRef.current);
1414
2531
  }, [phase, runUpload, triggerProcess]);
1415
- React7.useEffect(() => () => abortRef.current?.abort(), []);
1416
- const state = React7.useMemo(
2532
+ React22.useEffect(() => () => abortRef.current?.abort(), []);
2533
+ const state = React22.useMemo(
1417
2534
  () => deriveUploadState(
1418
2535
  { phase, percent, file: currentFile, failure: uploadFailure, media, processRequested },
1419
2536
  videoState,
@@ -1421,23 +2538,38 @@ function useMediaUpload(options) {
1421
2538
  ),
1422
2539
  [phase, percent, currentFile, uploadFailure, media, processRequested, videoState, retry]
1423
2540
  );
1424
- const upload = React7.useCallback((file) => runUpload(file), [runUpload]);
2541
+ const upload = React22.useCallback((file) => runUpload(file), [runUpload]);
1425
2542
  return { upload, state, cancel, reset };
1426
2543
  }
1427
2544
  export {
2545
+ AttachmentCard,
1428
2546
  CropUpload,
2547
+ DevulturCapabilityError,
1429
2548
  DevulturProvider,
2549
+ DevulturViewerProvider,
2550
+ DocumentLink,
1430
2551
  DownloadLink,
1431
2552
  Image,
1432
2553
  MediaFetchError,
2554
+ MediaLibrary,
2555
+ MediaPicker,
1433
2556
  PdfPreviewProvider,
1434
2557
  PdfThumbnail,
1435
2558
  Preview,
2559
+ defaultAttachmentLabels,
2560
+ defaultMediaLibraryLabels,
2561
+ defaultMediaPickerLabels,
1436
2562
  defaultPreviewLabels,
2563
+ formatDuration,
2564
+ formatFileSize,
2565
+ matchesAccept,
1437
2566
  useDevultur,
2567
+ useDevulturApi,
1438
2568
  useDevulturMedia,
1439
2569
  useDevulturProgress,
2570
+ useMediaLibrary,
1440
2571
  useMediaObjectUrl,
2572
+ useMediaPicker,
1441
2573
  useMediaUpload,
1442
2574
  useMediaUrl,
1443
2575
  useVideoProps,