replaced file tree mit formgenerator gruppierung
This commit is contained in:
parent
e7a79a3484
commit
7c05cb0dd7
20 changed files with 904 additions and 1270 deletions
|
|
@ -23,6 +23,17 @@ export default tseslint.config(
|
||||||
'warn',
|
'warn',
|
||||||
{ allowConstantExport: true },
|
{ allowConstantExport: true },
|
||||||
],
|
],
|
||||||
|
'no-restricted-imports': [
|
||||||
|
'warn',
|
||||||
|
{
|
||||||
|
patterns: [
|
||||||
|
{
|
||||||
|
group: ['**/components/FolderTree/FolderTree*', '**/FolderTree/FolderTree*'],
|
||||||
|
message: 'FolderTree is deprecated — use FormGeneratorTable with groupingConfig instead.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -190,110 +190,87 @@ export async function deleteFiles(
|
||||||
return uniqueIds.map(fileId => ({ success: true, fileId }));
|
return uniqueIds.map(fileId => ({ success: true, fileId }));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteFolders(
|
|
||||||
request: ApiRequestFunction,
|
|
||||||
folderIds: string[],
|
|
||||||
recursiveFolders: boolean = true
|
|
||||||
): Promise<{ deletedFiles: number; deletedFolders: number }> {
|
|
||||||
const uniqueIds = [...new Set(folderIds.filter(Boolean))];
|
|
||||||
if (uniqueIds.length === 0) return { deletedFiles: 0, deletedFolders: 0 };
|
|
||||||
return await request({
|
|
||||||
url: '/api/files/batch-delete',
|
|
||||||
method: 'post',
|
|
||||||
data: { folderIds: uniqueIds, recursiveFolders }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// FOLDER API FUNCTIONS
|
// GROUP BULK API FUNCTIONS
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
export interface FolderInfo {
|
/** Patch scope for all files in a group (recursive) */
|
||||||
id: string;
|
export async function patchGroupScope(
|
||||||
name: string;
|
|
||||||
parentId: string | null;
|
|
||||||
fileCount?: number;
|
|
||||||
mandateId?: string;
|
|
||||||
featureInstanceId?: string;
|
|
||||||
createdAt?: number;
|
|
||||||
scope?: string;
|
|
||||||
neutralize?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchFolders(
|
|
||||||
request: ApiRequestFunction,
|
request: ApiRequestFunction,
|
||||||
parentId?: string | null
|
groupId: string,
|
||||||
): Promise<FolderInfo[]> {
|
scope: string
|
||||||
const params: any = {};
|
|
||||||
if (parentId !== undefined && parentId !== null) {
|
|
||||||
params.parentId = parentId;
|
|
||||||
}
|
|
||||||
const data = await request({
|
|
||||||
url: '/api/files/folders',
|
|
||||||
method: 'get',
|
|
||||||
params,
|
|
||||||
});
|
|
||||||
return Array.isArray(data) ? data : [];
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createFolder(
|
|
||||||
request: ApiRequestFunction,
|
|
||||||
name: string,
|
|
||||||
parentId?: string | null
|
|
||||||
): Promise<FolderInfo> {
|
|
||||||
return await request({
|
|
||||||
url: '/api/files/folders',
|
|
||||||
method: 'post',
|
|
||||||
data: { name, parentId: parentId || null },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function renameFolder(
|
|
||||||
request: ApiRequestFunction,
|
|
||||||
folderId: string,
|
|
||||||
name: string
|
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
return await request({
|
return await request({
|
||||||
url: `/api/files/folders/${folderId}`,
|
url: `/api/files/groups/${groupId}/scope`,
|
||||||
method: 'put',
|
method: 'patch',
|
||||||
data: { name },
|
data: { scope },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteFolderApi(
|
/** Patch neutralize for all files in a group (recursive, incl. knowledge purge/reindex) */
|
||||||
|
export async function patchGroupNeutralize(
|
||||||
request: ApiRequestFunction,
|
request: ApiRequestFunction,
|
||||||
folderId: string,
|
groupId: string,
|
||||||
recursive: boolean = false
|
neutralize: boolean
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
return await request({
|
return await request({
|
||||||
url: `/api/files/folders/${folderId}`,
|
url: `/api/files/groups/${groupId}/neutralize`,
|
||||||
|
method: 'patch',
|
||||||
|
data: { neutralize },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Download all files in a group as ZIP */
|
||||||
|
export async function downloadGroupZip(groupId: string): Promise<void> {
|
||||||
|
const { default: api } = await import('../api');
|
||||||
|
const response = await api.get(`/api/files/groups/${groupId}/download`, {
|
||||||
|
responseType: 'blob',
|
||||||
|
});
|
||||||
|
const url = window.URL.createObjectURL(response.data);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.setAttribute('download', `group-${groupId}.zip`);
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
link.remove();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Delete a group and optionally all its files */
|
||||||
|
export async function deleteGroup(
|
||||||
|
request: ApiRequestFunction,
|
||||||
|
groupId: string,
|
||||||
|
deleteItems: boolean = false
|
||||||
|
): Promise<any> {
|
||||||
|
return await request({
|
||||||
|
url: `/api/files/groups/${groupId}`,
|
||||||
method: 'delete',
|
method: 'delete',
|
||||||
params: { recursive },
|
params: { deleteItems },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function moveFolder(
|
/** Collect all file IDs belonging to a group recursively (client-side, from known groupTree) */
|
||||||
request: ApiRequestFunction,
|
export function collectGroupItemIds(
|
||||||
folderId: string,
|
groupTree: Array<{ id: string; itemIds: string[]; subGroups: any[] }>,
|
||||||
targetParentId: string | null
|
groupId: string
|
||||||
): Promise<any> {
|
): string[] {
|
||||||
return await request({
|
const collect = (nodes: Array<{ id: string; itemIds: string[]; subGroups: any[] }>): string[] | null => {
|
||||||
url: `/api/files/folders/${folderId}/move`,
|
for (const node of nodes) {
|
||||||
method: 'post',
|
if (node.id === groupId) {
|
||||||
data: { targetParentId },
|
const ids: string[] = [...node.itemIds];
|
||||||
});
|
const sub = (n: { id: string; itemIds: string[]; subGroups: any[] }) => {
|
||||||
}
|
ids.push(...n.itemIds);
|
||||||
|
n.subGroups.forEach(sub);
|
||||||
export async function moveFile(
|
};
|
||||||
request: ApiRequestFunction,
|
node.subGroups.forEach(sub);
|
||||||
fileId: string,
|
return ids;
|
||||||
targetFolderId: string | null
|
}
|
||||||
): Promise<any> {
|
const found = collect(node.subGroups);
|
||||||
return await request({
|
if (found) return found;
|
||||||
url: `/api/files/${fileId}/move`,
|
}
|
||||||
method: 'post',
|
return null;
|
||||||
data: { targetFolderId },
|
};
|
||||||
});
|
return collect(groupTree) ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Note: The following operations require special handling (FormData, blob responses)
|
// Note: The following operations require special handling (FormData, blob responses)
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
* AI Chat sidebar for the GraphicalEditor.
|
* AI Chat sidebar for the GraphicalEditor.
|
||||||
* Streams responses via SSE (same pattern as Workspace chat).
|
* Streams responses via SSE (same pattern as Workspace chat).
|
||||||
* File & data-source attachment UX mirrors WorkspaceInput:
|
* File & data-source attachment UX mirrors WorkspaceInput:
|
||||||
* - Files: drag & drop from FolderTree onto input area, or click in UDB
|
* - Files: drag & drop from FilesTab (UDB) onto input area, or click in UDB
|
||||||
* - Data Sources: 🔗 picker button next to input (toggle-select from active sources)
|
* - Data Sources: 🔗 picker button next to input (toggle-select from active sources)
|
||||||
*/
|
*/
|
||||||
import React, { useState, useCallback, useEffect, useRef } from 'react';
|
import React, { useState, useCallback, useEffect, useRef } from 'react';
|
||||||
|
|
@ -32,7 +32,7 @@ import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
export interface PendingFile {
|
export interface PendingFile {
|
||||||
fileId: string;
|
fileId: string;
|
||||||
fileName: string;
|
fileName: string;
|
||||||
itemType?: 'file' | 'folder';
|
itemType?: 'file' | 'group';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EditorDataSource {
|
export interface EditorDataSource {
|
||||||
|
|
@ -241,7 +241,12 @@ export const EditorChatPanel: React.FC<EditorChatPanelProps> = ({ instanceId,
|
||||||
}, [_handleSend]);
|
}, [_handleSend]);
|
||||||
|
|
||||||
const _handleDragOver = useCallback((e: React.DragEvent) => {
|
const _handleDragOver = useCallback((e: React.DragEvent) => {
|
||||||
if (e.dataTransfer.types.includes('application/tree-items')) {
|
if (
|
||||||
|
e.dataTransfer.types.includes('application/tree-items') ||
|
||||||
|
e.dataTransfer.types.includes('application/group-id') ||
|
||||||
|
e.dataTransfer.types.includes('application/file-id') ||
|
||||||
|
e.dataTransfer.types.includes('application/file-ids')
|
||||||
|
) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.dataTransfer.dropEffect = 'copy';
|
e.dataTransfer.dropEffect = 'copy';
|
||||||
setTreeDropOver(true);
|
setTreeDropOver(true);
|
||||||
|
|
@ -252,6 +257,12 @@ export const EditorChatPanel: React.FC<EditorChatPanelProps> = ({ instanceId,
|
||||||
|
|
||||||
const _handleDrop = useCallback((e: React.DragEvent) => {
|
const _handleDrop = useCallback((e: React.DragEvent) => {
|
||||||
setTreeDropOver(false);
|
setTreeDropOver(false);
|
||||||
|
const groupId = e.dataTransfer.getData('application/group-id');
|
||||||
|
if (groupId) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
return;
|
||||||
|
}
|
||||||
const treeItemsJson = e.dataTransfer.getData('application/tree-items');
|
const treeItemsJson = e.dataTransfer.getData('application/tree-items');
|
||||||
if (treeItemsJson) {
|
if (treeItemsJson) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -282,11 +293,11 @@ export const EditorChatPanel: React.FC<EditorChatPanelProps> = ({ instanceId,
|
||||||
<span key={pf.fileId} style={{
|
<span key={pf.fileId} style={{
|
||||||
display: 'inline-flex', alignItems: 'center', gap: 4,
|
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||||||
padding: '2px 8px', borderRadius: 12, fontSize: 11,
|
padding: '2px 8px', borderRadius: 12, fontSize: 11,
|
||||||
background: pf.itemType === 'folder' ? '#e3f2fd' : '#fff3e0',
|
background: pf.itemType === 'group' ? '#e3f2fd' : '#fff3e0',
|
||||||
color: pf.itemType === 'folder' ? '#1565c0' : '#e65100',
|
color: pf.itemType === 'group' ? '#1565c0' : '#e65100',
|
||||||
fontWeight: 500, border: `1px solid ${pf.itemType === 'folder' ? '#bbdefb' : '#ffe0b2'}`,
|
fontWeight: 500, border: `1px solid ${pf.itemType === 'group' ? '#bbdefb' : '#ffe0b2'}`,
|
||||||
}}>
|
}}>
|
||||||
{pf.itemType === 'folder' ? '\uD83D\uDCC1' : '\uD83D\uDCCE'} {pf.fileName.length > 20 ? pf.fileName.slice(0, 20) + '...' : pf.fileName}
|
{pf.itemType === 'group' ? '\uD83D\uDCC2' : '\uD83D\uDCCE'} {pf.fileName.length > 20 ? pf.fileName.slice(0, 20) + '...' : pf.fileName}
|
||||||
{onRemovePendingFile && (
|
{onRemovePendingFile && (
|
||||||
<button onClick={() => onRemovePendingFile(pf.fileId)} style={{
|
<button onClick={() => onRemovePendingFile(pf.fileId)} style={{
|
||||||
border: 'none', background: 'none', cursor: 'pointer', fontSize: 11, color: '#e65100', padding: 0, lineHeight: 1,
|
border: 'none', background: 'none', cursor: 'pointer', fontSize: 11, color: '#e65100', padding: 0, lineHeight: 1,
|
||||||
|
|
|
||||||
|
|
@ -280,9 +280,10 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({ nodes,
|
||||||
const reservedMimes = new Set([
|
const reservedMimes = new Set([
|
||||||
'application/json',
|
'application/json',
|
||||||
'application/tree-items',
|
'application/tree-items',
|
||||||
|
'application/group-file-ids',
|
||||||
'application/file-id',
|
'application/file-id',
|
||||||
'application/file-ids',
|
'application/file-ids',
|
||||||
'application/folder-id',
|
'application/group-id',
|
||||||
]);
|
]);
|
||||||
for (const mime of Array.from(e.dataTransfer.types)) {
|
for (const mime of Array.from(e.dataTransfer.types)) {
|
||||||
if (!mime.startsWith('application/') || reservedMimes.has(mime)) continue;
|
if (!mime.startsWith('application/') || reservedMimes.has(mime)) continue;
|
||||||
|
|
|
||||||
|
|
@ -247,6 +247,34 @@
|
||||||
box-shadow: 0 3px 8px rgba(197, 48, 48, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.1) !important;
|
box-shadow: 0 3px 8px rgba(197, 48, 48, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.1) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Compact mode (sidebar/UDB) */
|
||||||
|
.compact {
|
||||||
|
width: 20px !important;
|
||||||
|
height: 20px !important;
|
||||||
|
min-width: 0 !important;
|
||||||
|
min-height: 0 !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
background: transparent !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
color: var(--color-text-secondary, #6b7280) !important;
|
||||||
|
border-radius: 3px !important;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact .actionIcon {
|
||||||
|
font-size: 12px !important;
|
||||||
|
width: 12px !important;
|
||||||
|
height: 12px !important;
|
||||||
|
filter: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact:hover {
|
||||||
|
background: var(--color-secondary, #4A6FA5) !important;
|
||||||
|
color: #fff !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
transform: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
/* Responsive Design */
|
/* Responsive Design */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.actionButtons {
|
.actionButtons {
|
||||||
|
|
|
||||||
|
|
@ -1172,3 +1172,68 @@ tbody .actionsColumn {
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Compact sidebar mode ───────────────────────────────────────────────────── */
|
||||||
|
.compactMode {
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compactMode .tableWrapper {
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Switch to auto layout so the action column shrinks to its content width
|
||||||
|
and the name column fills all remaining space naturally */
|
||||||
|
.compactMode .table {
|
||||||
|
table-layout: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compactMode .td {
|
||||||
|
padding: 5px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
border-right: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
/* Let the browser size this column based on content */
|
||||||
|
width: auto;
|
||||||
|
min-width: unset;
|
||||||
|
max-width: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Re-apply tree indent for data cells in compact mode */
|
||||||
|
.compactMode .treeRowIndented > .td {
|
||||||
|
padding-top: 5px !important;
|
||||||
|
padding-right: 8px !important;
|
||||||
|
padding-bottom: 5px !important;
|
||||||
|
padding-left: calc(8px + var(--row-tree-indent)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The action column: fixed narrow width, no background strip */
|
||||||
|
.compactMode .actionsColumn {
|
||||||
|
width: 28px !important;
|
||||||
|
min-width: 0 !important;
|
||||||
|
max-width: 28px !important;
|
||||||
|
padding: 2px !important;
|
||||||
|
background: transparent !important;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compactMode .actionButtons {
|
||||||
|
display: inline-flex !important;
|
||||||
|
width: auto !important;
|
||||||
|
gap: 0 !important;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Re-apply tree indent for action column in compact mode (overrides the default padding above) */
|
||||||
|
.compactMode .treeRowIndented > .actionsColumn {
|
||||||
|
padding: 2px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tighten group rows in compact mode */
|
||||||
|
.compactMode :global(.groupRow) {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,7 @@ import React, { useState, useMemo, useRef, useEffect, useLayoutEffect, useCallba
|
||||||
import type { IconType } from 'react-icons';
|
import type { IconType } from 'react-icons';
|
||||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
import styles from './FormGeneratorTable.module.css';
|
import styles from './FormGeneratorTable.module.css';
|
||||||
|
import actionBtnStyles from '../ActionButtons/ActionButton.module.css';
|
||||||
import {
|
import {
|
||||||
EditActionButton,
|
EditActionButton,
|
||||||
DeleteActionButton,
|
DeleteActionButton,
|
||||||
|
|
@ -119,6 +120,11 @@ const _EMPTY_FILTER_SENTINEL = '__EMPTY__';
|
||||||
function _genGroupId(): string {
|
function _genGroupId(): string {
|
||||||
return `g-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`;
|
return `g-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** All direct file IDs in this group subtree (nested subgroups included). */
|
||||||
|
function _collectAllItemIdsUnderGroup(node: TableGroupNode): string[] {
|
||||||
|
return [...node.itemIds, ...node.subGroups.flatMap(_collectAllItemIdsUnderGroup)];
|
||||||
|
}
|
||||||
function _treeAddRoot(tree: TableGroupNode[], node: TableGroupNode): TableGroupNode[] {
|
function _treeAddRoot(tree: TableGroupNode[], node: TableGroupNode): TableGroupNode[] {
|
||||||
return [...tree, node];
|
return [...tree, node];
|
||||||
}
|
}
|
||||||
|
|
@ -361,6 +367,11 @@ export interface FormGeneratorTableProps<T = any> {
|
||||||
onRowDragStart?: (e: React.DragEvent<HTMLTableRowElement>, row: T) => void;
|
onRowDragStart?: (e: React.DragEvent<HTMLTableRowElement>, row: T) => void;
|
||||||
/** Enable persistent user-defined grouping for this table instance. */
|
/** Enable persistent user-defined grouping for this table instance. */
|
||||||
groupingConfig?: GroupingConfig;
|
groupingConfig?: GroupingConfig;
|
||||||
|
/** Provide additional bulk actions rendered on each group row (e.g. scope/neutralize/download).
|
||||||
|
* Receives the groupId and all itemIds (recursive) for that group. */
|
||||||
|
groupBulkActionsProvider?: (groupId: string, itemIds: string[]) => import('../GroupingManager/GroupRow').GroupBulkAction[];
|
||||||
|
/** Render component in compact sidebar mode */
|
||||||
|
compact?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const _FILTER_PAGE_SIZE = 100;
|
const _FILTER_PAGE_SIZE = 100;
|
||||||
|
|
@ -721,6 +732,8 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
rowDraggable = false,
|
rowDraggable = false,
|
||||||
onRowDragStart,
|
onRowDragStart,
|
||||||
groupingConfig,
|
groupingConfig,
|
||||||
|
groupBulkActionsProvider,
|
||||||
|
compact = false,
|
||||||
}: FormGeneratorTableProps<T>) {
|
}: FormGeneratorTableProps<T>) {
|
||||||
const { t, currentLanguage: contextLanguage } = useLanguage();
|
const { t, currentLanguage: contextLanguage } = useLanguage();
|
||||||
// When only onDelete is provided, use it for multi-delete too so Delete stays visible with 2+ selected
|
// When only onDelete is provided, use it for multi-delete too so Delete stays visible with 2+ selected
|
||||||
|
|
@ -1260,8 +1273,8 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
if (actionButtons.length === 0 && customActions.length === 0) return 0;
|
if (actionButtons.length === 0 && customActions.length === 0) return 0;
|
||||||
const totalButtons = actionButtons.length + customActions.length;
|
const totalButtons = actionButtons.length + customActions.length;
|
||||||
const calculatedWidth = totalButtons * 26 + (totalButtons - 1) * 2 + 8;
|
const calculatedWidth = totalButtons * 26 + (totalButtons - 1) * 2 + 8;
|
||||||
return Math.max(MIN_ACTIONS_WIDTH_FOR_4_ICONS, calculatedWidth);
|
return compact ? calculatedWidth : Math.max(MIN_ACTIONS_WIDTH_FOR_4_ICONS, calculatedWidth);
|
||||||
}, [actionButtons.length, customActions.length]);
|
}, [actionButtons.length, customActions.length, compact]);
|
||||||
|
|
||||||
// Current actions column width (user-defined or default)
|
// Current actions column width (user-defined or default)
|
||||||
const currentActionsWidth = actionsColumnWidth ?? defaultActionsWidth;
|
const currentActionsWidth = actionsColumnWidth ?? defaultActionsWidth;
|
||||||
|
|
@ -2452,9 +2465,9 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`${styles.formGeneratorTable} ${className}`}>
|
<div className={`${styles.formGeneratorTable} ${compact ? styles.compactMode : ''} ${className}`}>
|
||||||
|
|
||||||
{(searchable || (selectable && selectedIds.size > 0)) && (
|
{!compact && (searchable || (selectable && selectedIds.size > 0)) && (
|
||||||
<FormGeneratorControls
|
<FormGeneratorControls
|
||||||
fields={detectedColumns}
|
fields={detectedColumns}
|
||||||
searchTerm={searchTerm}
|
searchTerm={searchTerm}
|
||||||
|
|
@ -2552,13 +2565,15 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
|
|
||||||
{/* Table Wrapper - contains top scrollbar and table container */}
|
{/* Table Wrapper - contains top scrollbar and table container */}
|
||||||
<div className={`${styles.tableWrapper} ${displayData.length === 0 && !loading ? styles.emptyTable : ''}`}>
|
<div className={`${styles.tableWrapper} ${displayData.length === 0 && !loading ? styles.emptyTable : ''}`}>
|
||||||
{/* Top horizontal scrollbar - syncs with table container */}
|
{/* Top horizontal scrollbar - hidden in compact mode */}
|
||||||
<div
|
{!compact && (
|
||||||
ref={topScrollbarRef}
|
<div
|
||||||
className={styles.topScrollbar}
|
ref={topScrollbarRef}
|
||||||
>
|
className={styles.topScrollbar}
|
||||||
<div ref={topScrollbarInnerRef} className={styles.topScrollbarInner} />
|
>
|
||||||
</div>
|
<div ref={topScrollbarInnerRef} className={styles.topScrollbarInner} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Table Container - vertical scroll only */}
|
{/* Table Container - vertical scroll only */}
|
||||||
<div
|
<div
|
||||||
|
|
@ -2574,7 +2589,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<table ref={tableRef} className={styles.table}>
|
<table ref={tableRef} className={styles.table}>
|
||||||
<thead>
|
{!compact && <thead>
|
||||||
<tr>
|
<tr>
|
||||||
{selectable && (
|
{selectable && (
|
||||||
<th className={styles.selectColumn} style={{ width: '40px', minWidth: '40px', maxWidth: '40px' }}>
|
<th className={styles.selectColumn} style={{ width: '40px', minWidth: '40px', maxWidth: '40px' }}>
|
||||||
|
|
@ -2860,7 +2875,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>}
|
||||||
<tbody>
|
<tbody>
|
||||||
{groupBy && groupedData ? (
|
{groupBy && groupedData ? (
|
||||||
Array.from(groupedData.entries()).map(([groupKey, groupRows]) => {
|
Array.from(groupedData.entries()).map(([groupKey, groupRows]) => {
|
||||||
|
|
@ -2890,7 +2905,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
{hasActionColumn && (
|
{hasActionColumn && (
|
||||||
<td
|
<td
|
||||||
className={styles.actionsColumn}
|
className={styles.actionsColumn}
|
||||||
style={{ width: `${currentActionsWidth}px`, minWidth: `${defaultActionsWidth}px` }}
|
style={compact ? undefined : { width: `${currentActionsWidth}px`, minWidth: `${defaultActionsWidth}px` }}
|
||||||
>
|
>
|
||||||
{!selectable && (
|
{!selectable && (
|
||||||
<span style={{
|
<span style={{
|
||||||
|
|
@ -2971,10 +2986,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
{hasActionColumn && (
|
{hasActionColumn && (
|
||||||
<td
|
<td
|
||||||
className={styles.actionsColumn}
|
className={styles.actionsColumn}
|
||||||
style={{
|
style={compact ? undefined : { width: `${currentActionsWidth}px`, minWidth: `${defaultActionsWidth}px` }}
|
||||||
width: `${currentActionsWidth}px`,
|
|
||||||
minWidth: `${defaultActionsWidth}px`
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
ref={(el) => {
|
ref={(el) => {
|
||||||
|
|
@ -3005,7 +3017,8 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
const isProcessing = actionButton.isProcessing ? actionButton.isProcessing(row) : false;
|
const isProcessing = actionButton.isProcessing ? actionButton.isProcessing(row) : false;
|
||||||
const baseProps = {
|
const baseProps = {
|
||||||
row, disabled: disabledResult, loading: isLoading,
|
row, disabled: disabledResult, loading: isLoading,
|
||||||
className: actionButton.className, title: actionTitle,
|
className: [compact ? actionBtnStyles.compact : '', actionButton.className ?? ''].filter(Boolean).join(' '),
|
||||||
|
title: actionTitle,
|
||||||
idField: actionButton.idField ?? 'id', nameField: actionButton.nameField ?? 'name',
|
idField: actionButton.idField ?? 'id', nameField: actionButton.nameField ?? 'name',
|
||||||
typeField: actionButton.typeField ?? 'type', contentField: actionButton.contentField ?? 'content',
|
typeField: actionButton.typeField ?? 'type', contentField: actionButton.contentField ?? 'content',
|
||||||
operationName: actionButton.operationName, loadingStateName: actionButton.loadingStateName
|
operationName: actionButton.operationName, loadingStateName: actionButton.loadingStateName
|
||||||
|
|
@ -3089,8 +3102,25 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
dragSourceDepthRef.current = indentLevel;
|
dragSourceDepthRef.current = indentLevel;
|
||||||
setDragWillUngroup(false);
|
setDragWillUngroup(false);
|
||||||
setDraggedRowId(rowId);
|
setDraggedRowId(rowId);
|
||||||
e.dataTransfer.effectAllowed = 'move';
|
e.dataTransfer.effectAllowed = 'copyMove';
|
||||||
e.dataTransfer.setData('text/plain', rowId);
|
e.dataTransfer.setData('text/plain', rowId);
|
||||||
|
// External drop targets (Workspace, Teamsbot): same shapes as FolderTree /
|
||||||
|
// FilesPage drag protocol. Keep ``text/plain`` as row id UUID so in-table
|
||||||
|
// drop fallback still resolves the row.
|
||||||
|
if (!(rowDraggable && onRowDragStart)) {
|
||||||
|
const rf = row as { fileName?: string; name?: string };
|
||||||
|
const rowLabel =
|
||||||
|
typeof rf.fileName === 'string' && rf.fileName
|
||||||
|
? rf.fileName
|
||||||
|
: typeof rf.name === 'string' && rf.name
|
||||||
|
? rf.name
|
||||||
|
: rowId;
|
||||||
|
e.dataTransfer.setData('application/file-id', rowId);
|
||||||
|
e.dataTransfer.setData(
|
||||||
|
'application/tree-items',
|
||||||
|
JSON.stringify([{ id: rowId, type: 'file' as const, name: rowLabel }]),
|
||||||
|
);
|
||||||
|
}
|
||||||
// Delay source-group collapse: browser needs one frame to
|
// Delay source-group collapse: browser needs one frame to
|
||||||
// capture the drag ghost before we remove the element from DOM.
|
// capture the drag ghost before we remove the element from DOM.
|
||||||
if (indentLevel > 0) {
|
if (indentLevel > 0) {
|
||||||
|
|
@ -3138,7 +3168,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
{hasActionColumn && (
|
{hasActionColumn && (
|
||||||
<td
|
<td
|
||||||
className={styles.actionsColumn}
|
className={styles.actionsColumn}
|
||||||
style={{ width: `${currentActionsWidth}px`, minWidth: `${defaultActionsWidth}px` }}
|
style={compact ? undefined : { width: `${currentActionsWidth}px`, minWidth: `${defaultActionsWidth}px` }}
|
||||||
>
|
>
|
||||||
<div ref={(el) => { if (el) actionButtonsRefs.current.set(index, el); else actionButtonsRefs.current.delete(index); }}
|
<div ref={(el) => { if (el) actionButtonsRefs.current.set(index, el); else actionButtonsRefs.current.delete(index); }}
|
||||||
className={`${styles.actionButtons} ${shouldWrapActionButtons ? styles.actionButtonsWrap : ''}`}>
|
className={`${styles.actionButtons} ${shouldWrapActionButtons ? styles.actionButtonsWrap : ''}`}>
|
||||||
|
|
@ -3153,7 +3183,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
}
|
}
|
||||||
const isLd = ab.loading ? ab.loading(row) : false;
|
const isLd = ab.loading ? ab.loading(row) : false;
|
||||||
const isProc = ab.isProcessing ? ab.isProcessing(row) : false;
|
const isProc = ab.isProcessing ? ab.isProcessing(row) : false;
|
||||||
const bp = { row, disabled: dis, loading: isLd, className: ab.className, title: abTitle, idField: ab.idField ?? 'id', nameField: ab.nameField ?? 'name', typeField: ab.typeField ?? 'type', contentField: ab.contentField ?? 'content', operationName: ab.operationName, loadingStateName: ab.loadingStateName };
|
const bp = { row, disabled: dis, loading: isLd, className: [compact ? actionBtnStyles.compact : '', ab.className ?? ''].filter(Boolean).join(' '), title: abTitle, idField: ab.idField ?? 'id', nameField: ab.nameField ?? 'name', typeField: ab.typeField ?? 'type', contentField: ab.contentField ?? 'content', operationName: ab.operationName, loadingStateName: ab.loadingStateName };
|
||||||
switch (ab.type) {
|
switch (ab.type) {
|
||||||
case 'edit': return <EditActionButton key={`a-${ai}`} {...bp} onEdit={ab.onAction} hookData={hookData} />;
|
case 'edit': return <EditActionButton key={`a-${ai}`} {...bp} onEdit={ab.onAction} hookData={hookData} />;
|
||||||
case 'delete': return <DeleteActionButton key={`a-${ai}`} {...bp} containerRef={{ current: actionButtonsRefs.current.get(index) || null }} hookData={hookData} />;
|
case 'delete': return <DeleteActionButton key={`a-${ai}`} {...bp} containerRef={{ current: actionButtonsRefs.current.get(index) || null }} hookData={hookData} />;
|
||||||
|
|
@ -3275,6 +3305,15 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
disabled: applicable.length === 0,
|
disabled: applicable.length === 0,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
// Inject domain-specific group bulk actions from caller
|
||||||
|
if (groupBulkActionsProvider) {
|
||||||
|
const _collectAll2 = (n: typeof node): string[] => [
|
||||||
|
...n.itemIds,
|
||||||
|
...n.subGroups.flatMap(_collectAll2),
|
||||||
|
];
|
||||||
|
const domainActions = groupBulkActionsProvider(node.id, _collectAll2(node));
|
||||||
|
_bulkActions.push(...domainActions);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment key={`g-${node.id}`}>
|
<React.Fragment key={`g-${node.id}`}>
|
||||||
|
|
@ -3365,7 +3404,21 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
dragSourceDepthRef.current = depth;
|
dragSourceDepthRef.current = depth;
|
||||||
setDragWillUngroup(false);
|
setDragWillUngroup(false);
|
||||||
e.dataTransfer.setData('application/porta-group', node.id);
|
e.dataTransfer.setData('application/porta-group', node.id);
|
||||||
e.dataTransfer.effectAllowed = 'move';
|
const groupLabel = node.name?.trim() || node.id;
|
||||||
|
// Snapshot of membership at drag time — survives debounced persistence lag
|
||||||
|
// so Workspace/other drop targets attach files immediately.
|
||||||
|
e.dataTransfer.setData(
|
||||||
|
'application/group-file-ids',
|
||||||
|
JSON.stringify(_collectAllItemIdsUnderGroup(node)),
|
||||||
|
);
|
||||||
|
// WorkspaceInput / Teamsbot / FlowCanvas (attach to chat) — expects these:
|
||||||
|
e.dataTransfer.setData('application/group-id', node.id);
|
||||||
|
e.dataTransfer.setData('text/plain', groupLabel);
|
||||||
|
e.dataTransfer.setData(
|
||||||
|
'application/tree-items',
|
||||||
|
JSON.stringify([{ id: node.id, type: 'group' as const, name: groupLabel }]),
|
||||||
|
);
|
||||||
|
e.dataTransfer.effectAllowed = 'copyMove';
|
||||||
setDraggedGroupId(node.id);
|
setDraggedGroupId(node.id);
|
||||||
// Delay parent-group collapse to let the drag ghost be captured first
|
// Delay parent-group collapse to let the drag ghost be captured first
|
||||||
if (depth > 0) {
|
if (depth > 0) {
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,36 @@
|
||||||
import React, { useState, useCallback, useRef, useMemo } from 'react';
|
import React, { useCallback, useRef, useMemo } from 'react';
|
||||||
import { FaFileImport } from 'react-icons/fa';
|
import { FaFileImport, FaPaperPlane } from 'react-icons/fa';
|
||||||
import type { UdbContext } from './UnifiedDataBar';
|
import type { UdbContext } from './UnifiedDataBar';
|
||||||
import api from '../../api';
|
import api from '../../api';
|
||||||
import FolderTree from '../../components/FolderTree/FolderTree';
|
import { useUserFiles, useFileOperations } from '../../hooks/useFiles';
|
||||||
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 { useApiRequest } from '../../hooks/useApi';
|
||||||
import {
|
import {
|
||||||
importWorkflowFromFile,
|
importWorkflowFromFile,
|
||||||
WORKFLOW_FILE_EXTENSION,
|
WORKFLOW_FILE_EXTENSION,
|
||||||
} from '../../api/workflowApi';
|
} from '../../api/workflowApi';
|
||||||
import { useToast } from '../../contexts/ToastContext';
|
import { useToast } from '../../contexts/ToastContext';
|
||||||
|
import { FormGeneratorTable } from '../FormGenerator/FormGeneratorTable';
|
||||||
|
import { ViewActionButton } from '../FormGenerator/ActionButtons/ViewActionButton';
|
||||||
|
import actionBtnStyles from '../FormGenerator/ActionButtons/ActionButton.module.css';
|
||||||
import styles from './FilesTab.module.css';
|
import styles from './FilesTab.module.css';
|
||||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||||
|
import type { TableGroupNode } from '../../api/connectionApi';
|
||||||
|
|
||||||
|
function _findGroupDisplayName(nodes: TableGroupNode[], groupId: string): string | null {
|
||||||
|
for (const n of nodes) {
|
||||||
|
if (n.id === groupId) return (n.name && n.name.trim()) || groupId;
|
||||||
|
const sub = _findGroupDisplayName(n.subGroups, groupId);
|
||||||
|
if (sub !== null) return sub;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
interface FilesTabProps {
|
interface FilesTabProps {
|
||||||
context: UdbContext;
|
context: UdbContext;
|
||||||
onFileSelect?: (fileId: string, fileName?: string) => void;
|
onFileSelect?: (fileId: string, fileName?: string) => void;
|
||||||
onSendToChat?: (items: Array<{ id: string; type: 'file' | 'folder'; name: string }>) => void;
|
onSendToChat?: (items: Array<{ id: string; type: 'file' | 'group'; name: string }>) => void;
|
||||||
/** Wird aufgerufen, wenn ein ``.workflow.json``-File via Custom-Action in
|
/** Wird aufgerufen, wenn ein ``.workflow.json``-File via Custom-Action in
|
||||||
* den Graph-Editor importiert wurde. Aktivierung im Editor (Refresh-Liste,
|
* den Graph-Editor importiert wurde. */
|
||||||
* Auto-Select) bleibt Aufgabe des Aufrufers. */
|
|
||||||
onWorkflowImported?: (workflowId: string) => void;
|
onWorkflowImported?: (workflowId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -29,57 +38,23 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect, onSendToChat
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
const { request } = useApiRequest();
|
const { request } = useApiRequest();
|
||||||
const { showSuccess, showError } = useToast();
|
const { showSuccess, showError } = useToast();
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [isDragOver, setIsDragOver] = React.useState(false);
|
||||||
const [isDragOver, setIsDragOver] = useState(false);
|
const [uploading, setUploading] = React.useState(false);
|
||||||
const [uploading, setUploading] = useState(false);
|
|
||||||
const [selectedFolderId, setSelectedFolderId] = useState<string | null>(null);
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
folders,
|
data: files,
|
||||||
refreshFolders,
|
pagination,
|
||||||
treeFileNodes,
|
loading,
|
||||||
treeFilesLoading,
|
refetch,
|
||||||
refreshTreeFiles,
|
groupTree,
|
||||||
updateTreeFileNode,
|
} = useUserFiles();
|
||||||
expandedFolderIds,
|
|
||||||
toggleFolderExpanded,
|
|
||||||
handleCreateFolder,
|
|
||||||
handleRenameFolder,
|
|
||||||
handleDeleteFolder,
|
|
||||||
handleMoveFolder,
|
|
||||||
handleMoveFolders,
|
|
||||||
handleMoveFile,
|
|
||||||
handleMoveFiles: contextMoveFiles,
|
|
||||||
handleFileDelete,
|
|
||||||
handleDownloadFolder,
|
|
||||||
} = useFileContext();
|
|
||||||
|
|
||||||
const _folderNodes = useMemo(() => {
|
const { handleFileDelete, previewingFiles } = useFileOperations() as any;
|
||||||
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(() => {
|
const _tableRefetch = useCallback(async (params?: any) => {
|
||||||
let result = treeFileNodes;
|
await refetch(params);
|
||||||
if (searchQuery.trim()) {
|
}, [refetch]);
|
||||||
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[]) => {
|
const _uploadFiles = useCallback(async (fileList: FileList | File[]) => {
|
||||||
if (!context.instanceId || uploading) return;
|
if (!context.instanceId || uploading) return;
|
||||||
|
|
@ -93,13 +68,13 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect, onSendToChat
|
||||||
headers: { 'Content-Type': 'multipart/form-data' },
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
await _refreshAll();
|
await _tableRefetch();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('File upload failed:', err);
|
console.error('File upload failed:', err);
|
||||||
} finally {
|
} finally {
|
||||||
setUploading(false);
|
setUploading(false);
|
||||||
}
|
}
|
||||||
}, [context.instanceId, uploading, _refreshAll]);
|
}, [context.instanceId, uploading, _tableRefetch]);
|
||||||
|
|
||||||
const _handleDragOver = useCallback((e: React.DragEvent) => {
|
const _handleDragOver = useCallback((e: React.DragEvent) => {
|
||||||
if (e.dataTransfer.types.includes('Files')) {
|
if (e.dataTransfer.types.includes('Files')) {
|
||||||
|
|
@ -131,97 +106,63 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect, onSendToChat
|
||||||
}
|
}
|
||||||
}, [_uploadFiles]);
|
}, [_uploadFiles]);
|
||||||
|
|
||||||
const _onMoveFile = useCallback(async (fileId: string, targetFolderId: string | null) => {
|
|
||||||
await handleMoveFile(fileId, targetFolderId);
|
|
||||||
}, [handleMoveFile]);
|
|
||||||
|
|
||||||
const _onMoveFiles = useCallback(async (fileIds: string[], targetFolderId: string | null) => {
|
const columns = useMemo(() => [{
|
||||||
await contextMoveFiles(fileIds, targetFolderId);
|
key: 'fileName',
|
||||||
}, [contextMoveFiles]);
|
label: t('Dateiname'),
|
||||||
|
sortable: false,
|
||||||
|
filterable: false,
|
||||||
|
searchable: false,
|
||||||
|
formatter: (value: any, row: any) => (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, minWidth: 0 }}>
|
||||||
|
<ViewActionButton
|
||||||
|
row={row}
|
||||||
|
onView={() => {}}
|
||||||
|
idField="id"
|
||||||
|
nameField="fileName"
|
||||||
|
typeField="mimeType"
|
||||||
|
loadingStateName="previewingFiles"
|
||||||
|
hookData={{ previewingFiles }}
|
||||||
|
className={actionBtnStyles.compact}
|
||||||
|
/>
|
||||||
|
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontSize: 12 }}>
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}], [t, previewingFiles]);
|
||||||
|
|
||||||
const _onDeleteFolder = useCallback(async (folderId: string) => {
|
const _groupBulkActionsProvider = useMemo(() => {
|
||||||
await handleDeleteFolder(folderId);
|
if (!onSendToChat) return undefined;
|
||||||
if (selectedFolderId === folderId) setSelectedFolderId(null);
|
return (groupId: string, itemIds: string[]) => [
|
||||||
}, [handleDeleteFolder, selectedFolderId]);
|
{
|
||||||
|
icon: <FaPaperPlane />,
|
||||||
|
title: t('Gruppe an Chat anhängen'),
|
||||||
|
onClick: () => {
|
||||||
|
const name = _findGroupDisplayName(groupTree, groupId) ?? groupId;
|
||||||
|
onSendToChat([{ id: groupId, type: 'group', name }]);
|
||||||
|
},
|
||||||
|
disabled: itemIds.length === 0,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}, [onSendToChat, groupTree, t]);
|
||||||
|
|
||||||
const _onRenameFile = useCallback(async (fileId: string, newName: string) => {
|
const _customActions = useMemo(() => {
|
||||||
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 [];
|
if (context.surface !== 'graphEditor') return [];
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
id: 'workflow.openInEditor',
|
id: 'workflow.openInEditor',
|
||||||
label: t('In Graph-Editor laden'),
|
icon: <FaFileImport />,
|
||||||
icon: FaFileImport,
|
title: t('In Graph-Editor laden'),
|
||||||
scope: 'file',
|
onClick: async (row: any) => {
|
||||||
channels: ['inline', 'menu', 'sheet', 'drop'],
|
if (!context.instanceId || !row?.id) return;
|
||||||
dragMime: 'application/json+workflow',
|
if (!row.fileName?.toLowerCase().endsWith(WORKFLOW_FILE_EXTENSION)) return;
|
||||||
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 {
|
try {
|
||||||
const result = await importWorkflowFromFile(request, context.instanceId, {
|
const result = await importWorkflowFromFile(request, context.instanceId, { fileId: row.id });
|
||||||
fileId: file.id,
|
|
||||||
});
|
|
||||||
const warnings = result?.warnings ?? [];
|
const warnings = result?.warnings ?? [];
|
||||||
const wfId = result?.workflow?.id;
|
const wfId = result?.workflow?.id;
|
||||||
if (warnings.length > 0) {
|
if (warnings.length > 0) {
|
||||||
showSuccess(
|
showSuccess(t('Workflow importiert ({n} Warnungen).', { n: String(warnings.length) }));
|
||||||
t('Workflow importiert ({n} Warnungen). Aktivierung manuell.', {
|
|
||||||
n: String(warnings.length),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
showSuccess(t('Workflow importiert (deaktiviert).'));
|
showSuccess(t('Workflow importiert (deaktiviert).'));
|
||||||
}
|
}
|
||||||
|
|
@ -231,24 +172,11 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect, onSendToChat
|
||||||
showError(t('Import fehlgeschlagen: {msg}', { msg }));
|
showError(t('Import fehlgeschlagen: {msg}', { msg }));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
hidden: (row: any) => !row?.fileName?.toLowerCase().endsWith(WORKFLOW_FILE_EXTENSION),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}, [context.surface, context.instanceId, t, request, showSuccess, showError, onWorkflowImported]);
|
}, [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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={styles.filesTab}
|
className={styles.filesTab}
|
||||||
|
|
@ -280,7 +208,7 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect, onSendToChat
|
||||||
{uploading ? '...' : '+'}
|
{uploading ? '...' : '+'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={_refreshAll}
|
onClick={() => _tableRefetch()}
|
||||||
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, color: '#F25843' }}
|
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, color: '#F25843' }}
|
||||||
>
|
>
|
||||||
{'\u21BB'}
|
{'\u21BB'}
|
||||||
|
|
@ -296,57 +224,33 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect, onSendToChat
|
||||||
onChange={_handleFileInputChange}
|
onChange={_handleFileInputChange}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<input
|
<div style={{ flex: 1, overflow: 'auto', minHeight: 0 }}>
|
||||||
type="text"
|
<FormGeneratorTable
|
||||||
placeholder={t('Dateien suchen')}
|
data={files || []}
|
||||||
value={searchQuery}
|
columns={columns}
|
||||||
onChange={e => setSearchQuery(e.target.value)}
|
apiEndpoint="/api/files/list"
|
||||||
style={{
|
loading={loading}
|
||||||
width: '100%', padding: '6px 10px', fontSize: 12, borderRadius: 4,
|
pagination={true}
|
||||||
border: '1px solid #ddd', boxSizing: 'border-box', margin: '0 0 4px',
|
pageSize={50}
|
||||||
}}
|
searchable={false}
|
||||||
/>
|
filterable={false}
|
||||||
|
sortable={false}
|
||||||
<div style={{ flex: 1, overflow: 'auto' }}>
|
selectable={false}
|
||||||
<FolderTree
|
onRowClick={(row: any) => onFileSelect?.(row.id, row.fileName)}
|
||||||
folders={_folderNodes}
|
actionButtons={[]}
|
||||||
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}
|
customActions={_customActions}
|
||||||
udbContext={context.surface}
|
hookData={{
|
||||||
|
refetch: _tableRefetch,
|
||||||
|
pagination,
|
||||||
|
handleDelete: handleFileDelete,
|
||||||
|
previewingFiles,
|
||||||
|
groupTree,
|
||||||
|
}}
|
||||||
|
groupingConfig={{ contextKey: 'files/list', enabled: true }}
|
||||||
|
groupBulkActionsProvider={_groupBulkActionsProvider}
|
||||||
|
emptyMessage={t('Keine Dateien. Drag & Drop zum Hochladen.')}
|
||||||
|
compact={true}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{_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>
|
||||||
|
|
||||||
<div className={styles.legend}>
|
<div className={styles.legend}>
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,8 @@ import styles from './UnifiedDataBar.module.css';
|
||||||
|
|
||||||
export type UdbTab = 'chats' | 'files' | 'sources';
|
export type UdbTab = 'chats' | 'files' | 'sources';
|
||||||
|
|
||||||
/** Aufruf-Surface, in der die UDB gerade lebt. Wird an `FolderTree.udbContext`
|
/** Aufruf-Surface, in der die UDB gerade lebt. Wird an Custom-Actions
|
||||||
* weitergereicht, damit Custom-Actions (z. B. `workflow.openInEditor`) sich
|
* (z. B. `workflow.openInEditor`) weitergereicht, damit sie sich
|
||||||
* pro Surface registrieren können. */
|
* pro Surface registrieren können. */
|
||||||
export type UdbSurface =
|
export type UdbSurface =
|
||||||
| 'workspace'
|
| 'workspace'
|
||||||
|
|
@ -28,7 +28,7 @@ export interface UdbContext {
|
||||||
|
|
||||||
export interface AddToChat_FileItem {
|
export interface AddToChat_FileItem {
|
||||||
id: string;
|
id: string;
|
||||||
type: 'file' | 'folder';
|
type: 'file' | 'group';
|
||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,36 +1,9 @@
|
||||||
import React, { createContext, useContext, useCallback, useState, useEffect, useMemo } from 'react';
|
import React, { createContext, useContext } from 'react';
|
||||||
import { useLocation } from 'react-router-dom';
|
|
||||||
import api from '../api';
|
|
||||||
import { useFileOperations, type FilePreviewResult } from '../hooks/useFiles';
|
import { useFileOperations, type FilePreviewResult } from '../hooks/useFiles';
|
||||||
import type { FolderInfo } from '../api/fileApi';
|
|
||||||
import type { FileNode } from '../components/FolderTree/FolderTree';
|
|
||||||
|
|
||||||
export type { FolderInfo };
|
|
||||||
|
|
||||||
interface FileContextType {
|
interface FileContextType {
|
||||||
folders: FolderInfo[];
|
|
||||||
foldersLoading: boolean;
|
|
||||||
refreshFolders: () => Promise<void>;
|
|
||||||
|
|
||||||
treeFileNodes: FileNode[];
|
|
||||||
treeFilesLoading: boolean;
|
|
||||||
loadTreeFiles: (folderId: string) => Promise<void>;
|
|
||||||
refreshTreeFiles: () => Promise<void>;
|
|
||||||
updateTreeFileNode: (fileId: string, patch: Partial<FileNode>) => void;
|
|
||||||
|
|
||||||
expandedFolderIds: Set<string>;
|
|
||||||
toggleFolderExpanded: (id: string) => void;
|
|
||||||
|
|
||||||
handleCreateFolder: (name: string, parentId: string | null) => Promise<void>;
|
|
||||||
handleRenameFolder: (folderId: string, newName: string) => Promise<void>;
|
|
||||||
handleDeleteFolder: (folderId: string) => Promise<void>;
|
|
||||||
handleMoveFolder: (folderId: string, targetParentId: string | null) => Promise<void>;
|
|
||||||
handleMoveFolders: (folderIds: string[], targetParentId: string | null) => Promise<void>;
|
|
||||||
handleMoveFile: (fileId: string, targetFolderId: string | null) => Promise<void>;
|
|
||||||
handleMoveFiles: (fileIds: string[], targetFolderId: string | null) => Promise<void>;
|
|
||||||
handleDownloadFolder: (folderId: string, folderName: string) => Promise<void>;
|
|
||||||
handleFileDelete: (fileId: string, onOptimisticDelete?: () => void) => Promise<boolean>;
|
|
||||||
handleFileUpload: (file: File, workflowId?: string) => Promise<{ success: boolean; fileData?: any; error?: string }>;
|
handleFileUpload: (file: File, workflowId?: string) => Promise<{ success: boolean; fileData?: any; error?: string }>;
|
||||||
|
handleFileDelete: (fileId: string, onOptimisticDelete?: () => void) => Promise<boolean>;
|
||||||
handleFilePreview: (fileId: string, fileName: string, mimeType?: string) => Promise<FilePreviewResult>;
|
handleFilePreview: (fileId: string, fileName: string, mimeType?: string) => Promise<FilePreviewResult>;
|
||||||
handleFileDownload: (fileId: string, fileName: string) => Promise<void>;
|
handleFileDownload: (fileId: string, fileName: string) => Promise<void>;
|
||||||
uploadingFile: boolean;
|
uploadingFile: boolean;
|
||||||
|
|
@ -41,21 +14,6 @@ interface FileContextType {
|
||||||
|
|
||||||
export const FileContext = createContext<FileContextType | undefined>(undefined);
|
export const FileContext = createContext<FileContextType | undefined>(undefined);
|
||||||
|
|
||||||
const _ROOT_KEY = '';
|
|
||||||
|
|
||||||
function _toFileNode(f: any): FileNode {
|
|
||||||
return {
|
|
||||||
id: f.id,
|
|
||||||
fileName: f.fileName || f.name || 'unknown',
|
|
||||||
mimeType: f.mimeType,
|
|
||||||
fileSize: f.fileSize,
|
|
||||||
folderId: f.folderId ?? null,
|
|
||||||
scope: f.scope,
|
|
||||||
neutralize: f.neutralize,
|
|
||||||
sysCreatedBy: f.sysCreatedBy,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FileProvider({ children }: { children: React.ReactNode }) {
|
export function FileProvider({ children }: { children: React.ReactNode }) {
|
||||||
const {
|
const {
|
||||||
handleFileUpload: hookHandleFileUpload,
|
handleFileUpload: hookHandleFileUpload,
|
||||||
|
|
@ -68,254 +26,11 @@ export function FileProvider({ children }: { children: React.ReactNode }) {
|
||||||
downloadingFiles,
|
downloadingFiles,
|
||||||
} = useFileOperations();
|
} = useFileOperations();
|
||||||
|
|
||||||
// ── Derive a session-scoped storage key from the current feature-instance URL ──
|
|
||||||
const location = useLocation();
|
|
||||||
const storageKey = useMemo(() => {
|
|
||||||
const match = location.pathname.match(/^\/mandates\/([^/]+)\/([^/]+)\/([^/]+)/);
|
|
||||||
const instanceId = match ? match[3] : '_global';
|
|
||||||
return `folderTree-expandedIds-${instanceId}`;
|
|
||||||
}, [location.pathname]);
|
|
||||||
|
|
||||||
// ── Folder expanded state (persisted per feature-instance in sessionStorage) ──
|
|
||||||
const _loadExpanded = (key: string): Set<string> => {
|
|
||||||
try {
|
|
||||||
const stored = sessionStorage.getItem(key);
|
|
||||||
if (!stored) return new Set<string>();
|
|
||||||
const ids: string[] = JSON.parse(stored);
|
|
||||||
return new Set(ids.filter(id => id && id !== '__root__'));
|
|
||||||
} catch { return new Set<string>(); }
|
|
||||||
};
|
|
||||||
|
|
||||||
const [expandedFolderIds, setExpandedFolderIds] = useState<Set<string>>(() => _loadExpanded(storageKey));
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setExpandedFolderIds(_loadExpanded(storageKey));
|
|
||||||
setTreeFilesMap(new Map());
|
|
||||||
setFolders([]);
|
|
||||||
}, [storageKey]);
|
|
||||||
|
|
||||||
// ── Folder state ──────────────────────────────────────────────────────
|
|
||||||
const [folders, setFolders] = useState<FolderInfo[]>([]);
|
|
||||||
const [foldersLoading, setFoldersLoading] = useState(false);
|
|
||||||
|
|
||||||
const refreshFolders = useCallback(async () => {
|
|
||||||
setFoldersLoading(true);
|
|
||||||
try {
|
|
||||||
const response = await api.get('/api/files/folders');
|
|
||||||
const data = Array.isArray(response.data) ? response.data : [];
|
|
||||||
setFolders(data);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to load folders:', err);
|
|
||||||
} finally {
|
|
||||||
setFoldersLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => { refreshFolders(); }, [refreshFolders, storageKey]);
|
|
||||||
|
|
||||||
// ── Tree files: lazy-loaded per expanded folder ───────────────────────
|
|
||||||
const [treeFilesMap, setTreeFilesMap] = useState<Map<string, FileNode[]>>(new Map());
|
|
||||||
const [treeFilesLoading, setTreeFilesLoading] = useState(false);
|
|
||||||
|
|
||||||
const loadTreeFiles = useCallback(async (folderId: string) => {
|
|
||||||
const key = folderId || _ROOT_KEY;
|
|
||||||
setTreeFilesLoading(true);
|
|
||||||
try {
|
|
||||||
const filterValue = folderId || null;
|
|
||||||
const resp = await api.get('/api/files/list', {
|
|
||||||
params: {
|
|
||||||
pagination: JSON.stringify({
|
|
||||||
page: 1,
|
|
||||||
pageSize: 500,
|
|
||||||
filters: { folderId: filterValue },
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const items: any[] = resp.data?.items || [];
|
|
||||||
setTreeFilesMap(prev => {
|
|
||||||
const next = new Map(prev);
|
|
||||||
next.set(key, items.map(_toFileNode));
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`Failed to load tree files for folder ${folderId}:`, err);
|
|
||||||
} finally {
|
|
||||||
setTreeFilesLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const _removeTreeFiles = useCallback((folderId: string) => {
|
|
||||||
const key = folderId || _ROOT_KEY;
|
|
||||||
setTreeFilesMap(prev => {
|
|
||||||
const next = new Map(prev);
|
|
||||||
next.delete(key);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const refreshTreeFiles = useCallback(async () => {
|
|
||||||
const keys = Array.from(treeFilesMap.keys());
|
|
||||||
if (!keys.includes(_ROOT_KEY)) keys.push(_ROOT_KEY);
|
|
||||||
await Promise.all(
|
|
||||||
keys.map(key => loadTreeFiles(key === _ROOT_KEY ? '' : key)),
|
|
||||||
);
|
|
||||||
}, [treeFilesMap, loadTreeFiles]);
|
|
||||||
|
|
||||||
const updateTreeFileNode = useCallback((fileId: string, patch: Partial<FileNode>) => {
|
|
||||||
setTreeFilesMap(prev => {
|
|
||||||
const next = new Map<string, FileNode[]>();
|
|
||||||
let found = false;
|
|
||||||
for (const [key, files] of prev) {
|
|
||||||
const updated = files.map(f => {
|
|
||||||
if (f.id === fileId) {
|
|
||||||
found = true;
|
|
||||||
return { ...f, ...patch };
|
|
||||||
}
|
|
||||||
return f;
|
|
||||||
});
|
|
||||||
next.set(key, updated);
|
|
||||||
}
|
|
||||||
return found ? next : prev;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Load root files on mount and on context change
|
|
||||||
useEffect(() => { loadTreeFiles(''); }, [loadTreeFiles, storageKey]);
|
|
||||||
|
|
||||||
// Load files for expanded folders on mount and context change
|
|
||||||
useEffect(() => {
|
|
||||||
expandedFolderIds.forEach(id => {
|
|
||||||
if (!treeFilesMap.has(id)) {
|
|
||||||
loadTreeFiles(id);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [storageKey]);
|
|
||||||
|
|
||||||
const treeFileNodes: FileNode[] = useMemo(() => {
|
|
||||||
const result: FileNode[] = [];
|
|
||||||
for (const [, files] of treeFilesMap) {
|
|
||||||
for (const f of files) result.push(f);
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}, [treeFilesMap]);
|
|
||||||
|
|
||||||
// ── Toggle expand: load/unload tree files ─────────────────────────────
|
|
||||||
const toggleFolderExpanded = useCallback((id: string) => {
|
|
||||||
setExpandedFolderIds(prev => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
if (next.has(id)) {
|
|
||||||
next.delete(id);
|
|
||||||
} else {
|
|
||||||
next.add(id);
|
|
||||||
loadTreeFiles(id);
|
|
||||||
}
|
|
||||||
try { sessionStorage.setItem(storageKey, JSON.stringify([...next])); } catch {}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}, [storageKey, loadTreeFiles]);
|
|
||||||
|
|
||||||
// ── Folder operations ─────────────────────────────────────────────────
|
|
||||||
const handleCreateFolder = useCallback(async (name: string, parentId: string | null) => {
|
|
||||||
await api.post('/api/files/folders', { name, parentId: parentId || null });
|
|
||||||
await refreshFolders();
|
|
||||||
}, [refreshFolders]);
|
|
||||||
|
|
||||||
const handleRenameFolder = useCallback(async (folderId: string, newName: string) => {
|
|
||||||
await api.put(`/api/files/folders/${folderId}`, { name: newName });
|
|
||||||
await refreshFolders();
|
|
||||||
}, [refreshFolders]);
|
|
||||||
|
|
||||||
const handleDeleteFolder = useCallback(async (folderId: string) => {
|
|
||||||
await api.delete(`/api/files/folders/${folderId}`, { params: { recursive: true } });
|
|
||||||
_removeTreeFiles(folderId);
|
|
||||||
await refreshFolders();
|
|
||||||
}, [refreshFolders, _removeTreeFiles]);
|
|
||||||
|
|
||||||
const handleMoveFolder = useCallback(async (folderId: string, targetParentId: string | null) => {
|
|
||||||
await api.post(`/api/files/folders/${folderId}/move`, { targetParentId });
|
|
||||||
await refreshFolders();
|
|
||||||
}, [refreshFolders]);
|
|
||||||
|
|
||||||
const handleMoveFolders = useCallback(async (folderIds: string[], targetParentId: string | null) => {
|
|
||||||
await api.post('/api/files/batch-move', { folderIds, targetParentId });
|
|
||||||
await refreshFolders();
|
|
||||||
}, [refreshFolders]);
|
|
||||||
|
|
||||||
// ── File operations ───────────────────────────────────────────────────
|
|
||||||
const handleMoveFile = useCallback(async (fileId: string, targetFolderId: string | null) => {
|
|
||||||
await api.post(`/api/files/${fileId}/move`, { targetFolderId });
|
|
||||||
await refreshTreeFiles();
|
|
||||||
await refreshFolders();
|
|
||||||
}, [refreshTreeFiles, refreshFolders]);
|
|
||||||
|
|
||||||
const handleMoveFiles = useCallback(async (fileIds: string[], targetFolderId: string | null) => {
|
|
||||||
await api.post('/api/files/batch-move', { fileIds, targetFolderId });
|
|
||||||
await refreshTreeFiles();
|
|
||||||
await refreshFolders();
|
|
||||||
}, [refreshTreeFiles, refreshFolders]);
|
|
||||||
|
|
||||||
const handleFileUpload = useCallback(async (file: File, workflowId?: string) => {
|
|
||||||
const result = await hookHandleFileUpload(file, workflowId);
|
|
||||||
if (result.success) {
|
|
||||||
await refreshTreeFiles();
|
|
||||||
await refreshFolders();
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}, [hookHandleFileUpload, refreshTreeFiles, refreshFolders]);
|
|
||||||
|
|
||||||
const handleFileDelete = useCallback(async (fileId: string, onOptimisticDelete?: () => void) => {
|
|
||||||
const success = await hookHandleFileDelete(fileId, () => {
|
|
||||||
onOptimisticDelete?.();
|
|
||||||
});
|
|
||||||
if (success) {
|
|
||||||
await refreshTreeFiles();
|
|
||||||
await refreshFolders();
|
|
||||||
}
|
|
||||||
return success;
|
|
||||||
}, [hookHandleFileDelete, refreshTreeFiles, refreshFolders]);
|
|
||||||
|
|
||||||
const handleDownloadFolder = useCallback(async (folderId: string, folderName: string) => {
|
|
||||||
try {
|
|
||||||
const response = await api.get(`/api/files/folders/${folderId}/download`, {
|
|
||||||
responseType: 'blob',
|
|
||||||
});
|
|
||||||
const url = window.URL.createObjectURL(response.data);
|
|
||||||
const link = document.createElement('a');
|
|
||||||
link.href = url;
|
|
||||||
link.setAttribute('download', `${folderName}.zip`);
|
|
||||||
document.body.appendChild(link);
|
|
||||||
link.click();
|
|
||||||
link.remove();
|
|
||||||
window.URL.revokeObjectURL(url);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to download folder:', err);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FileContext.Provider
|
<FileContext.Provider
|
||||||
value={{
|
value={{
|
||||||
folders,
|
handleFileUpload: hookHandleFileUpload,
|
||||||
foldersLoading,
|
handleFileDelete: hookHandleFileDelete,
|
||||||
refreshFolders,
|
|
||||||
treeFileNodes,
|
|
||||||
treeFilesLoading,
|
|
||||||
loadTreeFiles,
|
|
||||||
refreshTreeFiles,
|
|
||||||
updateTreeFileNode,
|
|
||||||
expandedFolderIds,
|
|
||||||
toggleFolderExpanded,
|
|
||||||
handleCreateFolder,
|
|
||||||
handleRenameFolder,
|
|
||||||
handleDeleteFolder,
|
|
||||||
handleMoveFolder,
|
|
||||||
handleMoveFolders,
|
|
||||||
handleMoveFile,
|
|
||||||
handleMoveFiles,
|
|
||||||
handleDownloadFolder,
|
|
||||||
handleFileDelete,
|
|
||||||
handleFileUpload,
|
|
||||||
handleFilePreview,
|
handleFilePreview,
|
||||||
handleFileDownload: async (fileId: string, fileName: string) => {
|
handleFileDownload: async (fileId: string, fileName: string) => {
|
||||||
await handleFileDownload(fileId, fileName);
|
await handleFileDownload(fileId, fileName);
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,7 @@ export function formatApiError(error: any, defaultMessage: string): string {
|
||||||
// Type for API request options
|
// Type for API request options
|
||||||
export interface ApiRequestOptions<T> {
|
export interface ApiRequestOptions<T> {
|
||||||
url: string;
|
url: string;
|
||||||
method: 'get' | 'post' | 'put' | 'delete';
|
method: 'get' | 'post' | 'put' | 'patch' | 'delete';
|
||||||
data?: T;
|
data?: T;
|
||||||
params?: Record<string, string | number | boolean>;
|
params?: Record<string, string | number | boolean>;
|
||||||
additionalConfig?: Record<string, any>; // For responseType, headers, etc.
|
additionalConfig?: Record<string, any>; // For responseType, headers, etc.
|
||||||
|
|
@ -74,7 +74,7 @@ export function useApiRequest<RequestData = any, ResponseData = any>() {
|
||||||
// Generate cache key for GET requests (only cache GET requests)
|
// Generate cache key for GET requests (only cache GET requests)
|
||||||
const cacheKey = method === 'get' ? generateCacheKey(url, method, params) : null;
|
const cacheKey = method === 'get' ? generateCacheKey(url, method, params) : null;
|
||||||
|
|
||||||
// Mutating requests (POST/PUT/DELETE) invalidate the entire GET cache.
|
// Mutating requests (POST/PUT/PATCH/DELETE) invalidate the entire GET cache.
|
||||||
// This ensures refetch() after create/update/delete returns fresh data.
|
// This ensures refetch() after create/update/delete returns fresh data.
|
||||||
if (method !== 'get') {
|
if (method !== 'get') {
|
||||||
requestCache.clear();
|
requestCache.clear();
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@ import {
|
||||||
updateFile as updateFileApi,
|
updateFile as updateFileApi,
|
||||||
deleteFile as deleteFileApi,
|
deleteFile as deleteFileApi,
|
||||||
deleteFiles as deleteFilesApi,
|
deleteFiles as deleteFilesApi,
|
||||||
type FolderInfo,
|
|
||||||
} from '../api/fileApi';
|
} from '../api/fileApi';
|
||||||
import type { TableGroupNode } from '../api/connectionApi';
|
import type { TableGroupNode } from '../api/connectionApi';
|
||||||
|
|
||||||
|
|
@ -499,7 +498,6 @@ export function useFileOperations() {
|
||||||
file: globalThis.File,
|
file: globalThis.File,
|
||||||
workflowId?: string,
|
workflowId?: string,
|
||||||
featureInstanceId?: string,
|
featureInstanceId?: string,
|
||||||
folderId?: string | null,
|
|
||||||
) => {
|
) => {
|
||||||
setUploadError(null);
|
setUploadError(null);
|
||||||
setUploadingFile(true);
|
setUploadingFile(true);
|
||||||
|
|
@ -524,9 +522,6 @@ export function useFileOperations() {
|
||||||
if (featureInstanceId) {
|
if (featureInstanceId) {
|
||||||
formData.append('featureInstanceId', featureInstanceId);
|
formData.append('featureInstanceId', featureInstanceId);
|
||||||
}
|
}
|
||||||
if (folderId) {
|
|
||||||
formData.append('folderId', folderId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// FormData is now correctly configured for backend
|
// FormData is now correctly configured for backend
|
||||||
|
|
||||||
|
|
@ -702,87 +697,4 @@ export function useFileOperations() {
|
||||||
handleInlineUpdate,
|
handleInlineUpdate,
|
||||||
isLoading
|
isLoading
|
||||||
};
|
};
|
||||||
}
|
|
||||||
|
|
||||||
// ── Folder management hook ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export function useFolders() {
|
|
||||||
const [folders, setFolders] = useState<FolderInfo[]>([]);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const { showError } = useToast();
|
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const response = await api.get('/api/files/folders');
|
|
||||||
const data = Array.isArray(response.data) ? response.data : [];
|
|
||||||
setFolders(data);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to load folders:', err);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => { refresh(); }, [refresh]);
|
|
||||||
|
|
||||||
const handleCreateFolder = useCallback(async (name: string, parentId: string | null) => {
|
|
||||||
try {
|
|
||||||
await api.post('/api/files/folders', { name, parentId: parentId || null });
|
|
||||||
await refresh();
|
|
||||||
} catch (err: any) {
|
|
||||||
showError(err?.response?.data?.detail || err?.message || 'Folder creation failed');
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}, [refresh, showError]);
|
|
||||||
|
|
||||||
const handleRenameFolder = useCallback(async (folderId: string, newName: string) => {
|
|
||||||
try {
|
|
||||||
await api.put(`/api/files/folders/${folderId}`, { name: newName });
|
|
||||||
await refresh();
|
|
||||||
} catch (err: any) {
|
|
||||||
showError(err?.response?.data?.detail || err?.message || 'Rename failed');
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}, [refresh, showError]);
|
|
||||||
|
|
||||||
const handleDeleteFolder = useCallback(async (folderId: string) => {
|
|
||||||
try {
|
|
||||||
await api.delete(`/api/files/folders/${folderId}`, { params: { recursive: true } });
|
|
||||||
await refresh();
|
|
||||||
} catch (err: any) {
|
|
||||||
showError(err?.response?.data?.detail || err?.message || 'Delete failed');
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}, [refresh, showError]);
|
|
||||||
|
|
||||||
const handleMoveFolder = useCallback(async (folderId: string, targetParentId: string | null) => {
|
|
||||||
try {
|
|
||||||
await api.post(`/api/files/folders/${folderId}/move`, { targetParentId });
|
|
||||||
await refresh();
|
|
||||||
} catch (err: any) {
|
|
||||||
showError(err?.response?.data?.detail || err?.message || 'Move failed');
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}, [refresh, showError]);
|
|
||||||
|
|
||||||
const handleMoveFile = useCallback(async (fileId: string, targetFolderId: string | null) => {
|
|
||||||
try {
|
|
||||||
await api.post(`/api/files/${fileId}/move`, { targetFolderId });
|
|
||||||
} catch (err: any) {
|
|
||||||
showError(err?.response?.data?.detail || err?.message || 'Move failed');
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}, [showError]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
folders,
|
|
||||||
loading,
|
|
||||||
refresh,
|
|
||||||
handleCreateFolder,
|
|
||||||
handleRenameFolder,
|
|
||||||
handleDeleteFolder,
|
|
||||||
handleMoveFolder,
|
|
||||||
handleMoveFile,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
@ -1,33 +1,29 @@
|
||||||
/**
|
/**
|
||||||
* FilesPage
|
* FilesPage
|
||||||
*
|
*
|
||||||
* Split-view file management: FolderTree on the left, FormGeneratorTable on the right.
|
* Full-width file management using FormGeneratorTable with persistent grouping.
|
||||||
* The tree is the master – it dictates which folder's files the table shows (paginated).
|
* Organisation exclusively via groupTree/groupId — no physical folder navigation.
|
||||||
* Tree files are managed by FileContext (lazy-loaded per expanded folder).
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useMemo, useEffect, useRef, useCallback } from 'react';
|
import React, { useState, useMemo, useEffect, useRef, useCallback } from 'react';
|
||||||
import api from '../../api';
|
|
||||||
import { useUserFiles, useFileOperations } from '../../hooks/useFiles';
|
import { useUserFiles, useFileOperations } from '../../hooks/useFiles';
|
||||||
import { useFileContext } from '../../contexts/FileContext';
|
|
||||||
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
|
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
|
||||||
import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm';
|
import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm';
|
||||||
import FolderTree from '../../components/FolderTree/FolderTree';
|
import { FaSync, FaUpload, FaDownload, FaLock, FaLockOpen, FaFileArchive, FaTrash } from 'react-icons/fa';
|
||||||
import { useResizablePanels } from '../../hooks/useResizablePanels';
|
|
||||||
import { FaSync, FaUpload, FaDownload, FaFolderPlus } from 'react-icons/fa';
|
|
||||||
import { useToast } from '../../contexts/ToastContext';
|
import { useToast } from '../../contexts/ToastContext';
|
||||||
import { usePrompt } from '../../hooks/usePrompt';
|
import { useApiRequest } from '../../hooks/useApi';
|
||||||
|
import { patchGroupScope, downloadGroupZip, deleteGroup } from '../../api/fileApi';
|
||||||
import styles from '../admin/Admin.module.css';
|
import styles from '../admin/Admin.module.css';
|
||||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||||
import { getUserDataCache } from '../../utils/userCache';
|
import { getUserDataCache } from '../../utils/userCache';
|
||||||
import { resolveColumnTypes } from '../../utils/columnTypeResolver';
|
import { resolveColumnTypes } from '../../utils/columnTypeResolver';
|
||||||
|
import type { GroupBulkAction } from '../../components/FormGenerator/GroupingManager/GroupRow';
|
||||||
|
|
||||||
interface UserFile {
|
interface UserFile {
|
||||||
id: string;
|
id: string;
|
||||||
fileName: string;
|
fileName: string;
|
||||||
mimeType?: string;
|
mimeType?: string;
|
||||||
fileSize?: number;
|
fileSize?: number;
|
||||||
folderId?: string | null;
|
|
||||||
featureInstanceId?: string;
|
featureInstanceId?: string;
|
||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
}
|
}
|
||||||
|
|
@ -36,19 +32,9 @@ export const FilesPage: React.FC = () => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const { showSuccess, showError } = useToast();
|
const { showSuccess, showError } = useToast();
|
||||||
const { prompt: promptInput, PromptDialog } = usePrompt();
|
const { request } = useApiRequest();
|
||||||
const [selectedFolderId, setSelectedFolderId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const {
|
// ── Table data ────────────────────────────────────────────────────────
|
||||||
leftWidth, isDragging, handleMouseDown, containerRef,
|
|
||||||
} = useResizablePanels({
|
|
||||||
storageKey: 'filesPage-panelWidth',
|
|
||||||
defaultLeftWidth: 22,
|
|
||||||
minLeftWidth: 15,
|
|
||||||
maxLeftWidth: 40,
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Table data (paginated, filtered by selectedFolderId) ──────────────
|
|
||||||
const {
|
const {
|
||||||
data: tableFiles,
|
data: tableFiles,
|
||||||
attributes,
|
attributes,
|
||||||
|
|
@ -75,127 +61,22 @@ export const FilesPage: React.FC = () => {
|
||||||
previewingFiles,
|
previewingFiles,
|
||||||
} = useFileOperations();
|
} = useFileOperations();
|
||||||
|
|
||||||
// ── Tree data (from FileContext – lazy-loaded per expanded folder) ─────
|
|
||||||
const {
|
|
||||||
folders,
|
|
||||||
refreshFolders,
|
|
||||||
treeFileNodes,
|
|
||||||
refreshTreeFiles,
|
|
||||||
updateTreeFileNode,
|
|
||||||
expandedFolderIds,
|
|
||||||
toggleFolderExpanded,
|
|
||||||
handleCreateFolder,
|
|
||||||
handleRenameFolder,
|
|
||||||
handleDeleteFolder,
|
|
||||||
handleMoveFolder,
|
|
||||||
handleMoveFolders,
|
|
||||||
handleMoveFile: contextMoveFile,
|
|
||||||
handleMoveFiles: contextMoveFiles,
|
|
||||||
handleDownloadFolder,
|
|
||||||
} = useFileContext();
|
|
||||||
|
|
||||||
const [editingFile, setEditingFile] = useState<UserFile | null>(null);
|
const [editingFile, setEditingFile] = useState<UserFile | null>(null);
|
||||||
const [selectedFiles, setSelectedFiles] = useState<UserFile[]>([]);
|
const [selectedFiles, setSelectedFiles] = useState<UserFile[]>([]);
|
||||||
const [treeSelectedIds, setTreeSelectedIds] = useState<Set<string>>(new Set());
|
|
||||||
const [highlightedFileId, setHighlightedFileId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// ── Table refetch: filter by real folderId ───────────────────────────
|
// ── Table refetch wrapper ──────────────────────────────────────────────
|
||||||
const _tableRefetch = useCallback(async (params?: any) => {
|
const _tableRefetch = useCallback(async (params?: any) => {
|
||||||
const nextParams = { ...(params || {}) };
|
await tableRefetch(params);
|
||||||
const nextFilters = { ...(nextParams.filters || {}) };
|
}, [tableRefetch]);
|
||||||
|
|
||||||
if (!selectedFolderId) {
|
|
||||||
nextFilters.folderId = null;
|
|
||||||
} else {
|
|
||||||
nextFilters.folderId = selectedFolderId;
|
|
||||||
}
|
|
||||||
|
|
||||||
nextParams.filters = nextFilters;
|
|
||||||
await tableRefetch(nextParams);
|
|
||||||
}, [tableRefetch, selectedFolderId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
_tableRefetch({ page: 1, pageSize: 25 });
|
|
||||||
}, [selectedFolderId, _tableRefetch]);
|
|
||||||
|
|
||||||
const _refreshAll = useCallback(async () => {
|
const _refreshAll = useCallback(async () => {
|
||||||
await Promise.all([_tableRefetch(), refreshTreeFiles(), refreshFolders()]);
|
await _tableRefetch({ page: 1, pageSize: 25 });
|
||||||
}, [_tableRefetch, refreshTreeFiles, refreshFolders]);
|
}, [_tableRefetch]);
|
||||||
|
|
||||||
const _handleScopeChange = useCallback(async (fileId: string, newScope: string) => {
|
// Initial fetch
|
||||||
updateTreeFileNode(fileId, { scope: newScope });
|
useEffect(() => {
|
||||||
try {
|
_tableRefetch({ page: 1, pageSize: 25 });
|
||||||
await api.patch(`/api/files/${fileId}/scope`, { scope: newScope });
|
}, [_tableRefetch]);
|
||||||
_tableRefetch();
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to update scope:', err);
|
|
||||||
await Promise.all([refreshTreeFiles(), _tableRefetch()]);
|
|
||||||
}
|
|
||||||
}, [updateTreeFileNode, refreshTreeFiles, _tableRefetch]);
|
|
||||||
|
|
||||||
const _handleNeutralizeToggle = useCallback(async (fileId: string, newValue: boolean) => {
|
|
||||||
updateTreeFileNode(fileId, { neutralize: newValue });
|
|
||||||
try {
|
|
||||||
await api.patch(`/api/files/${fileId}/neutralize`, { neutralize: newValue });
|
|
||||||
_tableRefetch();
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to toggle neutralize:', err);
|
|
||||||
await Promise.all([refreshTreeFiles(), _tableRefetch()]);
|
|
||||||
}
|
|
||||||
}, [updateTreeFileNode, refreshTreeFiles, _tableRefetch]);
|
|
||||||
|
|
||||||
const _handleFolderScopeChange = useCallback(async (folderId: string, newScope: string) => {
|
|
||||||
try {
|
|
||||||
await api.patch(`/api/files/folders/${folderId}/scope`, { scope: newScope });
|
|
||||||
await Promise.all([refreshFolders(), refreshTreeFiles(), _tableRefetch()]);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to update folder scope:', err);
|
|
||||||
}
|
|
||||||
}, [refreshFolders, refreshTreeFiles, _tableRefetch]);
|
|
||||||
|
|
||||||
const _handleFolderNeutralizeToggle = useCallback(async (folderId: string, newValue: boolean) => {
|
|
||||||
try {
|
|
||||||
await api.patch(`/api/files/folders/${folderId}/neutralize`, { neutralize: newValue });
|
|
||||||
await Promise.all([refreshFolders(), refreshTreeFiles(), _tableRefetch()]);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to toggle folder neutralize:', err);
|
|
||||||
}
|
|
||||||
}, [refreshFolders, refreshTreeFiles, _tableRefetch]);
|
|
||||||
|
|
||||||
// ── Folder nodes for tree (real folders only) ────────────────────────
|
|
||||||
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 selectedFolderName = useMemo(() => {
|
|
||||||
if (!selectedFolderId) return null;
|
|
||||||
return folders.find(f => f.id === selectedFolderId)?.name ?? null;
|
|
||||||
}, [folders, selectedFolderId]);
|
|
||||||
|
|
||||||
const emptyTableMessage = useMemo(() => {
|
|
||||||
if (!selectedFolderId) {
|
|
||||||
return t('Keine Dateien gefunden');
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem', alignItems: 'center' }}>
|
|
||||||
<div style={{ fontWeight: 600 }}>
|
|
||||||
{selectedFolderName
|
|
||||||
? t('Der Ordner „{name}" ist leer.', { name: selectedFolderName })
|
|
||||||
: t('Dieser Ordner ist leer.')}
|
|
||||||
</div>
|
|
||||||
<div style={{ color: 'var(--text-muted, #64748b)' }}>
|
|
||||||
{t('Lade eine neue Datei hoch oder verschiebe bestehende Dateien hierher.')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}, [selectedFolderId, selectedFolderName, t]);
|
|
||||||
|
|
||||||
// ── Columns ───────────────────────────────────────────────────────────
|
// ── Columns ───────────────────────────────────────────────────────────
|
||||||
const columns = useMemo(() => {
|
const columns = useMemo(() => {
|
||||||
|
|
@ -226,9 +107,6 @@ export const FilesPage: React.FC = () => {
|
||||||
maxWidth: 250,
|
maxWidth: 250,
|
||||||
displayField: 'sysCreatedByLabel',
|
displayField: 'sysCreatedByLabel',
|
||||||
} as any);
|
} as any);
|
||||||
// sysModifiedAt is marked frontend_visible=false in PowerOnModel so it
|
|
||||||
// never reaches us via the /api/attributes endpoint - declare type
|
|
||||||
// explicitly so the FormGenerator renders it as a timestamp.
|
|
||||||
cols.push({
|
cols.push({
|
||||||
key: 'sysModifiedAt',
|
key: 'sysModifiedAt',
|
||||||
label: t('Geaendert am'),
|
label: t('Geaendert am'),
|
||||||
|
|
@ -250,50 +128,6 @@ export const FilesPage: React.FC = () => {
|
||||||
const currentUserId = useMemo(() => getUserDataCache()?.id || '', []);
|
const currentUserId = useMemo(() => getUserDataCache()?.id || '', []);
|
||||||
const _isOwned = useCallback((row: UserFile) => row.sysCreatedBy === currentUserId, [currentUserId]);
|
const _isOwned = useCallback((row: UserFile) => row.sysCreatedBy === currentUserId, [currentUserId]);
|
||||||
|
|
||||||
// ── Tree event handlers ───────────────────────────────────────────────
|
|
||||||
const _handleTreeFileSelect = useCallback((fileId: string) => {
|
|
||||||
const file = treeFileNodes.find(f => f.id === fileId);
|
|
||||||
if (file) {
|
|
||||||
setSelectedFolderId(file.folderId ?? null);
|
|
||||||
setHighlightedFileId(fileId);
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
const row = document.querySelector('tr[data-highlighted="true"]');
|
|
||||||
if (row) row.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
||||||
});
|
|
||||||
setTimeout(() => setHighlightedFileId(null), 2500);
|
|
||||||
}
|
|
||||||
}, [treeFileNodes]);
|
|
||||||
|
|
||||||
const _handleMoveFile = useCallback(async (fileId: string, targetFolderId: string | null) => {
|
|
||||||
await contextMoveFile(fileId, targetFolderId);
|
|
||||||
await _tableRefetch();
|
|
||||||
}, [contextMoveFile, _tableRefetch]);
|
|
||||||
|
|
||||||
const _handleMoveFiles = useCallback(async (fileIds: string[], targetFolderId: string | null) => {
|
|
||||||
await contextMoveFiles(fileIds, targetFolderId);
|
|
||||||
await _tableRefetch();
|
|
||||||
}, [contextMoveFiles, _tableRefetch]);
|
|
||||||
|
|
||||||
const _handleRenameFile = useCallback(async (fileId: string, newName: string) => {
|
|
||||||
await handleFileUpdate(fileId, { fileName: newName });
|
|
||||||
await Promise.all([_tableRefetch(), refreshTreeFiles()]);
|
|
||||||
}, [handleFileUpdate, _tableRefetch, refreshTreeFiles]);
|
|
||||||
|
|
||||||
const _handleDeleteTreeFile = useCallback(async (fileId: string) => {
|
|
||||||
await handleFileDelete(fileId);
|
|
||||||
await Promise.all([_tableRefetch(), refreshTreeFiles(), refreshFolders()]);
|
|
||||||
}, [handleFileDelete, _tableRefetch, refreshTreeFiles, refreshFolders]);
|
|
||||||
|
|
||||||
const _handleDeleteTreeFiles = useCallback(async (fileIds: string[]) => {
|
|
||||||
await handleFileDeleteMultiple(fileIds);
|
|
||||||
await Promise.all([_tableRefetch(), refreshTreeFiles(), refreshFolders()]);
|
|
||||||
}, [handleFileDeleteMultiple, _tableRefetch, refreshTreeFiles, refreshFolders]);
|
|
||||||
|
|
||||||
const _handleDeleteTreeFolders = useCallback(async (folderIds: string[]) => {
|
|
||||||
await api.post('/api/files/batch-delete', { folderIds, recursiveFolders: true });
|
|
||||||
await Promise.all([refreshFolders(), refreshTreeFiles(), _tableRefetch()]);
|
|
||||||
}, [refreshFolders, refreshTreeFiles, _tableRefetch]);
|
|
||||||
|
|
||||||
// ── Table event handlers ──────────────────────────────────────────────
|
// ── Table event handlers ──────────────────────────────────────────────
|
||||||
const handleEditClick = async (file: UserFile) => {
|
const handleEditClick = async (file: UserFile) => {
|
||||||
const fullFile = await fetchFileById(file.id);
|
const fullFile = await fetchFileById(file.id);
|
||||||
|
|
@ -303,7 +137,7 @@ export const FilesPage: React.FC = () => {
|
||||||
const handleEditSubmit = async (data: Partial<UserFile>) => {
|
const handleEditSubmit = async (data: Partial<UserFile>) => {
|
||||||
if (!editingFile) return;
|
if (!editingFile) return;
|
||||||
const changes: Record<string, any> = {};
|
const changes: Record<string, any> = {};
|
||||||
const editableFields = ['fileName', 'scope', 'tags', 'description', 'folderId', 'neutralize'] as const;
|
const editableFields = ['fileName', 'scope', 'tags', 'description', 'neutralize'] as const;
|
||||||
for (const field of editableFields) {
|
for (const field of editableFields) {
|
||||||
if (data[field] !== undefined && data[field] !== editingFile[field]) {
|
if (data[field] !== undefined && data[field] !== editingFile[field]) {
|
||||||
changes[field] = data[field];
|
changes[field] = data[field];
|
||||||
|
|
@ -315,19 +149,19 @@ export const FilesPage: React.FC = () => {
|
||||||
const result = await handleFileUpdate(editingFile.id, changes);
|
const result = await handleFileUpdate(editingFile.id, changes);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
setEditingFile(null);
|
setEditingFile(null);
|
||||||
await Promise.all([_tableRefetch(), refreshTreeFiles()]);
|
await _tableRefetch();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (file: UserFile) => {
|
const handleDelete = async (file: UserFile) => {
|
||||||
const success = await handleFileDelete(file.id);
|
const success = await handleFileDelete(file.id);
|
||||||
if (success) await Promise.all([_tableRefetch(), refreshTreeFiles(), refreshFolders()]);
|
if (success) await _tableRefetch();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteMultiple = async (filesToDelete: UserFile[]) => {
|
const handleDeleteMultiple = async (filesToDelete: UserFile[]) => {
|
||||||
const ids = filesToDelete.map(f => f.id);
|
const ids = filesToDelete.map(f => f.id);
|
||||||
const success = await handleFileDeleteMultiple(ids);
|
const success = await handleFileDeleteMultiple(ids);
|
||||||
if (success) await Promise.all([_tableRefetch(), refreshTreeFiles(), refreshFolders()]);
|
if (success) await _tableRefetch();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDownload = async (file: UserFile) => {
|
const handleDownload = async (file: UserFile) => {
|
||||||
|
|
@ -342,11 +176,11 @@ export const FilesPage: React.FC = () => {
|
||||||
let successCount = 0;
|
let successCount = 0;
|
||||||
let errorCount = 0;
|
let errorCount = 0;
|
||||||
for (const file of Array.from(picked)) {
|
for (const file of Array.from(picked)) {
|
||||||
const result = await handleFileUpload(file, undefined, undefined, selectedFolderId);
|
const result = await handleFileUpload(file);
|
||||||
if (result?.success) successCount++; else errorCount++;
|
if (result?.success) successCount++; else errorCount++;
|
||||||
}
|
}
|
||||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||||
await Promise.all([_tableRefetch(), refreshTreeFiles(), refreshFolders()]);
|
await _tableRefetch();
|
||||||
if (successCount > 0) {
|
if (successCount > 0) {
|
||||||
showSuccess(
|
showSuccess(
|
||||||
t('Upload erfolgreich'),
|
t('Upload erfolgreich'),
|
||||||
|
|
@ -360,12 +194,54 @@ export const FilesPage: React.FC = () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const _handleNewFolder = useCallback(async () => {
|
const _groupBulkActionsProvider = useCallback((groupId: string, itemIds: string[]): GroupBulkAction[] => {
|
||||||
const name = await promptInput(t('Neuer Ordnername:'), { title: t('Neuer Ordner'), placeholder: t('Ordnername') });
|
return [
|
||||||
if (name?.trim()) {
|
{
|
||||||
await handleCreateFolder(name.trim(), selectedFolderId);
|
icon: <FaLock />,
|
||||||
}
|
title: t('Scope: personal'),
|
||||||
}, [handleCreateFolder, selectedFolderId, promptInput, t]);
|
onClick: async () => {
|
||||||
|
try {
|
||||||
|
await patchGroupScope(request, groupId, 'personal');
|
||||||
|
showSuccess(t('Scope aktualisiert'), t('{n} Dateien auf personal gesetzt', { n: String(itemIds.length) }));
|
||||||
|
await _tableRefetch();
|
||||||
|
} catch (e) { showError(t('Fehler'), String(e)); }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <FaLockOpen />,
|
||||||
|
title: t('Scope: mandate'),
|
||||||
|
onClick: async () => {
|
||||||
|
try {
|
||||||
|
await patchGroupScope(request, groupId, 'mandate');
|
||||||
|
showSuccess(t('Scope aktualisiert'), t('{n} Dateien auf mandate gesetzt', { n: String(itemIds.length) }));
|
||||||
|
await _tableRefetch();
|
||||||
|
} catch (e) { showError(t('Fehler'), String(e)); }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <FaFileArchive />,
|
||||||
|
title: t('ZIP herunterladen'),
|
||||||
|
onClick: async () => {
|
||||||
|
try { await downloadGroupZip(groupId); }
|
||||||
|
catch (e) { showError(t('Fehler'), String(e)); }
|
||||||
|
},
|
||||||
|
disabled: itemIds.length === 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <FaTrash />,
|
||||||
|
title: t('Gruppe + Dateien löschen'),
|
||||||
|
variant: 'danger' as const,
|
||||||
|
onClick: async () => {
|
||||||
|
try {
|
||||||
|
await deleteGroup(request, groupId, true);
|
||||||
|
showSuccess(t('Gelöscht'), t('Gruppe und {n} Dateien gelöscht', { n: String(itemIds.length) }));
|
||||||
|
await _tableRefetch();
|
||||||
|
} catch (e) { showError(t('Fehler'), String(e)); }
|
||||||
|
},
|
||||||
|
disabled: itemIds.length === 0,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}, [request, showSuccess, showError, _tableRefetch, t]);
|
||||||
|
|
||||||
const _onRowDragStart = useCallback((e: React.DragEvent<HTMLTableRowElement>, row: UserFile) => {
|
const _onRowDragStart = useCallback((e: React.DragEvent<HTMLTableRowElement>, row: UserFile) => {
|
||||||
const isInSelection = selectedFiles.some(f => f.id === row.id);
|
const isInSelection = selectedFiles.some(f => f.id === row.id);
|
||||||
|
|
@ -374,11 +250,11 @@ export const FilesPage: React.FC = () => {
|
||||||
} else {
|
} else {
|
||||||
e.dataTransfer.setData('application/file-id', row.id);
|
e.dataTransfer.setData('application/file-id', row.id);
|
||||||
}
|
}
|
||||||
e.dataTransfer.effectAllowed = 'move';
|
e.dataTransfer.effectAllowed = 'copyMove';
|
||||||
}, [selectedFiles]);
|
}, [selectedFiles]);
|
||||||
|
|
||||||
const formAttributes = useMemo(() => {
|
const formAttributes = useMemo(() => {
|
||||||
const excludedFields = ['id', 'mandateId', 'fileHash', 'sysCreatedBy', 'sysCreatedAt', 'sysModifiedAt', 'creationDate', 'source'];
|
const excludedFields = ['id', 'mandateId', 'fileHash', 'folderId', 'sysCreatedBy', 'sysCreatedAt', 'sysModifiedAt', 'creationDate', 'source'];
|
||||||
return (attributes || []).filter(attr => !excludedFields.includes(attr.name));
|
return (attributes || []).filter(attr => !excludedFields.includes(attr.name));
|
||||||
}, [attributes]);
|
}, [attributes]);
|
||||||
|
|
||||||
|
|
@ -412,157 +288,88 @@ export const FilesPage: React.FC = () => {
|
||||||
<p className={styles.pageSubtitle}>{t('Dateiverwaltung')}</p>
|
<p className={styles.pageSubtitle}>{t('Dateiverwaltung')}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.headerActions}>
|
<div className={styles.headerActions}>
|
||||||
<button className={styles.secondaryButton} onClick={() => _refreshAll()} disabled={tableLoading}>
|
<button className={styles.secondaryButton} onClick={_refreshAll} disabled={tableLoading}>
|
||||||
<FaSync className={tableLoading ? 'spinning' : ''} /> {t('Aktualisieren')}
|
<FaSync className={tableLoading ? 'spinning' : ''} /> {t('Aktualisieren')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div style={{ flex: 1, overflow: 'auto', display: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
||||||
ref={containerRef as React.RefObject<HTMLDivElement>}
|
|
||||||
style={{ display: 'flex', flex: 1, overflow: 'hidden', minHeight: 0, position: 'relative' }}
|
|
||||||
>
|
|
||||||
{/* Left panel: FolderTree */}
|
|
||||||
<div style={{
|
<div style={{
|
||||||
width: `${leftWidth}%`,
|
display: 'flex', gap: 8, padding: '8px 12px',
|
||||||
minWidth: 0,
|
borderBottom: '1px solid var(--color-border, #e0e0e0)',
|
||||||
overflow: 'auto',
|
flexShrink: 0, alignItems: 'center', flexWrap: 'wrap',
|
||||||
borderRight: '1px solid var(--color-border, #e0e0e0)',
|
|
||||||
padding: '8px 4px',
|
|
||||||
}}>
|
}}>
|
||||||
<FolderTree
|
{canCreate && (
|
||||||
folders={folderNodes}
|
<button className={styles.primaryButton} onClick={handleUploadClick} disabled={uploadingFile}>
|
||||||
files={treeFileNodes}
|
<FaUpload /> {uploadingFile ? t('Wird hochgeladen…') : t('Datei hochladen')}
|
||||||
showFiles={true}
|
</button>
|
||||||
selectedFolderId={selectedFolderId}
|
)}
|
||||||
onSelect={setSelectedFolderId}
|
|
||||||
onFileSelect={_handleTreeFileSelect}
|
|
||||||
selectedItemIds={treeSelectedIds}
|
|
||||||
onSelectionChange={setTreeSelectedIds}
|
|
||||||
expandedIds={expandedFolderIds}
|
|
||||||
onToggleExpand={toggleFolderExpanded}
|
|
||||||
onRefresh={_refreshAll}
|
|
||||||
onCreateFolder={handleCreateFolder}
|
|
||||||
onRenameFolder={handleRenameFolder}
|
|
||||||
onDeleteFolder={async (folderId) => {
|
|
||||||
await handleDeleteFolder(folderId);
|
|
||||||
if (selectedFolderId === folderId) setSelectedFolderId(null);
|
|
||||||
await _tableRefetch();
|
|
||||||
}}
|
|
||||||
onMoveFolder={handleMoveFolder}
|
|
||||||
onMoveFolders={handleMoveFolders}
|
|
||||||
onMoveFile={_handleMoveFile}
|
|
||||||
onMoveFiles={_handleMoveFiles}
|
|
||||||
onRenameFile={_handleRenameFile}
|
|
||||||
onDeleteFile={_handleDeleteTreeFile}
|
|
||||||
onDeleteFiles={_handleDeleteTreeFiles}
|
|
||||||
onDeleteFolders={_handleDeleteTreeFolders}
|
|
||||||
onDownloadFolder={handleDownloadFolder}
|
|
||||||
onScopeChange={_handleScopeChange}
|
|
||||||
onNeutralizeToggle={_handleNeutralizeToggle}
|
|
||||||
onFolderScopeChange={_handleFolderScopeChange}
|
|
||||||
onFolderNeutralizeToggle={_handleFolderNeutralizeToggle}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Resizable divider */}
|
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||||
<div
|
<FormGeneratorTable
|
||||||
onMouseDown={handleMouseDown}
|
data={tableFiles || []}
|
||||||
style={{
|
columns={columns}
|
||||||
width: 6,
|
apiEndpoint="/api/files/list"
|
||||||
cursor: 'col-resize',
|
loading={tableLoading}
|
||||||
background: isDragging ? 'var(--primary-dark-bg, rgba(242, 88, 67, 0.2))' : 'transparent',
|
pagination={true}
|
||||||
transition: isDragging ? 'none' : 'background 0.15s',
|
pageSize={25}
|
||||||
flexShrink: 0,
|
searchable={true}
|
||||||
zIndex: 10,
|
filterable={true}
|
||||||
}}
|
sortable={true}
|
||||||
onMouseEnter={(e) => { (e.target as HTMLElement).style.background = 'var(--color-border-hover, #bbb)'; }}
|
selectable={true}
|
||||||
onMouseLeave={(e) => { if (!isDragging) (e.target as HTMLElement).style.background = 'transparent'; }}
|
onRowSelect={(rows) => setSelectedFiles(rows as UserFile[])}
|
||||||
/>
|
rowDraggable={true}
|
||||||
|
onRowDragStart={_onRowDragStart}
|
||||||
{/* Right panel: File table */}
|
actionButtons={[
|
||||||
<div style={{ flex: 1, minWidth: 0, overflow: 'auto', display: 'flex', flexDirection: 'column' }}>
|
{
|
||||||
<div style={{
|
type: 'view' as const,
|
||||||
display: 'flex', gap: 8, padding: '8px 12px',
|
onAction: () => {},
|
||||||
borderBottom: '1px solid var(--color-border, #e0e0e0)',
|
title: t('Vorschau'),
|
||||||
flexShrink: 0, alignItems: 'center', flexWrap: 'wrap',
|
idField: 'id',
|
||||||
}}>
|
nameField: 'fileName',
|
||||||
<button className={styles.secondaryButton} onClick={_handleNewFolder}>
|
typeField: 'mimeType',
|
||||||
<FaFolderPlus /> {t('Neuer Ordner')}
|
loadingStateName: 'previewingFiles',
|
||||||
</button>
|
},
|
||||||
{canCreate && (
|
...(canUpdate ? [{
|
||||||
<button className={styles.primaryButton} onClick={handleUploadClick} disabled={uploadingFile}>
|
type: 'edit' as const,
|
||||||
<FaUpload /> {uploadingFile ? t('Wird hochgeladen…') : t('Datei hochladen')}
|
onAction: handleEditClick,
|
||||||
</button>
|
title: t('Bearbeiten'),
|
||||||
)}
|
disabled: (row: UserFile) => !_isOwned(row) ? { disabled: true, message: t('Nur Eigentümer kann bearbeiten') } : false,
|
||||||
</div>
|
}] : []),
|
||||||
|
...(canDelete ? [{
|
||||||
<div style={{ flex: 1, overflow: 'auto' }}>
|
type: 'delete' as const,
|
||||||
<FormGeneratorTable
|
title: t('Löschen'),
|
||||||
data={tableFiles || []}
|
loading: (row: UserFile) => deletingFiles.has(row.id),
|
||||||
columns={columns}
|
disabled: (row: UserFile) => !_isOwned(row) ? { disabled: true, message: t('Nur Eigentümer kann löschen') } : false,
|
||||||
apiEndpoint="/api/files/list"
|
}] : []),
|
||||||
loading={tableLoading}
|
]}
|
||||||
pagination={true}
|
customActions={[
|
||||||
pageSize={25}
|
{
|
||||||
searchable={true}
|
id: 'download',
|
||||||
filterable={true}
|
icon: <FaDownload />,
|
||||||
sortable={true}
|
onClick: handleDownload,
|
||||||
selectable={true}
|
title: t('Herunterladen'),
|
||||||
onRowSelect={(rows) => setSelectedFiles(rows as UserFile[])}
|
loading: (row: UserFile) => downloadingFiles.has(row.id),
|
||||||
rowDraggable={true}
|
},
|
||||||
onRowDragStart={_onRowDragStart}
|
]}
|
||||||
getRowDataAttributes={(row: UserFile) =>
|
onDelete={handleDelete}
|
||||||
({ highlighted: row.id === highlightedFileId ? 'true' : 'false' })
|
onDeleteMultiple={handleDeleteMultiple}
|
||||||
}
|
hookData={{
|
||||||
actionButtons={[
|
refetch: _tableRefetch,
|
||||||
{
|
pagination,
|
||||||
type: 'view' as const,
|
permissions,
|
||||||
onAction: () => { /* ContentPreview fetches the file itself once the popup opens */ },
|
handleDelete: handleFileDelete,
|
||||||
title: t('Vorschau'),
|
handleInlineUpdate,
|
||||||
idField: 'id',
|
updateOptimistically: updateFileOptimistically,
|
||||||
nameField: 'fileName',
|
previewingFiles,
|
||||||
typeField: 'mimeType',
|
groupTree,
|
||||||
loadingStateName: 'previewingFiles',
|
}}
|
||||||
},
|
groupingConfig={{ contextKey: 'files/list', enabled: true }}
|
||||||
...(canUpdate ? [{
|
groupBulkActionsProvider={_groupBulkActionsProvider}
|
||||||
type: 'edit' as const,
|
emptyMessage={t('Keine Dateien gefunden')}
|
||||||
onAction: handleEditClick,
|
/>
|
||||||
title: t('Bearbeiten'),
|
|
||||||
disabled: (row: UserFile) => !_isOwned(row) ? { disabled: true, message: t('Nur Eigentümer kann bearbeiten') } : false,
|
|
||||||
}] : []),
|
|
||||||
...(canDelete ? [{
|
|
||||||
type: 'delete' as const,
|
|
||||||
title: t('Löschen'),
|
|
||||||
loading: (row: UserFile) => deletingFiles.has(row.id),
|
|
||||||
disabled: (row: UserFile) => !_isOwned(row) ? { disabled: true, message: t('Nur Eigentümer kann löschen') } : false,
|
|
||||||
}] : []),
|
|
||||||
]}
|
|
||||||
customActions={[
|
|
||||||
{
|
|
||||||
id: 'download',
|
|
||||||
icon: <FaDownload />,
|
|
||||||
onClick: handleDownload,
|
|
||||||
title: t('Herunterladen'),
|
|
||||||
loading: (row: UserFile) => downloadingFiles.has(row.id),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
onDelete={handleDelete}
|
|
||||||
onDeleteMultiple={handleDeleteMultiple}
|
|
||||||
hookData={{
|
|
||||||
refetch: _tableRefetch,
|
|
||||||
pagination,
|
|
||||||
permissions,
|
|
||||||
handleDelete: handleFileDelete,
|
|
||||||
handleInlineUpdate,
|
|
||||||
updateOptimistically: updateFileOptimistically,
|
|
||||||
previewingFiles,
|
|
||||||
groupTree,
|
|
||||||
}}
|
|
||||||
groupingConfig={{ contextKey: 'files', enabled: true }}
|
|
||||||
emptyMessage={emptyTableMessage}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -594,7 +401,6 @@ export const FilesPage: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<PromptDialog />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
|
import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
|
||||||
import { useToast } from '../../../contexts/ToastContext';
|
import { useToast } from '../../../contexts/ToastContext';
|
||||||
import { useFileContext } from '../../../contexts/FileContext';
|
import { useFileOperations, useUserFiles } from '../../../hooks/useFiles';
|
||||||
import { useConnections, type Connection } from '../../../hooks/useConnections';
|
import { useConnections, type Connection } from '../../../hooks/useConnections';
|
||||||
import {
|
import {
|
||||||
getNeutralizationConfig,
|
getNeutralizationConfig,
|
||||||
|
|
@ -178,7 +178,8 @@ const ConfigTab: React.FC = () => {
|
||||||
const PlaygroundTab: React.FC = () => {
|
const PlaygroundTab: React.FC = () => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
const { showSuccess, showError } = useToast();
|
const { showSuccess, showError } = useToast();
|
||||||
const { refreshTreeFiles: refetchFiles, handleFileDownload } = useFileContext();
|
const { handleFileDownload } = useFileOperations();
|
||||||
|
const { refetch: refetchFiles } = useUserFiles();
|
||||||
const { connections } = useConnections();
|
const { connections } = useConnections();
|
||||||
|
|
||||||
const msftConnections = connections.filter(
|
const msftConnections = connections.filter(
|
||||||
|
|
|
||||||
|
|
@ -418,6 +418,8 @@ export const TeamsbotSessionView: React.FC = () => {
|
||||||
e.dataTransfer.types.includes('Files') ||
|
e.dataTransfer.types.includes('Files') ||
|
||||||
e.dataTransfer.types.includes('application/file-id') ||
|
e.dataTransfer.types.includes('application/file-id') ||
|
||||||
e.dataTransfer.types.includes('application/file-ids') ||
|
e.dataTransfer.types.includes('application/file-ids') ||
|
||||||
|
e.dataTransfer.types.includes('application/group-file-ids') ||
|
||||||
|
e.dataTransfer.types.includes('application/group-id') ||
|
||||||
e.dataTransfer.types.includes('application/tree-items')
|
e.dataTransfer.types.includes('application/tree-items')
|
||||||
) {
|
) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -432,6 +434,8 @@ export const TeamsbotSessionView: React.FC = () => {
|
||||||
e.dataTransfer.types.includes('Files') ||
|
e.dataTransfer.types.includes('Files') ||
|
||||||
e.dataTransfer.types.includes('application/file-id') ||
|
e.dataTransfer.types.includes('application/file-id') ||
|
||||||
e.dataTransfer.types.includes('application/file-ids') ||
|
e.dataTransfer.types.includes('application/file-ids') ||
|
||||||
|
e.dataTransfer.types.includes('application/group-file-ids') ||
|
||||||
|
e.dataTransfer.types.includes('application/group-id') ||
|
||||||
e.dataTransfer.types.includes('application/tree-items')
|
e.dataTransfer.types.includes('application/tree-items')
|
||||||
) {
|
) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -453,6 +457,17 @@ export const TeamsbotSessionView: React.FC = () => {
|
||||||
directorDragCounterRef.current = 0;
|
directorDragCounterRef.current = 0;
|
||||||
setDirectorDragOver(false);
|
setDirectorDragOver(false);
|
||||||
|
|
||||||
|
const groupFileIdsJson = e.dataTransfer.getData('application/group-file-ids');
|
||||||
|
if (groupFileIdsJson) {
|
||||||
|
try {
|
||||||
|
const ids: unknown = JSON.parse(groupFileIdsJson);
|
||||||
|
if (Array.isArray(ids) && ids.length > 0) {
|
||||||
|
ids.forEach((id) => typeof id === 'string' && id && _addDirectorFile(id));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch { /* ignore malformed */ }
|
||||||
|
}
|
||||||
|
|
||||||
const fileIdsJson = e.dataTransfer.getData('application/file-ids');
|
const fileIdsJson = e.dataTransfer.getData('application/file-ids');
|
||||||
if (fileIdsJson) {
|
if (fileIdsJson) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -469,10 +484,16 @@ export const TeamsbotSessionView: React.FC = () => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const groupId = e.dataTransfer.getData('application/group-id');
|
||||||
|
if (groupId) {
|
||||||
|
_addDirectorFile(groupId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const treeItemsJson = e.dataTransfer.getData('application/tree-items');
|
const treeItemsJson = e.dataTransfer.getData('application/tree-items');
|
||||||
if (treeItemsJson) {
|
if (treeItemsJson) {
|
||||||
try {
|
try {
|
||||||
const items: Array<{ id: string; type: 'file' | 'folder'; name: string }> = JSON.parse(treeItemsJson);
|
const items: Array<{ id: string; type: 'file' | 'group'; name: string }> = JSON.parse(treeItemsJson);
|
||||||
items.filter((it) => it.type === 'file').forEach((it) => _addDirectorFile(it.id, it.name));
|
items.filter((it) => it.type === 'file').forEach((it) => _addDirectorFile(it.id, it.name));
|
||||||
} catch { /* ignore malformed */ }
|
} catch { /* ignore malformed */ }
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -104,9 +104,11 @@ export const ToolActivityLog: React.FC<ToolActivityLogProps> = ({ activities })
|
||||||
case 'connectNodes': return t('Knoten verbinden');
|
case 'connectNodes': return t('Knoten verbinden');
|
||||||
case 'copyFile': return t('Datei kopieren');
|
case 'copyFile': return t('Datei kopieren');
|
||||||
case 'createChart': return t('Diagramm erstellen');
|
case 'createChart': return t('Diagramm erstellen');
|
||||||
|
case 'createGroup': return t('Gruppe anlegen');
|
||||||
case 'createFolder': return t('Ordner anlegen');
|
case 'createFolder': return t('Ordner anlegen');
|
||||||
case 'createRecord': return t('Datensatz erstellen');
|
case 'createRecord': return t('Datensatz erstellen');
|
||||||
case 'deleteFile': return t('Datei löschen');
|
case 'deleteFile': return t('Datei löschen');
|
||||||
|
case 'deleteGroup': return t('Gruppe löschen');
|
||||||
case 'deleteFolder': return t('Ordner löschen');
|
case 'deleteFolder': return t('Ordner löschen');
|
||||||
case 'deleteRecord': return t('Datensatz löschen');
|
case 'deleteRecord': return t('Datensatz löschen');
|
||||||
case 'describeImage': return t('Bild beschreiben');
|
case 'describeImage': return t('Bild beschreiben');
|
||||||
|
|
@ -123,10 +125,18 @@ export const ToolActivityLog: React.FC<ToolActivityLogProps> = ({ activities })
|
||||||
case 'listAvailableNodeTypes': return t('Verfügbare Knotentypen auflisten');
|
case 'listAvailableNodeTypes': return t('Verfügbare Knotentypen auflisten');
|
||||||
case 'listConnections': return t('Verbindungen auflisten');
|
case 'listConnections': return t('Verbindungen auflisten');
|
||||||
case 'listFiles': return t('Dateien auflisten');
|
case 'listFiles': return t('Dateien auflisten');
|
||||||
|
case 'listGroups': return t('Gruppen auflisten');
|
||||||
|
case 'listItemsInGroup': return t('Gruppeninhalt auflisten');
|
||||||
|
case 'addItemsToGroup': return t('Zu Gruppe hinzufügen');
|
||||||
|
case 'moveItemsBetweenGroups': return t('Zwischen Gruppen verschieben');
|
||||||
|
case 'ensureInstanceGroup': return t('Instanzgruppe sicherstellen');
|
||||||
|
case 'ensureTempGroup': return t('Temp-Gruppe sicherstellen');
|
||||||
case 'listFolders': return t('Ordner auflisten');
|
case 'listFolders': return t('Ordner auflisten');
|
||||||
case 'listTables': return t('Tabellen auflisten');
|
case 'listTables': return t('Tabellen auflisten');
|
||||||
case 'listWorkflowHistory': return t('Workflow-Verlauf');
|
case 'listWorkflowHistory': return t('Workflow-Verlauf');
|
||||||
case 'moveFile': return t('Datei verschieben');
|
case 'moveFile': return t('Datei verschieben');
|
||||||
|
case 'moveGroup': return t('Gruppe verschieben');
|
||||||
|
case 'renameGroup': return t('Gruppe umbenennen');
|
||||||
case 'moveFolder': return t('Ordner verschieben');
|
case 'moveFolder': return t('Ordner verschieben');
|
||||||
case 'neutralizeData': return t('Daten neutralisieren');
|
case 'neutralizeData': return t('Daten neutralisieren');
|
||||||
case 'outlook_composeAndDraftReply': return t('Outlook-Antwort entwerfen');
|
case 'outlook_composeAndDraftReply': return t('Outlook-Antwort entwerfen');
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,14 @@
|
||||||
* voice toggle (generic audio capture hook), and data source selection.
|
* voice toggle (generic audio capture hook), and data source selection.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useCallback, useRef, useEffect } from 'react';
|
import React, {
|
||||||
|
useState,
|
||||||
|
useCallback,
|
||||||
|
useRef,
|
||||||
|
useEffect,
|
||||||
|
useImperativeHandle,
|
||||||
|
forwardRef,
|
||||||
|
} from 'react';
|
||||||
import { ProviderMultiSelect } from '../../../components/ProviderSelector';
|
import { ProviderMultiSelect } from '../../../components/ProviderSelector';
|
||||||
import type { ProviderSelection } from '../../../components/ProviderSelector';
|
import type { ProviderSelection } from '../../../components/ProviderSelector';
|
||||||
import { getPageIcon } from '../../../config/pageRegistry';
|
import { getPageIcon } from '../../../config/pageRegistry';
|
||||||
|
|
@ -14,16 +21,25 @@ import type { WorkspaceFile, DataSource, FeatureDataSource } from './useWorkspac
|
||||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
import { useVoiceCatalog } from '../../../contexts/VoiceCatalogContext';
|
import { useVoiceCatalog } from '../../../contexts/VoiceCatalogContext';
|
||||||
|
|
||||||
interface PendingFile {
|
export interface TreeItemDrop {
|
||||||
fileId: string;
|
id: string;
|
||||||
fileName: string;
|
type: 'file' | 'group';
|
||||||
itemType?: 'file' | 'folder';
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TreeItemDrop {
|
/** An attachment chip shown in the input bar.
|
||||||
id: string;
|
* Groups are kept as-is (show as single chip); file IDs are resolved at send-time. */
|
||||||
type: 'file' | 'folder';
|
export type AttachmentItem =
|
||||||
name: string;
|
| { type: 'file'; id: string; name: string }
|
||||||
|
| { type: 'group'; id: string; name: string; fileIds: string[] };
|
||||||
|
|
||||||
|
/** Parent resolves groups to concrete file IDs using persisted group tree. */
|
||||||
|
export type ResolveTreeItemsToFileIds = (items: TreeItemDrop[]) => Promise<string[]>;
|
||||||
|
|
||||||
|
export interface WorkspaceInputHandle {
|
||||||
|
attachFileIds: (ids: string[]) => void;
|
||||||
|
attachTreeItems: (items: TreeItemDrop[]) => Promise<void>;
|
||||||
|
ingestTreeDataTransfer: (dt: DataTransfer) => Promise<boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface WorkspaceInputProps {
|
interface WorkspaceInputProps {
|
||||||
|
|
@ -34,14 +50,12 @@ interface WorkspaceInputProps {
|
||||||
files: WorkspaceFile[];
|
files: WorkspaceFile[];
|
||||||
dataSources: DataSource[];
|
dataSources: DataSource[];
|
||||||
featureDataSources?: FeatureDataSource[];
|
featureDataSources?: FeatureDataSource[];
|
||||||
pendingFiles?: PendingFile[];
|
resolveTreeItemsToFileIds: ResolveTreeItemsToFileIds;
|
||||||
onRemovePendingFile?: (fileId: string) => void;
|
|
||||||
onFileUploadClick?: () => void;
|
onFileUploadClick?: () => void;
|
||||||
uploading?: boolean;
|
uploading?: boolean;
|
||||||
providerSelection?: ProviderSelection;
|
providerSelection?: ProviderSelection;
|
||||||
onProviderSelectionChange?: (selection: ProviderSelection) => void;
|
onProviderSelectionChange?: (selection: ProviderSelection) => void;
|
||||||
isMobile?: boolean;
|
isMobile?: boolean;
|
||||||
onTreeItemsDrop?: (items: TreeItemDrop[]) => void;
|
|
||||||
onFeatureSourceDrop?: (params: { featureInstanceId: string; featureCode: string; tableName?: string; objectKey: string; label: string; fieldName?: string }) => void;
|
onFeatureSourceDrop?: (params: { featureInstanceId: string; featureCode: string; tableName?: string; objectKey: string; label: string; fieldName?: string }) => void;
|
||||||
onDataSourceDrop?: (params: { connectionId: string; sourceType: string; path: string; label: string; displayPath?: string }) => void;
|
onDataSourceDrop?: (params: { connectionId: string; sourceType: string; path: string; label: string; displayPath?: string }) => void;
|
||||||
pendingAttachDsId?: string;
|
pendingAttachDsId?: string;
|
||||||
|
|
@ -51,36 +65,62 @@ interface WorkspaceInputProps {
|
||||||
onPasteAsFile?: (file: File) => void;
|
onPasteAsFile?: (file: File) => void;
|
||||||
draftAppend?: string;
|
draftAppend?: string;
|
||||||
onDraftAppendConsumed?: () => void;
|
onDraftAppendConsumed?: () => void;
|
||||||
/**
|
|
||||||
* Per-chat attachment persistence. When the parent loads a workflow, it
|
|
||||||
* passes the IDs the backend has stored for that chat plus a nonce that
|
|
||||||
* increments on every load. The chip-bar is then rehydrated, dropping
|
|
||||||
* any IDs that no longer resolve against the available sources.
|
|
||||||
*
|
|
||||||
* `workflowId` is needed so that "x" detachments can be persisted via a
|
|
||||||
* PATCH call without waiting for the next sendMessage round-trip.
|
|
||||||
*/
|
|
||||||
workflowId?: string | null;
|
workflowId?: string | null;
|
||||||
loadedAttachedDataSourceIds?: string[];
|
loadedAttachedDataSourceIds?: string[];
|
||||||
loadedAttachedFeatureDataSourceIds?: string[];
|
loadedAttachedFeatureDataSourceIds?: string[];
|
||||||
loadedNonce?: number;
|
loadedNonce?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({ instanceId,
|
function _itemsFromTreeDataTransfer(dt: DataTransfer): TreeItemDrop[] | null {
|
||||||
|
const groupId = dt.getData('application/group-id');
|
||||||
|
if (groupId) {
|
||||||
|
return [{ id: groupId, type: 'group', name: dt.getData('text/plain') || groupId }];
|
||||||
|
}
|
||||||
|
const portaG = dt.getData('application/porta-group');
|
||||||
|
if (portaG) {
|
||||||
|
return [{ id: portaG, type: 'group', name: dt.getData('text/plain') || portaG }];
|
||||||
|
}
|
||||||
|
const treeItemsJson = dt.getData('application/tree-items');
|
||||||
|
if (treeItemsJson) {
|
||||||
|
try {
|
||||||
|
const items = JSON.parse(treeItemsJson) as TreeItemDrop[];
|
||||||
|
return Array.isArray(items) && items.length ? items : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const fileIdsJson = dt.getData('application/file-ids');
|
||||||
|
if (fileIdsJson) {
|
||||||
|
try {
|
||||||
|
const ids: string[] = JSON.parse(fileIdsJson);
|
||||||
|
return ids.map(id => ({ id, type: 'file' as const, name: id }));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const singleFileId = dt.getData('application/file-id');
|
||||||
|
if (singleFileId) {
|
||||||
|
const lbl = dt.getData('text/plain');
|
||||||
|
const name = lbl && lbl !== singleFileId ? lbl : singleFileId;
|
||||||
|
return [{ id: singleFileId, type: 'file', name }];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WorkspaceInput = forwardRef<WorkspaceInputHandle, WorkspaceInputProps>(function WorkspaceInput({
|
||||||
|
instanceId,
|
||||||
onSend,
|
onSend,
|
||||||
isProcessing,
|
isProcessing,
|
||||||
onStop,
|
onStop,
|
||||||
files,
|
files,
|
||||||
dataSources,
|
dataSources,
|
||||||
featureDataSources = [],
|
featureDataSources = [],
|
||||||
pendingFiles = [],
|
resolveTreeItemsToFileIds,
|
||||||
onRemovePendingFile,
|
|
||||||
onFileUploadClick,
|
onFileUploadClick,
|
||||||
uploading = false,
|
uploading = false,
|
||||||
providerSelection,
|
providerSelection,
|
||||||
onProviderSelectionChange,
|
onProviderSelectionChange,
|
||||||
isMobile = false,
|
isMobile = false,
|
||||||
onTreeItemsDrop,
|
|
||||||
onFeatureSourceDrop,
|
onFeatureSourceDrop,
|
||||||
onDataSourceDrop,
|
onDataSourceDrop,
|
||||||
pendingAttachDsId,
|
pendingAttachDsId,
|
||||||
|
|
@ -94,23 +134,37 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({ instanceId,
|
||||||
loadedAttachedDataSourceIds,
|
loadedAttachedDataSourceIds,
|
||||||
loadedAttachedFeatureDataSourceIds,
|
loadedAttachedFeatureDataSourceIds,
|
||||||
loadedNonce,
|
loadedNonce,
|
||||||
}) => {
|
}, ref) {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
const { languages: voiceCatalogLanguages } = useVoiceCatalog();
|
const { languages: voiceCatalogLanguages } = useVoiceCatalog();
|
||||||
const [prompt, setPrompt] = useState('');
|
const [prompt, setPrompt] = useState('');
|
||||||
const [showAutocomplete, setShowAutocomplete] = useState(false);
|
const [showAutocomplete, setShowAutocomplete] = useState(false);
|
||||||
const [autocompleteFilter, setAutocompleteFilter] = useState('');
|
const [autocompleteFilter, setAutocompleteFilter] = useState('');
|
||||||
const [treeDropOver, setTreeDropOver] = useState(false);
|
const [treeDropOver, setTreeDropOver] = useState(false);
|
||||||
|
const textareaAreaDragDepth = useRef(0);
|
||||||
const [voiceActive, setVoiceActive] = useState(false);
|
const [voiceActive, setVoiceActive] = useState(false);
|
||||||
const [voiceLanguage, setVoiceLanguage] = useState('de-DE');
|
const [voiceLanguage, setVoiceLanguage] = useState('de-DE');
|
||||||
const [showLangPicker, setShowLangPicker] = useState(false);
|
const [showLangPicker, setShowLangPicker] = useState(false);
|
||||||
const _sttPrefsLoaded = useRef(false);
|
const _sttPrefsLoaded = useRef(false);
|
||||||
const [attachedFileIds, setAttachedFileIds] = useState<string[]>([]);
|
const [attachments, setAttachments] = useState<AttachmentItem[]>([]);
|
||||||
const [attachedDataSourceIds, setAttachedDataSourceIds] = useState<string[]>([]);
|
const [attachedDataSourceIds, setAttachedDataSourceIds] = useState<string[]>([]);
|
||||||
const [attachedFeatureDataSourceIds, setAttachedFeatureDataSourceIds] = useState<string[]>([]);
|
const [attachedFeatureDataSourceIds, setAttachedFeatureDataSourceIds] = useState<string[]>([]);
|
||||||
const [neutralizeActive, setNeutralizeActive] = useState(false);
|
const [neutralizeActive, setNeutralizeActive] = useState(false);
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
|
const _appendAttachment = useCallback((item: AttachmentItem) => {
|
||||||
|
setAttachments(prev => prev.some(a => a.id === item.id) ? prev : [...prev, item]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const _appendFileIds = useCallback((ids: string[]) => {
|
||||||
|
if (!ids.length) return;
|
||||||
|
setAttachments(prev => {
|
||||||
|
const existing = new Set(prev.map(a => a.id));
|
||||||
|
const added = ids.filter(id => !existing.has(id)).map(id => ({ type: 'file' as const, id, name: id }));
|
||||||
|
return added.length ? [...prev, ...added] : prev;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (draftAppend) {
|
if (draftAppend) {
|
||||||
setPrompt(prev => prev + (prev ? '\n' : '') + draftAppend);
|
setPrompt(prev => prev + (prev ? '\n' : '') + draftAppend);
|
||||||
|
|
@ -118,10 +172,6 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({ instanceId,
|
||||||
}
|
}
|
||||||
}, [draftAppend, onDraftAppendConsumed]);
|
}, [draftAppend, onDraftAppendConsumed]);
|
||||||
|
|
||||||
// Persist a changed attachment list to the backend so the next chat
|
|
||||||
// reload reflects the current state. Defined early so the
|
|
||||||
// pendingAttachDsId / pendingAttachFdsId effects below can also persist
|
|
||||||
// immediately after a 💬-click or drag-drop attach.
|
|
||||||
const _persistAttachments = useCallback((dsIds: string[], fdsIds: string[]) => {
|
const _persistAttachments = useCallback((dsIds: string[], fdsIds: string[]) => {
|
||||||
if (!instanceId || !workflowId) return;
|
if (!instanceId || !workflowId) return;
|
||||||
api.patch(`/api/workspace/${instanceId}/workflows/${workflowId}/attachments`, {
|
api.patch(`/api/workspace/${instanceId}/workflows/${workflowId}/attachments`, {
|
||||||
|
|
@ -130,10 +180,6 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({ instanceId,
|
||||||
}).catch(err => console.warn('Failed to persist chat attachments:', err));
|
}).catch(err => console.warn('Failed to persist chat attachments:', err));
|
||||||
}, [instanceId, workflowId]);
|
}, [instanceId, workflowId]);
|
||||||
|
|
||||||
// 💬-click or drag-drop attach: parent sets pendingAttachDsId after
|
|
||||||
// creating/finding the DataSource. Add to the chip bar AND persist
|
|
||||||
// immediately so a chat reload before the user sends a message still
|
|
||||||
// shows the chip.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!pendingAttachDsId) return;
|
if (!pendingAttachDsId) return;
|
||||||
setAttachedDataSourceIds(prev => {
|
setAttachedDataSourceIds(prev => {
|
||||||
|
|
@ -156,34 +202,20 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({ instanceId,
|
||||||
onPendingAttachFdsConsumed?.();
|
onPendingAttachFdsConsumed?.();
|
||||||
}, [pendingAttachFdsId, onPendingAttachFdsConsumed, _persistAttachments, attachedDataSourceIds]);
|
}, [pendingAttachFdsId, onPendingAttachFdsConsumed, _persistAttachments, attachedDataSourceIds]);
|
||||||
|
|
||||||
// Rehydrate the chip-bar whenever the parent re-loads a chat (loadedNonce
|
|
||||||
// bumps on every loadWorkflow call). We trust the loaded IDs initially;
|
|
||||||
// a separate one-shot reconciliation below drops IDs that don't resolve
|
|
||||||
// once the source lists have arrived from the backend.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (loadedNonce === undefined) return;
|
if (loadedNonce === undefined) return;
|
||||||
setAttachedFileIds([]);
|
setAttachments([]);
|
||||||
setAttachedDataSourceIds(Array.isArray(loadedAttachedDataSourceIds) ? [...loadedAttachedDataSourceIds] : []);
|
setAttachedDataSourceIds(Array.isArray(loadedAttachedDataSourceIds) ? [...loadedAttachedDataSourceIds] : []);
|
||||||
setAttachedFeatureDataSourceIds(Array.isArray(loadedAttachedFeatureDataSourceIds) ? [...loadedAttachedFeatureDataSourceIds] : []);
|
setAttachedFeatureDataSourceIds(Array.isArray(loadedAttachedFeatureDataSourceIds) ? [...loadedAttachedFeatureDataSourceIds] : []);
|
||||||
}, [loadedNonce]);
|
}, [loadedNonce, loadedAttachedDataSourceIds, loadedAttachedFeatureDataSourceIds]);
|
||||||
|
|
||||||
// Drop persisted attachment IDs that no longer resolve to an existing
|
|
||||||
// source (e.g. the DataSource was deleted while the chat was closed).
|
|
||||||
//
|
|
||||||
// CRITICAL: this MUST run only once per chat-load (per `loadedNonce`),
|
|
||||||
// and only after the source lists have actually arrived. A continuous
|
|
||||||
// filter would race with `_handleDataSourceDrop` /
|
|
||||||
// `_handleSendToChat_FeatureSource` in the parent: the drop sets the
|
|
||||||
// chip via `pendingAttachDsId` *before* `refreshDataSources()` has
|
|
||||||
// returned, so a continuous filter would briefly evict the freshly
|
|
||||||
// dropped ID and the chip would visibly flash in and out.
|
|
||||||
const _reconciledDsForNonce = useRef<number | undefined>(undefined);
|
const _reconciledDsForNonce = useRef<number | undefined>(undefined);
|
||||||
const _reconciledFdsForNonce = useRef<number | undefined>(undefined);
|
const _reconciledFdsForNonce = useRef<number | undefined>(undefined);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (loadedNonce === undefined) return;
|
if (loadedNonce === undefined) return;
|
||||||
if (_reconciledDsForNonce.current === loadedNonce) return;
|
if (_reconciledDsForNonce.current === loadedNonce) return;
|
||||||
if (dataSources.length === 0) return; // wait for the list to arrive
|
if (dataSources.length === 0) return;
|
||||||
_reconciledDsForNonce.current = loadedNonce;
|
_reconciledDsForNonce.current = loadedNonce;
|
||||||
const validIds = new Set(dataSources.map(d => d.id));
|
const validIds = new Set(dataSources.map(d => d.id));
|
||||||
setAttachedDataSourceIds(prev => {
|
setAttachedDataSourceIds(prev => {
|
||||||
|
|
@ -217,9 +249,61 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({ instanceId,
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const _resolveGroupItem = useCallback(async (item: TreeItemDrop): Promise<AttachmentItem> => {
|
||||||
|
const fileIds = await resolveTreeItemsToFileIds([item]);
|
||||||
|
return { type: 'group', id: item.id, name: item.name, fileIds };
|
||||||
|
}, [resolveTreeItemsToFileIds]);
|
||||||
|
|
||||||
|
/** Ingest a DataTransfer and append the right attachment chips. Returns true if handled. */
|
||||||
|
const _ingestDataTransfer = useCallback(async (dt: DataTransfer): Promise<boolean> => {
|
||||||
|
// Group with drag-time snapshot of its file IDs
|
||||||
|
const groupId = dt.getData('application/group-id') || dt.getData('application/porta-group');
|
||||||
|
if (groupId) {
|
||||||
|
const name = dt.getData('text/plain') || groupId;
|
||||||
|
const snapshotJson = dt.getData('application/group-file-ids');
|
||||||
|
let fileIds: string[] = [];
|
||||||
|
if (snapshotJson) {
|
||||||
|
try {
|
||||||
|
const parsed: unknown = JSON.parse(snapshotJson);
|
||||||
|
if (Array.isArray(parsed)) fileIds = parsed.filter((f): f is string => typeof f === 'string');
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
if (!fileIds.length) {
|
||||||
|
fileIds = await resolveTreeItemsToFileIds([{ id: groupId, type: 'group', name }]);
|
||||||
|
}
|
||||||
|
_appendAttachment({ type: 'group', id: groupId, name, fileIds });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Generic tree-items (may contain groups or files)
|
||||||
|
const items = _itemsFromTreeDataTransfer(dt);
|
||||||
|
if (!items?.length) return false;
|
||||||
|
await Promise.all(items.map(async item => {
|
||||||
|
if (item.type === 'group') {
|
||||||
|
_appendAttachment(await _resolveGroupItem(item));
|
||||||
|
} else {
|
||||||
|
_appendAttachment({ type: 'file', id: item.id, name: item.name });
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
return true;
|
||||||
|
}, [resolveTreeItemsToFileIds, _appendAttachment, _resolveGroupItem]);
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
attachFileIds: (ids: string[]) => _appendFileIds(ids),
|
||||||
|
attachTreeItems: async (items: TreeItemDrop[]) => {
|
||||||
|
await Promise.all(items.map(async item => {
|
||||||
|
if (item.type === 'group') {
|
||||||
|
_appendAttachment(await _resolveGroupItem(item));
|
||||||
|
} else {
|
||||||
|
_appendAttachment({ type: 'file', id: item.id, name: item.name });
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
ingestTreeDataTransfer: (dt: DataTransfer) => _ingestDataTransfer(dt),
|
||||||
|
}), [_appendFileIds, _appendAttachment, _resolveGroupItem, _ingestDataTransfer]);
|
||||||
|
|
||||||
const _extractFileRefs = useCallback(
|
const _extractFileRefs = useCallback(
|
||||||
(text: string): string[] => {
|
(text: string): string[] => {
|
||||||
const pattern = /@([\w.\-]+)/g;
|
const pattern = /@([\w.-]+)/g;
|
||||||
const matched: string[] = [];
|
const matched: string[] = [];
|
||||||
let match;
|
let match;
|
||||||
while ((match = pattern.exec(text)) !== null) {
|
while ((match = pattern.exec(text)) !== null) {
|
||||||
|
|
@ -236,17 +320,21 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({ instanceId,
|
||||||
[files],
|
[files],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const hasFileOrSourceAttachments =
|
||||||
|
attachments.length > 0 || attachedDataSourceIds.length > 0 || attachedFeatureDataSourceIds.length > 0;
|
||||||
|
const _canSend = Boolean(prompt.trim()) || attachments.length > 0;
|
||||||
|
|
||||||
const _handleSend = useCallback(() => {
|
const _handleSend = useCallback(() => {
|
||||||
const trimmed = prompt.trim();
|
if ((!prompt.trim() && attachments.length === 0) || isProcessing) return;
|
||||||
if (!trimmed || isProcessing) return;
|
const inlineFileIds = _extractFileRefs(prompt);
|
||||||
const inlineFileIds = _extractFileRefs(trimmed);
|
const attachedFileIds = attachments.flatMap(a => a.type === 'file' ? [a.id] : a.fileIds);
|
||||||
const allFileIds = [...new Set([...attachedFileIds, ...inlineFileIds])];
|
const allFileIds = [...new Set([...attachedFileIds, ...inlineFileIds])];
|
||||||
const options = neutralizeActive ? { requireNeutralization: true } : undefined;
|
const options = neutralizeActive ? { requireNeutralization: true } : undefined;
|
||||||
onSend(trimmed, allFileIds, attachedDataSourceIds, attachedFeatureDataSourceIds, options);
|
onSend(prompt.trim(), allFileIds, attachedDataSourceIds, attachedFeatureDataSourceIds, options);
|
||||||
setPrompt('');
|
setPrompt('');
|
||||||
setShowAutocomplete(false);
|
setShowAutocomplete(false);
|
||||||
setAttachedFileIds([]);
|
setAttachments([]);
|
||||||
}, [prompt, isProcessing, _extractFileRefs, attachedFileIds, attachedDataSourceIds, attachedFeatureDataSourceIds, neutralizeActive, onSend]);
|
}, [prompt, isProcessing, _extractFileRefs, attachments, attachedDataSourceIds, attachedFeatureDataSourceIds, neutralizeActive, onSend]);
|
||||||
|
|
||||||
const _handleKeyDown = useCallback(
|
const _handleKeyDown = useCallback(
|
||||||
(e: React.KeyboardEvent) => {
|
(e: React.KeyboardEvent) => {
|
||||||
|
|
@ -264,7 +352,7 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({ instanceId,
|
||||||
setPrompt(value);
|
setPrompt(value);
|
||||||
const cursorPos = e.target.selectionStart;
|
const cursorPos = e.target.selectionStart;
|
||||||
const textBeforeCursor = value.slice(0, cursorPos);
|
const textBeforeCursor = value.slice(0, cursorPos);
|
||||||
const atMatch = textBeforeCursor.match(/@([\w.\-]*)$/);
|
const atMatch = textBeforeCursor.match(/@([\w.-]*)$/);
|
||||||
if (atMatch) {
|
if (atMatch) {
|
||||||
setAutocompleteFilter(atMatch[1].toLowerCase());
|
setAutocompleteFilter(atMatch[1].toLowerCase());
|
||||||
setShowAutocomplete(true);
|
setShowAutocomplete(true);
|
||||||
|
|
@ -291,8 +379,8 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({ instanceId,
|
||||||
[prompt],
|
[prompt],
|
||||||
);
|
);
|
||||||
|
|
||||||
const _removeAttachedFile = useCallback((fileId: string) => {
|
const _removeAttachment = useCallback((id: string) => {
|
||||||
setAttachedFileIds(prev => prev.filter(id => id !== fileId));
|
setAttachments(prev => prev.filter(a => a.id !== id));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const _removeAttachedDataSource = useCallback((dsId: string) => {
|
const _removeAttachedDataSource = useCallback((dsId: string) => {
|
||||||
|
|
@ -370,7 +458,7 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({ instanceId,
|
||||||
? files.filter(f => f.fileName.toLowerCase().includes(autocompleteFilter))
|
? files.filter(f => f.fileName.toLowerCase().includes(autocompleteFilter))
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
const hasAttachments = attachedFileIds.length > 0 || attachedDataSourceIds.length > 0 || attachedFeatureDataSourceIds.length > 0;
|
const hasAttachments = hasFileOrSourceAttachments;
|
||||||
const _horizontalPadding = isMobile ? 12 : 24;
|
const _horizontalPadding = isMobile ? 12 : 24;
|
||||||
const _controlSize = isMobile ? 38 : 40;
|
const _controlSize = isMobile ? 38 : 40;
|
||||||
|
|
||||||
|
|
@ -385,9 +473,21 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({ instanceId,
|
||||||
}
|
}
|
||||||
}, [onPasteAsFile]);
|
}, [onPasteAsFile]);
|
||||||
|
|
||||||
|
const _isTreeMimeDrag = useCallback((e: React.DragEvent) => {
|
||||||
|
const types = e.dataTransfer.types;
|
||||||
|
return (
|
||||||
|
types.includes('application/tree-items') ||
|
||||||
|
types.includes('application/group-file-ids') ||
|
||||||
|
types.includes('application/group-id') ||
|
||||||
|
types.includes('application/porta-group') ||
|
||||||
|
types.includes('application/file-id') ||
|
||||||
|
types.includes('application/file-ids')
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const _handlePromptDragOver = useCallback((e: React.DragEvent) => {
|
const _handlePromptDragOver = useCallback((e: React.DragEvent) => {
|
||||||
if (
|
if (
|
||||||
e.dataTransfer.types.includes('application/tree-items') ||
|
_isTreeMimeDrag(e) ||
|
||||||
e.dataTransfer.types.includes('application/chat-id') ||
|
e.dataTransfer.types.includes('application/chat-id') ||
|
||||||
e.dataTransfer.types.includes('application/feature-source') ||
|
e.dataTransfer.types.includes('application/feature-source') ||
|
||||||
e.dataTransfer.types.includes('application/datasource')
|
e.dataTransfer.types.includes('application/datasource')
|
||||||
|
|
@ -396,11 +496,39 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({ instanceId,
|
||||||
e.dataTransfer.dropEffect = 'copy';
|
e.dataTransfer.dropEffect = 'copy';
|
||||||
setTreeDropOver(true);
|
setTreeDropOver(true);
|
||||||
}
|
}
|
||||||
|
}, [_isTreeMimeDrag]);
|
||||||
|
|
||||||
|
const _handlePromptDragLeave = useCallback((e: React.DragEvent) => {
|
||||||
|
if (!e.relatedTarget || !(e.currentTarget as Node).contains(e.relatedTarget as Node)) {
|
||||||
|
setTreeDropOver(false);
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const _handlePromptDragLeave = useCallback(() => setTreeDropOver(false), []);
|
const _handleTextareaDragEnter = useCallback((e: React.DragEvent) => {
|
||||||
|
if (!_isTreeMimeDrag(e)) return;
|
||||||
|
e.preventDefault();
|
||||||
|
textareaAreaDragDepth.current += 1;
|
||||||
|
setTreeDropOver(true);
|
||||||
|
}, [_isTreeMimeDrag]);
|
||||||
|
|
||||||
const _handlePromptDrop = useCallback((e: React.DragEvent) => {
|
const _handleTextareaDragLeave = useCallback((e: React.DragEvent) => {
|
||||||
|
if (!_isTreeMimeDrag(e)) return;
|
||||||
|
e.preventDefault();
|
||||||
|
textareaAreaDragDepth.current = Math.max(0, textareaAreaDragDepth.current - 1);
|
||||||
|
if (textareaAreaDragDepth.current === 0) {
|
||||||
|
setTreeDropOver(false);
|
||||||
|
}
|
||||||
|
}, [_isTreeMimeDrag]);
|
||||||
|
|
||||||
|
const _handleTextareaDragOver = useCallback((e: React.DragEvent) => {
|
||||||
|
if (!_isTreeMimeDrag(e)) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
e.dataTransfer.dropEffect = 'copy';
|
||||||
|
}, [_isTreeMimeDrag]);
|
||||||
|
|
||||||
|
const _handlePromptDrop = useCallback(async (e: React.DragEvent) => {
|
||||||
|
textareaAreaDragDepth.current = 0;
|
||||||
setTreeDropOver(false);
|
setTreeDropOver(false);
|
||||||
|
|
||||||
const chatId = e.dataTransfer.getData('application/chat-id');
|
const chatId = e.dataTransfer.getData('application/chat-id');
|
||||||
|
|
@ -408,8 +536,8 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({ instanceId,
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const chatLabel = e.dataTransfer.getData('text/plain');
|
const chatLabel = e.dataTransfer.getData('text/plain');
|
||||||
const ref = chatLabel ? `[Chat: ${chatLabel}]` : `[Chat: ${chatId.slice(0, 8)}]`;
|
const refLabel = chatLabel ? `[Chat: ${chatLabel}]` : `[Chat: ${chatId.slice(0, 8)}]`;
|
||||||
setPrompt(prev => (prev ? `${prev} ${ref}` : ref));
|
setPrompt(prev => (prev ? `${prev} ${refLabel}` : refLabel));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -431,14 +559,13 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({ instanceId,
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const treeItemsJson = e.dataTransfer.getData('application/tree-items');
|
const handled = await _ingestDataTransfer(e.dataTransfer);
|
||||||
if (treeItemsJson && onTreeItemsDrop) {
|
if (handled) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const items: TreeItemDrop[] = JSON.parse(treeItemsJson);
|
textareaRef.current?.focus();
|
||||||
onTreeItemsDrop(items);
|
|
||||||
}
|
}
|
||||||
}, [onTreeItemsDrop, onFeatureSourceDrop, onDataSourceDrop]);
|
}, [_ingestDataTransfer, onFeatureSourceDrop, onDataSourceDrop]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -452,74 +579,44 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({ instanceId,
|
||||||
}}
|
}}
|
||||||
onDragOver={_handlePromptDragOver}
|
onDragOver={_handlePromptDragOver}
|
||||||
onDragLeave={_handlePromptDragLeave}
|
onDragLeave={_handlePromptDragLeave}
|
||||||
onDrop={_handlePromptDrop}
|
onDrop={e => void _handlePromptDrop(e)}
|
||||||
>
|
>
|
||||||
{/* Pending uploaded files */}
|
|
||||||
{pendingFiles.length > 0 && (
|
|
||||||
<div style={{
|
|
||||||
padding: `6px ${_horizontalPadding}px`,
|
|
||||||
display: 'flex',
|
|
||||||
gap: 6,
|
|
||||||
flexWrap: 'wrap',
|
|
||||||
borderBottom: '1px solid var(--border-color, #f0f0f0)',
|
|
||||||
background: 'var(--bg-secondary, #fafafa)',
|
|
||||||
}}>
|
|
||||||
{pendingFiles.map(pf => (
|
|
||||||
<span
|
|
||||||
key={pf.fileId}
|
|
||||||
style={{
|
|
||||||
display: 'inline-flex', alignItems: 'center', gap: 4,
|
|
||||||
padding: '3px 8px', borderRadius: 12, fontSize: 11,
|
|
||||||
background: pf.itemType === 'folder' ? '#e3f2fd' : '#fff3e0',
|
|
||||||
color: pf.itemType === 'folder' ? '#1565c0' : '#e65100',
|
|
||||||
fontWeight: 500,
|
|
||||||
border: `1px solid ${pf.itemType === 'folder' ? '#bbdefb' : '#ffe0b2'}`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{pf.itemType === 'folder' ? '📁' : '📎'} {pf.fileName.length > 25 ? pf.fileName.slice(0, 25) + '...' : pf.fileName}
|
|
||||||
{onRemovePendingFile && (
|
|
||||||
<button
|
|
||||||
onClick={() => onRemovePendingFile(pf.fileId)}
|
|
||||||
style={{
|
|
||||||
border: 'none', background: 'none', cursor: 'pointer',
|
|
||||||
fontSize: 12, color: '#e65100', padding: 0, lineHeight: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Attachment bar */}
|
|
||||||
{hasAttachments && (
|
{hasAttachments && (
|
||||||
<div style={{
|
<div style={{
|
||||||
padding: `6px ${_horizontalPadding}px`,
|
padding: `8px ${_horizontalPadding}px`,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
gap: 6,
|
gap: 6,
|
||||||
flexWrap: 'wrap',
|
flexWrap: 'wrap',
|
||||||
borderBottom: '1px solid var(--border-color, #f0f0f0)',
|
borderBottom: '1px solid var(--border-color, #f0f0f0)',
|
||||||
background: '#fafafa',
|
background: '#fafafa',
|
||||||
}}>
|
}}>
|
||||||
{attachedFileIds.map(fId => {
|
{attachments.map(att => {
|
||||||
const file = files.find(f => f.id === fId);
|
const isGroup = att.type === 'group';
|
||||||
|
const label = isGroup
|
||||||
|
? att.name
|
||||||
|
: (files.find(f => f.id === att.id)?.fileName || att.name || att.id);
|
||||||
|
const chipBg = isGroup ? '#e8f5e9' : '#e3f2fd';
|
||||||
|
const chipColor = isGroup ? '#1b5e20' : '#1565c0';
|
||||||
|
const chipBorder = isGroup ? '1px solid #c8e6c9' : '1px solid #bbdefb';
|
||||||
|
const countBadge = isGroup ? ` (${att.fileIds.length})` : '';
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
key={fId}
|
key={att.id}
|
||||||
|
title={isGroup ? `${att.fileIds.length} Datei(en) in dieser Gruppe` : label}
|
||||||
style={{
|
style={{
|
||||||
display: 'inline-flex', alignItems: 'center', gap: 4,
|
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||||||
padding: '3px 8px', borderRadius: 12, fontSize: 11,
|
padding: '3px 8px', borderRadius: 12, fontSize: 11,
|
||||||
background: '#e3f2fd', color: '#1565c0', fontWeight: 500,
|
background: chipBg, color: chipColor, fontWeight: 500,
|
||||||
|
border: chipBorder,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
📄 {file?.fileName || fId}
|
{isGroup ? '📁' : '📄'} {label}{countBadge}
|
||||||
<button
|
<button
|
||||||
onClick={() => _removeAttachedFile(fId)}
|
type="button"
|
||||||
|
onClick={() => _removeAttachment(att.id)}
|
||||||
style={{
|
style={{
|
||||||
border: 'none', background: 'none', cursor: 'pointer',
|
border: 'none', background: 'none', cursor: 'pointer',
|
||||||
fontSize: 12, color: '#1565c0', padding: 0, lineHeight: 1,
|
fontSize: 12, color: chipColor, padding: 0, lineHeight: 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
|
|
@ -540,6 +637,7 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({ instanceId,
|
||||||
>
|
>
|
||||||
🔗 {ds?.label || ds?.path || dsId}
|
🔗 {ds?.label || ds?.path || dsId}
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => _removeAttachedDataSource(dsId)}
|
onClick={() => _removeAttachedDataSource(dsId)}
|
||||||
style={{
|
style={{
|
||||||
border: 'none', background: 'none', cursor: 'pointer',
|
border: 'none', background: 'none', cursor: 'pointer',
|
||||||
|
|
@ -566,6 +664,7 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({ instanceId,
|
||||||
<span style={{ display: 'flex', alignItems: 'center', fontSize: 12 }}>{fdsIcon || '\uD83D\uDDC3\uFE0F'}</span>
|
<span style={{ display: 'flex', alignItems: 'center', fontSize: 12 }}>{fdsIcon || '\uD83D\uDDC3\uFE0F'}</span>
|
||||||
{fds?.label || fdsId} – {fds?.tableName || ''}
|
{fds?.label || fdsId} – {fds?.tableName || ''}
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => _toggleFeatureDataSource(fdsId)}
|
onClick={() => _toggleFeatureDataSource(fdsId)}
|
||||||
style={{
|
style={{
|
||||||
border: 'none', background: 'none', cursor: 'pointer',
|
border: 'none', background: 'none', cursor: 'pointer',
|
||||||
|
|
@ -580,7 +679,6 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({ instanceId,
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Autocomplete dropdown */}
|
|
||||||
{showAutocomplete && filteredFiles.length > 0 && (
|
{showAutocomplete && filteredFiles.length > 0 && (
|
||||||
<div style={{
|
<div style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
|
|
@ -598,6 +696,7 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({ instanceId,
|
||||||
{filteredFiles.slice(0, 10).map(f => (
|
{filteredFiles.slice(0, 10).map(f => (
|
||||||
<div
|
<div
|
||||||
key={f.id}
|
key={f.id}
|
||||||
|
role="presentation"
|
||||||
onClick={() => _insertFileRef(f.fileName)}
|
onClick={() => _insertFileRef(f.fileName)}
|
||||||
style={{
|
style={{
|
||||||
padding: '8px 12px',
|
padding: '8px 12px',
|
||||||
|
|
@ -617,7 +716,6 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({ instanceId,
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Main input row */}
|
|
||||||
<div style={{
|
<div style={{
|
||||||
padding: `8px ${_horizontalPadding}px 12px`,
|
padding: `8px ${_horizontalPadding}px 12px`,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
|
|
@ -631,25 +729,35 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({ instanceId,
|
||||||
onChange={_handleChange}
|
onChange={_handleChange}
|
||||||
onKeyDown={_handleKeyDown}
|
onKeyDown={_handleKeyDown}
|
||||||
onPaste={_handlePaste}
|
onPaste={_handlePaste}
|
||||||
placeholder={t('Geben Sie eine Nachricht ein, verwenden Sie @file für Dateien')}
|
onDragEnter={_handleTextareaDragEnter}
|
||||||
|
onDragLeave={_handleTextareaDragLeave}
|
||||||
|
onDragOver={_handleTextareaDragOver}
|
||||||
|
onDrop={e => void _handlePromptDrop(e)}
|
||||||
|
placeholder={
|
||||||
|
attachments.length > 0
|
||||||
|
? t('Nachricht eingeben … ({n} Anhang/Anhänge)', { n: String(attachments.length) })
|
||||||
|
: t('Geben Sie eine Nachricht ein — Dateien hierher ziehen oder @file verwenden')
|
||||||
|
}
|
||||||
disabled={isProcessing}
|
disabled={isProcessing}
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minHeight: isMobile ? 44 : 40,
|
minHeight: isMobile ? 52 : 48,
|
||||||
maxHeight: 120,
|
maxHeight: 120,
|
||||||
resize: 'vertical',
|
resize: 'vertical',
|
||||||
padding: '10px 14px',
|
padding: '10px 14px',
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
border: '1px solid var(--border-color, #ccc)',
|
border: treeDropOver ? '2px dashed var(--primary-color, #F25843)' : '1px solid var(--border-color, #ccc)',
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontFamily: 'inherit',
|
fontFamily: 'inherit',
|
||||||
outline: 'none',
|
outline: 'none',
|
||||||
flexBasis: isMobile ? '100%' : undefined,
|
flexBasis: isMobile ? '100%' : undefined,
|
||||||
|
boxSizing: 'border-box',
|
||||||
}}
|
}}
|
||||||
rows={1}
|
rows={1}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={onFileUploadClick}
|
onClick={onFileUploadClick}
|
||||||
disabled={uploading || isProcessing}
|
disabled={uploading || isProcessing}
|
||||||
title={t('Datei anhängen')}
|
title={t('Datei anhängen')}
|
||||||
|
|
@ -665,8 +773,6 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({ instanceId,
|
||||||
{uploading ? '...' : '+'}
|
{uploading ? '...' : '+'}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Source picker removed — data sources are now attached directly from the UDB Sources/Files tabs via "send to chat" buttons */}
|
|
||||||
|
|
||||||
{onProviderSelectionChange && providerSelection && (
|
{onProviderSelectionChange && providerSelection && (
|
||||||
<ProviderMultiSelect
|
<ProviderMultiSelect
|
||||||
selection={providerSelection}
|
selection={providerSelection}
|
||||||
|
|
@ -678,6 +784,7 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({ instanceId,
|
||||||
|
|
||||||
<div style={{ position: 'relative', display: 'flex', gap: 2 }}>
|
<div style={{ position: 'relative', display: 'flex', gap: 2 }}>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => setShowLangPicker(prev => !prev)}
|
onClick={() => setShowLangPicker(prev => !prev)}
|
||||||
title={t('Sprache wählen')}
|
title={t('Sprache wählen')}
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -691,6 +798,7 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({ instanceId,
|
||||||
{voiceLanguage.split('-')[0].toUpperCase()}
|
{voiceLanguage.split('-')[0].toUpperCase()}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={_toggleVoice}
|
onClick={_toggleVoice}
|
||||||
title={voiceActive ? t('Aufnahme stoppen') : t('Sprachaufnahme starten')}
|
title={voiceActive ? t('Aufnahme stoppen') : t('Sprachaufnahme starten')}
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -712,6 +820,7 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({ instanceId,
|
||||||
{voiceCatalogLanguages.map(lang => (
|
{voiceCatalogLanguages.map(lang => (
|
||||||
<div
|
<div
|
||||||
key={lang.bcp47}
|
key={lang.bcp47}
|
||||||
|
role="presentation"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setVoiceLanguage(lang.bcp47);
|
setVoiceLanguage(lang.bcp47);
|
||||||
setShowLangPicker(false);
|
setShowLangPicker(false);
|
||||||
|
|
@ -733,6 +842,7 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({ instanceId,
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => setNeutralizeActive(v => !v)}
|
onClick={() => setNeutralizeActive(v => !v)}
|
||||||
title={neutralizeActive ? t('Neutralisierung aktiv, klicken zum Deaktivieren') : t('Neutralisierung aus, klicken zum Aktivieren')}
|
title={neutralizeActive ? t('Neutralisierung aktiv, klicken zum Deaktivieren') : t('Neutralisierung aus, klicken zum Aktivieren')}
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -749,6 +859,7 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({ instanceId,
|
||||||
|
|
||||||
{isProcessing ? (
|
{isProcessing ? (
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={onStop}
|
onClick={onStop}
|
||||||
style={{
|
style={{
|
||||||
padding: '10px 20px', borderRadius: 8, border: 'none',
|
padding: '10px 20px', borderRadius: 8, border: 'none',
|
||||||
|
|
@ -760,12 +871,13 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({ instanceId,
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={_handleSend}
|
onClick={_handleSend}
|
||||||
disabled={!prompt.trim()}
|
disabled={!_canSend}
|
||||||
style={{
|
style={{
|
||||||
padding: '10px 20px', borderRadius: 8, border: 'none',
|
padding: '10px 20px', borderRadius: 8, border: 'none',
|
||||||
background: prompt.trim() ? 'var(--primary-color, #F25843)' : 'var(--color-gray-disabled, #ccc)',
|
background: _canSend ? 'var(--primary-color, #F25843)' : 'var(--color-gray-disabled, #ccc)',
|
||||||
color: '#fff', cursor: prompt.trim() ? 'pointer' : 'default', fontWeight: 600,
|
color: '#fff', cursor: _canSend ? 'pointer' : 'default', fontWeight: 600,
|
||||||
minWidth: isMobile ? 84 : undefined,
|
minWidth: isMobile ? 84 : undefined,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
@ -775,4 +887,4 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({ instanceId,
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
|
||||||
|
|
@ -14,11 +14,14 @@ import { useFileOperations } from '../../../hooks/useFiles';
|
||||||
import { useWorkspace } from './useWorkspace';
|
import { useWorkspace } from './useWorkspace';
|
||||||
import { ChatStream } from './ChatStream';
|
import { ChatStream } from './ChatStream';
|
||||||
import { WorkspaceInput } from './WorkspaceInput';
|
import { WorkspaceInput } from './WorkspaceInput';
|
||||||
|
import type { WorkspaceInputHandle, TreeItemDrop } from './WorkspaceInput';
|
||||||
import { FilePreview } from './FilePreview';
|
import { FilePreview } from './FilePreview';
|
||||||
import { ToolActivityLog } from './ToolActivityLog';
|
import { ToolActivityLog } from './ToolActivityLog';
|
||||||
import { UnifiedDataBar } from '../../../components/UnifiedDataBar';
|
import { UnifiedDataBar } from '../../../components/UnifiedDataBar';
|
||||||
import type { UdbContext, UdbTab, AddToChat_FileItem, AddToChat_FeatureSource } from '../../../components/UnifiedDataBar';
|
import type { UdbContext, UdbTab, AddToChat_FileItem, AddToChat_FeatureSource } from '../../../components/UnifiedDataBar';
|
||||||
import api from '../../../api';
|
import api from '../../../api';
|
||||||
|
import { collectGroupItemIds } from '../../../api/fileApi';
|
||||||
|
import type { TableGroupNode } from '../../../api/connectionApi';
|
||||||
import { _defaultProviderSelection, _toBackendProviders } from '../../../components/ProviderSelector';
|
import { _defaultProviderSelection, _toBackendProviders } from '../../../components/ProviderSelector';
|
||||||
import type { ProviderSelection } from '../../../components/ProviderSelector';
|
import type { ProviderSelection } from '../../../components/ProviderSelector';
|
||||||
import { useBilling } from '../../../hooks/useBilling';
|
import { useBilling } from '../../../hooks/useBilling';
|
||||||
|
|
@ -58,12 +61,6 @@ function _useResizable(initialWidth: number, minWidth: number, maxWidth: number)
|
||||||
}
|
}
|
||||||
type RightTab = 'activity' | 'preview';
|
type RightTab = 'activity' | 'preview';
|
||||||
|
|
||||||
interface PendingFile {
|
|
||||||
fileId: string;
|
|
||||||
fileName: string;
|
|
||||||
itemType?: 'file' | 'folder';
|
|
||||||
}
|
|
||||||
|
|
||||||
interface WorkspacePageProps {
|
interface WorkspacePageProps {
|
||||||
persistentInstanceId?: string;
|
persistentInstanceId?: string;
|
||||||
}
|
}
|
||||||
|
|
@ -85,7 +82,9 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
|
||||||
const [rightTab, setRightTab] = useState<RightTab>('activity');
|
const [rightTab, setRightTab] = useState<RightTab>('activity');
|
||||||
const [udbTab, setUdbTab] = useState<UdbTab>('chats');
|
const [udbTab, setUdbTab] = useState<UdbTab>('chats');
|
||||||
const [selectedFileId, setSelectedFileId] = useState<string | null>(null);
|
const [selectedFileId, setSelectedFileId] = useState<string | null>(null);
|
||||||
const [pendingFiles, setPendingFiles] = useState<PendingFile[]>([]);
|
const workspaceInputRef = useRef<WorkspaceInputHandle>(null);
|
||||||
|
/** Persisted grouping tree from /api/files/list — resolves dropped groups → file IDs */
|
||||||
|
const [filesListGroupTree, setFilesListGroupTree] = useState<TableGroupNode[]>([]);
|
||||||
const [providerSelection, setProviderSelection] = useState<ProviderSelection>(_defaultProviderSelection());
|
const [providerSelection, setProviderSelection] = useState<ProviderSelection>(_defaultProviderSelection());
|
||||||
const { allowedProviders } = useBilling();
|
const { allowedProviders } = useBilling();
|
||||||
const [isDragOver, setIsDragOver] = useState(false);
|
const [isDragOver, setIsDragOver] = useState(false);
|
||||||
|
|
@ -116,6 +115,27 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
|
||||||
}
|
}
|
||||||
}, [isMobile]);
|
}, [isMobile]);
|
||||||
|
|
||||||
|
const _pullFilesGroupTree = useCallback(async (): Promise<TableGroupNode[]> => {
|
||||||
|
if (!instanceId) return [];
|
||||||
|
try {
|
||||||
|
const res = await api.get<{ groupTree?: TableGroupNode[] }>('/api/files/list', {
|
||||||
|
params: { page: 1, pageSize: 1 },
|
||||||
|
});
|
||||||
|
const gt = res.data?.groupTree;
|
||||||
|
const list = Array.isArray(gt) ? gt : [];
|
||||||
|
setFilesListGroupTree(list);
|
||||||
|
return list;
|
||||||
|
} catch {
|
||||||
|
setFilesListGroupTree([]);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}, [instanceId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
_pullFilesGroupTree();
|
||||||
|
}, [_pullFilesGroupTree]);
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (autoStartHandled.current || !instanceId || workspace.isProcessing) return;
|
if (autoStartHandled.current || !instanceId || workspace.isProcessing) return;
|
||||||
const prompt = searchParams.get('prompt');
|
const prompt = searchParams.get('prompt');
|
||||||
|
|
@ -132,23 +152,77 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
|
||||||
}
|
}
|
||||||
}, [instanceId, searchParams, setSearchParams, workspace, providerSelection, allowedProviders]);
|
}, [instanceId, searchParams, setSearchParams, workspace, providerSelection, allowedProviders]);
|
||||||
|
|
||||||
|
const _resolveTreeItemsToFileIds = useCallback(async (items: TreeItemDrop[]) => {
|
||||||
|
let tree = filesListGroupTree;
|
||||||
|
if (items.some(i => i.type === 'group')) {
|
||||||
|
tree = await _pullFilesGroupTree();
|
||||||
|
}
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const it of items) {
|
||||||
|
if (it.type === 'group') {
|
||||||
|
out.push(...collectGroupItemIds(tree, it.id));
|
||||||
|
} else {
|
||||||
|
out.push(it.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...new Set(out)];
|
||||||
|
}, [filesListGroupTree, _pullFilesGroupTree]);
|
||||||
|
|
||||||
const _uploadAndAttach = useCallback(async (file: File) => {
|
const _uploadAndAttach = useCallback(async (file: File) => {
|
||||||
const result = await fileOps.handleFileUpload(file, undefined, instanceId);
|
const result = await fileOps.handleFileUpload(file, undefined, instanceId);
|
||||||
if (result.success && result.fileData) {
|
if (result.success && result.fileData) {
|
||||||
const data = result.fileData.file || result.fileData;
|
const data = result.fileData.file || result.fileData;
|
||||||
if (data?.id) {
|
if (data?.id) {
|
||||||
setPendingFiles(prev => [...prev, { fileId: data.id, fileName: data.fileName || file.name }]);
|
workspaceInputRef.current?.attachFileIds([data.id]);
|
||||||
}
|
}
|
||||||
workspace.refreshFiles();
|
workspace.refreshFiles();
|
||||||
}
|
}
|
||||||
}, [fileOps, workspace, instanceId]);
|
}, [fileOps, workspace, instanceId]);
|
||||||
|
|
||||||
|
const _consumeDataTransferFilesOrChat = useCallback(async (dt: React.DragEvent['dataTransfer']) => {
|
||||||
|
const chatId = dt.getData('application/chat-id');
|
||||||
|
if (chatId) {
|
||||||
|
try {
|
||||||
|
const res = await api.post(`/api/workspace/${instanceId}/resolve-rag`, { chatId });
|
||||||
|
const body = res.data ?? {};
|
||||||
|
if (body.summary) setDraftAppend(body.summary);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('RAG resolve failed for dropped chat:', err);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (workspaceInputRef.current && (await workspaceInputRef.current.ingestTreeDataTransfer(dt))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (dt.files && dt.files.length > 0) {
|
||||||
|
for (const file of Array.from(dt.files)) {
|
||||||
|
await _uploadAndAttach(file);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}, [_uploadAndAttach, instanceId]);
|
||||||
|
|
||||||
|
const _isCenterDropInteresting = useCallback((e: React.DragEvent) => {
|
||||||
|
const types = e.dataTransfer.types;
|
||||||
|
return (
|
||||||
|
types.includes('application/tree-items') ||
|
||||||
|
types.includes('application/group-file-ids') ||
|
||||||
|
types.includes('application/group-id') ||
|
||||||
|
types.includes('application/porta-group') ||
|
||||||
|
types.includes('application/file-id') ||
|
||||||
|
types.includes('application/file-ids') ||
|
||||||
|
types.includes('application/chat-id') ||
|
||||||
|
types.includes('Files')
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const _handleDragEnter = useCallback((e: React.DragEvent) => {
|
const _handleDragEnter = useCallback((e: React.DragEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
dragCounterRef.current++;
|
dragCounterRef.current++;
|
||||||
if (e.dataTransfer.types.includes('Files')) setIsDragOver(true);
|
if (_isCenterDropInteresting(e)) setIsDragOver(true);
|
||||||
}, []);
|
}, [_isCenterDropInteresting]);
|
||||||
|
|
||||||
const _handleDragLeave = useCallback((e: React.DragEvent) => {
|
const _handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -158,9 +232,11 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const _handleDragOver = useCallback((e: React.DragEvent) => {
|
const _handleDragOver = useCallback((e: React.DragEvent) => {
|
||||||
|
if (!_isCenterDropInteresting(e)) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
}, []);
|
e.dataTransfer.dropEffect = 'copy';
|
||||||
|
}, [_isCenterDropInteresting]);
|
||||||
|
|
||||||
const _handleDrop = useCallback(async (e: React.DragEvent) => {
|
const _handleDrop = useCallback(async (e: React.DragEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -168,27 +244,8 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
|
||||||
dragCounterRef.current = 0;
|
dragCounterRef.current = 0;
|
||||||
setIsDragOver(false);
|
setIsDragOver(false);
|
||||||
|
|
||||||
const chatId = e.dataTransfer.getData('application/chat-id');
|
await _consumeDataTransferFilesOrChat(e.dataTransfer);
|
||||||
if (chatId) {
|
}, [_consumeDataTransferFilesOrChat]);
|
||||||
try {
|
|
||||||
const res = await api.post(`/api/workspace/${instanceId}/resolve-rag`, { chatId });
|
|
||||||
const body = res.data ?? {};
|
|
||||||
if (body.summary) {
|
|
||||||
setDraftAppend(body.summary);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('RAG resolve failed for dropped chat:', err);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const droppedFiles = e.dataTransfer.files;
|
|
||||||
if (droppedFiles.length > 0) {
|
|
||||||
for (const file of Array.from(droppedFiles)) {
|
|
||||||
await _uploadAndAttach(file);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [_uploadAndAttach, instanceId, workspace]);
|
|
||||||
|
|
||||||
const _handleFileInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
const _handleFileInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
if (e.target.files && e.target.files.length > 0) {
|
if (e.target.files && e.target.files.length > 0) {
|
||||||
|
|
@ -197,22 +254,10 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
|
||||||
}
|
}
|
||||||
}, [_uploadAndAttach]);
|
}, [_uploadAndAttach]);
|
||||||
|
|
||||||
const _handleRemovePendingFile = useCallback((fileId: string) => {
|
const _handleSendToChat_Files = useCallback((items: AddToChat_FileItem[]) => {
|
||||||
setPendingFiles(prev => prev.filter(f => f.fileId !== fileId));
|
void workspaceInputRef.current?.attachTreeItems(
|
||||||
}, []);
|
items.map(i => ({ id: i.id, type: i.type, name: i.name })),
|
||||||
|
);
|
||||||
const _handleTreeItemsDrop = useCallback((items: Array<{ id: string; type: 'file' | 'folder'; name: string }>) => {
|
|
||||||
setPendingFiles(prev => {
|
|
||||||
const existing = new Set(prev.map(f => f.fileId));
|
|
||||||
const toAdd: PendingFile[] = [];
|
|
||||||
for (const item of items) {
|
|
||||||
if (!existing.has(item.id)) {
|
|
||||||
toAdd.push({ fileId: item.id, fileName: item.name, itemType: item.type });
|
|
||||||
existing.add(item.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return [...prev, ...toAdd];
|
|
||||||
});
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (!instanceId) {
|
if (!instanceId) {
|
||||||
|
|
@ -279,20 +324,6 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
|
||||||
workspace.refreshFeatureDataSources();
|
workspace.refreshFeatureDataSources();
|
||||||
}, [workspace]);
|
}, [workspace]);
|
||||||
|
|
||||||
const _handleSendToChat_Files = useCallback((items: AddToChat_FileItem[]) => {
|
|
||||||
setPendingFiles(prev => {
|
|
||||||
const existing = new Set(prev.map(f => f.fileId));
|
|
||||||
const toAdd: PendingFile[] = [];
|
|
||||||
for (const item of items) {
|
|
||||||
if (!existing.has(item.id)) {
|
|
||||||
toAdd.push({ fileId: item.id, fileName: item.name, itemType: item.type });
|
|
||||||
existing.add(item.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return [...prev, ...toAdd];
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const [pendingAttachFdsId, setPendingAttachFdsId] = useState<string>('');
|
const [pendingAttachFdsId, setPendingAttachFdsId] = useState<string>('');
|
||||||
|
|
||||||
const _handleSendToChat_FeatureSource = useCallback(async (params: AddToChat_FeatureSource) => {
|
const _handleSendToChat_FeatureSource = useCallback(async (params: AddToChat_FeatureSource) => {
|
||||||
|
|
@ -497,7 +528,7 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
|
||||||
fontSize: 16, fontWeight: 600, color: 'var(--primary-color, #F25843)',
|
fontSize: 16, fontWeight: 600, color: 'var(--primary-color, #F25843)',
|
||||||
pointerEvents: 'none',
|
pointerEvents: 'none',
|
||||||
}}>
|
}}>
|
||||||
Dateien hier ablegen
|
{t('Dateien oder Gruppen hier ablegen')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<ChatStream
|
<ChatStream
|
||||||
|
|
@ -510,26 +541,23 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
|
||||||
onOpenEditor={() => navigate(`/mandates/${mandateId}/${featureCode}/${routeInstanceId}/editor`)}
|
onOpenEditor={() => navigate(`/mandates/${mandateId}/${featureCode}/${routeInstanceId}/editor`)}
|
||||||
/>
|
/>
|
||||||
<WorkspaceInput
|
<WorkspaceInput
|
||||||
|
ref={workspaceInputRef}
|
||||||
instanceId={instanceId}
|
instanceId={instanceId}
|
||||||
onSend={(prompt, fileIds, dataSourceIds, featureDataSourceIds, options) => {
|
onSend={(prompt, fileIds, dataSourceIds, featureDataSourceIds, options) => {
|
||||||
const allFileIds = [...new Set([...pendingFiles.map(f => f.fileId), ...(fileIds || [])])];
|
|
||||||
const resolvedProviders = _toBackendProviders(providerSelection, allowedProviders);
|
const resolvedProviders = _toBackendProviders(providerSelection, allowedProviders);
|
||||||
workspace.sendMessage(prompt, allFileIds, dataSourceIds, resolvedProviders, featureDataSourceIds, options);
|
workspace.sendMessage(prompt, fileIds || [], dataSourceIds, resolvedProviders, featureDataSourceIds, options);
|
||||||
setPendingFiles([]);
|
|
||||||
}}
|
}}
|
||||||
isProcessing={workspace.isProcessing}
|
isProcessing={workspace.isProcessing}
|
||||||
onStop={workspace.stopProcessing}
|
onStop={workspace.stopProcessing}
|
||||||
files={workspace.files}
|
files={workspace.files}
|
||||||
dataSources={workspace.dataSources}
|
dataSources={workspace.dataSources}
|
||||||
featureDataSources={workspace.featureDataSources}
|
featureDataSources={workspace.featureDataSources}
|
||||||
pendingFiles={pendingFiles}
|
resolveTreeItemsToFileIds={_resolveTreeItemsToFileIds}
|
||||||
onRemovePendingFile={_handleRemovePendingFile}
|
|
||||||
onFileUploadClick={() => fileInputRef.current?.click()}
|
onFileUploadClick={() => fileInputRef.current?.click()}
|
||||||
uploading={fileOps.uploadingFile}
|
uploading={fileOps.uploadingFile}
|
||||||
providerSelection={providerSelection}
|
providerSelection={providerSelection}
|
||||||
onProviderSelectionChange={setProviderSelection}
|
onProviderSelectionChange={setProviderSelection}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
onTreeItemsDrop={_handleTreeItemsDrop}
|
|
||||||
onFeatureSourceDrop={_handleSendToChat_FeatureSource}
|
onFeatureSourceDrop={_handleSendToChat_FeatureSource}
|
||||||
onDataSourceDrop={_handleDataSourceDrop}
|
onDataSourceDrop={_handleDataSourceDrop}
|
||||||
pendingAttachDsId={pendingAttachDsId}
|
pendingAttachDsId={pendingAttachDsId}
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,6 @@ export interface WorkspaceFile {
|
||||||
mimeType: string;
|
mimeType: string;
|
||||||
fileSize: number;
|
fileSize: number;
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
folderId?: string;
|
|
||||||
status?: string;
|
status?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
featureInstanceId?: string;
|
featureInstanceId?: string;
|
||||||
|
|
@ -44,12 +43,6 @@ export interface WorkspaceFile {
|
||||||
neutralize: boolean;
|
neutralize: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WorkspaceFolder {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
parentId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DataSource {
|
export interface DataSource {
|
||||||
id: string;
|
id: string;
|
||||||
connectionId: string;
|
connectionId: string;
|
||||||
|
|
@ -101,7 +94,6 @@ interface UseWorkspaceReturn {
|
||||||
loadWorkflow: (workflowId: string) => void;
|
loadWorkflow: (workflowId: string) => void;
|
||||||
resetToNew: () => void;
|
resetToNew: () => void;
|
||||||
files: WorkspaceFile[];
|
files: WorkspaceFile[];
|
||||||
folders: WorkspaceFolder[];
|
|
||||||
dataSources: DataSource[];
|
dataSources: DataSource[];
|
||||||
featureDataSources: FeatureDataSource[];
|
featureDataSources: FeatureDataSource[];
|
||||||
refreshFeatureDataSources: () => void;
|
refreshFeatureDataSources: () => void;
|
||||||
|
|
@ -113,7 +105,6 @@ interface UseWorkspaceReturn {
|
||||||
workflowId: string | null;
|
workflowId: string | null;
|
||||||
workflowVersion: number;
|
workflowVersion: number;
|
||||||
refreshFiles: () => void;
|
refreshFiles: () => void;
|
||||||
refreshFolders: () => void;
|
|
||||||
refreshDataSources: () => void;
|
refreshDataSources: () => void;
|
||||||
dataSourceAccesses: DataSourceAccessEvent[];
|
dataSourceAccesses: DataSourceAccessEvent[];
|
||||||
/**
|
/**
|
||||||
|
|
@ -135,7 +126,6 @@ export function useWorkspace(instanceId: string): UseWorkspaceReturn {
|
||||||
const [messages, setMessages] = useState<Message[]>([]);
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
const [isProcessing, setIsProcessing] = useState(false);
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
const [files, setFiles] = useState<WorkspaceFile[]>([]);
|
const [files, setFiles] = useState<WorkspaceFile[]>([]);
|
||||||
const [folders, setFolders] = useState<WorkspaceFolder[]>([]);
|
|
||||||
const [dataSources, setDataSources] = useState<DataSource[]>([]);
|
const [dataSources, setDataSources] = useState<DataSource[]>([]);
|
||||||
const [featureDataSources, setFeatureDataSources] = useState<FeatureDataSource[]>([]);
|
const [featureDataSources, setFeatureDataSources] = useState<FeatureDataSource[]>([]);
|
||||||
const [agentProgress, setAgentProgress] = useState<AgentProgress | null>(null);
|
const [agentProgress, setAgentProgress] = useState<AgentProgress | null>(null);
|
||||||
|
|
@ -156,13 +146,6 @@ export function useWorkspace(instanceId: string): UseWorkspaceReturn {
|
||||||
.catch(err => console.error('Failed to load workspace files:', err));
|
.catch(err => console.error('Failed to load workspace files:', err));
|
||||||
}, [instanceId]);
|
}, [instanceId]);
|
||||||
|
|
||||||
const refreshFolders = useCallback(() => {
|
|
||||||
if (!instanceId) return;
|
|
||||||
api.get(`/api/workspace/${instanceId}/folders`)
|
|
||||||
.then(res => setFolders(res.data.folders || []))
|
|
||||||
.catch(err => console.error('Failed to load workspace folders:', err));
|
|
||||||
}, [instanceId]);
|
|
||||||
|
|
||||||
const refreshDataSources = useCallback(() => {
|
const refreshDataSources = useCallback(() => {
|
||||||
if (!instanceId) return;
|
if (!instanceId) return;
|
||||||
api.get(`/api/workspace/${instanceId}/datasources`)
|
api.get(`/api/workspace/${instanceId}/datasources`)
|
||||||
|
|
@ -180,10 +163,9 @@ export function useWorkspace(instanceId: string): UseWorkspaceReturn {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!instanceId) return;
|
if (!instanceId) return;
|
||||||
refreshFiles();
|
refreshFiles();
|
||||||
refreshFolders();
|
|
||||||
refreshDataSources();
|
refreshDataSources();
|
||||||
refreshFeatureDataSources();
|
refreshFeatureDataSources();
|
||||||
}, [instanceId, refreshFiles, refreshFolders, refreshDataSources, refreshFeatureDataSources]);
|
}, [instanceId, refreshFiles, refreshDataSources, refreshFeatureDataSources]);
|
||||||
|
|
||||||
const loadWorkflow = useCallback((wfId: string) => {
|
const loadWorkflow = useCallback((wfId: string) => {
|
||||||
if (!instanceId || !wfId) return;
|
if (!instanceId || !wfId) return;
|
||||||
|
|
@ -511,7 +493,6 @@ export function useWorkspace(instanceId: string): UseWorkspaceReturn {
|
||||||
loadWorkflow,
|
loadWorkflow,
|
||||||
resetToNew,
|
resetToNew,
|
||||||
files,
|
files,
|
||||||
folders,
|
|
||||||
dataSources,
|
dataSources,
|
||||||
featureDataSources,
|
featureDataSources,
|
||||||
refreshFeatureDataSources,
|
refreshFeatureDataSources,
|
||||||
|
|
@ -523,7 +504,6 @@ export function useWorkspace(instanceId: string): UseWorkspaceReturn {
|
||||||
workflowId,
|
workflowId,
|
||||||
workflowVersion,
|
workflowVersion,
|
||||||
refreshFiles,
|
refreshFiles,
|
||||||
refreshFolders,
|
|
||||||
refreshDataSources,
|
refreshDataSources,
|
||||||
dataSourceAccesses,
|
dataSourceAccesses,
|
||||||
loadedAttachedDataSourceIds,
|
loadedAttachedDataSourceIds,
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@
|
||||||
"files": [],
|
"files": [],
|
||||||
"references": [
|
"references": [
|
||||||
{ "path": "./tsconfig.app.json" },
|
{ "path": "./tsconfig.app.json" },
|
||||||
{ "path": "./tsconfig.node.json" },
|
{ "path": "./tsconfig.node.json" }
|
||||||
{ "path": "./tsconfig.test.json" }
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue