186 lines
8.8 KiB
TypeScript
186 lines
8.8 KiB
TypeScript
"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>
|
|
);
|
|
}
|