@rebasepro/studio 0.24.0 → 0.24.1-canary.gcb918fc

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 (48) hide show
  1. package/dist/{ApiExplorer-CCtczj3F.js → ApiExplorer-Bzt7Ye49.js} +5 -3
  2. package/dist/ApiExplorer-Bzt7Ye49.js.map +1 -0
  3. package/dist/{ApiKeysView-B66TdzhH.js → ApiKeysView-Bqf6XYR1.js} +2 -1
  4. package/dist/{ApiKeysView-B66TdzhH.js.map → ApiKeysView-Bqf6XYR1.js.map} +1 -1
  5. package/dist/{BackupsView-DmSMzAbT.js → BackupsView-BhOHB6iP.js} +6 -20
  6. package/dist/BackupsView-BhOHB6iP.js.map +1 -0
  7. package/dist/{BranchesView-DP1a87E-.js → BranchesView-BHfwGTYc.js} +5 -3
  8. package/dist/BranchesView-BHfwGTYc.js.map +1 -0
  9. package/dist/{CronJobsView-TDErHTuK.js → CronJobsView-CMNK7eWO.js} +52 -59
  10. package/dist/CronJobsView-CMNK7eWO.js.map +1 -0
  11. package/dist/{JSEditor-B59pwg4c.js → JSEditor-DIO2Mw8q.js} +115 -96
  12. package/dist/JSEditor-DIO2Mw8q.js.map +1 -0
  13. package/dist/{LogsExplorer-BKM4yfP8.js → LogsExplorer-C-bwDFIl.js} +19 -23
  14. package/dist/LogsExplorer-C-bwDFIl.js.map +1 -0
  15. package/dist/{RLSEditor-BlLLV6xs.js → RLSEditor-jFD_d06f.js} +10 -8
  16. package/dist/RLSEditor-jFD_d06f.js.map +1 -0
  17. package/dist/{SQLEditor-1An7w0j6.js → SQLEditor-D5Ci4pyW.js} +372 -545
  18. package/dist/SQLEditor-D5Ci4pyW.js.map +1 -0
  19. package/dist/{SchemaVisualizer-DCLVfA5t.js → SchemaVisualizer-D-rAqwul.js} +479 -206
  20. package/dist/SchemaVisualizer-D-rAqwul.js.map +1 -0
  21. package/dist/{StorageView-CbsHuCBf.js → StorageView-CPkDKbsX.js} +498 -241
  22. package/dist/StorageView-CPkDKbsX.js.map +1 -0
  23. package/dist/assets/dagre-layout.worker-BFnhAzuR.js.map +1 -0
  24. package/dist/components/SQLEditor/SQLEditor.d.ts +11 -0
  25. package/dist/components/SchemaVisualizer/dagre-layout.d.ts +32 -0
  26. package/dist/components/SchemaVisualizer/dagre-layout.worker.d.ts +13 -0
  27. package/dist/components/SchemaVisualizer/layout-runner.d.ts +23 -0
  28. package/dist/components/SchemaVisualizer/schema-visualizer.utils.d.ts +21 -10
  29. package/dist/components/SchemaVisualizer/useSchemaGraph.d.ts +38 -1
  30. package/dist/components/StorageView/useVisibleFileMetadata.d.ts +46 -0
  31. package/dist/dagre-layout.worker-j9NbH9Gb.js +27 -0
  32. package/dist/dagre-layout.worker-j9NbH9Gb.js.map +1 -0
  33. package/dist/index.es.js +16 -14
  34. package/dist/index.es.js.map +1 -1
  35. package/dist/sql_utils-BZD31xE3.js +478 -0
  36. package/dist/sql_utils-BZD31xE3.js.map +1 -0
  37. package/dist/utils/sql_utils.d.ts +60 -0
  38. package/package.json +9 -9
  39. package/dist/ApiExplorer-CCtczj3F.js.map +0 -1
  40. package/dist/BackupsView-DmSMzAbT.js.map +0 -1
  41. package/dist/BranchesView-DP1a87E-.js.map +0 -1
  42. package/dist/CronJobsView-TDErHTuK.js.map +0 -1
  43. package/dist/JSEditor-B59pwg4c.js.map +0 -1
  44. package/dist/LogsExplorer-BKM4yfP8.js.map +0 -1
  45. package/dist/RLSEditor-BlLLV6xs.js.map +0 -1
  46. package/dist/SQLEditor-1An7w0j6.js.map +0 -1
  47. package/dist/SchemaVisualizer-DCLVfA5t.js.map +0 -1
  48. package/dist/StorageView-CbsHuCBf.js.map +0 -1
@@ -1,11 +1,163 @@
1
1
  import { t as classifyLoadFailure } from "./load-failure-Bd8lFIO7.js";
2
2
  import { ErrorView, useApiBase, useApiConfig, useSnackbarController, useStorageSource, useStorageSources, useTranslation } from "@rebasepro/app";
3
- import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
- import { ArrowLeftIcon, Button, CheckIcon, Checkbox, Chip, CircularProgress, CopyIcon, Dialog, DialogActions, DialogContent, DialogTitle, DownloadIcon, FileTextIcon, FileUpload, FolderIcon, FolderPlusIcon, IconButton, ImageIcon, LayoutGridIcon, ListIcon, LoadingButton, Music2Icon, PlusIcon, RefreshCwIcon, Select, SelectItem, TextField, Tooltip, Trash2Icon, Typography, UploadCloudIcon, VideoIcon, XIcon, cls, defaultBorderMixin, iconSize, isKeyHandled } from "@rebasepro/ui";
3
+ import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
4
+ import { ArrowLeftIcon, Button, CheckIcon, Checkbox, Chip, CircularProgress, CopyIcon, Dialog, DialogActions, DialogContent, DialogTitle, DownloadIcon, FileTextIcon, FileUpload, FolderIcon, FolderPlusIcon, IconButton, ImageIcon, LayoutGridIcon, ListIcon, LoadingButton, Music2Icon, PlusIcon, RefreshCwIcon, Select, SelectItem, Table, TableBody, TableCell, TableHeader, TableRow, TextField, Tooltip, Trash2Icon, Typography, UploadCloudIcon, VideoIcon, XIcon, cls, defaultBorderMixin, iconSize, isKeyHandled } from "@rebasepro/ui";
5
5
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
6
6
  import { useSearchParams } from "react-router";
7
7
  import { DEFAULT_STORAGE_SOURCE_KEY, isPublicStorageKey } from "@rebasepro/types";
8
8
  import { useDropzone } from "react-dropzone";
9
+ //#region src/components/StorageView/useVisibleFileMetadata.ts
10
+ function fileMetadataOf(config) {
11
+ return {
12
+ size: config.metadata?.size,
13
+ contentType: config.metadata?.contentType,
14
+ downloadUrl: config.url ?? void 0
15
+ };
16
+ }
17
+ var NO_METADATA = /* @__PURE__ */ new Map();
18
+ /**
19
+ * The metadata asked for one listing: each path once, {@link METADATA_CONCURRENCY}
20
+ * at a time, through the source that listed it. Cancelled when the listing is
21
+ * replaced, so an answer for a folder or source the view has left is dropped.
22
+ */
23
+ var MetadataRun = class {
24
+ source;
25
+ files;
26
+ publish;
27
+ wants;
28
+ known;
29
+ requested = /* @__PURE__ */ new Set();
30
+ queue = [];
31
+ inFlight = 0;
32
+ cancelled = false;
33
+ answered = [];
34
+ flushQueued = false;
35
+ constructor(source, files, publish, wants, known) {
36
+ this.source = source;
37
+ this.files = files;
38
+ this.publish = publish;
39
+ this.wants = wants;
40
+ this.known = known;
41
+ }
42
+ want(path) {
43
+ if (this.cancelled || !this.source || !path || this.requested.has(path) || this.known.has(path)) return;
44
+ const file = this.files.get(path);
45
+ if (!file || !this.wants(file)) return;
46
+ this.requested.add(path);
47
+ this.queue.push(path);
48
+ this.pump();
49
+ }
50
+ cancel() {
51
+ this.cancelled = true;
52
+ this.queue = [];
53
+ this.answered = [];
54
+ }
55
+ pump() {
56
+ const source = this.source;
57
+ if (!source) return;
58
+ while (!this.cancelled && this.inFlight < 6 && this.queue.length > 0) {
59
+ const path = this.queue.shift();
60
+ if (path === void 0) break;
61
+ this.inFlight++;
62
+ source.getSignedUrl(path).then((config) => this.deliver(path, fileMetadataOf(config))).catch(() => this.deliver(path, {})).finally(() => {
63
+ this.inFlight--;
64
+ this.pump();
65
+ });
66
+ }
67
+ }
68
+ /** Answers that arrive together are published as one update. */
69
+ deliver(path, metadata) {
70
+ if (this.cancelled) return;
71
+ this.answered.push([path, metadata]);
72
+ if (this.flushQueued) return;
73
+ this.flushQueued = true;
74
+ queueMicrotask(() => {
75
+ this.flushQueued = false;
76
+ if (this.cancelled || this.answered.length === 0) return;
77
+ const answered = this.answered;
78
+ this.answered = [];
79
+ this.publish(answered);
80
+ });
81
+ }
82
+ };
83
+ /**
84
+ * Metadata for the files of a listing, asked for as each comes on screen —
85
+ * and only for those `wants` says need it: the listing already describes most
86
+ * files (see `StorageReference.size`), so what is left is a file a source
87
+ * listed bare, and the link an image's thumbnail is drawn from.
88
+ *
89
+ * The browser used to ask for every file's metadata before it showed any of
90
+ * them: a folder of 1,000 files was 1,001 requests and seconds of a spinner.
91
+ * The listing is shown as it comes back; a file's metadata is asked for when
92
+ * its element — given {@link observe} as its `ref`, and its path as
93
+ * `data-storage-path` — scrolls into view, a few requests at a time.
94
+ *
95
+ * Kept per listing: keyed by path, and dropped when `files` or `source`
96
+ * changes — another folder, another source, or the same folder read again
97
+ * after an upload or a delete, whose files may have changed. Without an
98
+ * `IntersectionObserver` every file of the listing is asked for, still a few
99
+ * at a time and still after the listing is shown.
100
+ */
101
+ function useVisibleFileMetadata(source, files, wants = () => true) {
102
+ const [store, setStore] = useState(() => ({
103
+ files,
104
+ entries: NO_METADATA
105
+ }));
106
+ const storeRef = useRef(store);
107
+ useLayoutEffect(() => {
108
+ storeRef.current = store;
109
+ });
110
+ const runRef = useRef(null);
111
+ const elementsRef = useRef(/* @__PURE__ */ new Set());
112
+ const observerRef = useRef(null);
113
+ const observer = useCallback(() => {
114
+ if (observerRef.current) return observerRef.current;
115
+ if (typeof IntersectionObserver !== "function") return null;
116
+ observerRef.current = new IntersectionObserver((entries) => {
117
+ for (const entry of entries) if (entry.isIntersecting && entry.target instanceof HTMLElement) runRef.current?.want(entry.target.dataset.storagePath);
118
+ }, { rootMargin: "200px" });
119
+ return observerRef.current;
120
+ }, []);
121
+ useLayoutEffect(() => {
122
+ const known = storeRef.current.files === files ? storeRef.current.entries : NO_METADATA;
123
+ const run = new MetadataRun(source, new Map(files.map((file) => [file.fullPath, file])), (answered) => setStore((previous) => ({
124
+ files,
125
+ entries: new Map([...previous.files === files ? previous.entries : NO_METADATA, ...answered])
126
+ })), wants, known);
127
+ runRef.current = run;
128
+ const io = observer();
129
+ for (const element of elementsRef.current) if (io) {
130
+ io.unobserve(element);
131
+ io.observe(element);
132
+ } else run.want(element.dataset.storagePath);
133
+ return () => run.cancel();
134
+ }, [
135
+ source,
136
+ files,
137
+ wants,
138
+ observer
139
+ ]);
140
+ useEffect(() => () => {
141
+ observerRef.current?.disconnect();
142
+ observerRef.current = null;
143
+ }, []);
144
+ const observe = useCallback((element) => {
145
+ if (!element) return void 0;
146
+ elementsRef.current.add(element);
147
+ const io = observer();
148
+ if (io) io.observe(element);
149
+ else runRef.current?.want(element.dataset.storagePath);
150
+ return () => {
151
+ elementsRef.current.delete(element);
152
+ io?.unobserve(element);
153
+ };
154
+ }, [observer]);
155
+ return {
156
+ metadata: store.files === files ? store.entries : NO_METADATA,
157
+ observe
158
+ };
159
+ }
160
+ //#endregion
9
161
  //#region src/components/StorageView/StorageView.tsx
10
162
  function fileLinkOf(config, fullPath) {
11
163
  if (!config.url) return null;
@@ -13,7 +165,17 @@ function fileLinkOf(config, fullPath) {
13
165
  return {
14
166
  url: config.url,
15
167
  expiresInSeconds: metadata?.token && typeof metadata.tokenExpiresIn === "number" ? metadata.tokenExpiresIn : void 0,
16
- isPublic: metadata ? metadata.public === true : isPublicStorageKey(fullPath)
168
+ isPublic: metadata ? metadata.public === true : isPublicStorageKey(fullPath),
169
+ size: metadata?.size,
170
+ contentType: metadata?.contentType
171
+ };
172
+ }
173
+ /** `file`, with what the link minted for it says where the listing did not. */
174
+ function withLinkFacts(file, link) {
175
+ return {
176
+ ...file,
177
+ size: file.size ?? link?.size,
178
+ contentType: file.contentType ?? link?.contentType
17
179
  };
18
180
  }
19
181
  /** A URL the browser can open from anywhere: relative ones are this page's origin's. */
@@ -34,17 +196,45 @@ function formatFileSize(bytes) {
34
196
  if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
35
197
  return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
36
198
  }
37
- function getFileIcon(contentType) {
38
- if (!contentType) return FileTextIcon;
39
- if (contentType.startsWith("image/")) return ImageIcon;
40
- if (contentType.startsWith("video/")) return VideoIcon;
41
- if (contentType.startsWith("audio/")) return Music2Icon;
199
+ var IMAGE_EXTENSIONS = [
200
+ "jpg",
201
+ "jpeg",
202
+ "png",
203
+ "gif",
204
+ "webp",
205
+ "svg"
206
+ ];
207
+ var VIDEO_EXTENSIONS = [
208
+ "mp4",
209
+ "webm",
210
+ "ogg",
211
+ "mov"
212
+ ];
213
+ var AUDIO_EXTENSIONS = [
214
+ "mp3",
215
+ "wav",
216
+ "ogg",
217
+ "m4a"
218
+ ];
219
+ /**
220
+ * The icon for a file: by its stored type, or — where the listing carried
221
+ * none, as an S3 listing does not — by its extension.
222
+ */
223
+ function getFileIcon(contentType, name) {
224
+ const ext = getExtension(name).toLowerCase();
225
+ if (contentType ? contentType.startsWith("image/") : IMAGE_EXTENSIONS.includes(ext)) return ImageIcon;
226
+ if (contentType ? contentType.startsWith("video/") : VIDEO_EXTENSIONS.includes(ext)) return VideoIcon;
227
+ if (contentType ? contentType.startsWith("audio/") : AUDIO_EXTENSIONS.includes(ext)) return Music2Icon;
42
228
  return FileTextIcon;
43
229
  }
44
230
  function getExtension(name) {
45
231
  const parts = name.split(".");
46
232
  return parts.length > 1 ? parts[parts.length - 1].toUpperCase() : "";
47
233
  }
234
+ /** Whether the grid draws `file` as a thumbnail — which needs a link to it. */
235
+ function isImageFile(file) {
236
+ return file.contentType?.startsWith("image/") === true || IMAGE_EXTENSIONS.includes(getExtension(file.name).toLowerCase());
237
+ }
48
238
  function breadcrumbSegments(path, rootLabel) {
49
239
  if (!path || path === "/") return [{
50
240
  label: rootLabel,
@@ -87,14 +277,39 @@ async function listAllPages(source, prefix) {
87
277
  prefixes
88
278
  };
89
279
  }
280
+ /** How many deletes run at once: one at a time, a folder of 1,000 files took a minute. */
281
+ var DELETE_CONCURRENCY = 8;
282
+ /**
283
+ * `work` for each of `items`, at most `limit` at once. The first failure stops
284
+ * what has not started; what has started is let finish, then it is thrown.
285
+ */
286
+ async function forEachBounded(items, limit, work) {
287
+ const run = { next: 0 };
288
+ const lane = async () => {
289
+ while (!run.failure && run.next < items.length) {
290
+ const item = items[run.next++];
291
+ try {
292
+ await work(item);
293
+ } catch (error) {
294
+ run.failure ??= { error };
295
+ }
296
+ }
297
+ };
298
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, lane));
299
+ if (run.failure) throw run.failure.error;
300
+ }
90
301
  /**
91
302
  * Delete a folder: every file in it, every folder below it, and the folder
92
303
  * itself — the directory on local disk, or the zero-byte `name/` marker that
93
304
  * "New folder" writes on S3 and GCS, which no listing shows as an entry.
305
+ *
306
+ * The files of a level go {@link DELETE_CONCURRENCY} at a time; the folders
307
+ * below it one after another, so no more than that are ever in flight, and a
308
+ * folder's own marker goes last, once everything in it has.
94
309
  */
95
310
  async function deleteFolderRecursive(source, prefix) {
96
311
  const { items, prefixes } = await listAllPages(source, prefix);
97
- for (const item of items) await source.deleteObject(item.fullPath);
312
+ await forEachBounded(items, DELETE_CONCURRENCY, (item) => source.deleteObject(item.fullPath));
98
313
  for (const sub of prefixes) await deleteFolderRecursive(source, sub.fullPath);
99
314
  try {
100
315
  await source.deleteObject(prefix.endsWith("/") ? prefix : `${prefix}/`);
@@ -116,9 +331,10 @@ function UploadDialog({ open, currentPath, onClose, onUpload }) {
116
331
  setUploading(true);
117
332
  setError(null);
118
333
  try {
119
- await onUpload(selectedFiles);
120
- setSelectedFiles([]);
121
- onClose();
334
+ if (await onUpload(selectedFiles)) {
335
+ setSelectedFiles([]);
336
+ onClose();
337
+ }
122
338
  } catch (err) {
123
339
  setError(err instanceof Error ? err.message : t("studio_storage_upload_failed"));
124
340
  } finally {
@@ -203,6 +419,7 @@ function UploadDialog({ open, currentPath, onClose, onUpload }) {
203
419
  children: formatFileSize(file.size)
204
420
  })]
205
421
  }), /* @__PURE__ */ jsx(IconButton, {
422
+ "aria-label": t("remove"),
206
423
  size: "small",
207
424
  onClick: (e) => {
208
425
  e.stopPropagation();
@@ -237,7 +454,7 @@ function FilePreviewPanel({ file, onClose, onDelete, link, mintLink }) {
237
454
  file.contentType?.startsWith("image/");
238
455
  file.contentType?.startsWith("video/");
239
456
  file.contentType?.startsWith("audio/");
240
- const FileIconComponent = getFileIcon(file.contentType);
457
+ const FileIconComponent = getFileIcon(file.contentType, file.name);
241
458
  const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
242
459
  const [urlCopied, setUrlCopied] = useState(false);
243
460
  const reportLinkError = (e) => {
@@ -291,6 +508,7 @@ function FilePreviewPanel({ file, onClose, onDelete, link, mintLink }) {
291
508
  })
292
509
  }),
293
510
  /* @__PURE__ */ jsx(IconButton, {
511
+ "aria-label": t("close"),
294
512
  size: "small",
295
513
  onClick: onClose,
296
514
  children: /* @__PURE__ */ jsx(XIcon, { size: iconSize.smallest })
@@ -304,26 +522,9 @@ function FilePreviewPanel({ file, onClose, onDelete, link, mintLink }) {
304
522
  className: cls("flex flex-col items-center justify-center min-h-[200px] p-4 bg-surface-sheet border-b", defaultBorderMixin),
305
523
  children: (() => {
306
524
  const ext = getExtension(file.name)?.toLowerCase() || "";
307
- const isImage = file.contentType?.startsWith("image/") || [
308
- "jpg",
309
- "jpeg",
310
- "png",
311
- "gif",
312
- "webp",
313
- "svg"
314
- ].includes(ext);
315
- const isVideo = file.contentType?.startsWith("video/") || [
316
- "mp4",
317
- "webm",
318
- "ogg",
319
- "mov"
320
- ].includes(ext);
321
- const isAudio = file.contentType?.startsWith("audio/") || [
322
- "mp3",
323
- "wav",
324
- "ogg",
325
- "m4a"
326
- ].includes(ext);
525
+ const isImage = file.contentType?.startsWith("image/") || IMAGE_EXTENSIONS.includes(ext);
526
+ const isVideo = file.contentType?.startsWith("video/") || VIDEO_EXTENSIONS.includes(ext);
527
+ const isAudio = file.contentType?.startsWith("audio/") || AUDIO_EXTENSIONS.includes(ext);
327
528
  const previewUrl = link?.url;
328
529
  if (isImage && previewUrl) return /* @__PURE__ */ jsx("img", {
329
530
  src: previewUrl,
@@ -358,8 +559,9 @@ function FilePreviewPanel({ file, onClose, onDelete, link, mintLink }) {
358
559
  className: "p-4 space-y-3",
359
560
  children: [
360
561
  /* @__PURE__ */ jsx("div", { children: /* @__PURE__ */ jsx(Typography, {
361
- variant: "caption",
362
- className: "text-text-disabled dark:text-text-disabled-dark text-[10px] uppercase tracking-wider font-semibold mb-1 block",
562
+ variant: "label",
563
+ color: "secondary",
564
+ className: "mb-1 block",
363
565
  children: t("studio_storage_file_info")
364
566
  }) }),
365
567
  /* @__PURE__ */ jsxs("div", {
@@ -511,12 +713,26 @@ var StorageView = () => {
511
713
  const [failure, setFailure] = useState(null);
512
714
  const [folders, setFolders] = useState([]);
513
715
  const [files, setFiles] = useState([]);
716
+ /** The source `files` were listed from: their metadata is read through it, whatever is selected since. */
717
+ const [listedFrom, setListedFrom] = useState(null);
718
+ const [viewMode, setViewMode] = useState("grid");
719
+ const { metadata, observe: observeFile } = useVisibleFileMetadata(listedFrom, files, useCallback((file) => file.size === void 0 || viewMode === "grid" && isImageFile(file), [viewMode]));
720
+ /** `file` with what its metadata has said so far, over what the listing said. */
721
+ const withMetadata = useCallback((file) => {
722
+ const read = metadata.get(file.fullPath);
723
+ if (!read) return file;
724
+ return {
725
+ ...file,
726
+ size: read.size ?? file.size,
727
+ contentType: read.contentType ?? file.contentType,
728
+ downloadUrl: read.downloadUrl ?? file.downloadUrl
729
+ };
730
+ }, [metadata]);
514
731
  const [selectedFile, setSelectedFile] = useState(null);
515
732
  const [selectedLink, setSelectedLink] = useState(null);
516
733
  /** The latest link asked for the preview: an earlier one answering later is dropped. */
517
734
  const linkRequestRef = useRef(0);
518
735
  const [uploadDialogOpen, setUploadDialogOpen] = useState(false);
519
- const [viewMode, setViewMode] = useState("grid");
520
736
  const [selectedPaths, setSelectedPaths] = useState(/* @__PURE__ */ new Set());
521
737
  const lastClickedRef = useRef(null);
522
738
  const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
@@ -532,10 +748,11 @@ var StorageView = () => {
532
748
  storageSourceRef.current = storageSource;
533
749
  }, [storageSource]);
534
750
  /**
535
- * The latest listing asked for. Only its answer is shown: a listing awaits
536
- * every file's metadata, so an earlier one — of the source or folder the
537
- * view has since left — can answer after a later one, and showed that
538
- * source's files under the one selected, where their actions ran.
751
+ * The latest listing asked for. Only its answer is shown: a listing of a
752
+ * large folder takes several pages, so an earlier one — of the source or
753
+ * folder the view has since left — can answer after a later one, and
754
+ * showed that source's files under the one selected, where their actions
755
+ * ran.
539
756
  */
540
757
  const listingRequestRef = useRef(0);
541
758
  const fetchContents = useCallback(async (path) => {
@@ -551,28 +768,17 @@ var StorageView = () => {
551
768
  fullPath: ref.fullPath,
552
769
  isFolder: true
553
770
  }));
554
- const fileItems = await Promise.all(result.items.map(async (ref) => {
555
- try {
556
- const downloadConfig = await source.getSignedUrl(ref.fullPath);
557
- return {
558
- name: ref.name,
559
- fullPath: ref.fullPath,
560
- isFolder: false,
561
- size: downloadConfig.metadata?.size,
562
- contentType: downloadConfig.metadata?.contentType,
563
- downloadUrl: downloadConfig.url ?? void 0
564
- };
565
- } catch {
566
- return {
567
- name: ref.name,
568
- fullPath: ref.fullPath,
569
- isFolder: false
570
- };
571
- }
771
+ const fileItems = result.items.map((ref) => ({
772
+ name: ref.name,
773
+ fullPath: ref.fullPath,
774
+ isFolder: false,
775
+ size: ref.size,
776
+ contentType: ref.contentType
572
777
  }));
573
778
  if (!isCurrent()) return;
574
779
  setFolders(folderItems);
575
780
  setFiles(fileItems);
781
+ setListedFrom(source);
576
782
  } catch (e) {
577
783
  if (!isCurrent()) return;
578
784
  console.error("Storage list error:", e);
@@ -670,23 +876,52 @@ var StorageView = () => {
670
876
  if (item.isFolder) handleNavigate(item.fullPath);
671
877
  else openPreview(item);
672
878
  }, [handleNavigate, openPreview]);
879
+ /**
880
+ * Files an upload would write over, waiting for the person to say whether
881
+ * it should. `answer` settles the upload that asked.
882
+ */
883
+ const [replacePrompt, setReplacePrompt] = useState(null);
884
+ /**
885
+ * Whether `uploadFiles` may go ahead: at once when none of them has the
886
+ * name of a file in the folder, and otherwise once the person says to
887
+ * replace those. An upload is a put, and a put over an existing key
888
+ * replaces the file without a trace.
889
+ */
890
+ const mayReplace = useCallback((uploadFiles) => {
891
+ const listed = new Set(files.map((file) => file.name));
892
+ const names = [...new Set(uploadFiles.map((file) => file.name))].filter((name) => listed.has(name));
893
+ if (names.length === 0) return Promise.resolve(true);
894
+ return new Promise((resolve) => setReplacePrompt({
895
+ names,
896
+ answer: (replace) => {
897
+ setReplacePrompt(null);
898
+ resolve(replace);
899
+ }
900
+ }));
901
+ }, [files]);
673
902
  const handleUpload = useCallback(async (uploadFiles) => {
674
- for (const file of uploadFiles) {
675
- const key = currentPath ? `${currentPath}/${file.name}` : file.name;
676
- await storageSourceRef.current.putObject({
677
- file,
678
- key
679
- });
903
+ if (!await mayReplace(uploadFiles)) return false;
904
+ try {
905
+ for (const file of uploadFiles) {
906
+ const key = currentPath ? `${currentPath}/${file.name}` : file.name;
907
+ await storageSourceRef.current.putObject({
908
+ file,
909
+ key
910
+ });
911
+ }
912
+ } finally {
913
+ await fetchContents(currentPath);
680
914
  }
681
915
  snackbarController.open({
682
916
  type: "success",
683
917
  message: t("studio_storage_files_uploaded", { count: uploadFiles.length })
684
918
  });
685
- await fetchContents(currentPath);
919
+ return true;
686
920
  }, [
687
921
  currentPath,
688
922
  snackbarController,
689
923
  fetchContents,
924
+ mayReplace,
690
925
  t
691
926
  ]);
692
927
  const handleCreateFolder = useCallback(async () => {
@@ -755,30 +990,14 @@ var StorageView = () => {
755
990
  onDrop: useCallback(async (droppedFiles) => {
756
991
  if (droppedFiles.length === 0) return;
757
992
  try {
758
- for (const file of droppedFiles) {
759
- const key = currentPath ? `${currentPath}/${file.name}` : file.name;
760
- await storageSourceRef.current.putObject({
761
- file,
762
- key
763
- });
764
- }
765
- snackbarController.open({
766
- type: "success",
767
- message: t("studio_storage_files_uploaded", { count: droppedFiles.length })
768
- });
769
- await fetchContents(currentPath);
993
+ await handleUpload(droppedFiles);
770
994
  } catch (e) {
771
995
  snackbarController.open({
772
996
  type: "error",
773
997
  message: e instanceof Error ? e.message : String(e)
774
998
  });
775
999
  }
776
- }, [
777
- currentPath,
778
- snackbarController,
779
- fetchContents,
780
- t
781
- ]),
1000
+ }, [handleUpload, snackbarController]),
782
1001
  noClick: true,
783
1002
  noKeyboard: true,
784
1003
  noDragEventsBubbling: true
@@ -795,8 +1014,8 @@ var StorageView = () => {
795
1014
  */
796
1015
  const deleteItems = useCallback(async (items, folder) => {
797
1016
  const source = storageSourceRef.current;
1017
+ await forEachBounded(items.filter((item) => !item.isFolder), DELETE_CONCURRENCY, (item) => source.deleteObject(item.fullPath));
798
1018
  for (const item of items) if (item.isFolder) await deleteFolderRecursive(source, item.fullPath);
799
- else await source.deleteObject(item.fullPath);
800
1019
  const listed = await listAllPages(source, folder);
801
1020
  const stillListed = new Set([...listed.items, ...listed.prefixes].map((ref) => ref.fullPath));
802
1021
  return items.filter((item) => stillListed.has(item.fullPath));
@@ -826,12 +1045,13 @@ var StorageView = () => {
826
1045
  next.delete(file.fullPath);
827
1046
  return next;
828
1047
  });
829
- fetchContents(currentPath);
830
1048
  } catch (e) {
831
1049
  snackbarController.open({
832
1050
  type: "error",
833
1051
  message: e instanceof Error ? e.message : String(e)
834
1052
  });
1053
+ } finally {
1054
+ fetchContents(currentPath);
835
1055
  }
836
1056
  }, [
837
1057
  currentPath,
@@ -854,13 +1074,13 @@ var StorageView = () => {
854
1074
  setSelectedPaths(/* @__PURE__ */ new Set());
855
1075
  setSelectedFile(null);
856
1076
  setSelectedLink(null);
857
- await fetchContents(currentPath);
858
1077
  } catch (e) {
859
1078
  snackbarController.open({
860
1079
  type: "error",
861
1080
  message: e instanceof Error ? e.message : String(e)
862
1081
  });
863
1082
  } finally {
1083
+ await fetchContents(currentPath);
864
1084
  setDeleting(false);
865
1085
  setDeleteDialogOpen(false);
866
1086
  setDeleteDialogTarget(null);
@@ -894,13 +1114,13 @@ var StorageView = () => {
894
1114
  next.delete(deleteDialogTarget.fullPath);
895
1115
  return next;
896
1116
  });
897
- await fetchContents(currentPath);
898
1117
  } catch (e) {
899
1118
  snackbarController.open({
900
1119
  type: "error",
901
1120
  message: e instanceof Error ? e.message : String(e)
902
1121
  });
903
1122
  } finally {
1123
+ await fetchContents(currentPath);
904
1124
  setDeleting(false);
905
1125
  setDeleteDialogOpen(false);
906
1126
  setDeleteDialogTarget(null);
@@ -921,7 +1141,7 @@ var StorageView = () => {
921
1141
  }, [allItems, selectedPaths]);
922
1142
  useEffect(() => {
923
1143
  const handler = (e) => {
924
- if (deleteDialogOpen || uploadDialogOpen || newFolderDialogOpen) return;
1144
+ if (deleteDialogOpen || uploadDialogOpen || newFolderDialogOpen || replacePrompt) return;
925
1145
  if (isKeyHandled(e)) return;
926
1146
  if ((e.metaKey || e.ctrlKey) && e.key === "a") {
927
1147
  e.preventDefault();
@@ -946,7 +1166,8 @@ var StorageView = () => {
946
1166
  selectedPaths,
947
1167
  deleteDialogOpen,
948
1168
  uploadDialogOpen,
949
- newFolderDialogOpen
1169
+ newFolderDialogOpen,
1170
+ replacePrompt
950
1171
  ]);
951
1172
  const handleRefresh = useCallback(() => {
952
1173
  fetchContents(currentPath);
@@ -1035,12 +1256,12 @@ var StorageView = () => {
1035
1256
  });
1036
1257
  if (viewMode === "list") return /* @__PURE__ */ jsx("div", {
1037
1258
  className: "flex-grow overflow-auto",
1038
- children: /* @__PURE__ */ jsxs("table", {
1259
+ children: /* @__PURE__ */ jsxs(Table, {
1039
1260
  className: "w-full",
1040
- children: [/* @__PURE__ */ jsx("thead", { children: /* @__PURE__ */ jsxs("tr", {
1041
- className: cls("border-b text-left text-[10px] uppercase tracking-wider text-text-disabled dark:text-text-disabled-dark", defaultBorderMixin),
1261
+ children: [/* @__PURE__ */ jsxs(TableHeader, {
1262
+ className: "text-xs font-normal text-text-secondary dark:text-text-secondary-dark",
1042
1263
  children: [
1043
- /* @__PURE__ */ jsx("th", {
1264
+ /* @__PURE__ */ jsx(TableCell, {
1044
1265
  className: "pl-3 pr-0 py-2 w-8",
1045
1266
  children: /* @__PURE__ */ jsx(Checkbox, {
1046
1267
  size: "small",
@@ -1049,148 +1270,155 @@ var StorageView = () => {
1049
1270
  onCheckedChange: handleSelectAll
1050
1271
  })
1051
1272
  }),
1052
- /* @__PURE__ */ jsx("th", {
1053
- className: "px-2 py-2 font-semibold",
1273
+ /* @__PURE__ */ jsx(TableCell, {
1274
+ className: "px-2 py-2 font-medium",
1054
1275
  children: t("studio_storage_name")
1055
1276
  }),
1056
- /* @__PURE__ */ jsx("th", {
1057
- className: "px-4 py-2 font-semibold w-24",
1277
+ /* @__PURE__ */ jsx(TableCell, {
1278
+ className: "px-4 py-2 font-medium w-24",
1058
1279
  children: t("studio_storage_type")
1059
1280
  }),
1060
- /* @__PURE__ */ jsx("th", {
1061
- className: "px-4 py-2 font-semibold w-24 text-right",
1281
+ /* @__PURE__ */ jsx(TableCell, {
1282
+ className: "px-4 py-2 font-medium w-24 text-right",
1062
1283
  children: t("studio_storage_size")
1063
1284
  }),
1064
- /* @__PURE__ */ jsx("th", { className: "px-2 py-2 w-10" })
1285
+ /* @__PURE__ */ jsx(TableCell, { className: "px-2 py-2 w-10" })
1065
1286
  ]
1066
- }) }), /* @__PURE__ */ jsxs("tbody", { children: [folders.map((folder) => {
1067
- const isChecked = selectedPaths.has(folder.fullPath);
1068
- return /* @__PURE__ */ jsxs("tr", {
1069
- "data-storage-item": true,
1070
- className: cls("cursor-pointer transition-colors border-b group", defaultBorderMixin, isChecked ? "bg-primary/5 dark:bg-primary/10" : "hover:bg-surface-hover"),
1071
- onClick: (e) => handleItemClick(folder, e),
1072
- onDoubleClick: () => handleItemDoubleClick(folder),
1073
- children: [
1074
- /* @__PURE__ */ jsx("td", {
1075
- className: "pl-3 pr-0 py-2.5",
1076
- onClick: (e) => e.stopPropagation(),
1077
- children: /* @__PURE__ */ jsx(Checkbox, {
1078
- size: "small",
1079
- checked: isChecked,
1080
- onCheckedChange: () => {
1081
- setSelectedPaths((prev) => {
1082
- const next = new Set(prev);
1083
- if (next.has(folder.fullPath)) next.delete(folder.fullPath);
1084
- else next.add(folder.fullPath);
1085
- return next;
1086
- });
1087
- }
1088
- })
1089
- }),
1090
- /* @__PURE__ */ jsx("td", {
1091
- className: "px-2 py-2.5",
1092
- children: /* @__PURE__ */ jsxs("div", {
1093
- className: "flex items-center gap-2",
1094
- children: [/* @__PURE__ */ jsx(FolderIcon, {
1095
- size: iconSize.smallest,
1096
- className: "text-amber-500 dark:text-amber-400 shrink-0"
1097
- }), /* @__PURE__ */ jsx(Typography, {
1098
- variant: "body2",
1099
- className: "text-[13px] font-medium truncate",
1100
- children: folder.name
1101
- })]
1102
- })
1103
- }),
1104
- /* @__PURE__ */ jsx("td", {
1105
- className: "px-4 py-2.5",
1106
- children: /* @__PURE__ */ jsx(Typography, {
1107
- variant: "caption",
1108
- className: "text-text-secondary dark:text-text-secondary-dark",
1109
- children: t("studio_storage_folder")
1110
- })
1111
- }),
1112
- /* @__PURE__ */ jsx("td", {
1113
- className: "px-4 py-2.5 text-right",
1114
- children: /* @__PURE__ */ jsx(Typography, {
1115
- variant: "caption",
1116
- className: "text-text-disabled dark:text-text-disabled-dark",
1117
- children: "—"
1118
- })
1119
- }),
1120
- /* @__PURE__ */ jsx("td", { className: "px-2 py-2.5" })
1121
- ]
1122
- }, folder.fullPath);
1123
- }), files.map((file) => {
1124
- const FileIconComp = getFileIcon(file.contentType);
1125
- const isChecked = selectedPaths.has(file.fullPath);
1126
- return /* @__PURE__ */ jsxs("tr", {
1127
- "data-storage-item": true,
1128
- className: cls("cursor-pointer transition-colors border-b group", defaultBorderMixin, isChecked ? "bg-primary/5 dark:bg-primary/10" : "hover:bg-surface-hover"),
1129
- onClick: (e) => handleItemClick(file, e),
1130
- onDoubleClick: () => handleItemDoubleClick(file),
1131
- children: [
1132
- /* @__PURE__ */ jsx("td", {
1133
- className: "pl-3 pr-0 py-2.5",
1134
- onClick: (e) => e.stopPropagation(),
1135
- children: /* @__PURE__ */ jsx(Checkbox, {
1136
- size: "small",
1137
- checked: isChecked,
1138
- onCheckedChange: () => {
1139
- setSelectedPaths((prev) => {
1140
- const next = new Set(prev);
1141
- if (next.has(file.fullPath)) next.delete(file.fullPath);
1142
- else next.add(file.fullPath);
1143
- return next;
1144
- });
1145
- }
1146
- })
1147
- }),
1148
- /* @__PURE__ */ jsx("td", {
1149
- className: "px-2 py-2.5",
1150
- children: /* @__PURE__ */ jsxs("div", {
1151
- className: "flex items-center gap-2",
1152
- children: [/* @__PURE__ */ jsx(FileIconComp, {
1153
- size: iconSize.smallest,
1154
- className: "text-surface-accent-400 shrink-0"
1155
- }), /* @__PURE__ */ jsx(Typography, {
1156
- variant: "body2",
1157
- className: "text-[13px] truncate",
1158
- children: file.name
1159
- })]
1160
- })
1161
- }),
1162
- /* @__PURE__ */ jsx("td", {
1163
- className: "px-4 py-2.5",
1164
- children: /* @__PURE__ */ jsx(Typography, {
1165
- variant: "caption",
1166
- className: "text-text-secondary dark:text-text-secondary-dark",
1167
- children: getExtension(file.name) || file.contentType?.split("/")[1]?.toUpperCase() || "—"
1168
- })
1169
- }),
1170
- /* @__PURE__ */ jsx("td", {
1171
- className: "px-4 py-2.5 text-right",
1172
- children: /* @__PURE__ */ jsx(Typography, {
1173
- variant: "caption",
1174
- className: "text-text-secondary dark:text-text-secondary-dark font-mono text-[11px]",
1175
- children: file.size !== void 0 ? formatFileSize(file.size) : "—"
1176
- })
1177
- }),
1178
- /* @__PURE__ */ jsx("td", {
1179
- className: "px-2 py-2.5",
1180
- onClick: (e) => e.stopPropagation(),
1181
- children: /* @__PURE__ */ jsx(IconButton, {
1182
- size: "smallest",
1183
- className: "opacity-0 group-hover:opacity-100 transition-opacity",
1184
- onClick: () => {
1185
- setDeleteDialogTarget(file);
1186
- setDeleteDialogOpen(true);
1187
- },
1188
- children: /* @__PURE__ */ jsx(Trash2Icon, { size: 14 })
1287
+ }), /* @__PURE__ */ jsxs(TableBody, {
1288
+ variant: "list",
1289
+ children: [folders.map((folder) => {
1290
+ const isChecked = selectedPaths.has(folder.fullPath);
1291
+ return /* @__PURE__ */ jsxs(TableRow, {
1292
+ "data-storage-item": true,
1293
+ className: cls("group", isChecked && "bg-primary/5 dark:bg-primary/10"),
1294
+ onClick: (e) => handleItemClick(folder, e),
1295
+ onDoubleClick: () => handleItemDoubleClick(folder),
1296
+ children: [
1297
+ /* @__PURE__ */ jsx(TableCell, {
1298
+ className: "pl-3 pr-0 py-2.5",
1299
+ onClick: (e) => e.stopPropagation(),
1300
+ children: /* @__PURE__ */ jsx(Checkbox, {
1301
+ size: "small",
1302
+ checked: isChecked,
1303
+ onCheckedChange: () => {
1304
+ setSelectedPaths((prev) => {
1305
+ const next = new Set(prev);
1306
+ if (next.has(folder.fullPath)) next.delete(folder.fullPath);
1307
+ else next.add(folder.fullPath);
1308
+ return next;
1309
+ });
1310
+ }
1311
+ })
1312
+ }),
1313
+ /* @__PURE__ */ jsx(TableCell, {
1314
+ className: "px-2 py-2.5",
1315
+ children: /* @__PURE__ */ jsxs("div", {
1316
+ className: "flex items-center gap-2",
1317
+ children: [/* @__PURE__ */ jsx(FolderIcon, {
1318
+ size: iconSize.smallest,
1319
+ className: "text-amber-500 dark:text-amber-400 shrink-0"
1320
+ }), /* @__PURE__ */ jsx(Typography, {
1321
+ variant: "body2",
1322
+ className: "text-[13px] font-medium truncate",
1323
+ children: folder.name
1324
+ })]
1325
+ })
1326
+ }),
1327
+ /* @__PURE__ */ jsx(TableCell, {
1328
+ className: "px-4 py-2.5",
1329
+ children: /* @__PURE__ */ jsx(Typography, {
1330
+ variant: "caption",
1331
+ className: "text-text-secondary dark:text-text-secondary-dark",
1332
+ children: t("studio_storage_folder")
1333
+ })
1334
+ }),
1335
+ /* @__PURE__ */ jsx(TableCell, {
1336
+ className: "px-4 py-2.5 text-right",
1337
+ children: /* @__PURE__ */ jsx(Typography, {
1338
+ variant: "caption",
1339
+ className: "text-text-disabled dark:text-text-disabled-dark",
1340
+ children: "—"
1341
+ })
1342
+ }),
1343
+ /* @__PURE__ */ jsx(TableCell, { className: "px-2 py-2.5" })
1344
+ ]
1345
+ }, folder.fullPath);
1346
+ }), files.map((listed) => {
1347
+ const file = withMetadata(listed);
1348
+ const FileIconComp = getFileIcon(file.contentType, file.name);
1349
+ const isChecked = selectedPaths.has(file.fullPath);
1350
+ return /* @__PURE__ */ jsxs(TableRow, {
1351
+ ref: observeFile,
1352
+ "data-storage-path": file.fullPath,
1353
+ "data-storage-item": true,
1354
+ className: cls("group", isChecked && "bg-primary/5 dark:bg-primary/10"),
1355
+ onClick: (e) => handleItemClick(file, e),
1356
+ onDoubleClick: () => handleItemDoubleClick(file),
1357
+ children: [
1358
+ /* @__PURE__ */ jsx(TableCell, {
1359
+ className: "pl-3 pr-0 py-2.5",
1360
+ onClick: (e) => e.stopPropagation(),
1361
+ children: /* @__PURE__ */ jsx(Checkbox, {
1362
+ size: "small",
1363
+ checked: isChecked,
1364
+ onCheckedChange: () => {
1365
+ setSelectedPaths((prev) => {
1366
+ const next = new Set(prev);
1367
+ if (next.has(file.fullPath)) next.delete(file.fullPath);
1368
+ else next.add(file.fullPath);
1369
+ return next;
1370
+ });
1371
+ }
1372
+ })
1373
+ }),
1374
+ /* @__PURE__ */ jsx(TableCell, {
1375
+ className: "px-2 py-2.5",
1376
+ children: /* @__PURE__ */ jsxs("div", {
1377
+ className: "flex items-center gap-2",
1378
+ children: [/* @__PURE__ */ jsx(FileIconComp, {
1379
+ size: iconSize.smallest,
1380
+ className: "text-surface-accent-400 shrink-0"
1381
+ }), /* @__PURE__ */ jsx(Typography, {
1382
+ variant: "body2",
1383
+ className: "text-[13px] truncate",
1384
+ children: file.name
1385
+ })]
1386
+ })
1387
+ }),
1388
+ /* @__PURE__ */ jsx(TableCell, {
1389
+ className: "px-4 py-2.5",
1390
+ children: /* @__PURE__ */ jsx(Typography, {
1391
+ variant: "caption",
1392
+ className: "text-text-secondary dark:text-text-secondary-dark",
1393
+ children: getExtension(file.name) || file.contentType?.split("/")[1]?.toUpperCase() || "—"
1394
+ })
1395
+ }),
1396
+ /* @__PURE__ */ jsx(TableCell, {
1397
+ className: "px-4 py-2.5 text-right",
1398
+ children: /* @__PURE__ */ jsx(Typography, {
1399
+ variant: "caption",
1400
+ className: "text-text-secondary dark:text-text-secondary-dark font-mono text-[11px]",
1401
+ children: file.size !== void 0 ? formatFileSize(file.size) : "—"
1402
+ })
1403
+ }),
1404
+ /* @__PURE__ */ jsx(TableCell, {
1405
+ className: "px-2 py-2.5",
1406
+ onClick: (e) => e.stopPropagation(),
1407
+ children: /* @__PURE__ */ jsx(IconButton, {
1408
+ "aria-label": t("studio_storage_delete"),
1409
+ size: "smallest",
1410
+ className: "opacity-0 group-hover:opacity-100 transition-opacity",
1411
+ onClick: () => {
1412
+ setDeleteDialogTarget(file);
1413
+ setDeleteDialogOpen(true);
1414
+ },
1415
+ children: /* @__PURE__ */ jsx(Trash2Icon, { size: 14 })
1416
+ })
1189
1417
  })
1190
- })
1191
- ]
1192
- }, file.fullPath);
1193
- })] })]
1418
+ ]
1419
+ }, file.fullPath);
1420
+ })]
1421
+ })]
1194
1422
  })
1195
1423
  });
1196
1424
  return /* @__PURE__ */ jsxs("div", {
@@ -1226,20 +1454,16 @@ var StorageView = () => {
1226
1454
  children: t("studio_storage_files_heading", { count: files.length })
1227
1455
  }), /* @__PURE__ */ jsx("div", {
1228
1456
  className: "grid gap-3 grid-cols-[repeat(auto-fill,minmax(140px,1fr))]",
1229
- children: files.map((file) => {
1230
- const FileIconComp = getFileIcon(file.contentType);
1231
- const ext = getExtension(file.name)?.toLowerCase() || "";
1232
- const isImage = file.contentType?.startsWith("image/") || [
1233
- "jpg",
1234
- "jpeg",
1235
- "png",
1236
- "gif",
1237
- "webp",
1238
- "svg"
1239
- ].includes(ext);
1457
+ children: files.map((listed) => {
1458
+ const file = withMetadata(listed);
1459
+ const FileIconComp = getFileIcon(file.contentType, file.name);
1460
+ const isImage = isImageFile(file);
1461
+ const isChecked = selectedPaths.has(file.fullPath);
1240
1462
  return /* @__PURE__ */ jsxs("div", {
1463
+ ref: observeFile,
1464
+ "data-storage-path": file.fullPath,
1241
1465
  "data-storage-item": true,
1242
- className: cls("rounded-lg overflow-hidden cursor-pointer border", "transition-shadow duration-150", defaultBorderMixin, "hover:shadow-md", selectedPaths.has(file.fullPath) && "ring-2 ring-primary"),
1466
+ className: cls("rounded-lg overflow-hidden cursor-pointer border", "transition-shadow duration-150", defaultBorderMixin, "hover:shadow-md", isChecked && "ring-2 ring-primary"),
1243
1467
  onClick: (e) => handleItemClick(file, e),
1244
1468
  onDoubleClick: () => handleItemDoubleClick(file),
1245
1469
  children: [/* @__PURE__ */ jsxs("div", {
@@ -1489,7 +1713,7 @@ var StorageView = () => {
1489
1713
  }), selectedFile && /* @__PURE__ */ jsx("div", {
1490
1714
  className: "w-80 lg:w-96 shrink-0",
1491
1715
  children: /* @__PURE__ */ jsx(FilePreviewPanel, {
1492
- file: selectedFile,
1716
+ file: withLinkFacts(withMetadata(selectedFile), selectedLink),
1493
1717
  link: selectedLink,
1494
1718
  mintLink: refreshPreviewLink,
1495
1719
  onClose: () => {
@@ -1506,6 +1730,39 @@ var StorageView = () => {
1506
1730
  onClose: () => setUploadDialogOpen(false),
1507
1731
  onUpload: handleUpload
1508
1732
  }),
1733
+ /* @__PURE__ */ jsxs(Dialog, {
1734
+ open: replacePrompt !== null,
1735
+ onOpenChange: (open) => {
1736
+ if (!open) replacePrompt?.answer(false);
1737
+ },
1738
+ children: [
1739
+ /* @__PURE__ */ jsx(DialogTitle, {
1740
+ hidden: true,
1741
+ children: t("studio_storage_replace_title")
1742
+ }),
1743
+ /* @__PURE__ */ jsxs(DialogContent, { children: [/* @__PURE__ */ jsx(Typography, {
1744
+ variant: "subtitle1",
1745
+ className: "font-semibold mb-2",
1746
+ children: t("studio_storage_replace_question", { count: replacePrompt?.names.length ?? 0 })
1747
+ }), /* @__PURE__ */ jsx(Typography, {
1748
+ variant: "body2",
1749
+ color: "secondary",
1750
+ children: t("studio_storage_replace_body", {
1751
+ count: replacePrompt?.names.length ?? 0,
1752
+ names: replacePrompt?.names.join(", ") ?? ""
1753
+ })
1754
+ })] }),
1755
+ /* @__PURE__ */ jsxs(DialogActions, { children: [/* @__PURE__ */ jsx(Button, {
1756
+ variant: "text",
1757
+ onClick: () => replacePrompt?.answer(false),
1758
+ children: t("studio_storage_cancel")
1759
+ }), /* @__PURE__ */ jsx(Button, {
1760
+ variant: "filled",
1761
+ onClick: () => replacePrompt?.answer(true),
1762
+ children: t("studio_storage_replace")
1763
+ })] })
1764
+ ]
1765
+ }),
1509
1766
  /* @__PURE__ */ jsxs(Dialog, {
1510
1767
  open: deleteDialogOpen,
1511
1768
  onOpenChange: (open) => {
@@ -1617,4 +1874,4 @@ var StorageView = () => {
1617
1874
  //#endregion
1618
1875
  export { StorageView };
1619
1876
 
1620
- //# sourceMappingURL=StorageView-CbsHuCBf.js.map
1877
+ //# sourceMappingURL=StorageView-CPkDKbsX.js.map