@plumix/plugin-media 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +211 -0
  3. package/dist/admin/FocalPointField.d.ts +22 -0
  4. package/dist/admin/FocalPointField.d.ts.map +1 -0
  5. package/dist/admin/FocalPointField.js +71 -0
  6. package/dist/admin/FocalPointField.js.map +1 -0
  7. package/dist/admin/MediaLibrary.d.ts +43 -0
  8. package/dist/admin/MediaLibrary.d.ts.map +1 -0
  9. package/dist/admin/MediaLibrary.js +698 -0
  10. package/dist/admin/MediaLibrary.js.map +1 -0
  11. package/dist/admin/MediaListPickerField.d.ts +14 -0
  12. package/dist/admin/MediaListPickerField.d.ts.map +1 -0
  13. package/dist/admin/MediaListPickerField.js +161 -0
  14. package/dist/admin/MediaListPickerField.js.map +1 -0
  15. package/dist/admin/MediaPickerField.d.ts +25 -0
  16. package/dist/admin/MediaPickerField.d.ts.map +1 -0
  17. package/dist/admin/MediaPickerField.js +82 -0
  18. package/dist/admin/MediaPickerField.js.map +1 -0
  19. package/dist/admin/MediaUrlField.d.ts +14 -0
  20. package/dist/admin/MediaUrlField.d.ts.map +1 -0
  21. package/dist/admin/MediaUrlField.js +29 -0
  22. package/dist/admin/MediaUrlField.js.map +1 -0
  23. package/dist/admin/index.d.ts +11 -0
  24. package/dist/admin/index.d.ts.map +1 -0
  25. package/dist/admin/index.js +49 -0
  26. package/dist/admin/index.js.map +1 -0
  27. package/dist/admin/messages.d.ts +24 -0
  28. package/dist/admin/messages.d.ts.map +1 -0
  29. package/dist/admin/messages.js +28 -0
  30. package/dist/admin/messages.js.map +1 -0
  31. package/dist/blocks/file/index.d.ts +2 -0
  32. package/dist/blocks/file/index.d.ts.map +1 -0
  33. package/dist/blocks/file/index.js +109 -0
  34. package/dist/blocks/file/index.js.map +1 -0
  35. package/dist/blocks/file/normalize.d.ts +10 -0
  36. package/dist/blocks/file/normalize.d.ts.map +1 -0
  37. package/dist/blocks/file/normalize.js +45 -0
  38. package/dist/blocks/file/normalize.js.map +1 -0
  39. package/dist/blocks/image/index.d.ts +2 -0
  40. package/dist/blocks/image/index.d.ts.map +1 -0
  41. package/dist/blocks/image/index.js +141 -0
  42. package/dist/blocks/image/index.js.map +1 -0
  43. package/dist/blocks/image/normalize.d.ts +7 -0
  44. package/dist/blocks/image/normalize.d.ts.map +1 -0
  45. package/dist/blocks/image/normalize.js +20 -0
  46. package/dist/blocks/image/normalize.js.map +1 -0
  47. package/dist/blocks.d.ts +2 -0
  48. package/dist/blocks.d.ts.map +1 -0
  49. package/dist/blocks.js +6 -0
  50. package/dist/blocks.js.map +1 -0
  51. package/dist/builder.d.ts +80 -0
  52. package/dist/builder.d.ts.map +1 -0
  53. package/dist/builder.js +61 -0
  54. package/dist/builder.js.map +1 -0
  55. package/dist/fields.d.ts +3 -0
  56. package/dist/fields.d.ts.map +1 -0
  57. package/dist/fields.js +7 -0
  58. package/dist/fields.js.map +1 -0
  59. package/dist/image-dimensions.d.ts +7 -0
  60. package/dist/image-dimensions.d.ts.map +1 -0
  61. package/dist/image-dimensions.js +142 -0
  62. package/dist/image-dimensions.js.map +1 -0
  63. package/dist/index.d.ts +62 -0
  64. package/dist/index.d.ts.map +1 -0
  65. package/dist/index.js +216 -0
  66. package/dist/index.js.map +1 -0
  67. package/dist/lookup.d.ts +43 -0
  68. package/dist/lookup.d.ts.map +1 -0
  69. package/dist/lookup.js +154 -0
  70. package/dist/lookup.js.map +1 -0
  71. package/dist/magic-bytes.d.ts +4 -0
  72. package/dist/magic-bytes.d.ts.map +1 -0
  73. package/dist/magic-bytes.js +107 -0
  74. package/dist/magic-bytes.js.map +1 -0
  75. package/dist/mcp-tools.d.ts +10 -0
  76. package/dist/mcp-tools.d.ts.map +1 -0
  77. package/dist/mcp-tools.js +43 -0
  78. package/dist/mcp-tools.js.map +1 -0
  79. package/dist/media-blocks.d.ts +3 -0
  80. package/dist/media-blocks.d.ts.map +1 -0
  81. package/dist/media-blocks.js +10 -0
  82. package/dist/media-blocks.js.map +1 -0
  83. package/dist/meta.d.ts +12 -0
  84. package/dist/meta.d.ts.map +1 -0
  85. package/dist/meta.js +20 -0
  86. package/dist/meta.js.map +1 -0
  87. package/dist/mime.d.ts +3 -0
  88. package/dist/mime.d.ts.map +1 -0
  89. package/dist/mime.js +58 -0
  90. package/dist/mime.js.map +1 -0
  91. package/dist/read-service.d.ts +66 -0
  92. package/dist/read-service.d.ts.map +1 -0
  93. package/dist/read-service.js +156 -0
  94. package/dist/read-service.js.map +1 -0
  95. package/dist/rpc.d.ts +7 -0
  96. package/dist/rpc.d.ts.map +1 -0
  97. package/dist/rpc.js +352 -0
  98. package/dist/rpc.js.map +1 -0
  99. package/dist/serve-route.d.ts +19 -0
  100. package/dist/serve-route.d.ts.map +1 -0
  101. package/dist/serve-route.js +122 -0
  102. package/dist/serve-route.js.map +1 -0
  103. package/dist/upload-route.d.ts +17 -0
  104. package/dist/upload-route.d.ts.map +1 -0
  105. package/dist/upload-route.js +106 -0
  106. package/dist/upload-route.js.map +1 -0
  107. package/package.json +97 -0
@@ -0,0 +1,698 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3
+ import { useInfiniteQuery, useMutation, useQueryClient, } from "@tanstack/react-query";
4
+ import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, Button, Input, Skeleton, } from "plumix/admin/ui";
5
+ import { Trans, useLingui } from "plumix/i18n";
6
+ // Descriptors that need runtime indirection — used outside JSX (aria
7
+ // strings, native attribute values). JSX-text strings stay inline at
8
+ // their `<Trans>` callsite for extraction discoverability.
9
+ const M = {
10
+ titleLibrary: {
11
+ id: "plugin.media.library.title.library",
12
+ message: "Media Library",
13
+ },
14
+ titlePicker: {
15
+ id: "plugin.media.library.title.picker",
16
+ message: "Select media",
17
+ },
18
+ pickItemAria: {
19
+ id: "plugin.media.library.pickItemAria",
20
+ message: "Pick {title}",
21
+ comment: "title: the media item's display title",
22
+ },
23
+ openDetailsAria: {
24
+ id: "plugin.media.library.openDetailsAria",
25
+ message: "Open details for {title}",
26
+ comment: "title: the media item's display title",
27
+ },
28
+ closeDetailsAria: {
29
+ id: "plugin.media.detail.closeAria",
30
+ message: "Close details",
31
+ },
32
+ describeImage: {
33
+ id: "plugin.media.detail.altPlaceholderImage",
34
+ message: "Describe this image…",
35
+ },
36
+ describeAsset: {
37
+ id: "plugin.media.detail.altPlaceholderOther",
38
+ message: "Add a description…",
39
+ },
40
+ confirmDeleteDescription: {
41
+ id: "plugin.media.detail.deleteDescription",
42
+ message: '"{title}" will be removed permanently. Pages or posts that embed it will show a broken link.',
43
+ comment: "title: the media item being deleted",
44
+ },
45
+ uploadingFiles: {
46
+ id: "plugin.media.upload.progress",
47
+ message: "Uploading {count, plural, one {# file} other {# files}}…",
48
+ comment: "count: number of files currently uploading",
49
+ },
50
+ searchPlaceholder: {
51
+ id: "plugin.media.library.searchPlaceholder",
52
+ message: "Search by filename…",
53
+ },
54
+ };
55
+ const PAGE_SIZE = 24;
56
+ const UPLOAD_CONCURRENCY = 4;
57
+ // `MEDIA_LIST_KEY` is parametric over `accept` so the picker grid
58
+ // stays in its own cache slot — the page-mode library and a picker
59
+ // scoped to `accept: "image/"` never poison each other's data.
60
+ function mediaListKey(accept, search) {
61
+ return ["media", "list", accept ?? null, search];
62
+ }
63
+ // Matches the admin's DebouncedSearchInput interval for list-screen parity.
64
+ const SEARCH_DEBOUNCE_MS = 250;
65
+ // Local debounce — the admin shell's DebouncedSearchInput lives behind
66
+ // the `@/` alias and isn't part of the plugin-facing surface.
67
+ function useDebouncedValue(value, delayMs) {
68
+ const [debounced, setDebounced] = useState(value);
69
+ useEffect(() => {
70
+ const timer = setTimeout(() => {
71
+ setDebounced(value);
72
+ }, delayMs);
73
+ return () => {
74
+ clearTimeout(timer);
75
+ };
76
+ }, [value, delayMs]);
77
+ return debounced;
78
+ }
79
+ // Resolve a media URL to absolute form for copy/display. The plugin
80
+ // emits relative `/_plumix/media/serve/<id>` URLs in binding-only
81
+ // mode (no `publicUrlBase`); they work for `<img src=...>` on the
82
+ // admin page itself but break the moment a user copies the URL into
83
+ // an email, an external editor, or a different origin's post body.
84
+ // `Copy URL` MUST hand back something pasteable.
85
+ function toAbsoluteUrl(url) {
86
+ if (url.startsWith("http://") || url.startsWith("https://"))
87
+ return url;
88
+ if (typeof window === "undefined")
89
+ return url;
90
+ try {
91
+ return new URL(url, window.location.origin).toString();
92
+ }
93
+ catch {
94
+ return url;
95
+ }
96
+ }
97
+ async function runWithConcurrency(items, limit, worker) {
98
+ let cursor = 0;
99
+ const next = async () => {
100
+ const i = cursor++;
101
+ if (i >= items.length)
102
+ return;
103
+ const item = items[i];
104
+ if (item !== undefined)
105
+ await worker(item);
106
+ return next();
107
+ };
108
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, () => next()));
109
+ }
110
+ function toSelection(item) {
111
+ return {
112
+ id: String(item.id),
113
+ url: item.url,
114
+ alt: item.alt,
115
+ mime: item.mime,
116
+ filename: item.title,
117
+ width: item.width,
118
+ height: item.height,
119
+ };
120
+ }
121
+ // Hand-rolled oRPC POST. Plugin `media/*` procedures don't surface in the
122
+ // admin's typed client (`AppRouterClient` covers core only), so we speak
123
+ // the StandardRPC envelope `{ json, meta: [] }` directly.
124
+ // The subdirectory mount the host exposes (see plumix-globals), used to prefix
125
+ // the worker-routed `/_plumix/...` URLs this file builds in two scopes.
126
+ function pluginBasePath() {
127
+ return (globalThis.plumix?.basePath ?? "");
128
+ }
129
+ async function rpcCall(procedure, input) {
130
+ const res = await fetch(`${pluginBasePath()}/_plumix/rpc/${procedure}`, {
131
+ method: "POST",
132
+ headers: {
133
+ "content-type": "application/json",
134
+ "x-plumix-request": "1",
135
+ },
136
+ body: JSON.stringify({ json: input, meta: [] }),
137
+ });
138
+ const envelope = (await res.json().catch(() => null));
139
+ if (!res.ok) {
140
+ const error = envelope?.json;
141
+ const reason = error?.data?.reason ?? error?.message ?? `rpc_${String(res.status)}`;
142
+ // eslint-disable-next-line no-restricted-syntax -- admin-side rpc envelope rethrow; server-derived message is the discriminator
143
+ throw new Error(reason);
144
+ }
145
+ return envelope?.json;
146
+ }
147
+ // Browser PUT with progress reporting. `fetch()` in 2026 still doesn't
148
+ // expose request-body progress; XMLHttpRequest's `upload.onprogress` is
149
+ // the only portable signal. The signed headers must be echoed verbatim.
150
+ function putWithProgress(url, method, headers, body, onProgress) {
151
+ return new Promise((resolve, reject) => {
152
+ const xhr = new XMLHttpRequest();
153
+ xhr.open(method, url);
154
+ for (const [name, value] of Object.entries(headers)) {
155
+ xhr.setRequestHeader(name, value);
156
+ }
157
+ xhr.upload.onprogress = (event) => {
158
+ if (event.lengthComputable)
159
+ onProgress(event.loaded, event.total);
160
+ };
161
+ xhr.onload = () => {
162
+ if (xhr.status >= 200 && xhr.status < 300)
163
+ resolve();
164
+ else
165
+ reject(new Error(`upload_failed_${String(xhr.status)}`));
166
+ };
167
+ xhr.onerror = () => reject(new Error("upload_network_error"));
168
+ xhr.send(body);
169
+ });
170
+ }
171
+ function useMediaUpload(invalidateList) {
172
+ const [pending, setPending] = useState([]);
173
+ const [errorMsg, setErrorMsg] = useState(null);
174
+ const uploadOne = useCallback(async (file) => {
175
+ const slot = {
176
+ id: crypto.randomUUID(),
177
+ name: file.name,
178
+ progress: 0,
179
+ };
180
+ setPending((prev) => [...prev, slot]);
181
+ try {
182
+ const init = await rpcCall("media/createUploadUrl", {
183
+ filename: file.name,
184
+ contentType: file.type,
185
+ size: file.size,
186
+ });
187
+ try {
188
+ // Same-origin worker route needs the CSRF header that the
189
+ // dispatcher enforces on `/_plumix/*`. R2 (cross-origin) must
190
+ // NOT receive it — extra headers would break SigV4.
191
+ const headers = init.uploadUrl.startsWith("/")
192
+ ? { ...init.headers, "x-plumix-request": "1" }
193
+ : init.headers;
194
+ await putWithProgress(init.uploadUrl, init.method, headers, file, (loaded, total) => {
195
+ setPending((prev) => prev.map((p) => p.id === slot.id ? { ...p, progress: loaded / total } : p));
196
+ });
197
+ await rpcCall("media/confirm", { id: init.mediaId });
198
+ }
199
+ catch (error) {
200
+ await tryCleanupDraft(init.mediaId);
201
+ throw error;
202
+ }
203
+ }
204
+ catch (error) {
205
+ setErrorMsg(error instanceof Error ? error.message : String(error));
206
+ }
207
+ finally {
208
+ setPending((prev) => prev.filter((p) => p.id !== slot.id));
209
+ }
210
+ }, []);
211
+ const startUpload = useCallback(async (files) => {
212
+ if (files.length === 0)
213
+ return;
214
+ // Cap parallelism so a 50-file drop doesn't fire 50 simultaneous
215
+ // RPCs / XHRs. Browsers throttle to ~6 connections per origin
216
+ // anyway; pulling work off a small pool gives proper backpressure.
217
+ await runWithConcurrency(files, UPLOAD_CONCURRENCY, uploadOne);
218
+ invalidateList();
219
+ }, [uploadOne, invalidateList]);
220
+ return { pending, errorMsg, setErrorMsg, startUpload };
221
+ }
222
+ // Generic intersection-observer-on-sentinel hook. Re-binds when the
223
+ // data length changes so we don't miss the next intersection after a
224
+ // page lands.
225
+ function useInfiniteScrollSentinel(sentinelRef, hasNextPage, isFetchingNextPage, fetchNextPage, dataLength) {
226
+ useEffect(() => {
227
+ const node = sentinelRef.current;
228
+ if (!node)
229
+ return;
230
+ if (!hasNextPage)
231
+ return;
232
+ const observer = new IntersectionObserver((entries) => {
233
+ if (entries[0]?.isIntersecting && !isFetchingNextPage) {
234
+ // The callback may be async (e.g. React Query's fetchNextPage)
235
+ // — discard the promise so eslint's no-misused-promises stays
236
+ // happy and we don't accidentally await inside the observer.
237
+ void fetchNextPage();
238
+ }
239
+ });
240
+ observer.observe(node);
241
+ return () => observer.disconnect();
242
+ }, [sentinelRef, hasNextPage, isFetchingNextPage, fetchNextPage, dataLength]);
243
+ }
244
+ export function MediaLibrary({ mode = "page", accept, onSelect, onCancel, } = {}) {
245
+ const queryClient = useQueryClient();
246
+ const { i18n } = useLingui();
247
+ const [dragging, setDragging] = useState(false);
248
+ const sentinelRef = useRef(null);
249
+ const isPicker = mode === "picker";
250
+ const [search, setSearch] = useState("");
251
+ const debouncedSearch = useDebouncedValue(search.trim(), SEARCH_DEBOUNCE_MS);
252
+ const queryKey = mediaListKey(accept, debouncedSearch);
253
+ const list = useInfiniteQuery({
254
+ queryKey,
255
+ initialPageParam: 0,
256
+ queryFn: ({ pageParam }) => rpcCall("media/list", {
257
+ limit: PAGE_SIZE,
258
+ offset: pageParam,
259
+ // `accept` only flows through in picker mode. Page mode shows
260
+ // the full library regardless.
261
+ ...(isPicker && accept !== undefined ? { accept } : {}),
262
+ ...(debouncedSearch ? { search: debouncedSearch } : {}),
263
+ }),
264
+ getNextPageParam: (last, allPages) => last.hasMore ? allPages.length * PAGE_SIZE : undefined,
265
+ });
266
+ const items = useMemo(() => list.data?.pages.flatMap((p) => p.items) ?? [], [list.data?.pages]);
267
+ const invalidateList = useCallback(() => {
268
+ void queryClient.invalidateQueries({ queryKey });
269
+ }, [queryClient, queryKey]);
270
+ // `list.fetchNextPage` is a stable React Query callback. Passing it
271
+ // directly (rather than wrapping it in a fresh arrow each render)
272
+ // keeps the IntersectionObserver from being torn down + rebuilt on
273
+ // every parent re-render.
274
+ useInfiniteScrollSentinel(sentinelRef, list.hasNextPage, list.isFetchingNextPage, list.fetchNextPage, list.data?.pages.length);
275
+ const { pending, errorMsg, setErrorMsg, startUpload } = useMediaUpload(invalidateList);
276
+ const remove = useMutation({
277
+ mutationFn: (id) => rpcCall("media/delete", { id }),
278
+ onSuccess: invalidateList,
279
+ onError: (error) => setErrorMsg(error instanceof Error ? error.message : String(error)),
280
+ });
281
+ const update = useMutation({
282
+ mutationFn: (input) => rpcCall("media/update", input),
283
+ onSuccess: invalidateList,
284
+ onError: (error) => setErrorMsg(error instanceof Error ? error.message : String(error)),
285
+ });
286
+ // Drop anywhere on the page — including the empty state, the loading
287
+ // state, and the gaps between cards. The grid div used to own these
288
+ // handlers, but it only renders when items exist; on a fresh install
289
+ // the user lands on the empty state and dropping a file did nothing.
290
+ const dropProps = {
291
+ onDragOver: (e) => {
292
+ if (!hasFiles(e))
293
+ return;
294
+ e.preventDefault();
295
+ setDragging(true);
296
+ },
297
+ onDragLeave: (e) => {
298
+ // `dragleave` fires on every child boundary cross — ignore unless
299
+ // the cursor actually left the root container.
300
+ if (e.currentTarget.contains(e.relatedTarget))
301
+ return;
302
+ setDragging(false);
303
+ },
304
+ onDrop: (e) => {
305
+ e.preventDefault();
306
+ setDragging(false);
307
+ const files = Array.from(e.dataTransfer.files);
308
+ void startUpload(files);
309
+ },
310
+ };
311
+ // Keep `selectedItem` in state, not derived from `items`. The list
312
+ // refetches on every mutation; deriving would make the drawer
313
+ // disappear silently when the row briefly drops out of the page
314
+ // window or while a refetch is in flight. We refresh from the list
315
+ // by id when a fresh copy is available, never null it from absence.
316
+ //
317
+ // setState-in-effect is the intentional pattern here: we DO want the
318
+ // mirrored copy so the drawer survives a refetch window. The settled-
319
+ // and-gone branch is the only path that nulls.
320
+ const [selectedItem, setSelectedItem] = useState(null);
321
+ useEffect(() => {
322
+ if (selectedItem === null)
323
+ return;
324
+ const fresh = items.find((it) => it.id === selectedItem.id);
325
+ // eslint-disable-next-line react-hooks/set-state-in-effect -- mirroring is intentional; see block comment above.
326
+ if (fresh && fresh !== selectedItem)
327
+ setSelectedItem(fresh);
328
+ // If the row was deleted (not in items AND list is settled), close.
329
+ if (!fresh && list.status === "success" && !list.isFetching) {
330
+ setSelectedItem(null);
331
+ }
332
+ }, [items, list.status, list.isFetching, selectedItem]);
333
+ // Picker-mode selection (single-pick). Hybrid click model:
334
+ // - single click → set this id as the picked one (footer enables)
335
+ // - double click → confirm + close (auto-close)
336
+ // Footer "Use selection" is the keyboard-friendly confirmation.
337
+ // Hold the whole item, not just its id: the footer confirm must resolve a
338
+ // full selection even after a search/scroll evicts it from the loaded page.
339
+ const [pickerSelected, setPickerSelected] = useState(null);
340
+ const handleCardActivate = useCallback((item) => {
341
+ if (isPicker) {
342
+ setPickerSelected(item);
343
+ return;
344
+ }
345
+ setSelectedItem(item);
346
+ }, [isPicker]);
347
+ const handleCardConfirm = useCallback((item) => {
348
+ if (!isPicker)
349
+ return;
350
+ onSelect?.(toSelection(item));
351
+ }, [isPicker, onSelect]);
352
+ // ESC closes the detail drawer. Depend on the boolean `open` flag
353
+ // (primitive) so the listener doesn't tear down/rebind on every
354
+ // refresh of `selectedItem`.
355
+ const drawerOpen = selectedItem !== null;
356
+ useEffect(() => {
357
+ if (!drawerOpen)
358
+ return;
359
+ const onKey = (e) => {
360
+ if (e.key === "Escape")
361
+ setSelectedItem(null);
362
+ };
363
+ window.addEventListener("keydown", onKey);
364
+ return () => window.removeEventListener("keydown", onKey);
365
+ }, [drawerOpen]);
366
+ return (_jsxs("div", { "data-testid": "media-library", "data-mode": mode, className: "relative flex min-h-full gap-6", ...dropProps, children: [_jsxs("div", { className: "flex min-w-0 flex-1 flex-col gap-6", children: [_jsxs("header", { className: "flex items-center justify-between", children: [_jsx("h1", { "data-testid": "media-library-title", className: `${isPicker ? "text-xl" : "text-2xl"} font-semibold`, children: isPicker ? i18n._(M.titlePicker) : i18n._(M.titleLibrary) }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Input, { type: "search", role: "searchbox", value: search, onChange: (e) => {
367
+ setSearch(e.target.value);
368
+ }, placeholder: i18n._(M.searchPlaceholder), "aria-label": i18n._(M.searchPlaceholder), maxLength: 200, "data-testid": "media-library-search", className: "w-56" }), _jsx(UploadButton, { onSelect: (files) => void startUpload(files) })] })] }), pending.length > 0 && _jsx(UploadProgressBar, { pending: pending }), list.status === "pending" && _jsx(MediaSkeletonGrid, {}), list.status === "error" && (_jsx("div", { role: "alert", "data-testid": "media-library-error", className: "text-destructive text-sm", children: _jsx(Trans, { id: "plugin.media.library.failedToLoad", message: "Failed to load media." }) })), list.status === "success" &&
369
+ items.length === 0 &&
370
+ (debouncedSearch ? (_jsx("p", { "data-testid": "media-library-no-matches", className: "text-muted-foreground text-sm", children: _jsx(Trans, { id: "plugin.media.library.noMatches", message: "No files match your search." }) })) : (_jsx(Dropzone, { onSelect: (files) => void startUpload(files), highlight: dragging }))), list.status === "success" && items.length > 0 && (_jsx("div", { "data-testid": "media-library-grid", className: "grid grid-cols-[repeat(auto-fill,minmax(160px,1fr))] gap-4", children: items.map((item) => {
371
+ const selected = isPicker
372
+ ? pickerSelected?.id === item.id
373
+ : selectedItem?.id === item.id;
374
+ return (_jsx(MediaCard, { item: item, selected: selected, onActivate: () => handleCardActivate(item),
375
+ // Page mode: double-click is idempotent with single-
376
+ // click (open detail). Picker mode: confirm + close.
377
+ onConfirm: isPicker
378
+ ? () => handleCardConfirm(item)
379
+ : () => handleCardActivate(item), ariaActionLabel: isPicker
380
+ ? i18n._(M.pickItemAria.id, { title: item.title }, { message: M.pickItemAria.message })
381
+ : i18n._(M.openDetailsAria.id, { title: item.title }, { message: M.openDetailsAria.message }) }, item.id));
382
+ }) })), _jsx("div", { ref: sentinelRef, "data-testid": "media-library-sentinel" }), list.isFetchingNextPage && (_jsx("div", { "data-testid": "media-library-loading-more", className: "text-muted-foreground text-center text-sm", children: _jsx(Trans, { id: "plugin.media.library.loadingMore", message: "Loading more\u2026" }) })), dragging && items.length > 0 && (_jsx("div", { "data-testid": "media-library-drop-overlay", "aria-hidden": "true", className: "border-primary pointer-events-none absolute inset-4 rounded-lg border-2 border-dashed bg-white/5" })), isPicker && (_jsxs("footer", { "data-testid": "media-library-picker-footer", className: "bg-background sticky bottom-0 -mx-2 flex items-center justify-end gap-2 border-t px-2 py-3", children: [_jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => onCancel?.(), "data-testid": "media-library-picker-cancel", children: _jsx(Trans, { id: "plugin.media.library.pickerCancel", message: "Cancel" }) }), _jsx(Button, { type: "button", size: "sm", disabled: pickerSelected === null, onClick: () => {
383
+ if (pickerSelected)
384
+ onSelect?.(toSelection(pickerSelected));
385
+ }, "data-testid": "media-library-picker-confirm", children: _jsx(Trans, { id: "plugin.media.library.pickerUseSelection", message: "Use selection" }) })] }))] }), !isPicker && selectedItem && (_jsx(MediaDetailDrawer, { item: selectedItem, onClose: () => setSelectedItem(null), onAltChange: (alt) => update.mutate({ id: selectedItem.id, alt }), onDelete: () => {
386
+ // Confirmation handled inside the drawer's ConfirmDialog;
387
+ // by the time we get here the user already confirmed.
388
+ const id = selectedItem.id;
389
+ setSelectedItem(null);
390
+ remove.mutate(id);
391
+ } })), errorMsg && (_jsx(ErrorBanner, { testIdRoot: "media-library-banner-error", message: friendlyError(errorMsg, (d) => i18n._(d)), onDismiss: () => setErrorMsg(null) }))] }));
392
+ }
393
+ function hasFiles(e) {
394
+ return Array.from(e.dataTransfer.types).includes("Files");
395
+ }
396
+ // Map opaque RPC `reason` codes to actionable, translatable text. The
397
+ // error banner is the only surface a user sees when an upload fails —
398
+ // raw reasons like `mime_mismatch` read like 404s. Several codes share
399
+ // a descriptor (`payload_too_large` + `rpc_413` etc.) — the translator
400
+ // translates one message, the lookup serves it for every aliased code.
401
+ const ERROR_DESCRIPTORS = {
402
+ storageNotConfigured: {
403
+ id: "plugin.media.error.storageNotConfigured",
404
+ message: "No storage adapter is wired up — set `storage:` in plumix.config.ts.",
405
+ },
406
+ payloadTooLarge: {
407
+ id: "plugin.media.error.payloadTooLarge",
408
+ message: "File exceeds the configured maxUploadSize.",
409
+ },
410
+ unsupportedMediaType: {
411
+ id: "plugin.media.error.unsupportedMediaType",
412
+ message: "This file type isn't allowed by the media plugin's acceptedTypes.",
413
+ },
414
+ mimeMismatch: {
415
+ id: "plugin.media.error.mimeMismatch",
416
+ message: "The uploaded bytes don't match the declared file type.",
417
+ },
418
+ objectNotFound: {
419
+ id: "plugin.media.error.objectNotFound",
420
+ message: "Upload didn't reach storage — check your bucket's CORS rules.",
421
+ },
422
+ alreadyConfirmed: {
423
+ id: "plugin.media.error.alreadyConfirmed",
424
+ message: "This upload was already confirmed by another tab or device.",
425
+ },
426
+ serverCouldntProcess: {
427
+ id: "plugin.media.error.serverCouldntProcess",
428
+ message: "Server couldn't process this upload. Try again.",
429
+ },
430
+ contentLengthRequired: {
431
+ id: "plugin.media.error.contentLengthRequired",
432
+ message: "Upload missing Content-Length — your browser/proxy may be using chunked transfer.",
433
+ },
434
+ csrfTokenMissing: {
435
+ id: "plugin.media.error.csrfTokenMissing",
436
+ message: "Request blocked by CSRF check. Reload the page and try again.",
437
+ },
438
+ };
439
+ const FRIENDLY_ERRORS = {
440
+ storage_not_configured: ERROR_DESCRIPTORS.storageNotConfigured,
441
+ payload_too_large: ERROR_DESCRIPTORS.payloadTooLarge,
442
+ rpc_413: ERROR_DESCRIPTORS.payloadTooLarge,
443
+ unsupported_media_type: ERROR_DESCRIPTORS.unsupportedMediaType,
444
+ rpc_415: ERROR_DESCRIPTORS.unsupportedMediaType,
445
+ content_type_mismatch: ERROR_DESCRIPTORS.unsupportedMediaType,
446
+ mime_mismatch: ERROR_DESCRIPTORS.mimeMismatch,
447
+ object_not_found: ERROR_DESCRIPTORS.objectNotFound,
448
+ already_confirmed: ERROR_DESCRIPTORS.alreadyConfirmed,
449
+ media_meta_invalid: ERROR_DESCRIPTORS.serverCouldntProcess,
450
+ db_insert_failed: ERROR_DESCRIPTORS.serverCouldntProcess,
451
+ storage_put_failed: ERROR_DESCRIPTORS.serverCouldntProcess,
452
+ content_length_required: ERROR_DESCRIPTORS.contentLengthRequired,
453
+ csrf_token_missing: ERROR_DESCRIPTORS.csrfTokenMissing,
454
+ };
455
+ /**
456
+ * Resolve a server `reason` code to a translated string. Returns the
457
+ * raw code when no descriptor matches (developer-facing fallback).
458
+ */
459
+ function friendlyError(raw, render) {
460
+ const descriptor = FRIENDLY_ERRORS[raw];
461
+ return descriptor ? render(descriptor) : raw;
462
+ }
463
+ function ErrorBanner({ testIdRoot, message, onDismiss, }) {
464
+ return (_jsxs("div", { role: "alert", "data-testid": testIdRoot, className: "text-destructive flex items-center justify-between gap-3 text-sm", children: [_jsx("span", { children: message }), _jsx(Button, { type: "button", variant: "link", size: "sm", "data-testid": `${testIdRoot}-dismiss`, onClick: onDismiss, className: "text-destructive h-auto p-0 text-xs", children: _jsx(Trans, { id: "plugin.media.banner.dismiss", message: "Dismiss" }) })] }));
465
+ }
466
+ async function tryCleanupDraft(mediaId) {
467
+ try {
468
+ await rpcCall("media/delete", { id: mediaId });
469
+ }
470
+ catch {
471
+ // Best-effort — server-side draft GC will catch it.
472
+ }
473
+ }
474
+ // First-impression empty state. Mirrors the shape of CF R2's bucket
475
+ // dashboard: a dashed-border drop target with cloud-up glyph and an
476
+ // inline "select from computer" picker. The page-wide drop handlers
477
+ // already cover the entire library, but a visible target on the empty
478
+ // state tells the user the library accepts files at all — without it
479
+ // the page reads as "nothing to do here".
480
+ function Dropzone({ onSelect, highlight, }) {
481
+ return (_jsxs("label", { "data-testid": "media-library-dropzone", "data-active": highlight ? "true" : undefined, className: `flex cursor-pointer flex-col items-center justify-center gap-3 rounded-lg border-2 border-dashed px-4 py-16 text-center transition-colors duration-150 ${highlight ? "border-primary bg-white/5" : "border-border bg-transparent"}`, children: [_jsx("input", { type: "file", multiple: true, className: "sr-only", onChange: (event) => {
482
+ const files = Array.from(event.target.files ?? []);
483
+ if (files.length > 0)
484
+ onSelect(files);
485
+ event.target.value = "";
486
+ } }), _jsx(CloudUploadGlyph, {}), _jsxs("div", { className: "flex flex-col gap-1", children: [_jsx("p", { className: "m-0 text-sm font-medium", children: _jsx(Trans, { id: "plugin.media.dropzone.headline", message: "Your library is empty. Add files to get started." }) }), _jsx("p", { className: "text-muted-foreground m-0 text-xs", children: _jsx(Trans, { id: "plugin.media.dropzone.subline", message: "Drag and drop or <0>select from computer</0>", components: {
487
+ 0: _jsx("span", { className: "underline" }),
488
+ } }) })] })] }));
489
+ }
490
+ function CloudUploadGlyph() {
491
+ return (_jsxs("svg", { "aria-hidden": "true", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "1.5", strokeLinecap: "round", strokeLinejoin: "round", className: "text-muted-foreground size-12", children: [_jsx("path", { d: "M16 16l-4-4-4 4" }), _jsx("path", { d: "M12 12v9" }), _jsx("path", { d: "M20.39 18.39A5 5 0 0 0 18 9h-1.26A8 8 0 1 0 3 16.3" })] }));
492
+ }
493
+ function UploadButton({ onSelect, }) {
494
+ return (_jsxs("label", { "data-testid": "media-library-upload", className: "bg-card hover:bg-muted cursor-pointer rounded border px-4 py-2 text-sm", children: [_jsx("input", { type: "file", multiple: true, className: "sr-only", onChange: (event) => {
495
+ const files = Array.from(event.target.files ?? []);
496
+ if (files.length > 0)
497
+ onSelect(files);
498
+ event.target.value = "";
499
+ } }), _jsx(Trans, { id: "plugin.media.upload.button", message: "Upload" })] }));
500
+ }
501
+ function UploadProgressBar({ pending, }) {
502
+ const total = pending.reduce((sum, p) => sum + p.progress, 0);
503
+ const ratio = total / pending.length;
504
+ const pct = Math.round(ratio * 100);
505
+ return (_jsxs("div", { "data-testid": "media-library-progress", className: "border-border bg-card flex flex-col gap-1.5 rounded-md border px-4 py-3 text-xs", children: [_jsxs("div", { className: "flex items-center justify-between", children: [_jsx("span", { children: _jsx(Trans, { id: "plugin.media.upload.progress", message: "Uploading {count, plural, one {# file} other {# files}}\u2026", values: { count: pending.length }, comment: "count: number of files currently uploading" }) }), _jsxs("span", { "data-testid": "media-library-progress-pct", children: [pct, "%"] })] }), _jsx("div", { className: "h-1.5 w-full overflow-hidden rounded-sm bg-white/10", children: _jsx("div", { className: "bg-primary h-full transition-[width] duration-200 ease-in-out", style: { width: `${String(pct)}%` } }) })] }));
506
+ }
507
+ function MediaCard({ item, selected, onActivate, onConfirm, ariaActionLabel, }) {
508
+ const { i18n } = useLingui();
509
+ const isImage = item.mime.startsWith("image/");
510
+ return (_jsxs("article", { "data-testid": `media-card-${String(item.id)}`, "data-selected": selected ? "true" : undefined, className: `border-border bg-card relative flex cursor-pointer flex-col gap-2 rounded-lg border p-3 ${selected ? "outline-primary outline-2 outline-offset-1" : ""}`, onClick: onActivate, onDoubleClick: onConfirm, onKeyDown: (e) => {
511
+ if (e.key === "Enter" || e.key === " ") {
512
+ e.preventDefault();
513
+ onActivate();
514
+ }
515
+ }, role: "button", tabIndex: 0, "aria-label": ariaActionLabel, children: [_jsxs("div", { className: "bg-muted relative flex aspect-square w-full items-center justify-center overflow-hidden rounded-sm", children: [isImage ? (_jsx(ImageWithFallback, { src: item.thumbnailUrl, alt: item.alt ?? item.title, mime: item.mime, testId: `media-card-${String(item.id)}-thumb` })) : (_jsx(FileGlyph, { mime: item.mime })), _jsx(FileTypeBadge, { mime: item.mime })] }), _jsx("div", { "data-testid": `media-card-${String(item.id)}-title`, className: "truncate text-sm", title: item.title, children: item.title }), _jsxs("div", { className: "flex gap-2 text-[0.7rem] opacity-60", children: [_jsx("span", { children: formatShortDate(i18n.locale, item.uploadedAt) }), _jsx("span", { children: "\u00B7" }), _jsx("span", { children: formatSize(i18n.locale, item.size) })] })] }));
516
+ }
517
+ function MediaSkeletonGrid() {
518
+ // Same grid shape as the populated state so the layout is stable
519
+ // across the loading → loaded transition (no shift, no reflow).
520
+ const placeholders = Array.from({ length: 8 }, (_, i) => i);
521
+ return (_jsx("div", { "data-testid": "media-library-loading", className: "grid grid-cols-[repeat(auto-fill,minmax(160px,1fr))] gap-4", children: placeholders.map((i) => (_jsxs("div", { "aria-hidden": "true", className: "border-border bg-card flex flex-col gap-2 rounded-lg border p-3", children: [_jsx(Skeleton, { className: "aspect-square w-full rounded-sm" }), _jsx(Skeleton, { className: "h-3.5 w-[70%] rounded-sm" }), _jsx(Skeleton, { className: "h-[0.7rem] w-[40%] rounded-sm" })] }, i))) }));
522
+ }
523
+ function FileTypeBadge({ mime }) {
524
+ const label = badgeLabel(mime);
525
+ if (!label)
526
+ return null;
527
+ return (_jsx("span", { className: "absolute end-2 top-2 rounded-sm bg-black/75 px-1.5 py-0.5 text-[0.65rem] font-semibold tracking-wider text-white", children: label }));
528
+ }
529
+ const EXACT_BADGE_LABELS = {
530
+ "application/pdf": "PDF",
531
+ "application/msword": "DOC",
532
+ "application/vnd.ms-excel": "XLS",
533
+ "application/vnd.ms-powerpoint": "PPT",
534
+ "application/zip": "ZIP",
535
+ };
536
+ const SUBSTRING_BADGE_LABELS = [
537
+ ["wordprocessingml", "DOCX"],
538
+ ["spreadsheetml", "XLSX"],
539
+ ["presentationml", "PPTX"],
540
+ ];
541
+ function badgeLabel(mime) {
542
+ const exact = EXACT_BADGE_LABELS[mime];
543
+ if (exact)
544
+ return exact;
545
+ const sub = SUBSTRING_BADGE_LABELS.find(([needle]) => mime.includes(needle));
546
+ if (sub)
547
+ return sub[1];
548
+ const tail = mime.split("/")[1] ?? "";
549
+ return tail.replace(/^x-/, "").toUpperCase().slice(0, 5);
550
+ }
551
+ function formatShortDate(locale, iso) {
552
+ // Locale comes from `i18n.locale`, NOT browser default — a German user
553
+ // on a Spanish-default browser sees German chrome but otherwise would
554
+ // see Spanish dates. `.toUpperCase()` is skipped — Turkish dotless-i
555
+ // breaks; the card grid is already small-caps via CSS.
556
+ const d = new Date(iso);
557
+ if (Number.isNaN(d.getTime()))
558
+ return iso;
559
+ return new Intl.DateTimeFormat(locale, {
560
+ month: "short",
561
+ day: "numeric",
562
+ }).format(d);
563
+ }
564
+ function formatLongDate(locale, iso) {
565
+ const d = new Date(iso);
566
+ if (Number.isNaN(d.getTime()))
567
+ return iso;
568
+ return new Intl.DateTimeFormat(locale, {
569
+ year: "numeric",
570
+ month: "short",
571
+ day: "numeric",
572
+ }).format(d);
573
+ }
574
+ function MediaDetailDrawer({ item, onClose, onAltChange, onDelete, }) {
575
+ const { i18n } = useLingui();
576
+ const [copied, setCopied] = useState(false);
577
+ const [confirmingDelete, setConfirmingDelete] = useState(false);
578
+ const isImage = item.mime.startsWith("image/");
579
+ const absoluteUrl = toAbsoluteUrl(item.url);
580
+ const copy = useCallback(async () => {
581
+ try {
582
+ await navigator.clipboard.writeText(absoluteUrl);
583
+ setCopied(true);
584
+ setTimeout(() => setCopied(false), 1500);
585
+ }
586
+ catch {
587
+ /* user can copy from the visible URL */
588
+ }
589
+ }, [absoluteUrl]);
590
+ return (_jsxs("aside", { "data-testid": "media-detail-drawer", className: "border-border bg-card sticky top-8 flex max-h-[calc(100vh-4rem)] w-80 flex-shrink-0 flex-col self-start overflow-y-auto rounded-lg border", children: [_jsxs("div", { className: "border-border flex items-center justify-between border-b px-4 py-3", children: [_jsx("span", { className: "text-xs tracking-wider opacity-70", children: _jsx(Trans, { id: "plugin.media.detail.heading", message: "ASSET DETAILS" }) }), _jsx(Button, { type: "button", variant: "ghost", size: "icon-sm", onClick: onClose, "aria-label": i18n._(M.closeDetailsAria), "data-testid": "media-detail-close", className: "text-base leading-none", children: "\u00D7" })] }), _jsx("div", { className: "bg-muted aspect-square w-full overflow-hidden", children: isImage ? (_jsx("img", { src: item.thumbnailUrl, alt: item.alt ?? item.title, className: "size-full object-contain" })) : (_jsx(FileGlyph, { mime: item.mime })) }), _jsxs("div", { className: "flex flex-col gap-4 p-4", children: [_jsx("div", { children: _jsx("h2", { className: "m-0 text-base font-semibold break-all", children: item.title }) }), _jsx(DetailField, { label: _jsx(Trans, { id: "plugin.media.detail.field.assetType", message: "ASSET TYPE" }), value: item.mime }), _jsx(DetailField, { label: _jsx(Trans, { id: "plugin.media.detail.field.fileSize", message: "FILE SIZE" }), value: formatSize(i18n.locale, item.size) }), _jsx(DetailField, { label: _jsx(Trans, { id: "plugin.media.detail.field.uploaded", message: "UPLOADED" }), value: formatLongDate(i18n.locale, item.uploadedAt) }), _jsxs("div", { children: [_jsx(DetailLabel, { children: _jsx(Trans, { id: "plugin.media.detail.field.altText", message: "ALT TEXT" }) }), _jsx(AltEditor, { cardId: item.id, testIdPrefix: "media-detail", value: item.alt ?? "", placeholder: isImage ? i18n._(M.describeImage) : i18n._(M.describeAsset), onSave: onAltChange })] }), _jsxs("div", { children: [_jsx(DetailLabel, { children: _jsx(Trans, { id: "plugin.media.detail.field.url", message: "URL" }) }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx("code", { "data-testid": "media-detail-url", className: "flex-1 truncate text-[0.7rem] break-all opacity-85", title: absoluteUrl, children: absoluteUrl }), _jsx(Button, { type: "button", variant: "outline", size: "xs", onClick: () => void copy(), "data-testid": "media-detail-copy", className: "flex-shrink-0 text-[0.7rem]", children: copied ? (_jsx(Trans, { id: "plugin.media.detail.copied", message: "Copied" })) : (_jsx(Trans, { id: "plugin.media.detail.copy", message: "Copy" })) })] })] }), _jsxs("div", { className: "border-border flex gap-2 border-t pt-2", children: [_jsx(Button, { asChild: true, variant: "outline", size: "sm", className: "flex-1", children: _jsx("a", {
591
+ // Always go through the worker serve route with
592
+ // ?attachment=1 — the HTML `download` attribute is ignored
593
+ // cross-origin (e.g. when `publicUrlBase` is configured),
594
+ // but the route always sends `Content-Disposition:
595
+ // attachment` for this query param, so downloads work
596
+ // regardless of which mode `item.url` is in.
597
+ href: `${pluginBasePath()}/_plumix/media/serve/${String(item.id)}?attachment=1`, download: item.title, "data-testid": "media-detail-download", children: _jsx(Trans, { id: "plugin.media.detail.download", message: "Download" }) }) }), _jsx(Button, { type: "button", variant: "outline", size: "sm", "data-testid": "media-detail-delete", onClick: () => setConfirmingDelete(true), className: "flex-1", children: _jsx(Trans, { id: "plugin.media.detail.delete", message: "Delete" }) })] })] }), confirmingDelete && (_jsx(ConfirmDialog, { title: _jsx(Trans, { id: "plugin.media.detail.deleteTitle", message: "Delete this asset?" }), description: i18n._(M.confirmDeleteDescription.id, { title: item.title }, { message: M.confirmDeleteDescription.message }), confirmLabel: _jsx(Trans, { id: "plugin.media.detail.deleteConfirm", message: "Delete" }), danger: true, onCancel: () => setConfirmingDelete(false), onConfirm: () => {
598
+ setConfirmingDelete(false);
599
+ onDelete();
600
+ } }))] }));
601
+ }
602
+ function ConfirmDialog({ title, description, confirmLabel, danger = false, onCancel, onConfirm, }) {
603
+ // Mounted only while confirming (parent renders conditionally), so the
604
+ // dialog is always open while present; closing via ESC, the overlay, or
605
+ // Cancel routes through `onOpenChange` → `onCancel`. Radix handles the
606
+ // focus trap + ESC that this component used to wire by hand.
607
+ return (_jsx(AlertDialog, { open: true, onOpenChange: (next) => {
608
+ if (!next)
609
+ onCancel();
610
+ }, children: _jsxs(AlertDialogContent, { "data-testid": "confirm-dialog-overlay", children: [_jsxs(AlertDialogHeader, { children: [_jsx(AlertDialogTitle, { "data-testid": "confirm-dialog-title", children: title }), description ? (_jsx(AlertDialogDescription, { children: description })) : null] }), _jsxs(AlertDialogFooter, { children: [_jsx(AlertDialogCancel, { "data-testid": "confirm-dialog-cancel", children: _jsx(Trans, { id: "plugin.media.dialog.cancel", message: "Cancel" }) }), _jsx(AlertDialogAction, { "data-testid": "confirm-dialog-confirm", variant: danger ? "destructive" : "default", onClick: onConfirm, children: confirmLabel ?? (_jsx(Trans, { id: "plugin.media.dialog.confirm", message: "Confirm" })) })] })] }) }));
611
+ }
612
+ function DetailLabel({ children }) {
613
+ return (_jsx("div", { className: "mb-1 text-[0.7rem] tracking-wider opacity-60", children: children }));
614
+ }
615
+ function DetailField({ label, value, }) {
616
+ return (_jsxs("div", { children: [_jsx(DetailLabel, { children: label }), _jsx("div", { className: "text-sm break-words", children: value })] }));
617
+ }
618
+ function AltEditor({ cardId, testIdPrefix = "media-card", value, placeholder, onSave, }) {
619
+ const [draft, setDraft] = useState(value);
620
+ const [savedFlash, setSavedFlash] = useState(false);
621
+ const dirtyRef = useRef(false);
622
+ // Track the last value WE saved so we can recognise it when it
623
+ // round-trips back via list refetch and not stomp the user's draft.
624
+ // The naive "if not dirty, sync" approach raced: between commit
625
+ // (which clears dirty) and the refetch arriving with the new value,
626
+ // a re-render would set draft back to the OLD value because the
627
+ // effect ran before the refetch updated `value`.
628
+ const lastSavedRef = useRef(value);
629
+ useEffect(() => {
630
+ if (value === lastSavedRef.current)
631
+ return; // our own save came back
632
+ if (!dirtyRef.current)
633
+ setDraft(value); // external update, not editing
634
+ }, [value]);
635
+ const commit = useCallback(() => {
636
+ dirtyRef.current = false;
637
+ if (draft !== value) {
638
+ lastSavedRef.current = draft;
639
+ onSave(draft);
640
+ setSavedFlash(true);
641
+ setTimeout(() => setSavedFlash(false), 1200);
642
+ }
643
+ }, [draft, value, onSave]);
644
+ return (_jsxs("div", { className: "relative", children: [_jsx(Input, { "data-testid": `${testIdPrefix}-${String(cardId)}-alt`, type: "text", value: draft, placeholder: placeholder, onChange: (e) => {
645
+ dirtyRef.current = true;
646
+ setDraft(e.target.value);
647
+ }, onBlur: commit, onKeyDown: (e) => {
648
+ if (e.key === "Enter") {
649
+ e.preventDefault();
650
+ commit();
651
+ e.currentTarget.blur();
652
+ }
653
+ else if (e.key === "Escape") {
654
+ // Discard edit + restore canonical value, no save.
655
+ e.preventDefault();
656
+ dirtyRef.current = false;
657
+ setDraft(value);
658
+ e.currentTarget.blur();
659
+ }
660
+ } }), savedFlash && (_jsx("span", { "data-testid": `${testIdPrefix}-${String(cardId)}-alt-saved`, "aria-live": "polite", className: "text-primary pointer-events-none absolute end-2 top-1/2 -translate-y-1/2 text-[0.65rem]", children: _jsx(Trans, { id: "plugin.media.altEditor.saved", message: "\u2713 Saved" }) }))] }));
661
+ }
662
+ function FileGlyph({ mime }) {
663
+ return (_jsx("div", { className: "text-muted-foreground flex size-full items-center justify-center text-xl font-semibold tracking-widest", children: mimeGlyph(mime) }));
664
+ }
665
+ // Image with avatar-style fallback. Shows a shimmer skeleton while
666
+ // loading, fades the image in once `onLoad` fires, falls back to the
667
+ // file glyph if the image errors. Container has a fixed aspect ratio
668
+ // so there's no layout shift between skeleton → image.
669
+ function ImageWithFallback({ src, alt, mime, testId, }) {
670
+ const [state, setState] = useState("loading");
671
+ return (_jsxs(_Fragment, { children: [state !== "loaded" && (_jsx("div", { "aria-hidden": "true", className: `absolute inset-0 flex items-center justify-center ${state === "error" ? "bg-muted" : "animate-pulse bg-white/10"}`, children: state === "error" && _jsx(FileGlyph, { mime: mime }) })), _jsx("img", { "data-testid": testId, src: src, alt: alt, loading: "lazy", decoding: "async", onLoad: () => setState("loaded"), onError: () => setState("error"), className: `block size-full object-cover transition-opacity duration-200 ${state === "loaded" ? "opacity-100" : "opacity-0"}` })] }));
672
+ }
673
+ function mimeGlyph(mime) {
674
+ if (mime.startsWith("video/"))
675
+ return "VID";
676
+ if (mime.startsWith("audio/"))
677
+ return "AUD";
678
+ if (mime === "application/pdf")
679
+ return "PDF";
680
+ if (mime.includes("zip"))
681
+ return "ZIP";
682
+ if (mime.startsWith("text/"))
683
+ return "TXT";
684
+ return "DOC";
685
+ }
686
+ function formatSize(locale, bytes) {
687
+ // Unit labels (B/KB/MB) are SI/IEC and conventionally untranslated;
688
+ // the locale governs decimal separator (German "1,5 MB" vs US "1.5 MB").
689
+ if (typeof bytes !== "number" || !Number.isFinite(bytes))
690
+ return "—";
691
+ const nf = new Intl.NumberFormat(locale, { maximumFractionDigits: 1 });
692
+ if (bytes < 1024)
693
+ return `${nf.format(bytes)} B`;
694
+ if (bytes < 1024 * 1024)
695
+ return `${nf.format(bytes / 1024)} KB`;
696
+ return `${nf.format(bytes / 1024 / 1024)} MB`;
697
+ }
698
+ //# sourceMappingURL=MediaLibrary.js.map