diff --git a/backend/package.json b/backend/package.json index e17ad67..163e4ed 100644 --- a/backend/package.json +++ b/backend/package.json @@ -13,6 +13,8 @@ "license": "ISC", "dependencies": { "@prisma/client": "^5.22.0", + "@supabase/storage-js": "^2.7.1", + "base64-arraybuffer": "^1.0.2", "bcrypt": "^5.1.1", "connect-redis": "^7.1.1", "cors": "^2.8.5", diff --git a/backend/src/config/storage.ts b/backend/src/config/storage.ts new file mode 100644 index 0000000..cd5ba3d --- /dev/null +++ b/backend/src/config/storage.ts @@ -0,0 +1,68 @@ +import { StorageClient } from '@supabase/storage-js'; +import { decode } from 'base64-arraybuffer'; + +const STORAGE_URL = process.env['STORAGE_URL']; +const SERVICE_KEY = process.env['SERVICE_ROLE']; + +if (process.env['NODE_ENV'] !== 'test' && !STORAGE_URL) { + throw new Error('Storage URL not found.'); +} +if (process.env['NODE_ENV'] !== 'test' && !SERVICE_KEY) { + throw new Error('Service key not found.'); +} + +export let storageClient: StorageClient | null = null; +if (STORAGE_URL && SERVICE_KEY) { + storageClient = new StorageClient(STORAGE_URL, { + apikey: SERVICE_KEY, + Authorization: `Bearer ${SERVICE_KEY}`, + }); +} + +export const uploadFile = async ( + file: string, + fileType: string, + societyId: number, + eventName: string +) => { + if (!storageClient) { + throw new Error('Storage client not initialised.'); + } + const { data, error } = await storageClient + .from('images') + .upload( + `${societyId}/${eventName}/banner.${fileType.split('/')[1]}`, + decode(file), + { + contentType: fileType, + upsert: true, + } + ); + if (error) { + throw new Error(error.message); + } + console.log(data); + return data.path; +}; + +export const getFile = async (path: string) => { + if (!storageClient) { + throw new Error('Storage client not initialised.'); + } + const { data, error } = await storageClient.from('images').download(path); + if (error) { + throw new Error(error.message); + } + + return data; +}; + +export const getFileUrl = async (path: string) => { + if (!storageClient) { + throw new Error('Storage client not initialised.'); + } + + const { data } = await storageClient.from('images').getPublicUrl(path); + + return data; +}; diff --git a/backend/src/index.ts b/backend/src/index.ts index a05c729..0ef8f80 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -28,6 +28,12 @@ import { findUserFromId, updateUserPasswordFromEmail, } from './routes/User/user'; +import { + getFile, + getFileUrl, + storageClient, + uploadFile, +} from './config/storage'; declare module 'express-session' { interface SessionData { @@ -78,7 +84,7 @@ app.use( credentials: true, }) ); -app.use(express.json()); +app.use(express.json({ limit: '20mb' })); if (process.env['SESSION_SECRET'] === undefined) { console.error('Session secret not provided in .env file'); @@ -191,7 +197,7 @@ app.post('/auth/otp/generate', async (req: Request, res: Response) => { }); } - if (process.env['CI']) { + if (process.env['NODE_ENV'] === 'test') { return res.status(200).json({ message: token }); } return res.status(200).json({ message: 'ok' }); @@ -256,6 +262,8 @@ app.post('/auth/password/forgot', async (req: Request, res: Response) => { await updateUserPasswordFromEmail(email, password, SALT_ROUNDS); + await redisClient.del(email); + return res.status(200).json({ message: 'ok' }); } catch (error) { return res.status(400).json({ @@ -626,10 +634,33 @@ app.post( return res.status(400).json({ message: 'Invalid date' }); } + //console.log(event); + + // upload image to storage and get link + let imagePath = ''; + try { + if (event.banner && storageClient) { + const metaData = event.banner.metaData; + const base64Data = event.banner.buffer.split(',')[1]; + if (base64Data) { + imagePath = await uploadFile( + base64Data, + metaData.type, + event.societyId, + event.name + ); + } else { + throw new Error('Invalid base64 string.'); + } + } + } catch (error) { + return res.status(400).json({ message: `Unable to upload image.` }); + } + try { const eventRes = await prisma.event.create({ data: { - banner: event.banner, + banner: imagePath, name: event.name, startDateTime: dayjs(event.startDateTime).toISOString(), endDateTime: dayjs(event.endDateTime).toISOString(), @@ -644,6 +675,7 @@ app.post( }); return res.status(200).json(eventRes); } catch (e) { + console.log(e); return res.status(400).json({ message: 'Invalid event input' }); } } @@ -675,13 +707,37 @@ app.put('/event', async (req: TypedRequest, res: Response) => { return res.status(400).json({ message: 'Invalid date' }); } + // upload image to storage and get link + let imagePath; + try { + if (Object.keys(event.banner).length > 0) { + const base64Data = req.body.banner.buffer.split(',')[1]; + if (base64Data) { + const metaData = req.body.banner.metaData; + + imagePath = await uploadFile( + base64Data, + metaData.type, + event.societyId, + event.name + ); + } else { + throw new Error('Invalid base64 string.'); + } + } else { + throw new Error('No banner submitted.'); + } + } catch (error) { + return res.status(400).json({ error: (error as Error).message }); + } + try { const eventRes = await prisma.event.update({ where: { id: eventID, }, data: { - banner: req.body.banner, + banner: imagePath, name: req.body.name, startDateTime: dayjs(req.body.startDateTime).toISOString(), endDateTime: dayjs(req.body.endDateTime).toISOString(), @@ -689,7 +745,15 @@ app.put('/event', async (req: TypedRequest, res: Response) => { description: req.body.description, }, }); - return res.status(200).json(eventRes); + // we are choosing to send the image back as a url + let imageFile; + try { + imageFile = await getFileUrl(event.banner); // getFile(event.banner) if we wanted raw file + } catch (error) { + return res.status(400).json({ error: (error as Error).message }); + } + + return res.status(200).json({ ...event, banner: imageFile }); } catch (e) { return res.status(400).json({ message: 'Invalid event input' }); } @@ -1175,16 +1239,16 @@ app.get('/user/keywords', async (req, res: Response) => { const userID = sessionFromDB.userId; - let userKeywords = await prisma.user.findFirst({ - where: { - id: userID, - }, - select: { - keywords: true, - } - }); - - if (userKeywords === null) return res.status(404).json([]); + let userKeywords = await prisma.user.findFirst({ + where: { + id: userID, + }, + select: { + keywords: true, + }, + }); + + if (userKeywords === null) return res.status(404).json([]); return res.status(200).json(userKeywords.keywords); }); @@ -1224,21 +1288,21 @@ app.post( }); return res.status(200).json(newKeyword); } catch (e) { - return res.status(400).json({ message: "Invalid keyword input." }); + return res.status(400).json({ message: 'Invalid keyword input.' }); } } ); // associates a user with a keyword app.post( - "/user/keyword", + '/user/keyword', async (req: TypedRequest, res: Response) => { //get userid from session const sessionFromDB = await validateSession( req.session ? req.session : null ); if (!sessionFromDB) { - return res.status(401).json({ message: "Invalid session provided." }); + return res.status(401).json({ message: 'Invalid session provided.' }); } const userID = sessionFromDB.userId; @@ -1253,7 +1317,7 @@ app.post( }); if (!keywordExists) { - return res.status(404).json({ message: "Invalid keyword." }); + return res.status(404).json({ message: 'Invalid keyword.' }); } //Connect keyword and user @@ -1283,19 +1347,20 @@ app.post( }, }); - return res.status(200).json({ message: "ok" }); -}); + return res.status(200).json({ message: 'ok' }); + } +); // disassociates a user with a keyword app.delete( - "/user/keyword", + '/user/keyword', async (req: TypedRequest, res: Response) => { //get userid from session const sessionFromDB = await validateSession( req.session ? req.session : null ); if (!sessionFromDB) { - return res.status(401).json({ message: "Invalid session provided." }); + return res.status(401).json({ message: 'Invalid session provided.' }); } const userID = sessionFromDB.userId; @@ -1310,7 +1375,7 @@ app.delete( }); if (!societyId) { - return res.status(400).json({ message: "Invalid keyword." }); + return res.status(400).json({ message: 'Invalid keyword.' }); } //Disconnect keyword and user @@ -1340,8 +1405,9 @@ app.delete( }, }); - return res.status(200).json({ message: "ok" }); -}); + return res.status(200).json({ message: 'ok' }); + } +); app.get('/hello', () => { console.log('Hello World!'); diff --git a/backend/src/requestTypes.ts b/backend/src/requestTypes.ts index c2702a0..c97125b 100644 --- a/backend/src/requestTypes.ts +++ b/backend/src/requestTypes.ts @@ -24,7 +24,7 @@ export interface UserIdBody { } export interface CreateEventBody { - banner: string; + banner: Base64Image; name: string; startDateTime: Date; endDateTime: Date; @@ -33,6 +33,15 @@ export interface CreateEventBody { societyId: number; } +interface Base64Image { + buffer: string, + metaData: { + name: string, + type: string, + size: number, + } +} + export interface UpdateEventBody extends CreateEventBody { id: number; } diff --git a/backend/tests/otp.test.ts b/backend/tests/otp.test.ts index 7f2a5f8..bf84d83 100644 --- a/backend/tests/otp.test.ts +++ b/backend/tests/otp.test.ts @@ -5,7 +5,7 @@ import app from '../src/index'; import { createClient } from 'redis'; import prisma from '../src/prisma'; -describe.skip('Password change', () => { +describe('Password change', () => { test('Forgot password OTP', async () => { const redisClient = createClient({ url: `redis://localhost:${process.env['REDIS_PORT']}`, @@ -45,6 +45,7 @@ describe.skip('Password change', () => { const expToken = await redisClient.get(newUser.email); expect(expToken).not.toBeNull(); + expect(expToken).toEqual(expResToken); await new Promise((resolve) => setTimeout(resolve, 1000)); @@ -106,14 +107,11 @@ describe.skip('Password change', () => { const forgotRes = await request(app).post('/auth/password/forgot').send({ email: 'pyramidstestdump@gmail.com', token: fResToken, - newPassword: 'oraclefan1', + password: 'oraclefan1', }); - //console.log(forgotRes.error.text); expect(forgotRes.status).toBe(200); - await new Promise((resolve) => setTimeout(resolve, 1000)); - const fCheckVerTokens = await redisClient.get(newUser.email); expect(fCheckVerTokens).toBeNull(); @@ -194,7 +192,7 @@ describe.skip('Password change', () => { username: 'richard grayson', password: 'iheartkori', }); - expect(updateResFail.status).toBe(400); + expect(oldPassResFail.status).toBe(400); const newPassRes = await request(app).post('/auth/login').send({ username: 'richard grayson', diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 646a6e6..b34ac97 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -10,7 +10,7 @@ importers: dependencies: '@heroicons/react': specifier: ^2.1.5 - version: 2.1.5(react@18.3.1) + version: 2.2.0(react@18.3.1) date-fns: specifier: ^4.1.0 version: 4.1.0 @@ -22,7 +22,7 @@ importers: version: 18.3.1(react@18.3.1) react-router: specifier: ^7.0.1 - version: 7.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 7.0.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react-router-dom: specifier: ^7.0.2 version: 7.0.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -364,8 +364,8 @@ packages: resolution: {integrity: sha512-c7hNEllBlenFTHBky65mhq8WD2kbN9Q6gk0bTk8lSBvc554jpXSkST1iePudpt7+A/AQvuHs9EMqjHDXMY1lrA==} engines: {node: '>=18.18'} - '@jridgewell/gen-mapping@0.3.5': - resolution: {integrity: sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==} + '@jridgewell/gen-mapping@0.3.8': + resolution: {integrity: sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==} engines: {node: '>=6.0.0'} '@jridgewell/resolve-uri@3.1.2': @@ -650,8 +650,8 @@ packages: deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} - electron-to-chromium@1.5.41: - resolution: {integrity: sha512-dfdv/2xNjX0P8Vzme4cfzHqnPm5xsZXwsolTYr0eyW18IUmNyG08vL+fttvinTfhKfIKdRoqkDIC9e9iWQCNYQ==} + electron-to-chromium@1.5.73: + resolution: {integrity: sha512-8wGNxG9tAG5KhGd3eeA0o6ixhiNdgr0DcHWm85XPCphwZgD1lIEoi6t3VERayWao7SF7AAZTw6oARGJeVjH8Kg==} esbuild@0.21.5: resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} @@ -820,8 +820,8 @@ packages: resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} hasBin: true - jsesc@3.0.2: - resolution: {integrity: sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==} + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} hasBin: true @@ -961,16 +961,6 @@ packages: react-dom: optional: true - react-router@7.0.2: - resolution: {integrity: sha512-m5AcPfTRUcjwmhBzOJGEl6Y7+Crqyju0+TgTQxoS4SO+BkWbhOrcfZNq6wSWdl2BBbJbsAoBUb8ZacOFT+/JlA==} - engines: {node: '>=20.0.0'} - peerDependencies: - react: '>=18' - react-dom: '>=18' - peerDependenciesMeta: - react-dom: - optional: true - react@18.3.1: resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} engines: {node: '>=0.10.0'} @@ -1122,7 +1112,7 @@ snapshots: '@ampproject/remapping@2.3.0': dependencies: - '@jridgewell/gen-mapping': 0.3.5 + '@jridgewell/gen-mapping': 0.3.8 '@jridgewell/trace-mapping': 0.3.25 '@babel/code-frame@7.26.2': @@ -1157,9 +1147,9 @@ snapshots: dependencies: '@babel/parser': 7.26.3 '@babel/types': 7.26.3 - '@jridgewell/gen-mapping': 0.3.5 + '@jridgewell/gen-mapping': 0.3.8 '@jridgewell/trace-mapping': 0.3.25 - jsesc: 3.0.2 + jsesc: 3.1.0 '@babel/helper-compilation-targets@7.25.9': dependencies: @@ -1378,7 +1368,7 @@ snapshots: '@humanwhocodes/retry@0.4.1': {} - '@jridgewell/gen-mapping@0.3.5': + '@jridgewell/gen-mapping@0.3.8': dependencies: '@jridgewell/set-array': 1.2.1 '@jridgewell/sourcemap-codec': 1.5.0 @@ -1621,14 +1611,14 @@ snapshots: browserslist@4.24.2: dependencies: - caniuse-lite: 1.0.30001669 - electron-to-chromium: 1.5.41 - node-releases: 2.0.18 + caniuse-lite: 1.0.30001688 + electron-to-chromium: 1.5.73 + node-releases: 2.0.19 update-browserslist-db: 1.1.1(browserslist@4.24.2) callsites@3.1.0: {} - caniuse-lite@1.0.30001669: {} + caniuse-lite@1.0.30001688: {} chalk@4.1.2: dependencies: @@ -1663,7 +1653,7 @@ snapshots: deep-is@0.1.4: {} - electron-to-chromium@1.5.41: {} + electron-to-chromium@1.5.73: {} esbuild@0.21.5: optionalDependencies: @@ -1853,7 +1843,7 @@ snapshots: dependencies: argparse: 2.0.1 - jsesc@3.0.2: {} + jsesc@3.1.0: {} json-buffer@3.0.1: {} @@ -1974,16 +1964,6 @@ snapshots: optionalDependencies: react-dom: 18.3.1(react@18.3.1) - react-router@7.0.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1): - dependencies: - '@types/cookie': 0.6.0 - cookie: 1.0.2 - react: 18.3.1 - set-cookie-parser: 2.7.1 - turbo-stream: 2.4.0 - optionalDependencies: - react-dom: 18.3.1(react@18.3.1) - react@18.3.1: dependencies: loose-envify: 1.4.0 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7cecd74..4382ebd 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -14,7 +14,7 @@ import { DiscordPage } from './Settings/SettingsPage/DiscordPage/DiscordPage'; import { Unauthenticated } from './Unauthenticated/Unauthenticated'; import { ProtectedRoute } from './ProtectedRoute/ProtectedRoute'; import { useEffect, useState } from 'react'; -import { Societies, User, UserContext } from './UserContext/UserContext'; +import { Societies, Society, User, UserContext } from './UserContext/UserContext'; import GenerateOTP from './GenerateOTP/GenerateOTP'; import VerifyOTP from './VerifyOTP/VerifyOTP'; import { SocietyManagementPage } from './Settings/SettingsPage/SocietyManagementPage/SocietyManagementPage'; @@ -26,6 +26,7 @@ function App() { joined: [], administering: [], }); + const [society, setSociety] = useState(null); useEffect(() => { fetch('http://localhost:5180/user', { @@ -45,7 +46,7 @@ function App() { return ( - +
} /> diff --git a/frontend/src/Register/Register.tsx b/frontend/src/Register/Register.tsx index ec79664..58c3ae8 100644 --- a/frontend/src/Register/Register.tsx +++ b/frontend/src/Register/Register.tsx @@ -4,7 +4,7 @@ import { TextInput, TextOptions } from "../TextInput/TextInput"; import { AtSymbolIcon, UserCircleIcon } from "@heroicons/react/24/outline"; import { LockClosedIcon } from "@heroicons/react/24/outline"; import { useState, FormEvent } from "react"; -import { Link } from "react-router"; +import { Link, useNavigate } from "react-router"; import { errorHandler, AuthError } from "../errorHandler"; export default function RegisterPage() { @@ -12,6 +12,7 @@ export default function RegisterPage() { const [password, setPassword] = useState(""); const [email, setEmail] = useState(""); const [error, setError] = useState(undefined); + const navigate = useNavigate(); async function handleSubmit(event: FormEvent) { event.preventDefault(); const res = await fetch("http://localhost:5180/auth/register", { @@ -31,6 +32,7 @@ export default function RegisterPage() { setError(errorHandler(json.error)); } else { setError(undefined); + navigate("/login"); } } diff --git a/frontend/src/Settings/Settings.module.css b/frontend/src/Settings/Settings.module.css index 4369c91..b548380 100644 --- a/frontend/src/Settings/Settings.module.css +++ b/frontend/src/Settings/Settings.module.css @@ -3,4 +3,5 @@ align-items: center; height: 100%; gap: 15%; + position: relative; } diff --git a/frontend/src/Settings/Settings.tsx b/frontend/src/Settings/Settings.tsx index 8a69c62..83da53e 100644 --- a/frontend/src/Settings/Settings.tsx +++ b/frontend/src/Settings/Settings.tsx @@ -1,12 +1,14 @@ import { Outlet } from 'react-router'; import { SettingsNavbar } from './SettingsNavbar/SettingsNavbar'; import classes from './Settings.module.css'; +import { SocietyDropdown } from './SocietyDropdown/SocietyDropdown'; export function Settings() { return ( -
- - -
+
+ + + +
); } diff --git a/frontend/src/Settings/SettingsPage/EventManagementPage/CreateNewEvent/CreateNewEvent.module.css b/frontend/src/Settings/SettingsPage/EventManagementPage/CreateNewEvent/CreateNewEvent.module.css index 2060c42..0365754 100644 --- a/frontend/src/Settings/SettingsPage/EventManagementPage/CreateNewEvent/CreateNewEvent.module.css +++ b/frontend/src/Settings/SettingsPage/EventManagementPage/CreateNewEvent/CreateNewEvent.module.css @@ -2,6 +2,18 @@ display: flex; } +.error{ + padding: 10px; + color: hsl(0, 100%, 64%); + font-weight: bold; +} + +.fileName{ + margin-top: 0; + margin-left: 0; + font-size: 15px; +} + .main { width: 40vw; min-width: 400px; @@ -22,6 +34,10 @@ padding: 10px; } +.photoArea { + margin-bottom: 36px; +} + .photo { border: 4px hsl(0, 0%, 85%) dashed; height: 120px; @@ -30,7 +46,11 @@ justify-content: center; align-items: center; color: hsl(0, 0%, 60%); - margin-bottom: 36px; +} + +.photo:active, .photo:focus, .photo:hover, .photoDragged { + border-color: hsl(249, 88%, 60%); + color: hsl(249, 88%, 60%); } .cameraIcon { @@ -45,6 +65,12 @@ font-weight: bold; } +.submitting { + padding: 10px; + color: hsl(0, 0%, 0%); + font-weight: bold; +} + .textInput { background-color: hsl(0, 0%, 93%); color: hsla(0, 0%, 0%, 30%); @@ -57,6 +83,18 @@ margin-bottom: 34px; } +.description { + line-height: 160%; + padding-left: 20px; + padding-right: 20px; +} + +.thumbnail { + width: 100px; + margin-top: 10px; + cursor: pointer; +} + .times { display: flex; justify-content: space-between; @@ -65,10 +103,4 @@ .timeInput { width: 23vh; -} - -.description { - line-height: 160%; - padding-left: 20px; - padding-right: 20px; -} +} \ No newline at end of file diff --git a/frontend/src/Settings/SettingsPage/EventManagementPage/CreateNewEvent/CreateNewEvent.tsx b/frontend/src/Settings/SettingsPage/EventManagementPage/CreateNewEvent/CreateNewEvent.tsx index 30339d2..4d0fbc3 100644 --- a/frontend/src/Settings/SettingsPage/EventManagementPage/CreateNewEvent/CreateNewEvent.tsx +++ b/frontend/src/Settings/SettingsPage/EventManagementPage/CreateNewEvent/CreateNewEvent.tsx @@ -3,8 +3,245 @@ import Button from '../../../../Button/Button'; import { SettingsPage } from '../../SettingsPage'; import classes from './CreateNewEvent.module.css'; import { ButtonIcons, ButtonVariants } from '../../../../Button/ButtonTypes'; +import { ChangeEventHandler, MouseEventHandler, useContext, useEffect, useRef, useState } from 'react'; +import { UserContext } from '../../../../UserContext/UserContext'; +import { TextInput, TextOptions } from '../../../../TextInput/TextInput'; +import { useNavigate } from 'react-router'; + +type StringSetter = React.Dispatch>; + +enum ErrorMessage { + TYPE = "Banner must be an image file.", + NUMBER = "Please upload only one image.", + SIZE = "Maximum file size of 10MB.", + default = "", +}; + +interface UploadError { + status: boolean, + message: ErrorMessage, +}; + +const updateError = (newMessage: ErrorMessage) => { + return { + status: newMessage !== ErrorMessage.default, + message: newMessage + } +}; + +const fileToBase64 = (file: File): Promise => { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onloadend = () => resolve(reader.result as string); + reader.onerror = reject; + reader.readAsDataURL(file); + }); +}; + +export interface FormStructure { + societyId: number | undefined, + banner?: File | Base64Image, + name: string, + location: string, + startDateTime: Date, + endDateTime: Date, + description: string, +}; + +interface Base64Image { + buffer: string, + metaData: { + name: string, + type: string, + size: number, + } +}; export function CreateNewEventPage() { + const inputRef = useRef(null); + const [uploadError, setUploadError] = useState({status: false, message: ErrorMessage.default}); + const [submitError, setSubmitError] = useState(''); + const [fileDragging, setFileDragging] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [inactiveForms, setInactiveForms] = useState([]); + const { society } = useContext(UserContext); + const navigate = useNavigate(); + + const defaultForm = { + societyId: society?.id, + name: "Training Program Induction", + location: "John Lions Garden", + startDateTime: new Date(), + endDateTime: new Date(Date.now() + (60*60*1000)), // one hour by default + description: "Your event description." + }; + + const [formContent, setFormContent] = useState(defaultForm); + + const uploadBanner = (files: File[]) => { + if(files.length <= 1) { + if(files.length === 1) { + const file = files[0]; + if(file.type.split("/")[0] !== "image") { + setUploadError(updateError(ErrorMessage.TYPE)); + return; + } + if(file.size > 1000000 * 10) { + setUploadError(updateError(ErrorMessage.SIZE)); + return; + } + setUploadError(updateError(ErrorMessage.default)); + setFormContent({...formContent, banner: file}); + return; + } + setFormContent({...formContent, banner: undefined}); + } else { + setUploadError(updateError(ErrorMessage.NUMBER)); + } + } + + const handleDrop: React.DragEventHandler = (e) => { + e.preventDefault(); + setFileDragging(false); + + const droppedItems = e.dataTransfer.files; + if(droppedItems) { + console.log(e.dataTransfer); + uploadBanner([...droppedItems]); + } + } + + const handleDropzoneClick: MouseEventHandler = (e) => { + e.preventDefault(); + if(inputRef?.current) { + inputRef.current.click(); + } + } + + const handleInputChange: ChangeEventHandler = (e) => { + var files = e.target.files; + if(files) { + uploadBanner([...files]); + } + } + + const handleDropzoneDragOver: React.DragEventHandler = (e) => { + e.preventDefault(); + setFileDragging(true); + } + + const handleDropzoneDragEnd: React.DragEventHandler= (e) => { + e.preventDefault(); + setFileDragging(false); + } + + const removeFile: MouseEventHandler = (e) => { + e.preventDefault(); + setFormContent({...formContent, banner: undefined}); + } + + const submitForm = async () => { + let formResponses = formContent; + if(formContent.banner && formContent.banner instanceof File) { + const file = formContent.banner; + const buffer = await fileToBase64(file); + const data = { + buffer, + metaData: { + name: file.name, + type: file.type, + size: file.size, + } + }; + formResponses = {...formContent, banner: data}; + } + + console.log(formResponses); + + setSubmitting(true); + const res = await fetch("http://localhost:5180/event", { + method: "POST", + credentials: "include", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(formResponses) + }); + const json = await res.json(); + + console.log(json); + setSubmitting(false); + if(res.ok){ + emptyForm(); + navigate("/settings/events", {state: { creationSuccess: true} } ); + } else { + setSubmitError(json.message); + } + }; + + const emptyForm = () => { + setFormContent(defaultForm); + } + + const setFormItem = (itemKey: keyof FormStructure) => { + const getUpdatedDateTime = (val: string, newDateTime: Date) => { + //check if time + const times = val.split(":"); + if(times.length === 1) { + //must be date + const [year, month, day] = val.split('-').map(Number); + newDateTime.setFullYear(year, month, day); + } else { + const [hour, minute] = times.map(Number); + newDateTime.setHours(hour, minute); + } + return newDateTime; + } + + const setItemContent: StringSetter = (val) => { + if(typeof val === "string") { + console.log(val); + if(formContent[itemKey] instanceof Date) { + const newDateTime = getUpdatedDateTime(val, formContent[itemKey]); + setFormContent({...formContent, [itemKey]: newDateTime}); + return; + } + setFormContent({...formContent, [itemKey]: val}); + } else { + setFormContent((prev) => { + const item = prev[itemKey]; + let newVal; + if(item instanceof Date) { + // assumes val ouputs in appropriate Date/Time format + const newDateTime = val(item.toString()); + newVal = getUpdatedDateTime(newDateTime, item); + } else { + newVal = val(prev[itemKey] as string); + } + return {...prev, [itemKey]: newVal}; + }); + } + }; + return setItemContent; + } + + useEffect(() => { + if(!formContent.societyId) { + setFormContent({...formContent, societyId: society?.id}); + return; + } + if(society?.id !== formContent.societyId) { + setInactiveForms((prev) => [...prev.filter(item => item.societyId !== formContent.societyId), formContent]); + const archived = inactiveForms.find((form) => form.societyId === society?.id); + console.log(archived); + if(archived) { + setFormContent(archived); + return; + } + } + setFormContent({...defaultForm, societyId: society?.id}); + }, [society]); + return ( , ]} >
-
- -

Upload photo here...

+ {submitError && +
+

{submitError}

+
} + {submitting && +
+

creating event...

+
} +
+
+ +

Upload photo here...

+
+ + {uploadError.status && +
+

{uploadError.message}

+
} + {formContent.banner instanceof File && +
+ +

{formContent.banner.name}

+
}
-
Training Program Induction
+ -
John Lions Garden
+
-
6/5/24
+
-
11:00AM
+
-
5:00PM
+
-
- Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vestibulum - elementum pulvinar cursus. Duis vel convallis orci. Duis blandit - ultrices hendrerit. Morbi ullamcorper vehicula arcu, et suscipit ex - posuere quis. Sed turpis massa, placerat ut sem -
+ ); diff --git a/frontend/src/Settings/SettingsPage/EventManagementPage/EventManagementPage.module.css b/frontend/src/Settings/SettingsPage/EventManagementPage/EventManagementPage.module.css new file mode 100644 index 0000000..8ef9350 --- /dev/null +++ b/frontend/src/Settings/SettingsPage/EventManagementPage/EventManagementPage.module.css @@ -0,0 +1,6 @@ +.success { + padding: 10px; + color: hsla(125, 69%, 50%, 0.76); + font-size: 1.3rem; + font-weight: bold; +} \ No newline at end of file diff --git a/frontend/src/Settings/SettingsPage/EventManagementPage/EventManagementPage.tsx b/frontend/src/Settings/SettingsPage/EventManagementPage/EventManagementPage.tsx index bf99160..1f3f9c0 100644 --- a/frontend/src/Settings/SettingsPage/EventManagementPage/EventManagementPage.tsx +++ b/frontend/src/Settings/SettingsPage/EventManagementPage/EventManagementPage.tsx @@ -1,22 +1,16 @@ -import { Link } from 'react-router'; +import { Link, useLocation } from 'react-router'; import Button from '../../../Button/Button'; import { ButtonIcons, ButtonVariants } from '../../../Button/ButtonTypes'; import { SettingsPage } from '../SettingsPage'; import { useEffect } from 'react'; +import classes from './EventManagementPage.module.css'; export function EventManagementPage() { + const location = useLocation(); + const { creationSuccess } = location.state; + useEffect(() => { const getEvents = async () => { - const societies = await fetch('http://localhost:5180/user/societies', { - method: 'GET', - credentials: 'include', - }); - - if (societies.ok) { - const societiesJson = await societies.json(); - console.log(societiesJson); - } - const events = await fetch( 'http://localhost:5180/society/events?' + new URLSearchParams({ @@ -50,6 +44,11 @@ export function EventManagementPage() { , ]} > + {creationSuccess && ( +
+

Event created!

+
+ )} diff --git a/frontend/src/Settings/SocietyDropdown/SocietyDropdown.module.css b/frontend/src/Settings/SocietyDropdown/SocietyDropdown.module.css new file mode 100644 index 0000000..4337ab3 --- /dev/null +++ b/frontend/src/Settings/SocietyDropdown/SocietyDropdown.module.css @@ -0,0 +1,40 @@ +.dropdown { + +} + +.container { + position: absolute; + right: 0; + display: flex; + align-items: center; + flex-direction: column; + background: hsl(0, 0%, 91%); + padding: 32px; + padding-left: 24px; + border-radius: 16px 0px 0px 16px; + width: 19%; + } + +.label { + font-weight: bold; +} + +.select { + width: 100%; + text-align: center; + padding: 10px; + font-weight:bold; + margin-top: 10px; + font-size: 1rem; + text-overflow: ellipsis; +} + +.option { + max-width: 280px; + font-size: 1.2rem; + text-overflow: ellipsis; +} + +.option:hover { + font-weight: bold; +} \ No newline at end of file diff --git a/frontend/src/Settings/SocietyDropdown/SocietyDropdown.tsx b/frontend/src/Settings/SocietyDropdown/SocietyDropdown.tsx new file mode 100644 index 0000000..699b8cd --- /dev/null +++ b/frontend/src/Settings/SocietyDropdown/SocietyDropdown.tsx @@ -0,0 +1,59 @@ +import { ChangeEvent, useContext, useEffect } from 'react'; +import classes from './SocietyDropdown.module.css'; +import { UserContext } from '../../UserContext/UserContext'; + +export function SocietyDropdown() { + const { societies, setSocieties, setSociety } = useContext(UserContext); + + useEffect(() => { + const getSocieties = async () => { + const res = await fetch('http://localhost:5180/user/societies', { + method: 'Get', + credentials: 'include', + }); + const json = await res.json(); + + console.log(json); + + if (json) { + setSocieties?.(json); + } + }; + getSocieties(); + }, []); + + const handleSelect = (event: ChangeEvent) => { + const newSelection = event.target.value; + const findSelection = societies?.administering.find( + (soc) => soc.name === newSelection + ); + console.log(newSelection); + if (findSelection) { + setSociety?.(findSelection); + console.log(findSelection); + } + }; + + return ( +
+ + +
+ ); +} diff --git a/frontend/src/TextInput/TextInput.module.css b/frontend/src/TextInput/TextInput.module.css index 799cbd9..91ad250 100644 --- a/frontend/src/TextInput/TextInput.module.css +++ b/frontend/src/TextInput/TextInput.module.css @@ -8,6 +8,7 @@ background: none; font-size: 1.2rem; width: 100%; + resize: none; } .input::placeholder { diff --git a/frontend/src/TextInput/TextInput.tsx b/frontend/src/TextInput/TextInput.tsx index 94f8895..fb05dad 100644 --- a/frontend/src/TextInput/TextInput.tsx +++ b/frontend/src/TextInput/TextInput.tsx @@ -5,15 +5,21 @@ export enum TextOptions { Text = "text", Password = "password", Email = "email", + Date = "date", + Time = "time", } type TextInputProp = { + autofocus?: boolean; + className?: string; icon?: ReactNode; placeholder: string; name: string; onChange: React.Dispatch>; type: TextOptions; error: boolean; + textarea?: boolean; + value?: string; }; export function TextInput(props: TextInputProp) { @@ -21,26 +27,40 @@ export function TextInput(props: TextInputProp) { const onFocus = () => setFocus(true); const onBlur = () => setFocus(false); - function handleChange(event: ChangeEvent) { + function handleChange(event: ChangeEvent) { const value = event.target.value; props.onChange(value); } return (
- + {props.textarea ? +