258 lines
9.3 KiB
TypeScript
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';
|
|
}
|