hoàn thành chức năng hiện username annotation của từng user

This commit is contained in:
2026-04-01 20:13:48 +07:00
parent 386871bd4a
commit 217e2f9712
7 changed files with 376 additions and 10 deletions
+9
View File
@@ -151,6 +151,15 @@ export default function AdminPage() {
</div>
<div className="flex items-center gap-3">
<span className="text-sm text-gray-500 hidden sm:block">{me?.username}</span>
<button
onClick={() => router.push("/change-password")}
className="text-sm text-gray-500 hover:text-blue-600 transition"
title="Đổi mật khẩu"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
</button>
<button
onClick={async () => { await api.logout(); router.replace("/login"); }}
className="text-sm text-gray-500 hover:text-red-600 transition"
+200
View File
@@ -0,0 +1,200 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { api } from "@/lib/api";
export default function ChangePasswordPage() {
const router = useRouter();
const [form, setForm] = useState({
current_password: "",
new_password: "",
confirm_password: "",
});
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState(false);
const [showCurrent, setShowCurrent] = useState(false);
const [showNew, setShowNew] = useState(false);
const [showConfirm, setShowConfirm] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError("");
if (form.new_password !== form.confirm_password) {
setError("Mật khẩu mới và xác nhận không khớp.");
return;
}
if (form.new_password.length < 8) {
setError("Mật khẩu mới phải ít nhất 8 ký tự.");
return;
}
setLoading(true);
try {
await api.changePassword({
current_password: form.current_password,
new_password: form.new_password,
});
setSuccess(true);
setForm({ current_password: "", new_password: "", confirm_password: "" });
} catch (e: unknown) {
setError(e instanceof Error ? e.message : "Đổi mật khẩu thất bại.");
} finally {
setLoading(false);
}
}
return (
<div className="min-h-screen bg-gray-50 flex flex-col items-center justify-center px-4">
<div className="w-full max-w-md bg-white rounded-2xl shadow-md border border-gray-200 p-8">
{/* Header */}
<div className="flex items-center gap-3 mb-6">
<button
onClick={() => router.back()}
className="text-gray-400 hover:text-blue-600 transition"
title="Quay lại"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</button>
<h1 className="text-xl font-bold text-gray-900">Đi mật khẩu</h1>
</div>
{/* Success */}
{success && (
<div className="mb-5 bg-green-50 border border-green-200 text-green-700 text-sm px-4 py-3 rounded-lg flex items-center gap-2">
<svg className="w-4 h-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
Đi mật khẩu thành công!
</div>
)}
{/* Error */}
{error && (
<div className="mb-5 bg-red-50 border border-red-200 text-red-700 text-sm px-4 py-3 rounded-lg flex items-center justify-between">
{error}
<button onClick={() => setError("")} className="ml-3 text-red-400 hover:text-red-600"></button>
</div>
)}
<form onSubmit={handleSubmit} className="flex flex-col gap-5">
{/* Current password */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">
Mật khẩu hiện tại
</label>
<div className="relative">
<input
type={showCurrent ? "text" : "password"}
value={form.current_password}
onChange={e => setForm(f => ({ ...f, current_password: e.target.value }))}
required
className="w-full border border-gray-300 rounded-lg px-3 py-2.5 pr-10 text-sm focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
placeholder="Nhập mật khẩu hiện tại"
/>
<button
type="button"
onClick={() => setShowCurrent(v => !v)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
{showCurrent
? <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" /></svg>
: <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" /></svg>
}
</button>
</div>
</div>
{/* New password */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">
Mật khẩu mới
</label>
<div className="relative">
<input
type={showNew ? "text" : "password"}
value={form.new_password}
onChange={e => setForm(f => ({ ...f, new_password: e.target.value }))}
required
minLength={8}
className="w-full border border-gray-300 rounded-lg px-3 py-2.5 pr-10 text-sm focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
placeholder="Ít nhất 8 ký tự"
/>
<button
type="button"
onClick={() => setShowNew(v => !v)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
{showNew
? <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" /></svg>
: <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" /></svg>
}
</button>
</div>
{/* Strength indicator */}
{form.new_password && (
<div className="mt-1.5 flex gap-1">
{[1,2,3,4].map(i => {
const len = form.new_password.length;
const hasUpper = /[A-Z]/.test(form.new_password);
const hasSpecial = /[^A-Za-z0-9]/.test(form.new_password);
const score = (len >= 8 ? 1 : 0) + (len >= 12 ? 1 : 0) + (hasUpper ? 1 : 0) + (hasSpecial ? 1 : 0);
const active = i <= score;
const color = score <= 1 ? "bg-red-400" : score === 2 ? "bg-yellow-400" : score === 3 ? "bg-blue-400" : "bg-green-500";
return <div key={i} className={`h-1 flex-1 rounded-full ${active ? color : "bg-gray-200"}`} />;
})}
</div>
)}
</div>
{/* Confirm password */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">
Xác nhận mật khẩu mới
</label>
<div className="relative">
<input
type={showConfirm ? "text" : "password"}
value={form.confirm_password}
onChange={e => setForm(f => ({ ...f, confirm_password: e.target.value }))}
required
className={`w-full border rounded-lg px-3 py-2.5 pr-10 text-sm focus:outline-none focus:ring-1 ${
form.confirm_password && form.confirm_password !== form.new_password
? "border-red-400 focus:border-red-400 focus:ring-red-300"
: "border-gray-300 focus:border-blue-500 focus:ring-blue-500"
}`}
placeholder="Nhập lại mật khẩu mới"
/>
<button
type="button"
onClick={() => setShowConfirm(v => !v)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
{showConfirm
? <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" /></svg>
: <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" /></svg>
}
</button>
</div>
{form.confirm_password && form.confirm_password !== form.new_password && (
<p className="mt-1 text-xs text-red-500">Mật khẩu không khớp.</p>
)}
</div>
<button
type="submit"
disabled={loading || !form.current_password || !form.new_password || !form.confirm_password}
className="mt-1 w-full bg-blue-600 hover:bg-blue-700 disabled:opacity-60 text-white font-semibold text-sm py-2.5 rounded-lg transition"
>
{loading ? "Đang lưu…" : "Đổi mật khẩu"}
</button>
</form>
</div>
</div>
);
}
+9
View File
@@ -79,6 +79,15 @@ export default function DashboardPage() {
Quản trị
</button>
)}
<button
onClick={() => router.push("/change-password")}
className="text-sm text-gray-500 hover:text-blue-600 transition"
title="Đổi mật khẩu"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
</button>
<button
onClick={handleLogout}
className="text-sm text-gray-500 hover:text-red-600 transition"
+129 -9
View File
@@ -144,6 +144,10 @@ export default function WorkbookViewer({ pdfId }: Props) {
const currentUserIdRef = useRef<number | null>(null); // populated from api.me(); used to identify own vs remote objects
const [userRole, setUserRole] = useState<"admin" | "teacher" | "student">("student");
const userRoleRef = useRef<"admin" | "teacher" | "student">("student");
const [currentUsername, setCurrentUsername] = useState("");
const currentUsernameRef = useRef("");
const userMapRef = useRef<Map<number, string>>(new Map());
const [annotationTooltip, setAnnotationTooltip] = useState<{ x: number; y: number; text: string } | null>(null);
// Buffer for remote events that arrive while renderPageWithAnnotations is in progress
const renderingRef = useRef(false);
const pendingRemoteEvents = useRef<RemoteEvent[]>([]);
@@ -177,8 +181,8 @@ export default function WorkbookViewer({ pdfId }: Props) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const syncTempCanvasRef = useRef<(page: number) => void>(() => {});
// Keep currentPageRef in sync
useEffect(() => { currentPageRef.current = currentPage; }, [currentPage]);
// Keep currentPageRef in sync; clear any lingering hover tooltip on page navigation
useEffect(() => { currentPageRef.current = currentPage; setAnnotationTooltip(null); }, [currentPage]);
// Keep mutable refs in sync
useEffect(() => { currentToolRef.current = tool; }, [tool]);
@@ -186,6 +190,7 @@ export default function WorkbookViewer({ pdfId }: Props) {
useEffect(() => { strokeWidthRef.current = strokeWidth; }, [strokeWidth]);
useEffect(() => { fitModeRef.current = fitMode; }, [fitMode]);
useEffect(() => { scrollModeRef.current = scrollMode; }, [scrollMode]);
useEffect(() => { currentUsernameRef.current = currentUsername; }, [currentUsername]);
// Keep syncTempCanvasRef current so closures inside Fabric events always see latest pdfId
useEffect(() => {
@@ -193,7 +198,7 @@ export default function WorkbookViewer({ pdfId }: Props) {
const fc = fabricRef.current;
if (!fc) return;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const fullData: any = fc.toJSON(["_owner_id"]);
const fullData: any = fc.toJSON(["_owner_id", "_owner_username"]);
const uid = currentUserIdRef.current;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const ownObjects = ((fullData.objects as any[]) ?? []).filter(
@@ -229,6 +234,9 @@ export default function WorkbookViewer({ pdfId }: Props) {
currentUserIdRef.current = u.id;
setUserRole(u.role);
userRoleRef.current = u.role;
setCurrentUsername(u.username);
currentUsernameRef.current = u.username;
userMapRef.current.set(u.id, u.username);
// Assign deterministic color from preset palette based on user id
const assigned = userIdToPresetColor(u.id);
setPenColor(assigned);
@@ -307,6 +315,7 @@ export default function WorkbookViewer({ pdfId }: Props) {
objects.forEach((obj: any) => {
obj.collab_id = objJson.collab_id;
obj._isRemote = true; // don't save other users' live strokes under our account
if ((objJson as any)._owner_username) obj._owner_username = (objJson as any)._owner_username;
obj.selectable = canInteract;
obj.evented = canInteract;
fc.add(obj);
@@ -317,7 +326,7 @@ export default function WorkbookViewer({ pdfId }: Props) {
});
return;
}
}, []);
}, []);;
// When a new peer joins the room, broadcast all our own (non-remote) canvas
// objects so they receive our pre-existing annotations immediately.
@@ -358,6 +367,11 @@ export default function WorkbookViewer({ pdfId }: Props) {
collabSendRef.current = { objectAdd: sendObjectAdd, objectRemove: sendObjectRemove, clear: sendClear, colorSync: sendColorSync };
}, [sendObjectAdd, sendObjectRemove, sendClear, sendColorSync]);
// Keep user id→name map updated as peers connect/disconnect
useEffect(() => {
collabUsers.forEach(u => userMapRef.current.set(u.user_id, u.username));
}, [collabUsers]);
// Broadcast pen color only when BOTH WS is connected AND user color has been fetched.
// This prevents both users broadcasting the same default "#e63946" before api.me() resolves.
// Also re-broadcasts whenever the user manually picks a new color.
@@ -481,8 +495,17 @@ export default function WorkbookViewer({ pdfId }: Props) {
addedObjects.current = [];
if (annotationData) {
const rawObjs: any[] = ((annotationData as any).objects ?? []);
await new Promise<void>((resolve) => {
fc.loadFromJSON(annotationData, () => {
// Enforce custom properties — Fabric may not restore underscore-prefixed props
fc.getObjects().forEach((obj: any, i: number) => {
const raw = rawObjs[i];
if (raw) {
if (obj._owner_id === undefined) obj._owner_id = raw._owner_id ?? null;
if (obj._owner_username === undefined) obj._owner_username = raw._owner_username ?? null;
}
});
// Mark objects from other users as remote — prevents saving them under current user
const uid = currentUserIdRef.current;
if (uid !== null) {
@@ -493,6 +516,7 @@ export default function WorkbookViewer({ pdfId }: Props) {
obj._isRemote = true;
obj.selectable = canInteract;
obj.evented = canInteract;
if (!obj._owner_username) obj._owner_username = userMapRef.current.get(obj._owner_id) ?? null;
}
});
}
@@ -522,6 +546,7 @@ export default function WorkbookViewer({ pdfId }: Props) {
objects.forEach((obj: any) => {
obj.collab_id = objJson.collab_id;
obj._isRemote = true;
if ((objJson as any)._owner_username) obj._owner_username = (objJson as any)._owner_username;
obj.selectable = canInteract;
obj.evented = canInteract;
fc.add(obj);
@@ -620,8 +645,17 @@ export default function WorkbookViewer({ pdfId }: Props) {
} catch { /* no annotation */ }
}
if (annotationData) {
const rawObjsCont: any[] = ((annotationData as any).objects ?? []);
await new Promise<void>((resolve) => {
fc.loadFromJSON(annotationData, () => {
// Enforce custom properties — Fabric may not restore underscore-prefixed props
fc.getObjects().forEach((obj: any, i: number) => {
const raw = rawObjsCont[i];
if (raw) {
if (obj._owner_id === undefined) obj._owner_id = raw._owner_id ?? null;
if (obj._owner_username === undefined) obj._owner_username = raw._owner_username ?? null;
}
});
// Mark objects from other users as remote
const uid = currentUserIdRef.current;
if (uid !== null) {
@@ -632,6 +666,7 @@ export default function WorkbookViewer({ pdfId }: Props) {
obj._isRemote = true;
obj.selectable = canInteract;
obj.evented = canInteract;
if (!obj._owner_username) obj._owner_username = userMapRef.current.get(obj._owner_id) ?? null;
}
});
}
@@ -708,7 +743,7 @@ export default function WorkbookViewer({ pdfId }: Props) {
if (skipRemoteRef.current) return;
if (!obj.collab_id) obj.collab_id = crypto.randomUUID();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const json = (obj as any).toJSON(["collab_id"]);
const json = (obj as any).toJSON(["collab_id", "_owner_username", "_owner_id"]);
collabSendRef.current?.objectAdd(json, currentPageRef.current);
});
}
@@ -721,11 +756,14 @@ export default function WorkbookViewer({ pdfId }: Props) {
options.path.set({ opacity: 0.42 });
fc.renderAll();
}
// Tag with owner info for tooltip display
options.path._owner_id = currentUserIdRef.current;
options.path._owner_username = currentUsernameRef.current;
// Broadcast stroke to collaborators
if (!skipRemoteRef.current) {
options.path.collab_id = crypto.randomUUID();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const obj = (options.path as any).toJSON(["collab_id"]);
const obj = (options.path as any).toJSON(["collab_id", "_owner_username", "_owner_id"]);
collabSendRef.current?.objectAdd(obj, currentPageRef.current);
}
// Sync unsaved canvas to Redis so late-joining users see this stroke
@@ -784,6 +822,61 @@ export default function WorkbookViewer({ pdfId }: Props) {
};
}, [pdfId, renderPageWithAnnotations, generateThumbnails, renderAllPages]);
// ── Annotation hover tooltip (works in all modes including pan) ────────
// Uses scroll-container mousemove + Fabric findTarget() so pointer-events:none
// on the canvas wrapper doesn't block tooltip detection.
useEffect(() => {
if (!isReady) return;
const container = scrollContainerRef.current;
if (!container) return;
const resolveOwnerName = (obj: any): string | null => {
if (obj._owner_username) return obj._owner_username as string;
const ownerId: number | null = obj._owner_id ?? null;
if (ownerId === null) return null; // no owner info — don't attribute to current user
const uid = currentUserIdRef.current;
if (ownerId === uid) return currentUsernameRef.current || null;
return userMapRef.current.get(ownerId) ?? `User #${ownerId}`;
};
let lastTarget: any = null;
const handleMouseMove = (e: MouseEvent) => {
let found: any = null;
if (scrollModeRef.current === "single") {
const fc = fabricRef.current;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if (fc) found = (fc as any).findTarget(e, false) ?? null;
} else {
for (const fc of pageFabricRefs.current) {
if (!fc) continue;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const hit = (fc as any).findTarget(e, false);
if (hit) { found = hit; break; }
}
}
if (found === lastTarget) return; // avoid redundant state updates
lastTarget = found;
if (found) {
const name = resolveOwnerName(found);
if (name) {
setAnnotationTooltip({ x: e.clientX, y: e.clientY, text: name });
} else {
setAnnotationTooltip(null);
}
} else {
setAnnotationTooltip(null);
}
};
const handleMouseLeave = () => { lastTarget = null; setAnnotationTooltip(null); };
container.addEventListener("mousemove", handleMouseMove);
container.addEventListener("mouseleave", handleMouseLeave);
return () => {
container.removeEventListener("mousemove", handleMouseMove);
container.removeEventListener("mouseleave", handleMouseLeave);
};
}, [isReady]);
// Re-render when switching scroll modes
useEffect(() => {
if (!isReady) return;
@@ -893,6 +986,8 @@ export default function WorkbookViewer({ pdfId }: Props) {
fontFamily: "Arial, sans-serif",
padding: 4,
});
textObj._owner_id = currentUserIdRef.current;
textObj._owner_username = currentUsernameRef.current;
fc.add(textObj);
fc.setActiveObject(textObj);
textObj.enterEditing();
@@ -993,7 +1088,7 @@ export default function WorkbookViewer({ pdfId }: Props) {
const fc = fabricRef.current;
if (!fc) return;
// Preserve _owner_id so remote-object detection works on cache hits
localAnnotations.current[currentPage] = fc.toJSON(["_owner_id"]);
localAnnotations.current[currentPage] = fc.toJSON(["_owner_id", "_owner_username"]);
await renderPageWithAnnotations(newPage);
setCurrentPage(newPage);
},
@@ -1011,8 +1106,8 @@ export default function WorkbookViewer({ pdfId }: Props) {
if (!fc || !isReady || saving) return;
setSaving(true);
// Include _owner_id in serialization so we can filter remote objects
const fullCanvasData = fc.toJSON(["_owner_id"]);
// Include _owner_id and _owner_username in serialization
const fullCanvasData = fc.toJSON(["_owner_id", "_owner_username"]);
// Only save objects that belong to the current user (no _owner_id = own; _owner_id === uid = own)
const uid = currentUserIdRef.current;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -1124,6 +1219,17 @@ export default function WorkbookViewer({ pdfId }: Props) {
{pdfTitle}
</span>
{/* Current user badge */}
{currentUsername && (
<span className="flex items-center gap-1 text-xs font-medium text-white flex-shrink-0 px-2 py-0.5 rounded-full"
style={{ background: penColor }}
title={`Đang đăng nhập: ${currentUsername} (${userRole})`}
>
<span className="w-1.5 h-1.5 rounded-full bg-white opacity-80" />
{currentUsername}
</span>
)}
<div className="h-5 w-px bg-gray-200 flex-shrink-0" />
{/* Thumbnail toggle */}
@@ -1505,6 +1611,20 @@ export default function WorkbookViewer({ pdfId }: Props) {
</div>
{/* ── Color picker portal \u2014 rendered into document.body to escape overflow:hidden ── */}
{/* Annotation owner tooltip */}
{annotationTooltip && (
<div
style={{ position: "fixed", left: annotationTooltip.x + 14, top: annotationTooltip.y + 14, zIndex: 9999 }}
className="pointer-events-none bg-gray-800 bg-opacity-90 text-white text-xs rounded-lg px-2.5 py-1 shadow-xl whitespace-nowrap flex items-center gap-1"
>
<svg className="w-3 h-3 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
</svg>
{annotationTooltip.text}
</div>
)}
{colorPickerOpen && colorPickerPos && typeof document !== "undefined" && createPortal(
<div
ref={colorPickerRef}
+2
View File
@@ -30,6 +30,8 @@ export const api = {
register: (body: { username: string; email: string; password: string }) =>
request<import("@/types").User>("/api/auth/register", { method: "POST", body: JSON.stringify(body) }),
logout: () => request<void>("/api/auth/logout", { method: "POST" }),
changePassword: (body: { current_password: string; new_password: string }) =>
request<void>("/api/auth/change-password", { method: "POST", body: JSON.stringify(body) }),
// PDFs
listPdfs: () => request<import("@/types").PDFItem[]>("/api/pdfs"),