mirror of
https://git.victorphan.net/basketballcantho/ten-project.git
synced 2026-08-05 10:43:11 +07:00
hoàn thành chức năng hiện username annotation của từng user
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
Reference in New Issue
Block a user