Add annotation page gallery

This commit is contained in:
wuyang6
2026-07-20 10:56:59 +08:00
parent b7798e9985
commit 7cd6e5e464
5 changed files with 307 additions and 0 deletions
+3
View File
@@ -43,4 +43,7 @@ benchmarks/data/manifest.json
claude-code-sourcemap-main
router_session_parquet
# Locally managed annotation pages served by /annotations
annotation-pages/
标签定义
@@ -0,0 +1,28 @@
import { readFile } from "node:fs/promises";
import { resolveAnnotationPage } from "@/lib/annotation-pages";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET(
_request: Request,
{ params }: { params: Promise<{ name: string }> },
) {
const { name } = await params;
const filePath = resolveAnnotationPage(name);
if (!filePath)
return new Response("Invalid annotation page", { status: 400 });
try {
const content = await readFile(filePath);
return new Response(content, {
headers: {
"cache-control": "no-store",
"content-type": "text/html; charset=utf-8",
"x-content-type-options": "nosniff",
},
});
} catch {
return new Response("Annotation page not found", { status: 404 });
}
}
+167
View File
@@ -0,0 +1,167 @@
"use client";
import {
ArrowLeftIcon,
ExternalLinkIcon,
FileTextIcon,
RefreshCwIcon,
SearchIcon,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
type AnnotationPageInfo = {
name: string;
title: string;
size: number;
modifiedAt: string;
href: string;
};
export default function AnnotationsPage() {
const [items, setItems] = useState<AnnotationPageInfo[]>([]);
const [query, setQuery] = useState("");
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState("");
const refresh = useCallback(async () => {
setIsLoading(true);
setError("");
try {
const response = await fetch("/api/annotations", { cache: "no-store" });
const payload = (await response.json()) as {
items?: AnnotationPageInfo[];
error?: string;
};
if (!response.ok) throw new Error(payload.error ?? "加载失败");
setItems(payload.items ?? []);
} catch (reason) {
setError(reason instanceof Error ? reason.message : "加载失败");
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
void refresh();
}, [refresh]);
const filteredItems = useMemo(() => {
const keyword = query.trim().toLocaleLowerCase("zh-CN");
if (!keyword) return items;
return items.filter((item) =>
`${item.title}\n${item.name}`
.toLocaleLowerCase("zh-CN")
.includes(keyword),
);
}, [items, query]);
return (
<main className="min-h-dvh bg-[#f6f8fb] text-slate-900">
<header className="border-slate-200 border-b bg-white">
<div className="mx-auto flex w-full max-w-6xl items-center gap-3 px-5 py-4 sm:px-8">
<Button variant="ghost" size="icon" asChild title="返回工作台">
<a href="/">
<ArrowLeftIcon />
</a>
</Button>
<div className="min-w-0 flex-1">
<h1 className="font-semibold text-xl"></h1>
<p className="mt-0.5 text-slate-500 text-sm">
{isLoading ? "正在读取" : `${items.length} 个页面`}
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => void refresh()}
disabled={isLoading}
>
<RefreshCwIcon className={isLoading ? "animate-spin" : ""} />
</Button>
</div>
</header>
<section className="mx-auto w-full max-w-6xl px-5 py-6 sm:px-8">
<div className="relative mb-4 max-w-md">
<SearchIcon className="-translate-y-1/2 pointer-events-none absolute top-1/2 left-3 size-4 text-slate-400" />
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="搜索页面"
className="bg-white pl-9"
/>
</div>
<div className="overflow-hidden border-slate-200 border-y bg-white sm:rounded-md sm:border">
<div className="hidden grid-cols-[minmax(0,1fr)_140px_100px_44px] gap-4 border-slate-200 border-b bg-slate-50 px-4 py-2 font-medium text-slate-500 text-xs sm:grid">
<span></span>
<span></span>
<span></span>
<span />
</div>
{error ? (
<div className="px-4 py-10 text-center text-red-600 text-sm">
{error}
</div>
) : !isLoading && filteredItems.length === 0 ? (
<div className="px-4 py-14 text-center text-slate-500 text-sm">
</div>
) : (
<ul className="divide-y divide-slate-100">
{filteredItems.map((item) => (
<li key={item.name}>
<a
href={item.href}
target="_blank"
rel="noreferrer"
className="group grid min-h-18 grid-cols-[36px_minmax(0,1fr)_32px] items-center gap-3 px-4 py-3 transition-colors hover:bg-blue-50/60 sm:grid-cols-[36px_minmax(0,1fr)_140px_100px_32px]"
>
<span className="grid size-9 place-items-center rounded-md bg-blue-50 text-blue-600">
<FileTextIcon className="size-4" />
</span>
<span className="min-w-0">
<strong className="block truncate font-medium text-sm">
{item.title}
</strong>
<span className="mt-1 block truncate text-slate-500 text-xs">
{item.name}
</span>
</span>
<span className="hidden text-slate-500 text-sm sm:block">
{formatDate(item.modifiedAt)}
</span>
<span className="hidden text-slate-500 text-sm sm:block">
{formatSize(item.size)}
</span>
<ExternalLinkIcon className="size-4 text-slate-400 transition-colors group-hover:text-blue-600" />
</a>
</li>
))}
</ul>
)}
</div>
</section>
</main>
);
}
function formatDate(value: string) {
return new Intl.DateTimeFormat("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false,
}).format(new Date(value));
}
function formatSize(bytes: number) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
+21
View File
@@ -0,0 +1,21 @@
import { listAnnotationPages } from "@/lib/annotation-pages";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET() {
try {
const items = await listAnnotationPages();
return Response.json(
{ items },
{ headers: { "cache-control": "no-store" } },
);
} catch (error) {
return Response.json(
{
error: error instanceof Error ? error.message : "无法读取标注页面目录",
},
{ status: 500, headers: { "cache-control": "no-store" } },
);
}
}
+88
View File
@@ -0,0 +1,88 @@
import { mkdir, open, readdir, stat } from "node:fs/promises";
import path from "node:path";
export type AnnotationPageInfo = {
name: string;
title: string;
size: number;
modifiedAt: string;
href: string;
};
const annotationPagesRoot = path.resolve(
process.env.ANNOTATION_PAGES_DIR?.trim() || "/home/mi/annotation-pages",
);
export async function listAnnotationPages(): Promise<AnnotationPageInfo[]> {
await mkdir(annotationPagesRoot, { recursive: true });
const entries = await readdir(annotationPagesRoot, { withFileTypes: true });
const pages = await Promise.all(
entries
.filter((entry) => entry.isFile() && isSafeHtmlName(entry.name))
.map(async (entry) => {
const filePath = path.join(annotationPagesRoot, entry.name);
const [fileStat, title] = await Promise.all([
stat(filePath),
readHtmlTitle(filePath, entry.name),
]);
return {
name: entry.name,
title,
size: fileStat.size,
modifiedAt: fileStat.mtime.toISOString(),
href: `/annotations/${encodeURIComponent(entry.name)}`,
};
}),
);
return pages.sort(
(left, right) =>
Date.parse(right.modifiedAt) - Date.parse(left.modifiedAt) ||
left.name.localeCompare(right.name, "zh-CN"),
);
}
export function resolveAnnotationPage(name: string): string | null {
if (!isSafeHtmlName(name)) return null;
const resolved = path.resolve(annotationPagesRoot, name);
if (path.dirname(resolved) !== annotationPagesRoot) return null;
return resolved;
}
function isSafeHtmlName(name: string): boolean {
return (
name === path.basename(name) &&
!name.startsWith(".") &&
/\.html?$/i.test(name)
);
}
async function readHtmlTitle(filePath: string, fileName: string) {
const handle = await open(filePath, "r");
try {
const buffer = Buffer.alloc(16 * 1024);
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
const head = buffer.subarray(0, bytesRead).toString("utf8");
const match = head.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
const title = match?.[1]
?.replace(/<[^>]+>/g, " ")
.replace(/\s+/g, " ")
.trim();
return title ? decodeHtmlEntities(title) : stripHtmlExtension(fileName);
} finally {
await handle.close();
}
}
function stripHtmlExtension(fileName: string) {
return fileName.replace(/\.html?$/i, "");
}
function decodeHtmlEntities(value: string) {
return value
.replaceAll("&amp;", "&")
.replaceAll("&lt;", "<")
.replaceAll("&gt;", ">")
.replaceAll("&quot;", '"')
.replaceAll("&#39;", "'");
}