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
+15 -1
View File
@@ -5,7 +5,7 @@ import os
from ..database import get_db from ..database import get_db
from ..dependencies import get_current_user from ..dependencies import get_current_user
from ..models import User, UserRole, UserStatus from ..models import User, UserRole, UserStatus
from ..schemas import UserLogin, UserOut, UserRegister, UserApprove from ..schemas import UserLogin, UserOut, UserRegister, UserApprove, ChangePassword
from ..security import ( from ..security import (
ACCESS_TOKEN_EXPIRE_MINUTES, ACCESS_TOKEN_EXPIRE_MINUTES,
create_access_token, create_access_token,
@@ -117,3 +117,17 @@ def get_token(access_token: str | None = Cookie(default=None)):
if not access_token: if not access_token:
raise HTTPException(status_code=401, detail="Not authenticated.") raise HTTPException(status_code=401, detail="Not authenticated.")
return {"access_token": access_token} return {"access_token": access_token}
# ── POST /auth/change-password ────────────────────────────────────────────────
@router.post("/change-password", status_code=status.HTTP_204_NO_CONTENT)
def change_password(
body: ChangePassword,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
if not verify_password(body.current_password, current_user.password_hash):
raise HTTPException(status_code=400, detail="Mật khẩu hiện tại không đúng.")
current_user.password_hash = hash_password(body.new_password)
db.commit()
+12
View File
@@ -55,6 +55,18 @@ class UserRoleUpdate(BaseModel):
role: UserRole role: UserRole
class ChangePassword(BaseModel):
current_password: str
new_password: str
@field_validator("new_password")
@classmethod
def password_strength(cls, v: str) -> str:
if len(v) < 8:
raise ValueError("Password must be at least 8 characters.")
return v
class TokenPayload(BaseModel): class TokenPayload(BaseModel):
sub: int # user id sub: int # user id
exp: int exp: int
+9
View File
@@ -151,6 +151,15 @@ export default function AdminPage() {
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="text-sm text-gray-500 hidden sm:block">{me?.username}</span> <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 <button
onClick={async () => { await api.logout(); router.replace("/login"); }} onClick={async () => { await api.logout(); router.replace("/login"); }}
className="text-sm text-gray-500 hover:text-red-600 transition" 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ị Quản trị
</button> </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 <button
onClick={handleLogout} onClick={handleLogout}
className="text-sm text-gray-500 hover:text-red-600 transition" 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 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 [userRole, setUserRole] = useState<"admin" | "teacher" | "student">("student");
const userRoleRef = useRef<"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 // Buffer for remote events that arrive while renderPageWithAnnotations is in progress
const renderingRef = useRef(false); const renderingRef = useRef(false);
const pendingRemoteEvents = useRef<RemoteEvent[]>([]); const pendingRemoteEvents = useRef<RemoteEvent[]>([]);
@@ -177,8 +181,8 @@ export default function WorkbookViewer({ pdfId }: Props) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const syncTempCanvasRef = useRef<(page: number) => void>(() => {}); const syncTempCanvasRef = useRef<(page: number) => void>(() => {});
// Keep currentPageRef in sync // Keep currentPageRef in sync; clear any lingering hover tooltip on page navigation
useEffect(() => { currentPageRef.current = currentPage; }, [currentPage]); useEffect(() => { currentPageRef.current = currentPage; setAnnotationTooltip(null); }, [currentPage]);
// Keep mutable refs in sync // Keep mutable refs in sync
useEffect(() => { currentToolRef.current = tool; }, [tool]); useEffect(() => { currentToolRef.current = tool; }, [tool]);
@@ -186,6 +190,7 @@ export default function WorkbookViewer({ pdfId }: Props) {
useEffect(() => { strokeWidthRef.current = strokeWidth; }, [strokeWidth]); useEffect(() => { strokeWidthRef.current = strokeWidth; }, [strokeWidth]);
useEffect(() => { fitModeRef.current = fitMode; }, [fitMode]); useEffect(() => { fitModeRef.current = fitMode; }, [fitMode]);
useEffect(() => { scrollModeRef.current = scrollMode; }, [scrollMode]); useEffect(() => { scrollModeRef.current = scrollMode; }, [scrollMode]);
useEffect(() => { currentUsernameRef.current = currentUsername; }, [currentUsername]);
// Keep syncTempCanvasRef current so closures inside Fabric events always see latest pdfId // Keep syncTempCanvasRef current so closures inside Fabric events always see latest pdfId
useEffect(() => { useEffect(() => {
@@ -193,7 +198,7 @@ export default function WorkbookViewer({ pdfId }: Props) {
const fc = fabricRef.current; const fc = fabricRef.current;
if (!fc) return; if (!fc) return;
// eslint-disable-next-line @typescript-eslint/no-explicit-any // 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; const uid = currentUserIdRef.current;
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const ownObjects = ((fullData.objects as any[]) ?? []).filter( const ownObjects = ((fullData.objects as any[]) ?? []).filter(
@@ -229,6 +234,9 @@ export default function WorkbookViewer({ pdfId }: Props) {
currentUserIdRef.current = u.id; currentUserIdRef.current = u.id;
setUserRole(u.role); setUserRole(u.role);
userRoleRef.current = 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 // Assign deterministic color from preset palette based on user id
const assigned = userIdToPresetColor(u.id); const assigned = userIdToPresetColor(u.id);
setPenColor(assigned); setPenColor(assigned);
@@ -307,6 +315,7 @@ export default function WorkbookViewer({ pdfId }: Props) {
objects.forEach((obj: any) => { objects.forEach((obj: any) => {
obj.collab_id = objJson.collab_id; obj.collab_id = objJson.collab_id;
obj._isRemote = true; // don't save other users' live strokes under our account 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.selectable = canInteract;
obj.evented = canInteract; obj.evented = canInteract;
fc.add(obj); fc.add(obj);
@@ -317,7 +326,7 @@ export default function WorkbookViewer({ pdfId }: Props) {
}); });
return; return;
} }
}, []); }, []);;
// When a new peer joins the room, broadcast all our own (non-remote) canvas // When a new peer joins the room, broadcast all our own (non-remote) canvas
// objects so they receive our pre-existing annotations immediately. // 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 }; collabSendRef.current = { objectAdd: sendObjectAdd, objectRemove: sendObjectRemove, clear: sendClear, colorSync: sendColorSync };
}, [sendObjectAdd, sendObjectRemove, sendClear, 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. // 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. // This prevents both users broadcasting the same default "#e63946" before api.me() resolves.
// Also re-broadcasts whenever the user manually picks a new color. // Also re-broadcasts whenever the user manually picks a new color.
@@ -481,8 +495,17 @@ export default function WorkbookViewer({ pdfId }: Props) {
addedObjects.current = []; addedObjects.current = [];
if (annotationData) { if (annotationData) {
const rawObjs: any[] = ((annotationData as any).objects ?? []);
await new Promise<void>((resolve) => { await new Promise<void>((resolve) => {
fc.loadFromJSON(annotationData, () => { 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 // Mark objects from other users as remote — prevents saving them under current user
const uid = currentUserIdRef.current; const uid = currentUserIdRef.current;
if (uid !== null) { if (uid !== null) {
@@ -493,6 +516,7 @@ export default function WorkbookViewer({ pdfId }: Props) {
obj._isRemote = true; obj._isRemote = true;
obj.selectable = canInteract; obj.selectable = canInteract;
obj.evented = 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) => { objects.forEach((obj: any) => {
obj.collab_id = objJson.collab_id; obj.collab_id = objJson.collab_id;
obj._isRemote = true; obj._isRemote = true;
if ((objJson as any)._owner_username) obj._owner_username = (objJson as any)._owner_username;
obj.selectable = canInteract; obj.selectable = canInteract;
obj.evented = canInteract; obj.evented = canInteract;
fc.add(obj); fc.add(obj);
@@ -620,8 +645,17 @@ export default function WorkbookViewer({ pdfId }: Props) {
} catch { /* no annotation */ } } catch { /* no annotation */ }
} }
if (annotationData) { if (annotationData) {
const rawObjsCont: any[] = ((annotationData as any).objects ?? []);
await new Promise<void>((resolve) => { await new Promise<void>((resolve) => {
fc.loadFromJSON(annotationData, () => { 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 // Mark objects from other users as remote
const uid = currentUserIdRef.current; const uid = currentUserIdRef.current;
if (uid !== null) { if (uid !== null) {
@@ -632,6 +666,7 @@ export default function WorkbookViewer({ pdfId }: Props) {
obj._isRemote = true; obj._isRemote = true;
obj.selectable = canInteract; obj.selectable = canInteract;
obj.evented = 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 (skipRemoteRef.current) return;
if (!obj.collab_id) obj.collab_id = crypto.randomUUID(); if (!obj.collab_id) obj.collab_id = crypto.randomUUID();
// eslint-disable-next-line @typescript-eslint/no-explicit-any // 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); collabSendRef.current?.objectAdd(json, currentPageRef.current);
}); });
} }
@@ -721,11 +756,14 @@ export default function WorkbookViewer({ pdfId }: Props) {
options.path.set({ opacity: 0.42 }); options.path.set({ opacity: 0.42 });
fc.renderAll(); 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 // Broadcast stroke to collaborators
if (!skipRemoteRef.current) { if (!skipRemoteRef.current) {
options.path.collab_id = crypto.randomUUID(); options.path.collab_id = crypto.randomUUID();
// eslint-disable-next-line @typescript-eslint/no-explicit-any // 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); collabSendRef.current?.objectAdd(obj, currentPageRef.current);
} }
// Sync unsaved canvas to Redis so late-joining users see this stroke // 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]); }, [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 // Re-render when switching scroll modes
useEffect(() => { useEffect(() => {
if (!isReady) return; if (!isReady) return;
@@ -893,6 +986,8 @@ export default function WorkbookViewer({ pdfId }: Props) {
fontFamily: "Arial, sans-serif", fontFamily: "Arial, sans-serif",
padding: 4, padding: 4,
}); });
textObj._owner_id = currentUserIdRef.current;
textObj._owner_username = currentUsernameRef.current;
fc.add(textObj); fc.add(textObj);
fc.setActiveObject(textObj); fc.setActiveObject(textObj);
textObj.enterEditing(); textObj.enterEditing();
@@ -993,7 +1088,7 @@ export default function WorkbookViewer({ pdfId }: Props) {
const fc = fabricRef.current; const fc = fabricRef.current;
if (!fc) return; if (!fc) return;
// Preserve _owner_id so remote-object detection works on cache hits // 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); await renderPageWithAnnotations(newPage);
setCurrentPage(newPage); setCurrentPage(newPage);
}, },
@@ -1011,8 +1106,8 @@ export default function WorkbookViewer({ pdfId }: Props) {
if (!fc || !isReady || saving) return; if (!fc || !isReady || saving) return;
setSaving(true); setSaving(true);
// Include _owner_id in serialization so we can filter remote objects // Include _owner_id and _owner_username in serialization
const fullCanvasData = fc.toJSON(["_owner_id"]); 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) // Only save objects that belong to the current user (no _owner_id = own; _owner_id === uid = own)
const uid = currentUserIdRef.current; const uid = currentUserIdRef.current;
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -1124,6 +1219,17 @@ export default function WorkbookViewer({ pdfId }: Props) {
{pdfTitle} {pdfTitle}
</span> </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" /> <div className="h-5 w-px bg-gray-200 flex-shrink-0" />
{/* Thumbnail toggle */} {/* Thumbnail toggle */}
@@ -1505,6 +1611,20 @@ export default function WorkbookViewer({ pdfId }: Props) {
</div> </div>
{/* ── Color picker portal \u2014 rendered into document.body to escape overflow:hidden ── */} {/* ── 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( {colorPickerOpen && colorPickerPos && typeof document !== "undefined" && createPortal(
<div <div
ref={colorPickerRef} ref={colorPickerRef}
+2
View File
@@ -30,6 +30,8 @@ export const api = {
register: (body: { username: string; email: string; password: string }) => register: (body: { username: string; email: string; password: string }) =>
request<import("@/types").User>("/api/auth/register", { method: "POST", body: JSON.stringify(body) }), request<import("@/types").User>("/api/auth/register", { method: "POST", body: JSON.stringify(body) }),
logout: () => request<void>("/api/auth/logout", { method: "POST" }), 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 // PDFs
listPdfs: () => request<import("@/types").PDFItem[]>("/api/pdfs"), listPdfs: () => request<import("@/types").PDFItem[]>("/api/pdfs"),