ecourt+groups: N8 เปิดเอกสารจากลิงก์ ?fileUrl=&fileId=&typeId= (metadata-only + allowlist + ดึงไฟล์สด + login วนกลับ + การ์ดจำลองลิงก์ + path dev /samples) + N9 กลุ่มเอกสารตาม typeId — หน้ารายการจัด section ต่อกลุ่ม + "ไม่มีกลุ่ม" ท้ายสุด + หน้า /groups/[typeId] พร้อมหมายเหตุ "อาจไม่ครบทุกฉบับ" + docs sync ครบ (2026-09-23)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
Your Name 2026-09-23 10:44:14 +07:00
parent 0aef3ff028
commit 329ae8e956
31 changed files with 2766 additions and 155 deletions

View file

@ -0,0 +1,131 @@
"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 ไปเปิด/ส่งต่อได้) ตามที่พิมพ์สด ๆ
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { Link2 } from "lucide-react";
import { withBasePath } from "@/utils/base-path";
const inputClass =
"rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-700 placeholder:text-slate-400 focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20";
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(`/annotate?${queryString}`);
};
return (
<form
onSubmit={onSubmit}
className="mt-8 rounded-xl border border-dashed border-slate-300 bg-white/60 p-4"
>
<div className="flex items-center gap-2">
<Link2 className="h-4 w-4 shrink-0 text-primary" />
<p className="text-sm font-medium text-slate-700">เปิดเอกสารจากลิงก์ e-Court</p>
</div>
<div className="mt-3 grid gap-x-2 gap-y-3 sm:grid-cols-2">
<div className="sm:col-span-2">
<label
htmlFor="ecourt-file-url"
className="block text-xs font-medium text-slate-600"
>
ลิงก์ไฟล์
</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} mt-1 w-full`}
/>
</div>
<div>
<label
htmlFor="ecourt-file-id"
className="block text-xs font-medium text-slate-600"
>
รหัสเอกสาร fileId
</label>
<input
type="text"
id="ecourt-file-id"
name="fileId"
value={fileId}
onChange={(e) => setFileId(e.target.value)}
className={`${inputClass} mt-1 w-full`}
/>
</div>
<div>
<label
htmlFor="ecourt-type-id"
className="block text-xs font-medium text-slate-600"
>
กลุ่ม typeId
</label>
<input
type="text"
id="ecourt-type-id"
name="typeId"
value={typeId}
onChange={(e) => setTypeId(e.target.value)}
placeholder="เช่น 1"
className={`${inputClass} mt-1 w-full`}
/>
</div>
</div>
<button
type="submit"
className="mt-3 w-full rounded-md bg-primary px-4 py-2.5 text-sm font-medium text-white transition hover:opacity-90"
>
เปิดเอกสาร
</button>
<p className="mt-2 text-xs text-slate-500">
กดแล้วระบบลงทะเบียนเอกสาร (ถ้ายังไม่มี) แล้วพาเข้าหน้าอ่าน/มาร์กเอกสารทันที —
ไฟล์รีโมตรับเฉพาะ https ของโฮสต์ที่อนุญาต
</p>
<p className="mt-2 break-all rounded-md bg-slate-50 px-3 py-2 text-xs text-slate-500">
<span className="font-medium text-slate-600">ลิงก์เต็ม: </span>
{fullLink || "…"}
</p>
{error && <p className="mt-1 text-xs text-red-600">{error}</p>}
</form>
);
}

View file

@ -1,7 +1,8 @@
"use client";
// ฟอร์ม login (mock e-Court) — เรียก server action login แล้ว router.replace("/")
// ฟอร์ม login (mock e-Court) — เรียก server action login แล้ว router.replace(next ?? "/")
// (pattern ตาม UploadFileForm: useTransition + error state + ข้อความ fallback ตอน transport fail)
// next = path วนกลับหลัง login (N8 D5 — ผ่าน sanitizeNextPath จากหน้า login มาแล้ว)
import { useRef, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
@ -16,7 +17,7 @@ const INPUT_CLASS =
const SELECT_CLASS =
"block w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-base text-slate-900 transition focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/30 disabled:cursor-not-allowed disabled:bg-slate-50";
export default function LoginForm() {
export default function LoginForm({ next }: { next: string | null }) {
const router = useRouter();
const passwordRef = useRef<HTMLInputElement | null>(null);
const [error, setError] = useState<string | null>(null);
@ -32,7 +33,7 @@ export default function LoginForm() {
try {
const result = await login(usernameInput.value, passwordInput.value);
if (result.ok) {
router.replace("/");
router.replace(next ?? "/");
} else {
setError(result.error);
// คง username ไว้ (โอกาสพลาดอยู่ที่ password) — ล้างแล้วโฟกัสให้พิมพ์ทับได้ทันที

View file

@ -119,6 +119,7 @@ import {
MAX_BOOKMARK_TITLE_CHARS,
} from "@/utils/bookmark-limits";
import { fileKindFromUrl, imageFormatFromUrl } from "@/utils/file-kind";
import { isHttpUrl } from "@/utils/external-file";
import { withBasePath } from "@/utils/base-path";
import { formatThaiDate } from "@/utils/date-format";
import { decodeTiffPages } from "@/utils/tiff-client";
@ -1559,7 +1560,11 @@ export default function PDFViewer({
setTool((prev) => (prev === "highlight" ? "select" : prev));
const objectUrls: string[] = [];
// URL จริงที่ browser ใช้โหลดไฟล์ (fetch/img.src ตรง — ต้องเติม basePath เอง)
const fileUrl = withBasePath(file.pdfUrl);
// N8: รูปรีโมต (ลิงก์ e-Court) โหลดผ่าน /api/pdf ของเราแทนทางตรง — กัน CORS
// ของปลายทาง + ผ่าน allowlist/จำกัดขนาดเดียวกับ PDF (route คืน bytes รูป + content-type)
const fileUrl = isHttpUrl(file.pdfUrl)
? withBasePath(`/api/pdf?fileId=${file.id}`)
: withBasePath(file.pdfUrl);
(async () => {
let pages: { src: string | null; width: number; height: number }[];
if (imageFormatFromUrl(file.pdfUrl) === "tiff") {