Skip to content

ernesttan1976/museum

Repository files navigation

National Gallery Mobile App

Getting Started

https://museumrender.onrender.com/

Contribution

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.

Wireframe

Home Page Wireframe
Artwork Wireframe
Map Wireframe
Map Route Wireframe

User Stories

  • 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.

ERD Diagram (mermaid.live)

Screenshots

Home

Home Page Carousell

Sign Up

Sign Up

Login

Login

Create New Artwork (Admin View)

Create a New Artwork

Artworks

Artworks

Artwork Details

User Artwork Information

Artwork Edit & Delete Buttons (Admin View)

Admin Artwork Information

Artwork Edit Form (Admin View)

Edit Artwork

Create New Exhibition (Admin View)

New Exhibition Form

Exhibitions

Exhibition

Exhibition Details

Exhibition Page

Exhibition Artworks & Comments

Exhibition Artworks & Comments

Exhibition Edit Form (Admin View)

Exhibition Edit Form

Map Filter

Map

Map Route

Map Route

Map Explorer

Map Explorer

Technologies Used

  1. React
  2. React Material UI
  3. Node.js
  4. Express Framework
  5. MongoDB & Mongoose
  6. JavaScript
  7. Render deployment
  8. Git & GitHub

Features

Login, Sign Up (User & Admin)

Mongoose Model

  • 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.

Client Side Routing

  • 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">&nbsp;{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.

Artwork CRUD

Code extract example will focus on edit and update of Artwork CRUD.

Mongoose Model

  • 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.

Exhibition CRUD

Code extract example will focus on read and update of Exhibition CRUD.

Mongoose Model

  • 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

Icon Slider

  • 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;

Map Search

Mongoose Model

  • 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>
  );
}

Map Route

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:

  1. EXHIBITION Between Declarations and Dreams L1 to EXHIBITION Siapa Nama Kamu B1
  2. EXHIBITION Between Declarations and Dreams L2 to EXHIBITION As We See It L3

Mongoose Model

  • 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.

Mongoose Model

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>
    </>
  );
}

Conclusion

Biggest Challenge

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.

Key Learnings

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.

Why We Choose React?

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.

Why Material UI

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.

Why Node.js / Express / MongoDB (MERN Stack)

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.

Resources

Product Prototype: Figma
Wireframe: Google Docs
Project Management: Trello
Path & Components: Google Docs
Photos used: pixabay
Artworks and Exhibitions Reference: National Gallery Singapore

Releases

No releases published

Packages

No packages published

Contributors 4

  •  
  •  
  •  
  •  

Languages