84 lines
3.5 KiB
TypeScript
84 lines
3.5 KiB
TypeScript
|
|
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 };
|
||
|
|
}
|