"use client"; import Link from "next/link"; import { useMemo } from "react"; import { Bookmark, ChevronRight, ListFilter, StickyNote, } from "lucide-react"; import { AnnotationTypeIcon } from "@/components/AnnotationTypeIcon"; import type { ExplorerItem } from "@/components/explorer/FacetFilterPanel"; import type { AnnotationType } from "@/db/schema"; import { TYPE_ORDER, TYPE_SHORT, annotationNote, annotationPreviewText, annotationTypeLabel, } from "@/utils/annotation-display"; import { formatThaiDate } from "@/utils/date-format"; // คอลัมน์ content ของหน้าสรุป (redesign 2026-09-24) — คงหลัก "1 หน้า 1 การ์ด" // ของหน้าเดิมแต่ผิวบางลง: หัวการ์ด sticky (เลขหน้า + บรรทัดสรุป + เปิดหน้านี้) // แทนขอบ folio เต็ม ส่วนแถวรายการเป็น passive surface จุดคลิกเดียวคือปุ่ม // "View" ท้ายแถวเหมือน GroupList ของหน้ากลุ่ม (คำขอผู้ใช้ 2026-09-24) — deep // link ?ann= / ?page= เดิม; meta line ไม่มี segment ไฟล์ (ไฟล์เดียวทั้งหน้า) /** บรรทัดสรุปของหน้า จากรายการที่มองเห็นจริงหลังกรอง (ย้ายจาก server 2026-09-24 — * กรองเหลือแต่เส้นวาดของใครคนหนึ่ง บรรทัดสรุปต้องเล่าเรื่องนั้น ไม่ใช่ยอดดิบ): * "ไฮไลต์ 1 · คอมเมนต์ 1 (จัดการแล้ว 1) · โดย ผู้พิพากษา ก., เลขานุการ ข." * (หน้าที่เหลือแต่ที่คั่นหน้า = "โดย …" อย่างเดียว) */ function pageSummaryLine(items: ExplorerItem[]): string { const typeCounts: Partial> = {}; let resolvedCount = 0; const participants = new Set(); for (const it of items) { if (it.kind === "ann") { typeCounts[it.dto.type] = (typeCounts[it.dto.type] ?? 0) + 1; if (it.dto.type === "COMMENT" && it.dto.resolved) resolvedCount += 1; participants.add(it.dto.userName); } else { participants.add(it.dto.userName || "ไม่ทราบชื่อ"); } } const parts: string[] = []; for (const t of TYPE_ORDER) { const n = typeCounts[t]; if (!n) continue; if (t === "COMMENT" && resolvedCount > 0) parts.push(`คอมเมนต์ ${n} (จัดการแล้ว ${resolvedCount})`); else parts.push(`${TYPE_SHORT[t]} ${n}`); } // localeCompare "th" ทั้ง SSR/hydration — รับความเสี่ยง mismatch ต่ำไว้เหมือน // users ของ SummaryExplorer (code review 2026-09-24) const who = Array.from(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}`; } /** จุดคั่น "·" ของ meta line */ function Sep() { return ·; } /** meta line ของแถวสรุป: บริบทกลาง ("ใน: …" ของ sub bookmark / ชื่อชนิดของ * annotation) → ผู้บันทึก → วันที่ — ต่างจาก GroupList ตรงไม่มี segment ไฟล์ * (ไฟล์เดียว ชื่ออยู่แถบหัวของหน้าเองแล้ว) */ function RowMeta({ middle, user, date, }: { middle?: string; user: string; date: string; }) { return ( {middle && ( <> {middle} )} โดย {user} {/* formatThaiDate แปลงตาม timezone เคื่องที่ render — SSR/server กับ hydration/ผู้ใช้อาจต่างกัน ใส่ suppressHydrationWarning ตาม convention (เดียวกับ GroupList/AnnotationSidebar) */} {date} ); } /** โน้ตคำอธิบาย (N1) / โน้ตย่อยของ bookmark (N2) — 2 บรรทัดพอ */ function RowNote({ text }: { text: string }) { return ( {text} ); } /** การ์ดรายหน้าตามจำนวนที่ได้เห็น — anchorIds เฉพาะชุดบนจอ (ชุดพิมพ์ฉบับเต็ม * ที่ render คู่กันตอนกรองอยู่ต้องไม่มี id ซ้ำ) */ function PageSections({ fileId, items, anchorIds, }: { fileId: string; items: ExplorerItem[]; anchorIds: boolean; }) { // จัดกลุ่มตามหน้า — Map เรียงตาม insertion = หน้าน้อย→มาก (items เรียงจาก server) const pages = useMemo(() => { const byPage = new Map(); for (const it of items) { let arr = byPage.get(it.pageIndex); if (!arr) { arr = []; byPage.set(it.pageIndex, arr); } arr.push(it); } return Array.from(byPage.entries()); }, [items]); return (
{pages.map(([pi, pageItems]) => ( // ห้ามใส่ overflow-hidden — ฆ่า position:sticky ของหัวการ์ด (sticky ไม่ // ทำงานใน ancestor ที่ overflow:hidden; มุมโค้งไม่มีสีหลุดมุมเพราะหัวการ์ด // ขาวบนการ์ดขาว) — id page-N คงจากหน้าเดิมให้ลิงก์เก่ายังใช้ได้
{/* หัวการ์ด sticky ระดับ viewport — ไต่ list ยาวแล้วยังรู้ว่าอยู่หน้าไหน (รับช่วง orientation จากรายการตามหน้าจุดไข่ปลาที่ถอดออก; บนกระดาษ กลับเป็นตำแหน่งปกติผ่านกฎ .page-head ใน globals.css) */}

หน้า {/* เลขหน้าเป็น ink ไม่ใช่ primary — Genesis "น้ำเงินเฉพาะของ interactive" ความเด่นมาจาก font-display semibold */} {pi + 1}

{pageSummaryLine(pageItems)}

เปิดหน้านี้
    {pageItems.map((item) => { const isBm = item.kind === "bm"; // ปลายทาง + คำบอกตำแหน่งต่อชนิดรายการ (title tooltip + aria-label // ของปุ่ม View — ปุ่มชื่อเหมือนกันทั้ง list ต้องรู้จักแยก) ไม่ต่อ // ชื่อไฟล์เหมือนหน้ากลุ่ม (ไฟล์เดียว ชื่ออยู่แถบหัวแล้ว) const where = isBm ? `ไปที่หน้า ${item.pageIndex + 1}` : `ดู${annotationTypeLabel(item.dto)}ที่หน้า ${item.pageIndex + 1}`; const href = isBm ? `/note/annotate/${fileId}?page=${item.pageIndex + 1}` : `/note/annotate/${fileId}?ann=${item.dto.id}`; return ( // แถวเป็น passive surface — จุดคลิกเดียวคือปุ่ม "View" ท้ายแถว // (คำขอผู้ใช้ 2026-09-24 เหมือนหน้ากลุ่ม) จึงไม่มี hover bg ที่แถว
  • {isBm ? ( <> {item.dto.title} {item.dto.note && } ) : ( <> {annotationPreviewText(item.dto)} {item.dto.type === "COMMENT" && item.dto.resolved && ( จัดการแล้ว )} {annotationNote(item.dto) && ( )} )} {/* scroll-mt-12: โฟกัสผ่าน Tab แล้วปุ่มไม่จมใต้หัวการ์ด sticky (~41px) — WCAG 2.4.11 Focus not obscured */} View
  • ); })}
))}
); } export function SummaryList({ fileId, items, allItems, activeCount, onReset, }: { fileId: string; /** ผ่านตัวกรองแล้ว (เรียงลำดับจาก server คงเดิม) */ items: ExplorerItem[]; /** ข้อมูลทั้งหมดก่อนกรอง — ใช้เป็นชุดพิมพ์ฉบับเต็มตอนกรองอยู่ */ allItems: ExplorerItem[]; activeCount: number; onReset: () => void; }) { return ( <> {/* จำนวนผลลัพธ์อยู่ต้นคอลัมน์ content (ที่เห็นผลของตัวกรอง) ไม่ใช่ในแผง — aria-live ให้ screen reader ได้รับแจ้งผลกรองทันทีที่ toggle */}

พบ{" "} {items.length} {" "} รายการ

{activeCount > 0 && ( )}
{items.length > 0 && (
0 ? "print:hidden" : undefined}>
)} {items.length === 0 && (

ไม่มีรายการตรงตามตัวกรอง

)} {/* ตอนพิมพ์แสดงฉบับเต็มเสมอ — ตัวกรองที่ตั้งค้างไว้ต้องไม่ตัดรายการออกเงียบ ๆ บนกระดาษ (หน้าสรุปมีโอกาสถูกพิมพ์แนบสำนวน ความครบถ้วนสำคัญกว่า subset — ต้องการ subset มี export CSV อยู่แล้ว); ไม่กรอง = DOM ชุดเดียวไม่ซ้ำ */} {activeCount > 0 && (
)} ); }