frontend_nyla/src/components/FolderTree/FolderTree.tsx
2026-04-19 01:22:34 +02:00

915 lines
37 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, 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;
scope?: string;
}
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;
onFolderScopeChange?: (folderId: string, newScope: string) => 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;
}
/* ── Stable trio (chat | scope | neutralize) ──────────────────────────────
* Always rendered in this order, always at the right edge of the row.
* Each slot has a fixed width so missing actions render an invisible
* placeholder — icons never jump position between rows. */
interface StableTrioProps {
scope?: string;
neutralize?: boolean;
scopeLabels: Record<string, string>;
onChat?: () => void;
onScopeChange?: (newScope: string) => void;
onNeutralizeToggle?: (newValue: boolean) => void;
chatTitle: string;
}
function _StableTrio({
scope, neutralize,
scopeLabels,
onChat, onScopeChange, onNeutralizeToggle,
chatTitle,
}: StableTrioProps) {
const { t } = useLanguage();
const _cycleScope = (current: string | undefined) => {
const idx = _SCOPE_CYCLE.indexOf(current ?? 'personal');
return _SCOPE_CYCLE[(idx + 1) % _SCOPE_CYCLE.length];
};
return (
<span className={styles.stableActions}>
{/* Slot 1: Chat */}
{onChat ? (
<button
className={`${styles.actionBtn} ${styles.iconSlot}`}
onClick={(e) => { e.stopPropagation(); onChat(); }}
title={chatTitle}
style={{ fontSize: 12 }}
>
{'\u{1F4AC}'}
</button>
) : (
<span className={`${styles.iconSlot} ${styles.placeholder}`} aria-hidden="true">{'\u{1F4AC}'}</span>
)}
{/* Slot 2: Scope */}
{onScopeChange && scope != null ? (
<button
className={`${styles.actionBtn} ${styles.iconSlot}`}
onClick={(e) => { e.stopPropagation(); onScopeChange(_cycleScope(scope)); }}
title={`${t('Scope')}: ${scopeLabels[scope] || scope} (${t('klicken zum Wechseln')})`}
style={{ fontSize: 14 }}
>
{_SCOPE_ICONS[scope] || _SCOPE_ICONS.personal}
</button>
) : (
<span className={`${styles.iconSlot} ${styles.placeholder}`} aria-hidden="true">{_SCOPE_ICONS.personal}</span>
)}
{/* Slot 3: Neutralize */}
{onNeutralizeToggle ? (
<button
className={`${styles.actionBtn} ${styles.iconSlot}`}
onClick={(e) => { e.stopPropagation(); onNeutralizeToggle(!neutralize); }}
title={neutralize ? t('Neutralisierung aktiv, klicken zum Deaktivieren') : t('Neutralisierung aus, klicken zum Aktivieren')}
style={{ fontSize: 14, opacity: neutralize ? 1 : 0.4 }}
>
{'\uD83D\uDD12'}
</button>
) : (
<span className={`${styles.iconSlot} ${styles.placeholder}`} aria-hidden="true">{'\uD83D\uDD12'}</span>
)}
</span>
);
}
/* ── 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}>
{file.fileSize != null && (
<span className={styles.fileSize}>
{(file.fileSize / 1024).toFixed(0)}K
</span>
)}
<span className={styles.actions}>
{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>
<_StableTrio
scope={file.scope}
neutralize={file.neutralize}
scopeLabels={scopeLabels}
onChat={sel.onSendToChat ? () => sel.onSendToChat!([{ id: file.id, type: 'file', name: file.fileName }]) : undefined}
onScopeChange={sel.onScopeChange ? (next) => sel.onScopeChange!(file.id, next) : undefined}
onNeutralizeToggle={sel.onNeutralizeToggle ? (next) => sel.onNeutralizeToggle!(file.id, next) : undefined}
chatTitle={t('In Chat senden')}
/>
</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>;
onFolderScopeChange?: (folderId: string, newScope: string) => 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, onFolderScopeChange, onFolderNeutralizeToggle,
}: TreeNodeProps) {
const { t } = useLanguage();
const scopeLabels = useMemo((): Record<string, string> => ({
personal: t('Persönlich'),
featureInstance: t('Instanz'),
mandate: t('Mandant'),
global: t('Global'),
}), [t]);
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.rightZone}>
<span className={styles.actions}>
{!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>
)}
{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>
<_StableTrio
scope={node.scope}
neutralize={node.neutralize}
scopeLabels={scopeLabels}
onChat={(sel.onSendToChat && !(isMultiSelected && sel.selectedItemIds.size > 1)) ? () => sel.onSendToChat!([{ id: node.id, type: 'folder', name: node.name }]) : undefined}
onScopeChange={(onFolderScopeChange && !(isMultiSelected && sel.selectedItemIds.size > 1)) ? (next) => onFolderScopeChange(node.id, next) : undefined}
onNeutralizeToggle={(onFolderNeutralizeToggle && !(isMultiSelected && sel.selectedItemIds.size > 1)) ? (next) => onFolderNeutralizeToggle(node.id, next) : undefined}
chatTitle={t('In Chat senden')}
/>
</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}
onFolderScopeChange={onFolderScopeChange}
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, onFolderScopeChange, 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}
onFolderScopeChange={onFolderScopeChange}
onFolderNeutralizeToggle={onFolderNeutralizeToggle}
/>
))}
{rootFiles.map((file) => (
<_FileItem key={file.id} file={file} sel={sel} />
))}
</div>
<PromptDialog />
</div>
);
}