@backmatter/studio 0.0.6 → 0.0.8

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