@devultur/convex 1.3.1 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +462 -9
- package/dist/bin/init.js +44 -23
- package/dist/chunk-EX3W77VY.js +245 -0
- package/dist/chunk-HOFTKJWT.js +58 -0
- package/dist/chunk-KAIVI7RB.js +21 -0
- package/dist/chunk-QBHIZBOQ.js +139 -0
- package/dist/{chunk-GKLFRSGI.js → chunk-YIPZZDGD.js} +8 -1
- package/dist/component/_generated/api.d.ts +6 -0
- package/dist/component/_generated/api.d.ts.map +1 -1
- package/dist/component/_generated/api.js.map +1 -1
- package/dist/component/_generated/component.d.ts +27 -0
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/library.d.ts +93 -0
- package/dist/component/library.d.ts.map +1 -0
- package/dist/component/library.js +74 -0
- package/dist/component/library.js.map +1 -0
- package/dist/component/mediaMeta.d.ts +46 -0
- package/dist/component/mediaMeta.d.ts.map +1 -0
- package/dist/component/mediaMeta.js +82 -0
- package/dist/component/mediaMeta.js.map +1 -0
- package/dist/component/metadata.d.ts +59 -0
- package/dist/component/metadata.d.ts.map +1 -0
- package/dist/component/metadata.js +32 -0
- package/dist/component/metadata.js.map +1 -0
- package/dist/component/public.d.ts +17 -1
- package/dist/component/public.d.ts.map +1 -1
- package/dist/component/public.js +20 -11
- package/dist/component/public.js.map +1 -1
- package/dist/component/schema.d.ts +17 -2
- package/dist/component/schema.d.ts.map +1 -1
- package/dist/component/schema.js +13 -1
- package/dist/component/schema.js.map +1 -1
- package/dist/component/uploads.d.ts +13 -0
- package/dist/component/uploads.d.ts.map +1 -1
- package/dist/component/uploads.js +24 -5
- package/dist/component/uploads.js.map +1 -1
- package/dist/error-labels-BfqcFh2R.d.ts +19 -0
- package/dist/index.d.ts +204 -93
- package/dist/index.js +200 -128
- package/dist/locales/es.d.ts +11 -0
- package/dist/locales/es.js +162 -0
- package/dist/media-picker-labels-CiVcNUZo.d.ts +114 -0
- package/dist/{pdf-render-CiCK6V1X.d.ts → pdf-render-BwJGagvz.d.ts} +1 -1
- package/dist/pdfjs.d.ts +13 -2
- package/dist/pdfjs.js +9 -0
- package/dist/react.d.ts +462 -64
- package/dist/react.js +1625 -493
- package/dist/types-LrusGVDf.d.ts +62 -0
- package/dist/validators.d.ts +13 -1
- package/dist/validators.js +1 -1
- package/package.json +8 -4
package/README.md
CHANGED
|
@@ -12,7 +12,8 @@ pnpm exec devultur-init
|
|
|
12
12
|
```
|
|
13
13
|
|
|
14
14
|
`devultur-init` adds the component to `convex/convex.config.ts` (mounted with `httpPrefix: "/devultur/"`, see
|
|
15
|
-
[Webhook route](#webhook-route)), creates `convex/devultur.ts` with the exposed functions
|
|
15
|
+
[Webhook route](#webhook-route)), creates `convex/devultur.ts` with the exposed functions behind a sign-in guard (see
|
|
16
|
+
[Guarding the component's functions](#guarding-the-components-functions)), and prints the Convex env
|
|
16
17
|
vars to set (your media Worker's URL and credential).
|
|
17
18
|
|
|
18
19
|
## Webhook route
|
|
@@ -67,6 +68,55 @@ function CoverUpload({ onSaved }) {
|
|
|
67
68
|
}
|
|
68
69
|
```
|
|
69
70
|
|
|
71
|
+
## Guarding the component's functions
|
|
72
|
+
|
|
73
|
+
`defineDevulturFunctions(devultur, { guard })` returns the functions your app exposes to the browser (`createUploadUrl`,
|
|
74
|
+
`confirmUpload`, `updateMediaMeta`, `issueViewerToken`, `getMedia`, `listMediaPage`, `searchMedia`, `deleteMedia`,
|
|
75
|
+
`deleteVideo`, `processVideo` and the progress functions), and each one awaits `guard(ctx, { name, kind, args })` before
|
|
76
|
+
it runs. Throw from the guard to deny the call. `listMediaPage`, `searchMedia` and `updateMediaMeta` are library-wide:
|
|
77
|
+
they read or edit every user's media, so the guard decides who may call them. Export the functions your app uses, one
|
|
78
|
+
by one:
|
|
79
|
+
|
|
80
|
+
```typescript
|
|
81
|
+
// convex/devultur.ts
|
|
82
|
+
import { Devultur, defineDevulturFunctions } from "@devultur/convex";
|
|
83
|
+
import { ConvexError } from "convex/values";
|
|
84
|
+
import { components } from "./_generated/api";
|
|
85
|
+
|
|
86
|
+
const devultur = new Devultur(components.devultur);
|
|
87
|
+
|
|
88
|
+
export const { createUploadUrl, confirmUpload, issueViewerToken, getMedia, listMediaPage, deleteMedia } =
|
|
89
|
+
defineDevulturFunctions(devultur, {
|
|
90
|
+
guard: async (ctx, operation) => {
|
|
91
|
+
const identity = await ctx.auth.getUserIdentity();
|
|
92
|
+
if (!identity) throw new ConvexError("Sign in first");
|
|
93
|
+
// `operation` narrows on `name`: its `args` are that function's arguments.
|
|
94
|
+
if (operation.name === "deleteMedia" && !(await canDelete(ctx, identity, operation.args.key))) {
|
|
95
|
+
throw new ConvexError("You can't delete this file");
|
|
96
|
+
}
|
|
97
|
+
return { userId: identity.subject };
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
- `kind` is `"query"`, `"mutation"` or `"action"`, and `ctx` is that kind's context (every one has `ctx.auth`).
|
|
103
|
+
- The guard may return `{ userId }`: `issueViewerToken` then issues the viewer token for that user (its subject)
|
|
104
|
+
instead of the shared `viewer` one. The browser never names it: the exposed function only takes `sessionSecret`.
|
|
105
|
+
- That `userId` is also the only user the progress functions (`saveProgress`, `loadProgress`, `markComplete`,
|
|
106
|
+
`resetProgress`) act for: a call whose `userId` is another user's is refused with `user_mismatch` (403), so a
|
|
107
|
+
signed-in user can't read or overwrite someone else's progress. Return `{ userId, canActForOthers: true }` for a
|
|
108
|
+
caller who may (an admin resetting a learner's progress). A guard that returns no `userId` keeps the browser's, as
|
|
109
|
+
`exposeApi` does: return one whenever the progress functions are exported.
|
|
110
|
+
- `visibility: "internal"` registers them as internal functions, for your own actions and mutations to call.
|
|
111
|
+
- `config` takes what `Devultur.exposeApi` does (transcode preset, caption locales, upload lock).
|
|
112
|
+
- Each function keeps its arguments and result types, so `api.devultur.confirmUpload` is typed as before and
|
|
113
|
+
`<DevulturProvider api={api.devultur}>` takes them as it takes `exposeApi`'s.
|
|
114
|
+
|
|
115
|
+
> **`Devultur.exposeApi` has no auth.** It returns the same functions with no guard: anyone who can reach your Convex
|
|
116
|
+
> deployment can create upload URLs, confirm uploads, list the whole library and delete media. It keeps working, but
|
|
117
|
+
> use it only for a deployment where every caller is trusted, or move to `defineDevulturFunctions`, which takes the
|
|
118
|
+
> same `config`.
|
|
119
|
+
|
|
70
120
|
## Upload progress
|
|
71
121
|
|
|
72
122
|
`upload(file, { onProgress, signal })` reports the PUT's progress and resolves to the stored reference:
|
|
@@ -86,6 +136,85 @@ The PUT goes through `XMLHttpRequest` (`fetch` reports no upload progress), in p
|
|
|
86
136
|
optional field of `DevulturMedia` and of `dv.media()`. It is informational: delivery URLs are derived from `id`,
|
|
87
137
|
`kind`, `visibility` and `filename`.
|
|
88
138
|
|
|
139
|
+
## Multiple workspaces
|
|
140
|
+
|
|
141
|
+
An app that shows private media from several workspaces (an admin console that previews each one, for example) can
|
|
142
|
+
send extra arguments with every viewer token request. Pass them in `tokenArgs`, and pass your own action in
|
|
143
|
+
`issueViewerToken`: the component's `issueViewerToken` accepts only `sessionSecret`.
|
|
144
|
+
|
|
145
|
+
```typescript
|
|
146
|
+
// convex/admin.ts
|
|
147
|
+
import { v } from "convex/values";
|
|
148
|
+
import { components } from "./_generated/api";
|
|
149
|
+
import { action } from "./_generated/server";
|
|
150
|
+
|
|
151
|
+
export const issueWorkspaceViewerToken = action({
|
|
152
|
+
args: { sessionSecret: v.string(), workspaceId: v.id("workspaces") },
|
|
153
|
+
handler: async (ctx, { sessionSecret, workspaceId }) => {
|
|
154
|
+
await assertWorkspaceAccess(ctx, workspaceId); // your own check
|
|
155
|
+
return ctx.runAction(components.devultur.public.issueViewerToken, { sessionSecret });
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
```tsx
|
|
161
|
+
<DevulturProvider
|
|
162
|
+
api={api.devultur}
|
|
163
|
+
issueViewerToken={api.admin.issueWorkspaceViewerToken}
|
|
164
|
+
tokenArgs={{ workspaceId }}
|
|
165
|
+
>
|
|
166
|
+
<WorkspaceMedia />
|
|
167
|
+
</DevulturProvider>
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
The provider sends `{ sessionSecret, ...tokenArgs }` on the first issuance, on each renewal and on each re-issue.
|
|
171
|
+
`tokenArgs` are compared by value, so a new object with the same values changes nothing. When they change, the
|
|
172
|
+
provider drops the current token, private media wait for the new one, and a response for the previous arguments is
|
|
173
|
+
ignored. Your action returns what the component's action returns: `{ token, expiresIn }` or the token string.
|
|
174
|
+
|
|
175
|
+
### Display-only apps
|
|
176
|
+
|
|
177
|
+
An app that only shows media (no uploads, no deletes) uses `<DevulturViewerProvider>`, which needs only the base URL
|
|
178
|
+
and a viewer token action:
|
|
179
|
+
|
|
180
|
+
```tsx
|
|
181
|
+
import { DevulturViewerProvider, Preview } from "@devultur/convex/react";
|
|
182
|
+
|
|
183
|
+
<DevulturViewerProvider
|
|
184
|
+
baseUrl={MEDIA_URL}
|
|
185
|
+
issueViewerToken={api.admin.issueWorkspaceViewerToken}
|
|
186
|
+
tokenArgs={{ workspaceId }}
|
|
187
|
+
>
|
|
188
|
+
<Preview media={document.file} />
|
|
189
|
+
</DevulturViewerProvider>;
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
`<Image>`, `<Preview>`, `<PdfThumbnail>`, `<DownloadLink>`, `<DocumentLink>`, `<AttachmentCard>`, `useMediaObjectUrl`,
|
|
193
|
+
`useMediaUrl` and `useVideoProps` work under it. `useDevultur().upload`, `uploadUrl` and `deleteMedia`, and the hooks
|
|
194
|
+
that need the `api` (`useDevulturApi`, `useMediaUpload`, `<MediaLibrary>`, `<MediaPicker>`), throw a
|
|
195
|
+
`DevulturCapabilityError` whose `code` is `capability_unavailable`.
|
|
196
|
+
|
|
197
|
+
### When the viewer token can't be issued
|
|
198
|
+
|
|
199
|
+
`useDevultur()` tells where the viewer token stands:
|
|
200
|
+
|
|
201
|
+
```tsx
|
|
202
|
+
const { tokenStatus, tokenError, retryToken } = useDevultur();
|
|
203
|
+
// tokenStatus: "idle" | "loading" | "ready" | "error"
|
|
204
|
+
if (tokenStatus === "error") return <button onClick={retryToken}>Try again</button>; // tokenError has why
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
The status turns `"error"` when the action throws (your guard refused, for example) or returns no token, and stays
|
|
208
|
+
there until `retryToken()`: a failed issuance isn't retried on its own. Meanwhile private media fails at once with the
|
|
209
|
+
`MediaFetchError` code `token_unavailable` instead of loading: `useMediaObjectUrl` returns it as `error`,
|
|
210
|
+
`<Preview>` passes it to its `fallback`, `<PdfThumbnail>` and `<Image>` render their `fallback` (`<Image onLoadError>`
|
|
211
|
+
receives it), and `<DownloadLink onDownloadError>` receives it on click. They load once `retryToken()` gets a token.
|
|
212
|
+
|
|
213
|
+
`<Preview>`'s video re-issues the token once when the delivery answers the playlist or a segment with a 401
|
|
214
|
+
`session_mismatch`; after that, or on any other 401, it renders its `fallback` with `session_mismatch` or
|
|
215
|
+
`token_unavailable`. `<VideoPlayer onError>` (from `@devultur/react`) reports these failures when you render the
|
|
216
|
+
player yourself.
|
|
217
|
+
|
|
89
218
|
## Media library
|
|
90
219
|
|
|
91
220
|
Every upload, whatever its kind, gets a record in the component's `media` table once its bytes are stored, so
|
|
@@ -126,15 +255,16 @@ export const imagesPage = query({
|
|
|
126
255
|
// React: usePaginatedQuery(api.library.imagesPage, {}, { initialNumItems: 24 })
|
|
127
256
|
```
|
|
128
257
|
|
|
129
|
-
`
|
|
130
|
-
so
|
|
258
|
+
`defineDevulturFunctions` also returns a `listMediaPage` query (`{ type?, paginationOpts }`). It lists every user's
|
|
259
|
+
media, so its guard decides who may see the whole library (see
|
|
260
|
+
[Guarding the component's functions](#guarding-the-components-functions)).
|
|
131
261
|
|
|
132
262
|
A record is written only after the Worker confirms the object exists, so an abandoned upload never shows up:
|
|
133
263
|
|
|
134
264
|
1. After its PUT, the client calls `confirmUpload({ key, filename, width, height })`. The component asks the Worker
|
|
135
265
|
for the object (a `HEAD`) and records its size and content type. `useDevultur().upload()` and `useMediaUpload` do
|
|
136
266
|
this for you when the `api` you pass `<DevulturProvider>` has `confirmUpload` (add it to the functions you export
|
|
137
|
-
from `
|
|
267
|
+
from `defineDevulturFunctions` in `convex/devultur.ts`). From your server: `devultur.confirmUpload(ctx, { key })`.
|
|
138
268
|
2. `createUploadUrl` also schedules the same check for 25 hours later, past the longest an upload can take. An upload
|
|
139
269
|
whose client never confirmed is recorded then; one that was never stored, or was deleted, isn't.
|
|
140
270
|
|
|
@@ -142,11 +272,9 @@ A record's `key` is the media id for every kind (what `getMedia` takes), and `ob
|
|
|
142
272
|
`confirmUpload` only accepts upload keys (`{visibility}/{image|file}/{id}/{name}`, `{visibility}/video/{id}/raw/{name}`)
|
|
143
273
|
and is idempotent: a video's transcode state stays as it is.
|
|
144
274
|
|
|
145
|
-
`confirmUpload
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
The caller only names the key: the record's visibility, size and content type come from the Worker's `HEAD` of the
|
|
149
|
-
object, never from the arguments.
|
|
275
|
+
`confirmUpload` runs your guard first, like every function `defineDevulturFunctions` returns. The record's
|
|
276
|
+
visibility, size and content type come from the Worker's `HEAD` of the object, never from the arguments; the only
|
|
277
|
+
values a caller supplies are the hints described in [Media metadata](#media-metadata).
|
|
150
278
|
|
|
151
279
|
`deleteMedia` removes an image's or a file's record once the Worker
|
|
152
280
|
deleted the object (a delete refused by a lock keeps it). `deleteVideo` removes a video's.
|
|
@@ -154,6 +282,296 @@ deleted the object (a delete refused by a lock keeps it). `deleteVideo` removes
|
|
|
154
282
|
A video only `processVideo` recorded has no `visibility` or `objectKey`, and `mediaFromRecord` returns `null` for
|
|
155
283
|
it. `listMedia` doesn't report an object's lock: read it from an action with `devultur.getLock(ctx, record.objectKey)`.
|
|
156
284
|
|
|
285
|
+
### Media metadata
|
|
286
|
+
|
|
287
|
+
A record carries what is known about its media. All of these fields are optional, so records and `dv.media()`
|
|
288
|
+
references stored before 1.4.0 stay valid without a migration.
|
|
289
|
+
|
|
290
|
+
| Field | Set by |
|
|
291
|
+
| --- | --- |
|
|
292
|
+
| `sizeBytes` | The Worker's `HEAD` of the object, on every confirm. |
|
|
293
|
+
| `width`, `height` | The Worker, which reads an image's size from its header bytes (PNG, JPEG, GIF, WebP, AVIF) on a single PUT and returns it on `HEAD` (`x-devultur-image-width` / `-height`). Otherwise, the client's hint. |
|
|
294
|
+
| `duration` | The transcode, in seconds (`transcode.started` / `transcode.complete`). |
|
|
295
|
+
| `pageCount` | The client's hint, recorded for PDFs only. |
|
|
296
|
+
| `title`, `alt`, `tags` | Your app, with `updateMediaMeta`. |
|
|
297
|
+
|
|
298
|
+
**Client hints.** `confirmUpload` accepts `width`, `height` and `pageCount` from the client. They are the uploader's
|
|
299
|
+
word, not checked against the bytes: whole numbers from 1 to 100,000. Another value is dropped (with a warning in the
|
|
300
|
+
logs) and the upload is recorded without it: the bytes are already stored.
|
|
301
|
+
A size the Worker read wins over the client's. To record a PDF's page count at upload, count it in the browser with
|
|
302
|
+
pdf.js (imported on the first call; `pdfjs-dist` must be installed):
|
|
303
|
+
|
|
304
|
+
```tsx
|
|
305
|
+
import { countPdfPages } from "@devultur/convex/pdfjs";
|
|
306
|
+
|
|
307
|
+
const pageCount = file.type === "application/pdf" ? ((await countPdfPages(file)) ?? undefined) : undefined;
|
|
308
|
+
const media = await upload(file, { pageCount });
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
**Editing.** `updateMediaMeta({ key, title?, alt?, tags? })` sets only the fields it's given. Values are trimmed, an
|
|
312
|
+
empty `title` or `alt` clears it, and tags are deduplicated (case-insensitively, first spelling kept). Past a title of
|
|
313
|
+
200 characters, an alt text of 1,000, or 20 tags of 50 characters each, it fails with `invalid_media_metadata` (its
|
|
314
|
+
`details.field` names the field). It returns the updated record, or `null` when no record has that media id.
|
|
315
|
+
|
|
316
|
+
```typescript
|
|
317
|
+
// From the browser: export it from defineDevulturFunctions, so your guard decides who may edit which media.
|
|
318
|
+
export const { updateMediaMeta } = defineDevulturFunctions(devultur, { guard });
|
|
319
|
+
// From your server:
|
|
320
|
+
await devultur.updateMediaMeta(ctx, { key: record.key, title: "Cover", alt: "A mountain at dawn", tags: ["hero"] });
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
`mediaFromRecord(record)` copies the metadata into the reference, and `<Image>` uses `media.alt` when you pass no
|
|
324
|
+
`alt`. A stored reference is a copy: after an edit, read the record again (`getMedia`) or store the new reference.
|
|
325
|
+
|
|
326
|
+
Each write also keeps a `searchText` field (the title and filename, lowercased) with a `search_text` search index over
|
|
327
|
+
it, filterable by `type`. Records written before 1.4.0 get theirs from [the backfill](#records-from-before-140) or
|
|
328
|
+
from their next write.
|
|
329
|
+
|
|
330
|
+
### Searching the library
|
|
331
|
+
|
|
332
|
+
`searchMedia({ search, type?, paginationOpts })` pages through the records whose title or filename matches `search`,
|
|
333
|
+
best match first, optionally of one type. Convex's full-text search matches whole words, and the last word as a
|
|
334
|
+
prefix (`cov` finds "Cover photo"). A blank `search` lists the library newest first, like `listMediaPage`.
|
|
335
|
+
|
|
336
|
+
```typescript
|
|
337
|
+
// Behind your guard: like listMediaPage, it searches every user's media.
|
|
338
|
+
export const { listMediaPage, searchMedia } = defineDevulturFunctions(devultur, { guard });
|
|
339
|
+
// From your server:
|
|
340
|
+
await devultur.searchMedia(ctx, { search: "cover", type: "image", paginationOpts });
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
#### Records from before 1.4.0
|
|
344
|
+
|
|
345
|
+
Records written before 1.4.0 have no `searchText`, so `searchMedia` doesn't find them (`listMediaPage` still lists
|
|
346
|
+
them). Any write to one gives it its `searchText` (a transcode update, a confirmed upload, `updateMediaMeta`). To fill
|
|
347
|
+
them all at once, run the backfill once after upgrading. It fills 100 records per mutation and schedules the next
|
|
348
|
+
batch until it reaches the end of the table; running it again only fills what is still missing.
|
|
349
|
+
|
|
350
|
+
```bash
|
|
351
|
+
npx convex run --component devultur library:backfillSearchText '{}'
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
Or from a function of your own, for example an internal mutation you run from the dashboard:
|
|
355
|
+
|
|
356
|
+
```typescript
|
|
357
|
+
// convex/maintenance.ts
|
|
358
|
+
import { internalMutation } from "./_generated/server";
|
|
359
|
+
|
|
360
|
+
export const backfillMediaSearch = internalMutation({
|
|
361
|
+
args: {},
|
|
362
|
+
// { updated, isDone }: the first batch's count, and false while more batches are scheduled.
|
|
363
|
+
handler: async (ctx) => devultur.backfillSearchText(ctx, { batchSize: 200 }), // 1 to 1000, default 100
|
|
364
|
+
});
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
### React: `useMediaLibrary` and `<MediaLibrary>`
|
|
368
|
+
|
|
369
|
+
Both read the library through the `listMediaPage` and `searchMedia` you pass `<DevulturProvider api>`.
|
|
370
|
+
|
|
371
|
+
`useMediaLibrary({ type?, search?, pageSize? })` wraps Convex's `usePaginatedQuery`. It waits until `search` has
|
|
372
|
+
stayed the same for 250 ms before querying (clearing it applies at once), and a new search or type starts again from
|
|
373
|
+
the first page.
|
|
374
|
+
|
|
375
|
+
```tsx
|
|
376
|
+
import { useMediaLibrary } from "@devultur/convex/react";
|
|
377
|
+
|
|
378
|
+
const { items, status, loadMore, isLoading, isSearchPending } = useMediaLibrary({ type: "image", search, pageSize: 24 });
|
|
379
|
+
// status: "LoadingFirstPage" | "CanLoadMore" | "LoadingMore" | "Exhausted"; loadMore() loads pageSize more.
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
Like `usePaginatedQuery`, a query that fails (your guard throws, for example) throws during render to the nearest
|
|
383
|
+
error boundary. `<MediaLibrary>` has its own.
|
|
384
|
+
|
|
385
|
+
`<MediaLibrary>` is a grid with a search box and a type filter. Images show a 160 px thumbnail (`<Image widths>`,
|
|
386
|
+
`format: "auto"`); other media show an icon, and every item shows its title (or filename) and size.
|
|
387
|
+
|
|
388
|
+
```tsx
|
|
389
|
+
import { MediaLibrary } from "@devultur/convex/react";
|
|
390
|
+
|
|
391
|
+
<MediaLibrary type="image" selectable onSelect={(record, media) => setCover(media)} />;
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
| Prop | |
|
|
395
|
+
| --- | --- |
|
|
396
|
+
| `onSelect(record, media)` | Called on a click, Enter or Space. `media` is `mediaFromRecord(record)`, `null` for a record without a visibility. |
|
|
397
|
+
| `type` | Only this type; the type filter is hidden. |
|
|
398
|
+
| `selectable` | Keeps the chosen item selected (`aria-selected`, `data-selected`), for a picker. Default `false`. |
|
|
399
|
+
| `selectedKeys` | The selected records' keys (media ids), controlled: shown as selected instead of the last one chosen. Implies `selectable`. |
|
|
400
|
+
| `multiple` | Several items can be selected (`aria-multiselectable`), with `selectedKeys`. |
|
|
401
|
+
| `filter(record)` | Shows only the records it returns `true` for, of each loaded page (a page can then show fewer items). |
|
|
402
|
+
| `renderItem(record, { media, isSelected, isActive })` | Replaces an item's content; the item stays the focusable option. |
|
|
403
|
+
| `pdfThumbnails` | Draws page 1 of PDFs with `<PdfThumbnail>` (inside a `PdfPreviewProvider`). Off by default, because each thumbnail downloads its PDF. |
|
|
404
|
+
| `pageSize` | Records per page. Default 24. |
|
|
405
|
+
| `labels` | Any of the strings below. |
|
|
406
|
+
| `className`, `classNames` | Added to the root's and each part's class. |
|
|
407
|
+
|
|
408
|
+
The items are a `listbox` of `option`s with one tab stop: the arrow keys move between them (up and down by a row of the
|
|
409
|
+
grid), Home and End go to the first and last. While the first page loads, the library is empty, or nothing matches,
|
|
410
|
+
a `role="status"` message replaces the grid. A failed query shows `labels.error(error)` in a `role="alert"` with a
|
|
411
|
+
retry button; by default, a Devultur error's message (see [Error messages](#error-messages)).
|
|
412
|
+
|
|
413
|
+
The component sets only its layout. Each part has a class (`devultur-media-library`, and
|
|
414
|
+
`devultur-media-library__{search,filter,list,item,thumbnail,name,size,status,more}`), and these custom properties
|
|
415
|
+
tune the grid: `--devultur-library-item-width` (160px, the narrowest column), `--devultur-library-gap` (0.75rem),
|
|
416
|
+
`--devultur-library-item-gap` (0.25rem), `--devultur-library-thumbnail-height` (120px) and
|
|
417
|
+
`--devultur-library-accent` (`currentColor`, the selected item's ring).
|
|
418
|
+
|
|
419
|
+
The strings are English by default; `@devultur/convex/locales/es` exports Spanish ones:
|
|
420
|
+
|
|
421
|
+
```tsx
|
|
422
|
+
import { mediaLibraryLabels } from "@devultur/convex/locales/es";
|
|
423
|
+
|
|
424
|
+
<MediaLibrary labels={mediaLibraryLabels} onSelect={handleSelect} />;
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
## Media picker
|
|
428
|
+
|
|
429
|
+
`<MediaPicker>` is what a content editor uses to fill a media field: it uploads new files (with progress, cancel and
|
|
430
|
+
retry), browses and searches the library (`<MediaLibrary>`), and shows the selection (`<AttachmentCard>`, with an
|
|
431
|
+
inline `<Preview>`). It returns `DevulturMedia` references through `onChange`, ready for a `dv.media()` field: a
|
|
432
|
+
library item's reference carries its `key`, `title`, `alt`, `tags`, size and page count; a new upload's carries its
|
|
433
|
+
`key`, size and page count, and is selected as soon as it uploads.
|
|
434
|
+
|
|
435
|
+
It needs `createUploadUrl`, `listMediaPage` and `searchMedia` in `<DevulturProvider api>` (and `confirmUpload`, so
|
|
436
|
+
uploads show in the library).
|
|
437
|
+
|
|
438
|
+
```tsx
|
|
439
|
+
import { MediaPicker } from "@devultur/convex/react";
|
|
440
|
+
|
|
441
|
+
<MediaPicker accept={["image/*"]} value={cover} onChange={setCover} />;
|
|
442
|
+
<MediaPicker multiple value={attachments} onChange={setAttachments} />; // arrays in and out
|
|
443
|
+
```
|
|
444
|
+
|
|
445
|
+
| Prop | |
|
|
446
|
+
| --- | --- |
|
|
447
|
+
| `value` | The selection, controlled: a `DevulturMedia` or `null` (an array with `multiple`). Omitted, the picker keeps its own. |
|
|
448
|
+
| `onChange(value)` | Called on an upload, a pick from the library, a removal and a clear. |
|
|
449
|
+
| `multiple` | Several items: uploads add to the selection, and a library item toggles. Default `false`: a new pick replaces the item. |
|
|
450
|
+
| `accept` | File types, as `<input accept>` takes them (`image/*`, `application/pdf`, `.pdf`). Filters the files to upload and the library's items, and sets the library's type filter when every pattern is of one type. |
|
|
451
|
+
| `type` | Only `image`, `video` or `file` in the library; without `accept`, also what can be uploaded. |
|
|
452
|
+
| `labels` | Any of the picker's strings (`MediaPickerLabels`). |
|
|
453
|
+
| `libraryLabels`, `attachmentLabels` | The library's and the cards' strings. |
|
|
454
|
+
| `className` | Added to the root's `devultur-media-picker` class. |
|
|
455
|
+
| `children` | The layout (below). Default: the upload, the library and the selection. |
|
|
456
|
+
|
|
457
|
+
Under a `PdfPreviewProvider` with `pdfjs`, the picker reads a PDF's page count before it uploads it and records it
|
|
458
|
+
with the upload (`pageCount`). Without one, PDFs upload without it.
|
|
459
|
+
|
|
460
|
+
### Layout
|
|
461
|
+
|
|
462
|
+
The picker renders inline and has no dialog of its own. Its parts are `<MediaPicker.Upload>`, `<MediaPicker.Library>`
|
|
463
|
+
and `<MediaPicker.Selection>`; put them in any markup of yours, or pass a function as `children` to get the picker's
|
|
464
|
+
state (`selected`, `uploads`, `select`, `remove`, `clear`, `openFileDialog`, ...). `useMediaPicker()` returns the same
|
|
465
|
+
state, to build a part of your own.
|
|
466
|
+
|
|
467
|
+
```tsx
|
|
468
|
+
<MediaPicker multiple value={files} onChange={setFiles}>
|
|
469
|
+
{(picker) => (
|
|
470
|
+
<div className="grid grid-cols-[2fr_1fr] gap-4">
|
|
471
|
+
<MediaPicker.Library pageSize={12} />
|
|
472
|
+
<aside>
|
|
473
|
+
<MediaPicker.Upload />
|
|
474
|
+
<MediaPicker.Selection headingLevel={2} />
|
|
475
|
+
<p>{picker.selected.length} selected</p>
|
|
476
|
+
</aside>
|
|
477
|
+
</div>
|
|
478
|
+
)}
|
|
479
|
+
</MediaPicker>
|
|
480
|
+
```
|
|
481
|
+
|
|
482
|
+
### In a dialog, for a form field
|
|
483
|
+
|
|
484
|
+
A form keeps the reference in its state and saves it with the rest; the dialog only edits a draft of it. Here with a
|
|
485
|
+
native `<dialog>` (it traps focus and closes on Escape); any dialog component works the same way.
|
|
486
|
+
|
|
487
|
+
```tsx
|
|
488
|
+
import type { DevulturMedia } from "@devultur/core";
|
|
489
|
+
import { MediaPicker } from "@devultur/convex/react";
|
|
490
|
+
import { useMutation } from "convex/react";
|
|
491
|
+
import { api } from "../convex/_generated/api";
|
|
492
|
+
|
|
493
|
+
function CoverField({ value, onChange }: { value: DevulturMedia | null; onChange: (cover: DevulturMedia | null) => void }) {
|
|
494
|
+
const dialogRef = React.useRef<HTMLDialogElement>(null);
|
|
495
|
+
const [draft, setDraft] = React.useState(value);
|
|
496
|
+
|
|
497
|
+
const handleOpen = () => {
|
|
498
|
+
setDraft(value);
|
|
499
|
+
dialogRef.current?.showModal();
|
|
500
|
+
};
|
|
501
|
+
const handleConfirm = () => {
|
|
502
|
+
onChange(draft);
|
|
503
|
+
dialogRef.current?.close();
|
|
504
|
+
};
|
|
505
|
+
|
|
506
|
+
return (
|
|
507
|
+
<>
|
|
508
|
+
<span>{value?.title ?? value?.filename ?? "No cover"}</span>
|
|
509
|
+
<button type="button" onClick={handleOpen}>Choose a cover</button>
|
|
510
|
+
<dialog ref={dialogRef} aria-labelledby="cover-dialog-title">
|
|
511
|
+
<h2 id="cover-dialog-title">Cover</h2>
|
|
512
|
+
<MediaPicker accept={["image/*"]} value={draft} onChange={setDraft} />
|
|
513
|
+
<button type="button" onClick={() => dialogRef.current?.close()}>Cancel</button>
|
|
514
|
+
<button type="button" onClick={handleConfirm}>Use this image</button>
|
|
515
|
+
</dialog>
|
|
516
|
+
</>
|
|
517
|
+
);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function ArticleForm({ article }: { article: { _id: string; title: string; cover: DevulturMedia | null } }) {
|
|
521
|
+
const save = useMutation(api.articles.update); // args: { id, title, cover: dv.media("image").optional() }
|
|
522
|
+
const [title, setTitle] = React.useState(article.title);
|
|
523
|
+
const [cover, setCover] = React.useState(article.cover);
|
|
524
|
+
|
|
525
|
+
const handleSubmit = (event: React.FormEvent) => {
|
|
526
|
+
event.preventDefault();
|
|
527
|
+
save({ id: article._id, title, cover: cover ?? undefined });
|
|
528
|
+
};
|
|
529
|
+
|
|
530
|
+
return (
|
|
531
|
+
<form onSubmit={handleSubmit}>
|
|
532
|
+
<input value={title} onChange={(event) => setTitle(event.target.value)} aria-label="Title" />
|
|
533
|
+
<CoverField value={cover} onChange={setCover} />
|
|
534
|
+
<button type="submit">Save</button>
|
|
535
|
+
</form>
|
|
536
|
+
);
|
|
537
|
+
}
|
|
538
|
+
```
|
|
539
|
+
|
|
540
|
+
Inside a `<form>`, every button of the picker is `type="button"`, so none of them submits it.
|
|
541
|
+
|
|
542
|
+
### Accessibility
|
|
543
|
+
|
|
544
|
+
The upload button opens the file chooser; dropping files on the upload area is the pointer shortcut. Each upload
|
|
545
|
+
shows a labelled `<progress>`, a cancel button and, when it fails, its error in a `role="alert"` (by default a
|
|
546
|
+
Devultur error's message, see [Error messages](#error-messages)), with retry and dismiss. A polite live region
|
|
547
|
+
announces an upload's start, end and cancellation, and each selection and removal. After an upload, a removal and a
|
|
548
|
+
clear, focus moves to the selection's region (`<section>` named by its heading), so a keyboard user lands on the
|
|
549
|
+
result. The library keeps its keyboard navigation, with `aria-multiselectable` under `multiple`.
|
|
550
|
+
|
|
551
|
+
### Labels
|
|
552
|
+
|
|
553
|
+
The strings are English by default (`defaultMediaPickerLabels`). Spanish:
|
|
554
|
+
|
|
555
|
+
```tsx
|
|
556
|
+
import { attachmentLabels, mediaLibraryLabels, mediaPickerLabels } from "@devultur/convex/locales/es";
|
|
557
|
+
|
|
558
|
+
<MediaPicker
|
|
559
|
+
labels={mediaPickerLabels}
|
|
560
|
+
libraryLabels={mediaLibraryLabels}
|
|
561
|
+
attachmentLabels={attachmentLabels}
|
|
562
|
+
value={cover}
|
|
563
|
+
onChange={setCover}
|
|
564
|
+
/>;
|
|
565
|
+
```
|
|
566
|
+
|
|
567
|
+
### Styling
|
|
568
|
+
|
|
569
|
+
The picker sets no styles but the library's layout. Each part has a class (`devultur-media-picker`,
|
|
570
|
+
`devultur-media-picker__upload`, `devultur-media-picker__selection`, and the library's own) and its elements have
|
|
571
|
+
`data-part` attributes (`upload`, `drop-hint`, `uploads`, `filename`, `percent`, `error`, `items`, `empty`,
|
|
572
|
+
`actions`, `replace`, `clear`); the upload area has `data-dragging` while files are dragged over it, and each upload
|
|
573
|
+
`data-status` (`uploading` or `failed`).
|
|
574
|
+
|
|
157
575
|
## Using a self-hosted Worker
|
|
158
576
|
|
|
159
577
|
The component talks to any Worker built with `createWorker()` from
|
|
@@ -227,6 +645,41 @@ try {
|
|
|
227
645
|
`isDevulturError(error)` is the matching type guard. Errors that aren't Worker refusals, such as a network failure,
|
|
228
646
|
are rethrown unchanged.
|
|
229
647
|
|
|
648
|
+
### Error messages
|
|
649
|
+
|
|
650
|
+
`describeDevulturError(error, labels?)` turns an error into a message for your users: a component action's refusal,
|
|
651
|
+
a `DevulturApiError` from `@devultur/core`, or a `MediaFetchError` from a preview or download. It returns `null` for
|
|
652
|
+
any other error, so your app keeps its own fallback. A code it doesn't know gets the generic `worker_error` message.
|
|
653
|
+
|
|
654
|
+
`devulturErrorLabels` is the English map, typed `Record<DevulturErrorCode, string | ((details) => string)>`:
|
|
655
|
+
`DevulturErrorCode` is the union of every stable code (`DEVULTUR_ERROR_CODES` lists them), and a function label reads
|
|
656
|
+
the error's details, such as `file_too_large`'s `limit`. `@devultur/convex/locales/es` exports the Spanish map under
|
|
657
|
+
the same name:
|
|
658
|
+
|
|
659
|
+
```tsx
|
|
660
|
+
import { describeDevulturError } from "@devultur/convex";
|
|
661
|
+
import { devulturErrorLabels as spanishLabels } from "@devultur/convex/locales/es";
|
|
662
|
+
|
|
663
|
+
const deleteMedia = useAction(api.devultur.deleteMedia);
|
|
664
|
+
|
|
665
|
+
try {
|
|
666
|
+
await deleteMedia({ key });
|
|
667
|
+
} catch (error) {
|
|
668
|
+
// "Este archivo está protegido hasta el 15 jun 2030 y no se puede modificar ni eliminar antes de esa fecha."
|
|
669
|
+
setMessage(describeDevulturError(error, spanishLabels) ?? "Algo salió mal.");
|
|
670
|
+
}
|
|
671
|
+
```
|
|
672
|
+
|
|
673
|
+
A failed browser PUT (`upload()`, `useMediaUpload`) rejects with `UploadHttpError`, which carries the HTTP status
|
|
674
|
+
only: match on its `status` for those.
|
|
675
|
+
|
|
676
|
+
Pass a partial map to change some messages and keep the rest, or a full one for another language (typed
|
|
677
|
+
`DevulturErrorLabels`, so a missing code fails to compile):
|
|
678
|
+
|
|
679
|
+
```ts
|
|
680
|
+
describeDevulturError(error, { ...spanishLabels, lock_conflict: "Alguien más acaba de cambiarlo." });
|
|
681
|
+
```
|
|
682
|
+
|
|
230
683
|
## Object lock
|
|
231
684
|
|
|
232
685
|
- `devultur.createUploadUrl(ctx, { filename, contentType, lock, hold })` locks a file from its first write.
|
package/dist/bin/init.js
CHANGED
|
@@ -3,6 +3,49 @@
|
|
|
3
3
|
// src/bin/init.ts
|
|
4
4
|
import { existsSync, readFileSync, writeFileSync } from "fs";
|
|
5
5
|
import { join } from "path";
|
|
6
|
+
|
|
7
|
+
// src/bin/devultur-module-template.ts
|
|
8
|
+
var DEVULTUR_MODULE_TEMPLATE = `import { CaptionLocale, Devultur, defineDevulturFunctions, TranscodePreset } from "@devultur/convex";
|
|
9
|
+
import { ConvexError } from "convex/values";
|
|
10
|
+
import { components } from "./_generated/api";
|
|
11
|
+
|
|
12
|
+
const devultur = new Devultur(components.devultur);
|
|
13
|
+
|
|
14
|
+
// Every function below runs this guard first. Adjust it to your app's auth and rules (narrow on \`operation.name\`).
|
|
15
|
+
export const {
|
|
16
|
+
issueViewerToken,
|
|
17
|
+
createUploadUrl,
|
|
18
|
+
confirmUpload,
|
|
19
|
+
deleteMedia,
|
|
20
|
+
deleteVideo,
|
|
21
|
+
processVideo,
|
|
22
|
+
getMedia,
|
|
23
|
+
// The library lists and searches every user's media: the guard decides who may read it.
|
|
24
|
+
listMediaPage,
|
|
25
|
+
searchMedia,
|
|
26
|
+
// Editing a title, alt text or tags is library-wide too: authorize it in the guard before exporting it.
|
|
27
|
+
// updateMediaMeta,
|
|
28
|
+
saveProgress,
|
|
29
|
+
loadProgress,
|
|
30
|
+
markComplete,
|
|
31
|
+
resetProgress,
|
|
32
|
+
} = defineDevulturFunctions(devultur, {
|
|
33
|
+
guard: async (ctx, operation) => {
|
|
34
|
+
const identity = await ctx.auth.getUserIdentity();
|
|
35
|
+
if (!identity) throw new ConvexError(\`Sign in to call \${operation.name}\`);
|
|
36
|
+
// The progress functions act only for this user: another \`userId\` in their arguments is refused.
|
|
37
|
+
return { userId: identity.subject };
|
|
38
|
+
},
|
|
39
|
+
config: {
|
|
40
|
+
video: {
|
|
41
|
+
transcodePreset: TranscodePreset.HLS_720P,
|
|
42
|
+
captionLocales: [CaptionLocale.EN],
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
`;
|
|
47
|
+
|
|
48
|
+
// src/bin/init.ts
|
|
6
49
|
var CONVEX_DIR = findConvexDir();
|
|
7
50
|
function findConvexDir() {
|
|
8
51
|
const cwd = process.cwd();
|
|
@@ -70,29 +113,7 @@ function createDevulturModule() {
|
|
|
70
113
|
log("\u23ED\uFE0F convex/devultur.ts already exists \u2014 skipping");
|
|
71
114
|
return;
|
|
72
115
|
}
|
|
73
|
-
|
|
74
|
-
import { components } from "./_generated/api";
|
|
75
|
-
|
|
76
|
-
export const {
|
|
77
|
-
issueViewerToken,
|
|
78
|
-
createUploadUrl,
|
|
79
|
-
confirmUpload,
|
|
80
|
-
deleteMedia,
|
|
81
|
-
deleteVideo,
|
|
82
|
-
processVideo,
|
|
83
|
-
getMedia,
|
|
84
|
-
saveProgress,
|
|
85
|
-
loadProgress,
|
|
86
|
-
markComplete,
|
|
87
|
-
resetProgress,
|
|
88
|
-
} = Devultur.exposeApi(components.devultur, {
|
|
89
|
-
video: {
|
|
90
|
-
transcodePreset: TranscodePreset.HLS_720P,
|
|
91
|
-
captionLocales: [CaptionLocale.EN],
|
|
92
|
-
},
|
|
93
|
-
});
|
|
94
|
-
`;
|
|
95
|
-
writeFileSync(modulePath, content);
|
|
116
|
+
writeFileSync(modulePath, DEVULTUR_MODULE_TEMPLATE);
|
|
96
117
|
log("\u2705 Created convex/devultur.ts");
|
|
97
118
|
}
|
|
98
119
|
console.log("\n\u{1F527} @devultur/convex init\n");
|