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 47s
Deploy eCourt Note Web to Server / Deploy eCourt Note Web to Server (push) Successful in 3s
Deploy eCourt Note Web to Server / Notify Deployment Status (push) Successful in 1s
- utils/user.ts: MOCK_USERS (userId คงที่) + setLocalUser/getStoredLocalUser เขียนลง localStorage key เดิม — PDFViewer อ่าน getLocalUser() เหมือนเดิม - components/UserPicker.tsx: dropdown เลือกผู้ใช้ก่อนเข้า annotate (ยังไม่มี identity = default คนแรก, identity สุ่มเดิมแสดงเป็น option ชั่วคราว) - components/Select.tsx: select ถอดลูกศร native (ชิดขอบ) ใส่ ChevronDown เอง ใช้ที่ UserPicker + ตัวกรองรายคน + เลือกหัวข้อ bookmark 2 จุด - app/page.tsx: header monogram, การ์ดไฟล์ hover, empty state ใหม่ Co-Authored-By: Claude Code <noreply@anthropic.com>
833 lines
35 KiB
TypeScript
833 lines
35 KiB
TypeScript
"use client";
|
|
|
|
import { useRef, useState, type FormEvent } from "react";
|
|
import { createPortal } from "react-dom";
|
|
import {
|
|
ArrowRight,
|
|
Bookmark,
|
|
Check,
|
|
ChevronDown,
|
|
ChevronRight,
|
|
Circle,
|
|
Clock,
|
|
Copy,
|
|
EyeOff,
|
|
Highlighter,
|
|
MessageSquare,
|
|
Minus,
|
|
Pencil,
|
|
Plus,
|
|
Search,
|
|
Square,
|
|
Stamp,
|
|
StickyNote,
|
|
Trash2,
|
|
Type,
|
|
Underline,
|
|
Strikethrough,
|
|
X,
|
|
} from "lucide-react";
|
|
|
|
import Select from "../Select";
|
|
|
|
import {
|
|
listVersions,
|
|
restoreVersion,
|
|
searchAnnotations,
|
|
} from "@/app/actions/annotations";
|
|
import type { AnnotationVersionDTO } from "@/db/schema";
|
|
import type {
|
|
AnnotationData,
|
|
AnnotationDTO,
|
|
BookmarkDTO,
|
|
CommentData,
|
|
DrawData,
|
|
HighlightData,
|
|
RedactionData,
|
|
ShapeData,
|
|
} from "@/db/schema";
|
|
import { annotationNote, annotationPreviewText } from "@/utils/annotation-display";
|
|
|
|
function TypeIcon({ type, data }: { type: AnnotationDTO["type"]; data: AnnotationDTO["data"] }) {
|
|
const cls = "h-4 w-4 shrink-0 text-slate-400";
|
|
if (type === "HIGHLIGHT") {
|
|
const style = (data as HighlightData).style ?? "highlight";
|
|
if (style === "underline") return <Underline className={cls} />;
|
|
if (style === "strikethrough") return <Strikethrough className={cls} />;
|
|
return <Highlighter className={cls} />; // highlight / squiggle
|
|
}
|
|
if (type === "DRAW")
|
|
return (data as DrawData).pen === "highlighter" ? (
|
|
<Highlighter className={cls} />
|
|
) : (
|
|
<Pencil className={cls} />
|
|
);
|
|
if (type === "REDACTION") return <EyeOff className={cls} />;
|
|
if (type === "TEXTBOX") return <Type className={cls} />;
|
|
if (type === "STAMP") return <Stamp className={cls} />;
|
|
if (type === "SHAPE") {
|
|
const shape = (data as ShapeData).shape;
|
|
if (shape === "rect") return <Square className={cls} />;
|
|
if (shape === "ellipse") return <Circle className={cls} />;
|
|
if (shape === "line") return <Minus className={cls} />;
|
|
return <ArrowRight className={cls} />;
|
|
}
|
|
return <MessageSquare className={cls} />;
|
|
}
|
|
|
|
/** ใส่/ล้างโน้ตคำอธิบายใน data ของ annotation (ทุกชนิดยกเว้น COMMENT — message ทำหน้าที่นี้แล้ว) */
|
|
function withNote(data: Exclude<AnnotationData, CommentData>, note: string): AnnotationData {
|
|
return note ? { ...data, note } : { ...data, note: undefined };
|
|
}
|
|
|
|
// ─── N2: helper โครงสร้างต้นไม้ bookmark (3 ระดับ) — คำนวณบน state ที่โหลดครบ ────
|
|
// (ปกติลึกสุด 3 — cap กันลูปไม่รู้จบหากข้อมูลวนซ้ำ)
|
|
|
|
/** ความลึกของ bookmark (root = 1) — เดินขึ้นตาม parentId (parent หาย = ถือเป็น root) */
|
|
function bookmarkDepth(b: BookmarkDTO, byId: Map<string, BookmarkDTO>): number {
|
|
let depth = 1;
|
|
let cur: BookmarkDTO | undefined = b;
|
|
while (cur?.parentId && depth <= 100) {
|
|
const parent = byId.get(cur.parentId);
|
|
if (!parent) break;
|
|
depth += 1;
|
|
cur = parent;
|
|
}
|
|
return depth;
|
|
}
|
|
|
|
/** ความสูงของ subtree รวมตัวเอง (leaf = 1) */
|
|
function subtreeHeight(b: BookmarkDTO, kidsOf: (id: string) => BookmarkDTO[], level = 1): number {
|
|
if (level > 100) return 1; // กันข้อมูลวนซ้ำ
|
|
const kids = kidsOf(b.id);
|
|
return kids.length === 0
|
|
? 1
|
|
: 1 + Math.max(...kids.map((k) => subtreeHeight(k, kidsOf, level + 1)));
|
|
}
|
|
|
|
/** id เป็นลูกหลานของ ancestorId หรือไม่ (เดินขึ้นจาก id) */
|
|
function isDescendantOf(id: string, ancestorId: string, byId: Map<string, BookmarkDTO>): boolean {
|
|
let cur = byId.get(id);
|
|
for (let steps = 0; cur?.parentId && steps <= 100; steps += 1) {
|
|
if (cur.parentId === ancestorId) return true;
|
|
cur = byId.get(cur.parentId);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/** แท็บ Bookmarks / Annotations + ปุ่ม scroll-to + ปุ่มลบเฉพาะรายการของตัวเอง */
|
|
export default function AnnotationSidebar({
|
|
annotations,
|
|
bookmarks,
|
|
fileId,
|
|
userId,
|
|
userFilter,
|
|
users,
|
|
onUserFilterChange,
|
|
replyCounts,
|
|
onDeleteAnnotation,
|
|
onUpdateAnnotation,
|
|
onDeleteBookmark,
|
|
onAddSubBookmark,
|
|
onUpdateBookmark,
|
|
onGoToBookmark,
|
|
onGoToAnnotation,
|
|
}: {
|
|
annotations: AnnotationDTO[];
|
|
bookmarks: BookmarkDTO[];
|
|
/** ไฟล์ที่เปิดอยู่ — ใช้ค้นหาหมายเหตุทั้งไฟล์ (F14) */
|
|
fileId: string;
|
|
userId: string;
|
|
/** F4: "all" | "mine" | <userId> — กรองฝั่ง client ล้วน */
|
|
userFilter: string;
|
|
users: { userId: string; userName: string }[];
|
|
onUserFilterChange: (value: string) => void;
|
|
/** F9: จำนวน reply ต่อ annotation (แสดงเฉพาะ COMMENT) */
|
|
replyCounts: Record<string, number>;
|
|
onDeleteAnnotation: (id: string) => void;
|
|
/** แก้ไข data ของรายการของตัวเอง (โน้ตคำอธิบาย) — parent ทำ optimistic + updateAnnotation */
|
|
onUpdateAnnotation: (id: string, data: AnnotationData) => void;
|
|
onDeleteBookmark: (id: string) => void;
|
|
/** N2: ปุ่ม + ที่หัวข้อ — เพิ่ม bookmark หน้าที่ viewer ดูอยู่เข้าใต้หัวข้อนั้นทันที */
|
|
onAddSubBookmark: (parentId: string) => void;
|
|
/** N2: แก้ bookmark ของตัวเอง — ชื่อ/โน้ต/ย้ายหัวข้อ (patch เฉพาะ field ที่เปลี่ยน) */
|
|
onUpdateBookmark: (
|
|
id: string,
|
|
patch: { title?: string; note?: string | null; parentId?: string | null },
|
|
) => void;
|
|
onGoToBookmark: (b: BookmarkDTO) => void;
|
|
onGoToAnnotation: (a: AnnotationDTO) => void;
|
|
}) {
|
|
const [tab, setTab] = useState<"bookmarks" | "annotations">("bookmarks");
|
|
|
|
// ─── F14/D1: ค้นหาคำในหมายเหตุทั้งไฟล์ (highlight text / comment message) ────
|
|
const [searchQuery, setSearchQuery] = useState("");
|
|
const [searchResults, setSearchResults] = useState<AnnotationDTO[] | null>(null);
|
|
const [searching, setSearching] = useState(false);
|
|
|
|
async function runSearch(e: FormEvent) {
|
|
e.preventDefault();
|
|
const q = searchQuery.trim();
|
|
if (!q || !fileId) return;
|
|
setSearching(true);
|
|
try {
|
|
setSearchResults(await searchAnnotations(fileId, q));
|
|
} catch (err) {
|
|
console.error("ค้นหาไม่สำเร็จ", err);
|
|
setSearchResults([]);
|
|
} finally {
|
|
setSearching(false);
|
|
}
|
|
}
|
|
|
|
// ─── F19/A7: ประวัติ/กู้คืน — dialog ต่อรายการ (แสดงเฉพาะรายการของตัวเอง) ─────
|
|
const [historyFor, setHistoryFor] = useState<AnnotationDTO | null>(null);
|
|
const [versions, setVersions] = useState<AnnotationVersionDTO[] | null>(null);
|
|
const [restoringId, setRestoringId] = useState<string | null>(null);
|
|
const [historyError, setHistoryError] = useState<string | null>(null);
|
|
|
|
async function openHistory(a: AnnotationDTO) {
|
|
setHistoryFor(a);
|
|
setVersions(null);
|
|
setHistoryError(null);
|
|
try {
|
|
setVersions(await listVersions(a.id));
|
|
} catch (err) {
|
|
setHistoryError(err instanceof Error ? err.message : "โหลดประวัติไม่สำเร็จ");
|
|
}
|
|
}
|
|
|
|
/** กู้คืน = เขียนกลับ row เดิม (ต้นทางถูกลบไปแล้ว = สร้างใหม่ id ใหม่)
|
|
* sync ผ่าน SSE update/create event (รวมแท็บนี้) */
|
|
async function handleRestore(versionId: string) {
|
|
setRestoringId(versionId);
|
|
try {
|
|
await restoreVersion(versionId, userId);
|
|
setHistoryFor(null);
|
|
} catch (err) {
|
|
setHistoryError(err instanceof Error ? err.message : "กู้คืนไม่สำเร็จ");
|
|
} finally {
|
|
setRestoringId(null);
|
|
}
|
|
}
|
|
|
|
// F10/A5: แยกกลุ่มเฉพาะ COMMENT — "ค้างดำเนินการ" (รวม annotation ชนิดอื่น)
|
|
// กับ "จัดการแล้ว" (comment ที่ถูก resolve)
|
|
const pendingItems = annotations.filter((a) => a.type !== "COMMENT" || !a.resolved);
|
|
const resolvedItems = annotations.filter((a) => a.type === "COMMENT" && a.resolved);
|
|
|
|
// F12/C4: คัดลอก deep link ไปยัง annotation นี้ (?page= + ?ann=)
|
|
const [copiedId, setCopiedId] = useState<string | null>(null);
|
|
// FIX-7: clipboard ถูก block / ไม่มี API → แสดง feedback fail
|
|
// 1.5s แทนกลืน error เงียบ — timer เก็บใน ref กันคลิกรัว ๆ แล้ว feedback หายก่อนเวลา
|
|
const [copyFailedId, setCopyFailedId] = useState<string | null>(null);
|
|
const copyTimerRef = useRef<number | null>(null);
|
|
function copyAnnotationLink(a: AnnotationDTO) {
|
|
const url = `${window.location.origin}/annotate/${a.fileId}?page=${a.pageIndex + 1}&ann=${a.id}`;
|
|
if (copyTimerRef.current !== null) {
|
|
window.clearTimeout(copyTimerRef.current);
|
|
copyTimerRef.current = null;
|
|
}
|
|
const show = (ok: boolean) => {
|
|
setCopiedId(ok ? a.id : null);
|
|
setCopyFailedId(ok ? null : a.id);
|
|
copyTimerRef.current = window.setTimeout(() => {
|
|
copyTimerRef.current = null;
|
|
setCopiedId(null);
|
|
setCopyFailedId(null);
|
|
}, 1500);
|
|
};
|
|
// บริบทที่ไม่มี clipboard API (HTTP ที่ไม่ใช่ localhost) — optional chaining
|
|
// จะตัดทั้ง chain ทิ้ง ต้องตรวจก่อนถึงจะมี feedback fail จริง
|
|
if (!navigator.clipboard?.writeText) {
|
|
show(false);
|
|
return;
|
|
}
|
|
navigator.clipboard.writeText(url).then(
|
|
() => show(true),
|
|
() => show(false),
|
|
);
|
|
}
|
|
|
|
// ─── โน้ตคำอธิบายของรายการ (N1) — แก้ไขเฉพาะรายการของตัวเอง แสดงให้ทุกคน ─────
|
|
const [noteFor, setNoteFor] = useState<AnnotationDTO["id"] | null>(null);
|
|
const [noteDraft, setNoteDraft] = useState("");
|
|
|
|
function startNoteEdit(a: AnnotationDTO) {
|
|
setNoteFor(a.id);
|
|
setNoteDraft(annotationNote(a));
|
|
}
|
|
|
|
function saveNote(a: AnnotationDTO) {
|
|
const msg = noteDraft.trim();
|
|
setNoteFor(null);
|
|
if (a.type === "COMMENT" || msg === annotationNote(a)) return; // ไม่มีอะไรเปลี่ยน
|
|
onUpdateAnnotation(a.id, withNote(a.data as Exclude<AnnotationData, CommentData>, msg));
|
|
}
|
|
|
|
// ─── N2: bookmark ต้นไม้ 3 ระดับ (หมุดหัวข้อใหญ่ + sub + sub-sub) + edit inline ──
|
|
const [collapsedIds, setCollapsedIds] = useState<Set<string>>(new Set());
|
|
const bookmarkById = new Map(bookmarks.map((b) => [b.id, b] as const));
|
|
const [editBookmark, setEditBookmark] = useState<{
|
|
id: string;
|
|
title: string;
|
|
note: string;
|
|
parentId: string | null;
|
|
} | null>(null);
|
|
|
|
function toggleCollapsed(id: string) {
|
|
setCollapsedIds((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(id)) next.delete(id);
|
|
else next.add(id);
|
|
return next;
|
|
});
|
|
}
|
|
|
|
function startBookmarkEdit(b: BookmarkDTO) {
|
|
setEditBookmark({
|
|
id: b.id,
|
|
title: b.title,
|
|
note: b.note ?? "",
|
|
parentId: b.parentId,
|
|
});
|
|
}
|
|
|
|
function saveBookmarkEdit(b: BookmarkDTO) {
|
|
if (!editBookmark || editBookmark.id !== b.id) return;
|
|
const patch: { title?: string; note?: string | null; parentId?: string | null } = {};
|
|
const title = editBookmark.title.trim();
|
|
if (title && title !== b.title) patch.title = title;
|
|
const note = editBookmark.note.trim();
|
|
if (note !== (b.note ?? "")) patch.note = note || null;
|
|
if (editBookmark.parentId !== (b.parentId ?? null)) {
|
|
patch.parentId = editBookmark.parentId;
|
|
}
|
|
setEditBookmark(null);
|
|
if (Object.keys(patch).length > 0) onUpdateBookmark(b.id, patch);
|
|
}
|
|
|
|
const renderBookmarkNode = (b: BookmarkDTO, depth: number) => {
|
|
const children = bookmarks.filter((x) => x.parentId === b.id);
|
|
const hasChildren = children.length > 0;
|
|
const collapsed = collapsedIds.has(b.id);
|
|
const editing = editBookmark?.id === b.id;
|
|
// FIX-9: guard null ครั้งเดียวต้น branch แทน editBookmark! ซ้ำ 6 จุด
|
|
const eb = editing ? editBookmark : null;
|
|
// เป้าหมายที่ย้ายเข้าได้ = ไม่ใช่ตัวเอง/ลูกหลาน และความลึกแม่ + ความสูง subtree ตัวเอง ≤ 3 ระดับ
|
|
// (server ตรวจซ้ำอีกชั้นพร้อม lock — ตรงเกณฑ์เดียวกัน)
|
|
// FIX-11: คำนวณเฉพาะตอนเปิดแก้หัวข้อนี้ — ไล่ subtree ทุก node ทุก render เปลืองเปล่า
|
|
const movableTo = editing
|
|
? bookmarks.filter(
|
|
(t) =>
|
|
t.id !== b.id &&
|
|
!isDescendantOf(t.id, b.id, bookmarkById) &&
|
|
bookmarkDepth(t, bookmarkById) +
|
|
subtreeHeight(b, (id) => bookmarks.filter((x) => x.parentId === id)) <=
|
|
3,
|
|
)
|
|
: [];
|
|
return (
|
|
<li
|
|
key={b.id}
|
|
className={`group rounded hover:bg-slate-100 ${
|
|
depth === 1 ? "px-1 py-0.5" : `py-0.5 pr-1 ${depth === 2 ? "pl-5" : "pl-9"}`
|
|
}`}
|
|
>
|
|
{eb ? (
|
|
<div className="py-1">
|
|
<input
|
|
autoFocus
|
|
value={eb.title}
|
|
onChange={(e) => setEditBookmark({ ...eb, title: e.target.value })}
|
|
onKeyDown={(e) => {
|
|
e.stopPropagation();
|
|
if (e.key === "Enter") saveBookmarkEdit(b);
|
|
if (e.key === "Escape") setEditBookmark(null);
|
|
}}
|
|
placeholder="ชื่อหัวข้อ"
|
|
className="w-full rounded border border-slate-300 px-1.5 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-slate-400"
|
|
/>
|
|
<textarea
|
|
rows={2}
|
|
value={eb.note}
|
|
onChange={(e) => setEditBookmark({ ...eb, note: e.target.value })}
|
|
onKeyDown={(e) => {
|
|
e.stopPropagation();
|
|
if (e.key === "Escape") setEditBookmark(null);
|
|
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) saveBookmarkEdit(b);
|
|
}}
|
|
placeholder="โน้ต (ไม่บังคับ)"
|
|
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
|
|
value={eb.parentId ?? ""}
|
|
onChange={(e) =>
|
|
setEditBookmark({ ...eb, parentId: e.target.value || null })
|
|
}
|
|
onKeyDown={(e) => e.stopPropagation()}
|
|
className="mt-0.5 w-full rounded border border-slate-300 bg-white px-1.5 py-1 text-[11px] focus:outline-none focus:ring-1 focus:ring-slate-400"
|
|
>
|
|
<option value="">ระดับบนสุด (หมุดหัวข้อใหญ่)</option>
|
|
{movableTo.map((h) => (
|
|
<option key={h.id} value={h.id}>
|
|
ใต้ "{h.title}"
|
|
</option>
|
|
))}
|
|
</Select>
|
|
<div className="mt-1.5 flex justify-end gap-1.5 text-[11px]">
|
|
<button
|
|
type="button"
|
|
className="rounded px-2 py-0.5 text-slate-500 hover:bg-slate-100"
|
|
onClick={() => setEditBookmark(null)}
|
|
>
|
|
ยกเลิก
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="rounded bg-primary px-2 py-0.5 font-medium text-white"
|
|
onClick={() => saveBookmarkEdit(b)}
|
|
>
|
|
บันทึก
|
|
</button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="flex items-center gap-1.5 py-1">
|
|
{hasChildren ? (
|
|
<button
|
|
type="button"
|
|
aria-label={collapsed ? "กางหัวข้อ" : "หุบหัวข้อ"}
|
|
className="shrink-0 text-slate-400 hover:text-slate-600"
|
|
onClick={() => toggleCollapsed(b.id)}
|
|
>
|
|
{collapsed ? (
|
|
<ChevronRight className="h-3.5 w-3.5" />
|
|
) : (
|
|
<ChevronDown className="h-3.5 w-3.5" />
|
|
)}
|
|
</button>
|
|
) : (
|
|
<span className="w-3.5 shrink-0" />
|
|
)}
|
|
<button
|
|
type="button"
|
|
className="flex min-w-0 flex-1 items-start gap-1.5 text-left"
|
|
onClick={() => onGoToBookmark(b)}
|
|
>
|
|
<Bookmark
|
|
className={`h-3.5 w-3.5 shrink-0 ${
|
|
depth === 1 ? "text-amber-500" : "text-amber-300"
|
|
}`}
|
|
/>
|
|
<span className="min-w-0">
|
|
<span
|
|
className={`block truncate ${
|
|
depth === 1 ? "text-xs font-medium text-slate-700" : "text-xs"
|
|
}`}
|
|
>
|
|
{b.title}
|
|
{hasChildren && (
|
|
<span className="ml-1 font-normal text-slate-400">({children.length})</span>
|
|
)}
|
|
</span>
|
|
<span className="block text-[10px] text-slate-400">หน้า {b.pageIndex + 1}</span>
|
|
{b.note && (
|
|
<span className="mt-0.5 block line-clamp-2 whitespace-pre-wrap break-words text-[11px] text-slate-500">
|
|
{b.note}
|
|
</span>
|
|
)}
|
|
</span>
|
|
</button>
|
|
{b.userId === userId && (
|
|
<>
|
|
<button
|
|
type="button"
|
|
aria-label="แก้ไข bookmark"
|
|
title="แก้ชื่อ/โน้ต/ย้ายหัวข้อ"
|
|
className="shrink-0 text-slate-300 hover:text-slate-500 group-hover:text-slate-400"
|
|
onClick={() => startBookmarkEdit(b)}
|
|
>
|
|
<Pencil className="h-3.5 w-3.5" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
aria-label="ลบ bookmark"
|
|
className="shrink-0 text-slate-300 hover:text-red-500 group-hover:text-slate-400"
|
|
onClick={() => onDeleteBookmark(b.id)}
|
|
>
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
</button>
|
|
</>
|
|
)}
|
|
{/* ปุ่ม + ตอน depth < 3 — ดัก bookmark หน้าที่ viewer ดูอยู่เข้าใต้หัวข้อนี้ */}
|
|
{depth < 3 && userId !== "" && (
|
|
<button
|
|
type="button"
|
|
aria-label="เพิ่มหน้าที่ดูอยู่เป็น sub ของหัวข้อนี้"
|
|
title="เพิ่มหน้าที่ดูอยู่เป็น sub"
|
|
className="shrink-0 text-slate-300 hover:text-emerald-600 group-hover:text-slate-400"
|
|
onClick={() => onAddSubBookmark(b.id)}
|
|
>
|
|
<Plus className="h-3.5 w-3.5" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
{hasChildren && !collapsed && (
|
|
<ul className="space-y-0.5">
|
|
{children.map((c) => renderBookmarkNode(c, depth + 1))}
|
|
</ul>
|
|
)}
|
|
</li>
|
|
);
|
|
};
|
|
|
|
const renderAnnotationRow = (a: AnnotationDTO) => {
|
|
const note = annotationNote(a);
|
|
const editingNote = noteFor === a.id;
|
|
return (
|
|
<li
|
|
key={a.id}
|
|
className={`group rounded px-2 py-1.5 hover:bg-slate-100 ${
|
|
a.resolved ? "opacity-60" : ""
|
|
}`}
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
type="button"
|
|
className="flex min-w-0 flex-1 items-start gap-2 text-left"
|
|
onClick={() => {
|
|
// ระหว่างแก้โน้ต: คลิกข้อความไม่กระโดดหน้า (กัน scroll หนีตอน editor เปิดอยู่)
|
|
if (editingNote) return;
|
|
onGoToAnnotation(a);
|
|
}}
|
|
>
|
|
<TypeIcon type={a.type} data={a.data} />
|
|
<span className="min-w-0">
|
|
<span className="block truncate text-xs">{annotationPreviewText(a)}</span>
|
|
<span className="block text-[11px] text-slate-400">
|
|
หน้า {a.pageIndex + 1} · {a.userName}
|
|
{a.type === "COMMENT" && (replyCounts[a.id] ?? 0) > 0
|
|
? ` · ตอบ ${replyCounts[a.id]}`
|
|
: ""}
|
|
</span>
|
|
</span>
|
|
</button>
|
|
{/* F12/C4: คัดลอก deep link ไปยังตำแหน่ง annotation นี้ */}
|
|
<button
|
|
type="button"
|
|
aria-label="คัดลอกลิงก์ไปยัง annotation นี้"
|
|
title={copyFailedId === a.id ? "คัดลอกไม่ได้ (เบราว์เซอร์บล็อกคลิปบอร์ด)" : "คัดลอกลิงก์"}
|
|
className={`shrink-0 ${
|
|
copiedId === a.id
|
|
? "text-emerald-500"
|
|
: copyFailedId === a.id
|
|
? "text-red-500"
|
|
: "text-slate-300 hover:text-slate-500 group-hover:text-slate-400"
|
|
}`}
|
|
onClick={() => copyAnnotationLink(a)}
|
|
>
|
|
{copiedId === a.id ? (
|
|
<Check className="h-4 w-4" />
|
|
) : copyFailedId === a.id ? (
|
|
<X className="h-4 w-4" />
|
|
) : (
|
|
<Copy className="h-4 w-4" />
|
|
)}
|
|
</button>
|
|
{a.userId === userId && (
|
|
<>
|
|
{/* N1: โน้ตคำอธิบาย (เหตุผลของรายการ) — ยกเว้น COMMENT ที่มี message อยู่แล้ว */}
|
|
{a.type !== "COMMENT" && (
|
|
<button
|
|
type="button"
|
|
aria-label={note ? "แก้ไขโน้ต" : "เพิ่มโน้ต"}
|
|
title="โน้ต"
|
|
className={`shrink-0 ${
|
|
note
|
|
? "text-amber-400 hover:text-amber-500"
|
|
: "text-slate-300 hover:text-slate-500 group-hover:text-slate-400"
|
|
}`}
|
|
onClick={() => (editingNote ? setNoteFor(null) : startNoteEdit(a))}
|
|
>
|
|
<StickyNote className="h-4 w-4" />
|
|
</button>
|
|
)}
|
|
{/* F19/A7: ประวัติการเปลี่ยนแปลง + กู้คืน — เฉพาะรายการของตัวเอง */}
|
|
<button
|
|
type="button"
|
|
aria-label="ดูประวัติการเปลี่ยนแปลง"
|
|
title="ประวัติ"
|
|
className="shrink-0 text-slate-300 hover:text-slate-500 group-hover:text-slate-400"
|
|
onClick={() => void openHistory(a)}
|
|
>
|
|
<Clock className="h-4 w-4" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
aria-label="ลบ annotation"
|
|
className="shrink-0 text-slate-300 hover:text-red-500 group-hover:text-slate-400"
|
|
onClick={() => onDeleteAnnotation(a.id)}
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
{editingNote ? (
|
|
<div className="mt-1 pl-6">
|
|
<textarea
|
|
autoFocus
|
|
rows={2}
|
|
value={noteDraft}
|
|
onChange={(e) => setNoteDraft(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
e.stopPropagation(); // ไม่ให้ปุ่มใน textarea ชนคีย์ลัดของ viewer
|
|
if (e.key === "Escape") setNoteFor(null);
|
|
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) saveNote(a);
|
|
}}
|
|
placeholder="โน้ต เช่น เหตุผลที่ทำรายการนี้…"
|
|
className="w-full resize-none rounded border border-slate-300 p-1.5 text-[11px] focus:outline-none focus:ring-1 focus:ring-slate-400"
|
|
/>
|
|
<div className="mt-1 flex justify-end gap-1.5 text-[11px]">
|
|
<button
|
|
type="button"
|
|
className="rounded px-2 py-0.5 text-slate-500 hover:bg-slate-100"
|
|
onClick={() => setNoteFor(null)}
|
|
>
|
|
ยกเลิก
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="rounded bg-primary px-2 py-0.5 font-medium text-white"
|
|
onClick={() => saveNote(a)}
|
|
>
|
|
บันทึก
|
|
</button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
note && (
|
|
<p className="mt-0.5 flex items-start gap-1 pl-6 text-[11px] text-slate-500">
|
|
<StickyNote className="mt-0.5 h-3 w-3 shrink-0 text-amber-400" />
|
|
<span className="line-clamp-2 whitespace-pre-wrap break-words">{note}</span>
|
|
</p>
|
|
)
|
|
)}
|
|
</li>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<div className="flex h-full flex-col">
|
|
{/* F4: กรองแสดง annotation ตามผู้เขียน (viewer + sidebar พร้อมกัน) */}
|
|
<div className="shrink-0 border-b border-slate-200 p-2">
|
|
<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}
|
|
onChange={(e) => onUserFilterChange(e.target.value)}
|
|
>
|
|
<option value="all">แสดง annotation ของทุกคน</option>
|
|
<option value="mine">เฉพาะของฉัน</option>
|
|
{users.map((u) => (
|
|
<option key={u.userId} value={u.userId}>
|
|
เฉพาะ {u.userName}
|
|
</option>
|
|
))}
|
|
</Select>
|
|
</div>
|
|
|
|
{/* F14/D1: ค้นหาในหมายเหตุทั้งไฟล์ — คลิกผลลัพธ์ = กระโดดไปตำแหน่ง + เลือก */}
|
|
<form
|
|
className="flex shrink-0 gap-1 border-b border-slate-200 p-2"
|
|
onSubmit={(e) => void runSearch(e)}
|
|
>
|
|
<div className="relative min-w-0 flex-1">
|
|
<Search className="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-400" />
|
|
<input
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
onKeyDown={(e) => e.stopPropagation()} // ไม่ให้ปุ่มในช่องค้นหาชนคีย์ลัดของ viewer
|
|
placeholder="ค้นหาในหมายเหตุ…"
|
|
className="w-full rounded-md border border-slate-300 py-1.5 pl-7 pr-2 text-xs focus:outline-none focus:ring-2 focus:ring-slate-400"
|
|
/>
|
|
</div>
|
|
<button
|
|
type="submit"
|
|
disabled={!searchQuery.trim() || searching}
|
|
className="shrink-0 rounded-md border border-slate-300 px-2 py-1.5 text-xs text-slate-600 hover:bg-slate-100 disabled:opacity-40"
|
|
>
|
|
{searching ? "…" : "หา"}
|
|
</button>
|
|
</form>
|
|
|
|
{searchResults !== null && (
|
|
<div className="shrink-0 border-b border-slate-200 p-2">
|
|
<p className="px-1 pb-1 text-[10px] font-semibold uppercase tracking-wide text-slate-400">
|
|
{searchResults.length > 0
|
|
? `ผลการค้นหา (${searchResults.length})`
|
|
: "ผลการค้นหา"}
|
|
</p>
|
|
{searchResults.length === 0 ? (
|
|
<p className="px-1 pb-1 text-xs text-slate-400">ไม่พบ</p>
|
|
) : (
|
|
<ul className="max-h-40 space-y-1 overflow-y-auto">
|
|
{searchResults.map((a) => (
|
|
<li key={a.id}>
|
|
<button
|
|
type="button"
|
|
className="flex w-full items-start gap-2 rounded px-1 py-1 text-left hover:bg-slate-100"
|
|
onClick={() => onGoToAnnotation(a)}
|
|
>
|
|
<TypeIcon type={a.type} data={a.data} />
|
|
<span className="min-w-0">
|
|
<span className="block truncate text-xs">{annotationPreviewText(a)}</span>
|
|
<span className="block text-[11px] text-slate-400">
|
|
หน้า {a.pageIndex + 1} · {a.userName}
|
|
</span>
|
|
</span>
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex shrink-0 border-b border-slate-200">
|
|
{(
|
|
[
|
|
["bookmarks", `Bookmarks (${bookmarks.length})`],
|
|
["annotations", `Annotations (${annotations.length})`],
|
|
] as const
|
|
).map(([key, label]) => (
|
|
<button
|
|
key={key}
|
|
type="button"
|
|
className={`flex-1 px-2 py-2 text-xs font-medium transition ${
|
|
tab === key
|
|
? "border-b-2 border-primary text-slate-900"
|
|
: "text-slate-400 hover:text-slate-600"
|
|
}`}
|
|
onClick={() => setTab(key)}
|
|
>
|
|
{label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-y-auto p-2">
|
|
{tab === "bookmarks" ? (
|
|
bookmarks.length === 0 ? (
|
|
<p className="p-4 text-center text-xs text-slate-400">
|
|
ยังไม่มี bookmark — กดปุ่ม bookmark บน toolbar เพื่อเพิ่ม
|
|
</p>
|
|
) : (
|
|
<ul className="space-y-0.5">
|
|
{/* N2: root = หมุดหัวข้อใหญ่ เรียงตามเวลาสร้าง — sub render ซ้อนใต้ผ่าน renderBookmarkNode */}
|
|
{bookmarks
|
|
.filter((b) => !b.parentId)
|
|
.map((b) => renderBookmarkNode(b, 1))}
|
|
</ul>
|
|
)
|
|
) : annotations.length === 0 ? (
|
|
<p className="p-4 text-center text-xs text-slate-400">
|
|
ยังไม่มี annotation — คลุมดำ / วาดเส้น / คลิกวาง comment บนหน้าเอกสาร
|
|
</p>
|
|
) : (
|
|
<>
|
|
{pendingItems.length > 0 && (
|
|
<section>
|
|
<p className="px-2 pb-1 pt-2 text-[10px] font-semibold uppercase tracking-wide text-slate-400">
|
|
ค้างดำเนินการ
|
|
</p>
|
|
<ul className="space-y-1">{pendingItems.map(renderAnnotationRow)}</ul>
|
|
</section>
|
|
)}
|
|
{resolvedItems.length > 0 && (
|
|
<section>
|
|
<p className="px-2 pb-1 pt-3 text-[10px] font-semibold uppercase tracking-wide text-emerald-500">
|
|
จัดการแล้ว
|
|
</p>
|
|
<ul className="space-y-1">{resolvedItems.map(renderAnnotationRow)}</ul>
|
|
</section>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* F19/A7: dialog render ผ่าน portal ไป document.body —
|
|
aside.sticky สร้าง stacking context ครอบ z-50 ของ dialog ไว้ ทำให้ textLayer
|
|
(z-[2] ระดับ root) ทับปุ่มจนคลิกไม่ได้ (เจอตอน verify ด้วยเบราว์เซอร์จริง) */}
|
|
{typeof document === "undefined"
|
|
? null // SSR ไม่มี document — client component ก็ถูก render ฝั่ง server สำหรับ HTML แรก
|
|
: createPortal(
|
|
<>
|
|
{/* F19/A7: dialog ประวัติ — รายการ version (เวลา + ชนิดการเปลี่ยนแปลง) + ปุ่มกู้คืน */}
|
|
{historyFor && (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/40 p-4">
|
|
<div className="w-80 rounded-lg border border-slate-200 bg-white p-4 shadow-xl">
|
|
<p className="text-sm font-semibold">ประวัติการเปลี่ยนแปลง</p>
|
|
<p className="mt-0.5 truncate text-[11px] text-slate-400">
|
|
{annotationPreviewText(historyFor)}
|
|
</p>
|
|
{historyError && (
|
|
<p className="mt-2 rounded bg-red-50 px-2 py-1 text-[11px] text-red-600">
|
|
{historyError}
|
|
</p>
|
|
)}
|
|
{versions === null && !historyError && (
|
|
<p className="mt-3 text-xs text-slate-400">กำลังโหลด…</p>
|
|
)}
|
|
{versions !== null && versions.length === 0 && (
|
|
<p className="mt-3 text-xs text-slate-400">
|
|
ยังไม่มีบันทึกการเปลี่ยนแปลง (เกิดขึ้นเมื่อแก้ไขหรือลบรายการนี้)
|
|
</p>
|
|
)}
|
|
{versions !== null && versions.length > 0 && (
|
|
<ul className="mt-3 max-h-56 divide-y divide-slate-100 overflow-y-auto">
|
|
{versions.map((v) => (
|
|
<li key={v.id} className="flex items-center gap-2 py-2">
|
|
<span className="min-w-0 flex-1">
|
|
<span className="block text-xs font-medium">
|
|
{v.kind === "delete" ? "ถูกลบ" : "ถูกแก้ไข"}
|
|
</span>
|
|
<span className="block text-[11px] text-slate-400" suppressHydrationWarning>
|
|
{new Date(v.createdAt).toLocaleString("th-TH")}
|
|
</span>
|
|
</span>
|
|
<button
|
|
type="button"
|
|
disabled={restoringId !== null}
|
|
className="shrink-0 rounded border border-slate-300 px-2 py-1 text-[11px] text-slate-600 hover:bg-slate-100 disabled:opacity-40"
|
|
onClick={() => void handleRestore(v.id)}
|
|
>
|
|
{restoringId === v.id ? "กำลังกู้คืน…" : "กู้คืน"}
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
<div className="mt-3 flex justify-end text-xs">
|
|
<button
|
|
type="button"
|
|
className="rounded px-3 py-1.5 text-slate-500 hover:bg-slate-100"
|
|
onClick={() => setHistoryFor(null)}
|
|
>
|
|
ปิด
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
</>
|
|
,
|
|
document.body
|
|
)}
|
|
</div>
|
|
);
|
|
}
|