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

+ {userName} +

+ +
+
+
+ ); +} diff --git a/components/DotGrid.tsx b/components/DotGrid.tsx new file mode 100644 index 0000000..3e40ddf --- /dev/null +++ b/components/DotGrid.tsx @@ -0,0 +1,38 @@ +"use client"; + +// Genesis (docs/DESIGN.md) — กริดจุดอันตรกิริยา: ลวดลายตกแต่งชิ้นเดียวที่ design +// system อนุญาต ("the interactive dot grid is the only decorative element") ใช้เป็น +// พื้นหลังหน้า login — จุดเทาถี่ 24px + ไฮไลต์น้ำเงินจางตามตำแหน่ง pointer +// ⚠️ ต้องเป็น pointer-events-none เสมอ: ตัว layer เป็น absolute inset-0 ครอบพื้นที่ +// ทั้งหน้า ส่วนการ์ด login เป็น static element — ตัว positioned จะชนะ hit-test ทับ +// การ์ดจนคลิก/focus ช่องกรอกไม่ได้ (bug report 2026-09-23) จึงฟัง pointermove ที่ +// window แทนของตัวเอง — ไฮไลต์ยังตามเมาส์ได้แม้อยู่เหนือการ์ด + อัปเดตผ่าน CSS +// variable บน style ตรง ๆ ไม่ setState กัน re-render ทุก mousemove +// mask ไล่จางขอบให้กลืนกับพื้น canvas แทนการตัดเส้นชัด +import { useEffect, useRef } from "react"; + +export default function DotGrid() { + const ref = useRef(null); + + useEffect(() => { + const onPointerMove = (e: PointerEvent) => { + const el = ref.current; + if (!el) return; + const rect = el.getBoundingClientRect(); + el.style.setProperty("--dot-mx", `${e.clientX - rect.left}px`); + el.style.setProperty("--dot-my", `${e.clientY - rect.top}px`); + }; + window.addEventListener("pointermove", onPointerMove, { passive: true }); + return () => window.removeEventListener("pointermove", onPointerMove); + }, []); + + return ( +
+
+
+ ); +} diff --git a/components/EcourtLinkForm.tsx b/components/EcourtLinkForm.tsx index 53463e2..210985b 100644 --- a/components/EcourtLinkForm.tsx +++ b/components/EcourtLinkForm.tsx @@ -6,14 +6,15 @@ // ลิงก์จริงจาก e-Court (รับทั้ง path ในระบบเช่น /samples/… และ URL ของโฮสต์ที่อนุญาต // — ไม่มี scheme นำหน้าระบบเติม https:// ให้) // แถบลิงก์เต็มข้างล่างแสดง URL จริงที่ปุ่มจะพาไป (absolute — copy ไปเปิด/ส่งต่อได้) ตามที่พิมพ์สด ๆ +// Genesis: การ์ด surface 12px radius ขอบ line ใน section "เปิดเอกสารจากลิงก์ e-Court" +// (แยกจากส่วนเอกสาร) / ปุ่มเป็น secondary ขอบฟ้า / input ผ่าน .genesis-input import { useRouter } from "next/navigation"; import { useEffect, useState } from "react"; -import { Link2 } from "lucide-react"; import { withBasePath } from "@/utils/base-path"; const inputClass = - "rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-700 placeholder:text-slate-400 focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20"; + "genesis-input mt-1 w-full text-base transition aria-invalid:border-error"; export default function EcourtLinkForm() { const router = useRouter(); @@ -50,20 +51,12 @@ export default function EcourtLinkForm() { }; return ( -
-
- -

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

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

+

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

-

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

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

- {error &&

{error}

} + {error &&

{error}

} ); } diff --git a/components/LoginForm.tsx b/components/LoginForm.tsx index 199b595..34b9db3 100644 --- a/components/LoginForm.tsx +++ b/components/LoginForm.tsx @@ -3,6 +3,8 @@ // ฟอร์ม login (mock e-Court) — เรียก server action login แล้ว router.replace(next ?? "/") // (pattern ตาม UploadFileForm: useTransition + error state + ข้อความ fallback ตอน transport fail) // next = path วนกลับหลัง login (N8 D5 — ผ่าน sanitizeNextPath จากหน้า login มาแล้ว) +// Genesis (docs/DESIGN.md): การ์ด surface 12px radius ขอบ line ไม่มีเงาระดับ static / +// input ผ่าน .genesis-input / ปุ่ม primary hover ขึ้น 1px + glow ฟ้า (shadow-btn-glow) import { useRef, useState, useTransition } from "react"; import { useRouter } from "next/navigation"; @@ -11,11 +13,12 @@ import Select from "@/components/Select"; import { MOCK_USERS } from "@/utils/user"; // text input ของแอป — text-base (16px) เสมอ: กัน iOS auto-zoom ตอน focus บน iPad +// (ทับ text-sm ของ .genesis-input — utility อยู่หลัง @layer components ชนะเสมอ) +// error ตาม spec Inputs: ขอบเปลี่ยนเป็นแดง (ring ยังเป็นฟ้าตาม focus ring หลัก) const INPUT_CLASS = - "mt-1 block w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-base text-slate-900 placeholder:text-slate-400 transition focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/30 disabled:cursor-not-allowed disabled:bg-slate-50 aria-invalid:border-red-600 aria-invalid:focus:ring-red-600/30"; + "genesis-input mt-1 block w-full text-base transition aria-invalid:border-error"; // dropdown ผู้ใช้ — เหมือน INPUT_CLASS แต่ไม่มี mt-1 (Select ครอบ div.relative: มาร์จินที่ wrapper ข้างนอกแทน) -const SELECT_CLASS = - "block w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-base text-slate-900 transition focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/30 disabled:cursor-not-allowed disabled:bg-slate-50"; +const SELECT_CLASS = "genesis-input block w-full text-base transition"; export default function LoginForm({ next }: { next: string | null }) { const router = useRouter(); @@ -48,23 +51,29 @@ export default function LoginForm({ next }: { next: string | null }) { }; return ( -
- {/* brand lockup เดียวกับ header หน้ารายการ (ลดขนาดตัวอักษรให้พอการ์ดแคบ) */} +
+ {/* brand lockup เดียวกับ nav หน้ารายการ — tile JN น้ำเงินคือแบรนด์มาร์กของระบบ + (หน้าสรุปแถบน้ำเงิน 2026-09-17 สืบต่อจาก tile นี้) ชื่อแบรนด์เป็น ink ตามกฎ + "blue เฉพาะของ interactive" ของ Genesis */}
-
+
JN
-
-

Judge's Note

-

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

+
+

+ Judge's Note +

+

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

-

เข้าสู่ระบบ

+

เข้าสู่ระบบ

-
+
-
-