diff --git a/backend/server.ts b/backend/server.ts index c4943c98..fafd9188 100644 --- a/backend/server.ts +++ b/backend/server.ts @@ -16,6 +16,7 @@ import { registerNotificationHandlers, registerNotificationSchemaListener, } from "./sockets/notification"; +import { registerModuleEditingHandlers } from "./sockets/moduleEditing"; import helpRequestRouter from "./rest/helpRequestRoutes"; import notificationRouter from "./rest/notificationRoutes"; import courseRouter from "./rest/courseRoutes"; @@ -65,6 +66,7 @@ io.on("connection", (socket) => { if (!userId && typeof userId !== "string") return; socket.join(userId); registerNotificationHandlers(io, socket); + registerModuleEditingHandlers(io, socket); }); registerNotificationSchemaListener(io); diff --git a/backend/sockets/moduleEditing.ts b/backend/sockets/moduleEditing.ts new file mode 100644 index 00000000..3fcf0533 --- /dev/null +++ b/backend/sockets/moduleEditing.ts @@ -0,0 +1,122 @@ +import { Server, Socket } from "socket.io"; +import logger from "../utilities/logger"; + +const Logger = logger(__filename); + +// Store active module editing locks: moduleId -> { userId, userName, socketId } +const moduleEditingLocks = new Map< + string, + { userId: string; userName: string; socketId: string } +>(); + +interface AcquireLockPayload { + moduleId: string; + userId: string; + userName: string; +} + +interface ReleaseLockPayload { + moduleId: string; + userId: string; +} + +const registerModuleEditingHandlers = (io: Server, socket: Socket) => { + // Attempt to acquire editing lock for a module + const acquireLock = (payload: AcquireLockPayload) => { + const { moduleId, userId, userName } = payload; + + try { + const existingLock = moduleEditingLocks.get(moduleId); + + // Check if module is already locked by someone else + if (existingLock && existingLock.userId !== userId) { + Logger.info( + `Module ${moduleId} is already locked by user ${existingLock.userName}`, + ); + socket.emit("moduleEditing:lockDenied", { + moduleId, + currentEditor: { + userId: existingLock.userId, + userName: existingLock.userName, + }, + }); + return; + } + + // Acquire the lock + moduleEditingLocks.set(moduleId, { + userId, + userName, + socketId: socket.id, + }); + + Logger.info(`User ${userName} acquired lock for module ${moduleId}`); + socket.emit("moduleEditing:lockAcquired", { moduleId }); + } catch (error) { + Logger.error(`Failed to acquire lock for module ${moduleId}: ${error}`); + socket.emit("moduleEditing:error", { + moduleId, + message: "Failed to acquire editing lock", + }); + } + }; + + // Release editing lock for a module + const releaseLock = (payload: ReleaseLockPayload) => { + const { moduleId, userId } = payload; + + try { + const existingLock = moduleEditingLocks.get(moduleId); + + // Only the lock owner can release it + if (existingLock && existingLock.userId === userId) { + moduleEditingLocks.delete(moduleId); + Logger.info(`User ${userId} released lock for module ${moduleId}`); + socket.emit("moduleEditing:lockReleased", { moduleId }); + } else { + Logger.warn( + `User ${userId} attempted to release lock for module ${moduleId} but doesn't own it`, + ); + } + } catch (error) { + Logger.error(`Failed to release lock for module ${moduleId}: ${error}`); + } + }; + + // Clean up locks when socket disconnects + const handleDisconnect = () => { + try { + // Find and remove all locks held by this socket + const locksToRemove: string[] = []; + moduleEditingLocks.forEach((lock, moduleId) => { + if (lock.socketId === socket.id) { + locksToRemove.push(moduleId); + } + }); + + locksToRemove.forEach((moduleId) => { + moduleEditingLocks.delete(moduleId); + Logger.info( + `Released lock for module ${moduleId} due to socket disconnect`, + ); + }); + } catch (error) { + Logger.error(`Failed to clean up locks on disconnect: ${error}`); + } + }; + + socket.on("moduleEditing:acquireLock", acquireLock); + socket.on("moduleEditing:releaseLock", releaseLock); + socket.on("disconnect", handleDisconnect); +}; + +const removeModuleEditingHandlers = (socket: Socket) => { + socket.removeAllListeners("moduleEditing:acquireLock"); + socket.removeAllListeners("moduleEditing:releaseLock"); +}; + +export { + registerModuleEditingHandlers, + removeModuleEditingHandlers, + moduleEditingLocks, +}; diff --git a/frontend/src/APIClients/ActivityAPIClient.ts b/frontend/src/APIClients/ActivityAPIClient.ts index ad28d412..1bb55b28 100644 --- a/frontend/src/APIClients/ActivityAPIClient.ts +++ b/frontend/src/APIClients/ActivityAPIClient.ts @@ -45,6 +45,10 @@ const updateActivity = async ( ); return data; } catch (error) { + // eslint-disable-next-line no-alert + alert( + "Failed to update activity. Please refresh the page, try again later, or contact us.", + ); return null; } }; diff --git a/frontend/src/components/course_authoring/editorComponents/TypographyTextField.tsx b/frontend/src/components/course_authoring/editorComponents/TypographyTextField.tsx index 5331fd25..b369bf2c 100644 --- a/frontend/src/components/course_authoring/editorComponents/TypographyTextField.tsx +++ b/frontend/src/components/course_authoring/editorComponents/TypographyTextField.tsx @@ -106,3 +106,61 @@ export const BodyMediumTextField = ({ /> ); }; + +export const HeaderLargeTextField = ({ + defaultValue, + onChange, + placeholder, + minRows, + maxRows, + rows, + color, + onBlur, +}: { + defaultValue: string; + onChange: (newValue: string) => void; + placeholder?: string; + minRows?: number; + maxRows?: number; + rows?: number; + color?: string; + onBlur?: () => void; +}) => { + return ( + onChange(e.target.value)} + onBlur={onBlur} + sx={{ + "& .MuiInputBase-input": { + fontSize: "28px", + fontWeight: 600, + lineHeight: "120%", + textTransform: "none", + letterSpacing: "0.2px", + fontFamily: "Lexend Deca, sans-serif", + color, + }, + "& .MuiOutlinedInput-notchedOutline": { + border: "none", + }, + "& .MuiOutlinedInput-root": { + padding: 0, + "&:hover .MuiOutlinedInput-notchedOutline": { + border: "none", + }, + "&.Mui-focused .MuiOutlinedInput-notchedOutline": { + border: "none", + }, + }, + }} + /> + ); +}; diff --git a/frontend/src/components/pages/ModuleLockedModal.tsx b/frontend/src/components/pages/ModuleLockedModal.tsx new file mode 100644 index 00000000..381c6cf9 --- /dev/null +++ b/frontend/src/components/pages/ModuleLockedModal.tsx @@ -0,0 +1,159 @@ +import CloseIcon from "@mui/icons-material/Close"; +import LockIcon from "@mui/icons-material/Lock"; +import { + Box, + Button, + IconButton, + Modal, + Stack, + Typography, + useTheme, +} from "@mui/material"; +import * as Routes from "../../constants/Routes"; + +interface ModuleLockedModalProps { + open: boolean; + onClose: () => void; + editorName: string; + unitId?: string; +} + +const ModuleLockedModal = ({ + open, + onClose, + editorName, + unitId, +}: ModuleLockedModalProps) => { + const theme = useTheme(); + + const handleBackToCourse = () => { + if (unitId) { + window.location.href = `${Routes.COURSE_PAGE}?unitId=${unitId}`; + } else { + window.location.href = Routes.COURSE_PAGE; + } + onClose(); + }; + + const commonButtonStyles = { + display: "flex", + height: "40px", + flexDirection: "row", + justifyContent: "center", + alignItems: "center", + borderRadius: "4px", + textTransform: "none", + font: theme.typography.labelMedium, + lineHeight: "normal", + } as const; + + return ( + + + + + + + + + + + Module Currently Being Edited + + + + {editorName} is currently editing this module. Only + one administrator can edit a module at a time to prevent conflicts. + Please try again later or contact {editorName} directly. + + + + + + + + + + ); +}; + +export default ModuleLockedModal; diff --git a/frontend/src/components/pages/ViewModulePage.tsx b/frontend/src/components/pages/ViewModulePage.tsx index ae9e5606..7d4b4a26 100644 --- a/frontend/src/components/pages/ViewModulePage.tsx +++ b/frontend/src/components/pages/ViewModulePage.tsx @@ -85,9 +85,12 @@ import SurveySlides from "../courses/moduleViewing/learner-giving-feedback/Surve import ModuleSidebarThumbnail from "../courses/moduleViewing/Thumbnail"; import NeedHelpModal from "../help/NeedHelpModal"; import DeletePageModal from "./DeletePageModal"; +import ModuleLockedModal from "./ModuleLockedModal"; import "./ViewModulePage.css"; import { useCourseUnits } from "../../contexts/CourseUnitsContext"; import EditPublishedModuleModal from "../course_viewing/modals/EditPublishedModuleModal"; +import { useSocket } from "../../contexts/SocketContext"; +import PublishModuleModal from "../course_viewing/modals/PublishModuleModal"; pdfjs.GlobalWorkerOptions.workerSrc = new URL( "pdfjs-dist/build/pdf.worker.min.mjs", @@ -104,8 +107,9 @@ const ViewModulePage = () => { const requestedModuleId = queryParams.get("moduleId") || ""; const requestedPageId = queryParams.get("pageId") || ""; const requestedUnitId = queryParams.get("unitId") || ""; - const { role } = useUser(); const history = useHistory(); + const { role, id: userId, firstName, lastName } = useUser(); + const socket = useSocket(); const [currentPage, setCurrentPage] = useState(0); const [isFullScreen, setIsFullScreen] = useState(false); @@ -130,6 +134,7 @@ const ViewModulePage = () => { const numPages = module?.pages.length || 0; const [editPublishedModuleModalOpen, setEditPublishedModuleModalOpen] = useState(false); + const [publishModuleModalOpen, setPublishModuleModalOpen] = useState(false); const isFeedbackSurveyPage = role === "Learner" && currentPage === numPages; const isEmptyModuleEditing = @@ -153,6 +158,12 @@ const ViewModulePage = () => { const [isSnackbarSuccess, setIsSnackbarSuccess] = useState(true); const [isUploadingPdf, setIsUploadingPdf] = useState(false); const [isDeletingFromContext, setIsDeletingFromContext] = useState(false); + const [isModuleLockedModalOpen, setIsModuleLockedModalOpen] = useState(false); + const [currentEditorName, setCurrentEditorName] = useState(""); + const [hasEditingLock, setHasEditingLock] = useState(false); + + // Only allow editing if administrator has acquired the editing lock + const canEdit = role === "Administrator" && hasEditingLock; const [hasImage, setHasImage] = useState( (currentPageObject && @@ -530,6 +541,75 @@ const ViewModulePage = () => { fetchBookmarks(); }, [fetchBookmarks]); + // Module editing lock management (Administrators only) + useEffect(() => { + if (role !== "Administrator" || !socket || !requestedModuleId) { + return; + } + + const userName = `${firstName} ${lastName}`; + + // Request lock when component mounts + socket.emit("moduleEditing:acquireLock", { + moduleId: requestedModuleId, + userId, + userName, + }); + + // Listen for lock acquisition success + const handleLockAcquired = (data: { moduleId: string }) => { + if (data.moduleId === requestedModuleId) { + setHasEditingLock(true); + } + }; + + // Listen for lock denial (someone else is editing) + const handleLockDenied = (data: { + moduleId: string; + currentEditor: { userId: string; userName: string }; + }) => { + if (data.moduleId === requestedModuleId) { + setCurrentEditorName(data.currentEditor.userName); + setIsModuleLockedModalOpen(true); + setHasEditingLock(false); + } + }; + + // Listen for errors + const handleLockError = (data: { moduleId: string; message: string }) => { + if (data.moduleId === requestedModuleId) { + /* eslint-disable-next-line no-console */ + console.error("Module editing lock error:", data.message); + } + }; + + socket.on("moduleEditing:lockAcquired", handleLockAcquired); + socket.on("moduleEditing:lockDenied", handleLockDenied); + socket.on("moduleEditing:error", handleLockError); + + // Release lock when component unmounts or module changes + // eslint-disable-next-line consistent-return + return () => { + if (hasEditingLock) { + socket.emit("moduleEditing:releaseLock", { + moduleId: requestedModuleId, + userId, + }); + } + socket.off("moduleEditing:lockAcquired", handleLockAcquired); + socket.off("moduleEditing:lockDenied", handleLockDenied); + socket.off("moduleEditing:error", handleLockError); + }; + }, [ + role, + socket, + requestedModuleId, + userId, + firstName, + lastName, + hasEditingLock, + ]); + const handleDeletePage = async () => { if (!module || !currentPageObject) return; @@ -653,171 +733,199 @@ const ViewModulePage = () => { const SideBar = useMemo( () => ( - - {isEmptyModuleEditing && } - {module?.pages - .map((page, index) => ( - - {isLessonPage(page) && ( - Loading... - } - > - - - )} - {isActivityPage(page) && ( - - - {questionTypeIcons[page.questionType]} - - - Activity{" "} - {(() => { - const unitNumber = unit?.displayIndex ?? 0; - const moduleNumber = - (unit?.modules.findIndex( - (m) => m.id === module?.id, - ) ?? -1) + 1; - const activityNumber = - module?.pages - .slice(0, index + 1) - .filter(isActivityPage).length ?? 0; - return `${unitNumber}.${moduleNumber}.${activityNumber}`; - })()} - - - {questionTypeLabels[page.questionType]} - - - - - )} - - )) - .concat( - role === "Learner" ? ( + + {isEmptyModuleEditing && } + {module?.pages + .map((page, index) => ( - + {isLessonPage(page) && ( + Loading... + } + > + + + )} + {isActivityPage(page) && ( + + + {questionTypeIcons[page.questionType]} + + + Activity{" "} + {(() => { + const unitNumber = unit?.displayIndex ?? 0; + const moduleNumber = + (unit?.modules.findIndex( + (m) => m.id === module?.id, + ) ?? -1) + 1; + const activityNumber = + module?.pages + .slice(0, index + 1) + .filter(isActivityPage).length ?? 0; + return `${unitNumber}.${moduleNumber}.${activityNumber}`; + })()} + + + {questionTypeLabels[page.questionType]} + + + + + )} - ) : ( - [] - ), + )) + .concat( + role === "Learner" ? ( + + + + ) : ( + [] + ), + )} + {canEdit && draggedIndex !== null && module?.pages && ( + { + e.preventDefault(); + setHoverIndex(module.pages.length); + }} + onDragLeave={() => setHoverIndex(null)} + onDrop={() => handleDrop(module.pages.length)} + sx={{ + minHeight: "40px", + display: "flex", + alignItems: "center", + justifyContent: "center", + position: "relative", + marginTop: "-10px", + "&::before": + hoverIndex === module.pages.length + ? { + content: '""', + position: "absolute", + top: "0", + left: 0, + right: 0, + height: "3px", + backgroundColor: theme.palette.Learner.Dark.Default, + borderRadius: "2px", + zIndex: 10, + } + : {}, + }} + /> )} - {role === "Administrator" && draggedIndex !== null && module?.pages && ( + + {role === "Administrator" && module && ( { - e.preventDefault(); - setHoverIndex(module.pages.length); - }} - onDragLeave={() => setHoverIndex(null)} - onDrop={() => handleDrop(module.pages.length)} + padding="16px 24px" sx={{ - minHeight: "40px", display: "flex", + width: "100%", + padding: "16px 24px", alignItems: "center", justifyContent: "center", - position: "relative", - marginTop: "-10px", - "&::before": - hoverIndex === module.pages.length - ? { - content: '""', - position: "absolute", - top: "0", - left: 0, - right: 0, - height: "3px", - backgroundColor: theme.palette.Learner.Dark.Default, - borderRadius: "2px", - zIndex: 10, - } - : {}, + borderTop: `1px solid ${theme.palette.Neutral[300]}`, }} - /> + > + + )} - + ), [ - theme.palette.Neutral, - theme.palette.Learner.Dark.Default, - theme.palette.Learner.Light.Default, + theme.palette, isFullScreen, isEmptyModuleEditing, - module?.pages, - module?.id, + module, role, numPages, currentPage, + canEdit, draggedIndex, hoverIndex, isPageBookmarked, @@ -826,8 +934,7 @@ const ViewModulePage = () => { handleDragOver, handleDragLeave, handleDrop, - unit?.displayIndex, - unit?.modules, + unit, ], ); @@ -872,9 +979,7 @@ const ViewModulePage = () => { }; const isRightSidebarOpen = - role === "Administrator" && - currentPageObject && - isActivityPage(currentPageObject); + canEdit && currentPageObject && isActivityPage(currentPageObject); const getGridTemplateColumns = () => { if (isRightSidebarOpen) { @@ -1011,7 +1116,7 @@ const ViewModulePage = () => { {activity && (isMultipleChoiceActivity(activity) || isMultiSelectActivity(activity)) && - (role === "Administrator" ? ( + (canEdit ? ( { ))} {activity && isTableActivity(activity) && - (role === "Administrator" ? ( + (canEdit ? ( { ))} {activity && isMatchingActivity(activity) && - (role === "Administrator" ? ( + (canEdit ? ( { Fullscreen )} - {role === "Administrator" && ( + {canEdit && ( <>