Add annotation page gallery
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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