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
|
|
@ -1,7 +1,8 @@
|
|||
// Genesis (docs/DESIGN.md) Navigation: sticky แถบบน 56px + backdrop-blur + เส้นแบ่ง
|
||||
// ล่าง 1px — โลโก้ซ้าย / ผู้ใช้ + ออกจากระบบ ขวา (แอปหน้าเดียวต่อ context จึงไม่มี
|
||||
// ลิงก์กลางจอ) ใช้ร่วมทุกหน้าเนื้อหา (หน้ารายการ /, กลุ่ม /groups/[typeId]) —
|
||||
// แทน SessionBar เดิมที่แยกเป็นแถบของตัวเองกลางหน้า
|
||||
// ลิงก์กลางจอ) ใช้เฉพาะหน้ารายการ / — หน้ากลุ่ม /groups/[typeId] เลิกใช้ตาม
|
||||
// redesign 2026-09-24 (ไม่มี navbar เหลือ back link) — แทน SessionBar เดิมที่แยก
|
||||
// เป็นแถบของตัวเองกลางหน้า
|
||||
import Link from "next/link";
|
||||
|
||||
import LogoutButton from "./LogoutButton";
|
||||
|
|
|
|||
354
components/explorer/FacetFilterPanel.tsx
Normal file
354
components/explorer/FacetFilterPanel.tsx
Normal file
|
|
@ -0,0 +1,354 @@
|
|||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
Bookmark,
|
||||
Check,
|
||||
ChevronRight,
|
||||
FileText,
|
||||
Layers,
|
||||
PenLine,
|
||||
Shapes,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
|
||||
import { AnnotationTypeIcon } from "@/components/AnnotationTypeIcon";
|
||||
import type {
|
||||
AnnotationData,
|
||||
AnnotationDTO,
|
||||
AnnotationType,
|
||||
BookmarkDTO,
|
||||
} from "@/db/schema";
|
||||
import { TYPE_ORDER, TYPE_SHORT } from "@/utils/annotation-display";
|
||||
|
||||
// แผงตัวกรอง facet กลาง — tree menu 2 ระดับสไตล์ Quasar docs q-tree--dense
|
||||
// no-connectors (design 2026-09-24 ตามคำขอผู้ใช้: ธีม Genesis พื้นสว่าง + dense
|
||||
// บน desktop แต่เป้าแตะ ≥44px บนจอสัมผัส) — ย้ายมาจาก FilterControls ของหน้า
|
||||
// กลุ่ม (2026-09-24) เพื่อใช้ร่วมกับหน้าสรุป: มิติแรก (ไฟล์/หน้า) ส่งเข้ามาทาง
|
||||
// prop `firstGroup` ส่วนอีก 3 กลุ่ม (ชนิดรายการ/ชนิดของ Annotation/ผู้บันทึก)
|
||||
// เหมือนกันทุกหน้า — parent = <details> หุบ/กางอิสระ (chevron หมุน 90°),
|
||||
// leaf = <button> dense ไอคอน + ชื่อ + badge pill เลือก button แทน label+input
|
||||
// เพราะโดนกฎ coarse 44px ของ `main.listing-page`/`main.summary-page` ใน
|
||||
// globals.css พอดี (สรุปเป็น desktop-dense / touch-44 โดยไม่ต้องเพิ่ม CSS)
|
||||
|
||||
/** รายการรวมของ explorer — server เรียงตามลำดับอ่านเอกสารให้แล้ว
|
||||
* (หน้า → หน้าเดียวกัน bookmark ก่อน annotation → createdAt) */
|
||||
export type ExplorerItem =
|
||||
| { kind: "bm"; pageIndex: number; dto: BookmarkDTO; parentTitle: string | null }
|
||||
| { kind: "ann"; pageIndex: number; dto: AnnotationDTO };
|
||||
|
||||
/** state ตัวกรองกลาง — มิติแรก (`first`) เป็นไฟล์ id (หน้ากลุ่ม) หรือเลขหน้า
|
||||
* (หน้าสรุป); null/Set ว่าง = default "แสดงทั้งหมด" ทุกช่อง */
|
||||
export type FacetFilterState = {
|
||||
first: string | number | null;
|
||||
/** ชนิดรายการที่สนใจ — **ว่าง = แสดงทั้งหมด** (default), ติ๊กอะไรเห็นเฉพาะ
|
||||
* สิ่งนั้น (direct selection — คำขอผู้ใช้ 2026-09-24 แทนแบบเดิมที่เปิดครบ
|
||||
* ทั้งคู่ไว้แล้วต้องปิดเอง) */
|
||||
kinds: Set<"bm" | "ann">;
|
||||
/** ชนิดของ Annotation ที่สนใจ — กติกาเดียวกับ kinds: **ว่าง = ทุกชนิด** */
|
||||
types: Set<AnnotationType>;
|
||||
userId: string | null;
|
||||
};
|
||||
|
||||
/** สร้างใหม่ทุกครั้ง — ห้ามแชร์ object default เดียว (Set ถูก mutate ข้าม
|
||||
* หน้า/ข้าม render ได้) */
|
||||
export function createDefaultFacets(): FacetFilterState {
|
||||
return { first: null, kinds: new Set(), types: new Set(), userId: null };
|
||||
}
|
||||
|
||||
export type UserOption = { userId: string; label: string };
|
||||
|
||||
/** facet count แบบ static จากข้อมูลทั้งหมด (ไม่หดตามตัวกรองอื่น — กันเข้าใจ
|
||||
* ผิดว่าตัวเลือกหายไปตอนไล่กรอง); มิติแรกไม่มีในนี้ — นับไว้ใน options ของ
|
||||
* แต่ละตัวเลือกเอง */
|
||||
export type FacetCounts = {
|
||||
total: number;
|
||||
bm: number;
|
||||
ann: number;
|
||||
type: Partial<Record<AnnotationType, number>>;
|
||||
user: Record<string, number>;
|
||||
};
|
||||
|
||||
/** ตัวเลือกของมิติแรก (ไฟล์/หน้า) — icon ส่งเข้ามาเอง (ไฟล์ = FileText/
|
||||
* FileImage, หน้า = spacer ให้เลขชิดซ้ายตรงแนวกลุ่มผู้บันทึก) */
|
||||
export type FirstDimensionOption = {
|
||||
key: string | number;
|
||||
label: string;
|
||||
icon?: ReactNode;
|
||||
count: number;
|
||||
};
|
||||
|
||||
export type FirstDimensionGroup = {
|
||||
label: string;
|
||||
parentIcon: ReactNode;
|
||||
allLabel: string;
|
||||
allIcon?: ReactNode;
|
||||
options: FirstDimensionOption[];
|
||||
/** true = ตัวเลือกเยอะ (เช่น "หน้า" หลายสิบแถว) — ครอบด้วย max-h + scroll
|
||||
* ภายในกลุ่มกันกลุ่มอื่นถูกดันออกนอกจอ (facet หลักต้องเห็นใน first
|
||||
* viewport ของแผง) */
|
||||
optionsScroll?: boolean;
|
||||
};
|
||||
|
||||
/** data ตัวแทน (ค่าว่างทั้งหมด — เอาไว้เลือกไอคอนเท่านั้น ไม่เคยเข้าถึงจริง)
|
||||
* เพื่อ reuse AnnotationTypeIcon ในแถวกรองโดยไม่ต้องคอป map ไอคอนซ้ำ */
|
||||
const TYPE_ICON_DATA: Record<AnnotationType, AnnotationData> = {
|
||||
HIGHLIGHT: { text: "", color: "", rects: [], style: "highlight" },
|
||||
DRAW: { strokeColor: "", strokeWidth: 0, paths: [], pen: "pencil" },
|
||||
COMMENT: { position: { x: 0, y: 0 }, message: "" },
|
||||
SHAPE: {
|
||||
shape: "rect",
|
||||
rect: { x: 0, y: 0, width: 0, height: 0 },
|
||||
strokeColor: "",
|
||||
strokeWidth: 0,
|
||||
},
|
||||
REDACTION: { rect: { x: 0, y: 0, width: 0, height: 0 } },
|
||||
TEXTBOX: { position: { x: 0, y: 0 }, text: "", color: "", fontSize: 0 },
|
||||
STAMP: { label: "", position: { x: 0, y: 0 }, scale: 0 },
|
||||
};
|
||||
|
||||
const PARENT_ICON_CLS = "h-4 w-4 shrink-0 text-ink-soft";
|
||||
const LEAF_ICON_CLS = "h-4 w-4 shrink-0 text-ink-soft";
|
||||
|
||||
/** parent node — หุบ/กางอิสระ (default กาง) pattern bm-toggle ของต้นไม้ bookmark N2
|
||||
* disabled = กลุ่มใช้ไม่ได้: หรี่ + หุบ + แถวข้างในล็อก (สรุปยังกางดูได้ —
|
||||
* open เป็น prop เฉพาะ transition false↔true เท่านั้นที่เขียน DOM การหุบ/กาง
|
||||
* ด้วยมือของผู้ใช้จึงไม่ถูก render ถัดไปเขียนทับ) */
|
||||
function FilterGroup({
|
||||
icon,
|
||||
label,
|
||||
open = true,
|
||||
disabled = false,
|
||||
children,
|
||||
}: {
|
||||
icon: ReactNode;
|
||||
label: string;
|
||||
open?: boolean;
|
||||
disabled?: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<details open={open} className="group">
|
||||
<summary
|
||||
aria-disabled={disabled}
|
||||
className={`focus-ring flex cursor-pointer list-none items-center gap-1.5 rounded px-1.5 py-1.5 text-sm font-semibold text-ink transition hover:bg-ink/[0.03] hover:text-primary [&::-webkit-details-marker]:hidden ${
|
||||
disabled ? "opacity-50" : ""
|
||||
}`}
|
||||
>
|
||||
<ChevronRight
|
||||
aria-hidden
|
||||
className="h-3.5 w-3.5 shrink-0 text-ink-faint transition-transform group-open:rotate-90 motion-reduce:transition-none"
|
||||
/>
|
||||
{icon}
|
||||
<span className="min-w-0 flex-1 truncate">{label}</span>
|
||||
</summary>
|
||||
<div className="mt-0.5">{children}</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
/** leaf row — single-select มี highlight แถวเดียว, multi-select (multi) เพิ่ม ✓
|
||||
* border-l-2 โปร่งคงไว้ทุกแถว เปลี่ยนเป็นน้ำเงินเฉพาะ active กัน layout shift
|
||||
* ไอคอนแรกต่อแนวใต้ chevron ของ parent พอดี 26px = border-l-2 (2) + pl-6 (24)
|
||||
* = px-1.5 (6) + chevron 14 + gap-1.5 (6) — ต้องนับ border เข้าไปด้วย อย่าแยกออก
|
||||
* (responsive review 2026-09-24 เคยอ่านเป็น 24px แล้วเข้าใจผิดว่าเพี้ยน) */
|
||||
function LeafRow({
|
||||
active,
|
||||
multi = false,
|
||||
disabled = false,
|
||||
icon,
|
||||
label,
|
||||
count,
|
||||
onClick,
|
||||
}: {
|
||||
active: boolean;
|
||||
multi?: boolean;
|
||||
disabled?: boolean;
|
||||
icon: ReactNode;
|
||||
label: string;
|
||||
count: number;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
title={label}
|
||||
className="focus-ring flex w-full items-center gap-1.5 rounded border-l-2 border-transparent py-1 pl-6 pr-1.5 text-left text-sm text-ink transition hover:bg-ink/[0.03] aria-pressed:border-l-primary aria-pressed:bg-primary/[0.08] aria-pressed:text-primary disabled:pointer-events-none disabled:opacity-50"
|
||||
>
|
||||
{/* กลุ่ม multi มีกล่อง checkbox visual หน้าไอคอน (facet-filter convention —
|
||||
คำขอผู้ใช้ 2026-09-24): ปกติกรอบเทาโปร่งใน, เลือก = พื้นน้ำเงิน + ✓ ขาว
|
||||
เป็น visual ของ button เดิม ไม่ใช่ native input (กฎ coarse 44px ยังครบ) */}
|
||||
{multi && (
|
||||
<span
|
||||
aria-hidden
|
||||
className={`flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded border transition-colors ${
|
||||
active ? "border-primary bg-primary text-white" : "border-ink-faint"
|
||||
}`}
|
||||
>
|
||||
{active && <Check className="h-3 w-3" />}
|
||||
</span>
|
||||
)}
|
||||
{icon}
|
||||
<span className="min-w-0 flex-1 truncate">{label}</span>
|
||||
<span className="shrink-0 rounded-full bg-ink/[0.05] px-1.5 text-[11px] leading-4 tabular-nums text-ink-soft">
|
||||
{count}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function FacetFilterPanel({
|
||||
state,
|
||||
onChange,
|
||||
firstGroup,
|
||||
users,
|
||||
counts,
|
||||
}: {
|
||||
state: FacetFilterState;
|
||||
onChange: (next: FacetFilterState) => void;
|
||||
firstGroup: FirstDimensionGroup;
|
||||
users: UserOption[];
|
||||
counts: FacetCounts;
|
||||
}) {
|
||||
const toggleKind = (k: "bm" | "ann", on: boolean) => {
|
||||
const kinds = new Set(state.kinds);
|
||||
if (on) kinds.add(k);
|
||||
else kinds.delete(k);
|
||||
// เลิกเลือก Annotation = ไม่ได้ดู annotation แล้ว → ล้างชนิดที่ติ๊กค้างไว้
|
||||
// ให้เริ่มใหม่ตาม default ตอนกลับมาติ๊ก Annotation (คำขอผู้ใช้ 2026-09-24)
|
||||
const types = !on && k === "ann" ? new Set<AnnotationType>() : state.types;
|
||||
onChange({ ...state, kinds, types });
|
||||
};
|
||||
|
||||
const toggleType = (t: AnnotationType, on: boolean) => {
|
||||
const types = new Set(state.types);
|
||||
if (on) types.add(t);
|
||||
else types.delete(t);
|
||||
// ติ๊กชนิดของ Annotation = ตั้งใจดู annotation → เลือก Annotation ให้อัตโนมัติ
|
||||
// (คำขอผู้ใช้ 2026-09-24 — ก่อนหน้านี้ bookmark ยังปนอยู่ใน list) — ไม่ถอด
|
||||
// อัตโนมัติตอนเลิกติ๊ก เพราะผู้ใช้อาจตั้งใจอยู่มุมมอง annotation แล้ว
|
||||
const kinds = new Set(state.kinds);
|
||||
if (on) kinds.add("ann");
|
||||
onChange({ ...state, kinds, types });
|
||||
};
|
||||
|
||||
const firstOptions = firstGroup.options.map((o) => (
|
||||
<LeafRow
|
||||
key={o.key}
|
||||
active={state.first === o.key}
|
||||
icon={o.icon ?? <FileText aria-hidden className={LEAF_ICON_CLS} />}
|
||||
label={o.label}
|
||||
count={o.count}
|
||||
onClick={() => onChange({ ...state, first: o.key })}
|
||||
/>
|
||||
));
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{/* มิติแรก (ไฟล์/หน้า) — ตัวเลือก + count ส่งมาจากหน้าผ่าน firstGroup;
|
||||
optionsScroll ครอบด้วยกล่อง scroll เฉพาะกรณีตัวเลือกเยอะ */}
|
||||
<FilterGroup icon={firstGroup.parentIcon} label={firstGroup.label}>
|
||||
<LeafRow
|
||||
active={state.first === null}
|
||||
icon={firstGroup.allIcon ?? <FileText aria-hidden className={LEAF_ICON_CLS} />}
|
||||
label={firstGroup.allLabel}
|
||||
count={counts.total}
|
||||
onClick={() => onChange({ ...state, first: null })}
|
||||
/>
|
||||
{firstGroup.optionsScroll ? (
|
||||
<div className="max-h-64 overflow-y-auto overscroll-contain scroll-pb-1 scroll-pt-1">
|
||||
{firstOptions}
|
||||
</div>
|
||||
) : (
|
||||
firstOptions
|
||||
)}
|
||||
</FilterGroup>
|
||||
|
||||
{/* ไม่เลือก = แสดงทั้งหมด (default) — ติ๊กอะไรเห็นเฉพาะสิ่งนั้น */}
|
||||
<FilterGroup
|
||||
icon={<Layers aria-hidden className={PARENT_ICON_CLS} />}
|
||||
label="ชนิดรายการ"
|
||||
>
|
||||
<LeafRow
|
||||
multi
|
||||
active={state.kinds.has("bm")}
|
||||
icon={
|
||||
<Bookmark aria-hidden className="h-4 w-4 shrink-0 text-warning-ink" />
|
||||
}
|
||||
label="Bookmark"
|
||||
count={counts.bm}
|
||||
onClick={() => toggleKind("bm", !state.kinds.has("bm"))}
|
||||
/>
|
||||
<LeafRow
|
||||
multi
|
||||
active={state.kinds.has("ann")}
|
||||
icon={<PenLine aria-hidden className={LEAF_ICON_CLS} />}
|
||||
label="Annotation"
|
||||
count={counts.ann}
|
||||
onClick={() => toggleKind("ann", !state.kinds.has("ann"))}
|
||||
/>
|
||||
</FilterGroup>
|
||||
|
||||
{/* กติกาเดียวกับชนิดรายการ: ไม่เลือก = ทุกชนิด (default) ติ๊กอะไรเห็นเฉพาะ
|
||||
ชนิดนั้น + แสดงเฉพาะชนิดที่มี ≥1 รายการ (กดแล้วได้ list ว่าง = ไม่เสนอ) —
|
||||
**ไม่ได้ติ๊ก Annotation = กลุ่มนี้ disabled + หุบ** (คำขอผู้ใช้ 2026-09-24:
|
||||
กรองชนิดมีความหมายเฉพาะเมื่อกำลังดู annotation — ติ๊ก Annotation แล้ว
|
||||
กลุ่มกลับมาใช้ได้ + กางให้อัตโนมัติ) */}
|
||||
{counts.ann > 0 && (
|
||||
<FilterGroup
|
||||
icon={<Shapes aria-hidden className={PARENT_ICON_CLS} />}
|
||||
label="ชนิดของ Annotation"
|
||||
open={state.kinds.has("ann")}
|
||||
disabled={!state.kinds.has("ann")}
|
||||
>
|
||||
{TYPE_ORDER.filter((t) => (counts.type[t] ?? 0) > 0).map((t) => (
|
||||
<LeafRow
|
||||
key={t}
|
||||
multi
|
||||
disabled={!state.kinds.has("ann")}
|
||||
active={state.types.has(t)}
|
||||
icon={
|
||||
<AnnotationTypeIcon
|
||||
type={t}
|
||||
data={TYPE_ICON_DATA[t]}
|
||||
className={LEAF_ICON_CLS}
|
||||
/>
|
||||
}
|
||||
label={TYPE_SHORT[t]}
|
||||
count={counts.type[t] ?? 0}
|
||||
onClick={() => toggleType(t, !state.types.has(t))}
|
||||
/>
|
||||
))}
|
||||
</FilterGroup>
|
||||
)}
|
||||
|
||||
<FilterGroup
|
||||
icon={<Users aria-hidden className={PARENT_ICON_CLS} />}
|
||||
label="ผู้บันทึก"
|
||||
>
|
||||
{/* ไอคอนคนซ้ำ N แถวเป็น noise — spacer คง indentation แทน (pattern เดียวกับ
|
||||
leaf ของต้นไม้ bookmark ใน sidebar) */}
|
||||
<LeafRow
|
||||
active={state.userId === null}
|
||||
icon={<span aria-hidden className="w-4 shrink-0" />}
|
||||
label="ทุกคน"
|
||||
count={counts.total}
|
||||
onClick={() => onChange({ ...state, userId: null })}
|
||||
/>
|
||||
{users.map((u) => (
|
||||
<LeafRow
|
||||
key={u.userId}
|
||||
active={state.userId === u.userId}
|
||||
icon={<span aria-hidden className="w-4 shrink-0" />}
|
||||
label={u.label}
|
||||
count={counts.user[u.userId] ?? 0}
|
||||
onClick={() => onChange({ ...state, userId: u.userId })}
|
||||
/>
|
||||
))}
|
||||
</FilterGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
186
components/groups/GroupExplorer.tsx
Normal file
186
components/groups/GroupExplorer.tsx
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { ChevronDown, FileImage, FileText, Folder } from "lucide-react";
|
||||
|
||||
import {
|
||||
FacetFilterPanel,
|
||||
createDefaultFacets,
|
||||
type ExplorerItem,
|
||||
type FacetFilterState,
|
||||
} from "@/components/explorer/FacetFilterPanel";
|
||||
import type { AnnotationType } from "@/db/schema";
|
||||
|
||||
import { GroupList } from "./GroupList";
|
||||
|
||||
// N9 หน้ากลุ่ม (redesign 2026-09-24 ตามคำขอผู้ใช้): explorer 2 คอลัมน์ — ซ้ายเป็น
|
||||
// ตัวกรอง (ไฟล์/ชนิดรายการ/ชนิด annotation/ผู้บันทึก) ขวาเป็น list เดียวรวม
|
||||
// bookmark+annotation ทั้งกลุ่มไม่แยกไฟล์ — ข้อมูลทั้งหมด query ฝั่ง server ส่งมา
|
||||
// ครั้งเดียวแล้วกรองใน memory (read-only snapshot เหมือนเดิม ไม่มี SSE)
|
||||
// แผงตัวกรองเป็น component กลาง `components/explorer/FacetFilterPanel` (2026-09-24
|
||||
// — เดิม FilterControls เป็นของหน้านี้) ใช้ร่วมกับหน้าสรุปที่มิติแรกเป็น "หน้า"
|
||||
|
||||
/** ไฟล์ในกลุ่มแบบย่อ — เอาเฉพาะ field ที่ filter/list ใช้ (ไม่ส่ง row เต็มข้าม wire) */
|
||||
export type GroupFileSummary = {
|
||||
id: string;
|
||||
title: string;
|
||||
isImage: boolean;
|
||||
};
|
||||
|
||||
/** รายการเดียวรวมทั้งกลุ่ม — server เรียงตามลำดับอ่านเอกสารให้แล้ว (ไฟล์ตาม
|
||||
* createdAt → หน้า → หน้าเดียวกัน bookmark ก่อน annotation → createdAt) */
|
||||
export type GroupListItem = ExplorerItem;
|
||||
|
||||
export function GroupExplorer({
|
||||
files,
|
||||
items,
|
||||
}: {
|
||||
files: GroupFileSummary[];
|
||||
items: GroupListItem[];
|
||||
}) {
|
||||
const [filters, setFilters] = useState<FacetFilterState>(createDefaultFacets);
|
||||
|
||||
// facet count เป็น static จากข้อมูลทั้งกลุ่ม (ไม่หดตามตัวกรองอื่น — กันเข้าใจ
|
||||
// ผิดว่าตัวเลือกหายไปตอนไล่กรอง)
|
||||
const counts = useMemo(() => {
|
||||
let bm = 0;
|
||||
let ann = 0;
|
||||
const file: Record<string, number> = {};
|
||||
const type: Partial<Record<AnnotationType, number>> = {};
|
||||
const user: Record<string, number> = {};
|
||||
for (const it of items) {
|
||||
file[it.dto.fileId] = (file[it.dto.fileId] ?? 0) + 1;
|
||||
user[it.dto.userId] = (user[it.dto.userId] ?? 0) + 1;
|
||||
if (it.kind === "bm") bm++;
|
||||
else {
|
||||
ann++;
|
||||
type[it.dto.type] = (type[it.dto.type] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
return { total: items.length, bm, ann, file, type, user };
|
||||
}, [items]);
|
||||
|
||||
// รายชื่อผู้บันทึกที่มีรายการจริง เรียงชื่อไทย (คนละไฟล์รวมเป็นคนเดียว)
|
||||
const users = useMemo(() => {
|
||||
const labelById = new Map<string, string>();
|
||||
for (const it of items) {
|
||||
if (!labelById.has(it.dto.userId)) {
|
||||
labelById.set(it.dto.userId, it.dto.userName || "ไม่ทราบชื่อ");
|
||||
}
|
||||
}
|
||||
const out: { userId: string; label: string }[] = [];
|
||||
labelById.forEach((label, userId) => out.push({ userId, label }));
|
||||
return out.sort((a, b) => a.label.localeCompare(b.label, "th"));
|
||||
}, [items]);
|
||||
|
||||
// มิติแรกของหน้ากลุ่ม = ไฟล์ (count รายไฟล์ฝังใน options ของ firstGroup)
|
||||
const firstGroup = {
|
||||
label: "ไฟล์",
|
||||
parentIcon: <Folder aria-hidden className="h-4 w-4 shrink-0 text-ink-soft" />,
|
||||
allLabel: "ทุกไฟล์",
|
||||
options: files.map((f) => ({
|
||||
key: f.id,
|
||||
label: f.title,
|
||||
count: counts.file[f.id] ?? 0,
|
||||
icon: f.isImage ? (
|
||||
<FileImage aria-hidden className="h-4 w-4 shrink-0 text-ink-soft" />
|
||||
) : (
|
||||
<FileText aria-hidden className="h-4 w-4 shrink-0 text-ink-soft" />
|
||||
),
|
||||
})),
|
||||
};
|
||||
|
||||
const filtered = useMemo(
|
||||
() =>
|
||||
items.filter(
|
||||
(it) =>
|
||||
(!filters.first || it.dto.fileId === filters.first) &&
|
||||
(filters.kinds.size === 0 || filters.kinds.has(it.kind)) &&
|
||||
(it.kind === "ann"
|
||||
? filters.types.size === 0 || filters.types.has(it.dto.type)
|
||||
: true) &&
|
||||
(!filters.userId || it.dto.userId === filters.userId),
|
||||
),
|
||||
[items, filters],
|
||||
);
|
||||
|
||||
const selectedFile = filters.first
|
||||
? files.find((f) => f.id === filters.first) ?? null
|
||||
: null;
|
||||
|
||||
// จำนวนตัวกรองที่ ≠ default — โชว์ที่ summary มือถือ (กัน "กรองเก่ากรองเงียบ")
|
||||
// และเป็นเงื่อนไขปุ่ม "ล้างตัวกรอง"
|
||||
const activeCount =
|
||||
(filters.first !== null ? 1 : 0) +
|
||||
(filters.kinds.size > 0 ? 1 : 0) +
|
||||
(filters.types.size > 0 ? 1 : 0) +
|
||||
(filters.userId !== null ? 1 : 0);
|
||||
|
||||
const resetFilters = () => setFilters(createDefaultFacets());
|
||||
|
||||
return (
|
||||
<div className="mt-6 lg:grid lg:grid-cols-[15rem_minmax(0,1fr)] lg:items-start lg:gap-8">
|
||||
{/* มือถือ (< lg): ตัวกรองหุบ/กางเป็น <details> แบบเดียวกับหน้าสรุป —
|
||||
จอ lg ถูก display:none เอาออกจาก grid — ใช้ named group `group/filters`
|
||||
ไม่ใช่ `group` เปล่า: `group-open:` ของ parent tree ข้างใน match ทุก
|
||||
ancestor ที่มี .group[open] ใช้ชื่อซ้ำแล้ว chevron ของทุกกลุ่มหมุนตลอด
|
||||
ตอนกางแผงนี้ (responsive review 2026-09-24) */}
|
||||
<details className="group/filters rounded-xl border border-line bg-white lg:hidden">
|
||||
<summary className="focus-ring flex cursor-pointer list-none items-center justify-between rounded-md px-4 py-3 text-sm font-semibold text-ink transition hover:text-primary [&::-webkit-details-marker]:hidden">
|
||||
ตัวกรอง{activeCount > 0 ? ` (${activeCount})` : ""}
|
||||
<ChevronDown
|
||||
aria-hidden
|
||||
className="h-4 w-4 shrink-0 text-ink-faint transition-transform group-open/filters:rotate-180 motion-reduce:transition-none"
|
||||
/>
|
||||
</summary>
|
||||
{/* cap 70dvh + scroll ภายใน: กลุ่ม "ไฟล์"/"ผู้บันทึก" เยอะ ๆ ต้องไม่ดัน
|
||||
"พบ N รายการ"/list ลงไกลจนมองไม่เห็น (responsive review 2026-09-24) */}
|
||||
<div className="max-h-[70dvh] overscroll-contain overflow-y-auto border-t border-line/70 px-2.5 pb-3 pt-1">
|
||||
<FacetFilterPanel
|
||||
state={filters}
|
||||
onChange={setFilters}
|
||||
firstGroup={firstGroup}
|
||||
users={users}
|
||||
counts={counts}
|
||||
/>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
{/* desktop: แผงตัวกรอง sticky — หน้าไม่มี navbar จึง top-6 พอ ไฟล์เยอะ scroll ภายในแผงเอง
|
||||
(p-3 กระชับตามความ dense ของ tree — design 2026-09-24) — h2 "ตัวกรอง (N)"
|
||||
ให้ parity กับ summary มือถือ, scroll-pt/pb กัน focus ring โดนขอบ scroll
|
||||
ตัด, max-h เป็น dvh ตาม pattern h-dvh ของ viewer (responsive review 2026-09-24) */}
|
||||
<aside
|
||||
aria-label="ตัวกรองรายการ"
|
||||
className="hidden rounded-xl border border-line bg-white p-3 lg:sticky lg:top-6 lg:block lg:max-h-[calc(100dvh-3rem)] lg:scroll-pb-1 lg:scroll-pt-1 lg:overflow-y-auto"
|
||||
>
|
||||
<h2 className="px-1.5 text-sm font-semibold text-ink">
|
||||
ตัวกรอง{activeCount > 0 ? ` (${activeCount})` : ""}
|
||||
</h2>
|
||||
<div className="mt-3">
|
||||
<FacetFilterPanel
|
||||
state={filters}
|
||||
onChange={setFilters}
|
||||
firstGroup={firstGroup}
|
||||
users={users}
|
||||
counts={counts}
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section className="mt-5 lg:mt-0">
|
||||
<GroupList
|
||||
items={filtered}
|
||||
files={files}
|
||||
totalCount={counts.total}
|
||||
selectedFile={selectedFile}
|
||||
selectedFileTotal={
|
||||
selectedFile ? counts.file[selectedFile.id] ?? 0 : null
|
||||
}
|
||||
activeCount={activeCount}
|
||||
onReset={resetFilters}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,58 +0,0 @@
|
|||
"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-warning-ink" },
|
||||
annotation: { label: "Annotation", Icon: PenLine, iconCls: "text-ink-faint" },
|
||||
} 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="focus-ring flex w-full items-center gap-1.5 rounded-md py-1.5 text-left text-xs font-semibold text-ink-soft hover:text-ink"
|
||||
>
|
||||
{open ? (
|
||||
<ChevronDown aria-hidden className="h-3.5 w-3.5 shrink-0 text-ink-faint" />
|
||||
) : (
|
||||
<ChevronRight aria-hidden className="h-3.5 w-3.5 shrink-0 text-ink-faint" />
|
||||
)}
|
||||
<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-line" />
|
||||
</button>
|
||||
</h3>
|
||||
{open && (
|
||||
<div id={contentId} className="pl-5">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
176
components/summary/SummaryExplorer.tsx
Normal file
176
components/summary/SummaryExplorer.tsx
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { ChevronDown, FileStack } from "lucide-react";
|
||||
|
||||
import {
|
||||
FacetFilterPanel,
|
||||
createDefaultFacets,
|
||||
type ExplorerItem,
|
||||
type FacetFilterState,
|
||||
} from "@/components/explorer/FacetFilterPanel";
|
||||
import type { AnnotationType } from "@/db/schema";
|
||||
|
||||
import { SummaryList } from "./SummaryList";
|
||||
|
||||
// หน้าสรุปหมายเหตุ (F15/D2) redesign 2026-09-24 ตามคำขอผู้ใช้ — explorer 2 คอลัมน์
|
||||
// แบบหน้ากลุ่ม N9 โดยมิติแรกของตัวกรองเป็น "หน้า" แทน "ไฟล์" (ไฟล์เดียวทั้งหน้า):
|
||||
// ซ้าย = แผงตัวกรองกลาง FacetFilterPanel (หน้า/ชนิดรายการ/ชนิดของ Annotation/
|
||||
// ผู้บันทึก) ขวา = SummaryList การ์ดรายหน้า — ข้อมูล query ฝั่ง server ส่งมา
|
||||
// ครั้งเดียวแล้วกรองใน memory (read-only snapshot ไม่มี SSE เหมือนเดิม)
|
||||
|
||||
export function SummaryExplorer({
|
||||
fileId,
|
||||
items,
|
||||
}: {
|
||||
fileId: string;
|
||||
items: ExplorerItem[];
|
||||
}) {
|
||||
const [filters, setFilters] = useState<FacetFilterState>(createDefaultFacets);
|
||||
|
||||
// facet count เป็น static จากข้อมูลทั้งไฟล์ (ไม่หดตามตัวกรองอื่น — เหตุผลเดียว
|
||||
// กับหน้ากลุ่ม)
|
||||
const counts = useMemo(() => {
|
||||
let bm = 0;
|
||||
let ann = 0;
|
||||
const type: Partial<Record<AnnotationType, number>> = {};
|
||||
const user: Record<string, number> = {};
|
||||
for (const it of items) {
|
||||
user[it.dto.userId] = (user[it.dto.userId] ?? 0) + 1;
|
||||
if (it.kind === "bm") bm++;
|
||||
else {
|
||||
ann++;
|
||||
type[it.dto.type] = (type[it.dto.type] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
return { total: items.length, bm, ann, type, user };
|
||||
}, [items]);
|
||||
|
||||
// รายชื่อผู้บันทึกที่มีรายการจริง เรียงชื่อไทย — แทนบล็อก "ผู้ร่วมทำ" แบบอ่านอย่าง
|
||||
// เดียวของหน้าเดิม (กรองตามคนได้ + เห็น count) — localeCompare "th" รันทั้ง
|
||||
// SSR/hydration หลังย้ายมา client: CLDR สมัยใหม่ของ Node/browser ใกล้กันพอ
|
||||
// รับความเสี่ยง mismatch ต่ำไว้ (code review 2026-09-24 — ถ้าเจอ hydration
|
||||
// warning จริง ให้ย้าย sort นี้ไปฝั่ง server แล้วส่งเป็น prop)
|
||||
const users = useMemo(() => {
|
||||
const labelById = new Map<string, string>();
|
||||
for (const it of items) {
|
||||
if (!labelById.has(it.dto.userId)) {
|
||||
labelById.set(it.dto.userId, it.dto.userName || "ไม่ทราบชื่อ");
|
||||
}
|
||||
}
|
||||
const out: { userId: string; label: string }[] = [];
|
||||
labelById.forEach((label, userId) => out.push({ userId, label }));
|
||||
return out.sort((a, b) => a.label.localeCompare(b.label, "th"));
|
||||
}, [items]);
|
||||
|
||||
// มิติ "หน้า": เฉพาะหน้าที่มีรายการ ≥1 เรียงน้อย→มาก (ทางเข้าหน้าแทนรายการตาม
|
||||
// หน้าจุดไข่ปลาของหน้าเดิม — เห็น count ก่อนกระโดดดีกว่า) — ไอคอนเป็น spacer
|
||||
// ให้เลขชิดซ้ายตรงแนวกลุ่มผู้บันทึก; หน้าเยอะเป็นสิบ ๆ รายการใช้ optionsScroll
|
||||
// ให้ scroll ภายในกลุ่ม (max-h-64) กันกลุ่มอื่นถูกดันออกนอกจอ
|
||||
const pageOptions = useMemo(() => {
|
||||
const perPage = new Map<number, number>();
|
||||
for (const it of items) {
|
||||
perPage.set(it.pageIndex, (perPage.get(it.pageIndex) ?? 0) + 1);
|
||||
}
|
||||
return Array.from(perPage.entries())
|
||||
.sort((a, b) => a[0] - b[0])
|
||||
.map(([pi, n]) => ({
|
||||
key: pi,
|
||||
label: `หน้า ${pi + 1}`,
|
||||
count: n,
|
||||
icon: <span aria-hidden className="w-4 shrink-0" />,
|
||||
}));
|
||||
}, [items]);
|
||||
|
||||
const firstGroup = {
|
||||
label: "หน้า",
|
||||
parentIcon: (
|
||||
<FileStack aria-hidden className="h-4 w-4 shrink-0 text-ink-soft" />
|
||||
),
|
||||
allLabel: "ทุกหน้า",
|
||||
options: pageOptions,
|
||||
optionsScroll: true,
|
||||
};
|
||||
|
||||
// pure predicate — ห้าม sort ซ้ำ (server เรียงลำดับอ่านให้แล้ว)
|
||||
const filtered = useMemo(
|
||||
() =>
|
||||
items.filter(
|
||||
(it) =>
|
||||
(filters.first === null || it.pageIndex === filters.first) &&
|
||||
(filters.kinds.size === 0 || filters.kinds.has(it.kind)) &&
|
||||
(it.kind === "ann"
|
||||
? filters.types.size === 0 || filters.types.has(it.dto.type)
|
||||
: true) &&
|
||||
(filters.userId === null || it.dto.userId === filters.userId),
|
||||
),
|
||||
[items, filters],
|
||||
);
|
||||
|
||||
// จำนวนตัวกรองที่ ≠ default — โชว์ที่ summary มือถือ (กัน "กรองเก่ากรองเงียบ")
|
||||
// และเป็นเงื่อนไขปุ่ม "ล้างตัวกรอง"/ชุดพิมพ์ฉบับเต็ม
|
||||
const activeCount =
|
||||
(filters.first !== null ? 1 : 0) +
|
||||
(filters.kinds.size > 0 ? 1 : 0) +
|
||||
(filters.types.size > 0 ? 1 : 0) +
|
||||
(filters.userId !== null ? 1 : 0);
|
||||
|
||||
const resetFilters = () => setFilters(createDefaultFacets());
|
||||
|
||||
return (
|
||||
<div className="mt-6 lg:grid lg:grid-cols-[15rem_minmax(0,1fr)] lg:items-start lg:gap-8">
|
||||
{/* มือถือ (< lg): ตัวกรองหุบ/กางเป็น <details> — named group `group/filters`
|
||||
กัน `group-open:` match ancestor ซ้ำชื่อ (pattern ของหน้ากลุ่ม); ตอนพิมพ์
|
||||
ซ่อนทั้งสอง shell — กระดาษได้ฉบับเต็มจาก SummaryList เสมอ */}
|
||||
<details className="group/filters rounded-xl border border-line bg-white lg:hidden print:hidden">
|
||||
<summary className="focus-ring flex cursor-pointer list-none items-center justify-between rounded-md px-4 py-3 text-sm font-semibold text-ink transition hover:text-primary [&::-webkit-details-marker]:hidden">
|
||||
ตัวกรอง{activeCount > 0 ? ` (${activeCount})` : ""}
|
||||
<ChevronDown
|
||||
aria-hidden
|
||||
className="h-4 w-4 shrink-0 text-ink-faint transition-transform group-open/filters:rotate-180 motion-reduce:transition-none"
|
||||
/>
|
||||
</summary>
|
||||
<div className="max-h-[70dvh] overscroll-contain overflow-y-auto border-t border-line/70 px-2.5 pb-3 pt-1">
|
||||
<FacetFilterPanel
|
||||
state={filters}
|
||||
onChange={setFilters}
|
||||
firstGroup={firstGroup}
|
||||
users={users}
|
||||
counts={counts}
|
||||
/>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
{/* desktop: แผงตัวกรอง sticky — หน้าไม่มี navbar จึง top-6 พอ (เหมือนหน้ากลุ่ม) */}
|
||||
<aside
|
||||
aria-label="ตัวกรองรายการ"
|
||||
className="hidden rounded-xl border border-line bg-white p-3 lg:sticky lg:top-6 lg:block lg:max-h-[calc(100dvh-3rem)] lg:scroll-pb-1 lg:scroll-pt-1 lg:overflow-y-auto print:hidden"
|
||||
>
|
||||
<h2 className="px-1.5 text-sm font-semibold text-ink">
|
||||
ตัวกรอง{activeCount > 0 ? ` (${activeCount})` : ""}
|
||||
</h2>
|
||||
<div className="mt-3">
|
||||
<FacetFilterPanel
|
||||
state={filters}
|
||||
onChange={setFilters}
|
||||
firstGroup={firstGroup}
|
||||
users={users}
|
||||
counts={counts}
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* div ไม่ใช่ section — กฎ print `main.summary-page section` (break-inside
|
||||
avoid-page) มุ่งที่การ์ดรายหน้าข้างใน ห้ามครอบทั้ง list กลายเป็นก้อนเดียว */}
|
||||
<div className="mt-5 lg:mt-0">
|
||||
<SummaryList
|
||||
fileId={fileId}
|
||||
items={filtered}
|
||||
allItems={items}
|
||||
activeCount={activeCount}
|
||||
onReset={resetFilters}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
334
components/summary/SummaryList.tsx
Normal file
334
components/summary/SummaryList.tsx
Normal file
|
|
@ -0,0 +1,334 @@
|
|||
"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<Record<AnnotationType, number>> = {};
|
||||
let resolvedCount = 0;
|
||||
const participants = new Set<string>();
|
||||
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 <span aria-hidden className="text-ink-faint">·</span>;
|
||||
}
|
||||
|
||||
/** meta line ของแถวสรุป: บริบทกลาง ("ใน: …" ของ sub bookmark / ชื่อชนิดของ
|
||||
* annotation) → ผู้บันทึก → วันที่ — ต่างจาก GroupList ตรงไม่มี segment ไฟล์
|
||||
* (ไฟล์เดียว ชื่ออยู่แถบหัวของหน้าเองแล้ว) */
|
||||
function RowMeta({
|
||||
middle,
|
||||
user,
|
||||
date,
|
||||
}: {
|
||||
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">
|
||||
{middle && (
|
||||
<>
|
||||
<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
|
||||
(เดียวกับ GroupList/AnnotationSidebar) */}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
/** การ์ดรายหน้าตามจำนวนที่ได้เห็น — 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<number, ExplorerItem[]>();
|
||||
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 (
|
||||
<div className="flex flex-col gap-4">
|
||||
{pages.map(([pi, pageItems]) => (
|
||||
// ห้ามใส่ overflow-hidden — ฆ่า position:sticky ของหัวการ์ด (sticky ไม่
|
||||
// ทำงานใน ancestor ที่ overflow:hidden; มุมโค้งไม่มีสีหลุดมุมเพราะหัวการ์ด
|
||||
// ขาวบนการ์ดขาว) — id page-N คงจากหน้าเดิมให้ลิงก์เก่ายังใช้ได้
|
||||
<section
|
||||
key={pi}
|
||||
id={anchorIds ? `page-${pi + 1}` : undefined}
|
||||
aria-labelledby={anchorIds ? `page-${pi + 1}-title` : undefined}
|
||||
className="scroll-mt-6 rounded-xl border border-line bg-white"
|
||||
>
|
||||
{/* หัวการ์ด sticky ระดับ viewport — ไต่ list ยาวแล้วยังรู้ว่าอยู่หน้าไหน
|
||||
(รับช่วง orientation จากรายการตามหน้าจุดไข่ปลาที่ถอดออก; บนกระดาษ
|
||||
กลับเป็นตำแหน่งปกติผ่านกฎ .page-head ใน globals.css) */}
|
||||
<div className="page-head sticky top-0 z-10 flex flex-wrap items-center gap-x-3 gap-y-1 border-b border-line bg-white px-3 py-2.5 sm:px-4">
|
||||
<h2
|
||||
id={anchorIds ? `page-${pi + 1}-title` : undefined}
|
||||
className="flex items-baseline gap-1 tabular-nums"
|
||||
>
|
||||
<span className="text-[11px] font-medium text-ink-soft">หน้า</span>
|
||||
{/* เลขหน้าเป็น ink ไม่ใช่ primary — Genesis "น้ำเงินเฉพาะของ
|
||||
interactive" ความเด่นมาจาก font-display semibold */}
|
||||
<span className="font-display text-lg font-semibold leading-none text-ink">
|
||||
{pi + 1}
|
||||
</span>
|
||||
</h2>
|
||||
<p className="min-w-0 flex-1 text-xs leading-relaxed text-ink-soft">
|
||||
{pageSummaryLine(pageItems)}
|
||||
</p>
|
||||
<Link
|
||||
href={`/annotate/${fileId}?page=${pi + 1}`}
|
||||
className="focus-ring inline-flex shrink-0 items-center gap-0.5 text-xs text-primary hover:underline"
|
||||
>
|
||||
เปิดหน้านี้
|
||||
<ChevronRight aria-hidden className="h-3 w-3" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<ul className="divide-y divide-line/70">
|
||||
{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
|
||||
? `/annotate/${fileId}?page=${item.pageIndex + 1}`
|
||||
: `/annotate/${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
|
||||
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
|
||||
middle={annotationTypeLabel(item.dto)}
|
||||
user={item.dto.userName}
|
||||
date={formatThaiDate(item.dto.createdAt, true)}
|
||||
/>
|
||||
{annotationNote(item.dto) && (
|
||||
<RowNote text={annotationNote(item.dto)} />
|
||||
)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{/* scroll-mt-12: โฟกัสผ่าน Tab แล้วปุ่มไม่จมใต้หัวการ์ด sticky
|
||||
(~41px) — WCAG 2.4.11 Focus not obscured */}
|
||||
<Link
|
||||
href={href}
|
||||
title={where}
|
||||
aria-label={`View — ${where}`}
|
||||
className="focus-ring mt-0.5 inline-flex h-8 shrink-0 scroll-mt-12 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>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-1.5 px-1 pb-2.5 print:hidden">
|
||||
<p className="text-sm text-ink-soft" aria-live="polite">
|
||||
พบ{" "}
|
||||
<span className="font-semibold tabular-nums text-ink">
|
||||
{items.length}
|
||||
</span>{" "}
|
||||
รายการ
|
||||
</p>
|
||||
{activeCount > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onReset}
|
||||
className="focus-ring rounded-md px-1.5 text-sm text-primary transition hover:underline"
|
||||
>
|
||||
ล้างตัวกรอง
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{items.length > 0 && (
|
||||
<div className={activeCount > 0 ? "print:hidden" : undefined}>
|
||||
<PageSections fileId={fileId} items={items} anchorIds />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{items.length === 0 && (
|
||||
<div className="rounded-xl border border-dashed border-line bg-white/70 p-10 text-center print:hidden">
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* ตอนพิมพ์แสดงฉบับเต็มเสมอ — ตัวกรองที่ตั้งค้างไว้ต้องไม่ตัดรายการออกเงียบ ๆ
|
||||
บนกระดาษ (หน้าสรุปมีโอกาสถูกพิมพ์แนบสำนวน ความครบถ้วนสำคัญกว่า subset —
|
||||
ต้องการ subset มี export CSV อยู่แล้ว); ไม่กรอง = DOM ชุดเดียวไม่ซ้ำ */}
|
||||
{activeCount > 0 && (
|
||||
<div className="hidden print:block">
|
||||
<PageSections fileId={fileId} items={allItems} anchorIds={false} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue