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