ai workspace ui fixes
This commit is contained in:
parent
8babf0d4f9
commit
a7bfdddc00
9 changed files with 510 additions and 173 deletions
|
|
@ -479,7 +479,7 @@ export function useFileOperations() {
|
||||||
* - Removed workflowId from FileItem creation in interfaceComponentObjects.py
|
* - Removed workflowId from FileItem creation in interfaceComponentObjects.py
|
||||||
* - Upload should now work correctly
|
* - Upload should now work correctly
|
||||||
*/
|
*/
|
||||||
const handleFileUpload = async (file: globalThis.File, workflowId?: string) => {
|
const handleFileUpload = async (file: globalThis.File, workflowId?: string, featureInstanceId?: string) => {
|
||||||
setUploadError(null);
|
setUploadError(null);
|
||||||
setUploadingFile(true);
|
setUploadingFile(true);
|
||||||
|
|
||||||
|
|
@ -500,6 +500,9 @@ export function useFileOperations() {
|
||||||
if (workflowId) {
|
if (workflowId) {
|
||||||
formData.append('workflowId', workflowId);
|
formData.append('workflowId', workflowId);
|
||||||
}
|
}
|
||||||
|
if (featureInstanceId) {
|
||||||
|
formData.append('featureInstanceId', featureInstanceId);
|
||||||
|
}
|
||||||
|
|
||||||
// FormData is now correctly configured for backend
|
// FormData is now correctly configured for backend
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -88,6 +88,34 @@
|
||||||
border-color: var(--text-secondary);
|
border-color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.googleButton {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: #4285f4;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s, transform 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.googleButton:hover {
|
||||||
|
background: #3367d6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.googleButton:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
.googleButton:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
/* Filter Section Styles */
|
/* Filter Section Styles */
|
||||||
.filterSection {
|
.filterSection {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
|
||||||
|
|
@ -221,7 +221,7 @@ export const ConnectionsPage: React.FC = () => {
|
||||||
{canCreate && (
|
{canCreate && (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
className={styles.secondaryButton}
|
className={styles.googleButton}
|
||||||
onClick={handleCreateGoogle}
|
onClick={handleCreateGoogle}
|
||||||
disabled={isConnecting}
|
disabled={isConnecting}
|
||||||
>
|
>
|
||||||
|
|
@ -255,7 +255,7 @@ export const ConnectionsPage: React.FC = () => {
|
||||||
{canCreate && (
|
{canCreate && (
|
||||||
<div style={{ display: 'flex', gap: '0.75rem', justifyContent: 'center' }}>
|
<div style={{ display: 'flex', gap: '0.75rem', justifyContent: 'center' }}>
|
||||||
<button
|
<button
|
||||||
className={styles.secondaryButton}
|
className={styles.googleButton}
|
||||||
onClick={handleCreateGoogle}
|
onClick={handleCreateGoogle}
|
||||||
disabled={isConnecting}
|
disabled={isConnecting}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,15 @@
|
||||||
/**
|
/**
|
||||||
* ConversationList -- Shows all workspace workflows/conversations.
|
* ConversationList -- Shows all workspace workflows/conversations.
|
||||||
*
|
*
|
||||||
* Loads conversations from the workspace API, displays them sorted by
|
* Features: filter, rename (double-click), delete, archive, create new,
|
||||||
* last activity. Names are auto-generated ("Chat N") and editable inline.
|
* pagination (20 per page), last-activity display.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import api from '../../../api';
|
import api from '../../../api';
|
||||||
|
|
||||||
|
const _PAGE_SIZE = 20;
|
||||||
|
|
||||||
interface Conversation {
|
interface Conversation {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -32,12 +34,15 @@ export const ConversationList: React.FC<ConversationListProps> = ({
|
||||||
const [editingId, setEditingId] = useState<string | null>(null);
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
const [editName, setEditName] = useState('');
|
const [editName, setEditName] = useState('');
|
||||||
const [filterQuery, setFilterQuery] = useState('');
|
const [filterQuery, setFilterQuery] = useState('');
|
||||||
|
const [page, setPage] = useState(0);
|
||||||
|
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
|
||||||
|
const [viewMode, setViewMode] = useState<'active' | 'archived'>('active');
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const _loadConversations = useCallback(() => {
|
const _loadConversations = useCallback(() => {
|
||||||
if (!instanceId) return;
|
if (!instanceId) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
api.get(`/api/workspace/${instanceId}/workflows`)
|
api.get(`/api/workspace/${instanceId}/workflows`, { params: { includeArchived: true } })
|
||||||
.then(res => {
|
.then(res => {
|
||||||
const items = (res.data.workflows || res.data || [])
|
const items = (res.data.workflows || res.data || [])
|
||||||
.map((w: any) => ({
|
.map((w: any) => ({
|
||||||
|
|
@ -76,8 +81,8 @@ export const ConversationList: React.FC<ConversationListProps> = ({
|
||||||
if (diffDays === 0) {
|
if (diffDays === 0) {
|
||||||
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||||
}
|
}
|
||||||
if (diffDays === 1) return 'Yesterday';
|
if (diffDays === 1) return 'Gestern';
|
||||||
if (diffDays < 7) return `${diffDays}d ago`;
|
if (diffDays < 7) return `vor ${diffDays}d`;
|
||||||
return d.toLocaleDateString([], { month: 'short', day: 'numeric' });
|
return d.toLocaleDateString([], { month: 'short', day: 'numeric' });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -116,6 +121,41 @@ export const ConversationList: React.FC<ConversationListProps> = ({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const _handleDelete = (convId: string) => {
|
||||||
|
setConversations(prev => prev.filter(c => c.id !== convId));
|
||||||
|
if (activeWorkflowId === convId) onSelect('');
|
||||||
|
api.delete(`/api/workspace/${instanceId}/workflows/${convId}`)
|
||||||
|
.catch(() => _loadConversations());
|
||||||
|
};
|
||||||
|
|
||||||
|
const _handleArchive = (convId: string) => {
|
||||||
|
setConversations(prev => prev.map(c =>
|
||||||
|
c.id === convId ? { ...c, status: 'archived' } : c,
|
||||||
|
));
|
||||||
|
api.patch(`/api/workspace/${instanceId}/workflows/${convId}`, { status: 'archived' })
|
||||||
|
.catch(() => _loadConversations());
|
||||||
|
};
|
||||||
|
|
||||||
|
const _handleReactivate = (convId: string) => {
|
||||||
|
setConversations(prev => prev.map(c =>
|
||||||
|
c.id === convId ? { ...c, status: 'active' } : c,
|
||||||
|
));
|
||||||
|
api.patch(`/api/workspace/${instanceId}/workflows/${convId}`, { status: 'active' })
|
||||||
|
.catch(() => _loadConversations());
|
||||||
|
};
|
||||||
|
|
||||||
|
const _handleCreateNew = () => {
|
||||||
|
api.post(`/api/workspace/${instanceId}/workflows`, {})
|
||||||
|
.then(res => {
|
||||||
|
const wf = res.data;
|
||||||
|
if (wf?.id) {
|
||||||
|
_loadConversations();
|
||||||
|
onSelect(wf.id);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
};
|
||||||
|
|
||||||
const _filtered = (items: Conversation[], query: string): Conversation[] => {
|
const _filtered = (items: Conversation[], query: string): Conversation[] => {
|
||||||
if (!query.trim()) return items;
|
if (!query.trim()) return items;
|
||||||
const q = query.toLowerCase();
|
const q = query.toLowerCase();
|
||||||
|
|
@ -124,20 +164,70 @@ export const ConversationList: React.FC<ConversationListProps> = ({
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const _byStatus = viewMode === 'archived'
|
||||||
|
? conversations.filter(c => c.status === 'archived')
|
||||||
|
: conversations.filter(c => c.status !== 'archived');
|
||||||
|
const filtered = _filtered(_byStatus, filterQuery);
|
||||||
|
const totalPages = Math.ceil(filtered.length / _PAGE_SIZE);
|
||||||
|
const paginated = filtered.slice(page * _PAGE_SIZE, (page + 1) * _PAGE_SIZE);
|
||||||
|
|
||||||
|
const _archivedCount = conversations.filter(c => c.status === 'archived').length;
|
||||||
|
const _activeCount = conversations.filter(c => c.status !== 'archived').length;
|
||||||
|
|
||||||
|
useEffect(() => { setPage(0); }, [filterQuery, viewMode]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: 8 }}>
|
<div style={{ padding: 8 }}>
|
||||||
|
{/* Header */}
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||||||
<span style={{ fontSize: 12, fontWeight: 600, color: '#666', textTransform: 'uppercase' }}>Conversations</span>
|
<span style={{ fontSize: 12, fontWeight: 600, color: '#666', textTransform: 'uppercase' }}>Conversations</span>
|
||||||
|
<div style={{ display: 'flex', gap: 4 }}>
|
||||||
|
<button
|
||||||
|
onClick={_handleCreateNew}
|
||||||
|
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 14, color: '#1976d2' }}
|
||||||
|
title="Neuer Chat"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={_loadConversations}
|
||||||
|
disabled={loading}
|
||||||
|
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, color: '#1976d2' }}
|
||||||
|
>
|
||||||
|
{loading ? '...' : '\u21BB'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* View mode toggle */}
|
||||||
|
<div style={{ display: 'flex', marginBottom: 8, borderRadius: 6, overflow: 'hidden', border: '1px solid #ddd' }}>
|
||||||
<button
|
<button
|
||||||
onClick={_loadConversations}
|
onClick={() => setViewMode('active')}
|
||||||
disabled={loading}
|
style={{
|
||||||
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, color: '#1976d2' }}
|
flex: 1, padding: '5px 0', fontSize: 11, fontWeight: 600, border: 'none', cursor: 'pointer',
|
||||||
|
background: viewMode === 'active' ? 'var(--primary-color, #1976d2)' : 'transparent',
|
||||||
|
color: viewMode === 'active' ? '#fff' : '#888',
|
||||||
|
transition: 'background 0.15s, color 0.15s',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{loading ? '...' : '\u21BB'}
|
Aktiv {_activeCount > 0 && <span style={{ fontWeight: 400 }}>({_activeCount})</span>}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode('archived')}
|
||||||
|
style={{
|
||||||
|
flex: 1, padding: '5px 0', fontSize: 11, fontWeight: 600, border: 'none', cursor: 'pointer',
|
||||||
|
borderLeft: '1px solid #ddd',
|
||||||
|
background: viewMode === 'archived' ? '#ff9800' : 'transparent',
|
||||||
|
color: viewMode === 'archived' ? '#fff' : '#888',
|
||||||
|
transition: 'background 0.15s, color 0.15s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Archiv {_archivedCount > 0 && <span style={{ fontWeight: 400 }}>({_archivedCount})</span>}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{conversations.length > 0 && (
|
{/* Filter */}
|
||||||
|
{filtered.length > 3 && (
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Filter chats..."
|
placeholder="Filter chats..."
|
||||||
|
|
@ -150,13 +240,17 @@ export const ConversationList: React.FC<ConversationListProps> = ({
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{conversations.length === 0 && !loading && (
|
{/* Empty state */}
|
||||||
|
{filtered.length === 0 && !loading && (
|
||||||
<div style={{ fontSize: 12, color: '#999', textAlign: 'center', padding: 16 }}>
|
<div style={{ fontSize: 12, color: '#999', textAlign: 'center', padding: 16 }}>
|
||||||
No conversations yet. Send a message to start.
|
{viewMode === 'archived'
|
||||||
|
? 'Keine archivierten Chats.'
|
||||||
|
: 'Noch keine Chats. Sende eine Nachricht oder klicke "+".'}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{_filtered(conversations, filterQuery).map(conv => {
|
{/* List */}
|
||||||
|
{paginated.map(conv => {
|
||||||
const isActive = conv.id === activeWorkflowId;
|
const isActive = conv.id === activeWorkflowId;
|
||||||
const isEditing = editingId === conv.id;
|
const isEditing = editingId === conv.id;
|
||||||
return (
|
return (
|
||||||
|
|
@ -171,12 +265,18 @@ export const ConversationList: React.FC<ConversationListProps> = ({
|
||||||
background: isActive ? 'var(--primary-light, #e3f2fd)' : 'transparent',
|
background: isActive ? 'var(--primary-light, #e3f2fd)' : 'transparent',
|
||||||
border: isActive ? '1px solid var(--primary-color, #1976d2)20' : '1px solid transparent',
|
border: isActive ? '1px solid var(--primary-color, #1976d2)20' : '1px solid transparent',
|
||||||
transition: 'background 0.15s',
|
transition: 'background 0.15s',
|
||||||
|
position: 'relative',
|
||||||
}}
|
}}
|
||||||
onMouseEnter={e => {
|
onMouseEnter={e => {
|
||||||
if (!isActive) e.currentTarget.style.background = '#f5f5f5';
|
if (!isActive) e.currentTarget.style.background = '#f5f5f5';
|
||||||
|
const actions = e.currentTarget.querySelector('[data-actions]') as HTMLElement;
|
||||||
|
if (actions) actions.style.opacity = '1';
|
||||||
}}
|
}}
|
||||||
onMouseLeave={e => {
|
onMouseLeave={e => {
|
||||||
if (!isActive) e.currentTarget.style.background = 'transparent';
|
if (!isActive) e.currentTarget.style.background = 'transparent';
|
||||||
|
const actions = e.currentTarget.querySelector('[data-actions]') as HTMLElement;
|
||||||
|
if (actions) actions.style.opacity = '0';
|
||||||
|
if (confirmDeleteId === conv.id) setConfirmDeleteId(null);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Name row */}
|
{/* Name row */}
|
||||||
|
|
@ -208,23 +308,72 @@ export const ConversationList: React.FC<ConversationListProps> = ({
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
}}
|
}}
|
||||||
onDoubleClick={(e) => { e.stopPropagation(); _startEditing(conv); }}
|
onDoubleClick={(e) => { e.stopPropagation(); _startEditing(conv); }}
|
||||||
title="Double-click to rename"
|
title={conv.name}
|
||||||
>
|
>
|
||||||
{conv.name}
|
{conv.name}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Action buttons (visible on hover) */}
|
||||||
{!isEditing && (
|
{!isEditing && (
|
||||||
<button
|
<span
|
||||||
onClick={e => { e.stopPropagation(); _startEditing(conv); }}
|
data-actions=""
|
||||||
style={{
|
style={{ display: 'flex', gap: 2, opacity: 0, transition: 'opacity 0.15s', flexShrink: 0 }}
|
||||||
background: 'none', border: 'none', cursor: 'pointer',
|
|
||||||
fontSize: 10, color: '#999', padding: '0 2px', flexShrink: 0,
|
|
||||||
opacity: 0.6,
|
|
||||||
}}
|
|
||||||
title="Rename"
|
|
||||||
>
|
>
|
||||||
✎
|
<button
|
||||||
</button>
|
onClick={e => { e.stopPropagation(); _startEditing(conv); }}
|
||||||
|
style={_actionBtnStyle}
|
||||||
|
title="Umbenennen"
|
||||||
|
>
|
||||||
|
✎
|
||||||
|
</button>
|
||||||
|
{conv.status === 'archived' ? (
|
||||||
|
<button
|
||||||
|
onClick={e => { e.stopPropagation(); _handleReactivate(conv.id); }}
|
||||||
|
style={{ ..._actionBtnStyle, color: '#4caf50' }}
|
||||||
|
title="Reaktivieren"
|
||||||
|
>
|
||||||
|
↩
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={e => { e.stopPropagation(); _handleArchive(conv.id); }}
|
||||||
|
style={_actionBtnStyle}
|
||||||
|
title="Archivieren"
|
||||||
|
>
|
||||||
|
📦
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{confirmDeleteId === conv.id ? (
|
||||||
|
<span style={{
|
||||||
|
display: 'inline-flex', gap: 1, background: 'var(--color-secondary, #555)',
|
||||||
|
borderRadius: 12, padding: '1px 2px', alignItems: 'center',
|
||||||
|
}}>
|
||||||
|
<button
|
||||||
|
onClick={e => { e.stopPropagation(); setConfirmDeleteId(null); _handleDelete(conv.id); }}
|
||||||
|
style={{ ..._actionBtnStyle, color: '#fff', fontSize: 13 }}
|
||||||
|
title="Ja, loeschen"
|
||||||
|
>
|
||||||
|
✓
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={e => { e.stopPropagation(); setConfirmDeleteId(null); }}
|
||||||
|
style={{ ..._actionBtnStyle, color: '#fff', fontSize: 13 }}
|
||||||
|
title="Abbrechen"
|
||||||
|
>
|
||||||
|
✗
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={e => { e.stopPropagation(); setConfirmDeleteId(conv.id); }}
|
||||||
|
style={{ ..._actionBtnStyle, color: '#d32f2f' }}
|
||||||
|
title="Loeschen"
|
||||||
|
>
|
||||||
|
🗑
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -232,12 +381,15 @@ export const ConversationList: React.FC<ConversationListProps> = ({
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 3 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 3 }}>
|
||||||
<span style={{ fontSize: 10, color: '#999' }}>
|
<span style={{ fontSize: 10, color: '#999' }}>
|
||||||
{conv.status === 'active' && (
|
{conv.status === 'active' && (
|
||||||
<span style={{ color: '#4caf50' }}>{'\u25CF'} active</span>
|
<span style={{ color: '#4caf50' }}>{'\u25CF'} aktiv</span>
|
||||||
)}
|
)}
|
||||||
{conv.status === 'completed' && (
|
{conv.status === 'completed' && (
|
||||||
<span style={{ color: '#888' }}>{'\u25CF'} completed</span>
|
<span style={{ color: '#888' }}>{'\u25CF'} abgeschlossen</span>
|
||||||
)}
|
)}
|
||||||
{conv.status !== 'active' && conv.status !== 'completed' && (
|
{conv.status === 'archived' && (
|
||||||
|
<span style={{ color: '#ff9800' }}>{'\u25CF'} archiviert</span>
|
||||||
|
)}
|
||||||
|
{!['active', 'completed', 'archived'].includes(conv.status) && (
|
||||||
<span>{conv.status}</span>
|
<span>{conv.status}</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -251,6 +403,45 @@ export const ConversationList: React.FC<ConversationListProps> = ({
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', gap: 8, marginTop: 8, fontSize: 12 }}>
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(p => Math.max(0, p - 1))}
|
||||||
|
disabled={page === 0}
|
||||||
|
style={{ ..._pageBtnStyle, opacity: page === 0 ? 0.3 : 1 }}
|
||||||
|
>
|
||||||
|
<
|
||||||
|
</button>
|
||||||
|
<span style={{ color: '#888' }}>{page + 1} / {totalPages}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(p => Math.min(totalPages - 1, p + 1))}
|
||||||
|
disabled={page >= totalPages - 1}
|
||||||
|
style={{ ..._pageBtnStyle, opacity: page >= totalPages - 1 ? 0.3 : 1 }}
|
||||||
|
>
|
||||||
|
>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const _actionBtnStyle: React.CSSProperties = {
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: 11,
|
||||||
|
color: '#999',
|
||||||
|
padding: '0 2px',
|
||||||
|
};
|
||||||
|
|
||||||
|
const _pageBtnStyle: React.CSSProperties = {
|
||||||
|
background: 'none',
|
||||||
|
border: '1px solid #ddd',
|
||||||
|
borderRadius: 4,
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: '2px 8px',
|
||||||
|
color: '#666',
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,33 @@ const _SERVICE_ICONS: Record<string, string> = {
|
||||||
files: '\uD83D\uDCC2',
|
files: '\uD83D\uDCC2',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/* ─── Source colors & icons ──────────────────────────────────────────── */
|
||||||
|
|
||||||
|
const _SOURCE_COLORS: Record<string, string> = {
|
||||||
|
sharepointFolder: '#0078d4',
|
||||||
|
onedriveFolder: '#0078d4',
|
||||||
|
outlookFolder: '#0078d4',
|
||||||
|
googleDriveFolder: '#34a853',
|
||||||
|
gmailFolder: '#ea4335',
|
||||||
|
ftpFolder: '#795548',
|
||||||
|
};
|
||||||
|
|
||||||
|
function _getSourceColor(sourceType: string): string {
|
||||||
|
return _SOURCE_COLORS[sourceType] || '#1976d2';
|
||||||
|
}
|
||||||
|
|
||||||
|
function _getSourceIcon(sourceType: string): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
sharepointFolder: '\uD83D\uDCC1',
|
||||||
|
onedriveFolder: '\u2601\uFE0F',
|
||||||
|
outlookFolder: '\uD83D\uDCE7',
|
||||||
|
googleDriveFolder: '\uD83D\uDCC2',
|
||||||
|
gmailFolder: '\uD83D\uDCE8',
|
||||||
|
ftpFolder: '\uD83D\uDD17',
|
||||||
|
};
|
||||||
|
return map[sourceType] || '\uD83D\uDCC1';
|
||||||
|
}
|
||||||
|
|
||||||
/* ─── Component ─────────────────────────────────────────────────────── */
|
/* ─── Component ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
export const DataSourcePanel: React.FC<DataSourcePanelProps> = ({
|
export const DataSourcePanel: React.FC<DataSourcePanelProps> = ({
|
||||||
|
|
@ -186,26 +213,33 @@ export const DataSourcePanel: React.FC<DataSourcePanelProps> = ({
|
||||||
<div style={{ fontSize: 11, fontWeight: 600, color: '#666', textTransform: 'uppercase', marginBottom: 4 }}>
|
<div style={{ fontSize: 11, fontWeight: 600, color: '#666', textTransform: 'uppercase', marginBottom: 4 }}>
|
||||||
Active Sources
|
Active Sources
|
||||||
</div>
|
</div>
|
||||||
{dataSources.map(ds => (
|
{dataSources.map(ds => {
|
||||||
<div key={ds.id} style={{
|
const connColor = _getSourceColor(ds.sourceType);
|
||||||
display: 'flex', alignItems: 'center', gap: 6,
|
const connNode = tree.find(n => n.connectionId === ds.connectionId);
|
||||||
padding: '4px 6px', borderRadius: 4, marginBottom: 2,
|
const connLabel = connNode?.label || ds.connectionId;
|
||||||
background: 'var(--primary-light, #e3f2fd)',
|
const fullPath = `${connLabel} › ${ds.sourceType} › ${ds.path}`;
|
||||||
fontSize: 12,
|
return (
|
||||||
}}>
|
<div key={ds.id} style={{
|
||||||
<span style={{ color: '#4caf50', fontSize: 10 }}>{'\u25CF'}</span>
|
display: 'flex', alignItems: 'center', gap: 6,
|
||||||
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
padding: '4px 6px', borderRadius: 4, marginBottom: 2,
|
||||||
{ds.label}
|
background: `${connColor}18`,
|
||||||
</span>
|
borderLeft: `3px solid ${connColor}`,
|
||||||
<button
|
fontSize: 12,
|
||||||
onClick={() => _removeDatasource(ds.id)}
|
}} title={fullPath}>
|
||||||
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 11, color: '#999', padding: '0 2px' }}
|
<span style={{ fontSize: 12, flexShrink: 0 }}>{_getSourceIcon(ds.sourceType)}</span>
|
||||||
title="Remove"
|
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
>
|
{ds.label}
|
||||||
{'\u2715'}
|
</span>
|
||||||
</button>
|
<button
|
||||||
</div>
|
onClick={() => _removeDatasource(ds.id)}
|
||||||
))}
|
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 11, color: '#999', padding: '0 2px' }}
|
||||||
|
title="Entfernen"
|
||||||
|
>
|
||||||
|
{'\u2715'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
<div style={{ height: 1, background: 'var(--border-color, #e0e0e0)', margin: '8px 0' }} />
|
<div style={{ height: 1, background: 'var(--border-color, #e0e0e0)', margin: '8px 0' }} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,13 @@
|
||||||
/**
|
/**
|
||||||
* FileBrowser -- Folder + file browser panel with tags, search, and drag-and-drop upload.
|
* FileBrowser -- Tree-structured file browser.
|
||||||
|
*
|
||||||
|
* Level 1: Feature instance (group header, collapsible)
|
||||||
|
* Level 2: Files sorted alphabetically
|
||||||
|
*
|
||||||
|
* Supports search, drag-and-drop upload, and file selection.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useCallback, useRef } 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 type { WorkspaceFile, WorkspaceFolder } from './useWorkspace';
|
||||||
|
|
||||||
|
|
@ -14,6 +19,12 @@ interface FileBrowserProps {
|
||||||
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,
|
||||||
|
|
@ -21,27 +32,44 @@ export const FileBrowser: React.FC<FileBrowserProps> = ({
|
||||||
onRefresh,
|
onRefresh,
|
||||||
onFileSelect,
|
onFileSelect,
|
||||||
}) => {
|
}) => {
|
||||||
const [currentFolderId, setCurrentFolderId] = useState<string | null>(null);
|
|
||||||
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 fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const currentFolders = folders.filter(f =>
|
const _filteredFiles = useMemo(() => {
|
||||||
currentFolderId ? f.parentId === currentFolderId : !f.parentId,
|
if (!searchQuery.trim()) return files;
|
||||||
);
|
const q = searchQuery.toLowerCase();
|
||||||
const currentFiles = files.filter(f => {
|
return files.filter(f =>
|
||||||
const inFolder = currentFolderId ? f.folderId === currentFolderId : !f.folderId;
|
f.fileName.toLowerCase().includes(q)
|
||||||
const matchesSearch = !searchQuery
|
|| (f.tags || []).some(t => t.toLowerCase().includes(q)),
|
||||||
|| f.fileName.toLowerCase().includes(searchQuery.toLowerCase())
|
);
|
||||||
|| (f.tags || []).some(t => t.toLowerCase().includes(searchQuery.toLowerCase()));
|
}, [files, searchQuery]);
|
||||||
return inFolder && matchesSearch;
|
|
||||||
});
|
|
||||||
|
|
||||||
const _navigateUp = () => {
|
const _groups = useMemo((): _InstanceGroup[] => {
|
||||||
if (!currentFolderId) return;
|
const map: Record<string, _InstanceGroup> = {};
|
||||||
const folder = folders.find(f => f.id === currentFolderId);
|
for (const f of _filteredFiles) {
|
||||||
setCurrentFolderId(folder?.parentId || null);
|
const key = f.featureInstanceId || '_workspace';
|
||||||
|
if (!map[key]) {
|
||||||
|
map[key] = {
|
||||||
|
instanceId: key,
|
||||||
|
label: f.featureInstanceLabel || (key === '_workspace' ? 'Workspace' : key.slice(0, 8)),
|
||||||
|
files: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
map[key].files.push(f);
|
||||||
|
}
|
||||||
|
for (const g of Object.values(map)) {
|
||||||
|
g.files.sort((a, b) => a.fileName.localeCompare(b.fileName));
|
||||||
|
}
|
||||||
|
const groups = Object.values(map);
|
||||||
|
groups.sort((a, b) => a.label.localeCompare(b.label));
|
||||||
|
return groups;
|
||||||
|
}, [_filteredFiles]);
|
||||||
|
|
||||||
|
const _toggleGroup = (key: string) => {
|
||||||
|
setCollapsed(prev => ({ ...prev, [key]: !prev[key] }));
|
||||||
};
|
};
|
||||||
|
|
||||||
const _uploadFiles = useCallback(async (fileList: FileList | File[]) => {
|
const _uploadFiles = useCallback(async (fileList: FileList | File[]) => {
|
||||||
|
|
@ -51,9 +79,7 @@ export const FileBrowser: React.FC<FileBrowserProps> = ({
|
||||||
for (const file of Array.from(fileList)) {
|
for (const file of Array.from(fileList)) {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
if (currentFolderId) {
|
formData.append('featureInstanceId', instanceId);
|
||||||
formData.append('folderId', currentFolderId);
|
|
||||||
}
|
|
||||||
await api.post('/api/files/upload', formData, {
|
await api.post('/api/files/upload', formData, {
|
||||||
headers: { 'Content-Type': 'multipart/form-data' },
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
});
|
});
|
||||||
|
|
@ -64,7 +90,7 @@ export const FileBrowser: React.FC<FileBrowserProps> = ({
|
||||||
} finally {
|
} finally {
|
||||||
setUploading(false);
|
setUploading(false);
|
||||||
}
|
}
|
||||||
}, [instanceId, uploading, currentFolderId, onRefresh]);
|
}, [instanceId, uploading, onRefresh]);
|
||||||
|
|
||||||
const _handleDragOver = useCallback((e: React.DragEvent) => {
|
const _handleDragOver = useCallback((e: React.DragEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -101,23 +127,15 @@ export const FileBrowser: React.FC<FileBrowserProps> = ({
|
||||||
onDragLeave={_handleDragLeave}
|
onDragLeave={_handleDragLeave}
|
||||||
onDrop={_handleDrop}
|
onDrop={_handleDrop}
|
||||||
>
|
>
|
||||||
{/* Drag overlay */}
|
|
||||||
{isDragOver && (
|
{isDragOver && (
|
||||||
<div style={{
|
<div style={{
|
||||||
position: 'absolute',
|
position: 'absolute', inset: 0,
|
||||||
inset: 0,
|
|
||||||
background: 'rgba(25, 118, 210, 0.08)',
|
background: 'rgba(25, 118, 210, 0.08)',
|
||||||
border: '2px dashed #1976d2',
|
border: '2px dashed #1976d2', borderRadius: 8,
|
||||||
borderRadius: 8,
|
zIndex: 10, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
zIndex: 10,
|
fontSize: 13, fontWeight: 600, color: '#1976d2',
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: 600,
|
|
||||||
color: '#1976d2',
|
|
||||||
}}>
|
}}>
|
||||||
Drop files to upload
|
Dateien hier ablegen
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -133,22 +151,16 @@ export const FileBrowser: React.FC<FileBrowserProps> = ({
|
||||||
>
|
>
|
||||||
{uploading ? '...' : '+'}
|
{uploading ? '...' : '+'}
|
||||||
</button>
|
</button>
|
||||||
<button onClick={onRefresh} style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, color: '#1976d2' }}>↻</button>
|
<button onClick={onRefresh} style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, color: '#1976d2' }}>{'\u21BB'}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<input
|
<input ref={fileInputRef} type="file" multiple style={{ display: 'none' }} onChange={_handleFileInputChange} />
|
||||||
ref={fileInputRef}
|
|
||||||
type="file"
|
|
||||||
multiple
|
|
||||||
style={{ display: 'none' }}
|
|
||||||
onChange={_handleFileInputChange}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Search */}
|
{/* Search */}
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search files or tags..."
|
placeholder="Dateien suchen..."
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={e => setSearchQuery(e.target.value)}
|
onChange={e => setSearchQuery(e.target.value)}
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -157,85 +169,90 @@ export const FileBrowser: React.FC<FileBrowserProps> = ({
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Breadcrumb */}
|
{/* Tree */}
|
||||||
{currentFolderId && (
|
{_groups.length === 0 && (
|
||||||
<div
|
|
||||||
onClick={_navigateUp}
|
|
||||||
style={{ padding: '4px 8px', cursor: 'pointer', fontSize: 12, color: '#1976d2' }}
|
|
||||||
>
|
|
||||||
← Back
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Folders */}
|
|
||||||
{currentFolders.map(folder => (
|
|
||||||
<div
|
|
||||||
key={folder.id}
|
|
||||||
onClick={() => setCurrentFolderId(folder.id)}
|
|
||||||
style={{
|
|
||||||
padding: '6px 8px', cursor: 'pointer', fontSize: 13,
|
|
||||||
display: 'flex', alignItems: 'center', gap: 6,
|
|
||||||
borderRadius: 4,
|
|
||||||
}}
|
|
||||||
onMouseEnter={e => (e.currentTarget.style.background = '#f5f5f5')}
|
|
||||||
onMouseLeave={e => (e.currentTarget.style.background = '')}
|
|
||||||
>
|
|
||||||
<span>📁</span>
|
|
||||||
<span>{folder.name}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Files */}
|
|
||||||
{currentFiles.map(file => (
|
|
||||||
<div
|
|
||||||
key={file.id}
|
|
||||||
onClick={() => onFileSelect?.(file.id)}
|
|
||||||
style={{
|
|
||||||
padding: '6px 8px', fontSize: 13,
|
|
||||||
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 }}>{_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>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{currentFiles.length === 0 && currentFolders.length === 0 && (
|
|
||||||
<div style={{ fontSize: 12, color: '#999', textAlign: 'center', padding: 16 }}>
|
<div style={{ fontSize: 12, color: '#999', textAlign: 'center', padding: 16 }}>
|
||||||
{searchQuery ? 'No files match your search' : 'No files. Drag & drop to upload.'}
|
{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 {
|
function _fileIcon(mime: string): string {
|
||||||
if (mime.startsWith('image/')) return '🖼️';
|
if (!mime) return '\uD83D\uDCC4';
|
||||||
if (mime.includes('pdf')) return '📕';
|
if (mime.startsWith('image/')) return '\uD83D\uDDBC\uFE0F';
|
||||||
if (mime.includes('word') || mime.includes('docx')) return '📘';
|
if (mime.includes('pdf')) return '\uD83D\uDCD5';
|
||||||
if (mime.includes('sheet') || mime.includes('xlsx') || mime.includes('csv')) return '📊';
|
if (mime.includes('word') || mime.includes('docx')) return '\uD83D\uDCD8';
|
||||||
if (mime.includes('presentation') || mime.includes('pptx')) return '📙';
|
if (mime.includes('sheet') || mime.includes('xlsx') || mime.includes('csv')) return '\uD83D\uDCCA';
|
||||||
if (mime.includes('zip') || mime.includes('tar') || mime.includes('gz')) return '📦';
|
if (mime.includes('presentation') || mime.includes('pptx')) return '\uD83D\uDCD9';
|
||||||
if (mime.startsWith('text/') || mime.includes('json') || mime.includes('xml')) return '📝';
|
if (mime.includes('zip') || mime.includes('tar') || mime.includes('gz')) return '\uD83D\uDCE6';
|
||||||
return '📄';
|
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';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,39 @@ import { FileBrowser } from './FileBrowser';
|
||||||
import { DataSourcePanel } from './DataSourcePanel';
|
import { DataSourcePanel } from './DataSourcePanel';
|
||||||
import { FilePreview } from './FilePreview';
|
import { FilePreview } from './FilePreview';
|
||||||
import { ToolActivityLog } from './ToolActivityLog';
|
import { ToolActivityLog } from './ToolActivityLog';
|
||||||
|
|
||||||
|
function _useResizable(initialWidth: number, minWidth: number, maxWidth: number) {
|
||||||
|
const [width, setWidth] = useState(initialWidth);
|
||||||
|
const dragging = useRef(false);
|
||||||
|
const startX = useRef(0);
|
||||||
|
const startW = useRef(0);
|
||||||
|
|
||||||
|
const _onMouseDown = useCallback((e: React.MouseEvent, direction: 1 | -1 = 1) => {
|
||||||
|
e.preventDefault();
|
||||||
|
dragging.current = true;
|
||||||
|
startX.current = e.clientX;
|
||||||
|
startW.current = width;
|
||||||
|
|
||||||
|
const _onMouseMove = (ev: MouseEvent) => {
|
||||||
|
if (!dragging.current) return;
|
||||||
|
const delta = (ev.clientX - startX.current) * direction;
|
||||||
|
setWidth(Math.max(minWidth, Math.min(maxWidth, startW.current + delta)));
|
||||||
|
};
|
||||||
|
const _onMouseUp = () => {
|
||||||
|
dragging.current = false;
|
||||||
|
document.removeEventListener('mousemove', _onMouseMove);
|
||||||
|
document.removeEventListener('mouseup', _onMouseUp);
|
||||||
|
document.body.style.cursor = '';
|
||||||
|
document.body.style.userSelect = '';
|
||||||
|
};
|
||||||
|
document.addEventListener('mousemove', _onMouseMove);
|
||||||
|
document.addEventListener('mouseup', _onMouseUp);
|
||||||
|
document.body.style.cursor = 'col-resize';
|
||||||
|
document.body.style.userSelect = 'none';
|
||||||
|
}, [width, minWidth, maxWidth]);
|
||||||
|
|
||||||
|
return { width, onMouseDown: _onMouseDown };
|
||||||
|
}
|
||||||
type LeftTab = 'conversations' | 'files' | 'datasources';
|
type LeftTab = 'conversations' | 'files' | 'datasources';
|
||||||
type RightTab = 'activity' | 'preview';
|
type RightTab = 'activity' | 'preview';
|
||||||
|
|
||||||
|
|
@ -37,6 +70,8 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
|
||||||
const fileOps = useFileOperations();
|
const fileOps = useFileOperations();
|
||||||
const [leftCollapsed, setLeftCollapsed] = useState(false);
|
const [leftCollapsed, setLeftCollapsed] = useState(false);
|
||||||
const [rightCollapsed, setRightCollapsed] = useState(false);
|
const [rightCollapsed, setRightCollapsed] = useState(false);
|
||||||
|
const _leftResize = _useResizable(280, 200, 450);
|
||||||
|
const _rightResize = _useResizable(320, 200, 500);
|
||||||
const [leftTab, setLeftTab] = useState<LeftTab>('conversations');
|
const [leftTab, setLeftTab] = useState<LeftTab>('conversations');
|
||||||
const [rightTab, setRightTab] = useState<RightTab>('activity');
|
const [rightTab, setRightTab] = useState<RightTab>('activity');
|
||||||
const [selectedFileId, setSelectedFileId] = useState<string | null>(null);
|
const [selectedFileId, setSelectedFileId] = useState<string | null>(null);
|
||||||
|
|
@ -47,7 +82,7 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const _uploadAndAttach = useCallback(async (file: File) => {
|
const _uploadAndAttach = useCallback(async (file: File) => {
|
||||||
const result = await fileOps.handleFileUpload(file);
|
const result = await fileOps.handleFileUpload(file, undefined, instanceId);
|
||||||
if (result.success && result.fileData) {
|
if (result.success && result.fileData) {
|
||||||
const data = result.fileData.file || result.fileData;
|
const data = result.fileData.file || result.fileData;
|
||||||
if (data?.id) {
|
if (data?.id) {
|
||||||
|
|
@ -55,7 +90,7 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
|
||||||
}
|
}
|
||||||
workspace.refreshFiles();
|
workspace.refreshFiles();
|
||||||
}
|
}
|
||||||
}, [fileOps, workspace]);
|
}, [fileOps, workspace, instanceId]);
|
||||||
|
|
||||||
const _handleDragEnter = useCallback((e: React.DragEvent) => {
|
const _handleDragEnter = useCallback((e: React.DragEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -136,12 +171,13 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
|
||||||
{/* Left sidebar */}
|
{/* Left sidebar */}
|
||||||
{!leftCollapsed && (
|
{!leftCollapsed && (
|
||||||
<aside style={{
|
<aside style={{
|
||||||
width: 280,
|
width: _leftResize.width,
|
||||||
minWidth: 280,
|
minWidth: 200,
|
||||||
borderRight: '1px solid var(--border-color, #e0e0e0)',
|
borderRight: '1px solid var(--border-color, #e0e0e0)',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
|
flexShrink: 0,
|
||||||
}}>
|
}}>
|
||||||
<div style={{ padding: '6px 12px', borderBottom: '1px solid var(--border-color, #e0e0e0)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
<div style={{ padding: '6px 12px', borderBottom: '1px solid var(--border-color, #e0e0e0)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
<span style={{ fontWeight: 600, fontSize: 14 }}>Workspace</span>
|
<span style={{ fontWeight: 600, fontSize: 14 }}>Workspace</span>
|
||||||
|
|
@ -182,6 +218,16 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
|
||||||
</aside>
|
</aside>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Left resize handle */}
|
||||||
|
{!leftCollapsed && (
|
||||||
|
<div
|
||||||
|
onMouseDown={e => _leftResize.onMouseDown(e, 1)}
|
||||||
|
style={{ width: 4, cursor: 'col-resize', background: 'transparent', flexShrink: 0 }}
|
||||||
|
onMouseEnter={e => (e.currentTarget.style.background = '#1976d2')}
|
||||||
|
onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{leftCollapsed && (
|
{leftCollapsed && (
|
||||||
<div style={{ width: 32, display: 'flex', alignItems: 'start', justifyContent: 'center', paddingTop: 8, borderRight: '1px solid var(--border-color, #e0e0e0)' }}>
|
<div style={{ width: 32, display: 'flex', alignItems: 'start', justifyContent: 'center', paddingTop: 8, borderRight: '1px solid var(--border-color, #e0e0e0)' }}>
|
||||||
<button onClick={() => setLeftCollapsed(false)} style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 14, color: '#888' }}>▶</button>
|
<button onClick={() => setLeftCollapsed(false)} style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 14, color: '#888' }}>▶</button>
|
||||||
|
|
@ -239,15 +285,26 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
{/* Right resize handle */}
|
||||||
|
{!rightCollapsed && (
|
||||||
|
<div
|
||||||
|
onMouseDown={e => _rightResize.onMouseDown(e, -1)}
|
||||||
|
style={{ width: 4, cursor: 'col-resize', background: 'transparent', flexShrink: 0 }}
|
||||||
|
onMouseEnter={e => (e.currentTarget.style.background = '#1976d2')}
|
||||||
|
onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Right sidebar */}
|
{/* Right sidebar */}
|
||||||
{!rightCollapsed && (
|
{!rightCollapsed && (
|
||||||
<aside style={{
|
<aside style={{
|
||||||
width: 320,
|
width: _rightResize.width,
|
||||||
minWidth: 320,
|
minWidth: 200,
|
||||||
borderLeft: '1px solid var(--border-color, #e0e0e0)',
|
borderLeft: '1px solid var(--border-color, #e0e0e0)',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
|
flexShrink: 0,
|
||||||
}}>
|
}}>
|
||||||
<div style={{ padding: '6px 12px', borderBottom: '1px solid var(--border-color, #e0e0e0)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
<div style={{ padding: '6px 12px', borderBottom: '1px solid var(--border-color, #e0e0e0)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
|
|
||||||
|
|
@ -110,13 +110,18 @@ export const WorkspaceSettings: React.FC<WorkspaceSettingsProps> = ({ instanceId
|
||||||
voiceMap.forEach(e => {
|
voiceMap.forEach(e => {
|
||||||
mapObj[e.language] = { voiceName: e.voiceName || '' };
|
mapObj[e.language] = { voiceName: e.voiceName || '' };
|
||||||
});
|
});
|
||||||
await request({
|
const putResult = await request({
|
||||||
url: `/api/workspace/${instanceId}/settings/voice`,
|
url: `/api/workspace/${instanceId}/settings/voice`,
|
||||||
method: 'put',
|
method: 'put',
|
||||||
data: { ttsVoiceMap: mapObj },
|
data: { ttsVoiceMap: mapObj },
|
||||||
});
|
});
|
||||||
|
if ((putResult as any)?.error) {
|
||||||
|
setError((putResult as any).error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setSuccess('Einstellungen gespeichert');
|
setSuccess('Einstellungen gespeichert');
|
||||||
setTimeout(() => setSuccess(null), 3000);
|
setTimeout(() => setSuccess(null), 3000);
|
||||||
|
await _loadSettings();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message || 'Fehler beim Speichern');
|
setError(err.message || 'Fehler beim Speichern');
|
||||||
} finally {
|
} finally {
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,8 @@ export interface WorkspaceFile {
|
||||||
folderId?: string;
|
folderId?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
featureInstanceId?: string;
|
||||||
|
featureInstanceLabel?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WorkspaceFolder {
|
export interface WorkspaceFolder {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue