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:
@@ -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
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"extends": ["next/core-web-vitals", "next/typescript"]
|
||||||
|
}
|
||||||
+36
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
Generated
+6775
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
/** @type {import('postcss-load-config').Config} */
|
||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import LoginForm from '@/components/LoginForm';
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
return <LoginForm />;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
@@ -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;
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user