@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.
- package/LICENSE +21 -0
- package/README.md +211 -0
- package/dist/admin/FocalPointField.d.ts +22 -0
- package/dist/admin/FocalPointField.d.ts.map +1 -0
- package/dist/admin/FocalPointField.js +71 -0
- package/dist/admin/FocalPointField.js.map +1 -0
- package/dist/admin/MediaLibrary.d.ts +43 -0
- package/dist/admin/MediaLibrary.d.ts.map +1 -0
- package/dist/admin/MediaLibrary.js +698 -0
- package/dist/admin/MediaLibrary.js.map +1 -0
- package/dist/admin/MediaListPickerField.d.ts +14 -0
- package/dist/admin/MediaListPickerField.d.ts.map +1 -0
- package/dist/admin/MediaListPickerField.js +161 -0
- package/dist/admin/MediaListPickerField.js.map +1 -0
- package/dist/admin/MediaPickerField.d.ts +25 -0
- package/dist/admin/MediaPickerField.d.ts.map +1 -0
- package/dist/admin/MediaPickerField.js +82 -0
- package/dist/admin/MediaPickerField.js.map +1 -0
- package/dist/admin/MediaUrlField.d.ts +14 -0
- package/dist/admin/MediaUrlField.d.ts.map +1 -0
- package/dist/admin/MediaUrlField.js +29 -0
- package/dist/admin/MediaUrlField.js.map +1 -0
- package/dist/admin/index.d.ts +11 -0
- package/dist/admin/index.d.ts.map +1 -0
- package/dist/admin/index.js +49 -0
- package/dist/admin/index.js.map +1 -0
- package/dist/admin/messages.d.ts +24 -0
- package/dist/admin/messages.d.ts.map +1 -0
- package/dist/admin/messages.js +28 -0
- package/dist/admin/messages.js.map +1 -0
- package/dist/blocks/file/index.d.ts +2 -0
- package/dist/blocks/file/index.d.ts.map +1 -0
- package/dist/blocks/file/index.js +109 -0
- package/dist/blocks/file/index.js.map +1 -0
- package/dist/blocks/file/normalize.d.ts +10 -0
- package/dist/blocks/file/normalize.d.ts.map +1 -0
- package/dist/blocks/file/normalize.js +45 -0
- package/dist/blocks/file/normalize.js.map +1 -0
- package/dist/blocks/image/index.d.ts +2 -0
- package/dist/blocks/image/index.d.ts.map +1 -0
- package/dist/blocks/image/index.js +141 -0
- package/dist/blocks/image/index.js.map +1 -0
- package/dist/blocks/image/normalize.d.ts +7 -0
- package/dist/blocks/image/normalize.d.ts.map +1 -0
- package/dist/blocks/image/normalize.js +20 -0
- package/dist/blocks/image/normalize.js.map +1 -0
- package/dist/blocks.d.ts +2 -0
- package/dist/blocks.d.ts.map +1 -0
- package/dist/blocks.js +6 -0
- package/dist/blocks.js.map +1 -0
- package/dist/builder.d.ts +80 -0
- package/dist/builder.d.ts.map +1 -0
- package/dist/builder.js +61 -0
- package/dist/builder.js.map +1 -0
- package/dist/fields.d.ts +3 -0
- package/dist/fields.d.ts.map +1 -0
- package/dist/fields.js +7 -0
- package/dist/fields.js.map +1 -0
- package/dist/image-dimensions.d.ts +7 -0
- package/dist/image-dimensions.d.ts.map +1 -0
- package/dist/image-dimensions.js +142 -0
- package/dist/image-dimensions.js.map +1 -0
- package/dist/index.d.ts +62 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +216 -0
- package/dist/index.js.map +1 -0
- package/dist/lookup.d.ts +43 -0
- package/dist/lookup.d.ts.map +1 -0
- package/dist/lookup.js +154 -0
- package/dist/lookup.js.map +1 -0
- package/dist/magic-bytes.d.ts +4 -0
- package/dist/magic-bytes.d.ts.map +1 -0
- package/dist/magic-bytes.js +107 -0
- package/dist/magic-bytes.js.map +1 -0
- package/dist/mcp-tools.d.ts +10 -0
- package/dist/mcp-tools.d.ts.map +1 -0
- package/dist/mcp-tools.js +43 -0
- package/dist/mcp-tools.js.map +1 -0
- package/dist/media-blocks.d.ts +3 -0
- package/dist/media-blocks.d.ts.map +1 -0
- package/dist/media-blocks.js +10 -0
- package/dist/media-blocks.js.map +1 -0
- package/dist/meta.d.ts +12 -0
- package/dist/meta.d.ts.map +1 -0
- package/dist/meta.js +20 -0
- package/dist/meta.js.map +1 -0
- package/dist/mime.d.ts +3 -0
- package/dist/mime.d.ts.map +1 -0
- package/dist/mime.js +58 -0
- package/dist/mime.js.map +1 -0
- package/dist/read-service.d.ts +66 -0
- package/dist/read-service.d.ts.map +1 -0
- package/dist/read-service.js +156 -0
- package/dist/read-service.js.map +1 -0
- package/dist/rpc.d.ts +7 -0
- package/dist/rpc.d.ts.map +1 -0
- package/dist/rpc.js +352 -0
- package/dist/rpc.js.map +1 -0
- package/dist/serve-route.d.ts +19 -0
- package/dist/serve-route.d.ts.map +1 -0
- package/dist/serve-route.js +122 -0
- package/dist/serve-route.js.map +1 -0
- package/dist/upload-route.d.ts +17 -0
- package/dist/upload-route.d.ts.map +1 -0
- package/dist/upload-route.js +106 -0
- package/dist/upload-route.js.map +1 -0
- 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
|