eCourt-note/components/NoteHome.tsx
Your Name 2d466efd4f fix(note): address code review findings
- restoreVersion: ผ่าน validateAnnotationData ก่อน insert สาขา
  "ต้นทางถูกลบ" ให้สมมาตรกับสาขา update (invariant #8)
- PDFViewer: track timer ล้าง exportError ใน ref — กัน export พัง
  สองรอบติดกันแล้ว timer รอบแรกเคลียร์ error รอบหลังก่อนเวลา
- useHistory: เพิ่ม onError — undo/redo ล้มแสดง toast ผ่าน
  showActionError แทนหายเงียบ ๆ
- DebugConsole: ไม่โหลด Eruda จาก CDN ใน production build
  (ระบบ intranet + token อยู่ใน origin เดียวกัน)
- NoteHome: ตัด dead state session ที่ render ไม่ได้อ่าน
- note-auth: export SSO_REQUIRED_BANNER ให้หน้า gate ใช้ตรง ๆ
  ถอด non-null assertion ใน AnnotateView + bookmarkDepthOf

Verify: npx tsc --noEmit ผ่าน · vitest 317/317 ผ่าน

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-11-02 10:01:00 +07:00

290 lines
14 KiB
TypeScript

"use client";
// หน้าแรก /note — ข้อมูลเกี่ยวกับระบบ (2026-10-30 ตามคำขอผู้ใช้: ระบบทำอะไรได้ /
// วิธีเข้าใช้) + bootstrap ของลิงก์ e-Court (contract ปลายทางลิงก์ยังชี้ที่นี่):
// มี credential ใน URL (t หรือ ticket_data+HmacSha256 — จากลิงก์ที่ e-Court ชี้ตรงมา
// ที่ /note) = แลกเป็น access_token ที่ POST /api/note/auth/session แล้วเก็บ
// localStorage "note-auth" (แบบ add-in) → ตัด credential ออกจาก URL ทันที → มี fileId
// = พาเข้า viewer / ไม่มี = แสดงหน้าข้อมูล · ไม่มี credential = หน้า public ธรรมดา
// (แบนเนอร์ ?sso= ของลิงก์เก่า + fileError ยังรองรับ)
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import {
Bookmark,
ExternalLink,
FileText,
Highlighter,
MessagesSquare,
} from "lucide-react";
import AppNav from "@/components/AppNav";
import { parseSsoLinkParams, ssoBanner, type SsoBanner } from "@/lib/note-auth";
import { saveNoteSession } from "@/lib/note-session";
import { withBasePath } from "@/utils/base-path";
type Phase = "boot" | "exchanging" | "ready";
type SessionResponse = {
access_token?: string;
token_type?: string;
expires_in?: number;
userid?: string;
username?: string;
fileId?: string;
fileError?: string;
reason?: string;
};
export default function NoteHome() {
const router = useRouter();
const [phase, setPhase] = useState<Phase>("boot");
const [banner, setBanner] = useState<SsoBanner | null>(null);
const [fileError, setFileError] = useState<string | null>(null);
// StrictMode dev รัน effect สองครั้ง — bootstrap ต้องเกิดครั้งเดียวจริง ๆ
const bootedRef = useRef(false);
useEffect(() => {
if (bootedRef.current) return;
bootedRef.current = true;
const url = new URL(window.location.href);
const stripParams = () => {
url.search = "";
window.history.replaceState(null, "", url.toString());
};
const fail = (reason: "expired" | "invalid" | "error") => {
setBanner(ssoBanner(reason));
setPhase("ready");
};
const parsed = parseSsoLinkParams(url);
const hasCredentialKey =
url.searchParams.get("t") !== null ||
url.searchParams.get("ticket_data") !== null ||
url.searchParams.get("HmacSha256") !== null;
if (parsed.ok) {
setPhase("exchanging");
const body: Record<string, string> = {};
if (parsed.credential.kind === "jwt") {
body.t = parsed.credential.token;
} else {
body.ticket_data = parsed.credential.ticketData;
body.HmacSha256 = parsed.credential.hmacHex;
}
if (parsed.fileUrl !== undefined) body.fileUrl = parsed.fileUrl;
if (parsed.typeId !== undefined) body.typeId = parsed.typeId;
// credential ต้องออกจาก URL ทันทีที่รู้ผล — แต่ strip ก่อนยิงไม่ได้ (reload
// กลางคัน = ลิงก์ตาย) จึงกันค้างด้วย timeout: เครือข่ายนิ่งเกิน 45 วิ = ตัดจบ
const controller = new AbortController();
const timeoutId = window.setTimeout(() => controller.abort(), 45_000);
void (async () => {
try {
const res = await fetch(withBasePath("/api/note/auth/session"), {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
signal: controller.signal,
});
const data = (await res.json().catch(() => null)) as SessionResponse | null;
stripParams();
if (
!res.ok ||
data === null ||
data.access_token === undefined ||
data.userid === undefined ||
data.username === undefined ||
data.expires_in === undefined
) {
fail(
res.status === 401 && data?.reason === "expired"
? "expired"
: res.status === 401
? "invalid"
: "error",
);
return;
}
const { access_token, userid, username, expires_in, fileId, fileError } = data;
saveNoteSession({
token: access_token,
userid,
username,
expMs: Date.now() + expires_in * 1000,
});
if (fileError !== undefined) setFileError(fileError);
if (fileId !== undefined) {
// เข้า viewer เลย — คงสถานะ exchanging ให้ spinner อยู่จนหน้า viewer มาแทน
router.replace(`/note/annotate/${fileId}`);
return;
}
setPhase("ready");
} catch {
stripParams();
fail("error");
} finally {
window.clearTimeout(timeoutId);
}
})();
return;
}
if (hasCredentialKey) {
// มี credential ใน URL แต่รูปไม่ผ่าน (ticket ครึ่งเดียว/ยาวเกิน cap/สองชุดพร้อม
// กัน) — ตัดทิ้ง + แจ้งผู้ใช้ ไม่ปล่อยค่าเน่าค้างอยู่ใน history (D11)
stripParams();
fail("invalid");
return;
}
// ไม่มี credential — หน้าข้อมูลระบบปกติ (แบนเนอร์ ?sso= ของลิงก์เก่า ·
// fileError จาก GET /api/note/files/open ตอนเปิดไฟล์ตัวอย่างไม่สำเร็จ)
setBanner(ssoBanner(url.searchParams.get("sso") ?? undefined));
const openFileError = url.searchParams.get("fileError");
if (openFileError !== null && openFileError !== "") setFileError(openFileError);
setPhase("ready");
}, [router]);
return (
<>
<AppNav />
<main className="mx-auto max-w-4xl px-4 pb-16 pt-12 sm:px-6">
{phase !== "ready" ? (
<p className="mt-2 text-body text-ink-soft" role="status">
{phase === "exchanging"
? "กำลังเข้าสู่ระบบผ่านลิงก์ของ e-Court…"
: "กำลังโหลด…"}
</p>
) : (
<>
{/* ชื่อระบบ Latin ล้วน → General Sans tracking แน่นตาม Genesis display type */}
<h1 className="font-display text-3xl tracking-tight text-ink sm:text-headline">
Judge&apos;s Note
</h1>
<p className="mt-2 text-body text-ink-soft">
ระบบอ่านและทำเครื่องหมายเอกสารคดีของ e-Court — มาร์กข้อความ วาดเส้น
ใส่ความเห็น คั่นหน้า และสรุปหมายเหตุทั้งคดีได้ในที่เดียว
โดยไฟล์ต้นฉบับไม่ถูกแก้ไข
</p>
{banner && (
<div
role="status"
className={`mt-6 rounded-lg border p-4 text-sm ${
banner.intent === "error"
? "border-error/25 bg-error/5 text-error-ink"
: "border-warning/30 bg-warning/5 text-warning-ink"
}`}
>
{banner.text}
</div>
)}
{fileError && (
<div
role="alert"
className="mt-6 rounded-lg border border-error/25 bg-error/5 p-4 text-sm text-error-ink"
>
เปิดเอกสารจากลิงก์ไม่สำเร็จ: {fileError}
</div>
)}
{/* ── ระบบนี้ทำอะไรได้ ────────────────────────────────────────── */}
<section className="mt-10">
<div className="border-b border-line pb-3">
<h2 className="font-display text-section-title text-ink">
ระบบนี้ทำอะไรได้
</h2>
</div>
<div className="mt-5 grid gap-5 sm:grid-cols-2">
{[
{
icon: Highlighter,
title: "มาร์กและวาดบนเอกสาร",
detail:
"มาร์กข้อความไฮไลต์ได้หลายแบบ วาดเส้นด้วยปากกา วาดรูปทรง " +
"ใส่กล่องข้อความและตรายาง รวมถึงปิดบังข้อมูลที่ละเอียดอ่อน " +
"— ย้าย แก้ไข ย่อ-ขยายได้หลังวาง พร้อมปุ่มเรียกคืน (undo/redo)",
},
{
icon: MessagesSquare,
title: "ความเห็นและทำงานร่วมกัน",
detail:
"หมุดความเห็นพร้อมตอบกลับเป็นเธรด ทำเครื่องหมาย “จัดการแล้ว” " +
"เมื่อเสร็จ และเห็นการเปลี่ยนแปลงของทุกคนแบบสด ๆ " +
"ในไฟล์เดียวกันได้ทันที",
},
{
icon: Bookmark,
title: "คั่นหน้าและสรุปคดี",
detail:
"คั่นหน้าเป็นหัวข้อซ้อนกันได้ 3 ระดับพร้อมโน้ตย่อย ค้นหาทั้งไฟล์ " +
"แล้วดูรวมทั้งหมดที่หน้า “สรุปหมายเหตุ” — พิมพ์ออกมาเป็น " +
"เอกสารสรุปของคดีได้",
},
{
icon: FileText,
title: "เอกสารจาก e-Court",
detail:
"เปิด PDF และรูปภาพ (รวมไฟล์ TIFF หลายหน้า) จากลิงก์ของ e-Court " +
"จัดกลุ่มตามคดี และส่งออกเป็น PDF ที่ฝังมาร์กทุกอย่างไว้ในตัว " +
"— ไฟล์ต้นฉบับต้นทางไม่ถูกแตะต้อง",
},
].map(({ icon: Icon, title, detail }) => (
<div
key={title}
className="flex gap-4 rounded-xl border border-line bg-white p-6"
>
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/[0.08] text-primary">
<Icon className="h-5 w-5" aria-hidden />
</span>
<div>
<h3 className="font-display text-subhead text-ink">{title}</h3>
<p className="mt-1.5 text-sm leading-relaxed text-ink-soft">
{detail}
</p>
</div>
</div>
))}
</div>
</section>
{/* ── วิธีเข้าใช้งาน ─────────────────────────────────────────── */}
<section className="mt-10">
<div className="border-b border-line pb-3">
<h2 className="font-display text-section-title text-ink">
วิธีเข้าใช้งาน
</h2>
</div>
<div className="mt-5 flex max-w-xl gap-4 rounded-xl border border-line bg-white p-6">
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/[0.08] text-primary">
<ExternalLink className="h-5 w-5" aria-hidden />
</span>
<div>
<h3 className="font-display text-subhead text-ink">
เข้าใช้งานผ่านลิงก์ของ e-Court
</h3>
<p className="mt-1.5 text-sm leading-relaxed text-ink-soft">
ระบบนี้ไม่มีหน้าล็อกอิน — เปิดเอกสารจากระบบ e-Court
แล้วระบบพาเข้าหน้าอ่านเอกสารโดยอัตโนมัติ ใช้งานต่อได้ 8 ชม.
ต่อการเข้าหนึ่งครั้ง
</p>
</div>
</div>
<p className="mt-8 text-sm text-ink-soft">
ผู้พัฒนา/ผู้ทดสอบ:{" "}
<Link
href="/note/dev"
className="focus-ring -m-1 inline-block rounded-md px-1 py-0.5 font-medium text-primary transition hover:underline"
>
เครื่องมือทดสอบทางเข้าและเอกสารตัวอย่าง →
</Link>
</p>
</section>
</>
)}
</main>
</>
);
}