initial commit after split
This commit is contained in:
380
webui/src/features/files/components/FileManagerComponents.tsx
Normal file
380
webui/src/features/files/components/FileManagerComponents.tsx
Normal file
@@ -0,0 +1,380 @@
|
||||
import type { CSSProperties, DragEvent as ReactDragEvent, MouseEvent as ReactMouseEvent, ReactNode } from "react";
|
||||
import { Copy, Download, Folder, FolderOpen, Home, MoveRight, Plus, Trash2, UploadCloud } from "lucide-react";
|
||||
import { Button, Dialog } from "@govoplan/core-webui";
|
||||
import type { ConflictAction, FileSpace, RenameResponse } from "../../../api/files";
|
||||
import type { ConflictDialogState, FileActionTarget, FileConflictItem, FolderNode, ContextMenuState } from "../types";
|
||||
import { isPathUnderOrSame, normalizeFolder, treeNodeKey } from "../utils/fileManagerUtils";
|
||||
|
||||
export function TransferFolderSelector({
|
||||
space,
|
||||
nodes,
|
||||
selectedFolder,
|
||||
onSelect,
|
||||
disabled
|
||||
}: {
|
||||
space: FileSpace | null;
|
||||
nodes: FolderNode[];
|
||||
selectedFolder: string;
|
||||
onSelect: (folderPath: string) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="file-folder-selector" role="tree" aria-label="Destination folder">
|
||||
<button
|
||||
type="button"
|
||||
className={`file-folder-selector-node ${selectedFolder === "" ? "is-selected" : ""}`}
|
||||
onClick={() => onSelect("")}
|
||||
disabled={disabled || !space}
|
||||
>
|
||||
<Home size={15} aria-hidden="true" />
|
||||
<span>{space?.label || "Root"}</span>
|
||||
</button>
|
||||
{nodes.length === 0 && <span className="muted small-text file-folder-selector-empty">No folders yet. Choose the root folder.</span>}
|
||||
<TransferFolderSelectorNodes nodes={nodes} selectedFolder={selectedFolder} onSelect={onSelect} disabled={disabled} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export 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 (
|
||||
<div className="file-folder-selector-children">
|
||||
{nodes.map((node) => (
|
||||
<div key={node.path}>
|
||||
<button
|
||||
type="button"
|
||||
className={`file-folder-selector-node ${selectedFolder === node.path ? "is-selected" : ""}`}
|
||||
style={{ paddingLeft: `${Math.min(depth * 16 + 10, 74)}px` }}
|
||||
onClick={() => onSelect(node.path)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<FolderOpen size={15} aria-hidden="true" />
|
||||
<span>{node.name}</span>
|
||||
</button>
|
||||
<TransferFolderSelectorNodes nodes={node.children} selectedFolder={selectedFolder} onSelect={onSelect} disabled={disabled} depth={depth + 1} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FolderTree({
|
||||
nodes,
|
||||
activeSpaceId,
|
||||
spaceId,
|
||||
currentFolder,
|
||||
dropTargetKey = "",
|
||||
expandedKeys,
|
||||
onOpen,
|
||||
onToggle,
|
||||
onContextMenu,
|
||||
onDragOverTarget,
|
||||
onDropOnTarget,
|
||||
onClearDropState,
|
||||
onRequestDragExpand,
|
||||
onDragStartFolder,
|
||||
onDragEndFolder,
|
||||
dragDropEnabled = true,
|
||||
contextMenuEnabled = true,
|
||||
disabled,
|
||||
depth = 1
|
||||
}: {
|
||||
nodes: FolderNode[];
|
||||
activeSpaceId: string;
|
||||
spaceId: string;
|
||||
currentFolder: string;
|
||||
dropTargetKey?: string;
|
||||
expandedKeys: Set<string>;
|
||||
onOpen: (spaceId: string, path: string) => void;
|
||||
onToggle: (spaceId: string, path: string) => void;
|
||||
onContextMenu?: (event: ReactMouseEvent<HTMLElement>, spaceId: string, path: string) => void;
|
||||
onDragOverTarget?: (event: ReactDragEvent<HTMLElement>, target: FileActionTarget) => void;
|
||||
onDropOnTarget?: (event: ReactDragEvent<HTMLElement>, target: FileActionTarget) => Promise<void>;
|
||||
onClearDropState?: () => void;
|
||||
onRequestDragExpand?: (spaceId: string, path: string) => void;
|
||||
onDragStartFolder?: (spaceId: string, path: string, event: ReactDragEvent<HTMLElement>) => void;
|
||||
onDragEndFolder?: () => void;
|
||||
dragDropEnabled?: boolean;
|
||||
contextMenuEnabled?: boolean;
|
||||
disabled?: boolean;
|
||||
depth?: number;
|
||||
}) {
|
||||
if (nodes.length === 0) return null;
|
||||
return (
|
||||
<div className="file-tree-children">
|
||||
{nodes.map((node) => {
|
||||
const isActive = activeSpaceId === spaceId && currentFolder === node.path;
|
||||
const target = { spaceId, folderPath: node.path };
|
||||
const isDropTarget = dragDropEnabled && dropTargetKey === `${target.spaceId}:${normalizeFolder(target.folderPath)}`;
|
||||
const hasChildren = node.children.length > 0;
|
||||
const isExpanded = expandedKeys.has(treeNodeKey(spaceId, node.path));
|
||||
return (
|
||||
<div key={node.path}>
|
||||
<div
|
||||
className={`file-tree-node-wrap ${isActive ? "is-active" : ""} ${isDropTarget ? "is-drop-target" : ""}`}
|
||||
style={{ paddingLeft: `${Math.min(depth * 14, 56)}px` }}
|
||||
draggable={dragDropEnabled && !disabled}
|
||||
onDragStart={dragDropEnabled && onDragStartFolder ? (event) => onDragStartFolder(spaceId, node.path, event) : undefined}
|
||||
onDragEnd={dragDropEnabled ? onDragEndFolder : undefined}
|
||||
onContextMenu={contextMenuEnabled && onContextMenu ? (event) => onContextMenu(event, spaceId, node.path) : undefined}
|
||||
onDragOver={dragDropEnabled && onDragOverTarget ? (event) => {
|
||||
onDragOverTarget(event, target);
|
||||
if (hasChildren && !isExpanded) onRequestDragExpand?.(spaceId, node.path);
|
||||
} : undefined}
|
||||
onDragLeave={dragDropEnabled ? onClearDropState : undefined}
|
||||
onDrop={dragDropEnabled && onDropOnTarget ? (event) => void onDropOnTarget(event, target) : undefined}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="file-tree-toggle"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
if (hasChildren) onToggle(spaceId, node.path);
|
||||
}}
|
||||
disabled={disabled || !hasChildren}
|
||||
aria-label={`${isExpanded ? "Collapse" : "Expand"} ${node.name}`}
|
||||
aria-expanded={hasChildren ? isExpanded : undefined}
|
||||
>
|
||||
{isExpanded ? <FolderOpen size={15} aria-hidden="true" /> : <Folder size={15} aria-hidden="true" />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="file-tree-node"
|
||||
onClick={() => onOpen(spaceId, node.path)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<span>{node.name}</span>
|
||||
</button>
|
||||
</div>
|
||||
{hasChildren && isExpanded && (
|
||||
<FolderTree
|
||||
nodes={node.children}
|
||||
activeSpaceId={activeSpaceId}
|
||||
spaceId={spaceId}
|
||||
currentFolder={currentFolder}
|
||||
dropTargetKey={dropTargetKey}
|
||||
expandedKeys={expandedKeys}
|
||||
onOpen={onOpen}
|
||||
onToggle={onToggle}
|
||||
onContextMenu={onContextMenu}
|
||||
onDragOverTarget={onDragOverTarget}
|
||||
onDropOnTarget={onDropOnTarget}
|
||||
onClearDropState={onClearDropState}
|
||||
onRequestDragExpand={onRequestDragExpand}
|
||||
onDragStartFolder={onDragStartFolder}
|
||||
onDragEndFolder={onDragEndFolder}
|
||||
dragDropEnabled={dragDropEnabled}
|
||||
contextMenuEnabled={contextMenuEnabled}
|
||||
disabled={disabled}
|
||||
depth={depth + 1}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function RenamePreviewList({
|
||||
response,
|
||||
selectedFileIds,
|
||||
selectedFolderPaths,
|
||||
recursive,
|
||||
visibleCount,
|
||||
onShowMore,
|
||||
onShowFewer
|
||||
}: {
|
||||
response: RenameResponse;
|
||||
selectedFileIds: Set<string>;
|
||||
selectedFolderPaths: Set<string>;
|
||||
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 (
|
||||
<div className="rename-preview-panel">
|
||||
<div className="placeholder-stack rename-preview-list">
|
||||
{hiddenNonRecursiveItems > 0 && <span className="muted">{hiddenNonRecursiveItems} contained path(s) will move with the selected folder(s), but their own names will stay unchanged.</span>}
|
||||
{shownItems.map((item) => <span key={`${item.kind}:${item.id}:${item.old_path}`}><code>{item.old_path}</code> → <code>{item.new_path}</code></span>)}
|
||||
{remaining > 0 && (
|
||||
<button type="button" className="rename-preview-more" onClick={onShowMore}>… and {remaining} more — show next {Math.min(20, remaining)}</button>
|
||||
)}
|
||||
{shownItems.length > 20 && (
|
||||
<button type="button" className="rename-preview-more" onClick={onShowFewer}>Show fewer</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FileDialog({ title, onClose, children }: { title: string; onClose: () => void; children: ReactNode }) {
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
title={title}
|
||||
onClose={onClose}
|
||||
backdropClassName="file-dialog-backdrop"
|
||||
className="file-dialog"
|
||||
headerClassName="file-dialog-header"
|
||||
titleClassName="file-dialog-title"
|
||||
bodyClassName="file-dialog-body"
|
||||
>
|
||||
{children}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function FileContextMenu({
|
||||
menu,
|
||||
hasSelection,
|
||||
canCreateFolder,
|
||||
canUpload,
|
||||
canDownload,
|
||||
canOrganize,
|
||||
canDelete,
|
||||
downloadLabel,
|
||||
onCreateFolder,
|
||||
onUpload,
|
||||
onDownload,
|
||||
onMove,
|
||||
onCopy,
|
||||
onDelete
|
||||
}: {
|
||||
menu: ContextMenuState;
|
||||
hasSelection: boolean;
|
||||
canCreateFolder: boolean;
|
||||
canUpload: boolean;
|
||||
canDownload: boolean;
|
||||
canOrganize: boolean;
|
||||
canDelete: boolean;
|
||||
downloadLabel: string;
|
||||
onCreateFolder: () => void;
|
||||
onUpload: () => void;
|
||||
onDownload: () => void;
|
||||
onMove: () => void;
|
||||
onCopy: () => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const showNewFolder = true;
|
||||
const showDelete = menu.target !== "empty";
|
||||
const viewportWidth = typeof window === "undefined" ? 1024 : window.innerWidth;
|
||||
const viewportHeight = typeof window === "undefined" ? 768 : window.innerHeight;
|
||||
const estimatedWidth = 220;
|
||||
const estimatedHeight = 260;
|
||||
const left = Math.max(8, Math.min(menu.x, viewportWidth - estimatedWidth - 8));
|
||||
const openUp = menu.y + estimatedHeight > viewportHeight;
|
||||
const style: CSSProperties = openUp
|
||||
? { left, bottom: Math.max(8, viewportHeight - menu.y) }
|
||||
: { left, top: Math.max(8, menu.y) };
|
||||
return (
|
||||
<div className="file-context-menu" style={style} role="menu" onClick={(event) => event.stopPropagation()}>
|
||||
{showNewFolder && <button type="button" role="menuitem" onClick={onCreateFolder} disabled={!canCreateFolder}><Plus size={15} aria-hidden="true" /> New folder</button>}
|
||||
<button type="button" role="menuitem" onClick={onUpload} disabled={!canUpload}><UploadCloud size={15} aria-hidden="true" /> Upload</button>
|
||||
<button type="button" role="menuitem" onClick={onDownload} disabled={!hasSelection || !canDownload}><Download size={15} aria-hidden="true" /> {downloadLabel}</button>
|
||||
<button type="button" role="menuitem" onClick={onMove} disabled={!hasSelection || !canOrganize}><MoveRight size={15} aria-hidden="true" /> Move…</button>
|
||||
<button type="button" role="menuitem" onClick={onCopy} disabled={!hasSelection || !canOrganize}><Copy size={15} aria-hidden="true" /> Copy…</button>
|
||||
{showDelete && <button type="button" role="menuitem" className="danger" onClick={onDelete} disabled={!canDelete}><Trash2 size={15} aria-hidden="true" /> Delete</button>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export 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<FileConflictItem>) => void;
|
||||
}) {
|
||||
return (
|
||||
<FileDialog title={state.title} onClose={onClose}>
|
||||
<p className="muted">{state.message}</p>
|
||||
<div className="file-conflict-summary">
|
||||
<strong>{state.items.length} conflict(s)</strong>
|
||||
<span>{state.items.length > 1 ? "Choose the same action for all conflicts or review the target names individually." : "Choose how to resolve this conflict."}</span>
|
||||
</div>
|
||||
{state.review && (
|
||||
<div className="file-conflict-list">
|
||||
{state.items.map((item) => (
|
||||
<div className="file-conflict-row" key={item.id}>
|
||||
<div>
|
||||
<strong>{item.label}</strong>
|
||||
<small>{item.kind === "folder" ? "Folder" : "File"} conflict at <code>{item.targetPath}</code></small>
|
||||
</div>
|
||||
<select value={item.action} onChange={(event) => onUpdateItem(item.id, { action: event.target.value as ConflictAction })} disabled={busy}>
|
||||
<option value="overwrite">Overwrite</option>
|
||||
<option value="rename">Rename</option>
|
||||
<option value="skip">Skip</option>
|
||||
</select>
|
||||
<input
|
||||
value={item.newPath}
|
||||
onChange={(event) => onUpdateItem(item.id, { newPath: event.target.value })}
|
||||
disabled={busy || item.action !== "rename"}
|
||||
aria-label={`New path for ${item.label}`}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!state.review && (
|
||||
<div className="placeholder-stack">
|
||||
{state.items.slice(0, 8).map((item) => <span key={item.id}><code>{item.targetPath}</code></span>)}
|
||||
{state.items.length > 8 && <span>… and {state.items.length - 8} more</span>}
|
||||
</div>
|
||||
)}
|
||||
<div className="button-row compact-actions align-end">
|
||||
<Button onClick={onCancel} disabled={busy}>Cancel</Button>
|
||||
<Button onClick={onOverwrite} disabled={busy}>Overwrite all</Button>
|
||||
<Button onClick={onRename} disabled={busy}>Rename all</Button>
|
||||
{!state.review && state.items.length > 1 && <Button onClick={onReview} disabled={busy}>Review individually</Button>}
|
||||
{state.review && <Button variant="primary" onClick={onApplyReview} disabled={busy}>Apply reviewed choices</Button>}
|
||||
</div>
|
||||
</FileDialog>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user