eCourt-note/docs/PLAN.md

257 lines
26 KiB
Markdown
Raw Normal View History

# 📋 Implementation Plan — Judge's Note (PDF Annotation System for e-Court)
> แผนงานก่อนเริ่ม implement โปรเจกต์ Next.js แยกต่างหากจากโปรเจกต์ Nuxt (dms-main)
---
## 0. สมมติฐาน & ข้อตกลงเบื้องต้น (Assumptions)
| # | หัวข้อ | ข้อตกลง |
|---|--------|---------|
| A1 | **ตำแหน่งโปรเจกต์** | `D:\dms\judes-note\` — directory แยกต่างหาก (ไม่ใช่ subdirectory ของ dms-main) เพื่อไม่ชนกับโปรเจกต์ Nuxt ที่มีโฟลเดอร์ `app/` อยู่แล้ว |
| A2 | **Framework** | Next.js 14 (App Router) + TypeScript strict |
BASE_PATH: serve ใต้ /docedit/note — next.config.mjs ตั้ง basePath + env NEXT_PUBLIC_BASE_PATH (ประกาศค่าเดียวที่ไฟล์นี้) ให้ utils/base-path.ts (ใหม่) withBasePath() เติม prefix ให้ URL ฝั่ง browser ที่ Next ไม่เติมให้เอง (fetch/EventSource/plain href/workerSrc/img src): PDFViewer (workerSrc, /api/pdf ซ่อม ToUnicode, fallback file.pdfUrl, image branch N3 รวบเป็นตัวแปร fileUrl, export PDF F8), SSE stream ใน useAnnotationRealtime, ปุ่มส่งออก bookmark JSON (N7) + คัดลอกลิงก์ F12 ใน AnnotationSidebar (window.location.origin + basePath กัน paste แล้ว 404), Export CSV (F16) หน้า summary — <Link>/next/navigation/middleware.ts ไม่ต้องแก้ (Next 14.2 auto-prefix ให้ + matcher จับ path ที่ตัด basePath แล้ว + NextURL redirect ใส่กลับเอง ยืนยันจาก source ใน node_modules) + pdf_url ใน DB เก็บ path ไม่รวม basePath เติมตอนใช้เท่านั้น + sync เอกสารตาม governance: CLAUDE.md invariant #6 (workerSrc ผ่าน withBasePath) + #12 ใหม่ (URL ฝั่ง browser ที่สร้างเองต้องผ่าน withBasePath), PLAN.md A3/§4, rules pdf-viewer/realtime, plan-reviewer #8, README URL localhost:3000/docedit/note — verify: tsc strict ผ่าน + curl (root / 404, ไม่มี cookie 307→/docedit/note/login, worker 200, SSE reachable, มี cookie 200) + Playwright login จริงเปิดไฟล์ 44 หน้า render ครบ (SSE /api/pdf /worker 200 ผ่าน basePath) + code-reviewer ผ่าน (vitest 126 tests, ไม่พบ bug, blob URL ไม่โดน prefix ซ้ำ, env inline ครบทั้ง client/server ตาม define-env-plugin) — deploy: basePath เป็น build-time ต้อง build image ใหม่ถึงจะเสิร์ฟที่ /docedit/note + reverse proxy ต้อง forward path เต็มแบบไม่ strip prefix Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-17 16:06:20 +07:00
| A3 | **PDF Renderer** | `pdfjs-dist` เวอร์ชัน **v4** (`^4.10.38`) — ใช้ built-in `TextLayer` class; worker ตั้ง path ตรง ๆ: คัดลอก `pdf.worker.min.mjs` (จาก `node_modules/pdfjs-dist/build/`) มาไว้ที่ `public/` ด้วย **postinstall script** แล้วตั้ง `pdfjs.GlobalWorkerOptions.workerSrc = withBasePath("/pdf.worker.min.mjs")` (app serve ใต้ basePath `/docedit/note` — invariant #12 ของ CLAUDE.md) — **ไม่ใช้** `new URL(..., import.meta.url)` เพราะ v4 เป็นไฟล์ `.mjs` และมักชนกับ Turbopack/webpack ใน Next 14 |
mock login e-Court หน้า /login + identity จาก cookie oacCase แทน localStorage — dropdown ผู้ใช้ MOCK_USERS (user1–user5, utils/user.ts pure module ให้ client+server ใช้ร่วม) รหัสผ่านกลาง Demo1234 ผ่าน server action login (เช็ครายชื่อฝั่ง server เป็น defense กัน hand-craft + delay 500ms ให้เห็น "กำลังตรวจสอบ…") ตั้ง cookie oacCase ตาม format จริงของระบบศาล 16 field — u_userid/u_userlogin ต่างกันต่อคน (= ชื่อผู้ใช้) เพื่อให้สิทธิ์ "แก้ไขได้เฉพาะของตัวเอง" (guard ด้วย userId) แยกกันจริงทั้ง 5 คน httpOnly/sameSite lax/8 ชม. + middleware.ts บังคับ login ทุกหน้า (parse จริงด้วย parseOacCase ไม่ใช่แค่มี cookie — ขยะ/ว่าง/ขาด field = redirect /login; matcher ยกเว้น /login, /api/*, /uploads/*, _next และ static ใน public/ — SSE/export จึงไม่โดน 302) + lib/session.ts getSessionUser อ่าน identity ฝั่ง server ใช้ในทุกหน้า (defense ซ้อน middleware) + PDFViewer รับ user เป็น prop จาก annotate page ตั้งแต่ first paint (หมดช่วง null user ที่เคยสร้าง annotation ด้วย userId="" ได้ + ไม่มี hydration mismatch; Server Actions ไม่ต้องแก้ — vString รับ userId ใหม่อยู่แล้ว) + SessionBar แสดง "เข้าสู่ระบบเป็น <user>" + ปุ่มออกจากระบบ (ลบ cookie ด้วย path "/" ตรงตอน set + redirect ใน action เอง — client router.replace เองชนกับ re-render ของหน้าที่เพิ่งเสีย session จน navigation ไม่เกิด) แทน UserPicker mock 10 คน (ลบไฟล์พร้อม MOCK_USERS/localStorage/getLocalUser ทั้งชุด) + LoginForm การ์ด max-w-sm กลางจอ reuse brand lockup และปุ่ม primary เดิม, input text-base 16px กัน iOS zoom บน iPad, dropdown placeholder "เลือกผู้ใช้" บังคับเลือก (ไม่ preselect กัน login ผิดคนโดยไม่ตั้งใจ), error แถบเดียว role=alert "รหัสผ่านไม่ถูกต้อง" (dropdown เลือกผิดไม่ได้แล้ว), ตอนผิดล้าง password คงผู้ใช้ที่เลือกไว้ + focus+select + tests/utils/user.test.ts ล็อก format จริงของ e-Court (parse ตัวอย่าง cookie จริง 235821/tu_2, garbage/empty/ขาด field → null, round-trip, buildOacCaseCookie ครบ 16 field) + sync CLAUDE.md/PLAN.md §0 A4/README — annotation สมัย mock-user-01/UUID ไม่ใช่ของตัวเองอีก (auth จริงเทียบระบบศาล + roles ยัง out of scope; /api/* และ /uploads/* ยังไม่ต้อง login ตามขอบเขต mock) Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-17 13:41:02 +07:00
| A4 | **Identity (ปรับปรุง 2026-09-17 — mock login)** | หน้า `/login` mock e-Court (dropdown ผู้ใช้ `MOCK_USERS` user1–user5 + รหัสผ่านกลาง `Demo1234`) ตั้ง cookie `oacCase` เป็น query string ตาม format จริงของระบบศาล → identity อ่านจาก cookie: `userId` = `u_userid`, `userName` = `u_userlogin` (**ต่างกันต่อคน** = ชื่อผู้ใช้ เพื่อให้สิทธิ์ "แก้ไขได้เฉพาะของตัวเอง" แยกกันจริง) ผ่าน `lib/session.ts` + บังคับทุกหน้าด้วย `middleware.ts` (auth จริงเทียบระบบ e-Court + roles ยัง out of scope; เดิมเป็น anonymous user ใน `localStorage` — annotation ที่สร้างสมัย `mock-user-01`/UUID จึงไม่ใช่ของตัวเองอีก) |
popup ซ้อนกัน/ไม่ปิดตอนกดปุ่ม tool + แท็บที่คั่นหน้ากลับไม่ได้ + งานค้างใน working tree: ไฟล์รูปภาพ annotate ได้ (N3) + อัปโหลดไฟล์ (N4): ส่วนที่ 1 — แก้ popup ตาม bug report ("กดแก้ไขแล้วไปกดที่ tool popup ของแก้ไขไม่ปิด/แสดงซ้อนกัน"): (1) ปุ่ม tool แบบ dropdown ทั้ง 5 (highlight/draw/shape/stamp/textbox) เรียก switchTool เสมอแม้เป็น tool เดิม — เดิมกดปุ่ม tool ที่ active อยู่จะ toggle เมนูอย่างเดียวไม่เรียก switchTool (ซึ่งเป็นที่เดียวที่เคลียร์ popup บนหน้า: editor/แผง selection/popover) ผลคือ popup แก้ไขค้างเปิด + dropdown เปิดซ้อนขึ้นมาอีกชั้น — ตอนนี้จำ intent เปิด/พับเมนูไว้ก่อนเรียก (switchTool ปิดเมนูทุกตัว) แล้วเปิดคืนเฉพาะที่ตั้งใจ; toggle พับเมนูและกลไกกลืนคลิก M4 เท่าเดิม (mousedown บนปุ่ม mark อยู่แล้ว) (2) switchTool ปิด popup bookmark ด้วย + openBookmarkComposer ปิดเมนูทั้ง 5 ก่อนเปิด popup (เดิมคีย์ลัด B ระหว่างเมนูเปิด = สอง popup ซ้อนกัน) (3) คลิกหน้าว่าง = ปิด popover ของ comment pin + เลือกรายการอื่น/goToAnnotation ปิด popover เดิมทิ้ง (เดิมปิด popover ได้แค่กด pin เดิม/Escape/สลับ tool) (4) COMMENT ที่เปิด popover อยู่ไม่ render กรอบ selection/แผงเครื่องมือ — bbox ของ COMMENT ครอบพื้นที่ pin พอดี แผงจะทับ popover และแถบ handle บังการคลิก (sidebar ยังตาม selectedId ไฮไลต์เหมือนเดิม) (5) แก้แท็บ sidebar ตาม bug report ("เลือก Annotation แล้วจะกลับไป tabs bookmarks ไม่ได้"): effect เดิม dep รวม tab และบังคับ setTab ไปแท็บ annotations ทุกครั้งที่รันตราบ selectedId ไม่ null ทำให้กดแท็บที่คั่นหน้าแล้วถูกดึงกลับทันที — ใช้ ref จำ id ที่ประกาศแล้ว บังคับสลับเฉพาะตอน selectedId เพิ่งเปลี่ยน เคลียร์เป็น null ตอน deselect ให้เลือกรายการเดิมซ้ำภายหลังยังสลับแท็บให้ ส่วน scroll แถวตอนเปิดแท็บหมายเหตุคงเดิม - sync rules/pdf-viewer.md: กติกาใหม่ popup บนหน้าเปิดได้ทีละชุดห้ามค้างเปิดซ้อนกัน (ปุ่ม dropdown เรียก switchTool เสมอ + วงจรปิด popover/แผง) และยกเว้น markMenuClosed ของคีย์ B ในกติกา M4 - verify: tsc ผ่าน, vitest 120/120, Playwright 18 เคสผ่าน (repro ตรงตามรายงาน: สร้าง textbox → แก้ไขข้อความ → กดปุ่ม tool เดิม → editor+แผงปิด+เมนูใช้ได้; popover pin ไม่ซ้อนแผง/ปิดด้วยคลิกหน้าว่าง; popup bookmark ปิดตอนสลับ tool; แท็บที่คั่นหน้ากลับได้) ส่วนที่ 2 — งานที่ค้างใน working tree ก่อนหน้า (ตามแผน docs/PLAN-IMAGE.md): - N3 รองรับไฟล์รูปภาพ jpg/jpeg/png/tif เป็นเอกสาร annotate ได้ครบทุกชนิด (viewer render img แทน canvas ไม่มี textLayer, scale contract เดียวกับ export) + TIFF หลายเฟรม = หลายหน้า (decode ผ่าน utif2 dynamic import → PNG blob URL ต่อเฟรม ฝั่ง client) + export PDF ฝังรูป (utils/image-to-pdf.ts: embedJpg/embedPng + EXIF rotate) + utils/file-kind.ts derive ชนิดไฟล์ + tests ของทั้งสอง utils - N4 อัปโหลดไฟล์ PDF/รูป ≤100MB ผ่านหน้ารายการ (components/UploadFileForm.tsx + app/actions/files.ts: validate + เขียนไฟล์ใหม่ที่ public/uploads/ เท่านั้น — invariant #1) - เอกสาร: docs/PLAN-IMAGE.md เพิ่มใหม่ + sync PLAN.md/PLAN-FEAT.md/CLAUDE.md/README/gap-analysis/data-layer rule Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-15 14:12:51 +07:00
| A5 | **Upload แบบง่าย (ปรับปรุง 2026-09-15 — N4)** | หน้า `/` อ่านรายการจาก DB + ฟอร์มอัปโหลดไฟล์เดียว (PDF/jpg/jpeg/png/tif/tiff, ≤ 100MB) ผ่าน Server Action เขียนไฟล์**ใหม่**ที่ `public/uploads/<uuid>.<ext>` เท่านั้น (ไม่แตะไฟล์เดิม) — sample เก็บใน `public/samples/` แล้ว sync ด้วย `npm run seed-samples` (รายละเอียดด้านล่าง) |
| A6 | **ทุก annotation ลบได้** | แสดงปุ่มลบเฉพาะรายการของตัวเอง (`userId` ตรงกัน) — ไม่มี role/permission ในเฟสนี้ |
| A7 | **Database & Realtime (ปรับปรุง 2026-09-08)** | PostgreSQL แบบ **plain container เดียว** (`judes-note-db`, Docker, image `postgres:17-alpine`, พอร์ต 54322) — **เลิกใช้ Supabase stack ทั้งหมด** (ไม่มี CLI/Kong/realtime container) เพราะโปรเจกต์ใช้จริงแค่ DB + sync; realtime sync ใช้ **SSE (Server-Sent Events) + pub/sub in-process** แทน Supabase Realtime |
| A8 | **Event ของ SSE เป็น DTO ตรง ๆ (2026-09-08)** | Server Actions publish event ผ่าน `lib/annotate-events.ts` หลังเขียน DB สำเร็จ — payload เป็น DTO (camelCase + ISO string) โดยตรง **ไม่มีการแปลง snake_case** เหมือนสมัยใช้ Supabase Realtime |
### ไฟล์ตัวอย่าง (อยู่ที่ `public/samples/` แล้ว)
| ไฟล์ | ที่มา | หน้า | ขนาด | ลักษณะ |
|---|---|---|---|---|
| `01012-672427-4f-690609-0000840227.pdf` | [judgement-api.admincourt.go.th](https://judgement-api.admincourt.go.th/v1/media/uploads/CaseFile/view/01012-672427-4f-690609-0000840227.pdf) | 15 | ~1.8 MB | **สแกนภาพล้วน** — ไม่มี text layer |
| `01012-672427-1f-690609-0000840223.pdf` | [judgement-api.admincourt.go.th](https://judgement-api.admincourt.go.th/v1/media/uploads/CaseFile/view/01012-672427-1f-690609-0000840223.pdf) | 44 | ~38 MB | **สแกนภาพล้วน** — ไม่มี text layer |
| `dashboard-report-2026-09-03.pdf` | เพิ่มโดยผู้ใช้ (2026-09-08) | 2 | ~150 KB | **text-based ภาษาไทย** (ตรวจด้วย pdfjs: 285 text items หน้า 1) — ใช้ทดสอบ HIGHLIGHT ได้ |
> ⚠️ ไฟล์คดีทั้งสองเป็นภาพสแกนทุกหน้า (ตรวจแล้ว: 0 หน้ามี `/Font`) → ใช้ทดสอบ **DRAW / COMMENT / BOOKMARK / Page Virtualization** (ไฟล์ 44 หน้า 38 MB เหมาะกับทดสอบ virtualization มาก) แต่**ทดสอบ HIGHLIGHT ไม่ได้** เพราะ pdfjs สร้าง TextLayer ไม่ได้เมื่อไม่มี text
>
> **สถานะ (2026-09-08): มี text-based PDF แล้ว** — `dashboard-report-2026-09-03.pdf` (เพิ่มโดยผู้ใช้) ใช้ทดสอบ HIGHLIGHT ได้ ปัญหาเดิมเกี่ยวกับการหาไฟล์ text-based ยุติแล้ว
---
## 1. System Architecture — Multi-Layer Non-Destructive Overlay
หนึ่งหน้า PDF = หนึ่ง `<div data-page="N">` ที่ซ้อน layer ทั้งหมดไว้ใน viewport เดียวกัน **ไฟล์ PDF ต้นฉบับไม่ถูกแก้ไข** (Non-Destructive) — annotation เก็บใน PostgreSQL เป็นพิกัด % เท่านั้น
```
┌─ div.relative[data-page=N] (ขนาด = viewport.width × viewport.height)
│
├── z-1 <canvas> ← pdfjs page.render() | pointer-events: none เสมอ
├── z-2 div.highlight-layer ← วาด rects จาก HIGHLIGHT % | pointer-events: none เสมอ
│ (mix-blend-mode: multiply)
├── z-2 div.textLayer ← pdfjs TextLayer (span โปร่งใส) | pointer-events: สลับตาม tool
├── z-3 <svg> InkLayer ← freehand DRAW + live stroke | pointer-events: สลับตาม tool
└── z-4 div.comment-pins ← Sticky note pin COMMENT | pointer-events: auto (ยกเว้นตอน draw)
```
### ตารางสลับ pointer-events ตาม Tool
| Tool | Base (canvas) | TextLayer (z-2) | InkLayer (z-3) | Pins (z-4) | พฤติกรรม |
|------|:---:|:---:|:---:|:---:|----------|
| `select` (ค่าเริ่มต้น) | none | **auto** | none | auto | คลุมดำข้อความได้ → เด้ง Floating Toolbar → Highlight |
| `draw` | none | **none** | **auto** | none | เมาส์วาดเส้นอิสระ (pointer capture) |
| `comment` | none | auto* | none | auto | คลิกจุดบนหน้า → เปิด composer → บันทึก COMMENT |
| `shape` (PLAN-FEAT.md F5) | none | **none** | **auto** | none | ลากคลุมวาดรูปทรง rect/ellipse/line/arrow บน InkLayer (เหมือน draw) |
| `erase` (PLAN-FEAT.md F6) | none | **none** | **auto** | none | ลากผ่านเส้น/รูปทรงของตัวเองบน InkLayer → hit-test โดน = ลบทันที (comment/highlight ไม่เกี่ยว) |
\* โหมด comment ให้ TextLayer ยัง select ได้ ส่วนการคลิกวาง pin จับ event ที่ page wrapper (bubble ขึ้นจาก layer ล่าง)
> **จุดสำคัญ:** กฎ z-index 2 ตัว (highlight-layer กับ textLayer) ไม่ทับกันเพราะ DOM order กำหนด — textLayer อยู่ทีหลัง = อยู่บนสุดของ z-2 → ข้อความ select ได้และ highlight ซ้อนหลัง text เสมอ (text เป็นสีโปร่งใสอยู่แล้ว)
---
## 2. Coordinate System — Relative Percent (%)
- **ทุกพิกัดใน JSONB** (`rects`, `paths`, `position`) เป็น **% ของขนาดหน้า PDF ณ scale ปัจจุบัน ณ ตอนวาด** → นำไป render ที่ zoom/จอเล็กจอใหญ่ได้ไม่เบี้ยว
- `strokeWidth` ของ DRAW เก็บเป็น **% ของความกว้างหน้า** (เช่น `0.4` = 0.4% ของ width) → แปลงกลับเป็น px ตอน render
- `yOffset` ของ bookmark เก็บเป็น **% ของความสูงหน้า** (0–100)
### กลไกแปลง (utils/coordinates.ts)
```
Pixel (event/getBoundingClientRect) ──÷size×100──▶ Percent (เก็บลง DB)
Percent (จาก DB) ──×size÷100──▶ Pixel (render ลง SVG/div)
```
### เหตุผลที่ InkLayer ใช้ SVG **แบบไม่มี viewBox**
กลเล่น `viewBox="0 0 100 100" preserveAspectRatio="none"` จะทำให้ **ความหนาเส้นบิดเบี้ยว** (scale แกน x,y ไม่เท่ากัน) → จึง render ในระบบ px จริง: แปลง `% → px` ด้วยขนาดที่วัดจาก `ResizeObserver` แล้ววาด `<path>` ตรง ๆ (ไม่ต้องใช้ `vector-effect`)
---
## 3. Database Schema (Drizzle ORM + PostgreSQL)
ไฟล์ `db/schema.ts`
### ตาราง `files`
| column | type | หมายเหตุ |
|---|---|---|
| id | uuid PK `defaultRandom()` | |
| title | text NOT NULL | |
| description | text | nullable |
| pdf_url | text NOT NULL | |
| created_at | timestamptz `defaultNow()` | |
### Enum `annotation_type` = `'HIGHLIGHT' | 'DRAW' | 'COMMENT'`
### ตาราง `annotations`
| column | type | หมายเหตุ |
|---|---|---|
| id | uuid PK | |
| file_id | uuid FK → files ON DELETE CASCADE | |
| page_index | integer NOT NULL | 0-based |
mock login e-Court หน้า /login + identity จาก cookie oacCase แทน localStorage — dropdown ผู้ใช้ MOCK_USERS (user1–user5, utils/user.ts pure module ให้ client+server ใช้ร่วม) รหัสผ่านกลาง Demo1234 ผ่าน server action login (เช็ครายชื่อฝั่ง server เป็น defense กัน hand-craft + delay 500ms ให้เห็น "กำลังตรวจสอบ…") ตั้ง cookie oacCase ตาม format จริงของระบบศาล 16 field — u_userid/u_userlogin ต่างกันต่อคน (= ชื่อผู้ใช้) เพื่อให้สิทธิ์ "แก้ไขได้เฉพาะของตัวเอง" (guard ด้วย userId) แยกกันจริงทั้ง 5 คน httpOnly/sameSite lax/8 ชม. + middleware.ts บังคับ login ทุกหน้า (parse จริงด้วย parseOacCase ไม่ใช่แค่มี cookie — ขยะ/ว่าง/ขาด field = redirect /login; matcher ยกเว้น /login, /api/*, /uploads/*, _next และ static ใน public/ — SSE/export จึงไม่โดน 302) + lib/session.ts getSessionUser อ่าน identity ฝั่ง server ใช้ในทุกหน้า (defense ซ้อน middleware) + PDFViewer รับ user เป็น prop จาก annotate page ตั้งแต่ first paint (หมดช่วง null user ที่เคยสร้าง annotation ด้วย userId="" ได้ + ไม่มี hydration mismatch; Server Actions ไม่ต้องแก้ — vString รับ userId ใหม่อยู่แล้ว) + SessionBar แสดง "เข้าสู่ระบบเป็น <user>" + ปุ่มออกจากระบบ (ลบ cookie ด้วย path "/" ตรงตอน set + redirect ใน action เอง — client router.replace เองชนกับ re-render ของหน้าที่เพิ่งเสีย session จน navigation ไม่เกิด) แทน UserPicker mock 10 คน (ลบไฟล์พร้อม MOCK_USERS/localStorage/getLocalUser ทั้งชุด) + LoginForm การ์ด max-w-sm กลางจอ reuse brand lockup และปุ่ม primary เดิม, input text-base 16px กัน iOS zoom บน iPad, dropdown placeholder "เลือกผู้ใช้" บังคับเลือก (ไม่ preselect กัน login ผิดคนโดยไม่ตั้งใจ), error แถบเดียว role=alert "รหัสผ่านไม่ถูกต้อง" (dropdown เลือกผิดไม่ได้แล้ว), ตอนผิดล้าง password คงผู้ใช้ที่เลือกไว้ + focus+select + tests/utils/user.test.ts ล็อก format จริงของ e-Court (parse ตัวอย่าง cookie จริง 235821/tu_2, garbage/empty/ขาด field → null, round-trip, buildOacCaseCookie ครบ 16 field) + sync CLAUDE.md/PLAN.md §0 A4/README — annotation สมัย mock-user-01/UUID ไม่ใช่ของตัวเองอีก (auth จริงเทียบระบบศาล + roles ยัง out of scope; /api/* และ /uploads/* ยังไม่ต้อง login ตามขอบเขต mock) Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-17 13:41:02 +07:00
| user_id | text NOT NULL | จาก cookie oacCase (`u_userid`) |
| user_name | text NOT NULL | |
| type | annotation_type NOT NULL | |
| data | jsonb NOT NULL `$type<AnnotationData>()` | **strict typed union** (ด้านล่าง) |
| created_at / updated_at | timestamptz | |
Index: `(file_id, page_index)` สำหรับ query หลักตอนเปิด viewer
### ตาราง `bookmarks`
| column | type | หมายเหตุ |
|---|---|---|
| id | uuid PK | |
| file_id | uuid FK → files CASCADE | |
| page_index | integer NOT NULL | |
| user_id | text NOT NULL | |
| user_name | text NOT NULL DEFAULT '' | ชื่อผู้บันทึก (N6 — row เดิม backfill จาก `annotations` ครั้งเดียว) |
| title | text NOT NULL | |
| y_offset | double precision NOT NULL | % ของความสูงหน้า (0–100) |
| created_at | timestamptz | |
### Strict JSONB Union (export เป็น TypeScript types)
```ts
type Rect = { x: number; y: number; width: number; height: number }; // ทุกค่าเป็น %
type HighlightData = { text: string; color: string; rects: Rect[] };
type DrawData = { strokeColor: string; strokeWidth: number; paths: { x: number; y: number }[] }; // % + strokeWidth เป็น % ของ width
type CommentData = { position: { x: number; y: number }; message: string }; // %
type AnnotationData = HighlightData | DrawData | CommentData; // เลือกตาม column `type` (discriminated โดย enum)
```
→ พร้อม **DTO types** (`AnnotationDTO`, `BookmarkDTO`) ที่ timestamp เป็น ISO string เพื่อให้ server → client → realtime payload ใช้ type เดียวกัน
---
## 4. Data Flow & Realtime Sync
```
Client A วาด ──▶ Server Action createAnnotation() ──▶ INSERT PostgreSQL
│ (เขียนสำเร็จ)
▼
publish ใน process (lib/annotate-events.ts)
│
▼
SSE /api/annotations/stream?fileId=<uuid> ──▶ Client B ได้ event ทันที
```
- **เขียน:** ผ่าน Server Actions เท่านั้น (ต่อ `DATABASE_URL` ตรง ๆ) — และ**หลังเขียนสำเร็จทุกครั้ง ต้อง publish event** ให้ `lib/annotate-events.ts`
BASE_PATH: serve ใต้ /docedit/note — next.config.mjs ตั้ง basePath + env NEXT_PUBLIC_BASE_PATH (ประกาศค่าเดียวที่ไฟล์นี้) ให้ utils/base-path.ts (ใหม่) withBasePath() เติม prefix ให้ URL ฝั่ง browser ที่ Next ไม่เติมให้เอง (fetch/EventSource/plain href/workerSrc/img src): PDFViewer (workerSrc, /api/pdf ซ่อม ToUnicode, fallback file.pdfUrl, image branch N3 รวบเป็นตัวแปร fileUrl, export PDF F8), SSE stream ใน useAnnotationRealtime, ปุ่มส่งออก bookmark JSON (N7) + คัดลอกลิงก์ F12 ใน AnnotationSidebar (window.location.origin + basePath กัน paste แล้ว 404), Export CSV (F16) หน้า summary — <Link>/next/navigation/middleware.ts ไม่ต้องแก้ (Next 14.2 auto-prefix ให้ + matcher จับ path ที่ตัด basePath แล้ว + NextURL redirect ใส่กลับเอง ยืนยันจาก source ใน node_modules) + pdf_url ใน DB เก็บ path ไม่รวม basePath เติมตอนใช้เท่านั้น + sync เอกสารตาม governance: CLAUDE.md invariant #6 (workerSrc ผ่าน withBasePath) + #12 ใหม่ (URL ฝั่ง browser ที่สร้างเองต้องผ่าน withBasePath), PLAN.md A3/§4, rules pdf-viewer/realtime, plan-reviewer #8, README URL localhost:3000/docedit/note — verify: tsc strict ผ่าน + curl (root / 404, ไม่มี cookie 307→/docedit/note/login, worker 200, SSE reachable, มี cookie 200) + Playwright login จริงเปิดไฟล์ 44 หน้า render ครบ (SSE /api/pdf /worker 200 ผ่าน basePath) + code-reviewer ผ่าน (vitest 126 tests, ไม่พบ bug, blob URL ไม่โดน prefix ซ้ำ, env inline ครบทั้ง client/server ตาม define-env-plugin) — deploy: basePath เป็น build-time ต้อง build image ใหม่ถึงจะเสิร์ฟที่ /docedit/note + reverse proxy ต้อง forward path เต็มแบบไม่ strip prefix Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-17 16:06:20 +07:00
- **อ่าน/ sync:** `useAnnotationRealtime` hook เปิด `EventSource` ไปที่ SSE route `withBasePath("/api/annotations/stream?fileId=<id>")` — payload เป็น DTO (camelCase + ISO string) จาก server ตรง ๆ ไม่ต้องแปลง; `EventSource` ต่อใหม่อัตโนมัติเมื่อ connection ขาด (ห้ามทำ retry เอง)
- pub/sub เป็น **in-memory ของ Next.js process เดียว** (dev / `next start` ธรรมดา) — หากอนาคตรันหลาย instance ต้องเพิ่มตัวกลาง (Redis pub/sub หรือ Postgres `LISTEN/NOTIFY`) ก่อน
เลือก annotation บนหน้า PDF → sidebar ตามไปเน้นรายการนั้น + สร้างเสร็จเลือกให้ทันที + กัน duplicate ชั่วขณะตอนสร้าง: (1) ส่ง selectedId ให้ AnnotationSidebar — ไฮไลต์แถว (ทั้งลิสต์หลักและผลค้นหา) + scrollIntoView block:nearest + สลับมาแท็บ Annotations ให้เองถ้ากำลังอยู่ Bookmarks (rAF รอแถว render หลังสลับแท็บค่อยเลื่อน) (2) AUTO_SELECT_TYPES — สร้าง DRAW/SHAPE/TEXTBOX/STAMP/HIGHLIGHT/REDACTION เสร็จ = เลือกตัวใหม่ทันทีด้วย saved.id (กรอบ selection + sidebar ตามไปเลย; COMMENT ยกเว้นเพราะ composer/pin ทำหน้าที่นั้นอยู่แล้ว) — แก้ปัญหา "วาดเสร็จคลิกที่เส้น tool ยังเป็นวาด sidebar นิ่ง" (3) กันอาการ row โผล่แล้วหาย (0→2→1): เดิม optimistic row กับ row จาก server คนละ id ทำให้ SSE event มาก่อน action เสร็จ dedupe ไม่ได้ — ตอนนี้ client ส่ง id ของ optimistic row ให้ createAnnotation ใช้ซ้ำ (validate UUID, ไม่ส่ง = สุ่มเองตามเดิมสำหรับ redo path) ทุกชั้น dedupe ด้วย id เดียวกันตั้งแต่แรก — sync PLAN.md §4 + rules/realtime.md ให้ตรงพฤติกรรมใหม่ Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-14 11:35:05 +07:00
- **Optimistic update ด้วย `useOptimistic` (React 18):** ตอนผู้ใช้ปล่อยเมาส์ → `startTransition(() => { addOptimistic(row ชั่วคราว); return createAnnotation(...) })` → React ถอด optimistic row ออกอัตโนมัติเมื่อ action เสร็จ และ **rollback ให้เองหาก action ล้มเหลว** (ไม่ต้องจัดการ id `temp-*` เอง) — client ส่ง id ของ optimistic row ให้ `createAnnotation` ใช้ซ้ำ (action validate เป็น UUID) เพื่อให้ SSE event ที่มาถึงก่อน action เสร็จ dedupe ด้วย id เดียวกันได้ทันที **ไม่เห็น duplicate ชั่วขณะ** (เดิม row จริงใช้ id ใหม่ทำให้แสดง 2 แถวแวบหนึ่งก่อน row ชั่วคราวถูกถอด)
---
## 5. โครงสร้างไฟล์ทั้งหมด
```
judes-note/
├── PLAN.md ← ไฟล์นี้
├── README.md ← วิธี setup (docker run postgres, env, drizzle push, seed, run)
├── package.json / tsconfig.json / next.config.mjs
├── tailwind.config.ts / postcss.config.js / drizzle.config.ts
├── .env.example
├── db/
│ ├── schema.ts ← ตาราง + Strict JSONB types + DTO types + mapper toDTO
│ └── index.ts ← postgres.js client + drizzle (DATABASE_URL → container judes-note-db, prepare:false)
├── lib/
│ └── annotate-events.ts ← pub/sub in-process (Server Actions publish → SSE route subscribe)
├── utils/
│ ├── coordinates.ts ← pixel⇄% (point/rect/path) + getPointerPercent + clamp + mergeRectsByLine (รวบ rects บรรทัดเดียวกันเหลือ 1 rect/บรรทัดก่อนบันทึก)
mock login e-Court หน้า /login + identity จาก cookie oacCase แทน localStorage — dropdown ผู้ใช้ MOCK_USERS (user1–user5, utils/user.ts pure module ให้ client+server ใช้ร่วม) รหัสผ่านกลาง Demo1234 ผ่าน server action login (เช็ครายชื่อฝั่ง server เป็น defense กัน hand-craft + delay 500ms ให้เห็น "กำลังตรวจสอบ…") ตั้ง cookie oacCase ตาม format จริงของระบบศาล 16 field — u_userid/u_userlogin ต่างกันต่อคน (= ชื่อผู้ใช้) เพื่อให้สิทธิ์ "แก้ไขได้เฉพาะของตัวเอง" (guard ด้วย userId) แยกกันจริงทั้ง 5 คน httpOnly/sameSite lax/8 ชม. + middleware.ts บังคับ login ทุกหน้า (parse จริงด้วย parseOacCase ไม่ใช่แค่มี cookie — ขยะ/ว่าง/ขาด field = redirect /login; matcher ยกเว้น /login, /api/*, /uploads/*, _next และ static ใน public/ — SSE/export จึงไม่โดน 302) + lib/session.ts getSessionUser อ่าน identity ฝั่ง server ใช้ในทุกหน้า (defense ซ้อน middleware) + PDFViewer รับ user เป็น prop จาก annotate page ตั้งแต่ first paint (หมดช่วง null user ที่เคยสร้าง annotation ด้วย userId="" ได้ + ไม่มี hydration mismatch; Server Actions ไม่ต้องแก้ — vString รับ userId ใหม่อยู่แล้ว) + SessionBar แสดง "เข้าสู่ระบบเป็น <user>" + ปุ่มออกจากระบบ (ลบ cookie ด้วย path "/" ตรงตอน set + redirect ใน action เอง — client router.replace เองชนกับ re-render ของหน้าที่เพิ่งเสีย session จน navigation ไม่เกิด) แทน UserPicker mock 10 คน (ลบไฟล์พร้อม MOCK_USERS/localStorage/getLocalUser ทั้งชุด) + LoginForm การ์ด max-w-sm กลางจอ reuse brand lockup และปุ่ม primary เดิม, input text-base 16px กัน iOS zoom บน iPad, dropdown placeholder "เลือกผู้ใช้" บังคับเลือก (ไม่ preselect กัน login ผิดคนโดยไม่ตั้งใจ), error แถบเดียว role=alert "รหัสผ่านไม่ถูกต้อง" (dropdown เลือกผิดไม่ได้แล้ว), ตอนผิดล้าง password คงผู้ใช้ที่เลือกไว้ + focus+select + tests/utils/user.test.ts ล็อก format จริงของ e-Court (parse ตัวอย่าง cookie จริง 235821/tu_2, garbage/empty/ขาด field → null, round-trip, buildOacCaseCookie ครบ 16 field) + sync CLAUDE.md/PLAN.md §0 A4/README — annotation สมัย mock-user-01/UUID ไม่ใช่ของตัวเองอีก (auth จริงเทียบระบบศาล + roles ยัง out of scope; /api/* และ /uploads/* ยังไม่ต้อง login ตามขอบเขต mock) Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-17 13:41:02 +07:00
│ └── user.ts ← parse/build ค่า cookie oacCase → identity (mock login e-Court)
├── hooks/
│ └── useAnnotationRealtime.ts ← EventSource ต่อ SSE stream + dedupe + expose setters
├── app/
│ ├── layout.tsx / globals.css ← globals.css มี CSS ของ .textLayer ที่ pdfjs ต้องการ
│ ├── page.tsx ← รายการไฟล์ (server component, drizzle query)
│ ├── actions/annotations.ts ← Server Actions: CRUD annotations + bookmarks (+ validation guards
│ │ + publish event หลังเขียนสำเร็จทุกครั้ง)
│ ├── api/annotations/stream/route.ts ← SSE endpoint (GET ?fileId=) ยิง event จาก lib/annotate-events
│ └── annotate/[id]/page.tsx ← โหลด file + initial data → ส่งเข้า <PDFViewer/>
├── components/
│ ├── pdf/
│ │ ├── PDFViewer.tsx ← client root: โหลดเอกสาร, tool state, zoom, selection→highlight,
│ │ │ bookmark, comment composer, sidebar layout, Page Virtualization
│ │ │ (IntersectionObserver render เฉพาะหน้าใน viewport ± 1 หน้า)
│ │ ├── InkLayer.tsx ← SVG freehand (pointer capture, live stroke, ResizeObserver re-render)
│ │ └── HighlightToolbar.tsx ← Floating popup เลือกสี (fixed ตำแหน่งจาก selection rect)
│ └── sidebar/
│ └── AnnotationSidebar.tsx ← แท็บ Bookmarks / Annotations + ปุ่ม scroll-to + ลบรายการตัวเอง
```
---
## 6. พฤติกรรมหลักของแต่ละ Feature
### 6.1 Text Selection → Highlight
1. Tool `select` → คลุมดำบน TextLayer (span โปร่งใส + `::selection`)
2. `mouseup` → `window.getSelection()` → `range.getClientRects()` → กรอง rect กว้าง 0 → **`mergeRectsByLine`: รวบ rects ที่ y/height ใกล้กัน (บรรทัดเดียวกัน) เหลือ 1 rect ต่อบรรทัด กัน JSONB บวม** → แปลงเป็น % เทียบ `pageEl.getBoundingClientRect()`
3. เด้ง `HighlightToolbar` (position: fixed ที่มุม rect แรก) เลือกสี 5 สี
4. กดสี → `createAnnotation('HIGHLIGHT', { text, color, rects })` + optimistic
5. Render: div สีทึบนิดหน่อย + `mix-blend-mode: multiply` ที่ z-2 (อยู่หลัง text เสมอ)
### 6.2 Freehand Drawing (InkLayer)
1. Tool `draw` → svg รับ `pointerdown` → `setPointerCapture` (เส้นไม่ขาดเวลาเลื่อนออกนอก page)
2. เก็บจุดทั้ง px (render) และ % (save) — สร้าง path `M x y L ...`
3. `pointerup` → ถ้าจุด ≥ 2 → `createAnnotation('DRAW', { strokeColor, strokeWidth, paths })`
4. Path เดิมจาก DB แปลง % → px ด้วยขนาดที่ ResizeObserver วัด → re-render เมื่อ resize/zoom
### 6.3 Comment (Sticky Note)
1. Tool `comment` → คลิกตำแหน่งบนหน้า → เปิด composer เล็ก ๆ (fixed) ใส่ข้อความ
2. บันทึก → วาด pin (icon MessageSquare) ที่ตำแหน่ง % → คลิก pin เพื่อดู popover (ข้อความ + ผู้เขียน + เวลา)
### 6.4 Bookmark
1. ปุ่ม BookmarkPlus บน toolbar → หา "หน้าปัจจุบัน" (page ที่ midpoint ใกล้ viewport center สุด จาก scroll listener)
2. `yOffset` = ตำแหน่งกึ่งกลาง viewport เทียบหน้านั้น (% ของความสูงหน้า)
3. Sidebar คลิก bookmark → `window.scrollTo` (smooth) ไปที่ `pageTop + pageHeight × yOffset%` (คำนวณ absolute ก่อนแล้ว smooth-scroll ครั้งเดียว ไม่ scroll ซ้อนสองจังหวะ)
---
## 7. ลำดับการ Implement (ตาม Task Output Order) + เกณฑ์ตรวจ
| # | งาน | ไฟล์ | Verify |
|---|---|---|---|
| 1 | Scaffolding + Schema | `package.json`, config ทั้งหมด, `db/schema.ts`, `db/index.ts` | `tsc` ผ่าน, `drizzle-kit push` สร้างตารางได้ |
| 2 | Coordinate helpers | `utils/coordinates.ts`, `utils/user.ts` | Unit เช็คตาโค้ด: 1000px → 50% ที่ width 2000 (round-trip ต้องได้ค่าเดิม ± ε); mergeRectsByLine รวม rects หลายอันบรรทัดเดียวกัน → เหลือ 1 rect ครอบข้อความเดิม |
| 3 | Server Actions | `app/actions/annotations.ts` | เรียก action สร้าง/ลบ annotation แล้ว row ใน DB เปลี่ยนตาม; input ผิด type โดน reject |
| 4 | Realtime hook | `hooks/useAnnotationRealtime.ts` | เปิด 2 browser แท็บ — แท็บ A highlight แล้วแท็บ B เห็นภายใน ~1s โดยไม่ refresh |
| 5 | UI หลัก | `globals.css`, `layout.tsx`, `page.tsx`, `annotate/[id]/page.tsx`, `PDFViewer.tsx`, `InkLayer.tsx`, `HighlightToolbar.tsx`, `AnnotationSidebar.tsx` | คลุมดำ→toolbar→สีติดหน้า / วาดเส้นลื่น ไม่เบี้ยวตอน zoom / bookmark กดแล้ว scroll ลื่น / pin comment แสดงถูกตำแหน่ง / PDF ยาว 100 หน้า เลื่อนลื่น (canvas ของหน้าที่พ้นจอถูก unmount) |
| 6 | README | `README.md` | ทำตามขั้นตอนจากศูนย์แล้วรัน `npm run dev` ได้ |
---
## 8. Edge Cases ที่ออกแบบไว้ล่วงหน้า
- **TextLayer ต้อง re-render ทุกครั้งที่ zoom** (scale เปลี่ยน = viewport เปลี่ยน) → effect dep `[doc, pageNumber, scale]` + cancel render task ตอน unmount กัน memory leak
- **Canvas คมชัดบนจอ Retina** → `canvas.width × devicePixelRatio` + `transform: [dpr,0,0,dpr,0,0]` แต่ `style.width` คง viewport px
- **Selection ข้ามหน้า** → ยอมรับเฉพาะ rects ใน page เดียว (หา page จาก `commonAncestorContainer.closest('[data-page]')`) — สเปกเก็บ `pageIndex` ค่าเดียวต่อ annotation
- **SSE event ชน optimistic row** → duplicate ชั่วขณะหายเองเมื่อ `useOptimistic` ถอด row + dedupe ด้วย id ใน hook (ดู §4)
- **Server component ส่ง Date ไม่ได้ตรง ๆ กับ realtime string** → normalize เป็น ISO string DTO ทั้งระบบ
- **pdfjs บน SSR** → `dynamic import("pdfjs-dist")` ภายใน useEffect เท่านั้น (ไม่ import top-level ใน module scope ที่ server evaluate ด้วย)
- **PDF ยาว (20–100+ หน้า): Page Virtualization** → `IntersectionObserver` ใน PDFViewer render เฉพาะหน้าใน viewport ± 1 หน้า (canvas + TextLayer + InkLayer); หน้าที่พ้นจอเก็บเป็น placeholder `div` **ขนาดจริง** (วัดจาก `pdfPage.getViewport()` ทุกหน้าตอนโหลดครั้งเดียว — cost ถูก) เพื่อไม่ให้ scroll jump และให้ bookmark ยังคำนวณ absolute scroll ได้ แล้ว unmount layer ทั้งหมดของหน้านั้นคืน memory — annotation ของหน้าที่ยังไม่ render อยู่ใน state ตามปกติ จะแสดงเมื่อหน้าเข้าจอ
BASE_PATH: serve ใต้ /docedit/note — next.config.mjs ตั้ง basePath + env NEXT_PUBLIC_BASE_PATH (ประกาศค่าเดียวที่ไฟล์นี้) ให้ utils/base-path.ts (ใหม่) withBasePath() เติม prefix ให้ URL ฝั่ง browser ที่ Next ไม่เติมให้เอง (fetch/EventSource/plain href/workerSrc/img src): PDFViewer (workerSrc, /api/pdf ซ่อม ToUnicode, fallback file.pdfUrl, image branch N3 รวบเป็นตัวแปร fileUrl, export PDF F8), SSE stream ใน useAnnotationRealtime, ปุ่มส่งออก bookmark JSON (N7) + คัดลอกลิงก์ F12 ใน AnnotationSidebar (window.location.origin + basePath กัน paste แล้ว 404), Export CSV (F16) หน้า summary — <Link>/next/navigation/middleware.ts ไม่ต้องแก้ (Next 14.2 auto-prefix ให้ + matcher จับ path ที่ตัด basePath แล้ว + NextURL redirect ใส่กลับเอง ยืนยันจาก source ใน node_modules) + pdf_url ใน DB เก็บ path ไม่รวม basePath เติมตอนใช้เท่านั้น + sync เอกสารตาม governance: CLAUDE.md invariant #6 (workerSrc ผ่าน withBasePath) + #12 ใหม่ (URL ฝั่ง browser ที่สร้างเองต้องผ่าน withBasePath), PLAN.md A3/§4, rules pdf-viewer/realtime, plan-reviewer #8, README URL localhost:3000/docedit/note — verify: tsc strict ผ่าน + curl (root / 404, ไม่มี cookie 307→/docedit/note/login, worker 200, SSE reachable, มี cookie 200) + Playwright login จริงเปิดไฟล์ 44 หน้า render ครบ (SSE /api/pdf /worker 200 ผ่าน basePath) + code-reviewer ผ่าน (vitest 126 tests, ไม่พบ bug, blob URL ไม่โดน prefix ซ้ำ, env inline ครบทั้ง client/server ตาม define-env-plugin) — deploy: basePath เป็น build-time ต้อง build image ใหม่ถึงจะเสิร์ฟที่ /docedit/note + reverse proxy ต้อง forward path เต็มแบบไม่ strip prefix Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-17 16:06:20 +07:00
- **pdf.worker v4 เป็นไฟล์ `.mjs`** → คัดลอก `pdf.worker.min.mjs` จาก `node_modules/pdfjs-dist/build/` ไป `public/` ด้วย **postinstall script** (version ตรงกับ lockfile เสมอ) และตั้ง `pdfjs.GlobalWorkerOptions.workerSrc = withBasePath("/pdf.worker.min.mjs")` — อย่าใช้ `new URL(..., import.meta.url)` ซึ่งมักชน Turbopack/webpack ใน Next 14
---
## 9. ขอบเขตที่ **ไม่** ทำในเฟสนี้ (Out of Scope)
mock login e-Court หน้า /login + identity จาก cookie oacCase แทน localStorage — dropdown ผู้ใช้ MOCK_USERS (user1–user5, utils/user.ts pure module ให้ client+server ใช้ร่วม) รหัสผ่านกลาง Demo1234 ผ่าน server action login (เช็ครายชื่อฝั่ง server เป็น defense กัน hand-craft + delay 500ms ให้เห็น "กำลังตรวจสอบ…") ตั้ง cookie oacCase ตาม format จริงของระบบศาล 16 field — u_userid/u_userlogin ต่างกันต่อคน (= ชื่อผู้ใช้) เพื่อให้สิทธิ์ "แก้ไขได้เฉพาะของตัวเอง" (guard ด้วย userId) แยกกันจริงทั้ง 5 คน httpOnly/sameSite lax/8 ชม. + middleware.ts บังคับ login ทุกหน้า (parse จริงด้วย parseOacCase ไม่ใช่แค่มี cookie — ขยะ/ว่าง/ขาด field = redirect /login; matcher ยกเว้น /login, /api/*, /uploads/*, _next และ static ใน public/ — SSE/export จึงไม่โดน 302) + lib/session.ts getSessionUser อ่าน identity ฝั่ง server ใช้ในทุกหน้า (defense ซ้อน middleware) + PDFViewer รับ user เป็น prop จาก annotate page ตั้งแต่ first paint (หมดช่วง null user ที่เคยสร้าง annotation ด้วย userId="" ได้ + ไม่มี hydration mismatch; Server Actions ไม่ต้องแก้ — vString รับ userId ใหม่อยู่แล้ว) + SessionBar แสดง "เข้าสู่ระบบเป็น <user>" + ปุ่มออกจากระบบ (ลบ cookie ด้วย path "/" ตรงตอน set + redirect ใน action เอง — client router.replace เองชนกับ re-render ของหน้าที่เพิ่งเสีย session จน navigation ไม่เกิด) แทน UserPicker mock 10 คน (ลบไฟล์พร้อม MOCK_USERS/localStorage/getLocalUser ทั้งชุด) + LoginForm การ์ด max-w-sm กลางจอ reuse brand lockup และปุ่ม primary เดิม, input text-base 16px กัน iOS zoom บน iPad, dropdown placeholder "เลือกผู้ใช้" บังคับเลือก (ไม่ preselect กัน login ผิดคนโดยไม่ตั้งใจ), error แถบเดียว role=alert "รหัสผ่านไม่ถูกต้อง" (dropdown เลือกผิดไม่ได้แล้ว), ตอนผิดล้าง password คงผู้ใช้ที่เลือกไว้ + focus+select + tests/utils/user.test.ts ล็อก format จริงของ e-Court (parse ตัวอย่าง cookie จริง 235821/tu_2, garbage/empty/ขาด field → null, round-trip, buildOacCaseCookie ครบ 16 field) + sync CLAUDE.md/PLAN.md §0 A4/README — annotation สมัย mock-user-01/UUID ไม่ใช่ของตัวเองอีก (auth จริงเทียบระบบศาล + roles ยัง out of scope; /api/* และ /uploads/* ยังไม่ต้อง login ตามขอบเขต mock) Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-17 13:41:02 +07:00
- auth จริงเทียบระบบ e-Court + roles/สิทธิ์ — login ปัจจุบันเป็น mock หน้า `/login` (ตั้ง cookie `oacCase` ตาม format จริง แต่ไม่ได้ตรวจกับระบบศาลจริง)
- แก้ไข/ย้าย annotation ที่วาดแล้ว (มีเฉพาะสร้าง + ลบ) — **ข้อนี้ถูกขยายโดย `docs/PLAN-FEAT.md` F1**: อนุญาตแก้ไข `data` (ย้าย/ปรับขนาด/เปลี่ยนสี–ความหนา) ของตัวเอง ในหน้าเดียวกัน ผ่าน `updateAnnotation`
- Undo/redo, export PDF ที่ฝัง annotation
- แสดงรายชื่อ "ใครออนไลน์อยู่" (presence)
หากต้องการเพิ่มข้อใด กรุณาบอกก่อนเริ่ม implement