groups: หน้ากลุ่มแสดง bookmark+annotation ทั้งกลุ่มในการ์ดไฟล์ — หุบ/กาง <details> "Annotations & Bookmarks (N)" รวมรายการเดียวเรียงตามหน้า (หน้าเดียวกัน bookmark ก่อน) + deep link ?ann=/?page= เหมือนหน้าสรุป + วันเวลา (formatThaiDate) ต่อรายการ + meta การ์ดเป็น en + ย่อ banner เป็น "แสดงเฉพาะเอกสารที่เคยเปิดในระบบนี้" — ปิด gap 5.3.10 ครึ่งหลัง (bookmark รวมทั้งกลุ่ม) + docs sync ครบ (คำขอผู้ใช้ 2026-09-23)
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
parent
329ae8e956
commit
e254b935ce
5 changed files with 223 additions and 57 deletions
|
|
@ -1,11 +1,33 @@
|
|||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { asc, count, eq, inArray } from "drizzle-orm";
|
||||
import { ArrowLeft, FileImage, FileText, Folder } from "lucide-react";
|
||||
import { asc, eq, inArray } from "drizzle-orm";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Bookmark,
|
||||
ChevronDown,
|
||||
FileImage,
|
||||
FileText,
|
||||
Folder,
|
||||
StickyNote,
|
||||
} from "lucide-react";
|
||||
|
||||
import { AnnotationTypeIcon } from "@/components/AnnotationTypeIcon";
|
||||
import { db } from "@/db";
|
||||
import { annotations, bookmarks, files } from "@/db/schema";
|
||||
import {
|
||||
annotations,
|
||||
bookmarks,
|
||||
files,
|
||||
toAnnotationDTO,
|
||||
toBookmarkDTO,
|
||||
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";
|
||||
|
||||
|
|
@ -13,6 +35,27 @@ import { imageFormatFromUrl } from "@/utils/file-kind";
|
|||
// server component force-dynamic — login ถูกบังคับโดย middleware.ts อยู่แล้ว
|
||||
// snapshot ณ ตอนโหลดเหมือนหน้าสรุป F15 (ไม่มี SSE) — กลุ่มไม่มีไฟล์เลย = 404
|
||||
// เพราะไม่มีตารางกลุ่ม: กลุ่มมีชีวิตตามข้อมูลจริงใน files ณ ตอน query
|
||||
// การ์ดแต่ละไฟล์หุบ/กางดู bookmark+annotation ของไฟล์นั้น (คำขอผู้ใช้ 2026-09-23)
|
||||
// รวมเป็นรายการเดียวเรียงตามหน้า — แถวคลิกไป deep link เดียวกับหน้าสรุป (F12)
|
||||
|
||||
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 ร่วมของลิงก์รายการในการ์ด (เดียวกับ RING_CLS ของหน้าสรุป) */
|
||||
const RING_CLS =
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
|
|
@ -38,23 +81,41 @@ export default async function GroupPage({
|
|||
.orderBy(asc(files.createdAt));
|
||||
if (rows.length === 0) notFound();
|
||||
|
||||
// จำนวน annotation/bookmark ต่อไฟล์ — 2 query แยกด้วย groupBy fileId (ไม่ join หนัก)
|
||||
// bookmark+annotation ทั้งกลุ่ม 2 query แยก (ไม่ join หนัก) แล้วจัดตามไฟล์ใน JS —
|
||||
// ได้ทั้งจำนวนสำหรับบรรทัด meta และรายการเต็มสำหรับกางดูในการ์ด
|
||||
const ids = rows.map((f) => f.id);
|
||||
const [annCounts, bmCounts] = await Promise.all([
|
||||
const [annRows, bmRows] = await Promise.all([
|
||||
db
|
||||
.select({ fileId: annotations.fileId, n: count() })
|
||||
.select()
|
||||
.from(annotations)
|
||||
.where(inArray(annotations.fileId, ids))
|
||||
.groupBy(annotations.fileId),
|
||||
.orderBy(asc(annotations.pageIndex), asc(annotations.createdAt)),
|
||||
db
|
||||
.select({ fileId: bookmarks.fileId, n: count() })
|
||||
.select()
|
||||
.from(bookmarks)
|
||||
.where(inArray(bookmarks.fileId, ids))
|
||||
.groupBy(bookmarks.fileId),
|
||||
.orderBy(
|
||||
asc(bookmarks.pageIndex),
|
||||
asc(bookmarks.yOffset),
|
||||
asc(bookmarks.createdAt),
|
||||
),
|
||||
]);
|
||||
// drizzle count() ใช้ .mapWith(Number) ให้แล้ว — r.n เป็น number ทั้ง type และ runtime
|
||||
const annByFile = new Map(annCounts.map((r) => [r.fileId, r.n]));
|
||||
const bmByFile = new Map(bmCounts.map((r) => [r.fileId, r.n]));
|
||||
const annDtos = annRows.map(toAnnotationDTO);
|
||||
const bmDtos = bmRows.map(toBookmarkDTO);
|
||||
|
||||
const byFile = new Map<string, FileBucket>();
|
||||
const bucketOf = (fileId: string): FileBucket => {
|
||||
let b = byFile.get(fileId);
|
||||
if (!b) {
|
||||
b = { anns: [], bms: [] };
|
||||
byFile.set(fileId, b);
|
||||
}
|
||||
return b;
|
||||
};
|
||||
for (const d of annDtos) bucketOf(d.fileId).anns.push(d);
|
||||
for (const d of bmDtos) bucketOf(d.fileId).bms.push(d);
|
||||
// หา parent ของ sub bookmark แสดง "ใน: …" — id unique ทั้งตาราง ใช้ map เดียวร่วมทั้งกลุ่ม
|
||||
const bookmarkById = new Map(bmDtos.map((b) => [b.id, b] as const));
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-2xl px-4 py-10 sm:px-8">
|
||||
|
|
@ -81,39 +142,141 @@ export default async function GroupPage({
|
|||
{/* หมายเหตุแสดงทุกครั้ง (แผน N9 D3): ระบบรู้จักเฉพาะเอกสารที่เคยเปิดผ่านลิงก์ e-Court
|
||||
รายการครบทั้งคดีอยู่ใน e-Court ไม่ใช่ระบบนี้ */}
|
||||
<div className="mt-4 rounded-xl border border-blue-200 bg-blue-50 p-4 text-sm text-blue-800">
|
||||
แสดงเฉพาะเอกสารที่เคยเปิดในระบบนี้ — อาจไม่ครบทุกฉบับของกลุ่ม
|
||||
แสดงเฉพาะเอกสารที่เคยเปิดในระบบนี้
|
||||
</div>
|
||||
|
||||
<ul className="mt-6 space-y-3">
|
||||
{rows.map((f) => (
|
||||
<li
|
||||
key={f.id}
|
||||
className="group flex items-center gap-3 rounded-xl border border-slate-200 bg-white p-4 shadow-sm transition hover:border-primary/30 hover:shadow-md"
|
||||
>
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
||||
{imageFormatFromUrl(f.pdfUrl) ? (
|
||||
<FileImage className="h-5 w-5 text-primary" />
|
||||
) : (
|
||||
<FileText className="h-5 w-5 text-primary" />
|
||||
)}
|
||||
</div>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block break-all line-clamp-2 text-sm font-medium text-slate-900">
|
||||
{f.title}
|
||||
</span>
|
||||
<span className="mt-0.5 block text-xs text-slate-500">
|
||||
นำเข้า {formatThaiDate(f.createdAt, true)} · หมายเหตุ{" "}
|
||||
{annByFile.get(f.id) ?? 0} · ที่คั่นหน้า {bmByFile.get(f.id) ?? 0}
|
||||
</span>
|
||||
</span>
|
||||
<Link
|
||||
href={`/annotate/${f.id}`}
|
||||
className="shrink-0 rounded-md bg-primary px-4 py-2 text-sm font-medium text-white transition hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1"
|
||||
{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-slate-200 bg-white p-4 shadow-sm transition hover:border-primary/30 hover:shadow-md"
|
||||
>
|
||||
Annotate
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
||||
{imageFormatFromUrl(f.pdfUrl) ? (
|
||||
<FileImage className="h-5 w-5 text-primary" />
|
||||
) : (
|
||||
<FileText className="h-5 w-5 text-primary" />
|
||||
)}
|
||||
</div>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block break-all line-clamp-2 text-sm font-medium text-slate-900">
|
||||
{f.title}
|
||||
</span>
|
||||
<span className="mt-0.5 block text-xs text-slate-500">
|
||||
นำเข้า {formatThaiDate(f.createdAt, true)} · Annotations{" "}
|
||||
{bucket.anns.length} · Bookmarks {bucket.bms.length}
|
||||
</span>
|
||||
</span>
|
||||
<Link
|
||||
href={`/annotate/${f.id}`}
|
||||
className="shrink-0 rounded-md bg-primary px-4 py-2 text-sm font-medium text-white transition hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1"
|
||||
>
|
||||
Annotate
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{total > 0 && (
|
||||
<details className="group mt-3 border-t border-slate-100">
|
||||
<summary className="flex cursor-pointer list-none items-center justify-between rounded-md px-1 py-2 text-sm font-medium text-slate-700 transition hover:text-primary [&::-webkit-details-marker]:hidden">
|
||||
Annotations & Bookmarks ({total})
|
||||
<ChevronDown
|
||||
aria-hidden
|
||||
className="h-4 w-4 shrink-0 text-slate-400 transition-transform group-open:rotate-180 motion-reduce:transition-none"
|
||||
/>
|
||||
</summary>
|
||||
<ul className="divide-y divide-slate-100">
|
||||
{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-slate-50 ${RING_CLS}`}
|
||||
>
|
||||
<Bookmark
|
||||
aria-hidden
|
||||
className="mt-1 h-4 w-4 shrink-0 text-amber-500"
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block whitespace-pre-wrap break-words text-sm font-medium leading-snug text-slate-800">
|
||||
{item.dto.title}
|
||||
</span>
|
||||
<span className="mt-0.5 block truncate text-xs text-slate-500">
|
||||
หน้า {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-slate-500">
|
||||
<StickyNote
|
||||
aria-hidden
|
||||
className="mt-0.5 h-3 w-3 shrink-0 text-amber-400"
|
||||
/>
|
||||
<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-slate-50 ${RING_CLS}`}
|
||||
>
|
||||
<AnnotationTypeIcon
|
||||
type={item.dto.type}
|
||||
data={item.dto.data}
|
||||
className="mt-1 h-4 w-4 shrink-0 text-slate-500"
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block whitespace-pre-wrap break-words text-sm leading-snug text-slate-800">
|
||||
{annotationPreviewText(item.dto)}
|
||||
{item.dto.type === "COMMENT" &&
|
||||
item.dto.resolved && (
|
||||
<span className="ml-1.5 whitespace-nowrap rounded border border-emerald-600/50 bg-emerald-50 px-1.5 py-0.5 text-xs text-emerald-700">
|
||||
จัดการแล้ว
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="mt-0.5 block text-xs text-slate-500">
|
||||
หน้า {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-slate-500">
|
||||
<StickyNote
|
||||
aria-hidden
|
||||
className="mt-0.5 h-3 w-3 shrink-0 text-amber-400"
|
||||
/>
|
||||
<span className="line-clamp-2 whitespace-pre-wrap break-words">
|
||||
{annotationNote(item.dto)}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</details>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</main>
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue