eCourt-note/components/MockStageForm.tsx

231 lines
10 KiB
TypeScript
Raw Normal View History

feat(n11): add base64 document staging from e-Court เพิ่มทางเข้าเอกสารสำหรับ e-Court ที่ไม่มี URL ให้ดึง — POST /api/note/files/stage (x-api-key DOCVER_API_KEY) ส่งเนื้อไฟล์ PDF/รูปเป็น base64 ระบบเก็บใน RAM staging (TTL sliding 8 ชม. + LRU + cap 100MB/ไฟล์ 256MB รวม — ห้ามเขียน disk/DB/MinIO แม้แต่ไบต์เดียวตามโจทย์) ส่วน annotation/bookmark เก็บถาวรตามระบบเดิม: - lib/staged-docs.ts: Map บน globalThis + sentinel "staged:<uuid>.<ext>" ใน pdf_url (ext จาก magic bytes — ไม่ต้อง migration) + hasStagedDoc แยกจาก getStagedDoc - lib/ecourt-fid.ts: external_id เก็บ HMAC-SHA256(pepper, fid) แทน fid ดิบ (env FID_HASH_PEPPER fail-closed) + แปลง row เก่าด้วย npm run migrate:ecourt-fid - route stage: อ่าน body แบบ stream นับไบต์ + idle 60 วิ/total 10 นาที = 408 + concurrency default 1 = 429 + base64 strict + magic bytes กำหนด ext เอง - registerStagedFile: ลำดับกัน race 23505 — stage ใต้ id ก่อน insert โดนชนแล้ว re-stage ใต้ id ผู้ชนะ + try/finally คืน RAM ผู้แพ้ทุกทางออก - ทางอ่าน 4 จุด: /api/note/pdf + export route (410 gate ไทย), resolvePublicPdfPath guard PdfPathError, PDFViewer โหลดผ่าน route + objectURL (gate ครบทุกชนิดไฟล์) - session route (D5): ลิงก์ fid-only ตัดสิน staging ตั้งแต่แลก token — มี bytes ได้ fileId / หมดอายุ fileError "พ้นช่วงจัดเตรียม" / token ออกทุกกรณี - Swagger UI: endpoint ใหม่ + คู่มือ retry (408/429/5xx ยิงซ้ำได้ stage ซ้ำปลอดภัย) - MockStageForm บน /note/dev ทดสอบ 3 ขั้นในหน้าเดียว (ต้องถือ API key จริง) Verify ครบตาม docs/PLAN-STAGED.md: tsc สะอาด + vitest 59 เคสใหม่ผ่าน + E2E browser จริง (textLayer ไทยเรียงถูก / export ฝัง annotation / restart ได้ fileError ตั้งแต่ bootstrap / RAM 100MB: peak WS +580MB commit จริง +92MB) — ผ่าน code review รอบ 7 แก้ครบทุก finding Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-11-05 12:53:56 +07:00
"use client";
import { useState, type ChangeEvent, type FormEvent } from "react";
import { withBasePath } from "@/utils/base-path";
/**
* ฟอร์ม "จำลอง e-Court — ส่งเนื้อไฟล์เข้า staging" บนหน้า /note/dev (N11 —
* PLAN-STAGED.md · คำขอผู้ใช้ 2026-11-04): กดทดสอบ contract ทั้ง 3 ขั้นตามที่
* backend e-Court ต้องทำจริงในหน้าเดียว — (1) POST /api/note/files/stage
refactor(auth): consolidate auth under /api/auth/* endpoints - ยุบ route auth ที่เคย copy กันจน drift (flow N11 หายจาก /api/auth/session) เหลือโค้ด inline สองเส้นเดียว: POST /api/auth/token (ทางเข้า 3: x-api-key docver / x-api-key purpose:"login" mint ลิงก์ note / ticket ของ add-in) + POST /api/auth/session (แลก credential + register N8 + N11 fid-only) - POST /api/documents/token คงไว้เป็น alias re-export (กลุ่ม docver/e-Court เดิม — contract เดิมเป๊ะ, มี test toBe กัน copy แยกชุดกลับมา) - ถอดเส้นฝั่ง note ทั้งสอบ: /api/note/auth/token + /api/note/auth/session (e-Court ต้องย้ายมาทางเข้ารวม — แจ้งทีม e-Court ก่อน deploy) - utils/auth-store.ts: primitives กลางฝั่ง client (jwtExp/isExpired/ localStorage store + memory fallback) — lib/note-session ใช้ร่วม (expMs decode จาก JWT payload = นาฬิกา server) และวางไว้ให้ wordaddin ย้ายมาใช้รอบหน้าได้ (รอบนี้ไม่แต้ wordaddin ตามข้อตกลง) - นโยบาย token หมดอายุกลาง session: PDFViewer reportActionError จับ auth-error ของ Server Action ทุกจุด → ล้าง session แล้วพาไป /note?sso=expired (จบที่ gate เดียวกับ bootstrap 401) - openapi: ทางเข้า purpose:"login" + schema LoginTokenRequest + ลบ path เส้นที่ถอด + test กันเส้นตายหลุดค้างใน spec - เอกสาร sync: PLAN-SSO §0.6, CLAUDE.md, PLAN-STAGED, .env.example, /note/dev - tests: ย้าย/เพิ่มชุด auth (50 เคสกลุ่ม auth ผ่าน, ทั้งชุด 444 ผ่าน) + e2e ครบทั้ง API (mint/alias/ticket/404 เส้นเก่า/N11/D4) และ browser Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-11-09 14:30:29 +07:00
* (base64 ของไฟล์ที่เลือก) → (2) POST /api/auth/token แบบ purpose:"login"
* ด้วย fid เดิม (ทางเข้ารวม 2026-11-09) →
feat(n11): add base64 document staging from e-Court เพิ่มทางเข้าเอกสารสำหรับ e-Court ที่ไม่มี URL ให้ดึง — POST /api/note/files/stage (x-api-key DOCVER_API_KEY) ส่งเนื้อไฟล์ PDF/รูปเป็น base64 ระบบเก็บใน RAM staging (TTL sliding 8 ชม. + LRU + cap 100MB/ไฟล์ 256MB รวม — ห้ามเขียน disk/DB/MinIO แม้แต่ไบต์เดียวตามโจทย์) ส่วน annotation/bookmark เก็บถาวรตามระบบเดิม: - lib/staged-docs.ts: Map บน globalThis + sentinel "staged:<uuid>.<ext>" ใน pdf_url (ext จาก magic bytes — ไม่ต้อง migration) + hasStagedDoc แยกจาก getStagedDoc - lib/ecourt-fid.ts: external_id เก็บ HMAC-SHA256(pepper, fid) แทน fid ดิบ (env FID_HASH_PEPPER fail-closed) + แปลง row เก่าด้วย npm run migrate:ecourt-fid - route stage: อ่าน body แบบ stream นับไบต์ + idle 60 วิ/total 10 นาที = 408 + concurrency default 1 = 429 + base64 strict + magic bytes กำหนด ext เอง - registerStagedFile: ลำดับกัน race 23505 — stage ใต้ id ก่อน insert โดนชนแล้ว re-stage ใต้ id ผู้ชนะ + try/finally คืน RAM ผู้แพ้ทุกทางออก - ทางอ่าน 4 จุด: /api/note/pdf + export route (410 gate ไทย), resolvePublicPdfPath guard PdfPathError, PDFViewer โหลดผ่าน route + objectURL (gate ครบทุกชนิดไฟล์) - session route (D5): ลิงก์ fid-only ตัดสิน staging ตั้งแต่แลก token — มี bytes ได้ fileId / หมดอายุ fileError "พ้นช่วงจัดเตรียม" / token ออกทุกกรณี - Swagger UI: endpoint ใหม่ + คู่มือ retry (408/429/5xx ยิงซ้ำได้ stage ซ้ำปลอดภัย) - MockStageForm บน /note/dev ทดสอบ 3 ขั้นในหน้าเดียว (ต้องถือ API key จริง) Verify ครบตาม docs/PLAN-STAGED.md: tsc สะอาด + vitest 59 เคสใหม่ผ่าน + E2E browser จริง (textLayer ไทยเรียงถูก / export ฝัง annotation / restart ได้ fileError ตั้งแต่ bootstrap / RAM 100MB: peak WS +580MB commit จริง +92MB) — ผ่าน code review รอบ 7 แก้ครบทุก finding Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-11-05 12:53:56 +07:00
* (3) ลิงก์ /note?t=… (fid-only ไม่มี fileUrl — D5)
* · ต้องใส่ DOCVER_API_KEY จริงทุกครั้งจึงไม่เป็นประตูหลัง (เหมือน MockEcourtForm)
* · หน้า /note/dev เปิดด้วย env NOTE_SSO_DEV_MINT=1 อย่างเดียว
* · ไม่ใช่ dropzone ของแอป (การตัดสินใจข้อ 3 ของแผนยังอยู่ครบ — เครื่องมือ dev
* สำหรับผู้ถือ key เท่านั้น)
*/
export default function MockStageForm() {
const [apiKey, setApiKey] = useState("");
const [fid, setFid] = useState("STAGE-1");
const [userid, setUserid] = useState("oom");
const [username, setUsername] = useState("สรวิชญ์");
const [title, setTitle] = useState("");
const [typeId, setTypeId] = useState("");
const [file, setFile] = useState<File | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<{ fileId: string; link: string } | null>(null);
const onFileChange = (e: ChangeEvent<HTMLInputElement>) => {
setFile(e.target.files?.[0] ?? null);
setResult(null);
};
const onSubmit = async (e: FormEvent) => {
e.preventDefault();
setError(null);
setResult(null);
if (file === null) {
setError("เลือกไฟล์เอกสารก่อน (.pdf / .jpg / .png / .tif)");
return;
}
if (file.size > 100 * 1024 * 1024) {
setError(`ไฟล์ ${file.name} ใหญ่เกิน 100MB (เนื้อไฟล์) — endpoint จะตอบ 413`);
return;
}
setBusy(true);
try {
// base64 มาตรฐานต่อเนื่อง — readAsDataURL ได้ base64 ต่อเนื่องพร้อม padding
// อยู่แล้ว (ตัด prefix data:…;base64,) และไฟล์ใหญ่ก็ไม่ชน call stack แบบ btoa
const b64 = await readFileAsBase64(file);
// (1) stage เนื้อไฟล์ — ขั้นที่ e-Court ต้องทำก่อนแสดงปุ่มเปิดเอกสาร
const stageRes = await fetch(withBasePath("/api/note/files/stage"), {
method: "POST",
headers: { "x-api-key": apiKey, "content-type": "application/json" },
body: JSON.stringify({
fid: fid.trim(),
data: b64,
...(title.trim() !== "" ? { title: title.trim() } : {}),
...(typeId.trim() !== "" ? { typeId: typeId.trim() } : {}),
}),
});
const stageData = (await stageRes.json().catch(() => null)) as
| { fileId?: string; error?: string }
| null;
if (!stageRes.ok || stageData?.fileId === undefined) {
throw new Error(
`stage ไม่สำเร็จ (HTTP ${stageRes.status}) — ${stageData?.error ?? "ไม่ทราบสาเหตุ"}`,
);
}
// (2) mint login token ด้วย fid **เดิม** — dedupe key ecourt:<fid> ผูกกัน
refactor(auth): consolidate auth under /api/auth/* endpoints - ยุบ route auth ที่เคย copy กันจน drift (flow N11 หายจาก /api/auth/session) เหลือโค้ด inline สองเส้นเดียว: POST /api/auth/token (ทางเข้า 3: x-api-key docver / x-api-key purpose:"login" mint ลิงก์ note / ticket ของ add-in) + POST /api/auth/session (แลก credential + register N8 + N11 fid-only) - POST /api/documents/token คงไว้เป็น alias re-export (กลุ่ม docver/e-Court เดิม — contract เดิมเป๊ะ, มี test toBe กัน copy แยกชุดกลับมา) - ถอดเส้นฝั่ง note ทั้งสอบ: /api/note/auth/token + /api/note/auth/session (e-Court ต้องย้ายมาทางเข้ารวม — แจ้งทีม e-Court ก่อน deploy) - utils/auth-store.ts: primitives กลางฝั่ง client (jwtExp/isExpired/ localStorage store + memory fallback) — lib/note-session ใช้ร่วม (expMs decode จาก JWT payload = นาฬิกา server) และวางไว้ให้ wordaddin ย้ายมาใช้รอบหน้าได้ (รอบนี้ไม่แต้ wordaddin ตามข้อตกลง) - นโยบาย token หมดอายุกลาง session: PDFViewer reportActionError จับ auth-error ของ Server Action ทุกจุด → ล้าง session แล้วพาไป /note?sso=expired (จบที่ gate เดียวกับ bootstrap 401) - openapi: ทางเข้า purpose:"login" + schema LoginTokenRequest + ลบ path เส้นที่ถอด + test กันเส้นตายหลุดค้างใน spec - เอกสาร sync: PLAN-SSO §0.6, CLAUDE.md, PLAN-STAGED, .env.example, /note/dev - tests: ย้าย/เพิ่มชุด auth (50 เคสกลุ่ม auth ผ่าน, ทั้งชุด 444 ผ่าน) + e2e ครบทั้ง API (mint/alias/ticket/404 เส้นเก่า/N11/D4) และ browser Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-11-09 14:30:29 +07:00
// (purpose:"login" = ทางเข้า mint login-token ของ /api/auth/token)
const tokenRes = await fetch(withBasePath("/api/auth/token"), {
feat(n11): add base64 document staging from e-Court เพิ่มทางเข้าเอกสารสำหรับ e-Court ที่ไม่มี URL ให้ดึง — POST /api/note/files/stage (x-api-key DOCVER_API_KEY) ส่งเนื้อไฟล์ PDF/รูปเป็น base64 ระบบเก็บใน RAM staging (TTL sliding 8 ชม. + LRU + cap 100MB/ไฟล์ 256MB รวม — ห้ามเขียน disk/DB/MinIO แม้แต่ไบต์เดียวตามโจทย์) ส่วน annotation/bookmark เก็บถาวรตามระบบเดิม: - lib/staged-docs.ts: Map บน globalThis + sentinel "staged:<uuid>.<ext>" ใน pdf_url (ext จาก magic bytes — ไม่ต้อง migration) + hasStagedDoc แยกจาก getStagedDoc - lib/ecourt-fid.ts: external_id เก็บ HMAC-SHA256(pepper, fid) แทน fid ดิบ (env FID_HASH_PEPPER fail-closed) + แปลง row เก่าด้วย npm run migrate:ecourt-fid - route stage: อ่าน body แบบ stream นับไบต์ + idle 60 วิ/total 10 นาที = 408 + concurrency default 1 = 429 + base64 strict + magic bytes กำหนด ext เอง - registerStagedFile: ลำดับกัน race 23505 — stage ใต้ id ก่อน insert โดนชนแล้ว re-stage ใต้ id ผู้ชนะ + try/finally คืน RAM ผู้แพ้ทุกทางออก - ทางอ่าน 4 จุด: /api/note/pdf + export route (410 gate ไทย), resolvePublicPdfPath guard PdfPathError, PDFViewer โหลดผ่าน route + objectURL (gate ครบทุกชนิดไฟล์) - session route (D5): ลิงก์ fid-only ตัดสิน staging ตั้งแต่แลก token — มี bytes ได้ fileId / หมดอายุ fileError "พ้นช่วงจัดเตรียม" / token ออกทุกกรณี - Swagger UI: endpoint ใหม่ + คู่มือ retry (408/429/5xx ยิงซ้ำได้ stage ซ้ำปลอดภัย) - MockStageForm บน /note/dev ทดสอบ 3 ขั้นในหน้าเดียว (ต้องถือ API key จริง) Verify ครบตาม docs/PLAN-STAGED.md: tsc สะอาด + vitest 59 เคสใหม่ผ่าน + E2E browser จริง (textLayer ไทยเรียงถูก / export ฝัง annotation / restart ได้ fileError ตั้งแต่ bootstrap / RAM 100MB: peak WS +580MB commit จริง +92MB) — ผ่าน code review รอบ 7 แก้ครบทุก finding Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-11-05 12:53:56 +07:00
method: "POST",
headers: { "x-api-key": apiKey, "content-type": "application/json" },
body: JSON.stringify({
userid: userid.trim(),
username: username.trim(),
fid: fid.trim(),
ttl_seconds: 600,
refactor(auth): consolidate auth under /api/auth/* endpoints - ยุบ route auth ที่เคย copy กันจน drift (flow N11 หายจาก /api/auth/session) เหลือโค้ด inline สองเส้นเดียว: POST /api/auth/token (ทางเข้า 3: x-api-key docver / x-api-key purpose:"login" mint ลิงก์ note / ticket ของ add-in) + POST /api/auth/session (แลก credential + register N8 + N11 fid-only) - POST /api/documents/token คงไว้เป็น alias re-export (กลุ่ม docver/e-Court เดิม — contract เดิมเป๊ะ, มี test toBe กัน copy แยกชุดกลับมา) - ถอดเส้นฝั่ง note ทั้งสอบ: /api/note/auth/token + /api/note/auth/session (e-Court ต้องย้ายมาทางเข้ารวม — แจ้งทีม e-Court ก่อน deploy) - utils/auth-store.ts: primitives กลางฝั่ง client (jwtExp/isExpired/ localStorage store + memory fallback) — lib/note-session ใช้ร่วม (expMs decode จาก JWT payload = นาฬิกา server) และวางไว้ให้ wordaddin ย้ายมาใช้รอบหน้าได้ (รอบนี้ไม่แต้ wordaddin ตามข้อตกลง) - นโยบาย token หมดอายุกลาง session: PDFViewer reportActionError จับ auth-error ของ Server Action ทุกจุด → ล้าง session แล้วพาไป /note?sso=expired (จบที่ gate เดียวกับ bootstrap 401) - openapi: ทางเข้า purpose:"login" + schema LoginTokenRequest + ลบ path เส้นที่ถอด + test กันเส้นตายหลุดค้างใน spec - เอกสาร sync: PLAN-SSO §0.6, CLAUDE.md, PLAN-STAGED, .env.example, /note/dev - tests: ย้าย/เพิ่มชุด auth (50 เคสกลุ่ม auth ผ่าน, ทั้งชุด 444 ผ่าน) + e2e ครบทั้ง API (mint/alias/ticket/404 เส้นเก่า/N11/D4) และ browser Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-11-09 14:30:29 +07:00
purpose: "login",
feat(n11): add base64 document staging from e-Court เพิ่มทางเข้าเอกสารสำหรับ e-Court ที่ไม่มี URL ให้ดึง — POST /api/note/files/stage (x-api-key DOCVER_API_KEY) ส่งเนื้อไฟล์ PDF/รูปเป็น base64 ระบบเก็บใน RAM staging (TTL sliding 8 ชม. + LRU + cap 100MB/ไฟล์ 256MB รวม — ห้ามเขียน disk/DB/MinIO แม้แต่ไบต์เดียวตามโจทย์) ส่วน annotation/bookmark เก็บถาวรตามระบบเดิม: - lib/staged-docs.ts: Map บน globalThis + sentinel "staged:<uuid>.<ext>" ใน pdf_url (ext จาก magic bytes — ไม่ต้อง migration) + hasStagedDoc แยกจาก getStagedDoc - lib/ecourt-fid.ts: external_id เก็บ HMAC-SHA256(pepper, fid) แทน fid ดิบ (env FID_HASH_PEPPER fail-closed) + แปลง row เก่าด้วย npm run migrate:ecourt-fid - route stage: อ่าน body แบบ stream นับไบต์ + idle 60 วิ/total 10 นาที = 408 + concurrency default 1 = 429 + base64 strict + magic bytes กำหนด ext เอง - registerStagedFile: ลำดับกัน race 23505 — stage ใต้ id ก่อน insert โดนชนแล้ว re-stage ใต้ id ผู้ชนะ + try/finally คืน RAM ผู้แพ้ทุกทางออก - ทางอ่าน 4 จุด: /api/note/pdf + export route (410 gate ไทย), resolvePublicPdfPath guard PdfPathError, PDFViewer โหลดผ่าน route + objectURL (gate ครบทุกชนิดไฟล์) - session route (D5): ลิงก์ fid-only ตัดสิน staging ตั้งแต่แลก token — มี bytes ได้ fileId / หมดอายุ fileError "พ้นช่วงจัดเตรียม" / token ออกทุกกรณี - Swagger UI: endpoint ใหม่ + คู่มือ retry (408/429/5xx ยิงซ้ำได้ stage ซ้ำปลอดภัย) - MockStageForm บน /note/dev ทดสอบ 3 ขั้นในหน้าเดียว (ต้องถือ API key จริง) Verify ครบตาม docs/PLAN-STAGED.md: tsc สะอาด + vitest 59 เคสใหม่ผ่าน + E2E browser จริง (textLayer ไทยเรียงถูก / export ฝัง annotation / restart ได้ fileError ตั้งแต่ bootstrap / RAM 100MB: peak WS +580MB commit จริง +92MB) — ผ่าน code review รอบ 7 แก้ครบทุก finding Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-11-05 12:53:56 +07:00
}),
});
const tokenData = (await tokenRes.json().catch(() => null)) as
| { access_token?: string; error?: string }
| null;
if (!tokenRes.ok || tokenData?.access_token === undefined) {
throw new Error(
`stage สำเร็จ แต่ขอ token ไม่สำเร็จ (HTTP ${tokenRes.status}) — ${tokenData?.error ?? "ไม่ทราบสาเหตุ"}`,
);
}
// (3) ลิงก์ fid-only (ไม่มี fileUrl — เอกสาร staging) · withBasePath("") =
// /docedit/note (D11 — ห้ามส่ง "/note" เข้า withBasePath)
setResult({
fileId: stageData.fileId,
link: `${withBasePath("")}?t=${encodeURIComponent(tokenData.access_token)}`,
});
} catch (err) {
setError(err instanceof Error ? err.message : "ทดสอบ staging ไม่สำเร็จ");
} 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 sm:col-span-2">
<label className={labelCls} htmlFor="stage-key">
API key (env DOCVER_API_KEY ของเครื่องนี้)
</label>
<input
id="stage-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="stage-fid">
fid (รหัสเอกสารของ e-Court — ตัวเดียวกันทั้ง stage และ mint)
</label>
<input
id="stage-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="stage-file">
ไฟล์เอกสาร (PDF/รูป ≤ 100MB — ส่งเป็น base64)
</label>
<input
id="stage-file"
type="file"
accept=".pdf,.jpg,.jpeg,.png,.tif,.tiff"
onChange={onFileChange}
className={`${inputCls} py-1.5`}
required
/>
</div>
<div className="flex flex-col gap-1.5">
<label className={labelCls} htmlFor="stage-uid">userid</label>
<input id="stage-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="stage-uname">username</label>
<input id="stage-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="stage-title">ชื่อแสดง (ทางเลือก)</label>
<input id="stage-title" value={title} onChange={(e) => setTitle(e.target.value)} className={inputCls} placeholder={file?.name ?? "เอกสารจาก e-Court"} />
</div>
<div className="flex flex-col gap-1.5">
<label className={labelCls} htmlFor="stage-type">กลุ่ม typeId (ทางเลือก)</label>
<input id="stage-type" value={typeId} onChange={(e) => setTypeId(e.target.value)} className={inputCls} />
</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 ? "กำลังส่ง…" : "stage + สร้างลิงก์ทดสอบ"}
</button>
{file !== null && (
<span className="ml-3 text-xs text-ink-faint">
{file.name} · {(file.size / 1024 / 1024).toFixed(2)} MB (base64 ~
{((file.size * 1.34) / 1024 / 1024).toFixed(0)} MB)
</span>
)}
</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>
)}
{result && (
<div className="sm:col-span-2 rounded-lg border border-success/30 bg-success/5 p-3 text-sm">
<p className="text-success-ink">
stage สำเร็จ — fileId <code className="rounded bg-ink/[0.05] px-1 py-0.5 text-[13px] text-ink">{result.fileId}</code>{" "}
(ลิงก์อายุ 10 นาที · เอกสารอยู่ใน RAM ตาม TTL หลังแลก session)
</p>
<div className="mt-2 flex flex-wrap items-center gap-3">
<a
href={result.link}
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"
>
เปิดลิงก์
</a>
<span className="break-all text-xs text-ink-soft">{result.link.slice(0, 80)}…</span>
</div>
</div>
)}
</form>
);
}
/** base64 ต่อเนื่องของไฟล์ทั้งก้อน */
function readFileAsBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const result = String(reader.result ?? "");
const comma = result.indexOf(",");
if (!result.startsWith("data:") || comma < 0) {
reject(new Error("อ่านไฟล์ไม่สำเร็จ"));
return;
}
resolve(result.slice(comma + 1));
};
reader.onerror = () => reject(new Error("อ่านไฟล์ไม่สำเร็จ"));
reader.readAsDataURL(file);
});
}