diff --git a/cypress/e2e/pages/edit-profile.cy.ts b/cypress/e2e/pages/edit-profile.cy.ts new file mode 100644 index 00000000..374d5678 --- /dev/null +++ b/cypress/e2e/pages/edit-profile.cy.ts @@ -0,0 +1,35 @@ +/// + +describe('Edit Profile Page', () => { + beforeEach(() => { + cy.login('standard'); + cy.location('pathname').should('equal', '/'); + cy.visit('/profile/edit'); + cy.location('pathname').should('equal', '/profile/edit'); + }); + + it('Should update profile preview', () => { + // write something new to make sure it can save + cy.typeInForm('First', new Date().toISOString()); + cy.get('button').contains('Save').click(); + + cy.fixture('profile').then(profile => { + const { first, last, bio, major, year } = profile; + + cy.typeInForm('First', first); + cy.typeInForm('Last', last); + cy.selectInForm('Major', major); + cy.selectInForm('Graduation Year', year); + cy.typeInForm('Biography', bio); + cy.get('button').contains('Save').click(); + + cy.get('.Toastify').contains('Changes saved!').should('exist'); + + cy.get('section:contains("Current Profile")').within(() => { + Object.values(profile).forEach((value: string | number) => { + cy.contains(value).should('exist'); + }); + }); + }); + }); +}); diff --git a/cypress/fixtures/profile.json b/cypress/fixtures/profile.json new file mode 100644 index 00000000..5f6df697 --- /dev/null +++ b/cypress/fixtures/profile.json @@ -0,0 +1,7 @@ +{ + "first": "John", + "last": "Doe", + "bio": "I am a testing account.", + "major": "Computer Engineering", + "year": "2026" +} diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts index cb0ff5c3..a3473cb6 100644 --- a/cypress/support/commands.ts +++ b/cypress/support/commands.ts @@ -1 +1,50 @@ +declare global { + namespace Cypress { + interface Chainable { + /** + * Log in as the specified user, pulling details from accounts.json + * @example cy.login('standard') + */ + login(account: string): Chainable; + + /** + * Type text into a form input or textarea under specified label + * @param label - text of label that the input is a child of + * @param value - text to be typed into input + */ + typeInForm(label: string, value: string): Chainable; + + /** + * Select the given option in a select under specified label + * @param label - text of label that the select is a child of + * @param value - option to be selected + */ + selectInForm(label: string, value: string): Chainable; + } + } +} + +Cypress.Commands.add('login', (account: string) => { + cy.fixture('accounts.json').then(accs => { + if (!(account in accs)) + throw new Error(`Account '${account}' isn't specified in \`accounts.json\``); + const { email, password } = accs[account]; + + cy.visit('/login'); + cy.get('input[name="email"]').type(email); + cy.get('input[name="password"]').type(password); + cy.get('button').contains('Sign In').click(); + }); +}); + +Cypress.Commands.add('typeInForm', (label: string, value: string) => { + cy.get(`label:contains("${label}") input, label:contains("${label}") textarea`).as('input'); + cy.get('@input').clear(); + cy.get('@input').type(value as string); +}); + +Cypress.Commands.add('selectInForm', (label: string, value: string | number) => { + cy.get(`label:contains("${label}") select`).select(value); +}); + export {}; diff --git a/public/assets/icons/edit.svg b/public/assets/icons/edit.svg new file mode 100644 index 00000000..fc3c1e6a --- /dev/null +++ b/public/assets/icons/edit.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/public/assets/icons/email-solid.svg b/public/assets/icons/email-solid.svg new file mode 100644 index 00000000..a0caf3fd --- /dev/null +++ b/public/assets/icons/email-solid.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/assets/icons/facebook-icon.svg b/public/assets/icons/facebook-icon.svg index d3f40bca..02c27646 100644 --- a/public/assets/icons/facebook-icon.svg +++ b/public/assets/icons/facebook-icon.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/icons/github-icon.svg b/public/assets/icons/github-icon.svg new file mode 100644 index 00000000..458be3ac --- /dev/null +++ b/public/assets/icons/github-icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/assets/icons/ig-icon.svg b/public/assets/icons/ig-icon.svg deleted file mode 100644 index 6fcb443d..00000000 --- a/public/assets/icons/ig-icon.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/public/assets/icons/instagram.svg b/public/assets/icons/instagram.svg index c0985960..2e0ecf50 100644 --- a/public/assets/icons/instagram.svg +++ b/public/assets/icons/instagram.svg @@ -1,3 +1,3 @@ - + \ No newline at end of file diff --git a/public/assets/icons/leaderboard-icon.svg b/public/assets/icons/leaderboard-icon.svg index b8bdc0a4..5b3897e0 100644 --- a/public/assets/icons/leaderboard-icon.svg +++ b/public/assets/icons/leaderboard-icon.svg @@ -1,4 +1,4 @@ - + diff --git a/public/assets/icons/link-icon.svg b/public/assets/icons/link-icon.svg new file mode 100644 index 00000000..ea1335c6 --- /dev/null +++ b/public/assets/icons/link-icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/assets/icons/linkedin-icon.svg b/public/assets/icons/linkedin-icon.svg new file mode 100644 index 00000000..cb13cc42 --- /dev/null +++ b/public/assets/icons/linkedin-icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/assets/icons/major-icon.svg b/public/assets/icons/major-icon.svg index 64e55af9..224c42df 100644 --- a/public/assets/icons/major-icon.svg +++ b/public/assets/icons/major-icon.svg @@ -1,3 +1,3 @@ - - + + diff --git a/public/assets/icons/profile-icon.svg b/public/assets/icons/profile-icon.svg index 65a36ab4..3164f29e 100644 --- a/public/assets/icons/profile-icon.svg +++ b/public/assets/icons/profile-icon.svg @@ -1,3 +1,3 @@ - + diff --git a/public/assets/icons/twitter.svg b/public/assets/icons/twitter.svg new file mode 100644 index 00000000..6ec13e8b --- /dev/null +++ b/public/assets/icons/twitter.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/components/common/GifSafeImage/index.tsx b/src/components/common/GifSafeImage/index.tsx new file mode 100644 index 00000000..9d95c238 --- /dev/null +++ b/src/components/common/GifSafeImage/index.tsx @@ -0,0 +1,13 @@ +import { isSrcAGif } from '@/lib/utils'; +import Image, { ImageProps } from 'next/image'; + +/** + * A next/image Image that is automatically unoptimized when `src` is a gif + * @param props - props for Next Image component + * @returns image component + */ +const GifSafeImage = ({ src, alt, ...restProps }: ImageProps) => { + return ; +}; + +export default GifSafeImage; diff --git a/src/components/common/index.ts b/src/components/common/index.ts index 4eb274f1..40711238 100644 --- a/src/components/common/index.ts +++ b/src/components/common/index.ts @@ -3,6 +3,7 @@ export { default as Carousel } from './Carousel'; export { default as CommunityLogo } from './CommunityLogo'; export { default as Cropper } from './Cropper'; export { default as Dropdown } from './Dropdown'; +export { default as GifSafeImage } from './GifSafeImage'; export { default as LinkButton } from './LinkButton'; export { default as Modal } from './Modal'; export { default as PaginationControls } from './PaginationControls'; diff --git a/src/components/events/index.tsx b/src/components/events/index.tsx index 18664aff..31024178 100644 --- a/src/components/events/index.tsx +++ b/src/components/events/index.tsx @@ -1,2 +1,6 @@ +export { default as CalendarButtons } from './CalendarButtons'; +export { default as EventCard } from './EventCard'; export { default as EventCarousel } from './EventCarousel'; export { default as EventDisplay } from './EventDisplay'; +export { default as EventModal } from './EventModal'; +export { default as PointsDisplay } from './PointsDisplay'; diff --git a/src/components/home/Hero/style.module.scss b/src/components/home/Hero/style.module.scss index b150c8de..77973d2c 100644 --- a/src/components/home/Hero/style.module.scss +++ b/src/components/home/Hero/style.module.scss @@ -122,6 +122,11 @@ &:hover { box-shadow: 0 5px 10px var(--theme-shadow); } + + svg { + height: 24px; + width: 24px; + } } } } diff --git a/src/components/layout/Navbar/style.module.scss b/src/components/layout/Navbar/style.module.scss index 1bc87baf..3f9663d0 100644 --- a/src/components/layout/Navbar/style.module.scss +++ b/src/components/layout/Navbar/style.module.scss @@ -105,6 +105,8 @@ .iconLink { color: var(--theme-text-on-background-1); cursor: pointer; + height: 24px; + width: 24px; } } } @@ -150,6 +152,11 @@ &:hover { background-color: var(--theme-surface-1); } + + svg { + height: 24px; + width: 24px; + } } } diff --git a/src/components/leaderboard/LeaderboardRow/index.tsx b/src/components/leaderboard/LeaderboardRow/index.tsx index 3209df37..b857cac8 100644 --- a/src/components/leaderboard/LeaderboardRow/index.tsx +++ b/src/components/leaderboard/LeaderboardRow/index.tsx @@ -1,5 +1,4 @@ -import { isSrcAGif } from '@/lib/utils'; -import Image from 'next/image'; +import { GifSafeImage } from '@/components/common'; import Link from 'next/link'; import { useEffect, useRef } from 'react'; import styles from './style.module.scss'; @@ -52,14 +51,13 @@ const LeaderboardRow = ({ return ( {position} - {`Profile
diff --git a/src/components/leaderboard/TopThreeCard/index.tsx b/src/components/leaderboard/TopThreeCard/index.tsx index 763e4d59..f434ec88 100644 --- a/src/components/leaderboard/TopThreeCard/index.tsx +++ b/src/components/leaderboard/TopThreeCard/index.tsx @@ -1,5 +1,5 @@ -import { isSrcAGif, trim } from '@/lib/utils'; -import Image from 'next/image'; +import { GifSafeImage } from '@/components/common'; +import { trim } from '@/lib/utils'; import Link from 'next/link'; import styles from './style.module.scss'; @@ -18,13 +18,12 @@ const TopThreeCard = ({ position, rank, name, url, points, image }: UserCardProp
{position}
- User Profile Pic {trim(name, 25)} {rank} diff --git a/src/components/profile/Preview/index.tsx b/src/components/profile/Preview/index.tsx index a4a37b8c..34559c5e 100644 --- a/src/components/profile/Preview/index.tsx +++ b/src/components/profile/Preview/index.tsx @@ -1,13 +1,10 @@ -import { Typography } from '@/components/common'; +import { GifSafeImage, Typography } from '@/components/common'; import SocialMediaIcon from '@/components/profile/SocialMediaIcon'; import { PublicProfile } from '@/lib/types/apiResponses'; import { SocialMediaType } from '@/lib/types/enums'; -import { getLevel, getProfilePicture, isSrcAGif } from '@/lib/utils'; -import Image from 'next/image'; +import { fixUrl, getLevel, getProfilePicture } from '@/lib/utils'; import styles from './style.module.scss'; -const fixUrl = (url: string) => (url.includes('://') ? url : `http://${url}`); - interface PreviewStatProps { title: string; value: number; @@ -31,13 +28,12 @@ interface PreviewProps { const Preview = ({ user, pfpCacheBust }: PreviewProps) => { return (
- Profile picture {user.firstName} {user.lastName} diff --git a/src/components/profile/Preview/style.module.scss b/src/components/profile/Preview/style.module.scss index 4e26475c..0ad749a1 100644 --- a/src/components/profile/Preview/style.module.scss +++ b/src/components/profile/Preview/style.module.scss @@ -2,6 +2,7 @@ align-items: center; display: flex; flex-direction: column; + text-align: center; .pfp { @@ -33,6 +34,7 @@ .bio { white-space: pre-wrap; + word-break: break-word; } .socials { diff --git a/src/components/profile/UserHandleNotFound/index.tsx b/src/components/profile/UserHandleNotFound/index.tsx new file mode 100644 index 00000000..4bc258d8 --- /dev/null +++ b/src/components/profile/UserHandleNotFound/index.tsx @@ -0,0 +1,18 @@ +import { SignInButton } from '@/components/auth'; +import { Typography, VerticalForm } from '@/components/common'; +import Cat404 from '@/public/assets/graphics/cat404.png'; +import Image from 'next/image'; + +export interface UserHandleNotFoundProps { + handle: string; +} + +export const UserHandleNotFound = ({ handle }: UserHandleNotFoundProps) => ( + + + No user with handle ‘{handle}’ was found. + + Sad Cat + + +); diff --git a/src/components/profile/UserProfilePage/index.tsx b/src/components/profile/UserProfilePage/index.tsx new file mode 100644 index 00000000..ad1d5a60 --- /dev/null +++ b/src/components/profile/UserProfilePage/index.tsx @@ -0,0 +1,172 @@ +import { Carousel, GifSafeImage, Typography } from '@/components/common'; +import { EventCard } from '@/components/events'; +import SocialMediaIcon from '@/components/profile/SocialMediaIcon'; +import { config, showToast } from '@/lib'; +import { PublicAttendance, type PublicProfile } from '@/lib/types/apiResponses'; +import { SocialMediaType } from '@/lib/types/enums'; +import { copy, fixUrl, getLevel, getProfilePicture, getUserRank } from '@/lib/utils'; +import EditIcon from '@/public/assets/icons/edit.svg'; +import LeaderboardIcon from '@/public/assets/icons/leaderboard-icon.svg'; +import MajorIcon from '@/public/assets/icons/major-icon.svg'; +import ProfileIcon from '@/public/assets/icons/profile-icon.svg'; +import { Tooltip } from '@mui/material'; +import Link from 'next/link'; +import { useEffect, useState } from 'react'; +import styles from './style.module.scss'; + +export interface UserProfilePageProps { + handleUser: PublicProfile; + isSignedInUser: boolean; + signedInAttendances: PublicAttendance[]; + attendances?: PublicAttendance[]; +} + +export const UserProfilePage = ({ + handleUser, + attendances, + signedInAttendances, + isSignedInUser, +}: UserProfilePageProps) => { + // animate the progress bar + const [progress, setProgress] = useState(0); + useEffect(() => setProgress(handleUser.points % 100), [handleUser.points]); + + return ( +
+
+
+
+
+ +
+
+ {`${handleUser.firstName} ${handleUser.lastName}`} + +
+ { + copy(window.location.href); + showToast('Profile link copied!'); + }} + > + @{handleUser.handle} + +
+
+
+
+
{getUserRank(handleUser.points)}
+
+   + {handleUser.points.toLocaleString()} Leaderboard Points +
+
+ {isSignedInUser && ( +
+ +
+ +
+ +
+ )} +
+
+
+ + {isSignedInUser ? 'My' : `${handleUser.firstName}'s`} Progress + +
+ Level {getLevel(handleUser.points)} + {handleUser.points % 100}/100 +
+
+
+
+ + {isSignedInUser ? 'You need ' : `${handleUser.firstName} needs `} + {100 - (handleUser.points % 100)} more points to level up to + +  {getUserRank(handleUser.points + 100)} + + +
+ +
+
+ About me +
+ + Class of {handleUser.graduationYear} + + {handleUser.major} +
+
+ {handleUser.userSocialMedia?.map(social => ( + + + + ))} +
+
+
+ Bio + + {handleUser.bio || Nothing here...} + +
+ {isSignedInUser && ( +
+ +
+ +
+ +
+ )} +
+ {(attendances || isSignedInUser) && ( +
+ + Recently Attended Events + {!handleUser.isAttendancePublic && ( + +  (hidden for other users) + + )} + + + {(isSignedInUser ? signedInAttendances : (attendances as PublicAttendance[])).map( + ({ event }) => ( + uuid === event.uuid)} + /> + ) + )} + +
+ )} +
+ ); +}; diff --git a/src/components/profile/UserProfilePage/style.module.scss b/src/components/profile/UserProfilePage/style.module.scss new file mode 100644 index 00000000..7a1becda --- /dev/null +++ b/src/components/profile/UserProfilePage/style.module.scss @@ -0,0 +1,230 @@ +@use 'src/styles/vars.scss' as vars; + +.profilePage { + display: flex; + flex-direction: column; + gap: 2rem; + margin: auto; + max-width: vars.$breakpoint-md; + width: 100%; + + .cardWrapper { + padding-top: 3rem; + position: relative; + + .banner { + background-color: vars.$pink-2; + border-radius: 0.5rem; + height: 90%; + position: absolute; + top: 0; + width: 100%; + } + + .profileCard { + background-color: var(--theme-background); + border-radius: 0.25rem; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.25); + display: flex; + gap: 1rem; + margin: 0 2rem; + padding: 1rem 2rem; + position: relative; + + @media (max-width: vars.$breakpoint-md) { + align-items: center; + flex-direction: column; + } + + .profilePic { + border-radius: 4rem; + flex-shrink: 0; + height: 7rem; + overflow: hidden; + position: relative; + width: 7rem; + } + + .cardName { + align-items: flex-start; + display: flex; + flex-direction: column; + justify-content: center; + + @media (max-width: vars.$breakpoint-md) { + align-items: center; + flex-direction: column; + + h1, div { + text-align: center; + } + } + + .handle { + cursor: pointer; + } + } + + .cardRank { + align-items: flex-end; + display: flex; + flex-direction: column; + flex-grow: 1; + gap: 0.5rem; + justify-content: flex-end; + + @media (max-width: vars.$breakpoint-md) { + align-items: center; + color: var(--theme-text-on-background-2); + flex-direction: column; + } + + .rank { + color: var(--theme-text-on-background-1); + display: flex; + font-size: 1.25rem; + font-weight: 500; + margin-top: calc(2.375rem + 0.5rem); // give space for edit button + + @media (max-width: vars.$breakpoint-md) { + margin-top: 0; + text-align: center; + } + } + + .points { + color: var(--theme-text-on-background-1); + font-size: 1.125rem; + font-weight: 400; + line-height: 1.25; + text-align: end; + + @media (max-width: vars.$breakpoint-md) { + text-align: center; + } + + svg { + height: 1.125rem; + transform: translateY(calc((1.125rem * 1.25) - 1.125rem) * 0.5); // center the trophy icon + width: 1.125rem + } + } + } + } + } + + .section { + border: 2px solid #808184; + border-radius: 0.75rem; + padding: 1rem 1.5rem; + } + + .progressSection { + display: flex; + flex-direction: column; + gap: 0.9375rem; + + .progressInfo { + display: flex; + flex-flow: row wrap; + justify-content: space-between; + } + + .progressBar { + background-color: var(--theme-accent-line-2); + border-radius: 0.5rem; + height: 1.25rem; + width: 100%; + + .inner { + background-color: var(--theme-primary-2); + border-radius: 0.5rem; + height: 100%; + transition: width 1s ease; + } + } + + p { + @media (max-width: vars.$breakpoint-md) { + text-align: center; + } + } + } + + .aboutSection { + display: grid; + grid-template-columns: 20rem 1fr; + position: relative; + + @media (max-width: vars.$breakpoint-md) { + display: flex; + flex-direction: column; + gap: 1.875rem; + } + + .aboutMeSection { + align-items: center; + display: grid; + gap: 0.56rem; + grid-template-columns: auto 1fr; + margin: 0.81rem 0; + + .icon { + height: 1.313rem; + width: 1.313rem; + } + + } + + .socialIcons { + display: flex; + gap: 0.25rem; + + svg { + height: 2rem; + width: 2rem; + } + } + + .bioSection { + display: flex; + flex-direction: column; + gap: 0.5rem; + white-space: pre-wrap; + word-break: break-word; + } + } + + .card { + flex: none; + width: 20rem; + } + + .editWrapper { + position: absolute; + right: 1rem; + top: 1rem; + + div { + align-items: center; + background: var(--theme-background); + border: 1px solid var(--theme-accent-1-transparent); + border-radius: 0.625rem; + box-shadow: 0 0.0625rem 0.25rem 0 var(--theme-shadow); + display: flex; + height: 2.375rem; + justify-content: center; + transition: all 0.3s; + width: 2.75rem; + + &:hover { + transform: scale(1.1); + } + + svg { + height: 1.625rem; + width: 1.625rem; + } + } + } +} \ No newline at end of file diff --git a/src/components/profile/UserProfilePage/style.module.scss.d.ts b/src/components/profile/UserProfilePage/style.module.scss.d.ts new file mode 100644 index 00000000..b216de42 --- /dev/null +++ b/src/components/profile/UserProfilePage/style.module.scss.d.ts @@ -0,0 +1,30 @@ +export type Styles = { + aboutMeSection: string; + aboutSection: string; + banner: string; + bioSection: string; + card: string; + cardName: string; + cardRank: string; + cardWrapper: string; + editWrapper: string; + handle: string; + icon: string; + inner: string; + points: string; + profileCard: string; + profilePage: string; + profilePic: string; + progressBar: string; + progressInfo: string; + progressSection: string; + rank: string; + section: string; + socialIcons: string; +}; + +export type ClassNames = keyof Styles; + +declare const styles: Styles; + +export default styles; diff --git a/src/components/profile/index.ts b/src/components/profile/index.ts index 2304b886..af7d3385 100644 --- a/src/components/profile/index.ts +++ b/src/components/profile/index.ts @@ -2,3 +2,5 @@ export { EditBlock, EditField, SingleField } from './EditField'; export { default as Preview } from './Preview'; export { default as SocialMediaIcon } from './SocialMediaIcon'; export { default as Switch } from './Switch'; +export { UserHandleNotFound, type UserHandleNotFoundProps } from './UserHandleNotFound'; +export { UserProfilePage, type UserProfilePageProps } from './UserProfilePage'; diff --git a/src/lib/api/EventAPI.ts b/src/lib/api/EventAPI.ts index a32531ca..ad92f813 100644 --- a/src/lib/api/EventAPI.ts +++ b/src/lib/api/EventAPI.ts @@ -5,12 +5,10 @@ import { AttendEventResponse, CreateEventResponse, GetAllEventsResponse, - GetAttendancesForUserResponse, GetFutureEventsResponse, GetOneEventResponse, GetPastEventsResponse, PatchEventResponse, - PublicAttendance, PublicEvent, } from '@/lib/types/apiResponses'; import axios from 'axios'; @@ -72,23 +70,11 @@ export const getAllEvents = async (): Promise => { return response.data.events; }; -export const getAttendancesForUser = async (token: string): Promise => { - const requestUrl = `${config.api.baseUrl}${config.api.endpoints.attendance}`; - - const response = await axios.get(requestUrl, { - headers: { - Authorization: `Bearer ${token}`, - }, - }); - - return response.data.attendances; -}; - export const attendEvent = async ( token: string, attendanceCode: string ): Promise => { - const requestUrl = `${config.api.baseUrl}${config.api.endpoints.attendance}`; + const requestUrl = `${config.api.baseUrl}${config.api.endpoints.attendance.attendance}`; const requestBody = { attendanceCode, asStaff: false } as AttendEventRequest; diff --git a/src/lib/api/UserAPI.ts b/src/lib/api/UserAPI.ts index 16df638f..48e00f6e 100644 --- a/src/lib/api/UserAPI.ts +++ b/src/lib/api/UserAPI.ts @@ -9,11 +9,13 @@ import { UserPatches, } from '@/lib/types/apiRequests'; import type { + GetAttendancesForUserResponse, GetCurrentUserResponse, GetUserResponse, InsertSocialMediaResponse, PatchUserResponse, PrivateProfile, + PublicAttendance, PublicProfile, PublicUserSocialMedia, UpdateProfilePictureResponse, @@ -165,3 +167,45 @@ export const deleteSocialMedia = async (token: string, uuid: UUID): Promise => { + const requestUrl = `${config.api.baseUrl}${config.api.endpoints.attendance.attendance}`; + + const response = await axios.get(requestUrl, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + return response.data.attendances; +}; + +export const getAttendancesForUserByUUID = async ( + token: string, + uuid: UUID +): Promise => { + const requestUrl = `${config.api.baseUrl}${config.api.endpoints.attendance.forUserByUUID}/${uuid}`; + + const response = await axios.get(requestUrl, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + return response.data.attendances; +}; + +export const getPublicUserProfileByUUID = async ( + token: string, + uuid: UUID +): Promise => { + const requestUrl = `${config.api.baseUrl}${config.api.endpoints.user.user}/${uuid}`; + + const response = await axios.get(requestUrl, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + return response.data.user; +}; diff --git a/src/lib/config.ts b/src/lib/config.ts index b0477cb1..f732bcac 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -34,7 +34,10 @@ const config = { future: '/event/future', picture: '/event/picture', }, - attendance: '/attendance', + attendance: { + attendance: '/attendance', + forUserByUUID: '/attendance/user', + }, leaderboard: '/leaderboard', store: { collection: '/merch/collection', diff --git a/src/lib/constants/majors.json b/src/lib/constants/majors.json deleted file mode 100644 index 0b8e01ee..00000000 --- a/src/lib/constants/majors.json +++ /dev/null @@ -1,163 +0,0 @@ -{ - "majors": [ - "Aerospace Engineering", - "Anth (Conc Clim Chng&Humn Sol)", - "Anth (Conc Sociocultural Anth)", - "Anthropology(Conc in Archaeol)", - "Anthropology(Conc in Bio Anth)", - "Biochemistry", - "Biochemistry and Cell Biology", - "Bioengineering", - "Bioengineering (Biotechnology)", - "Bioengineering: Bioinformatics", - "Bioengineering: BioSystems", - "Biological Anthropology", - "Biology w/Spec Bioinformatics", - "BS PblHlth w/Con Biostatistics", - "BS PblHlth w/Con Clmt&EnvrnSci", - "BS PblHlth w/Con CmntyHlth Sci", - "BS PblHlth w/Con Epidemiology", - "BS PblHlth w/Con HlthPol&Mgmt", - "BS PblHlth w/Con Medicine Sci", - "Business Psychology", - "Chemical Engineering", - "Chemistry", - "Chinese Studies", - "Classical Studies", - "Cogn & Behavioral Neuroscience", - "Cognitive Science", - "Cogn Sci/Spec MachLrn & NuerCp", - "Cogn Sci w/Spec Clin Asp Cogn", - "Cogn Sci w/Spec Design & Inter", - "Cogn Sci w/Specializ Neurosci", - "Cogn Sci w/Spec Lang & Culture", - "Communication", - "Comp Sci w/Spec Bioinformatics", - "Computer Engineering", - "Computer Science", - "Critical Gender Studies", - "Dance", - "Data Science", - "Earth Sciences", - "Ecology, Behavior & Evolution", - "Economics", - "Education Sciences", - "Electrical Engin & Society", - "Electrical Engineering", - "Engineering Physics", - "Environmental Chemistry", - "Environmental Engineering", - "Environ Sys (Earth Sciences)", - "Environ Sys (Ecol,Behav&Evol)", - "Environ Sys(Environ Chemistry)", - "Environ Sys (Environ Policy)", - "Ethnic Studies", - "General Biology", - "General Physics", - "General Physics/Secondary Educ", - "German Studies", - "Global Health", - "Global South Studies", - "History", - "Human Biology", - "Human Developmental Sciences", - "HumDevSci w/Spec Eqty& Divrsty", - "HumDevSci w/Spec Healthy Aging", - "Interdisc Computing & the Arts", - "International Studies", - "International Studies-Anthro", - "International Studies-Econ", - "International Studies-History", - "International Studies-Intl Bus", - "International Studies-Linguist", - "International Studies-Lit", - "International Studies-Phil", - "International Studies-Poli Sci", - "International Studies-Sociol", - "Italian Studies", - "Japanese Studies", - "Jewish Studies", - "Joint Major Mathematics & Econ", - "Language Studies", - "Latin American Studies", - "Latin Am Stu (Con in Mexico)", - "Latin Am Stu (Con Mig&Brdr St)", - "Linguistics", - "Linguistics(Spec Cogn & Lang)", - "Linguistics(Spec Lang&Society)", - "Linguistics(Spec Spch&LangSci)", - "Literature/Writing", - "Literatures in English", - "Management Science", - "Marine Biology", - "Mathematics", - "Mathematics (Applied)", - "Mathematics-Computer Science", - "Mathematics-Scientif Computatn", - "Mathematics/Applied Science", - "Mathematics/Secondary Educ", - "Mechanical Engineering", - "MechEngW/Spec Cntrl & Robotics", - "MechEngW/SpecFluidMech&ThrmlSy", - "MechEngW/Spec Material Sci&Eng", - "MechEngW/SpecMechanics of Mat", - "MechEngW/SpecRnEnergy&EnvFlows", - "Microbiology", - "Molecular and Cell Biology", - "Molecular Biology", - "Molecular Synthesis", - "Music", - "Music Humanities", - "NanoEngineering", - "Neurobiology", - "Oceanic & Atmospheric Sciences", - "Pharmacological Chemistry", - "Philosophy", - "Physics", - "Physics-Biophysics", - "Physics w/Specializ Astrophys", - "Physics w/Specializ Earth Scis", - "Physics w/Specializ Mtrls Phys", - "Physiology & Neuroscience", - "Phys w/Spec Computational Phys", - "Political Science", - "Political Sci/Amer Politics", - "Political Sci/Compar Politics", - "Political Sci/Data Analytics", - "Political Sci/Intntl Relations", - "Political Sci/Political Theory", - "Political Sci/Public Law", - "Political Sci/Public Policy", - "Political Sci/Race Eth and Pol", - "Probability & Statistics", - "Psychology", - "Psychology/Clinical Psychology", - "Psychology/Cognitive Psych", - "Psychology/Developmental Psych", - "Psychology/Human Health", - "Psychology/Sensation&Perceptn", - "Psychology/Social Psychology", - "Public Health", - "Real Estate and Development", - "Russian EastEuro & Eurasian St", - "Sociology", - "Sociology-American Studies", - "Sociology-Culture/Communic", - "Sociology-Economy and Society", - "Sociology-International Stu", - "Sociology-Law and Society", - "Sociology-Science and Medicine", - "Sociology-Social Inequality", - "Spanish Literature", - "Speculative Design", - "Structural Engineering", - "Study of Religion", - "Theatre", - "Undeclared", - "Urban Studies and Planning", - "Visual Arts(Art Hist/Criticsm)", - "Visual Arts (Media)", - "Visual Arts (Studio)", - "World Literature and Culture" - ] -} diff --git a/src/lib/constants/majors.ts b/src/lib/constants/majors.ts new file mode 100644 index 00000000..6d85c8a4 --- /dev/null +++ b/src/lib/constants/majors.ts @@ -0,0 +1,163 @@ +const majors = [ + 'Aerospace Engineering', + 'Anth (Conc Clim Chng&Humn Sol)', + 'Anth (Conc Sociocultural Anth)', + 'Anthropology(Conc in Archaeol)', + 'Anthropology(Conc in Bio Anth)', + 'Biochemistry', + 'Biochemistry and Cell Biology', + 'Bioengineering', + 'Bioengineering (Biotechnology)', + 'Bioengineering: Bioinformatics', + 'Bioengineering: BioSystems', + 'Biological Anthropology', + 'Biology w/Spec Bioinformatics', + 'BS PblHlth w/Con Biostatistics', + 'BS PblHlth w/Con Clmt&EnvrnSci', + 'BS PblHlth w/Con CmntyHlth Sci', + 'BS PblHlth w/Con Epidemiology', + 'BS PblHlth w/Con HlthPol&Mgmt', + 'BS PblHlth w/Con Medicine Sci', + 'Business Psychology', + 'Chemical Engineering', + 'Chemistry', + 'Chinese Studies', + 'Classical Studies', + 'Cogn & Behavioral Neuroscience', + 'Cognitive Science', + 'Cogn Sci/Spec MachLrn & NuerCp', + 'Cogn Sci w/Spec Clin Asp Cogn', + 'Cogn Sci w/Spec Design & Inter', + 'Cogn Sci w/Specializ Neurosci', + 'Cogn Sci w/Spec Lang & Culture', + 'Communication', + 'Comp Sci w/Spec Bioinformatics', + 'Computer Engineering', + 'Computer Science', + 'Critical Gender Studies', + 'Dance', + 'Data Science', + 'Earth Sciences', + 'Ecology, Behavior & Evolution', + 'Economics', + 'Education Sciences', + 'Electrical Engin & Society', + 'Electrical Engineering', + 'Engineering Physics', + 'Environmental Chemistry', + 'Environmental Engineering', + 'Environ Sys (Earth Sciences)', + 'Environ Sys (Ecol,Behav&Evol)', + 'Environ Sys(Environ Chemistry)', + 'Environ Sys (Environ Policy)', + 'Ethnic Studies', + 'General Biology', + 'General Physics', + 'General Physics/Secondary Educ', + 'German Studies', + 'Global Health', + 'Global South Studies', + 'History', + 'Human Biology', + 'Human Developmental Sciences', + 'HumDevSci w/Spec Eqty& Divrsty', + 'HumDevSci w/Spec Healthy Aging', + 'Interdisc Computing & the Arts', + 'International Studies', + 'International Studies-Anthro', + 'International Studies-Econ', + 'International Studies-History', + 'International Studies-Intl Bus', + 'International Studies-Linguist', + 'International Studies-Lit', + 'International Studies-Phil', + 'International Studies-Poli Sci', + 'International Studies-Sociol', + 'Italian Studies', + 'Japanese Studies', + 'Jewish Studies', + 'Joint Major Mathematics & Econ', + 'Language Studies', + 'Latin American Studies', + 'Latin Am Stu (Con in Mexico)', + 'Latin Am Stu (Con Mig&Brdr St)', + 'Linguistics', + 'Linguistics(Spec Cogn & Lang)', + 'Linguistics(Spec Lang&Society)', + 'Linguistics(Spec Spch&LangSci)', + 'Literature/Writing', + 'Literatures in English', + 'Management Science', + 'Marine Biology', + 'Mathematics', + 'Mathematics (Applied)', + 'Mathematics-Computer Science', + 'Mathematics-Scientif Computatn', + 'Mathematics/Applied Science', + 'Mathematics/Secondary Educ', + 'Mechanical Engineering', + 'MechEngW/Spec Cntrl & Robotics', + 'MechEngW/SpecFluidMech&ThrmlSy', + 'MechEngW/Spec Material Sci&Eng', + 'MechEngW/SpecMechanics of Mat', + 'MechEngW/SpecRnEnergy&EnvFlows', + 'Microbiology', + 'Molecular and Cell Biology', + 'Molecular Biology', + 'Molecular Synthesis', + 'Music', + 'Music Humanities', + 'NanoEngineering', + 'Neurobiology', + 'Oceanic & Atmospheric Sciences', + 'Pharmacological Chemistry', + 'Philosophy', + 'Physics', + 'Physics-Biophysics', + 'Physics w/Specializ Astrophys', + 'Physics w/Specializ Earth Scis', + 'Physics w/Specializ Mtrls Phys', + 'Physiology & Neuroscience', + 'Phys w/Spec Computational Phys', + 'Political Science', + 'Political Sci/Amer Politics', + 'Political Sci/Compar Politics', + 'Political Sci/Data Analytics', + 'Political Sci/Intntl Relations', + 'Political Sci/Political Theory', + 'Political Sci/Public Law', + 'Political Sci/Public Policy', + 'Political Sci/Race Eth and Pol', + 'Probability & Statistics', + 'Psychology', + 'Psychology/Clinical Psychology', + 'Psychology/Cognitive Psych', + 'Psychology/Developmental Psych', + 'Psychology/Human Health', + 'Psychology/Sensation&Perceptn', + 'Psychology/Social Psychology', + 'Public Health', + 'Real Estate and Development', + 'Russian EastEuro & Eurasian St', + 'Sociology', + 'Sociology-American Studies', + 'Sociology-Culture/Communic', + 'Sociology-Economy and Society', + 'Sociology-International Stu', + 'Sociology-Law and Society', + 'Sociology-Science and Medicine', + 'Sociology-Social Inequality', + 'Spanish Literature', + 'Speculative Design', + 'Structural Engineering', + 'Study of Religion', + 'Theatre', + 'Undeclared', + 'Urban Studies and Planning', + 'Visual Arts(Art Hist/Criticsm)', + 'Visual Arts (Media)', + 'Visual Arts (Studio)', + 'World Literature and Culture', +]; + +export default majors; diff --git a/src/lib/constants/ranks.ts b/src/lib/constants/ranks.ts new file mode 100644 index 00000000..f2afc976 --- /dev/null +++ b/src/lib/constants/ranks.ts @@ -0,0 +1,19 @@ +const ranks = [ + 'Factorial Flatbread', + 'Exponential Eclair', + 'Polynomial Pita', + 'Cubic Croissant', + 'Quadratic Qornbread', + 'Linear Loaf', + 'nlog Naan', + 'Constant Cornbread', + 'Binary Baguette', + 'Blessed Boba', + 'Super Snu', + 'Soon(TM)', + 'Later(TM)', + 'Sometime(TM)', + 'We Ran Out Of Ranks', +]; + +export default ranks; diff --git a/src/lib/constants/socialMediaTypes.ts b/src/lib/constants/socialMediaTypes.ts index bc54883b..c2141cc2 100644 --- a/src/lib/constants/socialMediaTypes.ts +++ b/src/lib/constants/socialMediaTypes.ts @@ -1,12 +1,16 @@ import { SocialMediaType } from '@/lib/types/enums'; import DevpostIcon from '@/public/assets/icons/devpost-icon.svg'; -import { IconType } from 'react-icons'; -import { AiOutlineLink } from 'react-icons/ai'; -import { BsFacebook, BsGithub, BsInstagram, BsLinkedin, BsTwitter } from 'react-icons/bs'; -import { IoMail } from 'react-icons/io5'; +import EmailIcon from '@/public/assets/icons/email-solid.svg'; +import FacebookIcon from '@/public/assets/icons/facebook-icon.svg'; +import GithubIcon from '@/public/assets/icons/github-icon.svg'; +import InstagramIcon from '@/public/assets/icons/instagram.svg'; +import LinkIcon from '@/public/assets/icons/link-icon.svg'; +import LinkedInIcon from '@/public/assets/icons/linkedin-icon.svg'; +import TwitterIcon from '@/public/assets/icons/twitter.svg'; +import { ComponentType, SVGProps } from 'react'; interface SocialMediaInfo { - icon: IconType; + icon: ComponentType>; label: string; domain?: string; example: string; @@ -20,41 +24,41 @@ const socialMediaTypes: Record = { example: 'devpost.com/', }, [SocialMediaType.EMAIL]: { - icon: IoMail, + icon: EmailIcon, label: 'Email', example: 'you@example.com', }, [SocialMediaType.FACEBOOK]: { - icon: BsFacebook, + icon: FacebookIcon, label: 'Facebook', domain: 'facebook.com', example: 'facebook.com/', }, [SocialMediaType.GITHUB]: { - icon: BsGithub, + icon: GithubIcon, label: 'GitHub', domain: 'github.com', example: 'github.com/', }, [SocialMediaType.INSTAGRAM]: { - icon: BsInstagram, + icon: InstagramIcon, label: 'Instagram', domain: 'instagram.com', example: 'instagram.com/', }, [SocialMediaType.LINKEDIN]: { - icon: BsLinkedin, + icon: LinkedInIcon, label: 'LinkedIn', domain: 'linkedin.com/in', example: 'linkedin.com/in/', }, [SocialMediaType.PORTFOLIO]: { - icon: AiOutlineLink, + icon: LinkIcon, label: 'Portfolio', example: 'example.com', }, [SocialMediaType.TWITTER]: { - icon: BsTwitter, + icon: TwitterIcon, label: 'Twitter', domain: 'twitter.com', example: 'twitter.com/', diff --git a/src/lib/types/apiResponses.d.ts b/src/lib/types/apiResponses.d.ts index ba550fca..3834cbfc 100644 --- a/src/lib/types/apiResponses.d.ts +++ b/src/lib/types/apiResponses.d.ts @@ -332,7 +332,7 @@ export interface PublicProfile { major: string; bio: string | null; points: number; - userSocialMedia?: PublicUserSocialMedia[]; + userSocialMedia: PublicUserSocialMedia[]; isAttendancePublic: boolean; } diff --git a/src/lib/utils.ts b/src/lib/utils.ts index f58e90e6..4d0dd14d 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,4 +1,5 @@ import defaultProfilePictures from '@/lib/constants/profilePictures'; +import ranks from '@/lib/constants/ranks'; import type { URL } from '@/lib/types'; import type { CustomErrorBody, @@ -7,6 +8,11 @@ import type { ValidatorError, } from '@/lib/types/apiResponses'; import NoImage from '@/public/assets/graphics/cat404.png'; +import { + type StaticImageData, + type StaticImport, + type StaticRequire, +} from 'next/dist/shared/lib/get-img-props'; import { useEffect, useState } from 'react'; /** @@ -72,19 +78,24 @@ export const formatSearch = (text: string): string => { /** * Helper function to map each user to a numeric value deterministically - * TODO: Use the user's UUID to hash to a number since it will never change * @param user - * @returns + * @returns A 32-bit integer + * @see https://stackoverflow.com/questions/6122571/simple-non-secure-hash-function-for-javascript */ -const hashUser = (user: PublicProfile) => { - return user.points; +const hashUser = ({ uuid }: PublicProfile) => { + return uuid.split('').reduce((hash, char) => { + /* eslint-disable no-bitwise */ + const hash1 = (hash << 5) - hash + char.charCodeAt(0); + return hash1 | 0; // Convert to 32bit integer + /* eslint-enable no-bitwise */ + }, 0); }; export const getProfilePicture = (user: PublicProfile): URL => { if (user.profilePicture) return user.profilePicture; const NUM_IMAGES = defaultProfilePictures.length; - const index = hashUser(user) % NUM_IMAGES; + const index = ((hashUser(user) % NUM_IMAGES) + NUM_IMAGES) % NUM_IMAGES; const path = defaultProfilePictures[index]?.src ?? ''; return path; @@ -99,20 +110,28 @@ export const getLevel = (points: number): number => { return Math.floor(points / 100) + 1; }; -// TODO: Define all ranks and logic for this -export const getUserRank = (user: PublicProfile): string => { - const ranks = ['Polynomial Pita', 'Factorial Flatbread']; - const index = user.points % 2; - - return ranks[index] ?? ''; +/** + * Get a user's rank based on how many points they have + * @param points + * @returns rank name + */ +export const getUserRank = (points: number): string => { + const index = Math.min(ranks.length, getLevel(points)) - 1; + return ranks[index] as string; }; /** - * Checks whether an image source is a gif + * Checks whether a next/image Image source is a gif * @param src - source of the image * @returns whether or not the source is a gif */ -export const isSrcAGif = (src: string | null): boolean => src !== null && /\.gif($|&)/.test(src); +export const isSrcAGif = (src: string | StaticImport): boolean => { + const srcString = + (typeof src === 'string' && src) || + (src as StaticRequire).default?.src || + (src as StaticImageData).src; + return /\.gif($|&)/.test(srcString); +}; /** * A React hook for calling `URL.createObjectURL` on the given file. Avoids @@ -254,3 +273,10 @@ export const getDefaultMerchItemPhoto = (item: PublicMerchItem | undefined): str } return NoImage.src; }; + +/** + * Prepend 'http://' to a url if a protocol isn't specified + * @param url url to be fixed + * @returns url begnning with http:// + */ +export const fixUrl = (url: string) => (url.includes('://') ? url : `http://${url}`); diff --git a/src/pages/about.tsx b/src/pages/about.tsx index 8bb46390..d96f6f44 100644 --- a/src/pages/about.tsx +++ b/src/pages/about.tsx @@ -34,19 +34,19 @@ const AboutPage = () => { diff --git a/src/pages/events.tsx b/src/pages/events.tsx index c6cc0f84..cf2853be 100644 --- a/src/pages/events.tsx +++ b/src/pages/events.tsx @@ -1,6 +1,6 @@ import { Dropdown, PaginationControls, Typography } from '@/components/common'; import { EventDisplay } from '@/components/events'; -import { EventAPI } from '@/lib/api'; +import { EventAPI, UserAPI } from '@/lib/api'; import withAccessType from '@/lib/hoc/withAccessType'; import { CookieService, PermissionService } from '@/lib/services'; import type { PublicAttendance, PublicEvent } from '@/lib/types/apiResponses'; @@ -172,8 +172,10 @@ export default EventsPage; const getServerSidePropsFunc: GetServerSideProps = async ({ req, res }) => { const authToken = CookieService.getServerCookie(CookieType.ACCESS_TOKEN, { req, res }); - const events = await EventAPI.getAllEvents(); - const attendances = await EventAPI.getAttendancesForUser(authToken); + const [events, attendances] = await Promise.all([ + EventAPI.getAllEvents(), + UserAPI.getAttendancesForCurrentUser(authToken), + ]); return { props: { events, attendances } }; }; diff --git a/src/pages/index.tsx b/src/pages/index.tsx index 568b7d36..65f6f9df 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -126,7 +126,7 @@ const getServerSidePropsFunc: GetServerSideProps = async ({ req, res, query }) = // After that, fetch the other API calls. const eventsPromise = EventAPI.getAllEvents(); - const attendancesPromise = EventAPI.getAttendancesForUser(authToken); + const attendancesPromise = UserAPI.getAttendancesForCurrentUser(authToken); const userPromise = UserAPI.getCurrentUser(authToken); const [events, attendances, user] = await Promise.all([ diff --git a/src/pages/leaderboard.tsx b/src/pages/leaderboard.tsx index 592b6bb1..4b6c4e68 100644 --- a/src/pages/leaderboard.tsx +++ b/src/pages/leaderboard.tsx @@ -123,7 +123,7 @@ const LeaderboardPage = ({ sort, leaderboard, user: { uuid } }: LeaderboardProps { - return ( - <> -

Portal Profile Page

- - Manage Account - - - ); -}; +const UserProfilePage: NextPage = () => null; export default UserProfilePage; -const getServerSidePropsFunc: GetServerSideProps = async () => ({ - props: {}, -}); +const getServerSidePropsFunc: GetServerSideProps = async ({ req, res }) => { + const token = CookieService.getServerCookie(CookieType.ACCESS_TOKEN, { req, res }); + const user = await UserAPI.getCurrentUser(token); + + return { + redirect: { + destination: `${config.userProfileRoute}${user.handle}`, + permanent: false, + }, + }; +}; export const getServerSideProps = withAccessType( getServerSidePropsFunc, diff --git a/src/pages/profile/edit.tsx b/src/pages/profile/edit.tsx index a35932ae..37f40d92 100644 --- a/src/pages/profile/edit.tsx +++ b/src/pages/profile/edit.tsx @@ -1,4 +1,4 @@ -import { Cropper } from '@/components/common'; +import { Cropper, GifSafeImage } from '@/components/common'; import { EditBlock, EditField, @@ -9,19 +9,18 @@ import { } from '@/components/profile'; import { config, showToast } from '@/lib'; import { AuthAPI, ResumeAPI, UserAPI } from '@/lib/api'; -import majors from '@/lib/constants/majors.json'; +import majors from '@/lib/constants/majors'; import socialMediaTypes from '@/lib/constants/socialMediaTypes'; import withAccessType from '@/lib/hoc/withAccessType'; import { CookieService, PermissionService } from '@/lib/services'; import { PrivateProfile } from '@/lib/types/apiResponses'; import { CookieType, SocialMediaType } from '@/lib/types/enums'; -import { capitalize, getMessagesFromError, getProfilePicture, isSrcAGif } from '@/lib/utils'; +import { capitalize, getMessagesFromError, getProfilePicture } from '@/lib/utils'; import DownloadIcon from '@/public/assets/icons/download-icon.svg'; import DropdownIcon from '@/public/assets/icons/dropdown-arrow-1.svg'; import styles from '@/styles/pages/profile/edit.module.scss'; import { AxiosError } from 'axios'; import type { GetServerSideProps } from 'next'; -import Image from 'next/image'; import Link from 'next/link'; import { FormEvent, useEffect, useId, useMemo, useState } from 'react'; @@ -36,7 +35,8 @@ function reportError(title: string, error: unknown) { } function fixUrl(input: string, prefix?: string): string { - if (!input || input.startsWith('https://')) { + // Return input as-is if it's blank or includes a protocol + if (!input || input.includes('://')) { return input; } // Encourage https:// @@ -278,7 +278,7 @@ const EditProfilePage = ({ user: initUser, authToken }: EditProfileProps) => { onDragLeave={() => setPfpDrop(false)} >
@@ -376,7 +375,7 @@ const EditProfilePage = ({ user: initUser, authToken }: EditProfileProps) => {
-
+

About Me

@@ -385,7 +384,7 @@ const EditProfilePage = ({ user: initUser, authToken }: EditProfileProps) => { { } name="major" - options={data.majors} + options={majors} element="select" placeholder="Major" error={errors.major} diff --git a/src/pages/u/[handle].tsx b/src/pages/u/[handle].tsx index 98b20c60..2e7d800f 100644 --- a/src/pages/u/[handle].tsx +++ b/src/pages/u/[handle].tsx @@ -1,30 +1,61 @@ +import { + UserHandleNotFound, + UserHandleNotFoundProps, + UserProfilePage, + UserProfilePageProps, +} from '@/components/profile'; import { config } from '@/lib'; import { UserAPI } from '@/lib/api'; import withAccessType from '@/lib/hoc/withAccessType'; import { CookieService, PermissionService } from '@/lib/services'; -import type { PublicProfile } from '@/lib/types/apiResponses'; +import { setServerCookie } from '@/lib/services/CookieService'; import { CookieType } from '@/lib/types/enums'; import type { GetServerSideProps } from 'next/types'; -interface UserProfilePageProps { - user: PublicProfile; -} +type UserHandlePageProps = UserHandleNotFoundProps | UserProfilePageProps; -const UserProfilePage = ({ user }: UserProfilePageProps) => { - return
{JSON.stringify(user, null, 2)}
; +const isUserHandleNotFound = (props: UserHandlePageProps): props is UserHandleNotFoundProps => + 'handle' in props; + +const UserHandlePage = (props: UserHandlePageProps) => { + if (isUserHandleNotFound(props)) { + const { handle } = props; + return ; + } + + return ; }; -export default UserProfilePage; +export default UserHandlePage; const getServerSidePropsFunc: GetServerSideProps = async ({ params, req, res }) => { const handle = params?.handle as string; const token = CookieService.getServerCookie(CookieType.ACCESS_TOKEN, { req, res }); try { - const user = await UserAPI.getUserByHandle(token, handle); + const [handleUser, user, signedInAttendances] = await Promise.all([ + UserAPI.getUserByHandle(token, handle).catch(() => null), + UserAPI.getCurrentUser(token), + UserAPI.getAttendancesForCurrentUser(token), + ]); + setServerCookie(CookieType.USER, JSON.stringify(user), { req, res }); + + // render UserHandleNotFoundPage when user with handle is not retrieved + if (handleUser === null) return { props: { handle } }; + + const isSignedInUser = handleUser.uuid === user.uuid; + + let attendances = null; + if (!isSignedInUser && handleUser.isAttendancePublic) + attendances = (await UserAPI.getAttendancesForUserByUUID(token, user.uuid)).slice(0, 10); + + // render UserProfilePage return { props: { - user, + handleUser, + isSignedInUser, + signedInAttendances, + attendances, }, }; } catch (err: any) { diff --git a/src/styles/pages/about.module.scss b/src/styles/pages/about.module.scss index 4f6d4e78..6cc06b17 100644 --- a/src/styles/pages/about.module.scss +++ b/src/styles/pages/about.module.scss @@ -45,8 +45,10 @@ gap: 0.5rem; margin-bottom: 0.7rem; - .theme { + .icon { fill: currentColor; + height: 24px; + width: 24px; } } } diff --git a/src/styles/pages/about.module.scss.d.ts b/src/styles/pages/about.module.scss.d.ts index b50acecd..e0297ef6 100644 --- a/src/styles/pages/about.module.scss.d.ts +++ b/src/styles/pages/about.module.scss.d.ts @@ -4,9 +4,9 @@ export type Styles = { description: string; discordPreview: string; header: string; + icon: string; logo: string; socials: string; - theme: string; }; export type ClassNames = keyof Styles; diff --git a/src/styles/pages/profile/edit.module.scss b/src/styles/pages/profile/edit.module.scss index 0f00b6f5..d1f2c4a3 100644 --- a/src/styles/pages/profile/edit.module.scss +++ b/src/styles/pages/profile/edit.module.scss @@ -33,9 +33,14 @@ .columnLeft { border-right: 0.5px solid vars.$light-primary-2; flex: none; + margin-bottom: -2rem; // extend column all the way to the bottom margin-right: 2rem; + max-height: calc(100vh - 4rem); // subtract height of navbar + overflow-y: auto; padding: 2rem; padding-left: 0; + position: sticky; + top: 4rem; width: 24rem; h2 {