feat: 初始化公网编辑器后端项目

- 配置 Next.js 14 + TypeScript + TailwindCSS
- 集成 Supabase (Auth + Storage + Database)
- 实现认证 API (login/logout/me)
- 实现文件存储 API (upload/list/get/delete)
- 实现数据库操作 API (tables/query)
- 添加 Supabase 初始化 SQL 脚本
- 添加环境变量配置示例
- 添加项目文档
This commit is contained in:
2026-03-17 01:03:03 +08:00
commit 23b117a3fa
32 changed files with 8446 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
# Supabase Configuration
NEXT_PUBLIC_SUPABASE_URL=your-supabase-url
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-supabase-anon-key
SUPABASE_SERVICE_ROLE_KEY=your-supabase-service-role-key
# App Configuration
NEXT_PUBLIC_APP_URL=http://localhost:3000
+3
View File
@@ -0,0 +1,3 @@
{
"extends": ["next/core-web-vitals", "next/typescript"]
}
+36
View File
@@ -0,0 +1,36 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
.yarn/install-state.gz
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
+159
View File
@@ -0,0 +1,159 @@
# Public Editor Backend
公网编辑器后端服务 - 基于 Next.js API + Supabase
## 技术栈
- **Framework:** Next.js 14 (App Router)
- **Language:** TypeScript
- **Database:** Supabase (PostgreSQL)
- **Storage:** Supabase Storage
- **Auth:** Supabase Auth
## 快速开始
### 1. 配置 Supabase
1. 在 [Supabase](https://supabase.com) 创建新项目
2. 获取项目 URL 和 API Keys
3. 复制 `.env.local.example``.env.local` 并填入配置
```bash
cp .env.local.example .env.local
```
### 2. 初始化数据库
在 Supabase SQL Editor 中运行 `supabase/init.sql` 脚本:
```sql
-- 复制 supabase/init.sql 的全部内容并执行
```
这将创建:
- `files` 数据表
- Row Level Security 策略
- Storage Bucket 和访问策略
### 3. 安装依赖
```bash
npm install
```
### 4. 启动开发服务器
```bash
npm run dev
```
访问 http://localhost:3000
## API 端点
### 认证
- `POST /api/auth/login` - 用户登录
- `POST /api/auth/logout` - 用户登出
- `GET /api/auth/me` - 获取当前用户信息
### 文件存储
- `GET /api/files` - 获取文件列表
- `POST /api/files/upload` - 上传文件
- `GET /api/files/[id]` - 获取单个文件信息
- `DELETE /api/files/[id]` - 删除文件
### 数据库操作
- `GET /api/database/tables` - 获取所有表
- `POST /api/database/query` - 执行数据库操作
## API 使用示例
### 登录
```bash
curl -X POST http://localhost:3000/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"user@example.com","password":"password123"}'
```
### 上传文件
```bash
curl -X POST http://localhost:3000/api/files/upload \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-F "file=@/path/to/file.pdf"
```
### 获取文件列表
```bash
curl http://localhost:3000/api/files \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"
```
### 数据库查询
```bash
curl -X POST http://localhost:3000/api/database/query \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"table": "files",
"operation": "select",
"filters": {}
}'
```
## 环境变量
| 变量名 | 说明 |
|--------|------|
| `NEXT_PUBLIC_SUPABASE_URL` | Supabase 项目 URL |
| `NEXT_PUBLIC_SUPABASE_ANON_KEY` | Supabase 匿名 Key |
| `SUPABASE_SERVICE_ROLE_KEY` | Supabase 服务角色 Key |
| `NEXT_PUBLIC_APP_URL` | 应用 URL |
## 安全
- 所有 API 端点都需要认证(除了登录)
- 使用 Supabase Row Level Security 确保用户只能访问自己的数据
- Storage 策略确保用户只能操作自己文件夹中的文件
- 使用 HTTP-only cookies 存储 session
## 部署
### Vercel
1. 连接 GitHub 仓库
2. 添加环境变量
3. 部署
### Docker
```bash
docker build -t public-editor .
docker run -p 3000:3000 --env-file .env.local public-editor
```
## 开发
```bash
# 开发模式
npm run dev
# 构建
npm run build
# 生产模式
npm start
# 代码检查
npm run lint
```
## License
MIT
+4
View File
@@ -0,0 +1,4 @@
/** @type {import('next').NextConfig} */
const nextConfig = {};
export default nextConfig;
+6775
View File
File diff suppressed because it is too large Load Diff
+30
View File
@@ -0,0 +1,30 @@
{
"name": "public-editor",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@monaco-editor/react": "^4.7.0",
"next": "14.2.35",
"react": "^18",
"react-dom": "^18",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
"uuid": "^13.0.0"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"eslint": "^8",
"eslint-config-next": "14.2.35",
"postcss": "^8",
"tailwindcss": "^3.4.1",
"typescript": "^5"
}
}
+8
View File
@@ -0,0 +1,8 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
},
};
export default config;
+67
View File
@@ -0,0 +1,67 @@
import { NextRequest, NextResponse } from 'next/server';
import { supabase } from '@/lib/supabase';
export async function POST(request: NextRequest) {
try {
const { email, password } = await request.json();
if (!email || !password) {
return NextResponse.json(
{ success: false, error: 'Email and password are required' },
{ status: 400 }
);
}
// Sign in with Supabase Auth
const { data, error } = await supabase.auth.signInWithPassword({
email,
password,
});
if (error) {
return NextResponse.json(
{ success: false, error: error.message },
{ status: 401 }
);
}
// Set auth cookie
const response = NextResponse.json({
success: true,
data: {
user: data.user,
},
});
// Set session cookie
if (data.session) {
response.cookies.set({
name: 'sb-access-token',
value: data.session.access_token,
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
maxAge: 60 * 60 * 24 * 7, // 1 week
});
response.cookies.set({
name: 'sb-refresh-token',
value: data.session.refresh_token,
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
maxAge: 60 * 60 * 24 * 7, // 1 week
});
}
return response;
} catch (error) {
console.error('Login error:', error);
return NextResponse.json(
{ success: false, error: 'Internal server error' },
{ status: 500 }
);
}
}
+35
View File
@@ -0,0 +1,35 @@
import { NextRequest, NextResponse } from 'next/server';
import { supabase } from '@/lib/supabase';
export async function POST(request: NextRequest) {
try {
// Get the access token from cookie
const accessToken = request.cookies.get('sb-access-token')?.value;
if (accessToken) {
// Sign out from Supabase
await supabase.auth.signOut();
}
// Clear auth cookies
const response = NextResponse.json({
success: true,
data: { message: 'Logged out successfully' },
});
response.cookies.delete('sb-access-token');
response.cookies.delete('sb-refresh-token');
return response;
} catch (error) {
console.error('Logout error:', error);
return NextResponse.json(
{ success: false, error: 'Internal server error' },
{ status: 500 }
);
}
}
export async function GET(request: NextRequest) {
return POST(request);
}
+41
View File
@@ -0,0 +1,41 @@
import { NextRequest, NextResponse } from 'next/server';
import { supabase } from '@/lib/supabase';
export async function GET(request: NextRequest) {
try {
// Get the access token from cookie or header
const accessToken =
request.cookies.get('sb-access-token')?.value ||
request.headers.get('authorization')?.replace('Bearer ', '');
if (!accessToken) {
return NextResponse.json(
{ success: false, error: 'Not authenticated' },
{ status: 401 }
);
}
// Get user from Supabase
const { data, error } = await supabase.auth.getUser(accessToken);
if (error) {
return NextResponse.json(
{ success: false, error: error.message },
{ status: 401 }
);
}
return NextResponse.json({
success: true,
data: {
user: data.user,
},
});
} catch (error) {
console.error('Get user error:', error);
return NextResponse.json(
{ success: false, error: 'Internal server error' },
{ status: 500 }
);
}
}
+164
View File
@@ -0,0 +1,164 @@
import { NextRequest, NextResponse } from 'next/server';
import { supabase } from '@/lib/supabase';
// POST - Execute a query on a table
export async function POST(request: NextRequest) {
try {
// Get the access token from cookie or header
const accessToken =
request.cookies.get('sb-access-token')?.value ||
request.headers.get('authorization')?.replace('Bearer ', '');
if (!accessToken) {
return NextResponse.json(
{ success: false, error: 'Not authenticated' },
{ status: 401 }
);
}
// Verify user
const { data: userData, error: userError } = await supabase.auth.getUser(accessToken);
if (userError || !userData.user) {
return NextResponse.json(
{ success: false, error: 'Invalid authentication' },
{ status: 401 }
);
}
const userId = userData.user.id;
const { table, operation, data: queryData, filters } = await request.json();
if (!table) {
return NextResponse.json(
{ success: false, error: 'Table name is required' },
{ status: 400 }
);
}
let result;
switch (operation) {
case 'select': {
let query = supabase.from(table).select('*');
// Apply filters if provided
if (filters) {
for (const [key, value] of Object.entries(filters)) {
query = query.eq(key, value);
}
}
// Only allow user to query their own data
const { data: columns } = await supabase
.from(table)
.select('*')
.limit(1);
if (columns && columns.length > 0 && 'user_id' in columns[0]) {
query = query.eq('user_id', userId);
}
const { data, error } = await query;
if (error) throw error;
result = data;
break;
}
case 'insert': {
const insertData = {
...queryData,
user_id: userId,
};
const { data, error } = await supabase
.from(table)
.insert(insertData)
.select()
.single();
if (error) throw error;
result = data;
break;
}
case 'update': {
if (!filters || Object.keys(filters).length === 0) {
return NextResponse.json(
{ success: false, error: 'Filters required for update' },
{ status: 400 }
);
}
let query = supabase.from(table).update(queryData);
// Apply filters
for (const [key, value] of Object.entries(filters)) {
query = query.eq(key, value);
}
// Ensure user can only update their own data
const { data: columns } = await supabase
.from(table)
.select('*')
.limit(1);
if (columns && columns.length > 0 && 'user_id' in columns[0]) {
query = query.eq('user_id', userId);
}
const { data, error } = await query.select().single();
if (error) throw error;
result = data;
break;
}
case 'delete': {
if (!filters || Object.keys(filters).length === 0) {
return NextResponse.json(
{ success: false, error: 'Filters required for delete' },
{ status: 400 }
);
}
let query = supabase.from(table).delete();
// Apply filters
for (const [key, value] of Object.entries(filters)) {
query = query.eq(key, value);
}
// Ensure user can only delete their own data
const { data: columns } = await supabase
.from(table)
.select('*')
.limit(1);
if (columns && columns.length > 0 && 'user_id' in columns[0]) {
query = query.eq('user_id', userId);
}
const { error } = await query;
if (error) throw error;
result = { deleted: true };
break;
}
default:
return NextResponse.json(
{ success: false, error: 'Invalid operation' },
{ status: 400 }
);
}
return NextResponse.json({
success: true,
data: result,
});
} catch (error) {
console.error('Database query error:', error);
return NextResponse.json(
{ success: false, error: 'Query failed' },
{ status: 500 }
);
}
}
+59
View File
@@ -0,0 +1,59 @@
import { NextRequest, NextResponse } from 'next/server';
import { supabase } from '@/lib/supabase';
// GET - List all tables accessible to current user
export async function GET(request: NextRequest) {
try {
// Get the access token from cookie or header
const accessToken =
request.cookies.get('sb-access-token')?.value ||
request.headers.get('authorization')?.replace('Bearer ', '');
if (!accessToken) {
return NextResponse.json(
{ success: false, error: 'Not authenticated' },
{ status: 401 }
);
}
// Verify user
const { data: userData, error: userError } = await supabase.auth.getUser(accessToken);
if (userError || !userData.user) {
return NextResponse.json(
{ success: false, error: 'Invalid authentication' },
{ status: 401 }
);
}
// Query information schema for tables
const { data, error } = await supabase
.from('information_schema.tables')
.select('table_name, table_schema')
.eq('table_schema', 'public');
if (error) {
// Fallback: return known tables
return NextResponse.json({
success: true,
data: {
tables: ['files', 'users'],
},
});
}
const tables = (data || []).map((row: any) => row.table_name);
return NextResponse.json({
success: true,
data: {
tables: tables.filter((t: string) => !t.startsWith('pg_')),
},
});
} catch (error) {
console.error('List tables error:', error);
return NextResponse.json(
{ success: false, error: 'Failed to list tables' },
{ status: 500 }
);
}
}
+153
View File
@@ -0,0 +1,153 @@
import { NextRequest, NextResponse } from 'next/server';
import { supabase, supabaseAdmin } from '@/lib/supabase';
// DELETE - Delete a file
export async function DELETE(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
// Get the access token from cookie or header
const accessToken =
request.cookies.get('sb-access-token')?.value ||
request.headers.get('authorization')?.replace('Bearer ', '');
if (!accessToken) {
return NextResponse.json(
{ success: false, error: 'Not authenticated' },
{ status: 401 }
);
}
// Verify user
const { data: userData, error: userError } = await supabase.auth.getUser(accessToken);
if (userError || !userData.user) {
return NextResponse.json(
{ success: false, error: 'Invalid authentication' },
{ status: 401 }
);
}
const userId = userData.user.id;
const fileId = params.id;
// Get file record
const { data: fileData, error: fetchError } = await supabase
.from('files')
.select('*')
.eq('id', fileId)
.eq('user_id', userId)
.single();
if (fetchError || !fileData) {
return NextResponse.json(
{ success: false, error: 'File not found' },
{ status: 404 }
);
}
// Delete from storage
const { error: storageError } = await supabaseAdmin
.storage
.from('files')
.remove([fileData.path]);
if (storageError) {
throw storageError;
}
// Delete from database
const { error: dbError } = await supabase
.from('files')
.delete()
.eq('id', fileId);
if (dbError) {
throw dbError;
}
return NextResponse.json({
success: true,
data: { message: 'File deleted successfully' },
});
} catch (error) {
console.error('Delete file error:', error);
return NextResponse.json(
{ success: false, error: 'Failed to delete file' },
{ status: 500 }
);
}
}
// GET - Get single file info
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
// Get the access token from cookie or header
const accessToken =
request.cookies.get('sb-access-token')?.value ||
request.headers.get('authorization')?.replace('Bearer ', '');
if (!accessToken) {
return NextResponse.json(
{ success: false, error: 'Not authenticated' },
{ status: 401 }
);
}
// Verify user
const { data: userData, error: userError } = await supabase.auth.getUser(accessToken);
if (userError || !userData.user) {
return NextResponse.json(
{ success: false, error: 'Invalid authentication' },
{ status: 401 }
);
}
const userId = userData.user.id;
const fileId = params.id;
// Get file record
const { data: fileData, error: fetchError } = await supabase
.from('files')
.select('*')
.eq('id', fileId)
.eq('user_id', userId)
.single();
if (fetchError || !fileData) {
return NextResponse.json(
{ success: false, error: 'File not found' },
{ status: 404 }
);
}
// Get public URL
const { data: urlData } = await supabase
.storage
.from('files')
.getPublicUrl(fileData.path);
return NextResponse.json({
success: true,
data: {
id: fileData.id,
name: fileData.name,
path: fileData.path,
size: fileData.size,
mimeType: fileData.mime_type,
url: urlData.publicUrl,
createdAt: fileData.created_at,
updatedAt: fileData.updated_at,
},
});
} catch (error) {
console.error('Get file error:', error);
return NextResponse.json(
{ success: false, error: 'Failed to get file' },
{ status: 500 }
);
}
}
+84
View File
@@ -0,0 +1,84 @@
import { NextRequest, NextResponse } from 'next/server';
// 模拟文件系统数据
const mockFiles = {
id: 'root',
name: 'root',
type: 'folder' as const,
path: '/',
children: [
{
id: '1',
name: 'README.md',
type: 'file' as const,
path: '/README.md',
content: '# 欢迎使用公网编辑器\n\n这是一个基于 Next.js 的在线 Markdown 编辑器。'
},
{
id: '2',
name: 'docs',
type: 'folder' as const,
path: '/docs',
children: [
{
id: '3',
name: 'guide.md',
type: 'file' as const,
path: '/docs/guide.md',
content: '# 使用指南\n\n## 功能特性\n\n- Markdown 编辑\n- 实时预览\n- 文件管理'
}
]
}
]
};
export async function GET(request: NextRequest) {
const path = request.nextUrl.searchParams.get('path') || '/';
// 模拟从文件系统读取
// TODO: 替换为真实的后端 API 调用
if (path === '/') {
return NextResponse.json(mockFiles);
}
// 查找文件
const findFile = (node: any, targetPath: string): any => {
if (node.path === targetPath) return node;
if (node.children) {
for (const child of node.children) {
const found = findFile(child, targetPath);
if (found) return found;
}
}
return null;
};
const file = findFile(mockFiles, path);
if (file) {
return NextResponse.json(file);
}
return NextResponse.json(
{ message: '文件未找到' },
{ status: 404 }
);
}
export async function POST(request: NextRequest) {
try {
const { path, content } = await request.json();
// TODO: 调用后端 API 保存文件
console.log('Saving file:', path, 'content length:', content?.length);
return NextResponse.json({
success: true,
message: '文件已保存'
});
} catch (error) {
return NextResponse.json(
{ message: '保存失败' },
{ status: 500 }
);
}
}
+103
View File
@@ -0,0 +1,103 @@
import { NextRequest, NextResponse } from 'next/server';
import { supabase, supabaseAdmin } from '@/lib/supabase';
import { v4 as uuidv4 } from 'uuid';
export async function POST(request: NextRequest) {
try {
// Get the access token from cookie or header
const accessToken =
request.cookies.get('sb-access-token')?.value ||
request.headers.get('authorization')?.replace('Bearer ', '');
if (!accessToken) {
return NextResponse.json(
{ success: false, error: 'Not authenticated' },
{ status: 401 }
);
}
// Verify user
const { data: userData, error: userError } = await supabase.auth.getUser(accessToken);
if (userError || !userData.user) {
return NextResponse.json(
{ success: false, error: 'Invalid authentication' },
{ status: 401 }
);
}
const userId = userData.user.id;
// Parse multipart form data
const formData = await request.formData();
const file = formData.get('file') as File;
if (!file) {
return NextResponse.json(
{ success: false, error: 'No file provided' },
{ status: 400 }
);
}
// Generate unique filename
const fileExt = file.name.split('.').pop();
const fileName = `${uuidv4()}.${fileExt}`;
const filePath = `${userId}/${fileName}`;
// Upload to Supabase Storage
const { data: uploadData, error: uploadError } = await supabaseAdmin
.storage
.from('files')
.upload(filePath, file, {
cacheControl: '3600',
upsert: false,
});
if (uploadError) {
throw uploadError;
}
// Get file URL
const { data: urlData } = await supabaseAdmin
.storage
.from('files')
.getPublicUrl(filePath);
// Insert file record into database
const { data: dbData, error: dbError } = await supabaseAdmin
.from('files')
.insert({
name: file.name,
path: filePath,
size: file.size,
mime_type: file.type,
user_id: userId,
})
.select()
.single();
if (dbError) {
// Clean up uploaded file if database insert fails
await supabaseAdmin.storage.from('files').remove([filePath]);
throw dbError;
}
return NextResponse.json({
success: true,
data: {
id: dbData.id,
name: dbData.name,
path: dbData.path,
size: dbData.size,
mimeType: dbData.mime_type,
url: urlData.publicUrl,
createdAt: dbData.created_at,
},
});
} catch (error) {
console.error('Upload error:', error);
return NextResponse.json(
{ success: false, error: 'Upload failed' },
{ status: 500 }
);
}
}
+33
View File
@@ -0,0 +1,33 @@
import { NextRequest, NextResponse } from 'next/server';
export async function POST(request: NextRequest) {
try {
const { username, password } = await request.json();
// TODO: 这里应该调用后端 API 进行验证
// 现在是模拟登录
if (username && password) {
// 模拟生成 token
const token = Buffer.from(JSON.stringify({
username,
exp: Date.now() + 7 * 24 * 60 * 60 * 1000
})).toString('base64');
return NextResponse.json({
success: true,
token,
user: { username }
});
}
return NextResponse.json(
{ message: '用户名或密码错误' },
{ status: 401 }
);
} catch (error) {
return NextResponse.json(
{ message: '服务器错误' },
{ status: 500 }
);
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.
Binary file not shown.
+27
View File
@@ -0,0 +1,27 @@
@tailwind base;
@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;
}
@layer utilities {
.text-balance {
text-wrap: balance;
}
}
+35
View File
@@ -0,0 +1,35 @@
import type { Metadata } from "next";
import localFont from "next/font/local";
import "./globals.css";
const geistSans = localFont({
src: "./fonts/GeistVF.woff",
variable: "--font-geist-sans",
weight: "100 900",
});
const geistMono = localFont({
src: "./fonts/GeistMonoVF.woff",
variable: "--font-geist-mono",
weight: "100 900",
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{children}
</body>
</html>
);
}
+5
View File
@@ -0,0 +1,5 @@
import LoginForm from '@/components/LoginForm';
export default function LoginPage() {
return <LoginForm />;
}
+101
View File
@@ -0,0 +1,101 @@
import Image from "next/image";
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="https://nextjs.org/icons/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">
src/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="https://nextjs.org/icons/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>
</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="https://nextjs.org/icons/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="https://nextjs.org/icons/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="https://nextjs.org/icons/globe.svg"
alt="Globe icon"
width={16}
height={16}
/>
Go to nextjs.org
</a>
</footer>
</div>
);
}
+95
View File
@@ -0,0 +1,95 @@
'use client';
import { useState } from 'react';
interface FileNode {
id: string;
name: string;
type: 'file' | 'folder';
children?: FileNode[];
path: string;
}
interface FileTreeProps {
files: FileNode[];
onFileSelect: (path: string) => void;
selectedFile?: string;
}
function FileTreeNode({
node,
depth = 0,
onFileSelect,
selectedFile
}: {
node: FileNode;
depth: number;
onFileSelect: (path: string) => void;
selectedFile?: string;
}) {
const [isExpanded, setIsExpanded] = useState(true);
const handleClick = () => {
if (node.type === 'folder') {
setIsExpanded(!isExpanded);
} else {
onFileSelect(node.path);
}
};
const isSelected = selectedFile === node.path;
return (
<div>
<div
className={`flex items-center py-1 px-2 cursor-pointer hover:bg-gray-100 rounded transition-colors ${
isSelected ? 'bg-blue-100 text-blue-700' : ''
}`}
style={{ paddingLeft: `${depth * 16 + 8}px` }}
onClick={handleClick}
>
<span className="mr-2 text-gray-500">
{node.type === 'folder' ? (
isExpanded ? '📂' : '📁'
) : (
'📄'
)}
</span>
<span className="text-sm truncate">{node.name}</span>
</div>
{node.type === 'folder' && isExpanded && node.children && (
<div>
{node.children.map((child) => (
<FileTreeNode
key={child.id}
node={child}
depth={depth + 1}
onFileSelect={onFileSelect}
selectedFile={selectedFile}
/>
))}
</div>
)}
</div>
);
}
export default function FileTree({ files, onFileSelect, selectedFile }: FileTreeProps) {
return (
<div className="h-full overflow-auto bg-white border-r border-gray-200">
<div className="p-3 border-b border-gray-200">
<h2 className="font-semibold text-gray-700"></h2>
</div>
<div className="py-2">
{files.map((file) => (
<FileTreeNode
key={file.id}
node={file}
onFileSelect={onFileSelect}
selectedFile={selectedFile}
/>
))}
</div>
</div>
);
}
+96
View File
@@ -0,0 +1,96 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
export default function LoginForm() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const router = useRouter();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError('');
try {
const response = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
if (response.ok) {
const data = await response.json();
localStorage.setItem('token', data.token);
router.push('/editor');
} else {
const err = await response.json();
setError(err.message || '登录失败');
}
} catch {
setError('网络错误,请稍后重试');
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-500 to-purple-600">
<div className="bg-white p-8 rounded-2xl shadow-2xl w-full max-w-md">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-gray-800"></h1>
<p className="text-gray-600 mt-2"></p>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label htmlFor="username" className="block text-sm font-medium text-gray-700 mb-2">
</label>
<input
id="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
placeholder="请输入用户名"
required
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-2">
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
placeholder="请输入密码"
required
/>
</div>
{error && (
<div className="bg-red-50 text-red-600 p-3 rounded-lg text-sm">
{error}
</div>
)}
<button
type="submit"
disabled={loading}
className="w-full bg-blue-600 text-white py-3 rounded-lg font-medium hover:bg-blue-700 focus:ring-4 focus:ring-blue-200 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? '登录中...' : '登录'}
</button>
</form>
</div>
</div>
);
}
+88
View File
@@ -0,0 +1,88 @@
'use client';
import { useState, useCallback } from 'react';
import Editor from '@monaco-editor/react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
interface MarkdownEditorProps {
value: string;
onChange: (value: string) => void;
onSave?: () => void;
}
export default function MarkdownEditor({ value, onChange, onSave }: MarkdownEditorProps) {
const [showPreview, setShowPreview] = useState(false);
const handleEditorChange = useCallback((newValue?: string) => {
if (newValue !== undefined) {
onChange(newValue);
}
}, [onChange]);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
e.preventDefault();
onSave?.();
}
}, [onSave]);
return (
<div className="h-full flex flex-col" onKeyDown={handleKeyDown}>
<div className="flex items-center justify-between p-2 border-b border-gray-200 bg-gray-50">
<div className="flex items-center space-x-2">
<button
onClick={() => setShowPreview(false)}
className={`px-3 py-1 rounded text-sm transition-colors ${
!showPreview
? 'bg-blue-600 text-white'
: 'bg-white text-gray-700 hover:bg-gray-100'
}`}
>
</button>
<button
onClick={() => setShowPreview(true)}
className={`px-3 py-1 rounded text-sm transition-colors ${
showPreview
? 'bg-blue-600 text-white'
: 'bg-white text-gray-700 hover:bg-gray-100'
}`}
>
</button>
</div>
<div className="text-xs text-gray-500">
{value.length}
</div>
</div>
<div className="flex-1 overflow-hidden">
{showPreview ? (
<div className="h-full overflow-auto p-6 prose prose-blue max-w-none">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{value}
</ReactMarkdown>
</div>
) : (
<Editor
height="100%"
defaultLanguage="markdown"
value={value}
onChange={handleEditorChange}
theme="vs-light"
options={{
minimap: { enabled: false },
fontSize: 14,
lineNumbers: 'on',
wordWrap: 'on',
automaticLayout: true,
tabSize: 2,
insertSpaces: true,
}}
/>
)}
</div>
</div>
);
}
+33
View File
@@ -0,0 +1,33 @@
import { createClient } from '@supabase/supabase-js';
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const supabaseServiceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
// Client-side Supabase client (uses anon key)
export const supabase = createClient(supabaseUrl, supabaseAnonKey);
// Server-side Supabase client (uses service role key for admin operations)
export const supabaseAdmin = createClient(supabaseUrl, supabaseServiceRoleKey, {
auth: {
autoRefreshToken: false,
persistSession: false,
},
});
// Create Supabase client with user token for server-side operations
export function createServerClient(accessToken?: string) {
return createClient(supabaseUrl, supabaseAnonKey, {
global: {
headers: accessToken
? {
Authorization: `Bearer ${accessToken}`,
}
: undefined,
},
auth: {
autoRefreshToken: false,
persistSession: false,
},
});
}
+66
View File
@@ -0,0 +1,66 @@
// Database Types
export interface Database {
public: {
Tables: {
files: {
Row: {
id: string;
name: string;
path: string;
size: number;
mime_type: string;
user_id: string;
created_at: string;
updated_at: string;
};
Insert: {
id?: string;
name: string;
path: string;
size: number;
mime_type: string;
user_id: string;
created_at?: string;
updated_at?: string;
};
Update: {
id?: string;
name?: string;
path?: string;
size?: number;
mime_type?: string;
user_id?: string;
created_at?: string;
updated_at?: string;
};
};
};
Views: {};
Functions: {};
Enums: {};
};
}
// API Response Types
export interface ApiResponse<T = unknown> {
success: boolean;
data?: T;
error?: string;
}
export interface FileRecord {
id: string;
name: string;
path: string;
size: number;
mimeType: string;
userId: string;
createdAt: string;
updatedAt: string;
}
export interface User {
id: string;
email: string;
created_at: string;
}
+94
View File
@@ -0,0 +1,94 @@
-- Enable UUID extension
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- Create files table
CREATE TABLE IF NOT EXISTS files (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name TEXT NOT NULL,
path TEXT NOT NULL,
size BIGINT NOT NULL,
mime_type TEXT NOT NULL,
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Create index on user_id for faster queries
CREATE INDEX IF NOT EXISTS idx_files_user_id ON files(user_id);
-- Create index on created_at for sorting
CREATE INDEX IF NOT EXISTS idx_files_created_at ON files(created_at DESC);
-- Enable Row Level Security
ALTER TABLE files ENABLE ROW LEVEL SECURITY;
-- Create policy to allow users to see only their own files
CREATE POLICY "Users can view their own files"
ON files
FOR SELECT
USING (auth.uid() = user_id);
-- Create policy to allow users to insert their own files
CREATE POLICY "Users can insert their own files"
ON files
FOR INSERT
WITH CHECK (auth.uid() = user_id);
-- Create policy to allow users to update their own files
CREATE POLICY "Users can update their own files"
ON files
FOR UPDATE
USING (auth.uid() = user_id);
-- Create policy to allow users to delete their own files
CREATE POLICY "Users can delete their own files"
ON files
FOR DELETE
USING (auth.uid() = user_id);
-- Create storage bucket for files
INSERT INTO storage.buckets (id, name, public)
VALUES ('files', 'files', true)
ON CONFLICT (id) DO NOTHING;
-- Create policy to allow authenticated users to upload files
CREATE POLICY "Users can upload files"
ON storage.objects
FOR INSERT
WITH CHECK (
bucket_id = 'files' AND
auth.uid()::text = (storage.foldername(name))[1]
);
-- Create policy to allow users to view their own files
CREATE POLICY "Users can view their own files"
ON storage.objects
FOR SELECT
USING (
bucket_id = 'files' AND
auth.uid()::text = (storage.foldername(name))[1]
);
-- Create policy to allow users to delete their own files
CREATE POLICY "Users can delete their own files"
ON storage.objects
FOR DELETE
USING (
bucket_id = 'files' AND
auth.uid()::text = (storage.foldername(name))[1]
);
-- Create updated_at trigger function
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Create trigger to auto-update updated_at
CREATE TRIGGER update_files_updated_at
BEFORE UPDATE ON files
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
+19
View File
@@ -0,0 +1,19 @@
import type { Config } from "tailwindcss";
const config: Config = {
content: [
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {
colors: {
background: "var(--background)",
foreground: "var(--foreground)",
},
},
},
plugins: [],
};
export default config;
+26
View File
@@ -0,0 +1,26 @@
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}