@rebasepro/studio 0.0.1-canary.eae7889 → 0.1.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 (66) hide show
  1. package/dist/{ApiExplorer-gMJt5JrS.js → ApiExplorer-DHVmWYfK.js} +13 -13
  2. package/dist/ApiExplorer-DHVmWYfK.js.map +1 -0
  3. package/dist/AuthSimulationSelector-CM488Eei.js +106 -0
  4. package/dist/AuthSimulationSelector-CM488Eei.js.map +1 -0
  5. package/dist/{JSEditor-D8nVp3Lp.js → JSEditor-CSHA0t_O.js} +2 -2
  6. package/dist/{JSEditor-D8nVp3Lp.js.map → JSEditor-CSHA0t_O.js.map} +1 -1
  7. package/dist/{RLSEditor-DBH09u9v.js → RLSEditor-BzDjqo6w.js} +46 -5
  8. package/dist/RLSEditor-BzDjqo6w.js.map +1 -0
  9. package/dist/{SQLEditor-CkVx9vgr.js → SQLEditor-Cr9Kg_Qg.js} +63 -75
  10. package/dist/SQLEditor-Cr9Kg_Qg.js.map +1 -0
  11. package/dist/{SchemaVisualizer-BgD5Zb77.js → SchemaVisualizer-BGpmzyXT.js} +5 -5
  12. package/dist/SchemaVisualizer-BGpmzyXT.js.map +1 -0
  13. package/dist/StorageView-BYoslzBR.js +870 -0
  14. package/dist/StorageView-BYoslzBR.js.map +1 -0
  15. package/dist/core/src/components/BootstrapAdminBanner.d.ts +4 -0
  16. package/dist/core/src/components/LoginView/LoginView.d.ts +22 -0
  17. package/dist/core/src/components/common/useDataTableController.d.ts +3 -3
  18. package/dist/core/src/components/index.d.ts +1 -0
  19. package/dist/core/src/hooks/data/useRelationSelector.d.ts +2 -2
  20. package/dist/core/src/hooks/index.d.ts +1 -0
  21. package/dist/core/src/hooks/useCollapsedGroups.d.ts +16 -1
  22. package/dist/core/src/hooks/useResolvedComponent.d.ts +47 -0
  23. package/dist/index.es.js +79 -71
  24. package/dist/index.es.js.map +1 -1
  25. package/dist/index.umd.js +821 -834
  26. package/dist/index.umd.js.map +1 -1
  27. package/dist/types/src/controllers/auth.d.ts +8 -2
  28. package/dist/types/src/controllers/client.d.ts +13 -0
  29. package/dist/types/src/controllers/collection_registry.d.ts +2 -1
  30. package/dist/types/src/controllers/data_driver.d.ts +36 -1
  31. package/dist/types/src/controllers/navigation.d.ts +18 -6
  32. package/dist/types/src/controllers/registry.d.ts +9 -1
  33. package/dist/types/src/controllers/side_entity_controller.d.ts +7 -0
  34. package/dist/types/src/rebase_context.d.ts +17 -0
  35. package/dist/types/src/types/backend_hooks.d.ts +187 -0
  36. package/dist/types/src/types/collections.d.ts +31 -11
  37. package/dist/types/src/types/component_ref.d.ts +47 -0
  38. package/dist/types/src/types/cron.d.ts +1 -1
  39. package/dist/types/src/types/entity_views.d.ts +6 -7
  40. package/dist/types/src/types/formex.d.ts +40 -0
  41. package/dist/types/src/types/index.d.ts +3 -0
  42. package/dist/types/src/types/plugins.d.ts +6 -3
  43. package/dist/types/src/types/properties.d.ts +72 -88
  44. package/dist/types/src/types/slots.d.ts +20 -10
  45. package/dist/types/src/types/translations.d.ts +6 -0
  46. package/dist/ui/src/components/FileUpload.d.ts +1 -1
  47. package/dist/ui/src/components/SearchBar.d.ts +5 -1
  48. package/dist/ui/src/styles.d.ts +2 -2
  49. package/package.json +10 -10
  50. package/src/components/ApiExplorer/ApiExplorer.tsx +7 -5
  51. package/src/components/ApiExplorer/TryItPanel.tsx +29 -53
  52. package/src/components/AuthSimulationSelector.tsx +13 -17
  53. package/src/components/RLSEditor/RLSEditor.tsx +82 -3
  54. package/src/components/SQLEditor/SQLEditor.tsx +16 -18
  55. package/src/components/SQLEditor/SchemaBrowser.tsx +6 -8
  56. package/src/components/SchemaVisualizer/SchemaVisualizer.tsx +20 -22
  57. package/src/components/StorageView/StorageView.tsx +719 -304
  58. package/src/components/StudioHomePage.tsx +4 -1
  59. package/dist/ApiExplorer-gMJt5JrS.js.map +0 -1
  60. package/dist/AuthSimulationSelector-BF4rkRGp.js +0 -118
  61. package/dist/AuthSimulationSelector-BF4rkRGp.js.map +0 -1
  62. package/dist/RLSEditor-DBH09u9v.js.map +0 -1
  63. package/dist/SQLEditor-CkVx9vgr.js.map +0 -1
  64. package/dist/SchemaVisualizer-BgD5Zb77.js.map +0 -1
  65. package/dist/StorageView-CTqGFhY9.js +0 -907
  66. package/dist/StorageView-CTqGFhY9.js.map +0 -1
@@ -1,9 +1,11 @@
1
1
 
2
- import React, { useState, useEffect, useCallback, useMemo } from "react";
3
- import { Typography, cls, defaultBorderMixin, Button, IconButton, Tooltip, CircularProgress, ResizablePanels, Chip, Dialog, DialogContent, DialogActions, FileUpload , iconSize } from "@rebasepro/ui";
4
- import { VideoIcon, Music2Icon, RefreshCwIcon, Trash2Icon, XIcon, PlusIcon, DownloadIcon, UploadCloudIcon, FolderIcon, FileTextIcon, ImageIcon, ArrowLeftIcon, FileIcon, HomeIcon, LayoutGridIcon, ListIcon } from "lucide-react";
5
- import { useStorageSource, useSnackbarController, ErrorView } from "@rebasepro/core";
2
+ import React, { useState, useEffect, useCallback, useMemo, useRef } from "react";
3
+ import { Typography, cls, defaultBorderMixin, Button, IconButton, Tooltip, CircularProgress, ResizablePanels, Chip, Dialog, DialogTitle, DialogContent, DialogActions, FileUpload, iconSize, Checkbox, LoadingButton, TextField } from "@rebasepro/ui";
4
+ import { VideoIcon, Music2Icon, RefreshCwIcon, Trash2Icon, XIcon, PlusIcon, DownloadIcon, UploadCloudIcon, FolderIcon, FolderPlusIcon, FileTextIcon, ImageIcon, ArrowLeftIcon, FileIcon, HomeIcon, LayoutGridIcon, ListIcon, CopyIcon, CheckIcon } from "lucide-react";
5
+ import { useStorageSource, useSnackbarController, ErrorView, useApiConfig } from "@rebasepro/core";
6
6
  import type { StorageListResult } from "@rebasepro/types";
7
+ import { useSearchParams } from "react-router-dom";
8
+ import { useDropzone } from "react-dropzone";
7
9
 
8
10
  // ──────────────────────────────────────────────
9
11
  // Types
@@ -110,78 +112,69 @@ function UploadDialog({
110
112
 
111
113
  return (
112
114
  <Dialog open={open} onOpenChange={(o) => !o && handleClose()} maxWidth="md">
113
- <DialogContent className="p-0">
114
- <div className="p-4 border-b border-surface-accent-200 dark:border-surface-accent-700">
115
- <Typography variant="h6">Upload Files</Typography>
116
- <Typography variant="caption" className="text-text-secondary dark:text-text-secondary-dark mt-1 block">
117
- to <span className="font-mono text-primary">/{currentPath || "root"}</span>
118
- </Typography>
119
- </div>
115
+ <DialogTitle>
116
+ Upload Files
117
+ <Typography variant="caption" className="text-text-secondary dark:text-text-secondary-dark mt-0.5 block">
118
+ to <span className="font-mono text-primary">/{currentPath || "root"}</span>
119
+ </Typography>
120
+ </DialogTitle>
121
+ <DialogContent className="space-y-4">
122
+ <FileUpload
123
+ onFilesAdded={handleFilesAdded}
124
+ size="large"
125
+ uploadDescription={
126
+ <div className="flex flex-col items-center justify-center pointer-events-none">
127
+ <UploadCloudIcon className="text-surface-accent-400 mb-2 w-8 h-8"/>
128
+ <Typography variant="label">
129
+ Drop files here or click to browse
130
+ </Typography>
131
+ <Typography variant="caption" color="secondary">
132
+ Any file type supported
133
+ </Typography>
134
+ </div>
135
+ }
136
+ />
120
137
 
121
- <div className="p-4">
122
- {/* Drop Zone */}
123
- <FileUpload
124
- accept={{} as Record<string, string[]>}
125
- onFilesAdded={handleFilesAdded}
126
- size="large"
127
- uploadDescription={
128
- <div className="flex flex-col items-center justify-center pointer-events-none mt-2">
129
- <UploadCloudIcon className="text-surface-accent-400 mb-2 w-8 h-8"/>
130
- <Typography variant="h6" className="font-bold">
131
- Drop files here or click to browse
132
- </Typography>
133
- <Typography variant="caption" className="text-surface-accent-500 font-medium">
134
- Any file type supported
135
- </Typography>
136
- </div>
137
- }
138
- />
138
+ {error && (
139
+ <Typography variant="caption" className="text-red-500 block whitespace-pre-line">
140
+ {error}
141
+ </Typography>
142
+ )}
139
143
 
140
- {error && (
141
- <Typography variant="caption" className="text-red-500 mt-2 block whitespace-pre-line">
142
- {error}
144
+ {selectedFiles.length > 0 && (
145
+ <div className="space-y-2">
146
+ <Typography variant="caption" color="secondary">
147
+ Selected files ({selectedFiles.length})
143
148
  </Typography>
144
- )}
145
-
146
- {selectedFiles.length > 0 && (
147
- <div className="mt-4 space-y-2">
148
- <Typography variant="caption" className="text-surface-accent-500">
149
- Selected files ({selectedFiles.length})
150
- </Typography>
151
- <div className="max-h-40 overflow-auto space-y-1">
152
- {selectedFiles.map((file, index) => (
153
- <div
154
- key={`${file.name}-${index}`}
155
- className={cls(
156
- "flex items-center justify-between p-2 rounded",
157
- "bg-surface-accent-50 dark:bg-surface-accent-800"
158
- )}
159
- >
160
- <div className="flex-1 min-w-0 mr-2">
161
- <Typography variant="body2" className="truncate">
162
- {file.name}
163
- </Typography>
164
- <Typography variant="caption" className="text-surface-accent-500">
165
- {formatFileSize(file.size)}
166
- </Typography>
167
- </div>
168
- <Button
169
- variant="text"
170
- size="small"
171
- onClick={(e) => {
172
- e.stopPropagation();
173
- handleRemoveFile(index);
174
- }}
175
- disabled={uploading}
176
- >
177
- Remove
178
- </Button>
149
+ <div className="max-h-40 overflow-auto space-y-1">
150
+ {selectedFiles.map((file, index) => (
151
+ <div
152
+ key={`${file.name}-${index}`}
153
+ className="flex items-center justify-between p-2 rounded bg-surface-100 dark:bg-surface-800"
154
+ >
155
+ <div className="flex-1 min-w-0 mr-2">
156
+ <Typography variant="body2" className="truncate">
157
+ {file.name}
158
+ </Typography>
159
+ <Typography variant="caption" color="secondary">
160
+ {formatFileSize(file.size)}
161
+ </Typography>
179
162
  </div>
180
- ))}
181
- </div>
163
+ <IconButton
164
+ size="small"
165
+ onClick={(e) => {
166
+ e.stopPropagation();
167
+ handleRemoveFile(index);
168
+ }}
169
+ disabled={uploading}
170
+ >
171
+ <XIcon size={14}/>
172
+ </IconButton>
173
+ </div>
174
+ ))}
182
175
  </div>
183
- )}
184
- </div>
176
+ </div>
177
+ )}
185
178
  </DialogContent>
186
179
 
187
180
  <DialogActions>
@@ -192,15 +185,9 @@ function UploadDialog({
192
185
  variant="filled"
193
186
  onClick={handleUpload}
194
187
  disabled={selectedFiles.length === 0 || uploading}
188
+ startIcon={uploading ? <CircularProgress size="smallest"/> : <UploadCloudIcon size={14}/>}
195
189
  >
196
- {uploading ? (
197
- <>
198
- <CircularProgress size="smallest"/>
199
- Uploading...
200
- </>
201
- ) : (
202
- `Upload ${selectedFiles.length > 0 ? `(${selectedFiles.length})` : ""}`
203
- )}
190
+ {uploading ? "Uploading..." : `Upload${selectedFiles.length > 0 ? ` (${selectedFiles.length})` : ""}`}
204
191
  </Button>
205
192
  </DialogActions>
206
193
  </Dialog>
@@ -227,13 +214,14 @@ function FilePreviewPanel({
227
214
  const isAudio = file.contentType?.startsWith("audio/");
228
215
  const FileIconComponent = getFileIcon(file.contentType);
229
216
  const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
217
+ const [urlCopied, setUrlCopied] = useState(false);
230
218
 
231
219
  return (
232
220
  <>
233
221
  <div className={cls(
234
222
  "flex flex-col h-full border-l",
235
223
  defaultBorderMixin,
236
- "bg-white dark:bg-surface-950"
224
+ "bg-white dark:bg-surface-800"
237
225
  )}>
238
226
  {/* Header */}
239
227
  <div className={cls("flex items-center justify-between p-3 border-b shrink-0", defaultBorderMixin)}>
@@ -268,7 +256,7 @@ function FilePreviewPanel({
268
256
 
269
257
  {/* Preview */}
270
258
  <div className="flex-1 overflow-auto">
271
- <div className="flex flex-col items-center justify-center min-h-[200px] p-4 bg-surface-50 dark:bg-surface-900 border-b border-surface-accent-200 dark:border-surface-accent-700">
259
+ <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)}>
272
260
  {(() => {
273
261
  const ext = getExtension(file.name)?.toLowerCase() || "";
274
262
  const isImage = file.contentType?.startsWith("image/") || ["jpg", "jpeg", "png", "gif", "webp", "svg"].includes(ext);
@@ -371,17 +359,36 @@ function FilePreviewPanel({
371
359
  URL
372
360
  </Typography>
373
361
  <div
374
- className="p-2 rounded bg-surface-100 dark:bg-surface-950 cursor-pointer hover:bg-surface-200 dark:hover:bg-surface-700 transition-colors"
362
+ className={cls(
363
+ "flex items-center gap-2 p-2 rounded cursor-pointer transition-colors",
364
+ "bg-surface-100 dark:bg-surface-800 hover:bg-surface-200 dark:hover:bg-surface-700"
365
+ )}
375
366
  onClick={() => {
376
- navigator.clipboard.writeText(downloadUrl);
367
+ const fullUrl = downloadUrl.startsWith("http")
368
+ ? downloadUrl
369
+ : `${window.location.origin}${downloadUrl.startsWith("/") ? "" : "/"}${downloadUrl}`;
370
+ navigator.clipboard.writeText(fullUrl).then(() => {
371
+ setUrlCopied(true);
372
+ setTimeout(() => setUrlCopied(false), 2000);
373
+ });
377
374
  }}
378
375
  >
379
- <Typography variant="caption" className="font-mono text-[11px] break-all text-primary">
380
- {downloadUrl}
381
- </Typography>
382
- <Typography variant="caption" className="text-text-disabled dark:text-text-disabled-dark text-[10px] block mt-1">
383
- Click to copy
376
+ <Typography variant="caption" className="font-mono text-[11px] truncate flex-1 min-w-0 text-primary">
377
+ {(() => {
378
+ const fullUrl = downloadUrl.startsWith("http")
379
+ ? downloadUrl
380
+ : `${window.location.origin}${downloadUrl.startsWith("/") ? "" : "/"}${downloadUrl}`;
381
+ return fullUrl;
382
+ })()}
384
383
  </Typography>
384
+ <Tooltip title={urlCopied ? "Copied!" : "Copy URL"}>
385
+ <div className="shrink-0">
386
+ {urlCopied
387
+ ? <CheckIcon size={14} className="text-green-500"/>
388
+ : <CopyIcon size={14} className="text-surface-accent-400"/>
389
+ }
390
+ </div>
391
+ </Tooltip>
385
392
  </div>
386
393
  </div>
387
394
  )}
@@ -419,79 +426,6 @@ function FilePreviewPanel({
419
426
  );
420
427
  }
421
428
 
422
- // ──────────────────────────────────────────────
423
- // Sidebar (folder tree)
424
- // ──────────────────────────────────────────────
425
-
426
- function StorageSidebar({
427
- folders,
428
- currentPath,
429
- onNavigate,
430
- loading
431
- }: {
432
- folders: StorageFile[];
433
- currentPath: string;
434
- onNavigate: (path: string) => void;
435
- loading: boolean;
436
- }) {
437
- const segments = breadcrumbSegments(currentPath);
438
-
439
- return (
440
- <div className={cls("flex flex-col h-full w-full bg-white dark:bg-surface-950 border-r", defaultBorderMixin)}>
441
- <div className={cls("p-3 border-b flex justify-between items-center bg-surface-50 dark:bg-surface-900 shrink-0", defaultBorderMixin)}>
442
- <Typography variant="caption" className="font-bold uppercase tracking-wider text-text-disabled dark:text-text-disabled-dark">
443
- Folders
444
- </Typography>
445
- </div>
446
-
447
- <div className="flex-grow overflow-y-auto no-scrollbar p-1">
448
- {/* Folder tree */}
449
- <div
450
- className={cls(
451
- "flex items-center p-1.5 cursor-pointer rounded transition-colors",
452
- currentPath === "" || !currentPath
453
- ? "bg-primary/10 text-primary dark:bg-primary/20 dark:text-primary-light"
454
- : "hover:bg-surface-100 dark:hover:bg-surface-950 text-text-secondary dark:text-text-secondary-dark"
455
- )}
456
- onClick={() => onNavigate("")}
457
- >
458
- <HomeIcon size={iconSize.smallest} className="mr-1.5 shrink-0"/>
459
- <Typography variant="body2" className="text-xs truncate">Root</Typography>
460
- </div>
461
-
462
- {loading && folders.length === 0 ? (
463
- <div className="flex justify-center p-4">
464
- <CircularProgress size="small"/>
465
- </div>
466
- ) : (
467
- <div className="mt-1 space-y-0.5">
468
- {folders.map(folder => {
469
- const isSelected = currentPath === folder.fullPath;
470
- return (
471
- <div
472
- key={folder.fullPath}
473
- className={cls(
474
- "flex items-center p-1.5 cursor-pointer rounded transition-colors group",
475
- isSelected
476
- ? "bg-primary/10 text-primary dark:bg-primary/20 dark:text-primary-light"
477
- : "hover:bg-surface-100 dark:hover:bg-surface-950 text-text-secondary dark:text-text-secondary-dark"
478
- )}
479
- onClick={() => onNavigate(folder.fullPath)}
480
- >
481
- <FolderIcon size={iconSize.smallest} className="mr-1.5 shrink-0 text-amber-500 dark:text-amber-400"/>
482
- <Typography variant="body2" className="text-xs truncate flex-1 min-w-0">
483
- {folder.name}
484
- </Typography>
485
- </div>
486
- );
487
- })}
488
- </div>
489
- )}
490
- </div>
491
- </div>
492
- );
493
- }
494
-
495
429
  // ──────────────────────────────────────────────
496
430
  // Main StorageView Export
497
431
  // ──────────────────────────────────────────────
@@ -501,7 +435,8 @@ export const StorageView = () => {
501
435
  const snackbarController = useSnackbarController();
502
436
 
503
437
  // Navigation
504
- const [currentPath, setCurrentPath] = useState("");
438
+ const [searchParams, setSearchParams] = useSearchParams();
439
+ const currentPath = searchParams.get("path") || "";
505
440
  const [loading, setLoading] = useState(true);
506
441
  const [error, setError] = useState<string | null>(null);
507
442
 
@@ -519,30 +454,32 @@ export const StorageView = () => {
519
454
  // View mode
520
455
  const [viewMode, setViewMode] = useState<"grid" | "list">("grid");
521
456
 
522
- // Resizable panels
457
+ // Multi-selection
458
+ const [selectedPaths, setSelectedPaths] = useState<Set<string>>(new Set());
459
+ const lastClickedRef = useRef<string | null>(null);
523
460
 
524
- // Resizable panels
525
- const [sidebarSize, setSidebarSize] = useState(() => {
526
- try {
527
- const saved = localStorage.getItem("rebase_storage_sidebar_size");
528
- return saved !== null ? parseFloat(saved) : 18;
529
- } catch {
530
- return 18;
531
- }
532
- });
461
+ // Bulk / folder delete
462
+ const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
463
+ const [deleteDialogTarget, setDeleteDialogTarget] = useState<"selection" | StorageFile | null>(null);
464
+ const [deleting, setDeleting] = useState(false);
465
+
466
+ // New folder
467
+ const [newFolderDialogOpen, setNewFolderDialogOpen] = useState(false);
468
+ const [newFolderName, setNewFolderName] = useState("");
469
+ const [creatingFolder, setCreatingFolder] = useState(false);
470
+ const apiConfig = useApiConfig();
533
471
 
472
+ const storageSourceRef = React.useRef(storageSource);
534
473
  useEffect(() => {
535
- try {
536
- localStorage.setItem("rebase_storage_sidebar_size", sidebarSize.toString());
537
- } catch { /* noop */ }
538
- }, [sidebarSize]);
474
+ storageSourceRef.current = storageSource;
475
+ }, [storageSource]);
539
476
 
540
477
  // ── Fetch directory contents ──
541
478
  const fetchContents = useCallback(async (path: string) => {
542
479
  setLoading(true);
543
480
  setError(null);
544
481
  try {
545
- const result: StorageListResult = await storageSource.listObjects(path);
482
+ const result: StorageListResult = await storageSourceRef.current.listObjects(path);
546
483
 
547
484
  const folderItems: StorageFile[] = (result.prefixes ?? []).map(ref => ({
548
485
  name: ref.name,
@@ -554,7 +491,7 @@ export const StorageView = () => {
554
491
  const fileItems: StorageFile[] = await Promise.all(
555
492
  (result.items ?? []).map(async (ref) => {
556
493
  try {
557
- const downloadConfig = await storageSource.getSignedUrl(ref.fullPath);
494
+ const downloadConfig = await storageSourceRef.current.getSignedUrl(ref.fullPath);
558
495
  return {
559
496
  name: ref.name,
560
497
  fullPath: ref.fullPath,
@@ -581,7 +518,7 @@ export const StorageView = () => {
581
518
  } finally {
582
519
  setLoading(false);
583
520
  }
584
- }, [storageSource]);
521
+ }, []);
585
522
 
586
523
  useEffect(() => {
587
524
  fetchContents(currentPath);
@@ -589,10 +526,16 @@ export const StorageView = () => {
589
526
 
590
527
  // Navigate to path
591
528
  const handleNavigate = useCallback((path: string) => {
592
- setCurrentPath(path);
529
+ if (!path) {
530
+ setSearchParams({});
531
+ } else {
532
+ setSearchParams({ path });
533
+ }
593
534
  setSelectedFile(null);
594
535
  setSelectedDownloadUrl(null);
595
- }, []);
536
+ setSelectedPaths(new Set());
537
+ lastClickedRef.current = null;
538
+ }, [setSearchParams]);
596
539
 
597
540
  // Navigate up one level
598
541
  const handleNavigateUp = useCallback(() => {
@@ -601,26 +544,76 @@ export const StorageView = () => {
601
544
  handleNavigate(parts.join("/"));
602
545
  }, [currentPath, handleNavigate]);
603
546
 
604
- // Select a file for preview
605
- const handleSelectFile = useCallback(async (file: StorageFile) => {
606
- setSelectedFile(file);
607
- if (file.downloadUrl) {
608
- setSelectedDownloadUrl(file.downloadUrl);
547
+ // All items (folders + files) in display order, for shift-range select
548
+ const allItems = useMemo(() => [...folders, ...files], [folders, files]);
549
+
550
+ // ── Multi-select click handler ──
551
+ const handleItemClick = useCallback((item: StorageFile, e: React.MouseEvent) => {
552
+ const path = item.fullPath;
553
+ if (e.metaKey || e.ctrlKey) {
554
+ // Toggle individual item
555
+ setSelectedPaths(prev => {
556
+ const next = new Set(prev);
557
+ if (next.has(path)) next.delete(path);
558
+ else next.add(path);
559
+ return next;
560
+ });
561
+ lastClickedRef.current = path;
562
+ } else if (e.shiftKey && lastClickedRef.current) {
563
+ // Range select
564
+ const allPaths = allItems.map(i => i.fullPath);
565
+ const anchorIdx = allPaths.indexOf(lastClickedRef.current);
566
+ const currentIdx = allPaths.indexOf(path);
567
+ if (anchorIdx >= 0 && currentIdx >= 0) {
568
+ const [start, end] = anchorIdx < currentIdx ? [anchorIdx, currentIdx] : [currentIdx, anchorIdx];
569
+ setSelectedPaths(prev => {
570
+ const next = new Set(prev);
571
+ for (let i = start; i <= end; i++) next.add(allPaths[i]);
572
+ return next;
573
+ });
574
+ }
609
575
  } else {
610
- try {
611
- const config = await storageSource.getSignedUrl(file.fullPath);
612
- setSelectedDownloadUrl(config.url);
613
- } catch {
576
+ // Exclusive select
577
+ setSelectedPaths(new Set([path]));
578
+ lastClickedRef.current = path;
579
+ // Also open preview if it's a file
580
+ if (!item.isFolder) {
581
+ setSelectedFile(item);
582
+ if (item.downloadUrl) {
583
+ setSelectedDownloadUrl(item.downloadUrl);
584
+ } else {
585
+ storageSourceRef.current.getSignedUrl(item.fullPath)
586
+ .then(config => setSelectedDownloadUrl(config.url))
587
+ .catch(() => setSelectedDownloadUrl(null));
588
+ }
589
+ } else {
590
+ setSelectedFile(null);
614
591
  setSelectedDownloadUrl(null);
615
592
  }
616
593
  }
617
- }, [storageSource]);
594
+ }, [allItems]);
595
+
596
+ // Double-click: open folder or preview file
597
+ const handleItemDoubleClick = useCallback((item: StorageFile) => {
598
+ if (item.isFolder) {
599
+ handleNavigate(item.fullPath);
600
+ } else {
601
+ setSelectedFile(item);
602
+ if (item.downloadUrl) {
603
+ setSelectedDownloadUrl(item.downloadUrl);
604
+ } else {
605
+ storageSourceRef.current.getSignedUrl(item.fullPath)
606
+ .then(config => setSelectedDownloadUrl(config.url))
607
+ .catch(() => setSelectedDownloadUrl(null));
608
+ }
609
+ }
610
+ }, [handleNavigate]);
618
611
 
619
612
  // Upload files
620
613
  const handleUpload = useCallback(async (uploadFiles: File[]) => {
621
614
  for (const file of uploadFiles) {
622
615
  const key = currentPath ? `${currentPath}/${file.name}` : file.name;
623
- await storageSource.putObject({
616
+ await storageSourceRef.current.putObject({
624
617
  file,
625
618
  key
626
619
  });
@@ -629,23 +622,214 @@ export const StorageView = () => {
629
622
  type: "success",
630
623
  message: `${uploadFiles.length} file${uploadFiles.length > 1 ? "s" : ""} uploaded successfully`
631
624
  });
632
- fetchContents(currentPath);
633
- }, [storageSource, currentPath, snackbarController, fetchContents]);
625
+ await fetchContents(currentPath);
626
+ }, [currentPath, snackbarController, fetchContents]);
627
+
628
+ // Create new folder
629
+ const handleCreateFolder = useCallback(async () => {
630
+ if (!newFolderName.trim() || !apiConfig?.apiUrl) return;
631
+
632
+ // Validate folder name
633
+ const name = newFolderName.trim();
634
+ if (name.includes("/") || name.includes("\\")) {
635
+ snackbarController.open({ type: "error", message: "Folder name cannot contain slashes" });
636
+ return;
637
+ }
638
+
639
+ // Check if folder already exists
640
+ const existingFolder = folders.find(f => f.name === name);
641
+ if (existingFolder) {
642
+ snackbarController.open({ type: "error", message: `Folder "${name}" already exists` });
643
+ return;
644
+ }
634
645
 
635
- // Delete a file
646
+ setCreatingFolder(true);
647
+ try {
648
+ const folderPath = currentPath ? `default/${currentPath}/${name}` : `default/${name}`;
649
+ const token = apiConfig.getAuthToken ? await apiConfig.getAuthToken() : null;
650
+ const response = await fetch(`${apiConfig.apiUrl}/api/storage/folder`, {
651
+ method: "POST",
652
+ headers: {
653
+ "Content-Type": "application/json",
654
+ ...(token ? { "Authorization": `Bearer ${token}` } : {})
655
+ },
656
+ body: JSON.stringify({ path: folderPath })
657
+ });
658
+
659
+ if (!response.ok) {
660
+ const err = await response.json().catch(() => ({ error: "Failed to create folder" }));
661
+ throw new Error(err.error || "Failed to create folder");
662
+ }
663
+
664
+ snackbarController.open({ type: "success", message: `Folder "${name}" created` });
665
+ setNewFolderDialogOpen(false);
666
+ setNewFolderName("");
667
+ await fetchContents(currentPath);
668
+ } catch (e) {
669
+ snackbarController.open({ type: "error", message: e instanceof Error ? e.message : String(e) });
670
+ } finally {
671
+ setCreatingFolder(false);
672
+ }
673
+ }, [newFolderName, currentPath, apiConfig, snackbarController, fetchContents, folders]);
674
+
675
+ // Drag-and-drop on main view
676
+ const handleDropFiles = useCallback(async (droppedFiles: File[]) => {
677
+ if (droppedFiles.length === 0) return;
678
+ try {
679
+ for (const file of droppedFiles) {
680
+ const key = currentPath ? `${currentPath}/${file.name}` : file.name;
681
+ await storageSourceRef.current.putObject({ file, key });
682
+ }
683
+ snackbarController.open({
684
+ type: "success",
685
+ message: `${droppedFiles.length} file${droppedFiles.length > 1 ? "s" : ""} uploaded successfully`
686
+ });
687
+ await fetchContents(currentPath);
688
+ } catch (e) {
689
+ snackbarController.open({
690
+ type: "error",
691
+ message: e instanceof Error ? e.message : String(e)
692
+ });
693
+ }
694
+ }, [currentPath, snackbarController, fetchContents]);
695
+
696
+ const {
697
+ getRootProps: getDropRootProps,
698
+ getInputProps: getDropInputProps,
699
+ isDragActive
700
+ } = useDropzone({
701
+ onDrop: handleDropFiles,
702
+ noClick: true,
703
+ noKeyboard: true,
704
+ noDragEventsBubbling: true
705
+ });
706
+
707
+ // ── Recursive folder delete helper ──
708
+ const deleteFolderRecursive = useCallback(async (prefix: string) => {
709
+ const result = await storageSourceRef.current.listObjects(prefix);
710
+ // Delete all files in this level
711
+ for (const item of result.items ?? []) {
712
+ await storageSourceRef.current.deleteObject(item.fullPath);
713
+ }
714
+ // Recurse into sub-folders
715
+ for (const sub of result.prefixes ?? []) {
716
+ await deleteFolderRecursive(sub.fullPath);
717
+ }
718
+ // Delete the folder entry itself (needed for local filesystem)
719
+ try {
720
+ await storageSourceRef.current.deleteObject(prefix);
721
+ } catch {
722
+ // Ignore — S3 folders are virtual and may not exist as objects
723
+ }
724
+ }, []);
725
+
726
+ // Delete a single file
636
727
  const handleDeleteFile = useCallback(async (file: StorageFile) => {
637
728
  try {
638
- await storageSource.deleteObject(file.fullPath);
639
- snackbarController.open({ type: "success",
640
- message: `"${file.name}" deleted` });
729
+ if (file.isFolder) {
730
+ await deleteFolderRecursive(file.fullPath);
731
+ } else {
732
+ await storageSourceRef.current.deleteObject(file.fullPath);
733
+ }
734
+ snackbarController.open({ type: "success", message: `"${file.name}" deleted` });
641
735
  setSelectedFile(null);
642
736
  setSelectedDownloadUrl(null);
737
+ setSelectedPaths(prev => {
738
+ const next = new Set(prev);
739
+ next.delete(file.fullPath);
740
+ return next;
741
+ });
643
742
  fetchContents(currentPath);
644
743
  } catch (e) {
645
- snackbarController.open({ type: "error",
646
- message: e instanceof Error ? e.message : String(e) });
744
+ snackbarController.open({ type: "error", message: e instanceof Error ? e.message : String(e) });
647
745
  }
648
- }, [storageSource, currentPath, snackbarController, fetchContents]);
746
+ }, [currentPath, snackbarController, fetchContents, deleteFolderRecursive]);
747
+
748
+ // Bulk delete (selected items)
749
+ const handleBulkDelete = useCallback(async () => {
750
+ setDeleting(true);
751
+ try {
752
+ const items = allItems.filter(i => selectedPaths.has(i.fullPath));
753
+ for (const item of items) {
754
+ if (item.isFolder) {
755
+ await deleteFolderRecursive(item.fullPath);
756
+ } else {
757
+ await storageSourceRef.current.deleteObject(item.fullPath);
758
+ }
759
+ }
760
+ snackbarController.open({ type: "success", message: `${items.length} item${items.length !== 1 ? "s" : ""} deleted` });
761
+ setSelectedPaths(new Set());
762
+ setSelectedFile(null);
763
+ setSelectedDownloadUrl(null);
764
+ await fetchContents(currentPath);
765
+ } catch (e) {
766
+ snackbarController.open({ type: "error", message: e instanceof Error ? e.message : String(e) });
767
+ } finally {
768
+ setDeleting(false);
769
+ setDeleteDialogOpen(false);
770
+ setDeleteDialogTarget(null);
771
+ }
772
+ }, [allItems, selectedPaths, currentPath, snackbarController, fetchContents, deleteFolderRecursive]);
773
+
774
+ // Confirm delete for a single folder
775
+ const handleConfirmDeleteFolder = useCallback(async () => {
776
+ if (!deleteDialogTarget || deleteDialogTarget === "selection") return;
777
+ setDeleting(true);
778
+ try {
779
+ await deleteFolderRecursive(deleteDialogTarget.fullPath);
780
+ snackbarController.open({ type: "success", message: `Folder "${deleteDialogTarget.name}" deleted` });
781
+ setSelectedPaths(prev => {
782
+ const next = new Set(prev);
783
+ next.delete(deleteDialogTarget.fullPath);
784
+ return next;
785
+ });
786
+ await fetchContents(currentPath);
787
+ } catch (e) {
788
+ snackbarController.open({ type: "error", message: e instanceof Error ? e.message : String(e) });
789
+ } finally {
790
+ setDeleting(false);
791
+ setDeleteDialogOpen(false);
792
+ setDeleteDialogTarget(null);
793
+ }
794
+ }, [deleteDialogTarget, currentPath, snackbarController, fetchContents, deleteFolderRecursive]);
795
+
796
+ // Select all / deselect
797
+ const handleSelectAll = useCallback(() => {
798
+ if (selectedPaths.size === allItems.length) {
799
+ setSelectedPaths(new Set());
800
+ } else {
801
+ setSelectedPaths(new Set(allItems.map(i => i.fullPath)));
802
+ }
803
+ }, [allItems, selectedPaths]);
804
+
805
+ // ── Keyboard shortcuts ──
806
+ useEffect(() => {
807
+ const handler = (e: KeyboardEvent) => {
808
+ // Don't handle shortcuts when a dialog is open
809
+ if (deleteDialogOpen || uploadDialogOpen || newFolderDialogOpen) return;
810
+ // Cmd/Ctrl+A: select all
811
+ if ((e.metaKey || e.ctrlKey) && e.key === "a") {
812
+ e.preventDefault();
813
+ handleSelectAll();
814
+ }
815
+ // Escape: deselect
816
+ if (e.key === "Escape") {
817
+ setSelectedPaths(new Set());
818
+ setSelectedFile(null);
819
+ setSelectedDownloadUrl(null);
820
+ }
821
+ // Delete / Backspace: delete selected
822
+ if ((e.key === "Delete" || e.key === "Backspace") && selectedPaths.size > 0 && !e.metaKey && !e.ctrlKey) {
823
+ // Don't trigger if user is typing in an input
824
+ if ((e.target as HTMLElement)?.tagName === "INPUT" || (e.target as HTMLElement)?.tagName === "TEXTAREA") return;
825
+ e.preventDefault();
826
+ setDeleteDialogTarget("selection");
827
+ setDeleteDialogOpen(true);
828
+ }
829
+ };
830
+ window.addEventListener("keydown", handler);
831
+ return () => window.removeEventListener("keydown", handler);
832
+ }, [handleSelectAll, selectedPaths, deleteDialogOpen, uploadDialogOpen, newFolderDialogOpen]);
649
833
 
650
834
  // Handle refresh
651
835
  const handleRefresh = useCallback(() => {
@@ -654,6 +838,7 @@ message: e instanceof Error ? e.message : String(e) });
654
838
 
655
839
  const segments = breadcrumbSegments(currentPath);
656
840
 
841
+
657
842
  // ── Render file grid/list ──
658
843
  const renderContents = () => {
659
844
  if (loading) {
@@ -677,7 +862,6 @@ message: e instanceof Error ? e.message : String(e) });
677
862
  );
678
863
  }
679
864
 
680
- const allItems = [...folders, ...files];
681
865
 
682
866
  if (allItems.length === 0) {
683
867
  return (
@@ -689,10 +873,19 @@ message: e instanceof Error ? e.message : String(e) });
689
873
  <Typography variant="body2">
690
874
  This folder is empty
691
875
  </Typography>
692
- <Button className="mt-3" onClick={() => setUploadDialogOpen(true)}>
693
- <PlusIcon size={iconSize.smallest}/>
694
- Upload files
695
- </Button>
876
+ <div className="flex items-center gap-2 mt-3">
877
+ <Button variant="text" onClick={() => {
878
+ setNewFolderName("");
879
+ setNewFolderDialogOpen(true);
880
+ }}>
881
+ <FolderPlusIcon size={iconSize.smallest}/>
882
+ New folder
883
+ </Button>
884
+ <Button onClick={() => setUploadDialogOpen(true)}>
885
+ <PlusIcon size={iconSize.smallest}/>
886
+ Upload files
887
+ </Button>
888
+ </div>
696
889
  </div>
697
890
  </div>
698
891
  );
@@ -704,53 +897,105 @@ message: e instanceof Error ? e.message : String(e) });
704
897
  <table className="w-full">
705
898
  <thead>
706
899
  <tr className={cls("border-b text-left text-[10px] uppercase tracking-wider text-text-disabled dark:text-text-disabled-dark", defaultBorderMixin)}>
707
- <th className="px-4 py-2 font-bold">Name</th>
900
+ <th className="pl-3 pr-0 py-2 w-8">
901
+ <Checkbox
902
+ size="small"
903
+ checked={allItems.length > 0 && selectedPaths.size === allItems.length}
904
+ indeterminate={selectedPaths.size > 0 && selectedPaths.size < allItems.length}
905
+ onCheckedChange={handleSelectAll}
906
+ />
907
+ </th>
908
+ <th className="px-2 py-2 font-bold">Name</th>
708
909
  <th className="px-4 py-2 font-bold w-24">Type</th>
709
910
  <th className="px-4 py-2 font-bold w-24 text-right">Size</th>
911
+ <th className="px-2 py-2 w-10"/>
710
912
  </tr>
711
913
  </thead>
712
914
  <tbody>
713
- {folders.map(folder => (
714
- <tr
715
- key={folder.fullPath}
716
- className="hover:bg-surface-100 dark:hover:bg-surface-950 cursor-pointer transition-colors border-b border-surface-100 dark:border-surface-950/50"
717
- onClick={() => handleNavigate(folder.fullPath)}
718
- >
719
- <td className="px-4 py-2.5">
720
- <div className="flex items-center gap-2">
721
- <FolderIcon size={iconSize.smallest} className="text-amber-500 dark:text-amber-400 shrink-0"/>
722
- <Typography variant="body2" className="text-[13px] font-medium truncate">
723
- {folder.name}
915
+ {folders.map(folder => {
916
+ const isChecked = selectedPaths.has(folder.fullPath);
917
+ return (
918
+ <tr
919
+ key={folder.fullPath}
920
+ data-storage-item
921
+ className={cls(
922
+ "cursor-pointer transition-colors border-b group",
923
+ defaultBorderMixin,
924
+ isChecked
925
+ ? "bg-primary/5 dark:bg-primary/10"
926
+ : "hover:bg-surface-100 dark:hover:bg-surface-800"
927
+ )}
928
+ onClick={(e) => handleItemClick(folder, e)}
929
+ onDoubleClick={() => handleItemDoubleClick(folder)}
930
+ >
931
+ <td className="pl-3 pr-0 py-2.5" onClick={(e) => e.stopPropagation()}>
932
+ <Checkbox
933
+ size="small"
934
+ checked={isChecked}
935
+ onCheckedChange={() => {
936
+ setSelectedPaths(prev => {
937
+ const next = new Set(prev);
938
+ if (next.has(folder.fullPath)) next.delete(folder.fullPath);
939
+ else next.add(folder.fullPath);
940
+ return next;
941
+ });
942
+ }}
943
+ />
944
+ </td>
945
+ <td className="px-2 py-2.5">
946
+ <div className="flex items-center gap-2">
947
+ <FolderIcon size={iconSize.smallest} className="text-amber-500 dark:text-amber-400 shrink-0"/>
948
+ <Typography variant="body2" className="text-[13px] font-medium truncate">
949
+ {folder.name}
950
+ </Typography>
951
+ </div>
952
+ </td>
953
+ <td className="px-4 py-2.5">
954
+ <Typography variant="caption" className="text-text-secondary dark:text-text-secondary-dark">
955
+ Folder
724
956
  </Typography>
725
- </div>
726
- </td>
727
- <td className="px-4 py-2.5">
728
- <Typography variant="caption" className="text-text-secondary dark:text-text-secondary-dark">
729
- Folder
730
- </Typography>
731
- </td>
732
- <td className="px-4 py-2.5 text-right">
733
- <Typography variant="caption" className="text-text-disabled dark:text-text-disabled-dark">
734
-
735
- </Typography>
736
- </td>
737
- </tr>
738
- ))}
957
+ </td>
958
+ <td className="px-4 py-2.5 text-right">
959
+ <Typography variant="caption" className="text-text-disabled dark:text-text-disabled-dark">
960
+
961
+ </Typography>
962
+ </td>
963
+ <td className="px-2 py-2.5"/>
964
+ </tr>
965
+ );
966
+ })}
739
967
  {files.map(file => {
740
968
  const FileIconComp = getFileIcon(file.contentType);
741
- const isSelected = selectedFile?.fullPath === file.fullPath;
969
+ const isChecked = selectedPaths.has(file.fullPath);
742
970
  return (
743
971
  <tr
744
972
  key={file.fullPath}
973
+ data-storage-item
745
974
  className={cls(
746
- "cursor-pointer transition-colors border-b border-surface-100 dark:border-surface-950/50",
747
- isSelected
975
+ "cursor-pointer transition-colors border-b group",
976
+ defaultBorderMixin,
977
+ isChecked
748
978
  ? "bg-primary/5 dark:bg-primary/10"
749
- : "hover:bg-surface-100 dark:hover:bg-surface-950"
979
+ : "hover:bg-surface-100 dark:hover:bg-surface-800"
750
980
  )}
751
- onClick={() => handleSelectFile(file)}
981
+ onClick={(e) => handleItemClick(file, e)}
982
+ onDoubleClick={() => handleItemDoubleClick(file)}
752
983
  >
753
- <td className="px-4 py-2.5">
984
+ <td className="pl-3 pr-0 py-2.5" onClick={(e) => e.stopPropagation()}>
985
+ <Checkbox
986
+ size="small"
987
+ checked={isChecked}
988
+ onCheckedChange={() => {
989
+ setSelectedPaths(prev => {
990
+ const next = new Set(prev);
991
+ if (next.has(file.fullPath)) next.delete(file.fullPath);
992
+ else next.add(file.fullPath);
993
+ return next;
994
+ });
995
+ }}
996
+ />
997
+ </td>
998
+ <td className="px-2 py-2.5">
754
999
  <div className="flex items-center gap-2">
755
1000
  <FileIconComp size={iconSize.smallest} className="text-surface-accent-400 shrink-0"/>
756
1001
  <Typography variant="body2" className="text-[13px] truncate">
@@ -768,6 +1013,15 @@ message: e instanceof Error ? e.message : String(e) });
768
1013
  {file.size !== undefined ? formatFileSize(file.size) : "—"}
769
1014
  </Typography>
770
1015
  </td>
1016
+ <td className="px-2 py-2.5" onClick={(e) => e.stopPropagation()}>
1017
+ <IconButton
1018
+ size="smallest"
1019
+ className="opacity-0 group-hover:opacity-100 transition-opacity"
1020
+ onClick={() => handleDeleteFile(file)}
1021
+ >
1022
+ <Trash2Icon size={14}/>
1023
+ </IconButton>
1024
+ </td>
771
1025
  </tr>
772
1026
  );
773
1027
  })}
@@ -786,24 +1040,31 @@ message: e instanceof Error ? e.message : String(e) });
786
1040
  <Typography variant="caption" className="text-[10px] uppercase tracking-wider font-bold text-text-disabled dark:text-text-disabled-dark mb-2 block">
787
1041
  Folders
788
1042
  </Typography>
789
- <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-3">
790
- {folders.map(folder => (
791
- <div
792
- key={folder.fullPath}
793
- className={cls(
794
- "rounded-lg p-3 cursor-pointer transition-all duration-150 border",
795
- defaultBorderMixin,
796
- "hover:bg-surface-100 dark:hover:bg-surface-950 hover:shadow-sm",
797
- "flex items-center gap-2"
798
- )}
799
- onClick={() => handleNavigate(folder.fullPath)}
800
- >
801
- <FolderIcon size={iconSize.smallest} className="text-amber-500 dark:text-amber-400 shrink-0"/>
802
- <Typography variant="body2" className="text-[13px] font-medium truncate">
803
- {folder.name}
804
- </Typography>
805
- </div>
806
- ))}
1043
+ <div className="grid gap-3 grid-cols-[repeat(auto-fill,minmax(140px,1fr))]">
1044
+ {folders.map(folder => {
1045
+ const isChecked = selectedPaths.has(folder.fullPath);
1046
+ return (
1047
+ <div
1048
+ key={folder.fullPath}
1049
+ data-storage-item
1050
+ className={cls(
1051
+ "rounded-lg p-3 cursor-pointer border",
1052
+ "transition-colors duration-150",
1053
+ defaultBorderMixin,
1054
+ "hover:bg-surface-100 dark:hover:bg-surface-800 hover:shadow-sm",
1055
+ "flex items-center gap-2",
1056
+ isChecked && "ring-2 ring-primary bg-primary/5 dark:bg-primary/10"
1057
+ )}
1058
+ onClick={(e) => handleItemClick(folder, e)}
1059
+ onDoubleClick={() => handleItemDoubleClick(folder)}
1060
+ >
1061
+ <FolderIcon size={iconSize.smallest} className="text-amber-500 dark:text-amber-400 shrink-0"/>
1062
+ <Typography variant="body2" className="text-[13px] font-medium truncate">
1063
+ {folder.name}
1064
+ </Typography>
1065
+ </div>
1066
+ );
1067
+ })}
807
1068
  </div>
808
1069
  </div>
809
1070
  )}
@@ -814,31 +1075,34 @@ message: e instanceof Error ? e.message : String(e) });
814
1075
  <Typography variant="caption" className="text-[10px] uppercase tracking-wider font-bold text-text-disabled dark:text-text-disabled-dark mb-2 block">
815
1076
  Files ({files.length})
816
1077
  </Typography>
817
- <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-3">
1078
+ <div className="grid gap-3 grid-cols-[repeat(auto-fill,minmax(140px,1fr))]">
818
1079
  {files.map(file => {
819
1080
  const FileIconComp = getFileIcon(file.contentType);
820
1081
  const ext = getExtension(file.name)?.toLowerCase() || "";
821
1082
  const isImage = file.contentType?.startsWith("image/") || ["jpg", "jpeg", "png", "gif", "webp", "svg"].includes(ext);
822
- const isSelected = selectedFile?.fullPath === file.fullPath;
1083
+ const isChecked = selectedPaths.has(file.fullPath);
823
1084
 
824
1085
  return (
825
1086
  <div
826
1087
  key={file.fullPath}
1088
+ data-storage-item
827
1089
  className={cls(
828
- "rounded-lg overflow-hidden cursor-pointer transition-all duration-150 border group",
1090
+ "rounded-lg overflow-hidden cursor-pointer border",
1091
+ "transition-shadow duration-150",
829
1092
  defaultBorderMixin,
830
- "hover:shadow-md hover:-translate-y-0.5",
831
- isSelected && "ring-2 ring-primary"
1093
+ "hover:shadow-md",
1094
+ isChecked && "ring-2 ring-primary"
832
1095
  )}
833
- onClick={() => handleSelectFile(file)}
1096
+ onClick={(e) => handleItemClick(file, e)}
1097
+ onDoubleClick={() => handleItemDoubleClick(file)}
834
1098
  >
835
1099
  {/* Thumbnail or icon */}
836
- <div className="aspect-square relative overflow-hidden bg-surface-100 dark:bg-surface-950 flex items-center justify-center">
1100
+ <div className="aspect-square relative overflow-hidden bg-surface-100 dark:bg-surface-800 flex items-center justify-center">
837
1101
  {isImage && file.downloadUrl ? (
838
1102
  <img
839
1103
  src={file.downloadUrl}
840
1104
  alt={file.name}
841
- className="w-full h-full object-cover transition-transform duration-200 group-hover:scale-105"
1105
+ className="w-full h-full object-cover"
842
1106
  loading="lazy"
843
1107
  />
844
1108
  ) : (
@@ -851,12 +1115,6 @@ message: e instanceof Error ? e.message : String(e) });
851
1115
  {getExtension(file.name)}
852
1116
  </div>
853
1117
  )}
854
-
855
- {/* Hover overlay */}
856
- <div className={cls(
857
- "absolute inset-0 bg-black/0 group-hover:bg-black/10",
858
- "transition-colors duration-200"
859
- )}/>
860
1118
  </div>
861
1119
 
862
1120
  {/* Name & size */}
@@ -879,28 +1137,14 @@ message: e instanceof Error ? e.message : String(e) });
879
1137
  };
880
1138
 
881
1139
  return (
882
- <div className="flex h-full w-full bg-white dark:bg-surface-950 overflow-hidden text-text-primary dark:text-text-primary-dark">
883
- <ResizablePanels
884
- orientation="horizontal"
885
- panelSizePercent={sidebarSize}
886
- onPanelSizeChange={setSidebarSize}
887
- minPanelSizePx={180}
888
- firstPanel={
889
- <StorageSidebar
890
- folders={folders}
891
- currentPath={currentPath}
892
- onNavigate={handleNavigate}
893
- loading={loading}
894
- />
895
- }
896
- secondPanel={
897
- <div className="flex h-full w-full">
898
- {/* Main content */}
899
- <div className="flex-grow flex flex-col min-w-0 h-full">
1140
+ <div className="flex h-full w-full bg-white dark:bg-surface-800 overflow-hidden text-text-primary dark:text-text-primary-dark">
1141
+ <div className="flex h-full w-full">
1142
+ {/* Main content */}
1143
+ <div className="flex-grow flex flex-col min-w-0 h-full">
900
1144
  {/* Toolbar */}
901
- <div className={cls("flex items-center justify-between pr-2 border-b bg-white dark:bg-surface-950 shrink-0", defaultBorderMixin)}>
1145
+ <div className={cls("flex items-center justify-between pr-2 border-b bg-white dark:bg-surface-800 shrink-0 h-10", defaultBorderMixin)}>
902
1146
  <div className="flex items-center gap-1.5 flex-grow overflow-hidden px-3 py-2">
903
- {/* Breadcrumbs */}
1147
+ {/* Breadcrumbs — always visible */}
904
1148
  {currentPath && (
905
1149
  <Tooltip title="Go up">
906
1150
  <IconButton size="small" onClick={handleNavigateUp}>
@@ -933,13 +1177,42 @@ message: e instanceof Error ? e.message : String(e) });
933
1177
 
934
1178
  <div className="flex-1"/>
935
1179
 
936
- {/* FileIcon count */}
937
- {!loading && (
1180
+ {/* Selection actions or file count */}
1181
+ {selectedPaths.size > 0 ? (
1182
+ <div className="flex items-center gap-1.5 shrink-0">
1183
+ <Typography variant="body2" className="text-[13px] font-medium whitespace-nowrap">
1184
+ {selectedPaths.size} selected
1185
+ </Typography>
1186
+ <Button
1187
+ size="small"
1188
+ variant="text"
1189
+ onClick={() => {
1190
+ setDeleteDialogTarget("selection");
1191
+ setDeleteDialogOpen(true);
1192
+ }}
1193
+ >
1194
+ <Trash2Icon size={14} className="mr-1"/>
1195
+ Delete
1196
+ </Button>
1197
+ <Button
1198
+ size="small"
1199
+ variant="text"
1200
+ onClick={() => {
1201
+ setSelectedPaths(new Set());
1202
+ setSelectedFile(null);
1203
+ setSelectedDownloadUrl(null);
1204
+ }}
1205
+ >
1206
+ <XIcon size={14} className="mr-1"/>
1207
+ Deselect
1208
+ </Button>
1209
+ </div>
1210
+ ) : !loading ? (
938
1211
  <Chip size="small" className="shrink-0 text-[10px]">
939
1212
  {files.length} file{files.length !== 1 ? "s" : ""}
940
1213
  {folders.length > 0 ? `, ${folders.length} folder${folders.length !== 1 ? "s" : ""}` : ""}
941
1214
  </Chip>
942
- )}
1215
+ ) : null}
943
1216
  </div>
944
1217
 
945
1218
  <div className="flex shrink-0 items-center justify-end gap-1.5 pr-1">
@@ -948,7 +1221,7 @@ message: e instanceof Error ? e.message : String(e) });
948
1221
  <IconButton
949
1222
  size="small"
950
1223
  onClick={() => setViewMode("grid")}
951
- className={cls(viewMode === "grid" && "bg-surface-100 dark:bg-surface-950")}
1224
+ className={cls(viewMode === "grid" && "bg-surface-100 dark:bg-surface-800")}
952
1225
  >
953
1226
  <LayoutGridIcon size={iconSize.smallest}/>
954
1227
  </IconButton>
@@ -957,13 +1230,13 @@ message: e instanceof Error ? e.message : String(e) });
957
1230
  <IconButton
958
1231
  size="small"
959
1232
  onClick={() => setViewMode("list")}
960
- className={cls(viewMode === "list" && "bg-surface-100 dark:bg-surface-950")}
1233
+ className={cls(viewMode === "list" && "bg-surface-100 dark:bg-surface-800")}
961
1234
  >
962
1235
  <ListIcon size={iconSize.smallest}/>
963
1236
  </IconButton>
964
1237
  </Tooltip>
965
1238
 
966
- <div className="h-4 w-px bg-surface-200 dark:bg-surface-950 mx-0.5"/>
1239
+ <div className={cls("h-4 w-px mx-0.5", defaultBorderMixin, "bg-surface-200 dark:bg-surface-700")}/>
967
1240
 
968
1241
  <Tooltip title="Refresh">
969
1242
  <IconButton size="small" onClick={handleRefresh} disabled={loading}>
@@ -971,6 +1244,17 @@ message: e instanceof Error ? e.message : String(e) });
971
1244
  </IconButton>
972
1245
  </Tooltip>
973
1246
 
1247
+ <Tooltip title="New folder">
1248
+ <IconButton
1249
+ size="small"
1250
+ onClick={() => {
1251
+ setNewFolderName("");
1252
+ setNewFolderDialogOpen(true);
1253
+ }}
1254
+ >
1255
+ <FolderPlusIcon size={iconSize.smallest}/>
1256
+ </IconButton>
1257
+ </Tooltip>
974
1258
  <Button
975
1259
  size="small"
976
1260
  color="primary"
@@ -982,13 +1266,38 @@ message: e instanceof Error ? e.message : String(e) });
982
1266
  </div>
983
1267
  </div>
984
1268
 
985
- {/* FileIcon grid / list */}
986
- <div className="flex-grow flex flex-col overflow-hidden min-h-0">
1269
+ {/* File grid / list — drop zone */}
1270
+ <div {...getDropRootProps()}
1271
+ className="flex-grow flex flex-col overflow-hidden min-h-0 relative"
1272
+ onClick={(e) => {
1273
+ const target = e.target as HTMLElement;
1274
+ if (!target.closest("[data-storage-item]") && selectedPaths.size > 0) {
1275
+ setSelectedPaths(new Set());
1276
+ setSelectedFile(null);
1277
+ setSelectedDownloadUrl(null);
1278
+ }
1279
+ }}
1280
+ >
1281
+ <input {...getDropInputProps()} />
987
1282
  {renderContents()}
1283
+ {/* Drag overlay */}
1284
+ {isDragActive && (
1285
+ <div className="absolute inset-0 z-10 flex items-center justify-center bg-primary/5 dark:bg-primary/10 backdrop-blur-[2px]">
1286
+ <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">
1287
+ <UploadCloudIcon className="w-10 h-10 text-primary"/>
1288
+ <Typography variant="subtitle2" className="text-primary font-semibold">
1289
+ Drop files to upload
1290
+ </Typography>
1291
+ <Typography variant="caption" color="secondary">
1292
+ to /{currentPath || "root"}
1293
+ </Typography>
1294
+ </div>
1295
+ </div>
1296
+ )}
988
1297
  </div>
989
1298
 
990
1299
  {/* Status bar */}
991
- <div className={cls("px-4 py-1.5 border-t bg-surface-50 dark:bg-surface-900 flex items-center justify-between shrink-0", defaultBorderMixin)}>
1300
+ <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)}>
992
1301
  <div className="flex items-center gap-4 text-[11px]">
993
1302
  <span className="text-text-disabled dark:text-text-disabled-dark font-bold uppercase tracking-tighter">
994
1303
  Path
@@ -997,11 +1306,15 @@ message: e instanceof Error ? e.message : String(e) });
997
1306
  /{currentPath || ""}
998
1307
  </span>
999
1308
  </div>
1000
- {selectedFile && (
1309
+ {selectedPaths.size > 0 ? (
1310
+ <div className="text-[11px] text-text-secondary dark:text-text-secondary-dark">
1311
+ {selectedPaths.size} item{selectedPaths.size !== 1 ? "s" : ""} selected
1312
+ </div>
1313
+ ) : selectedFile ? (
1001
1314
  <div className="text-[11px] text-text-secondary dark:text-text-secondary-dark">
1002
1315
  Selected: <span className="font-mono">{selectedFile.name}</span>
1003
1316
  </div>
1004
- )}
1317
+ ) : null}
1005
1318
  </div>
1006
1319
  </div>
1007
1320
 
@@ -1019,9 +1332,7 @@ message: e instanceof Error ? e.message : String(e) });
1019
1332
  />
1020
1333
  </div>
1021
1334
  )}
1022
- </div>
1023
- }
1024
- />
1335
+ </div>
1025
1336
 
1026
1337
  {/* Upload Dialog */}
1027
1338
  <UploadDialog
@@ -1030,6 +1341,110 @@ message: e instanceof Error ? e.message : String(e) });
1030
1341
  onClose={() => setUploadDialogOpen(false)}
1031
1342
  onUpload={handleUpload}
1032
1343
  />
1344
+
1345
+ {/* Delete confirmation dialog */}
1346
+ <Dialog
1347
+ open={deleteDialogOpen}
1348
+ onOpenChange={(open) => {
1349
+ if (!open && !deleting) {
1350
+ setDeleteDialogOpen(false);
1351
+ setDeleteDialogTarget(null);
1352
+ }
1353
+ }}
1354
+ >
1355
+ <DialogContent>
1356
+ <Typography variant="subtitle1" className="font-semibold mb-2">
1357
+ {deleteDialogTarget === "selection"
1358
+ ? `Delete ${selectedPaths.size} item${selectedPaths.size !== 1 ? "s" : ""}?`
1359
+ : deleteDialogTarget
1360
+ ? `Delete folder "${deleteDialogTarget.name}"?`
1361
+ : "Delete?"}
1362
+ </Typography>
1363
+ <Typography variant="body2" color="secondary">
1364
+ {deleteDialogTarget === "selection"
1365
+ ? "This will permanently delete all selected files and folders, including their contents. This action cannot be undone."
1366
+ : "This will permanently delete the folder and all of its contents. This action cannot be undone."}
1367
+ </Typography>
1368
+ </DialogContent>
1369
+ <DialogActions>
1370
+ <Button
1371
+ variant="text"
1372
+ onClick={() => {
1373
+ setDeleteDialogOpen(false);
1374
+ setDeleteDialogTarget(null);
1375
+ }}
1376
+ disabled={deleting}
1377
+ >
1378
+ Cancel
1379
+ </Button>
1380
+ <LoadingButton
1381
+ color="error"
1382
+ loading={deleting}
1383
+ onClick={deleteDialogTarget === "selection" ? handleBulkDelete : handleConfirmDeleteFolder}
1384
+ >
1385
+ <Trash2Icon size={14} className="mr-1"/>
1386
+ Delete
1387
+ </LoadingButton>
1388
+ </DialogActions>
1389
+ </Dialog>
1390
+
1391
+ {/* New Folder Dialog */}
1392
+ <Dialog
1393
+ open={newFolderDialogOpen}
1394
+ onOpenChange={(open) => {
1395
+ if (!open && !creatingFolder) {
1396
+ setNewFolderDialogOpen(false);
1397
+ setNewFolderName("");
1398
+ }
1399
+ }}
1400
+ >
1401
+ <DialogContent>
1402
+ <Typography variant="subtitle1" className="font-semibold mb-4">
1403
+ New Folder
1404
+ </Typography>
1405
+ <TextField
1406
+ autoFocus
1407
+ size="small"
1408
+ label="Folder name"
1409
+ value={newFolderName}
1410
+ onChange={(e) => setNewFolderName(e.target.value)}
1411
+ onKeyDown={(e) => {
1412
+ if (e.key === "Enter" && newFolderName.trim()) {
1413
+ e.preventDefault();
1414
+ handleCreateFolder();
1415
+ }
1416
+ }}
1417
+ disabled={creatingFolder}
1418
+ placeholder="Enter folder name"
1419
+ />
1420
+ {currentPath && (
1421
+ <Typography variant="caption" color="secondary" className="mt-2">
1422
+ Will be created in <span className="font-mono">/{currentPath}/</span>
1423
+ </Typography>
1424
+ )}
1425
+ </DialogContent>
1426
+ <DialogActions>
1427
+ <Button
1428
+ variant="text"
1429
+ onClick={() => {
1430
+ setNewFolderDialogOpen(false);
1431
+ setNewFolderName("");
1432
+ }}
1433
+ disabled={creatingFolder}
1434
+ >
1435
+ Cancel
1436
+ </Button>
1437
+ <LoadingButton
1438
+ color="primary"
1439
+ loading={creatingFolder}
1440
+ disabled={!newFolderName.trim()}
1441
+ onClick={handleCreateFolder}
1442
+ >
1443
+ <FolderPlusIcon size={14} className="mr-1"/>
1444
+ Create
1445
+ </LoadingButton>
1446
+ </DialogActions>
1447
+ </Dialog>
1033
1448
  </div>
1034
1449
  );
1035
1450
  };