eCourt-note/components/pdf/AnnotateView.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

151 lines
5.8 KiB
TypeScript

"use client";
// client gate ของหน้า /note/annotate/[id] (ฉบับ token 2026-10-30) — เดิมเป็น server
// component อ่าน identity จาก cookie ผ่าน getSessionUser + query DB ตอน render:
// ตอนนี้หน้าเป็น shell public ฝั่ง server (แค่ตรวจ UUID) ส่วนข้อมูลเริ่มต้น
// (file + annotations + bookmarks + replies) ดึงผ่าน GET /api/note/files/[id]/
// bootstrap พร้อม Bearer หลังเห็น token ใน localStorage — ไม่มี/หมดอายุ = หน้า
// gate "กลับ e-Court กดลิงก์ใหม่" (คู่ขนาน sso=required ของรอบ middleware)
import Link from "next/link";
import { useEffect, useState } from "react";
import DebugConsole from "@/components/DebugConsole"; // TEMP-DEBUG (ลบพร้อมไฟล์)
import { SSO_REQUIRED_BANNER } from "@/lib/note-auth";
import {
clearNoteSession,
noteAuthFetch,
readNoteSession,
type NoteSession,
} from "@/lib/note-session";
import type { AnnotationDTO, BookmarkDTO, ReplyDTO } from "@/db/schema";
import PDFViewer from "@/components/pdf/PDFViewer";
type BootstrapData = {
file: { id: string; title: string; pdfUrl: string };
annotations: AnnotationDTO[];
bookmarks: BookmarkDTO[];
replies: ReplyDTO[];
user: { userId: string; userName: string };
};
type Phase = "boot" | "loading" | "no-session" | "error" | "ready";
const BTN_CLS =
"focus-ring inline-flex h-10 items-center rounded-md bg-primary px-5 text-sm font-medium text-white transition hover:-translate-y-px hover:bg-primary-hover hover:shadow-btn-glow";
function GateScreen({ title }: { title: string }) {
return (
<main className="mx-auto max-w-2xl px-4 py-10 sm:px-8">
<div
role="status"
className="rounded-lg border border-warning/30 bg-warning/5 p-4 text-sm text-warning-ink"
>
{SSO_REQUIRED_BANNER.text}
</div>
<p className="mt-3 text-sm text-ink-soft">{title}</p>
<Link href="/note" className={`${BTN_CLS} mt-4`}>
กลับหน้าหลัก
</Link>
</main>
);
}
export default function AnnotateView({ fileId }: { fileId: string }) {
const [phase, setPhase] = useState<Phase>("boot");
const [data, setData] = useState<BootstrapData | null>(null);
const [token, setToken] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const session: NoteSession | null = readNoteSession();
if (session === null) {
setPhase("no-session");
return;
}
setToken(session.token);
setPhase("loading");
let cancelled = false;
void (async () => {
try {
const res = await noteAuthFetch(`/api/note/files/${fileId}/bootstrap`);
if (res.status === 401) {
// token หมดอายุ/ถูกเพิกถิอกลางวัน — ล้างทิ้งแล้วเป็น gate เหมือนไม่เคยเข้า
clearNoteSession();
if (!cancelled) setPhase("no-session");
return;
}
const body = (await res.json().catch(() => null)) as
| BootstrapData
| { error?: string }
| null;
if (!res.ok || body === null || !("file" in body)) {
if (!cancelled) {
setError(
(body as { error?: string } | null)?.error ??
`โหลดเอกสารไม่สำเร็จ (HTTP ${res.status})`,
);
setPhase("error");
}
return;
}
if (!cancelled) {
setData(body);
setPhase("ready");
}
} catch (err) {
if (cancelled) return;
if (err instanceof Error && err.message === "no-session") {
// session หมดพอดีระหว่างต้น effect กับตอน fetch — เป็น gate ไม่ใช่ network error
clearNoteSession();
setPhase("no-session");
return;
}
setError("โหลดเอกสารไม่สำเร็จ — ตรวจการเชื่อมต่อแล้วลองใหม่อีกครั้ง");
setPhase("error");
}
})();
return () => {
cancelled = true;
};
}, [fileId]);
return (
<>
{/* TEMP-DEBUG: อ่าน log บนจอ iPad ผ่าน Eruda เมื่อเปิดหน้าพร้อม ?debug */}
<DebugConsole />
{phase === "no-session" && (
<GateScreen title="ยังไม่มี session ของแอปนี้บนเบราว์เซอร์ หรือ session หมดอายุแล้ว (อายุ 8 ชม.)" />
)}
{phase === "error" && (
<main className="mx-auto max-w-2xl px-4 py-10 sm:px-8">
<div
role="alert"
className="rounded-lg border border-error/25 bg-error/10 p-4 text-sm text-error-ink"
>
{error}
</div>
<Link href="/note" className={`${BTN_CLS} mt-4`}>
กลับหน้าหลัก
</Link>
</main>
)}
{(phase === "boot" || phase === "loading") && (
<main className="flex min-h-dvh items-center justify-center">
<p className="text-sm text-ink-soft" role="status">
กำลังโหลดเอกสาร…
</p>
</main>
)}
{phase === "ready" && data !== null && token !== null && (
<PDFViewer
file={data.file}
initialAnnotations={data.annotations}
initialBookmarks={data.bookmarks}
initialReplies={data.replies}
user={data.user}
token={token}
/>
)}
</>
);
}