From 9e792bc74fe91c1a2a4321434c3f9387251b203c Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Tue, 17 Mar 2026 19:19:32 +0100 Subject: [PATCH 1/2] file system and stt and ttss reevisions --- src/api/billingApi.ts | 1 + src/api/fileApi.ts | 125 ++- .../FolderTree/FolderTree.module.css | 157 ++++ src/components/FolderTree/FolderTree.tsx | 731 ++++++++++++++++++ .../FormGeneratorTable/FormGeneratorTable.tsx | 12 +- src/contexts/FileContext.tsx | 131 +++- src/hooks/useCommcoach.ts | 102 +-- src/hooks/useFiles.ts | 88 ++- src/hooks/useSpeechAudioCapture.ts | 198 +++++ src/hooks/useTtsPlayback.ts | 79 ++ src/index.css | 11 +- src/pages/basedata/FilesPage.tsx | 457 +++++++---- src/pages/billing/BillingDataView.tsx | 57 +- .../views/commcoach/CommcoachDossierView.tsx | 16 +- .../views/commcoach/useVoiceController.ts | 236 +----- .../views/workspace/ConversationList.tsx | 61 +- src/pages/views/workspace/FileBrowser.tsx | 262 +++---- src/pages/views/workspace/WorkspaceInput.tsx | 208 ++--- src/pages/views/workspace/WorkspacePage.tsx | 18 +- 19 files changed, 2211 insertions(+), 739 deletions(-) create mode 100644 src/components/FolderTree/FolderTree.module.css create mode 100644 src/components/FolderTree/FolderTree.tsx create mode 100644 src/hooks/useSpeechAudioCapture.ts create mode 100644 src/hooks/useTtsPlayback.ts diff --git a/src/api/billingApi.ts b/src/api/billingApi.ts index 5e25261..e82e438 100644 --- a/src/api/billingApi.ts +++ b/src/api/billingApi.ts @@ -99,6 +99,7 @@ export interface CreditAddRequest { export interface CheckoutCreateRequest { userId?: string; amount: number; + returnUrl: string; } export interface CheckoutCreateResponse { diff --git a/src/api/fileApi.ts b/src/api/fileApi.ts index 8c94328..b079d6a 100644 --- a/src/api/fileApi.ts +++ b/src/api/fileApi.ts @@ -176,21 +176,116 @@ export async function deleteFiles( request: ApiRequestFunction, fileIds: string[] ): Promise> { - const results = await Promise.allSettled( - fileIds.map(fileId => - request({ - url: `/api/files/${fileId}`, - method: 'delete' - }).then(() => ({ success: true, fileId })) - .catch((error) => ({ success: false, fileId, error })) - ) - ); - - return results.map((result, index) => { - if (result.status === 'fulfilled') { - return result.value; - } - return { success: false, fileId: fileIds[index], error: result.reason }; + const uniqueIds = [...new Set(fileIds.filter(Boolean))]; + if (uniqueIds.length === 0) return []; + await request({ + url: '/api/files/batch-delete', + method: 'post', + data: { fileIds: uniqueIds } + }); + 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 +// ============================================================================ + +export interface FolderInfo { + id: string; + name: string; + parentId: string | null; + mandateId?: string; + featureInstanceId?: string; + createdAt?: number; +} + +export async function fetchFolders( + request: ApiRequestFunction, + parentId?: string | null +): Promise { + 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 { + 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 { + return await request({ + url: `/api/files/folders/${folderId}`, + method: 'put', + data: { name }, + }); +} + +export async function deleteFolderApi( + request: ApiRequestFunction, + folderId: string, + recursive: boolean = false +): Promise { + return await request({ + url: `/api/files/folders/${folderId}`, + method: 'delete', + params: { recursive }, + }); +} + +export async function moveFolder( + request: ApiRequestFunction, + folderId: string, + targetParentId: string | null +): Promise { + return await request({ + url: `/api/files/folders/${folderId}/move`, + method: 'post', + data: { targetParentId }, + }); +} + +export async function moveFile( + request: ApiRequestFunction, + fileId: string, + targetFolderId: string | null +): Promise { + return await request({ + url: `/api/files/${fileId}/move`, + method: 'post', + data: { targetFolderId }, }); } diff --git a/src/components/FolderTree/FolderTree.module.css b/src/components/FolderTree/FolderTree.module.css new file mode 100644 index 0000000..52df54d --- /dev/null +++ b/src/components/FolderTree/FolderTree.module.css @@ -0,0 +1,157 @@ +.folderTree { + font-size: 0.875rem; + user-select: none; +} + +.treeNode { + display: flex; + align-items: center; + padding: 4px 8px; + cursor: pointer; + border-radius: 4px; + gap: 6px; + min-height: 32px; + position: relative; +} + +.treeNode:hover { + background: var(--color-bg-hover, rgba(0, 0, 0, 0.04)); +} + +.treeNode.selected { + background: var(--color-bg-selected, rgba(25, 118, 210, 0.08)); + font-weight: 600; +} + +.treeNode.multiSelected { + background: var(--color-bg-multi-selected, rgba(25, 118, 210, 0.14)); + box-shadow: inset 3px 0 0 var(--color-primary, #1976d2); +} + +.treeNode.multiSelected:hover { + background: var(--color-bg-multi-selected-hover, rgba(25, 118, 210, 0.20)); +} + +.treeNode.dropTarget { + background: var(--color-bg-drop, rgba(25, 118, 210, 0.15)); + outline: 2px dashed var(--color-primary, #1976d2); + outline-offset: -2px; +} + +.treeNode.dragging { + opacity: 0.5; +} + +.chevron { + width: 16px; + height: 16px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + transition: transform 0.15s ease; + color: var(--color-text-secondary, #666); + font-size: 10px; +} + +.chevron.expanded { + transform: rotate(90deg); +} + +.chevron.empty { + visibility: hidden; +} + +.folderIcon { + flex-shrink: 0; + color: var(--color-text-secondary, #888); + font-size: 14px; +} + +.folderName { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.renameInput { + flex: 1; + border: 1px solid var(--color-primary, #1976d2); + border-radius: 3px; + padding: 1px 4px; + font-size: inherit; + font-family: inherit; + outline: none; + min-width: 0; +} + +.actions { + display: none; + gap: 2px; + margin-left: auto; + flex-shrink: 0; +} + +.treeNode:hover .actions { + display: flex; +} + +.actionBtn { + background: none; + border: none; + cursor: pointer; + padding: 2px 4px; + border-radius: 3px; + color: var(--color-text-secondary, #888); + font-size: 12px; + line-height: 1; + display: flex; + align-items: center; +} + +.actionBtn:hover { + background: var(--color-bg-hover, rgba(0, 0, 0, 0.08)); + color: var(--color-text-primary, #333); +} + +.actionBtn.danger:hover { + color: var(--color-error, #d32f2f); +} + +.children { + padding-left: 16px; +} + +.rootLabel { + font-weight: 600; + color: var(--color-text-primary, #333); +} + +/* File nodes inside the tree */ +.fileNode { + cursor: pointer; +} + +.fileNode:hover { + background: var(--color-bg-hover, rgba(0, 0, 0, 0.04)); +} + +.fileIcon { + flex-shrink: 0; + font-size: 12px; +} + +.fileSize { + font-size: 10px; + color: var(--color-text-secondary, #999); + flex-shrink: 0; + margin-left: auto; +} + +.rootActions { + display: flex; + gap: 2px; + margin-left: auto; + flex-shrink: 0; +} diff --git a/src/components/FolderTree/FolderTree.tsx b/src/components/FolderTree/FolderTree.tsx new file mode 100644 index 0000000..8a3579f --- /dev/null +++ b/src/components/FolderTree/FolderTree.tsx @@ -0,0 +1,731 @@ +/** + * FolderTree – Shared recursive folder/file tree component. + * + * Used on the Files page and in the Workspace chat. + * Supports: + * - Alphabetical sorting per level (folders first, then files) + * - Multi-selection (CTRL+click, SHIFT+click) with visual highlight + * - Batch drag-and-drop for selected items + * - Inline CRUD icons for folders + * - showFiles mode renders files inline under their parent folder + * - Drag-out: sets application/tree-items on dataTransfer for external drop targets + */ + +import React, { useState, useCallback, useRef, useEffect, useMemo } from 'react'; +import { FaFolder, FaFolderOpen, FaPlus, FaPen, FaTrash, FaChevronRight, FaGlobe, FaSyncAlt } from 'react-icons/fa'; +import styles from './FolderTree.module.css'; + +/* ── Public types ──────────────────────────────────────────────────────── */ + +export interface FolderNode { + id: string; + name: string; + parentId: string | null; + children?: FolderNode[]; +} + +export interface FileNode { + id: string; + fileName: string; + mimeType?: string; + fileSize?: number; + folderId?: string | null; +} + +export interface TreeItem { + id: string; + type: 'file' | 'folder'; + name: string; +} + +export interface FolderTreeProps { + folders: FolderNode[]; + files?: FileNode[]; + showFiles?: boolean; + selectedFolderId: string | null; + onSelect: (folderId: string | null) => void; + onFileSelect?: (fileId: string) => void; + selectedItemIds?: Set; + onSelectionChange?: (selectedIds: Set) => void; + expandedIds?: Set; + onToggleExpand?: (id: string) => void; + onRefresh?: () => void; + onCreateFolder?: (name: string, parentId: string | null) => Promise; + onRenameFolder?: (folderId: string, newName: string) => Promise; + onDeleteFolder?: (folderId: string) => Promise; + onMoveFolder?: (folderId: string, targetParentId: string | null) => Promise; + onMoveFolders?: (folderIds: string[], targetParentId: string | null) => Promise; + onMoveFile?: (fileId: string, targetFolderId: string | null) => Promise; + onMoveFiles?: (fileIds: string[], targetFolderId: string | null) => Promise; + onRenameFile?: (fileId: string, newName: string) => Promise; + onDeleteFile?: (fileId: string) => Promise; + onDeleteFiles?: (fileIds: string[]) => Promise; + onDeleteFolders?: (folderIds: string[]) => Promise; +} + +/* ── Helpers ───────────────────────────────────────────────────────────── */ + +function _buildTree(folders: FolderNode[]): FolderNode[] { + const map = new Map(); + const roots: FolderNode[] = []; + for (const f of folders) map.set(f.id, { ...f, children: [] }); + for (const f of folders) { + const node = map.get(f.id)!; + if (f.parentId && map.has(f.parentId)) { + map.get(f.parentId)!.children!.push(node); + } else { + roots.push(node); + } + } + const _sortLevel = (nodes: FolderNode[]) => { + nodes.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })); + for (const n of nodes) { + if (n.children && n.children.length > 0) _sortLevel(n.children); + } + }; + _sortLevel(roots); + return roots; +} + +function _groupFilesByFolder(files: FileNode[]): Map { + const map = new Map(); + for (const f of files) { + const key = f.folderId || ''; + if (!map.has(key)) map.set(key, []); + map.get(key)!.push(f); + } + for (const [, arr] of map) { + arr.sort((a, b) => a.fileName.localeCompare(b.fileName, undefined, { sensitivity: 'base' })); + } + return map; +} + +function _computeFlatList( + tree: FolderNode[], + expandedIds: Set, + showFiles: boolean, + filesByFolder: Map, +): TreeItem[] { + const result: TreeItem[] = []; + const _walk = (nodes: FolderNode[]) => { + for (const node of nodes) { + result.push({ id: node.id, type: 'folder', name: node.name }); + if (expandedIds.has(node.id)) { + if (node.children) _walk(node.children); + if (showFiles) { + for (const f of (filesByFolder.get(node.id) || [])) { + result.push({ id: f.id, type: 'file', name: f.fileName }); + } + } + } + } + }; + _walk(tree); + if (showFiles) { + for (const f of (filesByFolder.get('') || [])) { + result.push({ id: f.id, type: 'file', name: f.fileName }); + } + } + return result; +} + +function _fileIcon(mime?: string): string { + if (!mime) return '\uD83D\uDCC4'; + if (mime.startsWith('image/')) return '\uD83D\uDDBC\uFE0F'; + if (mime.includes('pdf')) return '\uD83D\uDCD5'; + if (mime.includes('word') || mime.includes('docx')) return '\uD83D\uDCD8'; + if (mime.includes('sheet') || mime.includes('xlsx') || mime.includes('csv')) return '\uD83D\uDCCA'; + if (mime.includes('presentation') || mime.includes('pptx')) return '\uD83D\uDCD9'; + if (mime.includes('zip') || mime.includes('tar') || mime.includes('gz')) return '\uD83D\uDCE6'; + if (mime.startsWith('text/') || mime.includes('json') || mime.includes('xml')) return '\uD83D\uDCDD'; + if (mime.startsWith('audio/')) return '\uD83C\uDFB5'; + if (mime.startsWith('video/')) return '\uD83C\uDFA5'; + return '\uD83D\uDCC4'; +} + +/* ── Selection context threaded through the tree ──────────────────────── */ + +interface SelectionCtx { + selectedItemIds: Set; + selectedFileIds: string[]; + selectedFolderIds: string[]; + onItemClick: (id: string, type: 'file' | 'folder', e: React.MouseEvent) => void; + onItemDragStart: (e: React.DragEvent, id: string, type: 'file' | 'folder', name: string) => void; + onRenameFile?: (fileId: string, newName: string) => Promise; + onDeleteFile?: (fileId: string) => Promise; + onDeleteFiles?: (fileIds: string[]) => Promise; + onDeleteFolders?: (folderIds: string[]) => Promise; +} + +/* ── File node (leaf) ─────────────────────────────────────────────────── */ + +function _FileItem({ file, sel }: { file: FileNode; sel: SelectionCtx }) { + const [dragging, setDragging] = useState(false); + const [renaming, setRenaming] = useState(false); + const [renameValue, setRenameValue] = useState(''); + const isSelected = sel.selectedItemIds.has(file.id); + const multiSelected = sel.selectedItemIds.size > 1; + + const _handleRename = useCallback(async () => { + const trimmed = renameValue.trim(); + if (trimmed && trimmed !== file.fileName && sel.onRenameFile) { + await sel.onRenameFile(file.id, trimmed); + } + setRenaming(false); + }, [renameValue, file.id, file.fileName, sel.onRenameFile]); + + const _handleDeleteFiles = useCallback(async (e: React.MouseEvent) => { + e.stopPropagation(); + if (sel.selectedFileIds.length > 0 && sel.onDeleteFiles) { + await sel.onDeleteFiles(sel.selectedFileIds); + } + }, [sel]); + + const _handleDeleteFolders = useCallback(async (e: React.MouseEvent) => { + e.stopPropagation(); + if (sel.selectedFolderIds.length > 0 && sel.onDeleteFolders) { + await sel.onDeleteFolders(sel.selectedFolderIds); + } + }, [sel]); + + const _handleDeleteSingle = useCallback(async (e: React.MouseEvent) => { + e.stopPropagation(); + if (sel.onDeleteFile) await sel.onDeleteFile(file.id); + }, [file.id, sel]); + + return ( +
sel.onItemClick(file.id, 'file', e)} + draggable + onDragStart={(e) => { + sel.onItemDragStart(e, file.id, 'file', file.fileName); + setDragging(true); + }} + onDragEnd={() => setDragging(false)} + > + {_fileIcon(file.mimeType)} + {renaming ? ( + setRenameValue(e.target.value)} + onBlur={_handleRename} + onKeyDown={(e) => { + if (e.key === 'Enter') _handleRename(); + if (e.key === 'Escape') setRenaming(false); + }} + onClick={(e) => e.stopPropagation()} + /> + ) : ( + {file.fileName} + )} + {!renaming && file.fileSize != null && ( + + {(file.fileSize / 1024).toFixed(0)}K + + )} + {!renaming && ( + + {sel.onRenameFile && !multiSelected && ( + + )} + {multiSelected && isSelected ? ( + <> + {sel.selectedFolderIds.length > 0 && sel.onDeleteFolders && ( + + )} + {sel.selectedFileIds.length > 0 && sel.onDeleteFiles && ( + + )} + + ) : ( + (sel.onDeleteFile || sel.onDeleteFiles) && ( + + ) + )} + + )} +
+ ); +} + +/* ── Tree node (folder) ───────────────────────────────────────────────── */ + +interface TreeNodeProps { + node: FolderNode; + depth: number; + selectedFolderId: string | null; + expandedIds: Set; + showFiles: boolean; + filesByFolder: Map; + sel: SelectionCtx; + onToggle: (id: string) => void; + onSelect: (id: string | null) => void; + onCreateFolder?: (name: string, parentId: string | null) => Promise; + onRenameFolder?: (folderId: string, newName: string) => Promise; + onDeleteFolder?: (folderId: string) => Promise; + onMoveFolder?: (folderId: string, targetParentId: string | null) => Promise; + onMoveFolders?: (folderIds: string[], targetParentId: string | null) => Promise; + onMoveFile?: (fileId: string, targetFolderId: string | null) => Promise; + onMoveFiles?: (fileIds: string[], targetFolderId: string | null) => Promise; +} + +function _TreeNode({ + node, depth, selectedFolderId, expandedIds, showFiles, filesByFolder, sel, + onToggle, onSelect, + onCreateFolder, onRenameFolder, onDeleteFolder, onMoveFolder, onMoveFolders, onMoveFile, onMoveFiles, +}: TreeNodeProps) { + const [renaming, setRenaming] = useState(false); + const [renameValue, setRenameValue] = useState(node.name); + const [dropOver, setDropOver] = useState(false); + const [dragging, setDragging] = useState(false); + const inputRef = useRef(null); + const isExpanded = expandedIds.has(node.id); + const isNavSelected = selectedFolderId === node.id; + const isMultiSelected = sel.selectedItemIds.has(node.id); + const folderFiles = showFiles ? (filesByFolder.get(node.id) || []) : []; + const hasChildren = (node.children && node.children.length > 0) || folderFiles.length > 0; + + useEffect(() => { + if (renaming && inputRef.current) inputRef.current.focus(); + }, [renaming]); + + const _handleRename = useCallback(async () => { + const trimmed = renameValue.trim(); + if (trimmed && trimmed !== node.name && onRenameFolder) { + await onRenameFolder(node.id, trimmed); + } + setRenaming(false); + }, [renameValue, node.id, node.name, onRenameFolder]); + + const _handleAdd = useCallback(async (e: React.MouseEvent) => { + e.stopPropagation(); + if (!onCreateFolder) return; + const name = prompt('Neuer Ordnername:'); + if (name?.trim()) { + await onCreateFolder(name.trim(), node.id); + if (!expandedIds.has(node.id)) onToggle(node.id); + } + }, [onCreateFolder, node.id, expandedIds, onToggle]); + + const _handleDeleteSingle = useCallback(async (e: React.MouseEvent) => { + e.stopPropagation(); + if (onDeleteFolder) await onDeleteFolder(node.id); + }, [onDeleteFolder, node.id]); + + const _handleDeleteFolders = useCallback(async (e: React.MouseEvent) => { + e.stopPropagation(); + if (sel.selectedFolderIds.length > 0 && sel.onDeleteFolders) { + await sel.onDeleteFolders(sel.selectedFolderIds); + } + }, [sel]); + + const _handleDeleteFiles = useCallback(async (e: React.MouseEvent) => { + e.stopPropagation(); + if (sel.selectedFileIds.length > 0 && sel.onDeleteFiles) { + await sel.onDeleteFiles(sel.selectedFileIds); + } + }, [sel]); + + const _handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + setDropOver(true); + }, []); + + const _handleDragLeave = useCallback(() => setDropOver(false), []); + + const _handleDrop = useCallback(async (e: React.DragEvent) => { + e.preventDefault(); + setDropOver(false); + + const treeItemsJson = e.dataTransfer.getData('application/tree-items'); + if (treeItemsJson) { + const items: TreeItem[] = JSON.parse(treeItemsJson); + const fileIds = items.filter(i => i.type === 'file').map(i => i.id); + const folderIds = items.filter(i => i.type === 'folder' && i.id !== node.id).map(i => i.id); + + if (folderIds.length > 0 && onMoveFolders) { + await onMoveFolders(folderIds, node.id); + } else if (onMoveFolder) { + for (const fId of folderIds) await onMoveFolder(fId, node.id); + } + if (fileIds.length > 0 && onMoveFiles) { + await onMoveFiles(fileIds, node.id); + } else if (fileIds.length > 0 && onMoveFile) { + for (const fId of fileIds) await onMoveFile(fId, node.id); + } + return; + } + + const folderId = e.dataTransfer.getData('application/folder-id'); + const fileIdsJson = e.dataTransfer.getData('application/file-ids'); + const fileId = e.dataTransfer.getData('application/file-id'); + if (folderId && folderId !== node.id && onMoveFolder) { + await onMoveFolder(folderId, node.id); + } else if (fileIdsJson && onMoveFiles) { + await onMoveFiles(JSON.parse(fileIdsJson), node.id); + } else if (fileId && onMoveFile) { + await onMoveFile(fileId, node.id); + } + }, [node.id, onMoveFolder, onMoveFolders, onMoveFile, onMoveFiles]); + + const nodeClasses = [ + styles.treeNode, + isNavSelected && !isMultiSelected ? styles.selected : '', + isMultiSelected ? styles.multiSelected : '', + dropOver ? styles.dropTarget : '', + dragging ? styles.dragging : '', + ].filter(Boolean).join(' '); + + return ( +
+
sel.onItemClick(node.id, 'folder', e)} + draggable + onDragStart={(e) => { + sel.onItemDragStart(e, node.id, 'folder', node.name); + setDragging(true); + }} + onDragEnd={() => setDragging(false)} + onDragOver={_handleDragOver} + onDragLeave={_handleDragLeave} + onDrop={_handleDrop} + > + { e.stopPropagation(); if (hasChildren) onToggle(node.id); }} + > + + + + {isExpanded ? : } + + {renaming ? ( + setRenameValue(e.target.value)} + onBlur={_handleRename} + onKeyDown={(e) => { + if (e.key === 'Enter') _handleRename(); + if (e.key === 'Escape') setRenaming(false); + }} + onClick={(e) => e.stopPropagation()} + /> + ) : ( + {node.name} + )} + + {onCreateFolder && !(isMultiSelected && sel.selectedItemIds.size > 1) && ( + + )} + {onRenameFolder && !(isMultiSelected && sel.selectedItemIds.size > 1) && ( + + )} + {isMultiSelected && sel.selectedItemIds.size > 1 ? ( + <> + {sel.selectedFolderIds.length > 0 && sel.onDeleteFolders && ( + + )} + {sel.selectedFileIds.length > 0 && sel.onDeleteFiles && ( + + )} + + ) : onDeleteFolder && ( + + )} + +
+ {isExpanded && hasChildren && ( +
+ {node.children!.map((child) => ( + <_TreeNode + key={child.id} + node={child} + depth={depth + 1} + selectedFolderId={selectedFolderId} + expandedIds={expandedIds} + showFiles={showFiles} + filesByFolder={filesByFolder} + sel={sel} + onToggle={onToggle} + onSelect={onSelect} + onCreateFolder={onCreateFolder} + onRenameFolder={onRenameFolder} + onDeleteFolder={onDeleteFolder} + onMoveFolder={onMoveFolder} + onMoveFolders={onMoveFolders} + onMoveFile={onMoveFile} + onMoveFiles={onMoveFiles} + /> + ))} + {folderFiles.map((file) => ( + <_FileItem key={file.id} file={file} sel={sel} /> + ))} +
+ )} +
+ ); +} + +/* ── Root component ────────────────────────────────────────────────────── */ + +export default function FolderTree({ + folders, files, showFiles = false, selectedFolderId, onSelect, onFileSelect, + selectedItemIds: externalSelectedIds, onSelectionChange, + expandedIds: externalExpandedIds, onToggleExpand, + onCreateFolder, onRenameFolder, onDeleteFolder, onMoveFolder, onMoveFolders, onMoveFile, onMoveFiles, + onRenameFile, onDeleteFile, onDeleteFiles, onDeleteFolders, onRefresh, +}: FolderTreeProps) { + const [internalExpandedIds, setInternalExpandedIds] = useState>(new Set()); + const [rootDropOver, setRootDropOver] = useState(false); + const [internalSelectedIds, setInternalSelectedIds] = useState>(new Set()); + const lastClickedIdRef = useRef(null); + + const expandedIds = externalExpandedIds ?? internalExpandedIds; + + const tree = useMemo(() => _buildTree(folders), [folders]); + const filesByFolder = useMemo(() => _groupFilesByFolder(files || []), [files]); + const rootFiles = showFiles ? (filesByFolder.get('') || []) : []; + + const selectedItemIds = externalSelectedIds ?? internalSelectedIds; + + const flatList = useMemo( + () => _computeFlatList(tree, expandedIds, showFiles, filesByFolder), + [tree, expandedIds, showFiles, filesByFolder], + ); + + const _handleToggle = useCallback((id: string) => { + if (onToggleExpand) { + onToggleExpand(id); + return; + } + setInternalExpandedIds((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); else next.add(id); + return next; + }); + }, []); + + const _setSelection = useCallback((ids: Set) => { + if (onSelectionChange) { + onSelectionChange(ids); + } else { + setInternalSelectedIds(ids); + } + }, [onSelectionChange]); + + const _handleItemClick = useCallback((id: string, type: 'file' | 'folder', e: React.MouseEvent) => { + if (e.ctrlKey || e.metaKey) { + const next = new Set(selectedItemIds); + if (next.has(id)) next.delete(id); else next.add(id); + _setSelection(next); + lastClickedIdRef.current = id; + return; + } + + if (e.shiftKey && lastClickedIdRef.current) { + const lastIdx = flatList.findIndex(i => i.id === lastClickedIdRef.current); + const currIdx = flatList.findIndex(i => i.id === id); + if (lastIdx >= 0 && currIdx >= 0) { + const [from, to] = lastIdx < currIdx ? [lastIdx, currIdx] : [currIdx, lastIdx]; + const next = new Set(selectedItemIds); + for (let i = from; i <= to; i++) next.add(flatList[i].id); + _setSelection(next); + } + return; + } + + _setSelection(new Set([id])); + lastClickedIdRef.current = id; + if (type === 'folder') onSelect(id); + if (type === 'file') onFileSelect?.(id); + }, [selectedItemIds, flatList, _setSelection, onSelect, onFileSelect]); + + const _handleItemDragStart = useCallback((e: React.DragEvent, id: string, type: 'file' | 'folder', name: string) => { + const isInSelection = selectedItemIds.has(id) && selectedItemIds.size > 1; + + if (isInSelection) { + const items: TreeItem[] = []; + for (const selId of selectedItemIds) { + const item = flatList.find(i => i.id === selId); + if (item) items.push(item); + } + e.dataTransfer.setData('application/tree-items', JSON.stringify(items)); + const fileIds = items.filter(i => i.type === 'file').map(i => i.id); + if (fileIds.length > 0) { + e.dataTransfer.setData('application/file-ids', JSON.stringify(fileIds)); + } + } else { + e.dataTransfer.setData('application/tree-items', JSON.stringify([{ id, type, name }])); + if (type === 'file') { + e.dataTransfer.setData('application/file-id', id); + } else { + e.dataTransfer.setData('application/folder-id', id); + } + } + e.dataTransfer.effectAllowed = 'move'; + }, [selectedItemIds, flatList]); + + const allFileIds = useMemo(() => { + const ids = new Set(); + for (const [, arr] of filesByFolder) for (const f of arr) ids.add(f.id); + return ids; + }, [filesByFolder]); + + const allFolderIds = useMemo(() => { + const ids = new Set(); + const _collect = (nodes: FolderNode[]) => { for (const n of nodes) { ids.add(n.id); if (n.children) _collect(n.children); } }; + _collect(tree); + return ids; + }, [tree]); + + const sel: SelectionCtx = useMemo(() => { + const selFileIds = Array.from(selectedItemIds).filter(id => allFileIds.has(id)); + const selFolderIds = Array.from(selectedItemIds).filter(id => allFolderIds.has(id)); + return { + selectedItemIds, + selectedFileIds: selFileIds, + selectedFolderIds: selFolderIds, + onItemClick: _handleItemClick, + onItemDragStart: _handleItemDragStart, + onRenameFile, + onDeleteFile, + onDeleteFiles, + onDeleteFolders, + }; + }, [selectedItemIds, allFileIds, allFolderIds, _handleItemClick, _handleItemDragStart, onRenameFile, onDeleteFile, onDeleteFiles, onDeleteFolders]); + + const _handleRootDrop = useCallback(async (e: React.DragEvent) => { + e.preventDefault(); + setRootDropOver(false); + + const treeItemsJson = e.dataTransfer.getData('application/tree-items'); + if (treeItemsJson) { + const items: TreeItem[] = JSON.parse(treeItemsJson); + const fileIds = items.filter(i => i.type === 'file').map(i => i.id); + const folderIds = items.filter(i => i.type === 'folder').map(i => i.id); + if (folderIds.length > 0 && onMoveFolders) { + await onMoveFolders(folderIds, null); + } else if (onMoveFolder) { + for (const fId of folderIds) await onMoveFolder(fId, null); + } + if (fileIds.length > 0 && onMoveFiles) { + await onMoveFiles(fileIds, null); + } else if (fileIds.length > 0 && onMoveFile) { + for (const fId of fileIds) await onMoveFile(fId, null); + } + return; + } + + const folderId = e.dataTransfer.getData('application/folder-id'); + const fileIdsJson = e.dataTransfer.getData('application/file-ids'); + const fileId = e.dataTransfer.getData('application/file-id'); + if (folderId && onMoveFolder) { + await onMoveFolder(folderId, null); + } else if (fileIdsJson && onMoveFiles) { + await onMoveFiles(JSON.parse(fileIdsJson), null); + } else if (fileId && onMoveFile) { + await onMoveFile(fileId, null); + } + }, [onMoveFolder, onMoveFolders, onMoveFile, onMoveFiles]); + + const rootClasses = [ + styles.treeNode, + selectedFolderId === null ? styles.selected : '', + rootDropOver ? styles.dropTarget : '', + ].filter(Boolean).join(' '); + + return ( +
+
{ onSelect(null); _setSelection(new Set()); }} + onDragOver={(e) => { e.preventDefault(); setRootDropOver(true); }} + onDragLeave={() => setRootDropOver(false)} + onDrop={_handleRootDrop} + > + + (Global) + + {onRefresh && ( + + )} + {onCreateFolder && ( + + )} + +
+
+ {tree.map((node) => ( + <_TreeNode + key={node.id} + node={node} + depth={1} + selectedFolderId={selectedFolderId} + expandedIds={expandedIds} + showFiles={showFiles} + filesByFolder={filesByFolder} + sel={sel} + onToggle={_handleToggle} + onSelect={onSelect} + onCreateFolder={onCreateFolder} + onRenameFolder={onRenameFolder} + onDeleteFolder={onDeleteFolder} + onMoveFolder={onMoveFolder} + onMoveFolders={onMoveFolders} + onMoveFile={onMoveFile} + onMoveFiles={onMoveFiles} + /> + ))} + {rootFiles.map((file) => ( + <_FileItem key={file.id} file={file} sel={sel} /> + ))} +
+
+ ); +} diff --git a/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx b/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx index 3ba1764..f97d6d8 100644 --- a/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx +++ b/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx @@ -171,6 +171,8 @@ export interface FormGeneratorTableProps { groupRowData?: (groupKey: string, groupRows: T[]) => Record; groupDefaultExpanded?: boolean; groupActions?: (groupKey: string, groupRows: T[]) => React.ReactNode; + rowDraggable?: boolean; + onRowDragStart?: (e: React.DragEvent, row: T) => void; } export function FormGeneratorTable>({ @@ -208,7 +210,9 @@ export function FormGeneratorTable>({ groupRenderer: _groupRenderer, groupRowData, groupDefaultExpanded = true, - groupActions + groupActions, + rowDraggable = false, + onRowDragStart, }: FormGeneratorTableProps) { const { t, currentLanguage: contextLanguage } = useLanguage(); // When only onDelete is provided, use it for multi-delete too so Delete stays visible with 2+ selected @@ -282,7 +286,7 @@ export function FormGeneratorTable>({ // Track if we've loaded from localStorage for this storage key const loadedStorageKeyRef = useRef(null); - // Check if backend pagination is supported (hookData has refetch that accepts params) + // Check if backend pagination is supported (hookData has refetch that accepts params). const supportsBackendPagination = hookData?.refetch && typeof hookData.refetch === 'function'; // Debounce search term for backend calls @@ -1971,6 +1975,8 @@ export function FormGeneratorTable>({ key={`${groupKey}-row-${rowIndex}`} className={`${styles.tr} ${selectedRows.has(globalIndex) ? styles.selected : ''} ${onRowClick ? styles.clickable : ''}`} onClick={() => onRowClick?.(row, globalIndex)} + draggable={rowDraggable} + onDragStart={rowDraggable && onRowDragStart ? (e) => onRowDragStart(e, row) : undefined} {...Object.fromEntries( Object.entries(dataAttributes).map(([key, value]) => [`data-${key}`, value]) )} @@ -2084,6 +2090,8 @@ export function FormGeneratorTable>({ key={index} className={`${styles.tr} ${selectedRows.has(index) ? styles.selected : ''} ${onRowClick ? styles.clickable : ''}`} onClick={() => onRowClick?.(row, index)} + draggable={rowDraggable} + onDragStart={rowDraggable && onRowDragStart ? (e) => onRowDragStart(e, row) : undefined} {...Object.fromEntries( Object.entries(dataAttributes).map(([key, value]) => [`data-${key}`, value]) )} diff --git a/src/contexts/FileContext.tsx b/src/contexts/FileContext.tsx index 4d20e64..df0ca11 100644 --- a/src/contexts/FileContext.tsx +++ b/src/contexts/FileContext.tsx @@ -1,5 +1,9 @@ -import React, { createContext, useContext, useCallback } from 'react'; +import React, { createContext, useContext, useCallback, useState, useEffect } from 'react'; +import api from '../api'; import { useUserFiles, useFileOperations, UserFile } from '../hooks/useFiles'; +import type { FolderInfo } from '../api/fileApi'; + +export type { FolderInfo }; interface FileContextType { files: UserFile[]; @@ -14,6 +18,18 @@ interface FileContextType { deletingFiles: Set; previewingFiles: Set; downloadingFiles: Set; + folders: FolderInfo[]; + foldersLoading: boolean; + refreshFolders: () => Promise; + handleCreateFolder: (name: string, parentId: string | null) => Promise; + handleRenameFolder: (folderId: string, newName: string) => Promise; + handleDeleteFolder: (folderId: string) => Promise; + handleMoveFolder: (folderId: string, targetParentId: string | null) => Promise; + handleMoveFile: (fileId: string, targetFolderId: string | null) => Promise; + handleMoveFiles: (fileIds: string[], targetFolderId: string | null) => Promise; + handleMoveFolders: (folderIds: string[], targetParentId: string | null) => Promise; + expandedFolderIds: Set; + toggleFolderExpanded: (id: string) => void; } export const FileContext = createContext(undefined); @@ -31,45 +47,102 @@ export function FileProvider({ children }: { children: React.ReactNode }) { downloadingFiles } = useFileOperations(); - // Centralized file upload that updates the shared state + useEffect(() => { refetchFiles(); }, []); + + // ── Folder expanded state (persisted in localStorage) ─────────────────── + const _STORAGE_KEY = 'folderTree-expandedIds'; + const [expandedFolderIds, setExpandedFolderIds] = useState>(() => { + try { + const stored = localStorage.getItem(_STORAGE_KEY); + return stored ? new Set(JSON.parse(stored)) : new Set(); + } catch { return new Set(); } + }); + + const toggleFolderExpanded = useCallback((id: string) => { + setExpandedFolderIds(prev => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); else next.add(id); + try { localStorage.setItem(_STORAGE_KEY, JSON.stringify([...next])); } catch {} + return next; + }); + }, []); + + // ── Folder state (single source of truth) ────────────────────────────── + const [folders, setFolders] = useState([]); + 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]); + + 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 } }); + await refreshFolders(); + await refetchFiles(); + }, [refreshFolders, refetchFiles]); + + const handleMoveFolder = useCallback(async (folderId: string, targetParentId: string | null) => { + await api.post(`/api/files/folders/${folderId}/move`, { targetParentId }); + await refreshFolders(); + }, [refreshFolders]); + + const handleMoveFile = useCallback(async (fileId: string, targetFolderId: string | null) => { + await api.post(`/api/files/${fileId}/move`, { targetFolderId }); + await refetchFiles(); + }, [refetchFiles]); + + const handleMoveFiles = useCallback(async (fileIds: string[], targetFolderId: string | null) => { + await api.post('/api/files/batch-move', { fileIds, targetFolderId }); + await refetchFiles(); + }, [refetchFiles]); + + const handleMoveFolders = useCallback(async (folderIds: string[], targetParentId: string | null) => { + await api.post('/api/files/batch-move', { folderIds, targetParentId }); + await refreshFolders(); + }, [refreshFolders]); + + // ── File operations ──────────────────────────────────────────────────── + const handleFileUpload = useCallback(async (file: File, workflowId?: string) => { const result = await hookHandleFileUpload(file, workflowId); - if (result.success && result.fileData) { - // The API response structure: { message, file: FileInfo, ... } - // The file data is nested in the 'file' property - const responseData = result.fileData; - const fileData = responseData.file || responseData; // Support both nested and direct structure - - if (!fileData || !fileData.id) { - console.error('File upload response missing file data:', responseData); - return result; - } - - // File will be added via refetch - // Refetch to ensure we have the latest data (this will update all consumers) await refetchFiles(); } - return result; }, [hookHandleFileUpload, refetchFiles]); - // Centralized file delete that updates the shared state const handleFileDelete = useCallback(async (fileId: string, onOptimisticDelete?: () => void) => { const success = await hookHandleFileDelete(fileId, () => { removeFileOptimistically(fileId); onOptimisticDelete?.(); }); - if (success) { - // Refetch to ensure we have the latest data await refetchFiles(); } - return success; }, [hookHandleFileDelete, removeFileOptimistically, refetchFiles]); - // Expose refetch function const refetch = useCallback(async () => { await refetchFiles(); }, [refetchFiles]); @@ -86,12 +159,23 @@ export function FileProvider({ children }: { children: React.ReactNode }) { handleFilePreview: handleFilePreview as FileContextType['handleFilePreview'], handleFileDownload: async (fileId: string, fileName: string) => { await handleFileDownload(fileId, fileName); - // Return void (ignore boolean return value) }, uploadingFile, deletingFiles, previewingFiles, - downloadingFiles + downloadingFiles, + folders, + foldersLoading, + refreshFolders, + handleCreateFolder, + handleRenameFolder, + handleDeleteFolder, + handleMoveFolder, + handleMoveFile, + handleMoveFiles, + handleMoveFolders, + expandedFolderIds, + toggleFolderExpanded, }} > {children} @@ -106,4 +190,3 @@ export function useFileContext() { } return context; } - diff --git a/src/hooks/useCommcoach.ts b/src/hooks/useCommcoach.ts index b5f3bf0..1e14ed1 100644 --- a/src/hooks/useCommcoach.ts +++ b/src/hooks/useCommcoach.ts @@ -15,8 +15,7 @@ import { type CoachingContext, type CoachingSession, type CoachingMessage, type CoachingTask, type CoachingScore, type SSEEvent, } from '../api/commcoachApi'; - -export type TtsEvent = 'playing' | 'ended' | 'paused' | 'error'; +import { useTtsPlayback, type TtsEvent } from './useTtsPlayback'; export interface CommcoachHookReturn { contexts: CoachingContext[]; @@ -49,8 +48,11 @@ export interface CommcoachHookReturn { cancelSession: () => Promise; stopTts: () => void; + pauseTts: () => void; resumeTts: () => void; hasAudioToResume: () => boolean; + ttsIsPlaying: boolean; + ttsIsPaused: boolean; onTtsEventRef: MutableRefObject<((event: TtsEvent) => void) | null>; @@ -90,12 +92,21 @@ export function useCommcoach(): CommcoachHookReturn { const [actionLoading, setActionLoading] = useState(null); const isMountedRef = useRef(true); - const currentAudioRef = useRef(null); const abortControllerRef = useRef(null); const onTtsEventRef = useRef<((event: TtsEvent) => void) | null>(null); const onDocumentCreatedRef = useRef<((doc: any) => void) | null>(null); - useEffect(() => { isMountedRef.current = true; return () => { isMountedRef.current = false; }; }, []); + const ttsPlayback = useTtsPlayback({ + onPlaying: () => { (window as any).__dlog?.('TTS-PLAYING'); onTtsEventRef.current?.('playing'); }, + onEnded: () => { (window as any).__dlog?.('TTS-ENDED'); onTtsEventRef.current?.('ended'); }, + onPaused: () => { (window as any).__dlog?.('TTS-PAUSED'); onTtsEventRef.current?.('paused'); }, + onError: () => { (window as any).__dlog?.('TTS-ERROR'); onTtsEventRef.current?.('error'); }, + }); + + useEffect(() => { + isMountedRef.current = true; + return () => { isMountedRef.current = false; }; + }, []); const refreshContexts = useCallback(async () => { if (!instanceId) return; @@ -111,54 +122,21 @@ export function useCommcoach(): CommcoachHookReturn { } }, [request, instanceId]); - const _emitTts = useCallback((event: TtsEvent) => { - (window as any).__dlog?.(`TTS-${event.toUpperCase()}`); - onTtsEventRef.current?.(event); - }, []); - - const _playTtsAudio = useCallback((audioB64: string) => { - if (!audioB64 || !isMountedRef.current) return; - if (currentAudioRef.current) { - currentAudioRef.current.pause(); - currentAudioRef.current = null; - } - try { - const audio = new Audio(`data:audio/mp3;base64,${audioB64}`); - currentAudioRef.current = audio; - audio.onended = () => { - currentAudioRef.current = null; - _emitTts('ended'); - }; - audio.play().then(() => { - _emitTts('playing'); - }).catch(() => { - _emitTts('error'); - }); - } catch { - _emitTts('error'); - } - }, [_emitTts]); - const stopTts = useCallback(() => { - if (currentAudioRef.current) { - currentAudioRef.current.pause(); - _emitTts('paused'); - } - }, [_emitTts]); + ttsPlayback.stop(); + }, [ttsPlayback]); + + const pauseTts = useCallback(() => { + ttsPlayback.pause(); + }, [ttsPlayback]); const resumeTts = useCallback(() => { - if (currentAudioRef.current && currentAudioRef.current.paused) { - currentAudioRef.current.play().then(() => { - _emitTts('playing'); - }).catch(() => { - _emitTts('error'); - }); - } - }, [_emitTts]); + ttsPlayback.resume(); + }, [ttsPlayback]); const hasAudioToResume = useCallback(() => { - return !!(currentAudioRef.current && currentAudioRef.current.paused && currentAudioRef.current.currentTime > 0); - }, []); + return ttsPlayback.isPaused; + }, [ttsPlayback]); const selectContext = useCallback(async (contextId: string, options?: { skipSessionResume?: boolean }) => { if (!instanceId) return; @@ -196,7 +174,7 @@ export function useCommcoach(): CommcoachHookReturn { setMessages(eventData.messages); } } else if (eventType === 'ttsAudio' && eventData?.audio) { - _playTtsAudio(eventData.audio); + ttsPlayback.play(eventData.audio); } if (eventType === 'complete') setIsStreaming(false); }, @@ -210,7 +188,7 @@ export function useCommcoach(): CommcoachHookReturn { } catch (err: any) { if (isMountedRef.current) setError(err.message || 'Fehler beim Laden des Kontexts'); } - }, [request, instanceId, _playTtsAudio]); + }, [request, instanceId, ttsPlayback.play]); const createContext = useCallback(async (title: string, description?: string, category?: string, goals?: string[]) => { if (!instanceId) return; @@ -298,7 +276,7 @@ export function useCommcoach(): CommcoachHookReturn { return [...prev, msg]; }); } else if (eventType === 'ttsAudio' && eventData?.audio) { - _playTtsAudio(eventData.audio); + ttsPlayback.play(eventData.audio); } else if (eventType === 'status' && eventData) { setStreamingStatus(eventData.label || null); } else if (eventType === 'taskCreated' && eventData) { @@ -333,7 +311,7 @@ export function useCommcoach(): CommcoachHookReturn { } finally { if (isMountedRef.current) setActionLoading(null); } - }, [instanceId, selectedContextId, _playTtsAudio]); + }, [instanceId, selectedContextId, ttsPlayback.play]); const sendMessage = useCallback(async (content: string) => { const normalizedContent = content.trim(); @@ -343,10 +321,7 @@ export function useCommcoach(): CommcoachHookReturn { const ac = new AbortController(); abortControllerRef.current = ac; - if (currentAudioRef.current) { - currentAudioRef.current.pause(); - currentAudioRef.current = null; - } + ttsPlayback.stop(); await _unlockAudioForTts(); setError(null); setIsStreaming(true); @@ -396,7 +371,7 @@ export function useCommcoach(): CommcoachHookReturn { }); } else if (eventType === 'ttsAudio' && eventData?.audio) { setError(null); - _playTtsAudio(eventData.audio); + ttsPlayback.play(eventData.audio); } else if (eventType === 'status' && eventData) { setStreamingStatus(eventData.label || null); } else if (eventType === 'taskCreated' && eventData) { @@ -433,14 +408,11 @@ export function useCommcoach(): CommcoachHookReturn { setIsStreaming(false); } } - }, [instanceId, session, _playTtsAudio]); + }, [instanceId, session, ttsPlayback.play]); const sendAudio = useCallback(async (audioBlob: Blob) => { if (!instanceId || !session) return; - if (currentAudioRef.current) { - currentAudioRef.current.pause(); - currentAudioRef.current = null; - } + ttsPlayback.stop(); await _unlockAudioForTts(); setError(null); setIsStreaming(true); @@ -474,7 +446,7 @@ export function useCommcoach(): CommcoachHookReturn { }); } else if (eventType === 'ttsAudio' && eventData?.audio) { setError(null); - _playTtsAudio(eventData.audio); + ttsPlayback.play(eventData.audio); } else if (eventType === 'taskCreated' && eventData) { setTasks(prev => [eventData, ...prev]); } else if (eventType === 'documentCreated' && eventData) { @@ -585,8 +557,10 @@ export function useCommcoach(): CommcoachHookReturn { error, inputValue, setInputValue, selectContext, createContext, archiveContext, startSession: startSessionCb, - sendMessage, sendAudio, completeSession: completeSessionCb, cancelSession: cancelSessionCb, - stopTts, resumeTts, hasAudioToResume, + sendMessage, sendAudio, + completeSession: completeSessionCb, cancelSession: cancelSessionCb, + stopTts, pauseTts, resumeTts, hasAudioToResume, + ttsIsPlaying: ttsPlayback.isPlaying, ttsIsPaused: ttsPlayback.isPaused, onTtsEventRef, actionLoading, toggleTaskStatus, addTask, removeTask, diff --git a/src/hooks/useFiles.ts b/src/hooks/useFiles.ts index f680077..067874e 100644 --- a/src/hooks/useFiles.ts +++ b/src/hooks/useFiles.ts @@ -11,7 +11,8 @@ import { fetchFileById as fetchFileByIdApi, updateFile as updateFileApi, deleteFile as deleteFileApi, - deleteFiles as deleteFilesApi + deleteFiles as deleteFilesApi, + type FolderInfo, } from '../api/fileApi'; // File interfaces - exactly matching backend FileItem model @@ -968,4 +969,87 @@ export function useFileOperations() { handleInlineUpdate, isLoading }; -} \ No newline at end of file +} + +// ── Folder management hook ────────────────────────────────────────────────── + +export function useFolders() { + const [folders, setFolders] = useState([]); + 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, + }; +} \ No newline at end of file diff --git a/src/hooks/useSpeechAudioCapture.ts b/src/hooks/useSpeechAudioCapture.ts new file mode 100644 index 0000000..1b6d7ac --- /dev/null +++ b/src/hooks/useSpeechAudioCapture.ts @@ -0,0 +1,198 @@ +/** + * useVoiceStream — single hook for mic capture + STT streaming. + * + * Starts MediaRecorder, opens a WebSocket to the generic STT endpoint, + * sends audio chunks, and receives interim/final transcripts from + * Google Streaming Recognition on the backend. + * + * No client-side VAD, no segmentation, no recorder restarts. + * Google handles silence detection and endpoint natively. + */ + +import { useCallback, useEffect, useRef, useState } from 'react'; +import api from '../api'; + +export type VoiceStreamStatus = 'idle' | 'connecting' | 'listening' | 'error'; + +export interface VoiceStreamCallbacks { + onInterim?: (text: string) => void; + onFinal?: (text: string) => void; + onStatusChange?: (status: VoiceStreamStatus) => void; + onError?: (error: unknown) => void; +} + +export interface VoiceStreamApi { + status: VoiceStreamStatus; + interimText: string; + start: (language?: string) => Promise; + stop: () => void; +} + +const _RECORDING_CHUNK_MS = 250; +const _MAX_RECONNECT_ATTEMPTS = 3; + +export function useVoiceStream(callbacks: VoiceStreamCallbacks): VoiceStreamApi { + const [status, setStatus] = useState('idle'); + const [interimText, setInterimText] = useState(''); + + const cbRef = useRef(callbacks); + cbRef.current = callbacks; + + const wsRef = useRef(null); + const recorderRef = useRef(null); + const streamRef = useRef(null); + const languageRef = useRef('de-DE'); + const stoppingRef = useRef(false); + const reconnectAttemptsRef = useRef(0); + + const _setStatus = useCallback((next: VoiceStreamStatus) => { + setStatus(next); + cbRef.current.onStatusChange?.(next); + }, []); + + const _pickMimeType = useCallback((): string => { + for (const mime of ['audio/webm;codecs=opus', 'audio/webm', 'audio/mp4']) { + try { if (MediaRecorder.isTypeSupported(mime)) return mime; } catch { /* skip */ } + } + throw new Error('No supported audio MIME type for MediaRecorder'); + }, []); + + const _closeWs = useCallback(() => { + const ws = wsRef.current; + if (!ws) return; + wsRef.current = null; + try { + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: 'close' })); + } + ws.close(); + } catch { /* ignore */ } + }, []); + + const _stopRecorder = useCallback(() => { + const recorder = recorderRef.current; + if (recorder && recorder.state !== 'inactive') { + try { recorder.stop(); } catch { /* ignore */ } + } + recorderRef.current = null; + }, []); + + const _releaseDevices = useCallback(() => { + if (streamRef.current) { + streamRef.current.getTracks().forEach(t => t.stop()); + streamRef.current = null; + } + }, []); + + const stop = useCallback(() => { + stoppingRef.current = true; + _stopRecorder(); + _closeWs(); + _releaseDevices(); + setInterimText(''); + _setStatus('idle'); + stoppingRef.current = false; + }, [_stopRecorder, _closeWs, _releaseDevices, _setStatus]); + + const start = useCallback(async (language?: string) => { + if (status === 'listening' || status === 'connecting') return; + stoppingRef.current = false; + reconnectAttemptsRef.current = 0; + languageRef.current = language || 'de-DE'; + _setStatus('connecting'); + + try { + if (!streamRef.current) { + streamRef.current = await navigator.mediaDevices.getUserMedia({ + audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true, channelCount: 1 }, + }); + } + + const tokenResp = await api.post('/voice-google/stt/token'); + const wsToken: string = tokenResp.data.wsToken; + + const baseURL = api.defaults.baseURL || window.location.origin; + const wsBase = baseURL.replace(/^http/i, 'ws'); + const wsUrl = `${wsBase}/voice-google/stt/stream?wsToken=${encodeURIComponent(wsToken)}`; + + const ws = new WebSocket(wsUrl); + wsRef.current = ws; + + ws.onopen = () => { + if (stoppingRef.current) { ws.close(); return; } + ws.send(JSON.stringify({ type: 'open', language: languageRef.current })); + + const mimeType = _pickMimeType(); + const recorder = new MediaRecorder(streamRef.current!, { mimeType }); + recorderRef.current = recorder; + + recorder.ondataavailable = (event: BlobEvent) => { + if (!event.data || event.data.size === 0) return; + if (ws.readyState !== WebSocket.OPEN) return; + const reader = new FileReader(); + reader.onloadend = () => { + if (ws.readyState !== WebSocket.OPEN) return; + const dataUrl = reader.result as string; + const b64 = dataUrl.split(',')[1]; + if (b64) ws.send(JSON.stringify({ type: 'audio', chunk: b64 })); + }; + reader.readAsDataURL(event.data); + }; + + recorder.start(_RECORDING_CHUNK_MS); + _setStatus('listening'); + }; + + ws.onmessage = (event) => { + try { + const msg = JSON.parse(event.data); + if (msg.type === 'interim' && msg.text) { + setInterimText(msg.text); + cbRef.current.onInterim?.(msg.text); + } else if (msg.type === 'final' && msg.text) { + setInterimText(''); + cbRef.current.onFinal?.(msg.text); + } else if (msg.type === 'error') { + cbRef.current.onError?.(new Error(msg.message || msg.code || 'STT error')); + } else if (msg.type === 'reconnect_required') { + if (reconnectAttemptsRef.current < _MAX_RECONNECT_ATTEMPTS && !stoppingRef.current) { + reconnectAttemptsRef.current++; + _closeWs(); + start(languageRef.current).catch(() => {}); + } + } + } catch { /* ignore parse errors */ } + }; + + ws.onerror = () => { + if (!stoppingRef.current) { + cbRef.current.onError?.(new Error('WebSocket connection error')); + _setStatus('error'); + } + }; + + ws.onclose = () => { + if (!stoppingRef.current) { + _setStatus('idle'); + } + }; + + } catch (err) { + cbRef.current.onError?.(err); + _setStatus('error'); + _releaseDevices(); + throw err; + } + }, [status, _setStatus, _pickMimeType, _closeWs, _releaseDevices]); + + useEffect(() => { + return () => { + stoppingRef.current = true; + _stopRecorder(); + _closeWs(); + _releaseDevices(); + }; + }, [_stopRecorder, _closeWs, _releaseDevices]); + + return { status, interimText, start, stop }; +} diff --git a/src/hooks/useTtsPlayback.ts b/src/hooks/useTtsPlayback.ts new file mode 100644 index 0000000..ecb3edd --- /dev/null +++ b/src/hooks/useTtsPlayback.ts @@ -0,0 +1,79 @@ +/** + * useTtsPlayback — central hook for TTS audio playback. + * + * Plays base64-encoded audio (MP3), manages current playback state, + * emits lifecycle events. Used by all features (CommCoach, Workspace, etc.). + */ + +import { useCallback, useRef, useState } from 'react'; + +export type TtsEvent = 'playing' | 'paused' | 'ended' | 'error'; + +export interface TtsPlaybackCallbacks { + onPlaying?: () => void; + onPaused?: () => void; + onEnded?: () => void; + onError?: () => void; +} + +export interface TtsPlaybackApi { + isPlaying: boolean; + isPaused: boolean; + play: (base64Audio: string, format?: string) => void; + pause: () => void; + resume: () => void; + stop: () => void; +} + +export function useTtsPlayback(callbacks?: TtsPlaybackCallbacks): TtsPlaybackApi { + const [isPlaying, setIsPlaying] = useState(false); + const [isPaused, setIsPaused] = useState(false); + const audioRef = useRef(null); + const cbRef = useRef(callbacks); + cbRef.current = callbacks; + + const _emit = useCallback((event: TtsEvent) => { + if (event === 'playing') { setIsPlaying(true); setIsPaused(false); cbRef.current?.onPlaying?.(); } + else if (event === 'paused') { setIsPaused(true); cbRef.current?.onPaused?.(); } + else if (event === 'ended') { setIsPlaying(false); setIsPaused(false); cbRef.current?.onEnded?.(); } + else if (event === 'error') { setIsPlaying(false); setIsPaused(false); cbRef.current?.onError?.(); } + }, []); + + const stop = useCallback(() => { + if (audioRef.current) { + audioRef.current.pause(); + audioRef.current = null; + } + setIsPlaying(false); + setIsPaused(false); + }, []); + + const play = useCallback((base64Audio: string, format?: string) => { + if (!base64Audio) return; + stop(); + try { + const mimeType = format === 'wav' ? 'audio/wav' : 'audio/mp3'; + const audio = new Audio(`data:${mimeType};base64,${base64Audio}`); + audioRef.current = audio; + audio.onended = () => { audioRef.current = null; _emit('ended'); }; + audio.onpause = () => { if (audioRef.current === audio && audio.currentTime < audio.duration) _emit('paused'); }; + audio.play().then(() => _emit('playing')).catch(() => _emit('error')); + } catch { + _emit('error'); + } + }, [stop, _emit]); + + const pause = useCallback(() => { + if (audioRef.current && !audioRef.current.paused) { + audioRef.current.pause(); + } + }, []); + + const resume = useCallback(() => { + if (audioRef.current && audioRef.current.paused) { + audioRef.current.play().then(() => _emit('playing')).catch(() => _emit('error')); + } + }, [_emit]); + + return { isPlaying, isPaused, play, pause, resume, stop }; +} diff --git a/src/index.css b/src/index.css index f4923b5..082c1dc 100644 --- a/src/index.css +++ b/src/index.css @@ -20,4 +20,13 @@ html, body { margin: 0; padding: 0; font-family: var(--font-family, "DM Sans", sans-serif); -} \ No newline at end of file +} + +tr[data-highlighted="true"] { + animation: rowHighlight 2s ease-out; +} + +@keyframes rowHighlight { + 0% { background: rgba(25, 118, 210, 0.25); } + 100% { background: transparent; } +} \ No newline at end of file diff --git a/src/pages/basedata/FilesPage.tsx b/src/pages/basedata/FilesPage.tsx index 36eb49f..4798e5f 100644 --- a/src/pages/basedata/FilesPage.tsx +++ b/src/pages/basedata/FilesPage.tsx @@ -1,15 +1,20 @@ /** * FilesPage - * - * Page for file management using FormGeneratorTable. - * Follows the pattern established in AdminUsersPage/WorkflowsPage. + * + * Split-view file management: FolderTree on the left, FormGeneratorTable on the right. + * Uses useResizablePanels for the divider. */ -import React, { useState, useMemo, useEffect, useRef } from 'react'; +import React, { useState, useMemo, useEffect, useRef, useCallback } from 'react'; +import api from '../../api'; import { useUserFiles, useFileOperations } from '../../hooks/useFiles'; +import { useFileContext } from '../../contexts/FileContext'; import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable'; import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm'; -import { FaSync, FaFolder, FaUpload, FaDownload, FaEye } from 'react-icons/fa'; +import FolderTree from '../../components/FolderTree/FolderTree'; +import type { FileNode } from '../../components/FolderTree/FolderTree'; +import { useResizablePanels } from '../../hooks/useResizablePanels'; +import { FaSync, FaFolder, FaUpload, FaDownload, FaEye, FaFolderPlus } from 'react-icons/fa'; import { useToast } from '../../contexts/ToastContext'; import styles from '../admin/Admin.module.css'; @@ -18,27 +23,36 @@ interface UserFile { fileName: string; mimeType?: string; fileSize?: number; + folderId?: string | null; + featureInstanceId?: string; [key: string]: any; } export const FilesPage: React.FC = () => { const fileInputRef = useRef(null); const { showSuccess, showError } = useToast(); - - // Data hook + const [selectedFolderId, setSelectedFolderId] = useState(null); + + const { + leftWidth, isDragging, handleMouseDown, containerRef, + } = useResizablePanels({ + storageKey: 'filesPage-panelWidth', + defaultLeftWidth: 22, + minLeftWidth: 15, + maxLeftWidth: 40, + }); + const { data: files, attributes, permissions, - pagination, loading, error, refetch, fetchFileById, updateFileOptimistically, } = useUserFiles(); - - // Operations hook + const { handleFileDownload, handleFileDelete, @@ -53,16 +67,61 @@ export const FilesPage: React.FC = () => { previewingFiles, } = useFileOperations(); + const { + folders, + refreshFolders, + handleCreateFolder, + handleRenameFolder, + handleDeleteFolder, + handleMoveFolder, + handleMoveFolders, + handleMoveFile, + handleMoveFiles: contextMoveFiles, + expandedFolderIds, + toggleFolderExpanded, + } = useFileContext(); + const [editingFile, setEditingFile] = useState(null); + const [selectedFiles, setSelectedFiles] = useState([]); + const [treeSelectedIds, setTreeSelectedIds] = useState>(new Set()); + const [highlightedFileId, setHighlightedFileId] = useState(null); - // Initial fetch - useEffect(() => { - refetch(); - }, []); + useEffect(() => { refetch(); }, []); + + const treeFileNodes: FileNode[] = useMemo(() => { + if (!files) return []; + return files.map((f: UserFile) => ({ + id: f.id, + fileName: f.fileName, + mimeType: f.mimeType, + fileSize: f.fileSize, + folderId: f.folderId ?? null, + })); + }, [files]); + + const _handleTreeFileSelect = useCallback((fileId: string) => { + const file = files?.find((f: UserFile) => 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); + } + }, [files]); + + const filteredFiles = useMemo(() => { + if (!files) return []; + if (selectedFolderId === null) { + return files.filter((f: UserFile) => !f.folderId); + } + return files.filter((f: UserFile) => f.folderId === selectedFolderId); + }, [files, selectedFolderId]); - // Generate columns from attributes - hide internal fields const columns = useMemo(() => { - const hiddenColumns = ['id', 'mandateId', 'featureInstanceId', 'fileHash']; + const hiddenColumns = ['id', 'mandateId', 'fileHash', 'folderId']; const cols = (attributes || []) .filter(attr => !hiddenColumns.includes(attr.name)) @@ -76,9 +135,10 @@ export const FilesPage: React.FC = () => { width: attr.width || 150, minWidth: attr.minWidth || 100, maxWidth: attr.maxWidth || 400, + fkSource: (attr as any).fkSource, + fkDisplayField: (attr as any).fkDisplayField, })); - // Add _createdBy column with FK resolution to show username cols.push({ key: '_createdBy', label: 'Created By', @@ -94,20 +154,15 @@ export const FilesPage: React.FC = () => { return cols; }, [attributes]); - // Check permissions const canCreate = permissions?.create !== 'n'; const canUpdate = permissions?.update !== 'n'; const canDelete = permissions?.delete !== 'n'; - // Handle edit click const handleEditClick = async (file: UserFile) => { const fullFile = await fetchFileById(file.id); - if (fullFile) { - setEditingFile(fullFile as UserFile); - } + if (fullFile) setEditingFile(fullFile as UserFile); }; - // Handle edit submit const handleEditSubmit = async (data: Partial) => { if (!editingFile) return; const result = await handleFileUpdate(editingFile.id, { @@ -119,29 +174,21 @@ export const FilesPage: React.FC = () => { } }; - // Handle delete single file (confirmation handled by DeleteActionButton) const handleDelete = async (file: UserFile) => { const success = await handleFileDelete(file.id); - if (success) { - refetch(); - } + if (success) refetch(); }; - // Handle delete multiple files (confirmation handled by FormGenerator) const handleDeleteMultiple = async (filesToDelete: UserFile[]) => { const ids = filesToDelete.map(f => f.id); const success = await handleFileDeleteMultiple(ids); - if (success) { - refetch(); - } + if (success) refetch(); }; - // Handle download const handleDownload = async (file: UserFile) => { await handleFileDownload(file.id, file.fileName); }; - // Handle preview const handlePreview = async (file: UserFile) => { const result = await handleFilePreview(file.id, file.fileName, file.mimeType); if (result.success && result.previewUrl) { @@ -149,36 +196,19 @@ export const FilesPage: React.FC = () => { } }; - // Handle upload click - const handleUploadClick = () => { - fileInputRef.current?.click(); - }; + const handleUploadClick = () => { fileInputRef.current?.click(); }; - // Handle file selection const handleFileSelect = async (e: React.ChangeEvent) => { const selectedFiles = e.target.files; if (selectedFiles && selectedFiles.length > 0) { let successCount = 0; let errorCount = 0; - for (const file of Array.from(selectedFiles)) { const result = await handleFileUpload(file); - if (result?.success) { - successCount++; - } else { - errorCount++; - } + if (result?.success) successCount++; else errorCount++; } - - // Reset input first - if (fileInputRef.current) { - fileInputRef.current.value = ''; - } - - // Refresh table to show new files + if (fileInputRef.current) fileInputRef.current.value = ''; await refetch(); - - // Show feedback if (successCount > 0) { showSuccess( 'Upload erfolgreich', @@ -190,11 +220,75 @@ export const FilesPage: React.FC = () => { } }; - // Form attributes for edit modal + const _handleNewFolder = useCallback(async () => { + const name = prompt('Neuer Ordnername:'); + if (name?.trim()) { + await handleCreateFolder(name.trim(), selectedFolderId); + } + }, [handleCreateFolder, selectedFolderId]); + + const _onRowDragStart = useCallback((e: React.DragEvent, row: UserFile) => { + const isInSelection = selectedFiles.some(f => f.id === row.id); + if (isInSelection && selectedFiles.length > 1) { + const ids = selectedFiles.map(f => f.id); + e.dataTransfer.setData('application/file-ids', JSON.stringify(ids)); + } else { + e.dataTransfer.setData('application/file-id', row.id); + } + e.dataTransfer.effectAllowed = 'move'; + }, [selectedFiles]); + + const _handleMoveFilePage = useCallback(async (fileId: string, targetFolderId: string | null) => { + await handleMoveFile(fileId, targetFolderId); + await refetch(); + }, [handleMoveFile, refetch]); + + const _handleMoveFiles = useCallback(async (fileIds: string[], targetFolderId: string | null) => { + await contextMoveFiles(fileIds, targetFolderId); + await refetch(); + }, [contextMoveFiles, refetch]); + + const _handleRenameFile = useCallback(async (fileId: string, newName: string) => { + await handleFileUpdate(fileId, { fileName: newName }); + await refetch(); + }, [handleFileUpdate, refetch]); + + const _handleDeleteTreeFile = useCallback(async (fileId: string) => { + await handleFileDelete(fileId); + await refetch(); + }, [handleFileDelete, refetch]); + + const _handleDeleteTreeFiles = useCallback(async (fileIds: string[]) => { + await handleFileDeleteMultiple(fileIds); + await refetch(); + }, [handleFileDeleteMultiple, refetch]); + + const _handleDeleteTreeFolders = useCallback(async (folderIds: string[]) => { + await api.post('/api/files/batch-delete', { folderIds, recursiveFolders: true }); + await refreshFolders(); + await refetch(); + }, [refreshFolders, refetch]); + + const _handleTreeRefresh = useCallback(async () => { + await refetch(); + await refreshFolders(); + }, [refetch, refreshFolders]); + + const _tableRefetch = useCallback(async (params?: any) => { + const nextParams = { ...(params || {}) }; + const nextFilters = { ...(nextParams.filters || {}) }; + nextFilters.folderId = selectedFolderId; + nextParams.filters = nextFilters; + await refetch(nextParams); + }, [refetch, selectedFolderId]); + + useEffect(() => { + _tableRefetch({ page: 1, pageSize: 25 }); + }, [selectedFolderId, _tableRefetch]); + const formAttributes = useMemo(() => { const excludedFields = ['id', 'mandateId', 'fileHash', '_createdBy', '_createdAt', '_modifiedAt', 'creationDate', 'source']; - return (attributes || []) - .filter(attr => !excludedFields.includes(attr.name)); + return (attributes || []).filter(attr => !excludedFields.includes(attr.name)); }, [attributes]); if (error) { @@ -213,7 +307,6 @@ export const FilesPage: React.FC = () => { return (
- {/* Hidden file input */} {

Dateiverwaltung

- - {canCreate && ( - - )}
-
- {loading && (!files || files.length === 0) ? ( -
-
- Lade Dateien... -
- ) : !files || files.length === 0 ? ( -
- -

Keine Dateien vorhanden

-

- Laden Sie eine Datei hoch, um loszulegen. -

+ {/* Split-view container */} +
} + style={{ display: 'flex', flex: 1, overflow: 'hidden', minHeight: 0, position: 'relative' }} + > + {/* Left panel: FolderTree */} +
+ { + await handleDeleteFolder(folderId); + if (selectedFolderId === folderId) setSelectedFolderId(null); + await refetch(); + }} + onMoveFolder={handleMoveFolder} + onMoveFolders={handleMoveFolders} + onMoveFile={_handleMoveFilePage} + onMoveFiles={_handleMoveFiles} + onRenameFile={_handleRenameFile} + onDeleteFile={_handleDeleteTreeFile} + onDeleteFiles={_handleDeleteTreeFiles} + onDeleteFolders={_handleDeleteTreeFolders} + /> +
+ + {/* Resizable divider */} +
{ (e.target as HTMLElement).style.background = 'var(--color-border-hover, #bbb)'; }} + onMouseLeave={(e) => { if (!isDragging) (e.target as HTMLElement).style.background = 'transparent'; }} + /> + + {/* Right panel: File table */} +
+ {/* Toolbar above table */} +
+ {canCreate && ( - )}
- ) : ( - deletingFiles.has(row.id), - }] : []), - ]} - customActions={[ - { - id: 'download', - icon: , - onClick: handleDownload, - title: 'Herunterladen', - loading: (row: UserFile) => downloadingFiles.has(row.id), - }, - { - id: 'preview', - icon: , - onClick: handlePreview, - title: 'Vorschau', - loading: (row: UserFile) => previewingFiles.has(row.id), - }, - ]} - onDelete={handleDelete} - onDeleteMultiple={handleDeleteMultiple} - hookData={{ - refetch, - permissions, - pagination, - handleDelete: handleFileDelete, - handleInlineUpdate, - updateOptimistically: updateFileOptimistically, - }} - emptyMessage="Keine Dateien gefunden" - /> - )} + + {/* Table content */} +
+ {loading && (!files || files.length === 0) ? ( +
+
+ Lade Dateien... +
+ ) : filteredFiles.length === 0 ? ( +
+ +

+ {selectedFolderId ? 'Ordner ist leer' : 'Keine Dateien vorhanden'} +

+

+ {selectedFolderId + ? 'Verschieben Sie Dateien hierher oder laden Sie neue hoch.' + : 'Laden Sie eine Datei hoch, um loszulegen.'} +

+ {canCreate && ( + + )} +
+ ) : ( + setSelectedFiles(rows as UserFile[])} + rowDraggable={true} + onRowDragStart={_onRowDragStart} + getRowDataAttributes={(row: UserFile) => + ({ highlighted: row.id === highlightedFileId ? 'true' : 'false' }) + } + actionButtons={[ + ...(canUpdate ? [{ + type: 'edit' as const, + onAction: handleEditClick, + title: 'Bearbeiten', + }] : []), + ...(canDelete ? [{ + type: 'delete' as const, + title: 'Löschen', + loading: (row: UserFile) => deletingFiles.has(row.id), + }] : []), + ]} + customActions={[ + { + id: 'download', + icon: , + onClick: handleDownload, + title: 'Herunterladen', + loading: (row: UserFile) => downloadingFiles.has(row.id), + }, + { + id: 'preview', + icon: , + onClick: handlePreview, + title: 'Vorschau', + loading: (row: UserFile) => previewingFiles.has(row.id), + }, + ]} + onDelete={handleDelete} + onDeleteMultiple={handleDeleteMultiple} + hookData={{ + refetch: _tableRefetch, + permissions, + handleDelete: handleFileDelete, + handleInlineUpdate, + updateOptimistically: updateFileOptimistically, + }} + emptyMessage="Keine Dateien gefunden" + /> + )} +
+
{/* Edit Modal */} @@ -331,12 +495,7 @@ export const FilesPage: React.FC = () => {
e.stopPropagation()}>

Datei bearbeiten

- +
{formAttributes.length === 0 ? ( diff --git a/src/pages/billing/BillingDataView.tsx b/src/pages/billing/BillingDataView.tsx index 493b5eb..083fa0d 100644 --- a/src/pages/billing/BillingDataView.tsx +++ b/src/pages/billing/BillingDataView.tsx @@ -337,15 +337,51 @@ export const BillingDataView: React.FC = () => { const successParam = searchParams.get('success'); const canceledParam = searchParams.get('canceled'); + const sessionIdParam = searchParams.get('session_id'); useEffect(() => { - if (successParam === 'true') { - setCheckoutMessage({ type: 'success', text: 'Zahlung erfolgreich. Guthaben wird gutgeschrieben.' }); - refetchBalances(); - } else if (canceledParam === 'true') { - setCheckoutMessage({ type: 'error', text: 'Zahlung abgebrochen.' }); - } - }, [successParam, canceledParam, refetchBalances]); + let cancelled = false; + + const _confirmCheckoutIfNeeded = async () => { + if (successParam !== 'true') { + if (canceledParam === 'true' && !cancelled) { + setCheckoutMessage({ type: 'error', text: 'Zahlung abgebrochen.' }); + } + return; + } + + if (!sessionIdParam) { + if (!cancelled) { + setCheckoutMessage({ type: 'success', text: 'Zahlung erfolgreich. Guthaben wird gutgeschrieben.' }); + } + refetchBalances(); + return; + } + + try { + await api.post('/api/billing/checkout/confirm', { sessionId: sessionIdParam }); + if (!cancelled) { + setCheckoutMessage({ type: 'success', text: 'Zahlung erfolgreich. Guthaben wurde verbucht.' }); + } + } catch (err: any) { + const detail = err?.response?.data?.detail; + if (!cancelled) { + setCheckoutMessage({ + type: 'error', + text: detail || 'Zahlung erfolgreich, aber Verbuchung konnte nicht bestaetigt werden.' + }); + } + } finally { + refetchBalances(); + } + }; + + _confirmCheckoutIfNeeded(); + + return () => { + cancelled = true; + }; + }, [successParam, canceledParam, sessionIdParam, refetchBalances]); const _clearStripeParams = useCallback(() => { searchParams.delete('success'); @@ -360,9 +396,16 @@ export const BillingDataView: React.FC = () => { setCheckoutMessage(null); try { const currentUser = getUserDataCache(); + const currentUrl = new URL(window.location.href); + currentUrl.searchParams.delete('success'); + currentUrl.searchParams.delete('canceled'); + currentUrl.searchParams.delete('session_id'); + currentUrl.hash = ''; + const returnUrl = `${currentUrl.origin}${currentUrl.pathname}${currentUrl.search}`; const result = await createCheckoutSession(request, mandateId, { userId: currentUser?.id, amount, + returnUrl, }); if (result?.redirectUrl) { window.location.href = result.redirectUrl; diff --git a/src/pages/views/commcoach/CommcoachDossierView.tsx b/src/pages/views/commcoach/CommcoachDossierView.tsx index 4eb9318..50f0346 100644 --- a/src/pages/views/commcoach/CommcoachDossierView.tsx +++ b/src/pages/views/commcoach/CommcoachDossierView.tsx @@ -6,7 +6,8 @@ */ import React, { useState, useRef, useCallback, useEffect } from 'react'; -import { useCommcoach, type TtsEvent } from '../../../hooks/useCommcoach'; +import { useCommcoach } from '../../../hooks/useCommcoach'; +import { type TtsEvent } from '../../../hooks/useTtsPlayback'; import { useApiRequest } from '../../../hooks/useApi'; import { useInstanceId } from '../../../hooks/useCurrentInstance'; import api from '../../../api'; @@ -46,7 +47,9 @@ export const CommcoachDossierView: React.FC = () => { const sendMessageRef = useRef(coach.sendMessage); sendMessageRef.current = coach.sendMessage; - const voice = useVoiceController((text) => sendMessageRef.current(text)); + const voice = useVoiceController({ + onFinalText: (text) => sendMessageRef.current(text), + }); // #region agent log const debugLogsRef = useRef([]); @@ -116,13 +119,13 @@ export const CommcoachDossierView: React.FC = () => { }, [activeTab, coach.session?.id, voice]); const handleStopTts = useCallback(() => coach.stopTts(), [coach]); + const handlePauseTts = useCallback(() => coach.pauseTts(), [coach]); const handleResumeTts = useCallback(() => coach.resumeTts(), [coach]); const handleSend = useCallback(async () => { if (!coach.inputValue.trim() || coach.isStreaming) return; - voice.cancelPendingSpeech(); await coach.sendMessage(coach.inputValue); - }, [coach, voice]); + }, [coach]); const handleKeyDown = useCallback((e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend(); } @@ -335,7 +338,10 @@ export const CommcoachDossierView: React.FC = () => { Session aktiv
{voice.state === 'botSpeaking' && ( - + <> + + + )} {voice.state === 'interrupted' && coach.hasAudioToResume() && ( diff --git a/src/pages/views/commcoach/useVoiceController.ts b/src/pages/views/commcoach/useVoiceController.ts index 71d8c4b..142d4d6 100644 --- a/src/pages/views/commcoach/useVoiceController.ts +++ b/src/pages/views/commcoach/useVoiceController.ts @@ -4,18 +4,15 @@ * States: idle | listening | botSpeaking | interrupted * Muted: orthogonal boolean flag (independent of main state) * - * Recognition is STOPPED during botSpeaking or when muted=true. - * Recognition is STARTED when entering listening/interrupted AND muted=false. - * Each start() creates a fresh results session (processedIndex resets to 0). + * Uses the generic useVoiceStream hook for mic capture + STT streaming. + * Google Streaming STT handles silence detection natively. */ -import { useState, useRef, useCallback, useEffect } from 'react'; +import { useState, useRef, useCallback } from 'react'; +import { useVoiceStream } from '../../../hooks/useSpeechAudioCapture'; export type VoiceState = 'idle' | 'listening' | 'botSpeaking' | 'interrupted'; -const SILENCE_TIMEOUT_MS = 1000; -const REC_AUTORESTART_DELAY_MS = 300; - export interface VoiceControllerApi { state: VoiceState; muted: boolean; @@ -26,28 +23,25 @@ export interface VoiceControllerApi { ttsPaused: () => void; ttsEnded: () => void; toggleMute: () => void; - cancelPendingSpeech: () => void; } -export function useVoiceController(onMessage: (text: string) => void): VoiceControllerApi { +export interface VoiceControllerCallbacks { + onFinalText?: (text: string) => void | Promise; + onInterimText?: (text: string) => void; +} + +export function useVoiceController(callbacks: VoiceControllerCallbacks): VoiceControllerApi { const [state, setState] = useState('idle'); const [muted, setMuted] = useState(false); - const [liveTranscript, setLiveTranscript] = useState(''); const stateRef = useRef('idle'); const mutedRef = useRef(false); - const streamRef = useRef(null); - const recognitionRef = useRef(null); - const transcriptPartsRef = useRef([]); - const processedIndexRef = useRef(0); - const silenceTimerRef = useRef | null>(null); - const onMessageRef = useRef(onMessage); - onMessageRef.current = onMessage; + const cbRef = useRef(callbacks); + cbRef.current = callbacks; const _dlog = useCallback((tag: string, info?: string) => { const t = new Date(); const ts = `${t.getMinutes()}:${String(t.getSeconds()).padStart(2, '0')}.${String(t.getMilliseconds()).padStart(3, '0')}`; - const entry = `[${ts}] ${tag}${info ? ' ' + info : ''}`; - (window as any).__dlog?.(entry); + (window as any).__dlog?.(`[${ts}] ${tag}${info ? ' ' + info : ''}`); }, []); const _setState = useCallback((next: VoiceState) => { @@ -64,183 +58,51 @@ export function useVoiceController(onMessage: (text: string) => void): VoiceCont _dlog('MUTED', String(next)); }, [_dlog]); - const _cancelSilenceTimer = useCallback(() => { - if (silenceTimerRef.current) { - clearTimeout(silenceTimerRef.current); - silenceTimerRef.current = null; - } - }, []); - - const _finalizeTranscript = useCallback(() => { - const full = transcriptPartsRef.current.join(' ').trim(); - _dlog('SEND', `"${full.substring(0, 80)}"`); - if (full) onMessageRef.current(full); - transcriptPartsRef.current = []; - setLiveTranscript(''); - }, [_dlog]); - - const _resetSilenceTimer = useCallback(() => { - _cancelSilenceTimer(); - silenceTimerRef.current = setTimeout(() => { - _finalizeTranscript(); - }, SILENCE_TIMEOUT_MS); - }, [_cancelSilenceTimer, _finalizeTranscript]); - - const _startRecognition = useCallback(() => { - if (mutedRef.current) return; - const rec = recognitionRef.current; - if (!rec) return; - try { - rec.start(); - _dlog('REC-START', 'ok'); - } catch { - _dlog('REC-START', 'failed'); - } - }, [_dlog]); - - const _stopRecognition = useCallback(() => { - const rec = recognitionRef.current; - if (!rec) return; - try { - rec.stop(); - } catch { - /* ignore */ - } - }, []); - - const _createRecognition = useCallback(() => { - const SpeechRecognitionApi = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition; - if (!SpeechRecognitionApi) return; - - const recognition = new SpeechRecognitionApi(); - recognition.continuous = true; - recognition.interimResults = true; - recognition.lang = 'de-DE'; - - recognition.onspeechstart = () => { - if (stateRef.current !== 'listening' && stateRef.current !== 'interrupted') return; - _resetSilenceTimer(); - }; - - recognition.onresult = (event: SpeechRecognitionEvent) => { - if (stateRef.current !== 'listening' && stateRef.current !== 'interrupted') return; - const interimParts: string[] = []; - for (let i = processedIndexRef.current; i < event.results.length; i++) { - const r = event.results[i]; - if (r.isFinal) { - const text = r[0].transcript.trim(); - if (text) transcriptPartsRef.current.push(text); - processedIndexRef.current = i + 1; - } else { - const text = r[0].transcript.trim(); - if (text) interimParts.push(text); - } - } - const currentInterim = interimParts.join(' '); - const preview = [...transcriptPartsRef.current, currentInterim].join(' ').trim(); - setLiveTranscript(preview); - if (preview) _resetSilenceTimer(); - }; - - recognition.onspeechend = () => { - if (stateRef.current !== 'listening' && stateRef.current !== 'interrupted') return; - _resetSilenceTimer(); - }; - - recognition.onend = () => { - _dlog('REC-END', `state=${stateRef.current} muted=${mutedRef.current}`); - if (recognitionRef.current !== recognition) return; - const cur = stateRef.current; - if (cur === 'botSpeaking' || cur === 'idle' || mutedRef.current) return; - processedIndexRef.current = 0; - setTimeout(() => { - if (recognitionRef.current !== recognition) return; - if (stateRef.current !== 'listening' && stateRef.current !== 'interrupted') return; - if (mutedRef.current) return; - try { - recognition.start(); - _dlog('REC-AUTOSTART', 'ok'); - } catch { - _dlog('REC-AUTOSTART', 'failed'); - } - }, REC_AUTORESTART_DELAY_MS); - }; - - recognition.onerror = (event: any) => { - _dlog('REC-ERR', event.error); - if (event.error === 'no-speech' || event.error === 'aborted') return; - console.warn('SpeechRecognition error:', event.error); - }; - - recognitionRef.current = recognition; - _startRecognition(); - }, [_dlog, _resetSilenceTimer, _startRecognition]); + const voiceStream = useVoiceStream({ + onFinal: (text) => { + cbRef.current.onFinalText?.(text); + }, + onInterim: (text) => { + cbRef.current.onInterimText?.(text); + }, + onError: (err) => _dlog('VOICE-ERR', String(err)), + }); const activate = useCallback(async () => { if (stateRef.current !== 'idle') return; _setState('listening'); - transcriptPartsRef.current = []; - processedIndexRef.current = 0; - setLiveTranscript(''); - try { - if (!streamRef.current) { - const stream = await navigator.mediaDevices.getUserMedia({ - audio: { echoCancellation: true, noiseSuppression: true }, - }); - streamRef.current = stream; - } - _createRecognition(); + await voiceStream.start('de-DE'); } catch (err) { - console.warn('Mic access failed:', err); + _dlog('MIC-ERR', String(err)); _setState('idle'); } - }, [_setState, _createRecognition]); + }, [_setState, voiceStream, _dlog]); const deactivate = useCallback(() => { - _cancelSilenceTimer(); + voiceStream.stop(); _setState('idle'); - if (recognitionRef.current) { - try { recognitionRef.current.stop(); } catch { /* ignore */ } - recognitionRef.current = null; - } - if (streamRef.current) { - streamRef.current.getTracks().forEach(t => t.stop()); - streamRef.current = null; - } - transcriptPartsRef.current = []; - processedIndexRef.current = 0; - setLiveTranscript(''); - }, [_setState, _cancelSilenceTimer]); + }, [_setState, voiceStream]); const ttsPlaying = useCallback(() => { const cur = stateRef.current; if (cur === 'idle') return; - _cancelSilenceTimer(); - _finalizeTranscript(); - _stopRecognition(); + voiceStream.stop(); _setState('botSpeaking'); - }, [_setState, _cancelSilenceTimer, _finalizeTranscript, _stopRecognition]); + }, [_setState, voiceStream]); const ttsPaused = useCallback(() => { - const cur = stateRef.current; - if (cur !== 'botSpeaking') return; - transcriptPartsRef.current = []; - processedIndexRef.current = 0; - setLiveTranscript(''); + if (stateRef.current !== 'botSpeaking') return; _setState('interrupted'); - _startRecognition(); - }, [_setState, _startRecognition]); + voiceStream.start('de-DE').catch((err) => _dlog('MIC-ERR', String(err))); + }, [_setState, voiceStream, _dlog]); const ttsEnded = useCallback(() => { const cur = stateRef.current; if (cur !== 'botSpeaking' && cur !== 'interrupted') return; - transcriptPartsRef.current = []; - processedIndexRef.current = 0; - setLiveTranscript(''); _setState('listening'); - _startRecognition(); - }, [_setState, _startRecognition]); + voiceStream.start('de-DE').catch((err) => _dlog('MIC-ERR', String(err))); + }, [_setState, voiceStream, _dlog]); const toggleMute = useCallback(() => { const cur = stateRef.current; @@ -248,45 +110,23 @@ export function useVoiceController(onMessage: (text: string) => void): VoiceCont if (mutedRef.current) { _setMuted(false); if (cur === 'listening' || cur === 'interrupted') { - _startRecognition(); + voiceStream.start('de-DE').catch((err) => _dlog('MIC-ERR', String(err))); } } else { _setMuted(true); - _stopRecognition(); + voiceStream.stop(); } - }, [_setMuted, _startRecognition, _stopRecognition]); - - const cancelPendingSpeech = useCallback(() => { - _cancelSilenceTimer(); - transcriptPartsRef.current = []; - setLiveTranscript(''); - _dlog('CANCEL-SPEECH', 'pending speech cleared for text input'); - }, [_cancelSilenceTimer, _dlog]); - - useEffect(() => { - return () => { - if (silenceTimerRef.current) clearTimeout(silenceTimerRef.current); - if (recognitionRef.current) { - try { recognitionRef.current.stop(); } catch { /* ignore */ } - recognitionRef.current = null; - } - if (streamRef.current) { - streamRef.current.getTracks().forEach(t => t.stop()); - streamRef.current = null; - } - }; - }, []); + }, [_setMuted, voiceStream, _dlog]); return { state, muted, - liveTranscript, + liveTranscript: voiceStream.interimText, activate, deactivate, ttsPlaying, ttsPaused, ttsEnded, toggleMute, - cancelPendingSpeech, }; } diff --git a/src/pages/views/workspace/ConversationList.tsx b/src/pages/views/workspace/ConversationList.tsx index 46abf22..c70f5f1 100644 --- a/src/pages/views/workspace/ConversationList.tsx +++ b/src/pages/views/workspace/ConversationList.tsx @@ -303,21 +303,29 @@ export const ConversationList: React.FC = ({ }} /> ) : ( - { e.stopPropagation(); _startEditing(conv); }} - title={conv.name} - > - {conv.name} - + <> + + {_formatTime(conv.lastActivity)} + + { e.stopPropagation(); _startEditing(conv); }} + title={conv.name} + > + {conv.name} + + )} {/* Action buttons (visible on hover) */} @@ -383,29 +391,6 @@ export const ConversationList: React.FC = ({ )}
- {/* Status + last activity */} -
- - {conv.status === 'active' && ( - {'\u25CF'} aktiv - )} - {conv.status === 'completed' && ( - {'\u25CF'} abgeschlossen - )} - {conv.status === 'archived' && ( - {'\u25CF'} archiviert - )} - {!['active', 'completed', 'archived'].includes(conv.status) && ( - {conv.status} - )} - - - {_formatTime(conv.lastActivity)} - -
); })} diff --git a/src/pages/views/workspace/FileBrowser.tsx b/src/pages/views/workspace/FileBrowser.tsx index 6b28842..5c9d3d8 100644 --- a/src/pages/views/workspace/FileBrowser.tsx +++ b/src/pages/views/workspace/FileBrowser.tsx @@ -1,76 +1,84 @@ /** - * FileBrowser -- Tree-structured file browser. + * FileBrowser -- Folder-tree file browser for workspace. * - * Level 1: Feature instance (group header, collapsible) - * Level 2: Files sorted alphabetically - * - * Supports search, drag-and-drop upload, and file selection. + * Uses useFileContext() for folders (shared state with Dateien page). + * Uses FolderTree with showFiles=true so folders and files render inline. */ import React, { useState, useCallback, useRef, useMemo } from 'react'; import api from '../../../api'; -import type { WorkspaceFile, WorkspaceFolder } from './useWorkspace'; +import FolderTree from '../../../components/FolderTree/FolderTree'; +import type { FileNode } from '../../../components/FolderTree/FolderTree'; +import { useFileContext } from '../../../contexts/FileContext'; +import type { WorkspaceFile } from './useWorkspace'; interface FileBrowserProps { instanceId: string; files: WorkspaceFile[]; - folders: WorkspaceFolder[]; onRefresh: () => void; onFileSelect?: (fileId: string) => void; } -interface _InstanceGroup { - instanceId: string; - label: string; - files: WorkspaceFile[]; -} - export const FileBrowser: React.FC = ({ instanceId, files, - folders: _folders, onRefresh, onFileSelect, }) => { const [searchQuery, setSearchQuery] = useState(''); const [isDragOver, setIsDragOver] = useState(false); const [uploading, setUploading] = useState(false); - const [collapsed, setCollapsed] = useState>({}); + const [selectedFolderId, setSelectedFolderId] = useState(null); const fileInputRef = useRef(null); - const _filteredFiles = useMemo(() => { - if (!searchQuery.trim()) return files; - const q = searchQuery.toLowerCase(); - return files.filter(f => - f.fileName.toLowerCase().includes(q) - || (f.tags || []).some(t => t.toLowerCase().includes(q)), - ); + const { + folders, + refreshFolders, + handleCreateFolder, + handleRenameFolder, + handleDeleteFolder, + handleMoveFolder, + handleMoveFolders, + handleMoveFile, + handleMoveFiles: contextMoveFiles, + handleFileDelete, + expandedFolderIds, + toggleFolderExpanded, + } = useFileContext(); + + const _folderNodes = useMemo(() => + folders.map(f => ({ + id: f.id, + name: f.name, + parentId: f.parentId ?? null, + })), + [folders], + ); + + const _fileNodes: FileNode[] = useMemo(() => { + let result: WorkspaceFile[] = files; + if (searchQuery.trim()) { + const q = searchQuery.toLowerCase(); + result = result.filter(f => + f.fileName.toLowerCase().includes(q) + || (f.tags || []).some((t: string) => t.toLowerCase().includes(q)), + ); + } + return result + .sort((a, b) => a.fileName.localeCompare(b.fileName)) + .map(f => ({ + id: f.id, + fileName: f.fileName, + mimeType: f.mimeType, + fileSize: f.fileSize, + folderId: f.folderId ?? null, + })); }, [files, searchQuery]); - const _groups = useMemo((): _InstanceGroup[] => { - const map: Record = {}; - for (const f of _filteredFiles) { - const key = f.featureInstanceId || '_workspace'; - if (!map[key]) { - map[key] = { - instanceId: key, - label: f.featureInstanceLabel || (key === '_workspace' ? 'Workspace' : key.slice(0, 8)), - files: [], - }; - } - map[key].files.push(f); - } - for (const g of Object.values(map)) { - g.files.sort((a, b) => a.fileName.localeCompare(b.fileName)); - } - const groups = Object.values(map); - groups.sort((a, b) => a.label.localeCompare(b.label)); - return groups; - }, [_filteredFiles]); - - const _toggleGroup = (key: string) => { - setCollapsed(prev => ({ ...prev, [key]: !prev[key] })); - }; + const _refreshAll = useCallback(() => { + onRefresh(); + refreshFolders(); + }, [onRefresh, refreshFolders]); const _uploadFiles = useCallback(async (fileList: FileList | File[]) => { if (!instanceId || uploading) return; @@ -84,18 +92,20 @@ export const FileBrowser: React.FC = ({ headers: { 'Content-Type': 'multipart/form-data' }, }); } - onRefresh(); + _refreshAll(); } catch (err) { console.error('File upload failed:', err); } finally { setUploading(false); } - }, [instanceId, uploading, onRefresh]); + }, [instanceId, uploading, _refreshAll]); const _handleDragOver = useCallback((e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - setIsDragOver(true); + if (e.dataTransfer.types.includes('Files')) { + e.preventDefault(); + e.stopPropagation(); + setIsDragOver(true); + } }, []); const _handleDragLeave = useCallback((e: React.DragEvent) => { @@ -120,9 +130,46 @@ export const FileBrowser: React.FC = ({ } }, [_uploadFiles]); + const _onMoveFile = useCallback(async (fileId: string, targetFolderId: string | null) => { + await handleMoveFile(fileId, targetFolderId); + onRefresh(); + }, [handleMoveFile, onRefresh]); + + const _onMoveFiles = useCallback(async (fileIds: string[], targetFolderId: string | null) => { + await contextMoveFiles(fileIds, targetFolderId); + onRefresh(); + }, [contextMoveFiles, onRefresh]); + + const _onDeleteFolder = useCallback(async (folderId: string) => { + await handleDeleteFolder(folderId); + if (selectedFolderId === folderId) setSelectedFolderId(null); + onRefresh(); + }, [handleDeleteFolder, selectedFolderId, onRefresh]); + + const _onRenameFile = useCallback(async (fileId: string, newName: string) => { + await api.put(`/api/files/${fileId}`, { fileName: newName }); + onRefresh(); + }, [onRefresh]); + + const _onDeleteFile = useCallback(async (fileId: string) => { + await handleFileDelete(fileId); + onRefresh(); + }, [handleFileDelete, onRefresh]); + + const _onDeleteFiles = useCallback(async (fileIds: string[]) => { + await api.post('/api/files/batch-delete', { fileIds }); + onRefresh(); + }, [onRefresh]); + + const _onDeleteFolders = useCallback(async (folderIds: string[]) => { + await api.post('/api/files/batch-delete', { folderIds, recursiveFolders: true }); + refreshFolders(); + onRefresh(); + }, [refreshFolders, onRefresh]); + return (
= ({ )} {/* Header */} -
+
Files
- +
@@ -165,94 +212,39 @@ export const FileBrowser: React.FC = ({ onChange={e => setSearchQuery(e.target.value)} style={{ width: '100%', padding: '6px 10px', fontSize: 12, borderRadius: 4, - border: '1px solid #ddd', marginBottom: 8, boxSizing: 'border-box', + border: '1px solid #ddd', boxSizing: 'border-box', }} /> - {/* Tree */} - {_groups.length === 0 && ( + {/* Folder tree with inline files */} + + + {_fileNodes.length === 0 && (
{searchQuery ? 'Keine Dateien gefunden' : 'Keine Dateien. Drag & Drop zum Hochladen.'}
)} - - {_groups.map(group => { - const isCollapsed = !!collapsed[group.instanceId]; - return ( -
- {/* Group header */} -
_toggleGroup(group.instanceId)} - style={{ - display: 'flex', alignItems: 'center', gap: 6, - padding: '5px 6px', cursor: 'pointer', borderRadius: 4, - background: 'var(--bg-secondary, #f5f5f5)', - marginBottom: 2, - }} - onMouseEnter={e => (e.currentTarget.style.background = '#eee')} - onMouseLeave={e => (e.currentTarget.style.background = 'var(--bg-secondary, #f5f5f5)')} - > - - {isCollapsed ? '\u25B6' : '\u25BC'} - - {'\uD83D\uDCC1'} - - {group.label} - - {group.files.length} -
- - {/* Files */} - {!isCollapsed && group.files.map(file => ( -
onFileSelect?.(file.id)} - style={{ - padding: '4px 8px 4px 28px', fontSize: 12, - display: 'flex', alignItems: 'center', gap: 6, - borderRadius: 4, - cursor: onFileSelect ? 'pointer' : 'default', - }} - onMouseEnter={e => (e.currentTarget.style.background = '#f5f5f5')} - onMouseLeave={e => (e.currentTarget.style.background = '')} - > - {_fileIcon(file.mimeType)} -
-
- {file.fileName} -
- {file.tags && file.tags.length > 0 && ( -
- {file.tags.map(tag => ( - - {tag} - - ))} -
- )} -
- - {(file.fileSize / 1024).toFixed(0)}K - -
- ))} -
- ); - })}
); }; - -function _fileIcon(mime: string): string { - if (!mime) return '\uD83D\uDCC4'; - if (mime.startsWith('image/')) return '\uD83D\uDDBC\uFE0F'; - if (mime.includes('pdf')) return '\uD83D\uDCD5'; - if (mime.includes('word') || mime.includes('docx')) return '\uD83D\uDCD8'; - if (mime.includes('sheet') || mime.includes('xlsx') || mime.includes('csv')) return '\uD83D\uDCCA'; - if (mime.includes('presentation') || mime.includes('pptx')) return '\uD83D\uDCD9'; - if (mime.includes('zip') || mime.includes('tar') || mime.includes('gz')) return '\uD83D\uDCE6'; - if (mime.startsWith('text/') || mime.includes('json') || mime.includes('xml')) return '\uD83D\uDCDD'; - if (mime.startsWith('audio/')) return '\uD83C\uDFB5'; - if (mime.startsWith('video/')) return '\uD83C\uDFA5'; - return '\uD83D\uDCC4'; -} diff --git a/src/pages/views/workspace/WorkspaceInput.tsx b/src/pages/views/workspace/WorkspaceInput.tsx index b400553..56bb812 100644 --- a/src/pages/views/workspace/WorkspaceInput.tsx +++ b/src/pages/views/workspace/WorkspaceInput.tsx @@ -1,10 +1,11 @@ /** * WorkspaceInput -- Prompt input with @file autocomplete, attachment bar, - * voice toggle (live transcript via SpeechRecognition), and data source selection. + * voice toggle (generic audio capture hook), and data source selection. */ import React, { useState, useCallback, useRef, useEffect } from 'react'; import { ProviderMultiSelect } from '../../../components/ProviderSelector'; +import { useVoiceStream } from '../../../hooks/useSpeechAudioCapture'; import type { WorkspaceFile, DataSource } from './useWorkspace'; const _STT_LANGUAGES = [ @@ -22,13 +23,16 @@ const _STT_LANGUAGES = [ { code: 'zh-CN', label: 'Chinese' }, ]; -function _getSpeechRecognitionApi(): (new () => SpeechRecognition) | null { - return (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition || null; -} - interface PendingFile { fileId: string; fileName: string; + itemType?: 'file' | 'folder'; +} + +interface TreeItemDrop { + id: string; + type: 'file' | 'folder'; + name: string; } interface WorkspaceInputProps { @@ -45,6 +49,8 @@ interface WorkspaceInputProps { selectedProviders?: string[]; onProvidersChange?: (providers: string[]) => void; isMobile?: boolean; + onTreeItemsDrop?: (items: TreeItemDrop[]) => void; + onPasteAsFile?: (file: File) => void; } export const WorkspaceInput: React.FC = ({ @@ -61,21 +67,22 @@ export const WorkspaceInput: React.FC = ({ selectedProviders = [], onProvidersChange, isMobile = false, + onTreeItemsDrop, + onPasteAsFile, }) => { const [prompt, setPrompt] = useState(''); const [showAutocomplete, setShowAutocomplete] = useState(false); const [autocompleteFilter, setAutocompleteFilter] = useState(''); + const [treeDropOver, setTreeDropOver] = useState(false); const [voiceActive, setVoiceActive] = useState(false); const [voiceLanguage, setVoiceLanguage] = useState(() => localStorage.getItem('workspace_stt_lang') || 'de-DE'); - const [, setLiveTranscript] = useState(''); const [showLangPicker, setShowLangPicker] = useState(false); const [attachedFileIds, setAttachedFileIds] = useState([]); const [attachedDataSourceIds, setAttachedDataSourceIds] = useState([]); const textareaRef = useRef(null); - const recognitionRef = useRef(null); - const transcriptPartsRef = useRef([]); - const processedIndexRef = useRef(0); const promptBeforeVoiceRef = useRef(''); + const finalizedTextRef = useRef(''); + const currentInterimRef = useRef(''); useEffect(() => { localStorage.setItem('workspace_stt_lang', voiceLanguage); @@ -171,98 +178,60 @@ export const WorkspaceInput: React.FC = ({ ); }, []); - const _stopRecognition = useCallback(() => { - if (recognitionRef.current) { - try { recognitionRef.current.stop(); } catch { /* ignore */ } - recognitionRef.current = null; - } - const finalText = transcriptPartsRef.current.join(' ').trim(); - if (finalText) { - setPrompt(() => { - const base = promptBeforeVoiceRef.current; - return base ? `${base} ${finalText}` : finalText; - }); - } - setLiveTranscript(''); - transcriptPartsRef.current = []; - processedIndexRef.current = 0; - setVoiceActive(false); + const _buildPromptFromRefs = useCallback(() => { + const parts = [ + promptBeforeVoiceRef.current, + finalizedTextRef.current, + currentInterimRef.current, + ].filter(Boolean); + return parts.join(' '); }, []); + const voiceStream = useVoiceStream({ + onFinal: (text) => { + finalizedTextRef.current = finalizedTextRef.current + ? `${finalizedTextRef.current} ${text}` + : text; + currentInterimRef.current = ''; + setPrompt(_buildPromptFromRefs()); + }, + onInterim: (text) => { + currentInterimRef.current = text; + setPrompt(_buildPromptFromRefs()); + }, + onError: (error) => { + console.warn('Workspace voice stream error', error); + }, + }); + + const _stopVoiceCapture = useCallback(() => { + if (currentInterimRef.current) { + finalizedTextRef.current = finalizedTextRef.current + ? `${finalizedTextRef.current} ${currentInterimRef.current}` + : currentInterimRef.current; + currentInterimRef.current = ''; + } + setPrompt(_buildPromptFromRefs()); + voiceStream.stop(); + setVoiceActive(false); + }, [voiceStream, _buildPromptFromRefs]); + const _toggleVoice = useCallback(async () => { if (voiceActive) { - _stopRecognition(); - return; - } - - const SpeechRecognitionApi = _getSpeechRecognitionApi(); - if (!SpeechRecognitionApi) { - console.error('SpeechRecognition not supported in this browser'); - return; - } - - try { - await navigator.mediaDevices.getUserMedia({ audio: true }); - } catch { - console.error('Microphone access denied'); + _stopVoiceCapture(); return; } promptBeforeVoiceRef.current = prompt; - transcriptPartsRef.current = []; - processedIndexRef.current = 0; - setLiveTranscript(''); - - const recognition = new SpeechRecognitionApi(); - recognition.continuous = true; - recognition.interimResults = true; - recognition.lang = voiceLanguage; - - recognition.onresult = (event: SpeechRecognitionEvent) => { - const interimParts: string[] = []; - for (let i = processedIndexRef.current; i < event.results.length; i++) { - const r = event.results[i]; - if (r.isFinal) { - const text = r[0].transcript.trim(); - if (text) transcriptPartsRef.current.push(text); - processedIndexRef.current = i + 1; - } else { - const text = r[0].transcript.trim(); - if (text) interimParts.push(text); - } - } - const finalSoFar = transcriptPartsRef.current.join(' '); - const interim = interimParts.join(' '); - const combined = [finalSoFar, interim].filter(Boolean).join(' '); - setLiveTranscript(combined); - - const base = promptBeforeVoiceRef.current; - const display = base ? `${base} ${combined}` : combined; - setPrompt(display); - }; - - recognition.onerror = (event: any) => { - if (event.error === 'no-speech' || event.error === 'aborted') return; - console.warn('SpeechRecognition error:', event.error); - }; - - recognition.onend = () => { - if (!recognitionRef.current) return; - processedIndexRef.current = 0; - setTimeout(() => { - if (!recognitionRef.current) return; - try { recognitionRef.current.start(); } catch { /* ignore */ } - }, 300); - }; - + finalizedTextRef.current = ''; + currentInterimRef.current = ''; try { - recognition.start(); - recognitionRef.current = recognition; setVoiceActive(true); - } catch (err) { - console.error('SpeechRecognition start failed:', err); + await voiceStream.start(voiceLanguage); + } catch { + setVoiceActive(false); } - }, [voiceActive, voiceLanguage, prompt, _stopRecognition]); + }, [voiceActive, prompt, voiceStream, voiceLanguage, _stopVoiceCapture]); const filteredFiles = showAutocomplete ? files.filter(f => f.fileName.toLowerCase().includes(autocompleteFilter)) @@ -272,12 +241,52 @@ export const WorkspaceInput: React.FC = ({ const _horizontalPadding = isMobile ? 12 : 24; const _controlSize = isMobile ? 38 : 40; + const _handlePaste = useCallback((e: React.ClipboardEvent) => { + if (!onPasteAsFile) return; + const text = e.clipboardData.getData('text/plain'); + if (text && text.length >= 1000) { + e.preventDefault(); + const blob = new Blob([text], { type: 'text/plain' }); + const file = new File([blob], `pasted-text-${Date.now()}.txt`, { type: 'text/plain' }); + onPasteAsFile(file); + } + }, [onPasteAsFile]); + + const _handlePromptDragOver = useCallback((e: React.DragEvent) => { + if (e.dataTransfer.types.includes('application/tree-items')) { + e.preventDefault(); + e.dataTransfer.dropEffect = 'copy'; + setTreeDropOver(true); + } + }, []); + + const _handlePromptDragLeave = useCallback(() => setTreeDropOver(false), []); + + const _handlePromptDrop = useCallback((e: React.DragEvent) => { + const treeItemsJson = e.dataTransfer.getData('application/tree-items'); + if (treeItemsJson && onTreeItemsDrop) { + e.preventDefault(); + e.stopPropagation(); + setTreeDropOver(false); + const items: TreeItemDrop[] = JSON.parse(treeItemsJson); + onTreeItemsDrop(items); + } + }, [onTreeItemsDrop]); + return ( -
+
{/* Pending uploaded files */} {pendingFiles.length > 0 && (
= ({ style={{ display: 'inline-flex', alignItems: 'center', gap: 4, padding: '3px 8px', borderRadius: 12, fontSize: 11, - background: '#fff3e0', color: '#e65100', fontWeight: 500, - border: '1px solid #ffe0b2', + background: pf.itemType === 'folder' ? '#e3f2fd' : '#fff3e0', + color: pf.itemType === 'folder' ? '#1565c0' : '#e65100', + fontWeight: 500, + border: `1px solid ${pf.itemType === 'folder' ? '#bbdefb' : '#ffe0b2'}`, }} > - 📎 {pf.fileName.length > 25 ? pf.fileName.slice(0, 25) + '...' : pf.fileName} + {pf.itemType === 'folder' ? '📁' : '📎'} {pf.fileName.length > 25 ? pf.fileName.slice(0, 25) + '...' : pf.fileName} {onRemovePendingFile && ( - -
- -