861 lines
35 KiB
TypeScript
861 lines
35 KiB
TypeScript
/**
|
||
* 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, FaSyncAlt, FaDownload } from 'react-icons/fa';
|
||
import { usePrompt, type PromptOptions } from '../../hooks/usePrompt';
|
||
import styles from './FolderTree.module.css';
|
||
|
||
import { useLanguage } from '../../providers/language/LanguageContext';
|
||
|
||
/* ── Public types ──────────────────────────────────────────────────────── */
|
||
|
||
export interface FolderNode {
|
||
id: string;
|
||
name: string;
|
||
parentId: string | null;
|
||
fileCount?: number;
|
||
children?: FolderNode[];
|
||
isProtected?: boolean;
|
||
isReadonly?: boolean;
|
||
icon?: string;
|
||
neutralize?: boolean;
|
||
}
|
||
|
||
export interface FileNode {
|
||
id: string;
|
||
fileName: string;
|
||
mimeType?: string;
|
||
fileSize?: number;
|
||
folderId?: string | null;
|
||
scope?: string;
|
||
neutralize?: boolean;
|
||
sysCreatedBy?: string;
|
||
isReadonly?: 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;
|
||
onFolderNeutralizeToggle?: (folderId: string, newValue: boolean) => void;
|
||
onSendToChat?: (items: Array<{ id: string; type: 'file' | 'folder'; name: string }>) => 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'];
|
||
|
||
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;
|
||
onSendToChat?: (items: Array<{ id: string; type: 'file' | 'folder'; name: string }>) => void;
|
||
}
|
||
|
||
/* ── File node (leaf) ─────────────────────────────────────────────────── */
|
||
|
||
function _FileItem({ file, sel }: { file: FileNode; sel: SelectionCtx }) {
|
||
const { t } = useLanguage();
|
||
const scopeLabels = useMemo((): Record<string, string> => ({
|
||
personal: t('Persönlich'),
|
||
featureInstance: t('Instanz'),
|
||
mandate: t('Mandant'),
|
||
global: t('Global'),
|
||
}), [t]);
|
||
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.chevron} style={{ visibility: 'hidden' }}><FaChevronRight /></span>
|
||
<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.onSendToChat && (
|
||
<button className={styles.actionBtn} onClick={(e) => { e.stopPropagation(); sel.onSendToChat!([{ id: file.id, type: 'file', name: file.fileName }]); }} title={t('In Chat senden')} style={{ fontSize: 12 }}>
|
||
{'\u{1F4AC}'}
|
||
</button>
|
||
)}
|
||
{sel.onRenameFile && !multiSelected && (
|
||
<button className={styles.actionBtn} onClick={(e) => { e.stopPropagation(); setRenameValue(file.fileName); setRenaming(true); }} title={t('Umbenennen')}>
|
||
<FaPen />
|
||
</button>
|
||
)}
|
||
{multiSelected && isSelected ? (
|
||
<>
|
||
{sel.selectedFolderIds.length > 0 && sel.onDeleteFolders && (
|
||
<button className={`${styles.actionBtn} ${styles.danger}`} onClick={_handleDeleteFolders} title={t('{count} Ordner löschen', { count: String(sel.selectedFolderIds.length) })}>
|
||
<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} ${t('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={t('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={`${t('Scope')}: ${scopeLabels[file.scope!] || file.scope} (${t('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 ? t('Neutralisierung aktiv, klicken zum Deaktivieren') : t('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>;
|
||
onFolderNeutralizeToggle?: (folderId: string, newValue: boolean) => void;
|
||
}
|
||
|
||
function _TreeNode({
|
||
node, depth, selectedFolderId, expandedIds, showFiles, filesByFolder, sel,
|
||
promptFolderName,
|
||
onToggle, onSelect,
|
||
onCreateFolder, onRenameFolder, onDeleteFolder, onMoveFolder, onMoveFolders, onMoveFile, onMoveFiles,
|
||
onDownloadFolder, onFolderNeutralizeToggle,
|
||
}: TreeNodeProps) {
|
||
const { t } = useLanguage();
|
||
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 || (node.fileCount ?? 0) > 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(t('Neuer Ordnername:'), { title: t('Neuer Ordner'), placeholder: t('Ordnername') });
|
||
if (name?.trim()) {
|
||
await onCreateFolder(name.trim(), node.id);
|
||
if (!expandedIds.has(node.id)) onToggle(node.id);
|
||
}
|
||
}, [onCreateFolder, node.id, expandedIds, onToggle, promptFolderName, t]);
|
||
|
||
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(' ');
|
||
|
||
const isProtected = node.isProtected === true;
|
||
const isReadonly = node.isReadonly === true;
|
||
const notDraggable = isProtected || isReadonly;
|
||
const notEditable = isProtected || isReadonly;
|
||
const customIcon = node.icon;
|
||
|
||
return (
|
||
<div>
|
||
<div
|
||
className={nodeClasses}
|
||
onClick={(e) => sel.onItemClick(node.id, 'folder', e)}
|
||
draggable={!notDraggable}
|
||
onDragStart={notDraggable ? undefined : (e) => {
|
||
sel.onItemDragStart(e, node.id, 'folder', node.name);
|
||
setDragging(true);
|
||
}}
|
||
onDragEnd={notDraggable ? undefined : () => setDragging(false)}
|
||
onDragOver={isProtected ? undefined : _handleDragOver}
|
||
onDragLeave={isProtected ? undefined : _handleDragLeave}
|
||
onDrop={isProtected ? undefined : _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}>
|
||
{customIcon ? (
|
||
<span style={{ fontSize: 14 }}>{customIcon}</span>
|
||
) : isExpanded ? <FaFolderOpen /> : <FaFolder />}
|
||
</span>
|
||
{renaming && !notEditable ? (
|
||
<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} style={notEditable ? { fontWeight: 600 } : undefined}>{node.name}</span>
|
||
)}
|
||
{!isProtected && (
|
||
<span className={styles.actions}>
|
||
{sel.onSendToChat && !(isMultiSelected && sel.selectedItemIds.size > 1) && (
|
||
<button className={styles.actionBtn} onClick={(e) => { e.stopPropagation(); sel.onSendToChat!([{ id: node.id, type: 'folder', name: node.name }]); }} title={t('In Chat senden')} style={{ fontSize: 12 }}>
|
||
{'\u{1F4AC}'}
|
||
</button>
|
||
)}
|
||
{!notEditable && onDownloadFolder && !(isMultiSelected && sel.selectedItemIds.size > 1) && (
|
||
<button className={styles.actionBtn} onClick={(e) => { e.stopPropagation(); onDownloadFolder(node.id, node.name); }} title={t('Ordner herunterladen (ZIP)')}>
|
||
<FaDownload />
|
||
</button>
|
||
)}
|
||
{onFolderNeutralizeToggle && !(isMultiSelected && sel.selectedItemIds.size > 1) && (
|
||
<button
|
||
className={styles.actionBtn}
|
||
onClick={(e) => { e.stopPropagation(); onFolderNeutralizeToggle(node.id, !node.neutralize); }}
|
||
title={node.neutralize ? t('Ordner-Neutralisierung aktiv, klicken zum Deaktivieren') : t('Ordner-Neutralisierung aus, klicken zum Aktivieren')}
|
||
style={{ fontSize: 14, opacity: node.neutralize ? 1 : 0.4 }}
|
||
>
|
||
{'\uD83D\uDD12'}
|
||
</button>
|
||
)}
|
||
{onCreateFolder && !(isMultiSelected && sel.selectedItemIds.size > 1) && (
|
||
<button className={styles.actionBtn} onClick={_handleAdd} title={t('Neuer Unterordner')}>
|
||
<FaPlus />
|
||
</button>
|
||
)}
|
||
{!notEditable && onRenameFolder && !(isMultiSelected && sel.selectedItemIds.size > 1) && (
|
||
<button className={styles.actionBtn} onClick={(e) => { e.stopPropagation(); setRenameValue(node.name); setRenaming(true); }} title={t('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} ${t('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} ${t('Dateien löschen')}`}>
|
||
<FaTrash />
|
||
<span style={{ fontSize: 9, marginLeft: 2, fontWeight: 600 }}>{sel.selectedFileIds.length}</span>
|
||
</button>
|
||
)}
|
||
</>
|
||
) : !notEditable && onDeleteFolder && (
|
||
<button className={`${styles.actionBtn} ${styles.danger}`} onClick={_handleDeleteSingle} title={t('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}
|
||
onFolderNeutralizeToggle={onFolderNeutralizeToggle}
|
||
/>
|
||
))}
|
||
{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, onFolderNeutralizeToggle, onSendToChat,
|
||
}: FolderTreeProps) {
|
||
const { t } = useLanguage();
|
||
|
||
const [internalExpandedIds, setInternalExpandedIds] = useState<Set<string>>(new Set());
|
||
const [internalSelectedIds, setInternalSelectedIds] = useState<Set<string>>(new Set());
|
||
const lastClickedIdRef = useRef<string | null>(null);
|
||
const { prompt: promptFolderName, PromptDialog } = usePrompt();
|
||
const [rootDropOver, setRootDropOver] = useState(false);
|
||
|
||
const expandedIds = externalExpandedIds ?? internalExpandedIds;
|
||
const selectedItemIds = externalSelectedIds ?? internalSelectedIds;
|
||
|
||
const realTree = useMemo(() => _buildTree(folders), [folders]);
|
||
const filesByFolder = useMemo(() => _groupFilesByFolder(files || []), [files]);
|
||
const rootFiles = showFiles ? (filesByFolder.get('') || []) : [];
|
||
|
||
const knownFolderIds = 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(realTree);
|
||
return ids;
|
||
}, [realTree]);
|
||
|
||
const tree = useMemo(() => {
|
||
if (!showFiles) return realTree;
|
||
const orphanFolders: FolderNode[] = [];
|
||
for (const key of filesByFolder.keys()) {
|
||
if (key && !knownFolderIds.has(key)) {
|
||
orphanFolders.push({ id: key, name: key.slice(0, 8) + '…', parentId: null, fileCount: filesByFolder.get(key)?.length ?? 0, isProtected: true });
|
||
}
|
||
}
|
||
if (orphanFolders.length === 0) return realTree;
|
||
return [...realTree, ...orphanFolders.sort((a, b) => a.name.localeCompare(b.name))];
|
||
}, [realTree, showFiles, filesByFolder, knownFolderIds]);
|
||
|
||
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;
|
||
});
|
||
}, [onToggleExpand]);
|
||
|
||
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,
|
||
onSendToChat,
|
||
};
|
||
}, [selectedItemIds, allFileIds, allFolderIds, _handleItemClick, _handleItemDragStart, onRenameFile, onDeleteFile, onDeleteFiles, onDeleteFolders, onScopeChange, onNeutralizeToggle, onSendToChat]);
|
||
|
||
// Root drop handler: items dropped on the empty area go to root (null)
|
||
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 (onMoveFile) for (const fId of fileIds) await onMoveFile(fId, null);
|
||
return;
|
||
}
|
||
|
||
const folderId = e.dataTransfer.getData('application/folder-id');
|
||
const fileId = e.dataTransfer.getData('application/file-id');
|
||
if (folderId && onMoveFolder) await onMoveFolder(folderId, null);
|
||
else if (fileId && onMoveFile) await onMoveFile(fileId, null);
|
||
}, [onMoveFolder, onMoveFolders, onMoveFile, onMoveFiles]);
|
||
|
||
const _handleRootAddFolder = useCallback(async () => {
|
||
if (!onCreateFolder) return;
|
||
const name = await promptFolderName(t('Neuer Ordnername:'), { title: t('Neuer Ordner'), placeholder: t('Ordnername') });
|
||
if (name?.trim()) await onCreateFolder(name.trim(), null);
|
||
}, [onCreateFolder, promptFolderName, t]);
|
||
|
||
const isRootSelected = selectedFolderId === null;
|
||
|
||
const _handleRootClick = useCallback(() => {
|
||
_setSelection(new Set());
|
||
onSelect(null);
|
||
}, [_setSelection, onSelect]);
|
||
|
||
return (
|
||
<div className={styles.folderTree}>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '2px 4px' }}>
|
||
<span
|
||
className={`${styles.treeNode} ${isRootSelected ? styles.selected : ''} ${rootDropOver ? styles.dropTarget : ''}`}
|
||
style={{ flex: 1, cursor: 'pointer', fontWeight: 600, paddingLeft: 4 }}
|
||
onClick={_handleRootClick}
|
||
onDragOver={(e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; setRootDropOver(true); }}
|
||
onDragLeave={() => setRootDropOver(false)}
|
||
onDrop={_handleRootDrop}
|
||
>
|
||
/
|
||
</span>
|
||
<span className={styles.actions}>
|
||
{onCreateFolder && (
|
||
<button className={styles.actionBtn} onClick={_handleRootAddFolder} title={t('Neuer Ordner')}>
|
||
<FaPlus />
|
||
</button>
|
||
)}
|
||
{onRefresh && (
|
||
<button className={styles.actionBtn} onClick={onRefresh} title={t('Aktualisieren')}>
|
||
<FaSyncAlt />
|
||
</button>
|
||
)}
|
||
</span>
|
||
</div>
|
||
<div className={styles.children}>
|
||
{tree.map((node) => (
|
||
<_TreeNode
|
||
key={node.id}
|
||
node={node}
|
||
depth={0}
|
||
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}
|
||
onFolderNeutralizeToggle={onFolderNeutralizeToggle}
|
||
/>
|
||
))}
|
||
{rootFiles.map((file) => (
|
||
<_FileItem key={file.id} file={file} sel={sel} />
|
||
))}
|
||
</div>
|
||
<PromptDialog />
|
||
</div>
|
||
);
|
||
}
|