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
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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue