chore: init monorepo snapshot

This commit is contained in:
liaibo
2025-11-23 10:55:04 +08:00
commit c70ff52869
941 changed files with 246586 additions and 0 deletions
View File
@@ -0,0 +1,99 @@
import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import { ensureDefaultWorkspace, resolveActiveWorkspaceId } from "@/lib/workspaces";
type CreateChildPayload = {
parentId: string | null;
title?: string;
blocks?: unknown;
};
export const dynamic = "force-dynamic";
export async function POST(request: Request) {
const supabase = await createSupabaseRouteClient();
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) {
return NextResponse.json({ error: "未登录无法创建页面" }, { status: 401 });
}
const { parentId, title, blocks }: CreateChildPayload = await request.json();
if (typeof parentId === "undefined") {
return NextResponse.json({ error: "缺少 parentId" }, { status: 400 });
}
let workspaceId: string;
let accessScope: "private" | "shared" | "public" = "private";
if (parentId) {
const { data: parentDoc, error: parentError } = await supabase
.from("documents")
.select("workspace_id,access_scope")
.eq("id", parentId)
.single();
if (parentError || !parentDoc) {
return NextResponse.json({ error: "父页面不存在" }, { status: 404 });
}
workspaceId = parentDoc.workspace_id;
accessScope = (parentDoc.access_scope ?? "private") as typeof accessScope;
} else {
await ensureDefaultWorkspace(supabase, session.user.id, session.user.email ?? "我的空间");
workspaceId = await resolveActiveWorkspaceId(supabase, session.user.id);
if (!workspaceId) {
return NextResponse.json({ error: "缺少目标工作空间" }, { status: 400 });
}
}
const siblingQuery = supabase
.from("documents")
.select("id", { head: true, count: "exact" })
.eq("workspace_id", workspaceId);
if (parentId) {
siblingQuery.eq("parent_id", parentId);
} else {
siblingQuery.is("parent_id", null);
}
const { count: siblingCount = 0, error: countError } = await siblingQuery;
if (countError) {
return NextResponse.json({ error: countError.message }, { status: 500 });
}
const resolvedTitle =
title && title.trim().length > 0 ? title.trim() : "未命名页面";
const contentPayload = Array.isArray(blocks) ? blocks : [];
const { data, error } = await supabase
.from("documents")
.insert({
user_id: session.user.id,
parent_id: parentId,
workspace_id: workspaceId,
access_scope: accessScope,
title: resolvedTitle,
content: contentPayload,
sort_order: siblingCount,
})
.select("id,title")
.single();
if (error || !data) {
return NextResponse.json(
{ error: error?.message ?? "创建子页面失败" },
{ status: 500 },
);
}
return NextResponse.json({
pageId: data.id,
title: data.title ?? resolvedTitle,
});
}
@@ -0,0 +1,123 @@
import { randomUUID } from "crypto";
import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import { ensureDefaultWorkspace, resolveActiveWorkspaceId } from "@/lib/workspaces";
import type { Json } from "@/types/supabase";
import { composeContentWithBlocks, extractBlocksFromContent } from "@/lib/document-content";
export async function POST(request: Request) {
try {
return await handleCreateRequest(request);
} catch (error) {
console.error("创建页面失败", error);
const message = error instanceof Error ? error.message : "创建页面失败,请稍后再试";
return NextResponse.json({ error: message }, { status: 500 });
}
}
async function handleCreateRequest(request: Request) {
const supabase = await createSupabaseRouteClient();
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const { parentId }: { parentId?: string | null } = await request.json();
await ensureDefaultWorkspace(supabase, session.user.id, session.user.email ?? "我的空间");
let workspaceId: string | null = null;
let parentContent: Json | null = null;
let accessScope: "private" | "shared" | "public" = "private";
if (parentId) {
const { data: parentDoc, error: parentError } = await supabase
.from("documents")
.select("workspace_id,access_scope,content,user_id")
.eq("id", parentId)
.eq("user_id", session.user.id)
.single();
if (parentError || !parentDoc) {
return NextResponse.json({ error: "父页面不存在或无权限" }, { status: 404 });
}
workspaceId = parentDoc.workspace_id;
accessScope = (parentDoc.access_scope ?? "private") as typeof accessScope;
parentContent = parentDoc.content;
} else {
workspaceId = await resolveActiveWorkspaceId(supabase, session.user.id);
if (!workspaceId) {
return NextResponse.json({ error: "缺少目标工作空间" }, { status: 400 });
}
}
if (!workspaceId) {
return NextResponse.json({ error: "缺少目标工作空间" }, { status: 400 });
}
const siblingQuery = supabase
.from("documents")
.select("id", { head: true, count: "exact" })
.eq("workspace_id", workspaceId);
if (parentId) {
siblingQuery.eq("parent_id", parentId);
} else {
siblingQuery.is("parent_id", null);
}
const { count: siblingCount = 0, error: countError } = await siblingQuery;
if (countError) {
return NextResponse.json({ error: countError.message }, { status: 500 });
}
const { data, error } = await supabase
.from("documents")
.insert({
user_id: session.user.id,
parent_id: parentId ?? null,
workspace_id: workspaceId,
title: "无标题",
content: { blocks: [] },
access_scope: accessScope,
sort_order: siblingCount,
})
.select(
"id,title,parent_id,sort_order,is_starred,created_at,updated_at,workspace_id,access_scope,is_template",
)
.single();
if (error) {
return NextResponse.json({ error: error.message }, { status: 400 });
}
if (parentId && data) {
const existingBlocks = extractBlocksFromContent(parentContent);
const pageReferenceBlock = {
id: randomUUID(),
type: "pageReference",
props: {
pageId: data.id,
title: data.title ?? "无标题",
},
};
const nextBlocks = [...existingBlocks, pageReferenceBlock as Json];
const payload = composeContentWithBlocks(parentContent, nextBlocks);
const timestamp = new Date().toISOString();
const { error: parentUpdateError } = await supabase
.from("documents")
.update({ content: payload, updated_at: timestamp })
.eq("id", parentId)
.eq("user_id", session.user.id);
if (parentUpdateError) {
return NextResponse.json({ error: parentUpdateError.message }, { status: 500 });
}
}
return NextResponse.json(data);
}
@@ -0,0 +1,36 @@
import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
export const dynamic = "force-dynamic";
export async function POST(request: Request) {
const supabase = await createSupabaseRouteClient();
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const { documentId } = await request.json();
if (!documentId) {
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
}
const { error } = await supabase
.from("documents")
.update({
deleted_at: new Date().toISOString(),
deleted_by: session.user.id,
})
.eq("id", documentId)
.eq("user_id", session.user.id);
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json({ success: true });
}
@@ -0,0 +1,74 @@
import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
interface DuplicatePayload {
documentId: string;
}
export async function POST(request: Request) {
const supabase = await createSupabaseRouteClient();
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const { documentId }: DuplicatePayload = await request.json();
if (!documentId) {
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
}
const { data: sourceDoc, error: sourceError } = await supabase
.from("documents")
.select("id,title,content,parent_id,workspace_id,access_scope")
.eq("id", documentId)
.eq("user_id", session.user.id)
.single();
if (sourceError || !sourceDoc) {
return NextResponse.json({ error: "页面不存在或无权访问" }, { status: 404 });
}
const siblingQuery = supabase
.from("documents")
.select("id", { head: true, count: "exact" })
.eq("workspace_id", sourceDoc.workspace_id);
if (sourceDoc.parent_id) {
siblingQuery.eq("parent_id", sourceDoc.parent_id);
} else {
siblingQuery.is("parent_id", null);
}
const { count: siblingCount = 0, error: countError } = await siblingQuery;
if (countError) {
return NextResponse.json({ error: countError.message }, { status: 500 });
}
const fallbackTitle = sourceDoc.title?.trim() && sourceDoc.title.trim().length > 0 ? sourceDoc.title.trim() : "无标题";
const duplicatedTitle = `${fallbackTitle} 副本`;
const { data: duplicated, error: duplicateError } = await supabase
.from("documents")
.insert({
user_id: session.user.id,
workspace_id: sourceDoc.workspace_id,
parent_id: sourceDoc.parent_id,
access_scope: sourceDoc.access_scope ?? "private",
title: duplicatedTitle,
content: sourceDoc.content,
sort_order: siblingCount,
})
.select("id,title,parent_id,sort_order")
.single();
if (duplicateError || !duplicated) {
return NextResponse.json({ error: duplicateError?.message ?? "复制失败" }, { status: 500 });
}
return NextResponse.json(duplicated);
}
@@ -0,0 +1,76 @@
import { randomUUID } from "crypto";
import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import type { Json } from "@/types/supabase";
import { composeContentWithBlocks, extractBlocksFromContent } from "@/lib/document-content";
interface EmbedPayload {
sourceId: string;
targetId: string;
}
export async function POST(request: Request) {
const supabase = await createSupabaseRouteClient();
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const { sourceId, targetId }: EmbedPayload = await request.json();
if (!sourceId || !targetId) {
return NextResponse.json({ error: "缺少参数" }, { status: 400 });
}
const { data: sourceDoc, error: sourceError } = await supabase
.from("documents")
.select("id,title,user_id")
.eq("id", sourceId)
.eq("user_id", session.user.id)
.single();
if (sourceError || !sourceDoc) {
return NextResponse.json({ error: "原始页面不存在或无权限" }, { status: 404 });
}
const { data: targetDoc, error: targetError } = await supabase
.from("documents")
.select("id,content,user_id")
.eq("id", targetId)
.eq("user_id", session.user.id)
.single();
if (targetError || !targetDoc) {
return NextResponse.json({ error: "目标页面不存在或无权限" }, { status: 404 });
}
const currentBlocks = extractBlocksFromContent(targetDoc.content);
const nextBlocks: Json[] = [
...currentBlocks,
{
id: randomUUID(),
type: "pageReference",
props: {
pageId: sourceDoc.id,
title: sourceDoc.title ?? "无标题",
},
},
];
const payload: Json = composeContentWithBlocks(targetDoc.content, nextBlocks);
const { error: updateError } = await supabase
.from("documents")
.update({ content: payload })
.eq("id", targetDoc.id)
.eq("user_id", session.user.id);
if (updateError) {
return NextResponse.json({ error: updateError.message }, { status: 500 });
}
return NextResponse.json({ ok: true });
}
@@ -0,0 +1,53 @@
import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
export const dynamic = "force-dynamic";
interface EmptyTrashPayload {
workspaceId?: string;
}
export async function POST(request: Request) {
const supabase = await createSupabaseRouteClient();
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const { workspaceId }: EmptyTrashPayload = await request.json();
if (!workspaceId) {
return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 });
}
const { data: membership, error: membershipError } = await supabase
.from("workspace_members")
.select("workspace_id")
.eq("workspace_id", workspaceId)
.eq("user_id", session.user.id)
.limit(1);
if (membershipError) {
return NextResponse.json({ error: membershipError.message }, { status: 500 });
}
if (!membership || membership.length === 0) {
return NextResponse.json({ error: "无权操作该工作空间" }, { status: 403 });
}
const { error } = await supabase
.from("documents")
.delete()
.eq("workspace_id", workspaceId)
.eq("user_id", session.user.id)
.not("deleted_at", "is", null);
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json({ success: true });
}
@@ -0,0 +1,37 @@
import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
interface MovePayload {
documentId: string;
parentId?: string | null;
position: number;
}
export async function POST(request: Request) {
const supabase = await createSupabaseRouteClient();
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const { documentId, parentId = null, position }: MovePayload = await request.json();
const sortOrder = Number.isFinite(position) ? Math.floor(position) : 0;
const { error } = await supabase
.from("documents")
.update({
parent_id: parentId,
sort_order: sortOrder,
})
.eq("id", documentId)
.eq("user_id", session.user.id);
if (error) {
return NextResponse.json({ error: error.message }, { status: 400 });
}
return NextResponse.json({ ok: true });
}
@@ -0,0 +1,61 @@
import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import type { PageOptionsState } from "@/types/page-options";
import type { Database } from "@/types/supabase";
type OptionsPayload = {
documentId: string;
options: Partial<PageOptionsState>;
};
const COLUMN_MAP: Record<keyof PageOptionsState, keyof Database["public"]["Tables"]["documents"]["Row"]> = {
wideLayout: "wide_layout",
smallText: "use_small_text",
showHeadingNumbers: "show_heading_numbers",
showToc: "show_toc",
showStructure: "show_structure",
protectEditing: "protect_editing",
showWordCount: "show_word_count",
};
export async function POST(request: Request) {
const supabase = await createSupabaseRouteClient();
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const { documentId, options }: OptionsPayload = await request.json();
if (!documentId || !options) {
return NextResponse.json({ error: "缺少参数" }, { status: 400 });
}
const updatePayload: Record<string, boolean> = {};
(Object.keys(options) as (keyof PageOptionsState)[]).forEach((key) => {
const column = COLUMN_MAP[key];
if (!column) return;
const value = options[key];
if (typeof value === "boolean") {
updatePayload[column as string] = value;
}
});
if (Object.keys(updatePayload).length === 0) {
return NextResponse.json({ error: "缺少可更新的选项" }, { status: 400 });
}
const { error } = await supabase
.from("documents")
.update(updatePayload)
.eq("id", documentId)
.eq("user_id", session.user.id);
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json({ ok: true });
}
@@ -0,0 +1,33 @@
import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
export const dynamic = "force-dynamic";
export async function POST(request: Request) {
const supabase = await createSupabaseRouteClient();
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const { documentId } = await request.json();
if (!documentId) {
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
}
const { error } = await supabase
.from("documents")
.delete()
.eq("id", documentId)
.eq("user_id", session.user.id);
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json({ success: true });
}
@@ -0,0 +1,38 @@
import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
export const dynamic = "force-dynamic";
export async function POST(request: Request) {
const supabase = await createSupabaseRouteClient();
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const { documentId } = await request.json();
if (!documentId) {
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
}
const { error } = await supabase
.from("documents")
.update({
deleted_at: null,
deleted_by: null,
parent_id: null,
access_scope: "private",
})
.eq("id", documentId)
.eq("user_id", session.user.id);
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json({ success: true });
}
@@ -0,0 +1,32 @@
import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
interface SavePayload {
documentId: string;
content: unknown;
}
export async function POST(request: Request) {
const supabase = await createSupabaseRouteClient();
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const { documentId, content }: SavePayload = await request.json();
const { error } = await supabase
.from("documents")
.update({ content })
.eq("id", documentId)
.eq("user_id", session.user.id);
if (error) {
return NextResponse.json({ error: error.message }, { status: 400 });
}
return NextResponse.json({ ok: true });
}
@@ -0,0 +1,40 @@
import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import type { DocumentStats } from "@/types/page-options";
interface StatsPayload {
documentId: string;
stats: DocumentStats;
}
export async function POST(request: Request) {
const supabase = await createSupabaseRouteClient();
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const { documentId, stats }: StatsPayload = await request.json();
if (!documentId || !stats) {
return NextResponse.json({ error: "缺少参数" }, { status: 400 });
}
const { error } = await supabase
.from("documents")
.update({
word_count: stats.wordCount,
character_count: stats.characterCount,
block_count: stats.blockCount,
})
.eq("id", documentId)
.eq("user_id", session.user.id);
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json({ ok: true });
}
@@ -0,0 +1,32 @@
import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
interface RenamePayload {
documentId: string;
title: string;
}
export async function POST(request: Request) {
const supabase = await createSupabaseRouteClient();
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const { documentId, title }: RenamePayload = await request.json();
const { error } = await supabase
.from("documents")
.update({ title })
.eq("id", documentId)
.eq("user_id", session.user.id);
if (error) {
return NextResponse.json({ error: error.message }, { status: 400 });
}
return NextResponse.json({ ok: true });
}
@@ -0,0 +1,92 @@
import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import type { MediaAsset } from "@/types/media";
export const dynamic = "force-dynamic";
export async function GET(request: Request) {
const supabase = await createSupabaseRouteClient();
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const workspaceId = searchParams.get("workspaceId");
const limit = Number.parseInt(searchParams.get("limit") ?? "12", 10);
if (!workspaceId) {
return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 });
}
let query = supabase
.from("media_assets")
.select("*")
.eq("workspace_id", workspaceId)
.order("created_at", { ascending: false })
.limit(Number.isNaN(limit) ? 12 : limit);
const assetType = searchParams.get("assetType");
if (assetType) {
query = query.eq("asset_type", assetType);
}
const { data, error } = await query;
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json({ items: (data ?? []) as MediaAsset[] });
}
export async function POST(request: Request) {
const supabase = await createSupabaseRouteClient();
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const payload = (await request.json()) as {
workspaceId: string;
documentId: string;
fileUrl: string;
thumbnailUrl?: string;
assetType?: string;
fileName?: string;
fileSize?: number;
mimeType?: string;
};
if (!payload.workspaceId || !payload.documentId || !payload.fileUrl) {
return NextResponse.json({ error: "参数不完整" }, { status: 400 });
}
const { data, error } = await supabase
.from("media_assets")
.insert({
workspace_id: payload.workspaceId,
document_id: payload.documentId,
file_url: payload.fileUrl,
thumbnail_url: payload.thumbnailUrl ?? payload.fileUrl,
asset_type: payload.assetType ?? "image",
file_name: payload.fileName,
file_size: payload.fileSize ?? null,
mime_type: payload.mimeType ?? null,
created_by: session.user.id,
})
.select("*")
.single();
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json({ asset: data as MediaAsset });
}
@@ -0,0 +1,43 @@
import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
export async function POST(request: Request) {
const supabase = await createSupabaseRouteClient();
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const { assetId } = (await request.json()) as { assetId?: string };
if (!assetId) {
return NextResponse.json({ error: "缺少 assetId" }, { status: 400 });
}
const { error } = await supabase
.from("media_assets")
.update({ ocr_status: "processing" })
.eq("id", assetId)
.limit(1);
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
const backendUrl = process.env.NEXT_PUBLIC_BACKEND_URL;
if (backendUrl) {
void fetch(`${backendUrl}/api/v1/tasks/media-ocr`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ asset_id: assetId }),
}).catch((err) => {
console.warn("触发后端 OCR 失败", err);
});
}
return NextResponse.json({ ok: true });
}
@@ -0,0 +1,82 @@
import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import type { MediaAsset } from "@/types/media";
import { extname } from "path";
export const dynamic = "force-dynamic";
const MEDIA_BUCKET = process.env.NEXT_PUBLIC_SUPABASE_MEDIA_BUCKET ?? "media";
const resolveAssetType = (mime: string): "image" | "video" | "audio" | "file" => {
if (mime.startsWith("image/")) return "image";
if (mime.startsWith("video/")) return "video";
if (mime.startsWith("audio/")) return "audio";
return "file";
};
export async function POST(request: Request) {
const supabase = await createSupabaseRouteClient();
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const formData = await request.formData();
const file = formData.get("file");
const workspaceId = String(formData.get("workspaceId") ?? "");
const documentId = String(formData.get("documentId") ?? "");
if (!(file instanceof File) || !workspaceId || !documentId) {
return NextResponse.json({ error: "缺少必要参数" }, { status: 400 });
}
try {
const arrayBuffer = await file.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
const extension = extname(file.name || "").replace(/\s+/g, "");
const uniqueId = typeof crypto.randomUUID === "function" ? crypto.randomUUID() : Math.random().toString(36).slice(2);
const path = `${workspaceId}/${Date.now()}-${uniqueId}${extension}`;
const assetType = resolveAssetType(file.type || "");
const { error: uploadError } = await supabase.storage.from(MEDIA_BUCKET).upload(path, buffer, {
contentType: file.type,
upsert: false,
});
if (uploadError) {
return NextResponse.json({ error: uploadError.message }, { status: 500 });
}
const {
data: { publicUrl },
} = supabase.storage.from(MEDIA_BUCKET).getPublicUrl(path);
const { data: asset, error } = await supabase
.from("media_assets")
.insert({
workspace_id: workspaceId,
document_id: documentId,
file_url: publicUrl,
thumbnail_url: publicUrl,
asset_type: assetType,
file_name: file.name,
file_size: file.size,
mime_type: file.type,
created_by: session.user.id,
})
.select("*")
.single();
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json({ asset: asset as MediaAsset });
} catch (error) {
console.error(error);
return NextResponse.json({ error: "上传失败" }, { status: 500 });
}
}
@@ -0,0 +1,50 @@
import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
export async function GET(request: Request) {
const supabase = await createSupabaseRouteClient();
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const workspaceId = searchParams.get("workspaceId");
const pageId = searchParams.get("pageId");
const limit = Number(searchParams.get("limit") ?? "50");
const offset = Number(searchParams.get("offset") ?? "0");
if (!workspaceId || !pageId) {
return NextResponse.json({ error: "缺少 workspaceId 或 pageId" }, { status: 400 });
}
const { data: membership, error: membershipError } = await supabase
.from("workspace_members")
.select("workspace_id")
.eq("workspace_id", workspaceId)
.eq("user_id", session.user.id)
.maybeSingle();
if (membershipError) {
return NextResponse.json({ error: membershipError.message }, { status: 500 });
}
if (!membership) {
return NextResponse.json({ error: "无权访问该工作空间" }, { status: 403 });
}
const { data, error } = await supabase.rpc("list_backlinks", {
p_workspace_id: workspaceId,
p_target_page_id: pageId,
p_limit: Number.isFinite(limit) ? limit : 50,
p_offset: Number.isFinite(offset) ? offset : 0,
});
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json({ backlinks: data ?? [] });
}
@@ -0,0 +1,68 @@
import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
type DisplayMode = "inline" | "embed";
interface RecordReferencePayload {
workspaceId: string;
sourcePageId: string;
targetPageId: string;
sourceBlockId?: string | null;
alias?: string | null;
displayMode: DisplayMode;
isPreviewable?: boolean;
}
const isValidDisplayMode = (mode: string): mode is DisplayMode => mode === "inline" || mode === "embed";
export async function POST(request: Request) {
const supabase = await createSupabaseRouteClient();
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const body = (await request.json()) as RecordReferencePayload;
const { workspaceId, sourcePageId, targetPageId, sourceBlockId, alias, displayMode, isPreviewable = true } = body;
if (!workspaceId || !sourcePageId || !targetPageId) {
return NextResponse.json({ error: "缺少必要参数" }, { status: 400 });
}
if (!isValidDisplayMode(displayMode)) {
return NextResponse.json({ error: "非法的引用模式" }, { status: 400 });
}
const { data: membership, error: membershipError } = await supabase
.from("workspace_members")
.select("workspace_id")
.eq("workspace_id", workspaceId)
.eq("user_id", session.user.id)
.maybeSingle();
if (membershipError) {
return NextResponse.json({ error: membershipError.message }, { status: 500 });
}
if (!membership) {
return NextResponse.json({ error: "无权访问该工作空间" }, { status: 403 });
}
const { data, error } = await supabase.rpc("record_page_ref", {
p_workspace_id: workspaceId,
p_source_page_id: sourcePageId,
p_source_block_id: sourceBlockId ?? null,
p_target_page_id: targetPageId,
p_alias: alias ?? null,
p_display_mode: displayMode,
p_is_previewable: isPreviewable,
p_created_by: session.user.id,
});
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json({ reference: data });
}
@@ -0,0 +1,303 @@
import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import type {
DocumentSearchFilters,
DocumentSearchRequest,
DocumentSearchResponse,
DocumentSearchResult,
DocumentSearchTimeRange,
} from "@/types/search";
import { buildSnippet } from "@/lib/search/snippet";
const MAX_LIMIT = 50;
const DEFAULT_FILTERS: DocumentSearchFilters = {
titleOnly: false,
exact: false,
onlyCurrentPage: false,
includeOcr: false,
timeRange: "any",
timeField: "updated",
};
const TIME_RANGE_TO_MS: Record<DocumentSearchTimeRange, number | null> = {
any: null,
"7d": 1000 * 60 * 60 * 24 * 7,
"30d": 1000 * 60 * 60 * 24 * 30,
};
const TIME_FIELD_COLUMN = {
updated: "updated_at",
created: "created_at",
} as const;
const escapeLike = (value: string): string => value.replace(/[%_\\]/g, (match) => `\\${match}`);
interface DocumentRow {
id: string;
title: string | null;
raw_text: string | null;
updated_at: string | null;
created_at: string | null;
}
const buildIsoBoundary = (value: string, isEnd = false): string | null => {
if (!value) {
return null;
}
const normalized = value.trim();
if (!normalized) {
return null;
}
const suffix = isEnd ? "T23:59:59.999Z" : "T00:00:00.000Z";
const date = new Date(`${normalized}${suffix}`);
if (Number.isNaN(date.getTime())) {
return null;
}
return date.toISOString();
};
const mapDocumentToResult = (
row: DocumentRow,
keyword: string | null,
forcedMatch?: DocumentSearchResult["matchField"],
): DocumentSearchResult => {
const normalizedKeyword = keyword?.trim() ?? "";
const normalizedTitle = row.title ?? "无标题";
let matchField: DocumentSearchResult["matchField"] = "recent";
if (forcedMatch) {
matchField = forcedMatch;
} else if (normalizedKeyword) {
const matchesTitle = normalizedTitle.toLowerCase().includes(normalizedKeyword.toLowerCase());
matchField = matchesTitle ? "title" : "content";
}
return {
id: row.id,
title: normalizedTitle,
snippet: buildSnippet(row.raw_text, normalizedKeyword || null),
updatedAt: row.updated_at,
createdAt: row.created_at,
matchField,
hasOcr: Boolean(row.raw_text),
publicPath: `/documents/${row.id}`,
score: matchField === "title" ? 2 : 1,
};
};
type RouteSupabaseClient = Awaited<ReturnType<typeof createSupabaseRouteClient>>;
const fetchRecentResults = async (
supabase: RouteSupabaseClient,
userId: string,
workspaceId: string,
): Promise<DocumentSearchResult[]> => {
const { data: recentRows, error: recentError } = await supabase
.from("user_recent_pages")
.select("document_id,last_accessed_at")
.eq("user_id", userId)
.eq("workspace_id", workspaceId)
.order("last_accessed_at", { ascending: false })
.limit(10);
if (recentError || !recentRows || recentRows.length === 0) {
return [];
}
const documentIds = recentRows.map((row) => row.document_id);
const { data: docRows, error: docsError } = await supabase
.from("documents")
.select("id,title,updated_at,created_at,raw_text")
.in("id", documentIds)
.is("deleted_at", null);
if (docsError || !docRows) {
return [];
}
const docMap = new Map(docRows.map((row) => [row.id, row]));
return recentRows
.map((row) => docMap.get(row.document_id))
.filter((row): row is DocumentRow => Boolean(row))
.map((row) => mapDocumentToResult(row, null, "recent"));
};
const fetchOcrMatches = async (
supabase: RouteSupabaseClient,
workspaceId: string,
likePattern: string,
limit: number,
) => {
const { data, error } = await supabase
.from("media_assets")
.select("document_id,ocr_text")
.eq("workspace_id", workspaceId)
.not("ocr_text", "is", null)
.ilike("ocr_text", likePattern)
.limit(limit);
if (error || !data) {
return [];
}
return data;
};
export async function POST(request: Request) {
const supabase = await createSupabaseRouteClient();
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const payload = (await request.json()) as DocumentSearchRequest;
const workspaceId = payload.workspaceId;
if (!workspaceId) {
return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 });
}
const filters: DocumentSearchFilters = {
...DEFAULT_FILTERS,
...payload.filters,
};
const { data: membership, error: membershipError } = await supabase
.from("workspace_members")
.select("workspace_id")
.eq("workspace_id", workspaceId)
.eq("user_id", session.user.id)
.limit(1);
if (membershipError) {
return NextResponse.json({ error: membershipError.message }, { status: 500 });
}
if (!membership || membership.length === 0) {
return NextResponse.json({ error: "无权访问该工作空间" }, { status: 403 });
}
if (filters.onlyCurrentPage && !payload.documentId) {
filters.onlyCurrentPage = false;
}
const limit = Math.min(payload.limit ?? 30, MAX_LIMIT);
const normalizedQuery = payload.query?.trim() ?? "";
const likePattern = filters.exact ? normalizedQuery : `%${escapeLike(normalizedQuery)}%`;
const timeColumn = TIME_FIELD_COLUMN[filters.timeField ?? "updated"];
let builder = supabase
.from("documents")
.select("id,title,updated_at,created_at,raw_text,is_starred,access_scope")
.eq("workspace_id", workspaceId)
.is("deleted_at", null)
.order(timeColumn, { ascending: false })
.limit(limit);
if (filters.onlyCurrentPage && payload.documentId) {
builder = builder.eq("id", payload.documentId);
}
const customFrom = filters.customRange?.from ? buildIsoBoundary(filters.customRange.from, false) : null;
const customTo = filters.customRange?.to ? buildIsoBoundary(filters.customRange.to, true) : null;
if (customFrom) {
builder = builder.gte(timeColumn, customFrom);
}
if (customTo) {
builder = builder.lte(timeColumn, customTo);
}
if (!customFrom && !customTo) {
const now = Date.now();
const offset = TIME_RANGE_TO_MS[filters.timeRange];
if (offset) {
const from = new Date(now - offset).toISOString();
builder = builder.gte(timeColumn, from);
}
}
if (normalizedQuery) {
if (filters.titleOnly) {
builder = filters.exact ? builder.eq("title", normalizedQuery) : builder.ilike("title", likePattern);
} else {
const clauses = [
`title.${filters.exact ? `eq.${normalizedQuery}` : `ilike.${likePattern}`}`,
];
if (filters.includeOcr) {
clauses.push(`raw_text.ilike.${likePattern}`);
}
builder = builder.or(clauses.join(","));
}
}
const shouldSearchOcr = Boolean(filters.includeOcr && normalizedQuery);
const [{ data, error }, recentResults, ocrMatches] = await Promise.all([
builder,
fetchRecentResults(supabase, session.user.id, workspaceId),
shouldSearchOcr ? fetchOcrMatches(supabase, workspaceId, likePattern, limit) : Promise.resolve([]),
]);
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
let results: DocumentSearchResult[] = (data ?? []).map((row) =>
mapDocumentToResult(row, normalizedQuery || null),
);
if (ocrMatches.length > 0 && normalizedQuery) {
const snippetMap = new Map<string, string>();
ocrMatches.forEach((row: { document_id: string; ocr_text: string | null }) => {
if (!row.ocr_text) return;
if (!snippetMap.has(row.document_id)) {
snippetMap.set(row.document_id, row.ocr_text);
}
});
if (snippetMap.size > 0) {
const resultMap = new Map(results.map((item) => [item.id, item]));
const missingDocIds: string[] = [];
snippetMap.forEach((text, docId) => {
const snippet = buildSnippet(text, normalizedQuery || null);
if (resultMap.has(docId)) {
const existing = resultMap.get(docId)!;
resultMap.set(docId, {
...existing,
snippet: snippet || existing.snippet,
hasOcr: true,
matchField: "content",
});
} else {
missingDocIds.push(docId);
}
});
let extraResults: DocumentSearchResult[] = [];
if (missingDocIds.length > 0) {
const { data: extraDocs } = await supabase
.from("documents")
.select("id,title,updated_at,created_at,raw_text")
.in("id", missingDocIds)
.is("deleted_at", null);
extraResults =
extraDocs?.map((row) => {
const snippetText = snippetMap.get(row.id) ?? row.raw_text ?? "";
return {
...mapDocumentToResult(row, normalizedQuery || null, "content"),
snippet: buildSnippet(snippetText, normalizedQuery || null),
hasOcr: true,
};
}) ?? [];
}
results = [...resultMap.values(), ...extraResults];
}
}
const response: DocumentSearchResponse = {
results,
recent: recentResults,
};
return NextResponse.json(response);
}
@@ -0,0 +1,54 @@
import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
interface RecentPayload {
workspaceId: string;
documentId: string;
}
export async function POST(request: Request) {
const supabase = await createSupabaseRouteClient();
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const { workspaceId, documentId }: RecentPayload = await request.json();
if (!workspaceId || !documentId) {
return NextResponse.json({ error: "缺少参数" }, { status: 400 });
}
const { data: membership, error: membershipError } = await supabase
.from("workspace_members")
.select("workspace_id")
.eq("workspace_id", workspaceId)
.eq("user_id", session.user.id)
.maybeSingle();
if (membershipError) {
return NextResponse.json({ error: membershipError.message }, { status: 500 });
}
if (!membership) {
return NextResponse.json({ error: "无权访问该工作空间" }, { status: 403 });
}
const { error } = await supabase.from("user_recent_pages").upsert(
{
user_id: session.user.id,
workspace_id: workspaceId,
document_id: documentId,
last_accessed_at: new Date().toISOString(),
},
{ onConflict: "user_id,document_id" },
);
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json({ ok: true });
}
@@ -0,0 +1,47 @@
import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import { ensureDefaultWorkspace, fetchWorkspaceSummaries } from "@/lib/workspaces";
import { fetchSidebarDataset } from "@/lib/sidebar-tree";
import type { SidebarInitialData } from "@/components/sidebar/types";
export const dynamic = "force-dynamic";
export async function GET(request: Request) {
const supabase = await createSupabaseRouteClient();
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const url = new URL(request.url);
const workspaceIdParam = url.searchParams.get("workspaceId");
await ensureDefaultWorkspace(supabase, session.user.id, session.user.email ?? "我的空间");
const { workspaces, activeWorkspaceId } = await fetchWorkspaceSummaries(supabase, session.user.id);
const targetWorkspaceId = workspaceIdParam || activeWorkspaceId;
if (!targetWorkspaceId) {
return NextResponse.json({ error: "暂无可用工作空间" }, { status: 404 });
}
try {
const dataset = await fetchSidebarDataset(supabase, targetWorkspaceId);
const payload: SidebarInitialData = {
activeWorkspaceId: targetWorkspaceId,
workspaces,
documents: dataset.documents,
trashedDocuments: dataset.trashedDocuments,
};
return NextResponse.json(payload);
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "拉取侧边栏数据失败" },
{ status: 500 },
);
}
}
@@ -0,0 +1,54 @@
import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
export const dynamic = "force-dynamic";
export async function POST(request: Request) {
const supabase = await createSupabaseRouteClient();
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const payload = await request.json().catch(() => ({}));
const workspaceId = payload.workspaceId as string | undefined;
if (!workspaceId) {
return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 });
}
const { data: membership, error: membershipError } = await supabase
.from("workspace_members")
.select("id")
.eq("workspace_id", workspaceId)
.eq("user_id", session.user.id)
.single();
if (membershipError || !membership) {
return NextResponse.json({ error: "无权切换至该工作空间" }, { status: 403 });
}
const { error: resetError } = await supabase
.from("workspace_members")
.update({ is_default: false })
.eq("user_id", session.user.id);
if (resetError) {
return NextResponse.json({ error: resetError.message }, { status: 500 });
}
const { error: switchError } = await supabase
.from("workspace_members")
.update({ is_default: true })
.eq("user_id", session.user.id)
.eq("workspace_id", workspaceId);
if (switchError) {
return NextResponse.json({ error: switchError.message }, { status: 500 });
}
return NextResponse.json({ success: true });
}