file system and stt and ttss reevisions

This commit is contained in:
ValueOn AG 2026-03-17 19:19:32 +01:00
parent 7e45b6a638
commit 9e792bc74f
19 changed files with 2211 additions and 739 deletions

View file

@ -99,6 +99,7 @@ export interface CreditAddRequest {
export interface CheckoutCreateRequest {
userId?: string;
amount: number;
returnUrl: string;
}
export interface CheckoutCreateResponse {

View file

@ -176,21 +176,116 @@ export async function deleteFiles(
request: ApiRequestFunction,
fileIds: string[]
): Promise<Array<{ success: boolean; fileId: string; error?: any }>> {
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;
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 }));
}
return { success: false, fileId: fileIds[index], error: result.reason };
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<FolderInfo[]> {
const params: any = {};
if (parentId !== undefined && parentId !== null) {
params.parentId = parentId;
}
const data = await request({
url: '/api/files/folders',
method: 'get',
params,
});
return Array.isArray(data) ? data : [];
}
export async function createFolder(
request: ApiRequestFunction,
name: string,
parentId?: string | null
): Promise<FolderInfo> {
return await request({
url: '/api/files/folders',
method: 'post',
data: { name, parentId: parentId || null },
});
}
export async function renameFolder(
request: ApiRequestFunction,
folderId: string,
name: string
): Promise<any> {
return await request({
url: `/api/files/folders/${folderId}`,
method: 'put',
data: { name },
});
}
export async function deleteFolderApi(
request: ApiRequestFunction,
folderId: string,
recursive: boolean = false
): Promise<any> {
return await request({
url: `/api/files/folders/${folderId}`,
method: 'delete',
params: { recursive },
});
}
export async function moveFolder(
request: ApiRequestFunction,
folderId: string,
targetParentId: string | null
): Promise<any> {
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<any> {
return await request({
url: `/api/files/${fileId}/move`,
method: 'post',
data: { targetFolderId },
});
}

View file

@ -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;
}

View file

@ -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<string>;
onSelectionChange?: (selectedIds: Set<string>) => void;
expandedIds?: Set<string>;
onToggleExpand?: (id: string) => void;
onRefresh?: () => void;
onCreateFolder?: (name: string, parentId: string | null) => Promise<void>;
onRenameFolder?: (folderId: string, newName: string) => Promise<void>;
onDeleteFolder?: (folderId: string) => Promise<void>;
onMoveFolder?: (folderId: string, targetParentId: string | null) => Promise<void>;
onMoveFolders?: (folderIds: string[], targetParentId: string | null) => Promise<void>;
onMoveFile?: (fileId: string, targetFolderId: string | null) => Promise<void>;
onMoveFiles?: (fileIds: string[], targetFolderId: string | null) => Promise<void>;
onRenameFile?: (fileId: string, newName: string) => Promise<void>;
onDeleteFile?: (fileId: string) => Promise<void>;
onDeleteFiles?: (fileIds: string[]) => Promise<void>;
onDeleteFolders?: (folderIds: string[]) => Promise<void>;
}
/* ── Helpers ───────────────────────────────────────────────────────────── */
function _buildTree(folders: FolderNode[]): FolderNode[] {
const map = new Map<string, FolderNode>();
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<string, FileNode[]> {
const map = new Map<string, FileNode[]>();
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<string>,
showFiles: boolean,
filesByFolder: Map<string, FileNode[]>,
): 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<string>;
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<void>;
onDeleteFile?: (fileId: string) => Promise<void>;
onDeleteFiles?: (fileIds: string[]) => Promise<void>;
onDeleteFolders?: (folderIds: string[]) => Promise<void>;
}
/* ── 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 (
<div
className={[
styles.treeNode,
styles.fileNode,
isSelected ? styles.multiSelected : '',
dragging ? styles.dragging : '',
].filter(Boolean).join(' ')}
onClick={(e) => sel.onItemClick(file.id, 'file', e)}
draggable
onDragStart={(e) => {
sel.onItemDragStart(e, file.id, 'file', file.fileName);
setDragging(true);
}}
onDragEnd={() => setDragging(false)}
>
<span className={styles.fileIcon}>{_fileIcon(file.mimeType)}</span>
{renaming ? (
<input
autoFocus
className={styles.renameInput}
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onBlur={_handleRename}
onKeyDown={(e) => {
if (e.key === 'Enter') _handleRename();
if (e.key === 'Escape') setRenaming(false);
}}
onClick={(e) => e.stopPropagation()}
/>
) : (
<span className={styles.folderName}>{file.fileName}</span>
)}
{!renaming && file.fileSize != null && (
<span className={styles.fileSize}>
{(file.fileSize / 1024).toFixed(0)}K
</span>
)}
{!renaming && (
<span className={styles.actions}>
{sel.onRenameFile && !multiSelected && (
<button className={styles.actionBtn} onClick={(e) => { e.stopPropagation(); setRenameValue(file.fileName); setRenaming(true); }} title="Umbenennen">
<FaPen />
</button>
)}
{multiSelected && isSelected ? (
<>
{sel.selectedFolderIds.length > 0 && sel.onDeleteFolders && (
<button className={`${styles.actionBtn} ${styles.danger}`} onClick={_handleDeleteFolders} title={`${sel.selectedFolderIds.length} Ordner löschen`}>
<FaFolder style={{ fontSize: 8, marginRight: 1 }} /><FaTrash />
<span style={{ fontSize: 9, marginLeft: 2, fontWeight: 600 }}>{sel.selectedFolderIds.length}</span>
</button>
)}
{sel.selectedFileIds.length > 0 && sel.onDeleteFiles && (
<button className={`${styles.actionBtn} ${styles.danger}`} onClick={_handleDeleteFiles} title={`${sel.selectedFileIds.length} Dateien löschen`}>
<FaTrash />
<span style={{ fontSize: 9, marginLeft: 2, fontWeight: 600 }}>{sel.selectedFileIds.length}</span>
</button>
)}
</>
) : (
(sel.onDeleteFile || sel.onDeleteFiles) && (
<button className={`${styles.actionBtn} ${styles.danger}`} onClick={_handleDeleteSingle} title="Löschen">
<FaTrash />
</button>
)
)}
</span>
)}
</div>
);
}
/* ── Tree node (folder) ───────────────────────────────────────────────── */
interface TreeNodeProps {
node: FolderNode;
depth: number;
selectedFolderId: string | null;
expandedIds: Set<string>;
showFiles: boolean;
filesByFolder: Map<string, FileNode[]>;
sel: SelectionCtx;
onToggle: (id: string) => void;
onSelect: (id: string | null) => void;
onCreateFolder?: (name: string, parentId: string | null) => Promise<void>;
onRenameFolder?: (folderId: string, newName: string) => Promise<void>;
onDeleteFolder?: (folderId: string) => Promise<void>;
onMoveFolder?: (folderId: string, targetParentId: string | null) => Promise<void>;
onMoveFolders?: (folderIds: string[], targetParentId: string | null) => Promise<void>;
onMoveFile?: (fileId: string, targetFolderId: string | null) => Promise<void>;
onMoveFiles?: (fileIds: string[], targetFolderId: string | null) => Promise<void>;
}
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<HTMLInputElement>(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 (
<div>
<div
className={nodeClasses}
onClick={(e) => 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}
>
<span
className={`${styles.chevron} ${isExpanded ? styles.expanded : ''} ${!hasChildren ? styles.empty : ''}`}
onClick={(e) => { e.stopPropagation(); if (hasChildren) onToggle(node.id); }}
>
<FaChevronRight />
</span>
<span className={styles.folderIcon}>
{isExpanded ? <FaFolderOpen /> : <FaFolder />}
</span>
{renaming ? (
<input
ref={inputRef}
className={styles.renameInput}
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onBlur={_handleRename}
onKeyDown={(e) => {
if (e.key === 'Enter') _handleRename();
if (e.key === 'Escape') setRenaming(false);
}}
onClick={(e) => e.stopPropagation()}
/>
) : (
<span className={styles.folderName}>{node.name}</span>
)}
<span className={styles.actions}>
{onCreateFolder && !(isMultiSelected && sel.selectedItemIds.size > 1) && (
<button className={styles.actionBtn} onClick={_handleAdd} title="Neuer Unterordner">
<FaPlus />
</button>
)}
{onRenameFolder && !(isMultiSelected && sel.selectedItemIds.size > 1) && (
<button className={styles.actionBtn} onClick={(e) => { e.stopPropagation(); setRenameValue(node.name); setRenaming(true); }} title="Umbenennen">
<FaPen />
</button>
)}
{isMultiSelected && sel.selectedItemIds.size > 1 ? (
<>
{sel.selectedFolderIds.length > 0 && sel.onDeleteFolders && (
<button className={`${styles.actionBtn} ${styles.danger}`} onClick={_handleDeleteFolders} title={`${sel.selectedFolderIds.length} Ordner löschen`}>
<FaFolder style={{ fontSize: 8, marginRight: 1 }} /><FaTrash />
<span style={{ fontSize: 9, marginLeft: 2, fontWeight: 600 }}>{sel.selectedFolderIds.length}</span>
</button>
)}
{sel.selectedFileIds.length > 0 && sel.onDeleteFiles && (
<button className={`${styles.actionBtn} ${styles.danger}`} onClick={_handleDeleteFiles} title={`${sel.selectedFileIds.length} Dateien löschen`}>
<FaTrash />
<span style={{ fontSize: 9, marginLeft: 2, fontWeight: 600 }}>{sel.selectedFileIds.length}</span>
</button>
)}
</>
) : onDeleteFolder && (
<button className={`${styles.actionBtn} ${styles.danger}`} onClick={_handleDeleteSingle} title="Löschen">
<FaTrash />
</button>
)}
</span>
</div>
{isExpanded && hasChildren && (
<div className={styles.children}>
{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} />
))}
</div>
)}
</div>
);
}
/* ── 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<Set<string>>(new Set());
const [rootDropOver, setRootDropOver] = useState(false);
const [internalSelectedIds, setInternalSelectedIds] = useState<Set<string>>(new Set());
const lastClickedIdRef = useRef<string | null>(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<string>) => {
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<string>();
for (const [, arr] of filesByFolder) for (const f of arr) ids.add(f.id);
return ids;
}, [filesByFolder]);
const allFolderIds = useMemo(() => {
const ids = new Set<string>();
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 (
<div className={styles.folderTree}>
<div
className={rootClasses}
onClick={() => { onSelect(null); _setSelection(new Set()); }}
onDragOver={(e) => { e.preventDefault(); setRootDropOver(true); }}
onDragLeave={() => setRootDropOver(false)}
onDrop={_handleRootDrop}
>
<span className={styles.folderIcon}><FaGlobe /></span>
<span className={`${styles.folderName} ${styles.rootLabel}`}>(Global)</span>
<span className={styles.rootActions}>
{onRefresh && (
<button className={styles.actionBtn} onClick={(e) => { e.stopPropagation(); onRefresh(); }} title="Aktualisieren">
<FaSyncAlt />
</button>
)}
{onCreateFolder && (
<button
className={styles.actionBtn}
onClick={async (e) => {
e.stopPropagation();
const name = prompt('Neuer Ordnername:');
if (name?.trim()) await onCreateFolder(name.trim(), null);
}}
title="Neuer Ordner"
>
<FaPlus />
</button>
)}
</span>
</div>
<div className={styles.children}>
{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} />
))}
</div>
</div>
);
}

View file

@ -171,6 +171,8 @@ export interface FormGeneratorTableProps<T = any> {
groupRowData?: (groupKey: string, groupRows: T[]) => Record<string, React.ReactNode>;
groupDefaultExpanded?: boolean;
groupActions?: (groupKey: string, groupRows: T[]) => React.ReactNode;
rowDraggable?: boolean;
onRowDragStart?: (e: React.DragEvent<HTMLTableRowElement>, row: T) => void;
}
export function FormGeneratorTable<T extends Record<string, any>>({
@ -208,7 +210,9 @@ export function FormGeneratorTable<T extends Record<string, any>>({
groupRenderer: _groupRenderer,
groupRowData,
groupDefaultExpanded = true,
groupActions
groupActions,
rowDraggable = false,
onRowDragStart,
}: FormGeneratorTableProps<T>) {
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<T extends Record<string, any>>({
// Track if we've loaded from localStorage for this storage key
const loadedStorageKeyRef = useRef<string | null>(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<T extends Record<string, any>>({
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<T extends Record<string, any>>({
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])
)}

View file

@ -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<string>;
previewingFiles: Set<string>;
downloadingFiles: Set<string>;
folders: FolderInfo[];
foldersLoading: boolean;
refreshFolders: () => Promise<void>;
handleCreateFolder: (name: string, parentId: string | null) => Promise<void>;
handleRenameFolder: (folderId: string, newName: string) => Promise<void>;
handleDeleteFolder: (folderId: string) => Promise<void>;
handleMoveFolder: (folderId: string, targetParentId: string | null) => Promise<void>;
handleMoveFile: (fileId: string, targetFolderId: string | null) => Promise<void>;
handleMoveFiles: (fileIds: string[], targetFolderId: string | null) => Promise<void>;
handleMoveFolders: (folderIds: string[], targetParentId: string | null) => Promise<void>;
expandedFolderIds: Set<string>;
toggleFolderExpanded: (id: string) => void;
}
export const FileContext = createContext<FileContextType | undefined>(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<Set<string>>(() => {
try {
const stored = localStorage.getItem(_STORAGE_KEY);
return stored ? new Set<string>(JSON.parse(stored)) : new Set<string>();
} catch { return new Set<string>(); }
});
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<FolderInfo[]>([]);
const [foldersLoading, setFoldersLoading] = useState(false);
const refreshFolders = useCallback(async () => {
setFoldersLoading(true);
try {
const response = await api.get('/api/files/folders');
const data = Array.isArray(response.data) ? response.data : [];
setFolders(data);
} catch (err) {
console.error('Failed to load folders:', err);
} finally {
setFoldersLoading(false);
}
}, []);
useEffect(() => { refreshFolders(); }, [refreshFolders]);
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;
}

View file

@ -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<void>;
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<string | null>(null);
const isMountedRef = useRef(true);
const currentAudioRef = useRef<HTMLAudioElement | null>(null);
const abortControllerRef = useRef<AbortController | null>(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,

View file

@ -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
@ -969,3 +970,86 @@ export function useFileOperations() {
isLoading
};
}
// ── Folder management hook ──────────────────────────────────────────────────
export function useFolders() {
const [folders, setFolders] = useState<FolderInfo[]>([]);
const [loading, setLoading] = useState(false);
const { showError } = useToast();
const refresh = useCallback(async () => {
setLoading(true);
try {
const response = await api.get('/api/files/folders');
const data = Array.isArray(response.data) ? response.data : [];
setFolders(data);
} catch (err) {
console.error('Failed to load folders:', err);
} finally {
setLoading(false);
}
}, []);
useEffect(() => { refresh(); }, [refresh]);
const handleCreateFolder = useCallback(async (name: string, parentId: string | null) => {
try {
await api.post('/api/files/folders', { name, parentId: parentId || null });
await refresh();
} catch (err: any) {
showError(err?.response?.data?.detail || err?.message || 'Folder creation failed');
throw err;
}
}, [refresh, showError]);
const handleRenameFolder = useCallback(async (folderId: string, newName: string) => {
try {
await api.put(`/api/files/folders/${folderId}`, { name: newName });
await refresh();
} catch (err: any) {
showError(err?.response?.data?.detail || err?.message || 'Rename failed');
throw err;
}
}, [refresh, showError]);
const handleDeleteFolder = useCallback(async (folderId: string) => {
try {
await api.delete(`/api/files/folders/${folderId}`, { params: { recursive: true } });
await refresh();
} catch (err: any) {
showError(err?.response?.data?.detail || err?.message || 'Delete failed');
throw err;
}
}, [refresh, showError]);
const handleMoveFolder = useCallback(async (folderId: string, targetParentId: string | null) => {
try {
await api.post(`/api/files/folders/${folderId}/move`, { targetParentId });
await refresh();
} catch (err: any) {
showError(err?.response?.data?.detail || err?.message || 'Move failed');
throw err;
}
}, [refresh, showError]);
const handleMoveFile = useCallback(async (fileId: string, targetFolderId: string | null) => {
try {
await api.post(`/api/files/${fileId}/move`, { targetFolderId });
} catch (err: any) {
showError(err?.response?.data?.detail || err?.message || 'Move failed');
throw err;
}
}, [showError]);
return {
folders,
loading,
refresh,
handleCreateFolder,
handleRenameFolder,
handleDeleteFolder,
handleMoveFolder,
handleMoveFile,
};
}

View file

@ -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<void>;
stop: () => void;
}
const _RECORDING_CHUNK_MS = 250;
const _MAX_RECONNECT_ATTEMPTS = 3;
export function useVoiceStream(callbacks: VoiceStreamCallbacks): VoiceStreamApi {
const [status, setStatus] = useState<VoiceStreamStatus>('idle');
const [interimText, setInterimText] = useState('');
const cbRef = useRef(callbacks);
cbRef.current = callbacks;
const wsRef = useRef<WebSocket | null>(null);
const recorderRef = useRef<MediaRecorder | null>(null);
const streamRef = useRef<MediaStream | null>(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 };
}

View file

@ -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<HTMLAudioElement | null>(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 };
}

View file

@ -21,3 +21,12 @@ html, body {
padding: 0;
font-family: var(--font-family, "DM Sans", sans-serif);
}
tr[data-highlighted="true"] {
animation: rowHighlight 2s ease-out;
}
@keyframes rowHighlight {
0% { background: rgba(25, 118, 210, 0.25); }
100% { background: transparent; }
}

View file

@ -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,19 +23,29 @@ interface UserFile {
fileName: string;
mimeType?: string;
fileSize?: number;
folderId?: string | null;
featureInstanceId?: string;
[key: string]: any;
}
export const FilesPage: React.FC = () => {
const fileInputRef = useRef<HTMLInputElement>(null);
const { showSuccess, showError } = useToast();
const [selectedFolderId, setSelectedFolderId] = useState<string | null>(null);
const {
leftWidth, isDragging, handleMouseDown, containerRef,
} = useResizablePanels({
storageKey: 'filesPage-panelWidth',
defaultLeftWidth: 22,
minLeftWidth: 15,
maxLeftWidth: 40,
});
// Data hook
const {
data: files,
attributes,
permissions,
pagination,
loading,
error,
refetch,
@ -38,7 +53,6 @@ export const FilesPage: React.FC = () => {
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<UserFile | null>(null);
const [selectedFiles, setSelectedFiles] = useState<UserFile[]>([]);
const [treeSelectedIds, setTreeSelectedIds] = useState<Set<string>>(new Set());
const [highlightedFileId, setHighlightedFileId] = useState<string | null>(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<UserFile>) => {
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<HTMLInputElement>) => {
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<HTMLTableRowElement>, 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 (
<div className={styles.adminPage}>
{/* Hidden file input */}
<input
ref={fileInputRef}
type="file"
@ -228,51 +321,115 @@ export const FilesPage: React.FC = () => {
<p className={styles.pageSubtitle}>Dateiverwaltung</p>
</div>
<div className={styles.headerActions}>
<button
className={styles.secondaryButton}
onClick={() => refetch()}
disabled={loading}
>
<button className={styles.secondaryButton} onClick={() => { refetch(); refreshFolders(); }} disabled={loading}>
<FaSync className={loading ? 'spinning' : ''} /> Aktualisieren
</button>
{canCreate && (
<button
className={styles.primaryButton}
onClick={handleUploadClick}
disabled={uploadingFile}
</div>
</div>
{/* Split-view container */}
<div
ref={containerRef as React.RefObject<HTMLDivElement>}
style={{ display: 'flex', flex: 1, overflow: 'hidden', minHeight: 0, position: 'relative' }}
>
{/* Left panel: FolderTree */}
<div style={{
width: `${leftWidth}%`,
minWidth: 0,
overflow: 'auto',
borderRight: '1px solid var(--color-border, #e0e0e0)',
padding: '8px 4px',
}}>
<FolderTree
folders={folders}
files={treeFileNodes}
showFiles={true}
selectedFolderId={selectedFolderId}
onSelect={setSelectedFolderId}
onFileSelect={_handleTreeFileSelect}
selectedItemIds={treeSelectedIds}
onSelectionChange={setTreeSelectedIds}
expandedIds={expandedFolderIds}
onToggleExpand={toggleFolderExpanded}
onRefresh={_handleTreeRefresh}
onCreateFolder={handleCreateFolder}
onRenameFolder={handleRenameFolder}
onDeleteFolder={async (folderId) => {
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}
/>
</div>
{/* Resizable divider */}
<div
onMouseDown={handleMouseDown}
style={{
width: 6,
cursor: 'col-resize',
background: isDragging ? 'var(--color-primary, #1976d2)' : 'transparent',
transition: isDragging ? 'none' : 'background 0.15s',
flexShrink: 0,
zIndex: 10,
}}
onMouseEnter={(e) => { (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 */}
<div style={{ flex: 1, minWidth: 0, overflow: 'auto', display: 'flex', flexDirection: 'column' }}>
{/* Toolbar above table */}
<div style={{
display: 'flex', gap: 8, padding: '8px 12px',
borderBottom: '1px solid var(--color-border, #e0e0e0)',
flexShrink: 0, alignItems: 'center', flexWrap: 'wrap',
}}>
<button className={styles.secondaryButton} onClick={_handleNewFolder}>
<FaFolderPlus /> Neuer Ordner
</button>
{canCreate && (
<button className={styles.primaryButton} onClick={handleUploadClick} disabled={uploadingFile}>
<FaUpload /> {uploadingFile ? 'Uploading...' : 'Datei hochladen'}
</button>
)}
</div>
</div>
<div className={styles.tableContainer}>
{/* Table content */}
<div style={{ flex: 1, overflow: 'auto' }}>
{loading && (!files || files.length === 0) ? (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>Lade Dateien...</span>
</div>
) : !files || files.length === 0 ? (
) : filteredFiles.length === 0 ? (
<div className={styles.emptyState}>
<FaFolder className={styles.emptyIcon} />
<h3 className={styles.emptyTitle}>Keine Dateien vorhanden</h3>
<h3 className={styles.emptyTitle}>
{selectedFolderId ? 'Ordner ist leer' : 'Keine Dateien vorhanden'}
</h3>
<p className={styles.emptyDescription}>
Laden Sie eine Datei hoch, um loszulegen.
{selectedFolderId
? 'Verschieben Sie Dateien hierher oder laden Sie neue hoch.'
: 'Laden Sie eine Datei hoch, um loszulegen.'}
</p>
{canCreate && (
<button
className={styles.primaryButton}
onClick={handleUploadClick}
disabled={uploadingFile}
>
<FaUpload /> Erste Datei hochladen
<button className={styles.primaryButton} onClick={handleUploadClick} disabled={uploadingFile}>
<FaUpload /> Datei hochladen
</button>
)}
</div>
) : (
<FormGeneratorTable
data={files}
data={filteredFiles}
columns={columns}
apiEndpoint="/api/files/list"
loading={loading}
@ -282,6 +439,12 @@ export const FilesPage: React.FC = () => {
filterable={true}
sortable={true}
selectable={true}
onRowSelect={(rows) => setSelectedFiles(rows as UserFile[])}
rowDraggable={true}
onRowDragStart={_onRowDragStart}
getRowDataAttributes={(row: UserFile) =>
({ highlighted: row.id === highlightedFileId ? 'true' : 'false' })
}
actionButtons={[
...(canUpdate ? [{
type: 'edit' as const,
@ -313,9 +476,8 @@ export const FilesPage: React.FC = () => {
onDelete={handleDelete}
onDeleteMultiple={handleDeleteMultiple}
hookData={{
refetch,
refetch: _tableRefetch,
permissions,
pagination,
handleDelete: handleFileDelete,
handleInlineUpdate,
updateOptimistically: updateFileOptimistically,
@ -324,6 +486,8 @@ export const FilesPage: React.FC = () => {
/>
)}
</div>
</div>
</div>
{/* Edit Modal */}
{editingFile && (
@ -331,12 +495,7 @@ export const FilesPage: React.FC = () => {
<div className={styles.modal} onClick={e => e.stopPropagation()}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>Datei bearbeiten</h2>
<button
className={styles.modalClose}
onClick={() => setEditingFile(null)}
>
</button>
<button className={styles.modalClose} onClick={() => setEditingFile(null)}></button>
</div>
<div className={styles.modalContent}>
{formAttributes.length === 0 ? (

View file

@ -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') {
let cancelled = false;
const _confirmCheckoutIfNeeded = async () => {
if (successParam !== 'true') {
if (canceledParam === 'true' && !cancelled) {
setCheckoutMessage({ type: 'error', text: 'Zahlung abgebrochen.' });
}
}, [successParam, canceledParam, refetchBalances]);
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;

View file

@ -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<string[]>([]);
@ -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 = () => {
<span className={styles.sessionLabel}>Session aktiv</span>
<div className={styles.sessionActions}>
{voice.state === 'botSpeaking' && (
<>
<button className={styles.btnSmall} onClick={handlePauseTts}>Pause</button>
<button className={styles.btnSmallDanger} onClick={handleStopTts}>Stop</button>
</>
)}
{voice.state === 'interrupted' && coach.hasAudioToResume() && (
<button className={styles.btnSmall} onClick={handleResumeTts}>Weitersprechen</button>

View file

@ -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<void>;
onInterimText?: (text: string) => void;
}
export function useVoiceController(callbacks: VoiceControllerCallbacks): VoiceControllerApi {
const [state, setState] = useState<VoiceState>('idle');
const [muted, setMuted] = useState(false);
const [liveTranscript, setLiveTranscript] = useState('');
const stateRef = useRef<VoiceState>('idle');
const mutedRef = useRef(false);
const streamRef = useRef<MediaStream | null>(null);
const recognitionRef = useRef<SpeechRecognition | null>(null);
const transcriptPartsRef = useRef<string[]>([]);
const processedIndexRef = useRef(0);
const silenceTimerRef = useRef<ReturnType<typeof setTimeout> | 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,
};
}

View file

@ -303,6 +303,13 @@ export const ConversationList: React.FC<ConversationListProps> = ({
}}
/>
) : (
<>
<span
style={{ fontSize: 10, color: '#aaa', flexShrink: 0, marginRight: 6 }}
title={_formatDate(conv.lastActivity)}
>
{_formatTime(conv.lastActivity)}
</span>
<span
style={{
fontSize: 13,
@ -318,6 +325,7 @@ export const ConversationList: React.FC<ConversationListProps> = ({
>
{conv.name}
</span>
</>
)}
{/* Action buttons (visible on hover) */}
@ -383,29 +391,6 @@ export const ConversationList: React.FC<ConversationListProps> = ({
)}
</div>
{/* Status + last activity */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 3 }}>
<span style={{ fontSize: 10, color: '#999' }}>
{conv.status === 'active' && (
<span style={{ color: '#4caf50' }}>{'\u25CF'} aktiv</span>
)}
{conv.status === 'completed' && (
<span style={{ color: '#888' }}>{'\u25CF'} abgeschlossen</span>
)}
{conv.status === 'archived' && (
<span style={{ color: '#ff9800' }}>{'\u25CF'} archiviert</span>
)}
{!['active', 'completed', 'archived'].includes(conv.status) && (
<span>{conv.status}</span>
)}
</span>
<span
style={{ fontSize: 10, color: '#aaa', flexShrink: 0 }}
title={_formatDate(conv.lastActivity)}
>
{_formatTime(conv.lastActivity)}
</span>
</div>
</div>
);
})}

View file

@ -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<FileBrowserProps> = ({
instanceId,
files,
folders: _folders,
onRefresh,
onFileSelect,
}) => {
const [searchQuery, setSearchQuery] = useState('');
const [isDragOver, setIsDragOver] = useState(false);
const [uploading, setUploading] = useState(false);
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({});
const [selectedFolderId, setSelectedFolderId] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(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<string, _InstanceGroup> = {};
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<FileBrowserProps> = ({
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) => {
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<FileBrowserProps> = ({
}
}, [_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 (
<div
style={{ padding: 8, position: 'relative' }}
style={{ padding: 8, position: 'relative', display: 'flex', flexDirection: 'column', gap: 4 }}
onDragOver={_handleDragOver}
onDragLeave={_handleDragLeave}
onDrop={_handleDrop}
@ -140,7 +187,7 @@ export const FileBrowser: React.FC<FileBrowserProps> = ({
)}
{/* Header */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontSize: 12, fontWeight: 600, color: '#666', textTransform: 'uppercase' }}>Files</span>
<div style={{ display: 'flex', gap: 4 }}>
<button
@ -151,7 +198,7 @@ export const FileBrowser: React.FC<FileBrowserProps> = ({
>
{uploading ? '...' : '+'}
</button>
<button onClick={onRefresh} style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, color: '#1976d2' }}>{'\u21BB'}</button>
<button onClick={_refreshAll} style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, color: '#1976d2' }}>{'\u21BB'}</button>
</div>
</div>
@ -165,94 +212,39 @@ export const FileBrowser: React.FC<FileBrowserProps> = ({
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 */}
<FolderTree
folders={_folderNodes}
files={_fileNodes}
showFiles={true}
selectedFolderId={selectedFolderId}
onSelect={setSelectedFolderId}
onFileSelect={onFileSelect}
expandedIds={expandedFolderIds}
onToggleExpand={toggleFolderExpanded}
onRefresh={_refreshAll}
onCreateFolder={handleCreateFolder}
onRenameFolder={handleRenameFolder}
onDeleteFolder={_onDeleteFolder}
onMoveFolder={handleMoveFolder}
onMoveFolders={handleMoveFolders}
onMoveFile={_onMoveFile}
onMoveFiles={_onMoveFiles}
onRenameFile={_onRenameFile}
onDeleteFile={_onDeleteFile}
onDeleteFiles={_onDeleteFiles}
onDeleteFolders={_onDeleteFolders}
/>
{_fileNodes.length === 0 && (
<div style={{ fontSize: 12, color: '#999', textAlign: 'center', padding: 16 }}>
{searchQuery ? 'Keine Dateien gefunden' : 'Keine Dateien. Drag & Drop zum Hochladen.'}
</div>
)}
{_groups.map(group => {
const isCollapsed = !!collapsed[group.instanceId];
return (
<div key={group.instanceId} style={{ marginBottom: 4 }}>
{/* Group header */}
<div
onClick={() => _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)')}
>
<span style={{ fontSize: 10, color: '#888', width: 12, textAlign: 'center' }}>
{isCollapsed ? '\u25B6' : '\u25BC'}
</span>
<span style={{ fontSize: 12 }}>{'\uD83D\uDCC1'}</span>
<span style={{ fontSize: 12, fontWeight: 600, flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{group.label}
</span>
<span style={{ fontSize: 10, color: '#aaa', flexShrink: 0 }}>{group.files.length}</span>
</div>
{/* Files */}
{!isCollapsed && group.files.map(file => (
<div
key={file.id}
onClick={() => 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 = '')}
>
<span style={{ fontSize: 11, flexShrink: 0 }}>{_fileIcon(file.mimeType)}</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{file.fileName}
</div>
{file.tags && file.tags.length > 0 && (
<div style={{ display: 'flex', gap: 3, marginTop: 2 }}>
{file.tags.map(tag => (
<span key={tag} style={{ fontSize: 9, padding: '1px 5px', borderRadius: 3, background: '#e3f2fd', color: '#1565c0' }}>
{tag}
</span>
))}
</div>
)}
</div>
<span style={{ fontSize: 10, color: '#999', flexShrink: 0 }}>
{(file.fileSize / 1024).toFixed(0)}K
</span>
</div>
))}
</div>
);
})}
</div>
);
};
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';
}

View file

@ -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<WorkspaceInputProps> = ({
@ -61,21 +67,22 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({
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<string[]>([]);
const [attachedDataSourceIds, setAttachedDataSourceIds] = useState<string[]>([]);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const recognitionRef = useRef<SpeechRecognition | null>(null);
const transcriptPartsRef = useRef<string[]>([]);
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<WorkspaceInputProps> = ({
);
}, []);
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<WorkspaceInputProps> = ({
const _horizontalPadding = isMobile ? 12 : 24;
const _controlSize = isMobile ? 38 : 40;
const _handlePaste = useCallback((e: React.ClipboardEvent<HTMLTextAreaElement>) => {
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 (
<div style={{
<div
style={{
borderTop: '1px solid var(--border-color, #e0e0e0)',
position: 'relative',
flexShrink: 0,
}}>
outline: treeDropOver ? '2px dashed #1976d2' : 'none',
background: treeDropOver ? 'rgba(25, 118, 210, 0.04)' : undefined,
transition: 'background 0.15s, outline 0.15s',
}}
onDragOver={_handlePromptDragOver}
onDragLeave={_handlePromptDragLeave}
onDrop={_handlePromptDrop}
>
{/* Pending uploaded files */}
{pendingFiles.length > 0 && (
<div style={{
@ -294,11 +303,13 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({
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 && (
<button
onClick={() => onRemovePendingFile(pf.fileId)}
@ -426,6 +437,7 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({
value={prompt}
onChange={_handleChange}
onKeyDown={_handleKeyDown}
onPaste={_handlePaste}
placeholder="Type a message... Use @filename to reference files"
disabled={isProcessing}
style={{

View file

@ -57,6 +57,7 @@ type RightTab = 'activity' | 'preview';
interface PendingFile {
fileId: string;
fileName: string;
itemType?: 'file' | 'folder';
}
interface WorkspacePageProps {
@ -156,6 +157,20 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
setPendingFiles(prev => prev.filter(f => f.fileId !== fileId));
}, []);
const _handleTreeItemsDrop = useCallback((items: Array<{ id: string; type: 'file' | 'folder'; name: string }>) => {
setPendingFiles(prev => {
const existing = new Set(prev.map(f => f.fileId));
const toAdd: PendingFile[] = [];
for (const item of items) {
if (!existing.has(item.id)) {
toAdd.push({ fileId: item.id, fileName: item.name, itemType: item.type });
existing.add(item.id);
}
}
return [...prev, ...toAdd];
});
}, []);
if (!instanceId) {
return (
<div style={{ padding: 32, textAlign: 'center', color: '#999' }}>
@ -212,7 +227,6 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
<FileBrowser
instanceId={instanceId}
files={workspace.files}
folders={workspace.folders}
onRefresh={workspace.refreshFiles}
onFileSelect={_handleFileSelect}
/>
@ -391,6 +405,8 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
selectedProviders={selectedProviders}
onProvidersChange={setSelectedProviders}
isMobile={isMobile}
onTreeItemsDrop={_handleTreeItemsDrop}
onPasteAsFile={_uploadAndAttach}
/>
</main>