diff --git a/.claude/rules/data-layer.md b/.claude/rules/data-layer.md index a8d268d..6bd6e41 100644 --- a/.claude/rules/data-layer.md +++ b/.claude/rules/data-layer.md @@ -20,5 +20,5 @@ paths: - ตาราง `annotation_versions` (F19/A7): `annotation_id` เป็น uuid **ไม่ FK** (D8 — snapshot ตอน delete ต้องรอดจาก CASCADE ของแม่) + `file_id` FK CASCADE + `page_index` (restore ต้องรู้หน้า) + `kind` text `'update' | 'delete'` + index `(file_id)`; `updateAnnotation`/`deleteAnnotation` snapshot row เดิม **ก่อนเขียน/ลบ ใน transaction เดียวกัน**; `restoreVersion(versionId, userId)` — **เจ้าของเท่านั้น (D9)**: guard `userId` ทั้ง row ปัจจุบัน (ต้นทางยังอยู่) และ snapshot (ต้นทางถูกลบ) / ต้นทางยังอยู่ = **เขียนกลับ row เดิม** (snapshot ปัจจุบันก่อน) + publish update event / ต้นทางถูกลบ = สร้างใหม่ id ใหม่ + publish create event (ห้ามสร้างสำเนาใหม่ทุกกรณี — เคยทำให้เกิดรายการซ้ำบนหน้า) - ห้ามเพิ่มตาราง/column ที่ไม่มีในแผน (ดู out of scope §9) โดยไม่อัปเดต `docs/PLAN.md` ก่อน - `files.pdf_url` เก็บ path **รูปภาพ** (jpg/jpeg/png/tif/tiff) ใน `public/` ได้เหมือน PDF (N3) — **ไม่มี column kind**: ชนิดไฟล์ derive จาก extension ผ่าน `utils/file-kind.ts` เท่านั้น (จะเพิ่ม column ต้องแก้แผนก่อน); TIFF หลายเฟรม = `pageIndex` ต่อเฟรม (1 เฟรม = 1 หน้า) -- Server Action `uploadFile` (`app/actions/files.ts`, N4): validate extension (whitelist `SAMPLE_FILE_EXTENSIONS`) + ≤ 100MB ก่อนเขียน — บันทึกเป็นไฟล์**ใหม่** `public/uploads/.` เท่านั้น (ชื่อจาก randomUUID — ห้ามใช้ชื่อผู้ใช้ตกแต่ง path), insert row แล้ว `revalidatePath("/")` — ไม่ publish SSE event (รายการไฟล์ไม่ได้ sync ผ่าน SSE); seed-samples จัดการเฉพาะ `/samples/%` ไม่แตะ `/uploads/` -- `files.type_id` (N9 — รหัสกลุ่มเอกสารจากลิงก์ e-Court `?typeId=`): **เขียนที่ `registerExternalFile` ของ N8 เท่านั้น** (insert ครั้งแรก + update เมื่อลิงก์รอบใหม่ส่ง `typeId` มา — ลิงก์ไม่ส่ง = คงเดิม ห้ามเคลียร์) — N9 อ่านอย่างเดียว (หน้า `/` จัด section + `/groups/[typeId]`) ไม่มี Server Action/SSE ใหม่; กลุ่ม = ค่า distinct ของ `type_id` ณ ตอน query — ไม่มีตารางกลุ่ม +- Server Action `uploadFile` (`app/actions/files.ts`, N4): validate extension (whitelist `SAMPLE_FILE_EXTENSIONS`) + ≤ 100MB ก่อนเขียน — บันทึกเป็นไฟล์**ใหม่** `public/uploads/.` เท่านั้น (ชื่อจาก randomUUID — ห้ามใช้ชื่อผู้ใช้ตกแต่ง path), insert row (รวม `type_id` จากช่องกลุ่มทางเลือกของฟอร์ม — 2026-09-23) แล้ว `revalidatePath("/")` — ไม่ publish SSE event (รายการไฟล์ไม่ได้ sync ผ่าน SSE); seed-samples จัดการเฉพาะ `/samples/%` ไม่แตะ `/uploads/` +- `files.type_id` (N9 — รหัสกลุ่มเอกสาร จากลิงก์ e-Court `?typeId=` หรือช่อง "กลุ่ม typeId" ของฟอร์มอัปโหลด N4): **เขียนสองที่** — (1) `registerExternalFile` ของ N8 (insert ครั้งแรก + update เมื่อลิงก์รอบใหม่ส่ง `typeId` มา — ลิงก์ไม่ส่ง = คงเดิม ห้ามเคลียร์) (2) `uploadFile` ของ N4 ช่องทางเลือกในฟอร์มอัปโหลด — insert ตอนสร้าง row ใหม่เท่านั้น ไม่มีหน้าทางแก้กลุ่มของไฟล์เดิม; normalize ผ่าน `normDocumentGroup` ของ `utils/external-file.ts` ที่เดียว (trim → ว่าง = NULL, cap 200 — responsive audit 2026-09-24 รวมจากเดิมที่เป็นสองสำเนา mirroring กัน) — N9 อ่านอย่างเดียว (หน้า `/` จัด section + `/groups/[typeId]`) ไม่มี Server Action/SSE ใหม่; กลุ่ม = ค่า distinct ของ `type_id` ณ ตอน query — ไม่มีตารางกลุ่ม diff --git a/.claude/rules/pdf-viewer.md b/.claude/rules/pdf-viewer.md index 7913bd7..d5cc7fb 100644 --- a/.claude/rules/pdf-viewer.md +++ b/.claude/rules/pdf-viewer.md @@ -17,7 +17,7 @@ paths: - คีย์ลัด H/D/C/B ต้อง `e.preventDefault()` เสมอ — B เปิด popup bookmark ที่ input `autoFocus` ทันทีใน keydown เดียวกัน ถ้าไม่กัน browser พิมพ์ "b" ตามลงในช่องชื่อ - `DEL`/`Backspace` ลบ annotation ที่เลือกอยู่ (`selectedId`) — เฉพาะรายการของตัวเอง (เงื่อนไขเดียวกับปุ่มลบในแผง selection), ข้ามเมื่อ focus อยู่ใน form (`inForm` เดียวกับคีย์ลัดอื่น — พิมพ์ใน composer/textbox ต้องไม่โดนลบ) - Page Virtualization: `IntersectionObserver` render เฉพาะหน้าใน viewport ± 1 หน้า; หน้าพ้นจอ = placeholder `div` ขนาดจริง (จาก `pdfPage.getViewport()` ทุกหน้า ครั้งเดียวตอนโหลด) + unmount canvas/TextLayer/InkLayer คืน memory — annotation ของหน้าที่ไม่ render ยังอยู่ใน state -- pdfjs: `dynamic import("pdfjs-dist")` ภายใน `useEffect` เท่านั้น; `GlobalWorkerOptions.workerSrc = withBasePath("/pdf.worker.min.mjs")` (ไฟล์คัดลอกไว้ที่ `public/` ด้วย postinstall) — ห้าม `new URL(..., import.meta.url)` +- pdfjs: `dynamic import("pdfjs-dist")` ภายใน `useEffect` เท่านั้น; `GlobalWorkerOptions.workerSrc = withBasePath("/pdf.worker.min.mjs")` — ไฟล์ที่ `public/` **ไม่ใช่สำเนาตรง ๆ** ของ node_modules: postinstall (`scripts/patch-pdf-worker.mjs`) คัดลอกแล้ว patch heuristic fake-space ไทย = ถอด `if (scaledDim)` รอบ `textChunk.prevTransform = getCurrentTextTransform()` ให้อัปเดตทุก glyph (เดิมข้าม glyph กว้าง 0 อย่างสระ/วรรณยุกต์ — charSpacing ของ glyph เหล่านั้นสะสมเป็น gap จน `addFakeSpaces` แทรก " " กลางคำ "ฉะนั้ น" ใน PDF ที่ justify ด้วย letter-spacing ทุก glyph; การอัปเดตทุก glyph ทำให้ gap เหลือ charSpacing ตัวเดียว ต่ำกว่า threshold — อักขระอื่นทั้งหมดไม่เปลี่ยน เฉพาะ space หลอกหาย) — อัปเกรด pdfjs-dist แล้ว pattern minified เปลี่ยน = postinstall fail ดัง (ตั้งใจ กัน patch หลุดเงียบ) + test กัน regression ที่ `tests/scripts/patch-pdf-worker.test.ts` (fixture `tests/fixtures/thai_letterspacing.pdf` — ห้ามวางใน `public/samples/`: seed-samples จะ insert เป็นรายการเอกสารบนหน้ารายการทุก deploy); ห้าม `new URL(..., import.meta.url)` - Bookmark scroll: คำนวณ absolute position ครั้งเดียวแล้ว smooth-scroll ครั้งเดียว (ห้าม scroll ซ้อนสองจังหวะ) - Deep link (F12/C4): PDFViewer อ่าน `window.location.search` **ครั้งเดียวหลัง `pageSizes` พร้อม** (guard ด้วย ref — ไม่ใช้ `useSearchParams` เลี่ยงข้อจำกัด prerender ของ Next 14) — `?page=N` (1-based) scroll ไปหน้านั้น, `?ann=` scroll ไปหน้าของ annotation นั้น + `setSelectedId` — placeholder ขนาดจริงทุกหน้าทำให้คำนวณ absolute scroll ได้แม้หน้ายังไม่ render (กลไกเดียวกับ bookmark); ปุ่ม "คัดลอกลิงก์" ใน sidebar สร้าง URL รูป `/annotate/?page=&ann=` - Dialog `fixed z-50` ใน sidebar (ประวัติ) ต้อง render ผ่าน **`createPortal` ไป `document.body`** + guard `typeof document === "undefined"` ตอน SSR — `aside.sticky` สร้าง stacking context ครอบ z-index ของ dialog ไว้ ทำให้ `fixed z-50` แพ้ `textLayer` (z-[2] ระดับ root) จนคลิกปุ่มไม่ได้ (PLAN-FEAT.md §5) diff --git a/CLAUDE.md b/CLAUDE.md index 2f7fbc8..3eac683 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,9 +10,9 @@ ## Tech Stack - Next.js 14 (App Router) + TypeScript **strict** — serve ใต้ basePath `/docedit/note` (`next.config.mjs`) -- pdfjs-dist **v4** (`^4.10.38`) — built-in TextLayer + worker จาก `public/pdf.worker.min.mjs` (postinstall คัดลอกจาก node_modules) +- pdfjs-dist **v4** (`^4.10.38`) — built-in TextLayer + worker จาก `public/pdf.worker.min.mjs` (postinstall `scripts/patch-pdf-worker.mjs` คัดลอกจาก node_modules + **patch fake-space ไทย** — รายละเอียด invariant #6) - Drizzle ORM + PostgreSQL (Docker container เดียว: `judes-note-db`, image `postgres:17-alpine`, พอร์ต 54322) — **ไม่ใช้ Supabase** + realtime sync ด้วย **SSE (Server-Sent Events)** ผ่าน `app/api/annotations/stream` + pub/sub in-process (`lib/annotate-events.ts`) -- Tailwind CSS +- Tailwind CSS + design system **Genesis** (`docs/DESIGN.md`, 2026-09-23) — palette tokens ใน `tailwind.config.ts` (`primary` เดิม + `primary-hover`/`canvas`/`line`/`ink`/`ink-soft`/`ink-faint`/`success`/`warning`/`error` + ระดับข้อความ `success-ink`/`warning-ink`/`error-ink` ≥5:1 สำหรับข้อความ/ไอคอนสถานะ — token 500 ใช้กับ border/bg tint เท่านั้น) ห้ามใช้ raw palette shades ของ Tailwind (`slate-*`/`amber-*`/`emerald-*`/`red-*` ฯลฯ) อีก; ฟอนต์ display = **General Sans** (self-host จาก Fontshare ผ่าน `next/font/local` — `app/fonts/`, variable weight 200–700, ใช้ intranet ได้), body = **DM Sans** (`next/font/google`), ไทย fallback **Sarabun** ตามลำดับใน stack (Latin ก่อนไทย — `fontFamily`/globals.css); รัศมี: ปุ่ม/input 6px (รวมปุ่ม action เล็กใน popup/composer/dialog — คู่ ยกเลิก/บันทึก ต้องได้รัศมีเดียวกัน), menu/list row ใน dropdown/panel 4px, panel/dropdown 8px, การ์ด 12px; เงาเฉพาะ hover (`shadow-card-hover`/`shadow-btn-glow` — static ไม่มีเงา ยกเว้นชั้น floating: dropdown/popover `shadow-lg` และ pin ลอยบนเอกสาร ดู §App-specific exceptions ของ DESIGN.md); focus indicator = outline น้ำเงินทึบ 2px + offset ผ่าน class `.focus-ring`/`.genesis-input` ใน globals.css (+ สำเนา `FOCUS_RING_CLS` ใน PDFViewer/AnnotationSidebar/AnnotationSelectionLayer/ToolControls — แก้ต้องแก้พร้อมกัน; halo alpha จางเกิน WCAG 1.4.11); น้ำเงินเฉพาะของ interactive (แบรนด์มาร์ก tile JN/แถบหัวหน้าสรุปยกเว้น) - mock login e-Court — หน้า `/login` (dropdown `MOCK_USERS` user1–user5 + รหัสผ่านกลาง `Demo1234`) ตั้ง cookie `oacCase` ตาม format จริงของระบบศาล → identity (userId = `u_userid`, userName = `u_userlogin` — **ต่างกันต่อคน** เพื่อสิทธิ์เจ้าของ) อ่านจาก cookie ผ่าน `lib/session.ts` + บังคับทุกหน้าด้วย `middleware.ts` (auth จริงเทียบระบบ e-Court + roles ยัง out of scope) ## Invariants ที่ต้องรักษา (ห้ามละเมิด) @@ -22,7 +22,7 @@ 3. **ลำดับ layer ตาม PLAN.md §1:** canvas (`pointer-events: none` เสมอ) → highlight-layer (`none` เสมอ, `mix-blend-mode: multiply`) → textLayer → InkLayer (SVG) → comment pins — สลับ pointer-events ตาม tool ปัจจุบัน 4. **เขียนผ่าน Server Actions เท่านั้น** (`app/actions/annotations.ts`) และ**หลังเขียน DB สำเร็จทุกครั้ง ต้อง publish event ผ่าน `lib/annotate-events.ts` + `revalidatePath` หน้า viewer และ summary ของ fileId นั้น** (helper `revalidateFilePages` — สั่ง browser ทิ้ง Router Cache ฝั่ง client, `force-dynamic` ปิดแค่ฝั่ง server ไม่พอ: ไม่งั้นกด back แล้วคลิกเข้าไฟล์ใหม่ยังได้ initial data เก่าจนต้อง hard refresh) / **อ่าน + sync ผ่าน SSE hook** (`hooks/useAnnotationRealtime.ts` เปิด `EventSource` ไป `/api/annotations/stream?fileId=`) — event เป็น DTO (camelCase + ISO string) จาก server ตรง ๆ — **ยกเว้น `registerExternalFile` (N8)** ที่เขียน row `files` ระหว่าง render ของหน้า `/` และ `/annotate` (entry point ทั้งสองของสัญญาลิงก์ `?fileUrl=`) แล้ว `redirect` ออกทันที (หน้า `force-dynamic` + รายการไฟล์ไม่ได้ sync ผ่าน SSE — ไม่ต้อง publish event/`revalidatePath`) 5. **Timestamp ข้าม server→client→SSE เป็น ISO string เสมอ** (DTO types) — ห้ามส่ง Date object ตรง ๆ -6. **pdfjs-dist ต้อง `dynamic import()` ภายใน useEffect เท่านั้น** — ห้าม import top-level ใน module scope ที่ server evaluate และต้องตั้ง `GlobalWorkerOptions.workerSrc = withBasePath("/pdf.worker.min.mjs")` (postinstall คัดลอกไฟล์ไป `public/`) — ห้ามใช้ `new URL(..., import.meta.url)` +6. **pdfjs-dist ต้อง `dynamic import()` ภายใน useEffect เท่านั้น** — ห้าม import top-level ใน module scope ที่ server evaluate และต้องตั้ง `GlobalWorkerOptions.workerSrc = withBasePath("/pdf.worker.min.mjs")` (postinstall `scripts/patch-pdf-worker.mjs` คัดลอกไฟล์ไป `public/` **แล้ว patch จุดเดียว: ถอด `if (scaledDim)` รอบ `textChunk.prevTransform = getCurrentTextTransform()` ให้อัปเดตทุก glyph** — pdf.js เดิมข้าม glyph กว้าง 0 อย่างสระ/วรรณยุกต์ไทย ทำให้ charSpacing ของ glyph เหล่านั้นสะสมเป็น gap จน `addFakeSpaces` แทรก " " กลางคำ "ฉะนั้ น" — bug ของ pdf.js ที่ upstream ยังไม่แก้; การ patch ทำบนโค้ด minified ด้วย pattern จำเพาะ ถ้าอัปเกรด pdfjs-dist แล้ว pattern ไม่ตรง postinstall จะ **fail ดัง ๆ โดยตั้งใจ** กัน patch หลุดเงียบ — test กัน regression: `tests/scripts/patch-pdf-worker.test.ts` + fixture `tests/fixtures/thai_letterspacing.pdf` — Docker build ต้องใช้ script นี้ด้วย ไม่ใช่ copyFileSync เปล่า ดู `docker/web.Dockerfile`) — ห้ามใช้ `new URL(..., import.meta.url)` 7. **InkLayer ใช้ SVG แบบไม่มี viewBox** — render ด้วย px จริง แปลง % → px จากขนาดที่ ResizeObserver วัด (viewBox จะทำให้ความหนาเส้นบิดเบี้ยว) 8. **JSONB `data` เป็น strict discriminated union** (`AnnotationData` แยกตาม enum `type`) — ห้าม loosen type เป็น `any`/generic object 9. **Optimistic update ใช้ `useOptimistic` (React 18) ภายใน `startTransition`** — React rollback อัตโนมัติเมื่อ action ล้มเหลว + dedupe ด้วย id ใน `hooks/useAnnotationRealtime.ts` (duplicate ชั่วขณะกับ realtime event หายเอง) @@ -33,6 +33,7 @@ ## โครงสร้างไฟล์ (ตามแผน) ``` +scripts/patch-pdf-worker.mjs ← postinstall: คัดลอก pdf.worker.min.mjs จาก node_modules ไป public/ + patch fake-space ไทย (invariant #6 — เจอ pattern ≠ 1 จุดหลังอัปเกรด pdfjs-dist = exit 1 ดัง ๆ) db/schema.ts ← ตาราง + JSONB types + DTO + mapper toDTO db/index.ts ← postgres.js + drizzle (DATABASE_URL → container judes-note-db, prepare:false) middleware.ts ← บังคับ login ทุกหน้า — ไม่มี cookie oacCase ที่ parse ได้ = redirect /login (ยกเว้น /login, /api/*, /uploads/*, static ใน public/) @@ -47,7 +48,7 @@ utils/annotation-display.ts ← label/ข้อความย่อต่อช utils/date-format.ts ← formatThaiDate วันที่ไทยแบบเต็ม "18 กันยายน พ.ศ. 2569" (+เวลา "… 16:33:00 น.") — ทุกจุดที่แสดงวันที่ใน UI ใช้ร่วม (เว้น export CSV ที่ lock Asia/Bangkok) utils/pdf-export.ts ← แปลงพิกัดจอ⇄PDF (/Rotate) + wrapText + hexToRgb (export route) utils/user.ts ← parse/build ค่า cookie oacCase → OacUser {userId: u_userid, userName: u_userlogin} (pure module — middleware ใช้ร่วมได้) -utils/external-file.ts ← N8 validation ลิงก์ไฟล์รีโมต (allowlist "admincourt.go.th" + env EXTERNAL_FILE_HOSTS, suffix match, https เท่านั้น ห้าม user:pass@) + titleFromExternalUrl/externalIdFor/isHttpUrl (pure) +utils/external-file.ts ← N8 validation ลิงก์ไฟล์รีโมต (allowlist "admincourt.go.th" + env EXTERNAL_FILE_HOSTS, suffix match, https เท่านั้น ห้าม user:pass@) + titleFromExternalUrl/externalIdFor/isHttpUrl + normDocumentGroup (N9 normalize รหัสกลุ่ม — ใช้ร่วม N8/N4, pure) utils/base-path.ts ← withBasePath() เติม basePath /docedit/note ให้ URL ฝั่ง browser ที่ Next ไม่เติมให้เอง (ค่าจาก next.config.mjs ผ่าน env — invariant #12) utils/file-kind.ts ← derive ชนิดไฟล์ pdf/image จาก extension ของ path (N3 — ใช้ร่วม viewer/export/หน้ารายการ/seed) utils/tiff-client.ts ← decode TIFF ทุกเฟรม → PNG blob URL ฝั่ง client (N3) @@ -56,7 +57,7 @@ hooks/useAnnotationRealtime.ts ← EventSource ต่อ SSE stream + dedupe hooks/useHistory.ts ← undo/redo stack ฝั่ง client (F2) app/actions/annotations.ts ← Server Actions CRUD + validation + publish event app/actions/auth.ts ← Server Action login (mock user_x/Demo1234 → ตั้ง cookie oacCase) / logout (ลบ cookie) -app/actions/files.ts ← Server Action uploadFile — validate + เขียนไฟล์ใหม่ที่ public/uploads/ (N4) +app/actions/files.ts ← Server Action uploadFile — validate + เขียนไฟล์ใหม่ที่ public/uploads/ + ช่องกลุ่ม typeId ทางเลือก normalize เหมือน N8 (N4) app/api/annotations/stream/route.ts ← SSE endpoint (GET ?fileId=) app/api/annotations/export/route.ts ← export PDF ฝัง annotation (pdf-lib, F8) app/api/annotations/export-csv/route.ts ← export สรุป CSV พร้อม BOM (F16) @@ -74,15 +75,16 @@ components/pdf/TextboxStampLayer.tsx ← overlay TEXTBOX + STAMP (F17/F18) components/pdf/AnnotationSelectionLayer.tsx ← กรอบ selection + handles ย้าย/ปรับขนาด (F1) + แถบสี/ความหนา + ปุ่มลบ components/sidebar/AnnotationSidebar.tsx ← + โน้ตคำอธิบาย (note) inline ต่อรายการ (PLAN-FEAT.md §7 N1) components/AnnotationTypeIcon.tsx ← ไอคอนตามชนิด annotation (ย้ายจาก sidebar) — ไม่มี "use client" ให้ server component ใช้ร่วมได้ (sidebar + หน้าสรุป) -components/UploadFileForm.tsx ← ฟอร์มอัปโหลดไฟล์บนหน้ารายการ (N4) +components/UploadFileForm.tsx ← ฟอร์มอัปโหลดไฟล์บนหน้ารายการ + ช่องกลุ่ม typeId ทางเลือก ไม่กรอก = ไม่มีกลุ่ม (N4) components/EcourtLinkForm.tsx ← การ์ดจำลองลิงก์ e-Court บนหน้ารายการ (N8 — กรอก fileUrl/fileId/typeId กด "เปิดเอกสาร" แล้ว router.push ไป /?fileUrl=&fileId=&typeId= ให้หน้า / ลงทะเบียน+redirect เอง — ใช้ทดสอบก่อนมีลิงก์จริงจาก e-Court) components/LoginForm.tsx ← ฟอร์ม login (client — เรียก action login แล้ว router.replace("/")) -components/SessionBar.tsx ← แถบแสดงผู้ใช้ที่ login + ปุ่มออกจากระบบบนหน้ารายการ (แทน UserPicker เดิม) +components/AppNav.tsx ← nav sticky 56px แบบ Genesis (backdrop-blur + border-b) ใช้ร่วมหน้า / และ /groups/[typeId] — โลโก้ซ้าย + ชื่อผู้ใช้/LogoutButton ขวา (แทน SessionBar เดิม 2026-09-23) +components/DotGrid.tsx ← กริดจุดอันตรกิริยาตาม pointer พื้นหลังหน้า login — decorative ชิ้นเดียวที่ Genesis อนุญาต (อัปเดตตำแหน่งผ่าน CSS variable บน style ไม่ setState) ``` ## ขอบเขต — ห้ามทำเกิน (Out of Scope) -> ขอบเขตด้านล่างเป็นของ**เฟสแรก** — ฟีเจอร์ต่อยอด (edit/ย้าย, undo/redo, export, reply, resolve ฯลฯ) อนุญาต**เฉพาะตาม task F1–F19 ใน `docs/PLAN-FEAT.md`** และทำผ่าน `/implement-feat` เท่านั้น ห้ามทำเกินสเปกของแผนนั้น — **ปัจจุบันทำครบตามแผนแล้ว ยกเว้น F11 (export/import JSON) ที่ถูกถอดออกจากโค้ดแล้ว**: แก้ไข/ย้าย/ปรับขนาด (F1), undo/redo (F2), คีย์ลัด (F3), กรองรายคน (F4), รูปทรง (F5), eraser (F6), สไตลัส/pressure (F7), redaction + export PDF (F8), reply เธรด (F9), resolve (F10), deep link (F12), แก้ไข comment (F13), ค้นหา (F14), หน้าสรุป (F15), export CSV (F16), text box (F17), stamp (F18), version history + กู้คืน (F19) — ฟีเจอร์เสริมหลังแผน (อนุญาตตาม §7 ของ `docs/PLAN-FEAT.md`): โน้ตคำอธิบาย `note?` ฝังใน annotation ทุกชนิดยกเว้น COMMENT แก้ไขใน sidebar + hover tooltip (N1), bookmark โครงสร้าง 3 ระดับ `parent_id` + โน้ต + popup สร้าง/แก้ไข/ย้ายหัวข้อ + ปุ่ม bookmark ในแผง selection + ชื่อ default จากข้อความที่คลุม (N2), รองรับไฟล์รูปภาพ jpg/jpeg/png/tif เป็นเอกสาร annotate ได้ครบทุกชนิด + TIFF หลายเฟรม = หลายหน้า + export PDF ฝังรูป (N3 — แผน `docs/PLAN-IMAGE.md`), อัปโหลดไฟล์ PDF/รูปผ่านหน้ารายการแบบง่าย (N4), bookmark เรียงตามลำดับหน้า (N5), ค้นหา bookmark พร้อมเงื่อนไข คำค้น/ช่วงหน้า/ผู้บันทึก/ช่วงวันที่ + `bookmarks.user_name` (N6 — แผน `docs/PLAN-BOOKMARK.md`), รายงานรายการ bookmark เป็น PDF ผ่าน route แยก (สร้างเอกสารใหม่ล้วน ไม่โหลดไฟล์ต้นฉบับ — N7 แผน `docs/PLAN-BOOKMARK-PDF.md` — **2026-09-17 เปลี่ยน deliverable เป็นส่งออก JSON** แทนรายงาน PDF ตามคำขอผู้ใช้: flat array `BookmarkDTO` เรียง N5 + envelope fileId/title/exportedAt ผ่าน route เดิม), เปิดเอกสารจากลิงก์ e-Court `?fileUrl=&fileId=&typeId=` — ลงทะเบียน metadata-only (dedupe `external_id`, ไม่เก็บสำเนาไฟล์) + ดึงไฟล์สดตอนเปิดดู/export ผ่าน allowlist + การ์ดจำลองลิงก์บนหน้ารายการ + รับ path ในเครื่อง (`/samples/…`) เพื่อทดสอบ grouping ใน dev (N8 — แผน `docs/PLAN-ECOURT.md`, implement ครบแล้ว 2026-09-22 รวม D5 login วนกลับ), กลุ่มเอกสารตาม `type_id` จากลิงก์ e-Court — หน้ารายการ `/` จัด section ต่อกลุ่ม + ส่วน "ไม่มีกลุ่ม" ท้ายสุด และหน้า `/groups/[typeId]` แสดงเอกสารที่รู้จักของกลุ่มพร้อมหมายเหตุ "แสดงเฉพาะเอกสารที่เคยเปิดในระบบนี้" + การ์ดแต่ละไฟล์หุบ/กางดู bookmark+annotation ทั้งไฟล์ (2026-09-23 ตามคำขอผู้ใช้ — ปิด gap 5.3.10 ครึ่งหลัง bookmark รวมทั้งกลุ่ม; อ่าน `type_id` อย่างเดียว — เขียนที่ N8 `registerExternalFile` เท่านั้น ไม่มี action/SSE ใหม่) (N9 — แผน `docs/PLAN-DOCSET.md`) +> ขอบเขตด้านล่างเป็นของ**เฟสแรก** — ฟีเจอร์ต่อยอด (edit/ย้าย, undo/redo, export, reply, resolve ฯลฯ) อนุญาต**เฉพาะตาม task F1–F19 ใน `docs/PLAN-FEAT.md`** และทำผ่าน `/implement-feat` เท่านั้น ห้ามทำเกินสเปกของแผนนั้น — **ปัจจุบันทำครบตามแผนแล้ว ยกเว้น F11 (export/import JSON) ที่ถูกถอดออกจากโค้ดแล้ว**: แก้ไข/ย้าย/ปรับขนาด (F1), undo/redo (F2), คีย์ลัด (F3), กรองรายคน (F4), รูปทรง (F5), eraser (F6), สไตลัส/pressure (F7), redaction + export PDF (F8), reply เธรด (F9), resolve (F10), deep link (F12), แก้ไข comment (F13), ค้นหา (F14), หน้าสรุป (F15), export CSV (F16), text box (F17), stamp (F18), version history + กู้คืน (F19) — ฟีเจอร์เสริมหลังแผน (อนุญาตตาม §7 ของ `docs/PLAN-FEAT.md`): โน้ตคำอธิบาย `note?` ฝังใน annotation ทุกชนิดยกเว้น COMMENT แก้ไขใน sidebar + hover tooltip (N1), bookmark โครงสร้าง 3 ระดับ `parent_id` + โน้ต + popup สร้าง/แก้ไข/ย้ายหัวข้อ + ปุ่ม bookmark ในแผง selection + ชื่อ default จากข้อความที่คลุม (N2), รองรับไฟล์รูปภาพ jpg/jpeg/png/tif เป็นเอกสาร annotate ได้ครบทุกชนิด + TIFF หลายเฟรม = หลายหน้า + export PDF ฝังรูป (N3 — แผน `docs/PLAN-IMAGE.md`), อัปโหลดไฟล์ PDF/รูปผ่านหน้ารายการแบบง่าย + ช่องกลุ่ม typeId ทางเลือก ไม่กรอก = ไม่มีกลุ่ม (N4 — เพิ่มช่องกลุ่ม 2026-09-23 ตามคำขอผู้ใช้), bookmark เรียงตามลำดับหน้า (N5), ค้นหา bookmark พร้อมเงื่อนไข คำค้น/ช่วงหน้า/ผู้บันทึก/ช่วงวันที่ + `bookmarks.user_name` (N6 — แผน `docs/PLAN-BOOKMARK.md`), รายงานรายการ bookmark เป็น PDF ผ่าน route แยก (สร้างเอกสารใหม่ล้วน ไม่โหลดไฟล์ต้นฉบับ — N7 แผน `docs/PLAN-BOOKMARK-PDF.md` — **2026-09-17 เปลี่ยน deliverable เป็นส่งออก JSON** แทนรายงาน PDF ตามคำขอผู้ใช้: flat array `BookmarkDTO` เรียง N5 + envelope fileId/title/exportedAt ผ่าน route เดิม), เปิดเอกสารจากลิงก์ e-Court `?fileUrl=&fileId=&typeId=` — ลงทะเบียน metadata-only (dedupe `external_id`, ไม่เก็บสำเนาไฟล์) + ดึงไฟล์สดตอนเปิดดู/export ผ่าน allowlist + การ์ดจำลองลิงก์บนหน้ารายการ + รับ path ในเครื่อง (`/samples/…`) เพื่อทดสอบ grouping ใน dev (N8 — แผน `docs/PLAN-ECOURT.md`, implement ครบแล้ว 2026-09-22 รวม D5 login วนกลับ), กลุ่มเอกสารตาม `type_id` จากลิงก์ e-Court — หน้ารายการ `/` จัด section ต่อกลุ่ม + ส่วน "ไม่มีกลุ่ม" ท้ายสุด และหน้า `/groups/[typeId]` แสดงเอกสารที่รู้จักของกลุ่มพร้อมหมายเหตุ "แสดงเฉพาะเอกสารที่เคยเปิดในระบบนี้" + การ์ดแต่ละไฟล์หุบ/กางดู bookmark+annotation ทั้งไฟล์ (2026-09-23 ตามคำขอผู้ใช้ — ปิด gap 5.3.10 ครึ่งหลัง bookmark รวมทั้งกลุ่ม; อ่าน `type_id` อย่างเดียว — เขียนสองที่: N8 `registerExternalFile` + ช่องกลุ่มทางเลือกของฟอร์มอัปโหลด N4 `uploadFile` (2026-09-23 ตามคำขอผู้ใช้ — insert ตอนสร้าง row ใหม่เท่านั้น normalize เหมือน N8: trim → ว่าง = NULL, cap 200) ไม่มี action/SSE ใหม่) (N9 — แผน `docs/PLAN-DOCSET.md`) - ไม่มี auth จริง/roles (login เป็น mock หน้า `/login` ตั้ง cookie `oacCase` — ไม่ได้ตรวจกับระบบ e-Court จริง), ไม่มี presence — undo/redo เป็น history ฝั่ง client ต่อ session ต่อผู้ใช้ ไม่เก็บใน DB (D10) ส่วน version history/กู้คืน (F19) เก็บในตาราง `annotation_versions` - Annotation: สร้าง + ลบ + แก้ไข/ย้าย/ปรับขนาด/เปลี่ยนสี–ความหนา/แก้ไขข้อความ **ของตัวเอง ในหน้าเดียวกัน** — resolve เป็นสิ่งเดียวที่ใครก็ toggle ได้ (D6) diff --git a/app/actions/files.ts b/app/actions/files.ts index 6826269..40b013d 100644 --- a/app/actions/files.ts +++ b/app/actions/files.ts @@ -11,6 +11,7 @@ import { revalidatePath } from "next/cache"; import { db } from "@/db"; import { files } from "@/db/schema"; +import { normDocumentGroup } from "@/utils/external-file"; import { SAMPLE_FILE_EXTENSIONS } from "@/utils/file-kind"; const MAX_UPLOAD_BYTES = 100 * 1024 * 1024; // 100MB (ตาม bodySizeLimit ใน next.config.mjs) @@ -94,8 +95,9 @@ export async function uploadFile(formData: FormData): Promise const title = sanitizeTitle(file.name); const pdfUrl = `/uploads/${diskName}`; + const typeId = normDocumentGroup(formData.get("typeId")); try { - await db.insert(files).values({ id, title, pdfUrl }); + await db.insert(files).values({ id, title, pdfUrl, typeId }); } catch (err) { // DB fail หลังเขียนไฟล์ — เก็บกวาดไฟล์ก่อนโยนต่อ กันไฟล์กำพร้าไม่มี row ชี้ (ไม่มีกลไกเก็บอื่น) await unlink(diskPath).catch(() => undefined); diff --git a/app/annotate/[id]/summary/page.tsx b/app/annotate/[id]/summary/page.tsx index ff61f2a..5e7d74b 100644 --- a/app/annotate/[id]/summary/page.tsx +++ b/app/annotate/[id]/summary/page.tsx @@ -84,11 +84,10 @@ function pageSummaryLine(b: PageBucket): string { return parts.length > 0 ? `${parts.join(" · ")} · โดย ${whoLabel}` : `โดย ${whoLabel}`; } -/** focus ring ร่วมของทุกลิงก์ในหน้า — จอสัมผัสได้ 44px อัตโนมัติจากกฎ - * `main.summary-page a`/`button` ใน globals.css (ห้ามถอด class summary-page - * ของ
) */ -const RING_CLS = - "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1"; +/** focus ring ร่วมของทุกลิงก์ในหน้า — Genesis .focus-ring (outline น้ำเงินทึบ 2px + * + offset ตาม spec Elevation); จอสัมผัสได้ 44px อัตโนมัติจากกฎ + * `main.summary-page a`/`button` ใน globals.css (ห้ามถอด class summary-page ของ
) */ +const RING_CLS = "focus-ring"; export const dynamic = "force-dynamic"; @@ -177,7 +176,7 @@ export default async function SummaryPage({
{/* หัวกระดาษสำนวน — แถบน้ำเงินสืบต่อจาก tile JN ของแบรนด์ (หน้ารายการ/login): ชื่อรายงาน + ชื่อแฟ้ม + สถิติในที่เดียว แล้วปิดท้ายด้วยเส้นคั่นบาง */} -
+

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

{file.title}

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

+

+ +

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

-

+

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

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

+

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

@@ -281,32 +280,32 @@ export default async function SummaryPage({ เป็นการ์ดชุดเดียวกับ
มือถือและการ์ดรายหน้า (แสดงเฉพาะหน้าที่มี รายการ ≥ 1 ตามหลัก minimize choices; max-h กันเอกสารหลายสิบหน้าสูงเกินจอ — ครึ่งล่างของ sticky กดไม่ได้ตลอดกาล) */} -
+ )} + + ); + })} + +
+ ); } diff --git a/app/layout.tsx b/app/layout.tsx index 7a6a612..d0d15e3 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,17 +1,39 @@ import type { Metadata } from "next"; -import { Sarabun } from "next/font/google"; +import { DM_Sans, Sarabun } from "next/font/google"; +import localFont from "next/font/local"; import "./globals.css"; -/** typography audit 2026-09-16 (Option B): Sarabun — ฟอนต์ตระกูลมาตรฐานเอกสาร - * ราชการไทย (TH Sarabun) เจ้าหน้าที่คุ้นเคยที่สุด / next/font self-host ตอน build: - * ไฟล์ฟอนต์ serve จาก origin เดียวกับแอป ไม่พึ่ง Google ตอนรัน จึงใช้ใน intranet - * ได้ (แต่เครื่อง build ต้องเห็นอินเทอร์เน็ต) — โหลดเฉพาะ 2 weights ตามแผน - * ~100-160KB แลกกับการแสดงผลเหมือนกันทุกอุปกรณ์ รวมกล่อง TEXTBOX ที่ผูก - * stack นี้ (T1-C: wrap ข้อความตรงกันข้ามอุปกรณ์จริง) */ +/* typography audit 2026-09-16 (Option B): Sarabun — ฟอนต์ตระกูลมาตรฐานเอกสาร + * ราชการไทย (TH Sarabun) เจ้าหน้าที่คุ้นเคยที่สุด / next/font self-host ตอน build: + * ไฟล์ฟอนต์ serve จาก origin เดียวกับแอป ไม่พึ่ง Google ตอนรัน จึงใช้ใน intranet + * ได้ (แต่เครื่อง build ต้องเห็นอินเทอร์เน็ต) + * + * Genesis (docs/DESIGN.md 2026-09-23): เพิ่ม General Sans (display/heading) จาก + * Fontshare เป็น variable woff2 ไฟล์เดียว self-host ผ่าน next/font/local + DM Sans + * (body) จาก Google — ทั้งคู่เป็น Latin เท่านั้น ไม่มี glyph ไทย จึงวาง Sarabun + * ต่อท้ายใน stack (globals.css / tailwind fontFamily): ตัว Latin/ตัวเลขได้บุคลิก + * Genesis ส่วนข้อความไทยคง Sarabun เดิม — Sarabun เพิ่ม weight 700 ให้ heading + * ไทย (fallback ของ General Sans bold) */ +const generalSans = localFont({ + src: "./fonts/GeneralSans-Variable.woff2", + // ประกาศช่วง wght จริงของ variable font (Fontshare: 200–700) — ไม่ใส่แล้ว + // @font-face ที่ next/font generate ไม่มี descriptor font-weight → cover แค่ 400 + // และ font-bold/700 กลายเป็น synthetic bold จาก instance 400 (UX review C1 2026-09-23) + weight: "200 700", + display: "swap", + variable: "--font-general-sans", +}); + +const dmSans = DM_Sans({ + subsets: ["latin"], + display: "swap", + variable: "--font-dm-sans", +}); + const sarabun = Sarabun({ subsets: ["thai", "latin"], - weight: ["400", "600"], + weight: ["400", "600", "700"], display: "swap", variable: "--font-sarabun", }); @@ -27,8 +49,11 @@ export default function RootLayout({ children: React.ReactNode; }) { return ( - - {children} + + {children} ); } diff --git a/app/login/page.tsx b/app/login/page.tsx index 824903c..8ce183f 100644 --- a/app/login/page.tsx +++ b/app/login/page.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next"; import { redirect } from "next/navigation"; +import DotGrid from "@/components/DotGrid"; import LoginForm from "@/components/LoginForm"; import { getSessionUser } from "@/lib/session"; import { firstSearchParam, sanitizeNextPath } from "@/utils/external-file"; @@ -24,7 +25,10 @@ export default function LoginPage({ if (user) redirect(next ?? "/"); return ( -
+ // Genesis: กริดจุดอันตรกิริยา ( decorative element ชิ้นเดียวที่อนุญาต) เป็นพื้นหลัง + // หน้า login — DotGrid อยู่ก่อนการ์ดใน DOM จึงถูกทับโดยการ์ดเอง ไม่ต้องเล่น z-index +
+
); diff --git a/app/page.tsx b/app/page.tsx index f9fba77..4e49fdd 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -5,8 +5,8 @@ import { FileImage, FileText, Folder } from "lucide-react"; import { db } from "@/db"; import { files } from "@/db/schema"; +import AppNav from "@/components/AppNav"; import EcourtLinkForm from "@/components/EcourtLinkForm"; -import SessionBar from "@/components/SessionBar"; import UploadFileForm from "@/components/UploadFileForm"; import { imageFormatFromUrl } from "@/utils/file-kind"; import { formatThaiDate } from "@/utils/date-format"; @@ -32,35 +32,42 @@ function kindBadge(pdfUrl: string): string { } } -/** การ์ดไฟล์ของหน้ารายการ — ใช้ร่วมทุก section (N9 จัดกลุ่มแล้วยังหน้าตาเดิม) */ +/** การ์ดไฟล์ของหน้ารายการ (Genesis kit-card: surface 12px radius ขอบ line ไม่มีเงา + * static — hover ยก -2px + shadow-card-hover; tile ไอคอนเป็นกลาง น้ำเงินสงวนให้ของ + * interactive ตาม Do&Don't ของ design system) ใช้ร่วมทุก section (N9) */ function FileCard({ file }: { file: FileRow }) { return ( -
  • -
    - {imageFormatFromUrl(file.pdfUrl) ? ( - - ) : ( - - )} +
  • +
    +
    + {imageFormatFromUrl(file.pdfUrl) ? ( + + ) : ( + + )} +
    + + {kindBadge(file.pdfUrl)} +
    - - - {file.title} - - - - {kindBadge(file.pdfUrl)} - - {formatThaiDate(file.createdAt, true)} - + + {file.title} - {/* ปุ่มเข้า annotate แทนลิงก์กางเต็มการ์ด (M4) ตาม feedback — เป้าจิ้ม 36px (H4) */} - - Annotate - + {/* วันที่เป็นข้อมูลที่ผู้ใช้อ่านจริง — ink-soft (ผ่าน 4.5:1); ink-faint เก็บไว้ + ให้ placeholder/disabled เท่านั้น (UX review M2 2026-09-23) */} + + {formatThaiDate(file.createdAt, true)} + + {/* ปุ่มเข้า annotate แทนลิงก์กางเต็มการ์ด (M4) ตาม feedback — เป็น secondary + (หนึ่ง primary ต่อ section ตาม Genesis; การ์ดทั้งกริดคือ passive surface) */} +
    + + Annotate + +
  • ); } @@ -113,103 +120,117 @@ export default async function HomePage({ ); return ( -
    -
    -
    - JN -
    -
    -

    - Judge's Note -

    -

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

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

    + Judge's Note +

    +

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

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

    เพิ่มเอกสาร

    - -
    - -
    -

    เอกสาร

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

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

    -

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

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

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

    +
    +
    + +
    +
    + +
    +
    +

    เอกสาร

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

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

    +

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

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

    + {typeId} +

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

    ไม่มีกลุ่ม

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

    + {userName} +

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

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

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

    +

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

    -

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

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

    - {error &&

    {error}

    } + {error &&

    {error}

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

    Judge's Note

    -

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

    +
    +

    + Judge's Note +

    +

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

    -

    เข้าสู่ระบบ

    +

    เข้าสู่ระบบ

    -
    +
    -