Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions backend/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down
122 changes: 122 additions & 0 deletions backend/sockets/moduleEditing.ts
Original file line number Diff line number Diff line change
@@ -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,
};
4 changes: 4 additions & 0 deletions frontend/src/APIClients/ActivityAPIClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ const updateActivity = async <ActivityType extends Activity>(
);
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;
}
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<TextField
variant="outlined"
fullWidth
multiline
minRows={minRows}
maxRows={maxRows}
rows={rows}
placeholder={placeholder}
defaultValue={defaultValue}
onChange={(e) => 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",
},
},
}}
/>
);
};
159 changes: 159 additions & 0 deletions frontend/src/components/pages/ModuleLockedModal.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Modal
open={open}
onClose={onClose}
aria-labelledby="module-locked-title"
aria-describedby="module-locked-description"
>
<Box
sx={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
display: "flex",
width: "450px",
padding: "32px",
flexDirection: "column",
justifyContent: "flex-end",
alignItems: "flex-start",
gap: "32px",
borderRadius: "8px",
border: "1px solid",
borderColor: theme.palette.Neutral[400],
background: "#FFF",
boxShadow: 24,
}}
>
<IconButton
onClick={onClose}
sx={{
position: "absolute",
top: "8px",
right: "8px",
color: theme.palette.Neutral[700],
}}
aria-label="Close modal"
>
<CloseIcon />
</IconButton>

<Stack direction="column" alignItems="flex-start" gap="16px">
<Box
sx={{
display: "flex",
alignItems: "center",
gap: "12px",
}}
>
<LockIcon
sx={{
color: theme.palette.Warning.Dark.Default,
fontSize: "32px",
}}
/>
<Typography
id="module-locked-title"
variant="headlineMedium"
sx={{ color: theme.palette.Neutral[900] }}
>
Module Currently Being Edited
</Typography>
</Box>
<Typography
id="module-locked-description"
variant="bodyMedium"
sx={{ color: theme.palette.Neutral[700] }}
>
<strong>{editorName}</strong> 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.
</Typography>
</Stack>

<Stack direction="row" sx={{ alignSelf: "flex-end", gap: "12px" }}>
<Button
variant="outlined"
onClick={onClose}
sx={{
...commonButtonStyles,
color: theme.palette.Neutral[600],
border: `1px solid ${theme.palette.Neutral[400]}`,
"&:hover": {
border: `1px solid ${theme.palette.Neutral[400]}`,
backgroundColor: theme.palette.Neutral[200],
},
}}
>
Close
</Button>
<Button
variant="contained"
onClick={handleBackToCourse}
sx={{
...commonButtonStyles,
color: "#FFF",
backgroundColor: theme.palette.Administrator.Dark.Default,
"&:hover": {
backgroundColor: theme.palette.Administrator.Dark.Hover,
},
}}
>
Back to Course
</Button>
</Stack>
</Box>
</Modal>
);
};

export default ModuleLockedModal;
Loading