frontend_nyla/src/components/UnifiedDataBar/FilesTab.tsx
2026-05-03 22:24:47 +02:00

222 lines
7.9 KiB
TypeScript

import React, { useCallback, useRef, useMemo, useState } from 'react';
import type { UdbContext } from './UnifiedDataBar';
import api from '../../api';
import { useApiRequest } from '../../hooks/useApi';
import {
importWorkflowFromFile,
WORKFLOW_FILE_EXTENSION,
} from '../../api/workflowApi';
import { useToast } from '../../contexts/ToastContext';
import { FormGeneratorTree } from '../FormGenerator/FormGeneratorTree';
import { createFolderFileProvider } from '../FormGenerator/FormGeneratorTree/providers/FolderFileProvider';
import type { TreeNode } from '../FormGenerator/FormGeneratorTree';
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' | 'group'; name: string }>) => void;
/** Wird aufgerufen, wenn ein ``.workflow.json``-File via Custom-Action in
* den Graph-Editor importiert wurde. */
onWorkflowImported?: (workflowId: string) => void;
}
const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect, onSendToChat, onWorkflowImported }) => {
const { t } = useLanguage();
const { request } = useApiRequest();
const { showSuccess, showError } = useToast();
const [isDragOver, setIsDragOver] = useState(false);
const [uploading, setUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const provider = useMemo(() => createFolderFileProvider(), []);
const [ownTreeKey, setOwnTreeKey] = useState(0);
const [sharedTreeKey, setSharedTreeKey] = useState(0);
const _handleNodeClick = useCallback((node: TreeNode) => {
if (node.type === 'file') {
onFileSelect?.(node.id, node.name);
}
}, [onFileSelect]);
const _handleRefresh = useCallback(() => {
setOwnTreeKey(k => k + 1);
setSharedTreeKey(k => k + 1);
}, []);
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' },
});
}
_handleRefresh();
} catch (err) {
console.error('File upload failed:', err);
} finally {
setUploading(false);
}
}, [context.instanceId, uploading, _handleRefresh]);
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]);
/* Workflow import is only available when embedded in the graph editor */
const _handleWorkflowImport = useCallback(async (fileId: string, fileName: string) => {
if (context.surface !== 'graphEditor' || !context.instanceId) return;
if (!fileName?.toLowerCase().endsWith(WORKFLOW_FILE_EXTENSION)) return;
try {
const result = await importWorkflowFromFile(request, context.instanceId, { fileId });
const warnings = result?.warnings ?? [];
const wfId = result?.workflow?.id;
if (warnings.length > 0) {
showSuccess(t('Workflow importiert ({n} Warnungen).', { 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, request, showSuccess, showError, t, onWorkflowImported]);
const _handleNodeClickWithImport = useCallback((node: TreeNode) => {
_handleNodeClick(node);
if (node.type === 'file') {
_handleWorkflowImport(node.id, node.name);
}
}, [_handleNodeClick, _handleWorkflowImport]);
const _handleSendToChat = useCallback((node: TreeNode) => {
onSendToChat?.([{ id: node.id, type: node.type === 'folder' ? 'group' : 'file', name: node.name }]);
}, [onSendToChat]);
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',
pointerEvents: 'auto',
}}
onDragOver={(e) => { e.preventDefault(); e.stopPropagation(); e.dataTransfer.dropEffect = 'copy'; }}
onDragLeave={(e) => {
if (!e.relatedTarget || !(e.currentTarget as Node).contains(e.relatedTarget as Node)) {
setIsDragOver(false);
}
}}
onDrop={_handleDrop}
>
{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={_handleRefresh}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, color: '#F25843' }}
title={t('Aktualisieren')}
>
{'\u21BB'}
</button>
</div>
</div>
<input
ref={fileInputRef}
type="file"
multiple
style={{ display: 'none' }}
onChange={_handleFileInputChange}
/>
<div style={{ flex: 1, overflow: 'auto', minHeight: 0 }}>
<FormGeneratorTree
key={`own-${ownTreeKey}`}
provider={provider}
ownership="own"
title={t('Eigene')}
compact={true}
showFilter={true}
onNodeClick={_handleNodeClickWithImport}
onSendToChat={_handleSendToChat}
/>
<FormGeneratorTree
key={`shared-${sharedTreeKey}`}
provider={provider}
ownership="shared"
title={t('Geteilt mit mir')}
compact={true}
collapsible={true}
defaultCollapsed={true}
emptyMessage={t('Keine geteilten Dateien')}
onNodeClick={_handleNodeClickWithImport}
onSendToChat={_handleSendToChat}
/>
</div>
<div className={styles.legend}>
<span>{'\uD83D\uDC64'} {t('Persoenlich')}</span>
<span>{'\uD83D\uDC65'} {t('Instanz')}</span>
<span>{'\uD83C\uDFE2'} {t('Mandant')}</span>
<span>{'\uD83D\uDD12'} {t('Neutralisiert')}</span>
</div>
</div>
);
};
export default FilesTab;