groups+summary: redesign หน้า /groups/[typeId] + หน้าสรุปเป็น explorer 2 คอลัมน์ (ตัวกรองซ้าย + list เดียว) — หน้าสรุปใช้มิติแรก "หน้า" ตามแบบหน้ากลุ่มที่ใช้ "ไฟล์" — (1) หน้า /groups/[typeId]: ถอด navbar (เหลือ back link — AppNav เหลือใช้ที่หน้า / อย่างเดียว) + ถอดหมายเหตุ "แสดงเฉพาะเอกสารที่เคยเปิดในระบบนี้", query ทั้งหมดฝั่ง server ส่ง GroupFileSummary[]+GroupListItem[] ให้ GroupExplorer กรองใน memory — ตัวกรอง tree menu 2 ระดับสไตล์ Quasar docs 4 กลุ่ม (ไฟล์/ชนิดรายการ/ชนิดของ Annotation/ผู้บันทึก — ชนิดรายการเป็น checkbox คู่ ไม่เลือก = แสดงทั้งหมด, ติ๊กชนิด → เลือก Annotation อัตโนมัติ, เลิกเลือก Annotation → ล้างชนิดค้าง) + GroupList แถว passive จุดคลิกเดียวคือปุ่ม "View" (2) แยกแผงตัวกรองเป็น component กลาง components/explorer/FacetFilterPanel.tsx — มิติแรกส่งเข้าทาง prop firstGroup (groups = ไฟล์ / สรุป = หน้า พร้อม optionsScroll ให้ scroll ภายในกลุ่ม max-h-64 กันหลายสิบหน้าดันกลุ่มอื่นออกจอ) + TYPE_SHORT/TYPE_ORDER รวมจาก 2 ที่ที่เคย define ซ้ำมาอยู่ utils/annotation-display.ts จุดเดียว (3) หน้าสรุป /annotate/[id]/summary: server ส่ง ExplorerItem[] (หน้า → bookmark ก่อน annotation → ในหน้าเดียวกัน bookmark เรียง yOffset ตาม N5/annotation ตาม createdAt) ให้ SummaryExplorer + SummaryList การ์ดรายหน้าหัวการ์ด sticky (เลขหน้า + บรรทัดสรุปหน้าคำนวณจากรายการที่เห็นจริงหลังกรอง + ปุ่มเปิดหน้านี้) + แถว passive ปุ่ม View deep link ?ann=/?page= เหมือนหน้ากลุ่ม — ถอดรายการตามหน้าจุดไข่ปลา + การ์ด folio + CollapsibleBlock ออก (anchor #page-N ของ section คงไว้ให้ลิงก์เก่ายังใช้ได้) (4) ตอนพิมพ์แสดงฉบับเต็มเสมอ — ตัวกรองที่ตั้งค้างต้องไม่ตัดรายการออกเงียบ ๆ บนกระดาษ (ตอนกรองอยู่ render ชุดบนจอ print:hidden + ชุดเต็ม hidden print:block, prop anchorIds กัน duplicate id) + กฎ print ใหม่ใน globals.css (section>ul>li break-inside:avoid-page + .page-head position:static) (5) ผ่าน code review 2026-09-24 (tsc/build/vitest 144 tests): คืน N5 yOffset ให้ bookmark ต่อหน้าที่ sort createdAt ทับไปโดยไม่ตั้งใจ + จดรับความเสี่ยง localeCompare "th" รันทั้ง SSR/hydration ไว้ที่ comment พร้อมทางแก้ + docs sync ครบ (CLAUDE.md โครงสร้างไฟล์ + PLAN-FEAT ถอดหมายเหตุ + PLAN-DOCSET อ้าง FacetFilterPanel) (คำขอผู้ใช้ 2026-09-24)
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
parent
c5c6b65bd2
commit
df502088b7
14 changed files with 1522 additions and 616 deletions
293
components/groups/GroupList.tsx
Normal file
293
components/groups/GroupList.tsx
Normal file
|
|
@ -0,0 +1,293 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
Bookmark,
|
||||
ClipboardList,
|
||||
FileImage,
|
||||
FileText,
|
||||
ListFilter,
|
||||
StickyNote,
|
||||
} from "lucide-react";
|
||||
|
||||
import { AnnotationTypeIcon } from "@/components/AnnotationTypeIcon";
|
||||
import {
|
||||
annotationNote,
|
||||
annotationPreviewText,
|
||||
annotationTypeLabel,
|
||||
} from "@/utils/annotation-display";
|
||||
import { formatThaiDate } from "@/utils/date-format";
|
||||
|
||||
import type { GroupFileSummary, GroupListItem } from "./GroupExplorer";
|
||||
|
||||
// คอลัมน์ content ของหน้ากลุ่ม (design 2026-09-24) — list เดียวรวมทั้งกลุ่ม
|
||||
// ไม่แยกไฟล์ (ตัวกรองฝั่งซ้ายทำหน้าที่แยกให้แล้ว) แต่ละแถวบอกไฟล์ที่มาที่
|
||||
// ต้น meta line — แถวเป็น passive surface จุดคลิกเดียวคือปุ่ม "View" ท้ายแถว
|
||||
// (deep link ?ann= / ?page= เดียวกับหน้าสรุป — คำขอผู้ใช้ 2026-09-24 ไม่กดทั้งแถว
|
||||
// ทำให้พ้นข้อจำกัด interactive ซ้อน <a> เดิม และกฎ coarse ของ listing-page ที่
|
||||
// display:inline-flex ชนะ utility flex ก็ไม่กระทบแถวอีกต่อไป — เหลือแค่ปุ่ม)
|
||||
|
||||
/** จุดคั่น "·" ของ meta line */
|
||||
function Sep() {
|
||||
return <span aria-hidden className="text-ink-faint">·</span>;
|
||||
}
|
||||
|
||||
/** meta line: ไฟล์ที่มา (truncate เฉพาะ span ของตัวเอง — ไม่งั้นชื่อยาวกินบรรทัด
|
||||
* จนตัด หน้า/ผู้บันทึก/วันที่ ทิ้ง) → หน้า → บริบทกลาง → ผู้บันทึก → วันที่
|
||||
* flex-wrap ให้วันที่เต็มรูปแบบไทยตกบรรทัดใหม่บนมือถืออย่างสง่า — บริบทกลาง
|
||||
* ("ใน: …" ของ sub bookmark — ชื่อ parent มาจากข้อความที่คลุม ยาวไม่มีขอบเขต)
|
||||
* กับ "โดย …" ต้อง min-w-0+truncate ด้วย: whitespace-nowrap เปล่า ๆ หดไม่ได้
|
||||
* ล้นการ์ดแล้วโดน overflow-hidden ตัดดื้อ ๆ ไม่มี ellipsis (review 2026-09-24) */
|
||||
function RowMeta({
|
||||
file,
|
||||
pageIndex,
|
||||
middle,
|
||||
user,
|
||||
date,
|
||||
}: {
|
||||
file: GroupFileSummary;
|
||||
pageIndex: number;
|
||||
/** ชื่อชนิดของ annotation หรือ "ใน: …" ของ sub bookmark */
|
||||
middle?: string;
|
||||
user: string;
|
||||
date: string;
|
||||
}) {
|
||||
return (
|
||||
<span className="mt-0.5 flex flex-wrap items-center gap-x-1.5 gap-y-0.5 text-xs text-ink-soft">
|
||||
<span className="inline-flex min-w-0 max-w-[12rem] items-center gap-1 sm:max-w-[16rem]">
|
||||
{file.isImage ? (
|
||||
<FileImage aria-hidden className="h-3 w-3 shrink-0" />
|
||||
) : (
|
||||
<FileText aria-hidden className="h-3 w-3 shrink-0" />
|
||||
)}
|
||||
<span className="truncate" title={file.title}>
|
||||
{file.title}
|
||||
</span>
|
||||
</span>
|
||||
<Sep />
|
||||
<span className="whitespace-nowrap">หน้า {pageIndex + 1}</span>
|
||||
{middle && (
|
||||
<>
|
||||
<Sep />
|
||||
<span className="min-w-0 max-w-full truncate" title={middle}>
|
||||
{middle}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<Sep />
|
||||
<span className="min-w-0 max-w-full truncate">โดย {user}</span>
|
||||
<Sep />
|
||||
{/* formatThaiDate แปลงตาม timezone เครื่องที่ render — SSR/server กับ
|
||||
hydration/ผู้ใช้อาจต่างกัน ใส่ suppressHydrationWarning ตาม convention
|
||||
(เดียวกับ AnnotationSidebar/PDFViewer) */}
|
||||
<span className="whitespace-nowrap" suppressHydrationWarning>
|
||||
{date}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** โน้ตคำอธิบาย (N1) / โน้ตย่อยของ bookmark (N2) — 2 บรรทัดพอ */
|
||||
function RowNote({ text }: { text: string }) {
|
||||
return (
|
||||
<span className="mt-0.5 flex items-start gap-1 text-xs leading-relaxed text-ink-soft">
|
||||
<StickyNote aria-hidden className="mt-0.5 h-3 w-3 shrink-0 text-warning-ink" />
|
||||
<span className="line-clamp-2 whitespace-pre-wrap break-words">{text}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** ปุ่ม secondary "เปิดเอกสารนี้" — ทางเข้าไฟล์ที่ยังไม่มีรายการใด ๆ
|
||||
* (หน้าเดิมมีปุ่ม Annotate ทุกการ์ด — เก็บไว้ตามคำตัดสินใจผู้ใช้ 2026-09-24) */
|
||||
function OpenFileButton({ file }: { file: GroupFileSummary }) {
|
||||
return (
|
||||
<Link
|
||||
href={`/annotate/${file.id}`}
|
||||
className="focus-ring inline-flex items-center gap-1.5 rounded-md border border-line bg-white px-3 py-1.5 text-sm font-medium text-ink transition hover:-translate-y-px hover:border-ink-faint"
|
||||
>
|
||||
{file.isImage ? (
|
||||
<FileImage aria-hidden className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<FileText aria-hidden className="h-3.5 w-3.5" />
|
||||
)}
|
||||
เปิดเอกสารนี้
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export function GroupList({
|
||||
items,
|
||||
files,
|
||||
totalCount,
|
||||
selectedFile,
|
||||
selectedFileTotal,
|
||||
activeCount,
|
||||
onReset,
|
||||
}: {
|
||||
/** ผ่านตัวกรองแล้ว (เรียงลำดับจาก server คงเดิม) */
|
||||
items: GroupListItem[];
|
||||
files: GroupFileSummary[];
|
||||
totalCount: number;
|
||||
selectedFile: GroupFileSummary | null;
|
||||
/** จำนวนรายการทั้งหมดของไฟล์ที่เลือก (ก่อนกรอง) — null = ไม่ได้เลือกไฟล์เดี่ยว */
|
||||
selectedFileTotal: number | null;
|
||||
activeCount: number;
|
||||
onReset: () => void;
|
||||
}) {
|
||||
const fileById = useMemo(
|
||||
() => new Map(files.map((f) => [f.id, f] as const)),
|
||||
[files],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* จำนวนผลลัพธ์อยู่ต้นคอลัมน์ content (ที่เห็นผลของตัวกรอง) ไม่ใช่ในแผง */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-1.5 px-1 pb-2.5">
|
||||
<p className="text-sm text-ink-soft">
|
||||
พบ{" "}
|
||||
<span className="font-semibold tabular-nums text-ink">
|
||||
{items.length}
|
||||
</span>{" "}
|
||||
รายการ
|
||||
</p>
|
||||
<div className="flex items-center gap-2.5">
|
||||
{selectedFile && <OpenFileButton file={selectedFile} />}
|
||||
{activeCount > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onReset}
|
||||
className="focus-ring rounded-md px-1.5 text-sm text-primary transition hover:underline"
|
||||
>
|
||||
ล้างตัวกรอง
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{items.length > 0 ? (
|
||||
<div className="overflow-hidden rounded-xl border border-line bg-white">
|
||||
<ul className="divide-y divide-line/70">
|
||||
{items.map((item) => {
|
||||
// items สร้างจาก files ชุดเดียวกันตอน build ที่ server — หาได้เสมอ
|
||||
const file = fileById.get(item.dto.fileId)!;
|
||||
// ปลายทาง + คำบอกตำแหน่งต่อชนิดรายการ (title tooltip + aria-label
|
||||
// ของปุ่ม View — แถวเดียวกันหลายสิบปุ่มชื่อเหมือนกัน ต้องรู้จักแยก)
|
||||
const isBm = item.kind === "bm";
|
||||
const where = isBm
|
||||
? `ไปที่หน้า ${item.pageIndex + 1}`
|
||||
: `ดู${annotationTypeLabel(item.dto)}ที่หน้า ${item.pageIndex + 1}`;
|
||||
const href = isBm
|
||||
? `/annotate/${item.dto.fileId}?page=${item.pageIndex + 1}`
|
||||
: `/annotate/${item.dto.fileId}?ann=${item.dto.id}`;
|
||||
return (
|
||||
// แถวเป็น passive surface — จุดคลิกเดียวคือปุ่ม "View" ท้ายแถว
|
||||
// (คำขอผู้ใช้ 2026-09-24: ไม่ต้องกดทั้งแถว) จึงไม่มี hover bg ที่แถว
|
||||
<li
|
||||
key={item.dto.id}
|
||||
className="flex items-start gap-2.5 px-3 py-3 sm:px-4"
|
||||
>
|
||||
{isBm ? (
|
||||
<>
|
||||
<Bookmark
|
||||
aria-hidden
|
||||
className="mt-1 h-4 w-4 shrink-0 text-warning-ink"
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block whitespace-pre-wrap break-words text-sm font-medium leading-snug text-ink">
|
||||
{item.dto.title}
|
||||
</span>
|
||||
<RowMeta
|
||||
file={file}
|
||||
pageIndex={item.pageIndex}
|
||||
middle={
|
||||
item.parentTitle
|
||||
? `ใน: ${item.parentTitle}`
|
||||
: undefined
|
||||
}
|
||||
user={item.dto.userName || "ไม่ทราบชื่อ"}
|
||||
date={formatThaiDate(item.dto.createdAt, true)}
|
||||
/>
|
||||
{item.dto.note && <RowNote text={item.dto.note} />}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<AnnotationTypeIcon
|
||||
type={item.dto.type}
|
||||
data={item.dto.data}
|
||||
className="mt-1 h-4 w-4 shrink-0 text-ink-soft"
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block whitespace-pre-wrap break-words text-sm leading-snug text-ink">
|
||||
{annotationPreviewText(item.dto)}
|
||||
{item.dto.type === "COMMENT" && item.dto.resolved && (
|
||||
<span className="ml-1.5 whitespace-nowrap rounded border border-success/40 bg-success/10 px-1.5 py-0.5 text-xs font-medium text-success-ink">
|
||||
จัดการแล้ว
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<RowMeta
|
||||
file={file}
|
||||
pageIndex={item.pageIndex}
|
||||
middle={annotationTypeLabel(item.dto)}
|
||||
user={item.dto.userName}
|
||||
date={formatThaiDate(item.dto.createdAt, true)}
|
||||
/>
|
||||
{annotationNote(item.dto) && (
|
||||
<RowNote text={annotationNote(item.dto)} />
|
||||
)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<Link
|
||||
href={href}
|
||||
title={`${where} ใน "${file.title}"`}
|
||||
aria-label={`View — ${where} ใน "${file.title}"`}
|
||||
className="focus-ring mt-0.5 inline-flex h-8 shrink-0 items-center rounded-md border border-line bg-white px-3 text-sm font-medium text-ink transition hover:-translate-y-px hover:border-primary hover:text-primary"
|
||||
>
|
||||
View
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
) : totalCount === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-line bg-white/70 p-10 text-center">
|
||||
<ClipboardList aria-hidden className="mx-auto h-8 w-8 text-ink-faint" />
|
||||
<p className="mt-3 text-sm font-medium text-ink-soft">
|
||||
ยังไม่มี Annotation หรือ Bookmark ในกลุ่มนี้
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-ink-soft">
|
||||
เปิดเอกสารในกลุ่มเพื่อมาร์กข้อความ วาดเส้น ใส่คอมเมนต์ หรือคั่นหน้า
|
||||
</p>
|
||||
</div>
|
||||
) : selectedFile && selectedFileTotal === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-line bg-white/70 p-10 text-center">
|
||||
<ClipboardList aria-hidden className="mx-auto h-8 w-8 text-ink-faint" />
|
||||
<p className="mt-3 text-sm font-medium text-ink-soft">
|
||||
ไฟล์นี้ยังไม่มี Annotation หรือ Bookmark
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<OpenFileButton file={selectedFile} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl border border-dashed border-line bg-white/70 p-10 text-center">
|
||||
<ListFilter aria-hidden className="mx-auto h-8 w-8 text-ink-faint" />
|
||||
<p className="mt-3 text-sm font-medium text-ink-soft">
|
||||
ไม่มีรายการตรงตามตัวกรอง
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onReset}
|
||||
className="focus-ring mt-4 inline-flex rounded-md border border-line bg-white px-4 py-2 text-sm font-medium text-ink transition hover:-translate-y-px hover:border-ink-faint"
|
||||
>
|
||||
ล้างตัวกรองทั้งหมด
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue