generated from template/astro-template
feat: add ai-html
This commit is contained in:
@@ -30,7 +30,7 @@ export const ChatHistoryList = ({ storeId }: { storeId: string }) => {
|
||||
},
|
||||
});
|
||||
const url = new URL(location.href);
|
||||
url.searchParams.set('chatId', id);
|
||||
url.searchParams.set('id', id);
|
||||
setHistoryState({}, url.toString());
|
||||
};
|
||||
const { control, handleSubmit, reset, getValues } = useForm({ defaultValues: { title: '', id: '' } });
|
||||
|
||||
@@ -64,7 +64,7 @@ export const Chat = ({ storeId }: { storeId: string }) => {
|
||||
const chat = getHistoryState()[storeId];
|
||||
const chatId = chat?.chatId;
|
||||
const url = new URL(window.location.href);
|
||||
const urlChatId = url.searchParams.get('chatId');
|
||||
const urlChatId = url.searchParams.get('id');
|
||||
if (chatId) {
|
||||
setId(chatId);
|
||||
} else if (urlChatId) {
|
||||
|
||||
@@ -104,14 +104,14 @@ export const ModelNav = () => {
|
||||
<Save className='w-4 h-4' />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title='发送请求。'>
|
||||
{/* <Tooltip title='发送请求。'>
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
chat();
|
||||
}}>
|
||||
<Send className='w-4 h-4' />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Tooltip> */}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
169
src/apps/ai-html/Home.tsx
Normal file
169
src/apps/ai-html/Home.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
import { createEditor } from '@kevisual/codemirror';
|
||||
import { Chain } from '@kevisual/codemirror/utils';
|
||||
import { IconButton } from '@/components/a/button.tsx';
|
||||
import { Tooltip } from '@/components/a/tooltip.tsx';
|
||||
import { UploadIcon, UploadCloud as CloudUploadOutlined } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useDropzone } from 'react-dropzone';
|
||||
import { CacheWorkspace } from '@kevisual/cache';
|
||||
import { useHomeStore } from './store/index.ts';
|
||||
import { UploadModal } from './module/UploadModal.tsx';
|
||||
import { useShallow } from 'zustand/shallow';
|
||||
import { toast } from 'react-toastify';
|
||||
import { SuccessModal } from './module/SuccessModal.tsx';
|
||||
import { debounce } from 'lodash-es';
|
||||
const chain = new Chain();
|
||||
export const Home = () => {
|
||||
const editorElRef = useRef<HTMLDivElement>(null);
|
||||
const editorRef = useRef<ReturnType<typeof createEditor>>(null);
|
||||
const [isHtml, setIsHtml] = useState(true);
|
||||
const [html, setHtml] = useState('');
|
||||
const { initApp, setOpenUploadModal, setText, filename, openPreview, setOpenPreview } = useHomeStore(
|
||||
useShallow((state) => ({
|
||||
initApp: state.initApp,
|
||||
setOpenUploadModal: state.setOpenUploadModal,
|
||||
setText: state.setText,
|
||||
filename: state.filename,
|
||||
openPreview: state.openPreview,
|
||||
setOpenPreview: state.setOpenPreview,
|
||||
})),
|
||||
);
|
||||
const onDrop = (acceptedFiles) => {
|
||||
console.log(acceptedFiles);
|
||||
const file = acceptedFiles[0];
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const content = e.target?.result as string;
|
||||
if (editorRef.current) {
|
||||
editorRef.current.dispatch({
|
||||
changes: { from: 0, to: editorRef.current.state.doc.length, insert: content },
|
||||
});
|
||||
} else {
|
||||
toast.error('编辑器未初始化,请稍后再试');
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
};
|
||||
const { getRootProps, getInputProps } = useDropzone({ onDrop, accept: { 'text/html': ['.html'], 'text/javascript': ['.js'], 'text/css': ['.css'] } });
|
||||
|
||||
useEffect(() => {
|
||||
initApp();
|
||||
initEditor();
|
||||
return () => {
|
||||
if (editorRef.current) {
|
||||
chain.destroy();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const initEditor = async () => {
|
||||
if (!editorElRef.current) return;
|
||||
const cache = new CacheWorkspace();
|
||||
let cacheData = '';
|
||||
try {
|
||||
cacheData = (await cache.storage.get('html-editor')) || '';
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
const type = filename.endsWith('.js') ? 'javascript' : filename.endsWith('.css') ? 'css' : 'html';
|
||||
setIsHtml(type === 'html');
|
||||
const debouncedSetHtml = debounce((value: string) => {
|
||||
setHtml(value);
|
||||
}, 500);
|
||||
const editor = createEditor(editorElRef.current, {
|
||||
type: type || 'html',
|
||||
onChange: (value) => {
|
||||
cache.storage.set('html-editor', value);
|
||||
debouncedSetHtml(value);
|
||||
},
|
||||
});
|
||||
const cmScroller = editorElRef.current.querySelector('.cm-scroller');
|
||||
if (cmScroller) {
|
||||
cmScroller.classList.add('scrollbar');
|
||||
}
|
||||
chain.setEditor(editor);
|
||||
editorRef.current = editor;
|
||||
setTimeout(() => {
|
||||
if (cacheData) {
|
||||
editorRef.current!.dispatch({
|
||||
changes: { from: 0, to: editorRef.current!.state.doc.length, insert: cacheData },
|
||||
});
|
||||
}
|
||||
}, 300);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='flex flex-row w-full h-full'>
|
||||
<div className='flex flex-col text-primary border-r border-gray-200 px-2 pt-2'>
|
||||
<div>
|
||||
<Tooltip title='部署应用' placement='right'>
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
// const editorContent = editorRef.current?.getContent();
|
||||
const editorContent = editorRef.current?.state.doc.toString();
|
||||
if (editorContent) {
|
||||
setOpenUploadModal(true);
|
||||
setText(editorContent);
|
||||
} else {
|
||||
toast.error('请先输入代码');
|
||||
}
|
||||
}}>
|
||||
<CloudUploadOutlined />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className=' mt-2' {...getRootProps()}>
|
||||
<Tooltip title='点击上传html或者js文件 ' placement='right'>
|
||||
<div>
|
||||
<IconButton>
|
||||
<UploadIcon size={16} />
|
||||
</IconButton>
|
||||
<input type='file' style={{ display: 'none' }} {...getInputProps()} />
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div className='h-full grow px-2 flex flex-col'>
|
||||
<div className=' py-1'>
|
||||
<i className=' text-gray-500' style={{ fontSize: 10 }}>
|
||||
{'>'} 快速部署html小应用, 粘贴前端html代码。点击部署。(这个页面内容自动缓存到本地)
|
||||
<a className='text-blue-500 ml-2' href='/root/center/app/edit/list'>
|
||||
管理
|
||||
</a>
|
||||
</i>
|
||||
</div>
|
||||
<div className='w-full flex overflow-hidden' style={{ height: 'calc(100% - 50px)' }}>
|
||||
<div className='w-full h-full border rounded-md border-gray-200 scrollbar relative'>
|
||||
<div className='h-full overflow-hidden' ref={editorElRef}></div>
|
||||
{isHtml && (
|
||||
<IconButton className='absolute top-2 right-2'>
|
||||
<Tooltip title='预览' placement='right'>
|
||||
<input
|
||||
className='cursor-pointer'
|
||||
type='checkbox'
|
||||
checked={openPreview}
|
||||
onChange={(e) => {
|
||||
setOpenPreview(e.target.checked);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</IconButton>
|
||||
)}
|
||||
</div>
|
||||
{isHtml && openPreview && (
|
||||
<div className='w-1/2 h-full shrink-0'>
|
||||
<iframe
|
||||
className='w-full h-full border rounded-md border-gray-200 scrollbar'
|
||||
srcDoc={html}
|
||||
title='Preview'
|
||||
sandbox='allow-scripts allow-same-origin'
|
||||
style={{ width: '100%', height: '100%' }}></iframe>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<UploadModal />
|
||||
<SuccessModal />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
14
src/apps/ai-html/index.tsx
Normal file
14
src/apps/ai-html/index.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Home } from './Home';
|
||||
import { ToastProvider } from '@/modules/toast/Provider';
|
||||
|
||||
export const App = () => {
|
||||
return (
|
||||
<ToastProvider>
|
||||
<AIHTML />
|
||||
</ToastProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export const AIHTML = () => {
|
||||
return <Home />;
|
||||
};
|
||||
76
src/apps/ai-html/module/SuccessModal.tsx
Normal file
76
src/apps/ai-html/module/SuccessModal.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { Modal } from '@/components/a/modal';
|
||||
import { useShallow } from 'zustand/shallow';
|
||||
import { useHomeStore } from '../store';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { queryLogin } from '@/modules/query';
|
||||
import { Button } from '@/components/a/button';
|
||||
export const Label = ({ label, children }: { label: string; children: React.ReactNode }) => {
|
||||
return (
|
||||
<div className='text-sm text-gray-500 w-full flex gap-2'>
|
||||
<div className='min-w-[60px]'>{label}</div>
|
||||
<div className=''>{children}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export const SuccessModal = () => {
|
||||
const { openSuccessModal, setOpenSuccessModal, appKey, version, filename } = useHomeStore(
|
||||
useShallow((state) => ({
|
||||
openSuccessModal: state.openSuccessModal,
|
||||
setOpenSuccessModal: state.setOpenSuccessModal,
|
||||
appKey: state.appKey, //
|
||||
version: state.version, //
|
||||
filename: state.filename, //
|
||||
})),
|
||||
);
|
||||
const [link, setLink] = useState<string>('');
|
||||
useEffect(() => {
|
||||
getLink();
|
||||
}, [openSuccessModal, appKey, filename]);
|
||||
const getLink = async () => {
|
||||
const user = await queryLogin.checkLocalUser();
|
||||
if (!user) {
|
||||
setLink('');
|
||||
return;
|
||||
}
|
||||
const _currentHref = new URL(window.location.href);
|
||||
const username = user?.username;
|
||||
const _filename = filename;
|
||||
let pathname = `/${username}/${appKey}/`;
|
||||
if (_filename.endsWith('.html')) {
|
||||
if (_filename !== 'index.html') {
|
||||
pathname += _filename;
|
||||
}
|
||||
}
|
||||
const newHref = new URL(pathname, _currentHref.origin);
|
||||
const link = newHref.toString();
|
||||
setLink(link);
|
||||
return link;
|
||||
};
|
||||
return (
|
||||
<Modal open={openSuccessModal} setOpen={setOpenSuccessModal} title='部署成功'>
|
||||
<div>
|
||||
<div className='flex flex-col gap-2 w-[400px] min-h-[100px] text-black'>
|
||||
<Label label='应用 Key: '>{appKey}</Label>
|
||||
<Label label='版本:'>{version}</Label>
|
||||
<Label label='访问地址:'>
|
||||
<a href={link} className='text-blue-500' rel='noreferrer'>
|
||||
{link}
|
||||
</a>
|
||||
</Label>
|
||||
<Label label='配置地址:'>
|
||||
<a href={`/root/center/app/edit/list`} className='text-blue-500' target='_self' rel='noreferrer'>
|
||||
{`/root/center/app/edit/list`}
|
||||
</a>
|
||||
</Label>
|
||||
<div className='mt-1 text-gray-500 italic' style={{ fontSize: 10 }}>
|
||||
注: 如果需要其他人访问,需要设置共享。
|
||||
</div>
|
||||
|
||||
<div className='mt-4 flex justify-end'>
|
||||
<Button onClick={() => setOpenSuccessModal(false)}>知道了</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
114
src/apps/ai-html/module/UploadModal.tsx
Normal file
114
src/apps/ai-html/module/UploadModal.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import { useHomeStore } from '../store';
|
||||
import { Modal } from '@/components/a/modal';
|
||||
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { Button } from '@/components/a/button';
|
||||
import { useEffect } from 'react';
|
||||
import { customAlphabet } from 'nanoid';
|
||||
import { toast } from 'react-toastify';
|
||||
import { uploadFile } from './upload-file';
|
||||
import { RefreshCcw } from 'lucide-react';
|
||||
import { queryApp } from '../store';
|
||||
import { Input } from '@/components/a/input';
|
||||
import { Tooltip } from '@/components/a/tooltip';
|
||||
|
||||
export const nanoid = customAlphabet('abcdefghijklmnopqrstuvwxyz', 10);
|
||||
export const UploadModal = () => {
|
||||
const { appKey, version, filename, openUploadModal, text, setOpenUploadModal, setAppKey, setVersion, setFilename, setOpenSuccessModal } = useHomeStore();
|
||||
const { control, handleSubmit, reset, setValue } = useForm();
|
||||
// const { publishVersion } = useAppVersionStore(useShallow((state) => ({ publishVersion: state.publishVersion })));
|
||||
useEffect(() => {
|
||||
if (openUploadModal) {
|
||||
reset({ appKey: appKey || randomAppKey(), version: version || '1.0.0', filename: filename || 'index.html' });
|
||||
}
|
||||
}, [openUploadModal]);
|
||||
const randomAppKey = () => {
|
||||
const randomAppKey = nanoid(4) + nanoid(4);
|
||||
return randomAppKey;
|
||||
};
|
||||
const onSubmit = async (data: any) => {
|
||||
console.log(data);
|
||||
if (!text) {
|
||||
toast.error('代码不能为空');
|
||||
return;
|
||||
}
|
||||
if (!data.appKey) {
|
||||
toast.error('应用key不能为空');
|
||||
return;
|
||||
}
|
||||
if (!data.version) {
|
||||
toast.error('版本不能为空');
|
||||
return;
|
||||
}
|
||||
if (!data.filename) {
|
||||
toast.error('文件名不能为空');
|
||||
return;
|
||||
}
|
||||
setAppKey(data.appKey);
|
||||
setVersion(data.version);
|
||||
setFilename(data.filename);
|
||||
const res = await uploadFile({
|
||||
appKey: data.appKey,
|
||||
version: data.version,
|
||||
filename: data.filename,
|
||||
text,
|
||||
});
|
||||
if (res?.code === 200) {
|
||||
// toast.success('部署成功');
|
||||
const toastId = toast.loading('发布中...');
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
const res = await queryApp.publishVersion({ data: { appKey: data.appKey, version: data.version } });
|
||||
toast.dismiss(toastId);
|
||||
if (res?.code === 200) {
|
||||
toast.success('发布成功');
|
||||
setOpenSuccessModal(true);
|
||||
} else {
|
||||
toast.error(res?.message || '发布失败');
|
||||
}
|
||||
} else {
|
||||
toast.error(res?.message || '部署失败');
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Modal open={openUploadModal} setOpen={setOpenUploadModal}>
|
||||
<div>
|
||||
<div>部署页面</div>
|
||||
<div className='text-sm text-gray-500 p-4'>
|
||||
<form className='flex flex-col gap-3 pt-1 ' onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className='flex flex-col gap-1'>
|
||||
<label className='text-sm flex gap-2 items-center'>
|
||||
应用key
|
||||
<Tooltip title='随机生成应用key'>
|
||||
<RefreshCcw
|
||||
className='cursor-pointer w-4 h-4'
|
||||
onClick={() => {
|
||||
setValue('appKey', randomAppKey());
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</label>
|
||||
<Controller control={control} name='appKey' render={({ field }) => <Input {...field} />} />
|
||||
</div>
|
||||
<div className='flex flex-col gap-1'>
|
||||
<label className='text-sm'>版本</label>
|
||||
<Controller control={control} name='version' render={({ field }) => <Input {...field} />} />
|
||||
</div>
|
||||
<div className='flex flex-col gap-1'>
|
||||
<label className='text-sm'>文件名</label>
|
||||
<Controller control={control} name='filename' render={({ field }) => <Input {...field} />} />
|
||||
</div>
|
||||
<div className='flex justify-end gap-2 mt-3'>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setOpenUploadModal(false);
|
||||
}}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type='submit'>提交</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
30
src/apps/ai-html/module/upload-file.ts
Normal file
30
src/apps/ai-html/module/upload-file.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { uploadChunkV2 } from '@/apps/draw/modules/upload';
|
||||
import { toFile } from '@/apps/draw/modules/to-file';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
type UploadFileOpts = {
|
||||
appKey: string;
|
||||
version: string;
|
||||
filename: string;
|
||||
text: string;
|
||||
};
|
||||
const getFilenameExtension = (filename: string) => {
|
||||
return filename.split('.').pop() || '';
|
||||
};
|
||||
const allowFilesName = ['js', 'css', 'json', 'html'];
|
||||
export const uploadFile = async (uploadFileOpts: UploadFileOpts) => {
|
||||
const { appKey, version, filename, text } = uploadFileOpts;
|
||||
const extension = getFilenameExtension(filename);
|
||||
if (!allowFilesName.includes(extension)) {
|
||||
toast.error('文件类型不支持');
|
||||
return;
|
||||
}
|
||||
const file = toFile(text, filename);
|
||||
const res = await uploadChunkV2(file, {
|
||||
appKey,
|
||||
version,
|
||||
filename,
|
||||
noCheckAppFiles: false,
|
||||
});
|
||||
return res as any;
|
||||
};
|
||||
64
src/apps/ai-html/store/index.ts
Normal file
64
src/apps/ai-html/store/index.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
import { query } from '@/query/index.ts';
|
||||
|
||||
import { QueryApp } from '@/query/query-app/query-app';
|
||||
|
||||
export const queryApp = new QueryApp({ query });
|
||||
|
||||
export type HomeStore = {
|
||||
appKey: string;
|
||||
version: string;
|
||||
setAppKey: (appKey: string) => void;
|
||||
setVersion: (version: string) => void;
|
||||
filename: string;
|
||||
setFilename: (filename: string) => void;
|
||||
initApp: () => void;
|
||||
openUploadModal: boolean;
|
||||
setOpenUploadModal: (open: boolean) => void;
|
||||
text: string;
|
||||
setText: (text: string) => void;
|
||||
openSuccessModal: boolean;
|
||||
setOpenSuccessModal: (open: boolean) => void;
|
||||
openPreview: boolean;
|
||||
setOpenPreview: (open: boolean) => void;
|
||||
};
|
||||
export const useHomeStore = create<HomeStore>((set) => ({
|
||||
appKey: '',
|
||||
version: '',
|
||||
openPreview: false,
|
||||
setOpenPreview: (open: boolean) => {
|
||||
set({ openPreview: open });
|
||||
},
|
||||
setAppKey: (appKey: string) => {
|
||||
set({ appKey });
|
||||
localStorage.setItem('home-app-key', appKey);
|
||||
},
|
||||
setVersion: (version: string) => {
|
||||
set({ version });
|
||||
localStorage.setItem('home-app-version', version);
|
||||
},
|
||||
filename: '',
|
||||
setFilename: (filename: string) => {
|
||||
set({ filename });
|
||||
localStorage.setItem('home-file-name', filename);
|
||||
},
|
||||
initApp: () => {
|
||||
const appKey = localStorage.getItem('home-app-key') || '';
|
||||
const version = localStorage.getItem('home-app-version') || '';
|
||||
const filename = localStorage.getItem('home-file-name') || '';
|
||||
set({ appKey, version, filename });
|
||||
},
|
||||
openUploadModal: false,
|
||||
setOpenUploadModal: (open: boolean) => {
|
||||
set({ openUploadModal: open });
|
||||
},
|
||||
text: '',
|
||||
setText: (text: string) => {
|
||||
set({ text });
|
||||
},
|
||||
openSuccessModal: false,
|
||||
setOpenSuccessModal: (open: boolean) => {
|
||||
set({ openSuccessModal: open });
|
||||
},
|
||||
}));
|
||||
48
src/apps/assistant-home/data/link.ts
Normal file
48
src/apps/assistant-home/data/link.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { isDev } from '../module/is-dev';
|
||||
|
||||
export const links = [
|
||||
{
|
||||
title: '单页面应用开发',
|
||||
description: '单页面应用开发模块。部署单个的网页的页面,适配简单的模块。复杂的使用管理中心。',
|
||||
link: '/root/ai-pages/apps/html/',
|
||||
},
|
||||
{
|
||||
title: '提示词规划器(Mark Prompts)',
|
||||
description: '提示词规划设计的模块。Mark的数据,type为chat的数据。',
|
||||
link: '/root/ai-pages/mark/ai-prompts/',
|
||||
},
|
||||
{
|
||||
title: '管理中心',
|
||||
description: '高级的代码管理中心,网页的版本上传和管理功能。',
|
||||
link: '/root/center/',
|
||||
},
|
||||
{
|
||||
title: 'Mark Link',
|
||||
description: 'Mark的管理模块, 网页导航搜索查询。Mark的数据,type类型为md。',
|
||||
link: '/root/ai-pages/mark/ai-mark/',
|
||||
},
|
||||
{
|
||||
title: 'Mark Draw',
|
||||
description: 'Mark的管理模块, Excaildraw的封装应用。Mark的数据,type类型为excaildraw。',
|
||||
link: '/root/ai-pages/mark/draw/',
|
||||
},
|
||||
{
|
||||
title: 'Editor(Dev)',
|
||||
description: 'Demo编辑器,支持Markdown编辑',
|
||||
link: '/root/ai-pages/ai-editor/',
|
||||
},
|
||||
].map((item) => {
|
||||
if (isDev()) {
|
||||
console.warn('Dev mode: link replaced', item.link);
|
||||
item.link = item.link.replace('/root/ai-pages', '');
|
||||
}
|
||||
return item;
|
||||
});
|
||||
|
||||
export const links2 = [
|
||||
{
|
||||
title: 'cli',
|
||||
description: '命令行工具,支持多种功能',
|
||||
link: '/root/cli',
|
||||
},
|
||||
];
|
||||
@@ -6,7 +6,8 @@ import { getSuggestionItems } from '../ai-chat/editor/suggestion/item';
|
||||
import { html2md } from '@kevisual/markdown-editor/tiptap/index.ts';
|
||||
import { chatId } from '../ai-chat/utils/uuid';
|
||||
import '../ai-chat/index.css';
|
||||
export const App = (props: any) => {
|
||||
import { links } from './data/link.ts';
|
||||
export const Editor = () => {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const editorRef = useRef<TextEditor | null>(null);
|
||||
useEffect(() => {
|
||||
@@ -23,11 +24,32 @@ export const App = (props: any) => {
|
||||
};
|
||||
}, []);
|
||||
return (
|
||||
<div className='w-full h-full flex flex-col'>
|
||||
<div className='w-[600px] h-[400px] border border-gray-300 flex flex-col mx-auto'>
|
||||
<div className='w-full scrollbar' style={{ height: 'calc(100% - 90px)' }} ref={ref}></div>
|
||||
</div>
|
||||
<Button>发送</Button>
|
||||
<div className='w-[600px] h-[400px] border border-gray-300 flex flex-col mx-auto'>
|
||||
<div className='w-full scrollbar' style={{ height: 'calc(100% - 90px)' }} ref={ref}></div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export const App = (props: any) => {
|
||||
return (
|
||||
<div className='w-full h-full flex flex-col'>
|
||||
<ShowLinks links={links} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ShowLinks = (props: { links: { title: string; description: string; link: string }[] }) => {
|
||||
return (
|
||||
<div className='w-full h-full flex flex-col'>
|
||||
<div className='flex-1 overflow-y-auto'>
|
||||
{props.links.map((item, index) => (
|
||||
<div key={index} className='p-4 border-b border-gray-200 hover:bg-gray-50'>
|
||||
<a href={item.link} className='text-blue-600 hover:underline' target='_blank' rel='noopener noreferrer'>
|
||||
{item.title}
|
||||
</a>
|
||||
<p className='text-gray-600'>{item.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
1
src/apps/assistant-home/module/is-dev.ts
Normal file
1
src/apps/assistant-home/module/is-dev.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const isDev = () => location.origin.includes('localhost:4321');
|
||||
103
src/apps/draw/App.tsx
Normal file
103
src/apps/draw/App.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import { useEffect, useLayoutEffect, useState } from 'react';
|
||||
import { getHistoryState, setHistoryState } from '@kevisual/store/web-page.js';
|
||||
import { Draw } from './pages/Draw';
|
||||
import { App as Manager, ProviderManagerName, useManagerStore } from '../mark/manager/Manager';
|
||||
import { ToastProvider } from '@/modules/toast/Provider';
|
||||
|
||||
import './index.css';
|
||||
import { useShallow } from 'zustand/shallow';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
// @ts-ignore
|
||||
window.EXCALIDRAW_ASSET_PATH = 'https://esm.sh/@excalidraw/excalidraw@0.18.0/dist/prod/';
|
||||
|
||||
export const App = () => {
|
||||
return (
|
||||
<ToastProvider>
|
||||
<DrawApp />
|
||||
</ToastProvider>
|
||||
);
|
||||
};
|
||||
export const getUrlId = () => {
|
||||
const url = new URL(window.location.href);
|
||||
return url.searchParams.get('id') || '';
|
||||
};
|
||||
export const DrawApp = () => {
|
||||
const [id, setId] = useState('');
|
||||
const urlId = getUrlId();
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const state = getHistoryState();
|
||||
if (state?.id) {
|
||||
setId(state.id);
|
||||
return;
|
||||
}
|
||||
if (urlId) {
|
||||
setId(urlId);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className='bg-white w-full h-full flex'>
|
||||
<Manager
|
||||
showSearch={true}
|
||||
showAdd={true}
|
||||
markType={'excalidraw'}
|
||||
openMenu={!urlId}
|
||||
onClick={(data) => {
|
||||
if (data.id !== id) {
|
||||
setId('');
|
||||
const url = new URL(location.href);
|
||||
url.searchParams.set('id', data.id);
|
||||
console.log('set url', url.toString());
|
||||
setHistoryState({}, url.toString());
|
||||
setTimeout(() => {
|
||||
setId(data.id);
|
||||
}, 200);
|
||||
const _store = useManagerStore.getState(ProviderManagerName);
|
||||
if (_store.markData) {
|
||||
_store.setCurrentMarkId('');
|
||||
// _store.setOpen(false);
|
||||
_store.setMarkData(undefined);
|
||||
}
|
||||
} else if (data.id === id) {
|
||||
toast.success('已选择当前画布');
|
||||
}
|
||||
console.log('onClick', data, id);
|
||||
}}>
|
||||
<div className='h-full grow'>
|
||||
<DrawWrapper id={id} setId={setId} />
|
||||
</div>
|
||||
</Manager>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const DrawWrapper = (props: { id?: string; setId: (id: string) => void }) => {
|
||||
const { id, setId } = props;
|
||||
const store = useManagerStore(
|
||||
useShallow((state) => {
|
||||
return {
|
||||
currentMarkId: state.currentMarkId,
|
||||
};
|
||||
}),
|
||||
);
|
||||
console.log('DrawApp store', store);
|
||||
return (
|
||||
<>
|
||||
{id ? (
|
||||
<Draw
|
||||
id={id}
|
||||
onClose={() => {
|
||||
setId('');
|
||||
setHistoryState({
|
||||
id: '',
|
||||
});
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className='flex items-center justify-center h-full text-gray-500'> {store.currentMarkId ? '' : '请先选择一个画布'} </div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
21
src/apps/draw/Bootstrap.tsx
Normal file
21
src/apps/draw/Bootstrap.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { App } from './App.tsx';
|
||||
import { ToastContainer } from 'react-toastify';
|
||||
// import { I18NextProvider, initI18n } from '@kevisual/components/translate/index.tsx';
|
||||
|
||||
export const Bootstrap = (element: HTMLElement) => {
|
||||
createRoot(element).render(
|
||||
<>
|
||||
{/* <I18NextProvider basename={'/root/locales'} noUse={false}> */}
|
||||
<App />
|
||||
{/* </I18NextProvider> */}
|
||||
<ToastContainer />
|
||||
</>,
|
||||
);
|
||||
};
|
||||
type RenderProps = {
|
||||
renderRoot: HTMLElement;
|
||||
};
|
||||
export const render = ({ renderRoot }: RenderProps) => {
|
||||
Bootstrap(renderRoot);
|
||||
};
|
||||
5902
src/apps/draw/assets/excalidraw.css
Normal file
5902
src/apps/draw/assets/excalidraw.css
Normal file
File diff suppressed because it is too large
Load Diff
4
src/apps/draw/index.css
Normal file
4
src/apps/draw/index.css
Normal file
@@ -0,0 +1,4 @@
|
||||
@import 'tailwindcss';
|
||||
/* @import '@excalidraw/excalidraw/index.css'; */
|
||||
/* @import './assets/excalidraw.css'; */
|
||||
|
||||
8
src/apps/draw/libs.ts
Normal file
8
src/apps/draw/libs.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Bootstrap } from './Bootstrap';
|
||||
|
||||
type RenderProps = {
|
||||
renderRoot: HTMLElement;
|
||||
};
|
||||
export const render = ({ renderRoot }: RenderProps) => {
|
||||
Bootstrap(renderRoot);
|
||||
};
|
||||
4
src/apps/draw/main.tsx
Normal file
4
src/apps/draw/main.tsx
Normal file
@@ -0,0 +1,4 @@
|
||||
import { render } from './Bootstrap';
|
||||
import './index.css';
|
||||
// import '@excalidraw/excalidraw/index.css';
|
||||
render({ renderRoot: document.getElementById('root')! });
|
||||
32
src/apps/draw/modules/hash-file.ts
Normal file
32
src/apps/draw/modules/hash-file.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import MD5 from 'crypto-js/md5';
|
||||
export const hashFile = (file: File): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = async (event) => {
|
||||
try {
|
||||
const content = event.target?.result;
|
||||
if (content instanceof ArrayBuffer) {
|
||||
const contentString = new TextDecoder().decode(content);
|
||||
const hashHex = MD5(contentString).toString();
|
||||
resolve(hashHex);
|
||||
} else if (typeof content === 'string') {
|
||||
const hashHex = MD5(content).toString();
|
||||
resolve(hashHex);
|
||||
} else {
|
||||
throw new Error('Invalid content type');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('hashFile error', error);
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
|
||||
reader.onerror = (error) => {
|
||||
reject(error);
|
||||
};
|
||||
|
||||
// 读取文件为 ArrayBuffer
|
||||
reader.readAsArrayBuffer(file);
|
||||
});
|
||||
};
|
||||
7
src/apps/draw/modules/query.ts
Normal file
7
src/apps/draw/modules/query.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { query } from '@/modules/query';
|
||||
import { QueryMark } from '@/query/query-mark/query-mark';
|
||||
|
||||
export const queryMark = new QueryMark({
|
||||
query: query,
|
||||
markType: 'excalidraw',
|
||||
});
|
||||
112
src/apps/draw/modules/to-file.ts
Normal file
112
src/apps/draw/modules/to-file.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
const getFileExtension = (filename: string) => {
|
||||
return filename.split('.').pop();
|
||||
};
|
||||
const getFileType = (extension: string) => {
|
||||
switch (extension) {
|
||||
case 'js':
|
||||
return 'text/javascript';
|
||||
case 'css':
|
||||
return 'text/css';
|
||||
case 'html':
|
||||
return 'text/html';
|
||||
case 'json':
|
||||
return 'application/json';
|
||||
case 'png':
|
||||
return 'image/png';
|
||||
case 'jpg':
|
||||
return 'image/jpeg';
|
||||
case 'jpeg':
|
||||
return 'image/jpeg';
|
||||
case 'gif':
|
||||
return 'image/gif';
|
||||
case 'svg':
|
||||
return 'image/svg+xml';
|
||||
case 'webp':
|
||||
return 'image/webp';
|
||||
case 'ico':
|
||||
return 'image/x-icon';
|
||||
default:
|
||||
return 'text/plain';
|
||||
}
|
||||
};
|
||||
const checkIsBase64 = (content: string) => {
|
||||
return content.startsWith('data:');
|
||||
};
|
||||
/**
|
||||
* 获取文件的目录和文件名
|
||||
* @param filename 文件名
|
||||
* @returns 目录和文件名
|
||||
*/
|
||||
export const getDirectoryAndName = (filename: string) => {
|
||||
if (!filename) {
|
||||
return null;
|
||||
}
|
||||
if (filename.startsWith('.')) {
|
||||
return null;
|
||||
} else {
|
||||
filename = filename.replace(/^\/+/, ''); // Remove all leading slashes
|
||||
}
|
||||
const hasDirectory = filename.includes('/');
|
||||
if (!hasDirectory) {
|
||||
return { directory: '', name: filename };
|
||||
}
|
||||
const parts = filename.split('/');
|
||||
const name = parts.pop()!; // Get the last part as the file name
|
||||
const directory = parts.join('/'); // Join the remaining parts as the directory
|
||||
return { directory, name };
|
||||
};
|
||||
/**
|
||||
* 把字符串转为文件流,并返回文件流,根据filename的扩展名,自动设置文件类型.
|
||||
* 当不是文本类型,自动需要把base64的字符串转为blob
|
||||
* @param content 字符串
|
||||
* @param filename 文件名
|
||||
* @returns 文件流
|
||||
*/
|
||||
export const toFile = (content: string, filename: string) => {
|
||||
// 如果文件名是 a/d/a.js 格式的,则需要把d作为目录,a.js作为文件名
|
||||
const directoryAndName = getDirectoryAndName(filename);
|
||||
if (!directoryAndName) {
|
||||
throw new Error('Invalid filename');
|
||||
}
|
||||
const { name } = directoryAndName;
|
||||
const extension = getFileExtension(name);
|
||||
if (!extension) {
|
||||
throw new Error('Invalid filename');
|
||||
}
|
||||
const isBase64 = checkIsBase64(content);
|
||||
const type = getFileType(extension);
|
||||
|
||||
if (isBase64) {
|
||||
// Decode base64 string
|
||||
let base64Data = content.split(',')[1]; // Remove the data URL prefix
|
||||
const byteCharacters = atob(base64Data);
|
||||
const byteNumbers = new Array(byteCharacters.length);
|
||||
for (let i = 0; i < byteCharacters.length; i++) {
|
||||
byteNumbers[i] = byteCharacters.charCodeAt(i);
|
||||
}
|
||||
const byteArray = new Uint8Array(byteNumbers);
|
||||
const blob = new Blob([byteArray], { type });
|
||||
return new File([blob], filename, { type });
|
||||
} else {
|
||||
const blob = new Blob([content], { type });
|
||||
return new File([blob], filename, { type });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 把字符串转为文本文件
|
||||
* @param content 字符串
|
||||
* @param filename 文件名
|
||||
* @returns 文件流
|
||||
*/
|
||||
export const toTextFile = (content: string = 'keep directory exist', filename: string = 'keep.txt') => {
|
||||
const file = toFile(content, filename);
|
||||
return file;
|
||||
};
|
||||
|
||||
export const downloadFile = (file: File) => {
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(file);
|
||||
a.download = file.name;
|
||||
a.click();
|
||||
};
|
||||
57
src/apps/draw/modules/upload.ts
Normal file
57
src/apps/draw/modules/upload.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
// import NProgress from 'nprogress';
|
||||
import { Id, toast } from 'react-toastify';
|
||||
import { toastLogin } from '@/modules/toast/ToastLogin';
|
||||
import { uploadFileChunked, UploadProgress } from '@/query/query-upload/query-upload';
|
||||
|
||||
export type ConvertOpts = {
|
||||
appKey?: string;
|
||||
version?: string;
|
||||
username?: string;
|
||||
directory?: string;
|
||||
isPublic?: boolean;
|
||||
filename?: string;
|
||||
/**
|
||||
* 是否不检查应用文件, 默认 true,默认不检测
|
||||
*/
|
||||
noCheckAppFiles?: boolean;
|
||||
};
|
||||
|
||||
export const uploadChunkV2 = async (file: File, opts: ConvertOpts) => {
|
||||
const filename = opts.filename || file.name;
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) {
|
||||
console.log('uploadChunk token', token);
|
||||
toastLogin();
|
||||
return;
|
||||
}
|
||||
let loaded: Id;
|
||||
const uploadProgress = new UploadProgress({
|
||||
onStart: function () {
|
||||
// NProgress.start();
|
||||
loaded = toast.loading(`${filename} 上传中...`);
|
||||
},
|
||||
onDone: () => {
|
||||
// NProgress.done();
|
||||
toast.dismiss(loaded);
|
||||
},
|
||||
onProgress: (progress, data) => {
|
||||
// NProgress.set(progress);
|
||||
// console.log('uploadChunk progress', progress, data);
|
||||
toast.update(loaded, {
|
||||
render: `${filename} 上传中... ${progress.toFixed(2)}%`,
|
||||
isLoading: true,
|
||||
autoClose: false,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const result = await uploadFileChunked(file, opts, {
|
||||
uploadProgress,
|
||||
token,
|
||||
createEventSource: (url: string, searchParams: URLSearchParams) => {
|
||||
return new EventSource(url + '?' + searchParams.toString());
|
||||
},
|
||||
FormDataFn: FormData,
|
||||
});
|
||||
return result;
|
||||
};
|
||||
41
src/apps/draw/pages/Draw.tsx
Normal file
41
src/apps/draw/pages/Draw.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createMarkStore, store, useMarkStore } from '../store';
|
||||
console.log('store', store);
|
||||
import { StoreContextProvider } from '@kevisual/store/react';
|
||||
import { LineChart } from 'lucide-react';
|
||||
import { useShallow } from 'zustand/shallow';
|
||||
import { Core } from './core/Excalidraw';
|
||||
|
||||
export const Draw = ({ id, onClose }: { id: string; onClose: () => void }) => {
|
||||
useLayoutEffect(() => {
|
||||
// @ts-ignore
|
||||
window.EXCALIDRAW_ASSET_PATH = 'https://esm.sh/@excalidraw/excalidraw@0.18.0/dist/prod/';
|
||||
// window.EXCALIDRAW_ASSET_PATH = '/';
|
||||
}, []);
|
||||
return (
|
||||
<StoreContextProvider id={id} stateCreator={createMarkStore}>
|
||||
<ExcaliDrawComponent id={id} onClose={onClose} />
|
||||
</StoreContextProvider>
|
||||
);
|
||||
};
|
||||
|
||||
type ExcaliDrawComponentProps = {
|
||||
/** 修改的id */
|
||||
id: string;
|
||||
/** 关闭 */
|
||||
onClose: () => void;
|
||||
};
|
||||
export const ExcaliDrawComponent = ({ id, onClose }: ExcaliDrawComponentProps) => {
|
||||
const store = useMarkStore(
|
||||
useShallow((state) => {
|
||||
return {
|
||||
id: state.id,
|
||||
setId: state.setId,
|
||||
getMark: state.getMark,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const memo = useMemo(() => <Core onClose={onClose} id={id} />, [id, onClose]);
|
||||
return <>{memo}</>;
|
||||
};
|
||||
284
src/apps/draw/pages/core/Excalidraw.tsx
Normal file
284
src/apps/draw/pages/core/Excalidraw.tsx
Normal file
@@ -0,0 +1,284 @@
|
||||
import { Excalidraw } from '@excalidraw/excalidraw';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { BinaryFileData, ExcalidrawImperativeAPI } from '@excalidraw/excalidraw/types';
|
||||
import { Languages, LogOut, Save } from 'lucide-react';
|
||||
import { MainMenu, Sidebar, Footer } from '@excalidraw/excalidraw';
|
||||
import { throttle } from 'lodash-es';
|
||||
import { useMarkStore, store as StoreManager } from '../../store';
|
||||
import { useShallow } from 'zustand/shallow';
|
||||
import { useListenLang } from './hooks/listen-lang';
|
||||
import { toast } from 'react-toastify';
|
||||
import { hashFile } from '../../modules/hash-file';
|
||||
import { uploadChunkV2 } from '../../modules/upload';
|
||||
import { downloadFile, toFile } from '../../modules/to-file';
|
||||
|
||||
type ImageResource = {};
|
||||
export const ImagesResources = (props: ImageResource) => {
|
||||
const [images, setImages] = useState<string[]>([]);
|
||||
useEffect(() => {
|
||||
initImages();
|
||||
}, []);
|
||||
const refFiles = useRef<any>({});
|
||||
const { api } = useMarkStore(
|
||||
useShallow((state) => {
|
||||
return {
|
||||
api: state.api,
|
||||
};
|
||||
}),
|
||||
);
|
||||
const initImages = async () => {
|
||||
if (!api) return;
|
||||
const res = api.getFiles();
|
||||
console.log('res from ImageResource', res);
|
||||
setImages(Object.keys(res));
|
||||
refFiles.current = res;
|
||||
};
|
||||
return (
|
||||
<div className='w-full h-full'>
|
||||
{images.map((image) => {
|
||||
const isUrl = refFiles.current[image]?.dataURL?.startsWith?.('http');
|
||||
const dataURL = refFiles.current[image]?.dataURL;
|
||||
return (
|
||||
<div className='flex items-center gap-2 w-full overflow-hidden p-2 m-2 border border-gray-200 rounded-md shadow ' key={image}>
|
||||
<img className='w-10 h-10 m-4' src={dataURL} alt='image' />
|
||||
<div className='flex flex-col gap-1'>
|
||||
{isUrl && <div className='text-xs line-clamp-4 break-all'> {dataURL}</div>}
|
||||
<div className='text-xs'>{refFiles.current[image]?.name}</div>
|
||||
<div className='text-xs'>{refFiles.current[image]?.type}</div>
|
||||
<div className='text-xs'>{refFiles.current[image]?.id}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ExcalidrawExpand = (props: { onClose: () => void }) => {
|
||||
const { onClose } = props;
|
||||
const [docked, setDocked] = useState(false);
|
||||
const store = useMarkStore(
|
||||
useShallow((state) => {
|
||||
return {
|
||||
loading: state.loading,
|
||||
updateMark: state.updateMark,
|
||||
api: state.api,
|
||||
};
|
||||
}),
|
||||
);
|
||||
const { lang, setLang, isZh } = useListenLang();
|
||||
return (
|
||||
<>
|
||||
{store.loading && (
|
||||
<div className='w-full h-full flex items-center justify-center absolute top-0 left-0 z-10'>
|
||||
<div className='w-full h-full bg-black opacity-10 absolute top-0 left-0 z-1'></div>
|
||||
</div>
|
||||
)}
|
||||
<MainMenu>
|
||||
<MainMenu.Item
|
||||
onSelect={() => {
|
||||
store.updateMark();
|
||||
}}>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Save />
|
||||
<span>{isZh ? '保存' : 'Save'}</span>
|
||||
</div>
|
||||
</MainMenu.Item>
|
||||
<MainMenu.DefaultItems.LoadScene />
|
||||
<MainMenu.DefaultItems.Export />
|
||||
<MainMenu.DefaultItems.SaveToActiveFile />
|
||||
<MainMenu.DefaultItems.SaveAsImage />
|
||||
{/* <MainMenu.DefaultItems.LiveCollaborationTrigger onSelect={() => {}} isCollaborating={false} /> */}
|
||||
{/* <MainMenu.DefaultItems.CommandPalette /> */}
|
||||
<MainMenu.DefaultItems.SearchMenu />
|
||||
<MainMenu.DefaultItems.Help />
|
||||
<MainMenu.DefaultItems.ClearCanvas />
|
||||
<MainMenu.Separator />
|
||||
<MainMenu.DefaultItems.ChangeCanvasBackground />
|
||||
<MainMenu.Separator />
|
||||
<MainMenu.DefaultItems.ToggleTheme />
|
||||
<MainMenu.Separator />
|
||||
|
||||
<MainMenu.Item
|
||||
onSelect={(e) => {
|
||||
const newLang = lang === 'zh-CN' ? 'en' : 'zh-CN';
|
||||
setLang(newLang);
|
||||
}}>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Languages />
|
||||
<span>{isZh ? 'English' : '中文'}</span>
|
||||
</div>
|
||||
</MainMenu.Item>
|
||||
{onClose && (
|
||||
<>
|
||||
<MainMenu.Separator />
|
||||
<MainMenu.Item onSelect={onClose}>
|
||||
<div className='flex items-center gap-2'>
|
||||
<LogOut />
|
||||
<span>{isZh ? '退出当前画布' : 'Exit current canvas'}</span>
|
||||
</div>
|
||||
</MainMenu.Item>
|
||||
</>
|
||||
)}
|
||||
</MainMenu>
|
||||
<Sidebar name='custom' docked={docked} onDock={setDocked}>
|
||||
<Sidebar.Header />
|
||||
<Sidebar.Tabs>
|
||||
<Sidebar.Tab tab='one'>{<ImagesResources />}</Sidebar.Tab>
|
||||
<Sidebar.Tab tab='two'>Tab two!</Sidebar.Tab>
|
||||
<Sidebar.TabTriggers>
|
||||
<Sidebar.TabTrigger tab='one'>Image Resources</Sidebar.TabTrigger>
|
||||
<Sidebar.TabTrigger tab='two'>Two</Sidebar.TabTrigger>
|
||||
</Sidebar.TabTriggers>
|
||||
</Sidebar.Tabs>
|
||||
</Sidebar>
|
||||
|
||||
<Footer>
|
||||
<Sidebar.Trigger
|
||||
name='custom'
|
||||
tab='one'
|
||||
style={{
|
||||
marginLeft: '0.5rem',
|
||||
background: '#70b1ec',
|
||||
color: 'white',
|
||||
}}>
|
||||
{isZh ? '图片资源' : 'Image Resources'}
|
||||
</Sidebar.Trigger>
|
||||
</Footer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
type CoreProps = {
|
||||
onClose: () => void;
|
||||
id: string;
|
||||
};
|
||||
export const Core = ({ onClose, id }: CoreProps) => {
|
||||
const ref = useRef<ExcalidrawImperativeAPI>(null);
|
||||
const { lang } = useListenLang();
|
||||
const uploadLoadingRef = useRef<boolean>(false);
|
||||
const store = useMarkStore(
|
||||
useShallow((state) => {
|
||||
return {
|
||||
id: state.id,
|
||||
loading: state.loading,
|
||||
getMark: state.getMark,
|
||||
api: state.api,
|
||||
setApi: state.setApi,
|
||||
getCache: state.getCache,
|
||||
updateMark: state.updateMark,
|
||||
};
|
||||
}),
|
||||
);
|
||||
const cacheDataRef = useRef<{
|
||||
elements: { [key: string]: number };
|
||||
filesObject: Record<string, any>;
|
||||
}>({
|
||||
elements: {},
|
||||
filesObject: {},
|
||||
});
|
||||
useEffect(() => {
|
||||
id && store.getMark(id);
|
||||
}, [id]);
|
||||
const onSave = throttle(async (elements, appState, filesObject) => {
|
||||
const { elements: cacheElements, filesObject: cacheFiles } = cacheDataRef.current;
|
||||
const _store = StoreManager.getStore(id);
|
||||
if (!_store) {
|
||||
console.error('Store not found for id:', id);
|
||||
return;
|
||||
}
|
||||
const { setCache, loading } = _store.getState();
|
||||
if (loading) {
|
||||
return;
|
||||
}
|
||||
if (uploadLoadingRef.current) return;
|
||||
let isChange = false;
|
||||
const elementsObj = elements.reduce((acc, e) => {
|
||||
acc[e.id] = e.version;
|
||||
return acc;
|
||||
}, {});
|
||||
if (JSON.stringify(elementsObj) !== JSON.stringify(cacheElements)) {
|
||||
isChange = true;
|
||||
}
|
||||
if (JSON.stringify(cacheFiles) !== JSON.stringify(filesObject)) {
|
||||
isChange = true;
|
||||
const files = Object.values(filesObject) as any as BinaryFileData[];
|
||||
uploadLoadingRef.current = true;
|
||||
for (const file of files) {
|
||||
if (file.dataURL.startsWith('data')) {
|
||||
const _file = toFile(file.dataURL, file.id);
|
||||
const res = (await uploadChunkV2(_file, {
|
||||
filename: file.id,
|
||||
directory: id,
|
||||
})) as any;
|
||||
if (res.code === 200) {
|
||||
toast.success('上传图片成功');
|
||||
} else {
|
||||
toast.error('上传图片失败');
|
||||
}
|
||||
|
||||
const [upload] = res.data?.upload || [];
|
||||
if (upload) {
|
||||
filesObject[file.id] = {
|
||||
...filesObject[file.id],
|
||||
dataURL: upload.path,
|
||||
};
|
||||
} else {
|
||||
toast.error('上传图片失败');
|
||||
}
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
uploadLoadingRef.current = false;
|
||||
}
|
||||
console.log('onSave', elements, appState, filesObject, 'isChange', isChange);
|
||||
if (!isChange) {
|
||||
return;
|
||||
}
|
||||
cacheDataRef.current = { elements: elementsObj, filesObject };
|
||||
setCache({
|
||||
data: {
|
||||
elements,
|
||||
filesObject,
|
||||
},
|
||||
});
|
||||
}, 2000);
|
||||
return (
|
||||
<Excalidraw
|
||||
initialData={{}}
|
||||
onChange={(elements, appState, filesObject) => {
|
||||
if (store.loading) return;
|
||||
onSave(elements, appState, filesObject);
|
||||
}}
|
||||
langCode={lang || 'zh-CN'}
|
||||
renderTopRightUI={() => {
|
||||
return <div></div>;
|
||||
}}
|
||||
excalidrawAPI={async (api) => {
|
||||
ref.current = api;
|
||||
store.setApi(api);
|
||||
const cache = await store.getCache(id, true);
|
||||
if (!cache) return;
|
||||
const elementsObj = cache.elements.reduce((acc, e) => {
|
||||
acc[e.id] = e.version;
|
||||
return acc;
|
||||
}, {});
|
||||
cacheDataRef.current = {
|
||||
elements: elementsObj,
|
||||
filesObject: cache.filesObject,
|
||||
};
|
||||
}}
|
||||
generateIdForFile={async (file) => {
|
||||
// return dayjs().format('YYYY-MM-DD-HH-mm-ss') + '.' + file.type.split('/')[1];
|
||||
const hash = await hashFile(file);
|
||||
console.log('hash', hash, 'filetype', file.type);
|
||||
const fileId = hash + '.' + file.type.split('/')[1];
|
||||
console.log('fileId', fileId);
|
||||
|
||||
return fileId;
|
||||
}}>
|
||||
<ExcalidrawExpand onClose={onClose} />
|
||||
</Excalidraw>
|
||||
);
|
||||
};
|
||||
29
src/apps/draw/pages/core/hooks/listen-lang.ts
Normal file
29
src/apps/draw/pages/core/hooks/listen-lang.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export const useListenLang = () => {
|
||||
const [lang, setLang] = useState('zh-CN');
|
||||
useEffect(() => {
|
||||
const lang = localStorage.getItem('excalidrawLang');
|
||||
if (lang) {
|
||||
setLang(lang);
|
||||
}
|
||||
// 监听 localStorage中excalidrawLang的变化
|
||||
const onStorage = (e: StorageEvent) => {
|
||||
if (e.key === 'excalidrawLang') {
|
||||
e.newValue && setLang(e.newValue);
|
||||
}
|
||||
};
|
||||
window.addEventListener('storage', onStorage);
|
||||
return () => {
|
||||
window.removeEventListener('storage', onStorage);
|
||||
};
|
||||
}, []);
|
||||
return {
|
||||
lang,
|
||||
setLang: (lang: string) => {
|
||||
// setLang(lang);
|
||||
localStorage.setItem('excalidrawLang', lang);
|
||||
},
|
||||
isZh: lang === 'zh-CN',
|
||||
};
|
||||
};
|
||||
30
src/apps/draw/pages/core/hooks/listen-library.ts
Normal file
30
src/apps/draw/pages/core/hooks/listen-library.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export const useListenLibrary = () => {
|
||||
useEffect(() => {
|
||||
addLibraryItem();
|
||||
}, []);
|
||||
|
||||
const addLibraryItem = async () => {
|
||||
const hash = window.location.hash; // 获取哈希值
|
||||
const addLibrary = hash.split('addLibrary=')[1];
|
||||
if (!addLibrary || addLibrary === 'undefined') {
|
||||
return;
|
||||
}
|
||||
const token = hash.split('token=')[1];
|
||||
console.log('addLibrary', addLibrary, token);
|
||||
const _fetchURL = decodeURIComponent(addLibrary);
|
||||
const fetchURL = _fetchURL.split('&')[0];
|
||||
|
||||
console.log('fetchURL', fetchURL);
|
||||
const res = await fetch(fetchURL, {
|
||||
method: 'GET',
|
||||
mode: 'cors',
|
||||
});
|
||||
const data = await res.json();
|
||||
console.log('data', data);
|
||||
};
|
||||
return {
|
||||
addLibraryItem,
|
||||
};
|
||||
};
|
||||
215
src/apps/draw/store/index.ts
Normal file
215
src/apps/draw/store/index.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
import { StoreManager } from '@kevisual/store';
|
||||
import { useContextKey } from '@kevisual/store/context';
|
||||
import { StateCreator, StoreApi, UseBoundStore } from 'zustand';
|
||||
import { queryMark } from '../modules/query';
|
||||
import { useStore, BoundStore } from '@kevisual/store/react';
|
||||
import { createStore, set as setCache, get as getCache } from 'idb-keyval';
|
||||
import { OrderedExcalidrawElement } from '@excalidraw/excalidraw/element/types';
|
||||
import { toast } from 'react-toastify';
|
||||
import { BinaryFileData, ExcalidrawImperativeAPI } from '@excalidraw/excalidraw/types';
|
||||
export const cacheStore = createStore('excalidraw-store', 'excalidraw');
|
||||
|
||||
export const store = useContextKey('store', () => {
|
||||
return new StoreManager();
|
||||
});
|
||||
|
||||
type MarkStore = {
|
||||
id: string;
|
||||
setId: (id: string) => void;
|
||||
mark: any;
|
||||
setMark: (mark: any) => void;
|
||||
info: any;
|
||||
setInfo: (info: any) => void;
|
||||
getList: () => Promise<void>;
|
||||
list: any[];
|
||||
setList: (list: any[]) => void;
|
||||
getMark: (markId: string) => Promise<void>;
|
||||
updateMark: () => Promise<void>;
|
||||
getCache: (
|
||||
id: string,
|
||||
updateApiData?: boolean,
|
||||
) => Promise<
|
||||
| {
|
||||
elements: OrderedExcalidrawElement[];
|
||||
filesObject: Record<string, any>;
|
||||
}
|
||||
| undefined
|
||||
>;
|
||||
setCache: (cache: any, version?: number) => Promise<void>;
|
||||
loading: boolean;
|
||||
setLoading: (loading: boolean) => void;
|
||||
// excalidraw
|
||||
|
||||
api: ExcalidrawImperativeAPI | null;
|
||||
setApi: (api: ExcalidrawImperativeAPI) => void;
|
||||
};
|
||||
export const createMarkStore: StateCreator<MarkStore, [], [], MarkStore> = (set, get, store) => {
|
||||
return {
|
||||
id: '',
|
||||
setId: (id: string) => set(() => ({ id })),
|
||||
mark: null,
|
||||
setMark: (mark: any) => set(() => ({ mark })),
|
||||
loading: true,
|
||||
setLoading: (loading: boolean) => set(() => ({ loading })),
|
||||
info: null,
|
||||
setCache: async (cache: any, version?: number) => {
|
||||
const { id, mark } = get();
|
||||
console.log('cacheData setCache ,id', cache, id);
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
const cacheData = (await getCache(`${id}`, cacheStore)) || {};
|
||||
await setCache(
|
||||
`${id}`,
|
||||
{
|
||||
...cacheData,
|
||||
...cache,
|
||||
data: {
|
||||
...cacheData?.data,
|
||||
...cache?.data,
|
||||
},
|
||||
version: version || mark?.version || 0,
|
||||
},
|
||||
cacheStore,
|
||||
);
|
||||
},
|
||||
updateMark: async () => {
|
||||
const { id } = get();
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cacheData = await getCache(id, cacheStore);
|
||||
let mark = cacheData || {};
|
||||
if (!mark) {
|
||||
return;
|
||||
}
|
||||
const { data } = mark;
|
||||
const { elements, filesObject } = data;
|
||||
console.log('updateMark', elements, filesObject);
|
||||
const res = await queryMark.updateMark({ id, data });
|
||||
if (res.code === 200) {
|
||||
set(() => ({ mark: res.data }));
|
||||
toast.success('更新成功');
|
||||
get().setCache({}, res.data!.version);
|
||||
}
|
||||
},
|
||||
getCache: async (id: string, updateApiData?: boolean) => {
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
// 获取缓存
|
||||
let cacheData = (await getCache(`${id}`, cacheStore)) || { data: { elements: [], filesObject: {} } };
|
||||
console.log('getCache', id, cacheData);
|
||||
if (cacheData) {
|
||||
if (updateApiData) {
|
||||
const api = get().api;
|
||||
if (api) {
|
||||
const files = Object.values(cacheData.data.filesObject || {}) as BinaryFileData[];
|
||||
api.addFiles(files || []);
|
||||
api.updateScene({
|
||||
elements: [...(cacheData.data?.elements || [])],
|
||||
appState: {},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
elements: cacheData.data.elements || [],
|
||||
filesObject: cacheData.data.filesObject || {},
|
||||
};
|
||||
},
|
||||
setInfo: (info: any) => set(() => ({ info })),
|
||||
getList: async () => {
|
||||
const res = await queryMark.getMarkList({ page: 1, pageSize: 10 });
|
||||
console.log(res);
|
||||
},
|
||||
list: [],
|
||||
setList: (list: any[]) => set(() => ({ list })),
|
||||
getMark: async (markId: string) => {
|
||||
set(() => ({ loading: true, id: markId }));
|
||||
const toastId = toast.loading(`获取数据中...`);
|
||||
const now = new Date().getTime();
|
||||
const cacheData = await getCache(markId, cacheStore);
|
||||
const checkVersion = await queryMark.checkVersion(markId, cacheData?.version);
|
||||
if (checkVersion) {
|
||||
const res = await queryMark.getMark(markId);
|
||||
if (res.code === 200) {
|
||||
set(() => ({
|
||||
mark: res.data,
|
||||
id: markId,
|
||||
}));
|
||||
const mark = res.data!;
|
||||
const excalidrawData = mark.data || {};
|
||||
await get().setCache({
|
||||
data: {
|
||||
elements: excalidrawData.elements,
|
||||
filesObject: excalidrawData.filesObject,
|
||||
},
|
||||
version: mark.version,
|
||||
});
|
||||
get().getCache(markId, true);
|
||||
} else {
|
||||
toast.error(res.message || '获取数据失败');
|
||||
}
|
||||
}
|
||||
|
||||
const end = new Date().getTime();
|
||||
const getTime = end - now;
|
||||
if (getTime < 2 * 1000) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 2 * 1000 - getTime));
|
||||
}
|
||||
toast.dismiss(toastId);
|
||||
set(() => ({ loading: false }));
|
||||
},
|
||||
api: null,
|
||||
setApi: (api: ExcalidrawImperativeAPI) => set(() => ({ api })),
|
||||
};
|
||||
};
|
||||
|
||||
export const useMarkStore = useStore as BoundStore<MarkStore>;
|
||||
|
||||
export const fileDemo = {
|
||||
abc: {
|
||||
dataURL: 'https://kevisual.xiongxiao.me/root/center/panda.png' as any,
|
||||
// @ts-ignore
|
||||
id: 'abc',
|
||||
name: 'test2.png',
|
||||
type: 'image/png',
|
||||
},
|
||||
};
|
||||
export const demoElements: OrderedExcalidrawElement[] = [
|
||||
{
|
||||
id: '1',
|
||||
type: 'image',
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 100,
|
||||
height: 100,
|
||||
fileId: 'abc' as any,
|
||||
version: 2,
|
||||
versionNonce: 28180243,
|
||||
index: 'a0' as any,
|
||||
isDeleted: false,
|
||||
fillStyle: 'solid',
|
||||
strokeWidth: 2,
|
||||
strokeStyle: 'solid',
|
||||
roughness: 1,
|
||||
opacity: 100,
|
||||
angle: 0,
|
||||
strokeColor: '#1e1e1e',
|
||||
backgroundColor: 'transparent',
|
||||
seed: 1,
|
||||
groupIds: [],
|
||||
frameId: null,
|
||||
roundness: null,
|
||||
boundElements: [],
|
||||
updated: 1743219351869,
|
||||
link: null,
|
||||
locked: false,
|
||||
status: 'pending',
|
||||
scale: [1, 1],
|
||||
crop: null,
|
||||
},
|
||||
];
|
||||
6
src/apps/draw/vite-env.d.ts
vendored
Normal file
6
src/apps/draw/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
/// <reference types="vite/client" />
|
||||
type SimpleObject = {
|
||||
[key: string | number]: any;
|
||||
};
|
||||
|
||||
declare let BASE_NAME: string;
|
||||
331
src/apps/mark/manager/Manager.tsx
Normal file
331
src/apps/mark/manager/Manager.tsx
Normal file
@@ -0,0 +1,331 @@
|
||||
import { useManagerStore } from './store';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useShallow } from 'zustand/shallow';
|
||||
import { ManagerProvider } from './Provider';
|
||||
import { ChevronDown, X, Edit, Plus, Search, Trash, Menu as MenuIcon, MenuSquare } from 'lucide-react';
|
||||
import dayjs from 'dayjs';
|
||||
import { EditMark as EditMarkComponent } from './edit/Edit';
|
||||
import { toast } from 'react-toastify';
|
||||
import clsx from 'clsx';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { IconButton } from '@/components/a/button';
|
||||
import { MarkType } from '@/query/query-mark/query-mark';
|
||||
import { Menu } from '@/components/a/menu';
|
||||
import { MarkTypes } from './constant';
|
||||
type ManagerProps = {
|
||||
showSearch?: boolean;
|
||||
showAdd?: boolean;
|
||||
onClick?: (data?: any, e?: Event) => void;
|
||||
markType?: MarkType;
|
||||
showSelect?: boolean;
|
||||
};
|
||||
export { useManagerStore };
|
||||
export const Manager = (props: ManagerProps) => {
|
||||
const { showSearch = true, showAdd = false, onClick, showSelect = true } = props;
|
||||
|
||||
const { control } = useForm({ defaultValues: { search: '' } });
|
||||
const { list, init, setCurrentMarkId, currentMarkId, markData, deleteMark, getMark, setMarkData, pagination, setPagination, getList, search, setSearch } =
|
||||
useManagerStore(
|
||||
useShallow((state) => {
|
||||
return {
|
||||
list: state.list,
|
||||
init: state.init,
|
||||
markData: state.markData,
|
||||
currentMarkId: state.currentMarkId,
|
||||
setCurrentMarkId: state.setCurrentMarkId,
|
||||
deleteMark: state.deleteMark,
|
||||
getMark: state.getMark,
|
||||
setMarkData: state.setMarkData,
|
||||
pagination: state.pagination,
|
||||
setPagination: state.setPagination,
|
||||
search: state.search,
|
||||
setSearch: state.setSearch,
|
||||
getList: state.getList,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const handleMenuItemClick = (option: string) => {
|
||||
console.log('option', option);
|
||||
init(option as any);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const url = new URL(window.location.href);
|
||||
let markType = url.searchParams.get('markType') || '';
|
||||
if (!markType && props.markType) {
|
||||
markType = props.markType;
|
||||
}
|
||||
init((markType as any) || 'md');
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (search) {
|
||||
getList();
|
||||
} else if (pagination.current > 1) {
|
||||
getList();
|
||||
}
|
||||
}, [pagination.current, search]);
|
||||
const onEditMark = async (markId: string) => {
|
||||
setCurrentMarkId(markId);
|
||||
const res = await getMark(markId);
|
||||
console.log('mark', res);
|
||||
if (res.code === 200) {
|
||||
setMarkData(res.data!);
|
||||
}
|
||||
};
|
||||
const onDeleteMark = async (markId: string) => {
|
||||
const res = await deleteMark(markId);
|
||||
if (res.code === 200) {
|
||||
toast.success('删除成功');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='w-full h-full p-4 bg-white border-r border-r-gray-200 relative'>
|
||||
<div className='flex px-4 mb-4 justify-between items-center absolute top-0 left-0 h-[56px] w-full'>
|
||||
<div className='flex ml-12 items-center space-x-2'>
|
||||
<Controller
|
||||
name='search'
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<div className={`relative ${showSearch ? 'block' : 'hidden'}`}>
|
||||
<input
|
||||
{...field}
|
||||
type='text'
|
||||
className='py-2 px-3 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500'
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
setSearch(field.value);
|
||||
if (!field.value) {
|
||||
getList();
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className='absolute inset-y-0 right-0 flex items-center pr-3 cursor-pointer'>
|
||||
<Search className='w-4 h-4' onClick={() => setSearch(field.value)} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className={'flex items-center space-x-2'}>
|
||||
{showSelect && (
|
||||
<>
|
||||
<Menu
|
||||
options={MarkTypes.map((item) => {
|
||||
return { label: item, value: item };
|
||||
})}
|
||||
onSelect={handleMenuItemClick}>
|
||||
<MenuIcon className='w-4 h-4' />
|
||||
</Menu>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
className={clsx(
|
||||
'text-blue-500 cursor-pointer hover:underline flex items-center p-2 rounded-md hover:bg-blue-100 transition duration-200',
|
||||
showAdd ? '' : 'hidden',
|
||||
)}>
|
||||
<Plus
|
||||
className={clsx('w-4 h-4 ')}
|
||||
onClick={() => {
|
||||
setCurrentMarkId('');
|
||||
|
||||
setMarkData({
|
||||
id: '',
|
||||
title: '',
|
||||
description: '',
|
||||
markType: props.markType || ('md' as any),
|
||||
summary: '',
|
||||
tags: [],
|
||||
link: '',
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
{markData && (
|
||||
<button
|
||||
className='text-blue-500 cursor-pointer hover:underline flex items-center p-2 rounded-md hover:bg-blue-100 transition duration-200'
|
||||
onClick={() => {
|
||||
setCurrentMarkId('');
|
||||
setMarkData(undefined);
|
||||
}}>
|
||||
<X className='w-4 h-4 ' />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className='mt-[56px] overflow-auto scrollbar' style={{ height: 'calc(100% - 56px)' }}>
|
||||
{list.map((item, index) => {
|
||||
const isCurrent = item.id === currentMarkId;
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className={`border rounded-lg p-4 mb-4 shadow-md bg-white border-gray-200 ${isCurrent ? 'border-blue-500' : ''}`}
|
||||
onClick={(e) => {
|
||||
onClick?.(item, e as any);
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}}>
|
||||
<div className='flex justify-between items-center'>
|
||||
<div className={`text-lg font-bold truncate cursor-pointer ${isCurrent ? 'text-blue-500' : ''}`}>{item.title}</div>
|
||||
<div className='flex space-x-2'>
|
||||
<button
|
||||
className='text-blue-500 cursor-pointer hover:underline flex items-center p-2 rounded-md hover:bg-blue-100 transition duration-200'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEditMark(item.id);
|
||||
}}>
|
||||
<Edit className='w-4 h-4 ' />
|
||||
</button>
|
||||
<button
|
||||
className='text-red-500 cursor-pointer hover:underline flex items-center p-2 rounded-md hover:bg-red-100 transition duration-200'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDeleteMark(item.id);
|
||||
}}>
|
||||
<Trash className='w-4 h-4 ' />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className='text-sm text-gray-600'>类型: {item.markType}</div>
|
||||
<div className='text-sm text-gray-600'>概要: {item.summary}</div>
|
||||
<div className='text-sm text-gray-600'>标签: {item.tags?.join?.(', ')}</div>
|
||||
{/* <div className='text-sm text-gray-600 hidden sm:block'>描述: {item.description}</div> */}
|
||||
<div
|
||||
className='text-sm text-gray-600 hidden sm:block truncate'
|
||||
onClick={() => {
|
||||
window.open(item.link, '_blank');
|
||||
}}>
|
||||
链接: {item.link}
|
||||
</div>
|
||||
<div className='text-sm text-gray-600 hidden sm:block'>创建时间: {dayjs(item.createdAt).format('YYYY-MM-DD HH:mm:ss')}</div>
|
||||
<div className='text-sm text-gray-600 hidden sm:block'>更新时间: {dayjs(item.updatedAt).format('YYYY-MM-DD HH:mm:ss')}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className='flex justify-center items-center'>
|
||||
{list.length < pagination.total && (
|
||||
<button
|
||||
className='text-blue-500 cursor-pointer hover:underline flex items-center p-2 rounded-md hover:bg-blue-100 transition duration-200'
|
||||
onClick={() => {
|
||||
setPagination({ ...pagination, current: pagination.current + 1 });
|
||||
}}>
|
||||
<ChevronDown className='w-4 h-4 ' />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const EditMark = () => {
|
||||
const { markData } = useManagerStore(
|
||||
useShallow((state) => {
|
||||
return {
|
||||
markData: state.markData,
|
||||
};
|
||||
}),
|
||||
);
|
||||
const mark = markData;
|
||||
if (!mark) {
|
||||
return null;
|
||||
}
|
||||
if (mark) {
|
||||
return <EditMarkComponent />;
|
||||
}
|
||||
return <div className='w-full h-full'></div>;
|
||||
};
|
||||
export const LayoutMain = (props: { children?: React.ReactNode; expandChildren?: React.ReactNode; open?: boolean }) => {
|
||||
const getDocumentHeight = () => {
|
||||
return document.documentElement.scrollHeight;
|
||||
};
|
||||
const mStore = useManagerStore(
|
||||
useShallow((state) => {
|
||||
return {
|
||||
open: state.open,
|
||||
setOpen: state.setOpen,
|
||||
markData: state.markData,
|
||||
};
|
||||
}),
|
||||
);
|
||||
const markData = mStore.markData;
|
||||
const openMenu = mStore.open;
|
||||
const setOpenMenu = mStore.setOpen;
|
||||
useEffect(() => {
|
||||
if (props.open !== undefined) {
|
||||
setOpenMenu!(props.open);
|
||||
}
|
||||
}, []);
|
||||
const isEdit = !!markData;
|
||||
const hasExpandChildren = !!props.expandChildren;
|
||||
const style = useMemo(() => {
|
||||
if (!hasExpandChildren || openMenu) {
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
top: getDocumentHeight() / 2 + 10,
|
||||
};
|
||||
}, [getDocumentHeight, hasExpandChildren, openMenu]);
|
||||
return (
|
||||
<div className='w-full h-full flex'>
|
||||
<div className={clsx('absolute top-4 z-10', openMenu ? 'left-4' : '-left-4')} style={style}>
|
||||
<IconButton
|
||||
color={openMenu ? 'info' : 'primary'}
|
||||
onClick={() => {
|
||||
setOpenMenu(!openMenu);
|
||||
}}>
|
||||
<MenuSquare className='w-4 h-4' />
|
||||
</IconButton>
|
||||
</div>
|
||||
<div className={clsx('h-full w-full sm:w-1/3', openMenu ? '' : 'hidden')}>{props.children}</div>
|
||||
{(!props.expandChildren || isEdit) && (
|
||||
<div className={clsx('h-full hidden sm:block sm:w-2/3', openMenu ? '' : 'hidden')}>
|
||||
<EditMark />
|
||||
</div>
|
||||
)}
|
||||
{props.expandChildren && <div className='h-full grow'>{props.expandChildren}</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export type AppProps = {
|
||||
/**
|
||||
* 标记类型, wallnote md excalidraw
|
||||
*/
|
||||
markType?: MarkType;
|
||||
/**
|
||||
* 是否显示搜索框
|
||||
*/
|
||||
showSearch?: boolean;
|
||||
/**
|
||||
* 是否显示添加按钮
|
||||
*/
|
||||
showAdd?: boolean;
|
||||
/**
|
||||
* 点击事件
|
||||
*/
|
||||
onClick?: (data?: any) => void;
|
||||
/**
|
||||
* 管理器id, 存储到store的id
|
||||
*/
|
||||
managerId?: string;
|
||||
children?: React.ReactNode;
|
||||
showSelect?: boolean;
|
||||
openMenu?: boolean;
|
||||
};
|
||||
export const ProviderManagerName = 'mark-manager';
|
||||
export const App = (props: AppProps) => {
|
||||
return (
|
||||
<ManagerProvider id={props.managerId}>
|
||||
<LayoutMain expandChildren={props.children} open={props.openMenu}>
|
||||
<Manager
|
||||
markType={props.markType}
|
||||
showSearch={props.showSearch}
|
||||
showAdd={props.showAdd}
|
||||
onClick={props.onClick}
|
||||
showSelect={props.showSelect}></Manager>
|
||||
</LayoutMain>
|
||||
</ManagerProvider>
|
||||
);
|
||||
};
|
||||
9
src/apps/mark/manager/Provider.tsx
Normal file
9
src/apps/mark/manager/Provider.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { StoreContextProvider } from '@kevisual/store/react';
|
||||
import { createManagerStore } from './store/index';
|
||||
export const ManagerProvider = ({ children, id }: { children: React.ReactNode; id?: string }) => {
|
||||
return (
|
||||
<StoreContextProvider id={id || 'mark-manager'} stateCreator={createManagerStore}>
|
||||
{children}
|
||||
</StoreContextProvider>
|
||||
);
|
||||
};
|
||||
27
src/apps/mark/manager/components/index.tsx
Normal file
27
src/apps/mark/manager/components/index.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Button as aButton } from '@/components/a/button';
|
||||
import { Input } from '@/components/a/input';
|
||||
export const Button = aButton;
|
||||
|
||||
export const TextField = Input;
|
||||
|
||||
export const IconButton = aButton;
|
||||
|
||||
export const Menu = () => {
|
||||
return <>dev</>;
|
||||
};
|
||||
|
||||
export const InputAdornment = () => {
|
||||
return <>dev</>;
|
||||
};
|
||||
export const MenuItem = () => {
|
||||
return <>dev</>;
|
||||
};
|
||||
export const Autocomplete = () => {
|
||||
return <>dev</>;
|
||||
};
|
||||
|
||||
export const Box = () => {
|
||||
return <>dev</>;
|
||||
};
|
||||
|
||||
|
||||
1
src/apps/mark/manager/constant.ts
Normal file
1
src/apps/mark/manager/constant.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const MarkTypes = ['md', 'wallnote', 'excalidraw', 'chat'];
|
||||
156
src/apps/mark/manager/edit/Edit.tsx
Normal file
156
src/apps/mark/manager/edit/Edit.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import { TextField } from '../components';
|
||||
import { Button } from '@/components/a/button';
|
||||
import { AutoComplate } from '@/components/a/auto-complate';
|
||||
import { TagsInput } from '@/components/a/input';
|
||||
import { useManagerStore } from '../store';
|
||||
import { useShallow } from 'zustand/shallow';
|
||||
import { useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { pick } from 'lodash-es';
|
||||
import { toast } from 'react-toastify';
|
||||
import { Select } from '@/components/a/select';
|
||||
import { MarkTypes } from '../constant';
|
||||
|
||||
export const EditMark = () => {
|
||||
const { control, handleSubmit, reset } = useForm();
|
||||
const { updateMark, markData, setCurrentMarkId, setMarkData } = useManagerStore(
|
||||
useShallow((state) => {
|
||||
return {
|
||||
updateMark: state.updateMark,
|
||||
markData: state.markData,
|
||||
setCurrentMarkId: state.setCurrentMarkId,
|
||||
currentMarkId: state.currentMarkId,
|
||||
setMarkData: state.setMarkData,
|
||||
};
|
||||
}),
|
||||
);
|
||||
// const [mark, setMark] = useState<Mark | undefined>(markData);
|
||||
const mark = pick(markData, ['id', 'title', 'description', 'markType', 'summary', 'tags', 'link', 'thumbnail']);
|
||||
useEffect(() => {
|
||||
reset(mark);
|
||||
console.log('markData', markData);
|
||||
}, [markData?.id]);
|
||||
const onSubmit = async (data: any) => {
|
||||
const res = await updateMark({ ...mark, ...data });
|
||||
if (res.code === 200) {
|
||||
toast.success('编辑成功');
|
||||
}
|
||||
|
||||
// setCurrentMarkId('');
|
||||
// setMarkData(undefined);
|
||||
};
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} noValidate autoComplete='off' className='w-full h-full overflow-auto px-2 py-1'>
|
||||
<Controller
|
||||
name='title'
|
||||
control={control}
|
||||
defaultValue={mark?.title || ''}
|
||||
render={({ field }) => (
|
||||
<div className='mb-4'>
|
||||
<label className='block text-sm font-medium mb-1'>标题</label>
|
||||
<input {...field} className='w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500' type='text' />
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name='description'
|
||||
control={control}
|
||||
defaultValue={mark?.description || ''}
|
||||
render={({ field }) => (
|
||||
<div className='mb-4'>
|
||||
<label className='block text-sm font-medium mb-1'>描述</label>
|
||||
<textarea {...field} className='w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500' rows={3} />
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name='markType'
|
||||
control={control}
|
||||
defaultValue={mark?.markType || ''}
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<div className='mb-4'>
|
||||
<label className='block text-sm font-medium mb-1'>类型</label>
|
||||
<AutoComplate
|
||||
{...field}
|
||||
options={MarkTypes.map((item) => {
|
||||
return { label: item, value: item };
|
||||
})}
|
||||
onChange={(value) => field.onChange(value)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Controller
|
||||
name='summary'
|
||||
control={control}
|
||||
defaultValue={mark?.summary || ''}
|
||||
render={({ field }) => (
|
||||
<div className='mb-4'>
|
||||
<label className='block text-sm font-medium mb-1'>概要</label>
|
||||
<textarea {...field} className='w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500' rows={2} />
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name='tags'
|
||||
control={control}
|
||||
defaultValue={mark?.tags || ''}
|
||||
render={({ field }) => {
|
||||
const label = '标签';
|
||||
return (
|
||||
<div className='mb-4'>
|
||||
<label className='block text-sm font-medium mb-1'>标签</label>
|
||||
<TagsInput
|
||||
{...field}
|
||||
options={field.value?.map((tag: string) => ({ label: tag, value: tag })) || []}
|
||||
placeholder={label}
|
||||
onChange={(value) => {
|
||||
field.onChange(value);
|
||||
console.log('tags', value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Controller
|
||||
name='link'
|
||||
control={control}
|
||||
defaultValue={mark?.link || ''}
|
||||
render={({ field }) => (
|
||||
<div className='mb-4'>
|
||||
<label className='block text-sm font-medium mb-1'>链接</label>
|
||||
<input {...field} className='w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500' type='text' />
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name='thumbnail'
|
||||
control={control}
|
||||
defaultValue={mark?.thumbnail || ''}
|
||||
render={({ field }) => (
|
||||
<div className='mb-4'>
|
||||
<label className='block text-sm font-medium mb-1'>缩略图</label>
|
||||
<input {...field} className='w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500' type='text' />
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<div className='flex gap-2'>
|
||||
<Button type='submit' color='primary'>
|
||||
保存
|
||||
</Button>
|
||||
<Button
|
||||
color='secondary'
|
||||
onClick={() => {
|
||||
setCurrentMarkId('');
|
||||
setMarkData(undefined);
|
||||
}}>
|
||||
取消
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
137
src/apps/mark/manager/store/index.ts
Normal file
137
src/apps/mark/manager/store/index.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { StateCreator, StoreManager } from '@kevisual/store';
|
||||
import { useContextKey } from '@kevisual/store/context';
|
||||
// import { StateCreator, StoreApi, UseBoundStore } from 'zustand';
|
||||
import { query as queryClient } from '@/modules/query';
|
||||
import { Result } from '@kevisual/query/query';
|
||||
import { QueryMark, Mark, MarkType } from '@/query/query-mark/query-mark';
|
||||
import { useStore, BoundStore } from '@kevisual/store/react';
|
||||
import { uniqBy } from 'lodash-es';
|
||||
export const store = useContextKey('store', () => {
|
||||
return new StoreManager();
|
||||
});
|
||||
|
||||
type ManagerStore = {
|
||||
/** 当前选中的Mark */
|
||||
currentMarkId: string;
|
||||
setCurrentMarkId: (markId: string) => void;
|
||||
markData: Mark | undefined;
|
||||
setMarkData: (mark?: Partial<Mark>) => void;
|
||||
/** 获取Mark列表 */
|
||||
getList: () => Promise<any>;
|
||||
getMarkFromList: (markId: string) => Mark | undefined;
|
||||
updateMark: (mark: Mark) => Promise<any>;
|
||||
getMark: (markId: string) => Promise<Result<Mark>>;
|
||||
deleteMark: (markId: string) => Promise<any>;
|
||||
/** Mark列表 */
|
||||
list: Mark[];
|
||||
setList: (list: Mark[]) => void;
|
||||
pagination: {
|
||||
current: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
};
|
||||
setPagination: (pagination: { current: number; pageSize: number; total: number }) => void;
|
||||
/** 搜索 */
|
||||
search: string;
|
||||
setSearch: (search: string) => void;
|
||||
/** 初始化 */
|
||||
init: (markType: MarkType) => Promise<void>;
|
||||
queryMark: QueryMark;
|
||||
markType: MarkType;
|
||||
open: boolean;
|
||||
setOpen: (open?: boolean) => void;
|
||||
};
|
||||
export const createManagerStore: StateCreator<ManagerStore, [], [], any> = (set, get, store) => {
|
||||
return {
|
||||
currentMarkId: '',
|
||||
setCurrentMarkId: (markId: string) => set(() => ({ currentMarkId: markId })),
|
||||
open: false,
|
||||
setOpen: (open: boolean) => set(() => ({ open })),
|
||||
getList: async () => {
|
||||
const queryMark = get().queryMark;
|
||||
const { search, pagination } = get();
|
||||
const res = await queryMark.getMarkList({ page: pagination.current, pageSize: pagination.pageSize, search });
|
||||
const oldList = get().list;
|
||||
if (res.code === 200) {
|
||||
const { pagination, list } = res.data || {};
|
||||
const newList = [...oldList, ...list!];
|
||||
const uniqueList = uniqBy(newList, 'id');
|
||||
set(() => ({ list: uniqueList }));
|
||||
set(() => ({ pagination: { current: pagination!.current, pageSize: pagination!.pageSize, total: pagination!.total } }));
|
||||
}
|
||||
},
|
||||
getMarkFromList: (markId: string) => {
|
||||
return get().list.find((item) => item.id === markId);
|
||||
},
|
||||
updateMark: async (mark: Mark) => {
|
||||
const queryMark = get().queryMark;
|
||||
const res = await queryMark.updateMark(mark);
|
||||
if (res.code === 200) {
|
||||
set((state) => {
|
||||
const oldList = state.list;
|
||||
const resMark = res.data!;
|
||||
const newList = oldList.map((item) => (item.id === mark.id ? mark : item));
|
||||
if (!mark.id) {
|
||||
newList.unshift(resMark);
|
||||
}
|
||||
return {
|
||||
list: newList,
|
||||
};
|
||||
});
|
||||
}
|
||||
return res;
|
||||
},
|
||||
getMark: async (markId: string) => {
|
||||
const queryMark = get().queryMark;
|
||||
const res = await queryMark.getMark(markId);
|
||||
return res;
|
||||
},
|
||||
list: [],
|
||||
setList: (list: any[]) => set(() => ({ list })),
|
||||
init: async (markType: MarkType = 'wallnote') => {
|
||||
// await get().getList();
|
||||
console.log('init', set, get);
|
||||
const queryMark = new QueryMark({
|
||||
query: queryClient as any,
|
||||
markType,
|
||||
});
|
||||
const url = new URL(window.location.href);
|
||||
const pageSize = url.searchParams.get('pageSize') || '10';
|
||||
set({ queryMark, markType, list: [], pagination: { current: 1, pageSize: parseInt(pageSize), total: 0 }, currentMarkId: '', markData: undefined });
|
||||
setTimeout(async () => {
|
||||
console.log('get', get);
|
||||
get().getList();
|
||||
}, 1000);
|
||||
},
|
||||
deleteMark: async (markId: string) => {
|
||||
const queryMark = get().queryMark;
|
||||
const res = await queryMark.deleteMark(markId);
|
||||
const currentMarkId = get().currentMarkId;
|
||||
if (res.code === 200) {
|
||||
// get().getList();
|
||||
set((state) => ({
|
||||
list: state.list.filter((item) => item.id !== markId),
|
||||
}));
|
||||
if (currentMarkId === markId) {
|
||||
set(() => ({ currentMarkId: '', markData: undefined }));
|
||||
}
|
||||
}
|
||||
return res;
|
||||
},
|
||||
queryMark: undefined,
|
||||
markType: 'simple',
|
||||
markData: undefined,
|
||||
setMarkData: (mark: Mark) => set(() => ({ markData: mark })),
|
||||
pagination: {
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
total: 0,
|
||||
},
|
||||
setPagination: (pagination: { current: number; pageSize: number; total: number }) => set(() => ({ pagination })),
|
||||
/** 搜索 */
|
||||
search: '',
|
||||
setSearch: (search: string) => set(() => ({ search, list: [], pagination: { current: 1, pageSize: 10, total: 0 } })),
|
||||
};
|
||||
};
|
||||
|
||||
export const useManagerStore = useStore as BoundStore<ManagerStore>;
|
||||
@@ -1,6 +1,92 @@
|
||||
import { Input as UIInput } from '@/components/ui/input';
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
|
||||
export type InputProps = { label?: string } & React.ComponentProps<'input'>;
|
||||
export const Input = (props: InputProps) => {
|
||||
return <UIInput {...props} />;
|
||||
};
|
||||
|
||||
type TagsInputProps = {
|
||||
value: string[];
|
||||
onChange: (value: string[]) => void;
|
||||
placeholder?: string;
|
||||
label?: React.ReactNode;
|
||||
showLabel?: boolean;
|
||||
options?: string[]; // 可选,暂未实现自动补全
|
||||
};
|
||||
|
||||
export const TagsInput = ({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = '',
|
||||
label = '',
|
||||
showLabel = false,
|
||||
}: TagsInputProps) => {
|
||||
const [input, setInput] = useState('');
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setInput('');
|
||||
}, [value]);
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setInput(e.target.value);
|
||||
};
|
||||
|
||||
const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (
|
||||
(e.key === 'Enter' || e.key === ',' || e.key === 'Tab') &&
|
||||
input.trim()
|
||||
) {
|
||||
e.preventDefault();
|
||||
const newTag = input.trim();
|
||||
if (!value.includes(newTag)) {
|
||||
onChange([...value, newTag]);
|
||||
}
|
||||
setInput('');
|
||||
} else if (e.key === 'Backspace' && !input && value.length) {
|
||||
onChange(value.slice(0, -1));
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveTag = (idx: number) => {
|
||||
onChange(value.filter((_, i) => i !== idx));
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{showLabel && label && (
|
||||
<label className="block mb-1 text-sm font-medium text-gray-700">{label}</label>
|
||||
)}
|
||||
<div
|
||||
className="flex flex-wrap items-center gap-2 border rounded px-2 py-1 min-h-[40px] focus-within:ring-2 focus-within:ring-blue-500 bg-white"
|
||||
onClick={() => inputRef.current?.focus()}
|
||||
>
|
||||
{value.map((tag, idx) => (
|
||||
<span
|
||||
key={tag + idx}
|
||||
className="flex items-center bg-blue-100 text-blue-800 rounded px-2 py-0.5 text-sm mr-1 mb-1"
|
||||
>
|
||||
{tag}
|
||||
<button
|
||||
type="button"
|
||||
className="ml-1 text-blue-500 hover:text-blue-700 focus:outline-none"
|
||||
onClick={() => handleRemoveTag(idx)}
|
||||
aria-label="Remove tag"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="flex-1 min-w-[80px] border-none outline-none bg-transparent py-1 text-sm"
|
||||
value={input}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
39
src/components/a/menu.tsx
Normal file
39
src/components/a/menu.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuItem,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { useEffect, useState } from 'react';
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
options?: { label: string; value: string }[];
|
||||
onSelect?: (value: string) => void;
|
||||
};
|
||||
|
||||
export const Menu = (props: Props) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [selectedValue, setSelectedValue] = useState('');
|
||||
|
||||
const handleSelect = (value: string) => {
|
||||
setSelectedValue(value);
|
||||
props.onSelect?.(value);
|
||||
setOpen(false);
|
||||
};
|
||||
const showSelectedValue = selectedValue || 'Select an option';
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenuTrigger className={props.className}>{props.children ? props.children : showSelectedValue}</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
{props.options?.map((option) => (
|
||||
<DropdownMenuItem key={option.value} onSelect={() => handleSelect(option.value)}>
|
||||
{option.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
@@ -1,12 +1,12 @@
|
||||
import { Tooltip as UITooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import React from 'react';
|
||||
|
||||
export const Tooltip = (props: { children?: React.ReactNode; title?: React.ReactNode }) => {
|
||||
export const Tooltip = (props: { children?: React.ReactNode; title?: React.ReactNode; placement?: 'top' | 'bottom' | 'left' | 'right' }) => {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<UITooltip>
|
||||
<TooltipTrigger asChild>{props.children}</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<TooltipContent className='bg-gray-800 text-white' side={props.placement || 'top'}>
|
||||
<p>{props.title}</p>
|
||||
</TooltipContent>
|
||||
</UITooltip>
|
||||
|
||||
255
src/components/ui/dropdown-menu.tsx
Normal file
255
src/components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,255 @@
|
||||
import * as React from "react"
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function DropdownMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Trigger
|
||||
data-slot="dropdown-menu-trigger"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
className,
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CircleIcon className="size-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto size-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
}
|
||||
10
src/pages/apps/html.astro
Normal file
10
src/pages/apps/html.astro
Normal file
@@ -0,0 +1,10 @@
|
||||
---
|
||||
import '@/styles/theme.css';
|
||||
import '@/styles/global.css';
|
||||
import Blank from '@/components/html/blank.astro';
|
||||
import { App } from '@/apps/ai-html';
|
||||
---
|
||||
|
||||
<Blank>
|
||||
<App client:only />
|
||||
</Blank>
|
||||
@@ -1,7 +1,6 @@
|
||||
---
|
||||
import { getCollection } from 'astro:content';
|
||||
const posts = await getCollection('kevisual');
|
||||
console.log('post', posts);
|
||||
import { basename } from '@/modules/basename';
|
||||
import Blank from '@/components/html/blank.astro';
|
||||
---
|
||||
|
||||
11
src/pages/mark/draw.astro
Normal file
11
src/pages/mark/draw.astro
Normal file
@@ -0,0 +1,11 @@
|
||||
---
|
||||
import '@/styles/theme.css';
|
||||
import '@/styles/global.css';
|
||||
import Blank from '@/components/html/blank.astro';
|
||||
import { App } from '@/apps/draw/App';
|
||||
---
|
||||
|
||||
<link rel="stylesheet" href="https://esm.sh/@excalidraw/excalidraw@0.18.0/dist/prod/index.css" />
|
||||
<Blank>
|
||||
<App client:only />
|
||||
</Blank>
|
||||
10
src/pages/mark/index.astro
Normal file
10
src/pages/mark/index.astro
Normal file
@@ -0,0 +1,10 @@
|
||||
---
|
||||
import '@/styles/theme.css';
|
||||
import '@/styles/global.css';
|
||||
import Blank from '@/components/html/blank.astro';
|
||||
import { App } from '@/apps/mark/manager/Manager';
|
||||
---
|
||||
|
||||
<Blank>
|
||||
<App client:only openMenu={true} />
|
||||
</Blank>
|
||||
@@ -15,4 +15,7 @@ export class QueryApp extends BaseQuery {
|
||||
getList(data: any, opts?: DataOpts) {
|
||||
return this.appDefine.queryChain('listApps').post(data, opts);
|
||||
}
|
||||
publishVersion(data: any, opts?: DataOpts) {
|
||||
return this.appDefine.queryChain('publishApp').post(data, opts);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
// console.log('upload)
|
||||
Reference in New Issue
Block a user