frontend_nyla/src/components/FolderTree/FolderTree.tsx
ValueOn AG 71bf6baae5 fixes
2026-03-30 23:39:13 +02:00

804 lines
31 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* FolderTree Shared recursive folder/file tree component.
*
* Used on the Files page and in the Workspace chat.
* Supports:
* - Alphabetical sorting per level (folders first, then files)
* - Multi-selection (CTRL+click, SHIFT+click) with visual highlight
* - Batch drag-and-drop for selected items
* - Inline CRUD icons for folders
* - showFiles mode renders files inline under their parent folder
* - Drag-out: sets application/tree-items on dataTransfer for external drop targets
*/
import React, { useState, useCallback, useRef, useEffect, useMemo } from 'react';
import { FaFolder, FaFolderOpen, FaPlus, FaPen, FaTrash, FaChevronRight, FaGlobe, FaSyncAlt, FaDownload } from 'react-icons/fa';
import { usePrompt, type PromptOptions } from '../../hooks/usePrompt';
import styles from './FolderTree.module.css';
/* ── Public types ──────────────────────────────────────────────────────── */
export interface FolderNode {
id: string;
name: string;
parentId: string | null;
children?: FolderNode[];
}
export interface FileNode {
id: string;
fileName: string;
mimeType?: string;
fileSize?: number;
folderId?: string | null;
scope?: string;
neutralize?: boolean;
}
export interface TreeItem {
id: string;
type: 'file' | 'folder';
name: string;
}
export interface FolderTreeProps {
folders: FolderNode[];
files?: FileNode[];
showFiles?: boolean;
selectedFolderId: string | null;
onSelect: (folderId: string | null) => void;
onFileSelect?: (fileId: string) => void;
selectedItemIds?: Set<string>;
onSelectionChange?: (selectedIds: Set<string>) => void;
expandedIds?: Set<string>;
onToggleExpand?: (id: string) => void;
onRefresh?: () => void;
onCreateFolder?: (name: string, parentId: string | null) => Promise<void>;
onRenameFolder?: (folderId: string, newName: string) => Promise<void>;
onDeleteFolder?: (folderId: string) => Promise<void>;
onMoveFolder?: (folderId: string, targetParentId: string | null) => Promise<void>;
onMoveFolders?: (folderIds: string[], targetParentId: string | null) => Promise<void>;
onMoveFile?: (fileId: string, targetFolderId: string | null) => Promise<void>;
onMoveFiles?: (fileIds: string[], targetFolderId: string | null) => Promise<void>;
onRenameFile?: (fileId: string, newName: string) => Promise<void>;
onDeleteFile?: (fileId: string) => Promise<void>;
onDeleteFiles?: (fileIds: string[]) => Promise<void>;
onDeleteFolders?: (folderIds: string[]) => Promise<void>;
onDownloadFolder?: (folderId: string, folderName: string) => Promise<void>;
onScopeChange?: (fileId: string, newScope: string) => void;
onNeutralizeToggle?: (fileId: string, newValue: boolean) => void;
}
/* ── Helpers ───────────────────────────────────────────────────────────── */
function _buildTree(folders: FolderNode[]): FolderNode[] {
const map = new Map<string, FolderNode>();
const roots: FolderNode[] = [];
for (const f of folders) map.set(f.id, { ...f, children: [] });
for (const f of folders) {
const node = map.get(f.id)!;
if (f.parentId && map.has(f.parentId)) {
map.get(f.parentId)!.children!.push(node);
} else {
roots.push(node);
}
}
const _sortLevel = (nodes: FolderNode[]) => {
nodes.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }));
for (const n of nodes) {
if (n.children && n.children.length > 0) _sortLevel(n.children);
}
};
_sortLevel(roots);
return roots;
}
function _groupFilesByFolder(files: FileNode[]): Map<string, FileNode[]> {
const map = new Map<string, FileNode[]>();
for (const f of files) {
const key = f.folderId || '';
if (!map.has(key)) map.set(key, []);
map.get(key)!.push(f);
}
for (const [, arr] of map) {
arr.sort((a, b) => a.fileName.localeCompare(b.fileName, undefined, { sensitivity: 'base' }));
}
return map;
}
function _computeFlatList(
tree: FolderNode[],
expandedIds: Set<string>,
showFiles: boolean,
filesByFolder: Map<string, FileNode[]>,
): TreeItem[] {
const result: TreeItem[] = [];
const _walk = (nodes: FolderNode[]) => {
for (const node of nodes) {
result.push({ id: node.id, type: 'folder', name: node.name });
if (expandedIds.has(node.id)) {
if (node.children) _walk(node.children);
if (showFiles) {
for (const f of (filesByFolder.get(node.id) || [])) {
result.push({ id: f.id, type: 'file', name: f.fileName });
}
}
}
}
};
_walk(tree);
if (showFiles) {
for (const f of (filesByFolder.get('') || [])) {
result.push({ id: f.id, type: 'file', name: f.fileName });
}
}
return result;
}
function _fileIcon(mime?: string): string {
if (!mime) return '\uD83D\uDCC4';
if (mime.startsWith('image/')) return '\uD83D\uDDBC\uFE0F';
if (mime.includes('pdf')) return '\uD83D\uDCD5';
if (mime.includes('word') || mime.includes('docx')) return '\uD83D\uDCD8';
if (mime.includes('sheet') || mime.includes('xlsx') || mime.includes('csv')) return '\uD83D\uDCCA';
if (mime.includes('presentation') || mime.includes('pptx')) return '\uD83D\uDCD9';
if (mime.includes('zip') || mime.includes('tar') || mime.includes('gz')) return '\uD83D\uDCE6';
if (mime.startsWith('text/') || mime.includes('json') || mime.includes('xml')) return '\uD83D\uDCDD';
if (mime.startsWith('audio/')) return '\uD83C\uDFB5';
if (mime.startsWith('video/')) return '\uD83C\uDFA5';
return '\uD83D\uDCC4';
}
/* ── Selection context threaded through the tree ──────────────────────── */
const _SCOPE_ICONS: Record<string, string> = {
personal: '\uD83D\uDC64',
featureInstance: '\uD83D\uDC65',
mandate: '\uD83C\uDFE2',
global: '\uD83C\uDF10',
};
const _SCOPE_CYCLE: string[] = ['personal', 'featureInstance', 'mandate'];
const _SCOPE_LABELS: Record<string, string> = {
personal: 'Persönlich',
featureInstance: 'Instanz',
mandate: 'Mandant',
global: 'Global',
};
interface SelectionCtx {
selectedItemIds: Set<string>;
selectedFileIds: string[];
selectedFolderIds: string[];
onItemClick: (id: string, type: 'file' | 'folder', e: React.MouseEvent) => void;
onItemDragStart: (e: React.DragEvent, id: string, type: 'file' | 'folder', name: string) => void;
onRenameFile?: (fileId: string, newName: string) => Promise<void>;
onDeleteFile?: (fileId: string) => Promise<void>;
onDeleteFiles?: (fileIds: string[]) => Promise<void>;
onDeleteFolders?: (folderIds: string[]) => Promise<void>;
onScopeChange?: (fileId: string, newScope: string) => void;
onNeutralizeToggle?: (fileId: string, newValue: boolean) => void;
}
/* ── File node (leaf) ─────────────────────────────────────────────────── */
function _FileItem({ file, sel }: { file: FileNode; sel: SelectionCtx }) {
const [dragging, setDragging] = useState(false);
const [renaming, setRenaming] = useState(false);
const [renameValue, setRenameValue] = useState('');
const isSelected = sel.selectedItemIds.has(file.id);
const multiSelected = sel.selectedItemIds.size > 1;
const _handleRename = useCallback(async () => {
const trimmed = renameValue.trim();
if (trimmed && trimmed !== file.fileName && sel.onRenameFile) {
await sel.onRenameFile(file.id, trimmed);
}
setRenaming(false);
}, [renameValue, file.id, file.fileName, sel.onRenameFile]);
const _handleDeleteFiles = useCallback(async (e: React.MouseEvent) => {
e.stopPropagation();
if (sel.selectedFileIds.length > 0 && sel.onDeleteFiles) {
await sel.onDeleteFiles(sel.selectedFileIds);
}
}, [sel]);
const _handleDeleteFolders = useCallback(async (e: React.MouseEvent) => {
e.stopPropagation();
if (sel.selectedFolderIds.length > 0 && sel.onDeleteFolders) {
await sel.onDeleteFolders(sel.selectedFolderIds);
}
}, [sel]);
const _handleDeleteSingle = useCallback(async (e: React.MouseEvent) => {
e.stopPropagation();
if (sel.onDeleteFile) await sel.onDeleteFile(file.id);
}, [file.id, sel]);
return (
<div
className={[
styles.treeNode,
styles.fileNode,
isSelected ? styles.multiSelected : '',
dragging ? styles.dragging : '',
].filter(Boolean).join(' ')}
onClick={(e) => sel.onItemClick(file.id, 'file', e)}
draggable
onDragStart={(e) => {
sel.onItemDragStart(e, file.id, 'file', file.fileName);
setDragging(true);
}}
onDragEnd={() => setDragging(false)}
>
<span className={styles.fileIcon}>{_fileIcon(file.mimeType)}</span>
{renaming ? (
<input
autoFocus
className={styles.renameInput}
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onBlur={_handleRename}
onKeyDown={(e) => {
if (e.key === 'Enter') _handleRename();
if (e.key === 'Escape') setRenaming(false);
}}
onClick={(e) => e.stopPropagation()}
/>
) : (
<span className={styles.folderName}>{file.fileName}</span>
)}
{!renaming && (
<span className={styles.rightZone}>
<span className={styles.actions}>
{sel.onRenameFile && !multiSelected && (
<button className={styles.actionBtn} onClick={(e) => { e.stopPropagation(); setRenameValue(file.fileName); setRenaming(true); }} title="Umbenennen">
<FaPen />
</button>
)}
{multiSelected && isSelected ? (
<>
{sel.selectedFolderIds.length > 0 && sel.onDeleteFolders && (
<button className={`${styles.actionBtn} ${styles.danger}`} onClick={_handleDeleteFolders} title={`${sel.selectedFolderIds.length} Ordner löschen`}>
<FaFolder style={{ fontSize: 8, marginRight: 1 }} /><FaTrash />
<span style={{ fontSize: 9, marginLeft: 2, fontWeight: 600 }}>{sel.selectedFolderIds.length}</span>
</button>
)}
{sel.selectedFileIds.length > 0 && sel.onDeleteFiles && (
<button className={`${styles.actionBtn} ${styles.danger}`} onClick={_handleDeleteFiles} title={`${sel.selectedFileIds.length} Dateien löschen`}>
<FaTrash />
<span style={{ fontSize: 9, marginLeft: 2, fontWeight: 600 }}>{sel.selectedFileIds.length}</span>
</button>
)}
</>
) : (
(sel.onDeleteFile || sel.onDeleteFiles) && (
<button className={`${styles.actionBtn} ${styles.danger}`} onClick={_handleDeleteSingle} title="Löschen">
<FaTrash />
</button>
)
)}
</span>
{file.fileSize != null && (
<span className={styles.fileSize}>
{(file.fileSize / 1024).toFixed(0)}K
</span>
)}
{file.scope != null && (
<span className={styles.scopeIcons}>
<button
className={styles.actionBtn}
onClick={(e) => {
e.stopPropagation();
if (!sel.onScopeChange) return;
const idx = _SCOPE_CYCLE.indexOf(file.scope!);
const next = _SCOPE_CYCLE[(idx + 1) % _SCOPE_CYCLE.length];
sel.onScopeChange(file.id, next);
}}
title={`Scope: ${_SCOPE_LABELS[file.scope!] || file.scope} (klicken zum Wechseln)`}
style={{ fontSize: 14 }}
>
{_SCOPE_ICONS[file.scope!] || '\uD83D\uDC64'}
</button>
<button
className={styles.actionBtn}
onClick={(e) => {
e.stopPropagation();
sel.onNeutralizeToggle?.(file.id, !file.neutralize);
}}
title={file.neutralize ? 'Neutralisierung aktiv (klicken zum Deaktivieren)' : 'Neutralisierung aus (klicken zum Aktivieren)'}
style={{ fontSize: 14, opacity: file.neutralize ? 1 : 0.4 }}
>
{'\uD83D\uDD12'}
</button>
</span>
)}
</span>
)}
</div>
);
}
/* ── Tree node (folder) ───────────────────────────────────────────────── */
interface TreeNodeProps {
node: FolderNode;
depth: number;
selectedFolderId: string | null;
expandedIds: Set<string>;
showFiles: boolean;
filesByFolder: Map<string, FileNode[]>;
sel: SelectionCtx;
promptFolderName: (message: string, options?: PromptOptions) => Promise<string | null>;
onToggle: (id: string) => void;
onSelect: (id: string | null) => void;
onCreateFolder?: (name: string, parentId: string | null) => Promise<void>;
onRenameFolder?: (folderId: string, newName: string) => Promise<void>;
onDeleteFolder?: (folderId: string) => Promise<void>;
onMoveFolder?: (folderId: string, targetParentId: string | null) => Promise<void>;
onMoveFolders?: (folderIds: string[], targetParentId: string | null) => Promise<void>;
onMoveFile?: (fileId: string, targetFolderId: string | null) => Promise<void>;
onMoveFiles?: (fileIds: string[], targetFolderId: string | null) => Promise<void>;
onDownloadFolder?: (folderId: string, folderName: string) => Promise<void>;
}
function _TreeNode({
node, depth, selectedFolderId, expandedIds, showFiles, filesByFolder, sel,
promptFolderName,
onToggle, onSelect,
onCreateFolder, onRenameFolder, onDeleteFolder, onMoveFolder, onMoveFolders, onMoveFile, onMoveFiles,
onDownloadFolder,
}: TreeNodeProps) {
const [renaming, setRenaming] = useState(false);
const [renameValue, setRenameValue] = useState(node.name);
const [dropOver, setDropOver] = useState(false);
const [dragging, setDragging] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const isExpanded = expandedIds.has(node.id);
const isNavSelected = selectedFolderId === node.id;
const isMultiSelected = sel.selectedItemIds.has(node.id);
const folderFiles = showFiles ? (filesByFolder.get(node.id) || []) : [];
const hasChildren = (node.children && node.children.length > 0) || folderFiles.length > 0;
useEffect(() => {
if (renaming && inputRef.current) inputRef.current.focus();
}, [renaming]);
const _handleRename = useCallback(async () => {
const trimmed = renameValue.trim();
if (trimmed && trimmed !== node.name && onRenameFolder) {
await onRenameFolder(node.id, trimmed);
}
setRenaming(false);
}, [renameValue, node.id, node.name, onRenameFolder]);
const _handleAdd = useCallback(async (e: React.MouseEvent) => {
e.stopPropagation();
if (!onCreateFolder) return;
const name = await promptFolderName('Neuer Ordnername:', { title: 'Neuer Ordner', placeholder: 'Ordnername' });
if (name?.trim()) {
await onCreateFolder(name.trim(), node.id);
if (!expandedIds.has(node.id)) onToggle(node.id);
}
}, [onCreateFolder, node.id, expandedIds, onToggle, promptFolderName]);
const _handleDeleteSingle = useCallback(async (e: React.MouseEvent) => {
e.stopPropagation();
if (onDeleteFolder) await onDeleteFolder(node.id);
}, [onDeleteFolder, node.id]);
const _handleDeleteFolders = useCallback(async (e: React.MouseEvent) => {
e.stopPropagation();
if (sel.selectedFolderIds.length > 0 && sel.onDeleteFolders) {
await sel.onDeleteFolders(sel.selectedFolderIds);
}
}, [sel]);
const _handleDeleteFiles = useCallback(async (e: React.MouseEvent) => {
e.stopPropagation();
if (sel.selectedFileIds.length > 0 && sel.onDeleteFiles) {
await sel.onDeleteFiles(sel.selectedFileIds);
}
}, [sel]);
const _handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
setDropOver(true);
}, []);
const _handleDragLeave = useCallback(() => setDropOver(false), []);
const _handleDrop = useCallback(async (e: React.DragEvent) => {
e.preventDefault();
setDropOver(false);
const treeItemsJson = e.dataTransfer.getData('application/tree-items');
if (treeItemsJson) {
const items: TreeItem[] = JSON.parse(treeItemsJson);
const fileIds = items.filter(i => i.type === 'file').map(i => i.id);
const folderIds = items.filter(i => i.type === 'folder' && i.id !== node.id).map(i => i.id);
if (folderIds.length > 0 && onMoveFolders) {
await onMoveFolders(folderIds, node.id);
} else if (onMoveFolder) {
for (const fId of folderIds) await onMoveFolder(fId, node.id);
}
if (fileIds.length > 0 && onMoveFiles) {
await onMoveFiles(fileIds, node.id);
} else if (fileIds.length > 0 && onMoveFile) {
for (const fId of fileIds) await onMoveFile(fId, node.id);
}
return;
}
const folderId = e.dataTransfer.getData('application/folder-id');
const fileIdsJson = e.dataTransfer.getData('application/file-ids');
const fileId = e.dataTransfer.getData('application/file-id');
if (folderId && folderId !== node.id && onMoveFolder) {
await onMoveFolder(folderId, node.id);
} else if (fileIdsJson && onMoveFiles) {
await onMoveFiles(JSON.parse(fileIdsJson), node.id);
} else if (fileId && onMoveFile) {
await onMoveFile(fileId, node.id);
}
}, [node.id, onMoveFolder, onMoveFolders, onMoveFile, onMoveFiles]);
const nodeClasses = [
styles.treeNode,
isNavSelected && !isMultiSelected ? styles.selected : '',
isMultiSelected ? styles.multiSelected : '',
dropOver ? styles.dropTarget : '',
dragging ? styles.dragging : '',
].filter(Boolean).join(' ');
return (
<div>
<div
className={nodeClasses}
onClick={(e) => sel.onItemClick(node.id, 'folder', e)}
draggable
onDragStart={(e) => {
sel.onItemDragStart(e, node.id, 'folder', node.name);
setDragging(true);
}}
onDragEnd={() => setDragging(false)}
onDragOver={_handleDragOver}
onDragLeave={_handleDragLeave}
onDrop={_handleDrop}
>
<span
className={`${styles.chevron} ${isExpanded ? styles.expanded : ''} ${!hasChildren ? styles.empty : ''}`}
onClick={(e) => { e.stopPropagation(); if (hasChildren) onToggle(node.id); }}
>
<FaChevronRight />
</span>
<span className={styles.folderIcon}>
{isExpanded ? <FaFolderOpen /> : <FaFolder />}
</span>
{renaming ? (
<input
ref={inputRef}
className={styles.renameInput}
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onBlur={_handleRename}
onKeyDown={(e) => {
if (e.key === 'Enter') _handleRename();
if (e.key === 'Escape') setRenaming(false);
}}
onClick={(e) => e.stopPropagation()}
/>
) : (
<span className={styles.folderName}>{node.name}</span>
)}
<span className={styles.actions}>
{onDownloadFolder && !(isMultiSelected && sel.selectedItemIds.size > 1) && (
<button className={styles.actionBtn} onClick={(e) => { e.stopPropagation(); onDownloadFolder(node.id, node.name); }} title="Ordner herunterladen (ZIP)">
<FaDownload />
</button>
)}
{onCreateFolder && !(isMultiSelected && sel.selectedItemIds.size > 1) && (
<button className={styles.actionBtn} onClick={_handleAdd} title="Neuer Unterordner">
<FaPlus />
</button>
)}
{onRenameFolder && !(isMultiSelected && sel.selectedItemIds.size > 1) && (
<button className={styles.actionBtn} onClick={(e) => { e.stopPropagation(); setRenameValue(node.name); setRenaming(true); }} title="Umbenennen">
<FaPen />
</button>
)}
{isMultiSelected && sel.selectedItemIds.size > 1 ? (
<>
{sel.selectedFolderIds.length > 0 && sel.onDeleteFolders && (
<button className={`${styles.actionBtn} ${styles.danger}`} onClick={_handleDeleteFolders} title={`${sel.selectedFolderIds.length} Ordner löschen`}>
<FaFolder style={{ fontSize: 8, marginRight: 1 }} /><FaTrash />
<span style={{ fontSize: 9, marginLeft: 2, fontWeight: 600 }}>{sel.selectedFolderIds.length}</span>
</button>
)}
{sel.selectedFileIds.length > 0 && sel.onDeleteFiles && (
<button className={`${styles.actionBtn} ${styles.danger}`} onClick={_handleDeleteFiles} title={`${sel.selectedFileIds.length} Dateien löschen`}>
<FaTrash />
<span style={{ fontSize: 9, marginLeft: 2, fontWeight: 600 }}>{sel.selectedFileIds.length}</span>
</button>
)}
</>
) : onDeleteFolder && (
<button className={`${styles.actionBtn} ${styles.danger}`} onClick={_handleDeleteSingle} title="Löschen">
<FaTrash />
</button>
)}
</span>
</div>
{isExpanded && hasChildren && (
<div className={styles.children}>
{node.children!.map((child) => (
<_TreeNode
key={child.id}
node={child}
depth={depth + 1}
selectedFolderId={selectedFolderId}
expandedIds={expandedIds}
showFiles={showFiles}
filesByFolder={filesByFolder}
sel={sel}
promptFolderName={promptFolderName}
onToggle={onToggle}
onSelect={onSelect}
onCreateFolder={onCreateFolder}
onRenameFolder={onRenameFolder}
onDeleteFolder={onDeleteFolder}
onMoveFolder={onMoveFolder}
onMoveFolders={onMoveFolders}
onMoveFile={onMoveFile}
onMoveFiles={onMoveFiles}
onDownloadFolder={onDownloadFolder}
/>
))}
{folderFiles.map((file) => (
<_FileItem key={file.id} file={file} sel={sel} />
))}
</div>
)}
</div>
);
}
/* ── Root component ────────────────────────────────────────────────────── */
export default function FolderTree({
folders, files, showFiles = false, selectedFolderId, onSelect, onFileSelect,
selectedItemIds: externalSelectedIds, onSelectionChange,
expandedIds: externalExpandedIds, onToggleExpand,
onCreateFolder, onRenameFolder, onDeleteFolder, onMoveFolder, onMoveFolders, onMoveFile, onMoveFiles,
onRenameFile, onDeleteFile, onDeleteFiles, onDeleteFolders, onRefresh, onDownloadFolder,
onScopeChange, onNeutralizeToggle,
}: FolderTreeProps) {
const [internalExpandedIds, setInternalExpandedIds] = useState<Set<string>>(new Set());
const [rootDropOver, setRootDropOver] = useState(false);
const [internalSelectedIds, setInternalSelectedIds] = useState<Set<string>>(new Set());
const lastClickedIdRef = useRef<string | null>(null);
const { prompt: promptFolderName, PromptDialog } = usePrompt();
const expandedIds = externalExpandedIds ?? internalExpandedIds;
const tree = useMemo(() => _buildTree(folders), [folders]);
const filesByFolder = useMemo(() => _groupFilesByFolder(files || []), [files]);
const rootFiles = showFiles ? (filesByFolder.get('') || []) : [];
const selectedItemIds = externalSelectedIds ?? internalSelectedIds;
const flatList = useMemo(
() => _computeFlatList(tree, expandedIds, showFiles, filesByFolder),
[tree, expandedIds, showFiles, filesByFolder],
);
const _handleToggle = useCallback((id: string) => {
if (onToggleExpand) {
onToggleExpand(id);
return;
}
setInternalExpandedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
}, []);
const _setSelection = useCallback((ids: Set<string>) => {
if (onSelectionChange) {
onSelectionChange(ids);
} else {
setInternalSelectedIds(ids);
}
}, [onSelectionChange]);
const _handleItemClick = useCallback((id: string, type: 'file' | 'folder', e: React.MouseEvent) => {
if (e.ctrlKey || e.metaKey) {
const next = new Set(selectedItemIds);
if (next.has(id)) next.delete(id); else next.add(id);
_setSelection(next);
lastClickedIdRef.current = id;
return;
}
if (e.shiftKey && lastClickedIdRef.current) {
const lastIdx = flatList.findIndex(i => i.id === lastClickedIdRef.current);
const currIdx = flatList.findIndex(i => i.id === id);
if (lastIdx >= 0 && currIdx >= 0) {
const [from, to] = lastIdx < currIdx ? [lastIdx, currIdx] : [currIdx, lastIdx];
const next = new Set(selectedItemIds);
for (let i = from; i <= to; i++) next.add(flatList[i].id);
_setSelection(next);
}
return;
}
_setSelection(new Set([id]));
lastClickedIdRef.current = id;
if (type === 'folder') onSelect(id);
if (type === 'file') onFileSelect?.(id);
}, [selectedItemIds, flatList, _setSelection, onSelect, onFileSelect]);
const _handleItemDragStart = useCallback((e: React.DragEvent, id: string, type: 'file' | 'folder', name: string) => {
const isInSelection = selectedItemIds.has(id) && selectedItemIds.size > 1;
if (isInSelection) {
const items: TreeItem[] = [];
for (const selId of selectedItemIds) {
const item = flatList.find(i => i.id === selId);
if (item) items.push(item);
}
e.dataTransfer.setData('application/tree-items', JSON.stringify(items));
const fileIds = items.filter(i => i.type === 'file').map(i => i.id);
if (fileIds.length > 0) {
e.dataTransfer.setData('application/file-ids', JSON.stringify(fileIds));
}
} else {
e.dataTransfer.setData('application/tree-items', JSON.stringify([{ id, type, name }]));
if (type === 'file') {
e.dataTransfer.setData('application/file-id', id);
} else {
e.dataTransfer.setData('application/folder-id', id);
}
}
e.dataTransfer.effectAllowed = 'copyMove';
}, [selectedItemIds, flatList]);
const allFileIds = useMemo(() => {
const ids = new Set<string>();
for (const [, arr] of filesByFolder) for (const f of arr) ids.add(f.id);
return ids;
}, [filesByFolder]);
const allFolderIds = useMemo(() => {
const ids = new Set<string>();
const _collect = (nodes: FolderNode[]) => { for (const n of nodes) { ids.add(n.id); if (n.children) _collect(n.children); } };
_collect(tree);
return ids;
}, [tree]);
const sel: SelectionCtx = useMemo(() => {
const selFileIds = Array.from(selectedItemIds).filter(id => allFileIds.has(id));
const selFolderIds = Array.from(selectedItemIds).filter(id => allFolderIds.has(id));
return {
selectedItemIds,
selectedFileIds: selFileIds,
selectedFolderIds: selFolderIds,
onItemClick: _handleItemClick,
onItemDragStart: _handleItemDragStart,
onRenameFile,
onDeleteFile,
onDeleteFiles,
onDeleteFolders,
onScopeChange,
onNeutralizeToggle,
};
}, [selectedItemIds, allFileIds, allFolderIds, _handleItemClick, _handleItemDragStart, onRenameFile, onDeleteFile, onDeleteFiles, onDeleteFolders, onScopeChange, onNeutralizeToggle]);
const _handleRootDrop = useCallback(async (e: React.DragEvent) => {
e.preventDefault();
setRootDropOver(false);
const treeItemsJson = e.dataTransfer.getData('application/tree-items');
if (treeItemsJson) {
const items: TreeItem[] = JSON.parse(treeItemsJson);
const fileIds = items.filter(i => i.type === 'file').map(i => i.id);
const folderIds = items.filter(i => i.type === 'folder').map(i => i.id);
if (folderIds.length > 0 && onMoveFolders) {
await onMoveFolders(folderIds, null);
} else if (onMoveFolder) {
for (const fId of folderIds) await onMoveFolder(fId, null);
}
if (fileIds.length > 0 && onMoveFiles) {
await onMoveFiles(fileIds, null);
} else if (fileIds.length > 0 && onMoveFile) {
for (const fId of fileIds) await onMoveFile(fId, null);
}
return;
}
const folderId = e.dataTransfer.getData('application/folder-id');
const fileIdsJson = e.dataTransfer.getData('application/file-ids');
const fileId = e.dataTransfer.getData('application/file-id');
if (folderId && onMoveFolder) {
await onMoveFolder(folderId, null);
} else if (fileIdsJson && onMoveFiles) {
await onMoveFiles(JSON.parse(fileIdsJson), null);
} else if (fileId && onMoveFile) {
await onMoveFile(fileId, null);
}
}, [onMoveFolder, onMoveFolders, onMoveFile, onMoveFiles]);
const rootClasses = [
styles.treeNode,
selectedFolderId === null ? styles.selected : '',
rootDropOver ? styles.dropTarget : '',
].filter(Boolean).join(' ');
return (
<div className={styles.folderTree}>
<div
className={rootClasses}
onClick={() => { onSelect(null); _setSelection(new Set()); }}
onDragOver={(e) => { e.preventDefault(); setRootDropOver(true); }}
onDragLeave={() => setRootDropOver(false)}
onDrop={_handleRootDrop}
>
<span className={styles.folderIcon}><FaGlobe /></span>
<span className={`${styles.folderName} ${styles.rootLabel}`}>(Global)</span>
<span className={styles.rootActions}>
{onRefresh && (
<button className={styles.actionBtn} onClick={(e) => { e.stopPropagation(); onRefresh(); }} title="Aktualisieren">
<FaSyncAlt />
</button>
)}
{onCreateFolder && (
<button
className={styles.actionBtn}
onClick={async (e) => {
e.stopPropagation();
const name = await promptFolderName('Neuer Ordnername:', { title: 'Neuer Ordner', placeholder: 'Ordnername' });
if (name?.trim()) await onCreateFolder(name.trim(), null);
}}
title="Neuer Ordner"
>
<FaPlus />
</button>
)}
</span>
</div>
<div className={styles.children}>
{tree.map((node) => (
<_TreeNode
key={node.id}
node={node}
depth={1}
selectedFolderId={selectedFolderId}
expandedIds={expandedIds}
showFiles={showFiles}
filesByFolder={filesByFolder}
sel={sel}
promptFolderName={promptFolderName}
onToggle={_handleToggle}
onSelect={onSelect}
onCreateFolder={onCreateFolder}
onRenameFolder={onRenameFolder}
onDeleteFolder={onDeleteFolder}
onMoveFolder={onMoveFolder}
onMoveFolders={onMoveFolders}
onMoveFile={onMoveFile}
onMoveFiles={onMoveFiles}
onDownloadFolder={onDownloadFolder}
/>
))}
{rootFiles.map((file) => (
<_FileItem key={file.id} file={file} sel={sel} />
))}
</div>
<PromptDialog />
</div>
);
}