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