diff --git a/.claude/rules/data-layer.md b/.claude/rules/data-layer.md index 0f258aa..a8d268d 100644 --- a/.claude/rules/data-layer.md +++ b/.claude/rules/data-layer.md @@ -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/.` เท่านั้น (ชื่อจาก 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/.` เท่านั้น (ชื่อจาก 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 — ไม่มีตารางกลุ่ม diff --git a/.claude/rules/pdf-viewer.md b/.claude/rules/pdf-viewer.md index d5cc7fb..7913bd7 100644 --- a/.claude/rules/pdf-viewer.md +++ b/.claude/rules/pdf-viewer.md @@ -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=` scroll ไปหน้าของ annotation นั้น + `setSelectedId` — placeholder ขนาดจริงทุกหน้าทำให้คำนวณ absolute scroll ได้แม้หน้ายังไม่ render (กลไกเดียวกับ bookmark); ปุ่ม "คัดลอกลิงก์" ใน sidebar สร้าง URL รูป `/annotate/?page=&ann=` - 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) diff --git a/.env.example b/.env.example index 7e1100d..cb82ad0 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/CLAUDE.md b/CLAUDE.md index 3425796..2f7fbc8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,9 +10,9 @@ ## Tech Stack - Next.js 14 (App Router) + TypeScript **strict** — serve ใต้ basePath `/docedit/note` (`next.config.mjs`) -- pdfjs-dist **v4** (`^4.10.38`) — built-in TextLayer + worker จาก `public/pdf.worker.min.mjs` (postinstall `scripts/patch-pdf-worker.mjs` คัดลอกจาก node_modules + **patch fake-space ไทย** — รายละเอียด invariant #6) +- pdfjs-dist **v4** (`^4.10.38`) — built-in TextLayer + worker จาก `public/pdf.worker.min.mjs` (postinstall คัดลอกจาก node_modules) - Drizzle ORM + PostgreSQL (Docker container เดียว: `judes-note-db`, image `postgres:17-alpine`, พอร์ต 54322) — **ไม่ใช้ Supabase** + realtime sync ด้วย **SSE (Server-Sent Events)** ผ่าน `app/api/annotations/stream` + pub/sub in-process (`lib/annotate-events.ts`) -- Tailwind CSS + design system **Genesis** (`docs/DESIGN.md`, 2026-09-23) — palette tokens ใน `tailwind.config.ts` (`primary` เดิม + `primary-hover`/`canvas`/`line`/`ink`/`ink-soft`/`ink-faint`/`success`/`warning`/`error` + ระดับข้อความ `success-ink`/`warning-ink`/`error-ink` ≥5:1 สำหรับข้อความ/ไอคอนสถานะ — token 500 ใช้กับ border/bg tint เท่านั้น) ห้ามใช้ raw palette shades ของ Tailwind (`slate-*`/`amber-*`/`emerald-*`/`red-*` ฯลฯ) อีก; ฟอนต์ display = **General Sans** (self-host จาก Fontshare ผ่าน `next/font/local` — `app/fonts/`, variable weight 200–700, ใช้ intranet ได้), body = **DM Sans** (`next/font/google`), ไทย fallback **Sarabun** ตามลำดับใน stack (Latin ก่อนไทย — `fontFamily`/globals.css); รัศมี: ปุ่ม/input 6px (รวมปุ่ม action เล็กใน popup/composer/dialog — คู่ ยกเลิก/บันทึก ต้องได้รัศมีเดียวกัน), menu/list row ใน dropdown/panel 4px, panel/dropdown 8px, การ์ด 12px; เงาเฉพาะ hover (`shadow-card-hover`/`shadow-btn-glow` — static ไม่มีเงา ยกเว้นชั้น floating: dropdown/popover `shadow-lg` และ pin ลอยบนเอกสาร ดู §App-specific exceptions ของ DESIGN.md); focus indicator = outline น้ำเงินทึบ 2px + offset ผ่าน class `.focus-ring`/`.genesis-input` ใน globals.css (+ สำเนา `FOCUS_RING_CLS` ใน PDFViewer/AnnotationSidebar/AnnotationSelectionLayer/ToolControls — แก้ต้องแก้พร้อมกัน; halo alpha จางเกิน WCAG 1.4.11); น้ำเงินเฉพาะของ interactive (แบรนด์มาร์ก tile JN/แถบหัวหน้าสรุปยกเว้น) +- Tailwind CSS - mock login e-Court — หน้า `/login` (dropdown `MOCK_USERS` user1–user5 + รหัสผ่านกลาง `Demo1234`) ตั้ง cookie `oacCase` ตาม format จริงของระบบศาล → identity (userId = `u_userid`, userName = `u_userlogin` — **ต่างกันต่อคน** เพื่อสิทธิ์เจ้าของ) อ่านจาก cookie ผ่าน `lib/session.ts` + บังคับทุกหน้าด้วย `middleware.ts` (auth จริงเทียบระบบ e-Court + roles ยัง out of scope) ## Invariants ที่ต้องรักษา (ห้ามละเมิด) @@ -22,7 +22,7 @@ 3. **ลำดับ layer ตาม PLAN.md §1:** canvas (`pointer-events: none` เสมอ) → highlight-layer (`none` เสมอ, `mix-blend-mode: multiply`) → textLayer → InkLayer (SVG) → comment pins — สลับ pointer-events ตาม tool ปัจจุบัน 4. **เขียนผ่าน Server Actions เท่านั้น** (`app/actions/annotations.ts`) และ**หลังเขียน DB สำเร็จทุกครั้ง ต้อง publish event ผ่าน `lib/annotate-events.ts` + `revalidatePath` หน้า viewer และ summary ของ fileId นั้น** (helper `revalidateFilePages` — สั่ง browser ทิ้ง Router Cache ฝั่ง client, `force-dynamic` ปิดแค่ฝั่ง server ไม่พอ: ไม่งั้นกด back แล้วคลิกเข้าไฟล์ใหม่ยังได้ initial data เก่าจนต้อง hard refresh) / **อ่าน + sync ผ่าน SSE hook** (`hooks/useAnnotationRealtime.ts` เปิด `EventSource` ไป `/api/annotations/stream?fileId=`) — event เป็น DTO (camelCase + ISO string) จาก server ตรง ๆ — **ยกเว้น `registerExternalFile` (N8)** ที่เขียน row `files` ระหว่าง render ของหน้า `/` และ `/annotate` (entry point ทั้งสองของสัญญาลิงก์ `?fileUrl=`) แล้ว `redirect` ออกทันที (หน้า `force-dynamic` + รายการไฟล์ไม่ได้ sync ผ่าน SSE — ไม่ต้อง publish event/`revalidatePath`) 5. **Timestamp ข้าม server→client→SSE เป็น ISO string เสมอ** (DTO types) — ห้ามส่ง Date object ตรง ๆ -6. **pdfjs-dist ต้อง `dynamic import()` ภายใน useEffect เท่านั้น** — ห้าม import top-level ใน module scope ที่ server evaluate และต้องตั้ง `GlobalWorkerOptions.workerSrc = withBasePath("/pdf.worker.min.mjs")` (postinstall `scripts/patch-pdf-worker.mjs` คัดลอกไฟล์ไป `public/` **แล้ว patch จุดเดียว: ถอด `if (scaledDim)` รอบ `textChunk.prevTransform = getCurrentTextTransform()` ให้อัปเดตทุก glyph** — pdf.js เดิมข้าม glyph กว้าง 0 อย่างสระ/วรรณยุกต์ไทย ทำให้ charSpacing ของ glyph เหล่านั้นสะสมเป็น gap จน `addFakeSpaces` แทรก " " กลางคำ "ฉะนั้ น" — bug ของ pdf.js ที่ upstream ยังไม่แก้; การ patch ทำบนโค้ด minified ด้วย pattern จำเพาะ ถ้าอัปเกรด pdfjs-dist แล้ว pattern ไม่ตรง postinstall จะ **fail ดัง ๆ โดยตั้งใจ** กัน patch หลุดเงียบ — test กัน regression: `tests/scripts/patch-pdf-worker.test.ts` + fixture `tests/fixtures/thai_letterspacing.pdf` — Docker build ต้องใช้ script นี้ด้วย ไม่ใช่ copyFileSync เปล่า ดู `docker/web.Dockerfile`) — ห้ามใช้ `new URL(..., import.meta.url)` +6. **pdfjs-dist ต้อง `dynamic import()` ภายใน useEffect เท่านั้น** — ห้าม import top-level ใน module scope ที่ server evaluate และต้องตั้ง `GlobalWorkerOptions.workerSrc = withBasePath("/pdf.worker.min.mjs")` (postinstall คัดลอกไฟล์ไป `public/`) — ห้ามใช้ `new URL(..., import.meta.url)` 7. **InkLayer ใช้ SVG แบบไม่มี viewBox** — render ด้วย px จริง แปลง % → px จากขนาดที่ ResizeObserver วัด (viewBox จะทำให้ความหนาเส้นบิดเบี้ยว) 8. **JSONB `data` เป็น strict discriminated union** (`AnnotationData` แยกตาม enum `type`) — ห้าม loosen type เป็น `any`/generic object 9. **Optimistic update ใช้ `useOptimistic` (React 18) ภายใน `startTransition`** — React rollback อัตโนมัติเมื่อ action ล้มเหลว + dedupe ด้วย id ใน `hooks/useAnnotationRealtime.ts` (duplicate ชั่วขณะกับ realtime event หายเอง) @@ -33,7 +33,6 @@ ## โครงสร้างไฟล์ (ตามแผน) ``` -scripts/patch-pdf-worker.mjs ← postinstall: คัดลอก pdf.worker.min.mjs จาก node_modules ไป public/ + patch fake-space ไทย (invariant #6 — เจอ pattern ≠ 1 จุดหลังอัปเกรด pdfjs-dist = exit 1 ดัง ๆ) db/schema.ts ← ตาราง + JSONB types + DTO + mapper toDTO db/index.ts ← postgres.js + drizzle (DATABASE_URL → container judes-note-db, prepare:false) middleware.ts ← บังคับ login ทุกหน้า — ไม่มี cookie oacCase ที่ parse ได้ = redirect /login (ยกเว้น /login, /api/*, /uploads/*, static ใน public/) @@ -42,15 +41,13 @@ lib/server-pdf.ts ← path guard อ่าน PDF จาก public/ (export lib/session.ts ← getSessionUser — identity จาก cookie oacCase (mock login e-Court) lib/external-import.ts ← N8 registerExternalFile — ลงทะเบียนเอกสารจากลิงก์ e-Court metadata-only (dedupe external_id + refresh pdf_url/typeId — เรียกระหว่าง render หน้า / และ /annotate ไม่ใช่ Server Action, ดู carve-out ใน invariant #4; `firstSearchParam` อยู่ที่ `utils/external-file.ts` แทน) lib/remote-file.ts ← N8 ดึงไฟล์รีโมตจาก allowlist (validate ซ้ำหลัง redirect + จำกัด 100MB + timeout 60s — /api/pdf กับ export ใช้ร่วม) -lib/external-api-auth.ts ← N10 auth ของ /api/external/* — requireExternalApiKey เทียบ header `x-api-key` กับ env `EXTERNAL_API_KEY` ด้วย SHA-256 + crypto.timingSafeEqual (env ไม่ตั้ง/ว่าง = 401 fail closed ทุก request; ข้อความ 401 อันเดียวกันทั้งไม่ใส่/key ผิด กัน enumerate; ห้าม log ค่า key/header ดิบ) + externalApiError สร้าง Response ผิดพลาด JSON {"error": ไทย} ใช้ร่วมทุก route ของ N10 — **module นี้ห้าม import อะไรที่ลาก @/db มาด้วย** (db/index.ts throw ตอน import เมื่อไม่มี DATABASE_URL — unit test tests/lib/external-api-auth.test.ts ต้องรันได้โดยไม่แตะ DB) -lib/external-api.ts ← N10 ส่วนกลางของ route — parseQueryBody (validate JSON body ของ /api/external/documents: รับเฉพาะ fileId/typeId/filter, ต้องมี fileId หรือ typeId อย่างน้อยหนึ่งอย่าง — ส่งทั้งคู่ได้ = AND เหลือเฉพาะเอกสารเดียวที่ตรงทั้งสองเงื่อนไข, คำขอผู้ใช้ 2026-09-25 (รอบเดียวกันเคยเป็น OR แล้วเปลี่ยนหลังทดสอบจริง), filter รับเฉพาะ type ∈ bookmarks|annotations ไม่ส่ง = ทั้งสอง + userId (string|array กรองตามเจ้าของรายการ annotations+bookmarks) + annotationTypes (ค่า enum annotation_type เฉพาะ annotations ค่าอื่น = 400 — เดิมชื่อ types เปลี่ยน 2026-09-25) — คืน discriminated mode "file" (fileId + typeId อาจมาด้วย) / "type" (typeId เดี่ยว)) + loadDocumentBundles(fileRows, parts) (bulk: inArray ทุกไฟล์ในชุด query เดียว = 3 query ต่อ request ไม่ว่ากลุ่มกี่ไฟล์ แทนยิง 3 query ต่อไฟล์พร้อมกันทั้งกลุ่ม, code review 2026-09-25 — กรอง userIds/annotationTypes ใน SQL; คืน bundle ยุบชั้น: field เอกสาร + annotations + bookmarks ระดับเดียวกัน ไม่มี object document ซ้อน; ต่อรายการมี `url` deep link F12 กลับหน้า `/annotate/[id]` — annotation `?ann=` / bookmark `?page=<หน้า 1-based>` — URL เต็ม: origin จาก request ผู้เรียก (route อ่าน x-forwarded-proto/host แล้ว fallback host ตรง ส่งเข้าทาง parameter origin) + basePath ผ่าน withBasePath, คำขอผู้ใช้ 2026-09-25) + type ExternalDocumentDTO (externalFileId เป็น null สำหรับไฟล์ url:-fallback/อัปโหลด N4 ในกลุ่ม) utils/coordinates.ts ← pixel⇄% + getPointerPercent + clamp + mergeRectsByLine utils/geometry.ts ← bbox/translate/scale/hitTestPath ของ annotation (pure, หน่วย %) -utils/annotation-display.ts ← label/ข้อความย่อต่อชนิด + TYPE_SHORT/TYPE_ORDER ชื่อย่อ/ลำดับชนิด (บรรทัดสรุปหน้าของหน้าสรุป + แผงตัวกรอง explorer — ย้ายจาก 2 ที่ซ้ำกันมารวมที่นี่ 2026-09-24) + ค่า default TEXTBOX/STAMP + describeAnnotationChange สรุปสิ่งที่เปลี่ยนของ version history F19 (sidebar/สรุป/groups/CSV ใช้ร่วม) +utils/annotation-display.ts ← label/ข้อความย่อต่อชนิด + ค่า default TEXTBOX/STAMP + describeAnnotationChange สรุปสิ่งที่เปลี่ยนของ version history F19 (sidebar/สรุป/CSV ใช้ร่วม) utils/date-format.ts ← formatThaiDate วันที่ไทยแบบเต็ม "18 กันยายน พ.ศ. 2569" (+เวลา "… 16:33:00 น.") — ทุกจุดที่แสดงวันที่ใน UI ใช้ร่วม (เว้น export CSV ที่ lock Asia/Bangkok) utils/pdf-export.ts ← แปลงพิกัดจอ⇄PDF (/Rotate) + wrapText + hexToRgb (export route) utils/user.ts ← parse/build ค่า cookie oacCase → OacUser {userId: u_userid, userName: u_userlogin} (pure module — middleware ใช้ร่วมได้) -utils/external-file.ts ← N8 validation ลิงก์ไฟล์รีโมต (allowlist "admincourt.go.th" + env EXTERNAL_FILE_HOSTS, suffix match, https เท่านั้น ห้าม user:pass@) + titleFromExternalUrl/externalIdFor/isHttpUrl + normDocumentGroup (N9 normalize รหัสกลุ่ม — ใช้ร่วม N8/N4, pure) +utils/external-file.ts ← N8 validation ลิงก์ไฟล์รีโมต (allowlist "admincourt.go.th" + env EXTERNAL_FILE_HOSTS, suffix match, https เท่านั้น ห้าม user:pass@) + titleFromExternalUrl/externalIdFor/isHttpUrl (pure) utils/base-path.ts ← withBasePath() เติม basePath /docedit/note ให้ URL ฝั่ง browser ที่ Next ไม่เติมให้เอง (ค่าจาก next.config.mjs ผ่าน env — invariant #12) utils/file-kind.ts ← derive ชนิดไฟล์ pdf/image จาก extension ของ path (N3 — ใช้ร่วม viewer/export/หน้ารายการ/seed) utils/tiff-client.ts ← decode TIFF ทุกเฟรม → PNG blob URL ฝั่ง client (N3) @@ -59,37 +56,33 @@ hooks/useAnnotationRealtime.ts ← EventSource ต่อ SSE stream + dedupe hooks/useHistory.ts ← undo/redo stack ฝั่ง client (F2) app/actions/annotations.ts ← Server Actions CRUD + validation + publish event app/actions/auth.ts ← Server Action login (mock user_x/Demo1234 → ตั้ง cookie oacCase) / logout (ลบ cookie) -app/actions/files.ts ← Server Action uploadFile — validate + เขียนไฟล์ใหม่ที่ public/uploads/ + ช่องกลุ่ม typeId ทางเลือก normalize เหมือน N8 (N4) +app/actions/files.ts ← Server Action uploadFile — validate + เขียนไฟล์ใหม่ที่ public/uploads/ (N4) app/api/annotations/stream/route.ts ← SSE endpoint (GET ?fileId=) app/api/annotations/export/route.ts ← export PDF ฝัง annotation (pdf-lib, F8) -app/api/external/documents/route.ts ← N10 API ภายนอกเส้นเดียว (POST + JSON body {fileId และ/หรือ typeId, filter:{type, userId, annotationTypes}} — auth ผ่าน requireExternalApiKey ก่อนแตะ DB, ถาม fileId = ค้น files.external_id = "ecourt:" ไม่เจอ 404 แม้ส่ง typeId มาด้วย / ถาม typeId = ทุกไฟล์ในกลุ่ม กลุ่มว่าง = 200 documents:[] / ส่งทั้งคู่ = AND กรอง externalId+typeId ใน query เดียว เหลือเอกสารเดียว ไม่ตรงคู่ = 404 แยกสาเหตุในข้อความ (2026-09-25 เดิมเคยเป็น OR แล้วเป็น 400); response รูปเดียว {documents:[bundle…]} ผ่าน loadDocumentBundles ทั้งสอง mode; ครอบ try/catch ทุก DB call — ล้ม = log ฝั่ง server + 500 JSON ตาม contract ไม่ปล่อยเป็น 500 ของ framework; read-only ไม่ publish SSE ไม่ revalidatePath — snapshot ณ ตอนเรียก) +app/api/annotations/export-csv/route.ts ← export สรุป CSV พร้อม BOM (F16) +app/api/bookmarks/export/route.ts ← ส่งออกรายการ bookmark เป็น JSON (N7 — ข้อมูลล้วนจาก DB ไม่โหลดไฟล์ต้นฉบับ; เดิมเป็นรายงาน PDF เปลี่ยนตามคำขอผู้ใช้ 2026-09-17) app/uploads/[name]/route.ts ← เสิร์ฟไฟล์อัปโหลดจาก public/uploads/ (N4 — Next production เห็นไฟล์ static เฉพาะที่มีตอน startup ไฟล์ที่เขียน runtime ต้องเสิร์ฟผ่าน route) app/page.tsx ← รายการไฟล์จัด section ตามกลุ่ม type_id + "ไม่มีกลุ่ม" ท้ายสุด (N9 — server component) -app/groups/[typeId]/page.tsx ← N9 หน้าเอกสารในกลุ่ม (server component force-dynamic — files where type_id orderBy createdAt, ไม่มี = 404; ~~หมายเหตุ "แสดงเฉพาะเอกสารที่เคยเปิดในระบบนี้"~~ ถอดออกตามคำขอผู้ใช้ 2026-09-24; **redesign 2026-09-24 ตามคำขอผู้ใช้: ไม่มี navbar** — explorer 2 คอลัมน์ ตัวกรองซ้าย + list เดียวรวม bookmark+annotation ทั้งกลุ่มไม่แยกไฟล์: query ทั้งหมดฝั่ง server แล้วส่ง `GroupFileSummary[]` + `GroupListItem[]` (เรียง ไฟล์ตาม createdAt → หน้า → bookmark ก่อน annotation → createdAt) ให้ `GroupExplorer` กรองใน memory, แถวรายการ deep link `?ann=`/`?page=` เหมือนหน้าสรุป F12 + ไฟล์ที่มาต้น meta line) +app/groups/[typeId]/page.tsx ← N9 หน้าเอกสารในกลุ่ม (server component force-dynamic — files where type_id orderBy createdAt, ไม่มี = 404, หมายเหตุ "แสดงเฉพาะเอกสารที่เคยเปิดในระบบนี้" + การ์ดแต่ละไฟล์
"Annotations & Bookmarks" หุบ/กางดูรายการ bookmark+annotation ทั้งไฟล์ deep link เหมือนหน้าสรุป F12) app/login/page.tsx ← หน้า login (มี session แล้ว redirect /) app/annotate/[id]/page.tsx ← โหลด file + initial data → PDFViewer -app/annotate/[id]/summary/page.tsx ← หน้าสรุป Annotation+Bookmark ของไฟล์เดียว (server component — query ทั้งหมดฝั่ง server แล้วส่ง `ExplorerItem[]` เรียง หน้า → หน้าเดียวกัน bookmark ก่อน annotation (bookmark ต่อหน้าเรียงตาม yOffset N5 เหมือนเดิม, annotation ตาม createdAt) ให้ `SummaryExplorer` กรองใน memory; design "แฟ้มสำนวน" 2026-09-17 แถบหัวน้ำเงิน + การ์ดรายหน้าขอบ folio + รายการตามหน้าจุดไข่ปลา → **redesign 2026-09-24 ตามคำขอผู้ใช้เป็น explorer 2 คอลัมน์แบบหน้ากลุ่ม N9 โดยมิติแรกของตัวกรองเป็น "หน้า" แทน "ไฟล์"** — แถบหัวน้ำเงิน + action bar (กลับหน้าอ่าน — ปุ่มส่งออก CSV F16/ส่งออก Bookmark JSON N7 ถอด 2026-09-25 แทนด้วย N10 API) + empty state คงเดิม, ไฟล์ว่าง = ไม่ mount explorer เลย; generateMetadata ตั้งชื่อแท็บ browser ตามคดี; ตอนพิมพ์ header เป็นกรอบเส้นน้ำเงิน + แผงตัวกรอง/แถบ action ซ่อนด้วย print:hidden — กฎ @media print อยู่ใน globals.css) — อย่าถอด class `summary-page` ของ
(กฎ coarse 44px ใน globals.css ผูกอยู่กับ selector นี้ ครอบทั้ง `a` และ `button`) -components/summary/SummaryExplorer.tsx ← client root ของหน้าสรุป (redesign 2026-09-24) — state ตัวกรอง `FacetFilterState` จากแผงกลาง explorer + กรองใน memory + facet count/static users derive เอง + มิติ "หน้า" เป็นตัวเลือกแบบ single-select เฉพาะหน้าที่มีรายการ ≥1 เรียงน้อย→มาก (`optionsScroll` ให้ scroll ภายในกลุ่ม max-h-64 กันหน้าเยอะดันกลุ่มอื่นออกจอ) + layout 2 คอลัมน์ (มือถือ < lg ตัวกรองหุบเป็น `
`, desktop aside sticky 15rem — pattern เดียวกับ GroupExplorer; คอลัมน์ content ครอบด้วย `
` ไม่ใช่ `
` เพราะกฎ print break-inside ของ `main.summary-page section` มุ่งการ์ดรายหน้าข้างใน) -components/summary/SummaryList.tsx ← คอลัมน์ content ของหน้าสรุป — การ์ดรายหน้า "1 หน้า 1 การ์ด" หัวการ์ด sticky ระดับ viewport (เลขหน้า + บรรทัดสรุปหน้า `pageSummaryLine` **คำนวณจากรายการที่เห็นจริงหลังกรอง** + ปุ่ม "เปิดหน้านี้"; กลับเป็น position static ตอนพิมพ์ผ่านกฎ `.page-head` ใน globals.css) + แถว passive จุดคลิกเดียวคือปุ่ม `` "View" เหมือน GroupList (deep link `?ann=`/`?page=` — bookmark ไปหน้า, annotation ไปรายการ; meta line ไม่มี segment ไฟล์เพราะไฟล์เดียวทั้งหน้า) + header "พบ N รายการ" (aria-live) + ปุ่ม "ล้างตัวกรอง" (เฉพาะมีตัวกรอง active) + empty state กรองจนไม่เหลือ + **ตอนพิมพ์แสดงฉบับเต็มเสมอ** — ตัวกรองที่ตั้งค้างต้องไม่ตัดรายการออกเงียบ ๆ บนกระดาษ: ตอน activeCount > 0 render ชุดบนจอ (print:hidden) + ชุดเต็ม (hidden print:block, ไม่มี id/aria-labelledby ซ้ำ) แยกกัน; anchor `#page-N` ของ section คงจาก design เดิมให้ลิงก์เก่ายังใช้ได้ +app/annotate/[id]/summary/page.tsx ← หน้าสรุป Annotation+Bookmark จัดกลุ่มตามหน้า (design "แฟ้มสำนวน" 2026-09-17: แถบหัวน้ำเงิน + รายการตามหน้าจุดไข่ปลา + การ์ดรายหน้า 1 หน้า 1 การ์ด ขอบ folio — คำขอผู้ใช้ 2026-09-17 แทนแผ่นกระดาษแผ่นเดียวคั่นเส้น; ต่อหน้า = บรรทัดสรุปหน้า (ไม่มีหัวข้อ) + Bookmark/Annotation หุบ-กางได้ผ่าน CollapsibleBlock; รายการตามหน้า desktop = การ์ด sticky มี max-h, มือถือ < lg =
การ์ด ไม่มี JS; generateMetadata ตั้งชื่อแท็บ browser ตามคดี; ตอนพิมพ์ header เป็นกรอบเส้นน้ำเงิน + ซ่อนแถบ action/รายการตามหน้ามือถือ — กฎ @media print อยู่ใน globals.css) — อย่าถอด class `summary-page` ของ
(กฎ coarse 44px ใน globals.css ผูกอยู่กับ selector นี้ ครอบทั้ง `a` และ `button`) +components/summary/CollapsibleBlock.tsx ← client component เดียวของหน้าสรุป (state หุบ/กางกลุ่ม Bookmark/Annotation — children ส่งจาก server ได้ตรง ๆ) components/pdf/PDFViewer.tsx ← client root (tool state, zoom, selection, lazy render หน้า) components/pdf/InkLayer.tsx ← SVG freehand + รูปทรง + redaction components/pdf/TextboxStampLayer.tsx ← overlay TEXTBOX + STAMP (F17/F18) components/pdf/AnnotationSelectionLayer.tsx ← กรอบ selection + handles ย้าย/ปรับขนาด (F1) + แถบสี/ความหนา + ปุ่มลบ components/sidebar/AnnotationSidebar.tsx ← + โน้ตคำอธิบาย (note) inline ต่อรายการ (PLAN-FEAT.md §7 N1) components/AnnotationTypeIcon.tsx ← ไอคอนตามชนิด annotation (ย้ายจาก sidebar) — ไม่มี "use client" ให้ server component ใช้ร่วมได้ (sidebar + หน้าสรุป) -components/UploadFileForm.tsx ← ฟอร์มอัปโหลดไฟล์บนหน้ารายการ + ช่องกลุ่ม typeId ทางเลือก ไม่กรอก = ไม่มีกลุ่ม (N4) +components/UploadFileForm.tsx ← ฟอร์มอัปโหลดไฟล์บนหน้ารายการ (N4) components/EcourtLinkForm.tsx ← การ์ดจำลองลิงก์ e-Court บนหน้ารายการ (N8 — กรอก fileUrl/fileId/typeId กด "เปิดเอกสาร" แล้ว router.push ไป /?fileUrl=&fileId=&typeId= ให้หน้า / ลงทะเบียน+redirect เอง — ใช้ทดสอบก่อนมีลิงก์จริงจาก e-Court) -components/groups/GroupExplorer.tsx ← N9 client root ของหน้า /groups/[typeId] (redesign 2026-09-24) — state ตัวกรองก้อนเดียว `FacetFilterState` (first=fileId/`kinds: Set<"bm"|"ann">` ชนิดรายการ + `types: Set` ชนิดของ Annotation — **ทั้งคู่ Set ว่าง = แสดงทั้งหมด (default), ติ๊กอะไรเห็นเฉพาะสิ่งนั้น** คำขอผู้ใช้ 2026-09-24/ผู้บันทึก) + กรองใน memory ด้วย useMemo + facet count/static users derive เอง + layout 2 คอลัมน์ (มือถือ < lg ตัวกรองหุบเป็น `
` แบบหน้าสรุป, desktop aside sticky 15rem scroll ภายใน) — render `FacetFilterPanel` สองที่แชร์ state เดียว (`firstGroup` = ไฟล์) + re-export `GroupListItem` = `ExplorerItem` -components/explorer/FacetFilterPanel.tsx ← แผงตัวกรอง facet **กลางใช้ร่วม 2 หน้า** — ย้ายมาจาก FilterControls ของหน้ากลุ่ม 2026-09-24 ให้หน้าสรุปใช้ร่วม (มิติแรกส่งเข้าทาง prop `firstGroup`: groups = "ไฟล์", สรุป = "หน้า" พร้อม `optionsScroll` ให้ scroll ภายในกลุ่ม max-h-64 กันหลายสิบหน้าดันกลุ่มอื่นออกจอ; อีก 3 กลุ่ม ชนิดรายการ/ชนิดของ Annotation/ผู้บันทึก เหมือนกันทุกหน้า) — **tree menu 2 ระดับสไตล์ Quasar docs** (q-tree--dense no-connectors — design รอบสอง 2026-09-24 ตามคำขอผู้ใช้, ธีม Genesis พื้นสว่าง) — **ชนิดรายการ (Bookmark/Annotation) เป็น checkbox คู่ที่ไม่เลือก = แสดงทั้งหมด (default) ติ๊กอะไรเห็นเฉพาะสิ่งนั้น** (direct selection ตามคำขอผู้ใช้ 2026-09-24 รอบสอง — แบบเดิมเปิดครบทั้งคู่ต้องปิดเอง = สับสน; เคยย้ายไป segmented บนหัว content แล้วย้ายกลับตามคำขอผู้ใช้) = parent `
` หุบ/กางอิสระ + chevron หมุน 90°, leaf = ` -

+

กดแล้วระบบลงทะเบียนเอกสาร (ถ้ายังไม่มี) แล้วพาเข้าหน้าอ่าน/มาร์กเอกสารทันที — ไฟล์รีโมตรับเฉพาะ https ของโฮสต์ที่อนุญาต

-

- ลิงก์เต็ม: +

+ ลิงก์เต็ม: {fullLink || "…"}

- {error &&

{error}

} + {error &&

{error}

} ); } diff --git a/components/LoginForm.tsx b/components/LoginForm.tsx index 34b9db3..199b595 100644 --- a/components/LoginForm.tsx +++ b/components/LoginForm.tsx @@ -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 ( -
- {/* brand lockup เดียวกับ nav หน้ารายการ — tile JN น้ำเงินคือแบรนด์มาร์กของระบบ - (หน้าสรุปแถบน้ำเงิน 2026-09-17 สืบต่อจาก tile นี้) ชื่อแบรนด์เป็น ink ตามกฎ - "blue เฉพาะของ interactive" ของ Genesis */} +
+ {/* brand lockup เดียวกับ header หน้ารายการ (ลดขนาดตัวอักษรให้พอการ์ดแคบ) */}
-
+
JN
-
-

- Judge's Note -

-

- ระบบ PDF Annotation สำหรับ e-Court -

+
+

Judge's Note

+

ระบบ PDF Annotation สำหรับ e-Court

-

เข้าสู่ระบบ

+

เข้าสู่ระบบ

-
+
-
-
- {/* กลุ่ม typeId ทางเลือก (N9) — ช่องเดียวกับลิงก์ e-Court, ไม่กรอก = "ไม่มีกลุ่ม"; - uncontrolled ส่งไปกับ FormData เหมือนช่องไฟล์ (normalize ที่ action) */} - - -

+

รองรับ PDF, JPG, PNG, TIFF — ไม่เกิน 100MB ต่อไฟล์

- {error &&

{error}

} + {error &&

{error}

} ); } diff --git a/components/explorer/FacetFilterPanel.tsx b/components/explorer/FacetFilterPanel.tsx deleted file mode 100644 index 37c4a39..0000000 --- a/components/explorer/FacetFilterPanel.tsx +++ /dev/null @@ -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 =
หุบ/กางอิสระ (chevron หมุน 90°), -// leaf = - ); -} - -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() : 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) => ( - } - label={o.label} - count={o.count} - onClick={() => onChange({ ...state, first: o.key })} - /> - )); - - return ( -
- {/* มิติแรก (ไฟล์/หน้า) — ตัวเลือก + count ส่งมาจากหน้าผ่าน firstGroup; - optionsScroll ครอบด้วยกล่อง scroll เฉพาะกรณีตัวเลือกเยอะ */} - - } - label={firstGroup.allLabel} - count={counts.total} - onClick={() => onChange({ ...state, first: null })} - /> - {firstGroup.optionsScroll ? ( -
- {firstOptions} -
- ) : ( - firstOptions - )} -
- - {/* ไม่เลือก = แสดงทั้งหมด (default) — ติ๊กอะไรเห็นเฉพาะสิ่งนั้น */} - } - label="ชนิดรายการ" - > - - } - label="Bookmark" - count={counts.bm} - onClick={() => toggleKind("bm", !state.kinds.has("bm"))} - /> - } - label="Annotation" - count={counts.ann} - onClick={() => toggleKind("ann", !state.kinds.has("ann"))} - /> - - - {/* กติกาเดียวกับชนิดรายการ: ไม่เลือก = ทุกชนิด (default) ติ๊กอะไรเห็นเฉพาะ - ชนิดนั้น + แสดงเฉพาะชนิดที่มี ≥1 รายการ (กดแล้วได้ list ว่าง = ไม่เสนอ) — - **ไม่ได้ติ๊ก Annotation = กลุ่มนี้ disabled + หุบ** (คำขอผู้ใช้ 2026-09-24: - กรองชนิดมีความหมายเฉพาะเมื่อกำลังดู annotation — ติ๊ก Annotation แล้ว - กลุ่มกลับมาใช้ได้ + กางให้อัตโนมัติ) */} - {counts.ann > 0 && ( - } - label="ชนิดของ Annotation" - open={state.kinds.has("ann")} - disabled={!state.kinds.has("ann")} - > - {TYPE_ORDER.filter((t) => (counts.type[t] ?? 0) > 0).map((t) => ( - - } - label={TYPE_SHORT[t]} - count={counts.type[t] ?? 0} - onClick={() => toggleType(t, !state.types.has(t))} - /> - ))} - - )} - - } - label="ผู้บันทึก" - > - {/* ไอคอนคนซ้ำ N แถวเป็น noise — spacer คง indentation แทน (pattern เดียวกับ - leaf ของต้นไม้ bookmark ใน sidebar) */} - } - label="ทุกคน" - count={counts.total} - onClick={() => onChange({ ...state, userId: null })} - /> - {users.map((u) => ( - } - label={u.label} - count={counts.user[u.userId] ?? 0} - onClick={() => onChange({ ...state, userId: u.userId })} - /> - ))} - -
- ); -} diff --git a/components/groups/GroupExplorer.tsx b/components/groups/GroupExplorer.tsx deleted file mode 100644 index c6b53cb..0000000 --- a/components/groups/GroupExplorer.tsx +++ /dev/null @@ -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(createDefaultFacets); - - // facet count เป็น static จากข้อมูลทั้งกลุ่ม (ไม่หดตามตัวกรองอื่น — กันเข้าใจ - // ผิดว่าตัวเลือกหายไปตอนไล่กรอง) - const counts = useMemo(() => { - let bm = 0; - let ann = 0; - const file: Record = {}; - const type: Partial> = {}; - const user: Record = {}; - 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(); - 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: , - allLabel: "ทุกไฟล์", - options: files.map((f) => ({ - key: f.id, - label: f.title, - count: counts.file[f.id] ?? 0, - icon: f.isImage ? ( - - ) : ( - - ), - })), - }; - - 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 ( -
- {/* มือถือ (< lg): ตัวกรองหุบ/กางเป็น
แบบเดียวกับหน้าสรุป — - จอ lg ถูก display:none เอาออกจาก grid — ใช้ named group `group/filters` - ไม่ใช่ `group` เปล่า: `group-open:` ของ parent tree ข้างใน match ทุก - ancestor ที่มี .group[open] ใช้ชื่อซ้ำแล้ว chevron ของทุกกลุ่มหมุนตลอด - ตอนกางแผงนี้ (responsive review 2026-09-24) */} -
- - ตัวกรอง{activeCount > 0 ? ` (${activeCount})` : ""} - - - {/* cap 70dvh + scroll ภายใน: กลุ่ม "ไฟล์"/"ผู้บันทึก" เยอะ ๆ ต้องไม่ดัน - "พบ N รายการ"/list ลงไกลจนมองไม่เห็น (responsive review 2026-09-24) */} -
- -
-
- - {/* 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) */} - - -
- -
-
- ); -} diff --git a/components/groups/GroupList.tsx b/components/groups/GroupList.tsx deleted file mode 100644 index e1097ad..0000000 --- a/components/groups/GroupList.tsx +++ /dev/null @@ -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 ซ้อน เดิม และกฎ coarse ของ listing-page ที่ -// display:inline-flex ชนะ utility flex ก็ไม่กระทบแถวอีกต่อไป — เหลือแค่ปุ่ม) - -/** จุดคั่น "·" ของ meta line */ -function Sep() { - return ·; -} - -/** 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 ( - - - {file.isImage ? ( - - ) : ( - - )} - - {file.title} - - - - หน้า {pageIndex + 1} - {middle && ( - <> - - - {middle} - - - )} - - โดย {user} - - {/* formatThaiDate แปลงตาม timezone เครื่องที่ render — SSR/server กับ - hydration/ผู้ใช้อาจต่างกัน ใส่ suppressHydrationWarning ตาม convention - (เดียวกับ AnnotationSidebar/PDFViewer) */} - - {date} - - - ); -} - -/** โน้ตคำอธิบาย (N1) / โน้ตย่อยของ bookmark (N2) — 2 บรรทัดพอ */ -function RowNote({ text }: { text: string }) { - return ( - - - {text} - - ); -} - -/** ปุ่ม secondary "เปิดเอกสารนี้" — ทางเข้าไฟล์ที่ยังไม่มีรายการใด ๆ - * (หน้าเดิมมีปุ่ม Annotate ทุกการ์ด — เก็บไว้ตามคำตัดสินใจผู้ใช้ 2026-09-24) */ -function OpenFileButton({ file }: { file: GroupFileSummary }) { - return ( - - {file.isImage ? ( - - ) : ( - - )} - เปิดเอกสารนี้ - - ); -} - -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 (ที่เห็นผลของตัวกรอง) ไม่ใช่ในแผง */} -
-

- พบ{" "} - - {items.length} - {" "} - รายการ -

-
- {selectedFile && } - {activeCount > 0 && ( - - )} -
-
- - {items.length > 0 ? ( -
-
    - {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 ที่แถว -
  • - {isBm ? ( - <> - - - - {item.dto.title} - - - {item.dto.note && } - - - ) : ( - <> - - - - {annotationPreviewText(item.dto)} - {item.dto.type === "COMMENT" && item.dto.resolved && ( - - จัดการแล้ว - - )} - - - {annotationNote(item.dto) && ( - - )} - - - )} - - View - -
  • - ); - })} -
-
- ) : totalCount === 0 ? ( -
- -

- ยังไม่มี Annotation หรือ Bookmark ในกลุ่มนี้ -

-

- เปิดเอกสารในกลุ่มเพื่อมาร์กข้อความ วาดเส้น ใส่คอมเมนต์ หรือคั่นหน้า -

-
- ) : selectedFile && selectedFileTotal === 0 ? ( -
- -

- ไฟล์นี้ยังไม่มี Annotation หรือ Bookmark -

-
- -
-
- ) : ( -
- -

- ไม่มีรายการตรงตามตัวกรอง -

- -
- )} - - ); -} diff --git a/components/pdf/AnnotationSelectionLayer.tsx b/components/pdf/AnnotationSelectionLayer.tsx index dedb287..5313e59 100644 --- a/components/pdf/AnnotationSelectionLayer.tsx +++ b/components/pdf/AnnotationSelectionLayer.tsx @@ -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(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) => (
)} - {/* Responsive audit 2026-09-24: รายการของผู้อื่นที่เลือกอยู่ — ป้าย "ชนิด · ผู้ทำ" ลอย - เหนือกรอบ (จอสัมผัสไม่มี hover tooltip N1 ให้ดูว่าใครทำ เดิมต้องเปิด sidebar - อย่างเดียว) — read-only pointer-events:none ตามกติกา container ของ layer นี้ - ไม่บัง textLayer; ชิดหัวหน้าเกินก็ห้อยใต้กรอบแทน */} - {!editable && ( -
- {annotationTypeLabel(annotation)} · {annotation.userName || "ไม่ทราบชื่อ"} -
- )} - {/* แผงเครื่องมือของรายการที่เลือก — ตัวควบคุมเดียวกับ dropdown ของ tool ผู้สร้าง (สี/ความหนา/ขนาด/รูปแบบตามชนิด) + ปุ่มลบท้ายแผง — แสดงทุกชนิดรวม HIGHLIGHT (ไม่มี handle ย้าย/ขยาย แต่เปลี่ยนสี/รูปแบบ/ลบได้) — เฉพาะรายการของตัวเอง @@ -557,7 +507,7 @@ export default function AnnotationSelectionLayer({ {editable && !disabled && (
commitHighlightStyle(key)} > @@ -649,7 +599,7 @@ export default function AnnotationSelectionLayer({ -
+
{( [ ["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({ )} - {hasStyleControls &&
} + {hasStyleControls &&
}
{/* N2: bookmark ตำแหน่งของรายการนี้ — เปิด popup ตั้งชื่อ/โน้ต/เลือกหัวข้อต่อ */} - {/* hover เป็นกลางแบบปุ่มจิ๋วอื่นในแผง (เดิม copy มาจากปุ่มลบข้าง ๆ จน hover - แดง = สัญญาณอันตรายเท็จกับการ "บันทึก" — UX review M4 2026-09-23) */} {/* เส้นคั่นแนวตั้ง — เฉพาะกรณีปุ่มติดกัน (แผงไม่มี row สไตล์): แยก bookmark ออกจากปุ่มลบ */} {!hasStyleControls && ( -