diff --git a/app/(site)/team/page.tsx b/app/(site)/team/page.tsx index d80c5a2..9fa76a0 100644 --- a/app/(site)/team/page.tsx +++ b/app/(site)/team/page.tsx @@ -1,23 +1,29 @@ -import { client } from "@/sanity/lib/client"; -import { teamPageQuery, teamMembersQuery } from "@/sanity/lib/queries"; -import { TeamMember, TeamPageData } from "@/lib/sanity/types"; -import TeamPageClient from "@/components/TeamPageClient"; +import { Metadata } from 'next' +import { client } from '@/sanity/lib/client' +import { teamPageQuery, teamMembersQuery } from '@/sanity/lib/queries' +import { TeamMember, TeamPageData } from '@/lib/sanity/types' +import TeamPageClient from '@/components/TeamPageClient' + +export const metadata: Metadata = { + title: 'Team | Monash Association of Coding', + description: 'Meet the team behind Monash Association of Coding', +} async function getTeamPageData(): Promise { try { - return await client.fetch(teamPageQuery); + return await client.fetch(teamPageQuery) } catch (error) { - console.error("Error fetching team page data:", error); - return null; + console.error('Failed to fetch team page data:', error) + return null } } -async function getTeamMembers(): Promise { +async function getTeamMembers(): Promise { try { - return await client.fetch(teamMembersQuery); + return (await client.fetch(teamMembersQuery)) || [] } catch (error) { - console.error("Error fetching team members:", error); - return null; + console.error('Failed to fetch team members:', error) + return [] } } @@ -25,7 +31,7 @@ export default async function TeamPage() { const [pageData, members] = await Promise.all([ getTeamPageData(), getTeamMembers(), - ]); + ]) - return ; + return } diff --git a/components/ChromaGrid.css b/components/ChromaGrid.css new file mode 100644 index 0000000..566d7ea --- /dev/null +++ b/components/ChromaGrid.css @@ -0,0 +1,174 @@ +.chroma-grid { + position: relative; + width: 100%; + height: 100%; + display: grid; + grid-template-columns: repeat(var(--cols, 3), 320px); + grid-auto-rows: auto; + justify-content: center; + gap: 0.75rem; + max-width: 1200px; + margin: 0 auto; + padding: 1rem; + box-sizing: border-box; + + --x: 50%; + --y: 50%; + --r: 220px; +} + +@media (max-width: 1124px) { + .chroma-grid { + grid-template-columns: repeat(auto-fit, minmax(320px, 320px)); + gap: 0.5rem; + padding: 0.5rem; + } +} + +@media (max-width: 480px) { + .chroma-grid { + grid-template-columns: 320px; + gap: 0.75rem; + padding: 1rem; + } +} + +.chroma-card { + position: relative; + display: flex; + flex-direction: column; + width: 320px; + height: auto; + border-radius: 20px; + overflow: hidden; + border: 1px solid #333; + transition: border-color 0.3s ease; + background: var(--card-gradient); + + --mouse-x: 50%; + --mouse-y: 50%; + --spotlight-color: rgba(255, 255, 255, 0.3); +} + +.chroma-card:hover { + border-color: var(--card-border); +} + +.chroma-card::before { + content: ''; + position: absolute; + inset: 0; + background: radial-gradient(circle at var(--mouse-x) var(--mouse-y), var(--spotlight-color), transparent 70%); + pointer-events: none; + opacity: 0; + transition: opacity 0.5s ease; + z-index: 2; +} + +.chroma-card:hover::before { + opacity: 1; +} + +.chroma-img-wrapper { + position: relative; + z-index: 1; + flex: 1; + padding: 10px; + box-sizing: border-box; + background: transparent; + transition: background 0.3s ease; +} + +.chroma-img-wrapper img { + width: 100%; + height: 100%; + object-fit: cover; + border-radius: 10px; + display: block; +} + +.chroma-info { + position: relative; + z-index: 1; + padding: 0.75rem 1rem; + color: #fff; + font-family: system-ui, sans-serif; + display: grid; + grid-template-columns: 1fr auto; + row-gap: 0.25rem; + column-gap: 0.75rem; +} + +.chroma-info .role, +.chroma-info .handle { + color: #aaa; +} + +.chroma-overlay { + position: absolute; + inset: 0; + pointer-events: none; + z-index: 3; + backdrop-filter: grayscale(1) brightness(0.78); + -webkit-backdrop-filter: grayscale(1) brightness(0.78); + background: rgba(0, 0, 0, 0.001); + + mask-image: radial-gradient( + circle var(--r) at var(--x) var(--y), + transparent 0%, + transparent 15%, + rgba(0, 0, 0, 0.1) 30%, + rgba(0, 0, 0, 0.22) 45%, + rgba(0, 0, 0, 0.35) 60%, + rgba(0, 0, 0, 0.5) 75%, + rgba(0, 0, 0, 0.68) 88%, + white 100% + ); + -webkit-mask-image: radial-gradient( + circle var(--r) at var(--x) var(--y), + transparent 0%, + transparent 15%, + rgba(0, 0, 0, 0.1) 30%, + rgba(0, 0, 0, 0.22) 45%, + rgba(0, 0, 0, 0.35) 60%, + rgba(0, 0, 0, 0.5) 75%, + rgba(0, 0, 0, 0.68) 88%, + white 100% + ); +} + +.chroma-fade { + position: absolute; + inset: 0; + pointer-events: none; + z-index: 4; + backdrop-filter: grayscale(1) brightness(0.78); + -webkit-backdrop-filter: grayscale(1) brightness(0.78); + background: rgba(0, 0, 0, 0.001); + + mask-image: radial-gradient( + circle var(--r) at var(--x) var(--y), + white 0%, + white 15%, + rgba(255, 255, 255, 0.9) 30%, + rgba(255, 255, 255, 0.78) 45%, + rgba(255, 255, 255, 0.65) 60%, + rgba(255, 255, 255, 0.5) 75%, + rgba(255, 255, 255, 0.32) 88%, + transparent 100% + ); + -webkit-mask-image: radial-gradient( + circle var(--r) at var(--x) var(--y), + white 0%, + white 15%, + rgba(255, 255, 255, 0.9) 30%, + rgba(255, 255, 255, 0.78) 45%, + rgba(255, 255, 255, 0.65) 60%, + rgba(255, 255, 255, 0.5) 75%, + rgba(255, 255, 255, 0.32) 88%, + transparent 100% + ); + + opacity: 1; + transition: opacity 0.25s ease; +} diff --git a/components/ChromaGrid.jsx b/components/ChromaGrid.jsx new file mode 100644 index 0000000..1e2a7e6 --- /dev/null +++ b/components/ChromaGrid.jsx @@ -0,0 +1,174 @@ +import { useRef, useEffect } from 'react'; +import { gsap } from 'gsap'; +import './ChromaGrid.css'; + +export const ChromaGrid = ({ + items, + className = '', + radius = 300, + columns = 3, + rows = 2, + damping = 0.45, + fadeOut = 0.6, + ease = 'power3.out' +}) => { + const rootRef = useRef(null); + const fadeRef = useRef(null); + const setX = useRef(null); + const setY = useRef(null); + const pos = useRef({ x: 0, y: 0 }); + + const demo = [ + { + image: 'https://i.pravatar.cc/300?img=8', + title: 'Alex Rivera', + subtitle: 'Full Stack Developer', + handle: '@alexrivera', + borderColor: '#4F46E5', + gradient: 'linear-gradient(145deg, #4F46E5, #000)', + url: 'https://github.com/' + }, + { + image: 'https://i.pravatar.cc/300?img=11', + title: 'Jordan Chen', + subtitle: 'DevOps Engineer', + handle: '@jordanchen', + borderColor: '#10B981', + gradient: 'linear-gradient(210deg, #10B981, #000)', + url: 'https://linkedin.com/in/' + }, + { + image: 'https://i.pravatar.cc/300?img=3', + title: 'Morgan Blake', + subtitle: 'UI/UX Designer', + handle: '@morganblake', + borderColor: '#F59E0B', + gradient: 'linear-gradient(165deg, #F59E0B, #000)', + url: 'https://dribbble.com/' + }, + { + image: 'https://i.pravatar.cc/300?img=16', + title: 'Casey Park', + subtitle: 'Data Scientist', + handle: '@caseypark', + borderColor: '#EF4444', + gradient: 'linear-gradient(195deg, #EF4444, #000)', + url: 'https://kaggle.com/' + }, + { + image: 'https://i.pravatar.cc/300?img=25', + title: 'Sam Kim', + subtitle: 'Mobile Developer', + handle: '@thesamkim', + borderColor: '#8B5CF6', + gradient: 'linear-gradient(225deg, #8B5CF6, #000)', + url: 'https://github.com/' + }, + { + image: 'https://i.pravatar.cc/300?img=60', + title: 'Tyler Rodriguez', + subtitle: 'Cloud Architect', + handle: '@tylerrod', + borderColor: '#06B6D4', + gradient: 'linear-gradient(135deg, #06B6D4, #000)', + url: 'https://aws.amazon.com/' + } + ]; + const data = items?.length ? items : demo; + + useEffect(() => { + const el = rootRef.current; + if (!el) return; + setX.current = gsap.quickSetter(el, '--x', 'px'); + setY.current = gsap.quickSetter(el, '--y', 'px'); + const { width, height } = el.getBoundingClientRect(); + pos.current = { x: width / 2, y: height / 2 }; + setX.current(pos.current.x); + setY.current(pos.current.y); + }, []); + + const moveTo = (x, y) => { + gsap.to(pos.current, { + x, + y, + duration: damping, + ease, + onUpdate: () => { + setX.current?.(pos.current.x); + setY.current?.(pos.current.y); + }, + overwrite: true + }); + }; + + const handleMove = e => { + const r = rootRef.current.getBoundingClientRect(); + moveTo(e.clientX - r.left, e.clientY - r.top); + gsap.to(fadeRef.current, { opacity: 0, duration: 0.25, overwrite: true }); + }; + + const handleLeave = () => { + gsap.to(fadeRef.current, { + opacity: 1, + duration: fadeOut, + overwrite: true + }); + }; + + const handleCardClick = url => { + if (url) { + window.open(url, '_blank', 'noopener,noreferrer'); + } + }; + + const handleCardMove = e => { + const card = e.currentTarget; + const rect = card.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + card.style.setProperty('--mouse-x', `${x}px`); + card.style.setProperty('--mouse-y', `${y}px`); + }; + + return ( +
+ {data.map((c, i) => ( +
handleCardClick(c.url)} + style={{ + '--card-border': c.borderColor || 'transparent', + '--card-gradient': c.gradient, + cursor: c.url ? 'pointer' : 'default' + }} + > +
+ {c.title} +
+
+

{c.title}

+ {c.handle && {c.handle}} +

{c.subtitle}

+ {c.location && {c.location}} +
+
+ ))} +
+
+
+ ); +}; + +export default ChromaGrid; diff --git a/components/CircularText.jsx b/components/CircularText.jsx index 23643cd..c3c5f74 100644 --- a/components/CircularText.jsx +++ b/components/CircularText.jsx @@ -19,7 +19,7 @@ const getTransition = (duration, from) => ({ } }); -const CircularText = ({ text, spinDuration = 20, onHover = 'speedUp', className = '' }) => { +const CircularText = ({ text, spinDuration = 20, onHover = 'speedUp', className = '', textColor = 'text-foreground' }) => { const letters = Array.from(text); const controls = useAnimation(); const rotation = useMotionValue(0); @@ -81,7 +81,7 @@ const CircularText = ({ text, spinDuration = 20, onHover = 'speedUp', className return ( {letter} diff --git a/components/ClickSpark.tsx b/components/ClickSpark.tsx index 73a838d..44991cf 100644 --- a/components/ClickSpark.tsx +++ b/components/ClickSpark.tsx @@ -105,8 +105,7 @@ export default function ClickSpark({ style={{ position: "relative", width: "100%", - height: "100%", - overflow: "hidden", + minHeight: "100%", }} > {children} diff --git a/components/Dither.css b/components/Dither.css new file mode 100644 index 0000000..f508fbd --- /dev/null +++ b/components/Dither.css @@ -0,0 +1,5 @@ +.dither-container { + width: 100%; + height: 100%; + position: relative; +} diff --git a/components/Dither.jsx b/components/Dither.jsx new file mode 100644 index 0000000..0142b82 --- /dev/null +++ b/components/Dither.jsx @@ -0,0 +1,295 @@ +import { useRef, useEffect, forwardRef } from 'react'; +import { Canvas, useFrame, useThree } from '@react-three/fiber'; +import { EffectComposer, wrapEffect } from '@react-three/postprocessing'; +import { Effect } from 'postprocessing'; +import * as THREE from 'three'; + +import './Dither.css'; + +const waveVertexShader = ` +precision highp float; +varying vec2 vUv; +void main() { + vUv = uv; + vec4 modelPosition = modelMatrix * vec4(position, 1.0); + vec4 viewPosition = viewMatrix * modelPosition; + gl_Position = projectionMatrix * viewPosition; +} +`; + +const waveFragmentShader = ` +precision highp float; +uniform vec2 resolution; +uniform float time; +uniform float waveSpeed; +uniform float waveFrequency; +uniform float waveAmplitude; +uniform vec3 waveColor; +uniform vec2 mousePos; +uniform int enableMouseInteraction; +uniform float mouseRadius; + +vec4 mod289(vec4 x) { return x - floor(x * (1.0/289.0)) * 289.0; } +vec4 permute(vec4 x) { return mod289(((x * 34.0) + 1.0) * x); } +vec4 taylorInvSqrt(vec4 r) { return 1.79284291400159 - 0.85373472095314 * r; } +vec2 fade(vec2 t) { return t*t*t*(t*(t*6.0-15.0)+10.0); } + +float cnoise(vec2 P) { + vec4 Pi = floor(P.xyxy) + vec4(0.0,0.0,1.0,1.0); + vec4 Pf = fract(P.xyxy) - vec4(0.0,0.0,1.0,1.0); + Pi = mod289(Pi); + vec4 ix = Pi.xzxz; + vec4 iy = Pi.yyww; + vec4 fx = Pf.xzxz; + vec4 fy = Pf.yyww; + vec4 i = permute(permute(ix) + iy); + vec4 gx = fract(i * (1.0/41.0)) * 2.0 - 1.0; + vec4 gy = abs(gx) - 0.5; + vec4 tx = floor(gx + 0.5); + gx = gx - tx; + vec2 g00 = vec2(gx.x, gy.x); + vec2 g10 = vec2(gx.y, gy.y); + vec2 g01 = vec2(gx.z, gy.z); + vec2 g11 = vec2(gx.w, gy.w); + vec4 norm = taylorInvSqrt(vec4(dot(g00,g00), dot(g01,g01), dot(g10,g10), dot(g11,g11))); + g00 *= norm.x; g01 *= norm.y; g10 *= norm.z; g11 *= norm.w; + float n00 = dot(g00, vec2(fx.x, fy.x)); + float n10 = dot(g10, vec2(fx.y, fy.y)); + float n01 = dot(g01, vec2(fx.z, fy.z)); + float n11 = dot(g11, vec2(fx.w, fy.w)); + vec2 fade_xy = fade(Pf.xy); + vec2 n_x = mix(vec2(n00, n01), vec2(n10, n11), fade_xy.x); + return 2.3 * mix(n_x.x, n_x.y, fade_xy.y); +} + +const int OCTAVES = 4; +float fbm(vec2 p) { + float value = 0.0; + float amp = 1.0; + float freq = waveFrequency; + for (int i = 0; i < OCTAVES; i++) { + value += amp * abs(cnoise(p)); + p *= freq; + amp *= waveAmplitude; + } + return value; +} + +float pattern(vec2 p) { + vec2 p2 = p - time * waveSpeed; + return fbm(p + fbm(p2)); +} + +void main() { + vec2 uv = gl_FragCoord.xy / resolution.xy; + uv -= 0.5; + uv.x *= resolution.x / resolution.y; + float f = pattern(uv); + if (enableMouseInteraction == 1) { + vec2 mouseNDC = (mousePos / resolution - 0.5) * vec2(1.0, -1.0); + mouseNDC.x *= resolution.x / resolution.y; + float dist = length(uv - mouseNDC); + float effect = 1.0 - smoothstep(0.0, mouseRadius, dist); + f -= 0.5 * effect; + } + vec3 col = mix(vec3(0.0), waveColor, f); + gl_FragColor = vec4(col, 1.0); +} +`; + +const ditherFragmentShader = ` +precision highp float; +uniform float colorNum; +uniform float pixelSize; +const float bayerMatrix8x8[64] = float[64]( + 0.0/64.0, 48.0/64.0, 12.0/64.0, 60.0/64.0, 3.0/64.0, 51.0/64.0, 15.0/64.0, 63.0/64.0, + 32.0/64.0,16.0/64.0, 44.0/64.0, 28.0/64.0, 35.0/64.0,19.0/64.0, 47.0/64.0, 31.0/64.0, + 8.0/64.0, 56.0/64.0, 4.0/64.0, 52.0/64.0, 11.0/64.0,59.0/64.0, 7.0/64.0, 55.0/64.0, + 40.0/64.0,24.0/64.0, 36.0/64.0, 20.0/64.0, 43.0/64.0,27.0/64.0, 39.0/64.0, 23.0/64.0, + 2.0/64.0, 50.0/64.0, 14.0/64.0, 62.0/64.0, 1.0/64.0,49.0/64.0, 13.0/64.0, 61.0/64.0, + 34.0/64.0,18.0/64.0, 46.0/64.0, 30.0/64.0, 33.0/64.0,17.0/64.0, 45.0/64.0, 29.0/64.0, + 10.0/64.0,58.0/64.0, 6.0/64.0, 54.0/64.0, 9.0/64.0,57.0/64.0, 5.0/64.0, 53.0/64.0, + 42.0/64.0,26.0/64.0, 38.0/64.0, 22.0/64.0, 41.0/64.0,25.0/64.0, 37.0/64.0, 21.0/64.0 +); + +vec3 dither(vec2 uv, vec3 color) { + vec2 scaledCoord = floor(uv * resolution / pixelSize); + int x = int(mod(scaledCoord.x, 8.0)); + int y = int(mod(scaledCoord.y, 8.0)); + float threshold = bayerMatrix8x8[y * 8 + x] - 0.25; + float step = 1.0 / (colorNum - 1.0); + color += threshold * step; + float bias = 0.2; + color = clamp(color - bias, 0.0, 1.0); + return floor(color * (colorNum - 1.0) + 0.5) / (colorNum - 1.0); +} + +void mainImage(in vec4 inputColor, in vec2 uv, out vec4 outputColor) { + vec2 normalizedPixelSize = pixelSize / resolution; + vec2 uvPixel = normalizedPixelSize * floor(uv / normalizedPixelSize); + vec4 color = texture2D(inputBuffer, uvPixel); + color.rgb = dither(uv, color.rgb); + outputColor = color; +} +`; + +class RetroEffectImpl extends Effect { + constructor() { + const uniforms = new Map([ + ['colorNum', new THREE.Uniform(4.0)], + ['pixelSize', new THREE.Uniform(2.0)] + ]); + super('RetroEffect', ditherFragmentShader, { uniforms }); + this.uniforms = uniforms; + } + set colorNum(v) { + this.uniforms.get('colorNum').value = v; + } + get colorNum() { + return this.uniforms.get('colorNum').value; + } + set pixelSize(v) { + this.uniforms.get('pixelSize').value = v; + } + get pixelSize() { + return this.uniforms.get('pixelSize').value; + } +} + +const WrappedRetro = wrapEffect(RetroEffectImpl); + +const RetroEffect = forwardRef((props, ref) => { + const { colorNum, pixelSize } = props; + return ; +}); +RetroEffect.displayName = 'RetroEffect'; + +function DitheredWaves({ + waveSpeed, + waveFrequency, + waveAmplitude, + waveColor, + colorNum, + pixelSize, + disableAnimation, + enableMouseInteraction, + mouseRadius +}) { + const mesh = useRef(null); + const mouseRef = useRef(new THREE.Vector2()); + const { viewport, size, gl } = useThree(); + + const waveUniformsRef = useRef({ + time: new THREE.Uniform(0), + resolution: new THREE.Uniform(new THREE.Vector2(0, 0)), + waveSpeed: new THREE.Uniform(waveSpeed), + waveFrequency: new THREE.Uniform(waveFrequency), + waveAmplitude: new THREE.Uniform(waveAmplitude), + waveColor: new THREE.Uniform(new THREE.Color(...waveColor)), + mousePos: new THREE.Uniform(new THREE.Vector2(0, 0)), + enableMouseInteraction: new THREE.Uniform(enableMouseInteraction ? 1 : 0), + mouseRadius: new THREE.Uniform(mouseRadius) + }); + + useEffect(() => { + const dpr = gl.getPixelRatio(); + const w = Math.floor(size.width * dpr), + h = Math.floor(size.height * dpr); + const res = waveUniformsRef.current.resolution.value; + if (res.x !== w || res.y !== h) { + res.set(w, h); + } + }, [size, gl]); + + const prevColor = useRef([...waveColor]); + useFrame(({ clock }) => { + const u = waveUniformsRef.current; + + if (!disableAnimation) { + u.time.value = clock.getElapsedTime(); + } + + if (u.waveSpeed.value !== waveSpeed) u.waveSpeed.value = waveSpeed; + if (u.waveFrequency.value !== waveFrequency) u.waveFrequency.value = waveFrequency; + if (u.waveAmplitude.value !== waveAmplitude) u.waveAmplitude.value = waveAmplitude; + + if (!prevColor.current.every((v, i) => v === waveColor[i])) { + u.waveColor.value.set(...waveColor); + prevColor.current = [...waveColor]; + } + + u.enableMouseInteraction.value = enableMouseInteraction ? 1 : 0; + u.mouseRadius.value = mouseRadius; + + if (enableMouseInteraction) { + u.mousePos.value.copy(mouseRef.current); + } + }); + + const handlePointerMove = e => { + if (!enableMouseInteraction) return; + const rect = gl.domElement.getBoundingClientRect(); + const dpr = gl.getPixelRatio(); + mouseRef.current.set((e.clientX - rect.left) * dpr, (e.clientY - rect.top) * dpr); + }; + + return ( + <> + + + + + + + + + + + + + + + ); +} + +export default function Dither({ + waveSpeed = 0.05, + waveFrequency = 3, + waveAmplitude = 0.3, + waveColor = [0.5, 0.5, 0.5], + colorNum = 4, + pixelSize = 2, + disableAnimation = false, + enableMouseInteraction = true, + mouseRadius = 1 +}) { + return ( + + + + ); +} diff --git a/components/FlowingMenu.css b/components/FlowingMenu.css new file mode 100644 index 0000000..8b94043 --- /dev/null +++ b/components/FlowingMenu.css @@ -0,0 +1,98 @@ +.menu-wrap { + width: 100%; + height: 100%; + overflow: hidden; +} + +.menu { + display: flex; + flex-direction: column; + height: 100%; + margin: 0; + padding: 0; +} + +.menu__item { + flex: 1; + position: relative; + overflow: hidden; + text-align: center; + border-top: 1px solid; +} + +.menu__item:first-child { + border-top: none; +} + +.menu__item-link { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + position: relative; + cursor: pointer; + text-transform: uppercase; + text-decoration: none; + white-space: nowrap; + font-weight: 600; + font-size: 4vh; +} + +.menu__item-link:hover { + color: inherit; +} + +.menu__item-link:focus:not(:focus-visible) { + color: inherit; +} + +.marquee { + position: absolute; + top: 0; + left: 0; + overflow: hidden; + width: 100%; + height: 100%; + pointer-events: none; + transform: translate3d(0, 101%, 0); +} + +.marquee__inner-wrap { + height: 100%; + width: 100%; + overflow: hidden; +} + +.marquee__inner { + display: flex; + align-items: center; + position: relative; + height: 100%; + width: fit-content; + will-change: transform; +} + +.marquee__part { + display: flex; + align-items: center; + flex-shrink: 0; +} + +.marquee span { + white-space: nowrap; + text-transform: uppercase; + font-weight: 400; + font-size: 4vh; + line-height: 1; + padding: 0 1vw; +} + +.marquee__img { + width: 200px; + height: 7vh; + margin: 2em 2vw; + padding: 1em 0; + border-radius: 50px; + background-size: cover; + background-position: 50% 50%; +} diff --git a/components/FlowingMenu.jsx b/components/FlowingMenu.jsx new file mode 100644 index 0000000..1dc65ee --- /dev/null +++ b/components/FlowingMenu.jsx @@ -0,0 +1,165 @@ +import { useRef, useEffect, useState } from 'react'; +import { gsap } from 'gsap'; + +import './FlowingMenu.css'; + +function FlowingMenu({ + items = [], + speed = 15, + textColor = '#fff', + bgColor = '#060010', + marqueeBgColor = '#fff', + marqueeTextColor = '#060010', + borderColor = '#fff' +}) { + return ( +
+ +
+ ); +} + +function MenuItem({ link, text, image, speed, textColor, marqueeBgColor, marqueeTextColor, borderColor }) { + const itemRef = useRef(null); + const marqueeRef = useRef(null); + const marqueeInnerRef = useRef(null); + const animationRef = useRef(null); + const [repetitions, setRepetitions] = useState(4); + + const animationDefaults = { duration: 0.6, ease: 'expo' }; + + const findClosestEdge = (mouseX, mouseY, width, height) => { + const topEdgeDist = distMetric(mouseX, mouseY, width / 2, 0); + const bottomEdgeDist = distMetric(mouseX, mouseY, width / 2, height); + return topEdgeDist < bottomEdgeDist ? 'top' : 'bottom'; + }; + + const distMetric = (x, y, x2, y2) => { + const xDiff = x - x2; + const yDiff = y - y2; + return xDiff * xDiff + yDiff * yDiff; + }; + + useEffect(() => { + const calculateRepetitions = () => { + if (!marqueeInnerRef.current) return; + + // Get the first marquee part to measure content width + const marqueeContent = marqueeInnerRef.current.querySelector('.marquee__part'); + if (!marqueeContent) return; + + const contentWidth = marqueeContent.offsetWidth; + const viewportWidth = window.innerWidth; + + // Calculate how many copies we need to fill viewport + extra for seamless loop + // We need at least 2, but calculate based on content vs viewport + const needed = Math.ceil(viewportWidth / contentWidth) + 2; + setRepetitions(Math.max(4, needed)); + }; + + calculateRepetitions(); + window.addEventListener('resize', calculateRepetitions); + return () => window.removeEventListener('resize', calculateRepetitions); + }, [text, image]); + + useEffect(() => { + const setupMarquee = () => { + if (!marqueeInnerRef.current) return; + + const marqueeContent = marqueeInnerRef.current.querySelector('.marquee__part'); + if (!marqueeContent) return; + + const contentWidth = marqueeContent.offsetWidth; + if (contentWidth === 0) return; + + if (animationRef.current) { + animationRef.current.kill(); + } + + // Animate exactly one content width for seamless loop + animationRef.current = gsap.to(marqueeInnerRef.current, { + x: -contentWidth, + duration: speed, + ease: 'none', + repeat: -1 + }); + }; + + // Small delay to ensure DOM is ready after repetitions update + const timer = setTimeout(setupMarquee, 50); + + return () => { + clearTimeout(timer); + if (animationRef.current) { + animationRef.current.kill(); + } + }; + }, [text, image, repetitions, speed]); + + const handleMouseEnter = ev => { + if (!itemRef.current || !marqueeRef.current || !marqueeInnerRef.current) return; + const rect = itemRef.current.getBoundingClientRect(); + const x = ev.clientX - rect.left; + const y = ev.clientY - rect.top; + const edge = findClosestEdge(x, y, rect.width, rect.height); + + gsap + .timeline({ defaults: animationDefaults }) + .set(marqueeRef.current, { y: edge === 'top' ? '-101%' : '101%' }, 0) + .set(marqueeInnerRef.current, { y: edge === 'top' ? '101%' : '-101%' }, 0) + .to([marqueeRef.current, marqueeInnerRef.current], { y: '0%' }, 0); + }; + + const handleMouseLeave = ev => { + if (!itemRef.current || !marqueeRef.current || !marqueeInnerRef.current) return; + const rect = itemRef.current.getBoundingClientRect(); + const x = ev.clientX - rect.left; + const y = ev.clientY - rect.top; + const edge = findClosestEdge(x, y, rect.width, rect.height); + + gsap + .timeline({ defaults: animationDefaults }) + .to(marqueeRef.current, { y: edge === 'top' ? '-101%' : '101%' }, 0) + .to(marqueeInnerRef.current, { y: edge === 'top' ? '101%' : '-101%' }, 0); + }; + + return ( +
+ + {text} + +
+
+ +
+
+ ); +} + +export default FlowingMenu; diff --git a/components/Navigation.tsx b/components/Navigation.tsx index 9344ef3..5453f32 100644 --- a/components/Navigation.tsx +++ b/components/Navigation.tsx @@ -89,8 +89,10 @@ function NavLink({ item, onClick }: { item: NavItem; onClick: () => void }) { export default function Navigation({ data }: NavigationProps) { const [isOpen, setIsOpen] = useState(false); const [isPastHero, setIsPastHero] = useState(false); + const [isPastDither, setIsPastDither] = useState(false); const pathname = usePathname(); const isHomePage = pathname === "/"; + const isTeamPage = pathname === "/team"; // Use Sanity data or fallbacks const navItems: NavItem[] = data?.navItems || defaultNavItems; @@ -101,6 +103,9 @@ export default function Navigation({ data }: NavigationProps) { const handleScroll = () => { // Consider "past hero" when scrolled more than 80% of viewport height setIsPastHero(window.scrollY > window.innerHeight * 0.8); + // Consider "past dither" when scrolled more than 300px (before timeline starts at 400px) + // This ensures the text turns black before reaching the dark timeline + setIsPastDither(window.scrollY > 300); }; window.addEventListener("scroll", handleScroll); @@ -183,6 +188,7 @@ export default function Navigation({ data }: NavigationProps) { onHover={undefined} spinDuration={20} className="absolute inset-0" + textColor={isTeamPage && !isPastDither ? "text-white" : "text-foreground"} />
diff --git a/components/TeamPageClient.tsx b/components/TeamPageClient.tsx index a545820..1a2d231 100644 --- a/components/TeamPageClient.tsx +++ b/components/TeamPageClient.tsx @@ -1,236 +1,304 @@ -"use client"; - -import { motion, AnimatePresence } from "framer-motion"; -import { useState, useMemo } from "react"; -import Image from "next/image"; -import { urlFor } from "@/sanity/lib/image"; -import { TeamMember, TeamPageData } from "@/lib/sanity/types"; - -// Fallback data -const defaultTeams = [ - { - name: "Executive", - members: [ - { name: "Alex Chen", role: "President", bio: "Leading MAC's vision to empower students through code." }, - { name: "Sarah Kim", role: "Vice President", bio: "Coordinating club activities and partnerships." }, - { name: "James Liu", role: "Secretary", bio: "Keeping everything organized and running smoothly." }, - { name: "Emily Zhang", role: "Treasurer", bio: "Managing finances and budgets for all events." }, - ], - }, - { - name: "Education", - members: [ - { name: "Michael Park", role: "Education Lead", bio: "Designing curriculum and workshop content." }, - { name: "Lisa Wang", role: "Workshop Coordinator", bio: "Organizing weekly coding workshops." }, - { name: "David Nguyen", role: "Mentor Lead", bio: "Managing our peer mentorship program." }, - ], - }, - { - name: "Events", - members: [ - { name: "Rachel Lee", role: "Events Lead", bio: "Planning hackathons and networking events." }, - { name: "Tom Anderson", role: "Logistics Coordinator", bio: "Handling venues and equipment." }, - { name: "Amy Patel", role: "Sponsorship Coordinator", bio: "Building relationships with industry partners." }, - ], - }, - { - name: "Marketing", - members: [ - { name: "Chris Wu", role: "Marketing Lead", bio: "Growing our community reach and engagement." }, - { name: "Jessica Brown", role: "Content Creator", bio: "Creating engaging social media content." }, - { name: "Ryan Martinez", role: "Designer", bio: "Crafting visual identity and materials." }, - ], - }, - { - name: "Technology", - members: [ - { name: "Kevin Tran", role: "Tech Lead", bio: "Building and maintaining club infrastructure." }, - { name: "Anna Johnson", role: "Web Developer", bio: "Creating our digital presence." }, - { name: "Mark Wilson", role: "DevOps", bio: "Managing deployments and systems." }, - ], - }, -]; +'use client' + +import { motion, AnimatePresence } from 'framer-motion' +import { useState, useMemo } from 'react' +import Image from 'next/image' +import dynamic from 'next/dynamic' +import { urlFor } from '@/sanity/lib/image' +import { TeamMember, TeamPageData, TeamSlug } from '@/lib/sanity/types' +import Timeline from '@/components/team/Timeline' + +const Dither = dynamic(() => import('@/components/Dither'), { ssr: false }) interface TeamPageClientProps { - pageData: TeamPageData | null; - members: TeamMember[] | null; + pageData: TeamPageData | null + members: TeamMember[] } -type TeamName = "Executive" | "Education" | "Events" | "Marketing" | "Technology"; - -interface GroupedTeam { - name: TeamName; - members: TeamMember[]; +const TEAM_LABELS: Record = { + management: 'Management', + events: 'Events', + marketing: 'Marketing', + design: 'Design', + 'human-resources': 'Human Resources', + sponsorship: 'Sponsorship', + media: 'Media', + projects: 'Projects', + outreach: 'Outreach', } +const TEAM_ORDER: TeamSlug[] = [ + 'management', + 'events', + 'marketing', + 'design', + 'human-resources', + 'sponsorship', + 'media', + 'projects', + 'outreach', +] + export default function TeamPageClient({ pageData, members }: TeamPageClientProps) { - const [selectedTeam, setSelectedTeam] = useState(null); + const [selectedTeam, setSelectedTeam] = useState('all') + const [selectedMember, setSelectedMember] = useState(null) + + const title = pageData?.pageTitle || 'Meet the Team' // Group members by team - const teams: GroupedTeam[] = useMemo(() => { - if (!members || members.length === 0) { - // Use fallback data structure - return defaultTeams.map((team) => ({ - name: team.name as TeamName, - members: team.members.map((m, i) => ({ - _id: `fallback-${team.name}-${i}`, - name: m.name, - role: m.role, - team: team.name as TeamName, - bio: m.bio, - })), - })); + const teamGroups = useMemo(() => { + const groups: Record = { + management: [], + events: [], + marketing: [], + design: [], + 'human-resources': [], + sponsorship: [], + media: [], + projects: [], + outreach: [], } - const teamOrder: TeamName[] = ["Executive", "Education", "Events", "Marketing", "Technology"]; - const grouped: Record = { - Executive: [], - Education: [], - Events: [], - Marketing: [], - Technology: [], - }; - members.forEach((member) => { - if (member.team && grouped[member.team]) { - grouped[member.team].push(member); + if (member.team && groups[member.team]) { + groups[member.team].push(member) } - }); + }) - return teamOrder.map((name) => ({ - name, - members: grouped[name], - })).filter((team) => team.members.length > 0); - }, [members]); + return groups + }, [members]) - const currentTeam = teams.find((t) => t.name === selectedTeam); + // Filter members based on selected team + const filteredMembers = useMemo(() => { + if (selectedTeam === 'all') return members + return teamGroups[selectedTeam] || [] + }, [selectedTeam, members, teamGroups]) - const title = pageData?.title || "Meet the Team"; - const subtitle = pageData?.subtitle || "Click on a branch to explore each team"; + // Get teams that have members + const activeTeams = TEAM_ORDER.filter((team) => teamGroups[team].length > 0) return ( -
-
- - {title} - - - {subtitle} - -
- - - {!selectedTeam ? ( - -
- {/* Tree Trunk */} - - - {/* Branches */} -
- {teams.map((team, index) => ( - setSelectedTeam(team.name)} - initial={{ opacity: 0, y: 20 }} - animate={{ opacity: 1, y: 0 }} - transition={{ duration: 0.4, delay: 0.4 + index * 0.1 }} - > -
- - {team.name} -
- {team.members.length} members -
-
- - ))} -
-
-
- ) : ( - + {/* Hero Section with Dither Background - exactly 400px */} +
+
+ +
+
+ + {title} + +
+
+ + {/* Timeline Section - positioned immediately below dither */} + + + + + {/* Team Filter and Grid Section */} +
+ {/* Filter Tabs */} +
+
+ {activeTeams.map((team) => ( + + ))} +
+
-

{selectedTeam} Team

- -
- {currentTeam?.members.map((member, index) => ( + {/* Team Member Grid */} +
+ + + {filteredMembers.map((member, index) => ( setSelectedMember(member)} + className="group cursor-pointer overflow-hidden rounded-2xl bg-white border border-black/10 shadow-sm transition-all duration-300 hover:-translate-y-1 hover:shadow-md" > - {member.image?.asset ? ( -
+ {/* Photo */} +
+ {member.photo?.asset ? ( {member.image.alt -
+ ) : ( +
+ + {member.name + .split(' ') + .map((n) => n[0]) + .join('')} + +
+ )} +
+ {/* Info */} +
+

{member.name}

+

{member.role}

+

+ {TEAM_LABELS[member.team]} +

+
+
+ ))} +
+
+ + {filteredMembers.length === 0 && ( +
+ No team members found for this team yet. +
+ )} +
+
+ + {/* Member Popup */} + + {selectedMember && ( + setSelectedMember(null)} + > + e.stopPropagation()} + > + {/* Close Button */} + + +
+ {/* Photo */} +
+ {selectedMember.photo?.asset ? ( + {selectedMember.photo.alt ) : ( -
- {member.name.split(" ").map((n) => n[0]).join("")} +
+ + {selectedMember.name + .split(' ') + .map((n) => n[0]) + .join('')} +
)} -

{member.name}

-

{member.role}

- {member.bio && ( -

{member.bio}

+
+ + {/* Info */} +

{selectedMember.name}

+

{selectedMember.role}

+

{TEAM_LABELS[selectedMember.team]}

+ + {selectedMember.bio && ( +

{selectedMember.bio}

+ )} + + {/* Social Links */} +
+ {selectedMember.linkedIn && ( + + + + + )} - - ))} -
- + {selectedMember.email && ( + + + + + + + )} +
+
+
+
)}
- ); + ) } diff --git a/components/team/TeamGrid.tsx b/components/team/TeamGrid.tsx new file mode 100644 index 0000000..da484a2 --- /dev/null +++ b/components/team/TeamGrid.tsx @@ -0,0 +1,103 @@ +'use client' + +import { useState, useMemo } from 'react' +import { motion, AnimatePresence } from 'framer-motion' +import { TeamMember, TeamSlug } from '@/lib/sanity/types' +import TeamMemberCard from './TeamMemberCard' +import TeamMemberPopup from './TeamMemberPopup' + +interface TeamGridProps { + members: TeamMember[] +} + +type FilterOption = 'all' | TeamSlug + +const FILTER_OPTIONS: { value: FilterOption; label: string }[] = [ + { value: 'all', label: 'All' }, + { value: 'management', label: 'Management' }, + { value: 'events', label: 'Events' }, + { value: 'marketing', label: 'Marketing' }, + { value: 'design', label: 'Design' }, + { value: 'human-resources', label: 'Human Resources' }, + { value: 'sponsorship', label: 'Sponsorship' }, + { value: 'media', label: 'Media' }, + { value: 'projects', label: 'Projects' }, + { value: 'outreach', label: 'Outreach' }, +] + +export default function TeamGrid({ members }: TeamGridProps) { + const [activeFilter, setActiveFilter] = useState('all') + const [selectedMember, setSelectedMember] = useState(null) + + const filteredMembers = useMemo(() => { + if (activeFilter === 'all') return members + return members.filter((member) => member.team === activeFilter) + }, [members, activeFilter]) + + const availableFilters = useMemo(() => { + const teamsWithMembers = new Set(members.map((m) => m.team)) + return FILTER_OPTIONS.filter( + (option) => option.value === 'all' || teamsWithMembers.has(option.value as TeamSlug) + ) + }, [members]) + + return ( + <> + {/* Filter Tabs */} +
+
+ {availableFilters.map((option) => ( + + ))} +
+
+ + {/* Grid */} + + + {filteredMembers.length > 0 ? ( + filteredMembers.map((member, index) => ( + setSelectedMember(member)} + index={index} + /> + )) + ) : ( + + No team members found + + )} + + + + {/* Popup */} + setSelectedMember(null)} + /> + + ) +} diff --git a/components/team/TeamMemberCard.tsx b/components/team/TeamMemberCard.tsx new file mode 100644 index 0000000..e3d72c1 --- /dev/null +++ b/components/team/TeamMemberCard.tsx @@ -0,0 +1,75 @@ +'use client' + +import { motion } from 'framer-motion' +import Image from 'next/image' +import { TeamMember } from '@/lib/sanity/types' +import { urlFor } from '@/sanity/lib/image' + +interface TeamMemberCardProps { + member: TeamMember + onClick: () => void + index: number +} + +function getInitials(name: string): string { + return name + .split(' ') + .map((part) => part[0]) + .join('') + .toUpperCase() + .slice(0, 2) +} + +export default function TeamMemberCard({ + member, + onClick, + index, +}: TeamMemberCardProps) { + const imageUrl = member.photo?.asset + ? urlFor(member.photo).width(400).height(400).fit('crop').url() + : null + + return ( + + {/* Photo Container */} +
+ {imageUrl ? ( + {member.photo?.alt + ) : ( +
+ + {getInitials(member.name)} + +
+ )} + + {/* Hover Overlay */} +
+ View Profile +
+
+ + {/* Info */} +
+

+ {member.name} +

+

{member.role}

+
+
+ ) +} diff --git a/components/team/TeamMemberPopup.tsx b/components/team/TeamMemberPopup.tsx new file mode 100644 index 0000000..40996ff --- /dev/null +++ b/components/team/TeamMemberPopup.tsx @@ -0,0 +1,159 @@ +'use client' + +import { useEffect } from 'react' +import { motion, AnimatePresence } from 'framer-motion' +import Image from 'next/image' +import { X, Linkedin, Mail } from 'lucide-react' +import { TeamMember, TeamSlug } from '@/lib/sanity/types' +import { urlFor } from '@/sanity/lib/image' + +interface TeamMemberPopupProps { + member: TeamMember | null + onClose: () => void +} + +const TEAM_LABELS: Record = { + management: 'Management', + events: 'Events', + marketing: 'Marketing', + design: 'Design', + 'human-resources': 'Human Resources', + sponsorship: 'Sponsorship', + media: 'Media', + projects: 'Projects', + outreach: 'Outreach', +} + +function getInitials(name: string): string { + return name + .split(' ') + .map((part) => part[0]) + .join('') + .toUpperCase() + .slice(0, 2) +} + +export default function TeamMemberPopup({ + member, + onClose, +}: TeamMemberPopupProps) { + useEffect(() => { + if (member) { + document.body.style.overflow = 'hidden' + } + return () => { + document.body.style.overflow = 'unset' + } + }, [member]) + + useEffect(() => { + const handleEscape = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose() + } + window.addEventListener('keydown', handleEscape) + return () => window.removeEventListener('keydown', handleEscape) + }, [onClose]) + + const imageUrl = member?.photo?.asset + ? urlFor(member.photo).width(600).height(600).fit('crop').url() + : null + + return ( + + {member && ( + <> + {/* Backdrop */} + + + {/* Popup */} + + {/* Close Button */} + + + {/* Content */} +
+ {/* Photo */} +
+ {imageUrl ? ( + {member.photo?.alt + ) : ( +
+ + {getInitials(member.name)} + +
+ )} +
+ + {/* Info */} +
+

+ {member.name} +

+

{member.role}

+ + {TEAM_LABELS[member.team] || member.team} + + + {/* Bio */} + {member.bio && ( +

{member.bio}

+ )} + + {/* Social Links */} + {(member.linkedIn || member.email) && ( +
+ {member.linkedIn && ( + + + LinkedIn + + )} + {member.email && ( + + + Email + + )} +
+ )} +
+
+
+ + )} +
+ ) +} diff --git a/components/team/Timeline.tsx b/components/team/Timeline.tsx new file mode 100644 index 0000000..b25746e --- /dev/null +++ b/components/team/Timeline.tsx @@ -0,0 +1,481 @@ +'use client' + +import { useRef, useEffect, useState } from 'react' +import { motion, AnimatePresence } from 'framer-motion' +import { gsap } from 'gsap' +import { ChevronDown } from 'lucide-react' +import { TimelineEvent } from '@/lib/sanity/types' + +interface TimelineProps { + events: TimelineEvent[] +} + +interface YearGroup { + year: string + events: TimelineEvent[] +} + + +function YearMenuItem({ + year, + events, + isExpanded, + onToggle, +}: { + year: string + events: TimelineEvent[] + isExpanded: boolean + onToggle: () => void +}) { + const itemRef = useRef(null) + const marqueeRef = useRef(null) + const marqueeInnerRef = useRef(null) + const animationRef = useRef(null) + const [repetitions, setRepetitions] = useState(6) + + const animationDefaults = { duration: 0.6, ease: 'expo' } + + const findClosestEdge = (mouseX: number, mouseY: number, width: number, height: number) => { + const topEdgeDist = (mouseX - width / 2) ** 2 + mouseY ** 2 + const bottomEdgeDist = (mouseX - width / 2) ** 2 + (mouseY - height) ** 2 + return topEdgeDist < bottomEdgeDist ? 'top' : 'bottom' + } + + useEffect(() => { + const calculateRepetitions = () => { + if (!marqueeInnerRef.current) return + const marqueeContent = marqueeInnerRef.current.querySelector('.marquee__part') + if (!marqueeContent) return + const contentWidth = (marqueeContent as HTMLElement).offsetWidth + const viewportWidth = window.innerWidth + const needed = Math.ceil(viewportWidth / contentWidth) + 2 + setRepetitions(Math.max(6, needed)) + } + calculateRepetitions() + window.addEventListener('resize', calculateRepetitions) + return () => window.removeEventListener('resize', calculateRepetitions) + }, [year]) + + useEffect(() => { + const setupMarquee = () => { + if (!marqueeInnerRef.current) return + const marqueeContent = marqueeInnerRef.current.querySelector('.marquee__part') + if (!marqueeContent) return + const contentWidth = (marqueeContent as HTMLElement).offsetWidth + if (contentWidth === 0) return + if (animationRef.current) animationRef.current.kill() + animationRef.current = gsap.to(marqueeInnerRef.current, { + x: -contentWidth, + duration: 12, + ease: 'none', + repeat: -1, + }) + } + const timer = setTimeout(setupMarquee, 50) + return () => { + clearTimeout(timer) + if (animationRef.current) animationRef.current.kill() + } + }, [year, repetitions]) + + const handleMouseEnter = (ev: React.MouseEvent) => { + if (!itemRef.current || !marqueeRef.current || !marqueeInnerRef.current) return + const rect = itemRef.current.getBoundingClientRect() + const x = ev.clientX - rect.left + const y = ev.clientY - rect.top + const edge = findClosestEdge(x, y, rect.width, rect.height) + gsap + .timeline({ defaults: animationDefaults }) + .set(marqueeRef.current, { y: edge === 'top' ? '-101%' : '101%' }, 0) + .set(marqueeInnerRef.current, { y: edge === 'top' ? '101%' : '-101%' }, 0) + .to([marqueeRef.current, marqueeInnerRef.current], { y: '0%' }, 0) + } + + const handleMouseLeave = (ev: React.MouseEvent) => { + if (!itemRef.current || !marqueeRef.current || !marqueeInnerRef.current) return + const rect = itemRef.current.getBoundingClientRect() + const x = ev.clientX - rect.left + const y = ev.clientY - rect.top + const edge = findClosestEdge(x, y, rect.width, rect.height) + gsap + .timeline({ defaults: animationDefaults }) + .to(marqueeRef.current, { y: edge === 'top' ? '-101%' : '101%' }, 0) + .to(marqueeInnerRef.current, { y: edge === 'top' ? '101%' : '-101%' }, 0) + } + + return ( +
+ {/* Year Menu Item */} +
+ + {/* Marquee on hover */} +
+
+ +
+
+
+ + {/* Expanded Events Grid */} + + {isExpanded && ( + + + + )} + +
+ ) +} + +function EventsGrid({ events }: { events: TimelineEvent[] }) { + const rootRef = useRef(null) + const fadeRef = useRef(null) + const chromaRef = useRef(null) + const hasInteracted = useRef(false) + const setX = useRef<((value: number) => void) | null>(null) + const setY = useRef<((value: number) => void) | null>(null) + const pos = useRef({ x: 0, y: 0 }) + + useEffect(() => { + const el = rootRef.current + if (!el) return + setX.current = gsap.quickSetter(el, '--x', 'px') as (value: number) => void + setY.current = gsap.quickSetter(el, '--y', 'px') as (value: number) => void + const { width, height } = el.getBoundingClientRect() + pos.current = { x: width / 2, y: height / 2 } + setX.current(pos.current.x) + setY.current(pos.current.y) + }, []) + + const moveTo = (x: number, y: number) => { + gsap.to(pos.current, { + x, + y, + duration: 0.45, + ease: 'power3.out', + onUpdate: () => { + setX.current?.(pos.current.x) + setY.current?.(pos.current.y) + }, + overwrite: true, + }) + } + + const handleMove = (e: React.PointerEvent) => { + const r = rootRef.current?.getBoundingClientRect() + if (!r) return + moveTo(e.clientX - r.left, e.clientY - r.top) + + // Show chroma overlay on first interaction + if (!hasInteracted.current) { + hasInteracted.current = true + gsap.to(chromaRef.current, { opacity: 1, duration: 0.4, overwrite: true }) + } + gsap.to(fadeRef.current, { opacity: 0, duration: 0.25, overwrite: true }) + } + + const handleLeave = () => { + // Only show fade overlay if user has interacted + if (hasInteracted.current) { + gsap.to(fadeRef.current, { opacity: 1, duration: 0.6, overwrite: true }) + } + } + + const handleCardMove = (e: React.MouseEvent) => { + const card = e.currentTarget + const rect = card.getBoundingClientRect() + const x = e.clientX - rect.left + const y = e.clientY - rect.top + card.style.setProperty('--mouse-x', `${x}px`) + card.style.setProperty('--mouse-y', `${y}px`) + } + + return ( +
+ {events.map((event, i) => ( + + {/* Spotlight effect */} +
+
+ + {event.date} + +

{event.title}

+ {event.description && ( +

{event.description}

+ )} +
+ + ))} + {/* Chroma overlay */} +
+ {/* Fade overlay */} +
+
+ ) +} + +function HeaderItem({ text }: { text: string }) { + const itemRef = useRef(null) + const marqueeRef = useRef(null) + const marqueeInnerRef = useRef(null) + const animationRef = useRef(null) + const [repetitions, setRepetitions] = useState(6) + + const animationDefaults = { duration: 0.6, ease: 'expo' } + + const findClosestEdge = (mouseX: number, mouseY: number, width: number, height: number) => { + const topEdgeDist = (mouseX - width / 2) ** 2 + mouseY ** 2 + const bottomEdgeDist = (mouseX - width / 2) ** 2 + (mouseY - height) ** 2 + return topEdgeDist < bottomEdgeDist ? 'top' : 'bottom' + } + + useEffect(() => { + const calculateRepetitions = () => { + if (!marqueeInnerRef.current) return + const marqueeContent = marqueeInnerRef.current.querySelector('.marquee__part') + if (!marqueeContent) return + const contentWidth = (marqueeContent as HTMLElement).offsetWidth + const viewportWidth = window.innerWidth + const needed = Math.ceil(viewportWidth / contentWidth) + 2 + setRepetitions(Math.max(6, needed)) + } + calculateRepetitions() + window.addEventListener('resize', calculateRepetitions) + return () => window.removeEventListener('resize', calculateRepetitions) + }, [text]) + + useEffect(() => { + const setupMarquee = () => { + if (!marqueeInnerRef.current) return + const marqueeContent = marqueeInnerRef.current.querySelector('.marquee__part') + if (!marqueeContent) return + const contentWidth = (marqueeContent as HTMLElement).offsetWidth + if (contentWidth === 0) return + if (animationRef.current) animationRef.current.kill() + animationRef.current = gsap.to(marqueeInnerRef.current, { + x: -contentWidth, + duration: 10, + ease: 'none', + repeat: -1, + }) + } + const timer = setTimeout(setupMarquee, 50) + return () => { + clearTimeout(timer) + if (animationRef.current) animationRef.current.kill() + } + }, [text, repetitions]) + + const handleMouseEnter = (ev: React.MouseEvent) => { + if (!itemRef.current || !marqueeRef.current || !marqueeInnerRef.current) return + const rect = itemRef.current.getBoundingClientRect() + const x = ev.clientX - rect.left + const y = ev.clientY - rect.top + const edge = findClosestEdge(x, y, rect.width, rect.height) + gsap + .timeline({ defaults: animationDefaults }) + .set(marqueeRef.current, { y: edge === 'top' ? '-101%' : '101%' }, 0) + .set(marqueeInnerRef.current, { y: edge === 'top' ? '101%' : '-101%' }, 0) + .to([marqueeRef.current, marqueeInnerRef.current], { y: '0%' }, 0) + } + + const handleMouseLeave = (ev: React.MouseEvent) => { + if (!itemRef.current || !marqueeRef.current || !marqueeInnerRef.current) return + const rect = itemRef.current.getBoundingClientRect() + const x = ev.clientX - rect.left + const y = ev.clientY - rect.top + const edge = findClosestEdge(x, y, rect.width, rect.height) + gsap + .timeline({ defaults: animationDefaults }) + .to(marqueeRef.current, { y: edge === 'top' ? '-101%' : '101%' }, 0) + .to(marqueeInnerRef.current, { y: edge === 'top' ? '101%' : '-101%' }, 0) + } + + return ( +
+
+ {text} +
+ {/* Marquee on hover */} +
+
+ +
+
+
+ ) +} + +export default function Timeline({ events }: TimelineProps) { + const [expandedYear, setExpandedYear] = useState(null) + + if (!events || events.length === 0) { + return null + } + + const timelineEvents = events + + // Group events by year + const yearGroups: YearGroup[] = timelineEvents.reduce((acc: YearGroup[], event) => { + const yearMatch = event.date.match(/\d{4}/) + const year = yearMatch ? yearMatch[0] : 'Unknown' + const existingGroup = acc.find((g) => g.year === year) + if (existingGroup) { + existingGroup.events.push(event) + } else { + acc.push({ year, events: [event] }) + } + return acc + }, []) + + // Sort by year + yearGroups.sort((a, b) => parseInt(a.year) - parseInt(b.year)) + + const handleToggle = (year: string) => { + setExpandedYear(expandedYear === year ? null : year) + } + + return ( +
+ + {yearGroups.map((group) => ( + handleToggle(group.year)} + /> + ))} +
+ ) +} diff --git a/lib/sanity/types.ts b/lib/sanity/types.ts index 63c2a94..1c47b4c 100644 --- a/lib/sanity/types.ts +++ b/lib/sanity/types.ts @@ -59,19 +59,67 @@ export interface NavigationData { circularText: string } -// Team +// Team types +export type TeamSlug = + | 'management' + | 'events' + | 'marketing' + | 'design' + | 'human-resources' + | 'sponsorship' + | 'media' + | 'projects' + | 'outreach' + +export interface TeamMemberImage { + asset: { + _id: string + url: string + metadata?: { + dimensions: { + width: number + height: number + } + } + } + alt?: string + hotspot?: { + x: number + y: number + height: number + width: number + } + crop?: { + top: number + bottom: number + left: number + right: number + } +} + export interface TeamMember { _id: string name: string role: string - team: 'Executive' | 'Education' | 'Events' | 'Marketing' | 'Technology' + team: TeamSlug + photo?: TeamMemberImage bio?: string - image?: SanityImage + linkedIn?: string + email?: string + order: number } -export interface TeamPageData { +export interface TimelineEvent { + _key: string + date: string title: string - subtitle: string + description?: string +} + +export interface TeamPageData { + pageTitle: string + pageSubtitle?: string + timeline?: TimelineEvent[] } // Recruitment diff --git a/package-lock.json b/package-lock.json index de56470..50015a2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@portabletext/react": "^6.0.2", "@react-three/drei": "^10.7.7", "@react-three/fiber": "^9.5.0", + "@react-three/postprocessing": "^3.0.4", "@sanity/image-url": "^2.0.3", "@sanity/vision": "^5.4.0", "@splinetool/react-spline": "^4.1.0", @@ -19,17 +20,19 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "framer-motion": "^12.26.2", + "gsap": "^3.14.2", "lucide-react": "^0.562.0", "motion": "^12.26.2", "next": "^16.0.6", "next-sanity": "^12.0.12", "ogl": "^1.0.11", + "postprocessing": "^6.38.2", "react": "^19.2.0", "react-dom": "^19.2.0", "sanity": "^5.4.0", "tailwind-merge": "^3.4.0", "tailwindcss-animate": "^1.0.7", - "three": "^0.182.0", + "three": "^0.167.1", "tw-animate-css": "^1.4.0" }, "devDependencies": { @@ -105,6 +108,7 @@ "resolved": "https://registry.npmjs.org/@octokit/core/-/core-5.2.2.tgz", "integrity": "sha512-/g2d4sW9nUDJOMz3mabVQvOGhVa4e/BN/Um7yca9Bb2XTzPPnfTWHWQg+IsEYO7M3Vx+EXvaM/I2pJWIMun1bg==", "license": "MIT", + "peer": true, "dependencies": { "@octokit/auth-token": "^4.0.0", "@octokit/graphql": "^7.1.0", @@ -679,6 +683,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -2464,6 +2469,7 @@ "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.39.11.tgz", "integrity": "sha512-bWdeR8gWM87l4DB/kYSF9A+dVackzDb/V56Tq7QVrQ7rn86W0rgZFtlL3g3pem6AeGcb9NQNoy3ao4WpW4h5tQ==", "license": "MIT", + "peer": true, "dependencies": { "@codemirror/state": "^6.5.0", "crelt": "^1.0.6", @@ -2671,6 +2677,7 @@ "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", @@ -3111,6 +3118,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -3152,6 +3160,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -3191,6 +3200,7 @@ "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", "license": "MIT", + "peer": true, "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", @@ -3277,7 +3287,6 @@ "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.4.0.tgz", "integrity": "sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==", "license": "MIT", - "peer": true, "dependencies": { "@emotion/memoize": "^0.9.0" } @@ -3286,15 +3295,13 @@ "version": "0.9.0", "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz", "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@emotion/unitless": { "version": "0.10.0", "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz", "integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@endemolshinegroup/cosmiconfig-typescript-loader": { "version": "3.0.2", @@ -5203,6 +5210,7 @@ "integrity": "sha512-/g2d4sW9nUDJOMz3mabVQvOGhVa4e/BN/Um7yca9Bb2XTzPPnfTWHWQg+IsEYO7M3Vx+EXvaM/I2pJWIMun1bg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@octokit/auth-token": "^4.0.0", "@octokit/graphql": "^7.1.0", @@ -5703,6 +5711,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "@napi-rs/wasm-runtime": "0.2.4", "@yarnpkg/lockfile": "^1.1.0", @@ -5801,6 +5810,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -7254,6 +7264,7 @@ "integrity": "sha512-7RKRKuA4xTjMhY+eG3jthb3hlZCsOwg3rztWh75Xc+ShDWOfDDATWbeZpAHBNRpm4Tv9WgBMOy1zEJYXG6NJ7Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@octokit/auth-token": "^2.4.4", "@octokit/graphql": "^4.5.8", @@ -7517,6 +7528,7 @@ "resolved": "https://registry.npmjs.org/@portabletext/editor/-/editor-4.2.4.tgz", "integrity": "sha512-zkeBSVKjVfyGi3qGf/Yx58320mBC8axg4AscIN1bgON8GeJTMASg1GD6tNPe9q0d3A7xIRjU42KT/XDrcx2J7g==", "license": "MIT", + "peer": true, "dependencies": { "@portabletext/block-tools": "^5.0.0", "@portabletext/keyboard-shortcuts": "^2.1.2", @@ -7776,6 +7788,7 @@ "resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.5.0.tgz", "integrity": "sha512-FiUzfYW4wB1+PpmsE47UM+mCads7j2+giRBltfwH7SNhah95rqJs3ltEs9V3pP8rYdS0QlNne+9Aj8dS/SiaIA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.17.8", "@types/webxr": "*", @@ -7843,6 +7856,32 @@ "ieee754": "^1.2.1" } }, + "node_modules/@react-three/postprocessing": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@react-three/postprocessing/-/postprocessing-3.0.4.tgz", + "integrity": "sha512-e4+F5xtudDYvhxx3y0NtWXpZbwvQ0x1zdOXWTbXMK6fFLVDd4qucN90YaaStanZGS4Bd5siQm0lGL/5ogf8iDQ==", + "license": "MIT", + "dependencies": { + "maath": "^0.6.0", + "n8ao": "^1.9.4", + "postprocessing": "^6.36.6" + }, + "peerDependencies": { + "@react-three/fiber": "^9.0.0", + "react": "^19.0", + "three": ">= 0.156.0" + } + }, + "node_modules/@react-three/postprocessing/node_modules/maath": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/maath/-/maath-0.6.0.tgz", + "integrity": "sha512-dSb2xQuP7vDnaYqfoKzlApeRcR2xtN8/f7WV/TMAkBC8552TwTLtOO0JTcSygkYMjNDPoo6V01jTw/aPi4JrMw==", + "license": "MIT", + "peerDependencies": { + "@types/three": ">=0.144.0", + "three": ">=0.144.0" + } + }, "node_modules/@rexxars/react-json-inspector": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/@rexxars/react-json-inspector/-/react-json-inspector-9.0.1.tgz", @@ -8862,6 +8901,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -9080,6 +9120,7 @@ "resolved": "https://registry.npmjs.org/@sanity/client/-/client-7.14.0.tgz", "integrity": "sha512-eXue3rc4MqJh89mvuTC0h0pdoY8lwXjlV8odFB3EF7aSFKF7F5BL0NU2mlTrCZYbPAlV3JTvMPPLGJCORqOKDw==", "license": "MIT", + "peer": true, "dependencies": { "@sanity/eventsource": "^5.0.2", "get-it": "^8.7.0", @@ -9470,7 +9511,8 @@ "version": "19.2.3", "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.3.tgz", "integrity": "sha512-qJNJfu81ByyabuG7hPFEbXqNcWSU3+eVus+KJs+0ncpGfMyYdvSmxiJxbWR65lYi1I+/0HBcliO029gc4F+PnA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@sanity/json-match": { "version": "1.0.5", @@ -9891,6 +9933,7 @@ "resolved": "https://registry.npmjs.org/@sanity/cli-core/-/cli-core-0.1.0-alpha.6.tgz", "integrity": "sha512-yrZWRxSBZBuk+FxXULZI1gwOVWIPZ9tvdrswWmHsnqAU8051y9sDb9+ntHU8rn6c9jsRS0cJBJH+uhIlXt+1bA==", "license": "MIT", + "peer": true, "dependencies": { "@inquirer/prompts": "^8.1.0", "@oclif/core": "^4.8.0", @@ -10307,6 +10350,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -12134,6 +12178,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -12228,6 +12273,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -12589,6 +12635,7 @@ "resolved": "https://registry.npmjs.org/@sanity/types/-/types-5.4.0.tgz", "integrity": "sha512-wy+w1K2WuMj+6xcP3z4dDLdMQiyinfemi0koX0HQulZ+s6uSPi2rDKgixe0+7hcQD8k1m2fGpDfvCfnlXAT0Vg==", "license": "MIT", + "peer": true, "dependencies": { "@sanity/client": "^7.14.0", "@sanity/media-library-types": "^1.2.0" @@ -13045,6 +13092,7 @@ "version": "1.12.5", "resolved": "https://registry.npmjs.org/@splinetool/runtime/-/runtime-1.12.5.tgz", "integrity": "sha512-l9wXG1qIIV9ssvFuNC9ctQ5jEXmXnljpX0RafxkJH0H0j5J/DcUcDxUHgQRFZdXg/CWLCh8Mi0wY0nDmO0qQRQ==", + "peer": true, "dependencies": { "on-change": "^4.0.0", "semver-compare": "^1.0.0" @@ -13615,6 +13663,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz", "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -13649,6 +13698,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -13703,8 +13753,7 @@ "version": "4.2.7", "resolved": "https://registry.npmjs.org/@types/stylis/-/stylis-4.2.7.tgz", "integrity": "sha512-VgDNokpBoKF+wrdvhAAfS55OMQpL6QRglwTwNC3kIgBrzZxA4WsFj+2eLfEA/uMUDzBcEhYmjSbwQakn/i3ajA==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/tar-stream": { "version": "3.1.4", @@ -13720,6 +13769,7 @@ "resolved": "https://registry.npmjs.org/@types/three/-/three-0.182.0.tgz", "integrity": "sha512-WByN9V3Sbwbe2OkWuSGyoqQO8Du6yhYaXtXLoA5FkKTUJorZ+yOHBZ35zUUPQXlAKABZmbYp5oAqpA4RBjtJ/Q==", "license": "MIT", + "peer": true, "dependencies": { "@dimforge/rapier3d-compat": "~0.12.0", "@tweenjs/tween.js": "~23.1.3", @@ -13813,6 +13863,7 @@ "integrity": "sha512-jCzKdm/QK0Kg4V4IK/oMlRZlY+QOcdjv89U2NgKHZk1CYTj82/RVSx1mV/0gqCVMJ/DA+Zf/S4NBWNF8GQ+eqQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.48.0", "@typescript-eslint/types": "8.48.0", @@ -14486,6 +14537,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -15332,6 +15384,7 @@ "resolved": "https://registry.npmjs.org/babel-plugin-react-compiler/-/babel-plugin-react-compiler-1.0.0.tgz", "integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==", "license": "MIT", + "peer": true, "dependencies": { "@babel/types": "^7.26.0" } @@ -15566,6 +15619,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.25", "caniuse-lite": "^1.0.30001754", @@ -15801,7 +15855,6 @@ "resolved": "https://registry.npmjs.org/camelize/-/camelize-1.0.1.tgz", "integrity": "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -16723,6 +16776,7 @@ "integrity": "sha512-pondGvTuVYDk++upghXJabWzL6Kxu6f26ljFw64Swq9v6sQPUL3EUlVDV56diOjpCayKihL6hVe8exIACU4XcA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/parse-json": "^4.0.0", "import-fresh": "^3.2.1", @@ -16858,7 +16912,6 @@ "resolved": "https://registry.npmjs.org/css-color-keywords/-/css-color-keywords-1.0.0.tgz", "integrity": "sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg==", "license": "ISC", - "peer": true, "engines": { "node": ">=4" } @@ -16884,7 +16937,6 @@ "resolved": "https://registry.npmjs.org/css-to-react-native/-/css-to-react-native-3.2.0.tgz", "integrity": "sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ==", "license": "MIT", - "peer": true, "dependencies": { "camelize": "^1.0.0", "css-color-keywords": "^1.0.0", @@ -17636,31 +17688,6 @@ "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", "license": "MIT" }, - "node_modules/encoding": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", - "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "iconv-lite": "^0.6.2" - } - }, - "node_modules/encoding/node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/end-of-stream": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", @@ -17961,6 +17988,7 @@ "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", "hasInstallScript": true, "license": "MIT", + "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -18035,6 +18063,7 @@ "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -18220,6 +18249,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -19033,7 +19063,8 @@ "resolved": "https://registry.npmjs.org/fp-ts/-/fp-ts-2.16.11.tgz", "integrity": "sha512-LaI+KaX2NFkfn1ZGHoKCmcfv7yrZsC3b8NtWsTVQeHkq4F27vI5igUuO53sxqDEa2gNQMHFPmpojDw/1zmUK7w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/fraction.js": { "version": "5.3.4", @@ -19873,6 +19904,12 @@ "node": ">= 14" } }, + "node_modules/gsap": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/gsap/-/gsap-3.14.2.tgz", + "integrity": "sha512-P8/mMxVLU7o4+55+1TCnQrPmgjPKnwkzkXOK1asnR9Jg2lna4tEY5qBJjMmAaOBDDZWtlRjBXjLa0w53G/uBLA==", + "license": "Standard 'no charge' license: https://gsap.com/standard-license." + }, "node_modules/gunzip-maybe": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/gunzip-maybe/-/gunzip-maybe-1.4.2.tgz", @@ -20299,6 +20336,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.23.2" } @@ -21372,6 +21410,7 @@ "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", "license": "MIT", + "peer": true, "bin": { "jiti": "lib/jiti-cli.mjs" } @@ -21945,6 +21984,7 @@ "integrity": "sha512-/g2d4sW9nUDJOMz3mabVQvOGhVa4e/BN/Um7yca9Bb2XTzPPnfTWHWQg+IsEYO7M3Vx+EXvaM/I2pJWIMun1bg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@octokit/auth-token": "^4.0.0", "@octokit/graphql": "^7.1.0", @@ -22445,6 +22485,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "@napi-rs/wasm-runtime": "0.2.4", "@yarnpkg/lockfile": "^1.1.0", @@ -22543,6 +22584,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -22709,6 +22751,7 @@ "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", "devOptional": true, "license": "MPL-2.0", + "peer": true, "dependencies": { "detect-libc": "^2.0.3" }, @@ -22740,6 +22783,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -22760,6 +22804,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -22780,6 +22825,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -22800,6 +22846,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -22820,6 +22867,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -22840,6 +22888,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -22860,6 +22909,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -22880,6 +22930,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -22900,6 +22951,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -22920,6 +22972,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -22940,6 +22993,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -23979,6 +24033,16 @@ "integrity": "sha512-wmunL3uoPhma/tWy8PrDPZkvJpXvSFBwbD3KkC4PG8Ztjfb1X3hRJwGUAQyRz7z99b/ovLm2UTTitrkvStjH4w==", "license": "MIT" }, + "node_modules/n8ao": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/n8ao/-/n8ao-1.10.1.tgz", + "integrity": "sha512-hhI1pC+BfOZBV1KMwynBrVlIm8wqLxj/abAWhF2nZ0qQKyzTSQa1QtLVS2veRiuoBQXojxobcnp0oe+PUoxf/w==", + "license": "ISC", + "peerDependencies": { + "postprocessing": ">=6.30.0", + "three": ">=0.137" + } + }, "node_modules/nano-pubsub": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/nano-pubsub/-/nano-pubsub-3.0.0.tgz", @@ -24058,6 +24122,7 @@ "resolved": "https://registry.npmjs.org/next/-/next-16.1.3.tgz", "integrity": "sha512-gthG3TRD+E3/mA0uDQb9lqBmx1zVosq5kIwxNN6+MRNd085GzD+9VXMPUs+GGZCbZ+GDZdODUq4Pm7CTXK6ipw==", "license": "MIT", + "peer": true, "dependencies": { "@next/env": "16.1.3", "@swc/helpers": "0.5.15", @@ -26106,6 +26171,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -26135,6 +26201,16 @@ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", "license": "MIT" }, + "node_modules/postprocessing": { + "version": "6.38.2", + "resolved": "https://registry.npmjs.org/postprocessing/-/postprocessing-6.38.2.tgz", + "integrity": "sha512-7DwuT7Tkst41ZjSj287g7C9c5/D3Xx5rMgBosg0dadbUPoZD2HNzkadKPol1d2PJAoI9f+Jeh1/v9YfLzpFGVw==", + "license": "Zlib", + "peer": true, + "peerDependencies": { + "three": ">= 0.157.0 < 0.183.0" + } + }, "node_modules/potpack": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/potpack/-/potpack-1.0.2.tgz", @@ -26491,6 +26567,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -26521,6 +26598,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -27547,6 +27625,7 @@ "resolved": "https://registry.npmjs.org/sanity/-/sanity-5.4.0.tgz", "integrity": "sha512-N/oNn//xFiepDUjga3binKNzn/6HTP+RlH5sA7pmpgRUBtX6ZUo+NqG6Qj3vN/kRU9omN5oYTwRnGCLJb79lAw==", "license": "MIT", + "peer": true, "dependencies": { "@date-fns/tz": "^1.4.1", "@dnd-kit/core": "^6.3.1", @@ -28078,6 +28157,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -28111,7 +28191,8 @@ "version": "19.2.3", "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.3.tgz", "integrity": "sha512-qJNJfu81ByyabuG7hPFEbXqNcWSU3+eVus+KJs+0ncpGfMyYdvSmxiJxbWR65lYi1I+/0HBcliO029gc4F+PnA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/sanity/node_modules/read-pkg": { "version": "5.2.0", @@ -28215,6 +28296,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -28440,8 +28522,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz", "integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/sharp": { "version": "0.34.5", @@ -28721,13 +28802,15 @@ "version": "0.120.0", "resolved": "https://registry.npmjs.org/slate/-/slate-0.120.0.tgz", "integrity": "sha512-CXK/DADGgMZb4z9RTtXylzIDOxvmNJEF9bXV2bAGkLWhQ3rm7GORY9q0H/W41YJvAGZsLbH7nnrhMYr550hWDQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/slate-dom": { "version": "0.119.0", "resolved": "https://registry.npmjs.org/slate-dom/-/slate-dom-0.119.0.tgz", "integrity": "sha512-foc8a2NkE+1SldDIYaoqjhVKupt8RSuvHI868rfYOcypD4we5TT7qunjRKJ852EIRh/Ql8sSTepXgXKOUJnt1w==", "license": "MIT", + "peer": true, "dependencies": { "@juggle/resize-observer": "^3.4.0", "direction": "^1.0.4", @@ -29334,7 +29417,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.7", "picocolors": "^1.1.1", @@ -29371,8 +29453,7 @@ "version": "4.3.6", "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.6.tgz", "integrity": "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/supports-color": { "version": "7.2.0", @@ -29441,7 +29522,8 @@ "version": "4.1.18", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/tailwindcss-animate": { "version": "1.0.7", @@ -29645,10 +29727,11 @@ } }, "node_modules/three": { - "version": "0.182.0", - "resolved": "https://registry.npmjs.org/three/-/three-0.182.0.tgz", - "integrity": "sha512-GbHabT+Irv+ihI1/f5kIIsZ+Ef9Sl5A1Y7imvS5RQjWgtTPfPnZ43JmlYI7NtCRDK9zir20lQpfg8/9Yd02OvQ==", - "license": "MIT" + "version": "0.167.1", + "resolved": "https://registry.npmjs.org/three/-/three-0.167.1.tgz", + "integrity": "sha512-gYTLJA/UQip6J/tJvl91YYqlZF47+D/kxiWrbTon35ZHlXEN0VOo+Qke2walF1/x92v55H6enomymg4Dak52kw==", + "license": "MIT", + "peer": true }, "node_modules/three-mesh-bvh": { "version": "0.8.3", @@ -29765,6 +29848,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -29994,6 +30078,7 @@ "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "license": "MIT", + "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -30224,6 +30309,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -30779,6 +30865,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -31343,6 +31430,7 @@ "resolved": "https://registry.npmjs.org/xstate/-/xstate-5.25.1.tgz", "integrity": "sha512-oyvsNH5pF2qkHmiHEMdWqc3OjDtoZOH2MTAI35r01f/ZQWOD+VLOiYqo65UgQET0XMA5s9eRm8fnsIo+82biEw==", "license": "MIT", + "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/xstate" @@ -31514,6 +31602,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz", "integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 09a34b0..14334a2 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "@portabletext/react": "^6.0.2", "@react-three/drei": "^10.7.7", "@react-three/fiber": "^9.5.0", + "@react-three/postprocessing": "^3.0.4", "@sanity/image-url": "^2.0.3", "@sanity/vision": "^5.4.0", "@splinetool/react-spline": "^4.1.0", @@ -33,17 +34,19 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "framer-motion": "^12.26.2", + "gsap": "^3.14.2", "lucide-react": "^0.562.0", "motion": "^12.26.2", "next": "^16.0.6", "next-sanity": "^12.0.12", "ogl": "^1.0.11", + "postprocessing": "^6.38.2", "react": "^19.2.0", "react-dom": "^19.2.0", "sanity": "^5.4.0", "tailwind-merge": "^3.4.0", "tailwindcss-animate": "^1.0.7", - "three": "^0.182.0", + "three": "^0.167.1", "tw-animate-css": "^1.4.0" }, "devDependencies": { diff --git a/sanity/lib/queries/team.ts b/sanity/lib/queries/team.ts index ee555ba..acf57a6 100644 --- a/sanity/lib/queries/team.ts +++ b/sanity/lib/queries/team.ts @@ -2,8 +2,14 @@ import { groq } from 'next-sanity' export const teamPageQuery = groq` *[_type == "teamPage"][0] { - title, - subtitle + pageTitle, + pageSubtitle, + timeline[] { + _key, + date, + title, + description + } } ` @@ -14,7 +20,10 @@ export const teamMembersQuery = groq` role, team, bio, - image { + linkedIn, + email, + order, + photo { asset->, alt, hotspot, @@ -30,7 +39,10 @@ export const teamMembersByTeamQuery = groq` role, team, bio, - image { + linkedIn, + email, + order, + photo { asset->, alt, hotspot, diff --git a/sanity/schemas/team.ts b/sanity/schemas/team.ts index 39272ab..7fb0be5 100644 --- a/sanity/schemas/team.ts +++ b/sanity/schemas/team.ts @@ -25,25 +25,22 @@ export const teamMember = defineType({ type: 'string', options: { list: [ - { title: 'Executive', value: 'Executive' }, - { title: 'Education', value: 'Education' }, - { title: 'Events', value: 'Events' }, - { title: 'Marketing', value: 'Marketing' }, - { title: 'Technology', value: 'Technology' }, + { title: 'Management', value: 'management' }, + { title: 'Events', value: 'events' }, + { title: 'Marketing', value: 'marketing' }, + { title: 'Design', value: 'design' }, + { title: 'Human Resources', value: 'human-resources' }, + { title: 'Sponsorship', value: 'sponsorship' }, + { title: 'Media', value: 'media' }, + { title: 'Projects', value: 'projects' }, + { title: 'Outreach', value: 'outreach' }, ], layout: 'radio', }, validation: (Rule) => Rule.required(), }), defineField({ - name: 'bio', - title: 'Bio', - type: 'text', - rows: 3, - validation: (Rule) => Rule.max(200), - }), - defineField({ - name: 'image', + name: 'photo', title: 'Photo', type: 'image', options: { @@ -57,6 +54,23 @@ export const teamMember = defineType({ }), ], }), + defineField({ + name: 'bio', + title: 'Bio', + type: 'text', + rows: 3, + validation: (Rule) => Rule.max(300), + }), + defineField({ + name: 'linkedIn', + title: 'LinkedIn URL', + type: 'url', + }), + defineField({ + name: 'email', + title: 'Email', + type: 'string', + }), defineField({ name: 'order', title: 'Display Order', @@ -80,7 +94,7 @@ export const teamMember = defineType({ title: 'name', subtitle: 'role', team: 'team', - media: 'image', + media: 'photo', }, prepare({ title, subtitle, team, media }) { return { @@ -99,16 +113,48 @@ export const teamPage = defineType({ icon: UsersIcon, fields: [ defineField({ - name: 'title', + name: 'pageTitle', title: 'Page Title', type: 'string', initialValue: 'Meet the Team', }), defineField({ - name: 'subtitle', + name: 'pageSubtitle', title: 'Page Subtitle', type: 'string', - initialValue: 'Click on a branch to explore each team', + }), + defineField({ + name: 'timeline', + title: 'Timeline Events', + type: 'array', + of: [ + { + type: 'object', + name: 'timelineEvent', + title: 'Timeline Event', + fields: [ + defineField({ + name: 'date', + title: 'Date', + type: 'string', + description: 'e.g. "July 2019"', + validation: (Rule) => Rule.required(), + }), + defineField({ + name: 'title', + title: 'Title', + type: 'string', + validation: (Rule) => Rule.required(), + }), + defineField({ + name: 'description', + title: 'Description', + type: 'text', + rows: 2, + }), + ], + }, + ], }), ], preview: {