Compare commits

..

No commits in common. "882325b58af216cc01ad9d99371dc419582c8af2" and "e254b935ce7eff91eb81b9ace499997845fa2aff" have entirely different histories.

63 changed files with 1344 additions and 3659 deletions

View file

@ -20,6 +20,5 @@ paths:
- ตาราง `annotation_versions` (F19/A7): `annotation_id` เป็น uuid **ไม่ FK** (D8 — snapshot ตอน delete ต้องรอดจาก CASCADE ของแม่) + `file_id` FK CASCADE + `page_index` (restore ต้องรู้หน้า) + `kind` text `'update' | 'delete'` + index `(file_id)`; `updateAnnotation`/`deleteAnnotation` snapshot row เดิม **ก่อนเขียน/ลบ ใน transaction เดียวกัน**; `restoreVersion(versionId, userId)` — **เจ้าของเท่านั้น (D9)**: guard `userId` ทั้ง row ปัจจุบัน (ต้นทางยังอยู่) และ snapshot (ต้นทางถูกลบ) / ต้นทางยังอยู่ = **เขียนกลับ row เดิม** (snapshot ปัจจุบันก่อน) + publish update event / ต้นทางถูกลบ = สร้างใหม่ id ใหม่ + publish create event (ห้ามสร้างสำเนาใหม่ทุกกรณี — เคยทำให้เกิดรายการซ้ำบนหน้า)
- ห้ามเพิ่มตาราง/column ที่ไม่มีในแผน (ดู out of scope §9) โดยไม่อัปเดต `docs/PLAN.md` ก่อน
- `files.pdf_url` เก็บ path **รูปภาพ** (jpg/jpeg/png/tif/tiff) ใน `public/` ได้เหมือน PDF (N3) — **ไม่มี column kind**: ชนิดไฟล์ derive จาก extension ผ่าน `utils/file-kind.ts` เท่านั้น (จะเพิ่ม column ต้องแก้แผนก่อน); TIFF หลายเฟรม = `pageIndex` ต่อเฟรม (1 เฟรม = 1 หน้า)
- Server Action `uploadFile` (`app/actions/files.ts`, N4): validate extension (whitelist `SAMPLE_FILE_EXTENSIONS`) + ≤ 100MB ก่อนเขียน — บันทึกเป็นไฟล์**ใหม่** `public/uploads/<uuid>.<ext>` เท่านั้น (ชื่อจาก randomUUID — ห้ามใช้ชื่อผู้ใช้ตกแต่ง path), insert row (รวม `type_id` จากช่องกลุ่มทางเลือกของฟอร์ม — 2026-09-23) แล้ว `revalidatePath("/")` — ไม่ publish SSE event (รายการไฟล์ไม่ได้ sync ผ่าน SSE); seed-samples จัดการเฉพาะ `/samples/%` ไม่แตะ `/uploads/`
- `files.type_id` (N9 — รหัสกลุ่มเอกสาร จากลิงก์ e-Court `?typeId=` หรือช่อง "กลุ่ม typeId" ของฟอร์มอัปโหลด N4): **เขียนสองที่** — (1) `registerExternalFile` ของ N8 (insert ครั้งแรก + update เมื่อลิงก์รอบใหม่ส่ง `typeId` มา — ลิงก์ไม่ส่ง = คงเดิม ห้ามเคลียร์) (2) `uploadFile` ของ N4 ช่องทางเลือกในฟอร์มอัปโหลด — insert ตอนสร้าง row ใหม่เท่านั้น ไม่มีหน้าทางแก้กลุ่มของไฟล์เดิม; normalize ผ่าน `normDocumentGroup` ของ `utils/external-file.ts` ที่เดียว (trim → ว่าง = NULL, cap 200 — responsive audit 2026-09-24 รวมจากเดิมที่เป็นสองสำเนา mirroring กัน) — N9 อ่านอย่างเดียว (หน้า `/` จัด section + `/groups/[typeId]`) ไม่มี Server Action/SSE ใหม่; กลุ่ม = ค่า distinct ของ `type_id` ณ ตอน query — ไม่มีตารางกลุ่ม
- API ภายนอก (N10 — `app/api/external/documents` เส้นเดียว **POST + JSON body** `{fileId และ/หรือ typeId, filter:{type, userId, annotationTypes}}` — ส่งทั้งคู่ = AND เหลือเฉพาะเอกสารเดียวที่ตรงทั้งสองเงื่อนไข): **read-only ทั้งหมด** — ไม่เขียน DB ไม่ publish SSE event ไม่ `revalidatePath` (snapshot ณ ตอนเรียก ต่าง context กับ Server Action ใน invariant #4); auth ผ่าน `requireExternalApiKey` ของ `lib/external-api-auth.ts` เป็นอย่างแรกก่อนแตะ DB เสมอ (header `x-api-key` เทียบ env `EXTERNAL_API_KEY` แบบ timing-safe) — **env ว่าง/ไม่ตั้ง = fail closed 401 ทุก request** ไม่มี mode เปิดฟรี; ผู้เรียกคือ backend e-Court (server-to-server) จึงไม่ใส่ CORS headers; validation ของ body (`parseQueryBody`) + การ query/mapper (`loadDocumentBundles` — bulk `inArray` ทุกไฟล์ในชุด query เดียว ไม่ยิงต่อไฟล์) รวมอยู่ `lib/external-api.ts` จุดเดียว ห้ามแยกสำเนา; route ครอบ try/catch ทุก DB call — query ล้ม = log ฝั่ง server + **500 JSON ตาม contract** ของ endpoint (ไม่ปล่อยเป็น 500 ของ framework); `lib/external-api-auth.ts` ห้าม import module ที่ลาก `@/db` มา (unit test ต้องรันได้ไม่ต้องตั้ง DATABASE_URL)
- Server Action `uploadFile` (`app/actions/files.ts`, N4): validate extension (whitelist `SAMPLE_FILE_EXTENSIONS`) + ≤ 100MB ก่อนเขียน — บันทึกเป็นไฟล์**ใหม่** `public/uploads/<uuid>.<ext>` เท่านั้น (ชื่อจาก randomUUID — ห้ามใช้ชื่อผู้ใช้ตกแต่ง path), insert row แล้ว `revalidatePath("/")` — ไม่ publish SSE event (รายการไฟล์ไม่ได้ sync ผ่าน SSE); seed-samples จัดการเฉพาะ `/samples/%` ไม่แตะ `/uploads/`
- `files.type_id` (N9 — รหัสกลุ่มเอกสารจากลิงก์ e-Court `?typeId=`): **เขียนที่ `registerExternalFile` ของ N8 เท่านั้น** (insert ครั้งแรก + update เมื่อลิงก์รอบใหม่ส่ง `typeId` มา — ลิงก์ไม่ส่ง = คงเดิม ห้ามเคลียร์) — N9 อ่านอย่างเดียว (หน้า `/` จัด section + `/groups/[typeId]`) ไม่มี Server Action/SSE ใหม่; กลุ่ม = ค่า distinct ของ `type_id` ณ ตอน query — ไม่มีตารางกลุ่ม

View file

@ -17,7 +17,7 @@ paths:
- คีย์ลัด H/D/C/B ต้อง `e.preventDefault()` เสมอ — B เปิด popup bookmark ที่ input `autoFocus` ทันทีใน keydown เดียวกัน ถ้าไม่กัน browser พิมพ์ "b" ตามลงในช่องชื่อ
- `DEL`/`Backspace` ลบ annotation ที่เลือกอยู่ (`selectedId`) — เฉพาะรายการของตัวเอง (เงื่อนไขเดียวกับปุ่มลบในแผง selection), ข้ามเมื่อ focus อยู่ใน form (`inForm` เดียวกับคีย์ลัดอื่น — พิมพ์ใน composer/textbox ต้องไม่โดนลบ)
- Page Virtualization: `IntersectionObserver` render เฉพาะหน้าใน viewport ± 1 หน้า; หน้าพ้นจอ = placeholder `div` ขนาดจริง (จาก `pdfPage.getViewport()` ทุกหน้า ครั้งเดียวตอนโหลด) + unmount canvas/TextLayer/InkLayer คืน memory — annotation ของหน้าที่ไม่ render ยังอยู่ใน state
- pdfjs: `dynamic import("pdfjs-dist")` ภายใน `useEffect` เท่านั้น; `GlobalWorkerOptions.workerSrc = withBasePath("/pdf.worker.min.mjs")` — ไฟล์ที่ `public/` **ไม่ใช่สำเนาตรง ๆ** ของ node_modules: postinstall (`scripts/patch-pdf-worker.mjs`) คัดลอกแล้ว patch heuristic fake-space ไทย = ถอด `if (scaledDim)` รอบ `textChunk.prevTransform = getCurrentTextTransform()` ให้อัปเดตทุก glyph (เดิมข้าม glyph กว้าง 0 อย่างสระ/วรรณยุกต์ — charSpacing ของ glyph เหล่านั้นสะสมเป็น gap จน `addFakeSpaces` แทรก " " กลางคำ "ฉะนั้ น" ใน PDF ที่ justify ด้วย letter-spacing ทุก glyph; การอัปเดตทุก glyph ทำให้ gap เหลือ charSpacing ตัวเดียว ต่ำกว่า threshold — อักขระอื่นทั้งหมดไม่เปลี่ยน เฉพาะ space หลอกหาย) — อัปเกรด pdfjs-dist แล้ว pattern minified เปลี่ยน = postinstall fail ดัง (ตั้งใจ กัน patch หลุดเงียบ) + test กัน regression ที่ `tests/scripts/patch-pdf-worker.test.ts` (fixture `tests/fixtures/thai_letterspacing.pdf` — ห้ามวางใน `public/samples/`: seed-samples จะ insert เป็นรายการเอกสารบนหน้ารายการทุก deploy); ห้าม `new URL(..., import.meta.url)`
- pdfjs: `dynamic import("pdfjs-dist")` ภายใน `useEffect` เท่านั้น; `GlobalWorkerOptions.workerSrc = withBasePath("/pdf.worker.min.mjs")` (ไฟล์คัดลอกไว้ที่ `public/` ด้วย postinstall) — ห้าม `new URL(..., import.meta.url)`
- Bookmark scroll: คำนวณ absolute position ครั้งเดียวแล้ว smooth-scroll ครั้งเดียว (ห้าม scroll ซ้อนสองจังหวะ)
- Deep link (F12/C4): PDFViewer อ่าน `window.location.search` **ครั้งเดียวหลัง `pageSizes` พร้อม** (guard ด้วย ref — ไม่ใช้ `useSearchParams` เลี่ยงข้อจำกัด prerender ของ Next 14) — `?page=N` (1-based) scroll ไปหน้านั้น, `?ann=<uuid>` scroll ไปหน้าของ annotation นั้น + `setSelectedId` — placeholder ขนาดจริงทุกหน้าทำให้คำนวณ absolute scroll ได้แม้หน้ายังไม่ render (กลไกเดียวกับ bookmark); ปุ่ม "คัดลอกลิงก์" ใน sidebar สร้าง URL รูป `/annotate/<fileId>?page=<N>&ann=<id>`
- Dialog `fixed z-50` ใน sidebar (ประวัติ) ต้อง render ผ่าน **`createPortal` ไป `document.body`** + guard `typeof document === "undefined"` ตอน SSR — `aside.sticky` สร้าง stacking context ครอบ z-index ของ dialog ไว้ ทำให้ `fixed z-50` แพ้ `textLayer` (z-[2] ระดับ root) จนคลิกปุ่มไม่ได้ (PLAN-FEAT.md §5)

View file

@ -2,6 +2,3 @@
DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:54322/ecourt-note
# N8 (optional): allowlist โฮสต์ลิงก์ไฟล์รีโมต — ไม่ตั้ง = admincourt.go.th
# EXTERNAL_FILE_HOSTS=admincourt.go.th
# N10 (optional): API key ของ /api/external/* (backend e-Court เรียก server-to-server)
# ไม่ตั้ง/ว่าง = ปฏิเสธทุก request (fail closed) — ตั้งค่ายาวและสุ่ม เช่น 32+ ตัวอักษร
# EXTERNAL_API_KEY=

File diff suppressed because one or more lines are too long

View file

@ -47,12 +47,9 @@ docker run -d --name judes-note-db \
```env
DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:54322/ecourt-note
# N10 (optional): API key ของ /api/external/* สำหรับ backend e-Court เรียกแบบ server-to-server
# ไม่ตั้งหรือเว้นว่าง = API ปฏิเสธทุก request (fail closed) — ตั้งค่ายาวและสุ่ม เช่น 32+ ตัวอักษร
# EXTERNAL_API_KEY=
```
(เทียบกับ `.env.example`)
(เทียบกับ `.env.example` — มีแค่ตัวเดียว)
### 4. สร้างตารางในฐานข้อมูล
@ -92,46 +89,12 @@ Sidebar ซ้าย: แท็บ Bookmarks / Annotations — คลิกร
ทดสอบ realtime: เปิดหน้า annotate ไฟล์เดียวกันใน 2 แท็บ แล้ววาด/comment จากแท็บหนึ่ง — อีกแท็บเห็นทันทีโดยไม่ต้อง refresh
## API สำหรับระบบภายนอก (N10)
Read-only API ให้ **backend ของ e-Court** เรียกแบบ server-to-server (ไม่มีหน้า UI) — endpoint เดียว `POST /api/external/documents` (JSON body) ตรวจสิทธิ์ด้วย API Key เดียวใน env `EXTERNAL_API_KEY` ส่งผ่าน header `x-api-key` (env ไม่ตั้ง/ว่าง = ปฏิเสธทุก request — fail closed) — คู่มือการใช้สำหรับผู้เรียก: `docs/API-DOCUMENTS.md` · สเปก/เหตุผลการออกแบบ: `docs/PLAN-API.md`
```jsonc
// POST /docedit/note/api/external/documents (URL เต็มฝั่งผู้เรียกอยู่ใต้ basePath)
// headers: x-api-key + content-type: application/json
{
"fileId": "<fileId ของ e-Court>", // ถามเอกสารเดียว — ส่งพร้อม typeId ได้ = กรองเฉพาะเอกสารที่ตรงทั้งสองเงื่อนไข (AND)
"typeId": "<typeId>", // ถามทุกเอกสารในกลุ่ม N9 — กลุ่มว่าง = 200 documents: []
"filter": {
"type": "bookmarks", // optional: "bookmarks" | "annotations" — ไม่ส่ง (หรือ type เป็น "") = ทั้งสอง
"userId": "<userId>", // optional: string หรือ array — กรองตามเจ้าของรายการ (ทั้ง annotations/bookmarks)
"annotationTypes": ["COMMENT"], // optional: ค่าจาก HIGHLIGHT|DRAW|COMMENT|SHAPE|REDACTION|TEXTBOX|STAMP — เฉพาะ annotations
}
}
```
- Response 200 เป็นรูปเดียวเสมอ: `{ "documents": [ { "id", "externalFileId", "title", "typeId", "createdAt", "annotations": [… พร้อม url + replies], "bookmarks": [… พร้อม url] } ] }` (field เอกสารอยู่ระดับเดียวกับ annotations/bookmarks) — ถามด้วย fileId ได้ array ยาว 1 ชิ้น (ไม่เคยเปิดในระบบ = 404)
- `url` ต่อรายการ = deep link กลับมาหน้า viewer เป็น **URL เต็มพร้อมใช้ทันที** (`http://<host>/docedit/note/annotate/<fileId>?ann=<id>` สำหรับ annotation · `?page=<หน้า 1-based>` สำหรับ bookmark) — origin อ่านจาก request ที่เรียกเข้ามา (ผ่าน reverse proxy ต้องตั้งส่งต่อ `x-forwarded-proto`/`x-forwarded-host`)
- error ทุกสถานะ (400/401/404/500) เป็น JSON `{ "error": "<ข้อความไทย>" }` รูปเดียวกันทั้ง endpoint — 500 เกิดเมื่อ query ฐานข้อมูลล้ม (log รายละเอียดฝั่ง server อย่างเดียว)
- ตัวอย่าง:
```bash
curl -X POST -H "x-api-key: $EXTERNAL_API_KEY" -H "content-type: application/json" \
-d '{"fileId":"1234","filter":{"type":"bookmarks"}}' \
"http://localhost:3000/docedit/note/api/external/documents"
curl -X POST -H "x-api-key: $EXTERNAL_API_KEY" -H "content-type: application/json" \
-d '{"typeId":"CASE-2569-0001"}' \
"http://localhost:3000/docedit/note/api/external/documents"
```
## Troubleshooting
| อาการ | สาเหตุ/ทางแก้ |
|---|---|
| Realtime ไม่ซิงก์ | sync ใช้ pub/sub **in-memory ของ process เดียว** — ทุกแท็บต้องเข้าผ่าน dev server เดียวกัน (ถ้าวันหนึ่งรันหลาย instance ต้องเพิ่ม Redis pub/sub หรือ Postgres LISTEN/NOTIFY ก่อน) |
| หน้าเว็บ 500 หลังรัน `next build` ระหว่างที่ dev server กำลังรัน | build เขียนทับ cache ของ dev — ปิด dev server ก่อน build หรือ restart dev server หลัง build |
| `tsc --noEmit` error ชี้ route ที่ถูกลบไปแล้ว (เช่น `app/api/external/document*` หลังรวมเป็น `query` รอบสองของ N10) | type stub ค้างใน `.next/types/` ของ dev/build รอบก่อน — ลบ `.next` หรือรัน `next build`/restart dev server หนึ่งครั้งให้ regenerate แล้วรันใหม่ |
| ต่อ DB ไม่ได้ | ตรวจว่า container รันอยู่ (`docker start judes-note-db`) และพอร์ต 54322 ไม่ถูกใช้โดย container อื่น |
| Highlight ใช้ไม่ได้กับไฟล์คดีตัวอย่าง | ไฟล์ `01012-*.pdf` ทั้งสองเป็น**ภาพสแกนล้วน** (ไม่มี text layer) — ใช้ `dashboard-report-2026-09-03.pdf` ซึ่งเป็น text-based PDF แทน จึงจะคลุมดำได้ (DRAW/COMMENT/BOOKMARK ใช้ได้ปกติกับทุกไฟล์) |

View file

@ -11,7 +11,6 @@ import { revalidatePath } from "next/cache";
import { db } from "@/db";
import { files } from "@/db/schema";
import { normDocumentGroup } from "@/utils/external-file";
import { SAMPLE_FILE_EXTENSIONS } from "@/utils/file-kind";
const MAX_UPLOAD_BYTES = 100 * 1024 * 1024; // 100MB (ตาม bodySizeLimit ใน next.config.mjs)
@ -95,9 +94,8 @@ export async function uploadFile(formData: FormData): Promise<UploadFileResult>
const title = sanitizeTitle(file.name);
const pdfUrl = `/uploads/${diskName}`;
const typeId = normDocumentGroup(formData.get("typeId"));
try {
await db.insert(files).values({ id, title, pdfUrl, typeId });
await db.insert(files).values({ id, title, pdfUrl });
} catch (err) {
// DB fail หลังเขียนไฟล์ — เก็บกวาดไฟล์ก่อนโยนต่อ กันไฟล์กำพร้าไม่มี row ชี้ (ไม่มีกลไกเก็บอื่น)
await unlink(diskPath).catch(() => undefined);

View file

@ -2,10 +2,19 @@ import type { Metadata } from "next";
import Link from "next/link";
import { asc, eq } from "drizzle-orm";
import { notFound } from "next/navigation";
import { ArrowLeft, ClipboardList } from "lucide-react";
import {
ArrowLeft,
Bookmark,
Braces,
ChevronDown,
ChevronRight,
ClipboardList,
FileDown,
StickyNote,
} from "lucide-react";
import type { ExplorerItem } from "@/components/explorer/FacetFilterPanel";
import { SummaryExplorer } from "@/components/summary/SummaryExplorer";
import { AnnotationTypeIcon } from "@/components/AnnotationTypeIcon";
import CollapsibleBlock from "@/components/summary/CollapsibleBlock";
import { db } from "@/db";
import {
annotations,
@ -13,24 +22,73 @@ 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 (แถบหัวน้ำเงิน + การ์ดรายหน้าขอบ folio +
// รายการตามหน้าจุดไข่ปลา) → redesign 2026-09-24 ตามคำขอผู้ใช้เป็น explorer
// 2 คอลัมน์แบบหน้ากลุ่ม N9: ตัวกรองฝั่งซ้าย (หน้า/ชนิดรายการ/ชนิดของ
// Annotation/ผู้บันทึก) + การ์ดรายหน้าหัว sticky — แถบหัวน้ำเงิน + action bar +
// empty state คงเดิม; query ทั้งหมดทำที่นี่แล้วส่ง `ExplorerItem[]` ให้
// SummaryExplorer กรองใน memory ฝั่ง client (read-only snapshot ไม่มี SSE)
// จัดกลุ่มตามหน้า แต่ละแถวกระโดดเป็น 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)
const UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
/** focus ring ร่วมของทุกลิงก์ในหน้า — Genesis .focus-ring (outline น้ำเงินทึบ 2px
* + offset ตาม spec Elevation); จอสัมผัสได้ 44px อัตโนมัติจากกฎ
* `main.summary-page a`/`button` ใน globals.css (ห้ามถอด class summary-page ของ <main>) */
const RING_CLS = "focus-ring";
/** ชื่อย่อต่อชนิดสำหรับบรรทัดสรุปของแต่ละหน้า (ชื่อเต็มอยู่ที่ 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 ร่วมของทุกลิงก์ในหน้า — จอสัมผัสได้ 44px อัตโนมัติจากกฎ
* `main.summary-page a`/`button` ใน globals.css (ห้ามถอด class summary-page
* ของ <main>) */
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";
@ -83,85 +141,328 @@ export default async function SummaryPage({
const bmDtos = bmRows.map(toBookmarkDTO);
const bookmarkById = new Map(bmDtos.map((b) => [b.id, b] as const));
// 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()),
);
// จัดกลุ่มตามหน้าแบบ 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]);
// จำนวนหน้าที่มีรายการ ≥1 สำหรับสถิติแถบหัว
const pageCount = new Set(items.map((it) => it.pageIndex)).size;
// ภาพรวมระดับไฟล์สำหรับรายการตามหน้า 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"));
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">
ชื่อรายงาน + ชื่อแฟ้ม + สถิติในที่เดียว แล้วปิดท้ายด้วยเส้นคั่นบาง */}
<header className="rounded-xl bg-primary px-5 py-4 text-white shadow-sm 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>
{items.length > 0 && (
{pages.length > 0 && (
<p className="mt-2 text-xs tabular-nums text-white/70">
Annotation {dtos.length} รายการ · Bookmark {bmDtos.length} รายการ ·{" "}
{pageCount} หน้า
{pages.length} หน้า
</p>
)}
</header>
{/* แถบ action — ghost button ขาวมีขอบ ภาษาเดียวกับการ์ดของหน้า (คำขอผู้ใช้
2026-09-17 แทนลิงก์ข้อความเปล่า — เห็นเป็นปุ่มชัดขึ้น; จอสัมผัสได้ 44px
จากกฎ coarse; ตอนพิมพ์ซ่อนทิ้ง — ปุ่มกลับไม่มีความหมายบนกระดาษ)
ปุ่มส่งออก CSV (F16) / Bookmark JSON (N7) ถูกถอด 2026-09-25 ตามคำขอผู้ใช้
— ระบบภายนอกดึงข้อมูลผ่าน N10 API แทนทั้งหมดแล้ว */}
<div className="mt-3 flex flex-wrap items-center gap-x-2 gap-y-1 print:hidden">
จากกฎ coarse; ตอนพิมพ์ซ่อนทิ้ง — ปุ่มกลับ/export ไม่มีความหมายบนกระดาษ) */}
<div className="mt-3 flex flex-wrap items-center justify-between gap-x-2 gap-y-1 print:hidden">
<Link
href={`/annotate/${id}`}
className={`inline-flex items-center gap-1.5 rounded-md border border-line bg-white px-3 py-1.5 text-sm text-ink hover:border-ink-faint hover:bg-ink/[0.03] ${RING_CLS}`}
className={`inline-flex items-center gap-1.5 rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-sm text-slate-700 shadow-sm hover:border-slate-300 hover:bg-slate-50 ${RING_CLS}`}
>
<ArrowLeft aria-hidden className="h-3.5 w-3.5" />
กลับหน้าอ่าน
</Link>
<div className="flex items-center gap-2">
{/* F16/D3: export สรุปเป็น CSV (UTF-8 BOM) */}
<a
href={withBasePath(`/api/annotations/export-csv?fileId=${id}`)}
className={`flex items-center gap-1.5 rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-sm text-slate-700 shadow-sm hover:border-slate-300 hover:bg-slate-50 ${RING_CLS}`}
>
<FileDown aria-hidden className="h-3.5 w-3.5" />
ส่งออกสรุป (CSV)
</a>
{/* N7: ส่งออก Bookmark ทั้งหมดเป็น JSON (label บอกขอบเขตเอง — คู่กับ
"ส่งออกสรุป (CSV)" ข้างบน ไม่ต้องพึ่ง title tooltip ที่จอสัมผัสใช้ไม่ได้) */}
<a
href={withBasePath(`/api/bookmarks/export?fileId=${id}`)}
className={`flex items-center gap-1.5 rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-sm text-slate-700 shadow-sm hover:border-slate-300 hover:bg-slate-50 ${RING_CLS}`}
>
<Braces aria-hidden className="h-3.5 w-3.5" />
ส่งออก Bookmark (JSON)
</a>
</div>
</div>
{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">
{pages.length === 0 ? (
<div className="mt-5 rounded-xl border border-dashed border-slate-300 bg-white/70 p-10 text-center">
<ClipboardList aria-hidden className="mx-auto h-8 w-8 text-slate-300" />
<p className="mt-3 text-sm font-medium text-slate-600">
ยังไม่มี Annotation หรือ Bookmark ในไฟล์นี้
</p>
<p className="mt-1 text-xs text-ink-soft">
<p className="mt-1 text-xs text-slate-500">
เปิดหน้าอ่านเพื่อมาร์กข้อความ วาดเส้น ใส่คอมเมนต์ หรือคั่นหน้า
</p>
<Link
href={`/annotate/${id}`}
className={`mt-4 inline-flex rounded-md bg-primary px-4 py-2 text-sm font-medium text-white transition hover:-translate-y-px hover:bg-primary-hover hover:shadow-btn-glow ${RING_CLS}`}
className={`mt-4 inline-flex rounded-md bg-primary px-4 py-2 text-sm font-medium text-white hover:opacity-90 ${RING_CLS}`}
>
เปิดหน้าอ่าน
</Link>
</div>
) : (
<SummaryExplorer fileId={id} items={items} />
<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-slate-200 bg-white shadow-sm 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-slate-800 hover:text-primary [&::-webkit-details-marker]:hidden">
รายการตามหน้าและผู้ร่วมทำ
<ChevronDown
aria-hidden
className="h-4 w-4 shrink-0 text-slate-400 transition-transform group-open:rotate-180 motion-reduce:transition-none"
/>
</summary>
<nav
aria-label="รายการตามหน้า"
className="flex flex-col gap-0.5 border-t border-slate-100 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-slate-600 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-slate-400"
/>
<span className="leading-5 tabular-nums text-slate-500">
{b.anns.length + b.bms.length}
</span>
</a>
))}
</nav>
<p className="border-t border-slate-100 px-4 py-3 text-xs leading-relaxed text-slate-600">
ผู้ร่วมทำ: {allParticipants.join(" · ")}
</p>
</details>
{/* รายการตามหน้า (จอ ≥ lg) — จุดไข่ปลาแบบสารบัญของเอกสารราชการ anchor ล้วนไม่มี JS
เป็นการ์ดชุดเดียวกับ <details> มือถือและการ์ดรายหน้า (แสดงเฉพาะหน้าที่มี
รายการ ≥ 1 ตามหลัก minimize choices; max-h กันเอกสารหลายสิบหน้าสูงเกินจอ
— ครึ่งล่างของ sticky กดไม่ได้ตลอดกาล) */}
<aside className="hidden rounded-xl border border-slate-200 bg-white p-4 shadow-sm lg:sticky lg:top-6 lg:block lg:max-h-[calc(100vh-3rem)] lg:overflow-y-auto">
<h2 className="text-sm font-semibold text-slate-800">รายการตามหน้า</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-slate-600 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-slate-400"
/>
<span className="leading-5 tabular-nums text-slate-500">
{b.anns.length + b.bms.length}
</span>
</a>
))}
</nav>
<h2 className="mt-6 border-t border-slate-300/60 pt-4 text-sm font-semibold text-slate-800">
ผู้ร่วมทำ
</h2>
<p className="mt-1.5 px-1 text-xs leading-relaxed text-slate-600">
{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-slate-200 bg-white shadow-sm sm:grid-cols-[3.75rem_1fr]"
>
{/* ขอบ folio — "หน้า" เล็ก + เลขใหญ่ชิดเส้นดิ่ง */}
<div className="border-r border-slate-100 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-slate-500">
หน้า
</span>
<span className="mt-1 block text-2xl font-semibold leading-none text-primary">
{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-slate-600">
{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-slate-100">
{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-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">
{bm.title}
</span>
<span className="mt-0.5 block truncate text-xs text-slate-500">
โดย {bm.userName || "ไม่ทราบชื่อ"}
{parent ? ` · ใน: ${parent.title}` : ""}
</span>
{bm.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">
{bm.note}
</span>
</span>
)}
</span>
<ChevronRight
aria-hidden
className="mt-1.5 h-4 w-4 shrink-0 text-slate-300 group-hover:text-slate-400"
/>
</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-slate-100">
{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-slate-50 ${RING_CLS}`}
>
<AnnotationTypeIcon
type={a.type}
data={a.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(a)}
{a.type === "COMMENT" && a.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">
{annotationTypeLabel(a)} · โดย {a.userName}
</span>
{annotationNote(a) && (
<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(a)}
</span>
</span>
)}
</span>
<ChevronRight
aria-hidden
className="mt-1.5 h-4 w-4 shrink-0 text-slate-300 group-hover:text-slate-400"
/>
</Link>
</li>
))}
</ul>
</CollapsibleBlock>
</div>
)}
</div>
</section>
);
})}
</div>
</div>
)}
</main>
);

View file

@ -29,13 +29,13 @@ export default async function AnnotateEntryPage({
<main className="mx-auto max-w-2xl px-4 py-10 sm:px-8">
<div
role="alert"
className="rounded-lg border border-error/30 bg-error/10 p-4 text-sm text-error-ink"
className="rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-700"
>
เปิดเอกสารจากลิงก์ไม่สำเร็จ: {result.error}
</div>
<Link
href="/"
className="focus-ring mt-4 inline-block rounded-md bg-primary px-4 py-2 text-sm font-medium text-white transition hover:-translate-y-px hover:bg-primary-hover hover:shadow-btn-glow"
className="mt-4 inline-block rounded-md bg-primary px-4 py-2 text-sm font-medium text-white transition hover:opacity-90"
>
กลับหน้ารายการ
</Link>
@ -46,15 +46,15 @@ export default async function AnnotateEntryPage({
// ไม่มี fileUrl เลย — ไม่มีอะไรให้เปิด (route จริงของ viewer อยู่ที่ /annotate/[id])
return (
<main className="mx-auto max-w-2xl px-4 py-10 sm:px-8">
<div className="rounded-xl border border-line bg-white p-6 text-sm text-ink-soft">
ไม่ได้ระบุลิงก์เอกสาร — ใช้ <code className="rounded bg-ink/[0.05] px-1">
<div className="rounded-xl border border-slate-200 bg-white p-6 text-sm text-slate-600">
ไม่ได้ระบุลิงก์เอกสาร — ใช้ <code className="rounded bg-slate-100 px-1">
/annotate?fileUrl=…&fileId=…&typeId=…
</code>{" "}
หรือเลือกไฟล์จากหน้ารายการ
</div>
<Link
href="/"
className="focus-ring mt-4 inline-block rounded-md bg-primary px-4 py-2 text-sm font-medium text-white transition hover:-translate-y-px hover:bg-primary-hover hover:shadow-btn-glow"
className="mt-4 inline-block rounded-md bg-primary px-4 py-2 text-sm font-medium text-white transition hover:opacity-90"
>
กลับหน้ารายการ
</Link>

View file

@ -0,0 +1,55 @@
import { asc, eq } from "drizzle-orm";
import { db } from "@/db";
import { annotations, files, toAnnotationDTO } from "@/db/schema";
import { annotationNote, annotationPreviewText, annotationTypeLabel } from "@/utils/annotation-display";
import { csvField } from "@/utils/csv";
import { encodeRFC5987 } from "@/utils/pdf-export";
// Export สรุปหมายเหตุเป็น CSV (F16/D3) — ข้อมูลเดียวกับหน้าสรุป (F15)
// ขึ้นต้นด้วย UTF-8 BOM เพื่อให้ Excel ภาษาไทยอ่านถูกต้อง (RFC 4180: ครอบ field
// ด้วย double quote, ขึ้นบรรทัดใหม่ด้วย CRLF)
const UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export async function GET(req: Request) {
const fileId = new URL(req.url).searchParams.get("fileId") ?? "";
if (!UUID_RE.test(fileId)) {
return new Response("fileId ไม่ถูกต้อง", { status: 400 });
}
const [file] = await db.select().from(files).where(eq(files.id, fileId));
if (!file) return new Response("ไม่พบไฟล์นี้", { status: 404 });
const rows = await db
.select()
.from(annotations)
.where(eq(annotations.fileId, fileId))
.orderBy(asc(annotations.pageIndex), asc(annotations.createdAt));
const header = ["หน้า", "ชนิด", "ผู้เขียน", "ข้อความ", "โน้ต", "สร้างเมื่อ"];
const lines = rows.map((row) => {
const a = toAnnotationDTO(row);
return [
String(a.pageIndex + 1),
annotationTypeLabel(a),
a.userName,
annotationPreviewText(a),
annotationNote(a),
new Date(a.createdAt).toLocaleString("th-TH", { timeZone: "Asia/Bangkok" }),
]
.map(csvField)
.join(",");
});
//  = UTF-8 BOM — ต้องเป็นตัวแรกสุดของไฟล์เสมอ
const csv = `${[header.map(csvField).join(","), ...lines].join("\r\n")}\r\n`;
const filename = encodeRFC5987(`${file.title}-summary.csv`);
return new Response(csv, {
headers: {
"content-type": "text/csv; charset=utf-8",
"content-disposition": `attachment; filename="summary.csv"; filename*=UTF-8''${filename}`,
},
});
}

View file

@ -0,0 +1,52 @@
import { asc, eq } from "drizzle-orm";
import { db } from "@/db";
import { bookmarks, files, toBookmarkDTO } from "@/db/schema";
import { encodeRFC5987 } from "@/utils/pdf-export";
// ส่งออกรายการ bookmark เป็น JSON (N7 — เดิมเป็นรายงาน PDF ตาม requirement 5.3.15
// เปลี่ยนตามคำขอผู้ใช้ 2026-09-17)
// - ข้อมูลล้วนจาก DB ไม่โหลดไฟล์เอกสารต้นฉบับเลย (invariant #1)
// - array flat เก็บ parentId ครบ (lossless) เรียงตามลำดับ N5 เหมือน sidebar
// ต้นไม้ reconstruct จาก parentId ได้เอง จึงไม่ต้อง build tree/cycle guard
// - read-only: ไม่ publish event ไม่ revalidatePath (ไม่เขียน DB)
const UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export async function GET(req: Request) {
const fileId = new URL(req.url).searchParams.get("fileId") ?? "";
if (!UUID_RE.test(fileId)) {
return new Response("fileId ไม่ถูกต้อง", { status: 400 });
}
const [file] = await db.select().from(files).where(eq(files.id, fileId));
if (!file) return new Response("ไม่พบไฟล์นี้", { status: 404 });
const rows = await db
.select()
.from(bookmarks)
.where(eq(bookmarks.fileId, fileId))
.orderBy(
asc(bookmarks.pageIndex),
asc(bookmarks.yOffset),
asc(bookmarks.createdAt),
);
// item เป็น BookmarkDTO ตรง ๆ (camelCase + ISO string — invariant #5)
const payload = {
fileId: file.id,
title: file.title,
exportedAt: new Date().toISOString(),
bookmarks: rows.map(toBookmarkDTO),
};
const filename = encodeRFC5987(`${file.title}-bookmarks.json`);
return new Response(JSON.stringify(payload, null, 2) + "\n", {
headers: {
"content-type": "application/json; charset=utf-8",
// filename เป็น ASCII fallback + filename* UTF-8 สำหรับชื่อไทย (pattern F8/F16)
"content-disposition": `attachment; filename="bookmarks.json"; filename*=UTF-8''${filename}`,
},
});
}

View file

@ -1,98 +0,0 @@
import { and, asc, eq } from "drizzle-orm";
import { db } from "@/db";
import { files } from "@/db/schema";
import { externalApiError, requireExternalApiKey } from "@/lib/external-api-auth";
import { loadDocumentBundles, parseQueryBody } from "@/lib/external-api";
// N10 (PLAN-API.md D2) — POST /api/external/documents
// (เดิมชื่อ /api/external/query ถึงรอบสาม — เปลี่ยนชื่อตาม resource ที่คืน 2026-09-25)
// เส้นเดียวถามด้วย JSON body { fileId และ/หรือ typeId, filter } — response
// รูปเดียวเสมอ { documents: [bundle…] } (bundle ยุบชั้น — field เอกสารอยู่
// ระดับเดียวกับ annotations/bookmarks): ถาม fileId = ยาว 1 ชิ้น (ไม่เคยเปิด
// ในระบบ = 404 — ไฟล์ url: fallback ของ N8 ค้นด้วย ecourt: prefix ไม่ได้ =
// 404 ตามสัญญาลิงก์), ถาม typeId = ทุกไฟล์ในกลุ่ม N9 (กลุ่มว่าง = documents: []),
// ส่งทั้งคู่ = AND เหลือเฉพาะเอกสารเดียวที่ตรงทั้งสองเงื่อนไข (2026-09-25 —
// รอบเดียวกันเคยเป็น OR รวมผลแล้วเปลี่ยนหลังทดสอบจริง: ผู้เรียกต้องการเอกสาร
// เดียวตามที่ส่งมา ไม่ใช่ทั้งกลุ่ม)
// read-only: ไม่ publish SSE ไม่ revalidatePath — snapshot ณ ตอนเรียก
const JSON_HEADERS = { "content-type": "application/json; charset=utf-8" };
/** origin เต็มของ request นี้ (ใช้ประกอบ url เต็มใน response — คำขอผู้ใช้
* 2026-09-25): เชื่อ header ของ reverse proxy ก่อน (x-forwarded-proto /
* x-forwarded-host — proxy หลายชั้นส่งเป็น comma list ใช้ค่าแรก) แล้วค่อย
* fallback จาก host/protocol ของ request ตรง ๆ */
function requestOrigin(req: Request): string {
const url = new URL(req.url);
const proto =
req.headers.get("x-forwarded-proto")?.split(",")[0]?.trim() ??
url.protocol.replace(":", "");
const host =
req.headers.get("x-forwarded-host")?.split(",")[0]?.trim() ??
req.headers.get("host") ??
url.host;
return `${proto}://${host}`;
}
export async function POST(req: Request) {
const unauthorized = requireExternalApiKey(req);
if (unauthorized) return unauthorized;
let body: unknown;
try {
body = await req.json();
} catch {
return externalApiError("body ต้องเป็น JSON ที่ถูกต้อง", 400);
}
const query = parseQueryBody(body);
if (!query.ok) return externalApiError(query.error, 400);
const origin = requestOrigin(req);
// ครอบทุก DB call (code review 2026-09-25) — ล้มไม่ว่าเหตุใด (connection
// หลุด/timeout/table มีปัญหา) ต้องได้ 500 JSON ตาม contract เดียวกับ error
// อื่นของ endpoint นี้ ไม่ใช่ 500 ของ framework ที่ format ต่างออกไป; log
// รายละเอียดฝั่ง server อย่างเดียว — ข้อความตอบกลาง ๆ กัน leak SQL/path
try {
if (query.mode === "file") {
// ส่งทั้งคู่ = AND กรองทั้ง externalId และกลุ่มใน query เดียว เหลือเอกสารเดียว
// (external_id unique — match ได้มากสุด 1 ฉบับ) ไม่ตรง = 404 โดยแยกสาเหตุ:
// ไม่เคยเปิดในระบบ หรืออยู่คนละกลุ่มกับ typeId ที่ระบุ
const [file] = await db
.select()
.from(files)
.where(
query.typeId
? and(
eq(files.externalId, `ecourt:${query.fileId}`),
eq(files.typeId, query.typeId),
)
: eq(files.externalId, `ecourt:${query.fileId}`),
)
.limit(1);
if (!file) {
return externalApiError(
query.typeId
? "ไม่พบเอกสารนี้ในระบบ (ยังไม่เคยเปิด หรือไม่ได้อยู่ในกลุ่ม typeId ที่ระบุ)"
: "ไม่พบเอกสารนี้ในระบบ (ยังไม่เคยเปิด)",
404,
);
}
const documents = await loadDocumentBundles([file], query.parts, origin);
return new Response(JSON.stringify({ documents }), { headers: JSON_HEADERS });
}
const fileRows = await db
.select()
.from(files)
.where(eq(files.typeId, query.typeId))
.orderBy(asc(files.createdAt));
const documents = await loadDocumentBundles(fileRows, query.parts, origin);
return new Response(JSON.stringify({ documents }), { headers: JSON_HEADERS });
} catch (err) {
console.error("external/documents: query ฐานข้อมูลล้ม", err);
return externalApiError("เกิดข้อผิดพลาดของระบบ โปรดลองใหม่อีกครั้ง", 500);
}
}

Binary file not shown.

View file

@ -2,22 +2,6 @@
@tailwind components;
@tailwind utilities;
/* ─── Genesis utilities (docs/DESIGN.md 2026-09-23) ────────────────────────
focus indicator = outline น้ำเงินทึบ 2px + offset 2px ของ design system — แยกเป็น
class ร่วมเพื่อใช้ซ้ำทุกปุ่ม/ลิงก์; input ใช้ .genesis-input (ขอบน้ำเงิน + outline ตอน
:focus ตาม spec Inputs) — ครอบด้วย @layer components ให้ Tailwind utilities ทับได้
(UX review M3 2026-09-23: halo alpha 0.12 ของ spec เดิม blend บนขาวแล้ว ~1.2:1
ต่ำกว่าเกณฑ์ 3:1 ของ WCAG 1.4.11 — alpha 0.25–0.35 ก็ยังไม่ผ่าน จึงเปลี่ยนเป็น
outline ทึบเป็นตัวชี้วัด + offset คั่นไม่ให้จมกับพื้นปุ่ม primary) */
@layer components {
.focus-ring {
@apply focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary;
}
.genesis-input {
@apply rounded-md border border-line bg-white px-3.5 py-2 text-sm text-ink placeholder:text-ink-faint focus:border-primary focus:outline focus:outline-2 focus:outline-offset-1 focus:outline-primary;
}
}
/* ─── TextLayer ของ pdfjs v4 ─────────────────────────────────────────────────
คัดมาจาก node_modules/pdfjs-dist/web/pdf_viewer.css (ส่วนที่จำเป็น)
ใช้กับ pdfjs.TextLayer ที่ render ลง div.textLayer ใน PDFViewer */
@ -152,15 +136,10 @@
inset: -4px;
}
/* ปุ่ม bookmark/ลบในแผง selection (20×20) + swatch สีในแผง (16×16) —
sel-panel-btn ใส่ centering แบบ .coarse-36 กันผู้ใช้ class ไม่ได้เอา flex
มาเอง (ไอคอนลอยชิดบนในกล่อง 36px); ปุ่มที่ใช้อยู่มี flex แล้ว ผลลัพธ์ไม่เปลี่ยน */
/* ปุ่ม bookmark/ลบในแผง selection (20×20) + swatch สีในแผง (16×16) */
.sel-panel-btn {
min-height: 2.25rem;
min-width: 2.25rem;
display: inline-flex;
align-items: center;
justify-content: center;
}
.sel-panel .color-swatches button,
.sel-panel .color-swatches label {
@ -241,66 +220,12 @@
align-items: center;
}
/* ปุ่มที่ไม่ใช่ <a> (แถวตัวกรองในแผง explorer ของหน้าสรุป, ปุ่มล้างตัวกรอง)
หลุดจากกฎข้างบน — ใส่แค่ min-height (ปุ่มเป็น flex w-full text-left อยู่แล้ว
ไม่ต้อง display/justify แบบกฎ <a>; เดิมเป็นปุ่มหุบ/กางของ CollapsibleBlock
design review 2026-09-17 H1 ก่อนหน้าถูกแทนด้วยตัวกรอง explorer 2026-09-24) */
/* design review 2026-09-17 H1: ปุ่มหุบ/กางของ CollapsibleBlock เป็น <button>
ไม่ใช่ <a> จึงหลุดจากกฎข้างบน (สูงจริง ~29px) — ปุ่มเป็น flex w-full
text-left อยู่แล้ว ใส่แค่ min-height (ไม่ต้อง display/justify แบบกฎ <a>) */
main.summary-page button {
min-height: 2.75rem;
}
/* UX review M5 2026-09-23: ช่องกรอกทุกชนิด ≥16px บนจอสัมผัส — iOS Safari ซูมทั้ง
หน้าทุกครั้งที่แตะโฟกัสช่องที่ตัวอักษรเล็กกว่า 16px (ทีมตั้งกติกาเดียวกันไว้
หน้า login แล้ว — ขยายครบทุกช่องของแอปเฉพาะอุปกรณ์จอสัมผัส; !important จำเป็น
เพราะช่องส่วนใหญ่กำหนดขนาดด้วย text-xs/text-[11px] ที่ specificity สูงกว่า)
responsive audit 2026-09-24: ครอบ contentEditable ของ TEXTBOX editor ด้วย —
fontSize default 2.5% ≈ 15px ทำ Safari ซูม viewport ตอนแตะเข้าโหมดแก้ไข
(ตัวอักษรขยายเป็น 16px เฉพาะช่วงพิมพ์ ต่างจากผลลัพธ์ ~7% — trade-off มาตรฐาน
ของ editor บน iOS เพราะ maximum-scale กัน focus-zoom ไม่ได้) */
input,
textarea,
select,
[contenteditable]:not([contenteditable="false"]) {
font-size: 1rem !important;
}
}
/* UX review M7 2026-09-23: ปุ่มหลักของหน้ารายการ/กลุ่ม (Annotate h-8, ลิงก์กลุ่ม,
ออกจากระบบ) สูง 32px ต่ำกว่ามาตรฐาน touch 44px ที่ viewer/หน้าสรุปได้แล้ว —
หน้าที่ผู้ใช้ iPad แตะบ่อยที่สุดก่อนเปิดเอกสารต้องได้เป้าเท่ากัน (ครอบ a/button
ของ <main class="listing-page"> + header ของ AppNav; select/summary เป็น
native control/มี flex อยู่แล้ว ใส่แค่ min-height)
ครอบด้วย @layer components ให้ Tailwind utilities (flex/items-*) ทับได้ —
กฎ unlayered ชนะทุก layer จนบังคับ align-items:center ทับ items-start ของ
แถวลิงก์ในการ์ดกลุ่ม กลายเป็นไอคอนลอยกลางแถว 2–3 บรรทัด (code review 2026-09-23)
+ justify-content:center คืนค่า default ของปุ่ม (text-align:center ของ UA
หมดผลเมื่อเป็น flex) ไม่งั้นปุ่ม w-full เช่น "เปิดเอกสาร"/อัปโหลด ข้อความชิดซ้าย
(คำขอผู้ใช้ 2026-09-23)
responsive audit 2026-09-24: @layer ต้องครอบ @media จากด้านนอกเท่านั้น — ซ้อนกลับกัน
(@media { @layer {…} }) แล้ว Tailwind v3 ยกเนื้อหา @layer ออกไปวางที่ components
section โดยทิ้ง media wrapper กฎจึงหลุดไป apply บน desktop ด้วย (ยืนยันด้วยการ
build จริง: ปุ่มหน้ารายการได้ min-height 44px + flex บนจอ desktop) */
@layer components {
@media (pointer: coarse) {
main.listing-page a,
main.listing-page button {
min-height: 2.75rem;
display: inline-flex;
align-items: center;
justify-content: center;
}
main.listing-page select,
main.listing-page summary {
min-height: 2.75rem;
}
header.appnav a,
header.appnav button {
min-height: 2.75rem;
display: inline-flex;
align-items: center;
justify-content: center;
}
}
}
/* ─── Print (หน้าสรุป) — design review 2026-09-17 L4 ───────────────────────
@ -309,10 +234,7 @@
แปลงเป็นกรอบเส้นน้ำเงิน + ตัวอักษรน้ำเงินแทน (อ่านได้บนเครื่องพิมพ์ขาวดำ
ประหยัดหมึก) และไม่ผ่าหน้ากระดาษกลาง section ของหน้าเอกสาร (หน้าที่ยาวเกิน
กระดาษเดียว browser เลื่อนทั้งก้อนขึ้นหน้าถัดไปเอง — break-inside เป็นคำ
แนะนำ ไม่ใช่กฎเหล็ก) / แถบ action กับแผงตัวกรอง (ทั้ง <details> มือถือและ
aside desktop) ซ่อนด้วย print:hidden ใน JSX — รายการบนกระดาษเป็นฉบับเต็ม
เสมอไม่สนตัวกรองที่ตั้งค้างไว้ (redesign explorer 2026-09-24: SummaryList
render ชุดเต็มแยกไว้เป็น hidden print:block) */
แนะนำ ไม่ใช่กฎเหล็ก) / แถบ action กับรายการตามหน้ามือถือซ่อนด้วย print:hidden ใน JSX */
@media print {
main.summary-page header {
background: #fff;
@ -325,28 +247,18 @@
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) →
Genesis 2026-09-23: DM Sans (next/font ตัวแปร Latin) นำหน้า stack ให้ตัว
Latin/ตัวเลขได้บุคลิก body ของ design system ก่อน จากนั้น webfont Sarabun
(next/font — มี adjusted fallback ของ next/font ในตัว) รับ glyph ไทยโดยเฉพาะ
แสดงผลเหมือนกันทุกอุปกรณ์; ที่เหลือเป็น fallback ตอนฟอนต์ยังไม่ถึง
(display:swap)/โหลดไม่ได้ — ไทยได้ Leelawadee UI (Windows) / Thonburi (iPad)
/ Noto Sans Thai (Android/Linux) / heading ใช้คลาส font-display ที่นำ
General Sans มาก่อน Sarabun ด้วยหลักการเดียวกัน */
/* UX review 2026-09-14 H2 → typography audit 2026-09-16 (T1 + Option B): webfont
Sarabun (next/font จาก layout.tsx — variable มี adjusted fallback ของ next/font
ในตัวแล้ว) นำหน้า stack เพื่อแสดงผลเหมือนกันทุกอุปกรณ์; ที่เหลือเป็น fallback
ตอนฟอนต์ยังไม่ถึง (display:swap)/โหลดไม่ได้ — เรียงแบบ Latin-first ตาม T1:
Latin/ตัวเลขได้ UI font ของ OS (Segoe UI/SF) ส่วนไทยได้ Leelawadee UI
(Windows) / Thonburi (iPad) / Noto Sans Thai (Android/Linux) */
body {
font-family: var(--font-dm-sans, "DM Sans"), var(--font-sarabun, "Sarabun"),
-apple-system, "Segoe UI", "Helvetica Neue", Arial, "Leelawadee UI",
"Thonburi", "Noto Sans Thai", sans-serif;
font-family: var(--font-sarabun, "Sarabun"), -apple-system, "Segoe UI",
"Helvetica Neue", Arial, "Leelawadee UI", "Thonburi", "Noto Sans Thai",
sans-serif;
}
/* M6: touch hygiene — ตัดแถบเทากระพริบของ iOS ทุกครั้งที่แตะปุ่ม และกัน
@ -394,13 +306,12 @@ html {
}
/* เส้นคั่นบางระหว่างแถวตั้งค่า (PanelRow) ที่ต่อกันในเมนู tool/แผง selection —
ระยะ/เส้นเท่ากับแทรก <div class="my-1.5 border-t border-line"> ไว้ระหว่างแถว
ทุกจุด (แถวแรกของกลุ่มไม่มีเส้น — ไม่ซ้ำกับ divider ที่เมนูใส่ไว้เองแล้ว)
(#E8E8EC = token line — เดิม #f1f5f9 เป็น slate-100 หลงเหลือก่อน Genesis) */
ระยะ/เส้นเท่ากับแทรก <div class="my-1.5 border-t border-slate-100"> ไว้ระหว่างแถว
ทุกจุด (แถวแรกของกลุ่มไม่มีเส้น — ไม่ซ้ำกับ divider ที่เมนูใส่ไว้เองแล้ว) */
.panel-row + .panel-row {
margin-top: 0.375rem;
padding-top: 0.625rem;
border-top: 1px solid #e8e8ec;
border-top: 1px solid #f1f5f9;
}
/* M6: safe-area — defensive (ค่าเป็น 0 จนกว่าจะใส่ viewport-fit=cover ใน meta
@ -414,9 +325,7 @@ header {
/* UX audit 2026-09-16 L7: เคารพ prefers-reduced-motion — ตัด transition ของ
sidebar, animation กระพริบของปุ่ม export และ smooth scroll ทุกจุด
(scrollToBelowHeader ฝั่ง JS เช็ค media query เดียวกันตอนเลือก behavior)
UX review m7 2026-09-23: เพิ่ม blanket rule ครอบ transition/animation ที่เหลือ
(hover-lift ของการ์ด, scale ของสวอตช์สี ฯลฯ) — ยก motion ทั้งหน้าให้จบทันที */
(scrollToBelowHeader ฝั่ง JS เช็ค media query เดียวกันตอนเลือก behavior) */
@media (prefers-reduced-motion: reduce) {
aside[data-sidebar] {
transition: none;
@ -424,12 +333,7 @@ header {
.animate-pulse {
animation: none;
}
*,
::before,
::after {
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
* {
scroll-behavior: auto !important;
}
}

View file

@ -2,13 +2,17 @@ import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { asc, eq, inArray } from "drizzle-orm";
import { ArrowLeft, Folder } from "lucide-react";
import {
GroupExplorer,
type GroupFileSummary,
type GroupListItem,
} from "@/components/groups/GroupExplorer";
ArrowLeft,
Bookmark,
ChevronDown,
FileImage,
FileText,
Folder,
StickyNote,
} from "lucide-react";
import { AnnotationTypeIcon } from "@/components/AnnotationTypeIcon";
import { db } from "@/db";
import {
annotations,
@ -19,20 +23,40 @@ 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";
// N9 (PLAN-DOCSET.md D3) — หน้ารวมเอกสารของกลุ่ม (type_id จากลิงก์ e-Court)
// server component force-dynamic — login ถูกบังคับโดย middleware.ts อยู่แล้ว
// snapshot ณ ตอนโหลดเหมือนหน้าสรุป F15 (ไม่มี SSE) — กลุ่มไม่มีไฟล์เลย = 404
// เพราะไม่มีตารางกลุ่ม: กลุ่มมีชีวิตตามข้อมูลจริงใน files ณ ตอน query
//
// redesign 2026-09-24 ตามคำขอผู้ใช้: ไม่มี navbar — explorer 2 คอลัมน์ ตัวกรอง
// ฝั่งซ้าย + list เดียวรวม bookmark+annotation ทั้งกลุ่มไม่แยกไฟล์ (เคยเป็นการ์ด
// ต่อไฟล์ + <details>) — query ทั้งหมดทำที่นี่แล้วส่งข้อมูลพร้อมใช้ให้ GroupExplorer
// กรองใน memory ฝั่ง client
// การ์ดแต่ละไฟล์หุบ/กางดู 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";
export async function generateMetadata({
@ -57,7 +81,8 @@ export default async function GroupPage({
.orderBy(asc(files.createdAt));
if (rows.length === 0) notFound();
// bookmark+annotation ทั้งกลุ่ม 2 query แยก (ไม่ join หนัก) แล้วจัดตามไฟล์ใน JS
// bookmark+annotation ทั้งกลุ่ม 2 query แยก (ไม่ join หนัก) แล้วจัดตามไฟล์ใน JS —
// ได้ทั้งจำนวนสำหรับบรรทัด meta และรายการเต็มสำหรับกางดูในการ์ด
const ids = rows.map((f) => f.id);
const [annRows, bmRows] = await Promise.all([
db
@ -92,70 +117,167 @@ 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 (
// 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">
<main className="mx-auto max-w-2xl px-4 py-10 sm: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"
className="inline-flex items-center gap-1.5 text-sm text-slate-500 transition hover:text-primary"
>
<ArrowLeft aria-hidden className="h-4 w-4" />
<ArrowLeft 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 className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-primary/10">
<Folder className="h-5 w-5 text-primary" />
</div>
<div className="min-w-0">
<h1 className="font-display break-all text-section-title text-ink">
<h1 className="break-all text-xl font-bold leading-tight text-primary">
{typeId}
</h1>
<p className="mt-0.5 text-sm text-ink-soft">
{rows.length} เอกสาร · {items.length} รายการ
</p>
<p className="text-sm text-slate-500">{rows.length} เอกสาร</p>
</div>
</header>
{/* หมายเหตุ "แสดงเฉพาะเอกสารที่เคยเปิดในระบบนี้" (แผน N9 D3 เดิม) ถูกถอดออก
ตามคำขอผู้ใช้ 2026-09-24 — ระบบรู้จักเฉพาะเอกสารที่เคยเปิดผ่านลิงก์ e-Court
{/* หมายเหตุแสดงทุกครั้ง (แผน 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>
<GroupExplorer files={fileSummaries} items={items} />
<ul className="mt-6 space-y-3">
{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"
>
<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 &amp; 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>
);
}

View file

@ -1,39 +1,17 @@
import type { Metadata } from "next";
import { DM_Sans, Sarabun } from "next/font/google";
import localFont from "next/font/local";
import { Sarabun } from "next/font/google";
import "./globals.css";
/* typography audit 2026-09-16 (Option B): Sarabun — ฟอนต์ตระกูลมาตรฐานเอกสาร
* ราชการไทย (TH Sarabun) เจ้าหน้าที่คุ้นเคยที่สุด / next/font self-host ตอน build:
* ไฟล์ฟอนต์ serve จาก origin เดียวกับแอป ไม่พึ่ง Google ตอนรัน จึงใช้ใน intranet
* ได้ (แต่เครื่อง build ต้องเห็นอินเทอร์เน็ต)
*
* Genesis (docs/DESIGN.md 2026-09-23): เพิ่ม General Sans (display/heading) จาก
* Fontshare เป็น variable woff2 ไฟล์เดียว self-host ผ่าน next/font/local + DM Sans
* (body) จาก Google — ทั้งคู่เป็น Latin เท่านั้น ไม่มี glyph ไทย จึงวาง Sarabun
* ต่อท้ายใน stack (globals.css / tailwind fontFamily): ตัว Latin/ตัวเลขได้บุคลิก
* Genesis ส่วนข้อความไทยคง Sarabun เดิม — Sarabun เพิ่ม weight 700 ให้ heading
* ไทย (fallback ของ General Sans bold) */
const generalSans = localFont({
src: "./fonts/GeneralSans-Variable.woff2",
// ประกาศช่วง wght จริงของ variable font (Fontshare: 200–700) — ไม่ใส่แล้ว
// @font-face ที่ next/font generate ไม่มี descriptor font-weight → cover แค่ 400
// และ font-bold/700 กลายเป็น synthetic bold จาก instance 400 (UX review C1 2026-09-23)
weight: "200 700",
display: "swap",
variable: "--font-general-sans",
});
const dmSans = DM_Sans({
subsets: ["latin"],
display: "swap",
variable: "--font-dm-sans",
});
/** typography audit 2026-09-16 (Option B): Sarabun — ฟอนต์ตระกูลมาตรฐานเอกสาร
* ราชการไทย (TH Sarabun) เจ้าหน้าที่คุ้นเคยที่สุด / next/font self-host ตอน build:
* ไฟล์ฟอนต์ serve จาก origin เดียวกับแอป ไม่พึ่ง Google ตอนรัน จึงใช้ใน intranet
* ได้ (แต่เครื่อง build ต้องเห็นอินเทอร์เน็ต) — โหลดเฉพาะ 2 weights ตามแผน
* ~100-160KB แลกกับการแสดงผลเหมือนกันทุกอุปกรณ์ รวมกล่อง TEXTBOX ที่ผูก
* stack นี้ (T1-C: wrap ข้อความตรงกันข้ามอุปกรณ์จริง) */
const sarabun = Sarabun({
subsets: ["thai", "latin"],
weight: ["400", "600", "700"],
weight: ["400", "600"],
display: "swap",
variable: "--font-sarabun",
});
@ -49,11 +27,8 @@ export default function RootLayout({
children: React.ReactNode;
}) {
return (
<html
lang="th"
className={`${generalSans.variable} ${dmSans.variable} ${sarabun.variable}`}
>
<body className="bg-canvas font-body text-ink antialiased">{children}</body>
<html lang="th" className={sarabun.variable}>
<body className="bg-slate-100 text-slate-900 antialiased">{children}</body>
</html>
);
}

View file

@ -1,7 +1,6 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import DotGrid from "@/components/DotGrid";
import LoginForm from "@/components/LoginForm";
import { getSessionUser } from "@/lib/session";
import { firstSearchParam, sanitizeNextPath } from "@/utils/external-file";
@ -25,10 +24,7 @@ export default function LoginPage({
if (user) redirect(next ?? "/");
return (
// Genesis: กริดจุดอันตรกิริยา ( decorative element ชิ้นเดียวที่อนุญาต) เป็นพื้นหลัง
// หน้า login — DotGrid อยู่ก่อนการ์ดใน DOM จึงถูกทับโดยการ์ดเอง ไม่ต้องเล่น z-index
<main className="relative flex min-h-dvh items-center justify-center px-4 py-10">
<DotGrid />
<main className="flex min-h-dvh items-center justify-center px-4 py-10">
<LoginForm next={next} />
</main>
);

View file

@ -5,8 +5,8 @@ import { FileImage, FileText, Folder } from "lucide-react";
import { db } from "@/db";
import { files } from "@/db/schema";
import AppNav from "@/components/AppNav";
import EcourtLinkForm from "@/components/EcourtLinkForm";
import SessionBar from "@/components/SessionBar";
import UploadFileForm from "@/components/UploadFileForm";
import { imageFormatFromUrl } from "@/utils/file-kind";
import { formatThaiDate } from "@/utils/date-format";
@ -32,42 +32,35 @@ function kindBadge(pdfUrl: string): string {
}
}
/** การ์ดไฟล์ของหน้ารายการ (Genesis kit-card: surface 12px radius ขอบ line ไม่มีเงา
* static — hover ยก -2px + shadow-card-hover; tile ไอคอนเป็นกลาง น้ำเงินสงวนให้ของ
* interactive ตาม Do&Don't ของ design system) ใช้ร่วมทุก section (N9) */
/** การ์ดไฟล์ของหน้ารายการ — ใช้ร่วมทุก section (N9 จัดกลุ่มแล้วยังหน้าตาเดิม) */
function FileCard({ file }: { file: FileRow }) {
return (
<li className="flex flex-col rounded-xl border border-line bg-white p-5 transition duration-200 hover:-translate-y-0.5 hover:shadow-card-hover">
<div className="flex items-start justify-between gap-3">
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-lg bg-ink/[0.04]">
{imageFormatFromUrl(file.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="rounded bg-ink/[0.05] px-1.5 py-0.5 text-[11px] font-medium text-ink-soft">
{kindBadge(file.pdfUrl)}
<li 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(file.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">
{file.title}
</span>
<span className="mt-0.5 block text-xs text-slate-500">
<span className="mr-1.5 rounded bg-slate-100 px-1 py-0.5 font-medium text-slate-500">
{kindBadge(file.pdfUrl)}
</span>
{formatThaiDate(file.createdAt, true)}
</span>
</div>
<span className="mt-4 block break-all line-clamp-2 text-body font-medium text-ink">
{file.title}
</span>
{/* วันที่เป็นข้อมูลที่ผู้ใช้อ่านจริง — ink-soft (ผ่าน 4.5:1); ink-faint เก็บไว้
ให้ placeholder/disabled เท่านั้น (UX review M2 2026-09-23) */}
<span className="mt-1 block text-caption text-ink-soft">
{formatThaiDate(file.createdAt, true)}
</span>
{/* ปุ่มเข้า annotate แทนลิงก์กางเต็มการ์ด (M4) ตาม feedback — เป็น secondary
(หนึ่ง primary ต่อ section ตาม Genesis; การ์ดทั้งกริดคือ passive surface) */}
<div className="mt-5 flex flex-1 items-end justify-end">
<Link
href={`/annotate/${file.id}`}
className="focus-ring inline-flex h-8 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>
{/* ปุ่มเข้า annotate แทนลิงก์กางเต็มการ์ด (M4) ตาม feedback — เป้าจิ้ม 36px (H4) */}
<Link
href={`/annotate/${file.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>
</li>
);
}
@ -120,117 +113,103 @@ export default async function HomePage({
);
return (
<>
<AppNav userName={user.userName} />
{/* listing-page: hook ของกฎ coarse 44px ใน globals.css (อย่าถอด — เหมือน
summary-page ของหน้าสรุป) */}
<main className="listing-page mx-auto max-w-7xl px-4 pb-16 pt-10 sm:px-6">
{/* ชื่อระบบ Latin ล้วน → General Sans tracking แน่นตาม Genesis display type */}
<h1 className="font-display text-4xl tracking-tight text-ink sm:text-headline">
Judge&apos;s Note
</h1>
<p className="mt-2 text-body text-ink-soft">
ระบบ PDF Annotation สำหรับ e-Court
</p>
<main className="mx-auto max-w-2xl px-4 py-10 sm:px-8">
<header className="flex items-center gap-3">
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-primary text-base font-bold text-white shadow-sm">
JN
</div>
<div>
<h1 className="text-2xl font-bold leading-tight text-primary">
Judge&apos;s Note
</h1>
<p className="text-sm text-slate-500">
ระบบ PDF Annotation สำหรับ e-Court
</p>
</div>
</header>
{registerError && (
<div
role="alert"
className="mt-6 rounded-lg border border-error/25 bg-error/5 p-4 text-sm text-error-ink"
>
เปิดเอกสารจากลิงก์ไม่สำเร็จ: {registerError}
</div>
<SessionBar userName={user.userName} />
{registerError && (
<div
role="alert"
className="mt-6 rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-700"
>
เปิดเอกสารจากลิงก์ไม่สำเร็จ: {registerError}
</div>
)}
{/* การ์ดเปิดเอกสารจากลิงก์ e-Court — บล็อกแยกของตัวเอง มีหัวเรื่องในตัวแล้ว (N8) */}
<EcourtLinkForm />
<section className="mt-8">
<h2 className="text-sm font-semibold text-slate-700">เพิ่มเอกสาร</h2>
<UploadFileForm />
</section>
<div className="mt-8 flex items-baseline justify-between">
<h2 className="text-sm font-semibold text-slate-700">เอกสาร</h2>
{rows.length > 0 && (
<span className="text-xs tabular-nums text-slate-500">{rows.length} ไฟล์</span>
)}
</div>
{/* การ์ดจำลองลิงก์ e-Court (N8) อยู่เหนือรายการเอกสาร — เป็นทางเข้าเปิดเอกสาร
จากระบบ e-Court ไม่ใช่การเพิ่มไฟล์เข้ารายการ (คุมความกว้างเดียวกับ
ฟอร์มอัปโหลด) */}
<section className="mt-12">
<div className="border-b border-line pb-3">
<h2 className="font-display text-section-title text-ink">
เปิดเอกสารจากลิงก์ e-Court
</h2>
</div>
<div className="mt-5 max-w-xl">
<EcourtLinkForm />
</div>
</section>
<section className="mt-12">
<div className="flex items-baseline justify-between gap-3 border-b border-line pb-3">
<h2 className="font-display text-section-title text-ink">เอกสาร</h2>
{rows.length > 0 && (
<span className="text-caption tabular-nums text-ink-soft">
{rows.length} ไฟล์
</span>
)}
</div>
{/* อัปโหลดไฟล์ (N4) อยู่กับรายการเอกสาร — ไฟล์ที่อัปโหลดเข้ารายการนี้เอง
(คุมความกว้างไม่ให้ฟอร์มกินเต็มความกว้างของกริดรายการ) */}
<div className="mt-5 max-w-xl">
<UploadFileForm />
</div>
{rows.length === 0 ? (
<div className="mt-5 rounded-xl border border-dashed border-line bg-white/60 p-12 text-center">
<FileText aria-hidden className="mx-auto h-8 w-8 text-ink-faint" />
<p className="mt-3 text-sm font-medium text-ink-soft">
ยังไม่มีไฟล์ในระบบ
</p>
<p className="mt-1 text-xs text-ink-soft">
รัน /sync-db เพื่อ seed ไฟล์ตัวอย่าง
</p>
</div>
) : (
<>
{sections.map(([typeId, list]) => (
<section key={typeId} className="mt-8">
<div className="flex flex-wrap items-center gap-x-2.5 gap-y-1">
<Folder
aria-hidden
className="h-4 w-4 shrink-0 text-ink-soft"
/>
<h3 className="font-display min-w-0 break-all text-subhead text-ink">
{typeId}
</h3>
<span className="text-caption tabular-nums text-ink-soft">
{list.length} ไฟล์
</span>
{/* Next Link เติม basePath ให้เอง (invariant #12) */}
<Link
href={`/groups/${encodeURIComponent(typeId)}`}
className="focus-ring ml-auto shrink-0 rounded text-sm font-medium text-primary hover:underline"
>
เอกสารในกลุ่ม
</Link>
</div>
<ul className="mt-4 grid gap-5 sm:grid-cols-2 xl:grid-cols-3">
{list.map((f) => (
<FileCard key={f.id} file={f} />
))}
</ul>
</section>
))}
{ungrouped.length > 0 && (
<section className="mt-8">
<div className="flex flex-wrap items-center gap-x-2.5 gap-y-1">
<h3 className="font-display text-subhead text-ink">ไม่มีกลุ่ม</h3>
<span className="text-caption tabular-nums text-ink-soft">
{ungrouped.length} ไฟล์
</span>
</div>
<ul className="mt-4 grid gap-5 sm:grid-cols-2 xl:grid-cols-3">
{ungrouped.map((f) => (
<FileCard key={f.id} file={f} />
))}
</ul>
</section>
)}
</>
{rows.length === 0 ? (
<div className="mt-3 rounded-xl border border-dashed border-slate-300 bg-white/60 p-10 text-center">
<FileText className="mx-auto h-8 w-8 text-slate-300" />
<p className="mt-3 text-sm font-medium text-slate-500">
ยังไม่มีไฟล์ในระบบ
</p>
<p className="mt-1 text-xs text-slate-500">
รัน /sync-db เพื่อ seed ไฟล์ตัวอย่าง
</p>
</div>
) : (
<>
{sections.map(([typeId, list]) => (
<section key={typeId} className="mt-6">
<div className="flex items-center gap-2 border-b border-slate-200 pb-2">
<Folder className="h-4 w-4 shrink-0 text-primary" />
<span className="min-w-0 break-all text-sm font-semibold text-slate-700">
{typeId}
</span>
<span className="shrink-0 text-xs tabular-nums text-slate-500">
{list.length} ไฟล์
</span>
{/* Next Link เติม basePath ให้เอง (invariant #12) */}
<Link
href={`/groups/${encodeURIComponent(typeId)}`}
className="ml-auto shrink-0 rounded text-xs font-medium text-primary hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1"
>
เอกสารในกลุ่ม
</Link>
</div>
<ul className="mt-3 space-y-3">
{list.map((f) => (
<FileCard key={f.id} file={f} />
))}
</ul>
</section>
))}
{ungrouped.length > 0 && (
<section className="mt-6">
<div className="flex items-center gap-2 border-b border-slate-200 pb-2">
<span className="text-sm font-semibold text-slate-700">
ไม่มีกลุ่ม
</span>
<span className="text-xs tabular-nums text-slate-500">
{ungrouped.length} ไฟล์
</span>
</div>
<ul className="mt-3 space-y-3">
{ungrouped.map((f) => (
<FileCard key={f.id} file={f} />
))}
</ul>
</section>
)}
</section>
</main>
</>
</>
)}
</main>
);
}

View file

@ -26,7 +26,7 @@ import type {
export function AnnotationTypeIcon({
type,
data,
className = "h-4 w-4 shrink-0 text-ink-soft",
className = "h-4 w-4 shrink-0 text-slate-500",
}: {
type: AnnotationDTO["type"];
data: AnnotationDTO["data"];

View file

@ -1,37 +0,0 @@
// Genesis (docs/DESIGN.md) Navigation: sticky แถบบน 56px + backdrop-blur + เส้นแบ่ง
// ล่าง 1px — โลโก้ซ้าย / ผู้ใช้ + ออกจากระบบ ขวา (แอปหน้าเดียวต่อ context จึงไม่มี
// ลิงก์กลางจอ) ใช้เฉพาะหน้ารายการ / — หน้ากลุ่ม /groups/[typeId] เลิกใช้ตาม
// redesign 2026-09-24 (ไม่มี navbar เหลือ back link) — แทน SessionBar เดิมที่แยก
// เป็นแถบของตัวเองกลางหน้า
import Link from "next/link";
import LogoutButton from "./LogoutButton";
export default function AppNav({ userName }: { userName: string }) {
return (
// appnav: hook ของกฎ coarse 44px ให้ปุ่มในแถบนี้ (globals.css — อย่าถอด)
<header className="appnav sticky top-0 z-40 h-14 border-b border-line bg-white/80 backdrop-blur">
<div className="mx-auto flex h-full max-w-7xl items-center justify-between gap-3 px-4 sm:px-6">
{/* ชื่อแบรนด์เป็น ink ตามกฎ "น้ำเงินเฉพาะของ interactive" — tile JN น้ำเงินคือ
แบรนด์มาร์ก (สืบต่อจากหน้าสรุป/login ดั้งเดิม) ทั้งแถบเป็นลิงก์กลับหน้ารายการ */}
<Link
href="/"
className="focus-ring flex min-w-0 items-center gap-2.5 rounded-md"
>
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary text-xs font-bold text-white">
JN
</span>
<span className="font-display truncate text-[15px] font-bold tracking-tight text-ink">
Judge&apos;s Note
</span>
</Link>
<div className="flex min-w-0 items-center gap-3">
<p className="hidden min-w-0 truncate text-sm text-ink-soft sm:block">
{userName}
</p>
<LogoutButton />
</div>
</div>
</header>
);
}

View file

@ -1,38 +0,0 @@
"use client";
// Genesis (docs/DESIGN.md) — กริดจุดอันตรกิริยา: ลวดลายตกแต่งชิ้นเดียวที่ design
// system อนุญาต ("the interactive dot grid is the only decorative element") ใช้เป็น
// พื้นหลังหน้า login — จุดเทาถี่ 24px + ไฮไลต์น้ำเงินจางตามตำแหน่ง pointer
// ⚠️ ต้องเป็น pointer-events-none เสมอ: ตัว layer เป็น absolute inset-0 ครอบพื้นที่
// ทั้งหน้า ส่วนการ์ด login เป็น static element — ตัว positioned จะชนะ hit-test ทับ
// การ์ดจนคลิก/focus ช่องกรอกไม่ได้ (bug report 2026-09-23) จึงฟัง pointermove ที่
// window แทนของตัวเอง — ไฮไลต์ยังตามเมาส์ได้แม้อยู่เหนือการ์ด + อัปเดตผ่าน CSS
// variable บน style ตรง ๆ ไม่ setState กัน re-render ทุก mousemove
// mask ไล่จางขอบให้กลืนกับพื้น canvas แทนการตัดเส้นชัด
import { useEffect, useRef } from "react";
export default function DotGrid() {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const onPointerMove = (e: PointerEvent) => {
const el = ref.current;
if (!el) return;
const rect = el.getBoundingClientRect();
el.style.setProperty("--dot-mx", `${e.clientX - rect.left}px`);
el.style.setProperty("--dot-my", `${e.clientY - rect.top}px`);
};
window.addEventListener("pointermove", onPointerMove, { passive: true });
return () => window.removeEventListener("pointermove", onPointerMove);
}, []);
return (
<div
ref={ref}
aria-hidden
className="pointer-events-none absolute inset-0 -z-10 [background-image:radial-gradient(circle,rgba(10,10,10,0.10)_1px,transparent_1px)] [background-size:24px_24px] [-webkit-mask-image:radial-gradient(72%_58%_at_50%_42%,black,transparent)] [mask-image:radial-gradient(72%_58%_at_50%_42%,black,transparent)]"
>
<div className="absolute inset-0 [background-image:radial-gradient(280px_circle_at_var(--dot-mx,50%)_var(--dot-my,36%),rgba(0,51,161,0.07),transparent_72%)]" />
</div>
);
}

View file

@ -6,15 +6,14 @@
// ลิงก์จริงจาก e-Court (รับทั้ง path ในระบบเช่น /samples/… และ URL ของโฮสต์ที่อนุญาต
// — ไม่มี scheme นำหน้าระบบเติม https:// ให้)
// แถบลิงก์เต็มข้างล่างแสดง URL จริงที่ปุ่มจะพาไป (absolute — copy ไปเปิด/ส่งต่อได้) ตามที่พิมพ์สด ๆ
// Genesis: การ์ด surface 12px radius ขอบ line ใน section "เปิดเอกสารจากลิงก์ e-Court"
// (แยกจากส่วนเอกสาร) / ปุ่มเป็น secondary ขอบฟ้า / input ผ่าน .genesis-input
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { Link2 } from "lucide-react";
import { withBasePath } from "@/utils/base-path";
const inputClass =
"genesis-input mt-1 w-full text-base transition aria-invalid:border-error";
"rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-700 placeholder:text-slate-400 focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20";
export default function EcourtLinkForm() {
const router = useRouter();
@ -51,12 +50,20 @@ export default function EcourtLinkForm() {
};
return (
<form onSubmit={onSubmit} className="flex flex-col rounded-xl border border-line bg-white p-5">
{/* ไม่มีหัวการ์ดซ้ำ — h2 "เปิดเอกสารจากลิงก์ e-Court" ของ section ในหน้ารายการ
เป็นหัวเดียวพอแล้ว (responsive audit 2026-09-24: เดิมซ้ำสองชั้นติดกัน) */}
<div className="grid gap-x-4 gap-y-3 sm:grid-cols-2">
<form
onSubmit={onSubmit}
className="mt-8 rounded-xl border border-dashed border-slate-300 bg-white/60 p-4"
>
<div className="flex items-center gap-2">
<Link2 className="h-4 w-4 shrink-0 text-primary" />
<p className="text-sm font-medium text-slate-700">เปิดเอกสารจากลิงก์ e-Court</p>
</div>
<div className="mt-3 grid gap-x-2 gap-y-3 sm:grid-cols-2">
<div className="sm:col-span-2">
<label htmlFor="ecourt-file-url" className="block text-sm font-medium text-ink">
<label
htmlFor="ecourt-file-url"
className="block text-xs font-medium text-slate-600"
>
ลิงก์ไฟล์
</label>
<input
@ -67,11 +74,14 @@ export default function EcourtLinkForm() {
value={fileUrl}
onChange={(e) => setFileUrl(e.target.value)}
placeholder="เช่น /samples/ชื่อไฟล์.pdf หรือ https://…"
className={inputClass}
className={`${inputClass} mt-1 w-full`}
/>
</div>
<div>
<label htmlFor="ecourt-file-id" className="block text-sm font-medium text-ink">
<label
htmlFor="ecourt-file-id"
className="block text-xs font-medium text-slate-600"
>
รหัสเอกสาร fileId
</label>
<input
@ -80,11 +90,14 @@ export default function EcourtLinkForm() {
name="fileId"
value={fileId}
onChange={(e) => setFileId(e.target.value)}
className={inputClass}
className={`${inputClass} mt-1 w-full`}
/>
</div>
<div>
<label htmlFor="ecourt-type-id" className="block text-sm font-medium text-ink">
<label
htmlFor="ecourt-type-id"
className="block text-xs font-medium text-slate-600"
>
กลุ่ม typeId
</label>
<input
@ -94,25 +107,25 @@ export default function EcourtLinkForm() {
value={typeId}
onChange={(e) => setTypeId(e.target.value)}
placeholder="เช่น 1"
className={inputClass}
className={`${inputClass} mt-1 w-full`}
/>
</div>
</div>
<button
type="submit"
className="focus-ring mt-4 w-full rounded-md border border-primary/30 px-4 py-2 text-sm font-medium text-primary transition hover:-translate-y-px hover:border-primary hover:bg-primary/5"
className="mt-3 w-full rounded-md bg-primary px-4 py-2.5 text-sm font-medium text-white transition hover:opacity-90"
>
เปิดเอกสาร
</button>
<p className="mt-3 text-xs leading-relaxed text-ink-soft">
<p className="mt-2 text-xs text-slate-500">
กดแล้วระบบลงทะเบียนเอกสาร (ถ้ายังไม่มี) แล้วพาเข้าหน้าอ่าน/มาร์กเอกสารทันที —
ไฟล์รีโมตรับเฉพาะ https ของโฮสต์ที่อนุญาต
</p>
<p className="mt-3 break-all rounded-lg bg-ink/[0.04] px-3 py-2.5 text-xs text-ink-soft">
<span className="font-medium text-ink">ลิงก์เต็ม: </span>
<p className="mt-2 break-all rounded-md bg-slate-50 px-3 py-2 text-xs text-slate-500">
<span className="font-medium text-slate-600">ลิงก์เต็ม: </span>
{fullLink || "…"}
</p>
{error && <p className="mt-2 text-xs text-error-ink">{error}</p>}
{error && <p className="mt-1 text-xs text-red-600">{error}</p>}
</form>
);
}

View file

@ -3,8 +3,6 @@
// ฟอร์ม login (mock e-Court) — เรียก server action login แล้ว router.replace(next ?? "/")
// (pattern ตาม UploadFileForm: useTransition + error state + ข้อความ fallback ตอน transport fail)
// next = path วนกลับหลัง login (N8 D5 — ผ่าน sanitizeNextPath จากหน้า login มาแล้ว)
// Genesis (docs/DESIGN.md): การ์ด surface 12px radius ขอบ line ไม่มีเงาระดับ static /
// input ผ่าน .genesis-input / ปุ่ม primary hover ขึ้น 1px + glow ฟ้า (shadow-btn-glow)
import { useRef, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
@ -13,12 +11,11 @@ import Select from "@/components/Select";
import { MOCK_USERS } from "@/utils/user";
// text input ของแอป — text-base (16px) เสมอ: กัน iOS auto-zoom ตอน focus บน iPad
// (ทับ text-sm ของ .genesis-input — utility อยู่หลัง @layer components ชนะเสมอ)
// error ตาม spec Inputs: ขอบเปลี่ยนเป็นแดง (ring ยังเป็นฟ้าตาม focus ring หลัก)
const INPUT_CLASS =
"genesis-input mt-1 block w-full text-base transition aria-invalid:border-error";
"mt-1 block w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-base text-slate-900 placeholder:text-slate-400 transition focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/30 disabled:cursor-not-allowed disabled:bg-slate-50 aria-invalid:border-red-600 aria-invalid:focus:ring-red-600/30";
// dropdown ผู้ใช้ — เหมือน INPUT_CLASS แต่ไม่มี mt-1 (Select ครอบ div.relative: มาร์จินที่ wrapper ข้างนอกแทน)
const SELECT_CLASS = "genesis-input block w-full text-base transition";
const SELECT_CLASS =
"block w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-base text-slate-900 transition focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/30 disabled:cursor-not-allowed disabled:bg-slate-50";
export default function LoginForm({ next }: { next: string | null }) {
const router = useRouter();
@ -51,29 +48,23 @@ export default function LoginForm({ next }: { next: string | null }) {
};
return (
<div className="w-full max-w-sm rounded-xl border border-line bg-white p-8">
{/* brand lockup เดียวกับ nav หน้ารายการ — tile JN น้ำเงินคือแบรนด์มาร์กของระบบ
(หน้าสรุปแถบน้ำเงิน 2026-09-17 สืบต่อจาก tile นี้) ชื่อแบรนด์เป็น ink ตามกฎ
"blue เฉพาะของ interactive" ของ Genesis */}
<div className="w-full max-w-sm rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
{/* brand lockup เดียวกับ header หน้ารายการ (ลดขนาดตัวอักษรให้พอการ์ดแคบ) */}
<div className="flex items-center gap-3">
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-lg bg-primary text-base font-bold text-white">
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-primary text-base font-bold text-white shadow-sm">
JN
</div>
<div className="min-w-0">
<p className="font-display text-lg font-bold leading-tight text-ink">
Judge&apos;s Note
</p>
<p className="mt-0.5 text-xs text-ink-soft">
ระบบ PDF Annotation สำหรับ e-Court
</p>
<div>
<p className="text-lg font-bold leading-tight text-primary">Judge&apos;s Note</p>
<p className="text-xs text-slate-500">ระบบ PDF Annotation สำหรับ e-Court</p>
</div>
</div>
<h1 className="font-display mt-8 text-subhead text-ink">เข้าสู่ระบบ</h1>
<h1 className="mt-6 text-lg font-semibold text-slate-900">เข้าสู่ระบบ</h1>
<form onSubmit={onSubmit} className="mt-5">
<form onSubmit={onSubmit} className="mt-4">
<div>
<label htmlFor="username" className="block text-sm font-medium text-ink">
<label htmlFor="username" className="text-sm font-semibold text-slate-700">
ชื่อผู้ใช้
</label>
<div className="mt-1">
@ -101,7 +92,7 @@ export default function LoginForm({ next }: { next: string | null }) {
</div>
<div className="mt-4">
<label htmlFor="password" className="block text-sm font-medium text-ink">
<label htmlFor="password" className="text-sm font-semibold text-slate-700">
รหัสผ่าน
</label>
<input
@ -120,20 +111,19 @@ export default function LoginForm({ next }: { next: string | null }) {
<button
type="submit"
disabled={pending}
className="focus-ring mt-6 w-full rounded-md bg-primary px-4 py-2.5 text-sm font-medium text-white transition hover:-translate-y-px hover:bg-primary-hover hover:shadow-btn-glow disabled:pointer-events-none disabled:opacity-50"
className="mt-6 w-full rounded-md bg-primary px-4 py-2.5 text-sm font-medium text-white transition hover:opacity-90 disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1"
>
{pending ? "กำลังตรวจสอบ…" : "เข้าสู่ระบบ"}
</button>
{/* รหัสผ่านทดสอบ — รายชื่อเห็นใน dropdown แล้ว เหลือบอกแค่รหัสผ่าน (mock ไม่มีระบบสมัคร)
กล่อง metadata 8px radius ตาม Genesis (พื้นเทาโปร่งจาง ไม่ใช่การเน้น) */}
<p className="mt-5 rounded-lg bg-ink/[0.04] px-3 py-2.5 text-xs leading-relaxed text-ink-soft">
{/* รหัสผ่านทดสอบ — รายชื่อเห็นใน dropdown แล้ว เหลือบอกแค่รหัสผ่าน (mock ไม่มีระบบสมัคร) */}
<p className="mt-5 rounded-md bg-slate-50 px-3 py-2 text-xs text-slate-500">
รหัสผ่านทดสอบ:{" "}
<span className="font-semibold text-ink">Demo1234</span>
<span className="font-semibold text-slate-700">Demo1234</span>
</p>
{error && (
<p role="alert" className="mt-3 text-xs text-error-ink">
<p role="alert" className="mt-3 text-xs text-red-600">
{error}
</p>
)}

View file

@ -20,7 +20,7 @@ export default function LogoutButton() {
type="button"
onClick={onLogout}
disabled={pending}
className="focus-ring flex h-8 shrink-0 items-center gap-1.5 rounded-md border border-line px-3 text-xs font-medium text-ink-soft transition hover:-translate-y-px hover:border-ink-faint hover:text-ink disabled:pointer-events-none disabled:opacity-50"
className="flex shrink-0 items-center gap-1.5 rounded-md border border-slate-300 px-3 py-2 text-xs font-medium text-slate-600 transition hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1 disabled:opacity-50"
>
<LogOut className="h-3.5 w-3.5" aria-hidden />
{pending ? "กำลังออก…" : "ออกจากระบบ"}

View file

@ -13,7 +13,7 @@ export default function Select({
return (
<div className="relative">
<select {...props} className={`appearance-none pr-7 ${className}`} />
<ChevronDown className="pointer-events-none absolute right-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-ink-faint" />
<ChevronDown className="pointer-events-none absolute right-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-500" />
</div>
);
}

15
components/SessionBar.tsx Normal file
View file

@ -0,0 +1,15 @@
// แถบแสดงผู้ใช้ที่เข้าสู่ระบบบนหน้ารายการ (แทน UserPicker เดิม) — server component
// รับค่าจาก cookie ผ่าน props จากหน้า (middleware การันตีว่ามี session ก่อนถึงหน้านี้)
import LogoutButton from "./LogoutButton";
export default function SessionBar({ userName }: { userName: string }) {
return (
<div className="mt-6 flex flex-wrap items-center justify-between gap-x-3 gap-y-2 rounded-xl border border-slate-200 bg-white px-4 py-3 shadow-sm">
<p className="min-w-0 text-sm text-slate-600">
เข้าสู่ระบบเป็น{" "}
<span className="font-semibold text-slate-900">{userName}</span>
</p>
<LogoutButton />
</div>
);
}

View file

@ -2,10 +2,7 @@
// N4 — ฟอร์มอัปโหลดไฟล์เอกสารแบบง่ายบนหน้ารายการ (PDF/รูป รับพร้อมกัน)
// เสร็จแล้วรายการโผล่เองจาก revalidatePath("/") ของ action
// Genesis: หน้าตาเป็นการ์ด surface 12px radius ขอบ line (ใต้หัวข้อของ section
// "เอกสาร" — ไฟล์ที่อัปโหลดเข้ารายการนั้น) + ปุ่ม primary เดียวของ section
import { useState, useTransition } from "react";
import { Upload } from "lucide-react";
import { uploadFile } from "@/app/actions/files";
@ -46,43 +43,29 @@ export default function UploadFileForm() {
};
return (
<form onSubmit={onSubmit} className="flex flex-col rounded-xl border border-line bg-white p-5">
<div className="flex items-center gap-2">
<Upload aria-hidden className="h-4 w-4 shrink-0 text-ink-faint" />
<p className="text-sm font-semibold text-ink">อัปโหลดไฟล์</p>
</div>
<form
onSubmit={onSubmit}
className="mt-3 flex flex-wrap items-center gap-2 rounded-xl border border-dashed border-slate-300 bg-white/60 p-4"
>
<input
type="file"
name="file"
accept={ACCEPT}
required
disabled={pending}
className="mt-4 block w-full text-sm text-ink-soft file:mr-3 file:cursor-pointer file:rounded-md file:border-0 file:bg-ink/[0.05] file:px-4 file:py-2 file:text-sm file:font-medium file:text-ink"
/>
{/* กลุ่ม typeId ทางเลือก (N9) — ช่องเดียวกับลิงก์ e-Court, ไม่กรอก = "ไม่มีกลุ่ม";
uncontrolled ส่งไปกับ FormData เหมือนช่องไฟล์ (normalize ที่ action) */}
<label htmlFor="upload-type-id" className="mt-3 block text-sm font-medium text-ink">
กลุ่ม typeId
</label>
<input
type="text"
id="upload-type-id"
name="typeId"
disabled={pending}
placeholder="ไม่กรอก = ไม่มีกลุ่ม"
className="genesis-input mt-1 w-full text-base"
className="block w-full max-w-xs text-sm text-slate-600 file:mr-3 file:cursor-pointer file:rounded-md file:border-0 file:bg-primary/10 file:px-4 file:py-2 file:text-sm file:font-medium file:text-primary"
/>
<button
type="submit"
disabled={pending}
className="focus-ring mt-4 w-full rounded-md bg-primary px-4 py-2 text-sm font-medium text-white transition hover:-translate-y-px hover:bg-primary-hover hover:shadow-btn-glow disabled:pointer-events-none disabled:opacity-50"
className="ml-auto shrink-0 rounded-md bg-primary px-4 py-2.5 text-sm font-medium text-white transition hover:opacity-90 disabled:opacity-50"
>
{pending ? "กำลังอัปโหลด…" : "อัปโหลด"}
</button>
<p className="mt-3 text-xs leading-relaxed text-ink-soft">
<p className="w-full text-xs text-slate-500">
รองรับ PDF, JPG, PNG, TIFF — ไม่เกิน 100MB ต่อไฟล์
</p>
{error && <p className="mt-2 text-xs text-error-ink">{error}</p>}
{error && <p className="w-full text-xs text-red-600">{error}</p>}
</form>
);
}

View file

@ -1,354 +0,0 @@
"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>
);
}

View file

@ -1,186 +0,0 @@
"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>
);
}

View file

@ -1,293 +0,0 @@
"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>
)}
</>
);
}

View file

@ -11,35 +11,24 @@ import { AlignCenter, AlignLeft, AlignRight, BookmarkPlus, Pencil, Trash2 } from
import type {
AnnotationData,
AnnotationDTO,
CommentData,
HighlightData,
HighlightStyle,
TextboxData,
} from "@/db/schema";
import {
COMMENT_PIN_COARSE_SIZE_PX,
COMMENT_PIN_SIZE_PX,
TEXTBOX_MAX_WIDTH_PCT,
annotationBBox,
commentPinBoxPct,
commentPinBoxPx,
scaleAnnotationData,
translateAnnotationData,
} from "@/utils/geometry";
import {
clamp,
getPointerPercent,
type Point,
type Rect,
type Size,
} from "@/utils/coordinates";
import { annotationTypeLabel, STAMP_COLOR } from "@/utils/annotation-display";
import { clamp, getPointerPercent, type Point, type Rect, type Size } from "@/utils/coordinates";
import { STAMP_COLOR } from "@/utils/annotation-display";
import { ColorSwatches, HIGHLIGHT_STYLE_BTNS, NumberCombo, PanelRow } from "./ToolControls";
/** UX audit 2026-09-16: focus ring คีย์บอร์ด — ค่าเดียวกับ FOCUS_RING_CLS ของ
* PDFViewer/AnnotationSidebar (ประกาศแยกที่นี่กัน circular import) */
const FOCUS_RING_CLS =
"focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary";
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1";
// กรอบ selection + handles ของ annotation ที่เลือก (PLAN-FEAT.md §3 F1)
// - container เป็น z-4 เหมือน comment pins และ pointer-events: none **เสมอ**
@ -53,9 +42,6 @@ const STRIP_PX = 8; // ความหนาขอบกรอบ (translate)
const MIN_BOX_PCT = 0.5; // bbox เล็กสุด (% ของหน้า) กันกรอบหาย
const EPS = 1e-6;
// COMMENT: ขนาด pin px + bbox px/% ของ pin ย้ายไป utils/geometry.ts แล้ว (ใช้ร่วมกับ
// goToAnnotation ของ PDFViewer — single source ตาม responsive audit 2026-09-24)
type DragState = {
// wleft/wright = TEXTBOX กว้างคงที่: ลากขอบซ้าย/ขวาปรับความกว้าง (ฟอนต์คงขนาด)
mode: "move" | "scale" | "wleft" | "wright";
@ -130,25 +116,8 @@ export default function AnnotationSelectionLayer({
const movedRef = useRef(false);
const latestRef = useRef<AnnotationData | null>(null);
// อุปกรณ์จอสัมผัส (pointer หลัก coarse) — คำนวณครั้งเดียวตอน mount เหมือน
// coarseTouch ของ PDFViewer: .coarse-36 ขยายปุ่ม pin เป็น 36px บนพวกนี้
const [coarseTouch] = useState(
() =>
typeof window !== "undefined" &&
window.matchMedia("(pointer: coarse)").matches,
);
// COMMENT ใช้กล่องครอบ pin จริง (px คงที่ตามอุปกรณ์) — ชนิดอื่นคือ bbox ตามขนาดหน้า
// boxPct ก็ใช้มาตราฐานเดียวกัน (% จาก px จริง): move-clamp กับปุ่ม bookmark ของ pin
// ต้องเห็น pin ตามที่ render ไม่ใช่ COMMENT_PIN_BOX_PCT ประมาณ (audit 2026-09-24)
const isCommentPin = annotation.type === "COMMENT";
const pinPx = coarseTouch ? COMMENT_PIN_COARSE_SIZE_PX : COMMENT_PIN_SIZE_PX;
const boxPx = isCommentPin
? commentPinBoxPx(annotation.data as CommentData, size, pinPx)
: annotationBBox(annotation, size);
const boxPct = isCommentPin
? commentPinBoxPct(annotation.data as CommentData, size, pinPx)
: annotationBBox(annotation, { width: 100, height: 100 });
const boxPx = annotationBBox(annotation, size);
const boxPct = annotationBBox(annotation, { width: 100, height: 100 });
// FIX-9: เอา non-null ! ออก — handler ยิงได้แค่ตอน DOM mount แล้ว
// ref จึงไม่ควรเป็น null (คืน 0,0 เผื่อไว้เฉย ๆ ให้ type ชัด)
@ -506,10 +475,8 @@ export default function AnnotationSelectionLayer({
/>
{/* มุมทั้ง 4 — ลาก = scale รอบ anchor มุมตรงข้าม (TEXTBOX กว้างคงที่ไม่มีมุม —
ปรับความกว้างที่ขอบซ้าย/ขวาแทน ส่วนสูงตามจำนวนบรรทัดอัตโนมัติ; COMMENT ไม่
มีมุมเช่นกัน — pin ไม่มี field ขนาด scale จึงไร้ความหมาย และ anchor จาก boxPct
(% รอบจุด) กับ handle บนกล่อง pin คนละกล่องทำให้ลากแล้วเหลื่อมจากนิ้ว) */}
{!isFixedTextbox && !isCommentPin && corners.map((c) => (
ปรับความกว้างที่ขอบซ้าย/ขวาแทน ส่วนสูงตามจำนวนบรรทัดอัตโนมัติ) */}
{!isFixedTextbox && corners.map((c) => (
<div
key={c.key}
className="absolute touch-none sel-handle rounded-sm border border-primary bg-primary/50"
@ -531,23 +498,6 @@ export default function AnnotationSelectionLayer({
</>
)}
{/* Responsive audit 2026-09-24: รายการของผู้อื่นที่เลือกอยู่ — ป้าย "ชนิด · ผู้ทำ" ลอย
เหนือกรอบ (จอสัมผัสไม่มี hover tooltip N1 ให้ดูว่าใครทำ เดิมต้องเปิด sidebar
อย่างเดียว) — read-only pointer-events:none ตามกติกา container ของ layer นี้
ไม่บัง textLayer; ชิดหัวหน้าเกินก็ห้อยใต้กรอบแทน */}
{!editable && (
<div
className="absolute z-10 max-w-[200px] truncate rounded-md border border-line bg-white px-1.5 py-0.5 text-[11px] text-ink-soft shadow-lg"
style={{
left: Math.max(0, Math.min(boxPx.x, size.width - 204)),
top: boxPx.y - 24 < 0 ? boxPx.y + boxPx.height + 4 : boxPx.y - 24,
pointerEvents: "none",
}}
>
{annotationTypeLabel(annotation)} · {annotation.userName || "ไม่ทราบชื่อ"}
</div>
)}
{/* แผงเครื่องมือของรายการที่เลือก — ตัวควบคุมเดียวกับ dropdown ของ tool ผู้สร้าง
(สี/ความหนา/ขนาด/รูปแบบตามชนิด) + ปุ่มลบท้ายแผง — แสดงทุกชนิดรวม HIGHLIGHT
(ไม่มี handle ย้าย/ขยาย แต่เปลี่ยนสี/รูปแบบ/ลบได้) — เฉพาะรายการของตัวเอง
@ -557,7 +507,7 @@ export default function AnnotationSelectionLayer({
{editable && !disabled && (
<div
ref={panelRef}
className={`absolute ${hasStyleControls ? "w-60" : "w-fit"} sel-panel rounded-lg border border-line bg-white p-1.5 shadow-lg`}
className={`absolute ${hasStyleControls ? "w-60" : "w-fit"} sel-panel rounded-lg border border-slate-200 bg-white p-1.5 shadow-lg`}
style={{
left: Math.max(0, Math.min(boxPx.x, size.width - 240 - 4)),
top: openAbove ? boxPx.y - 6 : boxPx.y + boxPx.height + 4,
@ -582,7 +532,7 @@ export default function AnnotationSelectionLayer({
className={`flex h-6 w-6 items-center justify-center rounded-md transition ${FOCUS_RING_CLS} ${
(highlightData.style ?? "highlight") === key
? "bg-primary text-white"
: "text-ink-soft hover:bg-ink/[0.05]"
: "text-slate-500 hover:bg-slate-100"
}`}
onClick={() => commitHighlightStyle(key)}
>
@ -649,7 +599,7 @@ export default function AnnotationSelectionLayer({
<PanelRow label="ข้อความ">
<button
type="button"
className={`flex flex-1 items-center justify-center gap-1 rounded-md border border-line px-2 py-1 text-[11px] font-medium text-ink-soft hover:bg-ink/[0.05] ${FOCUS_RING_CLS}`}
className={`flex flex-1 items-center justify-center gap-1 rounded-md border border-slate-300 px-2 py-1 text-[11px] font-medium text-slate-600 hover:bg-slate-100 ${FOCUS_RING_CLS}`}
onClick={() => onStartEdit?.(annotation.id)}
>
<Pencil className="h-3.5 w-3.5" />
@ -693,7 +643,7 @@ export default function AnnotationSelectionLayer({
className={`flex h-6 w-6 items-center justify-center rounded-md border transition ${FOCUS_RING_CLS} ${
textboxData.bold
? "border-primary bg-primary text-white"
: "border-line bg-white text-ink-soft hover:bg-ink/[0.05]"
: "border-slate-300 bg-white text-slate-500 hover:bg-slate-100"
}`}
onClick={() => commitTextboxPatch({ bold: !textboxData.bold })}
>
@ -704,7 +654,7 @@ export default function AnnotationSelectionLayer({
</button>
</PanelRow>
<PanelRow label="จัดแนว">
<div className="flex overflow-hidden rounded-md border border-line">
<div className="flex overflow-hidden rounded-md border border-slate-300">
{(
[
["left", AlignLeft, "ชิดซ้าย"],
@ -720,7 +670,7 @@ export default function AnnotationSelectionLayer({
className={`px-2 py-1 transition ${FOCUS_RING_CLS} ${
(textboxData.align ?? "left") === a
? "bg-primary text-white"
: "text-ink-soft hover:bg-ink/[0.05]"
: "text-slate-500 hover:bg-slate-100"
}`}
onClick={() => commitTextboxPatch({ align: a })}
>
@ -731,29 +681,27 @@ export default function AnnotationSelectionLayer({
</PanelRow>
</>
)}
{hasStyleControls && <div className="my-1 border-t border-line/60" />}
{hasStyleControls && <div className="my-1 border-t border-slate-100" />}
<div className={hasStyleControls ? "flex items-center justify-between" : "flex items-center"}>
{/* N2: bookmark ตำแหน่งของรายการนี้ — เปิด popup ตั้งชื่อ/โน้ต/เลือกหัวข้อต่อ */}
{/* hover เป็นกลางแบบปุ่มจิ๋วอื่นในแผง (เดิม copy มาจากปุ่มลบข้าง ๆ จน hover
แดง = สัญญาณอันตรายเท็จกับการ "บันทึก" — UX review M4 2026-09-23) */}
<button
type="button"
aria-label="บันทึก bookmark ตำแหน่งรายการนี้"
title="Bookmark ตำแหน่งนี้"
className={`flex h-5 w-5 sel-panel-btn items-center justify-center rounded text-ink-faint hover:bg-ink/[0.05] hover:text-ink ${FOCUS_RING_CLS}`}
className={`flex h-5 w-5 sel-panel-btn items-center justify-center rounded text-slate-400 hover:bg-amber-50 hover:text-amber-500 ${FOCUS_RING_CLS}`}
onClick={() => onBookmark(boxPct.y)}
>
<BookmarkPlus className="h-3.5 w-3.5" />
</button>
{/* เส้นคั่นแนวตั้ง — เฉพาะกรณีปุ่มติดกัน (แผงไม่มี row สไตล์): แยก bookmark ออกจากปุ่มลบ */}
{!hasStyleControls && (
<span aria-hidden="true" className="mx-1 h-3.5 w-px bg-line" />
<span aria-hidden="true" className="mx-1 h-3.5 w-px bg-slate-200" />
)}
<button
type="button"
aria-label="ลบรายการนี้"
title="ลบ"
className={`flex h-5 w-5 sel-panel-btn items-center justify-center rounded text-error-ink hover:bg-error/10 ${FOCUS_RING_CLS}`}
className={`flex h-5 w-5 sel-panel-btn items-center justify-center rounded text-red-500 hover:bg-red-50 ${FOCUS_RING_CLS}`}
onClick={() => onDelete(annotation.id)}
>
<Trash2 className="h-3.5 w-3.5" />

File diff suppressed because it is too large Load diff

View file

@ -194,7 +194,7 @@ function TextboxEditor({
e.clipboardData.getData("text/plain"),
);
}}
className="min-h-[1.5em] w-full whitespace-pre-wrap break-words rounded border border-dashed border-ink-faint/70 bg-transparent p-0 caret-current focus:outline-none"
className="min-h-[1.5em] w-full whitespace-pre-wrap break-words rounded border border-dashed border-slate-500/70 bg-transparent p-0 caret-current focus:outline-none"
style={{
fontFamily: TEXTBOX_FONT_STACK,
fontSize: toPx(fontSizePct, size.width),

View file

@ -14,7 +14,7 @@ import { colorName } from "@/utils/annotation-display";
* PDFViewer/AnnotationSidebar (ประกาศแยกที่นี่เพราะ PDFViewer import ไฟล์นี้
* อยู่แล้ว ห้ามวนกลับ) */
const FOCUS_RING_CLS =
"focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary";
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1";
/** สี preset ให้เลือก (ทุก tool ที่ปรับสีได้: draw/shape/highlight/textbox/stamp)
* สีอื่นนอกรายการเลือกได้ผ่านปุ่มสีรุ้ง (input type="color") ท้ายแถว */
@ -38,7 +38,7 @@ export function PanelRow({ label, children }: { label?: string; children: ReactN
return (
<div className="panel-row flex items-start gap-2 px-1 py-1">
{label ? (
<span className="w-11 shrink-0 pt-1 text-[11px] font-medium text-ink-soft">{label}</span>
<span className="w-11 shrink-0 pt-1 text-[11px] font-medium text-slate-500">{label}</span>
) : null}
{children}
</div>
@ -57,7 +57,7 @@ export function ColorSwatches({ value, onChange }: { value: string; onChange: (c
type="button"
aria-label={`เลือกสี ${colorName(c)}`}
title={colorName(c)}
className={`aspect-square max-w-8 w-full justify-self-center rounded-full border border-line transition ${FOCUS_RING_CLS} ${
className={`aspect-square max-w-8 w-full justify-self-center rounded-full border border-slate-300 transition ${FOCUS_RING_CLS} ${
value === c ? "ring-2 ring-primary ring-offset-1" : "hover:scale-110"
}`}
style={{ backgroundColor: c }}
@ -66,7 +66,7 @@ export function ColorSwatches({ value, onChange }: { value: string; onChange: (c
))}
<label
title="เลือกสีอื่น"
className={`relative aspect-square max-w-8 w-full cursor-pointer justify-self-center rounded-full border border-line transition has-[:focus-visible]:outline has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-offset-2 has-[:focus-visible]:outline-primary ${
className={`relative aspect-square max-w-8 w-full cursor-pointer justify-self-center rounded-full border border-slate-300 transition has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-primary has-[:focus-visible]:ring-offset-1 ${
INK_COLORS.includes(value) ? "hover:scale-110" : "ring-2 ring-primary ring-offset-1"
}`}
style={{
@ -179,7 +179,7 @@ export function NumberCombo({
}
}}
>
<div className="flex items-stretch overflow-hidden rounded-md border border-line bg-white">
<div className="flex items-stretch overflow-hidden rounded-md border border-slate-300 bg-white">
<input
type="number"
value={draft}
@ -196,16 +196,16 @@ export function NumberCombo({
if (e.key !== "Escape") e.stopPropagation(); // พิมพ์ในช่องต้องไม่ชนคีย์ลัด F3 (Esc ปล่อยไหลไปปิดเมนู)
if (e.key === "Enter") commitDraft(); // ยืนยันครั้งเดียวตรงนี้ ไม่ต้องรอ blur
}}
className="min-w-0 flex-1 border-0 px-1.5 py-1.5 text-[11px] text-ink focus:outline focus:outline-2 focus:outline-offset-1 focus:outline-primary [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
className="min-w-0 flex-1 border-0 px-1.5 py-1.5 text-[11px] text-slate-700 focus:outline-none focus:ring-1 focus:ring-inset focus:ring-slate-400 [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
/>
<div className="flex flex-col border-l border-line">
<div className="flex flex-col border-l border-slate-300">
{/* combo-step: ปุ่ม ▲▼ ซ้อนแนวตั้ง 2 ชั้น — ต้องหลุดจาก rule touch 36px
ของ dropdown (globals.css) ไม่งั้นคอลัมน์สูง 72px ดึงทั้งแถวบวมตาม */}
<button
type="button"
aria-label="เพิ่มค่า"
onMouseDown={(e) => e.preventDefault()} // ไม่แย่ง focus จาก input (dropdown ไม่กระพริบ)
className={`combo-step flex flex-1 items-center justify-center rounded-sm px-1.5 text-ink-soft hover:bg-ink/[0.05] ${FOCUS_RING_CLS}`}
className={`combo-step flex flex-1 items-center justify-center rounded-sm px-1.5 text-slate-500 hover:bg-slate-100 ${FOCUS_RING_CLS}`}
onClick={() => stepValue(1)}
>
<ChevronUp className="h-3 w-3" />
@ -214,7 +214,7 @@ export function NumberCombo({
type="button"
aria-label="ลดค่า"
onMouseDown={(e) => e.preventDefault()}
className={`combo-step flex flex-1 items-center justify-center rounded-sm border-t border-line px-1.5 text-ink-soft hover:bg-ink/[0.05] ${FOCUS_RING_CLS}`}
className={`combo-step flex flex-1 items-center justify-center rounded-sm border-t border-slate-300 px-1.5 text-slate-500 hover:bg-slate-100 ${FOCUS_RING_CLS}`}
onClick={() => stepValue(-1)}
>
<ChevronDown className="h-3 w-3" />
@ -227,7 +227,7 @@ export function NumberCombo({
// L2 (UX audit 2026-09-16): เปิด "ขึ้นบน" (bottom-full) เสมอ — แถวท้ายเมนู
// tool (ความทึบ/ความหนา) เปิดลงจะล้นเขต scroll ของเมนู ต้องเลื่อนเมนูจึงเห็น
// ครบ / ทุกจุดใช้งานมีแถวอื่นอยู่ข้างบนเสมอ จึงไม่หลุดขอบบนของเมนูเช่นกัน
className="absolute bottom-full right-0 z-[60] mb-1 w-full overflow-hidden rounded-md border border-line bg-white py-0.5 shadow-lg"
className="absolute bottom-full right-0 z-[60] mb-1 w-full overflow-hidden rounded-md border border-slate-200 bg-white py-0.5 shadow-lg"
>
{options.map((opt) => (
<button
@ -238,8 +238,8 @@ export function NumberCombo({
onMouseDown={(e) => e.preventDefault()} // ไม่แย่ง focus จาก input ก่อนคลิก
className={`flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs font-medium transition ${FOCUS_RING_CLS} ${
value === opt
? "bg-primary/10 text-primary"
: "text-ink-soft hover:bg-ink/[0.05]"
? "bg-slate-100 text-slate-900"
: "text-slate-600 hover:bg-slate-100"
}`}
onClick={() => {
onChange(opt);

View file

@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, type FormEvent } fro
import { createPortal } from "react-dom";
import {
Bookmark,
Braces,
Check,
ChevronDown,
ChevronRight,
@ -53,7 +54,7 @@ import { formatThaiDate } from "@/utils/date-format";
* ของ PDFViewer.tsx (ห้าม import ข้ามไฟล์: PDFViewer → sidebar → PDFViewer เป็น
* circular import — แก้ต้องแก้ทั้งสองที่พร้อมกัน) */
const FOCUS_RING_CLS =
"focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary focus-visible:z-10";
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1 focus-visible:z-10";
/** ใส่/ล้างโน้ตคำอธิบายใน data ของ annotation (ทุกชนิดยกเว้น COMMENT — message ทำหน้าที่นี้แล้ว) */
function withNote(data: Exclude<AnnotationData, CommentData>, note: string): AnnotationData {
@ -138,7 +139,7 @@ function ViewSwitch<T extends string>({
onChange: (v: T) => void;
}) {
return (
<div className="flex rounded-md bg-ink/[0.05] p-0.5">
<div className="flex rounded-md bg-slate-100 p-0.5">
{options.map((o) => (
<button
key={o.key}
@ -146,8 +147,8 @@ function ViewSwitch<T extends string>({
aria-pressed={value === o.key}
className={`flex-1 rounded px-2 py-1 text-[11px] font-medium transition ${FOCUS_RING_CLS} ${
value === o.key
? "bg-white text-ink ring-1 ring-line"
: "text-ink-soft hover:text-ink"
? "bg-white text-slate-900 shadow-sm"
: "text-slate-500 hover:text-slate-600"
}`}
onClick={() => onChange(o.key)}
>
@ -177,7 +178,7 @@ function MenuItem({
type="button"
role="menuitem"
className={`flex w-full items-start gap-2 rounded px-2 py-1.5 text-left text-xs transition ${FOCUS_RING_CLS} ${
danger ? "text-error-ink hover:bg-error/10" : "text-ink-soft hover:bg-ink/[0.05]"
danger ? "text-red-600 hover:bg-red-50" : "text-slate-600 hover:bg-slate-100"
}`}
onClick={onClick}
>
@ -203,17 +204,17 @@ function PageGroupHeader({
<button
type="button"
aria-label={collapsed ? `กางหน้า ${pageIndex + 1}` : `หุบหน้า ${pageIndex + 1}`}
className={`flex w-full items-center gap-1.5 rounded px-1 py-1 text-left hover:bg-ink/[0.05] ${FOCUS_RING_CLS}`}
className={`flex w-full items-center gap-1.5 rounded px-1 py-1 text-left hover:bg-slate-100 ${FOCUS_RING_CLS}`}
onClick={onToggle}
>
{collapsed ? (
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-ink-soft" />
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-slate-500" />
) : (
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-ink-soft" />
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-slate-500" />
)}
<span className="text-xs font-medium text-ink">
<span className="text-xs font-medium text-slate-700">
หน้า {pageIndex + 1}
<span className="ml-1 font-normal text-ink-soft">({count})</span>
<span className="ml-1 font-normal text-slate-500">({count})</span>
</span>
</button>
);
@ -773,7 +774,7 @@ export default function AnnotationSidebar({
if (e.key === "Escape") setEditBookmark(null);
}}
placeholder="ชื่อหัวข้อ"
className="w-full rounded-md border border-line px-1.5 py-1 text-xs focus:border-primary focus:outline focus:outline-2 focus:outline-offset-1 focus:outline-primary"
className="w-full rounded border border-slate-300 px-1.5 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-slate-400"
/>
<textarea
rows={2}
@ -786,16 +787,16 @@ export default function AnnotationSidebar({
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) saveBookmarkEdit(b);
}}
placeholder="โน้ต (ไม่บังคับ)"
className="mt-1 w-full resize-none rounded-md border border-line p-1.5 text-[11px] focus:border-primary focus:outline focus:outline-2 focus:outline-offset-1 focus:outline-primary"
className="mt-1 w-full resize-none rounded border border-slate-300 p-1.5 text-[11px] focus:outline-none focus:ring-1 focus:ring-slate-400"
/>
<p className="mt-1 text-[11px] text-ink-soft">วางที่</p>
<p className="mt-1 text-[11px] text-slate-500">วางที่</p>
<Select
value={eb.parentId ?? ""}
onChange={(e) =>
setEditBookmark({ ...eb, parentId: e.target.value || null })
}
onKeyDown={(e) => e.stopPropagation()}
className="mt-0.5 w-full rounded-md border border-line bg-white px-1.5 py-1 text-[11px] focus:border-primary focus:outline focus:outline-2 focus:outline-offset-1 focus:outline-primary"
className="mt-0.5 w-full rounded border border-slate-300 bg-white px-1.5 py-1 text-[11px] focus:outline-none focus:ring-1 focus:ring-slate-400"
>
<option value="">ระดับบนสุด (หมุดหัวข้อใหญ่)</option>
{movableTo.map((h) => (
@ -807,14 +808,14 @@ export default function AnnotationSidebar({
<div className="mt-1.5 flex justify-end gap-1.5 text-[11px]">
<button
type="button"
className={`rounded-md px-2 py-0.5 text-ink-soft hover:bg-ink/[0.05] ${FOCUS_RING_CLS}`}
className={`rounded px-2 py-0.5 text-slate-500 hover:bg-slate-100 ${FOCUS_RING_CLS}`}
onClick={() => setEditBookmark(null)}
>
ยกเลิก
</button>
<button
type="button"
className={`rounded-md bg-primary px-2 py-0.5 font-medium text-white ${FOCUS_RING_CLS}`}
className={`rounded bg-primary px-2 py-0.5 font-medium text-white ${FOCUS_RING_CLS}`}
onClick={() => saveBookmarkEdit(b)}
>
บันทึก
@ -822,7 +823,7 @@ export default function AnnotationSidebar({
</div>
</div>
) : (
<div className={`group flex items-center gap-1.5 rounded py-1 hover:bg-ink/[0.05] ${pad}`}>
<div className={`group flex items-center gap-1.5 rounded py-1 hover:bg-slate-100 ${pad}`}>
{hasChildren ? (
// bm-toggle: คอลัมน์นี้เป็น "ตาราง" ของต้นไม้ ต้องกว้าง 14px เท่า spacer
// ของแถวลูกเสมอ — บนจอสัมผัส rule coarse ที่ขยายปุ่มเป็น 32px จะดันไอคอน
@ -831,7 +832,7 @@ export default function AnnotationSidebar({
<button
type="button"
aria-label={collapsed ? `กางหัวข้อ ${b.title}` : `หุบหัวข้อ ${b.title}`}
className={`shrink-0 bm-toggle text-ink-soft hover:text-ink ${FOCUS_RING_CLS}`}
className={`shrink-0 bm-toggle text-slate-500 hover:text-slate-600 ${FOCUS_RING_CLS}`}
onClick={() => toggleCollapsed(b.id)}
>
{collapsed ? (
@ -849,25 +850,26 @@ export default function AnnotationSidebar({
className={`flex min-w-0 flex-1 items-start gap-1.5 text-left ${FOCUS_RING_CLS}`}
onClick={() => onGoToBookmark(b)}
>
{/* ไอคอน bookmark สีเดียวทุกระดับ (warning-ink ผ่าน 5:1 บนขาว — เดิมแยก
root/ลูกด้วย amber-500/300 แต่ตัว 300 จาง 1.6:1 แทบมองไม่เห็น;
ลำดับชั้นของต้นไม้สื่อด้วย font-semibold + indent อยู่แล้ว) */}
<Bookmark className="h-3.5 w-3.5 shrink-0 text-warning-ink" />
<Bookmark
className={`h-3.5 w-3.5 shrink-0 ${
depth === 1 ? "text-amber-500" : "text-amber-300"
}`}
/>
<span className="min-w-0">
<span
className={`block truncate text-[13px] leading-snug ${
depth === 1 ? "font-semibold text-ink" : ""
depth === 1 ? "font-semibold text-slate-700" : ""
}`}
>
{b.title}
</span>
{/* แสดงผู้บันทึกด้วย — เหมือนแถวผลการค้นหา (N6) และแถวหมายเหตุ
(แถวเดิมมีแค่ "หน้า N" ไม่รู้ว่าใครเป็นคนคั่นไว้) */}
<span className="block text-xs text-ink-soft">
<span className="block text-xs text-slate-500">
หน้า {b.pageIndex + 1} · {b.userName || "ไม่ทราบชื่อ"}
</span>
{b.note && (
<span className="mt-0.5 block line-clamp-2 whitespace-pre-wrap break-words text-[11px] text-ink-soft">
<span className="mt-0.5 block line-clamp-2 whitespace-pre-wrap break-words text-[11px] text-slate-500">
{b.note}
</span>
)}
@ -875,7 +877,7 @@ export default function AnnotationSidebar({
</button>
{/* จำนวนหมุดย่อย — badge ขวาสุด (UX audit: แยกจากชื่อ ไม่ปนตอนอ่านไว) */}
{hasChildren && (
<span className="shrink-0 self-center rounded-full bg-ink/[0.05] px-1.5 text-[10px] tabular-nums leading-4 text-ink-soft">
<span className="shrink-0 self-center rounded-full bg-slate-100 px-1.5 text-[10px] tabular-nums leading-4 text-slate-500">
{children.length}
</span>
)}
@ -886,7 +888,7 @@ export default function AnnotationSidebar({
aria-label="จัดการหัวข้อนี้"
aria-haspopup="menu"
aria-expanded={menuFor?.kind === "bookmark" && menuFor.id === b.id}
className={`shrink-0 text-ink-soft hover:text-ink group-hover:text-ink-soft ${FOCUS_RING_CLS}`}
className={`shrink-0 text-slate-500 hover:text-slate-600 group-hover:text-slate-600 ${FOCUS_RING_CLS}`}
onClick={(e) => {
if (menuFor?.kind === "bookmark" && menuFor.id === b.id) closeRowMenu();
else openRowMenu(e.currentTarget, { kind: "bookmark", id: b.id });
@ -900,7 +902,7 @@ export default function AnnotationSidebar({
{hasChildren && !collapsed && (
// เส้น guide แนวตั้งนำสายตากลับหา parent แบบ file tree (indent ~17px/ระดับ
// ≈ ของเดิม 16px — spacer/chevron w-3.5 คงเดิมจึงตรงแนวกันทุกระดับ)
<ul className="ml-3 space-y-0.5 border-l border-line pl-1">
<ul className="ml-3 space-y-0.5 border-l border-slate-200 pl-1">
{children.map((c) => renderBookmarkNode(c, depth + 1))}
</ul>
)}
@ -918,7 +920,7 @@ export default function AnnotationSidebar({
className={`group rounded px-2 py-1.5 ${
a.id === selectedId
? "bg-primary/10 ring-1 ring-inset ring-primary/30"
: "hover:bg-ink/[0.05]"
: "hover:bg-slate-100"
} ${a.resolved ? "opacity-60" : ""}`}
>
<div className="flex items-center gap-2">
@ -936,7 +938,7 @@ export default function AnnotationSidebar({
{/* UX audit: อักษรไทยมีสระ/วรรณยุกต์ซ้อนหลายชั้น — ข้อความหลักต้อง 13px
จึงอ่านได้สบาย (บรรทัดรอง/โน้ตคง 11px) */}
<span className="block truncate text-[13px] leading-snug">{annotationPreviewText(a)}</span>
<span className="block text-xs text-ink-soft">
<span className="block text-xs text-slate-500">
หน้า {a.pageIndex + 1} · {a.userName}
{a.type === "COMMENT" && (replyCounts[a.id] ?? 0) > 0
? ` · ตอบ ${replyCounts[a.id]}`
@ -955,10 +957,10 @@ export default function AnnotationSidebar({
title={copyFailedId === a.id ? "คัดลอกไม่ได้ (เบราว์เซอร์บล็อกคลิปบอร์ด)" : "จัดการหมายเหตุนี้"}
className={`shrink-0 ${FOCUS_RING_CLS} ${
copiedId === a.id
? "text-success-ink"
? "text-emerald-500"
: copyFailedId === a.id
? "text-error-ink"
: "text-ink-soft hover:text-ink group-hover:text-ink-soft"
? "text-red-500"
: "text-slate-500 hover:text-slate-600 group-hover:text-slate-600"
}`}
onClick={(e) => {
if (menuFor?.kind === "annotation" && menuFor.id === a.id) closeRowMenu();
@ -987,19 +989,19 @@ export default function AnnotationSidebar({
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) saveNote(a);
}}
placeholder="โน้ต เช่น เหตุผลที่ทำรายการนี้…"
className="w-full resize-none rounded-md border border-line p-1.5 text-[11px] focus:border-primary focus:outline focus:outline-2 focus:outline-offset-1 focus:outline-primary"
className="w-full resize-none rounded border border-slate-300 p-1.5 text-[11px] focus:outline-none focus:ring-1 focus:ring-slate-400"
/>
<div className="mt-1 flex justify-end gap-1.5 text-[11px]">
<button
type="button"
className={`rounded-md px-2 py-0.5 text-ink-soft hover:bg-ink/[0.05] ${FOCUS_RING_CLS}`}
className={`rounded px-2 py-0.5 text-slate-500 hover:bg-slate-100 ${FOCUS_RING_CLS}`}
onClick={() => setNoteFor(null)}
>
ยกเลิก
</button>
<button
type="button"
className={`rounded-md bg-primary px-2 py-0.5 font-medium text-white ${FOCUS_RING_CLS}`}
className={`rounded bg-primary px-2 py-0.5 font-medium text-white ${FOCUS_RING_CLS}`}
onClick={() => saveNote(a)}
>
บันทึก
@ -1008,8 +1010,8 @@ export default function AnnotationSidebar({
</div>
) : (
note && (
<p className="mt-0.5 flex items-start gap-1 pl-6 text-[11px] text-ink-soft">
<StickyNote className="mt-0.5 h-3 w-3 shrink-0 text-warning-ink" />
<p className="mt-0.5 flex items-start gap-1 pl-6 text-[11px] text-slate-500">
<StickyNote className="mt-0.5 h-3 w-3 shrink-0 text-amber-400" />
<span className="line-clamp-2 whitespace-pre-wrap break-words">{note}</span>
</p>
)
@ -1021,10 +1023,10 @@ export default function AnnotationSidebar({
return (
<div className="flex h-full flex-col">
{/* F4: กรองแสดง annotation ตามผู้เขียน (viewer + sidebar พร้อมกัน) */}
<div className="shrink-0 border-b border-line p-2">
<div className="shrink-0 border-b border-slate-200 p-2">
<Select
aria-label="เลือกเจ้าของหมายเหตุที่แสดง"
className="w-full rounded-md border border-line bg-white px-2 py-1.5 text-xs text-ink-soft focus:border-primary focus:outline focus:outline-2 focus:outline-offset-1 focus:outline-primary"
className="w-full rounded-md border border-slate-300 bg-white px-2 py-1.5 text-xs text-slate-600 focus:outline-none focus:ring-2 focus:ring-slate-400"
value={userFilter}
onChange={(e) => onUserFilterChange(e.target.value)}
>
@ -1039,7 +1041,7 @@ export default function AnnotationSidebar({
</div>
{/* แท็บหลัก — tablist/tab semantics ให้ screen reader ประกาศว่าเป็นแท็บ */}
<div role="tablist" aria-label="รายการในแผงข้าง" className="flex shrink-0 border-b border-line">
<div role="tablist" aria-label="รายการในแผงข้าง" className="flex shrink-0 border-b border-slate-200">
{(
[
["bookmarks", `Bookmark (${visibleBookmarks.length})`],
@ -1053,8 +1055,8 @@ export default function AnnotationSidebar({
aria-selected={tab === key}
className={`flex-1 px-2 py-2 text-xs font-medium tabular-nums transition ${FOCUS_RING_CLS} ${
tab === key
? "border-b-2 border-primary text-ink"
: "text-ink-soft hover:text-ink"
? "border-b-2 border-primary text-slate-900"
: "text-slate-500 hover:text-slate-600"
}`}
onClick={() => setTab(key)}
>
@ -1066,25 +1068,25 @@ export default function AnnotationSidebar({
{/* N6: ค้นหา bookmark พร้อมเงื่อนไข — เฉพาะแท็บที่คั่นหน้า
(แยกจากช่อง F14 ที่ค้นหมายเหตุ ซึ่งอยู่เหนือแท็บ) */}
{tab === "bookmarks" && (
<div className="shrink-0 space-y-1.5 border-b border-line p-2">
<div className="shrink-0 space-y-1.5 border-b border-slate-200 p-2">
{/* แถวคำค้น — โครงเดียวกับ F14 ของแท็บหมายเหตุ: input เต็มกว้าง + ปุ่มเดียว
(เดิมปุ่ม "เงื่อนไข"+badge แย่งแถวเดียวกันจน input เหลือพื้นที่ข้อความ
~80px ≈ 6–7 ตัวอักษรไทย พิมพ์คำค้นจริงไม่พอ) */}
<form className="flex gap-1" onSubmit={(e) => void runBookmarkSearch(e)}>
<div className="relative min-w-0 flex-1">
<Search className="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-ink-soft" />
<Search className="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-500" />
<input
value={bmQuery}
onChange={(e) => setBmQuery(e.target.value)}
onKeyDown={(e) => e.stopPropagation()} // ไม่ให้ปุ่มในช่องค้นหาชนคีย์ลัดของ viewer
placeholder="ชื่อ หรือ โน้ต…"
className="w-full rounded-md border border-line py-1.5 pl-7 pr-2 text-xs focus:border-primary focus:outline focus:outline-2 focus:outline-offset-1 focus:outline-primary"
className="w-full rounded-md border border-slate-300 py-1.5 pl-7 pr-2 text-xs focus:outline-none focus:ring-2 focus:ring-slate-400"
/>
</div>
<button
type="submit"
disabled={activeConditionCount === 0 || bmSearching}
className={`shrink-0 rounded-md border border-line px-2 py-1.5 text-xs text-ink-soft hover:bg-ink/[0.05] disabled:opacity-40 ${FOCUS_RING_CLS}`}
className={`shrink-0 rounded-md border border-slate-300 px-2 py-1.5 text-xs text-slate-600 hover:bg-slate-100 disabled:opacity-40 ${FOCUS_RING_CLS}`}
>
{bmSearching ? "…" : "หา"}
</button>
@ -1097,8 +1099,8 @@ export default function AnnotationSidebar({
type="button"
aria-expanded={bmShowConditions}
onClick={() => setBmShowConditions((v) => !v)}
className={`flex min-w-0 flex-1 items-center gap-1.5 rounded px-1.5 py-1 text-[11px] hover:bg-ink/[0.05] ${FOCUS_RING_CLS} ${
hasBmConditionValues ? "text-primary" : "text-ink-soft"
className={`flex min-w-0 flex-1 items-center gap-1.5 rounded px-1.5 py-1 text-[11px] hover:bg-slate-100 ${FOCUS_RING_CLS} ${
hasBmConditionValues ? "text-primary" : "text-slate-500"
}`}
>
<SlidersHorizontal className="h-3 w-3 shrink-0" />
@ -1111,14 +1113,22 @@ export default function AnnotationSidebar({
<ChevronRight className="h-3 w-3 shrink-0" />
)}
</button>
{/* ปุ่มส่งออก JSON (N7) ถูกถอด 2026-09-25 ตามคำขอผู้ใช้ — ระบบภายนอก
ดึงข้อมูลผ่าน N10 API แทน (ปุ่มเดียวที่เหลือคือ toggle เงื่อนไขค้นหา) */}
{/* N7: ส่งออกรายการ bookmark เป็น JSON — ลิงก์ธรรมดา pattern Export CSV (F16)
ส่งออกทุกรายการของไฟล์เสมอ ไม่ผูกกับเงื่อนไขค้นหาที่ตั้งไว้ */}
<a
href={withBasePath(`/api/bookmarks/export?fileId=${fileId}`)}
title="ส่งออกทุกที่คั่นหน้าของไฟล์นี้เป็น JSON"
aria-label="ส่งออกทุกที่คั่นหน้าของไฟล์นี้เป็น JSON"
className={`coarse-36 shrink-0 rounded-md p-1.5 text-slate-500 hover:bg-slate-100 hover:text-slate-700 ${FOCUS_RING_CLS}`}
>
<Braces className="h-3.5 w-3.5" />
</a>
</div>
{bmShowConditions && (
// จังหวะเดียวทุกแถว: label บนบรรทัด + คู่ input แบ่งความกว้าง grid 1fr–1fr
// (เดิม label inline 2 แถว + label บน 1 แถว อ่านไม่เป็นระเบียบ ขอบ input
// ไม่ตรงแนวกัน และคู่ date ในแถว inline ล้น 288px จนต้องหนีขึ้นบรรทัดบน)
<div className="space-y-1.5 text-[11px] text-ink-soft">
<div className="space-y-1.5 text-[11px] text-slate-600">
<div>
<span className="block pb-0.5">หน้า</span>
<div className="grid grid-cols-[1fr_auto_1fr] items-center gap-1">
@ -1133,9 +1143,9 @@ export default function AnnotationSidebar({
}
onKeyDown={(e) => e.stopPropagation()}
placeholder="จาก"
className="w-full min-w-0 rounded-md border border-line px-1.5 py-1 text-xs focus:border-primary focus:outline focus:outline-2 focus:outline-offset-1 focus:outline-primary"
className="w-full min-w-0 rounded border border-slate-300 px-1.5 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-slate-400"
/>
<span className="text-ink-faint">–</span>
<span className="text-slate-400">–</span>
<input
type="number"
min={1}
@ -1147,7 +1157,7 @@ export default function AnnotationSidebar({
}
onKeyDown={(e) => e.stopPropagation()}
placeholder="ถึง"
className="w-full min-w-0 rounded-md border border-line px-1.5 py-1 text-xs focus:border-primary focus:outline focus:outline-2 focus:outline-offset-1 focus:outline-primary"
className="w-full min-w-0 rounded border border-slate-300 px-1.5 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-slate-400"
/>
</div>
</div>
@ -1160,7 +1170,7 @@ export default function AnnotationSidebar({
setBmConditions({ ...bmConditions, userId: e.target.value })
}
onKeyDown={(e) => e.stopPropagation()}
className="w-full rounded-md border border-line bg-white px-1.5 py-1 text-[11px] focus:border-primary focus:outline focus:outline-2 focus:outline-offset-1 focus:outline-primary"
className="w-full rounded border border-slate-300 bg-white px-1.5 py-1 text-[11px] focus:outline-none focus:ring-1 focus:ring-slate-400"
>
<option value="">ทุกคน</option>
{bookmarkAuthors.map((u) => (
@ -1172,11 +1182,7 @@ export default function AnnotationSidebar({
</div>
<div>
<span className="block pb-0.5">บันทึกวันที่</span>
{/* เรียง 2 แถวเต็มกว้าง (ไม่ใช่คู่แนวนอน + "–" คั่น): คอลัมน์คู่แคบ
~122px ของ sidebar ใส่ค่า "23 ก.ย. 2026" ที่ 16px (rule จอสัมผัส
กัน iOS zoom-on-focus) + icon ปฏิทินไม่พอ ปีโดนตัด (code review
2026-09-23) — aria-label กำกับช่วงเริ่ม/สุดท้ายอยู่แล้ว */}
<div className="grid gap-1">
<div className="grid grid-cols-[1fr_auto_1fr] items-center gap-1">
<input
type="date"
aria-label="วันที่เริ่ม"
@ -1185,8 +1191,9 @@ export default function AnnotationSidebar({
setBmConditions({ ...bmConditions, dateFrom: e.target.value })
}
onKeyDown={(e) => e.stopPropagation()}
className="w-full min-w-0 rounded-md border border-line px-1.5 py-1 text-xs focus:border-primary focus:outline focus:outline-2 focus:outline-offset-1 focus:outline-primary"
className="w-full min-w-0 rounded border border-slate-300 px-1.5 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-slate-400"
/>
<span className="text-slate-400">–</span>
<input
type="date"
aria-label="วันที่สุดท้าย"
@ -1195,14 +1202,14 @@ export default function AnnotationSidebar({
setBmConditions({ ...bmConditions, dateTo: e.target.value })
}
onKeyDown={(e) => e.stopPropagation()}
className="w-full min-w-0 rounded-md border border-line px-1.5 py-1 text-xs focus:border-primary focus:outline focus:outline-2 focus:outline-offset-1 focus:outline-primary"
className="w-full min-w-0 rounded border border-slate-300 px-1.5 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-slate-400"
/>
</div>
</div>
<div className="flex justify-end">
<button
type="button"
className={`rounded-md px-1.5 py-1 text-ink-soft hover:bg-ink/[0.05] ${FOCUS_RING_CLS}`}
className={`rounded px-1.5 py-1 text-slate-500 hover:bg-slate-100 ${FOCUS_RING_CLS}`}
onClick={() => {
// UX audit: ล้างให้สิ้นเชิง — คำค้น + เงื่อนไข + ผลที่แสดง พร้อมกัน
setBmConditions(EMPTY_BM_CONDITIONS);
@ -1218,12 +1225,12 @@ export default function AnnotationSidebar({
{/* error กับ hint เป็นคำเตือนคนละเรื่อง (ฟอร์มผิด vs ผลเป็นของเงื่อนไขเก่า)
— มี error แสดงเดี่ยว ไม่ซ้อนสองข้อความพร้อมกัน */}
{bmSearchError ? (
<p className="px-1 text-[11px] text-error-ink">{bmSearchError}</p>
<p className="px-1 text-[11px] text-red-600">{bmSearchError}</p>
) : (
bmResults !== null &&
bmSearchedSig !== null &&
bmSearchedSig !== JSON.stringify([bmQuery.trim(), bmConditions]) && (
<p className="px-1 text-[11px] text-warning-ink">
<p className="px-1 text-[11px] text-amber-600">
เงื่อนไขเปลี่ยนแล้ว กด &quot;หา&quot; เพื่อค้นใหม่
</p>
)
@ -1236,23 +1243,23 @@ export default function AnnotationSidebar({
เนื้อหาใน list ด้านล่าง คลิกแถว = กระโดดไปตำแหน่ง + เลือก */}
{tab === "annotations" && (
<form
className="flex shrink-0 gap-1 border-b border-line p-2"
className="flex shrink-0 gap-1 border-b border-slate-200 p-2"
onSubmit={(e) => void runSearch(e)}
>
<div className="relative min-w-0 flex-1">
<Search className="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-ink-soft" />
<Search className="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-500" />
<input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => e.stopPropagation()} // ไม่ให้ปุ่มในช่องค้นหาชนคีย์ลัดของ viewer
placeholder="ค้นหาในหมายเหตุ…"
className="w-full rounded-md border border-line py-1.5 pl-7 pr-2 text-xs focus:border-primary focus:outline focus:outline-2 focus:outline-offset-1 focus:outline-primary"
className="w-full rounded-md border border-slate-300 py-1.5 pl-7 pr-2 text-xs focus:outline-none focus:ring-2 focus:ring-slate-400"
/>
</div>
<button
type="submit"
disabled={!searchQuery.trim() || searching}
className={`shrink-0 rounded-md border border-line px-2 py-1.5 text-xs text-ink-soft hover:bg-ink/[0.05] disabled:opacity-40 ${FOCUS_RING_CLS}`}
className={`shrink-0 rounded-md border border-slate-300 px-2 py-1.5 text-xs text-slate-600 hover:bg-slate-100 disabled:opacity-40 ${FOCUS_RING_CLS}`}
>
{searching ? "…" : "หา"}
</button>
@ -1264,14 +1271,14 @@ export default function AnnotationSidebar({
bmResults !== null ? (
<div>
<div className="flex items-center justify-between px-1 pb-1">
<p className="text-[11px] font-semibold tabular-nums text-ink-soft">
<p className="text-[11px] font-semibold tabular-nums text-slate-500">
ผลการค้นหา ({bmResults.length})
</p>
<button
type="button"
aria-label="ปิดผลการค้นหา"
title="กลับไปรายการที่คั่นหน้า"
className={`text-ink-soft hover:text-ink ${FOCUS_RING_CLS}`}
className={`text-slate-500 hover:text-slate-600 ${FOCUS_RING_CLS}`}
onClick={() => setBmResults(null)}
>
<X className="h-3.5 w-3.5" />
@ -1281,14 +1288,14 @@ export default function AnnotationSidebar({
แชร์จอให้คนอื่นดู (แก้ฟอร์มหลังค้นบรรทัดนี้ไม่เปลี่ยน จนกด "หา" ใหม่) */}
{bmSearchedSummary && (
<p
className="truncate px-1 pb-1 text-[11px] text-ink-soft"
className="truncate px-1 pb-1 text-[11px] text-slate-500"
title={bmSearchedSummary}
>
ค้นด้วย: {bmSearchedSummary}
</p>
)}
{bmResults.length === 0 ? (
<p className="px-1 pb-1 text-xs text-ink-soft">ไม่พบ</p>
<p className="px-1 pb-1 text-xs text-slate-500">ไม่พบ</p>
) : (
<ul className="space-y-0.5">
{bmResults.map((b) => {
@ -1297,22 +1304,22 @@ export default function AnnotationSidebar({
<li key={b.id}>
<button
type="button"
className={`flex w-full items-start gap-1.5 rounded px-1 py-1 text-left hover:bg-ink/[0.05] ${FOCUS_RING_CLS}`}
className={`flex w-full items-start gap-1.5 rounded px-1 py-1 text-left hover:bg-slate-100 ${FOCUS_RING_CLS}`}
onClick={() => onGoToBookmark(b)}
>
<Bookmark className="mt-0.5 h-3.5 w-3.5 shrink-0 text-warning-ink" />
<Bookmark className="mt-0.5 h-3.5 w-3.5 shrink-0 text-amber-500" />
<span className="min-w-0">
<span className="block truncate text-[13px] leading-snug">{b.title}</span>
<span className="block text-xs text-ink-soft">
<span className="block text-xs text-slate-500">
หน้า {b.pageIndex + 1} · {b.userName || "ไม่ทราบชื่อ"}
</span>
{parent && (
<span className="block truncate text-[11px] text-ink-soft">
<span className="block truncate text-[11px] text-slate-500">
ใน: {parent.title}
</span>
)}
{b.note && (
<span className="mt-0.5 block line-clamp-2 whitespace-pre-wrap break-words text-[11px] text-ink-soft">
<span className="mt-0.5 block line-clamp-2 whitespace-pre-wrap break-words text-[11px] text-slate-500">
{b.note}
</span>
)}
@ -1325,11 +1332,11 @@ export default function AnnotationSidebar({
)}
</div>
) : bookmarks.length === 0 ? (
<p className="p-4 text-center text-xs text-ink-soft">
<p className="p-4 text-center text-xs text-slate-500">
ยังไม่มีที่คั่นหน้า — กดปุ่มคั่นหน้าบนแถบเครื่องมือเพื่อเพิ่ม
</p>
) : visibleBookmarks.length === 0 ? (
<p className="p-4 text-center text-xs text-ink-soft">
<p className="p-4 text-center text-xs text-slate-500">
ไม่มีที่คั่นหน้าของเจ้าของหมายเหตุที่เลือก
</p>
) : (
@ -1366,27 +1373,27 @@ export default function AnnotationSidebar({
<li key={b.id}>
<button
type="button"
className={`flex w-full items-start gap-1.5 rounded py-1 pl-5 pr-1 text-left hover:bg-ink/[0.05] ${FOCUS_RING_CLS}`}
className="flex w-full items-start gap-1.5 rounded py-1 pl-5 pr-1 text-left hover:bg-slate-100"
onClick={() => onGoToBookmark(b)}
>
<Bookmark className="mt-0.5 h-3.5 w-3.5 shrink-0 text-warning-ink" />
<Bookmark className="mt-0.5 h-3.5 w-3.5 shrink-0 text-amber-500" />
<span className="min-w-0 flex-1">
<span className="block truncate text-[13px] leading-snug font-medium text-ink">
<span className="block truncate text-[13px] leading-snug font-medium text-slate-700">
{b.title}
</span>
{/* ผู้บันทึกเหมือนแถวผลการค้นหา — ไม่ใส่ "หน้า N"
เพราะหัวกลุ่มของมุมมองนี้บอกหน้าอยู่แล้ว */}
<span className="block text-xs text-ink-soft">
<span className="block text-xs text-slate-500">
{b.userName || "ไม่ทราบชื่อ"}
</span>
{b.note && (
<span className="mt-0.5 block line-clamp-2 whitespace-pre-wrap break-words text-[11px] text-ink-soft">
<span className="mt-0.5 block line-clamp-2 whitespace-pre-wrap break-words text-[11px] text-slate-500">
{b.note}
</span>
)}
</span>
{childCount > 0 && (
<span className="ml-auto shrink-0 self-center rounded-full bg-ink/[0.05] px-1.5 text-[10px] tabular-nums leading-4 text-ink-soft">
<span className="ml-auto shrink-0 self-center rounded-full bg-slate-100 px-1.5 text-[10px] tabular-nums leading-4 text-slate-500">
{childCount}
</span>
)}
@ -1413,29 +1420,29 @@ export default function AnnotationSidebar({
) : visibleSearchResults !== null ? (
<div>
<div className="flex items-center justify-between px-1 pb-1">
<p className="text-[11px] font-semibold tabular-nums text-ink-soft">
<p className="text-[11px] font-semibold tabular-nums text-slate-500">
ผลการค้นหา ({visibleSearchResults.length})
</p>
<button
type="button"
aria-label="ปิดผลการค้นหา"
title="กลับไปรายการหมายเหตุ"
className={`text-ink-soft hover:text-ink ${FOCUS_RING_CLS}`}
className={`text-slate-500 hover:text-slate-600 ${FOCUS_RING_CLS}`}
onClick={() => setSearchResults(null)}
>
<X className="h-3.5 w-3.5" />
</button>
</div>
{visibleSearchResults.length === 0 ? (
<p className="px-1 pb-1 text-xs text-ink-soft">ไม่พบ</p>
<p className="px-1 pb-1 text-xs text-slate-500">ไม่พบ</p>
) : (
// reuse แถวเดิม — li มี data-ann-id ให้ effect scrollIntoView หาเจอ
<ul className="space-y-1">{visibleSearchResults.map(renderAnnotationRow)}</ul>
)}
</div>
) : annotations.length === 0 ? (
<p className="p-4 text-center text-xs text-ink-soft">
ยังไม่มีหมายเหตุ — คลุมข้อความ / วาดเส้น / คอมเมนต์บนหน้าเอกสาร
<p className="p-4 text-center text-xs text-slate-500">
ยังไม่มีหมายเหตุ — คลุมข้อความ / วาดเส้น / คลิกวางคอมเมนต์บนหน้าเอกสาร
<span className="mt-1 block text-[11px]">
คีย์ลัด: H มาร์ก · D วาด · C comment · B คั่นหน้า · Ctrl+Z ย้อน
</span>
@ -1474,7 +1481,7 @@ export default function AnnotationSidebar({
<>
{pendingItems.length > 0 && (
<section>
<p className="px-2 pb-1 pt-2 text-[11px] font-semibold text-ink-soft">
<p className="px-2 pb-1 pt-2 text-[11px] font-semibold text-slate-500">
ค้างดำเนินการ
</p>
<ul className="space-y-1">{pendingItems.map(renderAnnotationRow)}</ul>
@ -1482,7 +1489,7 @@ export default function AnnotationSidebar({
)}
{resolvedItems.length > 0 && (
<section>
<p className="px-2 pb-1 pt-3 text-[11px] font-semibold text-success-ink">
<p className="px-2 pb-1 pt-3 text-[11px] font-semibold text-emerald-500">
จัดการแล้ว
</p>
<ul className="space-y-1">{resolvedItems.map(renderAnnotationRow)}</ul>
@ -1504,35 +1511,35 @@ export default function AnnotationSidebar({
{/* F19/A7: dialog ประวัติ — รายการ version (เวลา + ชนิดการเปลี่ยนแปลง) + ปุ่มกู้คืน */}
{historyFor && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-ink/40 p-4"
className="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/40 p-4"
onClick={() => setHistoryFor(null)}
>
<div
role="dialog"
aria-modal="true"
aria-label="ประวัติการเปลี่ยนแปลง"
className="w-96 max-w-full rounded-lg border border-line bg-white p-4 shadow-xl"
className="w-96 max-w-full rounded-lg border border-slate-200 bg-white p-4 shadow-xl"
onClick={(e) => e.stopPropagation()}
>
<p className="text-sm font-semibold">ประวัติการเปลี่ยนแปลง</p>
<p className="mt-0.5 truncate text-[11px] text-ink-soft">
<p className="mt-0.5 truncate text-[11px] text-slate-500">
{annotationPreviewText(historyFor)}
</p>
{historyError && (
<p className="mt-2 rounded bg-error/10 px-2 py-1 text-[11px] text-error-ink">
<p className="mt-2 rounded bg-red-50 px-2 py-1 text-[11px] text-red-600">
{historyError}
</p>
)}
{versions === null && !historyError && (
<p className="mt-3 text-xs text-ink-soft">กำลังโหลด…</p>
<p className="mt-3 text-xs text-slate-500">กำลังโหลด…</p>
)}
{versions !== null && versions.length === 0 && (
<p className="mt-3 text-xs text-ink-soft">
<p className="mt-3 text-xs text-slate-500">
ยังไม่มีบันทึกการเปลี่ยนแปลง (เกิดขึ้นเมื่อแก้ไขหรือลบรายการนี้)
</p>
)}
{versions !== null && versions.length > 0 && (
<ul className="mt-3 max-h-[60vh] divide-y divide-line/60 overflow-y-auto">
<ul className="mt-3 max-h-[60vh] divide-y divide-slate-100 overflow-y-auto">
{versions.map((v, i) => {
// สิ่งที่เปลี่ยนของรอบนี้ = snapshot ก่อนแก้ (v.data) เทียบ state
// ถัดไป: snapshot ของรอบแก้ที่ใหม่กว่าถัดไป (i-1) หรือค่าปัจจุบัน
@ -1552,11 +1559,11 @@ export default function AnnotationSidebar({
<span className="block text-xs font-medium">
{v.kind === "delete" ? "ถูกลบ" : "ถูกแก้ไข"}
</span>
<span className="block text-[11px] text-ink-soft" suppressHydrationWarning>
<span className="block text-[11px] text-slate-500" suppressHydrationWarning>
{formatThaiDate(v.createdAt, true)} · โดย {v.userName}
</span>
{changes.map((c) => (
<span key={c} className="mt-0.5 block text-[11px] text-ink-soft">
<span key={c} className="mt-0.5 block text-[11px] text-slate-600">
{c}
</span>
))}
@ -1564,7 +1571,7 @@ export default function AnnotationSidebar({
<button
type="button"
disabled={restoringId !== null}
className={`mt-0.5 shrink-0 rounded-md border border-line px-2 py-1 text-[11px] text-ink-soft hover:bg-ink/[0.05] disabled:opacity-40 ${FOCUS_RING_CLS}`}
className={`mt-0.5 shrink-0 rounded border border-slate-300 px-2 py-1 text-[11px] text-slate-600 hover:bg-slate-100 disabled:opacity-40 ${FOCUS_RING_CLS}`}
onClick={() => void handleRestore(v.id)}
>
{restoringId === v.id ? "กำลังกู้คืน…" : "กู้คืน"}
@ -1578,7 +1585,7 @@ export default function AnnotationSidebar({
<button
type="button"
autoFocus
className={`rounded-md px-3 py-1.5 text-ink-soft hover:bg-ink/[0.05] ${FOCUS_RING_CLS}`}
className={`rounded px-3 py-1.5 text-slate-500 hover:bg-slate-100 ${FOCUS_RING_CLS}`}
onClick={() => setHistoryFor(null)}
>
ปิด
@ -1598,7 +1605,7 @@ export default function AnnotationSidebar({
data-tool-menu=""
role="menu"
aria-label={menuFor.kind === "annotation" ? "จัดการหมายเหตุ" : "จัดการหัวข้อ"}
className="w-60 rounded-lg border border-line bg-white p-1.5 shadow-lg"
className="w-60 rounded-lg border border-slate-200 bg-white p-1.5 shadow-lg"
style={{
position: "fixed",
left: menuPos.left,
@ -1612,20 +1619,20 @@ export default function AnnotationSidebar({
>
{menuConfirmDelete ? (
<div className="p-1">
<p className="px-1 text-xs text-ink">
<p className="px-1 text-xs text-slate-700">
ลบ{menuFor.kind === "annotation" ? "รายการ" : "หัวข้อ"}นี้?
</p>
<div className="mt-1.5 flex justify-end gap-1.5">
<button
type="button"
className={`rounded-md px-2 py-1 text-xs text-ink-soft hover:bg-ink/[0.05] ${FOCUS_RING_CLS}`}
className={`rounded px-2 py-1 text-xs text-slate-500 hover:bg-slate-100 ${FOCUS_RING_CLS}`}
onClick={closeRowMenu}
>
ยกเลิก
</button>
<button
type="button"
className={`rounded-md border border-error px-2 py-1 text-xs font-medium text-error-ink hover:bg-error/10 ${FOCUS_RING_CLS}`}
className={`rounded bg-red-600 px-2 py-1 text-xs font-medium text-white hover:bg-red-700 ${FOCUS_RING_CLS}`}
onClick={() => {
// อ่าน kind/id ก่อน close — setMenuFor(null) แล้วเงื่อนไขเดิมหาย
if (menuFor.kind === "annotation") onDeleteAnnotation(menuFor.id);
@ -1671,7 +1678,7 @@ export default function AnnotationSidebar({
)}
{menuAnn.userId === userId && (
<>
<div className="my-1 border-t border-line/60" />
<div className="my-1 border-t border-slate-100" />
<MenuItem
danger
Icon={Trash2}
@ -1706,7 +1713,7 @@ export default function AnnotationSidebar({
)}
{menuBm.userId === userId && (
<>
<div className="my-1 border-t border-line/60" />
<div className="my-1 border-t border-slate-100" />
<MenuItem
danger
Icon={Trash2}

View file

@ -0,0 +1,58 @@
"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-amber-500" },
annotation: { label: "Annotation", Icon: PenLine, iconCls: "text-slate-400" },
} 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="flex w-full items-center gap-1.5 rounded-md py-1.5 text-left text-xs font-semibold text-slate-500 hover:text-slate-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1"
>
{open ? (
<ChevronDown aria-hidden className="h-3.5 w-3.5 shrink-0 text-slate-400" />
) : (
<ChevronRight aria-hidden className="h-3.5 w-3.5 shrink-0 text-slate-400" />
)}
<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-slate-100" />
</button>
</h3>
{open && (
<div id={contentId} className="pl-5">
{children}
</div>
)}
</div>
);
}

View file

@ -1,176 +0,0 @@
"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>
);
}

View file

@ -1,334 +0,0 @@
"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>
)}
</>
);
}

View file

@ -134,7 +134,7 @@ export const files = pgTable(
pdfUrl: text("pdf_url").notNull(),
/** N8: dedupe key เอกสารนำเข้าจากลิงก์ e-Court ("ecourt:<fileId>" / "url:<href>") — NULL = ไฟล์ seed/อัปโหลด */
externalId: text("external_id"),
/** N9: รหัสกลุ่มเอกสารที่เกี่ยวเนื่องกัน เช่น คดีเดียวกัน (จากลิงก์ e-Court `?typeId=` หรือช่องกลุ่มของฟอร์มอัปโหลด N4 — NULL = ไม่มีกลุ่ม)
/** N9: รหัสกลุ่มเอกสารที่เกี่ยวเนื่องกัน เช่น คดีเดียวกัน (จากลิงก์ e-Court `?typeId=` — NULL = ไม่มีกลุ่ม)
* ไม่มีคอลัมน์ชื่อกลุ่ม — แสดงผลด้วยรหัสกลุ่มโดยตรง (ถอด type_name ตามคำขอผู้ใช้ 2026-09-22) */
typeId: text("type_id"),
createdAt: timestamp("created_at", { withTimezone: true })

View file

@ -11,9 +11,8 @@ WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# ประกันว่า pdf.worker.min.mjs ตรงกับเวอร์ชัน pdfjs-dist ใน node_modules เสมอ
# (postinstall รันใน stage deps ซึ่งยังไม่มี public/ ของ repo) — ต้องใช้ตัว patch ด้วย
# ไม่ใช่ copyFileSync เปล่า ๆ ไม่งั้น worker ที่ patch fake-space ไทยถูกเขียนทับด้วยตัวดิบ
RUN node scripts/patch-pdf-worker.mjs
# (postinstall รันใน stage deps ซึ่งยังไม่มี public/ ของ repo)
RUN node -e "require('fs').copyFileSync('node_modules/pdfjs-dist/build/pdf.worker.min.mjs','public/pdf.worker.min.mjs')"
ENV NEXT_TELEMETRY_DISABLED=1
# db/index.ts throw ถ้าไม่มี DATABASE_URL ตอน next build เก็บ page data —
# ใส่ค่า dummy (postgres.js เชื่อมแบบ lazy ไม่มี query ตอน build) ตัวจริงฉีดตอน docker run

View file

@ -1,291 +0,0 @@
# คู่มือการใช้ Documents API (N10)
> สำหรับ: ทีม backend ของ e-Court ที่จะเรียก API นี้แบบ **server-to-server** (ไม่มี browser/หน้า UI)
> ใช้กับโค้ดเวอร์ชัน 2026-09-25 · สเปกฉบับเต็ม/เหตุผลการออกแบบ: [`docs/PLAN-API.md`](./PLAN-API.md)
## 1. API นี้ทำอะไรให้
ดึงข้อมูลที่เกิดในระบบ Judge's Note กลับไปใช้ต่อ โดยถามได้ 2 แบบจาก **endpoint เดียว**:
| ต้องการ | ส่งอะไร | ได้อะไร |
|---|---|---|
| เอกสารเดียว | `fileId` (id ของ e-Court) | `documents` ยาว 1 ชิ้น พร้อม annotation + bookmark ของไฟล์นั้น |
| ทุกเอกสารในกลุ่ม | `typeId` | `documents` ทุกไฟล์ในกลุ่ม เรียงตามวันที่เปิดเข้าระบบ |
| เอกสารเดียว (ยืนยันกลุ่มด้วย) | `fileId` + `typeId` พร้อมกัน | เหลือเฉพาะเอกสารเดียวที่ตรง**ทั้งสอง**เงื่อนไข (AND) — ไม่ตรงคู่ = 404 |
เป็น **read-only** ทั้งหมด — ข้อมูลที่ได้เป็น snapshot ณ ตอนเรียก ไม่มี webhook/event push แจ้งเมื่อมีรายการใหม่ (ต้องยิงซ้ำเองตามความจำเป็น)
## 2. สิ่งที่ต้องมีก่อนเรียก
| สิ่งที่ต้องมี | ค่า |
|---|---|
| URL | `https://<host>/docedit/note/api/external/documents` (แอป serve ใต้ basePath `/docedit/note`) |
| Method | `POST` (อ่านอย่างเดียว แต่ใช้ POST เพราะถามด้วย JSON body — GET ที่แนบ body ไม่เป็นมาตรฐาน) |
| API Key | ขอจากผู้ดูแลระบบ Judge's Note — เก็บเป็น secret ฝั่ง backend e-Court อย่าใส่ใน log/URL |
| Content-Type | `application/json` |
## 3. Authentication
ส่ง API Key ผ่าน header **`x-api-key`** ทุก request:
```http
POST /docedit/note/api/external/documents HTTP/1.1
Host: <host>
x-api-key: <API Key ที่ได้รับ>
content-type: application/json
```
- ไม่ใส่ header หรือ key ผิด → **401** พร้อมข้อความเดียวกันทั้งสองกรณี (`{"error":"API key ไม่ถูกต้อง"}`)
- Key นี้เชื่อถือได้ระดับ **backend e-Court เท่านั้น** — ห้ามส่งต่อให้ browser/แอปฝั่งผู้ใช้ (API ไม่ได้ใส่ CORS headers เพราะออกแบบให้เรียกจาก server เท่านั้น)
- ผลลัพธ์รวมตำแหน่งกล่องปิดพราง (REDACTION) ด้วย — กล่าวคือผู้ถือ key เห็นว่ามีการปิดพรางตรงไหนของหน้า (เห็นเฉพาะพิกัด เนื้อหาใต้กล่องไม่ได้)
## 4. Request body
```jsonc
{
"fileId": "<fileId ของ e-Court>", // ถามเอกสารเดียว
"typeId": "<typeId ของกลุ่ม>", // ถามทุกเอกสารในกลุ่ม
"filter": { // optional ทั้งก้อน
"type": "bookmarks", // "bookmarks" | "annotations" — ไม่ส่ง = ทั้งสอง
"userId": "<userId ของระบบนี้>", // หรือ array — กรองตามเจ้าของรายการ
"annotationTypes": ["COMMENT", "HIGHLIGHT"] // หรือค่าเดียว — กรองชนิดของ annotation
}
}
```
### 4.1 field ระดับบนสุด
| field | ชนิด | ค่าเริ่มต้น | คำอธิบาย |
|---|---|---|---|
| `fileId` | string | — | id ของเอกสาร **ตามที่ e-Court ส่งมาในลิงก์** (`?fileId=`) ไม่ใช่ UUID ของระบบนี้ |
| `typeId` | string | — | รหัสกลุ่มเอกสาร ตามที่ส่งมาในลิงก์ (`?typeId=`) |
| `filter` | object | ไม่กรอง | ดู §4.2 |
กติกา:
- ต้องมี `fileId` **หรือ** `typeId` อย่างน้อยหนึ่งอย่าง — ขาดทั้งคู่ = 400
- ส่ง**ทั้งคู่**ได้ = AND กรองเฉพาะเอกสารเดียวที่ตรงทั้งสองเงื่อนไข (เช่น กันเข้าผิดกลุ่ม) — ไม่ตรงคู่ = 404
- ค่า string จะถูก trim — ส่งมาเป็นค่าว่าง/whitespace ล้วน = ถือว่าไม่ได้ส่ง field นั้น
- รับเฉพาะ 3 field นี้ — field อื่น (พิมพ์ผิด เช่น `fileID`, `filters`) = 400 บอกชื่อ field ที่ไม่รู้จัก
### 4.2 `filter`
| field | ชนิด | ค่าเริ่มต้น | คำอธิบาย |
|---|---|---|---|
| `type` | `"bookmarks"` \| `"annotations"` | ทั้งสอง | เลือกเอาเฉพาะส่วน — ส่วนที่ไม่เลือกคืนเป็น array ว่าง ไม่ใช่หายไปจาก response |
| `userId` | string หรือ array ของ string | ทุกคน | กรองตาม**เจ้าของรายการ**ทั้ง annotations และ bookmarks (`userId` ระบบนี้ = `u_userid` ที่เห็นใน DTO) — ไม่กรองตามผู้ตอบ reply (reply ตามมาครบกับ annotation ที่ผ่านตัวกรอง) |
| `annotationTypes` | string หรือ array ของ enum | ทุกชนิด | กรองชนิดของ annotation — มีผลเฉพาะเมื่อผลลัพธ์รวม annotations (เลือก `type:"bookmarks"` อย่างเดียว = เพิกเฉยเงียบ ๆ ไม่ใช่ 400) |
ค่า enum ของ `annotationTypes`: `HIGHLIGHT` `DRAW` `COMMENT` `SHAPE` `REDACTION` `TEXTBOX` `STAMP` — ค่าอื่น = 400
`filter` รับเฉพาะ 3 field นี้เช่นกัน (`type`/`userId`/`annotationTypes`) — field อื่น = 400
## 5. Response 200
รูปเดียวเสมอ (ไม่ว่าถามแบบไหน): `{ "documents": [ … ] }` — `documents` เป็น array ยาว 1 ตอนถามด้วย `fileId`, หลายชิ้นตอนถามด้วย `typeId` (กลุ่มว่าง = array ว่าง ไม่ใช่ error)
### 5.1 field ของแต่ละเอกสาร
| field | ชนิด | คำอธิบาย |
|---|---|---|
| `id` | string | UUID ของไฟล์ในระบบนี้ (ใช้ประกอบ `url` — ไม่ต้องจำ) |
| `externalFileId` | string \| null | `fileId` ของ e-Court ตามที่ส่งมาในลิงก์ — **null** ได้: ไฟล์ในกลุ่มที่มาโดยไม่ผ่าน fileId (เข้าจากลิงก์ไม่มี fileId หรืออัปโหลดในระบบนี้) |
| `title` | string | ชื่อเอกสาร |
| `typeId` | string \| null | กลุ่ม — null = ไม่มีกลุ่ม |
| `createdAt` | string | ISO 8601 (วันที่เปิดเข้าระบบครั้งแรก) |
| `annotations` | array | ดู §5.2 — เรียง `(pageIndex, createdAt)` |
| `bookmarks` | array | ดู §5.3 — เรียง `pageIndex → yOffset → createdAt` |
ลำดับ `documents` เมื่อถามด้วย `typeId`: ตาม `createdAt` ของไฟล์ (เก่า → ใหม่)
### 5.2 annotation แต่ละรายการ
| field | ชนิด | คำอธิบาย |
|---|---|---|
| `id` | string | UUID ของรายการ |
| `fileId` | string | UUID ของไฟล์ |
| `pageIndex` | number | **0-based** — หน้า 1 ของเอกสาร = `0` |
| `userId` / `userName` | string | ผู้สร้างรายการ (`userName` = `u_userlogin` ของ e-Court) |
| `type` | string | หนึ่งใน 7 enum ด้านบน |
| `data` | object | รายละเอียดตามชนิด — ดูตาราง §5.4 |
| `createdAt` / `updatedAt` | string | ISO 8601 |
| `resolved` | boolean | สถานะ "จัดการแล้ว" (F10) |
| `resolvedAt` | string \| null | ISO 8601 / null |
| `url` | string | deep link กลับมาหน้าอ่านเอกสารของระบบนี้ ไปตรงรายการนี้ (`…/annotate/<id>?ann=<annotationId>`) — เป็น **URL เต็ม** ใช้ได้ทันที |
| `replies` | array | คำตอบในเธรดของ comment — ดู §5.5 — เรียง `createdAt` |
รวม annotation **ทุกสถานะ** (ดู `resolved`) และ**ทุกผู้ใช้** (เว้นแต่กรองด้วย `filter.userId`)
### 5.3 bookmark แต่ละรายการ
| field | ชนิด | คำอธิบาย |
|---|---|---|
| `id` | string | UUID ของรายการ |
| `fileId` | string | UUID ของไฟล์ |
| `pageIndex` | number | **0-based** เช่นเดียวกับ annotation |
| `userId` / `userName` | string | ผู้สร้าง (`userName` อาจเป็น `""` สำหรับรายการเก่า = ไม่ทราบชื่อ) |
| `title` | string | ชื่อหัวข้อ bookmark |
| `parentId` | string \| null | โครงสร้าง 3 ระดับ — null = ระดับบนสุด, มีค่า = เป็นหัวข้อย่อยของ bookmark นั้น |
| `note` | string \| null | โน้ตย่อยของ bookmark |
| `yOffset` | number | ตำแหน่งบนหน้า เป็น % ของความสูงหน้า (0–100) |
| `createdAt` | string | ISO 8601 |
| `url` | string | deep link กลับมาหน้าอ่าน (`…/annotate/<id>?page=<หน้า>` เป็น **1-based** — `?page=1` = `pageIndex` 0) |
### 5.4 field `data` ของ annotation ตามชนิด
พิกัดทุกค่าเป็น **% ของขนาดหน้า** (ไม่ใช่ pixel — วาดทับตำแหน่งเดิมได้ทุกขนาดจอ): `Rect` = `{x, y, width, height}`, `Point` = `{x, y}`
| `type` | field ใน `data` | หมายเหตุ |
|---|---|---|
| `HIGHLIGHT` | `text`, `color`, `rects[]`, `style?`, `opacity?`, `note?` | `text` = ข้อความที่คลุม, `rects` = 1 rect ต่อบรรทัด, `style` = `highlight`\|`underline`\|`strikethrough`\|`squiggle` (ไม่มี = ไฮไลต์ทึบ) |
| `DRAW` | `strokeColor`, `strokeWidth`, `paths[]`, `pen?`, `widths?`, `opacity?`, `note?` | เส้นวาดมือ — `strokeWidth` เป็น % ของความกว้างหน้า, `pen` = `pencil`\|`highlighter`, `widths` = ความหนาต่อจุดจากสไตลัส (ยาวเท่า `paths`) |
| `COMMENT` | `position`, `message` | sticky note — `position` = ตำแหน่ง pin |
| `SHAPE` | `shape`, `strokeColor`, `strokeWidth`, `note?` + `rect` (rect/ellipse) **หรือ** `from`/`to` (line/arrow) | `shape` = `rect`\|`ellipse`\|`line`\|`arrow` |
| `REDACTION` | `rect`, `note?` | กล่องปิดพราง — เห็นเฉพาะตำแหน่ง ไม่มีเนื้อหาใต้กล่องใน DB |
| `TEXTBOX` | `position`, `text`, `color`, `fontSize`, `bold?`, `opacity?`, `align?`, `segments?`, `width?`, `note?` | `position` = มุมซ้ายบนของกล่อง, `fontSize` = % ของความกว้างหน้า, `text` เก็บข้อความรวมเสมอ (แม้มี `segments` ตัวหนาเฉพาะส่วน) |
| `STAMP` | `label`, `position`, `scale`, `color?`, `note?` | ตรายางข้อความ preset — `scale` = ความกว้างกรอบ % ของความกว้างหน้า |
`note` (optional ทุกชนิด) = โน้ตคำอธิบายของรายการ เช่น เหตุผลที่ไฮไลต์
### 5.5 reply แต่ละรายการ (ใน `replies` ของ annotation)
| field | ชนิด | คำอธิบาย |
|---|---|---|
| `id` | string | UUID ของคำตอบ |
| `annotationId` | string | UUID ของ annotation แม่ |
| `userId` / `userName` | string | ผู้ตอบ |
| `message` | string | ข้อความ |
| `createdAt` | string | ISO 8601 |
## 6. Error
error ทุกสถานะเป็น JSON รูปเดียวกัน: `{ "error": "<ข้อความไทย>" }`
| สถานะ | เมื่อไร | ข้อความ |
|---|---|---|
| 400 | body ไม่ใช่ JSON ที่ถูกต้อง | `body ต้องเป็น JSON ที่ถูกต้อง` |
| 400 | body ไม่ใช่ object | `body ต้องเป็น JSON object` |
| 400 | มี field ที่ไม่รู้จัก | `ไม่รู้จัก field '<field>' (รับเฉพาะ fileId, typeId, filter)` |
| 400 | `fileId`/`typeId` ไม่ใช่ string | `fileId ต้องเป็น string` / `typeId ต้องเป็น string` |
| 400 | ไม่มีทั้ง `fileId` และ `typeId` | `ต้องระบุ fileId หรือ typeId อย่างน้อยหนึ่งอย่าง` |
| 400 | `filter` ไม่ใช่ object / มี field อื่น | `filter ต้องเป็น object เช่น { "type": "bookmarks" }` / `filter รับเฉพาะ field 'type', 'userId', 'annotationTypes'` |
| 400 | `filter.type` ค่าไม่รู้จัก (รวมเอกพจน์ `annotation`/`bookmark`) | `filter.type รับค่า bookmarks หรือ annotations เท่านั้น` |
| 400 | `filter.userId` ไม่ใช่ string/array ของ string | `filter.userId ต้องเป็น string หรือ array ของ string` |
| 400 | `filter.annotationTypes` ค่านอก enum | `filter.annotationTypes รับค่า HIGHLIGHT, DRAW, COMMENT, SHAPE, REDACTION, TEXTBOX, STAMP เท่านั้น` |
| 401 | ไม่ใส่ `x-api-key` หรือ key ผิด | `API key ไม่ถูกต้อง` (ข้อความเดียวกันกัน enumeration) |
| 401 | ฝั่ง server ยังไม่ตั้ง key | `API ยังไม่ได้ตั้งค่าคีย์` |
| 404 | ถาม `fileId` ที่ไม่เคยเปิดในระบบ | `ไม่พบเอกสารนี้ในระบบ (ยังไม่เคยเปิด)` |
| 404 | ส่งคู่ `fileId`+`typeId` แต่ไม่ตรงคู่ | `ไม่พบเอกสารนี้ในระบบ (ยังไม่เคยเปิด หรือไม่ได้อยู่ในกลุ่ม typeId ที่ระบุ)` |
| 500 | ระบบนี้มีปัญหาฐานข้อมูล ณ ตอนนั้น | `เกิดข้อผิดพลาดของระบบ โปรดลองใหม่อีกครั้ง` (รายละเอียดอยู่ใน log ฝั่ง server อย่างเดียว) |
การแยก "ไม่มีข้อมูล" ออกจาก "ผิดพลาด":
- เอกสารเคยเปิดแต่ไม่มีรายการ → **200** พร้อม `annotations: []` / `bookmarks: []`
- กลุ่มไม่มีเอกสารเลย → **200** พร้อม `documents: []`
- `filter.userId` ไม่ตรงกับผู้ใช้ใดในระบบ → **200** รายการว่าง (ไม่ใช่ 400)
## 7. ตัวอย่างการเรียก
### 7.1 เอกสารเดียว เอาทุกอย่าง
```bash
curl -X POST -H "x-api-key: $EXTERNAL_API_KEY" -H "content-type: application/json" \
-d '{"fileId":"1234"}' \
"http://localhost:3000/docedit/note/api/external/documents"
```
Response (ตัดย่อ):
```jsonc
{
"documents": [
{
"id": "3f2a9c1e-8b7d-4e2a-9f10-11a2b3c4d5e6",
"externalFileId": "1234",
"title": "คำพิพากษา ผบ.123/2569",
"typeId": "CASE-2569-0001",
"createdAt": "2026-09-24T09:15:33.120Z",
"annotations": [
{
"id": "7c1d2e3f-4a5b-6c7d-8e9f-0a1b2c3d4e5f",
"fileId": "3f2a9c1e-8b7d-4e2a-9f10-11a2b3c4d5e6",
"pageIndex": 0,
"userId": "u_1234",
"userName": "somchai.j",
"type": "COMMENT",
"data": { "position": { "x": 41.2, "y": 37.8 }, "message": "ข้อเท็จจริงตรงนี้ขัดกับคำให้การ ร.2" },
"createdAt": "2026-09-24T10:02:11.530Z",
"updatedAt": "2026-09-24T10:02:11.530Z",
"resolved": false,
"resolvedAt": null,
"url": "http://localhost:3000/docedit/note/annotate/3f2a9c1e-…?ann=7c1d2e3f-…",
"replies": [
{
"id": "9e8f0a1b-2c3d-4e5f-6a7b-8c9d0e1f2a3b",
"annotationId": "7c1d2e3f-4a5b-6c7d-8e9f-0a1b2c3d4e5f",
"userId": "u_2200",
"userName": "somsri.k",
"message": "บันทึกไว้ก่อน ค่อยถามผู้เบิกความวันพรุ่งนี้",
"createdAt": "2026-09-25T08:44:02.310Z"
}
]
}
],
"bookmarks": [
{
"id": "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
"fileId": "3f2a9c1e-8b7d-4e2a-9f10-11a2b3c4d5e6",
"pageIndex": 2,
"userId": "u_1234",
"userName": "somchai.j",
"title": "บทวินิจฉัย",
"parentId": null,
"note": null,
"yOffset": 12.4,
"createdAt": "2026-09-24T10:05:47.900Z",
"url": "http://localhost:3000/docedit/note/annotate/3f2a9c1e-…?page=3"
}
]
}
]
}
```
### 7.2 เอกสารเดียว เฉพาะ bookmark ของผู้ใช้หนึ่ง
```bash
curl -X POST -H "x-api-key: $EXTERNAL_API_KEY" -H "content-type: application/json" \
-d '{"fileId":"1234","filter":{"type":"bookmarks","userId":"u_1234"}}' \
"http://localhost:3000/docedit/note/api/external/documents"
```
(`userId` เป็นค่าของระบบนี้ — เก็บ collect จาก response ก่อนหน้า หรือดึงมาทั้งหมดแล้วกรองฝั่งผู้เรียกเองก็ได้)
### 7.3 ทุกเอกสารในกลุ่ม เฉพาะ COMMENT และ HIGHLIGHT
```bash
curl -X POST -H "x-api-key: $EXTERNAL_API_KEY" -H "content-type: application/json" \
-d '{"typeId":"CASE-2569-0001","filter":{"annotationTypes":["COMMENT","HIGHLIGHT"]}}' \
"http://localhost:3000/docedit/note/api/external/documents"
```
ได้ `documents` หลายชิ้นเรียงตามวันที่เปิดเข้าระบบ — ไฟล์ที่มาโดยไม่ผ่าน fileId (อัปโหลดในระบบนี้) ก็รวมอยู่ในกลุ่ม โดย `externalFileId: null`
### 7.4 fileId + typeId พร้อมกัน (AND)
```bash
curl -X POST -H "x-api-key: $EXTERNAL_API_KEY" -H "content-type: application/json" \
-d '{"fileId":"1234","typeId":"CASE-2569-0001"}' \
"http://localhost:3000/docedit/note/api/external/documents"
```
ได้เฉพาะเอกสารเดียวที่ตรงทั้งคู่ — ไฟล์นี้ไม่เคยเปิดหรืออยู่คนละกลุ่มกับ typeId ที่ระบุ = 404
## 8. ข้อควรรู้/ข้อจำกัด
1. **เอกสารต้องเคยเปิดผ่านลิงก์ e-Court ก่อนอย่างน้อยหนึ่งครั้ง** — ระบบนี้ลงทะเบียนเอกสาร (metadata-only) เมื่อมีผู้เปิดจากลิงก์ `?fileId=&fileUrl=&typeId=` ครั้งแรกเท่านั้น ยังไม่เคยเปิด = 404 (ไม่ใช่ error ฝั่ง e-Court) · ลิงก์ที่ไม่มี `fileId` (ส่งแต่ `fileUrl`) จะค้นด้วยวิธีนี้ไม่ได้
2. **`pageIndex` เป็น 0-based ส่วน `?page=` ใน url เป็น 1-based** — `pageIndex: 0` กับ `?page=1` คือหน้าเดียวกัน
3. **`url` อ่าน origin จาก request ของผู้เรียก** — ถ้าระบบนี้ deploy หลัง reverse proxy ต้องตั้งให้ proxy ส่งต่อ `x-forwarded-proto`/`x-forwarded-host` ให้ถูกต้อง ไม่งั้นลิงก์จะชี้ origin ของ proxy แทน public URL
4. **timestamp เป็น ISO 8601 (UTC) ทุกจุด**
5. ไม่มี pagination/rate limiting/caching — กลุ่มใหญ่ได้ response ยาวตามจำนวนรายการจริง
6. ไม่มี API ฝั่งเขียน (สร้าง/แก้ annotation ผ่าน API) และไม่มีการดึงไฟล์ PDF/bytes ผ่าน endpoint นี้ — ข้อมูลที่ได้มีเฉพาะ metadata + พิกัด + ข้อความของ annotation/bookmark

View file

@ -1,78 +0,0 @@
# Genesis
## Overview
An editorial precision interface for **Judge's Note** — a Thai court PDF annotation app (judes-note) used daily by judges on desktops and iPads. The aesthetic is quietly confident — bold display typography, generous spacing, and gallery-frame card surfaces. The mood is professional and modern without being sterile. High information density balanced by breathing room. (Text is primarily Thai; Latin display type carries the brand voice in headings and numbers.)
## Colors
- **Primary** (rgb(0 51 161 / <alpha-value>) / #0033A1): CTAs, active states, links, focus indicators, interactive highlights — royal blue
- **Primary Hover** (#002780): Darker blue for hover states on primary elements
- **Neutral** (#9C9C9C): Placeholders, disabled states — decorative metadata only (see `ink` families for readable text)
- **Background** (#FAFAFA): Page background, light warm gray
- **Surface** (#FFFFFF): Cards, panels, modals, nav backdrop
- **Text Primary** (#0A0A0A): Headings, body text, primary labels — near-black
- **Text Secondary** (#6B6B6B): Descriptions, metadata, secondary labels — passes 4.5:1 on white; use for meaningful small text (dates, counts, authors)
- **Border** (#E8E8EC): Card borders, dividers, input borders — subtle and recessive
- **Success** (#10B981): Positive status — borders and tinted backgrounds (`success/10`–`/15`)
- **Warning** (#F59E0B): Pending status, caution — borders and tinted backgrounds (`warning/10`–`/15`)
- **Error** (#EF4444): Destructive actions, validation errors — borders and tinted backgrounds (`error/10`–`/15`)
- **Success Ink** (#047857), **Warning Ink** (#B45309), **Error Ink** (#B91C1C): Text- and icon-safe variants of the status colors (≥5:1 on white). Any status *text or icon* on a light surface uses the `-ink` token; the base 500 tokens are for fills, borders, and tints only. Never reach for raw Tailwind `amber-*`/`emerald-*`/`red-*` shades.
Note: the template's Secondary green (#20970B) has no role in this app and is not registered as a token. Code font (JetBrains Mono) is likewise unused — no code UI in this app.
## Typography
- **Display Font**: General Sans — loaded from Fontshare
- **Body Font**: DM Sans — loaded from Google Fonts
- **Code Font**: JetBrains Mono — loaded from Google Fonts
Display and heading text uses General Sans at bold weight with tight letter spacing (-0.03em to -0.04em). Body and UI text uses DM Sans at regular and medium weights. The contrast between the geometric display font and the humanist body font creates a refined editorial feel. Code blocks, API keys, and CLI commands use JetBrains Mono at regular weight.
Type scale: Display 72px, Headline 60px, Section heading 32px, Subhead 24px, Body 15px, Small 13px, Caption 12px, Overline 11px uppercase.
## Elevation
This design uses minimal shadows. Cards rest flat with a 1px border and gain a subtle shadow on hover (0 8px 30px rgba(0,0,0,0.08)) combined with a -2px vertical lift. Primary buttons gain a tinted glow shadow on hover (0 4px 12px rgba(0,51,161,0.35)). The nav uses backdrop-blur rather than a shadow to convey elevation. Dropdowns and popovers use shadow-lg. Focus indicators use a 2px solid royal-blue outline with a 2px offset (≥3:1 against adjacent surfaces, WCAG 1.4.11/2.4.13) — a translucent halo alone is too faint to qualify.
## Components
- **Buttons**: Primary uses blue fill with white text, 6px radius, medium weight. Secondary uses transparent bg with 1px border, same radius. Ghost has no border or bg, just text color change on hover. Destructive uses red text with red border. All buttons shift up 1px on hover. Sizes: small (32px), medium (38px), large (44px).
- **Cards**: White surface, 1px subtle border, 12px radius, overflow hidden. Content cards (file cards, form cards) stack a content area with title, metadata, and actions. Hover lifts the card 2px and increases shadow. Transition duration 200ms.
- **Inputs**: 1px subtle border, surface background, 6px radius, 8px vertical and 14px horizontal padding, 14px font size (16px on touch devices — iOS zooms on focus below 16px). Focus: border turns blue with the 2px outlined focus indicator. Error: border turns red. Placeholder text uses muted color.
- **Chips**: Tag chips use rounded-full (pill shape), gray-100 background, gray-600 text, 4px vertical and 12px horizontal padding, 12px font size. Active state: blue background with white text. Status chips follow the same shape but use semantic colors (green for published, yellow for pending, red for rejected).
- **Lists**: Stacked rows with 1px dividers between items. Each row is flex with space-between, 12px vertical and 16px horizontal padding. Hover: subtle background change.
- **Checkboxes**: 20px size, rounded-full, gray-200 unchecked, blue checked with white checkmark. Used as toggle switches for preferences.
- **Tooltips**: Native browser tooltips via title attributes. No custom tooltip component currently.
- **Navigation**: Sticky top nav with backdrop-blur, 56px height, 1px bottom border. Logo left, links center (desktop) or hamburger drawer (mobile), user avatar dropdown right. Nav links: 14px medium weight, hover shows bg-alt background.
- **Search**: Global search triggered by ⌘K. Rendered as a rounded-xl bar with magnifying glass icon and keyboard shortcut badge.
## Spacing
- Base unit: 4px
- Scale: 4, 8, 12, 16, 20, 24, 32, 40, 48, 64, 80, 96px
- Component padding: small 8×12, medium 10×16, large 12×24
- Section spacing: 32px mobile, 48px tablet, 64px desktop
- Container max width: 1280px with 24px horizontal padding
- Card grid gap: 20-24px
## Border Radius
- 4px: Tags, chips, badges, inline code — and menu/list rows inside dropdowns, panels, and popovers (a row belongs to its container, not a standalone button)
- 6px: Buttons, inputs, selects — including small action buttons inside popups, composers, and dialogs; a cancel/save pair must always share the same radius
- 8px: Metadata cards, dropdowns, panels
- 12px: Kit preview cards, search bar, featured sections
- 9999px: Avatars, status dots, pill badges
## Do's and Don'ts
- Do use blue (rgb(0 51 161 / <alpha-value>)) only for interactive elements — never for decoration or static text. Exceptions (brand marks, agreed 2026-09-23): the JN logo tile, and the blue header band of the summary page. Static numbers (e.g. folio numbers) use `ink`.
- Do maintain the 4px spacing grid for all padding, margins, and gaps
- Do use General Sans for headings and DM Sans for body — never swap them
- Do keep kit cards at 12px radius and buttons/inputs at 6px — don't mix these values
- Do provide sufficient contrast in both light and dark modes — test both
- Do use the `-ink` status tokens for status text/icons and the 500 tokens for fills, borders, and tints
- Don't use raw Tailwind palette shades (slate, amber, emerald, red…) — only the tokens registered in tailwind.config.ts
- Don't use pure black (#000000) or pure white (#FFFFFF) for text — use the defined palette values
- Don't add decorative gradients or illustrations — the interactive dot grid is the only decorative element
- Don't use shadows on static elements — reserve shadow elevation for hover and focus states (floating layers and document pins excepted — see App-specific exceptions)
- Don't use more than two font weights on a single screen (dense working-surface screens such as the document viewer are excepted — see App-specific exceptions)
- Don't place more than one primary (filled blue) button in the same view section
## App-specific exceptions
- The document viewer header is a solid white bar (not the nav's backdrop-blur): it hosts a dense tool row where blur would smear the scrolled document beneath. Only the site nav (AppNav) blurs.
- Comment pins on the document page carry a small static shadow: they are floating markers that must lift off the paper layer, in the same spirit as dropdown/popover elevation (agreed 2026-09-24).
- The document viewer (working surface) uses three font weights on one screen — 400 body, 500 labels/buttons, 600 panel/file titles. The two-weight rule still holds for content pages (agreed 2026-09-24).
- The text-selection preview in the viewer (drag-preview tint of the select/comment/textbox/stamp tools) is a translucent tint of primary (`rgb(0 51 161 / 0.3)`), keeping every blue on the screen in the brand hue (agreed 2026-09-24).

View file

@ -1,6 +1,6 @@
# แผน N10 — API ดึงข้อมูลเอกสาร/กลุ่มให้ระบบภายนอก (API Key)
> สถานะ: **implement แล้ว 2026-09-24** (รันผ่าน `/implement-feat N10`) — เสนอ 2026-09-22 ตามคำขอผู้ใช้: "เพิ่ม API ดึงข้อมูลเอกสารทีละรายการ หรือทุกเอกสารที่อยู่ในกลุ่มเดียวกัน เอาทั้งหมดหรือเฉพาะ bookmark ใช้ API Key ในการ Authentication"
> สถานะ: **ร่างแผน (ยังไม่ implement)** — เสนอ 2026-09-22 ตามคำขอผู้ใช้: "เพิ่ม API ดึงข้อมูลเอกสารทีละรายการ หรือทุกเอกสารที่อยู่ในกลุ่มเดียวกัน เอาทั้งหมดหรือเฉพาะ bookmark ใช้ API Key ในการ Authentication"
> Dependency: **N8** (`docs/PLAN-ECOURT.md` — `external_id` สำหรับค้นด้วย fileId ของ e-Court) + **N9** (`docs/PLAN-DOCSET.md` — `files.type_id` สำหรับค้นตามกลุ่ม)
> ตาม governance ของโปรเจกต์: implement ต้องเขียนสเปกลง `docs/PLAN-FEAT.md` §7 (**N10**) + ขยาย whitelist ใน `CLAUDE.md` ก่อน — เป็น task แรกของแผน แล้วรันผ่าน `/implement-feat N10`
@ -10,66 +10,55 @@
- ระบบเราเป็นฝ่ายถูกเรียกเท่านั้นในแผนนี้ (read-only) — ทิศทาง "เราเรียก e-Court" ไม่มี
- `middleware.ts` ยกเว้น `/api/*` อยู่แล้ว (ไม่บังคับ login แบบ oacCase) — จึงตรวจ API Key ในตัว route เอง ไม่ชนกับระบบ login เดิม
## การตัดสินใจ (ยืนยันกับผู้ใช้แล้ว 2026-09-22 · ปรับรอบสอง 2026-09-24 · รอบสาม 2026-09-25 · รอบสี่ 2026-09-25 · รอบห้า 2026-09-25 · รอบหก 2026-09-25 · รอบเจ็ด 2026-09-25 · รอบแปด 2026-09-25 · รอบเก้า 2026-09-25)
## การตัดสินใจ (ยืนยันกับผู้ใช้แล้ว 2026-09-22)
1. **API Key เดียวใน env** `EXTERNAL_API_KEY` — ผู้เรียกคือ backend e-Court ตัวเดียว เปลี่ยน key = แก้ env แล้ว redeploy (ไม่ทำตาราง key ใน DB / UI จัดการ key)
2. **ระบุเอกสารด้วย fileId ของ e-Court** (ค้น `external_id = "ecourt:<fileId>"`) — ผู้เรียกรู้จัก id ของตัวเอง ไม่ต้องรู้ UUID ของเรา
3. **POST เส้นเดียว + JSON body** `POST /api/external/documents` — รอบแรกเป็น 2 endpoints แบบ GET query-param (ตาม pattern ของบ้าน), **รอบสอง 2026-09-24 ตามคำขอผู้ใช้รวมเป็นเส้นเดียวถามด้วย body** `{fileId | typeId, filter}` — GET ที่แนบ body ไม่ standard จึงใช้ POST (ยัง read-only เหมือนเดิม) · response รูปเดียวเสมอ (`{documents: [...]}`) ผู้เรียกจัดการ contract เดียว · **รอบสาม 2026-09-25 ตามคำขอผู้ใช้: ส่ง `fileId`+`typeId` พร้อมกันได้ = รวมผล OR** (เดิม 400 กำกวม) และ**ยุบชั้น `document` ใน response** ให้ field เอกสารอยู่ระดับเดียวกับ `annotations`/`bookmarks` (ดูง่ายขึ้น) · **รอบสี่ 2026-09-25 ตามคำขอผู้ใช้: เปลี่ยนชื่อ endpoint เป็น `documents`** (เดิม `query` — ตั้งชื่อตาม resource ที่คืน ตรงกับ response `{documents}`) · **รอบห้า 2026-09-25 ตามคำขอผู้ใช้: ส่ง `fileId`+`typeId` พร้อมกัน = AND** เหลือเฉพาะเอกสารเดียวที่ตรงทั้งสองเงื่อนไข (แทน OR รวมผลของรอบสาม — ทดสอบจริงแล้ว OR คืนทั้งกลุ่ม ไม่ใช่สิ่งที่ผู้เรียกต้องการ) · ยังไม่มีผู้เรียกจริงจึงแทนที่ขาด (ถอด GET เดิมทั้งสองออก)
4. **`filter.type` เลือกส่วนที่เอา**: `"bookmarks"` | `"annotations"` — ไม่ส่ง `filter` หรือ `type` เป็นค่าว่าง = ทั้งสอง ("เอาทั้งหมด" — กฎเดียวกับ fileId ว่าง = ไม่ได้ส่ง, คำขอผู้ใช้ 2026-09-24) — ส่วนที่ไม่เลือกคืนเป็น array ว่าง (contract คงที่ ง่ายต่อผู้บริโภค) · **รอบหก 2026-09-25 เพิ่มมิติตัวกรอง**: `filter.userId` (กรองตามเจ้าของรายการ) + `filter.annotationTypes` (ชนิดของ annotation — เดิมชื่อ `types` เปลี่ยนรอบแปด) — ดู D2
3. **2 endpoints ตาม pattern query-param ของบ้าน** (เหมือน `/api/annotations/export?fileId=`) ไม่ใช้ path parameter
4. **`include` เลือกส่วนที่เอา**: `annotations`, `bookmarks` คั่น comma — default = ทั้งสอง ("เอาทั้งหมด"), `include=bookmarks` = เฉพาะ bookmark — ส่วนที่ไม่เลือกคืนเป็น array ว่าง (contract คงที่ ง่ายต่อผู้บริโภค)
## Design
### D1 — Auth (`lib/external-api-auth.ts` ใหม่)
- `requireExternalApiKey(req: Request): Response | null` — คืน `null` เมื่อผ่าน / คืน `Response` 401 เมื่อไม่ผ่าน (body JSON `{ "error": "<ข้อความไทย>" }` + `content-type: application/json; charset=utf-8`)
- อ่าน header **`x-api-key`** → เทียบกับ env `EXTERNAL_API_KEY` ด้วย SHA-256 ทั้งสองค่าแล้ว `crypto.timingSafeEqual` (ทั้งสองยาวเท่ากันหลัง hash — กัน timing + length leak; ค่า env ตั้งให้ยาวและสุ่ม เช่น 32+ ตัวอักษร; เทียบกับค่า **trim แล้ว** — กัน trailing newline จาก K8s secret / docker env_file ทำ 401 ทั้งระบบทั้งที่ผู้เรียกส่ง key ถูก — code review 2026-09-24)
- อ่าน header **`x-api-key`** → เทียบกับ env `EXTERNAL_API_KEY` ด้วย SHA-256 ทั้งสองค่าแล้ว `crypto.timingSafeEqual` (ทั้งสองยาวเท่ากันหลัง hash — กัน timing + length leak; ค่า env ตั้งให้ยาวและสุ่ม เช่น 32+ ตัวอักษร)
- **env ไม่ตั้งหรือว่าง = ปฏิเสธทุก request** (fail closed — 401 "API ยังไม่ได้ตั้งค่าคีย์") ไม่มี mode เปิดฟรี
- ห้าม log ค่า key / header ดิบ (error log บอกแค่ "auth failed")
### D2 — Endpoint เดียว (POST + JSON body)
### D2 — Endpoints (2 routes ใหม่)
URL เต็มฝั่งผู้เรียกอยู่ใต้ basePath: `https://<host>/docedit/note/api/external/documents`
URL เต็มฝั่งผู้เรียกอยู่ใต้ basePath: `https://<host>/docedit/note/api/external/...`
**`POST /api/external/documents`** — header `x-api-key` + `content-type: application/json`, body:
**1) เอกสารเดียว** — `GET /api/external/document?fileId=<fileId ของ e-Court>&include=annotations,bookmarks`
```jsonc
{
"fileId": "<fileId ของ e-Court>", // ถามเอกสารเดียว — ส่งพร้อม typeId ได้ = กรองเฉพาะเอกสารที่ตรงทั้งสองเงื่อนไข (AND)
"typeId": "<typeId>", // ถามทุกเอกสารในกลุ่ม N9
"filter": {
"type": "bookmarks", // optional — "bookmarks" | "annotations", ไม่ส่ง = ทั้งสอง
"userId": "<userId ของเรา>", // optional — string หรือ array, กรองตามเจ้าของรายการ (ทั้ง annotations/bookmarks)
"annotationTypes": ["COMMENT", "HIGHLIGHT"], // optional — ค่าจาก enum annotation_type, มีผลเฉพาะ annotations
}
"document": { // null = 404 (ไม่เคยเปิดในระบบ)
"id": "<uuid ของเรา>", // ใช้อ้างต่อใน DTO ข้างล่าง
"externalFileId": "<fileId ของ e-Court>",
"title": "…",
"typeId": "…", // null ได้ (ไม่มีกลุ่ม)
"createdAt": "<ISO string>"
},
"annotations": [ AnnotationDTO ตาม db/schema.ts พร้อม "replies": ReplyDTO[] ต่อรายการ ],
"bookmarks": [ BookmarkDTO ตาม db/schema.ts ]
}
```
Response 200 เป็นรูปเดียวเสมอ (bundle ยุบชั้น — ไม่มี object `document` ซ้อน, 2026-09-25):
**2) ทุกเอกสารในกลุ่ม** — `GET /api/external/documents?typeId=<typeId>&include=…`
```jsonc
{
"documents": [ // ถามด้วย fileId = ยาว 1 ชิ้น (ไม่เคยเปิดในระบบ = 404) · ถามด้วย typeId = ทุกไฟล์ในกลุ่มเรียง files.createdAt (กลุ่มว่าง = []) · ส่งทั้งคู่ = เฉพาะเอกสารเดียวที่ตรงทั้งสองเงื่อนไข (ยาว 1 ชิ้น เช่นเดียวกับถาม fileId)
{
"id": "<uuid ของเรา>",
"externalFileId": "<fileId ของ e-Court>", // null ได้ — เอกสารในกลุ่มที่ไม่มี fileId ของ e-Court (url: fallback ของ N8 / อัปโหลด N4 พร้อมช่องกลุ่ม — mirror กฎ typeId: null)
"title": "…",
"typeId": "…", // null ได้ (ไม่มีกลุ่ม)
"createdAt": "<ISO string>",
"annotations": [ AnnotationDTO ตาม db/schema.ts พร้อม "url" + "replies": ReplyDTO[] ต่อรายการ ],
"bookmarks": [ BookmarkDTO ตาม db/schema.ts พร้อม "url" ต่อรายการ ]
}
]
"typeId": "…",
"documents": [ { "document": {…}, "annotations": […], "bookmarks": […] } ] // รูปร่างเดียวกับ endpoint 1
}
```
- **`url` ต่อรายการ = deep link กลับมาหน้า viewer ของเรา** (F12 — เพิ่ม 2026-09-25 ตามคำขอผู้ใช้): annotation → `…/annotate/<fileId>?ann=<annotationId>` (ไปรายการ) · bookmark → `…/annotate/<fileId>?page=<หน้า 1-based>` (ไปหน้า — เลขเดียวกับลิงก์ View ของหน้าสรุป/กลุ่ม) — เป็น **URL เต็ม (absolute)** เช่น `https://<host>/docedit/note/annotate/<fileId>?ann=…`: origin อ่านจาก request ที่เรียกเข้ามา (route เชื่อ header `x-forwarded-proto`/`x-forwarded-host` ของ reverse proxy ก่อน — หลายชั้นส่ง comma list ใช้ค่าแรก — แล้ว fallback host/protocol ของ request ตรง ๆ) + basePath ผ่าน `withBasePath` (เดิมเป็น relative path ให้ผู้เรียกเติม origin เอง — เปลี่ยนตามคำขอผู้ใช้ 2026-09-25)
- **body validation (strict กัน typo เงียบ ๆ):** ต้องเป็น JSON object · รับเฉพาะ 3 field `fileId`/`typeId`/`filter` (field อื่น = 400 บอกชื่อ field) · `fileId`/`typeId` ต้องเป็น string (ชนิดอื่น = 400; trim แล้วว่าง = ถือว่าไม่ได้ส่ง field นั้น) · ต้องมี `fileId` หรือ `typeId` **อย่างน้อยหนึ่งอย่าง** — ขาดทั้งคู่ = 400; **ส่งพร้อมกันได้ = AND** กรองเฉพาะเอกสารที่ตรงทั้งสองเงื่อนไข เหลือยาว 1 ชิ้น (รอบห้า 2026-09-25 · รอบสามเคยเป็น OR รวมผล · เดิมส่งพร้อมกัน = 400 กำกวม — ยังไม่มีผู้เรียกจริงจึงเปลี่ยนกติกาได้ขาด) · `filter` ถ้าส่งต้องเป็น object ที่มีเฉพาะ field `type`/`userId`/`annotationTypes` (field อื่นใน filter = 400) — `type` ค่า `"bookmarks"`/`"annotations"` — ค่าว่าง (หลัง trim) หรือไม่มี = ไม่ได้กรอง เอาทั้งสอง ส่วนค่าอื่น = 400 บอกค่าที่รับ · **`userId`** string หรือ array ของ string (trim แล้วค่าว่างทั้งคู่ = ไม่ได้กรอง) กรองตาม**เจ้าของรายการ**ทั้ง annotations/bookmarks — ไม่ใช่ผู้ตอบ (replies ตามมาครบกับ annotation ที่ผ่านตัวกรอง) · **`annotationTypes`** string หรือ array ค่าจาก enum `annotation_type` (`HIGHLIGHT`/`DRAW`/`COMMENT`/`SHAPE`/`REDACTION`/`TEXTBOX`/`STAMP` — ค่าอื่น = 400 บอกค่าที่รับกัน typo เงียบ) มีผลเฉพาะเมื่อรวม annotations (รอบหก 2026-09-25 · เดิมชื่อ field `types` เปลี่ยนรอบแปด)
- **ไม่มีเอกสารในกลุ่ม = 200 + `documents: []`** (ต่างจากหน้า UI `/groups/[typeId]` ที่ 404 — API คืน empty เป็นผลลัพธ์สมเหตุสมผลสำหรับ integration)
- โค้ดกลางของ route: `lib/external-api.ts` — `parseQueryBody` (validate body ตามข้อบน คืน discriminated 2 กรณี: `mode: "file"` มี fileId + typeId อาจมาด้วยสำหรับ AND / `mode: "type"` typeId เดี่ยว) + `loadDocumentBundles(fileRows, parts)` (**bulk**: query รวมชุดเดียวด้วย `inArray` ต่อทุกไฟล์ — annotations/bookmarks คู่ขนาน + replies ตามหลัง = 3 query ต่อ request ไม่ว่ากลุ่มจะกี่ไฟล์ แทนยิง 3 query ต่อไฟล์พร้อมกันทั้งกลุ่มด้วย Promise.all ที่กด connection pool เมื่อกลุ่มใหญ่ — code review 2026-09-25) แล้วจัดกลุ่มรายไฟล์ใน memory (push ตามลำดับ query จึงรักษาลำดับเดิมในไฟล์เดียวกัน) map เป็น DTO — สอง mode ใช้ function เดียวกัน (เอกสารเดียว = array ยาว 1) ได้รูปร่าง bundle ตรงกันเป๊ะ; route กรองเงื่อนไขคู่ (`externalId` + `typeId`) ใน query เดียว และ**ครอบ try/catch ทุก DB call** — ล้ม = log ฝั่ง server + **500 JSON ตาม contract** ไม่ปล่อยเป็น 500 ของ framework ที่ format ต่าง
- ลำดับ: `documents` ตาม `files.createdAt` (เหมือนหน้า `/groups/[typeId]`) · `annotations` ตาม `(pageIndex, createdAt)` เหมือนหน้าสรุป F15 · `bookmarks` ตาม N5 (`pageIndex → yOffset → createdAt`)
- timestamp เป็น ISO string ทั้งหมด (ผ่าน mapper `toDTO` ที่มีอยู่ — invariant #5), `data` คือ JSONB ตาม discriminated union เดิม
- error ทุกอันเป็น JSON `{ "error": "<ไทย>" }`: 400 (body ไม่ใช่ JSON / object ผิดสัญญา / เงื่อนไข fileId–typeId / `filter` ค่าไม่รู้จัก), 401 (key), 404 (ถามด้วย fileId ที่ไม่เคยลงทะเบียน), **500 (query ฐานข้อมูลล้ม — ข้อความกลาง ๆ ไม่แนบรายละเอียด กัน leak SQL/path, log stack ฝั่ง server อย่างเดียว · เพิ่ม code review 2026-09-25)**
- error ทุกอันเป็น JSON `{ "error": "<ไทย>" }`: 400 (param ขาด/`include` ค่าไม่รู้จัก), 401 (key), 404 (เอกสารเดียว: fileId ไม่เคยลงทะเบียน)
### D3 — การ query / สิทธิ์ / ความปลอดภัย
@ -78,14 +67,14 @@ Response 200 เป็นรูปเดียวเสมอ (bundle ยุบ
- ค้น `fileId`: `where eq(files.externalId, "ecourt:" + fileId)` — ไฟล์ที่ลงทะเบียนแบบ fallback `"url:<href>"` (ลิงก์ไม่มี fileId) ค้นด้วยวิธีนี้ไม่ได้ = 404 (ยอมรับ — สัญญาลิงก์ N8 แนะนำให้ส่ง fileId เสมอ)
- **REDACTION รวมใน `annotations` เมื่อเลือก include** — ผู้ถือ key เห็นตำแหน่งกล่องปิดพราง (DB เก็บแค่พิกัด ไม่มีเนื้อหาใต้กล่อง) — ระบุไว้ในสเปกว่า key นี้เชื่อถือได้ระดับ backend e-Court เท่านั้น
- annotation ทุกสถานะรวมหมด (resolved ดูจาก field `resolved` ได้) ทุกผู้ใช้ (userId/userName อยู่ใน DTO)
- route เรียก `requireExternalApiKey` เป็นอย่างแรกก่อนแตะ DB
- route ทั้งสองเรียก `requireExternalApiKey` เป็นอย่างแรกก่อนแตะ DB
## Docs/rules sync (plan-first — ทำเป็น task แรก ก่อนแตะโค้ด)
| ไฟล์ | สิ่งที่แก้ |
|---|---|
| `docs/PLAN-FEAT.md` §7 | หัวข้อใหม่ **N10 · API ดึงข้อมูลเอกสาร/กลุ่ม (API Key)** (format ตาม N5–N7 + อ้างแผนฉบับเต็ม `docs/PLAN-API.md`) |
| `CLAUDE.md` | whitelist เพิ่ม N10 + โครงสร้างไฟล์เพิ่ม `lib/external-api-auth.ts`, `lib/external-api.ts`, `app/api/external/documents/route.ts` + บันทึก env `EXTERNAL_API_KEY` (README `.env.example` ถ้ามี) |
| `CLAUDE.md` | whitelist เพิ่ม N10 + โครงสร้างไฟล์เพิ่ม `lib/external-api-auth.ts`, `app/api/external/document/route.ts`, `app/api/external/documents/route.ts` + บันทึก env `EXTERNAL_API_KEY` (README `.env.example` ถ้ามี) |
| `.claude/rules/data-layer.md` | เพิ่ม: routes ของ N10 เป็น read-only ไม่ publish SSE/`revalidatePath`; env `EXTERNAL_API_KEY` ว่าง = fail closed |
| `README.md` | สารบัญ env เพิ่ม `EXTERNAL_API_KEY` + ตัวอย่างการเรียก (curl) |
@ -99,37 +88,18 @@ Response 200 เป็นรูปเดียวเสมอ (bundle ยุบ
| 4 | Route กลุ่ม | `app/api/external/documents/route.ts` | curl: หลายไฟล์เรียง `createdAt`, typeId ไม่มี = `documents: []`, รูปร่าง document เหมือน endpoint 1 เป๊ะ |
| 5 | ปิดงาน | — | plan-reviewer + code-reviewer + vitest + `tsc`; ทดสอบร่วมกับ N8/N9 ด้วยข้อมูลจริงจากลิงก์ e-Court |
> **รอบสอง (2026-09-24 ตามคำขอผู้ใช้):** task 3+4 ถูกแทนที่ — รวม 2 GET เป็น **`POST /api/external/query` เส้นเดียว** (route `app/api/external/query/route.ts` + `parseQueryBody` ใน `lib/external-api.ts` แทน `parseInclude`; ถอด `app/api/external/document/` กับ `app/api/external/documents/` ออก — ยังไม่มีผู้เรียกจริง) ตาม D2 ฉบับใหม่
>
> **รอบสาม (2026-09-25 ตามคำขอผู้ใช้):** (1) รับ `fileId`+`typeId` **พร้อมกัน** = รวมผล OR dedupe เรียง `files.createdAt` (ถอดกติกาเดิม "ส่งพร้อมกัน = 400 กำกวม") (2) ยุบชั้น `document` ออกจาก response — field เอกสาร (`id`/`externalFileId`/`title`/`typeId`/`createdAt`) ขึ้นมาอยู่ระดับเดียวกับ `annotations`/`bookmarks` ให้อ่านง่ายขึ้น (3) เพิ่ม `url` deep link กลับมาหน้า `/annotate/[id]` ต่อ annotation/bookmark
>
> **รอบสี่ (2026-09-25 ตามคำขอผู้ใช้):** เปลี่ยนชื่อ endpoint `POST /api/external/query` → **`POST /api/external/documents`** (ย้าย route เป็น `app/api/external/documents/route.ts`) — ตั้งชื่อตาม resource ที่คืน ตรงกับ response `{documents}` · ยังไม่มีผู้เรียกจริงจึงเปลี่ยนได้ขาด ไม่ต้องเก็บ path เดิม
>
> **รอบห้า (2026-09-25 ตามคำขอผู้ใช้):** เปลี่ยน semantics ของการส่ง `fileId`+`typeId` พร้อมกันจาก **OR รวมผล** (รอบสาม) เป็น **AND กรองเฉพาะเอกสารเดียวที่ตรงทั้งสองเงื่อนไข** — ทดสอบจริงแล้ว OR คืนทั้งกลุ่ม ผู้เรียกต้องการเฉพาะเอกสารตามที่ส่งมา; ไม่ตรงคู่ (ไม่เคยเปิด / คนละกลุ่ม) = 404 แยกสาเหตุในข้อความ · ถอด Map dedupe + sort ที่จำเป็นแค่ตอน OR ออก (เหลือ query เงื่อนไขคู่ในคำสั่งเดียว)
>
> **รอบหก (2026-09-25 ตามคำขอผู้ใช้):** เพิ่มมิติตัวกรองใน `filter` — **`userId`** (string หรือ array — กรองตามเจ้าของรายการทั้ง annotations/bookmarks; replies ตามมาครบกับ annotation ที่ผ่านตัวกรอง ไม่กรองตามผู้ตอบ) + **`types`** (ค่า enum `annotation_type` — มีผลเฉพาะ annotations) ทั้งคู่ค่าว่าง = ไม่ได้กรองตามกฎเดิม · `since` (ดึง incremental) ที่เสนอด้วยถูกตัด — นิยาม "สร้างหลังจาก X" ไม่ครอบคลุมรายการเก่าที่ถูกแก้ไขภายหลัง ถ้าจะมีวันนั้นต้องออกแบบเป็น change feed เต็มรูปแบบ
>
> **รอบเจ็ด (2026-09-25 ตาม code review ของผู้ใช้):** (1) route **ครอบ try/catch ทุก DB call** — ก่อนหน้า query ล้มจะหลุดเป็น unhandled exception กลายเป็น 500 ของ framework ที่ format ต่างจาก JSON error contract ของ endpoint นี้ทั้งหมด (2) **ถอด fan-out ต่อไฟล์** — `loadDocumentBundle` ต่อไฟล์ + `Promise.all` ทั้งกลุ่ม (3 query ต่อไฟล์พร้อมกัน กด connection pool เมื่อกลุ่มใหญ่) เปลี่ยนเป็น `loadDocumentBundles` bulk: `inArray` ทุกไฟล์ในชุด query เดียว = 3 query ต่อ request ไม่ว่ากลุ่มจะกี่ไฟล์ (ดีกว่า batching เป็นกลุ่มเล็กที่ยังยิง N รอบ)
>
> **รอบแปด (2026-09-25 ตามคำขอผู้ใช้):** เปลี่ยนชื่อ field `filter.types` → **`filter.annotationTypes`** — ชื่อเดิมสั้นจนชนกับ `filter.type` (การเลือก bookmarks/annotations) อ่านสับสน; ยังไม่มีผู้เรียกจริงจึงเปลี่ยนขาด ไม่ต้องรับชื่อเดิม
>
> **รอบเก้า (2026-09-25 ตามคำขอผู้ใช้):** `url` เปลี่ยนจาก relative path เป็น **URL เต็ม** — route อ่าน origin จาก request ของผู้เรียก (`x-forwarded-proto`/`x-forwarded-host` ก่อน แล้ว fallback host/protocol ของ request) ส่งเข้า `loadDocumentBundles` เป็น parameter `origin` — ผู้เรียกใช้ลิงก์ได้ทันทีไม่ต้องประกอบเอง; ข้อควรระวัง: deploy ผ่าน reverse proxy ต้องส่ง header forward ให้ถูกต้อง ไม่งั้น origin จะเป็นของ proxy แทนที่จะเป็น public URL
## Edge cases (ออกแบบไว้ล่วงหน้า)
1. **env `EXTERNAL_API_KEY` ไม่ตั้ง/ว่าง** → 401 ทุก request (fail closed) — deploy ต้องตั้งก่อนเปิดใช้
2. **key ผิด** → 401 ข้อความเดียวกับไม่ใส่ key (ไม่บอกว่าขาดอะไร — กัน enumerate)
3. **fileId ที่ e-Court ไม่เคยลิงก์มา** → 404 "ไม่พบเอกสารนี้ในระบบ (ยังไม่เคยเปิด)" — ไม่ใช่ 500
4. **ไฟล์ลงทะเบียนแบบ `"url:<href>"`** (ลิงก์ไม่มี fileId) → ค้นไม่ได้ = 404 — เอกสารนี้ต้องเข้าผ่านลิงก์ที่มี fileId ก่อน
5. **`filter.type` ค่าไม่รู้จัก** (รวมเอกพจน์ "annotation"/"bookmark") → 400 บอกค่าที่รับ · **`type` ค่าว่าง/whitespace ล้วน หรือไม่มี (`filter: {}`)** → ไม่ได้กรอง เอาทั้งสอง (กฎเดียวกับ fileId ว่าง — คำขอผู้ใช้ 2026-09-24 ตอนทดลองยิง `"type": ""` เจอ 400) — เดิมรอบแรกเป็น query-param `include` แบบ comma (trim + dedupe) เปลี่ยนมาเป็น `filter.type` ตามรอบสอง
6. **ไม่มีทั้ง `fileId` และ `typeId`** (ไม่ส่งเลย หรือส่งมาแต่ค่าว่าง/whitespace ล้วนจนไม่เหลือ) → 400 ข้อความไทย (ต้องระบุอย่างน้อยหนึ่งอย่าง)
7. **ส่งทั้ง `fileId` และ `typeId` พร้อมกัน** → AND เหลือเฉพาะเอกสารเดียวที่ตรงทั้งสองเงื่อนไข (รอบห้า 2026-09-25 · รอบสามเคยเป็น OR รวมผล · เดิม = 400 กำกวม) · **ไม่ตรงคู่ = 404** — ไฟล์ไม่เคยเปิดในระบบ หรืออยู่คนละกลุ่มกับ `typeId` ที่ระบุ (แยกสาเหตุในข้อความ) · **field นอกเหนือ `fileId`/`typeId`/`filter`** → 400 บอกชื่อ field (กัน typo เช่น `fileID` เงียบ ๆ) · **`filter` ผิดสัญญา** (ไม่ใช่ object / มี field อื่นนอกจาก `type` / ไม่มี `type`) → 400
5. **`include` ซ้ำค่า / มี whitespace / ค่าไม่รู้จัก** → trim + dedupe; มีค่านอก `{annotations, bookmarks}` สักตัว = 400 บอกค่าที่รับ
6. **ไม่มี param `fileId`/`typeId` เลย** → 400 ข้อความไทยระบุ param ที่ขาด
7. **`typeId` ตัวเปล่า / `include` ตัวเปล่า** → 400 (ไม่ปล่อยผ่านเป็น "ทั้งหมด")
8. **เอกสารไม่มี annotation/bookmark เลย** → arrays ว่าง ไม่ใช่ 404 (ไฟล์เคยเปิด = ข้อมูลเอกสารมีค่าเสมอ)
9. **ไฟล์ไม่มีกลุ่ม** → `document.typeId: null` (ไม่ใช่ string ว่าง)
10. **คนละรอบเรียกพร้อมกัน** → read-only ไม่มีสถานะแชร์ — ไม่มี race (pool `prepare: false` ตาม invariant #11)
11. **เอกสารในกลุ่มที่ไม่มี fileId ของ e-Court** (ลงทะเบียนแบบ `"url:<href>"` ของ N8 หรืออัปโหลดผ่าน N4 พร้อมช่องกลุ่ม) → endpoint กลุ่มยังรวมไฟล์เหล่านี้ แต่ `document.externalFileId: null` — mirror กฎ `typeId: null` ของข้อ 9 (endpoint เดียวไม่กระทบ — ค้นด้วย `ecourt:` prefix เจอเฉพาะไฟล์ที่มี fileId เสมอ)
12. **`filter.userId` ที่ไม่มีในระบบ** → ไม่ใช่ 400 — ได้ `annotations: []`/`bookmarks: []` ตาม contract (ตัวเอกสารยังคืนถ้าเคยเปิด) เพราะไม่ validate กับ DB (ผู้ใช้เพิ่ม/เปลี่ยนได้ตลอด ตรวจก็เสีย query เปล่า) · **`filter.annotationTypes` เมื่อเลือก `type: "bookmarks"` อย่างเดียว** → เพิกเฉยเงียบ ๆ ไม่ใช่ 400 (ไม่มี annotation ให้กรองอยู่แล้ว — ค่า annotationTypes ยังถูก validate ตามปกติก่อนถึงขั้นนั้น)
13. **query ฐานข้อมูลล้มกลางคัน** (connection หลุด / timeout / table มีปัญหา) → **500 JSON ตาม contract** ผ่าน `externalApiError` ไม่ใช่ 500 ของ framework ที่ format ต่าง — try/catch ที่ route ครอบทุก DB call (code review 2026-09-25) · log stack ฝั่ง server อย่างเดียว ข้อความตอบเป็นกลาง ๆ ไม่แนบ error ดิบ · ไม่ retry ฝั่งเรา (read-only snapshot — ผู้เรียกตัดสินใจเรียกซ้ำเอง)
## Out of scope (ไม่ทำในแผนนี้)

View file

@ -1,8 +1,6 @@
# แผน 5.3.15 — รายงานรายการ bookmark เป็น PDF (N7)
> **อัปเดต 2026-09-17:** ผู้ใช้ขอเปลี่ยน deliverable ของ N7 เป็น**ส่งออก JSON** แทนรายงาน PDF — route เดิม (`app/api/bookmarks/export/route.ts`) ถูกเขียนใหม่เป็น JSON (flat array `BookmarkDTO` เรียง N5 + envelope `{ fileId, title, exportedAt, bookmarks }`) โค้ด PDF ในแผนนี้จึงเป็น **historical record** — สเปกปัจจุบันอยู่ที่ `docs/PLAN-FEAT.md` §7 N7 (ถอด: build tree/cycle guard/ฟอนต์/layout ทั้งหมด)
>
> **อัปเดต 2026-09-25:** N7 ถูก**ถอดออกจากโค้ดทั้งหมด**ตามคำขอผู้ใช้ (route + ปุ่มใน sidebar/หน้าสรุป) — ระบบภายนอกดึงข้อมูล bookmark ผ่าน **N10 API** (`POST /api/external/documents`, แผน `docs/PLAN-API.md`) แทน; ทั้งแผนนี้และ §7 N7 ของ PLAN-FEAT.md เป็น historical record ทั้งคู่แล้ว
> สถานะ: **implement เสร็จสมบูรณ์** (2026-09-16) — ครบ 4 task, `tsc` ผ่าน, vitest 120/120, plan-reviewer + code-reviewer ผ่าน (findings ทั้งหมดแก้แล้ว: **บรรทัดแรกของหน้าใหม่ถูกวาดกลับลงหน้าเก่า** — `page.drawText({ y: advanceLine(...) })` ประเมิน base ของ method call ก่อน arguments ตาม spec ทำให้ซ้อนบรรทัดบนสุดของหน้าเก่า → ต้อง advance ก่อนแล้วอ่าน `page` ทีหลัง, **cycle guard ซ้ำ/หาย** — seen set ต่อ root ทำให้วงจรล้วนซ้ำ 2 เท่าและวงจรปน root หายเงียบ → seen ร่วมทุก root + เติมโหนดที่ไม่ถูกเดินเป็นระดับ 1, **ชื่อไฟล์ในหัวรายงานไม่ wrap** — title ยาวถึง 200 ตัวล้นขอบขวา → wrap ด้วย `wrapTextByWidth` เหมือนรายการ) — re-verify หลังแก้ด้วยไฟล์ทดสอบ (40 รายการ + วงจรปน root + title ยาว 200): 4 หน้า **ไม่มีข้อความซ้อนทับเลย** ทุกหน้ามีเนื้อหาจริง วงจรโผล่ครบระดับ 1 · ไฟล์วงจรล้วน 2 รายการ = แบนระดับ 1 ทั้งคู่ · ทดสอบเดิมผ่านครบ: ไฟล์จริง 7 bookmarks ต้นไม้ 2 ระดับ indent x48↔x62 ลำดับ N5 ถูก, 40 รายการ = 5 หน้า + footer ทุกหน้า, 0 bookmark = "ยังไม่มีรายการ…", invalid id = 400, ไม่มีไฟล์ = 404, ฟอนต์ไทย render ถูก (ยืนยันสระผสม "คำสั่ง" ผ่าน render PNG + vision OCR — "คำา" ใน text extraction เป็น artifact ของ ToUnicode ตอน subset ไม่กระทบการแสดงผล), ปุ่มโผล่ใน SSR ของหน้า annotate พร้อม href/aria-label ถูก
> อ้างอิง: requirement 5.3.15 (gap analysis `docs/requirement/gap-analysis.md` **กลุ่ม B** — ข้อสุดท้ายที่เหลือ: 5.3.11 = N5 ✅, 5.3.12 = N6 ✅)

File diff suppressed because one or more lines are too long

View file

@ -196,7 +196,7 @@ Index: `(file_id)`
- **F13 A6 แก้ไขข้อความ comment:** ปุ่ม "แก้ไข" ใน popover (เจ้าของเท่านั้น) → textarea → `updateAnnotation` (reuse F1)
- **F14 D1 ค้นหา:** Action `searchAnnotations(fileId, q)` — `ilike` บน `data->>'text'` และ `data->>'message'` (escape `%`/`_` ใน q) — คีย์ `text` ครอบทั้ง HIGHLIGHT และ TEXTBOX อัตโนมัติ (ชื่อฟิลด์ใน JSONB ตรงกัน); กล่องค้นหาบน sidebar → ผลลัพธ์คลิก → goTo + select
- **F15 D2 หน้าสรุป:** `app/annotate/[id]/summary/page.tsx` — server component รวมทุก annotation จัดกลุ่มตามหน้า (หน้า / ชนิด / ผู้เขียน / ข้อความ) + ปุ่มกระโดดเป็น deep link (F12); ทางเข้า = ปุ่มบน toolbar ของ viewer + ลิงก์กลับหน้าอ่านบนหน้าสรุปเอง; label/ข้อความย่อของแต่ละชนิดแชร์กับ sidebar ผ่าน `utils/annotation-display.ts`
- **F16 D3 Export CSV:** ~~`app/api/annotations/export-csv/route.ts?fileId=` — CSV เดียวกับข้อมูลหน้าสรุป, ขึ้นต้นด้วย UTF-8 BOM (เปิดใน Excel ภาษาไทยถูกต้อง); ปุ่มอยู่หน้าสรุป~~ **ถอดออกจากโค้ดแล้ว 2026-09-25 ตามคำขอผู้ใช้** — ระบบภายนอกดึงข้อมูลผ่าน N10 API แทน (route + ปุ่มหน้าสรุป + `utils/csv.ts` + test ถอดครบ)
- **F16 D3 Export CSV:** `app/api/annotations/export-csv/route.ts?fileId=` — CSV เดียวกับข้อมูลหน้าสรุป, ขึ้นต้นด้วย UTF-8 BOM (เปิดใน Excel ภาษาไทยถูกต้อง); ปุ่มอยู่หน้าสรุป
- **F17 B2 Text box:** tool `textbox` → คลิกบนหน้า → inline `<textarea>` วางบนตำแหน่งนั้น → บันทึก TEXTBOX; render เป็น div absolute (fontSize % → px); เจ้าของแก้ได้ (pattern F13); export วาดข้อความด้วย Sarabun (D5)
- **F18 B5 Stamp:** preset คงที่ในโค้ด (`STAMP_PRESETS` เช่น "ได้รับแล้ว", "สำเนาถูกต้อง") → tool `stamp` → คลิกวางที่ตำแหน่ง; render เป็นกรอบข้อความสไตล์ตรายาง (scale % → px); export วาดกรอบ+ข้อความ (D7)
@ -303,10 +303,8 @@ Dependencies เพิ่มเติม: F3 ← F2 · F12/F13/F19 ← F1 · F15
### N4 · อัปโหลดไฟล์แบบง่าย (2026-09-15)
> เพิ่มเติม (2026-09-23 ตามคำขอผู้ใช้): ช่อง **"กลุ่ม typeId"** ทางเลือกในฟอร์ม (ไม่กรอก = ไม่มีกลุ่ม) — ไฟล์อัปโหลดเข้ากลุ่ม N9 ได้โดยไม่ต้องผ่านลิงก์ e-Court; normalize เหมือน N8 (`trim` → ว่าง = NULL, cap 200 — `normTypeId` mirroring `normGroup`)
- **UI:** ฟอร์มเดียวบนหน้า `/` (`components/UploadFileForm.tsx`) — `<input type="file" accept=".pdf,.jpg,.jpeg,.png,.tif,.tiff">` + ช่อง "กลุ่ม typeId" ทางเลือก (uncontrolled ส่งไปกับ FormData) + ปุ่มอัปโหลด (`useTransition`, disabled ระหว่างส่ง) รับทั้ง PDF และรูป
- **Server Action `uploadFile`** (`app/actions/files.ts`): รับ `FormData` — validate extension (whitelist เดียวกับ `SAMPLE_FILE_EXTENSIONS`) + ขนาด ≤ 100MB + ไฟล์ไม่ว่าง → เขียน `public/uploads/<uuid>.<ext>` (**ชื่อไฟล์บนดิสก์จาก randomUUID เท่านั้น** — กันชนกันและกัน path traversal; `title` = ชื่อไฟล์เดิมที่ sanitize) → insert `files (title, pdf_url, type_id)` (typeId จากช่องกลุ่ม — ว่าง = NULL, insert ตอนสร้าง row ใหม่เท่านั้น ไม่มีทางแก้กลุ่มไฟล์เดิม) → `revalidatePath("/")` — ไม่มี SSE event (รายการไฟล์ไม่ได้ sync ผ่าน SSE)
- **UI:** ฟอร์มเดียวบนหน้า `/` (`components/UploadFileForm.tsx`) — `<input type="file" accept=".pdf,.jpg,.jpeg,.png,.tif,.tiff">` + ปุ่มอัปโหลด (`useTransition`, disabled ระหว่างส่ง) รับทั้ง PDF และรูป
- **Server Action `uploadFile`** (`app/actions/files.ts`): รับ `FormData` — validate extension (whitelist เดียวกับ `SAMPLE_FILE_EXTENSIONS`) + ขนาด ≤ 100MB + ไฟล์ไม่ว่าง → เขียน `public/uploads/<uuid>.<ext>` (**ชื่อไฟล์บนดิสก์จาก randomUUID เท่านั้น** — กันชนกันและกัน path traversal; `title` = ชื่อไฟล์เดิมที่ sanitize) → insert `files (title, pdf_url)` → `revalidatePath("/")` — ไม่มี SSE event (รายการไฟล์ไม่ได้ sync ผ่าน SSE)
- **next.config:** `experimental.serverActions.bodySizeLimit = "100mb"` (default 1MB ไม่พอสำหรับ PDF/สแกนศาล)
- **เสิร์ฟไฟล์อัปโหลดผ่าน `app/uploads/[name]/route.ts`** (GET — อ่าน `public/uploads/<uuid>.<ext>` + path guard ของ `lib/server-pdf.ts` + content-type จาก `utils/file-kind.ts`): Next.js production (`next start`/standalone) ไล่รายชื่อไฟล์ใน `public/` เก็บใน Set **ครั้งเดียวตอน startup** (router-utils/filesystem.js `setupFsCheck`) — ไฟล์ที่ action เขียนตอน runtime GET ตรง ๆ แล้ว 404 (dev ไม่เป็น → อาการ "local ผ่าน Docker พัง"); ไฟล์ที่ bake ตอน build (pdf.worker/ฟอนต์/samples) ถูกเสิร์ฟโดย static path ก่อน route นี้อยู่แล้วตามลำดับ routing; PDF ใน viewer โหลดผ่าน `/api/pdf` อยู่แล้ว ตัวที่ต้องใช้ route นี้จริง = รูปภาพ (`<img src={pdfUrl}>`)
- **ความสัมพันธ์กับ seed:** seed-samples จัดการเฉพาะ `/samples/%` — ไฟล์อัปโหลดใน `/uploads/` ไม่ถูก seed ลบ
@ -332,7 +330,7 @@ Dependencies เพิ่มเติม: F3 ← F2 · F12/F13/F19 ← F1 · F15
### N7 · ส่งออกรายการ bookmark เป็น JSON (2026-09-16 · เปลี่ยนจากรายงาน PDF 2026-09-17)
> แผนงานฉบับเต็ม (ตอนเป็น PDF): `docs/PLAN-BOOKMARK-PDF.md` — requirement 5.3.15 (gap analysis กลุ่ม B ข้อสุดท้าย) · 2026-09-17 ผู้ใช้ขอเปลี่ยน deliverable เป็น JSON แทนรายงาน PDF · **ถอดออกจากโค้ดแล้ว 2026-09-25 ตามคำขอผู้ใช้** — ระบบภายนอกดึงข้อมูล bookmark ผ่าน N10 API แทน (route `app/api/bookmarks/export/` + ปุ่มใน sidebar ถอดครบ; รายละเอียดข้างล่างเป็น historical record)
> แผนงานฉบับเต็ม (ตอนเป็น PDF): `docs/PLAN-BOOKMARK-PDF.md` — requirement 5.3.15 (gap analysis กลุ่ม B ข้อสุดท้าย) · 2026-09-17 ผู้ใช้ขอเปลี่ยน deliverable เป็น JSON แทนรายงาน PDF
- **Route:** `app/api/bookmarks/export/route.ts` (GET `?fileId=`) — **ข้อมูลล้วนจาก DB ไม่โหลดไฟล์เอกสารต้นฉบับเลย** (invariant #1): validate `fileId` (UUID → 400) + หาไฟล์ไม่เจอ = 404 แล้ว query `bookmarks` ทั้งไฟล์ `orderBy asc(pageIndex), asc(yOffset), asc(createdAt)` (ลำดับเดียวกับ sidebar/N5) → response JSON `{ fileId, title, exportedAt (ISO), bookmarks: BookmarkDTO[] }` — item เป็น `toBookmarkDTO` ตรง ๆ (camelCase + ISO string, invariant #5) เก็บ `parentId` ครบ **lossless** reconstruct ต้นไม้ได้จากข้อมูลเอง จึงไม่ต้อง build tree/cycle guard/ฟอนต์/layout แบบตอนเป็น PDF; pretty-print 2 space; 0 bookmark = `"bookmarks": []` (ไม่ 404 — ปุ่มทำงานเสมอ); `content-type: application/json; charset=utf-8` + `attachment` + `filename*` จาก `encodeRFC5987("{title}-bookmarks.json")` (pattern F8/F16)
- **UI:** ปุ่มไอคอน `Braces` ({ }) ท้ายแถว toggle เงื่อนไขค้นหา N6 ในแท็บ "ที่คั่นหน้า" — `<a href="/api/bookmarks/export?fileId=">` ลิงก์ธรรมดา (pattern Export CSV F16 — ไม่มี JS/state) + `coarse-36` — ส่งออก**ทุก bookmark ของไฟล์เสมอ** (ไม่ผูกเงื่อนไขค้นหา N6 ที่ตั้งไว้)
@ -360,18 +358,6 @@ Dependencies เพิ่มเติม: F3 ← F2 · F12/F13/F19 ← F1 · F15
- **กลุ่มระดับเดียว กำหนดด้วย `files.type_id`** (จากลิงก์ e-Court `?typeId=` — เขียนที่ `registerExternalFile` ของ N8 เท่านั้น: insert ครั้งแรก + ย้ายกลุ่มเมื่อลิงก์รอบใหม่ส่ง `typeId` คนละค่า, ไม่ส่ง = คงเดิม ห้ามเคลียร์) — ไม่มีตารางกลุ่ม/many-to-many/หลายระดับ: กลุ่ม = ค่า distinct ของ `type_id` ณ ตอน query, แสดงผลด้วยรหัสกลุ่มโดยตรง (ไม่มีคอลัมน์ชื่อกลุ่ม — ถอด `type_name` ตามคำขอผู้ใช้) — ไฟล์ seed/อัปโหลด N4/ลิงก์ไม่มี typeId = NULL เสมอ
- **หน้ารายการ `/` จัดกลุ่ม:** section ต่อกลุ่ม (`type_id` ไม่ NULL) เรียงตาม `createdAt` ของไฟล์ล่าสุดในกลุ่ม (กลุ่มที่มี activity ใหม่อยู่บน) — การ์ดไฟล์ในกลุ่มเรียง `createdAt` asc เหมือนเดิม + ปุ่ม Annotate เดิม; หัวกลุ่ม = รหัสกลุ่ม + จำนวนไฟล์ + ลิงก์ "เอกสารในกลุ่ม" → `/groups/{encodeURIComponent(typeId)}`; ไฟล์ `type_id` NULL → section **"ไม่มีกลุ่ม"** ท้ายสุด (ใกล้ฟอร์มเพิ่มเอกสาร — แสดงเสมอถ้ามีไฟล์) — จัดกลุ่มเป็นการแสดงผลล้วนบน server component เดิม ไม่มี client component/action ใหม่
- **หน้า `/groups/[typeId]`** (server component `force-dynamic` — login ถูกบังคับโดย `middleware.ts` อยู่แล้ว): query `files where type_id` `orderBy createdAt` — **ไม่มี = 404** (กลุ่มที่ระบบไม่รู้จัก/ไฟล์ย้ายออกหมดแล้ว — ไม่มีตารางกลุ่มค้าง); หัวกลุ่ม (รหัส + "N เอกสาร") + ลิงก์กลับ `/` + กล่อง info **"แสดงเฉพาะเอกสารที่เคยเปิดในระบบนี้"** (ถอดออกตามคำขอผู้ใช้ 2026-09-24); รายการ = ชื่อไฟล์ + วันที่นำเข้า (`formatThaiDate`) + จำนวน annotation/bookmark + ปุ่ม Annotate → `/annotate/{fileId}`; **การ์ดแต่ละไฟล์หุบ/กางดู bookmark+annotation ทั้งไฟล์** (คำขอผู้ใช้ 2026-09-23 — select รายการเต็ม 2 query แทนการ `groupBy` นับอย่างเดียว, รวมเป็นรายการเดียวเรียงตามหน้า หน้าเดียวกันให้ที่คั่นหน้าก่อน, แต่ละแถว = deep link `/annotate/{fileId}?ann=…` / `?page=…` เดียวกับหน้าสรุป F12 + วันเวลาที่สร้าง (`formatThaiDate` withTime) ในบรรทัด meta, ใช้ `<details>` ไม่มี client component — ปิด gap 5.3.10 ครึ่งหลัง "bookmark รวมทั้งกลุ่ม"); `generateMetadata` ตั้งชื่อแท็บ browser ตามรหัสกลุ่ม
- **หน้า `/groups/[typeId]`** (server component `force-dynamic` — login ถูกบังคับโดย `middleware.ts` อยู่แล้ว): query `files where type_id` `orderBy createdAt` — **ไม่มี = 404** (กลุ่มที่ระบบไม่รู้จัก/ไฟล์ย้ายออกหมดแล้ว — ไม่มีตารางกลุ่มค้าง); หัวกลุ่ม (รหัส + "N เอกสาร") + ลิงก์กลับ `/` + กล่อง info **"แสดงเฉพาะเอกสารที่เคยเปิดในระบบนี้"** แสดงทุกครั้ง (ย่อจากข้อความเดิม "… — อาจไม่ครบทุกฉบับของกลุ่ม" ตามคำขอผู้ใช้ 2026-09-23); รายการ = ชื่อไฟล์ + วันที่นำเข้า (`formatThaiDate`) + จำนวน annotation/bookmark + ปุ่ม Annotate → `/annotate/{fileId}`; **การ์ดแต่ละไฟล์หุบ/กางดู bookmark+annotation ทั้งไฟล์** (คำขอผู้ใช้ 2026-09-23 — select รายการเต็ม 2 query แทนการ `groupBy` นับอย่างเดียว, รวมเป็นรายการเดียวเรียงตามหน้า หน้าเดียวกันให้ที่คั่นหน้าก่อน, แต่ละแถว = deep link `/annotate/{fileId}?ann=…` / `?page=…` เดียวกับหน้าสรุป F12 + วันเวลาที่สร้าง (`formatThaiDate` withTime) ในบรรทัด meta, ใช้ `<details>` ไม่มี client component — ปิด gap 5.3.10 ครึ่งหลัง "bookmark รวมทั้งกลุ่ม"); `generateMetadata` ตั้งชื่อแท็บ browser ตามรหัสกลุ่ม
- **ไม่มี Server Action/SSE/migration ใหม่** (schema `type_id` + index มากับ N8 ใน migration เดียวกัน) — การเปลี่ยนกลุ่มเกิดที่ N8 ตอน register แล้ว redirect ออกจาก `/` ทันที ไม่มี Router Cache ค้าง
- **Out of scope ของ N9:** จัดกลุ่ม/แก้ชื่อ/ลบกลุ่มด้วยมือใน UI, ไฟล์อยู่หลายกลุ่ม (many-to-many) / กลุ่มซ้อนหลายระดับ, export JSON ตามกลุ่ม (N7 ยังรับ `fileId` เดียว), รายการเอกสารครบทุกฉบับของคดีรวมฉบับที่ยังไม่เคยเปิด (ข้อมูลอยู่ที่ e-Court), API ให้ระบบภายนอก (**N10** `docs/PLAN-API.md`)
### N10 · API ดึงข้อมูลเอกสาร/กลุ่มให้ระบบภายนอกด้วย API Key (2026-09-24)
> แผนงานฉบับเต็ม: `docs/PLAN-API.md` — ผู้เรียกคือ **backend ของ e-Court** (server-to-server ไม่มี browser/UI) ต้องการดึง annotation/bookmark ที่เกิดในระบบเรากลับไปใช้ต่อ
- **Auth (`lib/external-api-auth.ts`):** API Key เดียวใน env `EXTERNAL_API_KEY` ส่งผ่าน header `x-api-key` — เทียบด้วย SHA-256 ทั้งสองค่าแล้ว `crypto.timingSafeEqual` (hash ยาวเท่ากันเสมอ — กัน timing + length leak); **env ไม่ตั้ง/ว่าง = fail closed** 401 ทุก request ("API ยังไม่ได้ตั้งค่าคีย์"); ข้อความ 401 ของ "ไม่ใส่ key" กับ "key ผิด" เป็นอันเดียวกัน (กัน enumerate); ห้าม log ค่า key/header ดิบ — `middleware.ts` ยกเว้น `/api/*` อยู่แล้วจึงไม่ชนระบบ login oacCase เดิม; module นี้**ไม่ import อะไรที่ลาก `@/db` มาด้วย** (db/index.ts throw ตอน import ถ้าไม่มี DATABASE_URL — unit test ของ auth ต้องรันได้โดยไม่แตะ DB) และส่งออก `externalApiError(message, status)` สร้าง Response ผิดพลาด JSON `{"error": "<ไทย>"}` ใช้ร่วมทุก route ของ N10
- **Endpoint เดียว POST + JSON body:** `POST /api/external/documents` (header `x-api-key` + `content-type: application/json` — เดิมชื่อ `/api/external/query` ถึงรอบสาม เปลี่ยนตาม resource ที่คืน 2026-09-25) — body `{ fileId และ/หรือ typeId, filter }`: ถามเอกสารเดียวด้วย `fileId` ของ e-Court (ค้น `files.external_id = "ecourt:<fileId>"` ไม่เจอ = 404 "ไม่พบเอกสารนี้ในระบบ (ยังไม่เคยเปิด)"; ไฟล์ `url:` fallback ของ N8 ค้นไม่ได้ = 404 ตามสัญญาลิงก์ที่แนะนำให้ส่ง fileId เสมอ) และ/หรือถามทุกเอกสารในกลุ่มด้วย `typeId` (N9 — **กลุ่มว่าง = 200 `documents: []`** ต่างจากหน้า UI `/groups/[typeId]` ที่ 404) — ต้องมีอย่างน้อยหนึ่งอย่าง; **ส่งทั้งคู่ = AND เหลือเฉพาะเอกสารเดียวที่ตรงทั้งสองเงื่อนไข** ไม่ตรงคู่ (ไม่เคยเปิด / คนละกลุ่ม) = 404 แยกสาเหตุในข้อความ (คำขอผู้ใช้ 2026-09-25 หลังทดสอบจริง · รอบเดียวกันเคยเป็น OR รวมผล · เดิมส่งพร้อมกัน = 400 กำกวม); รอบแรกเป็น 2 GET query-param แล้วรวมเป็นเส้นเดียวตามคำขอผู้ใช้ (2026-09-24 — ยังไม่มีผู้เรียกจริงจึงแทนที่ขาด)
- **`filter.type`:** `"bookmarks"` | `"annotations"` — ไม่ส่ง `filter` หรือ `type` เป็นค่าว่าง = ทั้งสอง (กฎเดียวกับ fileId ว่าง = ไม่ได้ส่ง — คำขอผู้ใช้ 2026-09-24), ส่วนที่ไม่เลือกคืนเป็น array ว่าง (contract คงที่); ค่าอื่น = 400 บอกค่าที่รับ — validation รวมทั้งรูปร่าง body ทั้งหมด (field นอก `fileId`/`typeId`/`filter` = 400 กัน typo, ชนิดไม่ใช่ string = 400, filter ผิดสัญญา = 400) อยู่ที่ `parseQueryBody` ใน `lib/external-api.ts` · **`filter.userId`** (string หรือ array — กรองตามเจ้าของรายการทั้ง annotations/bookmarks, replies ตาม annotation ที่ผ่านตัวกรองครบ; userId ไม่มีในระบบ = รายการว่างไม่ใช่ 400) + **`filter.annotationTypes`** (string หรือ array ค่า enum `annotation_type` ค่าอื่น = 400 — มีผลเฉพาะ annotations; เดิมชื่อ `types` เปลี่ยน 2026-09-25 กันสับสนกับ `filter.type`) เพิ่ม 2026-09-25 ตามคำขอผู้ใช้ ค่าว่าง = ไม่ได้กรองทุก field
- **Response รูปเดียวเสมอ:** `{ documents: [ { id, externalFileId, title, typeId, createdAt, annotations: [AnnotationDTO + `url` + `replies: ReplyDTO[]`], bookmarks: [BookmarkDTO + `url`] } ] }` — bundle ยุบชั้น: field ของเอกสารอยู่ระดับเดียวกับ `annotations`/`bookmarks` ไม่มี object `document` ซ้อน (คำขอผู้ใช้ 2026-09-25 · ถามด้วย fileId = ยาว 1 ชิ้น) · **`url` ต่อรายการ** = deep link F12 กลับมาหน้า viewer (annotation `?ann=<id>` / bookmark `?page=<หน้า 1-based>` — เลขเดียวกับลิงก์ View ของหน้าสรุป/กลุ่ม) เป็น **URL เต็ม**: origin จาก request ของผู้เรียก (route อ่าน `x-forwarded-proto`/`x-forwarded-host` แล้ว fallback host ตรง ส่งเข้า parameter `origin` ของ `loadDocumentBundles`) + basePath ผ่าน `withBasePath` (เดิมเป็น relative เปลี่ยนตามคำขอผู้ใช้ 2026-09-25) — ลำดับ: documents ตาม `files.createdAt` (เหมือนหน้า `/groups/[typeId]` — ส่งทั้งคู่ = ยาว 1 ชิ้น) · annotations ตาม `(pageIndex, createdAt)` เหมือนหน้าสรุป F15/CSV · bookmarks ตาม N5 (`pageIndex → yOffset → createdAt`); timestamp ISO string ผ่าน mapper `toDTO` ทั้งหมด (invariant #5); `typeId: null` เมื่อไฟล์ไม่มีกลุ่ม และ **`externalFileId: null` เมื่อเอกสารในกลุ่มไม่มี fileId ของ e-Court** (ลงทะเบียนแบบ `url:` fallback หรืออัปโหลด N4 พร้อมช่องกลุ่ม — mirror กฎ typeId null)
- **ส่วนกลางของ route:** `lib/external-api.ts` — `parseQueryBody` (คืน discriminated 2 กรณี: `mode: "file"` มี fileId + typeId อาจมาด้วยสำหรับ AND / `mode: "type"` typeId เดี่ยว) + `loadDocumentBundles(fileRows, parts)` (**bulk** — `inArray` ทุกไฟล์ในชุด query เดียว: annotations/bookmarks คู่ขนาน + replies ตามหลัง = 3 query ต่อ request ไม่ว่ากลุ่มกี่ไฟล์ แทนยิง 3 query ต่อไฟล์พร้อมกันทั้งกลุ่ม, code review 2026-09-25) แล้วจัดกลุ่มรายไฟล์ใน memory map เป็น DTO — ใช้ function เดียวกันทุกทาง (เอกสารเดียว = array ยาว 1) ให้รูปร่าง bundle ตรงกันเป๊ะ; route **ครอบ try/catch ทุก DB call** — ล้ม = log ฝั่ง server + 500 JSON ตาม contract (ไม่ปล่อยเป็น 500 ของ framework ที่ format ต่าง)
- **ความปลอดภัย:** read-only ทั้งหมด — **ไม่เขียน DB ไม่ publish SSE ไม่ `revalidatePath`** (snapshot ณ ตอนเรียก — คนละ context กับ Server Action ใน invariant #4); ไม่ใส่ CORS headers (server-to-server — browser ไม่ควรเรียน); **REDACTION รวมใน annotations เมื่อเลือก include** (ผู้ถือ key เห็นตำแหน่งกล่องปิดพราง — DB เก็บแค่พิกัด ไม่มีเนื้อหาใต้กล่อง — key นี้เชื่อถือได้ระดับ backend e-Court เท่านั้น); annotation ทุกสถานะ (resolved ดูจาก field) ทุกผู้ใช้; route ทั้งสองเรียก `requireExternalApiKey` เป็นอย่างแรกก่อนแตะ DB
- **Out of scope ของ N10:** หลาย key/ตาราง key ใน DB/UI จัดการ key, rate limiting/pagination/caching, write API (สร้าง/แก้ annotation ผ่าน API), ดึง bytes ไฟล์ผ่าน API นี้, webhook/event push, XML/CSV, auth จริงเทียบระบบ e-Court

BIN
docs/requirement/1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

BIN
docs/requirement/2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 359 KiB

View file

@ -2,7 +2,7 @@
- **วันที่ตรวจสอบ:** 2026-09-14
- **อัปเดตล่าสุด:** 2026-09-17 — ปิด gap 4 ข้อ: 5.3.11 ✅ (N5), 5.3.12 ✅ (N6), 5.3.14 ✅ (API ส่งออก bookmark เป็น JSON), 5.3.15 ✅ (export PDF ของ F8 มี bookmark ติดไป) · 2026-09-15 — แก้ไขข้อความข้อ 5.3.18–5.3.25 ตามที่เจ้าของเอกสารยืนยันใหม่
- **เอกสาร requirement ต้นทาง:** ~~`docs/requirement/1.png`, `docs/requirement/2.png`~~ (ข้อกำหนดระบบบันทึกความเห็นของกรรมการ — Judges' Notes System — รูปทั้งสองถูกถอดออกจาก repo 2026-09-25 ตามคำขอผู้ใช้ ดูต่อได้จาก git history ก่อนหน้านี้)
- **เอกสาร requirement ต้นทาง:** `docs/requirement/1.png`, `docs/requirement/2.png` (ข้อกำหนดระบบบันทึกความเห็นของกรรมการ — Judges' Notes System)
- **โค้ดที่ใช้เทียบ:** branch `forgejo-dev` — ข้อ 5.3.11–5.3.15 อัปเดตตามโค้ดล่าสุด 2026-09-17 (รวม N5–N6 commit `a020e96` และ N7 → JSON export) · ตรวจครั้งแรก ณ commit `b3b884c` (implement ครบตาม PLAN.md + PLAN-FEAT.md แล้ว ยกเว้น F11 ที่ถูกถอดออก)
- **ช่วงเลขข้อ (ยืนยันโดยเจ้าของเอกสาร):** ภาพที่ 1 = ข้อ **5.3.1–5.3.6** / ภาพที่ 2 = ข้อ **5.3.7–5.3.25**
@ -48,7 +48,7 @@
| **5.3.11** แสดง bookmark ตามลำดับหน้าได้ | ✅ | ทำแล้ว (2026-09-16, N5): แท็บ "ที่คั่นหน้า" เรียงตาม **หน้า → ตำแหน่งบนหน้า (yOffset) → เวลาสร้าง** ทั้ง root ของต้นไม้และลูกในแม่เดียวกัน — ลำดับเดียวกันใช้ทุกที่: PDF outline ตอน export (F8), ผลค้นหา (N6), ส่งออก JSON (N7) |
| **5.3.12** สามารถกำหนดเงื่อนไขการค้นหา bookmarks ได้ | ✅ | ทำแล้ว (2026-09-16, N6): block ค้นหาเฉพาะแท็บ "ที่คั่นหน้า" — คำค้น (ชื่อ+โน้ต) + เงื่อนไข **ช่วงหน้า / ผู้บันทึก / ช่วงวันที่บันทึก** ผสมกันได้ + ปุ่มล้างเงื่อนไข (Server Action `searchBookmarks` — แผน `docs/PLAN-BOOKMARK.md`) |
| **5.3.13** แสดงผลรายการหน้าของเอกสารที่ bookmark ไว้ | ✅ | แท็บ Bookmarks ใน sidebar + หน้าสรุป (F15) |
| **5.3.14** จัดทำชุด API ดึงข้อมูล bookmark สำหรับเชื่อมต่อระบบอื่น | ✅ | ปัจจุบันตอบด้วย **N10 API** (2026-09-25): `POST /api/external/documents` (auth API Key `x-api-key`) คืน bookmark พร้อม annotation ทั้งเอกสาร/ทั้งกลุ่ม พร้อม filter ผู้บันทึก/ชนิด — เดิมเคยเป็น GET `/api/bookmarks/export?fileId=` (2026-09-17) แล้ว**ถูกถอด 2026-09-25** ตามคำขอผู้ใช้ เพราะ N10 ครอบคลุมข้อมูลชุดเดียวกันพร้อม auth |
| **5.3.14** จัดทำชุด API ดึงข้อมูล bookmark สำหรับเชื่อมต่อระบบอื่น | ✅ | ทำแล้ว (2026-09-17): GET `/api/bookmarks/export?fileId=` คืน **JSON** ข้อมูล bookmark ทั้งไฟล์ (envelope `fileId`/`title`/`exportedAt` + `BookmarkDTO[]` ครบทุก field รวม `parentId` โครงสร้างต้นไม้ เรียงตามลำดับหน้า) — ระบบอื่นดึงข้อมูลไปใช้ต่อได้ทันที (read-only, ยังไม่มี auth ตามขอบเขตเฟสแรก) |
| **5.3.15** ส่งออก (Export) รายการ bookmark เป็น PDF | ✅ | export PDF ของ F8 (`app/api/annotations/export/route.ts`) ฝัง bookmark เป็น **PDF outline** ติดไปกับไฟล์ (ต้นไม้ 3 ระดับ เรียงตามหน้า) — เจ้าของเอกสารยืนยัน (2026-09-17) ถือว่าตอบข้อนี้แล้ว · หมายเหตุ: รายงาน PDF แยกต่างหากของ N7 เคยทำแล้วถูกเปลี่ยนเป็น**ส่งออก JSON** ตามคำขอผู้ใช้ (2026-09-17) |
| **5.3.16** หน้าจอเชื่อมต่อกับระบบอื่น (Integrate) เป็น modal หรือ iframe | ❌ | เป็น standalone Next.js app — ไม่มีโหมด embed |
| **5.3.17** รองรับ Web Browser: Google Chrome, Microsoft Edge, Safari | ✅ | Web app มาตรฐาน — ทดสอบ desktop + iOS/Android แล้ว |
@ -70,7 +70,7 @@
- Authentication ผู้ใช้งาน + สิทธิการใช้งานตามระบบปกครอง (5.3.22, 5.3.23)
- Private/Group visibility ของ bookmark (5.3.9)
- ~~ชุด API สำหรับระบบภายนอก (5.3.14)~~ — **ปิดแล้ว**: ปัจจุบันตอบด้วย **N10 API** `POST /api/external/documents` (API Key) ครอบทั้ง bookmark+annotation+replies — เดิม GET `/api/bookmarks/export` (2026-09-17) ถูกถอด 2026-09-25 (ดูตาราง 5.3.14)
- ~~ชุด API สำหรับระบบภายนอก (5.3.14)~~ — **ปิดแล้ว (2026-09-17)**: GET `/api/bookmarks/export` คืน JSON ข้อมูล bookmark ทั้งไฟล์ ระบบอื่นดึงไปใช้ได้ (ดูตาราง 5.3.14)
- โหมด Integrate แบบ modal/iframe (5.3.16)
- เชื่อมโยงกับระบบงานคดีปกครอง — ทั้งเรียกเอกสารโดยตรงและเชื่อมต่อระบบ (5.3.1, 5.3.25)

View file

@ -1,43 +0,0 @@
// N10 (PLAN-API.md D1) — auth ของ /api/external/* (ผู้เรียก: backend e-Court,
// server-to-server ไม่มี browser)
// - API Key เดียวใน env EXTERNAL_API_KEY ส่งผ่าน header "x-api-key"
// - เทียบแบบ timing-safe: hash SHA-256 ทั้งสองค่าแล้ว timingSafeEqual
// (digest ยาว 32 ไบต์เท่ากันเสมอ — กัน timing + length leak)
// - env ไม่ตั้ง/ว่าง = ปฏิเสธทุก request (fail closed) — ไม่มี mode เปิดฟรี
// - ข้อความ 401 ของ "ไม่ใส่ key" กับ "key ผิด" เป็นอันเดียวกัน (กัน enumerate)
// - ห้าม log ค่า key / header ดิบ
//
// ห้าม import อะไรที่ลาก @/db มาด้วย (db/index.ts throw ตอน import เมื่อไม่มี
// DATABASE_URL) — unit test ของไฟล์นี้ต้องรันได้โดยไม่ต้องตั้งค่า DB เลย
import { createHash, timingSafeEqual } from "node:crypto";
/** Response ผิดพลาดของ API ภายนอกทุกกรณี — JSON {"error": "<ไทย>"} ตาม D2 */
export function externalApiError(message: string, status: number): Response {
return new Response(JSON.stringify({ error: message }), {
status,
headers: { "content-type": "application/json; charset=utf-8" },
});
}
/**
* คืน null เมื่อผ่าน / คืน Response 401 เมื่อไม่ผ่าน — route เรียกเป็นอย่างแรก
* ก่อนแตะ DB ทุกครั้ง (PLAN-API.md D3)
*/
export function requireExternalApiKey(req: Request): Response | null {
// เทียบกับค่า trim แล้ว — key สุ่มไม่ควรมี whitespace อยู่แล้ว และกัน
// trailing newline จาก K8s secret / docker env_file ทำ 401 ทั้งระบบตามผู้เรียก
// ที่ส่ง key ถูก (header เองก็ strip OWS รอบค่า ผู้เรียกส่ง whitespace ครอบไม่ได้)
const envKey = process.env.EXTERNAL_API_KEY?.trim();
if (envKey === undefined || envKey === "") {
return externalApiError("API ยังไม่ได้ตั้งค่าคีย์", 401);
}
const provided = req.headers.get("x-api-key");
if (provided === null || provided === "") {
return externalApiError("API key ไม่ถูกต้อง", 401);
}
const providedHash = createHash("sha256").update(provided).digest();
const envHash = createHash("sha256").update(envKey).digest();
return timingSafeEqual(providedHash, envHash)
? null
: externalApiError("API key ไม่ถูกต้อง", 401);
}

View file

@ -1,312 +0,0 @@
// N10 (PLAN-API.md D2) — ส่วนกลางของ POST /api/external/documents
// (ถามด้วย fileId และ/หรือ typeId — ส่งทั้งคู่ = AND เหลือเอกสารเดียว, คำขอผู้ใช้ 2026-09-25)
// ใช้ loadDocumentBundles (bulk) ร่วมทุกทางให้รูปร่าง bundle ตรงกันเป๊ะ
// read-only ทั้งหมด: ไม่เขียน DB ไม่ publish SSE ไม่ revalidatePath
// (snapshot ณ ตอนเรียก — คนละ context กับ Server Action ใน invariant #4)
import { and, asc, inArray } from "drizzle-orm";
import { db } from "@/db";
import {
annotationReplies,
annotationType,
annotations,
bookmarks,
files,
toAnnotationDTO,
toBookmarkDTO,
toReplyDTO,
type AnnotationDTO,
type AnnotationRow,
type AnnotationType,
type BookmarkRow,
type BookmarkDTO,
type ReplyDTO,
} from "@/db/schema";
import { withBasePath } from "@/utils/base-path";
type FileRow = typeof files.$inferSelect;
/** ข้อมูลเอกสารใน response ของ N10 — externalFileId เป็น null สำหรับไฟล์
* url:-fallback ของ N8 หรืออัปโหลด N4 ที่อยู่ในกลุ่ม (mirror กฎ typeId: null) */
export type ExternalDocumentDTO = {
id: string;
externalFileId: string | null;
title: string;
typeId: string | null;
createdAt: string;
};
/** AnnotationDTO พร้อม url (deep link F12 `?ann=`) + replies ต่อรายการ
* (PLAN-API.md D2 — url เป็น URL เต็ม: origin จาก request ที่เรียกเข้ามา
* ส่งผ่าน parameter `origin` ของ loadDocumentBundles + basePath) */
export type ExternalAnnotationDTO = AnnotationDTO & {
url: string;
replies: ReplyDTO[];
};
/** BookmarkDTO พร้อม url — ไปที่หน้าของ bookmark ด้วย `?page=` 1-based
* (เลขเดียวกับลิงก์ View ของหน้าสรุป/กลุ่ม — F12 ไม่มี anchor รายการ bookmark) */
export type ExternalBookmarkDTO = BookmarkDTO & { url: string };
/** bundle ยุบชั้น (2026-09-25 ตามคำขอผู้ใช้): field ของเอกสารอยู่ระดับเดียวกับ
* annotations/bookmarks เลย — ไม่มี object `document` ซ้อนอีกต่อไป */
export type DocumentBundle = ExternalDocumentDTO & {
annotations: ExternalAnnotationDTO[];
bookmarks: ExternalBookmarkDTO[];
};
export type IncludeParts = {
annotations: boolean;
bookmarks: boolean;
/** ผู้บันทึก (filter.userId normalize เป็น array) — ว่าง = ทุกคน; กรองตามเจ้าของ
* รายการทั้ง annotations/bookmarks (replies ตามมาครบกับ annotation ที่ผ่านตัวกรอง) */
userIds: string[];
/** ชนิดของ annotation (filter.annotationTypes) — ว่าง = ทุกชนิด; มีผลเฉพาะเมื่อรวม annotations */
annotationTypes: AnnotationType[];
};
const ECOURT_PREFIX = "ecourt:";
function externalDocumentDTO(row: FileRow): ExternalDocumentDTO {
return {
id: row.id,
externalFileId:
row.externalId && row.externalId.startsWith(ECOURT_PREFIX)
? row.externalId.slice(ECOURT_PREFIX.length)
: null,
title: row.title,
typeId: row.typeId,
createdAt: row.createdAt.toISOString(),
};
}
/**
* validate JSON body ของ POST /api/external/documents (PLAN-API.md D2) — strict กัน
* typo เงียบ ๆ: รับเฉพาะ fileId/typeId/filter · fileId/typeId ต้องเป็น string
* (trim แล้วว่าง = ถือว่าไม่ได้ส่ง field นั้น) · ต้องมี fileId หรือ typeId
* อย่างน้อยหนึ่งอย่าง — ส่งทั้งคู่ได้ = กรองเฉพาะเอกสารที่ตรงทั้งสองเงื่อนไข
* (AND — คำขอผู้ใช้ 2026-09-25 หลังทดสอบจริง · ก่อนหน้าเคยเป็น OR แล้วเป็น
* 400 กำกวม) · filter ถ้าส่งต้องเป็น object ที่มีเฉพาะ field type/userId/
* annotationTypes — type ค่า "bookmarks"/"annotations" (ไม่ส่ง = ทั้งสอง ส่วน
* ที่ไม่เลือกคืนเป็น array ว่างตาม contract คงที่) · userId (string หรือ array)
* กรองตามเจ้าของรายการทั้ง annotations/bookmarks · annotationTypes (string
* หรือ array ค่า enum annotation_type — ค่าอื่น = 400 บอกค่าที่รับ) มีผล
* เฉพาะ annotations (เพิ่ม 2026-09-25 ตามคำขอผู้ใช้ · เดิมชื่อ field "types"
* เปลี่ยน 2026-09-25 กันสับสนกับ filter.type)
*/
export type ParsedQueryBody =
| { ok: false; error: string }
| { ok: true; mode: "file"; fileId: string; typeId: string | undefined; parts: IncludeParts }
| { ok: true; mode: "type"; typeId: string; parts: IncludeParts };
const BODY_FIELDS = ["fileId", "typeId", "filter"];
function trimOrUndefined(value: unknown): string | undefined {
return typeof value === "string" && value.trim() !== "" ? value.trim() : undefined;
}
/** normalize filter field ที่รับ string เดี่ยวหรือ array — trim แล้วตัดค่าว่างทิ้ง
* (ผลลัพธ์ว่าง = ไม่ได้กรอง กฎเดียวกับ fileId ว่าง = ไม่ได้ส่ง) */
function stringOrArray(
value: unknown,
field: string,
): { ok: true; values: string[] } | { ok: false; error: string } {
if (value === undefined) return { ok: true, values: [] };
const raw = Array.isArray(value) ? value : [value];
for (const item of raw) {
if (typeof item !== "string") {
return { ok: false, error: `filter.${field} ต้องเป็น string หรือ array ของ string` };
}
}
return { ok: true, values: raw.map((item) => item.trim()).filter((item) => item !== "") };
}
export function parseQueryBody(body: unknown): ParsedQueryBody {
if (body === null || typeof body !== "object" || Array.isArray(body)) {
return { ok: false, error: "body ต้องเป็น JSON object" };
}
const raw = body as Record<string, unknown>;
for (const key of Object.keys(raw)) {
if (!BODY_FIELDS.includes(key)) {
return {
ok: false,
error: `ไม่รู้จัก field '${key}' (รับเฉพาะ fileId, typeId, filter)`,
};
}
}
if (raw.fileId !== undefined && typeof raw.fileId !== "string") {
return { ok: false, error: "fileId ต้องเป็น string" };
}
if (raw.typeId !== undefined && typeof raw.typeId !== "string") {
return { ok: false, error: "typeId ต้องเป็น string" };
}
const fileId = trimOrUndefined(raw.fileId);
const typeId = trimOrUndefined(raw.typeId);
const parts: IncludeParts = {
annotations: true,
bookmarks: true,
userIds: [],
annotationTypes: [],
};
if (raw.filter !== undefined) {
const filter = raw.filter;
if (filter === null || typeof filter !== "object" || Array.isArray(filter)) {
return { ok: false, error: 'filter ต้องเป็น object เช่น { "type": "bookmarks" }' };
}
for (const key of Object.keys(filter)) {
if (key !== "type" && key !== "userId" && key !== "annotationTypes") {
return {
ok: false,
error: "filter รับเฉพาะ field 'type', 'userId', 'annotationTypes'",
};
}
}
const filterRecord = filter as Record<string, unknown>;
const type = filterRecord.type;
if (type !== undefined && !(typeof type === "string" && type.trim() === "")) {
// "" หลัง trim = ไม่ได้กรอง เอาทั้งสอง — กฎเดียวกับ fileId ว่าง = ไม่ได้ส่ง
// (คำขอผู้ใช้ 2026-09-24 · เดิมรอบสองเป็น strict: ค่าใดที่ไม่ใช่สองค่านี้ = 400)
if (type !== "bookmarks" && type !== "annotations") {
return { ok: false, error: "filter.type รับค่า bookmarks หรือ annotations เท่านั้น" };
}
parts.annotations = type === "annotations";
parts.bookmarks = type === "bookmarks";
}
// userId กรองตามเจ้าของรายการทั้ง annotations/bookmarks — ไม่มีจริงใน DB = รายการ
// ว่าง ไม่ใช่ 400 (ผู้ใช้เปลี่ยนได้ตลอด ตรวจก็เสีย query) · annotationTypes ค่าไม่
// รู้จัก = 400 กัน typo เงียบเหมือน filter.type — มีผลเฉพาะ annotations (เลือก
// bookmarks อย่างเดียว = ไม่มี annotation ให้กรองอยู่แล้ว)
const userIds = stringOrArray(filterRecord.userId, "userId");
if (!userIds.ok) return { ok: false, error: userIds.error };
parts.userIds = userIds.values;
const annotationTypes = stringOrArray(filterRecord.annotationTypes, "annotationTypes");
if (!annotationTypes.ok) return { ok: false, error: annotationTypes.error };
// enumValues ของ drizzle เป็น tuple [AnnotationType, ...] ทำ includes(string)
// ไม่ได้ — ขยายเป็น readonly string[] แล้วตรวจผ่าน type guard แทน as cast
const enumValues: readonly string[] = annotationType.enumValues;
const isAnnotationType = (value: string): value is AnnotationType =>
enumValues.includes(value);
if (!annotationTypes.values.every(isAnnotationType)) {
return {
ok: false,
error: `filter.annotationTypes รับค่า ${annotationType.enumValues.join(", ")} เท่านั้น`,
};
}
parts.annotationTypes = annotationTypes.values.filter(isAnnotationType);
}
// mode "file" = มี fileId (typeId อาจมาด้วยสำหรับ AND) / mode "type" = typeId เดี่ยว —
// discriminated ให้ route ใช้ typeId ของ mode "type" ได้โดยไม่ต้อง assert
if (fileId) return { ok: true, mode: "file", fileId, typeId, parts };
if (typeId) return { ok: true, mode: "type", typeId, parts };
return { ok: false, error: "ต้องระบุ fileId หรือ typeId อย่างน้อยหนึ่งอย่าง" };
}
/**
* โหลด bundle ของหลายเอกสารด้วยชุด query รวม (bulk) — annotations/bookmarks
* คู่ขนานด้วย inArray ต่อทุกไฟล์ + replies ตามหลัง = 3 query ต่อ request ไม่ว่า
* กลุ่มจะกี่ไฟล์ (แทนยิง 3 query ต่อไฟล์พร้อมกันทั้งกลุ่มด้วย Promise.all —
* code review 2026-09-25: กลุ่มใหญ่กด connection pool) แล้วจัดกลุ่มรายไฟล์ใน
* memory โดย push ตามลำดับ query จึงรักษาลำดับเดิม: annotations (pageIndex,
* createdAt) เหมือนหน้าสรุป F15/CSV · bookmarks ตาม N5 (pageIndex → yOffset →
* createdAt) · replies ของทุก annotation เรียง createdAt (pattern เดียวกับ
* initial data ของหน้า annotate)
*/
export async function loadDocumentBundles(
fileRows: FileRow[],
parts: IncludeParts,
origin: string,
): Promise<DocumentBundle[]> {
if (fileRows.length === 0) return [];
const fileIds = fileRows.map((row) => row.id);
// annotations กับ bookmarks อิสระต่อกันจึงยิงคู่ขนาน (pattern หน้า annotate) —
// ส่วนที่ไม่เลือกไม่ยิง query เลย คืน array ว่างตาม contract คงที่;
// userIds/annotationTypes กรองใน SQL เลย (ว่าง = ไม่ใส่เงื่อนไข = ทุกคน/ทุกชนิด)
const [annotationRows, bookmarkRows] = await Promise.all([
parts.annotations
? db
.select()
.from(annotations)
.where(
and(
inArray(annotations.fileId, fileIds),
...(parts.userIds.length
? [inArray(annotations.userId, parts.userIds)]
: []),
...(parts.annotationTypes.length
? [inArray(annotations.type, parts.annotationTypes)]
: []),
),
)
.orderBy(asc(annotations.pageIndex), asc(annotations.createdAt))
: ([] as AnnotationRow[]),
parts.bookmarks
? db
.select()
.from(bookmarks)
.where(
and(
inArray(bookmarks.fileId, fileIds),
...(parts.userIds.length ? [inArray(bookmarks.userId, parts.userIds)] : []),
),
)
.orderBy(
asc(bookmarks.pageIndex),
asc(bookmarks.yOffset),
asc(bookmarks.createdAt),
)
: ([] as BookmarkRow[]),
]);
const annotationIds = annotationRows.map((row) => row.id);
const replyRows = annotationIds.length
? await db
.select()
.from(annotationReplies)
.where(inArray(annotationReplies.annotationId, annotationIds))
.orderBy(asc(annotationReplies.createdAt))
: [];
const repliesByAnnotation = new Map<string, ReplyDTO[]>();
for (const row of replyRows) {
const dto = toReplyDTO(row);
const list = repliesByAnnotation.get(dto.annotationId);
if (list) list.push(dto);
else repliesByAnnotation.set(dto.annotationId, [dto]);
}
// จัดกลุ่มรายไฟล์ (push ตามลำดับ query = ลำดับในไฟล์เดียวกันคงเดิม ไม่ต้อง
// sort ซ้ำ; ลำดับ documents ตาม fileRows ที่ route ส่งมา) — url กลับมาหน้า
// viewer เป็น URL เต็ม (คำขอผู้ใช้ 2026-09-25): origin ที่ route อ่านจาก
// request ของผู้เรียก + basePath ผ่าน withBasePath (อ่านค่าจาก env ที่ inline
// ตอน build) — annotation ไปรายการด้วย ?ann= · bookmark ไปหน้าด้วย ?page=
// 1-based เหมือนลิงก์ View ของหน้าสรุป/กลุ่ม
const annotationsByFile = new Map<string, ExternalAnnotationDTO[]>();
for (const row of annotationRows) {
const dto: ExternalAnnotationDTO = {
...toAnnotationDTO(row),
url: `${origin}${withBasePath(`/annotate/${row.fileId}`)}?ann=${row.id}`,
replies: repliesByAnnotation.get(row.id) ?? [],
};
const list = annotationsByFile.get(row.fileId);
if (list) list.push(dto);
else annotationsByFile.set(row.fileId, [dto]);
}
const bookmarksByFile = new Map<string, ExternalBookmarkDTO[]>();
for (const row of bookmarkRows) {
const dto: ExternalBookmarkDTO = {
...toBookmarkDTO(row),
url: `${origin}${withBasePath(`/annotate/${row.fileId}`)}?page=${row.pageIndex + 1}`,
};
const list = bookmarksByFile.get(row.fileId);
if (list) list.push(dto);
else bookmarksByFile.set(row.fileId, [dto]);
}
return fileRows.map((file) => ({
...externalDocumentDTO(file),
annotations: annotationsByFile.get(file.id) ?? [],
bookmarks: bookmarksByFile.get(file.id) ?? [],
}));
}

View file

@ -12,7 +12,6 @@ import { PdfPathError, readPublicPdf } from "@/lib/server-pdf";
import {
externalIdFor,
isHttpUrl,
normDocumentGroup,
titleFromExternalUrl,
validateExternalFileUrl,
type ExternalFileUrlError,
@ -30,6 +29,12 @@ const URL_ERROR_TEXT: Record<ExternalFileUrlError, string> = {
host_not_allowed: "โฮสต์ของลิงก์ไฟล์ไม่อยู่ในรายการอนุญาต",
};
/** รหัสกลุ่ม normalize: trim → ว่าง = ไม่มี, cap 200 ตัวอักษร (เทียบ title) */
function normGroup(s: string | undefined): string | undefined {
const v = (s ?? "").trim().slice(0, 200);
return v || undefined;
}
/** path ในเครื่อง (ทดสอบใน dev เช่น /samples/…): decode %20, ห้าม .., นามสกุลผ่าน
* whitelist และต้องมีไฟล์จริงใน public/ ผ่าน path guard เดียวกับ viewer/export */
function validateLocalPath(
@ -71,7 +76,7 @@ export async function registerExternalFile(
trimmed = `https://${trimmed}`;
}
const typeId = normDocumentGroup(rawTypeId) ?? undefined;
const typeId = normGroup(rawTypeId);
// สองทาง: https ของ e-Court (ตามสัญญาลิงก์ — validate + allowlist) หรือ path ในเครื่อง
// (ทดสอบ dev เช่น /samples/…) — ทั้งคู่เก็บลง pdf_url เหมือนกัน ต่างกันแค่ตอนเปิดดู

View file

@ -8,7 +8,7 @@
"test": "vitest run",
"start": "next start",
"seed-samples": "node --env-file=.env scripts/seed-samples.mjs",
"postinstall": "node scripts/patch-pdf-worker.mjs"
"postinstall": "node -e \"require('fs').copyFileSync('node_modules/pdfjs-dist/build/pdf.worker.min.mjs','public/pdf.worker.min.mjs')\""
},
"dependencies": {
"@pdf-lib/fontkit": "^1.1.1",

File diff suppressed because one or more lines are too long

View file

@ -1,44 +0,0 @@
// คัดลอก pdf.worker.min.mjs จาก node_modules ไป public/ แล้ว patch heuristic
// การแทรก fake space กลางคำไทย (bug report 2026-09-24):
//
// pdf.js (ทั้ง v4.10 จนถึง master ปัจจุบัน) เก็บตำแหน่งอ้างอิงของ glyph ก่อนหน้า
// เฉพาะ glyph ที่มีความกว้าง ≠ 0 เท่านั้น:
// if (scaledDim) { textChunk.prevTransform = getCurrentTextTransform(); }
// สระบน-ล่าง/วรรณยุกต์ไทยเป็น glyph กว้าง 0 แต่ charSpacing ของ glyph เหล่านั้น
// ยังขยับตำแหน่ง x อยู่ — พอข้าม glyph กว้าง 0 ตั้งแต่ 2 ตัวขึ้นไป gap สะสม
// (charSpacing ต่อ glyph × จำนวน) จะเกิน spaceInFlowMin จน addFakeSpaces แทรก
// " " กลางคำ ("ฉะนั้ น ให้ ท่ าน") ทั้งที่บนกระดาษตัวอักษรติดกันสนิท
//
// แก้โดยถอดเงื่อนไข if ให้อัปเดต prevTransform ทุก glyph — glyph กว้าง 0 ที่
// charSpacing = 0 ไม่ขยับตำแหน่งอยู่แล้ว (compareWithLastPosition early-return)
// จึงไม่มีผลกับเอกสารทั่วไป ส่วน space จริง (glyph SPACE) ไปทาง saveLastChar
// ซึ่งไม่เกี่ยวกับจุดนี้ จึงยังคงอยู่ครบ
//
// ทำบนไฟล์ minified ด้วย pattern จำเพาะ (ชื่อตัวแปร minify อาจเปลี่ยนตามเวอร์ชัน):
// เจอ ≠ 1 จุด = โครงสร้างโค้ดเปลี่ยนหลังอัปเกรด pdfjs-dist → fail ดัง ๆ ทันที
// ไม่ปล่อยให้ patch หลุดเงียบ ๆ แล้ว fake space กลับมาโดยไม่มีใครรู้ตัว
import { readFileSync, writeFileSync } from "node:fs";
const SRC = "node_modules/pdfjs-dist/build/pdf.worker.min.mjs";
const DEST = "public/pdf.worker.min.mjs";
const code = readFileSync(SRC, "utf8");
// src/core/evaluator.js: if (scaledDim) { textChunk.prevTransform = getCurrentTextTransform(); }
// minified (v4.10.38): C&&(h.prevTransform=getCurrentTextTransform());
// lookbehind (?<![.\w$]) กันจับ identifier ต่อท้าย member expression ผิด ๆ หลังอัปเกรด
// (เช่น a.b&&(...) จะ match แค่ b&& แล้ว replace ได้ a.h.prevTransform=… พัดเงียบ)
const PATCH_RE =
/(?<![.\w$])[A-Za-z$_][\w$]*&&\(([A-Za-z$_][\w$]*\.prevTransform=getCurrentTextTransform\(\))\);/g;
const found = [...code.matchAll(PATCH_RE)];
if (found.length !== 1) {
console.error(
`[patch-pdf-worker] หา pattern ไม่ได้ตามที่คาด (เจอ ${found.length} จุด ต้องการ 1) — ` +
`pdfjs-dist อัปเกรดแล้วโครงสร้างโค้ดเปลี่ยน ปรับ PATCH_RE ใน scripts/patch-pdf-worker.mjs`,
);
process.exit(1);
}
writeFileSync(DEST, code.replace(PATCH_RE, "$1;"));
console.log("[patch-pdf-worker] คัดลอก worker + patch fake-space ไทยเรียบร้อย");

View file

@ -6,69 +6,7 @@ const config: Config = {
extend: {
colors: {
// สีหลักของระบบ (accent ทุกจุด: ปุ่ม active/action, กรอบ selection, หัวข้อหลัก)
// ห้ามเปลี่ยนโดยไม่เช็ค contrast บนขาว/canvas ≥3:1 — เป็นสี focus outline ทึบ
// ของ Genesis (.focus-ring ใน globals.css; #0033A1 ปัจจุบัน ≈10.6:1)
primary: "rgb(0 51 161 / <alpha-value>)",
// Genesis (docs/DESIGN.md 2026-09-23): palette ส่วนที่เหลือของ design system —
// naming หลีก "primary/secondary" ของข้อความเพื่อไม่ชนกับ primary น้ำเงินด้านบน
"primary-hover": "#002780",
success: "#10B981",
warning: "#F59E0B",
error: "#EF4444",
// ระดับข้อความ/ไอคอนสถานะบนพื้นขาว (UX review 2026-09-23): ตัว 500 บนขาวได้แค่
// 2.2–3.8:1 ไม่ผ่าน WCAG 4.5:1 ของตัวอักษร — ข้อความ/ไอคอนสถานะใช้ค่า -ink
// (ผ่าน 5:1 ขึ้นไปทั้งชุด) ส่วน 500 คงไว้สำหรับ border/bg tint (เช่น bg-warning/15)
"success-ink": "#047857",
"warning-ink": "#B45309",
"error-ink": "#B91C1C",
canvas: "#FAFAFA",
line: "#E8E8EC",
ink: "#0A0A0A",
"ink-soft": "#6B6B6B",
"ink-faint": "#9C9C9C",
},
fontFamily: {
// Genesis: display = General Sans, body = DM Sans — ทั้งคู่ Latin เท่านั้น
// ใส่ Sarabun ต่อท้ายเป็น fallback ไทย (glyph ไทยตกไป Sarabun อัตโนมัติ)
display: [
"var(--font-general-sans)",
"var(--font-sarabun)",
"-apple-system",
"Segoe UI",
"Leelawadee UI",
"Thonburi",
"Noto Sans Thai",
"sans-serif",
],
body: [
"var(--font-dm-sans)",
"var(--font-sarabun)",
"-apple-system",
"Segoe UI",
"Leelawadee UI",
"Thonburi",
"Noto Sans Thai",
"sans-serif",
],
},
fontSize: {
// Genesis type scale: Display 72 / Headline 60 / Section 32 / Subhead 24 /
// Body 15 / Small 13 / Caption 12 / Overline 11 — heading ผูก bold + tracking
// แน่นตาม spec (-0.03em ถึง -0.04em) ให้ไทย (Sarabun) ได้ weight 700 ตาม
display: ["4.5rem", { lineHeight: "1.02", letterSpacing: "-0.04em", fontWeight: "700" }],
headline: ["3.75rem", { lineHeight: "1.05", letterSpacing: "-0.04em", fontWeight: "700" }],
"section-title": ["2rem", { lineHeight: "1.2", letterSpacing: "-0.03em", fontWeight: "700" }],
subhead: ["1.5rem", { lineHeight: "1.3", letterSpacing: "-0.02em", fontWeight: "700" }],
body: ["0.9375rem", { lineHeight: "1.65" }],
small: ["0.8125rem", { lineHeight: "1.55" }],
caption: ["0.75rem", { lineHeight: "1.5" }],
overline: ["0.6875rem", { lineHeight: "1.4", letterSpacing: "0.08em", fontWeight: "500" }],
},
boxShadow: {
// Genesis elevation: card ได้เงาเฉพาะตอน hover (static ไม่มีเงา) +
// ปุ่ม primary ได้ glow ฟ้าตอน hover / dropdown ใช้ shadow-lg ของ default
"card-hover": "0 8px 30px rgba(0,0,0,0.08)",
"btn-glow": "0 4px 12px rgba(0,51,161,0.35)",
},
},
},

Binary file not shown.

View file

@ -1,74 +0,0 @@
import { afterEach, describe, expect, it } from "vitest";
import { requireExternalApiKey } from "@/lib/external-api-auth";
// N10 (PLAN-API.md D1) — auth ของ /api/external/*: fail closed เมื่อ env ว่าง +
// ข้อความ 401 อันเดียวกันทั้งไม่ใส่ key และ key ผิด (กัน enumerate) — ไฟล์นี้
// ตั้งใจไม่แตะ module ใดที่ลาก @/db มาด้วย (db/index.ts throw ตอน import เมื่อ
// ไม่มี DATABASE_URL — vitest ไม่โหลด .env)
const HAD_KEY = Object.prototype.hasOwnProperty.call(process.env, "EXTERNAL_API_KEY");
const ORIG_KEY = process.env.EXTERNAL_API_KEY;
function restoreKey() {
if (HAD_KEY) process.env.EXTERNAL_API_KEY = ORIG_KEY;
else delete process.env.EXTERNAL_API_KEY;
}
function reqWithKey(key?: string): Request {
const headers = new Headers();
if (key !== undefined) headers.set("x-api-key", key);
return new Request("http://localhost/docedit/note/api/external/documents", { headers });
}
async function bodyOf(res: Response): Promise<{ error: string }> {
expect(res.headers.get("content-type")).toBe("application/json; charset=utf-8");
return (await res.json()) as { error: string };
}
describe("requireExternalApiKey", () => {
afterEach(restoreKey);
it("env ไม่ตั้ง / ว่าง / whitespace ล้วน = 401 fail closed (แม้จะส่ง key มาถูก)", async () => {
for (const empty of [undefined, "", " "]) {
if (empty === undefined) delete process.env.EXTERNAL_API_KEY;
else process.env.EXTERNAL_API_KEY = empty;
const res = requireExternalApiKey(reqWithKey("correct-key"));
expect(res).not.toBeNull();
expect(res!.status).toBe(401);
expect(await bodyOf(res!)).toEqual({ error: "API ยังไม่ได้ตั้งค่าคีย์" });
}
});
it("ไม่ใส่ header กับ key ผิด = 401 ข้อความ/รูปร่างเดียวกัน (ไม่บอกว่าขาดอะไร)", async () => {
process.env.EXTERNAL_API_KEY = "correct-key";
const missing = requireExternalApiKey(reqWithKey());
const wrong = requireExternalApiKey(reqWithKey("wrong-key"));
expect(missing!.status).toBe(401);
expect(wrong!.status).toBe(401);
const missingBody = await bodyOf(missing!);
const wrongBody = await bodyOf(wrong!);
expect(missingBody).toEqual(wrongBody);
expect(Object.keys(missingBody)).toEqual(["error"]);
});
it("key ถูก = ผ่าน (คืน null)", () => {
process.env.EXTERNAL_API_KEY = "correct-key";
expect(requireExternalApiKey(reqWithKey("correct-key"))).toBeNull();
});
it("env มี whitespace ครอบ (trailing newline ของ K8s secret/env_file) = เทียบด้วยค่า trim แล้ว", () => {
process.env.EXTERNAL_API_KEY = " correct-key\n";
expect(requireExternalApiKey(reqWithKey("correct-key"))).toBeNull();
expect(requireExternalApiKey(reqWithKey("wrong-key"))!.status).toBe(401);
});
it("key ยาว (300+ ตัวอักษร) เทียบตรง ๆ ได้ — ต่างแม้ตัวเดียว = 401", () => {
// header เป็น ByteString จึงทดสอบด้วย latin1 เท่านั้น (ไทย/unicode ส่งผ่าน
// header ไม่ได้ตั้งแต่ใน Headers.set แล้ว)
const key = "k".repeat(300) + "-ok";
process.env.EXTERNAL_API_KEY = key;
expect(requireExternalApiKey(reqWithKey(key))).toBeNull();
expect(requireExternalApiKey(reqWithKey(key.slice(0, -1)))!.status).toBe(401);
});
});

View file

@ -1,68 +0,0 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
import { describe, expect, it } from "vitest";
// patch fake-space ไทยของ pdf.js worker — scripts/patch-pdf-worker.mjs (bug 2026-09-24):
// pdf.js เก็บตำแหน่งอ้างอิง glyph ก่อนหน้า (prevTransform) เฉพาะ glyph ที่กว้าง ≠ 0
// ทำให้ charSpacing ของสระบน-ล่าง/วรรณยุกต์ (glyph กว้าง 0) สะสมเป็น gap จน
// addFakeSpaces แทรก " " กลางคำ ("ฉะนั้ น") — patch ถอด if ครอบการอัปเดตออก
// ให้อัปเดตทุก glyph แทน
const ROOT = process.cwd();
const WORKER = join(ROOT, "public", "pdf.worker.min.mjs");
// ไฟล์ Word→PDF ที่ justify ด้วย letter-spacing ทุก glyph (มี charSpacing ~0.05×fontSize)
// — เงื่อนไขที่ทำให้ heuristic ของ pdf.js แทรก fake space กลางคำไทย
// (อยู่ใน tests/fixtures/ ไม่ใช่ public/samples/ — ไม่ให้ seed-samples สร้าง
// รายการเอกสาร "ทดสอบ" บนหน้ารายการของทุก deploy)
const SAMPLE = join(ROOT, "tests", "fixtures", "thai_letterspacing.pdf");
describe("public/pdf.worker.min.mjs — ผ่าน patch fake-space ไทย", () => {
const code = readFileSync(WORKER, "utf8");
it("อัปเดต prevTransform ทุก glyph (รูปแบบก่อนแพตช์ C&&(h.prevTransform=…) หายไป)", () => {
// หลัง patch: }h.prevTransform=getCurrentTextTransform(); (เคยถูก if ครอบ
// จนเหลือ } ปิด block เปล่านำหน้า)
expect(code).toMatch(
/[;{}][A-Za-z$_][\w$]*\.prevTransform=getCurrentTextTransform\(\);/,
);
// ก่อน patch: C&&(h.prevTransform=getCurrentTextTransform());
expect(code).not.toMatch(
/&&\([A-Za-z$_][\w$]*\.prevTransform=getCurrentTextTransform\(\)\);/,
);
});
});
describe("getTextContent ผ่าน worker ที่ patch แล้ว — ไฟล์ justify letter-spacing", () => {
it("ไม่มี fake space กลางคำไทย (space ตามด้วยสระ/วรรณยุกต์ = ผิดเสมอ)", async () => {
const pdfjs = await import("pdfjs-dist/legacy/build/pdf.mjs");
pdfjs.GlobalWorkerOptions.workerSrc = pathToFileURL(WORKER).href;
const doc = await pdfjs.getDocument({
data: new Uint8Array(readFileSync(SAMPLE)),
}).promise;
// space หน้า combining mark ไม่มีทางถูกต้องในภาษาไทย (mark ต้องเกาะฐานหน้า)
const badRe = / [ัิีึืุู็่้๊๋์ํ]/;
const offenders: string[] = [];
let targetLine = "";
for (let p = 1; p <= doc.numPages; p++) {
const tc = await (await doc.getPage(p)).getTextContent();
for (const item of tc.items) {
if (!("str" in item)) continue;
if (!targetLine && item.str.includes("ฉะนั้น")) targetLine = item.str;
if (badRe.test(item.str)) {
offenders.push(`p${p}: ${JSON.stringify(item.str.slice(0, 60))}`);
}
}
}
expect(offenders).toEqual([]);
// บรรทัดเป้าหมาย (เคยได้ "ฉะนั้ น ให้ ท่ าน…คำฟ้ อง") — space จริง (glyph SPACE
// ในไฟล์) คงอยู้ครบ ส่วนช่อง letter-spacing เล็ก ๆ ที่ไม่ใช่ space จริงติดกัน
// ("ให้การแก้" — ในไฟล์ไม่มี space glyph ตรงนั้น)
expect(targetLine).toBe(
"ฉะนั้น ให้ ท่านทำคำให้การแก้คำฟ้อง พร้อมด้วยพยานหลักฐาน ๑ ชุด",
);
});
});

36
tests/utils/csv.test.ts Normal file
View file

@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import { csvField } from "@/utils/csv";
describe("csvField", () => {
it("ครอบ double quote ตาม RFC 4180", () => {
expect(csvField("ทดสอบ")).toBe('"ทดสอบ"');
});
it("escape double quote ในค่าเป็น \"\"", () => {
expect(csvField('พูดว่า "สวัสดี"')).toBe('"พูดว่า ""สวัสดี"""');
});
it("ตัวเลขหน้าไม่ถูกนำหน้าอะไร", () => {
expect(csvField("1")).toBe('"1"');
expect(csvField("12")).toBe('"12"');
});
it("FIX-6: ขึ้นต้นด้วย = + - @ ต้องนำหน้าด้วย ' กันสูตร Excel", () => {
expect(csvField("=1+1")).toBe('"\'=1+1"');
expect(csvField("+SUM(A1:A2)")).toBe('"\'+SUM(A1:A2)"');
expect(csvField("-2+3")).toBe('"\'-2+3"');
expect(csvField("@SUM(A1)")).toBe('"\'@SUM(A1)"');
});
it("FIX-6: ขึ้นต้นด้วย TAB/CR ต้องนำหน้าด้วย ' เช่นกัน", () => {
expect(csvField("\t=cmd")).toBe('"\'\t=cmd"');
expect(csvField("\r=cmd")).toBe('"\'\r=cmd"');
});
it("อักขระพิเศษที่ไม่ใช่จุดเริ่มสูตรไม่ถูกนำหน้า", () => {
expect(csvField("1+1")).toBe('"1+1"');
expect(csvField("a=b")).toBe('"a=b"');
expect(csvField("ราคา -50 บาท")).toBe('"ราคา -50 บาท"');
});
});

View file

@ -50,13 +50,6 @@ export function effectiveDrawOpacity(d: { pen?: DrawPen; opacity?: number }): nu
return d.opacity ?? DRAW_PENS[d.pen ?? "pencil"].opacity;
}
// ค่า default สีตอนสร้างรายการใหม่ของ tool shape/highlight — ค่าเดียวกับ preset ใน
// INK_COLORS ของ ToolControls.tsx (แดง = ปากกา/รูปทรง, เหลือง = ไฮไลต์) ประกาศเป็น
// named constant ตาม pattern TEXTBOX_DEFAULT_COLOR (design audit 2026-09-24 — เดิม
// PDFViewer ถือ HIGHLIGHT_COLORS 5 สีเอง ทั้งที่ใช้จริงแค่ตัวแรก ซ้ำกับ preset)
export const SHAPE_DEFAULT_COLOR = "#ef4444";
export const HIGHLIGHT_DEFAULT_COLOR = "#fde047";
// ค่า default ตอนสร้าง TEXTBOX (F17/B2) — fontSize เป็น % ของความกว้างหน้า
export const TEXTBOX_DEFAULT_COLOR = "#111827";
// M2: 2.5% ≈ 15px ที่หน้า A4 (595pt, scale 1) — อ่านออกทันทีที่พิมพ์เสร็จ (เดิม 1.2% ≈ 7px
@ -114,23 +107,6 @@ export function sanitizePdfText(s: string): string {
.trim();
}
/** ชื่อย่อต่อชนิดสำหรับบรรทัดสรุปของแต่ละหน้า (หน้าสรุป F15) + แผงตัวกรอง
* explorer — ชื่อเต็มอยู่ที่ annotationTypeLabel (เดิม define ซ้ำ 2 ที่: หน้าสรุป
* กับ FilterControls ของหน้ากลุ่ม รวมมาที่นี่ตอนแยกแผงเป็นกลาง 2026-09-24) */
export const TYPE_SHORT: Record<AnnotationType, string> = {
HIGHLIGHT: "ไฮไลต์",
DRAW: "เส้นวาด",
COMMENT: "คอมเมนต์",
SHAPE: "รูปทรง",
REDACTION: "ปิดพราง",
TEXTBOX: "กล่องข้อความ",
STAMP: "ตรายาง",
};
/** ลำดับชนิดในบรรทัดสรุป/แผงตัวกรอง — ตามตำแหน่ง key ของ TYPE_SHORT (เรียงให้ตรง
* ลำดับ enum) ให้ deterministic ไม่ขึ้นกับลำดับสร้างรายการ */
export const TYPE_ORDER = Object.keys(TYPE_SHORT) as AnnotationType[];
/** AnnotationDTO เป็น flat type (type/data แยกกัน) → แยกตาม type ก่อนแคสต์ data */
export function annotationTypeLabel(a: AnnotationDTO): string {
switch (a.type) {

9
utils/csv.ts Normal file
View file

@ -0,0 +1,9 @@
// CSV field สำหรับ export สรุปหมายเหตุ (F16/D3) — route export-csv ใช้ร่วม
/** ครอบ field ด้วย double quote (RFC 4180) แล้ว escape `"` เป็น `""`
* FIX-6: field ที่ขึ้นต้นด้วย = + - @ หรือ TAB/CR ต้องนำหน้าด้วย '
* ก่อนครอบ quote — กัน Excel/Sheets ตีความค่าเป็นสูตรเมื่อเปิดไฟล์ (OWASP CSV Injection) */
export function csvField(v: string): string {
const guarded = /^[=+\-@\t\r]/.test(v) ? `'${v}` : v;
return `"${guarded.replace(/"/g, '""')}"`;
}

View file

@ -90,15 +90,3 @@ export function sanitizeNextPath(s: string): string | null {
if (!s.startsWith("/") || s.startsWith("//") || s.startsWith("/\\")) return null;
return s;
}
/** รหัสกลุ่มเอกสาร (N9 `type_id`) normalize — single source ใช้ร่วมทั้ง N8
* `registerExternalFile` (ลิงก์ e-Court) และ N4 `uploadFile` (ช่องกลุ่มของฟอร์ม
* อัปโหลด): trim → ว่าง = ไม่มีกลุ่ม (null), cap 200 ตัวอักษร (เทียบ title)
* ค่าที่ไม่ใช่ string (เช่น File จาก FormData) = null (responsive audit 2026-09-24:
* เดิมเป็นสองสำเนา mirroring กัน — normGroup ของ external-import + normTypeId
* ของ files.ts — เสี่ยง drift) */
export function normDocumentGroup(raw: unknown): string | null {
if (typeof raw !== "string") return null;
const v = raw.trim().slice(0, 200);
return v || null;
}

View file

@ -13,37 +13,9 @@ import {
// ข้อมูลเข้า/ออกเป็นพิกัด % ตาม invariant #2 ยกเว้นฟังก์ชันที่รับ `size`
// เพื่อแปลง % → px (เช่น bbox สำหรับวาดกรอบ selection, hit-test หน่วย px)
/** ขนาดกล่อง bbox ของ comment pin (% ของความกว้างหน้า) — pin เป็นปุ่มขนาดคงที่ ไม่มีพิกัดกว้างยาวใน data
* เป็นค่าประมาณ % สำหรับ hit-test ที่ไม่รู้ขนาด render เท่านั้น — ทุกจุดที่รู้ขนาดหน้าจริง
* (กรอบ selection / scroll target / move-clamp) ใช้ commentPinBoxPx/commentPinBoxPct ตาม px จริงข้างล่าง */
/** ขนาดกล่อง bbox ของ comment pin (% ของความกว้างหน้า) — pin เป็นปุ่มขนาดคงที่ ไม่มีพิกัดกว้างยาวใน data */
export const COMMENT_PIN_BOX_PCT = 4;
/** COMMENT: ขนาด px จริงของปุ่ม pin — desktop 26px (icon 16 + p-1 รอบด้าน + border 1×2)
* และจอสัมผัส 36px (.coarse-36 ใน globals.css บังคับ min-width/height 2.25rem ให้ปุ่ม
* pin ใน PDFViewer) — pin เป็น px คงที่ไม่ scale ตาม zoom ต่างจากชนิดอื่นที่เป็น % ทั้งหมด
* (bug report 2026-09-23: กรอบไม่ตรงกล่อง + responsive audit 2026-09-24: scroll/clamp
* คิดจาก % เดิมจน pin จมใต้ header ตอนซูมออก) — แก้ปุ่ม pin หรือ .coarse-36 ต้องแก้ค่านี้ตาม */
export const COMMENT_PIN_SIZE_PX = 26;
export const COMMENT_PIN_COARSE_SIZE_PX = 36;
/** bbox px ของ COMMENT ตามขนาด render จริง — จัตุรัส px คงที่ กึ่งกลางแนวนอนที่จุด position
* และขอบล่างชนจุด (สูตรเดียวกับ -translate-x-1/2 -translate-y-full ของปุ่ม pin — ทั้งลูก
* ลอยเหนือจุดคลิก) */
export function commentPinBoxPx(d: { position: Point }, size: Size, pinPx: number): Rect {
const p = pointToPx(d.position, size);
return { x: p.x - pinPx / 2, y: p.y - pinPx, width: pinPx, height: pinPx };
}
/** bbox ของ COMMENT หน่วย % คิดจากขนาด render จริง (size = px ของหน้าที่กำลัง render) —
* ใช้กับ scroll target ของ goToAnnotation/deep link + move-clamp/bookmark ของกรอบ selection
* ให้เป็นมาตราฐานเดียวกับที่ render (responsive audit 2026-09-24 — เดิมสองมาตราฐาน:
* render ใช้ px แต่ scroll/clamp ยังใช้ COMMENT_PIN_BOX_PCT ของ annotationBBox) */
export function commentPinBoxPct(d: { position: Point }, size: Size, pinPx: number): Rect {
const wPct = (pinPx / size.width) * 100;
const hPct = (pinPx / size.height) * 100;
return { x: d.position.x - wPct / 2, y: d.position.y - hPct, width: wPct, height: hPct };
}
// TEXTBOX (F17/B2 — §5): ค่าประมาณสำหรับ bbox ของกล่องข้อความ
// render จริงกว้างสุด 50% ของหน้าแล้ว wrap อัตโนมัติ — bbox ประมาณจากตัวอักษร
// กว้าง ~0.6 × fontSize ต่อตัว บรรทัดสูง 1.5 × fontSize (ค่าประมาณพอสำหรับกรอบ selection)