From f7cd4b8312deaff9a1078bfb0698cb500937fbf5 Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 22 Sep 2026 09:57:55 +0700 Subject: [PATCH 01/41] =?UTF-8?q?viewer:=20=E0=B9=80=E0=B8=9B=E0=B8=A5?= =?UTF-8?q?=E0=B8=B5=E0=B9=88=E0=B8=A2=E0=B8=99=E0=B8=95=E0=B8=B1=E0=B8=A7?= =?UTF-8?q?=E0=B9=80=E0=B8=A5=E0=B8=B7=E0=B9=88=E0=B8=AD=E0=B8=99=E0=B8=82?= =?UTF-8?q?=E0=B8=AD=E0=B8=87=E0=B8=AB=E0=B8=99=E0=B9=89=E0=B8=B2=20annota?= =?UTF-8?q?te=20=E0=B8=88=E0=B8=B2=E0=B8=81=20window=20=E0=B8=A1=E0=B8=B2?= =?UTF-8?q?=E0=B9=80=E0=B8=9B=E0=B9=87=E0=B8=99=20
=20=E0=B8=82?= =?UTF-8?q?=E0=B8=AD=E0=B8=87=E0=B9=80=E0=B8=AD=E0=B8=81=E0=B8=AA=E0=B8=B2?= =?UTF-8?q?=E0=B8=A3=20=E2=80=94=20zoom=20=E0=B9=80=E0=B8=81=E0=B8=B4?= =?UTF-8?q?=E0=B8=99=E0=B8=88=E0=B8=AD=E0=B9=81=E0=B8=A5=E0=B9=89=E0=B8=A7?= =?UTF-8?q?=E0=B9=80=E0=B8=A5=E0=B8=B7=E0=B9=88=E0=B8=AD=E0=B8=99=E0=B9=84?= =?UTF-8?q?=E0=B8=94=E0=B9=89=E0=B8=97=E0=B8=B1=E0=B9=89=E0=B8=87=E0=B8=AA?= =?UTF-8?q?=E0=B8=AD=E0=B8=87=E0=B9=81=E0=B8=81=E0=B8=99=20sidebar/header?= =?UTF-8?q?=20=E0=B8=AD=E0=B8=A2=E0=B8=B9=E0=B9=88=E0=B8=81=E0=B8=B1?= =?UTF-8?q?=E0=B8=9A=E0=B8=97=E0=B8=B5=E0=B9=88=20(=E0=B8=84=E0=B8=B3?= =?UTF-8?q?=E0=B8=82=E0=B8=AD=E0=B8=9C=E0=B8=B9=E0=B9=89=E0=B9=83=E0=B8=8A?= =?UTF-8?q?=E0=B9=89=202026-09-22:=20=E0=B9=80=E0=B8=94=E0=B8=B4=E0=B8=A1?= =?UTF-8?q?=E0=B9=80=E0=B8=A5=E0=B8=B7=E0=B9=88=E0=B8=AD=E0=B8=99/?= =?UTF-8?q?=E0=B8=8B=E0=B8=B9=E0=B8=A1=E0=B8=97=E0=B8=B5=E0=B9=80=E0=B8=94?= =?UTF-8?q?=E0=B8=B5=E0=B8=A2=E0=B8=A7=E0=B9=80=E0=B8=9B=E0=B9=87=E0=B8=99?= =?UTF-8?q?=E0=B8=97=E0=B8=B1=E0=B9=89=E0=B8=87=E0=B8=AB=E0=B8=99=E0=B9=89?= =?UTF-8?q?=E0=B8=B2=E0=B9=80=E0=B8=A7=E0=B9=87=E0=B8=9A=E0=B8=A3=E0=B8=A7?= =?UTF-8?q?=E0=B8=A1=20sidebar=20=E0=B9=84=E0=B8=9B=E0=B8=94=E0=B9=89?= =?UTF-8?q?=E0=B8=A7=E0=B8=A2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PDFViewer root เป็น app shell flex h-dvh overflow-hidden (dvh กันปัญหา URL bar ของจอสัมผัส) + content column เป็น flex-col +
ได้ class pdf-scroll-main min-h-0 flex-1 overflow-auto overscroll-contain = ตัวเลื่อนเดียวของหน้า; aside เปลี่ยน h-screen เป็น h-full (ถอด sticky top-0 ที่เป็น no-op แล้ว เหลือ top-0 สำหรับ max-lg:fixed) - class ใหม่ .pdf-scroll-main ใน globals.css: touch-action pan-x pan-y บนพื้นที่เอกสาร — กัน browser pinch/double-tap zoom ทั้งหน้าเว็บ (อาการ sidebar โต้ตามตอนซูมบน tablet) วางก่อน .pdf-custom-select เพื่อให้โหมดมาร์กข้อความ (touch-action:none) ยังชนะตอนเปิดใช้; ปุ่มซูม +- เป็นทางเลือก single-pointer ตาม WCAG 2.5.1 - retarget scroll ทุกจุดจาก window มาที่ mainRef.current: scrollToBelowHeader รับ main + helper ใหม่ contentTopInScroll(main, clientTop) แทนสูตร r.top + window.scrollY (ลบ viewerHeaderHeight ที่ไม่จำเป็น — header อยู่นอกกรอบ scroll), ตรวจหน้าปัจจุบันย้าย listener มาที่ main, IO ของ virtualization ตั้ง root เป็น main, deep link ?page/?ann, goToPage/goToBookmark/goToAnnotation/currentViewPosition, tool pan ใช้ main.scrollBy (ลากได้ทั้งแนวนอนด้วย) - useCustomSelection auto-scroll: เลื่อน scroll container ของ
แทน window + เพิ่มแกนนอน (ลากชิดขอบซ้าย/ขวาเลื่อนต่อ — แก้ zoom แล้วเข้าไม่ถึงครึ่งขวาของหน้าในโหมดมาร์กข้อความบน tablet) และขอบวัดจาก rect ของ main (ใต้ header) แทนขอบจอที่โดน header บัง + hint เปลี่ยนเป็น "ลากชิดขอบจอเพื่อเลื่อนหน้า" - sync กติกา .claude/rules/pdf-viewer.md: เพิ่ม bullet scroll architecture (ห้ามกลับไปเลื่อนที่ window) + แก้คำอธิบาย pan/auto-scroll ที่อ้าง window เดิม verify: npx tsc --noEmit ผ่าน, vitest 139/139 ผ่าน — การวาด/สไตลัส/pressure และพิกัด % ไม่ถูกแตะ Co-Authored-By: Claude Code --- .claude/rules/pdf-viewer.md | 5 +- app/globals.css | 10 ++++ components/pdf/PDFViewer.tsx | 97 +++++++++++++++++++++--------------- hooks/useCustomSelection.ts | 48 ++++++++++-------- 4 files changed, 96 insertions(+), 64 deletions(-) diff --git a/.claude/rules/pdf-viewer.md b/.claude/rules/pdf-viewer.md index 7c33ca7..0daa23f 100644 --- a/.claude/rules/pdf-viewer.md +++ b/.claude/rules/pdf-viewer.md @@ -6,6 +6,7 @@ paths: # PDF Viewer Rules (รายละเอียดเต็ม: docs/PLAN.md §1, §6, §8) +- **Scroll architecture (2026-09-22):** root ของ PDFViewer เป็น app shell `flex h-dvh overflow-hidden` — **ตัวเลื่อนเดียวของหน้าคือ `
` ของเอกสาร** (`pdf-scroll-main min-h-0 flex-1 overflow-auto overscroll-contain` ทั้งแกนตั้ง+แนวนอน ซูมจนหน้าเกินจอแล้วเลื่อนดูได้, sidebar/header อยู่นอกกรอบ scroll จึงนิ่ง) — **ห้ามกลับไปเลื่อนที่ window/document** (`window.scrollY` เป็น 0 เสมอ): ทุก scroll API (bookmark/deep link/goToPage/goToAnnotation/pan/auto-scroll) ต้องเลื่อน `mainRef.current` และแปลงพิกัดด้วย `contentTopInScroll(main, clientTop)` (แทน `r.top + window.scrollY`); `.pdf-scroll-main` ตั้ง `touch-action: pan-x pan-y` กัน browser pinch-zoom ทั้งหน้าเว็บ (ต้องอยู่ก่อน `.pdf-custom-select` ใน globals.css เพื่อให้ touch-action:none ของโหมดมาร์กข้อความชนะ) — IO ของ virtualization ตั้ง `root: mainRef.current` - Layer stack ตายตัวตาม DOM order: `` → `div.highlight-layer` (`mix-blend-mode: multiply`) → `div.textLayer` → `` InkLayer → `div.comment-pins` — textLayer ต้องอยู่หลัง highlight-layer เสมอ (text select ได้และ highlight ซ้อนหลัง text) - `pointer-events`: canvas และ highlight-layer = `none` **เสมอ**; textLayer/InkLayer สลับตาม tool ตามตาราง §1 (`select` → textLayer, `draw` → InkLayer, `comment` → จับ event ที่ page wrapper) - Canvas Retina: `canvas.width = viewport.width × devicePixelRatio` + render `transform: [dpr,0,0,dpr,0,0]` แต่ `style.width` คง viewport px @@ -22,9 +23,9 @@ paths: - 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) - เมนู dropdown ของ tool ทั้ง 5 (highlight/draw/shape/stamp/textbox) + popup bookmark ก็ render ผ่าน **`createPortal` → `document.body` เป็น `position: fixed`** เหมือนกัน แต่ยึดพิกัดจริงของปุ่ม (`fixedMenuPos` — `getBoundingClientRect()` ตอนคลิก + clamp ให้อยู่ในจอ; popup bookmark คำนวณจาก ref ของปุ่ม 🔖 เพราะเปิดได้จากคีย์ลัด B/ปุ่มในแผง selection ด้วย) — **ห้าม**กลับไปเป็น `absolute` ใน scroll container ของแถบ tool และ**ห้ามสลับ `overflow` ของแถบตอนเปิดเมนู** (สลับแล้ว scroll position ของแถบรีเซ็ต เมนูเปิดนอกจอบนมือถือ และทั้ง app เอียงเลื่อนแนวนอนได้); เลื่อนแถบต่อหรือ **ความกว้างจอเปลี่ยน** (หมุนจอ/split view — เทียบ `menuOpenWRef` ตอนเปิด) = ปิดเมนูทั้งหมด แต่ resize ที่เปลี่ยนแค่ความสูง (URL bar ยุบ/คีย์บอร์ดของมือถือ) ปิดไม่ได้ เพราะเมนู fixed ยึด sticky header ยังอยู่กับที่ (ตำแหน่งคำนวณตอนเปิดเท่านั้น ไม่ตามปุ่มที่เลื่อนไป); outside-click ต้อง**ข้ามโหนดใน `[data-tool-menu]` ก่อน** — ref ของ wrapper มองไม่เห็นโหนดใน portal ไม่ข้ามแล้วคลิกในเมนู = ปิดเมนูเอง - DRAW วาดเส้นตรง: **ปล่อยเมาส์แล้ว snap อัตโนมัติ** เมื่อจุดทุกจุดห่างจากเส้นตรง จุดเริ่ม→จุดสุดท้าย ไม่เกิน max(5% ของความยาว, 3px) และเส้นยาว ≥ 24px (`isNearlyStraight`) — หรือกดค้าง **Shift** ระหว่างลาก (กด/ปล่อยกลางทางได้ — listener ระดับ window + reset ตอน blur) เพื่อบังคับตรงเสมอ → บันทึกเหลือจุดเริ่ม→จุดสุดท้าย 2 จุด (ความหนา uniform — ไม่เก็บ `widths` จาก pressure) — ใช้ได้เฉพาะโหมดวาดอิสระ ไม่ใช่ shape/eraser/redact; เส้นโค้ง/ขดจริงหลุดเกณฑ์จึงเหลือ freehand -- Tool `pan` (มือเลื่อนหน้า — ปุ่มแยกถัดจาก select ในแถบ tool): ลากบน `
` = `window.scrollBy` สวนทางการลาก + cursor grab/grabbing; ระหว่างใช้ pan ทุก overlay ปิด pointer-events ผ่านตัวแปร `overlayFrozen` (`inkActive || pan`) **ยกเว้น InkLayer ต้องไม่ active** (ไม่งั้น pan กลายเป็นวาดเส้น) +- Tool `pan` (มือเลื่อนหน้า — ปุ่มแยกถัดจาก select ในแถบ tool): ลากบน `
` = `mainRef.current.scrollBy` สวนทางการลาก (ทั้งแกน x/y) + cursor grab/grabbing; ระหว่างใช้ pan ทุก overlay ปิด pointer-events ผ่านตัวแปร `overlayFrozen` (`inkActive || pan`) **ยกเว้น InkLayer ต้องไม่ active** (ไม่งั้น pan กลายเป็นวาดเส้น) - HIGHLIGHT มี `style?` 4 แบบ: highlight ทึบเหมือนเดิม / underline = เส้นที่ขอบล่าง rect ต่อบรรทัด / strikethrough = เส้นกลางบรรทัด / squiggle = svg path ขยุกขยิก (`squigglePath`) ที่ขอบล่าง — export วาด line/sine แทน rect ทึบ; สร้างได้จาก **tool "highlight" ทางเดียว** (dropdown บนปุ่ม = รูปแบบ + สี → คลุมข้อความ = สร้างทันที พร้อม live preview ผ่าน `selectionchange` บน desktop / custom selection ของ `useCustomSelection` บนจอสัมผัส + ซ่อน native selection) — tool "เลือกข้อความ" คลุมแล้วเกิด popup ไม่ได้แล้ว (ถอดออก) และคีย์ลัด `H` สลับไป tool highlight -- **จอสัมผัส (iOS/Android) + tool highlight ใช้ custom selection ผ่าน `hooks/useCustomSelection.ts`** (ไม่พึ่ง native selection ของ OS อีก — OS ยึด gesture ปล่อยนิ้วไปแสดงเมนู/handles เอง ทำให้ commit-on-release ทำไม่ได้): เปิดเฉพาะอุปกรณ์ที่ `matchMedia("(pointer: coarse)")` จริง (`enabled = tool highlight + coarse` — **desktop ใช้ native mouse selection + commit ตอน mouseup ตามเดิม ไม่เปลี่ยน**; tool อื่นบนจอสัมผัสก็ native selection + long-press copy ของ OS ตามปกติ) — Pointer Events ชุดเดียวคุม mouse+touch บน `
`: `pointerdown` เก็บ anchor caret (จาก `caretRangeFromPoint` → fallback `caretPositionFromPoint` → ไม่มี = bail เงียบ ๆ; ต้องเป็น text node + เริ่มบน `.textLayer` เท่านั้น) ยังไม่ทำอะไร → เลย threshold **8px** จึง `setPointerCapture` + เริ่ม select จริง (**จับ pointer ตอนเริ่ม select ไม่ใช่ pointerdown** — capture ตั้งแต่กดจะ retarget click ไป container จน tap เลือก annotation/ปุ่ม pin ใช้ไม่ได้; tap ที่ไม่ขยับ = ไม่ capture click ไปถึงเป้าหมายตามเดิม และ click หลังลากจริง retarget ไป main = ไม่หลุดเข้า handlePageClick) — ระหว่างลาก: อ่าน focus caret ที่จุดนิ้ว**เลื่อนขึ้น 12px** (`FINGER_OFFSET_PX` เฉพาะ pointerType touch — นิ้วบังข้อความ), **word snap** ขอบคำทั้ง anchor/focus (`Intl.Segmenter` แยกคำไทยได้ → fallback regex `\S+`, กลางช่องว่าง = ไม่ขยาย), live preview ผ่าน **rAF throttle** (Range → `getClientRects` → `rectsInSweptPath` กรองแนวเส้นทางนิ้ว → `rectToPercent`+`mergeRectsByLine` — รูปร่างเดียวกับ liveMark ส่งเข้า PdfPage ทดแทนตอนมีค่า `customSelPreview ?? liveMark` — **จุดเส้นทางที่ใช้กรองต้องเป็นจุดเดียวกับที่หา caret (จอสัมผัส = จุด y−12)**: เก็บ `anchorPathPt` = จุด probe ที่ resolve anchor สำเร็จ และ `pathEnd` = จุด focus ที่หัก offset แล้ว ใช้ทั้งใน preview และ payload commit — ใช้พิกัดนิ้ว raw ตรง ๆ จะคลาด 12px จนบรรทัดเป้าหมายโดน lineTol ตัดทิ้ง (ลากขึ้น) หรือมาร์กเกิน (ลากลง) เมื่อฟอนต์เล็ก), **auto-scroll** rAF loop เมื่อนิ้วใกล้ขอบบน/ล่าง viewport 60px (เลื่อนแล้วคำนวณ preview ใหม่ที่จุดนิ้วเดิม) — **`pointerup` = commit ทันที**ผ่าน `onCommit(range,start,end)` → `handleCustomSelectionCommit` (ยืม `selDragStart/EndRef` ชั่วคราว + `readTextSelection(pi, { withText: true, range })` ชุดเดียวกับ desktop) → `commitHighlight` — **ไม่มีปุ่มยืนยันลอย/debounce/snapshot ใน ref อีกต่อไป** (selectionchange ของ tool highlight บน coarse ไม่ยิงเพราะไม่มี native selection); ระหว่าง enabled ให้ `
` ได้ class `.pdf-custom-select` (globals.css: `user-select`/`-webkit-user-select`/`-webkit-touch-callout: none` + `touch-action: none` กัน browser ยึด gesture เป็น scroll → pointercancel; restore `user-select:text` ให้ input/textarea/contenteditable ใต้ main) **ถอดทันที**เมื่อ tool เปลี่ยน/ปิด — ผลคือช่วง tool highlight บนจอสัมผัส **ลากที่ไหนก็ไม่เลื่อนหน้า** (gesture เป็นของ selection หมด เลื่อนหน้าต่อได้ด้วย auto-scroll ระหว่างลาก หรือสลับ tool) — เคลียร์ state ครบทุกทาง: pointerup/pointercancel (cancel = ทิ้ง selection ไม่ commit), enabled เปลี่ยน, unmount — ยกเลิก rAF ทั้ง preview/auto-scroll ไม่มี loop ค้าง — **กัน "ลากแล้วบางทีไม่ commit" ด้วยกลไก 3 จุด** (bug report 2026-09-15): (1) การหา caret เป็น 4 ชั้นเรียงตามต้นทุน (`resolveTextCaretDeep` — ใช้**เฉพาะตอน pointerdown** กับทั้งจุดชดเชยนิ้ว y−12 และจุดแตะจริง): ① `caretRangeFromPoint`/`caretPositionFromPoint` → startContainer เป็น Element (จุดตก gap/padding ระหว่าง span) ให้ดำดิ่งหา text node ผ่าน `childNodes[startOffset]` + `findFirstTextNode` DFS → ② retry ขยับพิกัด 8 ทิศ (±2/±3px) → ③ `elementsFromPoint` เจาะผ่าน overlay ที่ไร้ text (SVG ของ InkLayer/มาร์ก/กรอบ) จับ span ข้อความใต้จุด → ④ span ของหน้านั้นที่**ใกล้สุดแนวตั้ง ≤40px** (`NEAREST_SPAN_MAX_PX` — เกินถือว่าจุดนั้นไม่มีข้อความจริง) — หมดทั้ง 4 ชั้นค่อย bail เป็น tap ธรรมดา; **ระหว่างลาก (ทุก rAF frame) ใช้เฉพาะชั้น ①② ที่ถูก** — หาไม่เจอ (ลากผ่านพื้นที่ว่าง/ช่องว่างระหว่างหน้า) ให้**คง focus เดิมจาก frame ก่อน** (`dragRef.lastFocus`) แทน re-scan ด้วยชั้นแพง — selection หยุดรอที่ข้อความสุดท้ายแล้วขยายต่อเมื่อนิ้วถึงข้อความอีก (cache รายชื่อ span ต่อ 1 gesture สำหรับชั้น ④) (2) `lastRef` (payload commit ล่าสุด) **เคลียร์เฉพาะตอน pointerdown, finish และเมื่อหน้า anchor ถูก virtualization unmount กลางลาก (`anchorPageEl.isConnected === false` — rects ของ detached nodes ว่างหมด ต้องไม่ commit ของเก่าเงียบ ๆ) เท่านั้น** — frame ที่ `rectsInSweptPath` กรองว่าง (ลากเร็ว sample ห่าง/นิ้วสั่นตอนปล่อย) ล้างแค่ preview เพื่อให้ pointerup ยัง commit selection ล่าสุดที่ไม่ว่างได้ (3) ลากล้ำเข้าหน้าถัดไป (range คร่อม 2 หน้า → หา `[data-page]` ไม่ได้) **ห้าม bail** — `clampToAnchorPage`: clamp ฝั่ง focus ไปท้ายหน้า anchor (ลากลง) หรือต้นหน้า anchor (ลากขึ้น) ด้วย text node แรก/สุดท้ายของ textLayer หน้า anchor (`anchorPageEl` เก็บตั้งแต่ pointerdown, boundary cache ต่อ gesture ใน `clampBounds` — ห้ามเดิน TreeWalker ใหม่ทุก rAF frame) — selection จบที่ขอบหน้า anchor แทนการหายทั้งเส้น +- **จอสัมผัส (iOS/Android) + tool highlight ใช้ custom selection ผ่าน `hooks/useCustomSelection.ts`** (ไม่พึ่ง native selection ของ OS อีก — OS ยึด gesture ปล่อยนิ้วไปแสดงเมนู/handles เอง ทำให้ commit-on-release ทำไม่ได้): เปิดเฉพาะอุปกรณ์ที่ `matchMedia("(pointer: coarse)")` จริง (`enabled = tool highlight + coarse` — **desktop ใช้ native mouse selection + commit ตอน mouseup ตามเดิม ไม่เปลี่ยน**; tool อื่นบนจอสัมผัสก็ native selection + long-press copy ของ OS ตามปกติ) — Pointer Events ชุดเดียวคุม mouse+touch บน `
`: `pointerdown` เก็บ anchor caret (จาก `caretRangeFromPoint` → fallback `caretPositionFromPoint` → ไม่มี = bail เงียบ ๆ; ต้องเป็น text node + เริ่มบน `.textLayer` เท่านั้น) ยังไม่ทำอะไร → เลย threshold **8px** จึง `setPointerCapture` + เริ่ม select จริง (**จับ pointer ตอนเริ่ม select ไม่ใช่ pointerdown** — capture ตั้งแต่กดจะ retarget click ไป container จน tap เลือก annotation/ปุ่ม pin ใช้ไม่ได้; tap ที่ไม่ขยับ = ไม่ capture click ไปถึงเป้าหมายตามเดิม และ click หลังลากจริง retarget ไป main = ไม่หลุดเข้า handlePageClick) — ระหว่างลาก: อ่าน focus caret ที่จุดนิ้ว**เลื่อนขึ้น 12px** (`FINGER_OFFSET_PX` เฉพาะ pointerType touch — นิ้วบังข้อความ), **word snap** ขอบคำทั้ง anchor/focus (`Intl.Segmenter` แยกคำไทยได้ → fallback regex `\S+`, กลางช่องว่าง = ไม่ขยาย), live preview ผ่าน **rAF throttle** (Range → `getClientRects` → `rectsInSweptPath` กรองแนวเส้นทางนิ้ว → `rectToPercent`+`mergeRectsByLine` — รูปร่างเดียวกับ liveMark ส่งเข้า PdfPage ทดแทนตอนมีค่า `customSelPreview ?? liveMark` — **จุดเส้นทางที่ใช้กรองต้องเป็นจุดเดียวกับที่หา caret (จอสัมผัส = จุด y−12)**: เก็บ `anchorPathPt` = จุด probe ที่ resolve anchor สำเร็จ และ `pathEnd` = จุด focus ที่หัก offset แล้ว ใช้ทั้งใน preview และ payload commit — ใช้พิกัดนิ้ว raw ตรง ๆ จะคลาด 12px จนบรรทัดเป้าหมายโดน lineTol ตัดทิ้ง (ลากขึ้น) หรือมาร์กเกิน (ลากลง) เมื่อฟอนต์เล็ก), **auto-scroll** rAF loop เมื่อนิ้วใกล้ขอบทั้ง 4 ด้านของพื้นที่เอกสาร (rect ของ `
` ใต้ header ไม่ใช่ขอบจอ) 60px — เลื่อน scroll container ของ `
` ทั้งแกนตั้ง/แนวนอน แล้วคำนวณ preview ใหม่ที่จุดนิ้วเดิม — **`pointerup` = commit ทันที**ผ่าน `onCommit(range,start,end)` → `handleCustomSelectionCommit` (ยืม `selDragStart/EndRef` ชั่วคราว + `readTextSelection(pi, { withText: true, range })` ชุดเดียวกับ desktop) → `commitHighlight` — **ไม่มีปุ่มยืนยันลอย/debounce/snapshot ใน ref อีกต่อไป** (selectionchange ของ tool highlight บน coarse ไม่ยิงเพราะไม่มี native selection); ระหว่าง enabled ให้ `
` ได้ class `.pdf-custom-select` (globals.css: `user-select`/`-webkit-user-select`/`-webkit-touch-callout: none` + `touch-action: none` กัน browser ยึด gesture เป็น scroll → pointercancel; restore `user-select:text` ให้ input/textarea/contenteditable ใต้ main) **ถอดทันที**เมื่อ tool เปลี่ยน/ปิด — ผลคือช่วง tool highlight บนจอสัมผัส **ลากที่ไหนก็ไม่เลื่อนหน้า** (gesture เป็นของ selection หมด เลื่อนหน้าต่อได้ด้วย auto-scroll ระหว่างลาก หรือสลับ tool) — เคลียร์ state ครบทุกทาง: pointerup/pointercancel (cancel = ทิ้ง selection ไม่ commit), enabled เปลี่ยน, unmount — ยกเลิก rAF ทั้ง preview/auto-scroll ไม่มี loop ค้าง — **กัน "ลากแล้วบางทีไม่ commit" ด้วยกลไก 3 จุด** (bug report 2026-09-15): (1) การหา caret เป็น 4 ชั้นเรียงตามต้นทุน (`resolveTextCaretDeep` — ใช้**เฉพาะตอน pointerdown** กับทั้งจุดชดเชยนิ้ว y−12 และจุดแตะจริง): ① `caretRangeFromPoint`/`caretPositionFromPoint` → startContainer เป็น Element (จุดตก gap/padding ระหว่าง span) ให้ดำดิ่งหา text node ผ่าน `childNodes[startOffset]` + `findFirstTextNode` DFS → ② retry ขยับพิกัด 8 ทิศ (±2/±3px) → ③ `elementsFromPoint` เจาะผ่าน overlay ที่ไร้ text (SVG ของ InkLayer/มาร์ก/กรอบ) จับ span ข้อความใต้จุด → ④ span ของหน้านั้นที่**ใกล้สุดแนวตั้ง ≤40px** (`NEAREST_SPAN_MAX_PX` — เกินถือว่าจุดนั้นไม่มีข้อความจริง) — หมดทั้ง 4 ชั้นค่อย bail เป็น tap ธรรมดา; **ระหว่างลาก (ทุก rAF frame) ใช้เฉพาะชั้น ①② ที่ถูก** — หาไม่เจอ (ลากผ่านพื้นที่ว่าง/ช่องว่างระหว่างหน้า) ให้**คง focus เดิมจาก frame ก่อน** (`dragRef.lastFocus`) แทน re-scan ด้วยชั้นแพง — selection หยุดรอที่ข้อความสุดท้ายแล้วขยายต่อเมื่อนิ้วถึงข้อความอีก (cache รายชื่อ span ต่อ 1 gesture สำหรับชั้น ④) (2) `lastRef` (payload commit ล่าสุด) **เคลียร์เฉพาะตอน pointerdown, finish และเมื่อหน้า anchor ถูก virtualization unmount กลางลาก (`anchorPageEl.isConnected === false` — rects ของ detached nodes ว่างหมด ต้องไม่ commit ของเก่าเงียบ ๆ) เท่านั้น** — frame ที่ `rectsInSweptPath` กรองว่าง (ลากเร็ว sample ห่าง/นิ้วสั่นตอนปล่อย) ล้างแค่ preview เพื่อให้ pointerup ยัง commit selection ล่าสุดที่ไม่ว่างได้ (3) ลากล้ำเข้าหน้าถัดไป (range คร่อม 2 หน้า → หา `[data-page]` ไม่ได้) **ห้าม bail** — `clampToAnchorPage`: clamp ฝั่ง focus ไปท้ายหน้า anchor (ลากลง) หรือต้นหน้า anchor (ลากขึ้น) ด้วย text node แรก/สุดท้ายของ textLayer หน้า anchor (`anchorPageEl` เก็บตั้งแต่ pointerdown, boundary cache ต่อ gesture ใน `clampBounds` — ห้ามเดิน TreeWalker ใหม่ทุก rAF frame) — selection จบที่ขอบหน้า anchor แทนการหายทั้งเส้น - **tap/คลิกที่ใช้ "ปิดเมนู" ต้องไม่สร้างรายการ**: outside-click ปิดเมนูที่ `mousedown` ก่อน `click` ยิงถึง page wrapper ทำให้ tool comment/textbox/stamp วางรายการจาก tap ปิดเมนูโดยไม่ตั้งใจ — `handlePageClick` เช็ค `menuClosedAtRef` (เขียนทุก path ปิดเมนูผ่าน `markMenuClosed`: outside-click/Escape/switchTool/เลื่อนแถบ/resize; ยกเว้นคีย์ B ปิดเมนูเพื่อเปิด popup bookmark — คีย์บอร์ดไม่มี click หลงเหลือให้กลืน) และเฉย 300ms แรก คลิกที่สองค่อยวาง (มาตรฐาน dropdown ทั่วไป ใช้ทั้ง desktop และ touch) - **popup บนหน้าเปิดได้ทีละชุด ห้ามค้างเปิดซ้อนกัน** (bug "กดแก้ไขแล้วกดปุ่ม tool popup ไม่ปิด/ซ้อนกัน"): ปุ่ม dropdown ทั้ง 5 **เรียก `switchTool` เสมอแม้เป็น tool เดิม** (จำ intent เปิด/พับเมนูไว้ก่อนเรียก เพราะ `switchTool` ปิดเมนูทุกตัว) เนื่องจาก `switchTool` เป็นที่เดียวที่เคลียร์ popup บนหน้าทั้งหมด — composer/pin popover/แผง selection/editing comment·textbox + popup bookmark (`setBookmarkComposer(null)`); **สร้างรายการแล้ว auto-select (`AUTO_SELECT_TYPES`) ก็ปิดเมนู+popup bookmark เช่นกัน** — บน iOS การลาก (คลุมข้อความ/วาดเส้น) ไม่ยิง compat mousedown ให้ outside-click ปิดเมนูเอง (Android/desktop ปิดตั้งแต่เริ่มกดแล้ว) ไม่ปิดตรงนี้ = เมนูค้างเปิดซ้อนแผงที่โผล่หลังสร้าง; คลิกหน้าว่าง (`handlePageClick`) ปิด popover ของ pin + popup bookmark; เลือกรายการอื่น (`handleSelectAnnotation`/`goToAnnotation`) ปิด popover เดิม; popover ของ COMMENT เปิดอยู่ (`openPinId === selectedId`) = **ไม่ render กรอบ selection/แผงเครื่องมือของตัวนั้น** — bbox ของ COMMENT ครอบพื้นที่ pin พอดี แผงจะทับ popover และแถบ handle บังการคลิก (sidebar ยังตาม `selectedId` ไฮไลต์เหมือนเดิม) - **คลุมข้อความได้ 5 tool** (`TEXT_SELECTION_TOOLS` set ใน PDFViewer: select/highlight/comment/textbox/stamp — ที่เหลือ textLayer ปิด pointer-events คลุมไม่ได้): ทั้งหมด inject `
+ // 2026-09-22: app shell สูงจอเดียว (dvh กันปัญหา URL bar ของจอสัมผัส) — + // ตัวเลื่อนเดียวของหน้าคือ
ของเอกสาร sidebar/header อยู่กับที่ + // ไม่หนีไปกับ scroll/zoom ตามคำขอผู้ใช้ (เดิมเลื่อนที่ window ทั้งหน้า) +
{/* tool คลุมข้อความได้ (TEXT_SELECTION_TOOLS) — ซ่อนสี selection ของเบราว์เซอร์ ให้เห็นแต่ preview มาร์ก/selection สีฟ้าของเรา (กรองแนวเส้นทางลากแล้ว สะอาดกว่า native) */} @@ -2876,12 +2892,13 @@ export default function PDFViewer({ onClick={() => setSidebarOpen(false)} /> )} - {/* lg+ = sticky แยกคอลัมน์ (เปิดแล้ว content ขยับไปข้าง ๆ ไม่บังกัน) + {/* lg+ = แยกคอลัมน์เต็มความสูง shell (เปิดแล้ว content ขยับไปข้าง ๆ ไม่บังกัน) / < lg = overlay เลื่อนเข้า-ออก กว้างเป็นสัดส่วนของจอกว้างกว่า 288px - (ปิดบน desktop ใช้ lg:hidden ออกจาก flow → main เต็มจอ) */} + (ปิดบน desktop ใช้ lg:hidden ออกจาก flow → main เต็มจอ; top-0 จำเป็น + ตอน max-lg:fixed ยึดขอบบนจอ) */} -
+
{/* แยกเป็นสองแถว: บน = ชื่อไฟล์ + ตัวเลื่อนหน้า/ซูม (กลาง) + หน้าสรุป, ล่าง = แถบ tool + undo/redo + bookmark/export (ชิดขวา) */}
@@ -3709,7 +3726,7 @@ export default function PDFViewer({
{ const p = panRef.current; if (!p) return; - window.scrollBy(p.x - e.clientX, p.y - e.clientY); + mainRef.current?.scrollBy(p.x - e.clientX, p.y - e.clientY); panRef.current = { x: e.clientX, y: e.clientY }; }} onPointerUp={() => { @@ -3764,7 +3781,7 @@ export default function PDFViewer({ = แอปค้าง" (แสดง 3 ครั้งแรกต่อ session) */} {tool === "highlight" && coarseTouch && coarseHlHintVisible && (

- โหมดมาร์กข้อความ: ลากบนหน้าเพื่อคลุมข้อความ ลากชิดขอบบน/ล่างจอเพื่อเลื่อนหน้า + โหมดมาร์กข้อความ: ลากบนหน้าเพื่อคลุมข้อความ ลากชิดขอบจอเพื่อเลื่อนหน้า หรือสลับเป็นเครื่องมืออื่นเพื่อปัดเลื่อนตามปกติ

)} diff --git a/hooks/useCustomSelection.ts b/hooks/useCustomSelection.ts index 2f82e98..9c5ee07 100644 --- a/hooks/useCustomSelection.ts +++ b/hooks/useCustomSelection.ts @@ -25,7 +25,7 @@ const MOVE_THRESHOLD_PX = 8; /** อ่าน caret จากจุด y ตรงนิ้วเลื่อนขึ้น 12px — นิ้วบังข้อความที่กำลังเลือก * (เฉพาะ pointerType touch; เมาส์/สไตลัสชี้แม่นอยู่แล้ว) */ const FINGER_OFFSET_PX = 12; -/** นิ้วอยู่ใกล้ขอบบน/ล่างของ viewport แค่นี้ = auto-scroll ต่อ (rAF loop) */ +/** นิ้วอยู่ใกล้ขอบของพื้นที่เอกสาร (ทั้ง 4 ด้าน) แค่นี้ = auto-scroll ต่อ (rAF loop) */ const AUTOSCROLL_MARGIN_PX = 60; const AUTOSCROLL_MAX_SPEED_PX = 12; @@ -503,35 +503,39 @@ export function useCustomSelection({ }); }; - /** auto-scroll ระหว่างลากค้างใกล้ขอบจอ — rAF loop เลื่อน window แล้วคำนวณ preview - * ใหม่ (จุดนิ้วเดิมชี้ข้อความใหม่หลัง scroll) จบเองเมื่อเลิกลาก */ + /** auto-scroll ระหว่างลากค้างใกล้ขอบจอ — rAF loop เลื่อน scroll container ของ + * เอกสาร (el =
ตัวเลื่อนเดียวของ viewer, ทั้งแกนตั้งและแกนนอนกรณี + * ซูมจนหน้ากว้างเกินจอ) แล้วคำนวณ preview ใหม่ (จุดนิ้วเดิมชี้ข้อความใหม่หลัง + * scroll) จบเองเมื่อเลิกลาก — ขอบวัดจากกรอบของ el เอง (ใต้ header) ไม่ใช่ + * ขอบจอ เพราะส่วนบนถูก header บังอยู่ */ const startAutoScroll = () => { if (scrollRafRef.current !== null) return; const step = () => { scrollRafRef.current = null; const d = dragRef.current; if (!d?.active) return; - const vh = window.innerHeight; + const br = el.getBoundingClientRect(); + const speed = (into: number) => + Math.max( + 1, + Math.ceil( + (into / AUTOSCROLL_MARGIN_PX) * AUTOSCROLL_MAX_SPEED_PX, + ), + ); + let dx = 0; let dy = 0; - if (d.curPt.y < AUTOSCROLL_MARGIN_PX) { - dy = -Math.max( - 1, - Math.ceil( - ((AUTOSCROLL_MARGIN_PX - d.curPt.y) / AUTOSCROLL_MARGIN_PX) * - AUTOSCROLL_MAX_SPEED_PX, - ), - ); - } else if (d.curPt.y > vh - AUTOSCROLL_MARGIN_PX) { - dy = Math.max( - 1, - Math.ceil( - ((d.curPt.y - (vh - AUTOSCROLL_MARGIN_PX)) / AUTOSCROLL_MARGIN_PX) * - AUTOSCROLL_MAX_SPEED_PX, - ), - ); + if (d.curPt.x < br.left + AUTOSCROLL_MARGIN_PX) { + dx = -speed(br.left + AUTOSCROLL_MARGIN_PX - d.curPt.x); + } else if (d.curPt.x > br.right - AUTOSCROLL_MARGIN_PX) { + dx = speed(d.curPt.x - (br.right - AUTOSCROLL_MARGIN_PX)); } - if (dy !== 0) { - window.scrollBy(0, dy); + if (d.curPt.y < br.top + AUTOSCROLL_MARGIN_PX) { + dy = -speed(br.top + AUTOSCROLL_MARGIN_PX - d.curPt.y); + } else if (d.curPt.y > br.bottom - AUTOSCROLL_MARGIN_PX) { + dy = speed(d.curPt.y - (br.bottom - AUTOSCROLL_MARGIN_PX)); + } + if (dx !== 0 || dy !== 0) { + el.scrollBy(dx, dy); schedulePreview(); } scrollRafRef.current = requestAnimationFrame(step); From 357f3e972fbbf814f19efbea1e0f1ec7efc3859a Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 22 Sep 2026 11:48:53 +0700 Subject: [PATCH 02/41] =?UTF-8?q?toolbar:=20=E0=B9=81=E0=B8=81=E0=B9=89=20?= =?UTF-8?q?quick=20wins=20=E0=B8=95=E0=B8=B2=E0=B8=A1=20usability=20audit?= =?UTF-8?q?=202026-09-22=20=E2=80=94=20=E0=B8=9B=E0=B8=B4=E0=B8=94?= =?UTF-8?q?=E0=B9=80=E0=B8=A1=E0=B8=99=E0=B8=B9=E0=B8=AB=E0=B8=A5=E0=B8=B1?= =?UTF-8?q?=E0=B8=87=E0=B9=80=E0=B8=A5=E0=B8=B7=E0=B8=AD=E0=B8=81=E0=B9=81?= =?UTF-8?q?=E0=B8=96=E0=B8=A7=E0=B9=80=E0=B8=94=E0=B8=B5=E0=B9=88=E0=B8=A2?= =?UTF-8?q?=E0=B8=A7=20+=20=E0=B9=81=E0=B8=9A=E0=B8=99=E0=B9=80=E0=B8=99?= =?UTF-8?q?=E0=B8=AD=E0=B8=A3=E0=B9=8C=20hint=20=E0=B8=A2=E0=B8=B2?= =?UTF-8?q?=E0=B8=87=E0=B8=A5=E0=B8=9A/tool=20=E0=B8=A7=E0=B8=B2=E0=B8=87?= =?UTF-8?q?=E0=B8=82=E0=B8=AD=E0=B8=87=20+=20=E0=B8=9B=E0=B8=B8=E0=B9=88?= =?UTF-8?q?=E0=B8=A1=20%=20=E0=B8=8B=E0=B8=B9=E0=B8=A1=E0=B8=A3=E0=B8=B5?= =?UTF-8?q?=E0=B9=80=E0=B8=8B=E0=B9=87=E0=B8=95=20fit-width=20+=20?= =?UTF-8?q?=E0=B8=8A=E0=B8=B7=E0=B9=88=E0=B8=AD=20tool=20"=E0=B9=80?= =?UTF-8?q?=E0=B8=A5=E0=B8=B7=E0=B8=AD=E0=B8=81=E0=B8=82=E0=B9=89=E0=B8=AD?= =?UTF-8?q?=E0=B8=84=E0=B8=A7=E0=B8=B2=E0=B8=A1"=20=E0=B9=80=E0=B8=9B?= =?UTF-8?q?=E0=B9=87=E0=B8=99=20"=E0=B9=80=E0=B8=A5=E0=B8=B7=E0=B8=AD?= =?UTF-8?q?=E0=B8=81"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - เมนู tool 4 ตัว (รูปแบบมาร์ก/ชนิดปากกา/ชนิดรูปทรง/preset ตรายาง): เลือกแถวเดี่ยวแล้ว ปิดเมนูทันทีด้วย setState ตรง ๆ โดยไม่ผ่าน markMenuClosed (guard กลืน tap 300ms จะทำให้แตะหน้าครั้งถัดไปเงียบ — เดิมวางตรายาง 1 ชิ้นต้องแตะ 4 ครั้ง เหลือ 3 ครั้ง ที่ตอบสนองทุกครั้ง) ส่วนแถวปรับค่า สี/ความหนา/ความทึบ/ขนาด ยังคงเมนูเปิด ให้ปรับต่อหลายค่าติดกันตามเจตนาเดิม - แบนเนอร์ใต้ header (reuse pattern M3/M6/H2): tool คอมเมนต์/กล่องข้อความ/ตรายาง แสดง "โหมด…: คลิก (แตะ) ตำแหน่งบนหน้าเพื่อวาง" ตลอดตอน active (ทำหน้าที่ mode indicator แก้อาการเลือก tool แล้วจอนิ่ง — เดิม hint อยู่แค่ empty state ของ sidebar ที่หายทันทีที่มีรายการ) + ยางลบบอกขอบเขตว่าลบได้เฉพาะเส้น/รูปทรง/ตรายางของตัวเอง (ไม่แตะตรรกะ eraseAt ตามสเปก F6 เดิม) + label ปุ่มยางลบเปลี่ยนตาม - cursor เป็น crosshair ตอนใช้ tool วางของ 3 ตัวบน desktop (จอสัมผัสไม่มี cursor) - ตัวเลข % ซูมกลายเป็นปุ่มรีเซ็ตกลับ fit-width — extract สูตรจาก effect ตอนโหลด เป็น fitWidthScale() ใช้ร่วมกัน คิดจากความกว้าง main ปัจจุบัน (sidebar/จออาจเปลี่ยน) - ชื่อ tool select "เลือกข้อความ" → "เลือก" (ไม่ชน "มาร์กข้อความ" — title/aria-label/ ตัวเลื่อนปุ่ม active อ้างจาก TOOLS อัตโนมัติ) + sync comment ที่อ้างชื่อเก่า 4 จุด - contrast: text-slate-400 → slate-500 ที่ข้อความข้อมูล 4 จุด (เวลา reply, เวลา comment popup, "วางที่" ของ popup bookmark, "กำลังโหลดเอกสาร…") - ปุ่มสี ColorSwatches ใช้ชื่อสีไทยใน aria-label/title ผ่าน colorName() ที่ export เพิ่มจาก utils/annotation-display.ts (แทน hex "#ef4444" ที่ screen reader อ่านไม่รู้เรื่อง) ยังคงเดิมตามคำสั่งผู้ใช้: ปุ่ม Bookmark/Export PDF คงป้ายอังกฤษ, แท็บ sidebar, InkLayer/AnnotationSelectionLayer verify: tsc ผ่าน + vitest 139/139 + Playwright 24 checks ผ่าน (flow ตรายาง 4 แตะ→3 แตะ, แถวปรับค่าเมนูยังเปิด, แบนเนอร์/cursor ครบทุก tool, ซูม 100%→150%→รีเซ็ตกลับ 100%, aria-label "เลือกสี แดง") Co-Authored-By: Claude Code --- components/pdf/PDFViewer.tsx | 106 +++++++++++++++++++++++++------- components/pdf/ToolControls.tsx | 6 +- utils/annotation-display.ts | 3 +- 3 files changed, 90 insertions(+), 25 deletions(-) diff --git a/components/pdf/PDFViewer.tsx b/components/pdf/PDFViewer.tsx index 96839c7..16a9581 100644 --- a/components/pdf/PDFViewer.tsx +++ b/components/pdf/PDFViewer.tsx @@ -364,18 +364,28 @@ function selectionTextWithin(kept: DOMRect[], range: Range): string { * ผู้ใช้ศาลไม่คุ้นไอคอนล้วน โดยเฉพาะจอสัมผัสที่ hover อ่าน title ไม่ได้ — แทน span * ชื่อ tool-active ของ H3 ที่ถอดออก) */ const TOOLS: { key: Tool; label: string; text: string; Icon: LucideIcon }[] = [ - { key: "select", label: "เลือกข้อความ", text: "เลือกข้อความ", Icon: MousePointer2 }, + { key: "select", label: "เลือก", text: "เลือก", Icon: MousePointer2 }, { key: "pan", label: "เลื่อนหน้า (pan)", text: "เลื่อนหน้า", Icon: Hand }, { key: "highlight", label: "มาร์กข้อความ (H)", text: "มาร์กข้อความ", Icon: Highlighter }, { key: "draw", label: "วาดเส้น (D)", text: "วาดเส้น", Icon: Pencil }, { key: "comment", label: "วางคอมเมนต์ (C)", text: "วางคอมเมนต์", Icon: MessageSquarePlus }, { key: "shape", label: "รูปทรง", text: "รูปทรง", Icon: Shapes }, - { key: "erase", label: "ยางลบ (ลบด้วยการลาก)", text: "ยางลบ", Icon: Eraser }, + { key: "erase", label: "ยางลบ (เส้น/รูปทรง/ตรายางของตัวเอง)", text: "ยางลบ", Icon: Eraser }, { key: "redact", label: "ปิดพราง (redaction)", text: "ปิดพราง", Icon: EyeOff }, { key: "textbox", label: "กล่องข้อความ", text: "กล่องข้อความ", Icon: Type }, { key: "stamp", label: "ตรายาง (stamp)", text: "ตรายาง", Icon: Stamp }, ]; +/** แบนเนอร์แนะนำวิธีใช้ tool ที่วางของด้วยคลิกบนหน้า (UX audit 2026-09-22): เดิมเลือก tool + * แล้วจอนิ่ง ไม่มีอะไรบอกขั้นต่อไป (hint เดิมอยู่แค่ empty state ของ sidebar ที่หายทันที + * ที่มีรายการ) — แสดงตลอดตอน tool active ให้ทำหน้าที่ mode indicator ไปพร้อมกัน + * (เงื่อนไข tool เดี่ยว ๆ จึงไม่มีวันซ้อนกับแบนเนอร์ M3/M6/H2 ข้างบน) */ +const PLACE_TOOL_HINTS: Partial> = { + comment: "โหมดวางคอมเมนต์: คลิก (แตะ) ตำแหน่งบนหน้าเพื่อวางกล่องคอมเมนต์", + textbox: "โหมดกล่องข้อความ: คลิก (แตะ) ตำแหน่งบนหน้าเพื่อวางกล่อง แล้วพิมพ์ข้อความ", + stamp: "โหมดตรายาง: คลิก (แตะ) ตำแหน่งบนหน้าเพื่อประทับตรายาง", +}; + /** UX review 2026-09-14 M4: focus ring ตอน keyboard focus — ทั้งโปรเจกต์ไม่เคยมี focus-visible * ปุ่ม toolbar จึงกลายเป็นกด Tab แล้วไม่รู้ focus อยู่ปุ่มไหน * ring เต็มสี + ring-offset คั่นขาว 1px ให้เห็นชัดแม้บนปุ่ม active ที่พื้น primary เอง @@ -383,6 +393,16 @@ const TOOLS: { key: Tool; label: string; text: string; Icon: LucideIcon }[] = [ const FOCUS_RING_CLS = "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1 focus-visible:z-10"; +/** สเกลที่ทำให้หน้ากระดาษพอดีความกว้างของ main (ตัด padding px-4 ซ้าย-ขวา = 32px — สูตร + * เดิมของ effect fit-width ตอนโหลด ย้ายมาเป็น helper ใช้ร่วมกับปุ่ม % ที่ header ด้วย + * (UX audit 2026-09-22: ผู้ใช้ซูมดูรายละเอียดแล้วไม่มีทางกลับมาดูทั้งหน้า) — คิดจากความ + * กว้าง main ปัจจุบันทุกครั้งเพราะ sidebar/จออาจเปลี่ยนไปแล้ว; ยังไม่รู้ขนาดหน้า = null */ +function fitWidthScale(mainW: number | undefined, pageW: number | undefined): number | null { + const avail = (mainW ?? 0) - 32; + if (!avail || !pageW) return null; + return pageW > avail ? clamp(Math.floor((avail / pageW) * 100) / 100, 0.5, 1) : 1; +} + /** ชนิดที่สร้างเสร็จแล้วเลือกรายการใหม่ให้อัตโนมัติ (กรอบ selection + sidebar ไฮไลต์) * ทุกชนิดยกเว้น COMMENT — เปิด composer พิมพ์ก่อนบันทึก และคลิก pin = เลือกอยู่แล้ว */ const AUTO_SELECT_TYPES = new Set([ @@ -506,7 +526,7 @@ function ReplyBox({

{r.userName} - + {formatThaiDate(r.createdAt, true)}

@@ -628,7 +648,7 @@ function PdfPage({ /** live preview มาร์กระหว่างลากคลุมด้วย tool highlight (null = ไม่มี) */ liveMark: { pageIndex: number; rects: Rect[] } | null; liveMarkStyle: { color: string; style: HighlightStyle; opacity?: number }; - /** selection สีฟ้าสะอาด ๆ ของ tool เลือกข้อความ — กรองแนวเส้นทางลากเหมือน liveMark + /** selection สีฟ้าสะอาด ๆ ของ tool เลือก — กรองแนวเส้นทางลากเหมือน liveMark * แต่ไม่สร้างไฮไลต์ (ซ่อนสี selection ของเบราว์เซอร์ วาดเองแทน) */ selectPreview: { pageIndex: number; rects: Rect[] } | null; /** ความกว้างหน้าแรกที่ scale 1 — แปลง px↔% ของ fontSize ในแผง selection */ @@ -892,7 +912,7 @@ function PdfPage({ liveMarkStyle.opacity, ), )} - {/* selection สีฟ้าของ tool เลือกข้อความ — กรองแนวเส้นทางลากแล้ว วาดเองแทน + {/* selection สีฟ้าของ tool เลือก — กรองแนวเส้นทางลากแล้ว วาดเองแทน สี selection ของเบราว์เซอร์ (ไม่สร้างไฮไลต์) */} {selectPreview && selectPreview.pageIndex === pageNumber - 1 && @@ -1084,7 +1104,7 @@ function PdfPage({ {a.data.message}

)} -

+

{formatThaiDate(a.createdAt, true)}

{/* F10/A5: toggle จัดการแล้ว — ใครก็ได้ (D6) */} @@ -1211,7 +1231,7 @@ export default function PDFViewer({ const [currentPage, setCurrentPage] = useState(0); // tool highlight — live preview มาร์กตามรูปแบบ/สีที่ตั้งไว้ ระหว่างลากคลุมข้อความ const [liveMark, setLiveMark] = useState<{ pageIndex: number; rects: Rect[] } | null>(null); - // tool เลือกข้อความ — selection สีฟ้าสะอาด ๆ (กรองแนวเส้นทางลากแล้ว ไม่สร้างไฮไลต์) + // tool เลือก — selection สีฟ้าสะอาด ๆ (กรองแนวเส้นทางลากแล้ว ไม่สร้างไฮไลต์) const [selectPreview, setSelectPreview] = useState<{ pageIndex: number; rects: Rect[] } | null>(null); const [composer, setComposer] = useState(null); const [draft, setDraft] = useState(""); @@ -1689,11 +1709,8 @@ export default function PDFViewer({ useEffect(() => { if (pageSizes.length === 0 || fitWidthDoneRef.current) return; fitWidthDoneRef.current = true; - const avail = (mainRef.current?.clientWidth ?? 0) - 32; - const pageW = pageSizes[0].width; - if (avail > 0 && pageW > avail) { - setScale(clamp(Math.floor((avail / pageW) * 100) / 100, 0.5, 1)); - } + const s = fitWidthScale(mainRef.current?.clientWidth, pageSizes[0].width); + if (s !== null && s !== 1) setScale(s); }, [pageSizes]); useEffect(() => { @@ -2641,7 +2658,7 @@ export default function PDFViewer({ pageEl: HTMLElement, ) { // ปล่อยเมาส์จากการลากคลุมข้อความ (comment/textbox/stamp ก็คลุมได้) ไม่ถือเป็นคลิก — - // selection ยังค้างตอน click ยิง ให้ผ่านไปเฉย ๆ เหมือน tool เลือกข้อความ ไม่เปิด + // selection ยังค้างตอน click ยิง ให้ผ่านไปเฉย ๆ เหมือน tool เลือก ไม่เปิด // composer/วางตรายาง (คลิกล้วน ๆ selection ถูกยุบตั้งแต่ mousedown จึงไม่โดน guard นี้) const selection = window.getSelection(); if (selection && !selection.isCollapsed) return; @@ -2998,9 +3015,20 @@ export default function PDFViewer({ > - + {/* UX audit 2026-09-22: ตัวเลข % เป็นปุ่มกลับไป fit-width — PDF viewer ที่ + ผู้ใช้เคยใช้ทุกตัวมี affordance รีเซ็ตซูม คิดจากความกว้าง main ปัจจุบัน */} +
+
+ + setFileId(e.target.value)} + className={`${inputClass} mt-1 w-full`} + /> +
+
+ + setTypeId(e.target.value)} + placeholder="เช่น 1" + className={`${inputClass} mt-1 w-full`} + /> +
+
+ +

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

+

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

+ {error &&

{error}

} + + ); +} diff --git a/components/LoginForm.tsx b/components/LoginForm.tsx index 08e4365..199b595 100644 --- a/components/LoginForm.tsx +++ b/components/LoginForm.tsx @@ -1,7 +1,8 @@ "use client"; -// ฟอร์ม login (mock e-Court) — เรียก server action login แล้ว router.replace("/") +// ฟอร์ม 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 มาแล้ว) import { useRef, useState, useTransition } from "react"; import { useRouter } from "next/navigation"; @@ -16,7 +17,7 @@ const INPUT_CLASS = 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() { +export default function LoginForm({ next }: { next: string | null }) { const router = useRouter(); const passwordRef = useRef(null); const [error, setError] = useState(null); @@ -32,7 +33,7 @@ export default function LoginForm() { try { const result = await login(usernameInput.value, passwordInput.value); if (result.ok) { - router.replace("/"); + router.replace(next ?? "/"); } else { setError(result.error); // คง username ไว้ (โอกาสพลาดอยู่ที่ password) — ล้างแล้วโฟกัสให้พิมพ์ทับได้ทันที diff --git a/components/pdf/PDFViewer.tsx b/components/pdf/PDFViewer.tsx index a561b5a..e542bf1 100644 --- a/components/pdf/PDFViewer.tsx +++ b/components/pdf/PDFViewer.tsx @@ -119,6 +119,7 @@ import { MAX_BOOKMARK_TITLE_CHARS, } from "@/utils/bookmark-limits"; import { fileKindFromUrl, imageFormatFromUrl } from "@/utils/file-kind"; +import { isHttpUrl } from "@/utils/external-file"; import { withBasePath } from "@/utils/base-path"; import { formatThaiDate } from "@/utils/date-format"; import { decodeTiffPages } from "@/utils/tiff-client"; @@ -1559,7 +1560,11 @@ export default function PDFViewer({ setTool((prev) => (prev === "highlight" ? "select" : prev)); const objectUrls: string[] = []; // URL จริงที่ browser ใช้โหลดไฟล์ (fetch/img.src ตรง — ต้องเติม basePath เอง) - const fileUrl = withBasePath(file.pdfUrl); + // N8: รูปรีโมต (ลิงก์ e-Court) โหลดผ่าน /api/pdf ของเราแทนทางตรง — กัน CORS + // ของปลายทาง + ผ่าน allowlist/จำกัดขนาดเดียวกับ PDF (route คืน bytes รูป + content-type) + const fileUrl = isHttpUrl(file.pdfUrl) + ? withBasePath(`/api/pdf?fileId=${file.id}`) + : withBasePath(file.pdfUrl); (async () => { let pages: { src: string | null; width: number; height: number }[]; if (imageFormatFromUrl(file.pdfUrl) === "tiff") { diff --git a/db/schema.ts b/db/schema.ts index 1fca663..be1ed54 100644 --- a/db/schema.ts +++ b/db/schema.ts @@ -8,6 +8,7 @@ import { pgTable, text, timestamp, + uniqueIndex, uuid, } from "drizzle-orm/pg-core"; @@ -124,15 +125,28 @@ export type AnnotationData = // ─── Tables ────────────────────────────────────────────────────────────────── -export const files = pgTable("files", { - id: uuid("id").primaryKey().defaultRandom(), - title: text("title").notNull(), - description: text("description"), - pdfUrl: text("pdf_url").notNull(), - createdAt: timestamp("created_at", { withTimezone: true }) - .defaultNow() - .notNull(), -}); +export const files = pgTable( + "files", + { + id: uuid("id").primaryKey().defaultRandom(), + title: text("title").notNull(), + description: text("description"), + pdfUrl: text("pdf_url").notNull(), + /** N8: dedupe key เอกสารนำเข้าจากลิงก์ e-Court ("ecourt:" / "url:") — NULL = ไฟล์ seed/อัปโหลด */ + externalId: text("external_id"), + /** N9: รหัสกลุ่มเอกสารที่เกี่ยวเนื่องกัน เช่น คดีเดียวกัน (จากลิงก์ e-Court `?typeId=` — NULL = ไม่มีกลุ่ม) + * ไม่มีคอลัมน์ชื่อกลุ่ม — แสดงผลด้วยรหัสกลุ่มโดยตรง (ถอด type_name ตามคำขอผู้ใช้ 2026-09-22) */ + typeId: text("type_id"), + createdAt: timestamp("created_at", { withTimezone: true }) + .defaultNow() + .notNull(), + }, + (t) => [ + index("files_type_id_idx").on(t.typeId), + // unique บน nullable ยอมหลาย NULL → row seed/อัปโหลดเดิม (external_id = NULL) ไม่ชนกัน + uniqueIndex("files_external_id_key").on(t.externalId), + ], +); export const annotations = pgTable( "annotations", diff --git a/docs/PLAN-API.md b/docs/PLAN-API.md new file mode 100644 index 0000000..840ed3a --- /dev/null +++ b/docs/PLAN-API.md @@ -0,0 +1,111 @@ +# แผน N10 — API ดึงข้อมูลเอกสาร/กลุ่มให้ระบบภายนอก (API Key) + +> สถานะ: **ร่างแผน (ยังไม่ 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` + +## Context + +- ผู้เรียกคือ **backend ของ e-Court** (server-to-server — ไม่มี browser ไม่มีหน้า UI) ต้องการดึงข้อมูลที่เกิดในระบบเรากลับไปใช้ต่อ: annotation/bookmark ของเอกสารหนึ่งฉบับ หรือของทุกเอกสารในกลุ่ม (`typeId`) เดียวกัน +- ระบบเราเป็นฝ่ายถูกเรียกเท่านั้นในแผนนี้ (read-only) — ทิศทาง "เราเรียก e-Court" ไม่มี +- `middleware.ts` ยกเว้น `/api/*` อยู่แล้ว (ไม่บังคับ login แบบ oacCase) — จึงตรวจ API Key ในตัว route เอง ไม่ชนกับระบบ login เดิม + +## การตัดสินใจ (ยืนยันกับผู้ใช้แล้ว 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:"`) — ผู้เรียกรู้จัก id ของตัวเอง ไม่ต้องรู้ UUID ของเรา +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+ ตัวอักษร) +- **env ไม่ตั้งหรือว่าง = ปฏิเสธทุก request** (fail closed — 401 "API ยังไม่ได้ตั้งค่าคีย์") ไม่มี mode เปิดฟรี +- ห้าม log ค่า key / header ดิบ (error log บอกแค่ "auth failed") + +### D2 — Endpoints (2 routes ใหม่) + +URL เต็มฝั่งผู้เรียกอยู่ใต้ basePath: `https:///docedit/note/api/external/...` + +**1) เอกสารเดียว** — `GET /api/external/document?fileId=&include=annotations,bookmarks` + +```jsonc +{ + "document": { // null = 404 (ไม่เคยเปิดในระบบ) + "id": "", // ใช้อ้างต่อใน DTO ข้างล่าง + "externalFileId": "", + "title": "…", + "typeId": "…", // null ได้ (ไม่มีกลุ่ม) + "createdAt": "" + }, + "annotations": [ AnnotationDTO ตาม db/schema.ts พร้อม "replies": ReplyDTO[] ต่อรายการ ], + "bookmarks": [ BookmarkDTO ตาม db/schema.ts ] +} +``` + +**2) ทุกเอกสารในกลุ่ม** — `GET /api/external/documents?typeId=&include=…` + +```jsonc +{ + "typeId": "…", + "documents": [ { "document": {…}, "annotations": […], "bookmarks": […] } ] // รูปร่างเดียวกับ endpoint 1 +} +``` + +- **ไม่มีเอกสารในกลุ่ม = 200 + `documents: []`** (ต่างจากหน้า UI `/groups/[typeId]` ที่ 404 — API คืน empty เป็นผลลัพธ์สมเหตุสมผลสำหรับ integration) +- ลำดับ: `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 (param ขาด/`include` ค่าไม่รู้จัก), 401 (key), 404 (เอกสารเดียว: fileId ไม่เคยลงทะเบียน) + +### D3 — การ query / สิทธิ์ / ความปลอดภัย + +- read-only ทั้งหมด: **ไม่เขียน DB ไม่ publish SSE ไม่ `revalidatePath`** — snapshot ณ ตอนเรียก (คนละ context กับ Server Action ใน invariant #4) +- ไม่ใส่ CORS headers (server-to-server — browser ไม่ควรเรียก; ไม่เปิด `Access-Control-Allow-Origin`) +- ค้น `fileId`: `where eq(files.externalId, "ecourt:" + fileId)` — ไฟล์ที่ลงทะเบียนแบบ fallback `"url:"` (ลิงก์ไม่มี fileId) ค้นด้วยวิธีนี้ไม่ได้ = 404 (ยอมรับ — สัญญาลิงก์ N8 แนะนำให้ส่ง fileId เสมอ) +- **REDACTION รวมใน `annotations` เมื่อเลือก include** — ผู้ถือ key เห็นตำแหน่งกล่องปิดพราง (DB เก็บแค่พิกัด ไม่มีเนื้อหาใต้กล่อง) — ระบุไว้ในสเปกว่า key นี้เชื่อถือได้ระดับ backend e-Court เท่านั้น +- annotation ทุกสถานะรวมหมด (resolved ดูจาก field `resolved` ได้) ทุกผู้ใช้ (userId/userName อยู่ใน DTO) +- 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`, `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) | + +## ลำดับ task + verify (ทุก task: `npx tsc --noEmit` ผ่าน) + +| # | งาน | ไฟล์หลัก | Verify เพิ่มเติม | +|---|---|---|---| +| 1 | Docs sync N10 | ตารางด้านบน | สเปก §7 ครบตาม format; README มี env + ตัวอย่าง | +| 2 | Auth helper | `lib/external-api-auth.ts` | unit test (vitest): ไม่ใส่ header / key ผิด / env ว่าง = 401; key ถูก = ผ่าน — ไม่มีข้อความบอกว่าขาดอะไร | +| 3 | Route เอกสารเดียว | `app/api/external/document/route.ts` | curl: 200 รูปร่างตาม D2 (replies ใน annotation, ISO timestamps), `include=bookmarks` → `annotations: []`, fileId ไม่รู้จัก = 404, `include=foo` = 400 | +| 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 | + +## 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:"`** (ลิงก์ไม่มี fileId) → ค้นไม่ได้ = 404 — เอกสารนี้ต้องเข้าผ่านลิงก์ที่มี fileId ก่อน +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) + +## Out of scope (ไม่ทำในแผนนี้) + +- หลาย key / ตาราง key ใน DB / เพิกถอนราย key / UI จัดการ key +- rate limiting / pagination / caching ของ response +- write API (สร้าง/แก้ annotation ผ่าน API) — ระบบยังเขียนผ่าน Server Actions + login เท่านั้น +- ดึงไฟล์ PDF/bytes เอกสารผ่าน API นี้ (`/api/pdf` มีอยู่แล้วแต่ไม่มี key — ความเสี่ยงที่รับไว้ตามข้อ 3 ของ plan-reviewer ใน PLAN-ECOURT.md) +- webhook/event push แจ้งเตือนเมื่อมี annotation ใหม่, API รูปแบบอื่น (XML/CSV) +- auth จริงเทียบระบบ e-Court (ยัง out of scope ของโปรเจกต์) diff --git a/docs/PLAN-DOCSET.md b/docs/PLAN-DOCSET.md index 63ece3c..fbdd03b 100644 --- a/docs/PLAN-DOCSET.md +++ b/docs/PLAN-DOCSET.md @@ -1,130 +1,107 @@ -# แผน 5.3.10 — ชุดเอกสาร (Document Set) + bookmark รวมทั้งชุด (N8) +# แผน N9 — กลุ่มเอกสารตาม `typeId` จาก e-Court + หน้ารายการเอกสารในกลุ่ม -> สถานะ: **ร่างแผน (ยังไม่ implement)** — เสนอ 2026-09-17 -> อ้างอิง: requirement 5.3.10 (gap analysis `docs/requirement/gap-analysis.md` **กลุ่ม C**): *"แสดง bookmark ของเอกสารแต่ละไฟล์ หรือชุดของเอกสารได้"* — ครึ่งแรก (รายไฟล์) ✅ ทำแล้วใน sidebar ครึ่งหลัง (ชุดของเอกสาร) คือ gap นี้ -> ตาม governance ของโปรเจกต์: implement ต้องเขียนสเปกลง `docs/PLAN-FEAT.md` §7 (**N8**) + ขยาย whitelist ใน `CLAUDE.md` + อัปเดต `docs/PLAN.md` §3 (schema) ก่อน — เป็น task แรกของแผนนี้ แล้วรันผ่าน `/implement-feat N8` +> สถานะ: **implement แล้ว (2026-09-23)** — `tsc` + vitest 142/142 + Playwright ผ่านทุกข้อ verify ของ task 3–4 + plan-reviewer/code-reviewer ไม่พบ violation (คงเหลือ e2e ลิงก์ e-Court จริงตามแผน ตอนปิดงาน N8) — เขียนใหม่ 2026-09-22 แทนร่างเดิม "Document Set จัดชุดมือใน UI" (2026-09-17, commit 6940b38) ตามคำขอผู้ใช้: ให้ e-Court บอกกลุ่มผ่านลิงก์แทนการจัดชุดด้วยมือ +> เลขฟีเจอร์: ร่างเดิม (2026-09-17) เคยใช้เลข N8 — ยกให้แผนลิงก์ e-Court (`docs/PLAN-ECOURT.md`) ซึ่งเป็น dependency ต้นทาง แผนนี้จึงเป็น **N9** +> Dependency: **N8** — การรับ/refresh `typeId` เกิดที่ `registerExternalFile` ของ N8 ทั้งหมด (แผนนี้ไม่มี code path ที่เขียน `type_id` เอง) — **N8 implement ครบแล้ว (2026-09-22 รวม D5)**: schema `type_id` + index มากับ migration `drizzle/0002` ด้วย +> ตาม governance ของโปรเจกต์: implement ต้องเขียนสเปกลง `docs/PLAN-FEAT.md` §7 (**N9**) + ขยาย whitelist ใน `CLAUDE.md` + อัปเดต `docs/PLAN.md` §3 (schema) ก่อน — เป็น task แรกของแผน แล้วรันผ่าน `/implement-feat N9` ## Context -- ตอนนี้ไฟล์เป็น**ลิสต์แบน**หน้าเดียว (หน้า `/` เรียงตาม `createdAt`) — ไม่มี concept การจัดกลุ่มไฟล์ และ bookmark ดูได้เฉพาะภายในไฟล์ที่เปิด (แท็บ "ที่คั่นหน้า" + หน้าสรุป F15 ซึ่งเป็น annotation ไม่ใช่ bookmark) -- กรณีจริงของศาล: คดีหนึ่งงวด = หลายเอกสาร (คำฟ้อง / คำพิพากษา / คำสั่งศาล) — ผู้ใช้อยากเห็น bookmark **รวมทุกไฟล์ของคดีเดียวกันในหน้าเดียว** แล้วคลิกกระโดดเข้าไปอ่านตรงจุดนั้น -- ของพร้อมใช้ทั้งหมด: โครงสร้างต้นไม้ bookmark + ลำดับ N5 (`pageIndex → yOffset → createdAt`), deep link F12 (`?page=` อ่านตอน mount), mock login e-Court (ทุกหน้าถูก `middleware.ts` บังคับ login แล้ว) +- กรณีจริงของศาล: คดีหนึ่งงวด = หลายเอกสาร (คำฟ้อง / คำพิพากษา / คำสั่งศาล) — ผู้ใช้อยากเห็นเอกสารที่เกี่ยวเนื่องกันจัดกลุ่มมาแสดง และมีหน้ารวมทุกเอกสารของกลุ่ม +- ร่างเดิม (2026-09-17) ออกแบบเป็นตาราง `document_sets` + ย้ายไฟล์เข้า/ออกด้วยมือใน UI — ผู้ใช้ตัดสินใจใหม่ (2026-09-22): ให้ e-Court ส่งรหัสกลุ่มมากับลิงก์ (`?typeId=` — เปลี่ยนชื่อจาก caseId ตามคำขอ) จึง**ไม่ต้องมีตารางแยก / Server Actions / UI จัดการกลุ่มเลย** เหลือแค่ column + การแสดงผล +- ข้อจำกัดที่ยอมรับ (ผู้ใช้ยืนยัน 2026-09-22): ระบบเรารู้จักเฉพาะเอกสารที่เคยเปิดผ่านลิงก์ e-Court (row เกิดตอน `registerExternalFile`) — หน้ากลุ่มจึงแสดง**เฉพาะเอกสารที่รู้จักแล้ว** พร้อมหมายเหตุให้ผู้ใช้ทราบว่าอาจไม่ครบทุกฉบับของคดี (รายการครบทั้งคดีอยู่ใน e-Court ไม่ใช่ระบบเรา) -## การตัดสินใจ (ยืนยันกับผู้ใช้แล้ว 2026-09-17) +## การตัดสินใจ (ยืนยันกับผู้ใช้แล้ว 2026-09-22) -1. **ไฟล์หนึ่งอยู่ชุดเดียว** — column `set_id` (nullable) บนตาราง `files` — ไม่ทำ many-to-many (เอกสารหนึ่งฉบับผูกคดีเดียวตามธรรมชาติของงาน) -2. **หน้า bookmark รวมชุดเป็นหน้าใหม่ `/sets/[id]`** เข้าจากหน้ารายการ (หน้า `/` จัดกลุ่มการ์ดไฟล์ตามชุด + หัวชุดมีลิงก์เปิด) — ไม่ยัดลง sidebar ของ viewer (แคบ 288px และต้องเปิดไฟล์ก่อนถึงเห็น) -3. **จัดการชุดผ่าน UI บนหน้ารายการ** — ฟอร์มเล็ก "สร้างชุด" + ปุ่มบนการ์ดไฟล์ย้ายเข้า/ออกชุด + เมนูแก้ชื่อ/ลบชุด (ทุกคนที่ login จัดการได้ — roles ยัง out of scope ตาม 5.3.23) +1. **กลุ่มระดับเดียว กำหนดด้วย `typeId` จากลิงก์ e-Court** — เพิ่ม column บนตาราง `files` ค่าเดียว (`type_id` — ถอด `type_name` ตามคำขอผู้ใช้ 2026-09-22, แสดงผลด้วยรหัสกลุ่มโดยตรง) — ไม่มีตารางกลุ่มแยก ไม่มี many-to-many ไม่มีหลายระดับ (เอกสารหนึ่งอยู่กลุ่มเดียวตามธรรมชาติของงาน) — ความหมายของ id เป็นของ e-Court ฝั่งเราถือว่า "ส่งมาเหมือนกัน = กลุ่มเดียวกัน" +2. **เข้ามาอัตโนมัติทั้งหมด ไม่มี UI จัดการกลุ่ม** — ไฟล์ได้กลุ่มเมื่อเปิดผ่านลิงก์ที่มี `typeId` (เขียนที่ N8) — กลุ่มเกิด/หายตามข้อมูลจริงใน `files` ไม่มีสถานะกลุ่มค้างในตารางอื่น +3. **หน้ารายการ `/` จัดกลุ่มตาม `typeId`** (section ต่อกลุ่ม + ส่วน "ไม่มีกลุ่ม" ท้ายสุด) + **หน้าใหม่ `/groups/[typeId]`** แสดงเอกสารทั้งกลุ่มที่รู้จัก พร้อมหมายเหตุ "อาจไม่ครบทุกฉบับ" ## Design ### D1 — Schema (`db/schema.ts` + `drizzle/`) ```ts -export const documentSets = pgTable("document_sets", { - id: uuid("id").primaryKey().defaultRandom(), - title: text("title").notNull(), - createdAt: timestamp("created_at", { withTimezone: true }) - .defaultNow() - .notNull(), -}); - -// files เพิ่ม: -setId: uuid("set_id").references(() => documentSets.id, { onDelete: "set null" }), -// + index ("files_set_id_idx") บน set_id +// files เพิ่ม (เก็บเฉพาะเอกสารนำเข้าจากลิงก์ — ไฟล์ seed/อัปโหลด = NULL เสมอ): +typeId: text("type_id"), +// + index ("files_type_id_idx") บน type_id ``` -- ตาราง `document_sets` ประกาศ**ก่อน** `files` (drizzle `references` ต้องเห็น table ก่อน) -- **ไม่เก็บผู้สร้างชุด** (ไม่มี user_id/user_name) — ชุดเป็นของกลางของคดี ใครก็จัดการได้ และไม่มี UI ใดในสเปกนี้ต้องโชว์ผู้สร้าง -- `onDelete: "set null"` — ลบชุดแล้วไฟล์ในชุดตกกลับเป็น "ไม่อยู่ชุด" **ไฟล์ไม่ถูกลบ** (คนละเรื่องกับ CASCADE ของ annotations ที่ผูกไฟล์) -- ไม่มี DTO/mapper ใหม่ — หน้าที่ใช้เป็น server component ทั้งหมด ส่งข้ามไป client เฉพาะ `{id, title}` ธรรมดา (createdAt ไม่ต้องส่ง — ไม่มี UI โชว์; ถ้าส่งต้อง ISO string ตาม invariant #5) -- migration: `/sync-db` (push) + **`npx drizzle-kit generate`** — rule data-layer: Docker migrate จาก SQL ใน `drizzle/` เท่านั้น +- ไม่มี FK/ตาราง `document_sets` (ต่างจากร่างเดิม) — กลุ่ม = ค่า distinct ของ `type_id` ณ ตอน query +- migration: `/sync-db` (push) + **`npx drizzle-kit generate`** — rule data-layer: Docker migrate จาก SQL ใน `drizzle/` เท่านั้น; ไฟล์ generated idempotent (`IF NOT EXISTS`) +- การเขียน `type_id` ทั้งหมดเป็นของ N8 `registerExternalFile` (insert ครั้งแรก + update เมื่อลิงก์รอบใหม่ส่ง `typeId` มา — ดู PLAN-ECOURT.md D1) — แผนนี้**อ่านอย่างเดียว** +- ไม่มี DTO/mapper ใหม่ — หน้าที่ใช้เป็น server component ทั้งหมด ข้อมูลข้ามไป client เป็น plain object `{id, title, typeId}` (ถ้าต้องส่ง `createdAt` ข้าม boundary = ISO string ตาม invariant #5) -### D2 — Server Actions (`app/actions/sets.ts`) +### D2 — หน้ารายการ `/` จัดกลุ่ม (`app/page.tsx`) -`"use server"` + validate ก่อนทุกครั้ง (pattern `app/actions/files.ts`): +- query `files` เหมือนเดิม → แบ่งเป็น section ต่อกลุ่ม (`type_id` ไม่ NULL) เรียงตาม `createdAt` ของไฟล์ล่าสุดในกลุ่ม (กลุ่มที่มี activity ใหม่อยู่บน) — การ์ดไฟล์ในกลุ่มเรียง `createdAt` เหมือนเดิม + ปุ่ม Annotate เดิม +- หัวกลุ่ม = `type_id` + จำนวนไฟล์ + **ลิงก์ "เอกสารในกลุ่ม" → `/groups/{encodeURIComponent(typeId)}`** (icon Folder) +- ไฟล์ที่ `type_id` NULL (seed / อัปโหลด N4 / ลิงก์แบบไม่มี typeId) → section **"ไม่มีกลุ่ม"** ไว้ท้ายสุด (ใกล้ฟอร์มเพิ่มเอกสาร) — แสดงเสมอถ้ามีไฟล์ +- ไม่มี client component ใหม่ ไม่มี action ใหม่ — จัดกลุ่มเป็นการแสดงผลล้วนบน server component เดิม -- `createSet(title)` — title trim, ว่าง = error, ยาว ≤ 200 (เทียบ `files.title`) → insert -- `renameSet(setId, title)` — validate UUID + title เหมือนกัน + ชุุดมีจริง → update -- `deleteSet(setId)` — validate UUID + มีจริง → delete (ไฟล์ในชุดกลายเป็น null อัตโนมัติจาก FK) -- `setFileSet(fileId, setId | null)` — validate ไฟล์มีจริง + (ถ้าไม่ null) ชุดมีจริง → update `files.set_id` -- **ไม่ publish SSE event** — การจัดชุดไม่แตะข้อมูลรายไฟล์ใน viewer (annotation/bookmark เดิมทั้งหมด) จึงไม่มีอะไรให้ sync ผ่าน `/api/annotations/stream` (ซึ่งผูก `fileId` รายไฟล์อยู่แล้ว) — เหมือน `uploadFile` ที่ใช้ `revalidatePath` อย่างเดียว -- **revalidatePath ทุก action:** `/` เสมอ + `/sets/{setId}` เมื่อแก้ชุดนั้น (rename) — กัน Router Cache ฝั่ง client ค้างหน้าเก่า (เหตุผลเดียวกับ `revalidateFilePages`) -- สิทธิ์: ทุกคนที่ login — action เรียก `getSessionUser()` defense ซ้อน middleware (คืน error ถ้าไม่มี session) +### D3 — หน้า `/groups/[typeId]` แสดงเอกสารในกลุ่ม (`app/groups/[typeId]/page.tsx`) -### D3 — หน้ารายการ `/` จัดกลุ่มตามชุด +Server component `force-dynamic` (login ถูกบังคับโดย `middleware.ts` อยู่แล้ว — path ใหม่เข้า net อัตโนมัติ): -- query `files` + `documentSets` → จัดกลุ่ม: **section ต่อชุด** (เรียง `createdAt`) — หัวชุด = ชื่อชุด + จำนวนไฟล์ + **ลิงก์ "bookmark ของชุด" → `/sets/{id}`** (icon Bookmark) + เมนู ⋯ (แก้ชื่อ / ลบชุด — confirm ก่อนลบ) -- การ์ดไฟล์ในชุดเรียง `createdAt` เหมือนเดิม + ปุ่ม Annotate เดิม + **ปุ่ม "ใส่ชุด"** เปิด dropdown เลือกชุดที่มีอยู่ / "ออกจากชุด" (ถ้าอยู่ชุด) -- ไฟล์ที่ `set_id` null → section **"ไม่อยู่ชุด"** ไว้ท้ายสุด (ใกล้ฟอร์มสร้าง/อัปโหลด — workflow อัปโหลดแล้วค่อยย้ายเข้าชุด) — ซ่อนเมื่อว่าง -- ฟอร์มเล็ก **"สร้างชุด"** — section ของตัวเองคู่ "เพิ่มเอกสาร" (client component: input ชื่อ + ปุ่ม, `useTransition` + action `createSet`) -- client component ใหม่: `components/CreateSetForm.tsx` (ฟอร์มสร้างชุด), `components/SetFileMenu.tsx` (dropdown ย้ายเข้า/ออกชุดบนการ์ด + เมนูแก้ชื่อ/ลบของหัวชุด — รับ prop list ชุุด `{id, title}[]` จาก server) +- โหลด: `files where type_id = params.typeId` `orderBy createdAt` (Next decode dynamic param ให้อัตโนมัติ — **อย่าเรียก `decodeURIComponent` ซ้ำ** ตาม edge case #5) — **ไม่มี = 404** (กลุ่มที่ระบบไม่รู้จัก/ย้ายไฟล์ออกหมดแล้ว) +- แสดง: หัวกลุ่ม (`type_id` + "N เอกสาร") + ลิงก์กลับ `/` +- **หมายเหตุกำกับทุกครั้ง** (ผู้ใช้ยืนยัน): "แสดงเฉพาะเอกสารที่เคยเปิดในระบบนี้ — อาจไม่ครบทุกฉบับของกลุ่ม" (กล่อง info ใต้หัวกลุ่ม) +- รายการเอกสาร: ชื่อไฟล์ + วันที่นำเข้า (`formatThaiDate`) + จำนวน annotation/bookmark (query นับแยกด้วย `groupBy fileId` — 2 query เสริม ไม่ join หนัก) + ปุ่ม/ลิงก์ **Annotate → `/annotate/{fileId}`** (ไม่ต้อง deep link หน้า — เข้าไปเหมือนปกติ) +- ไม่มี SSE (snapshot ณ ตอนโหลด เหมือนหน้าสรุป F15) ไม่มีปุ่มแก้ไข/ย้ายไฟล์ (จัดการที่ e-Court ฝั่งเดียว) +- `generateMetadata` ตั้งชื่อแท็บ browser ตามชื่อกลุ่ม (pattern เดียวกับหน้าสรุป F15) -### D4 — หน้า `/sets/[id]` — bookmark รวมทั้งชุด (`app/sets/[id]/page.tsx`) +### D4 — สิทธิ์/การเข้าถึง -Server component `force-dynamic` (มี login check ซ้อน middleware เหมือนหน้า `/`): - -- โหลด: ชุุด (ไม่มี = 404) + ไฟล์ในชุุด (`orderBy createdAt`) + bookmark ของทุกไฟล์ (`inArray(fileId, …)` `orderBy asc(pageIndex), asc(yOffset), asc(createdAt)` — N5) -- แสดง: หัวชุด (ชื่อ + "N เอกสาร · M รายการ") + ลิงก์กลับ `/` -- **จัดกลุ่มตามไฟล์** (หัวไฟล์ = ชื่อไฟล์) ตามลำดับไฟล์ในชุด → ข้างในแสดง**ต้นไม้ bookmark 3 ระดับ** (indent เหมือน sidebar — build `childrenOf`/`roots` จาก array ที่เรียง N5 แล้ว + orphan → root + cycle guard seen ชุดเดียวร่วมทุก root เหมือน pattern N7/route เดิม) **read-only**: ชื่อ + "หน้า N · ผู้บันทึก · วันที่" + โน้ต — คลิกทั้งแถว = ลิงก์ **`/annotate/{fileId}?page={หน้า}`** กระโดดเข้า viewer ตรงหน้า (deep link F12 — ⚠️ implement ต้องเช็คจาก F12 จริงว่า `?page=` เป็น 1-based หรือ 0-based ให้ตรงกับปุ่มกระโดดของหน้าสรุป F15) -- ไม่มีปุ่มแก้ไข/ลบ bookmark ที่นี่ (จัดการที่ viewer เท่านั้น) + ไม่มี SSE (snapshot ณ ตอนโหลด เหมือนหน้าสรุป F15) -- ปุ่มส่งออก JSON (N7) **ไม่มี**หน้านี้ — N7 รับ `fileId` เดียว การขยายเป็นชุดอยู่ใน out of scope - -### D5 — สิทธิ์/การเข้าถึง - -- `middleware.ts` บังคับ login ทุก path อยู่แล้ว — `/sets/*` + actions เข้า่ายใน net อัตโนมัติ -- ใครก็จัดการชุดได้ (สร้าง/แก้ชื่อ/ลบ/ย้ายไฟล์) — ไม่มี roles (5.3.23 ยัง out of scope) +- ใครก็ที่ login เห็นได้ทุกกลุ่ม (mock login — roles ยัง out of scope ตามแผนหลัก) +- ไม่มี Server Action ใหม่ → ไม่มี publish SSE / `revalidatePath` ของแผนนี้ (การเปลี่ยนกลุ่มเกิดที่ N8 ตอน register แล้ว redirect ออกจาก `/` ทันที — ไม่มี Router Cache ค้าง) ## Docs/rules sync (plan-first — ทำเป็น task แรก ก่อนแตะโค้ด) | ไฟล์ | สิ่งที่แก้ | |---|---| -| `docs/PLAN.md` §3 | เพิ่มตาราง `document_sets` + column `files.set_id` (FK SET NULL) — rule data-layer: ห้ามเพิ่มตารางโดยไม่แก้ PLAN.md ก่อน | -| `docs/PLAN-FEAT.md` §7 | หัวข้อใหม่ **N8 · ชุดเอกสาร + bookmark รวมทั้งชุด** (format ตาม N5–N7 + อ้างแผนฉบับเต็ม `docs/PLAN-DOCSET.md`) | -| `CLAUDE.md` | whitelist ฟีเจอร์เสริมหลังแผน เพิ่ม N8 + โครงสร้างไฟล์เพิ่ม `app/actions/sets.ts`, `app/sets/[id]/page.tsx`, `components/CreateSetForm.tsx`, `components/SetFileMenu.tsx` | -| `.claude/rules/data-layer.md` | เพิ่ม: `document_sets` + `files.set_id` (FK **SET NULL** ไม่ใช่ CASCADE), actions ของ sets **ไม่ publish SSE** (ไม่แตะข้อมูลรายไฟล์) แต่ `revalidatePath("/")` (+`/sets/[id]` ตอน rename) เสมอ | +| `docs/PLAN.md` §3 | เพิ่ม column `files.type_id` (text NULL + index — ไม่มีตารางกลุ่ม/ไม่มีคอลัมน์ชื่อกลุ่ม) + `files.external_id` (ค้างจาก N8 — ทำพร้อมกันตอน sync 2026-09-23) | +| `docs/PLAN-FEAT.md` §7 | หัวข้อใหม่ **N9 · กลุ่มเอกสารตาม typeId + หน้า /groups/[typeId]** (format ตาม N5–N7 + อ้างแผนฉบับเต็ม `docs/PLAN-DOCSET.md`) | +| `CLAUDE.md` | whitelist ฟีเจอร์เสริมหลังแผน เพิ่ม N9 + โครงสร้างไฟล์เพิ่ม `app/groups/[typeId]/page.tsx` | +| `.claude/rules/data-layer.md` | เพิ่ม: `files.type_id` เขียนที่ N8 `registerExternalFile` เท่านั้น (ลิงก์ไม่ส่ง = คงเดิม) — ไม่มี action/SSE ใหม่ของ N9 | -- `docs/requirement/gap-analysis.md` — ปรับ 5.3.10 ⚠️ → ✊ **หลัง implement เสร็จเท่านั้น** (task ปิดงาน) ไม่ใช่ตอนนี้ -- `.claude/rules/realtime.md`, `pdf-viewer.md`, `coordinates.md` **ไม่แตะ** (ไม่มี SSE event ใหม่ ไม่แตะ viewer/พิกัด) +- `docs/requirement/gap-analysis.md` 5.3.10 ครึ่งหลัง ("หรือชุดของเอกสาร") — **ยังไม่ปิด** ด้วยแผนนี้ (แผนนี้แสดง *เอกสาร* รวมกลุ่ม ไม่ใช่ *bookmark* รวมกลุ่มตาม requirement) — อย่าเปลี่ยนสถานะ +- `.claude/rules/realtime.md`, `pdf-viewer.md`, `coordinates.md` **ไม่แตะ** ## ลำดับ task + verify (ทุก task: `npx tsc --noEmit` ผ่าน) | # | งาน | ไฟล์หลัก | Verify เพิ่มเติม | |---|---|---|---| -| 1 | Docs sync N8 | ตารางด้านบน | สเปก §7 ครบตาม format N5–N7; PLAN.md §3 มีตาราง/column ใหม่ | -| 2 | Schema + migrate | `db/schema.ts`, `drizzle/` | `/sync-db` + `drizzle-kit generate` → psql `\d files` เห็น `set_id` + `\d document_sets` มีตาราง; SQL generated idempotent (`IF NOT EXISTS`) | -| 3 | Server Actions | `app/actions/sets.ts` | ผ่าน UI ใน task 4–5; title ว่าง/ยาว = error; setId/fileId ไม่มีจริง = error ไม่ throw ระเบิดหน้า | -| 4 | หน้ารายการจัดกลุ่มชุด + ฟอร์มสร้าง + ย้ายไฟล์ | `app/page.tsx`, `components/CreateSetForm.tsx`, `components/SetFileMenu.tsx` | Playwright: สร้างชุุด → การ์ดจัดกลุ่มถูก → ย้ายไฟล์เข้า/ออกชุด → แก้ชื่อ/ลบชุดแล้วไฟล์ไม่หาย (กลับ "ไม่อยู่ชุด"); **regression: อัปโหลด N4 + Annotate ยังทำงาน** | -| 5 | หน้า `/sets/[id]` | `app/sets/[id]/page.tsx` | Playwright: bookmark หลายไฟล์แสดงครบ เรียง ไฟล์ → N5 ต้นไม้ indent ถูก; คลิกกระโดด `/annotate/{fileId}?page=` แล้ว viewer ไปถูกหน้า; ชุดว่าง / ไฟล์ไม่มี bookmark / ชุดที่ถูกลบ = 404 | -| 6 | ปิดงาน | — | plan-reviewer + code-reviewer + vitest + `tsc` + gap-analysis 5.3.10 → ✅ | +| 1 | Docs sync N9 | ตารางด้านบน | สเปก §7 ครบตาม format N5–N7; PLAN.md §3 มี column ใหม่ | +| 2 | Schema + migrate — **ทำแล้วกับ N8** (`type_id` + `files_type_id_idx` อยู่ใน `db/schema.ts` + migration `drizzle/0002` idempotent + dev DB ยืนยันด้วย psql 2026-09-23) | `db/schema.ts`, `drizzle/` | ตรวจแล้วผ่าน: psql `\d files` เห็น `type_id` + index (ไม่มี type_name) | +| 3 | หน้า `/` จัดกลุ่ม | `app/page.tsx` | ทดสอบด้วยลิงก์จำลอง (การ์ด `EcourtLinkForm` / path `/samples/…` + `typeId` — N8 พร้อมแล้ว) หรือ update `files` set `type_id` ตรง ๆ ก็ได้: จัด section ถูก / "ไม่มีกลุ่ม" ท้ายสุด; **regression: อัปโหลด N4 + Annotate ยังทำงาน** | +| 4 | หน้า `/groups/[typeId]` | `app/groups/[typeId]/page.tsx` | รายการครบถูกลำดับ + หมายเหตุแสดง + นับ annotation/bookmark ตรง + กด Annotate เปิดไฟล์ได้; typeId ไม่รู้จัก = 404; ชื่อแท็บ browser ตามกลุ่ม | +| 5 | ปิดงาน | — | plan-reviewer + code-reviewer + vitest + `tsc`; e2e เต็ม (ลิงก์ e-Court จริง) ทำตอนปิดงาน N8 | ## Edge cases (ออกแบบไว้ล่วงหน้า) -1. **ลบชุดที่มีไฟล์** → FK `ON DELETE SET NULL` — ไฟล์ตกกลับ "ไม่อยู่ชุด" ไม่ถูกลบ (มี confirm dialog กันมือเพื่อน) -2. **เข้า `/sets/[id]` ชุดที่ถูกลบ** → 404 -3. **ชื่อชุดซ้ำ** → อนุญาต (ไม่ unique — คดีต่างงวดชื่อคล้ายกันได้ ไม่มีเหตุผลบังคับ) -4. **ชื่อชุดว่าง/whitespace/ยาวเกิน 200** → action validate (trim → ว่าง = error, clamp 200) -5. **ชุดว่าง (ไม่มีไฟล์)** → หน้า set แสดง "ยังไม่มีเอกสารในชุดนี้" — ชุดว่างยังโชว์หน้า `/` ได้ (สร้างชุดรอไว้ก่อนแล้วค่อยย้ายไฟล์เข้า) -6. **ไฟล์ในชุดไม่มี bookmark** → หัวไฟล์ + "ไม่มีที่คั่นหน้าในไฟล์นี้" (เห็นว่าไฟล์อยู่ชุดแต่ว่าง — ไม่หายเงียบ) -7. **cycle ใน bookmark tree** (แก้ตรง DB หลุด guard ของ action) → เหมือน N7: DFS seen ชุุดเดียวร่วมทุก root + เติมโหนดที่ไม่ถูกเดินเป็นระดับ 1 -8. **`?page=` format ของ F12** → เช็คจากโค้ดจริง (1-based/0-based) ให้ตรงกับปุ่มกระโดดหน้าสรุป F15 — อย่าเดา -9. **Dev DB แชร์กับการทดสอบของผู้ใช้** → เทส Playwright สร้างชุด/ย้ายไฟล์ด้วย id ที่จับเป๊ะ ไม่ลบ/รีเซ็ตข้อมูลทั้งหมด -10. **ไฟล์ถูก "อยู่ชุด" แล้วเลือกย้ายเข้าชุดเดิม** → update ค่าเดิม idempotent ไม่ error +1. **ลิงก์รอบใหม่ส่ง `typeId` คนละค่า** → ย้ายกลุ่มตามค่าล่าสุด (เกิดที่ N8) — annotation/bookmark ผูกกับ `file_id` ไม่เกี่ยวกับกลุ่ม จึงตามไฟล์ไปเอง +2. **ลิงก์รอบใหม่ไม่ส่ง `typeId`** → ไฟล์คงกลุ่มเดิม (N8: ห้ามเคลียร์ค่าเดิม) +3. **ไฟล์ไม่เคยมี `typeId`** (seed / อัปโหลด N4 / ลิงก์เก่า) → section "ไม่มีกลุ่ม" บนหน้า `/`, ไม่มีหน้ากลุ่ม +4. **กลุ่มแสดงผลด้วยรหัสล้วน** — ไม่มีคอลัมน์ชื่อกลุ่ม (ถอด `type_name` ตามคำขอผู้ใช้ 2026-09-22) — ถ้าภายหลังต้องการชื่อกลุ่ม กลับมาเพิ่ม column ใหม่ตาม governance +5. **`typeId` มีอักขระพิเศษ/ยาว** → ผ่าน URL ด้วย `encodeURIComponent`; normalize ที่ N8 (trim → ว่าง = NULL, cap 200); Next decode ให้อัตโนมัติตอนอ่าน `params` +6. **กลุ่มว่างลง (ไฟล์ย้ายออกหมด)** → กลุ่มหายจากหน้า `/` เอง และ `/groups/[typeId]` กลายเป็น 404 (query จาก `files` จริง ไม่มีตารางกลุ่มค้าง) +7. **Dev DB แชร์กับการทดสอบของผู้ใช้** → เทสหน้า UI ด้วยการตั้ง `type_id` บน id ที่จับเป๊ะ ไม่ reset ข้อมูลทั้งกลุ่ม +8. **N8 ยังไม่ implement ตอนทำ N9** → ~~verify หน้า UI ด้วยการเขียน DB ตรง (task 3–4) แล้วรอ e2e จริงตอนปิดงาน N8~~ (ล้าสมัย — N8 implement ครบแล้ว 2026-09-22: verify ได้ด้วยลิงก์จำลอง/การ์ด `EcourtLinkForm` + path `/samples/…` ทันที) +9. **`typeId` มี `/` (เช่น เลขคดีไทย `ด2024/2567`)** → ลิงก์ `/groups/…%2F…` — Next 14 route ได้ (match บน raw path ก่อน decode) แต่ reverse proxy บางตัว (Tomcat/JBoss ปฏิเสธ `%2F` โดย default) อาจ 404 ทั้งที่กลุ่มมีจริง — **known risk ที่รับไว้** (code-reviewer 2026-09-23): ทดสอบ e2e กับ deployment จริงของ e-Court ตอนปิดงาน N8 ถ้าเจอปัญหาแก้ที่จุดสร้างลิงก์จุดเดียว (replace `/` ด้วยตัวอักขระปลอดภัยตอน encode) หรือเปลี่ยนเป็น query param ## Out of scope (ไม่ทำในแผนนี้) -- ไฟล์อยู่หลายชุด (many-to-many) — ตัดสินใจแล้ว: ชุุดเดียว -- export/JSON bookmark **ตามชุด** (ขยาย N7 รับ `setId`) — N7 ยังรับ `fileId` เดียว -- roles/สิทธิ์การจัดการชุด (gap 5.3.23) -- reorder ไฟล์ในชุด / reorder ชุด / ค้นหาข้ามชุด (ขยาย N6 เป็นหลายไฟล์) -- หน้ารวมทุกชุด `/sets` (ทางเข้าอยู่หน้ารายการแล้ว) -- seed ตัวอย่างชุดใน `npm run seed-samples` (สร้างผ่าน UI ได้ — ไม่บังคับ) -- annotation รวมชุด (bookmark พอตาม requirement 5.3.10) +- จัดกลุ่ม/แก้ชื่อ/ลบกลุ่มด้วยมือใน UI ของเรา (ทิ้งจากร่างเดิม — กลุ่มมาจาก e-Court อย่างเดียว) +- ไฟล์อยู่หลายกลุ่ม (many-to-many) / กลุ่มซ้อนหลายระดับ +- **bookmark รวมทั้งกลุ่ม** (ครึ่งหลังของ requirement 5.3.10 แบบร่างเดิม — น่าดึงกลับมาภายหลังได้เมื่อผู้ใช้ขอ) +- export JSON ตามกลุ่ม (N7 ยังรับ `fileId` เดียว) +- API ดึงข้อมูลให้ระบบภายนอก = **N10** `docs/PLAN-API.md` (แผนแยก) +- รายการเอกสารครบทุกฉบับของคดีรวมฉบับที่ยังไม่เคยเปิด (ข้อมูลอยู่ที่ e-Court) -## Verification (ภาพรวม end-to-end) +## Verification (ภาพรวม end-to-end — หลัง N8 เสร็จ) -1. `npm run dev` → login → หน้า `/`: สร้างชุด "คดีตัวอย่าง" → ย้ายไฟล์ 2 ไฟล์เข้าชุุด → การ์ดจัดกลุ่มใต้หัวชุด -2. หัวชุด → "bookmark ของชุด" → `/sets/[id]`: เห็น bookmark ทั้งสองไฟล์ จัดกลุ่มตามไฟล์ เรียง N5 ต้นไม้ indent ถูก -3. คลิกรายการ → กระโดดเข้า viewer ไฟล์นั้น**ถูกหน้า** -4. ลบชุด → ไฟล์ทั้งสองกลับมาอยู่ "ไม่อยู่ชุด" ครบ ไม่มีอะไรหาย -5. Regression: annotate / ค้นหา N6 / export JSON N7 / อัปโหลด N4 ยังทำงานเหมือนเดิม +1. `npm run dev` → login → เปิดลิงก์ e-Court 2 ไฟล์ที่ `typeId` เดียวกัน + 1 ไฟล์ `typeId` ต่างกลุ่ม → หน้า `/` จัด 2 section ตามกลุ่ม + section "ไม่มีกลุ่ม" (ไฟล์ seed/อัปโหลด) +2. หัวกลุ่ม → "เอกสารในกลุ่ม" → `/groups/[typeId]`: เห็นไฟล์ครบตามลำดับ + หมายเหตุ "อาจไม่ครบทุกฉบับ" + จำนวน annotation/bookmark ต่อไฟล์ +3. กด Annotate → เข้า viewer ของไฟล์นั้นได้ตามปกติ +4. เปิดลิงก์รอบใหม่ของไฟล์เดิมด้วย `typeId` คนละค่า → ไฟล์ย้ายกลุ่ม กลุ่มเดิมลดจำนวน/หายไปเมื่อว่าง +5. Regression: อัปโหลด N4 / annotate / ค้นหา N6 / export JSON N7 ยังทำงานเหมือนเดิม diff --git a/docs/PLAN-ECOURT.md b/docs/PLAN-ECOURT.md new file mode 100644 index 0000000..58db09a --- /dev/null +++ b/docs/PLAN-ECOURT.md @@ -0,0 +1,112 @@ +# แผน N8 — เปิดเอกสารจากลิงก์ e-Court `?fileUrl=&fileId=&typeId=` (metadata-only + ดึงไฟล์สด) + +> อ้างอิง: PLAN-FEAT.md §7 **N8** · สร้าง 2026-09-18 ตามข้อตกลงกับผู้ใช้ (สัญญาลิงก์ `fileUrl`+`fileId`, **ไม่เก็บสำเนาไฟล์ เอาแค่ชื่อ**, id เปลี่ยน = ไฟล์ใหม่) · อัปเดต 2026-09-22: เพิ่ม param `typeId` จัดกลุ่มเอกสารที่เกี่ยวเนื่องกัน (ส่งต่อให้ **N9** `docs/PLAN-DOCSET.md`) + แก้สถานะการ implement ตามที่ตรวจพบจริง (ด้านล่าง) + +## Context + +ระบบ e-Court (admcasedey.admincourt.go.th) จะลิงก์เข้ามาที่ app รูปแบบ: + +``` +https://ecourt.chamomind.com/docedit/docedit/note?fileUrl=&fileId=&typeId=<รหัสกลุ่มเอกสารที่เกี่ยวเนื่องกัน> +``` + +ตัวอย่าง URL ไฟล์: `https://admcasedey.admincourt.go.th/admCaseEfiling/sysDocument/Attach/Temp/9/tmp_efile_tu_2_64373_0_2026-09-01_13-55-31_s.pdf` + +ก่อนหน้านี้ไม่มีหน้าไหนอ่าน `searchParams` เลย (ตรวจแล้ว) — หน้า `/` เมิน query ทิ้งจึงดู "ว่างเปล่า" และสถาปัตยกรรมเดิมรองรับเฉพาะไฟล์ในเครื่อง (`files.pdf_url` = path ใน `public/` + path guard ใน `lib/server-pdf.ts`) + +## การตัดสินใจ (ยืนยันกับผู้ใช้แล้ว 2026-09-18) + +1. **สัญญาลิงก์ = `fileUrl` จำเป็น + `fileId` แนะนำ** — มี fileId ใช้เป็น dedupe key (`external_id = "ecourt:"`), ไม่มี fallback เป็น `"url:"` · เพิ่ม 2026-09-22: **`typeId` แนะนำ** (ผู้ใช้เลือกชื่อนี้แทน caseId) — รหัสกลุ่มเอกสารที่เกี่ยวเนื่องกัน (เช่น คดีเดียวกัน) เอกสารที่ e-Court ส่ง `typeId` เดียวกัน = กลุ่มเดียวกัน (แสดงผลใน N9) +2. **ไม่เก็บสำเนาไฟล์ เอาแค่ชื่อ** — ลง DB แค่ metadata (title จาก basename ของ URL + external_id + ตัว URL เก็บใน `pdf_url`) **ไม่ดาวน์โหลดเขียนลง `public/uploads/`** ตอนเปิดดู/export ให้ server ดึงจาก e-Court สด ๆ ผ่าน route ของเรา (กัน CORS + คุม SSRF ที่จุดเดียว) +3. **หลักจัดการเวอร์ชัน: id เปลี่ยน = ไฟล์ใหม่** + +| สถานการณ์ | ผลลัพธ์ | +|---|---| +| `fileId` เดิม + URL เปลี่ยน (Temp link อายุใหม่) | row เดิม — ได้ annotation/bookmark เดิมครบ + `pdf_url` ถูก refresh เป็น URL ใหม่อัตโนมัติ | +| `fileId` เปลี่ยน (e-Court ออก id ใหม่ต่อเวอร์ชัน) | row ใหม่ = เอกสารใหม่เริ่ม blank **ไม่สืบทอดของเวอร์ชันก่อน** (ไฟล์เก่ายังอยู่ในรายการ เปิดย้อนดูได้) — กัน annotation เก่าไปวาดตกตำแหน่งคลาดบนเนื้อหาที่ขยับ (พิกัดเป็น % ของหน้า) | + +## Design + +### D1 — Entry point + สร้าง/หา row + +- `app/page.tsx` รับ `searchParams` (Next 14 = plain object ค่า `string | string[]` — helper `firstParam`): มี `fileUrl` → เรียก `registerExternalFile` → สำเร็จ `redirect("/annotate/")` (เรียก**นอก try/catch เสมอ** — `redirect()` โยน `NEXT_REDIRECT`) / ไม่สำเร็จ render กล่อง error ไทย `role="alert"` เหนือ section "เพิ่มเอกสาร" (หน้ารายการยังแสดง ไม่ blank) +- `lib/external-import.ts` — **server module ธรรมดา ไม่ใช่ "use server" action** (เรียกจาก server component; ไม่เปิดเป็น endpoint ให้ client ยิง): `registerExternalFile(rawFileUrl, rawFileId?)` + 1. `validateExternalFileUrl` → fail คืนข้อความไทย + 2. `externalIdFor(fileId, url)` → `select ... where external_id = key limit 1` → เจอ: ถ้า `pdf_url ≠ URL ใหม่` → `update files set pdf_url` (refresh Temp link) + ถ้าลิงก์ครั้งนี้มี `typeId` → `update files set type_id` ตามค่าล่าสุดด้วย (e-Court เปลี่ยน `typeId` = ย้ายกลุ่มตามค่าล่าสุด; **ลิงก์ไม่ส่ง `typeId` มา = คงค่าเดิม ห้ามเคลียร์**) แล้วคืน id เดิม + 3. ไม่เจอ → `insert { id: randomUUID(), title: titleFromExternalUrl(url), pdfUrl: url.href, externalId, typeId? }` — `typeId` normalize: trim → ว่าง = ไม่เก็บ (NULL), cap 200 ตัวอักษร (เทียบ title) + 4. **catch postgres `23505`** (insert ชน unique จาก 2 แท็บเปิดพร้อมกันครั้งแรก) → select ใหม่คืน row เดิม (ไม่มีไฟล์บนดิสก์ = ไม่มี cleanup) +- **ไม่ `revalidatePath`** — เรียกระหว่าง render ของ server component ไม่ใช่ use case ที่รองรับ และไม่จำเป็น (`/` เป็น `force-dynamic` + redirect ออกทันที) ไม่มี SSE event (รายการไฟล์ไม่ได้ sync ผ่าน SSE — เหมือน N4) +- Schema: `files` เพิ่ม `external_id text NULL UNIQUE` + `type_id text NULL` (N9) + index `files_type_id_idx` — unique บน nullable ยอมหลาย NULL → row seed/อัปโหลดเดิม (`external_id = NULL`) ไม่ชนกัน + +### D2 — Validation + allowlist (pure: `utils/external-file.ts`) + +- `externalHosts()` — default `["admincourt.go.th"]` (ครอบทั้ง `admcasedey.` และ `judgement-api.`), override ด้วย env `EXTERNAL_FILE_HOSTS` (comma-separated) — **อ่านตอน call ไม่ใช่ module load** เพื่อ test ได้ +- `isAllowedExternalHost(host)` — suffix match: `host === entry || host.endsWith("." + entry)` (กัน `admincourt.go.th.evil.com` และ `xadmincourt.go.th`) +- `validateExternalFileUrl(raw)` — `new URL` parse ได้ + `https:` เท่านั้น + ไม่มี `user:pass@` + host ผ่าน allowlist → ไม่ผ่านคืน error code (`invalid_url` / `not_https` / `has_credentials` / `host_not_allowed`) +- `titleFromExternalUrl(url, fallbackId?)` — basename ของ pathname แล้ว `decodeURIComponent` (try/catch `URIError`) ตัดนามสกุล cap 200 ตัวอักษร; URL ไม่มีชื่อไฟล์ (path ลงท้าย `/`) → fallback `fallbackId` หรือ `"เอกสารจาก e-Court"` +- `externalIdFor(fileId?, url)` — `"ecourt:" + fileId` (trim) หรือ `"url:" + url.href` +- `sanitizeNextPath(s)` — ใช้ได้เฉพาะขึ้นต้น `/` และไม่ใช่ `//` / `/\` (กัน open-redirect ตอน login วนกลับ) — ไม่ผ่านคืน `null` +- `isHttpUrl(s)` — absolute `http(s)://` หรือไม่ (ใช้ทั้งฝั่ง server เช็ค `pdf_url` เป็นรีโมต และฝั่ง client เลือกทางโหลดรูป) + +### D3 — ดึงไฟล์รีโมต (server-only: `lib/remote-file.ts`) + +- `isRemoteSource(pdfUrl)` = `isHttpUrl(pdfUrl)` +- `fetchSourceBytes(url)` — validate ซ้ำอีกชั้น (defense in depth) → `fetch(url, { redirect: "follow", signal: AbortSignal.timeout(60_000) })` → ไม่ใช่ 2xx = `RemoteFileError` → **re-validate `res.url` หลัง redirect** ว่า host ยังอยู่ใน allowlist (กัน redirect chain เด้งไป host อื่น) → pre-check `Content-Length` + นับ byte ระหว่าง stream ด้วย `ReadableStream` reader ถ้าเกิน **100MB** (เท่า limit อัปโหลด N4) ยกเลิกแล้ว error → คืน `Buffer` +- จุดใช้ 2 ที่ (pattern เดียวกัน เปลี่ยนเฉพาะแหล่ง bytes ก่อนเข้า pipeline เดิม): + - `app/api/pdf/route.ts` — `isRemoteSource(file.pdfUrl) ? await fetchSourceBytes(...) : readPublicPdf(file.pdfUrl)` แล้ว branch รูป (`imageContentTypeFromUrl`)/PDF (`fixPdfActualText`) ทำงานต่อเหมือนเดิม + - `app/api/annotations/export/route.ts` (F8) — จุดเดียวกันก่อน `imageDocFromBytes`/`PDFDocument.load` +- export CSV (F16) / bookmarks JSON (N7) / หน้าสรุป (F15) ไม่อ่าน bytes อยู่แล้ว → ไม่แตะ + +### D4 — Viewer กับ absolute URL ใน `pdf_url` + +- `utils/base-path.ts` — `withBasePath` คืนค่าเดิมเมื่อเป็น absolute `^https?://` (ไม่งั้น prefix ทับจนพัง: `/docedit/notehttps://...`) +- PDF branch ของ viewer โหลดผ่าน `/api/pdf?fileId=` อยู่แล้ว (same-origin — ไม่กระทบ); fallback `fetch(withBasePath(file.pdfUrl))` ทำงานผ่าน bypass (อาจโดน CORS ของปลายทางแต่เป็นแค่ fallback) +- **รูปภาพ (N3)** โหลด static `pdfUrl` ตรง ๆ — ไฟล์รีโมตต้องเปลี่ยนไปโหลดผ่าน `withBasePath("/api/pdf?fileId=" + file.id)` แทน (route คืน bytes รูป + content-type อยู่แล้ว) — ใช้ `isHttpUrl` แยกทาง + +### D5 — Login ไม่ทำ query หาย (deep link ของคนยังไม่ login) + +- `middleware.ts` เดิม `url.search = ""` ตอน redirect `/login` → query หายทั้งหมด — แก้: เก็บ `next = pathname + search` ก่อนล้าง แล้ว `url.searchParams.set("next", next)` (เฉพาะ `next !== "/"`) +- `app/login/page.tsx` รับ `searchParams` → `sanitizeNextPath` → `if (user) redirect(next ?? "/")` + ส่ง prop `next` ให้ `LoginForm` → `router.replace(next ?? "/")` (middleware เห็น path ตัด basePath แล้ว, NextURL/router ใส่ basePath คืนเอง — invariant #12) + +## Edge cases + +- **URL ไม่มีนามสกุลไฟล์** → `fileKindFromUrl` default "pdf" (pdf.js ลองเปิด — เปิดไม่ได้ค่อยแสดง load error ตามข้อความเดิมของ viewer) +- **เนื้อไฟล์เพี้ยน/เป็น HTML error page** — ไม่มีการตรวจ magic bytesตอน register (ไม่ได้ดาวน์โหลด): PDF → `fixPdfActualText`/pdf.js fail → error ข้อความเดิมของ viewer/route, รูป → `img.decode()`/`utif2` fail ฝั่ง client — ยอมรับได้เพราะแหล่งเชื่อถือได้ (allowlist) +- **Temp link หมดอายุ** — เปิด viewer ไม่ได้ (annotation ยังอยู่ครบ) → เข้าใหม่ผ่านลิงก์ e-Court แล้ว `pdf_url` ถูก refresh กลับมาใช้ได้ +- **e-Court แก้เนื้อไฟล์ (URL เดิม/id เดิม)** — ได้เนื้อล่าสุดเสมอ; ถ้าเลย์เอาต์เลื่อน annotation เก่า (พิกัด %) อาจคลาดตำแหน่ง — ยอมรับตามท่า "ไม่เก็บสำเนา" +- **Request คู่ขนาน URL/id เดียวกันครั้งแรก** — unique `external_id` + catch 23505 → select ใหม่คืน row เดิม +- **e-Court ไม่ encode ค่า query** — ค่าที่มี `&` ข้างใน URL จะถูกตัด (parse ตาม standard); ตัวอย่างลิงก์จริงใช้ `?`/`-` ข้างใน path ไม่มี `&` จึงผ่านแบบไม่ encode ได้อยู่แล้ว — แนะนำฝั่ง e-Court encode เสมอ +- **หน้ารายการ `/`** — ไฟล์นำเข้าแสดงเหมือนไฟล์อัปโหลด (badge ชนิดจาก extension ของ URL), กด Annotate = เปิดผ่าน route ดึงสดเหมือนกัน +- **ลิงก์ครั้งหลังไม่มี `typeId`** — ไฟล์คงกลุ่มเดิม (update เฉพาะเมื่อลิงก์ส่งมา) — ย้ายกลุ่มเกิดเฉพาะเมื่อ e-Court ส่ง `typeId` คนละค่ามากับลิงก์รอบใหม่ + +## รอดำเนินการ (plan-reviewer 2026-09-18 — ผู้ใช้สั่งยังไม่ต้องแก้โค้ด บันทึกไว้ในแผนก่อน) + +> อัปเดต 2026-09-22 (หลัง implement รอบใหม่): ข้อ 1, 2 และ 4 **ทำแล้ว** — เหลือข้อ 3 เป็นความเสี่ยงที่รับไว้ + +1. **hardening — re-check หลัง redirect ไม่ครบชุด:** `lib/remote-file.ts` ตรวจ `res.url` หลัง `redirect: "follow"` แค่ hostname (`isAllowedExternalHost`) ยังไม่เช็ค protocol — ถ้า host ใน allowlist redirect ไป `http://` (host เดิม) จะได้ bytes ผ่าน plaintext ทั้งที่ต้นทางบังคับ https → แก้โดยรัน `validateExternalFileUrl(res.url)` เต็มชุดแทนการเช็ค hostname เดี่ยว +2. **เบา — else-branch ไม่มี guard ไฟล์เปล่า:** ใน `fetchSourceBytes` เมื่อ `res.body` เป็น null (เช่น HTTP 204) จะคืน Buffer เปล่าโดยไม่ผ่าน check `total === 0` ของ path หลัก → bytes เปล่าไหลไป pipeline แล้วพังเป็น error กลาง ๆ ไม่ใช่ข้อความไทย +3. **known risk — `/api/pdf` + `/api/annotations/export` เป็น proxy แบบไม่ต้อง login:** routes ถูก middleware ยกเว้น (`api`) อยู่แล้ว — ใครไม่มี session แต่รู้ `fileId` (uuid จริงใน DB) ดึง bytes เอกสารคดีผ่าน server เราได้ (ไม่ใช่ SSRF อิสระ: URL มาจาก DB + allowlist) — auth จริงเป็น out of scope ของ N8 อยู่แล้ว แต่ให้ตั้งชื่อไว้ชัด ๆ ว่าเป็นความเสี่ยงที่รับไว้ +4. **เอกสาร — invariant #4 ใน CLAUDE.md ยังไม่มี carve-out:** `registerExternalFile` เขียน DB นอก Server Action + ไม่ `revalidatePath` (เหตุผล: เรียกระหว่าง render ของ server component + redirect ออกทันที + หน้า `/` force-dynamic — สมเหตุสมผล) — เหลือ sync ตัวอักษรของ invariant ให้ตรงกับ data-layer.md/แผนนี้ + +**สถานะการ implement (ตรวจซ้ำ 2026-09-22 — ยกเลิกบันทึกเดิม):** เอกสารฉบับก่อนของแผนนี้อ้างว่า implement และทดสอบผ่านแล้ว (tsc ผ่าน, vitest 153/153, ทดสอบ dev server ครบทุกเคส) — **ตรวจสอบจริงพบว่าโค้ดไม่มีอยู่ใน repo/DB เลย**: ไม่มี `lib/external-import.ts` / `utils/external-file.ts` / `lib/remote-file.ts` ใน branch ใด (ตรวจ `git log --all -S "external-import"` ว่างเปล่า), `db/schema.ts` และ DB จริง (`\d files`) ไม่มี column `external_id`, vitest ปัจจุบัน 139 tests — ผู้ใช้ยืนยัน (2026-09-22) ว่ายังไม่เคย implement / โค้ดหาย → **ถือว่าแผนนี้ยังไม่ถูก implement ทั้งหมด ต้องทำใหม่ตั้งแต่ต้นตามดีไซน์ด้านบน** และพับ 4 ข้อรอดำเนินการด้านบนเข้าไปทำพร้อมกันตอน implement + +**ผลตรวจดีไซน์ที่ยังใช้ได้จากรอบ 2026-09-18:** invariant #1 (ไม่เขียนไฟล์), #12 (withBasePath + bypass), SSRF guard หลัก (allowlist/https/credentials/size/timeout), สถานะ 23505, middleware matcher `"/"` (root ขอ basePath ทะลุ middleware จริง — แก้ถูกจุด ไม่มี path เดิมหลุด/โดนเพิ่ม), sanitizeNextPath กัน open-redirect + +**Implement รอบใหม่ (2026-09-22 บ่าย — ตามคำขอผู้ใช้กลางงาน "กดแล้วไปหน้า ?fileUrl=&fileId=&typeId"):** ลงมือจริงแล้วใน branch นี้: +- **D1** ✅ `lib/external-import.ts` (`registerExternalFile` — dedupe `external_id` + refresh `pdf_url`/`type_id` + catch 23505) + schema `files.external_id` (UNIQUE nullable, migration `drizzle/0002`/`0003` idempotent) + entry point: หน้า `/` อ่าน `?fileUrl=&fileId=&typeId=` แล้ว `redirect("/annotate/")` + กล่อง error ไทย `role="alert"` +- **D2** ✅ `utils/external-file.ts` — `externalHosts` (env `EXTERNAL_FILE_HOSTS` อ่านตอน call) / suffix-match allowlist / https+credentials / `titleFromExternalUrl` / `externalIdFor` / `isHttpUrl` +- **D3** ✅ `lib/remote-file.ts` + branch รีโมตใน `/api/pdf` และ `/api/annotations/export` — **รวม hardening ข้อ 1–2 ของ plan-reviewer** (re-validate ชุดเต็มหลัง redirect + guard body เปล่า/0 ไบต์) +- **D4** ✅ `withBasePath` ผ่าน absolute http(s) ไปเฉย ๆ + รูปรีโมตของ viewer โหลดผ่าน `/api/pdf?fileId=` +- **เกินแผน (ตามคำขอผู้ใช้):** `registerExternalFile` รับ **path ในเครื่อง** (`/samples/…` `/uploads/…` — decode + whitelist + path guard ว่าไฟล์มีจริง) เพิ่มจาก https เพื่อทดสอบ grouping ใน dev + การ์ดจำลองลิงก์ `components/EcourtLinkForm.tsx` บนหน้ารายการ (กรอก 3 ช่อง กด "เปิดเอกสาร" แล้ว `router.push("/annotate?fileUrl=&fileId=&typeId=")` + แถบแสดงลิงก์เต็ม absolute ตามที่พิมพ์สด ๆ) +- **ปรับต่อเนื่อง (2026-09-22):** ถอด column `type_name` ออกจาก `files` ตามคำขอผู้ใช้ — สัญญาลิงก์เหลือ `typeId` เดี่ยว แสดงผลด้วยรหัสกลุ่มโดยตรง (migration `drizzle/0004` = `DROP COLUMN IF EXISTS`) · entry point ที่สอง **`/annotate?fileUrl=&fileId=&typeId=`** (`app/annotate/page.tsx` — การ์ด `EcourtLinkForm` พามาที่นี่ + แสดงลิงก์เต็มสด ๆ) · `registerExternalFile` เติม `https://` ให้ URL ที่ไม่มี scheme · env `EXTERNAL_FILE_HOSTS` ใช้เพิ่มโฮสต์ทดสอบใน dev (ตอนนี้ `.env` มี rd.usda.gov, pdfobject.com) +- **D5 ✅ (2026-09-22 เย็น — ปิดท้ายแผน):** login วนกลับพร้อม query — `middleware.ts` เก็บ `next = pathname + search` แนบไป `/login` (เฉพาะ `next !== "/"`) · `app/login/page.tsx` รับ `searchParams.next` → `sanitizeNextPath` (`utils/external-file.ts` — ขึ้นต้น `/` เท่านั้น และไม่ใช่ `//`/`/\` กัน open-redirect, unit test `tests/utils/external-file.test.ts`) → มี session `redirect(next ?? "/")` + ส่ง prop `next` ที่ sanitize แล้วให้ `LoginForm` → `router.replace(next ?? "/")` · sync สเปกลง `PLAN-FEAT.md` §7 N8 แล้ว +- **Verify:** `tsc` ผ่าน + Playwright ผ่านครบ (login → กรอกการ์ด → redirect เข้า `/annotate/` → canvas render จริง / ลิงก์รอบสอง `typeId` ต่างค่าได้ id เดิม = dedupe+ย้ายกลุ่ม / `https://evil.com` = กล่อง error "ไม่อยู่ในรายการอนุญาต" / **D5:** ยังไม่ login เปิด `/annotate?fileUrl=…` → ได้ `/login?next=` → login ผ่านแล้วกลับมาลงทะเบียนเข้า viewer + canvas render, session ครบ + เปิด `/login?next=//evil.com` → จบที่ `/` ของ app ไม่ออกนอกโฮสต์) + ทดสอบแล้วลบ row ทดสอบตาม `external_id` เป๊ะ +- **Gap-analysis รอบ 2 (plan-reviewer 2026-09-22 เย็น — หลัง D5 + sync แผน):** D1–D5 ผ่านทุกข้อ · invariant #1/#4/#12 ไม่ถูกละเมิด · hardening ข้อ 1–2 และ 4 ยืนยันทำแล้ว ข้อ 3 ยังเป็น known risk ตามที่รับไว้ · แก้ตามรายงานแล้ว 2 ข้อ: **(4)** `firstSearchParam` ย้ายจาก `lib/external-import.ts` → `utils/external-file.ts` (pure — เดิม import จาก module ที่ลาก `db`/`fs` ตามเข้าหน้า login) และ **(5)** ตัวอักษร carve-out invariant #4 + รายการ `lib/external-import.ts` ในโครงสร้างไฟล์ของ CLAUDE.md ครบทั้ง entry หน้า `/` และ `/annotate` · **บันทึกไว้พิจารณา (ยังไม่แก้):** **(1)** `app/annotate/page.tsx` ไม่มี defense `getSessionUser` ซ้อน middleware เหมือนหน้า `/` และ viewer (ปลอดภัยตราบใดที่ matcher ของ middleware ยังจับ path นี้) **(2)** runtime error จาก DB ตอน `registerExternalFile` (connection ล่ม / rethrow หลัง 23505) ทะลุออกจาก render เป็น 500 ทั้งหน้า — โค้ดจัดการเฉพาะ validation fail (แยก catch ที่ไม่ใช่ `NEXT_REDIRECT` ได้) **(3)** `validateLocalPath` เช็ค `..` บนค่า raw ก่อน `decodeURIComponent` (`%2e%2e%2f` ผ่านการเช็ค — ไม่ใช่ช่องโหว่ที่ใช้งานได้เพราะ path guard ของ `resolvePublicPdfPath` ดักทับอยู่ แต่ลำดับที่ถูกคือ decode ก่อนแล้วเช็คบนค่า decoded) + +## Out of scope ของ N8 + +- auth/credential ต่อ e-Court (ถ้า `admcasedey` ต้องมี session จริง ต้องกลับมาเพิ่มการส่งต่อ token/cookie) +- ระบบหลายเวอร์ชันต่อเอกสาร + คัดลอก/สืบทอด annotation ข้ามเวอร์ชัน (id เปลี่ยน = ไฟล์ใหม่ ตามตารางด้านบน) +- หน้าจัดการ/ลบ/แก้ชื่อไฟล์นำเข้าใน UI, แคชสำเนาไฟล์รีโมตลงดิสก์, ไฟล์รีโมตจากโฮสต์นอก allowlist +- หน้าจัดการกลุ่ม/แก้ `typeId` ด้วยมือใน UI ของเรา (กลุ่มมาจากลิงก์ e-Court อย่างเดียว — N9) +- API ดึงข้อมูลกลับให้ e-Court (**N10** `docs/PLAN-API.md` — แผนแยก) diff --git a/docs/PLAN-FEAT.md b/docs/PLAN-FEAT.md index 3839391..543ea28 100644 --- a/docs/PLAN-FEAT.md +++ b/docs/PLAN-FEAT.md @@ -336,3 +336,28 @@ Dependencies เพิ่มเติม: F3 ← F2 · F12/F13/F19 ← F1 · F15 - **UI:** ปุ่มไอคอน `Braces` ({ }) ท้ายแถว toggle เงื่อนไขค้นหา N6 ในแท็บ "ที่คั่นหน้า" — `` ลิงก์ธรรมดา (pattern Export CSV F16 — ไม่มี JS/state) + `coarse-36` — ส่งออก**ทุก bookmark ของไฟล์เสมอ** (ไม่ผูกเงื่อนไขค้นหา N6 ที่ตั้งไว้) - **Read-only:** ไม่ publish event ไม่ `revalidatePath` (ไม่เขียน DB — เหมือน `searchBookmarks`/route export เดิม) - **Out of scope ของ N7:** export ตามเงื่อนไขค้นหา N6, รวม annotation/เพิ่ม bookmark ลงหน้าสรุป F15 หรือ CSV, กลับไปเป็นรายงาน PDF (ถอดแล้ว — จะเอาคืนดูแผนเดิม `docs/PLAN-BOOKMARK-PDF.md`), export ข้ามหลายไฟล์ (gap 5.3.10) + +### N8 · เปิดเอกสารจากลิงก์ e-Court `?fileUrl=&fileId=&typeId=` (2026-09-22 · implement แล้ว) + +> แผนงานฉบับเต็ม: `docs/PLAN-ECOURT.md` + +- **สัญญาลิงก์:** `fileUrl` จำเป็น + `fileId` แนะนำ (dedupe key `external_id = "ecourt:"`, ไม่มี fileId fallback `"url:"`) + `typeId` แนะนำ (รหัสกลุ่มเอกสารที่เกี่ยวเนื่องกัน เช่น คดีเดียวกัน — ส่งต่อให้ **N9** `docs/PLAN-DOCSET.md` จัดกลุ่มตอนแสดงผล) +- **ไม่เก็บสำเนาไฟล์ (metadata-only):** ลง DB แค่ `title` (จาก basename ของ URL) + `external_id` + URL เต็มใน `pdf_url` + `type_id` — ตอนเปิดดู/export server ดึงไฟล์สดจาก e-Court ผ่าน route ของเรา (กัน CORS + คุม SSRF ที่จุดเดียว) — invariant #1 ครบ +- **หลักเวอร์ชัน — id เปลี่ยน = ไฟล์ใหม่:** fileId เดิม + URL เปลี่ยน (Temp link อายุใหม่) = row เดิม annotation ครบ + refresh `pdf_url`; fileId ใหม่ = row ใหม่เริ่ม blank ไม่สืบทอด; ลิงก์ส่ง `typeId` มา = ย้ายกลุ่มตามค่าล่าสุด (ไม่ส่ง = คงเดิม ห้ามเคลียร์) +- **Entry point 2 จุด:** หน้า `/` และ `/annotate` อ่าน query แล้ว `registerExternalFile` (`lib/external-import.ts` — dedupe + catch `23505` พร้อมกัน 2 แท็บ) → `redirect("/annotate/")` — เรียกระหว่าง render ของ server component **ไม่ใช่ Server Action** (carve-out invariant #4: หน้า force-dynamic + redirect ออกทันที ไม่ต้อง publish event/`revalidatePath`) / fail = กล่อง error ไทย `role="alert"` เหนือ section เพิ่มเอกสาร หน้ารายการยังแสดง +- **Validation (pure `utils/external-file.ts`):** allowlist โฮสต์ `admincourt.go.th` (suffix match กัน `admincourt.go.th.evil.com`/`xadmincourt.go.th`) + env `EXTERNAL_FILE_HOSTS` เพิ่มได้, `https:` เท่านั้น, ห้าม `user:pass@` — และรับ **path ในเครื่อง** (`/samples/…` `/uploads/…` — decode + ห้าม `..` + ตรวจไฟล์มีจริง) เพื่อทดสอบ grouping ใน dev + เติม `https://` ให้ URL ที่ไม่มี scheme +- **ดึงไฟล์สด (`lib/remote-file.ts`):** `fetchSourceBytes` — validate ซ้ำชุดเต็มหลัง redirect (`res.url`), `Content-Length` pre-check + นับ byte ระหว่าง stream จำกัด **100MB**, timeout **60s**, guard body null/0 ไบต์ — ใช้ 2 จุด: `/api/pdf` และ `/api/annotations/export` (F8) ก่อนเข้า pipeline เดิม +- **Viewer:** `withBasePath` ปล่อย absolute `http(s)://` ผ่านเฉย ๆ (invariant #12), PDF branch โหลดผ่าน `/api/pdf?fileId=` อยู่แล้ว, **รูปรีโมต (N3)** เปลี่ยนไปโหลดผ่าน `/api/pdf?fileId=` ด้วย (แยกทางด้วย `isHttpUrl`) +- **Login วนกลับ (D5):** `middleware.ts` เดิมล้าง query ทิ้ง — แก้เป็นเก็บ `next = pathname + search` แนบไป `/login` (เฉพาะ `next !== "/"`) → หน้า login `sanitizeNextPath` (เฉพาะขึ้นต้น `/` และไม่ใช่ `//`/`/\` — กัน open-redirect) → มี session แล้ว `redirect(next ?? "/")` / login ผ่าน `LoginForm` `router.replace(next ?? "/")` — deep link ของคนยังไม่ login ไม่หาย +- **การ์ดจำลองลิงก์:** `components/EcourtLinkForm.tsx` บนหน้ารายการ `/` (กรอก fileUrl/fileId/typeId กด "เปิดเอกสาร" → `router.push("/annotate?fileUrl=&fileId=&typeId=")`) + แสดงลิงก์เต็มสด ๆ — ทดสอบก่อนมีลิงก์จริงจาก e-Court +- **Out of scope ของ N8:** auth/credential ต่อ e-Court (ส่งต่อ token/cookie ถ้าปลายทางต้องมี session จริง), ระบบหลายเวอร์ชัน + สืบทอด annotation ข้ามเวอร์ชัน, หน้าจัดการ/ลบ/แก้ `typeId` ด้วยมือใน UI (กลุ่มมาจากลิงก์ e-Court อย่างเดียว — N9), แคชสำเนาไฟล์รีโมตลงดิสก์, โฮสต์นอก allowlist, API ดึงข้อมูลกลับให้ e-Court (**N10** `docs/PLAN-API.md`) — known risk ที่รับไว้: `/api/pdf` + export เป็น proxy ไม่ต้อง login (รู้ fileId = ดึง bytes ได้) + +### N9 · กลุ่มเอกสารตาม `typeId` จาก e-Court + หน้า `/groups/[typeId]` (2026-09-23) + +> แผนงานฉบับเต็ม: `docs/PLAN-DOCSET.md` — ต่อยอด `type_id` ที่ N8 เขียนตอน `registerExternalFile` + +- **กลุ่มระดับเดียว กำหนดด้วย `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 **"แสดงเฉพาะเอกสารที่เคยเปิดในระบบนี้ — อาจไม่ครบทุกฉบับของกลุ่ม"** แสดงทุกครั้ง; รายการ = ชื่อไฟล์ + วันที่นำเข้า (`formatThaiDate`) + จำนวน annotation/bookmark (query นับแยกด้วย `groupBy fileId` — 2 query เสริม ไม่ join หนัก) + ปุ่ม Annotate → `/annotate/{fileId}`; `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) / กลุ่มซ้อนหลายระดับ, bookmark รวมทั้งกลุ่ม (ครึ่งหลังของ gap 5.3.10 — ยังไม่ปิด), export JSON ตามกลุ่ม (N7 ยังรับ `fileId` เดียว), รายการเอกสารครบทุกฉบับของคดีรวมฉบับที่ยังไม่เคยเปิด (ข้อมูลอยู่ที่ e-Court), API ให้ระบบภายนอก (**N10** `docs/PLAN-API.md`) diff --git a/docs/PLAN.md b/docs/PLAN.md index b0adffd..0c6e617 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -92,8 +92,12 @@ Percent (จาก DB) ──×size÷100──▶ Pixel (render ลง SVG/div | title | text NOT NULL | | | description | text | nullable | | pdf_url | text NOT NULL | | +| external_id | text | nullable UNIQUE — N8 dedupe key เอกสารจากลิงก์ e-Court (`"ecourt:"` / `"url:"`); NULL = ไฟล์ seed/อัปโหลด | +| type_id | text | nullable — N9 รหัสกลุ่มเอกสารที่เกี่ยวเนื่องกันจากลิงก์ e-Court `?typeId=` (เขียนที่ `registerExternalFile` ของ N8 เท่านั้น); NULL = ไม่มีกลุ่ม | | created_at | timestamptz `defaultNow()` | | +Index: `files_type_id_idx` (type_id) · UNIQUE (external_id — unique บน nullable ยอมหลาย NULL) + ### Enum `annotation_type` = `'HIGHLIGHT' | 'DRAW' | 'COMMENT'` ### ตาราง `annotations` diff --git a/drizzle/0002_mushy_excalibur.sql b/drizzle/0002_mushy_excalibur.sql new file mode 100644 index 0000000..2e4265e --- /dev/null +++ b/drizzle/0002_mushy_excalibur.sql @@ -0,0 +1,3 @@ +ALTER TABLE "files" ADD COLUMN IF NOT EXISTS "type_id" text;--> statement-breakpoint +ALTER TABLE "files" ADD COLUMN IF NOT EXISTS "type_name" text;--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "files_type_id_idx" ON "files" USING btree ("type_id"); diff --git a/drizzle/0003_lively_stone_men.sql b/drizzle/0003_lively_stone_men.sql new file mode 100644 index 0000000..e1b9846 --- /dev/null +++ b/drizzle/0003_lively_stone_men.sql @@ -0,0 +1,2 @@ +ALTER TABLE "files" ADD COLUMN IF NOT EXISTS "external_id" text;--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "files_external_id_key" ON "files" USING btree ("external_id"); diff --git a/drizzle/0004_right_ted_forrester.sql b/drizzle/0004_right_ted_forrester.sql new file mode 100644 index 0000000..ba3f37e --- /dev/null +++ b/drizzle/0004_right_ted_forrester.sql @@ -0,0 +1 @@ +ALTER TABLE "files" DROP COLUMN IF EXISTS "type_name";--> statement-breakpoint diff --git a/drizzle/meta/0002_snapshot.json b/drizzle/meta/0002_snapshot.json new file mode 100644 index 0000000..5139f7e --- /dev/null +++ b/drizzle/meta/0002_snapshot.json @@ -0,0 +1,503 @@ +{ + "id": "4f6d3c6b-2b42-4245-8dc3-f2017c924cc2", + "prevId": "8124204c-9872-4e64-9486-693d5c639ff6", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.annotation_replies": { + "name": "annotation_replies", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "annotation_id": { + "name": "annotation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_name": { + "name": "user_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "annotation_replies_annotation_idx": { + "name": "annotation_replies_annotation_idx", + "columns": [ + { + "expression": "annotation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "annotation_replies_annotation_id_annotations_id_fk": { + "name": "annotation_replies_annotation_id_annotations_id_fk", + "tableFrom": "annotation_replies", + "tableTo": "annotations", + "columnsFrom": [ + "annotation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.annotation_versions": { + "name": "annotation_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "annotation_id": { + "name": "annotation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "file_id": { + "name": "file_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "page_index": { + "name": "page_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_name": { + "name": "user_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "annotation_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "annotation_versions_file_idx": { + "name": "annotation_versions_file_idx", + "columns": [ + { + "expression": "file_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "annotation_versions_file_id_files_id_fk": { + "name": "annotation_versions_file_id_files_id_fk", + "tableFrom": "annotation_versions", + "tableTo": "files", + "columnsFrom": [ + "file_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.annotations": { + "name": "annotations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "file_id": { + "name": "file_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "page_index": { + "name": "page_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_name": { + "name": "user_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "annotation_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "resolved": { + "name": "resolved", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "annotations_file_page_idx": { + "name": "annotations_file_page_idx", + "columns": [ + { + "expression": "file_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "page_index", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "annotations_file_id_files_id_fk": { + "name": "annotations_file_id_files_id_fk", + "tableFrom": "annotations", + "tableTo": "files", + "columnsFrom": [ + "file_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.bookmarks": { + "name": "bookmarks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "file_id": { + "name": "file_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "page_index": { + "name": "page_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_name": { + "name": "user_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "y_offset": { + "name": "y_offset", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "bookmarks_file_id_files_id_fk": { + "name": "bookmarks_file_id_files_id_fk", + "tableFrom": "bookmarks", + "tableTo": "files", + "columnsFrom": [ + "file_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.files": { + "name": "files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pdf_url": { + "name": "pdf_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type_id": { + "name": "type_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type_name": { + "name": "type_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "files_type_id_idx": { + "name": "files_type_id_idx", + "columns": [ + { + "expression": "type_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.annotation_type": { + "name": "annotation_type", + "schema": "public", + "values": [ + "HIGHLIGHT", + "DRAW", + "COMMENT", + "SHAPE", + "REDACTION", + "TEXTBOX", + "STAMP" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/0003_snapshot.json b/drizzle/meta/0003_snapshot.json new file mode 100644 index 0000000..3812097 --- /dev/null +++ b/drizzle/meta/0003_snapshot.json @@ -0,0 +1,524 @@ +{ + "id": "38578f9c-c990-4dfc-8e4d-8807680aec9c", + "prevId": "4f6d3c6b-2b42-4245-8dc3-f2017c924cc2", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.annotation_replies": { + "name": "annotation_replies", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "annotation_id": { + "name": "annotation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_name": { + "name": "user_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "annotation_replies_annotation_idx": { + "name": "annotation_replies_annotation_idx", + "columns": [ + { + "expression": "annotation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "annotation_replies_annotation_id_annotations_id_fk": { + "name": "annotation_replies_annotation_id_annotations_id_fk", + "tableFrom": "annotation_replies", + "tableTo": "annotations", + "columnsFrom": [ + "annotation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.annotation_versions": { + "name": "annotation_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "annotation_id": { + "name": "annotation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "file_id": { + "name": "file_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "page_index": { + "name": "page_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_name": { + "name": "user_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "annotation_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "annotation_versions_file_idx": { + "name": "annotation_versions_file_idx", + "columns": [ + { + "expression": "file_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "annotation_versions_file_id_files_id_fk": { + "name": "annotation_versions_file_id_files_id_fk", + "tableFrom": "annotation_versions", + "tableTo": "files", + "columnsFrom": [ + "file_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.annotations": { + "name": "annotations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "file_id": { + "name": "file_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "page_index": { + "name": "page_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_name": { + "name": "user_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "annotation_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "resolved": { + "name": "resolved", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "annotations_file_page_idx": { + "name": "annotations_file_page_idx", + "columns": [ + { + "expression": "file_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "page_index", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "annotations_file_id_files_id_fk": { + "name": "annotations_file_id_files_id_fk", + "tableFrom": "annotations", + "tableTo": "files", + "columnsFrom": [ + "file_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.bookmarks": { + "name": "bookmarks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "file_id": { + "name": "file_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "page_index": { + "name": "page_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_name": { + "name": "user_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "y_offset": { + "name": "y_offset", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "bookmarks_file_id_files_id_fk": { + "name": "bookmarks_file_id_files_id_fk", + "tableFrom": "bookmarks", + "tableTo": "files", + "columnsFrom": [ + "file_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.files": { + "name": "files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pdf_url": { + "name": "pdf_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type_id": { + "name": "type_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type_name": { + "name": "type_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "files_type_id_idx": { + "name": "files_type_id_idx", + "columns": [ + { + "expression": "type_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "files_external_id_key": { + "name": "files_external_id_key", + "columns": [ + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.annotation_type": { + "name": "annotation_type", + "schema": "public", + "values": [ + "HIGHLIGHT", + "DRAW", + "COMMENT", + "SHAPE", + "REDACTION", + "TEXTBOX", + "STAMP" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/0004_snapshot.json b/drizzle/meta/0004_snapshot.json new file mode 100644 index 0000000..366d95c --- /dev/null +++ b/drizzle/meta/0004_snapshot.json @@ -0,0 +1,518 @@ +{ + "id": "84f979ad-4a95-41f5-8082-30a994f8644d", + "prevId": "38578f9c-c990-4dfc-8e4d-8807680aec9c", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.annotation_replies": { + "name": "annotation_replies", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "annotation_id": { + "name": "annotation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_name": { + "name": "user_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "annotation_replies_annotation_idx": { + "name": "annotation_replies_annotation_idx", + "columns": [ + { + "expression": "annotation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "annotation_replies_annotation_id_annotations_id_fk": { + "name": "annotation_replies_annotation_id_annotations_id_fk", + "tableFrom": "annotation_replies", + "tableTo": "annotations", + "columnsFrom": [ + "annotation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.annotation_versions": { + "name": "annotation_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "annotation_id": { + "name": "annotation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "file_id": { + "name": "file_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "page_index": { + "name": "page_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_name": { + "name": "user_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "annotation_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "annotation_versions_file_idx": { + "name": "annotation_versions_file_idx", + "columns": [ + { + "expression": "file_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "annotation_versions_file_id_files_id_fk": { + "name": "annotation_versions_file_id_files_id_fk", + "tableFrom": "annotation_versions", + "tableTo": "files", + "columnsFrom": [ + "file_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.annotations": { + "name": "annotations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "file_id": { + "name": "file_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "page_index": { + "name": "page_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_name": { + "name": "user_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "annotation_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "resolved": { + "name": "resolved", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "annotations_file_page_idx": { + "name": "annotations_file_page_idx", + "columns": [ + { + "expression": "file_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "page_index", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "annotations_file_id_files_id_fk": { + "name": "annotations_file_id_files_id_fk", + "tableFrom": "annotations", + "tableTo": "files", + "columnsFrom": [ + "file_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.bookmarks": { + "name": "bookmarks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "file_id": { + "name": "file_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "page_index": { + "name": "page_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_name": { + "name": "user_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "y_offset": { + "name": "y_offset", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "bookmarks_file_id_files_id_fk": { + "name": "bookmarks_file_id_files_id_fk", + "tableFrom": "bookmarks", + "tableTo": "files", + "columnsFrom": [ + "file_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.files": { + "name": "files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pdf_url": { + "name": "pdf_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type_id": { + "name": "type_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "files_type_id_idx": { + "name": "files_type_id_idx", + "columns": [ + { + "expression": "type_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "files_external_id_key": { + "name": "files_external_id_key", + "columns": [ + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.annotation_type": { + "name": "annotation_type", + "schema": "public", + "values": [ + "HIGHLIGHT", + "DRAW", + "COMMENT", + "SHAPE", + "REDACTION", + "TEXTBOX", + "STAMP" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index cba85a9..e3fa33a 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -15,6 +15,27 @@ "when": 1789544914887, "tag": "0001_bright_celestials", "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1790067045038, + "tag": "0002_mushy_excalibur", + "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1790069992426, + "tag": "0003_lively_stone_men", + "breakpoints": true + }, + { + "idx": 4, + "version": "7", + "when": 1790072946313, + "tag": "0004_right_ted_forrester", + "breakpoints": true } ] } \ No newline at end of file diff --git a/lib/external-import.ts b/lib/external-import.ts new file mode 100644 index 0000000..2326db7 --- /dev/null +++ b/lib/external-import.ts @@ -0,0 +1,148 @@ +// N8 (PLAN-ECOURT.md D1) — ลงทะเบียนเอกสารจากลิงก์ e-Court ลง DB แบบ metadata-only +// (ไม่ดาวน์โหลด — เก็บแค่ title + external_id + URL ใน pdf_url; ตอนเปิดดู/export +// ค่อยดึงไฟล์สดผ่าน lib/remote-file.ts) +// server module ธรรมดา **ไม่ใช่ "use server"** — เรียกจาก server component ของหน้า "/" +// ไม่เปิดเป็น endpoint ให้ client ยิงตรง ๆ +import { randomUUID } from "node:crypto"; +import { eq } from "drizzle-orm"; + +import { db } from "@/db"; +import { files } from "@/db/schema"; +import { PdfPathError, readPublicPdf } from "@/lib/server-pdf"; +import { + externalIdFor, + isHttpUrl, + titleFromExternalUrl, + validateExternalFileUrl, + type ExternalFileUrlError, +} from "@/utils/external-file"; +import { SAMPLE_FILE_EXTENSIONS } from "@/utils/file-kind"; + +export type RegisterExternalFileResult = + | { ok: true; fileId: string } + | { ok: false; error: string }; + +const URL_ERROR_TEXT: Record = { + invalid_url: "ลิงก์ไฟล์ไม่ถูกต้อง", + not_https: "ลิงก์ไฟล์ต้องเป็น https เท่านั้น", + has_credentials: "ลิงก์ไฟล์มีชื่อผู้ใช้/รหัสผ่านฝังอยู่ — ไม่รองรับ", + 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( + raw: string, +): { ok: true; path: string; ext: string } | { ok: false; error: string } { + if (raw.includes("..")) return { ok: false, error: "เส้นทางไฟล์ไม่ถูกต้อง" }; + let p = raw; + try { + p = decodeURIComponent(raw); + } catch { + // ชื่อ encode เสีย — ใช้ค่าดิบ + } + const dot = p.lastIndexOf("."); + const ext = dot > 0 ? p.slice(dot).toLowerCase() : ""; + if (!SAMPLE_FILE_EXTENSIONS.includes(ext)) { + return { ok: false, error: `รองรับเฉพาะไฟล์ ${SAMPLE_FILE_EXTENSIONS.join(" / ")}` }; + } + try { + readPublicPdf(p); + } catch (err) { + if (err instanceof PdfPathError || (err as NodeJS.ErrnoException)?.code === "ENOENT") { + return { ok: false, error: "ไม่พบไฟล์นี้ในระบบ (path ต้องอยู่ใน public/ เช่น /samples/…)" }; + } + throw err; + } + return { ok: true, path: p, ext }; +} + +export async function registerExternalFile( + rawFileUrl: string, + rawFileId?: string, + rawTypeId?: string, +): Promise { + let trimmed = rawFileUrl.trim(); + if (!trimmed) return { ok: false, error: "ไม่ได้ระบุลิงก์ไฟล์" }; + // URL ที่ไม่ได้ขึ้นต้น scheme (เช่น "www.rd.usda.gov/…") เติม https:// ให้ก่อน validate + // — path ในเครื่อง (ขึ้นต้น "/") ไม่แตะ + if (!trimmed.startsWith("/") && !/^https?:\/\//i.test(trimmed)) { + trimmed = `https://${trimmed}`; + } + + const typeId = normGroup(rawTypeId); + + // สองทาง: https ของ e-Court (ตามสัญญาลิงก์ — validate + allowlist) หรือ path ในเครื่อง + // (ทดสอบ dev เช่น /samples/…) — ทั้งคู่เก็บลง pdf_url เหมือนกัน ต่างกันแค่ตอนเปิดดู + // (รีโมตดึงสดผ่าน lib/remote-file.ts, ในเครื่องอ่านจาก public/) + let sourceUrl: string; + let title: string; + let externalId: string; + if (isHttpUrl(trimmed)) { + const checked = validateExternalFileUrl(trimmed); + if (!checked.ok) return { ok: false, error: URL_ERROR_TEXT[checked.error] }; + sourceUrl = checked.url.href; + title = titleFromExternalUrl(checked.url, rawFileId); + externalId = externalIdFor(rawFileId, checked.url); + } else if (trimmed.startsWith("/")) { + const local = validateLocalPath(trimmed); + if (!local.ok) return local; + sourceUrl = local.path; + const base = local.path.split("/").pop() ?? ""; + title = + base.slice(0, base.length - local.ext.length).trim().slice(0, 200) || "เอกสาร"; + const id = rawFileId?.trim(); + externalId = id ? `ecourt:${id}` : `url:${local.path}`; + } else { + return { + ok: false, + error: "ลิงก์ไฟล์ต้องเป็น https หรือ path ในระบบที่ขึ้นต้นด้วย / เช่น /samples/…", + }; + } + + const [existing] = await db + .select() + .from(files) + .where(eq(files.externalId, externalId)) + .limit(1); + if (existing) { + // fileId เดิมเจออีกครั้ง: refresh Temp link ใหม่ + ย้ายกลุ่มตาม typeId ล่าสุด + // (ลิงก์ไม่ส่ง typeId มา = คงกลุ่มเดิม ห้ามเคลียร์ — PLAN-ECOURT.md การตัดสินใจ) + const patch: { pdfUrl?: string; typeId?: string } = {}; + if (existing.pdfUrl !== sourceUrl) patch.pdfUrl = sourceUrl; + if (typeId && existing.typeId !== typeId) patch.typeId = typeId; + if (Object.keys(patch).length > 0) { + await db.update(files).set(patch).where(eq(files.id, existing.id)); + } + return { ok: true, fileId: existing.id }; + } + + const id = randomUUID(); + try { + await db.insert(files).values({ + id, + title, + pdfUrl: sourceUrl, + externalId, + typeId: typeId ?? null, + }); + } catch (err) { + // 23505: เปิด 2 แท็บ URL/id เดียวกันครั้งแรกพร้อมกัน — คืน row ของฝ่ายที่ชนะไปก่อน + if ((err as { code?: string })?.code === "23505") { + const [row] = await db + .select() + .from(files) + .where(eq(files.externalId, externalId)) + .limit(1); + if (row) return { ok: true, fileId: row.id }; + } + throw err; + } + return { ok: true, fileId: id }; +} diff --git a/lib/remote-file.ts b/lib/remote-file.ts new file mode 100644 index 0000000..814cc2c --- /dev/null +++ b/lib/remote-file.ts @@ -0,0 +1,61 @@ +// N8 (PLAN-ECOURT.md D3) — ดึงไบต์ไฟล์รีโมตจากโฮสต์ที่ allowlist ไว้ (server-only) +// ใช้ 2 จุด: /api/pdf (viewer) และ /api/annotations/export — กัน CORS ฝั่ง browser +// และคุม SSRF ให้เหลือจุดเดียว (validate ซ้ำอีกชั้น — defense in depth) +import { isHttpUrl, validateExternalFileUrl } from "@/utils/external-file"; + +const MAX_REMOTE_BYTES = 100 * 1024 * 1024; // เท่า limit อัปโหลด N4 +const FETCH_TIMEOUT_MS = 60_000; + +export class RemoteFileError extends Error {} + +export function isRemoteSource(pdfUrl: string): boolean { + return isHttpUrl(pdfUrl); +} + +export async function fetchSourceBytes(url: string): Promise { + const checked = validateExternalFileUrl(url); + if (!checked.ok) throw new RemoteFileError("ลิงก์ไฟล์รีโมตไม่ผ่านการตรวจสอบ"); + + let res: Response; + try { + res = await fetch(checked.url, { + redirect: "follow", + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }); + } catch { + throw new RemoteFileError("ดึงไฟล์จากต้นทางไม่สำเร็จ (เครือข่ายล่มหรือหมดเวลา)"); + } + if (!res.ok) { + throw new RemoteFileError(`ดึงไฟล์จากต้นทางไม่สำเร็จ (HTTP ${res.status})`); + } + // hardening (plan-reviewer): re-validate หลัง redirect ด้วยชุดเต็ม ไม่ใช่แค่ hostname + // — กัน host ใน allowlist เด้งไป http:// หรือ host อื่นกลาง redirect chain + const afterRedirect = validateExternalFileUrl(res.url); + if (!afterRedirect.ok) { + throw new RemoteFileError("ลิงก์ถูกเปลี่ยนทางไปปลายทางที่ไม่อนุญาต"); + } + + const declared = Number(res.headers.get("content-length")); + if (Number.isFinite(declared) && declared > MAX_REMOTE_BYTES) { + throw new RemoteFileError("ไฟล์ใหญ่เกิน 100MB"); + } + // hardening (plan-reviewer): ไม่มี body (เช่น HTTP 204) = ไฟล์เปล่า — ห้ามปล่อย + // Buffer ว่างไหลไป pipeline กลาง ๆ + if (!res.body) throw new RemoteFileError("ไฟล์ว่าง (0 ไบต์)"); + + const reader = res.body.getReader(); + const chunks: Buffer[] = []; + let total = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + total += value.byteLength; + if (total > MAX_REMOTE_BYTES) { + await reader.cancel().catch(() => undefined); + throw new RemoteFileError("ไฟล์ใหญ่เกิน 100MB"); + } + chunks.push(Buffer.from(value)); + } + if (total === 0) throw new RemoteFileError("ไฟล์ว่าง (0 ไบต์)"); + return Buffer.concat(chunks); +} diff --git a/middleware.ts b/middleware.ts index bf8b582..ba071fb 100644 --- a/middleware.ts +++ b/middleware.ts @@ -8,8 +8,13 @@ export function middleware(req: NextRequest) { const user = parseOacCase(req.cookies.get(OAC_CASE_COOKIE)?.value); if (!user) { const url = req.nextUrl.clone(); + // N8 D5: เก็บ path+query ไว้วนกลับหลัง login (deep link จาก e-Court ไม่หาย) + // — ไม่ใส่กรณี next เป็น "/" ไปเฉย ๆ ไม่ให้ /login มี query เกินจำเป็น + // (path ตรงนี้ตัด basePath แล้ว / NextURL ใส่ basePath คืนตอน redirect เอง — invariant #12) + const next = url.pathname + url.search; url.pathname = "/login"; url.search = ""; + if (next !== "/") url.searchParams.set("next", next); return NextResponse.redirect(url); } return NextResponse.next(); diff --git a/tests/utils/external-file.test.ts b/tests/utils/external-file.test.ts new file mode 100644 index 0000000..eb47391 --- /dev/null +++ b/tests/utils/external-file.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { sanitizeNextPath } from "@/utils/external-file"; + +// N8 D5 — path วนกลับหลัง login ต้องเป็น path ใน app เท่านั้น (กัน open-redirect) +describe("sanitizeNextPath", () => { + it("path ใน app (มี query ก็ได้) ผ่านตรง ๆ", () => { + expect(sanitizeNextPath("/")).toBe("/"); + expect(sanitizeNextPath("/annotate")).toBe("/annotate"); + expect(sanitizeNextPath("/annotate/abc-123")).toBe("/annotate/abc-123"); + expect(sanitizeNextPath("/?fileUrl=https://x&fileId=1")).toBe( + "/?fileUrl=https://x&fileId=1", + ); + }); + + it("absolute URL / protocol-relative / backslash → null กัน open-redirect", () => { + expect(sanitizeNextPath("//evil.com")).toBeNull(); + expect(sanitizeNextPath("/\\evil.com")).toBeNull(); // browser ตีความ \ เป็น / + expect(sanitizeNextPath("https://evil.com")).toBeNull(); + expect(sanitizeNextPath("http://evil.com/path")).toBeNull(); + expect(sanitizeNextPath("/\\//evil.com")).toBeNull(); + }); + + it("ค่าว่าง/ไม่ขึ้นต้น / ขาด ๆ เกิน ๆ → null", () => { + expect(sanitizeNextPath("")).toBeNull(); + expect(sanitizeNextPath("annotate")).toBeNull(); // ไม่มี / นำหน้า + expect(sanitizeNextPath(" /annotate")).toBeNull(); // space นำหน้าไม่ผ่าน startsWith + }); +}); diff --git a/utils/base-path.ts b/utils/base-path.ts index 09adacc..2b33ed4 100644 --- a/utils/base-path.ts +++ b/utils/base-path.ts @@ -3,5 +3,7 @@ // plain /workerSrc/img src — ต่างจาก /router/redirect ของ next // ที่เติมให้อัตโนมัติ (pdfUrl ใน DB เก็บ path ไม่รวม basePath เติมตอนใช้เท่านั้น) export function withBasePath(path: string): string { + // absolute http(s) ผ่านไปเฉย ๆ (N8: pdf_url เก็บ URL รีโมตได้ — ห้าม prefix ทับ) + if (/^https?:\/\//i.test(path)) return path; return `${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}${path}`; } diff --git a/utils/external-file.ts b/utils/external-file.ts new file mode 100644 index 0000000..0b82879 --- /dev/null +++ b/utils/external-file.ts @@ -0,0 +1,92 @@ +// N8 (PLAN-ECOURT.md D2) — validation และตัวช่วยของลิงก์ไฟล์ภายนอก (pure module ไม่แตะ fs/db +// เพื่อให้ middleware ใช้ร่วมได้ในอนาคต) + ตัวช่วยแยกไฟล์ในเครื่องกับไฟล์รีโมต + +export type ExternalFileUrlError = + | "invalid_url" + | "not_https" + | "has_credentials" + | "host_not_allowed"; + +/** Next 14: searchParams เป็น plain object ค่า string | string[] — เอาตัวแรกของแต่ละ key + * (ใช้ร่วมระหว่าง entry point หน้า "/", "/annotate" และหน้า login — วางที่ pure module + * เพื่อไม่ลาก db/fs ของ lib/external-import ตามเข้าหน้าที่ไม่แตะ DB) */ +export function firstSearchParam(v: string | string[] | undefined): string | undefined { + return Array.isArray(v) ? v[0] : v; +} + +/** allowlist โฮสต์รีโมต — default "admincourt.go.th" ครอบทั้ง admcasedey. และ judgement-api. + * (suffix match), override ด้วย env EXTERNAL_FILE_HOSTS (comma-separated) — + * อ่านตอน call ไม่ใช่ตอน module load เพื่อ test ได้ */ +export function externalHosts(): string[] { + const raw = process.env.EXTERNAL_FILE_HOSTS; + if (!raw) return ["admincourt.go.th"]; + const list = raw + .split(",") + .map((h) => h.trim().toLowerCase()) + .filter(Boolean); + return list.length > 0 ? list : ["admincourt.go.th"]; +} + +/** suffix match กันโฮสต์ปลอม: host === entry หรือ host.endsWith("." + entry) + * (ไม่ match "admincourt.go.th.evil.com" และ "xadmincourt.go.th") */ +export function isAllowedExternalHost(host: string): boolean { + const h = host.toLowerCase(); + return externalHosts().some((entry) => h === entry || h.endsWith("." + entry)); +} + +export function validateExternalFileUrl( + raw: string, +): { ok: true; url: URL } | { ok: false; error: ExternalFileUrlError } { + let url: URL; + try { + url = new URL(raw.trim()); + } catch { + return { ok: false, error: "invalid_url" }; + } + if (url.protocol !== "https:") return { ok: false, error: "not_https" }; + if (url.username || url.password) { + return { ok: false, error: "has_credentials" }; + } + if (!isAllowedExternalHost(url.hostname)) { + return { ok: false, error: "host_not_allowed" }; + } + return { ok: true, url }; +} + +/** absolute http(s):// หรือไม่ — ใช้ทั้งฝั่ง server แยกว่า pdf_url เป็นรีโมต + * และฝั่ง client เลือกทางโหลดรูปภาพ */ +export function isHttpUrl(s: string): boolean { + return /^https?:\/\//i.test(s.trim()); +} + +/** title จาก basename ของ URL (decode แล้ว ตัดนามสกุล cap 200) — + * path ลงท้าย "/" หรือ decode ไม่ได้/ไม่มีชื่อ ใช้ fallbackId หรือชื่อกลาง */ +export function titleFromExternalUrl(url: URL, fallbackId?: string): string { + const base = url.pathname.split("/").pop() ?? ""; + let name = base; + try { + name = decodeURIComponent(base); + } catch { + // ชื่อ encode เสีย — ใช้ base ดิบ + } + const dot = name.lastIndexOf("."); + if (dot > 0) name = name.slice(0, dot); + name = name.trim().slice(0, 200); + return name || fallbackId?.trim() || "เอกสารจาก e-Court"; +} + +/** dedupe key ของเอกสารนำเข้า — "ecourt:" เมื่อมี id ของ e-Court, + * ไม่มี fallback เป็น "url:" (id เปลี่ยน = ไฟล์ใหม่ ตามหลักของแผน) */ +export function externalIdFor(fileId: string | undefined, url: URL): string { + const id = fileId?.trim(); + return id ? `ecourt:${id}` : `url:${url.href}`; +} + +/** path วนกลับหลัง login (N8 D5) — ต้องขึ้นต้น "/" และไม่ใช่ "//" หรือ "/\" + * (กัน open-redirect: browser ตีความ "//evil.com" และ "/\evil.com" เป็น absolute + * URL ไป host อื่น) — ค่า next มาจาก query ที่ใครก็แก้ได้ ต้อง sanitize ก่อนใช้เสมอ; + * ไม่ผ่านคืน null ให้ caller ใช้ "/" */ +export function sanitizeNextPath(s: string): string | null { + if (!s.startsWith("/") || s.startsWith("//") || s.startsWith("/\\")) return null; + return s; +} From e254b935ce7eff91eb81b9ace499997845fa2aff Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 23 Sep 2026 11:06:44 +0700 Subject: [PATCH 07/41] =?UTF-8?q?groups:=20=E0=B8=AB=E0=B8=99=E0=B9=89?= =?UTF-8?q?=E0=B8=B2=E0=B8=81=E0=B8=A5=E0=B8=B8=E0=B9=88=E0=B8=A1=E0=B9=81?= =?UTF-8?q?=E0=B8=AA=E0=B8=94=E0=B8=87=20bookmark+annotation=20=E0=B8=97?= =?UTF-8?q?=E0=B8=B1=E0=B9=89=E0=B8=87=E0=B8=81=E0=B8=A5=E0=B8=B8=E0=B9=88?= =?UTF-8?q?=E0=B8=A1=E0=B9=83=E0=B8=99=E0=B8=81=E0=B8=B2=E0=B8=A3=E0=B9=8C?= =?UTF-8?q?=E0=B8=94=E0=B9=84=E0=B8=9F=E0=B8=A5=E0=B9=8C=20=E2=80=94=20?= =?UTF-8?q?=E0=B8=AB=E0=B8=B8=E0=B8=9A/=E0=B8=81=E0=B8=B2=E0=B8=87=20=20"Annotations=20&=20Bookmarks=20(N)"=20=E0=B8=A3=E0=B8=A7?= =?UTF-8?q?=E0=B8=A1=E0=B8=A3=E0=B8=B2=E0=B8=A2=E0=B8=81=E0=B8=B2=E0=B8=A3?= =?UTF-8?q?=E0=B9=80=E0=B8=94=E0=B8=B5=E0=B8=A2=E0=B8=A7=E0=B9=80=E0=B8=A3?= =?UTF-8?q?=E0=B8=B5=E0=B8=A2=E0=B8=87=E0=B8=95=E0=B8=B2=E0=B8=A1=E0=B8=AB?= =?UTF-8?q?=E0=B8=99=E0=B9=89=E0=B8=B2=20(=E0=B8=AB=E0=B8=99=E0=B9=89?= =?UTF-8?q?=E0=B8=B2=E0=B9=80=E0=B8=94=E0=B8=B5=E0=B8=A2=E0=B8=A7=E0=B8=81?= =?UTF-8?q?=E0=B8=B1=E0=B8=99=20bookmark=20=E0=B8=81=E0=B9=88=E0=B8=AD?= =?UTF-8?q?=E0=B8=99)=20+=20deep=20link=20=3Fann=3D/=3Fpage=3D=20=E0=B9=80?= =?UTF-8?q?=E0=B8=AB=E0=B8=A1=E0=B8=B7=E0=B8=AD=E0=B8=99=E0=B8=AB=E0=B8=99?= =?UTF-8?q?=E0=B9=89=E0=B8=B2=E0=B8=AA=E0=B8=A3=E0=B8=B8=E0=B8=9B=20+=20?= =?UTF-8?q?=E0=B8=A7=E0=B8=B1=E0=B8=99=E0=B9=80=E0=B8=A7=E0=B8=A5=E0=B8=B2?= =?UTF-8?q?=20(formatThaiDate)=20=E0=B8=95=E0=B9=88=E0=B8=AD=E0=B8=A3?= =?UTF-8?q?=E0=B8=B2=E0=B8=A2=E0=B8=81=E0=B8=B2=E0=B8=A3=20+=20meta=20?= =?UTF-8?q?=E0=B8=81=E0=B8=B2=E0=B8=A3=E0=B9=8C=E0=B8=94=E0=B9=80=E0=B8=9B?= =?UTF-8?q?=E0=B9=87=E0=B8=99=20en=20+=20=E0=B8=A2=E0=B9=88=E0=B8=AD=20ban?= =?UTF-8?q?ner=20=E0=B9=80=E0=B8=9B=E0=B9=87=E0=B8=99=20"=E0=B9=81?= =?UTF-8?q?=E0=B8=AA=E0=B8=94=E0=B8=87=E0=B9=80=E0=B8=89=E0=B8=9E=E0=B8=B2?= =?UTF-8?q?=E0=B8=B0=E0=B9=80=E0=B8=AD=E0=B8=81=E0=B8=AA=E0=B8=B2=E0=B8=A3?= =?UTF-8?q?=E0=B8=97=E0=B8=B5=E0=B9=88=E0=B9=80=E0=B8=84=E0=B8=A2=E0=B9=80?= =?UTF-8?q?=E0=B8=9B=E0=B8=B4=E0=B8=94=E0=B9=83=E0=B8=99=E0=B8=A3=E0=B8=B0?= =?UTF-8?q?=E0=B8=9A=E0=B8=9A=E0=B8=99=E0=B8=B5=E0=B9=89"=20=E2=80=94=20?= =?UTF-8?q?=E0=B8=9B=E0=B8=B4=E0=B8=94=20gap=205.3.10=20=E0=B8=84=E0=B8=A3?= =?UTF-8?q?=E0=B8=B6=E0=B9=88=E0=B8=87=E0=B8=AB=E0=B8=A5=E0=B8=B1=E0=B8=87?= =?UTF-8?q?=20(bookmark=20=E0=B8=A3=E0=B8=A7=E0=B8=A1=E0=B8=97=E0=B8=B1?= =?UTF-8?q?=E0=B9=89=E0=B8=87=E0=B8=81=E0=B8=A5=E0=B8=B8=E0=B9=88=E0=B8=A1?= =?UTF-8?q?)=20+=20docs=20sync=20=E0=B8=84=E0=B8=A3=E0=B8=9A=20(=E0=B8=84?= =?UTF-8?q?=E0=B8=B3=E0=B8=82=E0=B8=AD=E0=B8=9C=E0=B8=B9=E0=B9=89=E0=B9=83?= =?UTF-8?q?=E0=B8=8A=E0=B9=89=202026-09-23)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Code --- CLAUDE.md | 4 +- app/groups/[typeId]/page.tsx | 245 +++++++++++++++++++++++++------ docs/PLAN-DOCSET.md | 23 +-- docs/PLAN-FEAT.md | 4 +- docs/requirement/gap-analysis.md | 4 +- 5 files changed, 223 insertions(+), 57 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 006ee11..2f7fbc8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -63,7 +63,7 @@ app/api/annotations/export-csv/route.ts ← export สรุป CSV พร้ 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, หมายเหตุ "อาจไม่ครบทุกฉบับ") +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 จัดกลุ่มตามหน้า (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`) @@ -82,7 +82,7 @@ components/SessionBar.tsx ← แถบแสดงผู้ใช้ที่ l ## ขอบเขต — ห้ามทำเกิน (Out of Scope) -> ขอบเขตด้านล่างเป็นของ**เฟสแรก** — ฟีเจอร์ต่อยอด (edit/ย้าย, undo/redo, export, reply, resolve ฯลฯ) อนุญาต**เฉพาะตาม task F1–F19 ใน `docs/PLAN-FEAT.md`** และทำผ่าน `/implement-feat` เท่านั้น ห้ามทำเกินสเปกของแผนนั้น — **ปัจจุบันทำครบตามแผนแล้ว ยกเว้น F11 (export/import JSON) ที่ถูกถอดออกจากโค้ดแล้ว**: แก้ไข/ย้าย/ปรับขนาด (F1), undo/redo (F2), คีย์ลัด (F3), กรองรายคน (F4), รูปทรง (F5), eraser (F6), สไตลัส/pressure (F7), redaction + export PDF (F8), reply เธรด (F9), resolve (F10), deep link (F12), แก้ไข comment (F13), ค้นหา (F14), หน้าสรุป (F15), export CSV (F16), text box (F17), stamp (F18), version history + กู้คืน (F19) — ฟีเจอร์เสริมหลังแผน (อนุญาตตาม §7 ของ `docs/PLAN-FEAT.md`): โน้ตคำอธิบาย `note?` ฝังใน annotation ทุกชนิดยกเว้น COMMENT แก้ไขใน sidebar + hover tooltip (N1), bookmark โครงสร้าง 3 ระดับ `parent_id` + โน้ต + popup สร้าง/แก้ไข/ย้ายหัวข้อ + ปุ่ม bookmark ในแผง selection + ชื่อ default จากข้อความที่คลุม (N2), รองรับไฟล์รูปภาพ jpg/jpeg/png/tif เป็นเอกสาร annotate ได้ครบทุกชนิด + TIFF หลายเฟรม = หลายหน้า + export PDF ฝังรูป (N3 — แผน `docs/PLAN-IMAGE.md`), อัปโหลดไฟล์ PDF/รูปผ่านหน้ารายการแบบง่าย (N4), bookmark เรียงตามลำดับหน้า (N5), ค้นหา bookmark พร้อมเงื่อนไข คำค้น/ช่วงหน้า/ผู้บันทึก/ช่วงวันที่ + `bookmarks.user_name` (N6 — แผน `docs/PLAN-BOOKMARK.md`), รายงานรายการ bookmark เป็น PDF ผ่าน route แยก (สร้างเอกสารใหม่ล้วน ไม่โหลดไฟล์ต้นฉบับ — N7 แผน `docs/PLAN-BOOKMARK-PDF.md` — **2026-09-17 เปลี่ยน deliverable เป็นส่งออก JSON** แทนรายงาน PDF ตามคำขอผู้ใช้: flat array `BookmarkDTO` เรียง N5 + envelope fileId/title/exportedAt ผ่าน route เดิม), เปิดเอกสารจากลิงก์ e-Court `?fileUrl=&fileId=&typeId=` — ลงทะเบียน metadata-only (dedupe `external_id`, ไม่เก็บสำเนาไฟล์) + ดึงไฟล์สดตอนเปิดดู/export ผ่าน allowlist + การ์ดจำลองลิงก์บนหน้ารายการ + รับ path ในเครื่อง (`/samples/…`) เพื่อทดสอบ grouping ใน dev (N8 — แผน `docs/PLAN-ECOURT.md`, implement ครบแล้ว 2026-09-22 รวม D5 login วนกลับ), กลุ่มเอกสารตาม `type_id` จากลิงก์ e-Court — หน้ารายการ `/` จัด section ต่อกลุ่ม + ส่วน "ไม่มีกลุ่ม" ท้ายสุด และหน้า `/groups/[typeId]` แสดงเอกสารที่รู้จักของกลุ่มพร้อมหมายเหตุ "อาจไม่ครบทุกฉบับ" (อ่าน `type_id` อย่างเดียว — เขียนที่ N8 `registerExternalFile` เท่านั้น ไม่มี action/SSE ใหม่) (N9 — แผน `docs/PLAN-DOCSET.md`) +> ขอบเขตด้านล่างเป็นของ**เฟสแรก** — ฟีเจอร์ต่อยอด (edit/ย้าย, undo/redo, export, reply, resolve ฯลฯ) อนุญาต**เฉพาะตาม task F1–F19 ใน `docs/PLAN-FEAT.md`** และทำผ่าน `/implement-feat` เท่านั้น ห้ามทำเกินสเปกของแผนนั้น — **ปัจจุบันทำครบตามแผนแล้ว ยกเว้น F11 (export/import JSON) ที่ถูกถอดออกจากโค้ดแล้ว**: แก้ไข/ย้าย/ปรับขนาด (F1), undo/redo (F2), คีย์ลัด (F3), กรองรายคน (F4), รูปทรง (F5), eraser (F6), สไตลัส/pressure (F7), redaction + export PDF (F8), reply เธรด (F9), resolve (F10), deep link (F12), แก้ไข comment (F13), ค้นหา (F14), หน้าสรุป (F15), export CSV (F16), text box (F17), stamp (F18), version history + กู้คืน (F19) — ฟีเจอร์เสริมหลังแผน (อนุญาตตาม §7 ของ `docs/PLAN-FEAT.md`): โน้ตคำอธิบาย `note?` ฝังใน annotation ทุกชนิดยกเว้น COMMENT แก้ไขใน sidebar + hover tooltip (N1), bookmark โครงสร้าง 3 ระดับ `parent_id` + โน้ต + popup สร้าง/แก้ไข/ย้ายหัวข้อ + ปุ่ม bookmark ในแผง selection + ชื่อ default จากข้อความที่คลุม (N2), รองรับไฟล์รูปภาพ jpg/jpeg/png/tif เป็นเอกสาร annotate ได้ครบทุกชนิด + TIFF หลายเฟรม = หลายหน้า + export PDF ฝังรูป (N3 — แผน `docs/PLAN-IMAGE.md`), อัปโหลดไฟล์ PDF/รูปผ่านหน้ารายการแบบง่าย (N4), bookmark เรียงตามลำดับหน้า (N5), ค้นหา bookmark พร้อมเงื่อนไข คำค้น/ช่วงหน้า/ผู้บันทึก/ช่วงวันที่ + `bookmarks.user_name` (N6 — แผน `docs/PLAN-BOOKMARK.md`), รายงานรายการ bookmark เป็น PDF ผ่าน route แยก (สร้างเอกสารใหม่ล้วน ไม่โหลดไฟล์ต้นฉบับ — N7 แผน `docs/PLAN-BOOKMARK-PDF.md` — **2026-09-17 เปลี่ยน deliverable เป็นส่งออก JSON** แทนรายงาน PDF ตามคำขอผู้ใช้: flat array `BookmarkDTO` เรียง N5 + envelope fileId/title/exportedAt ผ่าน route เดิม), เปิดเอกสารจากลิงก์ e-Court `?fileUrl=&fileId=&typeId=` — ลงทะเบียน metadata-only (dedupe `external_id`, ไม่เก็บสำเนาไฟล์) + ดึงไฟล์สดตอนเปิดดู/export ผ่าน allowlist + การ์ดจำลองลิงก์บนหน้ารายการ + รับ path ในเครื่อง (`/samples/…`) เพื่อทดสอบ grouping ใน dev (N8 — แผน `docs/PLAN-ECOURT.md`, implement ครบแล้ว 2026-09-22 รวม D5 login วนกลับ), กลุ่มเอกสารตาม `type_id` จากลิงก์ e-Court — หน้ารายการ `/` จัด section ต่อกลุ่ม + ส่วน "ไม่มีกลุ่ม" ท้ายสุด และหน้า `/groups/[typeId]` แสดงเอกสารที่รู้จักของกลุ่มพร้อมหมายเหตุ "แสดงเฉพาะเอกสารที่เคยเปิดในระบบนี้" + การ์ดแต่ละไฟล์หุบ/กางดู bookmark+annotation ทั้งไฟล์ (2026-09-23 ตามคำขอผู้ใช้ — ปิด gap 5.3.10 ครึ่งหลัง bookmark รวมทั้งกลุ่ม; อ่าน `type_id` อย่างเดียว — เขียนที่ N8 `registerExternalFile` เท่านั้น ไม่มี action/SSE ใหม่) (N9 — แผน `docs/PLAN-DOCSET.md`) - ไม่มี auth จริง/roles (login เป็น mock หน้า `/login` ตั้ง cookie `oacCase` — ไม่ได้ตรวจกับระบบ e-Court จริง), ไม่มี presence — undo/redo เป็น history ฝั่ง client ต่อ session ต่อผู้ใช้ ไม่เก็บใน DB (D10) ส่วน version history/กู้คืน (F19) เก็บในตาราง `annotation_versions` - Annotation: สร้าง + ลบ + แก้ไข/ย้าย/ปรับขนาด/เปลี่ยนสี–ความหนา/แก้ไขข้อความ **ของตัวเอง ในหน้าเดียวกัน** — resolve เป็นสิ่งเดียวที่ใครก็ toggle ได้ (D6) diff --git a/app/groups/[typeId]/page.tsx b/app/groups/[typeId]/page.tsx index 774e3d5..5ba2d3a 100644 --- a/app/groups/[typeId]/page.tsx +++ b/app/groups/[typeId]/page.tsx @@ -1,11 +1,33 @@ import type { Metadata } from "next"; import Link from "next/link"; import { notFound } from "next/navigation"; -import { asc, count, eq, inArray } from "drizzle-orm"; -import { ArrowLeft, FileImage, FileText, Folder } from "lucide-react"; +import { asc, eq, inArray } from "drizzle-orm"; +import { + ArrowLeft, + Bookmark, + ChevronDown, + FileImage, + FileText, + Folder, + StickyNote, +} from "lucide-react"; +import { AnnotationTypeIcon } from "@/components/AnnotationTypeIcon"; import { db } from "@/db"; -import { annotations, bookmarks, files } from "@/db/schema"; +import { + annotations, + bookmarks, + files, + toAnnotationDTO, + toBookmarkDTO, + type AnnotationDTO, + type BookmarkDTO, +} from "@/db/schema"; +import { + annotationNote, + annotationPreviewText, + annotationTypeLabel, +} from "@/utils/annotation-display"; import { formatThaiDate } from "@/utils/date-format"; import { imageFormatFromUrl } from "@/utils/file-kind"; @@ -13,6 +35,27 @@ import { imageFormatFromUrl } from "@/utils/file-kind"; // server component force-dynamic — login ถูกบังคับโดย middleware.ts อยู่แล้ว // snapshot ณ ตอนโหลดเหมือนหน้าสรุป F15 (ไม่มี SSE) — กลุ่มไม่มีไฟล์เลย = 404 // เพราะไม่มีตารางกลุ่ม: กลุ่มมีชีวิตตามข้อมูลจริงใน files ณ ตอน query +// การ์ดแต่ละไฟล์หุบ/กางดู bookmark+annotation ของไฟล์นั้น (คำขอผู้ใช้ 2026-09-23) +// รวมเป็นรายการเดียวเรียงตามหน้า — แถวคลิกไป deep link เดียวกับหน้าสรุป (F12) + +type FileBucket = { anns: AnnotationDTO[]; bms: BookmarkDTO[] }; + +/** bookmark+annotation ของไฟล์รวมเป็นรายการเดียวเรียงตามหน้า (หน้าเดียวกัน + * ให้ที่คั่นหน้าก่อนหมายเหตุ — ไล่ตามตำแหน่งในเอกสาร) */ +function mergedItems(b: FileBucket) { + return [ + ...b.bms.map((dto) => ({ kind: "bm" as const, pageIndex: dto.pageIndex, dto })), + ...b.anns.map((dto) => ({ kind: "ann" as const, pageIndex: dto.pageIndex, dto })), + ].sort( + (a, x) => + a.pageIndex - x.pageIndex || + (a.kind === x.kind ? 0 : a.kind === "bm" ? -1 : 1), + ); +} + +/** focus ring ร่วมของลิงก์รายการในการ์ด (เดียวกับ RING_CLS ของหน้าสรุป) */ +const RING_CLS = + "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1"; export const dynamic = "force-dynamic"; @@ -38,23 +81,41 @@ export default async function GroupPage({ .orderBy(asc(files.createdAt)); if (rows.length === 0) notFound(); - // จำนวน annotation/bookmark ต่อไฟล์ — 2 query แยกด้วย groupBy fileId (ไม่ join หนัก) + // bookmark+annotation ทั้งกลุ่ม 2 query แยก (ไม่ join หนัก) แล้วจัดตามไฟล์ใน JS — + // ได้ทั้งจำนวนสำหรับบรรทัด meta และรายการเต็มสำหรับกางดูในการ์ด const ids = rows.map((f) => f.id); - const [annCounts, bmCounts] = await Promise.all([ + const [annRows, bmRows] = await Promise.all([ db - .select({ fileId: annotations.fileId, n: count() }) + .select() .from(annotations) .where(inArray(annotations.fileId, ids)) - .groupBy(annotations.fileId), + .orderBy(asc(annotations.pageIndex), asc(annotations.createdAt)), db - .select({ fileId: bookmarks.fileId, n: count() }) + .select() .from(bookmarks) .where(inArray(bookmarks.fileId, ids)) - .groupBy(bookmarks.fileId), + .orderBy( + asc(bookmarks.pageIndex), + asc(bookmarks.yOffset), + asc(bookmarks.createdAt), + ), ]); - // drizzle count() ใช้ .mapWith(Number) ให้แล้ว — r.n เป็น number ทั้ง type และ runtime - const annByFile = new Map(annCounts.map((r) => [r.fileId, r.n])); - const bmByFile = new Map(bmCounts.map((r) => [r.fileId, r.n])); + const annDtos = annRows.map(toAnnotationDTO); + const bmDtos = bmRows.map(toBookmarkDTO); + + const byFile = new Map(); + const bucketOf = (fileId: string): FileBucket => { + let b = byFile.get(fileId); + if (!b) { + b = { anns: [], bms: [] }; + byFile.set(fileId, b); + } + return b; + }; + for (const d of annDtos) bucketOf(d.fileId).anns.push(d); + for (const d of bmDtos) bucketOf(d.fileId).bms.push(d); + // หา parent ของ sub bookmark แสดง "ใน: …" — id unique ทั้งตาราง ใช้ map เดียวร่วมทั้งกลุ่ม + const bookmarkById = new Map(bmDtos.map((b) => [b.id, b] as const)); return (
@@ -81,39 +142,141 @@ export default async function GroupPage({ {/* หมายเหตุแสดงทุกครั้ง (แผน N9 D3): ระบบรู้จักเฉพาะเอกสารที่เคยเปิดผ่านลิงก์ e-Court รายการครบทั้งคดีอยู่ใน e-Court ไม่ใช่ระบบนี้ */}
- แสดงเฉพาะเอกสารที่เคยเปิดในระบบนี้ — อาจไม่ครบทุกฉบับของกลุ่ม + แสดงเฉพาะเอกสารที่เคยเปิดในระบบนี้
    - {rows.map((f) => ( -
  • -
    - {imageFormatFromUrl(f.pdfUrl) ? ( - - ) : ( - - )} -
    - - - {f.title} - - - นำเข้า {formatThaiDate(f.createdAt, true)} · หมายเหตุ{" "} - {annByFile.get(f.id) ?? 0} · ที่คั่นหน้า {bmByFile.get(f.id) ?? 0} - - - { + const bucket = byFile.get(f.id) ?? { anns: [], bms: [] }; + const total = bucket.anns.length + bucket.bms.length; + return ( +
  • - Annotate - -
  • - ))} +
    +
    + {imageFormatFromUrl(f.pdfUrl) ? ( + + ) : ( + + )} +
    + + + {f.title} + + + นำเข้า {formatThaiDate(f.createdAt, true)} · Annotations{" "} + {bucket.anns.length} · Bookmarks {bucket.bms.length} + + + + Annotate + +
    + + {total > 0 && ( +
    + + Annotations & Bookmarks ({total}) + + +
      + {mergedItems(bucket).map((item) => { + const parent = + item.kind === "bm" && item.dto.parentId + ? bookmarkById.get(item.dto.parentId) + : undefined; + return item.kind === "bm" ? ( +
    • + + + + + {item.dto.title} + + + หน้า {item.pageIndex + 1} ·{" "} + {formatThaiDate(item.dto.createdAt, true)} · โดย{" "} + {item.dto.userName || "ไม่ทราบชื่อ"} + {parent ? ` · ใน: ${parent.title}` : ""} + + {item.dto.note && ( + + + + {item.dto.note} + + + )} + + +
    • + ) : ( +
    • + + + + + {annotationPreviewText(item.dto)} + {item.dto.type === "COMMENT" && + item.dto.resolved && ( + + จัดการแล้ว + + )} + + + หน้า {item.pageIndex + 1} ·{" "} + {annotationTypeLabel(item.dto)} ·{" "} + {formatThaiDate(item.dto.createdAt, true)} · โดย{" "} + {item.dto.userName} + + {annotationNote(item.dto) && ( + + + + {annotationNote(item.dto)} + + + )} + + +
    • + ); + })} +
    +
    + )} + + ); + })}
); diff --git a/docs/PLAN-DOCSET.md b/docs/PLAN-DOCSET.md index fbdd03b..d6549c7 100644 --- a/docs/PLAN-DOCSET.md +++ b/docs/PLAN-DOCSET.md @@ -1,6 +1,7 @@ # แผน N9 — กลุ่มเอกสารตาม `typeId` จาก e-Court + หน้ารายการเอกสารในกลุ่ม > สถานะ: **implement แล้ว (2026-09-23)** — `tsc` + vitest 142/142 + Playwright ผ่านทุกข้อ verify ของ task 3–4 + plan-reviewer/code-reviewer ไม่พบ violation (คงเหลือ e2e ลิงก์ e-Court จริงตามแผน ตอนปิดงาน N8) — เขียนใหม่ 2026-09-22 แทนร่างเดิม "Document Set จัดชุดมือใน UI" (2026-09-17, commit 6940b38) ตามคำขอผู้ใช้: ให้ e-Court บอกกลุ่มผ่านลิงก์แทนการจัดชุดด้วยมือ +> เพิ่มเติม (2026-09-23 ตามคำขอผู้ใช้): การ์ดไฟล์หน้า `/groups/[typeId]` หุบ/กางดู bookmark+annotation ทั้งไฟล์ (ปิด gap 5.3.10 ครึ่งหลังที่เคยเป็น out of scope) + ย่อข้อความหมายเหตุเหลือ "แสดงเฉพาะเอกสารที่เคยเปิดในระบบนี้" > เลขฟีเจอร์: ร่างเดิม (2026-09-17) เคยใช้เลข N8 — ยกให้แผนลิงก์ e-Court (`docs/PLAN-ECOURT.md`) ซึ่งเป็น dependency ต้นทาง แผนนี้จึงเป็น **N9** > Dependency: **N8** — การรับ/refresh `typeId` เกิดที่ `registerExternalFile` ของ N8 ทั้งหมด (แผนนี้ไม่มี code path ที่เขียน `type_id` เอง) — **N8 implement ครบแล้ว (2026-09-22 รวม D5)**: schema `type_id` + index มากับ migration `drizzle/0002` ด้วย > ตาม governance ของโปรเจกต์: implement ต้องเขียนสเปกลง `docs/PLAN-FEAT.md` §7 (**N9**) + ขยาย whitelist ใน `CLAUDE.md` + อัปเดต `docs/PLAN.md` §3 (schema) ก่อน — เป็น task แรกของแผน แล้วรันผ่าน `/implement-feat N9` @@ -9,13 +10,13 @@ - กรณีจริงของศาล: คดีหนึ่งงวด = หลายเอกสาร (คำฟ้อง / คำพิพากษา / คำสั่งศาล) — ผู้ใช้อยากเห็นเอกสารที่เกี่ยวเนื่องกันจัดกลุ่มมาแสดง และมีหน้ารวมทุกเอกสารของกลุ่ม - ร่างเดิม (2026-09-17) ออกแบบเป็นตาราง `document_sets` + ย้ายไฟล์เข้า/ออกด้วยมือใน UI — ผู้ใช้ตัดสินใจใหม่ (2026-09-22): ให้ e-Court ส่งรหัสกลุ่มมากับลิงก์ (`?typeId=` — เปลี่ยนชื่อจาก caseId ตามคำขอ) จึง**ไม่ต้องมีตารางแยก / Server Actions / UI จัดการกลุ่มเลย** เหลือแค่ column + การแสดงผล -- ข้อจำกัดที่ยอมรับ (ผู้ใช้ยืนยัน 2026-09-22): ระบบเรารู้จักเฉพาะเอกสารที่เคยเปิดผ่านลิงก์ e-Court (row เกิดตอน `registerExternalFile`) — หน้ากลุ่มจึงแสดง**เฉพาะเอกสารที่รู้จักแล้ว** พร้อมหมายเหตุให้ผู้ใช้ทราบว่าอาจไม่ครบทุกฉบับของคดี (รายการครบทั้งคดีอยู่ใน e-Court ไม่ใช่ระบบเรา) +- ข้อจำกัดที่ยอมรับ (ผู้ใช้ยืนยัน 2026-09-22): ระบบเรารู้จักเฉพาะเอกสารที่เคยเปิดผ่านลิงก์ e-Court (row เกิดตอน `registerExternalFile`) — หน้ากลุ่มจึงแสดง**เฉพาะเอกสารที่รู้จักแล้ว** พร้อมหมายเหตุกำกับว่า "แสดงเฉพาะเอกสารที่เคยเปิดในระบบนี้" (รายการครบทั้งคดีอยู่ใน e-Court ไม่ใช่ระบบเรา — ข้อความเดิมยาวกว่า ย่อตามคำขอผู้ใช้ 2026-09-23) ## การตัดสินใจ (ยืนยันกับผู้ใช้แล้ว 2026-09-22) 1. **กลุ่มระดับเดียว กำหนดด้วย `typeId` จากลิงก์ e-Court** — เพิ่ม column บนตาราง `files` ค่าเดียว (`type_id` — ถอด `type_name` ตามคำขอผู้ใช้ 2026-09-22, แสดงผลด้วยรหัสกลุ่มโดยตรง) — ไม่มีตารางกลุ่มแยก ไม่มี many-to-many ไม่มีหลายระดับ (เอกสารหนึ่งอยู่กลุ่มเดียวตามธรรมชาติของงาน) — ความหมายของ id เป็นของ e-Court ฝั่งเราถือว่า "ส่งมาเหมือนกัน = กลุ่มเดียวกัน" 2. **เข้ามาอัตโนมัติทั้งหมด ไม่มี UI จัดการกลุ่ม** — ไฟล์ได้กลุ่มเมื่อเปิดผ่านลิงก์ที่มี `typeId` (เขียนที่ N8) — กลุ่มเกิด/หายตามข้อมูลจริงใน `files` ไม่มีสถานะกลุ่มค้างในตารางอื่น -3. **หน้ารายการ `/` จัดกลุ่มตาม `typeId`** (section ต่อกลุ่ม + ส่วน "ไม่มีกลุ่ม" ท้ายสุด) + **หน้าใหม่ `/groups/[typeId]`** แสดงเอกสารทั้งกลุ่มที่รู้จัก พร้อมหมายเหตุ "อาจไม่ครบทุกฉบับ" +3. **หน้ารายการ `/` จัดกลุ่มตาม `typeId`** (section ต่อกลุ่ม + ส่วน "ไม่มีกลุ่ม" ท้ายสุด) + **หน้าใหม่ `/groups/[typeId]`** แสดงเอกสารทั้งกลุ่มที่รู้จัก พร้อมหมายเหตุ "แสดงเฉพาะเอกสารที่เคยเปิดในระบบนี้" และการ์ดแต่ละไฟล์หุบ/กางดู bookmark+annotation ของไฟล์นั้นได้ (2026-09-23) ## Design @@ -45,8 +46,9 @@ Server component `force-dynamic` (login ถูกบังคับโดย `mi - โหลด: `files where type_id = params.typeId` `orderBy createdAt` (Next decode dynamic param ให้อัตโนมัติ — **อย่าเรียก `decodeURIComponent` ซ้ำ** ตาม edge case #5) — **ไม่มี = 404** (กลุ่มที่ระบบไม่รู้จัก/ย้ายไฟล์ออกหมดแล้ว) - แสดง: หัวกลุ่ม (`type_id` + "N เอกสาร") + ลิงก์กลับ `/` -- **หมายเหตุกำกับทุกครั้ง** (ผู้ใช้ยืนยัน): "แสดงเฉพาะเอกสารที่เคยเปิดในระบบนี้ — อาจไม่ครบทุกฉบับของกลุ่ม" (กล่อง info ใต้หัวกลุ่ม) -- รายการเอกสาร: ชื่อไฟล์ + วันที่นำเข้า (`formatThaiDate`) + จำนวน annotation/bookmark (query นับแยกด้วย `groupBy fileId` — 2 query เสริม ไม่ join หนัก) + ปุ่ม/ลิงก์ **Annotate → `/annotate/{fileId}`** (ไม่ต้อง deep link หน้า — เข้าไปเหมือนปกติ) +- **หมายเหตุกำกับทุกครั้ง** (ผู้ใช้ยืนยัน): "แสดงเฉพาะเอกสารที่เคยเปิดในระบบนี้" (กล่อง info ใต้หัวกลุ่ม — ย่อจากข้อความเดิม "… — อาจไม่ครบทุกฉบับของกลุ่ม" ตามคำขอผู้ใช้ 2026-09-23) +- รายการเอกสาร: ชื่อไฟล์ + วันที่นำเข้า (`formatThaiDate`) + จำนวน annotation/bookmark + ปุ่ม/ลิงก์ **Annotate → `/annotate/{fileId}`** (ไม่ต้อง deep link หน้า — เข้าไปเหมือนปกติ) +- **การ์ดแต่ละไฟล์หุบ/กางดู bookmark+annotation ทั้งไฟล์** (คำขอผู้ใช้ 2026-09-23 — แถบ summary เป็น en: "Annotations & Bookmarks (N)"): select รายการเต็ม 2 query (`inArray fileId` + `orderBy` หน้า — เดิมนับอย่างเดียวด้วย `groupBy`) แล้วจัดตามไฟล์ใน JS; รวมเป็นรายการเดียวเรียงตามหน้า (หน้าเดียวกันให้ที่คั่นหน้าก่อนหมายเหตุ — ไล่ตามตำแหน่งในเอกสาร); แต่ละแถว deep link เดียวกับหน้าสรุป F12 (`?ann=` / `?page=N`) + รูปแบบแถว/ไอคอน/โน้ต/ป้าย "จัดการแล้ว"/"ใน: …" ของ sub bookmark ตามหน้าสรุป F15 + วันเวลาที่สร้าง (`formatThaiDate` withTime — คำขอผู้ใช้ 2026-09-23) ในบรรทัด meta ก่อน "โดย"; ใช้ `
` ล้วน ไม่มี client component; ไฟล์ที่ไม่มีรายการเลยไม่มีแถบหุบ/กาง - ไม่มี SSE (snapshot ณ ตอนโหลด เหมือนหน้าสรุป F15) ไม่มีปุ่มแก้ไข/ย้ายไฟล์ (จัดการที่ e-Court ฝั่งเดียว) - `generateMetadata` ตั้งชื่อแท็บ browser ตามชื่อกลุ่ม (pattern เดียวกับหน้าสรุป F15) @@ -64,7 +66,7 @@ Server component `force-dynamic` (login ถูกบังคับโดย `mi | `CLAUDE.md` | whitelist ฟีเจอร์เสริมหลังแผน เพิ่ม N9 + โครงสร้างไฟล์เพิ่ม `app/groups/[typeId]/page.tsx` | | `.claude/rules/data-layer.md` | เพิ่ม: `files.type_id` เขียนที่ N8 `registerExternalFile` เท่านั้น (ลิงก์ไม่ส่ง = คงเดิม) — ไม่มี action/SSE ใหม่ของ N9 | -- `docs/requirement/gap-analysis.md` 5.3.10 ครึ่งหลัง ("หรือชุดของเอกสาร") — **ยังไม่ปิด** ด้วยแผนนี้ (แผนนี้แสดง *เอกสาร* รวมกลุ่ม ไม่ใช่ *bookmark* รวมกลุ่มตาม requirement) — อย่าเปลี่ยนสถานะ +- `docs/requirement/gap-analysis.md` 5.3.10 ครึ่งหลัง ("หรือชุดของเอกสาร") — เดิมแผนนี้ไม่ปิด (แสดงเฉพาะ *เอกสาร* รวมกลุ่ม) แต่**ปิดแล้ว 2026-09-23** ด้วยการหุบ/กางดู bookmark+annotation ต่อไฟล์บนหน้ากลุ่ม (D3) - `.claude/rules/realtime.md`, `pdf-viewer.md`, `coordinates.md` **ไม่แตะ** ## ลำดับ task + verify (ทุก task: `npx tsc --noEmit` ผ่าน) @@ -93,7 +95,7 @@ Server component `force-dynamic` (login ถูกบังคับโดย `mi - จัดกลุ่ม/แก้ชื่อ/ลบกลุ่มด้วยมือใน UI ของเรา (ทิ้งจากร่างเดิม — กลุ่มมาจาก e-Court อย่างเดียว) - ไฟล์อยู่หลายกลุ่ม (many-to-many) / กลุ่มซ้อนหลายระดับ -- **bookmark รวมทั้งกลุ่ม** (ครึ่งหลังของ requirement 5.3.10 แบบร่างเดิม — น่าดึงกลับมาภายหลังได้เมื่อผู้ใช้ขอ) +- ~~bookmark รวมทั้งกลุ่ม~~ — **ทำแล้ว (2026-09-23)**: การ์ดไฟล์หน้ากลุ่มหุบ/กางดู bookmark+annotation ต่อไฟล์ (D3) - export JSON ตามกลุ่ม (N7 ยังรับ `fileId` เดียว) - API ดึงข้อมูลให้ระบบภายนอก = **N10** `docs/PLAN-API.md` (แผนแยก) - รายการเอกสารครบทุกฉบับของคดีรวมฉบับที่ยังไม่เคยเปิด (ข้อมูลอยู่ที่ e-Court) @@ -101,7 +103,8 @@ Server component `force-dynamic` (login ถูกบังคับโดย `mi ## Verification (ภาพรวม end-to-end — หลัง N8 เสร็จ) 1. `npm run dev` → login → เปิดลิงก์ e-Court 2 ไฟล์ที่ `typeId` เดียวกัน + 1 ไฟล์ `typeId` ต่างกลุ่ม → หน้า `/` จัด 2 section ตามกลุ่ม + section "ไม่มีกลุ่ม" (ไฟล์ seed/อัปโหลด) -2. หัวกลุ่ม → "เอกสารในกลุ่ม" → `/groups/[typeId]`: เห็นไฟล์ครบตามลำดับ + หมายเหตุ "อาจไม่ครบทุกฉบับ" + จำนวน annotation/bookmark ต่อไฟล์ -3. กด Annotate → เข้า viewer ของไฟล์นั้นได้ตามปกติ -4. เปิดลิงก์รอบใหม่ของไฟล์เดิมด้วย `typeId` คนละค่า → ไฟล์ย้ายกลุ่ม กลุ่มเดิมลดจำนวน/หายไปเมื่อว่าง -5. Regression: อัปโหลด N4 / annotate / ค้นหา N6 / export JSON N7 ยังทำงานเหมือนเดิม +2. หัวกลุ่ม → "เอกสารในกลุ่ม" → `/groups/[typeId]`: เห็นไฟล์ครบตามลำดับ + หมายเหตุ "แสดงเฉพาะเอกสารที่เคยเปิดในระบบนี้" + จำนวน annotation/bookmark ต่อไฟล์ +3. กาง "Annotations & Bookmarks (N)" ในการ์ดไฟล์ → เห็น bookmark+annotation ทั้งไฟล์เรียงตามหน้า + กดแถวแต่ละรายการเด้งไปตำแหน่งนั้นใน viewer (`?ann=` / `?page=`) +4. กด Annotate → เข้า viewer ของไฟล์นั้นได้ตามปกติ +5. เปิดลิงก์รอบใหม่ของไฟล์เดิมด้วย `typeId` คนละค่า → ไฟล์ย้ายกลุ่ม กลุ่มเดิมลดจำนวน/หายไปเมื่อว่าง +6. Regression: อัปโหลด N4 / annotate / ค้นหา N6 / export JSON N7 ยังทำงานเหมือนเดิม diff --git a/docs/PLAN-FEAT.md b/docs/PLAN-FEAT.md index 543ea28..c75f70e 100644 --- a/docs/PLAN-FEAT.md +++ b/docs/PLAN-FEAT.md @@ -358,6 +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 **"แสดงเฉพาะเอกสารที่เคยเปิดในระบบนี้ — อาจไม่ครบทุกฉบับของกลุ่ม"** แสดงทุกครั้ง; รายการ = ชื่อไฟล์ + วันที่นำเข้า (`formatThaiDate`) + จำนวน annotation/bookmark (query นับแยกด้วย `groupBy fileId` — 2 query เสริม ไม่ join หนัก) + ปุ่ม Annotate → `/annotate/{fileId}`; `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, ใช้ `
` ไม่มี 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) / กลุ่มซ้อนหลายระดับ, bookmark รวมทั้งกลุ่ม (ครึ่งหลังของ gap 5.3.10 — ยังไม่ปิด), export JSON ตามกลุ่ม (N7 ยังรับ `fileId` เดียว), รายการเอกสารครบทุกฉบับของคดีรวมฉบับที่ยังไม่เคยเปิด (ข้อมูลอยู่ที่ e-Court), API ให้ระบบภายนอก (**N10** `docs/PLAN-API.md`) +- **Out of scope ของ N9:** จัดกลุ่ม/แก้ชื่อ/ลบกลุ่มด้วยมือใน UI, ไฟล์อยู่หลายกลุ่ม (many-to-many) / กลุ่มซ้อนหลายระดับ, export JSON ตามกลุ่ม (N7 ยังรับ `fileId` เดียว), รายการเอกสารครบทุกฉบับของคดีรวมฉบับที่ยังไม่เคยเปิด (ข้อมูลอยู่ที่ e-Court), API ให้ระบบภายนอก (**N10** `docs/PLAN-API.md`) diff --git a/docs/requirement/gap-analysis.md b/docs/requirement/gap-analysis.md index 716f74c..21f7a19 100644 --- a/docs/requirement/gap-analysis.md +++ b/docs/requirement/gap-analysis.md @@ -44,7 +44,7 @@ | **5.3.7** เพิ่ม แก้ไข ลบ ข้อความที่เขียนหรือวาดได้ เช่น สีหรือขนาด | ✅ | ครบทั้งวงจร: สร้าง annotation ทุกชนิด (HIGHLIGHT/DRAW/SHAPE/TEXTBOX/STAMP) + **แก้ไขสี/ความหนา/ขนาด** ผ่านแผง selection (F1 — `AnnotationSelectionLayer`: แถบสี + ความหนา, TEXTBOX ปรับ fontSize ได้) + ย้าย/ปรับขนาด + F13 แก้ไขข้อความ + ลบของตัวเอง + F19 version history | | **5.3.8** ข้อมูลบันทึก bookmark ต้องอ้างถึงไฟล์เอกสาร หน้าเอกสาร วันที่บันทึก และผู้บันทึก | ✅ | ตาราง `bookmarks` มีครบทั้ง 4: `file_id` (FK → ไฟล์เอกสาร), `page_index` (หน้าเอกสาร), `created_at` (วันที่บันทึก), `user_id` (ผู้บันทึก) — นำมาแสดงในแท็บ Bookmarks, หน้าสรุป F15 และ export | | **5.3.9** กำหนดความเป็นส่วนตัวของ bookmark: ส่วนบุคคล (Private) / กลุ่ม (Group) | ❌ | ไม่มี Private/Group visibility — ทุกคนเห็นทุกรายการ (ไม่มี auth/roles โดยเจตนา) | -| **5.3.10** แสดง bookmark ของเอกสารแต่ละไฟล์ หรือชุดของเอกสารได้ | ⚠️ | รายไฟล์ ✅ — แท็บ Bookmarks ใน sidebar แสดง bookmark ทั้งหมดของไฟล์ที่เปิด (ผูก `file_id`) + หน้าสรุป F15 / แต่**ชุดของเอกสาร** ❌ — ไม่มี concept การจัดกลุ่มไฟล์ (document set) และไม่มีหน้าแสดง bookmark รวมข้ามหลายไฟล์ | +| **5.3.10** แสดง bookmark ของเอกสารแต่ละไฟล์ หรือชุดของเอกสารได้ | ✅ | ครบทั้งสองแบบ — รายไฟล์: แท็บ Bookmarks ใน sidebar (ผูก `file_id`) + หน้าสรุป F15; ชุดของเอกสาร: กลุ่มตาม `type_id` จากลิงก์ e-Court (N9) — หน้า `/groups/[typeId]` การ์ดแต่ละไฟล์หุบ/กางดู bookmark+annotation ทั้งไฟล์รวมทุกไฟล์ในกลุ่ม เรียงตามหน้า กดแถวเด้งไปตำแหน่งนั้น (2026-09-23) | | **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) | @@ -83,7 +83,7 @@ ### กลุ่ม C — ของใหม่ที่ไม่เคยอยู่ในแผน - ~~**รองรับไฟล์รูปภาพ jpg/jpeg/png/tif** (5.3.4)~~ — **ทำแล้ว (2026-09-15, N3 + N4 ตามแผน `docs/PLAN-IMAGE.md`)**: รูปคือ "หน้าเอกสาร" (layers เดิมซ้อนบน `` พิกัด % ใช้ของเดิมได้ทันที) + TIFF หลายเฟรม + EXIF orientation + export PDF ฝังรูป + อัปโหลดผ่านหน้ารายการ -- **ชุดของเอกสาร (document set) + แสดง bookmark รวมหลายไฟล์** (5.3.10) — ต้องเพิ่ม data model จัดกลุ่มไฟล์ + หน้ารวม bookmark ข้ามไฟล์ (ตอนนี้ไฟล์เป็นลิสต์แบนหน้าเดียว) +- ~~**ชุดของเอกสาร (document set) + แสดง bookmark รวมหลายไฟล์** (5.3.10)~~ — **ทำแล้ว (2026-09-23, N9 ตามแผน `docs/PLAN-DOCSET.md`)**: กลุ่มตาม `files.type_id` จากลิงก์ e-Court (ไม่มีตารางกลุ่ม — จัดใน UI) + หน้า `/groups/[typeId]` กางดู bookmark+annotation รวมทุกไฟล์ของกลุ่ม ### กลุ่ม D — infrastructure (ระยะยาว) - On-Premise/Cloud packaging (5.3.24) — รวมถึงเตรียม multi-instance สำหรับ SSE From c5c6b65bd28377283b78e548838c155b64e6f985 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 24 Sep 2026 11:46:02 +0700 Subject: [PATCH 08/41] =?UTF-8?q?genesis+worker:=20design=20system=20Genes?= =?UTF-8?q?is=20=E0=B8=97=E0=B8=B1=E0=B9=89=E0=B8=87=E0=B8=8A=E0=B8=B8?= =?UTF-8?q?=E0=B8=94=20+=20patch=20pdf.worker=20=E0=B9=81=E0=B8=81?= =?UTF-8?q?=E0=B9=89=20fake-space=20=E0=B9=84=E0=B8=97=E0=B8=A2=20+=20?= =?UTF-8?q?=E0=B8=9B=E0=B8=B4=E0=B8=94=20review=20design/responsive/qualit?= =?UTF-8?q?y=20=E0=B8=A3=E0=B8=AD=E0=B8=9A=202026-09-24=20=E2=80=94=20(1)?= =?UTF-8?q?=20Genesis:=20=E0=B8=9F=E0=B8=AD=E0=B8=99=E0=B8=95=E0=B9=8C=20G?= =?UTF-8?q?eneral=20Sans=20self-host=20+=20DM=20Sans=20+=20Sarabun,=20AppN?= =?UTF-8?q?av=2056px=20backdrop-blur=20=E0=B9=81=E0=B8=97=E0=B8=99=20Sessi?= =?UTF-8?q?onBar,=20DotGrid=20=E0=B8=AB=E0=B8=99=E0=B9=89=E0=B8=B2=20login?= =?UTF-8?q?,=20palette/=E0=B8=A3=E0=B8=B1=E0=B8=A8=E0=B8=A1=E0=B8=B5/?= =?UTF-8?q?=E0=B9=80=E0=B8=87=E0=B8=B2/focus=20=E0=B8=95=E0=B8=B2=E0=B8=A1?= =?UTF-8?q?=20docs/DESIGN.md=20=E0=B8=84=E0=B8=A3=E0=B8=9A=E0=B8=97?= =?UTF-8?q?=E0=B8=B8=E0=B8=81=E0=B8=AB=E0=B8=99=E0=B9=89=E0=B8=B2=20(2)=20?= =?UTF-8?q?postinstall=20scripts/patch-pdf-worker.mjs=20=E0=B8=96=E0=B8=AD?= =?UTF-8?q?=E0=B8=94=20if=20(scaledDim)=20=E0=B8=A3=E0=B8=AD=E0=B8=9A=20pr?= =?UTF-8?q?evTransform=20=E0=B9=83=E0=B8=AB=E0=B9=89=E0=B8=AD=E0=B8=B1?= =?UTF-8?q?=E0=B8=9B=E0=B9=80=E0=B8=94=E0=B8=95=E0=B8=97=E0=B8=B8=E0=B8=81?= =?UTF-8?q?=20glyph=20=E2=80=94=20=E0=B8=81=E0=B8=B1=E0=B8=99=20addFakeSpa?= =?UTF-8?q?ces=20=E0=B9=81=E0=B8=97=E0=B8=A3=E0=B8=81=20space=20=E0=B8=81?= =?UTF-8?q?=E0=B8=A5=E0=B8=B2=E0=B8=87=E0=B8=84=E0=B8=B3=E0=B9=84=E0=B8=97?= =?UTF-8?q?=E0=B8=A2=E0=B9=83=E0=B8=99=20PDF=20=E0=B8=97=E0=B8=B5=E0=B9=88?= =?UTF-8?q?=20justify=20=E0=B8=94=E0=B9=89=E0=B8=A7=E0=B8=A2=20letter-spac?= =?UTF-8?q?ing=20+=20test=20=E0=B8=81=E0=B8=B1=E0=B8=99=20regression=20(te?= =?UTF-8?q?sts/scripts/=20+=20fixture=20thai=5Fletterspacing.pdf,=20invari?= =?UTF-8?q?ant=20#6)=20(3)=20=E0=B8=9F=E0=B8=AD=E0=B8=A3=E0=B9=8C=E0=B8=A1?= =?UTF-8?q?=E0=B8=AD=E0=B8=B1=E0=B8=9B=E0=B9=82=E0=B8=AB=E0=B8=A5=E0=B8=94?= =?UTF-8?q?=20+=20=E0=B8=8A=E0=B9=88=E0=B8=AD=E0=B8=87=E0=B8=81=E0=B8=A5?= =?UTF-8?q?=E0=B8=B8=E0=B9=88=E0=B8=A1=20typeId=20=E0=B8=97=E0=B8=B2?= =?UTF-8?q?=E0=B8=87=E0=B9=80=E0=B8=A5=E0=B8=B7=E0=B8=AD=E0=B8=81=20?= =?UTF-8?q?=E2=80=94=20normDocumentGroup=20=E0=B8=97=E0=B8=B5=E0=B9=88=20u?= =?UTF-8?q?tils/external-file.ts=20=E0=B9=80=E0=B8=9B=E0=B9=87=E0=B8=99=20?= =?UTF-8?q?single=20source=20=E0=B8=A3=E0=B9=88=E0=B8=A7=E0=B8=A1=E0=B8=81?= =?UTF-8?q?=E0=B8=B1=E0=B8=9A=20N8=20(4)=20token=20fixes:=20focus=20ring?= =?UTF-8?q?=20=E0=B8=9B=E0=B8=B8=E0=B9=88=E0=B8=A1=20pin=20+=20=E0=B9=81?= =?UTF-8?q?=E0=B8=96=E0=B8=A7=20bookmark=20=E0=B8=95=E0=B8=B2=E0=B8=A1?= =?UTF-8?q?=E0=B8=AB=E0=B8=99=E0=B9=89=E0=B8=B2,=20=E0=B8=9B=E0=B8=B8?= =?UTF-8?q?=E0=B9=88=E0=B8=A1=20action=20=E0=B9=83=E0=B8=99=20popup/compos?= =?UTF-8?q?er/dialog=20=E0=B9=80=E0=B8=9B=E0=B9=87=E0=B8=99=20rounded-md?= =?UTF-8?q?=20=E0=B8=97=E0=B8=B1=E0=B9=89=E0=B8=87=E0=B8=84=E0=B8=B9?= =?UTF-8?q?=E0=B9=88,=20selection=20preview=20=E0=B9=80=E0=B8=9B=E0=B9=87?= =?UTF-8?q?=E0=B8=99=20tint=20=E0=B8=82=E0=B8=AD=E0=B8=87=20primary=20?= =?UTF-8?q?=E0=B9=81=E0=B8=97=E0=B8=99=20blue-500=20=E0=B8=94=E0=B8=B4?= =?UTF-8?q?=E0=B8=9A,=20default=20=E0=B8=AA=E0=B8=B5=20shape/highlight=20?= =?UTF-8?q?=E0=B8=A3=E0=B8=A7=E0=B8=A1=E0=B8=97=E0=B8=B5=E0=B9=88=20utils/?= =?UTF-8?q?annotation-display,=20loading=20animate-pulse=20+=20=E0=B8=88?= =?UTF-8?q?=E0=B8=94=E0=B8=82=E0=B9=89=E0=B8=AD=E0=B8=A2=E0=B8=81=E0=B9=80?= =?UTF-8?q?=E0=B8=A7=E0=B9=89=E0=B8=99=20(=E0=B9=80=E0=B8=87=E0=B8=B2=20pi?= =?UTF-8?q?n,=20font=20weights=20=E0=B8=82=E0=B8=AD=E0=B8=87=20viewer)=20?= =?UTF-8?q?=E0=B8=A5=E0=B8=87=20DESIGN.md=20(5)=20responsive:=20Export=20P?= =?UTF-8?q?DF=20max-lg:order-first=20=E0=B9=83=E0=B8=AB=E0=B9=89=E0=B9=80?= =?UTF-8?q?=E0=B8=AB=E0=B9=87=E0=B8=99=E0=B8=9A=E0=B8=99=20iPad,=20?= =?UTF-8?q?=E0=B9=81=E0=B8=96=E0=B8=A7=E0=B8=9A=E0=B8=99=20header=20?= =?UTF-8?q?=E0=B9=81=E0=B8=96=E0=B8=A7=E0=B9=80=E0=B8=94=E0=B8=B5=E0=B8=A2?= =?UTF-8?q?=E0=B8=A7=E0=B9=80=E0=B8=A5=E0=B8=B7=E0=B9=88=E0=B8=AD=E0=B8=99?= =?UTF-8?q?=E0=B9=84=E0=B8=94=E0=B9=89=E0=B8=9A=E0=B8=99=E0=B8=88=E0=B8=AD?= =?UTF-8?q?=E0=B9=81=E0=B8=84=E0=B8=9A,=20re-fit=20=E0=B8=84=E0=B8=A7?= =?UTF-8?q?=E0=B8=B2=E0=B8=A1=E0=B8=81=E0=B8=A7=E0=B9=89=E0=B8=B2=E0=B8=87?= =?UTF-8?q?=E0=B8=95=E0=B8=AD=E0=B8=99=20toggle=20sidebar=20=E0=B9=80?= =?UTF-8?q?=E0=B8=89=E0=B8=9E=E0=B8=B2=E0=B8=B0=E0=B9=80=E0=B8=A1=E0=B8=B7?= =?UTF-8?q?=E0=B9=88=E0=B8=AD=E0=B8=A2=E0=B8=B1=E0=B8=87=E0=B9=84=E0=B8=A1?= =?UTF-8?q?=E0=B9=88=E0=B8=8B=E0=B8=B9=E0=B8=A1=E0=B9=80=E0=B8=AD=E0=B8=87?= =?UTF-8?q?,=20pin=20popover=20flip=20=E0=B8=82=E0=B8=B6=E0=B9=89=E0=B8=99?= =?UTF-8?q?=E0=B8=9A=E0=B8=99=E0=B9=80=E0=B8=A1=E0=B8=B7=E0=B9=88=E0=B8=AD?= =?UTF-8?q?=E0=B9=83=E0=B8=81=E0=B8=A5=E0=B9=89=E0=B8=97=E0=B9=89=E0=B8=B2?= =?UTF-8?q?=E0=B8=A2=E0=B8=AB=E0=B8=99=E0=B9=89=E0=B8=B2,=20toast=20hint?= =?UTF-8?q?=20=E0=B8=95=E0=B8=AD=E0=B8=99=E0=B8=AA=E0=B8=A5=E0=B8=B1?= =?UTF-8?q?=E0=B8=9A=20tool=20=E0=B8=81=E0=B8=A5=E0=B8=B2=E0=B8=87?= =?UTF-8?q?=E0=B9=80=E0=B8=AD=E0=B8=81=E0=B8=AA=E0=B8=B2=E0=B8=A3,=20?= =?UTF-8?q?=E0=B8=9B=E0=B9=89=E0=B8=B2=E0=B8=A2=20"=E0=B8=8A=E0=B8=99?= =?UTF-8?q?=E0=B8=B4=E0=B8=94=20=C2=B7=20=E0=B8=9C=E0=B8=B9=E0=B9=89?= =?UTF-8?q?=E0=B8=97=E0=B8=B3"=20=E0=B8=82=E0=B8=AD=E0=B8=87=E0=B8=A3?= =?UTF-8?q?=E0=B8=B2=E0=B8=A2=E0=B8=81=E0=B8=B2=E0=B8=A3=E0=B8=9C=E0=B8=B9?= =?UTF-8?q?=E0=B9=89=E0=B8=AD=E0=B8=B7=E0=B9=88=E0=B8=99=E0=B8=AA=E0=B8=B3?= =?UTF-8?q?=E0=B8=AB=E0=B8=A3=E0=B8=B1=E0=B8=9A=E0=B8=88=E0=B8=AD=E0=B8=AA?= =?UTF-8?q?=E0=B8=B1=E0=B8=A1=E0=B8=9C=E0=B8=B1=E0=B8=AA=20(6)=20=E0=B8=84?= =?UTF-8?q?=E0=B8=B8=E0=B8=93=E0=B8=A0=E0=B8=B2=E0=B8=9E:=20@layer=20?= =?UTF-8?q?=E0=B8=84=E0=B8=A3=E0=B8=AD=E0=B8=9A=20@media=20=E0=B8=81?= =?UTF-8?q?=E0=B8=B1=E0=B8=99=E0=B8=81=E0=B8=8E=20touch=20=E0=B8=A3?= =?UTF-8?q?=E0=B8=B1=E0=B9=88=E0=B8=A2=E0=B9=84=E0=B8=9B=20desktop=20(Tail?= =?UTF-8?q?wind=20v3=20=E0=B8=A2=E0=B8=81=20@layer=20=E0=B8=AD=E0=B8=AD?= =?UTF-8?q?=E0=B8=81=E0=B8=88=E0=B8=B2=E0=B8=81=20media=20=E0=B8=97?= =?UTF-8?q?=E0=B8=B4=E0=B9=89=E0=B8=87=20wrapper=20=E2=80=94=20=E0=B8=A2?= =?UTF-8?q?=E0=B8=B7=E0=B8=99=E0=B8=A2=E0=B8=B1=E0=B8=99=E0=B8=94=E0=B9=89?= =?UTF-8?q?=E0=B8=A7=E0=B8=A2=20build=20=E0=B8=88=E0=B8=A3=E0=B8=B4?= =?UTF-8?q?=E0=B8=87=20+=20=E0=B8=A7=E0=B8=B1=E0=B8=94=E0=B8=9A=E0=B8=99?= =?UTF-8?q?=E0=B8=88=E0=B8=AD),=20=E0=B8=81=E0=B8=8E=20M5=20=E0=B8=84?= =?UTF-8?q?=E0=B8=A3=E0=B8=AD=E0=B8=9A=20contenteditable=20=E0=B8=81?= =?UTF-8?q?=E0=B8=B1=E0=B8=99=20Safari=20zoom=20=E0=B8=95=E0=B8=AD?= =?UTF-8?q?=E0=B8=99=E0=B9=81=E0=B8=81=E0=B9=89=E0=B9=84=E0=B8=82=20TEXTBO?= =?UTF-8?q?X=20=E0=B8=9A=E0=B8=99=20iPad,=20COMMENT=20=E0=B9=83=E0=B8=8A?= =?UTF-8?q?=E0=B9=89=20box=20=E0=B8=A1=E0=B8=B2=E0=B8=95=E0=B8=A3=E0=B8=B2?= =?UTF-8?q?=E0=B8=90=E0=B8=B2=E0=B8=99=E0=B9=80=E0=B8=94=E0=B8=B5=E0=B8=A2?= =?UTF-8?q?=E0=B8=A7=E0=B8=95=E0=B8=B2=E0=B8=A1=20pin=20px=20=E0=B8=88?= =?UTF-8?q?=E0=B8=A3=E0=B8=B4=E0=B8=87=20(render/scroll=20target/move-clam?= =?UTF-8?q?p/bookmark),=20=E0=B8=95=E0=B8=B1=E0=B8=94=E0=B8=AB=E0=B8=B1?= =?UTF-8?q?=E0=B8=A7=E0=B8=81=E0=B8=B2=E0=B8=A3=E0=B9=8C=E0=B8=94=E0=B8=8B?= =?UTF-8?q?=E0=B9=89=E0=B8=B3=E0=B8=81=E0=B8=B1=E0=B8=9A=20h2=20=E0=B8=AB?= =?UTF-8?q?=E0=B8=99=E0=B9=89=E0=B8=B2=E0=B8=A3=E0=B8=B2=E0=B8=A2=E0=B8=81?= =?UTF-8?q?=E0=B8=B2=E0=B8=A3=20=E2=80=94=20=E0=B8=9E=E0=B8=A3=E0=B9=89?= =?UTF-8?q?=E0=B8=AD=E0=B8=A1=E0=B9=84=E0=B8=9F=E0=B8=A5=E0=B9=8C=20untrac?= =?UTF-8?q?ked=20=E0=B8=97=E0=B8=B5=E0=B9=88=20postinstall/=E0=B9=82?= =?UTF-8?q?=E0=B8=84=E0=B9=89=E0=B8=94=E0=B8=AD=E0=B9=89=E0=B8=B2=E0=B8=87?= =?UTF-8?q?=E0=B8=96=E0=B8=B6=E0=B8=87=E0=B8=84=E0=B8=A3=E0=B8=9A=E0=B8=8A?= =?UTF-8?q?=E0=B8=B8=E0=B8=94=20(app/fonts/,=20AppNav,=20DotGrid,=20tests/?= =?UTF-8?q?)=20=E0=B8=81=E0=B8=B1=E0=B8=99=20fresh=20clone=20fail=20?= =?UTF-8?q?=E0=B8=97=E0=B8=B5=E0=B9=88=20npm=20install=20(=E0=B8=84?= =?UTF-8?q?=E0=B8=B3=E0=B8=82=E0=B8=AD=E0=B8=9C=E0=B8=B9=E0=B9=89=E0=B9=83?= =?UTF-8?q?=E0=B8=8A=E0=B9=89=202026-09-24)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Code --- .claude/rules/data-layer.md | 4 +- .claude/rules/pdf-viewer.md | 2 +- CLAUDE.md | 18 +- app/actions/files.ts | 4 +- app/annotate/[id]/summary/page.tsx | 104 +++--- app/annotate/page.tsx | 10 +- app/fonts/GeneralSans-Variable.woff2 | Bin 0 -> 38132 bytes app/globals.css | 114 +++++- app/groups/[typeId]/page.tsx | 325 +++++++++-------- app/layout.tsx | 45 ++- app/login/page.tsx | 6 +- app/page.tsx | 261 +++++++------- components/AnnotationTypeIcon.tsx | 2 +- components/AppNav.tsx | 36 ++ components/DotGrid.tsx | 38 ++ components/EcourtLinkForm.tsx | 49 +-- components/LoginForm.tsx | 46 ++- components/LogoutButton.tsx | 2 +- components/Select.tsx | 2 +- components/SessionBar.tsx | 15 - components/UploadFileForm.tsx | 33 +- components/pdf/AnnotationSelectionLayer.tsx | 86 ++++- components/pdf/PDFViewer.tsx | 374 +++++++++++++------- components/pdf/TextboxStampLayer.tsx | 2 +- components/pdf/ToolControls.tsx | 24 +- components/sidebar/AnnotationSidebar.tsx | 218 ++++++------ components/summary/CollapsibleBlock.tsx | 12 +- db/schema.ts | 2 +- docker/web.Dockerfile | 5 +- docs/DESIGN.md | 78 ++++ docs/PLAN-DOCSET.md | 15 +- docs/PLAN-FEAT.md | 6 +- lib/external-import.ts | 9 +- package.json | 2 +- public/pdf.worker.min.mjs | 2 +- scripts/patch-pdf-worker.mjs | 44 +++ tailwind.config.ts | 62 ++++ tests/fixtures/thai_letterspacing.pdf | Bin 0 -> 117548 bytes tests/scripts/patch-pdf-worker.test.ts | 68 ++++ utils/annotation-display.ts | 7 + utils/external-file.ts | 12 + utils/geometry.ts | 30 +- 42 files changed, 1429 insertions(+), 745 deletions(-) create mode 100644 app/fonts/GeneralSans-Variable.woff2 create mode 100644 components/AppNav.tsx create mode 100644 components/DotGrid.tsx delete mode 100644 components/SessionBar.tsx create mode 100644 docs/DESIGN.md create mode 100644 scripts/patch-pdf-worker.mjs create mode 100644 tests/fixtures/thai_letterspacing.pdf create mode 100644 tests/scripts/patch-pdf-worker.test.ts diff --git a/.claude/rules/data-layer.md b/.claude/rules/data-layer.md index a8d268d..6bd6e41 100644 --- a/.claude/rules/data-layer.md +++ b/.claude/rules/data-layer.md @@ -20,5 +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 แล้ว `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 — ไม่มีตารางกลุ่ม +- 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 — ไม่มีตารางกลุ่ม diff --git a/.claude/rules/pdf-viewer.md b/.claude/rules/pdf-viewer.md index 7913bd7..d5cc7fb 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/` ด้วย postinstall) — ห้าม `new URL(..., import.meta.url)` +- 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)` - 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/CLAUDE.md b/CLAUDE.md index 2f7fbc8..3eac683 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 คัดลอกจาก node_modules) +- 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) - 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 +- 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/แถบหัวหน้าสรุปยกเว้น) - 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 คัดลอกไฟล์ไป `public/`) — ห้ามใช้ `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 `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)` 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,6 +33,7 @@ ## โครงสร้างไฟล์ (ตามแผน) ``` +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/) @@ -47,7 +48,7 @@ utils/annotation-display.ts ← label/ข้อความย่อต่อช 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 (pure) +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/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) @@ -56,7 +57,7 @@ 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/ (N4) +app/actions/files.ts ← Server Action uploadFile — validate + เขียนไฟล์ใหม่ที่ public/uploads/ + ช่องกลุ่ม typeId ทางเลือก normalize เหมือน N8 (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/annotations/export-csv/route.ts ← export สรุป CSV พร้อม BOM (F16) @@ -74,15 +75,16 @@ 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 ← ฟอร์มอัปโหลดไฟล์บนหน้ารายการ (N4) +components/UploadFileForm.tsx ← ฟอร์มอัปโหลดไฟล์บนหน้ารายการ + ช่องกลุ่ม typeId ทางเลือก ไม่กรอก = ไม่มีกลุ่ม (N4) components/EcourtLinkForm.tsx ← การ์ดจำลองลิงก์ e-Court บนหน้ารายการ (N8 — กรอก fileUrl/fileId/typeId กด "เปิดเอกสาร" แล้ว router.push ไป /?fileUrl=&fileId=&typeId= ให้หน้า / ลงทะเบียน+redirect เอง — ใช้ทดสอบก่อนมีลิงก์จริงจาก e-Court) components/LoginForm.tsx ← ฟอร์ม login (client — เรียก action login แล้ว router.replace("/")) -components/SessionBar.tsx ← แถบแสดงผู้ใช้ที่ login + ปุ่มออกจากระบบบนหน้ารายการ (แทน UserPicker เดิม) +components/AppNav.tsx ← nav sticky 56px แบบ Genesis (backdrop-blur + border-b) ใช้ร่วมหน้า / และ /groups/[typeId] — โลโก้ซ้าย + ชื่อผู้ใช้/LogoutButton ขวา (แทน SessionBar เดิม 2026-09-23) +components/DotGrid.tsx ← กริดจุดอันตรกิริยาตาม pointer พื้นหลังหน้า login — decorative ชิ้นเดียวที่ Genesis อนุญาต (อัปเดตตำแหน่งผ่าน CSS variable บน style ไม่ setState) ``` ## ขอบเขต — ห้ามทำเกิน (Out of Scope) -> ขอบเขตด้านล่างเป็นของ**เฟสแรก** — ฟีเจอร์ต่อยอด (edit/ย้าย, undo/redo, export, reply, resolve ฯลฯ) อนุญาต**เฉพาะตาม task F1–F19 ใน `docs/PLAN-FEAT.md`** และทำผ่าน `/implement-feat` เท่านั้น ห้ามทำเกินสเปกของแผนนั้น — **ปัจจุบันทำครบตามแผนแล้ว ยกเว้น F11 (export/import JSON) ที่ถูกถอดออกจากโค้ดแล้ว**: แก้ไข/ย้าย/ปรับขนาด (F1), undo/redo (F2), คีย์ลัด (F3), กรองรายคน (F4), รูปทรง (F5), eraser (F6), สไตลัส/pressure (F7), redaction + export PDF (F8), reply เธรด (F9), resolve (F10), deep link (F12), แก้ไข comment (F13), ค้นหา (F14), หน้าสรุป (F15), export CSV (F16), text box (F17), stamp (F18), version history + กู้คืน (F19) — ฟีเจอร์เสริมหลังแผน (อนุญาตตาม §7 ของ `docs/PLAN-FEAT.md`): โน้ตคำอธิบาย `note?` ฝังใน annotation ทุกชนิดยกเว้น COMMENT แก้ไขใน sidebar + hover tooltip (N1), bookmark โครงสร้าง 3 ระดับ `parent_id` + โน้ต + popup สร้าง/แก้ไข/ย้ายหัวข้อ + ปุ่ม bookmark ในแผง selection + ชื่อ default จากข้อความที่คลุม (N2), รองรับไฟล์รูปภาพ jpg/jpeg/png/tif เป็นเอกสาร annotate ได้ครบทุกชนิด + TIFF หลายเฟรม = หลายหน้า + export PDF ฝังรูป (N3 — แผน `docs/PLAN-IMAGE.md`), อัปโหลดไฟล์ PDF/รูปผ่านหน้ารายการแบบง่าย (N4), bookmark เรียงตามลำดับหน้า (N5), ค้นหา bookmark พร้อมเงื่อนไข คำค้น/ช่วงหน้า/ผู้บันทึก/ช่วงวันที่ + `bookmarks.user_name` (N6 — แผน `docs/PLAN-BOOKMARK.md`), รายงานรายการ bookmark เป็น PDF ผ่าน route แยก (สร้างเอกสารใหม่ล้วน ไม่โหลดไฟล์ต้นฉบับ — N7 แผน `docs/PLAN-BOOKMARK-PDF.md` — **2026-09-17 เปลี่ยน deliverable เป็นส่งออก JSON** แทนรายงาน PDF ตามคำขอผู้ใช้: flat array `BookmarkDTO` เรียง N5 + envelope fileId/title/exportedAt ผ่าน route เดิม), เปิดเอกสารจากลิงก์ e-Court `?fileUrl=&fileId=&typeId=` — ลงทะเบียน metadata-only (dedupe `external_id`, ไม่เก็บสำเนาไฟล์) + ดึงไฟล์สดตอนเปิดดู/export ผ่าน allowlist + การ์ดจำลองลิงก์บนหน้ารายการ + รับ path ในเครื่อง (`/samples/…`) เพื่อทดสอบ grouping ใน dev (N8 — แผน `docs/PLAN-ECOURT.md`, implement ครบแล้ว 2026-09-22 รวม D5 login วนกลับ), กลุ่มเอกสารตาม `type_id` จากลิงก์ e-Court — หน้ารายการ `/` จัด section ต่อกลุ่ม + ส่วน "ไม่มีกลุ่ม" ท้ายสุด และหน้า `/groups/[typeId]` แสดงเอกสารที่รู้จักของกลุ่มพร้อมหมายเหตุ "แสดงเฉพาะเอกสารที่เคยเปิดในระบบนี้" + การ์ดแต่ละไฟล์หุบ/กางดู bookmark+annotation ทั้งไฟล์ (2026-09-23 ตามคำขอผู้ใช้ — ปิด gap 5.3.10 ครึ่งหลัง bookmark รวมทั้งกลุ่ม; อ่าน `type_id` อย่างเดียว — เขียนที่ N8 `registerExternalFile` เท่านั้น ไม่มี action/SSE ใหม่) (N9 — แผน `docs/PLAN-DOCSET.md`) +> ขอบเขตด้านล่างเป็นของ**เฟสแรก** — ฟีเจอร์ต่อยอด (edit/ย้าย, undo/redo, export, reply, resolve ฯลฯ) อนุญาต**เฉพาะตาม task F1–F19 ใน `docs/PLAN-FEAT.md`** และทำผ่าน `/implement-feat` เท่านั้น ห้ามทำเกินสเปกของแผนนั้น — **ปัจจุบันทำครบตามแผนแล้ว ยกเว้น F11 (export/import JSON) ที่ถูกถอดออกจากโค้ดแล้ว**: แก้ไข/ย้าย/ปรับขนาด (F1), undo/redo (F2), คีย์ลัด (F3), กรองรายคน (F4), รูปทรง (F5), eraser (F6), สไตลัส/pressure (F7), redaction + export PDF (F8), reply เธรด (F9), resolve (F10), deep link (F12), แก้ไข comment (F13), ค้นหา (F14), หน้าสรุป (F15), export CSV (F16), text box (F17), stamp (F18), version history + กู้คืน (F19) — ฟีเจอร์เสริมหลังแผน (อนุญาตตาม §7 ของ `docs/PLAN-FEAT.md`): โน้ตคำอธิบาย `note?` ฝังใน annotation ทุกชนิดยกเว้น COMMENT แก้ไขใน sidebar + hover tooltip (N1), bookmark โครงสร้าง 3 ระดับ `parent_id` + โน้ต + popup สร้าง/แก้ไข/ย้ายหัวข้อ + ปุ่ม bookmark ในแผง selection + ชื่อ default จากข้อความที่คลุม (N2), รองรับไฟล์รูปภาพ jpg/jpeg/png/tif เป็นเอกสาร annotate ได้ครบทุกชนิด + TIFF หลายเฟรม = หลายหน้า + export PDF ฝังรูป (N3 — แผน `docs/PLAN-IMAGE.md`), อัปโหลดไฟล์ PDF/รูปผ่านหน้ารายการแบบง่าย + ช่องกลุ่ม typeId ทางเลือก ไม่กรอก = ไม่มีกลุ่ม (N4 — เพิ่มช่องกลุ่ม 2026-09-23 ตามคำขอผู้ใช้), bookmark เรียงตามลำดับหน้า (N5), ค้นหา bookmark พร้อมเงื่อนไข คำค้น/ช่วงหน้า/ผู้บันทึก/ช่วงวันที่ + `bookmarks.user_name` (N6 — แผน `docs/PLAN-BOOKMARK.md`), รายงานรายการ bookmark เป็น PDF ผ่าน route แยก (สร้างเอกสารใหม่ล้วน ไม่โหลดไฟล์ต้นฉบับ — N7 แผน `docs/PLAN-BOOKMARK-PDF.md` — **2026-09-17 เปลี่ยน deliverable เป็นส่งออก JSON** แทนรายงาน PDF ตามคำขอผู้ใช้: flat array `BookmarkDTO` เรียง N5 + envelope fileId/title/exportedAt ผ่าน route เดิม), เปิดเอกสารจากลิงก์ e-Court `?fileUrl=&fileId=&typeId=` — ลงทะเบียน metadata-only (dedupe `external_id`, ไม่เก็บสำเนาไฟล์) + ดึงไฟล์สดตอนเปิดดู/export ผ่าน allowlist + การ์ดจำลองลิงก์บนหน้ารายการ + รับ path ในเครื่อง (`/samples/…`) เพื่อทดสอบ grouping ใน dev (N8 — แผน `docs/PLAN-ECOURT.md`, implement ครบแล้ว 2026-09-22 รวม D5 login วนกลับ), กลุ่มเอกสารตาม `type_id` จากลิงก์ e-Court — หน้ารายการ `/` จัด section ต่อกลุ่ม + ส่วน "ไม่มีกลุ่ม" ท้ายสุด และหน้า `/groups/[typeId]` แสดงเอกสารที่รู้จักของกลุ่มพร้อมหมายเหตุ "แสดงเฉพาะเอกสารที่เคยเปิดในระบบนี้" + การ์ดแต่ละไฟล์หุบ/กางดู bookmark+annotation ทั้งไฟล์ (2026-09-23 ตามคำขอผู้ใช้ — ปิด gap 5.3.10 ครึ่งหลัง bookmark รวมทั้งกลุ่ม; อ่าน `type_id` อย่างเดียว — เขียนสองที่: N8 `registerExternalFile` + ช่องกลุ่มทางเลือกของฟอร์มอัปโหลด N4 `uploadFile` (2026-09-23 ตามคำขอผู้ใช้ — insert ตอนสร้าง row ใหม่เท่านั้น normalize เหมือน N8: trim → ว่าง = NULL, cap 200) ไม่มี action/SSE ใหม่) (N9 — แผน `docs/PLAN-DOCSET.md`) - ไม่มี auth จริง/roles (login เป็น mock หน้า `/login` ตั้ง cookie `oacCase` — ไม่ได้ตรวจกับระบบ e-Court จริง), ไม่มี presence — undo/redo เป็น history ฝั่ง client ต่อ session ต่อผู้ใช้ ไม่เก็บใน DB (D10) ส่วน version history/กู้คืน (F19) เก็บในตาราง `annotation_versions` - Annotation: สร้าง + ลบ + แก้ไข/ย้าย/ปรับขนาด/เปลี่ยนสี–ความหนา/แก้ไขข้อความ **ของตัวเอง ในหน้าเดียวกัน** — resolve เป็นสิ่งเดียวที่ใครก็ toggle ได้ (D6) diff --git a/app/actions/files.ts b/app/actions/files.ts index 6826269..40b013d 100644 --- a/app/actions/files.ts +++ b/app/actions/files.ts @@ -11,6 +11,7 @@ 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) @@ -94,8 +95,9 @@ export async function uploadFile(formData: FormData): Promise const title = sanitizeTitle(file.name); const pdfUrl = `/uploads/${diskName}`; + const typeId = normDocumentGroup(formData.get("typeId")); try { - await db.insert(files).values({ id, title, pdfUrl }); + await db.insert(files).values({ id, title, pdfUrl, typeId }); } catch (err) { // DB fail หลังเขียนไฟล์ — เก็บกวาดไฟล์ก่อนโยนต่อ กันไฟล์กำพร้าไม่มี row ชี้ (ไม่มีกลไกเก็บอื่น) await unlink(diskPath).catch(() => undefined); diff --git a/app/annotate/[id]/summary/page.tsx b/app/annotate/[id]/summary/page.tsx index ff61f2a..5e7d74b 100644 --- a/app/annotate/[id]/summary/page.tsx +++ b/app/annotate/[id]/summary/page.tsx @@ -84,11 +84,10 @@ function pageSummaryLine(b: PageBucket): string { return parts.length > 0 ? `${parts.join(" · ")} · โดย ${whoLabel}` : `โดย ${whoLabel}`; } -/** focus ring ร่วมของทุกลิงก์ในหน้า — จอสัมผัสได้ 44px อัตโนมัติจากกฎ - * `main.summary-page a`/`button` ใน globals.css (ห้ามถอด class summary-page - * ของ
) */ -const RING_CLS = - "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1"; +/** focus ring ร่วมของทุกลิงก์ในหน้า — Genesis .focus-ring (outline น้ำเงินทึบ 2px + * + offset ตาม spec Elevation); จอสัมผัสได้ 44px อัตโนมัติจากกฎ + * `main.summary-page a`/`button` ใน globals.css (ห้ามถอด class summary-page ของ
) */ +const RING_CLS = "focus-ring"; export const dynamic = "force-dynamic"; @@ -177,7 +176,7 @@ export default async function SummaryPage({
{/* หัวกระดาษสำนวน — แถบน้ำเงินสืบต่อจาก tile JN ของแบรนด์ (หน้ารายการ/login): ชื่อรายงาน + ชื่อแฟ้ม + สถิติในที่เดียว แล้วปิดท้ายด้วยเส้นคั่นบาง */} -
+

สรุปหมายเหตุ

{file.title}

{pages.length > 0 && ( @@ -194,7 +193,7 @@ export default async function SummaryPage({ {pages.length === 0 ? ( -
- -

+

+ +

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

-

+

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

เปิดหน้าอ่าน @@ -242,37 +241,37 @@ export default async function SummaryPage({ ไปบนมือถือ (aside ด้านล่างแสดงเฉพาะ lg) ใช้
ได้หุบ/กาง + keyboard โดยไม่มี JS เพิ่ม — จุดไข่ปลา + ผู้ร่วมทำ ภาษาเดียวกับ desktop (จอ lg กับ ตอนพิมพ์ซ่อน — display:none ไม่กินช่องของกริด) */} -
- +
+ รายการตามหน้าและผู้ร่วมทำ -

+

ผู้ร่วมทำ: {allParticipants.join(" · ")}

@@ -281,32 +280,32 @@ export default async function SummaryPage({ เป็นการ์ดชุดเดียวกับ
มือถือและการ์ดรายหน้า (แสดงเฉพาะหน้าที่มี รายการ ≥ 1 ตามหลัก minimize choices; max-h กันเอกสารหลายสิบหน้าสูงเกินจอ — ครึ่งล่างของ sticky กดไม่ได้ตลอดกาล) */} -
+ )} + + ); + })} + +
+ ); } diff --git a/app/layout.tsx b/app/layout.tsx index 7a6a612..d0d15e3 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,17 +1,39 @@ import type { Metadata } from "next"; -import { Sarabun } from "next/font/google"; +import { DM_Sans, Sarabun } from "next/font/google"; +import localFont from "next/font/local"; import "./globals.css"; -/** 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 ข้อความตรงกันข้ามอุปกรณ์จริง) */ +/* 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", +}); + const sarabun = Sarabun({ subsets: ["thai", "latin"], - weight: ["400", "600"], + weight: ["400", "600", "700"], display: "swap", variable: "--font-sarabun", }); @@ -27,8 +49,11 @@ export default function RootLayout({ children: React.ReactNode; }) { return ( - - {children} + + {children} ); } diff --git a/app/login/page.tsx b/app/login/page.tsx index 824903c..8ce183f 100644 --- a/app/login/page.tsx +++ b/app/login/page.tsx @@ -1,6 +1,7 @@ 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"; @@ -24,7 +25,10 @@ export default function LoginPage({ if (user) redirect(next ?? "/"); return ( -
+ // Genesis: กริดจุดอันตรกิริยา ( decorative element ชิ้นเดียวที่อนุญาต) เป็นพื้นหลัง + // หน้า login — DotGrid อยู่ก่อนการ์ดใน DOM จึงถูกทับโดยการ์ดเอง ไม่ต้องเล่น z-index +
+
); diff --git a/app/page.tsx b/app/page.tsx index f9fba77..4e49fdd 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -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,35 +32,42 @@ function kindBadge(pdfUrl: string): string { } } -/** การ์ดไฟล์ของหน้ารายการ — ใช้ร่วมทุก section (N9 จัดกลุ่มแล้วยังหน้าตาเดิม) */ +/** การ์ดไฟล์ของหน้ารายการ (Genesis kit-card: surface 12px radius ขอบ line ไม่มีเงา + * static — hover ยก -2px + shadow-card-hover; tile ไอคอนเป็นกลาง น้ำเงินสงวนให้ของ + * interactive ตาม Do&Don't ของ design system) ใช้ร่วมทุก section (N9) */ function FileCard({ file }: { file: FileRow }) { return ( -
  • -
    - {imageFormatFromUrl(file.pdfUrl) ? ( - - ) : ( - - )} +
  • +
    +
    + {imageFormatFromUrl(file.pdfUrl) ? ( + + ) : ( + + )} +
    + + {kindBadge(file.pdfUrl)} +
    - - - {file.title} - - - - {kindBadge(file.pdfUrl)} - - {formatThaiDate(file.createdAt, true)} - + + {file.title} - {/* ปุ่มเข้า annotate แทนลิงก์กางเต็มการ์ด (M4) ตาม feedback — เป้าจิ้ม 36px (H4) */} - - Annotate - + {/* วันที่เป็นข้อมูลที่ผู้ใช้อ่านจริง — ink-soft (ผ่าน 4.5:1); ink-faint เก็บไว้ + ให้ placeholder/disabled เท่านั้น (UX review M2 2026-09-23) */} + + {formatThaiDate(file.createdAt, true)} + + {/* ปุ่มเข้า annotate แทนลิงก์กางเต็มการ์ด (M4) ตาม feedback — เป็น secondary + (หนึ่ง primary ต่อ section ตาม Genesis; การ์ดทั้งกริดคือ passive surface) */} +
    + + Annotate + +
  • ); } @@ -113,103 +120,117 @@ export default async function HomePage({ ); return ( -
    -
    -
    - JN -
    -
    -

    - Judge's Note -

    -

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

    -
    -
    + <> + + {/* listing-page: hook ของกฎ coarse 44px ใน globals.css (อย่าถอด — เหมือน + summary-page ของหน้าสรุป) */} +
    + {/* ชื่อระบบ Latin ล้วน → General Sans tracking แน่นตาม Genesis display type */} +

    + Judge's Note +

    +

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

    - - - {registerError && ( -
    - เปิดเอกสารจากลิงก์ไม่สำเร็จ: {registerError} -
    - )} - - {/* การ์ดเปิดเอกสารจากลิงก์ e-Court — บล็อกแยกของตัวเอง มีหัวเรื่องในตัวแล้ว (N8) */} - - -
    -

    เพิ่มเอกสาร

    - -
    - -
    -

    เอกสาร

    - {rows.length > 0 && ( - {rows.length} ไฟล์ + {registerError && ( +
    + เปิดเอกสารจากลิงก์ไม่สำเร็จ: {registerError} +
    )} -
    - {rows.length === 0 ? ( -
    - -

    - ยังไม่มีไฟล์ในระบบ -

    -

    - รัน /sync-db เพื่อ seed ไฟล์ตัวอย่าง -

    -
    - ) : ( - <> - {sections.map(([typeId, list]) => ( -
    -
    - - - {typeId} - - - {list.length} ไฟล์ - - {/* Next Link เติม basePath ให้เอง (invariant #12) */} - - เอกสารในกลุ่ม - -
    -
      - {list.map((f) => ( - - ))} -
    -
    - ))} - {ungrouped.length > 0 && ( -
    -
    - - ไม่มีกลุ่ม - - - {ungrouped.length} ไฟล์ - -
    -
      - {ungrouped.map((f) => ( - - ))} -
    -
    + {/* การ์ดจำลองลิงก์ e-Court (N8) อยู่เหนือรายการเอกสาร — เป็นทางเข้าเปิดเอกสาร + จากระบบ e-Court ไม่ใช่การเพิ่มไฟล์เข้ารายการ (คุมความกว้างเดียวกับ + ฟอร์มอัปโหลด) */} +
    +
    +

    + เปิดเอกสารจากลิงก์ e-Court +

    +
    +
    + +
    +
    + +
    +
    +

    เอกสาร

    + {rows.length > 0 && ( + + {rows.length} ไฟล์ + + )} +
    + + {/* อัปโหลดไฟล์ (N4) อยู่กับรายการเอกสาร — ไฟล์ที่อัปโหลดเข้ารายการนี้เอง + (คุมความกว้างไม่ให้ฟอร์มกินเต็มความกว้างของกริดรายการ) */} +
    + +
    + + {rows.length === 0 ? ( +
    + +

    + ยังไม่มีไฟล์ในระบบ +

    +

    + รัน /sync-db เพื่อ seed ไฟล์ตัวอย่าง +

    +
    + ) : ( + <> + {sections.map(([typeId, list]) => ( +
    +
    + +

    + {typeId} +

    + + {list.length} ไฟล์ + + {/* Next Link เติม basePath ให้เอง (invariant #12) */} + + เอกสารในกลุ่ม + +
    +
      + {list.map((f) => ( + + ))} +
    +
    + ))} + {ungrouped.length > 0 && ( +
    +
    +

    ไม่มีกลุ่ม

    + + {ungrouped.length} ไฟล์ + +
    +
      + {ungrouped.map((f) => ( + + ))} +
    +
    + )} + )} - - )} -
    + +
    + ); } diff --git a/components/AnnotationTypeIcon.tsx b/components/AnnotationTypeIcon.tsx index b7fdb72..c8a8a2a 100644 --- a/components/AnnotationTypeIcon.tsx +++ b/components/AnnotationTypeIcon.tsx @@ -26,7 +26,7 @@ import type { export function AnnotationTypeIcon({ type, data, - className = "h-4 w-4 shrink-0 text-slate-500", + className = "h-4 w-4 shrink-0 text-ink-soft", }: { type: AnnotationDTO["type"]; data: AnnotationDTO["data"]; diff --git a/components/AppNav.tsx b/components/AppNav.tsx new file mode 100644 index 0000000..b1537d6 --- /dev/null +++ b/components/AppNav.tsx @@ -0,0 +1,36 @@ +// Genesis (docs/DESIGN.md) Navigation: sticky แถบบน 56px + backdrop-blur + เส้นแบ่ง +// ล่าง 1px — โลโก้ซ้าย / ผู้ใช้ + ออกจากระบบ ขวา (แอปหน้าเดียวต่อ context จึงไม่มี +// ลิงก์กลางจอ) ใช้ร่วมทุกหน้าเนื้อหา (หน้ารายการ /, กลุ่ม /groups/[typeId]) — +// แทน SessionBar เดิมที่แยกเป็นแถบของตัวเองกลางหน้า +import Link from "next/link"; + +import LogoutButton from "./LogoutButton"; + +export default function AppNav({ userName }: { userName: string }) { + return ( + // appnav: hook ของกฎ coarse 44px ให้ปุ่มในแถบนี้ (globals.css — อย่าถอด) +
    +
    + {/* ชื่อแบรนด์เป็น ink ตามกฎ "น้ำเงินเฉพาะของ interactive" — tile JN น้ำเงินคือ + แบรนด์มาร์ก (สืบต่อจากหน้าสรุป/login ดั้งเดิม) ทั้งแถบเป็นลิงก์กลับหน้ารายการ */} + + + JN + + + Judge's Note + + +
    +

    + {userName} +

    + +
    +
    +
    + ); +} diff --git a/components/DotGrid.tsx b/components/DotGrid.tsx new file mode 100644 index 0000000..3e40ddf --- /dev/null +++ b/components/DotGrid.tsx @@ -0,0 +1,38 @@ +"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(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 ( +
    +
    +
    + ); +} diff --git a/components/EcourtLinkForm.tsx b/components/EcourtLinkForm.tsx index 53463e2..210985b 100644 --- a/components/EcourtLinkForm.tsx +++ b/components/EcourtLinkForm.tsx @@ -6,14 +6,15 @@ // ลิงก์จริงจาก 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 = - "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"; + "genesis-input mt-1 w-full text-base transition aria-invalid:border-error"; export default function EcourtLinkForm() { const router = useRouter(); @@ -50,20 +51,12 @@ export default function EcourtLinkForm() { }; return ( -
    -
    - -

    เปิดเอกสารจากลิงก์ e-Court

    -
    -
    + + {/* ไม่มีหัวการ์ดซ้ำ — h2 "เปิดเอกสารจากลิงก์ e-Court" ของ section ในหน้ารายการ + เป็นหัวเดียวพอแล้ว (responsive audit 2026-09-24: เดิมซ้ำสองชั้นติดกัน) */} +
    -
    -
    -
    -

    +

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

    -

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

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

    - {error &&

    {error}

    } + {error &&

    {error}

    } ); } diff --git a/components/LoginForm.tsx b/components/LoginForm.tsx index 199b595..34b9db3 100644 --- a/components/LoginForm.tsx +++ b/components/LoginForm.tsx @@ -3,6 +3,8 @@ // ฟอร์ม 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"; @@ -11,11 +13,12 @@ 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 = - "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"; + "genesis-input mt-1 block w-full text-base transition aria-invalid:border-error"; // dropdown ผู้ใช้ — เหมือน INPUT_CLASS แต่ไม่มี mt-1 (Select ครอบ div.relative: มาร์จินที่ wrapper ข้างนอกแทน) -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"; +const SELECT_CLASS = "genesis-input block w-full text-base transition"; export default function LoginForm({ next }: { next: string | null }) { const router = useRouter(); @@ -48,23 +51,29 @@ export default function LoginForm({ next }: { next: string | null }) { }; return ( -
    - {/* brand lockup เดียวกับ header หน้ารายการ (ลดขนาดตัวอักษรให้พอการ์ดแคบ) */} +
    + {/* brand lockup เดียวกับ nav หน้ารายการ — tile JN น้ำเงินคือแบรนด์มาร์กของระบบ + (หน้าสรุปแถบน้ำเงิน 2026-09-17 สืบต่อจาก tile นี้) ชื่อแบรนด์เป็น ink ตามกฎ + "blue เฉพาะของ interactive" ของ Genesis */}
    -
    +
    JN
    -
    -

    Judge's Note

    -

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

    +
    +

    + Judge's Note +

    +

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

    -

    เข้าสู่ระบบ

    +

    เข้าสู่ระบบ

    -
    +
    -