Merge remote-tracking branch 'origin/int'
This commit is contained in:
commit
e727996a18
20 changed files with 767 additions and 156 deletions
|
|
@ -36,6 +36,7 @@ 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> {
|
||||||
|
|
@ -109,6 +110,7 @@ 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,6 +158,7 @@ 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);
|
||||||
|
|
||||||
|
|
@ -598,8 +599,22 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (loading || nodeTypes.length === 0) return;
|
if (loading || nodeTypes.length === 0) return;
|
||||||
if (currentWorkflowId || initialWorkflowId) return;
|
if (currentWorkflowId || initialWorkflowId) {
|
||||||
if (canvasNodes.length > 0) return;
|
didBootstrapEmptyCanvasRef.current = false;
|
||||||
|
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,
|
||||||
});
|
});
|
||||||
|
|
@ -609,8 +624,9 @@ 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,6 +20,8 @@ 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;
|
||||||
|
|
@ -842,6 +844,8 @@ 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?.();
|
||||||
|
|
@ -1019,12 +1023,19 @@ export const FlowCanvas = forwardRef<FlowCanvasHandle, FlowCanvasProps>(function
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const lastEmittedSelectionRef = useRef<{ nodeId: string | null; signature: string | null }>({
|
||||||
|
nodeId: null,
|
||||||
|
signature: null,
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (onSelectionChange) {
|
const node = selectedNodeId ? nodes.find((n) => n.id === selectedNodeId) ?? null : null;
|
||||||
const node = selectedNodeId ? nodes.find((n) => n.id === selectedNodeId) ?? null : null;
|
const signature = node ? JSON.stringify(node) : null;
|
||||||
onSelectionChange(node);
|
const last = lastEmittedSelectionRef.current;
|
||||||
}
|
if (last.nodeId === selectedNodeId && last.signature === signature) return;
|
||||||
}, [selectedNodeId, nodes, onSelectionChange]);
|
lastEmittedSelectionRef.current = { nodeId: selectedNodeId, signature };
|
||||||
|
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();
|
||||||
|
|
@ -1088,6 +1099,11 @@ 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([
|
||||||
|
|
@ -1113,16 +1129,35 @@ 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) return;
|
if (!raw || !containerRef.current) {
|
||||||
|
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 (_) {}
|
} catch (error) {
|
||||||
|
console.debug(`${LOG} drop parse failed`, {
|
||||||
|
raw,
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[onDropNodeType, onExternalDrop, panOffset, zoom, emitHistoryCheckpoint]
|
[onDropNodeType, onExternalDrop, panOffset, zoom, emitHistoryCheckpoint]
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ 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';
|
||||||
|
|
@ -253,6 +254,7 @@ 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;
|
||||||
|
|
||||||
|
|
@ -378,6 +380,15 @@ 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.');
|
||||||
|
|
@ -483,11 +494,71 @@ export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({ node,
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{extractContentAccordionItems !== null ? (
|
{extractContentAccordionItems !== null ? (
|
||||||
<AccordionList<string>
|
<>
|
||||||
key={`${node.id}-extract-accordion`}
|
{extractContentContextParam ? (
|
||||||
defaultOpenId={null}
|
<div
|
||||||
items={extractContentAccordionItems}
|
key={`${node.id}-${extractContentContextParam.name}`}
|
||||||
/>
|
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,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,69 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
2
src/components/FormGenerator/FilterSearchInput/index.ts
Normal file
2
src/components/FormGenerator/FilterSearchInput/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
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 12px;
|
padding: 8px 28px 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,6 +2,7 @@ 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";
|
||||||
|
|
@ -189,14 +190,15 @@ export function FormGeneratorControls({
|
||||||
<div className={styles.searchContainer}>
|
<div className={styles.searchContainer}>
|
||||||
{searchable && (
|
{searchable && (
|
||||||
<div className={styles.floatingLabelInput}>
|
<div className={styles.floatingLabelInput}>
|
||||||
<input
|
<FilterSearchInput
|
||||||
type="text"
|
variant="inherit"
|
||||||
placeholder=" "
|
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => onSearchChange(e.target.value)}
|
onChange={onSearchChange}
|
||||||
|
placeholder=" "
|
||||||
onFocus={() => onSearchFocus(true)}
|
onFocus={() => onSearchFocus(true)}
|
||||||
onBlur={() => onSearchFocus(false)}
|
onBlur={() => onSearchFocus(false)}
|
||||||
className={`${styles.searchInput} ${searchFocused || searchTerm ? styles.focused : ''}`}
|
inputClassName={`${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,6 +69,7 @@ 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,
|
||||||
|
|
@ -446,22 +447,11 @@ 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)' }}>
|
||||||
<input
|
<FilterSearchInput
|
||||||
ref={searchInputRef}
|
inputRef={searchInputRef}
|
||||||
type="text"
|
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => { setSearchTerm(e.target.value); setDisplayCount(_FILTER_PAGE_SIZE); }}
|
onChange={(value) => { setSearchTerm(value); setDisplayCount(_FILTER_PAGE_SIZE); }}
|
||||||
placeholder="Filter..."
|
onInputClick={(e) => e.stopPropagation()}
|
||||||
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,7 +1,6 @@
|
||||||
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;
|
||||||
|
|
@ -137,7 +136,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 } });
|
const filesRes = await api.get('/api/files/list', { params: { pagination: paginationParam, owner } });
|
||||||
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 : [])
|
||||||
|
|
@ -193,7 +192,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 },
|
params: { pagination: paginationParam, owner },
|
||||||
});
|
});
|
||||||
const data = filesRes.data;
|
const data = filesRes.data;
|
||||||
let rawFiles: FileData[] = [];
|
let rawFiles: FileData[] = [];
|
||||||
|
|
@ -203,10 +202,6 @@ 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;
|
||||||
onCreateNew?: () => void;
|
chatListRefreshKey?: number;
|
||||||
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,
|
||||||
onCreateNew,
|
chatListRefreshKey,
|
||||||
onRenameChat,
|
onRenameChat,
|
||||||
onDeleteChat,
|
onDeleteChat,
|
||||||
}) => {
|
}) => {
|
||||||
|
|
@ -82,13 +82,14 @@ 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 [loading, setLoading] = useState(true);
|
const [hasLoadedOnce, setHasLoadedOnce] = useState(false);
|
||||||
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;
|
||||||
|
|
@ -140,7 +141,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 {
|
||||||
setLoading(false);
|
setHasLoadedOnce(true);
|
||||||
}
|
}
|
||||||
}, [context.instanceId, t]);
|
}, [context.instanceId, t]);
|
||||||
|
|
||||||
|
|
@ -163,6 +164,12 @@ 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();
|
||||||
|
|
@ -188,8 +195,18 @@ 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;
|
||||||
await onRenameChat(chatId, trimmed);
|
const prev = groupsRef.current;
|
||||||
_loadChats();
|
setGroups(gs => gs.map(g => ({
|
||||||
|
...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) => {
|
||||||
|
|
@ -201,23 +218,41 @@ 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, _loadChats]);
|
}, [context.instanceId, _setChatStatus]);
|
||||||
|
|
||||||
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, _loadChats]);
|
}, [context.instanceId, _setChatStatus]);
|
||||||
|
|
||||||
const _isArchived = (chat: ChatItem) => chat.status === 'archived';
|
const _isArchived = (chat: ChatItem) => chat.status === 'archived';
|
||||||
|
|
||||||
|
|
@ -311,7 +346,17 @@ const ChatsTab: React.FC<ChatsTabProps> = ({ context,
|
||||||
{onDeleteChat && (
|
{onDeleteChat && (
|
||||||
<button
|
<button
|
||||||
className={`${styles.actionBtn} ${styles.actionBtnDanger}`}
|
className={`${styles.actionBtn} ${styles.actionBtnDanger}`}
|
||||||
onClick={async (e) => { e.stopPropagation(); await onDeleteChat(chat.id); _loadChats(); }}
|
onClick={async (e) => {
|
||||||
|
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')}
|
||||||
>
|
>
|
||||||
🗑️
|
🗑️
|
||||||
|
|
@ -334,8 +379,6 @@ 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}>
|
||||||
|
|
@ -346,11 +389,6 @@ 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)}
|
||||||
|
|
@ -437,7 +475,7 @@ const ChatsTab: React.FC<ChatsTabProps> = ({ context,
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{_allChats.length === 0 && (
|
{hasLoadedOnce && _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,6 +81,60 @@
|
||||||
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,6 +28,8 @@ 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(), []);
|
||||||
|
|
@ -54,21 +56,41 @@ 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 {
|
||||||
for (const file of Array.from(fileList)) {
|
const files = 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 {
|
||||||
setUploading(false);
|
if (uploadRunIdRef.current === runId) {
|
||||||
|
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]);
|
||||||
|
|
||||||
|
|
@ -135,6 +157,10 @@ 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}
|
||||||
|
|
@ -170,10 +196,26 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect, onSendToChat
|
||||||
<button
|
<button
|
||||||
onClick={() => fileInputRef.current?.click()}
|
onClick={() => fileInputRef.current?.click()}
|
||||||
disabled={uploading}
|
disabled={uploading}
|
||||||
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, color: '#F25843' }}
|
className={styles.uploadCircleButton}
|
||||||
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}
|
||||||
onCreateNew={onCreateNewChat}
|
chatListRefreshKey={chatListRefreshKey}
|
||||||
onRenameChat={onRenameChat}
|
onRenameChat={onRenameChat}
|
||||||
onDeleteChat={onDeleteChat}
|
onDeleteChat={onDeleteChat}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,7 @@ 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
|
||||||
|
|
@ -150,6 +151,7 @@ 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
|
||||||
|
|
@ -164,7 +166,11 @@ 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: { mode: 'groupSummary', pagination: JSON.stringify(pObj) },
|
params: {
|
||||||
|
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 : [];
|
||||||
},
|
},
|
||||||
|
|
@ -192,7 +198,10 @@ 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: { pagination: JSON.stringify(pObj) },
|
params: {
|
||||||
|
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 };
|
||||||
|
|
@ -408,6 +417,7 @@ 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);
|
||||||
|
|
@ -564,9 +574,11 @@ 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 {
|
||||||
|
|
||||||
|
|
@ -593,7 +605,14 @@ 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
|
||||||
|
|
@ -625,6 +644,7 @@ export function useFileOperations() {
|
||||||
return { success: false, error: errorMessage };
|
return { success: false, error: errorMessage };
|
||||||
} finally {
|
} finally {
|
||||||
setUploadingFile(false);
|
setUploadingFile(false);
|
||||||
|
setUploadProgress(0);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -749,6 +769,7 @@ export function useFileOperations() {
|
||||||
deletingFiles,
|
deletingFiles,
|
||||||
editingFiles,
|
editingFiles,
|
||||||
uploadingFile,
|
uploadingFile,
|
||||||
|
uploadProgress,
|
||||||
downloadError,
|
downloadError,
|
||||||
deleteError,
|
deleteError,
|
||||||
uploadError,
|
uploadError,
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,17 @@ 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();
|
||||||
|
|
@ -67,14 +78,16 @@ 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);
|
||||||
|
|
@ -103,14 +116,24 @@ 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 || {}) };
|
||||||
if (viewMode === 'folder' && selectedFolderId) {
|
const normalizedFolderId = normalizeFolderFilterId(selectedFolderId);
|
||||||
nextFilters.folderId = selectedFolderId;
|
const rootSelected = isSyntheticRootFolderId(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, viewMode]);
|
}, [tableRefetch, selectedFolderId, selectedOwnership, viewMode]);
|
||||||
|
|
||||||
const fetchGroupSectionSummaries = useCallback(
|
const fetchGroupSectionSummaries = useCallback(
|
||||||
async (base: {
|
async (base: {
|
||||||
|
|
@ -122,12 +145,20 @@ export const FilesPage: React.FC = () => {
|
||||||
groupDirection?: 'asc' | 'desc';
|
groupDirection?: 'asc' | 'desc';
|
||||||
}) => {
|
}) => {
|
||||||
const filters = { ...(base.filters || {}) };
|
const filters = { ...(base.filters || {}) };
|
||||||
if (viewMode === 'folder' && selectedFolderId) {
|
const normalizedFolderId = normalizeFolderFilterId(selectedFolderId);
|
||||||
filters.folderId = selectedFolderId;
|
const rootSelected = isSyntheticRootFolderId(selectedFolderId);
|
||||||
|
if (viewMode === 'folder' && selectedFolderId && !rootSelected) {
|
||||||
|
filters.folderId = normalizedFolderId;
|
||||||
}
|
}
|
||||||
return fetchGroupSectionSummariesFromHook({ ...base, filters });
|
const owner: FileOwnerScope =
|
||||||
|
selectedOwnership === 'own'
|
||||||
|
? 'me'
|
||||||
|
: selectedOwnership === 'shared'
|
||||||
|
? 'shared'
|
||||||
|
: 'all';
|
||||||
|
return fetchGroupSectionSummariesFromHook({ ...base, filters, owner });
|
||||||
},
|
},
|
||||||
[fetchGroupSectionSummariesFromHook, viewMode, selectedFolderId],
|
[fetchGroupSectionSummariesFromHook, viewMode, selectedFolderId, selectedOwnership],
|
||||||
);
|
);
|
||||||
|
|
||||||
const refetchForSection = useCallback(
|
const refetchForSection = useCallback(
|
||||||
|
|
@ -137,12 +168,20 @@ export const FilesPage: React.FC = () => {
|
||||||
parentColumnFilters?: Record<string, unknown>,
|
parentColumnFilters?: Record<string, unknown>,
|
||||||
) => {
|
) => {
|
||||||
const merged = { ...(parentColumnFilters || {}) };
|
const merged = { ...(parentColumnFilters || {}) };
|
||||||
if (viewMode === 'folder' && selectedFolderId) {
|
const normalizedFolderId = normalizeFolderFilterId(selectedFolderId);
|
||||||
merged.folderId = selectedFolderId;
|
const rootSelected = isSyntheticRootFolderId(selectedFolderId);
|
||||||
|
if (viewMode === 'folder' && selectedFolderId && !rootSelected) {
|
||||||
|
merged.folderId = normalizedFolderId;
|
||||||
}
|
}
|
||||||
return refetchForSectionFromHook(paginationParams, sectionFilter, merged);
|
const owner: FileOwnerScope =
|
||||||
|
selectedOwnership === 'own'
|
||||||
|
? 'me'
|
||||||
|
: selectedOwnership === 'shared'
|
||||||
|
? 'shared'
|
||||||
|
: 'all';
|
||||||
|
return refetchForSectionFromHook({ ...paginationParams, owner }, sectionFilter, merged);
|
||||||
},
|
},
|
||||||
[refetchForSectionFromHook, viewMode, selectedFolderId],
|
[refetchForSectionFromHook, viewMode, selectedFolderId, selectedOwnership],
|
||||||
);
|
);
|
||||||
|
|
||||||
const _refreshAll = useCallback(async () => {
|
const _refreshAll = useCallback(async () => {
|
||||||
|
|
@ -152,14 +191,15 @@ export const FilesPage: React.FC = () => {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
_tableRefetch({ page: 1, pageSize: 25 });
|
_tableRefetch({ page: 1, pageSize: 25 });
|
||||||
}, [selectedFolderId, viewMode, _tableRefetch]);
|
}, [selectedFolderId, selectedOwnership, 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);
|
setSelectedFolderId(node.parentId ?? null);
|
||||||
setHighlightedFileId(node.id);
|
setHighlightedFileId(node.id);
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
const row = document.querySelector('tr[data-highlighted="true"]');
|
const row = document.querySelector('tr[data-highlighted="true"]');
|
||||||
|
|
@ -264,24 +304,38 @@ 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) {
|
||||||
let successCount = 0;
|
setIsUploadingBatch(true);
|
||||||
let errorCount = 0;
|
setUploadProgressPercent(0);
|
||||||
for (const file of Array.from(picked)) {
|
try {
|
||||||
const result = await handleFileUpload(file);
|
let successCount = 0;
|
||||||
if (result?.success) successCount++; else errorCount++;
|
let errorCount = 0;
|
||||||
}
|
const files = Array.from(picked);
|
||||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
const totalFiles = files.length;
|
||||||
await _tableRefetch();
|
for (const [index, file] of files.entries()) {
|
||||||
setTreeKey(k => k + 1);
|
const result = await handleFileUpload(file, undefined, undefined, fileProgress => {
|
||||||
if (successCount > 0) {
|
const baseProgress = (index / totalFiles) * 100;
|
||||||
showSuccess(
|
const scaledFileProgress = fileProgress / totalFiles;
|
||||||
t('Upload erfolgreich'),
|
setUploadProgressPercent(Math.min(100, Math.round(baseProgress + scaledFileProgress)));
|
||||||
errorCount > 0
|
});
|
||||||
? t('{successCount} Datei(en) hochgeladen, {errorCount} fehlgeschlagen', { successCount, errorCount })
|
if (result?.success) successCount++; else errorCount++;
|
||||||
: t('{successCount} Datei(en) hochgeladen', { successCount }),
|
}
|
||||||
);
|
setUploadProgressPercent(100);
|
||||||
} else if (errorCount > 0) {
|
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||||
showError(t('Upload fehlgeschlagen'), t('{errorCount} Datei(en) konnten nicht hochgeladen werden', { errorCount }));
|
await _tableRefetch();
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -436,8 +490,39 @@ export const FilesPage: React.FC = () => {
|
||||||
<div style={{ flex: 1 }} />
|
<div style={{ flex: 1 }} />
|
||||||
|
|
||||||
{canCreate && (
|
{canCreate && (
|
||||||
<button className={styles.primaryButton} onClick={handleUploadClick} disabled={uploadingFile}>
|
<button
|
||||||
<FaUpload /> {uploadingFile ? t('Wird hochgeladen...') : t('Datei hochladen')}
|
className={styles.primaryButton}
|
||||||
|
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,6 +851,52 @@
|
||||||
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,6 +25,7 @@ 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.
|
||||||
|
|
@ -54,6 +55,8 @@ 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;
|
||||||
|
|
@ -746,6 +749,64 @@ 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' }}>
|
||||||
|
|
@ -1154,63 +1215,69 @@ 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}>
|
||||||
<h4 className={styles.panelTitle}>
|
<div className={styles.panelTitleBar}>
|
||||||
{t('Transkript ({count} Segmente)', { count: transcripts.length })}
|
<h4 className={styles.panelTitle}>
|
||||||
</h4>
|
{t('Transkript ({count} Segmente)', { count: transcripts.length })}
|
||||||
|
</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}>
|
||||||
{transcripts.map((seg) => (
|
{_renderTranscriptList(transcriptEndRef)}
|
||||||
<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}>
|
||||||
<h4 className={styles.panelTitle}>Bot-Antworten ({botResponses.length})</h4>
|
<div className={styles.panelTitleBar}>
|
||||||
|
<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}>
|
||||||
{botResponses.map((r) => (
|
{_renderBotResponsesList()}
|
||||||
<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,6 +94,7 @@ 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 = () => {
|
||||||
|
|
@ -254,6 +255,27 @@ 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',
|
||||||
|
|
@ -356,7 +378,7 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
|
||||||
onTabChange={setUdbTab}
|
onTabChange={setUdbTab}
|
||||||
onSelectChat={_handleConversationSelect}
|
onSelectChat={_handleConversationSelect}
|
||||||
activeWorkflowId={workspace.workflowId ?? undefined}
|
activeWorkflowId={workspace.workflowId ?? undefined}
|
||||||
onCreateNewChat={workspace.resetToNew}
|
chatListRefreshKey={chatListRefreshKey}
|
||||||
onRenameChat={_handleRenameChat}
|
onRenameChat={_handleRenameChat}
|
||||||
onDeleteChat={_handleDeleteChat}
|
onDeleteChat={_handleDeleteChat}
|
||||||
onFileSelect={_handleFileSelect}
|
onFileSelect={_handleFileSelect}
|
||||||
|
|
@ -408,7 +430,10 @@ 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>
|
||||||
<button onClick={() => setLeftCollapsed(true)} style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 14, color: '#888' }}>◀</button>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||||
|
<button onClick={_handleCreateNewChat} style={createChatBtnStyle} title={t('Neuer Chat')}>+</button>
|
||||||
|
<button onClick={() => setLeftCollapsed(true)} style={sidebarHeaderBtnStyle}>◀</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{_leftPanelBody}
|
{_leftPanelBody}
|
||||||
</aside>
|
</aside>
|
||||||
|
|
@ -604,7 +629,10 @@ 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>
|
||||||
<button onClick={() => setMobileLeftOpen(false)} style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 18, color: '#666' }}>×</button>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||||
|
<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