diff --git a/backend/src/middleware/validScenarioId.js b/backend/src/middleware/validScenarioId.js new file mode 100644 index 00000000..d210d68a --- /dev/null +++ b/backend/src/middleware/validScenarioId.js @@ -0,0 +1,18 @@ +import mongoose from "mongoose"; + +const HTTP_BAD_REQUEST = 400; + +/** + * Checks if the scenarioId is valid + */ +export default async function validScenarioId(req, res, next) { + if ( + req.params?.scenarioId && + !mongoose.isValidObjectId(req.params.scenarioId) + ) { + res.status(HTTP_BAD_REQUEST).json({ error: "Invalid scenario ID." }); + return; + } + + next(); +} diff --git a/backend/src/routes/api/group.js b/backend/src/routes/api/group.js index fc828fc5..80fd0d80 100644 --- a/backend/src/routes/api/group.js +++ b/backend/src/routes/api/group.js @@ -1,21 +1,23 @@ import { Router } from "express"; - import { + createGroup, getCurrentScene, getGroup, - createGroup, getGroupByScenarioId, } from "../../db/daos/groupDao.js"; import { retrieveRoleList, updateRoleList } from "../../db/daos/scenarioDao.js"; import Group from "../../db/models/group.js"; +import validScenarioId from "../../middleware/validScenarioId.js"; + const router = Router(); const HTTP_OK = 200; const HTTP_CONFLICT = 409; const HTTP_NO_CONTENT = 204; const HTTP_NOT_FOUND = 404; +const HTTP_BAD_REQUEST = 400; // get the groups assigned to a scenario router.get("/scenario/:scenarioId", async (req, res) => { @@ -39,6 +41,19 @@ router.get("/path/:groupId", async (req, res) => { } }); +// get a group by its id +router.get("/retrieve/:groupId", async (req, res) => { + const { groupId } = req.params; + const group = await getGroup(groupId); + if (!group) { + return res.status(HTTP_NOT_FOUND).json({ error: "Group not found" }); + } + return res.status(HTTP_OK).json(group); +}); + +export default router; + +router.use("/:scenarioId", validScenarioId); // create a new group router.post("/:scenarioId", async (req, res) => { const { groupList, roleList } = req.body; @@ -98,20 +113,7 @@ router.post("/:scenarioId", async (req, res) => { router.get("/:scenarioId/roleList", async (req, res) => { const { scenarioId } = req.params; - const roleList = await retrieveRoleList(scenarioId); res.status(HTTP_OK).json(roleList); }); - -// get a group by its id -router.get("/retrieve/:groupId", async (req, res) => { - const { groupId } = req.params; - const group = await getGroup(groupId); - if (!group) { - return res.status(HTTP_NOT_FOUND).json({ error: "Group not found" }); - } - return res.status(HTTP_OK).json(group); -}); - -export default router; diff --git a/backend/src/routes/api/scenario.js b/backend/src/routes/api/scenario.js index 0bc6ee6a..4186559e 100644 --- a/backend/src/routes/api/scenario.js +++ b/backend/src/routes/api/scenario.js @@ -1,13 +1,15 @@ import { Router } from "express"; import auth from "../../middleware/firebaseAuth.js"; import scenarioAuth from "../../middleware/scenarioAuth.js"; +import validScenarioId from "../../middleware/validScenarioId.js"; import { createScenario, - retrieveScenarioList, - updateScenario, deleteScenario, + retrieveScenario, + retrieveScenarioList, updateDurations, + updateScenario, } from "../../db/daos/scenarioDao.js"; import { retrieveAssignedScenarioList } from "../../db/daos/userDao.js"; @@ -48,8 +50,15 @@ router.post("/", async (req, res) => { }); // Apply scenario auth middleware +router.use("/:scenarioId", validScenarioId); router.use("/:scenarioId", scenarioAuth); +// Get a scenario by id. +router.get("/:scenarioId", async (req, res) => { + const scenario = await retrieveScenario(req.params.scenarioId); + res.status(HTTP_OK).json(scenario); +}); + // Update a scenario by a user router.put("/:scenarioId", async (req, res) => { const { name, duration } = req.body; diff --git a/backend/src/routes/api/scene.js b/backend/src/routes/api/scene.js index 85e5f0f1..02bed3ed 100644 --- a/backend/src/routes/api/scene.js +++ b/backend/src/routes/api/scene.js @@ -2,15 +2,16 @@ import { Router } from "express"; import { createScene, - retrieveSceneList, - retrieveScene, - updateScene, deleteScene, duplicateScene, incrementVisisted, + retrieveScene, + retrieveSceneList, + updateScene, } from "../../db/daos/sceneDao.js"; import auth from "../../middleware/firebaseAuth.js"; import scenarioAuth from "../../middleware/scenarioAuth.js"; +import validScenarioId from "../../middleware/validScenarioId.js"; const router = Router({ mergeParams: true }); @@ -20,6 +21,7 @@ const HTTP_NOT_FOUND = 404; // Apply auth middleware to all routes below this point router.use(auth); // Apply scenario auth middleware +router.use(validScenarioId); router.use(scenarioAuth); // Get scene infromation diff --git a/frontend/index.html b/frontend/index.html index b9e3f512..74ac41ea 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,10 +1,18 @@ -<!DOCTYPE html> -<html lang="en" data-theme="emerald"> +<!doctype html> +<html lang="en" data-theme="VPSTheme"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="theme-color" content="#000000" /> + <link + rel="preload" + href="/fonts/MonaSans.woff2" + as="font" + type="font/woff2" + crossorigin="anonymous" + /> + <title>Virtual Patient System - UoA</title> </head> diff --git a/frontend/src/components/DashedCard.jsx b/frontend/src/components/DashedCard.jsx index fdce33d9..1d8148ce 100644 --- a/frontend/src/components/DashedCard.jsx +++ b/frontend/src/components/DashedCard.jsx @@ -1,5 +1,6 @@ import { Box } from "@material-ui/core"; -import styles from "./ListContainer/ListContainer.module.scss"; + +import AddRoundedIcon from "@mui/icons-material/AddRounded"; /** * Component used to represent a card with a dashed border, used to indicate that a new card can be created. @@ -16,27 +17,27 @@ import styles from "./ListContainer/ListContainer.module.scss"; */ export default function DashedCard({ onClick }) { return ( - <div className={styles.imageListItemWide}> + <div> <div style={{ position: "relative" }}> <Box height={160} - border="5px dashed grey" - borderRadius={10} - borderColor="#747474" - overflow="hidden" - textAlign="center" + onClick={onClick} sx={{ - background: "#f1f1f1", + background: "#f1f5f9", "&:hover": { - background: "#cccccc", + background: "#fff", }, }} - onClick={onClick} - /> - <div className={styles.crossHorizontalLine} /> - <div className={styles.crossVerticalLine} /> + className="cursor-pointer flex justify-center items-center overflow-hidden rounded-xl border-2 border-dashed border-slate-400 bg-slate-100" + > + <AddRoundedIcon + className="text-slate-500" + sx={{ + fontSize: "5rem", + }} + /> + </Box> </div> - <p className={styles.text}>Create New Scene</p> </div> ); } diff --git a/frontend/src/components/DeleteModal.jsx b/frontend/src/components/DeleteModal.jsx index 7f4654eb..7da9a243 100644 --- a/frontend/src/components/DeleteModal.jsx +++ b/frontend/src/components/DeleteModal.jsx @@ -1,5 +1,4 @@ -import { useState } from "react"; -import DeleteButton from "./DeleteButton"; +import DeleteOutlineRoundedIcon from "@mui/icons-material/DeleteOutlineRounded"; function DeleteModal({ onDelete, currentScenario }) { const handleClickOpen = () => { @@ -18,10 +17,11 @@ function DeleteModal({ onDelete, currentScenario }) { return ( <div> <button - className="btn important w-full" + className="btn important w-full font-mono" onClick={handleClickOpen} disabled={!currentScenario} > + <DeleteOutlineRoundedIcon /> Delete </button> <dialog id="delete_modal" className="modal modal-bottom sm:modal-middle"> diff --git a/frontend/src/components/HelpButton.jsx b/frontend/src/components/HelpButton.jsx index 5a814777..445afb38 100644 --- a/frontend/src/components/HelpButton.jsx +++ b/frontend/src/components/HelpButton.jsx @@ -1,4 +1,3 @@ -import Button from "@material-ui/core/Button"; import HelpIcon from "@material-ui/icons/Help"; import { useState } from "react"; diff --git a/frontend/src/components/ListContainer/ListContainer.jsx b/frontend/src/components/ListContainer/ListContainer.jsx deleted file mode 100644 index 979969ef..00000000 --- a/frontend/src/components/ListContainer/ListContainer.jsx +++ /dev/null @@ -1,232 +0,0 @@ -import { useState } from "react"; - -import { Box } from "@material-ui/core"; -import ImageList from "@material-ui/core/ImageList"; -import ImageListItem from "@material-ui/core/ImageListItem"; - -import Thumbnail from "features/authoring/components/Thumbnail"; -import DashedCard from "../DashedCard"; - -import styles from "./ListContainer.module.scss"; -import useStyles from "./component.styles"; - -/** - * Component used to display cards in a list format for scenario and scene selection. - * - * @component - * @example - * const data = [ ... ] - * const wide = true - * const sceneSelectionPage = false - * const scenarioId = "1ef4cD1wsd676dS" - * function onItemSelected() { - * console.log("Selected.") - * } - * function onItemDoubleClick() { - * console.log("Double clicked.") - * } - * function addCard() { - * console.log("Card Added.") - * } - * function onItemBlur() { - * console.log("Item Blurred.") - * } - * return ( - * <ListContainer - * data={data} - * wide={wide} - * sceneSelectionPage={sceneSelectionPage} - * scenarioId={scenarioId} - * onItemSelected={onItemSelected} - * onItemDoubleClick={onDoubleClick} - * addCard={addCard} - * onItemBlur={onItemBlur} - * /> - * ) - */ -export default function ListContainer({ - data, // could be scenarios or scenes data - assignedScenarios, - onItemSelected, - onItemDoubleClick, - wide, - addCard, - onItemBlur, - sceneSelectionPage, - scenarioId, - invalidNameId, -}) { - const classes = useStyles(); - const [selected, setSelected] = useState(); - const columns = wide ? 5 : 4; - - /** Function which executes when an image in the image list is clicked. */ - const onItemClick = (event, item) => { - if (event.detail === 2) { - onItemDoubleClick(item); - } else { - setSelected(item._id); - onItemSelected(item); - } - }; - - /** Function which executes when an image in the image list is right-clicked. Select item. */ - const onItemRightClick = (item) => { - setSelected(item._id); - onItemSelected(item); - }; - - return ( - <> - <div - className={ - wide ? styles.scenarioListContainerWide : styles.scenarioListContainer - } - > - {!sceneSelectionPage && ( - <h1 className="text-3xl font-bold my-3">Created scenarios</h1> - )} - - <ImageList rowHeight={210} cols={columns} gap={30}> - {addCard ? ( - <ImageListItem - className={classes.listContainerItem} - key={-1} - cols={1} - height={200} - > - <DashedCard onClick={addCard} /> - </ImageListItem> - ) : null} - {data && data.length > 0 - ? data.map((item) => ( - <ImageListItem - className={classes.listContainerItem} - key={item._id} - cols={1} - height={200} - onClick={(event) => onItemClick(event, item)} - onContextMenu={() => onItemRightClick(item)} - > - <div - className={ - wide ? styles.imageListItemWide : styles.imageListItem - } - > - <Box - height={160} - border={5} - borderRadius={10} - borderColor={ - item._id === selected ? "#035084" : "#747474" - } - overflow="hidden" - textAlign="center" - display="flex" - sx={{ - background: "#f1f1f1", - "&:hover": { - background: "#cccccc", - }, - }} - > - {sceneSelectionPage ? ( - <Thumbnail components={item.components} /> - ) : ( - <Thumbnail - components={item.thumbnail?.components || []} - /> - )} - </Box> - <input - className={styles.text} - defaultValue={item.name} - onBlur={onItemBlur} - key={item._id} - /> - </div> - {invalidNameId === item._id && ( - <p1 className="nullNameWarning">invalid null name</p1> - )} - </ImageListItem> - )) - : null} - </ImageList> - - {assignedScenarios && assignedScenarios.length ? ( - <> - {!sceneSelectionPage && ( - <h1 className="text-3xl font-bold my-3">Assigned scenarios</h1> - )} - - <ImageList rowHeight={210} cols={columns} gap={30}> - {addCard ? ( - <ImageListItem - className={classes.listContainerItem} - key={-1} - cols={1} - height={200} - > - <DashedCard onClick={addCard} /> - </ImageListItem> - ) : null} - {assignedScenarios && assignedScenarios.length > 0 - ? assignedScenarios.map((item) => ( - <ImageListItem - className={classes.listContainerItem} - key={item._id} - cols={1} - height={200} - onClick={() => { - window.open(`/play/${item._id}`, "_blank"); - }} - > - <div - className={ - wide ? styles.imageListItemWide : styles.imageListItem - } - > - <Box - height={160} - border={5} - borderRadius={10} - borderColor={ - item._id === selected ? "#035084" : "#747474" - } - overflow="hidden" - textAlign="center" - sx={{ - background: "#f1f1f1", - "&:hover": { - background: "#cccccc", - }, - }} - > - <Thumbnail - components={ - sceneSelectionPage - ? item.components - : item.thumbnail?.components || [] - } - /> - </Box> - <input - className={styles.text} - defaultValue={item.name} - onBlur={onItemBlur} - key={item._id} - /> - </div> - {invalidNameId === item._id && ( - <p1 className="nullNameWarning">invalid null name</p1> - )} - </ImageListItem> - )) - : null} - </ImageList> - </> - ) : null} - </div> - </> - ); -} diff --git a/frontend/src/components/ListContainer/ThumbnailList.jsx b/frontend/src/components/ListContainer/ThumbnailList.jsx new file mode 100644 index 00000000..1ff3ece9 --- /dev/null +++ b/frontend/src/components/ListContainer/ThumbnailList.jsx @@ -0,0 +1,140 @@ +import { useState } from "react"; + +import { Box } from "@material-ui/core"; +import ImageList from "@material-ui/core/ImageList"; +import ImageListItem from "@material-ui/core/ImageListItem"; + +import Thumbnail from "features/authoring/components/Thumbnail"; +import DashedCard from "../DashedCard"; + +import styles from "./ThumbnailList.module.scss"; +import useStyles from "./component.styles"; + +/** + * Component used to display cards in a list format for scenario and scene selection. + * + * @component + * @example + * const data = [ ... ] + * function onItemSelected() { + * console.log("Selected.") + * } + * function onItemDoubleClick() { + * console.log("Double clicked.") + * } + * function addCard() { + * console.log("Card Added.") + * } + * function onItemBlur() { + * console.log("Item Blurred.") + * } + * return ( + * <ListContainer + * data={data} + * onItemSelected={onItemSelected} + * onItemDoubleClick={onDoubleClick} + * addCard={addCard} + * onItemBlur={onItemBlur} + * /> + * ) + */ +export default function ThumbnailList({ + data, // could be scenarios or scenes data, but expects components. + invalidNameId, + highlightOnSelect = true, // Whether or not to highlight the card border on select. + addCard, + onItemBlur, + onItemSelected = () => {}, + onItemDoubleClick = () => {}, +}) { + const classes = useStyles(); + const [selected, setSelected] = useState(); + + /** Function which executes when an image in the image list is clicked. */ + const onItemClick = (event, item) => { + if (event.detail === 2) { + onItemDoubleClick(item); + } else { + if (highlightOnSelect) { + setSelected(item._id); + } + onItemSelected(item); + } + }; + + /** Function which executes when an image in the image list is right-clicked. Select item. */ + const onItemRightClick = (item) => { + setSelected(item._id); + onItemSelected(item); + }; + + return ( + <> + <div className={styles.scenarioListContainer}> + <ImageList rowHeight={210} gap={30}> + {addCard ? ( + <ImageListItem + className="min-w-72 max-w-80 min-h-48" + key={-1} + cols={1} + > + <DashedCard onClick={addCard} /> + </ImageListItem> + ) : null} + {data && data.length > 0 + ? data.map((item) => ( + <ImageListItem + className="min-w-72 max-w-80 min-h-48" + key={item._id} + cols={1} + onClick={(event) => onItemClick(event, item)} + onContextMenu={() => onItemRightClick(item)} + > + <div className="flex flex-col gap-2"> + <Box + className="cursor-pointer" + height={160} + border={item._id === selected ? 4 : 2} + borderRadius={10} + borderColor={ + item._id === selected ? "#035084" : "#94a3b8 " + } + overflow="hidden" + textAlign="center" + display="flex" + justifyContent="center" + sx={{ + background: "#e2e8f0", + "&:hover": { + background: "#035084", + }, + }} + > + <Thumbnail components={item?.components || []} /> + </Box> + <div className="w-full flex justify-center "> + {onItemBlur ? ( + <input + className="w-fit font-mona bg-white border border-slate-300 rounded-lg text-center px-2 py-[0.125rem] max-w-full overflow-ellipsis" + defaultValue={item.name} + onBlur={onItemBlur} + key={item._id} + /> + ) : ( + <p className="w-fit font-mona bg-slate-200 rounded-full text-center px-4 py-[0.125rem] max-w-full overflow-ellipsis"> + {item.name} + </p> + )} + </div> + </div> + {invalidNameId === item._id && ( + <p1 className="nullNameWarning">invalid null name</p1> + )} + </ImageListItem> + )) + : null} + </ImageList> + </div> + </> + ); +} diff --git a/frontend/src/components/ListContainer/ListContainer.module.scss b/frontend/src/components/ListContainer/ThumbnailList.module.scss similarity index 76% rename from frontend/src/components/ListContainer/ListContainer.module.scss rename to frontend/src/components/ListContainer/ThumbnailList.module.scss index 09450471..a06d11fd 100644 --- a/frontend/src/components/ListContainer/ListContainer.module.scss +++ b/frontend/src/components/ListContainer/ThumbnailList.module.scss @@ -1,20 +1,11 @@ -.scenarioListContainer { - width: 85vw; - height: 100vh; +.listContainer { + width: 100%; overflow-y: scroll; overflow-x: hidden; padding-right: 30px; padding-left: 30px; } -.scenarioListContainerWide { - flex-grow: 1; - width: 100vw; - height: 90vh; - overflow-y: scroll; - overflow-x: hidden; -} - .imageListItem { margin-top: 20px; margin-left: 3px; @@ -22,13 +13,6 @@ cursor: pointer; } -.imageListItemWide { - padding-top: 20px; - padding-left: 20px; - padding-right: 20px; - cursor: pointer; -} - .crossHorizontalLine { position: absolute; top: calc(50% - 5px); diff --git a/frontend/src/components/ListContainer/component.styles.js b/frontend/src/components/ListContainer/component.styles.js index 0800ef87..65566aee 100644 --- a/frontend/src/components/ListContainer/component.styles.js +++ b/frontend/src/components/ListContainer/component.styles.js @@ -4,12 +4,6 @@ import { makeStyles } from "@material-ui/core"; * This file contains all the styles used to override material-ui components which are being used within a component. */ -const useStyles = makeStyles({ - listContainerItem: { - height: "250px !important", - paddingBottom: "0 !important", - textAlign: "center", - }, -}); +const useStyles = makeStyles({}); export default useStyles; diff --git a/frontend/src/components/ScreenContainer/ScreenContainer.module.scss b/frontend/src/components/ScreenContainer/ScreenContainer.module.scss index 348c68b2..79ec91fb 100644 --- a/frontend/src/components/ScreenContainer/ScreenContainer.module.scss +++ b/frontend/src/components/ScreenContainer/ScreenContainer.module.scss @@ -1,6 +1,7 @@ .rowContainer { display: flex; flex-direction: row; + height: 100%; } .colContainer { diff --git a/frontend/src/components/ShareModal/ShareModal.jsx b/frontend/src/components/ShareModal/ShareModal.jsx index 597a96a7..026572d8 100644 --- a/frontend/src/components/ShareModal/ShareModal.jsx +++ b/frontend/src/components/ShareModal/ShareModal.jsx @@ -1,7 +1,5 @@ -import Button from "@material-ui/core/Button"; import { useContext, useState } from "react"; import ScenarioContext from "../../context/ScenarioContext"; -import styles from "./ShareModal.module.scss"; /** * Component used to a display a share model on the screen, conisting of a copiable link and a button. @@ -19,7 +17,7 @@ import styles from "./ShareModal.module.scss"; export default function ShareModal({ isOpen, handleClose }) { const { currentScenario } = useContext(ScenarioContext); const [copySuccess, setCopySuccess] = useState(false); - const url = `${window.location.origin}/play/${currentScenario._id}`; + const url = `${window.location.origin}/play/${currentScenario?._id}`; /** Function which executes when the modal is closed. */ function onClose() { diff --git a/frontend/src/components/SideBar/SideBar.jsx b/frontend/src/components/SideBar/SideBar.jsx index 5dbd2283..9f87169d 100644 --- a/frontend/src/components/SideBar/SideBar.jsx +++ b/frontend/src/components/SideBar/SideBar.jsx @@ -1,14 +1,17 @@ -import Button from "@material-ui/core/Button"; import { useContext, useState } from "react"; -import { Link, Router, useHistory } from "react-router-dom"; +import { useHistory } from "react-router-dom"; import AuthenticationContext from "../../context/AuthenticationContext"; import ScenarioContext from "../../context/ScenarioContext"; -import AccessLevel from "../../enums/route.access.level"; import { useDelete, usePost } from "../../hooks/crudHooks"; -import styles from "./SideBar.module.scss"; -import HelpButton from "../HelpButton"; import CreateScenerioCard from "../CreateScenarioCard/CreateScenarioCard"; import DeleteModal from "../DeleteModal"; +import HelpButton from "../HelpButton"; +import styles from "./SideBar.module.scss"; + +import AddCircleOutlineRoundedIcon from "@mui/icons-material/AddCircleOutlineRounded"; +import EditRoundedIcon from "@mui/icons-material/EditRounded"; +import LogoutIcon from "@mui/icons-material/Logout"; +import PlayArrowRoundedIcon from "@mui/icons-material/PlayArrowRounded"; /** * Component used for navigation and executing actions located at the left side of the screen. @@ -48,9 +51,6 @@ export default function SideBar() { history.push(`/scenario/${newScenario._id}`); } - /** Calls backend end point to switch to the lecturer's dashboard */ - function openDashboard() {} - /** Calls backend end point to delete a scenario. */ async function deleteScenario() { await useDelete(`/api/scenario/${currentScenario._id}`, getUserIdToken); @@ -81,74 +81,74 @@ export default function SideBar() { onClose={handleCloseCard} /> )} - <div className={styles.sideBar}> - <img - draggable="false" - className={styles.logo} - src="uoa-logo.png" - alt="University of Auckland Logo" - /> - <ul className={styles.sideBarList}> - <li> - <button - className="btn vps w-full" - onClick={() => { - handleOpenCard(); - }} - > - Create - </button> - </li> - {VpsUser.role === AccessLevel.STAFF ? ( + + {/* Main sidebar */} + <div className={`${styles.sideBar} bg-uoa-blue `}> + {/* UoA logo container */} + <div className="flex-0 p-7"> + <img + draggable="false" + className={styles.logo} + src="uoa-logo.png" + alt="University of Auckland Logo" + /> + </div> + + {/* Button containers */} + <div className="flex flex-col w-full flex-1 justify-between pb-5"> + <ul className={`${styles.sideBarList}`}> <li> <button - className="btn vps w-full" + className="btn vps font-mono" onClick={() => { - history.push("/dashboard"); + handleOpenCard(); }} + > + <AddCircleOutlineRoundedIcon /> + <span className="min-w-12">Create</span> + </button> + </li> + <li> + <button + className="btn vps font-mono" + onClick={playScenario} + disabled={!currentScenario} + > + <PlayArrowRoundedIcon /> + <span className="min-w-12">Play</span> + </button> + </li> + <li> + <button + className="btn vps font-mono" disabled={!currentScenario} + onClick={() => { + history.push(`/scenario/${currentScenario._id}`); + }} > - Dashboard + <EditRoundedIcon /> + <span className="min-w-12">Edit</span> </button> </li> - ) : ( - "" - )} - <li> - <button - className="btn vps w-full" - onClick={playScenario} - disabled={!currentScenario} - > - Play - </button> - </li> - <li> - <button - className="btn vps w-full" - disabled={!currentScenario} - onClick={() => { - history.push(`/scenario/${currentScenario._id}`); - }} - > - Edit - </button> - </li> - <li> - <DeleteModal - onDelete={deleteScenario} - currentScenario={currentScenario} - /> - </li> - <li> - <button className="btn vps w-full" onClick={signOut}> - Logout - </button> - </li> - <li className="styles.helpButton"> - <HelpButton isSidebar /> - </li> - </ul> + <li> + <DeleteModal + onDelete={deleteScenario} + currentScenario={currentScenario} + /> + </li> + </ul> + + <ul className={styles.sideBarList}> + <li> + <button className="btn vps font-mono" onClick={signOut}> + <LogoutIcon /> <span className="min-w-13">Logout</span> + </button> + </li> + <li> + <HelpButton isSidebar /> + </li> + </ul> + </div> </div> </> ); diff --git a/frontend/src/components/SideBar/SideBar.module.scss b/frontend/src/components/SideBar/SideBar.module.scss index 16b56d57..92dd93ea 100644 --- a/frontend/src/components/SideBar/SideBar.module.scss +++ b/frontend/src/components/SideBar/SideBar.module.scss @@ -1,36 +1,30 @@ .sideBar { - min-width: 250px; - width: 15vw; - height: 100vh; - background-color: #035084; - text-align: center; + min-width: 16rem /* 240px */; + width: 16rem /* 240px */; + height: 100%; display: flex; flex-direction: column; align-items: center; - --tooltip-color: black; - - .logo { - margin: 10px; - width: 230px; - } .sideBarList { list-style: none; - padding: 0; - width: 12.5rem; - - li:not(:last-child) { - margin-bottom: 10%; - } + width: 100%; + padding-left: 1.75rem /* 28px */; + padding-right: 1.75rem /* 28px */; + display: flex; + flex-direction: column; + justify-content: center; + gap: 0.75rem /* 12px */; .buttonDisabled { opacity: 0.3; } - } - .helpButton { - & > button { + li > button { width: 100%; + letter-spacing: 0.05em; + font-weight: 300; + text-align: left; } } diff --git a/frontend/src/context/ScenarioContextProvider.jsx b/frontend/src/context/ScenarioContextProvider.jsx index 35eed6a2..e05c1e2c 100644 --- a/frontend/src/context/ScenarioContextProvider.jsx +++ b/frontend/src/context/ScenarioContextProvider.jsx @@ -1,6 +1,8 @@ -import { useState } from "react"; +import { useContext, useEffect, useState } from "react"; + import { useGet } from "../hooks/crudHooks"; import useLocalStorage from "../hooks/useLocalStorage"; +import AuthenticationContext from "./AuthenticationContext"; import ScenarioContext from "./ScenarioContext"; /** @@ -8,6 +10,7 @@ import ScenarioContext from "./ScenarioContext"; * ScenarioContextProvider allows access to scenario info and the refetch function */ export default function ScenarioContextProvider({ children }) { + const { user } = useContext(AuthenticationContext); const [currentScenario, setCurrentScenario] = useLocalStorage( "currentScenario", null @@ -16,20 +19,30 @@ export default function ScenarioContextProvider({ children }) { const [assignedScenarios, setAssignedScenarios] = useState(); const [roleList, setRoleList] = useState(); - const { reFetch } = useGet(`api/scenario`, setScenarios, true); + const { reFetch } = useGet(`api/scenario`, setScenarios, true, !user); const { reFetch: reFetch2 } = useGet( `api/scenario/assigned`, setAssignedScenarios, - true + true, + !user ); - useGet( + const { reFetch: reFetch3 } = useGet( `api/group/${currentScenario?._id}/roleList`, setRoleList, true, !currentScenario // Skip request if there is no current scenario. ); + // We may load before the auth is ready, refetch if we did. + useEffect(() => { + if (user) { + reFetch(); + reFetch2(); + reFetch3(); + } + }, [user]); + return ( <ScenarioContext.Provider value={{ diff --git a/frontend/src/context/SceneContextProvider.jsx b/frontend/src/context/SceneContextProvider.jsx index c0732122..624d9b1d 100644 --- a/frontend/src/context/SceneContextProvider.jsx +++ b/frontend/src/context/SceneContextProvider.jsx @@ -1,6 +1,7 @@ import { useContext, useEffect, useRef, useState } from "react"; import { useGet } from "../hooks/crudHooks"; import useLocalStorage from "../hooks/useLocalStorage"; +import AuthenticationContext from "./AuthenticationContext"; import ScenarioContext from "./ScenarioContext"; import SceneContext from "./SceneContext"; @@ -10,6 +11,7 @@ import SceneContext from "./SceneContext"; */ export default function SceneContextProvider({ children }) { const { currentScenario } = useContext(ScenarioContext); + const { user } = useContext(AuthenticationContext); const [scenes, setScenes] = useState([]); const [currentScene, setCurrentScene] = useLocalStorage("currentScene", null); const [monitorChange, setMonitorChange] = useState(false); @@ -17,15 +19,16 @@ export default function SceneContextProvider({ children }) { const currentSceneRef = useRef(currentScene); - let getScenes = null; + const { reFetch } = useGet( + `api/scenario/${currentScenario?._id}/scene/all`, + setScenes, + true, + !currentScenario + ); - if (currentScenario) { - getScenes = useGet( - `api/scenario/${currentScenario._id}/scene/all`, - setScenes, - true - ); - } + useEffect(() => { + reFetch(); + }, [user]); /** * monitorChange variable is used to determine @@ -67,7 +70,7 @@ export default function SceneContextProvider({ children }) { value={{ scenes, setScenes, - reFetch: getScenes?.reFetch, + reFetch, currentScene, setCurrentScene, hasChange, diff --git a/frontend/src/features/scenarioSelection/ScenarioSelectionPage.jsx b/frontend/src/features/scenarioSelection/ScenarioSelectionPage.jsx index 2f2951b0..b6d8c956 100644 --- a/frontend/src/features/scenarioSelection/ScenarioSelectionPage.jsx +++ b/frontend/src/features/scenarioSelection/ScenarioSelectionPage.jsx @@ -2,7 +2,7 @@ import MenuItem from "@material-ui/core/MenuItem"; import { useContext, useEffect, useState } from "react"; import { useHistory } from "react-router-dom"; import ContextMenu from "../../components/ContextMenu"; -import ListContainer from "../../components/ListContainer/ListContainer"; +import ThumbnailList from "../../components/ListContainer/ThumbnailList"; import ScreenContainer from "../../components/ScreenContainer/ScreenContainer"; import SideBar from "../../components/SideBar/SideBar"; import AuthenticationContext from "../../context/AuthenticationContext"; @@ -10,14 +10,17 @@ import ScenarioContext from "../../context/ScenarioContext"; import AccessLevel from "../../enums/route.access.level"; import { useDelete, usePut } from "../../hooks/crudHooks"; +import MovieFilterRoundedIcon from "@mui/icons-material/MovieFilterRounded"; +import TheatersRoundedIcon from "@mui/icons-material/TheatersRounded"; + /** * Page that shows the user's existing scenarios. * * @container */ -export default function ScenarioSelectionPage({ data = null }) { +export default function ScenarioSelectionPage() { const { - scenarios, + scenarios: userScenarios, reFetch, assignedScenarios, reFetch2, @@ -103,7 +106,7 @@ export default function ScenarioSelectionPage({ data = null }) { return ( <ScreenContainer> <SideBar /> - <div onContextMenu={handleContextMenu}> + <div onContextMenu={handleContextMenu} className="w-full h-full"> <ContextMenu position={contextMenuPosition} setPosition={setContextMenuPosition} @@ -125,14 +128,53 @@ export default function ScenarioSelectionPage({ data = null }) { "" )} </ContextMenu> - <ListContainer - data={data || scenarios} - assignedScenarios={assignedScenarios || []} - onItemSelected={setCurrentScenario} - onItemDoubleClick={editScenario} - onItemBlur={changeScenarioName} - invalidNameId={invalidNameId} - /> + + {/* Scenario List */} + <div className="w-full h-full px-10 py-10 overflow-x-hidden overflow-y-scroll flex flex-col gap-10"> + {/* List of scenarios created by the logged-in user */} + {userScenarios && ( + <div> + <h1 className="text-3xl font-mona font-bold my-3 flex items-center gap-3"> + <MovieFilterRoundedIcon fontSize="large" /> Your Scenarios + </h1> + + <div> + <ThumbnailList + // data={userScenarios} + data={userScenarios.map((scenario) => { + scenario.components = scenario.thumbnail?.components || []; + return scenario; + })} + onItemSelected={setCurrentScenario} + onItemDoubleClick={editScenario} + onItemBlur={changeScenarioName} + invalidNameId={invalidNameId} + /> + </div> + </div> + )} + + {/* List of scenarios assigned to the logged-in user */} + {assignedScenarios && ( + <div> + <h1 className="text-3xl font-mona font-bold my-3 flex items-center gap-3"> + <TheatersRoundedIcon fontSize="large" /> Assigned Scenarios + </h1> + <ThumbnailList + data={assignedScenarios.map((scenario) => { + scenario.components = scenario.thumbnail?.components || []; + return scenario; + })} + onItemSelected={(scenario) => { + // For assigned scenarios, play the scenario on click. + window.open(`/play/${scenario._id}`, "_blank"); + }} + invalidNameId={invalidNameId} + highlightOnSelect={false} + /> + </div> + )} + </div> </div> </ScreenContainer> ); diff --git a/frontend/src/features/sceneSelection/SceneSelectionPage.jsx b/frontend/src/features/sceneSelection/SceneSelectionPage.jsx index e048ebee..3d1561f2 100644 --- a/frontend/src/features/sceneSelection/SceneSelectionPage.jsx +++ b/frontend/src/features/sceneSelection/SceneSelectionPage.jsx @@ -1,5 +1,5 @@ -import { Button, Divider, MenuItem } from "@material-ui/core"; -import ListContainer from "components/ListContainer/ListContainer"; +import { Divider, MenuItem } from "@material-ui/core"; +import ThumbnailList from "components/ListContainer/ThumbnailList"; import Papa from "papaparse"; import { useContext, useEffect, useRef, useState } from "react"; import { @@ -16,9 +16,16 @@ import ShareModal from "../../components/ShareModal/ShareModal"; import TopBar from "../../components/TopBar/TopBar"; import AuthenticationContext from "../../context/AuthenticationContext"; import AuthoringToolContextProvider from "../../context/AuthoringToolContextProvider"; +import ScenarioContext from "../../context/ScenarioContext"; import SceneContext from "../../context/SceneContext"; import AccessLevel from "../../enums/route.access.level"; -import { useDelete, usePatch, usePost, usePut } from "../../hooks/crudHooks"; +import { + useDelete, + useGet, + usePatch, + usePost, + usePut, +} from "../../hooks/crudHooks"; import AuthoringToolPage from "../authoring/AuthoringToolPage"; // !! this should be handled by the backend instead @@ -35,14 +42,23 @@ function generateUID() { * * @container */ -export function SceneSelectionPage({ data = null }) { +export function SceneSelectionPage() { const [isShareModalOpen, setShareModalOpen] = useState(false); const { scenarioId } = useParams(); const { url } = useRouteMatch(); const history = useHistory(); + const { currentScenario, setCurrentScenario } = useContext(ScenarioContext); const { scenes, currentScene, setCurrentScene, reFetch } = useContext(SceneContext); - const { getUserIdToken, VpsUser } = useContext(AuthenticationContext); + const { user, getUserIdToken, VpsUser } = useContext(AuthenticationContext); + + // Retrieve scenario on load + useGet( + `api/scenario/${scenarioId}`, + setCurrentScenario, + true, + !(user && (!currentScenario || currentScenario?._id != scenarioId)) + ); // File input is a hidden input element that is activated via a click handler // This allows us to have an UI button that acts like a file <input> element. @@ -228,17 +244,17 @@ export function SceneSelectionPage({ data = null }) { </TopBar> {/* On top of the action button available in the top menu bar, we also override user's rightclick context menu to offer the same functionality. */} - <div onContextMenu={handleContextMenu}> + <div + onContextMenu={handleContextMenu} + className="w-full h-full px-10 py-7 overflow-y-scroll" + > {/* Scene list */} - <ListContainer - data={data || scenes} + <ThumbnailList + data={scenes} onItemSelected={setCurrentScene} onItemDoubleClick={editScene} addCard={createNewScene} - wide onItemBlur={changeSceneName} - sceneSelectionPage - scenarioId={scenarioId} invalidNameId={invalidNameId} /> diff --git a/frontend/src/hooks/crudHooks.jsx b/frontend/src/hooks/crudHooks.jsx index 1e5f9114..27fa5dfe 100644 --- a/frontend/src/hooks/crudHooks.jsx +++ b/frontend/src/hooks/crudHooks.jsx @@ -233,6 +233,8 @@ export function useGet(url, setData, requireAuth = true, skipRequest = false) { } useEffect(() => { + let isMounted = true; + async function fetchData() { let hasError = false; setLoading(true); @@ -252,7 +254,7 @@ export function useGet(url, setData, requireAuth = true, skipRequest = false) { hasError = isRealError(err); }); - if (!hasError) { + if (!hasError && isMounted) { setData(response.data); } @@ -263,7 +265,11 @@ export function useGet(url, setData, requireAuth = true, skipRequest = false) { if (!skipRequest) { fetchData(); } - }, [url, version]); + + return () => { + isMounted = false; + }; + }, [url, skipRequest, version]); return { isLoading, reFetch }; } diff --git a/frontend/src/index.css b/frontend/src/index.css index 7ca7dfdd..822e5f8b 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -5,7 +5,7 @@ /* MonaSans - licensed under OFL. See https://github.com/github/mona-sans/blob/main/LICENSE */ @font-face { font-family: "MonaSans"; - src: url("fonts/MonaSans.woff2") format("woff2"); + src: url("/fonts/MonaSans.woff2") format("woff2"); } /* Pass down viewport width/height to children */ diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js index 5bb3f87a..c4015fc1 100644 --- a/frontend/tailwind.config.js +++ b/frontend/tailwind.config.js @@ -17,6 +17,7 @@ module.exports = { themes: [ { VPSTheme: { + ...require("daisyui/src/theming/themes")["emerald"], primary: "#fafafa", secondary: "#035084", error: "#c13216",