@backmatter/studio 0.0.6 → 0.0.7

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 (42) hide show
  1. package/dist/chunks/{EmbedPublishPage-DazYJLaD.js → EmbedPublishPage-88VsmOhi.js} +1 -1
  2. package/dist/chunks/{UiKitPage-DyVyBp0I.js → UiKitPage-X4hkFL2d.js} +3 -3
  3. package/dist/chunks/{WorkspaceRoutes-CPvkgvxe.js → WorkspaceRoutes-DXzdWqN3.js} +20287 -18407
  4. package/dist/chunks/{dashboard-6aSS8oyj.js → dashboard-DCHRI0WD.js} +465 -443
  5. package/dist/chunks/{index-DgTNL4t3.js → index-BS0-Bvbk.js} +2 -2
  6. package/dist/chunks/{status-bar-action-RNBujSgk.js → status-bar-action-wj1KZ51m.js} +5512 -5382
  7. package/dist/dashboard.js +2 -2
  8. package/dist/index.js +4 -4
  9. package/dist/site-widget.js +2 -2
  10. package/dist/studio.css +450 -62
  11. package/package.json +3 -2
  12. package/src/App.tsx +2 -0
  13. package/src/WorkspaceRoutes.tsx +2 -0
  14. package/src/api.ts +80 -0
  15. package/src/chat/ChatMessage.tsx +10 -3
  16. package/src/chat/Composer.tsx +18 -18
  17. package/src/chat/chat-helpers.ts +58 -1
  18. package/src/chrome/AppSidebar.tsx +31 -156
  19. package/src/chrome/CommandPalette.tsx +211 -74
  20. package/src/chrome/ContentExplorerSidebar.tsx +1174 -0
  21. package/src/chrome/UserMenu.tsx +18 -3
  22. package/src/chrome/WorkspaceNavContext.tsx +1 -0
  23. package/src/chrome/command-palette-icons.tsx +152 -0
  24. package/src/chrome/content-path-tree.ts +95 -0
  25. package/src/chrome/content-repo-path.ts +52 -0
  26. package/src/chrome/explorer-icons.tsx +89 -0
  27. package/src/chrome/folder-create-context.ts +147 -0
  28. package/src/pages/BlockSettingsDialog.tsx +4 -0
  29. package/src/pages/CliAuthorizePage.tsx +43 -0
  30. package/src/pages/DocEditorPage.tsx +33 -4
  31. package/src/pages/MediaFieldInput.tsx +139 -0
  32. package/src/pages/MediaGalleryPage.tsx +752 -0
  33. package/src/pages/MediaLightbox.tsx +460 -0
  34. package/src/pages/SettingsUsagePage.tsx +19 -0
  35. package/src/pages/TypeDocsPage.tsx +2 -210
  36. package/src/pages/WorkspaceLayout.tsx +6 -1
  37. package/src/pages/media-grid.ts +190 -0
  38. package/src/pages/preview-url.ts +13 -2
  39. package/src/replica/useReplica.ts +9 -0
  40. package/src/ui/ConfirmDialog.tsx +1 -1
  41. package/src/workspace/permissions.ts +9 -0
  42. package/src/workspace-path.ts +13 -0
@@ -0,0 +1,752 @@
1
+ // packages/studio/src/pages/MediaGalleryPage.tsx
2
+ import { ContextMenu } from "@base-ui/react/context-menu";
3
+ import {
4
+ IconCloudUpload,
5
+ IconExpandSimple,
6
+ IconSquareBehindSquare1,
7
+ IconTrashCanSimple,
8
+ } from "@central-icons-react/round-outlined-radius-2-stroke-1.5";
9
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
10
+ import { useOutletContext, useSearchParams } from "react-router";
11
+ import * as stylex from "@stylexjs/stylex";
12
+ import { deleteMedia, fetchMedia, uploadMedia } from "../api";
13
+ import { colors, radius, space, type, z } from "../styles/tokens.stylex";
14
+ import { Button } from "../ui/Button";
15
+ import { ConfirmDialog } from "../ui/ConfirmDialog";
16
+ import { Text } from "../ui/Text";
17
+ import { cx, popup, popupClassName } from "../ui/popup";
18
+ import { canWriteContent } from "../workspace/permissions";
19
+ import type { WorkspaceOutletContext } from "../workspace/types";
20
+ import {
21
+ beginUploads,
22
+ emptyMediaGrid,
23
+ failUpload,
24
+ finishUpload,
25
+ gridLength,
26
+ lightboxImageId,
27
+ mediaIdAfterRemoval,
28
+ nextMediaIndex,
29
+ receivePage,
30
+ rejectionReason,
31
+ removeMedia,
32
+ searchWithLightboxImage,
33
+ setUploadProgress,
34
+ type CatalogMedia,
35
+ type MediaGridState,
36
+ type PendingUpload,
37
+ } from "./media-grid";
38
+ import { MediaLightbox } from "./MediaLightbox";
39
+
40
+ const CELL = 168;
41
+
42
+ export function MediaGalleryPage({
43
+ pick = false,
44
+ onPick,
45
+ }: {
46
+ pick?: boolean;
47
+ onPick?: (url: string) => void;
48
+ }) {
49
+ const { workspace } = useOutletContext<WorkspaceOutletContext>();
50
+ const [searchParams, setSearchParams] = useSearchParams();
51
+ const [state, setState] = useState<MediaGridState>(emptyMediaGrid);
52
+ const [error, setError] = useState<string | null>(null);
53
+ const [rejected, setRejected] = useState<string[]>([]);
54
+ const [dragging, setDragging] = useState(false);
55
+ const [pendingDelete, setPendingDelete] = useState<CatalogMedia | null>(null);
56
+ const [deleting, setDeleting] = useState(false);
57
+ const [deleteError, setDeleteError] = useState<string | null>(null);
58
+ const [selectedId, setSelectedId] = useState<string | null>(null);
59
+ const [settled, setSettled] = useState(false);
60
+ const dragDepth = useRef(0);
61
+ const scroller = useRef<HTMLDivElement>(null);
62
+ const [scroll, setScroll] = useState({ top: 0, height: 600, width: 800 });
63
+ const loadingPage = useRef(false);
64
+
65
+ const load = useCallback(
66
+ async (cursor?: string) => {
67
+ if (loadingPage.current) return;
68
+ loadingPage.current = true;
69
+ try {
70
+ const page = await fetchMedia(workspace.id, { cursor });
71
+ setState((current) => receivePage(current, page, cursor ? "append" : "replace"));
72
+ setError(null);
73
+ } catch (err) {
74
+ setError(err instanceof Error ? err.message : "Could not load media");
75
+ } finally {
76
+ loadingPage.current = false;
77
+ if (!cursor) setSettled(true);
78
+ }
79
+ },
80
+ [workspace.id],
81
+ );
82
+
83
+ useEffect(() => {
84
+ void load();
85
+ }, [load]);
86
+
87
+ useEffect(() => {
88
+ const node = scroller.current;
89
+ if (!node) return;
90
+ setScroll({ top: node.scrollTop, height: node.clientHeight || 600, width: node.clientWidth || 800 });
91
+ }, [state.pending.length, state.loaded.length]);
92
+
93
+ const uploadFiles = useCallback(
94
+ (files: File[]) => {
95
+ const accepted: File[] = [];
96
+ const names: string[] = [];
97
+ for (const file of files) {
98
+ const reason = rejectionReason(file);
99
+ if (reason) names.push(reason);
100
+ else accepted.push(file);
101
+ }
102
+ setRejected(names);
103
+ if (accepted.length === 0) return;
104
+ const items = accepted.map((file) => ({
105
+ key: crypto.randomUUID(),
106
+ filename: file.name,
107
+ previewUrl: URL.createObjectURL(file),
108
+ file,
109
+ }));
110
+ setState((current) => beginUploads(current, items));
111
+ for (const item of items) {
112
+ void uploadMedia(workspace.id, item.file, (fraction) => {
113
+ setState((current) => setUploadProgress(current, item.key, fraction));
114
+ })
115
+ .then((result) => {
116
+ URL.revokeObjectURL(item.previewUrl);
117
+ setState((current) => finishUpload(current, item.key, result.media));
118
+ })
119
+ .catch((err: unknown) => {
120
+ setState((current) =>
121
+ failUpload(current, item.key, err instanceof Error ? err.message : "Upload failed"),
122
+ );
123
+ });
124
+ }
125
+ },
126
+ [workspace.id],
127
+ );
128
+
129
+ const chooseFiles = useCallback(() => {
130
+ const input = document.createElement("input");
131
+ input.type = "file";
132
+ input.accept = "image/jpeg,image/png,image/webp,image/gif,image/avif";
133
+ input.multiple = true;
134
+ input.onchange = () => uploadFiles(Array.from(input.files ?? []));
135
+ input.click();
136
+ }, [uploadFiles]);
137
+
138
+ useEffect(() => {
139
+ if (pick) return;
140
+ const hasFiles = (event: DragEvent) =>
141
+ Array.from(event.dataTransfer?.types ?? []).includes("Files");
142
+ const onEnter = (event: DragEvent) => {
143
+ if (!hasFiles(event)) return;
144
+ event.preventDefault();
145
+ dragDepth.current += 1;
146
+ setDragging(true);
147
+ };
148
+ const onOver = (event: DragEvent) => {
149
+ if (!hasFiles(event)) return;
150
+ event.preventDefault();
151
+ };
152
+ const onLeave = () => {
153
+ dragDepth.current = Math.max(0, dragDepth.current - 1);
154
+ if (dragDepth.current === 0) setDragging(false);
155
+ };
156
+ const onDrop = (event: DragEvent) => {
157
+ if (!hasFiles(event)) return;
158
+ event.preventDefault();
159
+ dragDepth.current = 0;
160
+ setDragging(false);
161
+ uploadFiles(Array.from(event.dataTransfer?.files ?? []));
162
+ };
163
+ window.addEventListener("dragenter", onEnter);
164
+ window.addEventListener("dragover", onOver);
165
+ window.addEventListener("dragleave", onLeave);
166
+ window.addEventListener("drop", onDrop);
167
+ return () => {
168
+ window.removeEventListener("dragenter", onEnter);
169
+ window.removeEventListener("dragover", onOver);
170
+ window.removeEventListener("dragleave", onLeave);
171
+ window.removeEventListener("drop", onDrop);
172
+ };
173
+ }, [pick, uploadFiles]);
174
+
175
+ const browsable = [
176
+ ...state.pending.flatMap((item) => (item.media ? [item.media] : [])),
177
+ ...state.loaded.filter((item) => !state.pending.some((slot) => slot.media?.id === item.id)),
178
+ ];
179
+ const mediaPlaces = (() => {
180
+ const places: number[] = [];
181
+ state.pending.forEach((item, index) => {
182
+ if (item.media) places.push(index);
183
+ });
184
+ const lead = state.pending.length;
185
+ let catalog = 0;
186
+ for (const item of state.loaded) {
187
+ if (state.pending.some((slot) => slot.media?.id === item.id)) continue;
188
+ places.push(lead + catalog);
189
+ catalog += 1;
190
+ }
191
+ return places;
192
+ })();
193
+ const sharedId = pick ? null : lightboxImageId(searchParams);
194
+ const lightbox = sharedId ? browsable.findIndex((item) => item.id === sharedId) : -1;
195
+ const showImage = useCallback(
196
+ (id: string, replace: boolean) => {
197
+ setSearchParams(searchWithLightboxImage(searchParams, id), { replace });
198
+ },
199
+ [searchParams, setSearchParams],
200
+ );
201
+ const closeLightbox = useCallback(() => {
202
+ setSearchParams(searchWithLightboxImage(searchParams, null), { replace: true });
203
+ }, [searchParams, setSearchParams]);
204
+ const prevLightbox = useCallback(() => {
205
+ if (lightbox <= 0) return;
206
+ const item = browsable[lightbox - 1];
207
+ if (item) showImage(item.id, true);
208
+ }, [browsable, lightbox, showImage]);
209
+ const nextLightbox = useCallback(() => {
210
+ if (lightbox < 0 || lightbox >= browsable.length - 1) return;
211
+ const item = browsable[lightbox + 1];
212
+ if (item) showImage(item.id, true);
213
+ }, [browsable, lightbox, showImage]);
214
+
215
+ const canDelete = canWriteContent(workspace.role);
216
+ const requestDelete = useCallback((item: CatalogMedia) => {
217
+ setDeleteError(null);
218
+ setPendingDelete(item);
219
+ }, []);
220
+ const confirmDelete = useCallback(async () => {
221
+ if (!pendingDelete) return;
222
+ const id = pendingDelete.id;
223
+ const ids = [
224
+ ...state.pending.flatMap((item) => (item.media ? [item.media.id] : [])),
225
+ ...state.loaded
226
+ .filter((item) => !state.pending.some((slot) => slot.media?.id === item.id))
227
+ .map((item) => item.id),
228
+ ];
229
+ const nextId = mediaIdAfterRemoval(ids, id);
230
+ setDeleting(true);
231
+ try {
232
+ await deleteMedia(workspace.id, id);
233
+ setState((current) => removeMedia(current, id));
234
+ if (selectedId === id) setSelectedId(nextId);
235
+ if (lightboxImageId(searchParams) === id) {
236
+ setSearchParams(searchWithLightboxImage(searchParams, nextId), { replace: true });
237
+ }
238
+ setPendingDelete(null);
239
+ } catch (err) {
240
+ setDeleteError(err instanceof Error ? err.message : "Could not delete media");
241
+ } finally {
242
+ setDeleting(false);
243
+ }
244
+ }, [pendingDelete, searchParams, selectedId, setSearchParams, state.loaded, state.pending, workspace.id]);
245
+
246
+ useEffect(() => {
247
+ if (!sharedId) return;
248
+ if (browsable.some((item) => item.id === sharedId)) return;
249
+ if (!state.nextCursor) return;
250
+ void load(state.nextCursor);
251
+ }, [sharedId, browsable, state.nextCursor, load]);
252
+
253
+ const columns = Math.max(2, Math.floor((scroll.width - 24) / (CELL + 12)));
254
+ const count = gridLength(state);
255
+ const rows = Math.ceil(count / columns);
256
+ const startRow = Math.max(0, Math.floor(scroll.top / (CELL + 12)) - 1);
257
+ const endRow = Math.min(rows, Math.ceil((scroll.top + scroll.height) / (CELL + 12)) + 2);
258
+
259
+ const cells = useMemo(() => {
260
+ const out: Array<{ kind: "pending"; item: PendingUpload } | { kind: "media"; item: CatalogMedia } | { kind: "empty" }> = [];
261
+ for (const item of state.pending) {
262
+ if (item.media) out.push({ kind: "media", item: item.media });
263
+ else out.push({ kind: "pending", item });
264
+ }
265
+ for (const item of state.loaded) {
266
+ if (state.pending.some((slot) => slot.media?.id === item.id)) continue;
267
+ out.push({ kind: "media", item });
268
+ }
269
+ while (out.length < count) out.push({ kind: "empty" });
270
+ return out;
271
+ }, [state.pending, state.loaded, count]);
272
+
273
+ const visible = [];
274
+ for (let row = startRow; row < endRow; row++) {
275
+ for (let column = 0; column < columns; column++) {
276
+ const index = row * columns + column;
277
+ if (index >= cells.length) break;
278
+ visible.push({ index, row, column, cell: cells[index]! });
279
+ }
280
+ }
281
+
282
+ const maybeLoadMore = () => {
283
+ const node = scroller.current;
284
+ if (!node || !state.nextCursor) return;
285
+ if (node.scrollTop + node.clientHeight > node.scrollHeight - CELL * 2) {
286
+ void load(state.nextCursor);
287
+ }
288
+ };
289
+
290
+ useEffect(() => {
291
+ if (pick || sharedId) return;
292
+ const onKey = (event: KeyboardEvent) => {
293
+ if (!gridKeyTarget(event.target, scroller.current)) return;
294
+ if (document.querySelector("[role='menu'], [role='alertdialog']")) return;
295
+ const arrow =
296
+ event.key === "ArrowLeft"
297
+ ? "left"
298
+ : event.key === "ArrowRight"
299
+ ? "right"
300
+ : event.key === "ArrowUp"
301
+ ? "up"
302
+ : event.key === "ArrowDown"
303
+ ? "down"
304
+ : null;
305
+ if (arrow) {
306
+ event.preventDefault();
307
+ const index = selectedId ? browsable.findIndex((item) => item.id === selectedId) : -1;
308
+ const next = nextMediaIndex({
309
+ index: index < 0 ? null : index,
310
+ arrow,
311
+ columns,
312
+ pendingCount: 0,
313
+ mediaCount: browsable.length,
314
+ places: mediaPlaces,
315
+ });
316
+ const item = next === null ? undefined : browsable[next];
317
+ if (item) setSelectedId(item.id);
318
+ return;
319
+ }
320
+ if (event.key === "Escape") {
321
+ if (!selectedId) return;
322
+ event.preventDefault();
323
+ setSelectedId(null);
324
+ return;
325
+ }
326
+ if (event.key !== " " || !selectedId) return;
327
+ const item = browsable.find((entry) => entry.id === selectedId);
328
+ if (!item) return;
329
+ event.preventDefault();
330
+ showImage(item.id, searchParams.has("image"));
331
+ };
332
+ window.addEventListener("keydown", onKey);
333
+ return () => window.removeEventListener("keydown", onKey);
334
+ }, [
335
+ browsable,
336
+ columns,
337
+ mediaPlaces,
338
+ pick,
339
+ searchParams,
340
+ selectedId,
341
+ sharedId,
342
+ showImage,
343
+ state.pending.length,
344
+ ]);
345
+
346
+ useEffect(() => {
347
+ if (!selectedId || sharedId) return;
348
+ const index = browsable.findIndex((item) => item.id === selectedId);
349
+ const node = scroller.current;
350
+ if (index < 0 || !node) return;
351
+ const place = mediaPlaces[index];
352
+ if (place === undefined) return;
353
+ const row = Math.floor(place / columns);
354
+ const top = row * (CELL + 12);
355
+ const bottom = top + CELL;
356
+ if (top < node.scrollTop) node.scrollTop = top;
357
+ else if (bottom > node.scrollTop + node.clientHeight) node.scrollTop = bottom - node.clientHeight;
358
+ }, [browsable, columns, mediaPlaces, selectedId, sharedId, state.pending.length]);
359
+
360
+ const open = lightbox >= 0 ? browsable[lightbox] : undefined;
361
+ const showEmpty =
362
+ settled && state.pending.length === 0 && state.loaded.length === 0 && state.total === 0;
363
+
364
+ return (
365
+ <div {...stylex.props(styles.page)}>
366
+ <div {...stylex.props(styles.toolbar)}>
367
+ <Text as="h1" size="xl" weight="medium">
368
+ Media
369
+ </Text>
370
+ <Button variant="secondary" onClick={chooseFiles}>
371
+ Upload
372
+ </Button>
373
+ </div>
374
+ {error ? <Text tone="danger">{error}</Text> : null}
375
+ {rejected.length > 0 ? <Text tone="danger">{rejected.join(" · ")}</Text> : null}
376
+ <div
377
+ ref={scroller}
378
+ {...stylex.props(styles.scroller)}
379
+ onScroll={(event) => {
380
+ const node = event.currentTarget;
381
+ setScroll({ top: node.scrollTop, height: node.clientHeight, width: node.clientWidth });
382
+ maybeLoadMore();
383
+ }}
384
+ >
385
+ {showEmpty ? (
386
+ <div
387
+ {...stylex.props(styles.emptyState)}
388
+ onDragOver={(event) => event.preventDefault()}
389
+ onDrop={(event) => {
390
+ event.preventDefault();
391
+ event.stopPropagation();
392
+ uploadFiles(Array.from(event.dataTransfer?.files ?? []));
393
+ }}
394
+ >
395
+ <div {...stylex.props(styles.emptyCard, dragging && styles.emptyCardActive)}>
396
+ <div {...stylex.props(styles.emptyIcon)}>
397
+ <IconCloudUpload size={22} ariaHidden />
398
+ </div>
399
+ <Text as="h2" size="lg" weight="medium">
400
+ Drop images to upload
401
+ </Text>
402
+ <Text tone="muted" size="sm">
403
+ JPEG, PNG, WebP, GIF, and AVIF, up to 20 MB.
404
+ </Text>
405
+ <div {...stylex.props(styles.emptyAction)}>
406
+ <Button variant="secondary" onClick={chooseFiles}>
407
+ Upload
408
+ </Button>
409
+ </div>
410
+ </div>
411
+ </div>
412
+ ) : (
413
+ <div {...stylex.props(styles.canvas)} style={{ height: rows * (CELL + 12) }}>
414
+ {visible.map(({ index, row, column, cell }) => (
415
+ <div
416
+ key={cell.kind === "media" ? cell.item.id : cell.kind === "pending" ? cell.item.key : `empty-${index}`}
417
+ {...stylex.props(styles.cell)}
418
+ style={{
419
+ width: CELL,
420
+ height: CELL,
421
+ transform: `translate(${column * (CELL + 12)}px, ${row * (CELL + 12)}px)`,
422
+ }}
423
+ >
424
+ {cell.kind === "empty" ? <div {...stylex.props(styles.empty)} /> : null}
425
+ {cell.kind === "pending" ? (
426
+ <figure {...stylex.props(styles.figure)}>
427
+ <img src={cell.item.previewUrl} alt="" {...stylex.props(styles.image, styles.dimmed)} />
428
+ {cell.item.status === "error" ? (
429
+ <figcaption {...stylex.props(styles.caption)}>{cell.item.error}</figcaption>
430
+ ) : (
431
+ <div {...stylex.props(styles.barTrack)}>
432
+ <div
433
+ {...stylex.props(styles.barFill)}
434
+ style={{
435
+ ...stylex.props(styles.barFill).style,
436
+ width: `${Math.round(cell.item.progress * 100)}%`,
437
+ }}
438
+ />
439
+ </div>
440
+ )}
441
+ </figure>
442
+ ) : null}
443
+ {cell.kind === "media" ? (
444
+ <MediaHit
445
+ item={cell.item}
446
+ menu={!pick}
447
+ selected={!pick && !sharedId && cell.item.id === selectedId}
448
+ canDelete={canDelete}
449
+ onOpen={() => {
450
+ setSelectedId(null);
451
+ if (pick) {
452
+ if (onPick) onPick(cell.item.url);
453
+ return;
454
+ }
455
+ showImage(cell.item.id, searchParams.has("image"));
456
+ }}
457
+ onDelete={() => requestDelete(cell.item)}
458
+ />
459
+ ) : null}
460
+ </div>
461
+ ))}
462
+ </div>
463
+ )}
464
+ </div>
465
+ {dragging ? (
466
+ <div {...stylex.props(styles.overlay)}>
467
+ <Text size="xl" weight="medium">
468
+ Drop to upload
469
+ </Text>
470
+ </div>
471
+ ) : null}
472
+ {open && lightbox >= 0 ? (
473
+ <MediaLightbox
474
+ item={open}
475
+ index={lightbox}
476
+ count={browsable.length}
477
+ onClose={closeLightbox}
478
+ onPrev={prevLightbox}
479
+ onNext={nextLightbox}
480
+ onDelete={canDelete ? () => requestDelete(open) : undefined}
481
+ />
482
+ ) : null}
483
+ <ConfirmDialog
484
+ open={pendingDelete !== null}
485
+ onOpenChange={(next) => {
486
+ if (!next && !deleting) setPendingDelete(null);
487
+ }}
488
+ title="Delete media?"
489
+ description={
490
+ pendingDelete
491
+ ? `${pendingDelete.filename} leaves the gallery. Documents that already use its URL keep that URL.${deleteError ? ` ${deleteError}` : ""}`
492
+ : undefined
493
+ }
494
+ confirmLabel="Delete"
495
+ destructive
496
+ busy={deleting}
497
+ onConfirm={confirmDelete}
498
+ />
499
+ </div>
500
+ );
501
+ }
502
+
503
+ function MediaHit({
504
+ item,
505
+ menu,
506
+ selected,
507
+ canDelete,
508
+ onOpen,
509
+ onDelete,
510
+ }: {
511
+ item: CatalogMedia;
512
+ menu: boolean;
513
+ selected: boolean;
514
+ canDelete: boolean;
515
+ onOpen: () => void;
516
+ onDelete: () => void;
517
+ }) {
518
+ const image = (
519
+ <button
520
+ type="button"
521
+ aria-current={selected ? "true" : undefined}
522
+ {...stylex.props(styles.hit, selected && styles.selected)}
523
+ onClick={onOpen}
524
+ >
525
+ <img src={item.thumbUrl} alt={item.filename} {...stylex.props(styles.image)} />
526
+ </button>
527
+ );
528
+ if (!menu) return image;
529
+ return (
530
+ <ContextMenu.Root>
531
+ <ContextMenu.Trigger className={cx(styles.menuHit)}>{image}</ContextMenu.Trigger>
532
+ <ContextMenu.Portal>
533
+ <ContextMenu.Positioner className={cx(popup.positioner)}>
534
+ <ContextMenu.Popup
535
+ aria-label={item.filename}
536
+ className={(state) => popupClassName(state, styles.menu)}
537
+ >
538
+ <ContextMenu.Item
539
+ label="Open lightbox"
540
+ onClick={onOpen}
541
+ className={(state) => cx(popup.item, styles.menuItem, state.highlighted && popup.itemHighlighted)}
542
+ >
543
+ <IconExpandSimple size={14} ariaHidden style={{ flexShrink: 0 }} />
544
+ Open lightbox
545
+ </ContextMenu.Item>
546
+ <ContextMenu.Item
547
+ label="Copy path"
548
+ onClick={() => {
549
+ void navigator.clipboard.writeText(item.url);
550
+ }}
551
+ className={(state) => cx(popup.item, styles.menuItem, state.highlighted && popup.itemHighlighted)}
552
+ >
553
+ <IconSquareBehindSquare1 size={14} ariaHidden style={{ flexShrink: 0 }} />
554
+ Copy path
555
+ </ContextMenu.Item>
556
+ {canDelete ? (
557
+ <ContextMenu.Item
558
+ label="Delete media"
559
+ onClick={onDelete}
560
+ className={(state) =>
561
+ cx(popup.item, styles.menuItem, styles.menuDanger, state.highlighted && popup.itemHighlighted)
562
+ }
563
+ >
564
+ <IconTrashCanSimple size={14} ariaHidden style={{ flexShrink: 0 }} />
565
+ Delete media
566
+ </ContextMenu.Item>
567
+ ) : null}
568
+ </ContextMenu.Popup>
569
+ </ContextMenu.Positioner>
570
+ </ContextMenu.Portal>
571
+ </ContextMenu.Root>
572
+ );
573
+ }
574
+
575
+ function gridKeyTarget(target: EventTarget | null, scroller: HTMLElement | null): boolean {
576
+ if (!(target instanceof HTMLElement)) return false;
577
+ if (target.closest("input, textarea, select, [contenteditable='true']")) return false;
578
+ if (target === document.body || target === document.documentElement) return true;
579
+ return scroller !== null && scroller.contains(target);
580
+ }
581
+
582
+ const styles = stylex.create({
583
+ page: {
584
+ display: "flex",
585
+ flexDirection: "column",
586
+ minHeight: 0,
587
+ height: "100%",
588
+ position: "relative",
589
+ },
590
+ toolbar: {
591
+ display: "flex",
592
+ alignItems: "center",
593
+ justifyContent: "space-between",
594
+ gap: space.lg,
595
+ padding: space.xl,
596
+ },
597
+ scroller: {
598
+ flex: 1,
599
+ minHeight: 0,
600
+ overflow: "auto",
601
+ paddingInline: space.xl,
602
+ paddingBottom: space.xl,
603
+ },
604
+ emptyState: {
605
+ display: "flex",
606
+ alignItems: "center",
607
+ justifyContent: "center",
608
+ minHeight: "100%",
609
+ padding: space.xl,
610
+ },
611
+ emptyCard: {
612
+ display: "flex",
613
+ flexDirection: "column",
614
+ alignItems: "center",
615
+ gap: space.sm,
616
+ width: "100%",
617
+ maxWidth: "440px",
618
+ paddingBlock: "48px",
619
+ paddingInline: space.xxl,
620
+ borderWidth: 1,
621
+ borderStyle: "dashed",
622
+ borderColor: colors.border,
623
+ borderRadius: "16px",
624
+ textAlign: "center",
625
+ },
626
+ emptyCardActive: {
627
+ borderColor: colors.accent,
628
+ },
629
+ emptyIcon: {
630
+ display: "flex",
631
+ alignItems: "center",
632
+ justifyContent: "center",
633
+ width: "56px",
634
+ height: "56px",
635
+ marginBottom: space.md,
636
+ borderWidth: 1,
637
+ borderStyle: "solid",
638
+ borderColor: colors.border,
639
+ borderRadius: "14px",
640
+ backgroundColor: colors.bgPanel,
641
+ color: colors.text,
642
+ },
643
+ emptyAction: {
644
+ marginTop: space.md,
645
+ },
646
+ canvas: {
647
+ position: "relative",
648
+ },
649
+ cell: {
650
+ position: "absolute",
651
+ top: 0,
652
+ left: 0,
653
+ boxSizing: "border-box",
654
+ padding: 6,
655
+ },
656
+ empty: {
657
+ width: "100%",
658
+ height: "100%",
659
+ borderRadius: radius.md,
660
+ backgroundColor: colors.bgPanel,
661
+ },
662
+ figure: {
663
+ position: "relative",
664
+ margin: 0,
665
+ height: "100%",
666
+ display: "flex",
667
+ flexDirection: "column",
668
+ },
669
+ hit: {
670
+ display: "block",
671
+ width: "100%",
672
+ height: "100%",
673
+ padding: 0,
674
+ borderWidth: 0,
675
+ borderRadius: radius.md,
676
+ backgroundColor: "transparent",
677
+ cursor: "pointer",
678
+ },
679
+ selected: {
680
+ outlineWidth: 2,
681
+ outlineStyle: "solid",
682
+ outlineColor: colors.focus,
683
+ outlineOffset: 3,
684
+ },
685
+ menuHit: {
686
+ display: "block",
687
+ width: "100%",
688
+ height: "100%",
689
+ },
690
+ menu: {
691
+ minWidth: "11rem",
692
+ },
693
+ menuItem: {
694
+ cursor: "pointer",
695
+ },
696
+ menuDanger: {
697
+ color: colors.danger,
698
+ },
699
+ image: {
700
+ width: "100%",
701
+ height: "100%",
702
+ objectFit: "cover",
703
+ borderRadius: radius.md,
704
+ backgroundColor: colors.bgPanel,
705
+ },
706
+ dimmed: {
707
+ opacity: 0.55,
708
+ },
709
+ barTrack: {
710
+ position: "absolute",
711
+ left: 0,
712
+ right: 0,
713
+ bottom: 0,
714
+ height: "8px",
715
+ overflow: "hidden",
716
+ borderBottomLeftRadius: radius.md,
717
+ borderBottomRightRadius: radius.md,
718
+ backgroundColor: "rgba(0, 0, 0, 0.45)",
719
+ },
720
+ barFill: {
721
+ height: "100%",
722
+ backgroundColor: colors.textInverse,
723
+ transitionProperty: "width",
724
+ transitionDuration: "160ms",
725
+ transitionTimingFunction: "linear",
726
+ },
727
+ caption: {
728
+ position: "absolute",
729
+ left: space.sm,
730
+ bottom: space.sm,
731
+ paddingInline: space.sm,
732
+ paddingBlock: space.xs,
733
+ borderRadius: radius.sm,
734
+ backgroundColor: colors.overlay,
735
+ color: colors.textInverse,
736
+ fontSize: type.xs,
737
+ },
738
+ overlay: {
739
+ position: "fixed",
740
+ inset: 0,
741
+ zIndex: z.overlay,
742
+ display: "flex",
743
+ alignItems: "center",
744
+ justifyContent: "center",
745
+ borderWidth: 3,
746
+ borderStyle: "dashed",
747
+ borderColor: colors.accent,
748
+ backgroundColor: colors.overlay,
749
+ color: colors.textInverse,
750
+ pointerEvents: "none",
751
+ },
752
+ });