https://museumrender.onrender.com/
MIME were a team of four members, with each of us being responsible for different app functionalities and roles. We worked closely with the UI/UX team, K2Z, to develop the app prototype.
Ernest, the GitHub Manager, was responsible for setting up the app environment, configuring the React Material-UI theme, integrating GitHub functionality, and implementing a map search feature.
Ida, the Scrum Master, utilized Trello to foster communication and plan the scope around the deadlines. She was responsible for managing the exhibition CRUD functionality.
May, the Documenter, was responsible for wireframing, collecting sample database, and managing the user & admin login/sign-up and artwork CRUD functionalities.
Mark, the Database Manager, was responsible for creating the database model, seeding the data, and implementing the map route feature.
K2Z, a group consisting of three members, Kally, Kriti, and Zhi Qing, were primarily responsible for identifying the key UI/UX improvements and designing the app prototype.
- As a user, I want to read accessible, consistent information on home screen & exhibition detail screen.
- As a logged in user, I want to leave a comment about the exhibition that I joined at National Gallery Singapore.
- As a visitor, I want to improve my wayfinding and exploration of National Gallery Singapore through the map feature.
- As an admin, I want to create, update and delete exhibition and artworks information.
- React
- React Material UI
- Node.js
- Express Framework
- MongoDB & Mongoose
- JavaScript
- Render deployment
- Git & GitHub
User.js
const usersSchema = new Schema(
{
name: { type: String, required: true },
email: {
type: String,
unique: true,
trim: true,
lowercase: true,
required: true,
},
password: {
type: String,
unique: true,
trim: true,
minLength: 5,
required: true,
},
userRole: {
type: String,
required: true,
enum: ["user", "admin"],
},
},
{
timestamps: {
createdAt: "created_at",
updatedAt: "updated_at",
},
}
);
users-service.js
export async function signUp(userData) {
const token = await usersAPI.signUp(userData);
localStorage.setItem("token", token);
return getUser();
}
export function getUser() {
const token = getToken();
return token ? JSON.parse(window.atob(token?.split(".")[1])).user : null;
}
export function getToken() {
const token = localStorage.getItem("token");
if (!token) return null;
const payload = JSON.parse(window.atob(token.split(".")[1]));
if (payload.exp < Date.now() / 1000) {
localStorage.removeItem("token");
return null;
}
return token;
}
export function logout() {
localStorage.removeItem("token");
}
The signUp(userData) function is an asynchronous function that takes a userData object as input. It uses the usersAPI module to make a network request by calling the signUp() function and waiting for the response. The response is a JSON Web Token (JWT), which is stored in local storage using the localStorage.setItem() method. The function then calls getUser() to get the user associated with the token and returns the result.
The getUser() function calls getToken() to obtain the JSON Web Token (JWT). Then uses the token to retrieve the user associated with it. If a token is present, it decodes it and returns the user property of the resulting object. However, if there is no token, it returns null.
The getToken() function retrieves the JSON Web Token (JWT) stored in local storage. If there is no token stored, it returns null. If a token is present, it will decodes it using window.atob() and parses the resulting JSON string to obtain the payload. If the token has expired, as indicated by the exp property in the payload, the function removes the token from local storage and returns null. Otherwise, it returns the token itself.
The logout() function removes the JSON Web Token (JWT) from local storage using the localStorage.removeItem() method.
usersController.js
const create = async (req, res) => {
const { password } = req.body;
if (password.length < 5) {
res
.status(400)
.json({ message: "Password is too Short, Please Try Again." });
return;
}
try {
const user = await User.create(req.body);
const payload = { user };
const token = jwt.sign(payload, JWT_SECRET, { expiresIn: 60 }); // 1hr
res.status(201).json(token);
} catch (error) {
res.status(500).json(error);
}
};
const login = async (req, res) => {
const { email, password } = req.body;
if (password.length < 5) {
res.status(400).json({ message: "Incorrect Password" });
return;
}
try {
const user = await User.findOne({ email });
if (user === null) {
res.status(401).json({ message: "No user found, Please sign up." });
return;
}
const match = await bcrypt.compare(password, user.password);
if (match) {
const payload = { user };
const token = jwt.sign(payload, JWT_SECRET, { expiresIn: 60 });
res.status(200).json({ token });
console.log("user login successful");
} else {
res.status(401).json({ message: "Wrong password" });
}
} catch (error) {
res.status(500).json(error);
}
};
The create() function firstly checks the length of the password provided in the request body. If it's less than 5 characters/numbers, it responds with a 400 Bad Request status code and a JSON object containing an error message. If the password is long enough, the function tries to create a new user in the database using the User.create() method, passing in the request body as the data to be stored. If the user is created successfully, it creates a payload object containing the user and signs it with a JSON Web Token using the jwt.sign() method. The signed token is then returned in the response with a 201 Created status code. If an error occurs during user creation, such as a database error, it responds with a 500 Internal Server Error status code and returns the error as a JSON object.
The login() function attempts to find a user in the database with the specified email using the User.findOne() method. If the user is not found, it responds with a 401 Unauthorized status code and a JSON object containing a message property with the value "No user found, Please sign up." If a user is found, it compares the password provided in the request body with the user's stored password using the bcrypt.compare() method. If the passwords match, it creates a payload object containing the user and signs it with a JSON Web Token using the jwt.sign() method. The signed token is then returned in the response with a 200 OK status code. If the passwords don't match, it responds with a 401 Unauthorized status code and a JSON object containing a message property with the value "Wrong password." If an error occurs during the login process, such as a database error, it responds with a 500 Internal Server Error status code and returns the error as a JSON object.
App.jsx
...
<Route path="/users/signup" element={<SignUpForm />} />
<Route path="/users/login" element={<LoginForm setUser={setUser} />} />
<Route path="/users/logout" element={<LogOutMsg />} />
...
React routes, paths to different components. For example, when a user navigates to "/users/signup" in the application, the component will be rendered.
...
<Route path="/*" element={<AccessDeniedMsg />} />
...
If a user navigates to a URL that doesn't match any of the routes defined in the Router component, this AccessDeniedMsg component will be rendered instead.
...
<Route path="/artworks/new" element={user && user.userRole == "admin" ? <ArtworksNew user={user} /> : <AccessDeniedMsg />} />
<Route path="/artworks/:id/edit" element={user && user.userRole == "admin" ? <ArtworksEditForm user={user} /> : <AccessDeniedMsg />} />
<Route path="/exhibitions/new" element={user && user.userRole == "admin" ? <ExhibitionNew user={user} /> : <AccessDeniedMsg />} />
<Route path="/exhibitions/:id/edit" element={user && user.userRole == "admin" ? <ExhibitionUpdate user={user} /> : <AccessDeniedMsg />} />
<Route path="/admin/signup" element={user && user.userRole == "admin" ? <AdminSignUpForm user={user} /> : <AccessDeniedMsg />} />
...
The ternary operator is used to conditionally render certain components based on whether the user accessing the site is an admin or not. If the user is an admin, then they are allowed to access certain restricted routes. However, the user is not an admin, it renders an AccessDeniedMsg component, an "Access Denied" message will be shown and prompted the user to log in as an admin.
SignUpForm.jsx
export default function SignUpForm({ setUser }) {
const [state, setState] = useState({
name: "",
email: "",
password: "",
confirm: "",
userRole: "user",
});
const [error, setError] = useState("");
const navigate = useNavigate();
const disable = state.password !== state.confirm;
const handleSubmit = (event) => {
event.preventDefault();
if (state.password.length < 5) {
setError("Password must be at least 5 characters or numbers long.");
return;
}
window.alert(
state.email + " Account has been created successfully. Please Login."
);
fetch("/api/users", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(state),
})
.then((response) => response.json())
.then((data) => console.log(data));
console.log("submitted");
navigate("/users/login");
};
const handleChange = (event) => {
setState({
...state,
[event.target.name]: event.target.value,
userRole: "user",
});
console.log(state);
};
return (
<Box className="SignUpFormContainer">
<form
component="form"
autoComplete="off"
onSubmit={handleSubmit}
className="SignUpForm"
>
<Typography variant="h5">Sign Up a new Account </Typography>
{error}
<Box className="R1">
<TextField
id="outlined-basic"
label="UserName"
variant="outlined"
type="text"
name="name"
value={state.name}
onChange={handleChange}
required
/>
</Box>
<Box className="R1">
<TextField
id="outlined-basic"
label="Email Address"
variant="outlined"
type="email"
name="email"
value={state.email}
onChange={handleChange}
required
/>
</Box>
<Box className="R1">
<TextField
id="outlined-basic"
label="Password (min 5)"
variant="outlined"
type="password"
name="password"
value={state.password}
onChange={handleChange}
required
/>
</Box>
<Box className="R1">
<TextField
id="outlined-basic"
label="Confirm Password"
variant="outlined"
type="password"
name="confirm"
value={state.confirm}
onChange={handleChange}
required
/>
</Box>
<Box className="R1">
<Button variant="contained" type="submit" disabled={disable}>
Sign Up
</Button>
<p className="error-message"> {state.error}</p>
</Box>
<Box className="R1">
<Typography variant="p">
Already have an account?
<Link to={`/users/login`}>
{" "}
<Button>Login</Button>{" "}
</Link>
</Typography>
</Box>
</form>
</Box>
);
}
This sign up form component receives the setUser function as a prop, which is used to update the user state after successful sign up. The state contains the user's name, email, password, and a confirmation of the password. The user role is set as "user" by default. The handleChange() function is used to update the state whenever an input field changes and it’s user role is set as a user by default. The handleSubmit() function is called when the user submits the form. It checks if the password is at least 5 characters/numbers long and if the password and confirm fields match. If these conditions are met, it sends a POST request to the server to create a new user account. If the account is successfully created, it displays an alert message and navigates to the login page. Otherwise, it displays an error message.
LoginForm.jsx
export default function LoginForm({ setUser }) {
const [error, setError] = useState("");
const navigate = useNavigate();
const handleLogin = async (event) => {
event.preventDefault();
const formData = new FormData(event.target);
const body = Object.fromEntries(formData);
try {
const response = await fetch("/api/users/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
const data = await response.json();
if (data.token) {
localStorage.setItem("token", data.token);
setUser(getUser());
setError("");
window.alert("Account has login successfully.");
navigate("/");
} else {
setError(data.message);
}
} catch (error) {
setError(error.message);
}
};
return (
<Box className="LoginFormContainer">
<form onSubmit={handleLogin} className="LoginForm">
<br></br>
<Typography variant="h5">User Login </Typography>
{error}
<Box className="R2">
<TextField
type="email"
label="Enter your email address"
name="email"
required
/>
</Box>
<Box className="R2">
<TextField
label="Enter your password"
name="password"
type="password"
required
/>
</Box>
<Box className="R2">
<Button variant="contained" type="submit">
Login
</Button>
</Box>
<Box className="R2">
<Typography variant="p">No account yet? </Typography>
<Link to={`/users/signup`}>
{" "}
<Button>Sign Up</Button>{" "}
</Link>
</Box>
</form>
</Box>
);
}
The LoginForm component is similar to the SignUpForm component, except that it is used for logging in existing users. It also receives a setUser prop and uses the useState hook to manage form state and error messages. It uses the useNavigate hook to navigate between pages after the form is submitted. When the form is submitted, it sends a POST request to the /api/users/login endpoint with the form data. If the response contains a token, it is stored in localStorage and the user is redirected to the homepage. Otherwise, an error message is displayed. Non-existing users can sign up for an account using the link leading to the sign-up page.
Code extract example will focus on edit and update of Artwork CRUD.
Artwork.js
const artworkSchema = new Schema(
{
artworkUrl: {
type: String,
required: true,
},
artistName: {
type: String,
required: true,
},
artworkTitle: {
type: String,
required: true,
},
artworkYear: {
type: String,
required: true,
},
artworkMedium: {
type: String,
},
artworkDimension: {
type: String,
},
artworkInformation: {
type: String,
},
artworkLocation: {
type: String,
required: true,
},
artworkFloor: {
type: String,
enum: ["B1", "L1", "L2", "L3", "L4", "L5", "L6"],
default: "L1",
required: true,
},
},
{
timestamps: {
createdAt: "created_at",
updatedAt: "updated_at",
},
}
);
artworksController.js
const Artwork = require("../models/Artwork");
const create = async (req, res) => {
try {
const createdArtwork = await Artwork.create(req.body);
res.status(200).send(createdArtwork);
} catch (error) {
res.status(400).json({ error: error.message });
}
};
const index = async (req, res) => {
try {
const foundArtwork = await Artwork.find({});
res.status(200).send(foundArtwork);
} catch (error) {
res.status(400).json({ error: error.message });
}
};
const deleteArtwork = async (req, res) => {
try {
const deletedArtwork = await Artwork.findByIdAndRemove(req.params.id);
res.status(200).send(deletedArtwork);
} catch (error) {
res.status(400).json({ error: error.message });
}
};
const show = async (req, res) => {
try {
const artwork = await Artwork.findById(req.params.id);
res.status(200).send(artwork);
} catch (error) {
res.status(400).json({ error: error.message });
}
};
const update = async (req, res) => {
try {
const updatedArtwork = await Artwork.findByIdAndUpdate(
req.params.id,
req.body,
{ new: true }
);
res.status(200).send(updatedArtwork);
} catch (error) {
res.status(400).json({ error: error.message });
}
};
module.exports = {
create,
index,
delete: deleteArtwork,
show,
update,
};
The create() function handles the creation of new artwork by calling the create method on the Artwork model with the request body. If successful, it returns the created artwork with a status code of 200. If there is an error, it returns a status code of 400 and an error message.
The index() function retrieves all artworks by calling the find method on the Artwork model with an empty query. If successful, it returns an array of artworks with a status code of 200. If there is an error, it returns a status code of 400 and an error message.
The deleteArtwork() function handles the deletion of artwork by calling the findByIdAndRemove method on the Artwork model with the artwork ID from the request parameters. If successful, it returns the deleted artwork with a status code of 200. If there is an error, it returns a status code of 400 and an error message.
The show() function retrieves a single artwork by calling the findById method on the Artwork model with the artwork ID from the request parameters. If successful, it returns the artwork with a status code of 200. If there is an error, it returns a status code of 400 and an error message.
The update() function handles the updating of artwork by calling the findByIdAndUpdate method on the Artwork model with the artwork ID from the request parameters and the request body, with { new: true } to return the updated artwork. If successful, it returns the updated artwork with a status code of 200. If there is an error, it returns a status code of 400 and an error message.
ArtworksEditForm.jsx
function ArtworksEditForm({ user }) {
const { id } = useParams();
const [artwork, setArtwork] = useState({});
const navigate = useNavigate();
useEffect(() => {
const fetchArtwork = async () => {
const response = await fetch(`/api/artworks/${id}`);
const artwork = await response.json();
setArtwork(artwork);
};
fetchArtwork();
}, [id]);
const handleChange = (event) => {
const key = event.target?.name;
const value = event.target?.value;
setArtwork({ ...artwork, [key]: value });
};
const handleUpdate = async (event) => {
event.preventDefault();
const response = await fetch(`/api/artworks/${id}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(artwork),
});
navigate("/artworks");
};
return (
<Box
className="ArtworksEditForm"
component="form"
noValidate
autoComplete="off"
>
<FormControl className="EditArtworkForm" autoComplete="off">
<Typography className="EditArtworks" variant="h4">
Edit Artworks
</Typography>
<TextField
label="Image URL"
type="url"
name="artworkUrl"
value={artwork.artworkUrl || ""}
onChange={handleChange}
/>
<TextField
label="Artwork Title"
type="text"
name="artworkTitle"
value={artwork.artworkTitle || ""}
onChange={handleChange}
/>
<TextField
label="Artist Name"
type="text"
name="artistName"
value={artwork.artistName || ""}
onChange={handleChange}
/>
<TextField
label="Dimension"
type="text"
name="artworkDimension"
value={artwork.artworkDimension || ""}
onChange={handleChange}
/>
<TextField
label="Information"
type="text"
name="artworkInformation"
value={artwork.artworkInformation || ""}
onChange={handleChange}
/>
<TextField
label="Medium"
type="text"
name="artworkMedium"
value={artwork.artworkMedium || ""}
onChange={handleChange}
/>
<TextField
label="Year"
type="number"
name="artworkYear"
value={artwork.artworkYear || ""}
onChange={handleChange}
/>
<TextField
label="Location"
type="text"
name="artworkLocation"
value={artwork.artworkLocation || ""}
onChange={handleChange}
/>
<FormControl fullWidth sx={{ m: 2 }} autoComplete="off">
<label>Floor</label>
<Select
sx={{ width: "200px" }}
label="Floor"
name="artworkFloor"
value={artwork.artworkFloor || ""}
onChange={handleChange}
>
<MenuItem value="" disabled>
Select Floor
</MenuItem>
<MenuItem value="B1">B1</MenuItem>
<MenuItem value="L1">L1</MenuItem>
<MenuItem value="L2">L2</MenuItem>
<MenuItem value="L3">L3</MenuItem>
<MenuItem value="L4">L4</MenuItem>
<MenuItem value="L5">L5</MenuItem>
<MenuItem value="L6">L6</MenuItem>
</Select>
</FormControl>
<Button type="submit" variant="contained" onClick={handleUpdate}>
Update Artwork
</Button>
</FormControl>
</Box>
);
}
export default ArtworksEditForm;
This Artworks Edit Form component allows the admin to edit an artwork. It receives a user object as a prop and uses the useParams and useNavigate hooks from react-router-dom to get the ID of the artwork and navigate to the artworks page after updating the artwork. The handleChange() function is called when the admin changes the input fields and updates the state with the new values. The handleUpdate() function is used to update the artwork, when the admin clicks the "Update Artwork" button. It sends a PUT request to the backend API with the updated artwork data and navigates to the artworks page.
Code extract example will focus on read and update of Exhibition CRUD.
Exhibition.js
const commentSchema = new Schema(
{
comments: {
type: String,
trim: true,
},
},
{
timestamps: true,
}
);
const exhibitionsSchema = new Schema(
{
exhibitionTitle: {
type: String,
required: true,
trim: true,
minlength: 10,
maxlength: 50,
},
exhibitionTitleSub: {
type: String,
trim: true,
minlength: 10,
maxlength: 50,
},
exhibitionDescription: {
type: String,
required: true,
trim: true,
minlength: 50,
maxlength: 300,
},
exhibitionInformation: {
type: String,
required: true,
trim: true,
minlength: 200,
maxlength: 1500,
},
exhibitionStartDate: {
type: Date,
required: true,
min: Date.now,
},
exhibitionEndDate: {
type: Date,
required: true,
validate: {
validator: function (value) {
return value > this.exhibitionStartDate;
},
message: "Exhibition end date must be after exhibition start date",
},
},
exhibitionEntry: {
type: String,
required: true,
},
exhibitionImage: {
type: String,
required: true,
validate: {
validator: function (value) {
// Regular expression to match URLs ending with common image file extensions
var regex = /\.(jpg|jpeg|gif|png|bmp)$/i;
return regex.test(value);
},
message: "Please provide a valid image URL",
},
},
exhibitionLocation: {
type: String,
required: true,
},
exhibitionFloor: {
type: String,
enum: ["B1", "L1", "L2", "L3", "L4", "L5", "L6"],
required: true,
},
artworks: [
{
type: Schema.Types.ObjectId,
ref: "Artwork",
},
],
exhibitionComments: [commentSchema],
},
{
timestamps: {
createdAt: "created_at",
updatedAt: "updated_at",
},
}
);
exhibitionsControlller.jsx
const show = async (req, res) => {
try {
const exhibition = await Exhibition.findById(req.params.id).populate(
"artworks"
);
res.status(200).json(exhibition);
} catch (error) {
res.status(400).json({ error: error.message });
}
};
Implentation of Populate
ExhibitionAccordion.jsx
<Box className="exhibitionAccordionContainer">
<Accordion className="ExhibitionAccordion">
<AccordionSummary
className="AccordionSummary"
expandIcon={<ExpandMoreIcon />}
aria-controls="panel1a-content"
id="panel1a-header"
>
<Typography variant="h6">Basement 1</Typography>
</AccordionSummary>
<AccordionDetails className="AccordionDetails">
<Grid container className="ExhibitionGridContainer" spacing={2}>
<ExhibitionCardB1 item xs={12} md={6} lg={3} />
</Grid>
</AccordionDetails>
</Accordion>
</Box>
React Material UI Accordion
ExhibitionUpdate.jsx
const currentDate = new Date().toISOString().split("T")[0];
function ExhibitionUpdate() {
const { id } = useParams();
const navigate = useNavigate();
const [exhibition, setExhibition] = useState({});
const [artworks, setArtwork] = useState([]);
useEffect(() => {
const fetchExhibition = async () => {
const response = await fetch(`/api/exhibitions/${id}`);
const exhibition = await response.json();
setExhibition(exhibition);
};
fetchExhibition();
}, [id]);
useEffect(() => {
const fetchArtwork = async () => {
const response = await fetch("/api/artworks");
const artworks = await response.json();
setArtwork(artworks);
};
fetchArtwork();
}, [id]);
function handleChange(event) {
event.preventDefault();
const key = event.target.name;
const value = event.target.value;
setExhibition({ ...exhibition, [key]: value });
}
const handleUpdate = async (event) => {
event.preventDefault();
const response = await fetch(`/api/exhibitions/${id}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(exhibition),
});
console.log("exhibition info updated");
navigate("/");
return response.json();
};
return (
<Box
className="EditExhibitionFormContainer"
component="form"
sx={{
"& .MuiTextField-root": { m: 1 },
}}
noValidate
autoComplete="off"
>
<FormControl className="EditExhibitionForm" autoComplete="off">
<Typography className="EditExhibitionFormTitle" variant="h4">
Edit Exhibition
</Typography>
<TextField
type="text"
name="exhibitionTitle"
label="Exhibition Title"
value={exhibition.exhibitionTitle}
inputProps={{ maxLength: 50 }}
InputLabelProps={{ shrink: true }}
onChange={handleChange}
/>
/>
<TextField
multiline
rows={6}
type="text"
name="exhibitionInformation"
label="Full Description"
value={exhibition.exhibitionInformation}
inputProps={{ maxLength: 1500 }}
InputLabelProps={{ shrink: true }}
onChange={handleChange}
/>
<FormControl className="EditDropdown" sx={{ m: 1 }} autoComplete="off">
<label>Artwork</label>
<Select name="artworks" type="text" onChange={handleChange}>
{artworks.map((artwork) => (
<MenuItem value={artwork._id}>{artwork.artworkTitle}</MenuItem>
))}
</Select>
</FormControl>
<TextField
type="date"
name="exhibitionStartDate"
label="Start Date"
value={exhibition.exhibitionStartDate}
onChange={handleChange}
inputProps={{ min: currentDate }}
InputLabelProps={{ shrink: true }}
/>
<TextField
type="date"
name="exhibitionEndDate"
label="End Date"
value={exhibition.exhibitionEndDate}
onChange={handleChange}
inputProps={{ min: exhibition.exhibitionStartDate }}
InputLabelProps={{ shrink: true }}
/>
<Button
type="submit"
variant="contained"
color="primary"
onClick={handleUpdate}
>
Update Exhibition
</Button>
</FormControl>
</Box>
);
}
Updating multiple details while utilising inputProps to validate input data
ExhibitionComments.jsx
return (
<>
{user ? (
<>
<br></br>
<Typography variant="h6">Submit Your Comment:</Typography>
<br></br>
<TextField
type="text"
rows="4"
cols="50"
onChange={(event) => setComment(event.target.value)}
value={comment}
></TextField>
<br></br>
<Button
onClick={handleAddNewComment}
type="submit"
variant="contained"
color="primary"
>
Submit Comment
</Button>
<br></br>
</>
) : null}
{!comments || comments.length === 0 ? (
null
) : (
<>
<Typography variant="h4">Comments</Typography><br></br>
{comments.map((review) => (
<Typography variant="subtitle1" key={review._id}>
{review.comments}
</Typography>
))}
</>
)}
</>
);
}
Dynamically show comments and submit comment text field
IconSlider.jsx
const IconSlider = () => {
const BUTTON_LIST = [
{
name: "Tickets",
icon: <ConfirmationNumberIcon />,
href: "https://www.nationalgallery.sg/admissions",
},
{
name: "Exhibitions",
icon: <ArtTrackIcon />,
href: "https://www.nationalgallery.sg/see-do/exhibitions",
},
{
name: "Art Journey",
icon: <RouteIcon />,
href: "https://web.nationalgallery.sg/#/art-journeys",
},
{
name: "Tours",
icon: <SpatialAudioIcon />,
href: "http://web.nationalgallery.sg/#/search?q%20=&tab=Tours",
},
{
name: "Events",
icon: <EventIcon />,
href: "https://www.nationalgallery.sg/whats-on",
},
{
name: "Dining",
icon: <RestaurantIcon />,
href: "https://www.nationalgallery.sg/see-do/shopping-and-dining",
},
{
name: "Donate",
icon: <VolunteerActivismIcon />,
href: "https://donate.nationalgallery.sg/",
},
{
name: "Contact",
icon: <AddIcCallIcon />,
href: "https://www.nationalgallery.sg/about/contact-us",
},
];
return (
<Box className="IconSlider">
<Box className="IconSliderInner">
{BUTTON_LIST.map((item) => (
<Stack
key={item.name}
direction="column"
spacing={1}
alignItems="center"
>
<Button
className="IconButton"
size="large"
variant="contained"
href={item.href}
startIcon={item.icon}
/>
<Typography sx={{ fontSize: 10 }} variant="button">
{item.name}
</Typography>
</Stack>
))}
</Box>
</Box>
);
};
export default IconSlider;
Location.js
const locationObjectsSchema = new Schema({
type: {
type: String,
enum: ["Exhibitions", "Artworks", "Shop & Dine", "Amenities"],
default: "Artworks",
},
exhibitionId: {
type: Schema.Types.ObjectId,
ref: "Exhibition",
},
artworkId: {
type: Schema.Types.ObjectId,
ref: "Artwork",
},
name: String,
});
const unitsSchema = new Schema({
unit: {
type: Number,
required: true,
min: 1,
max: 20,
validate: {
validator: Number.isInteger,
message: "{VALUE} is not an integer.",
},
},
objects: [locationObjectsSchema],
});
const locationsSchema = new Schema(
{
floor: {
type: String,
enum: ["B1", "L1", "L2", "L3", "L4", "L5", "L6"],
default: "L1",
},
exhibitions: [
{
type: Schema.Types.ObjectId,
ref: "Exhibition",
},
],
artworks: [
{
type: Schema.Types.ObjectId,
ref: "Artwork",
},
],
shopanddine: [
{
type: String,
minlength: 3,
},
],
amenities: [
{
type: String,
minlength: 3,
},
],
},
{
timestamps: {
createdAt: "created_at",
updatedAt: "updated_at",
},
}
);
MapPage.jsx
export default function MapPage() {
const navigate = useNavigate();
useEffect(() => {
console.log("Component mounted");
//this function is in index.html
setScrollHeight();
}, []);
const [locations, setLocations] = useState([]);
useEffect(() => {
const fetchLocations = async () => {
const response = await fetch(`/api/locations`);
const data = await response.json();
setLocations(data);
};
fetchLocations();
}, []);
const [locationsFrom, setLocationsFrom] = useState([]);
const [locationsTo, setLocationsTo] = useState([]);
const [formData, setFormData] = useState({
from: {
label: "",
value: "",
},
to: {
label: "",
value: "",
},
});
const [level, setLevel] = useState("L1");
const [category, setCategory] = useState("");
const cat = {
EXHIBITIONS: "exhibitions",
ARTWORKS: "artworks",
"SHOP & DINE": "shopanddine",
AMENITIES: "amenities",
};
function filterLocations() {
if (!locations) return;
const filterFloor = locations.filter((item) => item.floor === level);
if (filterFloor.length === 0) return;
let result = [
{
value: "",
label: "",
},
];
if (
filterFloor[0].exhibitions.length > 0 &&
(category === "EXHIBITIONS" || category === "")
) {
result.push(
...filterFloor[0].exhibitions.map((item) => ({
value: item._id,
label: `EXHIBITION ${item.exhibitionTitle}`,
}))
);
}
if (
filterFloor[0].artworks.length > 0 &&
(category === "ARTWORKS" || category === "")
) {
result.push(
...filterFloor[0].artworks.map((item) => ({
value: item._id,
label: `ARTWORK ${item.artworkTitle}`,
}))
);
}
if (
filterFloor[0].shopanddine.length > 0 &&
(category === "SHOP & DINE" || category === "")
) {
result.push(
...filterFloor[0].shopanddine.map((item) => ({
value: item,
label: `SHOP & DINE ${item}`,
}))
);
}
if (
filterFloor[0].amenities.length > 0 &&
(category === "AMENITIES" || category === "")
) {
result.push(
...filterFloor[0].amenities.map((item) => ({
value: item,
label: `AMENITIES ${item}`,
}))
);
}
if (result.length > 0) {
setLocationsFrom(result);
setLocationsTo(result);
}
}
const handleLevel = (event, newLevel) => {
console.log("Level:", newLevel);
setLevel(newLevel);
filterLocations();
};
const handleCategory = (event, newCategory) => {
console.log("Category:", newCategory);
setCategory(newCategory);
filterLocations();
};
function handleChange(event, values, field) {
//field = 'from' or 'to'
console.log(field, ":", values);
setFormData({
...formData,
[field]: values,
});
console.log(formData);
}
useEffect(() => {
if (
formData.from.value !== "" &&
formData.from.value === formData.to.value
) {
const newLocationsTo = locationsTo.filter(
(item) => item.value !== formData.from.value
);
setLocationsTo(newLocationsTo);
const newFormData = { ...formData, to: { value: "", label: "" } };
setFormData(newFormData);
}
}, [formData]);
function handleSubmit(event) {
event.preventDefault();
console.log("Submit: ", formData);
navigate(
`/map/directions/from/${formData.from.value}/to/${formData.to.value}`
);
}
useEffect(() => {
filterLocations();
}, [level, category, locations]);
return (
<Box className="MapPage1">
<Box
className="MapPageForm1"
onSubmit={handleSubmit}
component="form"
sx={{ "& .MuiTextField-root": { m: 1 } }}
noValidate
autoComplete="off"
>
<div className="MapFormTopRow1">
<Button className="LeftButton1">
<RadioButtonCheckedIcon />
</Button>
<Autocomplete
className="MapFormTextField1"
sx={{ fontSize: "12px", minWidth: "150px" }}
value={formData.from}
size="small"
margin="dense"
placeholder="Enter where you are"
disablePortal
options={locationsFrom}
onChange={(event, values) => handleChange(event, values, "from")}
isOptionEqualToValue={(option, currentValue) => {
if (currentValue === "") return true;
return option.name === currentValue.name;
}}
renderInput={(params) => (
<TextField {...params} name="from" label="From" />
)}
/>
<Button className="RightButton1">
<SwapVertIcon />
</Button>
</div>
<div className="MapFormBottomRow1">
<Button className="LeftButton1">
<FmdGoodIcon />
</Button>
<Autocomplete
className="MapFormTextField1"
sx={{ fontSize: "12px", minWidth: "150px" }}
value={formData.to}
size="small"
margin="dense"
placeholder="Enter destination"
disablePortal
options={locationsTo}
onChange={(event, values) => handleChange(event, values, "to")}
isOptionEqualToValue={(option, currentValue) => {
if (currentValue === "") return true;
return option.name === currentValue.name;
}}
renderInput={(params) => (
<TextField {...params} name="to" label="To" />
)}
/>
<Button className="RightButton1" type="submit">
<DirectionsWalkRoundedIcon />
</Button>
</div>
</Box>
<ToggleButtonGroup
className="LevelButtonGroup1"
value={level}
exclusive
onChange={(ev, value) => handleLevel(ev, value)}
aria-label="level"
>
<ToggleButton value="B1" aria-label="B1">
B1
</ToggleButton>
<ToggleButton value="L1" aria-label="L1">
L1
</ToggleButton>
<ToggleButton value="L2" aria-label="L2">
L2
</ToggleButton>
<ToggleButton value="L3" aria-label="L3">
L3
</ToggleButton>
<ToggleButton value="L4" aria-label="L4">
L4
</ToggleButton>
<ToggleButton value="L5" aria-label="L5">
L5
</ToggleButton>
<ToggleButton value="L6" aria-label="L6">
L6
</ToggleButton>
</ToggleButtonGroup>
<ToggleButtonGroup
className="CategoryButtonGroup1"
value={category}
exclusive
onChange={handleCategory}
aria-label="category"
>
<ToggleButton value="EXHIBITIONS" aria-label="Exhibitions">
EXHIBITIONS
</ToggleButton>
<ToggleButton value="ARTWORKS" aria-label="Artworks">
ARTWORKS
</ToggleButton>
<ToggleButton value="SHOP & DINE" aria-label="Shop & Dine">
SHOP & DINE
</ToggleButton>
<ToggleButton value="AMENITIES" aria-label="Amenities">
AMENITIES
</ToggleButton>
</ToggleButtonGroup>
<Box className="MapComponent1">
<MapComponent level={level} />
</Box>
</Box>
);
}
The controller holds the logic of taking in the "to and from" from the map page and generating a pre-written route from the database. The controller pulls in the ObjectID of the respective exhibitions and runs it in an If Else statement to respond with the ObjectID of a route solution. Using req.params the objectIDs are retrieved from the URL from the :from and :to and passed through as arguements for the function findRoute.
The current working examples of the route are:
- EXHIBITION Between Declarations and Dreams L1 to EXHIBITION Siapa Nama Kamu B1
- EXHIBITION Between Declarations and Dreams L2 to EXHIBITION As We See It L3
Directions.js
const directionsSchema = new Schema(
{
route: {
type: String,
required: true,
},
mapImg: {
type: Array,
required: true,
},
routeDirections: [
{
type: Schema.Types.ObjectId,
ref: "Explorer",
},
],
},
{
timestamps: {
createdAt: "created_at",
updatedAt: "updated_at",
},
}
);
directionsController.jsx
const Directions = require("../models/Directions");
require("../models/Explorer");
const show = async (req, res) => {
try {
const from = req.params.from;
const to = req.params.to;
function findRoute(from, to) {
if (
from === "6425c260c9d195369ec02476" &&
to === "6425c854c9d195369ec02494"
) {
result = {
fromMapUrl: "EXHIBITION Between Declarations and Dreams L1",
toMapUrl: "EXHIBITION Siapa Nama Kamu B1",
steps: "642bb5bf350c1b20a4cdb627", // Object ID of Route A
};
} else if (
from === "6425c260c9d195369ec02476" &&
to === "6430ab5e625b2ad2b95f96ab"
) {
result = {
fromMapUrl: "EXHIBITION Between Declarations and Dreams L2",
toMapUrl: "EXHIBITION As We See It L3",
steps: "642e2ff3e64813ab8c55a0ca", // Object ID of Route B
};
} else {
result = {
steps: "64305ed5cf61dfb8fdd221cc",
};
}
return result;
}
const routeAnswer = findRoute(from, to).steps;
console.log("this result " + routeAnswer);
const foundDirections = await Directions.findById(routeAnswer).populate(
"routeDirections"
);
res.status(200).json(foundDirections);
} catch (error) {
res.status(400).json({ error: error.message });
}
};
module.exports = {
show,
};
The MapDirectionsExplorer is a child component of the Map Directions Route Page. It receives data from the database that holds the route information. For this component it has the props required to fill the table with Icons, a string of directions in text and an img URL for the route path. The data representing the icon in the database comes in a value of a Keyname such as "turnRight". An object "const Icons" contains a key value pair of the keyname and the Icon componenet from MUI. Therefore when the keyname is called into the table, "[Icons.turnRight]" will access the turnRight Key in the Icons object and access the value of the MUI Icon Component.
const explorerSchema = new Schema(
{
directions: {
type: String,
required: true,
},
explorerPrompt: {
type: String,
required: true,
},
featureUrl: {
type: String,
required: true,
},
featureTitle: {
type: String,
required: true,
},
icon: {
type: String,
required: true,
},
imgUrl: {
type: String,
required: true,
},
},
{
timestamps: {
createdAt: "created_at",
updatedAt: "updated_at",
},
}
);
MapDirectionsExplorer.jsx
const Icons = {
turnRight: <TurnRightOutlinedIcon />,
turnLeft: <TurnLeftOutlinedIcon />,
slightRight: <TurnSlightRightOutlinedIcon />,
slightLeft: <TurnSlightLeftOutlinedIcon />,
goStraight: <StraightOutlinedIcon />,
stairs: <StairsOutlinedIcon />,
camera: <CameraAltOutlinedIcon />,
artwork: <PhotoOutlinedIcon />,
};
export default function BasicTable(props) {
const { direction, table } = props;
const { id } = useParams();
const [tableData, setTableData] = useState([]);
return (
<>
<Typography>Explorer Mode - on</Typography>
<TableContainer style={{ width: "100%" }} component={Paper}>
<Table sx={{ minWidth: 400 }} aria-label="simple table">
<TableHead>
<TableRow>
<TableCell align="center">Direction icon </TableCell>
<TableCell align="center">Directions</TableCell>
<TableCell align="center">Img</TableCell>
</TableRow>
</TableHead>
<TableBody>
{table?.map((table, index) => (
<TableRow
key={index}
// sx={{ '&:last-child td, &:last-child th': { border: 0 } }}
>
<TableCell align="center" component="th" scope="row">
{Icons[table.icon]}
</TableCell>
<TableCell align="center">
{table.directions}
{table.explorerPrompt}
<a href={`${table.featureUrl}`}>{table.featureTitle}</a>
</TableCell>
<TableCell align="center">
<img src={`${table.imgUrl}`} height="150" />{" "}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</>
);
}
Ernest:
- Identifying the need for an industrial grade UI framework.
- Selecting and learning Material UI framework early in the project helped to allieviate most of the issues and reduced risk of delays.
- Splitting the coding responsibilities to avoid double work and clashes.
- Clarifying the scope of work with the UI/UX team helped to reduce the guesswork and speed up the development.
- Deployment was tricky because the host servers did not provide enough documentation and troubleshooting advice for free account users.
- Cyclic server had a disk limit of 240mb, which our deployment exceeded because of the large MUI packages. Render server was using Node 14, which was not surfaced up by the error logs but was hidden inside the documentation.
Ida:
- Learning to work in a group using Github.
- Managing expectations & communication.
- Working with referencing and embedding data.
- Material UI CSS Style Default Behaviour.
- React Props & State.
May:
- Material UI
- Understanding each other's style of code.
- The use of bcrypt and jsonwebtoken.
- Getting the artwork Information to show when user click on the artwork.
Mark:
- Working in the MUI environment.
- Initial modelling of Data Models and planning out relationships and dependencies.
- Formulating the logic of a map feature.
- Condensing a route solution into a single object that holds various fields of information, eg img,directions,features,icons.
Ernest:
- The power of JSX enables you to output HTML within the Javascript. Enabling you to output an icon component with an array like this.
i.e. The value of a property can be a JSX element.
const userMenuItems = [
{
text: "Home",
to: "/",
icon: <HomeOutlinedIcon />,
},
{
text: "Exhibition",
to: "/",
icon: <ArtTrackIcon />,
},
{
text: "Artworks",
to: "/artworks",
icon: <ImageSearchOutlinedIcon />,
},
{
text: "Map",
to: "/map",
icon: <LocationOnOutlinedIcon />,
},
];
Ida:
- Understand the significance of Group Git especially managing code conflicts.
- Learnt debugging, prioritising and analyzing skills from fellow members.
- Utilising wireframe & trello to align group vision.
- Getting familiarise with the key concepts of React (useState, useEffect, React Router, useParams, useNavigate).
- Implentation of API Fetching for GET,POST,PUT & DELETE.
- Working with Material UI to create beautiful interface with built in features.
- Working with referencing and embedding data.
- Understanding how model affects the entire app.
May:
- The use of Group Git, branches and conflicts during merge.
- Initial planning of the project (wireframes, model schemas, paths and routes), the more detailed the planning, result in a clearer and smoother implementation of the app.
- It is important to aim and focus on completing one specific task at a time.
- React CRUD.
- User Sign up and Login using bcrypt and jsonwebtoken.
- Utilization of ternary operator to enable certain user to accessible to certain informations.
Mark:
- Error finding through learning to read DevTools and testing routes with Insomnia.
- Understanding how to work in an environment where there are potential conflicts in code and resolving it by using a common seed data from the start.
- Planning the code with the MUI structure.
Based on this analysis, we can conclude that React is a popular choice due to its high job demand(1), ease of learning(2), component-based architecture(5), virtual DOM(4), reactive updates(8), and state management (Redux)(14). React also offers excellent performance (10), simplicity(11), and optional support for Typescript (12), making it suitable for building complex web applications. Additionally, React's mobile framework, React-Native (13), makes it an excellent choice for building mobile apps.
However, React does not offer native support for server-side rendering (15), forms(17), routing(16), CSS utilities(18), UI Component Libraries(19), but these functionalities can be added using third-party libraries such as Next.js, Formik, React Router, Tailwind CSS and Material UI, respectively.
Ultimately, the choice of technology depends on the specific requirements of the project, and we should consider factors such as community support, documentation, and compatibility with existing code. Nonetheless, React's popularity and vast ecosystem make it a solid choice for building modern web applications.
React Features | React | Vanilla JS | EJS Templates | Angular | Vue |
---|---|---|---|---|---|
(1) Job Demand | 57% | N/A | N/A | 31% | 11% |
(2) Ease of Learning | ★★★★★ | ★★★★★ | ★★★ | ★★★ | ★★★★★ |
(3) Declarative | Yes | N/A | N/A | Imperative | Yes |
(4) One-way data binding | ★★★★★ | N/A | N/A | 2-way | 2-way |
(5) Component-Based | ★★★★★ | N/A | N/A | ★★★★★ | ★★★★ |
(6) Virtual DOM | Yes | N/A | N/A | Direct DOM | Yes |
(7) JSX Syntax | Yes | N/A | EJS Syntax | Template Syntax | Template Syntax |
(8) Reactive Updates | ★★★★★ | N/A | N/A | ★★★★ | ★★★★ |
(9) Dependency Injection | ★★★★★ | N/A | N/A | ★★★★ | ★★ |
(10) Performance | ★★★★★ | ★★★★ | ★★★ | ★★★★ | ★★★ |
(11) Simplicity | ★★★★★ | ★★★★ | ★★★ | ★★★ | ★★★★ |
(12) Typescript | Optional | N/A | N/A | Tightly integrated | Optional |
(13) Mobile Framework | (React-Native) | N/A | N/A | NativeScript | Weex |
(14) State Management | (Redux) | N/A | N/A | (NgRx) | (Vuex) |
(15) Server-Side Rendering | (Next.js) | N/A | ★★★★ | (Angular Universal) | N/A |
(16) Routing | (React Router) | N/A | N/A | (Angular Router) | (Vue Router) |
(17) Forms | (Formik) | N/A | N/A | (Angular Forms) | (Vee-Validate) |
(18) CSS Utilities | (Tailwind CSS) | (Bootstrap) | N/A | (Angular Material) | (Vuetify) |
(19) UI Component Libraries | (Material UI) | N/A | N/A | (PrimeNG)(Material UI) | (Element UI) |
Notes:
- The ratings are represented by stars (★), with 5 stars being the highest rating.
- Some of the features are not applicable for certain frameworks (represented as N/A).
- The external libraries are listed in brackets in the column cells.
Material UI is one of the most popular React UI frameworks and well documented. It is easy to create beautiful, responsive user interfaces. It has a wide range of components available. Its powerful theming system gives it a consistent look and feel. It is sort of an industrial standard. Teams can expect to have higher productivity when utilizing Material UI because many components work out of the box.
Downsides to Material UI. The look and feel is similar to other Material UI sites. CSS is tightly controlled by the MUI system and tricky to customize. Overkill for small projects. Large bloated file size and perceived to be slow.
It may be possible to use the 'headless' MUI system to more deeply customize the look and feel of MUI.
The MERN stack is using Javascript on both frontend and backend, making it cost effective to hire only Javascript developers for the project. Context switching is minimized thus improving productivity.
Node.js is fast and scalable. Non-blocking single threaded IO event loop. Unlike Java, Node.js has no need for complex concurrent multi-threaded code, it's single threaded code is easier to comprehend.
MongoDB and its ODM Mongoose is very close to using JSON objects in Javascript. Thus it is easy to manipulate and query the data. MongoDB is suitable for web projects that have highly variable and unstructured data that otherwise will be restricted by SQL schemas.
Express is a defacto lightweight, performant and unopinionated Node.js web framework.
Product Prototype: Figma
Wireframe: Google Docs
Project Management: Trello
Path & Components: Google Docs
Photos used: pixabay
Artworks and Exhibitions Reference: National Gallery Singapore