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 components;
|
||||||
@tailwind utilities;
|
@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";
|
// app/layout.tsx
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import { Inter } from 'next/font/google'
|
||||||
import "./globals.css";
|
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({
|
const inter = Inter({ subsets: ['latin'] })
|
||||||
variable: "--font-geist-sans",
|
|
||||||
subsets: ["latin"],
|
|
||||||
});
|
|
||||||
|
|
||||||
const geistMono = Geist_Mono({
|
export const metadata = {
|
||||||
variable: "--font-geist-mono",
|
title: '在线协作文档编辑器',
|
||||||
subsets: ["latin"],
|
description: '基于 Next.js 的在线协作文档编辑器',
|
||||||
});
|
}
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: "Create Next App",
|
|
||||||
description: "Generated by create next app",
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode
|
||||||
}>) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="zh-CN">
|
||||||
<body
|
<body className={inter.className}>
|
||||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
<AntdRegistry>
|
||||||
>
|
<ConfigProvider locale={zhCN}>
|
||||||
{children}
|
{children}
|
||||||
|
</ConfigProvider>
|
||||||
|
</AntdRegistry>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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() {
|
export default function Home() {
|
||||||
return (
|
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)]">
|
<Layout className="h-screen">
|
||||||
<main className="flex flex-col gap-8 row-start-2 items-center sm:items-start">
|
<Header className="bg-white px-4 flex items-center justify-between border-b">
|
||||||
<Image
|
<h1 className="text-xl font-semibold">协作文档编辑器</h1>
|
||||||
className="dark:invert"
|
<div className="flex gap-4">
|
||||||
src="/next.svg"
|
<Link href="/document">
|
||||||
alt="Next.js logo"
|
<Button type="primary">新建文档</Button>
|
||||||
width={180}
|
</Link>
|
||||||
height={38}
|
<Link href="/login">
|
||||||
priority
|
<Button>登录</Button>
|
||||||
/>
|
</Link>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</Header>
|
||||||
<footer className="row-start-3 flex gap-6 flex-wrap items-center justify-center">
|
<Layout>
|
||||||
<a
|
<Sider width={250} className="bg-white border-r">
|
||||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
<FileTree />
|
||||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
</Sider>
|
||||||
target="_blank"
|
<Content className="p-6">
|
||||||
rel="noopener noreferrer"
|
<div className="bg-white rounded-lg shadow p-6 min-h-full">
|
||||||
>
|
<h2 className="text-lg font-medium mb-4">最近的文档</h2>
|
||||||
<Image
|
{/* 最近文档列表将在这里显示 */}
|
||||||
aria-hidden
|
</div>
|
||||||
src="/file.svg"
|
</Content>
|
||||||
alt="File icon"
|
</Layout>
|
||||||
width={16}
|
</Layout>
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
0
app/providers.tsx
Normal file
0
app/providers.tsx
Normal file
@ -10,7 +10,14 @@ const compat = new FlatCompat({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const eslintConfig = [
|
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;
|
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",
|
"dev": "next dev --turbopack",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint"
|
"lint": "next lint",
|
||||||
|
"server": "node server.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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": "^19.0.0",
|
||||||
"react-dom": "^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": {
|
"devDependencies": {
|
||||||
"typescript": "^5",
|
"@eslint/eslintrc": "^3",
|
||||||
"@types/node": "^20",
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
"@types/react": "^19",
|
"@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",
|
"@types/react-dom": "^19",
|
||||||
"postcss": "^8",
|
"autoprefixer": "^10.4.20",
|
||||||
"tailwindcss": "^3.4.1",
|
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "15.1.6",
|
"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"
|
"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: [],
|
plugins: [],
|
||||||
|
corePlugins:{
|
||||||
|
preflight:false,
|
||||||
|
}
|
||||||
} satisfies Config;
|
} 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"]
|
"exclude": ["node_modules"]
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user