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
|
|
@ -2,19 +2,10 @@ import type { Metadata } from "next";
|
|||
import Link from "next/link";
|
||||
import { asc, eq } from "drizzle-orm";
|
||||
import { notFound } from "next/navigation";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Bookmark,
|
||||
Braces,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
ClipboardList,
|
||||
FileDown,
|
||||
StickyNote,
|
||||
} from "lucide-react";
|
||||
import { ArrowLeft, Braces, ClipboardList, FileDown } from "lucide-react";
|
||||
|
||||
import { AnnotationTypeIcon } from "@/components/AnnotationTypeIcon";
|
||||
import CollapsibleBlock from "@/components/summary/CollapsibleBlock";
|
||||
import type { ExplorerItem } from "@/components/explorer/FacetFilterPanel";
|
||||
import { SummaryExplorer } from "@/components/summary/SummaryExplorer";
|
||||
import { db } from "@/db";
|
||||
import {
|
||||
annotations,
|
||||
|
|
@ -22,68 +13,21 @@ import {
|
|||
files,
|
||||
toAnnotationDTO,
|
||||
toBookmarkDTO,
|
||||
type AnnotationDTO,
|
||||
type AnnotationType,
|
||||
type BookmarkDTO,
|
||||
} from "@/db/schema";
|
||||
import {
|
||||
annotationNote,
|
||||
annotationPreviewText,
|
||||
annotationTypeLabel,
|
||||
} from "@/utils/annotation-display";
|
||||
import { withBasePath } from "@/utils/base-path";
|
||||
|
||||
// หน้าสรุปหมายเหตุ (F15/D2) — server component รวม annotation (ทุกผู้ใช้) + bookmark
|
||||
// จัดกลุ่มตามหน้า แต่ละแถวกระโดดเป็น deep link `/annotate/<id>?ann=` / `?page=` (F12)
|
||||
// — design "แฟ้มสำนวน" 2026-09-17: แถบหัวกระดาษน้ำเงิน (สืบต่อจาก tile JN ของแบรนด์) +
|
||||
// รายการตามหน้าจุดไข่ปลา + การ์ดรายหน้า 1 หน้า 1 การ์ด (คำขอผู้ใช้ 2026-09-17 แทนแผ่น
|
||||
// กระดาษแผ่นเดียวคั่นเส้น) แต่ละการ์ดมีขอบ folio เลขหน้าใหญ่; รายการตามหน้าเป็น anchor
|
||||
// link (#page-N) ล้วน — desktop เป็น sticky aside แบบการ์ด ส่วนมือถือ (< lg) เป็น
|
||||
// <details> แบบการ์ด ไม่มี JS / client component เดียวของหน้าคือ CollapsibleBlock
|
||||
// (หุบ/กางกลุ่ม Bookmark/Annotation)
|
||||
// แต่ละแถวกระโดดเป็น deep link `/annotate/<id>?ann=` / `?page=` (F12)
|
||||
// — design "แฟ้มสำนวน" 2026-09-17 (แถบหัวน้ำเงิน + การ์ดรายหน้าขอบ folio +
|
||||
// รายการตามหน้าจุดไข่ปลา) → redesign 2026-09-24 ตามคำขอผู้ใช้เป็น explorer
|
||||
// 2 คอลัมน์แบบหน้ากลุ่ม N9: ตัวกรองฝั่งซ้าย (หน้า/ชนิดรายการ/ชนิดของ
|
||||
// Annotation/ผู้บันทึก) + การ์ดรายหน้าหัว sticky — แถบหัวน้ำเงิน + action bar +
|
||||
// empty state คงเดิม; query ทั้งหมดทำที่นี่แล้วส่ง `ExplorerItem[]` ให้
|
||||
// SummaryExplorer กรองใน memory ฝั่ง client (read-only snapshot ไม่มี SSE)
|
||||
|
||||
const UUID_RE =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
/** ชื่อย่อต่อชนิดสำหรับบรรทัดสรุปของแต่ละหน้า (ชื่อเต็มอยู่ที่ annotationTypeLabel) */
|
||||
const TYPE_SHORT: Record<AnnotationType, string> = {
|
||||
HIGHLIGHT: "ไฮไลต์",
|
||||
DRAW: "เส้นวาด",
|
||||
COMMENT: "คอมเมนต์",
|
||||
SHAPE: "รูปทรง",
|
||||
REDACTION: "ปิดพราง",
|
||||
TEXTBOX: "กล่องข้อความ",
|
||||
STAMP: "ตรายาง",
|
||||
};
|
||||
|
||||
/** ลำดับชนิดในบรรทัดสรุป — ตามลำดับ enum ให้ deterministic ไม่ขึ้นกับลำดับสร้างรายการ */
|
||||
const TYPE_ORDER = Object.keys(TYPE_SHORT) as AnnotationType[];
|
||||
|
||||
type PageBucket = {
|
||||
anns: AnnotationDTO[];
|
||||
bms: BookmarkDTO[];
|
||||
typeCounts: Partial<Record<AnnotationType, number>>;
|
||||
resolvedCount: number;
|
||||
participants: Set<string>;
|
||||
};
|
||||
|
||||
/** บรรทัดสรุปของหน้า: "ไฮไลต์ 1 · คอมเมนต์ 1 (จัดการแล้ว 1) · โดย ผู้พิพากษา ก., เลขานุการ ข."
|
||||
* (หน้าที่มีแต่ที่คั่นหน้า = "โดย …" อย่างเดียว) */
|
||||
function pageSummaryLine(b: PageBucket): string {
|
||||
const parts: string[] = [];
|
||||
for (const t of TYPE_ORDER) {
|
||||
const n = b.typeCounts[t];
|
||||
if (!n) continue;
|
||||
if (t === "COMMENT" && b.resolvedCount > 0)
|
||||
parts.push(`คอมเมนต์ ${n} (จัดการแล้ว ${b.resolvedCount})`);
|
||||
else parts.push(`${TYPE_SHORT[t]} ${n}`);
|
||||
}
|
||||
const who = Array.from(b.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}`;
|
||||
}
|
||||
|
||||
/** focus ring ร่วมของทุกลิงก์ในหน้า — Genesis .focus-ring (outline น้ำเงินทึบ 2px
|
||||
* + offset ตาม spec Elevation); จอสัมผัสได้ 44px อัตโนมัติจากกฎ
|
||||
* `main.summary-page a`/`button` ใน globals.css (ห้ามถอด class summary-page ของ <main>) */
|
||||
|
|
@ -140,49 +84,46 @@ export default async function SummaryPage({
|
|||
const bmDtos = bmRows.map(toBookmarkDTO);
|
||||
const bookmarkById = new Map(bmDtos.map((b) => [b.id, b] as const));
|
||||
|
||||
// จัดกลุ่มตามหน้าแบบ union — หน้าปรากฏเมื่อมี annotation หรือ bookmark ≥ 1 รายการ
|
||||
const pageMap = new Map<number, PageBucket>();
|
||||
const bucketOf = (pageIndex: number): PageBucket => {
|
||||
let b = pageMap.get(pageIndex);
|
||||
if (!b) {
|
||||
b = { anns: [], bms: [], typeCounts: {}, resolvedCount: 0, participants: new Set() };
|
||||
pageMap.set(pageIndex, b);
|
||||
}
|
||||
return b;
|
||||
};
|
||||
for (const a of dtos) {
|
||||
const b = bucketOf(a.pageIndex);
|
||||
b.anns.push(a);
|
||||
b.typeCounts[a.type] = (b.typeCounts[a.type] ?? 0) + 1;
|
||||
if (a.type === "COMMENT" && a.resolved) b.resolvedCount += 1;
|
||||
b.participants.add(a.userName);
|
||||
}
|
||||
for (const b of bmDtos) {
|
||||
const bucket = bucketOf(b.pageIndex);
|
||||
bucket.bms.push(b);
|
||||
bucket.participants.add(b.userName || "ไม่ทราบชื่อ");
|
||||
}
|
||||
const pages = Array.from(pageMap.entries()).sort((x, y) => x[0] - y[0]);
|
||||
// list เดียวรวมทั้งไฟล์ไล่ตามลำดับอ่าน: หน้า → หน้าเดียวกันให้ที่คั่นหน้าก่อน
|
||||
// หมายเหตุ → ในหน้าเดียวกัน bookmark เรียงตามตำแหน่งบนหน้า (N5 เหมือน query/
|
||||
// sidebar — code review 2026-09-24 คืนจาก createdAt ที่เคยแทนที่โดยไม่ตั้งใจ)
|
||||
// ส่วน annotation ตาม createdAt (sort ครั้งเดียวบน server — ฝั่ง client กรอง
|
||||
// เป็น pure predicate ไม่ sort ซ้ำ)
|
||||
const items: ExplorerItem[] = [
|
||||
...bmDtos.map((dto) => ({
|
||||
kind: "bm" as const,
|
||||
pageIndex: dto.pageIndex,
|
||||
dto,
|
||||
parentTitle: dto.parentId
|
||||
? bookmarkById.get(dto.parentId)?.title ?? null
|
||||
: null,
|
||||
})),
|
||||
...dtos.map((dto) => ({ kind: "ann" as const, pageIndex: dto.pageIndex, dto })),
|
||||
].sort(
|
||||
(a, x) =>
|
||||
a.pageIndex - x.pageIndex ||
|
||||
(a.kind === x.kind ? 0 : a.kind === "bm" ? -1 : 1) ||
|
||||
(a.kind === "bm" && x.kind === "bm"
|
||||
? a.dto.yOffset - x.dto.yOffset
|
||||
: new Date(a.dto.createdAt).getTime() -
|
||||
new Date(x.dto.createdAt).getTime()),
|
||||
);
|
||||
|
||||
// ภาพรวมระดับไฟล์สำหรับรายการตามหน้า desktop (sticky aside) + มือถือ (<details>)
|
||||
const allParticipants = Array.from(
|
||||
new Set([
|
||||
...dtos.map((a) => a.userName),
|
||||
...bmDtos.map((b) => b.userName || "ไม่ทราบชื่อ"),
|
||||
]),
|
||||
).sort((x, y) => x.localeCompare(y, "th"));
|
||||
// จำนวนหน้าที่มีรายการ ≥1 สำหรับสถิติแถบหัว
|
||||
const pageCount = new Set(items.map((it) => it.pageIndex)).size;
|
||||
|
||||
return (
|
||||
<main className="summary-page mx-auto max-w-3xl px-4 py-6 lg:max-w-6xl lg:px-8">
|
||||
{/* หัวกระดาษสำนวน — แถบน้ำเงินสืบต่อจาก tile JN ของแบรนด์ (หน้ารายการ/login):
|
||||
ชื่อรายงาน + ชื่อแฟ้ม + สถิติในที่เดียว แล้วปิดท้ายด้วยเส้นคั่นบาง */}
|
||||
ชื่อรายงาน + ชื่อแฟ้ม + สถิติในที่เดียว แล้วปิดท้ายด้วยเส้นคั่นบาง
|
||||
(ตอนพิมพ์เป็นกรอบเส้นน้ำเงินตามกฎ @media print ของ summary-page) */}
|
||||
<header className="rounded-xl bg-primary px-5 py-4 text-white sm:px-6">
|
||||
<h1 className="text-lg font-semibold leading-snug">สรุปหมายเหตุ</h1>
|
||||
<p className="mt-0.5 break-words text-sm text-white/85">{file.title}</p>
|
||||
{pages.length > 0 && (
|
||||
{items.length > 0 && (
|
||||
<p className="mt-2 text-xs tabular-nums text-white/70">
|
||||
Annotation {dtos.length} รายการ · Bookmark {bmDtos.length} รายการ ·{" "}
|
||||
{pages.length} หน้า
|
||||
{pageCount} หน้า
|
||||
</p>
|
||||
)}
|
||||
</header>
|
||||
|
|
@ -219,7 +160,9 @@ export default async function SummaryPage({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{pages.length === 0 ? (
|
||||
{items.length === 0 ? (
|
||||
/* ไฟล์ว่าง = ไม่ mount explorer เลย — แผงกรองของข้อมูลว่างไม่มีความหมาย
|
||||
(minimize choices) ทางเข้าเอกสารคือปุ่ม "เปิดหน้าอ่าน" */
|
||||
<div className="mt-5 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">
|
||||
|
|
@ -236,235 +179,7 @@ export default async function SummaryPage({
|
|||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-5 lg:grid lg:grid-cols-[220px_minmax(0,1fr)] lg:items-start lg:gap-8">
|
||||
{/* รายการตามหน้ามือถือ (จอ < lg) — design review 2026-09-17 H2: คืน navigation ที่หาย
|
||||
ไปบนมือถือ (aside ด้านล่างแสดงเฉพาะ lg) ใช้ <details> ได้หุบ/กาง + keyboard
|
||||
โดยไม่มี JS เพิ่ม — จุดไข่ปลา + ผู้ร่วมทำ ภาษาเดียวกับ desktop (จอ lg กับ
|
||||
ตอนพิมพ์ซ่อน — display:none ไม่กินช่องของกริด) */}
|
||||
<details className="group rounded-xl border border-line bg-white lg:hidden print:hidden">
|
||||
<summary className="flex cursor-pointer list-none items-center justify-between rounded-md px-4 py-3 text-sm font-semibold text-ink hover:text-primary [&::-webkit-details-marker]:hidden">
|
||||
รายการตามหน้าและผู้ร่วมทำ
|
||||
<ChevronDown
|
||||
aria-hidden
|
||||
className="h-4 w-4 shrink-0 text-ink-faint transition-transform group-open:rotate-180 motion-reduce:transition-none"
|
||||
/>
|
||||
</summary>
|
||||
<nav
|
||||
aria-label="รายการตามหน้า"
|
||||
className="flex flex-col gap-0.5 border-t border-line/70 px-2 pb-2 pt-2"
|
||||
>
|
||||
{pages.map(([pi, b]) => (
|
||||
<a
|
||||
key={pi}
|
||||
href={`#page-${pi + 1}`}
|
||||
aria-label={`หน้า ${pi + 1}, ${b.anns.length + b.bms.length} รายการ`}
|
||||
className={`flex items-end gap-1.5 rounded-md px-2 py-2 text-sm text-ink-soft hover:text-primary ${RING_CLS}`}
|
||||
>
|
||||
<span className="leading-5">หน้า {pi + 1}</span>
|
||||
<span
|
||||
aria-hidden
|
||||
className="mb-1.5 min-w-4 flex-1 border-b border-dotted border-ink-faint"
|
||||
/>
|
||||
<span className="leading-5 tabular-nums text-ink-soft">
|
||||
{b.anns.length + b.bms.length}
|
||||
</span>
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
<p className="border-t border-line/70 px-4 py-3 text-xs leading-relaxed text-ink-soft">
|
||||
ผู้ร่วมทำ: {allParticipants.join(" · ")}
|
||||
</p>
|
||||
</details>
|
||||
|
||||
{/* รายการตามหน้า (จอ ≥ lg) — จุดไข่ปลาแบบสารบัญของเอกสารราชการ anchor ล้วนไม่มี JS
|
||||
เป็นการ์ดชุดเดียวกับ <details> มือถือและการ์ดรายหน้า (แสดงเฉพาะหน้าที่มี
|
||||
รายการ ≥ 1 ตามหลัก minimize choices; max-h กันเอกสารหลายสิบหน้าสูงเกินจอ
|
||||
— ครึ่งล่างของ sticky กดไม่ได้ตลอดกาล) */}
|
||||
<aside className="hidden rounded-xl border border-line bg-white p-4 lg:sticky lg:top-6 lg:block lg:max-h-[calc(100vh-3rem)] lg:overflow-y-auto">
|
||||
<h2 className="text-sm font-semibold text-ink">รายการตามหน้า</h2>
|
||||
<nav aria-label="รายการตามหน้า" className="mt-2 flex flex-col gap-1">
|
||||
{pages.map(([pi, b]) => (
|
||||
<a
|
||||
key={pi}
|
||||
href={`#page-${pi + 1}`}
|
||||
aria-label={`หน้า ${pi + 1}, ${b.anns.length + b.bms.length} รายการ`}
|
||||
className={`flex items-end gap-1.5 rounded-md px-1 text-sm text-ink-soft hover:text-primary ${RING_CLS}`}
|
||||
>
|
||||
<span className="leading-5">หน้า {pi + 1}</span>
|
||||
<span
|
||||
aria-hidden
|
||||
className="mb-1.5 min-w-4 flex-1 border-b border-dotted border-ink-faint"
|
||||
/>
|
||||
<span className="leading-5 tabular-nums text-ink-soft">
|
||||
{b.anns.length + b.bms.length}
|
||||
</span>
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<h2 className="mt-6 border-t border-line pt-4 text-sm font-semibold text-ink">
|
||||
ผู้ร่วมทำ
|
||||
</h2>
|
||||
<p className="mt-1.5 px-1 text-xs leading-relaxed text-ink-soft">
|
||||
{allParticipants.join(" · ")}
|
||||
</p>
|
||||
</aside>
|
||||
|
||||
{/* การ์ดรายหน้า — 1 หน้า 1 การ์ด เรียงลงมาแยกด้วยช่องว่าง (คำขอผู้ใช้
|
||||
2026-09-17 แทนแผ่นกระดาษแผ่นเดียวคั่นเส้น); แต่ละการ์ดมีขอบ folio:
|
||||
เลขหน้าใหญ่ + เส้นดิ่ง แบบเลขหน้าบนขอบกระดาษ (mt-5 lg:mt-0 — ระยะจาก
|
||||
รายการตามหน้า <details> บนมือถือ; จอ lg กริดจัดให้) */}
|
||||
<div className="mt-5 flex flex-col gap-4 lg:mt-0">
|
||||
{pages.map(([pi, b]) => {
|
||||
return (
|
||||
<section
|
||||
key={pi}
|
||||
id={`page-${pi + 1}`}
|
||||
aria-labelledby={`page-${pi + 1}-title`}
|
||||
className="scroll-mt-6 grid grid-cols-[2.75rem_1fr] rounded-xl border border-line bg-white sm:grid-cols-[3.75rem_1fr]"
|
||||
>
|
||||
{/* ขอบ folio — "หน้า" เล็ก + เลขใหญ่ชิดเส้นดิ่ง */}
|
||||
<div className="border-r border-line/60 py-4 pl-3 pr-2 text-right sm:pl-4">
|
||||
<h2 id={`page-${pi + 1}-title`} className="tabular-nums">
|
||||
<span className="block text-[11px] leading-none text-ink-soft">
|
||||
หน้า
|
||||
</span>
|
||||
{/* เลขหน้าเป็น ink ไม่ใช่ primary — Genesis "น้ำเงินเฉพาะของ
|
||||
interactive" (ตัดสินใจพร้อม exception list ใน DESIGN.md
|
||||
2026-09-23) ความเด่นมาจากขนาด 2xl semibold tabular-nums */}
|
||||
<span className="mt-1 block text-2xl font-semibold leading-none text-ink">
|
||||
{pi + 1}
|
||||
</span>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* เนื้อหาของหน้า — บรรทัดสรุปหน้า (ไม่มีหัวข้อคำอธิบาย) /
|
||||
Bookmark / Annotation (หุบ-กางได้, รายการเยื้องจากหัวข้อ) */}
|
||||
<div className="min-w-0 py-3 pl-3 pr-3 sm:pl-4 sm:pr-5 sm:py-4">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm leading-relaxed text-ink-soft">
|
||||
{pageSummaryLine(b)}
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href={`/annotate/${id}?page=${pi + 1}`}
|
||||
className={`inline-flex shrink-0 items-center gap-0.5 text-xs text-primary hover:underline ${RING_CLS}`}
|
||||
>
|
||||
เปิดหน้านี้
|
||||
<ChevronRight aria-hidden className="h-3 w-3" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* ที่คั่นหน้าของหน้านี้ทั้งหมด (root + sub) เรียงตาม yOffset */}
|
||||
{b.bms.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<CollapsibleBlock kind="bookmark" count={b.bms.length}>
|
||||
<ul className="divide-y divide-line/70">
|
||||
{b.bms.map((bm) => {
|
||||
const parent = bm.parentId
|
||||
? bookmarkById.get(bm.parentId)
|
||||
: undefined;
|
||||
return (
|
||||
<li key={bm.id} className="ml-4">
|
||||
<Link
|
||||
href={`/annotate/${id}?page=${pi + 1}`}
|
||||
className={`group -mx-2 flex items-start gap-2 rounded-lg px-2 py-2.5 transition hover:bg-ink/[0.03] ${RING_CLS}`}
|
||||
>
|
||||
<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">
|
||||
{bm.title}
|
||||
</span>
|
||||
<span className="mt-0.5 block truncate text-xs text-ink-soft">
|
||||
โดย {bm.userName || "ไม่ทราบชื่อ"}
|
||||
{parent ? ` · ใน: ${parent.title}` : ""}
|
||||
</span>
|
||||
{bm.note && (
|
||||
<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">
|
||||
{bm.note}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<ChevronRight
|
||||
aria-hidden
|
||||
className="mt-1.5 h-4 w-4 shrink-0 text-ink-faint group-hover:text-ink"
|
||||
/>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</CollapsibleBlock>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* หมายเหตุของหน้านี้ — แถวคลิกได้ทั้งแถว (เป้าใหญ่บนจอสัมผัส) */}
|
||||
{b.anns.length > 0 && (
|
||||
<div className={b.bms.length > 0 ? "mt-3" : "mt-2"}>
|
||||
<CollapsibleBlock kind="annotation" count={b.anns.length}>
|
||||
<ul className="divide-y divide-line/70">
|
||||
{b.anns.map((a) => (
|
||||
<li key={a.id} className="ml-4">
|
||||
<Link
|
||||
href={`/annotate/${id}?ann=${a.id}`}
|
||||
className={`group -mx-2 flex items-start gap-2 rounded-lg px-2 py-2.5 transition hover:bg-ink/[0.03] ${RING_CLS}`}
|
||||
>
|
||||
<AnnotationTypeIcon
|
||||
type={a.type}
|
||||
data={a.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(a)}
|
||||
{a.type === "COMMENT" && a.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>
|
||||
<span className="mt-0.5 block text-xs text-ink-soft">
|
||||
{annotationTypeLabel(a)} · โดย {a.userName}
|
||||
</span>
|
||||
{annotationNote(a) && (
|
||||
<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">
|
||||
{annotationNote(a)}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<ChevronRight
|
||||
aria-hidden
|
||||
className="mt-1.5 h-4 w-4 shrink-0 text-ink-faint group-hover:text-ink"
|
||||
/>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CollapsibleBlock>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<SummaryExplorer fileId={id} items={items} />
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -241,9 +241,10 @@
|
|||
align-items: center;
|
||||
}
|
||||
|
||||
/* design review 2026-09-17 H1: ปุ่มหุบ/กางของ CollapsibleBlock เป็น <button>
|
||||
ไม่ใช่ <a> จึงหลุดจากกฎข้างบน (สูงจริง ~29px) — ปุ่มเป็น flex w-full
|
||||
text-left อยู่แล้ว ใส่แค่ min-height (ไม่ต้อง display/justify แบบกฎ <a>) */
|
||||
/* ปุ่มที่ไม่ใช่ <a> (แถวตัวกรองในแผง explorer ของหน้าสรุป, ปุ่มล้างตัวกรอง)
|
||||
หลุดจากกฎข้างบน — ใส่แค่ min-height (ปุ่มเป็น flex w-full text-left อยู่แล้ว
|
||||
ไม่ต้อง display/justify แบบกฎ <a>; เดิมเป็นปุ่มหุบ/กางของ CollapsibleBlock
|
||||
design review 2026-09-17 H1 ก่อนหน้าถูกแทนด้วยตัวกรอง explorer 2026-09-24) */
|
||||
main.summary-page button {
|
||||
min-height: 2.75rem;
|
||||
}
|
||||
|
|
@ -308,7 +309,10 @@
|
|||
แปลงเป็นกรอบเส้นน้ำเงิน + ตัวอักษรน้ำเงินแทน (อ่านได้บนเครื่องพิมพ์ขาวดำ
|
||||
ประหยัดหมึก) และไม่ผ่าหน้ากระดาษกลาง section ของหน้าเอกสาร (หน้าที่ยาวเกิน
|
||||
กระดาษเดียว browser เลื่อนทั้งก้อนขึ้นหน้าถัดไปเอง — break-inside เป็นคำ
|
||||
แนะนำ ไม่ใช่กฎเหล็ก) / แถบ action กับรายการตามหน้ามือถือซ่อนด้วย print:hidden ใน JSX */
|
||||
แนะนำ ไม่ใช่กฎเหล็ก) / แถบ action กับแผงตัวกรอง (ทั้ง <details> มือถือและ
|
||||
aside desktop) ซ่อนด้วย print:hidden ใน JSX — รายการบนกระดาษเป็นฉบับเต็ม
|
||||
เสมอไม่สนตัวกรองที่ตั้งค้างไว้ (redesign explorer 2026-09-24: SummaryList
|
||||
render ชุดเต็มแยกไว้เป็น hidden print:block) */
|
||||
@media print {
|
||||
main.summary-page header {
|
||||
background: #fff;
|
||||
|
|
@ -321,6 +325,14 @@
|
|||
main.summary-page section {
|
||||
break-inside: avoid-page;
|
||||
}
|
||||
/* แถวรายการของหน้าเดียวกันไม่ถูกผ่ากลางระหว่างหน้ากระดาษ */
|
||||
main.summary-page section > ul > li {
|
||||
break-inside: avoid-page;
|
||||
}
|
||||
/* หัวการ์ดรายหน้าเป็น sticky บนจอ — กลับเป็นตำแหน่งปกติบนกระดาษ */
|
||||
main.summary-page .page-head {
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
|
||||
/* UX review 2026-09-14 H2 → typography audit 2026-09-16 (T1 + Option B) →
|
||||
|
|
|
|||
|
|
@ -1,19 +1,14 @@
|
|||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { notFound } from "next/navigation";
|
||||
import { asc, eq, inArray } from "drizzle-orm";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Bookmark,
|
||||
ChevronDown,
|
||||
FileImage,
|
||||
FileText,
|
||||
Folder,
|
||||
StickyNote,
|
||||
} from "lucide-react";
|
||||
import { ArrowLeft, Folder } from "lucide-react";
|
||||
|
||||
import { AnnotationTypeIcon } from "@/components/AnnotationTypeIcon";
|
||||
import AppNav from "@/components/AppNav";
|
||||
import {
|
||||
GroupExplorer,
|
||||
type GroupFileSummary,
|
||||
type GroupListItem,
|
||||
} from "@/components/groups/GroupExplorer";
|
||||
import { db } from "@/db";
|
||||
import {
|
||||
annotations,
|
||||
|
|
@ -24,41 +19,20 @@ import {
|
|||
type AnnotationDTO,
|
||||
type BookmarkDTO,
|
||||
} from "@/db/schema";
|
||||
import {
|
||||
annotationNote,
|
||||
annotationPreviewText,
|
||||
annotationTypeLabel,
|
||||
} from "@/utils/annotation-display";
|
||||
import { formatThaiDate } from "@/utils/date-format";
|
||||
import { imageFormatFromUrl } from "@/utils/file-kind";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
|
||||
// N9 (PLAN-DOCSET.md D3) — หน้ารวมเอกสารของกลุ่ม (type_id จากลิงก์ e-Court)
|
||||
// server component force-dynamic — login ถูกบังคับโดย middleware.ts อยู่แล้ว
|
||||
// snapshot ณ ตอนโหลดเหมือนหน้าสรุป F15 (ไม่มี SSE) — กลุ่มไม่มีไฟล์เลย = 404
|
||||
// เพราะไม่มีตารางกลุ่ม: กลุ่มมีชีวิตตามข้อมูลจริงใน files ณ ตอน query
|
||||
// การ์ดแต่ละไฟล์หุบ/กางดู bookmark+annotation ของไฟล์นั้น (คำขอผู้ใช้ 2026-09-23)
|
||||
// รวมเป็นรายการเดียวเรียงตามหน้า — แถวคลิกไป deep link เดียวกับหน้าสรุป (F12)
|
||||
//
|
||||
// redesign 2026-09-24 ตามคำขอผู้ใช้: ไม่มี navbar — explorer 2 คอลัมน์ ตัวกรอง
|
||||
// ฝั่งซ้าย + list เดียวรวม bookmark+annotation ทั้งกลุ่มไม่แยกไฟล์ (เคยเป็นการ์ด
|
||||
// ต่อไฟล์ + <details>) — query ทั้งหมดทำที่นี่แล้วส่งข้อมูลพร้อมใช้ให้ GroupExplorer
|
||||
// กรองใน memory ฝั่ง client
|
||||
|
||||
type FileBucket = { anns: AnnotationDTO[]; bms: BookmarkDTO[] };
|
||||
|
||||
/** bookmark+annotation ของไฟล์รวมเป็นรายการเดียวเรียงตามหน้า (หน้าเดียวกัน
|
||||
* ให้ที่คั่นหน้าก่อนหมายเหตุ — ไล่ตามตำแหน่งในเอกสาร) */
|
||||
function mergedItems(b: FileBucket) {
|
||||
return [
|
||||
...b.bms.map((dto) => ({ kind: "bm" as const, pageIndex: dto.pageIndex, dto })),
|
||||
...b.anns.map((dto) => ({ kind: "ann" as const, pageIndex: dto.pageIndex, dto })),
|
||||
].sort(
|
||||
(a, x) =>
|
||||
a.pageIndex - x.pageIndex ||
|
||||
(a.kind === x.kind ? 0 : a.kind === "bm" ? -1 : 1),
|
||||
);
|
||||
}
|
||||
|
||||
/** focus ring ร่วมของลิงก์รายการในการ์ด — Genesis .focus-ring (outline น้ำเงินทึบ
|
||||
* 2px + offset ตาม spec Elevation) เดียวกับหน้าสรุป */
|
||||
const RING_CLS = "focus-ring";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function generateMetadata({
|
||||
|
|
@ -76,10 +50,6 @@ export default async function GroupPage({
|
|||
}) {
|
||||
const { typeId } = params;
|
||||
|
||||
// AppNav ต้องการชื่อผู้ใช้ — defense ซ้อน middleware กัน render หน้าให้คนไม่มี session
|
||||
const user = getSessionUser();
|
||||
if (!user) redirect("/login");
|
||||
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(files)
|
||||
|
|
@ -87,8 +57,7 @@ export default async function GroupPage({
|
|||
.orderBy(asc(files.createdAt));
|
||||
if (rows.length === 0) notFound();
|
||||
|
||||
// bookmark+annotation ทั้งกลุ่ม 2 query แยก (ไม่ join หนัก) แล้วจัดตามไฟล์ใน JS —
|
||||
// ได้ทั้งจำนวนสำหรับบรรทัด meta และรายการเต็มสำหรับกางดูในการ์ด
|
||||
// bookmark+annotation ทั้งกลุ่ม 2 query แยก (ไม่ join หนัก) แล้วจัดตามไฟล์ใน JS
|
||||
const ids = rows.map((f) => f.id);
|
||||
const [annRows, bmRows] = await Promise.all([
|
||||
db
|
||||
|
|
@ -123,174 +92,70 @@ export default async function GroupPage({
|
|||
// หา parent ของ sub bookmark แสดง "ใน: …" — id unique ทั้งตาราง ใช้ map เดียวร่วมทั้งกลุ่ม
|
||||
const bookmarkById = new Map(bmDtos.map((b) => [b.id, b] as const));
|
||||
|
||||
const fileSummaries: GroupFileSummary[] = rows.map((f) => ({
|
||||
id: f.id,
|
||||
title: f.title,
|
||||
isImage: Boolean(imageFormatFromUrl(f.pdfUrl)),
|
||||
}));
|
||||
|
||||
// list เดียวรวมทั้งกลุ่มไล่ตามลำดับอ่านสำนวน: ไฟล์ตาม createdAt (rows เรียงแล้ว)
|
||||
// → หน้า → หน้าเดียวกันให้ที่คั่นหน้าก่อนหมายเหตุ → createdAt เป็น tiebreak
|
||||
// (sort ครั้งเดียวบน server — ฝั่ง client กรองเป็น pure predicate ไม่ sort ซ้ำ)
|
||||
const items: GroupListItem[] = rows.flatMap((f) => {
|
||||
const bucket = byFile.get(f.id) ?? { anns: [], bms: [] };
|
||||
return [
|
||||
...bucket.bms.map((dto) => {
|
||||
const parent = dto.parentId ? bookmarkById.get(dto.parentId) : undefined;
|
||||
return {
|
||||
kind: "bm" as const,
|
||||
pageIndex: dto.pageIndex,
|
||||
dto,
|
||||
parentTitle: parent?.title ?? null,
|
||||
};
|
||||
}),
|
||||
...bucket.anns.map((dto) => ({
|
||||
kind: "ann" as const,
|
||||
pageIndex: dto.pageIndex,
|
||||
dto,
|
||||
})),
|
||||
].sort(
|
||||
(a, x) =>
|
||||
a.pageIndex - x.pageIndex ||
|
||||
(a.kind === x.kind ? 0 : a.kind === "bm" ? -1 : 1) ||
|
||||
new Date(a.dto.createdAt).getTime() - new Date(x.dto.createdAt).getTime(),
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<AppNav userName={user.userName} />
|
||||
{/* listing-page: hook ของกฎ coarse 44px ใน globals.css (อย่าถอด) */}
|
||||
<main className="listing-page mx-auto max-w-3xl px-4 py-8 sm:px-6">
|
||||
<Link
|
||||
href="/"
|
||||
className="focus-ring inline-flex items-center gap-1.5 rounded-md text-sm text-ink-soft transition hover:text-ink"
|
||||
>
|
||||
<ArrowLeft aria-hidden className="h-4 w-4" />
|
||||
หน้ารายการ
|
||||
</Link>
|
||||
// listing-page: hook ของกฎ coarse 44px ใน globals.css (อย่าถอด)
|
||||
<main className="listing-page mx-auto max-w-3xl px-4 py-8 sm:px-6 lg:max-w-6xl lg:px-8">
|
||||
<Link
|
||||
href="/"
|
||||
className="focus-ring inline-flex items-center gap-1.5 rounded-md text-sm text-ink-soft transition hover:text-ink"
|
||||
>
|
||||
<ArrowLeft aria-hidden className="h-4 w-4" />
|
||||
หน้ารายการ
|
||||
</Link>
|
||||
|
||||
<header className="mt-4 flex items-center gap-3">
|
||||
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-lg bg-ink/[0.04]">
|
||||
<Folder aria-hidden className="h-5 w-5 text-ink-soft" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h1 className="font-display break-all text-section-title text-ink">
|
||||
{typeId}
|
||||
</h1>
|
||||
<p className="mt-0.5 text-sm text-ink-soft">{rows.length} เอกสาร</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* หมายเหตุแสดงทุกครั้ง (แผน N9 D3): ระบบรู้จักเฉพาะเอกสารที่เคยเปิดผ่านลิงก์ e-Court
|
||||
รายการครบทั้งคดีอยู่ใน e-Court ไม่ใช่ระบบนี้ — Genesis: แผงข้อมูลกลาง 8px radius
|
||||
(น้ำเงินสงวนให้ของ interactive จึงเป็นเทาโปร่ง ไม่ใช่แบนเนอร์ฟ้า) */}
|
||||
<div className="mt-4 rounded-lg border border-line bg-ink/[0.03] px-4 py-3 text-sm text-ink-soft">
|
||||
แสดงเฉพาะเอกสารที่เคยเปิดในระบบนี้
|
||||
<header className="mt-4 flex items-center gap-3">
|
||||
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-lg bg-ink/[0.04]">
|
||||
<Folder aria-hidden className="h-5 w-5 text-ink-soft" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h1 className="font-display break-all text-section-title text-ink">
|
||||
{typeId}
|
||||
</h1>
|
||||
<p className="mt-0.5 text-sm text-ink-soft">
|
||||
{rows.length} เอกสาร · {items.length} รายการ
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<ul className="mt-6 space-y-4">
|
||||
{rows.map((f) => {
|
||||
const bucket = byFile.get(f.id) ?? { anns: [], bms: [] };
|
||||
const total = bucket.anns.length + bucket.bms.length;
|
||||
return (
|
||||
<li
|
||||
key={f.id}
|
||||
className="rounded-xl border border-line bg-white p-5"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-ink/[0.04]">
|
||||
{imageFormatFromUrl(f.pdfUrl) ? (
|
||||
<FileImage aria-hidden className="h-5 w-5 text-ink-soft" />
|
||||
) : (
|
||||
<FileText aria-hidden className="h-5 w-5 text-ink-soft" />
|
||||
)}
|
||||
</div>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block break-all line-clamp-2 text-body font-medium text-ink">
|
||||
{f.title}
|
||||
</span>
|
||||
{/* วันที่/จำนวนเป็นข้อมูลที่อ่านจริง ใช้ ink-soft (ผ่าน 4.5:1) —
|
||||
ink-faint เก็บไว้ให้ placeholder/disabled เท่านั้น (UX review M2) */}
|
||||
<span className="mt-0.5 block text-caption text-ink-soft">
|
||||
นำเข้า {formatThaiDate(f.createdAt, true)} · Annotations{" "}
|
||||
{bucket.anns.length} · Bookmarks {bucket.bms.length}
|
||||
</span>
|
||||
</span>
|
||||
<Link
|
||||
href={`/annotate/${f.id}`}
|
||||
className="focus-ring inline-flex h-8 shrink-0 items-center rounded-md border border-line px-3 text-sm font-medium text-ink transition hover:-translate-y-px hover:border-ink-faint"
|
||||
>
|
||||
Annotate
|
||||
</Link>
|
||||
</div>
|
||||
{/* หมายเหตุ "แสดงเฉพาะเอกสารที่เคยเปิดในระบบนี้" (แผน N9 D3 เดิม) ถูกถอดออก
|
||||
ตามคำขอผู้ใช้ 2026-09-24 — ระบบรู้จักเฉพาะเอกสารที่เคยเปิดผ่านลิงก์ e-Court
|
||||
รายการครบทั้งคดีอยู่ใน e-Court ไม่ใช่ระบบนี้ */}
|
||||
|
||||
{total > 0 && (
|
||||
<details className="group mt-3 border-t border-line/70">
|
||||
<summary className="flex cursor-pointer list-none items-center justify-between rounded-md px-1 py-2.5 text-sm font-medium text-ink-soft transition hover:text-primary [&::-webkit-details-marker]:hidden">
|
||||
Annotations & Bookmarks ({total})
|
||||
<ChevronDown
|
||||
aria-hidden
|
||||
className="h-4 w-4 shrink-0 text-ink-faint transition-transform group-open:rotate-180 motion-reduce:transition-none"
|
||||
/>
|
||||
</summary>
|
||||
<ul className="divide-y divide-line/70">
|
||||
{mergedItems(bucket).map((item) => {
|
||||
const parent =
|
||||
item.kind === "bm" && item.dto.parentId
|
||||
? bookmarkById.get(item.dto.parentId)
|
||||
: undefined;
|
||||
return item.kind === "bm" ? (
|
||||
<li key={item.dto.id}>
|
||||
<Link
|
||||
href={`/annotate/${f.id}?page=${item.pageIndex + 1}`}
|
||||
className={`-mx-2 flex items-start gap-2 rounded-lg px-2 py-2.5 transition hover:bg-ink/[0.03] ${RING_CLS}`}
|
||||
>
|
||||
<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>
|
||||
<span className="mt-0.5 block truncate text-xs text-ink-soft">
|
||||
หน้า {item.pageIndex + 1} ·{" "}
|
||||
{formatThaiDate(item.dto.createdAt, true)} · โดย{" "}
|
||||
{item.dto.userName || "ไม่ทราบชื่อ"}
|
||||
{parent ? ` · ใน: ${parent.title}` : ""}
|
||||
</span>
|
||||
{item.dto.note && (
|
||||
<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">
|
||||
{item.dto.note}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
) : (
|
||||
<li key={item.dto.id}>
|
||||
<Link
|
||||
href={`/annotate/${f.id}?ann=${item.dto.id}`}
|
||||
className={`-mx-2 flex items-start gap-2 rounded-lg px-2 py-2.5 transition hover:bg-ink/[0.03] ${RING_CLS}`}
|
||||
>
|
||||
<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>
|
||||
<span className="mt-0.5 block text-xs text-ink-soft">
|
||||
หน้า {item.pageIndex + 1} ·{" "}
|
||||
{annotationTypeLabel(item.dto)} ·{" "}
|
||||
{formatThaiDate(item.dto.createdAt, true)} · โดย{" "}
|
||||
{item.dto.userName}
|
||||
</span>
|
||||
{annotationNote(item.dto) && (
|
||||
<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">
|
||||
{annotationNote(item.dto)}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</details>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</main>
|
||||
</>
|
||||
<GroupExplorer files={fileSummaries} items={items} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue