Merge branch 'dev' into working
# Conflicts: # README.md
This commit is contained in:
commit
02add92e3c
57 changed files with 3211 additions and 1363 deletions
|
|
@ -1,19 +1,32 @@
|
|||
"use client";
|
||||
|
||||
// Genesis (docs/DESIGN.md) Navigation: sticky แถบบน 56px + backdrop-blur + เส้นแบ่ง
|
||||
// ล่าง 1px — โลโก้ซ้าย / ผู้ใช้ + ออกจากระบบ ขวา (แอปหน้าเดียวต่อ context จึงไม่มี
|
||||
// ลิงก์กลางจอ) ใช้เฉพาะหน้ารายการ /note — หน้ากลุ่ม /note/groups/[typeId] เลิกใช้ตาม
|
||||
// redesign 2026-09-24 (ไม่มี navbar เหลือ back link) — แทน SessionBar เดิมที่แยก
|
||||
// เป็นแถบของตัวเองกลางหน้า
|
||||
// ล่าง 1px — โลโก้ซ้าย / ผู้ใช้ + ออกจากระบบ ขวา ใช้ที่หน้าแรก /note และหน้า
|
||||
// เครื่องมือทดสอบ /note/dev — หน้ากลุ่ม /note/groups/[typeId] เลิกใช้ตาม
|
||||
// redesign 2026-09-24 (ไม่มี navbar เหลือ back link)
|
||||
//
|
||||
// ฉบับ token 2026-10-30: เป็น client component อ่าน session จาก localStorage
|
||||
// ("note-auth") เอง — มี = ชื่อผู้ใช้ + ปุ่มออกจากระบบ (ล้าง token), ไม่มี =
|
||||
// hint "เข้าผ่านลิงก์ e-Court" (ไม่มีปุ่ม logout — ไม่มี session ให้ล้าง) ·
|
||||
// อ่านหลัง mount กัน hydration mismatch (server ไม่รู้ localStorage)
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import LogoutButton from "./LogoutButton";
|
||||
import { readNoteSession, type NoteSession } from "@/lib/note-session";
|
||||
|
||||
export default function AppNav() {
|
||||
const [session, setSession] = useState<NoteSession | null>(null);
|
||||
useEffect(() => {
|
||||
setSession(readNoteSession());
|
||||
}, []);
|
||||
|
||||
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 ดั้งเดิม) ทั้งแถบเป็นลิงก์กลับหน้ารายการ */}
|
||||
แบรนด์มาร์ก (สืบต่อจากหน้าสรุป/login ดั้งเดิม) ทั้งแถบเป็นลิงก์กลับหน้าหลัก */}
|
||||
<Link
|
||||
href="/note"
|
||||
className="focus-ring flex min-w-0 items-center gap-2.5 rounded-md"
|
||||
|
|
@ -27,9 +40,9 @@ export default function AppNav({ userName }: { userName: string }) {
|
|||
</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}
|
||||
{session?.username ?? "เข้าผ่านลิงก์ e-Court"}
|
||||
</p>
|
||||
<LogoutButton />
|
||||
{session !== null && <LogoutButton onCleared={() => setSession(null)} />}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
|
|
|||
|
|
@ -9,6 +9,9 @@ import { useEffect } from "react";
|
|||
* app/note/annotate/[id]/page.tsx */
|
||||
export default function DebugConsole() {
|
||||
useEffect(() => {
|
||||
// production build ไม่โหลด CDN เด็ดขาด (ระบบ intranet + token อยู่ใน origin
|
||||
// เดียวกับ script นี้) — ใช้ได้เฉพาะ dev server ที่เครื่องทดสอบเปิดให้ LAN
|
||||
if (process.env.NODE_ENV === "production") return;
|
||||
if (!new URLSearchParams(window.location.search).has("debug")) return;
|
||||
// React strict mode รัน effect สองรอบ — กัน append script ซ้ำ
|
||||
if (document.getElementById("eruda-loader")) return;
|
||||
|
|
|
|||
160
components/DevLoginLinkForm.tsx
Normal file
160
components/DevLoginLinkForm.tsx
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
"use client";
|
||||
|
||||
import { useState, type FormEvent } from "react";
|
||||
|
||||
import { withBasePath } from "@/utils/base-path";
|
||||
|
||||
/**
|
||||
* ฟอร์ม mint ลิงก์ SSO ทดสอบบนหน้า /note/dev (dev-only — route เปิดด้วย env
|
||||
* NOTE_SSO_DEV_MINT=1 เท่านั้น, คำขอผู้ใช้ 2026-10-29 "ใช้ได้ใน note") — server
|
||||
* คำนวณ token/ticket ด้วย secret ของตัวเองแล้วคืน URL เต็ม ค่าลับไม่ผ่านหน้า
|
||||
* เบราว์เซอร์ · ได้ลิงก์มา = คลิกเข้าจริงตาม flow ของทางนั้น (A/B)
|
||||
*/
|
||||
export default function DevLoginLinkForm() {
|
||||
const [mode, setMode] = useState<"jwt" | "ticket">("jwt");
|
||||
const [userid, setUserid] = useState("oom");
|
||||
const [username, setUsername] = useState("สรวิชญ์");
|
||||
const [fid, setFid] = useState("F123");
|
||||
const [ttl, setTtl] = useState(300);
|
||||
const [fileUrl, setFileUrl] = useState("/samples/01012-672427-1f-690609-0000840223.pdf");
|
||||
const [typeId, setTypeId] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [url, setUrl] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const onSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
setUrl(null);
|
||||
setCopied(false);
|
||||
try {
|
||||
const res = await fetch(withBasePath("/api/note/auth/dev-link"), {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
mode,
|
||||
userid,
|
||||
username,
|
||||
fid,
|
||||
ttl_seconds: ttl,
|
||||
...(fileUrl.trim() !== "" ? { fileUrl: fileUrl.trim() } : {}),
|
||||
...(typeId.trim() !== "" ? { typeId: typeId.trim() } : {}),
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = (await res.json().catch(() => null)) as { error?: string } | null;
|
||||
throw new Error(data?.error ?? `HTTP ${res.status}`);
|
||||
}
|
||||
const data = (await res.json()) as { url: string };
|
||||
setUrl(data.url);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "สร้างลิงก์ไม่สำเร็จ");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const inputCls =
|
||||
"genesis-input h-9 px-3 py-1.5 disabled:bg-canvas disabled:text-ink-faint";
|
||||
const labelCls = "text-xs font-medium text-ink-soft";
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit} className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className={labelCls} htmlFor="devlink-mode">ทางเข้า</label>
|
||||
<select
|
||||
id="devlink-mode"
|
||||
value={mode}
|
||||
onChange={(e) => setMode(e.target.value === "ticket" ? "ticket" : "jwt")}
|
||||
className={inputCls}
|
||||
>
|
||||
<option value="jwt">ทาง A — JWT</option>
|
||||
<option value="ticket">ทาง B — ticket</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className={labelCls} htmlFor="devlink-ttl">อายุ (วินาที 60–600)</label>
|
||||
<input
|
||||
id="devlink-ttl"
|
||||
type="number"
|
||||
min={60}
|
||||
max={600}
|
||||
value={ttl}
|
||||
onChange={(e) => setTtl(Number(e.target.value))}
|
||||
className={inputCls}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className={labelCls} htmlFor="devlink-uid">userid / uid</label>
|
||||
<input id="devlink-uid" value={userid} onChange={(e) => setUserid(e.target.value)} className={inputCls} required />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className={labelCls} htmlFor="devlink-uname">username / uname</label>
|
||||
<input id="devlink-uname" value={username} onChange={(e) => setUsername(e.target.value)} className={inputCls} required />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className={labelCls} htmlFor="devlink-fid">fid (fileId ของ e-Court)</label>
|
||||
<input id="devlink-fid" value={fid} onChange={(e) => setFid(e.target.value)} className={inputCls} required />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className={labelCls} htmlFor="devlink-typeid">typeId (ทางเลือก)</label>
|
||||
<input id="devlink-typeid" value={typeId} onChange={(e) => setTypeId(e.target.value)} className={inputCls} placeholder="ไม่กรอก = ไม่มี" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5 sm:col-span-2">
|
||||
<label className={labelCls} htmlFor="devlink-fileurl">fileUrl (ไม่กรอกก็ได้ — ไฟล์ตัวอย่าง /samples/… หรือลิงก์ https ที่อนุญาต)</label>
|
||||
<input id="devlink-fileurl" value={fileUrl} onChange={(e) => setFileUrl(e.target.value)} className={inputCls} placeholder="ไม่กรอก = เข้าหน้าแรกอย่างเดียว" />
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy}
|
||||
className="focus-ring inline-flex h-9 items-center rounded-md border border-line bg-white px-4 text-sm font-medium text-ink transition hover:-translate-y-px hover:border-ink-faint disabled:pointer-events-none disabled:opacity-50"
|
||||
>
|
||||
{busy ? "กำลังสร้าง…" : "สร้างลิงก์ทดสอบ"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p role="alert" className="sm:col-span-2 rounded-lg border border-error/25 bg-error/5 p-3 text-sm text-error-ink">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{url && (
|
||||
<div className="sm:col-span-2 rounded-lg border border-line bg-canvas p-3">
|
||||
<p className="text-xs font-medium text-ink-soft">
|
||||
ลิงก์พร้อมใช้ (อายุ {ttl} วินาที) — กดเพื่อทดสอบเข้าจริง:
|
||||
</p>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
<a
|
||||
href={url}
|
||||
className="focus-ring inline-flex h-8 items-center rounded-md bg-primary px-3 text-sm font-medium text-white transition hover:-translate-y-px hover:bg-primary-hover"
|
||||
>
|
||||
เปิดลิงก์
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
setCopied(true);
|
||||
} catch {
|
||||
/* clipboard โดน block — คัดลอกจากกล่องข้อความด้านล่างเอง */
|
||||
}
|
||||
}}
|
||||
className="focus-ring inline-flex h-8 items-center rounded-md border border-line bg-white px-3 text-xs font-medium text-ink-soft transition hover:border-ink-faint"
|
||||
>
|
||||
{copied ? "คัดลอกแล้ว" : "คัดลอก URL"}
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-2 break-all rounded border border-line bg-white px-2.5 py-2 text-[12px] leading-relaxed text-ink-soft">
|
||||
{url}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
"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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,118 +0,0 @@
|
|||
"use client";
|
||||
|
||||
// N8 — การ์ดจำลองลิงก์ e-Court บนหน้ารายการ: กรอก fileUrl (+ fileId + typeId) แล้วกด
|
||||
// "เปิดเอกสาร" จะพาไป /annotate?fileUrl=&fileId=&typeId= ซึ่งลงทะเบียนเอกสาร
|
||||
// (metadata-only) แล้ว redirect เข้าหน้า viewer ให้เอง — ใช้ทดสอบในเครื่องก่อนมี
|
||||
// ลิงก์จริงจาก 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 { withBasePath } from "@/utils/base-path";
|
||||
|
||||
const inputClass =
|
||||
"genesis-input mt-1 w-full text-base transition aria-invalid:border-error";
|
||||
|
||||
export default function EcourtLinkForm() {
|
||||
const router = useRouter();
|
||||
const [fileUrl, setFileUrl] = useState("");
|
||||
const [fileId, setFileId] = useState("");
|
||||
const [typeId, setTypeId] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// origin เอาตอน client mount เท่านั้น — SSR ไม่มี window (กัน hydration mismatch)
|
||||
const [origin, setOrigin] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
setOrigin(window.location.origin);
|
||||
}, []);
|
||||
|
||||
// query ชุดเดียวกันทั้ง preview และตอนกดปุ่ม — สิ่งที่เห็นคือสิ่งที่ถูกเปิดเป๊ะ
|
||||
const params = new URLSearchParams();
|
||||
if (fileUrl.trim()) params.set("fileUrl", fileUrl.trim());
|
||||
if (fileId.trim()) params.set("fileId", fileId.trim());
|
||||
if (typeId.trim()) params.set("typeId", typeId.trim());
|
||||
const queryString = params.toString();
|
||||
const fullLink = origin
|
||||
? `${origin}${withBasePath("/annotate")}${queryString ? `?${queryString}` : ""}`
|
||||
: "";
|
||||
|
||||
const onSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
if (!fileUrl.trim()) {
|
||||
setError("กรอกลิงก์ไฟล์ก่อน");
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
// router.push เติม basePath ให้เอง (invariant #12 — ต่างจาก fetch/plain href)
|
||||
router.push(`/note/annotate?${queryString}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<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-sm font-medium text-ink">
|
||||
ลิงก์ไฟล์
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="ecourt-file-url"
|
||||
name="fileUrl"
|
||||
required
|
||||
value={fileUrl}
|
||||
onChange={(e) => setFileUrl(e.target.value)}
|
||||
placeholder="เช่น /samples/ชื่อไฟล์.pdf หรือ https://…"
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="ecourt-file-id" className="block text-sm font-medium text-ink">
|
||||
รหัสเอกสาร fileId
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="ecourt-file-id"
|
||||
name="fileId"
|
||||
value={fileId}
|
||||
onChange={(e) => setFileId(e.target.value)}
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="ecourt-type-id" className="block text-sm font-medium text-ink">
|
||||
กลุ่ม typeId
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="ecourt-type-id"
|
||||
name="typeId"
|
||||
value={typeId}
|
||||
onChange={(e) => setTypeId(e.target.value)}
|
||||
placeholder="เช่น 1"
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
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-3 text-xs leading-relaxed text-ink-soft">
|
||||
กดแล้วระบบลงทะเบียนเอกสาร (ถ้ายังไม่มี) แล้วพาเข้าหน้าอ่าน/มาร์กเอกสารทันที —
|
||||
ไฟล์รีโมตรับเฉพาะ https ของโฮสต์ที่อนุญาต
|
||||
</p>
|
||||
<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-2 text-xs text-error-ink">{error}</p>}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,143 +0,0 @@
|
|||
"use client";
|
||||
|
||||
// ฟอร์ม login (mock e-Court) — เรียก server action login แล้ว router.replace(next ?? "/note")
|
||||
// (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";
|
||||
|
||||
import { login } from "@/app/actions/auth";
|
||||
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 =
|
||||
"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 = "genesis-input block w-full text-base transition";
|
||||
|
||||
export default function LoginForm({ next }: { next: string | null }) {
|
||||
const router = useRouter();
|
||||
const passwordRef = useRef<HTMLInputElement | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [pending, startTransition] = useTransition();
|
||||
|
||||
const onSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const form = e.currentTarget;
|
||||
setError(null);
|
||||
const usernameInput = form.elements.namedItem("username") as HTMLSelectElement;
|
||||
const passwordInput = form.elements.namedItem("password") as HTMLInputElement;
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const result = await login(usernameInput.value, passwordInput.value);
|
||||
if (result.ok) {
|
||||
router.replace(next ?? "/note");
|
||||
} else {
|
||||
setError(result.error);
|
||||
// คง username ไว้ (โอกาสพลาดอยู่ที่ password) — ล้างแล้วโฟกัสให้พิมพ์ทับได้ทันที
|
||||
passwordInput.value = "";
|
||||
passwordRef.current?.focus();
|
||||
passwordRef.current?.select();
|
||||
}
|
||||
} catch {
|
||||
setError("เข้าสู่ระบบไม่สำเร็จ — ลองใหม่อีกครั้ง");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<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-lg bg-primary text-base font-bold text-white">
|
||||
JN
|
||||
</div>
|
||||
<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="font-display mt-8 text-subhead text-ink">เข้าสู่ระบบ</h1>
|
||||
|
||||
<form onSubmit={onSubmit} className="mt-5">
|
||||
<div>
|
||||
<label htmlFor="username" className="block text-sm font-medium text-ink">
|
||||
ชื่อผู้ใช้
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<Select
|
||||
id="username"
|
||||
name="username"
|
||||
autoComplete="username"
|
||||
required
|
||||
autoFocus
|
||||
disabled={pending}
|
||||
defaultValue=""
|
||||
className={SELECT_CLASS}
|
||||
>
|
||||
{/* placeholder บังคับเลือก — ไม่ preselect คนแรก กันผู้ทดสอบ login ผิดคนโดยไม่ตั้งใจ */}
|
||||
<option value="" disabled>
|
||||
เลือกผู้ใช้
|
||||
</option>
|
||||
{MOCK_USERS.map((u) => (
|
||||
<option key={u} value={u}>
|
||||
{u}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<label htmlFor="password" className="block text-sm font-medium text-ink">
|
||||
รหัสผ่าน
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
disabled={pending}
|
||||
aria-invalid={!!error || undefined}
|
||||
ref={passwordRef}
|
||||
className={INPUT_CLASS}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={pending}
|
||||
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 ไม่มีระบบสมัคร)
|
||||
กล่อง 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-ink">Demo1234</span>
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<p role="alert" className="mt-3 text-xs text-error-ink">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,29 +1,26 @@
|
|||
"use client";
|
||||
|
||||
// ปุ่มออกจากระบบ — server action logout ลบ cookie oacCase แล้ว redirect /login เอง
|
||||
import { useTransition } from "react";
|
||||
// ปุ่มออกจากระบบ (ฉบับ token 2026-10-30) — session เป็น access_token ใน
|
||||
// localStorage ไม่ได้อยู่บน server แล้ว การออกจากระบบจึงแค่ล้าง "note-auth"
|
||||
// แล้วแจ้ง parent ให้ซ่อนปุ่ม/ชื่อผู้ใช้ (ไม่มี server action logout ให้เรียก)
|
||||
import { LogOut } from "lucide-react";
|
||||
|
||||
import { logout } from "@/app/actions/auth";
|
||||
|
||||
export default function LogoutButton() {
|
||||
const [pending, startTransition] = useTransition();
|
||||
import { clearNoteSession } from "@/lib/note-session";
|
||||
|
||||
export default function LogoutButton({ onCleared }: { onCleared?: () => void }) {
|
||||
const onLogout = () => {
|
||||
startTransition(async () => {
|
||||
await logout();
|
||||
});
|
||||
clearNoteSession();
|
||||
onCleared?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onLogout}
|
||||
disabled={pending}
|
||||
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"
|
||||
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"
|
||||
>
|
||||
<LogOut className="h-3.5 w-3.5" aria-hidden />
|
||||
{pending ? "กำลังออก…" : "ออกจากระบบ"}
|
||||
ออกจากระบบ
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
114
components/MockEcourtForm.tsx
Normal file
114
components/MockEcourtForm.tsx
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
"use client";
|
||||
|
||||
import { useState, type FormEvent } from "react";
|
||||
|
||||
import { withBasePath } from "@/utils/base-path";
|
||||
|
||||
/**
|
||||
* ฟอร์ม "จำลอง e-Court" บนหน้า /note/dev สำหรับตอน mint อัตโนมัติปิด
|
||||
* (production เสมอตาม devMintEnabled) — ผู้ทดสอบใส่ DOCVER_API_KEY แล้วยิง
|
||||
* POST /api/note/auth/token (เส้นเดียวกับที่ backend e-Court ใช้จริง) ได้
|
||||
* access_token มาแล้วพาเข้า /note?fileUrl=…&t=… ให้ NoteHome แลกเป็น session
|
||||
* ต่อ — ไม่เป็นประตูหลังเพราะต้องถือ key ที่ถูกต้องอยู่แล้ว
|
||||
* (คำขอผู้ใช้ 2026-11-02: กดเข้าเอกสารจากหน้า /note/dev บน production ให้ได้)
|
||||
*/
|
||||
export default function MockEcourtForm({ files }: { files: string[] }) {
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [userid, setUserid] = useState("oom");
|
||||
const [username, setUsername] = useState("สรวิชญ์");
|
||||
const [file, setFile] = useState(files[0] ?? "");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const onSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(withBasePath("/api/note/auth/token"), {
|
||||
method: "POST",
|
||||
headers: { "x-api-key": apiKey, "content-type": "application/json" },
|
||||
// fid ผูกไฟล์ที่เลือกแบบเดียวกับ dev-link (dev-<file>) — แต่ละไฟล์
|
||||
// ตัวอย่างมี row ecourt: ของตัวเอง ไม่ปนกัน
|
||||
body: JSON.stringify({
|
||||
userid: userid.trim(),
|
||||
username: username.trim(),
|
||||
fid: `dev-${file}`,
|
||||
ttl_seconds: 600,
|
||||
}),
|
||||
});
|
||||
const data = (await res.json().catch(() => null)) as
|
||||
| { access_token?: string; error?: string }
|
||||
| null;
|
||||
if (!res.ok || data?.access_token === undefined) {
|
||||
throw new Error(data?.error ?? `ขอ token ไม่สำเร็จ (HTTP ${res.status})`);
|
||||
}
|
||||
// withBasePath("") = /docedit/note — ห้ามส่ง "/note" เข้า withBasePath (D11)
|
||||
const params = new URLSearchParams({ fileUrl: `/samples/${file}`, t: data.access_token });
|
||||
window.location.href = `${withBasePath("")}?${params.toString()}`;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "ขอ token ไม่สำเร็จ");
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const inputCls =
|
||||
"genesis-input h-9 px-3 py-1.5 disabled:bg-canvas disabled:text-ink-faint";
|
||||
const labelCls = "text-xs font-medium text-ink-soft";
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit} className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="flex flex-col gap-1.5 sm:col-span-2">
|
||||
<label className={labelCls} htmlFor="mock-key">
|
||||
API key (env DOCVER_API_KEY ของเครื่องนี้)
|
||||
</label>
|
||||
<input
|
||||
id="mock-key"
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
className={inputCls}
|
||||
autoComplete="off"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className={labelCls} htmlFor="mock-uid">userid</label>
|
||||
<input id="mock-uid" value={userid} onChange={(e) => setUserid(e.target.value)} className={inputCls} required />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className={labelCls} htmlFor="mock-uname">username</label>
|
||||
<input id="mock-uname" value={username} onChange={(e) => setUsername(e.target.value)} className={inputCls} required />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5 sm:col-span-2">
|
||||
<label className={labelCls} htmlFor="mock-file">เอกสารตัวอย่าง</label>
|
||||
<select
|
||||
id="mock-file"
|
||||
value={file}
|
||||
onChange={(e) => setFile(e.target.value)}
|
||||
className={inputCls}
|
||||
>
|
||||
{files.map((f) => (
|
||||
<option key={f} value={f}>{f}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy}
|
||||
className="focus-ring inline-flex h-9 items-center rounded-md bg-primary px-4 text-sm font-medium text-white transition hover:-translate-y-px hover:bg-primary-hover disabled:pointer-events-none disabled:opacity-50"
|
||||
>
|
||||
{busy ? "กำลังเข้าระบบ…" : "ขอ token และเปิดเอกสาร"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p role="alert" className="sm:col-span-2 rounded-lg border border-error/25 bg-error/5 p-3 text-sm text-error-ink">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
290
components/NoteHome.tsx
Normal file
290
components/NoteHome.tsx
Normal file
|
|
@ -0,0 +1,290 @@
|
|||
"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'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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,88 +0,0 @@
|
|||
"use client";
|
||||
|
||||
// N4 — ฟอร์มอัปโหลดไฟล์เอกสารแบบง่ายบนหน้ารายการ (PDF/รูป รับพร้อมกัน)
|
||||
// เสร็จแล้วรายการโผล่เองจาก revalidatePath("/note") ของ 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";
|
||||
|
||||
const ACCEPT = ".pdf,.jpg,.jpeg,.png,.tif,.tiff";
|
||||
|
||||
/** ขนาดสูงสุดตาม validator ของ uploadFile (app/actions/files.ts) — เช็คฝั่ง client
|
||||
* ก่อนอัปโหลด (M5, UX audit 2026-09-16): ไฟล์สแกนคดีที่เกินไม่ต้องรอส่งจนจบ
|
||||
* บนเน็ตช้าก่อนเจอ error ของ server */
|
||||
const MAX_BYTES = 100 * 1024 * 1024;
|
||||
|
||||
export default function UploadFileForm() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [pending, startTransition] = useTransition();
|
||||
|
||||
const onSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const form = e.currentTarget;
|
||||
setError(null);
|
||||
const input = form.elements.namedItem("file") as HTMLInputElement | null;
|
||||
const file = input?.files?.[0];
|
||||
if (file && file.size > MAX_BYTES) {
|
||||
setError("ไฟล์ใหญ่เกิน 100MB — เลือกไฟล์เล็กกว่า");
|
||||
return;
|
||||
}
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const result = await uploadFile(new FormData(form));
|
||||
if (result.ok) {
|
||||
form.reset();
|
||||
} else {
|
||||
setError(result.error);
|
||||
}
|
||||
} catch {
|
||||
// action fail ที่ระดับ transport (เช่น body ใหญ่เกิน bodySizeLimit ของ next.config)
|
||||
setError("อัปโหลดไม่สำเร็จ — ลองใหม่อีกครั้ง");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<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="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="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="mt-3 text-xs leading-relaxed text-ink-soft">
|
||||
รองรับ PDF, JPG, PNG, TIFF — ไม่เกิน 100MB ต่อไฟล์
|
||||
</p>
|
||||
{error && <p className="mt-2 text-xs text-error-ink">{error}</p>}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
151
components/pdf/AnnotateView.tsx
Normal file
151
components/pdf/AnnotateView.tsx
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
"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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -113,7 +113,6 @@ import {
|
|||
type Rect,
|
||||
type Size,
|
||||
} from "@/utils/coordinates";
|
||||
import type { OacUser } from "@/utils/user";
|
||||
import {
|
||||
MAX_BOOKMARK_NOTE_CHARS,
|
||||
MAX_BOOKMARK_TITLE_CHARS,
|
||||
|
|
@ -1315,13 +1314,17 @@ export default function PDFViewer({
|
|||
initialBookmarks,
|
||||
initialReplies,
|
||||
user,
|
||||
token,
|
||||
}: {
|
||||
file: { id: string; title: string; pdfUrl: string };
|
||||
initialAnnotations: AnnotationDTO[];
|
||||
initialBookmarks: BookmarkDTO[];
|
||||
initialReplies: ReplyDTO[];
|
||||
/** identity จาก cookie oacCase (หน้า annotate การันตีให้ — ไม่มีช่วง null หลัง mount) */
|
||||
user: OacUser;
|
||||
/** identity จาก access_token ของ session (AnnotateView การันตีให้พร้อมกับ token
|
||||
* — ไม่มีช่วง null หลัง mount · ฉบับ token 2026-10-30 แทน cookie oacCase) */
|
||||
user: { userId: string; userName: string };
|
||||
/** access_token ส่งเข้าทุก Server Action — identity แกะฝั่ง server */
|
||||
token: string;
|
||||
}) {
|
||||
const [pdfjs, setPdfjs] = useState<typeof import("pdfjs-dist") | null>(null);
|
||||
const [doc, setDoc] = useState<import("pdfjs-dist").PDFDocumentProxy | null>(null);
|
||||
|
|
@ -1332,6 +1335,7 @@ export default function PDFViewer({
|
|||
// F8: Export PDF ดาวน์โหลดในแท็บเดิม (fetch → blob → <a download>) ไม่เปิดแท็บใหม่
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [exportError, setExportError] = useState<string | null>(null);
|
||||
const exportErrorTimerRef = useRef<number | null>(null);
|
||||
/** H1 (UX audit 2026-09-16): error ล่าสุดของการบันทึก/แก้ไข/ลบ annotation — เดิม catch
|
||||
* ทุกตัวมีแต่ console.error ผู้ใช้เห็นรายการหายเงียบ ๆ (React rollback optimistic)
|
||||
* โดยไม่รู้เลยว่าบันทึกไม่สำเร็จ */
|
||||
|
|
@ -1445,7 +1449,14 @@ export default function PDFViewer({
|
|||
redo: redoHistory,
|
||||
canUndo,
|
||||
canRedo,
|
||||
} = useHistory();
|
||||
} = useHistory({
|
||||
onError: (_err, kind) =>
|
||||
showActionError(
|
||||
kind === "undo"
|
||||
? "เรียกคืนไม่สำเร็จ — ตรวจสอบการเชื่อมต่อแล้วลองอีกครั้ง"
|
||||
: "ทำซ้ำไม่สำเร็จ — ตรวจสอบการเชื่อมต่อแล้วลองอีกครั้ง",
|
||||
),
|
||||
});
|
||||
|
||||
// Optimistic update (invariant #9): ใช้ useOptimistic ภายใน startTransition
|
||||
// React ถอด optimistic row ออกอัตโนมัติเมื่อ action เสร็จ + rollback เองเมื่อล้มเหลว
|
||||
|
|
@ -1687,7 +1698,13 @@ export default function PDFViewer({
|
|||
} catch (err) {
|
||||
console.error("export PDF ไม่สำเร็จ", err);
|
||||
setExportError("Export PDF ไม่สำเร็จ — ลองอีกครั้ง (ไฟล์อาจใหญ่เกินหรือต้นฉบับเสียหาย)");
|
||||
window.setTimeout(() => setExportError(null), 6000);
|
||||
// track handle เหมือน actionErrorTimerRef — กัน export พังสองรอบติดกันแล้ว
|
||||
// timer รอบแรกเคลียร์ error ของรอบหลังก่อนเวลา
|
||||
if (exportErrorTimerRef.current !== null) window.clearTimeout(exportErrorTimerRef.current);
|
||||
exportErrorTimerRef.current = window.setTimeout(() => {
|
||||
exportErrorTimerRef.current = null;
|
||||
setExportError(null);
|
||||
}, 6000);
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
|
|
@ -2152,7 +2169,7 @@ export default function PDFViewer({
|
|||
type: AnnotationType,
|
||||
data: AnnotationData,
|
||||
) {
|
||||
if (!user) return;
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const temp: AnnotationDTO = {
|
||||
id: crypto.randomUUID(),
|
||||
|
|
@ -2172,19 +2189,17 @@ export default function PDFViewer({
|
|||
await startTransition(async () => {
|
||||
applyAnnotation({ kind: "add", row: temp });
|
||||
try {
|
||||
const saved = await createAnnotation({
|
||||
const saved = await createAnnotation(token, {
|
||||
id: temp.id, // ใช้ id เดียวกับ optimistic row — กัน duplicate ชั่วขณะ (row โผล่แล้วหาย)
|
||||
fileId: file.id,
|
||||
pageIndex,
|
||||
userId: user.userId,
|
||||
userName: user.userName,
|
||||
type,
|
||||
data,
|
||||
});
|
||||
if (pending.cancelled) {
|
||||
// ถูกสั่งลบไปแล้วระหว่างรอ — ลบ row จริงที่ server แล้วจบเลย
|
||||
// ไม่ append เข้า state ไม่ลง history (optimistic row ถูกถอดเมื่อ transition จบ)
|
||||
await deleteAnnotation(saved.id, user.userId);
|
||||
await deleteAnnotation(token, saved.id);
|
||||
return;
|
||||
}
|
||||
setAnnotations((prev) =>
|
||||
|
|
@ -2205,15 +2220,13 @@ export default function PDFViewer({
|
|||
let currentId = saved.id;
|
||||
pushHistory({
|
||||
undo: async () => {
|
||||
await deleteAnnotation(currentId, user.userId);
|
||||
await deleteAnnotation(token, currentId);
|
||||
setAnnotations((prev) => prev.filter((a) => a.id !== currentId));
|
||||
},
|
||||
redo: async () => {
|
||||
const re = await createAnnotation({
|
||||
const re = await createAnnotation(token, {
|
||||
fileId: file.id,
|
||||
pageIndex,
|
||||
userId: user.userId,
|
||||
userName: user.userName,
|
||||
type,
|
||||
data,
|
||||
});
|
||||
|
|
@ -2233,7 +2246,7 @@ export default function PDFViewer({
|
|||
}
|
||||
|
||||
async function handleDeleteAnnotation(id: string) {
|
||||
if (!user) return;
|
||||
|
||||
// FIX-2: id นี้เป็นรายการที่ create ยังรอ network อยู่ — mark ให้ฝั่ง create ลบต่อเอง
|
||||
// เมื่อ action กลับมา (id ชั่วคราวยังไม่มีใน DB) — ไม่ dispatch remove แยกต่างหาก
|
||||
// เพราะ transition ใหม่จบทันทีแล้ว optimistic remove ถูก revert (row กระพริบกลับมา)
|
||||
|
|
@ -2251,18 +2264,16 @@ export default function PDFViewer({
|
|||
await startTransition(async () => {
|
||||
applyAnnotation({ kind: "remove", id });
|
||||
try {
|
||||
await deleteAnnotation(id, user.userId);
|
||||
await deleteAnnotation(token, id);
|
||||
setAnnotations((prev) => prev.filter((a) => a.id !== id));
|
||||
if (target) {
|
||||
// F2: undo ของ delete = สร้างใหม่จาก data เดิม (id ใหม่); redo = ลบ id ที่สร้างใหม่นั้น
|
||||
let currentId: string | null = null;
|
||||
pushHistory({
|
||||
undo: async () => {
|
||||
const re = await createAnnotation({
|
||||
const re = await createAnnotation(token, {
|
||||
fileId: file.id,
|
||||
pageIndex: target.pageIndex,
|
||||
userId: target.userId,
|
||||
userName: target.userName,
|
||||
type: target.type,
|
||||
data: target.data,
|
||||
});
|
||||
|
|
@ -2273,7 +2284,7 @@ export default function PDFViewer({
|
|||
},
|
||||
redo: async () => {
|
||||
if (!currentId) return;
|
||||
await deleteAnnotation(currentId, user.userId);
|
||||
await deleteAnnotation(token, currentId);
|
||||
setAnnotations((prev) => prev.filter((a) => a.id !== currentId));
|
||||
},
|
||||
});
|
||||
|
|
@ -2300,7 +2311,7 @@ export default function PDFViewer({
|
|||
* await ครั้งแรกเท่านั้น ไม่รอ action จบ ใช้ตัวนั้นหน่วงคิวแล้ว action เรียงไม่ได้) */
|
||||
function applyUpdate(id: string, data: AnnotationData, before: AnnotationData): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
if (!user) return resolve();
|
||||
|
||||
const current = annotations.find((a) => a.id === id);
|
||||
if (!current || current.userId !== user.userId) return resolve(); // ของคนอื่นแก้ไม่ได้ (D9)
|
||||
bumpPreviewClear();
|
||||
|
|
@ -2310,15 +2321,15 @@ export default function PDFViewer({
|
|||
kind: "update",
|
||||
row: { ...current, data, updatedAt: new Date().toISOString() },
|
||||
});
|
||||
const saved = await updateAnnotation(id, user.userId, data);
|
||||
const saved = await updateAnnotation(token, id, data);
|
||||
setAnnotations((prev) => prev.map((a) => (a.id === saved.id ? saved : a)));
|
||||
pushHistory({
|
||||
undo: async () => {
|
||||
const up = await updateAnnotation(id, user.userId, before);
|
||||
const up = await updateAnnotation(token, id, before);
|
||||
setAnnotations((prev) => prev.map((a) => (a.id === up.id ? up : a)));
|
||||
},
|
||||
redo: async () => {
|
||||
const up = await updateAnnotation(id, user.userId, data);
|
||||
const up = await updateAnnotation(token, id, data);
|
||||
setAnnotations((prev) => prev.map((a) => (a.id === up.id ? up : a)));
|
||||
},
|
||||
});
|
||||
|
|
@ -2392,7 +2403,7 @@ export default function PDFViewer({
|
|||
},
|
||||
});
|
||||
try {
|
||||
const saved = await setAnnotationResolved(id, resolved);
|
||||
const saved = await setAnnotationResolved(token, id, resolved);
|
||||
setAnnotations((prev) => prev.map((a) => (a.id === saved.id ? saved : a)));
|
||||
} catch (err) {
|
||||
console.error("ตั้งสถานะไม่สำเร็จ", err); // React rollback optimistic ให้เอง
|
||||
|
|
@ -2407,12 +2418,10 @@ export default function PDFViewer({
|
|||
annotationId: string,
|
||||
message: string,
|
||||
): Promise<boolean> {
|
||||
if (!user) return false;
|
||||
|
||||
try {
|
||||
const saved = await createReply({
|
||||
const saved = await createReply(token, {
|
||||
annotationId,
|
||||
userId: user.userId,
|
||||
userName: user.userName,
|
||||
message,
|
||||
});
|
||||
setReplies((prev) =>
|
||||
|
|
@ -2427,9 +2436,9 @@ export default function PDFViewer({
|
|||
}
|
||||
|
||||
async function handleDeleteReply(id: string) {
|
||||
if (!user) return;
|
||||
|
||||
try {
|
||||
await deleteReply(id, user.userId);
|
||||
await deleteReply(token, id);
|
||||
setReplies((prev) => prev.filter((r) => r.id !== id));
|
||||
} catch (err) {
|
||||
console.error("ลบคำตอบไม่สำเร็จ", err);
|
||||
|
|
@ -2466,8 +2475,12 @@ export default function PDFViewer({
|
|||
function bookmarkDepthOf(b: BookmarkDTO): number {
|
||||
let depth = 1;
|
||||
let cur: BookmarkDTO | undefined = b;
|
||||
while (cur?.parentId && depth <= 100) {
|
||||
const parent = optimisticBookmarks.find((x) => x.id === cur!.parentId);
|
||||
while (cur !== undefined && depth <= 100) {
|
||||
// annotate ชัดกัน TS7022 — inference ของ cur วนอ้าง parent ที่อ้าง
|
||||
// optimisticBookmarks ซึ่งยัง infer ไม่เสร็จ (function ถูกใช้ก่อนประกาศ)
|
||||
const pid: string | null = cur.parentId;
|
||||
if (!pid) break;
|
||||
const parent: BookmarkDTO | undefined = optimisticBookmarks.find((x) => x.id === pid);
|
||||
if (!parent) break;
|
||||
depth += 1;
|
||||
cur = parent;
|
||||
|
|
@ -2564,15 +2577,13 @@ export default function PDFViewer({
|
|||
|
||||
async function handleSaveBookmark() {
|
||||
const c = bookmarkComposer;
|
||||
if (!c || !user || bookmarkPendingRef.current) return;
|
||||
if (!c || bookmarkPendingRef.current) return;
|
||||
bookmarkPendingRef.current = true;
|
||||
setBookmarkSaving(true);
|
||||
try {
|
||||
const saved = await createBookmark({
|
||||
const saved = await createBookmark(token, {
|
||||
fileId: file.id,
|
||||
pageIndex: c.pageIndex,
|
||||
userId: user.userId,
|
||||
userName: user.userName,
|
||||
title: c.title.trim() || `หน้า ${c.pageIndex + 1}`,
|
||||
yOffset: c.yOffset,
|
||||
parentId: c.parentId,
|
||||
|
|
@ -2597,16 +2608,14 @@ export default function PDFViewer({
|
|||
|
||||
/** ปุ่ม + ที่หัวข้อใน sidebar → bookmark หน้าที่ดูอยู่เข้าใต้หัวข้อนั้นทันที (ไม่เปิด popup) */
|
||||
async function handleAddSubBookmark(parentId: string) {
|
||||
if (!user || bookmarkPendingRef.current) return;
|
||||
if (bookmarkPendingRef.current) return;
|
||||
const pos = currentViewPosition();
|
||||
if (!pos) return;
|
||||
bookmarkPendingRef.current = true;
|
||||
try {
|
||||
const saved = await createBookmark({
|
||||
const saved = await createBookmark(token, {
|
||||
fileId: file.id,
|
||||
pageIndex: pos.pageIndex,
|
||||
userId: user.userId,
|
||||
userName: user.userName,
|
||||
title: `หน้า ${pos.pageIndex + 1}`,
|
||||
yOffset: pos.yOffset,
|
||||
parentId,
|
||||
|
|
@ -2626,9 +2635,9 @@ export default function PDFViewer({
|
|||
id: string,
|
||||
patch: { title?: string; note?: string | null; parentId?: string | null },
|
||||
) {
|
||||
if (!user) return;
|
||||
|
||||
try {
|
||||
const saved = await updateBookmark({ id, userId: user.userId, ...patch });
|
||||
const saved = await updateBookmark(token, { id, ...patch });
|
||||
setBookmarks((prev) => prev.map((b) => (b.id === saved.id ? saved : b)));
|
||||
} catch (err) {
|
||||
console.error("แก้ไข bookmark ไม่สำเร็จ", err);
|
||||
|
|
@ -2636,11 +2645,11 @@ export default function PDFViewer({
|
|||
}
|
||||
|
||||
async function handleDeleteBookmark(id: string) {
|
||||
if (!user) return;
|
||||
|
||||
await startTransition(async () => {
|
||||
applyBookmark({ kind: "remove", id });
|
||||
try {
|
||||
await deleteBookmark(id, user.userId);
|
||||
await deleteBookmark(token, id);
|
||||
setBookmarks((prev) => prev.filter((b) => b.id !== id));
|
||||
} catch (err) {
|
||||
console.error("ลบ bookmark ไม่สำเร็จ", err);
|
||||
|
|
@ -3283,6 +3292,7 @@ export default function PDFViewer({
|
|||
bookmarks={optimisticBookmarks}
|
||||
fileId={file.id}
|
||||
userId={user?.userId ?? ""}
|
||||
token={token}
|
||||
userFilter={userFilter}
|
||||
users={users}
|
||||
onUserFilterChange={setUserFilter}
|
||||
|
|
@ -4483,7 +4493,7 @@ export default function PDFViewer({
|
|||
void handleDeleteReply(id);
|
||||
}}
|
||||
onStrokeEnd={(points, widths) => {
|
||||
if (!user) return;
|
||||
|
||||
void handleCreateAnnotation(i, "DRAW", {
|
||||
strokeColor: drawStyle.color,
|
||||
strokeWidth: drawStyle.strokeWidthPct,
|
||||
|
|
@ -4494,11 +4504,11 @@ export default function PDFViewer({
|
|||
});
|
||||
}}
|
||||
onShapeEnd={(data) => {
|
||||
if (!user) return;
|
||||
|
||||
void handleCreateAnnotation(i, "SHAPE", data);
|
||||
}}
|
||||
onRedactionEnd={(data) => {
|
||||
if (!user) return;
|
||||
|
||||
void handleCreateAnnotation(i, "REDACTION", data);
|
||||
}}
|
||||
onTextLayerRendered={handleTextLayerRendered}
|
||||
|
|
|
|||
|
|
@ -285,6 +285,7 @@ export default function AnnotationSidebar({
|
|||
bookmarks,
|
||||
fileId,
|
||||
userId,
|
||||
token,
|
||||
userFilter,
|
||||
users,
|
||||
onUserFilterChange,
|
||||
|
|
@ -303,6 +304,9 @@ export default function AnnotationSidebar({
|
|||
/** ไฟล์ที่เปิดอยู่ — ใช้ค้นหาหมายเหตุทั้งไฟล์ (F14) */
|
||||
fileId: string;
|
||||
userId: string;
|
||||
/** access_token ของ session — ส่งเข้าทุก action ที่แผงนี้เรียกเอง (ค้นหา/ประวัติ/
|
||||
* กู้คืน · ฉบับ token 2026-10-30 identity แกะฝั่ง server) */
|
||||
token: string;
|
||||
/** F4: "all" | "mine" | <userId> — กรองฝั่ง client ล้วน */
|
||||
userFilter: string;
|
||||
users: { userId: string; userName: string }[];
|
||||
|
|
@ -363,7 +367,7 @@ export default function AnnotationSidebar({
|
|||
if (!q || !fileId) return;
|
||||
setSearching(true);
|
||||
try {
|
||||
setSearchResults(await searchAnnotations(fileId, q));
|
||||
setSearchResults(await searchAnnotations(token, fileId, q));
|
||||
} catch (err) {
|
||||
console.error("ค้นหาไม่สำเร็จ", err);
|
||||
setSearchResults([]);
|
||||
|
|
@ -394,7 +398,7 @@ export default function AnnotationSidebar({
|
|||
setVersions(null);
|
||||
setHistoryError(null);
|
||||
try {
|
||||
setVersions(await listVersions(a.id));
|
||||
setVersions(await listVersions(token, a.id));
|
||||
} catch (err) {
|
||||
setHistoryError(err instanceof Error ? err.message : "โหลดประวัติไม่สำเร็จ");
|
||||
}
|
||||
|
|
@ -405,7 +409,7 @@ export default function AnnotationSidebar({
|
|||
async function handleRestore(versionId: string) {
|
||||
setRestoringId(versionId);
|
||||
try {
|
||||
await restoreVersion(versionId, userId);
|
||||
await restoreVersion(token, versionId);
|
||||
setHistoryFor(null);
|
||||
} catch (err) {
|
||||
setHistoryError(err instanceof Error ? err.message : "กู้คืนไม่สำเร็จ");
|
||||
|
|
@ -698,7 +702,7 @@ export default function AnnotationSidebar({
|
|||
try {
|
||||
const q = bmQuery.trim() || undefined;
|
||||
setBmResults(
|
||||
await searchBookmarks({
|
||||
await searchBookmarks(token, {
|
||||
fileId,
|
||||
q,
|
||||
pageFrom,
|
||||
|
|
|
|||
|
|
@ -52,13 +52,13 @@ export default function DependentDropdown({
|
|||
<div className="space-y-4 w-full">
|
||||
{/* Dropdown ที่ 1 */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-sm font-medium text-gray-700">{label1}</label>
|
||||
<label className="text-sm font-medium text-ink">{label1}</label>
|
||||
<select
|
||||
name={name1} // ใส่ name ตรงนี้เพื่อให้ Server Action ดึงค่าได้
|
||||
value={mainSelection}
|
||||
onChange={handleMainChange}
|
||||
required
|
||||
className="w-full p-2 border border-gray-300 rounded-md bg-white text-black text-sm"
|
||||
className="genesis-input w-full"
|
||||
>
|
||||
<option value="">{placeholder1}</option>
|
||||
{Object.keys(data).map((key) => (
|
||||
|
|
@ -69,14 +69,14 @@ export default function DependentDropdown({
|
|||
|
||||
{/* Dropdown ที่ 2 */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-sm font-medium text-gray-700">{label2}</label>
|
||||
<label className="text-sm font-medium text-ink">{label2}</label>
|
||||
<select
|
||||
name={name2} // ใส่ name ตรงนี้เพื่อให้ Server Action ดึงค่าได้
|
||||
value={subSelection}
|
||||
onChange={handleSubChange}
|
||||
disabled={!mainSelection}
|
||||
required
|
||||
className="w-full p-2 border border-gray-300 rounded-md bg-white disabled:bg-gray-100 text-black text-sm"
|
||||
className="genesis-input w-full disabled:bg-canvas disabled:text-ink-faint"
|
||||
>
|
||||
<option value="">
|
||||
{mainSelection ? '-- กรุณาเลือก --' : placeholder2}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue