From fa880b314e8df9813f75a76c230d22f37c1645cd Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 17 Sep 2026 16:06:20 +0700 Subject: [PATCH] =?UTF-8?q?BASE=5FPATH:=20serve=20=E0=B9=83=E0=B8=95?= =?UTF-8?q?=E0=B9=89=20/docedit/note=20=E2=80=94=20next.config.mjs=20?= =?UTF-8?q?=E0=B8=95=E0=B8=B1=E0=B9=89=E0=B8=87=20basePath=20+=20env=20NEX?= =?UTF-8?q?T=5FPUBLIC=5FBASE=5FPATH=20(=E0=B8=9B=E0=B8=A3=E0=B8=B0?= =?UTF-8?q?=E0=B8=81=E0=B8=B2=E0=B8=A8=E0=B8=84=E0=B9=88=E0=B8=B2=E0=B9=80?= =?UTF-8?q?=E0=B8=94=E0=B8=B5=E0=B8=A2=E0=B8=A7=E0=B8=97=E0=B8=B5=E0=B9=88?= =?UTF-8?q?=E0=B9=84=E0=B8=9F=E0=B8=A5=E0=B9=8C=E0=B8=99=E0=B8=B5=E0=B9=89?= =?UTF-8?q?)=20=E0=B9=83=E0=B8=AB=E0=B9=89=20utils/base-path.ts=20(?= =?UTF-8?q?=E0=B9=83=E0=B8=AB=E0=B8=A1=E0=B9=88)=20withBasePath()=20?= =?UTF-8?q?=E0=B9=80=E0=B8=95=E0=B8=B4=E0=B8=A1=20prefix=20=E0=B9=83?= =?UTF-8?q?=E0=B8=AB=E0=B9=89=20URL=20=E0=B8=9D=E0=B8=B1=E0=B9=88=E0=B8=87?= =?UTF-8?q?=20browser=20=E0=B8=97=E0=B8=B5=E0=B9=88=20Next=20=E0=B9=84?= =?UTF-8?q?=E0=B8=A1=E0=B9=88=E0=B9=80=E0=B8=95=E0=B8=B4=E0=B8=A1=E0=B9=83?= =?UTF-8?q?=E0=B8=AB=E0=B9=89=E0=B9=80=E0=B8=AD=E0=B8=87=20(fetch/EventSou?= =?UTF-8?q?rce/plain=20href/workerSrc/img=20src):=20PDFViewer=20(workerSrc?= =?UTF-8?q?,=20/api/pdf=20=E0=B8=8B=E0=B9=88=E0=B8=AD=E0=B8=A1=20ToUnicode?= =?UTF-8?q?,=20fallback=20file.pdfUrl,=20image=20branch=20N3=20=E0=B8=A3?= =?UTF-8?q?=E0=B8=A7=E0=B8=9A=E0=B9=80=E0=B8=9B=E0=B9=87=E0=B8=99=E0=B8=95?= =?UTF-8?q?=E0=B8=B1=E0=B8=A7=E0=B9=81=E0=B8=9B=E0=B8=A3=20fileUrl,=20expo?= =?UTF-8?q?rt=20PDF=20F8),=20SSE=20stream=20=E0=B9=83=E0=B8=99=20useAnnota?= =?UTF-8?q?tionRealtime,=20=E0=B8=9B=E0=B8=B8=E0=B9=88=E0=B8=A1=E0=B8=AA?= =?UTF-8?q?=E0=B9=88=E0=B8=87=E0=B8=AD=E0=B8=AD=E0=B8=81=20bookmark=20JSON?= =?UTF-8?q?=20(N7)=20+=20=E0=B8=84=E0=B8=B1=E0=B8=94=E0=B8=A5=E0=B8=AD?= =?UTF-8?q?=E0=B8=81=E0=B8=A5=E0=B8=B4=E0=B8=87=E0=B8=81=E0=B9=8C=20F12=20?= =?UTF-8?q?=E0=B9=83=E0=B8=99=20AnnotationSidebar=20(window.location.origi?= =?UTF-8?q?n=20+=20basePath=20=E0=B8=81=E0=B8=B1=E0=B8=99=20paste=20?= =?UTF-8?q?=E0=B9=81=E0=B8=A5=E0=B9=89=E0=B8=A7=20404),=20Export=20CSV=20(?= =?UTF-8?q?F16)=20=E0=B8=AB=E0=B8=99=E0=B9=89=E0=B8=B2=20summary=20?= =?UTF-8?q?=E2=80=94=20/next/navigation/middleware.ts=20=E0=B9=84?= =?UTF-8?q?=E0=B8=A1=E0=B9=88=E0=B8=95=E0=B9=89=E0=B8=AD=E0=B8=87=E0=B9=81?= =?UTF-8?q?=E0=B8=81=E0=B9=89=20(Next=2014.2=20auto-prefix=20=E0=B9=83?= =?UTF-8?q?=E0=B8=AB=E0=B9=89=20+=20matcher=20=E0=B8=88=E0=B8=B1=E0=B8=9A?= =?UTF-8?q?=20path=20=E0=B8=97=E0=B8=B5=E0=B9=88=E0=B8=95=E0=B8=B1?= =?UTF-8?q?=E0=B8=94=20basePath=20=E0=B9=81=E0=B8=A5=E0=B9=89=E0=B8=A7=20+?= =?UTF-8?q?=20NextURL=20redirect=20=E0=B9=83=E0=B8=AA=E0=B9=88=E0=B8=81?= =?UTF-8?q?=E0=B8=A5=E0=B8=B1=E0=B8=9A=E0=B9=80=E0=B8=AD=E0=B8=87=20?= =?UTF-8?q?=E0=B8=A2=E0=B8=B7=E0=B8=99=E0=B8=A2=E0=B8=B1=E0=B8=99=E0=B8=88?= =?UTF-8?q?=E0=B8=B2=E0=B8=81=20source=20=E0=B9=83=E0=B8=99=20node=5Fmodul?= =?UTF-8?q?es)=20+=20pdf=5Furl=20=E0=B9=83=E0=B8=99=20DB=20=E0=B9=80?= =?UTF-8?q?=E0=B8=81=E0=B9=87=E0=B8=9A=20path=20=E0=B9=84=E0=B8=A1?= =?UTF-8?q?=E0=B9=88=E0=B8=A3=E0=B8=A7=E0=B8=A1=20basePath=20=E0=B9=80?= =?UTF-8?q?=E0=B8=95=E0=B8=B4=E0=B8=A1=E0=B8=95=E0=B8=AD=E0=B8=99=E0=B9=83?= =?UTF-8?q?=E0=B8=8A=E0=B9=89=E0=B9=80=E0=B8=97=E0=B9=88=E0=B8=B2=E0=B8=99?= =?UTF-8?q?=E0=B8=B1=E0=B9=89=E0=B8=99=20+=20sync=20=E0=B9=80=E0=B8=AD?= =?UTF-8?q?=E0=B8=81=E0=B8=AA=E0=B8=B2=E0=B8=A3=E0=B8=95=E0=B8=B2=E0=B8=A1?= =?UTF-8?q?=20governance:=20CLAUDE.md=20invariant=20#6=20(workerSrc=20?= =?UTF-8?q?=E0=B8=9C=E0=B9=88=E0=B8=B2=E0=B8=99=20withBasePath)=20+=20#12?= =?UTF-8?q?=20=E0=B9=83=E0=B8=AB=E0=B8=A1=E0=B9=88=20(URL=20=E0=B8=9D?= =?UTF-8?q?=E0=B8=B1=E0=B9=88=E0=B8=87=20browser=20=E0=B8=97=E0=B8=B5?= =?UTF-8?q?=E0=B9=88=E0=B8=AA=E0=B8=A3=E0=B9=89=E0=B8=B2=E0=B8=87=E0=B9=80?= =?UTF-8?q?=E0=B8=AD=E0=B8=87=E0=B8=95=E0=B9=89=E0=B8=AD=E0=B8=87=E0=B8=9C?= =?UTF-8?q?=E0=B9=88=E0=B8=B2=E0=B8=99=20withBasePath),=20PLAN.md=20A3/?= =?UTF-8?q?=C2=A74,=20rules=20pdf-viewer/realtime,=20plan-reviewer=20#8,?= =?UTF-8?q?=20README=20URL=20localhost:3000/docedit/note=20=E2=80=94=20ver?= =?UTF-8?q?ify:=20tsc=20strict=20=E0=B8=9C=E0=B9=88=E0=B8=B2=E0=B8=99=20+?= =?UTF-8?q?=20curl=20(root=20/=20404,=20=E0=B9=84=E0=B8=A1=E0=B9=88?= =?UTF-8?q?=E0=B8=A1=E0=B8=B5=20cookie=20307=E2=86=92/docedit/note/login,?= =?UTF-8?q?=20worker=20200,=20SSE=20reachable,=20=E0=B8=A1=E0=B8=B5=20cook?= =?UTF-8?q?ie=20200)=20+=20Playwright=20login=20=E0=B8=88=E0=B8=A3?= =?UTF-8?q?=E0=B8=B4=E0=B8=87=E0=B9=80=E0=B8=9B=E0=B8=B4=E0=B8=94=E0=B9=84?= =?UTF-8?q?=E0=B8=9F=E0=B8=A5=E0=B9=8C=2044=20=E0=B8=AB=E0=B8=99=E0=B9=89?= =?UTF-8?q?=E0=B8=B2=20render=20=E0=B8=84=E0=B8=A3=E0=B8=9A=20(SSE=20/api/?= =?UTF-8?q?pdf=20/worker=20200=20=E0=B8=9C=E0=B9=88=E0=B8=B2=E0=B8=99=20ba?= =?UTF-8?q?sePath)=20+=20code-reviewer=20=E0=B8=9C=E0=B9=88=E0=B8=B2?= =?UTF-8?q?=E0=B8=99=20(vitest=20126=20tests,=20=E0=B9=84=E0=B8=A1?= =?UTF-8?q?=E0=B9=88=E0=B8=9E=E0=B8=9A=20bug,=20blob=20URL=20=E0=B9=84?= =?UTF-8?q?=E0=B8=A1=E0=B9=88=E0=B9=82=E0=B8=94=E0=B8=99=20prefix=20?= =?UTF-8?q?=E0=B8=8B=E0=B9=89=E0=B8=B3,=20env=20inline=20=E0=B8=84?= =?UTF-8?q?=E0=B8=A3=E0=B8=9A=E0=B8=97=E0=B8=B1=E0=B9=89=E0=B8=87=20client?= =?UTF-8?q?/server=20=E0=B8=95=E0=B8=B2=E0=B8=A1=20define-env-plugin)=20?= =?UTF-8?q?=E2=80=94=20deploy:=20basePath=20=E0=B9=80=E0=B8=9B=E0=B9=87?= =?UTF-8?q?=E0=B8=99=20build-time=20=E0=B8=95=E0=B9=89=E0=B8=AD=E0=B8=87?= =?UTF-8?q?=20build=20image=20=E0=B9=83=E0=B8=AB=E0=B8=A1=E0=B9=88?= =?UTF-8?q?=E0=B8=96=E0=B8=B6=E0=B8=87=E0=B8=88=E0=B8=B0=E0=B9=80=E0=B8=AA?= =?UTF-8?q?=E0=B8=B4=E0=B8=A3=E0=B9=8C=E0=B8=9F=E0=B8=97=E0=B8=B5=E0=B9=88?= =?UTF-8?q?=20/docedit/note=20+=20reverse=20proxy=20=E0=B8=95=E0=B9=89?= =?UTF-8?q?=E0=B8=AD=E0=B8=87=20forward=20path=20=E0=B9=80=E0=B8=95?= =?UTF-8?q?=E0=B9=87=E0=B8=A1=E0=B9=81=E0=B8=9A=E0=B8=9A=E0=B9=84=E0=B8=A1?= =?UTF-8?q?=E0=B9=88=20strip=20prefix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Code --- .claude/agents/plan-reviewer.md | 2 +- .claude/rules/pdf-viewer.md | 4 ++-- .claude/rules/realtime.md | 2 +- CLAUDE.md | 6 ++++-- README.md | 2 +- app/annotate/[id]/summary/page.tsx | 3 ++- components/pdf/PDFViewer.tsx | 17 ++++++++++------- components/sidebar/AnnotationSidebar.tsx | 5 +++-- docs/PLAN.md | 6 +++--- hooks/useAnnotationRealtime.ts | 3 ++- next.config.mjs | 7 +++++++ utils/base-path.ts | 7 +++++++ 12 files changed, 43 insertions(+), 21 deletions(-) create mode 100644 utils/base-path.ts diff --git a/.claude/agents/plan-reviewer.md b/.claude/agents/plan-reviewer.md index 24f0d8c..61648c3 100644 --- a/.claude/agents/plan-reviewer.md +++ b/.claude/agents/plan-reviewer.md @@ -22,7 +22,7 @@ You review the Judge's Note codebase — PDF annotation system for e-Court (Next 5. **เขียนผ่าน Server Actions เท่านั้น** — ไม่มี client insert/update/delete DB ตรง 6. **Realtime event** — SSE payload เป็น DTO (camelCase + ISO string) จาก server ตรง ๆ ไม่มีการแปลง; Server Actions ทุกตัว (รวมตัวใหม่: update/resolve/reply/import/restore) ต้อง publish ผ่าน `lib/annotate-events.ts` หลังเขียน DB สำเร็จ 7. **ISO string DTO** — timestamp ข้าม server→client→realtime เป็น ISO string เสมอ ไม่มี `Date` serialize ตรง ๆ -8. **pdfjs** — dynamic import ภายใน `useEffect` เท่านั้น; `GlobalWorkerOptions.workerSrc = "/pdf.worker.min.mjs"`; ห้าม `new URL(..., import.meta.url)` +8. **pdfjs** — dynamic import ภายใน `useEffect` เท่านั้น; `GlobalWorkerOptions.workerSrc = withBasePath("/pdf.worker.min.mjs")`; ห้าม `new URL(..., import.meta.url)` 9. **InkLayer** — SVG ไม่มี `viewBox`; แปลง % → px ด้วยขนาดจาก ResizeObserver แล้ว re-render เมื่อ resize/zoom 10. **JSONB strict** — `data` เป็น discriminated union `$type()` ตาม enum `type` — ไม่มี `any` 11. **Optimistic** — ใช้ `useOptimistic` ภายใน `startTransition`; realtime hook dedupe ด้วย id; ไม่มี id `temp-*` ค้างใน state diff --git a/.claude/rules/pdf-viewer.md b/.claude/rules/pdf-viewer.md index c99a685..7c33ca7 100644 --- a/.claude/rules/pdf-viewer.md +++ b/.claude/rules/pdf-viewer.md @@ -11,12 +11,12 @@ paths: - Canvas Retina: `canvas.width = viewport.width × devicePixelRatio` + render `transform: [dpr,0,0,dpr,0,0]` แต่ `style.width` คง viewport px - Canvas + TextLayer re-render เมื่อ dep `[doc, pageNumber, scale]` เปลี่ยน + cancel render task ตอน unmount (กัน memory leak) - Selection คลุมดำ: รับเฉพาะ rects ใน page เดียว (`commonAncestorContainer.closest('[data-page]')`) และกรอง rect กว้าง 0 ทิ้ง -- **PDF ไทย cmap ของฟอนต์เพี้น (คำสั่งศาล) → โหลดผ่าน `/api/pdf?fileId=` เสมอ** (ไม่ใช่ `file.pdfUrl` ตรง ๆ): `lib/pdf-actual-text.ts` เรียน glyph-code → ข้อความจาก `/ActualText` ใน marked content ของ content stream แล้วเขียน ToUnicode CMap ใหม่**บนสำเนาในหน่วยความจำ** (ไฟล์ใน public/ ไม่ถูกแตะ — invariant #1) ทำให้ textLayer มีข้อความถูกตั้งแต่ต้น (select/copy/ค้นหา/ชื่อ bookmark ถูกทั้งหมด) — PDF ปกติไม่มี ActualText = ได้ bytes เดิมคืน ไม่มี cost; fail ก็ตัวเอง fetch ไฟล์ดิบตามเดิม (fallback ใน PDFViewer) +- **PDF ไทย cmap ของฟอนต์เพี้น (คำสั่งศาล) → โหลดผ่าน `/api/pdf?fileId=` เสมอ** (ไม่ใช่ `file.pdfUrl` ตรง ๆ): `lib/pdf-actual-text.ts` เรียน glyph-code → ข้อความจาก `/ActualText` ใน marked content ของ content stream แล้วเขียน ToUnicode CMap ใหม่**บนสำเนาในหน่วยความจำ** (ไฟล์ใน public/ ไม่ถูกแตะ — invariant #1) ทำให้ textLayer มีข้อความถูกตั้งแต่ต้น (select/copy/ค้นหา/ชื่อ bookmark ถูกทั้งหมด) — PDF ปกติไม่มี ActualText = ได้ bytes เดิมคืน ไม่มี cost; fail ก็ตัวเอง fetch ไฟล์ดิบตามเดิม (fallback ใน PDFViewer) — **fetch/`img.src` ทุกตัว (รวม fallback และรูป N3 ที่โหลด static `pdfUrl` ตรง ๆ) ต้องผ่าน `withBasePath()`** (invariant #12) - **ข้อความจาก selection ห้ามใช้ `selection.toString()` ตรง ๆ** — แม้ซ่อม ToUnicode แล้ว textLayer ไทยบางไฟล์ยังเรียงใน DOM ไม่ตรงลำดับบนจอ มี ctrl/space ขยะหลงเหลือในส่วนที่ ActualText ไม่ครอบ และ DOM range ครอบ span เกินบริเวณที่ลาก: ใช้ `selectionTextWithin(keptRects, range)` (เดินทีละตัวอักษร เช็ค rect ตกใน rects ที่กรองแล้ว เรียงตามตำแหน่งบนจอ — แพง ใช้เฉพาะ mouseup ที่ `readTextSelection(pageIndex, { withText: true })` กับ event `copy` (กรองด้วยเส้นทางลากล่าสุดจาก `lastSweepRef`) เท่านั้น ไม่ใช้ใน live preview) ปิดท้ายด้วย `sanitizePdfText` (`utils/annotation-display.ts` — ตัด ctrl ขยะ + กลุ่ม space คู่ขึ้นไป มี unit tests) ก่อนเก็บ DB/ชื่อ bookmark/คลิปบอร์ด - คีย์ลัด 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 = "/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/` ด้วย postinstall) — ห้าม `new URL(..., import.meta.url)` - Bookmark scroll: คำนวณ absolute position ครั้งเดียวแล้ว smooth-scroll ครั้งเดียว (ห้าม scroll ซ้อนสองจังหวะ) - Deep link (F12/C4): PDFViewer อ่าน `window.location.search` **ครั้งเดียวหลัง `pageSizes` พร้อม** (guard ด้วย ref — ไม่ใช้ `useSearchParams` เลี่ยงข้อจำกัด prerender ของ Next 14) — `?page=N` (1-based) scroll ไปหน้านั้น, `?ann=` scroll ไปหน้าของ annotation นั้น + `setSelectedId` — placeholder ขนาดจริงทุกหน้าทำให้คำนวณ absolute scroll ได้แม้หน้ายังไม่ render (กลไกเดียวกับ bookmark); ปุ่ม "คัดลอกลิงก์" ใน sidebar สร้าง URL รูป `/annotate/?page=&ann=` - Dialog `fixed z-50` ใน sidebar (ประวัติ) ต้อง render ผ่าน **`createPortal` ไป `document.body`** + guard `typeof document === "undefined"` ตอน SSR — `aside.sticky` สร้าง stacking context ครอบ z-index ของ dialog ไว้ ทำให้ `fixed z-50` แพ้ `textLayer` (z-[2] ระดับ root) จนคลิกปุ่มไม่ได้ (PLAN-FEAT.md §5) diff --git a/.claude/rules/realtime.md b/.claude/rules/realtime.md index 991b6ef..d156101 100644 --- a/.claude/rules/realtime.md +++ b/.claude/rules/realtime.md @@ -7,7 +7,7 @@ paths: # Realtime Rules (รายละเอียด: docs/PLAN.md §4) -- Sync ใช้ **SSE**: hook เปิด `EventSource` ไป `/api/annotations/stream?fileId=` และ `es.close()` เมื่อ unmount +- Sync ใช้ **SSE**: hook เปิด `EventSource` ไป `withBasePath("/api/annotations/stream?fileId=")` (basePath ตาม invariant #12) และ `es.close()` เมื่อ unmount - `EventSource` ต่อใหม่อัตโนมัติเมื่อ connection ขาด — **ห้ามทำ retry logic เอง** - Event payload เป็น **DTO (camelCase + ISO string) จาก server ตรง ๆ** — ไม่มีการแปลง snake_case - Server Actions ต้อง **publish ผ่าน `lib/annotate-events.ts` หลังเขียน DB สำเร็จทุกครั้ง** (create/update/delete ของ annotations, create/delete ของ replies, create/**update**/delete ของ bookmarks — update ของ bookmarks ใช้กับแก้ชื่อ/โน้ต/ย้ายหัวข้อ และลูกที่ถูกยกขึ้น root ตอนแม่ถูกลบ, client replace by id) — ลืม publish = client อื่นไม่เห็น; คู่กันในจุดเดียวกันต้อง **`revalidatePath` หน้า viewer+summary** (`revalidateFilePages`) มิฉะนั้นกลับเข้าหน้าใหม่ (back → คลิกไฟล์) ได้ RSC payload เก่าจาก Router Cache ฝั่ง client จนต้อง hard refresh diff --git a/CLAUDE.md b/CLAUDE.md index 56919df..41b5d9f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,7 +9,7 @@ ## Tech Stack -- Next.js 14 (App Router) + TypeScript **strict** +- 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) - 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 @@ -22,12 +22,13 @@ 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 ตรง ๆ 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 = "/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 คัดลอกไฟล์ไป `public/`) — ห้ามใช้ `new URL(..., import.meta.url)` 7. **InkLayer ใช้ SVG แบบไม่มี viewBox** — render ด้วย px จริง แปลง % → px จากขนาดที่ ResizeObserver วัด (viewBox จะทำให้ความหนาเส้นบิดเบี้ยว) 8. **JSONB `data` เป็น strict discriminated union** (`AnnotationData` แยกตาม enum `type`) — ห้าม loosen type เป็น `any`/generic object 9. **Optimistic update ใช้ `useOptimistic` (React 18) ภายใน `startTransition`** — React rollback อัตโนมัติเมื่อ action ล้มเหลว + dedupe ด้วย id ใน `hooks/useAnnotationRealtime.ts` (duplicate ชั่วขณะกับ realtime event หายเอง) 10. **Page Virtualization:** render เฉพาะหน้าใน viewport ± 1 หน้าด้วย IntersectionObserver — หน้าพ้นจอเก็บ placeholder ขนาดจริง (scroll ไม่ jump, bookmark ยังคำนวณตำแหน่งได้) และ unmount canvas/TextLayer/InkLayer คืน memory — ห้าม render ทุกหน้าพร้อมกัน 11. **DB connection:** `DATABASE_URL` ชี้ที่ plain PostgreSQL container `judes-note-db` (`postgresql://postgres:postgres@127.0.0.1:54322/ecourt-note`) + `prepare: false` ใน postgres.js เสมอ — pub/sub ของ SSE เป็น in-memory (ใช้ได้เมื่อรัน process เดียว; ถ้ารันหลาย instance ต้องเพิ่ม Redis pub/sub หรือ Postgres LISTEN/NOTIFY ก่อน) +12. **URL ฝั่ง browser ที่สร้างเองต้องผ่าน `withBasePath()`** (`utils/base-path.ts`) — app serve ใต้ basePath `/docedit/note` ครอบคลุม: `fetch()`/`EventSource`/plain `` (export CSV/JSON)/`workerSrc`/`img.src`/`file.pdfUrl` ตอนโหลดไฟล์/ลิงก์คัดลอก F12 — ส่วน ``/`next/navigation` (router.push/replace/redirect) เติม basePath ให้เอง และ `middleware.ts` จับ path ที่ตัด basePath แล้ว (เขียน matcher/`url.pathname` เหมือนเดิม — NextURL ใส่ basePath กลับตอน redirect เอง); `pdf_url` ใน DB เก็บ path ไม่รวม basePath เติมตอนใช้เท่านั้น ## โครงสร้างไฟล์ (ตามแผน) @@ -43,6 +44,7 @@ utils/geometry.ts ← bbox/translate/scale/hitTestPath ของ annotation utils/annotation-display.ts ← label/ข้อความย่อต่อชนิด + ค่า default TEXTBOX/STAMP (sidebar/สรุป/CSV ใช้ร่วม) 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/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) utils/image-to-pdf.ts ← สร้าง PDF ใหม่จากไฟล์รูป embedJpg/embedPng + EXIF rotate + TIFF→PNG (N3, export route) diff --git a/README.md b/README.md index ae13d78..8d8b89d 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ npm run seed-samples npm run dev ``` -เปิด http://localhost:3000 — คลิกไฟล์เพื่อเข้าหน้า annotate หรืออัปโหลดไฟล์ใหม่ (PDF/รูปภาพ ≤ 100MB) ผ่านฟอร์มบนหน้ารายการ (N4 — ไฟล์เก็บที่ `public/uploads/`) +เปิด http://localhost:3000/docedit/note — คลิกไฟล์เพื่อเข้าหน้า annotate หรืออัปโหลดไฟล์ใหม่ (PDF/รูปภาพ ≤ 100MB) ผ่านฟอร์มบนหน้ารายการ (N4 — ไฟล์เก็บที่ `public/uploads/`) ## การใช้งานบนหน้า annotate diff --git a/app/annotate/[id]/summary/page.tsx b/app/annotate/[id]/summary/page.tsx index a0a881b..91fef30 100644 --- a/app/annotate/[id]/summary/page.tsx +++ b/app/annotate/[id]/summary/page.tsx @@ -6,6 +6,7 @@ import { ArrowLeft, MapPin } from "lucide-react"; import { db } from "@/db"; import { annotations, files, toAnnotationDTO, type AnnotationDTO } from "@/db/schema"; import { annotationNote, annotationPreviewText, annotationTypeLabel } from "@/utils/annotation-display"; +import { withBasePath } from "@/utils/base-path"; // หน้าสรุปหมายเหตุ (F15/D2) — server component รวมทุก annotation (ทุกผู้ใช้) // จัดกลุ่มตามหน้า พร้อมปุ่มกระโดดเป็น deep link `/annotate/?ann=` (F12) @@ -57,7 +58,7 @@ export default async function SummaryPage({ {/* F16/D3: export สรุปเป็น CSV (UTF-8 BOM) */} Export CSV diff --git a/components/pdf/PDFViewer.tsx b/components/pdf/PDFViewer.tsx index e154660..7a6a30b 100644 --- a/components/pdf/PDFViewer.tsx +++ b/components/pdf/PDFViewer.tsx @@ -115,6 +115,7 @@ import { } from "@/utils/coordinates"; import type { OacUser } from "@/utils/user"; import { fileKindFromUrl, imageFormatFromUrl } from "@/utils/file-kind"; +import { withBasePath } from "@/utils/base-path"; import { decodeTiffPages } from "@/utils/tiff-client"; import { TEXTBOX_DEFAULT_WIDTH_PCT, @@ -1401,7 +1402,7 @@ export default function PDFViewer({ setExporting(true); setExportError(null); try { - const res = await fetch(`/api/annotations/export?fileId=${file.id}`); + const res = await fetch(withBasePath(`/api/annotations/export?fileId=${file.id}`)); if (!res.ok) { const text = await res.text().catch(() => ""); throw new Error(text || `HTTP ${res.status}`); @@ -1449,21 +1450,23 @@ export default function PDFViewer({ setDoc(null); setPdfjs(null); const objectUrls: string[] = []; + // URL จริงที่ browser ใช้โหลดไฟล์ (fetch/img.src ตรง — ต้องเติม basePath เอง) + const fileUrl = withBasePath(file.pdfUrl); (async () => { let pages: { src: string | null; width: number; height: number }[]; if (imageFormatFromUrl(file.pdfUrl) === "tiff") { // N3: TIFF หลายเฟรม = หลายหน้า — decode ครั้งเดียวต่อการเปิดไฟล์ // (utif2 dynamic import ภายใน decodeTiffPages) แล้วเก็บ blob URL ไว้ใช้ต่อ - const buffer = await (await fetch(file.pdfUrl)).arrayBuffer(); + const buffer = await (await fetch(fileUrl)).arrayBuffer(); pages = await decodeTiffPages(buffer, IMAGE_PAGE_WIDTH); for (const p of pages) if (p.src) objectUrls.push(p.src); } else { const img = new Image(); - img.src = file.pdfUrl; + img.src = fileUrl; await img.decode(); const factor = IMAGE_PAGE_WIDTH / img.naturalWidth; pages = [ - { src: file.pdfUrl, width: IMAGE_PAGE_WIDTH, height: img.naturalHeight * factor }, + { src: fileUrl, width: IMAGE_PAGE_WIDTH, height: img.naturalHeight * factor }, ]; } if (cancelled) { @@ -1488,16 +1491,16 @@ export default function PDFViewer({ (async () => { const pdfjs = await import("pdfjs-dist"); - pdfjs.GlobalWorkerOptions.workerSrc = "/pdf.worker.min.mjs"; + pdfjs.GlobalWorkerOptions.workerSrc = withBasePath("/pdf.worker.min.mjs"); // โหลดผ่าน /api/pdf ที่ซ่อม ToUnicode จาก ActualText ของ PDF ไทยที่ cmap เพี้ยน // (ข้อความถูกทั้ง select/copy/ค้นหา) — endpoint fail ก็ถอยไปไฟล์ดิบตามเดิม let data: ArrayBuffer; try { - const res = await fetch(`/api/pdf?fileId=${file.id}`); + const res = await fetch(withBasePath(`/api/pdf?fileId=${file.id}`)); if (!res.ok) throw new Error(`HTTP ${res.status}`); data = await res.arrayBuffer(); } catch { - data = await (await fetch(file.pdfUrl)).arrayBuffer(); + data = await (await fetch(withBasePath(file.pdfUrl))).arrayBuffer(); } const doc = await pdfjs.getDocument({ data }).promise; loadedDoc = doc; diff --git a/components/sidebar/AnnotationSidebar.tsx b/components/sidebar/AnnotationSidebar.tsx index 6e9e791..90aa933 100644 --- a/components/sidebar/AnnotationSidebar.tsx +++ b/components/sidebar/AnnotationSidebar.tsx @@ -52,6 +52,7 @@ import type { ShapeData, } from "@/db/schema"; import { annotationNote, annotationPreviewText } from "@/utils/annotation-display"; +import { withBasePath } from "@/utils/base-path"; /** UX audit 2026-09-16: focus ring ตอน keyboard focus — ค่าเดียวกับ FOCUS_RING_CLS * ของ PDFViewer.tsx (ห้าม import ข้ามไฟล์: PDFViewer → sidebar → PDFViewer เป็น @@ -471,7 +472,7 @@ export default function AnnotationSidebar({ const [copyFailedId, setCopyFailedId] = useState(null); const copyTimerRef = useRef(null); function copyAnnotationLink(a: AnnotationDTO) { - const url = `${window.location.origin}/annotate/${a.fileId}?page=${a.pageIndex + 1}&ann=${a.id}`; + const url = `${window.location.origin}${withBasePath(`/annotate/${a.fileId}?page=${a.pageIndex + 1}&ann=${a.id}`)}`; if (copyTimerRef.current !== null) { window.clearTimeout(copyTimerRef.current); copyTimerRef.current = null; @@ -1117,7 +1118,7 @@ export default function AnnotationSidebar({ {/* N7: ส่งออกรายการ bookmark เป็น JSON — ลิงก์ธรรมดา pattern Export CSV (F16) ส่งออกทุกรายการของไฟล์เสมอ ไม่ผูกกับเงื่อนไขค้นหาที่ตั้งไว้ */} .` เท่านั้น (ไม่แตะไฟล์เดิม) — sample เก็บใน `public/samples/` แล้ว sync ด้วย `npm run seed-samples` (รายละเอียดด้านล่าง) | | A6 | **ทุก annotation ลบได้** | แสดงปุ่มลบเฉพาะรายการของตัวเอง (`userId` ตรงกัน) — ไม่มี role/permission ในเฟสนี้ | @@ -149,7 +149,7 @@ Client A วาด ──▶ Server Action createAnnotation() ──▶ INSERT P ``` - **เขียน:** ผ่าน Server Actions เท่านั้น (ต่อ `DATABASE_URL` ตรง ๆ) — และ**หลังเขียนสำเร็จทุกครั้ง ต้อง publish event** ให้ `lib/annotate-events.ts` -- **อ่าน/ sync:** `useAnnotationRealtime` hook เปิด `EventSource` ไปที่ SSE route `/api/annotations/stream?fileId=` — payload เป็น DTO (camelCase + ISO string) จาก server ตรง ๆ ไม่ต้องแปลง; `EventSource` ต่อใหม่อัตโนมัติเมื่อ connection ขาด (ห้ามทำ retry เอง) +- **อ่าน/ sync:** `useAnnotationRealtime` hook เปิด `EventSource` ไปที่ SSE route `withBasePath("/api/annotations/stream?fileId=")` — payload เป็น DTO (camelCase + ISO string) จาก server ตรง ๆ ไม่ต้องแปลง; `EventSource` ต่อใหม่อัตโนมัติเมื่อ connection ขาด (ห้ามทำ retry เอง) - pub/sub เป็น **in-memory ของ Next.js process เดียว** (dev / `next start` ธรรมดา) — หากอนาคตรันหลาย instance ต้องเพิ่มตัวกลาง (Redis pub/sub หรือ Postgres `LISTEN/NOTIFY`) ก่อน - **Optimistic update ด้วย `useOptimistic` (React 18):** ตอนผู้ใช้ปล่อยเมาส์ → `startTransition(() => { addOptimistic(row ชั่วคราว); return createAnnotation(...) })` → React ถอด optimistic row ออกอัตโนมัติเมื่อ action เสร็จ และ **rollback ให้เองหาก action ล้มเหลว** (ไม่ต้องจัดการ id `temp-*` เอง) — client ส่ง id ของ optimistic row ให้ `createAnnotation` ใช้ซ้ำ (action validate เป็น UUID) เพื่อให้ SSE event ที่มาถึงก่อน action เสร็จ dedupe ด้วย id เดียวกันได้ทันที **ไม่เห็น duplicate ชั่วขณะ** (เดิม row จริงใช้ id ใหม่ทำให้แสดง 2 แถวแวบหนึ่งก่อน row ชั่วคราวถูกถอด) @@ -242,7 +242,7 @@ judes-note/ - **Server component ส่ง Date ไม่ได้ตรง ๆ กับ realtime string** → normalize เป็น ISO string DTO ทั้งระบบ - **pdfjs บน SSR** → `dynamic import("pdfjs-dist")` ภายใน useEffect เท่านั้น (ไม่ import top-level ใน module scope ที่ server evaluate ด้วย) - **PDF ยาว (20–100+ หน้า): Page Virtualization** → `IntersectionObserver` ใน PDFViewer render เฉพาะหน้าใน viewport ± 1 หน้า (canvas + TextLayer + InkLayer); หน้าที่พ้นจอเก็บเป็น placeholder `div` **ขนาดจริง** (วัดจาก `pdfPage.getViewport()` ทุกหน้าตอนโหลดครั้งเดียว — cost ถูก) เพื่อไม่ให้ scroll jump และให้ bookmark ยังคำนวณ absolute scroll ได้ แล้ว unmount layer ทั้งหมดของหน้านั้นคืน memory — annotation ของหน้าที่ยังไม่ render อยู่ใน state ตามปกติ จะแสดงเมื่อหน้าเข้าจอ -- **pdf.worker v4 เป็นไฟล์ `.mjs`** → คัดลอก `pdf.worker.min.mjs` จาก `node_modules/pdfjs-dist/build/` ไป `public/` ด้วย **postinstall script** (version ตรงกับ lockfile เสมอ) และตั้ง `pdfjs.GlobalWorkerOptions.workerSrc = "/pdf.worker.min.mjs"` — อย่าใช้ `new URL(..., import.meta.url)` ซึ่งมักชน Turbopack/webpack ใน Next 14 +- **pdf.worker v4 เป็นไฟล์ `.mjs`** → คัดลอก `pdf.worker.min.mjs` จาก `node_modules/pdfjs-dist/build/` ไป `public/` ด้วย **postinstall script** (version ตรงกับ lockfile เสมอ) และตั้ง `pdfjs.GlobalWorkerOptions.workerSrc = withBasePath("/pdf.worker.min.mjs")` — อย่าใช้ `new URL(..., import.meta.url)` ซึ่งมักชน Turbopack/webpack ใน Next 14 --- diff --git a/hooks/useAnnotationRealtime.ts b/hooks/useAnnotationRealtime.ts index d9db212..3c2a98d 100644 --- a/hooks/useAnnotationRealtime.ts +++ b/hooks/useAnnotationRealtime.ts @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"; import type { AnnotationDTO, BookmarkDTO, ReplyDTO } from "@/db/schema"; import type { AnnotateEvent } from "@/lib/annotate-events"; +import { withBasePath } from "@/utils/base-path"; // SSE sync (PLAN.md §4, rules/realtime.md) // - เปิด EventSource ไป /api/annotations/stream?fileId= — reconnect อัตโนมัติเมื่อขาด (ห้ามทำ retry เอง) @@ -19,7 +20,7 @@ export function useAnnotationRealtime( const [replies, setReplies] = useState(initialReplies); useEffect(() => { - const es = new EventSource(`/api/annotations/stream?fileId=${fileId}`); + const es = new EventSource(withBasePath(`/api/annotations/stream?fileId=${fileId}`)); es.onmessage = (e: MessageEvent) => { let ev: AnnotateEvent; diff --git a/next.config.mjs b/next.config.mjs index cf3bd9b..9b075c8 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -1,7 +1,14 @@ +const basePath = "/docedit/note"; + /** @type {import('next').NextConfig} */ const nextConfig = { // สำหรับ Docker (docker/web.Dockerfile) — build แล้วได้ .next/standalone รันด้วย node server.js output: "standalone", + // serve ใต้ /docedit/note — /next/navigation เติมให้เอง แต่ fetch/EventSource/ + // plain /workerSrc ต้องผ่าน withBasePath() (utils/base-path.ts) + basePath, + // expose ให้โค้ดใช้ (inline ตอน build) — ค่าจริงประกาศที่เดียวที่ไฟล์นี้ + env: { NEXT_PUBLIC_BASE_PATH: basePath }, // N4 อัปโหลดไฟล์ผ่าน Server Action — default 1MB ไม่พอสำหรับ PDF/สแกนศาล (ต้องตรงกับ MAX_UPLOAD_BYTES ใน app/actions/files.ts) experimental: { serverActions: { diff --git a/utils/base-path.ts b/utils/base-path.ts new file mode 100644 index 0000000..09adacc --- /dev/null +++ b/utils/base-path.ts @@ -0,0 +1,7 @@ +// basePath (/docedit/note) — ค่าจาก next.config.mjs (inline ตอน build ผ่าน env) +// ใช้กับ URL ฝั่ง browser ที่ Next ไม่เติม basePath ให้เอง: fetch/EventSource/ +// plain /workerSrc/img src — ต่างจาก /router/redirect ของ next +// ที่เติมให้อัตโนมัติ (pdfUrl ใน DB เก็บ path ไม่รวม basePath เติมตอนใช้เท่านั้น) +export function withBasePath(path: string): string { + return `${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}${path}`; +}