Compare commits
No commits in common. "e727996a189c00d5c904fba2181f44f42afef5ae" and "5711450606a63b0a498c0a6a9069927e5320ecf5" have entirely different histories.
e727996a18
...
5711450606
20 changed files with 156 additions and 767 deletions
|
|
@ -36,7 +36,6 @@ export interface PaginationParams {
|
||||||
search?: string;
|
search?: string;
|
||||||
viewKey?: string;
|
viewKey?: string;
|
||||||
groupByLevels?: Array<{ field: string; nullLabel?: string; direction?: 'asc' | 'desc' }>;
|
groupByLevels?: Array<{ field: string; nullLabel?: string; direction?: 'asc' | 'desc' }>;
|
||||||
owner?: 'all' | 'me' | 'shared';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PaginatedResponse<T> {
|
export interface PaginatedResponse<T> {
|
||||||
|
|
@ -110,7 +109,6 @@ export async function fetchFiles(
|
||||||
if (params.search) paginationObj.search = params.search;
|
if (params.search) paginationObj.search = params.search;
|
||||||
if (params.viewKey) paginationObj.viewKey = params.viewKey;
|
if (params.viewKey) paginationObj.viewKey = params.viewKey;
|
||||||
if (params.groupByLevels !== undefined) paginationObj.groupByLevels = params.groupByLevels;
|
if (params.groupByLevels !== undefined) paginationObj.groupByLevels = params.groupByLevels;
|
||||||
if (params.owner) requestParams.owner = params.owner;
|
|
||||||
|
|
||||||
if (Object.keys(paginationObj).length > 0) {
|
if (Object.keys(paginationObj).length > 0) {
|
||||||
requestParams.pagination = JSON.stringify(paginationObj);
|
requestParams.pagination = JSON.stringify(paginationObj);
|
||||||
|
|
|
||||||
|
|
@ -158,7 +158,6 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
const [versions, setVersions] = useState<AutoVersion[]>([]);
|
const [versions, setVersions] = useState<AutoVersion[]>([]);
|
||||||
const [currentVersionId, setCurrentVersionId] = useState<string | null>(null);
|
const [currentVersionId, setCurrentVersionId] = useState<string | null>(null);
|
||||||
const [versionLoading, setVersionLoading] = useState(false);
|
const [versionLoading, setVersionLoading] = useState(false);
|
||||||
const didBootstrapEmptyCanvasRef = useRef(false);
|
|
||||||
|
|
||||||
const [targetFeatureInstanceId, setTargetFeatureInstanceId] = useState<string | null>(instanceId);
|
const [targetFeatureInstanceId, setTargetFeatureInstanceId] = useState<string | null>(instanceId);
|
||||||
|
|
||||||
|
|
@ -599,22 +598,8 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (loading || nodeTypes.length === 0) return;
|
if (loading || nodeTypes.length === 0) return;
|
||||||
if (currentWorkflowId || initialWorkflowId) {
|
if (currentWorkflowId || initialWorkflowId) return;
|
||||||
didBootstrapEmptyCanvasRef.current = false;
|
if (canvasNodes.length > 0) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (didBootstrapEmptyCanvasRef.current) return;
|
|
||||||
didBootstrapEmptyCanvasRef.current = true;
|
|
||||||
if (canvasNodes.length === 0 && canvasConnections.length === 0 && invocations.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
console.debug(`${LOG} bootstrapping empty canvas`, {
|
|
||||||
currentWorkflowId,
|
|
||||||
initialWorkflowId,
|
|
||||||
canvasNodes: canvasNodes.length,
|
|
||||||
canvasConnections: canvasConnections.length,
|
|
||||||
invocations: invocations.length,
|
|
||||||
});
|
|
||||||
applyGraphWithSync({ nodes: [], connections: [] }, [], {
|
applyGraphWithSync({ nodes: [], connections: [] }, [], {
|
||||||
skipHistory: true,
|
skipHistory: true,
|
||||||
});
|
});
|
||||||
|
|
@ -624,9 +609,8 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
currentWorkflowId,
|
currentWorkflowId,
|
||||||
initialWorkflowId,
|
initialWorkflowId,
|
||||||
canvasNodes.length,
|
canvasNodes.length,
|
||||||
canvasConnections.length,
|
|
||||||
invocations.length,
|
|
||||||
applyGraphWithSync,
|
applyGraphWithSync,
|
||||||
|
t,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const toggleCategory = useCallback((id: string) => {
|
const toggleCategory = useCallback((id: string) => {
|
||||||
|
|
|
||||||
|
|
@ -20,8 +20,6 @@ import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
import { AiBadge } from '../nodes/shared/AiBadge';
|
import { AiBadge } from '../nodes/shared/AiBadge';
|
||||||
import { switchOutputLabel } from '../nodes/shared/graphUtils';
|
import { switchOutputLabel } from '../nodes/shared/graphUtils';
|
||||||
|
|
||||||
const LOG = '[FlowCanvas]';
|
|
||||||
|
|
||||||
export interface CanvasNode {
|
export interface CanvasNode {
|
||||||
id: string;
|
id: string;
|
||||||
type: string;
|
type: string;
|
||||||
|
|
@ -844,8 +842,6 @@ export const FlowCanvas = forwardRef<FlowCanvasHandle, FlowCanvasProps>(function
|
||||||
|
|
||||||
const onHistoryCheckpointRef = useRef(onHistoryCheckpoint);
|
const onHistoryCheckpointRef = useRef(onHistoryCheckpoint);
|
||||||
onHistoryCheckpointRef.current = onHistoryCheckpoint;
|
onHistoryCheckpointRef.current = onHistoryCheckpoint;
|
||||||
const onSelectionChangeRef = useRef(onSelectionChange);
|
|
||||||
onSelectionChangeRef.current = onSelectionChange;
|
|
||||||
|
|
||||||
const emitHistoryCheckpoint = useCallback(() => {
|
const emitHistoryCheckpoint = useCallback(() => {
|
||||||
onHistoryCheckpointRef.current?.();
|
onHistoryCheckpointRef.current?.();
|
||||||
|
|
@ -1023,19 +1019,12 @@ export const FlowCanvas = forwardRef<FlowCanvasHandle, FlowCanvasProps>(function
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
const lastEmittedSelectionRef = useRef<{ nodeId: string | null; signature: string | null }>({
|
|
||||||
nodeId: null,
|
|
||||||
signature: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const node = selectedNodeId ? nodes.find((n) => n.id === selectedNodeId) ?? null : null;
|
if (onSelectionChange) {
|
||||||
const signature = node ? JSON.stringify(node) : null;
|
const node = selectedNodeId ? nodes.find((n) => n.id === selectedNodeId) ?? null : null;
|
||||||
const last = lastEmittedSelectionRef.current;
|
onSelectionChange(node);
|
||||||
if (last.nodeId === selectedNodeId && last.signature === signature) return;
|
}
|
||||||
lastEmittedSelectionRef.current = { nodeId: selectedNodeId, signature };
|
}, [selectedNodeId, nodes, onSelectionChange]);
|
||||||
onSelectionChangeRef.current?.(node);
|
|
||||||
}, [selectedNodeId, nodes]);
|
|
||||||
|
|
||||||
const handleConnectionClick = useCallback((e: React.MouseEvent, connId: string) => {
|
const handleConnectionClick = useCallback((e: React.MouseEvent, connId: string) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
@ -1099,11 +1088,6 @@ export const FlowCanvas = forwardRef<FlowCanvasHandle, FlowCanvasProps>(function
|
||||||
const handleDrop = useCallback(
|
const handleDrop = useCallback(
|
||||||
async (e: React.DragEvent) => {
|
async (e: React.DragEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
console.debug(`${LOG} drop received`, {
|
|
||||||
types: Array.from(e.dataTransfer.types),
|
|
||||||
clientX: e.clientX,
|
|
||||||
clientY: e.clientY,
|
|
||||||
});
|
|
||||||
// 1) externe Drop-Targets (z. B. ``application/json+workflow`` aus UDB-FilesTab)
|
// 1) externe Drop-Targets (z. B. ``application/json+workflow`` aus UDB-FilesTab)
|
||||||
if (onExternalDrop) {
|
if (onExternalDrop) {
|
||||||
const reservedMimes = new Set([
|
const reservedMimes = new Set([
|
||||||
|
|
@ -1129,35 +1113,16 @@ export const FlowCanvas = forwardRef<FlowCanvasHandle, FlowCanvasProps>(function
|
||||||
}
|
}
|
||||||
// 2) Standard: Node-Type aus der NodeSidebar
|
// 2) Standard: Node-Type aus der NodeSidebar
|
||||||
const raw = e.dataTransfer.getData('application/json');
|
const raw = e.dataTransfer.getData('application/json');
|
||||||
if (!raw || !containerRef.current) {
|
if (!raw || !containerRef.current) return;
|
||||||
console.debug(`${LOG} drop ignored`, {
|
|
||||||
hasRaw: Boolean(raw),
|
|
||||||
hasContainer: Boolean(containerRef.current),
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
const { type } = JSON.parse(raw);
|
const { type } = JSON.parse(raw);
|
||||||
const el = containerRef.current;
|
const el = containerRef.current;
|
||||||
const rect = el.getBoundingClientRect();
|
const rect = el.getBoundingClientRect();
|
||||||
const x = (e.clientX - rect.left - panOffset.x) / zoom - NODE_WIDTH / 2;
|
const x = (e.clientX - rect.left - panOffset.x) / zoom - NODE_WIDTH / 2;
|
||||||
const y = (e.clientY - rect.top - panOffset.y) / zoom - NODE_HEIGHT / 2;
|
const y = (e.clientY - rect.top - panOffset.y) / zoom - NODE_HEIGHT / 2;
|
||||||
console.debug(`${LOG} placing node from drop`, {
|
|
||||||
type,
|
|
||||||
raw,
|
|
||||||
dropX: x,
|
|
||||||
dropY: y,
|
|
||||||
panOffset,
|
|
||||||
zoom,
|
|
||||||
});
|
|
||||||
onDropNodeType(type, Math.max(0, x), Math.max(0, y));
|
onDropNodeType(type, Math.max(0, x), Math.max(0, y));
|
||||||
emitHistoryCheckpoint();
|
emitHistoryCheckpoint();
|
||||||
} catch (error) {
|
} catch (_) {}
|
||||||
console.debug(`${LOG} drop parse failed`, {
|
|
||||||
raw,
|
|
||||||
error,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[onDropNodeType, onExternalDrop, panOffset, zoom, emitHistoryCheckpoint]
|
[onDropNodeType, onExternalDrop, panOffset, zoom, emitHistoryCheckpoint]
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ import type { GraphDefinedSchemaRef, NodeType, NodeTypeParameter, PortSchema } f
|
||||||
import type { ApiRequestFunction } from '../../../api/workflowApi';
|
import type { ApiRequestFunction } from '../../../api/workflowApi';
|
||||||
import { getLabel } from '../nodes/shared/utils';
|
import { getLabel } from '../nodes/shared/utils';
|
||||||
import { FRONTEND_TYPE_RENDERERS } from '../nodes/frontendTypeRenderers';
|
import { FRONTEND_TYPE_RENDERERS } from '../nodes/frontendTypeRenderers';
|
||||||
import { ContextBuilderRenderer } from '../nodes/frontendTypeRenderers/ContextBuilderRenderer';
|
|
||||||
import { RequiredAttributePicker } from '../nodes/shared/RequiredAttributePicker';
|
import { RequiredAttributePicker } from '../nodes/shared/RequiredAttributePicker';
|
||||||
import { findRequiredErrors } from '../nodes/shared/paramValidation';
|
import { findRequiredErrors } from '../nodes/shared/paramValidation';
|
||||||
import { useAutomation2DataFlow } from '../context/Automation2DataFlowContext';
|
import { useAutomation2DataFlow } from '../context/Automation2DataFlowContext';
|
||||||
|
|
@ -254,7 +253,6 @@ export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({ node,
|
||||||
|
|
||||||
for (const param of sortedParameters) {
|
for (const param of sortedParameters) {
|
||||||
if (param.frontendType === 'hidden') continue;
|
if (param.frontendType === 'hidden') continue;
|
||||||
if (param.name === 'context') continue;
|
|
||||||
if (CONTEXT_EXTRACT_CHUNK_SET.has(param.name)) continue;
|
if (CONTEXT_EXTRACT_CHUNK_SET.has(param.name)) continue;
|
||||||
if (!parameterVisibleForFrontendOptions(param, params, nodeType)) continue;
|
if (!parameterVisibleForFrontendOptions(param, params, nodeType)) continue;
|
||||||
|
|
||||||
|
|
@ -380,15 +378,6 @@ export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({ node,
|
||||||
t,
|
t,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const extractContentContextParam = useMemo((): NodeTypeParameter | null => {
|
|
||||||
if (!node || !nodeType || node.type !== CONTEXT_EXTRACT_CONTENT_NODE_TYPE) return null;
|
|
||||||
const param = sortedParameters.find((p) => p.name === 'context') ?? null;
|
|
||||||
if (!param) return null;
|
|
||||||
if (param.frontendType === 'hidden') return null;
|
|
||||||
if (!parameterVisibleForFrontendOptions(param, params, nodeType)) return null;
|
|
||||||
return param;
|
|
||||||
}, [node, nodeType, sortedParameters, params]);
|
|
||||||
|
|
||||||
if (!node || !nodeType) return null;
|
if (!node || !nodeType) return null;
|
||||||
|
|
||||||
const isTrigger = node.type.startsWith('trigger.');
|
const isTrigger = node.type.startsWith('trigger.');
|
||||||
|
|
@ -494,71 +483,11 @@ export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({ node,
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{extractContentAccordionItems !== null ? (
|
{extractContentAccordionItems !== null ? (
|
||||||
<>
|
<AccordionList<string>
|
||||||
{extractContentContextParam ? (
|
key={`${node.id}-extract-accordion`}
|
||||||
<div
|
defaultOpenId={null}
|
||||||
key={`${node.id}-${extractContentContextParam.name}`}
|
items={extractContentAccordionItems}
|
||||||
style={{ marginBottom: 8, minWidth: 0, maxWidth: '100%' }}
|
/>
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 6,
|
|
||||||
marginBottom: 2,
|
|
||||||
flexWrap: 'wrap',
|
|
||||||
minWidth: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{extractContentContextParam.required && (
|
|
||||||
<span
|
|
||||||
title={t('Pflichtfeld')}
|
|
||||||
style={{ color: 'var(--danger-color, #dc3545)', fontWeight: 700, flexShrink: 0 }}
|
|
||||||
>
|
|
||||||
*
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{verboseSchema && extractContentContextParam.type && (
|
|
||||||
<span
|
|
||||||
title={t('Parameter-Typ')}
|
|
||||||
style={{
|
|
||||||
fontSize: 10,
|
|
||||||
fontWeight: 600,
|
|
||||||
color: 'var(--text-secondary)',
|
|
||||||
background: 'var(--bg-secondary)',
|
|
||||||
border: '1px solid var(--border-color)',
|
|
||||||
borderRadius: 4,
|
|
||||||
padding: '1px 6px',
|
|
||||||
maxWidth: '100%',
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{extractContentContextParam.type}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<ContextBuilderRenderer
|
|
||||||
param={extractContentContextParam}
|
|
||||||
value={workflowParamUiValue(params, extractContentContextParam)}
|
|
||||||
onChange={(val: unknown) => updateParam(extractContentContextParam.name, val)}
|
|
||||||
allParams={params}
|
|
||||||
instanceId={instanceId}
|
|
||||||
request={request}
|
|
||||||
nodeType={node.type}
|
|
||||||
onPatchParams={patchParams}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{extractContentAccordionItems.length > 0 ? (
|
|
||||||
<AccordionList<string>
|
|
||||||
key={`${node.id}-extract-accordion`}
|
|
||||||
defaultOpenId={null}
|
|
||||||
items={extractContentAccordionItems}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
) : (
|
) : (
|
||||||
parameters.map((param: NodeTypeParameter) => {
|
parameters.map((param: NodeTypeParameter) => {
|
||||||
// Safety net: hidden params have no UI footprint at all — no row,
|
// Safety net: hidden params have no UI footprint at all — no row,
|
||||||
|
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
.wrapper {
|
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input {
|
|
||||||
width: 100%;
|
|
||||||
padding: 3px 30px 3px 6px;
|
|
||||||
font-size: 12px;
|
|
||||||
border: 1px solid var(--border-color, #ccc);
|
|
||||||
border-radius: 3px;
|
|
||||||
outline: none;
|
|
||||||
box-sizing: border-box;
|
|
||||||
background: var(--color-bg, #fff);
|
|
||||||
color: var(--color-text, #334155);
|
|
||||||
}
|
|
||||||
|
|
||||||
.input:focus {
|
|
||||||
border-color: var(--primary-color, #F25843);
|
|
||||||
}
|
|
||||||
|
|
||||||
.input::placeholder {
|
|
||||||
color: var(--color-text-muted, #94a3b8);
|
|
||||||
}
|
|
||||||
|
|
||||||
.clearBtn {
|
|
||||||
position: absolute;
|
|
||||||
right: 5px;
|
|
||||||
top: 3px;
|
|
||||||
bottom: 3px;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 25px;
|
|
||||||
padding: 0;
|
|
||||||
border: none;
|
|
||||||
border-radius: 3px;
|
|
||||||
background: none;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 25px;
|
|
||||||
line-height: 1;
|
|
||||||
color: var(--color-text-secondary, #94a3b8);
|
|
||||||
}
|
|
||||||
|
|
||||||
.clearBtn:hover {
|
|
||||||
background: none;
|
|
||||||
color: var(--color-text-secondary, #94a3b8);
|
|
||||||
}
|
|
||||||
|
|
@ -1,69 +0,0 @@
|
||||||
import React, { type Ref } from 'react';
|
|
||||||
import styles from './FilterSearchInput.module.css';
|
|
||||||
|
|
||||||
export interface FilterSearchInputProps {
|
|
||||||
value: string;
|
|
||||||
onChange: (value: string) => void;
|
|
||||||
placeholder?: string;
|
|
||||||
inputRef?: Ref<HTMLInputElement>;
|
|
||||||
onInputClick?: (e: React.MouseEvent<HTMLInputElement>) => void;
|
|
||||||
onFocus?: () => void;
|
|
||||||
onBlur?: () => void;
|
|
||||||
/** When set, only `inputClassName` styles the input (for floating-label toolbar search). */
|
|
||||||
variant?: 'compact' | 'inherit';
|
|
||||||
inputClassName?: string;
|
|
||||||
wrapperClassName?: string;
|
|
||||||
clearTitle?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FilterSearchInput({
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
placeholder = 'Filter...',
|
|
||||||
inputRef,
|
|
||||||
onInputClick,
|
|
||||||
onFocus,
|
|
||||||
onBlur,
|
|
||||||
variant = 'compact',
|
|
||||||
inputClassName,
|
|
||||||
wrapperClassName,
|
|
||||||
clearTitle = 'Eingabe löschen',
|
|
||||||
}: FilterSearchInputProps) {
|
|
||||||
const inputClass = variant === 'inherit'
|
|
||||||
? inputClassName
|
|
||||||
: inputClassName
|
|
||||||
? `${styles.input} ${inputClassName}`
|
|
||||||
: styles.input;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={wrapperClassName ? `${styles.wrapper} ${wrapperClassName}` : styles.wrapper}>
|
|
||||||
<input
|
|
||||||
ref={inputRef}
|
|
||||||
type="text"
|
|
||||||
value={value}
|
|
||||||
onChange={(e) => onChange(e.target.value)}
|
|
||||||
placeholder={placeholder}
|
|
||||||
className={inputClass}
|
|
||||||
onClick={onInputClick}
|
|
||||||
onFocus={onFocus}
|
|
||||||
onBlur={onBlur}
|
|
||||||
/>
|
|
||||||
{value && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={styles.clearBtn}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onChange('');
|
|
||||||
}}
|
|
||||||
onMouseDown={(e) => e.preventDefault()}
|
|
||||||
title={clearTitle}
|
|
||||||
tabIndex={-1}
|
|
||||||
aria-label={clearTitle}
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
export { FilterSearchInput } from './FilterSearchInput';
|
|
||||||
export type { FilterSearchInputProps } from './FilterSearchInput';
|
|
||||||
|
|
@ -168,7 +168,7 @@
|
||||||
.searchInput {
|
.searchInput {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 40px;
|
height: 40px;
|
||||||
padding: 8px 28px 8px 12px;
|
padding: 8px 12px;
|
||||||
border: 1px solid var(--color-border, #E2E8F0);
|
border: 1px solid var(--color-border, #E2E8F0);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ import React from 'react';
|
||||||
import type { IconType } from 'react-icons';
|
import type { IconType } from 'react-icons';
|
||||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
import styles from './FormGeneratorControls.module.css';
|
import styles from './FormGeneratorControls.module.css';
|
||||||
import { FilterSearchInput } from '../FilterSearchInput';
|
|
||||||
import { Button } from '../../UiComponents/Button';
|
import { Button } from '../../UiComponents/Button';
|
||||||
import { IoIosRefresh } from "react-icons/io";
|
import { IoIosRefresh } from "react-icons/io";
|
||||||
import { FaTrash, FaDownload } from "react-icons/fa";
|
import { FaTrash, FaDownload } from "react-icons/fa";
|
||||||
|
|
@ -190,15 +189,14 @@ export function FormGeneratorControls({
|
||||||
<div className={styles.searchContainer}>
|
<div className={styles.searchContainer}>
|
||||||
{searchable && (
|
{searchable && (
|
||||||
<div className={styles.floatingLabelInput}>
|
<div className={styles.floatingLabelInput}>
|
||||||
<FilterSearchInput
|
<input
|
||||||
variant="inherit"
|
type="text"
|
||||||
value={searchTerm}
|
|
||||||
onChange={onSearchChange}
|
|
||||||
placeholder=" "
|
placeholder=" "
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => onSearchChange(e.target.value)}
|
||||||
onFocus={() => onSearchFocus(true)}
|
onFocus={() => onSearchFocus(true)}
|
||||||
onBlur={() => onSearchFocus(false)}
|
onBlur={() => onSearchFocus(false)}
|
||||||
inputClassName={`${styles.searchInput} ${searchFocused || searchTerm ? styles.focused : ''}`}
|
className={`${styles.searchInput} ${searchFocused || searchTerm ? styles.focused : ''}`}
|
||||||
clearTitle={t('Suche löschen')}
|
|
||||||
/>
|
/>
|
||||||
<label className={searchFocused || searchTerm ? styles.focusedLabel : styles.label}>
|
<label className={searchFocused || searchTerm ? styles.focusedLabel : styles.label}>
|
||||||
{t('Suchen...')}
|
{t('Suchen...')}
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,6 @@ import {
|
||||||
import { formatUnixTimestamp } from '../../../utils/time';
|
import { formatUnixTimestamp } from '../../../utils/time';
|
||||||
import { applyFrontendFormat } from '../../../utils/applyFrontendFormat';
|
import { applyFrontendFormat } from '../../../utils/applyFrontendFormat';
|
||||||
import { FormGeneratorControls } from '../FormGeneratorControls';
|
import { FormGeneratorControls } from '../FormGeneratorControls';
|
||||||
import { FilterSearchInput } from '../FilterSearchInput';
|
|
||||||
import { CopyableTruncatedValue } from '../../UiComponents/CopyableTruncatedValue';
|
import { CopyableTruncatedValue } from '../../UiComponents/CopyableTruncatedValue';
|
||||||
import {
|
import {
|
||||||
isDateTimeType,
|
isDateTimeType,
|
||||||
|
|
@ -447,11 +446,22 @@ function FilterValuesList({
|
||||||
<>
|
<>
|
||||||
{showSearch && (
|
{showSearch && (
|
||||||
<div style={{ padding: '4px 6px', borderBottom: '1px solid var(--border-color, #ddd)' }}>
|
<div style={{ padding: '4px 6px', borderBottom: '1px solid var(--border-color, #ddd)' }}>
|
||||||
<FilterSearchInput
|
<input
|
||||||
inputRef={searchInputRef}
|
ref={searchInputRef}
|
||||||
|
type="text"
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(value) => { setSearchTerm(value); setDisplayCount(_FILTER_PAGE_SIZE); }}
|
onChange={(e) => { setSearchTerm(e.target.value); setDisplayCount(_FILTER_PAGE_SIZE); }}
|
||||||
onInputClick={(e) => e.stopPropagation()}
|
placeholder="Filter..."
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '3px 6px',
|
||||||
|
fontSize: '12px',
|
||||||
|
border: '1px solid var(--border-color, #ccc)',
|
||||||
|
borderRadius: '3px',
|
||||||
|
outline: 'none',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { FaFolder, FaFile, FaTrash } from 'react-icons/fa';
|
import { FaFolder, FaFile, FaTrash } from 'react-icons/fa';
|
||||||
import type { TreeNodeProvider, TreeNode, Ownership, ScopeValue, TreeBatchAction } from '../types';
|
import type { TreeNodeProvider, TreeNode, Ownership, ScopeValue, TreeBatchAction } from '../types';
|
||||||
import api from '../../../../api';
|
import api from '../../../../api';
|
||||||
|
import { getUserDataCache } from '../../../../utils/userCache';
|
||||||
|
|
||||||
interface FolderData {
|
interface FolderData {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -136,7 +137,7 @@ export function createFolderFileProvider(options: { includeFiles?: boolean } = {
|
||||||
if ((f.parentId ?? null) === null) out.add(f.id);
|
if ((f.parentId ?? null) === null) out.add(f.id);
|
||||||
}
|
}
|
||||||
const paginationParam = JSON.stringify({ filters: { folderId: null }, pageSize: 500 });
|
const paginationParam = JSON.stringify({ filters: { folderId: null }, pageSize: 500 });
|
||||||
const filesRes = await api.get('/api/files/list', { params: { pagination: paginationParam, owner } });
|
const filesRes = await api.get('/api/files/list', { params: { pagination: paginationParam } });
|
||||||
const data = filesRes.data;
|
const data = filesRes.data;
|
||||||
const rawFiles: FileData[] = (data && typeof data === 'object' && 'items' in data)
|
const rawFiles: FileData[] = (data && typeof data === 'object' && 'items' in data)
|
||||||
? (Array.isArray(data.items) ? data.items : [])
|
? (Array.isArray(data.items) ? data.items : [])
|
||||||
|
|
@ -192,7 +193,7 @@ export function createFolderFileProvider(options: { includeFiles?: boolean } = {
|
||||||
}
|
}
|
||||||
const paginationParam = JSON.stringify({ filters, pageSize: 500 });
|
const paginationParam = JSON.stringify({ filters, pageSize: 500 });
|
||||||
const filesRes = await api.get('/api/files/list', {
|
const filesRes = await api.get('/api/files/list', {
|
||||||
params: { pagination: paginationParam, owner },
|
params: { pagination: paginationParam },
|
||||||
});
|
});
|
||||||
const data = filesRes.data;
|
const data = filesRes.data;
|
||||||
let rawFiles: FileData[] = [];
|
let rawFiles: FileData[] = [];
|
||||||
|
|
@ -202,6 +203,10 @@ export function createFolderFileProvider(options: { includeFiles?: boolean } = {
|
||||||
rawFiles = data;
|
rawFiles = data;
|
||||||
}
|
}
|
||||||
let matched = rawFiles.filter((f) => (f.folderId ?? null) === apiParentId);
|
let matched = rawFiles.filter((f) => (f.folderId ?? null) === apiParentId);
|
||||||
|
if (ownership === 'shared') {
|
||||||
|
const myId = getUserDataCache()?.id;
|
||||||
|
if (myId) matched = matched.filter((f) => f.sysCreatedBy !== myId);
|
||||||
|
}
|
||||||
const fileNodes = matched.map((f) => _mapFileToNode(f, ownership));
|
const fileNodes = matched.map((f) => _mapFileToNode(f, ownership));
|
||||||
if (apiParentId === null) {
|
if (apiParentId === null) {
|
||||||
for (const n of fileNodes) n.parentId = synthRootId;
|
for (const n of fileNodes) n.parentId = synthRootId;
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ interface ChatsTabProps {
|
||||||
onSelectChat?: (chatId: string, featureInstanceId: string) => void;
|
onSelectChat?: (chatId: string, featureInstanceId: string) => void;
|
||||||
onDragStart?: (chatId: string, event: React.DragEvent) => void;
|
onDragStart?: (chatId: string, event: React.DragEvent) => void;
|
||||||
activeWorkflowId?: string;
|
activeWorkflowId?: string;
|
||||||
chatListRefreshKey?: number;
|
onCreateNew?: () => void;
|
||||||
onRenameChat?: (chatId: string, newName: string) => void | Promise<void>;
|
onRenameChat?: (chatId: string, newName: string) => void | Promise<void>;
|
||||||
onDeleteChat?: (chatId: string) => void | Promise<void>;
|
onDeleteChat?: (chatId: string) => void | Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
@ -72,7 +72,7 @@ const ChatsTab: React.FC<ChatsTabProps> = ({ context,
|
||||||
onSelectChat,
|
onSelectChat,
|
||||||
onDragStart,
|
onDragStart,
|
||||||
activeWorkflowId,
|
activeWorkflowId,
|
||||||
chatListRefreshKey,
|
onCreateNew,
|
||||||
onRenameChat,
|
onRenameChat,
|
||||||
onDeleteChat,
|
onDeleteChat,
|
||||||
}) => {
|
}) => {
|
||||||
|
|
@ -82,14 +82,13 @@ const ChatsTab: React.FC<ChatsTabProps> = ({ context,
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [filter, setFilter] = useState<ChatFilter>('active');
|
const [filter, setFilter] = useState<ChatFilter>('active');
|
||||||
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
|
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
|
||||||
const [hasLoadedOnce, setHasLoadedOnce] = useState(false);
|
const [loading, setLoading] = useState(true);
|
||||||
const [editingId, setEditingId] = useState<string | null>(null);
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
const [editName, setEditName] = useState('');
|
const [editName, setEditName] = useState('');
|
||||||
const renameInputRef = useRef<HTMLInputElement>(null);
|
const renameInputRef = useRef<HTMLInputElement>(null);
|
||||||
const groupsRef = useRef(groups);
|
|
||||||
groupsRef.current = groups;
|
|
||||||
|
|
||||||
const _loadChats = useCallback(async (serverSearch?: string) => {
|
const _loadChats = useCallback(async (serverSearch?: string) => {
|
||||||
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const params: Record<string, unknown> = { includeArchived: true };
|
const params: Record<string, unknown> = { includeArchived: true };
|
||||||
if (serverSearch) params.search = serverSearch;
|
if (serverSearch) params.search = serverSearch;
|
||||||
|
|
@ -141,7 +140,7 @@ const ChatsTab: React.FC<ChatsTabProps> = ({ context,
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load chats:', err);
|
console.error('Failed to load chats:', err);
|
||||||
} finally {
|
} finally {
|
||||||
setHasLoadedOnce(true);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [context.instanceId, t]);
|
}, [context.instanceId, t]);
|
||||||
|
|
||||||
|
|
@ -164,12 +163,6 @@ const ChatsTab: React.FC<ChatsTabProps> = ({ context,
|
||||||
}
|
}
|
||||||
}, [activeWorkflowId]);
|
}, [activeWorkflowId]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (chatListRefreshKey) {
|
|
||||||
_loadChats();
|
|
||||||
}
|
|
||||||
}, [chatListRefreshKey, _loadChats]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (editingId && renameInputRef.current) {
|
if (editingId && renameInputRef.current) {
|
||||||
renameInputRef.current.focus();
|
renameInputRef.current.focus();
|
||||||
|
|
@ -195,18 +188,8 @@ const ChatsTab: React.FC<ChatsTabProps> = ({ context,
|
||||||
const trimmed = editName.trim();
|
const trimmed = editName.trim();
|
||||||
setEditingId(null);
|
setEditingId(null);
|
||||||
if (!trimmed || !onRenameChat) return;
|
if (!trimmed || !onRenameChat) return;
|
||||||
const prev = groupsRef.current;
|
await onRenameChat(chatId, trimmed);
|
||||||
setGroups(gs => gs.map(g => ({
|
_loadChats();
|
||||||
...g,
|
|
||||||
chats: g.chats.map(c => (c.id === chatId ? { ...c, label: trimmed } : c)),
|
|
||||||
})));
|
|
||||||
try {
|
|
||||||
await onRenameChat(chatId, trimmed);
|
|
||||||
_loadChats();
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to rename chat:', err);
|
|
||||||
setGroups(prev);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const _handleRenameKeyDown = (e: React.KeyboardEvent, chatId: string) => {
|
const _handleRenameKeyDown = (e: React.KeyboardEvent, chatId: string) => {
|
||||||
|
|
@ -218,41 +201,23 @@ const ChatsTab: React.FC<ChatsTabProps> = ({ context,
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const _setChatStatus = useCallback((chatId: string, status: string) => {
|
|
||||||
setGroups(gs => gs.map(g => ({
|
|
||||||
...g,
|
|
||||||
chats: g.chats.map(c => (c.id === chatId ? { ...c, status } : c)),
|
|
||||||
})));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const _removeChat = useCallback((chatId: string) => {
|
|
||||||
setGroups(gs => gs.map(g => ({
|
|
||||||
...g,
|
|
||||||
chats: g.chats.filter(c => c.id !== chatId),
|
|
||||||
})).filter(g => g.chats.length > 0));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const _archiveChat = useCallback(async (chatId: string) => {
|
const _archiveChat = useCallback(async (chatId: string) => {
|
||||||
const prev = groupsRef.current;
|
|
||||||
_setChatStatus(chatId, 'archived');
|
|
||||||
try {
|
try {
|
||||||
await api.patch(`/api/workspace/${context.instanceId}/workflows/${chatId}`, { status: 'archived' });
|
await api.patch(`/api/workspace/${context.instanceId}/workflows/${chatId}`, { status: 'archived' });
|
||||||
|
_loadChats();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to archive chat:', err);
|
console.error('Failed to archive chat:', err);
|
||||||
setGroups(prev);
|
|
||||||
}
|
}
|
||||||
}, [context.instanceId, _setChatStatus]);
|
}, [context.instanceId, _loadChats]);
|
||||||
|
|
||||||
const _restoreChat = useCallback(async (chatId: string) => {
|
const _restoreChat = useCallback(async (chatId: string) => {
|
||||||
const prev = groupsRef.current;
|
|
||||||
_setChatStatus(chatId, 'active');
|
|
||||||
try {
|
try {
|
||||||
await api.patch(`/api/workspace/${context.instanceId}/workflows/${chatId}`, { status: 'active' });
|
await api.patch(`/api/workspace/${context.instanceId}/workflows/${chatId}`, { status: 'active' });
|
||||||
|
_loadChats();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to restore chat:', err);
|
console.error('Failed to restore chat:', err);
|
||||||
setGroups(prev);
|
|
||||||
}
|
}
|
||||||
}, [context.instanceId, _setChatStatus]);
|
}, [context.instanceId, _loadChats]);
|
||||||
|
|
||||||
const _isArchived = (chat: ChatItem) => chat.status === 'archived';
|
const _isArchived = (chat: ChatItem) => chat.status === 'archived';
|
||||||
|
|
||||||
|
|
@ -346,17 +311,7 @@ const ChatsTab: React.FC<ChatsTabProps> = ({ context,
|
||||||
{onDeleteChat && (
|
{onDeleteChat && (
|
||||||
<button
|
<button
|
||||||
className={`${styles.actionBtn} ${styles.actionBtnDanger}`}
|
className={`${styles.actionBtn} ${styles.actionBtnDanger}`}
|
||||||
onClick={async (e) => {
|
onClick={async (e) => { e.stopPropagation(); await onDeleteChat(chat.id); _loadChats(); }}
|
||||||
e.stopPropagation();
|
|
||||||
const prev = groupsRef.current;
|
|
||||||
_removeChat(chat.id);
|
|
||||||
try {
|
|
||||||
await onDeleteChat(chat.id);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to delete chat:', err);
|
|
||||||
setGroups(prev);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
title={t('Löschen')}
|
title={t('Löschen')}
|
||||||
>
|
>
|
||||||
🗑️
|
🗑️
|
||||||
|
|
@ -379,6 +334,8 @@ const ChatsTab: React.FC<ChatsTabProps> = ({ context,
|
||||||
return labels[code] || code;
|
return labels[code] || code;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (loading) return <div className={styles.loading}>{t('Chats werden geladen…')}</div>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.chatsTab}>
|
<div className={styles.chatsTab}>
|
||||||
<div className={styles.toolbar}>
|
<div className={styles.toolbar}>
|
||||||
|
|
@ -389,6 +346,11 @@ const ChatsTab: React.FC<ChatsTabProps> = ({ context,
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
{onCreateNew && (
|
||||||
|
<button className={styles.createBtn} onClick={() => { onCreateNew(); setTimeout(_loadChats, 500); }} title={t('Neuer Chat')}>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
className={`${styles.modeToggle} ${flatMode ? styles.modeActive : ''}`}
|
className={`${styles.modeToggle} ${flatMode ? styles.modeActive : ''}`}
|
||||||
onClick={() => setFlatMode(!flatMode)}
|
onClick={() => setFlatMode(!flatMode)}
|
||||||
|
|
@ -475,7 +437,7 @@ const ChatsTab: React.FC<ChatsTabProps> = ({ context,
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{hasLoadedOnce && _allChats.length === 0 && (
|
{_allChats.length === 0 && (
|
||||||
<div className={styles.emptyState}>
|
<div className={styles.emptyState}>
|
||||||
{filter === 'archived' ? t('Keine archivierten Chats') : t('Keine aktiven Chats')}
|
{filter === 'archived' ? t('Keine archivierten Chats') : t('Keine aktiven Chats')}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -81,60 +81,6 @@
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.uploadCircleButton {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 12px;
|
|
||||||
color: #f25843;
|
|
||||||
width: 26px;
|
|
||||||
height: 26px;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.uploadCircleButton:disabled {
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.uploadCircleWrap {
|
|
||||||
position: relative;
|
|
||||||
width: 22px;
|
|
||||||
height: 22px;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.uploadCircleSvg {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
transform: rotate(-90deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.uploadCircleTrack {
|
|
||||||
fill: none;
|
|
||||||
stroke: rgba(242, 88, 67, 0.25);
|
|
||||||
stroke-width: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.uploadCircleProgress {
|
|
||||||
fill: none;
|
|
||||||
stroke: #f25843;
|
|
||||||
stroke-width: 2;
|
|
||||||
stroke-linecap: round;
|
|
||||||
transition: stroke-dashoffset 120ms linear;
|
|
||||||
}
|
|
||||||
|
|
||||||
.uploadCircleText {
|
|
||||||
font-size: 8px;
|
|
||||||
font-weight: 700;
|
|
||||||
line-height: 1;
|
|
||||||
color: #f25843;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
.fileRow:hover {
|
.fileRow:hover {
|
||||||
background: rgba(255, 255, 255, 0.05);
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
|
|
||||||
|
|
@ -28,8 +28,6 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect, onSendToChat
|
||||||
const { showSuccess, showError } = useToast();
|
const { showSuccess, showError } = useToast();
|
||||||
const [isDragOver, setIsDragOver] = useState(false);
|
const [isDragOver, setIsDragOver] = useState(false);
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
const [uploadProgressPercent, setUploadProgressPercent] = useState(0);
|
|
||||||
const uploadRunIdRef = useRef(0);
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const provider = useMemo(() => createFolderFileProvider(), []);
|
const provider = useMemo(() => createFolderFileProvider(), []);
|
||||||
|
|
@ -56,41 +54,21 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect, onSendToChat
|
||||||
|
|
||||||
const _uploadFiles = useCallback(async (fileList: FileList | File[]) => {
|
const _uploadFiles = useCallback(async (fileList: FileList | File[]) => {
|
||||||
if (!context.instanceId || uploading) return;
|
if (!context.instanceId || uploading) return;
|
||||||
uploadRunIdRef.current += 1;
|
|
||||||
const runId = uploadRunIdRef.current;
|
|
||||||
setUploading(true);
|
setUploading(true);
|
||||||
setUploadProgressPercent(0);
|
|
||||||
try {
|
try {
|
||||||
const files = Array.from(fileList);
|
for (const file of Array.from(fileList)) {
|
||||||
const totalFiles = files.length || 1;
|
|
||||||
for (const [index, file] of files.entries()) {
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
formData.append('featureInstanceId', context.instanceId);
|
formData.append('featureInstanceId', context.instanceId);
|
||||||
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' },
|
||||||
onUploadProgress: progressEvent => {
|
|
||||||
if (uploadRunIdRef.current !== runId) return;
|
|
||||||
if (!progressEvent.total) return;
|
|
||||||
const fileProgress = Math.min(100, Math.round((progressEvent.loaded * 100) / progressEvent.total));
|
|
||||||
const baseProgress = (index / totalFiles) * 100;
|
|
||||||
const scaledFileProgress = fileProgress / totalFiles;
|
|
||||||
setUploadProgressPercent(Math.min(100, Math.round(baseProgress + scaledFileProgress)));
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (uploadRunIdRef.current === runId) setUploadProgressPercent(100);
|
|
||||||
_handleRefresh();
|
_handleRefresh();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('File upload failed:', err);
|
console.error('File upload failed:', err);
|
||||||
} finally {
|
} finally {
|
||||||
if (uploadRunIdRef.current === runId) {
|
setUploading(false);
|
||||||
setUploading(false);
|
|
||||||
// Let 100% render briefly, then reset.
|
|
||||||
window.setTimeout(() => {
|
|
||||||
if (uploadRunIdRef.current === runId) setUploadProgressPercent(0);
|
|
||||||
}, 250);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, [context.instanceId, uploading, _handleRefresh]);
|
}, [context.instanceId, uploading, _handleRefresh]);
|
||||||
|
|
||||||
|
|
@ -157,10 +135,6 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect, onSendToChat
|
||||||
onSendToChat?.([{ id: node.id, type: node.type === 'folder' ? 'group' : 'file', name: node.name }]);
|
onSendToChat?.([{ id: node.id, type: node.type === 'folder' ? 'group' : 'file', name: node.name }]);
|
||||||
}, [onSendToChat]);
|
}, [onSendToChat]);
|
||||||
|
|
||||||
const circleRadius = 11;
|
|
||||||
const circleCircumference = 2 * Math.PI * circleRadius;
|
|
||||||
const circleOffset = circleCircumference * (1 - uploadProgressPercent / 100);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={styles.filesTab}
|
className={styles.filesTab}
|
||||||
|
|
@ -196,26 +170,10 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect, onSendToChat
|
||||||
<button
|
<button
|
||||||
onClick={() => fileInputRef.current?.click()}
|
onClick={() => fileInputRef.current?.click()}
|
||||||
disabled={uploading}
|
disabled={uploading}
|
||||||
className={styles.uploadCircleButton}
|
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, color: '#F25843' }}
|
||||||
title={t('Dateien hochladen')}
|
title={t('Dateien hochladen')}
|
||||||
>
|
>
|
||||||
{uploading ? (
|
{uploading ? '...' : '+'}
|
||||||
<span className={styles.uploadCircleWrap} aria-hidden="true">
|
|
||||||
<svg className={styles.uploadCircleSvg} viewBox="0 0 24 24">
|
|
||||||
<circle className={styles.uploadCircleTrack} cx="12" cy="12" r={circleRadius} />
|
|
||||||
<circle
|
|
||||||
className={styles.uploadCircleProgress}
|
|
||||||
cx="12"
|
|
||||||
cy="12"
|
|
||||||
r={circleRadius}
|
|
||||||
style={{ strokeDasharray: `${circleCircumference}`, strokeDashoffset: `${circleOffset}` }}
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span className={styles.uploadCircleText}>{uploadProgressPercent}%</span>
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
'+'
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={_handleRefresh}
|
onClick={_handleRefresh}
|
||||||
|
|
|
||||||
|
|
@ -47,8 +47,8 @@ interface UnifiedDataBarProps {
|
||||||
hideTabs?: UdbTab[];
|
hideTabs?: UdbTab[];
|
||||||
onSelectChat?: (chatId: string, featureInstanceId: string) => void;
|
onSelectChat?: (chatId: string, featureInstanceId: string) => void;
|
||||||
activeWorkflowId?: string;
|
activeWorkflowId?: string;
|
||||||
|
onCreateNewChat?: () => void;
|
||||||
onRenameChat?: (chatId: string, newName: string) => void;
|
onRenameChat?: (chatId: string, newName: string) => void;
|
||||||
chatListRefreshKey?: number;
|
|
||||||
onDeleteChat?: (chatId: string) => void;
|
onDeleteChat?: (chatId: string) => void;
|
||||||
onChatDragStart?: (chatId: string, event: React.DragEvent) => void;
|
onChatDragStart?: (chatId: string, event: React.DragEvent) => void;
|
||||||
onFileSelect?: (fileId: string, fileName?: string) => void;
|
onFileSelect?: (fileId: string, fileName?: string) => void;
|
||||||
|
|
@ -78,8 +78,8 @@ const UnifiedDataBar: React.FC<UnifiedDataBarProps> = ({
|
||||||
hideTabs,
|
hideTabs,
|
||||||
onSelectChat,
|
onSelectChat,
|
||||||
activeWorkflowId,
|
activeWorkflowId,
|
||||||
|
onCreateNewChat,
|
||||||
onRenameChat,
|
onRenameChat,
|
||||||
chatListRefreshKey,
|
|
||||||
onDeleteChat,
|
onDeleteChat,
|
||||||
onChatDragStart,
|
onChatDragStart,
|
||||||
onFileSelect,
|
onFileSelect,
|
||||||
|
|
@ -122,7 +122,7 @@ const UnifiedDataBar: React.FC<UnifiedDataBarProps> = ({
|
||||||
onSelectChat={onSelectChat}
|
onSelectChat={onSelectChat}
|
||||||
onDragStart={onChatDragStart}
|
onDragStart={onChatDragStart}
|
||||||
activeWorkflowId={activeWorkflowId}
|
activeWorkflowId={activeWorkflowId}
|
||||||
chatListRefreshKey={chatListRefreshKey}
|
onCreateNew={onCreateNewChat}
|
||||||
onRenameChat={onRenameChat}
|
onRenameChat={onRenameChat}
|
||||||
onDeleteChat={onDeleteChat}
|
onDeleteChat={onDeleteChat}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,6 @@ export interface PaginationParams {
|
||||||
filters?: Record<string, any>;
|
filters?: Record<string, any>;
|
||||||
search?: string;
|
search?: string;
|
||||||
viewKey?: string;
|
viewKey?: string;
|
||||||
owner?: 'all' | 'me' | 'shared';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Files list hook
|
// Files list hook
|
||||||
|
|
@ -151,7 +150,6 @@ export function useUserFiles() {
|
||||||
groupField: string;
|
groupField: string;
|
||||||
groupDirection?: 'asc' | 'desc';
|
groupDirection?: 'asc' | 'desc';
|
||||||
groupByLevels?: Array<{ field: string; nullLabel?: string; direction?: string }>;
|
groupByLevels?: Array<{ field: string; nullLabel?: string; direction?: string }>;
|
||||||
owner?: 'all' | 'me' | 'shared';
|
|
||||||
}) => {
|
}) => {
|
||||||
const levels = base.groupByLevels?.length
|
const levels = base.groupByLevels?.length
|
||||||
? base.groupByLevels
|
? base.groupByLevels
|
||||||
|
|
@ -166,11 +164,7 @@ export function useUserFiles() {
|
||||||
if (base.sort?.length) (pObj as { sort: typeof base.sort }).sort = base.sort;
|
if (base.sort?.length) (pObj as { sort: typeof base.sort }).sort = base.sort;
|
||||||
if (base.viewKey) (pObj as { viewKey: string }).viewKey = base.viewKey;
|
if (base.viewKey) (pObj as { viewKey: string }).viewKey = base.viewKey;
|
||||||
const { data } = await api.get('/api/files/list', {
|
const { data } = await api.get('/api/files/list', {
|
||||||
params: {
|
params: { mode: 'groupSummary', pagination: JSON.stringify(pObj) },
|
||||||
mode: 'groupSummary',
|
|
||||||
pagination: JSON.stringify(pObj),
|
|
||||||
...(base.owner ? { owner: base.owner } : {}),
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
return Array.isArray(data?.groups) ? data.groups : [];
|
return Array.isArray(data?.groups) ? data.groups : [];
|
||||||
},
|
},
|
||||||
|
|
@ -198,10 +192,7 @@ export function useUserFiles() {
|
||||||
if (paginationParams.search) (pObj as { search: string }).search = paginationParams.search;
|
if (paginationParams.search) (pObj as { search: string }).search = paginationParams.search;
|
||||||
if (paginationParams.viewKey) (pObj as { viewKey: string }).viewKey = paginationParams.viewKey;
|
if (paginationParams.viewKey) (pObj as { viewKey: string }).viewKey = paginationParams.viewKey;
|
||||||
const { data } = await api.get('/api/files/list', {
|
const { data } = await api.get('/api/files/list', {
|
||||||
params: {
|
params: { pagination: JSON.stringify(pObj) },
|
||||||
pagination: JSON.stringify(pObj),
|
|
||||||
...(paginationParams.owner ? { owner: paginationParams.owner } : {}),
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
if (data && typeof data === 'object' && 'items' in data) {
|
if (data && typeof data === 'object' && 'items' in data) {
|
||||||
return { items: data.items, pagination: data.pagination };
|
return { items: data.items, pagination: data.pagination };
|
||||||
|
|
@ -417,7 +408,6 @@ export function useFileOperations() {
|
||||||
const [deletingFiles, setDeletingFiles] = useState<Set<string>>(new Set());
|
const [deletingFiles, setDeletingFiles] = useState<Set<string>>(new Set());
|
||||||
const [editingFiles, setEditingFiles] = useState<Set<string>>(new Set());
|
const [editingFiles, setEditingFiles] = useState<Set<string>>(new Set());
|
||||||
const [uploadingFile, setUploadingFile] = useState(false);
|
const [uploadingFile, setUploadingFile] = useState(false);
|
||||||
const [uploadProgress, setUploadProgress] = useState(0);
|
|
||||||
const [isLoading] = useState(false);
|
const [isLoading] = useState(false);
|
||||||
const [downloadError, setDownloadError] = useState<string | null>(null);
|
const [downloadError, setDownloadError] = useState<string | null>(null);
|
||||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||||
|
|
@ -574,11 +564,9 @@ export function useFileOperations() {
|
||||||
file: globalThis.File,
|
file: globalThis.File,
|
||||||
workflowId?: string,
|
workflowId?: string,
|
||||||
featureInstanceId?: string,
|
featureInstanceId?: string,
|
||||||
onProgress?: (progress: number) => void,
|
|
||||||
) => {
|
) => {
|
||||||
setUploadError(null);
|
setUploadError(null);
|
||||||
setUploadingFile(true);
|
setUploadingFile(true);
|
||||||
setUploadProgress(0);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
||||||
|
|
@ -605,14 +593,7 @@ export function useFileOperations() {
|
||||||
|
|
||||||
|
|
||||||
// Do NOT set Content-Type manually – axios sets multipart/form-data with boundary for FormData
|
// Do NOT set Content-Type manually – axios sets multipart/form-data with boundary for FormData
|
||||||
const response = await api.post('/api/files/upload', formData, {
|
const response = await api.post('/api/files/upload', formData);
|
||||||
onUploadProgress: progressEvent => {
|
|
||||||
if (!progressEvent.total) return;
|
|
||||||
const progress = Math.min(100, Math.round((progressEvent.loaded * 100) / progressEvent.total));
|
|
||||||
setUploadProgress(progress);
|
|
||||||
onProgress?.(progress);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const fileData = response.data;
|
const fileData = response.data;
|
||||||
|
|
||||||
// Check if the response indicates a duplicate file
|
// Check if the response indicates a duplicate file
|
||||||
|
|
@ -644,7 +625,6 @@ export function useFileOperations() {
|
||||||
return { success: false, error: errorMessage };
|
return { success: false, error: errorMessage };
|
||||||
} finally {
|
} finally {
|
||||||
setUploadingFile(false);
|
setUploadingFile(false);
|
||||||
setUploadProgress(0);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -769,7 +749,6 @@ export function useFileOperations() {
|
||||||
deletingFiles,
|
deletingFiles,
|
||||||
editingFiles,
|
editingFiles,
|
||||||
uploadingFile,
|
uploadingFile,
|
||||||
uploadProgress,
|
|
||||||
downloadError,
|
downloadError,
|
||||||
deleteError,
|
deleteError,
|
||||||
uploadError,
|
uploadError,
|
||||||
|
|
|
||||||
|
|
@ -31,17 +31,6 @@ interface UserFile {
|
||||||
}
|
}
|
||||||
|
|
||||||
type ViewMode = 'folder' | 'all';
|
type ViewMode = 'folder' | 'all';
|
||||||
type FileOwnerScope = 'all' | 'me' | 'shared';
|
|
||||||
|
|
||||||
function normalizeFolderFilterId(folderId: string | null): string | null {
|
|
||||||
if (!folderId) return null;
|
|
||||||
if (folderId.startsWith('__filesRoot:')) return null;
|
|
||||||
return folderId;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isSyntheticRootFolderId(folderId: string | null): boolean {
|
|
||||||
return Boolean(folderId && folderId.startsWith('__filesRoot:'));
|
|
||||||
}
|
|
||||||
|
|
||||||
export const FilesPage: React.FC = () => {
|
export const FilesPage: React.FC = () => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
|
|
@ -78,16 +67,14 @@ export const FilesPage: React.FC = () => {
|
||||||
handleInlineUpdate,
|
handleInlineUpdate,
|
||||||
deletingFiles,
|
deletingFiles,
|
||||||
downloadingFiles,
|
downloadingFiles,
|
||||||
|
uploadingFile,
|
||||||
previewingFiles,
|
previewingFiles,
|
||||||
} = useFileOperations();
|
} = useFileOperations();
|
||||||
|
|
||||||
const [editingFile, setEditingFile] = useState<UserFile | null>(null);
|
const [editingFile, setEditingFile] = useState<UserFile | null>(null);
|
||||||
const [selectedFiles, setSelectedFiles] = useState<UserFile[]>([]);
|
const [selectedFiles, setSelectedFiles] = useState<UserFile[]>([]);
|
||||||
const [selectedFolderId, setSelectedFolderId] = useState<string | null>(null);
|
const [selectedFolderId, setSelectedFolderId] = useState<string | null>(null);
|
||||||
const [selectedOwnership, setSelectedOwnership] = useState<'own' | 'shared' | null>('own');
|
|
||||||
const [highlightedFileId, setHighlightedFileId] = useState<string | null>(null);
|
const [highlightedFileId, setHighlightedFileId] = useState<string | null>(null);
|
||||||
const [uploadProgressPercent, setUploadProgressPercent] = useState(0);
|
|
||||||
const [isUploadingBatch, setIsUploadingBatch] = useState(false);
|
|
||||||
|
|
||||||
const [treeWidth, setTreeWidth] = useState(300);
|
const [treeWidth, setTreeWidth] = useState(300);
|
||||||
const [treeVisible, setTreeVisible] = useState(true);
|
const [treeVisible, setTreeVisible] = useState(true);
|
||||||
|
|
@ -116,24 +103,14 @@ export const FilesPage: React.FC = () => {
|
||||||
const _tableRefetch = useCallback(async (params?: any) => {
|
const _tableRefetch = useCallback(async (params?: any) => {
|
||||||
const nextParams = { ...(params || {}) };
|
const nextParams = { ...(params || {}) };
|
||||||
const nextFilters = { ...(nextParams.filters || {}) };
|
const nextFilters = { ...(nextParams.filters || {}) };
|
||||||
const normalizedFolderId = normalizeFolderFilterId(selectedFolderId);
|
if (viewMode === 'folder' && selectedFolderId) {
|
||||||
const rootSelected = isSyntheticRootFolderId(selectedFolderId);
|
nextFilters.folderId = selectedFolderId;
|
||||||
const owner: FileOwnerScope =
|
|
||||||
selectedOwnership === 'own'
|
|
||||||
? 'me'
|
|
||||||
: selectedOwnership === 'shared'
|
|
||||||
? 'shared'
|
|
||||||
: 'all';
|
|
||||||
if (viewMode === 'folder' && selectedFolderId && !rootSelected) {
|
|
||||||
nextFilters.folderId = normalizedFolderId;
|
|
||||||
} else {
|
} else {
|
||||||
delete nextFilters.folderId;
|
delete nextFilters.folderId;
|
||||||
}
|
}
|
||||||
nextParams.filters = nextFilters;
|
nextParams.filters = nextFilters;
|
||||||
if (owner !== 'all') nextParams.owner = owner;
|
|
||||||
else delete nextParams.owner;
|
|
||||||
await tableRefetch(nextParams);
|
await tableRefetch(nextParams);
|
||||||
}, [tableRefetch, selectedFolderId, selectedOwnership, viewMode]);
|
}, [tableRefetch, selectedFolderId, viewMode]);
|
||||||
|
|
||||||
const fetchGroupSectionSummaries = useCallback(
|
const fetchGroupSectionSummaries = useCallback(
|
||||||
async (base: {
|
async (base: {
|
||||||
|
|
@ -145,20 +122,12 @@ export const FilesPage: React.FC = () => {
|
||||||
groupDirection?: 'asc' | 'desc';
|
groupDirection?: 'asc' | 'desc';
|
||||||
}) => {
|
}) => {
|
||||||
const filters = { ...(base.filters || {}) };
|
const filters = { ...(base.filters || {}) };
|
||||||
const normalizedFolderId = normalizeFolderFilterId(selectedFolderId);
|
if (viewMode === 'folder' && selectedFolderId) {
|
||||||
const rootSelected = isSyntheticRootFolderId(selectedFolderId);
|
filters.folderId = selectedFolderId;
|
||||||
if (viewMode === 'folder' && selectedFolderId && !rootSelected) {
|
|
||||||
filters.folderId = normalizedFolderId;
|
|
||||||
}
|
}
|
||||||
const owner: FileOwnerScope =
|
return fetchGroupSectionSummariesFromHook({ ...base, filters });
|
||||||
selectedOwnership === 'own'
|
|
||||||
? 'me'
|
|
||||||
: selectedOwnership === 'shared'
|
|
||||||
? 'shared'
|
|
||||||
: 'all';
|
|
||||||
return fetchGroupSectionSummariesFromHook({ ...base, filters, owner });
|
|
||||||
},
|
},
|
||||||
[fetchGroupSectionSummariesFromHook, viewMode, selectedFolderId, selectedOwnership],
|
[fetchGroupSectionSummariesFromHook, viewMode, selectedFolderId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const refetchForSection = useCallback(
|
const refetchForSection = useCallback(
|
||||||
|
|
@ -168,20 +137,12 @@ export const FilesPage: React.FC = () => {
|
||||||
parentColumnFilters?: Record<string, unknown>,
|
parentColumnFilters?: Record<string, unknown>,
|
||||||
) => {
|
) => {
|
||||||
const merged = { ...(parentColumnFilters || {}) };
|
const merged = { ...(parentColumnFilters || {}) };
|
||||||
const normalizedFolderId = normalizeFolderFilterId(selectedFolderId);
|
if (viewMode === 'folder' && selectedFolderId) {
|
||||||
const rootSelected = isSyntheticRootFolderId(selectedFolderId);
|
merged.folderId = selectedFolderId;
|
||||||
if (viewMode === 'folder' && selectedFolderId && !rootSelected) {
|
|
||||||
merged.folderId = normalizedFolderId;
|
|
||||||
}
|
}
|
||||||
const owner: FileOwnerScope =
|
return refetchForSectionFromHook(paginationParams, sectionFilter, merged);
|
||||||
selectedOwnership === 'own'
|
|
||||||
? 'me'
|
|
||||||
: selectedOwnership === 'shared'
|
|
||||||
? 'shared'
|
|
||||||
: 'all';
|
|
||||||
return refetchForSectionFromHook({ ...paginationParams, owner }, sectionFilter, merged);
|
|
||||||
},
|
},
|
||||||
[refetchForSectionFromHook, viewMode, selectedFolderId, selectedOwnership],
|
[refetchForSectionFromHook, viewMode, selectedFolderId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const _refreshAll = useCallback(async () => {
|
const _refreshAll = useCallback(async () => {
|
||||||
|
|
@ -191,15 +152,14 @@ export const FilesPage: React.FC = () => {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
_tableRefetch({ page: 1, pageSize: 25 });
|
_tableRefetch({ page: 1, pageSize: 25 });
|
||||||
}, [selectedFolderId, selectedOwnership, viewMode, _tableRefetch]);
|
}, [selectedFolderId, viewMode, _tableRefetch]);
|
||||||
|
|
||||||
// ── Tree interaction ──────────────────────────────────────────────────
|
// ── Tree interaction ──────────────────────────────────────────────────
|
||||||
const _handleTreeNodeClick = useCallback((node: TreeNode) => {
|
const _handleTreeNodeClick = useCallback((node: TreeNode) => {
|
||||||
setSelectedOwnership(node.ownership);
|
|
||||||
if (node.type === 'folder') {
|
if (node.type === 'folder') {
|
||||||
setSelectedFolderId(node.id);
|
setSelectedFolderId(node.id);
|
||||||
} else if (node.type === 'file') {
|
} else if (node.type === 'file') {
|
||||||
setSelectedFolderId(node.parentId ?? null);
|
setSelectedFolderId(node.parentId);
|
||||||
setHighlightedFileId(node.id);
|
setHighlightedFileId(node.id);
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
const row = document.querySelector('tr[data-highlighted="true"]');
|
const row = document.querySelector('tr[data-highlighted="true"]');
|
||||||
|
|
@ -304,38 +264,24 @@ export const FilesPage: React.FC = () => {
|
||||||
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const picked = e.target.files;
|
const picked = e.target.files;
|
||||||
if (picked && picked.length > 0) {
|
if (picked && picked.length > 0) {
|
||||||
setIsUploadingBatch(true);
|
let successCount = 0;
|
||||||
setUploadProgressPercent(0);
|
let errorCount = 0;
|
||||||
try {
|
for (const file of Array.from(picked)) {
|
||||||
let successCount = 0;
|
const result = await handleFileUpload(file);
|
||||||
let errorCount = 0;
|
if (result?.success) successCount++; else errorCount++;
|
||||||
const files = Array.from(picked);
|
}
|
||||||
const totalFiles = files.length;
|
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||||
for (const [index, file] of files.entries()) {
|
await _tableRefetch();
|
||||||
const result = await handleFileUpload(file, undefined, undefined, fileProgress => {
|
setTreeKey(k => k + 1);
|
||||||
const baseProgress = (index / totalFiles) * 100;
|
if (successCount > 0) {
|
||||||
const scaledFileProgress = fileProgress / totalFiles;
|
showSuccess(
|
||||||
setUploadProgressPercent(Math.min(100, Math.round(baseProgress + scaledFileProgress)));
|
t('Upload erfolgreich'),
|
||||||
});
|
errorCount > 0
|
||||||
if (result?.success) successCount++; else errorCount++;
|
? t('{successCount} Datei(en) hochgeladen, {errorCount} fehlgeschlagen', { successCount, errorCount })
|
||||||
}
|
: t('{successCount} Datei(en) hochgeladen', { successCount }),
|
||||||
setUploadProgressPercent(100);
|
);
|
||||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
} else if (errorCount > 0) {
|
||||||
await _tableRefetch();
|
showError(t('Upload fehlgeschlagen'), t('{errorCount} Datei(en) konnten nicht hochgeladen werden', { errorCount }));
|
||||||
setTreeKey(k => k + 1);
|
|
||||||
if (successCount > 0) {
|
|
||||||
showSuccess(
|
|
||||||
t('Upload erfolgreich'),
|
|
||||||
errorCount > 0
|
|
||||||
? t('{successCount} Datei(en) hochgeladen, {errorCount} fehlgeschlagen', { successCount, errorCount })
|
|
||||||
: t('{successCount} Datei(en) hochgeladen', { successCount }),
|
|
||||||
);
|
|
||||||
} else if (errorCount > 0) {
|
|
||||||
showError(t('Upload fehlgeschlagen'), t('{errorCount} Datei(en) konnten nicht hochgeladen werden', { errorCount }));
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setIsUploadingBatch(false);
|
|
||||||
setUploadProgressPercent(0);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -490,39 +436,8 @@ export const FilesPage: React.FC = () => {
|
||||||
<div style={{ flex: 1 }} />
|
<div style={{ flex: 1 }} />
|
||||||
|
|
||||||
{canCreate && (
|
{canCreate && (
|
||||||
<button
|
<button className={styles.primaryButton} onClick={handleUploadClick} disabled={uploadingFile}>
|
||||||
className={styles.primaryButton}
|
<FaUpload /> {uploadingFile ? t('Wird hochgeladen...') : t('Datei hochladen')}
|
||||||
onClick={handleUploadClick}
|
|
||||||
disabled={isUploadingBatch}
|
|
||||||
style={{ position: 'relative', overflow: 'hidden' }}
|
|
||||||
>
|
|
||||||
{isUploadingBatch && (
|
|
||||||
<span
|
|
||||||
aria-hidden="true"
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
left: 0,
|
|
||||||
top: 0,
|
|
||||||
bottom: 0,
|
|
||||||
width: `${uploadProgressPercent}%`,
|
|
||||||
background: 'rgba(255, 255, 255, 0.25)',
|
|
||||||
transition: 'width 120ms linear',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
position: 'relative',
|
|
||||||
zIndex: 1,
|
|
||||||
display: 'inline-flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 6,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<FaUpload />
|
|
||||||
<span>{t('Datei hochladen')}</span>
|
|
||||||
{isUploadingBatch && <span>{uploadProgressPercent}%</span>}
|
|
||||||
</span>
|
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -851,52 +851,6 @@
|
||||||
background: var(--surface-alt, #fafafa);
|
background: var(--surface-alt, #fafafa);
|
||||||
}
|
}
|
||||||
|
|
||||||
.panelTitleBar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 0.5rem;
|
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
border-bottom: 1px solid var(--border-color, #e0e0e0);
|
|
||||||
background: var(--surface-alt, #fafafa);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.panelTitleBar .panelTitle {
|
|
||||||
padding: 0;
|
|
||||||
border: none;
|
|
||||||
background: none;
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.panelExpandBtn {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 28px;
|
|
||||||
height: 28px;
|
|
||||||
padding: 0;
|
|
||||||
border: 1px solid var(--border-color, #e0e0e0);
|
|
||||||
border-radius: 4px;
|
|
||||||
background: var(--surface-color, #fff);
|
|
||||||
color: var(--text-secondary, #666);
|
|
||||||
cursor: pointer;
|
|
||||||
flex-shrink: 0;
|
|
||||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.panelExpandBtn:hover {
|
|
||||||
background: var(--surface-alt, #f5f5f5);
|
|
||||||
color: var(--primary-color, #4A90D9);
|
|
||||||
border-color: var(--primary-color, #4A90D9);
|
|
||||||
}
|
|
||||||
|
|
||||||
.popupPanelList {
|
|
||||||
max-height: none;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.transcriptList,
|
.transcriptList,
|
||||||
.responseList {
|
.responseList {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,6 @@ import styles from './Teamsbot.module.css';
|
||||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
import ReactMarkdown from 'react-markdown';
|
import ReactMarkdown from 'react-markdown';
|
||||||
import remarkGfm from 'remark-gfm';
|
import remarkGfm from 'remark-gfm';
|
||||||
import { Popup } from '../../../components/UiComponents/Popup';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TeamsbotSessionView - Live session view with real-time transcript and bot responses.
|
* TeamsbotSessionView - Live session view with real-time transcript and bot responses.
|
||||||
|
|
@ -55,8 +54,6 @@ export const TeamsbotSessionView: React.FC = () => {
|
||||||
const [screenshotsLoading, setScreenshotsLoading] = useState(false);
|
const [screenshotsLoading, setScreenshotsLoading] = useState(false);
|
||||||
const [screenshotsLoaded, setScreenshotsLoaded] = useState(false);
|
const [screenshotsLoaded, setScreenshotsLoaded] = useState(false);
|
||||||
const [screenshotsExpanded, setScreenshotsExpanded] = useState(false);
|
const [screenshotsExpanded, setScreenshotsExpanded] = useState(false);
|
||||||
const [transcriptPopupOpen, setTranscriptPopupOpen] = useState(false);
|
|
||||||
const [botResponsesPopupOpen, setBotResponsesPopupOpen] = useState(false);
|
|
||||||
const [ttsStatusEvents, setTtsStatusEvents] = useState<Array<{
|
const [ttsStatusEvents, setTtsStatusEvents] = useState<Array<{
|
||||||
status: string;
|
status: string;
|
||||||
message?: string;
|
message?: string;
|
||||||
|
|
@ -749,64 +746,6 @@ export const TeamsbotSessionView: React.FC = () => {
|
||||||
return colors[Math.abs(hash) % colors.length];
|
return colors[Math.abs(hash) % colors.length];
|
||||||
};
|
};
|
||||||
|
|
||||||
const _renderExpandIcon = () => (
|
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden>
|
|
||||||
<path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
const _renderTranscriptList = (endRef?: React.RefObject<HTMLDivElement | null>) => (
|
|
||||||
<>
|
|
||||||
{transcripts.map((seg) => (
|
|
||||||
<div key={seg.id} className={styles.transcriptItem}>
|
|
||||||
<span className={styles.transcriptTime}>{_formatTime(seg.timestamp)}</span>
|
|
||||||
<span
|
|
||||||
className={styles.transcriptSpeaker}
|
|
||||||
style={{ color: _getSpeakerColor(seg.speaker || t('Unbekannt')) }}
|
|
||||||
>
|
|
||||||
{seg.speaker || t('Unbekannt')}:
|
|
||||||
</span>
|
|
||||||
<span className={styles.transcriptText}>{seg.text}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{endRef && <div ref={endRef} />}
|
|
||||||
{transcripts.length === 0 && (
|
|
||||||
<div className={styles.emptyState}>{t('Noch kein Transkript vorhanden')}</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
const _renderBotResponsesList = () => (
|
|
||||||
<>
|
|
||||||
{botResponses.map((r) => (
|
|
||||||
<div key={r.id} className={styles.responseItem}>
|
|
||||||
<div className={styles.responseHeader}>
|
|
||||||
<span className={styles.responseIntent}>{r.detectedIntent}</span>
|
|
||||||
<span className={styles.responseTime}>{_formatTime(r.timestamp || '')}</span>
|
|
||||||
</div>
|
|
||||||
<div className={styles.responseText}>
|
|
||||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{r.responseText || ''}</ReactMarkdown>
|
|
||||||
</div>
|
|
||||||
{r.reasoning && (
|
|
||||||
<div className={styles.responseReasoning}>
|
|
||||||
<em>{t('Begründung: {text}', { text: r.reasoning })}</em>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{(r.modelName || r.processingTime != null) && (
|
|
||||||
<div className={styles.responseMeta}>
|
|
||||||
<span>{r.modelName || ''}</span>
|
|
||||||
{r.processingTime != null && <span>{r.processingTime.toFixed(1)}s</span>}
|
|
||||||
{r.priceCHF != null && <span>{r.priceCHF.toFixed(4)} CHF</span>}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{botResponses.length === 0 && (
|
|
||||||
<div className={styles.emptyState}>{t('Noch keine Botantworten')}</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (loading) return <div className={styles.loading}>{t('Sitzung laden')}</div>;
|
if (loading) return <div className={styles.loading}>{t('Sitzung laden')}</div>;
|
||||||
if (noSessions) return (
|
if (noSessions) return (
|
||||||
<div className={styles.emptyState || styles.loading} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: '1rem', padding: '3rem' }}>
|
<div className={styles.emptyState || styles.loading} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: '1rem', padding: '3rem' }}>
|
||||||
|
|
@ -1215,69 +1154,63 @@ export const TeamsbotSessionView: React.FC = () => {
|
||||||
<div className={styles.sessionContent}>
|
<div className={styles.sessionContent}>
|
||||||
{/* Left: Transcript */}
|
{/* Left: Transcript */}
|
||||||
<div className={styles.transcriptPanel}>
|
<div className={styles.transcriptPanel}>
|
||||||
<div className={styles.panelTitleBar}>
|
<h4 className={styles.panelTitle}>
|
||||||
<h4 className={styles.panelTitle}>
|
{t('Transkript ({count} Segmente)', { count: transcripts.length })}
|
||||||
{t('Transkript ({count} Segmente)', { count: transcripts.length })}
|
</h4>
|
||||||
</h4>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={styles.panelExpandBtn}
|
|
||||||
onClick={() => setTranscriptPopupOpen(true)}
|
|
||||||
title={t('Vollbild')}
|
|
||||||
aria-label={t('Transkript im Vollbild anzeigen')}
|
|
||||||
>
|
|
||||||
{_renderExpandIcon()}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className={styles.transcriptList}>
|
<div className={styles.transcriptList}>
|
||||||
{_renderTranscriptList(transcriptEndRef)}
|
{transcripts.map((seg) => (
|
||||||
|
<div key={seg.id} className={styles.transcriptItem}>
|
||||||
|
<span className={styles.transcriptTime}>{_formatTime(seg.timestamp)}</span>
|
||||||
|
<span
|
||||||
|
className={styles.transcriptSpeaker}
|
||||||
|
style={{ color: _getSpeakerColor(seg.speaker || t('Unbekannt')) }}
|
||||||
|
>
|
||||||
|
{seg.speaker || t('Unbekannt')}:
|
||||||
|
</span>
|
||||||
|
<span className={styles.transcriptText}>{seg.text}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div ref={transcriptEndRef} />
|
||||||
|
{transcripts.length === 0 && (
|
||||||
|
<div className={styles.emptyState}>{t('Noch kein Transkript vorhanden')}</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right: Bot Responses */}
|
{/* Right: Bot Responses */}
|
||||||
<div className={styles.responsesPanel}>
|
<div className={styles.responsesPanel}>
|
||||||
<div className={styles.panelTitleBar}>
|
<h4 className={styles.panelTitle}>Bot-Antworten ({botResponses.length})</h4>
|
||||||
<h4 className={styles.panelTitle}>Bot-Antworten ({botResponses.length})</h4>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={styles.panelExpandBtn}
|
|
||||||
onClick={() => setBotResponsesPopupOpen(true)}
|
|
||||||
title={t('Vollbild')}
|
|
||||||
aria-label={t('Bot-Antworten im Vollbild anzeigen')}
|
|
||||||
>
|
|
||||||
{_renderExpandIcon()}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className={styles.responseList}>
|
<div className={styles.responseList}>
|
||||||
{_renderBotResponsesList()}
|
{botResponses.map((r) => (
|
||||||
|
<div key={r.id} className={styles.responseItem}>
|
||||||
|
<div className={styles.responseHeader}>
|
||||||
|
<span className={styles.responseIntent}>{r.detectedIntent}</span>
|
||||||
|
<span className={styles.responseTime}>{_formatTime(r.timestamp || '')}</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.responseText}>
|
||||||
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>{r.responseText || ''}</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
{r.reasoning && (
|
||||||
|
<div className={styles.responseReasoning}>
|
||||||
|
<em>{t('Begründung: {text}', { text: r.reasoning })}</em>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(r.modelName || r.processingTime != null) && (
|
||||||
|
<div className={styles.responseMeta}>
|
||||||
|
<span>{r.modelName || ''}</span>
|
||||||
|
{r.processingTime != null && <span>{r.processingTime.toFixed(1)}s</span>}
|
||||||
|
{r.priceCHF != null && <span>{r.priceCHF.toFixed(4)} CHF</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{botResponses.length === 0 && (
|
||||||
|
<div className={styles.emptyState}>{t('Noch keine Botantworten')}</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Popup
|
|
||||||
isOpen={transcriptPopupOpen}
|
|
||||||
title={t('Transkript ({count} Segmente)', { count: transcripts.length })}
|
|
||||||
onClose={() => setTranscriptPopupOpen(false)}
|
|
||||||
size="fullscreen"
|
|
||||||
closeOnBackdropClick
|
|
||||||
>
|
|
||||||
<div className={`${styles.transcriptList} ${styles.popupPanelList}`}>
|
|
||||||
{_renderTranscriptList()}
|
|
||||||
</div>
|
|
||||||
</Popup>
|
|
||||||
|
|
||||||
<Popup
|
|
||||||
isOpen={botResponsesPopupOpen}
|
|
||||||
title={`Bot-Antworten (${botResponses.length})`}
|
|
||||||
onClose={() => setBotResponsesPopupOpen(false)}
|
|
||||||
size="fullscreen"
|
|
||||||
closeOnBackdropClick
|
|
||||||
>
|
|
||||||
<div className={`${styles.responseList} ${styles.popupPanelList}`}>
|
|
||||||
{_renderBotResponsesList()}
|
|
||||||
</div>
|
|
||||||
</Popup>
|
|
||||||
|
|
||||||
{/* Summary (for ended sessions) */}
|
{/* Summary (for ended sessions) */}
|
||||||
{session.summary && (
|
{session.summary && (
|
||||||
<div className={styles.summaryCard}>
|
<div className={styles.summaryCard}>
|
||||||
|
|
|
||||||
|
|
@ -94,7 +94,6 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
|
||||||
);
|
);
|
||||||
const [mobileLeftOpen, setMobileLeftOpen] = useState(false);
|
const [mobileLeftOpen, setMobileLeftOpen] = useState(false);
|
||||||
const [mobileRightOpen, setMobileRightOpen] = useState(false);
|
const [mobileRightOpen, setMobileRightOpen] = useState(false);
|
||||||
const [chatListRefreshKey, setChatListRefreshKey] = useState(0);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const _handleResize = () => {
|
const _handleResize = () => {
|
||||||
|
|
@ -255,27 +254,6 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
|
||||||
workspace.loadWorkflow(wfId);
|
workspace.loadWorkflow(wfId);
|
||||||
};
|
};
|
||||||
|
|
||||||
const sidebarHeaderBtnStyle: React.CSSProperties = {
|
|
||||||
background: 'none',
|
|
||||||
border: 'none',
|
|
||||||
cursor: 'pointer',
|
|
||||||
fontSize: 14,
|
|
||||||
color: '#888',
|
|
||||||
};
|
|
||||||
|
|
||||||
const createChatBtnStyle: React.CSSProperties = {
|
|
||||||
...sidebarHeaderBtnStyle,
|
|
||||||
fontSize: 20,
|
|
||||||
fontWeight: 700,
|
|
||||||
lineHeight: 1,
|
|
||||||
color: 'var(--text-secondary, #555)',
|
|
||||||
};
|
|
||||||
|
|
||||||
const _handleCreateNewChat = useCallback(() => {
|
|
||||||
workspace.resetToNew();
|
|
||||||
setChatListRefreshKey(k => k + 1);
|
|
||||||
}, [workspace]);
|
|
||||||
|
|
||||||
const tabButtonStyle = (active: boolean): React.CSSProperties => ({
|
const tabButtonStyle = (active: boolean): React.CSSProperties => ({
|
||||||
flex: 1,
|
flex: 1,
|
||||||
padding: '6px 0',
|
padding: '6px 0',
|
||||||
|
|
@ -378,7 +356,7 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
|
||||||
onTabChange={setUdbTab}
|
onTabChange={setUdbTab}
|
||||||
onSelectChat={_handleConversationSelect}
|
onSelectChat={_handleConversationSelect}
|
||||||
activeWorkflowId={workspace.workflowId ?? undefined}
|
activeWorkflowId={workspace.workflowId ?? undefined}
|
||||||
chatListRefreshKey={chatListRefreshKey}
|
onCreateNewChat={workspace.resetToNew}
|
||||||
onRenameChat={_handleRenameChat}
|
onRenameChat={_handleRenameChat}
|
||||||
onDeleteChat={_handleDeleteChat}
|
onDeleteChat={_handleDeleteChat}
|
||||||
onFileSelect={_handleFileSelect}
|
onFileSelect={_handleFileSelect}
|
||||||
|
|
@ -430,10 +408,7 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
|
||||||
}}>
|
}}>
|
||||||
<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 }}>{t('Workspace')}</span>
|
<span style={{ fontWeight: 600, fontSize: 14 }}>{t('Workspace')}</span>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
<button onClick={() => setLeftCollapsed(true)} style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 14, color: '#888' }}>◀</button>
|
||||||
<button onClick={_handleCreateNewChat} style={createChatBtnStyle} title={t('Neuer Chat')}>+</button>
|
|
||||||
<button onClick={() => setLeftCollapsed(true)} style={sidebarHeaderBtnStyle}>◀</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{_leftPanelBody}
|
{_leftPanelBody}
|
||||||
</aside>
|
</aside>
|
||||||
|
|
@ -629,10 +604,7 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
|
||||||
>
|
>
|
||||||
<div style={{ padding: '10px 12px', borderBottom: '1px solid var(--border-color, #e0e0e0)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
<div style={{ padding: '10px 12px', borderBottom: '1px solid var(--border-color, #e0e0e0)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
<span style={{ fontWeight: 600, fontSize: 14 }}>{t('Workspace')}</span>
|
<span style={{ fontWeight: 600, fontSize: 14 }}>{t('Workspace')}</span>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
<button onClick={() => setMobileLeftOpen(false)} style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 18, color: '#666' }}>×</button>
|
||||||
<button onClick={_handleCreateNewChat} style={createChatBtnStyle} title={t('Neuer Chat')}>+</button>
|
|
||||||
<button onClick={() => setMobileLeftOpen(false)} style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 18, color: '#666' }}>×</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{_leftPanelBody}
|
{_leftPanelBody}
|
||||||
</aside>
|
</aside>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue