eCourt-note/app/note/page.tsx
Your Name 8cc15ea75d
All checks were successful
Deploy eCourt Note Web to Server / Validate Database Schema (Drizzle) (push) Successful in 3s
Deploy eCourt Note Web to Server / Build Web Docker Image (push) Successful in 52s
Deploy eCourt Note Web to Server / Deploy eCourt Note Web to Server (push) Successful in 13s
Deploy eCourt Note Web to Server / Notify Deployment Status (push) Successful in 1s
refactor(routes): move routes under /note, APIs under /api/note
เตรียม basePath /docedit เป็นพื้นที่กลางให้แอปพี่น้อง (wordaddin ใน
อนาคต) — เดิม basePath ครอบ /docedit/note ทั้งหมดจนแชร์ prefix ไม่ได้
(Next มี basePath ได้ทางเดียว) และจัด API แยกต้นไม้ตามที่ผู้ใช้ขอ

(1) โครงสร้างใหม่: basePath = /docedit + route หน้าทั้งหมดย้ายเข้า
app/note/** + API 4 เส้นย้ายเข้า app/api/note/** + statics ย้ายเข้า
public/note/** (samples, uploads, pdf.worker) — หน้า/statics URL ปลายทาง
คงเดิมเป๊ะ (/docedit/note/…) ส่วน URL API เปลี่ยนเป็น /docedit/api/note/…
ตามคำขอผู้ใช้ (N10 endpoint เปลี่ยน — ต้องแจ้ง backend e-Court ก่อนใช้)

(2) กลไก: withBasePath (utils/base-path.ts) เติม base + app path /note
ยกเว้น path ที่ขึ้นต้น /api/ เติมแค่ base (caller เขียน /api/note/… เต็ม);
resolvePublicPdfPath root ที่ public/note (traversal guard เดิม);
middleware matcher ยกเว้นใหม่ (note/login, api/note, note/uploads,
note/samples, note/pdf.worker, _next, fonts, favicon) + redirect
/note/login; next.config เพิ่ม redirects /docedit -> /docedit/note;
navigation ภายใน 19 จุด + revalidatePath 3 จุด ใส่ prefix /note;
pdf_url ใน DB เป็น logical path ไม่แก้แม้แต่ตัวเดียว (seed/upload
เขียนค่าเดิม, browser เติมผ่าน withBasePath, disk เติมผ่าน guard)

(3) ตามแก้: patch-pdf-worker (DEST public/note + mkdirSync ปลายทาง),
seed-samples (SAMPLES_DIR), uploadFile (UPLOAD_DIR), Dockerfile/
.dockerignore/.gitignore/docker-compose.test.yml, tests 2 ไฟล์ที่อ้าง
disk path จริง, sync เอกสารทั้งหมด (CLAUDE.md invariant #6/#12 + tree,
README, docs/API-DOCUMENTS.md, .claude/rules/*, sync-db skill)

(4) !! ตอน deploy ต้องย้าย volume ตาม: mount /app/public/uploads ->
/app/public/note/uploads + ย้ายไฟล์ใน volume ให้ตรงตำแหน่ง ไม่งั้นไฟล์
ที่เคยอัปโหลด (พร้อม annotation ที่ผูกอยู่) 404 ทันทีที่สลับ image

(5) verify: build ผ่าน (route table /api/note/** + /note/** ครบ), tsc,
vitest 215/215, curl ครบทุก endpoint (login/redirect chain, worker,
samples, uploads, SSE, api/pdf, export, N8 register, N10 bundle + deep
link), Playwright browser จริง (canvas + textLayer ไทย render, browser
ยิง URL ใหม่ทั้งหมดไม่มี request ไป URL เก่า), plan-reviewer +
code-reviewer 3 รายงาน 0 blocking

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-06 14:55:07 +07:00

236 lines
11 KiB
TypeScript

import Link from "next/link";
import { redirect } from "next/navigation";
import { asc } from "drizzle-orm";
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 UploadFileForm from "@/components/UploadFileForm";
import { imageFormatFromUrl } from "@/utils/file-kind";
import { formatThaiDate } from "@/utils/date-format";
import { getSessionUser } from "@/lib/session";
import { registerExternalFile } from "@/lib/external-import";
import { firstSearchParam } from "@/utils/external-file";
export const dynamic = "force-dynamic";
type FileRow = typeof files.$inferSelect;
/** badge ชนิดไฟล์ต่อรายการ (N3) */
function kindBadge(pdfUrl: string): string {
switch (imageFormatFromUrl(pdfUrl)) {
case "jpeg":
return "JPG";
case "png":
return "PNG";
case "tiff":
return "TIFF";
default:
return "PDF";
}
}
/** การ์ดไฟล์ของหน้ารายการ (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="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="mt-4 block break-all line-clamp-2 text-body font-medium text-ink">
{file.title}
</span>
{/* วันที่เป็นข้อมูลที่ผู้ใช้อ่านจริง — 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={`/note/annotate/${file.id}`}
className="focus-ring inline-flex h-8 items-center rounded-md border border-line px-3 text-sm font-medium text-ink transition hover:-translate-y-px hover:border-ink-faint"
>
Annotate
</Link>
</div>
</li>
);
}
export default async function HomePage({
searchParams,
}: {
searchParams: Record<string, string | string[] | undefined>;
}) {
// defense ซ้อน middleware — หน้านี้อ่าน cookie อยู่ดีจึงกันซ้ำไม่ให้ render รายการให้คนไม่มี session
const user = getSessionUser();
if (!user) redirect("/note/login");
// N8: เข้าจากลิงก์ e-Court ?fileUrl=&fileId=&typeId= — ลงทะเบียนเอกสาร (metadata-only
// dedupe ด้วย external_id) แล้วพาเข้า viewer เลย — redirect() โยน NEXT_REDIRECT
// ต้องเรียกนอก try/catch เสมอ; หน้านี้ force-dynamic จึงไม่ต้อง revalidatePath
// (entry ที่สองอยู่ที่ /note/annotate?fileUrl=… — app/note/annotate/page.tsx)
let registerError: string | null = null;
const fileUrl = firstSearchParam(searchParams.fileUrl);
if (fileUrl) {
const result = await registerExternalFile(
fileUrl,
firstSearchParam(searchParams.fileId),
firstSearchParam(searchParams.typeId),
);
if (result.ok) redirect(`/note/annotate/${result.fileId}`);
registerError = result.error;
}
const rows = await db.select().from(files).orderBy(asc(files.createdAt));
// N9: จัดกลุ่มตาม type_id — การ์ดในกลุ่มเรียง createdAt เหมือนเดิม (ตาม query ที่ asc)
// ส่วนลำดับกลุ่มเรียงตาม createdAt ของไฟล์ล่าสุดในกลุ่ม (กลุ่มที่มี activity ใหม่อยู่บน)
// ไฟล์ type_id NULL (seed/อัปโหลด N4/ลิงก์ไม่มี typeId) = section "ไม่มีกลุ่ม" ท้ายสุด
const grouped = new Map<string, FileRow[]>();
const ungrouped: FileRow[] = [];
for (const f of rows) {
if (f.typeId) {
const list = grouped.get(f.typeId);
if (list) list.push(f);
else grouped.set(f.typeId, [f]);
} else {
ungrouped.push(f);
}
}
const sections = Array.from(grouped.entries()).sort(
(a, b) =>
b[1][b[1].length - 1].createdAt.getTime() -
a[1][a[1].length - 1].createdAt.getTime(),
);
return (
<>
<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&apos;s Note
</h1>
<p className="mt-2 text-body text-ink-soft">
ระบบ PDF Annotation สำหรับ e-Court
</p>
{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>
)}
{/* การ์ดจำลองลิงก์ 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={`/note/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>
)}
</>
)}
</section>
</main>
</>
);
}