Compare commits
4 commits
21b9958600
...
bc8ba83deb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc8ba83deb | ||
|
|
1ecb8efcea | ||
|
|
33c7002ffd | ||
|
|
a4cac15e42 |
16 changed files with 681 additions and 55 deletions
|
|
@ -6,7 +6,7 @@ paths:
|
|||
|
||||
# Data Layer Rules (รายละเอียด: docs/PLAN.md §3, §4)
|
||||
|
||||
- โครงสร้างตาราง/enum/JSONB union อ้าง §3 — หลังแก้ `db/schema.ts` ต้องรัน `/sync-db`
|
||||
- โครงสร้างตาราง/enum/JSONB union อ้าง §3 — หลังแก้ `db/schema.ts` ต้องรัน `/sync-db` **และ `npx drizzle-kit generate` ทุกครั้ง**: dev sync ด้วย push แต่ **image Docker รัน `scripts/migrate.mjs` จากไฟล์ SQL ใน `drizzle/` เท่านั้น** — ลืม generate = schema ฝั่ง server ตกรุ่นจน query พัง (เคยหลุด N6 `bookmarks.user_name` → หน้า annotate 500 ทั้งหน้าบน Docker) ; ไฟล์ที่ generated ควร idempotent (`IF NOT EXISTS`) เพราะ migrate.mjs ถือ error already-exists ทั้งไฟล์ว่า applied หมด
|
||||
- Enum/union ขยายตาม PLAN-FEAT.md §1 ทีละ task — ตอนนี้เพิ่มแล้ว: `SHAPE` + `ShapeData` (rect/ellipse เก็บ `rect` bbox, line/arrow เก็บ `from`/`to` ตาม D3), `REDACTION` + `RedactionData` (`{ rect }` — ดำทึบ ไม่มีสีให้เปลี่ยน), `TEXTBOX` + `TextboxData` (F17 — `{ position, text, color, fontSize, bold?, opacity?, align?, segments?, width? }` position เป็น**มุมซ้ายบนของกล่องเสมอ** (align จัดข้อความในกล่องอย่างเดียวแบบ Smallpdf — กล่องไม่ขยับ), fontSize = % ของ width; bold/opacity(0–1)/align('left'|'center'|'right')/width (กล่องกว้างคงที่ % ของ width — สไตล์ Smallpdf: มี = กล่องกว้างคงที่ลากขอบปรับได้, ไม่มี = shrink-to-fit แถวเดิม) เป็น optional ไม่มี = ตัวปกติ/ทึบเต็ม/ชิดซ้าย/หุ้มข้อความ — bold ตอน export ต้องมี `public/fonts/Sarabun-Bold.ttf` ไม่งั้น fallback เป็น Regular; `segments?` = ตัวหนาเฉพาะส่วน (rich text — editor เป็น contentEditable + Ctrl/Cmd+B, เก็บเฉพาะเมื่อมี bold run) โดย field `text` เก็บข้อความรวม (concat) เสมอเพื่อค้นหา F14/preview sidebar และ export ใช้ `wrapStyledText` วาดแบบ run ผสม), `STAMP` + `StampData` (F18 — `{ label, position, scale, color? }` scale = ความกว้างกรอบเป็น % ของ width, color optional ไม่มี = `STAMP_COLOR`), `DrawData.widths?` (F7 pressure, optional ยาวเท่า paths; `Point` define ที่ `db/schema.ts` แล้ว re-export จาก `utils/coordinates.ts`), `DrawData.pen?` ('pencil' | 'highlighter' — optional ไม่มี = pencil; ค่า preset ต่อ pen อยู่ที่ `DRAW_PENS` ใน `utils/annotation-display.ts`), `DrawData.opacity?` (0–1 optional — เส้นใหม่เก็บชัดเสมอ แถวเดิม fallback ตาม preset ของ pen ผ่าน `effectiveDrawOpacity`), `HighlightData.style?` ('highlight' | 'underline' | 'strikethrough' | 'squiggle' — optional ไม่มี = ไฮไลต์ทึบตามเดิม; เลือกจาก popup หลังคลุมดำ), `HighlightData.opacity?` (0–1 optional ไม่มี = ทึบเต็ม — ตั้งจาก dropdown ของ tool highlight ตอนสร้าง + แผง selection แก้ได้หลังสร้าง; export: ไฮไลต์ทึบคูณเข้าค่าเดิม 0.35 ส่วนเส้นทุกแบบใช้ตรง ๆ) — เพิ่ม type ใหม่ทุกครั้งต้องเติม case ใน `validateAnnotationData` ครบถ้วน (invariant #8)
|
||||
- column `data` ใช้ `$type<AnnotationData>()` (discriminated union ตาม enum `type`) — ห้าม `any` หรือ object type กว้าง — `note?: string` (โน้ตคำอธิบายของรายการ เช่น เหตุผลที่วาด — PLAN-FEAT.md §7 N1) มีในทุกชนิด**ยกเว้น COMMENT** (มี `message` ทำหน้าที่นี้อยู่แล้ว) — validator `vNote` trim แล้ว ค่าว่าง = ไม่มีโน้ต; อ่านค่าผ่าน `annotationNote()` ใน `utils/annotation-display.ts` เท่านั้น (sidebar/สรุป/CSV ใช้ร่วม)
|
||||
- DTO (`AnnotationDTO`, `BookmarkDTO`): timestamp เป็น **ISO string** เสมอ ผ่าน mapper `toDTO` — ห้ามส่ง `Date` object ข้าม server→client
|
||||
|
|
|
|||
|
|
@ -8,3 +8,5 @@ npm-debug.log*
|
|||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
# ไฟล์ที่อัปโหลดระหว่าง dev ห้ามติดไปใน image (production เขียน runtime + เก็บใน volume ไม่ใช่ bake ตอน build)
|
||||
public/uploads
|
||||
|
|
|
|||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -30,3 +30,6 @@ next-env.d.ts
|
|||
.claude/settings.local.json
|
||||
# N4: ไฟล์ที่ผู้ใช้อัปโหลดผ่านหน้ารายการ (รันไทม์ — ไม่เข้า repo)
|
||||
public/uploads/
|
||||
|
||||
# compose ทดสอบบนเครื่อง dev (build จาก repo + DB แยก รันที่ 6704) — ไม่ใช่ของ production
|
||||
docker-compose.test.yml
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ app/actions/files.ts ← Server Action uploadFile — validate + เขีย
|
|||
app/api/annotations/stream/route.ts ← SSE endpoint (GET ?fileId=)
|
||||
app/api/annotations/export/route.ts ← export PDF ฝัง annotation (pdf-lib, F8)
|
||||
app/api/annotations/export-csv/route.ts ← export สรุป CSV พร้อม BOM (F16)
|
||||
app/uploads/[name]/route.ts ← เสิร์ฟไฟล์อัปโหลดจาก public/uploads/ (N4 — Next production เห็นไฟล์ static เฉพาะที่มีตอน startup ไฟล์ที่เขียน runtime ต้องเสิร์ฟผ่าน route)
|
||||
app/page.tsx ← รายการไฟล์ (server component)
|
||||
app/annotate/[id]/page.tsx ← โหลด file + initial data → PDFViewer
|
||||
app/annotate/[id]/summary/page.tsx ← หน้าสรุปหมายเหตุจัดกลุ่มตามหน้า (F15)
|
||||
|
|
|
|||
|
|
@ -108,10 +108,11 @@
|
|||
|
||||
/* NumberCombo (ความหนา/ความทึบ/ขนาด): ▲▼ ซ้อนแนวตั้ง 2 ปุ่ม — ถ้าโดน min 36px
|
||||
คอลัมน์จะสูง 72px และ input (items-stretch) ถูกดึงบวมตามทั้งแถว — ปุ่ม ▲▼
|
||||
จึงหลุดจาก rule ด้านบน (combo-step) แล้วตั้งความสูงแถว input เองที่ 36px
|
||||
แทน (:first-child — แถว chips preset ของ L2 โดน rule ปุ่ม 36px ของเมนู
|
||||
ครอบอยู่แล้ว ห้ามบังคับสูงซ้ำ): แตะ chips preset = touch path หลัก
|
||||
ส่วน ▲▼ (ครึ่งแถว) เป็น fine-tune สำรอง — ขนาดใกล้ desktop ไม่บวม */
|
||||
จึงหลุดจาก rule ด้านบน (combo-step) แล้วตั้งความสูงแถว input เองที่ 36px แทน
|
||||
(:first-child — เว้น dropdown ของ preset ที่เป็นลูก div ตัวที่สอง ไม่ให้ถูก
|
||||
บังคับความสูงจนตัดรายการ): แตะ input = เปิด preset dropdown (เปิดขึ้นบน — L2:
|
||||
แถวท้ายเมนูเปิดลงจะล้นเขต scroll ของเมนู) คือ touch path หลัก ส่วน ▲▼
|
||||
(ครึ่งแถว) เป็น fine-tune สำรอง — ขนาดใกล้ desktop ไม่บวม */
|
||||
[data-tool-menu] .number-combo > div:first-child {
|
||||
height: 2.25rem;
|
||||
}
|
||||
|
|
@ -244,6 +245,15 @@ html {
|
|||
touch-action: auto;
|
||||
}
|
||||
|
||||
/* เส้นคั่นบางระหว่างแถวตั้งค่า (PanelRow) ที่ต่อกันในเมนู tool/แผง selection —
|
||||
ระยะ/เส้นเท่ากับแทรก <div class="my-1.5 border-t border-slate-100"> ไว้ระหว่างแถว
|
||||
ทุกจุด (แถวแรกของกลุ่มไม่มีเส้น — ไม่ซ้ำกับ divider ที่เมนูใส่ไว้เองแล้ว) */
|
||||
.panel-row + .panel-row {
|
||||
margin-top: 0.375rem;
|
||||
padding-top: 0.625rem;
|
||||
border-top: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
/* M6: safe-area — defensive (ค่าเป็น 0 จนกว่าจะใส่ viewport-fit=cover ใน meta
|
||||
ของ layout — ยังไม่ใส่ตามขอบเขตงานนี้ ใส่ไว้ก่อนเพื่อไม่ต้องแก้ CSS ซ้ำ) */
|
||||
header {
|
||||
|
|
|
|||
21
app/page.tsx
21
app/page.tsx
|
|
@ -1,6 +1,6 @@
|
|||
import Link from "next/link";
|
||||
import { asc } from "drizzle-orm";
|
||||
import { ChevronRight, FileImage, FileText } from "lucide-react";
|
||||
import { FileImage, FileText } from "lucide-react";
|
||||
|
||||
import { db } from "@/db";
|
||||
import { files } from "@/db/schema";
|
||||
|
|
@ -45,13 +45,17 @@ export default async function HomePage() {
|
|||
|
||||
<UserPicker />
|
||||
|
||||
<section className="mt-8">
|
||||
<h2 className="text-sm font-semibold text-slate-700">เพิ่มเอกสาร</h2>
|
||||
<UploadFileForm />
|
||||
</section>
|
||||
|
||||
<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 tabular-nums text-slate-500">{rows.length} ไฟล์</span>
|
||||
)}
|
||||
</div>
|
||||
<UploadFileForm />
|
||||
|
||||
{rows.length === 0 ? (
|
||||
<div className="mt-3 rounded-xl border border-dashed border-slate-300 bg-white/60 p-10 text-center">
|
||||
|
|
@ -68,7 +72,7 @@ export default async function HomePage() {
|
|||
{rows.map((f) => (
|
||||
<li
|
||||
key={f.id}
|
||||
className="group relative 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"
|
||||
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"
|
||||
>
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
||||
{imageFormatFromUrl(f.pdfUrl) ? (
|
||||
|
|
@ -88,14 +92,13 @@ export default async function HomePage() {
|
|||
{new Date(f.createdAt).toLocaleString("th-TH")}
|
||||
</span>
|
||||
</span>
|
||||
<ChevronRight aria-hidden className="h-4 w-4 shrink-0 text-slate-400" />
|
||||
{/* M4 (UX audit 2026-09-16): ลิงก์กางเต็มการ์ด — จอสัมผัสจิ้มตรงไหนก็เข้าได้
|
||||
(เดิมปุ่ม "Annotate" ~30px อย่างเดียว และเป็นภาษาอังกฤษใน UI ไทย) */}
|
||||
{/* ปุ่มเข้า annotate แทนลิงก์กางเต็มการ์ด (M4) ตาม feedback — เป้าจิ้ม 36px (H4) */}
|
||||
<Link
|
||||
href={`/annotate/${f.id}`}
|
||||
className="absolute inset-0 rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1"
|
||||
aria-label={`เปิด ${f.title}`}
|
||||
/>
|
||||
className="shrink-0 rounded-md bg-primary px-4 py-2 text-sm font-medium text-white transition hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1"
|
||||
>
|
||||
Annotate
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
|
|
|||
69
app/uploads/[name]/route.ts
Normal file
69
app/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/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/uploads/ หลัง server รันแล้วจึง GET ตรง ๆ ไม่ได้ (404)
|
||||
// ฝั่ง next dev ไม่ผ่าน branch นั้น เลยทำงานได้ — อาการคลาสสิก "local ได้ production พัง"
|
||||
// อ่านอย่างเดียว (invariant #1) + path guard ของ lib/server-pdf เหมือน /api/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",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -62,6 +62,9 @@ export default function UploadFileForm() {
|
|||
>
|
||||
{pending ? "กำลังอัปโหลด…" : "อัปโหลด"}
|
||||
</button>
|
||||
<p className="w-full text-xs text-slate-500">
|
||||
รองรับ PDF, JPG, PNG, TIFF — ไม่เกิน 100MB ต่อไฟล์
|
||||
</p>
|
||||
{error && <p className="w-full text-xs text-red-600">{error}</p>}
|
||||
</form>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -512,7 +512,7 @@ export default function AnnotationSelectionLayer({
|
|||
>
|
||||
{highlightData && (
|
||||
<>
|
||||
<PanelRow label="สี">
|
||||
<PanelRow>
|
||||
<ColorSwatches value={highlightData.color} onChange={commitColor} />
|
||||
</PanelRow>
|
||||
<PanelRow label="รูปแบบ">
|
||||
|
|
@ -550,7 +550,7 @@ export default function AnnotationSelectionLayer({
|
|||
</>
|
||||
)}
|
||||
{strokeData && (
|
||||
<PanelRow label="สี">
|
||||
<PanelRow>
|
||||
<ColorSwatches value={strokeData.strokeColor} onChange={commitColor} />
|
||||
</PanelRow>
|
||||
)}
|
||||
|
|
@ -568,7 +568,7 @@ export default function AnnotationSelectionLayer({
|
|||
)}
|
||||
{stampData && (
|
||||
<>
|
||||
<PanelRow label="สี">
|
||||
<PanelRow>
|
||||
<ColorSwatches
|
||||
value={stampData.color ?? STAMP_COLOR}
|
||||
onChange={commitColor}
|
||||
|
|
@ -601,7 +601,7 @@ export default function AnnotationSelectionLayer({
|
|||
แก้ไขข้อความ
|
||||
</button>
|
||||
</PanelRow>
|
||||
<PanelRow label="สี">
|
||||
<PanelRow>
|
||||
<ColorSwatches
|
||||
value={textboxData.color}
|
||||
onChange={(c) => commitTextboxPatch({ color: c })}
|
||||
|
|
|
|||
|
|
@ -3039,7 +3039,7 @@ export default function PDFViewer({
|
|||
</button>
|
||||
))}
|
||||
<div className="my-1.5 border-t border-slate-100" />
|
||||
<PanelRow label="สี">
|
||||
<PanelRow>
|
||||
<ColorSwatches value={highlightColor} onChange={setHighlightColor} />
|
||||
</PanelRow>
|
||||
<PanelRow label="ความทึบ">
|
||||
|
|
@ -3127,7 +3127,7 @@ export default function PDFViewer({
|
|||
);
|
||||
})}
|
||||
<div className="my-1.5 border-t border-slate-100" />
|
||||
<PanelRow label="สี">
|
||||
<PanelRow>
|
||||
<ColorSwatches value={drawColor} onChange={setDrawColor} />
|
||||
</PanelRow>
|
||||
<PanelRow label="ความหนา">
|
||||
|
|
@ -3220,7 +3220,7 @@ export default function PDFViewer({
|
|||
</button>
|
||||
))}
|
||||
<div className="my-1.5 border-t border-slate-100" />
|
||||
<PanelRow label="สี">
|
||||
<PanelRow>
|
||||
<ColorSwatches value={shapeColor} onChange={setShapeColor} />
|
||||
</PanelRow>
|
||||
<PanelRow label="ความหนา">
|
||||
|
|
@ -3298,7 +3298,7 @@ export default function PDFViewer({
|
|||
</button>
|
||||
))}
|
||||
<div className="my-1.5 border-t border-slate-100" />
|
||||
<PanelRow label="สี">
|
||||
<PanelRow>
|
||||
<ColorSwatches value={stampColor} onChange={setStampColor} />
|
||||
</PanelRow>
|
||||
<PanelRow label="ขนาด">
|
||||
|
|
@ -3360,7 +3360,7 @@ export default function PDFViewer({
|
|||
overflowY: "auto",
|
||||
}}
|
||||
>
|
||||
<PanelRow label="สี">
|
||||
<PanelRow>
|
||||
<ColorSwatches value={textboxColor} onChange={setTextboxColor} />
|
||||
</PanelRow>
|
||||
<PanelRow label="ขนาด">
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState, type ComponentType, type ReactNode } from "react";
|
||||
import { ChevronDown, ChevronUp, Highlighter, Strikethrough, Underline } from "lucide-react";
|
||||
import { useEffect, useRef, useState, type ComponentType, type ReactNode } from "react";
|
||||
import { Check, ChevronDown, ChevronUp, Highlighter, Strikethrough, Underline } from "lucide-react";
|
||||
|
||||
import type { HighlightStyle } from "@/db/schema";
|
||||
|
||||
|
|
@ -25,31 +25,37 @@ export const INK_COLORS = [
|
|||
"#3b82f6", // น้ำเงิน
|
||||
"#8b5cf6", // ม่วง
|
||||
"#ec4899", // ชมพู
|
||||
"#b45309", // น้ำตาล
|
||||
"#111827", // ดำ (default textbox)
|
||||
"#6b7280", // เทา
|
||||
];
|
||||
|
||||
/** แถวตั้งค่าในเมนู/แผงเครื่องมือ — label ซ้าย + ตัวควบคุมขวา */
|
||||
export function PanelRow({ label, children }: { label: string; children: ReactNode }) {
|
||||
/** แถวตั้งค่าในเมนู/แผงเครื่องมือ — label ซ้าย (optional: แถวสีไม่ใส่ ให้ตารางกินเต็มแถว)
|
||||
* + ตัวควบคุมขวา */
|
||||
export function PanelRow({ label, children }: { label?: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-start gap-2 px-1 py-1">
|
||||
<span className="w-11 shrink-0 pt-1 text-[11px] font-medium text-slate-500">{label}</span>
|
||||
<div className="panel-row flex items-start gap-2 px-1 py-1">
|
||||
{label ? (
|
||||
<span className="w-11 shrink-0 pt-1 text-[11px] font-medium text-slate-500">{label}</span>
|
||||
) : null}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** แถวปุ่มสีวงกลม preset + ปุ่มสีรุ้ง (color picker เลือกสีอื่นได้ไม่จำกัด) */
|
||||
/** แถวปุ่มสีวงกลม preset + ปุ่มสีรุ้งวงกลม (color picker เลือกสีอื่นได้ไม่จำกัด)
|
||||
* grid 4 ต่อแถว — 11 สี preset + สีรุ้ง = 12 วง เรียงจบพอดี 3 แถว (4+4+4)
|
||||
* ช่อง ~37px พ้นมาตรฐาน touch 36px เลยไม่ต้องปลด min ใน CSS จอสัมผัส */
|
||||
export function ColorSwatches({ value, onChange }: { value: string; onChange: (c: string) => void }) {
|
||||
return (
|
||||
<div className="color-swatches flex flex-1 flex-wrap items-center gap-1.5">
|
||||
<div className="color-swatches grid flex-1 grid-cols-4 gap-1.5">
|
||||
{INK_COLORS.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
aria-label={`เลือกสี ${c}`}
|
||||
title={c}
|
||||
className={`h-4 w-4 rounded-full border border-slate-300 transition ${FOCUS_RING_CLS} ${
|
||||
className={`aspect-square max-w-8 w-full justify-self-center rounded-full border border-slate-300 transition ${FOCUS_RING_CLS} ${
|
||||
value === c ? "ring-2 ring-primary ring-offset-1" : "hover:scale-110"
|
||||
}`}
|
||||
style={{ backgroundColor: c }}
|
||||
|
|
@ -58,7 +64,7 @@ export function ColorSwatches({ value, onChange }: { value: string; onChange: (c
|
|||
))}
|
||||
<label
|
||||
title="เลือกสีอื่น"
|
||||
className={`relative h-4 w-4 cursor-pointer rounded-full border border-slate-300 transition has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-primary has-[:focus-visible]:ring-offset-1 ${
|
||||
className={`relative aspect-square max-w-8 w-full cursor-pointer justify-self-center rounded-full border border-slate-300 transition has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-primary has-[:focus-visible]:ring-offset-1 ${
|
||||
INK_COLORS.includes(value) ? "hover:scale-110" : "ring-2 ring-primary ring-offset-1"
|
||||
}`}
|
||||
style={{
|
||||
|
|
@ -102,9 +108,11 @@ export function NumberCombo({
|
|||
suffix?: string;
|
||||
onChange: (v: number) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
// FIX-3: ถือ draft ระหว่างพิมพ์ — commit ครั้งเดียวตอน blur/Enter
|
||||
// ไม่ยิง server action + version snapshot ต่อ keystroke; sync กลับเมื่อค่าจากภายนอกเปลี่ยน
|
||||
const [draft, setDraft] = useState(String(value));
|
||||
const rootRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => setDraft(String(value)), [value]);
|
||||
|
||||
|
|
@ -124,6 +132,16 @@ export function NumberCombo({
|
|||
// eslint-disable-next-line react-hooks/exhaustive-deps -- draft/value/onChange ใช้ค่าล่าสุดตอน timer ยิง
|
||||
}, [draft]);
|
||||
|
||||
// คลิกนอกตัวควบคุม = ปิด dropdown (คลิกใน dropdown เองไม่นับ — อยู่ใน rootRef)
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onMouseDown = (e: MouseEvent) => {
|
||||
if (!rootRef.current?.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", onMouseDown);
|
||||
return () => document.removeEventListener("mousedown", onMouseDown);
|
||||
}, [open]);
|
||||
|
||||
/** ยืนยันค่าที่พิมพ์หนึ่งครั้ง — clamp เข้า min–max; พิมพ์ไม่เป็นตัวเลข = คืนค่าเดิม
|
||||
* ค่าเท่าเดิมไม่ยิง onChange — focus/blur เฉย ๆ ต้องไม่เกิด server action + snapshot ขยะ */
|
||||
function commitDraft() {
|
||||
|
|
@ -148,7 +166,17 @@ export function NumberCombo({
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="number-combo">
|
||||
<div
|
||||
ref={rootRef}
|
||||
className="number-combo relative flex-1"
|
||||
onKeyDown={(e) => {
|
||||
// Esc ปิดแค่ dropdown นี้ก่อน (stopPropagation ไม่ให้ปิดทั้งเมนู/กรอบ)
|
||||
if (e.key === "Escape" && open) {
|
||||
e.stopPropagation();
|
||||
setOpen(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-stretch overflow-hidden rounded-md border border-slate-300 bg-white">
|
||||
<input
|
||||
type="number"
|
||||
|
|
@ -158,12 +186,15 @@ export function NumberCombo({
|
|||
step={step}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onBlur={commitDraft}
|
||||
onFocus={(e) => e.target.select()} // พิมพ์ทับค่าเดิมได้ทันที
|
||||
onFocus={(e) => {
|
||||
setOpen(true); // กดที่ช่อง = เปิดรายการ preset ให้เลือกเลย (พิมพ์ค่าอื่นเองก็ได้)
|
||||
e.target.select(); // พิมพ์ทับค่าเดิมได้ทันที
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== "Escape") e.stopPropagation(); // พิมพ์ในช่องต้องไม่ชนคีย์ลัด F3 (Esc ปล่อยไหลไปปิดเมนู)
|
||||
if (e.key === "Enter") commitDraft(); // ยืนยันครั้งเดียวตรงนี้ ไม่ต้องรอ blur
|
||||
}}
|
||||
className="w-14 border-0 px-1.5 py-1.5 text-[11px] text-slate-700 focus:outline-none focus:ring-1 focus:ring-inset focus:ring-slate-400 [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
|
||||
className="min-w-0 flex-1 border-0 px-1.5 py-1.5 text-[11px] text-slate-700 focus:outline-none focus:ring-1 focus:ring-inset focus:ring-slate-400 [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
|
||||
/>
|
||||
<div className="flex flex-col border-l border-slate-300">
|
||||
{/* combo-step: ปุ่ม ▲▼ ซ้อนแนวตั้ง 2 ชั้น — ต้องหลุดจาก rule touch 36px
|
||||
|
|
@ -188,34 +219,40 @@ export function NumberCombo({
|
|||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* L2 (UX audit 2026-09-16): preset เดิมเป็น dropdown เปิดลงจากช่อง — แถวท้าย
|
||||
ของเมนู tool (ความทึบ/ความหนา) ล้นเขต scroll ของเมนู ต้องเลื่อนเมนูจึงเห็น
|
||||
ครบ เปลี่ยนเป็น chips แถวเดียวใต้ช่อง: เห็นค่าที่เลือกได้ทันที ไม่มี popup
|
||||
ซ้อนในเมนูอีก (จอสัมผัสได้ min 36px จาก rule [data-tool-menu] โดยอัตโนมัติ) */}
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{options.map((opt) => {
|
||||
const active = value === opt;
|
||||
return (
|
||||
{open && (
|
||||
<div
|
||||
role="listbox"
|
||||
// L2 (UX audit 2026-09-16): เปิด "ขึ้นบน" (bottom-full) เสมอ — แถวท้ายเมนู
|
||||
// tool (ความทึบ/ความหนา) เปิดลงจะล้นเขต scroll ของเมนู ต้องเลื่อนเมนูจึงเห็น
|
||||
// ครบ / ทุกจุดใช้งานมีแถวอื่นอยู่ข้างบนเสมอ จึงไม่หลุดขอบบนของเมนูเช่นกัน
|
||||
className="absolute bottom-full right-0 z-[60] mb-1 w-full overflow-hidden rounded-md border border-slate-200 bg-white py-0.5 shadow-lg"
|
||||
>
|
||||
{options.map((opt) => (
|
||||
<button
|
||||
key={opt}
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
className={`rounded border px-1.5 py-0.5 text-[11px] transition ${FOCUS_RING_CLS} ${
|
||||
active
|
||||
? "border-primary bg-primary/10 font-medium text-primary"
|
||||
: "border-slate-300 text-slate-500 hover:bg-slate-100"
|
||||
role="option"
|
||||
aria-selected={value === opt}
|
||||
onMouseDown={(e) => e.preventDefault()} // ไม่แย่ง focus จาก input ก่อนคลิก
|
||||
className={`flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs font-medium transition ${FOCUS_RING_CLS} ${
|
||||
value === opt
|
||||
? "bg-slate-100 text-slate-900"
|
||||
: "text-slate-600 hover:bg-slate-100"
|
||||
}`}
|
||||
onClick={() => {
|
||||
setDraft(String(opt));
|
||||
onChange(opt);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
{opt}
|
||||
{suffix}
|
||||
<span className="flex-1">
|
||||
{opt}
|
||||
{suffix}
|
||||
</span>
|
||||
{value === opt && <Check className="h-3.5 w-3.5 shrink-0" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,10 @@ COPY --from=builder --chown=nextjs:nodejs /app/scripts ./scripts
|
|||
# postgres.js โดน Next บีบ bundle เข้า server chunks — standalone node_modules ไม่มี package นี้
|
||||
# แต่ migrate.mjs import มันตรง ๆ → คัดลอกจาก stage deps (v3 ไม่มี dependency ย่อย โฟลเดอร์เดียวพอ)
|
||||
COPY --from=deps --chown=nextjs:nodejs /app/node_modules/postgres ./node_modules/postgres
|
||||
# สร้างโฟลเดอร์ uploads ให้ user nextjs เป็นเจ้าของ — named volume ที่ compose mount ทับ
|
||||
# (เก็บไฟล์อัปโหลดข้าม redeploy) จะได้ ownership จาก image ตอน init:
|
||||
# ไม่มี dir นี้ใน image = volume ถูกสร้างโดย root แล้ว action เขียนไฟล์ไม่ได้ (EACCES)
|
||||
RUN mkdir -p public/uploads && chown nextjs:nodejs public/uploads
|
||||
USER nextjs
|
||||
EXPOSE 3000
|
||||
# DATABASE_URL ฉีดตอนรันผ่าน env ของ container (อย่าฝังใน image)
|
||||
|
|
|
|||
|
|
@ -306,6 +306,7 @@ Dependencies เพิ่มเติม: F3 ← F2 · F12/F13/F19 ← F1 · F15
|
|||
- **UI:** ฟอร์มเดียวบนหน้า `/` (`components/UploadFileForm.tsx`) — `<input type="file" accept=".pdf,.jpg,.jpeg,.png,.tif,.tiff">` + ปุ่มอัปโหลด (`useTransition`, disabled ระหว่างส่ง) รับทั้ง PDF และรูป
|
||||
- **Server Action `uploadFile`** (`app/actions/files.ts`): รับ `FormData` — validate extension (whitelist เดียวกับ `SAMPLE_FILE_EXTENSIONS`) + ขนาด ≤ 100MB + ไฟล์ไม่ว่าง → เขียน `public/uploads/<uuid>.<ext>` (**ชื่อไฟล์บนดิสก์จาก randomUUID เท่านั้น** — กันชนกันและกัน path traversal; `title` = ชื่อไฟล์เดิมที่ sanitize) → insert `files (title, pdf_url)` → `revalidatePath("/")` — ไม่มี SSE event (รายการไฟล์ไม่ได้ sync ผ่าน SSE)
|
||||
- **next.config:** `experimental.serverActions.bodySizeLimit = "100mb"` (default 1MB ไม่พอสำหรับ PDF/สแกนศาล)
|
||||
- **เสิร์ฟไฟล์อัปโหลดผ่าน `app/uploads/[name]/route.ts`** (GET — อ่าน `public/uploads/<uuid>.<ext>` + path guard ของ `lib/server-pdf.ts` + content-type จาก `utils/file-kind.ts`): Next.js production (`next start`/standalone) ไล่รายชื่อไฟล์ใน `public/` เก็บใน Set **ครั้งเดียวตอน startup** (router-utils/filesystem.js `setupFsCheck`) — ไฟล์ที่ action เขียนตอน runtime GET ตรง ๆ แล้ว 404 (dev ไม่เป็น → อาการ "local ผ่าน Docker พัง"); ไฟล์ที่ bake ตอน build (pdf.worker/ฟอนต์/samples) ถูกเสิร์ฟโดย static path ก่อน route นี้อยู่แล้วตามลำดับ routing; PDF ใน viewer โหลดผ่าน `/api/pdf` อยู่แล้ว ตัวที่ต้องใช้ route นี้จริง = รูปภาพ (`<img src={pdfUrl}>`)
|
||||
- **ความสัมพันธ์กับ seed:** seed-samples จัดการเฉพาะ `/samples/%` — ไฟล์อัปโหลดใน `/uploads/` ไม่ถูก seed ลบ
|
||||
- **Out of scope ของ N4:** หน้าจัดการ/ลบ/แก้ชื่อไฟล์ใน UI, drag-drop, progress bar, multi-file พร้อมกัน, auth ผูกเจ้าของไฟล์, สิทธิ์ต่อไฟล์
|
||||
|
||||
|
|
|
|||
11
drizzle/0001_bright_celestials.sql
Normal file
11
drizzle/0001_bright_celestials.sql
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
ALTER TABLE "bookmarks" ADD COLUMN IF NOT EXISTS "user_name" text DEFAULT '' NOT NULL;--> statement-breakpoint
|
||||
-- N6 backfill ชื่อผู้บันทึกของ bookmark เดิมจาก annotations ตามแผน PLAN-BOOKMARK.md D2
|
||||
-- (เดิมรันมือครั้งเดียวบน dev — ฝั่ง deployment ใช้ migrate.mjs จึงต้องมาในไฟล์นี้;
|
||||
-- WHERE user_name = '' ทำให้รันซ้ำปลอดภัย แถวที่หาชื่อไม่ได้คง '' → UI แสดง "ไม่ทราบชื่อ")
|
||||
UPDATE "bookmarks" b
|
||||
SET user_name = COALESCE((
|
||||
SELECT a.user_name FROM annotations a
|
||||
WHERE a.user_id = b.user_id AND a.user_name <> ''
|
||||
ORDER BY a.created_at DESC LIMIT 1
|
||||
), '')
|
||||
WHERE b.user_name = '';
|
||||
475
drizzle/meta/0001_snapshot.json
Normal file
475
drizzle/meta/0001_snapshot.json
Normal file
|
|
@ -0,0 +1,475 @@
|
|||
{
|
||||
"id": "8124204c-9872-4e64-9486-693d5c639ff6",
|
||||
"prevId": "010b9cd2-5118-42d5-94c2-fdbfc3227018",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.annotation_replies": {
|
||||
"name": "annotation_replies",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"annotation_id": {
|
||||
"name": "annotation_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_name": {
|
||||
"name": "user_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"message": {
|
||||
"name": "message",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"annotation_replies_annotation_idx": {
|
||||
"name": "annotation_replies_annotation_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "annotation_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"annotation_replies_annotation_id_annotations_id_fk": {
|
||||
"name": "annotation_replies_annotation_id_annotations_id_fk",
|
||||
"tableFrom": "annotation_replies",
|
||||
"tableTo": "annotations",
|
||||
"columnsFrom": [
|
||||
"annotation_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.annotation_versions": {
|
||||
"name": "annotation_versions",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"annotation_id": {
|
||||
"name": "annotation_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"file_id": {
|
||||
"name": "file_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"page_index": {
|
||||
"name": "page_index",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_name": {
|
||||
"name": "user_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "annotation_type",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"data": {
|
||||
"name": "data",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"kind": {
|
||||
"name": "kind",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"annotation_versions_file_idx": {
|
||||
"name": "annotation_versions_file_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "file_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"annotation_versions_file_id_files_id_fk": {
|
||||
"name": "annotation_versions_file_id_files_id_fk",
|
||||
"tableFrom": "annotation_versions",
|
||||
"tableTo": "files",
|
||||
"columnsFrom": [
|
||||
"file_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.annotations": {
|
||||
"name": "annotations",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"file_id": {
|
||||
"name": "file_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"page_index": {
|
||||
"name": "page_index",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_name": {
|
||||
"name": "user_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "annotation_type",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"data": {
|
||||
"name": "data",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"resolved": {
|
||||
"name": "resolved",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"resolved_at": {
|
||||
"name": "resolved_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"annotations_file_page_idx": {
|
||||
"name": "annotations_file_page_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "file_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "page_index",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"annotations_file_id_files_id_fk": {
|
||||
"name": "annotations_file_id_files_id_fk",
|
||||
"tableFrom": "annotations",
|
||||
"tableTo": "files",
|
||||
"columnsFrom": [
|
||||
"file_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.bookmarks": {
|
||||
"name": "bookmarks",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"file_id": {
|
||||
"name": "file_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"page_index": {
|
||||
"name": "page_index",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_name": {
|
||||
"name": "user_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "''"
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"parent_id": {
|
||||
"name": "parent_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"note": {
|
||||
"name": "note",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"y_offset": {
|
||||
"name": "y_offset",
|
||||
"type": "double precision",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"bookmarks_file_id_files_id_fk": {
|
||||
"name": "bookmarks_file_id_files_id_fk",
|
||||
"tableFrom": "bookmarks",
|
||||
"tableTo": "files",
|
||||
"columnsFrom": [
|
||||
"file_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.files": {
|
||||
"name": "files",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"pdf_url": {
|
||||
"name": "pdf_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {
|
||||
"public.annotation_type": {
|
||||
"name": "annotation_type",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"HIGHLIGHT",
|
||||
"DRAW",
|
||||
"COMMENT",
|
||||
"SHAPE",
|
||||
"REDACTION",
|
||||
"TEXTBOX",
|
||||
"STAMP"
|
||||
]
|
||||
}
|
||||
},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,13 @@
|
|||
"when": 1789031603082,
|
||||
"tag": "0000_narrow_quentin_quire",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "7",
|
||||
"when": 1789544914887,
|
||||
"tag": "0001_bright_celestials",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue