Add application codebase: PDF annotation for e-Court
Next.js 14 + Drizzle/PostgreSQL + SSE realtime sync — highlight, freehand, comment, bookmark + ฟีเจอร์ต่อยอด F1–F19 (edit/move, undo/redo, shapes, redaction, export PDF/CSV, reply, resolve, search, summary, textbox, stamp, version history) Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
parent
6fd0c3174f
commit
eddcec28c7
60 changed files with 15055 additions and 0 deletions
83
hooks/useAnnotationRealtime.ts
Normal file
83
hooks/useAnnotationRealtime.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import { useEffect, useState } from "react";
|
||||
|
||||
import type { AnnotationDTO, BookmarkDTO, ReplyDTO } from "@/db/schema";
|
||||
import type { AnnotateEvent } from "@/lib/annotate-events";
|
||||
|
||||
// SSE sync (PLAN.md §4, rules/realtime.md)
|
||||
// - เปิด EventSource ไป /api/annotations/stream?fileId=<id> — reconnect อัตโนมัติเมื่อขาด (ห้ามทำ retry เอง)
|
||||
// - event เป็น DTO (camelCase + ISO string) จาก server ตรง ๆ — ไม่มีการแปลง
|
||||
// - dedupe ด้วย id เท่านั้น (คู่กับ useOptimistic ใน PDFViewer — PLAN.md §4)
|
||||
|
||||
export function useAnnotationRealtime(
|
||||
fileId: string,
|
||||
initialAnnotations: AnnotationDTO[],
|
||||
initialBookmarks: BookmarkDTO[],
|
||||
initialReplies: ReplyDTO[],
|
||||
) {
|
||||
const [annotations, setAnnotations] = useState(initialAnnotations);
|
||||
const [bookmarks, setBookmarks] = useState(initialBookmarks);
|
||||
const [replies, setReplies] = useState(initialReplies);
|
||||
|
||||
useEffect(() => {
|
||||
const es = new EventSource(`/api/annotations/stream?fileId=${fileId}`);
|
||||
|
||||
es.onmessage = (e: MessageEvent<string>) => {
|
||||
let ev: AnnotateEvent;
|
||||
try {
|
||||
ev = JSON.parse(e.data) as AnnotateEvent;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
// กรอง message ที่ไม่ใช่ annotate event (เช่น { type: "connected" })
|
||||
if (typeof ev.table !== "string" || typeof ev.action !== "string") return;
|
||||
|
||||
if (ev.table === "annotations") {
|
||||
if (ev.action === "create") {
|
||||
setAnnotations((prev) =>
|
||||
prev.some((a) => a.id === ev.row.id) ? prev : [...prev, ev.row],
|
||||
);
|
||||
} else if (ev.action === "update") {
|
||||
// replace ตาม id — idempotent: event มาก่อน/หลัง optimistic ก็ได้ผลเดียวกัน (PLAN-FEAT.md §5)
|
||||
setAnnotations((prev) =>
|
||||
prev.some((a) => a.id === ev.row.id)
|
||||
? prev.map((a) => (a.id === ev.row.id ? ev.row : a))
|
||||
: prev,
|
||||
);
|
||||
} else {
|
||||
setAnnotations((prev) => prev.filter((a) => a.id !== ev.id));
|
||||
// ลบแม่ → replies หายตาม CASCADE ของ DB — เก็บ state ให้เท่ากันด้วย (F9)
|
||||
setReplies((prev) => prev.filter((r) => r.annotationId !== ev.id));
|
||||
}
|
||||
} else if (ev.table === "replies") {
|
||||
if (ev.action === "create") {
|
||||
setReplies((prev) =>
|
||||
prev.some((r) => r.id === ev.row.id) ? prev : [...prev, ev.row],
|
||||
);
|
||||
} else {
|
||||
setReplies((prev) => prev.filter((r) => r.id !== ev.id));
|
||||
}
|
||||
} else {
|
||||
if (ev.action === "create") {
|
||||
setBookmarks((prev) =>
|
||||
prev.some((b) => b.id === ev.row.id) ? prev : [...prev, ev.row],
|
||||
);
|
||||
} else if (ev.action === "update") {
|
||||
// N2: แก้ชื่อ/โน้ต/ย้ายหัวข้อ (รวมลูกที่ถูกยกขึ้น root ตอนแม่ถูกลบ) — replace by id
|
||||
setBookmarks((prev) =>
|
||||
prev.some((b) => b.id === ev.row.id)
|
||||
? prev.map((b) => (b.id === ev.row.id ? ev.row : b))
|
||||
: prev,
|
||||
);
|
||||
} else {
|
||||
setBookmarks((prev) => prev.filter((b) => b.id !== ev.id));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return () => {
|
||||
es.close();
|
||||
};
|
||||
}, [fileId]);
|
||||
|
||||
return { annotations, bookmarks, replies, setAnnotations, setBookmarks, setReplies };
|
||||
}
|
||||
60
hooks/useHistory.ts
Normal file
60
hooks/useHistory.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
|
||||
// Undo/Redo (PLAN-FEAT.md §3 F2, D10) — history ฝั่ง client ต่อ session ต่อผู้ใช้
|
||||
// ไม่เก็บใน DB; push เฉพาะ action ของตัวเองที่สำเร็จเท่านั้น (event ของคนอื่นไม่เกี่ยว)
|
||||
// ตัว entry เก็บ closure ที่เรียก Server Action + sync state เอง — id ของ annotation
|
||||
// ที่ "สร้างใหม่" หลัง undo ต้องส่งต่อให้ redo ได้ จึงให้ entry เป็น object ที่แก้ field ได้
|
||||
|
||||
export type HistoryEntry = {
|
||||
undo: () => Promise<void>;
|
||||
redo: () => Promise<void>;
|
||||
};
|
||||
|
||||
export function useHistory() {
|
||||
// source of truth เป็น ref — state ที่เปลี่ยนแค่ไว้ trigger re-render ปุ่ม disabled
|
||||
const undoRef = useRef<HistoryEntry[]>([]);
|
||||
const redoRef = useRef<HistoryEntry[]>([]);
|
||||
const [, bump] = useState(0);
|
||||
|
||||
/** บันทึก action ที่สำเร็จ — action ใหม่เคลียร์ redo stack เสมอ */
|
||||
const push = useCallback((entry: HistoryEntry) => {
|
||||
undoRef.current.push(entry);
|
||||
redoRef.current = [];
|
||||
bump((n) => n + 1);
|
||||
}, []);
|
||||
|
||||
const undo = useCallback(async () => {
|
||||
const entry = undoRef.current.pop();
|
||||
if (!entry) return;
|
||||
try {
|
||||
await entry.undo();
|
||||
redoRef.current.push(entry);
|
||||
} catch (err) {
|
||||
// เช่น undo ของ create แต่ row ถูกลบไปแล้วโดยแท็บอื่น → ข้าม ไม่ crash (§5)
|
||||
console.error("undo ไม่สำเร็จ — ข้ามรายการนี้", err);
|
||||
}
|
||||
bump((n) => n + 1);
|
||||
}, []);
|
||||
|
||||
const redo = useCallback(async () => {
|
||||
const entry = redoRef.current.pop();
|
||||
if (!entry) return;
|
||||
try {
|
||||
await entry.redo();
|
||||
undoRef.current.push(entry);
|
||||
} catch (err) {
|
||||
console.error("redo ไม่สำเร็จ — ข้ามรายการนี้", err);
|
||||
}
|
||||
bump((n) => n + 1);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
push,
|
||||
undo,
|
||||
redo,
|
||||
canUndo: undoRef.current.length > 0,
|
||||
canRedo: redoRef.current.length > 0,
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue