feat: 新增对前端用Zustand进行状态管理
This commit is contained in:
Generated
+31
-1
@@ -18,7 +18,8 @@
|
|||||||
"postcss": "^8.5.8",
|
"postcss": "^8.5.8",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"tailwindcss": "^4.2.2"
|
"tailwindcss": "^4.2.2",
|
||||||
|
"zustand": "^5.0.12"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.4",
|
"@eslint/js": "^9.39.4",
|
||||||
@@ -6816,6 +6817,35 @@
|
|||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"zod": "^3.25.0 || ^4.0.0"
|
"zod": "^3.25.0 || ^4.0.0"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"node_modules/zustand": {
|
||||||
|
"version": "5.0.12",
|
||||||
|
"resolved": "https://registry.npmmirror.com/zustand/-/zustand-5.0.12.tgz",
|
||||||
|
"integrity": "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.20.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": ">=18.0.0",
|
||||||
|
"immer": ">=9.0.6",
|
||||||
|
"react": ">=18.0.0",
|
||||||
|
"use-sync-external-store": ">=1.2.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"immer": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"use-sync-external-store": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,8 @@
|
|||||||
"postcss": "^8.5.8",
|
"postcss": "^8.5.8",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"tailwindcss": "^4.2.2"
|
"tailwindcss": "^4.2.2",
|
||||||
|
"zustand": "^5.0.12"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.4",
|
"@eslint/js": "^9.39.4",
|
||||||
|
|||||||
@@ -1,16 +1,15 @@
|
|||||||
import type { Node } from '../../types/graph';
|
import { useGraphStore } from '../../store';
|
||||||
|
|
||||||
interface NodePanelProps {
|
|
||||||
node: Node | null;
|
|
||||||
onClose?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 节点详情面板组件
|
* 节点详情面板组件
|
||||||
* 展示选中节点的详细信息,包括属性和样式
|
* 展示选中节点的详细信息,包括属性和样式
|
||||||
*/
|
*/
|
||||||
export const NodePanel = ({ node, onClose }: NodePanelProps) => {
|
export const NodePanel = () => {
|
||||||
if (!node) return null;
|
const { selectedNode, setSelectedNode } = useGraphStore();
|
||||||
|
|
||||||
|
if (!selectedNode) return null;
|
||||||
|
|
||||||
|
const node = selectedNode;
|
||||||
|
|
||||||
// 判断属性是否可展示
|
// 判断属性是否可展示
|
||||||
const hasProperties = node.properties && Object.keys(node.properties).length > 0;
|
const hasProperties = node.properties && Object.keys(node.properties).length > 0;
|
||||||
@@ -19,17 +18,15 @@ export const NodePanel = ({ node, onClose }: NodePanelProps) => {
|
|||||||
<div className="w-full overflow-y-auto">
|
<div className="w-full overflow-y-auto">
|
||||||
<div className="p-4 border-b border-gray-200 flex justify-between items-center">
|
<div className="p-4 border-b border-gray-200 flex justify-between items-center">
|
||||||
<h3 className="text-lg font-semibold text-gray-800">节点详情</h3>
|
<h3 className="text-lg font-semibold text-gray-800">节点详情</h3>
|
||||||
{onClose && (
|
<button
|
||||||
<button
|
onClick={() => setSelectedNode(null)}
|
||||||
onClick={onClose}
|
className="text-gray-400 hover:text-gray-600 transition-colors"
|
||||||
className="text-gray-400 hover:text-gray-600 transition-colors"
|
aria-label="关闭面板"
|
||||||
aria-label="关闭面板"
|
>
|
||||||
>
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
</svg>
|
||||||
</svg>
|
</button>
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-4 space-y-4">
|
<div className="p-4 space-y-4">
|
||||||
|
|||||||
@@ -1,24 +1,13 @@
|
|||||||
import { useState, type ChangeEvent, type KeyboardEvent } from 'react';
|
import { useState, type ChangeEvent, type KeyboardEvent } from 'react';
|
||||||
import type { SearchNode } from '../../types/graph';
|
import { useGraphStore } from '../../store';
|
||||||
import { SEARCH_RESULT_MAX_HEIGHT } from '../../config/constants';
|
import { SEARCH_RESULT_MAX_HEIGHT } from '../../config/constants';
|
||||||
|
|
||||||
interface SearchBarProps {
|
|
||||||
onSearch?: (query: string) => void;
|
|
||||||
onNodeSelect?: (nodeId: string) => void;
|
|
||||||
searchResults?: SearchNode[];
|
|
||||||
placeholder?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 搜索栏组件
|
* 搜索栏组件
|
||||||
* 支持实时搜索和结果展示
|
* 支持实时搜索和结果展示
|
||||||
*/
|
*/
|
||||||
export const SearchBar = ({
|
export const SearchBar = ({ placeholder = '搜索节点...' }: { placeholder?: string }) => {
|
||||||
onSearch,
|
const { handleSearch, handleNodeSelectById, searchResults } = useGraphStore();
|
||||||
onNodeSelect,
|
|
||||||
searchResults = [],
|
|
||||||
placeholder = '搜索节点...',
|
|
||||||
}: SearchBarProps) => {
|
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
const [showResults, setShowResults] = useState(false);
|
const [showResults, setShowResults] = useState(false);
|
||||||
|
|
||||||
@@ -26,8 +15,8 @@ export const SearchBar = ({
|
|||||||
const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => {
|
const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||||
const value = e.target.value;
|
const value = e.target.value;
|
||||||
setQuery(value);
|
setQuery(value);
|
||||||
if (onSearch && value.trim()) {
|
if (value.trim()) {
|
||||||
onSearch(value.trim());
|
handleSearch(value.trim());
|
||||||
setShowResults(true);
|
setShowResults(true);
|
||||||
} else {
|
} else {
|
||||||
setShowResults(false);
|
setShowResults(false);
|
||||||
@@ -37,8 +26,8 @@ export const SearchBar = ({
|
|||||||
// 处理键盘事件(Enter 确认搜索)
|
// 处理键盘事件(Enter 确认搜索)
|
||||||
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
if (onSearch && query.trim()) {
|
if (query.trim()) {
|
||||||
onSearch(query.trim());
|
handleSearch(query.trim());
|
||||||
}
|
}
|
||||||
setShowResults(false);
|
setShowResults(false);
|
||||||
}
|
}
|
||||||
@@ -46,9 +35,7 @@ export const SearchBar = ({
|
|||||||
|
|
||||||
// 选择搜索结果中的节点
|
// 选择搜索结果中的节点
|
||||||
const handleNodeClick = (nodeId: string) => {
|
const handleNodeClick = (nodeId: string) => {
|
||||||
if (onNodeSelect) {
|
handleNodeSelectById(nodeId);
|
||||||
onNodeSelect(nodeId);
|
|
||||||
}
|
|
||||||
setShowResults(false);
|
setShowResults(false);
|
||||||
setQuery('');
|
setQuery('');
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,101 +0,0 @@
|
|||||||
import { useState, useCallback, useEffect } from 'react';
|
|
||||||
import type { GraphData, Node, SearchNode } from '../types/graph';
|
|
||||||
import { graphApi } from '../services/graphApi';
|
|
||||||
import { SAMPLE_GRAPH_DATA } from '../config/sampleData';
|
|
||||||
import { resetColorAssignment } from '../config/colors';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 知识图谱数据管理 Hook
|
|
||||||
* 负责图谱数据的加载、搜索和节点选择
|
|
||||||
*/
|
|
||||||
export const useGraphData = () => {
|
|
||||||
const [graphData, setGraphData] = useState<GraphData>({ nodes: [], edges: [] });
|
|
||||||
const [selectedNode, setSelectedNode] = useState<Node | null>(null);
|
|
||||||
const [searchResults, setSearchResults] = useState<SearchNode[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
|
|
||||||
// 从后端加载图谱数据
|
|
||||||
const loadGraphData = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
const data = await graphApi.getData();
|
|
||||||
|
|
||||||
// 重置颜色分配缓存,确保每次加载数据时颜色分配一致
|
|
||||||
resetColorAssignment();
|
|
||||||
|
|
||||||
// 如果后端返回空数据,使用示例数据
|
|
||||||
if (data.nodes.length === 0) {
|
|
||||||
setGraphData({ ...SAMPLE_GRAPH_DATA, id: `sample-${Date.now()}` });
|
|
||||||
} else {
|
|
||||||
setGraphData({ ...data, id: `data-${Date.now()}` });
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load graph data:', error);
|
|
||||||
// 重置颜色分配缓存
|
|
||||||
resetColorAssignment();
|
|
||||||
// API 失败时使用示例数据作为降级方案
|
|
||||||
setGraphData({ ...SAMPLE_GRAPH_DATA, id: `fallback-${Date.now()}` });
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 搜索节点(前端过滤,可根据需要切换到后端搜索)
|
|
||||||
const handleSearch = useCallback((query: string) => {
|
|
||||||
const results = graphData.nodes
|
|
||||||
.filter((node: Node) =>
|
|
||||||
node.label.toLowerCase().includes(query.toLowerCase()) ||
|
|
||||||
node.id.toLowerCase().includes(query.toLowerCase())
|
|
||||||
)
|
|
||||||
.map((node: Node) => ({
|
|
||||||
id: node.id,
|
|
||||||
label: node.label,
|
|
||||||
type: node.type,
|
|
||||||
matched: true,
|
|
||||||
}));
|
|
||||||
setSearchResults(results);
|
|
||||||
}, [graphData]);
|
|
||||||
|
|
||||||
// 通过 ID 选择节点(用于搜索结果点击)
|
|
||||||
const handleNodeSelectById = useCallback((nodeId: string) => {
|
|
||||||
console.log('[useGraphData] handleNodeSelectById called with id:', nodeId);
|
|
||||||
const node = graphData.nodes.find((n: Node) => n.id === nodeId);
|
|
||||||
console.log('[useGraphData] Found node:', node);
|
|
||||||
if (node) {
|
|
||||||
console.log('[useGraphData] Setting selectedNode:', node);
|
|
||||||
setSelectedNode(node);
|
|
||||||
} else {
|
|
||||||
console.log('[useGraphData] Node not found with id:', nodeId);
|
|
||||||
}
|
|
||||||
}, [graphData.nodes]);
|
|
||||||
|
|
||||||
// 选择节点(用于图谱点击)
|
|
||||||
const handleNodeSelect = useCallback((node: Node | null) => {
|
|
||||||
console.log('[useGraphData] handleNodeSelect called with node:', node);
|
|
||||||
console.log('[useGraphData] About to call setSelectedNode');
|
|
||||||
setSelectedNode(node);
|
|
||||||
console.log('[useGraphData] setSelectedNode called');
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 监听 selectedNode 变化,用于调试
|
|
||||||
useEffect(() => {
|
|
||||||
console.log('[useGraphData] selectedNode state changed:', selectedNode);
|
|
||||||
console.log('[useGraphData] selectedNode truthiness:', !!selectedNode);
|
|
||||||
}, [selectedNode]);
|
|
||||||
|
|
||||||
// 组件挂载时加载数据
|
|
||||||
useEffect(() => {
|
|
||||||
loadGraphData();
|
|
||||||
}, [loadGraphData]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
graphData,
|
|
||||||
selectedNode,
|
|
||||||
searchResults,
|
|
||||||
loading,
|
|
||||||
setSelectedNode,
|
|
||||||
handleSearch,
|
|
||||||
handleNodeSelectById,
|
|
||||||
handleNodeSelect,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import type { GraphLayoutConfig } from '../types/graph';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 知识图谱布局管理 Hook
|
|
||||||
* 返回固定的默认布局配置
|
|
||||||
*/
|
|
||||||
export const useGraphLayout = () => {
|
|
||||||
return {
|
|
||||||
layout: {
|
|
||||||
type: 'd3-force' as const,
|
|
||||||
collide: { radius: 60 } as const,
|
|
||||||
link: { distance: 150 } as const,
|
|
||||||
} as GraphLayoutConfig,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo, useEffect } from 'react';
|
||||||
import GraphView from '../components/GraphView/index';
|
import GraphView from '../components/GraphView/index';
|
||||||
import SearchBar from '../components/SearchBar/index';
|
import SearchBar from '../components/SearchBar/index';
|
||||||
import NodePanel from '../components/NodePanel/index';
|
import NodePanel from '../components/NodePanel/index';
|
||||||
import Legend from '../components/Legend/index';
|
import Legend from '../components/Legend/index';
|
||||||
import { useGraphData } from '../hooks/useGraphData';
|
import { useGraphStore } from '../store';
|
||||||
import { useGraphLayout } from '../hooks/useGraphLayout';
|
import { useLayoutStore } from '../store';
|
||||||
import { generateLegendData } from '../config/colors';
|
import { generateLegendData } from '../config/colors';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -18,15 +18,16 @@ export const KnowledgeGraph = () => {
|
|||||||
const {
|
const {
|
||||||
graphData,
|
graphData,
|
||||||
selectedNode,
|
selectedNode,
|
||||||
searchResults,
|
loadGraphData,
|
||||||
loading,
|
|
||||||
setSelectedNode,
|
|
||||||
handleSearch,
|
|
||||||
handleNodeSelectById,
|
|
||||||
handleNodeSelect,
|
handleNodeSelect,
|
||||||
} = useGraphData();
|
loading,
|
||||||
|
} = useGraphStore();
|
||||||
|
|
||||||
const { layout } = useGraphLayout();
|
const { layout } = useLayoutStore();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadGraphData();
|
||||||
|
}, [loadGraphData]);
|
||||||
|
|
||||||
const legendData = useMemo(() => {
|
const legendData = useMemo(() => {
|
||||||
if (!graphData || graphData.nodes.length === 0) {
|
if (!graphData || graphData.nodes.length === 0) {
|
||||||
@@ -52,11 +53,7 @@ export const KnowledgeGraph = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-4">
|
||||||
<SearchBar
|
<SearchBar />
|
||||||
onSearch={handleSearch}
|
|
||||||
onNodeSelect={handleNodeSelectById}
|
|
||||||
searchResults={searchResults}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -73,10 +70,7 @@ export const KnowledgeGraph = () => {
|
|||||||
|
|
||||||
{selectedNode && (
|
{selectedNode && (
|
||||||
<div className="fixed right-6 top-24 w-96 bg-white rounded-lg shadow-2xl border border-gray-200 z-50">
|
<div className="fixed right-6 top-24 w-96 bg-white rounded-lg shadow-2xl border border-gray-200 z-50">
|
||||||
<NodePanel
|
<NodePanel />
|
||||||
node={selectedNode}
|
|
||||||
onClose={() => setSelectedNode(null)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import type { GraphData, Node, SearchNode } from '../types/graph';
|
||||||
|
import { graphApi } from '../services/graphApi';
|
||||||
|
import { SAMPLE_GRAPH_DATA } from '../config/sampleData';
|
||||||
|
import { resetColorAssignment } from '../config/colors';
|
||||||
|
|
||||||
|
interface GraphState {
|
||||||
|
graphData: GraphData;
|
||||||
|
selectedNode: Node | null;
|
||||||
|
searchResults: SearchNode[];
|
||||||
|
loading: boolean;
|
||||||
|
addGraphData: (data: GraphData) => void;
|
||||||
|
setSelectedNode: (node: Node | null) => void;
|
||||||
|
loadGraphData: () => Promise<void>;
|
||||||
|
handleSearch: (query: string) => void;
|
||||||
|
handleNodeSelect: (node: Node | null) => void;
|
||||||
|
handleNodeSelectById: (nodeId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useGraphStore = create<GraphState>((set, get) => ({
|
||||||
|
graphData: { nodes: [], edges: [] },
|
||||||
|
selectedNode: null,
|
||||||
|
searchResults: [],
|
||||||
|
loading: true,
|
||||||
|
|
||||||
|
addGraphData: (data: GraphData) => {
|
||||||
|
set({ graphData: data });
|
||||||
|
},
|
||||||
|
|
||||||
|
setSelectedNode: (node: Node | null) => {
|
||||||
|
console.log('[graphStore] setSelectedNode called with:', node);
|
||||||
|
set({ selectedNode: node });
|
||||||
|
},
|
||||||
|
|
||||||
|
loadGraphData: async () => {
|
||||||
|
try {
|
||||||
|
set({ loading: true });
|
||||||
|
const data = await graphApi.getData();
|
||||||
|
|
||||||
|
resetColorAssignment();
|
||||||
|
|
||||||
|
if (data.nodes.length === 0) {
|
||||||
|
set({ graphData: { ...SAMPLE_GRAPH_DATA, id: `sample-${Date.now()}` } });
|
||||||
|
} else {
|
||||||
|
set({ graphData: { ...data, id: `data-${Date.now()}` } });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load graph data:', error);
|
||||||
|
resetColorAssignment();
|
||||||
|
set({ graphData: { ...SAMPLE_GRAPH_DATA, id: `fallback-${Date.now()}` } });
|
||||||
|
} finally {
|
||||||
|
set({ loading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
handleSearch: (query: string) => {
|
||||||
|
const { graphData } = get();
|
||||||
|
const results = graphData.nodes
|
||||||
|
.filter((node: Node) =>
|
||||||
|
node.label.toLowerCase().includes(query.toLowerCase()) ||
|
||||||
|
node.id.toLowerCase().includes(query.toLowerCase())
|
||||||
|
)
|
||||||
|
.map((node: Node) => ({
|
||||||
|
id: node.id,
|
||||||
|
label: node.label,
|
||||||
|
type: node.type,
|
||||||
|
matched: true,
|
||||||
|
}));
|
||||||
|
set({ searchResults: results });
|
||||||
|
},
|
||||||
|
|
||||||
|
handleNodeSelectById: (nodeId: string) => {
|
||||||
|
console.log('[graphStore] handleNodeSelectById called with id:', nodeId);
|
||||||
|
const { graphData } = get();
|
||||||
|
const node = graphData.nodes.find((n: Node) => n.id === nodeId);
|
||||||
|
console.log('[graphStore] Found node:', node);
|
||||||
|
if (node) {
|
||||||
|
console.log('[graphStore] Setting selectedNode:', node);
|
||||||
|
set({ selectedNode: node });
|
||||||
|
} else {
|
||||||
|
console.log('[graphStore] Node not found with id:', nodeId);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
handleNodeSelect: (node: Node | null) => {
|
||||||
|
console.log('[graphStore] handleNodeSelect called with node:', node);
|
||||||
|
console.log('[graphStore] About to set selectedNode');
|
||||||
|
set({ selectedNode: node });
|
||||||
|
console.log('[graphStore] selectedNode set');
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { useGraphStore } from './graphStore';
|
||||||
|
export { useLayoutStore } from './layoutStore';
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import type { GraphLayoutConfig } from '../types/graph';
|
||||||
|
|
||||||
|
interface LayoutState {
|
||||||
|
layout: GraphLayoutConfig;
|
||||||
|
setLayout: (layout: GraphLayoutConfig) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useLayoutStore = create<LayoutState>((set) => ({
|
||||||
|
layout: {
|
||||||
|
type: 'd3-force' as const,
|
||||||
|
collide: { radius: 60 } as const,
|
||||||
|
link: { distance: 150 } as const,
|
||||||
|
} as GraphLayoutConfig,
|
||||||
|
|
||||||
|
setLayout: (layout: GraphLayoutConfig) => {
|
||||||
|
set({ layout });
|
||||||
|
},
|
||||||
|
}));
|
||||||
Reference in New Issue
Block a user