@backmatter/studio 0.0.16 → 0.0.18

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 (45) hide show
  1. package/dist/chunks/{UiKitApp-_EDqWID3.js → UiKitApp-DkuQBiQZ.js} +2 -2
  2. package/dist/chunks/{WorkspaceRoutes-B2PrFgpz.js → WorkspaceRoutes-DVvb3N-V.js} +27489 -26622
  3. package/dist/chunks/{dashboard-BnlnMnnr.js → dashboard-B4FITg6c.js} +2 -2
  4. package/dist/chunks/{define-studio-BYtYDGLX.js → define-studio-DlYjgrwr.js} +12 -16
  5. package/dist/chunks/{index-D_BHF_8q.js → index-CegtXnEI.js} +1 -1
  6. package/dist/dashboard.js +2 -2
  7. package/dist/define.js +1 -1
  8. package/dist/index.js +4 -4
  9. package/dist/site-widget.css +45 -1
  10. package/dist/site-widget.js +2799 -2791
  11. package/dist/studio.css +113 -15
  12. package/package.json +2 -2
  13. package/src/api.ts +22 -0
  14. package/src/chat/ChatProvider.tsx +6 -0
  15. package/src/chat/Composer.tsx +3 -1
  16. package/src/chrome/Toast.tsx +115 -0
  17. package/src/chrome/WorkspaceStatusBar.tsx +12 -3
  18. package/src/chrome/commit-error.ts +13 -0
  19. package/src/chrome/pull-failure.ts +61 -0
  20. package/src/{pages/code-session-routes.ts → content-routes.ts} +6 -6
  21. package/src/fields/FieldControl.tsx +771 -0
  22. package/src/fields/LinkFieldInput.tsx +315 -0
  23. package/src/fields/choose-field-input.ts +69 -0
  24. package/src/fields/link-targets.ts +60 -0
  25. package/src/fields/load-relation-documents.ts +35 -0
  26. package/src/fields/omit-field.ts +12 -0
  27. package/src/fields/relation-targets.ts +44 -0
  28. package/src/fields/resolve-object-fields.ts +21 -0
  29. package/src/host-path.ts +13 -0
  30. package/src/pages/BlockSettingsDialog.tsx +19 -115
  31. package/src/pages/BlocksList.tsx +43 -35
  32. package/src/pages/BodyProse.tsx +16 -1
  33. package/src/pages/CodeSessionPage.tsx +186 -248
  34. package/src/pages/DocEditorPage.tsx +168 -213
  35. package/src/pages/MediaFieldInput.tsx +609 -26
  36. package/src/pages/MediaGalleryPage.tsx +14 -3
  37. package/src/pages/PreviewAddress.tsx +263 -0
  38. package/src/pages/RenderedBody.tsx +17 -2
  39. package/src/pages/branch-notice.ts +16 -0
  40. package/src/pages/code-session-boot.ts +0 -12
  41. package/src/pages/code-session-context.tsx +2 -0
  42. package/src/pages/media-selection.ts +79 -0
  43. package/src/shell-resets.css +15 -0
  44. package/src/ui/Input.tsx +178 -20
  45. package/src/ui/popup.ts +11 -2
@@ -1,53 +1,360 @@
1
1
  // packages/studio/src/pages/MediaFieldInput.tsx
2
- import { useState } from "react";
2
+ import { useCallback, useEffect, useRef, useState, type DragEvent } from "react";
3
3
  import { Dialog } from "@base-ui/react/dialog";
4
4
  import { mediaIdFromUrl, mediaVariantUrl } from "@backmatter/core";
5
5
  import * as stylex from "@stylexjs/stylex";
6
- import { colors, radius, space, type } from "../styles/tokens.stylex";
6
+ import { fetchMedia, uploadMedia, type CatalogMedia, type SchemaField } from "../api";
7
+ import { useOutletContext } from "react-router";
8
+ import { colors, radius, space, type, z } from "../styles/tokens.stylex";
7
9
  import { Button } from "../ui/Button";
10
+ import { Text } from "../ui/Text";
8
11
  import { cx, isTransitioning } from "../ui/popup";
12
+ import type { WorkspaceOutletContext } from "../workspace/types";
13
+ import { rejectionReason } from "./media-grid";
14
+ import {
15
+ acceptedDrop,
16
+ appendMediaUrls,
17
+ editorUrls,
18
+ moveMediaUrl,
19
+ notAddedMessage,
20
+ toggleMediaUrl,
21
+ } from "./media-selection";
9
22
  import { MediaGalleryPage } from "./MediaGalleryPage";
10
23
 
11
24
  const CDN_ORIGIN = "https://cdn.backmatter.app";
12
25
 
26
+ type PendingUpload = {
27
+ key: string;
28
+ filename: string;
29
+ previewUrl: string;
30
+ };
31
+
32
+ const hover = "@media (hover: hover)";
33
+ const GALLERY_PER_ROW = 4;
34
+
35
+ function thumbForUrl(url: string): string | null {
36
+ const id = mediaIdFromUrl(url);
37
+ return id ? mediaVariantUrl(CDN_ORIGIN, id, 400) : null;
38
+ }
39
+
13
40
  export function MediaFieldInput({
41
+ field,
14
42
  value,
15
43
  readOnly,
16
44
  onChange,
17
45
  }: {
18
46
  name: string;
47
+ field: SchemaField;
19
48
  value: unknown;
20
49
  readOnly?: boolean;
21
50
  onChange: (value: unknown) => void;
22
51
  }) {
52
+ const { workspace } = useOutletContext<WorkspaceOutletContext>();
53
+ const multiple = field.multiple === true;
54
+ const parsed = editorUrls(value, multiple);
55
+ const savedUrls = parsed.urls;
56
+
23
57
  const [open, setOpen] = useState(false);
24
- const url = typeof value === "string" ? value : "";
25
- const id = url ? mediaIdFromUrl(url) : null;
26
- const thumb = id ? mediaVariantUrl(CDN_ORIGIN, id, 400) : null;
58
+ const [expanded, setExpanded] = useState(false);
59
+ const [checkUrls, setCheckUrls] = useState<string[]>([]);
60
+ const [rejections, setRejections] = useState<string[]>([]);
61
+ const [notAdded, setNotAdded] = useState(0);
62
+ const [pending, setPending] = useState<PendingUpload[]>([]);
63
+ const dragFrom = useRef<number | null>(null);
64
+ const valueRef = useRef(value);
65
+ valueRef.current = value;
66
+ const onChangeRef = useRef(onChange);
67
+ onChangeRef.current = onChange;
68
+ const readOnlyRef = useRef(readOnly);
69
+ readOnlyRef.current = readOnly;
70
+
71
+ const write = useCallback((next: unknown, options?: { keepNotAdded?: boolean }) => {
72
+ if (readOnlyRef.current) return;
73
+ if (!options?.keepNotAdded) {
74
+ setNotAdded(0);
75
+ }
76
+ valueRef.current = next;
77
+ onChangeRef.current(next);
78
+ }, []);
79
+
80
+ const openModal = useCallback(() => {
81
+ setCheckUrls(editorUrls(valueRef.current, multiple).urls);
82
+ setOpen(true);
83
+ }, [multiple]);
84
+
85
+ const closeModal = useCallback(() => {
86
+ setOpen(false);
87
+ }, []);
88
+
89
+ const applyUse = useCallback(() => {
90
+ if (readOnly) return;
91
+ write(checkUrls.length === 0 ? undefined : checkUrls);
92
+ closeModal();
93
+ }, [checkUrls, closeModal, readOnly, write]);
94
+
95
+ const uploadFiles = useCallback(
96
+ (files: File[]) => {
97
+ if (readOnly) return;
98
+ const accepted: File[] = [];
99
+ const rejected: string[] = [];
100
+ for (const file of files) {
101
+ const reason = rejectionReason(file);
102
+ if (reason) rejected.push(reason);
103
+ else accepted.push(file);
104
+ }
105
+ setRejections(rejected);
106
+ if (accepted.length === 0) return;
107
+
108
+ const acceptedIds = accepted.map((file) => file.name);
109
+ const currentSaved = editorUrls(valueRef.current, multiple).urls;
110
+ const drop = acceptedDrop(currentSaved, acceptedIds, multiple);
111
+ setNotAdded(drop.notAdded);
112
+ const toUpload = multiple ? accepted : accepted.slice(0, drop.urls.length);
113
+
114
+ const items = toUpload.map((file) => ({
115
+ key: crypto.randomUUID(),
116
+ filename: file.name,
117
+ previewUrl: URL.createObjectURL(file),
118
+ file,
119
+ }));
120
+ setPending((current) => [
121
+ ...current,
122
+ ...items.map((item) => ({
123
+ key: item.key,
124
+ filename: item.filename,
125
+ previewUrl: item.previewUrl,
126
+ })),
127
+ ]);
128
+
129
+ for (const item of items) {
130
+ void uploadMedia(workspace.id, item.file)
131
+ .then((result) => {
132
+ URL.revokeObjectURL(item.previewUrl);
133
+ setPending((current) => current.filter((slot) => slot.key !== item.key));
134
+ const url = result.media.url;
135
+ const currentSaved = editorUrls(valueRef.current, multiple).urls;
136
+ if (multiple) {
137
+ write(appendMediaUrls(currentSaved, [url]));
138
+ return;
139
+ }
140
+ write(url, { keepNotAdded: true });
141
+ })
142
+ .catch(() => {
143
+ URL.revokeObjectURL(item.previewUrl);
144
+ setPending((current) => current.filter((slot) => slot.key !== item.key));
145
+ setRejections((current) => [...current, `${item.filename} could not be uploaded`]);
146
+ });
147
+ }
148
+ },
149
+ [multiple, readOnly, workspace.id, write],
150
+ );
151
+
152
+ const onFieldDrop = useCallback(
153
+ (event: DragEvent) => {
154
+ event.preventDefault();
155
+ event.stopPropagation();
156
+ uploadFiles(Array.from(event.dataTransfer?.files ?? []));
157
+ },
158
+ [uploadFiles],
159
+ );
160
+
161
+ const removeAt = useCallback(
162
+ (index: number) => {
163
+ const next = savedUrls.filter((_, i) => i !== index);
164
+ write(next.length === 0 ? undefined : next);
165
+ },
166
+ [savedUrls, write],
167
+ );
168
+
169
+ const onThumbDrop = useCallback(
170
+ (toIndex: number, event: DragEvent) => {
171
+ event.preventDefault();
172
+ const from = dragFrom.current;
173
+ dragFrom.current = null;
174
+ if (from === null || from === toIndex) return;
175
+ write(moveMediaUrl(savedUrls, from, toIndex));
176
+ },
177
+ [savedUrls, write],
178
+ );
179
+
180
+ const note = notAddedMessage(notAdded);
181
+ const messages = [...rejections, ...(note ? [note] : [])];
182
+
183
+ const wrongShapeOne = !multiple && Array.isArray(parsed.raw);
184
+ const showThumb = !wrongShapeOne && savedUrls.length > 0;
185
+ const selectedUrl = showThumb ? savedUrls[0]! : null;
186
+ const catalogKey = savedUrls.map((url) => mediaIdFromUrl(url) ?? "").join("\n");
187
+ const [catalog, setCatalog] = useState<CatalogMedia[]>([]);
188
+
189
+ useEffect(() => {
190
+ if (!catalogKey) {
191
+ setCatalog([]);
192
+ return;
193
+ }
194
+ let cancelled = false;
195
+ void fetchMedia(workspace.id)
196
+ .then((page) => {
197
+ if (!cancelled) setCatalog(page.media);
198
+ })
199
+ .catch(() => {
200
+ if (!cancelled) setCatalog([]);
201
+ });
202
+ return () => {
203
+ cancelled = true;
204
+ };
205
+ }, [catalogKey, workspace.id]);
206
+
207
+ const detailsFor = (url: string) => {
208
+ const id = mediaIdFromUrl(url);
209
+ return id ? catalog.find((item) => item.id === id) ?? null : null;
210
+ };
211
+ const leadDetails = selectedUrl ? detailsFor(selectedUrl) : null;
212
+ const galleryFilled = multiple && (showThumb || pending.length > 0);
213
+ const hiddenCount = multiple && showThumb ? Math.max(0, savedUrls.length - GALLERY_PER_ROW) : 0;
214
+ const galleryUrls =
215
+ multiple && showThumb && !expanded ? savedUrls.slice(0, GALLERY_PER_ROW) : savedUrls;
27
216
 
28
217
  return (
29
- <div {...stylex.props(styles.row)}>
30
- {thumb ? (
31
- <img src={thumb} alt="" {...stylex.props(styles.thumb)} />
32
- ) : (
33
- <div {...stylex.props(styles.placeholder)}>No image</div>
34
- )}
35
- {readOnly ? null : (
36
- <Button variant="secondary" onClick={() => setOpen(true)}>
37
- Choose
38
- </Button>
39
- )}
40
- {readOnly || !url ? null : (
41
- <Button variant="ghost" onClick={() => onChange("")}>
42
- Clear
43
- </Button>
44
- )}
45
- <Dialog.Root open={open} onOpenChange={setOpen}>
218
+ <div {...stylex.props(styles.stack)}>
219
+ <div
220
+ data-media-drop
221
+ {...stylex.props(styles.row)}
222
+ onDragOver={(event) => event.preventDefault()}
223
+ onDrop={onFieldDrop}
224
+ >
225
+ {multiple && showThumb ? (
226
+ <div {...stylex.props(styles.galleryStack)}>
227
+ <div data-media-grid {...stylex.props(styles.galleryGrid)}>
228
+ {galleryUrls.map((url, index) => {
229
+ const thumb = thumbForUrl(url);
230
+ return (
231
+ <div
232
+ key={`${url}-${index}`}
233
+ data-media-thumb
234
+ draggable={!readOnly}
235
+ {...stylex.props(styles.thumbWrap)}
236
+ onDragStart={() => {
237
+ dragFrom.current = index;
238
+ }}
239
+ onDragOver={(event) => event.preventDefault()}
240
+ onDrop={(event) => onThumbDrop(index, event)}
241
+ >
242
+ {thumb ? (
243
+ <img src={thumb} alt="" {...stylex.props(styles.galleryTile)} />
244
+ ) : (
245
+ <div {...stylex.props(styles.galleryTile, styles.moreEmpty)} />
246
+ )}
247
+ {readOnly || (field.required === true && savedUrls.length === 1) ? null : (
248
+ <button
249
+ type="button"
250
+ aria-label="Remove image"
251
+ {...stylex.props(styles.galleryRemove)}
252
+ onClick={() => removeAt(index)}
253
+ >
254
+ ×
255
+ </button>
256
+ )}
257
+ </div>
258
+ );
259
+ })}
260
+ {pending.map((item) => (
261
+ <div key={item.key} {...stylex.props(styles.thumbWrap)}>
262
+ <img src={item.previewUrl} alt="" {...stylex.props(styles.galleryTile, styles.pending)} />
263
+ </div>
264
+ ))}
265
+ </div>
266
+ {hiddenCount > 0 || !readOnly ? (
267
+ <div {...stylex.props(styles.galleryFooter)}>
268
+ {hiddenCount > 0 ? (
269
+ <button
270
+ type="button"
271
+ {...stylex.props(styles.showMore)}
272
+ onClick={() => setExpanded((current) => !current)}
273
+ >
274
+ {expanded ? "Show less" : "Show more"}
275
+ </button>
276
+ ) : (
277
+ <span />
278
+ )}
279
+ {readOnly ? null : (
280
+ <button
281
+ type="button"
282
+ aria-label="Edit image"
283
+ {...stylex.props(styles.iconBtn)}
284
+ onClick={openModal}
285
+ >
286
+ <PencilIcon />
287
+ </button>
288
+ )}
289
+ </div>
290
+ ) : null}
291
+ </div>
292
+ ) : multiple && pending[0] ? (
293
+ <FileCard
294
+ src={pending[0].previewUrl}
295
+ name={pending[0].filename}
296
+ meta="Uploading"
297
+ readOnly
298
+ required
299
+ onEdit={openModal}
300
+ onRemove={() => undefined}
301
+ />
302
+ ) : multiple ? (
303
+ <div {...stylex.props(styles.thumbRow)} />
304
+ ) : wrongShapeOne ? (
305
+ <Text tone="muted">Choose one image</Text>
306
+ ) : showThumb ? (
307
+ <FileCard
308
+ src={thumbForUrl(selectedUrl!) ?? selectedUrl!}
309
+ name={leadDetails?.filename ?? "Image"}
310
+ meta={leadDetails ? mediaMeta(leadDetails) : null}
311
+ readOnly={readOnly}
312
+ required={field.required === true}
313
+ onEdit={openModal}
314
+ onRemove={() => write(undefined)}
315
+ />
316
+ ) : pending[0] ? (
317
+ <FileCard
318
+ src={pending[0].previewUrl}
319
+ name={pending[0].filename}
320
+ meta="Uploading"
321
+ readOnly
322
+ required
323
+ onEdit={openModal}
324
+ onRemove={() => undefined}
325
+ />
326
+ ) : (
327
+ <div {...stylex.props(styles.placeholder)}>No image</div>
328
+ )}
329
+ {readOnly || galleryFilled || (!multiple && showThumb && !wrongShapeOne) ? null : (
330
+ <Button variant="secondary" onClick={openModal}>
331
+ Choose
332
+ </Button>
333
+ )}
334
+ {readOnly || galleryFilled || !multiple || !showThumb || field.required ? null : (
335
+ <Button variant="ghost" onClick={() => write(undefined)}>
336
+ Clear
337
+ </Button>
338
+ )}
339
+ </div>
340
+ {messages.length > 0 ? (
341
+ <Text tone="danger" size="sm">
342
+ {messages.join(" · ")}
343
+ </Text>
344
+ ) : null}
345
+ <Dialog.Root
346
+ open={open}
347
+ onOpenChange={(next) => {
348
+ if (!next) closeModal();
349
+ }}
350
+ >
46
351
  <Dialog.Portal>
47
352
  <Dialog.Backdrop
353
+ data-media-dialog-backdrop
48
354
  className={(state) =>
49
355
  cx(styles.backdrop, isTransitioning(state.transitionStatus) && styles.backdropHidden)
50
356
  }
357
+ onClick={() => closeModal()}
51
358
  />
52
359
  <Dialog.Popup
53
360
  className={(state) =>
@@ -58,12 +365,31 @@ export function MediaFieldInput({
58
365
  <div {...stylex.props(styles.gallery)}>
59
366
  <MediaGalleryPage
60
367
  pick
61
- onPick={(next) => {
62
- onChange(next);
63
- setOpen(false);
64
- }}
368
+ pickedUrls={multiple ? checkUrls : undefined}
369
+ onToggleUrl={
370
+ multiple
371
+ ? (url) => {
372
+ setCheckUrls((current) => toggleMediaUrl(current, url));
373
+ }
374
+ : undefined
375
+ }
376
+ onPick={
377
+ multiple
378
+ ? undefined
379
+ : (url) => {
380
+ write(url);
381
+ closeModal();
382
+ }
383
+ }
65
384
  />
66
385
  </div>
386
+ {multiple ? (
387
+ <div {...stylex.props(styles.footer)}>
388
+ <Button variant="primary" onClick={applyUse}>
389
+ Use
390
+ </Button>
391
+ </div>
392
+ ) : null}
67
393
  </Dialog.Popup>
68
394
  </Dialog.Portal>
69
395
  </Dialog.Root>
@@ -71,16 +397,172 @@ export function MediaFieldInput({
71
397
  );
72
398
  }
73
399
 
400
+ function FileCard({
401
+ src,
402
+ name,
403
+ meta,
404
+ readOnly,
405
+ required,
406
+ onEdit,
407
+ onRemove,
408
+ }: {
409
+ src: string;
410
+ name: string;
411
+ meta: string | null;
412
+ readOnly?: boolean;
413
+ required: boolean;
414
+ onEdit: () => void;
415
+ onRemove: () => void;
416
+ }) {
417
+ return (
418
+ <div {...stylex.props(styles.fileCard)} data-media-file>
419
+ <img src={src} alt="" {...stylex.props(styles.fileThumb)} />
420
+ <div {...stylex.props(styles.fileText)}>
421
+ <span {...stylex.props(styles.fileName)}>{name}</span>
422
+ {meta ? <span {...stylex.props(styles.fileMeta)}>{meta}</span> : null}
423
+ </div>
424
+ {readOnly ? null : (
425
+ <div {...stylex.props(styles.fileActions)}>
426
+ <button type="button" aria-label="Edit image" {...stylex.props(styles.iconBtn)} onClick={onEdit}>
427
+ <PencilIcon />
428
+ </button>
429
+ {required ? null : (
430
+ <button
431
+ type="button"
432
+ aria-label="Remove image"
433
+ {...stylex.props(styles.iconBtn)}
434
+ onClick={onRemove}
435
+ >
436
+ <CloseIcon />
437
+ </button>
438
+ )}
439
+ </div>
440
+ )}
441
+ </div>
442
+ );
443
+ }
444
+
445
+ function mediaMeta(item: Pick<CatalogMedia, "byteSize" | "width" | "height" | "contentType">): string {
446
+ const parts = [formatBytes(item.byteSize)];
447
+ if (item.width > 0 && item.height > 0) parts.push(`${item.width}×${item.height}`);
448
+ if (item.contentType) parts.push(item.contentType);
449
+ return parts.join(" - ");
450
+ }
451
+
452
+ function formatBytes(bytes: number): string {
453
+ if (!Number.isFinite(bytes) || bytes < 0) return "0B";
454
+ if (bytes < 1024) return `${Math.round(bytes)}B`;
455
+ if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)}KB`;
456
+ const mb = bytes / (1024 * 1024);
457
+ const rounded = mb >= 10 ? Math.round(mb) : Math.round(mb * 10) / 10;
458
+ return `${rounded}MB`;
459
+ }
460
+
461
+ function PencilIcon() {
462
+ return (
463
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden>
464
+ <path
465
+ d="M9.2 3.4 12.6 6.8 6.1 13.3H2.7V9.9L9.2 3.4Z"
466
+ stroke="currentColor"
467
+ strokeWidth="1.25"
468
+ strokeLinejoin="round"
469
+ />
470
+ <path d="M8.1 4.5 11.5 7.9" stroke="currentColor" strokeWidth="1.25" />
471
+ </svg>
472
+ );
473
+ }
474
+
475
+ function CloseIcon() {
476
+ return (
477
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden>
478
+ <path d="M4.5 4.5 11.5 11.5M11.5 4.5 4.5 11.5" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" />
479
+ </svg>
480
+ );
481
+ }
482
+
74
483
  const fade = stylex.keyframes({
75
484
  from: { opacity: 0 },
76
485
  to: { opacity: 1 },
77
486
  });
78
487
 
79
488
  const styles = stylex.create({
489
+ stack: {
490
+ display: "flex",
491
+ flexDirection: "column",
492
+ gap: space.sm,
493
+ },
80
494
  row: {
81
495
  display: "flex",
82
496
  alignItems: "center",
83
497
  gap: space.md,
498
+ flexWrap: "wrap",
499
+ width: "100%",
500
+ },
501
+ thumbRow: {
502
+ display: "flex",
503
+ flexWrap: "wrap",
504
+ gap: space.sm,
505
+ },
506
+ galleryStack: {
507
+ display: "flex",
508
+ flexDirection: "column",
509
+ gap: space.xs,
510
+ width: "fit-content",
511
+ maxWidth: "100%",
512
+ },
513
+ galleryGrid: {
514
+ display: "grid",
515
+ gridTemplateColumns: "repeat(4, 48px)",
516
+ gap: "4px",
517
+ },
518
+ galleryTile: {
519
+ width: "48px",
520
+ height: "48px",
521
+ objectFit: "cover",
522
+ borderRadius: radius.sm,
523
+ backgroundColor: colors.bgPanel,
524
+ },
525
+ galleryRemove: {
526
+ position: "absolute",
527
+ top: 2,
528
+ right: 2,
529
+ width: "14px",
530
+ height: "14px",
531
+ padding: 0,
532
+ borderWidth: 0,
533
+ borderRadius: "999px",
534
+ backgroundColor: colors.overlay,
535
+ color: colors.textInverse,
536
+ cursor: "pointer",
537
+ fontSize: type.xs,
538
+ lineHeight: 1,
539
+ },
540
+ galleryFooter: {
541
+ display: "flex",
542
+ alignItems: "center",
543
+ justifyContent: "space-between",
544
+ gap: space.sm,
545
+ },
546
+ showMore: {
547
+ padding: 0,
548
+ borderWidth: 0,
549
+ backgroundColor: "transparent",
550
+ color: colors.textMuted,
551
+ cursor: "pointer",
552
+ fontSize: type.sm,
553
+ [hover]: {
554
+ ":hover": {
555
+ color: colors.text,
556
+ },
557
+ },
558
+ },
559
+ moreEmpty: {
560
+ borderWidth: 1,
561
+ borderStyle: "solid",
562
+ borderColor: colors.border,
563
+ },
564
+ thumbWrap: {
565
+ position: "relative",
84
566
  },
85
567
  thumb: {
86
568
  width: "4.5rem",
@@ -89,6 +571,9 @@ const styles = stylex.create({
89
571
  borderRadius: radius.md,
90
572
  backgroundColor: colors.bgPanel,
91
573
  },
574
+ pending: {
575
+ opacity: 0.55,
576
+ },
92
577
  placeholder: {
93
578
  width: "4.5rem",
94
579
  height: "4.5rem",
@@ -100,12 +585,101 @@ const styles = stylex.create({
100
585
  color: colors.textMuted,
101
586
  fontSize: type.xs,
102
587
  },
588
+ fileCard: {
589
+ display: "flex",
590
+ alignItems: "center",
591
+ gap: space.md,
592
+ width: "100%",
593
+ minWidth: 0,
594
+ boxSizing: "border-box",
595
+ padding: space.sm,
596
+ paddingRight: space.md,
597
+ borderWidth: 1,
598
+ borderStyle: "solid",
599
+ borderColor: colors.border,
600
+ borderRadius: radius.md,
601
+ backgroundColor: colors.bgInput,
602
+ },
603
+ fileThumb: {
604
+ width: "40px",
605
+ height: "40px",
606
+ flexShrink: 0,
607
+ objectFit: "cover",
608
+ borderRadius: radius.sm,
609
+ backgroundColor: colors.bgPanel,
610
+ },
611
+ fileText: {
612
+ display: "flex",
613
+ minWidth: 0,
614
+ flex: 1,
615
+ flexDirection: "column",
616
+ gap: "2px",
617
+ },
618
+ fileName: {
619
+ overflow: "hidden",
620
+ textOverflow: "ellipsis",
621
+ whiteSpace: "nowrap",
622
+ color: colors.text,
623
+ fontSize: type.md,
624
+ },
625
+ fileMeta: {
626
+ overflow: "hidden",
627
+ textOverflow: "ellipsis",
628
+ whiteSpace: "nowrap",
629
+ color: colors.textMuted,
630
+ fontSize: type.xs,
631
+ },
632
+ fileActions: {
633
+ display: "flex",
634
+ flexShrink: 0,
635
+ alignItems: "center",
636
+ gap: space.xs,
637
+ },
638
+ iconBtn: {
639
+ display: "flex",
640
+ alignItems: "center",
641
+ justifyContent: "center",
642
+ width: "28px",
643
+ height: "28px",
644
+ padding: 0,
645
+ borderWidth: 0,
646
+ borderRadius: radius.sm,
647
+ backgroundColor: "transparent",
648
+ color: colors.textMuted,
649
+ cursor: "pointer",
650
+ [hover]: {
651
+ ":hover": {
652
+ backgroundColor: colors.bgHover,
653
+ color: colors.text,
654
+ },
655
+ },
656
+ },
657
+ remove: {
658
+ position: "absolute",
659
+ top: 2,
660
+ right: 2,
661
+ width: "1.25rem",
662
+ height: "1.25rem",
663
+ padding: 0,
664
+ borderWidth: 0,
665
+ borderRadius: radius.sm,
666
+ backgroundColor: colors.overlay,
667
+ color: colors.textInverse,
668
+ cursor: "pointer",
669
+ fontSize: type.sm,
670
+ lineHeight: 1,
671
+ },
103
672
  backdrop: {
104
673
  position: "fixed",
105
674
  inset: 0,
675
+ zIndex: z.overlay,
106
676
  backgroundColor: colors.overlay,
677
+ backdropFilter: "blur(8px)",
107
678
  animationName: fade,
108
679
  animationDuration: "120ms",
680
+ "@media (prefers-reduced-motion: reduce)": {
681
+ animationDuration: "0ms",
682
+ },
109
683
  },
110
684
  backdropHidden: {
111
685
  opacity: 0,
@@ -114,6 +688,7 @@ const styles = stylex.create({
114
688
  position: "fixed",
115
689
  top: "8vh",
116
690
  left: "50%",
691
+ zIndex: z.palette,
117
692
  transform: "translateX(-50%)",
118
693
  width: "min(960px, calc(100vw - 32px))",
119
694
  height: "min(720px, 80vh)",
@@ -136,4 +711,12 @@ const styles = stylex.create({
136
711
  flex: 1,
137
712
  minHeight: 0,
138
713
  },
714
+ footer: {
715
+ display: "flex",
716
+ justifyContent: "flex-end",
717
+ padding: space.lg,
718
+ borderTopWidth: 1,
719
+ borderTopStyle: "solid",
720
+ borderTopColor: colors.border,
721
+ },
139
722
  });