refactor(routes): move routes under /note, APIs under /api/note
All checks were successful
Deploy eCourt Note Web to Server / Validate Database Schema (Drizzle) (push) Successful in 3s
Deploy eCourt Note Web to Server / Build Web Docker Image (push) Successful in 52s
Deploy eCourt Note Web to Server / Deploy eCourt Note Web to Server (push) Successful in 13s
Deploy eCourt Note Web to Server / Notify Deployment Status (push) Successful in 1s
All checks were successful
Deploy eCourt Note Web to Server / Validate Database Schema (Drizzle) (push) Successful in 3s
Deploy eCourt Note Web to Server / Build Web Docker Image (push) Successful in 52s
Deploy eCourt Note Web to Server / Deploy eCourt Note Web to Server (push) Successful in 13s
Deploy eCourt Note Web to Server / Notify Deployment Status (push) Successful in 1s
เตรียม basePath /docedit เป็นพื้นที่กลางให้แอปพี่น้อง (wordaddin ใน อนาคต) — เดิม basePath ครอบ /docedit/note ทั้งหมดจนแชร์ prefix ไม่ได้ (Next มี basePath ได้ทางเดียว) และจัด API แยกต้นไม้ตามที่ผู้ใช้ขอ (1) โครงสร้างใหม่: basePath = /docedit + route หน้าทั้งหมดย้ายเข้า app/note/** + API 4 เส้นย้ายเข้า app/api/note/** + statics ย้ายเข้า public/note/** (samples, uploads, pdf.worker) — หน้า/statics URL ปลายทาง คงเดิมเป๊ะ (/docedit/note/…) ส่วน URL API เปลี่ยนเป็น /docedit/api/note/… ตามคำขอผู้ใช้ (N10 endpoint เปลี่ยน — ต้องแจ้ง backend e-Court ก่อนใช้) (2) กลไก: withBasePath (utils/base-path.ts) เติม base + app path /note ยกเว้น path ที่ขึ้นต้น /api/ เติมแค่ base (caller เขียน /api/note/… เต็ม); resolvePublicPdfPath root ที่ public/note (traversal guard เดิม); middleware matcher ยกเว้นใหม่ (note/login, api/note, note/uploads, note/samples, note/pdf.worker, _next, fonts, favicon) + redirect /note/login; next.config เพิ่ม redirects /docedit -> /docedit/note; navigation ภายใน 19 จุด + revalidatePath 3 จุด ใส่ prefix /note; pdf_url ใน DB เป็น logical path ไม่แก้แม้แต่ตัวเดียว (seed/upload เขียนค่าเดิม, browser เติมผ่าน withBasePath, disk เติมผ่าน guard) (3) ตามแก้: patch-pdf-worker (DEST public/note + mkdirSync ปลายทาง), seed-samples (SAMPLES_DIR), uploadFile (UPLOAD_DIR), Dockerfile/ .dockerignore/.gitignore/docker-compose.test.yml, tests 2 ไฟล์ที่อ้าง disk path จริง, sync เอกสารทั้งหมด (CLAUDE.md invariant #6/#12 + tree, README, docs/API-DOCUMENTS.md, .claude/rules/*, sync-db skill) (4) !! ตอน deploy ต้องย้าย volume ตาม: mount /app/public/uploads -> /app/public/note/uploads + ย้ายไฟล์ใน volume ให้ตรงตำแหน่ง ไม่งั้นไฟล์ ที่เคยอัปโหลด (พร้อม annotation ที่ผูกอยู่) 404 ทันทีที่สลับ image (5) verify: build ผ่าน (route table /api/note/** + /note/** ครบ), tsc, vitest 215/215, curl ครบทุก endpoint (login/redirect chain, worker, samples, uploads, SSE, api/pdf, export, N8 register, N10 bundle + deep link), Playwright browser จริง (canvas + textLayer ไทย render, browser ยิง URL ใหม่ทั้งหมดไม่มี request ไป URL เก่า), plan-reviewer + code-reviewer 3 รายงาน 0 blocking Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
parent
b123ecc9b2
commit
8cc15ea75d
52 changed files with 174 additions and 148 deletions
|
|
@ -19,7 +19,7 @@ paths:
|
|||
- `annotations.resolved` (NOT NULL default false) + `resolved_at` (NULL) — F10/A5: `setAnnotationResolved` **ใครก็ได้ toggle** (D6 คลายคืนได้ ต่างจาก delete ที่จำกัดเจ้าของ) + publish update event; ไม่เข้า history undo ของ F2
|
||||
- ตาราง `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 ก่อนเขียน — ชื่อไฟล์ต้องผ่าน `decodeUploadFilename` ของ `utils/upload-name.ts` ก่อนใช้ (ทั้ง extension และ title): undici ถอด `Content-Disposition` เป็น latin1 ทำชื่อ UTF-8 เพี้ยน (ชื่อไทยกลายเป็น "คำ…" — 2026-10-05) — บันทึกเป็นไฟล์**ใหม่** `public/uploads/<uuid>.<ext>` เท่านั้น (ชื่อจาก randomUUID — ห้ามใช้ชื่อผู้ใช้ตกแต่ง path), insert row (รวม `type_id` จากช่องกลุ่มทางเลือกของฟอร์ม — 2026-09-23) แล้ว `revalidatePath("/")` — ไม่ publish SSE event (รายการไฟล์ไม่ได้ sync ผ่าน SSE); seed-samples จัดการเฉพาะ `/samples/%` ไม่แตะ `/uploads/`
|
||||
- `files.pdf_url` เก็บ path **รูปภาพ** (jpg/jpeg/png/tif/tiff) ใน `public/note/` ได้เหมือน 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 ก่อนเขียน — ชื่อไฟล์ต้องผ่าน `decodeUploadFilename` ของ `utils/upload-name.ts` ก่อนใช้ (ทั้ง extension และ title): undici ถอด `Content-Disposition` เป็น latin1 ทำชื่อ UTF-8 เพี้ยน (ชื่อไทยกลายเป็น "คำ…" — 2026-10-05) — บันทึกเป็นไฟล์**ใหม่** `public/note/uploads/<uuid>.<ext>` เท่านั้น (ชื่อจาก randomUUID — ห้ามใช้ชื่อผู้ใช้ตกแต่ง path), insert row (รวม `type_id` จากช่องกลุ่มทางเลือกของฟอร์ม — 2026-09-23) แล้ว `revalidatePath("/note")` — ไม่ publish SSE event (รายการไฟล์ไม่ได้ sync ผ่าน SSE); seed-samples จัดการเฉพาะ `/samples/%` ไม่แตะ `/uploads/`
|
||||
- `files.type_id` (N9 — รหัสกลุ่มเอกสาร จากลิงก์ e-Court `?typeId=` หรือช่อง "กลุ่ม typeId" ของฟอร์มอัปโหลด N4): **เขียนสองที่** — (1) `registerExternalFile` ของ N8 (insert ครั้งแรก + update เมื่อลิงก์รอบใหม่ส่ง `typeId` มา — ลิงก์ไม่ส่ง = คงเดิม ห้ามเคลียร์) (2) `uploadFile` ของ N4 ช่องทางเลือกในฟอร์มอัปโหลด — insert ตอนสร้าง row ใหม่เท่านั้น ไม่มีหน้าทางแก้กลุ่มของไฟล์เดิม; normalize ผ่าน `normDocumentGroup` ของ `utils/external-file.ts` ที่เดียว (trim → ว่าง = NULL, cap 200 — responsive audit 2026-09-24 รวมจากเดิมที่เป็นสองสำเนา mirroring กัน) — N9 อ่านอย่างเดียว (หน้า `/` จัด section + `/groups/[typeId]`) ไม่มี Server Action/SSE ใหม่; กลุ่ม = ค่า distinct ของ `type_id` ณ ตอน query — ไม่มีตารางกลุ่ม
|
||||
- API ภายนอก (N10 — `app/api/external/documents` เส้นเดียว **POST + JSON body** `{fileId และ/หรือ typeId, filter:{type, userId, annotationTypes}}` — ส่งทั้งคู่ = AND เหลือเฉพาะเอกสารเดียวที่ตรงทั้งสองเงื่อนไข): **read-only ทั้งหมด** — ไม่เขียน DB ไม่ publish SSE event ไม่ `revalidatePath` (snapshot ณ ตอนเรียก ต่าง context กับ Server Action ใน invariant #4); auth ผ่าน `requireExternalApiKey` ของ `lib/external-api-auth.ts` เป็นอย่างแรกก่อนแตะ DB เสมอ (header `x-api-key` เทียบ env `EXTERNAL_API_KEY` แบบ timing-safe) — **env ว่าง/ไม่ตั้ง = fail closed 401 ทุก request** ไม่มี mode เปิดฟรี; ผู้เรียกคือ backend e-Court (server-to-server) จึงไม่ใส่ CORS headers; validation ของ body (`parseQueryBody`) + การ query/mapper (`loadDocumentBundles` — bulk `inArray` ทุกไฟล์ในชุด query เดียว ไม่ยิงต่อไฟล์) รวมอยู่ `lib/external-api.ts` จุดเดียว ห้ามแยกสำเนา; route ครอบ try/catch ทุก DB call — query ล้ม = log ฝั่ง server + **500 JSON ตาม contract** ของ endpoint (ไม่ปล่อยเป็น 500 ของ framework); `lib/external-api-auth.ts` ห้าม import module ที่ลาก `@/db` มา (unit test ต้องรันได้ไม่ต้องตั้ง DATABASE_URL)
|
||||
- API ภายนอก (N10 — `app/api/note/external/documents` เส้นเดียว **POST + JSON body** `{fileId และ/หรือ typeId, filter:{type, userId, annotationTypes}}` — ส่งทั้งคู่ = AND เหลือเฉพาะเอกสารเดียวที่ตรงทั้งสองเงื่อนไข): **read-only ทั้งหมด** — ไม่เขียน DB ไม่ publish SSE event ไม่ `revalidatePath` (snapshot ณ ตอนเรียก ต่าง context กับ Server Action ใน invariant #4); auth ผ่าน `requireExternalApiKey` ของ `lib/external-api-auth.ts` เป็นอย่างแรกก่อนแตะ DB เสมอ (header `x-api-key` เทียบ env `EXTERNAL_API_KEY` แบบ timing-safe) — **env ว่าง/ไม่ตั้ง = fail closed 401 ทุก request** ไม่มี mode เปิดฟรี; ผู้เรียกคือ backend e-Court (server-to-server) จึงไม่ใส่ CORS headers; validation ของ body (`parseQueryBody`) + การ query/mapper (`loadDocumentBundles` — bulk `inArray` ทุกไฟล์ในชุด query เดียว ไม่ยิงต่อไฟล์) รวมอยู่ `lib/external-api.ts` จุดเดียว ห้ามแยกสำเนา; route ครอบ try/catch ทุก DB call — query ล้ม = log ฝั่ง server + **500 JSON ตาม contract** ของ endpoint (ไม่ปล่อยเป็น 500 ของ framework); `lib/external-api-auth.ts` ห้าม import module ที่ลาก `@/db` มา (unit test ต้องรันได้ไม่ต้องตั้ง DATABASE_URL)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
paths:
|
||||
- "components/pdf/**"
|
||||
- "app/annotate/**"
|
||||
- "app/note/annotate/**"
|
||||
---
|
||||
|
||||
# PDF Viewer Rules (รายละเอียดเต็ม: docs/PLAN.md §1, §6, §8)
|
||||
|
|
@ -12,12 +12,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) — **fetch/`img.src` ทุกตัว (รวม fallback และรูป N3 ที่โหลด static `pdfUrl` ตรง ๆ) ต้องผ่าน `withBasePath()`** (invariant #12)
|
||||
- **PDF ไทย cmap ของฟอนต์เพี้น (คำสั่งศาล) → โหลดผ่าน `/api/note/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) — **hit-test ของแต่ละตัวอักษรต้องผ่านประตู 2 ชั้น: จุดกึ่งกลางแนวตั้งของตัวอักษรอยู่ในแถบของ rect (เกณฑ์หลักแยกบรรทัด) แล้วเลือก rect ที่ซ้อนทับแนวตั้งมากที่สุด ไม่ใช่ตัวแรกที่เจอ; แยกบรรทัดระหว่างกลุ่มด้วย "rect ไม่ซ้อนทับแนวตั้งกัน = คนละบรรทัด" (แทรก `\n`) ไม่ใช่ heuristic เทียบ midY กับความสูง** — บรรทัดไทยคุมชิด (ห่าง ~16px แต่ rect สูง ~16px เพราะครอบสระบน-ล่าง) ตัวอักษรบรรทัดถัดไป (โดยเฉพาะตัวที่สระบนดันกรอบขึ้น) เหลื่อมกับ rect บรรทัดก่อนหน้า**จริง 1-3px** การเช็คแค่การเหลื่อมไม่พอ: DOM range ของ pdf.js มักครอบ span บรรทัดถัดไปด้วย (ลากจบท้ายบรรทัด/เลยขอบขวา แล้ว caret ปลายทางหลุดเข้า span ถัดไป — `range.toString()` ได้ข้อความหลายบรรทัดทั้งที่ลากบรรทัดเดียว) ตัวอักษรเหล่านั้นเคยถูกดูดเข้ากลุ่มเดียวกันแล้วเรียงรวมด้วย x จนสองบรรทัดสอดแทรกตัวต่อตัวเป็น mojibake ในชื่อ bookmark/copy (bug report 2026-10-05: "แบบบันทึกผลการทดลอง…" กลายเป็น "แบบบันทึกผ(สำลกหารัรบท…") — จุดกึ่งกลางของตัวอักษรบรรทัดถัดไปอยู่นอกแถบเสมอ (เหลื่อมแค่ขอบ) จึงตัดทิ้งให้เหลือเฉพาะข้อความที่เห็นบนจอจริง ปิดท้ายด้วย `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" ตามลงในช่องชื่อ — **จับคู่ด้วย `e.code` (ตำแหน่งกายภาพของปุ่ม — map `TOOL_SHORTCUTS` ใน toolbar-tools.ts + `KeyB`/`KeyZ` ตรง ๆ) ไม่ใช่ `e.key` (2026-09-29, PLAN-TOOLBAR #1): ผู้ใช้ศาลส่วนใหญ่สลับ keyboard เป็น layout ไทย ซึ่งปุ่มเดียวกันให้ e.key เป็น "ภ"/"ว"/"แ"/"น" — เช็ค e.key ทำให้คีย์ลัดทั้งชุด (รวม Ctrl+Z undo/redo) ใช้ไม่ได้เลยบน layout ไทย**
|
||||
- `DEL`/`Backspace` ลบ annotation ที่เลือกอยู่ (`selectedId`) — เฉพาะรายการของตัวเอง (เงื่อนไขเดียวกับปุ่มลบในแผง selection), ข้ามเมื่อ focus อยู่ใน form (`inForm` เดียวกับคีย์ลัดอื่น — พิมพ์ใน composer/textbox ต้องไม่โดนลบ)
|
||||
- Page Virtualization: `IntersectionObserver` render เฉพาะหน้าใน viewport ± 1 หน้า; หน้าพ้นจอ = placeholder `div` ขนาดจริง (จาก `pdfPage.getViewport()` ทุกหน้า ครั้งเดียวตอนโหลด) + unmount canvas/TextLayer/InkLayer คืน memory — annotation ของหน้าที่ไม่ render ยังอยู่ใน state
|
||||
- pdfjs: `dynamic import("pdfjs-dist")` ภายใน `useEffect` เท่านั้น; `GlobalWorkerOptions.workerSrc = withBasePath("/pdf.worker.min.mjs")` — ไฟล์ที่ `public/` **ไม่ใช่สำเนาตรง ๆ** ของ node_modules: postinstall (`scripts/patch-pdf-worker.mjs`) คัดลอกแล้ว patch heuristic fake-space ไทย = ถอด `if (scaledDim)` รอบ `textChunk.prevTransform = getCurrentTextTransform()` ให้อัปเดตทุก glyph (เดิมข้าม glyph กว้าง 0 อย่างสระ/วรรณยุกต์ — charSpacing ของ glyph เหล่านั้นสะสมเป็น gap จน `addFakeSpaces` แทรก " " กลางคำ "ฉะนั้ น" ใน PDF ที่ justify ด้วย letter-spacing ทุก glyph; การอัปเดตทุก glyph ทำให้ gap เหลือ charSpacing ตัวเดียว ต่ำกว่า threshold — อักขระอื่นทั้งหมดไม่เปลี่ยน เฉพาะ space หลอกหาย) — อัปเกรด pdfjs-dist แล้ว pattern minified เปลี่ยน = postinstall fail ดัง (ตั้งใจ กัน patch หลุดเงียบ) + test กัน regression ที่ `tests/scripts/patch-pdf-worker.test.ts` (fixture `tests/fixtures/thai_letterspacing.pdf` — ห้ามวางใน `public/samples/`: seed-samples จะ insert เป็นรายการเอกสารบนหน้ารายการทุก deploy); ห้าม `new URL(..., import.meta.url)`
|
||||
- pdfjs: `dynamic import("pdfjs-dist")` ภายใน `useEffect` เท่านั้น; `GlobalWorkerOptions.workerSrc = withBasePath("/pdf.worker.min.mjs")` — ไฟล์ที่ `public/note/` **ไม่ใช่สำเนาตรง ๆ** ของ 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/note/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=<uuid>` scroll ไปหน้าของ annotation นั้น + `setSelectedId` — placeholder ขนาดจริงทุกหน้าทำให้คำนวณ absolute scroll ได้แม้หน้ายังไม่ render (กลไกเดียวกับ bookmark); ปุ่ม "คัดลอกลิงก์" ใน sidebar สร้าง URL รูป `/annotate/<fileId>?page=<N>&ann=<id>`
|
||||
- Dialog `fixed z-50` ใน sidebar (ประวัติ) ต้อง render ผ่าน **`createPortal` ไป `document.body`** + guard `typeof document === "undefined"` ตอน SSR — `aside.sticky` สร้าง stacking context ครอบ z-index ของ dialog ไว้ ทำให้ `fixed z-50` แพ้ `textLayer` (z-[2] ระดับ root) จนคลิกปุ่มไม่ได้ (PLAN-FEAT.md §5)
|
||||
|
|
@ -35,4 +35,4 @@ paths:
|
|||
- TEXTBOX กล่องกว้างคงที่ (`width?` สไตล์ Smallpdf/Acrobat): กล่องที่สร้างใหม่คลิกเดียวได้ `width = min(TEXTBOX_DEFAULT_WIDTH_PCT=30, textboxMaxWidthPct)` — **ลากขอบซ้าย/ขวาของกรอบ selection = ปรับความกว้าง** (mode `wleft`/`wright` ใน AnnotationSelectionLayer: ขอบซ้ายขยับ = เลื่อนกล่อง+เปลี่ยนกว้าง (`position.x` = ขอบซ้ายตรง ๆ), ขอบขวาขยับ = เปลี่ยนกว้างอย่างเดียว, clamp กว้าง 2–50% และอยู่ในหน้า) ฟอนต์คงขนาด ข้อความ wrap ใหม่ ส่วนสูงตามจำนวนบรรทัดอัตโนมัติ — กล่องแบบนี้**ซ่อนมุมกรอบ** (scale ไม่มีความหมาย) ย้ายกล่องใช้ขอบบน/ล่าง; กล่องเก่าที่ไม่มี `width` = shrink-to-fit หุ้มข้อความตามพฤติกรรมเดิมทั้งหมด; ฟอนต์ default ตอนสร้าง = `TEXTBOX_DEFAULT_FONT_SIZE_PCT` 2.5% (≈15px — เดิม 1.2% ≈ 7px อ่านไม่ออก และเล็กกว่า preset ต่ำสุด 10px ของตัวเอง) และเข้าโหมดแก้ข้อความได้จากปุ่ม "แก้ไขข้อความ" ในแผง selection (`onStartEdit`) นอกเหนือจาก double-click (จอสัมผัส iOS double-tap = zoom หน้า dblclick จึงใช้ไม่ได้)
|
||||
- คลิกเลือกบนหน้า: tool select/highlight คลิกบน annotation = `setSelectedId` ผ่าน `hitTestAnnotation` (DRAW/SHAPE ใช้ hit-test แม่นเดียวกับ eraser, HIGHLIGHT ดู rect, ที่เหลือ bbox+padding) — เช็คจากรายการหลังสุดก่อน (ตัวบนสุดชนะ), คลิกที่ว่าง = deselect; ใช้ `visibleAnnotations` (เคารพ filter รายคน F4)
|
||||
- Tooltip hover (N1): `onMouseMove` ของ page wrapper ทำ hit-test ชุดเดียวกับคลิก → `hoveredId` state **เดี่ยวระดับ root เปลี่ยนเฉพาะเมื่อ id เปลี่ยน** (ห้าม setState ทุก mousemove, hit-test เดินจากท้าย array ไม่ allocate ต่อ event) → tooltip "ชนิด · ผู้ทำ + โน้ต" ลอยข้าง bbox (`pointer-events: none` เสมอ, z-[5], ยกเว้น COMMENT) — ปิดช่วง tool ที่รับ pointer (`draw/shape/erase/redact/pan`), `e.buttons !== 0`, ตอน `openPinId !== null` (popover ของ pin เปิดอยู่) และ**เมื่อรายการที่ชี้ = `selectedId`** (แผง selection เปิดอยู่แถวนั้นแล้ว — เจอตอนลากคลุมจบแล้ว auto-select โผล่ที่ตำแหน่งเคอร์เซอร์พอดี ซ้อนเป็นสองกล่อง)
|
||||
- **N3 ไฟล์รูปภาพ (jpg/jpeg/png/tif — `fileKindFromUrl` จาก `utils/file-kind.ts`):** รูป**โหลดจาก static `pdfUrl` ตรง ๆ ไม่ผ่าน `/api/pdf`** (ไม่มี ToUnicode ให้ซ่อม — route นั้นมี defensive branch คืน bytes ดิบ + content-type รูป กัน stray call เข้า `fixPdfActualText`) — หน้ารูป render เป็น `<img>` แทน canvas ที่ z-1 (`pointer-events:none` เหมือน canvas — invariant #3, DOM order ไม่ขยับ) + **ไม่มี textLayer** (conditional render เฉพาะ pdf) → PdfPage เรียก `onTextLayerRendered(pageIndex, 0)` ให้ hint "ภาพสแกน" + custom selection จอสัมผัส bail เอง — **toolbar ซ่อน tool มาร์กข้อความ (HIGHLIGHT) ทั้งปุ่มและเมนูรูปแบบเมื่อไม่มีข้อความให้คลุม** — ทั้งไฟล์ภาพและ **PDF สแกนล้วน** (คำขอผู้ใช้ 2026-09-22 แทนแผนเดิมที่คงไว้พร้อม hint): `hideHighlightTool` = ไฟล์ภาพ หรือ `scannedOnly` = ทุกหน้าที่ render แล้วไม่มี span เลย (entry ของ `pageSpanCount` สะสมต่อหน้า ไม่ลบตอน virtualization unmount = **latch**: เคยเจอหน้ามีข้อความแม้หน้าเดียว ไฟล์ถือว่ามีข้อความ ปุ่มกลับมาค้าง — ไฟล์ผสมสแกนนำหน้าโผล่ครั้งเดียวพอ ไม่โผล่-หายตามหน้าที่เปิดดู) — กันคีย์ลัด H ที่ `switchTool` (**เก็บปุ่มเดิมไว้ แต่ยังเคลียร์เมนู/popup/selection เหมือนสลับ tool จริง — ไม่ no-op เงียบสนิท**, finding #4 ของ PLAN-TOOLBAR 2026-09-29) + reset กลับ "select" ทั้งตอนสลับมาเป็นไฟล์ภาพขณะ tool นี้ active (dynamic param เปลี่ยน = reuse component ไม่ reset state) และ effect ตอน `scannedOnly` กลายเป็น true — reset ทั้งสองจุดปิดเมนู dropdown ที่ค้างเปิดด้วย `closeToolMenus()` (2026-09-29 — เมนู portal fixed จะลอยไร้ปุ่มยึดเมื่อปุ่มถูกซ่อน); แถบ hint M6 "ภาพสแกน" เหลือเป็น defensive ช่วง transition เท่านั้น; `pageSizes` จาก `naturalWidth/Height` (browser คิด EXIF orientation ให้แล้ว) **normalize กว้าง `IMAGE_PAGE_WIDTH`=595** สัดส่วนเดิม — scale contract เดียวกับ export (`utils/image-to-pdf.ts`) ให้ zoom %/fit-width clamp 0.5–3/fontSize % ทำงานเหมือนหน้า A4; TIFF decode ผ่าน `utif2` **dynamic import เฉพาะไฟล์ tif** (pattern เดียวกับ invariant #6) → หนึ่ง PNG blob URL ต่อเฟรม (**หลายเฟรม = หลายหน้า**) และ **revoke ทุก blob URL ตอน cleanup ของ load effect**; เฟรมที่ decode พัง = `src: null` → placeholder เทาแต่ขนาดหน้ายังเอาจาก IFD header ได้
|
||||
- **N3 ไฟล์รูปภาพ (jpg/jpeg/png/tif — `fileKindFromUrl` จาก `utils/file-kind.ts`):** รูป**โหลดจาก static `pdfUrl` ตรง ๆ ไม่ผ่าน `/api/note/pdf`** (ไม่มี ToUnicode ให้ซ่อม — route นั้นมี defensive branch คืน bytes ดิบ + content-type รูป กัน stray call เข้า `fixPdfActualText`) — หน้ารูป render เป็น `<img>` แทน canvas ที่ z-1 (`pointer-events:none` เหมือน canvas — invariant #3, DOM order ไม่ขยับ) + **ไม่มี textLayer** (conditional render เฉพาะ pdf) → PdfPage เรียก `onTextLayerRendered(pageIndex, 0)` ให้ hint "ภาพสแกน" + custom selection จอสัมผัส bail เอง — **toolbar ซ่อน tool มาร์กข้อความ (HIGHLIGHT) ทั้งปุ่มและเมนูรูปแบบเมื่อไม่มีข้อความให้คลุม** — ทั้งไฟล์ภาพและ **PDF สแกนล้วน** (คำขอผู้ใช้ 2026-09-22 แทนแผนเดิมที่คงไว้พร้อม hint): `hideHighlightTool` = ไฟล์ภาพ หรือ `scannedOnly` = ทุกหน้าที่ render แล้วไม่มี span เลย (entry ของ `pageSpanCount` สะสมต่อหน้า ไม่ลบตอน virtualization unmount = **latch**: เคยเจอหน้ามีข้อความแม้หน้าเดียว ไฟล์ถือว่ามีข้อความ ปุ่มกลับมาค้าง — ไฟล์ผสมสแกนนำหน้าโผล่ครั้งเดียวพอ ไม่โผล่-หายตามหน้าที่เปิดดู) — กันคีย์ลัด H ที่ `switchTool` (**เก็บปุ่มเดิมไว้ แต่ยังเคลียร์เมนู/popup/selection เหมือนสลับ tool จริง — ไม่ no-op เงียบสนิท**, finding #4 ของ PLAN-TOOLBAR 2026-09-29) + reset กลับ "select" ทั้งตอนสลับมาเป็นไฟล์ภาพขณะ tool นี้ active (dynamic param เปลี่ยน = reuse component ไม่ reset state) และ effect ตอน `scannedOnly` กลายเป็น true — reset ทั้งสองจุดปิดเมนู dropdown ที่ค้างเปิดด้วย `closeToolMenus()` (2026-09-29 — เมนู portal fixed จะลอยไร้ปุ่มยึดเมื่อปุ่มถูกซ่อน); แถบ hint M6 "ภาพสแกน" เหลือเป็น defensive ช่วง transition เท่านั้น; `pageSizes` จาก `naturalWidth/Height` (browser คิด EXIF orientation ให้แล้ว) **normalize กว้าง `IMAGE_PAGE_WIDTH`=595** สัดส่วนเดิม — scale contract เดียวกับ export (`utils/image-to-pdf.ts`) ให้ zoom %/fit-width clamp 0.5–3/fontSize % ทำงานเหมือนหน้า A4; TIFF decode ผ่าน `utif2` **dynamic import เฉพาะไฟล์ tif** (pattern เดียวกับ invariant #6) → หนึ่ง PNG blob URL ต่อเฟรม (**หลายเฟรม = หลายหน้า**) และ **revoke ทุก blob URL ตอน cleanup ของ load effect**; เฟรมที่ decode พัง = `src: null` → placeholder เทาแต่ขนาดหน้ายังเอาจาก IFD header ได้
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ paths:
|
|||
|
||||
# Realtime Rules (รายละเอียด: docs/PLAN.md §4)
|
||||
|
||||
- Sync ใช้ **SSE**: hook เปิด `EventSource` ไป `withBasePath("/api/annotations/stream?fileId=<uuid>")` (basePath ตาม invariant #12) และ `es.close()` เมื่อ unmount
|
||||
- Sync ใช้ **SSE**: hook เปิด `EventSource` ไป `withBasePath("/api/note/annotations/stream?fileId=<uuid>")` (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
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ description: Sync Drizzle schema ลง PostgreSQL container judes-note-db + see
|
|||
## ขั้นตอน
|
||||
|
||||
1. **Push schema:** รัน `npx drizzle-kit push` (อ่าน config จาก `drizzle.config.ts`) — รายงาน output จริง ถ้าชนของเดิมให้แสดงและถามผู้ใช้ก่อน destructive ทางเลือก
|
||||
2. **Seed ตัวอย่าง (เฉพาะเมื่อตาราง `files` ว่าง):** INSERT ไฟล์จาก `public/samples/` โดย `pdf_url = '/samples/<ชื่อไฟล์>'` (ไฟล์และที่มาดูใน PLAN.md §0 ตาราง "ไฟล์ตัวอย่าง") — ใช้ SQL จาก README ถ้ามีแล้ว รันผ่าน `docker exec judes-note-db psql -U postgres -c "..."`
|
||||
2. **Seed ตัวอย่าง (เฉพาะเมื่อตาราง `files` ว่าง):** INSERT ไฟล์จาก `public/note/samples/` โดย `pdf_url = '/samples/<ชื่อไฟล์>'` (ไฟล์และที่มาดูใน PLAN.md §0 ตาราง "ไฟล์ตัวอย่าง") — ใช้ SQL จาก README ถ้ามีแล้ว รันผ่าน `docker exec judes-note-db psql -U postgres -c "..."`
|
||||
3. **รายงานผลแต่ละขั้นตามจริง** — ขั้นไหนไม่ได้ทำบอกสาเหตุ อย่าเดา
|
||||
|
||||
## ข้อควรระวัง
|
||||
|
|
|
|||
|
|
@ -9,4 +9,4 @@ npm-debug.log*
|
|||
.env.*
|
||||
!.env.example
|
||||
# ไฟล์ที่อัปโหลดระหว่าง dev ห้ามติดไปใน image (production เขียน runtime + เก็บใน volume ไม่ใช่ bake ตอน build)
|
||||
public/uploads
|
||||
public/note/uploads
|
||||
|
|
|
|||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -29,7 +29,7 @@ next-env.d.ts
|
|||
# Claude Code
|
||||
.claude/settings.local.json
|
||||
# N4: ไฟล์ที่ผู้ใช้อัปโหลดผ่านหน้ารายการ (รันไทม์ — ไม่เข้า repo)
|
||||
public/uploads/
|
||||
public/note/uploads/
|
||||
|
||||
# compose ทดสอบบนเครื่อง dev (build จาก repo + DB แยก รันที่ 6704) — ไม่ใช่ของ production
|
||||
docker-compose.test.yml
|
||||
|
|
|
|||
62
CLAUDE.md
62
CLAUDE.md
File diff suppressed because one or more lines are too long
28
README.md
28
README.md
|
|
@ -47,7 +47,7 @@ docker run -d --name judes-note-db \
|
|||
|
||||
```env
|
||||
DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:54322/ecourt-note
|
||||
# N10 (optional): API key ของ /api/external/* สำหรับ backend e-Court เรียกแบบ server-to-server
|
||||
# N10 (optional): API key ของ /api/note/external/* สำหรับ backend e-Court เรียกแบบ server-to-server
|
||||
# ไม่ตั้งหรือเว้นว่าง = API ปฏิเสธทุก request (fail closed) — ตั้งค่ายาวและสุ่ม เช่น 32+ ตัวอักษร
|
||||
# EXTERNAL_API_KEY=
|
||||
```
|
||||
|
|
@ -62,13 +62,13 @@ npx drizzle-kit push
|
|||
|
||||
### 5. Seed ไฟล์ตัวอย่าง
|
||||
|
||||
ไฟล์ตัวอย่างอยู่ที่ `public/samples/` แล้ว — sync ลงตาราง `files` (idempotent รันซ้ำได้) — รองรับทั้ง `.pdf` และรูปภาพ `.jpg/.jpeg/.png/.tif/.tiff` (N3):
|
||||
ไฟล์ตัวอย่างอยู่ที่ `public/note/samples/` แล้ว — sync ลงตาราง `files` (idempotent รันซ้ำได้) — รองรับทั้ง `.pdf` และรูปภาพ `.jpg/.jpeg/.png/.tif/.tiff` (N3):
|
||||
|
||||
```bash
|
||||
npm run seed-samples
|
||||
```
|
||||
|
||||
> บน production ไม่ต้องรันมือ — script เดียวกัน (`scripts/seed-samples.mjs`) รันอัตโนมัติตอน container start ต่อจาก migration ไฟล์ที่หายจาก `public/samples/` จะถูกลบ row พร้อม annotation ที่ผูกอยู่ (CASCADE)
|
||||
> บน production ไม่ต้องรันมือ — script เดียวกัน (`scripts/seed-samples.mjs`) รันอัตโนมัติตอน container start ต่อจาก migration ไฟล์ที่หายจาก `public/note/samples/` จะถูกลบ row พร้อม annotation ที่ผูกอยู่ (CASCADE)
|
||||
|
||||
### 6. รัน dev server
|
||||
|
||||
|
|
@ -76,7 +76,7 @@ npm run seed-samples
|
|||
npm run dev
|
||||
```
|
||||
|
||||
เปิด http://localhost:3000/docedit/note — คลิกไฟล์เพื่อเข้าหน้า annotate หรืออัปโหลดไฟล์ใหม่ (PDF/รูปภาพ ≤ 100MB) ผ่านฟอร์มบนหน้ารายการ (N4 — ไฟล์เก็บที่ `public/uploads/`)
|
||||
เปิด http://localhost:3000/docedit/note — คลิกไฟล์เพื่อเข้าหน้า annotate หรืออัปโหลดไฟล์ใหม่ (PDF/รูปภาพ ≤ 100MB) ผ่านฟอร์มบนหน้ารายการ (N4 — ไฟล์เก็บที่ `public/note/uploads/`)
|
||||
|
||||
## การใช้งานบนหน้า annotate
|
||||
|
||||
|
|
@ -94,10 +94,10 @@ Sidebar ซ้าย: แท็บ Bookmarks / Annotations — คลิกร
|
|||
|
||||
## API สำหรับระบบภายนอก (N10)
|
||||
|
||||
Read-only API ให้ **backend ของ e-Court** เรียกแบบ server-to-server (ไม่มีหน้า UI) — endpoint เดียว `POST /api/external/documents` (JSON body) ตรวจสิทธิ์ด้วย API Key เดียวใน env `EXTERNAL_API_KEY` ส่งผ่าน header `x-api-key` (env ไม่ตั้ง/ว่าง = ปฏิเสธทุก request — fail closed) — คู่มือการใช้สำหรับผู้เรียก: `docs/API-DOCUMENTS.md` · สเปก/เหตุผลการออกแบบ: `docs/PLAN-API.md`
|
||||
Read-only API ให้ **backend ของ e-Court** เรียกแบบ server-to-server (ไม่มีหน้า UI) — endpoint เดียว `POST /api/note/external/documents` (JSON body) ตรวจสิทธิ์ด้วย API Key เดียวใน env `EXTERNAL_API_KEY` ส่งผ่าน header `x-api-key` (env ไม่ตั้ง/ว่าง = ปฏิเสธทุก request — fail closed) — คู่มือการใช้สำหรับผู้เรียก: `docs/API-DOCUMENTS.md` · สเปก/เหตุผลการออกแบบ: `docs/PLAN-API.md`
|
||||
|
||||
```jsonc
|
||||
// POST /docedit/note/api/external/documents (URL เต็มฝั่งผู้เรียกอยู่ใต้ basePath)
|
||||
// POST /docedit/api/note/external/documents (URL เต็มฝั่งผู้เรียกอยู่ใต้ basePath)
|
||||
// headers: x-api-key + content-type: application/json
|
||||
{
|
||||
"fileId": "<fileId ของ e-Court>", // ถามเอกสารเดียว — ส่งพร้อม typeId ได้ = กรองเฉพาะเอกสารที่ตรงทั้งสองเงื่อนไข (AND)
|
||||
|
|
@ -118,11 +118,11 @@ Read-only API ให้ **backend ของ e-Court** เรียกแบบ s
|
|||
```bash
|
||||
curl -X POST -H "x-api-key: $EXTERNAL_API_KEY" -H "content-type: application/json" \
|
||||
-d '{"fileId":"1234","filter":{"type":"bookmarks"}}' \
|
||||
"http://localhost:3000/docedit/note/api/external/documents"
|
||||
"http://localhost:3000/docedit/api/note/external/documents"
|
||||
|
||||
curl -X POST -H "x-api-key: $EXTERNAL_API_KEY" -H "content-type: application/json" \
|
||||
-d '{"typeId":"CASE-2569-0001"}' \
|
||||
"http://localhost:3000/docedit/note/api/external/documents"
|
||||
"http://localhost:3000/docedit/api/note/external/documents"
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
|
@ -137,7 +137,7 @@ curl -X POST -H "x-api-key: $EXTERNAL_API_KEY" -H "content-type: application/jso
|
|||
|
||||
## หมายเหตุทางเทคนิค
|
||||
|
||||
- **Realtime = SSE + pub/sub in-process:** Server Actions (`app/actions/annotations.ts`) publish event หลังเขียน DB สำเร็จทุกครั้ง → `lib/annotate-events.ts` → SSE endpoint `app/api/annotations/stream` → hook เปิด `EventSource` รับ event (payload เป็น DTO จาก server ตรง ๆ)
|
||||
- **Realtime = SSE + pub/sub in-process:** Server Actions (`app/actions/annotations.ts`) publish event หลังเขียน DB สำเร็จทุกครั้ง → `lib/annotate-events.ts` → SSE endpoint `app/api/note/annotations/stream` → hook เปิด `EventSource` รับ event (payload เป็น DTO จาก server ตรง ๆ)
|
||||
- UI ใช้ `useOptimistic` ที่มาจาก React canary build ซึ่ง Next.js 14 vendored ไว้ให้ — หากหยิบ component ไปรันนอก Next bundling (เช่น unit test ด้วย `react` เปล่า) จะหา export นี้ไม่เจอ
|
||||
- พิกัด annotation ทุกชนิดเก็บเป็น **% ของขนาดหน้า** → render ถูกต้องทุก zoom/ขนาดจอ และ `mergeRectsByLine` รวบ rect ให้เหลือ 1 อันต่อบรรทัดก่อนบันทึก highlight
|
||||
- สถาปัตยกรรม/สเปกฉบับเต็ม: `docs/PLAN.md` · กติกาที่ต้องรักษาตอนแก้โค้ด: `CLAUDE.md` · ฟีเจอร์ต่อยอด: `docs/PLAN-FEAT.md`
|
||||
|
|
@ -152,11 +152,11 @@ utils/coordinates.ts px ⇄ % + clamp + mergeRectsByLine
|
|||
utils/user.ts parse/build cookie oacCase (mock login e-Court)
|
||||
hooks/useAnnotationRealtime.ts EventSource รับ SSE event + dedupe ด้วย id
|
||||
app/actions/annotations.ts Server Actions CRUD + publish event (ทางเขียนทางเดียว)
|
||||
app/api/annotations/stream/route.ts SSE endpoint (GET ?fileId=)
|
||||
app/page.tsx รายการไฟล์ + ฟอร์มอัปโหลด (N4)
|
||||
app/annotate/[id]/page.tsx โหลด file + initial data → PDFViewer
|
||||
app/api/note/annotations/stream/route.ts SSE endpoint (GET ?fileId=)
|
||||
app/note/page.tsx รายการไฟล์ + ฟอร์มอัปโหลด (N4)
|
||||
app/note/annotate/[id]/page.tsx โหลด file + initial data → PDFViewer
|
||||
components/pdf/ PDFViewer (root + virtualization), InkLayer, HighlightToolbar
|
||||
components/sidebar/ AnnotationSidebar
|
||||
public/samples/ ไฟล์ตัวอย่าง (PDF + รูปภาพ) — seed ด้วย npm run seed-samples
|
||||
public/uploads/ ไฟล์ที่อัปโหลดผ่านหน้ารายการ (N4)
|
||||
public/note/samples/ ไฟล์ตัวอย่าง (PDF + รูปภาพ) — seed ด้วย npm run seed-samples
|
||||
public/note/uploads/ ไฟล์ที่อัปโหลดผ่านหน้ารายการ (N4)
|
||||
```
|
||||
|
|
|
|||
|
|
@ -445,8 +445,8 @@ function validateAnnotationData(type: AnnotationType, v: unknown): AnnotationDat
|
|||
* hard refresh ถึงเห็น (force-dynamic ปิดแค่ cache ฝั่ง server เท่านั้น)
|
||||
*/
|
||||
function revalidateFilePages(fileId: string) {
|
||||
revalidatePath(`/annotate/${fileId}`);
|
||||
revalidatePath(`/annotate/${fileId}/summary`);
|
||||
revalidatePath(`/note/annotate/${fileId}`);
|
||||
revalidatePath(`/note/annotate/${fileId}/summary`);
|
||||
}
|
||||
|
||||
export async function createAnnotation(input: CreateAnnotationInput): Promise<AnnotationDTO> {
|
||||
|
|
|
|||
|
|
@ -36,6 +36,6 @@ export async function logout(): Promise<void> {
|
|||
// ลบ cookie ด้วย path "/" เท่านั้น (ตรงกับตอน set) ไม่งั้น browser ไม่ลบให้
|
||||
cookies().set(OAC_CASE_COOKIE, "", { path: "/", maxAge: 0 });
|
||||
// redirect ใน action เอง — ให้ Next จัดการ navigation กลไกเดียว
|
||||
// (ถ้าให้ client router.replace เอง จะชนกับ re-render ของหน้า "/" ที่เพิ่งเสีย session จน navigation ไม่เกิด)
|
||||
redirect("/login");
|
||||
// (ถ้าให้ client router.replace เอง จะชนกับ re-render ของหน้ารายการที่เพิ่งเสีย session จน navigation ไม่เกิด)
|
||||
redirect("/note/login");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
"use server";
|
||||
|
||||
// N4 — อัปโหลดไฟล์เอกสารแบบง่าย (PDF/jpg/jpeg/png/tif/tiff) ผ่านหน้ารายการ
|
||||
// เขียนไฟล์**ใหม่**ที่ public/uploads/<uuid>.<ext> เท่านั้น (ชื่อจาก randomUUID — กันชนกัน/กัน path traversal)
|
||||
// ไม่มี SSE event: รายการไฟล์ไม่ได้ sync ผ่าน SSE — revalidatePath("/") พอ (สเปก PLAN-FEAT.md §7 N4)
|
||||
// เขียนไฟล์**ใหม่**ที่ public/note/uploads/<uuid>.<ext> เท่านั้น (ชื่อจาก randomUUID — กันชนกัน/กัน path traversal)
|
||||
// ไม่มี SSE event: รายการไฟล์ไม่ได้ sync ผ่าน SSE — revalidatePath("/note") พอ (สเปก PLAN-FEAT.md §7 N4)
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdir, unlink, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
|
@ -16,7 +16,7 @@ import { SAMPLE_FILE_EXTENSIONS } from "@/utils/file-kind";
|
|||
import { decodeUploadFilename } from "@/utils/upload-name";
|
||||
|
||||
const MAX_UPLOAD_BYTES = 100 * 1024 * 1024; // 100MB (ตาม bodySizeLimit ใน next.config.mjs)
|
||||
const UPLOAD_DIR = path.join(process.cwd(), "public", "uploads");
|
||||
const UPLOAD_DIR = path.join(process.cwd(), "public", "note", "uploads");
|
||||
|
||||
export type UploadFileResult =
|
||||
| { ok: true; fileId: string; title: string }
|
||||
|
|
@ -108,6 +108,6 @@ export async function uploadFile(formData: FormData): Promise<UploadFileResult>
|
|||
throw err;
|
||||
}
|
||||
|
||||
revalidatePath("/");
|
||||
revalidatePath("/note");
|
||||
return { ok: true, fileId: id, title };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { files } from "@/db/schema";
|
|||
import { externalApiError, requireExternalApiKey } from "@/lib/external-api-auth";
|
||||
import { loadDocumentBundles, parseQueryBody } from "@/lib/external-api";
|
||||
|
||||
// N10 (PLAN-API.md D2) — POST /api/external/documents
|
||||
// N10 (PLAN-API.md D2) — POST /api/note/external/documents
|
||||
// (เดิมชื่อ /api/external/query ถึงรอบสาม — เปลี่ยนชื่อตาม resource ที่คืน 2026-09-25)
|
||||
// เส้นเดียวถามด้วย JSON body { fileId และ/หรือ typeId, filter } — response
|
||||
// รูปเดียวเสมอ { documents: [bundle…] } (bundle ยุบชั้น — field เอกสารอยู่
|
||||
|
|
@ -30,7 +30,7 @@ export default async function AnnotatePage({
|
|||
|
||||
// defense ซ้อน middleware — identity จาก cookie oacCase ส่งเข้า viewer ตั้งแต่ first paint
|
||||
const user = getSessionUser();
|
||||
if (!user) redirect("/login");
|
||||
if (!user) redirect("/note/login");
|
||||
|
||||
const [file] = await db.select().from(files).where(eq(files.id, id));
|
||||
if (!file) notFound();
|
||||
|
|
@ -16,7 +16,7 @@ import {
|
|||
} from "@/db/schema";
|
||||
|
||||
// หน้าสรุปหมายเหตุ (F15/D2) — server component รวม annotation (ทุกผู้ใช้) + bookmark
|
||||
// แต่ละแถวกระโดดเป็น deep link `/annotate/<id>?ann=` / `?page=` (F12)
|
||||
// แต่ละแถวกระโดดเป็น deep link `/note/annotate/<id>?ann=` / `?page=` (F12)
|
||||
// — design "แฟ้มสำนวน" 2026-09-17 (แถบหัวน้ำเงิน + การ์ดรายหน้าขอบ folio +
|
||||
// รายการตามหน้าจุดไข่ปลา) → redesign 2026-09-24 ตามคำขอผู้ใช้เป็น explorer
|
||||
// 2 คอลัมน์แบบหน้ากลุ่ม N9: ตัวกรองฝั่งซ้าย (หน้า/ชนิดรายการ/ชนิดของ
|
||||
|
|
@ -134,7 +134,7 @@ export default async function SummaryPage({
|
|||
— ระบบภายนอกดึงข้อมูลผ่าน N10 API แทนทั้งหมดแล้ว */}
|
||||
<div className="mt-3 flex flex-wrap items-center gap-x-2 gap-y-1 print:hidden">
|
||||
<Link
|
||||
href={`/annotate/${id}`}
|
||||
href={`/note/annotate/${id}`}
|
||||
className={`inline-flex items-center gap-1.5 rounded-md border border-line bg-white px-3 py-1.5 text-sm text-ink hover:border-ink-faint hover:bg-ink/[0.03] ${RING_CLS}`}
|
||||
>
|
||||
<ArrowLeft aria-hidden className="h-3.5 w-3.5" />
|
||||
|
|
@ -154,7 +154,7 @@ export default async function SummaryPage({
|
|||
เปิดหน้าอ่านเพื่อมาร์กข้อความ วาดเส้น ใส่คอมเมนต์ หรือคั่นหน้า
|
||||
</p>
|
||||
<Link
|
||||
href={`/annotate/${id}`}
|
||||
href={`/note/annotate/${id}`}
|
||||
className={`mt-4 inline-flex rounded-md bg-primary px-4 py-2 text-sm font-medium text-white transition hover:-translate-y-px hover:bg-primary-hover hover:shadow-btn-glow ${RING_CLS}`}
|
||||
>
|
||||
เปิดหน้าอ่าน
|
||||
|
|
@ -7,9 +7,9 @@ import { firstSearchParam } from "@/utils/external-file";
|
|||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* N8 entry point ที่สอง (ตามคำขอผู้ใช้ 2026-09-22): เข้า `/annotate?fileUrl=&fileId=&typeId=`
|
||||
* โดยตรง — ลงทะเบียนเอกสารแล้ว redirect ต่อไป `/annotate/<id>` ของตัว viewer จริง
|
||||
* (entry เดิมที่หน้า "/" ยังใช้ได้คู่กัน — สัญญาลิงก์รับทั้ง /?fileUrl= และ /annotate?fileUrl=)
|
||||
* N8 entry point ที่สอง (ตามคำขอผู้ใช้ 2026-09-22): เข้า `/note/annotate?fileUrl=&fileId=&typeId=`
|
||||
* โดยตรง — ลงทะเบียนเอกสารแล้ว redirect ต่อไป `/note/annotate/<id>` ของตัว viewer จริง
|
||||
* (entry เดิมที่หน้า "/note" ยังใช้ได้คู่กัน — สัญญาลิงก์รับทั้ง /note?fileUrl= และ /note/annotate?fileUrl=)
|
||||
*/
|
||||
export default async function AnnotateEntryPage({
|
||||
searchParams,
|
||||
|
|
@ -24,7 +24,7 @@ export default async function AnnotateEntryPage({
|
|||
firstSearchParam(searchParams.typeId),
|
||||
);
|
||||
// redirect โยน NEXT_REDIRECT — เรียกนอก try/catch เสมอ
|
||||
if (result.ok) redirect(`/annotate/${result.fileId}`);
|
||||
if (result.ok) redirect(`/note/annotate/${result.fileId}`);
|
||||
return (
|
||||
<main className="mx-auto max-w-2xl px-4 py-10 sm:px-8">
|
||||
<div
|
||||
|
|
@ -34,7 +34,7 @@ export default async function AnnotateEntryPage({
|
|||
เปิดเอกสารจากลิงก์ไม่สำเร็จ: {result.error}
|
||||
</div>
|
||||
<Link
|
||||
href="/"
|
||||
href="/note"
|
||||
className="focus-ring mt-4 inline-block rounded-md bg-primary px-4 py-2 text-sm font-medium text-white transition hover:-translate-y-px hover:bg-primary-hover hover:shadow-btn-glow"
|
||||
>
|
||||
กลับหน้ารายการ
|
||||
|
|
@ -43,7 +43,7 @@ export default async function AnnotateEntryPage({
|
|||
);
|
||||
}
|
||||
|
||||
// ไม่มี fileUrl เลย — ไม่มีอะไรให้เปิด (route จริงของ viewer อยู่ที่ /annotate/[id])
|
||||
// ไม่มี fileUrl เลย — ไม่มีอะไรให้เปิด (route จริงของ viewer อยู่ที่ /note/annotate/[id])
|
||||
return (
|
||||
<main className="mx-auto max-w-2xl px-4 py-10 sm:px-8">
|
||||
<div className="rounded-xl border border-line bg-white p-6 text-sm text-ink-soft">
|
||||
|
|
@ -53,7 +53,7 @@ export default async function AnnotateEntryPage({
|
|||
หรือเลือกไฟล์จากหน้ารายการ
|
||||
</div>
|
||||
<Link
|
||||
href="/"
|
||||
href="/note"
|
||||
className="focus-ring mt-4 inline-block rounded-md bg-primary px-4 py-2 text-sm font-medium text-white transition hover:-translate-y-px hover:bg-primary-hover hover:shadow-btn-glow"
|
||||
>
|
||||
กลับหน้ารายการ
|
||||
|
|
@ -130,7 +130,7 @@ export default async function GroupPage({
|
|||
// listing-page: hook ของกฎ coarse 44px ใน globals.css (อย่าถอด)
|
||||
<main className="listing-page mx-auto max-w-3xl px-4 py-8 sm:px-6 lg:max-w-6xl lg:px-8">
|
||||
<Link
|
||||
href="/"
|
||||
href="/note"
|
||||
className="focus-ring inline-flex items-center gap-1.5 rounded-md text-sm text-ink-soft transition hover:text-ink"
|
||||
>
|
||||
<ArrowLeft aria-hidden className="h-4 w-4" />
|
||||
|
|
@ -22,7 +22,7 @@ export default function LoginPage({
|
|||
|
||||
// เข้าสู่ระบบอยู่แล้ว (cookie ครบ) → ไม่ต้องเห็นฟอร์มอีก
|
||||
const user = getSessionUser();
|
||||
if (user) redirect(next ?? "/");
|
||||
if (user) redirect(next ?? "/note");
|
||||
|
||||
return (
|
||||
// Genesis: กริดจุดอันตรกิริยา ( decorative element ชิ้นเดียวที่อนุญาต) เป็นพื้นหลัง
|
||||
|
|
@ -62,7 +62,7 @@ function FileCard({ file }: { file: FileRow }) {
|
|||
(หนึ่ง primary ต่อ section ตาม Genesis; การ์ดทั้งกริดคือ passive surface) */}
|
||||
<div className="mt-5 flex flex-1 items-end justify-end">
|
||||
<Link
|
||||
href={`/annotate/${file.id}`}
|
||||
href={`/note/annotate/${file.id}`}
|
||||
className="focus-ring inline-flex h-8 items-center rounded-md border border-line px-3 text-sm font-medium text-ink transition hover:-translate-y-px hover:border-ink-faint"
|
||||
>
|
||||
Annotate
|
||||
|
|
@ -79,12 +79,12 @@ export default async function HomePage({
|
|||
}) {
|
||||
// defense ซ้อน middleware — หน้านี้อ่าน cookie อยู่ดีจึงกันซ้ำไม่ให้ render รายการให้คนไม่มี session
|
||||
const user = getSessionUser();
|
||||
if (!user) redirect("/login");
|
||||
if (!user) redirect("/note/login");
|
||||
|
||||
// N8: เข้าจากลิงก์ e-Court ?fileUrl=&fileId=&typeId= — ลงทะเบียนเอกสาร (metadata-only
|
||||
// dedupe ด้วย external_id) แล้วพาเข้า viewer เลย — redirect() โยน NEXT_REDIRECT
|
||||
// ต้องเรียกนอก try/catch เสมอ; หน้านี้ force-dynamic จึงไม่ต้อง revalidatePath
|
||||
// (entry ที่สองอยู่ที่ /annotate?fileUrl=… — app/annotate/page.tsx)
|
||||
// (entry ที่สองอยู่ที่ /note/annotate?fileUrl=… — app/note/annotate/page.tsx)
|
||||
let registerError: string | null = null;
|
||||
const fileUrl = firstSearchParam(searchParams.fileUrl);
|
||||
if (fileUrl) {
|
||||
|
|
@ -93,7 +93,7 @@ export default async function HomePage({
|
|||
firstSearchParam(searchParams.fileId),
|
||||
firstSearchParam(searchParams.typeId),
|
||||
);
|
||||
if (result.ok) redirect(`/annotate/${result.fileId}`);
|
||||
if (result.ok) redirect(`/note/annotate/${result.fileId}`);
|
||||
registerError = result.error;
|
||||
}
|
||||
|
||||
|
|
@ -199,7 +199,7 @@ export default async function HomePage({
|
|||
</span>
|
||||
{/* Next Link เติม basePath ให้เอง (invariant #12) */}
|
||||
<Link
|
||||
href={`/groups/${encodeURIComponent(typeId)}`}
|
||||
href={`/note/groups/${encodeURIComponent(typeId)}`}
|
||||
className="focus-ring ml-auto shrink-0 rounded text-sm font-medium text-primary hover:underline"
|
||||
>
|
||||
เอกสารในกลุ่ม
|
||||
|
|
@ -7,13 +7,13 @@ import {
|
|||
} from "@/utils/file-kind";
|
||||
import { PdfPathError, resolvePublicPdfPath } from "@/lib/server-pdf";
|
||||
|
||||
// N4 — เสิร์ฟไฟล์ที่อัปโหลด (`public/uploads/<uuid>.<ext>`) ผ่าน route handler
|
||||
// N4 — เสิร์ฟไฟล์ที่อัปโหลด (`public/note/uploads/<uuid>.<ext>`) ผ่าน route handler
|
||||
// ไม่ใช่ static file ของ Next: production server (next start / standalone) จะไล่
|
||||
// รายชื่อไฟล์ใน public/ เก็บไว้ใน Set **ครั้งเดียวตอน startup** เท่านั้น
|
||||
// (next/dist/server/lib/router-utils/filesystem.js — setupFsCheck) ไฟล์ที่ action
|
||||
// เขียนลง public/uploads/ หลัง server รันแล้วจึง GET ตรง ๆ ไม่ได้ (404)
|
||||
// เขียนลง public/note/uploads/ หลัง server รันแล้วจึง GET ตรง ๆ ไม่ได้ (404)
|
||||
// ฝั่ง next dev ไม่ผ่าน branch นั้น เลยทำงานได้ — อาการคลาสสิก "local ได้ production พัง"
|
||||
// อ่านอย่างเดียว (invariant #1) + path guard ของ lib/server-pdf เหมือน /api/pdf (§5)
|
||||
// อ่านอย่างเดียว (invariant #1) + path guard ของ lib/server-pdf เหมือน /api/note/pdf (§5)
|
||||
|
||||
// ชื่อไฟล์ต้องเป็น uuid + extension ใน whitelist เท่านั้น (ตรงรูปที่ uploadFile เขียน)
|
||||
// — ชื่ออื่น 404 กลับไป ไม่รู้จักก็ไม่เปิด (กัน path traversal โดยแบบชื่อเอง: อักขระ
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
// Genesis (docs/DESIGN.md) Navigation: sticky แถบบน 56px + backdrop-blur + เส้นแบ่ง
|
||||
// ล่าง 1px — โลโก้ซ้าย / ผู้ใช้ + ออกจากระบบ ขวา (แอปหน้าเดียวต่อ context จึงไม่มี
|
||||
// ลิงก์กลางจอ) ใช้เฉพาะหน้ารายการ / — หน้ากลุ่ม /groups/[typeId] เลิกใช้ตาม
|
||||
// ลิงก์กลางจอ) ใช้เฉพาะหน้ารายการ /note — หน้ากลุ่ม /note/groups/[typeId] เลิกใช้ตาม
|
||||
// redesign 2026-09-24 (ไม่มี navbar เหลือ back link) — แทน SessionBar เดิมที่แยก
|
||||
// เป็นแถบของตัวเองกลางหน้า
|
||||
import Link from "next/link";
|
||||
|
|
@ -15,7 +15,7 @@ export default function AppNav({ userName }: { userName: string }) {
|
|||
{/* ชื่อแบรนด์เป็น ink ตามกฎ "น้ำเงินเฉพาะของ interactive" — tile JN น้ำเงินคือ
|
||||
แบรนด์มาร์ก (สืบต่อจากหน้าสรุป/login ดั้งเดิม) ทั้งแถบเป็นลิงก์กลับหน้ารายการ */}
|
||||
<Link
|
||||
href="/"
|
||||
href="/note"
|
||||
className="focus-ring flex min-w-0 items-center gap-2.5 rounded-md"
|
||||
>
|
||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary text-xs font-bold text-white">
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { useEffect } from "react";
|
|||
* หน้าพร้อม ?debug ไว้อ่าน console.warn ของ log [debug] บน iPad จริง (ไม่มี Mac
|
||||
* ต่อ Safari Web Inspector) — โหลดสคริปต์จาก CDN เท่านั้น ไม่แตะ dependencies
|
||||
* ของ project — เคลียร์ debug เสร็จ = ลบไฟล์นี้ + บรรทัด import/render ใน
|
||||
* app/annotate/[id]/page.tsx */
|
||||
* app/note/annotate/[id]/page.tsx */
|
||||
export default function DebugConsole() {
|
||||
useEffect(() => {
|
||||
if (!new URLSearchParams(window.location.search).has("debug")) return;
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ export default function EcourtLinkForm() {
|
|||
}
|
||||
setError(null);
|
||||
// router.push เติม basePath ให้เอง (invariant #12 — ต่างจาก fetch/plain href)
|
||||
router.push(`/annotate?${queryString}`);
|
||||
router.push(`/note/annotate?${queryString}`);
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
// ฟอร์ม login (mock e-Court) — เรียก server action login แล้ว router.replace(next ?? "/")
|
||||
// ฟอร์ม login (mock e-Court) — เรียก server action login แล้ว router.replace(next ?? "/note")
|
||||
// (pattern ตาม UploadFileForm: useTransition + error state + ข้อความ fallback ตอน transport fail)
|
||||
// next = path วนกลับหลัง login (N8 D5 — ผ่าน sanitizeNextPath จากหน้า login มาแล้ว)
|
||||
// Genesis (docs/DESIGN.md): การ์ด surface 12px radius ขอบ line ไม่มีเงาระดับ static /
|
||||
|
|
@ -36,7 +36,7 @@ export default function LoginForm({ next }: { next: string | null }) {
|
|||
try {
|
||||
const result = await login(usernameInput.value, passwordInput.value);
|
||||
if (result.ok) {
|
||||
router.replace(next ?? "/");
|
||||
router.replace(next ?? "/note");
|
||||
} else {
|
||||
setError(result.error);
|
||||
// คง username ไว้ (โอกาสพลาดอยู่ที่ password) — ล้างแล้วโฟกัสให้พิมพ์ทับได้ทันที
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
// N4 — ฟอร์มอัปโหลดไฟล์เอกสารแบบง่ายบนหน้ารายการ (PDF/รูป รับพร้อมกัน)
|
||||
// เสร็จแล้วรายการโผล่เองจาก revalidatePath("/") ของ action
|
||||
// เสร็จแล้วรายการโผล่เองจาก revalidatePath("/note") ของ action
|
||||
// Genesis: หน้าตาเป็นการ์ด surface 12px radius ขอบ line (ใต้หัวข้อของ section
|
||||
// "เอกสาร" — ไฟล์ที่อัปโหลดเข้ารายการนั้น) + ปุ่ม primary เดียวของ section
|
||||
import { useState, useTransition } from "react";
|
||||
|
|
|
|||
|
|
@ -103,7 +103,7 @@ function RowNote({ text }: { text: string }) {
|
|||
function OpenFileButton({ file }: { file: GroupFileSummary }) {
|
||||
return (
|
||||
<Link
|
||||
href={`/annotate/${file.id}`}
|
||||
href={`/note/annotate/${file.id}`}
|
||||
className="focus-ring inline-flex items-center gap-1.5 rounded-md border border-line bg-white px-3 py-1.5 text-sm font-medium text-ink transition hover:-translate-y-px hover:border-ink-faint"
|
||||
>
|
||||
{file.isImage ? (
|
||||
|
|
@ -178,8 +178,8 @@ export function GroupList({
|
|||
? `ไปที่หน้า ${item.pageIndex + 1}`
|
||||
: `ดู${annotationTypeLabel(item.dto)}ที่หน้า ${item.pageIndex + 1}`;
|
||||
const href = isBm
|
||||
? `/annotate/${item.dto.fileId}?page=${item.pageIndex + 1}`
|
||||
: `/annotate/${item.dto.fileId}?ann=${item.dto.id}`;
|
||||
? `/note/annotate/${item.dto.fileId}?page=${item.pageIndex + 1}`
|
||||
: `/note/annotate/${item.dto.fileId}?ann=${item.dto.id}`;
|
||||
return (
|
||||
// แถวเป็น passive surface — จุดคลิกเดียวคือปุ่ม "View" ท้ายแถว
|
||||
// (คำขอผู้ใช้ 2026-09-24: ไม่ต้องกดทั้งแถว) จึงไม่มี hover bg ที่แถว
|
||||
|
|
|
|||
|
|
@ -1664,7 +1664,7 @@ export default function PDFViewer({
|
|||
: userFilter;
|
||||
const res = await fetch(
|
||||
withBasePath(
|
||||
`/api/annotations/export?fileId=${file.id}${filterUser ? `&user=${encodeURIComponent(filterUser)}` : ""}`,
|
||||
`/api/note/annotations/export?fileId=${file.id}${filterUser ? `&user=${encodeURIComponent(filterUser)}` : ""}`,
|
||||
),
|
||||
);
|
||||
if (!res.ok) {
|
||||
|
|
@ -1722,10 +1722,10 @@ export default function PDFViewer({
|
|||
closeToolMenus();
|
||||
const objectUrls: string[] = [];
|
||||
// URL จริงที่ browser ใช้โหลดไฟล์ (fetch/img.src ตรง — ต้องเติม basePath เอง)
|
||||
// N8: รูปรีโมต (ลิงก์ e-Court) โหลดผ่าน /api/pdf ของเราแทนทางตรง — กัน CORS
|
||||
// N8: รูปรีโมต (ลิงก์ e-Court) โหลดผ่าน /api/note/pdf ของเราแทนทางตรง — กัน CORS
|
||||
// ของปลายทาง + ผ่าน allowlist/จำกัดขนาดเดียวกับ PDF (route คืน bytes รูป + content-type)
|
||||
const fileUrl = isHttpUrl(file.pdfUrl)
|
||||
? withBasePath(`/api/pdf?fileId=${file.id}`)
|
||||
? withBasePath(`/api/note/pdf?fileId=${file.id}`)
|
||||
: withBasePath(file.pdfUrl);
|
||||
(async () => {
|
||||
let pages: { src: string | null; width: number; height: number }[];
|
||||
|
|
@ -1767,11 +1767,11 @@ export default function PDFViewer({
|
|||
(async () => {
|
||||
const pdfjs = await import("pdfjs-dist");
|
||||
pdfjs.GlobalWorkerOptions.workerSrc = withBasePath("/pdf.worker.min.mjs");
|
||||
// โหลดผ่าน /api/pdf ที่ซ่อม ToUnicode จาก ActualText ของ PDF ไทยที่ cmap เพี้ยน
|
||||
// โหลดผ่าน /api/note/pdf ที่ซ่อม ToUnicode จาก ActualText ของ PDF ไทยที่ cmap เพี้ยน
|
||||
// (ข้อความถูกทั้ง select/copy/ค้นหา) — endpoint fail ก็ถอยไปไฟล์ดิบตามเดิม
|
||||
let data: ArrayBuffer;
|
||||
try {
|
||||
const res = await fetch(withBasePath(`/api/pdf?fileId=${file.id}`));
|
||||
const res = await fetch(withBasePath(`/api/note/pdf?fileId=${file.id}`));
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
data = await res.arrayBuffer();
|
||||
} catch {
|
||||
|
|
@ -3397,7 +3397,7 @@ export default function PDFViewer({
|
|||
{/* F15/D2: หน้าสรุปหมายเหตุทั้งไฟล์ — label แสดงตั้งแต่ sm ขึ้นไปเท่านั้น
|
||||
(จอแคบกว่านั้นแถว title ใส่ไม่พอ ให้เหลือ icon เดี่ยวเหมือนชื่อไฟล์ที่ hidden sm:block) */}
|
||||
<Link
|
||||
href={`/annotate/${file.id}/summary`}
|
||||
href={`/note/annotate/${file.id}/summary`}
|
||||
title="หน้าสรุปหมายเหตุ"
|
||||
aria-label="หน้าสรุปหมายเหตุ"
|
||||
className={`flex items-center gap-1 whitespace-nowrap rounded-md border border-line px-2 py-2 text-ink-soft hover:bg-ink/[0.05] ${FOCUS_RING_CLS}`}
|
||||
|
|
|
|||
|
|
@ -165,7 +165,7 @@ function PageSections({
|
|||
{pageSummaryLine(pageItems)}
|
||||
</p>
|
||||
<Link
|
||||
href={`/annotate/${fileId}?page=${pi + 1}`}
|
||||
href={`/note/annotate/${fileId}?page=${pi + 1}`}
|
||||
className="focus-ring inline-flex shrink-0 items-center gap-0.5 text-xs text-primary hover:underline"
|
||||
>
|
||||
เปิดหน้านี้
|
||||
|
|
@ -183,8 +183,8 @@ function PageSections({
|
|||
? `ไปที่หน้า ${item.pageIndex + 1}`
|
||||
: `ดู${annotationTypeLabel(item.dto)}ที่หน้า ${item.pageIndex + 1}`;
|
||||
const href = isBm
|
||||
? `/annotate/${fileId}?page=${item.pageIndex + 1}`
|
||||
: `/annotate/${fileId}?ann=${item.dto.id}`;
|
||||
? `/note/annotate/${fileId}?page=${item.pageIndex + 1}`
|
||||
: `/note/annotate/${fileId}?ann=${item.dto.id}`;
|
||||
return (
|
||||
// แถวเป็น passive surface — จุดคลิกเดียวคือปุ่ม "View" ท้ายแถว
|
||||
// (คำขอผู้ใช้ 2026-09-24 เหมือนหน้ากลุ่ม) จึงไม่มี hover bg ที่แถว
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@ WORKDIR /app
|
|||
COPY package.json package-lock.json ./
|
||||
# postinstall รัน scripts/patch-pdf-worker.mjs ตอน npm ci — คัดลอกเข้ามาก่อน ไม่งั้นหาไฟล์ไม่เจอ
|
||||
COPY scripts/patch-pdf-worker.mjs scripts/
|
||||
# postinstall เขียน public/pdf.worker.min.mjs — ต้องมีโฟลเดอร์ public/ ก่อน ไม่งั้น npm ci ล้ม
|
||||
RUN mkdir -p public && npm ci
|
||||
# postinstall เขียน public/note/pdf.worker.min.mjs (script สร้าง dir ปลายทางเอง)
|
||||
RUN npm ci
|
||||
|
||||
# ─── Stage 2: build Next.js (output: standalone) ───
|
||||
FROM node:24-alpine AS builder
|
||||
|
|
@ -43,10 +43,10 @@ COPY --from=deps --chown=nextjs:nodejs /app/node_modules/postgres ./node_modules
|
|||
# สร้างโฟลเดอร์ uploads ให้ user nextjs เป็นเจ้าของ — named volume ที่ compose mount ทับ
|
||||
# (เก็บไฟล์อัปโหลดข้าม redeploy) จะได้ ownership จาก image ตอน init:
|
||||
# ไม่มี dir นี้ใน image = volume ถูกสร้างโดย root แล้ว action เขียนไฟล์ไม่ได้ (EACCES)
|
||||
RUN mkdir -p public/uploads && chown nextjs:nodejs public/uploads
|
||||
RUN mkdir -p public/note/uploads && chown nextjs:nodejs public/note/uploads
|
||||
USER nextjs
|
||||
EXPOSE 3000
|
||||
# DATABASE_URL ฉีดตอนรันผ่าน env ของ container (อย่าฝังใน image)
|
||||
# migrate สร้าง database ถ้ายังไม่มี + apply ไฟล์ที่ยังไม่ได้ apply (idempotent)
|
||||
# seed-samples sync ตาราง files กับ public/samples/ ที่ bake อยู่ใน image (idempotent)
|
||||
# seed-samples sync ตาราง files กับ public/note/samples/ ที่ bake อยู่ใน image (idempotent)
|
||||
CMD ["sh", "-c", "node scripts/migrate.mjs && node scripts/seed-samples.mjs && exec node server.js"]
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@
|
|||
|
||||
| สิ่งที่ต้องมี | ค่า |
|
||||
|---|---|
|
||||
| URL | `https://<host>/docedit/note/api/external/documents` (แอป serve ใต้ basePath `/docedit/note`) |
|
||||
| URL | `https://<host>/docedit/api/note/external/documents` (API อยู่ใต้ `app/api/note/**` ของ basePath `/docedit` — ต่างจากหน้า UI ที่อยู่ใต้ `/docedit/note/…`) |
|
||||
| Method | `POST` (อ่านอย่างเดียว แต่ใช้ POST เพราะถามด้วย JSON body — GET ที่แนบ body ไม่เป็นมาตรฐาน) |
|
||||
| API Key | ขอจากผู้ดูแลระบบ Judge's Note — เก็บเป็น secret ฝั่ง backend e-Court อย่าใส่ใน log/URL |
|
||||
| Content-Type | `application/json` |
|
||||
|
|
@ -29,7 +29,7 @@
|
|||
ส่ง API Key ผ่าน header **`x-api-key`** ทุก request:
|
||||
|
||||
```http
|
||||
POST /docedit/note/api/external/documents HTTP/1.1
|
||||
POST /docedit/api/note/external/documents HTTP/1.1
|
||||
Host: <host>
|
||||
x-api-key: <API Key ที่ได้รับ>
|
||||
content-type: application/json
|
||||
|
|
@ -191,7 +191,7 @@ error ทุกสถานะเป็น JSON รูปเดียวกั
|
|||
```bash
|
||||
curl -X POST -H "x-api-key: $EXTERNAL_API_KEY" -H "content-type: application/json" \
|
||||
-d '{"fileId":"1234"}' \
|
||||
"http://localhost:3000/docedit/note/api/external/documents"
|
||||
"http://localhost:3000/docedit/api/note/external/documents"
|
||||
```
|
||||
|
||||
Response (ตัดย่อ):
|
||||
|
|
@ -256,7 +256,7 @@ Response (ตัดย่อ):
|
|||
```bash
|
||||
curl -X POST -H "x-api-key: $EXTERNAL_API_KEY" -H "content-type: application/json" \
|
||||
-d '{"fileId":"1234","filter":{"type":"bookmarks","userId":"u_1234"}}' \
|
||||
"http://localhost:3000/docedit/note/api/external/documents"
|
||||
"http://localhost:3000/docedit/api/note/external/documents"
|
||||
```
|
||||
|
||||
(`userId` เป็นค่าของระบบนี้ — เก็บ collect จาก response ก่อนหน้า หรือดึงมาทั้งหมดแล้วกรองฝั่งผู้เรียกเองก็ได้)
|
||||
|
|
@ -266,7 +266,7 @@ curl -X POST -H "x-api-key: $EXTERNAL_API_KEY" -H "content-type: application/jso
|
|||
```bash
|
||||
curl -X POST -H "x-api-key: $EXTERNAL_API_KEY" -H "content-type: application/json" \
|
||||
-d '{"typeId":"CASE-2569-0001","filter":{"annotationTypes":["COMMENT","HIGHLIGHT"]}}' \
|
||||
"http://localhost:3000/docedit/note/api/external/documents"
|
||||
"http://localhost:3000/docedit/api/note/external/documents"
|
||||
```
|
||||
|
||||
ได้ `documents` หลายชิ้นเรียงตามวันที่เปิดเข้าระบบ — ไฟล์ที่มาโดยไม่ผ่าน fileId (อัปโหลดในระบบนี้) ก็รวมอยู่ในกลุ่ม โดย `externalFileId: null`
|
||||
|
|
@ -276,7 +276,7 @@ curl -X POST -H "x-api-key: $EXTERNAL_API_KEY" -H "content-type: application/jso
|
|||
```bash
|
||||
curl -X POST -H "x-api-key: $EXTERNAL_API_KEY" -H "content-type: application/json" \
|
||||
-d '{"fileId":"1234","typeId":"CASE-2569-0001"}' \
|
||||
"http://localhost:3000/docedit/note/api/external/documents"
|
||||
"http://localhost:3000/docedit/api/note/external/documents"
|
||||
```
|
||||
|
||||
ได้เฉพาะเอกสารเดียวที่ตรงทั้งคู่ — ไฟล์นี้ไม่เคยเปิดหรืออยู่คนละกลุ่มกับ typeId ที่ระบุ = 404
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ 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=<id> — reconnect อัตโนมัติเมื่อขาด (ห้ามทำ retry เอง)
|
||||
// - เปิด EventSource ไป /api/note/annotations/stream?fileId=<id> — reconnect อัตโนมัติเมื่อขาด (ห้ามทำ retry เอง)
|
||||
// - event เป็น DTO (camelCase + ISO string) จาก server ตรง ๆ — ไม่มีการแปลง
|
||||
// - dedupe ด้วย id เท่านั้น (คู่กับ useOptimistic ใน PDFViewer — PLAN.md §4)
|
||||
|
||||
|
|
@ -20,7 +20,7 @@ export function useAnnotationRealtime(
|
|||
const [replies, setReplies] = useState(initialReplies);
|
||||
|
||||
useEffect(() => {
|
||||
const es = new EventSource(withBasePath(`/api/annotations/stream?fileId=${fileId}`));
|
||||
const es = new EventSource(withBasePath(`/api/note/annotations/stream?fileId=${fileId}`));
|
||||
|
||||
es.onmessage = (e: MessageEvent<string>) => {
|
||||
let ev: AnnotateEvent;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import type { AnnotationDTO, BookmarkDTO, ReplyDTO } from "@/db/schema";
|
||||
|
||||
// pub/sub in-process (PLAN.md §4): Server Actions publish หลังเขียน DB สำเร็จ
|
||||
// → SSE route (app/api/annotations/stream) subscribe แล้วยิง event ให้ client ที่เปิดไฟล์เดียวกัน
|
||||
// → SSE route (app/api/note/annotations/stream) subscribe แล้วยิง event ให้ client ที่เปิดไฟล์เดียวกัน
|
||||
// ⚠ in-memory ของ process เดียว (dev / next start ธรรมดา) — ถ้าวันหนึ่งรันหลาย instance
|
||||
// ต้องเพิ่มตัวกลาง (Redis pub/sub หรือ Postgres LISTEN/NOTIFY) ก่อน ห้ามแอบ sync ผ่าน DB polling
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
// N10 (PLAN-API.md D1) — auth ของ /api/external/* (ผู้เรียก: backend e-Court,
|
||||
// N10 (PLAN-API.md D1) — auth ของ /api/note/external/* (ผู้เรียก: backend e-Court,
|
||||
// server-to-server ไม่มี browser)
|
||||
// - API Key เดียวใน env EXTERNAL_API_KEY ส่งผ่าน header "x-api-key"
|
||||
// - เทียบแบบ timing-safe: hash SHA-256 ทั้งสองค่าแล้ว timingSafeEqual
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
// N10 (PLAN-API.md D2) — ส่วนกลางของ POST /api/external/documents
|
||||
// N10 (PLAN-API.md D2) — ส่วนกลางของ POST /api/note/external/documents
|
||||
// (ถามด้วย fileId และ/หรือ typeId — ส่งทั้งคู่ = AND เหลือเอกสารเดียว, คำขอผู้ใช้ 2026-09-25)
|
||||
// ใช้ loadDocumentBundles (bulk) ร่วมทุกทางให้รูปร่าง bundle ตรงกันเป๊ะ
|
||||
// read-only ทั้งหมด: ไม่เขียน DB ไม่ publish SSE ไม่ revalidatePath
|
||||
|
|
@ -81,7 +81,7 @@ function externalDocumentDTO(row: FileRow): ExternalDocumentDTO {
|
|||
}
|
||||
|
||||
/**
|
||||
* validate JSON body ของ POST /api/external/documents (PLAN-API.md D2) — strict กัน
|
||||
* validate JSON body ของ POST /api/note/external/documents (PLAN-API.md D2) — strict กัน
|
||||
* typo เงียบ ๆ: รับเฉพาะ fileId/typeId/filter · fileId/typeId ต้องเป็น string
|
||||
* (trim แล้วว่าง = ถือว่าไม่ได้ส่ง field นั้น) · ต้องมี fileId หรือ typeId
|
||||
* อย่างน้อยหนึ่งอย่าง — ส่งทั้งคู่ได้ = กรองเฉพาะเอกสารที่ตรงทั้งสองเงื่อนไข
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
// N8 (PLAN-ECOURT.md D3) — ดึงไบต์ไฟล์รีโมตจากโฮสต์ที่ allowlist ไว้ (server-only)
|
||||
// ใช้ 2 จุด: /api/pdf (viewer) และ /api/annotations/export — กัน CORS ฝั่ง browser
|
||||
// ใช้ 2 จุด: /api/note/pdf (viewer) และ /api/note/annotations/export — กัน CORS ฝั่ง browser
|
||||
// และคุม SSRF ให้เหลือจุดเดียว (validate ซ้ำอีกชั้น — defense in depth)
|
||||
import { isHttpUrl, validateExternalFileUrl } from "@/utils/external-file";
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,9 @@ import path from "node:path";
|
|||
export class PdfPathError extends Error {}
|
||||
|
||||
export function resolvePublicPdfPath(pdfUrl: string): string {
|
||||
const publicDir = path.resolve(process.cwd(), "public");
|
||||
// statics ของแอปอยู่ใต้ public/note/** (URL /docedit/note/… ของ Next เสิร์ฟจากที่นี่ —
|
||||
// pdf_url ใน DB เป็น logical path ไม่รวม /note เติมตรงนี้ฝั่ง disk คู่กับ withBasePath ฝั่ง browser)
|
||||
const publicDir = path.resolve(process.cwd(), "public", "note");
|
||||
// ตัด root นำหน้าออกก่อน — ไม่งั้น path.resolve บน Windows ถือ "/samples/.." เป็น absolute
|
||||
// ของไดรว์ (D:\samples\..) แล้วหลุดนอก publicDir
|
||||
const pdfPath = path.resolve(publicDir, pdfUrl.replace(/^[/\\]+/, ""));
|
||||
|
|
|
|||
|
|
@ -9,10 +9,11 @@ export function middleware(req: NextRequest) {
|
|||
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)
|
||||
// — ไม่ใส่กรณี next เป็น "/" ไปเฉย ๆ ไม่ให้ /note/login มี query เกินจำเป็น
|
||||
// (path ตรงนี้ตัด basePath แล้ว จึงขึ้นต้น /note/… เสมอ / NextURL ใส่ basePath คืนตอน
|
||||
// redirect เอง — invariant #12)
|
||||
const next = url.pathname + url.search;
|
||||
url.pathname = "/login";
|
||||
url.pathname = "/note/login";
|
||||
url.search = "";
|
||||
if (next !== "/") url.searchParams.set("next", next);
|
||||
return NextResponse.redirect(url);
|
||||
|
|
@ -20,10 +21,12 @@ export function middleware(req: NextRequest) {
|
|||
return NextResponse.next();
|
||||
}
|
||||
|
||||
// ยกเว้น: หน้า login เอง, API/SSE/export, ไฟล์อัปโหลด, Next internal,
|
||||
// และ static ใน public/ (pdf.worker, samples, fonts, favicon)
|
||||
// ยกเว้น: หน้า login เอง, API/SSE/export (อยู่ใต้ api/note/**), ไฟล์อัปโหลด, Next internal,
|
||||
// และ static ใน public/note/ (pdf.worker, samples) + public/ ราก (fonts, favicon)
|
||||
// — matcher ทำงานบน path หลังตัด basePath (/docedit): หน้า/ไฟล์ของแอปขึ้นต้น note/
|
||||
// ส่วน API ขึ้นต้น api/note/ (URL /docedit/api/note/…)
|
||||
export const config = {
|
||||
matcher: [
|
||||
"/((?!login|api|uploads|_next|samples|fonts|pdf\\.worker\\.min\\.mjs|favicon\\.ico).*)",
|
||||
"/((?!note/login|api/note|note/uploads|note/samples|note/pdf\\.worker\\.min\\.mjs|_next|fonts|favicon\\.ico).*)",
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,14 +1,24 @@
|
|||
const basePath = "/docedit/note";
|
||||
const basePath = "/docedit";
|
||||
// prefix ระดับแอปของ judes-note ใน route tree (app/note/**) + statics (public/note/**)
|
||||
// — basePath /docedit เป็นของกลางแชร์กับแอปพี่น้อง (เช่น wordaddin ในอนาคต)
|
||||
// URL ปลายทางจึงยังเป็น /docedit/note/… เหมือนเดิมทุกจุด
|
||||
const appPath = "/note";
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
// สำหรับ Docker (docker/web.Dockerfile) — build แล้วได้ .next/standalone รันด้วย node server.js
|
||||
output: "standalone",
|
||||
// serve ใต้ /docedit/note — <Link>/next/navigation เติมให้เอง แต่ fetch/EventSource/
|
||||
// plain <a href>/workerSrc ต้องผ่าน withBasePath() (utils/base-path.ts)
|
||||
// serve ใต้ /docedit + route ทั้งหมดอยู่ใต้ app/note/** — <Link>/next/navigation เติม basePath
|
||||
// ให้เอง แต่ต้องเขียน path รวม /note (เช่น href="/note/login") ส่วน fetch/EventSource/
|
||||
// plain <a href>/workerSrc ต้องผ่าน withBasePath() (utils/base-path.ts — เติม base + /note)
|
||||
basePath,
|
||||
// expose ให้โค้ดใช้ (inline ตอน build) — ค่าจริงประกาศที่เดียวที่ไฟล์นี้
|
||||
env: { NEXT_PUBLIC_BASE_PATH: basePath },
|
||||
env: { NEXT_PUBLIC_BASE_PATH: basePath, NEXT_PUBLIC_APP_PATH: appPath },
|
||||
// เข้า /docedit/ ตรง ๆ (ไม่มี route ที่ root) → โผล่ที่หน้ารายการของ /note
|
||||
// (Next เติม basePath ให้ source/destination เอง + query ส่งต่ออัตโนมัติ — รองรับ /docedit/?fileUrl=…)
|
||||
async redirects() {
|
||||
return [{ source: "/", destination: appPath, permanent: false }];
|
||||
},
|
||||
// N4 อัปโหลดไฟล์ผ่าน Server Action — default 1MB ไม่พอสำหรับ PDF/สแกนศาล (ต้องตรงกับ MAX_UPLOAD_BYTES ใน app/actions/files.ts)
|
||||
experimental: {
|
||||
serverActions: {
|
||||
|
|
|
|||
|
|
@ -17,10 +17,12 @@
|
|||
// ทำบนไฟล์ minified ด้วย pattern จำเพาะ (ชื่อตัวแปร minify อาจเปลี่ยนตามเวอร์ชัน):
|
||||
// เจอ ≠ 1 จุด = โครงสร้างโค้ดเปลี่ยนหลังอัปเกรด pdfjs-dist → fail ดัง ๆ ทันที
|
||||
// ไม่ปล่อยให้ patch หลุดเงียบ ๆ แล้ว fake space กลับมาโดยไม่มีใครรู้ตัว
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
|
||||
const SRC = "node_modules/pdfjs-dist/build/pdf.worker.min.mjs";
|
||||
const DEST = "public/pdf.worker.min.mjs";
|
||||
// statics ของแอปอยู่ใต้ public/note/** (URL /docedit/note/… — basePath /docedit + route prefix /note)
|
||||
const DEST = "public/note/pdf.worker.min.mjs";
|
||||
|
||||
const code = readFileSync(SRC, "utf8");
|
||||
|
||||
|
|
@ -40,5 +42,7 @@ if (found.length !== 1) {
|
|||
process.exit(1);
|
||||
}
|
||||
|
||||
// Dockerfile mkdir แค่ public/ เปล่า ๆ — script ต้องสร้าง dir ปลายทางเอง
|
||||
mkdirSync(dirname(DEST), { recursive: true });
|
||||
writeFileSync(DEST, code.replace(PATCH_RE, "$1;"));
|
||||
console.log("[patch-pdf-worker] คัดลอก worker + patch fake-space ไทยเรียบร้อย");
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
// Sync ตาราง files กับไฟล์จริงใน public/samples/ (idempotent — รันซ้ำได้ทุกครั้ง)
|
||||
// Sync ตาราง files กับไฟล์จริงใน public/note/samples/ (idempotent — รันซ้ำได้ทุกครั้ง)
|
||||
// - INSERT ไฟล์ .pdf/.jpg/.jpeg/.png/.tif/.tiff ที่ยังไม่มี row (pdf_url = /samples/<ชื่อไฟล์> — N3)
|
||||
// (สำเนารายการ extension นี้ต้อง sync กับ SAMPLE_FILE_EXTENSIONS ใน utils/file-kind.ts)
|
||||
// - DELETE row ที่ชี้ /samples/* แต่ไฟล์หายจาก disk (CASCADE ลบ annotation/bookmark ตาม)
|
||||
|
|
@ -7,7 +7,7 @@ import fs from "node:fs";
|
|||
import path from "node:path";
|
||||
import postgres from "postgres";
|
||||
|
||||
const SAMPLES_DIR = path.join(process.cwd(), "public", "samples");
|
||||
const SAMPLES_DIR = path.join(process.cwd(), "public", "note", "samples");
|
||||
|
||||
// ต้องตรงกับ SAMPLE_FILE_EXTENSIONS ใน utils/file-kind.ts (script .mjs import TS ไม่ได้)
|
||||
const SAMPLE_FILE_EXTENSIONS = [".pdf", ".jpg", ".jpeg", ".png", ".tif", ".tiff"];
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { afterEach, describe, expect, it } from "vitest";
|
|||
|
||||
import { requireExternalApiKey } from "@/lib/external-api-auth";
|
||||
|
||||
// N10 (PLAN-API.md D1) — auth ของ /api/external/*: fail closed เมื่อ env ว่าง +
|
||||
// N10 (PLAN-API.md D1) — auth ของ /api/note/external/*: fail closed เมื่อ env ว่าง +
|
||||
// ข้อความ 401 อันเดียวกันทั้งไม่ใส่ key และ key ผิด (กัน enumerate) — ไฟล์นี้
|
||||
// ตั้งใจไม่แตะ module ใดที่ลาก @/db มาด้วย (db/index.ts throw ตอน import เมื่อ
|
||||
// ไม่มี DATABASE_URL — vitest ไม่โหลด .env)
|
||||
|
|
@ -18,7 +18,7 @@ function restoreKey() {
|
|||
function reqWithKey(key?: string): Request {
|
||||
const headers = new Headers();
|
||||
if (key !== undefined) headers.set("x-api-key", key);
|
||||
return new Request("http://localhost/docedit/note/api/external/documents", { headers });
|
||||
return new Request("http://localhost/docedit/api/note/external/documents", { headers });
|
||||
}
|
||||
|
||||
async function bodyOf(res: Response): Promise<{ error: string }> {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import { collectActualTextRuns, fixPdfActualText } from "@/lib/pdf-actual-text";
|
|||
// ไฟล์คำสั่งศาลปกครองจริง — cmap ของฟอนต์เพี้ยน (สระ/วรรณยุกต์กลายเป็น control char)
|
||||
// แต่ฝัง /ActualText ถูกไว้ใน marked content
|
||||
const SAMPLE = readFileSync(
|
||||
join(process.cwd(), "public", "samples", "court_command_11_123 1.pdf"),
|
||||
join(process.cwd(), "public", "note", "samples", "court_command_11_123 1.pdf"),
|
||||
);
|
||||
|
||||
describe("collectActualTextRuns — เรียนรู้ glyph จาก ActualText ของไฟล์จริง", () => {
|
||||
|
|
|
|||
|
|
@ -11,14 +11,15 @@ import { describe, expect, it } from "vitest";
|
|||
// ให้อัปเดตทุก glyph แทน
|
||||
|
||||
const ROOT = process.cwd();
|
||||
const WORKER = join(ROOT, "public", "pdf.worker.min.mjs");
|
||||
// statics ของแอปอยู่ใต้ public/note/** (URL /docedit/note/… — basePath /docedit + prefix /note)
|
||||
const WORKER = join(ROOT, "public", "note", "pdf.worker.min.mjs");
|
||||
// ไฟล์ Word→PDF ที่ justify ด้วย letter-spacing ทุก glyph (มี charSpacing ~0.05×fontSize)
|
||||
// — เงื่อนไขที่ทำให้ heuristic ของ pdf.js แทรก fake space กลางคำไทย
|
||||
// (อยู่ใน tests/fixtures/ ไม่ใช่ public/samples/ — ไม่ให้ seed-samples สร้าง
|
||||
// (อยู่ใน tests/fixtures/ ไม่ใช่ public/note/samples/ — ไม่ให้ seed-samples สร้าง
|
||||
// รายการเอกสาร "ทดสอบ" บนหน้ารายการของทุก deploy)
|
||||
const SAMPLE = join(ROOT, "tests", "fixtures", "thai_letterspacing.pdf");
|
||||
|
||||
describe("public/pdf.worker.min.mjs — ผ่าน patch fake-space ไทย", () => {
|
||||
describe("public/note/pdf.worker.min.mjs — ผ่าน patch fake-space ไทย", () => {
|
||||
const code = readFileSync(WORKER, "utf8");
|
||||
|
||||
it("อัปเดต prevTransform ทุก glyph (รูปแบบก่อนแพตช์ C&&(h.prevTransform=…) หายไป)", () => {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,15 @@
|
|||
// basePath (/docedit/note) — ค่าจาก next.config.mjs (inline ตอน build ผ่าน env)
|
||||
// basePath (/docedit) + app path (/note) — ค่าจาก next.config.mjs (inline ตอน build ผ่าน env)
|
||||
// ใช้กับ URL ฝั่ง browser ที่ Next ไม่เติม basePath ให้เอง: fetch/EventSource/
|
||||
// plain <a href>/workerSrc/img src — ต่างจาก <Link>/router/redirect ของ next
|
||||
// ที่เติมให้อัตโนมัติ (pdfUrl ใน DB เก็บ path ไม่รวม basePath เติมตอนใช้เท่านั้น)
|
||||
// plain <a href>/workerSrc/img src — ต่างจาก <Link>/router/redirect ของ next ที่เติม
|
||||
// basePath ให้อัตโนมัติ (path ที่เขียนถึง route จึงต้องมี /note นำหน้าเอง เช่น "/note/login")
|
||||
// ยกเว้น API route อยู่ใต้ app/api/note/** (URL /docedit/api/note/…) — path ที่ขึ้นต้น
|
||||
// "/api/" เติมแค่ basePath (caller เขียน "/api/note/…" เอง) ไม่มี /note ซ้อน
|
||||
// (pdfUrl ใน DB เก็บ logical path ไม่รวม prefix เติมตอนใช้เท่านั้น)
|
||||
export function withBasePath(path: string): string {
|
||||
// absolute http(s) ผ่านไปเฉย ๆ (N8: pdf_url เก็บ URL รีโมตได้ — ห้าม prefix ทับ)
|
||||
if (/^https?:\/\//i.test(path)) return path;
|
||||
if (path.startsWith("/api/")) {
|
||||
return `${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}${path}`;
|
||||
}
|
||||
return `${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}${process.env.NEXT_PUBLIC_APP_PATH ?? ""}${path}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ export function fileKindFromUrl(url: string): FileKind {
|
|||
return "pdf";
|
||||
}
|
||||
|
||||
/** whitelist extension ที่ seed-samples sync จาก public/samples/ (seed .mjs มีสำเนา — sync กัน) */
|
||||
/** whitelist extension ที่ seed-samples sync จาก public/note/samples/ (seed .mjs มีสำเนา — sync กัน) */
|
||||
export const SAMPLE_FILE_EXTENSIONS: readonly string[] = [
|
||||
".pdf",
|
||||
".jpg",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue