@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 +36 -0
- package/dist/react.d.ts +70 -9
- package/dist/react.js +265 -178
- package/package.json +3 -3
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
|
-
/**
|
|
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
|
-
/**
|
|
168
|
-
|
|
169
|
-
|
|
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
|
|
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/
|
|
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] =
|
|
243
|
-
const inputRef =
|
|
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 =
|
|
246
|
-
const handleCrop =
|
|
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 =
|
|
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
|
|
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] =
|
|
317
|
-
const inputsRef =
|
|
318
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
386
|
-
function PdfPreviewProvider({
|
|
387
|
-
|
|
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 } =
|
|
400
|
-
const [loaded, setLoaded] =
|
|
401
|
-
|
|
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
|
|
524
|
+
import React7 from "react";
|
|
419
525
|
|
|
420
526
|
// src/pdf-hooks.ts
|
|
421
|
-
import
|
|
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] =
|
|
466
|
-
|
|
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] =
|
|
593
|
+
const [state, setState] = React5.useState({
|
|
488
594
|
bytes: null,
|
|
489
595
|
document: null,
|
|
490
596
|
error: null
|
|
491
597
|
});
|
|
492
|
-
|
|
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] =
|
|
512
|
-
|
|
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
|
|
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,
|
|
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:
|
|
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 ??
|
|
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
|
-
|
|
684
|
+
renderFallback,
|
|
579
685
|
loading,
|
|
580
|
-
|
|
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,
|
|
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 =
|
|
588
|
-
const [width, setWidth] =
|
|
589
|
-
|
|
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:
|
|
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
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
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 =
|
|
612
|
-
const canvasRef =
|
|
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":
|
|
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] =
|
|
630
|
-
|
|
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 =
|
|
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 ??
|
|
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 =
|
|
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:
|
|
699
|
-
const
|
|
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
|
-
|
|
718
|
-
loading
|
|
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:
|
|
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 ??
|
|
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
|
-
|
|
740
|
-
loading
|
|
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:
|
|
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 =
|
|
758
|
-
var DevulturApiContext =
|
|
759
|
-
var ProgressContext =
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
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
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
1027
|
+
const onMarkComplete = React8.useCallback(
|
|
942
1028
|
async (videoId) => {
|
|
943
1029
|
await markCompleteMutation({ videoId });
|
|
944
1030
|
},
|
|
945
1031
|
[markCompleteMutation]
|
|
946
1032
|
);
|
|
947
|
-
const onReset =
|
|
1033
|
+
const onReset = React8.useCallback(
|
|
948
1034
|
async (videoId) => {
|
|
949
1035
|
await resetMutation({ videoId });
|
|
950
1036
|
},
|
|
951
1037
|
[resetMutation]
|
|
952
1038
|
);
|
|
953
|
-
const value =
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
|
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] =
|
|
1032
|
-
const [blobSrc, setBlobSrc] =
|
|
1033
|
-
const sessionRef =
|
|
1034
|
-
|
|
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
|
-
|
|
1125
|
+
React8.useEffect(() => {
|
|
1040
1126
|
setFailed(false);
|
|
1041
1127
|
setBlobSrc(null);
|
|
1042
1128
|
}, [media]);
|
|
1043
|
-
|
|
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 =
|
|
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
|
|
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 =
|
|
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 =
|
|
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
|
|
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 =
|
|
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] =
|
|
1236
|
-
const [percent, setPercent] =
|
|
1237
|
-
const [currentFile, setCurrentFile] =
|
|
1238
|
-
const [media, setMedia] =
|
|
1239
|
-
const [uploadFailure, setUploadFailure] =
|
|
1240
|
-
const [processRequested, setProcessRequested] =
|
|
1241
|
-
const abortRef =
|
|
1242
|
-
const lastFileRef =
|
|
1243
|
-
const lastKeyRef =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
1415
|
+
const cancel = React8.useCallback(() => {
|
|
1330
1416
|
abortRef.current?.abort();
|
|
1331
1417
|
}, []);
|
|
1332
|
-
const reset =
|
|
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 =
|
|
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
|
-
|
|
1349
|
-
const state =
|
|
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 =
|
|
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.
|
|
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.
|
|
59
|
-
"@devultur/react": "1.3.
|
|
58
|
+
"@devultur/core": "1.3.2",
|
|
59
|
+
"@devultur/react": "1.3.2"
|
|
60
60
|
},
|
|
61
61
|
"peerDependencies": {
|
|
62
62
|
"convex": ">=1.25.0",
|