chore: remove luckysheet integration
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
export default function LoadingDocument() {
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-4 px-12 py-8">
|
||||
<Skeleton className="h-9 w-1/3" />
|
||||
<Skeleton className="h-4 w-64" />
|
||||
<Skeleton className="h-[400px] w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { createSupabaseServerClient } from "@/lib/supabase/server";
|
||||
import { DocumentShell } from "@/components/editor/document-shell";
|
||||
import type { PageOptionsState, DocumentStats } from "@/types/page-options";
|
||||
|
||||
interface DocumentPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function DocumentPage({ params }: DocumentPageProps) {
|
||||
const { id } = await params;
|
||||
const supabase = await createSupabaseServerClient();
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
|
||||
if (!session) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
const { data: document } = await supabase
|
||||
.from("documents")
|
||||
.select(
|
||||
"id,title,content,updated_at,workspace_id,wide_layout,use_small_text,show_heading_numbers,show_toc,show_structure,protect_editing,show_word_count,word_count,character_count,block_count",
|
||||
)
|
||||
.eq("user_id", session.user.id)
|
||||
.eq("id", id)
|
||||
.single();
|
||||
|
||||
if (!document) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const initialOptions: PageOptionsState = {
|
||||
wideLayout: document.wide_layout ?? false,
|
||||
smallText: document.use_small_text ?? false,
|
||||
showHeadingNumbers: document.show_heading_numbers ?? true,
|
||||
showToc: document.show_toc ?? false,
|
||||
showStructure: document.show_structure ?? false,
|
||||
protectEditing: document.protect_editing ?? false,
|
||||
showWordCount: document.show_word_count ?? true,
|
||||
};
|
||||
|
||||
const initialStats: DocumentStats = {
|
||||
wordCount: document.word_count ?? 0,
|
||||
characterCount: document.character_count ?? 0,
|
||||
blockCount: document.block_count ?? 0,
|
||||
};
|
||||
|
||||
return (
|
||||
<DocumentShell
|
||||
documentId={document.id}
|
||||
workspaceId={document.workspace_id}
|
||||
title={document.title}
|
||||
updatedAt={document.updated_at}
|
||||
initialContent={document.content}
|
||||
initialOptions={initialOptions}
|
||||
initialStats={initialStats}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import type { ReactNode } from "react";
|
||||
import { Sidebar } from "@/components/sidebar/sidebar";
|
||||
import { Breadcrumb } from "@/components/breadcrumb";
|
||||
import { BottomToolbar } from "@/components/bottom-toolbar";
|
||||
import { MobileSidebarTrigger } from "@/components/mobile-sidebar-trigger";
|
||||
import type { DocumentRecord } from "@/lib/documents";
|
||||
import { createSupabaseServerClient } from "@/lib/supabase/server";
|
||||
import { ensureDefaultWorkspace, fetchWorkspaceSummaries } from "@/lib/workspaces";
|
||||
import { fetchSidebarDataset } from "@/lib/sidebar-tree";
|
||||
import type { SidebarInitialData } from "@/components/sidebar/types";
|
||||
import { SearchPalette } from "@/components/search/search-palette";
|
||||
|
||||
export default async function AppLayout({ children }: { children: ReactNode }) {
|
||||
const supabase = await createSupabaseServerClient();
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
|
||||
if (!session) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
await ensureDefaultWorkspace(supabase, session.user.id, session.user.email ?? "我的空间");
|
||||
const { workspaces, activeWorkspaceId } = await fetchWorkspaceSummaries(supabase, session.user.id);
|
||||
|
||||
let documents: DocumentRecord[] = [];
|
||||
let sidebarInitialData: SidebarInitialData | null = null;
|
||||
|
||||
if (activeWorkspaceId) {
|
||||
const dataset = await fetchSidebarDataset(supabase, activeWorkspaceId);
|
||||
documents = dataset.documents;
|
||||
sidebarInitialData = {
|
||||
activeWorkspaceId,
|
||||
workspaces,
|
||||
documents: dataset.documents,
|
||||
trashedDocuments: dataset.trashedDocuments,
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-white">
|
||||
{sidebarInitialData && <Sidebar initialData={sidebarInitialData} />}
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<header className="flex h-10 items-center gap-4 border-b border-[#eeeeee] px-4">
|
||||
<MobileSidebarTrigger />
|
||||
<Breadcrumb documents={documents} />
|
||||
</header>
|
||||
<main className="flex-1 overflow-hidden bg-white">{children}</main>
|
||||
<BottomToolbar />
|
||||
<SearchPalette workspaceId={sidebarInitialData?.activeWorkspaceId ?? null} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { supabaseBrowser } from "@/lib/supabase/client";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const [email, setEmail] = useState("test@163.com");
|
||||
const [password, setPassword] = useState("123456");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [message, setMessage] = useState<string>("");
|
||||
const [sessionInfo, setSessionInfo] = useState<string>("");
|
||||
|
||||
const handleLogin = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setMessage("");
|
||||
const { data, error } = await supabaseBrowser.auth.signInWithPassword({
|
||||
email,
|
||||
password,
|
||||
});
|
||||
setLoading(false);
|
||||
if (error) {
|
||||
setMessage(`登录失败:${error.message}`);
|
||||
return;
|
||||
}
|
||||
setMessage("登录成功,准备跳转...");
|
||||
setSessionInfo(JSON.stringify(data.session, null, 2));
|
||||
router.replace("/");
|
||||
}, [email, password, router]);
|
||||
|
||||
const handleGetSession = useCallback(async () => {
|
||||
setLoading(true);
|
||||
const {
|
||||
data: { session },
|
||||
error,
|
||||
} = await supabaseBrowser.auth.getSession();
|
||||
setLoading(false);
|
||||
if (error) {
|
||||
setMessage(`获取会话失败:${error.message}`);
|
||||
return;
|
||||
}
|
||||
if (!session) {
|
||||
setMessage("当前未登录");
|
||||
setSessionInfo("");
|
||||
return;
|
||||
}
|
||||
setMessage("发现有效 Session");
|
||||
setSessionInfo(JSON.stringify(session, null, 2));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-zinc-50 p-4">
|
||||
<Card className="w-[400px] space-y-4 bg-white/90 shadow-md backdrop-blur">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">Supabase 登录</CardTitle>
|
||||
<p className="text-sm text-zinc-500">测试账号:test@163.com / 123456</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm text-zinc-700">邮箱</label>
|
||||
<Input value={email} onChange={(e) => setEmail(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm text-zinc-700">密码</label>
|
||||
<Input type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button className="flex-1" onClick={handleLogin} disabled={loading}>
|
||||
{loading ? "处理中..." : "登录"}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={handleGetSession} disabled={loading}>
|
||||
会话检测
|
||||
</Button>
|
||||
</div>
|
||||
{message && <p className="whitespace-pre-wrap text-sm text-blue-600">{message}</p>}
|
||||
<Separator />
|
||||
{sessionInfo && (
|
||||
<pre className="max-h-64 overflow-auto rounded-md bg-zinc-900 p-3 text-xs text-zinc-100">
|
||||
{sessionInfo}
|
||||
</pre>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,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 });
|
||||
}
|
||||
+396
-12
@@ -1,26 +1,410 @@
|
||||
@import url("https://rsms.me/inter/inter.css");
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
}
|
||||
@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);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
: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;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
@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);
|
||||
}
|
||||
}
|
||||
|
||||
+19
-16
@@ -1,33 +1,36 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
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 geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
const inter = Inter({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-inter",
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
title: "Wolai Clone",
|
||||
description: "Stage0 + Stage1 implementation powered by Supabase",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
const supabase = await createSupabaseServerClient();
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
|
||||
return (
|
||||
<html lang="en">
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
{children}
|
||||
<html lang="zh-CN">
|
||||
<body className={`${inter.variable} antialiased`}>
|
||||
<SupabaseProvider session={session}>
|
||||
<QueryProvider>{children}</QueryProvider>
|
||||
</SupabaseProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
+39
-63
@@ -1,65 +1,41 @@
|
||||
import Image from "next/image";
|
||||
import { redirect } from "next/navigation";
|
||||
import { createSupabaseServerClient } from "@/lib/supabase/server";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
||||
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={100}
|
||||
height={20}
|
||||
priority
|
||||
/>
|
||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
||||
To get started, edit the page.tsx file.
|
||||
</h1>
|
||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
||||
Looking for a starting point or more instructions? Head over to{" "}
|
||||
<a
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Templates
|
||||
</a>{" "}
|
||||
or the{" "}
|
||||
<a
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Learning
|
||||
</a>{" "}
|
||||
center.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Deploy Now
|
||||
</a>
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Documentation
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
const escapeHtml = (value: string): string =>
|
||||
value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
|
||||
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>`);
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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),
|
||||
}));
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import { createServerComponentClient, createRouteHandlerClient } from "@supabase/auth-helpers-nextjs";
|
||||
import { getDecodedCookies } from "@/lib/server-cookies";
|
||||
|
||||
export const createSupabaseServerClient = async () => {
|
||||
const cookieStore = await getDecodedCookies();
|
||||
return createServerComponentClient({
|
||||
cookies: () => cookieStore,
|
||||
});
|
||||
};
|
||||
|
||||
export const createSupabaseRouteClient = async () => {
|
||||
const cookieStore = await getDecodedCookies();
|
||||
return createRouteHandlerClient({
|
||||
cookies: () => cookieStore,
|
||||
});
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user