Add application codebase: PDF annotation for e-Court
Next.js 14 + Drizzle/PostgreSQL + SSE realtime sync — highlight, freehand, comment, bookmark + ฟีเจอร์ต่อยอด F1–F19 (edit/move, undo/redo, shapes, redaction, export PDF/CSV, reply, resolve, search, summary, textbox, stamp, version history) Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
parent
6fd0c3174f
commit
eddcec28c7
60 changed files with 15055 additions and 0 deletions
63
app/annotate/[id]/page.tsx
Normal file
63
app/annotate/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import { asc, eq, inArray } from "drizzle-orm";
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
import { db } from "@/db";
|
||||
import {
|
||||
annotationReplies,
|
||||
annotations,
|
||||
bookmarks,
|
||||
files,
|
||||
toAnnotationDTO,
|
||||
toBookmarkDTO,
|
||||
toReplyDTO,
|
||||
} from "@/db/schema";
|
||||
import PDFViewer from "@/components/pdf/PDFViewer";
|
||||
|
||||
const UUID_RE =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function AnnotatePage({
|
||||
params,
|
||||
}: {
|
||||
params: { id: string };
|
||||
}) {
|
||||
const { id } = params;
|
||||
if (!UUID_RE.test(id)) notFound();
|
||||
|
||||
const [file] = await db.select().from(files).where(eq(files.id, id));
|
||||
if (!file) notFound();
|
||||
|
||||
const [annotationRows, bookmarkRows] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(annotations)
|
||||
.where(eq(annotations.fileId, id))
|
||||
.orderBy(asc(annotations.createdAt)),
|
||||
db
|
||||
.select()
|
||||
.from(bookmarks)
|
||||
.where(eq(bookmarks.fileId, id))
|
||||
.orderBy(asc(bookmarks.createdAt)),
|
||||
]);
|
||||
|
||||
// replies ของทุก comment ในไฟล์นี้ (F9) — แม่ถูก CASCADE ไปแล้ว row ก็หายตาม
|
||||
const annotationIds = annotationRows.map((r) => r.id);
|
||||
const replyRows = annotationIds.length
|
||||
? await db
|
||||
.select()
|
||||
.from(annotationReplies)
|
||||
.where(inArray(annotationReplies.annotationId, annotationIds))
|
||||
.orderBy(asc(annotationReplies.createdAt))
|
||||
: [];
|
||||
|
||||
return (
|
||||
<PDFViewer
|
||||
file={{ id: file.id, title: file.title, pdfUrl: file.pdfUrl }}
|
||||
initialAnnotations={annotationRows.map(toAnnotationDTO)}
|
||||
initialBookmarks={bookmarkRows.map(toBookmarkDTO)}
|
||||
initialReplies={replyRows.map(toReplyDTO)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
118
app/annotate/[id]/summary/page.tsx
Normal file
118
app/annotate/[id]/summary/page.tsx
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
import Link from "next/link";
|
||||
import { asc, eq } from "drizzle-orm";
|
||||
import { notFound } from "next/navigation";
|
||||
import { ArrowLeft, MapPin } from "lucide-react";
|
||||
|
||||
import { db } from "@/db";
|
||||
import { annotations, files, toAnnotationDTO, type AnnotationDTO } from "@/db/schema";
|
||||
import { annotationNote, annotationPreviewText, annotationTypeLabel } from "@/utils/annotation-display";
|
||||
|
||||
// หน้าสรุปหมายเหตุ (F15/D2) — server component รวมทุก annotation (ทุกผู้ใช้)
|
||||
// จัดกลุ่มตามหน้า พร้อมปุ่มกระโดดเป็น deep link `/annotate/<id>?ann=<uuid>` (F12)
|
||||
|
||||
const UUID_RE =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function SummaryPage({
|
||||
params,
|
||||
}: {
|
||||
params: { id: string };
|
||||
}) {
|
||||
const { id } = params;
|
||||
if (!UUID_RE.test(id)) notFound();
|
||||
|
||||
const [file] = await db.select().from(files).where(eq(files.id, id));
|
||||
if (!file) notFound();
|
||||
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(annotations)
|
||||
.where(eq(annotations.fileId, id))
|
||||
.orderBy(asc(annotations.pageIndex), asc(annotations.createdAt));
|
||||
const dtos = rows.map(toAnnotationDTO);
|
||||
|
||||
// จัดกลุ่มตามหน้า (เรียงหน้ามากไปน้อยไม่เกี่ยว — เดินตามลำดับหน้าเสมอ)
|
||||
const byPage = new Map<number, AnnotationDTO[]>();
|
||||
for (const a of dtos) {
|
||||
const list = byPage.get(a.pageIndex);
|
||||
if (list) list.push(a);
|
||||
else byPage.set(a.pageIndex, [a]);
|
||||
}
|
||||
const pages = Array.from(byPage.entries()).sort((x, y) => x[0] - y[0]);
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-3xl p-6">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<h1 className="min-w-0 flex-1 truncate text-lg font-bold text-primary">
|
||||
สรุปหมายเหตุ — {file.title}
|
||||
</h1>
|
||||
<Link
|
||||
href={`/annotate/${id}`}
|
||||
className="flex items-center gap-1 rounded-lg border border-slate-300 px-3 py-1.5 text-xs text-slate-600 hover:bg-slate-100"
|
||||
>
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
กลับหน้าอ่าน
|
||||
</Link>
|
||||
{/* F16/D3: export สรุปเป็น CSV (UTF-8 BOM) */}
|
||||
<a
|
||||
href={`/api/annotations/export-csv?fileId=${id}`}
|
||||
className="rounded-lg border border-slate-300 px-3 py-1.5 text-xs text-slate-600 hover:bg-slate-100"
|
||||
>
|
||||
Export CSV
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{pages.length === 0 ? (
|
||||
<p className="mt-10 rounded border border-dashed p-6 text-center text-sm text-slate-500">
|
||||
ยังไม่มี annotation ในไฟล์นี้
|
||||
</p>
|
||||
) : (
|
||||
pages.map(([pageIndex, items]) => (
|
||||
<section key={pageIndex} className="mt-6">
|
||||
<h2 className="border-b border-slate-200 pb-1 text-sm font-semibold">
|
||||
หน้า {pageIndex + 1}
|
||||
<span className="ml-2 text-xs font-normal text-slate-400">
|
||||
{items.length} รายการ
|
||||
</span>
|
||||
</h2>
|
||||
<ul className="mt-2 divide-y divide-slate-100">
|
||||
{items.map((a) => (
|
||||
<li key={a.id} className="flex items-start gap-3 py-2">
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-xs">
|
||||
<span className="font-medium">{annotationTypeLabel(a)}</span>
|
||||
{a.type === "COMMENT" && a.resolved && (
|
||||
<span className="ml-2 rounded bg-emerald-50 px-1.5 py-0.5 text-[10px] text-emerald-600">
|
||||
จัดการแล้ว
|
||||
</span>
|
||||
)}
|
||||
<span className="ml-2 text-slate-400">{a.userName}</span>
|
||||
</span>
|
||||
<span className="mt-0.5 block whitespace-pre-wrap break-words text-sm">
|
||||
{annotationPreviewText(a)}
|
||||
</span>
|
||||
{annotationNote(a) && (
|
||||
<span className="mt-0.5 block whitespace-pre-wrap break-words text-xs text-slate-500">
|
||||
โน้ต: {annotationNote(a)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{/* ปุ่มกระโดด = deep link ไปยัง annotation นั้น (F12 รับ ?ann= ตอน mount) */}
|
||||
<Link
|
||||
href={`/annotate/${id}?ann=${a.id}`}
|
||||
className="flex shrink-0 items-center gap-1 rounded-md border border-slate-300 px-2 py-1 text-[11px] text-slate-600 hover:bg-slate-100"
|
||||
>
|
||||
<MapPin className="h-3 w-3" />
|
||||
ไปตำแหน่ง
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue