first commit

This commit is contained in:
denislov 2025-01-31 19:32:50 +08:00
parent b83a7ff122
commit 0a0e95bab2
33 changed files with 5706 additions and 160 deletions

7
.eslintrc.json Normal file
View File

@ -0,0 +1,7 @@
{
"extends": ["next/core-web-vitals", "prettier"],
"rules": {
"no-unused-vars": "warn",
"@typescript-eslint/no-unused-vars": "warn"
}
}

View 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);
});
});
});

View File

@ -0,0 +1,6 @@
const Login: React.FC = () => {
return <></>;
}
export default Login

View File

@ -0,0 +1,6 @@
const Register: React.FC = () => {
return <></>;
}
export default Register

View 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>
)
}

View File

@ -0,0 +1,6 @@
const Document: React.FC = () => {
return <></>;
}
export default Document

View File

View 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 }
);
}
}

View 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
View 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 }
);
}
}

View 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>
)
}

View 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)
}
}

View File

View 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>
)
}

View File

View 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;
}

View File

@ -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
View File

0
app/lib/db.ts Normal file
View File

5
app/lib/prisma.ts Normal file
View 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

View 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
View 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;
}
},
};

View File

@ -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
View File

View 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
View File

@ -0,0 +1,7 @@
module.exports = {
testEnvironment: 'jsdom',
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/$1',
},
}

1
jest.setup.js Normal file
View File

@ -0,0 +1 @@
import '@testing-library/jest-dom'

View File

@ -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

File diff suppressed because it is too large Load Diff

28
prisma/schema.prisma Normal file
View 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
View 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')
})
})

View File

@ -15,4 +15,7 @@ export default {
},
},
plugins: [],
corePlugins:{
preflight:false,
}
} satisfies Config;

View File

@ -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"]
}