329 lines
10 KiB
TypeScript
329 lines
10 KiB
TypeScript
import React, { useState, useCallback, useRef, useMemo, useEffect } from 'react';
|
|
import type { UdbContext } from './UnifiedDataBar';
|
|
import api from '../../api';
|
|
import FolderTree from '../../components/FolderTree/FolderTree';
|
|
import type { FileNode } from '../../components/FolderTree/FolderTree';
|
|
import { useFileContext } from '../../contexts/FileContext';
|
|
import styles from './FilesTab.module.css';
|
|
|
|
interface FileEntry {
|
|
id: string;
|
|
fileName: string;
|
|
mimeType?: string;
|
|
fileSize?: number;
|
|
folderId?: string | null;
|
|
tags?: string[];
|
|
scope: string;
|
|
neutralize: boolean;
|
|
}
|
|
|
|
interface FilesTabProps {
|
|
context: UdbContext;
|
|
onFileSelect?: (fileId: string) => void;
|
|
}
|
|
|
|
const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect }) => {
|
|
const [files, setFiles] = useState<FileEntry[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [isDragOver, setIsDragOver] = useState(false);
|
|
const [uploading, setUploading] = useState(false);
|
|
const [selectedFolderId, setSelectedFolderId] = useState<string | null>(null);
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
const {
|
|
folders,
|
|
refreshFolders,
|
|
handleCreateFolder,
|
|
handleRenameFolder,
|
|
handleDeleteFolder,
|
|
handleMoveFolder,
|
|
handleMoveFolders,
|
|
handleMoveFile,
|
|
handleMoveFiles: contextMoveFiles,
|
|
handleFileDelete,
|
|
handleDownloadFolder,
|
|
expandedFolderIds,
|
|
toggleFolderExpanded,
|
|
} = useFileContext();
|
|
|
|
const _loadFiles = useCallback(async () => {
|
|
setLoading(true);
|
|
try {
|
|
const response = await api.get(`/api/workspace/${context.instanceId}/files`);
|
|
const body = response.data;
|
|
const rawList =
|
|
(Array.isArray(body?.files) && body.files) ||
|
|
(Array.isArray(body?.data) && body.data) ||
|
|
(Array.isArray(body) ? body : []);
|
|
setFiles(
|
|
rawList.map((f: any) => ({
|
|
id: f.id,
|
|
fileName: f.fileName || f.name || 'unknown',
|
|
mimeType: f.mimeType,
|
|
fileSize: f.fileSize,
|
|
folderId: f.folderId ?? null,
|
|
tags: f.tags || [],
|
|
scope: f.scope || 'personal',
|
|
neutralize: f.neutralize || false,
|
|
})),
|
|
);
|
|
} catch (err) {
|
|
console.error('Failed to load files:', err);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [context.instanceId]);
|
|
|
|
useEffect(() => {
|
|
_loadFiles();
|
|
}, [_loadFiles]);
|
|
|
|
const _folderNodes = useMemo(() =>
|
|
folders.map(f => ({
|
|
id: f.id,
|
|
name: f.name,
|
|
parentId: f.parentId ?? null,
|
|
})),
|
|
[folders],
|
|
);
|
|
|
|
const _fileNodes: FileNode[] = useMemo(() => {
|
|
let result = files;
|
|
if (searchQuery.trim()) {
|
|
const q = searchQuery.toLowerCase();
|
|
result = result.filter(f =>
|
|
f.fileName.toLowerCase().includes(q)
|
|
|| (f.tags || []).some((t: string) => t.toLowerCase().includes(q)),
|
|
);
|
|
}
|
|
return result
|
|
.sort((a, b) => a.fileName.localeCompare(b.fileName))
|
|
.map(f => ({
|
|
id: f.id,
|
|
fileName: f.fileName,
|
|
mimeType: f.mimeType,
|
|
fileSize: f.fileSize,
|
|
folderId: f.folderId ?? null,
|
|
scope: f.scope,
|
|
neutralize: f.neutralize,
|
|
}));
|
|
}, [files, searchQuery]);
|
|
|
|
const _refreshAll = useCallback(() => {
|
|
_loadFiles();
|
|
refreshFolders();
|
|
}, [_loadFiles, refreshFolders]);
|
|
|
|
const _uploadFiles = useCallback(async (fileList: FileList | File[]) => {
|
|
if (!context.instanceId || uploading) return;
|
|
setUploading(true);
|
|
try {
|
|
for (const file of Array.from(fileList)) {
|
|
const formData = new FormData();
|
|
formData.append('file', file);
|
|
formData.append('featureInstanceId', context.instanceId);
|
|
await api.post('/api/files/upload', formData, {
|
|
headers: { 'Content-Type': 'multipart/form-data' },
|
|
});
|
|
}
|
|
_refreshAll();
|
|
} catch (err) {
|
|
console.error('File upload failed:', err);
|
|
} finally {
|
|
setUploading(false);
|
|
}
|
|
}, [context.instanceId, uploading, _refreshAll]);
|
|
|
|
const _handleDragOver = useCallback((e: React.DragEvent) => {
|
|
if (e.dataTransfer.types.includes('Files')) {
|
|
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]);
|
|
|
|
const _onMoveFile = useCallback(async (fileId: string, targetFolderId: string | null) => {
|
|
await handleMoveFile(fileId, targetFolderId);
|
|
_loadFiles();
|
|
}, [handleMoveFile, _loadFiles]);
|
|
|
|
const _onMoveFiles = useCallback(async (fileIds: string[], targetFolderId: string | null) => {
|
|
await contextMoveFiles(fileIds, targetFolderId);
|
|
_loadFiles();
|
|
}, [contextMoveFiles, _loadFiles]);
|
|
|
|
const _onDeleteFolder = useCallback(async (folderId: string) => {
|
|
await handleDeleteFolder(folderId);
|
|
if (selectedFolderId === folderId) setSelectedFolderId(null);
|
|
_loadFiles();
|
|
}, [handleDeleteFolder, selectedFolderId, _loadFiles]);
|
|
|
|
const _onRenameFile = useCallback(async (fileId: string, newName: string) => {
|
|
await api.put(`/api/files/${fileId}`, { fileName: newName });
|
|
_loadFiles();
|
|
}, [_loadFiles]);
|
|
|
|
const _onDeleteFile = useCallback(async (fileId: string) => {
|
|
await handleFileDelete(fileId);
|
|
_loadFiles();
|
|
}, [handleFileDelete, _loadFiles]);
|
|
|
|
const _onDeleteFiles = useCallback(async (fileIds: string[]) => {
|
|
await api.post('/api/files/batch-delete', { fileIds });
|
|
_loadFiles();
|
|
}, [_loadFiles]);
|
|
|
|
const _onDeleteFolders = useCallback(async (folderIds: string[]) => {
|
|
await api.post('/api/files/batch-delete', { folderIds, recursiveFolders: true });
|
|
refreshFolders();
|
|
_loadFiles();
|
|
}, [refreshFolders, _loadFiles]);
|
|
|
|
const _onScopeChange = useCallback(async (fileId: string, newScope: string) => {
|
|
setFiles(prev => prev.map(f => (f.id === fileId ? { ...f, scope: newScope } : f)));
|
|
try {
|
|
await api.patch(`/api/files/${fileId}/scope`, { scope: newScope });
|
|
} catch (err) {
|
|
console.error('Failed to update scope:', err);
|
|
_loadFiles();
|
|
}
|
|
}, [_loadFiles]);
|
|
|
|
const _onNeutralizeToggle = useCallback(async (fileId: string, newValue: boolean) => {
|
|
setFiles(prev => prev.map(f => (f.id === fileId ? { ...f, neutralize: newValue } : f)));
|
|
try {
|
|
await api.patch(`/api/files/${fileId}/neutralize`, { neutralize: newValue });
|
|
} catch (err) {
|
|
console.error('Failed to toggle neutralize:', err);
|
|
_loadFiles();
|
|
}
|
|
}, [_loadFiles]);
|
|
|
|
if (loading) return <div className={styles.loading}>Lade Dateien...</div>;
|
|
|
|
return (
|
|
<div
|
|
className={styles.filesTab}
|
|
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>
|
|
)}
|
|
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '4px 8px' }}>
|
|
<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={_refreshAll}
|
|
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}
|
|
/>
|
|
|
|
<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', boxSizing: 'border-box', margin: '0 0 4px',
|
|
}}
|
|
/>
|
|
|
|
<div style={{ flex: 1, overflow: 'auto' }}>
|
|
<FolderTree
|
|
folders={_folderNodes}
|
|
files={_fileNodes}
|
|
showFiles={true}
|
|
selectedFolderId={selectedFolderId}
|
|
onSelect={setSelectedFolderId}
|
|
onFileSelect={onFileSelect}
|
|
expandedIds={expandedFolderIds}
|
|
onToggleExpand={toggleFolderExpanded}
|
|
onRefresh={_refreshAll}
|
|
onCreateFolder={handleCreateFolder}
|
|
onRenameFolder={handleRenameFolder}
|
|
onDeleteFolder={_onDeleteFolder}
|
|
onMoveFolder={handleMoveFolder}
|
|
onMoveFolders={handleMoveFolders}
|
|
onMoveFile={_onMoveFile}
|
|
onMoveFiles={_onMoveFiles}
|
|
onRenameFile={_onRenameFile}
|
|
onDeleteFile={_onDeleteFile}
|
|
onDeleteFiles={_onDeleteFiles}
|
|
onDeleteFolders={_onDeleteFolders}
|
|
onDownloadFolder={handleDownloadFolder}
|
|
onScopeChange={_onScopeChange}
|
|
onNeutralizeToggle={_onNeutralizeToggle}
|
|
/>
|
|
|
|
{_fileNodes.length === 0 && (
|
|
<div style={{ fontSize: 12, color: '#999', textAlign: 'center', padding: 16 }}>
|
|
{searchQuery ? 'Keine Dateien gefunden' : 'Keine Dateien. Drag & Drop zum Hochladen.'}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className={styles.legend}>
|
|
<span>{'\uD83D\uDC64'} Persönlich</span>
|
|
<span>{'\uD83D\uDC65'} Instanz</span>
|
|
<span>{'\uD83C\uDFE2'} Mandant</span>
|
|
<span>{'\uD83D\uDD12'} Neutralisiert</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default FilesTab;
|