fixed integration graph editor ai
This commit is contained in:
parent
13f4574098
commit
dcd1182521
11 changed files with 564 additions and 242 deletions
|
|
@ -212,6 +212,8 @@ export interface FolderInfo {
|
|||
mandateId?: string;
|
||||
featureInstanceId?: string;
|
||||
createdAt?: number;
|
||||
scope?: string;
|
||||
neutralize?: boolean;
|
||||
}
|
||||
|
||||
export async function fetchFolders(
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ import { Automation2DataFlowProvider } from '../context/Automation2DataFlowConte
|
|||
import { usePrompt } from '../../../hooks/usePrompt';
|
||||
import { EditorChatPanel } from './EditorChatPanel';
|
||||
import type { PendingFile, EditorDataSource, EditorFeatureDataSource } from './EditorChatPanel';
|
||||
import { EditorWorkflowChatList } from './EditorWorkflowChatList';
|
||||
import { RunTracingPanel } from './RunTracingPanel';
|
||||
import { UnifiedDataBar } from '../../../components/UnifiedDataBar';
|
||||
import type { UdbContext, UdbTab } from '../../../components/UnifiedDataBar';
|
||||
|
|
@ -114,7 +115,8 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
|||
const [tracingRunId, setTracingRunId] = useState<string | null>(null);
|
||||
const [tracingNodeStatuses, setTracingNodeStatuses] = useState<Record<string, string>>({});
|
||||
const [rightTab, setRightTab] = useState<'nodes' | 'tracing'>('nodes');
|
||||
const [udbTab, setUdbTab] = useState<UdbTab>('chats');
|
||||
type LeftTab = UdbTab | 'ai';
|
||||
const [udbTab, setUdbTab] = useState<LeftTab>('ai');
|
||||
|
||||
const udbContext: UdbContext = useMemo(() => ({
|
||||
instanceId,
|
||||
|
|
@ -649,19 +651,30 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
|||
{leftPanelOpen && (<>
|
||||
<div style={{ width: leftPanelWidth, flexShrink: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden', background: 'var(--bg-primary, #fff)' }}>
|
||||
<div className={styles.rightTabBar}>
|
||||
{(['chats', 'files', 'sources'] as const).map((tab) => (
|
||||
{(['ai', 'chats', 'files', 'sources'] as const).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
className={`${styles.rightTab} ${udbTab === tab ? styles.rightTabActive : ''}`}
|
||||
onClick={() => setUdbTab(tab)}
|
||||
>
|
||||
{{ chats: t('Chats'), files: t('Dateien'), sources: t('Quellen') }[tab]}
|
||||
{{ ai: t('KI'), chats: t('Chats'), files: t('Dateien'), sources: t('Quellen') }[tab]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ flex: 1, overflow: 'hidden' }}>
|
||||
{udbTab === 'chats' ? (
|
||||
<div style={{ flex: 1, overflow: 'hidden', position: 'relative' }}>
|
||||
{/*
|
||||
KI-Panel bleibt gemountet, damit der Chatverlauf beim Tab-Wechsel
|
||||
(Chats / Dateien / Quellen) erhalten bleibt. Nur per CSS umblenden.
|
||||
`key={currentWorkflowId}` setzt den Verlauf sauber zurück, wenn der
|
||||
Nutzer einen anderen Workflow wählt.
|
||||
*/}
|
||||
<div style={{
|
||||
display: udbTab === 'ai' ? 'flex' : 'none',
|
||||
flexDirection: 'column',
|
||||
height: '100%',
|
||||
}}>
|
||||
<EditorChatPanel
|
||||
key={currentWorkflowId || '__noWorkflow__'}
|
||||
instanceId={instanceId}
|
||||
workflowId={currentWorkflowId}
|
||||
onGraphUpdated={() => { if (currentWorkflowId) handleLoad(currentWorkflowId); }}
|
||||
|
|
@ -670,11 +683,21 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
|||
dataSources={dataSources}
|
||||
featureDataSources={featureDataSources}
|
||||
/>
|
||||
) : (
|
||||
</div>
|
||||
{udbTab === 'chats' && (
|
||||
<EditorWorkflowChatList
|
||||
workflows={workflows}
|
||||
currentWorkflowId={currentWorkflowId}
|
||||
onSelect={handleWorkflowSelect}
|
||||
onNew={handleNew}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
{(udbTab === 'files' || udbTab === 'sources') && (
|
||||
<UnifiedDataBar
|
||||
context={udbContext}
|
||||
activeTab={udbTab}
|
||||
onTabChange={setUdbTab}
|
||||
activeTab={udbTab as UdbTab}
|
||||
onTabChange={(tab) => setUdbTab(tab as LeftTab)}
|
||||
hideTabs={['chats']}
|
||||
onFileSelect={onFileSelect}
|
||||
onSourcesChanged={onSourcesChanged}
|
||||
|
|
|
|||
|
|
@ -7,11 +7,25 @@
|
|||
* - Files: drag & drop from FolderTree onto input area, or click in UDB
|
||||
* - Data Sources: 🔗 picker button next to input (toggle-select from active sources)
|
||||
*/
|
||||
import React, { useState, useCallback, useRef } from 'react';
|
||||
import React, { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import { startSseStream } from '../../../utils/sseClient';
|
||||
import { ChatMessageList } from '../../Chat';
|
||||
import type { ChatMessage } from '../../Chat';
|
||||
import { getPageIcon } from '../../../config/pageRegistry';
|
||||
import api from '../../../api';
|
||||
|
||||
interface PersistedEditorChatMessage {
|
||||
id: string;
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
content: string;
|
||||
timestamp?: number;
|
||||
sequenceNr?: number;
|
||||
}
|
||||
|
||||
interface PersistedEditorChatResponse {
|
||||
chatWorkflowId: string | null;
|
||||
messages: PersistedEditorChatMessage[];
|
||||
}
|
||||
|
||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||
|
||||
|
|
@ -58,16 +72,55 @@ export const EditorChatPanel: React.FC<EditorChatPanelProps> = ({ instanceId,
|
|||
}) => {
|
||||
const { t } = useLanguage();
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [historyLoading, setHistoryLoading] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [prompt, setPrompt] = useState('');
|
||||
const [attachedDataSourceIds, setAttachedDataSourceIds] = useState<string[]>([]);
|
||||
const [attachedFeatureDataSourceIds, setAttachedFeatureDataSourceIds] = useState<string[]>([]);
|
||||
const [showSourcePicker, setShowSourcePicker] = useState(false);
|
||||
const [treeDropOver, setTreeDropOver] = useState(false);
|
||||
const [stopping, setStopping] = useState(false);
|
||||
const abortRef = useRef<(() => void) | null>(null);
|
||||
const assistantIdRef = useRef<string | null>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const pickerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Load persisted chat history from the backend whenever the workflow changes.
|
||||
// The chat is stored in `ChatWorkflow.linkedWorkflowId == workflowId` and is
|
||||
// returned by `GET /api/workflows/{instanceId}/{workflowId}/chat/messages`.
|
||||
// For an unsaved workflow (workflowId == null) we just clear the panel.
|
||||
useEffect(() => {
|
||||
if (!workflowId) {
|
||||
setMessages([]);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
const _loadHistory = async () => {
|
||||
setHistoryLoading(true);
|
||||
try {
|
||||
const res = await api.get<PersistedEditorChatResponse>(
|
||||
`/api/workflows/${instanceId}/${workflowId}/chat/messages`,
|
||||
);
|
||||
if (cancelled) return;
|
||||
const persisted = (res.data?.messages || []).map((m): ChatMessage => ({
|
||||
id: m.id || `persisted-${++_msgCounter}`,
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
timestamp: m.timestamp ? Math.round(Number(m.timestamp) * 1000) : Date.now(),
|
||||
}));
|
||||
setMessages(persisted);
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
console.warn('EditorChatPanel: failed to load chat history', err);
|
||||
setMessages([]);
|
||||
} finally {
|
||||
if (!cancelled) setHistoryLoading(false);
|
||||
}
|
||||
};
|
||||
_loadHistory();
|
||||
return () => { cancelled = true; };
|
||||
}, [instanceId, workflowId]);
|
||||
|
||||
const _toggleDataSource = useCallback((dsId: string) => {
|
||||
setAttachedDataSourceIds(prev =>
|
||||
prev.includes(dsId) ? prev.filter(id => id !== dsId) : [...prev, dsId],
|
||||
|
|
@ -85,9 +138,10 @@ export const EditorChatPanel: React.FC<EditorChatPanelProps> = ({ instanceId,
|
|||
if (!workflowId || loading || !trimmed) return;
|
||||
|
||||
const fileIds = pendingFiles.map(f => f.fileId);
|
||||
// Note: conversationHistory is no longer sent — the backend loads it
|
||||
// server-side from the persisted ChatWorkflow (linkedWorkflowId).
|
||||
const body: Record<string, unknown> = {
|
||||
message: trimmed,
|
||||
conversationHistory: messages.map(m => ({ role: m.role, message: m.content })),
|
||||
userLanguage: navigator.language?.slice(0, 2) || 'de',
|
||||
};
|
||||
if (fileIds.length > 0) body.fileIds = fileIds;
|
||||
|
|
@ -106,11 +160,13 @@ export const EditorChatPanel: React.FC<EditorChatPanelProps> = ({ instanceId,
|
|||
setLoading(true);
|
||||
|
||||
const assistantId = `asst-${++_msgCounter}`;
|
||||
assistantIdRef.current = assistantId;
|
||||
let accumulated = '';
|
||||
setMessages(prev => [...prev, { id: assistantId, role: 'assistant', content: '', timestamp: Date.now() }]);
|
||||
|
||||
const baseURL = api.defaults.baseURL || '';
|
||||
const cleanup = startSseStream({
|
||||
url: `/api/workflows/${instanceId}/${workflowId}/chat/stream`,
|
||||
url: `${baseURL}/api/workflows/${instanceId}/${workflowId}/chat/stream`,
|
||||
body,
|
||||
handlers: {
|
||||
onChunk: (event) => {
|
||||
|
|
@ -142,17 +198,40 @@ export const EditorChatPanel: React.FC<EditorChatPanelProps> = ({ instanceId,
|
|||
}
|
||||
setLoading(false);
|
||||
},
|
||||
onStopped: () => setLoading(false),
|
||||
onStopped: () => {
|
||||
setMessages(prev => prev.map(m => m.id === assistantId
|
||||
? { ...m, content: (m.content ? m.content + '\n\n' : '') + `_${t('Gestoppt.')}_` }
|
||||
: m));
|
||||
setLoading(false);
|
||||
setStopping(false);
|
||||
},
|
||||
},
|
||||
onConnectionError: (err) => {
|
||||
setMessages(prev => prev.map(m => m.id === assistantId ? { ...m, content: `${t('Fehler')}: ${err.message}` } : m));
|
||||
setLoading(false);
|
||||
setStopping(false);
|
||||
},
|
||||
onStreamEnd: () => setLoading(false),
|
||||
onStreamEnd: () => { setLoading(false); setStopping(false); },
|
||||
});
|
||||
|
||||
abortRef.current = cleanup;
|
||||
}, [prompt, loading, workflowId, instanceId, messages, onGraphUpdated, pendingFiles, attachedDataSourceIds, attachedFeatureDataSourceIds, t]);
|
||||
}, [prompt, loading, workflowId, instanceId, onGraphUpdated, pendingFiles, attachedDataSourceIds, attachedFeatureDataSourceIds, t]);
|
||||
|
||||
const _handleStop = useCallback(async () => {
|
||||
if (!workflowId || stopping) return;
|
||||
setStopping(true);
|
||||
const assistantId = assistantIdRef.current;
|
||||
if (assistantId) {
|
||||
setMessages(prev => prev.map(m => m.id === assistantId
|
||||
? { ...m, content: (m.content ? m.content + '\n\n' : '') + `_${t('Stoppen…')}_` }
|
||||
: m));
|
||||
}
|
||||
try {
|
||||
await api.post(`/api/workflows/${instanceId}/${workflowId}/chat/stop`);
|
||||
} catch {
|
||||
}
|
||||
abortRef.current?.();
|
||||
}, [workflowId, instanceId, stopping, t]);
|
||||
|
||||
const _handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
|
|
@ -188,8 +267,8 @@ export const EditorChatPanel: React.FC<EditorChatPanelProps> = ({ instanceId,
|
|||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', background: 'var(--bg-secondary, #fafafa)' }}>
|
||||
<ChatMessageList
|
||||
messages={messages}
|
||||
isProcessing={loading}
|
||||
emptyMessage={t('Beschreiben Sie, was Sie tun möchten')}
|
||||
isProcessing={loading || historyLoading}
|
||||
emptyMessage={historyLoading ? t('Lade Verlauf…') : t('Beschreiben Sie, was Sie tun möchten')}
|
||||
/>
|
||||
|
||||
{/* Pending files (from UDB drag/click) */}
|
||||
|
|
@ -391,10 +470,12 @@ export const EditorChatPanel: React.FC<EditorChatPanelProps> = ({ instanceId,
|
|||
)}
|
||||
|
||||
{loading ? (
|
||||
<button onClick={() => abortRef.current?.()} style={{
|
||||
<button onClick={_handleStop} disabled={stopping} title={stopping ? t('Stoppen…') : t('Anfrage stoppen')} style={{
|
||||
padding: '8px 14px', borderRadius: 8, border: 'none',
|
||||
background: '#f44336', color: '#fff', cursor: 'pointer', fontWeight: 600, fontSize: 12,
|
||||
}}>{t('Stopp')}</button>
|
||||
background: stopping ? '#9e9e9e' : '#f44336', color: '#fff',
|
||||
cursor: stopping ? 'wait' : 'pointer', fontWeight: 600, fontSize: 12,
|
||||
opacity: stopping ? 0.7 : 1,
|
||||
}}>{stopping ? t('Stoppen…') : t('Stopp')}</button>
|
||||
) : (
|
||||
<button onClick={_handleSend} disabled={!prompt.trim() || !workflowId} style={{
|
||||
padding: '8px 14px', borderRadius: 8, border: 'none',
|
||||
|
|
|
|||
127
src/components/FlowEditor/editor/EditorWorkflowChatList.tsx
Normal file
127
src/components/FlowEditor/editor/EditorWorkflowChatList.tsx
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
/**
|
||||
* EditorWorkflowChatList
|
||||
*
|
||||
* UDB "Chats" tab content for the GraphicalEditor: each AutoWorkflow is treated
|
||||
* as one editor chat session. Lists workflows already loaded by the parent
|
||||
* editor (no extra fetch), supports search and "+ Neu" to start a fresh
|
||||
* workflow chat. Mirrors the spirit of the Workspace ChatsTab but uses
|
||||
* GraphicalEditor data instead of the workspace endpoint.
|
||||
*/
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import type { Automation2Workflow } from '../../../api/workflowApi';
|
||||
|
||||
interface EditorWorkflowChatListProps {
|
||||
workflows: Automation2Workflow[];
|
||||
currentWorkflowId: string | null;
|
||||
onSelect: (workflowId: string | null) => void;
|
||||
onNew: () => void;
|
||||
t: (key: string) => string;
|
||||
}
|
||||
|
||||
function _formatRelative(ts?: number): string {
|
||||
if (!ts) return '';
|
||||
const date = new Date(ts * 1000);
|
||||
if (isNaN(date.getTime())) return '';
|
||||
const diffMs = Date.now() - date.getTime();
|
||||
const diffMin = Math.floor(diffMs / 60_000);
|
||||
const diffH = Math.floor(diffMs / 3_600_000);
|
||||
const diffDays = Math.floor(diffMs / 86_400_000);
|
||||
if (diffMin < 1) return 'gerade eben';
|
||||
if (diffMin < 60) return `${diffMin}m`;
|
||||
if (diffH < 24) return `${diffH}h`;
|
||||
if (diffDays === 1) return 'gestern';
|
||||
if (diffDays < 7) return `vor ${diffDays}d`;
|
||||
return date.toLocaleDateString([], { month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
export const EditorWorkflowChatList: React.FC<EditorWorkflowChatListProps> = ({
|
||||
workflows,
|
||||
currentWorkflowId,
|
||||
onSelect,
|
||||
onNew,
|
||||
t,
|
||||
}) => {
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
const list = q
|
||||
? workflows.filter((w) => (w.label || '').toLowerCase().includes(q))
|
||||
: [...workflows];
|
||||
list.sort((a, b) => (b.lastStartedAt || b.createdAt || 0) - (a.lastStartedAt || a.createdAt || 0));
|
||||
return list;
|
||||
}, [workflows, search]);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', background: 'var(--bg-primary, #fff)' }}>
|
||||
<div style={{ padding: '8px 10px', display: 'flex', gap: 6, borderBottom: '1px solid var(--border-color, #e0e0e0)' }}>
|
||||
<input
|
||||
type="search"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t('Workflow suchen…')}
|
||||
style={{
|
||||
flex: 1, padding: '6px 8px', borderRadius: 6,
|
||||
border: '1px solid var(--border-color, #ddd)', fontSize: 12, outline: 'none',
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={onNew}
|
||||
title={t('Neuer Workflow')}
|
||||
style={{
|
||||
padding: '6px 10px', borderRadius: 6, border: '1px solid var(--border-color, #ddd)',
|
||||
background: 'var(--secondary-bg, #f5f5f5)', cursor: 'pointer', fontSize: 12, fontWeight: 600,
|
||||
}}
|
||||
>+ {t('Neu')}</button>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflowY: 'auto' }}>
|
||||
{filtered.length === 0 ? (
|
||||
<div style={{ padding: 16, fontSize: 12, color: '#999', textAlign: 'center' }}>
|
||||
{workflows.length === 0
|
||||
? t('Noch keine Workflows. Klicken Sie auf „+ Neu", um einen Workflow-Chat zu starten.')
|
||||
: t('Keine Treffer.')}
|
||||
</div>
|
||||
) : (
|
||||
filtered.map((wf) => {
|
||||
const isActive = wf.id === currentWorkflowId;
|
||||
const ts = wf.lastStartedAt || wf.createdAt;
|
||||
return (
|
||||
<div
|
||||
key={wf.id}
|
||||
onClick={() => onSelect(wf.id)}
|
||||
style={{
|
||||
padding: '10px 12px', cursor: 'pointer',
|
||||
borderBottom: '1px solid var(--border-color-soft, #f0f0f0)',
|
||||
background: isActive ? 'rgba(242, 88, 67, 0.08)' : 'transparent',
|
||||
borderLeft: isActive ? '3px solid var(--primary-color, #F25843)' : '3px solid transparent',
|
||||
}}
|
||||
onMouseEnter={(e) => { if (!isActive) e.currentTarget.style.background = '#f7f7f7'; }}
|
||||
onMouseLeave={(e) => { if (!isActive) e.currentTarget.style.background = 'transparent'; }}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 500, color: '#333', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{wf.label || t('(unbenannt)')}
|
||||
</span>
|
||||
{wf.isRunning && (
|
||||
<span title={t('läuft')} style={{
|
||||
width: 8, height: 8, borderRadius: '50%', background: '#4caf50', flexShrink: 0,
|
||||
}} />
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 4, fontSize: 11, color: '#999' }}>
|
||||
{typeof wf.runCount === 'number' && (
|
||||
<span>{wf.runCount} {wf.runCount === 1 ? t('Lauf') : t('Läufe')}</span>
|
||||
)}
|
||||
{ts ? <span>· {_formatRelative(ts)}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditorWorkflowChatList;
|
||||
|
|
@ -86,10 +86,21 @@
|
|||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Right zone: contains dynamic on-hover actions + always-visible stable trio.
|
||||
* The stable trio (chat / scope / neutralize) sits at the right edge in a
|
||||
* fixed slot order so icons never jump. Dynamic actions appear on hover
|
||||
* to the left of the trio without displacing it. */
|
||||
.rightZone {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: none;
|
||||
gap: 2px;
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
|
|
@ -97,6 +108,26 @@
|
|||
display: flex;
|
||||
}
|
||||
|
||||
.stableActions {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.iconSlot {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.iconSlot.placeholder {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.actionBtn {
|
||||
background: none;
|
||||
border: none;
|
||||
|
|
@ -148,25 +179,6 @@
|
|||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.scopeIcons {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.rightZone {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.rightZone .actions {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.rootActions {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ export interface FolderNode {
|
|||
isReadonly?: boolean;
|
||||
icon?: string;
|
||||
neutralize?: boolean;
|
||||
scope?: string;
|
||||
}
|
||||
|
||||
export interface FileNode {
|
||||
|
|
@ -76,6 +77,7 @@ export interface FolderTreeProps {
|
|||
onDownloadFolder?: (folderId: string, folderName: string) => Promise<void>;
|
||||
onScopeChange?: (fileId: string, newScope: string) => void;
|
||||
onNeutralizeToggle?: (fileId: string, newValue: boolean) => void;
|
||||
onFolderScopeChange?: (folderId: string, newScope: string) => void;
|
||||
onFolderNeutralizeToggle?: (folderId: string, newValue: boolean) => void;
|
||||
onSendToChat?: (items: Array<{ id: string; type: 'file' | 'folder'; name: string }>) => void;
|
||||
}
|
||||
|
|
@ -186,6 +188,78 @@ interface SelectionCtx {
|
|||
onSendToChat?: (items: Array<{ id: string; type: 'file' | 'folder'; name: string }>) => void;
|
||||
}
|
||||
|
||||
/* ── Stable trio (chat | scope | neutralize) ──────────────────────────────
|
||||
* Always rendered in this order, always at the right edge of the row.
|
||||
* Each slot has a fixed width so missing actions render an invisible
|
||||
* placeholder — icons never jump position between rows. */
|
||||
|
||||
interface StableTrioProps {
|
||||
scope?: string;
|
||||
neutralize?: boolean;
|
||||
scopeLabels: Record<string, string>;
|
||||
onChat?: () => void;
|
||||
onScopeChange?: (newScope: string) => void;
|
||||
onNeutralizeToggle?: (newValue: boolean) => void;
|
||||
chatTitle: string;
|
||||
}
|
||||
|
||||
function _StableTrio({
|
||||
scope, neutralize,
|
||||
scopeLabels,
|
||||
onChat, onScopeChange, onNeutralizeToggle,
|
||||
chatTitle,
|
||||
}: StableTrioProps) {
|
||||
const { t } = useLanguage();
|
||||
const _cycleScope = (current: string | undefined) => {
|
||||
const idx = _SCOPE_CYCLE.indexOf(current ?? 'personal');
|
||||
return _SCOPE_CYCLE[(idx + 1) % _SCOPE_CYCLE.length];
|
||||
};
|
||||
|
||||
return (
|
||||
<span className={styles.stableActions}>
|
||||
{/* Slot 1: Chat */}
|
||||
{onChat ? (
|
||||
<button
|
||||
className={`${styles.actionBtn} ${styles.iconSlot}`}
|
||||
onClick={(e) => { e.stopPropagation(); onChat(); }}
|
||||
title={chatTitle}
|
||||
style={{ fontSize: 12 }}
|
||||
>
|
||||
{'\u{1F4AC}'}
|
||||
</button>
|
||||
) : (
|
||||
<span className={`${styles.iconSlot} ${styles.placeholder}`} aria-hidden="true">{'\u{1F4AC}'}</span>
|
||||
)}
|
||||
{/* Slot 2: Scope */}
|
||||
{onScopeChange && scope != null ? (
|
||||
<button
|
||||
className={`${styles.actionBtn} ${styles.iconSlot}`}
|
||||
onClick={(e) => { e.stopPropagation(); onScopeChange(_cycleScope(scope)); }}
|
||||
title={`${t('Scope')}: ${scopeLabels[scope] || scope} (${t('klicken zum Wechseln')})`}
|
||||
style={{ fontSize: 14 }}
|
||||
>
|
||||
{_SCOPE_ICONS[scope] || _SCOPE_ICONS.personal}
|
||||
</button>
|
||||
) : (
|
||||
<span className={`${styles.iconSlot} ${styles.placeholder}`} aria-hidden="true">{_SCOPE_ICONS.personal}</span>
|
||||
)}
|
||||
{/* Slot 3: Neutralize */}
|
||||
{onNeutralizeToggle ? (
|
||||
<button
|
||||
className={`${styles.actionBtn} ${styles.iconSlot}`}
|
||||
onClick={(e) => { e.stopPropagation(); onNeutralizeToggle(!neutralize); }}
|
||||
title={neutralize ? t('Neutralisierung aktiv, klicken zum Deaktivieren') : t('Neutralisierung aus, klicken zum Aktivieren')}
|
||||
style={{ fontSize: 14, opacity: neutralize ? 1 : 0.4 }}
|
||||
>
|
||||
{'\uD83D\uDD12'}
|
||||
</button>
|
||||
) : (
|
||||
<span className={`${styles.iconSlot} ${styles.placeholder}`} aria-hidden="true">{'\uD83D\uDD12'}</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── File node (leaf) ─────────────────────────────────────────────────── */
|
||||
|
||||
function _FileItem({ file, sel }: { file: FileNode; sel: SelectionCtx }) {
|
||||
|
|
@ -265,12 +339,12 @@ function _FileItem({ file, sel }: { file: FileNode; sel: SelectionCtx }) {
|
|||
)}
|
||||
{!renaming && (
|
||||
<span className={styles.rightZone}>
|
||||
{file.fileSize != null && (
|
||||
<span className={styles.fileSize}>
|
||||
{(file.fileSize / 1024).toFixed(0)}K
|
||||
</span>
|
||||
)}
|
||||
<span className={styles.actions}>
|
||||
{sel.onSendToChat && (
|
||||
<button className={styles.actionBtn} onClick={(e) => { e.stopPropagation(); sel.onSendToChat!([{ id: file.id, type: 'file', name: file.fileName }]); }} title={t('In Chat senden')} style={{ fontSize: 12 }}>
|
||||
{'\u{1F4AC}'}
|
||||
</button>
|
||||
)}
|
||||
{sel.onRenameFile && !multiSelected && (
|
||||
<button className={styles.actionBtn} onClick={(e) => { e.stopPropagation(); setRenameValue(file.fileName); setRenaming(true); }} title={t('Umbenennen')}>
|
||||
<FaPen />
|
||||
|
|
@ -299,40 +373,15 @@ function _FileItem({ file, sel }: { file: FileNode; sel: SelectionCtx }) {
|
|||
)
|
||||
)}
|
||||
</span>
|
||||
{file.fileSize != null && (
|
||||
<span className={styles.fileSize}>
|
||||
{(file.fileSize / 1024).toFixed(0)}K
|
||||
</span>
|
||||
)}
|
||||
{file.scope != null && (
|
||||
<span className={styles.scopeIcons}>
|
||||
<button
|
||||
className={styles.actionBtn}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (!sel.onScopeChange) return;
|
||||
const idx = _SCOPE_CYCLE.indexOf(file.scope!);
|
||||
const next = _SCOPE_CYCLE[(idx + 1) % _SCOPE_CYCLE.length];
|
||||
sel.onScopeChange(file.id, next);
|
||||
}}
|
||||
title={`${t('Scope')}: ${scopeLabels[file.scope!] || file.scope} (${t('klicken zum Wechseln')})`}
|
||||
style={{ fontSize: 14 }}
|
||||
>
|
||||
{_SCOPE_ICONS[file.scope!] || '\uD83D\uDC64'}
|
||||
</button>
|
||||
<button
|
||||
className={styles.actionBtn}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
sel.onNeutralizeToggle?.(file.id, !file.neutralize);
|
||||
}}
|
||||
title={file.neutralize ? t('Neutralisierung aktiv, klicken zum Deaktivieren') : t('Neutralisierung aus, klicken zum Aktivieren')}
|
||||
style={{ fontSize: 14, opacity: file.neutralize ? 1 : 0.4 }}
|
||||
>
|
||||
{'\uD83D\uDD12'}
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
<_StableTrio
|
||||
scope={file.scope}
|
||||
neutralize={file.neutralize}
|
||||
scopeLabels={scopeLabels}
|
||||
onChat={sel.onSendToChat ? () => sel.onSendToChat!([{ id: file.id, type: 'file', name: file.fileName }]) : undefined}
|
||||
onScopeChange={sel.onScopeChange ? (next) => sel.onScopeChange!(file.id, next) : undefined}
|
||||
onNeutralizeToggle={sel.onNeutralizeToggle ? (next) => sel.onNeutralizeToggle!(file.id, next) : undefined}
|
||||
chatTitle={t('In Chat senden')}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -360,6 +409,7 @@ interface TreeNodeProps {
|
|||
onMoveFile?: (fileId: string, targetFolderId: string | null) => Promise<void>;
|
||||
onMoveFiles?: (fileIds: string[], targetFolderId: string | null) => Promise<void>;
|
||||
onDownloadFolder?: (folderId: string, folderName: string) => Promise<void>;
|
||||
onFolderScopeChange?: (folderId: string, newScope: string) => void;
|
||||
onFolderNeutralizeToggle?: (folderId: string, newValue: boolean) => void;
|
||||
}
|
||||
|
||||
|
|
@ -368,9 +418,15 @@ function _TreeNode({
|
|||
promptFolderName,
|
||||
onToggle, onSelect,
|
||||
onCreateFolder, onRenameFolder, onDeleteFolder, onMoveFolder, onMoveFolders, onMoveFile, onMoveFiles,
|
||||
onDownloadFolder, onFolderNeutralizeToggle,
|
||||
onDownloadFolder, onFolderScopeChange, onFolderNeutralizeToggle,
|
||||
}: TreeNodeProps) {
|
||||
const { t } = useLanguage();
|
||||
const scopeLabels = useMemo((): Record<string, string> => ({
|
||||
personal: t('Persönlich'),
|
||||
featureInstance: t('Instanz'),
|
||||
mandate: t('Mandant'),
|
||||
global: t('Global'),
|
||||
}), [t]);
|
||||
const [renaming, setRenaming] = useState(false);
|
||||
const [renameValue, setRenameValue] = useState(node.name);
|
||||
const [dropOver, setDropOver] = useState(false);
|
||||
|
|
@ -523,57 +579,53 @@ function _TreeNode({
|
|||
<span className={styles.folderName} style={notEditable ? { fontWeight: 600 } : undefined}>{node.name}</span>
|
||||
)}
|
||||
{!isProtected && (
|
||||
<span className={styles.actions}>
|
||||
{sel.onSendToChat && !(isMultiSelected && sel.selectedItemIds.size > 1) && (
|
||||
<button className={styles.actionBtn} onClick={(e) => { e.stopPropagation(); sel.onSendToChat!([{ id: node.id, type: 'folder', name: node.name }]); }} title={t('In Chat senden')} style={{ fontSize: 12 }}>
|
||||
{'\u{1F4AC}'}
|
||||
</button>
|
||||
)}
|
||||
{!notEditable && onDownloadFolder && !(isMultiSelected && sel.selectedItemIds.size > 1) && (
|
||||
<button className={styles.actionBtn} onClick={(e) => { e.stopPropagation(); onDownloadFolder(node.id, node.name); }} title={t('Ordner herunterladen (ZIP)')}>
|
||||
<FaDownload />
|
||||
</button>
|
||||
)}
|
||||
{onFolderNeutralizeToggle && !(isMultiSelected && sel.selectedItemIds.size > 1) && (
|
||||
<button
|
||||
className={styles.actionBtn}
|
||||
onClick={(e) => { e.stopPropagation(); onFolderNeutralizeToggle(node.id, !node.neutralize); }}
|
||||
title={node.neutralize ? t('Ordner-Neutralisierung aktiv, klicken zum Deaktivieren') : t('Ordner-Neutralisierung aus, klicken zum Aktivieren')}
|
||||
style={{ fontSize: 14, opacity: node.neutralize ? 1 : 0.4 }}
|
||||
>
|
||||
{'\uD83D\uDD12'}
|
||||
</button>
|
||||
)}
|
||||
{onCreateFolder && !(isMultiSelected && sel.selectedItemIds.size > 1) && (
|
||||
<button className={styles.actionBtn} onClick={_handleAdd} title={t('Neuer Unterordner')}>
|
||||
<FaPlus />
|
||||
</button>
|
||||
)}
|
||||
{!notEditable && onRenameFolder && !(isMultiSelected && sel.selectedItemIds.size > 1) && (
|
||||
<button className={styles.actionBtn} onClick={(e) => { e.stopPropagation(); setRenameValue(node.name); setRenaming(true); }} title={t('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} ${t('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} ${t('Dateien löschen')}`}>
|
||||
<FaTrash />
|
||||
<span style={{ fontSize: 9, marginLeft: 2, fontWeight: 600 }}>{sel.selectedFileIds.length}</span>
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
) : !notEditable && onDeleteFolder && (
|
||||
<button className={`${styles.actionBtn} ${styles.danger}`} onClick={_handleDeleteSingle} title={t('Löschen')}>
|
||||
<FaTrash />
|
||||
</button>
|
||||
)}
|
||||
<span className={styles.rightZone}>
|
||||
<span className={styles.actions}>
|
||||
{!notEditable && onDownloadFolder && !(isMultiSelected && sel.selectedItemIds.size > 1) && (
|
||||
<button className={styles.actionBtn} onClick={(e) => { e.stopPropagation(); onDownloadFolder(node.id, node.name); }} title={t('Ordner herunterladen (ZIP)')}>
|
||||
<FaDownload />
|
||||
</button>
|
||||
)}
|
||||
{onCreateFolder && !(isMultiSelected && sel.selectedItemIds.size > 1) && (
|
||||
<button className={styles.actionBtn} onClick={_handleAdd} title={t('Neuer Unterordner')}>
|
||||
<FaPlus />
|
||||
</button>
|
||||
)}
|
||||
{!notEditable && onRenameFolder && !(isMultiSelected && sel.selectedItemIds.size > 1) && (
|
||||
<button className={styles.actionBtn} onClick={(e) => { e.stopPropagation(); setRenameValue(node.name); setRenaming(true); }} title={t('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} ${t('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} ${t('Dateien löschen')}`}>
|
||||
<FaTrash />
|
||||
<span style={{ fontSize: 9, marginLeft: 2, fontWeight: 600 }}>{sel.selectedFileIds.length}</span>
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
) : !notEditable && onDeleteFolder && (
|
||||
<button className={`${styles.actionBtn} ${styles.danger}`} onClick={_handleDeleteSingle} title={t('Löschen')}>
|
||||
<FaTrash />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
<_StableTrio
|
||||
scope={node.scope}
|
||||
neutralize={node.neutralize}
|
||||
scopeLabels={scopeLabels}
|
||||
onChat={(sel.onSendToChat && !(isMultiSelected && sel.selectedItemIds.size > 1)) ? () => sel.onSendToChat!([{ id: node.id, type: 'folder', name: node.name }]) : undefined}
|
||||
onScopeChange={(onFolderScopeChange && !(isMultiSelected && sel.selectedItemIds.size > 1)) ? (next) => onFolderScopeChange(node.id, next) : undefined}
|
||||
onNeutralizeToggle={(onFolderNeutralizeToggle && !(isMultiSelected && sel.selectedItemIds.size > 1)) ? (next) => onFolderNeutralizeToggle(node.id, next) : undefined}
|
||||
chatTitle={t('In Chat senden')}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -600,6 +652,7 @@ function _TreeNode({
|
|||
onMoveFile={onMoveFile}
|
||||
onMoveFiles={onMoveFiles}
|
||||
onDownloadFolder={onDownloadFolder}
|
||||
onFolderScopeChange={onFolderScopeChange}
|
||||
onFolderNeutralizeToggle={onFolderNeutralizeToggle}
|
||||
/>
|
||||
))}
|
||||
|
|
@ -620,7 +673,7 @@ export default function FolderTree({
|
|||
expandedIds: externalExpandedIds, onToggleExpand,
|
||||
onCreateFolder, onRenameFolder, onDeleteFolder, onMoveFolder, onMoveFolders, onMoveFile, onMoveFiles,
|
||||
onRenameFile, onDeleteFile, onDeleteFiles, onDeleteFolders, onRefresh, onDownloadFolder,
|
||||
onScopeChange, onNeutralizeToggle, onFolderNeutralizeToggle, onSendToChat,
|
||||
onScopeChange, onNeutralizeToggle, onFolderScopeChange, onFolderNeutralizeToggle, onSendToChat,
|
||||
}: FolderTreeProps) {
|
||||
const { t } = useLanguage();
|
||||
|
||||
|
|
@ -848,6 +901,7 @@ export default function FolderTree({
|
|||
onMoveFile={onMoveFile}
|
||||
onMoveFiles={onMoveFiles}
|
||||
onDownloadFolder={onDownloadFolder}
|
||||
onFolderScopeChange={onFolderScopeChange}
|
||||
onFolderNeutralizeToggle={onFolderNeutralizeToggle}
|
||||
/>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -52,6 +52,22 @@ function _formatRelativeTime(dateStr?: string | number): string {
|
|||
return d.toLocaleDateString([], { month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Timestamp der letzten Aktivität eines Chats: `lastMessageAt` (Backend liefert
|
||||
* den Zeitstempel der letzten Nachricht) bevorzugt, sonst Fallback auf
|
||||
* `updatedAt` (Workflow-Lifecycle-Zeit). Wird sowohl für Anzeige als auch
|
||||
* Sortierung verwendet, damit Liste und Label konsistent sind.
|
||||
*/
|
||||
function _lastTouchValue(chat: ChatItem): string | number | undefined {
|
||||
return chat.lastMessageAt ?? chat.updatedAt;
|
||||
}
|
||||
|
||||
function _lastTouchTs(chat: ChatItem): number {
|
||||
const v = _lastTouchValue(chat);
|
||||
if (v === undefined || v === null) return 0;
|
||||
return typeof v === 'number' ? v : new Date(v).getTime();
|
||||
}
|
||||
|
||||
const ChatsTab: React.FC<ChatsTabProps> = ({ context,
|
||||
onSelectChat,
|
||||
onDragStart,
|
||||
|
|
@ -113,13 +129,7 @@ const ChatsTab: React.FC<ChatsTabProps> = ({ context,
|
|||
}
|
||||
|
||||
const sorted = Array.from(groupMap.values());
|
||||
sorted.forEach(g =>
|
||||
g.chats.sort((a, b) => {
|
||||
const ta = typeof a.updatedAt === 'number' ? a.updatedAt : new Date(a.updatedAt || 0).getTime();
|
||||
const tb = typeof b.updatedAt === 'number' ? b.updatedAt : new Date(b.updatedAt || 0).getTime();
|
||||
return tb - ta;
|
||||
}),
|
||||
);
|
||||
sorted.forEach(g => g.chats.sort((a, b) => _lastTouchTs(b) - _lastTouchTs(a)));
|
||||
setGroups(sorted);
|
||||
|
||||
if (expandedGroups.size === 0 && sorted.length > 0) {
|
||||
|
|
@ -218,16 +228,9 @@ const ChatsTab: React.FC<ChatsTabProps> = ({ context,
|
|||
.map(g => ({ ...g, chats: _applyFilter(g.chats) }))
|
||||
.filter(g => g.chats.length > 0);
|
||||
|
||||
const _toTs = (v?: string | number): number =>
|
||||
typeof v === 'number' ? v : new Date(v || 0).getTime();
|
||||
|
||||
const _allChats = _filteredGroups
|
||||
.flatMap(g => g.chats)
|
||||
.sort((a, b) => {
|
||||
const ta = _toTs(a.lastMessageAt ?? a.updatedAt);
|
||||
const tb = _toTs(b.lastMessageAt ?? b.updatedAt);
|
||||
return tb - ta;
|
||||
});
|
||||
.sort((a, b) => _lastTouchTs(b) - _lastTouchTs(a));
|
||||
|
||||
const _activeCount = groups.reduce((n, g) => n + g.chats.filter(c => !_isArchived(c)).length, 0);
|
||||
const _archivedCount = groups.reduce((n, g) => n + g.chats.filter(c => _isArchived(c)).length, 0);
|
||||
|
|
@ -270,7 +273,7 @@ const ChatsTab: React.FC<ChatsTabProps> = ({ context,
|
|||
) : (
|
||||
<>
|
||||
<span className={styles.chatDate}>
|
||||
{_formatRelativeTime(chat.updatedAt)}
|
||||
{_formatRelativeTime(_lastTouchValue(chat))}
|
||||
</span>
|
||||
<span
|
||||
className={styles.chatLabel}
|
||||
|
|
|
|||
|
|
@ -47,7 +47,8 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect, onSendToChat
|
|||
name: f.name,
|
||||
parentId: f.parentId ?? null,
|
||||
fileCount: f.fileCount ?? 0,
|
||||
neutralize: (f as any).neutralize ?? false,
|
||||
neutralize: f.neutralize ?? false,
|
||||
scope: f.scope ?? 'personal',
|
||||
}));
|
||||
}, [folders]);
|
||||
|
||||
|
|
@ -178,6 +179,16 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect, onSendToChat
|
|||
}
|
||||
}, [refreshFolders, refreshTreeFiles]);
|
||||
|
||||
const _onFolderScopeChange = useCallback(async (folderId: string, newScope: string) => {
|
||||
try {
|
||||
await api.patch(`/api/files/folders/${folderId}/scope`, { scope: newScope });
|
||||
await refreshFolders();
|
||||
await refreshTreeFiles();
|
||||
} catch (err) {
|
||||
console.error('Failed to change folder scope:', err);
|
||||
}
|
||||
}, [refreshFolders, refreshTreeFiles]);
|
||||
|
||||
if (treeFilesLoading && treeFileNodes.length === 0) {
|
||||
return <div className={styles.loading}>{t('Dateien laden')}</div>;
|
||||
}
|
||||
|
|
@ -268,6 +279,7 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect, onSendToChat
|
|||
onDownloadFolder={handleDownloadFolder}
|
||||
onScopeChange={_onScopeChange}
|
||||
onNeutralizeToggle={_onNeutralizeToggle}
|
||||
onFolderScopeChange={_onFolderScopeChange}
|
||||
onFolderNeutralizeToggle={_onFolderNeutralizeToggle}
|
||||
onSendToChat={onSendToChat}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1097,12 +1097,24 @@ const _TreeNodeView: React.FC<_TreeNodeViewProps> = ({
|
|||
{node.label}
|
||||
</span>
|
||||
|
||||
{/* Chat-Senden: always visible */}
|
||||
{/* Dynamic action: Remove (only when DS exists) — placed LEFT of the
|
||||
* stable trio so the trio always anchors at the right edge. */}
|
||||
{ds && (
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); onRemoveDs(ds.id); }}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 10, color: '#999', padding: '0 2px', flexShrink: 0 }}
|
||||
title={t('Entfernen')}
|
||||
>
|
||||
{'\u2715'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* ── Stable trio: chat | scope | neutralize (always in this order) ── */}
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); onSendToChat?.(_chatPayload); }}
|
||||
style={{
|
||||
background: 'none', border: 'none', cursor: 'pointer',
|
||||
fontSize: 13, padding: '0 2px', flexShrink: 0, lineHeight: 1,
|
||||
fontSize: 13, padding: '0 2px', flexShrink: 0, lineHeight: 1, width: 22, textAlign: 'center',
|
||||
opacity: ds ? 0.7 : (hovered ? 0.5 : 0.25),
|
||||
color: 'var(--primary-color, #F25843)',
|
||||
}}
|
||||
|
|
@ -1110,8 +1122,6 @@ const _TreeNodeView: React.FC<_TreeNodeViewProps> = ({
|
|||
>
|
||||
{'\u{1F4AC}'}
|
||||
</button>
|
||||
|
||||
{/* Scope: own DS → cycle, no DS → create DS then cycle */}
|
||||
<button
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
|
|
@ -1121,13 +1131,11 @@ const _TreeNodeView: React.FC<_TreeNodeViewProps> = ({
|
|||
try { await api.patch(`/api/datasources/${newId}/scope`, { scope: _nextScope(effectiveScope || 'personal') }); } catch {}
|
||||
}
|
||||
}}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, padding: '0 2px', lineHeight: 1, opacity: ds ? 1 : 0.35 }}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center', opacity: ds ? 1 : 0.35 }}
|
||||
title={ds ? scopeCycleTitle(ds.scope) : (effectiveScope ? `${t('Geerbt')}: ${effectiveScope}` : t('Scope setzen'))}
|
||||
>
|
||||
{_SCOPE_ICONS[ds?.scope || effectiveScope || 'personal']}
|
||||
</button>
|
||||
|
||||
{/* Neutralize: own DS → toggle, no DS → create DS then toggle */}
|
||||
<button
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
|
|
@ -1139,7 +1147,7 @@ const _TreeNodeView: React.FC<_TreeNodeViewProps> = ({
|
|||
}}
|
||||
style={{
|
||||
background: 'none', border: 'none', cursor: 'pointer',
|
||||
fontSize: 12, padding: '0 2px', lineHeight: 1,
|
||||
fontSize: 12, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center',
|
||||
opacity: (ds?.neutralize ?? effectiveNeutralize) ? 1 : 0.35,
|
||||
}}
|
||||
title={(ds?.neutralize ?? effectiveNeutralize) ? t('Neutralisierung an') : t('Neutralisierung aus')}
|
||||
|
|
@ -1147,17 +1155,6 @@ const _TreeNodeView: React.FC<_TreeNodeViewProps> = ({
|
|||
{'\uD83D\uDD12'}
|
||||
</button>
|
||||
|
||||
{/* Remove: only when DS exists */}
|
||||
{ds && (
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); onRemoveDs(ds.id); }}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 10, color: '#999', padding: '0 2px' }}
|
||||
title={t('Entfernen')}
|
||||
>
|
||||
{'\u2715'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
{node.expanded && node.children && node.children.length > 0 && (
|
||||
|
|
@ -1366,7 +1363,18 @@ const _FeatureNodeView: React.FC<_FeatureNodeViewProps> = ({
|
|||
{node.tableCount} {t('Tabellen')}
|
||||
</span>
|
||||
|
||||
{/* Chat: always visible */}
|
||||
{/* Dynamic Remove (left of stable trio) */}
|
||||
{wildcardFds && (
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); onRemoveFds(wildcardFds.id); }}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 10, color: '#999', padding: '0 2px', flexShrink: 0 }}
|
||||
title={t('Entfernen')}
|
||||
>
|
||||
{'\u2715'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* ── Stable trio: chat | scope | neutralize ── */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
|
|
@ -1379,15 +1387,13 @@ const _FeatureNodeView: React.FC<_FeatureNodeViewProps> = ({
|
|||
}}
|
||||
style={{
|
||||
background: 'none', border: 'none', cursor: 'pointer',
|
||||
fontSize: 14, padding: '0 2px', flexShrink: 0, lineHeight: 1,
|
||||
fontSize: 14, padding: '0 2px', flexShrink: 0, lineHeight: 1, width: 22, textAlign: 'center',
|
||||
opacity: wildcardFds ? 0.7 : (hovered ? 0.5 : 0.25), color: '#7b1fa2',
|
||||
}}
|
||||
title={t('Alle Tabellen in Chat senden')}
|
||||
>
|
||||
{'\u{1F4AC}'}
|
||||
</button>
|
||||
|
||||
{/* Scope: own wildcard-FDS → cycle, otherwise create then cycle */}
|
||||
<button
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
|
|
@ -1397,13 +1403,11 @@ const _FeatureNodeView: React.FC<_FeatureNodeViewProps> = ({
|
|||
try { await api.patch(`/api/datasources/${newId}/scope`, { scope: _nextScope('personal') }); } catch {}
|
||||
}
|
||||
}}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, padding: '0 2px', lineHeight: 1, opacity: wildcardFds ? 1 : 0.35 }}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center', opacity: wildcardFds ? 1 : 0.35 }}
|
||||
title={wildcardFds ? `${t('Bereich')}: ${wildcardFds.scope}` : t('Scope setzen')}
|
||||
>
|
||||
{_SCOPE_ICONS[wildcardFds?.scope || 'personal']}
|
||||
</button>
|
||||
|
||||
{/* Neutralize: own wildcard-FDS → toggle, otherwise create then toggle */}
|
||||
<button
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
|
|
@ -1413,23 +1417,12 @@ const _FeatureNodeView: React.FC<_FeatureNodeViewProps> = ({
|
|||
try { await api.patch(`/api/datasources/${newId}/neutralize`, { neutralize: true }); } catch {}
|
||||
}
|
||||
}}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, padding: '0 2px', lineHeight: 1, opacity: wildcardFds ? (wildcardFds.neutralize ? 1 : 0.35) : 0.35 }}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center', opacity: wildcardFds ? (wildcardFds.neutralize ? 1 : 0.35) : 0.35 }}
|
||||
title={wildcardFds?.neutralize ? t('Neutralisierung an') : t('Neutralisierung aus')}
|
||||
>
|
||||
{'\uD83D\uDD12'}
|
||||
</button>
|
||||
|
||||
{/* Remove: only when wildcard-FDS exists */}
|
||||
{wildcardFds && (
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); onRemoveFds(wildcardFds.id); }}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 10, color: '#999', padding: '0 2px' }}
|
||||
title={t('Entfernen')}
|
||||
>
|
||||
{'\u2715'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
{node.expanded && node.tables && node.tables.length > 0 && (
|
||||
|
|
@ -1587,20 +1580,29 @@ const _FeatureTableRow: React.FC<_FeatureTableRowProps> = ({
|
|||
)}
|
||||
</span>
|
||||
|
||||
{/* Chat: always visible */}
|
||||
{/* Dynamic Remove (left of stable trio) */}
|
||||
{fds && onRemoveFds && (
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); onRemoveFds(fds.id); }}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 10, color: '#999', padding: '0 2px', flexShrink: 0 }}
|
||||
title={t('Entfernen')}
|
||||
>
|
||||
{'\u2715'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* ── Stable trio: chat | scope | neutralize ── */}
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); onSendToChat?.(_chatPayload); }}
|
||||
style={{
|
||||
background: 'none', border: 'none', cursor: 'pointer',
|
||||
fontSize: 13, padding: '0 2px', flexShrink: 0, lineHeight: 1,
|
||||
fontSize: 13, padding: '0 2px', flexShrink: 0, lineHeight: 1, width: 22, textAlign: 'center',
|
||||
opacity: fds ? 0.7 : (hovered ? 0.5 : 0.25), color: '#7b1fa2',
|
||||
}}
|
||||
title={t('In Chat senden')}
|
||||
>
|
||||
{'\u{1F4AC}'}
|
||||
</button>
|
||||
|
||||
{/* Scope: own FDS → cycle, otherwise create then cycle */}
|
||||
<button
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
|
|
@ -1610,13 +1612,11 @@ const _FeatureTableRow: React.FC<_FeatureTableRowProps> = ({
|
|||
try { await api.patch(`/api/datasources/${newId}/scope`, { scope: _nextScope(effectiveScope || 'personal') }); } catch {}
|
||||
}
|
||||
}}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, padding: '0 2px', lineHeight: 1, opacity: fds ? 1 : 0.35 }}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center', opacity: fds ? 1 : 0.35 }}
|
||||
title={fds ? `${t('Bereich')}: ${fds.scope}` : (effectiveScope ? `${t('Geerbt')}: ${effectiveScope}` : t('Scope setzen'))}
|
||||
>
|
||||
{_SCOPE_ICONS[fds?.scope || effectiveScope || 'personal']}
|
||||
</button>
|
||||
|
||||
{/* Neutralize: own FDS → toggle, otherwise create then toggle */}
|
||||
<button
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
|
|
@ -1628,7 +1628,7 @@ const _FeatureTableRow: React.FC<_FeatureTableRowProps> = ({
|
|||
}}
|
||||
style={{
|
||||
background: 'none', border: 'none', cursor: 'pointer',
|
||||
fontSize: 12, padding: '0 2px', lineHeight: 1,
|
||||
fontSize: 12, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center',
|
||||
opacity: (fds?.neutralize ?? effectiveNeutralize) ? 1 : 0.35,
|
||||
}}
|
||||
title={(fds?.neutralize ?? effectiveNeutralize) ? t('Neutralisierung an') : t('Neutralisierung aus')}
|
||||
|
|
@ -1636,17 +1636,6 @@ const _FeatureTableRow: React.FC<_FeatureTableRowProps> = ({
|
|||
{'\uD83D\uDD12'}
|
||||
</button>
|
||||
|
||||
{/* Remove: only when FDS exists */}
|
||||
{fds && onRemoveFds && (
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); onRemoveFds(fds.id); }}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 10, color: '#999', padding: '0 2px' }}
|
||||
title={t('Entfernen')}
|
||||
>
|
||||
{'\u2715'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
{/* Expandable field sub-nodes */}
|
||||
|
|
@ -1732,26 +1721,30 @@ const _FeatureFieldRow: React.FC<_FeatureFieldRowProps> = ({
|
|||
{fieldName}
|
||||
</span>
|
||||
|
||||
{/* Chat: always visible */}
|
||||
{/* ── Stable trio: chat | scope | neutralize ── */}
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); onSendToChat?.(_chatPayload); }}
|
||||
style={{
|
||||
background: 'none', border: 'none', cursor: 'pointer',
|
||||
fontSize: 11, padding: '0 2px', flexShrink: 0, lineHeight: 1,
|
||||
fontSize: 11, padding: '0 2px', flexShrink: 0, lineHeight: 1, width: 22, textAlign: 'center',
|
||||
opacity: fds ? 0.7 : (hovered ? 0.5 : 0.25), color: '#7b1fa2',
|
||||
}}
|
||||
title={t('Feld in Chat senden')}
|
||||
>
|
||||
{'\u{1F4AC}'}
|
||||
</button>
|
||||
|
||||
{/* Neutralize: own FDS → clickable, otherwise dimmed */}
|
||||
<span
|
||||
style={{ fontSize: 11, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center', opacity: 0.25, display: 'inline-block' }}
|
||||
title={inheritedScope ? `${t('Geerbt')}: ${inheritedScope}` : t('Kein Scope gesetzt')}
|
||||
>
|
||||
{_SCOPE_ICONS[inheritedScope || 'personal']}
|
||||
</span>
|
||||
{fds && onToggleNeutralizeField ? (
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); onToggleNeutralizeField(fds, fieldName); }}
|
||||
style={{
|
||||
background: 'none', border: 'none', cursor: 'pointer',
|
||||
fontSize: 11, padding: '0 2px', lineHeight: 1,
|
||||
fontSize: 11, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center',
|
||||
opacity: isNeutralized ? 1 : 0.35,
|
||||
}}
|
||||
title={isNeutralized ? t('Feld-Neutralisierung an') : t('Feld-Neutralisierung aus')}
|
||||
|
|
@ -1760,20 +1753,12 @@ const _FeatureFieldRow: React.FC<_FeatureFieldRowProps> = ({
|
|||
</button>
|
||||
) : (
|
||||
<span
|
||||
style={{ fontSize: 11, padding: '0 2px', lineHeight: 1, opacity: isNeutralized ? 0.5 : 0.15 }}
|
||||
style={{ fontSize: 11, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center', opacity: isNeutralized ? 0.5 : 0.15, display: 'inline-block' }}
|
||||
title={isNeutralized ? t('Neutralisierung geerbt') : t('Neutralisierung aus')}
|
||||
>
|
||||
{'\uD83D\uDD12'}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Scope: inherited indicator */}
|
||||
<span
|
||||
style={{ fontSize: 10, padding: '0 2px', lineHeight: 1, opacity: 0.25 }}
|
||||
title={inheritedScope ? `${t('Geerbt')}: ${inheritedScope}` : t('Kein Scope gesetzt')}
|
||||
>
|
||||
{_SCOPE_ICONS[inheritedScope || 'personal']}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1949,7 +1934,18 @@ const _ParentRecordRow: React.FC<_ParentRecordRowProps> = ({
|
|||
{record.displayLabel}
|
||||
</span>
|
||||
|
||||
{/* Chat: always visible */}
|
||||
{/* Dynamic Remove (left of stable trio) */}
|
||||
{fds && onRemoveFds && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onRemoveFds(fds.id); }}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 10, color: '#999', padding: '0 2px', flexShrink: 0 }}
|
||||
title={t('Entfernen')}
|
||||
>
|
||||
{'\u2715'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* ── Stable trio: chat | scope | neutralize ── */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
|
|
@ -1962,61 +1958,46 @@ const _ParentRecordRow: React.FC<_ParentRecordRowProps> = ({
|
|||
}}
|
||||
style={{
|
||||
background: 'none', border: 'none', cursor: 'pointer',
|
||||
fontSize: 13, padding: '0 2px', flexShrink: 0, lineHeight: 1,
|
||||
fontSize: 13, padding: '0 2px', flexShrink: 0, lineHeight: 1, width: 22, textAlign: 'center',
|
||||
opacity: fds ? 0.7 : (hovered ? 0.5 : 0.25), color: '#7b1fa2',
|
||||
}}
|
||||
title={t('In Chat senden')}
|
||||
>
|
||||
{'\u{1F4AC}'}
|
||||
</button>
|
||||
|
||||
{/* Scope: own FDS → clickable, otherwise dimmed */}
|
||||
{fds && onCycleScope ? (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onCycleScope(fds); }}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, padding: '0 2px', lineHeight: 1 }}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center' }}
|
||||
title={`${t('Bereich')}: ${fds.scope}`}
|
||||
>
|
||||
{_SCOPE_ICONS[fds.scope] || _SCOPE_ICONS.personal}
|
||||
</button>
|
||||
) : (
|
||||
<span
|
||||
style={{ fontSize: 12, padding: '0 2px', lineHeight: 1, opacity: 0.25 }}
|
||||
style={{ fontSize: 12, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center', opacity: 0.25, display: 'inline-block' }}
|
||||
title={inheritedScope ? `${t('Geerbt')}: ${inheritedScope}` : t('Kein Scope gesetzt')}
|
||||
>
|
||||
{_SCOPE_ICONS[inheritedScope || 'personal']}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Neutralize: own FDS → clickable, otherwise dimmed */}
|
||||
{fds && onToggleNeutralize ? (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onToggleNeutralize(fds); }}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, padding: '0 2px', lineHeight: 1, opacity: fds.neutralize ? 1 : 0.35 }}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center', opacity: fds.neutralize ? 1 : 0.35 }}
|
||||
title={fds.neutralize ? t('Neutralisierung an') : t('Neutralisierung aus')}
|
||||
>
|
||||
{'\uD83D\uDD12'}
|
||||
</button>
|
||||
) : (
|
||||
<span
|
||||
style={{ fontSize: 12, padding: '0 2px', lineHeight: 1, opacity: (inheritedNeutralize ?? false) ? 0.5 : 0.15 }}
|
||||
style={{ fontSize: 12, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center', opacity: (inheritedNeutralize ?? false) ? 0.5 : 0.15, display: 'inline-block' }}
|
||||
title={(inheritedNeutralize ?? false) ? t('Neutralisierung geerbt') : t('Neutralisierung aus')}
|
||||
>
|
||||
{'\uD83D\uDD12'}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Remove: only when FDS exists */}
|
||||
{fds && onRemoveFds && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onRemoveFds(fds.id); }}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 10, color: '#999', padding: '0 2px' }}
|
||||
title={t('Entfernen')}
|
||||
>
|
||||
{'\u2715'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
{record.expanded && (
|
||||
|
|
|
|||
|
|
@ -142,6 +142,24 @@ export const FilesPage: React.FC = () => {
|
|||
}
|
||||
}, [updateTreeFileNode, refreshTreeFiles, _tableRefetch]);
|
||||
|
||||
const _handleFolderScopeChange = useCallback(async (folderId: string, newScope: string) => {
|
||||
try {
|
||||
await api.patch(`/api/files/folders/${folderId}/scope`, { scope: newScope });
|
||||
await Promise.all([refreshFolders(), refreshTreeFiles(), _tableRefetch()]);
|
||||
} catch (err) {
|
||||
console.error('Failed to update folder scope:', err);
|
||||
}
|
||||
}, [refreshFolders, refreshTreeFiles, _tableRefetch]);
|
||||
|
||||
const _handleFolderNeutralizeToggle = useCallback(async (folderId: string, newValue: boolean) => {
|
||||
try {
|
||||
await api.patch(`/api/files/folders/${folderId}/neutralize`, { neutralize: newValue });
|
||||
await Promise.all([refreshFolders(), refreshTreeFiles(), _tableRefetch()]);
|
||||
} catch (err) {
|
||||
console.error('Failed to toggle folder neutralize:', err);
|
||||
}
|
||||
}, [refreshFolders, refreshTreeFiles, _tableRefetch]);
|
||||
|
||||
// ── Folder nodes for tree (real folders only) ────────────────────────
|
||||
const folderNodes = useMemo(() => {
|
||||
return folders.map(f => ({
|
||||
|
|
@ -149,6 +167,8 @@ export const FilesPage: React.FC = () => {
|
|||
name: f.name,
|
||||
parentId: f.parentId ?? null,
|
||||
fileCount: f.fileCount ?? 0,
|
||||
neutralize: f.neutralize ?? false,
|
||||
scope: f.scope ?? 'personal',
|
||||
}));
|
||||
}, [folders]);
|
||||
|
||||
|
|
@ -426,6 +446,8 @@ export const FilesPage: React.FC = () => {
|
|||
onDownloadFolder={handleDownloadFolder}
|
||||
onScopeChange={_handleScopeChange}
|
||||
onNeutralizeToggle={_handleNeutralizeToggle}
|
||||
onFolderScopeChange={_handleFolderScopeChange}
|
||||
onFolderNeutralizeToggle={_handleFolderNeutralizeToggle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -52,6 +52,8 @@
|
|||
--bg-primary: #ffffff;
|
||||
--bg-secondary: #F7FAFC;
|
||||
--bg-dark: #EDF2F7;
|
||||
/* Canvas surface for the GraphicalEditor flow editor (slightly off-white). */
|
||||
--canvas-bg: #FAFAFA;
|
||||
|
||||
/* Surface colors */
|
||||
--surface-color: #F7FAFC;
|
||||
|
|
@ -126,6 +128,9 @@
|
|||
--bg-primary: #1A202C;
|
||||
--bg-secondary: #2D3748;
|
||||
--bg-dark: #171923;
|
||||
/* Canvas surface for the GraphicalEditor flow editor — slightly darker than
|
||||
--bg-primary so nodes and connection lines stay legible against it. */
|
||||
--canvas-bg: #131820;
|
||||
|
||||
/* Surface colors */
|
||||
--surface-color: #2D3748;
|
||||
|
|
|
|||
Loading…
Reference in a new issue