diff --git a/CLAUDE.md b/CLAUDE.md index aa5449f..1681511 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -62,14 +62,12 @@ app/uploads/[name]/route.ts ← เสิร์ฟไฟล์อัป app/page.tsx ← รายการไฟล์ (server component) app/login/page.tsx ← หน้า login (มี session แล้ว redirect /) app/annotate/[id]/page.tsx ← โหลด file + initial data → PDFViewer -app/annotate/[id]/summary/page.tsx ← หน้าสรุป Annotation+Bookmark จัดกลุ่มตามหน้า (design "แฟ้มสำนวน" 2026-09-17: แถบหัวน้ำเงิน + สารบัญจุดไข่ปลา + การ์ดรายหน้า 1 หน้า 1 การ์ด ขอบ folio — คำขอผู้ใช้ 2026-09-17 แทนแผ่นกระดาษแผ่นเดียวคั่นเส้น; ต่อหน้า = บรรทัดสรุปหน้า (ไม่มีหัวข้อ) + Bookmark/Annotation หุบ-กางได้ผ่าน CollapsibleBlock; สารบัญ desktop = การ์ด sticky มี max-h, มือถือ < lg =
การ์ด ไม่มี JS; generateMetadata ตั้งชื่อแท็บ browser ตามคดี; ตอนพิมพ์ header เป็นกรอบเส้นน้ำเงิน + ซ่อนแถบ action/สารบัญมือถือ — กฎ @media print อยู่ใน globals.css) — อย่าถอด class `summary-page` ของ
(กฎ coarse 44px ใน globals.css ผูกอยู่กับ selector นี้ ครอบทั้ง `a` และ `button`) -components/summary/CollapsibleBlock.tsx ← client component เดียวของหน้าสรุป (state หุบ/กางกลุ่ม Bookmark/Annotation — children ส่งจาก server ได้ตรง ๆ) +app/annotate/[id]/summary/page.tsx ← หน้าสรุปหมายเหตุจัดกลุ่มตามหน้า (F15) components/pdf/PDFViewer.tsx ← client root (tool state, zoom, selection, lazy render หน้า) components/pdf/InkLayer.tsx ← SVG freehand + รูปทรง + redaction components/pdf/TextboxStampLayer.tsx ← overlay TEXTBOX + STAMP (F17/F18) components/pdf/AnnotationSelectionLayer.tsx ← กรอบ selection + handles ย้าย/ปรับขนาด (F1) + แถบสี/ความหนา + ปุ่มลบ components/sidebar/AnnotationSidebar.tsx ← + โน้ตคำอธิบาย (note) inline ต่อรายการ (PLAN-FEAT.md §7 N1) -components/AnnotationTypeIcon.tsx ← ไอคอนตามชนิด annotation (ย้ายจาก sidebar) — ไม่มี "use client" ให้ server component ใช้ร่วมได้ (sidebar + หน้าสรุป) components/UploadFileForm.tsx ← ฟอร์มอัปโหลดไฟล์บนหน้ารายการ (N4) components/LoginForm.tsx ← ฟอร์ม login (client — เรียก action login แล้ว router.replace("/")) components/SessionBar.tsx ← แถบแสดงผู้ใช้ที่ login + ปุ่มออกจากระบบบนหน้ารายการ (แทน UserPicker เดิม) diff --git a/app/annotate/[id]/summary/page.tsx b/app/annotate/[id]/summary/page.tsx index af65e2a..91fef30 100644 --- a/app/annotate/[id]/summary/page.tsx +++ b/app/annotate/[id]/summary/page.tsx @@ -1,115 +1,21 @@ -import type { Metadata } from "next"; import Link from "next/link"; import { asc, eq } from "drizzle-orm"; import { notFound } from "next/navigation"; -import { - ArrowLeft, - Bookmark, - Braces, - ChevronDown, - ChevronRight, - ClipboardList, - FileDown, - StickyNote, -} from "lucide-react"; +import { ArrowLeft, MapPin } from "lucide-react"; -import { AnnotationTypeIcon } from "@/components/AnnotationTypeIcon"; -import CollapsibleBlock from "@/components/summary/CollapsibleBlock"; import { db } from "@/db"; -import { - annotations, - bookmarks, - files, - toAnnotationDTO, - toBookmarkDTO, - type AnnotationDTO, - type AnnotationType, - type BookmarkDTO, -} from "@/db/schema"; -import { - annotationNote, - annotationPreviewText, - annotationTypeLabel, -} from "@/utils/annotation-display"; +import { annotations, files, toAnnotationDTO, type AnnotationDTO } from "@/db/schema"; +import { annotationNote, annotationPreviewText, annotationTypeLabel } from "@/utils/annotation-display"; import { withBasePath } from "@/utils/base-path"; -// หน้าสรุปหมายเหตุ (F15/D2) — server component รวม annotation (ทุกผู้ใช้) + bookmark -// จัดกลุ่มตามหน้า แต่ละแถวกระโดดเป็น deep link `/annotate/?ann=` / `?page=` (F12) -// — design "แฟ้มสำนวน" 2026-09-17: แถบหัวกระดาษน้ำเงิน (สืบต่อจาก tile JN ของแบรนด์) + -// สารบัญจุดไข่ปลา + การ์ดรายหน้า 1 หน้า 1 การ์ด (คำขอผู้ใช้ 2026-09-17 แทนแผ่น -// กระดาษแผ่นเดียวคั่นเส้น) แต่ละการ์ดมีขอบ folio เลขหน้าใหญ่; สารบัญเป็น anchor -// link (#page-N) ล้วน — desktop เป็น sticky aside แบบการ์ด ส่วนมือถือ (< lg) เป็น -//
แบบการ์ด ไม่มี JS / client component เดียวของหน้าคือ CollapsibleBlock -// (หุบ/กางกลุ่ม Bookmark/Annotation) +// หน้าสรุปหมายเหตุ (F15/D2) — server component รวมทุก annotation (ทุกผู้ใช้) +// จัดกลุ่มตามหน้า พร้อมปุ่มกระโดดเป็น deep link `/annotate/?ann=` (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; -/** ชื่อย่อต่อชนิดสำหรับบรรทัดสรุปของแต่ละหน้า (ชื่อเต็มอยู่ที่ annotationTypeLabel) */ -const TYPE_SHORT: Record = { - HIGHLIGHT: "ไฮไลต์", - DRAW: "เส้นวาด", - COMMENT: "คอมเมนต์", - SHAPE: "รูปทรง", - REDACTION: "ปิดพาง", - TEXTBOX: "กล่องข้อความ", - STAMP: "ตรายาง", -}; - -/** ลำดับชนิดในบรรทัดสรุป — ตามลำดับ enum ให้ deterministic ไม่ขึ้นกับลำดับสร้างรายการ */ -const TYPE_ORDER = Object.keys(TYPE_SHORT) as AnnotationType[]; - -type PageBucket = { - anns: AnnotationDTO[]; - bms: BookmarkDTO[]; - typeCounts: Partial>; - resolvedCount: number; - participants: Set; -}; - -/** บรรทัดสรุปของหน้า: "ไฮไลต์ 1 · คอมเมนต์ 1 (จัดการแล้ว 1) · โดย ผู้พิพากษา ก., เลขานุการ ข." - * (หน้าที่มีแต่ที่คั่นหน้า = "โดย …" อย่างเดียว) */ -function pageSummaryLine(b: PageBucket): string { - const parts: string[] = []; - for (const t of TYPE_ORDER) { - const n = b.typeCounts[t]; - if (!n) continue; - if (t === "COMMENT" && b.resolvedCount > 0) - parts.push(`คอมเมนต์ ${n} (จัดการแล้ว ${b.resolvedCount})`); - else parts.push(`${TYPE_SHORT[t]} ${n}`); - } - const who = Array.from(b.participants).sort((x, y) => x.localeCompare(y, "th")); - const shown = who.slice(0, 3).join(", "); - const whoLabel = who.length > 3 ? `${shown} และอีก ${who.length - 3} คน` : shown; - return parts.length > 0 ? `${parts.join(" · ")} · โดย ${whoLabel}` : `โดย ${whoLabel}`; -} - -/** focus ring ร่วมของทุกลิงก์ในหน้า — จอสัมผัสได้ 44px อัตโนมัติจากกฎ - * `main.summary-page a`/`button` ใน globals.css (ห้ามถอด class summary-page - * ของ
) */ -const RING_CLS = - "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1"; - export const dynamic = "force-dynamic"; -// design review 2026-09-17 M5: ชื่อแท็บ browser ระบุคดี — เลขานุการเปิดหลายคดี -// หลายแท็บพร้อมกัน (เดิมทุกแท็บชื่อ "Judge's Note" เหมือนกันหมดแยกไม่ออก) -export async function generateMetadata({ - params, -}: { - params: { id: string }; -}): Promise { - const { id } = params; - if (!UUID_RE.test(id)) return { title: "สรุปหมายเหตุ | Judge's Note" }; - const [file] = await db - .select({ title: files.title }) - .from(files) - .where(eq(files.id, id)); - return { - title: `สรุปหมายเหตุ — ${file?.title ?? "ไม่พบไฟล์"} | Judge's Note`, - }; -} - export default async function SummaryPage({ params, }: { @@ -121,348 +27,92 @@ export default async function SummaryPage({ const [file] = await db.select().from(files).where(eq(files.id, id)); if (!file) notFound(); - const annRows = await db + const rows = await db .select() .from(annotations) .where(eq(annotations.fileId, id)) .orderBy(asc(annotations.pageIndex), asc(annotations.createdAt)); - const dtos = annRows.map(toAnnotationDTO); + const dtos = rows.map(toAnnotationDTO); - // bookmark เรียงตามหน้า → ตำแหน่งบนหน้า → เวลาสร้าง (N5 เหมือน sidebar) - const bmRows = await db - .select() - .from(bookmarks) - .where(eq(bookmarks.fileId, id)) - .orderBy( - asc(bookmarks.pageIndex), - asc(bookmarks.yOffset), - asc(bookmarks.createdAt), - ); - const bmDtos = bmRows.map(toBookmarkDTO); - const bookmarkById = new Map(bmDtos.map((b) => [b.id, b] as const)); - - // จัดกลุ่มตามหน้าแบบ union — หน้าปรากฏเมื่อมี annotation หรือ bookmark ≥ 1 รายการ - const pageMap = new Map(); - const bucketOf = (pageIndex: number): PageBucket => { - let b = pageMap.get(pageIndex); - if (!b) { - b = { anns: [], bms: [], typeCounts: {}, resolvedCount: 0, participants: new Set() }; - pageMap.set(pageIndex, b); - } - return b; - }; + // จัดกลุ่มตามหน้า (เรียงหน้ามากไปน้อยไม่เกี่ยว — เดินตามลำดับหน้าเสมอ) + const byPage = new Map(); for (const a of dtos) { - const b = bucketOf(a.pageIndex); - b.anns.push(a); - b.typeCounts[a.type] = (b.typeCounts[a.type] ?? 0) + 1; - if (a.type === "COMMENT" && a.resolved) b.resolvedCount += 1; - b.participants.add(a.userName); + const list = byPage.get(a.pageIndex); + if (list) list.push(a); + else byPage.set(a.pageIndex, [a]); } - for (const b of bmDtos) { - const bucket = bucketOf(b.pageIndex); - bucket.bms.push(b); - bucket.participants.add(b.userName || "ไม่ทราบชื่อ"); - } - const pages = Array.from(pageMap.entries()).sort((x, y) => x[0] - y[0]); - - // ภาพรวมระดับไฟล์สำหรับสารบัญ desktop (sticky aside) + สารบัญมือถือ (
) - const allParticipants = Array.from( - new Set([ - ...dtos.map((a) => a.userName), - ...bmDtos.map((b) => b.userName || "ไม่ทราบชื่อ"), - ]), - ).sort((x, y) => x.localeCompare(y, "th")); + const pages = Array.from(byPage.entries()).sort((x, y) => x[0] - y[0]); return ( -
- {/* หัวกระดาษสำนวน — แถบน้ำเงินสืบต่อจาก tile JN ของแบรนด์ (หน้ารายการ/login): - ชื่อรายงาน + ชื่อแฟ้ม + สถิติในที่เดียว แล้วปิดท้ายด้วยเส้นคั่นบาง */} -
-

สรุปหมายเหตุ

-

{file.title}

- {pages.length > 0 && ( -

- Annotation {dtos.length} รายการ · Bookmark {bmDtos.length} รายการ ·{" "} - {pages.length} หน้า -

- )} -
- - {/* แถบ action — ghost button ขาวมีขอบ ภาษาเดียวกับการ์ดของหน้า (คำขอผู้ใช้ - 2026-09-17 แทนลิงก์ข้อความเปล่า — เห็นเป็นปุ่มชัดขึ้น; จอสัมผัสได้ 44px - จากกฎ coarse; ตอนพิมพ์ซ่อนทิ้ง — ปุ่มกลับ/export ไม่มีความหมายบนกระดาษ) */} -
+
+
+

+ สรุปหมายเหตุ — {file.title} +

- + กลับหน้าอ่าน -
- {/* F16/D3: export สรุปเป็น CSV (UTF-8 BOM) */} - - - ส่งออกสรุป (CSV) - - {/* N7: ส่งออก Bookmark ทั้งหมดเป็น JSON (label บอกขอบเขตเอง — คู่กับ - "ส่งออกสรุป (CSV)" ข้างบน ไม่ต้องพึ่ง title tooltip ที่จอสัมผัสใช้ไม่ได้) */} - - - ส่งออก Bookmark (JSON) - -
+ {/* F16/D3: export สรุปเป็น CSV (UTF-8 BOM) */} + + Export CSV +
{pages.length === 0 ? ( -
- -

- ยังไม่มี Annotation หรือ Bookmark ในไฟล์นี้ -

-

- เปิดหน้าอ่านเพื่อมาร์กข้อความ วาดเส้น ใส่คอมเมนต์ หรือคั่นหน้า -

- - เปิดหน้าอ่าน - -
+

+ ยังไม่มี annotation ในไฟล์นี้ +

) : ( -
- {/* สารบัญมือถือ (จอ < lg) — design review 2026-09-17 H2: คืน navigation ที่หาย - ไปบนมือถือ (aside ด้านล่างแสดงเฉพาะ lg) ใช้
ได้หุบ/กาง + keyboard - โดยไม่มี JS เพิ่ม — จุดไข่ปลา + ผู้ร่วมทำ ภาษาเดียวกับ desktop (จอ lg กับ - ตอนพิมพ์ซ่อน — display:none ไม่กินช่องของกริด) */} -
- - สารบัญและผู้ร่วมทำ - - - -

- ผู้ร่วมทำ: {allParticipants.join(" · ")} -

-
- - {/* สารบัญ (จอ ≥ lg) — สารบัญแบบจุดไข่ปลาของเอกสารราชการ anchor ล้วนไม่มี JS - เป็นการ์ดชุดเดียวกับ
มือถือและการ์ดรายหน้า (แสดงเฉพาะหน้าที่มี - รายการ ≥ 1 ตามหลัก minimize choices; max-h กันเอกสารหลายสิบหน้าสูงเกินจอ - — ครึ่งล่างของ sticky กดไม่ได้ตลอดกาล) */} - - - {/* การ์ดรายหน้า — 1 หน้า 1 การ์ด เรียงลงมาแยกด้วยช่องว่าง (คำขอผู้ใช้ - 2026-09-17 แทนแผ่นกระดาษแผ่นเดียวคั่นเส้น); แต่ละการ์ดมีขอบ folio: - เลขหน้าใหญ่ + เส้นดิ่ง แบบเลขหน้าบนขอบกระดาษ (mt-5 lg:mt-0 — ระยะจาก - สารบัญ
บนมือถือ; จอ lg กริดจัดให้) */} -
- {pages.map(([pi, b]) => { - return ( -
- {/* ขอบ folio — "หน้า" เล็ก + เลขใหญ่ชิดเส้นดิ่ง */} -
-

- - หน้า +
    + {items.map((a) => ( +
  • + + + {annotationTypeLabel(a)} + {a.type === "COMMENT" && a.resolved && ( + + จัดการแล้ว + + )} + {a.userName} + + + {annotationPreviewText(a)} + + {annotationNote(a) && ( + + โน้ต: {annotationNote(a)} - - {pi + 1} - -

-
- - {/* เนื้อหาของหน้า — บรรทัดสรุปหน้า (ไม่มีหัวข้อคำอธิบาย) / - Bookmark / Annotation (หุบ-กางได้, รายการเยื้องจากหัวข้อ) */} -
-
-
-

- {pageSummaryLine(b)} -

-
- - เปิดหน้านี้ - - -
- - {/* ที่คั่นหน้าของหน้านี้ทั้งหมด (root + sub) เรียงตาม yOffset */} - {b.bms.length > 0 && ( -
- -
    - {b.bms.map((bm) => { - const parent = bm.parentId - ? bookmarkById.get(bm.parentId) - : undefined; - return ( -
  • - - - - - {bm.title} - - - โดย {bm.userName || "ไม่ทราบชื่อ"} - {parent ? ` · ใน: ${parent.title}` : ""} - - {bm.note && ( - - - - {bm.note} - - - )} - - - -
  • - ); - })} -
-
-
)} - - {/* หมายเหตุของหน้านี้ — แถวคลิกได้ทั้งแถว (เป้าใหญ่บนจอสัมผัส) */} - {b.anns.length > 0 && ( -
0 ? "mt-3" : "mt-2"}> - -
    - {b.anns.map((a) => ( -
  • - - - - - {annotationPreviewText(a)} - {a.type === "COMMENT" && a.resolved && ( - - จัดการแล้ว - - )} - - - {annotationTypeLabel(a)} · โดย {a.userName} - - {annotationNote(a) && ( - - - - {annotationNote(a)} - - - )} - - - -
  • - ))} -
-
-
- )} -
-
- ); - })} -
-
+ + {/* ปุ่มกระโดด = deep link ไปยัง annotation นั้น (F12 รับ ?ann= ตอน mount) */} + + + ไปตำแหน่ง + + + ))} + + + )) )}
); diff --git a/app/globals.css b/app/globals.css index 9a446d9..9862123 100644 --- a/app/globals.css +++ b/app/globals.css @@ -197,34 +197,6 @@ display: inline-flex; align-items: center; } - - /* design review 2026-09-17 H1: ปุ่มหุบ/กางของ CollapsibleBlock เป็น - - {open && ( -
- {children} -
- )} -
- ); -}