/** * FileBrowser -- Tree-structured file browser. * * Level 1: Feature instance (group header, collapsible) * Level 2: Files sorted alphabetically * * Supports search, drag-and-drop upload, and file selection. */ import React, { useState, useCallback, useRef, useMemo } from 'react'; import api from '../../../api'; import type { WorkspaceFile, WorkspaceFolder } from './useWorkspace'; interface FileBrowserProps { instanceId: string; files: WorkspaceFile[]; folders: WorkspaceFolder[]; onRefresh: () => void; onFileSelect?: (fileId: string) => void; } interface _InstanceGroup { instanceId: string; label: string; files: WorkspaceFile[]; } export const FileBrowser: React.FC = ({ instanceId, files, folders: _folders, onRefresh, onFileSelect, }) => { const [searchQuery, setSearchQuery] = useState(''); const [isDragOver, setIsDragOver] = useState(false); const [uploading, setUploading] = useState(false); const [collapsed, setCollapsed] = useState>({}); const fileInputRef = useRef(null); const _filteredFiles = useMemo(() => { if (!searchQuery.trim()) return files; const q = searchQuery.toLowerCase(); return files.filter(f => f.fileName.toLowerCase().includes(q) || (f.tags || []).some(t => t.toLowerCase().includes(q)), ); }, [files, searchQuery]); const _groups = useMemo((): _InstanceGroup[] => { const map: Record = {}; for (const f of _filteredFiles) { const key = f.featureInstanceId || '_workspace'; if (!map[key]) { map[key] = { instanceId: key, label: f.featureInstanceLabel || (key === '_workspace' ? 'Workspace' : key.slice(0, 8)), files: [], }; } map[key].files.push(f); } for (const g of Object.values(map)) { g.files.sort((a, b) => a.fileName.localeCompare(b.fileName)); } const groups = Object.values(map); groups.sort((a, b) => a.label.localeCompare(b.label)); return groups; }, [_filteredFiles]); const _toggleGroup = (key: string) => { setCollapsed(prev => ({ ...prev, [key]: !prev[key] })); }; const _uploadFiles = useCallback(async (fileList: FileList | File[]) => { if (!instanceId || uploading) return; setUploading(true); try { for (const file of Array.from(fileList)) { const formData = new FormData(); formData.append('file', file); formData.append('featureInstanceId', instanceId); await api.post('/api/files/upload', formData, { headers: { 'Content-Type': 'multipart/form-data' }, }); } onRefresh(); } catch (err) { console.error('File upload failed:', err); } finally { setUploading(false); } }, [instanceId, uploading, onRefresh]); const _handleDragOver = useCallback((e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); setIsDragOver(true); }, []); const _handleDragLeave = useCallback((e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); setIsDragOver(false); }, []); const _handleDrop = useCallback((e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); setIsDragOver(false); if (e.dataTransfer.files.length > 0) { _uploadFiles(e.dataTransfer.files); } }, [_uploadFiles]); const _handleFileInputChange = useCallback((e: React.ChangeEvent) => { if (e.target.files && e.target.files.length > 0) { _uploadFiles(e.target.files); e.target.value = ''; } }, [_uploadFiles]); return (
{isDragOver && (
Dateien hier ablegen
)} {/* Header */}
Files
{/* Search */} setSearchQuery(e.target.value)} style={{ width: '100%', padding: '6px 10px', fontSize: 12, borderRadius: 4, border: '1px solid #ddd', marginBottom: 8, boxSizing: 'border-box', }} /> {/* Tree */} {_groups.length === 0 && (
{searchQuery ? 'Keine Dateien gefunden' : 'Keine Dateien. Drag & Drop zum Hochladen.'}
)} {_groups.map(group => { const isCollapsed = !!collapsed[group.instanceId]; return (
{/* Group header */}
_toggleGroup(group.instanceId)} style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '5px 6px', cursor: 'pointer', borderRadius: 4, background: 'var(--bg-secondary, #f5f5f5)', marginBottom: 2, }} onMouseEnter={e => (e.currentTarget.style.background = '#eee')} onMouseLeave={e => (e.currentTarget.style.background = 'var(--bg-secondary, #f5f5f5)')} > {isCollapsed ? '\u25B6' : '\u25BC'} {'\uD83D\uDCC1'} {group.label} {group.files.length}
{/* Files */} {!isCollapsed && group.files.map(file => (
onFileSelect?.(file.id)} style={{ padding: '4px 8px 4px 28px', fontSize: 12, display: 'flex', alignItems: 'center', gap: 6, borderRadius: 4, cursor: onFileSelect ? 'pointer' : 'default', }} onMouseEnter={e => (e.currentTarget.style.background = '#f5f5f5')} onMouseLeave={e => (e.currentTarget.style.background = '')} > {_fileIcon(file.mimeType)}
{file.fileName}
{file.tags && file.tags.length > 0 && (
{file.tags.map(tag => ( {tag} ))}
)}
{(file.fileSize / 1024).toFixed(0)}K
))}
); })}
); }; 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'; }