diff --git a/index.html b/index.html
index 0c589ec..b3806bb 100644
--- a/index.html
+++ b/index.html
@@ -2,9 +2,9 @@
-
+
- Vite + React
+ ArchAIve
diff --git a/src/Layout.jsx b/src/Layout.jsx
index fdb152a..334f41f 100644
--- a/src/Layout.jsx
+++ b/src/Layout.jsx
@@ -2,6 +2,7 @@ import React, { useEffect } from 'react'
import { useDispatch } from 'react-redux'
import { Outlet, useNavigate } from 'react-router';
import Navbar from './components/Navbar';
+import { Toaster } from './components/ui/toaster';
function Layout() {
const dispatch = useDispatch();
@@ -12,10 +13,13 @@ function Layout() {
}, [])
return (
-
-
-
-
+ <>
+
+
+
+
+
+ >
)
}
diff --git a/src/assets/favicon.svg b/src/assets/favicon.svg
new file mode 100644
index 0000000..1c33844
--- /dev/null
+++ b/src/assets/favicon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/assets/hp1.png b/src/assets/hp1.png
new file mode 100644
index 0000000..0180bce
Binary files /dev/null and b/src/assets/hp1.png differ
diff --git a/src/assets/hp2.png b/src/assets/hp2.png
new file mode 100644
index 0000000..1d80252
Binary files /dev/null and b/src/assets/hp2.png differ
diff --git a/src/assets/hp3.png b/src/assets/hp3.png
new file mode 100644
index 0000000..629e057
Binary files /dev/null and b/src/assets/hp3.png differ
diff --git a/src/assets/hp4.png b/src/assets/hp4.png
new file mode 100644
index 0000000..12df40b
Binary files /dev/null and b/src/assets/hp4.png differ
diff --git a/src/assets/hp5.png b/src/assets/hp5.png
new file mode 100644
index 0000000..4f54d4c
Binary files /dev/null and b/src/assets/hp5.png differ
diff --git a/src/assets/hp6.png b/src/assets/hp6.png
new file mode 100644
index 0000000..a05695a
Binary files /dev/null and b/src/assets/hp6.png differ
diff --git a/src/assets/react.svg b/src/assets/react.svg
deleted file mode 100644
index 6c87de9..0000000
--- a/src/assets/react.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/src/assets/sccciBuildingOpening1964.png b/src/assets/sccciBuildingOpening1964.png
new file mode 100644
index 0000000..d33cf09
Binary files /dev/null and b/src/assets/sccciBuildingOpening1964.png differ
diff --git a/src/components/toastWizard.js b/src/components/toastWizard.js
new file mode 100644
index 0000000..2e12ebf
--- /dev/null
+++ b/src/components/toastWizard.js
@@ -0,0 +1,105 @@
+import { toaster } from "./ui/toaster";
+
+/**
+ * `ToastWizard` is a utility class for creating and managing toasts in the application.
+ * It provides methods to create standard toasts and promise-based toasts with various configurations.
+ *
+ * Usage:
+ * ```javascript
+ * import ToastWizard from './path/to/ToastWizard';
+ *
+ * const callback = () => console.log('Action clicked!');
+ *
+ * ToastWizard.standard(
+ * "success",
+ * "Operation Successful",
+ * "Your operation was completed successfully.",
+ * 5000, // duration (optional)
+ * true, // closable (optional)
+ * callback, // action (optional)
+ * "Undo" // actionLabel. provide if action is being provided. (optional)
+ * )
+ *
+ *
+ * // Promise-based usage
+ * const myPromise = new Promise((resolve, reject) => {
+ * // Simulate an async operation
+ * setTimeout(() => {
+ * resolve("Data loaded successfully");
+ * }, 2000);
+ * });
+ *
+ * ToastWizard.promiseBased(
+ * myPromise,
+ * "Operation Successful", // successTitle
+ * "Data loaded successfully", // successDescription
+ * "Error", // errorTitle
+ * "Failed to load data", // errorDescription
+ * "Loading", // loadingTitle
+ * "Please wait while we load your data", // loadingDescription
+ * callback, // action (optional)
+ * "Cancel" // actionLabel. provide if action is being provided. (optional)
+ * )
+ * ```
+ *
+ * `ToastWizard` uses the default `toaster` initialisation from Chakra UI. (See `src/components/ui/toaster.js` for more details)
+ */
+class ToastWizard {
+ // constructor(toaster) {
+ // self.toaster = toaster;
+ // }
+
+ static standard(type, title, description=null, duration=3000, closable=false, action=null, actionLabel=null) {
+ var creationObject = {
+ type: type,
+ title: title,
+ duration: duration,
+ closable: closable
+ }
+
+ if (description) {
+ creationObject.description = description;
+ }
+
+ if (action && actionLabel) {
+ creationObject.action = {
+ label: actionLabel,
+ onClick: action
+ }
+ }
+
+ const id = toaster.create(creationObject)
+ return id;
+ }
+
+ static promiseBased(promise, successTitle, successDescription, errorTitle, errorDescription, loadingTitle, loadingDescription, action=null, actionLabel=null) {
+ return toaster.promise(promise, {
+ success: {
+ title: successTitle,
+ description: successDescription,
+ action: action ? {
+ label: actionLabel,
+ onClick: action
+ } : null
+ },
+ error: {
+ title: errorTitle,
+ description: errorDescription,
+ action: action ? {
+ label: actionLabel,
+ onClick: action
+ } : null
+ },
+ loading: {
+ title: loadingTitle,
+ description: loadingDescription,
+ action: action ? {
+ label: actionLabel,
+ onClick: action
+ } : null
+ }
+ })
+ }
+}
+
+export default ToastWizard;
\ No newline at end of file
diff --git a/src/components/ui/toaster.jsx b/src/components/ui/toaster.jsx
index 6af2b70..4e66227 100644
--- a/src/components/ui/toaster.jsx
+++ b/src/components/ui/toaster.jsx
@@ -14,12 +14,22 @@ export const toaster = createToaster({
pauseOnPageIdle: true,
})
+const toastColors = {
+ success: 'primaryColour',
+ error: 'sccciColour',
+ info: 'blue.500',
+ warning: 'orange.500',
+ loading: 'gray.500',
+ default: 'gray.700',
+};
+
+
export const Toaster = () => {
return (
{(toast) => (
-
+
{toast.type === 'loading' ? (
) : (
diff --git a/src/main.jsx b/src/main.jsx
index 000928e..28bc32f 100644
--- a/src/main.jsx
+++ b/src/main.jsx
@@ -5,6 +5,7 @@ import { ChakraProvider, defaultSystem, Text } from "@chakra-ui/react"
import { Provider } from 'react-redux'
import { configureStore } from '@reduxjs/toolkit'
import universalReducer from './slices/UniversalState.js'
+import authReducer from './slices/AuthState.js'
import { BrowserRouter, Route, Routes } from 'react-router'
import Catalogue from './pages/Catalogue.jsx'
// import Test from './pages/test.jsx'
@@ -13,10 +14,12 @@ import GalleryLayout from './GalleryLayout.jsx';
import MainTheme from './themes/MainTheme.js'
import Health from './pages/Health.jsx'
import Homepage from './pages/Homepage.jsx'
+import Login from './pages/Login.jsx';
const store = configureStore({
reducer: {
- universal: universalReducer
+ universal: universalReducer,
+ auth: authReducer
}
})
@@ -29,6 +32,10 @@ createRoot(document.getElementById('root')).render(
} />
} />
+
+ } />
+
+
{/* } /> */}
diff --git a/src/networking.js b/src/networking.js
index eacef1c..5d3617e 100644
--- a/src/networking.js
+++ b/src/networking.js
@@ -5,10 +5,10 @@ class JSONResponse {
this.status = status || 400;
this.type = data.type || null;
this.message = data.message || null;
- this.data = structuredClone(data || {});
+ this.raw = structuredClone(data || {});
}
- isError() {
+ isErrorStatus() {
if (this.status === undefined || this.status === null) {
console.warn("JSONResponse isError: Status is undefined or null, defaulting to error positive.");
return true;
@@ -16,6 +16,14 @@ class JSONResponse {
return !String(this.status).startsWith("2");
}
+ errorType() {
+ return this.type === "ERROR";
+ }
+
+ userErrorType() {
+ return this.type === "UERROR";
+ }
+
fullMessage() {
if (this.type && this.message) {
return `${this.type}: ${this.message}`;
diff --git a/src/pages/Homepage.jsx b/src/pages/Homepage.jsx
index 3113ea0..cd6e46c 100644
--- a/src/pages/Homepage.jsx
+++ b/src/pages/Homepage.jsx
@@ -1,8 +1,54 @@
-import { Flex, Text } from '@chakra-ui/react'
+import { Box, Button, Flex, Image, Spacer, Text, VStack } from '@chakra-ui/react'
+import hp1 from '../assets/hp1.png';
+import hp2 from '../assets/hp2.png';
+import hp3 from '../assets/hp3.png';
+import hp4 from '../assets/hp4.png';
+import hp5 from '../assets/hp5.png';
+import hp6 from '../assets/hp6.png';
function Homepage() {
+ const imgResponsiveSizing = { base: "150px", md: "250px" }
+ const imgColumnMinWidth = { base: "350px", md: "450px" }
+ const imgOffset = "-100px"
+
return
- Connect
with your
roots
+
+
+
+
+ {/* First images column */}
+
+
+
+
+
+
+
+
+
+
+ {/* Title, subtitle, and CTA button column */}
+
+
+ Connect
with your
roots
+ The intelligent artefact digitisation platform.
+
+
+
+
+
+
+ {/* Second images column */}
+
+
+
+
+
+
+
+
+
+
}
diff --git a/src/pages/Login.jsx b/src/pages/Login.jsx
new file mode 100644
index 0000000..4c0e424
--- /dev/null
+++ b/src/pages/Login.jsx
@@ -0,0 +1,110 @@
+import { Button, Field, Fieldset, Flex, For, Image, Input, NativeSelect, Spacer, Stack, Text, VStack } from '@chakra-ui/react'
+import { PasswordInput } from "../components/ui/password-input"
+import { useState } from 'react'
+import sccciBuildingImage from '../assets/sccciBuildingOpening1964.png'
+import server, { JSONResponse } from '../networking'
+import ToastWizard from '../components/toastWizard'
+
+function Login() {
+ const [usernameOrEmail, setUsernameOrEmail] = useState('');
+ const [password, setPassword] = useState('');
+ const [loginLoading, setLoginLoading] = useState(false);
+
+ const handleLogin = async () => {
+ if (!usernameOrEmail || usernameOrEmail == "" || !password || password == "") {
+ ToastWizard.standard("error", "All fields are required.")
+ return;
+ }
+
+ setLoginLoading(true);
+
+ const ambiguousErrorToast = () => {
+ ToastWizard.standard("error", "Login failed.", "Something went wrong. Please try again.");
+ }
+
+ server.post('/auth/login', {
+ usernameOrEmail: usernameOrEmail,
+ password: password
+ })
+ .then(res => {
+ if (res.data instanceof JSONResponse) {
+ if (res.data.isErrorStatus()) {
+ console.log("Error response in login request:", res.data.fullMessage());
+ if (res.data.userErrorType()) {
+ ToastWizard.standard("error", "Login failed.", res.data.message);
+ } else {
+ ambiguousErrorToast();
+ }
+ }
+
+ // Success case
+ ToastWizard.standard("success", "Login successful.", "Welcome back to ArchAIve!")
+ } else {
+ console.log("Unexpected response in login request:", res.data);
+ ambiguousErrorToast();
+ }
+
+ setLoginLoading(false);
+ })
+ .catch(err => {
+ if (err.response.data instanceof JSONResponse) {
+ console.log("Error response in login request:", err.response.data.fullMessage());
+ if (err.response.data.userErrorType()) {
+ ToastWizard.standard("error", "Login failed.", err.response.data.message);
+ } else {
+ ambiguousErrorToast();
+ }
+ } else {
+ console.log("Unexpected error in login request:", err);
+ ambiguousErrorToast();
+ }
+ setLoginLoading(false);
+ })
+ }
+
+ return (
+
+
+
+
+ 47 Hill Street - the opening of the Singapore Chinese Chamber of Commerce and Industry building in 1964.
+
+
+
+
+
+ Welcome back to ArchAIve!
+ {/* Please login to continue. */}
+
+
+
+ Please login to continue
+
+ Access to ArchAIve is restricted to SCCCI staff. Unauthorised access is prohibited.
+
+
+
+
+
+ Username or Email
+ setUsernameOrEmail(e.target.value)} name="name" placeholder='e.g johndoe' />
+
+
+
+ Password
+ setPassword(e.target.value)} placeholder="Enter password" />
+
+
+
+
+
+
+
+
+
+ )
+}
+
+export default Login
\ No newline at end of file
diff --git a/src/pages/SampleProtected.jsx b/src/pages/SampleProtected.jsx
new file mode 100644
index 0000000..64c4f14
--- /dev/null
+++ b/src/pages/SampleProtected.jsx
@@ -0,0 +1,9 @@
+import React from 'react'
+
+function SampleProtected() {
+ return (
+ SampleProtected
+ )
+}
+
+export default SampleProtected
\ No newline at end of file
diff --git a/src/slices/AuthState.js b/src/slices/AuthState.js
new file mode 100644
index 0000000..9c4daea
--- /dev/null
+++ b/src/slices/AuthState.js
@@ -0,0 +1,136 @@
+import { createSlice } from '@reduxjs/toolkit';
+import server, { JSONResponse } from '../networking';
+
+const initialState = {
+ accountID: null,
+ username: null,
+ superuser: null,
+ loaded: false,
+ error: null,
+ disableSessionCheck: false,
+}
+
+const authSlice = createSlice({
+ name: 'auth',
+ initialState,
+ reducers: {
+ setAccountID: (state, action) => {
+ state.accountID = action.payload;
+ },
+ setUsername: (state, action) => {
+ state.username = action.payload;
+ },
+ setSuperuser: (state, action) => {
+ state.superuser = action.payload;
+ },
+ setLoaded: (state, action) => {
+ state.loaded = action.payload;
+ },
+ setError: (state, action) => {
+ state.error = action.payload;
+ },
+ setDisableSessionCheck: (state, action) => {
+ state.disableSessionCheck = action.payload;
+ },
+ stateLogout: (state) => {
+ state.accountID = null;
+ state.username = null;
+ state.superuser = null;
+ state.error = null;
+ }
+ },
+});
+
+export const retrieveSession = async () => {
+ try {
+ const response = await server.get('/auth/session');
+ if (response.data instanceof JSONResponse) {
+ if (response.data.isErrorStatus()) {
+ return response.data.fullMessage();
+ }
+
+ // Success
+ return {
+ accountID: response.data.raw.accID || null,
+ username: response.data.raw.username || null,
+ sessionStart: response.data.raw.sessionStart || null,
+ superuser: response.data.raw.superuser || null
+ }
+ } else {
+ throw new Error("Unexpected response format; response:", response.data);
+ }
+ } catch (err) {
+ var errorMessage = err || "Could not resolve an error message.";
+ if (err.response && err.response.data && err.response.data instanceof JSONResponse) {
+ errorMessage = err.response.data.fullMessage();
+ } else if (err.response && err.response.data && typeof err.response.data === 'string') {
+ errorMessage = err.response.data;
+ } else if (typeof err === 'string') {
+ errorMessage = err;
+ } else if (err.message && typeof err.message === 'string') {
+ errorMessage = err.message;
+ }
+ console.log("Error in fetching session:", errorMessage);
+ return errorMessage;
+ }
+}
+
+export const { setAccountID, setUsername, setSuperuser, setLoaded, setError, setDisableSessionCheck, stateLogout } = authSlice.actions;
+
+export const fetchSession = (handler=null) => async (dispatch) => {
+ // console.log('Fetching session...');
+ dispatch(setLoaded(false));
+ const response = await retrieveSession();
+ if (response.accountID && response.username && response.superuser !== undefined) {
+ dispatch(setAccountID(response.accountID));
+ dispatch(setUsername(response.username));
+ dispatch(setSuperuser(response.superuser));
+ } else {
+ dispatch(setError(response));
+ }
+ dispatch(setLoaded(true));
+
+ if (handler) {
+ handler(response);
+ }
+};
+
+export const logout = (disableSessionCheck=false, handler=null) => async (dispatch) => {
+ try {
+ const response = await server.get('/auth/logout');
+ if (response.data instanceof JSONResponse) {
+ if (response.data.isErrorStatus()) {
+ throw new Error({ response: { data: response.data }});
+ }
+
+ // Success
+ if (disableSessionCheck) {
+ dispatch(setDisableSessionCheck(true));
+ }
+ dispatch(stateLogout());
+
+ if (handler) {
+ handler(response.data);
+ }
+ } else {
+ throw new Error("Unexpected response format; response:", response.data);
+ }
+ } catch (err) {
+ var errorMessage = err || "Could not resolve an error message.";
+ if (err.response && err.response.data && err.response.data instanceof JSONResponse) {
+ errorMessage = err.response.data.fullMessage();
+ } else if (err.response && err.response.data && typeof err.response.data === 'string') {
+ errorMessage = err.response.data;
+ } else if (err.message && typeof err.message === 'string') {
+ errorMessage = err.message;
+ } else if (typeof err === 'string') {
+ errorMessage = err;
+ }
+ console.log("Error in logout:", errorMessage);
+ if (handler) {
+ handler(errorMessage);
+ }
+ }
+}
+
+export default authSlice.reducer;
\ No newline at end of file