@rebasepro/studio 0.17.3 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/LICENSE +0 -1
  2. package/README.md +12 -4
  3. package/dist/{ApiKeysView-DiCurTEU.js → ApiKeysView-EIvegrUk.js} +54 -39
  4. package/dist/ApiKeysView-EIvegrUk.js.map +1 -0
  5. package/dist/{BackupsView-DNS6LdVg.js → BackupsView-DVkKzaPR.js} +29 -31
  6. package/dist/BackupsView-DVkKzaPR.js.map +1 -0
  7. package/dist/{BranchesView-CM3B-ER3.js → BranchesView-Df8hQ8wE.js} +85 -24
  8. package/dist/BranchesView-Df8hQ8wE.js.map +1 -0
  9. package/dist/{CronJobsView-BHtJZeJZ.js → CronJobsView-CLOGjb-L.js} +32 -27
  10. package/dist/CronJobsView-CLOGjb-L.js.map +1 -0
  11. package/dist/{RLSEditor-CBX4uD66.js → RLSEditor-Sa1D34ny.js} +724 -615
  12. package/dist/RLSEditor-Sa1D34ny.js.map +1 -0
  13. package/dist/{SQLEditor-ByecwX8B.js → SQLEditor-BfNA8xM3.js} +9 -4
  14. package/dist/{SQLEditor-ByecwX8B.js.map → SQLEditor-BfNA8xM3.js.map} +1 -1
  15. package/dist/{SchemaVisualizer-D6tMZgeC.js → SchemaVisualizer-DTDMYL6o.js} +84 -110
  16. package/dist/SchemaVisualizer-DTDMYL6o.js.map +1 -0
  17. package/dist/{StorageView-BGDeC5GI.js → StorageView-Ed9Q_VlB.js} +5 -32
  18. package/dist/StorageView-Ed9Q_VlB.js.map +1 -0
  19. package/dist/components/RLSEditor/saveRules.d.ts +38 -0
  20. package/dist/components/RebaseStudio.d.ts +1 -1
  21. package/dist/components/SchemaVisualizer/schema-visualizer.utils.d.ts +5 -2
  22. package/dist/components/SchemaVisualizer/useSchemaGraph.d.ts +0 -4
  23. package/dist/components/load-failure-view.d.ts +17 -0
  24. package/dist/components/load-failure.d.ts +47 -0
  25. package/dist/index.es.js +56 -36
  26. package/dist/index.es.js.map +1 -1
  27. package/dist/load-failure-C6OMJSGC.js +31 -0
  28. package/dist/load-failure-C6OMJSGC.js.map +1 -0
  29. package/dist/load-failure-view-CBTB_h-h.js +48 -0
  30. package/dist/load-failure-view-CBTB_h-h.js.map +1 -0
  31. package/package.json +36 -21
  32. package/dist/ApiKeysView-DiCurTEU.js.map +0 -1
  33. package/dist/BackupsView-DNS6LdVg.js.map +0 -1
  34. package/dist/BranchesView-CM3B-ER3.js.map +0 -1
  35. package/dist/CronJobsView-BHtJZeJZ.js.map +0 -1
  36. package/dist/RLSEditor-CBX4uD66.js.map +0 -1
  37. package/dist/SchemaVisualizer-D6tMZgeC.js.map +0 -1
  38. package/dist/StorageView-BGDeC5GI.js.map +0 -1
  39. package/dist/components/StorageView/storage-failure.d.ts +0 -34
  40. package/src/components/ApiExplorer/ApiExplorer.tsx +0 -299
  41. package/src/components/ApiExplorer/EndpointDetail.tsx +0 -279
  42. package/src/components/ApiExplorer/TryItPanel.tsx +0 -499
  43. package/src/components/ApiExplorer/parseSpec.ts +0 -104
  44. package/src/components/ApiExplorer/types.ts +0 -84
  45. package/src/components/ApiKeys/ApiKeysView.tsx +0 -479
  46. package/src/components/ApiKeys/CreateApiKeyDialog.tsx +0 -711
  47. package/src/components/ApiKeys/permissions.ts +0 -145
  48. package/src/components/AuthSimulationSelector.tsx +0 -72
  49. package/src/components/Backups/BackupsView.tsx +0 -185
  50. package/src/components/Branches/BranchesView.tsx +0 -386
  51. package/src/components/CronJobs/CronJobsView.tsx +0 -413
  52. package/src/components/JSEditor/JSEditor.tsx +0 -1100
  53. package/src/components/JSEditor/JSEditorSidebar.tsx +0 -350
  54. package/src/components/JSEditor/JSMonacoEditor.tsx +0 -410
  55. package/src/components/LogsExplorer/LogsExplorer.tsx +0 -273
  56. package/src/components/LogsExplorer/useLogTail.ts +0 -356
  57. package/src/components/RLSEditor/PolicyEditor.tsx +0 -399
  58. package/src/components/RLSEditor/RLSEditor.tsx +0 -1128
  59. package/src/components/RLSEditor/index.ts +0 -1
  60. package/src/components/RLSEditor/policy-presets.ts +0 -176
  61. package/src/components/RLSEditor/saveSecurityRules.ts +0 -48
  62. package/src/components/RebaseStudio.tsx +0 -174
  63. package/src/components/SQLEditor/ExplainVisualizer.tsx +0 -128
  64. package/src/components/SQLEditor/MonacoEditor.tsx +0 -203
  65. package/src/components/SQLEditor/SQLEditor.tsx +0 -1518
  66. package/src/components/SQLEditor/SQLEditorSidebar.tsx +0 -173
  67. package/src/components/SQLEditor/SchemaBrowser.tsx +0 -168
  68. package/src/components/SQLEditor/sql_editor_types.ts +0 -18
  69. package/src/components/SchemaVisualizer/RelationEdge.tsx +0 -102
  70. package/src/components/SchemaVisualizer/SchemaVisualizer.tsx +0 -710
  71. package/src/components/SchemaVisualizer/TableNode.tsx +0 -257
  72. package/src/components/SchemaVisualizer/schema-visualizer.utils.ts +0 -140
  73. package/src/components/SchemaVisualizer/useLiveRls.ts +0 -86
  74. package/src/components/SchemaVisualizer/useSchemaGraph.ts +0 -426
  75. package/src/components/StorageView/StorageView.tsx +0 -1588
  76. package/src/components/StorageView/storage-failure.ts +0 -63
  77. package/src/components/StudioHomePage.tsx +0 -430
  78. package/src/index.ts +0 -31
  79. package/src/utils/parseSpec.test.ts +0 -309
  80. package/src/utils/sql_utils.test.ts +0 -265
  81. package/src/utils/sql_utils.ts +0 -291
@@ -1,1588 +0,0 @@
1
-
2
- import React, { useState, useEffect, useCallback, useMemo, useRef } from "react";
3
- import {
4
- ArrowLeftIcon,
5
- Button,
6
- Checkbox,
7
- CheckIcon,
8
- Chip,
9
- CircularProgress,
10
- cls,
11
- CopyIcon,
12
- defaultBorderMixin,
13
- Dialog,
14
- DialogActions,
15
- DialogContent,
16
- DialogTitle,
17
- DownloadIcon,
18
- FileIcon,
19
- FileTextIcon,
20
- FileUpload,
21
- FolderIcon,
22
- FolderPlusIcon,
23
- HomeIcon,
24
- IconButton,
25
- iconSize,
26
- ImageIcon,
27
- LayoutGridIcon,
28
- ListIcon,
29
- LoadingButton,
30
- Music2Icon,
31
- PlusIcon,
32
- RefreshCwIcon,
33
- ResizablePanels,
34
- Select,
35
- SelectItem,
36
- TextField,
37
- Tooltip,
38
- Trash2Icon,
39
- Typography,
40
- UploadCloudIcon,
41
- VideoIcon,
42
- XIcon
43
- } from "@rebasepro/ui";
44
- import { useStorageSource, useStorageSources, useSnackbarController, ErrorView, useApiBase, useApiConfig } from "@rebasepro/app";
45
- import { DEFAULT_STORAGE_SOURCE_KEY, type StorageListResult } from "@rebasepro/types";
46
- import { classifyStorageFailure, type StorageFailure } from "./storage-failure";
47
- import { useSearchParams } from "react-router";
48
- import { useDropzone } from "react-dropzone";
49
-
50
- // ──────────────────────────────────────────────
51
- // Types
52
- // ──────────────────────────────────────────────
53
-
54
- interface StorageFile {
55
- name: string;
56
- fullPath: string;
57
- isFolder: boolean;
58
- /** Only populated when metadata is fetched */
59
- size?: number;
60
- contentType?: string;
61
- downloadUrl?: string;
62
- }
63
-
64
- // ──────────────────────────────────────────────
65
- // Helpers
66
- // ──────────────────────────────────────────────
67
-
68
- /**
69
- * The query parameter holding the folder being browsed.
70
- *
71
- * Namespaced because Studio does not always own the URL it renders under: the
72
- * SaaS console embeds this view in a page that keeps its own state in `?tab=`
73
- * and `?sub=`, and a bare `path` is a name any host app might already be using.
74
- */
75
- const STORAGE_PATH_PARAM = "storagePath";
76
-
77
- function formatFileSize(bytes: number): string {
78
- if (bytes < 1024) return `${bytes} B`;
79
- if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
80
- if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
81
- return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
82
- }
83
-
84
- function getFileIcon(contentType?: string) {
85
- if (!contentType) return FileTextIcon;
86
- if (contentType.startsWith("image/")) return ImageIcon;
87
- if (contentType.startsWith("video/")) return VideoIcon;
88
- if (contentType.startsWith("audio/")) return Music2Icon;
89
- return FileTextIcon;
90
- }
91
-
92
- function getExtension(name: string): string {
93
- const parts = name.split(".");
94
- return parts.length > 1 ? parts[parts.length - 1].toUpperCase() : "";
95
- }
96
-
97
- function breadcrumbSegments(path: string): { label: string; path: string }[] {
98
- if (!path || path === "/") return [{ label: "Root",
99
- path: "" }];
100
- const parts = path.split("/").filter(Boolean);
101
- const segments = [{ label: "Root",
102
- path: "" }];
103
- let accumulated = "";
104
- for (const part of parts) {
105
- accumulated = accumulated ? `${accumulated}/${part}` : part;
106
- segments.push({ label: part,
107
- path: accumulated });
108
- }
109
- return segments;
110
- }
111
-
112
- // ──────────────────────────────────────────────
113
- // Upload Dialog
114
- // ──────────────────────────────────────────────
115
-
116
- function UploadDialog({
117
- open,
118
- currentPath,
119
- onClose,
120
- onUpload
121
- }: {
122
- open: boolean;
123
- currentPath: string;
124
- onClose: () => void;
125
- onUpload: (files: File[]) => Promise<void>;
126
- }) {
127
- const [uploading, setUploading] = useState(false);
128
- const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
129
- const [error, setError] = useState<string | null>(null);
130
-
131
- const handleFilesAdded = useCallback((files: File[]) => {
132
- setSelectedFiles(prev => [...prev, ...files]);
133
- }, []);
134
-
135
- const handleRemoveFile = useCallback((index: number) => {
136
- setSelectedFiles(prev => prev.filter((_, i) => i !== index));
137
- }, []);
138
-
139
- const handleUpload = useCallback(async () => {
140
- if (selectedFiles.length === 0) return;
141
- setUploading(true);
142
- setError(null);
143
- try {
144
- await onUpload(selectedFiles);
145
- setSelectedFiles([]);
146
- onClose();
147
- } catch (err) {
148
- setError(err instanceof Error ? err.message : "Upload failed");
149
- } finally {
150
- setUploading(false);
151
- }
152
- }, [selectedFiles, onUpload, onClose]);
153
-
154
- const handleClose = useCallback(() => {
155
- if (!uploading) {
156
- setSelectedFiles([]);
157
- setError(null);
158
- onClose();
159
- }
160
- }, [uploading, onClose]);
161
-
162
- return (
163
- <Dialog open={open} onOpenChange={(o) => !o && handleClose()} maxWidth="md">
164
- <DialogTitle>
165
- Upload Files
166
- <Typography variant="caption" className="text-text-secondary dark:text-text-secondary-dark mt-0.5 block">
167
- to <span className="font-mono text-primary">/{currentPath || "root"}</span>
168
- </Typography>
169
- </DialogTitle>
170
- <DialogContent className="space-y-4">
171
- <FileUpload
172
- onFilesAdded={handleFilesAdded}
173
- size="large"
174
- uploadDescription={
175
- <div className="flex flex-col items-center justify-center pointer-events-none">
176
- <UploadCloudIcon className="text-surface-accent-400 mb-2 w-8 h-8"/>
177
- <Typography variant="label">
178
- Drop files here or click to browse
179
- </Typography>
180
- <Typography variant="caption" color="secondary">
181
- Any file type supported
182
- </Typography>
183
- </div>
184
- }
185
- />
186
-
187
- {error && (
188
- <Typography variant="caption" className="text-red-500 block whitespace-pre-line">
189
- {error}
190
- </Typography>
191
- )}
192
-
193
- {selectedFiles.length > 0 && (
194
- <div className="space-y-2">
195
- <Typography variant="caption" color="secondary">
196
- Selected files ({selectedFiles.length})
197
- </Typography>
198
- <div className="max-h-40 overflow-auto space-y-1">
199
- {selectedFiles.map((file, index) => (
200
- <div
201
- key={`${file.name}-${index}`}
202
- className="flex items-center justify-between p-2 rounded bg-surface-100 dark:bg-surface-800"
203
- >
204
- <div className="flex-1 min-w-0 mr-2">
205
- <Typography variant="body2" className="truncate">
206
- {file.name}
207
- </Typography>
208
- <Typography variant="caption" color="secondary">
209
- {formatFileSize(file.size)}
210
- </Typography>
211
- </div>
212
- <IconButton
213
- size="small"
214
- onClick={(e) => {
215
- e.stopPropagation();
216
- handleRemoveFile(index);
217
- }}
218
- disabled={uploading}
219
- >
220
- <XIcon size={14}/>
221
- </IconButton>
222
- </div>
223
- ))}
224
- </div>
225
- </div>
226
- )}
227
- </DialogContent>
228
-
229
- <DialogActions>
230
- <Button variant="text" onClick={handleClose} disabled={uploading}>
231
- Cancel
232
- </Button>
233
- <Button
234
- variant="filled"
235
- onClick={handleUpload}
236
- disabled={selectedFiles.length === 0 || uploading}
237
- startIcon={uploading ? <CircularProgress size="smallest"/> : <UploadCloudIcon size={14}/>}
238
- >
239
- {uploading ? "Uploading..." : `Upload${selectedFiles.length > 0 ? ` (${selectedFiles.length})` : ""}`}
240
- </Button>
241
- </DialogActions>
242
- </Dialog>
243
- );
244
- }
245
-
246
- // ──────────────────────────────────────────────
247
- // FileIcon preview panel
248
- // ──────────────────────────────────────────────
249
-
250
- function FilePreviewPanel({
251
- file,
252
- onClose,
253
- onDelete,
254
- downloadUrl
255
- }: {
256
- file: StorageFile;
257
- onClose: () => void;
258
- onDelete: () => void;
259
- downloadUrl: string | null;
260
- }) {
261
- const isImage = file.contentType?.startsWith("image/");
262
- const isVideo = file.contentType?.startsWith("video/");
263
- const isAudio = file.contentType?.startsWith("audio/");
264
- const FileIconComponent = getFileIcon(file.contentType);
265
- const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
266
- const [urlCopied, setUrlCopied] = useState(false);
267
-
268
- return (
269
- <>
270
- <div className={cls(
271
- "flex flex-col h-full border-l",
272
- defaultBorderMixin,
273
- "bg-white dark:bg-surface-800"
274
- )}>
275
- {/* Header */}
276
- <div className={cls("flex items-center justify-between p-3 border-b shrink-0", defaultBorderMixin)}>
277
- <Typography variant="body2" className="font-medium truncate flex-1 mr-2">
278
- {file.name}
279
- </Typography>
280
- <div className="flex items-center gap-0.5">
281
- {downloadUrl && (
282
- <Tooltip title="Download">
283
- <IconButton
284
- size="small"
285
- onClick={() => window.open(downloadUrl, "_blank")}
286
- >
287
- <DownloadIcon size={iconSize.smallest}/>
288
- </IconButton>
289
- </Tooltip>
290
- )}
291
- <Tooltip title="Delete">
292
- <IconButton
293
- size="small"
294
- onClick={() => setDeleteDialogOpen(true)}
295
- className="text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20"
296
- >
297
- <Trash2Icon size={iconSize.smallest}/>
298
- </IconButton>
299
- </Tooltip>
300
- <IconButton size="small" onClick={onClose}>
301
- <XIcon size={iconSize.smallest}/>
302
- </IconButton>
303
- </div>
304
- </div>
305
-
306
- {/* Preview */}
307
- <div className="flex-1 overflow-auto">
308
- <div className={cls("flex flex-col items-center justify-center min-h-[200px] p-4 bg-surface-50 dark:bg-surface-800 border-b", defaultBorderMixin)}>
309
- {(() => {
310
- const ext = getExtension(file.name)?.toLowerCase() || "";
311
- const isImage = file.contentType?.startsWith("image/") || ["jpg", "jpeg", "png", "gif", "webp", "svg"].includes(ext);
312
- const isVideo = file.contentType?.startsWith("video/") || ["mp4", "webm", "ogg", "mov"].includes(ext);
313
- const isAudio = file.contentType?.startsWith("audio/") || ["mp3", "wav", "ogg", "m4a"].includes(ext);
314
- const downloadUrl = file.downloadUrl;
315
-
316
- if (isImage && downloadUrl) {
317
- return (
318
- <img
319
- src={downloadUrl}
320
- alt={file.name}
321
- className="max-w-full max-h-[400px] object-contain rounded-md shadow-sm"
322
- />
323
- );
324
- } else if (isVideo && downloadUrl) {
325
- return (
326
- <video
327
- src={downloadUrl}
328
- className="max-w-full max-h-[400px] rounded-md"
329
- controls
330
- />
331
- );
332
- } else if (isAudio && downloadUrl) {
333
- return (
334
- <div className="flex flex-col items-center gap-4">
335
- <Music2Icon className="text-surface-accent-400 w-10 h-10"/>
336
- <audio src={downloadUrl} controls className="w-full max-w-xs"/>
337
- </div>
338
- );
339
- } else {
340
- return (
341
- <div className="flex flex-col items-center gap-3 text-surface-accent-400">
342
- <FileIconComponent className="w-10 h-10"/>
343
- <Typography variant="caption" className="text-text-disabled dark:text-text-disabled-dark">
344
- No preview available
345
- </Typography>
346
- </div>
347
- );
348
- }
349
- })()}
350
- </div>
351
- </div>
352
-
353
- {/* Metadata */}
354
- <div className="p-4 space-y-3">
355
- <div>
356
- <Typography variant="caption" className="text-text-disabled dark:text-text-disabled-dark text-[10px] uppercase tracking-wider font-semibold mb-1 block">
357
- File Info
358
- </Typography>
359
- </div>
360
- <div className="grid grid-cols-2 gap-3">
361
- <div>
362
- <Typography variant="caption" className="text-surface-accent-500 text-[11px]">
363
- Name
364
- </Typography>
365
- <Typography variant="body2" className="text-[13px] break-all">
366
- {file.name}
367
- </Typography>
368
- </div>
369
- <div>
370
- <Typography variant="caption" className="text-surface-accent-500 text-[11px]">
371
- Type
372
- </Typography>
373
- <Typography variant="body2" className="text-[13px]">
374
- {file.contentType || "Unknown"}
375
- </Typography>
376
- </div>
377
- {file.size !== undefined && (
378
- <div>
379
- <Typography variant="caption" className="text-surface-accent-500 text-[11px]">
380
- Size
381
- </Typography>
382
- <Typography variant="body2" className="text-[13px]">
383
- {formatFileSize(file.size)}
384
- </Typography>
385
- </div>
386
- )}
387
- <div>
388
- <Typography variant="caption" className="text-surface-accent-500 text-[11px]">
389
- Extension
390
- </Typography>
391
- <Typography variant="body2" className="text-[13px] font-mono">
392
- {getExtension(file.name) || "—"}
393
- </Typography>
394
- </div>
395
- <div className="col-span-2">
396
- <Typography variant="caption" className="text-surface-accent-500 text-[11px]">
397
- Path
398
- </Typography>
399
- <Typography variant="body2" className="text-[13px] font-mono break-all">
400
- {file.fullPath}
401
- </Typography>
402
- </div>
403
- </div>
404
-
405
- {downloadUrl && (
406
- <div className="pt-2">
407
- <Typography variant="caption" className="text-surface-accent-500 text-[11px] block mb-1">
408
- URL
409
- </Typography>
410
- <div
411
- className={cls(
412
- "flex items-center gap-2 p-2 rounded cursor-pointer transition-colors",
413
- "bg-surface-100 dark:bg-surface-800 hover:bg-surface-200 dark:hover:bg-surface-700"
414
- )}
415
- onClick={() => {
416
- const fullUrl = downloadUrl.startsWith("http")
417
- ? downloadUrl
418
- : `${window.location.origin}${downloadUrl.startsWith("/") ? "" : "/"}${downloadUrl}`;
419
- navigator.clipboard.writeText(fullUrl).then(() => {
420
- setUrlCopied(true);
421
- setTimeout(() => setUrlCopied(false), 2000);
422
- });
423
- }}
424
- >
425
- <Typography variant="caption" className="font-mono text-[11px] truncate flex-1 min-w-0 text-primary">
426
- {(() => {
427
- const fullUrl = downloadUrl.startsWith("http")
428
- ? downloadUrl
429
- : `${window.location.origin}${downloadUrl.startsWith("/") ? "" : "/"}${downloadUrl}`;
430
- return fullUrl;
431
- })()}
432
- </Typography>
433
- <Tooltip title={urlCopied ? "Copied!" : "Copy URL"}>
434
- <div className="shrink-0">
435
- {urlCopied
436
- ? <CheckIcon size={14} className="text-green-500"/>
437
- : <CopyIcon size={14} className="text-surface-accent-400"/>
438
- }
439
- </div>
440
- </Tooltip>
441
- </div>
442
- </div>
443
- )}
444
- </div>
445
- </div>
446
-
447
- {/* Delete Confirmation */}
448
- <Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
449
- <DialogTitle hidden>Delete File</DialogTitle>
450
- <DialogContent>
451
- <Typography variant="subtitle1" className="mb-2">
452
- Delete File?
453
- </Typography>
454
- <Typography className="text-surface-accent-600 dark:text-surface-accent-400">
455
- Are you sure you want to delete &quot;{file.name}&quot;?
456
- This action cannot be undone.
457
- </Typography>
458
- </DialogContent>
459
- <DialogActions>
460
- <Button variant="text" onClick={() => setDeleteDialogOpen(false)}>
461
- Cancel
462
- </Button>
463
- <Button
464
- variant="filled"
465
- color="error"
466
- onClick={() => {
467
- setDeleteDialogOpen(false);
468
- onDelete();
469
- }}
470
- >
471
- Delete
472
- </Button>
473
- </DialogActions>
474
- </Dialog>
475
- </>
476
- );
477
- }
478
-
479
- // ──────────────────────────────────────────────
480
- // Main StorageView Export
481
- // ──────────────────────────────────────────────
482
-
483
- export const StorageView = () => {
484
- const defaultStorageSource = useStorageSource();
485
- const storageSources = useStorageSources();
486
- const snackbarController = useSnackbarController();
487
-
488
- // Available backends to browse. Always includes the default; named
489
- // sources come from `<Rebase storageSources={...}>`.
490
- const sourceKeys = useMemo(() => {
491
- const keys = Object.keys(storageSources.sources);
492
- if (!keys.includes(DEFAULT_STORAGE_SOURCE_KEY)) keys.unshift(DEFAULT_STORAGE_SOURCE_KEY);
493
- return keys;
494
- }, [storageSources.sources]);
495
-
496
- const [selectedSourceKey, setSelectedSourceKey] = useState<string>(DEFAULT_STORAGE_SOURCE_KEY);
497
-
498
- const storageSource = storageSources.sources[selectedSourceKey] ?? defaultStorageSource;
499
-
500
- // Navigation
501
- const [searchParams, setSearchParams] = useSearchParams();
502
- // Accepts the historical unqualified `path` so links already shared keep
503
- // working; only the namespaced one is ever written.
504
- const currentPath = searchParams.get(STORAGE_PATH_PARAM) || searchParams.get("path") || "";
505
- const [loading, setLoading] = useState(true);
506
- /** Why the listing failed, classified — see `storage-failure.ts`. */
507
- const [failure, setFailure] = useState<StorageFailure | null>(null);
508
-
509
- // Contents
510
- const [folders, setFolders] = useState<StorageFile[]>([]);
511
- const [files, setFiles] = useState<StorageFile[]>([]);
512
-
513
- // Selection and preview
514
- const [selectedFile, setSelectedFile] = useState<StorageFile | null>(null);
515
- const [selectedDownloadUrl, setSelectedDownloadUrl] = useState<string | null>(null);
516
-
517
- // Upload
518
- const [uploadDialogOpen, setUploadDialogOpen] = useState(false);
519
-
520
- // View mode
521
- const [viewMode, setViewMode] = useState<"grid" | "list">("grid");
522
-
523
- // Multi-selection
524
- const [selectedPaths, setSelectedPaths] = useState<Set<string>>(new Set());
525
- const lastClickedRef = useRef<string | null>(null);
526
-
527
- // Bulk / folder delete
528
- const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
529
- const [deleteDialogTarget, setDeleteDialogTarget] = useState<"selection" | StorageFile | null>(null);
530
- const [deleting, setDeleting] = useState(false);
531
-
532
- // New folder
533
- const [newFolderDialogOpen, setNewFolderDialogOpen] = useState(false);
534
- const [newFolderName, setNewFolderName] = useState("");
535
- const [creatingFolder, setCreatingFolder] = useState(false);
536
- const apiConfig = useApiConfig();
537
- const apiBase = useApiBase();
538
-
539
- const storageSourceRef = React.useRef(storageSource);
540
- useEffect(() => {
541
- storageSourceRef.current = storageSource;
542
- }, [storageSource]);
543
-
544
- // ── Fetch directory contents ──
545
- const fetchContents = useCallback(async (path: string) => {
546
- setLoading(true);
547
- setFailure(null);
548
- try {
549
- const result: StorageListResult = await storageSourceRef.current.listObjects(path);
550
-
551
- const folderItems: StorageFile[] = (result.prefixes ?? []).map(ref => ({
552
- name: ref.name,
553
- fullPath: ref.fullPath,
554
- isFolder: true
555
- }));
556
-
557
- // Build file items and fetch metadata for each
558
- const fileItems: StorageFile[] = await Promise.all(
559
- (result.items ?? []).map(async (ref) => {
560
- try {
561
- const downloadConfig = await storageSourceRef.current.getSignedUrl(ref.fullPath);
562
- return {
563
- name: ref.name,
564
- fullPath: ref.fullPath,
565
- isFolder: false,
566
- size: downloadConfig.metadata?.size,
567
- contentType: downloadConfig.metadata?.contentType,
568
- downloadUrl: downloadConfig.url ?? undefined
569
- };
570
- } catch {
571
- return {
572
- name: ref.name,
573
- fullPath: ref.fullPath,
574
- isFolder: false
575
- };
576
- }
577
- })
578
- );
579
-
580
- setFolders(folderItems);
581
- setFiles(fileItems);
582
- } catch (e) {
583
- console.error("Storage list error:", e);
584
- // A refusal from the project's own `storageAuthorize` hook is not a
585
- // fault — see `storage-failure.ts`. Rendering both the same way told
586
- // a customer with a working project that their storage was broken.
587
- setFailure(classifyStorageFailure(e));
588
- } finally {
589
- setLoading(false);
590
- }
591
- }, []);
592
-
593
- useEffect(() => {
594
- // `selectedSourceKey` is a dep so switching backend re-lists; the
595
- // ref-sync effect above runs first, so `storageSourceRef` is current.
596
- fetchContents(currentPath);
597
- }, [currentPath, fetchContents, selectedSourceKey]);
598
-
599
- // Navigate to path
600
- //
601
- // Updates only this view's own parameter and leaves the rest of the query
602
- // string alone. It used to assign the whole thing (`setSearchParams({})`),
603
- // which is fine when Studio owns the URL but destructive when it is
604
- // embedded: the SaaS console keeps the open tab in `?tab=`, so opening a
605
- // folder erased it and bounced the user out of Storage entirely. The key is
606
- // namespaced for the same reason — a bare `path` is a name a host app can
607
- // easily be using for something else.
608
- const handleNavigate = useCallback((path: string) => {
609
- setSearchParams(prev => {
610
- const next = new URLSearchParams(prev);
611
- if (path) next.set(STORAGE_PATH_PARAM, path);
612
- else next.delete(STORAGE_PATH_PARAM);
613
- return next;
614
- }, { replace: true });
615
- setSelectedFile(null);
616
- setSelectedDownloadUrl(null);
617
- setSelectedPaths(new Set());
618
- lastClickedRef.current = null;
619
- }, [setSearchParams]);
620
-
621
- // Navigate up one level
622
- const handleNavigateUp = useCallback(() => {
623
- const parts = currentPath.split("/").filter(Boolean);
624
- parts.pop();
625
- handleNavigate(parts.join("/"));
626
- }, [currentPath, handleNavigate]);
627
-
628
- // All items (folders + files) in display order, for shift-range select
629
- const allItems = useMemo(() => [...folders, ...files], [folders, files]);
630
-
631
- // ── Multi-select click handler ──
632
- const handleItemClick = useCallback((item: StorageFile, e: React.MouseEvent) => {
633
- const path = item.fullPath;
634
- if (e.metaKey || e.ctrlKey) {
635
- // Toggle individual item
636
- setSelectedPaths(prev => {
637
- const next = new Set(prev);
638
- if (next.has(path)) next.delete(path);
639
- else next.add(path);
640
- return next;
641
- });
642
- lastClickedRef.current = path;
643
- } else if (e.shiftKey && lastClickedRef.current) {
644
- // Range select
645
- const allPaths = allItems.map(i => i.fullPath);
646
- const anchorIdx = allPaths.indexOf(lastClickedRef.current);
647
- const currentIdx = allPaths.indexOf(path);
648
- if (anchorIdx >= 0 && currentIdx >= 0) {
649
- const [start, end] = anchorIdx < currentIdx ? [anchorIdx, currentIdx] : [currentIdx, anchorIdx];
650
- setSelectedPaths(prev => {
651
- const next = new Set(prev);
652
- for (let i = start; i <= end; i++) next.add(allPaths[i]);
653
- return next;
654
- });
655
- }
656
- } else {
657
- // Exclusive select
658
- setSelectedPaths(new Set([path]));
659
- lastClickedRef.current = path;
660
- // Also open preview if it's a file
661
- if (!item.isFolder) {
662
- setSelectedFile(item);
663
- if (item.downloadUrl) {
664
- setSelectedDownloadUrl(item.downloadUrl);
665
- } else {
666
- storageSourceRef.current.getSignedUrl(item.fullPath)
667
- .then(config => setSelectedDownloadUrl(config.url))
668
- .catch(() => setSelectedDownloadUrl(null));
669
- }
670
- } else {
671
- setSelectedFile(null);
672
- setSelectedDownloadUrl(null);
673
- }
674
- }
675
- }, [allItems]);
676
-
677
- // Double-click: open folder or preview file
678
- const handleItemDoubleClick = useCallback((item: StorageFile) => {
679
- if (item.isFolder) {
680
- handleNavigate(item.fullPath);
681
- } else {
682
- setSelectedFile(item);
683
- if (item.downloadUrl) {
684
- setSelectedDownloadUrl(item.downloadUrl);
685
- } else {
686
- storageSourceRef.current.getSignedUrl(item.fullPath)
687
- .then(config => setSelectedDownloadUrl(config.url))
688
- .catch(() => setSelectedDownloadUrl(null));
689
- }
690
- }
691
- }, [handleNavigate]);
692
-
693
- // Upload files
694
- const handleUpload = useCallback(async (uploadFiles: File[]) => {
695
- for (const file of uploadFiles) {
696
- const key = currentPath ? `${currentPath}/${file.name}` : file.name;
697
- await storageSourceRef.current.putObject({
698
- file,
699
- key
700
- });
701
- }
702
- snackbarController.open({
703
- type: "success",
704
- message: `${uploadFiles.length} file${uploadFiles.length > 1 ? "s" : ""} uploaded successfully`
705
- });
706
- await fetchContents(currentPath);
707
- }, [currentPath, snackbarController, fetchContents]);
708
-
709
- // Create new folder
710
- const handleCreateFolder = useCallback(async () => {
711
- if (!newFolderName.trim() || !apiConfig?.apiUrl) return;
712
-
713
- // Validate folder name
714
- const name = newFolderName.trim();
715
- if (name.includes("/") || name.includes("\\")) {
716
- snackbarController.open({ type: "error",
717
- message: "Folder name cannot contain slashes" });
718
- return;
719
- }
720
-
721
- // Check if folder already exists
722
- const existingFolder = folders.find(f => f.name === name);
723
- if (existingFolder) {
724
- snackbarController.open({ type: "error",
725
- message: `Folder "${name}" already exists` });
726
- return;
727
- }
728
-
729
- setCreatingFolder(true);
730
- try {
731
- const folderPath = currentPath ? `default/${currentPath}/${name}` : `default/${name}`;
732
- const token = apiConfig.getAuthToken ? await apiConfig.getAuthToken() : null;
733
- const response = await fetch(`${apiBase}/storage/folder`, {
734
- method: "POST",
735
- headers: {
736
- "Content-Type": "application/json",
737
- ...(token ? { "Authorization": `Bearer ${token}` } : {})
738
- },
739
- body: JSON.stringify({ path: folderPath })
740
- });
741
-
742
- if (!response.ok) {
743
- const err = await response.json().catch(() => ({ error: "Failed to create folder" }));
744
- throw new Error(err.error || "Failed to create folder");
745
- }
746
-
747
- snackbarController.open({ type: "success",
748
- message: `Folder "${name}" created` });
749
- setNewFolderDialogOpen(false);
750
- setNewFolderName("");
751
- await fetchContents(currentPath);
752
- } catch (e) {
753
- snackbarController.open({ type: "error",
754
- message: e instanceof Error ? e.message : String(e) });
755
- } finally {
756
- setCreatingFolder(false);
757
- }
758
- }, [newFolderName, currentPath, apiConfig, apiBase, snackbarController, fetchContents, folders]);
759
-
760
- // Drag-and-drop on main view
761
- const handleDropFiles = useCallback(async (droppedFiles: File[]) => {
762
- if (droppedFiles.length === 0) return;
763
- try {
764
- for (const file of droppedFiles) {
765
- const key = currentPath ? `${currentPath}/${file.name}` : file.name;
766
- await storageSourceRef.current.putObject({ file,
767
- key });
768
- }
769
- snackbarController.open({
770
- type: "success",
771
- message: `${droppedFiles.length} file${droppedFiles.length > 1 ? "s" : ""} uploaded successfully`
772
- });
773
- await fetchContents(currentPath);
774
- } catch (e) {
775
- snackbarController.open({
776
- type: "error",
777
- message: e instanceof Error ? e.message : String(e)
778
- });
779
- }
780
- }, [currentPath, snackbarController, fetchContents]);
781
-
782
- const {
783
- getRootProps: getDropRootProps,
784
- getInputProps: getDropInputProps,
785
- isDragActive
786
- } = useDropzone({
787
- onDrop: handleDropFiles,
788
- noClick: true,
789
- noKeyboard: true,
790
- noDragEventsBubbling: true
791
- });
792
-
793
- // ── Recursive folder delete helper ──
794
- const deleteFolderRecursive = useCallback(async (prefix: string) => {
795
- const result = await storageSourceRef.current.listObjects(prefix);
796
- // Delete all files in this level
797
- for (const item of result.items ?? []) {
798
- await storageSourceRef.current.deleteObject(item.fullPath);
799
- }
800
- // Recurse into sub-folders
801
- for (const sub of result.prefixes ?? []) {
802
- await deleteFolderRecursive(sub.fullPath);
803
- }
804
- // Delete the folder entry itself (needed for local filesystem)
805
- try {
806
- await storageSourceRef.current.deleteObject(prefix);
807
- } catch {
808
- // Ignore — S3 folders are virtual and may not exist as objects
809
- }
810
- }, []);
811
-
812
- // Delete a single file
813
- const handleDeleteFile = useCallback(async (file: StorageFile) => {
814
- try {
815
- if (file.isFolder) {
816
- await deleteFolderRecursive(file.fullPath);
817
- } else {
818
- await storageSourceRef.current.deleteObject(file.fullPath);
819
- }
820
- snackbarController.open({ type: "success",
821
- message: `"${file.name}" deleted` });
822
- setSelectedFile(null);
823
- setSelectedDownloadUrl(null);
824
- setSelectedPaths(prev => {
825
- const next = new Set(prev);
826
- next.delete(file.fullPath);
827
- return next;
828
- });
829
- fetchContents(currentPath);
830
- } catch (e) {
831
- snackbarController.open({ type: "error",
832
- message: e instanceof Error ? e.message : String(e) });
833
- }
834
- }, [currentPath, snackbarController, fetchContents, deleteFolderRecursive]);
835
-
836
- // Bulk delete (selected items)
837
- const handleBulkDelete = useCallback(async () => {
838
- setDeleting(true);
839
- try {
840
- const items = allItems.filter(i => selectedPaths.has(i.fullPath));
841
- for (const item of items) {
842
- if (item.isFolder) {
843
- await deleteFolderRecursive(item.fullPath);
844
- } else {
845
- await storageSourceRef.current.deleteObject(item.fullPath);
846
- }
847
- }
848
- snackbarController.open({ type: "success",
849
- message: `${items.length} item${items.length !== 1 ? "s" : ""} deleted` });
850
- setSelectedPaths(new Set());
851
- setSelectedFile(null);
852
- setSelectedDownloadUrl(null);
853
- await fetchContents(currentPath);
854
- } catch (e) {
855
- snackbarController.open({ type: "error",
856
- message: e instanceof Error ? e.message : String(e) });
857
- } finally {
858
- setDeleting(false);
859
- setDeleteDialogOpen(false);
860
- setDeleteDialogTarget(null);
861
- }
862
- }, [allItems, selectedPaths, currentPath, snackbarController, fetchContents, deleteFolderRecursive]);
863
-
864
- // Confirm delete for a single folder
865
- const handleConfirmDeleteFolder = useCallback(async () => {
866
- if (!deleteDialogTarget || deleteDialogTarget === "selection") return;
867
- setDeleting(true);
868
- try {
869
- await deleteFolderRecursive(deleteDialogTarget.fullPath);
870
- snackbarController.open({ type: "success",
871
- message: `Folder "${deleteDialogTarget.name}" deleted` });
872
- setSelectedPaths(prev => {
873
- const next = new Set(prev);
874
- next.delete(deleteDialogTarget.fullPath);
875
- return next;
876
- });
877
- await fetchContents(currentPath);
878
- } catch (e) {
879
- snackbarController.open({ type: "error",
880
- message: e instanceof Error ? e.message : String(e) });
881
- } finally {
882
- setDeleting(false);
883
- setDeleteDialogOpen(false);
884
- setDeleteDialogTarget(null);
885
- }
886
- }, [deleteDialogTarget, currentPath, snackbarController, fetchContents, deleteFolderRecursive]);
887
-
888
- // Select all / deselect
889
- const handleSelectAll = useCallback(() => {
890
- if (selectedPaths.size === allItems.length) {
891
- setSelectedPaths(new Set());
892
- } else {
893
- setSelectedPaths(new Set(allItems.map(i => i.fullPath)));
894
- }
895
- }, [allItems, selectedPaths]);
896
-
897
- // ── Keyboard shortcuts ──
898
- useEffect(() => {
899
- const handler = (e: KeyboardEvent) => {
900
- // Don't handle shortcuts when a dialog is open
901
- if (deleteDialogOpen || uploadDialogOpen || newFolderDialogOpen) return;
902
- // Cmd/Ctrl+A: select all
903
- if ((e.metaKey || e.ctrlKey) && e.key === "a") {
904
- e.preventDefault();
905
- handleSelectAll();
906
- }
907
- // Escape: deselect
908
- if (e.key === "Escape") {
909
- setSelectedPaths(new Set());
910
- setSelectedFile(null);
911
- setSelectedDownloadUrl(null);
912
- }
913
- // Delete / Backspace: delete selected
914
- if ((e.key === "Delete" || e.key === "Backspace") && selectedPaths.size > 0 && !e.metaKey && !e.ctrlKey) {
915
- // Don't trigger if user is typing in an input
916
- if ((e.target as HTMLElement)?.tagName === "INPUT" || (e.target as HTMLElement)?.tagName === "TEXTAREA") return;
917
- e.preventDefault();
918
- setDeleteDialogTarget("selection");
919
- setDeleteDialogOpen(true);
920
- }
921
- };
922
- window.addEventListener("keydown", handler);
923
- return () => window.removeEventListener("keydown", handler);
924
- }, [handleSelectAll, selectedPaths, deleteDialogOpen, uploadDialogOpen, newFolderDialogOpen]);
925
-
926
- // Handle refresh
927
- const handleRefresh = useCallback(() => {
928
- fetchContents(currentPath);
929
- }, [currentPath, fetchContents]);
930
-
931
- const segments = breadcrumbSegments(currentPath);
932
-
933
-
934
- // ── Render file grid/list ──
935
- const renderContents = () => {
936
- if (loading) {
937
- return (
938
- <div className="flex-grow flex items-center justify-center">
939
- <div className="text-center">
940
- <CircularProgress size="medium"/>
941
- <Typography variant="body2" className="mt-4 text-text-secondary dark:text-text-secondary-dark font-mono tracking-tight animate-pulse">
942
- Loading...
943
- </Typography>
944
- </div>
945
- </div>
946
- );
947
- }
948
-
949
- if (failure?.kind === "denied") {
950
- return (
951
- <div className="flex-grow flex items-center justify-center p-6 overflow-auto">
952
- <div className="max-w-md text-center">
953
- <Typography variant="subtitle2" className="block">
954
- This project&apos;s storage rules refused this listing
955
- </Typography>
956
- <Typography variant="body2" className="text-text-secondary dark:text-text-secondary-dark block mt-2">
957
- Nothing is wrong with the project. Its <code>storageAuthorize</code> hook
958
- decides who may read which keys, and it declined this path for the
959
- signed-in account — commonly because a listing must name a prefix the
960
- rule recognises rather than the bucket root.
961
- </Typography>
962
- <Typography variant="caption" className="text-text-disabled dark:text-text-disabled-dark block mt-3 font-mono break-all">
963
- {failure.detail}
964
- </Typography>
965
- </div>
966
- </div>
967
- );
968
- }
969
-
970
- if (failure) {
971
- return (
972
- <div className="flex-grow flex items-center justify-center p-6 overflow-auto">
973
- <ErrorView
974
- title="Could not read this project's storage"
975
- error={failure.detail}
976
- onRetry={failure.retryable ? handleRefresh : undefined}
977
- />
978
- </div>
979
- );
980
- }
981
-
982
-
983
- if (allItems.length === 0) {
984
- return (
985
- <div className="flex-grow flex items-center justify-center text-text-disabled dark:text-text-disabled-dark">
986
- <div className="text-center">
987
- <svg className="w-12 h-12 mx-auto mb-4 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
988
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/>
989
- </svg>
990
- <Typography variant="body2">
991
- This folder is empty
992
- </Typography>
993
- <div className="flex items-center gap-2 mt-3">
994
- <Button variant="text" onClick={() => {
995
- setNewFolderName("");
996
- setNewFolderDialogOpen(true);
997
- }}>
998
- <FolderPlusIcon size={iconSize.smallest}/>
999
- New folder
1000
- </Button>
1001
- <Button onClick={() => setUploadDialogOpen(true)}>
1002
- <PlusIcon size={iconSize.smallest}/>
1003
- Upload files
1004
- </Button>
1005
- </div>
1006
- </div>
1007
- </div>
1008
- );
1009
- }
1010
-
1011
- if (viewMode === "list") {
1012
- return (
1013
- <div className="flex-grow overflow-auto">
1014
- <table className="w-full">
1015
- <thead>
1016
- <tr className={cls("border-b text-left text-[10px] uppercase tracking-wider text-text-disabled dark:text-text-disabled-dark", defaultBorderMixin)}>
1017
- <th className="pl-3 pr-0 py-2 w-8">
1018
- <Checkbox
1019
- size="small"
1020
- checked={allItems.length > 0 && selectedPaths.size === allItems.length}
1021
- indeterminate={selectedPaths.size > 0 && selectedPaths.size < allItems.length}
1022
- onCheckedChange={handleSelectAll}
1023
- />
1024
- </th>
1025
- <th className="px-2 py-2 font-semibold">Name</th>
1026
- <th className="px-4 py-2 font-semibold w-24">Type</th>
1027
- <th className="px-4 py-2 font-semibold w-24 text-right">Size</th>
1028
- <th className="px-2 py-2 w-10"/>
1029
- </tr>
1030
- </thead>
1031
- <tbody>
1032
- {folders.map(folder => {
1033
- const isChecked = selectedPaths.has(folder.fullPath);
1034
- return (
1035
- <tr
1036
- key={folder.fullPath}
1037
- data-storage-item
1038
- className={cls(
1039
- "cursor-pointer transition-colors border-b group",
1040
- defaultBorderMixin,
1041
- isChecked
1042
- ? "bg-primary/5 dark:bg-primary/10"
1043
- : "hover:bg-surface-100 dark:hover:bg-surface-800"
1044
- )}
1045
- onClick={(e) => handleItemClick(folder, e)}
1046
- onDoubleClick={() => handleItemDoubleClick(folder)}
1047
- >
1048
- <td className="pl-3 pr-0 py-2.5" onClick={(e) => e.stopPropagation()}>
1049
- <Checkbox
1050
- size="small"
1051
- checked={isChecked}
1052
- onCheckedChange={() => {
1053
- setSelectedPaths(prev => {
1054
- const next = new Set(prev);
1055
- if (next.has(folder.fullPath)) next.delete(folder.fullPath);
1056
- else next.add(folder.fullPath);
1057
- return next;
1058
- });
1059
- }}
1060
- />
1061
- </td>
1062
- <td className="px-2 py-2.5">
1063
- <div className="flex items-center gap-2">
1064
- <FolderIcon size={iconSize.smallest} className="text-amber-500 dark:text-amber-400 shrink-0"/>
1065
- <Typography variant="body2" className="text-[13px] font-medium truncate">
1066
- {folder.name}
1067
- </Typography>
1068
- </div>
1069
- </td>
1070
- <td className="px-4 py-2.5">
1071
- <Typography variant="caption" className="text-text-secondary dark:text-text-secondary-dark">
1072
- Folder
1073
- </Typography>
1074
- </td>
1075
- <td className="px-4 py-2.5 text-right">
1076
- <Typography variant="caption" className="text-text-disabled dark:text-text-disabled-dark">
1077
-
1078
- </Typography>
1079
- </td>
1080
- <td className="px-2 py-2.5"/>
1081
- </tr>
1082
- );
1083
- })}
1084
- {files.map(file => {
1085
- const FileIconComp = getFileIcon(file.contentType);
1086
- const isChecked = selectedPaths.has(file.fullPath);
1087
- return (
1088
- <tr
1089
- key={file.fullPath}
1090
- data-storage-item
1091
- className={cls(
1092
- "cursor-pointer transition-colors border-b group",
1093
- defaultBorderMixin,
1094
- isChecked
1095
- ? "bg-primary/5 dark:bg-primary/10"
1096
- : "hover:bg-surface-100 dark:hover:bg-surface-800"
1097
- )}
1098
- onClick={(e) => handleItemClick(file, e)}
1099
- onDoubleClick={() => handleItemDoubleClick(file)}
1100
- >
1101
- <td className="pl-3 pr-0 py-2.5" onClick={(e) => e.stopPropagation()}>
1102
- <Checkbox
1103
- size="small"
1104
- checked={isChecked}
1105
- onCheckedChange={() => {
1106
- setSelectedPaths(prev => {
1107
- const next = new Set(prev);
1108
- if (next.has(file.fullPath)) next.delete(file.fullPath);
1109
- else next.add(file.fullPath);
1110
- return next;
1111
- });
1112
- }}
1113
- />
1114
- </td>
1115
- <td className="px-2 py-2.5">
1116
- <div className="flex items-center gap-2">
1117
- <FileIconComp size={iconSize.smallest} className="text-surface-accent-400 shrink-0"/>
1118
- <Typography variant="body2" className="text-[13px] truncate">
1119
- {file.name}
1120
- </Typography>
1121
- </div>
1122
- </td>
1123
- <td className="px-4 py-2.5">
1124
- <Typography variant="caption" className="text-text-secondary dark:text-text-secondary-dark">
1125
- {getExtension(file.name) || file.contentType?.split("/")[1]?.toUpperCase() || "—"}
1126
- </Typography>
1127
- </td>
1128
- <td className="px-4 py-2.5 text-right">
1129
- <Typography variant="caption" className="text-text-secondary dark:text-text-secondary-dark font-mono text-[11px]">
1130
- {file.size !== undefined ? formatFileSize(file.size) : "—"}
1131
- </Typography>
1132
- </td>
1133
- <td className="px-2 py-2.5" onClick={(e) => e.stopPropagation()}>
1134
- <IconButton
1135
- size="smallest"
1136
- className="opacity-0 group-hover:opacity-100 transition-opacity"
1137
- onClick={() => handleDeleteFile(file)}
1138
- >
1139
- <Trash2Icon size={14}/>
1140
- </IconButton>
1141
- </td>
1142
- </tr>
1143
- );
1144
- })}
1145
- </tbody>
1146
- </table>
1147
- </div>
1148
- );
1149
- }
1150
-
1151
- // Grid view
1152
- return (
1153
- <div className="flex-grow overflow-auto p-4">
1154
- {/* Folder cards */}
1155
- {folders.length > 0 && (
1156
- <div className="mb-4">
1157
- <Typography variant="caption" className="text-[10px] uppercase tracking-wider font-semibold text-text-disabled dark:text-text-disabled-dark mb-2 block">
1158
- Folders
1159
- </Typography>
1160
- <div className="grid gap-3 grid-cols-[repeat(auto-fill,minmax(140px,1fr))]">
1161
- {folders.map(folder => {
1162
- const isChecked = selectedPaths.has(folder.fullPath);
1163
- return (
1164
- <div
1165
- key={folder.fullPath}
1166
- data-storage-item
1167
- className={cls(
1168
- "rounded-lg p-3 cursor-pointer border",
1169
- "transition-colors duration-150",
1170
- defaultBorderMixin,
1171
- "hover:bg-surface-100 dark:hover:bg-surface-800 hover:shadow-sm",
1172
- "flex items-center gap-2",
1173
- isChecked && "ring-2 ring-primary bg-primary/5 dark:bg-primary/10"
1174
- )}
1175
- onClick={(e) => handleItemClick(folder, e)}
1176
- onDoubleClick={() => handleItemDoubleClick(folder)}
1177
- >
1178
- <FolderIcon size={iconSize.smallest} className="text-amber-500 dark:text-amber-400 shrink-0"/>
1179
- <Typography variant="body2" className="text-[13px] font-medium truncate">
1180
- {folder.name}
1181
- </Typography>
1182
- </div>
1183
- );
1184
- })}
1185
- </div>
1186
- </div>
1187
- )}
1188
-
1189
- {/* FileIcon cards */}
1190
- {files.length > 0 && (
1191
- <div>
1192
- <Typography variant="caption" className="text-[10px] uppercase tracking-wider font-semibold text-text-disabled dark:text-text-disabled-dark mb-2 block">
1193
- Files ({files.length})
1194
- </Typography>
1195
- <div className="grid gap-3 grid-cols-[repeat(auto-fill,minmax(140px,1fr))]">
1196
- {files.map(file => {
1197
- const FileIconComp = getFileIcon(file.contentType);
1198
- const ext = getExtension(file.name)?.toLowerCase() || "";
1199
- const isImage = file.contentType?.startsWith("image/") || ["jpg", "jpeg", "png", "gif", "webp", "svg"].includes(ext);
1200
- const isChecked = selectedPaths.has(file.fullPath);
1201
-
1202
- return (
1203
- <div
1204
- key={file.fullPath}
1205
- data-storage-item
1206
- className={cls(
1207
- "rounded-lg overflow-hidden cursor-pointer border",
1208
- "transition-shadow duration-150",
1209
- defaultBorderMixin,
1210
- "hover:shadow-md",
1211
- isChecked && "ring-2 ring-primary"
1212
- )}
1213
- onClick={(e) => handleItemClick(file, e)}
1214
- onDoubleClick={() => handleItemDoubleClick(file)}
1215
- >
1216
- {/* Thumbnail or icon */}
1217
- <div className="aspect-square relative overflow-hidden bg-surface-100 dark:bg-surface-800 flex items-center justify-center">
1218
- {isImage && file.downloadUrl ? (
1219
- <img
1220
- src={file.downloadUrl}
1221
- alt={file.name}
1222
- className="w-full h-full object-cover"
1223
- loading="lazy"
1224
- />
1225
- ) : (
1226
- <FileIconComp className="text-surface-accent-400 dark:text-surface-accent-500 w-8 h-8"/>
1227
- )}
1228
-
1229
- {/* Extension badge */}
1230
- {getExtension(file.name) && (
1231
- <div className="absolute bottom-1.5 right-1.5 px-1.5 py-0.5 rounded text-[9px] font-semibold uppercase bg-black/50 text-white backdrop-blur-sm">
1232
- {getExtension(file.name)}
1233
- </div>
1234
- )}
1235
- </div>
1236
-
1237
- {/* Name & size */}
1238
- <div className="p-2.5">
1239
- <Typography variant="body2" className="text-[12px] font-medium truncate text-surface-900 dark:text-white">
1240
- {file.name}
1241
- </Typography>
1242
- <Typography variant="caption" color="secondary" className="truncate block mt-0.5 text-[11px]">
1243
- {file.size !== undefined ? formatFileSize(file.size) : "—"}
1244
- </Typography>
1245
- </div>
1246
- </div>
1247
- );
1248
- })}
1249
- </div>
1250
- </div>
1251
- )}
1252
- </div>
1253
- );
1254
- };
1255
-
1256
- return (
1257
- <div className="flex h-full w-full bg-white dark:bg-surface-800 overflow-hidden text-text-primary dark:text-text-primary-dark">
1258
- <div className="flex h-full w-full">
1259
- {/* Main content */}
1260
- <div className="flex-grow flex flex-col min-w-0 h-full">
1261
- {/* Toolbar */}
1262
- <div className={cls("flex items-center justify-between pr-2 border-b bg-white dark:bg-surface-800 shrink-0 h-10", defaultBorderMixin)}>
1263
- <div className="flex items-center gap-1.5 flex-grow overflow-hidden px-3 py-2">
1264
- {/* Breadcrumbs — always visible */}
1265
- {currentPath && (
1266
- <Tooltip title="Go up">
1267
- <IconButton size="small" onClick={handleNavigateUp}>
1268
- <ArrowLeftIcon size={iconSize.smallest}/>
1269
- </IconButton>
1270
- </Tooltip>
1271
- )}
1272
- <div className="flex items-center gap-0.5 overflow-x-auto no-scrollbar">
1273
- {segments.map((seg, i) => (
1274
- <React.Fragment key={seg.path}>
1275
- {i > 0 && (
1276
- <Typography variant="caption" className="text-text-disabled dark:text-text-disabled-dark mx-0.5">/</Typography>
1277
- )}
1278
- <Button
1279
- variant="text"
1280
- size="small"
1281
- className={cls(
1282
- "px-1.5 py-0.5 min-h-0 min-w-0 h-6 text-xs whitespace-nowrap normal-case font-normal",
1283
- i === segments.length - 1
1284
- ? "text-text-primary dark:text-text-primary-dark font-medium"
1285
- : "text-text-secondary dark:text-text-secondary-dark"
1286
- )}
1287
- onClick={() => handleNavigate(seg.path)}
1288
- >
1289
- {seg.label}
1290
- </Button>
1291
- </React.Fragment>
1292
- ))}
1293
- </div>
1294
-
1295
- <div className="flex-1"/>
1296
-
1297
- {/* Selection actions or file count */}
1298
- {selectedPaths.size > 0 ? (
1299
- <div className="flex items-center gap-1.5 shrink-0">
1300
- <Typography variant="body2" className="text-[13px] font-medium whitespace-nowrap">
1301
- {selectedPaths.size} selected
1302
- </Typography>
1303
- <Button
1304
- size="small"
1305
- variant="text"
1306
- onClick={() => {
1307
- setDeleteDialogTarget("selection");
1308
- setDeleteDialogOpen(true);
1309
- }}
1310
- >
1311
- <Trash2Icon size={14} className="mr-1"/>
1312
- Delete
1313
- </Button>
1314
- <Button
1315
- size="small"
1316
- variant="text"
1317
- onClick={() => {
1318
- setSelectedPaths(new Set());
1319
- setSelectedFile(null);
1320
- setSelectedDownloadUrl(null);
1321
- }}
1322
- >
1323
- <XIcon size={14} className="mr-1"/>
1324
- Deselect
1325
- </Button>
1326
- </div>
1327
- ) : !loading ? (
1328
- <Chip size="small" className="shrink-0 text-[10px]">
1329
- {files.length} file{files.length !== 1 ? "s" : ""}
1330
- {folders.length > 0 ? `, ${folders.length} folder${folders.length !== 1 ? "s" : ""}` : ""}
1331
- </Chip>
1332
- ) : null}
1333
- </div>
1334
-
1335
- <div className="flex shrink-0 items-center justify-end gap-1.5 pr-1">
1336
-
1337
- {/* Backend picker — only shown when more than one storage source is available */}
1338
- {sourceKeys.length > 1 && (
1339
- <Select
1340
- size="small"
1341
- position="item-aligned"
1342
- value={selectedSourceKey}
1343
- onValueChange={(value) => {
1344
- if (value) setSelectedSourceKey(value);
1345
- }}
1346
- renderValue={(key) => {
1347
- const label = storageSources.registry[key]?.label;
1348
- return label ?? (key === DEFAULT_STORAGE_SOURCE_KEY ? "Default" : key);
1349
- }}>
1350
- {sourceKeys.map((key) => (
1351
- <SelectItem key={key} value={key}>
1352
- {storageSources.registry[key]?.label
1353
- ?? (key === DEFAULT_STORAGE_SOURCE_KEY ? "Default" : key)}
1354
- </SelectItem>
1355
- ))}
1356
- </Select>
1357
- )}
1358
-
1359
- <Tooltip title="Grid view">
1360
- <IconButton
1361
- size="small"
1362
- onClick={() => setViewMode("grid")}
1363
- className={cls(viewMode === "grid" && "bg-surface-100 dark:bg-surface-800")}
1364
- >
1365
- <LayoutGridIcon size={iconSize.smallest}/>
1366
- </IconButton>
1367
- </Tooltip>
1368
- <Tooltip title="List view">
1369
- <IconButton
1370
- size="small"
1371
- onClick={() => setViewMode("list")}
1372
- className={cls(viewMode === "list" && "bg-surface-100 dark:bg-surface-800")}
1373
- >
1374
- <ListIcon size={iconSize.smallest}/>
1375
- </IconButton>
1376
- </Tooltip>
1377
-
1378
- <div className={cls("h-4 w-px mx-0.5", defaultBorderMixin, "bg-surface-200 dark:bg-surface-700")}/>
1379
-
1380
- <Tooltip title="Refresh">
1381
- <IconButton size="small" onClick={handleRefresh} disabled={loading}>
1382
- <RefreshCwIcon size={iconSize.smallest}/>
1383
- </IconButton>
1384
- </Tooltip>
1385
-
1386
- <Tooltip title="New folder">
1387
- <IconButton
1388
- size="small"
1389
- onClick={() => {
1390
- setNewFolderName("");
1391
- setNewFolderDialogOpen(true);
1392
- }}
1393
- >
1394
- <FolderPlusIcon size={iconSize.smallest}/>
1395
- </IconButton>
1396
- </Tooltip>
1397
- <Button
1398
- size="small"
1399
- color="primary"
1400
- onClick={() => setUploadDialogOpen(true)}
1401
- >
1402
- <UploadCloudIcon size={iconSize.smallest} className="mr-1"/>
1403
- Upload
1404
- </Button>
1405
- </div>
1406
- </div>
1407
-
1408
- {/* File grid / list — drop zone */}
1409
- <div {...getDropRootProps()}
1410
- className="flex-grow flex flex-col overflow-hidden min-h-0 relative"
1411
- onClick={(e) => {
1412
- const target = e.target as HTMLElement;
1413
- if (!target.closest("[data-storage-item]") && selectedPaths.size > 0) {
1414
- setSelectedPaths(new Set());
1415
- setSelectedFile(null);
1416
- setSelectedDownloadUrl(null);
1417
- }
1418
- }}
1419
- >
1420
- <input {...getDropInputProps()} />
1421
- {renderContents()}
1422
- {/* Drag overlay */}
1423
- {isDragActive && (
1424
- <div className="absolute inset-0 z-10 flex items-center justify-center bg-primary/5 dark:bg-primary/10 backdrop-blur-[2px]">
1425
- <div className="flex flex-col items-center gap-2 p-6 rounded-xl border-2 border-dashed border-primary bg-white/80 dark:bg-surface-900/80">
1426
- <UploadCloudIcon className="w-10 h-10 text-primary"/>
1427
- <Typography variant="subtitle2" className="text-primary font-semibold">
1428
- Drop files to upload
1429
- </Typography>
1430
- <Typography variant="caption" color="secondary">
1431
- to /{currentPath || "root"}
1432
- </Typography>
1433
- </div>
1434
- </div>
1435
- )}
1436
- </div>
1437
-
1438
- {/* Status bar */}
1439
- <div className={cls("px-4 py-1.5 border-t bg-surface-50 dark:bg-surface-800 flex items-center justify-between shrink-0", defaultBorderMixin)}>
1440
- <div className="flex items-center gap-4 text-[11px]">
1441
- <span className="text-text-disabled dark:text-text-disabled-dark font-semibold uppercase tracking-tighter">
1442
- Path
1443
- </span>
1444
- <span className="font-mono text-text-secondary dark:text-text-secondary-dark">
1445
- /{currentPath || ""}
1446
- </span>
1447
- </div>
1448
- {selectedPaths.size > 0 ? (
1449
- <div className="text-[11px] text-text-secondary dark:text-text-secondary-dark">
1450
- {selectedPaths.size} item{selectedPaths.size !== 1 ? "s" : ""} selected
1451
- </div>
1452
- ) : selectedFile ? (
1453
- <div className="text-[11px] text-text-secondary dark:text-text-secondary-dark">
1454
- Selected: <span className="font-mono">{selectedFile.name}</span>
1455
- </div>
1456
- ) : null}
1457
- </div>
1458
- </div>
1459
-
1460
- {/* Preview panel */}
1461
- {selectedFile && (
1462
- <div className="w-80 lg:w-96 shrink-0">
1463
- <FilePreviewPanel
1464
- file={selectedFile}
1465
- downloadUrl={selectedDownloadUrl}
1466
- onClose={() => {
1467
- setSelectedFile(null);
1468
- setSelectedDownloadUrl(null);
1469
- }}
1470
- onDelete={() => handleDeleteFile(selectedFile)}
1471
- />
1472
- </div>
1473
- )}
1474
- </div>
1475
-
1476
- {/* Upload Dialog */}
1477
- <UploadDialog
1478
- open={uploadDialogOpen}
1479
- currentPath={currentPath}
1480
- onClose={() => setUploadDialogOpen(false)}
1481
- onUpload={handleUpload}
1482
- />
1483
-
1484
- {/* Delete confirmation dialog */}
1485
- <Dialog
1486
- open={deleteDialogOpen}
1487
- onOpenChange={(open) => {
1488
- if (!open && !deleting) {
1489
- setDeleteDialogOpen(false);
1490
- setDeleteDialogTarget(null);
1491
- }
1492
- }}
1493
- >
1494
- <DialogTitle hidden>Delete Confirmation</DialogTitle>
1495
- <DialogContent>
1496
- <Typography variant="subtitle1" className="font-semibold mb-2">
1497
- {deleteDialogTarget === "selection"
1498
- ? `Delete ${selectedPaths.size} item${selectedPaths.size !== 1 ? "s" : ""}?`
1499
- : deleteDialogTarget
1500
- ? `Delete folder "${deleteDialogTarget.name}"?`
1501
- : "Delete?"}
1502
- </Typography>
1503
- <Typography variant="body2" color="secondary">
1504
- {deleteDialogTarget === "selection"
1505
- ? "This will permanently delete all selected files and folders, including their contents. This action cannot be undone."
1506
- : "This will permanently delete the folder and all of its contents. This action cannot be undone."}
1507
- </Typography>
1508
- </DialogContent>
1509
- <DialogActions>
1510
- <Button
1511
- variant="text"
1512
- onClick={() => {
1513
- setDeleteDialogOpen(false);
1514
- setDeleteDialogTarget(null);
1515
- }}
1516
- disabled={deleting}
1517
- >
1518
- Cancel
1519
- </Button>
1520
- <LoadingButton
1521
- color="error"
1522
- loading={deleting}
1523
- onClick={deleteDialogTarget === "selection" ? handleBulkDelete : handleConfirmDeleteFolder}
1524
- >
1525
- <Trash2Icon size={14} className="mr-1"/>
1526
- Delete
1527
- </LoadingButton>
1528
- </DialogActions>
1529
- </Dialog>
1530
-
1531
- {/* New Folder Dialog */}
1532
- <Dialog
1533
- open={newFolderDialogOpen}
1534
- onOpenChange={(open) => {
1535
- if (!open && !creatingFolder) {
1536
- setNewFolderDialogOpen(false);
1537
- setNewFolderName("");
1538
- }
1539
- }}
1540
- >
1541
- <DialogTitle hidden>New Folder</DialogTitle>
1542
- <DialogContent>
1543
- <TextField
1544
- autoFocus
1545
- size="small"
1546
- label="Folder name"
1547
- value={newFolderName}
1548
- onChange={(e) => setNewFolderName(e.target.value)}
1549
- onKeyDown={(e) => {
1550
- if (e.key === "Enter" && newFolderName.trim()) {
1551
- e.preventDefault();
1552
- handleCreateFolder();
1553
- }
1554
- }}
1555
- disabled={creatingFolder}
1556
- placeholder="Enter folder name"
1557
- />
1558
- {currentPath && (
1559
- <Typography variant="caption" color="secondary" className="mt-2">
1560
- Will be created in <span className="font-mono">/{currentPath}/</span>
1561
- </Typography>
1562
- )}
1563
- </DialogContent>
1564
- <DialogActions>
1565
- <Button
1566
- variant="text"
1567
- onClick={() => {
1568
- setNewFolderDialogOpen(false);
1569
- setNewFolderName("");
1570
- }}
1571
- disabled={creatingFolder}
1572
- >
1573
- Cancel
1574
- </Button>
1575
- <LoadingButton
1576
- color="primary"
1577
- loading={creatingFolder}
1578
- disabled={!newFolderName.trim()}
1579
- onClick={handleCreateFolder}
1580
- >
1581
- <FolderPlusIcon size={14} className="mr-1"/>
1582
- Create
1583
- </LoadingButton>
1584
- </DialogActions>
1585
- </Dialog>
1586
- </div>
1587
- );
1588
- };