chore: merge existing main history
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
export default function LoadingDocument() {
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-4 px-12 py-8">
|
||||
<Skeleton className="h-9 w-1/3" />
|
||||
<Skeleton className="h-4 w-64" />
|
||||
<Skeleton className="h-[400px] w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { createSupabaseServerClient } from "@/lib/supabase/server";
|
||||
import { DocumentShell } from "@/components/editor/document-shell";
|
||||
import type { PageOptionsState, DocumentStats } from "@/types/page-options";
|
||||
|
||||
interface DocumentPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function DocumentPage({ params }: DocumentPageProps) {
|
||||
const { id } = await params;
|
||||
const supabase = await createSupabaseServerClient();
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
|
||||
if (!session) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
const { data: document } = await supabase
|
||||
.from("documents")
|
||||
.select(
|
||||
"id,title,content,updated_at,workspace_id,wide_layout,use_small_text,show_heading_numbers,show_toc,show_structure,protect_editing,show_word_count,word_count,character_count,block_count",
|
||||
)
|
||||
.eq("user_id", session.user.id)
|
||||
.eq("id", id)
|
||||
.single();
|
||||
|
||||
if (!document) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const initialOptions: PageOptionsState = {
|
||||
wideLayout: document.wide_layout ?? false,
|
||||
smallText: document.use_small_text ?? false,
|
||||
showHeadingNumbers: document.show_heading_numbers ?? true,
|
||||
showToc: document.show_toc ?? false,
|
||||
showStructure: document.show_structure ?? false,
|
||||
protectEditing: document.protect_editing ?? false,
|
||||
showWordCount: document.show_word_count ?? true,
|
||||
};
|
||||
|
||||
const initialStats: DocumentStats = {
|
||||
wordCount: document.word_count ?? 0,
|
||||
characterCount: document.character_count ?? 0,
|
||||
blockCount: document.block_count ?? 0,
|
||||
};
|
||||
|
||||
return (
|
||||
<DocumentShell
|
||||
documentId={document.id}
|
||||
workspaceId={document.workspace_id}
|
||||
title={document.title}
|
||||
updatedAt={document.updated_at}
|
||||
initialContent={document.content}
|
||||
initialOptions={initialOptions}
|
||||
initialStats={initialStats}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import type { ReactNode } from "react";
|
||||
import { Sidebar } from "@/components/sidebar/sidebar";
|
||||
import { Breadcrumb } from "@/components/breadcrumb";
|
||||
import { BottomToolbar } from "@/components/bottom-toolbar";
|
||||
import { MobileSidebarTrigger } from "@/components/mobile-sidebar-trigger";
|
||||
import type { DocumentRecord } from "@/lib/documents";
|
||||
import { createSupabaseServerClient } from "@/lib/supabase/server";
|
||||
import { ensureDefaultWorkspace, fetchWorkspaceSummaries } from "@/lib/workspaces";
|
||||
import { fetchSidebarDataset } from "@/lib/sidebar-tree";
|
||||
import type { SidebarInitialData } from "@/components/sidebar/types";
|
||||
import { SearchPalette } from "@/components/search/search-palette";
|
||||
|
||||
export default async function AppLayout({ children }: { children: ReactNode }) {
|
||||
const supabase = await createSupabaseServerClient();
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
|
||||
if (!session) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
await ensureDefaultWorkspace(supabase, session.user.id, session.user.email ?? "我的空间");
|
||||
const { workspaces, activeWorkspaceId } = await fetchWorkspaceSummaries(supabase, session.user.id);
|
||||
|
||||
let documents: DocumentRecord[] = [];
|
||||
let sidebarInitialData: SidebarInitialData | null = null;
|
||||
|
||||
if (activeWorkspaceId) {
|
||||
const dataset = await fetchSidebarDataset(supabase, activeWorkspaceId);
|
||||
documents = dataset.documents;
|
||||
sidebarInitialData = {
|
||||
activeWorkspaceId,
|
||||
workspaces,
|
||||
documents: dataset.documents,
|
||||
trashedDocuments: dataset.trashedDocuments,
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-white">
|
||||
{sidebarInitialData && <Sidebar initialData={sidebarInitialData} />}
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<header className="flex h-10 items-center gap-4 border-b border-[#eeeeee] px-4">
|
||||
<MobileSidebarTrigger />
|
||||
<Breadcrumb documents={documents} />
|
||||
</header>
|
||||
<main className="flex-1 overflow-hidden bg-white">{children}</main>
|
||||
<BottomToolbar />
|
||||
<SearchPalette workspaceId={sidebarInitialData?.activeWorkspaceId ?? null} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { supabaseBrowser } from "@/lib/supabase/client";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const [email, setEmail] = useState("test@163.com");
|
||||
const [password, setPassword] = useState("123456");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [message, setMessage] = useState<string>("");
|
||||
const [sessionInfo, setSessionInfo] = useState<string>("");
|
||||
|
||||
const handleLogin = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setMessage("");
|
||||
const { data, error } = await supabaseBrowser.auth.signInWithPassword({
|
||||
email,
|
||||
password,
|
||||
});
|
||||
setLoading(false);
|
||||
if (error) {
|
||||
setMessage(`登录失败:${error.message}`);
|
||||
return;
|
||||
}
|
||||
setMessage("登录成功,准备跳转...");
|
||||
setSessionInfo(JSON.stringify(data.session, null, 2));
|
||||
router.replace("/");
|
||||
}, [email, password, router]);
|
||||
|
||||
const handleGetSession = useCallback(async () => {
|
||||
setLoading(true);
|
||||
const {
|
||||
data: { session },
|
||||
error,
|
||||
} = await supabaseBrowser.auth.getSession();
|
||||
setLoading(false);
|
||||
if (error) {
|
||||
setMessage(`获取会话失败:${error.message}`);
|
||||
return;
|
||||
}
|
||||
if (!session) {
|
||||
setMessage("当前未登录");
|
||||
setSessionInfo("");
|
||||
return;
|
||||
}
|
||||
setMessage("发现有效 Session");
|
||||
setSessionInfo(JSON.stringify(session, null, 2));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-zinc-50 p-4">
|
||||
<Card className="w-[400px] space-y-4 bg-white/90 shadow-md backdrop-blur">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">Supabase 登录</CardTitle>
|
||||
<p className="text-sm text-zinc-500">测试账号:test@163.com / 123456</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm text-zinc-700">邮箱</label>
|
||||
<Input value={email} onChange={(e) => setEmail(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm text-zinc-700">密码</label>
|
||||
<Input type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button className="flex-1" onClick={handleLogin} disabled={loading}>
|
||||
{loading ? "处理中..." : "登录"}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={handleGetSession} disabled={loading}>
|
||||
会话检测
|
||||
</Button>
|
||||
</div>
|
||||
{message && <p className="whitespace-pre-wrap text-sm text-blue-600">{message}</p>}
|
||||
<Separator />
|
||||
{sessionInfo && (
|
||||
<pre className="max-h-64 overflow-auto rounded-md bg-zinc-900 p-3 text-xs text-zinc-100">
|
||||
{sessionInfo}
|
||||
</pre>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { randomUUID } from "crypto";
|
||||
import { NextResponse } from "next/server";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
import { ensureDefaultWorkspace, resolveActiveWorkspaceId } from "@/lib/workspaces";
|
||||
import type { Json } from "@/types/supabase";
|
||||
import { composeContentWithBlocks, extractBlocksFromContent } from "@/lib/document-content";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
return await handleCreateRequest(request);
|
||||
} catch (error) {
|
||||
console.error("创建页面失败", error);
|
||||
const message = error instanceof Error ? error.message : "创建页面失败,请稍后再试";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreateRequest(request: Request) {
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { parentId }: { parentId?: string | null } = await request.json();
|
||||
|
||||
await ensureDefaultWorkspace(supabase, session.user.id, session.user.email ?? "我的空间");
|
||||
|
||||
let workspaceId: string | null = null;
|
||||
let parentContent: Json | null = null;
|
||||
let accessScope: "private" | "shared" | "public" = "private";
|
||||
|
||||
if (parentId) {
|
||||
const { data: parentDoc, error: parentError } = await supabase
|
||||
.from("documents")
|
||||
.select("workspace_id,access_scope,content,user_id")
|
||||
.eq("id", parentId)
|
||||
.eq("user_id", session.user.id)
|
||||
.single();
|
||||
|
||||
if (parentError || !parentDoc) {
|
||||
return NextResponse.json({ error: "父页面不存在或无权限" }, { status: 404 });
|
||||
}
|
||||
workspaceId = parentDoc.workspace_id;
|
||||
accessScope = (parentDoc.access_scope ?? "private") as typeof accessScope;
|
||||
parentContent = parentDoc.content;
|
||||
} else {
|
||||
workspaceId = await resolveActiveWorkspaceId(supabase, session.user.id);
|
||||
if (!workspaceId) {
|
||||
return NextResponse.json({ error: "缺少目标工作空间" }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
if (!workspaceId) {
|
||||
return NextResponse.json({ error: "缺少目标工作空间" }, { status: 400 });
|
||||
}
|
||||
|
||||
const siblingQuery = supabase
|
||||
.from("documents")
|
||||
.select("id", { head: true, count: "exact" })
|
||||
.eq("workspace_id", workspaceId);
|
||||
|
||||
if (parentId) {
|
||||
siblingQuery.eq("parent_id", parentId);
|
||||
} else {
|
||||
siblingQuery.is("parent_id", null);
|
||||
}
|
||||
|
||||
const { count: siblingCount = 0, error: countError } = await siblingQuery;
|
||||
|
||||
if (countError) {
|
||||
return NextResponse.json({ error: countError.message }, { status: 500 });
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from("documents")
|
||||
.insert({
|
||||
user_id: session.user.id,
|
||||
parent_id: parentId ?? null,
|
||||
workspace_id: workspaceId,
|
||||
title: "无标题",
|
||||
content: { blocks: [] },
|
||||
access_scope: accessScope,
|
||||
sort_order: siblingCount,
|
||||
})
|
||||
.select(
|
||||
"id,title,parent_id,sort_order,is_starred,created_at,updated_at,workspace_id,access_scope,is_template",
|
||||
)
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 400 });
|
||||
}
|
||||
|
||||
if (parentId && data) {
|
||||
const existingBlocks = extractBlocksFromContent(parentContent);
|
||||
const pageReferenceBlock = {
|
||||
id: randomUUID(),
|
||||
type: "pageReference",
|
||||
props: {
|
||||
pageId: data.id,
|
||||
title: data.title ?? "无标题",
|
||||
},
|
||||
};
|
||||
const nextBlocks = [...existingBlocks, pageReferenceBlock as Json];
|
||||
const payload = composeContentWithBlocks(parentContent, nextBlocks);
|
||||
const timestamp = new Date().toISOString();
|
||||
const { error: parentUpdateError } = await supabase
|
||||
.from("documents")
|
||||
.update({ content: payload, updated_at: timestamp })
|
||||
.eq("id", parentId)
|
||||
.eq("user_id", session.user.id);
|
||||
|
||||
if (parentUpdateError) {
|
||||
return NextResponse.json({ error: parentUpdateError.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json(data);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { documentId } = await request.json();
|
||||
|
||||
if (!documentId) {
|
||||
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { error } = await supabase
|
||||
.from("documents")
|
||||
.update({
|
||||
deleted_at: new Date().toISOString(),
|
||||
deleted_by: session.user.id,
|
||||
})
|
||||
.eq("id", documentId)
|
||||
.eq("user_id", session.user.id);
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
|
||||
interface DuplicatePayload {
|
||||
documentId: string;
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { documentId }: DuplicatePayload = await request.json();
|
||||
|
||||
if (!documentId) {
|
||||
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { data: sourceDoc, error: sourceError } = await supabase
|
||||
.from("documents")
|
||||
.select("id,title,content,parent_id,workspace_id,access_scope")
|
||||
.eq("id", documentId)
|
||||
.eq("user_id", session.user.id)
|
||||
.single();
|
||||
|
||||
if (sourceError || !sourceDoc) {
|
||||
return NextResponse.json({ error: "页面不存在或无权访问" }, { status: 404 });
|
||||
}
|
||||
|
||||
const siblingQuery = supabase
|
||||
.from("documents")
|
||||
.select("id", { head: true, count: "exact" })
|
||||
.eq("workspace_id", sourceDoc.workspace_id);
|
||||
|
||||
if (sourceDoc.parent_id) {
|
||||
siblingQuery.eq("parent_id", sourceDoc.parent_id);
|
||||
} else {
|
||||
siblingQuery.is("parent_id", null);
|
||||
}
|
||||
|
||||
const { count: siblingCount = 0, error: countError } = await siblingQuery;
|
||||
|
||||
if (countError) {
|
||||
return NextResponse.json({ error: countError.message }, { status: 500 });
|
||||
}
|
||||
|
||||
const fallbackTitle = sourceDoc.title?.trim() && sourceDoc.title.trim().length > 0 ? sourceDoc.title.trim() : "无标题";
|
||||
const duplicatedTitle = `${fallbackTitle} 副本`;
|
||||
|
||||
const { data: duplicated, error: duplicateError } = await supabase
|
||||
.from("documents")
|
||||
.insert({
|
||||
user_id: session.user.id,
|
||||
workspace_id: sourceDoc.workspace_id,
|
||||
parent_id: sourceDoc.parent_id,
|
||||
access_scope: sourceDoc.access_scope ?? "private",
|
||||
title: duplicatedTitle,
|
||||
content: sourceDoc.content,
|
||||
sort_order: siblingCount,
|
||||
})
|
||||
.select("id,title,parent_id,sort_order")
|
||||
.single();
|
||||
|
||||
if (duplicateError || !duplicated) {
|
||||
return NextResponse.json({ error: duplicateError?.message ?? "复制失败" }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json(duplicated);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { randomUUID } from "crypto";
|
||||
import { NextResponse } from "next/server";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
import type { Json } from "@/types/supabase";
|
||||
import { composeContentWithBlocks, extractBlocksFromContent } from "@/lib/document-content";
|
||||
|
||||
interface EmbedPayload {
|
||||
sourceId: string;
|
||||
targetId: string;
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { sourceId, targetId }: EmbedPayload = await request.json();
|
||||
|
||||
if (!sourceId || !targetId) {
|
||||
return NextResponse.json({ error: "缺少参数" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { data: sourceDoc, error: sourceError } = await supabase
|
||||
.from("documents")
|
||||
.select("id,title,user_id")
|
||||
.eq("id", sourceId)
|
||||
.eq("user_id", session.user.id)
|
||||
.single();
|
||||
|
||||
if (sourceError || !sourceDoc) {
|
||||
return NextResponse.json({ error: "原始页面不存在或无权限" }, { status: 404 });
|
||||
}
|
||||
|
||||
const { data: targetDoc, error: targetError } = await supabase
|
||||
.from("documents")
|
||||
.select("id,content,user_id")
|
||||
.eq("id", targetId)
|
||||
.eq("user_id", session.user.id)
|
||||
.single();
|
||||
|
||||
if (targetError || !targetDoc) {
|
||||
return NextResponse.json({ error: "目标页面不存在或无权限" }, { status: 404 });
|
||||
}
|
||||
|
||||
const currentBlocks = extractBlocksFromContent(targetDoc.content);
|
||||
const nextBlocks: Json[] = [
|
||||
...currentBlocks,
|
||||
{
|
||||
id: randomUUID(),
|
||||
type: "pageReference",
|
||||
props: {
|
||||
pageId: sourceDoc.id,
|
||||
title: sourceDoc.title ?? "无标题",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const payload: Json = composeContentWithBlocks(targetDoc.content, nextBlocks);
|
||||
|
||||
const { error: updateError } = await supabase
|
||||
.from("documents")
|
||||
.update({ content: payload })
|
||||
.eq("id", targetDoc.id)
|
||||
.eq("user_id", session.user.id);
|
||||
|
||||
if (updateError) {
|
||||
return NextResponse.json({ error: updateError.message }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
interface EmptyTrashPayload {
|
||||
workspaceId?: string;
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { workspaceId }: EmptyTrashPayload = await request.json();
|
||||
|
||||
if (!workspaceId) {
|
||||
return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { data: membership, error: membershipError } = await supabase
|
||||
.from("workspace_members")
|
||||
.select("workspace_id")
|
||||
.eq("workspace_id", workspaceId)
|
||||
.eq("user_id", session.user.id)
|
||||
.limit(1);
|
||||
|
||||
if (membershipError) {
|
||||
return NextResponse.json({ error: membershipError.message }, { status: 500 });
|
||||
}
|
||||
|
||||
if (!membership || membership.length === 0) {
|
||||
return NextResponse.json({ error: "无权操作该工作空间" }, { status: 403 });
|
||||
}
|
||||
|
||||
const { error } = await supabase
|
||||
.from("documents")
|
||||
.delete()
|
||||
.eq("workspace_id", workspaceId)
|
||||
.eq("user_id", session.user.id)
|
||||
.not("deleted_at", "is", null);
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
|
||||
interface MovePayload {
|
||||
documentId: string;
|
||||
parentId?: string | null;
|
||||
position: number;
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { documentId, parentId = null, position }: MovePayload = await request.json();
|
||||
const sortOrder = Number.isFinite(position) ? Math.floor(position) : 0;
|
||||
|
||||
const { error } = await supabase
|
||||
.from("documents")
|
||||
.update({
|
||||
parent_id: parentId,
|
||||
sort_order: sortOrder,
|
||||
})
|
||||
.eq("id", documentId)
|
||||
.eq("user_id", session.user.id);
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 400 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
import type { PageOptionsState } from "@/types/page-options";
|
||||
import type { Database } from "@/types/supabase";
|
||||
|
||||
type OptionsPayload = {
|
||||
documentId: string;
|
||||
options: Partial<PageOptionsState>;
|
||||
};
|
||||
|
||||
const COLUMN_MAP: Record<keyof PageOptionsState, keyof Database["public"]["Tables"]["documents"]["Row"]> = {
|
||||
wideLayout: "wide_layout",
|
||||
smallText: "use_small_text",
|
||||
showHeadingNumbers: "show_heading_numbers",
|
||||
showToc: "show_toc",
|
||||
showStructure: "show_structure",
|
||||
protectEditing: "protect_editing",
|
||||
showWordCount: "show_word_count",
|
||||
};
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { documentId, options }: OptionsPayload = await request.json();
|
||||
if (!documentId || !options) {
|
||||
return NextResponse.json({ error: "缺少参数" }, { status: 400 });
|
||||
}
|
||||
|
||||
const updatePayload: Record<string, boolean> = {};
|
||||
(Object.keys(options) as (keyof PageOptionsState)[]).forEach((key) => {
|
||||
const column = COLUMN_MAP[key];
|
||||
if (!column) return;
|
||||
const value = options[key];
|
||||
if (typeof value === "boolean") {
|
||||
updatePayload[column as string] = value;
|
||||
}
|
||||
});
|
||||
|
||||
if (Object.keys(updatePayload).length === 0) {
|
||||
return NextResponse.json({ error: "缺少可更新的选项" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { error } = await supabase
|
||||
.from("documents")
|
||||
.update(updatePayload)
|
||||
.eq("id", documentId)
|
||||
.eq("user_id", session.user.id);
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { documentId } = await request.json();
|
||||
|
||||
if (!documentId) {
|
||||
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { error } = await supabase
|
||||
.from("documents")
|
||||
.delete()
|
||||
.eq("id", documentId)
|
||||
.eq("user_id", session.user.id);
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { documentId } = await request.json();
|
||||
|
||||
if (!documentId) {
|
||||
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { error } = await supabase
|
||||
.from("documents")
|
||||
.update({
|
||||
deleted_at: null,
|
||||
deleted_by: null,
|
||||
parent_id: null,
|
||||
access_scope: "private",
|
||||
})
|
||||
.eq("id", documentId)
|
||||
.eq("user_id", session.user.id);
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
|
||||
interface SavePayload {
|
||||
documentId: string;
|
||||
content: unknown;
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { documentId, content }: SavePayload = await request.json();
|
||||
|
||||
const { error } = await supabase
|
||||
.from("documents")
|
||||
.update({ content })
|
||||
.eq("id", documentId)
|
||||
.eq("user_id", session.user.id);
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 400 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
import type { DocumentStats } from "@/types/page-options";
|
||||
|
||||
interface StatsPayload {
|
||||
documentId: string;
|
||||
stats: DocumentStats;
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { documentId, stats }: StatsPayload = await request.json();
|
||||
if (!documentId || !stats) {
|
||||
return NextResponse.json({ error: "缺少参数" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { error } = await supabase
|
||||
.from("documents")
|
||||
.update({
|
||||
word_count: stats.wordCount,
|
||||
character_count: stats.characterCount,
|
||||
block_count: stats.blockCount,
|
||||
})
|
||||
.eq("id", documentId)
|
||||
.eq("user_id", session.user.id);
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
|
||||
interface RenamePayload {
|
||||
documentId: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { documentId, title }: RenamePayload = await request.json();
|
||||
|
||||
const { error } = await supabase
|
||||
.from("documents")
|
||||
.update({ title })
|
||||
.eq("id", documentId)
|
||||
.eq("user_id", session.user.id);
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 400 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
import type { MediaAsset } from "@/types/media";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const workspaceId = searchParams.get("workspaceId");
|
||||
const limit = Number.parseInt(searchParams.get("limit") ?? "12", 10);
|
||||
|
||||
if (!workspaceId) {
|
||||
return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 });
|
||||
}
|
||||
|
||||
let query = supabase
|
||||
.from("media_assets")
|
||||
.select("*")
|
||||
.eq("workspace_id", workspaceId)
|
||||
.order("created_at", { ascending: false })
|
||||
.limit(Number.isNaN(limit) ? 12 : limit);
|
||||
|
||||
const assetType = searchParams.get("assetType");
|
||||
if (assetType) {
|
||||
query = query.eq("asset_type", assetType);
|
||||
}
|
||||
|
||||
const { data, error } = await query;
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ items: (data ?? []) as MediaAsset[] });
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const payload = (await request.json()) as {
|
||||
workspaceId: string;
|
||||
documentId: string;
|
||||
fileUrl: string;
|
||||
thumbnailUrl?: string;
|
||||
assetType?: string;
|
||||
fileName?: string;
|
||||
fileSize?: number;
|
||||
mimeType?: string;
|
||||
};
|
||||
|
||||
if (!payload.workspaceId || !payload.documentId || !payload.fileUrl) {
|
||||
return NextResponse.json({ error: "参数不完整" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from("media_assets")
|
||||
.insert({
|
||||
workspace_id: payload.workspaceId,
|
||||
document_id: payload.documentId,
|
||||
file_url: payload.fileUrl,
|
||||
thumbnail_url: payload.thumbnailUrl ?? payload.fileUrl,
|
||||
asset_type: payload.assetType ?? "image",
|
||||
file_name: payload.fileName,
|
||||
file_size: payload.fileSize ?? null,
|
||||
mime_type: payload.mimeType ?? null,
|
||||
created_by: session.user.id,
|
||||
})
|
||||
.select("*")
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ asset: data as MediaAsset });
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { assetId } = (await request.json()) as { assetId?: string };
|
||||
if (!assetId) {
|
||||
return NextResponse.json({ error: "缺少 assetId" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { error } = await supabase
|
||||
.from("media_assets")
|
||||
.update({ ocr_status: "processing" })
|
||||
.eq("id", assetId)
|
||||
.limit(1);
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
|
||||
const backendUrl = process.env.NEXT_PUBLIC_BACKEND_URL;
|
||||
if (backendUrl) {
|
||||
void fetch(`${backendUrl}/api/v1/tasks/media-ocr`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ asset_id: assetId }),
|
||||
}).catch((err) => {
|
||||
console.warn("触发后端 OCR 失败", err);
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
import type { MediaAsset } from "@/types/media";
|
||||
import { extname } from "path";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const MEDIA_BUCKET = process.env.NEXT_PUBLIC_SUPABASE_MEDIA_BUCKET ?? "media";
|
||||
|
||||
const resolveAssetType = (mime: string): "image" | "video" | "audio" | "file" => {
|
||||
if (mime.startsWith("image/")) return "image";
|
||||
if (mime.startsWith("video/")) return "video";
|
||||
if (mime.startsWith("audio/")) return "audio";
|
||||
return "file";
|
||||
};
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const file = formData.get("file");
|
||||
const workspaceId = String(formData.get("workspaceId") ?? "");
|
||||
const documentId = String(formData.get("documentId") ?? "");
|
||||
|
||||
if (!(file instanceof File) || !workspaceId || !documentId) {
|
||||
return NextResponse.json({ error: "缺少必要参数" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
const extension = extname(file.name || "").replace(/\s+/g, "");
|
||||
const uniqueId = typeof crypto.randomUUID === "function" ? crypto.randomUUID() : Math.random().toString(36).slice(2);
|
||||
const path = `${workspaceId}/${Date.now()}-${uniqueId}${extension}`;
|
||||
const assetType = resolveAssetType(file.type || "");
|
||||
|
||||
const { error: uploadError } = await supabase.storage.from(MEDIA_BUCKET).upload(path, buffer, {
|
||||
contentType: file.type,
|
||||
upsert: false,
|
||||
});
|
||||
|
||||
if (uploadError) {
|
||||
return NextResponse.json({ error: uploadError.message }, { status: 500 });
|
||||
}
|
||||
|
||||
const {
|
||||
data: { publicUrl },
|
||||
} = supabase.storage.from(MEDIA_BUCKET).getPublicUrl(path);
|
||||
|
||||
const { data: asset, error } = await supabase
|
||||
.from("media_assets")
|
||||
.insert({
|
||||
workspace_id: workspaceId,
|
||||
document_id: documentId,
|
||||
file_url: publicUrl,
|
||||
thumbnail_url: publicUrl,
|
||||
asset_type: assetType,
|
||||
file_name: file.name,
|
||||
file_size: file.size,
|
||||
mime_type: file.type,
|
||||
created_by: session.user.id,
|
||||
})
|
||||
.select("*")
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ asset: asset as MediaAsset });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return NextResponse.json({ error: "上传失败" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const workspaceId = searchParams.get("workspaceId");
|
||||
const pageId = searchParams.get("pageId");
|
||||
const limit = Number(searchParams.get("limit") ?? "50");
|
||||
const offset = Number(searchParams.get("offset") ?? "0");
|
||||
|
||||
if (!workspaceId || !pageId) {
|
||||
return NextResponse.json({ error: "缺少 workspaceId 或 pageId" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { data: membership, error: membershipError } = await supabase
|
||||
.from("workspace_members")
|
||||
.select("workspace_id")
|
||||
.eq("workspace_id", workspaceId)
|
||||
.eq("user_id", session.user.id)
|
||||
.maybeSingle();
|
||||
|
||||
if (membershipError) {
|
||||
return NextResponse.json({ error: membershipError.message }, { status: 500 });
|
||||
}
|
||||
if (!membership) {
|
||||
return NextResponse.json({ error: "无权访问该工作空间" }, { status: 403 });
|
||||
}
|
||||
|
||||
const { data, error } = await supabase.rpc("list_backlinks", {
|
||||
p_workspace_id: workspaceId,
|
||||
p_target_page_id: pageId,
|
||||
p_limit: Number.isFinite(limit) ? limit : 50,
|
||||
p_offset: Number.isFinite(offset) ? offset : 0,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ backlinks: data ?? [] });
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
|
||||
type DisplayMode = "inline" | "embed";
|
||||
|
||||
interface RecordReferencePayload {
|
||||
workspaceId: string;
|
||||
sourcePageId: string;
|
||||
targetPageId: string;
|
||||
sourceBlockId?: string | null;
|
||||
alias?: string | null;
|
||||
displayMode: DisplayMode;
|
||||
isPreviewable?: boolean;
|
||||
}
|
||||
|
||||
const isValidDisplayMode = (mode: string): mode is DisplayMode => mode === "inline" || mode === "embed";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = (await request.json()) as RecordReferencePayload;
|
||||
const { workspaceId, sourcePageId, targetPageId, sourceBlockId, alias, displayMode, isPreviewable = true } = body;
|
||||
|
||||
if (!workspaceId || !sourcePageId || !targetPageId) {
|
||||
return NextResponse.json({ error: "缺少必要参数" }, { status: 400 });
|
||||
}
|
||||
if (!isValidDisplayMode(displayMode)) {
|
||||
return NextResponse.json({ error: "非法的引用模式" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { data: membership, error: membershipError } = await supabase
|
||||
.from("workspace_members")
|
||||
.select("workspace_id")
|
||||
.eq("workspace_id", workspaceId)
|
||||
.eq("user_id", session.user.id)
|
||||
.maybeSingle();
|
||||
|
||||
if (membershipError) {
|
||||
return NextResponse.json({ error: membershipError.message }, { status: 500 });
|
||||
}
|
||||
if (!membership) {
|
||||
return NextResponse.json({ error: "无权访问该工作空间" }, { status: 403 });
|
||||
}
|
||||
|
||||
const { data, error } = await supabase.rpc("record_page_ref", {
|
||||
p_workspace_id: workspaceId,
|
||||
p_source_page_id: sourcePageId,
|
||||
p_source_block_id: sourceBlockId ?? null,
|
||||
p_target_page_id: targetPageId,
|
||||
p_alias: alias ?? null,
|
||||
p_display_mode: displayMode,
|
||||
p_is_previewable: isPreviewable,
|
||||
p_created_by: session.user.id,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ reference: data });
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
import type {
|
||||
DocumentSearchFilters,
|
||||
DocumentSearchRequest,
|
||||
DocumentSearchResponse,
|
||||
DocumentSearchResult,
|
||||
DocumentSearchTimeRange,
|
||||
} from "@/types/search";
|
||||
import { buildSnippet } from "@/lib/search/snippet";
|
||||
|
||||
const MAX_LIMIT = 50;
|
||||
|
||||
const DEFAULT_FILTERS: DocumentSearchFilters = {
|
||||
titleOnly: false,
|
||||
exact: false,
|
||||
onlyCurrentPage: false,
|
||||
includeOcr: false,
|
||||
timeRange: "any",
|
||||
timeField: "updated",
|
||||
};
|
||||
|
||||
const TIME_RANGE_TO_MS: Record<DocumentSearchTimeRange, number | null> = {
|
||||
any: null,
|
||||
"7d": 1000 * 60 * 60 * 24 * 7,
|
||||
"30d": 1000 * 60 * 60 * 24 * 30,
|
||||
};
|
||||
|
||||
const TIME_FIELD_COLUMN = {
|
||||
updated: "updated_at",
|
||||
created: "created_at",
|
||||
} as const;
|
||||
|
||||
const escapeLike = (value: string): string => value.replace(/[%_\\]/g, (match) => `\\${match}`);
|
||||
interface DocumentRow {
|
||||
id: string;
|
||||
title: string | null;
|
||||
raw_text: string | null;
|
||||
updated_at: string | null;
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
const buildIsoBoundary = (value: string, isEnd = false): string | null => {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
const normalized = value.trim();
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
const suffix = isEnd ? "T23:59:59.999Z" : "T00:00:00.000Z";
|
||||
const date = new Date(`${normalized}${suffix}`);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return null;
|
||||
}
|
||||
return date.toISOString();
|
||||
};
|
||||
|
||||
const mapDocumentToResult = (
|
||||
row: DocumentRow,
|
||||
keyword: string | null,
|
||||
forcedMatch?: DocumentSearchResult["matchField"],
|
||||
): DocumentSearchResult => {
|
||||
const normalizedKeyword = keyword?.trim() ?? "";
|
||||
const normalizedTitle = row.title ?? "无标题";
|
||||
let matchField: DocumentSearchResult["matchField"] = "recent";
|
||||
if (forcedMatch) {
|
||||
matchField = forcedMatch;
|
||||
} else if (normalizedKeyword) {
|
||||
const matchesTitle = normalizedTitle.toLowerCase().includes(normalizedKeyword.toLowerCase());
|
||||
matchField = matchesTitle ? "title" : "content";
|
||||
}
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
title: normalizedTitle,
|
||||
snippet: buildSnippet(row.raw_text, normalizedKeyword || null),
|
||||
updatedAt: row.updated_at,
|
||||
createdAt: row.created_at,
|
||||
matchField,
|
||||
hasOcr: Boolean(row.raw_text),
|
||||
publicPath: `/documents/${row.id}`,
|
||||
score: matchField === "title" ? 2 : 1,
|
||||
};
|
||||
};
|
||||
|
||||
type RouteSupabaseClient = Awaited<ReturnType<typeof createSupabaseRouteClient>>;
|
||||
|
||||
const fetchRecentResults = async (
|
||||
supabase: RouteSupabaseClient,
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
): Promise<DocumentSearchResult[]> => {
|
||||
const { data: recentRows, error: recentError } = await supabase
|
||||
.from("user_recent_pages")
|
||||
.select("document_id,last_accessed_at")
|
||||
.eq("user_id", userId)
|
||||
.eq("workspace_id", workspaceId)
|
||||
.order("last_accessed_at", { ascending: false })
|
||||
.limit(10);
|
||||
|
||||
if (recentError || !recentRows || recentRows.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const documentIds = recentRows.map((row) => row.document_id);
|
||||
|
||||
const { data: docRows, error: docsError } = await supabase
|
||||
.from("documents")
|
||||
.select("id,title,updated_at,created_at,raw_text")
|
||||
.in("id", documentIds)
|
||||
.is("deleted_at", null);
|
||||
|
||||
if (docsError || !docRows) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const docMap = new Map(docRows.map((row) => [row.id, row]));
|
||||
return recentRows
|
||||
.map((row) => docMap.get(row.document_id))
|
||||
.filter((row): row is DocumentRow => Boolean(row))
|
||||
.map((row) => mapDocumentToResult(row, null, "recent"));
|
||||
};
|
||||
|
||||
const fetchOcrMatches = async (
|
||||
supabase: RouteSupabaseClient,
|
||||
workspaceId: string,
|
||||
likePattern: string,
|
||||
limit: number,
|
||||
) => {
|
||||
const { data, error } = await supabase
|
||||
.from("media_assets")
|
||||
.select("document_id,ocr_text")
|
||||
.eq("workspace_id", workspaceId)
|
||||
.not("ocr_text", "is", null)
|
||||
.ilike("ocr_text", likePattern)
|
||||
.limit(limit);
|
||||
|
||||
if (error || !data) {
|
||||
return [];
|
||||
}
|
||||
return data;
|
||||
};
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const payload = (await request.json()) as DocumentSearchRequest;
|
||||
const workspaceId = payload.workspaceId;
|
||||
|
||||
if (!workspaceId) {
|
||||
return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 });
|
||||
}
|
||||
|
||||
const filters: DocumentSearchFilters = {
|
||||
...DEFAULT_FILTERS,
|
||||
...payload.filters,
|
||||
};
|
||||
|
||||
const { data: membership, error: membershipError } = await supabase
|
||||
.from("workspace_members")
|
||||
.select("workspace_id")
|
||||
.eq("workspace_id", workspaceId)
|
||||
.eq("user_id", session.user.id)
|
||||
.limit(1);
|
||||
|
||||
if (membershipError) {
|
||||
return NextResponse.json({ error: membershipError.message }, { status: 500 });
|
||||
}
|
||||
|
||||
if (!membership || membership.length === 0) {
|
||||
return NextResponse.json({ error: "无权访问该工作空间" }, { status: 403 });
|
||||
}
|
||||
|
||||
if (filters.onlyCurrentPage && !payload.documentId) {
|
||||
filters.onlyCurrentPage = false;
|
||||
}
|
||||
|
||||
const limit = Math.min(payload.limit ?? 30, MAX_LIMIT);
|
||||
const normalizedQuery = payload.query?.trim() ?? "";
|
||||
const likePattern = filters.exact ? normalizedQuery : `%${escapeLike(normalizedQuery)}%`;
|
||||
const timeColumn = TIME_FIELD_COLUMN[filters.timeField ?? "updated"];
|
||||
|
||||
let builder = supabase
|
||||
.from("documents")
|
||||
.select("id,title,updated_at,created_at,raw_text,is_starred,access_scope")
|
||||
.eq("workspace_id", workspaceId)
|
||||
.is("deleted_at", null)
|
||||
.order(timeColumn, { ascending: false })
|
||||
.limit(limit);
|
||||
|
||||
if (filters.onlyCurrentPage && payload.documentId) {
|
||||
builder = builder.eq("id", payload.documentId);
|
||||
}
|
||||
|
||||
const customFrom = filters.customRange?.from ? buildIsoBoundary(filters.customRange.from, false) : null;
|
||||
const customTo = filters.customRange?.to ? buildIsoBoundary(filters.customRange.to, true) : null;
|
||||
|
||||
if (customFrom) {
|
||||
builder = builder.gte(timeColumn, customFrom);
|
||||
}
|
||||
if (customTo) {
|
||||
builder = builder.lte(timeColumn, customTo);
|
||||
}
|
||||
|
||||
if (!customFrom && !customTo) {
|
||||
const now = Date.now();
|
||||
const offset = TIME_RANGE_TO_MS[filters.timeRange];
|
||||
if (offset) {
|
||||
const from = new Date(now - offset).toISOString();
|
||||
builder = builder.gte(timeColumn, from);
|
||||
}
|
||||
}
|
||||
|
||||
if (normalizedQuery) {
|
||||
if (filters.titleOnly) {
|
||||
builder = filters.exact ? builder.eq("title", normalizedQuery) : builder.ilike("title", likePattern);
|
||||
} else {
|
||||
const clauses = [
|
||||
`title.${filters.exact ? `eq.${normalizedQuery}` : `ilike.${likePattern}`}`,
|
||||
];
|
||||
if (filters.includeOcr) {
|
||||
clauses.push(`raw_text.ilike.${likePattern}`);
|
||||
}
|
||||
builder = builder.or(clauses.join(","));
|
||||
}
|
||||
}
|
||||
|
||||
const shouldSearchOcr = Boolean(filters.includeOcr && normalizedQuery);
|
||||
const [{ data, error }, recentResults, ocrMatches] = await Promise.all([
|
||||
builder,
|
||||
fetchRecentResults(supabase, session.user.id, workspaceId),
|
||||
shouldSearchOcr ? fetchOcrMatches(supabase, workspaceId, likePattern, limit) : Promise.resolve([]),
|
||||
]);
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
|
||||
let results: DocumentSearchResult[] = (data ?? []).map((row) =>
|
||||
mapDocumentToResult(row, normalizedQuery || null),
|
||||
);
|
||||
|
||||
if (ocrMatches.length > 0 && normalizedQuery) {
|
||||
const snippetMap = new Map<string, string>();
|
||||
ocrMatches.forEach((row: { document_id: string; ocr_text: string | null }) => {
|
||||
if (!row.ocr_text) return;
|
||||
if (!snippetMap.has(row.document_id)) {
|
||||
snippetMap.set(row.document_id, row.ocr_text);
|
||||
}
|
||||
});
|
||||
if (snippetMap.size > 0) {
|
||||
const resultMap = new Map(results.map((item) => [item.id, item]));
|
||||
const missingDocIds: string[] = [];
|
||||
snippetMap.forEach((text, docId) => {
|
||||
const snippet = buildSnippet(text, normalizedQuery || null);
|
||||
if (resultMap.has(docId)) {
|
||||
const existing = resultMap.get(docId)!;
|
||||
resultMap.set(docId, {
|
||||
...existing,
|
||||
snippet: snippet || existing.snippet,
|
||||
hasOcr: true,
|
||||
matchField: "content",
|
||||
});
|
||||
} else {
|
||||
missingDocIds.push(docId);
|
||||
}
|
||||
});
|
||||
let extraResults: DocumentSearchResult[] = [];
|
||||
if (missingDocIds.length > 0) {
|
||||
const { data: extraDocs } = await supabase
|
||||
.from("documents")
|
||||
.select("id,title,updated_at,created_at,raw_text")
|
||||
.in("id", missingDocIds)
|
||||
.is("deleted_at", null);
|
||||
extraResults =
|
||||
extraDocs?.map((row) => {
|
||||
const snippetText = snippetMap.get(row.id) ?? row.raw_text ?? "";
|
||||
return {
|
||||
...mapDocumentToResult(row, normalizedQuery || null, "content"),
|
||||
snippet: buildSnippet(snippetText, normalizedQuery || null),
|
||||
hasOcr: true,
|
||||
};
|
||||
}) ?? [];
|
||||
}
|
||||
results = [...resultMap.values(), ...extraResults];
|
||||
}
|
||||
}
|
||||
|
||||
const response: DocumentSearchResponse = {
|
||||
results,
|
||||
recent: recentResults,
|
||||
};
|
||||
|
||||
return NextResponse.json(response);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
|
||||
interface RecentPayload {
|
||||
workspaceId: string;
|
||||
documentId: string;
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { workspaceId, documentId }: RecentPayload = await request.json();
|
||||
|
||||
if (!workspaceId || !documentId) {
|
||||
return NextResponse.json({ error: "缺少参数" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { data: membership, error: membershipError } = await supabase
|
||||
.from("workspace_members")
|
||||
.select("workspace_id")
|
||||
.eq("workspace_id", workspaceId)
|
||||
.eq("user_id", session.user.id)
|
||||
.maybeSingle();
|
||||
|
||||
if (membershipError) {
|
||||
return NextResponse.json({ error: membershipError.message }, { status: 500 });
|
||||
}
|
||||
if (!membership) {
|
||||
return NextResponse.json({ error: "无权访问该工作空间" }, { status: 403 });
|
||||
}
|
||||
|
||||
const { error } = await supabase.from("user_recent_pages").upsert(
|
||||
{
|
||||
user_id: session.user.id,
|
||||
workspace_id: workspaceId,
|
||||
document_id: documentId,
|
||||
last_accessed_at: new Date().toISOString(),
|
||||
},
|
||||
{ onConflict: "user_id,document_id" },
|
||||
);
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
import { ensureDefaultWorkspace, fetchWorkspaceSummaries } from "@/lib/workspaces";
|
||||
import { fetchSidebarDataset } from "@/lib/sidebar-tree";
|
||||
import type { SidebarInitialData } from "@/components/sidebar/types";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
const workspaceIdParam = url.searchParams.get("workspaceId");
|
||||
|
||||
await ensureDefaultWorkspace(supabase, session.user.id, session.user.email ?? "我的空间");
|
||||
const { workspaces, activeWorkspaceId } = await fetchWorkspaceSummaries(supabase, session.user.id);
|
||||
const targetWorkspaceId = workspaceIdParam || activeWorkspaceId;
|
||||
|
||||
if (!targetWorkspaceId) {
|
||||
return NextResponse.json({ error: "暂无可用工作空间" }, { status: 404 });
|
||||
}
|
||||
|
||||
try {
|
||||
const dataset = await fetchSidebarDataset(supabase, targetWorkspaceId);
|
||||
|
||||
const payload: SidebarInitialData = {
|
||||
activeWorkspaceId: targetWorkspaceId,
|
||||
workspaces,
|
||||
documents: dataset.documents,
|
||||
trashedDocuments: dataset.trashedDocuments,
|
||||
};
|
||||
|
||||
return NextResponse.json(payload);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : "拉取侧边栏数据失败" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const payload = await request.json().catch(() => ({}));
|
||||
const workspaceId = payload.workspaceId as string | undefined;
|
||||
|
||||
if (!workspaceId) {
|
||||
return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { data: membership, error: membershipError } = await supabase
|
||||
.from("workspace_members")
|
||||
.select("id")
|
||||
.eq("workspace_id", workspaceId)
|
||||
.eq("user_id", session.user.id)
|
||||
.single();
|
||||
|
||||
if (membershipError || !membership) {
|
||||
return NextResponse.json({ error: "无权切换至该工作空间" }, { status: 403 });
|
||||
}
|
||||
|
||||
const { error: resetError } = await supabase
|
||||
.from("workspace_members")
|
||||
.update({ is_default: false })
|
||||
.eq("user_id", session.user.id);
|
||||
|
||||
if (resetError) {
|
||||
return NextResponse.json({ error: resetError.message }, { status: 500 });
|
||||
}
|
||||
|
||||
const { error: switchError } = await supabase
|
||||
.from("workspace_members")
|
||||
.update({ is_default: true })
|
||||
.eq("user_id", session.user.id)
|
||||
.eq("workspace_id", workspaceId);
|
||||
|
||||
if (switchError) {
|
||||
return NextResponse.json({ error: switchError.message }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,410 @@
|
||||
@import url("https://rsms.me/inter/inter.css");
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
html {
|
||||
font-family: "Inter", system-ui, sans-serif;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.wolai-hover {
|
||||
@apply transition-colors duration-150 hover:bg-gray-50;
|
||||
}
|
||||
.wolai-selected {
|
||||
@apply border-l-2 border-blue-500 bg-blue-50;
|
||||
}
|
||||
.wolai-blue {
|
||||
@apply cursor-pointer text-[#2563eb] hover:underline;
|
||||
}
|
||||
.wolai-editor .bn-block-outer {
|
||||
padding: 2px 0;
|
||||
}
|
||||
.wolai-editor .bn-inline-content a {
|
||||
color: #2563eb;
|
||||
text-decoration: underline;
|
||||
}
|
||||
.wolai-editor .bn-drag-handle {
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
.wolai-editor .bn-block-group:hover .bn-drag-handle {
|
||||
opacity: 1;
|
||||
}
|
||||
.wolai-editor[data-heading-numbering="true"] {
|
||||
counter-reset: wolai-h1 wolai-h2 wolai-h3 wolai-h4 wolai-h5;
|
||||
}
|
||||
.wolai-editor[data-heading-numbering="true"] h1 {
|
||||
counter-increment: wolai-h1;
|
||||
counter-reset: wolai-h2;
|
||||
}
|
||||
.wolai-editor[data-heading-numbering="true"] h1::before {
|
||||
content: counter(wolai-h1) ". ";
|
||||
color: #94a3b8;
|
||||
margin-right: 8px;
|
||||
}
|
||||
.wolai-editor[data-heading-numbering="true"] h2 {
|
||||
counter-increment: wolai-h2;
|
||||
counter-reset: wolai-h3;
|
||||
}
|
||||
.wolai-editor[data-heading-numbering="true"] h2::before {
|
||||
content: counter(wolai-h1) "." counter(wolai-h2) ". ";
|
||||
color: #94a3b8;
|
||||
margin-right: 6px;
|
||||
}
|
||||
.wolai-editor[data-heading-numbering="true"] h3 {
|
||||
counter-increment: wolai-h3;
|
||||
counter-reset: wolai-h4;
|
||||
}
|
||||
.wolai-editor[data-heading-numbering="true"] h3::before {
|
||||
content: counter(wolai-h1) "." counter(wolai-h2) "." counter(wolai-h3) ". ";
|
||||
color: #cbd5f5;
|
||||
margin-right: 4px;
|
||||
}
|
||||
.wolai-editor[data-heading-numbering="true"] h4 {
|
||||
counter-increment: wolai-h4;
|
||||
counter-reset: wolai-h5;
|
||||
}
|
||||
.wolai-editor[data-heading-numbering="true"] h4::before {
|
||||
content: counter(wolai-h1) "." counter(wolai-h2) "." counter(wolai-h3) "." counter(wolai-h4) ". ";
|
||||
color: #d0d7e7;
|
||||
margin-right: 4px;
|
||||
}
|
||||
.wolai-editor[data-heading-numbering="true"] h5 {
|
||||
counter-increment: wolai-h5;
|
||||
}
|
||||
.wolai-editor[data-heading-numbering="true"] h5::before {
|
||||
content: counter(wolai-h1) "." counter(wolai-h2) "." counter(wolai-h3) "." counter(wolai-h4) "." counter(wolai-h5) ". ";
|
||||
color: #d4d4d8;
|
||||
margin-right: 4px;
|
||||
}
|
||||
.wolai-editor-show-structure .bn-block-outer {
|
||||
outline: 1px dashed #d4d4d8;
|
||||
border-radius: 8px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
.wolai-advanced-todo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.wolai-advanced-todo__status {
|
||||
border-radius: 999px;
|
||||
border: 1px solid #c7d2fe;
|
||||
padding: 2px 10px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
background-color: #eef2ff;
|
||||
color: #312e81;
|
||||
}
|
||||
.wolai-advanced-todo__status.status-doing {
|
||||
border-color: #fcd34d;
|
||||
background-color: #fff7ed;
|
||||
color: #92400e;
|
||||
}
|
||||
.wolai-advanced-todo__status.status-done {
|
||||
border-color: #4ade80;
|
||||
background-color: #ecfdf5;
|
||||
color: #047857;
|
||||
}
|
||||
.wolai-advanced-todo__status.status-cancelled {
|
||||
border-color: #fecdd3;
|
||||
background-color: #fff1f2;
|
||||
color: #9f1239;
|
||||
}
|
||||
.wolai-progress {
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
border: 1px solid #e5e7eb;
|
||||
background-color: #f8fafc;
|
||||
margin: 8px 0;
|
||||
}
|
||||
.wolai-progress__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 12px;
|
||||
color: #475569;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.wolai-progress__mode {
|
||||
font-size: 11px;
|
||||
border-radius: 12px;
|
||||
background-color: #e0edff;
|
||||
color: #1d4ed8;
|
||||
padding: 2px 8px;
|
||||
}
|
||||
.wolai-progress__bar {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 10px;
|
||||
border-radius: 999px;
|
||||
background-color: #e4e4e7;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
}
|
||||
.wolai-progress__fill {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
border-radius: inherit;
|
||||
background-image: linear-gradient(90deg, #60a5fa, #2563eb);
|
||||
}
|
||||
.wolai-progress__percent {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: #1d4ed8;
|
||||
font-weight: 600;
|
||||
}
|
||||
.wolai-progress__description {
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.wolai-media {
|
||||
position: relative;
|
||||
margin: 24px 0;
|
||||
border-radius: 18px;
|
||||
}
|
||||
.wolai-media::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -8px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 24px;
|
||||
transition: border-color 0.2s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
.wolai-media:hover::after,
|
||||
.wolai-media:focus-within::after {
|
||||
border-color: rgba(37, 99, 235, 0.15);
|
||||
}
|
||||
.wolai-media--empty {
|
||||
border: 1px dashed #bed5ff;
|
||||
background-color: #fff;
|
||||
padding: 20px;
|
||||
border-radius: 16px;
|
||||
}
|
||||
.wolai-media__canvas {
|
||||
position: relative;
|
||||
border-radius: 18px;
|
||||
overflow: hidden;
|
||||
background-color: #fff;
|
||||
box-shadow: 0 10px 40px rgba(15, 23, 42, 0.05);
|
||||
margin: 0 auto;
|
||||
}
|
||||
.wolai-media__figure {
|
||||
border-radius: inherit;
|
||||
overflow: hidden;
|
||||
background-color: #fff;
|
||||
}
|
||||
.wolai-media__figure img {
|
||||
width: 100%;
|
||||
border-radius: inherit;
|
||||
object-fit: cover;
|
||||
}
|
||||
.wolai-media__figure--border img {
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
.wolai-media__figure figcaption {
|
||||
padding: 8px 12px;
|
||||
}
|
||||
.wolai-media__figure--center figcaption {
|
||||
text-align: center;
|
||||
}
|
||||
.wolai-media__figure--right figcaption {
|
||||
text-align: right;
|
||||
}
|
||||
.wolai-media__quickbar {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 4px;
|
||||
border-radius: 9999px;
|
||||
background-color: rgba(15, 23, 42, 0.85);
|
||||
box-shadow: 0 12px 32px rgba(15, 23, 42, 0.35);
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
transition: opacity 0.15s ease, transform 0.15s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
.wolai-media:hover .wolai-media__quickbar,
|
||||
.wolai-media:focus-within .wolai-media__quickbar {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
pointer-events: auto;
|
||||
}
|
||||
.wolai-media__quickbutton {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 9999px;
|
||||
background-color: transparent;
|
||||
color: #e2e8f0;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
.wolai-media__quickbutton:hover,
|
||||
.wolai-media__quickbutton:focus-visible {
|
||||
background-color: rgba(255, 255, 255, 0.15);
|
||||
color: #fff;
|
||||
outline: none;
|
||||
}
|
||||
.wolai-media__hint {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
text-align: left;
|
||||
}
|
||||
.wolai-media__resize-handle {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
width: 12px;
|
||||
height: 44px;
|
||||
border-radius: 2px;
|
||||
background: rgba(15, 23, 42, 0.5);
|
||||
transform: translateY(-50%);
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
cursor: ew-resize;
|
||||
z-index: 20;
|
||||
}
|
||||
.wolai-media__resize-handle--left {
|
||||
left: -6px;
|
||||
}
|
||||
.wolai-media__resize-handle--right {
|
||||
right: -6px;
|
||||
}
|
||||
.wolai-media:hover .wolai-media__resize-handle,
|
||||
.wolai-media:focus-within .wolai-media__resize-handle {
|
||||
opacity: 1;
|
||||
}
|
||||
.wolai-media__resize-handle.is-dragging {
|
||||
background: rgba(37, 99, 235, 0.85);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Inter } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { SupabaseProvider } from "@/components/providers/supabase-provider";
|
||||
import { QueryProvider } from "@/components/providers/query-provider";
|
||||
import { createSupabaseServerClient } from "@/lib/supabase/server";
|
||||
|
||||
const inter = Inter({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-inter",
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Wolai Clone",
|
||||
description: "Stage0 + Stage1 implementation powered by Supabase",
|
||||
};
|
||||
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
const supabase = await createSupabaseServerClient();
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
|
||||
return (
|
||||
<html lang="zh-CN">
|
||||
<body className={`${inter.variable} antialiased`}>
|
||||
<SupabaseProvider session={session}>
|
||||
<QueryProvider>{children}</QueryProvider>
|
||||
</SupabaseProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { createSupabaseServerClient } from "@/lib/supabase/server";
|
||||
|
||||
export default async function Home() {
|
||||
const supabase = await createSupabaseServerClient();
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
|
||||
if (!session) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
const { data: firstDoc } = await supabase
|
||||
.from("documents")
|
||||
.select("id")
|
||||
.eq("user_id", session.user.id)
|
||||
.order("created_at", { ascending: true })
|
||||
.limit(1)
|
||||
.maybeSingle();
|
||||
|
||||
if (firstDoc?.id) {
|
||||
redirect(`/documents/${firstDoc.id}`);
|
||||
}
|
||||
|
||||
const { data: createdDoc, error } = await supabase
|
||||
.from("documents")
|
||||
.insert({
|
||||
user_id: session.user.id,
|
||||
title: "新页面",
|
||||
content: {},
|
||||
})
|
||||
.select("id")
|
||||
.single();
|
||||
|
||||
if (error || !createdDoc) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
redirect(`/documents/${createdDoc.id}`);
|
||||
}
|
||||
Reference in New Issue
Block a user