All checks were successful
Deploy eCourt Note Web to Server / Validate Database Schema (Drizzle) (push) Successful in 3s
Deploy eCourt Note Web to Server / Build Web Docker Image (push) Successful in 52s
Deploy eCourt Note Web to Server / Deploy eCourt Note Web to Server (push) Successful in 13s
Deploy eCourt Note Web to Server / Notify Deployment Status (push) Successful in 1s
เตรียม basePath /docedit เป็นพื้นที่กลางให้แอปพี่น้อง (wordaddin ใน อนาคต) — เดิม basePath ครอบ /docedit/note ทั้งหมดจนแชร์ prefix ไม่ได้ (Next มี basePath ได้ทางเดียว) และจัด API แยกต้นไม้ตามที่ผู้ใช้ขอ (1) โครงสร้างใหม่: basePath = /docedit + route หน้าทั้งหมดย้ายเข้า app/note/** + API 4 เส้นย้ายเข้า app/api/note/** + statics ย้ายเข้า public/note/** (samples, uploads, pdf.worker) — หน้า/statics URL ปลายทาง คงเดิมเป๊ะ (/docedit/note/…) ส่วน URL API เปลี่ยนเป็น /docedit/api/note/… ตามคำขอผู้ใช้ (N10 endpoint เปลี่ยน — ต้องแจ้ง backend e-Court ก่อนใช้) (2) กลไก: withBasePath (utils/base-path.ts) เติม base + app path /note ยกเว้น path ที่ขึ้นต้น /api/ เติมแค่ base (caller เขียน /api/note/… เต็ม); resolvePublicPdfPath root ที่ public/note (traversal guard เดิม); middleware matcher ยกเว้นใหม่ (note/login, api/note, note/uploads, note/samples, note/pdf.worker, _next, fonts, favicon) + redirect /note/login; next.config เพิ่ม redirects /docedit -> /docedit/note; navigation ภายใน 19 จุด + revalidatePath 3 จุด ใส่ prefix /note; pdf_url ใน DB เป็น logical path ไม่แก้แม้แต่ตัวเดียว (seed/upload เขียนค่าเดิม, browser เติมผ่าน withBasePath, disk เติมผ่าน guard) (3) ตามแก้: patch-pdf-worker (DEST public/note + mkdirSync ปลายทาง), seed-samples (SAMPLES_DIR), uploadFile (UPLOAD_DIR), Dockerfile/ .dockerignore/.gitignore/docker-compose.test.yml, tests 2 ไฟล์ที่อ้าง disk path จริง, sync เอกสารทั้งหมด (CLAUDE.md invariant #6/#12 + tree, README, docs/API-DOCUMENTS.md, .claude/rules/*, sync-db skill) (4) !! ตอน deploy ต้องย้าย volume ตาม: mount /app/public/uploads -> /app/public/note/uploads + ย้ายไฟล์ใน volume ให้ตรงตำแหน่ง ไม่งั้นไฟล์ ที่เคยอัปโหลด (พร้อม annotation ที่ผูกอยู่) 404 ทันทีที่สลับ image (5) verify: build ผ่าน (route table /api/note/** + /note/** ครบ), tsc, vitest 215/215, curl ครบทุก endpoint (login/redirect chain, worker, samples, uploads, SSE, api/pdf, export, N8 register, N10 bundle + deep link), Playwright browser จริง (canvas + textLayer ไทย render, browser ยิง URL ใหม่ทั้งหมดไม่มี request ไป URL เก่า), plan-reviewer + code-reviewer 3 รายงาน 0 blocking Co-Authored-By: Claude Code <noreply@anthropic.com>
236 lines
11 KiB
TypeScript
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'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>
|
|
</>
|
|
);
|
|
}
|