generated from template/astro-template
temp
This commit is contained in:
37
src/apps/ai-editor/content.tsx
Normal file
37
src/apps/ai-editor/content.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { useShallow } from 'zustand/shallow';
|
||||
import { getCurrentContent, useMenuStore } from './store/menu';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { checkText } from './modules/get-content-type';
|
||||
// import { TextEditor } from '@kevisual/markdown-editor/tiptap/editor.ts';
|
||||
|
||||
export const Content: React.FC = () => {
|
||||
const { currentPath } = useMenuStore(
|
||||
useShallow((state) => ({
|
||||
currentPath: state.currentPath,
|
||||
})),
|
||||
);
|
||||
const [content, setContent] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchContent = async () => {
|
||||
if (currentPath) {
|
||||
const isText = checkText(currentPath);
|
||||
if (!isText.isText) {
|
||||
setContent('This file type is not supported for viewing.');
|
||||
return;
|
||||
}
|
||||
setContent('Loading content...');
|
||||
const content = await getCurrentContent(currentPath);
|
||||
setContent(content);
|
||||
}
|
||||
};
|
||||
fetchContent();
|
||||
}, [currentPath]);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
return (
|
||||
<div className='h-full w-full overflow-y-auto scrollbar px-2'>
|
||||
<pre>{content}</pre>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,7 @@
|
||||
import { ToastProvider } from '@/modules/toast/Provider';
|
||||
import Sidebar from './sidebar';
|
||||
import { Content } from './content';
|
||||
import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
|
||||
|
||||
export const App = () => {
|
||||
return (
|
||||
@@ -8,11 +11,33 @@ export const App = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export const SidebarApp = () => {
|
||||
return (
|
||||
<ToastProvider>
|
||||
<Sidebar />
|
||||
</ToastProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export const AIEditor = () => {
|
||||
return (
|
||||
<div className='flex h-full w-full flex-col items-center justify-center'>
|
||||
<div className='text-2xl font-bold'>AI Editor</div>
|
||||
<div className='mt-4 text-gray-500'>This is a placeholder for the AI Editor application.</div>
|
||||
<div style={{ height: 'calc(100vh - 50px)' }} className='flex w-full'>
|
||||
<div className='flex w-full'>
|
||||
<PanelGroup direction='horizontal'>
|
||||
<Panel minSize={20} defaultSize={30}>
|
||||
<Sidebar />
|
||||
</Panel>
|
||||
<PanelResizeHandle />
|
||||
<Panel minSize={30}>
|
||||
<div className='w-full h-full overflow-hidden'>
|
||||
<Content />
|
||||
</div>
|
||||
</Panel>
|
||||
</PanelGroup>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
28
src/apps/ai-editor/modules/get-content-type.ts
Normal file
28
src/apps/ai-editor/modules/get-content-type.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
export const checkText = (filepath: string) => {
|
||||
const textExtensions = [
|
||||
'.txt',
|
||||
'.md',
|
||||
'.json',
|
||||
'.js',
|
||||
'.ts',
|
||||
'.html',
|
||||
'.css',
|
||||
'.xml',
|
||||
'.csv',
|
||||
|
||||
'.tsx',
|
||||
'.jsx',
|
||||
'.mjs',
|
||||
'.cjs',
|
||||
'.vue',
|
||||
|
||||
'.yaml',
|
||||
'.yml',
|
||||
'.log',
|
||||
'.conf',
|
||||
'.env',
|
||||
'.example', //
|
||||
];
|
||||
const extension = filepath.split('.').pop()?.toLowerCase();
|
||||
return { isText: textExtensions.includes(`.${extension}`) };
|
||||
};
|
||||
135
src/apps/ai-editor/sidebar.tsx
Normal file
135
src/apps/ai-editor/sidebar.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
import React, { useState, useMemo, useEffect } from 'react';
|
||||
import { File, Folder, ChevronRight, ChevronDown, Loader2 } from 'lucide-react';
|
||||
import { useMenuStore, init } from './store/menu.ts';
|
||||
import type { MenuItem } from './store/menu.ts';
|
||||
|
||||
// 辅助函数:获取目录的直接子项
|
||||
const getDirectChildren = (path: string, menu: MenuItem[]): MenuItem[] => {
|
||||
const pathPrefix = path ? path + '/' : '';
|
||||
|
||||
return menu.filter((item) => {
|
||||
if (!item.path.startsWith(pathPrefix)) return false;
|
||||
if (item.path === path) return false;
|
||||
|
||||
const relativePath = item.path.slice(pathPrefix.length);
|
||||
return !relativePath.includes('/');
|
||||
});
|
||||
};
|
||||
|
||||
interface DirectoryItemProps {
|
||||
item: MenuItem;
|
||||
level: number;
|
||||
prefix?: string;
|
||||
}
|
||||
|
||||
const DirectoryItem: React.FC<DirectoryItemProps> = ({ item, level, prefix }) => {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const { menu } = useMenuStore();
|
||||
const children = useMemo(() => {
|
||||
const children = getDirectChildren(item.path, menu);
|
||||
return children.sort((a, b) => {
|
||||
if (a.type === 'directory' && b.type === 'file') return -1; // 目录排在前面
|
||||
if (a.type === 'file' && b.type === 'directory') return 1; // 文件排在后面
|
||||
return a.path.localeCompare(b.path); // 同类型按路径排序
|
||||
});
|
||||
}, [menu, item.path]);
|
||||
const directory = item.path.replace(prefix || '', '').replace(/\/$/, '') || '/'; // 去掉前缀和末尾斜杠
|
||||
const newPrefix = item.path + '/';
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className='flex items-center cursor-pointer py-1 px-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded group'
|
||||
style={{ paddingLeft: `${level * 12}px` }}
|
||||
onClick={() => setExpanded(!expanded)}>
|
||||
{children.length > 0 ? (
|
||||
expanded ? (
|
||||
<ChevronDown className='w-4 h-4 mr-1 text-gray-500' />
|
||||
) : (
|
||||
<ChevronRight className='w-4 h-4 mr-1 text-gray-500' />
|
||||
)
|
||||
) : (
|
||||
<div className='w-4 h-4 mr-1' />
|
||||
)}
|
||||
<Folder className='w-4 h-4 mr-2 text-yellow-500 shrink-0' />
|
||||
<span className='text-sm truncate'>{directory}</span>
|
||||
</div>
|
||||
|
||||
{expanded && children.length > 0 && (
|
||||
<div className='ml-2'>
|
||||
{children.map((child) =>
|
||||
child.type === 'directory' ? (
|
||||
<DirectoryItem key={child.path} item={child} prefix={newPrefix} level={level + 1} />
|
||||
) : (
|
||||
<FileItem key={child.path} item={child} prefix={newPrefix} level={level + 1} />
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface FileItemProps {
|
||||
item: MenuItem;
|
||||
level: number;
|
||||
prefix?: string;
|
||||
}
|
||||
|
||||
const FileItem: React.FC<FileItemProps> = ({ item, level, prefix }) => {
|
||||
const { currentPath, setCurrentPath } = useMenuStore();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center cursor-pointer py-1 px-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded ${
|
||||
currentPath === item.path ? 'bg-blue-100 dark:bg-blue-900' : ''
|
||||
}`}
|
||||
style={{ paddingLeft: `${level * 12}px` }}
|
||||
onClick={() => setCurrentPath(item.path)}>
|
||||
<div className='w-4 h-4 mr-1' /> {/* 占位,保持对齐 */}
|
||||
<File className='w-4 h-4 mr-2 text-blue-500 shrink-0' />
|
||||
<span className='text-sm truncate'>{item.path.replace(prefix || '', '')}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const Sidebar: React.FC = () => {
|
||||
const { menu, isLoading } = useMenuStore();
|
||||
useEffect(() => {
|
||||
// 初始化菜单数据
|
||||
init();
|
||||
}, []);
|
||||
// 获取顶层项(根目录下的文件和文件夹)
|
||||
const rootItems = useMemo(() => {
|
||||
// return menu.filter((item) => !item.path.includes('/'));
|
||||
const _menu = menu.filter((item) => {
|
||||
return !item.path.includes('/');
|
||||
});
|
||||
return _menu.sort((a, b) => {
|
||||
if (a.type === 'directory' && b.type === 'file') return -1; // 目录排在前面
|
||||
if (a.type === 'file' && b.type === 'directory') return 1; // 文件排在后面
|
||||
return a.path.localeCompare(b.path); // 同类型按路径排序
|
||||
});
|
||||
}, [menu]);
|
||||
|
||||
return (
|
||||
<div className='border-r w-full border-gray-200 dark:border-gray-700 min-h-screen p-2 bg-white dark:bg-gray-900 h-full'>
|
||||
<div className='flex items-center justify-between mb-4 px-2'>
|
||||
{/* <h2 className='text-lg font-semibold'>文件</h2> */}
|
||||
{isLoading && <Loader2 className='w-4 h-4 animate-spin text-gray-500' />}
|
||||
</div>
|
||||
<div className='overflow-y-auto scrollbar' style={{ maxHeight: 'calc(100% - 58px)' }}>
|
||||
{menu.length === 0 && !isLoading ? (
|
||||
<div className='text-sm text-gray-500 px-2'>暂无文件</div>
|
||||
) : (
|
||||
<div className='space-y-1'>
|
||||
{rootItems.map((item) =>
|
||||
item.type === 'directory' ? <DirectoryItem key={item.path} item={item} level={1} /> : <FileItem key={item.path} item={item} level={1} />,
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Sidebar;
|
||||
104
src/apps/ai-editor/store/menu.ts
Normal file
104
src/apps/ai-editor/store/menu.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { create } from 'zustand';
|
||||
import { query } from '@/modules/query';
|
||||
import { QueryLoginBrowser } from '@/query/query-login/query-login-browser';
|
||||
import { QueryResources } from '@/query/query-resources/index';
|
||||
|
||||
export const queryLogin = new QueryLoginBrowser({ query });
|
||||
export const queryResources = new QueryResources({
|
||||
prefix: '/root/resources/',
|
||||
});
|
||||
|
||||
export type MenuItem = {
|
||||
type: 'file' | 'directory';
|
||||
name?: string; // type === 'file'
|
||||
prefix?: string; // type === 'directory'
|
||||
etag?: string;
|
||||
lastModified?: string; // ISO date string
|
||||
size: number;
|
||||
url: string;
|
||||
path: string; // a/b/file.md
|
||||
pathname: string;
|
||||
};
|
||||
|
||||
export type Menu = MenuItem[];
|
||||
|
||||
interface MenuState {
|
||||
menu: Menu;
|
||||
currentPath: string | null;
|
||||
isLoading: boolean;
|
||||
setMenu: (menu: Menu) => void;
|
||||
setCurrentPath: (path: string | null) => void;
|
||||
setLoading: (status: boolean) => void;
|
||||
}
|
||||
|
||||
export const useMenuStore = create<MenuState>((set) => ({
|
||||
menu: [],
|
||||
currentPath: null,
|
||||
isLoading: false,
|
||||
setMenu: (menu) => set({ menu }),
|
||||
setCurrentPath: (currentPath) => set({ currentPath }),
|
||||
setLoading: (isLoading) => set({ isLoading }),
|
||||
}));
|
||||
|
||||
class Status {
|
||||
isInitialized = false;
|
||||
username = '';
|
||||
}
|
||||
const status = new Status();
|
||||
|
||||
export const init = async (prefix: string = '') => {
|
||||
const { setMenu, setCurrentPath, setLoading } = useMenuStore.getState();
|
||||
let me = await queryLogin.checkLocalUser();
|
||||
const isInitialized = status.isInitialized;
|
||||
if (!isInitialized && me) {
|
||||
status.isInitialized = true;
|
||||
status.username = me.username!;
|
||||
queryResources.setUsername(status.username);
|
||||
}
|
||||
let recursive = true;
|
||||
const data = {};
|
||||
if (recursive) {
|
||||
data['recursive'] = recursive;
|
||||
}
|
||||
const res = await queryResources.getList(prefix, data);
|
||||
if (res.code === 200) {
|
||||
const menu = res.data!.map((item: any) => {
|
||||
if (item.prefix) {
|
||||
item.type = 'directory';
|
||||
} else {
|
||||
item.type = 'file';
|
||||
}
|
||||
return item;
|
||||
});
|
||||
console.log('init menu', menu);
|
||||
if (recursive) {
|
||||
const obj: Record<string, any> = {};
|
||||
menu.forEach((item) => {
|
||||
const parts = item.path.split('/');
|
||||
const dirParts = parts.slice(0, -1);
|
||||
for (let i = 0; i < dirParts.length; i++) {
|
||||
const dir = dirParts.slice(0, i + 1).join('/');
|
||||
if (!dir) continue; // skip root
|
||||
obj[dir] = obj[dir] || { type: 'directory', path: dir };
|
||||
}
|
||||
});
|
||||
Object.keys(obj).forEach((key) => {
|
||||
const item = obj[key];
|
||||
menu.push(item);
|
||||
});
|
||||
}
|
||||
setMenu(menu);
|
||||
setCurrentPath('');
|
||||
}
|
||||
};
|
||||
|
||||
export const getCurrentContent = async (currentPath: string): Promise<string | null> => {
|
||||
if (!currentPath) return null;
|
||||
const res = await queryResources.fetchFile(currentPath);
|
||||
if (res.success) {
|
||||
return res.data;
|
||||
} else {
|
||||
console.error('Error fetching content:', res.message);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
10
src/pages/ai-editor/index.astro
Normal file
10
src/pages/ai-editor/index.astro
Normal file
@@ -0,0 +1,10 @@
|
||||
---
|
||||
import '@/styles/global.css';
|
||||
import '@/styles/theme.css';
|
||||
import Blank from '@/components/html/blank.astro';
|
||||
import { App } from '@/apps/ai-editor';
|
||||
---
|
||||
|
||||
<Blank>
|
||||
<App client:only />
|
||||
</Blank>
|
||||
10
src/pages/ai-editor/sidebar.astro
Normal file
10
src/pages/ai-editor/sidebar.astro
Normal file
@@ -0,0 +1,10 @@
|
||||
---
|
||||
import '@/styles/global.css';
|
||||
import '@/styles/theme.css';
|
||||
import Blank from '@/components/html/blank.astro';
|
||||
import { SidebarApp } from '@/apps/ai-editor';
|
||||
---
|
||||
|
||||
<Blank>
|
||||
<SidebarApp client:only />
|
||||
</Blank>
|
||||
71
src/query/query-resources/index.ts
Normal file
71
src/query/query-resources/index.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { adapter, DataOpts, Result } from '@kevisual/query';
|
||||
|
||||
type QueryResourcesOptions = {
|
||||
prefix?: string;
|
||||
storage?: Storage;
|
||||
username?: string;
|
||||
[key: string]: any;
|
||||
};
|
||||
export class QueryResources {
|
||||
prefix: string; // root/resources
|
||||
storage: Storage;
|
||||
constructor(opts: QueryResourcesOptions) {
|
||||
if (opts.username) {
|
||||
this.prefix = `/${opts.username}/resources/`;
|
||||
} else {
|
||||
this.prefix = opts.prefix || '';
|
||||
}
|
||||
this.storage = opts.storage || localStorage;
|
||||
}
|
||||
setUsername(username: string) {
|
||||
this.prefix = `/${username}/resources/`;
|
||||
}
|
||||
header(headers?: Record<string, string>, json = true): Record<string, string> {
|
||||
const token = this.storage.getItem('token');
|
||||
const _headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...headers,
|
||||
};
|
||||
if (!json) {
|
||||
delete _headers['Content-Type'];
|
||||
}
|
||||
if (!token) {
|
||||
return _headers;
|
||||
}
|
||||
return {
|
||||
..._headers,
|
||||
Authorization: `Bearer ${token}`,
|
||||
};
|
||||
}
|
||||
async get(data: any, opts: DataOpts): Promise<any> {
|
||||
return adapter({
|
||||
url: opts.url!,
|
||||
method: 'GET',
|
||||
body: data,
|
||||
...opts,
|
||||
headers: this.header(opts?.headers),
|
||||
});
|
||||
}
|
||||
async getList(prefix: string, data?: { recursive?: boolean }, opts?: DataOpts): Promise<Result<any[]>> {
|
||||
return this.get(data, {
|
||||
url: `${this.prefix}${prefix}`,
|
||||
body: data,
|
||||
...opts,
|
||||
});
|
||||
}
|
||||
async fetchFile(filepath: string, opts?: DataOpts): Promise<Result<any>> {
|
||||
return fetch(`${this.prefix}${filepath}`, {
|
||||
method: 'GET',
|
||||
headers: this.header(opts?.headers, false),
|
||||
}).then(async (res) => {
|
||||
if (!res.ok) {
|
||||
return {
|
||||
code: 500,
|
||||
success: false,
|
||||
message: `Failed to fetch file: ${res.status} ${res.statusText}`,
|
||||
} as Result<any>;
|
||||
}
|
||||
return { code: 200, data: await res.text(), success: true } as Result<any>;
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user