222 lines
7.9 KiB
TypeScript
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;
|