thực hiện phân quyền admin giáo viên và học sinh xong

This commit is contained in:
2026-04-01 19:26:47 +07:00
parent 8546d41a26
commit 386871bd4a
14 changed files with 796 additions and 37 deletions
+104 -30
View File
@@ -142,6 +142,8 @@ export default function WorkbookViewer({ pdfId }: Props) {
const currentPageRef = useRef(1); // mutable copy for collab callbacks
const skipRemoteRef = useRef(false); // prevent echo-back when applying remote events
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");
// Buffer for remote events that arrive while renderPageWithAnnotations is in progress
const renderingRef = useRef(false);
const pendingRemoteEvents = useRef<RemoteEvent[]>([]);
@@ -225,6 +227,8 @@ export default function WorkbookViewer({ pdfId }: Props) {
useEffect(() => {
api.me().then((u) => {
currentUserIdRef.current = u.id;
setUserRole(u.role);
userRoleRef.current = u.role;
// Assign deterministic color from preset palette based on user id
const assigned = userIdToPresetColor(u.id);
setPenColor(assigned);
@@ -299,11 +303,12 @@ export default function WorkbookViewer({ pdfId }: Props) {
skipRemoteRef.current = true;
skipObjectTracking.current = true;
fabric.util.enlivenObjects([objJson], (objects: any[]) => {
const canInteract = userRoleRef.current !== "student";
objects.forEach((obj: any) => {
obj.collab_id = objJson.collab_id;
obj._isRemote = true; // don't save other users' live strokes under our account
obj.selectable = false;
obj.evented = false;
obj.selectable = canInteract;
obj.evented = canInteract;
fc.add(obj);
});
fc.renderAll();
@@ -481,12 +486,13 @@ export default function WorkbookViewer({ pdfId }: Props) {
// Mark objects from other users as remote — prevents saving them under current user
const uid = currentUserIdRef.current;
if (uid !== null) {
const canInteract = userRoleRef.current !== "student";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
fc.getObjects().forEach((obj: any) => {
if (obj._owner_id != null && obj._owner_id !== uid) {
obj._isRemote = true;
obj.selectable = false;
obj.evented = false;
obj.selectable = canInteract;
obj.evented = canInteract;
}
});
}
@@ -512,11 +518,12 @@ export default function WorkbookViewer({ pdfId }: Props) {
skipRemoteRef.current = true;
skipObjectTracking.current = true;
fabric.util.enlivenObjects([objJson], (objects: any[]) => {
const canInteract = userRoleRef.current !== "student";
objects.forEach((obj: any) => {
obj.collab_id = objJson.collab_id;
obj._isRemote = true;
obj.selectable = false;
obj.evented = false;
obj.selectable = canInteract;
obj.evented = canInteract;
fc.add(obj);
});
fc.renderAll();
@@ -618,12 +625,13 @@ export default function WorkbookViewer({ pdfId }: Props) {
// Mark objects from other users as remote
const uid = currentUserIdRef.current;
if (uid !== null) {
const canInteract = userRoleRef.current !== "student";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
fc.getObjects().forEach((obj: any) => {
if (obj._owner_id != null && obj._owner_id !== uid) {
obj._isRemote = true;
obj.selectable = false;
obj.evented = false;
obj.selectable = canInteract;
obj.evented = canInteract;
}
});
}
@@ -899,9 +907,15 @@ export default function WorkbookViewer({ pdfId }: Props) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
fc.on("mouse:down", (options: any) => {
if (!options.target) return;
const collab_id = options.target.collab_id as string | undefined;
fc.remove(options.target);
addedObjects.current = addedObjects.current.filter(o => o !== options.target);
const obj = options.target as any;
const ownerId: number | undefined = obj._owner_id;
const uid = currentUserIdRef.current;
const role = userRoleRef.current;
// Students can only erase their own annotations
if (role === "student" && (obj._isRemote || (ownerId != null && ownerId !== uid))) return;
const collab_id = obj.collab_id as string | undefined;
fc.remove(obj);
addedObjects.current = addedObjects.current.filter(o => o !== obj);
fc.renderAll();
if (collab_id) collabSendRef.current?.objectRemove(collab_id, currentPageRef.current);
});
@@ -947,8 +961,14 @@ export default function WorkbookViewer({ pdfId }: Props) {
fc.isDrawingMode = false; fc.selection = false;
fc.on("mouse:down", (options: any) => {
if (!options.target) return;
const collab_id = options.target.collab_id as string | undefined;
fc.remove(options.target); fc.renderAll();
const obj = options.target as any;
const ownerId: number | undefined = obj._owner_id;
const uid = currentUserIdRef.current;
const role = userRoleRef.current;
// Students can only erase their own annotations
if (role === "student" && (obj._isRemote || (ownerId != null && ownerId !== uid))) return;
const collab_id = obj.collab_id as string | undefined;
fc.remove(obj); fc.renderAll();
if (collab_id) collabSendRef.current?.objectRemove(collab_id, currentPageRef.current);
}); break;
}
@@ -1031,13 +1051,33 @@ export default function WorkbookViewer({ pdfId }: Props) {
const handleClear = useCallback(() => {
const fc = fabricRef.current;
if (!fc) return;
if (!confirm("Clear all annotations on this page?")) return;
fc.clear();
addedObjects.current = [];
fc.renderAll();
collabSendRef.current?.clear(currentPageRef.current);
// Push empty canvas to Redis so other users see the clear immediately on next load
syncTempCanvasRef.current(currentPageRef.current);
const role = userRoleRef.current;
const uid = currentUserIdRef.current;
if (role === "student") {
// Students: only remove their own objects, leave others untouched
if (!confirm("Xoá annotation của bạn trên trang này?")) return;
const toRemove = fc.getObjects().filter((o: any) => !o._isRemote && (o._owner_id == null || o._owner_id === uid));
toRemove.forEach((o: any) => {
fc.remove(o);
// Broadcast removal so teacher canvas updates in real time
const collab_id = o.collab_id as string | undefined;
if (collab_id) collabSendRef.current?.objectRemove(collab_id, currentPageRef.current);
});
addedObjects.current = addedObjects.current.filter(o => toRemove.indexOf(o) === -1);
fc.renderAll();
// Sync own cleared state to Redis
syncTempCanvasRef.current(currentPageRef.current);
} else {
// Teacher / Admin: clear everything
if (!confirm("Xoá tất cả annotation trên trang này?")) return;
fc.clear();
addedObjects.current = [];
fc.renderAll();
collabSendRef.current?.clear(currentPageRef.current);
syncTempCanvasRef.current(currentPageRef.current);
}
}, []);
// ─────────────────────────────────────────────────────────────────────────
@@ -1317,16 +1357,50 @@ export default function WorkbookViewer({ pdfId }: Props) {
<span className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${collabConnected ? "bg-green-500" : "bg-gray-300"}`} />
{collabUsers.length > 0 && (
<div className="flex -space-x-1">
{collabUsers.slice(0, 5).map(u => (
<span
key={u.user_id}
title={u.username}
style={{ background: u.color }}
className="w-5 h-5 rounded-full border-2 border-white flex items-center justify-center text-[9px] text-white font-bold uppercase"
>
{u.username[0]}
</span>
))}
{collabUsers.slice(0, 5).map(u => {
const isSelf = u.user_id === currentUserIdRef.current;
const canClear = !isSelf && (userRole === "admin" || userRole === "teacher");
return (
<div key={u.user_id} className="relative group">
<span
title={u.username}
style={{ background: u.color }}
className="w-5 h-5 rounded-full border-2 border-white flex items-center justify-center text-[9px] text-white font-bold uppercase select-none"
>
{u.username[0]}
</span>
{canClear && (
<button
onClick={async () => {
if (!confirm(`Xoá annotation của "${u.username}" trên trang ${currentPageRef.current}?`)) return;
try {
await api.clearUserAnnotation(pdfId, currentPageRef.current, u.user_id);
// Remove their objects from canvas
const removeFromCanvas = (fc: any) => {
const toRemove = fc.getObjects().filter((o: any) => o._owner_id === u.user_id);
toRemove.forEach((o: any) => fc.remove(o));
if (toRemove.length) fc.renderAll();
};
if (scrollModeRef.current === "continuous") {
pageFabricRefs.current.forEach(fc => { if (fc) removeFromCanvas(fc); });
} else if (fabricRef.current) {
removeFromCanvas(fabricRef.current);
}
localAnnotations.current = {};
} catch (e: unknown) {
alert(e instanceof Error ? e.message : "Xoá thất bại.");
}
}}
title={`Xoá annotation của ${u.username} trang này`}
className="absolute -top-1.5 -right-1.5 w-3.5 h-3.5 rounded-full bg-red-500 text-white hidden group-hover:flex items-center justify-center shadow z-10"
style={{ fontSize: 8 }}
>
</button>
)}
</div>
);
})}
{collabUsers.length > 5 && (
<span className="w-5 h-5 rounded-full border-2 border-white bg-gray-400 flex items-center justify-center text-[9px] text-white font-bold">
+{collabUsers.length - 5}