summary: หน้าสรุป design "แฟ้มสำนวน" + แก้ตาม design review 2026-09-17 — การ์ดรายหน้า 1 หน้า 1 การ์ด + สารบัญมือถือ + ghost buttons
All checks were successful
Deploy eCourt Note Web to Server / Validate Database Schema (Drizzle) (push) Successful in 4s
Deploy eCourt Note Web to Server / Build Web Docker Image (push) Successful in 46s
Deploy eCourt Note Web to Server / Deploy eCourt Note Web to Server (push) Successful in 4s
Deploy eCourt Note Web to Server / Notify Deployment Status (push) Successful in 1s
All checks were successful
Deploy eCourt Note Web to Server / Validate Database Schema (Drizzle) (push) Successful in 4s
Deploy eCourt Note Web to Server / Build Web Docker Image (push) Successful in 46s
Deploy eCourt Note Web to Server / Deploy eCourt Note Web to Server (push) Successful in 4s
Deploy eCourt Note Web to Server / Notify Deployment Status (push) Successful in 1s
งานหลักจาก design review (H/M/L ทั้งหมดยกเว้น L5 ที่จบเอง — หัวข้อ Explanation ถูกถอดไปก่อนหน้านี้ตามคำขอผู้ใช้ พร้อม icon ℹ + เส้น):
(1) H1 กฎ coarse 44px ครอบ button ด้วย (main.summary-page button min-height 2.75rem) — ปุ่มหุบ/กาง CollapsibleBlock เดิมหลุดกฎเพราะเป็น <button> ไม่ใช่ <a> สูงจริง ~29px
(2) H2 สารบัญมือถือ (< lg) — <details> การ์ด "สารบัญและผู้ร่วมทำ" จุดไข่ปลา + ผู้ร่วมทำภาษาเดียวกับ desktop zero JS (ได้ keyboard + screen reader ฟรี) แทนที่เดิมมือถือไม่มีทางกระโดดหน้าเลย
(3) H3 typography ขยายเพื่อผู้ใช้สูงวัย + อักษรไทย — เนื้อหาแถวรายการ text-[13px]→text-sm, โน้ต/badge text-[11px]→text-xs, บรรทัดสรุปหน้า→text-sm, "หน้า" text-[10px]→text-[11px], แถบ action→text-sm (CollapsibleBlock รวม)
(4) M1 aria-label anchor สารบัญ "หน้า N, X รายการ" กัน screen reader อ่านเลขติดกัน / M2 sticky aside max-h overflow (เอกสารหลายสิบหน้าครึ่งล่างเคยกดไม่ได้ตลอดกาล) / M3 ปุ่ม "เปิดหน้านี้" แสดงตลอดไม่ซ่อนบนมือถือ / M4 "ส่งออก CSV"→"ส่งออกสรุป (CSV)" คู่กับ "ส่งออก Bookmark (JSON)" (คง terminology อังกฤษตาม b1bc65e) ถอด title tooltip ที่จอสัมผัสใช้ไม่ได้ / M5 generateMetadata ชื่อแท็บ browser "สรุปหมายเหตุ — ชื่อคดี | Judge's Note"
(5) L2 CollapsibleBlock เพิ่ม useId + aria-controls/id ครบ disclosure pattern / L3 empty state CTA ใช้ RING_CLS ร่วม / L4 @media print (globals.css): header แปลงกรอบเส้นน้ำเงิน #0033a1 กัน text ขาวบนกระดาษขาว + break-inside ไม่ผ่าการ์ดกลางหน้ากระดาษ + ซ่อนแถบ action/สารบัญมือถือ / L1 แก้ comment หัวไฟล์ให้ตรงความจริง (มี client component เดียวคือ CollapsibleBlock)
ปรับตามคำขอผู้ใช้ระหว่างทาง (2026-09-17): เนื้อหาเปลี่ยนจากแผ่นกระดาษแผ่นเดียวคั่นเส้นเป็นการ์ดรายหน้า 1 หน้า 1 การ์ด (flex flex-col gap-4) / สารบัญ desktop เป็นการ์ด sticky / action bar เปลี่ยนลิงก์ข้อความเปล่าเป็น ghost button ขาวมีขอบ ภาษาเดียวกับการ์ด
งานคู่ขนานจากอีก session ในไฟล์ชุดนี้: แยก TypeIcon ออกจาก sidebar เป็น components/AnnotationTypeIcon.tsx (ไม่มี "use client") ใช้ร่วม sidebar + หน้าสรุป + ถอดหัวข้อคำอธิบายออกจากหน้าสรุป
CLAUDE.md sync โครงสร้าง/พฤติกรรมหน้าสรุปล่าสุด
verify: tsc strict ผ่าน + vitest 139/139 ผ่าน
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
parent
b1bc65e65d
commit
f9114b3f00
6 changed files with 570 additions and 114 deletions
|
|
@ -62,12 +62,14 @@ 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 ← หน้าสรุปหมายเหตุจัดกลุ่มตามหน้า (F15)
|
||||
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 = <details> การ์ด ไม่มี JS; generateMetadata ตั้งชื่อแท็บ browser ตามคดี; ตอนพิมพ์ header เป็นกรอบเส้นน้ำเงิน + ซ่อนแถบ action/สารบัญมือถือ — กฎ @media print อยู่ใน globals.css) — อย่าถอด class `summary-page` ของ <main> (กฎ coarse 44px ใน globals.css ผูกอยู่กับ selector นี้ ครอบทั้ง `a` และ `button`)
|
||||
components/summary/CollapsibleBlock.tsx ← client component เดียวของหน้าสรุป (state หุบ/กางกลุ่ม Bookmark/Annotation — children ส่งจาก server ได้ตรง ๆ)
|
||||
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 เดิม)
|
||||
|
|
|
|||
|
|
@ -1,21 +1,115 @@
|
|||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { asc, eq } from "drizzle-orm";
|
||||
import { notFound } from "next/navigation";
|
||||
import { ArrowLeft, MapPin } from "lucide-react";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Bookmark,
|
||||
Braces,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
ClipboardList,
|
||||
FileDown,
|
||||
StickyNote,
|
||||
} from "lucide-react";
|
||||
|
||||
import { AnnotationTypeIcon } from "@/components/AnnotationTypeIcon";
|
||||
import CollapsibleBlock from "@/components/summary/CollapsibleBlock";
|
||||
import { db } from "@/db";
|
||||
import { annotations, files, toAnnotationDTO, type AnnotationDTO } from "@/db/schema";
|
||||
import { annotationNote, annotationPreviewText, annotationTypeLabel } from "@/utils/annotation-display";
|
||||
import {
|
||||
annotations,
|
||||
bookmarks,
|
||||
files,
|
||||
toAnnotationDTO,
|
||||
toBookmarkDTO,
|
||||
type AnnotationDTO,
|
||||
type AnnotationType,
|
||||
type BookmarkDTO,
|
||||
} from "@/db/schema";
|
||||
import {
|
||||
annotationNote,
|
||||
annotationPreviewText,
|
||||
annotationTypeLabel,
|
||||
} from "@/utils/annotation-display";
|
||||
import { withBasePath } from "@/utils/base-path";
|
||||
|
||||
// หน้าสรุปหมายเหตุ (F15/D2) — server component รวมทุก annotation (ทุกผู้ใช้)
|
||||
// จัดกลุ่มตามหน้า พร้อมปุ่มกระโดดเป็น deep link `/annotate/<id>?ann=<uuid>` (F12)
|
||||
// หน้าสรุปหมายเหตุ (F15/D2) — server component รวม annotation (ทุกผู้ใช้) + bookmark
|
||||
// จัดกลุ่มตามหน้า แต่ละแถวกระโดดเป็น deep link `/annotate/<id>?ann=` / `?page=` (F12)
|
||||
// — design "แฟ้มสำนวน" 2026-09-17: แถบหัวกระดาษน้ำเงิน (สืบต่อจาก tile JN ของแบรนด์) +
|
||||
// สารบัญจุดไข่ปลา + การ์ดรายหน้า 1 หน้า 1 การ์ด (คำขอผู้ใช้ 2026-09-17 แทนแผ่น
|
||||
// กระดาษแผ่นเดียวคั่นเส้น) แต่ละการ์ดมีขอบ folio เลขหน้าใหญ่; สารบัญเป็น anchor
|
||||
// link (#page-N) ล้วน — desktop เป็น sticky aside แบบการ์ด ส่วนมือถือ (< lg) เป็น
|
||||
// <details> แบบการ์ด ไม่มี JS / client component เดียวของหน้าคือ CollapsibleBlock
|
||||
// (หุบ/กางกลุ่ม Bookmark/Annotation)
|
||||
|
||||
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<AnnotationType, string> = {
|
||||
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<Record<AnnotationType, number>>;
|
||||
resolvedCount: number;
|
||||
participants: Set<string>;
|
||||
};
|
||||
|
||||
/** บรรทัดสรุปของหน้า: "ไฮไลต์ 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
|
||||
* ของ <main>) */
|
||||
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<Metadata> {
|
||||
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,
|
||||
}: {
|
||||
|
|
@ -27,92 +121,348 @@ export default async function SummaryPage({
|
|||
const [file] = await db.select().from(files).where(eq(files.id, id));
|
||||
if (!file) notFound();
|
||||
|
||||
const rows = await db
|
||||
const annRows = await db
|
||||
.select()
|
||||
.from(annotations)
|
||||
.where(eq(annotations.fileId, id))
|
||||
.orderBy(asc(annotations.pageIndex), asc(annotations.createdAt));
|
||||
const dtos = rows.map(toAnnotationDTO);
|
||||
const dtos = annRows.map(toAnnotationDTO);
|
||||
|
||||
// จัดกลุ่มตามหน้า (เรียงหน้ามากไปน้อยไม่เกี่ยว — เดินตามลำดับหน้าเสมอ)
|
||||
const byPage = new Map<number, AnnotationDTO[]>();
|
||||
// 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<number, PageBucket>();
|
||||
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;
|
||||
};
|
||||
for (const a of dtos) {
|
||||
const list = byPage.get(a.pageIndex);
|
||||
if (list) list.push(a);
|
||||
else byPage.set(a.pageIndex, [a]);
|
||||
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 pages = Array.from(byPage.entries()).sort((x, y) => x[0] - y[0]);
|
||||
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) + สารบัญมือถือ (<details>)
|
||||
const allParticipants = Array.from(
|
||||
new Set([
|
||||
...dtos.map((a) => a.userName),
|
||||
...bmDtos.map((b) => b.userName || "ไม่ทราบชื่อ"),
|
||||
]),
|
||||
).sort((x, y) => x.localeCompare(y, "th"));
|
||||
|
||||
return (
|
||||
<main className="summary-page 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>
|
||||
<main className="summary-page mx-auto max-w-3xl px-4 py-6 lg:max-w-6xl lg:px-8">
|
||||
{/* หัวกระดาษสำนวน — แถบน้ำเงินสืบต่อจาก tile JN ของแบรนด์ (หน้ารายการ/login):
|
||||
ชื่อรายงาน + ชื่อแฟ้ม + สถิติในที่เดียว แล้วปิดท้ายด้วยเส้นคั่นบาง */}
|
||||
<header className="rounded-xl bg-primary px-5 py-4 text-white shadow-sm sm:px-6">
|
||||
<h1 className="text-lg font-semibold leading-snug">สรุปหมายเหตุ</h1>
|
||||
<p className="mt-0.5 break-words text-sm text-white/85">{file.title}</p>
|
||||
{pages.length > 0 && (
|
||||
<p className="mt-2 text-xs tabular-nums text-white/70">
|
||||
Annotation {dtos.length} รายการ · Bookmark {bmDtos.length} รายการ ·{" "}
|
||||
{pages.length} หน้า
|
||||
</p>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{/* แถบ action — ghost button ขาวมีขอบ ภาษาเดียวกับการ์ดของหน้า (คำขอผู้ใช้
|
||||
2026-09-17 แทนลิงก์ข้อความเปล่า — เห็นเป็นปุ่มชัดขึ้น; จอสัมผัสได้ 44px
|
||||
จากกฎ coarse; ตอนพิมพ์ซ่อนทิ้ง — ปุ่มกลับ/export ไม่มีความหมายบนกระดาษ) */}
|
||||
<div className="mt-3 flex flex-wrap items-center justify-between gap-x-2 gap-y-1 print:hidden">
|
||||
<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"
|
||||
className={`inline-flex items-center gap-1.5 rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-sm text-slate-700 shadow-sm hover:border-slate-300 hover:bg-slate-50 ${RING_CLS}`}
|
||||
>
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
<ArrowLeft aria-hidden className="h-3.5 w-3.5" />
|
||||
กลับหน้าอ่าน
|
||||
</Link>
|
||||
{/* F16/D3: export สรุปเป็น CSV (UTF-8 BOM) */}
|
||||
<a
|
||||
href={withBasePath(`/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 className="flex items-center gap-2">
|
||||
{/* F16/D3: export สรุปเป็น CSV (UTF-8 BOM) */}
|
||||
<a
|
||||
href={withBasePath(`/api/annotations/export-csv?fileId=${id}`)}
|
||||
className={`flex items-center gap-1.5 rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-sm text-slate-700 shadow-sm hover:border-slate-300 hover:bg-slate-50 ${RING_CLS}`}
|
||||
>
|
||||
<FileDown aria-hidden className="h-3.5 w-3.5" />
|
||||
ส่งออกสรุป (CSV)
|
||||
</a>
|
||||
{/* N7: ส่งออก Bookmark ทั้งหมดเป็น JSON (label บอกขอบเขตเอง — คู่กับ
|
||||
"ส่งออกสรุป (CSV)" ข้างบน ไม่ต้องพึ่ง title tooltip ที่จอสัมผัสใช้ไม่ได้) */}
|
||||
<a
|
||||
href={withBasePath(`/api/bookmarks/export?fileId=${id}`)}
|
||||
className={`flex items-center gap-1.5 rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-sm text-slate-700 shadow-sm hover:border-slate-300 hover:bg-slate-50 ${RING_CLS}`}
|
||||
>
|
||||
<Braces aria-hidden className="h-3.5 w-3.5" />
|
||||
ส่งออก Bookmark (JSON)
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{pages.length === 0 ? (
|
||||
<p className="mt-10 rounded border border-dashed p-6 text-center text-sm text-slate-500">
|
||||
ยังไม่มี annotation ในไฟล์นี้
|
||||
</p>
|
||||
<div className="mt-5 rounded-xl border border-dashed border-slate-300 bg-white/70 p-10 text-center">
|
||||
<ClipboardList aria-hidden className="mx-auto h-8 w-8 text-slate-300" />
|
||||
<p className="mt-3 text-sm font-medium text-slate-600">
|
||||
ยังไม่มี Annotation หรือ Bookmark ในไฟล์นี้
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-slate-500">
|
||||
เปิดหน้าอ่านเพื่อมาร์กข้อความ วาดเส้น ใส่คอมเมนต์ หรือคั่นหน้า
|
||||
</p>
|
||||
<Link
|
||||
href={`/annotate/${id}`}
|
||||
className={`mt-4 inline-flex rounded-md bg-primary px-4 py-2 text-sm font-medium text-white hover:opacity-90 ${RING_CLS}`}
|
||||
>
|
||||
เปิดหน้าอ่าน
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
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-[11px] 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 leading-relaxed">
|
||||
{annotationPreviewText(a)}
|
||||
</span>
|
||||
{annotationNote(a) && (
|
||||
<span className="mt-0.5 block whitespace-pre-wrap break-words text-xs leading-relaxed text-slate-500">
|
||||
โน้ต: {annotationNote(a)}
|
||||
</span>
|
||||
)}
|
||||
<div className="mt-5 lg:grid lg:grid-cols-[220px_minmax(0,1fr)] lg:items-start lg:gap-8">
|
||||
{/* สารบัญมือถือ (จอ < lg) — design review 2026-09-17 H2: คืน navigation ที่หาย
|
||||
ไปบนมือถือ (aside ด้านล่างแสดงเฉพาะ lg) ใช้ <details> ได้หุบ/กาง + keyboard
|
||||
โดยไม่มี JS เพิ่ม — จุดไข่ปลา + ผู้ร่วมทำ ภาษาเดียวกับ desktop (จอ lg กับ
|
||||
ตอนพิมพ์ซ่อน — display:none ไม่กินช่องของกริด) */}
|
||||
<details className="group rounded-xl border border-slate-200 bg-white shadow-sm lg:hidden print:hidden">
|
||||
<summary className="flex cursor-pointer list-none items-center justify-between rounded-md px-4 py-3 text-sm font-semibold text-slate-800 hover:text-primary [&::-webkit-details-marker]:hidden">
|
||||
สารบัญและผู้ร่วมทำ
|
||||
<ChevronDown
|
||||
aria-hidden
|
||||
className="h-4 w-4 shrink-0 text-slate-400 transition-transform group-open:rotate-180 motion-reduce:transition-none"
|
||||
/>
|
||||
</summary>
|
||||
<nav
|
||||
aria-label="สารบัญหน้าที่มีรายการ"
|
||||
className="flex flex-col gap-0.5 border-t border-slate-100 px-2 pb-2 pt-2"
|
||||
>
|
||||
{pages.map(([pi, b]) => (
|
||||
<a
|
||||
key={pi}
|
||||
href={`#page-${pi + 1}`}
|
||||
aria-label={`หน้า ${pi + 1}, ${b.anns.length + b.bms.length} รายการ`}
|
||||
className={`flex items-end gap-1.5 rounded-md px-2 py-2 text-sm text-slate-600 hover:text-primary ${RING_CLS}`}
|
||||
>
|
||||
<span className="leading-5">หน้า {pi + 1}</span>
|
||||
<span
|
||||
aria-hidden
|
||||
className="mb-1.5 min-w-4 flex-1 border-b border-dotted border-slate-400"
|
||||
/>
|
||||
<span className="leading-5 tabular-nums text-slate-500">
|
||||
{b.anns.length + b.bms.length}
|
||||
</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>
|
||||
</a>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))
|
||||
</nav>
|
||||
<p className="border-t border-slate-100 px-4 py-3 text-xs leading-relaxed text-slate-600">
|
||||
ผู้ร่วมทำ: {allParticipants.join(" · ")}
|
||||
</p>
|
||||
</details>
|
||||
|
||||
{/* สารบัญ (จอ ≥ lg) — สารบัญแบบจุดไข่ปลาของเอกสารราชการ anchor ล้วนไม่มี JS
|
||||
เป็นการ์ดชุดเดียวกับ <details> มือถือและการ์ดรายหน้า (แสดงเฉพาะหน้าที่มี
|
||||
รายการ ≥ 1 ตามหลัก minimize choices; max-h กันเอกสารหลายสิบหน้าสูงเกินจอ
|
||||
— ครึ่งล่างของ sticky กดไม่ได้ตลอดกาล) */}
|
||||
<aside className="hidden rounded-xl border border-slate-200 bg-white p-4 shadow-sm lg:sticky lg:top-6 lg:block lg:max-h-[calc(100vh-3rem)] lg:overflow-y-auto">
|
||||
<h2 className="text-sm font-semibold text-slate-800">สารบัญ</h2>
|
||||
<nav aria-label="สารบัญหน้าที่มีรายการ" className="mt-2 flex flex-col gap-1">
|
||||
{pages.map(([pi, b]) => (
|
||||
<a
|
||||
key={pi}
|
||||
href={`#page-${pi + 1}`}
|
||||
aria-label={`หน้า ${pi + 1}, ${b.anns.length + b.bms.length} รายการ`}
|
||||
className={`flex items-end gap-1.5 rounded-md px-1 text-sm text-slate-600 hover:text-primary ${RING_CLS}`}
|
||||
>
|
||||
<span className="leading-5">หน้า {pi + 1}</span>
|
||||
<span
|
||||
aria-hidden
|
||||
className="mb-1.5 min-w-4 flex-1 border-b border-dotted border-slate-400"
|
||||
/>
|
||||
<span className="leading-5 tabular-nums text-slate-500">
|
||||
{b.anns.length + b.bms.length}
|
||||
</span>
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<h2 className="mt-6 border-t border-slate-300/60 pt-4 text-sm font-semibold text-slate-800">
|
||||
ผู้ร่วมทำ
|
||||
</h2>
|
||||
<p className="mt-1.5 px-1 text-xs leading-relaxed text-slate-600">
|
||||
{allParticipants.join(" · ")}
|
||||
</p>
|
||||
</aside>
|
||||
|
||||
{/* การ์ดรายหน้า — 1 หน้า 1 การ์ด เรียงลงมาแยกด้วยช่องว่าง (คำขอผู้ใช้
|
||||
2026-09-17 แทนแผ่นกระดาษแผ่นเดียวคั่นเส้น); แต่ละการ์ดมีขอบ folio:
|
||||
เลขหน้าใหญ่ + เส้นดิ่ง แบบเลขหน้าบนขอบกระดาษ (mt-5 lg:mt-0 — ระยะจาก
|
||||
สารบัญ <details> บนมือถือ; จอ lg กริดจัดให้) */}
|
||||
<div className="mt-5 flex flex-col gap-4 lg:mt-0">
|
||||
{pages.map(([pi, b]) => {
|
||||
return (
|
||||
<section
|
||||
key={pi}
|
||||
id={`page-${pi + 1}`}
|
||||
aria-labelledby={`page-${pi + 1}-title`}
|
||||
className="scroll-mt-6 grid grid-cols-[2.75rem_1fr] rounded-xl border border-slate-200 bg-white shadow-sm sm:grid-cols-[3.75rem_1fr]"
|
||||
>
|
||||
{/* ขอบ folio — "หน้า" เล็ก + เลขใหญ่ชิดเส้นดิ่ง */}
|
||||
<div className="border-r border-slate-100 py-4 pl-3 pr-2 text-right sm:pl-4">
|
||||
<h2 id={`page-${pi + 1}-title`} className="tabular-nums">
|
||||
<span className="block text-[11px] leading-none text-slate-500">
|
||||
หน้า
|
||||
</span>
|
||||
<span className="mt-1 block text-2xl font-semibold leading-none text-primary">
|
||||
{pi + 1}
|
||||
</span>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* เนื้อหาของหน้า — บรรทัดสรุปหน้า (ไม่มีหัวข้อคำอธิบาย) /
|
||||
Bookmark / Annotation (หุบ-กางได้, รายการเยื้องจากหัวข้อ) */}
|
||||
<div className="min-w-0 py-3 pl-3 pr-3 sm:pl-4 sm:pr-5 sm:py-4">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm leading-relaxed text-slate-600">
|
||||
{pageSummaryLine(b)}
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href={`/annotate/${id}?page=${pi + 1}`}
|
||||
className={`inline-flex shrink-0 items-center gap-0.5 text-xs text-primary hover:underline ${RING_CLS}`}
|
||||
>
|
||||
เปิดหน้านี้
|
||||
<ChevronRight aria-hidden className="h-3 w-3" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* ที่คั่นหน้าของหน้านี้ทั้งหมด (root + sub) เรียงตาม yOffset */}
|
||||
{b.bms.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<CollapsibleBlock kind="bookmark" count={b.bms.length}>
|
||||
<ul className="divide-y divide-slate-100">
|
||||
{b.bms.map((bm) => {
|
||||
const parent = bm.parentId
|
||||
? bookmarkById.get(bm.parentId)
|
||||
: undefined;
|
||||
return (
|
||||
<li key={bm.id} className="ml-4">
|
||||
<Link
|
||||
href={`/annotate/${id}?page=${pi + 1}`}
|
||||
className={`group -mx-2 flex items-start gap-2 rounded-lg px-2 py-2.5 transition hover:bg-slate-50 ${RING_CLS}`}
|
||||
>
|
||||
<Bookmark
|
||||
aria-hidden
|
||||
className="mt-1 h-4 w-4 shrink-0 text-amber-500"
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block whitespace-pre-wrap break-words text-sm font-medium leading-snug text-slate-800">
|
||||
{bm.title}
|
||||
</span>
|
||||
<span className="mt-0.5 block truncate text-xs text-slate-500">
|
||||
โดย {bm.userName || "ไม่ทราบชื่อ"}
|
||||
{parent ? ` · ใน: ${parent.title}` : ""}
|
||||
</span>
|
||||
{bm.note && (
|
||||
<span className="mt-0.5 flex items-start gap-1 text-xs leading-relaxed text-slate-500">
|
||||
<StickyNote
|
||||
aria-hidden
|
||||
className="mt-0.5 h-3 w-3 shrink-0 text-amber-400"
|
||||
/>
|
||||
<span className="line-clamp-2 whitespace-pre-wrap break-words">
|
||||
{bm.note}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<ChevronRight
|
||||
aria-hidden
|
||||
className="mt-1.5 h-4 w-4 shrink-0 text-slate-300 group-hover:text-slate-400"
|
||||
/>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</CollapsibleBlock>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* หมายเหตุของหน้านี้ — แถวคลิกได้ทั้งแถว (เป้าใหญ่บนจอสัมผัส) */}
|
||||
{b.anns.length > 0 && (
|
||||
<div className={b.bms.length > 0 ? "mt-3" : "mt-2"}>
|
||||
<CollapsibleBlock kind="annotation" count={b.anns.length}>
|
||||
<ul className="divide-y divide-slate-100">
|
||||
{b.anns.map((a) => (
|
||||
<li key={a.id} className="ml-4">
|
||||
<Link
|
||||
href={`/annotate/${id}?ann=${a.id}`}
|
||||
className={`group -mx-2 flex items-start gap-2 rounded-lg px-2 py-2.5 transition hover:bg-slate-50 ${RING_CLS}`}
|
||||
>
|
||||
<AnnotationTypeIcon
|
||||
type={a.type}
|
||||
data={a.data}
|
||||
className="mt-1 h-4 w-4 shrink-0 text-slate-500"
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block whitespace-pre-wrap break-words text-sm leading-snug text-slate-800">
|
||||
{annotationPreviewText(a)}
|
||||
{a.type === "COMMENT" && a.resolved && (
|
||||
<span className="ml-1.5 whitespace-nowrap rounded border border-emerald-600/50 bg-emerald-50 px-1.5 py-0.5 text-xs text-emerald-700">
|
||||
จัดการแล้ว
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="mt-0.5 block text-xs text-slate-500">
|
||||
{annotationTypeLabel(a)} · โดย {a.userName}
|
||||
</span>
|
||||
{annotationNote(a) && (
|
||||
<span className="mt-0.5 flex items-start gap-1 text-xs leading-relaxed text-slate-500">
|
||||
<StickyNote
|
||||
aria-hidden
|
||||
className="mt-0.5 h-3 w-3 shrink-0 text-amber-400"
|
||||
/>
|
||||
<span className="line-clamp-2 whitespace-pre-wrap break-words">
|
||||
{annotationNote(a)}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<ChevronRight
|
||||
aria-hidden
|
||||
className="mt-1.5 h-4 w-4 shrink-0 text-slate-300 group-hover:text-slate-400"
|
||||
/>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CollapsibleBlock>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -197,6 +197,34 @@
|
|||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* design review 2026-09-17 H1: ปุ่มหุบ/กางของ CollapsibleBlock เป็น <button>
|
||||
ไม่ใช่ <a> จึงหลุดจากกฎข้างบน (สูงจริง ~29px) — ปุ่มเป็น flex w-full
|
||||
text-left อยู่แล้ว ใส่แค่ min-height (ไม่ต้อง display/justify แบบกฎ <a>) */
|
||||
main.summary-page button {
|
||||
min-height: 2.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── Print (หน้าสรุป) — design review 2026-09-17 L4 ───────────────────────
|
||||
หน้าสรุปมีโอกาสถูกสั่งพิมพ์แนบสำนวนสูง — browser ตัด background ตอนพิมพ์
|
||||
โดย default ทำให้แถบหัวน้ำเงินเหลือ text ขาวบนกระดาษขาว อ่านไม่ออกทั้งแถบ:
|
||||
แปลงเป็นกรอบเส้นน้ำเงิน + ตัวอักษรน้ำเงินแทน (อ่านได้บนเครื่องพิมพ์ขาวดำ
|
||||
ประหยัดหมึก) และไม่ผ่าหน้ากระดาษกลาง section ของหน้าเอกสาร (หน้าที่ยาวเกิน
|
||||
กระดาษเดียว browser เลื่อนทั้งก้อนขึ้นหน้าถัดไปเอง — break-inside เป็นคำ
|
||||
แนะนำ ไม่ใช่กฎเหล็ก) / แถบ action กับสารบัญมือถือซ่อนด้วย print:hidden ใน JSX */
|
||||
@media print {
|
||||
main.summary-page header {
|
||||
background: #fff;
|
||||
border: 1.5pt solid #0033a1;
|
||||
}
|
||||
main.summary-page header h1,
|
||||
main.summary-page header p {
|
||||
color: #0033a1;
|
||||
}
|
||||
main.summary-page section {
|
||||
break-inside: avoid-page;
|
||||
}
|
||||
}
|
||||
|
||||
/* UX review 2026-09-14 H2 → typography audit 2026-09-16 (T1 + Option B): webfont
|
||||
|
|
|
|||
58
components/AnnotationTypeIcon.tsx
Normal file
58
components/AnnotationTypeIcon.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import {
|
||||
ArrowRight,
|
||||
Circle,
|
||||
EyeOff,
|
||||
Highlighter,
|
||||
MessageSquare,
|
||||
Minus,
|
||||
Pencil,
|
||||
Square,
|
||||
Stamp,
|
||||
Strikethrough,
|
||||
Type,
|
||||
Underline,
|
||||
} from "lucide-react";
|
||||
|
||||
import type {
|
||||
AnnotationDTO,
|
||||
DrawData,
|
||||
HighlightData,
|
||||
ShapeData,
|
||||
} from "@/db/schema";
|
||||
|
||||
/** ไอคอนตามชนิด annotation — presentational ล้วน **ไม่มี "use client"** ให้หน้าสรุป
|
||||
* (server component, F15) ใช้ร่วมกับ sidebar (client) ได้: lucide icon เป็น SVG
|
||||
* component ล้วน render ฝั่ง server ได้ (ย้ายมาจาก AnnotationSidebar.tsx) */
|
||||
export function AnnotationTypeIcon({
|
||||
type,
|
||||
data,
|
||||
className = "h-4 w-4 shrink-0 text-slate-500",
|
||||
}: {
|
||||
type: AnnotationDTO["type"];
|
||||
data: AnnotationDTO["data"];
|
||||
className?: string;
|
||||
}) {
|
||||
if (type === "HIGHLIGHT") {
|
||||
const style = (data as HighlightData).style ?? "highlight";
|
||||
if (style === "underline") return <Underline className={className} />;
|
||||
if (style === "strikethrough") return <Strikethrough className={className} />;
|
||||
return <Highlighter className={className} />; // highlight / squiggle
|
||||
}
|
||||
if (type === "DRAW")
|
||||
return (data as DrawData).pen === "highlighter" ? (
|
||||
<Highlighter className={className} />
|
||||
) : (
|
||||
<Pencil className={className} />
|
||||
);
|
||||
if (type === "REDACTION") return <EyeOff className={className} />;
|
||||
if (type === "TEXTBOX") return <Type className={className} />;
|
||||
if (type === "STAMP") return <Stamp className={className} />;
|
||||
if (type === "SHAPE") {
|
||||
const shape = (data as ShapeData).shape;
|
||||
if (shape === "rect") return <Square className={className} />;
|
||||
if (shape === "ellipse") return <Circle className={className} />;
|
||||
if (shape === "line") return <Minus className={className} />;
|
||||
return <ArrowRight className={className} />;
|
||||
}
|
||||
return <MessageSquare className={className} />;
|
||||
}
|
||||
|
|
@ -3,35 +3,25 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState, type FormEvent } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import {
|
||||
ArrowRight,
|
||||
Bookmark,
|
||||
Braces,
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Circle,
|
||||
Clock,
|
||||
Copy,
|
||||
EyeOff,
|
||||
Highlighter,
|
||||
MessageSquare,
|
||||
Minus,
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
Plus,
|
||||
Search,
|
||||
SlidersHorizontal,
|
||||
Square,
|
||||
Stamp,
|
||||
StickyNote,
|
||||
Trash2,
|
||||
Type,
|
||||
Underline,
|
||||
Strikethrough,
|
||||
X,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
import { AnnotationTypeIcon } from "../AnnotationTypeIcon";
|
||||
import Select from "../Select";
|
||||
|
||||
import {
|
||||
|
|
@ -46,10 +36,7 @@ import type {
|
|||
AnnotationDTO,
|
||||
BookmarkDTO,
|
||||
CommentData,
|
||||
DrawData,
|
||||
HighlightData,
|
||||
RedactionData,
|
||||
ShapeData,
|
||||
} from "@/db/schema";
|
||||
import {
|
||||
annotationNote,
|
||||
|
|
@ -69,33 +56,6 @@ import { formatThaiDate } from "@/utils/date-format";
|
|||
const FOCUS_RING_CLS =
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1 focus-visible:z-10";
|
||||
|
||||
function TypeIcon({ type, data }: { type: AnnotationDTO["type"]; data: AnnotationDTO["data"] }) {
|
||||
const cls = "h-4 w-4 shrink-0 text-slate-500";
|
||||
if (type === "HIGHLIGHT") {
|
||||
const style = (data as HighlightData).style ?? "highlight";
|
||||
if (style === "underline") return <Underline className={cls} />;
|
||||
if (style === "strikethrough") return <Strikethrough className={cls} />;
|
||||
return <Highlighter className={cls} />; // highlight / squiggle
|
||||
}
|
||||
if (type === "DRAW")
|
||||
return (data as DrawData).pen === "highlighter" ? (
|
||||
<Highlighter className={cls} />
|
||||
) : (
|
||||
<Pencil className={cls} />
|
||||
);
|
||||
if (type === "REDACTION") return <EyeOff className={cls} />;
|
||||
if (type === "TEXTBOX") return <Type className={cls} />;
|
||||
if (type === "STAMP") return <Stamp className={cls} />;
|
||||
if (type === "SHAPE") {
|
||||
const shape = (data as ShapeData).shape;
|
||||
if (shape === "rect") return <Square className={cls} />;
|
||||
if (shape === "ellipse") return <Circle className={cls} />;
|
||||
if (shape === "line") return <Minus className={cls} />;
|
||||
return <ArrowRight className={cls} />;
|
||||
}
|
||||
return <MessageSquare className={cls} />;
|
||||
}
|
||||
|
||||
/** ใส่/ล้างโน้ตคำอธิบายใน data ของ annotation (ทุกชนิดยกเว้น COMMENT — message ทำหน้าที่นี้แล้ว) */
|
||||
function withNote(data: Exclude<AnnotationData, CommentData>, note: string): AnnotationData {
|
||||
return note ? { ...data, note } : { ...data, note: undefined };
|
||||
|
|
@ -952,7 +912,7 @@ export default function AnnotationSidebar({
|
|||
onGoToAnnotation(a);
|
||||
}}
|
||||
>
|
||||
<TypeIcon type={a.type} data={a.data} />
|
||||
<AnnotationTypeIcon type={a.type} data={a.data} />
|
||||
<span className="min-w-0">
|
||||
{/* UX audit: อักษรไทยมีสระ/วรรณยุกต์ซ้อนหลายชั้น — ข้อความหลักต้อง 13px
|
||||
จึงอ่านได้สบาย (บรรทัดรอง/โน้ตคง 11px) */}
|
||||
|
|
|
|||
58
components/summary/CollapsibleBlock.tsx
Normal file
58
components/summary/CollapsibleBlock.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
"use client";
|
||||
|
||||
import { useId, useState, type ReactNode } from "react";
|
||||
import { Bookmark, ChevronDown, ChevronRight, PenLine } from "lucide-react";
|
||||
|
||||
/** หุบ/กางกลุ่มรายการ (Bookmark/Annotation) ในหน้าสรุป — client component ตัวเดียว
|
||||
* ของหน้า: เก็บแค่ state หุบ/กางต่อบล็อก เริ่มกางไว้เสมอ (เปิดหน้าสรุปครั้งแรกเห็น
|
||||
* ครบ ผู้ใช้พับเฉพาะกลุ่มที่ไม่สนใจ) — รายการ (children) ส่งมาจาก server component
|
||||
* ได้ตรง ๆ และเยื้องเข้า (pl-5) ให้หัวข้อกับรายการไม่ชิดเส้นซ้ายเดียวกัน */
|
||||
const HEADS = {
|
||||
bookmark: { label: "Bookmark", Icon: Bookmark, iconCls: "text-amber-500" },
|
||||
annotation: { label: "Annotation", Icon: PenLine, iconCls: "text-slate-400" },
|
||||
} as const;
|
||||
|
||||
export default function CollapsibleBlock({
|
||||
kind,
|
||||
count,
|
||||
children,
|
||||
}: {
|
||||
kind: keyof typeof HEADS;
|
||||
count: number;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const [open, setOpen] = useState(true);
|
||||
// design review 2026-09-17 L2: aria-controls ชี้ id ของก้อนเนื้อหา (useId
|
||||
// กันชนกันระหว่างหลายหน้าเอกสารที่ render บล็อกนี้ซ้ำ)
|
||||
const contentId = useId();
|
||||
const { label, Icon, iconCls } = HEADS[kind];
|
||||
return (
|
||||
<div>
|
||||
{/* h3 ครอบ button = disclosure pattern มาตรฐาน (heading level คงเดิม) */}
|
||||
<h3>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
aria-controls={contentId}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="flex w-full items-center gap-1.5 rounded-md py-1.5 text-left text-xs font-semibold text-slate-500 hover:text-slate-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1"
|
||||
>
|
||||
{open ? (
|
||||
<ChevronDown aria-hidden className="h-3.5 w-3.5 shrink-0 text-slate-400" />
|
||||
) : (
|
||||
<ChevronRight aria-hidden className="h-3.5 w-3.5 shrink-0 text-slate-400" />
|
||||
)}
|
||||
<Icon aria-hidden className={`h-3.5 w-3.5 shrink-0 ${iconCls}`} />
|
||||
{label}
|
||||
<span className="font-normal tabular-nums">({count})</span>
|
||||
<span aria-hidden className="ml-1 h-px min-w-4 flex-1 bg-slate-100" />
|
||||
</button>
|
||||
</h3>
|
||||
{open && (
|
||||
<div id={contentId} className="pl-5">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue