genesis+worker: design system Genesis ทั้งชุด + patch pdf.worker แก้ fake-space ไทย + ปิด review design/responsive/quality รอบ 2026-09-24 — (1) Genesis: ฟอนต์ General Sans self-host + DM Sans + Sarabun, AppNav 56px backdrop-blur แทน SessionBar, DotGrid หน้า login, palette/รัศมี/เงา/focus ตาม docs/DESIGN.md ครบทุกหน้า (2) postinstall scripts/patch-pdf-worker.mjs ถอด if (scaledDim) รอบ prevTransform ให้อัปเดตทุก glyph — กัน addFakeSpaces แทรก space กลางคำไทยใน PDF ที่ justify ด้วย letter-spacing + test กัน regression (tests/scripts/ + fixture thai_letterspacing.pdf, invariant #6) (3) ฟอร์มอัปโหลด + ช่องกลุ่ม typeId ทางเลือก — normDocumentGroup ที่ utils/external-file.ts เป็น single source ร่วมกับ N8 (4) token fixes: focus ring ปุ่ม pin + แถว bookmark ตามหน้า, ปุ่ม action ใน popup/composer/dialog เป็น rounded-md ทั้งคู่, selection preview เป็น tint ของ primary แทน blue-500 ดิบ, default สี shape/highlight รวมที่ utils/annotation-display, loading animate-pulse + จดข้อยกเว้น (เงา pin, font weights ของ viewer) ลง DESIGN.md (5) responsive: Export PDF max-lg:order-first ให้เห็นบน iPad, แถวบน header แถวเดียวเลื่อนได้บนจอแคบ, re-fit ความกว้างตอน toggle sidebar เฉพาะเมื่อยังไม่ซูมเอง, pin popover flip ขึ้นบนเมื่อใกล้ท้ายหน้า, toast hint ตอนสลับ tool กลางเอกสาร, ป้าย "ชนิด · ผู้ทำ" ของรายการผู้อื่นสำหรับจอสัมผัส (6) คุณภาพ: @layer ครอบ @media กันกฎ touch รั่ยไป desktop (Tailwind v3 ยก @layer ออกจาก media ทิ้ง wrapper — ยืนยันด้วย build จริง + วัดบนจอ), กฎ M5 ครอบ contenteditable กัน Safari zoom ตอนแก้ไข TEXTBOX บน iPad, COMMENT ใช้ box มาตราฐานเดียวตาม pin px จริง (render/scroll target/move-clamp/bookmark), ตัดหัวการ์ดซ้ำกับ h2 หน้ารายการ — พร้อมไฟล์ untracked ที่ postinstall/โค้ดอ้างถึงครบชุด (app/fonts/, AppNav, DotGrid, tests/) กัน fresh clone fail ที่ npm install (คำขอผู้ใช้ 2026-09-24)
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
parent
e254b935ce
commit
c5c6b65bd2
42 changed files with 1429 additions and 745 deletions
|
|
@ -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/<uuid>.<ext>` เท่านั้น (ชื่อจาก 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/<uuid>.<ext>` เท่านั้น (ชื่อจาก 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 — ไม่มีตารางกลุ่ม
|
||||
|
|
|
|||
|
|
@ -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=<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)
|
||||
|
|
|
|||
18
CLAUDE.md
18
CLAUDE.md
File diff suppressed because one or more lines are too long
|
|
@ -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<UploadFileResult>
|
|||
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
* ของ <main>) */
|
||||
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 ของ <main>) */
|
||||
const RING_CLS = "focus-ring";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
|
|
@ -177,7 +176,7 @@ export default async function SummaryPage({
|
|||
<main className="summary-page mx-auto max-w-3xl px-4 py-6 lg:max-w-6xl lg:px-8">
|
||||
{/* หัวกระดาษสำนวน — แถบน้ำเงินสืบต่อจาก tile JN ของแบรนด์ (หน้ารายการ/login):
|
||||
ชื่อรายงาน + ชื่อแฟ้ม + สถิติในที่เดียว แล้วปิดท้ายด้วยเส้นคั่นบาง */}
|
||||
<header className="rounded-xl bg-primary px-5 py-4 text-white shadow-sm sm:px-6">
|
||||
<header className="rounded-xl bg-primary px-5 py-4 text-white sm:px-6">
|
||||
<h1 className="text-lg font-semibold leading-snug">สรุปหมายเหตุ</h1>
|
||||
<p className="mt-0.5 break-words text-sm text-white/85">{file.title}</p>
|
||||
{pages.length > 0 && (
|
||||
|
|
@ -194,7 +193,7 @@ export default async function SummaryPage({
|
|||
<div className="mt-3 flex flex-wrap items-center justify-between gap-x-2 gap-y-1 print:hidden">
|
||||
<Link
|
||||
href={`/annotate/${id}`}
|
||||
className={`inline-flex items-center gap-1.5 rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-sm text-slate-700 shadow-sm hover:border-slate-300 hover:bg-slate-50 ${RING_CLS}`}
|
||||
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" />
|
||||
กลับหน้าอ่าน
|
||||
|
|
@ -203,7 +202,7 @@ export default async function SummaryPage({
|
|||
{/* F16/D3: export สรุปเป็น CSV (UTF-8 BOM) */}
|
||||
<a
|
||||
href={withBasePath(`/api/annotations/export-csv?fileId=${id}`)}
|
||||
className={`flex items-center gap-1.5 rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-sm text-slate-700 shadow-sm hover:border-slate-300 hover:bg-slate-50 ${RING_CLS}`}
|
||||
className={`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}`}
|
||||
>
|
||||
<FileDown aria-hidden className="h-3.5 w-3.5" />
|
||||
ส่งออกสรุป (CSV)
|
||||
|
|
@ -212,7 +211,7 @@ export default async function SummaryPage({
|
|||
"ส่งออกสรุป (CSV)" ข้างบน ไม่ต้องพึ่ง title tooltip ที่จอสัมผัสใช้ไม่ได้) */}
|
||||
<a
|
||||
href={withBasePath(`/api/bookmarks/export?fileId=${id}`)}
|
||||
className={`flex items-center gap-1.5 rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-sm text-slate-700 shadow-sm hover:border-slate-300 hover:bg-slate-50 ${RING_CLS}`}
|
||||
className={`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}`}
|
||||
>
|
||||
<Braces aria-hidden className="h-3.5 w-3.5" />
|
||||
ส่งออก Bookmark (JSON)
|
||||
|
|
@ -221,17 +220,17 @@ export default async function SummaryPage({
|
|||
</div>
|
||||
|
||||
{pages.length === 0 ? (
|
||||
<div className="mt-5 rounded-xl border border-dashed border-slate-300 bg-white/70 p-10 text-center">
|
||||
<ClipboardList aria-hidden className="mx-auto h-8 w-8 text-slate-300" />
|
||||
<p className="mt-3 text-sm font-medium text-slate-600">
|
||||
<div className="mt-5 rounded-xl border border-dashed border-line bg-white/70 p-10 text-center">
|
||||
<ClipboardList aria-hidden className="mx-auto h-8 w-8 text-ink-faint" />
|
||||
<p className="mt-3 text-sm font-medium text-ink-soft">
|
||||
ยังไม่มี Annotation หรือ Bookmark ในไฟล์นี้
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-slate-500">
|
||||
<p className="mt-1 text-xs text-ink-soft">
|
||||
เปิดหน้าอ่านเพื่อมาร์กข้อความ วาดเส้น ใส่คอมเมนต์ หรือคั่นหน้า
|
||||
</p>
|
||||
<Link
|
||||
href={`/annotate/${id}`}
|
||||
className={`mt-4 inline-flex rounded-md bg-primary px-4 py-2 text-sm font-medium text-white hover:opacity-90 ${RING_CLS}`}
|
||||
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}`}
|
||||
>
|
||||
เปิดหน้าอ่าน
|
||||
</Link>
|
||||
|
|
@ -242,37 +241,37 @@ export default async function SummaryPage({
|
|||
ไปบนมือถือ (aside ด้านล่างแสดงเฉพาะ lg) ใช้ <details> ได้หุบ/กาง + keyboard
|
||||
โดยไม่มี JS เพิ่ม — จุดไข่ปลา + ผู้ร่วมทำ ภาษาเดียวกับ desktop (จอ lg กับ
|
||||
ตอนพิมพ์ซ่อน — display:none ไม่กินช่องของกริด) */}
|
||||
<details className="group rounded-xl border border-slate-200 bg-white shadow-sm lg:hidden print:hidden">
|
||||
<summary className="flex cursor-pointer list-none items-center justify-between rounded-md px-4 py-3 text-sm font-semibold text-slate-800 hover:text-primary [&::-webkit-details-marker]:hidden">
|
||||
<details className="group rounded-xl border border-line bg-white lg:hidden print:hidden">
|
||||
<summary className="flex cursor-pointer list-none items-center justify-between rounded-md px-4 py-3 text-sm font-semibold text-ink hover:text-primary [&::-webkit-details-marker]:hidden">
|
||||
รายการตามหน้าและผู้ร่วมทำ
|
||||
<ChevronDown
|
||||
aria-hidden
|
||||
className="h-4 w-4 shrink-0 text-slate-400 transition-transform group-open:rotate-180 motion-reduce:transition-none"
|
||||
className="h-4 w-4 shrink-0 text-ink-faint transition-transform group-open:rotate-180 motion-reduce:transition-none"
|
||||
/>
|
||||
</summary>
|
||||
<nav
|
||||
aria-label="รายการตามหน้า"
|
||||
className="flex flex-col gap-0.5 border-t border-slate-100 px-2 pb-2 pt-2"
|
||||
className="flex flex-col gap-0.5 border-t border-line/70 px-2 pb-2 pt-2"
|
||||
>
|
||||
{pages.map(([pi, b]) => (
|
||||
<a
|
||||
key={pi}
|
||||
href={`#page-${pi + 1}`}
|
||||
aria-label={`หน้า ${pi + 1}, ${b.anns.length + b.bms.length} รายการ`}
|
||||
className={`flex items-end gap-1.5 rounded-md px-2 py-2 text-sm text-slate-600 hover:text-primary ${RING_CLS}`}
|
||||
className={`flex items-end gap-1.5 rounded-md px-2 py-2 text-sm text-ink-soft hover:text-primary ${RING_CLS}`}
|
||||
>
|
||||
<span className="leading-5">หน้า {pi + 1}</span>
|
||||
<span
|
||||
aria-hidden
|
||||
className="mb-1.5 min-w-4 flex-1 border-b border-dotted border-slate-400"
|
||||
className="mb-1.5 min-w-4 flex-1 border-b border-dotted border-ink-faint"
|
||||
/>
|
||||
<span className="leading-5 tabular-nums text-slate-500">
|
||||
<span className="leading-5 tabular-nums text-ink-soft">
|
||||
{b.anns.length + b.bms.length}
|
||||
</span>
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
<p className="border-t border-slate-100 px-4 py-3 text-xs leading-relaxed text-slate-600">
|
||||
<p className="border-t border-line/70 px-4 py-3 text-xs leading-relaxed text-ink-soft">
|
||||
ผู้ร่วมทำ: {allParticipants.join(" · ")}
|
||||
</p>
|
||||
</details>
|
||||
|
|
@ -281,32 +280,32 @@ export default async function SummaryPage({
|
|||
เป็นการ์ดชุดเดียวกับ <details> มือถือและการ์ดรายหน้า (แสดงเฉพาะหน้าที่มี
|
||||
รายการ ≥ 1 ตามหลัก minimize choices; max-h กันเอกสารหลายสิบหน้าสูงเกินจอ
|
||||
— ครึ่งล่างของ sticky กดไม่ได้ตลอดกาล) */}
|
||||
<aside className="hidden rounded-xl border border-slate-200 bg-white p-4 shadow-sm lg:sticky lg:top-6 lg:block lg:max-h-[calc(100vh-3rem)] lg:overflow-y-auto">
|
||||
<h2 className="text-sm font-semibold text-slate-800">รายการตามหน้า</h2>
|
||||
<aside className="hidden rounded-xl border border-line bg-white p-4 lg:sticky lg:top-6 lg:block lg:max-h-[calc(100vh-3rem)] lg:overflow-y-auto">
|
||||
<h2 className="text-sm font-semibold text-ink">รายการตามหน้า</h2>
|
||||
<nav aria-label="รายการตามหน้า" className="mt-2 flex flex-col gap-1">
|
||||
{pages.map(([pi, b]) => (
|
||||
<a
|
||||
key={pi}
|
||||
href={`#page-${pi + 1}`}
|
||||
aria-label={`หน้า ${pi + 1}, ${b.anns.length + b.bms.length} รายการ`}
|
||||
className={`flex items-end gap-1.5 rounded-md px-1 text-sm text-slate-600 hover:text-primary ${RING_CLS}`}
|
||||
className={`flex items-end gap-1.5 rounded-md px-1 text-sm text-ink-soft hover:text-primary ${RING_CLS}`}
|
||||
>
|
||||
<span className="leading-5">หน้า {pi + 1}</span>
|
||||
<span
|
||||
aria-hidden
|
||||
className="mb-1.5 min-w-4 flex-1 border-b border-dotted border-slate-400"
|
||||
className="mb-1.5 min-w-4 flex-1 border-b border-dotted border-ink-faint"
|
||||
/>
|
||||
<span className="leading-5 tabular-nums text-slate-500">
|
||||
<span className="leading-5 tabular-nums text-ink-soft">
|
||||
{b.anns.length + b.bms.length}
|
||||
</span>
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<h2 className="mt-6 border-t border-slate-300/60 pt-4 text-sm font-semibold text-slate-800">
|
||||
<h2 className="mt-6 border-t border-line pt-4 text-sm font-semibold text-ink">
|
||||
ผู้ร่วมทำ
|
||||
</h2>
|
||||
<p className="mt-1.5 px-1 text-xs leading-relaxed text-slate-600">
|
||||
<p className="mt-1.5 px-1 text-xs leading-relaxed text-ink-soft">
|
||||
{allParticipants.join(" · ")}
|
||||
</p>
|
||||
</aside>
|
||||
|
|
@ -322,15 +321,18 @@ export default async function SummaryPage({
|
|||
key={pi}
|
||||
id={`page-${pi + 1}`}
|
||||
aria-labelledby={`page-${pi + 1}-title`}
|
||||
className="scroll-mt-6 grid grid-cols-[2.75rem_1fr] rounded-xl border border-slate-200 bg-white shadow-sm sm:grid-cols-[3.75rem_1fr]"
|
||||
className="scroll-mt-6 grid grid-cols-[2.75rem_1fr] rounded-xl border border-line bg-white sm:grid-cols-[3.75rem_1fr]"
|
||||
>
|
||||
{/* ขอบ folio — "หน้า" เล็ก + เลขใหญ่ชิดเส้นดิ่ง */}
|
||||
<div className="border-r border-slate-100 py-4 pl-3 pr-2 text-right sm:pl-4">
|
||||
<div className="border-r border-line/60 py-4 pl-3 pr-2 text-right sm:pl-4">
|
||||
<h2 id={`page-${pi + 1}-title`} className="tabular-nums">
|
||||
<span className="block text-[11px] leading-none text-slate-500">
|
||||
<span className="block text-[11px] leading-none text-ink-soft">
|
||||
หน้า
|
||||
</span>
|
||||
<span className="mt-1 block text-2xl font-semibold leading-none text-primary">
|
||||
{/* เลขหน้าเป็น ink ไม่ใช่ primary — Genesis "น้ำเงินเฉพาะของ
|
||||
interactive" (ตัดสินใจพร้อม exception list ใน DESIGN.md
|
||||
2026-09-23) ความเด่นมาจากขนาด 2xl semibold tabular-nums */}
|
||||
<span className="mt-1 block text-2xl font-semibold leading-none text-ink">
|
||||
{pi + 1}
|
||||
</span>
|
||||
</h2>
|
||||
|
|
@ -341,7 +343,7 @@ export default async function SummaryPage({
|
|||
<div className="min-w-0 py-3 pl-3 pr-3 sm:pl-4 sm:pr-5 sm:py-4">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm leading-relaxed text-slate-600">
|
||||
<p className="text-sm leading-relaxed text-ink-soft">
|
||||
{pageSummaryLine(b)}
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -358,7 +360,7 @@ export default async function SummaryPage({
|
|||
{b.bms.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<CollapsibleBlock kind="bookmark" count={b.bms.length}>
|
||||
<ul className="divide-y divide-slate-100">
|
||||
<ul className="divide-y divide-line/70">
|
||||
{b.bms.map((bm) => {
|
||||
const parent = bm.parentId
|
||||
? bookmarkById.get(bm.parentId)
|
||||
|
|
@ -367,25 +369,25 @@ export default async function SummaryPage({
|
|||
<li key={bm.id} className="ml-4">
|
||||
<Link
|
||||
href={`/annotate/${id}?page=${pi + 1}`}
|
||||
className={`group -mx-2 flex items-start gap-2 rounded-lg px-2 py-2.5 transition hover:bg-slate-50 ${RING_CLS}`}
|
||||
className={`group -mx-2 flex items-start gap-2 rounded-lg px-2 py-2.5 transition hover:bg-ink/[0.03] ${RING_CLS}`}
|
||||
>
|
||||
<Bookmark
|
||||
aria-hidden
|
||||
className="mt-1 h-4 w-4 shrink-0 text-amber-500"
|
||||
className="mt-1 h-4 w-4 shrink-0 text-warning-ink"
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block whitespace-pre-wrap break-words text-sm font-medium leading-snug text-slate-800">
|
||||
<span className="block whitespace-pre-wrap break-words text-sm font-medium leading-snug text-ink">
|
||||
{bm.title}
|
||||
</span>
|
||||
<span className="mt-0.5 block truncate text-xs text-slate-500">
|
||||
<span className="mt-0.5 block truncate text-xs text-ink-soft">
|
||||
โดย {bm.userName || "ไม่ทราบชื่อ"}
|
||||
{parent ? ` · ใน: ${parent.title}` : ""}
|
||||
</span>
|
||||
{bm.note && (
|
||||
<span className="mt-0.5 flex items-start gap-1 text-xs leading-relaxed text-slate-500">
|
||||
<span className="mt-0.5 flex items-start gap-1 text-xs leading-relaxed text-ink-soft">
|
||||
<StickyNote
|
||||
aria-hidden
|
||||
className="mt-0.5 h-3 w-3 shrink-0 text-amber-400"
|
||||
className="mt-0.5 h-3 w-3 shrink-0 text-warning-ink"
|
||||
/>
|
||||
<span className="line-clamp-2 whitespace-pre-wrap break-words">
|
||||
{bm.note}
|
||||
|
|
@ -395,7 +397,7 @@ export default async function SummaryPage({
|
|||
</span>
|
||||
<ChevronRight
|
||||
aria-hidden
|
||||
className="mt-1.5 h-4 w-4 shrink-0 text-slate-300 group-hover:text-slate-400"
|
||||
className="mt-1.5 h-4 w-4 shrink-0 text-ink-faint group-hover:text-ink"
|
||||
/>
|
||||
</Link>
|
||||
</li>
|
||||
|
|
@ -410,35 +412,35 @@ export default async function SummaryPage({
|
|||
{b.anns.length > 0 && (
|
||||
<div className={b.bms.length > 0 ? "mt-3" : "mt-2"}>
|
||||
<CollapsibleBlock kind="annotation" count={b.anns.length}>
|
||||
<ul className="divide-y divide-slate-100">
|
||||
<ul className="divide-y divide-line/70">
|
||||
{b.anns.map((a) => (
|
||||
<li key={a.id} className="ml-4">
|
||||
<Link
|
||||
href={`/annotate/${id}?ann=${a.id}`}
|
||||
className={`group -mx-2 flex items-start gap-2 rounded-lg px-2 py-2.5 transition hover:bg-slate-50 ${RING_CLS}`}
|
||||
className={`group -mx-2 flex items-start gap-2 rounded-lg px-2 py-2.5 transition hover:bg-ink/[0.03] ${RING_CLS}`}
|
||||
>
|
||||
<AnnotationTypeIcon
|
||||
type={a.type}
|
||||
data={a.data}
|
||||
className="mt-1 h-4 w-4 shrink-0 text-slate-500"
|
||||
className="mt-1 h-4 w-4 shrink-0 text-ink-soft"
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block whitespace-pre-wrap break-words text-sm leading-snug text-slate-800">
|
||||
<span className="block whitespace-pre-wrap break-words text-sm leading-snug text-ink">
|
||||
{annotationPreviewText(a)}
|
||||
{a.type === "COMMENT" && a.resolved && (
|
||||
<span className="ml-1.5 whitespace-nowrap rounded border border-emerald-600/50 bg-emerald-50 px-1.5 py-0.5 text-xs text-emerald-700">
|
||||
<span className="ml-1.5 whitespace-nowrap rounded border border-success/40 bg-success/10 px-1.5 py-0.5 text-xs font-medium text-success-ink">
|
||||
จัดการแล้ว
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="mt-0.5 block text-xs text-slate-500">
|
||||
<span className="mt-0.5 block text-xs text-ink-soft">
|
||||
{annotationTypeLabel(a)} · โดย {a.userName}
|
||||
</span>
|
||||
{annotationNote(a) && (
|
||||
<span className="mt-0.5 flex items-start gap-1 text-xs leading-relaxed text-slate-500">
|
||||
<span className="mt-0.5 flex items-start gap-1 text-xs leading-relaxed text-ink-soft">
|
||||
<StickyNote
|
||||
aria-hidden
|
||||
className="mt-0.5 h-3 w-3 shrink-0 text-amber-400"
|
||||
className="mt-0.5 h-3 w-3 shrink-0 text-warning-ink"
|
||||
/>
|
||||
<span className="line-clamp-2 whitespace-pre-wrap break-words">
|
||||
{annotationNote(a)}
|
||||
|
|
@ -448,7 +450,7 @@ export default async function SummaryPage({
|
|||
</span>
|
||||
<ChevronRight
|
||||
aria-hidden
|
||||
className="mt-1.5 h-4 w-4 shrink-0 text-slate-300 group-hover:text-slate-400"
|
||||
className="mt-1.5 h-4 w-4 shrink-0 text-ink-faint group-hover:text-ink"
|
||||
/>
|
||||
</Link>
|
||||
</li>
|
||||
|
|
|
|||
|
|
@ -29,13 +29,13 @@ export default async function AnnotateEntryPage({
|
|||
<main className="mx-auto max-w-2xl px-4 py-10 sm:px-8">
|
||||
<div
|
||||
role="alert"
|
||||
className="rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-700"
|
||||
className="rounded-lg border border-error/30 bg-error/10 p-4 text-sm text-error-ink"
|
||||
>
|
||||
เปิดเอกสารจากลิงก์ไม่สำเร็จ: {result.error}
|
||||
</div>
|
||||
<Link
|
||||
href="/"
|
||||
className="mt-4 inline-block rounded-md bg-primary px-4 py-2 text-sm font-medium text-white transition hover:opacity-90"
|
||||
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"
|
||||
>
|
||||
กลับหน้ารายการ
|
||||
</Link>
|
||||
|
|
@ -46,15 +46,15 @@ export default async function AnnotateEntryPage({
|
|||
// ไม่มี fileUrl เลย — ไม่มีอะไรให้เปิด (route จริงของ viewer อยู่ที่ /annotate/[id])
|
||||
return (
|
||||
<main className="mx-auto max-w-2xl px-4 py-10 sm:px-8">
|
||||
<div className="rounded-xl border border-slate-200 bg-white p-6 text-sm text-slate-600">
|
||||
ไม่ได้ระบุลิงก์เอกสาร — ใช้ <code className="rounded bg-slate-100 px-1">
|
||||
<div className="rounded-xl border border-line bg-white p-6 text-sm text-ink-soft">
|
||||
ไม่ได้ระบุลิงก์เอกสาร — ใช้ <code className="rounded bg-ink/[0.05] px-1">
|
||||
/annotate?fileUrl=…&fileId=…&typeId=…
|
||||
</code>{" "}
|
||||
หรือเลือกไฟล์จากหน้ารายการ
|
||||
</div>
|
||||
<Link
|
||||
href="/"
|
||||
className="mt-4 inline-block rounded-md bg-primary px-4 py-2 text-sm font-medium text-white transition hover:opacity-90"
|
||||
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"
|
||||
>
|
||||
กลับหน้ารายการ
|
||||
</Link>
|
||||
|
|
|
|||
BIN
app/fonts/GeneralSans-Variable.woff2
Normal file
BIN
app/fonts/GeneralSans-Variable.woff2
Normal file
Binary file not shown.
114
app/globals.css
114
app/globals.css
|
|
@ -2,6 +2,22 @@
|
|||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* ─── Genesis utilities (docs/DESIGN.md 2026-09-23) ────────────────────────
|
||||
focus indicator = outline น้ำเงินทึบ 2px + offset 2px ของ design system — แยกเป็น
|
||||
class ร่วมเพื่อใช้ซ้ำทุกปุ่ม/ลิงก์; input ใช้ .genesis-input (ขอบน้ำเงิน + outline ตอน
|
||||
:focus ตาม spec Inputs) — ครอบด้วย @layer components ให้ Tailwind utilities ทับได้
|
||||
(UX review M3 2026-09-23: halo alpha 0.12 ของ spec เดิม blend บนขาวแล้ว ~1.2:1
|
||||
ต่ำกว่าเกณฑ์ 3:1 ของ WCAG 1.4.11 — alpha 0.25–0.35 ก็ยังไม่ผ่าน จึงเปลี่ยนเป็น
|
||||
outline ทึบเป็นตัวชี้วัด + offset คั่นไม่ให้จมกับพื้นปุ่ม primary) */
|
||||
@layer components {
|
||||
.focus-ring {
|
||||
@apply focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary;
|
||||
}
|
||||
.genesis-input {
|
||||
@apply rounded-md border border-line bg-white px-3.5 py-2 text-sm text-ink placeholder:text-ink-faint focus:border-primary focus:outline focus:outline-2 focus:outline-offset-1 focus:outline-primary;
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── TextLayer ของ pdfjs v4 ─────────────────────────────────────────────────
|
||||
คัดมาจาก node_modules/pdfjs-dist/web/pdf_viewer.css (ส่วนที่จำเป็น)
|
||||
ใช้กับ pdfjs.TextLayer ที่ render ลง div.textLayer ใน PDFViewer */
|
||||
|
|
@ -136,10 +152,15 @@
|
|||
inset: -4px;
|
||||
}
|
||||
|
||||
/* ปุ่ม bookmark/ลบในแผง selection (20×20) + swatch สีในแผง (16×16) */
|
||||
/* ปุ่ม bookmark/ลบในแผง selection (20×20) + swatch สีในแผง (16×16) —
|
||||
sel-panel-btn ใส่ centering แบบ .coarse-36 กันผู้ใช้ class ไม่ได้เอา flex
|
||||
มาเอง (ไอคอนลอยชิดบนในกล่อง 36px); ปุ่มที่ใช้อยู่มี flex แล้ว ผลลัพธ์ไม่เปลี่ยน */
|
||||
.sel-panel-btn {
|
||||
min-height: 2.25rem;
|
||||
min-width: 2.25rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.sel-panel .color-swatches button,
|
||||
.sel-panel .color-swatches label {
|
||||
|
|
@ -226,6 +247,59 @@
|
|||
main.summary-page button {
|
||||
min-height: 2.75rem;
|
||||
}
|
||||
|
||||
/* UX review M5 2026-09-23: ช่องกรอกทุกชนิด ≥16px บนจอสัมผัส — iOS Safari ซูมทั้ง
|
||||
หน้าทุกครั้งที่แตะโฟกัสช่องที่ตัวอักษรเล็กกว่า 16px (ทีมตั้งกติกาเดียวกันไว้
|
||||
หน้า login แล้ว — ขยายครบทุกช่องของแอปเฉพาะอุปกรณ์จอสัมผัส; !important จำเป็น
|
||||
เพราะช่องส่วนใหญ่กำหนดขนาดด้วย text-xs/text-[11px] ที่ specificity สูงกว่า)
|
||||
responsive audit 2026-09-24: ครอบ contentEditable ของ TEXTBOX editor ด้วย —
|
||||
fontSize default 2.5% ≈ 15px ทำ Safari ซูม viewport ตอนแตะเข้าโหมดแก้ไข
|
||||
(ตัวอักษรขยายเป็น 16px เฉพาะช่วงพิมพ์ ต่างจากผลลัพธ์ ~7% — trade-off มาตรฐาน
|
||||
ของ editor บน iOS เพราะ maximum-scale กัน focus-zoom ไม่ได้) */
|
||||
input,
|
||||
textarea,
|
||||
select,
|
||||
[contenteditable]:not([contenteditable="false"]) {
|
||||
font-size: 1rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* UX review M7 2026-09-23: ปุ่มหลักของหน้ารายการ/กลุ่ม (Annotate h-8, ลิงก์กลุ่ม,
|
||||
ออกจากระบบ) สูง 32px ต่ำกว่ามาตรฐาน touch 44px ที่ viewer/หน้าสรุปได้แล้ว —
|
||||
หน้าที่ผู้ใช้ iPad แตะบ่อยที่สุดก่อนเปิดเอกสารต้องได้เป้าเท่ากัน (ครอบ a/button
|
||||
ของ <main class="listing-page"> + header ของ AppNav; select/summary เป็น
|
||||
native control/มี flex อยู่แล้ว ใส่แค่ min-height)
|
||||
ครอบด้วย @layer components ให้ Tailwind utilities (flex/items-*) ทับได้ —
|
||||
กฎ unlayered ชนะทุก layer จนบังคับ align-items:center ทับ items-start ของ
|
||||
แถวลิงก์ในการ์ดกลุ่ม กลายเป็นไอคอนลอยกลางแถว 2–3 บรรทัด (code review 2026-09-23)
|
||||
+ justify-content:center คืนค่า default ของปุ่ม (text-align:center ของ UA
|
||||
หมดผลเมื่อเป็น flex) ไม่งั้นปุ่ม w-full เช่น "เปิดเอกสาร"/อัปโหลด ข้อความชิดซ้าย
|
||||
(คำขอผู้ใช้ 2026-09-23)
|
||||
responsive audit 2026-09-24: @layer ต้องครอบ @media จากด้านนอกเท่านั้น — ซ้อนกลับกัน
|
||||
(@media { @layer {…} }) แล้ว Tailwind v3 ยกเนื้อหา @layer ออกไปวางที่ components
|
||||
section โดยทิ้ง media wrapper กฎจึงหลุดไป apply บน desktop ด้วย (ยืนยันด้วยการ
|
||||
build จริง: ปุ่มหน้ารายการได้ min-height 44px + flex บนจอ desktop) */
|
||||
@layer components {
|
||||
@media (pointer: coarse) {
|
||||
main.listing-page a,
|
||||
main.listing-page button {
|
||||
min-height: 2.75rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
main.listing-page select,
|
||||
main.listing-page summary {
|
||||
min-height: 2.75rem;
|
||||
}
|
||||
header.appnav a,
|
||||
header.appnav button {
|
||||
min-height: 2.75rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── Print (หน้าสรุป) — design review 2026-09-17 L4 ───────────────────────
|
||||
|
|
@ -249,16 +323,18 @@
|
|||
}
|
||||
}
|
||||
|
||||
/* UX review 2026-09-14 H2 → typography audit 2026-09-16 (T1 + Option B): webfont
|
||||
Sarabun (next/font จาก layout.tsx — variable มี adjusted fallback ของ next/font
|
||||
ในตัวแล้ว) นำหน้า stack เพื่อแสดงผลเหมือนกันทุกอุปกรณ์; ที่เหลือเป็น fallback
|
||||
ตอนฟอนต์ยังไม่ถึง (display:swap)/โหลดไม่ได้ — เรียงแบบ Latin-first ตาม T1:
|
||||
Latin/ตัวเลขได้ UI font ของ OS (Segoe UI/SF) ส่วนไทยได้ Leelawadee UI
|
||||
(Windows) / Thonburi (iPad) / Noto Sans Thai (Android/Linux) */
|
||||
/* UX review 2026-09-14 H2 → typography audit 2026-09-16 (T1 + Option B) →
|
||||
Genesis 2026-09-23: DM Sans (next/font ตัวแปร Latin) นำหน้า stack ให้ตัว
|
||||
Latin/ตัวเลขได้บุคลิก body ของ design system ก่อน จากนั้น webfont Sarabun
|
||||
(next/font — มี adjusted fallback ของ next/font ในตัว) รับ glyph ไทยโดยเฉพาะ
|
||||
แสดงผลเหมือนกันทุกอุปกรณ์; ที่เหลือเป็น fallback ตอนฟอนต์ยังไม่ถึง
|
||||
(display:swap)/โหลดไม่ได้ — ไทยได้ Leelawadee UI (Windows) / Thonburi (iPad)
|
||||
/ Noto Sans Thai (Android/Linux) / heading ใช้คลาส font-display ที่นำ
|
||||
General Sans มาก่อน Sarabun ด้วยหลักการเดียวกัน */
|
||||
body {
|
||||
font-family: var(--font-sarabun, "Sarabun"), -apple-system, "Segoe UI",
|
||||
"Helvetica Neue", Arial, "Leelawadee UI", "Thonburi", "Noto Sans Thai",
|
||||
sans-serif;
|
||||
font-family: var(--font-dm-sans, "DM Sans"), var(--font-sarabun, "Sarabun"),
|
||||
-apple-system, "Segoe UI", "Helvetica Neue", Arial, "Leelawadee UI",
|
||||
"Thonburi", "Noto Sans Thai", sans-serif;
|
||||
}
|
||||
|
||||
/* M6: touch hygiene — ตัดแถบเทากระพริบของ iOS ทุกครั้งที่แตะปุ่ม และกัน
|
||||
|
|
@ -306,12 +382,13 @@ html {
|
|||
}
|
||||
|
||||
/* เส้นคั่นบางระหว่างแถวตั้งค่า (PanelRow) ที่ต่อกันในเมนู tool/แผง selection —
|
||||
ระยะ/เส้นเท่ากับแทรก <div class="my-1.5 border-t border-slate-100"> ไว้ระหว่างแถว
|
||||
ทุกจุด (แถวแรกของกลุ่มไม่มีเส้น — ไม่ซ้ำกับ divider ที่เมนูใส่ไว้เองแล้ว) */
|
||||
ระยะ/เส้นเท่ากับแทรก <div class="my-1.5 border-t border-line"> ไว้ระหว่างแถว
|
||||
ทุกจุด (แถวแรกของกลุ่มไม่มีเส้น — ไม่ซ้ำกับ divider ที่เมนูใส่ไว้เองแล้ว)
|
||||
(#E8E8EC = token line — เดิม #f1f5f9 เป็น slate-100 หลงเหลือก่อน Genesis) */
|
||||
.panel-row + .panel-row {
|
||||
margin-top: 0.375rem;
|
||||
padding-top: 0.625rem;
|
||||
border-top: 1px solid #f1f5f9;
|
||||
border-top: 1px solid #e8e8ec;
|
||||
}
|
||||
|
||||
/* M6: safe-area — defensive (ค่าเป็น 0 จนกว่าจะใส่ viewport-fit=cover ใน meta
|
||||
|
|
@ -325,7 +402,9 @@ header {
|
|||
|
||||
/* UX audit 2026-09-16 L7: เคารพ prefers-reduced-motion — ตัด transition ของ
|
||||
sidebar, animation กระพริบของปุ่ม export และ smooth scroll ทุกจุด
|
||||
(scrollToBelowHeader ฝั่ง JS เช็ค media query เดียวกันตอนเลือก behavior) */
|
||||
(scrollToBelowHeader ฝั่ง JS เช็ค media query เดียวกันตอนเลือก behavior)
|
||||
UX review m7 2026-09-23: เพิ่ม blanket rule ครอบ transition/animation ที่เหลือ
|
||||
(hover-lift ของการ์ด, scale ของสวอตช์สี ฯลฯ) — ยก motion ทั้งหน้าให้จบทันที */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
aside[data-sidebar] {
|
||||
transition: none;
|
||||
|
|
@ -333,7 +412,12 @@ header {
|
|||
.animate-pulse {
|
||||
animation: none;
|
||||
}
|
||||
* {
|
||||
*,
|
||||
::before,
|
||||
::after {
|
||||
transition-duration: 0.01ms !important;
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { asc, eq, inArray } from "drizzle-orm";
|
||||
import {
|
||||
ArrowLeft,
|
||||
|
|
@ -13,6 +13,7 @@ import {
|
|||
} from "lucide-react";
|
||||
|
||||
import { AnnotationTypeIcon } from "@/components/AnnotationTypeIcon";
|
||||
import AppNav from "@/components/AppNav";
|
||||
import { db } from "@/db";
|
||||
import {
|
||||
annotations,
|
||||
|
|
@ -30,6 +31,7 @@ import {
|
|||
} from "@/utils/annotation-display";
|
||||
import { formatThaiDate } from "@/utils/date-format";
|
||||
import { imageFormatFromUrl } from "@/utils/file-kind";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
|
||||
// N9 (PLAN-DOCSET.md D3) — หน้ารวมเอกสารของกลุ่ม (type_id จากลิงก์ e-Court)
|
||||
// server component force-dynamic — login ถูกบังคับโดย middleware.ts อยู่แล้ว
|
||||
|
|
@ -53,9 +55,9 @@ function mergedItems(b: FileBucket) {
|
|||
);
|
||||
}
|
||||
|
||||
/** focus ring ร่วมของลิงก์รายการในการ์ด (เดียวกับ RING_CLS ของหน้าสรุป) */
|
||||
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) เดียวกับหน้าสรุป */
|
||||
const RING_CLS = "focus-ring";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
|
|
@ -74,6 +76,10 @@ export default async function GroupPage({
|
|||
}) {
|
||||
const { typeId } = params;
|
||||
|
||||
// AppNav ต้องการชื่อผู้ใช้ — defense ซ้อน middleware กัน render หน้าให้คนไม่มี session
|
||||
const user = getSessionUser();
|
||||
if (!user) redirect("/login");
|
||||
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(files)
|
||||
|
|
@ -118,166 +124,173 @@ export default async function GroupPage({
|
|||
const bookmarkById = new Map(bmDtos.map((b) => [b.id, b] as const));
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-2xl px-4 py-10 sm:px-8">
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center gap-1.5 text-sm text-slate-500 transition hover:text-primary"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
หน้ารายการ
|
||||
</Link>
|
||||
<>
|
||||
<AppNav userName={user.userName} />
|
||||
{/* listing-page: hook ของกฎ coarse 44px ใน globals.css (อย่าถอด) */}
|
||||
<main className="listing-page mx-auto max-w-3xl px-4 py-8 sm:px-6">
|
||||
<Link
|
||||
href="/"
|
||||
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" />
|
||||
หน้ารายการ
|
||||
</Link>
|
||||
|
||||
<header className="mt-4 flex items-center gap-3">
|
||||
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-primary/10">
|
||||
<Folder className="h-5 w-5 text-primary" />
|
||||
<header className="mt-4 flex items-center gap-3">
|
||||
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-lg bg-ink/[0.04]">
|
||||
<Folder aria-hidden className="h-5 w-5 text-ink-soft" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h1 className="font-display break-all text-section-title text-ink">
|
||||
{typeId}
|
||||
</h1>
|
||||
<p className="mt-0.5 text-sm text-ink-soft">{rows.length} เอกสาร</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* หมายเหตุแสดงทุกครั้ง (แผน N9 D3): ระบบรู้จักเฉพาะเอกสารที่เคยเปิดผ่านลิงก์ e-Court
|
||||
รายการครบทั้งคดีอยู่ใน e-Court ไม่ใช่ระบบนี้ — Genesis: แผงข้อมูลกลาง 8px radius
|
||||
(น้ำเงินสงวนให้ของ interactive จึงเป็นเทาโปร่ง ไม่ใช่แบนเนอร์ฟ้า) */}
|
||||
<div className="mt-4 rounded-lg border border-line bg-ink/[0.03] px-4 py-3 text-sm text-ink-soft">
|
||||
แสดงเฉพาะเอกสารที่เคยเปิดในระบบนี้
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h1 className="break-all text-xl font-bold leading-tight text-primary">
|
||||
{typeId}
|
||||
</h1>
|
||||
<p className="text-sm text-slate-500">{rows.length} เอกสาร</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* หมายเหตุแสดงทุกครั้ง (แผน N9 D3): ระบบรู้จักเฉพาะเอกสารที่เคยเปิดผ่านลิงก์ e-Court
|
||||
รายการครบทั้งคดีอยู่ใน e-Court ไม่ใช่ระบบนี้ */}
|
||||
<div className="mt-4 rounded-xl border border-blue-200 bg-blue-50 p-4 text-sm text-blue-800">
|
||||
แสดงเฉพาะเอกสารที่เคยเปิดในระบบนี้
|
||||
</div>
|
||||
|
||||
<ul className="mt-6 space-y-3">
|
||||
{rows.map((f) => {
|
||||
const bucket = byFile.get(f.id) ?? { anns: [], bms: [] };
|
||||
const total = bucket.anns.length + bucket.bms.length;
|
||||
return (
|
||||
<li
|
||||
key={f.id}
|
||||
className="rounded-xl border border-slate-200 bg-white p-4 shadow-sm transition hover:border-primary/30 hover:shadow-md"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
||||
{imageFormatFromUrl(f.pdfUrl) ? (
|
||||
<FileImage className="h-5 w-5 text-primary" />
|
||||
) : (
|
||||
<FileText className="h-5 w-5 text-primary" />
|
||||
)}
|
||||
<ul className="mt-6 space-y-4">
|
||||
{rows.map((f) => {
|
||||
const bucket = byFile.get(f.id) ?? { anns: [], bms: [] };
|
||||
const total = bucket.anns.length + bucket.bms.length;
|
||||
return (
|
||||
<li
|
||||
key={f.id}
|
||||
className="rounded-xl border border-line bg-white p-5"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-ink/[0.04]">
|
||||
{imageFormatFromUrl(f.pdfUrl) ? (
|
||||
<FileImage aria-hidden className="h-5 w-5 text-ink-soft" />
|
||||
) : (
|
||||
<FileText aria-hidden className="h-5 w-5 text-ink-soft" />
|
||||
)}
|
||||
</div>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block break-all line-clamp-2 text-body font-medium text-ink">
|
||||
{f.title}
|
||||
</span>
|
||||
{/* วันที่/จำนวนเป็นข้อมูลที่อ่านจริง ใช้ ink-soft (ผ่าน 4.5:1) —
|
||||
ink-faint เก็บไว้ให้ placeholder/disabled เท่านั้น (UX review M2) */}
|
||||
<span className="mt-0.5 block text-caption text-ink-soft">
|
||||
นำเข้า {formatThaiDate(f.createdAt, true)} · Annotations{" "}
|
||||
{bucket.anns.length} · Bookmarks {bucket.bms.length}
|
||||
</span>
|
||||
</span>
|
||||
<Link
|
||||
href={`/annotate/${f.id}`}
|
||||
className="focus-ring inline-flex h-8 shrink-0 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
|
||||
</Link>
|
||||
</div>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block break-all line-clamp-2 text-sm font-medium text-slate-900">
|
||||
{f.title}
|
||||
</span>
|
||||
<span className="mt-0.5 block text-xs text-slate-500">
|
||||
นำเข้า {formatThaiDate(f.createdAt, true)} · Annotations{" "}
|
||||
{bucket.anns.length} · Bookmarks {bucket.bms.length}
|
||||
</span>
|
||||
</span>
|
||||
<Link
|
||||
href={`/annotate/${f.id}`}
|
||||
className="shrink-0 rounded-md bg-primary px-4 py-2 text-sm font-medium text-white transition hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1"
|
||||
>
|
||||
Annotate
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{total > 0 && (
|
||||
<details className="group mt-3 border-t border-slate-100">
|
||||
<summary className="flex cursor-pointer list-none items-center justify-between rounded-md px-1 py-2 text-sm font-medium text-slate-700 transition hover:text-primary [&::-webkit-details-marker]:hidden">
|
||||
Annotations & Bookmarks ({total})
|
||||
<ChevronDown
|
||||
aria-hidden
|
||||
className="h-4 w-4 shrink-0 text-slate-400 transition-transform group-open:rotate-180 motion-reduce:transition-none"
|
||||
/>
|
||||
</summary>
|
||||
<ul className="divide-y divide-slate-100">
|
||||
{mergedItems(bucket).map((item) => {
|
||||
const parent =
|
||||
item.kind === "bm" && item.dto.parentId
|
||||
? bookmarkById.get(item.dto.parentId)
|
||||
: undefined;
|
||||
return item.kind === "bm" ? (
|
||||
<li key={item.dto.id}>
|
||||
<Link
|
||||
href={`/annotate/${f.id}?page=${item.pageIndex + 1}`}
|
||||
className={`-mx-2 flex items-start gap-2 rounded-lg px-2 py-2.5 transition hover:bg-slate-50 ${RING_CLS}`}
|
||||
>
|
||||
<Bookmark
|
||||
aria-hidden
|
||||
className="mt-1 h-4 w-4 shrink-0 text-amber-500"
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block whitespace-pre-wrap break-words text-sm font-medium leading-snug text-slate-800">
|
||||
{item.dto.title}
|
||||
</span>
|
||||
<span className="mt-0.5 block truncate text-xs text-slate-500">
|
||||
หน้า {item.pageIndex + 1} ·{" "}
|
||||
{formatThaiDate(item.dto.createdAt, true)} · โดย{" "}
|
||||
{item.dto.userName || "ไม่ทราบชื่อ"}
|
||||
{parent ? ` · ใน: ${parent.title}` : ""}
|
||||
</span>
|
||||
{item.dto.note && (
|
||||
<span className="mt-0.5 flex items-start gap-1 text-xs leading-relaxed text-slate-500">
|
||||
<StickyNote
|
||||
aria-hidden
|
||||
className="mt-0.5 h-3 w-3 shrink-0 text-amber-400"
|
||||
/>
|
||||
<span className="line-clamp-2 whitespace-pre-wrap break-words">
|
||||
{item.dto.note}
|
||||
</span>
|
||||
{total > 0 && (
|
||||
<details className="group mt-3 border-t border-line/70">
|
||||
<summary className="flex cursor-pointer list-none items-center justify-between rounded-md px-1 py-2.5 text-sm font-medium text-ink-soft transition hover:text-primary [&::-webkit-details-marker]:hidden">
|
||||
Annotations & Bookmarks ({total})
|
||||
<ChevronDown
|
||||
aria-hidden
|
||||
className="h-4 w-4 shrink-0 text-ink-faint transition-transform group-open:rotate-180 motion-reduce:transition-none"
|
||||
/>
|
||||
</summary>
|
||||
<ul className="divide-y divide-line/70">
|
||||
{mergedItems(bucket).map((item) => {
|
||||
const parent =
|
||||
item.kind === "bm" && item.dto.parentId
|
||||
? bookmarkById.get(item.dto.parentId)
|
||||
: undefined;
|
||||
return item.kind === "bm" ? (
|
||||
<li key={item.dto.id}>
|
||||
<Link
|
||||
href={`/annotate/${f.id}?page=${item.pageIndex + 1}`}
|
||||
className={`-mx-2 flex items-start gap-2 rounded-lg px-2 py-2.5 transition hover:bg-ink/[0.03] ${RING_CLS}`}
|
||||
>
|
||||
<Bookmark
|
||||
aria-hidden
|
||||
className="mt-1 h-4 w-4 shrink-0 text-warning-ink"
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block whitespace-pre-wrap break-words text-sm font-medium leading-snug text-ink">
|
||||
{item.dto.title}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
) : (
|
||||
<li key={item.dto.id}>
|
||||
<Link
|
||||
href={`/annotate/${f.id}?ann=${item.dto.id}`}
|
||||
className={`-mx-2 flex items-start gap-2 rounded-lg px-2 py-2.5 transition hover:bg-slate-50 ${RING_CLS}`}
|
||||
>
|
||||
<AnnotationTypeIcon
|
||||
type={item.dto.type}
|
||||
data={item.dto.data}
|
||||
className="mt-1 h-4 w-4 shrink-0 text-slate-500"
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block whitespace-pre-wrap break-words text-sm leading-snug text-slate-800">
|
||||
{annotationPreviewText(item.dto)}
|
||||
{item.dto.type === "COMMENT" &&
|
||||
item.dto.resolved && (
|
||||
<span className="ml-1.5 whitespace-nowrap rounded border border-emerald-600/50 bg-emerald-50 px-1.5 py-0.5 text-xs text-emerald-700">
|
||||
จัดการแล้ว
|
||||
<span className="mt-0.5 block truncate text-xs text-ink-soft">
|
||||
หน้า {item.pageIndex + 1} ·{" "}
|
||||
{formatThaiDate(item.dto.createdAt, true)} · โดย{" "}
|
||||
{item.dto.userName || "ไม่ทราบชื่อ"}
|
||||
{parent ? ` · ใน: ${parent.title}` : ""}
|
||||
</span>
|
||||
{item.dto.note && (
|
||||
<span className="mt-0.5 flex items-start gap-1 text-xs leading-relaxed text-ink-soft">
|
||||
<StickyNote
|
||||
aria-hidden
|
||||
className="mt-0.5 h-3 w-3 shrink-0 text-warning-ink"
|
||||
/>
|
||||
<span className="line-clamp-2 whitespace-pre-wrap break-words">
|
||||
{item.dto.note}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="mt-0.5 block text-xs text-slate-500">
|
||||
หน้า {item.pageIndex + 1} ·{" "}
|
||||
{annotationTypeLabel(item.dto)} ·{" "}
|
||||
{formatThaiDate(item.dto.createdAt, true)} · โดย{" "}
|
||||
{item.dto.userName}
|
||||
</span>
|
||||
{annotationNote(item.dto) && (
|
||||
<span className="mt-0.5 flex items-start gap-1 text-xs leading-relaxed text-slate-500">
|
||||
<StickyNote
|
||||
aria-hidden
|
||||
className="mt-0.5 h-3 w-3 shrink-0 text-amber-400"
|
||||
/>
|
||||
<span className="line-clamp-2 whitespace-pre-wrap break-words">
|
||||
{annotationNote(item.dto)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
) : (
|
||||
<li key={item.dto.id}>
|
||||
<Link
|
||||
href={`/annotate/${f.id}?ann=${item.dto.id}`}
|
||||
className={`-mx-2 flex items-start gap-2 rounded-lg px-2 py-2.5 transition hover:bg-ink/[0.03] ${RING_CLS}`}
|
||||
>
|
||||
<AnnotationTypeIcon
|
||||
type={item.dto.type}
|
||||
data={item.dto.data}
|
||||
className="mt-1 h-4 w-4 shrink-0 text-ink-soft"
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block whitespace-pre-wrap break-words text-sm leading-snug text-ink">
|
||||
{annotationPreviewText(item.dto)}
|
||||
{item.dto.type === "COMMENT" &&
|
||||
item.dto.resolved && (
|
||||
<span className="ml-1.5 whitespace-nowrap rounded border border-success/40 bg-success/10 px-1.5 py-0.5 text-xs font-medium text-success-ink">
|
||||
จัดการแล้ว
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</details>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</main>
|
||||
<span className="mt-0.5 block text-xs text-ink-soft">
|
||||
หน้า {item.pageIndex + 1} ·{" "}
|
||||
{annotationTypeLabel(item.dto)} ·{" "}
|
||||
{formatThaiDate(item.dto.createdAt, true)} · โดย{" "}
|
||||
{item.dto.userName}
|
||||
</span>
|
||||
{annotationNote(item.dto) && (
|
||||
<span className="mt-0.5 flex items-start gap-1 text-xs leading-relaxed text-ink-soft">
|
||||
<StickyNote
|
||||
aria-hidden
|
||||
className="mt-0.5 h-3 w-3 shrink-0 text-warning-ink"
|
||||
/>
|
||||
<span className="line-clamp-2 whitespace-pre-wrap break-words">
|
||||
{annotationNote(item.dto)}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</details>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<html lang="th" className={sarabun.variable}>
|
||||
<body className="bg-slate-100 text-slate-900 antialiased">{children}</body>
|
||||
<html
|
||||
lang="th"
|
||||
className={`${generalSans.variable} ${dmSans.variable} ${sarabun.variable}`}
|
||||
>
|
||||
<body className="bg-canvas font-body text-ink antialiased">{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<main className="flex min-h-dvh items-center justify-center px-4 py-10">
|
||||
// Genesis: กริดจุดอันตรกิริยา ( decorative element ชิ้นเดียวที่อนุญาต) เป็นพื้นหลัง
|
||||
// หน้า login — DotGrid อยู่ก่อนการ์ดใน DOM จึงถูกทับโดยการ์ดเอง ไม่ต้องเล่น z-index
|
||||
<main className="relative flex min-h-dvh items-center justify-center px-4 py-10">
|
||||
<DotGrid />
|
||||
<LoginForm next={next} />
|
||||
</main>
|
||||
);
|
||||
|
|
|
|||
261
app/page.tsx
261
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 (
|
||||
<li className="group flex items-center gap-3 rounded-xl border border-slate-200 bg-white p-4 shadow-sm transition hover:border-primary/30 hover:shadow-md">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
||||
{imageFormatFromUrl(file.pdfUrl) ? (
|
||||
<FileImage className="h-5 w-5 text-primary" />
|
||||
) : (
|
||||
<FileText className="h-5 w-5 text-primary" />
|
||||
)}
|
||||
<li className="flex flex-col rounded-xl border border-line bg-white p-5 transition duration-200 hover:-translate-y-0.5 hover:shadow-card-hover">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-lg bg-ink/[0.04]">
|
||||
{imageFormatFromUrl(file.pdfUrl) ? (
|
||||
<FileImage aria-hidden className="h-5 w-5 text-ink-soft" />
|
||||
) : (
|
||||
<FileText aria-hidden className="h-5 w-5 text-ink-soft" />
|
||||
)}
|
||||
</div>
|
||||
<span className="rounded bg-ink/[0.05] px-1.5 py-0.5 text-[11px] font-medium text-ink-soft">
|
||||
{kindBadge(file.pdfUrl)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block break-all line-clamp-2 text-sm font-medium text-slate-900">
|
||||
{file.title}
|
||||
</span>
|
||||
<span className="mt-0.5 block text-xs text-slate-500">
|
||||
<span className="mr-1.5 rounded bg-slate-100 px-1 py-0.5 font-medium text-slate-500">
|
||||
{kindBadge(file.pdfUrl)}
|
||||
</span>
|
||||
{formatThaiDate(file.createdAt, true)}
|
||||
</span>
|
||||
<span className="mt-4 block break-all line-clamp-2 text-body font-medium text-ink">
|
||||
{file.title}
|
||||
</span>
|
||||
{/* ปุ่มเข้า annotate แทนลิงก์กางเต็มการ์ด (M4) ตาม feedback — เป้าจิ้ม 36px (H4) */}
|
||||
<Link
|
||||
href={`/annotate/${file.id}`}
|
||||
className="shrink-0 rounded-md bg-primary px-4 py-2 text-sm font-medium text-white transition hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1"
|
||||
>
|
||||
Annotate
|
||||
</Link>
|
||||
{/* วันที่เป็นข้อมูลที่ผู้ใช้อ่านจริง — ink-soft (ผ่าน 4.5:1); ink-faint เก็บไว้
|
||||
ให้ placeholder/disabled เท่านั้น (UX review M2 2026-09-23) */}
|
||||
<span className="mt-1 block text-caption text-ink-soft">
|
||||
{formatThaiDate(file.createdAt, true)}
|
||||
</span>
|
||||
{/* ปุ่มเข้า annotate แทนลิงก์กางเต็มการ์ด (M4) ตาม feedback — เป็น secondary
|
||||
(หนึ่ง primary ต่อ section ตาม Genesis; การ์ดทั้งกริดคือ passive surface) */}
|
||||
<div className="mt-5 flex flex-1 items-end justify-end">
|
||||
<Link
|
||||
href={`/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
|
||||
</Link>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
|
@ -113,103 +120,117 @@ export default async function HomePage({
|
|||
);
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-2xl px-4 py-10 sm:px-8">
|
||||
<header className="flex items-center gap-3">
|
||||
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-primary text-base font-bold text-white shadow-sm">
|
||||
JN
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold leading-tight text-primary">
|
||||
Judge's Note
|
||||
</h1>
|
||||
<p className="text-sm text-slate-500">
|
||||
ระบบ PDF Annotation สำหรับ e-Court
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
<>
|
||||
<AppNav userName={user.userName} />
|
||||
{/* listing-page: hook ของกฎ coarse 44px ใน globals.css (อย่าถอด — เหมือน
|
||||
summary-page ของหน้าสรุป) */}
|
||||
<main className="listing-page mx-auto max-w-7xl px-4 pb-16 pt-10 sm:px-6">
|
||||
{/* ชื่อระบบ Latin ล้วน → General Sans tracking แน่นตาม Genesis display type */}
|
||||
<h1 className="font-display text-4xl tracking-tight text-ink sm:text-headline">
|
||||
Judge's Note
|
||||
</h1>
|
||||
<p className="mt-2 text-body text-ink-soft">
|
||||
ระบบ PDF Annotation สำหรับ e-Court
|
||||
</p>
|
||||
|
||||
<SessionBar userName={user.userName} />
|
||||
|
||||
{registerError && (
|
||||
<div
|
||||
role="alert"
|
||||
className="mt-6 rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-700"
|
||||
>
|
||||
เปิดเอกสารจากลิงก์ไม่สำเร็จ: {registerError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* การ์ดเปิดเอกสารจากลิงก์ e-Court — บล็อกแยกของตัวเอง มีหัวเรื่องในตัวแล้ว (N8) */}
|
||||
<EcourtLinkForm />
|
||||
|
||||
<section className="mt-8">
|
||||
<h2 className="text-sm font-semibold text-slate-700">เพิ่มเอกสาร</h2>
|
||||
<UploadFileForm />
|
||||
</section>
|
||||
|
||||
<div className="mt-8 flex items-baseline justify-between">
|
||||
<h2 className="text-sm font-semibold text-slate-700">เอกสาร</h2>
|
||||
{rows.length > 0 && (
|
||||
<span className="text-xs tabular-nums text-slate-500">{rows.length} ไฟล์</span>
|
||||
{registerError && (
|
||||
<div
|
||||
role="alert"
|
||||
className="mt-6 rounded-lg border border-error/25 bg-error/5 p-4 text-sm text-error-ink"
|
||||
>
|
||||
เปิดเอกสารจากลิงก์ไม่สำเร็จ: {registerError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{rows.length === 0 ? (
|
||||
<div className="mt-3 rounded-xl border border-dashed border-slate-300 bg-white/60 p-10 text-center">
|
||||
<FileText className="mx-auto h-8 w-8 text-slate-300" />
|
||||
<p className="mt-3 text-sm font-medium text-slate-500">
|
||||
ยังไม่มีไฟล์ในระบบ
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-slate-500">
|
||||
รัน /sync-db เพื่อ seed ไฟล์ตัวอย่าง
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{sections.map(([typeId, list]) => (
|
||||
<section key={typeId} className="mt-6">
|
||||
<div className="flex items-center gap-2 border-b border-slate-200 pb-2">
|
||||
<Folder className="h-4 w-4 shrink-0 text-primary" />
|
||||
<span className="min-w-0 break-all text-sm font-semibold text-slate-700">
|
||||
{typeId}
|
||||
</span>
|
||||
<span className="shrink-0 text-xs tabular-nums text-slate-500">
|
||||
{list.length} ไฟล์
|
||||
</span>
|
||||
{/* Next Link เติม basePath ให้เอง (invariant #12) */}
|
||||
<Link
|
||||
href={`/groups/${encodeURIComponent(typeId)}`}
|
||||
className="ml-auto shrink-0 rounded text-xs font-medium text-primary hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1"
|
||||
>
|
||||
เอกสารในกลุ่ม
|
||||
</Link>
|
||||
</div>
|
||||
<ul className="mt-3 space-y-3">
|
||||
{list.map((f) => (
|
||||
<FileCard key={f.id} file={f} />
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
{ungrouped.length > 0 && (
|
||||
<section className="mt-6">
|
||||
<div className="flex items-center gap-2 border-b border-slate-200 pb-2">
|
||||
<span className="text-sm font-semibold text-slate-700">
|
||||
ไม่มีกลุ่ม
|
||||
</span>
|
||||
<span className="text-xs tabular-nums text-slate-500">
|
||||
{ungrouped.length} ไฟล์
|
||||
</span>
|
||||
</div>
|
||||
<ul className="mt-3 space-y-3">
|
||||
{ungrouped.map((f) => (
|
||||
<FileCard key={f.id} file={f} />
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
{/* การ์ดจำลองลิงก์ e-Court (N8) อยู่เหนือรายการเอกสาร — เป็นทางเข้าเปิดเอกสาร
|
||||
จากระบบ e-Court ไม่ใช่การเพิ่มไฟล์เข้ารายการ (คุมความกว้างเดียวกับ
|
||||
ฟอร์มอัปโหลด) */}
|
||||
<section className="mt-12">
|
||||
<div className="border-b border-line pb-3">
|
||||
<h2 className="font-display text-section-title text-ink">
|
||||
เปิดเอกสารจากลิงก์ e-Court
|
||||
</h2>
|
||||
</div>
|
||||
<div className="mt-5 max-w-xl">
|
||||
<EcourtLinkForm />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-12">
|
||||
<div className="flex items-baseline justify-between gap-3 border-b border-line pb-3">
|
||||
<h2 className="font-display text-section-title text-ink">เอกสาร</h2>
|
||||
{rows.length > 0 && (
|
||||
<span className="text-caption tabular-nums text-ink-soft">
|
||||
{rows.length} ไฟล์
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* อัปโหลดไฟล์ (N4) อยู่กับรายการเอกสาร — ไฟล์ที่อัปโหลดเข้ารายการนี้เอง
|
||||
(คุมความกว้างไม่ให้ฟอร์มกินเต็มความกว้างของกริดรายการ) */}
|
||||
<div className="mt-5 max-w-xl">
|
||||
<UploadFileForm />
|
||||
</div>
|
||||
|
||||
{rows.length === 0 ? (
|
||||
<div className="mt-5 rounded-xl border border-dashed border-line bg-white/60 p-12 text-center">
|
||||
<FileText aria-hidden className="mx-auto h-8 w-8 text-ink-faint" />
|
||||
<p className="mt-3 text-sm font-medium text-ink-soft">
|
||||
ยังไม่มีไฟล์ในระบบ
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-ink-soft">
|
||||
รัน /sync-db เพื่อ seed ไฟล์ตัวอย่าง
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{sections.map(([typeId, list]) => (
|
||||
<section key={typeId} className="mt-8">
|
||||
<div className="flex flex-wrap items-center gap-x-2.5 gap-y-1">
|
||||
<Folder
|
||||
aria-hidden
|
||||
className="h-4 w-4 shrink-0 text-ink-soft"
|
||||
/>
|
||||
<h3 className="font-display min-w-0 break-all text-subhead text-ink">
|
||||
{typeId}
|
||||
</h3>
|
||||
<span className="text-caption tabular-nums text-ink-soft">
|
||||
{list.length} ไฟล์
|
||||
</span>
|
||||
{/* Next Link เติม basePath ให้เอง (invariant #12) */}
|
||||
<Link
|
||||
href={`/groups/${encodeURIComponent(typeId)}`}
|
||||
className="focus-ring ml-auto shrink-0 rounded text-sm font-medium text-primary hover:underline"
|
||||
>
|
||||
เอกสารในกลุ่ม
|
||||
</Link>
|
||||
</div>
|
||||
<ul className="mt-4 grid gap-5 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{list.map((f) => (
|
||||
<FileCard key={f.id} file={f} />
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
{ungrouped.length > 0 && (
|
||||
<section className="mt-8">
|
||||
<div className="flex flex-wrap items-center gap-x-2.5 gap-y-1">
|
||||
<h3 className="font-display text-subhead text-ink">ไม่มีกลุ่ม</h3>
|
||||
<span className="text-caption tabular-nums text-ink-soft">
|
||||
{ungrouped.length} ไฟล์
|
||||
</span>
|
||||
</div>
|
||||
<ul className="mt-4 grid gap-5 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{ungrouped.map((f) => (
|
||||
<FileCard key={f.id} file={f} />
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
</section>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"];
|
||||
|
|
|
|||
36
components/AppNav.tsx
Normal file
36
components/AppNav.tsx
Normal file
|
|
@ -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 — อย่าถอด)
|
||||
<header className="appnav sticky top-0 z-40 h-14 border-b border-line bg-white/80 backdrop-blur">
|
||||
<div className="mx-auto flex h-full max-w-7xl items-center justify-between gap-3 px-4 sm:px-6">
|
||||
{/* ชื่อแบรนด์เป็น ink ตามกฎ "น้ำเงินเฉพาะของ interactive" — tile JN น้ำเงินคือ
|
||||
แบรนด์มาร์ก (สืบต่อจากหน้าสรุป/login ดั้งเดิม) ทั้งแถบเป็นลิงก์กลับหน้ารายการ */}
|
||||
<Link
|
||||
href="/"
|
||||
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">
|
||||
JN
|
||||
</span>
|
||||
<span className="font-display truncate text-[15px] font-bold tracking-tight text-ink">
|
||||
Judge's Note
|
||||
</span>
|
||||
</Link>
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<p className="hidden min-w-0 truncate text-sm text-ink-soft sm:block">
|
||||
{userName}
|
||||
</p>
|
||||
<LogoutButton />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
38
components/DotGrid.tsx
Normal file
38
components/DotGrid.tsx
Normal file
|
|
@ -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<HTMLDivElement>(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 (
|
||||
<div
|
||||
ref={ref}
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0 -z-10 [background-image:radial-gradient(circle,rgba(10,10,10,0.10)_1px,transparent_1px)] [background-size:24px_24px] [-webkit-mask-image:radial-gradient(72%_58%_at_50%_42%,black,transparent)] [mask-image:radial-gradient(72%_58%_at_50%_42%,black,transparent)]"
|
||||
>
|
||||
<div className="absolute inset-0 [background-image:radial-gradient(280px_circle_at_var(--dot-mx,50%)_var(--dot-my,36%),rgba(0,51,161,0.07),transparent_72%)]" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<form
|
||||
onSubmit={onSubmit}
|
||||
className="mt-8 rounded-xl border border-dashed border-slate-300 bg-white/60 p-4"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link2 className="h-4 w-4 shrink-0 text-primary" />
|
||||
<p className="text-sm font-medium text-slate-700">เปิดเอกสารจากลิงก์ e-Court</p>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-x-2 gap-y-3 sm:grid-cols-2">
|
||||
<form onSubmit={onSubmit} className="flex flex-col rounded-xl border border-line bg-white p-5">
|
||||
{/* ไม่มีหัวการ์ดซ้ำ — h2 "เปิดเอกสารจากลิงก์ e-Court" ของ section ในหน้ารายการ
|
||||
เป็นหัวเดียวพอแล้ว (responsive audit 2026-09-24: เดิมซ้ำสองชั้นติดกัน) */}
|
||||
<div className="grid gap-x-4 gap-y-3 sm:grid-cols-2">
|
||||
<div className="sm:col-span-2">
|
||||
<label
|
||||
htmlFor="ecourt-file-url"
|
||||
className="block text-xs font-medium text-slate-600"
|
||||
>
|
||||
<label htmlFor="ecourt-file-url" className="block text-sm font-medium text-ink">
|
||||
ลิงก์ไฟล์
|
||||
</label>
|
||||
<input
|
||||
|
|
@ -74,14 +67,11 @@ export default function EcourtLinkForm() {
|
|||
value={fileUrl}
|
||||
onChange={(e) => setFileUrl(e.target.value)}
|
||||
placeholder="เช่น /samples/ชื่อไฟล์.pdf หรือ https://…"
|
||||
className={`${inputClass} mt-1 w-full`}
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="ecourt-file-id"
|
||||
className="block text-xs font-medium text-slate-600"
|
||||
>
|
||||
<label htmlFor="ecourt-file-id" className="block text-sm font-medium text-ink">
|
||||
รหัสเอกสาร fileId
|
||||
</label>
|
||||
<input
|
||||
|
|
@ -90,14 +80,11 @@ export default function EcourtLinkForm() {
|
|||
name="fileId"
|
||||
value={fileId}
|
||||
onChange={(e) => setFileId(e.target.value)}
|
||||
className={`${inputClass} mt-1 w-full`}
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="ecourt-type-id"
|
||||
className="block text-xs font-medium text-slate-600"
|
||||
>
|
||||
<label htmlFor="ecourt-type-id" className="block text-sm font-medium text-ink">
|
||||
กลุ่ม typeId
|
||||
</label>
|
||||
<input
|
||||
|
|
@ -107,25 +94,25 @@ export default function EcourtLinkForm() {
|
|||
value={typeId}
|
||||
onChange={(e) => setTypeId(e.target.value)}
|
||||
placeholder="เช่น 1"
|
||||
className={`${inputClass} mt-1 w-full`}
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="mt-3 w-full rounded-md bg-primary px-4 py-2.5 text-sm font-medium text-white transition hover:opacity-90"
|
||||
className="focus-ring mt-4 w-full rounded-md border border-primary/30 px-4 py-2 text-sm font-medium text-primary transition hover:-translate-y-px hover:border-primary hover:bg-primary/5"
|
||||
>
|
||||
เปิดเอกสาร
|
||||
</button>
|
||||
<p className="mt-2 text-xs text-slate-500">
|
||||
<p className="mt-3 text-xs leading-relaxed text-ink-soft">
|
||||
กดแล้วระบบลงทะเบียนเอกสาร (ถ้ายังไม่มี) แล้วพาเข้าหน้าอ่าน/มาร์กเอกสารทันที —
|
||||
ไฟล์รีโมตรับเฉพาะ https ของโฮสต์ที่อนุญาต
|
||||
</p>
|
||||
<p className="mt-2 break-all rounded-md bg-slate-50 px-3 py-2 text-xs text-slate-500">
|
||||
<span className="font-medium text-slate-600">ลิงก์เต็ม: </span>
|
||||
<p className="mt-3 break-all rounded-lg bg-ink/[0.04] px-3 py-2.5 text-xs text-ink-soft">
|
||||
<span className="font-medium text-ink">ลิงก์เต็ม: </span>
|
||||
{fullLink || "…"}
|
||||
</p>
|
||||
{error && <p className="mt-1 text-xs text-red-600">{error}</p>}
|
||||
{error && <p className="mt-2 text-xs text-error-ink">{error}</p>}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="w-full max-w-sm rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
|
||||
{/* brand lockup เดียวกับ header หน้ารายการ (ลดขนาดตัวอักษรให้พอการ์ดแคบ) */}
|
||||
<div className="w-full max-w-sm rounded-xl border border-line bg-white p-8">
|
||||
{/* brand lockup เดียวกับ nav หน้ารายการ — tile JN น้ำเงินคือแบรนด์มาร์กของระบบ
|
||||
(หน้าสรุปแถบน้ำเงิน 2026-09-17 สืบต่อจาก tile นี้) ชื่อแบรนด์เป็น ink ตามกฎ
|
||||
"blue เฉพาะของ interactive" ของ Genesis */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-primary text-base font-bold text-white shadow-sm">
|
||||
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-lg bg-primary text-base font-bold text-white">
|
||||
JN
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-lg font-bold leading-tight text-primary">Judge's Note</p>
|
||||
<p className="text-xs text-slate-500">ระบบ PDF Annotation สำหรับ e-Court</p>
|
||||
<div className="min-w-0">
|
||||
<p className="font-display text-lg font-bold leading-tight text-ink">
|
||||
Judge's Note
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-ink-soft">
|
||||
ระบบ PDF Annotation สำหรับ e-Court
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1 className="mt-6 text-lg font-semibold text-slate-900">เข้าสู่ระบบ</h1>
|
||||
<h1 className="font-display mt-8 text-subhead text-ink">เข้าสู่ระบบ</h1>
|
||||
|
||||
<form onSubmit={onSubmit} className="mt-4">
|
||||
<form onSubmit={onSubmit} className="mt-5">
|
||||
<div>
|
||||
<label htmlFor="username" className="text-sm font-semibold text-slate-700">
|
||||
<label htmlFor="username" className="block text-sm font-medium text-ink">
|
||||
ชื่อผู้ใช้
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
|
|
@ -92,7 +101,7 @@ export default function LoginForm({ next }: { next: string | null }) {
|
|||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<label htmlFor="password" className="text-sm font-semibold text-slate-700">
|
||||
<label htmlFor="password" className="block text-sm font-medium text-ink">
|
||||
รหัสผ่าน
|
||||
</label>
|
||||
<input
|
||||
|
|
@ -111,19 +120,20 @@ export default function LoginForm({ next }: { next: string | null }) {
|
|||
<button
|
||||
type="submit"
|
||||
disabled={pending}
|
||||
className="mt-6 w-full rounded-md bg-primary px-4 py-2.5 text-sm font-medium text-white transition hover:opacity-90 disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1"
|
||||
className="focus-ring mt-6 w-full rounded-md bg-primary px-4 py-2.5 text-sm font-medium text-white transition hover:-translate-y-px hover:bg-primary-hover hover:shadow-btn-glow disabled:pointer-events-none disabled:opacity-50"
|
||||
>
|
||||
{pending ? "กำลังตรวจสอบ…" : "เข้าสู่ระบบ"}
|
||||
</button>
|
||||
|
||||
{/* รหัสผ่านทดสอบ — รายชื่อเห็นใน dropdown แล้ว เหลือบอกแค่รหัสผ่าน (mock ไม่มีระบบสมัคร) */}
|
||||
<p className="mt-5 rounded-md bg-slate-50 px-3 py-2 text-xs text-slate-500">
|
||||
{/* รหัสผ่านทดสอบ — รายชื่อเห็นใน dropdown แล้ว เหลือบอกแค่รหัสผ่าน (mock ไม่มีระบบสมัคร)
|
||||
กล่อง metadata 8px radius ตาม Genesis (พื้นเทาโปร่งจาง ไม่ใช่การเน้น) */}
|
||||
<p className="mt-5 rounded-lg bg-ink/[0.04] px-3 py-2.5 text-xs leading-relaxed text-ink-soft">
|
||||
รหัสผ่านทดสอบ:{" "}
|
||||
<span className="font-semibold text-slate-700">Demo1234</span>
|
||||
<span className="font-semibold text-ink">Demo1234</span>
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<p role="alert" className="mt-3 text-xs text-red-600">
|
||||
<p role="alert" className="mt-3 text-xs text-error-ink">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ export default function LogoutButton() {
|
|||
type="button"
|
||||
onClick={onLogout}
|
||||
disabled={pending}
|
||||
className="flex shrink-0 items-center gap-1.5 rounded-md border border-slate-300 px-3 py-2 text-xs font-medium text-slate-600 transition hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1 disabled:opacity-50"
|
||||
className="focus-ring flex h-8 shrink-0 items-center gap-1.5 rounded-md border border-line px-3 text-xs font-medium text-ink-soft transition hover:-translate-y-px hover:border-ink-faint hover:text-ink disabled:pointer-events-none disabled:opacity-50"
|
||||
>
|
||||
<LogOut className="h-3.5 w-3.5" aria-hidden />
|
||||
{pending ? "กำลังออก…" : "ออกจากระบบ"}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ export default function Select({
|
|||
return (
|
||||
<div className="relative">
|
||||
<select {...props} className={`appearance-none pr-7 ${className}`} />
|
||||
<ChevronDown className="pointer-events-none absolute right-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-500" />
|
||||
<ChevronDown className="pointer-events-none absolute right-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-ink-faint" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +0,0 @@
|
|||
// แถบแสดงผู้ใช้ที่เข้าสู่ระบบบนหน้ารายการ (แทน UserPicker เดิม) — server component
|
||||
// รับค่าจาก cookie ผ่าน props จากหน้า (middleware การันตีว่ามี session ก่อนถึงหน้านี้)
|
||||
import LogoutButton from "./LogoutButton";
|
||||
|
||||
export default function SessionBar({ userName }: { userName: string }) {
|
||||
return (
|
||||
<div className="mt-6 flex flex-wrap items-center justify-between gap-x-3 gap-y-2 rounded-xl border border-slate-200 bg-white px-4 py-3 shadow-sm">
|
||||
<p className="min-w-0 text-sm text-slate-600">
|
||||
เข้าสู่ระบบเป็น{" "}
|
||||
<span className="font-semibold text-slate-900">{userName}</span>
|
||||
</p>
|
||||
<LogoutButton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -2,7 +2,10 @@
|
|||
|
||||
// N4 — ฟอร์มอัปโหลดไฟล์เอกสารแบบง่ายบนหน้ารายการ (PDF/รูป รับพร้อมกัน)
|
||||
// เสร็จแล้วรายการโผล่เองจาก revalidatePath("/") ของ action
|
||||
// Genesis: หน้าตาเป็นการ์ด surface 12px radius ขอบ line (ใต้หัวข้อของ section
|
||||
// "เอกสาร" — ไฟล์ที่อัปโหลดเข้ารายการนั้น) + ปุ่ม primary เดียวของ section
|
||||
import { useState, useTransition } from "react";
|
||||
import { Upload } from "lucide-react";
|
||||
|
||||
import { uploadFile } from "@/app/actions/files";
|
||||
|
||||
|
|
@ -43,29 +46,43 @@ export default function UploadFileForm() {
|
|||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={onSubmit}
|
||||
className="mt-3 flex flex-wrap items-center gap-2 rounded-xl border border-dashed border-slate-300 bg-white/60 p-4"
|
||||
>
|
||||
<form onSubmit={onSubmit} className="flex flex-col rounded-xl border border-line bg-white p-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Upload aria-hidden className="h-4 w-4 shrink-0 text-ink-faint" />
|
||||
<p className="text-sm font-semibold text-ink">อัปโหลดไฟล์</p>
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
name="file"
|
||||
accept={ACCEPT}
|
||||
required
|
||||
disabled={pending}
|
||||
className="block w-full max-w-xs text-sm text-slate-600 file:mr-3 file:cursor-pointer file:rounded-md file:border-0 file:bg-primary/10 file:px-4 file:py-2 file:text-sm file:font-medium file:text-primary"
|
||||
className="mt-4 block w-full text-sm text-ink-soft file:mr-3 file:cursor-pointer file:rounded-md file:border-0 file:bg-ink/[0.05] file:px-4 file:py-2 file:text-sm file:font-medium file:text-ink"
|
||||
/>
|
||||
{/* กลุ่ม typeId ทางเลือก (N9) — ช่องเดียวกับลิงก์ e-Court, ไม่กรอก = "ไม่มีกลุ่ม";
|
||||
uncontrolled ส่งไปกับ FormData เหมือนช่องไฟล์ (normalize ที่ action) */}
|
||||
<label htmlFor="upload-type-id" className="mt-3 block text-sm font-medium text-ink">
|
||||
กลุ่ม typeId
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="upload-type-id"
|
||||
name="typeId"
|
||||
disabled={pending}
|
||||
placeholder="ไม่กรอก = ไม่มีกลุ่ม"
|
||||
className="genesis-input mt-1 w-full text-base"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={pending}
|
||||
className="ml-auto shrink-0 rounded-md bg-primary px-4 py-2.5 text-sm font-medium text-white transition hover:opacity-90 disabled:opacity-50"
|
||||
className="focus-ring mt-4 w-full 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 disabled:pointer-events-none disabled:opacity-50"
|
||||
>
|
||||
{pending ? "กำลังอัปโหลด…" : "อัปโหลด"}
|
||||
</button>
|
||||
<p className="w-full text-xs text-slate-500">
|
||||
<p className="mt-3 text-xs leading-relaxed text-ink-soft">
|
||||
รองรับ PDF, JPG, PNG, TIFF — ไม่เกิน 100MB ต่อไฟล์
|
||||
</p>
|
||||
{error && <p className="w-full text-xs text-red-600">{error}</p>}
|
||||
{error && <p className="mt-2 text-xs text-error-ink">{error}</p>}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,24 +11,35 @@ import { AlignCenter, AlignLeft, AlignRight, BookmarkPlus, Pencil, Trash2 } from
|
|||
import type {
|
||||
AnnotationData,
|
||||
AnnotationDTO,
|
||||
CommentData,
|
||||
HighlightData,
|
||||
HighlightStyle,
|
||||
TextboxData,
|
||||
} from "@/db/schema";
|
||||
import {
|
||||
COMMENT_PIN_COARSE_SIZE_PX,
|
||||
COMMENT_PIN_SIZE_PX,
|
||||
TEXTBOX_MAX_WIDTH_PCT,
|
||||
annotationBBox,
|
||||
commentPinBoxPct,
|
||||
commentPinBoxPx,
|
||||
scaleAnnotationData,
|
||||
translateAnnotationData,
|
||||
} from "@/utils/geometry";
|
||||
import { clamp, getPointerPercent, type Point, type Rect, type Size } from "@/utils/coordinates";
|
||||
import { STAMP_COLOR } from "@/utils/annotation-display";
|
||||
import {
|
||||
clamp,
|
||||
getPointerPercent,
|
||||
type Point,
|
||||
type Rect,
|
||||
type Size,
|
||||
} from "@/utils/coordinates";
|
||||
import { annotationTypeLabel, STAMP_COLOR } from "@/utils/annotation-display";
|
||||
import { ColorSwatches, HIGHLIGHT_STYLE_BTNS, NumberCombo, PanelRow } from "./ToolControls";
|
||||
|
||||
/** UX audit 2026-09-16: focus ring คีย์บอร์ด — ค่าเดียวกับ FOCUS_RING_CLS ของ
|
||||
* PDFViewer/AnnotationSidebar (ประกาศแยกที่นี่กัน circular import) */
|
||||
const FOCUS_RING_CLS =
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1";
|
||||
"focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary";
|
||||
|
||||
// กรอบ selection + handles ของ annotation ที่เลือก (PLAN-FEAT.md §3 F1)
|
||||
// - container เป็น z-4 เหมือน comment pins และ pointer-events: none **เสมอ**
|
||||
|
|
@ -42,6 +53,9 @@ const STRIP_PX = 8; // ความหนาขอบกรอบ (translate)
|
|||
const MIN_BOX_PCT = 0.5; // bbox เล็กสุด (% ของหน้า) กันกรอบหาย
|
||||
const EPS = 1e-6;
|
||||
|
||||
// COMMENT: ขนาด pin px + bbox px/% ของ pin ย้ายไป utils/geometry.ts แล้ว (ใช้ร่วมกับ
|
||||
// goToAnnotation ของ PDFViewer — single source ตาม responsive audit 2026-09-24)
|
||||
|
||||
type DragState = {
|
||||
// wleft/wright = TEXTBOX กว้างคงที่: ลากขอบซ้าย/ขวาปรับความกว้าง (ฟอนต์คงขนาด)
|
||||
mode: "move" | "scale" | "wleft" | "wright";
|
||||
|
|
@ -116,8 +130,25 @@ export default function AnnotationSelectionLayer({
|
|||
const movedRef = useRef(false);
|
||||
const latestRef = useRef<AnnotationData | null>(null);
|
||||
|
||||
const boxPx = annotationBBox(annotation, size);
|
||||
const boxPct = annotationBBox(annotation, { width: 100, height: 100 });
|
||||
// อุปกรณ์จอสัมผัส (pointer หลัก coarse) — คำนวณครั้งเดียวตอน mount เหมือน
|
||||
// coarseTouch ของ PDFViewer: .coarse-36 ขยายปุ่ม pin เป็น 36px บนพวกนี้
|
||||
const [coarseTouch] = useState(
|
||||
() =>
|
||||
typeof window !== "undefined" &&
|
||||
window.matchMedia("(pointer: coarse)").matches,
|
||||
);
|
||||
|
||||
// COMMENT ใช้กล่องครอบ pin จริง (px คงที่ตามอุปกรณ์) — ชนิดอื่นคือ bbox ตามขนาดหน้า
|
||||
// boxPct ก็ใช้มาตราฐานเดียวกัน (% จาก px จริง): move-clamp กับปุ่ม bookmark ของ pin
|
||||
// ต้องเห็น pin ตามที่ render ไม่ใช่ COMMENT_PIN_BOX_PCT ประมาณ (audit 2026-09-24)
|
||||
const isCommentPin = annotation.type === "COMMENT";
|
||||
const pinPx = coarseTouch ? COMMENT_PIN_COARSE_SIZE_PX : COMMENT_PIN_SIZE_PX;
|
||||
const boxPx = isCommentPin
|
||||
? commentPinBoxPx(annotation.data as CommentData, size, pinPx)
|
||||
: annotationBBox(annotation, size);
|
||||
const boxPct = isCommentPin
|
||||
? commentPinBoxPct(annotation.data as CommentData, size, pinPx)
|
||||
: annotationBBox(annotation, { width: 100, height: 100 });
|
||||
|
||||
// FIX-9: เอา non-null ! ออก — handler ยิงได้แค่ตอน DOM mount แล้ว
|
||||
// ref จึงไม่ควรเป็น null (คืน 0,0 เผื่อไว้เฉย ๆ ให้ type ชัด)
|
||||
|
|
@ -475,8 +506,10 @@ export default function AnnotationSelectionLayer({
|
|||
/>
|
||||
|
||||
{/* มุมทั้ง 4 — ลาก = scale รอบ anchor มุมตรงข้าม (TEXTBOX กว้างคงที่ไม่มีมุม —
|
||||
ปรับความกว้างที่ขอบซ้าย/ขวาแทน ส่วนสูงตามจำนวนบรรทัดอัตโนมัติ) */}
|
||||
{!isFixedTextbox && corners.map((c) => (
|
||||
ปรับความกว้างที่ขอบซ้าย/ขวาแทน ส่วนสูงตามจำนวนบรรทัดอัตโนมัติ; COMMENT ไม่
|
||||
มีมุมเช่นกัน — pin ไม่มี field ขนาด scale จึงไร้ความหมาย และ anchor จาก boxPct
|
||||
(% รอบจุด) กับ handle บนกล่อง pin คนละกล่องทำให้ลากแล้วเหลื่อมจากนิ้ว) */}
|
||||
{!isFixedTextbox && !isCommentPin && corners.map((c) => (
|
||||
<div
|
||||
key={c.key}
|
||||
className="absolute touch-none sel-handle rounded-sm border border-primary bg-primary/50"
|
||||
|
|
@ -498,6 +531,23 @@ export default function AnnotationSelectionLayer({
|
|||
</>
|
||||
)}
|
||||
|
||||
{/* Responsive audit 2026-09-24: รายการของผู้อื่นที่เลือกอยู่ — ป้าย "ชนิด · ผู้ทำ" ลอย
|
||||
เหนือกรอบ (จอสัมผัสไม่มี hover tooltip N1 ให้ดูว่าใครทำ เดิมต้องเปิด sidebar
|
||||
อย่างเดียว) — read-only pointer-events:none ตามกติกา container ของ layer นี้
|
||||
ไม่บัง textLayer; ชิดหัวหน้าเกินก็ห้อยใต้กรอบแทน */}
|
||||
{!editable && (
|
||||
<div
|
||||
className="absolute z-10 max-w-[200px] truncate rounded-md border border-line bg-white px-1.5 py-0.5 text-[11px] text-ink-soft shadow-lg"
|
||||
style={{
|
||||
left: Math.max(0, Math.min(boxPx.x, size.width - 204)),
|
||||
top: boxPx.y - 24 < 0 ? boxPx.y + boxPx.height + 4 : boxPx.y - 24,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
{annotationTypeLabel(annotation)} · {annotation.userName || "ไม่ทราบชื่อ"}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* แผงเครื่องมือของรายการที่เลือก — ตัวควบคุมเดียวกับ dropdown ของ tool ผู้สร้าง
|
||||
(สี/ความหนา/ขนาด/รูปแบบตามชนิด) + ปุ่มลบท้ายแผง — แสดงทุกชนิดรวม HIGHLIGHT
|
||||
(ไม่มี handle ย้าย/ขยาย แต่เปลี่ยนสี/รูปแบบ/ลบได้) — เฉพาะรายการของตัวเอง
|
||||
|
|
@ -507,7 +557,7 @@ export default function AnnotationSelectionLayer({
|
|||
{editable && !disabled && (
|
||||
<div
|
||||
ref={panelRef}
|
||||
className={`absolute ${hasStyleControls ? "w-60" : "w-fit"} sel-panel rounded-lg border border-slate-200 bg-white p-1.5 shadow-lg`}
|
||||
className={`absolute ${hasStyleControls ? "w-60" : "w-fit"} sel-panel rounded-lg border border-line bg-white p-1.5 shadow-lg`}
|
||||
style={{
|
||||
left: Math.max(0, Math.min(boxPx.x, size.width - 240 - 4)),
|
||||
top: openAbove ? boxPx.y - 6 : boxPx.y + boxPx.height + 4,
|
||||
|
|
@ -532,7 +582,7 @@ export default function AnnotationSelectionLayer({
|
|||
className={`flex h-6 w-6 items-center justify-center rounded-md transition ${FOCUS_RING_CLS} ${
|
||||
(highlightData.style ?? "highlight") === key
|
||||
? "bg-primary text-white"
|
||||
: "text-slate-500 hover:bg-slate-100"
|
||||
: "text-ink-soft hover:bg-ink/[0.05]"
|
||||
}`}
|
||||
onClick={() => commitHighlightStyle(key)}
|
||||
>
|
||||
|
|
@ -599,7 +649,7 @@ export default function AnnotationSelectionLayer({
|
|||
<PanelRow label="ข้อความ">
|
||||
<button
|
||||
type="button"
|
||||
className={`flex flex-1 items-center justify-center gap-1 rounded-md border border-slate-300 px-2 py-1 text-[11px] font-medium text-slate-600 hover:bg-slate-100 ${FOCUS_RING_CLS}`}
|
||||
className={`flex flex-1 items-center justify-center gap-1 rounded-md border border-line px-2 py-1 text-[11px] font-medium text-ink-soft hover:bg-ink/[0.05] ${FOCUS_RING_CLS}`}
|
||||
onClick={() => onStartEdit?.(annotation.id)}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
|
|
@ -643,7 +693,7 @@ export default function AnnotationSelectionLayer({
|
|||
className={`flex h-6 w-6 items-center justify-center rounded-md border transition ${FOCUS_RING_CLS} ${
|
||||
textboxData.bold
|
||||
? "border-primary bg-primary text-white"
|
||||
: "border-slate-300 bg-white text-slate-500 hover:bg-slate-100"
|
||||
: "border-line bg-white text-ink-soft hover:bg-ink/[0.05]"
|
||||
}`}
|
||||
onClick={() => commitTextboxPatch({ bold: !textboxData.bold })}
|
||||
>
|
||||
|
|
@ -654,7 +704,7 @@ export default function AnnotationSelectionLayer({
|
|||
</button>
|
||||
</PanelRow>
|
||||
<PanelRow label="จัดแนว">
|
||||
<div className="flex overflow-hidden rounded-md border border-slate-300">
|
||||
<div className="flex overflow-hidden rounded-md border border-line">
|
||||
{(
|
||||
[
|
||||
["left", AlignLeft, "ชิดซ้าย"],
|
||||
|
|
@ -670,7 +720,7 @@ export default function AnnotationSelectionLayer({
|
|||
className={`px-2 py-1 transition ${FOCUS_RING_CLS} ${
|
||||
(textboxData.align ?? "left") === a
|
||||
? "bg-primary text-white"
|
||||
: "text-slate-500 hover:bg-slate-100"
|
||||
: "text-ink-soft hover:bg-ink/[0.05]"
|
||||
}`}
|
||||
onClick={() => commitTextboxPatch({ align: a })}
|
||||
>
|
||||
|
|
@ -681,27 +731,29 @@ export default function AnnotationSelectionLayer({
|
|||
</PanelRow>
|
||||
</>
|
||||
)}
|
||||
{hasStyleControls && <div className="my-1 border-t border-slate-100" />}
|
||||
{hasStyleControls && <div className="my-1 border-t border-line/60" />}
|
||||
<div className={hasStyleControls ? "flex items-center justify-between" : "flex items-center"}>
|
||||
{/* N2: bookmark ตำแหน่งของรายการนี้ — เปิด popup ตั้งชื่อ/โน้ต/เลือกหัวข้อต่อ */}
|
||||
{/* hover เป็นกลางแบบปุ่มจิ๋วอื่นในแผง (เดิม copy มาจากปุ่มลบข้าง ๆ จน hover
|
||||
แดง = สัญญาณอันตรายเท็จกับการ "บันทึก" — UX review M4 2026-09-23) */}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="บันทึก bookmark ตำแหน่งรายการนี้"
|
||||
title="Bookmark ตำแหน่งนี้"
|
||||
className={`flex h-5 w-5 sel-panel-btn items-center justify-center rounded text-slate-400 hover:bg-amber-50 hover:text-amber-500 ${FOCUS_RING_CLS}`}
|
||||
className={`flex h-5 w-5 sel-panel-btn items-center justify-center rounded text-ink-faint hover:bg-ink/[0.05] hover:text-ink ${FOCUS_RING_CLS}`}
|
||||
onClick={() => onBookmark(boxPct.y)}
|
||||
>
|
||||
<BookmarkPlus className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
{/* เส้นคั่นแนวตั้ง — เฉพาะกรณีปุ่มติดกัน (แผงไม่มี row สไตล์): แยก bookmark ออกจากปุ่มลบ */}
|
||||
{!hasStyleControls && (
|
||||
<span aria-hidden="true" className="mx-1 h-3.5 w-px bg-slate-200" />
|
||||
<span aria-hidden="true" className="mx-1 h-3.5 w-px bg-line" />
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="ลบรายการนี้"
|
||||
title="ลบ"
|
||||
className={`flex h-5 w-5 sel-panel-btn items-center justify-center rounded text-red-500 hover:bg-red-50 ${FOCUS_RING_CLS}`}
|
||||
className={`flex h-5 w-5 sel-panel-btn items-center justify-center rounded text-error-ink hover:bg-error/10 ${FOCUS_RING_CLS}`}
|
||||
onClick={() => onDelete(annotation.id)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -194,7 +194,7 @@ function TextboxEditor({
|
|||
e.clipboardData.getData("text/plain"),
|
||||
);
|
||||
}}
|
||||
className="min-h-[1.5em] w-full whitespace-pre-wrap break-words rounded border border-dashed border-slate-500/70 bg-transparent p-0 caret-current focus:outline-none"
|
||||
className="min-h-[1.5em] w-full whitespace-pre-wrap break-words rounded border border-dashed border-ink-faint/70 bg-transparent p-0 caret-current focus:outline-none"
|
||||
style={{
|
||||
fontFamily: TEXTBOX_FONT_STACK,
|
||||
fontSize: toPx(fontSizePct, size.width),
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import { colorName } from "@/utils/annotation-display";
|
|||
* PDFViewer/AnnotationSidebar (ประกาศแยกที่นี่เพราะ PDFViewer import ไฟล์นี้
|
||||
* อยู่แล้ว ห้ามวนกลับ) */
|
||||
const FOCUS_RING_CLS =
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1";
|
||||
"focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary";
|
||||
|
||||
/** สี preset ให้เลือก (ทุก tool ที่ปรับสีได้: draw/shape/highlight/textbox/stamp)
|
||||
* สีอื่นนอกรายการเลือกได้ผ่านปุ่มสีรุ้ง (input type="color") ท้ายแถว */
|
||||
|
|
@ -38,7 +38,7 @@ export function PanelRow({ label, children }: { label?: string; children: ReactN
|
|||
return (
|
||||
<div className="panel-row flex items-start gap-2 px-1 py-1">
|
||||
{label ? (
|
||||
<span className="w-11 shrink-0 pt-1 text-[11px] font-medium text-slate-500">{label}</span>
|
||||
<span className="w-11 shrink-0 pt-1 text-[11px] font-medium text-ink-soft">{label}</span>
|
||||
) : null}
|
||||
{children}
|
||||
</div>
|
||||
|
|
@ -57,7 +57,7 @@ export function ColorSwatches({ value, onChange }: { value: string; onChange: (c
|
|||
type="button"
|
||||
aria-label={`เลือกสี ${colorName(c)}`}
|
||||
title={colorName(c)}
|
||||
className={`aspect-square max-w-8 w-full justify-self-center rounded-full border border-slate-300 transition ${FOCUS_RING_CLS} ${
|
||||
className={`aspect-square max-w-8 w-full justify-self-center rounded-full border border-line transition ${FOCUS_RING_CLS} ${
|
||||
value === c ? "ring-2 ring-primary ring-offset-1" : "hover:scale-110"
|
||||
}`}
|
||||
style={{ backgroundColor: c }}
|
||||
|
|
@ -66,7 +66,7 @@ export function ColorSwatches({ value, onChange }: { value: string; onChange: (c
|
|||
))}
|
||||
<label
|
||||
title="เลือกสีอื่น"
|
||||
className={`relative aspect-square max-w-8 w-full cursor-pointer justify-self-center rounded-full border border-slate-300 transition has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-primary has-[:focus-visible]:ring-offset-1 ${
|
||||
className={`relative aspect-square max-w-8 w-full cursor-pointer justify-self-center rounded-full border border-line transition has-[:focus-visible]:outline has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-offset-2 has-[:focus-visible]:outline-primary ${
|
||||
INK_COLORS.includes(value) ? "hover:scale-110" : "ring-2 ring-primary ring-offset-1"
|
||||
}`}
|
||||
style={{
|
||||
|
|
@ -179,7 +179,7 @@ export function NumberCombo({
|
|||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-stretch overflow-hidden rounded-md border border-slate-300 bg-white">
|
||||
<div className="flex items-stretch overflow-hidden rounded-md border border-line bg-white">
|
||||
<input
|
||||
type="number"
|
||||
value={draft}
|
||||
|
|
@ -196,16 +196,16 @@ export function NumberCombo({
|
|||
if (e.key !== "Escape") e.stopPropagation(); // พิมพ์ในช่องต้องไม่ชนคีย์ลัด F3 (Esc ปล่อยไหลไปปิดเมนู)
|
||||
if (e.key === "Enter") commitDraft(); // ยืนยันครั้งเดียวตรงนี้ ไม่ต้องรอ blur
|
||||
}}
|
||||
className="min-w-0 flex-1 border-0 px-1.5 py-1.5 text-[11px] text-slate-700 focus:outline-none focus:ring-1 focus:ring-inset focus:ring-slate-400 [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
|
||||
className="min-w-0 flex-1 border-0 px-1.5 py-1.5 text-[11px] text-ink focus:outline focus:outline-2 focus:outline-offset-1 focus:outline-primary [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
|
||||
/>
|
||||
<div className="flex flex-col border-l border-slate-300">
|
||||
<div className="flex flex-col border-l border-line">
|
||||
{/* combo-step: ปุ่ม ▲▼ ซ้อนแนวตั้ง 2 ชั้น — ต้องหลุดจาก rule touch 36px
|
||||
ของ dropdown (globals.css) ไม่งั้นคอลัมน์สูง 72px ดึงทั้งแถวบวมตาม */}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="เพิ่มค่า"
|
||||
onMouseDown={(e) => e.preventDefault()} // ไม่แย่ง focus จาก input (dropdown ไม่กระพริบ)
|
||||
className={`combo-step flex flex-1 items-center justify-center rounded-sm px-1.5 text-slate-500 hover:bg-slate-100 ${FOCUS_RING_CLS}`}
|
||||
className={`combo-step flex flex-1 items-center justify-center rounded-sm px-1.5 text-ink-soft hover:bg-ink/[0.05] ${FOCUS_RING_CLS}`}
|
||||
onClick={() => stepValue(1)}
|
||||
>
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
|
|
@ -214,7 +214,7 @@ export function NumberCombo({
|
|||
type="button"
|
||||
aria-label="ลดค่า"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
className={`combo-step flex flex-1 items-center justify-center rounded-sm border-t border-slate-300 px-1.5 text-slate-500 hover:bg-slate-100 ${FOCUS_RING_CLS}`}
|
||||
className={`combo-step flex flex-1 items-center justify-center rounded-sm border-t border-line px-1.5 text-ink-soft hover:bg-ink/[0.05] ${FOCUS_RING_CLS}`}
|
||||
onClick={() => stepValue(-1)}
|
||||
>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
|
|
@ -227,7 +227,7 @@ export function NumberCombo({
|
|||
// L2 (UX audit 2026-09-16): เปิด "ขึ้นบน" (bottom-full) เสมอ — แถวท้ายเมนู
|
||||
// tool (ความทึบ/ความหนา) เปิดลงจะล้นเขต scroll ของเมนู ต้องเลื่อนเมนูจึงเห็น
|
||||
// ครบ / ทุกจุดใช้งานมีแถวอื่นอยู่ข้างบนเสมอ จึงไม่หลุดขอบบนของเมนูเช่นกัน
|
||||
className="absolute bottom-full right-0 z-[60] mb-1 w-full overflow-hidden rounded-md border border-slate-200 bg-white py-0.5 shadow-lg"
|
||||
className="absolute bottom-full right-0 z-[60] mb-1 w-full overflow-hidden rounded-md border border-line bg-white py-0.5 shadow-lg"
|
||||
>
|
||||
{options.map((opt) => (
|
||||
<button
|
||||
|
|
@ -238,8 +238,8 @@ export function NumberCombo({
|
|||
onMouseDown={(e) => e.preventDefault()} // ไม่แย่ง focus จาก input ก่อนคลิก
|
||||
className={`flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs font-medium transition ${FOCUS_RING_CLS} ${
|
||||
value === opt
|
||||
? "bg-slate-100 text-slate-900"
|
||||
: "text-slate-600 hover:bg-slate-100"
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-ink-soft hover:bg-ink/[0.05]"
|
||||
}`}
|
||||
onClick={() => {
|
||||
onChange(opt);
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ import { formatThaiDate } from "@/utils/date-format";
|
|||
* ของ PDFViewer.tsx (ห้าม import ข้ามไฟล์: PDFViewer → sidebar → PDFViewer เป็น
|
||||
* circular import — แก้ต้องแก้ทั้งสองที่พร้อมกัน) */
|
||||
const FOCUS_RING_CLS =
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1 focus-visible:z-10";
|
||||
"focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary focus-visible:z-10";
|
||||
|
||||
/** ใส่/ล้างโน้ตคำอธิบายใน data ของ annotation (ทุกชนิดยกเว้น COMMENT — message ทำหน้าที่นี้แล้ว) */
|
||||
function withNote(data: Exclude<AnnotationData, CommentData>, note: string): AnnotationData {
|
||||
|
|
@ -139,7 +139,7 @@ function ViewSwitch<T extends string>({
|
|||
onChange: (v: T) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex rounded-md bg-slate-100 p-0.5">
|
||||
<div className="flex rounded-md bg-ink/[0.05] p-0.5">
|
||||
{options.map((o) => (
|
||||
<button
|
||||
key={o.key}
|
||||
|
|
@ -147,8 +147,8 @@ function ViewSwitch<T extends string>({
|
|||
aria-pressed={value === o.key}
|
||||
className={`flex-1 rounded px-2 py-1 text-[11px] font-medium transition ${FOCUS_RING_CLS} ${
|
||||
value === o.key
|
||||
? "bg-white text-slate-900 shadow-sm"
|
||||
: "text-slate-500 hover:text-slate-600"
|
||||
? "bg-white text-ink ring-1 ring-line"
|
||||
: "text-ink-soft hover:text-ink"
|
||||
}`}
|
||||
onClick={() => onChange(o.key)}
|
||||
>
|
||||
|
|
@ -178,7 +178,7 @@ function MenuItem({
|
|||
type="button"
|
||||
role="menuitem"
|
||||
className={`flex w-full items-start gap-2 rounded px-2 py-1.5 text-left text-xs transition ${FOCUS_RING_CLS} ${
|
||||
danger ? "text-red-600 hover:bg-red-50" : "text-slate-600 hover:bg-slate-100"
|
||||
danger ? "text-error-ink hover:bg-error/10" : "text-ink-soft hover:bg-ink/[0.05]"
|
||||
}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
|
|
@ -204,17 +204,17 @@ function PageGroupHeader({
|
|||
<button
|
||||
type="button"
|
||||
aria-label={collapsed ? `กางหน้า ${pageIndex + 1}` : `หุบหน้า ${pageIndex + 1}`}
|
||||
className={`flex w-full items-center gap-1.5 rounded px-1 py-1 text-left hover:bg-slate-100 ${FOCUS_RING_CLS}`}
|
||||
className={`flex w-full items-center gap-1.5 rounded px-1 py-1 text-left hover:bg-ink/[0.05] ${FOCUS_RING_CLS}`}
|
||||
onClick={onToggle}
|
||||
>
|
||||
{collapsed ? (
|
||||
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-slate-500" />
|
||||
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-ink-soft" />
|
||||
) : (
|
||||
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-slate-500" />
|
||||
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-ink-soft" />
|
||||
)}
|
||||
<span className="text-xs font-medium text-slate-700">
|
||||
<span className="text-xs font-medium text-ink">
|
||||
หน้า {pageIndex + 1}
|
||||
<span className="ml-1 font-normal text-slate-500">({count})</span>
|
||||
<span className="ml-1 font-normal text-ink-soft">({count})</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
|
|
@ -774,7 +774,7 @@ export default function AnnotationSidebar({
|
|||
if (e.key === "Escape") setEditBookmark(null);
|
||||
}}
|
||||
placeholder="ชื่อหัวข้อ"
|
||||
className="w-full rounded border border-slate-300 px-1.5 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-slate-400"
|
||||
className="w-full rounded-md border border-line px-1.5 py-1 text-xs focus:border-primary focus:outline focus:outline-2 focus:outline-offset-1 focus:outline-primary"
|
||||
/>
|
||||
<textarea
|
||||
rows={2}
|
||||
|
|
@ -787,16 +787,16 @@ export default function AnnotationSidebar({
|
|||
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) saveBookmarkEdit(b);
|
||||
}}
|
||||
placeholder="โน้ต (ไม่บังคับ)"
|
||||
className="mt-1 w-full resize-none rounded border border-slate-300 p-1.5 text-[11px] focus:outline-none focus:ring-1 focus:ring-slate-400"
|
||||
className="mt-1 w-full resize-none rounded-md border border-line p-1.5 text-[11px] focus:border-primary focus:outline focus:outline-2 focus:outline-offset-1 focus:outline-primary"
|
||||
/>
|
||||
<p className="mt-1 text-[11px] text-slate-500">วางที่</p>
|
||||
<p className="mt-1 text-[11px] text-ink-soft">วางที่</p>
|
||||
<Select
|
||||
value={eb.parentId ?? ""}
|
||||
onChange={(e) =>
|
||||
setEditBookmark({ ...eb, parentId: e.target.value || null })
|
||||
}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
className="mt-0.5 w-full rounded border border-slate-300 bg-white px-1.5 py-1 text-[11px] focus:outline-none focus:ring-1 focus:ring-slate-400"
|
||||
className="mt-0.5 w-full rounded-md border border-line bg-white px-1.5 py-1 text-[11px] focus:border-primary focus:outline focus:outline-2 focus:outline-offset-1 focus:outline-primary"
|
||||
>
|
||||
<option value="">ระดับบนสุด (หมุดหัวข้อใหญ่)</option>
|
||||
{movableTo.map((h) => (
|
||||
|
|
@ -808,14 +808,14 @@ export default function AnnotationSidebar({
|
|||
<div className="mt-1.5 flex justify-end gap-1.5 text-[11px]">
|
||||
<button
|
||||
type="button"
|
||||
className={`rounded px-2 py-0.5 text-slate-500 hover:bg-slate-100 ${FOCUS_RING_CLS}`}
|
||||
className={`rounded-md px-2 py-0.5 text-ink-soft hover:bg-ink/[0.05] ${FOCUS_RING_CLS}`}
|
||||
onClick={() => setEditBookmark(null)}
|
||||
>
|
||||
ยกเลิก
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`rounded bg-primary px-2 py-0.5 font-medium text-white ${FOCUS_RING_CLS}`}
|
||||
className={`rounded-md bg-primary px-2 py-0.5 font-medium text-white ${FOCUS_RING_CLS}`}
|
||||
onClick={() => saveBookmarkEdit(b)}
|
||||
>
|
||||
บันทึก
|
||||
|
|
@ -823,7 +823,7 @@ export default function AnnotationSidebar({
|
|||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={`group flex items-center gap-1.5 rounded py-1 hover:bg-slate-100 ${pad}`}>
|
||||
<div className={`group flex items-center gap-1.5 rounded py-1 hover:bg-ink/[0.05] ${pad}`}>
|
||||
{hasChildren ? (
|
||||
// bm-toggle: คอลัมน์นี้เป็น "ตาราง" ของต้นไม้ ต้องกว้าง 14px เท่า spacer
|
||||
// ของแถวลูกเสมอ — บนจอสัมผัส rule coarse ที่ขยายปุ่มเป็น 32px จะดันไอคอน
|
||||
|
|
@ -832,7 +832,7 @@ export default function AnnotationSidebar({
|
|||
<button
|
||||
type="button"
|
||||
aria-label={collapsed ? `กางหัวข้อ ${b.title}` : `หุบหัวข้อ ${b.title}`}
|
||||
className={`shrink-0 bm-toggle text-slate-500 hover:text-slate-600 ${FOCUS_RING_CLS}`}
|
||||
className={`shrink-0 bm-toggle text-ink-soft hover:text-ink ${FOCUS_RING_CLS}`}
|
||||
onClick={() => toggleCollapsed(b.id)}
|
||||
>
|
||||
{collapsed ? (
|
||||
|
|
@ -850,26 +850,25 @@ export default function AnnotationSidebar({
|
|||
className={`flex min-w-0 flex-1 items-start gap-1.5 text-left ${FOCUS_RING_CLS}`}
|
||||
onClick={() => onGoToBookmark(b)}
|
||||
>
|
||||
<Bookmark
|
||||
className={`h-3.5 w-3.5 shrink-0 ${
|
||||
depth === 1 ? "text-amber-500" : "text-amber-300"
|
||||
}`}
|
||||
/>
|
||||
{/* ไอคอน bookmark สีเดียวทุกระดับ (warning-ink ผ่าน 5:1 บนขาว — เดิมแยก
|
||||
root/ลูกด้วย amber-500/300 แต่ตัว 300 จาง 1.6:1 แทบมองไม่เห็น;
|
||||
ลำดับชั้นของต้นไม้สื่อด้วย font-semibold + indent อยู่แล้ว) */}
|
||||
<Bookmark className="h-3.5 w-3.5 shrink-0 text-warning-ink" />
|
||||
<span className="min-w-0">
|
||||
<span
|
||||
className={`block truncate text-[13px] leading-snug ${
|
||||
depth === 1 ? "font-semibold text-slate-700" : ""
|
||||
depth === 1 ? "font-semibold text-ink" : ""
|
||||
}`}
|
||||
>
|
||||
{b.title}
|
||||
</span>
|
||||
{/* แสดงผู้บันทึกด้วย — เหมือนแถวผลการค้นหา (N6) และแถวหมายเหตุ
|
||||
(แถวเดิมมีแค่ "หน้า N" ไม่รู้ว่าใครเป็นคนคั่นไว้) */}
|
||||
<span className="block text-xs text-slate-500">
|
||||
<span className="block text-xs text-ink-soft">
|
||||
หน้า {b.pageIndex + 1} · {b.userName || "ไม่ทราบชื่อ"}
|
||||
</span>
|
||||
{b.note && (
|
||||
<span className="mt-0.5 block line-clamp-2 whitespace-pre-wrap break-words text-[11px] text-slate-500">
|
||||
<span className="mt-0.5 block line-clamp-2 whitespace-pre-wrap break-words text-[11px] text-ink-soft">
|
||||
{b.note}
|
||||
</span>
|
||||
)}
|
||||
|
|
@ -877,7 +876,7 @@ export default function AnnotationSidebar({
|
|||
</button>
|
||||
{/* จำนวนหมุดย่อย — badge ขวาสุด (UX audit: แยกจากชื่อ ไม่ปนตอนอ่านไว) */}
|
||||
{hasChildren && (
|
||||
<span className="shrink-0 self-center rounded-full bg-slate-100 px-1.5 text-[10px] tabular-nums leading-4 text-slate-500">
|
||||
<span className="shrink-0 self-center rounded-full bg-ink/[0.05] px-1.5 text-[10px] tabular-nums leading-4 text-ink-soft">
|
||||
{children.length}
|
||||
</span>
|
||||
)}
|
||||
|
|
@ -888,7 +887,7 @@ export default function AnnotationSidebar({
|
|||
aria-label="จัดการหัวข้อนี้"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={menuFor?.kind === "bookmark" && menuFor.id === b.id}
|
||||
className={`shrink-0 text-slate-500 hover:text-slate-600 group-hover:text-slate-600 ${FOCUS_RING_CLS}`}
|
||||
className={`shrink-0 text-ink-soft hover:text-ink group-hover:text-ink-soft ${FOCUS_RING_CLS}`}
|
||||
onClick={(e) => {
|
||||
if (menuFor?.kind === "bookmark" && menuFor.id === b.id) closeRowMenu();
|
||||
else openRowMenu(e.currentTarget, { kind: "bookmark", id: b.id });
|
||||
|
|
@ -902,7 +901,7 @@ export default function AnnotationSidebar({
|
|||
{hasChildren && !collapsed && (
|
||||
// เส้น guide แนวตั้งนำสายตากลับหา parent แบบ file tree (indent ~17px/ระดับ
|
||||
// ≈ ของเดิม 16px — spacer/chevron w-3.5 คงเดิมจึงตรงแนวกันทุกระดับ)
|
||||
<ul className="ml-3 space-y-0.5 border-l border-slate-200 pl-1">
|
||||
<ul className="ml-3 space-y-0.5 border-l border-line pl-1">
|
||||
{children.map((c) => renderBookmarkNode(c, depth + 1))}
|
||||
</ul>
|
||||
)}
|
||||
|
|
@ -920,7 +919,7 @@ export default function AnnotationSidebar({
|
|||
className={`group rounded px-2 py-1.5 ${
|
||||
a.id === selectedId
|
||||
? "bg-primary/10 ring-1 ring-inset ring-primary/30"
|
||||
: "hover:bg-slate-100"
|
||||
: "hover:bg-ink/[0.05]"
|
||||
} ${a.resolved ? "opacity-60" : ""}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
@ -938,7 +937,7 @@ export default function AnnotationSidebar({
|
|||
{/* UX audit: อักษรไทยมีสระ/วรรณยุกต์ซ้อนหลายชั้น — ข้อความหลักต้อง 13px
|
||||
จึงอ่านได้สบาย (บรรทัดรอง/โน้ตคง 11px) */}
|
||||
<span className="block truncate text-[13px] leading-snug">{annotationPreviewText(a)}</span>
|
||||
<span className="block text-xs text-slate-500">
|
||||
<span className="block text-xs text-ink-soft">
|
||||
หน้า {a.pageIndex + 1} · {a.userName}
|
||||
{a.type === "COMMENT" && (replyCounts[a.id] ?? 0) > 0
|
||||
? ` · ตอบ ${replyCounts[a.id]}`
|
||||
|
|
@ -957,10 +956,10 @@ export default function AnnotationSidebar({
|
|||
title={copyFailedId === a.id ? "คัดลอกไม่ได้ (เบราว์เซอร์บล็อกคลิปบอร์ด)" : "จัดการหมายเหตุนี้"}
|
||||
className={`shrink-0 ${FOCUS_RING_CLS} ${
|
||||
copiedId === a.id
|
||||
? "text-emerald-500"
|
||||
? "text-success-ink"
|
||||
: copyFailedId === a.id
|
||||
? "text-red-500"
|
||||
: "text-slate-500 hover:text-slate-600 group-hover:text-slate-600"
|
||||
? "text-error-ink"
|
||||
: "text-ink-soft hover:text-ink group-hover:text-ink-soft"
|
||||
}`}
|
||||
onClick={(e) => {
|
||||
if (menuFor?.kind === "annotation" && menuFor.id === a.id) closeRowMenu();
|
||||
|
|
@ -989,19 +988,19 @@ export default function AnnotationSidebar({
|
|||
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) saveNote(a);
|
||||
}}
|
||||
placeholder="โน้ต เช่น เหตุผลที่ทำรายการนี้…"
|
||||
className="w-full resize-none rounded border border-slate-300 p-1.5 text-[11px] focus:outline-none focus:ring-1 focus:ring-slate-400"
|
||||
className="w-full resize-none rounded-md border border-line p-1.5 text-[11px] focus:border-primary focus:outline focus:outline-2 focus:outline-offset-1 focus:outline-primary"
|
||||
/>
|
||||
<div className="mt-1 flex justify-end gap-1.5 text-[11px]">
|
||||
<button
|
||||
type="button"
|
||||
className={`rounded px-2 py-0.5 text-slate-500 hover:bg-slate-100 ${FOCUS_RING_CLS}`}
|
||||
className={`rounded-md px-2 py-0.5 text-ink-soft hover:bg-ink/[0.05] ${FOCUS_RING_CLS}`}
|
||||
onClick={() => setNoteFor(null)}
|
||||
>
|
||||
ยกเลิก
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`rounded bg-primary px-2 py-0.5 font-medium text-white ${FOCUS_RING_CLS}`}
|
||||
className={`rounded-md bg-primary px-2 py-0.5 font-medium text-white ${FOCUS_RING_CLS}`}
|
||||
onClick={() => saveNote(a)}
|
||||
>
|
||||
บันทึก
|
||||
|
|
@ -1010,8 +1009,8 @@ export default function AnnotationSidebar({
|
|||
</div>
|
||||
) : (
|
||||
note && (
|
||||
<p className="mt-0.5 flex items-start gap-1 pl-6 text-[11px] text-slate-500">
|
||||
<StickyNote className="mt-0.5 h-3 w-3 shrink-0 text-amber-400" />
|
||||
<p className="mt-0.5 flex items-start gap-1 pl-6 text-[11px] text-ink-soft">
|
||||
<StickyNote className="mt-0.5 h-3 w-3 shrink-0 text-warning-ink" />
|
||||
<span className="line-clamp-2 whitespace-pre-wrap break-words">{note}</span>
|
||||
</p>
|
||||
)
|
||||
|
|
@ -1023,10 +1022,10 @@ export default function AnnotationSidebar({
|
|||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* F4: กรองแสดง annotation ตามผู้เขียน (viewer + sidebar พร้อมกัน) */}
|
||||
<div className="shrink-0 border-b border-slate-200 p-2">
|
||||
<div className="shrink-0 border-b border-line p-2">
|
||||
<Select
|
||||
aria-label="เลือกเจ้าของหมายเหตุที่แสดง"
|
||||
className="w-full rounded-md border border-slate-300 bg-white px-2 py-1.5 text-xs text-slate-600 focus:outline-none focus:ring-2 focus:ring-slate-400"
|
||||
className="w-full rounded-md border border-line bg-white px-2 py-1.5 text-xs text-ink-soft focus:border-primary focus:outline focus:outline-2 focus:outline-offset-1 focus:outline-primary"
|
||||
value={userFilter}
|
||||
onChange={(e) => onUserFilterChange(e.target.value)}
|
||||
>
|
||||
|
|
@ -1041,7 +1040,7 @@ export default function AnnotationSidebar({
|
|||
</div>
|
||||
|
||||
{/* แท็บหลัก — tablist/tab semantics ให้ screen reader ประกาศว่าเป็นแท็บ */}
|
||||
<div role="tablist" aria-label="รายการในแผงข้าง" className="flex shrink-0 border-b border-slate-200">
|
||||
<div role="tablist" aria-label="รายการในแผงข้าง" className="flex shrink-0 border-b border-line">
|
||||
{(
|
||||
[
|
||||
["bookmarks", `Bookmark (${visibleBookmarks.length})`],
|
||||
|
|
@ -1055,8 +1054,8 @@ export default function AnnotationSidebar({
|
|||
aria-selected={tab === key}
|
||||
className={`flex-1 px-2 py-2 text-xs font-medium tabular-nums transition ${FOCUS_RING_CLS} ${
|
||||
tab === key
|
||||
? "border-b-2 border-primary text-slate-900"
|
||||
: "text-slate-500 hover:text-slate-600"
|
||||
? "border-b-2 border-primary text-ink"
|
||||
: "text-ink-soft hover:text-ink"
|
||||
}`}
|
||||
onClick={() => setTab(key)}
|
||||
>
|
||||
|
|
@ -1068,25 +1067,25 @@ export default function AnnotationSidebar({
|
|||
{/* N6: ค้นหา bookmark พร้อมเงื่อนไข — เฉพาะแท็บที่คั่นหน้า
|
||||
(แยกจากช่อง F14 ที่ค้นหมายเหตุ ซึ่งอยู่เหนือแท็บ) */}
|
||||
{tab === "bookmarks" && (
|
||||
<div className="shrink-0 space-y-1.5 border-b border-slate-200 p-2">
|
||||
<div className="shrink-0 space-y-1.5 border-b border-line p-2">
|
||||
{/* แถวคำค้น — โครงเดียวกับ F14 ของแท็บหมายเหตุ: input เต็มกว้าง + ปุ่มเดียว
|
||||
(เดิมปุ่ม "เงื่อนไข"+badge แย่งแถวเดียวกันจน input เหลือพื้นที่ข้อความ
|
||||
~80px ≈ 6–7 ตัวอักษรไทย พิมพ์คำค้นจริงไม่พอ) */}
|
||||
<form className="flex gap-1" onSubmit={(e) => void runBookmarkSearch(e)}>
|
||||
<div className="relative min-w-0 flex-1">
|
||||
<Search className="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-500" />
|
||||
<Search className="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-ink-soft" />
|
||||
<input
|
||||
value={bmQuery}
|
||||
onChange={(e) => setBmQuery(e.target.value)}
|
||||
onKeyDown={(e) => e.stopPropagation()} // ไม่ให้ปุ่มในช่องค้นหาชนคีย์ลัดของ viewer
|
||||
placeholder="ชื่อ หรือ โน้ต…"
|
||||
className="w-full rounded-md border border-slate-300 py-1.5 pl-7 pr-2 text-xs focus:outline-none focus:ring-2 focus:ring-slate-400"
|
||||
className="w-full rounded-md border border-line py-1.5 pl-7 pr-2 text-xs focus:border-primary focus:outline focus:outline-2 focus:outline-offset-1 focus:outline-primary"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={activeConditionCount === 0 || bmSearching}
|
||||
className={`shrink-0 rounded-md border border-slate-300 px-2 py-1.5 text-xs text-slate-600 hover:bg-slate-100 disabled:opacity-40 ${FOCUS_RING_CLS}`}
|
||||
className={`shrink-0 rounded-md border border-line px-2 py-1.5 text-xs text-ink-soft hover:bg-ink/[0.05] disabled:opacity-40 ${FOCUS_RING_CLS}`}
|
||||
>
|
||||
{bmSearching ? "…" : "หา"}
|
||||
</button>
|
||||
|
|
@ -1099,8 +1098,8 @@ export default function AnnotationSidebar({
|
|||
type="button"
|
||||
aria-expanded={bmShowConditions}
|
||||
onClick={() => setBmShowConditions((v) => !v)}
|
||||
className={`flex min-w-0 flex-1 items-center gap-1.5 rounded px-1.5 py-1 text-[11px] hover:bg-slate-100 ${FOCUS_RING_CLS} ${
|
||||
hasBmConditionValues ? "text-primary" : "text-slate-500"
|
||||
className={`flex min-w-0 flex-1 items-center gap-1.5 rounded px-1.5 py-1 text-[11px] hover:bg-ink/[0.05] ${FOCUS_RING_CLS} ${
|
||||
hasBmConditionValues ? "text-primary" : "text-ink-soft"
|
||||
}`}
|
||||
>
|
||||
<SlidersHorizontal className="h-3 w-3 shrink-0" />
|
||||
|
|
@ -1119,7 +1118,7 @@ export default function AnnotationSidebar({
|
|||
href={withBasePath(`/api/bookmarks/export?fileId=${fileId}`)}
|
||||
title="ส่งออกทุกที่คั่นหน้าของไฟล์นี้เป็น JSON"
|
||||
aria-label="ส่งออกทุกที่คั่นหน้าของไฟล์นี้เป็น JSON"
|
||||
className={`coarse-36 shrink-0 rounded-md p-1.5 text-slate-500 hover:bg-slate-100 hover:text-slate-700 ${FOCUS_RING_CLS}`}
|
||||
className={`coarse-36 shrink-0 rounded-md p-1.5 text-ink-soft hover:bg-ink/[0.05] hover:text-ink ${FOCUS_RING_CLS}`}
|
||||
>
|
||||
<Braces className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
|
|
@ -1128,7 +1127,7 @@ export default function AnnotationSidebar({
|
|||
// จังหวะเดียวทุกแถว: label บนบรรทัด + คู่ input แบ่งความกว้าง grid 1fr–1fr
|
||||
// (เดิม label inline 2 แถว + label บน 1 แถว อ่านไม่เป็นระเบียบ ขอบ input
|
||||
// ไม่ตรงแนวกัน และคู่ date ในแถว inline ล้น 288px จนต้องหนีขึ้นบรรทัดบน)
|
||||
<div className="space-y-1.5 text-[11px] text-slate-600">
|
||||
<div className="space-y-1.5 text-[11px] text-ink-soft">
|
||||
<div>
|
||||
<span className="block pb-0.5">หน้า</span>
|
||||
<div className="grid grid-cols-[1fr_auto_1fr] items-center gap-1">
|
||||
|
|
@ -1143,9 +1142,9 @@ export default function AnnotationSidebar({
|
|||
}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
placeholder="จาก"
|
||||
className="w-full min-w-0 rounded border border-slate-300 px-1.5 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-slate-400"
|
||||
className="w-full min-w-0 rounded-md border border-line px-1.5 py-1 text-xs focus:border-primary focus:outline focus:outline-2 focus:outline-offset-1 focus:outline-primary"
|
||||
/>
|
||||
<span className="text-slate-400">–</span>
|
||||
<span className="text-ink-faint">–</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
|
|
@ -1157,7 +1156,7 @@ export default function AnnotationSidebar({
|
|||
}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
placeholder="ถึง"
|
||||
className="w-full min-w-0 rounded border border-slate-300 px-1.5 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-slate-400"
|
||||
className="w-full min-w-0 rounded-md border border-line px-1.5 py-1 text-xs focus:border-primary focus:outline focus:outline-2 focus:outline-offset-1 focus:outline-primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1170,7 +1169,7 @@ export default function AnnotationSidebar({
|
|||
setBmConditions({ ...bmConditions, userId: e.target.value })
|
||||
}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
className="w-full rounded border border-slate-300 bg-white px-1.5 py-1 text-[11px] focus:outline-none focus:ring-1 focus:ring-slate-400"
|
||||
className="w-full rounded-md border border-line bg-white px-1.5 py-1 text-[11px] focus:border-primary focus:outline focus:outline-2 focus:outline-offset-1 focus:outline-primary"
|
||||
>
|
||||
<option value="">ทุกคน</option>
|
||||
{bookmarkAuthors.map((u) => (
|
||||
|
|
@ -1182,7 +1181,11 @@ export default function AnnotationSidebar({
|
|||
</div>
|
||||
<div>
|
||||
<span className="block pb-0.5">บันทึกวันที่</span>
|
||||
<div className="grid grid-cols-[1fr_auto_1fr] items-center gap-1">
|
||||
{/* เรียง 2 แถวเต็มกว้าง (ไม่ใช่คู่แนวนอน + "–" คั่น): คอลัมน์คู่แคบ
|
||||
~122px ของ sidebar ใส่ค่า "23 ก.ย. 2026" ที่ 16px (rule จอสัมผัส
|
||||
กัน iOS zoom-on-focus) + icon ปฏิทินไม่พอ ปีโดนตัด (code review
|
||||
2026-09-23) — aria-label กำกับช่วงเริ่ม/สุดท้ายอยู่แล้ว */}
|
||||
<div className="grid gap-1">
|
||||
<input
|
||||
type="date"
|
||||
aria-label="วันที่เริ่ม"
|
||||
|
|
@ -1191,9 +1194,8 @@ export default function AnnotationSidebar({
|
|||
setBmConditions({ ...bmConditions, dateFrom: e.target.value })
|
||||
}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
className="w-full min-w-0 rounded border border-slate-300 px-1.5 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-slate-400"
|
||||
className="w-full min-w-0 rounded-md border border-line px-1.5 py-1 text-xs focus:border-primary focus:outline focus:outline-2 focus:outline-offset-1 focus:outline-primary"
|
||||
/>
|
||||
<span className="text-slate-400">–</span>
|
||||
<input
|
||||
type="date"
|
||||
aria-label="วันที่สุดท้าย"
|
||||
|
|
@ -1202,14 +1204,14 @@ export default function AnnotationSidebar({
|
|||
setBmConditions({ ...bmConditions, dateTo: e.target.value })
|
||||
}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
className="w-full min-w-0 rounded border border-slate-300 px-1.5 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-slate-400"
|
||||
className="w-full min-w-0 rounded-md border border-line px-1.5 py-1 text-xs focus:border-primary focus:outline focus:outline-2 focus:outline-offset-1 focus:outline-primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
className={`rounded px-1.5 py-1 text-slate-500 hover:bg-slate-100 ${FOCUS_RING_CLS}`}
|
||||
className={`rounded-md px-1.5 py-1 text-ink-soft hover:bg-ink/[0.05] ${FOCUS_RING_CLS}`}
|
||||
onClick={() => {
|
||||
// UX audit: ล้างให้สิ้นเชิง — คำค้น + เงื่อนไข + ผลที่แสดง พร้อมกัน
|
||||
setBmConditions(EMPTY_BM_CONDITIONS);
|
||||
|
|
@ -1225,12 +1227,12 @@ export default function AnnotationSidebar({
|
|||
{/* error กับ hint เป็นคำเตือนคนละเรื่อง (ฟอร์มผิด vs ผลเป็นของเงื่อนไขเก่า)
|
||||
— มี error แสดงเดี่ยว ไม่ซ้อนสองข้อความพร้อมกัน */}
|
||||
{bmSearchError ? (
|
||||
<p className="px-1 text-[11px] text-red-600">{bmSearchError}</p>
|
||||
<p className="px-1 text-[11px] text-error-ink">{bmSearchError}</p>
|
||||
) : (
|
||||
bmResults !== null &&
|
||||
bmSearchedSig !== null &&
|
||||
bmSearchedSig !== JSON.stringify([bmQuery.trim(), bmConditions]) && (
|
||||
<p className="px-1 text-[11px] text-amber-600">
|
||||
<p className="px-1 text-[11px] text-warning-ink">
|
||||
เงื่อนไขเปลี่ยนแล้ว กด "หา" เพื่อค้นใหม่
|
||||
</p>
|
||||
)
|
||||
|
|
@ -1243,23 +1245,23 @@ export default function AnnotationSidebar({
|
|||
เนื้อหาใน list ด้านล่าง คลิกแถว = กระโดดไปตำแหน่ง + เลือก */}
|
||||
{tab === "annotations" && (
|
||||
<form
|
||||
className="flex shrink-0 gap-1 border-b border-slate-200 p-2"
|
||||
className="flex shrink-0 gap-1 border-b border-line p-2"
|
||||
onSubmit={(e) => void runSearch(e)}
|
||||
>
|
||||
<div className="relative min-w-0 flex-1">
|
||||
<Search className="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-500" />
|
||||
<Search className="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-ink-soft" />
|
||||
<input
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={(e) => e.stopPropagation()} // ไม่ให้ปุ่มในช่องค้นหาชนคีย์ลัดของ viewer
|
||||
placeholder="ค้นหาในหมายเหตุ…"
|
||||
className="w-full rounded-md border border-slate-300 py-1.5 pl-7 pr-2 text-xs focus:outline-none focus:ring-2 focus:ring-slate-400"
|
||||
className="w-full rounded-md border border-line py-1.5 pl-7 pr-2 text-xs focus:border-primary focus:outline focus:outline-2 focus:outline-offset-1 focus:outline-primary"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!searchQuery.trim() || searching}
|
||||
className={`shrink-0 rounded-md border border-slate-300 px-2 py-1.5 text-xs text-slate-600 hover:bg-slate-100 disabled:opacity-40 ${FOCUS_RING_CLS}`}
|
||||
className={`shrink-0 rounded-md border border-line px-2 py-1.5 text-xs text-ink-soft hover:bg-ink/[0.05] disabled:opacity-40 ${FOCUS_RING_CLS}`}
|
||||
>
|
||||
{searching ? "…" : "หา"}
|
||||
</button>
|
||||
|
|
@ -1271,14 +1273,14 @@ export default function AnnotationSidebar({
|
|||
bmResults !== null ? (
|
||||
<div>
|
||||
<div className="flex items-center justify-between px-1 pb-1">
|
||||
<p className="text-[11px] font-semibold tabular-nums text-slate-500">
|
||||
<p className="text-[11px] font-semibold tabular-nums text-ink-soft">
|
||||
ผลการค้นหา ({bmResults.length})
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="ปิดผลการค้นหา"
|
||||
title="กลับไปรายการที่คั่นหน้า"
|
||||
className={`text-slate-500 hover:text-slate-600 ${FOCUS_RING_CLS}`}
|
||||
className={`text-ink-soft hover:text-ink ${FOCUS_RING_CLS}`}
|
||||
onClick={() => setBmResults(null)}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
|
|
@ -1288,14 +1290,14 @@ export default function AnnotationSidebar({
|
|||
แชร์จอให้คนอื่นดู (แก้ฟอร์มหลังค้นบรรทัดนี้ไม่เปลี่ยน จนกด "หา" ใหม่) */}
|
||||
{bmSearchedSummary && (
|
||||
<p
|
||||
className="truncate px-1 pb-1 text-[11px] text-slate-500"
|
||||
className="truncate px-1 pb-1 text-[11px] text-ink-soft"
|
||||
title={bmSearchedSummary}
|
||||
>
|
||||
ค้นด้วย: {bmSearchedSummary}
|
||||
</p>
|
||||
)}
|
||||
{bmResults.length === 0 ? (
|
||||
<p className="px-1 pb-1 text-xs text-slate-500">ไม่พบ</p>
|
||||
<p className="px-1 pb-1 text-xs text-ink-soft">ไม่พบ</p>
|
||||
) : (
|
||||
<ul className="space-y-0.5">
|
||||
{bmResults.map((b) => {
|
||||
|
|
@ -1304,22 +1306,22 @@ export default function AnnotationSidebar({
|
|||
<li key={b.id}>
|
||||
<button
|
||||
type="button"
|
||||
className={`flex w-full items-start gap-1.5 rounded px-1 py-1 text-left hover:bg-slate-100 ${FOCUS_RING_CLS}`}
|
||||
className={`flex w-full items-start gap-1.5 rounded px-1 py-1 text-left hover:bg-ink/[0.05] ${FOCUS_RING_CLS}`}
|
||||
onClick={() => onGoToBookmark(b)}
|
||||
>
|
||||
<Bookmark className="mt-0.5 h-3.5 w-3.5 shrink-0 text-amber-500" />
|
||||
<Bookmark className="mt-0.5 h-3.5 w-3.5 shrink-0 text-warning-ink" />
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-[13px] leading-snug">{b.title}</span>
|
||||
<span className="block text-xs text-slate-500">
|
||||
<span className="block text-xs text-ink-soft">
|
||||
หน้า {b.pageIndex + 1} · {b.userName || "ไม่ทราบชื่อ"}
|
||||
</span>
|
||||
{parent && (
|
||||
<span className="block truncate text-[11px] text-slate-500">
|
||||
<span className="block truncate text-[11px] text-ink-soft">
|
||||
ใน: {parent.title}
|
||||
</span>
|
||||
)}
|
||||
{b.note && (
|
||||
<span className="mt-0.5 block line-clamp-2 whitespace-pre-wrap break-words text-[11px] text-slate-500">
|
||||
<span className="mt-0.5 block line-clamp-2 whitespace-pre-wrap break-words text-[11px] text-ink-soft">
|
||||
{b.note}
|
||||
</span>
|
||||
)}
|
||||
|
|
@ -1332,11 +1334,11 @@ export default function AnnotationSidebar({
|
|||
)}
|
||||
</div>
|
||||
) : bookmarks.length === 0 ? (
|
||||
<p className="p-4 text-center text-xs text-slate-500">
|
||||
<p className="p-4 text-center text-xs text-ink-soft">
|
||||
ยังไม่มีที่คั่นหน้า — กดปุ่มคั่นหน้าบนแถบเครื่องมือเพื่อเพิ่ม
|
||||
</p>
|
||||
) : visibleBookmarks.length === 0 ? (
|
||||
<p className="p-4 text-center text-xs text-slate-500">
|
||||
<p className="p-4 text-center text-xs text-ink-soft">
|
||||
ไม่มีที่คั่นหน้าของเจ้าของหมายเหตุที่เลือก
|
||||
</p>
|
||||
) : (
|
||||
|
|
@ -1373,27 +1375,27 @@ export default function AnnotationSidebar({
|
|||
<li key={b.id}>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-start gap-1.5 rounded py-1 pl-5 pr-1 text-left hover:bg-slate-100"
|
||||
className={`flex w-full items-start gap-1.5 rounded py-1 pl-5 pr-1 text-left hover:bg-ink/[0.05] ${FOCUS_RING_CLS}`}
|
||||
onClick={() => onGoToBookmark(b)}
|
||||
>
|
||||
<Bookmark className="mt-0.5 h-3.5 w-3.5 shrink-0 text-amber-500" />
|
||||
<Bookmark className="mt-0.5 h-3.5 w-3.5 shrink-0 text-warning-ink" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-[13px] leading-snug font-medium text-slate-700">
|
||||
<span className="block truncate text-[13px] leading-snug font-medium text-ink">
|
||||
{b.title}
|
||||
</span>
|
||||
{/* ผู้บันทึกเหมือนแถวผลการค้นหา — ไม่ใส่ "หน้า N"
|
||||
เพราะหัวกลุ่มของมุมมองนี้บอกหน้าอยู่แล้ว */}
|
||||
<span className="block text-xs text-slate-500">
|
||||
<span className="block text-xs text-ink-soft">
|
||||
{b.userName || "ไม่ทราบชื่อ"}
|
||||
</span>
|
||||
{b.note && (
|
||||
<span className="mt-0.5 block line-clamp-2 whitespace-pre-wrap break-words text-[11px] text-slate-500">
|
||||
<span className="mt-0.5 block line-clamp-2 whitespace-pre-wrap break-words text-[11px] text-ink-soft">
|
||||
{b.note}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{childCount > 0 && (
|
||||
<span className="ml-auto shrink-0 self-center rounded-full bg-slate-100 px-1.5 text-[10px] tabular-nums leading-4 text-slate-500">
|
||||
<span className="ml-auto shrink-0 self-center rounded-full bg-ink/[0.05] px-1.5 text-[10px] tabular-nums leading-4 text-ink-soft">
|
||||
{childCount}
|
||||
</span>
|
||||
)}
|
||||
|
|
@ -1420,29 +1422,29 @@ export default function AnnotationSidebar({
|
|||
) : visibleSearchResults !== null ? (
|
||||
<div>
|
||||
<div className="flex items-center justify-between px-1 pb-1">
|
||||
<p className="text-[11px] font-semibold tabular-nums text-slate-500">
|
||||
<p className="text-[11px] font-semibold tabular-nums text-ink-soft">
|
||||
ผลการค้นหา ({visibleSearchResults.length})
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="ปิดผลการค้นหา"
|
||||
title="กลับไปรายการหมายเหตุ"
|
||||
className={`text-slate-500 hover:text-slate-600 ${FOCUS_RING_CLS}`}
|
||||
className={`text-ink-soft hover:text-ink ${FOCUS_RING_CLS}`}
|
||||
onClick={() => setSearchResults(null)}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
{visibleSearchResults.length === 0 ? (
|
||||
<p className="px-1 pb-1 text-xs text-slate-500">ไม่พบ</p>
|
||||
<p className="px-1 pb-1 text-xs text-ink-soft">ไม่พบ</p>
|
||||
) : (
|
||||
// reuse แถวเดิม — li มี data-ann-id ให้ effect scrollIntoView หาเจอ
|
||||
<ul className="space-y-1">{visibleSearchResults.map(renderAnnotationRow)}</ul>
|
||||
)}
|
||||
</div>
|
||||
) : annotations.length === 0 ? (
|
||||
<p className="p-4 text-center text-xs text-slate-500">
|
||||
ยังไม่มีหมายเหตุ — คลุมข้อความ / วาดเส้น / คลิกวางคอมเมนต์บนหน้าเอกสาร
|
||||
<p className="p-4 text-center text-xs text-ink-soft">
|
||||
ยังไม่มีหมายเหตุ — คลุมข้อความ / วาดเส้น / คอมเมนต์บนหน้าเอกสาร
|
||||
<span className="mt-1 block text-[11px]">
|
||||
คีย์ลัด: H มาร์ก · D วาด · C comment · B คั่นหน้า · Ctrl+Z ย้อน
|
||||
</span>
|
||||
|
|
@ -1481,7 +1483,7 @@ export default function AnnotationSidebar({
|
|||
<>
|
||||
{pendingItems.length > 0 && (
|
||||
<section>
|
||||
<p className="px-2 pb-1 pt-2 text-[11px] font-semibold text-slate-500">
|
||||
<p className="px-2 pb-1 pt-2 text-[11px] font-semibold text-ink-soft">
|
||||
ค้างดำเนินการ
|
||||
</p>
|
||||
<ul className="space-y-1">{pendingItems.map(renderAnnotationRow)}</ul>
|
||||
|
|
@ -1489,7 +1491,7 @@ export default function AnnotationSidebar({
|
|||
)}
|
||||
{resolvedItems.length > 0 && (
|
||||
<section>
|
||||
<p className="px-2 pb-1 pt-3 text-[11px] font-semibold text-emerald-500">
|
||||
<p className="px-2 pb-1 pt-3 text-[11px] font-semibold text-success-ink">
|
||||
จัดการแล้ว
|
||||
</p>
|
||||
<ul className="space-y-1">{resolvedItems.map(renderAnnotationRow)}</ul>
|
||||
|
|
@ -1511,35 +1513,35 @@ export default function AnnotationSidebar({
|
|||
{/* F19/A7: dialog ประวัติ — รายการ version (เวลา + ชนิดการเปลี่ยนแปลง) + ปุ่มกู้คืน */}
|
||||
{historyFor && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/40 p-4"
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-ink/40 p-4"
|
||||
onClick={() => setHistoryFor(null)}
|
||||
>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="ประวัติการเปลี่ยนแปลง"
|
||||
className="w-96 max-w-full rounded-lg border border-slate-200 bg-white p-4 shadow-xl"
|
||||
className="w-96 max-w-full rounded-lg border border-line bg-white p-4 shadow-xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<p className="text-sm font-semibold">ประวัติการเปลี่ยนแปลง</p>
|
||||
<p className="mt-0.5 truncate text-[11px] text-slate-500">
|
||||
<p className="mt-0.5 truncate text-[11px] text-ink-soft">
|
||||
{annotationPreviewText(historyFor)}
|
||||
</p>
|
||||
{historyError && (
|
||||
<p className="mt-2 rounded bg-red-50 px-2 py-1 text-[11px] text-red-600">
|
||||
<p className="mt-2 rounded bg-error/10 px-2 py-1 text-[11px] text-error-ink">
|
||||
{historyError}
|
||||
</p>
|
||||
)}
|
||||
{versions === null && !historyError && (
|
||||
<p className="mt-3 text-xs text-slate-500">กำลังโหลด…</p>
|
||||
<p className="mt-3 text-xs text-ink-soft">กำลังโหลด…</p>
|
||||
)}
|
||||
{versions !== null && versions.length === 0 && (
|
||||
<p className="mt-3 text-xs text-slate-500">
|
||||
<p className="mt-3 text-xs text-ink-soft">
|
||||
ยังไม่มีบันทึกการเปลี่ยนแปลง (เกิดขึ้นเมื่อแก้ไขหรือลบรายการนี้)
|
||||
</p>
|
||||
)}
|
||||
{versions !== null && versions.length > 0 && (
|
||||
<ul className="mt-3 max-h-[60vh] divide-y divide-slate-100 overflow-y-auto">
|
||||
<ul className="mt-3 max-h-[60vh] divide-y divide-line/60 overflow-y-auto">
|
||||
{versions.map((v, i) => {
|
||||
// สิ่งที่เปลี่ยนของรอบนี้ = snapshot ก่อนแก้ (v.data) เทียบ state
|
||||
// ถัดไป: snapshot ของรอบแก้ที่ใหม่กว่าถัดไป (i-1) หรือค่าปัจจุบัน
|
||||
|
|
@ -1559,11 +1561,11 @@ export default function AnnotationSidebar({
|
|||
<span className="block text-xs font-medium">
|
||||
{v.kind === "delete" ? "ถูกลบ" : "ถูกแก้ไข"}
|
||||
</span>
|
||||
<span className="block text-[11px] text-slate-500" suppressHydrationWarning>
|
||||
<span className="block text-[11px] text-ink-soft" suppressHydrationWarning>
|
||||
{formatThaiDate(v.createdAt, true)} · โดย {v.userName}
|
||||
</span>
|
||||
{changes.map((c) => (
|
||||
<span key={c} className="mt-0.5 block text-[11px] text-slate-600">
|
||||
<span key={c} className="mt-0.5 block text-[11px] text-ink-soft">
|
||||
{c}
|
||||
</span>
|
||||
))}
|
||||
|
|
@ -1571,7 +1573,7 @@ export default function AnnotationSidebar({
|
|||
<button
|
||||
type="button"
|
||||
disabled={restoringId !== null}
|
||||
className={`mt-0.5 shrink-0 rounded border border-slate-300 px-2 py-1 text-[11px] text-slate-600 hover:bg-slate-100 disabled:opacity-40 ${FOCUS_RING_CLS}`}
|
||||
className={`mt-0.5 shrink-0 rounded-md border border-line px-2 py-1 text-[11px] text-ink-soft hover:bg-ink/[0.05] disabled:opacity-40 ${FOCUS_RING_CLS}`}
|
||||
onClick={() => void handleRestore(v.id)}
|
||||
>
|
||||
{restoringId === v.id ? "กำลังกู้คืน…" : "กู้คืน"}
|
||||
|
|
@ -1585,7 +1587,7 @@ export default function AnnotationSidebar({
|
|||
<button
|
||||
type="button"
|
||||
autoFocus
|
||||
className={`rounded px-3 py-1.5 text-slate-500 hover:bg-slate-100 ${FOCUS_RING_CLS}`}
|
||||
className={`rounded-md px-3 py-1.5 text-ink-soft hover:bg-ink/[0.05] ${FOCUS_RING_CLS}`}
|
||||
onClick={() => setHistoryFor(null)}
|
||||
>
|
||||
ปิด
|
||||
|
|
@ -1605,7 +1607,7 @@ export default function AnnotationSidebar({
|
|||
data-tool-menu=""
|
||||
role="menu"
|
||||
aria-label={menuFor.kind === "annotation" ? "จัดการหมายเหตุ" : "จัดการหัวข้อ"}
|
||||
className="w-60 rounded-lg border border-slate-200 bg-white p-1.5 shadow-lg"
|
||||
className="w-60 rounded-lg border border-line bg-white p-1.5 shadow-lg"
|
||||
style={{
|
||||
position: "fixed",
|
||||
left: menuPos.left,
|
||||
|
|
@ -1619,20 +1621,20 @@ export default function AnnotationSidebar({
|
|||
>
|
||||
{menuConfirmDelete ? (
|
||||
<div className="p-1">
|
||||
<p className="px-1 text-xs text-slate-700">
|
||||
<p className="px-1 text-xs text-ink">
|
||||
ลบ{menuFor.kind === "annotation" ? "รายการ" : "หัวข้อ"}นี้?
|
||||
</p>
|
||||
<div className="mt-1.5 flex justify-end gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
className={`rounded px-2 py-1 text-xs text-slate-500 hover:bg-slate-100 ${FOCUS_RING_CLS}`}
|
||||
className={`rounded-md px-2 py-1 text-xs text-ink-soft hover:bg-ink/[0.05] ${FOCUS_RING_CLS}`}
|
||||
onClick={closeRowMenu}
|
||||
>
|
||||
ยกเลิก
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`rounded bg-red-600 px-2 py-1 text-xs font-medium text-white hover:bg-red-700 ${FOCUS_RING_CLS}`}
|
||||
className={`rounded-md border border-error px-2 py-1 text-xs font-medium text-error-ink hover:bg-error/10 ${FOCUS_RING_CLS}`}
|
||||
onClick={() => {
|
||||
// อ่าน kind/id ก่อน close — setMenuFor(null) แล้วเงื่อนไขเดิมหาย
|
||||
if (menuFor.kind === "annotation") onDeleteAnnotation(menuFor.id);
|
||||
|
|
@ -1678,7 +1680,7 @@ export default function AnnotationSidebar({
|
|||
)}
|
||||
{menuAnn.userId === userId && (
|
||||
<>
|
||||
<div className="my-1 border-t border-slate-100" />
|
||||
<div className="my-1 border-t border-line/60" />
|
||||
<MenuItem
|
||||
danger
|
||||
Icon={Trash2}
|
||||
|
|
@ -1713,7 +1715,7 @@ export default function AnnotationSidebar({
|
|||
)}
|
||||
{menuBm.userId === userId && (
|
||||
<>
|
||||
<div className="my-1 border-t border-slate-100" />
|
||||
<div className="my-1 border-t border-line/60" />
|
||||
<MenuItem
|
||||
danger
|
||||
Icon={Trash2}
|
||||
|
|
|
|||
|
|
@ -8,8 +8,8 @@ import { Bookmark, ChevronDown, ChevronRight, PenLine } from "lucide-react";
|
|||
* ครบ ผู้ใช้พับเฉพาะกลุ่มที่ไม่สนใจ) — รายการ (children) ส่งมาจาก server component
|
||||
* ได้ตรง ๆ และเยื้องเข้า (pl-5) ให้หัวข้อกับรายการไม่ชิดเส้นซ้ายเดียวกัน */
|
||||
const HEADS = {
|
||||
bookmark: { label: "Bookmark", Icon: Bookmark, iconCls: "text-amber-500" },
|
||||
annotation: { label: "Annotation", Icon: PenLine, iconCls: "text-slate-400" },
|
||||
bookmark: { label: "Bookmark", Icon: Bookmark, iconCls: "text-warning-ink" },
|
||||
annotation: { label: "Annotation", Icon: PenLine, iconCls: "text-ink-faint" },
|
||||
} as const;
|
||||
|
||||
export default function CollapsibleBlock({
|
||||
|
|
@ -35,17 +35,17 @@ export default function CollapsibleBlock({
|
|||
aria-expanded={open}
|
||||
aria-controls={contentId}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="flex w-full items-center gap-1.5 rounded-md py-1.5 text-left text-xs font-semibold text-slate-500 hover:text-slate-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1"
|
||||
className="focus-ring flex w-full items-center gap-1.5 rounded-md py-1.5 text-left text-xs font-semibold text-ink-soft hover:text-ink"
|
||||
>
|
||||
{open ? (
|
||||
<ChevronDown aria-hidden className="h-3.5 w-3.5 shrink-0 text-slate-400" />
|
||||
<ChevronDown aria-hidden className="h-3.5 w-3.5 shrink-0 text-ink-faint" />
|
||||
) : (
|
||||
<ChevronRight aria-hidden className="h-3.5 w-3.5 shrink-0 text-slate-400" />
|
||||
<ChevronRight aria-hidden className="h-3.5 w-3.5 shrink-0 text-ink-faint" />
|
||||
)}
|
||||
<Icon aria-hidden className={`h-3.5 w-3.5 shrink-0 ${iconCls}`} />
|
||||
{label}
|
||||
<span className="font-normal tabular-nums">({count})</span>
|
||||
<span aria-hidden className="ml-1 h-px min-w-4 flex-1 bg-slate-100" />
|
||||
<span aria-hidden className="ml-1 h-px min-w-4 flex-1 bg-line" />
|
||||
</button>
|
||||
</h3>
|
||||
{open && (
|
||||
|
|
|
|||
|
|
@ -134,7 +134,7 @@ export const files = pgTable(
|
|||
pdfUrl: text("pdf_url").notNull(),
|
||||
/** N8: dedupe key เอกสารนำเข้าจากลิงก์ e-Court ("ecourt:<fileId>" / "url:<href>") — NULL = ไฟล์ seed/อัปโหลด */
|
||||
externalId: text("external_id"),
|
||||
/** N9: รหัสกลุ่มเอกสารที่เกี่ยวเนื่องกัน เช่น คดีเดียวกัน (จากลิงก์ e-Court `?typeId=` — NULL = ไม่มีกลุ่ม)
|
||||
/** N9: รหัสกลุ่มเอกสารที่เกี่ยวเนื่องกัน เช่น คดีเดียวกัน (จากลิงก์ e-Court `?typeId=` หรือช่องกลุ่มของฟอร์มอัปโหลด N4 — NULL = ไม่มีกลุ่ม)
|
||||
* ไม่มีคอลัมน์ชื่อกลุ่ม — แสดงผลด้วยรหัสกลุ่มโดยตรง (ถอด type_name ตามคำขอผู้ใช้ 2026-09-22) */
|
||||
typeId: text("type_id"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
|
|
|
|||
|
|
@ -11,8 +11,9 @@ WORKDIR /app
|
|||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
# ประกันว่า pdf.worker.min.mjs ตรงกับเวอร์ชัน pdfjs-dist ใน node_modules เสมอ
|
||||
# (postinstall รันใน stage deps ซึ่งยังไม่มี public/ ของ repo)
|
||||
RUN node -e "require('fs').copyFileSync('node_modules/pdfjs-dist/build/pdf.worker.min.mjs','public/pdf.worker.min.mjs')"
|
||||
# (postinstall รันใน stage deps ซึ่งยังไม่มี public/ ของ repo) — ต้องใช้ตัว patch ด้วย
|
||||
# ไม่ใช่ copyFileSync เปล่า ๆ ไม่งั้น worker ที่ patch fake-space ไทยถูกเขียนทับด้วยตัวดิบ
|
||||
RUN node scripts/patch-pdf-worker.mjs
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
# db/index.ts throw ถ้าไม่มี DATABASE_URL ตอน next build เก็บ page data —
|
||||
# ใส่ค่า dummy (postgres.js เชื่อมแบบ lazy ไม่มี query ตอน build) ตัวจริงฉีดตอน docker run
|
||||
|
|
|
|||
78
docs/DESIGN.md
Normal file
78
docs/DESIGN.md
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
# Genesis
|
||||
|
||||
## Overview
|
||||
An editorial precision interface for **Judge's Note** — a Thai court PDF annotation app (judes-note) used daily by judges on desktops and iPads. The aesthetic is quietly confident — bold display typography, generous spacing, and gallery-frame card surfaces. The mood is professional and modern without being sterile. High information density balanced by breathing room. (Text is primarily Thai; Latin display type carries the brand voice in headings and numbers.)
|
||||
|
||||
## Colors
|
||||
- **Primary** (rgb(0 51 161 / <alpha-value>) / #0033A1): CTAs, active states, links, focus indicators, interactive highlights — royal blue
|
||||
- **Primary Hover** (#002780): Darker blue for hover states on primary elements
|
||||
- **Neutral** (#9C9C9C): Placeholders, disabled states — decorative metadata only (see `ink` families for readable text)
|
||||
- **Background** (#FAFAFA): Page background, light warm gray
|
||||
- **Surface** (#FFFFFF): Cards, panels, modals, nav backdrop
|
||||
- **Text Primary** (#0A0A0A): Headings, body text, primary labels — near-black
|
||||
- **Text Secondary** (#6B6B6B): Descriptions, metadata, secondary labels — passes 4.5:1 on white; use for meaningful small text (dates, counts, authors)
|
||||
- **Border** (#E8E8EC): Card borders, dividers, input borders — subtle and recessive
|
||||
- **Success** (#10B981): Positive status — borders and tinted backgrounds (`success/10`–`/15`)
|
||||
- **Warning** (#F59E0B): Pending status, caution — borders and tinted backgrounds (`warning/10`–`/15`)
|
||||
- **Error** (#EF4444): Destructive actions, validation errors — borders and tinted backgrounds (`error/10`–`/15`)
|
||||
- **Success Ink** (#047857), **Warning Ink** (#B45309), **Error Ink** (#B91C1C): Text- and icon-safe variants of the status colors (≥5:1 on white). Any status *text or icon* on a light surface uses the `-ink` token; the base 500 tokens are for fills, borders, and tints only. Never reach for raw Tailwind `amber-*`/`emerald-*`/`red-*` shades.
|
||||
|
||||
Note: the template's Secondary green (#20970B) has no role in this app and is not registered as a token. Code font (JetBrains Mono) is likewise unused — no code UI in this app.
|
||||
|
||||
## Typography
|
||||
- **Display Font**: General Sans — loaded from Fontshare
|
||||
- **Body Font**: DM Sans — loaded from Google Fonts
|
||||
- **Code Font**: JetBrains Mono — loaded from Google Fonts
|
||||
|
||||
Display and heading text uses General Sans at bold weight with tight letter spacing (-0.03em to -0.04em). Body and UI text uses DM Sans at regular and medium weights. The contrast between the geometric display font and the humanist body font creates a refined editorial feel. Code blocks, API keys, and CLI commands use JetBrains Mono at regular weight.
|
||||
|
||||
Type scale: Display 72px, Headline 60px, Section heading 32px, Subhead 24px, Body 15px, Small 13px, Caption 12px, Overline 11px uppercase.
|
||||
|
||||
## Elevation
|
||||
This design uses minimal shadows. Cards rest flat with a 1px border and gain a subtle shadow on hover (0 8px 30px rgba(0,0,0,0.08)) combined with a -2px vertical lift. Primary buttons gain a tinted glow shadow on hover (0 4px 12px rgba(0,51,161,0.35)). The nav uses backdrop-blur rather than a shadow to convey elevation. Dropdowns and popovers use shadow-lg. Focus indicators use a 2px solid royal-blue outline with a 2px offset (≥3:1 against adjacent surfaces, WCAG 1.4.11/2.4.13) — a translucent halo alone is too faint to qualify.
|
||||
|
||||
## Components
|
||||
- **Buttons**: Primary uses blue fill with white text, 6px radius, medium weight. Secondary uses transparent bg with 1px border, same radius. Ghost has no border or bg, just text color change on hover. Destructive uses red text with red border. All buttons shift up 1px on hover. Sizes: small (32px), medium (38px), large (44px).
|
||||
- **Cards**: White surface, 1px subtle border, 12px radius, overflow hidden. Content cards (file cards, form cards) stack a content area with title, metadata, and actions. Hover lifts the card 2px and increases shadow. Transition duration 200ms.
|
||||
- **Inputs**: 1px subtle border, surface background, 6px radius, 8px vertical and 14px horizontal padding, 14px font size (16px on touch devices — iOS zooms on focus below 16px). Focus: border turns blue with the 2px outlined focus indicator. Error: border turns red. Placeholder text uses muted color.
|
||||
- **Chips**: Tag chips use rounded-full (pill shape), gray-100 background, gray-600 text, 4px vertical and 12px horizontal padding, 12px font size. Active state: blue background with white text. Status chips follow the same shape but use semantic colors (green for published, yellow for pending, red for rejected).
|
||||
- **Lists**: Stacked rows with 1px dividers between items. Each row is flex with space-between, 12px vertical and 16px horizontal padding. Hover: subtle background change.
|
||||
- **Checkboxes**: 20px size, rounded-full, gray-200 unchecked, blue checked with white checkmark. Used as toggle switches for preferences.
|
||||
- **Tooltips**: Native browser tooltips via title attributes. No custom tooltip component currently.
|
||||
- **Navigation**: Sticky top nav with backdrop-blur, 56px height, 1px bottom border. Logo left, links center (desktop) or hamburger drawer (mobile), user avatar dropdown right. Nav links: 14px medium weight, hover shows bg-alt background.
|
||||
- **Search**: Global search triggered by ⌘K. Rendered as a rounded-xl bar with magnifying glass icon and keyboard shortcut badge.
|
||||
|
||||
## Spacing
|
||||
- Base unit: 4px
|
||||
- Scale: 4, 8, 12, 16, 20, 24, 32, 40, 48, 64, 80, 96px
|
||||
- Component padding: small 8×12, medium 10×16, large 12×24
|
||||
- Section spacing: 32px mobile, 48px tablet, 64px desktop
|
||||
- Container max width: 1280px with 24px horizontal padding
|
||||
- Card grid gap: 20-24px
|
||||
|
||||
## Border Radius
|
||||
- 4px: Tags, chips, badges, inline code — and menu/list rows inside dropdowns, panels, and popovers (a row belongs to its container, not a standalone button)
|
||||
- 6px: Buttons, inputs, selects — including small action buttons inside popups, composers, and dialogs; a cancel/save pair must always share the same radius
|
||||
- 8px: Metadata cards, dropdowns, panels
|
||||
- 12px: Kit preview cards, search bar, featured sections
|
||||
- 9999px: Avatars, status dots, pill badges
|
||||
|
||||
## Do's and Don'ts
|
||||
- Do use blue (rgb(0 51 161 / <alpha-value>)) only for interactive elements — never for decoration or static text. Exceptions (brand marks, agreed 2026-09-23): the JN logo tile, and the blue header band of the summary page. Static numbers (e.g. folio numbers) use `ink`.
|
||||
- Do maintain the 4px spacing grid for all padding, margins, and gaps
|
||||
- Do use General Sans for headings and DM Sans for body — never swap them
|
||||
- Do keep kit cards at 12px radius and buttons/inputs at 6px — don't mix these values
|
||||
- Do provide sufficient contrast in both light and dark modes — test both
|
||||
- Do use the `-ink` status tokens for status text/icons and the 500 tokens for fills, borders, and tints
|
||||
- Don't use raw Tailwind palette shades (slate, amber, emerald, red…) — only the tokens registered in tailwind.config.ts
|
||||
- Don't use pure black (#000000) or pure white (#FFFFFF) for text — use the defined palette values
|
||||
- Don't add decorative gradients or illustrations — the interactive dot grid is the only decorative element
|
||||
- Don't use shadows on static elements — reserve shadow elevation for hover and focus states (floating layers and document pins excepted — see App-specific exceptions)
|
||||
- Don't use more than two font weights on a single screen (dense working-surface screens such as the document viewer are excepted — see App-specific exceptions)
|
||||
- Don't place more than one primary (filled blue) button in the same view section
|
||||
|
||||
## App-specific exceptions
|
||||
- The document viewer header is a solid white bar (not the nav's backdrop-blur): it hosts a dense tool row where blur would smear the scrolled document beneath. Only the site nav (AppNav) blurs.
|
||||
- Comment pins on the document page carry a small static shadow: they are floating markers that must lift off the paper layer, in the same spirit as dropdown/popover elevation (agreed 2026-09-24).
|
||||
- The document viewer (working surface) uses three font weights on one screen — 400 body, 500 labels/buttons, 600 panel/file titles. The two-weight rule still holds for content pages (agreed 2026-09-24).
|
||||
- The text-selection preview in the viewer (drag-preview tint of the select/comment/textbox/stamp tools) is a translucent tint of primary (`rgb(0 51 161 / 0.3)`), keeping every blue on the screen in the brand hue (agreed 2026-09-24).
|
||||
|
|
@ -2,8 +2,9 @@
|
|||
|
||||
> สถานะ: **implement แล้ว (2026-09-23)** — `tsc` + vitest 142/142 + Playwright ผ่านทุกข้อ verify ของ task 3–4 + plan-reviewer/code-reviewer ไม่พบ violation (คงเหลือ e2e ลิงก์ e-Court จริงตามแผน ตอนปิดงาน N8) — เขียนใหม่ 2026-09-22 แทนร่างเดิม "Document Set จัดชุดมือใน UI" (2026-09-17, commit 6940b38) ตามคำขอผู้ใช้: ให้ e-Court บอกกลุ่มผ่านลิงก์แทนการจัดชุดด้วยมือ
|
||||
> เพิ่มเติม (2026-09-23 ตามคำขอผู้ใช้): การ์ดไฟล์หน้า `/groups/[typeId]` หุบ/กางดู bookmark+annotation ทั้งไฟล์ (ปิด gap 5.3.10 ครึ่งหลังที่เคยเป็น out of scope) + ย่อข้อความหมายเหตุเหลือ "แสดงเฉพาะเอกสารที่เคยเปิดในระบบนี้"
|
||||
> เพิ่มเติม (2026-09-23 ตามคำขอผู้ใช้): ฟอร์มอัปโหลด N4 เพิ่มช่อง **"กลุ่ม typeId"** ทางเลือก (ไม่กรอก = ไม่มีกลุ่ม) — `uploadFile` กลายเป็นจุดเขียน `type_id` ที่สองนอกจาก N8 (insert ตอนสร้าง row ใหม่เท่านั้น normalize เหมือน N8: trim → ว่าง = NULL, cap 200) — ยังไม่มี UI แก้กลุ่มของไฟล์ที่มีอยู่เดิม
|
||||
> เลขฟีเจอร์: ร่างเดิม (2026-09-17) เคยใช้เลข N8 — ยกให้แผนลิงก์ e-Court (`docs/PLAN-ECOURT.md`) ซึ่งเป็น dependency ต้นทาง แผนนี้จึงเป็น **N9**
|
||||
> Dependency: **N8** — การรับ/refresh `typeId` เกิดที่ `registerExternalFile` ของ N8 ทั้งหมด (แผนนี้ไม่มี code path ที่เขียน `type_id` เอง) — **N8 implement ครบแล้ว (2026-09-22 รวม D5)**: schema `type_id` + index มากับ migration `drizzle/0002` ด้วย
|
||||
> Dependency: **N8** — การรับ/refresh `typeId` ของเอกสารจากลิงก์เกิดที่ `registerExternalFile` ของ N8 ทั้งหมด (เดิมแผนนี้ไม่มี code path ที่เขียน `type_id` เอง — ต่อมาเพิ่มช่องกลุ่มของฟอร์มอัปโหลด N4 ตามคำขอผู้ใช้ 2026-09-23) — **N8 implement ครบแล้ว (2026-09-22 รวม D5)**: schema `type_id` + index มากับ migration `drizzle/0002` ด้วย
|
||||
> ตาม governance ของโปรเจกต์: implement ต้องเขียนสเปกลง `docs/PLAN-FEAT.md` §7 (**N9**) + ขยาย whitelist ใน `CLAUDE.md` + อัปเดต `docs/PLAN.md` §3 (schema) ก่อน — เป็น task แรกของแผน แล้วรันผ่าน `/implement-feat N9`
|
||||
|
||||
## Context
|
||||
|
|
@ -15,7 +16,7 @@
|
|||
## การตัดสินใจ (ยืนยันกับผู้ใช้แล้ว 2026-09-22)
|
||||
|
||||
1. **กลุ่มระดับเดียว กำหนดด้วย `typeId` จากลิงก์ e-Court** — เพิ่ม column บนตาราง `files` ค่าเดียว (`type_id` — ถอด `type_name` ตามคำขอผู้ใช้ 2026-09-22, แสดงผลด้วยรหัสกลุ่มโดยตรง) — ไม่มีตารางกลุ่มแยก ไม่มี many-to-many ไม่มีหลายระดับ (เอกสารหนึ่งอยู่กลุ่มเดียวตามธรรมชาติของงาน) — ความหมายของ id เป็นของ e-Court ฝั่งเราถือว่า "ส่งมาเหมือนกัน = กลุ่มเดียวกัน"
|
||||
2. **เข้ามาอัตโนมัติทั้งหมด ไม่มี UI จัดการกลุ่ม** — ไฟล์ได้กลุ่มเมื่อเปิดผ่านลิงก์ที่มี `typeId` (เขียนที่ N8) — กลุ่มเกิด/หายตามข้อมูลจริงใน `files` ไม่มีสถานะกลุ่มค้างในตารางอื่น
|
||||
2. **เข้ามาอัตโนมัติ ไม่มี UI จัดการกลุ่ม** — ไฟล์ได้กลุ่มเมื่อเปิดผ่านลิงก์ที่มี `typeId` (เขียนที่ N8) หรือกรอกช่อง "กลุ่ม typeId" ตอนอัปโหลด N4 (เพิ่ม 2026-09-23) — กลุ่มเกิด/หายตามข้อมูลจริงใน `files` ไม่มีสถานะกลุ่มค้างในตารางอื่น ไม่มีหน้าทางย้ายไฟล์ที่มีอยู่เดิมเข้า/ออกกลุ่ม
|
||||
3. **หน้ารายการ `/` จัดกลุ่มตาม `typeId`** (section ต่อกลุ่ม + ส่วน "ไม่มีกลุ่ม" ท้ายสุด) + **หน้าใหม่ `/groups/[typeId]`** แสดงเอกสารทั้งกลุ่มที่รู้จัก พร้อมหมายเหตุ "แสดงเฉพาะเอกสารที่เคยเปิดในระบบนี้" และการ์ดแต่ละไฟล์หุบ/กางดู bookmark+annotation ของไฟล์นั้นได้ (2026-09-23)
|
||||
|
||||
## Design
|
||||
|
|
@ -23,21 +24,21 @@
|
|||
### D1 — Schema (`db/schema.ts` + `drizzle/`)
|
||||
|
||||
```ts
|
||||
// files เพิ่ม (เก็บเฉพาะเอกสารนำเข้าจากลิงก์ — ไฟล์ seed/อัปโหลด = NULL เสมอ):
|
||||
// files เพิ่ม (เอกสารจากลิงก์ e-Court / อัปโหลด N4 ที่กรอกกลุ่ม — ไฟล์ seed และอัปโหลดที่ไม่กรอก = NULL เสมอ):
|
||||
typeId: text("type_id"),
|
||||
// + index ("files_type_id_idx") บน type_id
|
||||
```
|
||||
|
||||
- ไม่มี FK/ตาราง `document_sets` (ต่างจากร่างเดิม) — กลุ่ม = ค่า distinct ของ `type_id` ณ ตอน query
|
||||
- migration: `/sync-db` (push) + **`npx drizzle-kit generate`** — rule data-layer: Docker migrate จาก SQL ใน `drizzle/` เท่านั้น; ไฟล์ generated idempotent (`IF NOT EXISTS`)
|
||||
- การเขียน `type_id` ทั้งหมดเป็นของ N8 `registerExternalFile` (insert ครั้งแรก + update เมื่อลิงก์รอบใหม่ส่ง `typeId` มา — ดู PLAN-ECOURT.md D1) — แผนนี้**อ่านอย่างเดียว**
|
||||
- การเขียน `type_id` สองที่: N8 `registerExternalFile` (insert ครั้งแรก + update เมื่อลิงก์รอบใหม่ส่ง `typeId` มา — ดู PLAN-ECOURT.md D1) และ `uploadFile` ของ N4 ช่องกลุ่มทางเลือก (insert ตอนสร้าง row ใหม่เท่านั้น — เพิ่ม 2026-09-23) — แผนนี้ (N9) **อ่านอย่างเดียว**
|
||||
- ไม่มี DTO/mapper ใหม่ — หน้าที่ใช้เป็น server component ทั้งหมด ข้อมูลข้ามไป client เป็น plain object `{id, title, typeId}` (ถ้าต้องส่ง `createdAt` ข้าม boundary = ISO string ตาม invariant #5)
|
||||
|
||||
### D2 — หน้ารายการ `/` จัดกลุ่ม (`app/page.tsx`)
|
||||
|
||||
- query `files` เหมือนเดิม → แบ่งเป็น section ต่อกลุ่ม (`type_id` ไม่ NULL) เรียงตาม `createdAt` ของไฟล์ล่าสุดในกลุ่ม (กลุ่มที่มี activity ใหม่อยู่บน) — การ์ดไฟล์ในกลุ่มเรียง `createdAt` เหมือนเดิม + ปุ่ม Annotate เดิม
|
||||
- หัวกลุ่ม = `type_id` + จำนวนไฟล์ + **ลิงก์ "เอกสารในกลุ่ม" → `/groups/{encodeURIComponent(typeId)}`** (icon Folder)
|
||||
- ไฟล์ที่ `type_id` NULL (seed / อัปโหลด N4 / ลิงก์แบบไม่มี typeId) → section **"ไม่มีกลุ่ม"** ไว้ท้ายสุด (ใกล้ฟอร์มเพิ่มเอกสาร) — แสดงเสมอถ้ามีไฟล์
|
||||
- ไฟล์ที่ `type_id` NULL (seed / อัปโหลด N4 ที่ไม่กรอกกลุ่ม / ลิงก์แบบไม่มี typeId) → section **"ไม่มีกลุ่ม"** ไว้ท้ายสุด (ใกล้ฟอร์มเพิ่มเอกสาร) — แสดงเสมอถ้ามีไฟล์
|
||||
- ไม่มี client component ใหม่ ไม่มี action ใหม่ — จัดกลุ่มเป็นการแสดงผลล้วนบน server component เดิม
|
||||
|
||||
### D3 — หน้า `/groups/[typeId]` แสดงเอกสารในกลุ่ม (`app/groups/[typeId]/page.tsx`)
|
||||
|
|
@ -83,7 +84,7 @@ Server component `force-dynamic` (login ถูกบังคับโดย `mi
|
|||
|
||||
1. **ลิงก์รอบใหม่ส่ง `typeId` คนละค่า** → ย้ายกลุ่มตามค่าล่าสุด (เกิดที่ N8) — annotation/bookmark ผูกกับ `file_id` ไม่เกี่ยวกับกลุ่ม จึงตามไฟล์ไปเอง
|
||||
2. **ลิงก์รอบใหม่ไม่ส่ง `typeId`** → ไฟล์คงกลุ่มเดิม (N8: ห้ามเคลียร์ค่าเดิม)
|
||||
3. **ไฟล์ไม่เคยมี `typeId`** (seed / อัปโหลด N4 / ลิงก์เก่า) → section "ไม่มีกลุ่ม" บนหน้า `/`, ไม่มีหน้ากลุ่ม
|
||||
3. **ไฟล์ไม่เคยมี `typeId`** (seed / อัปโหลด N4 ที่ไม่กรอกกลุ่ม / ลิงก์เก่า) → section "ไม่มีกลุ่ม" บนหน้า `/`, ไม่มีหน้ากลุ่ม
|
||||
4. **กลุ่มแสดงผลด้วยรหัสล้วน** — ไม่มีคอลัมน์ชื่อกลุ่ม (ถอด `type_name` ตามคำขอผู้ใช้ 2026-09-22) — ถ้าภายหลังต้องการชื่อกลุ่ม กลับมาเพิ่ม column ใหม่ตาม governance
|
||||
5. **`typeId` มีอักขระพิเศษ/ยาว** → ผ่าน URL ด้วย `encodeURIComponent`; normalize ที่ N8 (trim → ว่าง = NULL, cap 200); Next decode ให้อัตโนมัติตอนอ่าน `params`
|
||||
6. **กลุ่มว่างลง (ไฟล์ย้ายออกหมด)** → กลุ่มหายจากหน้า `/` เอง และ `/groups/[typeId]` กลายเป็น 404 (query จาก `files` จริง ไม่มีตารางกลุ่มค้าง)
|
||||
|
|
@ -102,7 +103,7 @@ Server component `force-dynamic` (login ถูกบังคับโดย `mi
|
|||
|
||||
## Verification (ภาพรวม end-to-end — หลัง N8 เสร็จ)
|
||||
|
||||
1. `npm run dev` → login → เปิดลิงก์ e-Court 2 ไฟล์ที่ `typeId` เดียวกัน + 1 ไฟล์ `typeId` ต่างกลุ่ม → หน้า `/` จัด 2 section ตามกลุ่ม + section "ไม่มีกลุ่ม" (ไฟล์ seed/อัปโหลด)
|
||||
1. `npm run dev` → login → เปิดลิงก์ e-Court 2 ไฟล์ที่ `typeId` เดียวกัน + 1 ไฟล์ `typeId` ต่างกลุ่ม → หน้า `/` จัด 2 section ตามกลุ่ม + section "ไม่มีกลุ่ม" (ไฟล์ seed/อัปโหลดที่ไม่กรอกกลุ่ม); อัปโหลดไฟล์พร้อมกรอกกลุ่ม → การ์ดไปอยู่ section ของกลุ่มนั้น + หน้า `/groups/[typeId]` เห็นไฟล์อัปโหลดด้วย
|
||||
2. หัวกลุ่ม → "เอกสารในกลุ่ม" → `/groups/[typeId]`: เห็นไฟล์ครบตามลำดับ + หมายเหตุ "แสดงเฉพาะเอกสารที่เคยเปิดในระบบนี้" + จำนวน annotation/bookmark ต่อไฟล์
|
||||
3. กาง "Annotations & Bookmarks (N)" ในการ์ดไฟล์ → เห็น bookmark+annotation ทั้งไฟล์เรียงตามหน้า + กดแถวแต่ละรายการเด้งไปตำแหน่งนั้นใน viewer (`?ann=` / `?page=`)
|
||||
4. กด Annotate → เข้า viewer ของไฟล์นั้นได้ตามปกติ
|
||||
|
|
|
|||
|
|
@ -303,8 +303,10 @@ Dependencies เพิ่มเติม: F3 ← F2 · F12/F13/F19 ← F1 · F15
|
|||
|
||||
### N4 · อัปโหลดไฟล์แบบง่าย (2026-09-15)
|
||||
|
||||
- **UI:** ฟอร์มเดียวบนหน้า `/` (`components/UploadFileForm.tsx`) — `<input type="file" accept=".pdf,.jpg,.jpeg,.png,.tif,.tiff">` + ปุ่มอัปโหลด (`useTransition`, disabled ระหว่างส่ง) รับทั้ง PDF และรูป
|
||||
- **Server Action `uploadFile`** (`app/actions/files.ts`): รับ `FormData` — validate extension (whitelist เดียวกับ `SAMPLE_FILE_EXTENSIONS`) + ขนาด ≤ 100MB + ไฟล์ไม่ว่าง → เขียน `public/uploads/<uuid>.<ext>` (**ชื่อไฟล์บนดิสก์จาก randomUUID เท่านั้น** — กันชนกันและกัน path traversal; `title` = ชื่อไฟล์เดิมที่ sanitize) → insert `files (title, pdf_url)` → `revalidatePath("/")` — ไม่มี SSE event (รายการไฟล์ไม่ได้ sync ผ่าน SSE)
|
||||
> เพิ่มเติม (2026-09-23 ตามคำขอผู้ใช้): ช่อง **"กลุ่ม typeId"** ทางเลือกในฟอร์ม (ไม่กรอก = ไม่มีกลุ่ม) — ไฟล์อัปโหลดเข้ากลุ่ม N9 ได้โดยไม่ต้องผ่านลิงก์ e-Court; normalize เหมือน N8 (`trim` → ว่าง = NULL, cap 200 — `normTypeId` mirroring `normGroup`)
|
||||
|
||||
- **UI:** ฟอร์มเดียวบนหน้า `/` (`components/UploadFileForm.tsx`) — `<input type="file" accept=".pdf,.jpg,.jpeg,.png,.tif,.tiff">` + ช่อง "กลุ่ม typeId" ทางเลือก (uncontrolled ส่งไปกับ FormData) + ปุ่มอัปโหลด (`useTransition`, disabled ระหว่างส่ง) รับทั้ง PDF และรูป
|
||||
- **Server Action `uploadFile`** (`app/actions/files.ts`): รับ `FormData` — validate extension (whitelist เดียวกับ `SAMPLE_FILE_EXTENSIONS`) + ขนาด ≤ 100MB + ไฟล์ไม่ว่าง → เขียน `public/uploads/<uuid>.<ext>` (**ชื่อไฟล์บนดิสก์จาก randomUUID เท่านั้น** — กันชนกันและกัน path traversal; `title` = ชื่อไฟล์เดิมที่ sanitize) → insert `files (title, pdf_url, type_id)` (typeId จากช่องกลุ่ม — ว่าง = NULL, insert ตอนสร้าง row ใหม่เท่านั้น ไม่มีทางแก้กลุ่มไฟล์เดิม) → `revalidatePath("/")` — ไม่มี SSE event (รายการไฟล์ไม่ได้ sync ผ่าน SSE)
|
||||
- **next.config:** `experimental.serverActions.bodySizeLimit = "100mb"` (default 1MB ไม่พอสำหรับ PDF/สแกนศาล)
|
||||
- **เสิร์ฟไฟล์อัปโหลดผ่าน `app/uploads/[name]/route.ts`** (GET — อ่าน `public/uploads/<uuid>.<ext>` + path guard ของ `lib/server-pdf.ts` + content-type จาก `utils/file-kind.ts`): Next.js production (`next start`/standalone) ไล่รายชื่อไฟล์ใน `public/` เก็บใน Set **ครั้งเดียวตอน startup** (router-utils/filesystem.js `setupFsCheck`) — ไฟล์ที่ action เขียนตอน runtime GET ตรง ๆ แล้ว 404 (dev ไม่เป็น → อาการ "local ผ่าน Docker พัง"); ไฟล์ที่ bake ตอน build (pdf.worker/ฟอนต์/samples) ถูกเสิร์ฟโดย static path ก่อน route นี้อยู่แล้วตามลำดับ routing; PDF ใน viewer โหลดผ่าน `/api/pdf` อยู่แล้ว ตัวที่ต้องใช้ route นี้จริง = รูปภาพ (`<img src={pdfUrl}>`)
|
||||
- **ความสัมพันธ์กับ seed:** seed-samples จัดการเฉพาะ `/samples/%` — ไฟล์อัปโหลดใน `/uploads/` ไม่ถูก seed ลบ
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { PdfPathError, readPublicPdf } from "@/lib/server-pdf";
|
|||
import {
|
||||
externalIdFor,
|
||||
isHttpUrl,
|
||||
normDocumentGroup,
|
||||
titleFromExternalUrl,
|
||||
validateExternalFileUrl,
|
||||
type ExternalFileUrlError,
|
||||
|
|
@ -29,12 +30,6 @@ const URL_ERROR_TEXT: Record<ExternalFileUrlError, string> = {
|
|||
host_not_allowed: "โฮสต์ของลิงก์ไฟล์ไม่อยู่ในรายการอนุญาต",
|
||||
};
|
||||
|
||||
/** รหัสกลุ่ม normalize: trim → ว่าง = ไม่มี, cap 200 ตัวอักษร (เทียบ title) */
|
||||
function normGroup(s: string | undefined): string | undefined {
|
||||
const v = (s ?? "").trim().slice(0, 200);
|
||||
return v || undefined;
|
||||
}
|
||||
|
||||
/** path ในเครื่อง (ทดสอบใน dev เช่น /samples/…): decode %20, ห้าม .., นามสกุลผ่าน
|
||||
* whitelist และต้องมีไฟล์จริงใน public/ ผ่าน path guard เดียวกับ viewer/export */
|
||||
function validateLocalPath(
|
||||
|
|
@ -76,7 +71,7 @@ export async function registerExternalFile(
|
|||
trimmed = `https://${trimmed}`;
|
||||
}
|
||||
|
||||
const typeId = normGroup(rawTypeId);
|
||||
const typeId = normDocumentGroup(rawTypeId) ?? undefined;
|
||||
|
||||
// สองทาง: https ของ e-Court (ตามสัญญาลิงก์ — validate + allowlist) หรือ path ในเครื่อง
|
||||
// (ทดสอบ dev เช่น /samples/…) — ทั้งคู่เก็บลง pdf_url เหมือนกัน ต่างกันแค่ตอนเปิดดู
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
"test": "vitest run",
|
||||
"start": "next start",
|
||||
"seed-samples": "node --env-file=.env scripts/seed-samples.mjs",
|
||||
"postinstall": "node -e \"require('fs').copyFileSync('node_modules/pdfjs-dist/build/pdf.worker.min.mjs','public/pdf.worker.min.mjs')\""
|
||||
"postinstall": "node scripts/patch-pdf-worker.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@pdf-lib/fontkit": "^1.1.1",
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
44
scripts/patch-pdf-worker.mjs
Normal file
44
scripts/patch-pdf-worker.mjs
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
// คัดลอก pdf.worker.min.mjs จาก node_modules ไป public/ แล้ว patch heuristic
|
||||
// การแทรก fake space กลางคำไทย (bug report 2026-09-24):
|
||||
//
|
||||
// pdf.js (ทั้ง v4.10 จนถึง master ปัจจุบัน) เก็บตำแหน่งอ้างอิงของ glyph ก่อนหน้า
|
||||
// เฉพาะ glyph ที่มีความกว้าง ≠ 0 เท่านั้น:
|
||||
// if (scaledDim) { textChunk.prevTransform = getCurrentTextTransform(); }
|
||||
// สระบน-ล่าง/วรรณยุกต์ไทยเป็น glyph กว้าง 0 แต่ charSpacing ของ glyph เหล่านั้น
|
||||
// ยังขยับตำแหน่ง x อยู่ — พอข้าม glyph กว้าง 0 ตั้งแต่ 2 ตัวขึ้นไป gap สะสม
|
||||
// (charSpacing ต่อ glyph × จำนวน) จะเกิน spaceInFlowMin จน addFakeSpaces แทรก
|
||||
// " " กลางคำ ("ฉะนั้ น ให้ ท่ าน") ทั้งที่บนกระดาษตัวอักษรติดกันสนิท
|
||||
//
|
||||
// แก้โดยถอดเงื่อนไข if ให้อัปเดต prevTransform ทุก glyph — glyph กว้าง 0 ที่
|
||||
// charSpacing = 0 ไม่ขยับตำแหน่งอยู่แล้ว (compareWithLastPosition early-return)
|
||||
// จึงไม่มีผลกับเอกสารทั่วไป ส่วน space จริง (glyph SPACE) ไปทาง saveLastChar
|
||||
// ซึ่งไม่เกี่ยวกับจุดนี้ จึงยังคงอยู่ครบ
|
||||
//
|
||||
// ทำบนไฟล์ minified ด้วย pattern จำเพาะ (ชื่อตัวแปร minify อาจเปลี่ยนตามเวอร์ชัน):
|
||||
// เจอ ≠ 1 จุด = โครงสร้างโค้ดเปลี่ยนหลังอัปเกรด pdfjs-dist → fail ดัง ๆ ทันที
|
||||
// ไม่ปล่อยให้ patch หลุดเงียบ ๆ แล้ว fake space กลับมาโดยไม่มีใครรู้ตัว
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
|
||||
const SRC = "node_modules/pdfjs-dist/build/pdf.worker.min.mjs";
|
||||
const DEST = "public/pdf.worker.min.mjs";
|
||||
|
||||
const code = readFileSync(SRC, "utf8");
|
||||
|
||||
// src/core/evaluator.js: if (scaledDim) { textChunk.prevTransform = getCurrentTextTransform(); }
|
||||
// minified (v4.10.38): C&&(h.prevTransform=getCurrentTextTransform());
|
||||
// lookbehind (?<![.\w$]) กันจับ identifier ต่อท้าย member expression ผิด ๆ หลังอัปเกรด
|
||||
// (เช่น a.b&&(...) จะ match แค่ b&& แล้ว replace ได้ a.h.prevTransform=… พัดเงียบ)
|
||||
const PATCH_RE =
|
||||
/(?<![.\w$])[A-Za-z$_][\w$]*&&\(([A-Za-z$_][\w$]*\.prevTransform=getCurrentTextTransform\(\))\);/g;
|
||||
|
||||
const found = [...code.matchAll(PATCH_RE)];
|
||||
if (found.length !== 1) {
|
||||
console.error(
|
||||
`[patch-pdf-worker] หา pattern ไม่ได้ตามที่คาด (เจอ ${found.length} จุด ต้องการ 1) — ` +
|
||||
`pdfjs-dist อัปเกรดแล้วโครงสร้างโค้ดเปลี่ยน ปรับ PATCH_RE ใน scripts/patch-pdf-worker.mjs`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
writeFileSync(DEST, code.replace(PATCH_RE, "$1;"));
|
||||
console.log("[patch-pdf-worker] คัดลอก worker + patch fake-space ไทยเรียบร้อย");
|
||||
|
|
@ -6,7 +6,69 @@ const config: Config = {
|
|||
extend: {
|
||||
colors: {
|
||||
// สีหลักของระบบ (accent ทุกจุด: ปุ่ม active/action, กรอบ selection, หัวข้อหลัก)
|
||||
// ห้ามเปลี่ยนโดยไม่เช็ค contrast บนขาว/canvas ≥3:1 — เป็นสี focus outline ทึบ
|
||||
// ของ Genesis (.focus-ring ใน globals.css; #0033A1 ปัจจุบัน ≈10.6:1)
|
||||
primary: "rgb(0 51 161 / <alpha-value>)",
|
||||
// Genesis (docs/DESIGN.md 2026-09-23): palette ส่วนที่เหลือของ design system —
|
||||
// naming หลีก "primary/secondary" ของข้อความเพื่อไม่ชนกับ primary น้ำเงินด้านบน
|
||||
"primary-hover": "#002780",
|
||||
success: "#10B981",
|
||||
warning: "#F59E0B",
|
||||
error: "#EF4444",
|
||||
// ระดับข้อความ/ไอคอนสถานะบนพื้นขาว (UX review 2026-09-23): ตัว 500 บนขาวได้แค่
|
||||
// 2.2–3.8:1 ไม่ผ่าน WCAG 4.5:1 ของตัวอักษร — ข้อความ/ไอคอนสถานะใช้ค่า -ink
|
||||
// (ผ่าน 5:1 ขึ้นไปทั้งชุด) ส่วน 500 คงไว้สำหรับ border/bg tint (เช่น bg-warning/15)
|
||||
"success-ink": "#047857",
|
||||
"warning-ink": "#B45309",
|
||||
"error-ink": "#B91C1C",
|
||||
canvas: "#FAFAFA",
|
||||
line: "#E8E8EC",
|
||||
ink: "#0A0A0A",
|
||||
"ink-soft": "#6B6B6B",
|
||||
"ink-faint": "#9C9C9C",
|
||||
},
|
||||
fontFamily: {
|
||||
// Genesis: display = General Sans, body = DM Sans — ทั้งคู่ Latin เท่านั้น
|
||||
// ใส่ Sarabun ต่อท้ายเป็น fallback ไทย (glyph ไทยตกไป Sarabun อัตโนมัติ)
|
||||
display: [
|
||||
"var(--font-general-sans)",
|
||||
"var(--font-sarabun)",
|
||||
"-apple-system",
|
||||
"Segoe UI",
|
||||
"Leelawadee UI",
|
||||
"Thonburi",
|
||||
"Noto Sans Thai",
|
||||
"sans-serif",
|
||||
],
|
||||
body: [
|
||||
"var(--font-dm-sans)",
|
||||
"var(--font-sarabun)",
|
||||
"-apple-system",
|
||||
"Segoe UI",
|
||||
"Leelawadee UI",
|
||||
"Thonburi",
|
||||
"Noto Sans Thai",
|
||||
"sans-serif",
|
||||
],
|
||||
},
|
||||
fontSize: {
|
||||
// Genesis type scale: Display 72 / Headline 60 / Section 32 / Subhead 24 /
|
||||
// Body 15 / Small 13 / Caption 12 / Overline 11 — heading ผูก bold + tracking
|
||||
// แน่นตาม spec (-0.03em ถึง -0.04em) ให้ไทย (Sarabun) ได้ weight 700 ตาม
|
||||
display: ["4.5rem", { lineHeight: "1.02", letterSpacing: "-0.04em", fontWeight: "700" }],
|
||||
headline: ["3.75rem", { lineHeight: "1.05", letterSpacing: "-0.04em", fontWeight: "700" }],
|
||||
"section-title": ["2rem", { lineHeight: "1.2", letterSpacing: "-0.03em", fontWeight: "700" }],
|
||||
subhead: ["1.5rem", { lineHeight: "1.3", letterSpacing: "-0.02em", fontWeight: "700" }],
|
||||
body: ["0.9375rem", { lineHeight: "1.65" }],
|
||||
small: ["0.8125rem", { lineHeight: "1.55" }],
|
||||
caption: ["0.75rem", { lineHeight: "1.5" }],
|
||||
overline: ["0.6875rem", { lineHeight: "1.4", letterSpacing: "0.08em", fontWeight: "500" }],
|
||||
},
|
||||
boxShadow: {
|
||||
// Genesis elevation: card ได้เงาเฉพาะตอน hover (static ไม่มีเงา) +
|
||||
// ปุ่ม primary ได้ glow ฟ้าตอน hover / dropdown ใช้ shadow-lg ของ default
|
||||
"card-hover": "0 8px 30px rgba(0,0,0,0.08)",
|
||||
"btn-glow": "0 4px 12px rgba(0,51,161,0.35)",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
BIN
tests/fixtures/thai_letterspacing.pdf
vendored
Normal file
BIN
tests/fixtures/thai_letterspacing.pdf
vendored
Normal file
Binary file not shown.
68
tests/scripts/patch-pdf-worker.test.ts
Normal file
68
tests/scripts/patch-pdf-worker.test.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
// patch fake-space ไทยของ pdf.js worker — scripts/patch-pdf-worker.mjs (bug 2026-09-24):
|
||||
// pdf.js เก็บตำแหน่งอ้างอิง glyph ก่อนหน้า (prevTransform) เฉพาะ glyph ที่กว้าง ≠ 0
|
||||
// ทำให้ charSpacing ของสระบน-ล่าง/วรรณยุกต์ (glyph กว้าง 0) สะสมเป็น gap จน
|
||||
// addFakeSpaces แทรก " " กลางคำ ("ฉะนั้ น") — patch ถอด if ครอบการอัปเดตออก
|
||||
// ให้อัปเดตทุก glyph แทน
|
||||
|
||||
const ROOT = process.cwd();
|
||||
const WORKER = join(ROOT, "public", "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 สร้าง
|
||||
// รายการเอกสาร "ทดสอบ" บนหน้ารายการของทุก deploy)
|
||||
const SAMPLE = join(ROOT, "tests", "fixtures", "thai_letterspacing.pdf");
|
||||
|
||||
describe("public/pdf.worker.min.mjs — ผ่าน patch fake-space ไทย", () => {
|
||||
const code = readFileSync(WORKER, "utf8");
|
||||
|
||||
it("อัปเดต prevTransform ทุก glyph (รูปแบบก่อนแพตช์ C&&(h.prevTransform=…) หายไป)", () => {
|
||||
// หลัง patch: }h.prevTransform=getCurrentTextTransform(); (เคยถูก if ครอบ
|
||||
// จนเหลือ } ปิด block เปล่านำหน้า)
|
||||
expect(code).toMatch(
|
||||
/[;{}][A-Za-z$_][\w$]*\.prevTransform=getCurrentTextTransform\(\);/,
|
||||
);
|
||||
// ก่อน patch: C&&(h.prevTransform=getCurrentTextTransform());
|
||||
expect(code).not.toMatch(
|
||||
/&&\([A-Za-z$_][\w$]*\.prevTransform=getCurrentTextTransform\(\)\);/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTextContent ผ่าน worker ที่ patch แล้ว — ไฟล์ justify letter-spacing", () => {
|
||||
it("ไม่มี fake space กลางคำไทย (space ตามด้วยสระ/วรรณยุกต์ = ผิดเสมอ)", async () => {
|
||||
const pdfjs = await import("pdfjs-dist/legacy/build/pdf.mjs");
|
||||
pdfjs.GlobalWorkerOptions.workerSrc = pathToFileURL(WORKER).href;
|
||||
const doc = await pdfjs.getDocument({
|
||||
data: new Uint8Array(readFileSync(SAMPLE)),
|
||||
}).promise;
|
||||
|
||||
// space หน้า combining mark ไม่มีทางถูกต้องในภาษาไทย (mark ต้องเกาะฐานหน้า)
|
||||
const badRe = / [ัิีึืุู็่้๊๋์ํ]/;
|
||||
const offenders: string[] = [];
|
||||
let targetLine = "";
|
||||
for (let p = 1; p <= doc.numPages; p++) {
|
||||
const tc = await (await doc.getPage(p)).getTextContent();
|
||||
for (const item of tc.items) {
|
||||
if (!("str" in item)) continue;
|
||||
if (!targetLine && item.str.includes("ฉะนั้น")) targetLine = item.str;
|
||||
if (badRe.test(item.str)) {
|
||||
offenders.push(`p${p}: ${JSON.stringify(item.str.slice(0, 60))}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(offenders).toEqual([]);
|
||||
|
||||
// บรรทัดเป้าหมาย (เคยได้ "ฉะนั้ น ให้ ท่ าน…คำฟ้ อง") — space จริง (glyph SPACE
|
||||
// ในไฟล์) คงอยู้ครบ ส่วนช่อง letter-spacing เล็ก ๆ ที่ไม่ใช่ space จริงติดกัน
|
||||
// ("ให้การแก้" — ในไฟล์ไม่มี space glyph ตรงนั้น)
|
||||
expect(targetLine).toBe(
|
||||
"ฉะนั้น ให้ ท่านทำคำให้การแก้คำฟ้อง พร้อมด้วยพยานหลักฐาน ๑ ชุด",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -50,6 +50,13 @@ export function effectiveDrawOpacity(d: { pen?: DrawPen; opacity?: number }): nu
|
|||
return d.opacity ?? DRAW_PENS[d.pen ?? "pencil"].opacity;
|
||||
}
|
||||
|
||||
// ค่า default สีตอนสร้างรายการใหม่ของ tool shape/highlight — ค่าเดียวกับ preset ใน
|
||||
// INK_COLORS ของ ToolControls.tsx (แดง = ปากกา/รูปทรง, เหลือง = ไฮไลต์) ประกาศเป็น
|
||||
// named constant ตาม pattern TEXTBOX_DEFAULT_COLOR (design audit 2026-09-24 — เดิม
|
||||
// PDFViewer ถือ HIGHLIGHT_COLORS 5 สีเอง ทั้งที่ใช้จริงแค่ตัวแรก ซ้ำกับ preset)
|
||||
export const SHAPE_DEFAULT_COLOR = "#ef4444";
|
||||
export const HIGHLIGHT_DEFAULT_COLOR = "#fde047";
|
||||
|
||||
// ค่า default ตอนสร้าง TEXTBOX (F17/B2) — fontSize เป็น % ของความกว้างหน้า
|
||||
export const TEXTBOX_DEFAULT_COLOR = "#111827";
|
||||
// M2: 2.5% ≈ 15px ที่หน้า A4 (595pt, scale 1) — อ่านออกทันทีที่พิมพ์เสร็จ (เดิม 1.2% ≈ 7px
|
||||
|
|
|
|||
|
|
@ -90,3 +90,15 @@ export function sanitizeNextPath(s: string): string | null {
|
|||
if (!s.startsWith("/") || s.startsWith("//") || s.startsWith("/\\")) return null;
|
||||
return s;
|
||||
}
|
||||
|
||||
/** รหัสกลุ่มเอกสาร (N9 `type_id`) normalize — single source ใช้ร่วมทั้ง N8
|
||||
* `registerExternalFile` (ลิงก์ e-Court) และ N4 `uploadFile` (ช่องกลุ่มของฟอร์ม
|
||||
* อัปโหลด): trim → ว่าง = ไม่มีกลุ่ม (null), cap 200 ตัวอักษร (เทียบ title)
|
||||
* ค่าที่ไม่ใช่ string (เช่น File จาก FormData) = null (responsive audit 2026-09-24:
|
||||
* เดิมเป็นสองสำเนา mirroring กัน — normGroup ของ external-import + normTypeId
|
||||
* ของ files.ts — เสี่ยง drift) */
|
||||
export function normDocumentGroup(raw: unknown): string | null {
|
||||
if (typeof raw !== "string") return null;
|
||||
const v = raw.trim().slice(0, 200);
|
||||
return v || null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,9 +13,37 @@ import {
|
|||
// ข้อมูลเข้า/ออกเป็นพิกัด % ตาม invariant #2 ยกเว้นฟังก์ชันที่รับ `size`
|
||||
// เพื่อแปลง % → px (เช่น bbox สำหรับวาดกรอบ selection, hit-test หน่วย px)
|
||||
|
||||
/** ขนาดกล่อง bbox ของ comment pin (% ของความกว้างหน้า) — pin เป็นปุ่มขนาดคงที่ ไม่มีพิกัดกว้างยาวใน data */
|
||||
/** ขนาดกล่อง bbox ของ comment pin (% ของความกว้างหน้า) — pin เป็นปุ่มขนาดคงที่ ไม่มีพิกัดกว้างยาวใน data
|
||||
* เป็นค่าประมาณ % สำหรับ hit-test ที่ไม่รู้ขนาด render เท่านั้น — ทุกจุดที่รู้ขนาดหน้าจริง
|
||||
* (กรอบ selection / scroll target / move-clamp) ใช้ commentPinBoxPx/commentPinBoxPct ตาม px จริงข้างล่าง */
|
||||
export const COMMENT_PIN_BOX_PCT = 4;
|
||||
|
||||
/** COMMENT: ขนาด px จริงของปุ่ม pin — desktop 26px (icon 16 + p-1 รอบด้าน + border 1×2)
|
||||
* และจอสัมผัส 36px (.coarse-36 ใน globals.css บังคับ min-width/height 2.25rem ให้ปุ่ม
|
||||
* pin ใน PDFViewer) — pin เป็น px คงที่ไม่ scale ตาม zoom ต่างจากชนิดอื่นที่เป็น % ทั้งหมด
|
||||
* (bug report 2026-09-23: กรอบไม่ตรงกล่อง + responsive audit 2026-09-24: scroll/clamp
|
||||
* คิดจาก % เดิมจน pin จมใต้ header ตอนซูมออก) — แก้ปุ่ม pin หรือ .coarse-36 ต้องแก้ค่านี้ตาม */
|
||||
export const COMMENT_PIN_SIZE_PX = 26;
|
||||
export const COMMENT_PIN_COARSE_SIZE_PX = 36;
|
||||
|
||||
/** bbox px ของ COMMENT ตามขนาด render จริง — จัตุรัส px คงที่ กึ่งกลางแนวนอนที่จุด position
|
||||
* และขอบล่างชนจุด (สูตรเดียวกับ -translate-x-1/2 -translate-y-full ของปุ่ม pin — ทั้งลูก
|
||||
* ลอยเหนือจุดคลิก) */
|
||||
export function commentPinBoxPx(d: { position: Point }, size: Size, pinPx: number): Rect {
|
||||
const p = pointToPx(d.position, size);
|
||||
return { x: p.x - pinPx / 2, y: p.y - pinPx, width: pinPx, height: pinPx };
|
||||
}
|
||||
|
||||
/** bbox ของ COMMENT หน่วย % คิดจากขนาด render จริง (size = px ของหน้าที่กำลัง render) —
|
||||
* ใช้กับ scroll target ของ goToAnnotation/deep link + move-clamp/bookmark ของกรอบ selection
|
||||
* ให้เป็นมาตราฐานเดียวกับที่ render (responsive audit 2026-09-24 — เดิมสองมาตราฐาน:
|
||||
* render ใช้ px แต่ scroll/clamp ยังใช้ COMMENT_PIN_BOX_PCT ของ annotationBBox) */
|
||||
export function commentPinBoxPct(d: { position: Point }, size: Size, pinPx: number): Rect {
|
||||
const wPct = (pinPx / size.width) * 100;
|
||||
const hPct = (pinPx / size.height) * 100;
|
||||
return { x: d.position.x - wPct / 2, y: d.position.y - hPct, width: wPct, height: hPct };
|
||||
}
|
||||
|
||||
// TEXTBOX (F17/B2 — §5): ค่าประมาณสำหรับ bbox ของกล่องข้อความ
|
||||
// render จริงกว้างสุด 50% ของหน้าแล้ว wrap อัตโนมัติ — bbox ประมาณจากตัวอักษร
|
||||
// กว้าง ~0.6 × fontSize ต่อตัว บรรทัดสูง 1.5 × fontSize (ค่าประมาณพอสำหรับกรอบ selection)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue