generated from template/vite-react-template
add wallnote
This commit is contained in:
parent
07d053abe7
commit
a91f80c1ba
27
package.json
27
package.json
@ -1,8 +1,9 @@
|
||||
{
|
||||
"name": "vite-react",
|
||||
"name": "wallnote",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"version": "0.0.2",
|
||||
"type": "module",
|
||||
"user": "apps",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"dev:web": "cross-env WEB_DEV=true vite --mode web",
|
||||
@ -10,7 +11,8 @@
|
||||
"lint": "eslint .",
|
||||
"deploy": "rsync -avz --delete dist/ light:~/apps/ai/dist",
|
||||
"preview": "vite preview",
|
||||
"pub": "envision deploy ./dist -k vite-react -v 0.0.1",
|
||||
"prepub": "envision switchOrg apps",
|
||||
"pub": "envision deploy ./dist -k wallnote -v 0.0.2 -y y",
|
||||
"ev": "npm run build && npm run deploy"
|
||||
},
|
||||
"stackblitz": {
|
||||
@ -20,21 +22,40 @@
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^5.6.1",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.0",
|
||||
"@kevisual/query": "0.0.7-alpha.3",
|
||||
"@kevisual/router": "0.0.6-alpha-5",
|
||||
"@kevisual/system-ui": "^0.0.3",
|
||||
"@kevisual/ui": "^0.0.4-alpha-1",
|
||||
"@mui/material": "^6.4.5",
|
||||
"@tiptap/core": "^2.11.5",
|
||||
"@tiptap/extension-code-block-lowlight": "^2.11.5",
|
||||
"@tiptap/extension-highlight": "^2.11.5",
|
||||
"@tiptap/extension-typography": "^2.11.5",
|
||||
"@tiptap/starter-kit": "^2.11.5",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/turndown": "^5.0.5",
|
||||
"@xyflow/react": "^12.4.3",
|
||||
"antd": "^5.24.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dayjs": "^1.11.13",
|
||||
"highlight.js": "^11.11.1",
|
||||
"idb": "^8.0.2",
|
||||
"idb-keyval": "^6.2.1",
|
||||
"immer": "^10.1.1",
|
||||
"lodash-es": "^4.17.21",
|
||||
"lowlight": "^3.3.0",
|
||||
"lucide-react": "^0.475.0",
|
||||
"marked": "^15.0.7",
|
||||
"nanoid": "^5.1.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router": "^7.2.0",
|
||||
"react-router-dom": "^7.2.0",
|
||||
"react-toastify": "^11.0.3",
|
||||
"tiptap-markdown": "^0.8.10",
|
||||
"turndown": "^7.2.0",
|
||||
"zustand": "^5.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
1515
pnpm-lock.yaml
generated
1515
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
28
src/App.tsx
28
src/App.tsx
@ -1,9 +1,27 @@
|
||||
import { Flow } from './pages/wall';
|
||||
import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
||||
import { Editor } from './pages/editor';
|
||||
import { ToastContainer } from 'react-toastify';
|
||||
import 'react-toastify/dist/ReactToastify.css';
|
||||
import { List } from './pages/wall/pages/List';
|
||||
import { Auth } from './modules/layouts/Auth';
|
||||
|
||||
export const App = () => {
|
||||
return (
|
||||
<div className='bg-slate-200 w-full h-full border'>
|
||||
<div className='test-loading bg-black'>
|
||||
<div></div>
|
||||
</div>
|
||||
</div>
|
||||
<>
|
||||
<BrowserRouter basename='/apps/wallnote'>
|
||||
<Routes>
|
||||
<Route element={<Auth auth={false} />}>
|
||||
<Route path='/' element={<Flow checkLogin={false}/>} />
|
||||
<Route path='/editor' element={<Editor />} />
|
||||
</Route>
|
||||
<Route element={<Auth auth={true} />}>
|
||||
<Route path='/edit/:id' element={<Flow checkLogin={true}/>} />
|
||||
<Route path='/list' element={<List />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
<ToastContainer />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -1,7 +1,35 @@
|
||||
@import "tailwindcss";
|
||||
@import 'tailwindcss';
|
||||
|
||||
@import './pages/wall/modules/CustomNode.css';
|
||||
@layer components {
|
||||
.test-loading {
|
||||
@apply w-20 h-20 bg-gray-300 rounded-full animate-spin;
|
||||
.node-editor {
|
||||
@apply w-20 h-20 bg-gray-300 bg-white;
|
||||
}
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
body {
|
||||
font-family: 'Roboto', sans-serif;
|
||||
background-color: #f5f5f5;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
#root {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
.react-flow__attribution {
|
||||
display: none;
|
||||
}
|
||||
.drawer-editor {
|
||||
.tiptap {
|
||||
border: unset;
|
||||
}
|
||||
}
|
11
src/modules/dayjs.ts
Normal file
11
src/modules/dayjs.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import dayjs from 'dayjs';
|
||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
export const formatDate = (date?: string, format = 'YYYY-MM-DD HH:mm:ss') => {
|
||||
return dayjs(date).format(format);
|
||||
};
|
||||
|
||||
export const formatRelativeDate = (date?: string) => {
|
||||
return dayjs(date).fromNow();
|
||||
};
|
24
src/modules/layouts/Auth.tsx
Normal file
24
src/modules/layouts/Auth.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useUserWallStore } from '../../pages/wall/store/user-wall';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
|
||||
export const Auth = ({ children, auth = true }: { children?: React.ReactNode; auth?: boolean }) => {
|
||||
const userStore = useUserWallStore(
|
||||
useShallow((state) => {
|
||||
return { user: state.user, queryMe: state.queryMe };
|
||||
}),
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!userStore.user) {
|
||||
userStore.queryMe(auth);
|
||||
}
|
||||
}, []);
|
||||
if (children) {
|
||||
if (auth) {
|
||||
return <>{userStore.user && children}</>;
|
||||
}
|
||||
return <>{children}</>;
|
||||
}
|
||||
return <>{<Outlet />}</>;
|
||||
};
|
10
src/modules/md2html.ts
Normal file
10
src/modules/md2html.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { marked } from 'marked';
|
||||
import TurndownService from 'turndown';
|
||||
export const md2html = async (md: string) => {
|
||||
return marked.parse(md);
|
||||
};
|
||||
|
||||
export const html2md = async (html: string) => {
|
||||
const turndownService = new TurndownService();
|
||||
return turndownService.turndown(html);
|
||||
};
|
23
src/modules/message.ts
Normal file
23
src/modules/message.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { toast, ToastOptions } from 'react-toastify';
|
||||
|
||||
export const message = {
|
||||
error: (message: string, options?: ToastOptions) => {
|
||||
toast.error(message, options);
|
||||
},
|
||||
success: (message: string, options?: ToastOptions) => {
|
||||
toast.success(message, {
|
||||
position: 'top-left',
|
||||
autoClose: 1000,
|
||||
...options,
|
||||
});
|
||||
},
|
||||
warning: (message: string, options?: ToastOptions) => {
|
||||
toast.warning(message, options);
|
||||
},
|
||||
info: (message: string, options?: ToastOptions) => {
|
||||
toast.info(message, options);
|
||||
},
|
||||
default: (message: string, options?: ToastOptions) => {
|
||||
toast(message, options);
|
||||
},
|
||||
};
|
17
src/modules/query.ts
Normal file
17
src/modules/query.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { QueryClient } from '@kevisual/query';
|
||||
import { modal } from './require-to-login';
|
||||
import { message } from './message';
|
||||
|
||||
export const query = new QueryClient();
|
||||
|
||||
query.afterResponse = async (res) => {
|
||||
if (res.code === 401) {
|
||||
modal.setOpen(true);
|
||||
}
|
||||
if (res.code === 403) {
|
||||
if (!res?.message) {
|
||||
message.error('Unauthorized');
|
||||
}
|
||||
}
|
||||
return res;
|
||||
};
|
36
src/modules/require-to-login.ts
Normal file
36
src/modules/require-to-login.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import { DialogModal } from '@kevisual/system-ui/dist/modal';
|
||||
// import '@kevisual/system-ui/dist/modal.css';
|
||||
|
||||
const content = document.createElement('div');
|
||||
const loginUrl = '/root/center/user/login';
|
||||
export const redirectToLogin = (open = true) => {
|
||||
const url = new URL(loginUrl, window.location.href);
|
||||
url.searchParams.set('redirect', window.location.href);
|
||||
if (open) {
|
||||
window.open(url.toString(), '_blank');
|
||||
} else {
|
||||
return url.toString();
|
||||
}
|
||||
};
|
||||
content.innerHTML = `
|
||||
<div class="bg-white p-8 rounded shadow-md w-full max-w-md text-center">
|
||||
<h2 class="text-2xl font-bold mb-4">Token 无效</h2>
|
||||
<p class="mb-6">您的登录凭证已失效,请重新登录。</p>
|
||||
<a href="${redirectToLogin(false)}" class="inline-block bg-red-500 text-white py-2 px-4 rounded hover:bg-red-600 transition duration-200">确定</a>
|
||||
</div>
|
||||
`;
|
||||
export const modal = DialogModal.render(content, {
|
||||
id: 'redirect-to-login',
|
||||
contentStyle: {
|
||||
width: 'unset',
|
||||
},
|
||||
dialogTitleStyle: {
|
||||
display: 'none',
|
||||
padding: '0',
|
||||
},
|
||||
dialogContentStyle: {
|
||||
padding: '0',
|
||||
},
|
||||
mask: true,
|
||||
open: false,
|
||||
});
|
98
src/modules/tiptap/editor.ts
Normal file
98
src/modules/tiptap/editor.ts
Normal file
@ -0,0 +1,98 @@
|
||||
import { Editor } from '@tiptap/core';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import Highlight from '@tiptap/extension-highlight';
|
||||
import Typography from '@tiptap/extension-typography';
|
||||
import { Markdown } from 'tiptap-markdown';
|
||||
|
||||
import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight';
|
||||
import { all, createLowlight } from 'lowlight';
|
||||
import 'highlight.js/styles/github.css';
|
||||
// 根据需要引入的语言支持
|
||||
import js from 'highlight.js/lib/languages/javascript';
|
||||
import ts from 'highlight.js/lib/languages/typescript';
|
||||
import html from 'highlight.js/lib/languages/xml';
|
||||
import css from 'highlight.js/lib/languages/css';
|
||||
import markdown from 'highlight.js/lib/languages/markdown';
|
||||
const lowlight = createLowlight(all);
|
||||
|
||||
// you can also register individual languages
|
||||
lowlight.register('html', html);
|
||||
lowlight.register('css', css);
|
||||
lowlight.register('js', js);
|
||||
lowlight.register('ts', ts);
|
||||
lowlight.register('markdown', markdown);
|
||||
|
||||
export class TextEditor {
|
||||
private editor?: Editor;
|
||||
|
||||
constructor() {}
|
||||
createEditor(el: HTMLElement, opts?: { markdown?: string; html?: string }) {
|
||||
if (this.editor) {
|
||||
this.destroy();
|
||||
}
|
||||
const html = opts?.html || '';
|
||||
this.editor = new Editor({
|
||||
element: el, // 指定编辑器容器
|
||||
extensions: [
|
||||
StarterKit, // 使用 StarterKit 包含基础功能
|
||||
Highlight,
|
||||
Typography,
|
||||
Markdown,
|
||||
CodeBlockLowlight.extend({
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
Tab: () => {
|
||||
const { state, dispatch } = this.editor.view;
|
||||
const { tr, selection } = state;
|
||||
const { from, to } = selection;
|
||||
|
||||
// 插入4个空格的缩进
|
||||
dispatch(tr.insertText(' ', from, to));
|
||||
return true;
|
||||
},
|
||||
'Shift-Tab': () => {
|
||||
const { state, dispatch } = this.editor.view;
|
||||
const { tr, selection } = state;
|
||||
const { from, to } = selection;
|
||||
|
||||
// 获取当前选中的文本
|
||||
const selectedText = state.doc.textBetween(from, to, '\n');
|
||||
|
||||
// 取消缩进:移除前面的4个空格
|
||||
const unindentedText = selectedText.replace(/^ {1,4}/gm, '');
|
||||
dispatch(tr.insertText(unindentedText, from, to));
|
||||
return true;
|
||||
},
|
||||
};
|
||||
},
|
||||
}).configure({
|
||||
lowlight,
|
||||
}),
|
||||
],
|
||||
content: html, // 初始化内容
|
||||
});
|
||||
}
|
||||
setContent(html: string) {
|
||||
this.editor?.commands.setContent(html);
|
||||
}
|
||||
getHtml() {
|
||||
return this.editor?.getHTML();
|
||||
}
|
||||
getContent() {
|
||||
return this.editor?.getText();
|
||||
}
|
||||
onContentChange(callback: (html: string) => void) {
|
||||
this.editor?.off('update'); // 移除之前的监听
|
||||
this.editor?.on('update', () => {
|
||||
callback(this.editor?.getHTML() || '');
|
||||
});
|
||||
}
|
||||
foucus() {
|
||||
this.editor?.view?.focus?.();
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.editor?.destroy();
|
||||
this.editor = undefined;
|
||||
}
|
||||
}
|
32
src/pages/editor/index.tsx
Normal file
32
src/pages/editor/index.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
import { TextEditor } from '@/modules/tiptap/editor';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import clsx from 'clsx';
|
||||
type EditorProps = {
|
||||
className?: string;
|
||||
value?: string;
|
||||
id?: string;
|
||||
onChange?: (value: string) => void;
|
||||
};
|
||||
export const Editor = ({ className, value, onChange, id }: EditorProps) => {
|
||||
const textEditorRef = useRef<TextEditor | null>(null);
|
||||
const editorRef = useRef<HTMLDivElement>(null);
|
||||
const [mount, setMount] = useState(false);
|
||||
useEffect(() => {
|
||||
const editor = new TextEditor();
|
||||
textEditorRef.current = editor;
|
||||
editor.createEditor(editorRef.current!, { html: value });
|
||||
editor.onContentChange((content) => {
|
||||
onChange?.(content);
|
||||
});
|
||||
setMount(true);
|
||||
return () => {
|
||||
editor.destroy();
|
||||
};
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (textEditorRef.current && id && mount) {
|
||||
textEditorRef.current.setContent(value || '');
|
||||
}
|
||||
}, [id, mount]);
|
||||
return <div ref={editorRef} className={clsx('w-full h-full node-editor', className)}></div>;
|
||||
};
|
21
src/pages/wall/components/Icon.tsx
Normal file
21
src/pages/wall/components/Icon.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
export function ResizeIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width='20'
|
||||
height='20'
|
||||
viewBox='0 0 24 24'
|
||||
strokeWidth='2'
|
||||
stroke='#ff0071'
|
||||
fill='none'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
className={className}>
|
||||
<path stroke='none' d='M0 0h24v24H0z' fill='none' />
|
||||
<polyline points='16 20 20 20 20 16' />
|
||||
<line x1='14' y1='14' x2='20' y2='20' />
|
||||
<polyline points='8 4 4 4 4 8' />
|
||||
<line x1='4' y1='4' x2='10' y2='10' />
|
||||
</svg>
|
||||
);
|
||||
}
|
1
src/pages/wall/constants.ts
Normal file
1
src/pages/wall/constants.ts
Normal file
@ -0,0 +1 @@
|
||||
export const BlankNoteText = '<i>double click to edit</i>';
|
35
src/pages/wall/hooks/check-double-click.ts
Normal file
35
src/pages/wall/hooks/check-double-click.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
|
||||
export const useCheckDoubleClick = ({
|
||||
onPaneDoubleClick,
|
||||
onPaneClick,
|
||||
}: {
|
||||
onPaneDoubleClick?: (e: React.MouseEvent) => void;
|
||||
onPaneClick?: (e: React.MouseEvent) => void;
|
||||
}) => {
|
||||
const lastClickTime = useRef(0);
|
||||
const clickTimeOut = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const onCheckPanelDoubleClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
const currentTime = Date.now();
|
||||
if (currentTime - lastClickTime.current < 300 && lastClickTime.current !== 0) {
|
||||
onPaneDoubleClick?.(e); // Use optional chaining to call debounceClick if it's defined
|
||||
clearTimeout(clickTimeOut.current!);
|
||||
clickTimeOut.current = null;
|
||||
lastClickTime.current = 0; // Reset
|
||||
return;
|
||||
} else if (lastClickTime.current === 0) {
|
||||
// First click, setup a timeout to handle single click
|
||||
lastClickTime.current = currentTime; // Update the last click time here as well
|
||||
clickTimeOut.current = setTimeout(() => {
|
||||
onPaneClick?.(e);
|
||||
lastClickTime.current = 0; // Reset
|
||||
}, 300);
|
||||
}
|
||||
},
|
||||
[onPaneDoubleClick, onPaneClick],
|
||||
);
|
||||
|
||||
return { onCheckPanelDoubleClick };
|
||||
};
|
62
src/pages/wall/hooks/tab-node.ts
Normal file
62
src/pages/wall/hooks/tab-node.ts
Normal file
@ -0,0 +1,62 @@
|
||||
import { useReactFlow } from '@xyflow/react';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export const useTabNode = () => {
|
||||
const reactFlowInstance = useReactFlow();
|
||||
useEffect(() => {
|
||||
const listener = (event: any) => {
|
||||
if (event.key === 'Tab') {
|
||||
console.log('tab');
|
||||
const nodes = reactFlowInstance.getNodes();
|
||||
const selectedNode = nodes.find((node) => node.selected);
|
||||
if (!selectedNode) return;
|
||||
// 获取选中的节点
|
||||
const { x, y } = selectedNode?.position || { x: 0, y: 0 };
|
||||
// 根据nodes的position的x和y进行排序,x小的在前,x相等时,y小的在前
|
||||
const newNodes = nodes.sort((a, b) => {
|
||||
const { x: ax, y: ay } = a.position || { x: 0, y: 0 };
|
||||
const { x: bx, y: by } = b.position || { x: 0, y: 0 };
|
||||
if (ax < bx) return -1;
|
||||
if (ax > bx) return 1;
|
||||
return ay - by;
|
||||
});
|
||||
const nextNode = newNodes.find((node) => {
|
||||
if (node.id === selectedNode?.id) return false;
|
||||
const { x: nx, y: ny } = node.position;
|
||||
if (nx > x) {
|
||||
return true;
|
||||
} else if (nx === x) {
|
||||
if (ny > y) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
if (nextNode) {
|
||||
const newNodes = nodes.map((node) => {
|
||||
if (node.id === nextNode.id) {
|
||||
return { ...node, selected: true };
|
||||
}
|
||||
return { ...node, selected: false };
|
||||
});
|
||||
reactFlowInstance.setNodes(newNodes);
|
||||
} else {
|
||||
const newNodes = nodes.map((node) => {
|
||||
if (node.id === nodes[0].id) {
|
||||
return { ...node, selected: true };
|
||||
}
|
||||
return { ...node, selected: false };
|
||||
});
|
||||
reactFlowInstance.setNodes(newNodes);
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', listener);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', listener);
|
||||
};
|
||||
}, [reactFlowInstance]);
|
||||
};
|
174
src/pages/wall/index.tsx
Normal file
174
src/pages/wall/index.tsx
Normal file
@ -0,0 +1,174 @@
|
||||
import {
|
||||
ReactFlow,
|
||||
MiniMap,
|
||||
Controls,
|
||||
Background,
|
||||
useNodesState,
|
||||
Node,
|
||||
useReactFlow,
|
||||
ReactFlowProvider,
|
||||
Panel,
|
||||
useStoreApi,
|
||||
useStore,
|
||||
XYPosition,
|
||||
NodeChange,
|
||||
} from '@xyflow/react';
|
||||
|
||||
import '@xyflow/react/dist/style.css';
|
||||
import { useWallStore } from './store/wall';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useCheckDoubleClick } from './hooks/check-double-click';
|
||||
import { randomId } from './utils/random';
|
||||
import { CustomNodeType } from './modules/CustomNode';
|
||||
import Drawer from './modules/Drawer';
|
||||
import { message } from '@/modules/message';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { BlankNoteText } from './constants';
|
||||
import { Toolbar } from './modules/toolbar/Toolbar';
|
||||
import { useUserWallStore } from './store/user-wall';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { SaveModal } from './modules/FormDialog';
|
||||
import { useTabNode } from './hooks/tab-node';
|
||||
import { Button } from '@mui/material';
|
||||
type NodeData = {
|
||||
id: string;
|
||||
position: XYPosition;
|
||||
data: any;
|
||||
};
|
||||
export function FlowContent() {
|
||||
const reactFlowInstance = useReactFlow();
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState<NodeData>([]);
|
||||
const wallStore = useWallStore((state) => state);
|
||||
const store = useStore((state) => state);
|
||||
const [mount, setMount] = useState(false);
|
||||
const _onNodesChange = useCallback((changes: NodeChange[]) => {
|
||||
const [change] = changes;
|
||||
if (change.type === 'position' && change.dragging === false) {
|
||||
// console.log('position changes', change);
|
||||
getNewNodes();
|
||||
}
|
||||
onNodesChange(changes);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
setNodes(wallStore.nodes);
|
||||
setMount(true);
|
||||
return () => {
|
||||
setMount(false);
|
||||
};
|
||||
}, [wallStore.nodes]);
|
||||
const onNodeDoubleClick = (event, node) => {
|
||||
wallStore.setOpen(true);
|
||||
wallStore.setSelectedNode(node);
|
||||
};
|
||||
const getNewNodes = () => {
|
||||
const nodes = reactFlowInstance.getNodes();
|
||||
wallStore.saveNodes(nodes);
|
||||
};
|
||||
useEffect(() => {
|
||||
if (mount) {
|
||||
// console.log('nodes', nodes);
|
||||
}
|
||||
}, [nodes, mount]);
|
||||
useTabNode();
|
||||
// 添加新节点的函数
|
||||
const onPaneDoubleClick = (event) => {
|
||||
// 计算节点位置
|
||||
const x = event.clientX;
|
||||
const y = event.clientY;
|
||||
const postion = reactFlowInstance.screenToFlowPosition({ x, y });
|
||||
const newNode = {
|
||||
id: randomId(), // 确保每个节点有唯一的ID
|
||||
type: 'wall', // 节点类型
|
||||
position: postion, // 使用事件的客户端坐标
|
||||
data: { html: BlankNoteText },
|
||||
};
|
||||
setNodes((nds) => {
|
||||
const newNodes = nds.concat(newNode);
|
||||
getNewNodes();
|
||||
return newNodes;
|
||||
});
|
||||
message.success('添加节点成功');
|
||||
setTimeout(() => {
|
||||
wallStore.setSelectedNode(newNode);
|
||||
wallStore.setOpen(true);
|
||||
}, 200);
|
||||
};
|
||||
const hasFoucedNode = useMemo(() => {
|
||||
return !!store.nodes.find((node) => node.selected);
|
||||
}, [store.nodes]);
|
||||
const { onCheckPanelDoubleClick } = useCheckDoubleClick({
|
||||
onPaneDoubleClick,
|
||||
});
|
||||
return (
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
// debug={DEV_SERVER}
|
||||
fitView
|
||||
onNodesChange={_onNodesChange}
|
||||
onNodeDoubleClick={onNodeDoubleClick}
|
||||
onPaneClick={onCheckPanelDoubleClick}
|
||||
zoomOnScroll={true}
|
||||
preventScrolling={!hasFoucedNode}
|
||||
nodeTypes={CustomNodeType}>
|
||||
<Controls />
|
||||
<MiniMap />
|
||||
<Background gap={[14, 14]} size={2} color='#E4E5E7' />
|
||||
<Panel position='top-left'>
|
||||
<Toolbar />
|
||||
</Panel>
|
||||
<Panel>
|
||||
<Drawer />
|
||||
<SaveModal />
|
||||
</Panel>
|
||||
</ReactFlow>
|
||||
);
|
||||
}
|
||||
export const Flow = ({ checkLogin = true }: { checkLogin?: boolean }) => {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const wallStore = useWallStore(
|
||||
useShallow((state) => {
|
||||
return {
|
||||
loaded: state.loaded,
|
||||
init: state.init,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
wallStore.init(id);
|
||||
console.log('checkLogin', checkLogin, id);
|
||||
}, [id, checkLogin]);
|
||||
|
||||
if (!wallStore.loaded) {
|
||||
return <div>loading...</div>;
|
||||
} else if (wallStore.loaded === 'error') {
|
||||
return (
|
||||
<div className='flex flex-col items-center justify-center h-screen gap-4'>
|
||||
<div className='text-2xl font-bold'>获取失败,请稍后刷新重试,或者转到首页</div>
|
||||
<Button
|
||||
variant='contained'
|
||||
onClick={() => {
|
||||
navigate('/');
|
||||
}}>
|
||||
转到首页
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ReactFlowProvider>
|
||||
<FlowContent />
|
||||
</ReactFlowProvider>
|
||||
);
|
||||
};
|
||||
export const FlowStatus = () => {
|
||||
const { nodes } = useWallStore();
|
||||
const reactFlow = useReactFlow();
|
||||
const flowStore = useStore((state) => state);
|
||||
return (
|
||||
<div>
|
||||
<div>节点数量: {nodes.length}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
149
src/pages/wall/modules/CustomNode.css
Normal file
149
src/pages/wall/modules/CustomNode.css
Normal file
@ -0,0 +1,149 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
@layer components {
|
||||
.node-editor {
|
||||
@apply w-full h-full bg-white;
|
||||
> div {
|
||||
@apply w-full h-full outline-none;
|
||||
}
|
||||
}
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.scrollbar::-webkit-scrollbar {
|
||||
display: block;
|
||||
width: 2px;
|
||||
height: 2px;
|
||||
}
|
||||
.scrollbar::-webkit-scrollbar-thumb {
|
||||
background-color: #ccc;
|
||||
border-radius: 2px;
|
||||
}
|
||||
.scrollbar::-webkit-scrollbar-thumb:horizontal {
|
||||
background-color: #ccc;
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
:root {
|
||||
--purple-light: #e0e0ff; /* 默认浅紫色背景 */
|
||||
--black: #000000; /* 默认黑色 */
|
||||
--white: #ffffff; /* 默认白色 */
|
||||
--gray-3: #d3d3d3; /* 默认灰色3 */
|
||||
--gray-2: #e5e5e5; /* 默认灰色2 */
|
||||
}
|
||||
.tiptap-preview {
|
||||
.tiptap {
|
||||
margin: 0;
|
||||
padding: 0.5rem;
|
||||
border: unset;
|
||||
}
|
||||
}
|
||||
.tiptap {
|
||||
margin: 0.5rem 1rem;
|
||||
padding: 0.5rem;
|
||||
border-radius: 5px;
|
||||
border: 1px solid #ccc;
|
||||
}
|
||||
/* Basic editor styles */
|
||||
.tiptap:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/* List styles */
|
||||
.tiptap ul,
|
||||
.tiptap ol {
|
||||
padding: 0 1rem;
|
||||
margin: 1.25rem 1rem 1.25rem 0.4rem;
|
||||
}
|
||||
|
||||
.tiptap ul li p,
|
||||
.tiptap ol li p {
|
||||
margin-top: 0.25em;
|
||||
margin-bottom: 0.25em;
|
||||
}
|
||||
|
||||
/* Heading styles */
|
||||
.tiptap h1,
|
||||
.tiptap h2,
|
||||
.tiptap h3,
|
||||
.tiptap h4,
|
||||
.tiptap h5,
|
||||
.tiptap h6 {
|
||||
line-height: 1.1;
|
||||
margin-top: 2.5rem;
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
.tiptap h1,
|
||||
.tiptap h2 {
|
||||
/* margin-top: 3.5rem; */
|
||||
margin-top: 1rem;
|
||||
margin-bottom: .5rem;
|
||||
}
|
||||
|
||||
.tiptap h1 {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.tiptap h2 {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tiptap h3 {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.tiptap h4,
|
||||
.tiptap h5,
|
||||
.tiptap h6 {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/* Code and preformatted text styles */
|
||||
.tiptap code {
|
||||
background-color: var(--purple-light);
|
||||
border-radius: 0.4rem;
|
||||
color: var(--black);
|
||||
font-size: 0.85rem;
|
||||
padding: 0.25em 0.3em;
|
||||
}
|
||||
|
||||
.tiptap pre {
|
||||
border: 1px solid #ccc;
|
||||
/* background: var(--black); */
|
||||
border-radius: 0.5rem;
|
||||
/* color: var(--white); */
|
||||
font-family: 'JetBrainsMono', monospace;
|
||||
margin: 1.5rem 0;
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.tiptap pre code {
|
||||
background: none;
|
||||
color: inherit;
|
||||
font-size: 0.8rem;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.tiptap mark {
|
||||
background-color: #FAF594;
|
||||
border-radius: 0.4rem;
|
||||
box-decoration-break: clone;
|
||||
padding: 0.1rem 0.3rem;
|
||||
}
|
||||
|
||||
.tiptap blockquote {
|
||||
border-left: 3px solid var(--gray-3);
|
||||
margin: 1.5rem 0;
|
||||
padding-left: 1rem;
|
||||
}
|
||||
|
||||
.tiptap hr {
|
||||
border: none;
|
||||
border-top: 1px solid var(--gray-2);
|
||||
margin: 2rem 0;
|
||||
}
|
150
src/pages/wall/modules/CustomNode.tsx
Normal file
150
src/pages/wall/modules/CustomNode.tsx
Normal file
@ -0,0 +1,150 @@
|
||||
import { useRef, memo, useEffect, useMemo, useState } from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { NodeResizer, useStore } from '@xyflow/react';
|
||||
import { useWallStore } from '../store/wall';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { toast } from 'react-toastify';
|
||||
import { message } from '@/modules/message';
|
||||
import hljs from 'highlight.js';
|
||||
import { Edit } from 'lucide-react';
|
||||
export type WallData<T = Record<string, any>> = {
|
||||
html: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
[key: string]: any;
|
||||
} & T;
|
||||
const ShowContent = (props: { data: WallData; selected: boolean }) => {
|
||||
const html = props.data.html;
|
||||
const selected = props.selected;
|
||||
const showRef = useRef<HTMLDivElement>(null);
|
||||
if (!html) return <div className='w-full h-full flex items-center justify-center '>空</div>;
|
||||
const [highlightHtml, setHighlightHtml] = useState('');
|
||||
const highlight = async (html: string) => {
|
||||
const _html = html.replace(/<pre><code class="language-(\w+)">([\s\S]*?)<\/code><\/pre>/g, (match, p1, p2) => {
|
||||
return `<pre><code class="language-${p1}">${hljs.highlight(p2, { language: p1 }).value}</code></pre>`;
|
||||
});
|
||||
return _html;
|
||||
};
|
||||
useEffect(() => {
|
||||
highlight(html).then((res) => {
|
||||
setHighlightHtml(res);
|
||||
});
|
||||
}, [html]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={showRef}
|
||||
className='p-2 w-full h-full overflow-y-auto scrollbar tiptap bg-white'
|
||||
style={{
|
||||
pointerEvents: selected ? 'auto' : 'none',
|
||||
}}
|
||||
dangerouslySetInnerHTML={{ __html: highlightHtml }}></div>
|
||||
);
|
||||
};
|
||||
|
||||
export const CustomNode = (props: { id: string; data: WallData; selected: boolean }) => {
|
||||
const data = props.data;
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const selected = props.selected;
|
||||
const wallStore = useWallStore(
|
||||
useShallow((state) => {
|
||||
return {
|
||||
setOpen: state.setOpen,
|
||||
setSelectedNode: state.setSelectedNode,
|
||||
saveNodes: state.saveNodes,
|
||||
};
|
||||
}),
|
||||
);
|
||||
const store = useStore((state) => {
|
||||
return {
|
||||
updateWallRect: (id: string, rect: { width: number; height: number }) => {
|
||||
const nodes = state.nodes.map((node) => {
|
||||
if (node.id === id) {
|
||||
node.data.width = rect.width;
|
||||
node.data.height = rect.height;
|
||||
}
|
||||
return node;
|
||||
});
|
||||
state.setNodes(nodes);
|
||||
wallStore.saveNodes(nodes);
|
||||
},
|
||||
getNode: (id: string) => {
|
||||
return state.nodes.find((node) => node.id === id);
|
||||
},
|
||||
deleteNode: (id: string) => {
|
||||
const nodes = state.nodes.filter((node) => node.id !== id);
|
||||
state.setNodes(nodes);
|
||||
wallStore.saveNodes(nodes);
|
||||
},
|
||||
};
|
||||
});
|
||||
useEffect(() => {
|
||||
if (selected) {
|
||||
const handleDelete = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Delete') {
|
||||
store.deleteNode(props.id);
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleDelete);
|
||||
return () => window.removeEventListener('keydown', handleDelete);
|
||||
}
|
||||
}, [selected]);
|
||||
const width = data.width || 100;
|
||||
const height = data.height || 100;
|
||||
const style: React.CSSProperties = {};
|
||||
style.width = width;
|
||||
style.height = height;
|
||||
const showOpen = () => {
|
||||
const node = store.getNode(props.id);
|
||||
if (node) {
|
||||
wallStore.setOpen(true);
|
||||
wallStore.setSelectedNode(node);
|
||||
} else {
|
||||
message.error('节点不存在');
|
||||
}
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={contentRef}
|
||||
onDoubleClick={(e) => {
|
||||
showOpen();
|
||||
// e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}}
|
||||
className={clsx('w-full h-full border relative border-gray-300 min-w-[100px] min-h-[50px] tiptap-preview')}
|
||||
style={style}>
|
||||
<ShowContent data={data} selected={props.selected} />
|
||||
</div>
|
||||
<div className={clsx('absolute top-0 right-0', props.selected ? 'opacity-100' : 'opacity-0')}>
|
||||
<button
|
||||
className='w-6 h-6 flex items-center justify-center'
|
||||
onClick={() => {
|
||||
showOpen();
|
||||
}}>
|
||||
<Edit className='w-4 h-4' />
|
||||
</button>
|
||||
</div>
|
||||
<NodeResizer
|
||||
minWidth={100}
|
||||
minHeight={50}
|
||||
onResizeStart={() => {}}
|
||||
isVisible={props.selected}
|
||||
onResizeEnd={(e) => {
|
||||
const parent = contentRef.current?.parentElement;
|
||||
if (!parent) return;
|
||||
const width = parent.style.width;
|
||||
const height = parent.style.height;
|
||||
const widthNum = parseInt(width);
|
||||
const heightNum = parseInt(height);
|
||||
if (!heightNum || !widthNum) return;
|
||||
store.updateWallRect(props.id, { width: widthNum, height: heightNum });
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
export const WallNoteNode = memo(CustomNode);
|
||||
export const CustomNodeType = {
|
||||
wall: WallNoteNode,
|
||||
};
|
106
src/pages/wall/modules/Drawer.tsx
Normal file
106
src/pages/wall/modules/Drawer.tsx
Normal file
@ -0,0 +1,106 @@
|
||||
import { useWallStore } from '../store/wall'; // 确保导入正确的路径
|
||||
import clsx from 'clsx';
|
||||
import { X } from 'lucide-react'; // 导入 Close 图标
|
||||
import { Editor } from '@/pages/editor';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useStore, useStoreApi } from '@xyflow/react';
|
||||
import { BlankNoteText } from '../constants';
|
||||
import { message } from '@/modules/message';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { isMac } from '../utils/is-mac';
|
||||
const Drawer = () => {
|
||||
const { open, setOpen, selectedNode, setSelectedNode, editValue, setEditValue } = useWallStore(
|
||||
useShallow((state) => ({
|
||||
open: state.open,
|
||||
setOpen: state.setOpen,
|
||||
selectedNode: state.selectedNode,
|
||||
setSelectedNode: state.setSelectedNode,
|
||||
editValue: state.editValue,
|
||||
setEditValue: state.setEditValue,
|
||||
})),
|
||||
);
|
||||
const store = useStore((state) => state);
|
||||
const storeApi = useStoreApi();
|
||||
useEffect(() => {
|
||||
if (open && selectedNode) {
|
||||
setEditValue(selectedNode?.data.html);
|
||||
}
|
||||
}, [open, selectedNode]);
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
setOpen(false);
|
||||
setSelectedNode(null);
|
||||
};
|
||||
}, []);
|
||||
const listener = async (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
const systemKey = e.metaKey || e.ctrlKey;
|
||||
|
||||
// mac command+s windows ctrl+s
|
||||
if (systemKey && e.key === 's') {
|
||||
onSave();
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
window.addEventListener('keydown', listener);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', listener);
|
||||
};
|
||||
}, []);
|
||||
const onSave = () => {
|
||||
const wallStore = useWallStore.getState();
|
||||
const selectedNode = wallStore.selectedNode;
|
||||
const _editValue = wallStore.editValue;
|
||||
if (selectedNode && _editValue) {
|
||||
selectedNode.data.html = _editValue;
|
||||
const newNodes = storeApi.getState().nodes.map((node) => (node.id === selectedNode.id ? selectedNode : node));
|
||||
storeApi.setState({ nodes: newNodes });
|
||||
if (wallStore.id) {
|
||||
message.success('保存成功', {
|
||||
closeOnClick: true,
|
||||
});
|
||||
}
|
||||
wallStore.saveNodes(newNodes);
|
||||
}
|
||||
};
|
||||
let html = selectedNode?.data?.html || '';
|
||||
if (html === BlankNoteText) {
|
||||
html = '';
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'transition-all duration-300 bg-white flex flex-col gap-2 h-full w-full overflow-hidden fixed right-0 top-0 z-10',
|
||||
open ? 'open' : 'hidden',
|
||||
'w-[800px] xs:w-[100%] sm:w-[100%] md:w-[600px] lg:w-[600px] xl:w-[600px] 2xl:w-[800px]', // 默认宽度,根据屏幕大小适配,小屏幕全屏幕
|
||||
)}>
|
||||
<div className='flex justify-between items-center h-10'>
|
||||
<button onClick={() => setOpen(false)}>
|
||||
<X className='w-6 h-6 cursor-pointer ml-2' />
|
||||
</button>
|
||||
{selectedNode && (
|
||||
<div>
|
||||
<button className='bg-blue-500 text-white px-4 py-1 rounded-md mr-4 cursor-pointer' onClick={onSave}>
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className='pr-4 mx-4 mb-4 rounded-md pb-4 box-border scrollbar border border-gray-300 '
|
||||
style={{
|
||||
height: 'calc(100vh - 2.5rem)',
|
||||
overflowY: 'auto',
|
||||
}}>
|
||||
{selectedNode && open && <Editor className='drawer-editor' value={html} onChange={setEditValue} id={selectedNode.id} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Drawer;
|
132
src/pages/wall/modules/FormDialog.tsx
Normal file
132
src/pages/wall/modules/FormDialog.tsx
Normal file
@ -0,0 +1,132 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Dialog, DialogTitle, DialogContent, TextField, DialogActions, Button, Chip } from '@mui/material';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { getNodeData, useWallStore } from '../store/wall';
|
||||
import { useReactFlow, useStore } from '@xyflow/react';
|
||||
import { useUserWallStore } from '../store/user-wall';
|
||||
import { message } from '@/modules/message';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
function FormDialog({ open, handleClose, handleSubmit, initialData }) {
|
||||
const [data, setData] = useState(initialData || { title: '', description: '', summary: '', tags: [] });
|
||||
|
||||
const handleChange = (event) => {
|
||||
setData({ ...data, [event.target.name]: event.target.value });
|
||||
};
|
||||
|
||||
const handleTagDelete = (tagToDelete) => {
|
||||
setData({ ...data, tags: data.tags.filter((tag) => tag !== tagToDelete) });
|
||||
};
|
||||
|
||||
const handleAddTag = (event) => {
|
||||
if (event.key === 'Enter' && event.target.value !== '') {
|
||||
setData({ ...data, tags: [...data.tags, event.target.value] });
|
||||
event.target.value = ''; // Clear input after adding tag
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={handleClose}>
|
||||
<DialogTitle>{initialData ? 'Edit Data' : 'Create Data'}</DialogTitle>
|
||||
<DialogContent>
|
||||
<TextField
|
||||
autoFocus
|
||||
margin='dense'
|
||||
name='title'
|
||||
label='Title'
|
||||
type='text'
|
||||
fullWidth
|
||||
variant='outlined'
|
||||
value={data.title}
|
||||
onChange={handleChange}
|
||||
required
|
||||
/>
|
||||
<TextField
|
||||
margin='dense'
|
||||
name='description'
|
||||
label='Description'
|
||||
type='text'
|
||||
fullWidth
|
||||
multiline
|
||||
variant='outlined'
|
||||
value={data.description}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<TextField
|
||||
margin='dense'
|
||||
name='summary'
|
||||
label='Summary'
|
||||
type='text'
|
||||
fullWidth
|
||||
multiline
|
||||
variant='outlined'
|
||||
value={data.summary}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<TextField
|
||||
margin='dense'
|
||||
name='tags'
|
||||
label='Tags'
|
||||
type='text'
|
||||
fullWidth
|
||||
variant='outlined'
|
||||
placeholder='Press enter to add tags'
|
||||
onKeyPress={handleAddTag}
|
||||
/>
|
||||
{data.tags.map((tag, index) => (
|
||||
<Chip key={index} label={tag} onDelete={() => handleTagDelete(tag)} style={{ margin: '5px' }} />
|
||||
))}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleClose}>Cancel</Button>
|
||||
<Button onClick={() => handleSubmit(data)}>Submit</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default FormDialog;
|
||||
|
||||
export const SaveModal = () => {
|
||||
const wallStore = useWallStore(useShallow((state) => state));
|
||||
const userWallStore = useUserWallStore(useShallow((state) => state));
|
||||
const { showFormDialog, setShowFormDialog, formDialogData, setFormDialogData } = wallStore;
|
||||
const reactFlowInstance = useReactFlow();
|
||||
const navigate = useNavigate();
|
||||
const { id } = wallStore;
|
||||
const onSubmit = async (values) => {
|
||||
const nodes = reactFlowInstance.getNodes();
|
||||
const data = {
|
||||
nodes: getNodeData(nodes),
|
||||
};
|
||||
const fromData = {
|
||||
title: values.title,
|
||||
description: values.description,
|
||||
summary: values.summary,
|
||||
tags: values.tags,
|
||||
markType: 'wall' as 'wall',
|
||||
data,
|
||||
};
|
||||
const res = await userWallStore.saveWall(fromData, { refresh: true });
|
||||
if (res.code === 200) {
|
||||
setShowFormDialog(false);
|
||||
if (!id) {
|
||||
// 新创建
|
||||
const data = res.data;
|
||||
wallStore.clear();
|
||||
setTimeout(() => {
|
||||
navigate(`/wall/${data.id}`);
|
||||
}, 2000);
|
||||
} else {
|
||||
// 编辑
|
||||
wallStore.setData(res.data);
|
||||
}
|
||||
} else {
|
||||
message.error('保存失败');
|
||||
}
|
||||
};
|
||||
if (!showFormDialog) {
|
||||
return null;
|
||||
}
|
||||
return <FormDialog open={showFormDialog} handleClose={() => setShowFormDialog(false)} handleSubmit={onSubmit} initialData={formDialogData} />;
|
||||
};
|
272
src/pages/wall/modules/toolbar/Toolbar.tsx
Normal file
272
src/pages/wall/modules/toolbar/Toolbar.tsx
Normal file
@ -0,0 +1,272 @@
|
||||
import { PanelTopOpen, PanelTopClose, Save, Download, Upload, User, Trash, Plus } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { useWallStore } from '../../store/wall';
|
||||
import clsx from 'clsx';
|
||||
import { useUserWallStore } from '../../store/user-wall';
|
||||
import { redirectToLogin } from '@/modules/require-to-login';
|
||||
import { useStore } from '@xyflow/react';
|
||||
import { message } from '@/modules/message';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ClickAwayListener } from '@mui/material';
|
||||
export const ToolbarItem = ({
|
||||
children,
|
||||
showBorder = true,
|
||||
onClick,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
showBorder?: boolean;
|
||||
onClick?: () => any;
|
||||
className?: string;
|
||||
}) => {
|
||||
return (
|
||||
<div onClick={onClick} className={clsx('flex items-center w-full gap-4 p-2 border-b border-gray-300 cursor-pointer', showBorder && 'border-b', className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
// 空白处点击,当不包函toolbar时候,关闭toolbar
|
||||
export const useBlankClick = () => {
|
||||
const { setToolbarOpen } = useWallStore(
|
||||
useShallow((state) => {
|
||||
return {
|
||||
setToolbarOpen: state.setToolbarOpen,
|
||||
};
|
||||
}),
|
||||
);
|
||||
useEffect(() => {
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
// 点击的内容,closest('.toolbar')
|
||||
const target = e.target as HTMLElement;
|
||||
const toolbar = target.closest('.toolbar'); // 往上找,找到toolbar为止
|
||||
console.log('toolbar', target, toolbar);
|
||||
// if (!toolbar) {
|
||||
// setToolbarOpen(false);
|
||||
// }
|
||||
};
|
||||
console.log('add event');
|
||||
document.addEventListener('click', handleClick);
|
||||
return () => {
|
||||
document.removeEventListener('click', handleClick);
|
||||
};
|
||||
}, []);
|
||||
};
|
||||
export const ToolbarContent = ({ open }) => {
|
||||
if (!open) {
|
||||
return null;
|
||||
}
|
||||
const wallStore = useWallStore(useShallow((state) => state));
|
||||
const userWallStore = useUserWallStore(useShallow((state) => state));
|
||||
const store = useStore((state) => state);
|
||||
const hasLogin = !!userWallStore.user;
|
||||
const navigate = useNavigate();
|
||||
type MenuItem = {
|
||||
label: string;
|
||||
key: string;
|
||||
icon?: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
onClick: () => any;
|
||||
};
|
||||
const menuList: MenuItem[] = [
|
||||
{
|
||||
label: '导出',
|
||||
key: 'export',
|
||||
icon: <Download />,
|
||||
onClick: () => {
|
||||
wallStore.exportWall(store.nodes);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: '导入',
|
||||
key: 'import',
|
||||
icon: <Upload />,
|
||||
children: (
|
||||
<>
|
||||
<div>
|
||||
<Upload />
|
||||
</div>
|
||||
<div>导入</div>
|
||||
<input
|
||||
type='file'
|
||||
id='import-file'
|
||||
accept='.json'
|
||||
style={{ display: 'none' }}
|
||||
onChange={async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const data = e.target?.result;
|
||||
const json = JSON.parse(data as string);
|
||||
const keys = ['id', 'type', 'position', 'data'];
|
||||
if (Array.isArray(json) && json.every((item) => keys.every((key) => item[key]))) {
|
||||
const nodes = store.nodes;
|
||||
const newNodes = json.filter((item) => {
|
||||
return !nodes.find((node) => node.id === item.id);
|
||||
});
|
||||
const _nodes = [...nodes, ...newNodes];
|
||||
store.setNodes(_nodes);
|
||||
wallStore.saveNodes(_nodes);
|
||||
} else {
|
||||
message.error('文件格式错误');
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
onClick: () => {
|
||||
const input = document.querySelector('#import-file')! as HTMLInputElement;
|
||||
if (input) {
|
||||
input.click();
|
||||
} else {
|
||||
message.error('请选择文件');
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: '清空',
|
||||
key: 'clear',
|
||||
icon: <Trash />,
|
||||
onClick: async () => {
|
||||
await wallStore.clear();
|
||||
message.success('清空成功');
|
||||
store.setNodes([]);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
if (!hasLogin) {
|
||||
menuList.push({
|
||||
label: '登录',
|
||||
key: 'login',
|
||||
icon: <User />,
|
||||
onClick: () => {
|
||||
redirectToLogin();
|
||||
},
|
||||
});
|
||||
if (wallStore.id) {
|
||||
menuList.push({
|
||||
label: '删除',
|
||||
key: 'delete',
|
||||
icon: <Trash />,
|
||||
onClick: async () => {
|
||||
const res = await userWallStore.deleteWall(wallStore.id!);
|
||||
if (res.code === 200) {
|
||||
navigate('/');
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
} else {
|
||||
if (!wallStore.id) {
|
||||
menuList.push({
|
||||
label: '保存到账号',
|
||||
key: 'saveToAccount',
|
||||
icon: <Save />,
|
||||
onClick: () => {
|
||||
wallStore.setShowFormDialog(true);
|
||||
wallStore.setFormDialogData({
|
||||
title: '',
|
||||
description: '',
|
||||
tags: [],
|
||||
summary: '',
|
||||
});
|
||||
},
|
||||
});
|
||||
} else {
|
||||
menuList.push({
|
||||
label: '编辑信息',
|
||||
key: 'saveToAccount',
|
||||
icon: <Save />,
|
||||
onClick: () => {
|
||||
wallStore.setShowFormDialog(true);
|
||||
const data = wallStore.data;
|
||||
wallStore.setFormDialogData({
|
||||
title: data?.title,
|
||||
description: data?.description,
|
||||
tags: data?.tags,
|
||||
summary: data?.summary,
|
||||
});
|
||||
},
|
||||
});
|
||||
menuList.push({
|
||||
label: '新增',
|
||||
key: 'add',
|
||||
icon: <Plus />,
|
||||
onClick: () => {
|
||||
navigate(`/`);
|
||||
wallStore.clearQueryWall();
|
||||
},
|
||||
});
|
||||
menuList.push({
|
||||
label: '删除',
|
||||
key: 'delete',
|
||||
icon: <Trash />,
|
||||
className: 'text-red-500',
|
||||
onClick: async () => {
|
||||
const res = await userWallStore.deleteWall(wallStore.id!);
|
||||
if (res.code === 200) {
|
||||
message.success('删除成功,返回首页');
|
||||
wallStore.clearQueryWall();
|
||||
navigate('/');
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
menuList.push({
|
||||
label: '退出 ',
|
||||
key: 'logout',
|
||||
icon: <User />,
|
||||
onClick: () => {
|
||||
userWallStore.logout();
|
||||
},
|
||||
});
|
||||
}
|
||||
return (
|
||||
<ClickAwayListener onClickAway={() => wallStore.setToolbarOpen(false)}>
|
||||
<div className=' flex flex-col items-center w-[200px] bg-white border border-gray-300 rounded-md absolute top-0 left-8'>
|
||||
{menuList.map((item) => (
|
||||
<ToolbarItem
|
||||
key={item.key}
|
||||
className={item.className}
|
||||
onClick={() => {
|
||||
item.onClick?.();
|
||||
if (item.key !== 'import') {
|
||||
wallStore.setToolbarOpen(false);
|
||||
}
|
||||
}}>
|
||||
{item.children ? (
|
||||
<>{item.children}</>
|
||||
) : (
|
||||
<>
|
||||
<div>{item.icon}</div>
|
||||
<div>{item.label}</div>
|
||||
</>
|
||||
)}
|
||||
</ToolbarItem>
|
||||
))}
|
||||
<div className='text-xs p-1 text-gray-500 italic'>{wallStore.id ? 'id:' + wallStore.id : '临时编辑,资源缓存在本地'}</div>
|
||||
{hasLogin && <div className='text-xs p-1 -mt-1 text-gray-500 w-full text-right mr-2'>用户: {userWallStore.user?.username}</div>}
|
||||
</div>
|
||||
</ClickAwayListener>
|
||||
);
|
||||
};
|
||||
export const Toolbar = () => {
|
||||
const wallStore = useWallStore(useShallow((state) => state));
|
||||
const { toolbarOpen, setToolbarOpen } = wallStore;
|
||||
|
||||
return (
|
||||
<div className='toolbar flex items-center gap-2 relative'>
|
||||
<div className='p-2 cursor-pointer' onClick={() => setToolbarOpen(!toolbarOpen)}>
|
||||
<PanelTopClose className={clsx('w-4 h-4', toolbarOpen && 'hidden')} />
|
||||
<PanelTopOpen className={clsx('w-4 h-4', !toolbarOpen && 'hidden')} />
|
||||
</div>
|
||||
<ToolbarContent open={toolbarOpen} />
|
||||
</div>
|
||||
);
|
||||
};
|
64
src/pages/wall/pages/List.tsx
Normal file
64
src/pages/wall/pages/List.tsx
Normal file
@ -0,0 +1,64 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useWallStore } from '../store/wall';
|
||||
import { useUserWallStore } from '../store/user-wall';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { formatDate, formatRelativeDate } from '../../../modules/dayjs';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
export const List = () => {
|
||||
const navigate = useNavigate();
|
||||
const wallStore = useUserWallStore(
|
||||
useShallow((state) => {
|
||||
return {
|
||||
wallList: state.wallList,
|
||||
queryWallList: state.queryWallList,
|
||||
};
|
||||
}),
|
||||
);
|
||||
useEffect(() => {
|
||||
init();
|
||||
}, []);
|
||||
const init = () => {
|
||||
wallStore.queryWallList();
|
||||
};
|
||||
return (
|
||||
<div className='p-4 bg-white w-full h-full flex flex-col'>
|
||||
<div className='flex justify-between h-10 items-center'>
|
||||
<div className='text-2xl font-bold'>Wall Note</div>
|
||||
</div>
|
||||
<div className='flex flex-col flex-grow overflow-hidden'>
|
||||
<div className='flex flex-wrap gap-4 overflow-y-auto'>
|
||||
{wallStore.wallList.map((wall) => {
|
||||
return (
|
||||
<div
|
||||
key={wall.id}
|
||||
className='p-4 border border-gray-200 w-80 rounded-md'
|
||||
onClick={() => {
|
||||
navigate(`/wall/${wall.id}`);
|
||||
}}>
|
||||
<div>
|
||||
<div>{wall.title}</div>
|
||||
</div>
|
||||
<div className='mt-2 flex flex-col gap-2'>
|
||||
<div className='text-sm text-gray-500 line-clamp-2'>{wall.summary}</div>
|
||||
<div className='text-sm text-gray-500 flex flex-wrap gap-2 '>
|
||||
{wall?.tags?.map?.((tag) => {
|
||||
return (
|
||||
<div className='text-xs text-gray-500 border border-gray-200 rounded-md px-2 py-1' key={tag}>
|
||||
{tag}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className='mt-2 flex justify-between'>
|
||||
<div className='text-sm text-gray-500'>{formatDate(wall?.createdAt, 'YYYY-MM-DD')}</div>
|
||||
<div className='text-sm text-gray-500'>{formatRelativeDate(wall?.createdAt)}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
104
src/pages/wall/store/user-wall.ts
Normal file
104
src/pages/wall/store/user-wall.ts
Normal file
@ -0,0 +1,104 @@
|
||||
import { message } from '@/modules/message';
|
||||
import { query } from '@/modules/query';
|
||||
import { create } from 'zustand';
|
||||
type User = {
|
||||
id: string;
|
||||
username: string;
|
||||
avatar: string;
|
||||
};
|
||||
type Wall = {
|
||||
id?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
type?: 'wall';
|
||||
data?: {
|
||||
[key: string]: any;
|
||||
};
|
||||
link?: string;
|
||||
summary?: string;
|
||||
tags?: string[];
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
uid?: string;
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
interface UserWallStore {
|
||||
user?: User;
|
||||
setUser: (user: User) => void;
|
||||
queryMe: (openOnNoLogin?: boolean) => Promise<void>;
|
||||
wallList: Wall[];
|
||||
queryWallList: () => Promise<void>;
|
||||
logout: () => void;
|
||||
saveWall: (data: Wall, opts?: { refresh?: boolean, showMessage?: boolean }) => Promise<any>;
|
||||
queryWall: (id: string) => Promise<any>;
|
||||
deleteWall: (id: string) => Promise<any>;
|
||||
}
|
||||
|
||||
export const useUserWallStore = create<UserWallStore>((set, get) => ({
|
||||
user: undefined,
|
||||
setUser: (user: User) => set({ user }),
|
||||
queryMe: async (openOnNoLogin = true) => {
|
||||
const res = await query.post(
|
||||
{
|
||||
path: 'user',
|
||||
key: 'me',
|
||||
},
|
||||
{
|
||||
afterResponse: !openOnNoLogin ? async (res) => res : undefined,
|
||||
},
|
||||
);
|
||||
console.log('queryMe', res);
|
||||
if (res.code === 200) {
|
||||
set({ user: res.data });
|
||||
}
|
||||
},
|
||||
wallList: [],
|
||||
queryWallList: async () => {
|
||||
const res = await query.post({
|
||||
path: 'mark',
|
||||
key: 'list',
|
||||
markType: 'wall',
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
});
|
||||
if (res.code === 200) {
|
||||
set({ wallList: res.data.list });
|
||||
}
|
||||
},
|
||||
saveWall: async (data: Wall, opts?: { refresh?: boolean, showMessage?: boolean }) => {
|
||||
const { queryWallList } = get();
|
||||
const res = await query.post({
|
||||
path: 'mark',
|
||||
key: 'update',
|
||||
data,
|
||||
});
|
||||
if (res.code === 200) {
|
||||
// 刷新列表
|
||||
opts?.refresh && (await queryWallList());
|
||||
opts?.showMessage && message.success('保存成功');
|
||||
return res;
|
||||
}
|
||||
return res;
|
||||
},
|
||||
queryWall: async (id: string) => {
|
||||
const res = await query.post({
|
||||
path: 'mark',
|
||||
key: 'get',
|
||||
id,
|
||||
});
|
||||
return res;
|
||||
},
|
||||
deleteWall: async (id: string) => {
|
||||
const res = await query.post({
|
||||
path: 'mark',
|
||||
key: 'delete',
|
||||
id,
|
||||
});
|
||||
return res;
|
||||
},
|
||||
logout: () => {
|
||||
set({ user: undefined });
|
||||
localStorage.removeItem('token');
|
||||
},
|
||||
}));
|
162
src/pages/wall/store/wall.ts
Normal file
162
src/pages/wall/store/wall.ts
Normal file
@ -0,0 +1,162 @@
|
||||
import { create } from 'zustand';
|
||||
import { XYPosition } from '@xyflow/react';
|
||||
import { getWallData, setWallData } from '../utils/db';
|
||||
import { useUserWallStore } from './user-wall';
|
||||
import { redirectToLogin } from '@/modules/require-to-login';
|
||||
import { message } from '@/modules/message';
|
||||
|
||||
type NodeData<T = { [key: string]: any }> = {
|
||||
id: string;
|
||||
position: XYPosition;
|
||||
data: T;
|
||||
type?: string; // wall
|
||||
};
|
||||
export const getNodeData = (nodes: NodeData[]) => {
|
||||
return nodes.map((node) => ({
|
||||
id: node.id,
|
||||
position: node.position,
|
||||
data: node.data,
|
||||
type: node.type,
|
||||
}));
|
||||
};
|
||||
|
||||
interface WallState {
|
||||
// 只做传递
|
||||
nodes: NodeData[];
|
||||
setNodes: (nodes: NodeData[]) => void;
|
||||
saveNodes: (nodes: NodeData[]) => Promise<void>;
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
selectedNode: NodeData | null;
|
||||
setSelectedNode: (node: NodeData | null) => void;
|
||||
editValue: string;
|
||||
setEditValue: (value: string) => void;
|
||||
data?: any;
|
||||
setData: (data: any) => void;
|
||||
init: (id?: string | null) => Promise<void>;
|
||||
id: string | null;
|
||||
setId: (id: string | null) => void;
|
||||
loading: boolean;
|
||||
setLoading: (loading: boolean) => void;
|
||||
loaded: boolean | 'error';
|
||||
toolbarOpen: boolean;
|
||||
setToolbarOpen: (open: boolean) => void;
|
||||
showFormDialog: boolean;
|
||||
setShowFormDialog: (show: boolean) => void;
|
||||
formDialogData: any;
|
||||
setFormDialogData: (data: any) => void;
|
||||
clear: () => Promise<void>;
|
||||
exportWall: (nodes: NodeData[]) => Promise<void>;
|
||||
clearQueryWall: () => Promise<void>;
|
||||
}
|
||||
const initialNodes = [
|
||||
// { id: '1', type: 'wall', position: { x: 0, y: 0 }, data: { html: '1' } },
|
||||
{
|
||||
id: '1',
|
||||
type: 'wall',
|
||||
position: { x: 0, y: 0 },
|
||||
data: { html: 'sadfsdaf1 sadfsdaf1 sadfsdaf1 sadfsdaf1 sadfsdaf1 sadfsdaf1 sadfsdaf1 sadfsdaf1', width: 410, height: 212 },
|
||||
},
|
||||
// { id: '2', type: 'wall', position: { x: 0, y: 100 }, data: { html: '3332' } },
|
||||
];
|
||||
|
||||
export const useWallStore = create<WallState>((set, get) => ({
|
||||
nodes: [],
|
||||
loading: false,
|
||||
setLoading: (loading) => set({ loading }),
|
||||
setNodes: (nodes) => {
|
||||
set({ nodes });
|
||||
},
|
||||
saveNodes: async (nodes: NodeData[]) => {
|
||||
if (!get().id) {
|
||||
const covertData = getNodeData(nodes);
|
||||
setWallData({ nodes: covertData });
|
||||
} else {
|
||||
const { id } = get();
|
||||
const userWallStore = useUserWallStore.getState();
|
||||
console.log('saveNodes id', id);
|
||||
if (id) {
|
||||
const covertData = getNodeData(nodes);
|
||||
const res = await userWallStore.saveWall({
|
||||
id,
|
||||
data: {
|
||||
nodes: covertData,
|
||||
},
|
||||
});
|
||||
if (res.code === 200) {
|
||||
// console.log('saveNodes res', res);
|
||||
message.success('保存成功', {
|
||||
closeOnClick: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
open: false,
|
||||
setOpen: (open) => set({ open }),
|
||||
selectedNode: null,
|
||||
setSelectedNode: (node) => set({ selectedNode: node }),
|
||||
editValue: '',
|
||||
setEditValue: (value) => set({ editValue: value }),
|
||||
data: null,
|
||||
setData: (data) => set({ data }),
|
||||
id: null,
|
||||
setId: (id) => set({ id }),
|
||||
loaded: false,
|
||||
init: async (id?: string | null) => {
|
||||
// 如果登陆了且如果有id,从服务器获取
|
||||
// 没有id,获取缓存
|
||||
const hasLogin = localStorage.getItem('token');
|
||||
if (hasLogin && id) {
|
||||
const res = await useUserWallStore.getState().queryWall(id);
|
||||
if (res.code === 200) {
|
||||
set({ nodes: res.data?.data?.nodes || [], loaded: true, id, data: res.data });
|
||||
} else {
|
||||
// message.error('获取失败,请稍后刷新重试');
|
||||
set({ loaded: 'error' });
|
||||
}
|
||||
} else if (!hasLogin && id) {
|
||||
// 没有登陆,但是有id,从服务器获取
|
||||
// 跳转到登陆页面
|
||||
redirectToLogin();
|
||||
} else {
|
||||
const data = await getWallData();
|
||||
set({ nodes: data?.nodes || [], loaded: true });
|
||||
}
|
||||
},
|
||||
toolbarOpen: false,
|
||||
setToolbarOpen: (open) => set({ toolbarOpen: open }),
|
||||
showFormDialog: false,
|
||||
setShowFormDialog: (show) => set({ showFormDialog: show }),
|
||||
formDialogData: null,
|
||||
setFormDialogData: (data) => set({ formDialogData: data }),
|
||||
clear: async () => {
|
||||
if (get().id) {
|
||||
set({ nodes: initialNodes, id: null, selectedNode: null, editValue: '', data: null });
|
||||
await useUserWallStore.getState().saveWall({
|
||||
id: get().id!,
|
||||
data: {
|
||||
nodes: [],
|
||||
},
|
||||
});
|
||||
} else {
|
||||
set({ nodes: initialNodes, id: null, selectedNode: null, editValue: '', data: null });
|
||||
await setWallData({ nodes: [] });
|
||||
}
|
||||
},
|
||||
exportWall: async (nodes: NodeData[]) => {
|
||||
const covertData = getNodeData(nodes);
|
||||
setWallData({ nodes: covertData });
|
||||
// 导出为json
|
||||
const json = JSON.stringify(covertData);
|
||||
const blob = new Blob([json], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'wall.json';
|
||||
a.click();
|
||||
},
|
||||
clearQueryWall: async () => {
|
||||
set({ nodes: initialNodes, id: null, selectedNode: null, editValue: '', data: null, toolbarOpen: false, loaded: false });
|
||||
},
|
||||
}));
|
0
src/pages/wall/utils/convet.ts
Normal file
0
src/pages/wall/utils/convet.ts
Normal file
14
src/pages/wall/utils/db.ts
Normal file
14
src/pages/wall/utils/db.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { get, set, clear } from 'idb-keyval';
|
||||
|
||||
export async function getWallData() {
|
||||
const data = await get('cacheWall');
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function setWallData(data: any) {
|
||||
await set('cacheWall', data);
|
||||
}
|
||||
|
||||
export async function clearWallData() {
|
||||
await clear();
|
||||
}
|
11
src/pages/wall/utils/is-mac.ts
Normal file
11
src/pages/wall/utils/is-mac.ts
Normal file
@ -0,0 +1,11 @@
|
||||
export const isMac = async () => {
|
||||
// Check if the newer API is available
|
||||
const navigator = window.navigator as Navigator & { userAgentData: { getHighEntropyValues: (keys: string[]) => Promise<{ platform: string }> } };
|
||||
if (navigator.userAgentData) {
|
||||
const uaData = await navigator.userAgentData.getHighEntropyValues(['platform']);
|
||||
return uaData.platform === 'macOS';
|
||||
} else {
|
||||
// Fallback to using the older userAgent string
|
||||
return /Macintosh|Mac OS X/i.test(navigator.userAgent);
|
||||
}
|
||||
};
|
10
src/pages/wall/utils/random.ts
Normal file
10
src/pages/wall/utils/random.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { customAlphabet } from 'nanoid';
|
||||
const alphabet = '0123456789abcdefghijklmnopqrstuvwxyz';
|
||||
const alphabetLetters = 'abcdefghijklmnopqrstuvwxyz';
|
||||
export const randomString = customAlphabet(alphabet, 10);
|
||||
export const randomLetters = customAlphabet(alphabetLetters, 10);
|
||||
export const randomId = () => {
|
||||
const firstChar = randomLetters(1);
|
||||
const restChars = randomString(21);
|
||||
return firstChar + restChars;
|
||||
};
|
@ -1,4 +1,4 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import { defineConfig, ProxyOptions } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'path';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
@ -9,12 +9,35 @@ const version = pkgs.version || '0.0.1';
|
||||
|
||||
const isDev = process.env.NODE_ENV === 'development';
|
||||
|
||||
const basename = isDev ? '/' : '/username/app';
|
||||
const basename = isDev ? '/' : '/apps/wallnote';
|
||||
const plugins = []
|
||||
const isWeb = false;
|
||||
const isKevisual = true;
|
||||
|
||||
if(isWeb) {
|
||||
plugins.push(basicSsl())
|
||||
}
|
||||
let proxy:Record<string, string | ProxyOptions> = {
|
||||
}
|
||||
if(isKevisual) {
|
||||
proxy = {
|
||||
'/api': {
|
||||
target: 'https://kevisual.xiongxiao.me',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/api/router': {
|
||||
target: 'ws://localhost:3000',
|
||||
changeOrigin: true,
|
||||
ws: true,
|
||||
rewriteWsOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/api/, '/api'),
|
||||
},
|
||||
'/root/center': {
|
||||
target: 'https://kevisual.xiongxiao.me',
|
||||
changeOrigin: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss(), ...plugins],
|
||||
@ -48,6 +71,7 @@ export default defineConfig({
|
||||
rewriteWsOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/api/, '/api'),
|
||||
},
|
||||
...proxy,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
Loading…
x
Reference in New Issue
Block a user