294 lines
14 KiB
TypeScript
294 lines
14 KiB
TypeScript
|
|
"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>
|
||
|
|
)}
|
||
|
|
</>
|
||
|
|
);
|
||
|
|
}
|