file system and stt and ttss reevisions
This commit is contained in:
parent
7e45b6a638
commit
9e792bc74f
19 changed files with 2211 additions and 739 deletions
|
|
@ -99,6 +99,7 @@ export interface CreditAddRequest {
|
||||||
export interface CheckoutCreateRequest {
|
export interface CheckoutCreateRequest {
|
||||||
userId?: string;
|
userId?: string;
|
||||||
amount: number;
|
amount: number;
|
||||||
|
returnUrl: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CheckoutCreateResponse {
|
export interface CheckoutCreateResponse {
|
||||||
|
|
|
||||||
|
|
@ -176,21 +176,116 @@ export async function deleteFiles(
|
||||||
request: ApiRequestFunction,
|
request: ApiRequestFunction,
|
||||||
fileIds: string[]
|
fileIds: string[]
|
||||||
): Promise<Array<{ success: boolean; fileId: string; error?: any }>> {
|
): Promise<Array<{ success: boolean; fileId: string; error?: any }>> {
|
||||||
const results = await Promise.allSettled(
|
const uniqueIds = [...new Set(fileIds.filter(Boolean))];
|
||||||
fileIds.map(fileId =>
|
if (uniqueIds.length === 0) return [];
|
||||||
request({
|
await request({
|
||||||
url: `/api/files/${fileId}`,
|
url: '/api/files/batch-delete',
|
||||||
method: 'delete'
|
method: 'post',
|
||||||
}).then(() => ({ success: true, fileId }))
|
data: { fileIds: uniqueIds }
|
||||||
.catch((error) => ({ success: false, fileId, error }))
|
});
|
||||||
)
|
return uniqueIds.map(fileId => ({ success: true, fileId }));
|
||||||
);
|
|
||||||
|
|
||||||
return results.map((result, index) => {
|
|
||||||
if (result.status === 'fulfilled') {
|
|
||||||
return result.value;
|
|
||||||
}
|
}
|
||||||
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 },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
157
src/components/FolderTree/FolderTree.module.css
Normal file
157
src/components/FolderTree/FolderTree.module.css
Normal 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;
|
||||||
|
}
|
||||||
731
src/components/FolderTree/FolderTree.tsx
Normal file
731
src/components/FolderTree/FolderTree.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -171,6 +171,8 @@ export interface FormGeneratorTableProps<T = any> {
|
||||||
groupRowData?: (groupKey: string, groupRows: T[]) => Record<string, React.ReactNode>;
|
groupRowData?: (groupKey: string, groupRows: T[]) => Record<string, React.ReactNode>;
|
||||||
groupDefaultExpanded?: boolean;
|
groupDefaultExpanded?: boolean;
|
||||||
groupActions?: (groupKey: string, groupRows: T[]) => React.ReactNode;
|
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>>({
|
export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
|
|
@ -208,7 +210,9 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
groupRenderer: _groupRenderer,
|
groupRenderer: _groupRenderer,
|
||||||
groupRowData,
|
groupRowData,
|
||||||
groupDefaultExpanded = true,
|
groupDefaultExpanded = true,
|
||||||
groupActions
|
groupActions,
|
||||||
|
rowDraggable = false,
|
||||||
|
onRowDragStart,
|
||||||
}: FormGeneratorTableProps<T>) {
|
}: FormGeneratorTableProps<T>) {
|
||||||
const { t, currentLanguage: contextLanguage } = useLanguage();
|
const { t, currentLanguage: contextLanguage } = useLanguage();
|
||||||
// When only onDelete is provided, use it for multi-delete too so Delete stays visible with 2+ selected
|
// When only onDelete is provided, use it for multi-delete too so Delete stays visible with 2+ selected
|
||||||
|
|
@ -282,7 +286,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
// Track if we've loaded from localStorage for this storage key
|
// Track if we've loaded from localStorage for this storage key
|
||||||
const loadedStorageKeyRef = useRef<string | null>(null);
|
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';
|
const supportsBackendPagination = hookData?.refetch && typeof hookData.refetch === 'function';
|
||||||
|
|
||||||
// Debounce search term for backend calls
|
// Debounce search term for backend calls
|
||||||
|
|
@ -1971,6 +1975,8 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
key={`${groupKey}-row-${rowIndex}`}
|
key={`${groupKey}-row-${rowIndex}`}
|
||||||
className={`${styles.tr} ${selectedRows.has(globalIndex) ? styles.selected : ''} ${onRowClick ? styles.clickable : ''}`}
|
className={`${styles.tr} ${selectedRows.has(globalIndex) ? styles.selected : ''} ${onRowClick ? styles.clickable : ''}`}
|
||||||
onClick={() => onRowClick?.(row, globalIndex)}
|
onClick={() => onRowClick?.(row, globalIndex)}
|
||||||
|
draggable={rowDraggable}
|
||||||
|
onDragStart={rowDraggable && onRowDragStart ? (e) => onRowDragStart(e, row) : undefined}
|
||||||
{...Object.fromEntries(
|
{...Object.fromEntries(
|
||||||
Object.entries(dataAttributes).map(([key, value]) => [`data-${key}`, value])
|
Object.entries(dataAttributes).map(([key, value]) => [`data-${key}`, value])
|
||||||
)}
|
)}
|
||||||
|
|
@ -2084,6 +2090,8 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
key={index}
|
key={index}
|
||||||
className={`${styles.tr} ${selectedRows.has(index) ? styles.selected : ''} ${onRowClick ? styles.clickable : ''}`}
|
className={`${styles.tr} ${selectedRows.has(index) ? styles.selected : ''} ${onRowClick ? styles.clickable : ''}`}
|
||||||
onClick={() => onRowClick?.(row, index)}
|
onClick={() => onRowClick?.(row, index)}
|
||||||
|
draggable={rowDraggable}
|
||||||
|
onDragStart={rowDraggable && onRowDragStart ? (e) => onRowDragStart(e, row) : undefined}
|
||||||
{...Object.fromEntries(
|
{...Object.fromEntries(
|
||||||
Object.entries(dataAttributes).map(([key, value]) => [`data-${key}`, value])
|
Object.entries(dataAttributes).map(([key, value]) => [`data-${key}`, value])
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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 { useUserFiles, useFileOperations, UserFile } from '../hooks/useFiles';
|
||||||
|
import type { FolderInfo } from '../api/fileApi';
|
||||||
|
|
||||||
|
export type { FolderInfo };
|
||||||
|
|
||||||
interface FileContextType {
|
interface FileContextType {
|
||||||
files: UserFile[];
|
files: UserFile[];
|
||||||
|
|
@ -14,6 +18,18 @@ interface FileContextType {
|
||||||
deletingFiles: Set<string>;
|
deletingFiles: Set<string>;
|
||||||
previewingFiles: Set<string>;
|
previewingFiles: Set<string>;
|
||||||
downloadingFiles: 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);
|
export const FileContext = createContext<FileContextType | undefined>(undefined);
|
||||||
|
|
@ -31,45 +47,102 @@ export function FileProvider({ children }: { children: React.ReactNode }) {
|
||||||
downloadingFiles
|
downloadingFiles
|
||||||
} = useFileOperations();
|
} = 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 handleFileUpload = useCallback(async (file: File, workflowId?: string) => {
|
||||||
const result = await hookHandleFileUpload(file, workflowId);
|
const result = await hookHandleFileUpload(file, workflowId);
|
||||||
|
|
||||||
if (result.success && result.fileData) {
|
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();
|
await refetchFiles();
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}, [hookHandleFileUpload, refetchFiles]);
|
}, [hookHandleFileUpload, refetchFiles]);
|
||||||
|
|
||||||
// Centralized file delete that updates the shared state
|
|
||||||
const handleFileDelete = useCallback(async (fileId: string, onOptimisticDelete?: () => void) => {
|
const handleFileDelete = useCallback(async (fileId: string, onOptimisticDelete?: () => void) => {
|
||||||
const success = await hookHandleFileDelete(fileId, () => {
|
const success = await hookHandleFileDelete(fileId, () => {
|
||||||
removeFileOptimistically(fileId);
|
removeFileOptimistically(fileId);
|
||||||
onOptimisticDelete?.();
|
onOptimisticDelete?.();
|
||||||
});
|
});
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
// Refetch to ensure we have the latest data
|
|
||||||
await refetchFiles();
|
await refetchFiles();
|
||||||
}
|
}
|
||||||
|
|
||||||
return success;
|
return success;
|
||||||
}, [hookHandleFileDelete, removeFileOptimistically, refetchFiles]);
|
}, [hookHandleFileDelete, removeFileOptimistically, refetchFiles]);
|
||||||
|
|
||||||
// Expose refetch function
|
|
||||||
const refetch = useCallback(async () => {
|
const refetch = useCallback(async () => {
|
||||||
await refetchFiles();
|
await refetchFiles();
|
||||||
}, [refetchFiles]);
|
}, [refetchFiles]);
|
||||||
|
|
@ -86,12 +159,23 @@ export function FileProvider({ children }: { children: React.ReactNode }) {
|
||||||
handleFilePreview: handleFilePreview as FileContextType['handleFilePreview'],
|
handleFilePreview: handleFilePreview as FileContextType['handleFilePreview'],
|
||||||
handleFileDownload: async (fileId: string, fileName: string) => {
|
handleFileDownload: async (fileId: string, fileName: string) => {
|
||||||
await handleFileDownload(fileId, fileName);
|
await handleFileDownload(fileId, fileName);
|
||||||
// Return void (ignore boolean return value)
|
|
||||||
},
|
},
|
||||||
uploadingFile,
|
uploadingFile,
|
||||||
deletingFiles,
|
deletingFiles,
|
||||||
previewingFiles,
|
previewingFiles,
|
||||||
downloadingFiles
|
downloadingFiles,
|
||||||
|
folders,
|
||||||
|
foldersLoading,
|
||||||
|
refreshFolders,
|
||||||
|
handleCreateFolder,
|
||||||
|
handleRenameFolder,
|
||||||
|
handleDeleteFolder,
|
||||||
|
handleMoveFolder,
|
||||||
|
handleMoveFile,
|
||||||
|
handleMoveFiles,
|
||||||
|
handleMoveFolders,
|
||||||
|
expandedFolderIds,
|
||||||
|
toggleFolderExpanded,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|
@ -106,4 +190,3 @@ export function useFileContext() {
|
||||||
}
|
}
|
||||||
return context;
|
return context;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,7 @@ import {
|
||||||
type CoachingContext, type CoachingSession, type CoachingMessage,
|
type CoachingContext, type CoachingSession, type CoachingMessage,
|
||||||
type CoachingTask, type CoachingScore, type SSEEvent,
|
type CoachingTask, type CoachingScore, type SSEEvent,
|
||||||
} from '../api/commcoachApi';
|
} from '../api/commcoachApi';
|
||||||
|
import { useTtsPlayback, type TtsEvent } from './useTtsPlayback';
|
||||||
export type TtsEvent = 'playing' | 'ended' | 'paused' | 'error';
|
|
||||||
|
|
||||||
export interface CommcoachHookReturn {
|
export interface CommcoachHookReturn {
|
||||||
contexts: CoachingContext[];
|
contexts: CoachingContext[];
|
||||||
|
|
@ -49,8 +48,11 @@ export interface CommcoachHookReturn {
|
||||||
cancelSession: () => Promise<void>;
|
cancelSession: () => Promise<void>;
|
||||||
|
|
||||||
stopTts: () => void;
|
stopTts: () => void;
|
||||||
|
pauseTts: () => void;
|
||||||
resumeTts: () => void;
|
resumeTts: () => void;
|
||||||
hasAudioToResume: () => boolean;
|
hasAudioToResume: () => boolean;
|
||||||
|
ttsIsPlaying: boolean;
|
||||||
|
ttsIsPaused: boolean;
|
||||||
|
|
||||||
onTtsEventRef: MutableRefObject<((event: TtsEvent) => void) | null>;
|
onTtsEventRef: MutableRefObject<((event: TtsEvent) => void) | null>;
|
||||||
|
|
||||||
|
|
@ -90,12 +92,21 @@ export function useCommcoach(): CommcoachHookReturn {
|
||||||
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
||||||
|
|
||||||
const isMountedRef = useRef(true);
|
const isMountedRef = useRef(true);
|
||||||
const currentAudioRef = useRef<HTMLAudioElement | null>(null);
|
|
||||||
const abortControllerRef = useRef<AbortController | null>(null);
|
const abortControllerRef = useRef<AbortController | null>(null);
|
||||||
const onTtsEventRef = useRef<((event: TtsEvent) => void) | null>(null);
|
const onTtsEventRef = useRef<((event: TtsEvent) => void) | null>(null);
|
||||||
const onDocumentCreatedRef = useRef<((doc: any) => 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 () => {
|
const refreshContexts = useCallback(async () => {
|
||||||
if (!instanceId) return;
|
if (!instanceId) return;
|
||||||
|
|
@ -111,54 +122,21 @@ export function useCommcoach(): CommcoachHookReturn {
|
||||||
}
|
}
|
||||||
}, [request, instanceId]);
|
}, [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(() => {
|
const stopTts = useCallback(() => {
|
||||||
if (currentAudioRef.current) {
|
ttsPlayback.stop();
|
||||||
currentAudioRef.current.pause();
|
}, [ttsPlayback]);
|
||||||
_emitTts('paused');
|
|
||||||
}
|
const pauseTts = useCallback(() => {
|
||||||
}, [_emitTts]);
|
ttsPlayback.pause();
|
||||||
|
}, [ttsPlayback]);
|
||||||
|
|
||||||
const resumeTts = useCallback(() => {
|
const resumeTts = useCallback(() => {
|
||||||
if (currentAudioRef.current && currentAudioRef.current.paused) {
|
ttsPlayback.resume();
|
||||||
currentAudioRef.current.play().then(() => {
|
}, [ttsPlayback]);
|
||||||
_emitTts('playing');
|
|
||||||
}).catch(() => {
|
|
||||||
_emitTts('error');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [_emitTts]);
|
|
||||||
|
|
||||||
const hasAudioToResume = useCallback(() => {
|
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 }) => {
|
const selectContext = useCallback(async (contextId: string, options?: { skipSessionResume?: boolean }) => {
|
||||||
if (!instanceId) return;
|
if (!instanceId) return;
|
||||||
|
|
@ -196,7 +174,7 @@ export function useCommcoach(): CommcoachHookReturn {
|
||||||
setMessages(eventData.messages);
|
setMessages(eventData.messages);
|
||||||
}
|
}
|
||||||
} else if (eventType === 'ttsAudio' && eventData?.audio) {
|
} else if (eventType === 'ttsAudio' && eventData?.audio) {
|
||||||
_playTtsAudio(eventData.audio);
|
ttsPlayback.play(eventData.audio);
|
||||||
}
|
}
|
||||||
if (eventType === 'complete') setIsStreaming(false);
|
if (eventType === 'complete') setIsStreaming(false);
|
||||||
},
|
},
|
||||||
|
|
@ -210,7 +188,7 @@ export function useCommcoach(): CommcoachHookReturn {
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (isMountedRef.current) setError(err.message || 'Fehler beim Laden des Kontexts');
|
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[]) => {
|
const createContext = useCallback(async (title: string, description?: string, category?: string, goals?: string[]) => {
|
||||||
if (!instanceId) return;
|
if (!instanceId) return;
|
||||||
|
|
@ -298,7 +276,7 @@ export function useCommcoach(): CommcoachHookReturn {
|
||||||
return [...prev, msg];
|
return [...prev, msg];
|
||||||
});
|
});
|
||||||
} else if (eventType === 'ttsAudio' && eventData?.audio) {
|
} else if (eventType === 'ttsAudio' && eventData?.audio) {
|
||||||
_playTtsAudio(eventData.audio);
|
ttsPlayback.play(eventData.audio);
|
||||||
} else if (eventType === 'status' && eventData) {
|
} else if (eventType === 'status' && eventData) {
|
||||||
setStreamingStatus(eventData.label || null);
|
setStreamingStatus(eventData.label || null);
|
||||||
} else if (eventType === 'taskCreated' && eventData) {
|
} else if (eventType === 'taskCreated' && eventData) {
|
||||||
|
|
@ -333,7 +311,7 @@ export function useCommcoach(): CommcoachHookReturn {
|
||||||
} finally {
|
} finally {
|
||||||
if (isMountedRef.current) setActionLoading(null);
|
if (isMountedRef.current) setActionLoading(null);
|
||||||
}
|
}
|
||||||
}, [instanceId, selectedContextId, _playTtsAudio]);
|
}, [instanceId, selectedContextId, ttsPlayback.play]);
|
||||||
|
|
||||||
const sendMessage = useCallback(async (content: string) => {
|
const sendMessage = useCallback(async (content: string) => {
|
||||||
const normalizedContent = content.trim();
|
const normalizedContent = content.trim();
|
||||||
|
|
@ -343,10 +321,7 @@ export function useCommcoach(): CommcoachHookReturn {
|
||||||
const ac = new AbortController();
|
const ac = new AbortController();
|
||||||
abortControllerRef.current = ac;
|
abortControllerRef.current = ac;
|
||||||
|
|
||||||
if (currentAudioRef.current) {
|
ttsPlayback.stop();
|
||||||
currentAudioRef.current.pause();
|
|
||||||
currentAudioRef.current = null;
|
|
||||||
}
|
|
||||||
await _unlockAudioForTts();
|
await _unlockAudioForTts();
|
||||||
setError(null);
|
setError(null);
|
||||||
setIsStreaming(true);
|
setIsStreaming(true);
|
||||||
|
|
@ -396,7 +371,7 @@ export function useCommcoach(): CommcoachHookReturn {
|
||||||
});
|
});
|
||||||
} else if (eventType === 'ttsAudio' && eventData?.audio) {
|
} else if (eventType === 'ttsAudio' && eventData?.audio) {
|
||||||
setError(null);
|
setError(null);
|
||||||
_playTtsAudio(eventData.audio);
|
ttsPlayback.play(eventData.audio);
|
||||||
} else if (eventType === 'status' && eventData) {
|
} else if (eventType === 'status' && eventData) {
|
||||||
setStreamingStatus(eventData.label || null);
|
setStreamingStatus(eventData.label || null);
|
||||||
} else if (eventType === 'taskCreated' && eventData) {
|
} else if (eventType === 'taskCreated' && eventData) {
|
||||||
|
|
@ -433,14 +408,11 @@ export function useCommcoach(): CommcoachHookReturn {
|
||||||
setIsStreaming(false);
|
setIsStreaming(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [instanceId, session, _playTtsAudio]);
|
}, [instanceId, session, ttsPlayback.play]);
|
||||||
|
|
||||||
const sendAudio = useCallback(async (audioBlob: Blob) => {
|
const sendAudio = useCallback(async (audioBlob: Blob) => {
|
||||||
if (!instanceId || !session) return;
|
if (!instanceId || !session) return;
|
||||||
if (currentAudioRef.current) {
|
ttsPlayback.stop();
|
||||||
currentAudioRef.current.pause();
|
|
||||||
currentAudioRef.current = null;
|
|
||||||
}
|
|
||||||
await _unlockAudioForTts();
|
await _unlockAudioForTts();
|
||||||
setError(null);
|
setError(null);
|
||||||
setIsStreaming(true);
|
setIsStreaming(true);
|
||||||
|
|
@ -474,7 +446,7 @@ export function useCommcoach(): CommcoachHookReturn {
|
||||||
});
|
});
|
||||||
} else if (eventType === 'ttsAudio' && eventData?.audio) {
|
} else if (eventType === 'ttsAudio' && eventData?.audio) {
|
||||||
setError(null);
|
setError(null);
|
||||||
_playTtsAudio(eventData.audio);
|
ttsPlayback.play(eventData.audio);
|
||||||
} else if (eventType === 'taskCreated' && eventData) {
|
} else if (eventType === 'taskCreated' && eventData) {
|
||||||
setTasks(prev => [eventData, ...prev]);
|
setTasks(prev => [eventData, ...prev]);
|
||||||
} else if (eventType === 'documentCreated' && eventData) {
|
} else if (eventType === 'documentCreated' && eventData) {
|
||||||
|
|
@ -585,8 +557,10 @@ export function useCommcoach(): CommcoachHookReturn {
|
||||||
error, inputValue, setInputValue,
|
error, inputValue, setInputValue,
|
||||||
selectContext, createContext, archiveContext,
|
selectContext, createContext, archiveContext,
|
||||||
startSession: startSessionCb,
|
startSession: startSessionCb,
|
||||||
sendMessage, sendAudio, completeSession: completeSessionCb, cancelSession: cancelSessionCb,
|
sendMessage, sendAudio,
|
||||||
stopTts, resumeTts, hasAudioToResume,
|
completeSession: completeSessionCb, cancelSession: cancelSessionCb,
|
||||||
|
stopTts, pauseTts, resumeTts, hasAudioToResume,
|
||||||
|
ttsIsPlaying: ttsPlayback.isPlaying, ttsIsPaused: ttsPlayback.isPaused,
|
||||||
onTtsEventRef,
|
onTtsEventRef,
|
||||||
actionLoading,
|
actionLoading,
|
||||||
toggleTaskStatus, addTask, removeTask,
|
toggleTaskStatus, addTask, removeTask,
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,8 @@ import {
|
||||||
fetchFileById as fetchFileByIdApi,
|
fetchFileById as fetchFileByIdApi,
|
||||||
updateFile as updateFileApi,
|
updateFile as updateFileApi,
|
||||||
deleteFile as deleteFileApi,
|
deleteFile as deleteFileApi,
|
||||||
deleteFiles as deleteFilesApi
|
deleteFiles as deleteFilesApi,
|
||||||
|
type FolderInfo,
|
||||||
} from '../api/fileApi';
|
} from '../api/fileApi';
|
||||||
|
|
||||||
// File interfaces - exactly matching backend FileItem model
|
// File interfaces - exactly matching backend FileItem model
|
||||||
|
|
@ -969,3 +970,86 @@ export function useFileOperations() {
|
||||||
isLoading
|
isLoading
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Folder management hook ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function useFolders() {
|
||||||
|
const [folders, setFolders] = useState<FolderInfo[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const { showError } = useToast();
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await api.get('/api/files/folders');
|
||||||
|
const data = Array.isArray(response.data) ? response.data : [];
|
||||||
|
setFolders(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load folders:', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { refresh(); }, [refresh]);
|
||||||
|
|
||||||
|
const handleCreateFolder = useCallback(async (name: string, parentId: string | null) => {
|
||||||
|
try {
|
||||||
|
await api.post('/api/files/folders', { name, parentId: parentId || null });
|
||||||
|
await refresh();
|
||||||
|
} catch (err: any) {
|
||||||
|
showError(err?.response?.data?.detail || err?.message || 'Folder creation failed');
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}, [refresh, showError]);
|
||||||
|
|
||||||
|
const handleRenameFolder = useCallback(async (folderId: string, newName: string) => {
|
||||||
|
try {
|
||||||
|
await api.put(`/api/files/folders/${folderId}`, { name: newName });
|
||||||
|
await refresh();
|
||||||
|
} catch (err: any) {
|
||||||
|
showError(err?.response?.data?.detail || err?.message || 'Rename failed');
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}, [refresh, showError]);
|
||||||
|
|
||||||
|
const handleDeleteFolder = useCallback(async (folderId: string) => {
|
||||||
|
try {
|
||||||
|
await api.delete(`/api/files/folders/${folderId}`, { params: { recursive: true } });
|
||||||
|
await refresh();
|
||||||
|
} catch (err: any) {
|
||||||
|
showError(err?.response?.data?.detail || err?.message || 'Delete failed');
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}, [refresh, showError]);
|
||||||
|
|
||||||
|
const handleMoveFolder = useCallback(async (folderId: string, targetParentId: string | null) => {
|
||||||
|
try {
|
||||||
|
await api.post(`/api/files/folders/${folderId}/move`, { targetParentId });
|
||||||
|
await refresh();
|
||||||
|
} catch (err: any) {
|
||||||
|
showError(err?.response?.data?.detail || err?.message || 'Move failed');
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}, [refresh, showError]);
|
||||||
|
|
||||||
|
const handleMoveFile = useCallback(async (fileId: string, targetFolderId: string | null) => {
|
||||||
|
try {
|
||||||
|
await api.post(`/api/files/${fileId}/move`, { targetFolderId });
|
||||||
|
} catch (err: any) {
|
||||||
|
showError(err?.response?.data?.detail || err?.message || 'Move failed');
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}, [showError]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
folders,
|
||||||
|
loading,
|
||||||
|
refresh,
|
||||||
|
handleCreateFolder,
|
||||||
|
handleRenameFolder,
|
||||||
|
handleDeleteFolder,
|
||||||
|
handleMoveFolder,
|
||||||
|
handleMoveFile,
|
||||||
|
};
|
||||||
|
}
|
||||||
198
src/hooks/useSpeechAudioCapture.ts
Normal file
198
src/hooks/useSpeechAudioCapture.ts
Normal 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 };
|
||||||
|
}
|
||||||
79
src/hooks/useTtsPlayback.ts
Normal file
79
src/hooks/useTtsPlayback.ts
Normal 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 };
|
||||||
|
}
|
||||||
|
|
@ -21,3 +21,12 @@ html, body {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
font-family: var(--font-family, "DM Sans", sans-serif);
|
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; }
|
||||||
|
}
|
||||||
|
|
@ -1,15 +1,20 @@
|
||||||
/**
|
/**
|
||||||
* FilesPage
|
* FilesPage
|
||||||
*
|
*
|
||||||
* Page for file management using FormGeneratorTable.
|
* Split-view file management: FolderTree on the left, FormGeneratorTable on the right.
|
||||||
* Follows the pattern established in AdminUsersPage/WorkflowsPage.
|
* 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 { useUserFiles, useFileOperations } from '../../hooks/useFiles';
|
||||||
|
import { useFileContext } from '../../contexts/FileContext';
|
||||||
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
|
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
|
||||||
import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm';
|
import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm';
|
||||||
import { 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 { useToast } from '../../contexts/ToastContext';
|
||||||
import styles from '../admin/Admin.module.css';
|
import styles from '../admin/Admin.module.css';
|
||||||
|
|
||||||
|
|
@ -18,19 +23,29 @@ interface UserFile {
|
||||||
fileName: string;
|
fileName: string;
|
||||||
mimeType?: string;
|
mimeType?: string;
|
||||||
fileSize?: number;
|
fileSize?: number;
|
||||||
|
folderId?: string | null;
|
||||||
|
featureInstanceId?: string;
|
||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const FilesPage: React.FC = () => {
|
export const FilesPage: React.FC = () => {
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const { showSuccess, showError } = useToast();
|
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 {
|
const {
|
||||||
data: files,
|
data: files,
|
||||||
attributes,
|
attributes,
|
||||||
permissions,
|
permissions,
|
||||||
pagination,
|
|
||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
refetch,
|
refetch,
|
||||||
|
|
@ -38,7 +53,6 @@ export const FilesPage: React.FC = () => {
|
||||||
updateFileOptimistically,
|
updateFileOptimistically,
|
||||||
} = useUserFiles();
|
} = useUserFiles();
|
||||||
|
|
||||||
// Operations hook
|
|
||||||
const {
|
const {
|
||||||
handleFileDownload,
|
handleFileDownload,
|
||||||
handleFileDelete,
|
handleFileDelete,
|
||||||
|
|
@ -53,16 +67,61 @@ export const FilesPage: React.FC = () => {
|
||||||
previewingFiles,
|
previewingFiles,
|
||||||
} = useFileOperations();
|
} = useFileOperations();
|
||||||
|
|
||||||
|
const {
|
||||||
|
folders,
|
||||||
|
refreshFolders,
|
||||||
|
handleCreateFolder,
|
||||||
|
handleRenameFolder,
|
||||||
|
handleDeleteFolder,
|
||||||
|
handleMoveFolder,
|
||||||
|
handleMoveFolders,
|
||||||
|
handleMoveFile,
|
||||||
|
handleMoveFiles: contextMoveFiles,
|
||||||
|
expandedFolderIds,
|
||||||
|
toggleFolderExpanded,
|
||||||
|
} = useFileContext();
|
||||||
|
|
||||||
const [editingFile, setEditingFile] = useState<UserFile | null>(null);
|
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 columns = useMemo(() => {
|
||||||
const hiddenColumns = ['id', 'mandateId', 'featureInstanceId', 'fileHash'];
|
const hiddenColumns = ['id', 'mandateId', 'fileHash', 'folderId'];
|
||||||
|
|
||||||
const cols = (attributes || [])
|
const cols = (attributes || [])
|
||||||
.filter(attr => !hiddenColumns.includes(attr.name))
|
.filter(attr => !hiddenColumns.includes(attr.name))
|
||||||
|
|
@ -76,9 +135,10 @@ export const FilesPage: React.FC = () => {
|
||||||
width: attr.width || 150,
|
width: attr.width || 150,
|
||||||
minWidth: attr.minWidth || 100,
|
minWidth: attr.minWidth || 100,
|
||||||
maxWidth: attr.maxWidth || 400,
|
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({
|
cols.push({
|
||||||
key: '_createdBy',
|
key: '_createdBy',
|
||||||
label: 'Created By',
|
label: 'Created By',
|
||||||
|
|
@ -94,20 +154,15 @@ export const FilesPage: React.FC = () => {
|
||||||
return cols;
|
return cols;
|
||||||
}, [attributes]);
|
}, [attributes]);
|
||||||
|
|
||||||
// Check permissions
|
|
||||||
const canCreate = permissions?.create !== 'n';
|
const canCreate = permissions?.create !== 'n';
|
||||||
const canUpdate = permissions?.update !== 'n';
|
const canUpdate = permissions?.update !== 'n';
|
||||||
const canDelete = permissions?.delete !== 'n';
|
const canDelete = permissions?.delete !== 'n';
|
||||||
|
|
||||||
// Handle edit click
|
|
||||||
const handleEditClick = async (file: UserFile) => {
|
const handleEditClick = async (file: UserFile) => {
|
||||||
const fullFile = await fetchFileById(file.id);
|
const fullFile = await fetchFileById(file.id);
|
||||||
if (fullFile) {
|
if (fullFile) setEditingFile(fullFile as UserFile);
|
||||||
setEditingFile(fullFile as UserFile);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle edit submit
|
|
||||||
const handleEditSubmit = async (data: Partial<UserFile>) => {
|
const handleEditSubmit = async (data: Partial<UserFile>) => {
|
||||||
if (!editingFile) return;
|
if (!editingFile) return;
|
||||||
const result = await handleFileUpdate(editingFile.id, {
|
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 handleDelete = async (file: UserFile) => {
|
||||||
const success = await handleFileDelete(file.id);
|
const success = await handleFileDelete(file.id);
|
||||||
if (success) {
|
if (success) refetch();
|
||||||
refetch();
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle delete multiple files (confirmation handled by FormGenerator)
|
|
||||||
const handleDeleteMultiple = async (filesToDelete: UserFile[]) => {
|
const handleDeleteMultiple = async (filesToDelete: UserFile[]) => {
|
||||||
const ids = filesToDelete.map(f => f.id);
|
const ids = filesToDelete.map(f => f.id);
|
||||||
const success = await handleFileDeleteMultiple(ids);
|
const success = await handleFileDeleteMultiple(ids);
|
||||||
if (success) {
|
if (success) refetch();
|
||||||
refetch();
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle download
|
|
||||||
const handleDownload = async (file: UserFile) => {
|
const handleDownload = async (file: UserFile) => {
|
||||||
await handleFileDownload(file.id, file.fileName);
|
await handleFileDownload(file.id, file.fileName);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle preview
|
|
||||||
const handlePreview = async (file: UserFile) => {
|
const handlePreview = async (file: UserFile) => {
|
||||||
const result = await handleFilePreview(file.id, file.fileName, file.mimeType);
|
const result = await handleFilePreview(file.id, file.fileName, file.mimeType);
|
||||||
if (result.success && result.previewUrl) {
|
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 handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const selectedFiles = e.target.files;
|
const selectedFiles = e.target.files;
|
||||||
if (selectedFiles && selectedFiles.length > 0) {
|
if (selectedFiles && selectedFiles.length > 0) {
|
||||||
let successCount = 0;
|
let successCount = 0;
|
||||||
let errorCount = 0;
|
let errorCount = 0;
|
||||||
|
|
||||||
for (const file of Array.from(selectedFiles)) {
|
for (const file of Array.from(selectedFiles)) {
|
||||||
const result = await handleFileUpload(file);
|
const result = await handleFileUpload(file);
|
||||||
if (result?.success) {
|
if (result?.success) successCount++; else errorCount++;
|
||||||
successCount++;
|
|
||||||
} else {
|
|
||||||
errorCount++;
|
|
||||||
}
|
}
|
||||||
}
|
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||||
|
|
||||||
// Reset input first
|
|
||||||
if (fileInputRef.current) {
|
|
||||||
fileInputRef.current.value = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Refresh table to show new files
|
|
||||||
await refetch();
|
await refetch();
|
||||||
|
|
||||||
// Show feedback
|
|
||||||
if (successCount > 0) {
|
if (successCount > 0) {
|
||||||
showSuccess(
|
showSuccess(
|
||||||
'Upload erfolgreich',
|
'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 formAttributes = useMemo(() => {
|
||||||
const excludedFields = ['id', 'mandateId', 'fileHash', '_createdBy', '_createdAt', '_modifiedAt', 'creationDate', 'source'];
|
const excludedFields = ['id', 'mandateId', 'fileHash', '_createdBy', '_createdAt', '_modifiedAt', 'creationDate', 'source'];
|
||||||
return (attributes || [])
|
return (attributes || []).filter(attr => !excludedFields.includes(attr.name));
|
||||||
.filter(attr => !excludedFields.includes(attr.name));
|
|
||||||
}, [attributes]);
|
}, [attributes]);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
|
|
@ -213,7 +307,6 @@ export const FilesPage: React.FC = () => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.adminPage}>
|
<div className={styles.adminPage}>
|
||||||
{/* Hidden file input */}
|
|
||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
|
|
@ -228,51 +321,115 @@ export const FilesPage: React.FC = () => {
|
||||||
<p className={styles.pageSubtitle}>Dateiverwaltung</p>
|
<p className={styles.pageSubtitle}>Dateiverwaltung</p>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.headerActions}>
|
<div className={styles.headerActions}>
|
||||||
<button
|
<button className={styles.secondaryButton} onClick={() => { refetch(); refreshFolders(); }} disabled={loading}>
|
||||||
className={styles.secondaryButton}
|
|
||||||
onClick={() => refetch()}
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
<FaSync className={loading ? 'spinning' : ''} /> Aktualisieren
|
<FaSync className={loading ? 'spinning' : ''} /> Aktualisieren
|
||||||
</button>
|
</button>
|
||||||
{canCreate && (
|
</div>
|
||||||
<button
|
</div>
|
||||||
className={styles.primaryButton}
|
|
||||||
onClick={handleUploadClick}
|
{/* Split-view container */}
|
||||||
disabled={uploadingFile}
|
<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'}
|
<FaUpload /> {uploadingFile ? 'Uploading...' : 'Datei hochladen'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.tableContainer}>
|
{/* Table content */}
|
||||||
|
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||||
{loading && (!files || files.length === 0) ? (
|
{loading && (!files || files.length === 0) ? (
|
||||||
<div className={styles.loadingContainer}>
|
<div className={styles.loadingContainer}>
|
||||||
<div className={styles.spinner} />
|
<div className={styles.spinner} />
|
||||||
<span>Lade Dateien...</span>
|
<span>Lade Dateien...</span>
|
||||||
</div>
|
</div>
|
||||||
) : !files || files.length === 0 ? (
|
) : filteredFiles.length === 0 ? (
|
||||||
<div className={styles.emptyState}>
|
<div className={styles.emptyState}>
|
||||||
<FaFolder className={styles.emptyIcon} />
|
<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}>
|
<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>
|
</p>
|
||||||
{canCreate && (
|
{canCreate && (
|
||||||
<button
|
<button className={styles.primaryButton} onClick={handleUploadClick} disabled={uploadingFile}>
|
||||||
className={styles.primaryButton}
|
<FaUpload /> Datei hochladen
|
||||||
onClick={handleUploadClick}
|
|
||||||
disabled={uploadingFile}
|
|
||||||
>
|
|
||||||
<FaUpload /> Erste Datei hochladen
|
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<FormGeneratorTable
|
<FormGeneratorTable
|
||||||
data={files}
|
data={filteredFiles}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
apiEndpoint="/api/files/list"
|
apiEndpoint="/api/files/list"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
|
|
@ -282,6 +439,12 @@ export const FilesPage: React.FC = () => {
|
||||||
filterable={true}
|
filterable={true}
|
||||||
sortable={true}
|
sortable={true}
|
||||||
selectable={true}
|
selectable={true}
|
||||||
|
onRowSelect={(rows) => setSelectedFiles(rows as UserFile[])}
|
||||||
|
rowDraggable={true}
|
||||||
|
onRowDragStart={_onRowDragStart}
|
||||||
|
getRowDataAttributes={(row: UserFile) =>
|
||||||
|
({ highlighted: row.id === highlightedFileId ? 'true' : 'false' })
|
||||||
|
}
|
||||||
actionButtons={[
|
actionButtons={[
|
||||||
...(canUpdate ? [{
|
...(canUpdate ? [{
|
||||||
type: 'edit' as const,
|
type: 'edit' as const,
|
||||||
|
|
@ -313,9 +476,8 @@ export const FilesPage: React.FC = () => {
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
onDeleteMultiple={handleDeleteMultiple}
|
onDeleteMultiple={handleDeleteMultiple}
|
||||||
hookData={{
|
hookData={{
|
||||||
refetch,
|
refetch: _tableRefetch,
|
||||||
permissions,
|
permissions,
|
||||||
pagination,
|
|
||||||
handleDelete: handleFileDelete,
|
handleDelete: handleFileDelete,
|
||||||
handleInlineUpdate,
|
handleInlineUpdate,
|
||||||
updateOptimistically: updateFileOptimistically,
|
updateOptimistically: updateFileOptimistically,
|
||||||
|
|
@ -324,6 +486,8 @@ export const FilesPage: React.FC = () => {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Edit Modal */}
|
{/* Edit Modal */}
|
||||||
{editingFile && (
|
{editingFile && (
|
||||||
|
|
@ -331,12 +495,7 @@ export const FilesPage: React.FC = () => {
|
||||||
<div className={styles.modal} onClick={e => e.stopPropagation()}>
|
<div className={styles.modal} onClick={e => e.stopPropagation()}>
|
||||||
<div className={styles.modalHeader}>
|
<div className={styles.modalHeader}>
|
||||||
<h2 className={styles.modalTitle}>Datei bearbeiten</h2>
|
<h2 className={styles.modalTitle}>Datei bearbeiten</h2>
|
||||||
<button
|
<button className={styles.modalClose} onClick={() => setEditingFile(null)}>✕</button>
|
||||||
className={styles.modalClose}
|
|
||||||
onClick={() => setEditingFile(null)}
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.modalContent}>
|
<div className={styles.modalContent}>
|
||||||
{formAttributes.length === 0 ? (
|
{formAttributes.length === 0 ? (
|
||||||
|
|
|
||||||
|
|
@ -337,15 +337,51 @@ export const BillingDataView: React.FC = () => {
|
||||||
|
|
||||||
const successParam = searchParams.get('success');
|
const successParam = searchParams.get('success');
|
||||||
const canceledParam = searchParams.get('canceled');
|
const canceledParam = searchParams.get('canceled');
|
||||||
|
const sessionIdParam = searchParams.get('session_id');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (successParam === 'true') {
|
let cancelled = false;
|
||||||
setCheckoutMessage({ type: 'success', text: 'Zahlung erfolgreich. Guthaben wird gutgeschrieben.' });
|
|
||||||
refetchBalances();
|
const _confirmCheckoutIfNeeded = async () => {
|
||||||
} else if (canceledParam === 'true') {
|
if (successParam !== 'true') {
|
||||||
|
if (canceledParam === 'true' && !cancelled) {
|
||||||
setCheckoutMessage({ type: 'error', text: 'Zahlung abgebrochen.' });
|
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(() => {
|
const _clearStripeParams = useCallback(() => {
|
||||||
searchParams.delete('success');
|
searchParams.delete('success');
|
||||||
|
|
@ -360,9 +396,16 @@ export const BillingDataView: React.FC = () => {
|
||||||
setCheckoutMessage(null);
|
setCheckoutMessage(null);
|
||||||
try {
|
try {
|
||||||
const currentUser = getUserDataCache();
|
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, {
|
const result = await createCheckoutSession(request, mandateId, {
|
||||||
userId: currentUser?.id,
|
userId: currentUser?.id,
|
||||||
amount,
|
amount,
|
||||||
|
returnUrl,
|
||||||
});
|
});
|
||||||
if (result?.redirectUrl) {
|
if (result?.redirectUrl) {
|
||||||
window.location.href = result.redirectUrl;
|
window.location.href = result.redirectUrl;
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,8 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useRef, useCallback, useEffect } from 'react';
|
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 { useApiRequest } from '../../../hooks/useApi';
|
||||||
import { useInstanceId } from '../../../hooks/useCurrentInstance';
|
import { useInstanceId } from '../../../hooks/useCurrentInstance';
|
||||||
import api from '../../../api';
|
import api from '../../../api';
|
||||||
|
|
@ -46,7 +47,9 @@ export const CommcoachDossierView: React.FC = () => {
|
||||||
const sendMessageRef = useRef(coach.sendMessage);
|
const sendMessageRef = useRef(coach.sendMessage);
|
||||||
sendMessageRef.current = coach.sendMessage;
|
sendMessageRef.current = coach.sendMessage;
|
||||||
|
|
||||||
const voice = useVoiceController((text) => sendMessageRef.current(text));
|
const voice = useVoiceController({
|
||||||
|
onFinalText: (text) => sendMessageRef.current(text),
|
||||||
|
});
|
||||||
|
|
||||||
// #region agent log
|
// #region agent log
|
||||||
const debugLogsRef = useRef<string[]>([]);
|
const debugLogsRef = useRef<string[]>([]);
|
||||||
|
|
@ -116,13 +119,13 @@ export const CommcoachDossierView: React.FC = () => {
|
||||||
}, [activeTab, coach.session?.id, voice]);
|
}, [activeTab, coach.session?.id, voice]);
|
||||||
|
|
||||||
const handleStopTts = useCallback(() => coach.stopTts(), [coach]);
|
const handleStopTts = useCallback(() => coach.stopTts(), [coach]);
|
||||||
|
const handlePauseTts = useCallback(() => coach.pauseTts(), [coach]);
|
||||||
const handleResumeTts = useCallback(() => coach.resumeTts(), [coach]);
|
const handleResumeTts = useCallback(() => coach.resumeTts(), [coach]);
|
||||||
|
|
||||||
const handleSend = useCallback(async () => {
|
const handleSend = useCallback(async () => {
|
||||||
if (!coach.inputValue.trim() || coach.isStreaming) return;
|
if (!coach.inputValue.trim() || coach.isStreaming) return;
|
||||||
voice.cancelPendingSpeech();
|
|
||||||
await coach.sendMessage(coach.inputValue);
|
await coach.sendMessage(coach.inputValue);
|
||||||
}, [coach, voice]);
|
}, [coach]);
|
||||||
|
|
||||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||||
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend(); }
|
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>
|
<span className={styles.sessionLabel}>Session aktiv</span>
|
||||||
<div className={styles.sessionActions}>
|
<div className={styles.sessionActions}>
|
||||||
{voice.state === 'botSpeaking' && (
|
{voice.state === 'botSpeaking' && (
|
||||||
|
<>
|
||||||
|
<button className={styles.btnSmall} onClick={handlePauseTts}>Pause</button>
|
||||||
<button className={styles.btnSmallDanger} onClick={handleStopTts}>Stop</button>
|
<button className={styles.btnSmallDanger} onClick={handleStopTts}>Stop</button>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
{voice.state === 'interrupted' && coach.hasAudioToResume() && (
|
{voice.state === 'interrupted' && coach.hasAudioToResume() && (
|
||||||
<button className={styles.btnSmall} onClick={handleResumeTts}>Weitersprechen</button>
|
<button className={styles.btnSmall} onClick={handleResumeTts}>Weitersprechen</button>
|
||||||
|
|
|
||||||
|
|
@ -4,18 +4,15 @@
|
||||||
* States: idle | listening | botSpeaking | interrupted
|
* States: idle | listening | botSpeaking | interrupted
|
||||||
* Muted: orthogonal boolean flag (independent of main state)
|
* Muted: orthogonal boolean flag (independent of main state)
|
||||||
*
|
*
|
||||||
* Recognition is STOPPED during botSpeaking or when muted=true.
|
* Uses the generic useVoiceStream hook for mic capture + STT streaming.
|
||||||
* Recognition is STARTED when entering listening/interrupted AND muted=false.
|
* Google Streaming STT handles silence detection natively.
|
||||||
* Each start() creates a fresh results session (processedIndex resets to 0).
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
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';
|
export type VoiceState = 'idle' | 'listening' | 'botSpeaking' | 'interrupted';
|
||||||
|
|
||||||
const SILENCE_TIMEOUT_MS = 1000;
|
|
||||||
const REC_AUTORESTART_DELAY_MS = 300;
|
|
||||||
|
|
||||||
export interface VoiceControllerApi {
|
export interface VoiceControllerApi {
|
||||||
state: VoiceState;
|
state: VoiceState;
|
||||||
muted: boolean;
|
muted: boolean;
|
||||||
|
|
@ -26,28 +23,25 @@ export interface VoiceControllerApi {
|
||||||
ttsPaused: () => void;
|
ttsPaused: () => void;
|
||||||
ttsEnded: () => void;
|
ttsEnded: () => void;
|
||||||
toggleMute: () => 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 [state, setState] = useState<VoiceState>('idle');
|
||||||
const [muted, setMuted] = useState(false);
|
const [muted, setMuted] = useState(false);
|
||||||
const [liveTranscript, setLiveTranscript] = useState('');
|
|
||||||
const stateRef = useRef<VoiceState>('idle');
|
const stateRef = useRef<VoiceState>('idle');
|
||||||
const mutedRef = useRef(false);
|
const mutedRef = useRef(false);
|
||||||
const streamRef = useRef<MediaStream | null>(null);
|
const cbRef = useRef(callbacks);
|
||||||
const recognitionRef = useRef<SpeechRecognition | null>(null);
|
cbRef.current = callbacks;
|
||||||
const transcriptPartsRef = useRef<string[]>([]);
|
|
||||||
const processedIndexRef = useRef(0);
|
|
||||||
const silenceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
const onMessageRef = useRef(onMessage);
|
|
||||||
onMessageRef.current = onMessage;
|
|
||||||
|
|
||||||
const _dlog = useCallback((tag: string, info?: string) => {
|
const _dlog = useCallback((tag: string, info?: string) => {
|
||||||
const t = new Date();
|
const t = new Date();
|
||||||
const ts = `${t.getMinutes()}:${String(t.getSeconds()).padStart(2, '0')}.${String(t.getMilliseconds()).padStart(3, '0')}`;
|
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?.(`[${ts}] ${tag}${info ? ' ' + info : ''}`);
|
||||||
(window as any).__dlog?.(entry);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const _setState = useCallback((next: VoiceState) => {
|
const _setState = useCallback((next: VoiceState) => {
|
||||||
|
|
@ -64,183 +58,51 @@ export function useVoiceController(onMessage: (text: string) => void): VoiceCont
|
||||||
_dlog('MUTED', String(next));
|
_dlog('MUTED', String(next));
|
||||||
}, [_dlog]);
|
}, [_dlog]);
|
||||||
|
|
||||||
const _cancelSilenceTimer = useCallback(() => {
|
const voiceStream = useVoiceStream({
|
||||||
if (silenceTimerRef.current) {
|
onFinal: (text) => {
|
||||||
clearTimeout(silenceTimerRef.current);
|
cbRef.current.onFinalText?.(text);
|
||||||
silenceTimerRef.current = null;
|
},
|
||||||
}
|
onInterim: (text) => {
|
||||||
}, []);
|
cbRef.current.onInterimText?.(text);
|
||||||
|
},
|
||||||
const _finalizeTranscript = useCallback(() => {
|
onError: (err) => _dlog('VOICE-ERR', String(err)),
|
||||||
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 activate = useCallback(async () => {
|
const activate = useCallback(async () => {
|
||||||
if (stateRef.current !== 'idle') return;
|
if (stateRef.current !== 'idle') return;
|
||||||
_setState('listening');
|
_setState('listening');
|
||||||
transcriptPartsRef.current = [];
|
|
||||||
processedIndexRef.current = 0;
|
|
||||||
setLiveTranscript('');
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!streamRef.current) {
|
await voiceStream.start('de-DE');
|
||||||
const stream = await navigator.mediaDevices.getUserMedia({
|
|
||||||
audio: { echoCancellation: true, noiseSuppression: true },
|
|
||||||
});
|
|
||||||
streamRef.current = stream;
|
|
||||||
}
|
|
||||||
_createRecognition();
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('Mic access failed:', err);
|
_dlog('MIC-ERR', String(err));
|
||||||
_setState('idle');
|
_setState('idle');
|
||||||
}
|
}
|
||||||
}, [_setState, _createRecognition]);
|
}, [_setState, voiceStream, _dlog]);
|
||||||
|
|
||||||
const deactivate = useCallback(() => {
|
const deactivate = useCallback(() => {
|
||||||
_cancelSilenceTimer();
|
voiceStream.stop();
|
||||||
_setState('idle');
|
_setState('idle');
|
||||||
if (recognitionRef.current) {
|
}, [_setState, voiceStream]);
|
||||||
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]);
|
|
||||||
|
|
||||||
const ttsPlaying = useCallback(() => {
|
const ttsPlaying = useCallback(() => {
|
||||||
const cur = stateRef.current;
|
const cur = stateRef.current;
|
||||||
if (cur === 'idle') return;
|
if (cur === 'idle') return;
|
||||||
_cancelSilenceTimer();
|
voiceStream.stop();
|
||||||
_finalizeTranscript();
|
|
||||||
_stopRecognition();
|
|
||||||
_setState('botSpeaking');
|
_setState('botSpeaking');
|
||||||
}, [_setState, _cancelSilenceTimer, _finalizeTranscript, _stopRecognition]);
|
}, [_setState, voiceStream]);
|
||||||
|
|
||||||
const ttsPaused = useCallback(() => {
|
const ttsPaused = useCallback(() => {
|
||||||
const cur = stateRef.current;
|
if (stateRef.current !== 'botSpeaking') return;
|
||||||
if (cur !== 'botSpeaking') return;
|
|
||||||
transcriptPartsRef.current = [];
|
|
||||||
processedIndexRef.current = 0;
|
|
||||||
setLiveTranscript('');
|
|
||||||
_setState('interrupted');
|
_setState('interrupted');
|
||||||
_startRecognition();
|
voiceStream.start('de-DE').catch((err) => _dlog('MIC-ERR', String(err)));
|
||||||
}, [_setState, _startRecognition]);
|
}, [_setState, voiceStream, _dlog]);
|
||||||
|
|
||||||
const ttsEnded = useCallback(() => {
|
const ttsEnded = useCallback(() => {
|
||||||
const cur = stateRef.current;
|
const cur = stateRef.current;
|
||||||
if (cur !== 'botSpeaking' && cur !== 'interrupted') return;
|
if (cur !== 'botSpeaking' && cur !== 'interrupted') return;
|
||||||
transcriptPartsRef.current = [];
|
|
||||||
processedIndexRef.current = 0;
|
|
||||||
setLiveTranscript('');
|
|
||||||
_setState('listening');
|
_setState('listening');
|
||||||
_startRecognition();
|
voiceStream.start('de-DE').catch((err) => _dlog('MIC-ERR', String(err)));
|
||||||
}, [_setState, _startRecognition]);
|
}, [_setState, voiceStream, _dlog]);
|
||||||
|
|
||||||
const toggleMute = useCallback(() => {
|
const toggleMute = useCallback(() => {
|
||||||
const cur = stateRef.current;
|
const cur = stateRef.current;
|
||||||
|
|
@ -248,45 +110,23 @@ export function useVoiceController(onMessage: (text: string) => void): VoiceCont
|
||||||
if (mutedRef.current) {
|
if (mutedRef.current) {
|
||||||
_setMuted(false);
|
_setMuted(false);
|
||||||
if (cur === 'listening' || cur === 'interrupted') {
|
if (cur === 'listening' || cur === 'interrupted') {
|
||||||
_startRecognition();
|
voiceStream.start('de-DE').catch((err) => _dlog('MIC-ERR', String(err)));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
_setMuted(true);
|
_setMuted(true);
|
||||||
_stopRecognition();
|
voiceStream.stop();
|
||||||
}
|
}
|
||||||
}, [_setMuted, _startRecognition, _stopRecognition]);
|
}, [_setMuted, voiceStream, _dlog]);
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
state,
|
state,
|
||||||
muted,
|
muted,
|
||||||
liveTranscript,
|
liveTranscript: voiceStream.interimText,
|
||||||
activate,
|
activate,
|
||||||
deactivate,
|
deactivate,
|
||||||
ttsPlaying,
|
ttsPlaying,
|
||||||
ttsPaused,
|
ttsPaused,
|
||||||
ttsEnded,
|
ttsEnded,
|
||||||
toggleMute,
|
toggleMute,
|
||||||
cancelPendingSpeech,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
<span
|
||||||
style={{
|
style={{
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
|
|
@ -318,6 +325,7 @@ export const ConversationList: React.FC<ConversationListProps> = ({
|
||||||
>
|
>
|
||||||
{conv.name}
|
{conv.name}
|
||||||
</span>
|
</span>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Action buttons (visible on hover) */}
|
{/* Action buttons (visible on hover) */}
|
||||||
|
|
@ -383,29 +391,6 @@ export const ConversationList: React.FC<ConversationListProps> = ({
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
|
||||||
|
|
@ -1,76 +1,84 @@
|
||||||
/**
|
/**
|
||||||
* FileBrowser -- Tree-structured file browser.
|
* FileBrowser -- Folder-tree file browser for workspace.
|
||||||
*
|
*
|
||||||
* Level 1: Feature instance (group header, collapsible)
|
* Uses useFileContext() for folders (shared state with Dateien page).
|
||||||
* Level 2: Files sorted alphabetically
|
* Uses FolderTree with showFiles=true so folders and files render inline.
|
||||||
*
|
|
||||||
* Supports search, drag-and-drop upload, and file selection.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useCallback, useRef, useMemo } from 'react';
|
import React, { useState, useCallback, useRef, useMemo } from 'react';
|
||||||
import api from '../../../api';
|
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 {
|
interface FileBrowserProps {
|
||||||
instanceId: string;
|
instanceId: string;
|
||||||
files: WorkspaceFile[];
|
files: WorkspaceFile[];
|
||||||
folders: WorkspaceFolder[];
|
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
onFileSelect?: (fileId: string) => void;
|
onFileSelect?: (fileId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface _InstanceGroup {
|
|
||||||
instanceId: string;
|
|
||||||
label: string;
|
|
||||||
files: WorkspaceFile[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const FileBrowser: React.FC<FileBrowserProps> = ({
|
export const FileBrowser: React.FC<FileBrowserProps> = ({
|
||||||
instanceId,
|
instanceId,
|
||||||
files,
|
files,
|
||||||
folders: _folders,
|
|
||||||
onRefresh,
|
onRefresh,
|
||||||
onFileSelect,
|
onFileSelect,
|
||||||
}) => {
|
}) => {
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [isDragOver, setIsDragOver] = useState(false);
|
const [isDragOver, setIsDragOver] = useState(false);
|
||||||
const [uploading, setUploading] = 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 fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const _filteredFiles = useMemo(() => {
|
const {
|
||||||
if (!searchQuery.trim()) return files;
|
folders,
|
||||||
const q = searchQuery.toLowerCase();
|
refreshFolders,
|
||||||
return files.filter(f =>
|
handleCreateFolder,
|
||||||
f.fileName.toLowerCase().includes(q)
|
handleRenameFolder,
|
||||||
|| (f.tags || []).some(t => t.toLowerCase().includes(q)),
|
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]);
|
}, [files, searchQuery]);
|
||||||
|
|
||||||
const _groups = useMemo((): _InstanceGroup[] => {
|
const _refreshAll = useCallback(() => {
|
||||||
const map: Record<string, _InstanceGroup> = {};
|
onRefresh();
|
||||||
for (const f of _filteredFiles) {
|
refreshFolders();
|
||||||
const key = f.featureInstanceId || '_workspace';
|
}, [onRefresh, refreshFolders]);
|
||||||
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 _uploadFiles = useCallback(async (fileList: FileList | File[]) => {
|
const _uploadFiles = useCallback(async (fileList: FileList | File[]) => {
|
||||||
if (!instanceId || uploading) return;
|
if (!instanceId || uploading) return;
|
||||||
|
|
@ -84,18 +92,20 @@ export const FileBrowser: React.FC<FileBrowserProps> = ({
|
||||||
headers: { 'Content-Type': 'multipart/form-data' },
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
onRefresh();
|
_refreshAll();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('File upload failed:', err);
|
console.error('File upload failed:', err);
|
||||||
} finally {
|
} finally {
|
||||||
setUploading(false);
|
setUploading(false);
|
||||||
}
|
}
|
||||||
}, [instanceId, uploading, onRefresh]);
|
}, [instanceId, uploading, _refreshAll]);
|
||||||
|
|
||||||
const _handleDragOver = useCallback((e: React.DragEvent) => {
|
const _handleDragOver = useCallback((e: React.DragEvent) => {
|
||||||
|
if (e.dataTransfer.types.includes('Files')) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setIsDragOver(true);
|
setIsDragOver(true);
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const _handleDragLeave = useCallback((e: React.DragEvent) => {
|
const _handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||||
|
|
@ -120,9 +130,46 @@ export const FileBrowser: React.FC<FileBrowserProps> = ({
|
||||||
}
|
}
|
||||||
}, [_uploadFiles]);
|
}, [_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 (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{ padding: 8, position: 'relative' }}
|
style={{ padding: 8, position: 'relative', display: 'flex', flexDirection: 'column', gap: 4 }}
|
||||||
onDragOver={_handleDragOver}
|
onDragOver={_handleDragOver}
|
||||||
onDragLeave={_handleDragLeave}
|
onDragLeave={_handleDragLeave}
|
||||||
onDrop={_handleDrop}
|
onDrop={_handleDrop}
|
||||||
|
|
@ -140,7 +187,7 @@ export const FileBrowser: React.FC<FileBrowserProps> = ({
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Header */}
|
{/* 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>
|
<span style={{ fontSize: 12, fontWeight: 600, color: '#666', textTransform: 'uppercase' }}>Files</span>
|
||||||
<div style={{ display: 'flex', gap: 4 }}>
|
<div style={{ display: 'flex', gap: 4 }}>
|
||||||
<button
|
<button
|
||||||
|
|
@ -151,7 +198,7 @@ export const FileBrowser: React.FC<FileBrowserProps> = ({
|
||||||
>
|
>
|
||||||
{uploading ? '...' : '+'}
|
{uploading ? '...' : '+'}
|
||||||
</button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -165,94 +212,39 @@ export const FileBrowser: React.FC<FileBrowserProps> = ({
|
||||||
onChange={e => setSearchQuery(e.target.value)}
|
onChange={e => setSearchQuery(e.target.value)}
|
||||||
style={{
|
style={{
|
||||||
width: '100%', padding: '6px 10px', fontSize: 12, borderRadius: 4,
|
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 */}
|
{/* Folder tree with inline files */}
|
||||||
{_groups.length === 0 && (
|
<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 }}>
|
<div style={{ fontSize: 12, color: '#999', textAlign: 'center', padding: 16 }}>
|
||||||
{searchQuery ? 'Keine Dateien gefunden' : 'Keine Dateien. Drag & Drop zum Hochladen.'}
|
{searchQuery ? 'Keine Dateien gefunden' : 'Keine Dateien. Drag & Drop zum Hochladen.'}
|
||||||
</div>
|
</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>
|
</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';
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
/**
|
/**
|
||||||
* WorkspaceInput -- Prompt input with @file autocomplete, attachment bar,
|
* 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 React, { useState, useCallback, useRef, useEffect } from 'react';
|
||||||
import { ProviderMultiSelect } from '../../../components/ProviderSelector';
|
import { ProviderMultiSelect } from '../../../components/ProviderSelector';
|
||||||
|
import { useVoiceStream } from '../../../hooks/useSpeechAudioCapture';
|
||||||
import type { WorkspaceFile, DataSource } from './useWorkspace';
|
import type { WorkspaceFile, DataSource } from './useWorkspace';
|
||||||
|
|
||||||
const _STT_LANGUAGES = [
|
const _STT_LANGUAGES = [
|
||||||
|
|
@ -22,13 +23,16 @@ const _STT_LANGUAGES = [
|
||||||
{ code: 'zh-CN', label: 'Chinese' },
|
{ code: 'zh-CN', label: 'Chinese' },
|
||||||
];
|
];
|
||||||
|
|
||||||
function _getSpeechRecognitionApi(): (new () => SpeechRecognition) | null {
|
|
||||||
return (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PendingFile {
|
interface PendingFile {
|
||||||
fileId: string;
|
fileId: string;
|
||||||
fileName: string;
|
fileName: string;
|
||||||
|
itemType?: 'file' | 'folder';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TreeItemDrop {
|
||||||
|
id: string;
|
||||||
|
type: 'file' | 'folder';
|
||||||
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface WorkspaceInputProps {
|
interface WorkspaceInputProps {
|
||||||
|
|
@ -45,6 +49,8 @@ interface WorkspaceInputProps {
|
||||||
selectedProviders?: string[];
|
selectedProviders?: string[];
|
||||||
onProvidersChange?: (providers: string[]) => void;
|
onProvidersChange?: (providers: string[]) => void;
|
||||||
isMobile?: boolean;
|
isMobile?: boolean;
|
||||||
|
onTreeItemsDrop?: (items: TreeItemDrop[]) => void;
|
||||||
|
onPasteAsFile?: (file: File) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({
|
export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({
|
||||||
|
|
@ -61,21 +67,22 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({
|
||||||
selectedProviders = [],
|
selectedProviders = [],
|
||||||
onProvidersChange,
|
onProvidersChange,
|
||||||
isMobile = false,
|
isMobile = false,
|
||||||
|
onTreeItemsDrop,
|
||||||
|
onPasteAsFile,
|
||||||
}) => {
|
}) => {
|
||||||
const [prompt, setPrompt] = useState('');
|
const [prompt, setPrompt] = useState('');
|
||||||
const [showAutocomplete, setShowAutocomplete] = useState(false);
|
const [showAutocomplete, setShowAutocomplete] = useState(false);
|
||||||
const [autocompleteFilter, setAutocompleteFilter] = useState('');
|
const [autocompleteFilter, setAutocompleteFilter] = useState('');
|
||||||
|
const [treeDropOver, setTreeDropOver] = useState(false);
|
||||||
const [voiceActive, setVoiceActive] = useState(false);
|
const [voiceActive, setVoiceActive] = useState(false);
|
||||||
const [voiceLanguage, setVoiceLanguage] = useState(() => localStorage.getItem('workspace_stt_lang') || 'de-DE');
|
const [voiceLanguage, setVoiceLanguage] = useState(() => localStorage.getItem('workspace_stt_lang') || 'de-DE');
|
||||||
const [, setLiveTranscript] = useState('');
|
|
||||||
const [showLangPicker, setShowLangPicker] = useState(false);
|
const [showLangPicker, setShowLangPicker] = useState(false);
|
||||||
const [attachedFileIds, setAttachedFileIds] = useState<string[]>([]);
|
const [attachedFileIds, setAttachedFileIds] = useState<string[]>([]);
|
||||||
const [attachedDataSourceIds, setAttachedDataSourceIds] = useState<string[]>([]);
|
const [attachedDataSourceIds, setAttachedDataSourceIds] = useState<string[]>([]);
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const recognitionRef = useRef<SpeechRecognition | null>(null);
|
|
||||||
const transcriptPartsRef = useRef<string[]>([]);
|
|
||||||
const processedIndexRef = useRef(0);
|
|
||||||
const promptBeforeVoiceRef = useRef('');
|
const promptBeforeVoiceRef = useRef('');
|
||||||
|
const finalizedTextRef = useRef('');
|
||||||
|
const currentInterimRef = useRef('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
localStorage.setItem('workspace_stt_lang', voiceLanguage);
|
localStorage.setItem('workspace_stt_lang', voiceLanguage);
|
||||||
|
|
@ -171,98 +178,60 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({
|
||||||
);
|
);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const _stopRecognition = useCallback(() => {
|
const _buildPromptFromRefs = useCallback(() => {
|
||||||
if (recognitionRef.current) {
|
const parts = [
|
||||||
try { recognitionRef.current.stop(); } catch { /* ignore */ }
|
promptBeforeVoiceRef.current,
|
||||||
recognitionRef.current = null;
|
finalizedTextRef.current,
|
||||||
}
|
currentInterimRef.current,
|
||||||
const finalText = transcriptPartsRef.current.join(' ').trim();
|
].filter(Boolean);
|
||||||
if (finalText) {
|
return parts.join(' ');
|
||||||
setPrompt(() => {
|
|
||||||
const base = promptBeforeVoiceRef.current;
|
|
||||||
return base ? `${base} ${finalText}` : finalText;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
setLiveTranscript('');
|
|
||||||
transcriptPartsRef.current = [];
|
|
||||||
processedIndexRef.current = 0;
|
|
||||||
setVoiceActive(false);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
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 () => {
|
const _toggleVoice = useCallback(async () => {
|
||||||
if (voiceActive) {
|
if (voiceActive) {
|
||||||
_stopRecognition();
|
_stopVoiceCapture();
|
||||||
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');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
promptBeforeVoiceRef.current = prompt;
|
promptBeforeVoiceRef.current = prompt;
|
||||||
transcriptPartsRef.current = [];
|
finalizedTextRef.current = '';
|
||||||
processedIndexRef.current = 0;
|
currentInterimRef.current = '';
|
||||||
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);
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
recognition.start();
|
|
||||||
recognitionRef.current = recognition;
|
|
||||||
setVoiceActive(true);
|
setVoiceActive(true);
|
||||||
} catch (err) {
|
await voiceStream.start(voiceLanguage);
|
||||||
console.error('SpeechRecognition start failed:', err);
|
} catch {
|
||||||
|
setVoiceActive(false);
|
||||||
}
|
}
|
||||||
}, [voiceActive, voiceLanguage, prompt, _stopRecognition]);
|
}, [voiceActive, prompt, voiceStream, voiceLanguage, _stopVoiceCapture]);
|
||||||
|
|
||||||
const filteredFiles = showAutocomplete
|
const filteredFiles = showAutocomplete
|
||||||
? files.filter(f => f.fileName.toLowerCase().includes(autocompleteFilter))
|
? files.filter(f => f.fileName.toLowerCase().includes(autocompleteFilter))
|
||||||
|
|
@ -272,12 +241,52 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({
|
||||||
const _horizontalPadding = isMobile ? 12 : 24;
|
const _horizontalPadding = isMobile ? 12 : 24;
|
||||||
const _controlSize = isMobile ? 38 : 40;
|
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 (
|
return (
|
||||||
<div style={{
|
<div
|
||||||
|
style={{
|
||||||
borderTop: '1px solid var(--border-color, #e0e0e0)',
|
borderTop: '1px solid var(--border-color, #e0e0e0)',
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
flexShrink: 0,
|
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 */}
|
{/* Pending uploaded files */}
|
||||||
{pendingFiles.length > 0 && (
|
{pendingFiles.length > 0 && (
|
||||||
<div style={{
|
<div style={{
|
||||||
|
|
@ -294,11 +303,13 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({
|
||||||
style={{
|
style={{
|
||||||
display: 'inline-flex', alignItems: 'center', gap: 4,
|
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||||||
padding: '3px 8px', borderRadius: 12, fontSize: 11,
|
padding: '3px 8px', borderRadius: 12, fontSize: 11,
|
||||||
background: '#fff3e0', color: '#e65100', fontWeight: 500,
|
background: pf.itemType === 'folder' ? '#e3f2fd' : '#fff3e0',
|
||||||
border: '1px solid #ffe0b2',
|
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 && (
|
{onRemovePendingFile && (
|
||||||
<button
|
<button
|
||||||
onClick={() => onRemovePendingFile(pf.fileId)}
|
onClick={() => onRemovePendingFile(pf.fileId)}
|
||||||
|
|
@ -426,6 +437,7 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({
|
||||||
value={prompt}
|
value={prompt}
|
||||||
onChange={_handleChange}
|
onChange={_handleChange}
|
||||||
onKeyDown={_handleKeyDown}
|
onKeyDown={_handleKeyDown}
|
||||||
|
onPaste={_handlePaste}
|
||||||
placeholder="Type a message... Use @filename to reference files"
|
placeholder="Type a message... Use @filename to reference files"
|
||||||
disabled={isProcessing}
|
disabled={isProcessing}
|
||||||
style={{
|
style={{
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,7 @@ type RightTab = 'activity' | 'preview';
|
||||||
interface PendingFile {
|
interface PendingFile {
|
||||||
fileId: string;
|
fileId: string;
|
||||||
fileName: string;
|
fileName: string;
|
||||||
|
itemType?: 'file' | 'folder';
|
||||||
}
|
}
|
||||||
|
|
||||||
interface WorkspacePageProps {
|
interface WorkspacePageProps {
|
||||||
|
|
@ -156,6 +157,20 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
|
||||||
setPendingFiles(prev => prev.filter(f => f.fileId !== fileId));
|
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) {
|
if (!instanceId) {
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: 32, textAlign: 'center', color: '#999' }}>
|
<div style={{ padding: 32, textAlign: 'center', color: '#999' }}>
|
||||||
|
|
@ -212,7 +227,6 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
|
||||||
<FileBrowser
|
<FileBrowser
|
||||||
instanceId={instanceId}
|
instanceId={instanceId}
|
||||||
files={workspace.files}
|
files={workspace.files}
|
||||||
folders={workspace.folders}
|
|
||||||
onRefresh={workspace.refreshFiles}
|
onRefresh={workspace.refreshFiles}
|
||||||
onFileSelect={_handleFileSelect}
|
onFileSelect={_handleFileSelect}
|
||||||
/>
|
/>
|
||||||
|
|
@ -391,6 +405,8 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
|
||||||
selectedProviders={selectedProviders}
|
selectedProviders={selectedProviders}
|
||||||
onProvidersChange={setSelectedProviders}
|
onProvidersChange={setSelectedProviders}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
|
onTreeItemsDrop={_handleTreeItemsDrop}
|
||||||
|
onPasteAsFile={_uploadAndAttach}
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue