362 lines
13 KiB
TypeScript
362 lines
13 KiB
TypeScript
import React, { useState, useCallback, useRef, useMemo } from 'react';
|
|
import { FaFileImport } from 'react-icons/fa';
|
|
import type { UdbContext } from './UnifiedDataBar';
|
|
import api from '../../api';
|
|
import FolderTree from '../../components/FolderTree/FolderTree';
|
|
import type { FileNode } from '../../components/FolderTree/FolderTree';
|
|
import type { FileAction } from '../../components/FolderTree/actions/types';
|
|
import { useFileContext } from '../../contexts/FileContext';
|
|
import { useApiRequest } from '../../hooks/useApi';
|
|
import {
|
|
importWorkflowFromFile,
|
|
WORKFLOW_FILE_EXTENSION,
|
|
} from '../../api/workflowApi';
|
|
import { useToast } from '../../contexts/ToastContext';
|
|
import styles from './FilesTab.module.css';
|
|
import { useLanguage } from '../../providers/language/LanguageContext';
|
|
|
|
interface FilesTabProps {
|
|
context: UdbContext;
|
|
onFileSelect?: (fileId: string, fileName?: string) => void;
|
|
onSendToChat?: (items: Array<{ id: string; type: 'file' | 'folder'; name: string }>) => void;
|
|
/** Wird aufgerufen, wenn ein ``.workflow.json``-File via Custom-Action in
|
|
* den Graph-Editor importiert wurde. Aktivierung im Editor (Refresh-Liste,
|
|
* Auto-Select) bleibt Aufgabe des Aufrufers. */
|
|
onWorkflowImported?: (workflowId: string) => void;
|
|
}
|
|
|
|
const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect, onSendToChat, onWorkflowImported }) => {
|
|
const { t } = useLanguage();
|
|
const { request } = useApiRequest();
|
|
const { showSuccess, showError } = useToast();
|
|
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,
|
|
treeFileNodes,
|
|
treeFilesLoading,
|
|
refreshTreeFiles,
|
|
updateTreeFileNode,
|
|
expandedFolderIds,
|
|
toggleFolderExpanded,
|
|
handleCreateFolder,
|
|
handleRenameFolder,
|
|
handleDeleteFolder,
|
|
handleMoveFolder,
|
|
handleMoveFolders,
|
|
handleMoveFile,
|
|
handleMoveFiles: contextMoveFiles,
|
|
handleFileDelete,
|
|
handleDownloadFolder,
|
|
} = useFileContext();
|
|
|
|
const _folderNodes = useMemo(() => {
|
|
return folders.map(f => ({
|
|
id: f.id,
|
|
name: f.name,
|
|
parentId: f.parentId ?? null,
|
|
fileCount: f.fileCount ?? 0,
|
|
neutralize: f.neutralize ?? false,
|
|
scope: f.scope ?? 'personal',
|
|
}));
|
|
}, [folders]);
|
|
|
|
const _fileNodes: FileNode[] = useMemo(() => {
|
|
let result = treeFileNodes;
|
|
if (searchQuery.trim()) {
|
|
const q = searchQuery.toLowerCase();
|
|
result = result.filter(f =>
|
|
f.fileName.toLowerCase().includes(q),
|
|
);
|
|
}
|
|
return result;
|
|
}, [treeFileNodes, searchQuery]);
|
|
|
|
const _refreshAll = useCallback(async () => {
|
|
await Promise.all([refreshTreeFiles(), refreshFolders()]);
|
|
}, [refreshTreeFiles, 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' },
|
|
});
|
|
}
|
|
await _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);
|
|
}, [handleMoveFile]);
|
|
|
|
const _onMoveFiles = useCallback(async (fileIds: string[], targetFolderId: string | null) => {
|
|
await contextMoveFiles(fileIds, targetFolderId);
|
|
}, [contextMoveFiles]);
|
|
|
|
const _onDeleteFolder = useCallback(async (folderId: string) => {
|
|
await handleDeleteFolder(folderId);
|
|
if (selectedFolderId === folderId) setSelectedFolderId(null);
|
|
}, [handleDeleteFolder, selectedFolderId]);
|
|
|
|
const _onRenameFile = useCallback(async (fileId: string, newName: string) => {
|
|
await api.put(`/api/files/${fileId}`, { fileName: newName });
|
|
await refreshTreeFiles();
|
|
}, [refreshTreeFiles]);
|
|
|
|
const _onDeleteFile = useCallback(async (fileId: string) => {
|
|
await handleFileDelete(fileId);
|
|
}, [handleFileDelete]);
|
|
|
|
const _onDeleteFiles = useCallback(async (fileIds: string[]) => {
|
|
await api.post('/api/files/batch-delete', { fileIds });
|
|
await Promise.all([refreshTreeFiles(), refreshFolders()]);
|
|
}, [refreshTreeFiles, refreshFolders]);
|
|
|
|
const _onDeleteFolders = useCallback(async (folderIds: string[]) => {
|
|
await api.post('/api/files/batch-delete', { folderIds, recursiveFolders: true });
|
|
await Promise.all([refreshFolders(), refreshTreeFiles()]);
|
|
}, [refreshFolders, refreshTreeFiles]);
|
|
|
|
const _onScopeChange = useCallback(async (fileId: string, newScope: string) => {
|
|
updateTreeFileNode(fileId, { scope: newScope });
|
|
try {
|
|
await api.patch(`/api/files/${fileId}/scope`, { scope: newScope });
|
|
} catch (err) {
|
|
console.error('Failed to update scope:', err);
|
|
await refreshTreeFiles();
|
|
}
|
|
}, [updateTreeFileNode, refreshTreeFiles]);
|
|
|
|
const _onNeutralizeToggle = useCallback(async (fileId: string, newValue: boolean) => {
|
|
updateTreeFileNode(fileId, { neutralize: newValue });
|
|
try {
|
|
await api.patch(`/api/files/${fileId}/neutralize`, { neutralize: newValue });
|
|
} catch (err) {
|
|
console.error('Failed to toggle neutralize:', err);
|
|
await refreshTreeFiles();
|
|
}
|
|
}, [updateTreeFileNode, refreshTreeFiles]);
|
|
|
|
const _onFolderNeutralizeToggle = useCallback(async (folderId: string, newValue: boolean) => {
|
|
try {
|
|
await api.patch(`/api/files/folders/${folderId}/neutralize`, { neutralize: newValue });
|
|
await refreshFolders();
|
|
await refreshTreeFiles();
|
|
} catch (err) {
|
|
console.error('Failed to toggle folder neutralize:', err);
|
|
}
|
|
}, [refreshFolders, refreshTreeFiles]);
|
|
|
|
const _customActions: FileAction[] = useMemo(() => {
|
|
if (context.surface !== 'graphEditor') return [];
|
|
return [
|
|
{
|
|
id: 'workflow.openInEditor',
|
|
label: t('In Graph-Editor laden'),
|
|
icon: FaFileImport,
|
|
scope: 'file',
|
|
channels: ['inline', 'menu', 'sheet', 'drop'],
|
|
dragMime: 'application/json+workflow',
|
|
sortOrder: 50,
|
|
predicate: ({ files }) =>
|
|
files.length === 1 &&
|
|
files[0].fileName.toLowerCase().endsWith(WORKFLOW_FILE_EXTENSION),
|
|
handler: async ({ files }) => {
|
|
const file = files[0];
|
|
if (!context.instanceId || !file) return;
|
|
try {
|
|
const result = await importWorkflowFromFile(request, context.instanceId, {
|
|
fileId: file.id,
|
|
});
|
|
const warnings = result?.warnings ?? [];
|
|
const wfId = result?.workflow?.id;
|
|
if (warnings.length > 0) {
|
|
showSuccess(
|
|
t('Workflow importiert ({n} Warnungen). Aktivierung manuell.', {
|
|
n: String(warnings.length),
|
|
}),
|
|
);
|
|
} else {
|
|
showSuccess(t('Workflow importiert (deaktiviert).'));
|
|
}
|
|
if (wfId && onWorkflowImported) onWorkflowImported(wfId);
|
|
} catch (e: unknown) {
|
|
const msg = e instanceof Error ? e.message : String(e);
|
|
showError(t('Import fehlgeschlagen: {msg}', { msg }));
|
|
}
|
|
},
|
|
},
|
|
];
|
|
}, [context.surface, context.instanceId, t, request, showSuccess, showError, onWorkflowImported]);
|
|
|
|
const _onFolderScopeChange = useCallback(async (folderId: string, newScope: string) => {
|
|
try {
|
|
await api.patch(`/api/files/folders/${folderId}/scope`, { scope: newScope });
|
|
await refreshFolders();
|
|
await refreshTreeFiles();
|
|
} catch (err) {
|
|
console.error('Failed to change folder scope:', err);
|
|
}
|
|
}, [refreshFolders, refreshTreeFiles]);
|
|
|
|
if (treeFilesLoading && treeFileNodes.length === 0) {
|
|
return <div className={styles.loading}>{t('Dateien laden')}</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 #F25843', borderRadius: 8,
|
|
zIndex: 10, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
fontSize: 13, fontWeight: 600, color: '#F25843',
|
|
}}>
|
|
{t('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' }}>{t('Dateien')}</span>
|
|
<div style={{ display: 'flex', gap: 4 }}>
|
|
<button
|
|
onClick={() => fileInputRef.current?.click()}
|
|
disabled={uploading}
|
|
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, color: '#F25843' }}
|
|
title={t('Dateien hochladen')}
|
|
>
|
|
{uploading ? '...' : '+'}
|
|
</button>
|
|
<button
|
|
onClick={_refreshAll}
|
|
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, color: '#F25843' }}
|
|
>
|
|
{'\u21BB'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
multiple
|
|
style={{ display: 'none' }}
|
|
onChange={_handleFileInputChange}
|
|
/>
|
|
|
|
<input
|
|
type="text"
|
|
placeholder={t('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 ? (fileId: string) => {
|
|
const file = treeFileNodes.find(f => f.id === fileId);
|
|
onFileSelect(fileId, file?.fileName);
|
|
} : undefined}
|
|
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}
|
|
onFolderScopeChange={_onFolderScopeChange}
|
|
onFolderNeutralizeToggle={_onFolderNeutralizeToggle}
|
|
onSendToChat={onSendToChat}
|
|
customActions={_customActions}
|
|
udbContext={context.surface}
|
|
/>
|
|
|
|
{_fileNodes.length === 0 && (
|
|
<div style={{ fontSize: 12, color: '#999', textAlign: 'center', padding: 16 }}>
|
|
{searchQuery ? t('Keine Dateien gefunden') : t('Keine Dateien. Drag & Drop zum Hochladen.')}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className={styles.legend}>
|
|
<span>{'\uD83D\uDC64'} {t('Persönlich')}</span>
|
|
<span>{'\uD83D\uDC65'} {t('Instanz')}</span>
|
|
<span>{'\uD83C\uDFE2'} {t('Mandant')}</span>
|
|
<span>{'\uD83D\uDD12'} {t('Neutralisiert')}</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default FilesTab;
|