-
-
-
{selectedStorage.name}
-
{selectedStorage.description}
-
-
- Upload
- Download
- Delete
-
+
+
+ Spaces
+
+ {spaces.length === 0 &&
No file spaces available.
}
+ {spaces.map((space) => {
+ const isActiveRoot = activeSpaceId === space.id && currentFolder === "";
+ const nodes = buildFolderTree(filesBySpace[space.id] ?? EMPTY_FILES, foldersBySpace[space.id] ?? EMPTY_FOLDERS);
+ const rootTarget = { spaceId: space.id, folderPath: "" };
+ const rootDropActive = dropTargetKey === dropTargetId(rootTarget);
+ return (
+
+ openFolder(space.id, "")}
+ onContextMenu={(event) => openTreeContextMenu(event, space.id, "")}
+ onDragOver={(event) => handleDropTargetDragOver(event, rootTarget)}
+ onDragLeave={clearDropState}
+ onDrop={(event) => void handleDropOnTarget(event, rootTarget)}
+ disabled={busy}
+ >
+
+ {space.label}
+
+
+
+ );
+ })}
+
+
+
+
+
+ {toolbar}
+
+ activeSpace && openFolder(activeSpace.id, "")} disabled={busy || !activeSpace}>
+ {activeSpace?.label || "Files"}
+
+ {folderCrumbs.map((crumb) => (
+
+
+ activeSpace && openFolder(activeSpace.id, crumb.path)} disabled={busy}>{crumb.name}
+
+ ))}
+
+
+
+ setSearchPattern(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter") void runPatternSearch(); }} />
+
+ void runPatternSearch()} disabled={busy || !activeSpace}>Search
+ {searchActive && void clearSearch()} disabled={busy}>Clear }
+
+ {selectedSummary}
+ {explorerEntries.filter((entry) => entry.kind === "folder").length} folder(s) · {explorerEntries.filter((entry) => entry.kind === "file").length} file(s)
+ {busy && Working… }
+ {unmatchedCount !== null && {unmatchedCount} visible file(s) were not matched. }
+ {selectedFiles.some((file) => file.audit_relevant) && Audit-relevant files stay retained when deleted from active browsing. }
+
+
+ toggleSort("name")}>{sortLabel("name", "Name")}
+ toggleSort("size")}>{sortLabel("size", "Size")}
+ toggleSort("modified")}>{sortLabel("modified", "Modified")}
+
+
- {active === "browse" && }
- {active === "upload" && }
- {active === "settings" && }
- {active === "retention" && }
- {active === "bulk" && }
- {active === "activity" && }
+ {
+ const target = toolbarTarget();
+ if (target) handleDropTargetDragOver(event, target);
+ setDragActive(true);
+ }}
+ onDragLeave={clearDropState}
+ onDrop={(event) => {
+ const target = toolbarTarget();
+ if (target) void handleDropOnTarget(event, target);
+ }}
+ onContextMenu={(event) => openContextMenu(event, "empty")}
+ onClick={(event) => {
+ if (event.target === event.currentTarget) applySelectionKeys(new Set
());
+ }}
+ >
+
+ {explorerEntries.length === 0 && (
+
This folder is empty. Use Upload or Create Folder to add content.
+ )}
+ {explorerEntries.map((entry) => {
+ const entryDropTarget = entry.kind === "folder" && activeSpace ? { spaceId: activeSpace.id, folderPath: entry.path } : null;
+ const isDropTarget = entryDropTarget ? dropTargetKey === dropTargetId(entryDropTarget) : false;
+ return entry.kind === "folder" ? (
+
handleInternalDragStart(entry, event)}
+ onDragEnd={() => { setInternalDrag(null); clearDropState(); }}
+ onDragOver={(event) => entryDropTarget && handleDropTargetDragOver(event, entryDropTarget)}
+ onDragLeave={clearDropState}
+ onDrop={(event) => entryDropTarget && void handleDropOnTarget(event, entryDropTarget)}
+ onMouseDown={preventTextSelectionOnShift}
+ onClick={(event) => handleEntrySelection(entry, event)}
+ onContextMenu={(event) => openContextMenu(event, "folder", entry)}
+ onKeyDown={(event) => handleEntryKeyDown(entry, event)}
+ >
+
+ { event.stopPropagation(); activeSpace && openFolder(activeSpace.id, entry.path); }} disabled={busy} title="Open folder">
+
+ {entry.name} {folderContentLabel(entry)}
+
+
+
{formatBytes(entry.totalSize)}
+
{formatDate(entry.updatedAt)}
+
+ ) : (
+
handleInternalDragStart(entry, event)}
+ onDragEnd={() => { setInternalDrag(null); clearDropState(); }}
+ onMouseDown={preventTextSelectionOnShift}
+ onClick={(event) => handleEntrySelection(entry, event)}
+ onContextMenu={(event) => openContextMenu(event, "file", entry)}
+ onKeyDown={(event) => handleEntryKeyDown(entry, event)}
+ >
+
+ { event.stopPropagation(); void downloadFile(settings, entry.file); }} title="Download file">
+
+ {searchActive ? entry.file.display_path : entry.file.filename}
+
+
+
{formatBytes(entry.file.size_bytes)}
+
{formatDate(entry.file.updated_at)}
+
+ );
+ })}
+
+ {busy && (
+
+ )}
- );
+
+ {conflictDialog && (
+
setConflictDialog(null)}
+ onCancel={() => setConflictDialog(null)}
+ onOverwrite={() => void applyConflictDecision("overwrite")}
+ onRename={() => void applyConflictDecision("rename")}
+ onReview={() => void applyConflictDecision("review")}
+ onApplyReview={() => void applyConflictDecision("review")}
+ onUpdateItem={updateConflictItem}
+ />
+ )}
+
+ void performConfirmedDelete()}
+ onCancel={() => setDeleteDialog(null)}
+ />
+
+ {contextMenu && (
+ 0 || selectedSetsForContext(contextMenu).folderPaths.size > 0}
+ downloadLabel={downloadLabelForSets(selectedSetsForContext(contextMenu), contextMenu.spaceId ?? activeSpaceId)}
+ onCreateFolder={() => openCreateFolderDialogForContext(contextMenu)}
+ onUpload={() => openUploadDialogForContext(contextMenu)}
+ onDownload={() => void downloadContextSelection(contextMenu)}
+ onMove={() => openTransferDialogForContext(contextMenu, "move")}
+ onCopy={() => openTransferDialogForContext(contextMenu, "copy")}
+ onDelete={() => void deleteContextSelection(contextMenu)}
+ />
+ )}
+
+ {dialog === "upload" && (
+
+ { event.preventDefault(); setDragActive(true); }}
+ onDragLeave={() => setDragActive(false)}
+ onDrop={(event) => {
+ event.preventDefault();
+ setDragActive(false);
+ void handleFilesUpload(event.dataTransfer.files);
+ }}
+ >
+
+ Drop files here
+ Files are uploaded into {activeDialogTarget?.folderPath || "Root"}.
+
+
+ event.target.files && void handleFilesUpload(event.target.files)} />
+
+ Cancel
+ fileInputRef.current?.click()} disabled={busy || !activeDialogSpace}>Select files…
+
+
+ )}
+
+ {dialog === "create-folder" && (
+
+
+ Folder name
+ { setNewFolderName(event.target.value); setNewFolderError(""); }} onKeyDown={(event) => { if (event.key === "Enter") void handleCreateFolder(); }} />
+ Created inside {activeDialogTarget?.folderPath || "Root"}.
+ {newFolderError && {newFolderError} }
+
+
+ Cancel
+ void handleCreateFolder()} disabled={busy || !newFolderName.trim()}>Create Folder
+
+
+ )}
+
+ {dialog === "transfer" && transferDialogState && (
+
+ Choose a destination space and folder. Folders are transferred recursively.
+
+
+ Destination space
+ setTransferDialogState((current) => current ? { ...current, targetSpaceId: event.target.value, targetFolder: "" } : current)}>
+ {spaces.map((space) => {space.label} )}
+
+
+
+ Destination folder
+ setTransferDialogState((current) => current ? { ...current, targetFolder: folderPath } : current)}
+ />
+
+
+
+ Cancel
+ void handleTransferDialogSubmit()} disabled={busy}>{transferMode === "copy" ? "Copy" : "Move"}
+
+
+ )}
+
+ {dialog === "single-rename" && (
+
+
+ New name
+ setSingleRenameName(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter") void runSingleRename(); }} />
+ Only the visible name changes. Immutable blobs stay stable for audit.
+
+
+ Cancel
+ void runSingleRename()} disabled={busy || !singleRenameName.trim()}>Rename
+
+
+ )}
+
+ {dialog === "rename" && (
+
+ Bulk rename changes managed display paths only. Immutable blobs stay stable for audit.
+
+
+ Mode
+ setRenameMode(event.target.value as RenameMode)}>
+ Add prefix
+ Add suffix before extension
+ Find and replace
+
+
+ {renameMode === "prefix" && Prefix setRenamePrefix(event.target.value)} /> }
+ {renameMode === "suffix" && Suffix setRenameSuffix(event.target.value)} /> }
+ {renameMode === "replace" && <>Find setRenameFind(event.target.value)} />Replacement setRenameReplacement(event.target.value)} /> >}
+
+ {selectedFolderPaths.size > 0 && (
+
+ )}
+
+ void runRenamePreview(true)} disabled={busy || selectedEntryCount === 0}>Preview rename
+ void runRenamePreview(false)} disabled={busy || selectedEntryCount === 0 || !renamePreview}>Apply preview
+
+ {renamePreview && (
+ setRenamePreviewVisibleCount((current) => current + 20)}
+ onShowFewer={() => setRenamePreviewVisibleCount(20)}
+ />
+ )}
+
+ )}
+
+ );
+}
+
+
+function TransferFolderSelector({
+ space,
+ nodes,
+ selectedFolder,
+ onSelect,
+ disabled
+}: {
+ space: FileSpace | null;
+ nodes: FolderNode[];
+ selectedFolder: string;
+ onSelect: (folderPath: string) => void;
+ disabled?: boolean;
+}) {
+ return (
+
+ onSelect("")}
+ disabled={disabled || !space}
+ >
+
+ {space?.label || "Root"}
+
+ {nodes.length === 0 && No folders yet. Choose the root folder. }
+
+
+ );
+}
+
+function TransferFolderSelectorNodes({
+ nodes,
+ selectedFolder,
+ onSelect,
+ disabled,
+ depth = 1
+}: {
+ nodes: FolderNode[];
+ selectedFolder: string;
+ onSelect: (folderPath: string) => void;
+ disabled?: boolean;
+ depth?: number;
+}) {
+ if (nodes.length === 0) return null;
+ return (
+
+ {nodes.map((node) => (
+
+ onSelect(node.path)}
+ disabled={disabled}
+ >
+
+ {node.name}
+
+
+
+ ))}
+
+ );
+}
+
+function FolderTree({
+ nodes,
+ activeSpaceId,
+ spaceId,
+ currentFolder,
+ dropTargetKey,
+ expandedKeys,
+ onOpen,
+ onToggle,
+ onContextMenu,
+ onDragOverTarget,
+ onDropOnTarget,
+ onClearDropState,
+ onRequestDragExpand,
+ disabled,
+ depth = 1
+}: {
+ nodes: FolderNode[];
+ activeSpaceId: string;
+ spaceId: string;
+ currentFolder: string;
+ dropTargetKey: string;
+ expandedKeys: Set
;
+ onOpen: (spaceId: string, path: string) => void;
+ onToggle: (spaceId: string, path: string) => void;
+ onContextMenu: (event: ReactMouseEvent, spaceId: string, path: string) => void;
+ onDragOverTarget: (event: ReactDragEvent, target: FileActionTarget) => void;
+ onDropOnTarget: (event: ReactDragEvent, target: FileActionTarget) => Promise;
+ onClearDropState: () => void;
+ onRequestDragExpand: (spaceId: string, path: string) => void;
+ disabled?: boolean;
+ depth?: number;
+}) {
+ if (nodes.length === 0) return null;
+ return (
+
+ {nodes.map((node) => {
+ const isActive = activeSpaceId === spaceId && currentFolder === node.path;
+ const target = { spaceId, folderPath: node.path };
+ const isDropTarget = dropTargetKey === `${target.spaceId}:${normalizeFolder(target.folderPath)}`;
+ const hasChildren = node.children.length > 0;
+ const isExpanded = expandedKeys.has(treeNodeKey(spaceId, node.path));
+ return (
+
+
onContextMenu(event, spaceId, node.path)}
+ onDragOver={(event) => {
+ onDragOverTarget(event, target);
+ if (hasChildren && !isExpanded) onRequestDragExpand(spaceId, node.path);
+ }}
+ onDragLeave={onClearDropState}
+ onDrop={(event) => void onDropOnTarget(event, target)}
+ >
+ {
+ event.stopPropagation();
+ if (hasChildren) onToggle(spaceId, node.path);
+ }}
+ disabled={disabled || !hasChildren}
+ aria-label={`${isExpanded ? "Collapse" : "Expand"} ${node.name}`}
+ aria-expanded={hasChildren ? isExpanded : undefined}
+ >
+ {isExpanded ? : }
+
+ onOpen(spaceId, node.path)}
+ disabled={disabled}
+ >
+ {node.name}
+
+
+ {hasChildren && isExpanded && (
+
+ )}
+
+ );
+ })}
+
+ );
+}
+
+function RenamePreviewList({
+ response,
+ selectedFileIds,
+ selectedFolderPaths,
+ recursive,
+ visibleCount,
+ onShowMore,
+ onShowFewer
+}: {
+ response: RenameResponse;
+ selectedFileIds: Set;
+ selectedFolderPaths: Set;
+ recursive: boolean;
+ visibleCount: number;
+ onShowMore: () => void;
+ onShowFewer: () => void;
+}) {
+ const selectedFolderRoots = Array.from(selectedFolderPaths).map(normalizeFolder);
+ const hiddenNonRecursiveItems = !recursive
+ ? response.items.filter((item) => {
+ if (item.kind === "file" && selectedFileIds.has(item.id)) return false;
+ if (item.kind === "folder" && selectedFolderRoots.includes(normalizeFolder(item.old_path))) return false;
+ return selectedFolderRoots.some((folder) => isPathUnderOrSame(item.old_path, folder));
+ }).length
+ : 0;
+ const visibleItems = response.items.filter((item) => {
+ if (recursive) return true;
+ if (item.kind === "file") {
+ if (selectedFileIds.has(item.id)) return true;
+ return !selectedFolderRoots.some((folder) => isPathUnderOrSame(item.old_path, folder));
+ }
+ return selectedFolderRoots.includes(normalizeFolder(item.old_path));
+ });
+ const safeVisibleCount = Math.max(20, visibleCount);
+ const shownItems = visibleItems.slice(0, safeVisibleCount);
+ const remaining = visibleItems.length - shownItems.length;
+ return (
+
+
+ {hiddenNonRecursiveItems > 0 && {hiddenNonRecursiveItems} contained path(s) will move with the selected folder(s), but their own names will stay unchanged. }
+ {shownItems.map((item) => {item.old_path} → {item.new_path} )}
+ {remaining > 0 && (
+ … and {remaining} more — show next {Math.min(20, remaining)}
+ )}
+ {shownItems.length > 20 && (
+ Show fewer
+ )}
+
+
+ );
+}
+
+function FileDialog({ title, onClose, children }: { title: string; onClose: () => void; children: ReactNode }) {
+ return (
+ { if (event.target === event.currentTarget) onClose(); }}>
+
+
+
{title}
+ ×
+
+
{children}
+
+
+ );
+}
+
+function FileContextMenu({
+ menu,
+ canDownload,
+ downloadLabel,
+ onCreateFolder,
+ onUpload,
+ onDownload,
+ onMove,
+ onCopy,
+ onDelete
+}: {
+ menu: ContextMenuState;
+ canDownload: boolean;
+ downloadLabel: string;
+ onCreateFolder: () => void;
+ onUpload: () => void;
+ onDownload: () => void;
+ onMove: () => void;
+ onCopy: () => void;
+ onDelete: () => void;
+}) {
+ const showNewFolder = true;
+ const showDelete = menu.target !== "empty";
+ return (
+ event.stopPropagation()}>
+ {showNewFolder &&
New folder}
+
Upload
+ {canDownload &&
{downloadLabel}}
+ {canDownload &&
Move…}
+ {canDownload &&
Copy…}
+ {showDelete &&
Delete}
+
+ );
+}
+
+function FileConflictDialog({
+ state,
+ busy,
+ onClose,
+ onCancel,
+ onOverwrite,
+ onRename,
+ onReview,
+ onApplyReview,
+ onUpdateItem
+}: {
+ state: ConflictDialogState;
+ busy: boolean;
+ onClose: () => void;
+ onCancel: () => void;
+ onOverwrite: () => void;
+ onRename: () => void;
+ onReview: () => void;
+ onApplyReview: () => void;
+ onUpdateItem: (id: string, patch: Partial) => void;
+}) {
+ return (
+
+ {state.message}
+
+ {state.items.length} conflict(s)
+ {state.items.length > 1 ? "Choose the same action for all conflicts or review the target names individually." : "Choose how to resolve this conflict."}
+
+ {state.review && (
+
+ {state.items.map((item) => (
+
+
+ {item.label}
+ {item.kind === "folder" ? "Folder" : "File"} conflict at {item.targetPath}
+
+
onUpdateItem(item.id, { action: event.target.value as ConflictAction })} disabled={busy}>
+ Overwrite
+ Rename
+ Skip
+
+
onUpdateItem(item.id, { newPath: event.target.value })}
+ disabled={busy || item.action !== "rename"}
+ aria-label={`New path for ${item.label}`}
+ />
+
+ ))}
+
+ )}
+ {!state.review && (
+
+ {state.items.slice(0, 8).map((item) => {item.targetPath} )}
+ {state.items.length > 8 && … and {state.items.length - 8} more }
+
+ )}
+
+ Cancel
+ Overwrite all
+ Rename all
+ {!state.review && state.items.length > 1 && Review individually }
+ {state.review && Apply reviewed choices }
+
+
+ );
+}
+
+function buildFolderTree(files: ManagedFile[], folders: FileFolder[]): FolderNode[] {
+ const byPath = new Map();
+ const ensureNode = (path: string, persisted: boolean): FolderNode | null => {
+ const normalized = normalizeFolder(path);
+ if (!normalized) return null;
+ const existing = byPath.get(normalized);
+ if (existing) {
+ existing.persisted = existing.persisted || persisted;
+ return existing;
+ }
+ const parts = normalized.split("/");
+ const node: FolderNode = {
+ name: parts[parts.length - 1],
+ path: normalized,
+ children: [],
+ fileCount: 0,
+ persisted
+ };
+ byPath.set(normalized, node);
+ const parentPath = parts.slice(0, -1).join("/");
+ const parent = ensureNode(parentPath, false);
+ if (parent) parent.children.push(node);
+ return node;
+ };
+ for (const folder of folders) ensureNode(folder.path, true);
+ for (const file of files) {
+ const parts = normalizeFilePath(file.display_path).split("/").filter(Boolean);
+ for (let index = 0; index < parts.length - 1; index += 1) {
+ const node = ensureNode(parts.slice(0, index + 1).join("/"), false);
+ if (node) node.fileCount += 1;
+ }
+ }
+ const roots = Array.from(byPath.values()).filter((node) => !node.path.includes("/"));
+ sortFolderNodes(roots);
+ return roots;
+}
+
+function sortFolderNodes(nodes: FolderNode[]) {
+ nodes.sort((a, b) => a.name.localeCompare(b.name));
+ for (const node of nodes) sortFolderNodes(node.children);
+}
+
+function treeNodeKey(spaceId: string, folderPath: string): string {
+ return `${spaceId}:${normalizeFolder(folderPath)}`;
+}
+
+function folderAncestorPaths(folderPath: string, options: { includeSelf?: boolean } = {}): string[] {
+ const parts = normalizeFolder(folderPath).split("/").filter(Boolean);
+ const limit = options.includeSelf ? parts.length : Math.max(parts.length - 1, 0);
+ const result: string[] = [];
+ for (let index = 1; index <= limit; index += 1) {
+ result.push(parts.slice(0, index).join("/"));
+ }
+ return result;
+}
+
+function isPathUnderOrSame(path: string, root: string): boolean {
+ const normalizedPath = normalizeFolder(path);
+ const normalizedRoot = normalizeFolder(root);
+ if (!normalizedRoot) return true;
+ return normalizedPath === normalizedRoot || normalizedPath.startsWith(`${normalizedRoot}/`);
+}
+
+function buildExplorerEntries(files: ManagedFile[], folders: FileFolder[], currentFolder: string, searchActive: boolean): ExplorerEntry[] {
+ if (searchActive) return files.map((file) => ({ kind: "file", id: file.id, file }));
+ const folder = normalizeFolder(currentFolder);
+ const prefix = folder ? `${folder}/` : "";
+ const folderMap = new Map();
+ const directFiles: FileEntry[] = [];
+
+ for (const persisted of folders) {
+ const path = normalizeFolder(persisted.path);
+ if (!path.startsWith(prefix) || path === folder) continue;
+ const relative = prefix ? path.slice(prefix.length) : path;
+ if (!relative || relative.includes("/")) continue;
+ folderMap.set(path, {
+ kind: "folder",
+ id: `folder:${path}`,
+ name: relative,
+ path,
+ fileCount: 0,
+ folderCount: 0,
+ totalSize: 0,
+ updatedAt: persisted.updated_at,
+ persisted: true
+ });
}
- return (
-
-
-
-
Files
-
Manage file storages first. Open a storage to browse content, upload files and configure retention.
-
-
- Refresh
- Add storage
-
-
+ for (const file of files) {
+ const path = normalizeFilePath(file.display_path || file.filename);
+ if (prefix && !path.startsWith(prefix)) continue;
+ const relativePath = prefix ? path.slice(prefix.length) : path;
+ if (!relativePath) continue;
+ const slashIndex = relativePath.indexOf("/");
+ if (slashIndex === -1) {
+ directFiles.push({ kind: "file", id: file.id, file });
+ continue;
+ }
+ const folderName = relativePath.slice(0, slashIndex);
+ const folderPath = prefix ? `${folder}/${folderName}` : folderName;
+ const existing = folderMap.get(folderPath);
+ if (existing) {
+ existing.totalSize += file.size_bytes;
+ if (file.updated_at > existing.updatedAt) existing.updatedAt = file.updated_at;
+ } else {
+ folderMap.set(folderPath, {
+ kind: "folder",
+ id: `folder:${folderPath}`,
+ name: folderName,
+ path: folderPath,
+ fileCount: 0,
+ folderCount: 0,
+ totalSize: file.size_bytes,
+ updatedAt: file.updated_at,
+ persisted: false
+ });
+ }
+ }
-
- File storages
- Storage endpoints are placeholders until the backend model is added
-
- }
- >
- storage.id}
- emptyText="No file storages configured."
- className="compact-table-wrap module-table-wrap module-entry-table"
- />
-
-
- );
+ for (const entry of folderMap.values()) {
+ const counts = directFolderCounts(files, folders, entry.path);
+ entry.fileCount = counts.files;
+ entry.folderCount = counts.folders;
+ }
+
+ return [...Array.from(folderMap.values()), ...directFiles];
}
-function StorageBrowse({ storage }: { storage: StorageRecord }) {
- return (
-