@devultur/convex 1.3.2 → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +426 -9
  2. package/dist/bin/init.js +44 -23
  3. package/dist/chunk-EX3W77VY.js +245 -0
  4. package/dist/chunk-HOFTKJWT.js +58 -0
  5. package/dist/chunk-KAIVI7RB.js +21 -0
  6. package/dist/chunk-QBHIZBOQ.js +139 -0
  7. package/dist/{chunk-GKLFRSGI.js → chunk-YIPZZDGD.js} +8 -1
  8. package/dist/component/_generated/api.d.ts +6 -0
  9. package/dist/component/_generated/api.d.ts.map +1 -1
  10. package/dist/component/_generated/api.js.map +1 -1
  11. package/dist/component/_generated/component.d.ts +27 -0
  12. package/dist/component/_generated/component.d.ts.map +1 -1
  13. package/dist/component/library.d.ts +93 -0
  14. package/dist/component/library.d.ts.map +1 -0
  15. package/dist/component/library.js +74 -0
  16. package/dist/component/library.js.map +1 -0
  17. package/dist/component/mediaMeta.d.ts +46 -0
  18. package/dist/component/mediaMeta.d.ts.map +1 -0
  19. package/dist/component/mediaMeta.js +82 -0
  20. package/dist/component/mediaMeta.js.map +1 -0
  21. package/dist/component/metadata.d.ts +59 -0
  22. package/dist/component/metadata.d.ts.map +1 -0
  23. package/dist/component/metadata.js +32 -0
  24. package/dist/component/metadata.js.map +1 -0
  25. package/dist/component/public.d.ts +17 -1
  26. package/dist/component/public.d.ts.map +1 -1
  27. package/dist/component/public.js +20 -11
  28. package/dist/component/public.js.map +1 -1
  29. package/dist/component/schema.d.ts +17 -2
  30. package/dist/component/schema.d.ts.map +1 -1
  31. package/dist/component/schema.js +13 -1
  32. package/dist/component/schema.js.map +1 -1
  33. package/dist/component/uploads.d.ts +13 -0
  34. package/dist/component/uploads.d.ts.map +1 -1
  35. package/dist/component/uploads.js +24 -5
  36. package/dist/component/uploads.js.map +1 -1
  37. package/dist/error-labels-BfqcFh2R.d.ts +19 -0
  38. package/dist/index.d.ts +204 -93
  39. package/dist/index.js +200 -128
  40. package/dist/locales/es.d.ts +11 -0
  41. package/dist/locales/es.js +162 -0
  42. package/dist/media-picker-labels-CiVcNUZo.d.ts +114 -0
  43. package/dist/{pdf-render-CiCK6V1X.d.ts → pdf-render-BwJGagvz.d.ts} +1 -1
  44. package/dist/pdfjs.d.ts +13 -2
  45. package/dist/pdfjs.js +9 -0
  46. package/dist/react.d.ts +441 -66
  47. package/dist/react.js +1663 -550
  48. package/dist/types-LrusGVDf.d.ts +62 -0
  49. package/dist/validators.d.ts +17 -5
  50. package/dist/validators.js +1 -1
  51. package/package.json +8 -4
package/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, and prints the Convex env
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:
@@ -122,6 +172,49 @@ The provider sends `{ sessionSecret, ...tokenArgs }` on the first issuance, on e
122
172
  provider drops the current token, private media wait for the new one, and a response for the previous arguments is
123
173
  ignored. Your action returns what the component's action returns: `{ token, expiresIn }` or the token string.
124
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
+
125
218
  ## Media library
126
219
 
127
220
  Every upload, whatever its kind, gets a record in the component's `media` table once its bytes are stored, so
@@ -162,15 +255,16 @@ export const imagesPage = query({
162
255
  // React: usePaginatedQuery(api.library.imagesPage, {}, { initialNumItems: 24 })
163
256
  ```
164
257
 
165
- `Devultur.exposeApi` also returns a `listMediaPage` query (`{ type?, paginationOpts }`). It lists every user's media,
166
- so export it only when every caller may see the whole library.
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)).
167
261
 
168
262
  A record is written only after the Worker confirms the object exists, so an abandoned upload never shows up:
169
263
 
170
264
  1. After its PUT, the client calls `confirmUpload({ key, filename, width, height })`. The component asks the Worker
171
265
  for the object (a `HEAD`) and records its size and content type. `useDevultur().upload()` and `useMediaUpload` do
172
266
  this for you when the `api` you pass `<DevulturProvider>` has `confirmUpload` (add it to the functions you export
173
- from `Devultur.exposeApi` in `convex/devultur.ts`). From your server: `devultur.confirmUpload(ctx, { key })`.
267
+ from `defineDevulturFunctions` in `convex/devultur.ts`). From your server: `devultur.confirmUpload(ctx, { key })`.
174
268
  2. `createUploadUrl` also schedules the same check for 25 hours later, past the longest an upload can take. An upload
175
269
  whose client never confirmed is recorded then; one that was never stored, or was deleted, isn't.
176
270
 
@@ -178,11 +272,9 @@ A record's `key` is the media id for every kind (what `getMedia` takes), and `ob
178
272
  `confirmUpload` only accepts upload keys (`{visibility}/{image|file}/{id}/{name}`, `{visibility}/video/{id}/raw/{name}`)
179
273
  and is idempotent: a video's transcode state stays as it is.
180
274
 
181
- `confirmUpload`, like every function `exposeApi` returns (and the `devultur-init` template exports), has no auth of
182
- its own: anyone who can call your Convex deployment can call it. Wrap it with your app's auth, as you would the other
183
- exposed functions: export your own action that checks `ctx.auth` and then calls `devultur.confirmUpload(ctx, { key })`.
184
- The caller only names the key: the record's visibility, size and content type come from the Worker's `HEAD` of the
185
- 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).
186
278
 
187
279
  `deleteMedia` removes an image's or a file's record once the Worker
188
280
  deleted the object (a delete refused by a lock keeps it). `deleteVideo` removes a video's.
@@ -190,6 +282,296 @@ deleted the object (a delete refused by a lock keeps it). `deleteVideo` removes
190
282
  A video only `processVideo` recorded has no `visibility` or `objectKey`, and `mediaFromRecord` returns `null` for
191
283
  it. `listMedia` doesn't report an object's lock: read it from an action with `devultur.getLock(ctx, record.objectKey)`.
192
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
+
193
575
  ## Using a self-hosted Worker
194
576
 
195
577
  The component talks to any Worker built with `createWorker()` from
@@ -263,6 +645,41 @@ try {
263
645
  `isDevulturError(error)` is the matching type guard. Errors that aren't Worker refusals, such as a network failure,
264
646
  are rethrown unchanged.
265
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
+
266
683
  ## Object lock
267
684
 
268
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
- const content = `import { CaptionLocale, Devultur, TranscodePreset } from "@devultur/convex";
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");