@devultur/convex 1.3.0 → 1.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -86,6 +86,42 @@ The PUT goes through `XMLHttpRequest` (`fetch` reports no upload progress), in p
86
86
  optional field of `DevulturMedia` and of `dv.media()`. It is informational: delivery URLs are derived from `id`,
87
87
  `kind`, `visibility` and `filename`.
88
88
 
89
+ ## Multiple workspaces
90
+
91
+ An app that shows private media from several workspaces (an admin console that previews each one, for example) can
92
+ send extra arguments with every viewer token request. Pass them in `tokenArgs`, and pass your own action in
93
+ `issueViewerToken`: the component's `issueViewerToken` accepts only `sessionSecret`.
94
+
95
+ ```typescript
96
+ // convex/admin.ts
97
+ import { v } from "convex/values";
98
+ import { components } from "./_generated/api";
99
+ import { action } from "./_generated/server";
100
+
101
+ export const issueWorkspaceViewerToken = action({
102
+ args: { sessionSecret: v.string(), workspaceId: v.id("workspaces") },
103
+ handler: async (ctx, { sessionSecret, workspaceId }) => {
104
+ await assertWorkspaceAccess(ctx, workspaceId); // your own check
105
+ return ctx.runAction(components.devultur.public.issueViewerToken, { sessionSecret });
106
+ },
107
+ });
108
+ ```
109
+
110
+ ```tsx
111
+ <DevulturProvider
112
+ api={api.devultur}
113
+ issueViewerToken={api.admin.issueWorkspaceViewerToken}
114
+ tokenArgs={{ workspaceId }}
115
+ >
116
+ <WorkspaceMedia />
117
+ </DevulturProvider>
118
+ ```
119
+
120
+ The provider sends `{ sessionSecret, ...tokenArgs }` on the first issuance, on each renewal and on each re-issue.
121
+ `tokenArgs` are compared by value, so a new object with the same values changes nothing. When they change, the
122
+ provider drops the current token, private media wait for the new one, and a response for the previous arguments is
123
+ ignored. Your action returns what the component's action returns: `{ token, expiresIn }` or the token string.
124
+
89
125
  ## Media library
90
126
 
91
127
  Every upload, whatever its kind, gets a record in the component's `media` table once its bytes are stored, so
package/dist/react.d.ts CHANGED
@@ -3,9 +3,18 @@ import { Visibility, DevulturMedia, MediaUrlOptions, ProcessingPhase, FailureRea
3
3
  import { BakeOutputFormat, MediaUploadState, MediaUploadError, UploadProgress } from '@devultur/react';
4
4
  export { MediaUploadError, MediaUploadState, UploadProgress } from '@devultur/react';
5
5
  import { FunctionReference } from 'convex/server';
6
+ import { Value } from 'convex/values';
6
7
  import { a as PdfjsLoader } from './pdf-render-CiCK6V1X.js';
7
8
  export { P as PdfjsModule } from './pdf-render-CiCK6V1X.js';
8
9
 
10
+ /** What a viewer token action returns: the token with its lifetime, or the bare token (default lifetime). */
11
+ type ViewerTokenResult = {
12
+ token: string;
13
+ expiresIn: number;
14
+ } | string;
15
+ /** Extra arguments sent with `sessionSecret` to the viewer token action. */
16
+ type ViewerTokenArgs = Record<string, Value>;
17
+
9
18
  interface CropUploadRenderProps {
10
19
  /** The state machine of the full cycle (idle → uploading → ready / failed). */
11
20
  state: MediaUploadState;
@@ -107,6 +116,32 @@ interface MediaObjectUrl {
107
116
  */
108
117
  declare function useMediaObjectUrl(media: DevulturMedia | null | undefined, options?: UseMediaObjectUrlOptions): MediaObjectUrl;
109
118
 
119
+ /**
120
+ * Every string `<Preview>`, `<PdfThumbnail>` and their PDF pages show or announce. Pass any subset as `labels` on a
121
+ * component or on `PdfPreviewProvider`; the component's wins over the provider's, which wins over the English defaults.
122
+ */
123
+ interface PreviewLabels {
124
+ /** Accessible name of a pdf.js document: its title and page count. */
125
+ document: (title: string, pageCount: number) => string;
126
+ /** Accessible name of one pdf.js page. */
127
+ page: (page: number, pageCount: number) => string;
128
+ /** Announced (visually hidden) while a preview loads, when no `loading` is passed. */
129
+ loading: string;
130
+ /** Note after the last rendered page when a PDF has more than `maxPages`; `fallback` follows it. */
131
+ pageCap: (shown: number, total: number) => string;
132
+ /** `title` of the default fallback's download link when a fetch was refused or the type can't be previewed. */
133
+ error: (code: MediaFetchErrorCode) => string;
134
+ /** Accessible name of a `<PdfThumbnail>`, from its title. */
135
+ thumbnail: (title: string) => string;
136
+ /** Name of a PDF (pdf.js document, `<iframe>` title, thumbnail) with no `title` and no filename. */
137
+ untitledPdf: string;
138
+ /** Name of an image or audio preview with no `title` and no filename. */
139
+ untitledMedia: string;
140
+ /** Text of the default fallback's download link when the media has no filename. */
141
+ download: string;
142
+ }
143
+ declare const defaultPreviewLabels: PreviewLabels;
144
+
110
145
  interface PdfPreviewConfig {
111
146
  /**
112
147
  * Imports pdf.js. Without it, PDFs render in an `<iframe>` (the browser's own viewer) and `<PdfThumbnail>` renders
@@ -115,15 +150,18 @@ interface PdfPreviewConfig {
115
150
  pdfjs?: PdfjsLoader;
116
151
  /** URL of `pdf.worker.min.mjs`, set on `GlobalWorkerOptions.workerSrc` before a document opens. */
117
152
  workerSrc?: string;
153
+ /** Labels for every `<Preview>` and `<PdfThumbnail>` below it; a component's own `labels` win over these. */
154
+ labels?: Partial<PreviewLabels>;
118
155
  }
119
156
  /**
120
- * Renders the PDFs of every `<Preview>` and `<PdfThumbnail>` below it with pdf.js, on a `<canvas>`.
157
+ * Renders the PDFs of every `<Preview>` and `<PdfThumbnail>` below it with pdf.js, on a `<canvas>`, and gives them
158
+ * default `labels`.
121
159
  *
122
160
  * @example
123
161
  * import { loadPdfjs } from "@devultur/convex/pdfjs";
124
162
  * <PdfPreviewProvider pdfjs={loadPdfjs}>{children}</PdfPreviewProvider>
125
163
  */
126
- declare function PdfPreviewProvider({ pdfjs, workerSrc, children }: PdfPreviewConfig & {
164
+ declare function PdfPreviewProvider({ pdfjs, workerSrc, labels, children, }: PdfPreviewConfig & {
127
165
  children: React.ReactNode;
128
166
  }): React.JSX.Element;
129
167
 
@@ -140,11 +178,13 @@ interface PdfThumbnailProps {
140
178
  fallback?: React.ReactNode;
141
179
  /** Shown until page 1 is drawn, including while off-screen. Default: nothing. */
142
180
  loading?: React.ReactNode;
143
- /** Accessible name of the thumbnail. Default: the filename. */
181
+ /** Title the accessible name is built from (`labels.thumbnail`). Default: the filename. */
144
182
  title?: string;
145
183
  className?: string;
146
184
  /** Overrides the provider's `workerSrc`. */
147
185
  pdfWorkerSrc?: string;
186
+ /** Overrides `thumbnail` and `untitledPdf`; wins over `PdfPreviewProvider`'s `labels`. English by default. */
187
+ labels?: Partial<PreviewLabels>;
148
188
  }
149
189
  /**
150
190
  * Page 1 of a PDF on a small canvas, for lists and upload queues. Nothing is fetched until it nears the viewport.
@@ -164,9 +204,14 @@ interface PreviewProps {
164
204
  maxBytes?: number;
165
205
  /** Size cap for text previews. Default 1 MiB. */
166
206
  maxTextBytes?: number;
167
- /** Shown for types that don't preview inline and when the fetch fails. Default: a `<DownloadLink>`. */
168
- fallback?: React.ReactNode;
169
- /** Shown while a private file is loading. Default: nothing. */
207
+ /**
208
+ * Shown for types that don't preview inline and when the fetch fails. Default: a `<DownloadLink>`.
209
+ * As a function it receives why: a `MediaFetchError` when the fetch was refused (`http_error`, `too_large`,
210
+ * `type_mismatch`) or the type doesn't preview inline (`unsupported_type`, nothing fetched), `null` otherwise
211
+ * (a network failure, a PDF pdf.js can't open, or the link after the `maxPages` note).
212
+ */
213
+ fallback?: React.ReactNode | ((error: MediaFetchError | null) => React.ReactNode);
214
+ /** Shown while a file loads. Default: `labels.loading`, visually hidden, in a `role="status"`. `null` → nothing. */
170
215
  loading?: React.ReactNode;
171
216
  /** Accessible name of the preview (`alt`, `title`, `aria-label`). Default: the filename (text: none). */
172
217
  title?: string;
@@ -178,6 +223,8 @@ interface PreviewProps {
178
223
  maxPages?: number;
179
224
  /** URL of pdf.js's worker for this preview; overrides `PdfPreviewProvider`'s `workerSrc`. */
180
225
  pdfWorkerSrc?: string;
226
+ /** Overrides any string shown or announced; wins over `PdfPreviewProvider`'s `labels`. English by default. */
227
+ labels?: Partial<PreviewLabels>;
181
228
  }
182
229
  /**
183
230
  * Renders a media inline by its content type: images in an `<img>`, PDFs on `<canvas>` pages with pdf.js under a
@@ -188,7 +235,7 @@ interface PreviewProps {
188
235
  * @example
189
236
  * <Preview media={document.file} maxBytes={20_000_000} className="h-[80vh] w-full" />
190
237
  */
191
- declare function Preview({ media, contentType, fallback, ...viewProps }: PreviewProps): React.JSX.Element | null;
238
+ declare function Preview({ media, contentType, fallback, loading, labels, ...viewProps }: PreviewProps): React.JSX.Element | null;
192
239
 
193
240
  /** How a content type renders inline: `<img>`, a PDF view (pdf.js canvases or `<iframe>`), `<audio>` or `<pre>`. */
194
241
  type PreviewKind = "image" | "pdf" | "audio" | "text";
@@ -266,9 +313,23 @@ interface DevulturProviderProps {
266
313
  api: DevulturApi;
267
314
  /** Media worker base (e.g. `https://media.your-domain.com`). Required for `useMediaUrl`. */
268
315
  baseUrl?: string;
316
+ /**
317
+ * Extra arguments sent with `sessionSecret` on every viewer token issuance (first, renewal and re-issue), for
318
+ * example `{ workspaceId }`. Compared by value: when they change, the current token is dropped and a new one is
319
+ * issued. The component's own `issueViewerToken` accepts only `sessionSecret`, so pass your own action in
320
+ * `issueViewerToken` that takes these arguments.
321
+ */
322
+ tokenArgs?: ViewerTokenArgs;
323
+ /**
324
+ * Your own viewer token action, used instead of `api.issueViewerToken`. It receives `{ sessionSecret, ...tokenArgs }`
325
+ * and returns what the component's `issueViewerToken` returns.
326
+ */
327
+ issueViewerToken?: FunctionReference<"action", "public", {
328
+ sessionSecret: string;
329
+ }>;
269
330
  children: React.ReactNode;
270
331
  }
271
- declare function DevulturProvider({ api, baseUrl, children }: DevulturProviderProps): React.JSX.Element;
332
+ declare function DevulturProvider({ api, baseUrl, tokenArgs, issueViewerToken, children }: DevulturProviderProps): React.JSX.Element;
272
333
  declare function useDevultur(): DevulturContextValue;
273
334
  declare function useDevulturProgress(): ProgressContextValue;
274
335
  /**
@@ -433,4 +494,4 @@ interface UseMediaUploadReturn {
433
494
  */
434
495
  declare function useMediaUpload(options?: UseMediaUploadOptions): UseMediaUploadReturn;
435
496
 
436
- export { type CaptionTrack, CropUpload, type CropUploadLabels, type CropUploadProps, type CropUploadRenderProps, DevulturProvider, DownloadLink, Image, MediaFetchError, type MediaFetchErrorCode, type MediaObjectUrl, type PdfPreviewConfig, PdfPreviewProvider, PdfThumbnail, type PdfThumbnailProps, PdfjsLoader, Preview, type PreviewKind, type PreviewProps, type UploadedMedia, type UseMediaObjectUrlOptions, type UseMediaUploadOptions, type UseMediaUploadReturn, type VideoProps, type VideoState, useDevultur, useDevulturMedia, useDevulturProgress, useMediaObjectUrl, useMediaUpload, useMediaUrl, useVideoProps, useVideoState };
497
+ export { type CaptionTrack, CropUpload, type CropUploadLabels, type CropUploadProps, type CropUploadRenderProps, DevulturProvider, DownloadLink, Image, MediaFetchError, type MediaFetchErrorCode, type MediaObjectUrl, type PdfPreviewConfig, PdfPreviewProvider, PdfThumbnail, type PdfThumbnailProps, PdfjsLoader, Preview, type PreviewKind, type PreviewLabels, type PreviewProps, type UploadedMedia, type UseMediaObjectUrlOptions, type UseMediaUploadOptions, type UseMediaUploadReturn, type VideoProps, type VideoState, type ViewerTokenArgs, type ViewerTokenResult, defaultPreviewLabels, useDevultur, useDevulturMedia, useDevulturProgress, useMediaObjectUrl, useMediaUpload, useMediaUrl, useVideoProps, useVideoState };
package/dist/react.js CHANGED
@@ -1,11 +1,10 @@
1
1
  "use client";
2
2
 
3
3
  // src/react.tsx
4
- import React7 from "react";
4
+ import React8 from "react";
5
5
  import {
6
6
  buildMediaUrl,
7
7
  CaptionLocaleLabel,
8
- DEFAULT_TOKEN_EXPIRY_SECONDS,
9
8
  MediaStatus,
10
9
  mediaCaptionPath,
11
10
  mediaPath as mediaPath2,
@@ -214,8 +213,76 @@ function deriveUploadState(local, videoState, retry) {
214
213
  }
215
214
  }
216
215
 
217
- // src/crop-upload.tsx
216
+ // src/viewer-token.ts
218
217
  import React from "react";
218
+ import { DEFAULT_TOKEN_EXPIRY_SECONDS } from "@devultur/core";
219
+ import { convexToJson } from "convex/values";
220
+ var REFRESH_BUFFER_MS = 5 * 60 * 1e3;
221
+ var MIN_REFRESH_MS = 6e4;
222
+ var DEFAULT_TTL_MS = DEFAULT_TOKEN_EXPIRY_SECONDS * 1e3;
223
+ function extractTokenResult(result) {
224
+ const tokenValue = typeof result === "string" ? result : result.token;
225
+ const ttlMs = typeof result === "string" ? DEFAULT_TTL_MS : result.expiresIn * 1e3;
226
+ return { tokenValue, ttlMs };
227
+ }
228
+ function sortKeys(json) {
229
+ if (Array.isArray(json)) return json.map(sortKeys);
230
+ if (json === null || typeof json !== "object") return json;
231
+ return Object.fromEntries(
232
+ Object.keys(json).sort().map((name) => [name, sortKeys(json[name])])
233
+ );
234
+ }
235
+ function tokenArgsKey(tokenArgs) {
236
+ return JSON.stringify(sortKeys(convexToJson(tokenArgs ?? {})));
237
+ }
238
+ function useViewerToken({ issueViewerToken, sessionSecret, tokenArgs }) {
239
+ const argsKey = tokenArgsKey(tokenArgs);
240
+ const stableArgs = React.useMemo(() => tokenArgs ?? {}, [argsKey]);
241
+ const [issued, setIssued] = React.useState(null);
242
+ const token = issued?.argsKey === argsKey ? issued.token : null;
243
+ const currentArgsKeyRef = React.useRef(argsKey);
244
+ const refreshTimerRef = React.useRef(null);
245
+ const inFlightRef = React.useRef(null);
246
+ React.useEffect(() => {
247
+ currentArgsKeyRef.current = argsKey;
248
+ }, [argsKey]);
249
+ React.useEffect(() => {
250
+ return () => {
251
+ if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current);
252
+ };
253
+ }, []);
254
+ const request = React.useCallback(() => {
255
+ const renew = () => void request();
256
+ return issueViewerToken({ ...stableArgs, sessionSecret }).then((result) => {
257
+ if (currentArgsKeyRef.current !== argsKey) return null;
258
+ const { tokenValue, ttlMs } = extractTokenResult(result);
259
+ setIssued({ token: tokenValue, argsKey });
260
+ if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current);
261
+ refreshTimerRef.current = setTimeout(renew, Math.max(ttlMs - REFRESH_BUFFER_MS, MIN_REFRESH_MS));
262
+ return tokenValue;
263
+ });
264
+ }, [issueViewerToken, stableArgs, argsKey, sessionSecret]);
265
+ const issueOnce = React.useCallback(() => {
266
+ const inFlight = inFlightRef.current;
267
+ if (inFlight?.argsKey === argsKey) return inFlight.promise;
268
+ const promise = request();
269
+ const settle = () => {
270
+ if (inFlightRef.current?.promise === promise) inFlightRef.current = null;
271
+ };
272
+ promise.then(settle, settle);
273
+ inFlightRef.current = { argsKey, promise };
274
+ return promise;
275
+ }, [request, argsKey]);
276
+ React.useEffect(() => {
277
+ if (token) return;
278
+ issueOnce();
279
+ }, [token, issueOnce]);
280
+ const reissueToken = React.useCallback(() => issueOnce().catch(() => null), [issueOnce]);
281
+ return { token, reissueToken };
282
+ }
283
+
284
+ // src/crop-upload.tsx
285
+ import React2 from "react";
219
286
  import { ImageCropper } from "@devultur/react";
220
287
  import { jsx, jsxs } from "react/jsx-runtime";
221
288
  var DEFAULT_LABELS = {
@@ -239,18 +306,18 @@ function CropUpload({
239
306
  children
240
307
  }) {
241
308
  const text = { ...DEFAULT_LABELS, ...labels };
242
- const [pendingFile, setPendingFile] = React.useState(null);
243
- const inputRef = React.useRef(null);
309
+ const [pendingFile, setPendingFile] = React2.useState(null);
310
+ const inputRef = React2.useRef(null);
244
311
  const { upload, state, cancel, reset } = useMediaUpload({ visibility, replaces, onComplete });
245
- const pickFile = React.useCallback(() => inputRef.current?.click(), []);
246
- const handleCrop = React.useCallback(
312
+ const pickFile = React2.useCallback(() => inputRef.current?.click(), []);
313
+ const handleCrop = React2.useCallback(
247
314
  (baked) => {
248
315
  setPendingFile(null);
249
316
  upload(baked);
250
317
  },
251
318
  [upload]
252
319
  );
253
- const resetAll = React.useCallback(() => {
320
+ const resetAll = React2.useCallback(() => {
254
321
  setPendingFile(null);
255
322
  reset();
256
323
  }, [reset]);
@@ -303,7 +370,7 @@ function CropUpload({
303
370
  }
304
371
 
305
372
  // src/media-resource.ts
306
- import React2 from "react";
373
+ import React3 from "react";
307
374
  import { mediaPath, mediaUrl } from "@devultur/core";
308
375
  var IDLE = { value: null, contentType: null, isLoading: false, error: null };
309
376
  var LOADING = { value: null, contentType: null, isLoading: true, error: null };
@@ -313,12 +380,12 @@ function useMediaResource(media, options) {
313
380
  const isPrivate = media?.visibility === "private";
314
381
  const isReady = !isPrivate || devultur.token !== null;
315
382
  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(() => {
383
+ const [settled, setSettled] = React3.useState(null);
384
+ const inputsRef = React3.useRef({ media, devultur, options });
385
+ React3.useEffect(() => {
319
386
  inputsRef.current = { media, devultur, options };
320
387
  });
321
- React2.useEffect(() => {
388
+ React3.useEffect(() => {
322
389
  if (!requestKey || !isReady) return;
323
390
  const { media: target, devultur: context, options: current } = inputsRef.current;
324
391
  if (!target || !context.baseUrl) return;
@@ -380,11 +447,46 @@ function useMediaObjectUrl(media, options) {
380
447
  }
381
448
 
382
449
  // src/pdf-context.tsx
383
- import React3 from "react";
450
+ import React4 from "react";
451
+
452
+ // src/preview-labels.ts
453
+ var ERROR_TEXT = {
454
+ http_error: "The file couldn't be loaded.",
455
+ too_large: "The file is too large to preview.",
456
+ unsupported_type: "This file type can't be previewed.",
457
+ type_mismatch: "The file's content doesn't match its type."
458
+ };
459
+ var defaultPreviewLabels = {
460
+ document: (title, pageCount) => `${title}, ${pageCount === 1 ? "1 page" : `${pageCount} pages`}`,
461
+ page: (page, pageCount) => `Page ${page} of ${pageCount}`,
462
+ loading: "Loading\u2026",
463
+ pageCap: (shown, total) => `Showing the first ${shown} of ${total} pages.`,
464
+ error: (code) => ERROR_TEXT[code],
465
+ thumbnail: (title) => title,
466
+ untitledPdf: "PDF",
467
+ untitledMedia: "Preview",
468
+ download: "Download"
469
+ };
470
+ function mergePreviewLabels(...sources) {
471
+ const merged = { ...defaultPreviewLabels };
472
+ for (const source of sources) {
473
+ for (const [key, value] of Object.entries(source ?? {})) {
474
+ if (value !== void 0) merged[key] = value;
475
+ }
476
+ }
477
+ return merged;
478
+ }
479
+
480
+ // src/pdf-context.tsx
384
481
  import { jsx as jsx2 } from "react/jsx-runtime";
385
- var PdfPreviewContext = React3.createContext({});
386
- function PdfPreviewProvider({ pdfjs, workerSrc, children }) {
387
- const config = React3.useMemo(() => ({ pdfjs, workerSrc }), [pdfjs, workerSrc]);
482
+ var PdfPreviewContext = React4.createContext({});
483
+ function PdfPreviewProvider({
484
+ pdfjs,
485
+ workerSrc,
486
+ labels,
487
+ children
488
+ }) {
489
+ const config = React4.useMemo(() => ({ pdfjs, workerSrc, labels }), [pdfjs, workerSrc, labels]);
388
490
  return /* @__PURE__ */ jsx2(PdfPreviewContext.Provider, { value: config, children });
389
491
  }
390
492
  var loadedModules = /* @__PURE__ */ new WeakMap();
@@ -396,9 +498,9 @@ function loadOnce(loader) {
396
498
  return loading;
397
499
  }
398
500
  function usePdfjs(workerSrcOverride) {
399
- const { pdfjs: loader, workerSrc } = React3.useContext(PdfPreviewContext);
400
- const [loaded, setLoaded] = React3.useState(null);
401
- React3.useEffect(() => {
501
+ const { pdfjs: loader, workerSrc } = React4.useContext(PdfPreviewContext);
502
+ const [loaded, setLoaded] = React4.useState(null);
503
+ React4.useEffect(() => {
402
504
  if (!loader) return;
403
505
  let isCurrent = true;
404
506
  loadOnce(loader).then((pdfjs) => {
@@ -413,12 +515,16 @@ function usePdfjs(workerSrcOverride) {
413
515
  if (!loaded.pdfjs) return { status: "unavailable" };
414
516
  return { status: "ready", pdfjs: loaded.pdfjs, workerSrc: workerSrcOverride ?? workerSrc };
415
517
  }
518
+ function usePreviewLabels(labels) {
519
+ const providerLabels = React4.useContext(PdfPreviewContext).labels;
520
+ return mergePreviewLabels(providerLabels, labels);
521
+ }
416
522
 
417
523
  // src/pdf-thumbnail.tsx
418
- import React6 from "react";
524
+ import React7 from "react";
419
525
 
420
526
  // src/pdf-hooks.ts
421
- import React4 from "react";
527
+ import React5 from "react";
422
528
 
423
529
  // src/pdf-render.ts
424
530
  var MOBILE_MAX_CANVAS_PIXELS = 5242880;
@@ -462,8 +568,8 @@ var isRenderCancelled = (error) => error instanceof Error && error.name === "Ren
462
568
 
463
569
  // src/pdf-hooks.ts
464
570
  function useIsVisible(ref, rootMargin = "200px") {
465
- const [isVisible, setIsVisible] = React4.useState(false);
466
- React4.useEffect(() => {
571
+ const [isVisible, setIsVisible] = React5.useState(false);
572
+ React5.useEffect(() => {
467
573
  const element = ref.current;
468
574
  if (isVisible || !element) return;
469
575
  if (typeof IntersectionObserver === "undefined") {
@@ -484,12 +590,12 @@ function useIsVisible(ref, rootMargin = "200px") {
484
590
  return isVisible;
485
591
  }
486
592
  function usePdfDocument({ pdfjs, bytes, workerSrc }) {
487
- const [state, setState] = React4.useState({
593
+ const [state, setState] = React5.useState({
488
594
  bytes: null,
489
595
  document: null,
490
596
  error: null
491
597
  });
492
- React4.useEffect(() => {
598
+ React5.useEffect(() => {
493
599
  let isCurrent = true;
494
600
  const loadingTask = openPdf(pdfjs, bytes, workerSrc);
495
601
  loadingTask.promise.then((document2) => {
@@ -508,8 +614,8 @@ function usePdfDocument({ pdfjs, bytes, workerSrc }) {
508
614
  }
509
615
  function usePageRender(options) {
510
616
  const { document: document2, pageNumber, canvasRef, width, isEnabled, shouldExtractText = false } = options;
511
- const [state, setState] = React4.useState({ isRendered: false, text: "" });
512
- React4.useEffect(() => {
617
+ const [state, setState] = React5.useState({ isRendered: false, text: "" });
618
+ React5.useEffect(() => {
513
619
  const canvas = canvasRef.current;
514
620
  if (!document2 || !isEnabled || !canvas) return;
515
621
  let isCurrent = true;
@@ -539,7 +645,7 @@ function usePageRender(options) {
539
645
  }
540
646
 
541
647
  // src/pdf-preview.tsx
542
- import React5 from "react";
648
+ import React6 from "react";
543
649
  import { Fragment, jsx as jsx3, jsxs as jsxs2 } from "react/jsx-runtime";
544
650
  var VISUALLY_HIDDEN = {
545
651
  position: "absolute",
@@ -555,7 +661,7 @@ var VISUALLY_HIDDEN = {
555
661
  var DEFAULT_MAX_PAGES = 500;
556
662
  var keepBytes = (bytes) => bytes;
557
663
  function PdfPreview(props) {
558
- const { media, expectedType, maxBytes, fallback, loading, title, className, pdfWorkerSrc, maxPages } = props;
664
+ const { media, expectedType, maxBytes, renderFallback, loading, labels, title, className, pdfWorkerSrc } = props;
559
665
  const pdfjsState = usePdfjs(pdfWorkerSrc);
560
666
  const { value: bytes, error } = useMediaResource(media, {
561
667
  isEnabled: true,
@@ -563,9 +669,9 @@ function PdfPreview(props) {
563
669
  contentType: expectedType,
564
670
  decode: keepBytes
565
671
  });
566
- if (error) return /* @__PURE__ */ jsx3(Fragment, { children: fallback });
672
+ if (error) return /* @__PURE__ */ jsx3(Fragment, { children: renderFallback(error) });
567
673
  if (!bytes || pdfjsState.status === "loading") return /* @__PURE__ */ jsx3(Fragment, { children: loading });
568
- const label = title ?? media.filename ?? "PDF";
674
+ const label = title ?? media.filename ?? labels.untitledPdf;
569
675
  if (pdfjsState.status === "unavailable") return /* @__PURE__ */ jsx3(PdfFrame, { bytes, label, className });
570
676
  return /* @__PURE__ */ jsx3(
571
677
  PdfCanvasDocument,
@@ -575,41 +681,56 @@ function PdfPreview(props) {
575
681
  bytes,
576
682
  label,
577
683
  className,
578
- fallback,
684
+ renderFallback,
579
685
  loading,
580
- maxPages: maxPages ?? DEFAULT_MAX_PAGES
686
+ labels,
687
+ maxPages: props.maxPages ?? DEFAULT_MAX_PAGES
581
688
  }
582
689
  );
583
690
  }
584
691
  function PdfCanvasDocument(props) {
585
- const { pdfjs, workerSrc, bytes, label, className, fallback, loading, maxPages } = props;
692
+ const { pdfjs, workerSrc, bytes, label, className, renderFallback, loading, labels, maxPages } = props;
586
693
  const { document: document2, error } = usePdfDocument({ pdfjs, bytes, workerSrc });
587
- const containerRef = React5.useRef(null);
588
- const [width, setWidth] = React5.useState(null);
589
- React5.useLayoutEffect(() => {
694
+ const containerRef = React6.useRef(null);
695
+ const [width, setWidth] = React6.useState(null);
696
+ React6.useLayoutEffect(() => {
590
697
  if (document2) setWidth(containerRef.current?.clientWidth ?? 0);
591
698
  }, [document2]);
592
- if (error) return /* @__PURE__ */ jsx3(Fragment, { children: fallback });
699
+ if (error) return /* @__PURE__ */ jsx3(Fragment, { children: renderFallback(error) });
593
700
  if (!document2) return /* @__PURE__ */ jsx3(Fragment, { children: loading });
594
701
  const shownPageCount = Math.min(document2.numPages, Math.max(1, maxPages));
595
702
  const isTruncated = shownPageCount < document2.numPages;
596
703
  const pageNumbers = Array.from({ length: shownPageCount }, (_, index) => index + 1);
597
- const pageCountLabel = document2.numPages === 1 ? "1 page" : `${document2.numPages} pages`;
598
- return /* @__PURE__ */ jsxs2("div", { ref: containerRef, role: "document", "aria-label": `${label}, ${pageCountLabel}`, className, children: [
599
- width !== null && pageNumbers.map((pageNumber) => /* @__PURE__ */ jsx3(PdfPageView, { document: document2, pageNumber, width }, pageNumber)),
600
- isTruncated && /* @__PURE__ */ jsxs2("p", { children: [
601
- "Showing the first ",
602
- shownPageCount,
603
- " of ",
604
- document2.numPages,
605
- " pages. ",
606
- fallback
607
- ] })
608
- ] });
704
+ return /* @__PURE__ */ jsxs2(
705
+ "div",
706
+ {
707
+ ref: containerRef,
708
+ role: "document",
709
+ "aria-label": labels.document(label, document2.numPages),
710
+ className,
711
+ children: [
712
+ width !== null && pageNumbers.map((pageNumber) => /* @__PURE__ */ jsx3(
713
+ PdfPageView,
714
+ {
715
+ document: document2,
716
+ pageNumber,
717
+ width,
718
+ label: labels.page(pageNumber, document2.numPages)
719
+ },
720
+ pageNumber
721
+ )),
722
+ isTruncated && /* @__PURE__ */ jsxs2("p", { children: [
723
+ labels.pageCap(shownPageCount, document2.numPages),
724
+ " ",
725
+ renderFallback(null)
726
+ ] })
727
+ ]
728
+ }
729
+ );
609
730
  }
610
- function PdfPageView({ document: document2, pageNumber, width }) {
611
- const pageRef = React5.useRef(null);
612
- const canvasRef = React5.useRef(null);
731
+ function PdfPageView({ document: document2, pageNumber, width, label }) {
732
+ const pageRef = React6.useRef(null);
733
+ const canvasRef = React6.useRef(null);
613
734
  const isVisible = useIsVisible(pageRef);
614
735
  const { isRendered, text } = usePageRender({
615
736
  document: document2,
@@ -620,14 +741,14 @@ function PdfPageView({ document: document2, pageNumber, width }) {
620
741
  shouldExtractText: true
621
742
  });
622
743
  const placeholderStyle = isRendered ? void 0 : { aspectRatio: "1 / 1.4142", maxWidth: "100%" };
623
- return /* @__PURE__ */ jsxs2("section", { ref: pageRef, "aria-label": `Page ${pageNumber} of ${document2.numPages}`, style: placeholderStyle, children: [
744
+ return /* @__PURE__ */ jsxs2("section", { ref: pageRef, "aria-label": label, style: placeholderStyle, children: [
624
745
  /* @__PURE__ */ jsx3("canvas", { ref: canvasRef, style: { display: "block", maxWidth: "100%" } }),
625
746
  text && /* @__PURE__ */ jsx3("p", { style: VISUALLY_HIDDEN, children: text })
626
747
  ] });
627
748
  }
628
749
  function PdfFrame({ bytes, label, className }) {
629
- const [objectUrl, setObjectUrl] = React5.useState(null);
630
- React5.useEffect(() => {
750
+ const [objectUrl, setObjectUrl] = React6.useState(null);
751
+ React6.useEffect(() => {
631
752
  const url = URL.createObjectURL(new Blob([bytes], { type: "application/pdf" }));
632
753
  setObjectUrl(url);
633
754
  return () => URL.revokeObjectURL(url);
@@ -642,9 +763,10 @@ var DEFAULT_THUMBNAIL_WIDTH = 96;
642
763
  var DEFAULT_THUMBNAIL_MAX_BYTES = 50 * 1024 * 1024;
643
764
  function PdfThumbnail(props) {
644
765
  const { media, contentType, width = DEFAULT_THUMBNAIL_WIDTH, fallback = null, loading = null } = props;
645
- const containerRef = React6.useRef(null);
766
+ const containerRef = React7.useRef(null);
646
767
  const isVisible = useIsVisible(containerRef);
647
768
  const pdfjsState = usePdfjs(props.pdfWorkerSrc);
769
+ const labels = usePreviewLabels(props.labels);
648
770
  const expectedType = media ? expectedContentType(media, contentType) : null;
649
771
  const isPdf = previewKindOf(expectedType) === "pdf";
650
772
  const { value: bytes, error } = useMediaResource(media, {
@@ -665,7 +787,7 @@ function PdfThumbnail(props) {
665
787
  workerSrc: pdfjsState.workerSrc,
666
788
  bytes,
667
789
  width,
668
- label: props.title ?? media.filename ?? "PDF",
790
+ label: labels.thumbnail(props.title ?? media.filename ?? labels.untitledPdf),
669
791
  fallback,
670
792
  loading
671
793
  }
@@ -674,7 +796,7 @@ function PdfThumbnail(props) {
674
796
  return /* @__PURE__ */ jsx4("div", { ref: containerRef, className: props.className, style: { width }, children: content() });
675
797
  }
676
798
  function ThumbnailCanvas({ pdfjs, workerSrc, bytes, width, label, fallback, loading }) {
677
- const canvasRef = React6.useRef(null);
799
+ const canvasRef = React7.useRef(null);
678
800
  const { document: document2, error } = usePdfDocument({ pdfjs, bytes, workerSrc });
679
801
  const { isRendered } = usePageRender({ document: document2, pageNumber: 1, canvasRef, width, isEnabled: true });
680
802
  if (error) return /* @__PURE__ */ jsx4(Fragment2, { children: fallback });
@@ -689,21 +811,32 @@ import { VideoPlayer } from "@devultur/react";
689
811
  import { Fragment as Fragment3, jsx as jsx5 } from "react/jsx-runtime";
690
812
  var DEFAULT_MAX_TEXT_BYTES = 1024 * 1024;
691
813
  var DEFAULT_MAX_BYTES = 50 * 1024 * 1024;
692
- function Preview({ media, contentType, fallback, ...viewProps }) {
814
+ function Preview({ media, contentType, fallback, loading, labels, ...viewProps }) {
815
+ const text = usePreviewLabels(labels);
693
816
  if (!media) return null;
694
- const fallbackNode = fallback === void 0 ? /* @__PURE__ */ jsx5(DownloadLink, { media, children: media.filename ?? "Download" }) : fallback;
695
817
  if (media.kind === "video") return /* @__PURE__ */ jsx5(VideoPreview, { media, className: viewProps.className });
818
+ const renderFallback = (error) => {
819
+ const fetchError = error instanceof MediaFetchError ? error : null;
820
+ if (typeof fallback === "function") return fallback(fetchError);
821
+ if (fallback !== void 0) return fallback;
822
+ return /* @__PURE__ */ jsx5(DefaultFallback, { media, error: fetchError, labels: text });
823
+ };
696
824
  const expectedType = expectedContentType(media, contentType);
697
825
  const kind = previewKindOf(expectedType);
698
- if (!expectedType || !kind) return /* @__PURE__ */ jsx5(Fragment3, { children: fallbackNode });
699
- const props = { ...viewProps, media, expectedType, fallback: fallbackNode };
826
+ if (!expectedType || !kind) return /* @__PURE__ */ jsx5(Fragment3, { children: renderFallback(unsupportedType(expectedType)) });
827
+ const loadingNode = loading === void 0 ? /* @__PURE__ */ jsx5(LoadingStatus, { text: text.loading }) : loading;
828
+ const props = { ...viewProps, media, expectedType, renderFallback, loading: loadingNode, labels: text };
700
829
  if (kind === "text") return /* @__PURE__ */ jsx5(TextPreview, { ...props });
701
- if (kind === "pdf") {
702
- const { maxBytes = DEFAULT_MAX_BYTES, loading = null } = props;
703
- return /* @__PURE__ */ jsx5(PdfPreview, { ...props, maxBytes, loading });
704
- }
830
+ if (kind === "pdf") return /* @__PURE__ */ jsx5(PdfPreview, { ...props, maxBytes: props.maxBytes ?? DEFAULT_MAX_BYTES });
705
831
  return /* @__PURE__ */ jsx5(BinaryPreview, { ...props, kind });
706
832
  }
833
+ var unsupportedType = (contentType) => new MediaFetchError("unsupported_type", `Can't preview ${contentType ?? "an unknown type"} inline`);
834
+ function DefaultFallback({ media, error, labels }) {
835
+ return /* @__PURE__ */ jsx5(DownloadLink, { media, title: error ? labels.error(error.code) : void 0, children: media.filename ?? labels.download });
836
+ }
837
+ function LoadingStatus({ text }) {
838
+ return /* @__PURE__ */ jsx5("span", { role: "status", style: VISUALLY_HIDDEN, children: text });
839
+ }
707
840
  function VideoPreview({ media, className }) {
708
841
  const videoProps = useVideoProps(media);
709
842
  if (!videoProps) return null;
@@ -714,8 +847,9 @@ function BinaryPreview({
714
847
  kind,
715
848
  expectedType,
716
849
  maxBytes = DEFAULT_MAX_BYTES,
717
- fallback,
718
- loading = null,
850
+ renderFallback,
851
+ loading,
852
+ labels,
719
853
  title,
720
854
  className
721
855
  }) {
@@ -723,9 +857,9 @@ function BinaryPreview({
723
857
  const isPublicImage = kind === "image" && media.visibility === "public";
724
858
  const verified = useMediaObjectUrl(media, { enabled: !isPublicImage, contentType: expectedType, maxBytes });
725
859
  const objectUrl = isPublicImage ? url(media) : verified.objectUrl;
726
- if (verified.error) return /* @__PURE__ */ jsx5(Fragment3, { children: fallback });
860
+ if (verified.error) return /* @__PURE__ */ jsx5(Fragment3, { children: renderFallback(verified.error) });
727
861
  if (!objectUrl) return /* @__PURE__ */ jsx5(Fragment3, { children: loading });
728
- const label = title ?? media.filename ?? "Preview";
862
+ const label = title ?? media.filename ?? labels.untitledMedia;
729
863
  if (kind === "image") {
730
864
  return /* @__PURE__ */ jsx5("img", { src: objectUrl, alt: label, width: media.width, height: media.height, className });
731
865
  }
@@ -736,8 +870,8 @@ function TextPreview({
736
870
  media,
737
871
  expectedType,
738
872
  maxTextBytes = DEFAULT_MAX_TEXT_BYTES,
739
- fallback,
740
- loading = null,
873
+ renderFallback,
874
+ loading,
741
875
  title,
742
876
  className
743
877
  }) {
@@ -747,84 +881,36 @@ function TextPreview({
747
881
  contentType: expectedType,
748
882
  decode: decodeText
749
883
  });
750
- if (error) return /* @__PURE__ */ jsx5(Fragment3, { children: fallback });
884
+ if (error) return /* @__PURE__ */ jsx5(Fragment3, { children: renderFallback(error) });
751
885
  if (text === null) return /* @__PURE__ */ jsx5(Fragment3, { children: loading });
752
886
  return /* @__PURE__ */ jsx5("pre", { title, className, children: text });
753
887
  }
754
888
 
755
889
  // src/react.tsx
756
890
  import { Fragment as Fragment4, jsx as jsx6 } from "react/jsx-runtime";
757
- var DevulturContext = React7.createContext(null);
758
- var DevulturApiContext = React7.createContext(null);
759
- var ProgressContext = React7.createContext(null);
760
- var REFRESH_BUFFER_MS = 5 * 60 * 1e3;
761
- var MIN_REFRESH_MS = 6e4;
762
- var DEFAULT_TTL_MS = DEFAULT_TOKEN_EXPIRY_SECONDS * 1e3;
763
- function extractTokenResult(result) {
764
- const tokenValue = typeof result === "string" ? result : result.token;
765
- const ttlMs = typeof result === "string" ? DEFAULT_TTL_MS : result.expiresIn * 1e3;
766
- return { tokenValue, ttlMs };
767
- }
768
- function DevulturProvider({ api, baseUrl, children }) {
769
- const [token, setToken] = React7.useState(null);
770
- const [sessionSecret] = React7.useState(() => typeof crypto !== "undefined" ? crypto.randomUUID() : "");
771
- const issueViewerToken = useAction(api.issueViewerToken);
891
+ var DevulturContext = React8.createContext(null);
892
+ var DevulturApiContext = React8.createContext(null);
893
+ var ProgressContext = React8.createContext(null);
894
+ function DevulturProvider({ api, baseUrl, tokenArgs, issueViewerToken, children }) {
895
+ const [sessionSecret] = React8.useState(() => typeof crypto !== "undefined" ? crypto.randomUUID() : "");
896
+ const issueViewerTokenAction = useAction(issueViewerToken ?? api.issueViewerToken);
897
+ const { token, reissueToken } = useViewerToken({
898
+ issueViewerToken: issueViewerTokenAction,
899
+ sessionSecret,
900
+ tokenArgs
901
+ });
772
902
  const createUploadUrlAction = useAction(api.createUploadUrl);
773
903
  const deleteMediaAction = useAction(api.deleteMedia);
774
904
  const confirmUploadAction = useAction(api.confirmUpload ?? api.createUploadUrl);
775
905
  const deleteVideoAction = useAction(api.deleteVideo ?? api.deleteMedia);
776
- const refreshTimerRef = React7.useRef(null);
777
- const reissueInFlightRef = React7.useRef(null);
778
- const scheduleRefresh = React7.useCallback(
779
- (ttlMs) => {
780
- if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current);
781
- const refreshIn = Math.max(ttlMs - REFRESH_BUFFER_MS, MIN_REFRESH_MS);
782
- refreshTimerRef.current = setTimeout(() => {
783
- issueViewerToken({ sessionSecret }).then((result) => {
784
- const { tokenValue, ttlMs: nextTtl } = extractTokenResult(result);
785
- setToken(tokenValue);
786
- scheduleRefresh(nextTtl);
787
- });
788
- }, refreshIn);
789
- },
790
- [issueViewerToken, sessionSecret]
791
- );
792
- React7.useEffect(() => {
793
- if (token) return;
794
- issueViewerToken({ sessionSecret }).then((result) => {
795
- const { tokenValue, ttlMs } = extractTokenResult(result);
796
- setToken(tokenValue);
797
- scheduleRefresh(ttlMs);
798
- });
799
- }, [token, issueViewerToken, scheduleRefresh, sessionSecret]);
800
- const reissueToken = React7.useCallback(() => {
801
- if (reissueInFlightRef.current) return reissueInFlightRef.current;
802
- const pending = issueViewerToken({ sessionSecret }).then((result) => {
803
- const { tokenValue, ttlMs } = extractTokenResult(result);
804
- setToken(tokenValue);
805
- scheduleRefresh(ttlMs);
806
- reissueInFlightRef.current = null;
807
- return tokenValue;
808
- }).catch(() => {
809
- reissueInFlightRef.current = null;
810
- return null;
811
- });
812
- reissueInFlightRef.current = pending;
813
- return pending;
814
- }, [issueViewerToken, scheduleRefresh, sessionSecret]);
815
- React7.useEffect(() => {
816
- return () => {
817
- if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current);
818
- };
819
- }, []);
820
- const uploadUrl = React7.useCallback(
906
+ const uploadUrl = React8.useCallback(
821
907
  async (file) => {
822
908
  const result = await createUploadUrlAction({ filename: file.name, contentType: file.type });
823
909
  return { url: result.url, key: result.key };
824
910
  },
825
911
  [createUploadUrlAction]
826
912
  );
827
- const upload = React7.useCallback(
913
+ const upload = React8.useCallback(
828
914
  async (file, options) => {
829
915
  const dimensions = await readImageDimensions(file);
830
916
  const target = await createUploadUrlAction({
@@ -845,14 +931,14 @@ function DevulturProvider({ api, baseUrl, children }) {
845
931
  },
846
932
  [createUploadUrlAction, confirmUploadAction, api.confirmUpload]
847
933
  );
848
- const url = React7.useCallback(
934
+ const url = React8.useCallback(
849
935
  (media, options) => {
850
936
  if (!baseUrl) throw new Error("Devultur `url()` requires `baseUrl` on <DevulturProvider>");
851
937
  return mediaUrl2(baseUrl, media, { ...options, token: token ?? void 0 });
852
938
  },
853
939
  [baseUrl, token]
854
940
  );
855
- const getCaptionsText = React7.useCallback(
941
+ const getCaptionsText = React8.useCallback(
856
942
  async (media, locale) => {
857
943
  if (!baseUrl) throw new Error("Devultur `getCaptionsText()` requires `baseUrl` on <DevulturProvider>");
858
944
  const path = mediaCaptionPath(media, locale);
@@ -871,7 +957,7 @@ function DevulturProvider({ api, baseUrl, children }) {
871
957
  },
872
958
  [baseUrl, token, reissueToken, sessionSecret]
873
959
  );
874
- const deleteMedia = React7.useCallback(
960
+ const deleteMedia = React8.useCallback(
875
961
  (media) => {
876
962
  if (media.kind === "video" && api.deleteVideo) {
877
963
  deleteVideoAction({ videoId: media.id, visibility: media.visibility }).catch(console.error);
@@ -881,7 +967,7 @@ function DevulturProvider({ api, baseUrl, children }) {
881
967
  },
882
968
  [deleteMediaAction, deleteVideoAction, api.deleteVideo]
883
969
  );
884
- const devulturValue = React7.useMemo(
970
+ const devulturValue = React8.useMemo(
885
971
  () => ({
886
972
  token,
887
973
  baseUrl: baseUrl ?? null,
@@ -921,13 +1007,13 @@ function ProgressBridge({
921
1007
  const saveMutation = useMutation(save);
922
1008
  const markCompleteMutation = useMutation(markComplete);
923
1009
  const resetMutation = useMutation(reset);
924
- const onSave = React7.useCallback(
1010
+ const onSave = React8.useCallback(
925
1011
  async (videoId, position) => {
926
1012
  await saveMutation({ videoId, position });
927
1013
  },
928
1014
  [saveMutation]
929
1015
  );
930
- const onLoad = React7.useCallback(
1016
+ const onLoad = React8.useCallback(
931
1017
  async (videoId) => {
932
1018
  const result = await convex.query(load, { videoId });
933
1019
  if (!result) return null;
@@ -938,37 +1024,37 @@ function ProgressBridge({
938
1024
  },
939
1025
  [convex, load]
940
1026
  );
941
- const onMarkComplete = React7.useCallback(
1027
+ const onMarkComplete = React8.useCallback(
942
1028
  async (videoId) => {
943
1029
  await markCompleteMutation({ videoId });
944
1030
  },
945
1031
  [markCompleteMutation]
946
1032
  );
947
- const onReset = React7.useCallback(
1033
+ const onReset = React8.useCallback(
948
1034
  async (videoId) => {
949
1035
  await resetMutation({ videoId });
950
1036
  },
951
1037
  [resetMutation]
952
1038
  );
953
- const value = React7.useMemo(
1039
+ const value = React8.useMemo(
954
1040
  () => ({ save: onSave, load: onLoad, markComplete: onMarkComplete, reset: onReset }),
955
1041
  [onSave, onLoad, onMarkComplete, onReset]
956
1042
  );
957
1043
  return /* @__PURE__ */ jsx6(ProgressContext, { value, children });
958
1044
  }
959
1045
  function useDevultur() {
960
- const context = React7.use(DevulturContext);
1046
+ const context = React8.use(DevulturContext);
961
1047
  if (!context) throw new Error("useDevultur must be used within <DevulturProvider>");
962
1048
  return context;
963
1049
  }
964
1050
  function useDevulturProgress() {
965
- const context = React7.use(ProgressContext);
1051
+ const context = React8.use(ProgressContext);
966
1052
  if (!context) throw new Error("useDevulturProgress requires progress API in DevulturProvider");
967
1053
  return context;
968
1054
  }
969
1055
  function useMediaUrl(key, options) {
970
1056
  const { token, baseUrl } = useDevultur();
971
- return React7.useMemo(() => {
1057
+ return React8.useMemo(() => {
972
1058
  if (!key) return null;
973
1059
  if (!baseUrl) throw new Error("useMediaUrl requires `baseUrl` on <DevulturProvider>");
974
1060
  const isPublic = visibilityFromKey(key) === "public";
@@ -996,7 +1082,7 @@ async function readImageDimensions(file) {
996
1082
  function useVideoProps(media, options) {
997
1083
  const { token, baseUrl, sessionSecret } = useDevultur();
998
1084
  const locales = options?.locales;
999
- return React7.useMemo(() => {
1085
+ return React8.useMemo(() => {
1000
1086
  if (media?.kind !== "video") return null;
1001
1087
  if (!baseUrl) throw new Error("useVideoProps requires `baseUrl` on <DevulturProvider>");
1002
1088
  const viewerToken = media.visibility === "private" ? token ?? void 0 : void 0;
@@ -1028,19 +1114,19 @@ function Image({
1028
1114
  ...imgProps
1029
1115
  }) {
1030
1116
  const { url, token, baseUrl, reissueToken, sessionSecret } = useDevultur();
1031
- const [failed, setFailed] = React7.useState(false);
1032
- const [blobSrc, setBlobSrc] = React7.useState(null);
1033
- const sessionRef = React7.useRef({ token, reissueToken });
1034
- React7.useEffect(() => {
1117
+ const [failed, setFailed] = React8.useState(false);
1118
+ const [blobSrc, setBlobSrc] = React8.useState(null);
1119
+ const sessionRef = React8.useRef({ token, reissueToken });
1120
+ React8.useEffect(() => {
1035
1121
  sessionRef.current = { token, reissueToken };
1036
1122
  });
1037
1123
  const isPrivate = media?.visibility === "private";
1038
1124
  const hasToken = token !== null;
1039
- React7.useEffect(() => {
1125
+ React8.useEffect(() => {
1040
1126
  setFailed(false);
1041
1127
  setBlobSrc(null);
1042
1128
  }, [media]);
1043
- React7.useEffect(() => {
1129
+ React8.useEffect(() => {
1044
1130
  if (!isPrivate || failed || !media || !hasToken) return;
1045
1131
  let cancelled = false;
1046
1132
  let objectUrl = null;
@@ -1072,7 +1158,7 @@ function Image({
1072
1158
  if (objectUrl) URL.revokeObjectURL(objectUrl);
1073
1159
  };
1074
1160
  }, [isPrivate, failed, media, hasToken, baseUrl, sessionSecret, width, height, fit, format, quality]);
1075
- const handleError = React7.useCallback(() => setFailed(true), []);
1161
+ const handleError = React8.useCallback(() => setFailed(true), []);
1076
1162
  let src;
1077
1163
  if (failed || !media) {
1078
1164
  src = externalFallback ?? null;
@@ -1083,7 +1169,7 @@ function Image({
1083
1169
  }
1084
1170
  if (!src) return /* @__PURE__ */ jsx6(Fragment4, { children: fallback });
1085
1171
  if (render) {
1086
- return React7.cloneElement(render, {
1172
+ return React8.cloneElement(render, {
1087
1173
  style: { ...render.props.style, backgroundImage: `url("${src}")` }
1088
1174
  });
1089
1175
  }
@@ -1101,7 +1187,7 @@ function Image({
1101
1187
  }
1102
1188
  function DownloadLink({ media, filename, children, ...anchorProps }) {
1103
1189
  const { url, token, baseUrl, sessionSecret, reissueToken } = useDevultur();
1104
- const handleClick = React7.useCallback(
1190
+ const handleClick = React8.useCallback(
1105
1191
  async (event) => {
1106
1192
  if (media?.visibility !== "private" || !baseUrl) return;
1107
1193
  event.preventDefault();
@@ -1125,7 +1211,7 @@ function DownloadLink({ media, filename, children, ...anchorProps }) {
1125
1211
  return /* @__PURE__ */ jsx6("a", { href: url(media, { download: filename ?? true }), download: true, ...anchorProps, onClick: handleClick, children });
1126
1212
  }
1127
1213
  function useDevulturMedia(key) {
1128
- const apiCtx = React7.use(DevulturApiContext);
1214
+ const apiCtx = React8.use(DevulturApiContext);
1129
1215
  if (!apiCtx?.getMedia) {
1130
1216
  throw new Error("useDevulturMedia requires getMedia in DevulturProvider api");
1131
1217
  }
@@ -1182,7 +1268,7 @@ function useVideoState(media, options) {
1182
1268
  const { token, baseUrl, sessionSecret } = useDevultur();
1183
1269
  const status = useDevulturMedia(media?.kind === "video" ? media.id : null);
1184
1270
  const locales = options?.locales;
1185
- return React7.useMemo(() => {
1271
+ return React8.useMemo(() => {
1186
1272
  if (media?.kind !== "video") return { phase: "idle" };
1187
1273
  if (status.isFailed) {
1188
1274
  return {
@@ -1226,23 +1312,23 @@ function useVideoState(media, options) {
1226
1312
  }
1227
1313
  function useMediaUpload(options) {
1228
1314
  const { visibility, onComplete, onError, replaces, process, locales } = options ?? {};
1229
- const api = React7.use(DevulturApiContext);
1315
+ const api = React8.use(DevulturApiContext);
1230
1316
  if (!api) throw new Error("useMediaUpload must be used within <DevulturProvider>");
1231
1317
  const createUploadUrlAction = useAction(api.createUploadUrl);
1232
1318
  const deleteMediaAction = useAction(api.deleteMedia);
1233
1319
  const confirmUploadAction = useAction(api.confirmUpload ?? api.createUploadUrl);
1234
1320
  const processVideoAction = useAction(api.processVideo ?? api.createUploadUrl);
1235
- const [phase, setPhase] = React7.useState("idle");
1236
- const [percent, setPercent] = React7.useState(0);
1237
- const [currentFile, setCurrentFile] = React7.useState(null);
1238
- const [media, setMedia] = React7.useState(null);
1239
- const [uploadFailure, setUploadFailure] = React7.useState(null);
1240
- const [processRequested, setProcessRequested] = React7.useState(false);
1241
- const abortRef = React7.useRef(null);
1242
- const lastFileRef = React7.useRef(null);
1243
- const lastKeyRef = React7.useRef(null);
1321
+ const [phase, setPhase] = React8.useState("idle");
1322
+ const [percent, setPercent] = React8.useState(0);
1323
+ const [currentFile, setCurrentFile] = React8.useState(null);
1324
+ const [media, setMedia] = React8.useState(null);
1325
+ const [uploadFailure, setUploadFailure] = React8.useState(null);
1326
+ const [processRequested, setProcessRequested] = React8.useState(false);
1327
+ const abortRef = React8.useRef(null);
1328
+ const lastFileRef = React8.useRef(null);
1329
+ const lastKeyRef = React8.useRef(null);
1244
1330
  const videoState = useVideoState(media, { locales });
1245
- const triggerProcess = React7.useCallback(
1331
+ const triggerProcess = React8.useCallback(
1246
1332
  (key) => {
1247
1333
  if (!api.processVideo) {
1248
1334
  throw new Error("useMediaUpload: processing a video requires `processVideo` in the <DevulturProvider> api");
@@ -1255,7 +1341,7 @@ function useMediaUpload(options) {
1255
1341
  },
1256
1342
  [api.processVideo, processVideoAction]
1257
1343
  );
1258
- const runUpload = React7.useCallback(
1344
+ const runUpload = React8.useCallback(
1259
1345
  async (file) => {
1260
1346
  const controller = new AbortController();
1261
1347
  abortRef.current = controller;
@@ -1326,10 +1412,10 @@ function useMediaUpload(options) {
1326
1412
  onError
1327
1413
  ]
1328
1414
  );
1329
- const cancel = React7.useCallback(() => {
1415
+ const cancel = React8.useCallback(() => {
1330
1416
  abortRef.current?.abort();
1331
1417
  }, []);
1332
- const reset = React7.useCallback(() => {
1418
+ const reset = React8.useCallback(() => {
1333
1419
  abortRef.current?.abort();
1334
1420
  setPhase("idle");
1335
1421
  setPercent(0);
@@ -1338,15 +1424,15 @@ function useMediaUpload(options) {
1338
1424
  setUploadFailure(null);
1339
1425
  setProcessRequested(false);
1340
1426
  }, []);
1341
- const retry = React7.useCallback(() => {
1427
+ const retry = React8.useCallback(() => {
1342
1428
  if (phase === "upload-failed" && lastFileRef.current) {
1343
1429
  runUpload(lastFileRef.current);
1344
1430
  return;
1345
1431
  }
1346
1432
  if (lastKeyRef.current) triggerProcess(lastKeyRef.current);
1347
1433
  }, [phase, runUpload, triggerProcess]);
1348
- React7.useEffect(() => () => abortRef.current?.abort(), []);
1349
- const state = React7.useMemo(
1434
+ React8.useEffect(() => () => abortRef.current?.abort(), []);
1435
+ const state = React8.useMemo(
1350
1436
  () => deriveUploadState(
1351
1437
  { phase, percent, file: currentFile, failure: uploadFailure, media, processRequested },
1352
1438
  videoState,
@@ -1354,7 +1440,7 @@ function useMediaUpload(options) {
1354
1440
  ),
1355
1441
  [phase, percent, currentFile, uploadFailure, media, processRequested, videoState, retry]
1356
1442
  );
1357
- const upload = React7.useCallback((file) => runUpload(file), [runUpload]);
1443
+ const upload = React8.useCallback((file) => runUpload(file), [runUpload]);
1358
1444
  return { upload, state, cancel, reset };
1359
1445
  }
1360
1446
  export {
@@ -1366,6 +1452,7 @@ export {
1366
1452
  PdfPreviewProvider,
1367
1453
  PdfThumbnail,
1368
1454
  Preview,
1455
+ defaultPreviewLabels,
1369
1456
  useDevultur,
1370
1457
  useDevulturMedia,
1371
1458
  useDevulturProgress,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@devultur/convex",
3
- "version": "1.3.0",
3
+ "version": "1.3.2",
4
4
  "description": "Convex component for Devultur media: media records, uploads, transcodes, watch progress and React bindings",
5
5
  "keywords": [
6
6
  "convex",
@@ -55,8 +55,8 @@
55
55
  "dist"
56
56
  ],
57
57
  "dependencies": {
58
- "@devultur/core": "1.3.0",
59
- "@devultur/react": "1.3.0"
58
+ "@devultur/core": "1.3.2",
59
+ "@devultur/react": "1.3.2"
60
60
  },
61
61
  "peerDependencies": {
62
62
  "convex": ">=1.25.0",