From a97c0901c24454761e84a928ca2dad30eacc5284 Mon Sep 17 00:00:00 2001 From: Sean Date: Sat, 30 Mar 2024 17:40:52 -0800 Subject: [PATCH] Profile backgrounds (#204) * Create SVG banner * Draw pies * Add color and blur For some reason the blur looks glitchy * Animate the blobs fading in * Fix blur clipping * Fix profile page * adjust banner sizes and color order don't shuffle the communities so banners dont change when we add more in the future * Fix viewPage, remove shuffle * Remove banner if user hasn't attended any events Adjust spacing for other profile things --- .../events/EventCarousel/style.module.scss | 2 +- src/components/profile/Banner/index.tsx | 153 ++++++++++++++++++ .../profile/Banner/style.module.scss | 42 +++++ .../profile/Banner/style.module.scss.d.ts | 16 ++ .../profile/UserProfilePage/index.tsx | 46 ++++-- .../profile/UserProfilePage/style.module.scss | 47 +++--- .../UserProfilePage/style.module.scss.d.ts | 1 + src/lib/utils.ts | 27 ++++ 8 files changed, 295 insertions(+), 39 deletions(-) create mode 100644 src/components/profile/Banner/index.tsx create mode 100644 src/components/profile/Banner/style.module.scss create mode 100644 src/components/profile/Banner/style.module.scss.d.ts diff --git a/src/components/events/EventCarousel/style.module.scss b/src/components/events/EventCarousel/style.module.scss index b393ac30..4025e29a 100644 --- a/src/components/events/EventCarousel/style.module.scss +++ b/src/components/events/EventCarousel/style.module.scss @@ -54,7 +54,7 @@ flex-grow: 1; gap: 1rem; justify-content: center; - margin: 1rem 0; + margin-top: 1rem; overflow: hidden; padding: 2rem 1rem; text-align: center; diff --git a/src/components/profile/Banner/index.tsx b/src/components/profile/Banner/index.tsx new file mode 100644 index 00000000..3ee31bcc --- /dev/null +++ b/src/components/profile/Banner/index.tsx @@ -0,0 +1,153 @@ +import { UUID } from '@/lib/types'; +import { PublicAttendance } from '@/lib/types/apiResponses'; +import { Community } from '@/lib/types/enums'; +import { seededRandom, toCommunity } from '@/lib/utils'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import styles from './style.module.scss'; + +const communityColors: [Community, string][] = [ + [Community.DESIGN, styles.design], + [Community.CYBER, styles.cyber], + [Community.HACK, styles.hack], + [Community.AI, styles.ai], + [Community.GENERAL, styles.general], +]; + +type PieSlice = { + path: string; + className: string; +}; + +function computePie( + uuid: UUID, + recentAttendances: PublicAttendance[], + width: number, + height: number +): PieSlice[] { + if (width === 0 && height === 0) { + return []; + } + + const hex = uuid.replaceAll('-', ''); + const random = seededRandom( + parseInt(hex.slice(0, 8), 16), + parseInt(hex.slice(8, 16), 16), + parseInt(hex.slice(16, 24), 16), + parseInt(hex.slice(24, 32), 16) + ); + + const communities: Record = { + [Community.HACK]: 0, + [Community.AI]: 0, + [Community.CYBER]: 0, + [Community.DESIGN]: 0, + [Community.GENERAL]: 0, + }; + let total = 0; + + recentAttendances.forEach(({ event: { committee } }) => { + const community = toCommunity(committee); + communities[community] += 1; + total += 1; + }); + + const radius = Math.hypot(width / 2, height / 2); + let angle = random() * Math.PI * 2; + + return ( + communityColors + .map(([community, className]) => { + const portion = communities[community] / total; + if (portion === 0) { + return { path: '', className, portion }; + } + if (portion === 1) { + // Draw a circle + return { + path: [ + `M ${width / 2 - radius} ${height / 2}`, + `A ${radius} ${radius} 0 0 0 ${width / 2 + radius} ${height / 2}`, + `A ${radius} ${radius} 0 0 0 ${width / 2 - radius} ${height / 2}`, + 'z', + ].join(''), + className, + portion, + }; + } + + const endAngle = angle + portion * Math.PI * 2; + + const path = [ + `M ${width / 2} ${height / 2}`, + `L ${width / 2 + Math.cos(angle) * radius} ${height / 2 + Math.sin(angle) * radius}`, + // A rx ry x-axis-rotation large-arc-flag sweep-flag x y + `A ${radius} ${radius} 0 ${portion > 0.5 ? 1 : 0} 1 ${ + width / 2 + Math.cos(endAngle) * radius + } ${height / 2 + Math.sin(endAngle) * radius}`, + 'z', + ].join(''); + + angle = endAngle; + return { path, className, portion }; + }) + // Make the biggest blobs fade in first + .sort((a, b) => b.portion - a.portion) + ); +} + +interface BannerProps { + uuid: UUID; + recentAttendances: PublicAttendance[]; +} + +const Banner = ({ uuid, recentAttendances }: BannerProps) => { + const [width, setWidth] = useState(0); + const [height, setHeight] = useState(0); + + const ref = useRef(null); + + useEffect(() => { + const observer = new ResizeObserver(entries => { + const size = entries[0]?.borderBoxSize[0]; + if (size) { + setWidth(size.inlineSize); + setHeight(size.blockSize); + } + }); + if (ref.current) { + observer.observe(ref.current); + } + return () => { + observer.disconnect(); + }; + }); + + const slices = useMemo( + () => computePie(uuid, recentAttendances, width, height), + [uuid, recentAttendances, width, height] + ); + const blurRadius = useMemo(() => Math.hypot(width / 2, height / 2) / 4, [width, height]); + + return ( + + {/* Avoid clipping off the blur: https://stackoverflow.com/a/6556655 */} + + + + + {slices.map(({ path, className }, i) => + path ? ( + + ) : null + )} + + ); +}; + +export default Banner; diff --git a/src/components/profile/Banner/style.module.scss b/src/components/profile/Banner/style.module.scss new file mode 100644 index 00000000..874323a3 --- /dev/null +++ b/src/components/profile/Banner/style.module.scss @@ -0,0 +1,42 @@ +@use 'src/styles/vars.scss' as vars; + +.banner { + height: 100%; + inset: 0; + position: absolute; + width: 100%; + + .path { + animation: fade-in 2s backwards; + + @keyframes fade-in { + from { + opacity: 0; + } + + to { + opacity: 1; + } + } + + &.hack { + fill: vars.$orange-5; + } + + &.ai { + fill: vars.$scarlet-4; + } + + &.cyber { + fill: vars.$cyan-5; + } + + &.design { + fill: vars.$pink-4; + } + + &.general { + fill: vars.$blue-5; + } + } +} diff --git a/src/components/profile/Banner/style.module.scss.d.ts b/src/components/profile/Banner/style.module.scss.d.ts new file mode 100644 index 00000000..881de3da --- /dev/null +++ b/src/components/profile/Banner/style.module.scss.d.ts @@ -0,0 +1,16 @@ +export type Styles = { + ai: string; + banner: string; + cyber: string; + design: string; + fadeIn: string; + general: string; + hack: string; + path: string; +}; + +export type ClassNames = keyof Styles; + +declare const styles: Styles; + +export default styles; diff --git a/src/components/profile/UserProfilePage/index.tsx b/src/components/profile/UserProfilePage/index.tsx index 1a7e086b..57fb08ed 100644 --- a/src/components/profile/UserProfilePage/index.tsx +++ b/src/components/profile/UserProfilePage/index.tsx @@ -1,5 +1,6 @@ import { EditButton, GifSafeImage, Typography } from '@/components/common'; import { EventCarousel } from '@/components/events'; +import Banner from '@/components/profile/Banner'; import SocialMediaIcon from '@/components/profile/SocialMediaIcon'; import { UserProgress } from '@/components/profile/UserProgress'; import { config, showToast } from '@/lib'; @@ -28,8 +29,19 @@ export const UserProfilePage = ({ return (
-
-
+
0 ? styles.hasBanner : ''}`} + > + {recentAttendances.length > 0 ? ( +
+ {/* Restart the animation when the UUID changes (e.g. navigating from user profile -> my profile) */} + +
+ ) : null}
{handleUser.major}
-
- {handleUser.userSocialMedia?.map(social => ( - - - - ))} -
+ {handleUser.userSocialMedia && handleUser.userSocialMedia.length > 0 ? ( +
+ {handleUser.userSocialMedia.map(social => ( + + + + ))} +
+ ) : null}
diff --git a/src/components/profile/UserProfilePage/style.module.scss b/src/components/profile/UserProfilePage/style.module.scss index 55a5a57c..336538f5 100644 --- a/src/components/profile/UserProfilePage/style.module.scss +++ b/src/components/profile/UserProfilePage/style.module.scss @@ -7,37 +7,41 @@ margin: auto; max-width: vars.$breakpoint-md; width: 100%; + @media screen and (max-width: vars.$breakpoint-md) { + position: relative; + z-index: 0; + } .cardWrapper { - padding-top: 6.5rem; - position: relative; + background-color: var(--theme-elevated-background); + border: 1px solid var(--theme-elevated-stroke); + border-radius: 0.75rem; + overflow: hidden; - @media screen and (max-width: vars.$breakpoint-md) { - padding-top: 1.5rem; + &.hasBanner { + @media screen and (max-width: vars.$breakpoint-md) { + margin-top: 8rem; + overflow: unset; + position: relative; + } } .banner { - background-color: vars.$pink-2; - border: 1px solid var(--theme-elevated-stroke); - border-bottom: 0; - border-radius: 0.75rem; - height: 100%; - position: absolute; - top: 0; - width: 100%; + background-color: var(--theme-background); + height: 10rem; + position: relative; @media screen and (max-width: vars.$breakpoint-md) { - border: 0; - border-radius: 0; - margin: -2rem; - width: 100vw; + background-color: var(--theme-elevated-background); + height: auto; + inset: 2rem -2rem; + position: absolute; + top: -10rem; + z-index: -1; } } .profileCard { - background-color: var(--theme-elevated-background); - border: 1px solid var(--theme-elevated-stroke); - border-radius: 0 0 0.75rem 0.75rem; border-top: 0; display: flex; gap: 1rem; @@ -46,8 +50,6 @@ @media (max-width: vars.$breakpoint-md) { align-items: center; - border-radius: 0.75rem; - border-top: 1px solid var(--theme-elevated-stroke); flex-direction: column; } @@ -137,7 +139,7 @@ display: grid; gap: 0.56rem; grid-template-columns: auto 1fr; - margin: 0.81rem 0; + margin-top: 0.81rem; .icon { height: 1.313rem; @@ -149,6 +151,7 @@ display: flex; flex-wrap: wrap; gap: 0.25rem; + margin-top: 0.81rem; svg { height: 2rem; diff --git a/src/components/profile/UserProfilePage/style.module.scss.d.ts b/src/components/profile/UserProfilePage/style.module.scss.d.ts index 8fb4c23b..a8d7da37 100644 --- a/src/components/profile/UserProfilePage/style.module.scss.d.ts +++ b/src/components/profile/UserProfilePage/style.module.scss.d.ts @@ -7,6 +7,7 @@ export type Styles = { cardWrapper: string; editWrapper: string; handle: string; + hasBanner: string; icon: string; points: string; profileCard: string; diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 55763694..4c9d199c 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -423,3 +423,30 @@ export const getDefaultEventCover = (src: unknown): string => { return '/assets/graphics/store/hero-photo.jpg'; return src; }; + +/** + * xoshiro128** PRNG algorithm, which apparently powers `Math.random`. Takes a + * 128-bit seed. + * @param a 32-bit seed 1 + * @param b 32-bit seed 2 + * @param c 32-bit seed 3 + * @param d 32-bit seed 4 + * @returns A non-pure function that produces random numbers in the range [0, + * 1). + */ +export function seededRandom(a: number, b: number, c: number, d: number): () => number { + return () => { + /* eslint-disable no-bitwise, no-param-reassign */ + const t = b << 9; + let r = b * 5; + r = ((r << 7) | (r >>> 25)) * 9; + c ^= a; + d ^= b; + b ^= c; + a ^= d; + c ^= t; + d = (d << 11) | (d >>> 21); + return (r >>> 0) / 4294967296; + /* eslint-enable no-bitwise, no-param-reassign */ + }; +}