ui-nyla/src/pages/views/workspace/FileBrowser.tsx
2026-03-16 11:48:17 +01:00

258 lines
9.3 KiB
TypeScript

/**
* 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<FileBrowserProps> = ({
instanceId,
files,
folders: _folders,
onRefresh,
onFileSelect,
}) => {
const [searchQuery, setSearchQuery] = useState('');
const [isDragOver, setIsDragOver] = useState(false);
const [uploading, setUploading] = useState(false);
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({});
const fileInputRef = useRef<HTMLInputElement>(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<string, _InstanceGroup> = {};
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<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
_uploadFiles(e.target.files);
e.target.value = '';
}
}, [_uploadFiles]);
return (
<div
style={{ padding: 8, position: 'relative' }}
onDragOver={_handleDragOver}
onDragLeave={_handleDragLeave}
onDrop={_handleDrop}
>
{isDragOver && (
<div style={{
position: 'absolute', inset: 0,
background: 'rgba(25, 118, 210, 0.08)',
border: '2px dashed #1976d2', borderRadius: 8,
zIndex: 10, display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 13, fontWeight: 600, color: '#1976d2',
}}>
Dateien hier ablegen
</div>
)}
{/* Header */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<span style={{ fontSize: 12, fontWeight: 600, color: '#666', textTransform: 'uppercase' }}>Files</span>
<div style={{ display: 'flex', gap: 4 }}>
<button
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, color: '#1976d2' }}
title="Upload files"
>
{uploading ? '...' : '+'}
</button>
<button onClick={onRefresh} style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, color: '#1976d2' }}>{'\u21BB'}</button>
</div>
</div>
<input ref={fileInputRef} type="file" multiple style={{ display: 'none' }} onChange={_handleFileInputChange} />
{/* Search */}
<input
type="text"
placeholder="Dateien suchen..."
value={searchQuery}
onChange={e => 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 && (
<div style={{ fontSize: 12, color: '#999', textAlign: 'center', padding: 16 }}>
{searchQuery ? 'Keine Dateien gefunden' : 'Keine Dateien. Drag & Drop zum Hochladen.'}
</div>
)}
{_groups.map(group => {
const isCollapsed = !!collapsed[group.instanceId];
return (
<div key={group.instanceId} style={{ marginBottom: 4 }}>
{/* Group header */}
<div
onClick={() => _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)')}
>
<span style={{ fontSize: 10, color: '#888', width: 12, textAlign: 'center' }}>
{isCollapsed ? '\u25B6' : '\u25BC'}
</span>
<span style={{ fontSize: 12 }}>{'\uD83D\uDCC1'}</span>
<span style={{ fontSize: 12, fontWeight: 600, flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{group.label}
</span>
<span style={{ fontSize: 10, color: '#aaa', flexShrink: 0 }}>{group.files.length}</span>
</div>
{/* Files */}
{!isCollapsed && group.files.map(file => (
<div
key={file.id}
onClick={() => 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 = '')}
>
<span style={{ fontSize: 11, flexShrink: 0 }}>{_fileIcon(file.mimeType)}</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{file.fileName}
</div>
{file.tags && file.tags.length > 0 && (
<div style={{ display: 'flex', gap: 3, marginTop: 2 }}>
{file.tags.map(tag => (
<span key={tag} style={{ fontSize: 9, padding: '1px 5px', borderRadius: 3, background: '#e3f2fd', color: '#1565c0' }}>
{tag}
</span>
))}
</div>
)}
</div>
<span style={{ fontSize: 10, color: '#999', flexShrink: 0 }}>
{(file.fileSize / 1024).toFixed(0)}K
</span>
</div>
))}
</div>
);
})}
</div>
);
};
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';
}