Add annotation page gallery
This commit is contained in:
@@ -43,4 +43,7 @@ benchmarks/data/manifest.json
|
|||||||
claude-code-sourcemap-main
|
claude-code-sourcemap-main
|
||||||
router_session_parquet
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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`;
|
||||||
|
}
|
||||||
@@ -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" } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">")
|
||||||
|
.replaceAll(""", '"')
|
||||||
|
.replaceAll("'", "'");
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user