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