refactor(routes): move routes under /note, APIs under /api/note
All checks were successful
Deploy eCourt Note Web to Server / Validate Database Schema (Drizzle) (push) Successful in 3s
Deploy eCourt Note Web to Server / Build Web Docker Image (push) Successful in 52s
Deploy eCourt Note Web to Server / Deploy eCourt Note Web to Server (push) Successful in 13s
Deploy eCourt Note Web to Server / Notify Deployment Status (push) Successful in 1s
All checks were successful
Deploy eCourt Note Web to Server / Validate Database Schema (Drizzle) (push) Successful in 3s
Deploy eCourt Note Web to Server / Build Web Docker Image (push) Successful in 52s
Deploy eCourt Note Web to Server / Deploy eCourt Note Web to Server (push) Successful in 13s
Deploy eCourt Note Web to Server / Notify Deployment Status (push) Successful in 1s
เตรียม basePath /docedit เป็นพื้นที่กลางให้แอปพี่น้อง (wordaddin ใน อนาคต) — เดิม basePath ครอบ /docedit/note ทั้งหมดจนแชร์ prefix ไม่ได้ (Next มี basePath ได้ทางเดียว) และจัด API แยกต้นไม้ตามที่ผู้ใช้ขอ (1) โครงสร้างใหม่: basePath = /docedit + route หน้าทั้งหมดย้ายเข้า app/note/** + API 4 เส้นย้ายเข้า app/api/note/** + statics ย้ายเข้า public/note/** (samples, uploads, pdf.worker) — หน้า/statics URL ปลายทาง คงเดิมเป๊ะ (/docedit/note/…) ส่วน URL API เปลี่ยนเป็น /docedit/api/note/… ตามคำขอผู้ใช้ (N10 endpoint เปลี่ยน — ต้องแจ้ง backend e-Court ก่อนใช้) (2) กลไก: withBasePath (utils/base-path.ts) เติม base + app path /note ยกเว้น path ที่ขึ้นต้น /api/ เติมแค่ base (caller เขียน /api/note/… เต็ม); resolvePublicPdfPath root ที่ public/note (traversal guard เดิม); middleware matcher ยกเว้นใหม่ (note/login, api/note, note/uploads, note/samples, note/pdf.worker, _next, fonts, favicon) + redirect /note/login; next.config เพิ่ม redirects /docedit -> /docedit/note; navigation ภายใน 19 จุด + revalidatePath 3 จุด ใส่ prefix /note; pdf_url ใน DB เป็น logical path ไม่แก้แม้แต่ตัวเดียว (seed/upload เขียนค่าเดิม, browser เติมผ่าน withBasePath, disk เติมผ่าน guard) (3) ตามแก้: patch-pdf-worker (DEST public/note + mkdirSync ปลายทาง), seed-samples (SAMPLES_DIR), uploadFile (UPLOAD_DIR), Dockerfile/ .dockerignore/.gitignore/docker-compose.test.yml, tests 2 ไฟล์ที่อ้าง disk path จริง, sync เอกสารทั้งหมด (CLAUDE.md invariant #6/#12 + tree, README, docs/API-DOCUMENTS.md, .claude/rules/*, sync-db skill) (4) !! ตอน deploy ต้องย้าย volume ตาม: mount /app/public/uploads -> /app/public/note/uploads + ย้ายไฟล์ใน volume ให้ตรงตำแหน่ง ไม่งั้นไฟล์ ที่เคยอัปโหลด (พร้อม annotation ที่ผูกอยู่) 404 ทันทีที่สลับ image (5) verify: build ผ่าน (route table /api/note/** + /note/** ครบ), tsc, vitest 215/215, curl ครบทุก endpoint (login/redirect chain, worker, samples, uploads, SSE, api/pdf, export, N8 register, N10 bundle + deep link), Playwright browser จริง (canvas + textLayer ไทย render, browser ยิง URL ใหม่ทั้งหมดไม่มี request ไป URL เก่า), plan-reviewer + code-reviewer 3 รายงาน 0 blocking Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
parent
b123ecc9b2
commit
8cc15ea75d
52 changed files with 174 additions and 148 deletions
74
app/note/annotate/[id]/page.tsx
Normal file
74
app/note/annotate/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import { asc, eq, inArray } from "drizzle-orm";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
|
||||
import { db } from "@/db";
|
||||
import {
|
||||
annotationReplies,
|
||||
annotations,
|
||||
bookmarks,
|
||||
files,
|
||||
toAnnotationDTO,
|
||||
toBookmarkDTO,
|
||||
toReplyDTO,
|
||||
} from "@/db/schema";
|
||||
import PDFViewer from "@/components/pdf/PDFViewer";
|
||||
import DebugConsole from "@/components/DebugConsole"; // TEMP-DEBUG (ลบพร้อมไฟล์)
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
|
||||
const UUID_RE =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function AnnotatePage({
|
||||
params,
|
||||
}: {
|
||||
params: { id: string };
|
||||
}) {
|
||||
const { id } = params;
|
||||
if (!UUID_RE.test(id)) notFound();
|
||||
|
||||
// defense ซ้อน middleware — identity จาก cookie oacCase ส่งเข้า viewer ตั้งแต่ first paint
|
||||
const user = getSessionUser();
|
||||
if (!user) redirect("/note/login");
|
||||
|
||||
const [file] = await db.select().from(files).where(eq(files.id, id));
|
||||
if (!file) notFound();
|
||||
|
||||
const [annotationRows, bookmarkRows] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(annotations)
|
||||
.where(eq(annotations.fileId, id))
|
||||
.orderBy(asc(annotations.createdAt)),
|
||||
db
|
||||
.select()
|
||||
.from(bookmarks)
|
||||
.where(eq(bookmarks.fileId, id))
|
||||
.orderBy(asc(bookmarks.createdAt)),
|
||||
]);
|
||||
|
||||
// replies ของทุก comment ในไฟล์นี้ (F9) — แม่ถูก CASCADE ไปแล้ว row ก็หายตาม
|
||||
const annotationIds = annotationRows.map((r) => r.id);
|
||||
const replyRows = annotationIds.length
|
||||
? await db
|
||||
.select()
|
||||
.from(annotationReplies)
|
||||
.where(inArray(annotationReplies.annotationId, annotationIds))
|
||||
.orderBy(asc(annotationReplies.createdAt))
|
||||
: [];
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* TEMP-DEBUG: อ่าน log บนจอ iPad ผ่าน Eruda เมื่อเปิดหน้าพร้อม ?debug */}
|
||||
<DebugConsole />
|
||||
<PDFViewer
|
||||
file={{ id: file.id, title: file.title, pdfUrl: file.pdfUrl }}
|
||||
initialAnnotations={annotationRows.map(toAnnotationDTO)}
|
||||
initialBookmarks={bookmarkRows.map(toBookmarkDTO)}
|
||||
initialReplies={replyRows.map(toReplyDTO)}
|
||||
user={user}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
168
app/note/annotate/[id]/summary/page.tsx
Normal file
168
app/note/annotate/[id]/summary/page.tsx
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { asc, eq } from "drizzle-orm";
|
||||
import { notFound } from "next/navigation";
|
||||
import { ArrowLeft, ClipboardList } from "lucide-react";
|
||||
|
||||
import type { ExplorerItem } from "@/components/explorer/FacetFilterPanel";
|
||||
import { SummaryExplorer } from "@/components/summary/SummaryExplorer";
|
||||
import { db } from "@/db";
|
||||
import {
|
||||
annotations,
|
||||
bookmarks,
|
||||
files,
|
||||
toAnnotationDTO,
|
||||
toBookmarkDTO,
|
||||
} from "@/db/schema";
|
||||
|
||||
// หน้าสรุปหมายเหตุ (F15/D2) — server component รวม annotation (ทุกผู้ใช้) + bookmark
|
||||
// แต่ละแถวกระโดดเป็น deep link `/note/annotate/<id>?ann=` / `?page=` (F12)
|
||||
// — design "แฟ้มสำนวน" 2026-09-17 (แถบหัวน้ำเงิน + การ์ดรายหน้าขอบ folio +
|
||||
// รายการตามหน้าจุดไข่ปลา) → redesign 2026-09-24 ตามคำขอผู้ใช้เป็น explorer
|
||||
// 2 คอลัมน์แบบหน้ากลุ่ม N9: ตัวกรองฝั่งซ้าย (หน้า/ชนิดรายการ/ชนิดของ
|
||||
// Annotation/ผู้บันทึก) + การ์ดรายหน้าหัว sticky — แถบหัวน้ำเงิน + action bar +
|
||||
// empty state คงเดิม; query ทั้งหมดทำที่นี่แล้วส่ง `ExplorerItem[]` ให้
|
||||
// SummaryExplorer กรองใน memory ฝั่ง client (read-only snapshot ไม่มี SSE)
|
||||
|
||||
const UUID_RE =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
/** focus ring ร่วมของทุกลิงก์ในหน้า — Genesis .focus-ring (outline น้ำเงินทึบ 2px
|
||||
* + offset ตาม spec Elevation); จอสัมผัสได้ 44px อัตโนมัติจากกฎ
|
||||
* `main.summary-page a`/`button` ใน globals.css (ห้ามถอด class summary-page ของ <main>) */
|
||||
const RING_CLS = "focus-ring";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// design review 2026-09-17 M5: ชื่อแท็บ browser ระบุคดี — เลขานุการเปิดหลายคดี
|
||||
// หลายแท็บพร้อมกัน (เดิมทุกแท็บชื่อ "Judge's Note" เหมือนกันหมดแยกไม่ออก)
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: { id: string };
|
||||
}): Promise<Metadata> {
|
||||
const { id } = params;
|
||||
if (!UUID_RE.test(id)) return { title: "สรุปหมายเหตุ | Judge's Note" };
|
||||
const [file] = await db
|
||||
.select({ title: files.title })
|
||||
.from(files)
|
||||
.where(eq(files.id, id));
|
||||
return {
|
||||
title: `สรุปหมายเหตุ — ${file?.title ?? "ไม่พบไฟล์"} | Judge's Note`,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function SummaryPage({
|
||||
params,
|
||||
}: {
|
||||
params: { id: string };
|
||||
}) {
|
||||
const { id } = params;
|
||||
if (!UUID_RE.test(id)) notFound();
|
||||
|
||||
const [file] = await db.select().from(files).where(eq(files.id, id));
|
||||
if (!file) notFound();
|
||||
|
||||
const annRows = await db
|
||||
.select()
|
||||
.from(annotations)
|
||||
.where(eq(annotations.fileId, id))
|
||||
.orderBy(asc(annotations.pageIndex), asc(annotations.createdAt));
|
||||
const dtos = annRows.map(toAnnotationDTO);
|
||||
|
||||
// bookmark เรียงตามหน้า → ตำแหน่งบนหน้า → เวลาสร้าง (N5 เหมือน sidebar)
|
||||
const bmRows = await db
|
||||
.select()
|
||||
.from(bookmarks)
|
||||
.where(eq(bookmarks.fileId, id))
|
||||
.orderBy(
|
||||
asc(bookmarks.pageIndex),
|
||||
asc(bookmarks.yOffset),
|
||||
asc(bookmarks.createdAt),
|
||||
);
|
||||
const bmDtos = bmRows.map(toBookmarkDTO);
|
||||
const bookmarkById = new Map(bmDtos.map((b) => [b.id, b] as const));
|
||||
|
||||
// list เดียวรวมทั้งไฟล์ไล่ตามลำดับอ่าน: หน้า → หน้าเดียวกันให้ที่คั่นหน้าก่อน
|
||||
// หมายเหตุ → ในหน้าเดียวกัน bookmark เรียงตามตำแหน่งบนหน้า (N5 เหมือน query/
|
||||
// sidebar — code review 2026-09-24 คืนจาก createdAt ที่เคยแทนที่โดยไม่ตั้งใจ)
|
||||
// ส่วน annotation ตาม createdAt (sort ครั้งเดียวบน server — ฝั่ง client กรอง
|
||||
// เป็น pure predicate ไม่ sort ซ้ำ)
|
||||
const items: ExplorerItem[] = [
|
||||
...bmDtos.map((dto) => ({
|
||||
kind: "bm" as const,
|
||||
pageIndex: dto.pageIndex,
|
||||
dto,
|
||||
parentTitle: dto.parentId
|
||||
? bookmarkById.get(dto.parentId)?.title ?? null
|
||||
: null,
|
||||
})),
|
||||
...dtos.map((dto) => ({ kind: "ann" as const, pageIndex: dto.pageIndex, dto })),
|
||||
].sort(
|
||||
(a, x) =>
|
||||
a.pageIndex - x.pageIndex ||
|
||||
(a.kind === x.kind ? 0 : a.kind === "bm" ? -1 : 1) ||
|
||||
(a.kind === "bm" && x.kind === "bm"
|
||||
? a.dto.yOffset - x.dto.yOffset
|
||||
: new Date(a.dto.createdAt).getTime() -
|
||||
new Date(x.dto.createdAt).getTime()),
|
||||
);
|
||||
|
||||
// จำนวนหน้าที่มีรายการ ≥1 สำหรับสถิติแถบหัว
|
||||
const pageCount = new Set(items.map((it) => it.pageIndex)).size;
|
||||
|
||||
return (
|
||||
<main className="summary-page mx-auto max-w-3xl px-4 py-6 lg:max-w-6xl lg:px-8">
|
||||
{/* หัวกระดาษสำนวน — แถบน้ำเงินสืบต่อจาก tile JN ของแบรนด์ (หน้ารายการ/login):
|
||||
ชื่อรายงาน + ชื่อแฟ้ม + สถิติในที่เดียว แล้วปิดท้ายด้วยเส้นคั่นบาง
|
||||
(ตอนพิมพ์เป็นกรอบเส้นน้ำเงินตามกฎ @media print ของ summary-page) */}
|
||||
<header className="rounded-xl bg-primary px-5 py-4 text-white sm:px-6">
|
||||
<h1 className="text-lg font-semibold leading-snug">สรุปหมายเหตุ</h1>
|
||||
<p className="mt-0.5 break-words text-sm text-white/85">{file.title}</p>
|
||||
{items.length > 0 && (
|
||||
<p className="mt-2 text-xs tabular-nums text-white/70">
|
||||
Annotation {dtos.length} รายการ · Bookmark {bmDtos.length} รายการ ·{" "}
|
||||
{pageCount} หน้า
|
||||
</p>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{/* แถบ action — ghost button ขาวมีขอบ ภาษาเดียวกับการ์ดของหน้า (คำขอผู้ใช้
|
||||
2026-09-17 แทนลิงก์ข้อความเปล่า — เห็นเป็นปุ่มชัดขึ้น; จอสัมผัสได้ 44px
|
||||
จากกฎ coarse; ตอนพิมพ์ซ่อนทิ้ง — ปุ่มกลับไม่มีความหมายบนกระดาษ)
|
||||
ปุ่มส่งออก CSV (F16) / Bookmark JSON (N7) ถูกถอด 2026-09-25 ตามคำขอผู้ใช้
|
||||
— ระบบภายนอกดึงข้อมูลผ่าน N10 API แทนทั้งหมดแล้ว */}
|
||||
<div className="mt-3 flex flex-wrap items-center gap-x-2 gap-y-1 print:hidden">
|
||||
<Link
|
||||
href={`/note/annotate/${id}`}
|
||||
className={`inline-flex items-center gap-1.5 rounded-md border border-line bg-white px-3 py-1.5 text-sm text-ink hover:border-ink-faint hover:bg-ink/[0.03] ${RING_CLS}`}
|
||||
>
|
||||
<ArrowLeft aria-hidden className="h-3.5 w-3.5" />
|
||||
กลับหน้าอ่าน
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{items.length === 0 ? (
|
||||
/* ไฟล์ว่าง = ไม่ mount explorer เลย — แผงกรองของข้อมูลว่างไม่มีความหมาย
|
||||
(minimize choices) ทางเข้าเอกสารคือปุ่ม "เปิดหน้าอ่าน" */
|
||||
<div className="mt-5 rounded-xl border border-dashed border-line bg-white/70 p-10 text-center">
|
||||
<ClipboardList aria-hidden className="mx-auto h-8 w-8 text-ink-faint" />
|
||||
<p className="mt-3 text-sm font-medium text-ink-soft">
|
||||
ยังไม่มี Annotation หรือ Bookmark ในไฟล์นี้
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-ink-soft">
|
||||
เปิดหน้าอ่านเพื่อมาร์กข้อความ วาดเส้น ใส่คอมเมนต์ หรือคั่นหน้า
|
||||
</p>
|
||||
<Link
|
||||
href={`/note/annotate/${id}`}
|
||||
className={`mt-4 inline-flex 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 ${RING_CLS}`}
|
||||
>
|
||||
เปิดหน้าอ่าน
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<SummaryExplorer fileId={id} items={items} />
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
63
app/note/annotate/page.tsx
Normal file
63
app/note/annotate/page.tsx
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { registerExternalFile } from "@/lib/external-import";
|
||||
import { firstSearchParam } from "@/utils/external-file";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* N8 entry point ที่สอง (ตามคำขอผู้ใช้ 2026-09-22): เข้า `/note/annotate?fileUrl=&fileId=&typeId=`
|
||||
* โดยตรง — ลงทะเบียนเอกสารแล้ว redirect ต่อไป `/note/annotate/<id>` ของตัว viewer จริง
|
||||
* (entry เดิมที่หน้า "/note" ยังใช้ได้คู่กัน — สัญญาลิงก์รับทั้ง /note?fileUrl= และ /note/annotate?fileUrl=)
|
||||
*/
|
||||
export default async function AnnotateEntryPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Record<string, string | string[] | undefined>;
|
||||
}) {
|
||||
const fileUrl = firstSearchParam(searchParams.fileUrl);
|
||||
if (fileUrl) {
|
||||
const result = await registerExternalFile(
|
||||
fileUrl,
|
||||
firstSearchParam(searchParams.fileId),
|
||||
firstSearchParam(searchParams.typeId),
|
||||
);
|
||||
// redirect โยน NEXT_REDIRECT — เรียกนอก try/catch เสมอ
|
||||
if (result.ok) redirect(`/note/annotate/${result.fileId}`);
|
||||
return (
|
||||
<main className="mx-auto max-w-2xl px-4 py-10 sm:px-8">
|
||||
<div
|
||||
role="alert"
|
||||
className="rounded-lg border border-error/30 bg-error/10 p-4 text-sm text-error-ink"
|
||||
>
|
||||
เปิดเอกสารจากลิงก์ไม่สำเร็จ: {result.error}
|
||||
</div>
|
||||
<Link
|
||||
href="/note"
|
||||
className="focus-ring mt-4 inline-block 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"
|
||||
>
|
||||
กลับหน้ารายการ
|
||||
</Link>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
// ไม่มี fileUrl เลย — ไม่มีอะไรให้เปิด (route จริงของ viewer อยู่ที่ /note/annotate/[id])
|
||||
return (
|
||||
<main className="mx-auto max-w-2xl px-4 py-10 sm:px-8">
|
||||
<div className="rounded-xl border border-line bg-white p-6 text-sm text-ink-soft">
|
||||
ไม่ได้ระบุลิงก์เอกสาร — ใช้ <code className="rounded bg-ink/[0.05] px-1">
|
||||
/annotate?fileUrl=…&fileId=…&typeId=…
|
||||
</code>{" "}
|
||||
หรือเลือกไฟล์จากหน้ารายการ
|
||||
</div>
|
||||
<Link
|
||||
href="/note"
|
||||
className="focus-ring mt-4 inline-block 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"
|
||||
>
|
||||
กลับหน้ารายการ
|
||||
</Link>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
161
app/note/groups/[typeId]/page.tsx
Normal file
161
app/note/groups/[typeId]/page.tsx
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { asc, eq, inArray } from "drizzle-orm";
|
||||
import { ArrowLeft, Folder } from "lucide-react";
|
||||
|
||||
import {
|
||||
GroupExplorer,
|
||||
type GroupFileSummary,
|
||||
type GroupListItem,
|
||||
} from "@/components/groups/GroupExplorer";
|
||||
import { db } from "@/db";
|
||||
import {
|
||||
annotations,
|
||||
bookmarks,
|
||||
files,
|
||||
toAnnotationDTO,
|
||||
toBookmarkDTO,
|
||||
type AnnotationDTO,
|
||||
type BookmarkDTO,
|
||||
} from "@/db/schema";
|
||||
import { imageFormatFromUrl } from "@/utils/file-kind";
|
||||
|
||||
// N9 (PLAN-DOCSET.md D3) — หน้ารวมเอกสารของกลุ่ม (type_id จากลิงก์ e-Court)
|
||||
// server component force-dynamic — login ถูกบังคับโดย middleware.ts อยู่แล้ว
|
||||
// snapshot ณ ตอนโหลดเหมือนหน้าสรุป F15 (ไม่มี SSE) — กลุ่มไม่มีไฟล์เลย = 404
|
||||
// เพราะไม่มีตารางกลุ่ม: กลุ่มมีชีวิตตามข้อมูลจริงใน files ณ ตอน query
|
||||
//
|
||||
// redesign 2026-09-24 ตามคำขอผู้ใช้: ไม่มี navbar — explorer 2 คอลัมน์ ตัวกรอง
|
||||
// ฝั่งซ้าย + list เดียวรวม bookmark+annotation ทั้งกลุ่มไม่แยกไฟล์ (เคยเป็นการ์ด
|
||||
// ต่อไฟล์ + <details>) — query ทั้งหมดทำที่นี่แล้วส่งข้อมูลพร้อมใช้ให้ GroupExplorer
|
||||
// กรองใน memory ฝั่ง client
|
||||
|
||||
type FileBucket = { anns: AnnotationDTO[]; bms: BookmarkDTO[] };
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: { typeId: string };
|
||||
}): Promise<Metadata> {
|
||||
return { title: `กลุ่มเอกสาร ${params.typeId} | Judge's Note` };
|
||||
}
|
||||
|
||||
export default async function GroupPage({
|
||||
params,
|
||||
}: {
|
||||
params: { typeId: string };
|
||||
}) {
|
||||
const { typeId } = params;
|
||||
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(files)
|
||||
.where(eq(files.typeId, typeId))
|
||||
.orderBy(asc(files.createdAt));
|
||||
if (rows.length === 0) notFound();
|
||||
|
||||
// bookmark+annotation ทั้งกลุ่ม 2 query แยก (ไม่ join หนัก) แล้วจัดตามไฟล์ใน JS
|
||||
const ids = rows.map((f) => f.id);
|
||||
const [annRows, bmRows] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(annotations)
|
||||
.where(inArray(annotations.fileId, ids))
|
||||
.orderBy(asc(annotations.pageIndex), asc(annotations.createdAt)),
|
||||
db
|
||||
.select()
|
||||
.from(bookmarks)
|
||||
.where(inArray(bookmarks.fileId, ids))
|
||||
.orderBy(
|
||||
asc(bookmarks.pageIndex),
|
||||
asc(bookmarks.yOffset),
|
||||
asc(bookmarks.createdAt),
|
||||
),
|
||||
]);
|
||||
const annDtos = annRows.map(toAnnotationDTO);
|
||||
const bmDtos = bmRows.map(toBookmarkDTO);
|
||||
|
||||
const byFile = new Map<string, FileBucket>();
|
||||
const bucketOf = (fileId: string): FileBucket => {
|
||||
let b = byFile.get(fileId);
|
||||
if (!b) {
|
||||
b = { anns: [], bms: [] };
|
||||
byFile.set(fileId, b);
|
||||
}
|
||||
return b;
|
||||
};
|
||||
for (const d of annDtos) bucketOf(d.fileId).anns.push(d);
|
||||
for (const d of bmDtos) bucketOf(d.fileId).bms.push(d);
|
||||
// หา parent ของ sub bookmark แสดง "ใน: …" — id unique ทั้งตาราง ใช้ map เดียวร่วมทั้งกลุ่ม
|
||||
const bookmarkById = new Map(bmDtos.map((b) => [b.id, b] as const));
|
||||
|
||||
const fileSummaries: GroupFileSummary[] = rows.map((f) => ({
|
||||
id: f.id,
|
||||
title: f.title,
|
||||
isImage: Boolean(imageFormatFromUrl(f.pdfUrl)),
|
||||
}));
|
||||
|
||||
// list เดียวรวมทั้งกลุ่มไล่ตามลำดับอ่านสำนวน: ไฟล์ตาม createdAt (rows เรียงแล้ว)
|
||||
// → หน้า → หน้าเดียวกันให้ที่คั่นหน้าก่อนหมายเหตุ → createdAt เป็น tiebreak
|
||||
// (sort ครั้งเดียวบน server — ฝั่ง client กรองเป็น pure predicate ไม่ sort ซ้ำ)
|
||||
const items: GroupListItem[] = rows.flatMap((f) => {
|
||||
const bucket = byFile.get(f.id) ?? { anns: [], bms: [] };
|
||||
return [
|
||||
...bucket.bms.map((dto) => {
|
||||
const parent = dto.parentId ? bookmarkById.get(dto.parentId) : undefined;
|
||||
return {
|
||||
kind: "bm" as const,
|
||||
pageIndex: dto.pageIndex,
|
||||
dto,
|
||||
parentTitle: parent?.title ?? null,
|
||||
};
|
||||
}),
|
||||
...bucket.anns.map((dto) => ({
|
||||
kind: "ann" as const,
|
||||
pageIndex: dto.pageIndex,
|
||||
dto,
|
||||
})),
|
||||
].sort(
|
||||
(a, x) =>
|
||||
a.pageIndex - x.pageIndex ||
|
||||
(a.kind === x.kind ? 0 : a.kind === "bm" ? -1 : 1) ||
|
||||
new Date(a.dto.createdAt).getTime() - new Date(x.dto.createdAt).getTime(),
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
// listing-page: hook ของกฎ coarse 44px ใน globals.css (อย่าถอด)
|
||||
<main className="listing-page mx-auto max-w-3xl px-4 py-8 sm:px-6 lg:max-w-6xl lg:px-8">
|
||||
<Link
|
||||
href="/note"
|
||||
className="focus-ring inline-flex items-center gap-1.5 rounded-md text-sm text-ink-soft transition hover:text-ink"
|
||||
>
|
||||
<ArrowLeft aria-hidden className="h-4 w-4" />
|
||||
หน้ารายการ
|
||||
</Link>
|
||||
|
||||
<header className="mt-4 flex items-center gap-3">
|
||||
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-lg bg-ink/[0.04]">
|
||||
<Folder aria-hidden className="h-5 w-5 text-ink-soft" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h1 className="font-display break-all text-section-title text-ink">
|
||||
{typeId}
|
||||
</h1>
|
||||
<p className="mt-0.5 text-sm text-ink-soft">
|
||||
{rows.length} เอกสาร · {items.length} รายการ
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* หมายเหตุ "แสดงเฉพาะเอกสารที่เคยเปิดในระบบนี้" (แผน N9 D3 เดิม) ถูกถอดออก
|
||||
ตามคำขอผู้ใช้ 2026-09-24 — ระบบรู้จักเฉพาะเอกสารที่เคยเปิดผ่านลิงก์ e-Court
|
||||
รายการครบทั้งคดีอยู่ใน e-Court ไม่ใช่ระบบนี้ */}
|
||||
|
||||
<GroupExplorer files={fileSummaries} items={items} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
35
app/note/login/page.tsx
Normal file
35
app/note/login/page.tsx
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import DotGrid from "@/components/DotGrid";
|
||||
import LoginForm from "@/components/LoginForm";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { firstSearchParam, sanitizeNextPath } from "@/utils/external-file";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const metadata: Metadata = { title: "เข้าสู่ระบบ | Judge's Note" };
|
||||
|
||||
export default function LoginPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Record<string, string | string[] | undefined>;
|
||||
}) {
|
||||
// N8 D5: path วนกลับหลัง login (middleware แนบมาตอน redirect เข้าหน้านี้) —
|
||||
// ค่ามาจาก query ที่ใครก็แก้ได้ ต้อง sanitize กัน open-redirect ก่อนใช้เสมอ
|
||||
const rawNext = firstSearchParam(searchParams.next);
|
||||
const next = rawNext !== undefined ? sanitizeNextPath(rawNext) : null;
|
||||
|
||||
// เข้าสู่ระบบอยู่แล้ว (cookie ครบ) → ไม่ต้องเห็นฟอร์มอีก
|
||||
const user = getSessionUser();
|
||||
if (user) redirect(next ?? "/note");
|
||||
|
||||
return (
|
||||
// Genesis: กริดจุดอันตรกิริยา ( decorative element ชิ้นเดียวที่อนุญาต) เป็นพื้นหลัง
|
||||
// หน้า login — DotGrid อยู่ก่อนการ์ดใน DOM จึงถูกทับโดยการ์ดเอง ไม่ต้องเล่น z-index
|
||||
<main className="relative flex min-h-dvh items-center justify-center px-4 py-10">
|
||||
<DotGrid />
|
||||
<LoginForm next={next} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
236
app/note/page.tsx
Normal file
236
app/note/page.tsx
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { asc } from "drizzle-orm";
|
||||
import { FileImage, FileText, Folder } from "lucide-react";
|
||||
|
||||
import { db } from "@/db";
|
||||
import { files } from "@/db/schema";
|
||||
import AppNav from "@/components/AppNav";
|
||||
import EcourtLinkForm from "@/components/EcourtLinkForm";
|
||||
import UploadFileForm from "@/components/UploadFileForm";
|
||||
import { imageFormatFromUrl } from "@/utils/file-kind";
|
||||
import { formatThaiDate } from "@/utils/date-format";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { registerExternalFile } from "@/lib/external-import";
|
||||
import { firstSearchParam } from "@/utils/external-file";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type FileRow = typeof files.$inferSelect;
|
||||
|
||||
/** badge ชนิดไฟล์ต่อรายการ (N3) */
|
||||
function kindBadge(pdfUrl: string): string {
|
||||
switch (imageFormatFromUrl(pdfUrl)) {
|
||||
case "jpeg":
|
||||
return "JPG";
|
||||
case "png":
|
||||
return "PNG";
|
||||
case "tiff":
|
||||
return "TIFF";
|
||||
default:
|
||||
return "PDF";
|
||||
}
|
||||
}
|
||||
|
||||
/** การ์ดไฟล์ของหน้ารายการ (Genesis kit-card: surface 12px radius ขอบ line ไม่มีเงา
|
||||
* static — hover ยก -2px + shadow-card-hover; tile ไอคอนเป็นกลาง น้ำเงินสงวนให้ของ
|
||||
* interactive ตาม Do&Don't ของ design system) ใช้ร่วมทุก section (N9) */
|
||||
function FileCard({ file }: { file: FileRow }) {
|
||||
return (
|
||||
<li className="flex flex-col rounded-xl border border-line bg-white p-5 transition duration-200 hover:-translate-y-0.5 hover:shadow-card-hover">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-lg bg-ink/[0.04]">
|
||||
{imageFormatFromUrl(file.pdfUrl) ? (
|
||||
<FileImage aria-hidden className="h-5 w-5 text-ink-soft" />
|
||||
) : (
|
||||
<FileText aria-hidden className="h-5 w-5 text-ink-soft" />
|
||||
)}
|
||||
</div>
|
||||
<span className="rounded bg-ink/[0.05] px-1.5 py-0.5 text-[11px] font-medium text-ink-soft">
|
||||
{kindBadge(file.pdfUrl)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="mt-4 block break-all line-clamp-2 text-body font-medium text-ink">
|
||||
{file.title}
|
||||
</span>
|
||||
{/* วันที่เป็นข้อมูลที่ผู้ใช้อ่านจริง — ink-soft (ผ่าน 4.5:1); ink-faint เก็บไว้
|
||||
ให้ placeholder/disabled เท่านั้น (UX review M2 2026-09-23) */}
|
||||
<span className="mt-1 block text-caption text-ink-soft">
|
||||
{formatThaiDate(file.createdAt, true)}
|
||||
</span>
|
||||
{/* ปุ่มเข้า annotate แทนลิงก์กางเต็มการ์ด (M4) ตาม feedback — เป็น secondary
|
||||
(หนึ่ง primary ต่อ section ตาม Genesis; การ์ดทั้งกริดคือ passive surface) */}
|
||||
<div className="mt-5 flex flex-1 items-end justify-end">
|
||||
<Link
|
||||
href={`/note/annotate/${file.id}`}
|
||||
className="focus-ring inline-flex h-8 items-center rounded-md border border-line px-3 text-sm font-medium text-ink transition hover:-translate-y-px hover:border-ink-faint"
|
||||
>
|
||||
Annotate
|
||||
</Link>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export default async function HomePage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Record<string, string | string[] | undefined>;
|
||||
}) {
|
||||
// defense ซ้อน middleware — หน้านี้อ่าน cookie อยู่ดีจึงกันซ้ำไม่ให้ render รายการให้คนไม่มี session
|
||||
const user = getSessionUser();
|
||||
if (!user) redirect("/note/login");
|
||||
|
||||
// N8: เข้าจากลิงก์ e-Court ?fileUrl=&fileId=&typeId= — ลงทะเบียนเอกสาร (metadata-only
|
||||
// dedupe ด้วย external_id) แล้วพาเข้า viewer เลย — redirect() โยน NEXT_REDIRECT
|
||||
// ต้องเรียกนอก try/catch เสมอ; หน้านี้ force-dynamic จึงไม่ต้อง revalidatePath
|
||||
// (entry ที่สองอยู่ที่ /note/annotate?fileUrl=… — app/note/annotate/page.tsx)
|
||||
let registerError: string | null = null;
|
||||
const fileUrl = firstSearchParam(searchParams.fileUrl);
|
||||
if (fileUrl) {
|
||||
const result = await registerExternalFile(
|
||||
fileUrl,
|
||||
firstSearchParam(searchParams.fileId),
|
||||
firstSearchParam(searchParams.typeId),
|
||||
);
|
||||
if (result.ok) redirect(`/note/annotate/${result.fileId}`);
|
||||
registerError = result.error;
|
||||
}
|
||||
|
||||
const rows = await db.select().from(files).orderBy(asc(files.createdAt));
|
||||
|
||||
// N9: จัดกลุ่มตาม type_id — การ์ดในกลุ่มเรียง createdAt เหมือนเดิม (ตาม query ที่ asc)
|
||||
// ส่วนลำดับกลุ่มเรียงตาม createdAt ของไฟล์ล่าสุดในกลุ่ม (กลุ่มที่มี activity ใหม่อยู่บน)
|
||||
// ไฟล์ type_id NULL (seed/อัปโหลด N4/ลิงก์ไม่มี typeId) = section "ไม่มีกลุ่ม" ท้ายสุด
|
||||
const grouped = new Map<string, FileRow[]>();
|
||||
const ungrouped: FileRow[] = [];
|
||||
for (const f of rows) {
|
||||
if (f.typeId) {
|
||||
const list = grouped.get(f.typeId);
|
||||
if (list) list.push(f);
|
||||
else grouped.set(f.typeId, [f]);
|
||||
} else {
|
||||
ungrouped.push(f);
|
||||
}
|
||||
}
|
||||
const sections = Array.from(grouped.entries()).sort(
|
||||
(a, b) =>
|
||||
b[1][b[1].length - 1].createdAt.getTime() -
|
||||
a[1][a[1].length - 1].createdAt.getTime(),
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AppNav userName={user.userName} />
|
||||
{/* listing-page: hook ของกฎ coarse 44px ใน globals.css (อย่าถอด — เหมือน
|
||||
summary-page ของหน้าสรุป) */}
|
||||
<main className="listing-page mx-auto max-w-7xl px-4 pb-16 pt-10 sm:px-6">
|
||||
{/* ชื่อระบบ Latin ล้วน → General Sans tracking แน่นตาม Genesis display type */}
|
||||
<h1 className="font-display text-4xl tracking-tight text-ink sm:text-headline">
|
||||
Judge's Note
|
||||
</h1>
|
||||
<p className="mt-2 text-body text-ink-soft">
|
||||
ระบบ PDF Annotation สำหรับ e-Court
|
||||
</p>
|
||||
|
||||
{registerError && (
|
||||
<div
|
||||
role="alert"
|
||||
className="mt-6 rounded-lg border border-error/25 bg-error/5 p-4 text-sm text-error-ink"
|
||||
>
|
||||
เปิดเอกสารจากลิงก์ไม่สำเร็จ: {registerError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* การ์ดจำลองลิงก์ e-Court (N8) อยู่เหนือรายการเอกสาร — เป็นทางเข้าเปิดเอกสาร
|
||||
จากระบบ e-Court ไม่ใช่การเพิ่มไฟล์เข้ารายการ (คุมความกว้างเดียวกับ
|
||||
ฟอร์มอัปโหลด) */}
|
||||
<section className="mt-12">
|
||||
<div className="border-b border-line pb-3">
|
||||
<h2 className="font-display text-section-title text-ink">
|
||||
เปิดเอกสารจากลิงก์ e-Court
|
||||
</h2>
|
||||
</div>
|
||||
<div className="mt-5 max-w-xl">
|
||||
<EcourtLinkForm />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-12">
|
||||
<div className="flex items-baseline justify-between gap-3 border-b border-line pb-3">
|
||||
<h2 className="font-display text-section-title text-ink">เอกสาร</h2>
|
||||
{rows.length > 0 && (
|
||||
<span className="text-caption tabular-nums text-ink-soft">
|
||||
{rows.length} ไฟล์
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* อัปโหลดไฟล์ (N4) อยู่กับรายการเอกสาร — ไฟล์ที่อัปโหลดเข้ารายการนี้เอง
|
||||
(คุมความกว้างไม่ให้ฟอร์มกินเต็มความกว้างของกริดรายการ) */}
|
||||
<div className="mt-5 max-w-xl">
|
||||
<UploadFileForm />
|
||||
</div>
|
||||
|
||||
{rows.length === 0 ? (
|
||||
<div className="mt-5 rounded-xl border border-dashed border-line bg-white/60 p-12 text-center">
|
||||
<FileText aria-hidden className="mx-auto h-8 w-8 text-ink-faint" />
|
||||
<p className="mt-3 text-sm font-medium text-ink-soft">
|
||||
ยังไม่มีไฟล์ในระบบ
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-ink-soft">
|
||||
รัน /sync-db เพื่อ seed ไฟล์ตัวอย่าง
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{sections.map(([typeId, list]) => (
|
||||
<section key={typeId} className="mt-8">
|
||||
<div className="flex flex-wrap items-center gap-x-2.5 gap-y-1">
|
||||
<Folder
|
||||
aria-hidden
|
||||
className="h-4 w-4 shrink-0 text-ink-soft"
|
||||
/>
|
||||
<h3 className="font-display min-w-0 break-all text-subhead text-ink">
|
||||
{typeId}
|
||||
</h3>
|
||||
<span className="text-caption tabular-nums text-ink-soft">
|
||||
{list.length} ไฟล์
|
||||
</span>
|
||||
{/* Next Link เติม basePath ให้เอง (invariant #12) */}
|
||||
<Link
|
||||
href={`/note/groups/${encodeURIComponent(typeId)}`}
|
||||
className="focus-ring ml-auto shrink-0 rounded text-sm font-medium text-primary hover:underline"
|
||||
>
|
||||
เอกสารในกลุ่ม
|
||||
</Link>
|
||||
</div>
|
||||
<ul className="mt-4 grid gap-5 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{list.map((f) => (
|
||||
<FileCard key={f.id} file={f} />
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
{ungrouped.length > 0 && (
|
||||
<section className="mt-8">
|
||||
<div className="flex flex-wrap items-center gap-x-2.5 gap-y-1">
|
||||
<h3 className="font-display text-subhead text-ink">ไม่มีกลุ่ม</h3>
|
||||
<span className="text-caption tabular-nums text-ink-soft">
|
||||
{ungrouped.length} ไฟล์
|
||||
</span>
|
||||
</div>
|
||||
<ul className="mt-4 grid gap-5 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{ungrouped.map((f) => (
|
||||
<FileCard key={f.id} file={f} />
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
69
app/note/uploads/[name]/route.ts
Normal file
69
app/note/uploads/[name]/route.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import { readFile } from "node:fs/promises";
|
||||
|
||||
import {
|
||||
fileKindFromUrl,
|
||||
imageContentTypeFromUrl,
|
||||
SAMPLE_FILE_EXTENSIONS,
|
||||
} from "@/utils/file-kind";
|
||||
import { PdfPathError, resolvePublicPdfPath } from "@/lib/server-pdf";
|
||||
|
||||
// N4 — เสิร์ฟไฟล์ที่อัปโหลด (`public/note/uploads/<uuid>.<ext>`) ผ่าน route handler
|
||||
// ไม่ใช่ static file ของ Next: production server (next start / standalone) จะไล่
|
||||
// รายชื่อไฟล์ใน public/ เก็บไว้ใน Set **ครั้งเดียวตอน startup** เท่านั้น
|
||||
// (next/dist/server/lib/router-utils/filesystem.js — setupFsCheck) ไฟล์ที่ action
|
||||
// เขียนลง public/note/uploads/ หลัง server รันแล้วจึง GET ตรง ๆ ไม่ได้ (404)
|
||||
// ฝั่ง next dev ไม่ผ่าน branch นั้น เลยทำงานได้ — อาการคลาสสิก "local ได้ production พัง"
|
||||
// อ่านอย่างเดียว (invariant #1) + path guard ของ lib/server-pdf เหมือน /api/note/pdf (§5)
|
||||
|
||||
// ชื่อไฟล์ต้องเป็น uuid + extension ใน whitelist เท่านั้น (ตรงรูปที่ uploadFile เขียน)
|
||||
// — ชื่ออื่น 404 กลับไป ไม่รู้จักก็ไม่เปิด (กัน path traversal โดยแบบชื่อเอง: อักขระ
|
||||
// นอก hex/ขีด/จุด ผ่าน regex นี้ไม่ได้)
|
||||
const UUID_RE =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(
|
||||
_req: Request,
|
||||
{ params }: { params: { name: string } }
|
||||
) {
|
||||
const dot = params.name.lastIndexOf(".");
|
||||
if (dot <= 0) return new Response(null, { status: 404 });
|
||||
const [id, ext] = [
|
||||
params.name.slice(0, dot),
|
||||
params.name.slice(dot).toLowerCase(),
|
||||
];
|
||||
if (!UUID_RE.test(id)) return new Response(null, { status: 404 });
|
||||
// ext ต้องอยู่ใน whitelist เดียวกับ uploadFile — ชื่ออื่น 404 ไม่เสิร์ฟ
|
||||
if (!SAMPLE_FILE_EXTENSIONS.includes(ext)) {
|
||||
return new Response(null, { status: 404 });
|
||||
}
|
||||
|
||||
// helper เหล่านี้คาดชื่อไฟล์เต็ม (จุดต้องอยู่หลังอักขระตัวแรก) — ส่งชื่อเต็ม ไม่ใช่ bare ext
|
||||
const contentType =
|
||||
fileKindFromUrl(params.name) === "image"
|
||||
? imageContentTypeFromUrl(params.name)
|
||||
: "application/pdf";
|
||||
|
||||
let bytes: Buffer;
|
||||
try {
|
||||
// guard: resolve แล้วต้องอยู่ใน public/ เท่านั้น (รูปแบบชื่อผ่าน regex อยู่แล้ว
|
||||
// — ชั้นนี้กันไว้เผื่อมีคนเรียก helper นี้ซ้ำในอนาคตด้วย input อื่น)
|
||||
const diskPath = resolvePublicPdfPath(`/uploads/${id}${ext}`);
|
||||
bytes = await readFile(diskPath);
|
||||
} catch (err) {
|
||||
if (err instanceof PdfPathError) return new Response(null, { status: 404 });
|
||||
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
return new Response(null, { status: 404 });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
return new Response(new Uint8Array(bytes), {
|
||||
headers: {
|
||||
"Content-Type": contentType,
|
||||
// ชื่อไฟล์เป็น uuid ต่อการอัปโหลดหนึ่งครั้ง ไม่มีการเขียนทับ — ค่าเดียวกันตลอดชีวิตไฟล์
|
||||
"Cache-Control": "public, max-age=31536000, immutable",
|
||||
},
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue