You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
494 lines
20 KiB
494 lines
20 KiB
"use client";
|
|
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import { CloseOutlined, PictureOutlined, PlusOutlined } from "@ant-design/icons";
|
|
import { useWorkflowStore } from "@/store/workflowStore";
|
|
import { NodeType } from "@/types";
|
|
import { useReactFlow } from "@xyflow/react";
|
|
import { useI18n } from "@/i18n";
|
|
import { buildDefaultGenerationNodeData } from "@/utils/defaultGenerationNodeModel";
|
|
import { GenerateNodeMenu } from "./GenerateNodeMenu";
|
|
import { ModelSearchDialog } from "./modals/ModelSearchDialog";
|
|
import { AssetPickerModal, PopiAssetItem } from "./AssetPickerModal";
|
|
import { getPopiImageAssetPreview, resolvePopiImageAsset } from "@/lib/popiAssetApi";
|
|
import { useFTUXStore, TutorialStep } from "@/store/ftuxStore";
|
|
|
|
// Get the center of the React Flow pane in screen coordinates
|
|
function getPaneCenter() {
|
|
const pane = document.querySelector(".react-flow");
|
|
if (pane) {
|
|
const rect = pane.getBoundingClientRect();
|
|
return {
|
|
x: rect.left + rect.width / 2,
|
|
y: rect.top + rect.height / 2,
|
|
};
|
|
}
|
|
return { x: window.innerWidth / 2, y: window.innerHeight / 2 };
|
|
}
|
|
|
|
function GenerateComboButton() {
|
|
const { t } = useI18n();
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const menuRef = useRef<HTMLDivElement>(null);
|
|
const addNode = useWorkflowStore((state) => state.addNode);
|
|
const selectSingleNode = useWorkflowStore((state) => state.selectSingleNode);
|
|
const { screenToFlowPosition } = useReactFlow();
|
|
|
|
useEffect(() => {
|
|
const handleClickOutside = (event: MouseEvent) => {
|
|
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
|
setIsOpen(false);
|
|
}
|
|
};
|
|
|
|
if (isOpen) {
|
|
document.addEventListener("mousedown", handleClickOutside);
|
|
}
|
|
|
|
return () => {
|
|
document.removeEventListener("mousedown", handleClickOutside);
|
|
};
|
|
}, [isOpen]);
|
|
|
|
const handleAddNode = async (type: NodeType) => {
|
|
const center = getPaneCenter();
|
|
const position = screenToFlowPosition({
|
|
x: center.x + Math.random() * 100 - 50,
|
|
y: center.y + Math.random() * 100 - 50,
|
|
});
|
|
|
|
const nodeId = addNode(type, position, await buildDefaultGenerationNodeData(type));
|
|
selectSingleNode(nodeId);
|
|
setIsOpen(false);
|
|
};
|
|
|
|
const handleDragStart = (event: React.DragEvent, type: NodeType) => {
|
|
event.dataTransfer.setData("application/node-type", type);
|
|
event.dataTransfer.effectAllowed = "copy";
|
|
setIsOpen(false);
|
|
};
|
|
|
|
return (
|
|
<div className="relative" ref={menuRef}>
|
|
<button
|
|
onClick={() => setIsOpen(!isOpen)}
|
|
title={t("toolbar.addNode")}
|
|
aria-label={t("toolbar.addNode")}
|
|
aria-expanded={isOpen}
|
|
className="flex h-8 w-8 items-center justify-center rounded-lg bg-neutral-200 text-neutral-950 shadow-sm transition-colors hover:bg-neutral-100"
|
|
>
|
|
<span className={`text-[18px] leading-none transition-transform duration-200 ${isOpen ? "rotate-45" : "rotate-0"}`}>
|
|
<PlusOutlined />
|
|
</span>
|
|
<span className="sr-only">{t("toolbar.addNode")}</span>
|
|
</button>
|
|
|
|
{isOpen && (
|
|
<GenerateNodeMenu
|
|
draggable
|
|
onSelect={handleAddNode}
|
|
onDragStart={handleDragStart}
|
|
className="absolute left-full top-0 ml-2 min-w-[140px] overflow-visible outline-none"
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ResourceImageButton() {
|
|
const { t } = useI18n();
|
|
const [isAssetPickerOpen, setIsAssetPickerOpen] = useState(false);
|
|
const addNode = useWorkflowStore((state) => state.addNode);
|
|
const updateNodeData = useWorkflowStore((state) => state.updateNodeData);
|
|
const selectSingleNode = useWorkflowStore((state) => state.selectSingleNode);
|
|
const { screenToFlowPosition } = useReactFlow();
|
|
|
|
const handleSelectAsset = (asset: PopiAssetItem) => {
|
|
const preview = getPopiImageAssetPreview(asset);
|
|
if (!preview) return;
|
|
|
|
const center = getPaneCenter();
|
|
const position = screenToFlowPosition({
|
|
x: center.x + Math.random() * 100 - 50,
|
|
y: center.y + Math.random() * 100 - 50,
|
|
});
|
|
|
|
const nodeId = addNode("imageInput", position, {
|
|
image: preview.imageUrl || preview.previewImageUrl,
|
|
imageRef: undefined,
|
|
assetId: preview.assetId,
|
|
previewImage: preview.previewImageUrl,
|
|
assetDetailLoading: true,
|
|
assetDetailError: null,
|
|
filename: preview.filename,
|
|
dimensions: preview.dimensions,
|
|
});
|
|
|
|
selectSingleNode(nodeId);
|
|
setIsAssetPickerOpen(false);
|
|
|
|
const applyResolvedAsset = (
|
|
resolved: NonNullable<Awaited<ReturnType<typeof resolvePopiImageAsset>>>,
|
|
dimensions: { width: number; height: number } | null
|
|
) => {
|
|
updateNodeData(nodeId, {
|
|
image: resolved.imageUrl || resolved.previewImageUrl,
|
|
imageRef: undefined,
|
|
assetId: resolved.assetId,
|
|
previewImage: resolved.previewImageUrl,
|
|
assetDetailLoading: false,
|
|
assetDetailError: null,
|
|
filename: resolved.filename,
|
|
dimensions,
|
|
});
|
|
};
|
|
|
|
void resolvePopiImageAsset(asset)
|
|
.then((resolved) => {
|
|
if (!resolved) {
|
|
updateNodeData(nodeId, {
|
|
assetDetailLoading: false,
|
|
assetDetailError: null,
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (resolved.dimensions || typeof Image === "undefined") {
|
|
applyResolvedAsset(resolved, resolved.dimensions);
|
|
return;
|
|
}
|
|
|
|
const image = new Image();
|
|
image.onload = () => applyResolvedAsset(resolved, { width: image.width, height: image.height });
|
|
image.onerror = () => applyResolvedAsset(resolved, preview.dimensions);
|
|
image.src = resolved.previewImageUrl;
|
|
})
|
|
.catch((error: unknown) => {
|
|
updateNodeData(nodeId, {
|
|
assetDetailLoading: false,
|
|
assetDetailError: error instanceof Error ? error.message : "Failed to load asset detail",
|
|
});
|
|
});
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<button
|
|
onClick={() => setIsAssetPickerOpen(true)}
|
|
title={t("imageInput.fromAssets")}
|
|
aria-label={t("imageInput.fromAssets")}
|
|
className="flex h-8 w-8 items-center justify-center rounded-lg text-neutral-300 transition-colors hover:bg-neutral-700 hover:text-neutral-100"
|
|
>
|
|
<PictureOutlined className="text-[18px]" />
|
|
<span className="sr-only">{t("imageInput.fromAssets")}</span>
|
|
</button>
|
|
|
|
{isAssetPickerOpen && (
|
|
<AssetPickerModal
|
|
kind="image"
|
|
onClose={() => setIsAssetPickerOpen(false)}
|
|
onSelect={handleSelectAsset}
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
export function FloatingActionBar() {
|
|
const { t } = useI18n();
|
|
const nodes = useWorkflowStore((state) => state.nodes) ?? [];
|
|
const isRunning = useWorkflowStore((state) => state.isRunning) ?? false;
|
|
const currentNodeIds = useWorkflowStore((state) => state.currentNodeIds) ?? [];
|
|
const executeWorkflow = useWorkflowStore((state) => state.executeWorkflow) ?? (async () => {});
|
|
const regenerateNode = useWorkflowStore((state) => state.regenerateNode) ?? (async () => {});
|
|
const executeSelectedNodes = useWorkflowStore((state) => state.executeSelectedNodes) ?? (async () => {});
|
|
const stopWorkflow = useWorkflowStore((state) => state.stopWorkflow) ?? (() => {});
|
|
const mockTutorialExecution = useWorkflowStore((state) => state.mockTutorialExecution) ?? (async () => {});
|
|
const validateWorkflow = useWorkflowStore((state) => state.validateWorkflow);
|
|
const edgeStyle = useWorkflowStore((state) => state.edgeStyle);
|
|
const setEdgeStyle = useWorkflowStore((state) => state.setEdgeStyle);
|
|
const setModelSearchOpen = useWorkflowStore((state) => state.setModelSearchOpen);
|
|
const modelSearchOpen = useWorkflowStore((state) => state.modelSearchOpen);
|
|
const modelSearchProvider = useWorkflowStore((state) => state.modelSearchProvider);
|
|
const [tutorialActive, setTutorialActive] = useState(false);
|
|
const [currentTutorialStep, setCurrentTutorialStep] = useState(0);
|
|
const [tutorialSteps, setTutorialSteps] = useState<TutorialStep[]>([]);
|
|
const [runMenuOpen, setRunMenuOpen] = useState(false);
|
|
const runMenuRef = useRef<HTMLDivElement>(null);
|
|
const { valid, errors } = validateWorkflow?.() ?? { valid: true, errors: [] };
|
|
|
|
useEffect(() => {
|
|
const unsubscribe = useFTUXStore.subscribe((state) => {
|
|
setTutorialActive(state.tutorialActive);
|
|
setCurrentTutorialStep(state.currentTutorialStep);
|
|
setTutorialSteps(state.tutorialSteps);
|
|
});
|
|
|
|
const currentState = useFTUXStore.getState();
|
|
setTutorialActive(currentState.tutorialActive);
|
|
setCurrentTutorialStep(currentState.currentTutorialStep);
|
|
setTutorialSteps(currentState.tutorialSteps);
|
|
|
|
return unsubscribe;
|
|
}, []);
|
|
|
|
const selectedNodes = useMemo(() => nodes.filter((node) => node.selected), [nodes]);
|
|
const selectedNode = useMemo(() => selectedNodes.length === 1 ? selectedNodes[0] : null, [selectedNodes]);
|
|
|
|
const isRunOptionsTutorialStep = useMemo(() => {
|
|
if (!tutorialActive || tutorialSteps.length === 0) return false;
|
|
const currentStep = tutorialSteps[currentTutorialStep];
|
|
return currentStep?.id === "explain-run-options";
|
|
}, [currentTutorialStep, tutorialActive, tutorialSteps]);
|
|
|
|
useEffect(() => {
|
|
const handleClickOutside = (event: MouseEvent) => {
|
|
if (runMenuRef.current && !runMenuRef.current.contains(event.target as Node) && !isRunOptionsTutorialStep) {
|
|
setRunMenuOpen(false);
|
|
}
|
|
};
|
|
|
|
if (runMenuOpen) {
|
|
document.addEventListener("mousedown", handleClickOutside);
|
|
}
|
|
|
|
return () => {
|
|
document.removeEventListener("mousedown", handleClickOutside);
|
|
};
|
|
}, [isRunOptionsTutorialStep, runMenuOpen]);
|
|
|
|
useEffect(() => {
|
|
if (isRunOptionsTutorialStep) {
|
|
setRunMenuOpen(true);
|
|
}
|
|
}, [isRunOptionsTutorialStep]);
|
|
|
|
useEffect(() => {
|
|
if (!tutorialActive || tutorialSteps.length === 0) return;
|
|
const currentStep = tutorialSteps[currentTutorialStep];
|
|
if (
|
|
currentStep?.id === "run-workflow" ||
|
|
currentStep?.id === "demonstrate-downstream" ||
|
|
currentStep?.id === "demonstrate-complete"
|
|
) {
|
|
setRunMenuOpen(false);
|
|
}
|
|
}, [currentTutorialStep, tutorialActive, tutorialSteps]);
|
|
|
|
const runningNodeCount = currentNodeIds.length;
|
|
const getRunningLabel = useCallback(() => {
|
|
if (runningNodeCount === 0) return t("toolbar.running");
|
|
if (runningNodeCount === 1) {
|
|
const node = nodes.find((item) => item.id === currentNodeIds[0]);
|
|
const nodeName = node?.data?.customTitle || node?.type || "node";
|
|
return t("toolbar.runningNode", { node: nodeName });
|
|
}
|
|
return t("toolbar.runningNodes", { count: runningNodeCount });
|
|
}, [currentNodeIds, nodes, runningNodeCount, t]);
|
|
|
|
const toggleEdgeStyle = () => {
|
|
setEdgeStyle(edgeStyle === "angular" ? "curved" : "angular");
|
|
};
|
|
|
|
const handleRunClick = useCallback(() => {
|
|
const ftuxState = useFTUXStore.getState();
|
|
const currentStep = ftuxState.tutorialSteps[ftuxState.currentTutorialStep];
|
|
|
|
if (isRunning) {
|
|
stopWorkflow();
|
|
} else if (ftuxState.tutorialActive && currentStep?.id === "run-workflow") {
|
|
void mockTutorialExecution();
|
|
} else {
|
|
void executeWorkflow();
|
|
}
|
|
}, [executeWorkflow, isRunning, mockTutorialExecution, stopWorkflow]);
|
|
|
|
const handleRunFromSelected = useCallback(() => {
|
|
if (!selectedNode) return;
|
|
void executeWorkflow(selectedNode.id);
|
|
setRunMenuOpen(false);
|
|
}, [executeWorkflow, selectedNode]);
|
|
|
|
const handleRunSelectedOnly = useCallback(() => {
|
|
if (!selectedNode) return;
|
|
void regenerateNode(selectedNode.id);
|
|
setRunMenuOpen(false);
|
|
}, [regenerateNode, selectedNode]);
|
|
|
|
const handleRunSelectedNodes = useCallback(() => {
|
|
if (selectedNodes.length === 0) return;
|
|
void executeSelectedNodes(selectedNodes.map((node) => node.id));
|
|
setRunMenuOpen(false);
|
|
}, [executeSelectedNodes, selectedNodes]);
|
|
|
|
return (
|
|
<div className="fixed left-5 top-1/2 z-50 -translate-y-1/2">
|
|
<div className="flex flex-col items-center gap-2 bg-neutral-800/95 rounded-2xl shadow-lg border border-neutral-700/80 px-1.5 py-1.5">
|
|
<GenerateComboButton />
|
|
<ResourceImageButton />
|
|
|
|
<button
|
|
onClick={() => setModelSearchOpen(true)}
|
|
title={t("toolbar.browseModels")}
|
|
aria-label={t("toolbar.allModels")}
|
|
className="flex h-8 w-8 items-center justify-center rounded-lg text-neutral-300 transition-colors hover:bg-neutral-700 hover:text-neutral-100"
|
|
>
|
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M4 7h16M4 12h16M4 17h16" />
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M8 4v16M16 4v16" />
|
|
</svg>
|
|
<span className="sr-only">{t("toolbar.allModels")}</span>
|
|
</button>
|
|
|
|
<button
|
|
onClick={toggleEdgeStyle}
|
|
title={t("toolbar.switchConnectors", { style: edgeStyle === "angular" ? t("toolbar.curved") : t("toolbar.angular") })}
|
|
className="flex h-8 w-8 items-center justify-center rounded-lg text-neutral-300 transition-colors hover:bg-neutral-700 hover:text-neutral-100"
|
|
>
|
|
{edgeStyle === "angular" ? (
|
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M4 12h4l4-8 4 8h4" />
|
|
</svg>
|
|
) : (
|
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M4 12c0 0 4-8 8-8s8 8 8 8" />
|
|
</svg>
|
|
)}
|
|
</button>
|
|
|
|
<div className="relative flex items-center" ref={runMenuRef}>
|
|
<button
|
|
onClick={handleRunClick}
|
|
disabled={!valid && !isRunning}
|
|
title={!valid ? errors.join("\n") : isRunning ? t("toolbar.stop") : t("toolbar.run")}
|
|
data-tutorial="floating-run-button"
|
|
className={`flex h-8 w-8 items-center justify-center rounded-lg transition-colors ${
|
|
isRunning
|
|
? "bg-neutral-100 text-neutral-950 hover:bg-neutral-200"
|
|
: valid
|
|
? "bg-neutral-100 text-neutral-950 hover:bg-neutral-200"
|
|
: "cursor-not-allowed bg-neutral-700 text-neutral-500"
|
|
}`}
|
|
>
|
|
{isRunning ? (
|
|
<>
|
|
<svg className="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="3" />
|
|
<path
|
|
className="opacity-75"
|
|
fill="currentColor"
|
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
|
/>
|
|
</svg>
|
|
<span className="sr-only" title={getRunningLabel()}>
|
|
{runningNodeCount > 1 ? t("toolbar.nodesCount", { count: runningNodeCount }) : t("toolbar.stop")}
|
|
</span>
|
|
</>
|
|
) : (
|
|
<>
|
|
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M8 5v14l11-7z" />
|
|
</svg>
|
|
<span className="sr-only">{t("toolbar.run")}</span>
|
|
</>
|
|
)}
|
|
</button>
|
|
|
|
{!isRunning && valid && (
|
|
<button
|
|
onClick={() => setRunMenuOpen(!runMenuOpen)}
|
|
data-tutorial="floating-run-dropdown"
|
|
className="absolute -bottom-1 -right-1 flex h-4 w-4 items-center justify-center rounded-full border border-neutral-600 bg-neutral-700 text-neutral-200 transition-colors hover:bg-neutral-600"
|
|
title={t("toolbar.runOptions")}
|
|
aria-label={t("toolbar.runOptions")}
|
|
>
|
|
<svg
|
|
className={`h-2.5 w-2.5 transition-transform ${runMenuOpen ? "rotate-180" : ""}`}
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
strokeWidth={2.5}
|
|
>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
|
</svg>
|
|
</button>
|
|
)}
|
|
|
|
{runMenuOpen && !isRunning && (
|
|
<div
|
|
data-tutorial="floating-run-menu"
|
|
className="absolute bottom-0 left-full ml-2 min-w-[180px] overflow-hidden rounded-lg border border-neutral-700 bg-neutral-800 shadow-xl"
|
|
>
|
|
<button
|
|
onClick={() => {
|
|
void executeWorkflow();
|
|
setRunMenuOpen(false);
|
|
}}
|
|
className="flex w-full items-center gap-2 px-3 py-2 text-left text-[11px] font-medium text-neutral-300 transition-colors hover:bg-neutral-700 hover:text-neutral-100"
|
|
>
|
|
<svg className="h-3.5 w-3.5" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M8 5v14l11-7z" />
|
|
</svg>
|
|
{t("toolbar.runEntire")}
|
|
</button>
|
|
<button
|
|
onClick={handleRunFromSelected}
|
|
disabled={!selectedNode}
|
|
title={!selectedNode ? t("toolbar.selectSingleNode") : undefined}
|
|
className={`flex w-full items-center gap-2 px-3 py-2 text-left text-[11px] font-medium transition-colors ${
|
|
selectedNode
|
|
? "text-neutral-300 hover:bg-neutral-700 hover:text-neutral-100"
|
|
: "cursor-not-allowed text-neutral-500"
|
|
}`}
|
|
>
|
|
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M13 5l7 7-7 7M5 5l7 7-7 7" />
|
|
</svg>
|
|
{t("toolbar.runFromSelected")}
|
|
</button>
|
|
<button
|
|
onClick={handleRunSelectedOnly}
|
|
disabled={!selectedNode}
|
|
title={!selectedNode ? t("toolbar.selectSingleNode") : undefined}
|
|
className={`flex w-full items-center gap-2 px-3 py-2 text-left text-[11px] font-medium transition-colors ${
|
|
selectedNode
|
|
? "text-neutral-300 hover:bg-neutral-700 hover:text-neutral-100"
|
|
: "cursor-not-allowed text-neutral-500"
|
|
}`}
|
|
>
|
|
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 010 1.972l-11.54 6.347a1.125 1.125 0 01-1.667-.986V5.653z" />
|
|
</svg>
|
|
{t("toolbar.runSelectedOnly")}
|
|
</button>
|
|
<button
|
|
onClick={handleRunSelectedNodes}
|
|
disabled={selectedNodes.length === 0}
|
|
title={selectedNodes.length === 0 ? t("toolbar.selectOneOrMoreNodes") : t("toolbar.runSelectedNodesCount", { count: selectedNodes.length })}
|
|
className={`flex w-full items-center gap-2 px-3 py-2 text-left text-[11px] font-medium transition-colors ${
|
|
selectedNodes.length > 0
|
|
? "text-neutral-300 hover:bg-neutral-700 hover:text-neutral-100"
|
|
: "cursor-not-allowed text-neutral-500"
|
|
}`}
|
|
>
|
|
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 010 1.972l-11.54 6.347a1.125 1.125 0 01-1.667-.986V5.653z" />
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9.75 9.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 010 1.972l-11.54 6.347a1.125 1.125 0 01-1.667-.986V9.653z" />
|
|
</svg>
|
|
{selectedNodes.length > 0
|
|
? t("toolbar.runSelectedNodesCount", { count: selectedNodes.length })
|
|
: t("toolbar.runSelectedNodes")}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<ModelSearchDialog
|
|
isOpen={modelSearchOpen}
|
|
onClose={() => setModelSearchOpen(false)}
|
|
initialProvider={modelSearchProvider}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|