Compare commits
2 commits
ec26d79f0e
...
289d735bf3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
289d735bf3 | ||
|
|
79c9e2cc41 |
13 changed files with 209 additions and 24 deletions
|
|
@ -59,12 +59,14 @@ npx drizzle-kit push
|
|||
|
||||
### 5. Seed ไฟล์ตัวอย่าง
|
||||
|
||||
ไฟล์ตัวอย่างอยู่ที่ `public/samples/` แล้ว — ลงทะเบียนในตาราง `files` (รันเฉพาะเมื่อตารางว่าง ไม่งั้นข้อมูลจะซ้ำ):
|
||||
ไฟล์ตัวอย่างอยู่ที่ `public/samples/` แล้ว — sync ลงตาราง `files` (idempotent รันซ้ำได้):
|
||||
|
||||
```bash
|
||||
docker exec judes-note-db psql -U postgres -c "insert into files (title, pdf_url) values ('01012-672427-4f-690609-0000840227.pdf','/samples/01012-672427-4f-690609-0000840227.pdf'), ('01012-672427-1f-690609-0000840223.pdf','/samples/01012-672427-1f-690609-0000840223.pdf'), ('dashboard-report-2026-09-03.pdf','/samples/dashboard-report-2026-09-03.pdf');"
|
||||
npm run seed-samples
|
||||
```
|
||||
|
||||
> บน production ไม่ต้องรันมือ — script เดียวกัน (`scripts/seed-samples.mjs`) รันอัตโนมัติตอน container start ต่อจาก migration ไฟล์ที่หายจาก `public/samples/` จะถูกลบ row พร้อม annotation ที่ผูกอยู่ (CASCADE)
|
||||
|
||||
### 6. รัน dev server
|
||||
|
||||
```bash
|
||||
|
|
|
|||
53
app/page.tsx
53
app/page.tsx
|
|
@ -4,6 +4,7 @@ import { FileText } from "lucide-react";
|
|||
|
||||
import { db } from "@/db";
|
||||
import { files } from "@/db/schema";
|
||||
import UserPicker from "@/components/UserPicker";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
|
|
@ -11,29 +12,55 @@ export default async function HomePage() {
|
|||
const rows = await db.select().from(files).orderBy(asc(files.createdAt));
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-2xl p-8">
|
||||
<h1 className="text-2xl font-bold text-primary">Jude's Note</h1>
|
||||
<p className="mt-1 text-sm text-slate-500">
|
||||
ระบบ PDF Annotation สำหรับ e-Court
|
||||
</p>
|
||||
<main className="mx-auto max-w-2xl px-4 py-10 sm:px-8">
|
||||
<header className="flex items-center gap-3">
|
||||
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-primary text-base font-bold text-white shadow-sm">
|
||||
JN
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold leading-tight text-primary">
|
||||
Jude's Note
|
||||
</h1>
|
||||
<p className="text-sm text-slate-500">
|
||||
ระบบ PDF Annotation สำหรับ e-Court
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<UserPicker />
|
||||
|
||||
<div className="mt-8 flex items-baseline justify-between">
|
||||
<h2 className="text-sm font-semibold text-slate-700">เอกสาร</h2>
|
||||
{rows.length > 0 && (
|
||||
<span className="text-xs text-slate-400">{rows.length} ไฟล์</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{rows.length === 0 ? (
|
||||
<p className="mt-8 rounded border border-dashed p-6 text-center text-sm text-slate-500">
|
||||
ยังไม่มีไฟล์ในระบบ — รัน /sync-db เพื่อ seed ไฟล์ตัวอย่าง
|
||||
</p>
|
||||
<div className="mt-3 rounded-xl border border-dashed border-slate-300 bg-white/60 p-10 text-center">
|
||||
<FileText className="mx-auto h-8 w-8 text-slate-300" />
|
||||
<p className="mt-3 text-sm font-medium text-slate-500">
|
||||
ยังไม่มีไฟล์ในระบบ
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-slate-400">
|
||||
รัน /sync-db เพื่อ seed ไฟล์ตัวอย่าง
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="mt-6 space-y-2">
|
||||
<ul className="mt-3 space-y-3">
|
||||
{rows.map((f) => (
|
||||
<li
|
||||
key={f.id}
|
||||
className="flex items-center gap-3 rounded-lg border bg-white p-4 shadow-sm"
|
||||
className="group flex items-center gap-3 rounded-xl border border-slate-200 bg-white p-4 shadow-sm transition hover:border-primary/30 hover:shadow-md"
|
||||
>
|
||||
<FileText className="h-5 w-5 shrink-0 text-slate-400" />
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
||||
<FileText className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block break-all line-clamp-2 text-sm font-medium">
|
||||
<span className="block break-all line-clamp-2 text-sm font-medium text-slate-900">
|
||||
{f.title}
|
||||
</span>
|
||||
<span className="block text-xs text-slate-400">
|
||||
<span className="mt-0.5 block text-xs text-slate-400">
|
||||
{new Date(f.createdAt).toLocaleString("th-TH")}
|
||||
</span>
|
||||
</span>
|
||||
|
|
|
|||
19
components/Select.tsx
Normal file
19
components/Select.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
"use client";
|
||||
|
||||
// <select> ที่ถอดลูกศร native ของ browser ออก (วาดชิดขอบและขยับด้วย padding ไม่ได้)
|
||||
// แล้วใส่ลูกศรเอง — ใช้เหมือน <select> ปกติทุกอย่าง (props/children)
|
||||
// className ที่ส่งเข้าไปใช้กับตัว <select> โดย append `appearance-none pr-7` ให้
|
||||
import type { SelectHTMLAttributes } from "react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
|
||||
export default function Select({
|
||||
className = "",
|
||||
...props
|
||||
}: SelectHTMLAttributes<HTMLSelectElement>) {
|
||||
return (
|
||||
<div className="relative">
|
||||
<select {...props} className={`appearance-none pr-7 ${className}`} />
|
||||
<ChevronDown className="pointer-events-none absolute right-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-400" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
66
components/UserPicker.tsx
Normal file
66
components/UserPicker.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
"use client";
|
||||
|
||||
// เลือก mock user ที่หน้าแรกก่อนเข้า annotate — เขียนลง localStorage key เดิม
|
||||
// ที่ getLocalUser() อ่าน (PDFViewer ไม่ต้องแก้อะไร)
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import Select from "./Select";
|
||||
import {
|
||||
MOCK_USERS,
|
||||
getStoredLocalUser,
|
||||
setLocalUser,
|
||||
type LocalUser,
|
||||
} from "@/utils/user";
|
||||
|
||||
export default function UserPicker() {
|
||||
const [current, setCurrent] = useState<LocalUser | null>(null);
|
||||
|
||||
// ต้องรันใน effect เพราะ SSR ไม่มี localStorage — ก่อน mount แสดงคนแรกไว้ก่อน
|
||||
useEffect(() => {
|
||||
const stored = getStoredLocalUser();
|
||||
if (!stored) {
|
||||
// ยังไม่เคยมี identity → เริ่มด้วย mock user คนแรก
|
||||
setLocalUser(MOCK_USERS[0]);
|
||||
setCurrent(MOCK_USERS[0]);
|
||||
return;
|
||||
}
|
||||
setCurrent(stored);
|
||||
}, []);
|
||||
|
||||
// identity สุ่มเดิมไม่อยู่ในรายการ mock → แสดงเป็น option ชั่วคราวให้เห็นว่าปัจจุบันคือใคร
|
||||
const isMock = current !== null && MOCK_USERS.some((u) => u.userId === current.userId);
|
||||
|
||||
const select = (userId: string) => {
|
||||
const user = MOCK_USERS.find((u) => u.userId === userId);
|
||||
if (!user) return;
|
||||
setLocalUser(user);
|
||||
setCurrent(user);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="mt-6 rounded-lg border bg-white p-4 shadow-sm">
|
||||
<h2 className="text-sm font-semibold text-primary">เลือกผู้ใช้ (mock)</h2>
|
||||
<p className="mt-0.5 text-xs text-slate-400">
|
||||
annotation ทั้งหมดจะบันทึกในชื่อผู้ใช้ที่เลือก
|
||||
</p>
|
||||
<Select
|
||||
value={current ? (isMock ? current.userId : "") : MOCK_USERS[0].userId}
|
||||
onChange={(e) => {
|
||||
if (e.target.value) select(e.target.value);
|
||||
}}
|
||||
className="mt-3 block w-full rounded-md border bg-white py-2 pl-3 text-sm"
|
||||
>
|
||||
{current && !isMock && (
|
||||
<option value="" disabled>
|
||||
{current.userName} (สุ่มอัตโนมัติ)
|
||||
</option>
|
||||
)}
|
||||
{MOCK_USERS.map((u) => (
|
||||
<option key={u.userId} value={u.userId}>
|
||||
{u.userName}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -109,6 +109,7 @@ import InkLayer, { type InkRedaction, type InkShape, type InkStroke } from "./In
|
|||
import TextboxStampLayer, { type InkStamp, type InkTextbox } from "./TextboxStampLayer";
|
||||
import AnnotationSelectionLayer from "./AnnotationSelectionLayer";
|
||||
import AnnotationSidebar from "../sidebar/AnnotationSidebar";
|
||||
import Select from "../Select";
|
||||
import {
|
||||
ColorSwatches,
|
||||
HIGHLIGHT_STYLE_BTNS,
|
||||
|
|
@ -2634,7 +2635,7 @@ export default function PDFViewer({
|
|||
className="mt-1.5 w-full resize-none rounded-md border border-slate-300 px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-slate-400"
|
||||
/>
|
||||
<p className="mt-1.5 text-[11px] text-slate-400">วางที่</p>
|
||||
<select
|
||||
<Select
|
||||
value={bookmarkComposer.parentId ?? ""}
|
||||
onChange={(e) =>
|
||||
setBookmarkComposer({
|
||||
|
|
@ -2654,7 +2655,7 @@ export default function PDFViewer({
|
|||
ใต้ "{h.title}"
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Select>
|
||||
{bookmarkError && (
|
||||
<p className="mt-1.5 rounded bg-red-50 px-2 py-1 text-[11px] text-red-600">
|
||||
{bookmarkError}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,8 @@ import {
|
|||
X,
|
||||
} from "lucide-react";
|
||||
|
||||
import Select from "../Select";
|
||||
|
||||
import {
|
||||
listVersions,
|
||||
restoreVersion,
|
||||
|
|
@ -359,7 +361,7 @@ export default function AnnotationSidebar({
|
|||
className="mt-1 w-full resize-none rounded border border-slate-300 p-1.5 text-[11px] focus:outline-none focus:ring-1 focus:ring-slate-400"
|
||||
/>
|
||||
<p className="mt-1 text-[10px] text-slate-400">วางที่</p>
|
||||
<select
|
||||
<Select
|
||||
value={eb.parentId ?? ""}
|
||||
onChange={(e) =>
|
||||
setEditBookmark({ ...eb, parentId: e.target.value || null })
|
||||
|
|
@ -373,7 +375,7 @@ export default function AnnotationSidebar({
|
|||
ใต้ "{h.title}"
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Select>
|
||||
<div className="mt-1.5 flex justify-end gap-1.5 text-[11px]">
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -622,7 +624,7 @@ export default function AnnotationSidebar({
|
|||
<div className="flex h-full flex-col">
|
||||
{/* F4: กรองแสดง annotation ตามผู้เขียน (viewer + sidebar พร้อมกัน) */}
|
||||
<div className="shrink-0 border-b border-slate-200 p-2">
|
||||
<select
|
||||
<Select
|
||||
aria-label="กรอง annotation ตามผู้เขียน"
|
||||
className="w-full rounded-md border border-slate-300 bg-white px-2 py-1.5 text-xs text-slate-600 focus:outline-none focus:ring-2 focus:ring-slate-400"
|
||||
value={userFilter}
|
||||
|
|
@ -635,7 +637,7 @@ export default function AnnotationSidebar({
|
|||
เฉพาะ {u.userName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* F14/D1: ค้นหาในหมายเหตุทั้งไฟล์ — คลิกผลลัพธ์ = กระโดดไปตำแหน่ง + เลือก */}
|
||||
|
|
|
|||
|
|
@ -41,4 +41,5 @@ USER nextjs
|
|||
EXPOSE 3000
|
||||
# DATABASE_URL ฉีดตอนรันผ่าน env ของ container (อย่าฝังใน image)
|
||||
# migrate สร้าง database ถ้ายังไม่มี + apply ไฟล์ที่ยังไม่ได้ apply (idempotent)
|
||||
CMD ["sh", "-c", "node scripts/migrate.mjs && exec node server.js"]
|
||||
# seed-samples sync ตาราง files กับ public/samples/ ที่ bake อยู่ใน image (idempotent)
|
||||
CMD ["sh", "-c", "node scripts/migrate.mjs && node scripts/seed-samples.mjs && exec node server.js"]
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
"build": "next build",
|
||||
"test": "vitest run",
|
||||
"start": "next start",
|
||||
"seed-samples": "node --env-file=.env scripts/seed-samples.mjs",
|
||||
"postinstall": "node -e \"require('fs').copyFileSync('node_modules/pdfjs-dist/build/pdf.worker.min.mjs','public/pdf.worker.min.mjs')\""
|
||||
},
|
||||
"dependencies": {
|
||||
|
|
|
|||
BIN
public/samples/court_command_11_123 1.pdf
Normal file
BIN
public/samples/court_command_11_123 1.pdf
Normal file
Binary file not shown.
BIN
public/samples/court_command_1_2547 1.pdf
Normal file
BIN
public/samples/court_command_1_2547 1.pdf
Normal file
Binary file not shown.
Binary file not shown.
40
scripts/seed-samples.mjs
Normal file
40
scripts/seed-samples.mjs
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
// Sync ตาราง files กับไฟล์จริงใน public/samples/ (idempotent — รันซ้ำได้ทุกครั้ง)
|
||||
// - INSERT ไฟล์ .pdf ที่ยังไม่มี row (pdf_url = /samples/<ชื่อไฟล์>)
|
||||
// - DELETE row ที่ชี้ /samples/* แต่ไฟล์หายจาก disk (CASCADE ลบ annotation/bookmark ตาม)
|
||||
// รันตอน container start ต่อจาก migrate.mjs และรันมือบนเครื่อง dev ได้ผ่าน npm run seed-samples
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import postgres from "postgres";
|
||||
|
||||
const SAMPLES_DIR = path.join(process.cwd(), "public", "samples");
|
||||
|
||||
const dbUrl = process.env.DATABASE_URL;
|
||||
if (!dbUrl) throw new Error("ต้องตั้ง DATABASE_URL ก่อนรัน seed-samples");
|
||||
|
||||
const sql = postgres(dbUrl, { prepare: false, max: 1 });
|
||||
try {
|
||||
const onDisk = fs
|
||||
.readdirSync(SAMPLES_DIR)
|
||||
.filter((f) => f.toLowerCase().endsWith(".pdf"))
|
||||
.map((f) => ({ title: f, pdfUrl: `/samples/${f}` }));
|
||||
const diskUrls = new Set(onDisk.map((f) => f.pdfUrl));
|
||||
|
||||
const rows = await sql`select id, title, pdf_url from files where pdf_url like '/samples/%'`;
|
||||
const dbUrls = new Set(rows.map((r) => r.pdf_url));
|
||||
|
||||
for (const file of onDisk) {
|
||||
if (dbUrls.has(file.pdfUrl)) continue;
|
||||
console.log(`[seed] เพิ่ม ${file.pdfUrl}`);
|
||||
await sql`insert into files (title, pdf_url) values (${file.title}, ${file.pdfUrl})`;
|
||||
}
|
||||
|
||||
for (const row of rows) {
|
||||
if (diskUrls.has(row.pdf_url)) continue;
|
||||
console.log(`[seed] ลบ ${row.pdf_url} (ไฟล์หายจาก image — annotation ที่ผูกอยู่ CASCADE ตามไปด้วย)`);
|
||||
await sql`delete from files where id = ${row.id}`;
|
||||
}
|
||||
|
||||
console.log(`[seed] sync เสร็จ: ${onDisk.length} ไฟล์ใน samples`);
|
||||
} finally {
|
||||
await sql.end();
|
||||
}
|
||||
|
|
@ -5,7 +5,27 @@ export type LocalUser = { userId: string; userName: string };
|
|||
|
||||
const STORAGE_KEY = "judes-note:user";
|
||||
|
||||
export function getLocalUser(): LocalUser {
|
||||
// mock users สำหรับเลือกที่หน้าแรก (demo ไม่มีระบบ auth) — userId คงที่
|
||||
// เพื่อให้กรองรายคนและสิทธิ์ "แก้ไขได้เฉพาะของตัวเอง" ทำงานสม่ำเสมอข้าม refresh
|
||||
export const MOCK_USERS: LocalUser[] = [
|
||||
{ userId: "mock-user-01", userName: "ผู้พิพากษา สมชาย" },
|
||||
{ userId: "mock-user-02", userName: "ผู้พิพากษา สมหญิง" },
|
||||
{ userId: "mock-user-03", userName: "อัยการ วิชัย" },
|
||||
{ userId: "mock-user-04", userName: "เลขานุการศาล ปิยะ" },
|
||||
{ userId: "mock-user-05", userName: "ทนายโจทก์ ณัฐ" },
|
||||
{ userId: "mock-user-06", userName: "ทนายจำเลย อรุณี" },
|
||||
{ userId: "mock-user-07", userName: "ผู้ช่วยผู้พิพากษา กันต์" },
|
||||
{ userId: "mock-user-08", userName: "พนักงานสอบสวน เดชา" },
|
||||
{ userId: "mock-user-09", userName: "ผู้เชี่ยวชาญ มานะ" },
|
||||
{ userId: "mock-user-10", userName: "ผู้ช่วยเลขานุการ ฝ้าย" },
|
||||
];
|
||||
|
||||
export function setLocalUser(user: LocalUser): void {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(user));
|
||||
}
|
||||
|
||||
// อ่าน identity ที่เก็บไว้โดยไม่สร้างใหม่ — ใช้แสดงค่าปัจจุบันในหน้าเลือกผู้ใช้
|
||||
export function getStoredLocalUser(): LocalUser | null {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (raw) {
|
||||
try {
|
||||
|
|
@ -14,9 +34,15 @@ export function getLocalUser(): LocalUser {
|
|||
return parsed;
|
||||
}
|
||||
} catch {
|
||||
// ข้อมูลเก่าเสียหาย → สร้างใหม่ต่อด้านล่าง
|
||||
// ข้อมูลเก่าเสียหาย → ถือว่าไม่มีข้อมูล
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getLocalUser(): LocalUser {
|
||||
const stored = getStoredLocalUser();
|
||||
if (stored) return stored;
|
||||
|
||||
const userId = crypto.randomUUID();
|
||||
const userName = `ผู้ใช้ ${userId.slice(0, 4).toUpperCase()}`;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue