first commit
This commit is contained in:
parent
b83a7ff122
commit
0a0e95bab2
7
.eslintrc.json
Normal file
7
.eslintrc.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": ["next/core-web-vitals", "prettier"],
|
||||
"rules": {
|
||||
"no-unused-vars": "warn",
|
||||
"@typescript-eslint/no-unused-vars": "warn"
|
||||
}
|
||||
}
|
||||
109
__test__/services/file.test.ts
Normal file
109
__test__/services/file.test.ts
Normal file
@ -0,0 +1,109 @@
|
||||
// __tests__/services/file.test.ts
|
||||
import { FileService } from "@/app/lib/services/file";
|
||||
import axios from "axios";
|
||||
|
||||
// Mock axios
|
||||
jest.mock("axios");
|
||||
const mockedAxios = axios as jest.Mocked<typeof axios>;
|
||||
|
||||
describe("FileService", () => {
|
||||
beforeEach(() => {
|
||||
// 清除所有 mock 的数据
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("getFileTree", () => {
|
||||
it("should fetch file tree successfully", async () => {
|
||||
const mockData = [
|
||||
{
|
||||
id: "1",
|
||||
name: "测试文件夹",
|
||||
type: "folder",
|
||||
children: [
|
||||
{
|
||||
id: "2",
|
||||
name: "测试文档.md",
|
||||
type: "file",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
mockedAxios.get.mockResolvedValueOnce({ data: mockData });
|
||||
|
||||
const result = await FileService.getFileTree();
|
||||
|
||||
expect(mockedAxios.get).toHaveBeenCalledWith("/api/files");
|
||||
expect(result).toEqual(mockData);
|
||||
});
|
||||
|
||||
it("should handle error when fetching file tree fails", async () => {
|
||||
const error = new Error("Network error");
|
||||
mockedAxios.get.mockRejectedValueOnce(error);
|
||||
|
||||
await expect(FileService.getFileTree()).rejects.toThrow("Network error");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createFile", () => {
|
||||
it("should create file successfully", async () => {
|
||||
const newFile = {
|
||||
name: "新文档.md",
|
||||
type: "file" as const,
|
||||
parentId: "1",
|
||||
};
|
||||
|
||||
const mockResponse = {
|
||||
id: "3",
|
||||
...newFile,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
mockedAxios.post.mockResolvedValueOnce({ data: mockResponse });
|
||||
|
||||
const result = await FileService.createFile(newFile);
|
||||
|
||||
expect(mockedAxios.post).toHaveBeenCalledWith("/api/files", newFile);
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateFile", () => {
|
||||
it("should update file successfully", async () => {
|
||||
const updateData = {
|
||||
id: "2",
|
||||
name: "重命名文档.md",
|
||||
};
|
||||
|
||||
const mockResponse = {
|
||||
...updateData,
|
||||
type: "file",
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
mockedAxios.put.mockResolvedValueOnce({ data: mockResponse });
|
||||
|
||||
const result = await FileService.updateFile(updateData);
|
||||
|
||||
expect(mockedAxios.put).toHaveBeenCalledWith(
|
||||
`/api/files/${updateData.id}`,
|
||||
updateData
|
||||
);
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteFile", () => {
|
||||
it("should delete file successfully", async () => {
|
||||
const fileId = "2";
|
||||
const mockResponse = { success: true };
|
||||
|
||||
mockedAxios.delete.mockResolvedValueOnce({ data: mockResponse });
|
||||
|
||||
const result = await FileService.deleteFile(fileId);
|
||||
|
||||
expect(mockedAxios.delete).toHaveBeenCalledWith(`/api/files/${fileId}`);
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
});
|
||||
});
|
||||
6
app/(auth)/login/page.tsx
Normal file
6
app/(auth)/login/page.tsx
Normal file
@ -0,0 +1,6 @@
|
||||
const Login: React.FC = () => {
|
||||
|
||||
return <></>;
|
||||
}
|
||||
|
||||
export default Login
|
||||
6
app/(auth)/register/page.tsx
Normal file
6
app/(auth)/register/page.tsx
Normal file
@ -0,0 +1,6 @@
|
||||
const Register: React.FC = () => {
|
||||
|
||||
return <></>;
|
||||
}
|
||||
|
||||
export default Register
|
||||
79
app/(editor)/document/[id]/page.tsx
Normal file
79
app/(editor)/document/[id]/page.tsx
Normal file
@ -0,0 +1,79 @@
|
||||
// app/(editor)/document/[id]/page.tsx
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Layout, message } from 'antd'
|
||||
import MonacoEditor from '@/app/components/Editor/MonacoEditor'
|
||||
import EditorToolbar from '@/app/components/Editor/EditorToolbar'
|
||||
import FileTree from '@/app/components/FileTree/FileTree'
|
||||
import { DocumentService } from '@/app/lib/services/document'
|
||||
|
||||
const { Sider, Content } = Layout
|
||||
|
||||
interface EditorPageProps {
|
||||
params: {
|
||||
id: string
|
||||
}
|
||||
}
|
||||
|
||||
export default function EditorPage({ params }: EditorPageProps) {
|
||||
const [content, setContent] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
// 加载文档内容
|
||||
useEffect(() => {
|
||||
const loadDocument = async () => {
|
||||
try {
|
||||
const doc = await DocumentService.getDocument(params.id)
|
||||
setContent(doc.content)
|
||||
} catch (error) {
|
||||
message.error('加载文档失败'+error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
loadDocument()
|
||||
}, [params.id])
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
await DocumentService.saveDocument({
|
||||
id: params.id,
|
||||
content,
|
||||
})
|
||||
message.success('保存成功')
|
||||
} catch (error) {
|
||||
message.error('保存失败'+error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleFormat = (type: string) => {
|
||||
// TODO: 实现格式化功能
|
||||
console.log('Formatting:', type)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div>Loading...</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout className="h-screen">
|
||||
<Sider width={250} className="bg-white border-r">
|
||||
<FileTree />
|
||||
</Sider>
|
||||
<Layout>
|
||||
<EditorToolbar onSave={handleSave} onFormat={handleFormat} />
|
||||
<Content>
|
||||
<div className="h-full">
|
||||
<MonacoEditor
|
||||
docId={params.id}
|
||||
defaultValue={content}
|
||||
onContentChange={setContent}
|
||||
/>
|
||||
</div>
|
||||
</Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
6
app/(editor)/document/page.tsx
Normal file
6
app/(editor)/document/page.tsx
Normal file
@ -0,0 +1,6 @@
|
||||
const Document: React.FC = () => {
|
||||
|
||||
return <></>;
|
||||
}
|
||||
|
||||
export default Document
|
||||
0
app/api/auth/[...nextauth]/route.ts
Normal file
0
app/api/auth/[...nextauth]/route.ts
Normal file
31
app/api/documents/[id]/route.ts
Normal file
31
app/api/documents/[id]/route.ts
Normal file
@ -0,0 +1,31 @@
|
||||
// app/api/documents/[id]/route.ts
|
||||
import prisma from "@/app/lib/prisma";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export async function PUT(
|
||||
request: Request,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const { content, title } = await request.json();
|
||||
|
||||
const document = await prisma.document.update({
|
||||
where: {
|
||||
id: params.id,
|
||||
},
|
||||
data: {
|
||||
content,
|
||||
title,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(document);
|
||||
} catch (error) {
|
||||
console.error("Error saving document:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to save document" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
50
app/api/files/[id]/route.ts
Normal file
50
app/api/files/[id]/route.ts
Normal file
@ -0,0 +1,50 @@
|
||||
// app/api/files/[id]/route.ts
|
||||
import { NextResponse } from "next/server";
|
||||
import prisma from "@/app/lib/prisma";
|
||||
|
||||
// 更新文件
|
||||
export async function PUT(
|
||||
request: Request,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const { name } = await request.json();
|
||||
|
||||
const file = await prisma.file.update({
|
||||
where: {
|
||||
id: params.id,
|
||||
},
|
||||
data: {
|
||||
name,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(file);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to update file"+error },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 删除文件
|
||||
export async function DELETE(
|
||||
request: Request,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
await prisma.file.delete({
|
||||
where: {
|
||||
id: params.id,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to delete file" + error },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
46
app/api/files/route.ts
Normal file
46
app/api/files/route.ts
Normal file
@ -0,0 +1,46 @@
|
||||
// app/api/files/route.ts
|
||||
import { NextResponse } from "next/server";
|
||||
import prisma from "@/app/lib/prisma";
|
||||
|
||||
// 获取文件树
|
||||
export async function GET() {
|
||||
try {
|
||||
const files = await prisma.file.findMany({
|
||||
include: {
|
||||
children: true,
|
||||
},
|
||||
where: {
|
||||
parentId: null, // 获取根目录文件
|
||||
},
|
||||
});
|
||||
return NextResponse.json(files);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch files" + error},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 创建新文件/文件夹
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const { name, type, parentId } = await request.json();
|
||||
|
||||
const file = await prisma.file.create({
|
||||
data: {
|
||||
name,
|
||||
type,
|
||||
parentId,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(file);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to create file" + error},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
51
app/components/Editor/EditorToolbar.tsx
Normal file
51
app/components/Editor/EditorToolbar.tsx
Normal file
@ -0,0 +1,51 @@
|
||||
// app/components/Editor/EditorToolbar.tsx
|
||||
'use client'
|
||||
|
||||
import { Space, Button, Tooltip } from 'antd'
|
||||
import {
|
||||
BoldOutlined,
|
||||
ItalicOutlined,
|
||||
OrderedListOutlined,
|
||||
UnorderedListOutlined,
|
||||
LinkOutlined,
|
||||
SaveOutlined,
|
||||
UserOutlined,
|
||||
} from '@ant-design/icons'
|
||||
|
||||
interface EditorToolbarProps {
|
||||
onSave?: () => void
|
||||
onFormat?: (type: string) => void
|
||||
}
|
||||
|
||||
export default function EditorToolbar({ onSave, onFormat }: EditorToolbarProps) {
|
||||
return (
|
||||
<div className="border-b p-2 bg-white">
|
||||
<Space>
|
||||
<Tooltip title="保存">
|
||||
<Button icon={<SaveOutlined />} onClick={onSave} />
|
||||
</Tooltip>
|
||||
<div className="h-5 border-r border-gray-300" />
|
||||
<Tooltip title="加粗">
|
||||
<Button icon={<BoldOutlined />} onClick={() => onFormat?.('bold')} />
|
||||
</Tooltip>
|
||||
<Tooltip title="斜体">
|
||||
<Button icon={<ItalicOutlined />} onClick={() => onFormat?.('italic')} />
|
||||
</Tooltip>
|
||||
<div className="h-5 border-r border-gray-300" />
|
||||
<Tooltip title="有序列表">
|
||||
<Button icon={<OrderedListOutlined />} onClick={() => onFormat?.('orderedList')} />
|
||||
</Tooltip>
|
||||
<Tooltip title="无序列表">
|
||||
<Button icon={<UnorderedListOutlined />} onClick={() => onFormat?.('unorderedList')} />
|
||||
</Tooltip>
|
||||
<Tooltip title="链接">
|
||||
<Button icon={<LinkOutlined />} onClick={() => onFormat?.('link')} />
|
||||
</Tooltip>
|
||||
<div className="h-5 border-r border-gray-300" />
|
||||
<Tooltip title="协作者">
|
||||
<Button icon={<UserOutlined />} />
|
||||
</Tooltip>
|
||||
</Space>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
143
app/components/Editor/MonacoEditor.tsx
Normal file
143
app/components/Editor/MonacoEditor.tsx
Normal file
@ -0,0 +1,143 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
// app/components/Editor/MonacoEditor.tsx
|
||||
'use client'
|
||||
|
||||
import { useRef, useCallback, useEffect } from 'react'
|
||||
import Editor from '@monaco-editor/react'
|
||||
import * as Y from 'yjs'
|
||||
import { WebsocketProvider } from 'y-websocket'
|
||||
import { MonacoBinding } from 'y-monaco'
|
||||
import { message } from 'antd'
|
||||
import { DocumentService } from '@/app/lib/services/document'
|
||||
|
||||
interface MonacoEditorProps {
|
||||
docId: string
|
||||
defaultValue?: string
|
||||
onContentChange?: (content: string) => void
|
||||
}
|
||||
|
||||
export default function MonacoEditor({
|
||||
docId,
|
||||
defaultValue = '',
|
||||
onContentChange
|
||||
}: MonacoEditorProps) {
|
||||
const editorRef = useRef<any>(null)
|
||||
const monacoRef = useRef<any>(null)
|
||||
|
||||
// 自动保存防抖
|
||||
const autoSaveDebounced = useCallback(
|
||||
debounce(async (content: string) => {
|
||||
try {
|
||||
await DocumentService.saveDocument({
|
||||
id: docId,
|
||||
content,
|
||||
})
|
||||
message.success('自动保存成功')
|
||||
} catch (error) {
|
||||
message.error('自动保存失败'+error)
|
||||
}
|
||||
}, 2000),
|
||||
[docId]
|
||||
)
|
||||
|
||||
// 手动保存
|
||||
const saveContent = async () => {
|
||||
if (!editorRef.current) return
|
||||
|
||||
const content = editorRef.current.getValue()
|
||||
try {
|
||||
await DocumentService.saveDocument({
|
||||
id: docId,
|
||||
content,
|
||||
})
|
||||
message.success('保存成功')
|
||||
} catch (error) {
|
||||
message.error('保存失败'+error)
|
||||
}
|
||||
}
|
||||
|
||||
// 监听内容变化
|
||||
const handleEditorChange = (value: string | undefined) => {
|
||||
if (value === undefined) return
|
||||
|
||||
onContentChange?.(value)
|
||||
autoSaveDebounced(value)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!editorRef.current) return
|
||||
|
||||
const ydoc = new Y.Doc()
|
||||
const provider = new WebsocketProvider(
|
||||
'ws://localhost:1234',
|
||||
docId,
|
||||
ydoc
|
||||
)
|
||||
const type = ydoc.getText('monaco')
|
||||
|
||||
const binding = new MonacoBinding(
|
||||
type,
|
||||
editorRef.current.getModel(),
|
||||
new Set([editorRef.current]),
|
||||
provider.awareness
|
||||
)
|
||||
|
||||
// 添加快捷键保存
|
||||
editorRef.current.addCommand(
|
||||
monacoRef.current.KeyMod.CtrlCmd | monacoRef.current.KeyCode.KeyS,
|
||||
() => {
|
||||
saveContent()
|
||||
}
|
||||
)
|
||||
|
||||
return () => {
|
||||
ydoc.destroy()
|
||||
provider.destroy()
|
||||
}
|
||||
}, [docId])
|
||||
|
||||
function handleEditorDidMount(editor: any, monaco: any) {
|
||||
editorRef.current = editor
|
||||
monacoRef.current = monaco
|
||||
|
||||
editor.updateOptions({
|
||||
fontSize: 14,
|
||||
fontFamily: 'JetBrains Mono, Consolas, monospace',
|
||||
minimap: { enabled: false },
|
||||
scrollBeyondLastLine: false,
|
||||
lineNumbers: 'on',
|
||||
renderWhitespace: 'boundary',
|
||||
wordWrap: 'on',
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full relative">
|
||||
<Editor
|
||||
height="100%"
|
||||
defaultLanguage="markdown"
|
||||
// defaultValue={defaultValue}
|
||||
theme="vs-light"
|
||||
// onMount={handleEditorDidMount}
|
||||
// onChange={handleEditorChange}
|
||||
options={{
|
||||
automaticLayout: true,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 防抖函数
|
||||
function debounce(func: Function, wait: number) {
|
||||
let timeout: NodeJS.Timeout
|
||||
return function executedFunction(...args: any[]) {
|
||||
const later = () => {
|
||||
clearTimeout(timeout)
|
||||
func(...args)
|
||||
}
|
||||
clearTimeout(timeout)
|
||||
timeout = setTimeout(later, wait)
|
||||
}
|
||||
}
|
||||
0
app/components/Editor/index.tsx
Normal file
0
app/components/Editor/index.tsx
Normal file
340
app/components/FileTree/FileTree.tsx
Normal file
340
app/components/FileTree/FileTree.tsx
Normal file
@ -0,0 +1,340 @@
|
||||
// app/components/FileTree/FileTree.tsx
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Tree, Input, Button, Dropdown, Modal, Form, message } from 'antd'
|
||||
import type { DataNode, TreeProps } from 'antd/es/tree'
|
||||
import { Key } from 'react'
|
||||
import {
|
||||
FileOutlined,
|
||||
FolderOutlined,
|
||||
PlusOutlined,
|
||||
FolderOpenOutlined,
|
||||
LoadingOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { FileService } from '@/app/lib/services/file'
|
||||
|
||||
interface FileNode {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
children?: FileNode[]
|
||||
parentKey?: string
|
||||
}
|
||||
|
||||
interface FileModalProps {
|
||||
type: 'new-doc' | 'new-folder' | 'rename'
|
||||
title: string
|
||||
open: boolean
|
||||
initialValue?: string
|
||||
onOk: (value: string) => void
|
||||
onCancel: () => void
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
// 文件操作模态框组件
|
||||
const FileModal: React.FC<FileModalProps> = ({
|
||||
type,
|
||||
title,
|
||||
open,
|
||||
initialValue = '',
|
||||
onOk,
|
||||
onCancel,
|
||||
loading,
|
||||
}) => {
|
||||
const [form] = Form.useForm()
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
form.setFieldsValue({ name: initialValue })
|
||||
}
|
||||
}, [open, initialValue, form])
|
||||
|
||||
const handleOk = async () => {
|
||||
try {
|
||||
const values = await form.validateFields()
|
||||
onOk(values.name)
|
||||
} catch (error) {
|
||||
// 表单验证错误
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={title}
|
||||
open={open}
|
||||
onOk={handleOk}
|
||||
onCancel={onCancel}
|
||||
confirmLoading={loading}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="名称"
|
||||
rules={[
|
||||
{ required: true, message: '请输入名称' },
|
||||
{
|
||||
pattern: /^[^\\/:*?"<>|]+$/,
|
||||
message: '名称不能包含特殊字符 \\ / : * ? " < > |'
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Input
|
||||
autoFocus
|
||||
placeholder={type === 'new-folder' ? '新建文件夹' : '新建文档'}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default function FileTree() {
|
||||
const router = useRouter()
|
||||
const [treeData, setTreeData] = useState<DataNode[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [searchValue, setSearchValue] = useState('')
|
||||
const [selectedKeys, setSelectedKeys] = useState<Key[]>([])
|
||||
const [expandedKeys, setExpandedKeys] = useState<Key[]>([])
|
||||
|
||||
// 模态框状态
|
||||
const [modal, setModal] = useState<{
|
||||
type: 'new-doc' | 'new-folder' | 'rename'
|
||||
open: boolean
|
||||
title: string
|
||||
parentId?: string
|
||||
targetId?: string
|
||||
initialValue?: string
|
||||
}>({
|
||||
type: 'new-doc',
|
||||
open: false,
|
||||
title: '',
|
||||
})
|
||||
|
||||
// 加载文件树数据
|
||||
const loadFileTree = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const files = await FileService.getFileTree()
|
||||
const formattedData = formatFileTree(files)
|
||||
setTreeData(formattedData)
|
||||
} catch (error) {
|
||||
message.error('加载文件失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadFileTree()
|
||||
}, [])
|
||||
|
||||
// 格式化文件树数据
|
||||
const formatFileTree = (files: FileNode[]): DataNode[] => {
|
||||
return files.map(file => ({
|
||||
key: file.id,
|
||||
title: file.name,
|
||||
isLeaf: file.type === 'file',
|
||||
children: file.children ? formatFileTree(file.children) : undefined
|
||||
}))
|
||||
}
|
||||
|
||||
// 处理新建文档
|
||||
const handleNewDocument = (parentId: string) => {
|
||||
setModal({
|
||||
type: 'new-doc',
|
||||
open: true,
|
||||
title: '新建文档',
|
||||
parentId,
|
||||
})
|
||||
}
|
||||
|
||||
// 处理新建文件夹
|
||||
const handleNewFolder = (parentId: string) => {
|
||||
setModal({
|
||||
type: 'new-folder',
|
||||
open: true,
|
||||
title: '新建文件夹',
|
||||
parentId,
|
||||
})
|
||||
}
|
||||
|
||||
// 处理重命名
|
||||
const handleRename = (targetId: string, initialValue: string) => {
|
||||
setModal({
|
||||
type: 'rename',
|
||||
open: true,
|
||||
title: '重命名',
|
||||
targetId,
|
||||
initialValue,
|
||||
})
|
||||
}
|
||||
|
||||
// 处理删除
|
||||
const handleDelete = async (id: string) => {
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: '删除后无法恢复,是否继续?',
|
||||
okText: '确认',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await FileService.deleteFile(id)
|
||||
message.success('删除成功')
|
||||
loadFileTree()
|
||||
} catch (error) {
|
||||
message.error('删除失败'+error)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 处理模态框确认
|
||||
const handleModalOk = async (name: string) => {
|
||||
try {
|
||||
setLoading(true)
|
||||
if (modal.type === 'rename' && modal.targetId) {
|
||||
await FileService.updateFile({
|
||||
id: modal.targetId,
|
||||
name,
|
||||
})
|
||||
message.success('重命名成功')
|
||||
} else if (modal.parentId) {
|
||||
await FileService.createFile({
|
||||
name,
|
||||
type: modal.type === 'new-folder' ? 'folder' : 'file',
|
||||
parentId: modal.parentId,
|
||||
})
|
||||
message.success(modal.type === 'new-folder' ? '文件夹创建成功' : '文档创建成功')
|
||||
}
|
||||
loadFileTree()
|
||||
setModal({ ...modal, open: false })
|
||||
} catch (error) {
|
||||
message.error('操作失败' + error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 处理模态框取消
|
||||
const handleModalCancel = () => {
|
||||
setModal({ ...modal, open: false })
|
||||
}
|
||||
|
||||
// 获取右键菜单
|
||||
const getContextMenu = (node: DataNode) => ({
|
||||
items: [
|
||||
{
|
||||
key: 'new-doc',
|
||||
label: '新建文档',
|
||||
icon: <FileOutlined />,
|
||||
onClick: () => handleNewDocument(node.key as string),
|
||||
},
|
||||
{
|
||||
key: 'new-folder',
|
||||
label: '新建文件夹',
|
||||
icon: <FolderOutlined />,
|
||||
onClick: () => handleNewFolder(node.key as string),
|
||||
},
|
||||
{
|
||||
type: 'divider',
|
||||
},
|
||||
{
|
||||
key: 'rename',
|
||||
label: '重命名',
|
||||
onClick: () => handleRename(node.key as string, node.title as string),
|
||||
},
|
||||
{
|
||||
key: 'delete',
|
||||
label: '删除',
|
||||
danger: true,
|
||||
onClick: () => handleDelete(node.key as string),
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
// 处理树节点选择
|
||||
const onSelect: TreeProps['onSelect'] = (selectedKeys, info) => {
|
||||
setSelectedKeys(selectedKeys as string[])
|
||||
const key = selectedKeys[0]
|
||||
if (info.node.isLeaf) {
|
||||
router.push(`/document/${key}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 处理树节点展开/收起
|
||||
const onExpand = (expandedKeys: Key[]) => {
|
||||
setExpandedKeys(expandedKeys)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="p-2 flex items-center gap-2 border-b">
|
||||
<Input
|
||||
placeholder="搜索文件..."
|
||||
value={searchValue}
|
||||
onChange={(e) => setSearchValue(e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
{
|
||||
key: 'new-doc',
|
||||
label: '新建文档',
|
||||
icon: <FileOutlined />,
|
||||
onClick: () => handleNewDocument('root'),
|
||||
},
|
||||
{
|
||||
key: 'new-folder',
|
||||
label: '新建文件夹',
|
||||
icon: <FolderOutlined />,
|
||||
onClick: () => handleNewFolder('root'),
|
||||
},
|
||||
],
|
||||
}}
|
||||
>
|
||||
<Button type="text" icon={<PlusOutlined />} />
|
||||
</Dropdown>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto p-2">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<LoadingOutlined className="text-2xl" />
|
||||
</div>
|
||||
) : (
|
||||
<Tree
|
||||
treeData={treeData}
|
||||
onSelect={onSelect}
|
||||
onExpand={onExpand}
|
||||
expandedKeys={expandedKeys}
|
||||
selectedKeys={selectedKeys}
|
||||
showIcon
|
||||
icon={(node) =>
|
||||
node.isLeaf ? <FileOutlined /> :
|
||||
node.expanded ? <FolderOpenOutlined /> : <FolderOutlined />
|
||||
}
|
||||
titleRender={(nodeData) => (
|
||||
<Dropdown
|
||||
menu={getContextMenu(nodeData)}
|
||||
trigger={['contextMenu']}
|
||||
>
|
||||
<span className="w-full inline-block">{nodeData.title}</span>
|
||||
</Dropdown>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<FileModal
|
||||
type={modal.type}
|
||||
title={modal.title}
|
||||
open={modal.open}
|
||||
initialValue={modal.initialValue}
|
||||
onOk={handleModalOk}
|
||||
onCancel={handleModalCancel}
|
||||
loading={loading}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
0
app/components/ui/navbar.tsx
Normal file
0
app/components/ui/navbar.tsx
Normal file
@ -2,20 +2,4 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
color: var(--foreground);
|
||||
background: var(--background);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
|
||||
@ -1,34 +1,32 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
// app/layout.tsx
|
||||
import { Inter } from 'next/font/google'
|
||||
import '@ant-design/v5-patch-for-react-19';
|
||||
import { ConfigProvider } from 'antd'
|
||||
import zhCN from 'antd/locale/zh_CN'
|
||||
import { AntdRegistry } from '@ant-design/nextjs-registry'
|
||||
import './globals.css'
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
const inter = Inter({ subsets: ['latin'] })
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
};
|
||||
export const metadata = {
|
||||
title: '在线协作文档编辑器',
|
||||
description: '基于 Next.js 的在线协作文档编辑器',
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
{children}
|
||||
<html lang="zh-CN">
|
||||
<body className={inter.className}>
|
||||
<AntdRegistry>
|
||||
<ConfigProvider locale={zhCN}>
|
||||
{children}
|
||||
</ConfigProvider>
|
||||
</AntdRegistry>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
)
|
||||
}
|
||||
0
app/lib/auth.ts
Normal file
0
app/lib/auth.ts
Normal file
0
app/lib/db.ts
Normal file
0
app/lib/db.ts
Normal file
5
app/lib/prisma.ts
Normal file
5
app/lib/prisma.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
// use `prisma` in your application to read and write data in your DB
|
||||
export default prisma
|
||||
33
app/lib/services/document.ts
Normal file
33
app/lib/services/document.ts
Normal file
@ -0,0 +1,33 @@
|
||||
// app/lib/services/document.ts
|
||||
import axios from "axios";
|
||||
|
||||
interface SaveDocumentParams {
|
||||
id: string;
|
||||
content: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export const DocumentService = {
|
||||
async saveDocument({ id, content, title }: SaveDocumentParams) {
|
||||
try {
|
||||
const response = await axios.put(`/api/documents/${id}`, {
|
||||
content,
|
||||
title,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error("Error saving document:", error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
async getDocument(id: string) {
|
||||
try {
|
||||
const response = await axios.get(`/api/documents/${id}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error("Error fetching document:", error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
};
|
||||
55
app/lib/services/file.ts
Normal file
55
app/lib/services/file.ts
Normal file
@ -0,0 +1,55 @@
|
||||
// app/lib/services/file.ts
|
||||
import axios from "axios";
|
||||
|
||||
interface CreateFileParams {
|
||||
name: string;
|
||||
type: "file" | "folder";
|
||||
parentId?: string;
|
||||
}
|
||||
|
||||
interface UpdateFileParams {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export const FileService = {
|
||||
async getFileTree() {
|
||||
try {
|
||||
const response = await axios.get("/api/files");
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error("Error fetching file tree:", error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
async createFile(params: CreateFileParams) {
|
||||
try {
|
||||
const response = await axios.post("/api/files", params);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error("Error creating file:", error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
async updateFile(params: UpdateFileParams) {
|
||||
try {
|
||||
const response = await axios.put(`/api/files/${params.id}`, params);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error("Error updating file:", error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
async deleteFile(id: string) {
|
||||
try {
|
||||
const response = await axios.delete(`/api/files/${id}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error("Error deleting file:", error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
};
|
||||
130
app/page.tsx
130
app/page.tsx
@ -1,101 +1,37 @@
|
||||
import Image from "next/image";
|
||||
// app/page.tsx
|
||||
'use client'
|
||||
|
||||
import { Layout, Button } from 'antd'
|
||||
import Link from 'next/link'
|
||||
import FileTree from './components/FileTree/FileTree'
|
||||
|
||||
const { Header, Sider, Content } = Layout
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20 font-[family-name:var(--font-geist-sans)]">
|
||||
<main className="flex flex-col gap-8 row-start-2 items-center sm:items-start">
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={180}
|
||||
height={38}
|
||||
priority
|
||||
/>
|
||||
<ol className="list-inside list-decimal text-sm text-center sm:text-left font-[family-name:var(--font-geist-mono)]">
|
||||
<li className="mb-2">
|
||||
Get started by editing{" "}
|
||||
<code className="bg-black/[.05] dark:bg-white/[.06] px-1 py-0.5 rounded font-semibold">
|
||||
app/page.tsx
|
||||
</code>
|
||||
.
|
||||
</li>
|
||||
<li>Save and see your changes instantly.</li>
|
||||
</ol>
|
||||
|
||||
<div className="flex gap-4 items-center flex-col sm:flex-row">
|
||||
<a
|
||||
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5"
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
Deploy now
|
||||
</a>
|
||||
<a
|
||||
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:min-w-44"
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Read our docs
|
||||
</a>
|
||||
<Layout className="h-screen">
|
||||
<Header className="bg-white px-4 flex items-center justify-between border-b">
|
||||
<h1 className="text-xl font-semibold">协作文档编辑器</h1>
|
||||
<div className="flex gap-4">
|
||||
<Link href="/document">
|
||||
<Button type="primary">新建文档</Button>
|
||||
</Link>
|
||||
<Link href="/login">
|
||||
<Button>登录</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</main>
|
||||
<footer className="row-start-3 flex gap-6 flex-wrap items-center justify-center">
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="/file.svg"
|
||||
alt="File icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Learn
|
||||
</a>
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="/window.svg"
|
||||
alt="Window icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Examples
|
||||
</a>
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="/globe.svg"
|
||||
alt="Globe icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Go to nextjs.org →
|
||||
</a>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
</Header>
|
||||
<Layout>
|
||||
<Sider width={250} className="bg-white border-r">
|
||||
<FileTree />
|
||||
</Sider>
|
||||
<Content className="p-6">
|
||||
<div className="bg-white rounded-lg shadow p-6 min-h-full">
|
||||
<h2 className="text-lg font-medium mb-4">最近的文档</h2>
|
||||
{/* 最近文档列表将在这里显示 */}
|
||||
</div>
|
||||
</Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
0
app/providers.tsx
Normal file
0
app/providers.tsx
Normal file
@ -10,7 +10,14 @@ const compat = new FlatCompat({
|
||||
});
|
||||
|
||||
const eslintConfig = [
|
||||
...compat.extends("next/core-web-vitals", "next/typescript"),
|
||||
...compat.config({
|
||||
extends: ["next/core-web-vitals", "next/typescript"],
|
||||
rules: {
|
||||
"no-unused-vars": "off",
|
||||
"@typescript-eslint/no-unused-vars": "off",
|
||||
"@typescript-eslint/no-unsafe-function-type":"off"
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
export default eslintConfig;
|
||||
|
||||
7
jest.config.js
Normal file
7
jest.config.js
Normal file
@ -0,0 +1,7 @@
|
||||
module.exports = {
|
||||
testEnvironment: 'jsdom',
|
||||
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
|
||||
moduleNameMapper: {
|
||||
'^@/(.*)$': '<rootDir>/$1',
|
||||
},
|
||||
}
|
||||
1
jest.setup.js
Normal file
1
jest.setup.js
Normal file
@ -0,0 +1 @@
|
||||
import '@testing-library/jest-dom'
|
||||
43
package.json
43
package.json
@ -6,23 +6,50 @@
|
||||
"dev": "next dev --turbopack",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
"lint": "next lint",
|
||||
"server": "node server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^5.6.0",
|
||||
"@ant-design/nextjs-registry": "^1.0.2",
|
||||
"@ant-design/v5-patch-for-react-19": "^1.0.3",
|
||||
"@codemirror/lang-javascript": "^6.2.2",
|
||||
"@codemirror/view": "^6.36.2",
|
||||
"@monaco-editor/react": "^4.6.0",
|
||||
"@prisma/client": "^6.3.0",
|
||||
"@types/ws": "^8.5.14",
|
||||
"antd": "^5.23.3",
|
||||
"axios": "^1.7.9",
|
||||
"codemirror": "^6.0.1",
|
||||
"next": "15.1.6",
|
||||
"next-auth": "^4.24.11",
|
||||
"prisma": "^6.3.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"next": "15.1.6"
|
||||
"socket.io-client": "^4.8.1",
|
||||
"ws": "^8.18.0",
|
||||
"y-codemirror.next": "^0.3.5",
|
||||
"y-monaco": "^0.1.6",
|
||||
"y-websocket": "^2.1.0",
|
||||
"yjs": "^13.6.23"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@eslint/eslintrc": "^3",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.2.0",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/node": "^20.17.16",
|
||||
"@types/react": "^19.0.8",
|
||||
"@types/react-dom": "^19",
|
||||
"postcss": "^8",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "15.1.6",
|
||||
"@eslint/eslintrc": "^3"
|
||||
"jest": "^29.7.0",
|
||||
"jest-environment-jsdom": "^29.7.0",
|
||||
"postcss": "^8",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.7.3"
|
||||
},
|
||||
"packageManager": "pnpm@9.15.4+sha512.b2dc20e2fc72b3e18848459b37359a32064663e5627a51e4c74b2c29dd8e8e0491483c3abb40789cfd578bf362fb6ba8261b05f0387d76792ed6e23ea3b1b6a0"
|
||||
}
|
||||
|
||||
4587
pnpm-lock.yaml
generated
4587
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
28
prisma/schema.prisma
Normal file
28
prisma/schema.prisma
Normal file
@ -0,0 +1,28 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model Document {
|
||||
id String @id @default(cuid())
|
||||
title String
|
||||
content String @db.Text
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model File {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
type String // "file" or "folder"
|
||||
content String? @db.Text
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
parentId String?
|
||||
parent File? @relation("FileToFile", fields: [parentId], references: [id], onDelete: Cascade)
|
||||
children File[] @relation("FileToFile")
|
||||
}
|
||||
23
server.js
Normal file
23
server.js
Normal file
@ -0,0 +1,23 @@
|
||||
// server.js
|
||||
const WebSocket = require('ws')
|
||||
const http = require('http')
|
||||
const wsServer = new WebSocket.Server({ port: 1234 })
|
||||
|
||||
const documents = new Map()
|
||||
|
||||
wsServer.on('connection', (socket) => {
|
||||
console.log('New client connected')
|
||||
|
||||
socket.on('message', (message) => {
|
||||
// 广播消息给所有连接的客户端
|
||||
wsServer.clients.forEach((client) => {
|
||||
if (client !== socket && client.readyState === WebSocket.OPEN) {
|
||||
client.send(message)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
socket.on('close', () => {
|
||||
console.log('Client disconnected')
|
||||
})
|
||||
})
|
||||
@ -15,4 +15,7 @@ export default {
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
corePlugins:{
|
||||
preflight:false,
|
||||
}
|
||||
} satisfies Config;
|
||||
|
||||
@ -22,6 +22,6 @@
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user