chore: init monorepo snapshot

This commit is contained in:
liaibo
2025-11-23 10:55:04 +08:00
commit c70ff52869
941 changed files with 246586 additions and 0 deletions
@@ -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}
/>
);
}
+55
View File
@@ -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>
);
}
View File
@@ -0,0 +1,99 @@
import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import { ensureDefaultWorkspace, resolveActiveWorkspaceId } from "@/lib/workspaces";
type CreateChildPayload = {
parentId: string | null;
title?: string;
blocks?: unknown;
};
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 { parentId, title, blocks }: CreateChildPayload = await request.json();
if (typeof parentId === "undefined") {
return NextResponse.json({ error: "缺少 parentId" }, { status: 400 });
}
let workspaceId: string;
let accessScope: "private" | "shared" | "public" = "private";
if (parentId) {
const { data: parentDoc, error: parentError } = await supabase
.from("documents")
.select("workspace_id,access_scope")
.eq("id", parentId)
.single();
if (parentError || !parentDoc) {
return NextResponse.json({ error: "父页面不存在" }, { status: 404 });
}
workspaceId = parentDoc.workspace_id;
accessScope = (parentDoc.access_scope ?? "private") as typeof accessScope;
} else {
await ensureDefaultWorkspace(supabase, session.user.id, session.user.email ?? "我的空间");
workspaceId = await resolveActiveWorkspaceId(supabase, session.user.id);
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 resolvedTitle =
title && title.trim().length > 0 ? title.trim() : "未命名页面";
const contentPayload = Array.isArray(blocks) ? blocks : [];
const { data, error } = await supabase
.from("documents")
.insert({
user_id: session.user.id,
parent_id: parentId,
workspace_id: workspaceId,
access_scope: accessScope,
title: resolvedTitle,
content: contentPayload,
sort_order: siblingCount,
})
.select("id,title")
.single();
if (error || !data) {
return NextResponse.json(
{ error: error?.message ?? "创建子页面失败" },
{ status: 500 },
);
}
return NextResponse.json({
pageId: data.id,
title: data.title ?? resolvedTitle,
});
}
@@ -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

+410
View File
@@ -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);
}
}
+37
View File
@@ -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>
);
}
+41
View File
@@ -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}`);
}
@@ -0,0 +1,36 @@
"use client";
import { MessageCircle, Sparkles, Wand2 } from "lucide-react";
import { useBackendHealth } from "@/hooks/use-backend-health";
import { cn } from "@/lib/utils";
export function BottomToolbar() {
const status = useBackendHealth();
const indicatorColor =
status === "ok" ? "bg-green-500" : status === "error" ? "bg-red-500" : "bg-gray-300";
return (
<footer className="flex h-12 items-center justify-between border-t border-[#eeeeee] px-6 text-sm">
<div className="flex items-center gap-2 text-xs text-gray-500">
<span className={cn("h-2 w-2 rounded-full", indicatorColor)} />
{status === "ok" ? "正常" : status === "error" ? "异常" : "检测中"}
</div>
<div className="flex items-center gap-3 text-gray-600">
<button type="button" className="wolai-hover rounded-full px-3 py-1">
<MessageCircle className="mr-1 inline h-4 w-4" />
</button>
<button type="button" className="wolai-hover rounded-full px-3 py-1">
<Wand2 className="mr-1 inline h-4 w-4" />
</button>
<button
type="button"
className="flex h-8 w-8 items-center justify-center rounded-full bg-[#2563eb] text-white shadow-sm"
>
<Sparkles className="h-4 w-4" />
</button>
</div>
</footer>
);
}
@@ -0,0 +1,63 @@
"use client";
import Link from "next/link";
import { useParams, useSelectedLayoutSegments } from "next/navigation";
import { ChevronRight, MoreHorizontal, Star } from "lucide-react";
import type { DocumentRecord } from "@/lib/documents";
import { findBreadcrumb } from "@/lib/documents";
import { usePageLayoutStore } from "@/store/page-layout";
interface BreadcrumbProps {
documents: DocumentRecord[];
}
export function Breadcrumb({ documents }: BreadcrumbProps) {
const segments = useSelectedLayoutSegments();
const params = useParams<{ id?: string }>();
const paramId = typeof params?.id === "string" ? params.id : "";
const activeId = paramId || segments?.[1] || "";
const path = findBreadcrumb(documents, activeId);
const showInspector = usePageLayoutStore((state) => state.showInspector);
const toggleInspector = usePageLayoutStore((state) => state.toggleInspector);
if (!path.length) {
return <div className="text-sm text-gray-400"></div>;
}
return (
<div className="flex flex-1 items-center justify-between">
<nav className="flex items-center text-sm text-gray-500">
{path.map((node, index) => {
const isLast = index === path.length - 1;
return (
<span key={node.id} className="flex items-center">
{index > 0 && <ChevronRight className="mx-1 h-4 w-4 text-gray-400" />}
{isLast ? (
<span className="font-medium text-gray-900">{node.title || "无标题"}</span>
) : (
<Link href={`/documents/${node.id}`} className="wolai-blue">
{node.title || "无标题"}
</Link>
)}
</span>
);
})}
</nav>
<div className="flex items-center gap-2 text-gray-500">
<button type="button" className="wolai-hover rounded-full px-3 py-1 text-sm">
<Star className="mr-1 inline h-4 w-4" />
</button>
<button
type="button"
className="wolai-hover rounded-full p-2 text-gray-500"
onClick={toggleInspector}
aria-label={showInspector ? "隐藏页面选项" : "显示页面选项"}
title={showInspector ? "隐藏页面选项" : "显示页面选项"}
>
<MoreHorizontal className="h-4 w-4" />
</button>
</div>
</div>
);
}
@@ -0,0 +1,87 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useSessionContext } from "@supabase/auth-helpers-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
interface TaskResponse {
task_id: string;
status: string;
progress: number;
message?: string | null;
}
interface Props {
documentId: string;
}
export function DocumentTaskPanel({ documentId }: Props) {
const { session } = useSessionContext();
const [task, setTask] = useState<TaskResponse | null>(null);
const [pending, setPending] = useState(false);
const backendUrl = useMemo(() => process.env.NEXT_PUBLIC_BACKEND_URL, []);
const triggerTask = async () => {
if (!backendUrl || !session?.access_token) return;
setPending(true);
try {
const response = await fetch(`${backendUrl}/api/v1/tasks/ocr`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${session.access_token}`,
},
body: JSON.stringify({
document_id: documentId,
file_url: "https://example.com/sample.pdf",
}),
});
const data = await response.json();
if (response.ok) {
setTask(data);
}
} finally {
setPending(false);
}
};
useEffect(() => {
if (!backendUrl || !session?.access_token || !task?.task_id) {
return;
}
const timer = setInterval(async () => {
const response = await fetch(`${backendUrl}/api/v1/tasks/${task.task_id}`, {
headers: {
Authorization: `Bearer ${session.access_token}`,
},
});
if (!response.ok) {
return;
}
const data = (await response.json()) as TaskResponse;
setTask(data);
if (data.status === "completed") {
clearInterval(timer);
}
}, 2000);
return () => clearInterval(timer);
}, [backendUrl, session?.access_token, task?.task_id]);
return (
<Card className="mt-4 bg-white shadow-sm">
<CardContent className="flex items-center justify-between py-3 text-sm text-gray-600">
<div>
<div className="font-medium text-gray-900"> OCR </div>
<div className="text-xs text-gray-500">
{task ? task.status : "未开始"} · {task ? `${task.progress}%` : "0%"}
</div>
{task?.message && <div className="text-xs text-gray-500">{task.message}</div>}
</div>
<Button onClick={triggerTask} disabled={pending} variant="outline">
{pending ? "触发中..." : "触发 OCR"}
</Button>
</CardContent>
</Card>
);
}
@@ -0,0 +1,585 @@
"use client";
import "@blocknote/core/style.css";
import "@blocknote/react/style.css";
import "@blocknote/mantine/style.css";
import { useCallback, useEffect, useMemo, useState } from "react";
import { BlockNoteView } from "@blocknote/mantine";
import {
SideMenuController,
useCreateBlockNote,
type SideMenuProps,
} from "@blocknote/react";
import { HocuspocusProvider } from "@hocuspocus/provider";
import * as Y from "yjs";
import type { Block } from "@blocknote/core";
import { cn } from "@/lib/utils";
import type { Json } from "@/types/supabase";
import type { DocumentStats, PageOptionsState } from "@/types/page-options";
import type { MediaAsset } from "@/types/media";
import { customBlockSchema, type CustomBlockSchema } from "./schema";
import { CustomSideMenu } from "./menus/CustomSideMenu";
import { CustomSlashMenu } from "./menus/CustomSlashMenu";
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
import { DocumentToc, type TocEntry } from "@/components/editor/document-toc";
import { useSearchPaletteStore } from "@/store/search-palette";
import { useEditorBridgeStore } from "@/store/editor-bridge";
import type { ReferenceTarget } from "@/types/search";
interface BlockNoteEditorProps {
documentId: string;
workspaceId: string;
initialContent: unknown;
pageOptions: PageOptionsState;
onStatsChange?: (stats: DocumentStats) => void;
onSnapshot?: (payload: { blocks: Json; stats: DocumentStats }) => void;
}
const extractInitialBlocks = (content: unknown): Json | undefined => {
if (Array.isArray(content) && content.length > 0) {
return content as Json;
}
if (content && typeof content === "object") {
const maybeBlocks = (content as Record<string, unknown>).blocks;
if (Array.isArray(maybeBlocks) && maybeBlocks.length > 0) {
return maybeBlocks as Json;
}
}
return undefined;
};
const extractInlineText = (block: Block<CustomBlockSchema>): string => {
const inlineNodes = (block.content ?? []) as Array<{ text?: string }>;
return inlineNodes.map((node) => (typeof node.text === "string" ? node.text : "")).join("").trim();
};
const buildHeadingToc = (blocks: Block<CustomBlockSchema>[]): TocEntry[] => {
const counters = [0, 0, 0, 0, 0];
const entries: TocEntry[] = [];
const walk = (targetBlocks: Block<CustomBlockSchema>[]) => {
targetBlocks.forEach((block) => {
if (block.type === "heading") {
const level = Math.min(5, Math.max(1, Number(block.props.level) || 1));
counters[level - 1] += 1;
for (let i = level; i < counters.length; i += 1) {
counters[i] = 0;
}
const numbering = counters.slice(0, level).filter((value) => value > 0).join(".");
entries.push({
id: block.id,
level,
numbering,
title: extractInlineText(block),
});
}
if (block.children && block.children.length > 0) {
walk(block.children as Block<CustomBlockSchema>[]);
}
});
};
walk(blocks);
return entries;
};
const findBlockById = (
blocks: Block<CustomBlockSchema>[],
id: string,
): Block<CustomBlockSchema> | undefined => {
for (const block of blocks) {
if (block.id === id) return block;
if (block.children && block.children.length > 0) {
const child = findBlockById(block.children as Block<CustomBlockSchema>[], id);
if (child) return child;
}
}
return undefined;
};
const syncProgressMeters = (editorInstance: ReturnType<typeof useCreateBlockNote>) => {
if (!editorInstance) {
return;
}
const blocks = editorInstance.topLevelBlocks as Block<CustomBlockSchema>[];
const progressStats = new Map<
string,
{ done: number; doing: number; total: number }
>();
let activeProgressId: string | null = null;
const traverse = (targetBlocks: Block<CustomBlockSchema>[]) => {
targetBlocks.forEach((block) => {
if (block.type === "progressMeter" && block.props.auto) {
activeProgressId = block.id;
progressStats.set(block.id, { done: 0, doing: 0, total: 0 });
} else if (block.type === "progressMeter" && !block.props.auto) {
activeProgressId = null;
} else if (block.type === "heading") {
activeProgressId = null;
} else if (block.type === "advancedTodo" && activeProgressId) {
const currentStat = progressStats.get(activeProgressId);
if (!currentStat) return;
if (block.props.status === "cancelled") {
return;
}
currentStat.total += 1;
if (block.props.status === "done") {
currentStat.done += 1;
} else if (block.props.status === "doing") {
currentStat.doing += 1;
}
}
if (block.children && block.children.length > 0) {
traverse(block.children as Block<CustomBlockSchema>[]);
}
});
};
traverse(blocks);
progressStats.forEach((stat, progressId) => {
const block = findBlockById(blocks, progressId);
if (!block) return;
const weightedDone = stat.done + stat.doing * 0.5;
const percent = stat.total === 0 ? 0 : Math.min(100, Math.round((weightedDone / stat.total) * 100));
const summary = stat.total === 0 ? "暂无条目" : `${stat.done}/${stat.total} 完成`;
if (block.props.percent !== percent || block.props.summary !== summary) {
editorInstance.updateBlock(block, {
props: {
percent,
summary,
},
});
}
});
};
export function BlockNoteEditor({
documentId,
workspaceId,
initialContent,
pageOptions,
onStatsChange,
onSnapshot,
}: BlockNoteEditorProps) {
const [isSaving, setIsSaving] = useState(false);
const [tocEntries, setTocEntries] = useState<TocEntry[]>([]);
const openReferencePalette = useSearchPaletteStore((state) => state.openReference);
const registerEditorBridge = useEditorBridgeStore((state) => state.registerBridge);
const normalizedInitialContent = useMemo(
() => extractInitialBlocks(initialContent),
[initialContent],
);
const collaboration = useMemo(() => {
const url = process.env.NEXT_PUBLIC_HOCUSPOCUS_URL;
if (!url) return null;
const doc = new Y.Doc();
const provider = new HocuspocusProvider({
url,
name: `document.${documentId}`,
document: doc,
});
return { doc, provider };
}, [documentId]);
const editor = useCreateBlockNote(
{
initialContent: normalizedInitialContent as never,
schema: customBlockSchema,
collaboration: collaboration
? {
provider: collaboration.provider,
fragment: collaboration.doc.getXmlFragment("wolai"),
user: {
name: "访客",
color: "#2563eb",
},
}
: undefined,
},
[documentId],
);
useEffect(
() => () => {
collaboration?.provider.destroy();
collaboration?.doc.destroy();
},
[collaboration],
);
const saveContent = useCallback(
async (content: Json) => {
setIsSaving(true);
try {
await fetch("/api/documents/save", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ documentId, content }),
});
} finally {
setIsSaving(false);
}
},
[documentId],
);
const debouncedSave = useDebouncedCallback(saveContent, 800);
useEffect(() => {
if (!editor) {
return undefined;
}
let disposed = false;
const runSync = () => {
if (disposed) {
return;
}
const blocks = editor.topLevelBlocks;
debouncedSave(blocks as Json);
const typedBlocks = blocks as Block<CustomBlockSchema>[];
setTocEntries(buildHeadingToc(typedBlocks));
syncProgressMeters(editor);
const stats = computeDocumentStats(typedBlocks);
onStatsChange?.(stats);
onSnapshot?.({ blocks: blocks as Json, stats });
};
runSync();
const unsubscribe = editor.onEditorContentChange(runSync);
return () => {
disposed = true;
if (typeof unsubscribe === "function") {
unsubscribe();
}
};
}, [editor, debouncedSave, onSnapshot, onStatsChange]);
const jumpToHeading = useCallback((headingId: string) => {
const target = document.querySelector<HTMLElement>(`[data-id="${headingId}"]`);
if (target) {
target.scrollIntoView({ behavior: "smooth", block: "center" });
}
}, []);
const editorWrapperClass = cn(
"relative min-h-[60vh] rounded-2xl border border-transparent bg-white p-4 shadow-sm",
pageOptions.smallText ? "text-[15px]" : "text-[16px]",
);
const blocknoteClass = cn(
"wolai-editor min-h-full",
pageOptions.showStructure && "wolai-editor-show-structure",
);
const buildDocumentPath = (documentId: string): string => {
if (typeof window === "undefined" || !window.location) {
return `/documents/${documentId}`;
}
return `${window.location.origin}/documents/${documentId}`;
};
const trimTrailingCharacter = (
editorInstance: ReturnType<typeof useCreateBlockNote> | null,
block: Block<CustomBlockSchema>,
char: string,
) => {
if (!editorInstance) {
return;
}
const content = Array.isArray(block.content) ? [...block.content] : [];
for (let index = content.length - 1; index >= 0; index -= 1) {
const node = content[index] as { text?: string };
if (typeof node?.text === "string" && node.text.endsWith(char)) {
const nextText = node.text.slice(0, -1);
if (nextText.length === 0) {
content.splice(index, 1);
} else {
content[index] = { ...node, text: nextText };
}
editorInstance.updateBlock(block, { content });
break;
}
}
};
const generateBlockId = () => {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return crypto.randomUUID();
}
return `ref_${Math.random().toString(36).slice(2, 10)}`;
};
const computeDocumentStats = (blocks: Block<CustomBlockSchema>[]): DocumentStats => {
let characterCount = 0;
let wordCount = 0;
const accumulate = (targetBlocks: Block<CustomBlockSchema>[]) => {
targetBlocks.forEach((block) => {
if (Array.isArray(block.content)) {
block.content.forEach((node: { text?: string }) => {
if (typeof node.text === "string") {
const text = node.text;
characterCount += text.length;
const trimmed = text.trim();
if (trimmed.length === 0) {
return;
}
const tokens = trimmed.split(/\s+/).filter(Boolean);
if (tokens.length > 1) {
wordCount += tokens.length;
} else {
wordCount += trimmed.replace(/\s+/g, "").length;
}
}
});
}
if (block.children && block.children.length > 0) {
accumulate(block.children as Block<CustomBlockSchema>[]);
}
});
};
accumulate(blocks);
return {
wordCount,
characterCount,
blockCount: blocks.length,
};
};
const insertMediaAssetBlock = useCallback(
(asset: MediaAsset) => {
if (!editor) {
return;
}
const fileUrl = asset.file_url ?? "";
if (!fileUrl) {
return;
}
const cursor = editor.getTextCursorPosition();
const referenceBlock =
cursor?.block ?? (editor.topLevelBlocks[editor.topLevelBlocks.length - 1] as Block<CustomBlockSchema> | undefined);
if (!referenceBlock) {
return;
}
editor.insertBlocks(
[
{
type: "media",
props: {
fileUrl,
thumbnailUrl: asset.thumbnail_url ?? fileUrl,
assetId: asset.id,
assetType: asset.asset_type ?? "image",
fileName: asset.file_name ?? "",
fileSize: asset.file_size ?? null,
mimeType: asset.mime_type ?? "",
ocrStatus: asset.ocr_status ?? "idle",
},
},
],
referenceBlock,
"after",
);
},
[editor],
);
const uploadClipboardMedia = useCallback(
async (file: File) => {
if (!workspaceId) {
throw new Error("缺少空间信息,无法上传文件");
}
const form = new FormData();
form.append("file", file);
form.append("workspaceId", workspaceId);
form.append("documentId", documentId);
const response = await fetch("/api/media/upload", {
method: "POST",
body: form,
});
if (!response.ok) {
const payload = await response.json().catch(() => null);
throw new Error(payload?.error ?? "上传失败");
}
const payload = (await response.json()) as { asset: MediaAsset };
if (!payload.asset) {
throw new Error("上传返回数据缺失");
}
return payload.asset;
},
[documentId, workspaceId],
);
useEffect(() => {
if (!editor) {
registerEditorBridge(null);
return;
}
const bridge = {
insertInlineReference: (target: ReferenceTarget, aliasText?: string) => {
editor.focus();
const cursor = editor.getTextCursorPosition();
const blockId = cursor?.block?.id ?? null;
const text = aliasText || target.title || "无标题";
editor.insertInlineContent([
{
type: "link",
href: buildDocumentPath(target.id),
content: text,
},
{ type: "text", text: " " },
]);
return { blockId };
},
insertEmbedReference: (target: ReferenceTarget) => {
editor.focus();
const cursor = editor.getTextCursorPosition();
const referenceBlock =
cursor?.block ?? editor.topLevelBlocks[editor.topLevelBlocks.length - 1];
const blockId = generateBlockId();
editor.insertBlocks(
[
{
id: blockId,
type: "pageReference",
props: {
pageId: target.id,
title: target.title ?? "无标题",
},
},
],
referenceBlock,
"after",
);
return { blockId };
},
replaceWithSnapshot: (payload: Json) => {
editor.focus();
const nextBlocks = Array.isArray(payload)
? payload
: Array.isArray((payload as { blocks?: Json }).blocks)
? ((payload as { blocks?: Json }).blocks as Json)
: [];
if (!Array.isArray(nextBlocks)) {
return;
}
editor.replaceBlocks(editor.topLevelBlocks, nextBlocks as never);
},
};
registerEditorBridge(bridge);
return () => registerEditorBridge(null);
}, [editor, registerEditorBridge]);
useEffect(() => {
if (!editor) {
return undefined;
}
const handlePaste = (event: ClipboardEvent) => {
const activeElement = event.target;
if (!(activeElement instanceof HTMLElement) || !activeElement.closest(".wolai-editor")) {
return;
}
const items = Array.from(event.clipboardData?.files ?? []);
if (items.length === 0) {
return;
}
const imageFile = items.find((candidate) => candidate.type?.startsWith("image/"));
if (!imageFile) {
return;
}
event.preventDefault();
void (async () => {
try {
const asset = await uploadClipboardMedia(imageFile);
insertMediaAssetBlock(asset);
} catch (error) {
console.error(error);
window.alert((error as Error).message ?? "粘贴图片失败,请稍后重试");
}
})();
};
window.addEventListener("paste", handlePaste);
return () => window.removeEventListener("paste", handlePaste);
}, [editor, insertMediaAssetBlock, uploadClipboardMedia]);
useEffect(() => {
if (!editor) {
return;
}
const buffer = { char: "", blockId: "", timestamp: 0 };
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key !== "[" && event.key !== "#") {
buffer.char = "";
buffer.blockId = "";
buffer.timestamp = 0;
return;
}
const activeElement = document.activeElement;
if (!(activeElement instanceof HTMLElement) || !activeElement.closest(".wolai-editor")) {
return;
}
const { block } = editor.getTextCursorPosition();
if (!block) {
return;
}
const now = Date.now();
if (
buffer.char === event.key &&
buffer.blockId === block.id &&
now - buffer.timestamp < 450
) {
event.preventDefault();
trimTrailingCharacter(editor, block, event.key);
openReferencePalette({
referenceMode: event.key === "[" ? "inline" : "embed",
});
buffer.char = "";
buffer.blockId = "";
buffer.timestamp = 0;
} else {
buffer.char = event.key;
buffer.blockId = block.id;
buffer.timestamp = now;
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [editor, openReferencePalette]);
const layoutClass = cn(
"relative mx-auto w-full",
pageOptions.wideLayout ? "max-w-none" : "max-w-[980px]",
);
return (
<div className={layoutClass}>
<div className={editorWrapperClass}>
<BlockNoteView
editor={editor}
theme="light"
data-heading-numbering={pageOptions.showHeadingNumbers ? "true" : "false"}
editable={!pageOptions.protectEditing}
className={blocknoteClass}
>
<SideMenuController
sideMenu={(props: SideMenuProps<CustomBlockSchema>) => (
<CustomSideMenu {...props} currentDocumentId={documentId} />
)}
/>
<CustomSlashMenu editor={editor} currentDocumentId={documentId} />
</BlockNoteView>
<div className="pointer-events-none absolute right-4 top-3 text-xs text-gray-400">
{isSaving ? "保存中..." : "已保存"}
</div>
</div>
<DocumentToc entries={tocEntries} visible={pageOptions.showToc} onJump={jumpToHeading} />
</div>
);
}
@@ -0,0 +1,56 @@
"use client";
import { createReactBlockSpec } from "@blocknote/react";
const TODO_STATES = ["todo", "doing", "done", "cancelled"] as const;
const STATUS_LABELS: Record<(typeof TODO_STATES)[number], string> = {
todo: "未开始",
doing: "进行中",
done: "已完成",
cancelled: "已取消",
};
export const advancedTodoBlock = createReactBlockSpec(
{
type: "advancedTodo",
propSchema: {
status: {
default: "todo",
values: TODO_STATES,
},
},
content: "inline",
},
{
render: ({ block, editor }) => {
const updateStatus = (next: (typeof TODO_STATES)[number]) => {
editor.updateBlock(block, { props: { status: next } });
};
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
const current = block.props.status as (typeof TODO_STATES)[number];
if (event.altKey) {
updateStatus("cancelled");
return;
}
const index = TODO_STATES.indexOf(current);
const nextState = TODO_STATES[(index + 1) % TODO_STATES.length];
updateStatus(nextState);
};
return (
<div className="wolai-advanced-todo">
<button
type="button"
className={`wolai-advanced-todo__status status-${block.props.status}`}
onClick={handleClick}
>
{STATUS_LABELS[block.props.status as (typeof TODO_STATES)[number]]}
</button>
<div className="wolai-advanced-todo__content" />
</div>
);
},
},
)();
@@ -0,0 +1,498 @@
"use client";
import {
useEffect,
useRef,
useState,
useMemo,
type JSX,
type MouseEvent as ReactMouseEvent,
} from "react";
import { createReactBlockSpec } from "@blocknote/react";
import type { Block, BlockNoteEditor } from "@blocknote/core";
import { Download, Image as ImageIcon, Link as LinkIcon, MoreHorizontal, Paperclip, RefreshCcw, Type } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { useImagePicker } from "@/components/media/image-picker-context";
import type { MediaKind } from "@/types/media";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import type { CustomBlockSchema } from "../schema";
type MediaAlign = "left" | "center" | "right";
type MediaBlockRenderProps = {
block: Block<CustomBlockSchema>;
editor: BlockNoteEditor<CustomBlockSchema>;
};
const TYPE_LABEL_MAP: Record<MediaKind, string> = {
image: "图片",
video: "视频",
audio: "音频",
file: "文件",
};
const deriveFileName = (value?: string) => {
if (!value) {
return "未命名资源";
}
try {
const url = new URL(value);
const last = url.pathname.split("/").filter(Boolean).pop();
if (last) {
return decodeURIComponent(last);
}
} catch {
const segments = value.split("?")[0]?.split("/") ?? [];
const last = segments.pop();
if (last) {
return decodeURIComponent(last);
}
}
return "未命名资源";
};
const formatFileSize = (size?: number | null) => {
if (!size || size <= 0) {
return "未知大小";
}
const units = ["B", "KB", "MB", "GB", "TB"];
let idx = 0;
let current = size;
while (current >= 1024 && idx < units.length - 1) {
current /= 1024;
idx += 1;
}
return `${current.toFixed(current >= 10 ? 0 : 1)} ${units[idx]}`;
};
const MediaBlockContent = ({ block, editor }: MediaBlockRenderProps) => {
const { openPicker } = useImagePicker();
const [busy, setBusy] = useState(false);
const fileUrl = block.props.fileUrl as string;
const rawAssetType = (block.props.assetType as string) || "image";
const assetType: MediaKind =
rawAssetType === "video" || rawAssetType === "audio" || rawAssetType === "file"
? (rawAssetType as MediaKind)
: "image";
const typeLabel = TYPE_LABEL_MAP[assetType] ?? TYPE_LABEL_MAP.image;
const canAlign = assetType === "image" || assetType === "video";
const canToggleBorder = assetType === "image";
const canTriggerOcr = assetType === "image";
const canResize = assetType === "image" || assetType === "video";
const [dragging, setDragging] = useState<null | { side: "left" | "right"; startX: number; startWidth: number }>(null);
const [localWidth, setLocalWidth] = useState(() => (block.props.width ? Number(block.props.width) : 0));
const mediaRef = useRef<HTMLDivElement | null>(null);
const latestWidthRef = useRef(localWidth);
const displayFileName = block.props.fileName ?? deriveFileName(fileUrl);
const captionRef = useRef<HTMLInputElement | null>(null);
const [captionEditing, setCaptionEditing] = useState(false);
const shouldShowCaption = captionEditing || Boolean(block.props.caption);
const handleChoose = () => {
openPicker({
defaultTab: fileUrl ? "recent" : "upload",
mediaType: assetType,
onSelect: (selection) => {
editor.updateBlock(block, {
props: {
fileUrl: selection.fileUrl,
thumbnailUrl: selection.thumbnailUrl ?? selection.fileUrl,
assetId: selection.assetId,
assetType: selection.assetType ?? rawAssetType,
fileName: selection.fileName ?? block.props.fileName ?? "",
fileSize: selection.fileSize ?? block.props.fileSize ?? null,
mimeType: selection.mimeType ?? block.props.mimeType ?? "",
ocrStatus: "idle",
},
});
},
});
};
const toggleBorder = () => {
editor.updateBlock(block, { props: { hasBorder: !block.props.hasBorder } });
};
const setAlign = (align: MediaAlign) => {
editor.updateBlock(block, { props: { captionAlign: align } });
};
const handleCaptionChange = (value: string) => {
editor.updateBlock(block, { props: { caption: value } });
};
const enableCaptionEdit = () => {
setCaptionEditing(true);
setTimeout(() => captionRef.current?.focus(), 0);
};
useEffect(() => {
if (!shouldShowCaption && captionEditing) {
setCaptionEditing(false);
}
}, [captionEditing, shouldShowCaption]);
useEffect(() => {
if (!dragging) {
setLocalWidth(block.props.width ? Number(block.props.width) : 0);
}
}, [block.props.width, dragging]);
useEffect(() => {
latestWidthRef.current = localWidth;
}, [localWidth]);
const resolvedWidth = useMemo(() => {
if (!canResize) return 0;
if (localWidth > 0) return clampWidth(localWidth);
if (block.props.width && Number(block.props.width) > 0) {
return clampWidth(Number(block.props.width));
}
return 0;
}, [block.props.width, canResize, localWidth]);
const handleResizeStart = (event: ReactMouseEvent<HTMLSpanElement>, side: "left" | "right") => {
if (!canResize) return;
event.preventDefault();
event.stopPropagation();
const canvasWidth = resolvedWidth || mediaRef.current?.offsetWidth || 0;
if (!canvasWidth) {
return;
}
setDragging({
side,
startX: event.clientX,
startWidth: canvasWidth,
});
};
useEffect(() => {
if (!dragging) {
return undefined;
}
const handleMove = (event: MouseEvent) => {
event.preventDefault();
const delta = event.clientX - dragging.startX;
const adjusted = dragging.side === "left" ? -delta : delta;
const next = clampWidth(dragging.startWidth + adjusted);
setLocalWidth(next);
};
const handleUp = () => {
const finalWidth = latestWidthRef.current > 0 ? latestWidthRef.current : dragging.startWidth;
editor.updateBlock(block, { props: { width: clampWidth(finalWidth) } });
setDragging(null);
};
window.addEventListener("mousemove", handleMove);
window.addEventListener("mouseup", handleUp);
return () => {
window.removeEventListener("mousemove", handleMove);
window.removeEventListener("mouseup", handleUp);
};
}, [dragging, editor, block]);
const handleLink = () => {
const next = window.prompt(`输入${typeLabel}跳转链接`, block.props.linkUrl ?? "");
if (next === null) return;
editor.updateBlock(block, { props: { linkUrl: next.trim() } });
};
const viewOriginal = () => {
if (!fileUrl) return;
window.open(fileUrl, "_blank", "noopener,noreferrer");
};
const downloadAsset = () => {
if (!fileUrl) return;
const anchor = document.createElement("a");
anchor.href = fileUrl;
anchor.download = block.props.fileName || block.props.caption || typeLabel;
anchor.click();
};
const triggerOcr = async () => {
if (!block.props.assetId) {
window.alert("请先上传图片后再执行 OCR");
return;
}
setBusy(true);
try {
const response = await fetch("/api/media/ocr", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ assetId: block.props.assetId }),
});
if (!response.ok) {
const payload = await response.json().catch(() => null);
throw new Error(payload?.error ?? "触发 OCR 失败");
}
editor.updateBlock(block, { props: { ocrStatus: "processing" } });
window.alert("已提交 OCR 任务,稍后可在搜索面板中通过 OCR 结果搜索。");
} catch (error) {
window.alert((error as Error).message);
} finally {
setBusy(false);
}
};
if (!fileUrl) {
return (
<div className="wolai-media wolai-media--empty">
<Button type="button" variant="outline" onClick={handleChoose} className="gap-2">
<ImageIcon className="h-4 w-4" />
{typeLabel}
</Button>
<p className="text-xs text-gray-500"></p>
</div>
);
}
const renderPreviewContent = () => {
if (assetType === "video") {
return (
<video
controls
className="max-h-[420px] w-full rounded-2xl bg-black"
poster={block.props.thumbnailUrl || undefined}
style={resolvedWidth ? { width: `${resolvedWidth}px` } : undefined}
>
<source src={fileUrl} type={block.props.mimeType || "video/mp4"} />
</video>
);
}
if (assetType === "audio") {
return (
<div className="rounded-2xl border border-gray-200 bg-white/80 p-5">
<audio controls className="w-full">
<source src={fileUrl} type={block.props.mimeType || "audio/mpeg"} />
</audio>
<p className="mt-2 text-sm text-gray-500">{displayFileName}</p>
</div>
);
}
if (assetType === "file") {
return (
<div className="flex items-center gap-3 rounded-2xl border border-gray-200 bg-white p-4 shadow-sm">
<span className="rounded-full bg-[#2563eb]/10 p-3 text-[#2563eb]">
<Paperclip className="h-5 w-5" />
</span>
<div className="flex-1">
<p className="text-sm font-medium text-gray-800">{displayFileName}</p>
{block.props.fileSize ? (
<p className="text-xs text-gray-500">{formatFileSize(block.props.fileSize)}</p>
) : null}
</div>
</div>
);
}
const inlineStyle = resolvedWidth ? { width: `${resolvedWidth}px` } : undefined;
return <img src={block.props.thumbnailUrl || fileUrl} alt={block.props.caption || typeLabel} style={inlineStyle} />;
};
const figure = (
<figure
className={cn(
"wolai-media__figure",
canToggleBorder && block.props.hasBorder && "wolai-media__figure--border",
canAlign && block.props.captionAlign === "center" && "wolai-media__figure--center",
canAlign && block.props.captionAlign === "right" && "wolai-media__figure--right",
)}
>
<div className="wolai-media__preview">{renderPreviewContent()}</div>
{shouldShowCaption && (
<figcaption>
<input
ref={captionRef}
value={block.props.caption ?? ""}
onChange={(event) => handleCaptionChange(event.target.value)}
onBlur={() => setCaptionEditing(false)}
placeholder={assetType === "file" ? "添加文件说明..." : "添加说明..."}
className="w-full border-none bg-transparent text-sm text-[#475569] outline-none"
/>
</figcaption>
)}
</figure>
);
type QuickAction = { key: string; label: string; icon: JSX.Element; onClick: () => void };
const quickActions: QuickAction[] = [
{
key: "replace",
label: `替换${typeLabel}`,
icon: <RefreshCcw className="h-4 w-4" />,
onClick: handleChoose,
},
canToggleBorder
? {
key: "border",
label: block.props.hasBorder ? "取消边框" : "显示边框",
icon: <ImageIcon className="h-4 w-4" />,
onClick: toggleBorder,
}
: null,
!shouldShowCaption
? {
key: "caption",
label: "添加说明",
icon: <Type className="h-4 w-4" />,
onClick: enableCaptionEdit,
}
: null,
{
key: "link",
label: block.props.linkUrl ? "编辑链接" : "添加链接",
icon: <LinkIcon className="h-4 w-4" />,
onClick: handleLink,
},
{
key: "download",
label: `下载${typeLabel}`,
icon: <Download className="h-4 w-4" />,
onClick: downloadAsset,
},
].filter((action): action is QuickAction => Boolean(action));
const dropdownLinkLabel = block.props.linkUrl ? "编辑链接" : "添加链接";
return (
<div className="wolai-media" ref={mediaRef}>
<div className="wolai-media__canvas" style={resolvedWidth ? { width: `${resolvedWidth}px` } : undefined}>
{block.props.linkUrl ? (
<a href={block.props.linkUrl} target="_blank" rel="noopener noreferrer">
{figure}
</a>
) : (
figure
)}
<div className="wolai-media__quickbar">
{quickActions.map((action) => (
<button
key={action.key}
type="button"
className="wolai-media__quickbutton"
onClick={action.onClick}
title={action.label}
aria-label={action.label}
>
{action.icon}
</button>
))}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button type="button" className="wolai-media__quickbutton" aria-label="更多操作">
<MoreHorizontal className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuItem onClick={handleChoose}></DropdownMenuItem>
{!shouldShowCaption && (
<DropdownMenuItem onClick={enableCaptionEdit}></DropdownMenuItem>
)}
{canToggleBorder && (
<DropdownMenuItem onClick={toggleBorder}>
{block.props.hasBorder ? "取消边框" : "显示边框"}
</DropdownMenuItem>
)}
{canAlign && (
<>
<DropdownMenuLabel className="text-xs text-gray-400"></DropdownMenuLabel>
<DropdownMenuItem onClick={() => setAlign("left")}></DropdownMenuItem>
<DropdownMenuItem onClick={() => setAlign("center")}></DropdownMenuItem>
<DropdownMenuItem onClick={() => setAlign("right")}></DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem onClick={handleLink}>{dropdownLinkLabel}</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleCopyLink(fileUrl)}></DropdownMenuItem>
<DropdownMenuItem onClick={viewOriginal}></DropdownMenuItem>
<DropdownMenuItem onClick={downloadAsset}></DropdownMenuItem>
{canTriggerOcr && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem disabled={busy} onClick={triggerOcr}>
{busy ? "OCR 进行中..." : "触发 OCR 识别"}
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
{canResize && (
<>
<ResizeHandle side="left" dragging={Boolean(dragging)} onMouseDown={(event) => handleResizeStart(event, "left")} />
<ResizeHandle side="right" dragging={Boolean(dragging)} onMouseDown={(event) => handleResizeStart(event, "right")} />
</>
)}
</div>
<div className="wolai-media__hint">{block.props.ocrStatus === "processing" ? "OCR 处理中..." : ""}</div>
</div>
);
};
export const mediaBlock = createReactBlockSpec(
{
type: "media",
propSchema: {
fileUrl: { default: "", type: "string" },
thumbnailUrl: { default: "", type: "string" },
caption: { default: "", type: "string" },
captionAlign: { default: "left", values: ["left", "center", "right"] as MediaAlign[] },
hasBorder: { default: true, type: "boolean" },
linkUrl: { default: "", type: "string" },
assetId: { default: "", type: "string" },
assetType: { default: "image", type: "string" },
fileName: { default: "", type: "string" },
fileSize: { default: 0, type: "number" },
mimeType: { default: "", type: "string" },
width: { default: 0, type: "number" },
ocrStatus: { default: "idle", type: "string" },
},
content: "none",
},
{
render: (props) => <MediaBlockContent {...props} />,
},
)();
const handleCopyLink = async (targetUrl: string | null) => {
if (!targetUrl) return;
try {
if (navigator?.clipboard?.writeText) {
await navigator.clipboard.writeText(targetUrl);
window.alert("链接已复制");
} else {
throw new Error("no clipboard");
}
} catch {
window.prompt("请复制以下链接", targetUrl);
}
};
const ResizeHandle = ({
side,
onMouseDown,
dragging,
}: {
side: "left" | "right";
dragging: boolean;
onMouseDown: (event: ReactMouseEvent<HTMLSpanElement>) => void;
}) => (
<span
role="separator"
tabIndex={0}
aria-orientation="horizontal"
onMouseDown={onMouseDown}
className={cn("wolai-media__resize-handle", `wolai-media__resize-handle--${side}`, dragging && "is-dragging")}
/>
);
const clampWidth = (value: number) => {
const min = 240;
const max = 960;
if (Number.isNaN(value)) return min;
return Math.max(min, Math.min(max, value));
};
@@ -0,0 +1,114 @@
"use client";
import { useEffect, useState } from "react";
import { createReactBlockSpec } from "@blocknote/react";
import { RiFileTextFill } from "react-icons/ri";
import { useRouter } from "next/navigation";
import { supabaseBrowser } from "@/lib/supabase/client";
const normalizeTitle = (value?: string | null) => {
if (!value || !value.trim()) {
return "未命名页面";
}
return value;
};
const PageReferenceContent = ({ pageId, title }: { pageId: string; title: string }) => {
const router = useRouter();
const fallbackTitle = normalizeTitle(title);
const [resolvedTitle, setResolvedTitle] = useState(fallbackTitle);
useEffect(() => {
setResolvedTitle(fallbackTitle);
}, [fallbackTitle]);
useEffect(() => {
if (!pageId) {
return;
}
let cancelled = false;
const applyTitle = (nextTitle?: string | null) => {
if (!cancelled) {
setResolvedTitle(normalizeTitle(nextTitle));
}
};
const fetchTitle = async () => {
try {
const { data } = await supabaseBrowser
.from("documents")
.select("title")
.eq("id", pageId)
.single();
if (data) {
applyTitle(data.title);
}
} catch {
// ignore fetch errors,等待后续订阅同步
}
};
void fetchTitle();
const channel = supabaseBrowser
.channel(`page-ref-${pageId}`)
.on(
"postgres_changes",
{ event: "UPDATE", schema: "public", table: "documents", filter: `id=eq.${pageId}` },
(payload) => {
const nextTitle = (payload.new as { title?: string } | null)?.title ?? null;
applyTitle(nextTitle);
},
)
.subscribe();
return () => {
cancelled = true;
supabaseBrowser.removeChannel(channel);
};
}, [pageId]);
const navigate = () => {
if (pageId) {
router.push(`/documents/${pageId}`);
}
};
return (
<div
role="button"
tabIndex={0}
onClick={navigate}
onKeyDown={(event) => {
if ((event.key === "Enter" || event.key === " ") && pageId) {
event.preventDefault();
navigate();
}
}}
className="group mt-2 flex items-center gap-3 rounded-[4px] px-4 py-3 text-[#2563eb] hover:bg-[#f5f5f5]"
style={{ fontFamily: "Inter, system-ui, sans-serif" }}
>
<RiFileTextFill className="text-xl" aria-hidden />
<span className="text-base font-medium leading-none">{resolvedTitle}</span>
<span className="ml-auto text-sm opacity-0 transition-opacity duration-150 group-hover:opacity-100">
</span>
</div>
);
};
export const pageReferenceBlock = createReactBlockSpec(
{
type: "pageReference",
propSchema: {
pageId: { default: "" },
title: { default: "未命名页面" },
},
content: "none",
},
() => ({
render: ({ block }) => (
<PageReferenceContent pageId={block.props.pageId} title={block.props.title} />
),
}),
)();
@@ -0,0 +1,50 @@
"use client";
import { createReactBlockSpec } from "@blocknote/react";
export const progressBlock = createReactBlockSpec(
{
type: "progressMeter",
propSchema: {
percent: { default: 0, type: "number" },
auto: { default: true, type: "boolean" },
summary: { default: "", type: "string" },
},
content: "inline",
},
{
render: ({ block, editor }) => {
const percent = block.props.percent ?? 0;
const handleToggle = () => {
editor.updateBlock(block, { props: { auto: !block.props.auto } });
};
const handleBarClick = () => {
if (block.props.auto) return;
const input = window.prompt("设置进度(0-100", percent.toString());
if (!input) return;
const value = Number.parseInt(input, 10);
if (Number.isNaN(value)) return;
editor.updateBlock(block, {
props: { percent: Math.min(100, Math.max(0, value)) },
});
};
return (
<div className="wolai-progress">
<div className="wolai-progress__header">
<span className="wolai-progress__summary">{block.props.summary || "暂无条目"}</span>
<button type="button" className="wolai-progress__mode" onClick={handleToggle}>
{block.props.auto ? "自动" : "手动"}
</button>
</div>
<div className="wolai-progress__bar" onClick={handleBarClick}>
<div className="wolai-progress__fill" style={{ width: `${percent}%` }} />
</div>
<span className="wolai-progress__percent">{percent}%</span>
<div className="wolai-progress__description" />
</div>
);
},
},
)();
@@ -0,0 +1,259 @@
"use client";
import dynamic from "next/dynamic";
import { useCallback, useEffect, useMemo, useState, type ChangeEvent, type KeyboardEvent } from "react";
import type { DocumentStats, PageOptionsState } from "@/types/page-options";
import { PageOptionsSidebar } from "@/components/editor/page-options-sidebar";
import { PageBacklinksPanel } from "@/components/editor/page-backlinks-panel";
import { useEditorBridgeStore } from "@/store/editor-bridge";
import { usePageLayoutStore } from "@/store/page-layout";
import type { Json } from "@/types/supabase";
import { DocumentHistoryDrawer } from "@/components/editor/document-history-drawer";
import type { DocumentSnapshot } from "@/types/document";
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
import { ImagePickerProvider } from "@/components/media/image-picker-context";
const BlockNoteEditor = dynamic(
() => import("@/components/editor/blocknote-editor").then((mod) => mod.BlockNoteEditor),
{
ssr: false,
loading: () => (
<div className="flex h-64 items-center justify-center text-sm text-gray-400">...</div>
),
},
);
export interface DocumentContentProps {
documentId: string;
workspaceId: string;
title: string | null;
updatedAt: string | null;
initialContent: unknown;
initialOptions: PageOptionsState;
initialStats: DocumentStats | null;
}
const defaultOptions: PageOptionsState = {
wideLayout: false,
smallText: false,
showHeadingNumbers: true,
showToc: false,
showStructure: false,
protectEditing: false,
showWordCount: true,
};
const defaultStats: DocumentStats = { wordCount: 0, characterCount: 0, blockCount: 0 };
export function DocumentContent({
documentId,
workspaceId,
title,
updatedAt,
initialContent,
initialOptions,
initialStats,
}: DocumentContentProps) {
const [options, setOptions] = useState<PageOptionsState>(initialOptions ?? defaultOptions);
const [stats, setStats] = useState<DocumentStats>(initialStats ?? defaultStats);
const [history, setHistory] = useState<DocumentSnapshot[]>([]);
const [historyOpen, setHistoryOpen] = useState(false);
const editorBridge = useEditorBridgeStore((state) => state.bridge);
const showInspector = usePageLayoutStore((state) => state.showInspector);
const [pageTitle, setPageTitle] = useState(title ?? "无标题");
/* eslint-disable react-hooks/set-state-in-effect */
useEffect(() => {
setPageTitle(title ?? "无标题");
}, [title]);
useEffect(() => {
setOptions(initialOptions ?? defaultOptions);
}, [initialOptions]);
useEffect(() => {
setStats(initialStats ?? defaultStats);
}, [initialStats]);
/* eslint-enable react-hooks/set-state-in-effect */
const persistTitle = useCallback(
async (nextTitle: string) => {
const payload = nextTitle.trim() || "无标题";
await fetch("/api/documents/title", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ documentId, title: payload }),
});
},
[documentId],
);
const debouncedPersistTitle = useDebouncedCallback((value: string) => {
void persistTitle(value);
}, 600);
const handleTitleChange = (event: ChangeEvent<HTMLInputElement>) => {
const value = event.target.value;
setPageTitle(value);
debouncedPersistTitle(value);
};
const handleTitleBlur = () => {
void persistTitle(pageTitle);
};
const handleTitleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === "Enter") {
event.preventDefault();
event.currentTarget.blur();
}
};
const persistOptions = useCallback(
async (patch: Partial<PageOptionsState>) => {
try {
const response = await fetch("/api/documents/options", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ documentId, options: patch }),
});
if (!response.ok) {
const payload = await response.json().catch(() => null);
console.error(payload?.error ?? "更新页面选项失败");
}
} catch (error) {
console.error(error);
}
},
[documentId],
);
const toggleOption = (key: keyof PageOptionsState) => {
setOptions((prev) => {
const nextValue = !prev[key];
const next = { ...prev, [key]: nextValue };
void persistOptions({ [key]: nextValue } as Partial<PageOptionsState>);
return next;
});
};
const formattedUpdatedAt = useMemo(() => {
if (!updatedAt) return "";
return new Date(updatedAt).toLocaleString();
}, [updatedAt]);
const handleExport = useCallback(() => {
const latest = history[0];
if (!latest) {
window.alert("暂无可导出的内容");
return;
}
const payload = JSON.stringify(latest.blocks, null, 2);
const blob = new Blob([payload], { type: "application/json" });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = `${title ?? "未命名页面"}-${new Date(latest.timestamp).toISOString()}.json`;
anchor.click();
URL.revokeObjectURL(url);
}, [history, title]);
const handleSnapshot = useCallback((payload: { blocks: Json; stats: DocumentStats }) => {
setHistory((prev) => {
const now = Date.now();
if (prev.length > 0 && now - prev[0].timestamp < 4000) {
return prev;
}
const snapshot: DocumentSnapshot = {
id: `${now}-${Math.random().toString(36).slice(2, 8)}`,
timestamp: now,
blocks: payload.blocks,
stats: payload.stats,
};
return [snapshot, ...prev].slice(0, 15);
});
}, []);
const persistStatsRequest = useCallback((next: DocumentStats) => {
void fetch("/api/documents/stats", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ documentId, stats: next }),
}).catch((error) => console.error(error));
}, [documentId]);
const persistStats = useDebouncedCallback(persistStatsRequest, 1500);
const handleStatsChange = useCallback(
(nextStats: DocumentStats) => {
setStats(nextStats);
persistStats(nextStats);
},
[persistStats],
);
const handleRestoreSnapshot = useCallback(
(snapshot: DocumentSnapshot) => {
if (!editorBridge) {
window.alert("编辑器尚未准备好,无法恢复历史版本");
return;
}
editorBridge.replaceWithSnapshot(snapshot.blocks);
setHistoryOpen(false);
},
[editorBridge],
);
return (
<ImagePickerProvider documentId={documentId} workspaceId={workspaceId}>
<div className="flex h-full overflow-hidden bg-white">
<div className="flex h-full flex-1 flex-col overflow-hidden">
<div className="border-b border-[#f5f5f5] px-12 pb-6 pt-8">
<div className="relative">
<input
value={pageTitle}
onChange={handleTitleChange}
onBlur={handleTitleBlur}
onKeyDown={handleTitleKeyDown}
placeholder="无标题"
className="w-full border-none bg-transparent text-3xl font-semibold text-[#333333] outline-none focus:ring-0"
aria-label="页面标题"
disabled={options.protectEditing}
/>
</div>
{options.protectEditing && (
<p className="mt-1 text-sm text-[#b91c1c]"></p>
)}
<p className="text-sm text-gray-400">{formattedUpdatedAt}</p>
</div>
<div className="flex-1 overflow-y-auto px-12 py-6">
<BlockNoteEditor
documentId={documentId}
workspaceId={workspaceId}
initialContent={initialContent}
pageOptions={options}
onStatsChange={handleStatsChange}
onSnapshot={handleSnapshot}
/>
<PageBacklinksPanel className="mt-10" workspaceId={workspaceId} documentId={documentId} />
</div>
</div>
{showInspector && (
<PageOptionsSidebar
documentId={documentId}
options={options}
stats={stats}
onToggle={toggleOption}
onExport={handleExport}
onOpenHistory={() => setHistoryOpen(true)}
/>
)}
</div>
<DocumentHistoryDrawer
open={historyOpen}
onOpenChange={setHistoryOpen}
history={history}
onRestore={handleRestoreSnapshot}
/>
</ImagePickerProvider>
);
}
@@ -0,0 +1,51 @@
"use client";
import { Drawer, DrawerContent, DrawerDescription, DrawerHeader, DrawerTitle } from "@/components/ui/drawer";
import { Button } from "@/components/ui/button";
import type { DocumentSnapshot } from "@/types/document";
interface DocumentHistoryDrawerProps {
open: boolean;
onOpenChange: (open: boolean) => void;
history: DocumentSnapshot[];
onRestore: (snapshot: DocumentSnapshot) => void;
}
export function DocumentHistoryDrawer({ open, onOpenChange, history, onRestore }: DocumentHistoryDrawerProps) {
return (
<Drawer open={open} onOpenChange={onOpenChange}>
<DrawerContent className="max-h-[90vh]">
<DrawerHeader className="text-left">
<DrawerTitle></DrawerTitle>
<DrawerDescription> 15 </DrawerDescription>
</DrawerHeader>
<div className="space-y-3 px-4 pb-6">
{history.length === 0 ? (
<div className="rounded-lg border border-dashed border-[#e2e8f0] px-4 py-10 text-center text-sm text-gray-500">
</div>
) : (
history.map((snapshot) => (
<div
key={snapshot.id}
className="flex items-center justify-between rounded-lg border border-[#e2e8f0] bg-white px-4 py-3 text-sm"
>
<div>
<div className="font-medium text-gray-800">
{new Date(snapshot.timestamp).toLocaleString()}
</div>
<div className="text-xs text-gray-500">
{snapshot.stats.wordCount} · {snapshot.stats.characterCount}
</div>
</div>
<Button variant="outline" size="sm" onClick={() => onRestore(snapshot)}>
</Button>
</div>
))
)}
</div>
</DrawerContent>
</Drawer>
);
}
@@ -0,0 +1,37 @@
"use client";
import dynamic from "next/dynamic";
import { useEffect, useState } from "react";
import type { DocumentContentProps } from "@/components/editor/document-content";
const DocumentContent = dynamic(
() => import("@/components/editor/document-content").then((mod) => mod.DocumentContent),
{
ssr: false,
loading: () => (
<div className="flex h-full flex-col items-center justify-center gap-2 text-sm text-gray-500">
...
</div>
),
},
);
export function DocumentShell(props: DocumentContentProps) {
const [mounted, setMounted] = useState(false);
/* eslint-disable react-hooks/set-state-in-effect */
useEffect(() => {
setMounted(true);
}, []);
/* eslint-enable react-hooks/set-state-in-effect */
if (!mounted) {
return (
<div className="flex h-full flex-col items-center justify-center gap-2 text-sm text-gray-500">
...
</div>
);
}
return <DocumentContent {...props} />;
}
@@ -0,0 +1,48 @@
"use client";
import { cn } from "@/lib/utils";
export interface TocEntry {
id: string;
title: string;
level: number;
numbering: string;
}
interface DocumentTocProps {
entries: TocEntry[];
visible: boolean;
onJump: (id: string) => void;
}
export function DocumentToc({ entries, visible, onJump }: DocumentTocProps) {
if (!visible || entries.length === 0) {
return null;
}
return (
<div className="pointer-events-none absolute right-0 top-0 z-10 hidden lg:block">
<div className="pointer-events-auto mt-2 w-48 max-h-[65vh] overflow-y-auto rounded-2xl border border-[#e4e4e7] bg-white/90 p-3 text-xs text-gray-600 shadow-lg backdrop-blur">
<div className="mb-2 text-[11px] font-semibold text-gray-400"></div>
<ul className="space-y-1">
{entries.map((entry) => (
<li key={entry.id}>
<button
type="button"
className={cn(
"w-full rounded-md px-2 py-1 text-left text-[11px] text-gray-500 transition-colors hover:bg-[#eef2ff] hover:text-[#2563eb]",
entry.level > 1 && "pl-4",
entry.level > 2 && "pl-6",
)}
onClick={() => onJump(entry.id)}
>
<span className="mr-2 font-mono text-[10px] text-gray-400">{entry.numbering}</span>
{entry.title || "未命名"}
</button>
</li>
))}
</ul>
</div>
</div>
);
}
@@ -0,0 +1,335 @@
'use client';
import { useCallback, useMemo } from "react";
import type { Block, PartialBlock } from "@blocknote/core";
import {
BlockColorsItem,
SideMenu,
TableColumnHeaderItem,
TableRowHeaderItem,
useBlockNoteEditor,
useComponentsContext,
type DragHandleMenuProps,
type SideMenuProps,
} from "@blocknote/react";
import { useRouter } from "next/navigation";
import type { CustomBlockSchema } from "../schema";
type InlineNode = { text?: unknown };
type TableMenuBlock = Parameters<
typeof TableRowHeaderItem
>[0]["block"];
type DraftBlock = PartialBlock<CustomBlockSchema> & { id?: string };
type ConvertOption = {
label: string;
type?: Block<CustomBlockSchema>["type"];
props?: Record<string, unknown>;
shortcut?: string;
action?: () => void;
};
type CustomDragProps = DragHandleMenuProps<CustomBlockSchema> & {
currentDocumentId: string;
};
const extractText = (block: Block<CustomBlockSchema>) => {
const inlineNodes = block.content as InlineNode[] | undefined;
const maybeText = inlineNodes?.[0]?.text;
if (typeof maybeText === "string" && maybeText.trim().length > 0) {
return maybeText.trim();
}
return "未命名页面";
};
const CustomDragHandleMenu = ({ block, currentDocumentId }: CustomDragProps) => {
const Components = useComponentsContext()!;
const editor = useBlockNoteEditor<CustomBlockSchema>();
const router = useRouter();
const duplicateBlock = useCallback(() => {
const blockWithoutId: DraftBlock = { ...block };
delete blockWithoutId.id;
editor.insertBlocks([blockWithoutId], block, "after");
}, [block, editor]);
const removePageReference = useCallback(async () => {
if (block.type === "pageReference") {
const pageId = block.props.pageId;
if (pageId) {
await fetch("/api/documents/delete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ documentId: pageId }),
});
}
}
editor.removeBlocks([block.id]);
router.refresh();
}, [block, editor, router]);
const handleDeleteBlock = useCallback(() => {
if (block.type === "pageReference") {
void removePageReference();
return;
}
editor.removeBlocks([block.id]);
}, [block, editor, removePageReference]);
const turnToPage = useCallback(async () => {
const response = await fetch("/api/documents/create-child", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
parentId: currentDocumentId,
title: extractText(block),
blocks: [block],
}),
});
if (!response.ok) {
return;
}
const { pageId, title } = await response.json();
editor.replaceBlocks(
[block.id],
[
{
type: "pageReference",
props: { pageId, title },
} as PartialBlock<CustomBlockSchema>,
],
);
router.refresh();
}, [block, currentDocumentId, editor, router]);
const moveOrEmbedBlock = useCallback(async () => {
if (typeof window === "undefined") {
return;
}
const targetParent = window.prompt("输入目标页面 ID(将在该页面末尾插入新子页面)", currentDocumentId);
if (!targetParent) {
return;
}
const response = await fetch("/api/documents/create-child", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
parentId: targetParent.trim(),
title: extractText(block),
blocks: [block],
}),
});
if (!response.ok) {
window.alert("移动失败,请确认页面 ID");
return;
}
const { pageId, title } = await response.json();
editor.replaceBlocks(
[block.id],
[
{
type: "pageReference",
props: { pageId, title },
} as PartialBlock<CustomBlockSchema>,
],
);
router.refresh();
}, [block, currentDocumentId, editor, router]);
const convertOptions = useMemo<ConvertOption[]>(
() => [
{ label: "文本", type: "paragraph", shortcut: "Ctrl+Alt+0" },
{ label: "待办列表", type: "checkListItem", shortcut: "Ctrl+Shift+5" },
{ label: "高级待办列表", type: "advancedTodo" },
{ label: "主标题", type: "heading", props: { level: 1 }, shortcut: "Ctrl+Shift+1" },
{ label: "大标题", type: "heading", props: { level: 2 }, shortcut: "Ctrl+Shift+2" },
{ label: "中标题", type: "heading", props: { level: 3 }, shortcut: "Ctrl+Shift+3" },
{ label: "小标题", type: "heading", props: { level: 4 }, shortcut: "Ctrl+Shift+4" },
{ label: "页面", action: turnToPage },
{ label: "列表", type: "bulletListItem", shortcut: "Ctrl+Shift+6" },
{ label: "数字列表", type: "numberedListItem", shortcut: "Ctrl+Shift+7" },
{ label: "折叠列表", type: "toggleListItem", shortcut: "Ctrl+Shift+8" },
{ label: "折叠标题", type: "heading", props: { level: 2, isToggleable: true } },
{ label: "引述文字", type: "blockquote" },
{ label: "代码片段", type: "codeBlock" },
],
[turnToPage],
);
const convertBlock = useCallback(
(option: ConvertOption) => {
if (option.action) {
option.action();
return;
}
if (!option.type) return;
editor.updateBlock(block, {
type: option.type,
props: option.props ?? {},
});
},
[block, editor],
);
const copyBlockLink = useCallback(async () => {
if (typeof window === "undefined") {
return;
}
const url = `${window.location.origin}/documents/${currentDocumentId}#block-${block.id}`;
try {
await navigator.clipboard.writeText(url);
window.alert("块链接已复制");
} catch {
window.prompt("复制失败,请手动复制", url);
}
}, [block.id, currentDocumentId]);
const openOnRight = useCallback(() => {
if (typeof window === "undefined") {
return;
}
const url = `${window.location.origin}/documents/${currentDocumentId}?preview=sidebar&focus=${block.id}`;
window.open(url, "_blank", "noopener,noreferrer");
}, [block.id, currentDocumentId]);
const setAdvancedTodoStatus = useCallback(
(status: "todo" | "doing" | "done" | "cancelled") => {
if (block.type !== "advancedTodo") return;
editor.updateBlock(block, {
props: { status },
});
},
[block, editor],
);
const toggleProgressMode = useCallback(() => {
if (block.type !== "progressMeter") return;
editor.updateBlock(block, {
props: { auto: !block.props.auto },
});
}, [block, editor]);
const setManualProgress = useCallback(() => {
if (block.type !== "progressMeter") return;
const value = Number.parseInt(window.prompt("手动设置进度(0-100", String(block.props.percent ?? 0)) ?? "", 10);
if (Number.isNaN(value)) return;
const clamped = Math.min(100, Math.max(0, value));
editor.updateBlock(block, {
props: { percent: clamped },
});
}, [block, editor]);
return (
<Components.Generic.Menu.Dropdown className="bn-menu-dropdown bn-drag-handle-menu">
<Components.Generic.Menu.Item className="bn-menu-item" onClick={openOnRight}>
</Components.Generic.Menu.Item>
<Components.Generic.Menu.Root sub>
<Components.Generic.Menu.Trigger sub>
<Components.Generic.Menu.Item className="bn-menu-item" subTrigger>
</Components.Generic.Menu.Item>
</Components.Generic.Menu.Trigger>
<Components.Generic.Menu.Dropdown sub className="bn-menu-dropdown">
{convertOptions.map((option) => (
<Components.Generic.Menu.Item
key={option.label}
className="bn-menu-item flex items-center justify-between gap-4"
onClick={() => convertBlock(option)}
>
<span>{option.label}</span>
{option.shortcut && <span className="text-[10px] text-gray-400">{option.shortcut}</span>}
</Components.Generic.Menu.Item>
))}
</Components.Generic.Menu.Dropdown>
</Components.Generic.Menu.Root>
<Components.Generic.Menu.Item className="bn-menu-item" onClick={duplicateBlock}>
</Components.Generic.Menu.Item>
<Components.Generic.Menu.Item className="bn-menu-item" onClick={copyBlockLink}>
</Components.Generic.Menu.Item>
<Components.Generic.Menu.Item className="bn-menu-item" onClick={moveOrEmbedBlock}>
/...
</Components.Generic.Menu.Item>
<Components.Generic.Menu.Item
className="bn-menu-item"
onClick={() => window.alert("块历史功能开发中,敬请期待")}
>
...
</Components.Generic.Menu.Item>
<Components.Generic.Menu.Item
className="bn-menu-item"
onClick={() => window.alert("评论功能暂未开放")}
>
</Components.Generic.Menu.Item>
<Components.Generic.Menu.Item className="bn-menu-item" onClick={handleDeleteBlock}>
</Components.Generic.Menu.Item>
<BlockColorsItem block={block}></BlockColorsItem>
<TableRowHeaderItem block={block as TableMenuBlock}></TableRowHeaderItem>
<TableColumnHeaderItem block={block as TableMenuBlock}></TableColumnHeaderItem>
{block.type === "advancedTodo" && (
<>
<Components.Generic.Menu.Divider className="bn-menu-divider" />
{[
{ label: "设为未开始", status: "todo" as const },
{ label: "设为进行中", status: "doing" as const },
{ label: "设为已完成", status: "done" as const },
{ label: "设为取消", status: "cancelled" as const },
].map((item) => (
<Components.Generic.Menu.Item
key={item.status}
className="bn-menu-item"
onClick={() => setAdvancedTodoStatus(item.status)}
>
{item.label}
</Components.Generic.Menu.Item>
))}
</>
)}
{block.type === "progressMeter" && (
<>
<Components.Generic.Menu.Divider className="bn-menu-divider" />
<Components.Generic.Menu.Item className="bn-menu-item" onClick={toggleProgressMode}>
{block.props.auto ? "切换为手动进度" : "切换为自动进度"}
</Components.Generic.Menu.Item>
{!block.props.auto && (
<Components.Generic.Menu.Item className="bn-menu-item" onClick={setManualProgress}>
</Components.Generic.Menu.Item>
)}
</>
)}
</Components.Generic.Menu.Dropdown>
);
};
type CustomSideMenuProps = SideMenuProps<CustomBlockSchema> & {
currentDocumentId: string;
};
export const CustomSideMenu = (props: CustomSideMenuProps) => (
<SideMenu
{...props}
dragHandleMenu={(dragProps) => (
<CustomDragHandleMenu
{...(dragProps as DragHandleMenuProps<CustomBlockSchema>)}
currentDocumentId={props.currentDocumentId}
/>
)}
/>
);
@@ -0,0 +1,343 @@
"use client";
import { useCallback, useMemo } from "react";
import type { JSX } from "react";
import {
SuggestionMenuController,
getDefaultReactSlashMenuItems,
type DefaultReactSuggestionItem,
} from "@blocknote/react";
import { filterSuggestionItems, type BlockNoteEditor } from "@blocknote/core";
import { useRouter } from "next/navigation";
import {
FileImage,
FilePlus2,
FileVideo,
ListTree,
Music,
Paperclip,
PilcrowSquare,
Play,
Sparkles,
SquareCheckBig,
} from "lucide-react";
import type { CustomBlockSchema } from "../schema";
import { useImagePicker } from "@/components/media/image-picker-context";
import type { MediaKind, MediaSelection } from "@/types/media";
type Props = {
editor: BlockNoteEditor<CustomBlockSchema>;
currentDocumentId: string;
};
const matchKeywords = (query: string, aliases: string[]) => {
const lower = query.trim().toLowerCase();
if (!lower) return true;
return aliases.some((alias) => alias.toLowerCase().includes(lower));
};
const GROUP_TRANSLATIONS: Record<string, string> = {
"Headings": "标题",
"Subheadings": "副标题",
"Basic blocks": "基础块",
"Advanced": "高级",
"Media": "媒体",
"Others": "其他",
};
const DEFAULT_ITEM_TRANSLATIONS: Record<
string,
{ title?: string; subtext?: string; group?: string; aliases?: string[] }
> = {
"Paragraph": { title: "正文", group: "基础块", aliases: ["zw", "paragraph", "body"] },
"Heading 1": { title: "主标题", group: "标题", aliases: ["biaoti", "bt1"] },
"Heading 2": { title: "大标题", group: "标题", aliases: ["biaoti", "bt2"] },
"Heading 3": { title: "中标题", group: "标题", aliases: ["biaoti", "bt3"] },
"Heading 4": { title: "小标题", group: "标题", aliases: ["biaoti", "bt4"] },
"Heading 5": { title: "极小标题", group: "标题", aliases: ["biaoti", "bt5"] },
"Heading 6": { title: "最小标题", group: "标题", aliases: ["biaoti", "bt6"] },
"Toggle Heading 1": { title: "可折叠主标题", group: "标题", aliases: ["toggle", "zd1"] },
"Toggle Heading 2": { title: "可折叠大标题", group: "标题", aliases: ["toggle", "zd2"] },
"Toggle Heading 3": { title: "可折叠中标题", group: "标题", aliases: ["toggle", "zd3"] },
"Quote": { title: "引用", group: "基础块", aliases: ["quote", "引用"] },
"Toggle List": { title: "折叠列表", group: "基础块", aliases: ["toggle list", "zd"] },
"Numbered List": { title: "数字列表", group: "基础块", aliases: ["ordered", "ol"] },
"Bullet List": { title: "符号列表", group: "基础块", aliases: ["ul", "list"] },
"Check List": { title: "任务列表", group: "基础块", aliases: ["todo", "checkbox"] },
"Code Block": { title: "代码块", group: "基础块", aliases: ["code", "pre"] },
"Table": { title: "表格", group: "高级", aliases: ["table", "biaoge"] },
"Image": { title: "插入图片", group: "媒体", subtext: "上传或引用图片资源", aliases: ["tupian", "image", "tp"] },
"Video": { title: "插入视频", group: "媒体", subtext: "上传或引用视频", aliases: ["shipin", "video", "sp"] },
"Audio": { title: "插入音频", group: "媒体", subtext: "上传或引用音频", aliases: ["yinpin", "audio", "yp"] },
"File": { title: "插入文件", group: "媒体", subtext: "上传附件并生成卡片", aliases: ["wenjian", "file", "wj"] },
"Emoji": { title: "插入表情", group: "其他", aliases: ["emoji", "biaoqing"] },
"Divider": { title: "分割线", group: "基础块", aliases: ["divider", "hr"] },
"Page Break": { title: "分页符", group: "基础块", aliases: ["page", "break"] },
};
const MEDIA_ICONS: Record<MediaKind, JSX.Element> = {
image: <FileImage className="h-4 w-4 text-[#2563eb]" />,
video: <FileVideo className="h-4 w-4 text-[#f97316]" />,
audio: <Music className="h-4 w-4 text-[#10b981]" />,
file: <Paperclip className="h-4 w-4 text-[#0f172a]" />,
};
const HEADING_PRESETS = [
{
level: 1,
title: "主标题",
subtext: "适合页面名称/顶层章节",
aliases: ["biaoti1", "h1", "level1"],
},
{
level: 2,
title: "大标题",
subtext: "用于章节逻辑层",
aliases: ["biaoti2", "h2", "level2"],
},
{
level: 3,
title: "中标题",
subtext: "用于小节和段落",
aliases: ["biaoti3", "h3", "level3"],
},
{
level: 4,
title: "小标题",
subtext: "更细的结构说明",
aliases: ["biaoti4", "h4", "level4"],
},
{
level: 5,
title: "极小标题",
subtext: "适合脚注/补充说明",
aliases: ["biaoti5", "h5", "level5"],
},
];
const isHeadingDefaultItem = (item: DefaultReactSuggestionItem) => {
const maybeKey = (item as { key?: string }).key ?? "";
if (maybeKey && (maybeKey === "heading" || maybeKey.startsWith("heading_") || maybeKey.startsWith("toggle_heading"))) {
return true;
}
const title = item.title ?? "";
return title.includes("标题");
};
export const CustomSlashMenu = ({ editor, currentDocumentId }: Props) => {
const defaultItems = useMemo(() => getDefaultReactSlashMenuItems(editor), [editor]);
const router = useRouter();
const { openPicker } = useImagePicker();
const getItems = useCallback(
async (query: string) => {
const cursor = editor.getTextCursorPosition();
const referenceBlock = cursor?.block ?? editor.topLevelBlocks[0];
const createPageItem: DefaultReactSuggestionItem = {
title: "嵌入页面",
group: "嵌入",
aliases: ["page", "ym", "子页面", "嵌入页面块"],
icon: <FilePlus2 className="h-4 w-4 text-[#2563eb]" />,
onItemClick: async () => {
const response = await fetch("/api/documents/create-child", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
parentId: currentDocumentId,
title: cursor?.block?.content?.[0]?.text ?? "未命名页面",
blocks: cursor ? [cursor.block] : [],
}),
});
if (!response.ok) return;
const { pageId, title } = await response.json();
editor.insertBlocks(
[
{
type: "pageReference",
props: { pageId, title },
},
],
referenceBlock,
"after",
);
router.refresh();
},
};
const headingItems: DefaultReactSuggestionItem[] = HEADING_PRESETS.map((preset) => ({
title: preset.title,
group: "标题",
subtext: preset.subtext,
aliases: preset.aliases,
icon: <PilcrowSquare className="h-4 w-4 text-[#0f172a]" />,
onItemClick: () => {
editor.insertBlocks(
[
{
type: "heading",
props: { level: preset.level },
},
],
referenceBlock,
"after",
);
},
}));
const foldHeading: DefaultReactSuggestionItem = {
title: "折叠标题",
group: "标题",
aliases: ["toggle", "zd", "fold"],
icon: <ListTree className="h-4 w-4 text-[#0f172a]" />,
onItemClick: () => {
editor.insertBlocks(
[
{
type: "heading",
props: { level: 2, isToggleable: true },
},
],
referenceBlock,
"after",
);
},
};
const advancedTodo: DefaultReactSuggestionItem = {
title: "高级待办",
group: "待办",
subtext: "四态状态 · Alt 直接取消",
aliases: ["gjdblb", "todopro", "gaoji", "todo+"],
icon: <SquareCheckBig className="h-4 w-4 text-[#2563eb]" />,
onItemClick: () => {
editor.insertBlocks(
[
{
type: "advancedTodo",
props: { status: "todo" },
},
],
referenceBlock,
"after",
);
},
};
const progressMeter: DefaultReactSuggestionItem = {
title: "进度条",
group: "进度",
subtext: "自动读取下方待办完成度",
aliases: ["jdt", "progress", "jindu"],
icon: <Sparkles className="h-4 w-4 text-[#f59e0b]" />,
onItemClick: () => {
editor.insertBlocks(
[
{
type: "progressMeter",
props: { percent: 0, auto: true },
},
],
referenceBlock,
"after",
);
},
};
const foldAdvancedTodo: DefaultReactSuggestionItem = {
title: "折叠高级待办",
group: "待办",
aliases: ["zdgjdb", "foldtodo"],
icon: <Play className="h-4 w-4 text-[#9f1239]" />,
onItemClick: () => {
editor.insertBlocks(
[
{
type: "advancedTodo",
props: { status: "todo" },
},
],
referenceBlock,
"after",
);
},
};
const customItems = [
...headingItems,
foldHeading,
createPageItem,
advancedTodo,
foldAdvancedTodo,
progressMeter,
].filter((item) => matchKeywords(query, item.aliases ?? []));
const insertMediaSelection = (selection: MediaSelection) => {
editor.insertBlocks(
[
{
type: "media",
props: {
fileUrl: selection.fileUrl,
thumbnailUrl: selection.thumbnailUrl ?? selection.fileUrl,
assetId: selection.assetId,
assetType: selection.assetType ?? "image",
fileName: selection.fileName ?? "",
fileSize: selection.fileSize ?? null,
mimeType: selection.mimeType ?? "",
ocrStatus: "idle",
},
},
],
referenceBlock,
"after",
);
};
const handleMediaPick = (mediaType: MediaKind) => {
openPicker({
mediaType,
onSelect: (selection) => {
insertMediaSelection({
...selection,
assetType: selection.assetType ?? mediaType,
});
},
});
};
const localizedDefaults = defaultItems.map((item) => {
const translation = DEFAULT_ITEM_TRANSLATIONS[item.title];
const next: DefaultReactSuggestionItem = { ...item };
if (translation?.title) next.title = translation.title;
if (translation?.subtext) next.subtext = translation.subtext;
if (translation?.aliases) next.aliases = translation.aliases;
if (translation?.group) {
next.group = translation.group;
} else if (item.group && GROUP_TRANSLATIONS[item.group]) {
next.group = GROUP_TRANSLATIONS[item.group];
}
if (["Image", "Video", "Audio", "File"].includes(item.title)) {
const mediaType = item.title.toLowerCase() as MediaKind;
next.icon = MEDIA_ICONS[mediaType];
next.group = translation?.group ?? "媒体";
next.subtext = translation?.subtext ?? next.subtext;
next.aliases = translation?.aliases ?? next.aliases;
next.onItemClick = () => handleMediaPick(mediaType);
}
return next;
});
const sanitizedDefaults = localizedDefaults.filter(
(item) => !isHeadingDefaultItem(item) && item.title !== "表格",
);
const merged = [...customItems, ...sanitizedDefaults];
return filterSuggestionItems(merged, query);
},
[currentDocumentId, defaultItems, editor, openPicker, router],
);
return <SuggestionMenuController triggerCharacter="/" getItems={getItems} />;
}
@@ -0,0 +1,77 @@
"use client";
import { useMemo } from "react";
import { Button } from "@/components/ui/button";
import { useBacklinks } from "@/hooks/use-backlinks";
import type { BacklinkRecord } from "@/types/references";
import { cn } from "@/lib/utils";
interface PageBacklinksPanelProps {
workspaceId: string;
documentId: string;
className?: string;
}
const formatRelative = (value: string) => {
if (!value) return "";
const date = new Date(value);
return date.toLocaleString();
};
const EmptyState = () => (
<div className="rounded-xl border border-dashed border-[#e2e8f0] bg-white/60 px-4 py-6 text-center text-sm text-gray-500">
<code className="rounded bg-gray-100 px-1">[[</code> <code className="rounded bg-gray-100 px-1">#</code>{" "}
</div>
);
const BacklinkItem = ({ record }: { record: BacklinkRecord }) => (
<div className="rounded-xl border border-[#f1f5f9] bg-white p-4 shadow-sm">
<div className="flex items-center justify-between text-sm font-medium text-gray-900">
<span>{record.alias || record.sourceTitle || "无标题"}</span>
<span className="text-xs text-gray-400">{record.displayMode === "embed" ? "嵌入块" : "行内引用"}</span>
</div>
<div className="mt-1 text-xs text-gray-500">
{record.sourceTitle || "无标题"} · {formatRelative(record.updatedAt)}
</div>
</div>
);
export function PageBacklinksPanel({ workspaceId, documentId, className }: PageBacklinksPanelProps) {
const { data, isLoading, error, refetch, isFetching } = useBacklinks({
workspaceId,
documentId,
});
const records = useMemo(() => data ?? [], [data]);
if (!isLoading && !error && records.length === 0) {
return null;
}
return (
<section className={cn("rounded-2xl border border-[#eef2ff] bg-[#fdfdff] p-5 shadow-sm", className)}>
<div className="mb-4 flex items-center justify-between">
<div>
<p className="text-base font-semibold text-[#1f2933]"></p>
<p className="text-xs text-gray-500"></p>
</div>
<Button size="sm" variant="ghost" onClick={() => refetch()} disabled={isFetching}>
{isFetching ? "刷新中..." : "刷新"}
</Button>
</div>
{isLoading ? (
<div className="py-6 text-center text-sm text-gray-500">...</div>
) : error ? (
<div className="py-6 text-center text-sm text-red-500">{(error as Error).message}</div>
) : records.length === 0 ? (
<EmptyState />
) : (
<div className="space-y-3">
{records.map((record) => (
<BacklinkItem key={record.id} record={record} />
))}
</div>
)}
</section>
);
}
@@ -0,0 +1,228 @@
"use client";
import { useState, type ComponentType } from "react";
import { BookOpenCheck, Focus, ListOrdered, ListTree, Maximize2, ShieldCheck, Type } from "lucide-react";
import { cn } from "@/lib/utils";
import type { DocumentStats, PageOptionsState } from "@/types/page-options";
import { DocumentTaskPanel } from "@/components/document-task-panel";
import { Button } from "@/components/ui/button";
type TabId = "page" | "custom" | "global";
const TABS: Array<{ id: TabId; label: string }> = [
{ id: "page", label: "页面选项" },
{ id: "custom", label: "自定义页面" },
{ id: "global", label: "全局选项" },
];
const OPTION_META: Record<
keyof PageOptionsState,
{ label: string; description: string; icon: ComponentType<{ className?: string }> }
> = {
wideLayout: {
label: "自适应宽度",
description: "让编辑区域根据屏幕自动铺满",
icon: Maximize2,
},
smallText: {
label: "小字体",
description: "使用更紧凑的字号排版",
icon: Type,
},
showHeadingNumbers: {
label: "标题编号",
description: "自动为标题添加编号",
icon: ListOrdered,
},
showToc: {
label: "显示目录",
description: "在右侧展示目录导航",
icon: ListTree,
},
showStructure: {
label: "块结构线框",
description: "显示块级元素的结构边界",
icon: Focus,
},
protectEditing: {
label: "编辑保护",
description: "保护内容避免误触修改",
icon: ShieldCheck,
},
showWordCount: {
label: "字数提示",
description: "实时展示字数和块统计",
icon: BookOpenCheck,
},
};
const CUSTOM_LAYOUT_OPTIONS: (keyof PageOptionsState)[] = ["wideLayout", "smallText"];
const CUSTOM_STRUCTURE_OPTIONS: (keyof PageOptionsState)[] = ["showHeadingNumbers", "showToc"];
const GLOBAL_OPTIONS: (keyof PageOptionsState)[] = ["showStructure", "protectEditing", "showWordCount"];
interface PageOptionsSidebarProps {
documentId: string;
options: PageOptionsState;
stats?: DocumentStats;
onToggle: (key: keyof PageOptionsState) => void;
onExport: () => void;
onOpenHistory: () => void;
}
export function PageOptionsSidebar({
documentId,
options,
stats,
onToggle,
onExport,
onOpenHistory,
}: PageOptionsSidebarProps) {
const [activeTab, setActiveTab] = useState<TabId>("page");
return (
<aside className="flex h-full w-80 shrink-0 flex-col border-l border-[#f0f0f0] bg-white/95">
<div className="flex border-b border-[#f5f5f5]">
{TABS.map((tab) => (
<button
key={tab.id}
type="button"
className={cn(
"flex-1 border-b-2 px-4 py-3 text-sm font-medium text-gray-500",
activeTab === tab.id ? "border-[#2563eb] text-[#2563eb]" : "border-transparent hover:text-gray-700",
)}
onClick={() => setActiveTab(tab.id)}
>
{tab.label}
</button>
))}
</div>
<div className="flex-1 overflow-y-auto p-4">
{activeTab === "page" && (
<div className="space-y-4">
{options.showWordCount && stats && (
<section className="rounded-2xl border border-[#eef1f6] p-4">
<div className="text-sm font-semibold text-gray-800"></div>
<div className="mt-3 grid grid-cols-3 gap-2 text-center text-xs text-gray-500">
<StatsCell label="字数" value={stats.wordCount} />
<StatsCell label="字符" value={stats.characterCount} />
<StatsCell label="块数" value={stats.blockCount} />
</div>
</section>
)}
<section className="rounded-2xl border border-[#eef1f6] p-4 text-sm text-gray-600">
<div className="flex items-center justify-between">
<span className="font-semibold text-gray-800"></span>
<div className="flex gap-2">
<Button type="button" variant="outline" size="sm" onClick={onExport}>
</Button>
<Button type="button" variant="outline" size="sm" onClick={onOpenHistory}>
</Button>
</div>
</div>
<p className="mt-2 text-xs text-gray-400"></p>
</section>
<DocumentTaskPanel documentId={documentId} />
</div>
)}
{activeTab === "custom" && (
<div className="space-y-5">
<OptionToggleGroup
title="布局与排版"
optionKeys={CUSTOM_LAYOUT_OPTIONS}
options={options}
onToggle={onToggle}
/>
<OptionToggleGroup
title="结构与目录"
optionKeys={CUSTOM_STRUCTURE_OPTIONS}
options={options}
onToggle={onToggle}
/>
</div>
)}
{activeTab === "global" && (
<div className="space-y-5">
<OptionToggleGroup title="全局偏好" optionKeys={GLOBAL_OPTIONS} options={options} onToggle={onToggle} />
<section className="rounded-2xl border border-dashed border-[#e3e3e3] p-4 text-xs text-gray-400">
Good Night
</section>
</div>
)}
</div>
</aside>
);
}
function OptionToggleGroup({
title,
optionKeys,
options,
onToggle,
}: {
title: string;
optionKeys: (keyof PageOptionsState)[];
options: PageOptionsState;
onToggle: (key: keyof PageOptionsState) => void;
}) {
return (
<section>
<div className="text-xs font-semibold uppercase tracking-wide text-gray-400">{title}</div>
<div className="mt-3 space-y-2">
{optionKeys.map((key) => (
<OptionToggle key={key} optionKey={key} options={options} onToggle={onToggle} />
))}
</div>
</section>
);
}
function OptionToggle({
optionKey,
options,
onToggle,
}: {
optionKey: keyof PageOptionsState;
options: PageOptionsState;
onToggle: (key: keyof PageOptionsState) => void;
}) {
const meta = OPTION_META[optionKey];
const Icon = meta.icon;
const active = options[optionKey];
return (
<button
type="button"
className="flex w-full items-center justify-between rounded-2xl border border-transparent bg-[#f9fafc] px-3 py-2 text-left shadow-sm transition hover:border-[#dbe7ff]"
onClick={() => onToggle(optionKey)}
>
<div className="flex items-center gap-3">
<div
className={cn(
"rounded-xl border p-2",
active ? "border-[#cddfff] bg-[#eef4ff]" : "border-transparent bg-white",
)}
>
<Icon className={cn("h-4 w-4", active ? "text-[#2563eb]" : "text-gray-500")} />
</div>
<div>
<div className="text-sm font-medium text-gray-900">{meta.label}</div>
<div className="text-xs text-gray-400">{meta.description}</div>
</div>
</div>
<span className={cn("text-xs font-semibold", active ? "text-[#2563eb]" : "text-gray-400")}>
{active ? "已开启" : "已关闭"}
</span>
</button>
);
}
function StatsCell({ label, value }: { label: string; value: number }) {
return (
<div className="rounded-xl bg-white py-3 text-center shadow-sm">
<div className="text-xs text-gray-400">{label}</div>
<div className="mt-1 text-lg font-semibold text-gray-900">{value}</div>
</div>
);
}
@@ -0,0 +1,36 @@
"use client";
import {
BlockNoteSchema,
createHeadingBlockSpec,
defaultBlockSpecs,
defaultInlineContentSpecs,
defaultStyleSpecs,
} from "@blocknote/core";
import { pageReferenceBlock } from "./blocks/PageReferenceBlock";
import { advancedTodoBlock } from "./blocks/AdvancedTodoBlock";
import { progressBlock } from "./blocks/ProgressBlock";
import { mediaBlock } from "./blocks/MediaBlock";
const headingSpec =
typeof window === "undefined"
? defaultBlockSpecs.heading
: createHeadingBlockSpec({
levels: [1, 2, 3, 4, 5],
allowToggleHeadings: true,
});
export const customBlockSchema = BlockNoteSchema.create({
blockSpecs: {
...defaultBlockSpecs,
heading: headingSpec,
pageReference: pageReferenceBlock,
advancedTodo: advancedTodoBlock,
progressMeter: progressBlock,
media: mediaBlock,
},
inlineContentSpecs: defaultInlineContentSpecs,
styleSpecs: defaultStyleSpecs,
});
export type CustomBlockSchema = typeof customBlockSchema.blockSchema;
@@ -0,0 +1,78 @@
"use client";
import { createContext, useContext, useMemo, useState, type ReactNode } from "react";
import { ImagePickerDialog, type PickerTab } from "@/components/media/image-picker-dialog";
import type { MediaKind, MediaSelection } from "@/types/media";
interface PickerState {
open: boolean;
defaultTab: PickerTab;
mediaType: MediaKind;
onSelect?: (selection: MediaSelection) => void;
}
interface ImagePickerContextValue {
openPicker: (options: { onSelect: (selection: MediaSelection) => void; defaultTab?: PickerTab; mediaType?: MediaKind }) => void;
}
const ImagePickerContext = createContext<ImagePickerContextValue | undefined>(undefined);
interface ImagePickerProviderProps {
documentId: string;
workspaceId: string;
children: ReactNode;
}
export function ImagePickerProvider({ documentId, workspaceId, children }: ImagePickerProviderProps) {
const [state, setState] = useState<PickerState>({
open: false,
defaultTab: "upload",
mediaType: "image",
});
const contextValue = useMemo<ImagePickerContextValue>(
() => ({
openPicker: ({ onSelect, defaultTab = "upload", mediaType = "image" }) => {
setState({
open: true,
onSelect,
defaultTab,
mediaType,
});
},
}),
[],
);
const handleClose = () => {
setState((prev) => ({ ...prev, open: false }));
};
const handleSelect = (selection: MediaSelection) => {
state.onSelect?.(selection);
setState((prev) => ({ ...prev, open: false }));
};
return (
<ImagePickerContext.Provider value={contextValue}>
{children}
<ImagePickerDialog
open={state.open}
defaultTab={state.defaultTab}
mediaType={state.mediaType}
documentId={documentId}
workspaceId={workspaceId}
onClose={handleClose}
onSelect={handleSelect}
/>
</ImagePickerContext.Provider>
);
}
export function useImagePicker() {
const context = useContext(ImagePickerContext);
if (!context) {
throw new Error("useImagePicker 必须在 ImagePickerProvider 中使用");
}
return context;
}
@@ -0,0 +1,392 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useDropzone } from "react-dropzone";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import type { MediaAsset, MediaKind, MediaSelection } from "@/types/media";
export type PickerTab = "upload" | "recent" | "link" | "icon";
interface ImagePickerDialogProps {
open: boolean;
defaultTab: PickerTab;
mediaType: MediaKind;
documentId: string;
workspaceId: string;
onClose: () => void;
onSelect: (selection: MediaSelection) => void;
}
const ICON_PRESETS: Array<{ id: string; label: string; url: string }> = [
{ id: "progress", label: "进度波纹", url: "https://media.giphy.com/media/QBd2kLB5qDmysEXre9/giphy.gif" },
{ id: "heartbeat", label: "心跳", url: "https://media.giphy.com/media/HEPwfdu6T6svpPE1eN/giphy.gif" },
{ id: "sparkles", label: "闪光", url: "https://media.giphy.com/media/f9jQLaPmuFKfa7E55Q/giphy.gif" },
{ id: "loading", label: "加载", url: "https://media.giphy.com/media/52qtwCtj9OLTi/giphy.gif" },
];
const MEDIA_TABS: Record<MediaKind, PickerTab[]> = {
image: ["upload", "recent", "link", "icon"],
video: ["upload", "recent", "link"],
audio: ["upload", "recent", "link"],
file: ["upload", "recent", "link"],
};
const TAB_LABELS: Record<PickerTab, string> = {
upload: "上传",
recent: "最近",
link: "链接",
icon: "动态图标",
};
const MEDIA_ACCEPTS: Record<MediaKind, Record<string, string[]> | null> = {
image: { "image/*": [] },
video: { "video/*": [] },
audio: { "audio/*": [] },
file: null,
};
const MEDIA_TITLES: Record<MediaKind, string> = {
image: "选择图片",
video: "选择视频",
audio: "选择音频",
file: "选择文件",
};
const formatBytes = (size?: number | null) => {
if (!size || size <= 0) {
return "";
}
const units = ["B", "KB", "MB", "GB", "TB"];
let value = size;
let index = 0;
while (value >= 1024 && index < units.length - 1) {
value /= 1024;
index += 1;
}
return `${value.toFixed(value >= 10 ? 0 : 1)} ${units[index]}`;
};
const guessFileNameFromUrl = (url: string) => {
try {
const parsed = new URL(url);
const name = parsed.pathname.split("/").filter(Boolean).pop();
if (name) {
return decodeURIComponent(name);
}
} catch {
const segments = url.split("?")[0]?.split("/") ?? [];
const name = segments.pop();
if (name) {
return decodeURIComponent(name);
}
}
return "未命名资源";
};
export function ImagePickerDialog({
open,
defaultTab,
mediaType,
documentId,
workspaceId,
onClose,
onSelect,
}: ImagePickerDialogProps) {
const [tab, setTab] = useState<PickerTab>(defaultTab);
const availableTabs = MEDIA_TABS[mediaType];
const [recent, setRecent] = useState<MediaAsset[]>([]);
const [recentLoading, setRecentLoading] = useState(false);
const [linkUrl, setLinkUrl] = useState("");
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (open) {
const availableTabs = MEDIA_TABS[mediaType];
const fallback = availableTabs.includes(defaultTab) ? defaultTab : availableTabs[0];
setTab(fallback);
}
}, [defaultTab, mediaType, open]);
const fetchRecent = useCallback(async () => {
if (!workspaceId) {
return;
}
setRecentLoading(true);
try {
const response = await fetch(
`/api/media/assets?workspaceId=${workspaceId}&assetType=${mediaType}`,
);
if (!response.ok) {
throw new Error("无法获取最近上传");
}
const payload = (await response.json()) as { items: MediaAsset[] };
setRecent(payload.items ?? []);
} catch (err) {
console.error(err);
setRecent([]);
} finally {
setRecentLoading(false);
}
}, [workspaceId, mediaType]);
useEffect(() => {
if (open && tab === "recent") {
void fetchRecent();
}
}, [open, tab, fetchRecent]);
const handleUpload = useCallback(
async (file: File) => {
if (!workspaceId || !documentId) {
setError("缺少必要参数");
return;
}
setUploading(true);
setError(null);
try {
const form = new FormData();
form.append("file", file);
form.append("workspaceId", workspaceId);
form.append("documentId", documentId);
const response = await fetch("/api/media/upload", {
method: "POST",
body: form,
});
if (!response.ok) {
const payload = await response.json().catch(() => null);
throw new Error(payload?.error ?? "上传失败");
}
const payload = (await response.json()) as { asset: MediaAsset };
if (!payload.asset?.file_url) {
throw new Error("返回数据缺少文件地址");
}
onSelect({
assetId: payload.asset.id,
fileUrl: payload.asset.file_url,
thumbnailUrl: payload.asset.thumbnail_url,
assetType: payload.asset.asset_type,
fileName: payload.asset.file_name,
fileSize: payload.asset.file_size,
mimeType: payload.asset.mime_type,
});
onClose();
} catch (err) {
setError((err as Error).message);
} finally {
setUploading(false);
}
},
[documentId, onClose, onSelect, workspaceId],
);
const onDrop = useCallback(
(acceptedFiles: File[]) => {
if (acceptedFiles.length === 0) return;
void handleUpload(acceptedFiles[0]);
},
[handleUpload],
);
const acceptConfig = MEDIA_ACCEPTS[mediaType];
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
accept: acceptConfig ?? undefined,
maxFiles: 1,
});
const handleLinkSubmit = async () => {
if (!linkUrl.trim()) return;
try {
const targetUrl = linkUrl.trim();
const response = await fetch("/api/media/assets", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
workspaceId,
documentId,
fileUrl: targetUrl,
thumbnailUrl: targetUrl,
assetType: mediaType,
fileName: guessFileNameFromUrl(targetUrl),
}),
});
if (!response.ok) {
const payload = await response.json().catch(() => null);
throw new Error(payload?.error ?? "保存失败");
}
const payload = (await response.json()) as { asset: MediaAsset };
if (!payload.asset?.file_url) {
throw new Error("缺少图片地址");
}
onSelect({
assetId: payload.asset.id,
fileUrl: payload.asset.file_url,
thumbnailUrl: payload.asset.thumbnail_url,
assetType: payload.asset.asset_type,
fileName: payload.asset.file_name,
fileSize: payload.asset.file_size,
mimeType: payload.asset.mime_type,
});
setLinkUrl("");
onClose();
} catch (err) {
setError((err as Error).message);
}
};
const renderRecent = () => {
if (recentLoading) {
return <div className="py-8 text-center text-sm text-gray-500">...</div>;
}
if (recent.length === 0) {
return <div className="py-8 text-center text-sm text-gray-400">{MEDIA_TITLES[mediaType].replace("选择", "")}</div>;
}
return (
<div className="grid grid-cols-3 gap-3">
{recent.map((asset) => (
<button
type="button"
key={asset.id}
className="rounded-xl border border-gray-200 bg-white p-2 text-left shadow-sm transition hover:border-[#2563eb]"
onClick={() => {
if (!asset.file_url) return;
onSelect({
assetId: asset.id,
fileUrl: asset.file_url,
thumbnailUrl: asset.thumbnail_url,
assetType: asset.asset_type,
fileName: asset.file_name,
fileSize: asset.file_size,
mimeType: asset.mime_type,
});
onClose();
}}
>
{mediaType === "image" ? (
<img
src={asset.thumbnail_url ?? asset.file_url ?? ""}
alt={asset.asset_type}
className="h-32 w-full rounded-lg object-cover"
/>
) : (
<div className="flex h-32 flex-col items-start justify-center gap-1 rounded-lg bg-gray-50 px-3">
<p className="text-sm font-medium text-gray-700">{asset.file_name ?? "未命名资源"}</p>
{asset.file_size ? <p className="text-xs text-gray-500">{formatBytes(asset.file_size)}</p> : null}
</div>
)}
<p className="mt-2 truncate text-xs text-gray-500">
{asset.asset_type === "icon" ? "动态图标" : MEDIA_TITLES[mediaType].replace("选择", "")}
</p>
</button>
))}
</div>
);
};
const renderIcons = () => (
<div className="grid grid-cols-2 gap-3">
{ICON_PRESETS.map((icon) => (
<button
key={icon.id}
type="button"
className="flex flex-col items-center rounded-xl border border-dashed border-gray-200 p-3 text-sm text-gray-600 transition hover:border-[#2563eb]"
onClick={() => {
onSelect({
assetId: `icon-${icon.id}`,
fileUrl: icon.url,
thumbnailUrl: icon.url,
assetType: "icon",
});
onClose();
}}
>
<img src={icon.url} alt={icon.label} className="h-24 w-24 rounded-lg object-cover" />
<span className="mt-2 text-xs">{icon.label}</span>
</button>
))}
</div>
);
const helperText = useMemo(() => {
const label = MEDIA_TITLES[mediaType].replace("选择", "");
switch (tab) {
case "upload":
return `支持拖拽或点击上传${label},单个文件不超过 200MB`;
case "recent":
return `最近 12 个${label},支持一键选取`;
case "link":
return `输入${label}直链或 CDN 地址`;
case "icon":
return "选择 Wolai 常用动态图标";
default:
return "";
}
}, [mediaType, tab]);
return (
<Dialog open={open} onOpenChange={(next) => !next && onClose()}>
<DialogContent className="max-w-3xl">
<DialogHeader className="text-left">
<DialogTitle>{MEDIA_TITLES[mediaType]}</DialogTitle>
<DialogDescription>{helperText}</DialogDescription>
</DialogHeader>
{error && <div className="rounded-md bg-red-50 px-3 py-2 text-sm text-red-600">{error}</div>}
<Tabs value={tab} onValueChange={(value) => setTab(value as PickerTab)}>
<TabsList className="mb-4">
{availableTabs.map((tabKey) => (
<TabsTrigger key={tabKey} value={tabKey}>
{TAB_LABELS[tabKey]}
</TabsTrigger>
))}
</TabsList>
{availableTabs.includes("upload") && (
<TabsContent value="upload">
<div
{...getRootProps()}
className={cn(
"flex h-48 cursor-pointer flex-col items-center justify-center rounded-2xl border-2 border-dashed border-gray-300 text-sm text-gray-500",
isDragActive && "border-[#2563eb] bg-[#f5f7ff]",
)}
>
<input {...getInputProps()} />
{uploading ? "上传中..." : "拖拽文件到此处,或点击选择文件"}
</div>
</TabsContent>
)}
{availableTabs.includes("recent") && (
<TabsContent value="recent">
<div className="mb-2 flex items-center justify-between text-sm">
<span className="text-gray-500">{MEDIA_TITLES[mediaType].replace("选择", "")}</span>
<Button type="button" variant="ghost" size="sm" onClick={() => void fetchRecent()}>
</Button>
</div>
{renderRecent()}
</TabsContent>
)}
{availableTabs.includes("link") && (
<TabsContent value="link">
<div className="flex gap-2">
<Input
value={linkUrl}
onChange={(event) => setLinkUrl(event.target.value)}
placeholder={`输入${MEDIA_TITLES[mediaType].replace("选择", "")}链接,例如 https://example.com/file`}
/>
<Button type="button" onClick={() => void handleLinkSubmit()}>
</Button>
</div>
</TabsContent>
)}
{availableTabs.includes("icon") && <TabsContent value="icon">{renderIcons()}</TabsContent>}
</Tabs>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,19 @@
"use client";
import { Menu } from "lucide-react";
import { useSidebarStore } from "@/store/sidebar";
export function MobileSidebarTrigger() {
const setOpen = useSidebarStore((state) => state.setOpen);
return (
<button
type="button"
aria-label="展开侧边栏"
onClick={() => setOpen(true)}
className="flex h-8 w-8 items-center justify-center rounded-md border border-[#eeeeee] text-gray-600 md:hidden"
>
<Menu className="h-4 w-4" />
</button>
);
}
@@ -0,0 +1,31 @@
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { useState, type ReactNode } from "react";
interface QueryProviderProps {
children: ReactNode;
}
export function QueryProvider({ children }: QueryProviderProps) {
const [client] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
refetchOnReconnect: true,
retry: 1,
},
},
}),
);
return (
<QueryClientProvider client={client}>
{children}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}
@@ -0,0 +1,18 @@
"use client";
import type { Session } from "@supabase/supabase-js";
import { SessionContextProvider } from "@supabase/auth-helpers-react";
import { supabaseBrowser } from "@/lib/supabase/client";
interface SupabaseProviderProps {
session: Session | null;
children: React.ReactNode;
}
export function SupabaseProvider({ session, children }: SupabaseProviderProps) {
return (
<SessionContextProvider supabaseClient={supabaseBrowser} initialSession={session}>
{children}
</SessionContextProvider>
);
}
@@ -0,0 +1,39 @@
import { Button } from "@/components/ui/button";
import type { DocumentSearchResult } from "@/types/search";
interface PageHoverCardProps {
result: DocumentSearchResult;
onPreview?: () => void;
}
const formatRelative = (value: string | null) => {
if (!value) return "未知时间";
const date = new Date(value);
return date.toLocaleString();
};
export function PageHoverCard({ result, onPreview }: PageHoverCardProps) {
return (
<div className="space-y-2 text-xs text-gray-600">
<div>
<p className="text-sm font-semibold text-gray-900">{result.title || "无标题"}</p>
<p className="text-[11px] text-gray-400">
{formatRelative(result.updatedAt)} · {formatRelative(result.createdAt)}
</p>
</div>
<div
className="line-clamp-4 rounded-lg bg-[#f8fafc] p-2 text-[11px] leading-relaxed text-gray-600"
dangerouslySetInnerHTML={{ __html: result.snippet }}
/>
<Button
type="button"
size="sm"
variant="outline"
className="w-full text-[11px]"
onClick={() => onPreview?.()}
>
</Button>
</div>
);
}
@@ -0,0 +1,654 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { DragEvent as ReactDragEvent } from "react";
import { useRouter, useSelectedLayoutSegments } from "next/navigation";
import { CalendarClock, ChevronsUpDown, Copy, Layers, Search } from "lucide-react";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card";
import { cn } from "@/lib/utils";
import { useSearchPaletteStore, type ReferenceInsertMode, type SearchPaletteMode } from "@/store/search-palette";
import type { DocumentSearchResult, DocumentSearchRequest, DocumentSearchTimeRange } from "@/types/search";
import { useDocumentSearch } from "@/hooks/use-document-search";
import { useReferenceComposer } from "@/hooks/use-reference-composer";
import { recordRecentPage } from "@/lib/search/record-recent";
import { PageHoverCard } from "@/components/reference/page-hover-card";
import { buildSearchRequest } from "@/lib/search/request";
import { resolveSearchOpenMode } from "@/lib/search/shortcuts";
interface SearchPaletteProps {
workspaceId: string | null;
}
const TIME_RANGE_LABEL: Record<DocumentSearchTimeRange, string> = {
any: "全部时间",
"7d": "最近 7 天",
"30d": "最近 30 天",
};
const formatDate = (value: string | null) => {
if (!value) return "未知时间";
const date = new Date(value);
return date.toLocaleString();
};
export function SearchPalette({ workspaceId }: SearchPaletteProps) {
const router = useRouter();
const segments = useSelectedLayoutSegments();
const activeDocumentId = segments?.[1] ?? null;
const inputRef = useRef<HTMLInputElement>(null);
const {
open,
mode,
query,
filters,
timeRange,
referenceMode,
alias,
recent,
setRecent,
openSearch,
openReference,
close,
setQuery,
toggleFilter,
setTimeRange,
setTimeField,
setCustomRange,
setReferenceMode,
setAlias,
rememberResult,
} = useSearchPaletteStore();
const [highlightedIndex, setHighlightedIndex] = useState(0);
const { insertReference } = useReferenceComposer({
workspaceId,
sourcePageId: activeDocumentId ?? null,
});
const [resultTab, setResultTab] = useState<"all" | "recent">(recent.length > 0 ? "recent" : "all");
const [referenceFiltersOpen, setReferenceFiltersOpen] = useState(mode === "reference");
const updateCustomRange = useCallback(
(patch: { from?: string; to?: string }) => {
const next = { ...(filters.customRange ?? {}), ...patch };
if (!next.from && !next.to) {
setCustomRange(null);
} else {
setCustomRange(next);
}
},
[filters.customRange, setCustomRange],
);
const clearCustomRange = useCallback(() => setCustomRange(null), [setCustomRange]);
const requestPayload = useMemo<DocumentSearchRequest | null>(
() =>
buildSearchRequest({
workspaceId,
activeDocumentId,
query,
filters,
timeRange,
}),
[workspaceId, activeDocumentId, query, filters, timeRange],
);
const searchQueryEnabled = Boolean(open && workspaceId);
const { data, isLoading, isFetching, error } = useDocumentSearch(requestPayload, searchQueryEnabled);
useEffect(() => {
if (data?.recent) {
setRecent(data.recent);
}
}, [data?.recent, setRecent]);
/* eslint-disable react-hooks/set-state-in-effect */
useEffect(() => {
if (mode === "reference") {
setReferenceFiltersOpen(true);
} else {
setReferenceFiltersOpen(false);
}
}, [mode]);
/* eslint-enable react-hooks/set-state-in-effect */
const searchResults = data?.results;
const remoteResults = useMemo(() => searchResults ?? [], [searchResults]);
const showRecent = !query.trim() && recent.length > 0;
const enableRecentTab = showRecent;
const effectiveTab = enableRecentTab ? resultTab : "all";
const activeResults = effectiveTab === "recent" ? recent : remoteResults;
useEffect(() => {
if (!open) {
return undefined;
}
inputRef.current?.focus();
const frame = requestAnimationFrame(() => setHighlightedIndex(0));
return () => cancelAnimationFrame(frame);
}, [open]);
useEffect(() => {
if (highlightedIndex >= activeResults.length) {
const frame = requestAnimationFrame(() =>
setHighlightedIndex(activeResults.length > 0 ? activeResults.length - 1 : 0),
);
return () => cancelAnimationFrame(frame);
}
return undefined;
}, [activeResults.length, highlightedIndex]);
useEffect(() => {
const handleGlobalHotkey = (event: KeyboardEvent) => {
if ((event.metaKey || event.ctrlKey) && !event.shiftKey && event.key.toLowerCase() === "p") {
event.preventDefault();
openSearch();
}
if ((event.metaKey || event.ctrlKey) && event.shiftKey && event.key.toLowerCase() === "r") {
event.preventDefault();
openReference();
}
};
window.addEventListener("keydown", handleGlobalHotkey);
return () => window.removeEventListener("keydown", handleGlobalHotkey);
}, [openSearch, openReference]);
const handleOpenResult = useCallback(
(result: DocumentSearchResult, openMode: "main" | "new-window" | "sidebar") => {
if (openMode === "new-window") {
window.open(result.publicPath, "_blank", "noopener,noreferrer");
} else if (openMode === "sidebar") {
window.open(`${result.publicPath}?preview=sidebar`, "_blank", "noopener,noreferrer");
} else {
router.push(result.publicPath);
}
void recordRecentPage(workspaceId, result.id);
rememberResult(result);
close();
},
[router, workspaceId, rememberResult, close],
);
const handleInsertReference = useCallback(
(result: DocumentSearchResult, overrideMode?: ReferenceInsertMode) => {
const run = async () => {
try {
const effectiveMode = overrideMode ?? referenceMode;
await insertReference(result, {
mode: effectiveMode,
alias: effectiveMode === "inline" ? alias : undefined,
});
rememberResult(result);
close();
} catch (error) {
console.error(error);
}
};
void run();
},
[alias, close, insertReference, referenceMode, rememberResult],
);
const handleCopyReference = useCallback((result: DocumentSearchResult) => {
if (typeof window === "undefined") {
return;
}
const payload = `((${result.id}))`;
if (navigator?.clipboard) {
navigator.clipboard
.writeText(payload)
.then(() => window.alert("块引用已复制"))
.catch(() => {
window.prompt("复制失败,请手动复制引用内容", payload);
});
} else {
window.prompt("复制块引用", payload);
}
}, []);
useEffect(() => {
if (!open) {
return;
}
const handleKey = (event: KeyboardEvent) => {
if (event.key === "ArrowDown") {
event.preventDefault();
setHighlightedIndex((prev) => Math.min(activeResults.length - 1, prev + 1));
} else if (event.key === "ArrowUp") {
event.preventDefault();
setHighlightedIndex((prev) => Math.max(0, prev - 1));
} else if (event.key === "Enter") {
event.preventDefault();
if (activeResults.length === 0) {
return;
}
const result = activeResults[highlightedIndex] ?? activeResults[0];
if (!result) return;
if (mode === "search") {
const openMode = resolveSearchOpenMode(event);
handleOpenResult(result, openMode);
} else {
handleInsertReference(result);
}
} else if (event.key === "Escape") {
event.preventDefault();
close();
}
};
window.addEventListener("keydown", handleKey);
return () => window.removeEventListener("keydown", handleKey);
}, [open, activeResults, highlightedIndex, mode, handleOpenResult, handleInsertReference, close]);
const pending = isLoading || isFetching;
const dialogTitle = mode === "search" ? "页面搜索面板" : "引用选择面板";
return (
<Dialog open={open} onOpenChange={(next) => !next && close()}>
<DialogContent className="max-h-[90vh] w-full max-w-3xl overflow-hidden border-none bg-white/95 p-0 shadow-xl">
<DialogTitle className="sr-only">{dialogTitle}</DialogTitle>
<div className="flex h-[520px] flex-col">
<div className="border-b border-[#eef2ff] p-4">
<div className="flex items-center gap-3">
<div className="relative flex-1">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
<Input
ref={inputRef}
value={query}
onChange={(event) => {
const nextValue = event.target.value;
if (nextValue.trim().length > 0 && resultTab !== "all") {
setResultTab("all");
}
setQuery(nextValue);
}}
placeholder={mode === "search" ? "搜索页面标题、正文或 OCR 内容..." : "选择要引用的页面"}
className="h-11 w-full rounded-xl border border-[#e2e8f0] bg-white pl-9 text-sm"
/>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="gap-2 text-xs text-gray-600">
<CalendarClock className="h-4 w-4" />
{TIME_RANGE_LABEL[timeRange]}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
{(Object.keys(TIME_RANGE_LABEL) as DocumentSearchTimeRange[]).map((range) => (
<DropdownMenuItem
key={range}
onClick={() => setTimeRange(range)}
className={cn(range === timeRange && "bg-[#eef2ff] text-[#2563eb]")}
>
{TIME_RANGE_LABEL[range]}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="text-xs text-gray-600">
{filters.timeField === "updated" ? "按编辑时间" : "按创建时间"}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-32">
<DropdownMenuItem
onClick={() => setTimeField("updated")}
className={cn(filters.timeField === "updated" && "bg-[#eef2ff] text-[#2563eb]")}
>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setTimeField("created")}
className={cn(filters.timeField === "created" && "bg-[#eef2ff] text-[#2563eb]")}
>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="mt-3 flex flex-wrap items-center gap-2 text-xs">
<FilterToggle label="仅标题" active={filters.titleOnly} onClick={() => toggleFilter("titleOnly")} />
<FilterToggle label="精确匹配" active={filters.exact} onClick={() => toggleFilter("exact")} />
<FilterToggle
label="当前页面"
active={filters.onlyCurrentPage && Boolean(activeDocumentId)}
disabled={!activeDocumentId}
onClick={() => toggleFilter("onlyCurrentPage")}
/>
<FilterToggle
label="图片 OCR"
active={filters.includeOcr}
onClick={() => toggleFilter("includeOcr")}
/>
{mode === "reference" && (
<Button
type="button"
size="sm"
variant="ghost"
className="ml-auto flex items-center gap-1 rounded-full text-xs text-gray-600"
onClick={() => setReferenceFiltersOpen((prev) => !prev)}
>
<ChevronsUpDown className="h-3 w-3" />
{referenceFiltersOpen ? "隐藏引用筛选" : "展开引用筛选"}
</Button>
)}
</div>
{mode === "reference" && referenceFiltersOpen && (
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs text-gray-500">
<ReferenceModeToggle
label="行内引用"
active={referenceMode === "inline"}
onClick={() => setReferenceMode("inline")}
/>
<ReferenceModeToggle
label="嵌入块"
active={referenceMode === "embed"}
onClick={() => setReferenceMode("embed")}
/>
{referenceMode === "inline" && (
<Input
value={alias}
onChange={(event) => setAlias(event.target.value)}
placeholder="引用别名"
className="h-8 w-40 text-xs"
/>
)}
<span className="text-[11px] text-gray-400"> [[ / # </span>
</div>
)}
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs text-gray-500">
<span></span>
<Input
type="date"
value={filters.customRange?.from ?? ""}
onChange={(event) => updateCustomRange({ from: event.target.value || undefined })}
className="h-8 w-36 text-xs"
/>
<Input
type="date"
value={filters.customRange?.to ?? ""}
onChange={(event) => updateCustomRange({ to: event.target.value || undefined })}
className="h-8 w-36 text-xs"
/>
<Button
type="button"
variant="ghost"
size="sm"
className="text-xs text-gray-500"
onClick={clearCustomRange}
disabled={!filters.customRange?.from && !filters.customRange?.to}
>
</Button>
</div>
</div>
<div className="flex-1 overflow-hidden">
{enableRecentTab && (
<div className="flex items-center gap-2 border-b border-[#eef2ff] px-4 py-2 text-xs">
<TabButton
label="最近访问"
active={resultTab === "recent"}
onClick={() => setResultTab("recent")}
disabled={!enableRecentTab}
/>
<TabButton label="全部页面" active={resultTab === "all"} onClick={() => setResultTab("all")} />
</div>
)}
{pending ? (
<div className="flex h-full items-center justify-center text-sm text-gray-500">
...
</div>
) : error ? (
<div className="flex h-full items-center justify-center text-sm text-red-500">
{(error as Error).message}
</div>
) : activeResults.length === 0 ? (
<div className="flex h-full items-center justify-center text-sm text-gray-400">
{effectiveTab === "recent" ? "暂无最近访问记录" : "暂无匹配结果"}
</div>
) : (
<div className="h-full divide-y divide-[#f1f5f9] overflow-y-auto">
{activeResults.map((result, index) => (
<ResultRow
key={`result-${result.id}-${index}-${resultTab}`}
result={result}
highlighted={highlightedIndex === index}
mode={mode}
onOpen={() => handleOpenResult(result, "main")}
onReference={() => handleInsertReference(result)}
onEmbed={() => handleInsertReference(result, "embed")}
onCopy={() => handleCopyReference(result)}
/>
))}
</div>
)}
</div>
<div className="border-t border-[#eef2ff] px-4 py-2 text-xs text-gray-500">
{mode === "search" ? (
<div className="flex items-center justify-between">
<span>Enter · Ctrl/Cmd+Enter · Alt+Enter </span>
<span>Esc </span>
</div>
) : (
<div className="flex items-center justify-between">
<span>
{referenceMode === "inline" ? "插入行内引用(链接高亮)" : "插入一个嵌入的页面块"}
</span>
<span>Esc </span>
</div>
)}
</div>
</div>
</DialogContent>
</Dialog>
);
}
function ResultRow({
result,
highlighted,
mode,
onOpen,
onReference,
onEmbed,
onCopy,
}: {
result: DocumentSearchResult;
highlighted: boolean;
mode: SearchPaletteMode;
onOpen: () => void;
onReference: () => void;
onEmbed: () => void;
onCopy: () => void;
}) {
const handleActivate = () => {
if (mode === "search") {
onOpen();
} else {
onReference();
}
};
const handleDragStart = (event: ReactDragEvent<HTMLDivElement>) => {
if (mode !== "reference") return;
if (event.dataTransfer) {
event.dataTransfer.setData("text/plain", `((${result.id}))`);
event.dataTransfer.effectAllowed = "copy";
}
};
const content = (
<div
role="button"
tabIndex={0}
onClick={handleActivate}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
handleActivate();
}
}}
className={cn(
"cursor-pointer px-4 py-3 transition-colors hover:bg-[#eef2ff]",
highlighted && "bg-[#e3ecff]",
)}
draggable={mode === "reference"}
onDragStart={handleDragStart}
>
<div className="flex items-center gap-2 text-sm font-medium text-gray-900">
{result.title || "无标题"}
{result.matchField !== "recent" && (
<Badge variant="secondary" className="bg-[#edf2ff] text-xs text-[#2563eb]">
{result.matchField === "title" ? "标题匹配" : "正文匹配"}
</Badge>
)}
{result.hasOcr && (
<Badge variant="outline" className="text-[10px] text-[#475569]">
OCR
</Badge>
)}
</div>
<div className="mt-1 text-xs text-gray-500" dangerouslySetInnerHTML={{ __html: result.snippet }} />
<div className="mt-2 text-[11px] text-gray-400">
{formatDate(result.updatedAt)} · {formatDate(result.createdAt)}
</div>
{mode === "reference" && (
<div className="mt-3 flex flex-wrap items-center gap-2 text-[11px] text-gray-500">
<Button
type="button"
size="sm"
variant="ghost"
className="h-7 rounded-full px-3"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
onReference();
}}
>
</Button>
<Button
type="button"
size="sm"
variant="ghost"
className="flex h-7 items-center gap-1 rounded-full px-3"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
onEmbed();
}}
>
<Layers className="h-3 w-3" />
...
</Button>
<Button
type="button"
size="sm"
variant="ghost"
className="flex h-7 items-center gap-1 rounded-full px-3"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
onCopy();
}}
>
<Copy className="h-3 w-3" />
</Button>
<span className="ml-auto text-[10px] text-gray-400"></span>
</div>
)}
</div>
);
return (
<HoverCard openDelay={250}>
<HoverCardTrigger asChild>{content}</HoverCardTrigger>
<HoverCardContent align="start">
<PageHoverCard result={result} onPreview={onOpen} />
</HoverCardContent>
</HoverCard>
);
}
interface FilterToggleProps {
label: string;
active: boolean;
onClick: () => void;
disabled?: boolean;
}
function FilterToggle({ label, active, onClick, disabled }: FilterToggleProps) {
return (
<Button
type="button"
size="sm"
variant={active ? "default" : "ghost"}
disabled={disabled}
className={cn(
"h-7 rounded-full border px-3 text-xs",
active
? "border-[#2563eb] bg-[#2563eb] text-white"
: "border-transparent text-gray-500 hover:bg-gray-100",
)}
onClick={onClick}
>
{label}
</Button>
);
}
interface TabButtonProps {
label: string;
active: boolean;
onClick: () => void;
disabled?: boolean;
}
function TabButton({ label, active, onClick, disabled }: TabButtonProps) {
return (
<Button
type="button"
size="sm"
variant={active ? "default" : "ghost"}
disabled={disabled}
className={cn(
"rounded-full px-4 text-xs",
active ? "bg-[#2563eb] text-white" : "text-gray-600 hover:bg-[#eef2ff]",
)}
onClick={onClick}
>
{label}
</Button>
);
}
interface ReferenceModeToggleProps {
label: string;
active: boolean;
onClick: () => void;
}
function ReferenceModeToggle({ label, active, onClick }: ReferenceModeToggleProps) {
return (
<Button
type="button"
size="sm"
variant={active ? "default" : "ghost"}
className={cn(
"h-7 rounded-full border px-3 text-[11px]",
active
? "border-[#2563eb] bg-[#2563eb] text-white"
: "border-transparent text-gray-500 hover:bg-gray-100",
)}
onClick={onClick}
>
{label}
</Button>
);
}
@@ -0,0 +1,257 @@
"use client";
import Link from "next/link";
import { useCallback, useMemo, useRef, useState } from "react";
import {
DndContext,
PointerSensor,
closestCenter,
useSensor,
useSensors,
type DragEndEvent,
type DragStartEvent,
} from "@dnd-kit/core";
import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { useVirtualizer, type VirtualItem } from "@tanstack/react-virtual";
import { ChevronRight, GripVertical, MoreHorizontal, Plus } from "lucide-react";
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
import type { DocumentNode } from "@/lib/documents";
import { cn } from "@/lib/utils";
import { flattenDocumentTree } from "@/lib/sidebar-tree";
interface PrivateTreeProps {
nodes: DocumentNode[];
expanded: Set<string>;
activeId: string;
onToggleExpand: (id: string) => void;
onMove: (nodeId: string, parentId: string | null, index: number) => void;
onCreateChild: (parentId: string | null) => void;
onContextMenu: (event: React.MouseEvent, node: DocumentNode) => void;
}
const ROW_HEIGHT = 36;
export function PrivateTree({
nodes,
expanded,
activeId,
onToggleExpand,
onMove,
onCreateChild,
onContextMenu,
}: PrivateTreeProps) {
const scrollAreaRef = useRef<HTMLDivElement>(null);
const [activeDragId, setActiveDragId] = useState<string | null>(null);
const flatNodes = useMemo(() => flattenDocumentTree(nodes, expanded), [nodes, expanded]);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 5 },
}),
);
// eslint-disable-next-line react-hooks/incompatible-library
const virtualizer = useVirtualizer({
count: flatNodes.length,
getScrollElement: () => scrollAreaRef.current,
estimateSize: () => ROW_HEIGHT,
overscan: 10,
});
const handleDragStart = useCallback((event: DragStartEvent) => {
setActiveDragId(event.active.id as string);
}, []);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const activeId = event.active.id as string;
const overId = event.over?.id as string | undefined;
setActiveDragId(null);
if (!overId || activeId === overId) {
return;
}
const activeIndex = flatNodes.findIndex((item) => item.node.id === activeId);
const overIndex = flatNodes.findIndex((item) => item.node.id === overId);
if (activeIndex === -1 || overIndex === -1) {
return;
}
const targetParent = flatNodes[overIndex].parentId;
const siblingList = flatNodes.filter((item) => item.parentId === targetParent);
const siblingIndex = siblingList.findIndex((item) => item.node.id === overId);
const position = siblingIndex === -1 ? siblingList.length : siblingIndex;
onMove(activeId, targetParent, position);
},
[flatNodes, onMove],
);
const handleDragCancel = useCallback(() => {
setActiveDragId(null);
}, []);
if (flatNodes.length === 0) {
return <div className="px-4 py-6 text-sm text-gray-400"></div>;
}
return (
<ScrollAreaPrimitive.Root type="auto" className="relative h-full w-full">
<ScrollAreaPrimitive.Viewport ref={scrollAreaRef} className="h-full w-full">
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
>
<SortableContext items={flatNodes.map((item) => item.node.id)} strategy={verticalListSortingStrategy}>
<div className="relative h-full">
<div style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
{virtualizer.getVirtualItems().map((virtualRow) => {
const item = flatNodes[virtualRow.index];
return (
<VirtualRow key={item.node.id} virtualRow={virtualRow}>
<SortableTreeRow
node={item.node}
depth={item.depth}
expanded={expanded.has(item.node.id)}
hasChildren={item.node.children.length > 0}
activeId={activeId}
isDragging={activeDragId === item.node.id}
onToggleExpand={() => onToggleExpand(item.node.id)}
onCreateChild={() => onCreateChild(item.node.id)}
onContextMenu={(event) => onContextMenu(event, item.node)}
/>
</VirtualRow>
);
})}
</div>
</div>
</SortableContext>
</DndContext>
</ScrollAreaPrimitive.Viewport>
<ScrollAreaPrimitive.ScrollAreaScrollbar
orientation="vertical"
className="flex w-2.5 touch-none select-none border-l border-l-transparent bg-transparent px-0.5 py-2"
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-[#c9d6f8]" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
);
}
function VirtualRow({
children,
virtualRow,
}: {
children: React.ReactNode;
virtualRow: VirtualItem;
}) {
return (
<div
className="absolute left-0 right-0"
style={{
transform: `translateY(${virtualRow.start}px)`,
height: `${virtualRow.size}px`,
}}
>
{children}
</div>
);
}
interface SortableTreeRowProps {
node: DocumentNode;
depth: number;
expanded: boolean;
hasChildren: boolean;
activeId: string;
isDragging: boolean;
onToggleExpand: () => void;
onCreateChild: () => void;
onContextMenu: (event: React.MouseEvent) => void;
}
function SortableTreeRow({
node,
depth,
expanded,
hasChildren,
activeId,
isDragging,
onToggleExpand,
onCreateChild,
onContextMenu,
}: SortableTreeRowProps) {
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({
id: node.id,
});
const style = {
transform: CSS.Translate.toString(transform),
transition,
};
return (
<div
ref={setNodeRef}
style={style}
className={cn(
"flex h-full items-center gap-1 border-b border-transparent px-2 text-sm text-gray-700 hover:bg-[#f5f7fb]",
activeId === node.id && "bg-[#e8f2ff] text-[#2563eb]",
isDragging && "opacity-60",
)}
onContextMenu={onContextMenu}
>
<button
type="button"
aria-label="拖拽排序"
className="flex h-6 w-6 items-center justify-center rounded-md text-gray-400 hover:text-gray-600"
{...listeners}
{...attributes}
>
<GripVertical className="h-4 w-4" />
</button>
<div
className="flex flex-1 items-center gap-2 rounded-md px-1 py-1"
style={{ paddingLeft: depth * 14 }}
>
{hasChildren ? (
<button
type="button"
aria-label="展开或折叠"
onClick={onToggleExpand}
className="flex h-5 w-5 items-center justify-center rounded-md hover:bg-gray-100"
>
<ChevronRight className={cn("h-3.5 w-3.5 transition-transform", expanded && "rotate-90")} />
</button>
) : (
<span className="h-5 w-5" />
)}
<Link href={`/documents/${node.id}`} className="flex-1 truncate text-left">
{node.title || "无标题"}
</Link>
</div>
<button
type="button"
aria-label="新建子页面"
onClick={onCreateChild}
className="rounded-md p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-700"
>
<Plus className="h-3.5 w-3.5" />
</button>
<button
type="button"
aria-label="更多操作"
onClick={(event) => {
event.preventDefault();
onContextMenu(event);
}}
className="rounded-md p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-700"
>
<MoreHorizontal className="h-4 w-4" />
</button>
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,19 @@
import type { DocumentRecord } from "@/lib/documents";
import type { WorkspaceSummary } from "@/lib/workspaces";
export type SidebarSectionId = "starred" | "public" | "shared" | "private" | "templates";
export interface TrashRecord {
id: string;
title: string | null;
deleted_at: string;
parent_id: string | null;
access_scope: DocumentRecord["access_scope"];
}
export interface SidebarInitialData {
activeWorkspaceId: string;
workspaces: WorkspaceSummary[];
documents: DocumentRecord[];
trashedDocuments: TrashRecord[];
}
@@ -0,0 +1,53 @@
"use client"
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "@/lib/utils"
function Avatar({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
className={cn(
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
)
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn("aspect-square size-full", className)}
{...props}
/>
)
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"bg-muted flex size-full items-center justify-center rounded-full",
className
)}
{...props}
/>
)
}
export { Avatar, AvatarImage, AvatarFallback }
@@ -0,0 +1,46 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span"
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }
@@ -0,0 +1,60 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
+92
View File
@@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
@@ -0,0 +1,184 @@
"use client"
import * as React from "react"
import { Command as CommandPrimitive } from "cmdk"
import { SearchIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
className
)}
{...props}
/>
)
}
function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
className,
showCloseButton = true,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string
description?: string
className?: string
showCloseButton?: boolean
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent
className={cn("overflow-hidden p-0", className)}
showCloseButton={showCloseButton}
>
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div
data-slot="command-input-wrapper"
className="flex h-9 items-center gap-2 border-b px-3"
>
<SearchIcon className="size-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
)
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn(
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
className
)}
{...props}
/>
)
}
function CommandEmpty({
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className="py-6 text-center text-sm"
{...props}
/>
)
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
className
)}
{...props}
/>
)
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("bg-border -mx-1 h-px", className)}
{...props}
/>
)
}
function CommandItem({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}
+152
View File
@@ -0,0 +1,152 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
fullScreen = false,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
fullScreen?: boolean
}) {
const baseClass =
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0";
const defaultLayout =
"data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg";
const fullscreenLayout =
"fixed inset-0 z-50 grid h-full w-full translate-x-0 translate-y-0 rounded-none border-none p-0 shadow-none duration-200";
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
baseClass,
fullScreen ? fullscreenLayout : defaultLayout,
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}
+135
View File
@@ -0,0 +1,135 @@
"use client"
import * as React from "react"
import { Drawer as DrawerPrimitive } from "vaul"
import { cn } from "@/lib/utils"
function Drawer({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
return <DrawerPrimitive.Root data-slot="drawer" {...props} />
}
function DrawerTrigger({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />
}
function DrawerPortal({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />
}
function DrawerClose({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />
}
function DrawerOverlay({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
return (
<DrawerPrimitive.Overlay
data-slot="drawer-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function DrawerContent({
className,
children,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
return (
<DrawerPortal data-slot="drawer-portal">
<DrawerOverlay />
<DrawerPrimitive.Content
data-slot="drawer-content"
className={cn(
"group/drawer-content bg-background fixed z-50 flex h-auto flex-col",
"data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b",
"data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t",
"data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm",
"data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm",
className
)}
{...props}
>
<div className="bg-muted mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
)
}
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-header"
className={cn(
"flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left",
className
)}
{...props}
/>
)
}
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function DrawerTitle({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
return (
<DrawerPrimitive.Title
data-slot="drawer-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
)
}
function DrawerDescription({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
return (
<DrawerPrimitive.Description
data-slot="drawer-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
}
@@ -0,0 +1,257 @@
"use client"
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}
@@ -0,0 +1,30 @@
"use client";
import * as HoverCardPrimitive from "@radix-ui/react-hover-card";
import { cn } from "@/lib/utils";
const HoverCard = HoverCardPrimitive.Root;
const HoverCardTrigger = HoverCardPrimitive.Trigger;
function HoverCardContent({
className,
align = "center",
sideOffset = 8,
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
return (
<HoverCardPrimitive.Content
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-64 rounded-2xl border border-[#e5e7eb] bg-white/95 p-4 text-sm shadow-lg outline-none backdrop-blur",
className,
)}
{...props}
/>
);
}
export { HoverCard, HoverCardContent, HoverCardTrigger };
@@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
)
}
export { Input }
@@ -0,0 +1,24 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }
@@ -0,0 +1,48 @@
"use client"
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
)
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
@@ -0,0 +1,58 @@
"use client"
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@/lib/utils"
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="bg-border relative flex-1 rounded-full"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
)
}
export { ScrollArea, ScrollBar }
@@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props}
/>
)
}
export { Separator }
+139
View File
@@ -0,0 +1,139 @@
"use client"
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
side === "right" &&
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
side === "left" &&
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
side === "top" &&
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
side === "bottom" &&
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
className
)}
{...props}
>
{children}
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}
@@ -0,0 +1,13 @@
import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("bg-accent animate-pulse rounded-md", className)}
{...props}
/>
)
}
export { Skeleton }
+66
View File
@@ -0,0 +1,66 @@
"use client"
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
function Tabs({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function TabsList({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.List>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
className={cn(
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
className
)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent }
@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
{...props}
/>
)
}
export { Textarea }
@@ -0,0 +1,47 @@
"use client"
import * as React from "react"
import * as TogglePrimitive from "@radix-ui/react-toggle"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const toggleVariants = cva(
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",
{
variants: {
variant: {
default: "bg-transparent",
outline:
"border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-9 px-2 min-w-9",
sm: "h-8 px-1.5 min-w-8",
lg: "h-10 px-2.5 min-w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Toggle({
className,
variant,
size,
...props
}: React.ComponentProps<typeof TogglePrimitive.Root> &
VariantProps<typeof toggleVariants>) {
return (
<TogglePrimitive.Root
data-slot="toggle"
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Toggle, toggleVariants }
View File
@@ -0,0 +1,40 @@
import { useEffect, useState } from "react";
type Status = "idle" | "ok" | "error";
export function useBackendHealth() {
const [status, setStatus] = useState<Status>(() => {
if (!process.env.NEXT_PUBLIC_BACKEND_URL) {
return "error";
}
return "idle";
});
useEffect(() => {
let destroyed = false;
const url = process.env.NEXT_PUBLIC_BACKEND_URL;
if (!url) {
return;
}
const controller = new AbortController();
const check = async () => {
try {
const response = await fetch(`${url}/health`, { signal: controller.signal });
if (!destroyed) {
setStatus(response.ok ? "ok" : "error");
}
} catch {
if (!destroyed) {
setStatus("error");
}
}
};
check();
return () => {
destroyed = true;
controller.abort();
};
}, []);
return status;
}
+61
View File
@@ -0,0 +1,61 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import type { BacklinkRecord } from "@/types/references";
interface UseBacklinksOptions {
workspaceId: string | null;
documentId: string | null;
}
interface RawBacklink {
id: string;
source_page_id: string;
source_block_id: string | null;
alias: string | null;
display_mode: string;
is_previewable: boolean;
created_at: string;
updated_at: string;
source_title: string | null;
}
const mapBacklink = (raw: RawBacklink): BacklinkRecord => ({
id: raw.id,
sourcePageId: raw.source_page_id,
sourceBlockId: raw.source_block_id,
alias: raw.alias,
displayMode: (raw.display_mode as BacklinkRecord["displayMode"]) ?? "inline",
isPreviewable: raw.is_previewable,
createdAt: raw.created_at,
updatedAt: raw.updated_at,
sourceTitle: raw.source_title,
});
const fetchBacklinks = async (workspaceId: string, documentId: string): Promise<BacklinkRecord[]> => {
const params = new URLSearchParams({
workspaceId,
pageId: documentId,
});
const response = await fetch(`/api/references/backlinks?${params.toString()}`);
if (!response.ok) {
const message = (await response.json().catch(() => null))?.error ?? "加载引用失败";
throw new Error(message);
}
const payload = (await response.json()) as { backlinks: RawBacklink[] };
return (payload.backlinks ?? []).map(mapBacklink);
};
export function useBacklinks({ workspaceId, documentId }: UseBacklinksOptions) {
return useQuery({
queryKey: ["page-backlinks", workspaceId, documentId],
queryFn: () => {
if (!workspaceId || !documentId) {
throw new Error("缺少引用参数");
}
return fetchBacklinks(workspaceId, documentId);
},
enabled: Boolean(workspaceId && documentId),
staleTime: 15_000,
});
}
@@ -0,0 +1,41 @@
"use client";
import { useEffect, useMemo, useRef } from "react";
type Procedure<T extends unknown[]> = (...args: T) => void;
type DebouncedProcedure<T extends unknown[]> = Procedure<T> & { cancel: () => void };
/**
* 用于本地输入的防抖工具,默认等待 800ms 再触发回调。
*/
export const useDebouncedCallback = <T extends unknown[]>(
callback: Procedure<T>,
delay: number,
): DebouncedProcedure<T> => {
const timerRef = useRef<NodeJS.Timeout | null>(null);
const callbackRef = useRef(callback);
useEffect(() => {
callbackRef.current = callback;
}, [callback]);
return useMemo(() => {
const debounced = ((...args: T) => {
if (timerRef.current) {
clearTimeout(timerRef.current);
}
timerRef.current = setTimeout(() => {
callbackRef.current(...args);
}, delay);
}) as DebouncedProcedure<T>;
debounced.cancel = () => {
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
};
return debounced;
}, [delay]);
};
@@ -0,0 +1,34 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import type { DocumentSearchRequest, DocumentSearchResponse } from "@/types/search";
const fetchDocumentSearch = async (payload: DocumentSearchRequest): Promise<DocumentSearchResponse> => {
const response = await fetch("/api/search/documents", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!response.ok) {
const message = (await response.json().catch(() => null))?.error ?? "搜索失败,请稍后再试";
throw new Error(message);
}
return response.json() as Promise<DocumentSearchResponse>;
};
export function useDocumentSearch(payload: DocumentSearchRequest | null, enabled: boolean) {
return useQuery({
queryKey: ["document-search", payload],
queryFn: () => {
if (!payload) {
throw new Error("缺少搜索参数");
}
return fetchDocumentSearch(payload);
},
enabled: enabled && Boolean(payload?.workspaceId),
staleTime: 30_000,
gcTime: 60_000,
});
}
@@ -0,0 +1,77 @@
"use client";
import { useCallback, useState } from "react";
import type { DocumentSearchResult } from "@/types/search";
import type { ReferenceInsertMode } from "@/store/search-palette";
import { useEditorBridgeStore } from "@/store/editor-bridge";
import { recordRecentPage } from "@/lib/search/record-recent";
interface ReferenceComposerOptions {
workspaceId: string | null;
sourcePageId: string | null;
}
interface ComposeParams {
mode: ReferenceInsertMode;
alias?: string;
}
export function useReferenceComposer({ workspaceId, sourcePageId }: ReferenceComposerOptions) {
const bridge = useEditorBridgeStore((state) => state.bridge);
const [pending, setPending] = useState(false);
const insertReference = useCallback(
async (target: DocumentSearchResult, params: ComposeParams) => {
if (!workspaceId || !sourcePageId) {
window.alert("当前页面或工作空间信息缺失,无法插入引用");
return;
}
if (!bridge) {
window.alert("编辑器尚未准备好,请稍后再试");
return;
}
const aliasText = params.alias?.trim();
const label = aliasText || target.title || "无标题";
const mode = params.mode;
const result =
mode === "inline"
? bridge.insertInlineReference(target, label)
: bridge.insertEmbedReference(target);
setPending(true);
try {
const response = await fetch("/api/references/record", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
workspaceId,
sourcePageId,
targetPageId: target.id,
sourceBlockId: result?.blockId ?? null,
alias: label,
displayMode: mode,
isPreviewable: true,
}),
});
if (!response.ok) {
const message = (await response.json().catch(() => null))?.error ?? "引用记录失败";
window.alert(message);
} else {
void recordRecentPage(workspaceId, target.id);
}
} catch (error) {
console.error(error);
window.alert("引用记录失败,请稍后再试");
} finally {
setPending(false);
}
},
[bridge, sourcePageId, workspaceId],
);
return {
insertReference,
referencing: pending,
};
}
@@ -0,0 +1,28 @@
import { useQuery, type UseQueryResult } from "@tanstack/react-query";
import type { SidebarInitialData } from "@/components/sidebar/types";
async function requestSidebarData(workspaceId: string): Promise<SidebarInitialData> {
const response = await fetch(`/api/sidebar?workspaceId=${workspaceId}`, {
method: "GET",
credentials: "include",
});
if (!response.ok) {
const payload = await response.json().catch(() => ({}));
const message = payload?.error ?? "获取侧边栏数据失败";
throw new Error(message);
}
return response.json();
}
export function useSidebarData(initialData: SidebarInitialData): UseQueryResult<SidebarInitialData> {
const workspaceId = initialData.activeWorkspaceId;
return useQuery({
queryKey: ["sidebar", workspaceId],
queryFn: () => requestSidebarData(workspaceId),
initialData,
staleTime: 1000 * 60,
});
}
@@ -0,0 +1,30 @@
import type { Json } from "@/types/supabase";
/**
* 提取文档内容中的块数组,兼容数组和 { blocks: [] } 两种结构。
*/
export const extractBlocksFromContent = (content: unknown): Json[] => {
if (Array.isArray(content)) {
return content as Json[];
}
if (content && typeof content === "object" && Array.isArray((content as { blocks?: Json[] }).blocks)) {
return ((content as { blocks?: Json[] }).blocks ?? []) as Json[];
}
return [];
};
/**
* 将新的块数组写回内容,保持原有结构额外字段。
*/
export const composeContentWithBlocks = (content: unknown, blocks: Json[]): Json => {
if (Array.isArray(content)) {
return blocks as Json;
}
if (content && typeof content === "object") {
return {
...(content as Record<string, unknown>),
blocks,
} as Json;
}
return { blocks } as Json;
};
+63
View File
@@ -0,0 +1,63 @@
export interface DocumentRecord {
access_scope: "private" | "shared" | "public";
id: string;
workspace_id: string;
title: string | null;
parent_id: string | null;
sort_order: number | null;
is_starred: boolean | null;
is_template: boolean;
created_at: string;
updated_at: string | null;
}
export interface DocumentNode extends DocumentRecord {
children: DocumentNode[];
}
export function buildDocumentTree(records: DocumentRecord[]): DocumentNode[] {
const nodeMap = new Map<string, DocumentNode>();
records.forEach((record) => {
nodeMap.set(record.id, { ...record, children: [] });
});
const roots: DocumentNode[] = [];
records.forEach((record) => {
const node = nodeMap.get(record.id);
if (!node) return;
if (record.parent_id && nodeMap.has(record.parent_id)) {
nodeMap.get(record.parent_id)!.children.push(node);
} else {
roots.push(node);
}
});
const sortTree = (nodes: DocumentNode[]) => {
nodes.sort((a, b) => {
const orderA = a.sort_order ?? Number.MAX_SAFE_INTEGER;
const orderB = b.sort_order ?? Number.MAX_SAFE_INTEGER;
if (orderA !== orderB) {
return orderA - orderB;
}
return new Date(a.created_at).getTime() - new Date(b.created_at).getTime();
});
nodes.forEach((child) => sortTree(child.children));
};
sortTree(roots);
return roots;
}
export function findBreadcrumb(records: DocumentRecord[], targetId: string): DocumentRecord[] {
const map = new Map<string, DocumentRecord>();
records.forEach((item) => map.set(item.id, item));
const path: DocumentRecord[] = [];
let current: DocumentRecord | undefined = map.get(targetId);
while (current) {
path.unshift(current);
current = current.parent_id ? map.get(current.parent_id) : undefined;
}
return path;
}
@@ -0,0 +1,14 @@
export async function recordRecentPage(workspaceId: string | null, documentId: string) {
if (!workspaceId) {
return;
}
try {
await fetch("/api/search/recent", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ workspaceId, documentId }),
});
} catch (error) {
console.error("recordRecentPage failed", error);
}
}
@@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import { buildSearchRequest } from "./request";
describe("buildSearchRequest", () => {
it("combines标题过滤与时间范围", () => {
const payload = buildSearchRequest({
workspaceId: "ws-1",
activeDocumentId: "doc-1",
query: "demo",
filters: {
titleOnly: true,
exact: false,
onlyCurrentPage: false,
includeOcr: false,
timeField: "updated",
customRange: undefined,
},
timeRange: "7d",
});
expect(payload).not.toBeNull();
expect(payload?.filters.titleOnly).toBe(true);
expect(payload?.filters.timeRange).toBe("7d");
});
it("自动重置仅当前页面过滤条件", () => {
const payload = buildSearchRequest({
workspaceId: "ws-2",
activeDocumentId: null,
query: "",
filters: {
titleOnly: false,
exact: false,
onlyCurrentPage: true,
includeOcr: false,
timeField: "created",
customRange: { from: "2025-02-01", to: "2025-02-07" },
},
timeRange: "any",
});
expect(payload?.filters.onlyCurrentPage).toBe(false);
expect(payload?.documentId).toBeUndefined();
});
});
+38
View File
@@ -0,0 +1,38 @@
import type {
DocumentSearchFilters,
DocumentSearchRequest,
DocumentSearchTimeRange,
} from "@/types/search";
interface BuildSearchRequestArgs {
workspaceId: string | null;
activeDocumentId: string | null;
query: string;
filters: Omit<DocumentSearchFilters, "timeRange">;
timeRange: DocumentSearchTimeRange;
}
export function buildSearchRequest({
workspaceId,
activeDocumentId,
query,
filters,
timeRange,
}: BuildSearchRequestArgs): DocumentSearchRequest | null {
if (!workspaceId) {
return null;
}
const normalizedFilters: DocumentSearchFilters = {
...filters,
timeRange,
onlyCurrentPage: filters.onlyCurrentPage && Boolean(activeDocumentId),
};
return {
workspaceId,
documentId: normalizedFilters.onlyCurrentPage ? activeDocumentId ?? undefined : undefined,
query,
filters: normalizedFilters,
};
}
@@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest";
import { resolveSearchOpenMode } from "./shortcuts";
describe("resolveSearchOpenMode", () => {
it("Ctrl/Cmd+Enter 打开新窗口", () => {
expect(resolveSearchOpenMode({ ctrlKey: true })).toBe("new-window");
expect(resolveSearchOpenMode({ metaKey: true, altKey: true })).toBe("new-window");
});
it("Alt+Enter 打开右侧预览", () => {
expect(resolveSearchOpenMode({ altKey: true })).toBe("sidebar");
});
it("默认回车在当前窗口打开", () => {
expect(resolveSearchOpenMode({})).toBe("main");
});
});
@@ -0,0 +1,17 @@
export type SearchOpenMode = "main" | "new-window" | "sidebar";
interface ShortcutLikeEvent {
metaKey?: boolean;
ctrlKey?: boolean;
altKey?: boolean;
}
export const resolveSearchOpenMode = (event: ShortcutLikeEvent): SearchOpenMode => {
if (event.metaKey || event.ctrlKey) {
return "new-window";
}
if (event.altKey) {
return "sidebar";
}
return "main";
};
@@ -0,0 +1,14 @@
import { describe, expect, it } from "vitest";
import { buildSnippet } from "./snippet";
describe("buildSnippet", () => {
it("命中关键字时高亮 OCR 片段", () => {
const snippet = buildSnippet("图像 OCR 测试文本,用于高亮", "OCR");
expect(snippet).toContain("<mark>OCR</mark>");
});
it("无关键字时返回截断文本", () => {
const snippet = buildSnippet("一段很长的文字用于测试截断逻辑", null);
expect(snippet.endsWith("…") || snippet.length <= 120).toBe(true);
});
});
+24
View File
@@ -0,0 +1,24 @@
const escapeHtml = (value: string): string =>
value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
const escapeRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
export const buildSnippet = (text: string | null, keyword: string | null): string => {
const source = (text ?? "").trim();
if (!source) {
return "暂无正文内容";
}
const safeSource = escapeHtml(source);
if (!keyword) {
return `${safeSource.slice(0, 120)}${safeSource.length > 120 ? "…" : ""}`;
}
const pattern = new RegExp(escapeRegExp(keyword), "gi");
const match = pattern.exec(safeSource);
if (!match) {
return `${safeSource.slice(0, 120)}${safeSource.length > 120 ? "…" : ""}`;
}
const start = Math.max(0, match.index - 20);
const end = Math.min(safeSource.length, match.index + keyword.length + 80);
const segment = safeSource.slice(start, end);
return segment.replace(pattern, (found) => `<mark>${found}</mark>`);
};
+46
View File
@@ -0,0 +1,46 @@
"use server";
import { cookies } from "next/headers";
type RequestCookies = Awaited<ReturnType<typeof cookies>>;
const decodeValue = (value?: string) => {
if (!value) return value;
return value.startsWith("base64-") ? Buffer.from(value.slice(7), "base64").toString("utf8") : value;
};
const encodeValue = (value: string) => {
if (!value) return value;
// Next.js 会自动 base64 编码,我们只需处理已有 base64 前缀的情况
if (value.startsWith("base64-")) {
return value;
}
return value;
};
const wrapCookies = (store: RequestCookies) => {
return {
get: (name: string) => {
const cookie = store.get(name);
if (!cookie) return cookie;
return { ...cookie, value: decodeValue(cookie.value) };
},
getAll: (...args: Parameters<RequestCookies["getAll"]>) =>
store.getAll(...args).map((cookie) => ({ ...cookie, value: decodeValue(cookie.value) })),
set: (...args: Parameters<RequestCookies["set"]>) => {
const [name, value, options] = args;
if (typeof value === "string") {
store.set(name, encodeValue(value), options);
} else {
store.set(name, value);
}
},
delete: (...args: Parameters<RequestCookies["delete"]>) => store.delete(...args),
has: (...args: Parameters<RequestCookies["has"]>) => store.has(...args),
};
};
export const getDecodedCookies = async () => {
const store = await cookies();
return wrapCookies(store) as RequestCookies;
};
+141
View File
@@ -0,0 +1,141 @@
import type { SupabaseClient } from "@supabase/supabase-js";
import type { DocumentRecord, DocumentNode } from "@/lib/documents";
import type { SidebarSectionId, TrashRecord } from "@/components/sidebar/types";
import type { Database } from "@/types/supabase";
import { buildDocumentTree } from "@/lib/documents";
type TypedClient = SupabaseClient<Database>;
export interface SidebarSectionSnapshot {
id: SidebarSectionId;
title: string;
icon?: string;
nodes: DocumentNode[];
}
export interface FlattenedTreeNode {
node: DocumentNode;
depth: number;
parentId: string | null;
}
const SECTION_META: Record<SidebarSectionId, { title: string; icon: string }> = {
starred: { title: "星标置顶", icon: "star" },
public: { title: "公共页面", icon: "globe" },
shared: { title: "共享页面", icon: "users" },
private: { title: "私有 / 我的页面", icon: "lock" },
templates: { title: "模板中心", icon: "grid" },
};
export async function fetchSidebarDataset(
client: TypedClient,
workspaceId: string,
): Promise<{ documents: DocumentRecord[]; trashedDocuments: TrashRecord[] }> {
const { data: documentRows, error } = await client
.from("documents")
.select(
"id,title,parent_id,sort_order,is_starred,access_scope,is_template,created_at,updated_at,workspace_id",
)
.eq("workspace_id", workspaceId)
.is("deleted_at", null)
.order("sort_order", { ascending: true, nullsFirst: false })
.order("created_at", { ascending: true });
if (error) {
throw new Error(`获取工作空间文档列表失败:${error.message}`);
}
const documents: DocumentRecord[] = (documentRows ?? []).map((row) => ({
access_scope: (row.access_scope ?? "private") as DocumentRecord["access_scope"],
id: row.id,
workspace_id: row.workspace_id,
title: row.title ?? "无标题",
parent_id: row.parent_id,
sort_order: row.sort_order,
is_starred: row.is_starred,
is_template: row.is_template ?? false,
created_at: row.created_at ?? "",
updated_at: row.updated_at ?? null,
}));
const { data: trashRows, error: trashError } = await client
.from("documents")
.select("id,title,parent_id,deleted_at,access_scope")
.eq("workspace_id", workspaceId)
.not("deleted_at", "is", null)
.order("deleted_at", { ascending: false })
.limit(100);
if (trashError) {
throw new Error(`获取垃圾桶内容失败:${trashError.message}`);
}
const trashedDocuments: TrashRecord[] = (trashRows ?? []).map((row) => ({
id: row.id,
title: row.title,
parent_id: row.parent_id,
deleted_at: row.deleted_at!,
access_scope: (row.access_scope ?? "private") as DocumentRecord["access_scope"],
}));
return {
documents,
trashedDocuments,
};
}
type NodePredicate = (node: DocumentNode) => boolean;
function projectTree(nodes: DocumentNode[], predicate: NodePredicate): DocumentNode[] {
const result: DocumentNode[] = [];
nodes.forEach((node) => {
const projectedChildren = projectTree(node.children, predicate);
if (predicate(node)) {
result.push({
...node,
children: projectedChildren,
});
} else {
result.push(...projectedChildren);
}
});
return result;
}
export function flattenDocumentTree(
nodes: DocumentNode[],
expanded: Set<string>,
depth = 0,
parentId: string | null = null,
): FlattenedTreeNode[] {
const list: FlattenedTreeNode[] = [];
nodes.forEach((node) => {
list.push({ node, depth, parentId });
if (node.children.length > 0 && expanded.has(node.id)) {
list.push(...flattenDocumentTree(node.children, expanded, depth + 1, node.id));
}
});
return list;
}
export function buildSidebarSections(records: DocumentRecord[]): SidebarSectionSnapshot[] {
const tree = buildDocumentTree(records);
return buildSidebarSectionsFromTree(tree);
}
export function buildSidebarSectionsFromTree(tree: DocumentNode[]): SidebarSectionSnapshot[] {
const sections: Array<{ id: SidebarSectionId; predicate: NodePredicate }> = [
{ id: "starred", predicate: (node) => Boolean(node.is_starred) },
{ id: "public", predicate: (node) => node.access_scope === "public" },
{ id: "shared", predicate: (node) => node.access_scope === "shared" },
{ id: "private", predicate: (node) => node.access_scope === "private" },
{ id: "templates", predicate: (node) => node.is_template },
];
return sections.map(({ id, predicate }) => ({
id,
title: SECTION_META[id].title,
icon: SECTION_META[id].icon,
nodes: projectTree(tree, predicate),
}));
}
+13
View File
@@ -0,0 +1,13 @@
import { createClientComponentClient } from "@supabase/auth-helpers-nextjs";
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
if (!supabaseUrl || !supabaseAnonKey) {
throw new Error("缺少 Supabase 环境变量,请在 .env.local 配置 NEXT_PUBLIC_SUPABASE_URL 与 NEXT_PUBLIC_SUPABASE_ANON_KEY");
}
export const supabaseBrowser = createClientComponentClient({
supabaseUrl,
supabaseKey: supabaseAnonKey,
});

Some files were not shown because too many files have changed in this diff Show More