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,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;
|
||||
}
|
||||
Reference in New Issue
Block a user