diff --git a/.gitignore b/.gitignore index d4e444b..9d243ae 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,6 @@ next-env.d.ts # claude .claude + +# local agent workspace +/.codex diff --git a/app/components/navbar/Navbar.tsx b/app/components/navbar/Navbar.tsx index 071924a..ba4ac21 100644 --- a/app/components/navbar/Navbar.tsx +++ b/app/components/navbar/Navbar.tsx @@ -57,13 +57,14 @@ export default function Navbar() { - {link.label} + {link.label} + ))} Gallery diff --git a/app/components/teams/Teams.tsx b/app/components/teams/Teams.tsx index cdba6bd..cc9f89e 100644 --- a/app/components/teams/Teams.tsx +++ b/app/components/teams/Teams.tsx @@ -1,22 +1,1026 @@ -const teams = ["Marketing", "Tech"]; +"use client"; + +import type { CSSProperties } from "react"; +import Image from "next/image"; +import { useEffect, useRef, useState } from "react"; +import { useIsAndroid } from "@/app/hooks/useIsAndroid"; +import { useIsMobile } from "@/app/hooks/useIsMobile"; +import { usePrefersReducedMotion } from "@/app/hooks/usePrefersReducedMotion"; +import { + ORDERED_OFFICER_TEAMS, + resolveConstellationLayout, + type OfficerTeam, + type ResolvedConstellationLayout, +} from "./constellationLayout"; +import { + getDesktopConstellationBox, + type ConstellationBox, + TEAM_CLUSTER_BOX, + TEAMS_BACKGROUND_STARS, + TEAMS_COPY, + TEAMS_LAYOUT, + TEAMS_SCROLL, +} from "./sceneConfig"; + +type ActiveNodeState = { + teamId: string; + personId: string; +} | null; + +function getInitials(name: string) { + const segments = name + .trim() + .split(/\s+/) + .filter(Boolean); + + return segments + .slice(0, 2) + .map((segment) => segment[0]?.toUpperCase() ?? "") + .join(""); +} + +function getMemberCountLabel(memberCount: number) { + return `${memberCount} MEMBERS`; +} + +function getTeamOpacity(activeTeamId: string | null, teamId: string) { + if (!activeTeamId || activeTeamId === teamId) { + return 1; + } + + return 0.25; +} + +function getTooltipPlacement( + nodeX: number, + nodeY: number, + box: ConstellationBox, +) { + let horizontal = "left-1/2 -translate-x-1/2"; + + if (nodeX < box.width * 0.18) { + horizontal = "left-0 translate-x-0"; + } else if (nodeX > box.width * 0.82) { + horizontal = "right-0 translate-x-0"; + } + + const vertical = + nodeY < box.height * 0.34 ? "top-full mt-4" : "bottom-full mb-4"; + + return `${vertical} ${horizontal}`; +} + +function areBoxesEqual(left: ConstellationBox, right: ConstellationBox) { + return ( + left.width === right.width && + left.height === right.height && + left.padding === right.padding && + left.verticalBias === right.verticalBias && + left.leadNodeSize === right.leadNodeSize && + left.nodeSize === right.nodeSize + ); +} + +function TeamConstellation({ + layout, + box, + activeTeamId, + setActiveTeamId, + activeNode, + openNode, + clearTooltipClose, + scheduleTooltipClose, + interactive, + showCaption = true, +}: { + layout: ResolvedConstellationLayout; + box: ConstellationBox; + activeTeamId: string | null; + setActiveTeamId: (teamId: string | null) => void; + activeNode: ActiveNodeState; + openNode: (teamId: string, personId: string) => void; + clearTooltipClose: () => void; + scheduleTooltipClose: () => void; + interactive: boolean; + showCaption?: boolean; +}) { + const teamOpacity = getTeamOpacity(activeTeamId, layout.team.id); + const memberCountLabel = getMemberCountLabel(layout.nodes.length); + const graphBounds = layout.nodes.reduce( + (bounds, node) => { + const radius = (node.isLead ? box.leadNodeSize : box.nodeSize) / 2 + 6; + + return { + minX: Math.min(bounds.minX, node.renderX - radius), + maxX: Math.max(bounds.maxX, node.renderX + radius), + }; + }, + { + minX: Number.POSITIVE_INFINITY, + maxX: Number.NEGATIVE_INFINITY, + }, + ); + const graphWidth = graphBounds.maxX - graphBounds.minX; + const graphOffsetX = (box.width - graphWidth) / 2 - graphBounds.minX; + const shiftedNodes = layout.nodes.map((node) => ({ + ...node, + shiftedX: node.renderX + graphOffsetX, + })); + const nodeMap = new Map(shiftedNodes.map((node) => [node.id, node])); + + const trimEdge = (fromId: string, toId: string) => { + const fromNode = nodeMap.get(fromId); + const toNode = nodeMap.get(toId); + + if (!fromNode || !toNode) { + return null; + } + + const dx = toNode.renderX - fromNode.renderX; + const dy = toNode.renderY - fromNode.renderY; + const distance = Math.hypot(dx, dy) || 1; + const fromRadius = + (fromNode.isLead ? box.leadNodeSize : box.nodeSize) / 2 + 3; + const toRadius = (toNode.isLead ? box.leadNodeSize : box.nodeSize) / 2 + 3; + + return { + x1: fromNode.shiftedX + (dx / distance) * fromRadius, + y1: fromNode.renderY + (dy / distance) * fromRadius, + x2: toNode.shiftedX - (dx / distance) * toRadius, + y2: toNode.renderY - (dy / distance) * toRadius, + }; + }; + + return ( +
{ + clearTooltipClose(); + setActiveTeamId(layout.team.id); + } + : undefined + } + onMouseLeave={interactive ? scheduleTooltipClose : undefined} + > +
+ + + {shiftedNodes.map((node) => { + const isActive = + activeNode?.teamId === layout.team.id && + activeNode.personId === node.person.id; + const tooltipPlacement = getTooltipPlacement( + node.shiftedX, + node.renderY, + box, + ); + const nodePositionStyle: CSSProperties = { + left: `${node.shiftedX}px`, + top: `${node.renderY}px`, + zIndex: isActive ? 30 : node.isLead ? 12 : 8, + }; + const nodeButtonStyle: CSSProperties = { + width: node.isLead ? `${box.leadNodeSize}px` : `${box.nodeSize}px`, + height: node.isLead ? `${box.leadNodeSize}px` : `${box.nodeSize}px`, + boxShadow: node.isLead + ? "0 0 0 8px rgba(243, 22, 103, 0.08)" + : "none", + }; + const nodeLabelStyle: CSSProperties = { + fontSize: node.isLead + ? `${Math.round(box.leadNodeSize * 0.42)}px` + : `${Math.round(box.nodeSize * 0.46)}px`, + }; + + return ( +
+ {isActive ? ( +
{ + clearTooltipClose(); + setActiveTeamId(layout.team.id); + }} + onMouseLeave={scheduleTooltipClose} + onFocusCapture={() => { + clearTooltipClose(); + setActiveTeamId(layout.team.id); + }} + onBlurCapture={(event) => { + const relatedTarget = event.relatedTarget; + + if ( + relatedTarget instanceof Node && + event.currentTarget.contains(relatedTarget) + ) { + return; + } + + scheduleTooltipClose(); + }} + > + +
+ {node.person.imageUrl ? ( + {node.person.name} + ) : ( +
+ {getInitials(node.person.name)} +
+ )} +
+

+ {node.person.name} +

+

+ {node.person.role} +

+
+
+ {node.person.quote ? ( +

+ “{node.person.quote}” +

+ ) : null} + +
+ ) : null} + + +
+ ); + })} +
+ + {showCaption ? ( +
+

+ {layout.template.name} +

+

+ {layout.team.label} +

+

+ {memberCountLabel} +

+
+ ) : null} +
+ ); +} + + +function buildLayouts(teams: OfficerTeam[], box: ConstellationBox) { + return teams.map((team) => + resolveConstellationLayout( + team, + box.width, + box.height, + box.padding, + box.verticalBias, + ), + ); +} + export default function Teams() { + const sectionRef = useRef(null); + const mobileSectionRef = useRef(null); + const mobileTrackRef = useRef(null); + const trackViewportRef = useRef(null); + const trackRef = useRef(null); + const tooltipCloseTimeoutRef = useRef(null); + const descTransitionRef = useRef(null); + const activeTeamIndexRef = useRef(0); + const isMobile = useIsMobile(); + const isAndroid = useIsAndroid(); + const prefersReducedMotion = usePrefersReducedMotion(); + const [displayedTeamIndex, setDisplayedTeamIndex] = useState(0); + const [descVisible, setDescVisible] = useState(true); + const [desktopBox, setDesktopBox] = useState( + TEAM_CLUSTER_BOX.desktop, + ); + const [mobileBox, setMobileBox] = useState(TEAM_CLUSTER_BOX.mobile); + const [activeTeamId, setActiveTeamId] = useState(null); + const [activeNode, setActiveNode] = useState(null); + + useEffect(() => { + if (isMobile) { + return; + } + + const trackViewport = trackViewportRef.current; + + if (!trackViewport) { + return; + } + + const updateDesktopBox = () => { + const nextBox = getDesktopConstellationBox( + trackViewport.offsetWidth, + window.innerHeight, + ); + + setDesktopBox((currentBox) => + areBoxesEqual(currentBox, nextBox) ? currentBox : nextBox, + ); + }; + + updateDesktopBox(); + + const resizeObserver = new ResizeObserver(updateDesktopBox); + resizeObserver.observe(trackViewport); + window.addEventListener("resize", updateDesktopBox); + + return () => { + resizeObserver.disconnect(); + window.removeEventListener("resize", updateDesktopBox); + }; + }, [isMobile]); + + useEffect(() => { + if (!isMobile) return; + + const update = () => { + const w = window.innerWidth; + const h = window.innerHeight; + + if (isAndroid) { + const clampedW = Math.min(w, 412); + const nodeSize = Math.round(Math.min(38 + (clampedW - 360) * 0.05, 44)); + const leadNodeSize = Math.round(nodeSize * 1.3); + setMobileBox({ + width: w, + // reduced from 0.46 to leave room for photo + description panel above + height: Math.round(h * 0.38), + padding: Math.round(w * 0.08), + verticalBias: 12, + leadNodeSize, + nodeSize, + }); + } else { + setMobileBox({ + width: w, + // reduced from 0.54 to leave room for photo + description panel above + height: Math.round(h * 0.40), + padding: Math.round(w * 0.07), + verticalBias: 16, + leadNodeSize: 58, + nodeSize: 44, + }); + } + }; + + update(); + window.addEventListener("resize", update); + return () => window.removeEventListener("resize", update); + }, [isMobile, isAndroid]); + + useEffect(() => { + if (!isMobile || prefersReducedMotion) return; + + const section = mobileSectionRef.current; + const track = mobileTrackRef.current; + + if (!section || !track) return; + + let frame = 0; + let currentX = 0; + let targetX = 0; + + const clamp = (value: number, min: number, max: number) => + Math.min(Math.max(value, min), max); + + const updateTarget = () => { + const maxTranslate = Math.max(track.scrollWidth - window.innerWidth, 0); + const scrollableDistance = Math.max( + section.offsetHeight - window.innerHeight, + 1, + ); + const progress = clamp( + (window.scrollY - section.offsetTop) / scrollableDistance, + 0, + 1, + ); + targetX = progress * maxTranslate; + + if (maxTranslate > 0) { + const slotWidth = track.scrollWidth / ORDERED_OFFICER_TEAMS.length; + const nextIndex = clamp( + Math.round((progress * maxTranslate) / slotWidth), + 0, + ORDERED_OFFICER_TEAMS.length - 1, + ); + if (nextIndex !== activeTeamIndexRef.current) { + activeTeamIndexRef.current = nextIndex; + setDescVisible(false); + if (descTransitionRef.current !== null) { + window.clearTimeout(descTransitionRef.current); + } + descTransitionRef.current = window.setTimeout(() => { + setDisplayedTeamIndex(nextIndex); + setDescVisible(true); + descTransitionRef.current = null; + }, 200); + } + } + + if (progress <= 0.001 || progress >= 0.999) { + currentX = targetX; + track.style.transform = `translate3d(${-currentX}px, 0, 0)`; + return; + } + + queueRender(); + }; + + const renderTrack = () => { + currentX += (targetX - currentX) * TEAMS_SCROLL.smoothing; + + if (Math.abs(targetX - currentX) < 0.12) { + currentX = targetX; + } + + track.style.transform = `translate3d(${-currentX}px, 0, 0)`; + + if (currentX !== targetX) { + frame = window.requestAnimationFrame(renderTrack); + return; + } + + frame = 0; + }; + + const queueRender = () => { + if (frame !== 0) return; + frame = window.requestAnimationFrame(renderTrack); + }; + + window.addEventListener("scroll", updateTarget, { passive: true }); + window.addEventListener("resize", updateTarget); + updateTarget(); + + return () => { + if (frame !== 0) window.cancelAnimationFrame(frame); + window.removeEventListener("scroll", updateTarget); + window.removeEventListener("resize", updateTarget); + track.style.transform = ""; + }; + }, [isMobile, prefersReducedMotion]); + + useEffect(() => { + if (isMobile || prefersReducedMotion) { + return; + } + + const section = sectionRef.current; + const trackViewport = trackViewportRef.current; + const track = trackRef.current; + + if (!section || !trackViewport || !track) { + return; + } + + let frame = 0; + let currentX = 0; + let targetX = 0; + let maxTranslate = 0; + + const clamp = (value: number, min: number, max: number) => + Math.min(Math.max(value, min), max); + + const updateTarget = () => { + const scrollableDistance = Math.max( + section.offsetHeight - window.innerHeight, + 1, + ); + const progress = clamp( + (window.scrollY - section.offsetTop) / scrollableDistance, + 0, + 1, + ); + targetX = progress * maxTranslate; + + if (maxTranslate > 0) { + const slotWidth = track.scrollWidth / ORDERED_OFFICER_TEAMS.length; + const nextIndex = clamp( + Math.round((progress * maxTranslate) / slotWidth), + 0, + ORDERED_OFFICER_TEAMS.length - 1, + ); + if (nextIndex !== activeTeamIndexRef.current) { + activeTeamIndexRef.current = nextIndex; + setDescVisible(false); + if (descTransitionRef.current !== null) { + window.clearTimeout(descTransitionRef.current); + } + descTransitionRef.current = window.setTimeout(() => { + setDisplayedTeamIndex(nextIndex); + setDescVisible(true); + descTransitionRef.current = null; + }, 200); + } + } + + if (progress <= 0.001 || progress >= 0.999) { + currentX = targetX; + track.style.transform = `translate3d(${-currentX}px, 0, 0)`; + return; + } + + queueRender(); + }; + + const updateMetrics = () => { + maxTranslate = Math.max(track.scrollWidth - trackViewport.offsetWidth, 0); + updateTarget(); + }; + + const renderTrack = () => { + currentX += (targetX - currentX) * TEAMS_SCROLL.smoothing; + + if (Math.abs(targetX - currentX) < 0.12) { + currentX = targetX; + } + + track.style.transform = `translate3d(${-currentX}px, 0, 0)`; + + if (currentX !== targetX) { + frame = window.requestAnimationFrame(renderTrack); + return; + } + + frame = 0; + }; + + const queueRender = () => { + if (frame !== 0) { + return; + } + + frame = window.requestAnimationFrame(renderTrack); + }; + + updateMetrics(); + + const resizeObserver = new ResizeObserver(updateMetrics); + resizeObserver.observe(trackViewport); + resizeObserver.observe(track); + window.addEventListener("scroll", updateTarget, { passive: true }); + window.addEventListener("resize", updateMetrics); + + return () => { + if (frame !== 0) { + window.cancelAnimationFrame(frame); + } + + resizeObserver.disconnect(); + window.removeEventListener("scroll", updateTarget); + window.removeEventListener("resize", updateMetrics); + track.style.transform = ""; + }; + }, [desktopBox.height, desktopBox.width, isMobile, prefersReducedMotion]); + + useEffect(() => { + return () => { + if (tooltipCloseTimeoutRef.current !== null) { + window.clearTimeout(tooltipCloseTimeoutRef.current); + } + if (descTransitionRef.current !== null) { + window.clearTimeout(descTransitionRef.current); + } + }; + }, []); + + const clearTooltipClose = () => { + if (tooltipCloseTimeoutRef.current === null) { + return; + } + + window.clearTimeout(tooltipCloseTimeoutRef.current); + tooltipCloseTimeoutRef.current = null; + }; + + const scheduleTooltipClose = () => { + clearTooltipClose(); + tooltipCloseTimeoutRef.current = window.setTimeout(() => { + setActiveNode(null); + setActiveTeamId(null); + tooltipCloseTimeoutRef.current = null; + }, TEAMS_SCROLL.tooltipCloseDelayMs); + }; + + const openNode = (teamId: string, personId: string) => { + clearTooltipClose(); + setActiveTeamId(teamId); + setActiveNode({ teamId, personId }); + }; + + const desktopLayouts = buildLayouts(ORDERED_OFFICER_TEAMS, desktopBox); + const mobileLayouts = buildLayouts(ORDERED_OFFICER_TEAMS, mobileBox); + + if (isMobile) { + const mobileStars = ( + + ); + + const mobilePhotoPanel = ( +
+ {ORDERED_OFFICER_TEAMS[displayedTeamIndex]?.groupPhotoUrl ? ( + {`${ORDERED_OFFICER_TEAMS[displayedTeamIndex]?.label} + ) : ( +
+

+ Group photo coming soon +

+
+ )} + {ORDERED_OFFICER_TEAMS[displayedTeamIndex]?.description ? ( +

+ {ORDERED_OFFICER_TEAMS[displayedTeamIndex]!.description} +

+ ) : null} +
+ ); + + if (prefersReducedMotion) { + return ( +
+ {mobileStars} +
+

+ {TEAMS_COPY.eyebrow} +

+

+ {TEAMS_COPY.heading[0]} +
+ {TEAMS_COPY.heading[1]} +

+
+ {mobileLayouts.map((layout) => ( + + ))} +
+
+
+ ); + } + + return ( +
+
+ {mobileStars} + +
+
+

+ {TEAMS_COPY.eyebrow} +

+

+ {TEAMS_COPY.heading[0]} +
+ {TEAMS_COPY.heading[1]} +

+
+ + {mobilePhotoPanel} + +
+
+ {mobileLayouts.map((layout) => ( + + ))} +
+
+
+
+
+ ); + } + + if (prefersReducedMotion) { + return ( +
+ + +
+

+ {TEAMS_COPY.eyebrow} +

+

+ {TEAMS_COPY.heading[0]} +
+ {TEAMS_COPY.heading[1]} +

+ +
+ {desktopLayouts.map((layout) => ( +
+ +
+ ))} +
+
+
+ ); + } + return ( -
-

- The -
- Constellation -

- -
- {teams.map((team) => ( -
-

{team}

- {/* Team member nodes will go here */} -
+
+
+ + +
+
+

+ {TEAMS_COPY.eyebrow} +

+

+ {TEAMS_COPY.heading[0]} +
+ {TEAMS_COPY.heading[1]} +

+

+ {ORDERED_OFFICER_TEAMS[displayedTeamIndex]?.description ?? ""} +

+ +
+ {ORDERED_OFFICER_TEAMS[displayedTeamIndex]?.groupPhotoUrl ? ( + {`${ORDERED_OFFICER_TEAMS[displayedTeamIndex]?.label} + ) : ( +
+

+ Group photo coming soon +

+
+ )} +
+
+ +
+
+
+ {desktopLayouts.map((layout, index) => ( +
+ +
+ ))} +
- ))} +
); diff --git a/app/components/teams/constellationLayout.ts b/app/components/teams/constellationLayout.ts new file mode 100644 index 0000000..fe5b410 --- /dev/null +++ b/app/components/teams/constellationLayout.ts @@ -0,0 +1,248 @@ +import constellationTemplatesData from "@/app/data/constellation-templates.json"; +import officerTeamsData from "@/app/data/officer-teams.json"; + +export type OfficerMember = { + id: string; + name: string; + role: string; + linkedinUrl: string; + quote?: string; + imageUrl?: string; +}; + +export type OfficerTeam = { + id: string; + label: string; + order: number; + templateId?: string; + description?: string; + groupPhotoUrl?: string; + lead: OfficerMember; + members: OfficerMember[]; +}; + +export type ConstellationNode = { + id: string; + x: number; + y: number; +}; + +export type ConstellationEdge = { + fromId: string; + toId: string; +}; + +export type ConstellationAppendRule = { + fromNodeId: string; + toNodeId: string; + stepX: number; + stepY: number; +}; + +export type ConstellationTemplate = { + id: string; + name: string; + starCount: number; + leadNodeId: string; + nodes: ConstellationNode[]; + edges: ConstellationEdge[]; + append: ConstellationAppendRule; +}; + +export type ResolvedOfficerNode = ConstellationNode & { + person: OfficerMember; + isLead: boolean; + isOverflow: boolean; +}; + +export type RenderedConstellationEdge = { + fromId: string; + toId: string; + from: { x: number; y: number }; + to: { x: number; y: number }; +}; + +export type RenderedOfficerNode = ResolvedOfficerNode & { + renderX: number; + renderY: number; +}; + +export type ResolvedConstellationLayout = { + team: OfficerTeam; + template: ConstellationTemplate; + nodes: RenderedOfficerNode[]; + edges: RenderedConstellationEdge[]; +}; + +const officerTeams = officerTeamsData as OfficerTeam[]; +const constellationTemplates = constellationTemplatesData as ConstellationTemplate[]; + +export const ORDERED_OFFICER_TEAMS = officerTeams + .slice() + .sort((left, right) => left.order - right.order); + +function hashString(input: string) { + let hash = 0; + + for (const character of input) { + hash = (hash * 31 + character.charCodeAt(0)) >>> 0; + } + + return hash; +} + +function pickTemplate(teamId: string, peopleCount: number, templateId?: string) { + if (templateId) { + const explicit = constellationTemplates.find((t) => t.id === templateId); + if (explicit) return explicit; + } + + const availableCounts = Array.from( + new Set(constellationTemplates.map((template) => template.starCount)), + ).sort((left, right) => left - right); + + const chosenCount = + availableCounts.filter((count) => count <= peopleCount).at(-1) ?? + availableCounts[0]; + const candidates = constellationTemplates.filter( + (template) => template.starCount === chosenCount, + ); + + return candidates[hashString(teamId) % candidates.length]; +} + +function getAssignmentOrder(template: ConstellationTemplate) { + return [ + template.leadNodeId, + ...template.nodes + .map((node) => node.id) + .filter((nodeId) => nodeId !== template.leadNodeId), + ]; +} + +function fitNodesToBox( + nodes: ResolvedOfficerNode[], + width: number, + height: number, + padding: number, + verticalBias: number, +) { + const minX = Math.min(...nodes.map((node) => node.x)); + const maxX = Math.max(...nodes.map((node) => node.x)); + const minY = Math.min(...nodes.map((node) => node.y)); + const maxY = Math.max(...nodes.map((node) => node.y)); + const rawWidth = Math.max(maxX - minX, 0.01); + const rawHeight = Math.max(maxY - minY, 0.01); + const scale = Math.min( + (width - padding * 2) / rawWidth, + (height - padding * 2) / rawHeight, + ); + const contentWidth = rawWidth * scale; + const contentHeight = rawHeight * scale; + const offsetX = (width - contentWidth) / 2; + const centeredOffsetY = (height - contentHeight) / 2; + const offsetY = Math.min( + centeredOffsetY + verticalBias, + height - contentHeight - padding * 0.4, + ); + + return nodes.map((node) => ({ + ...node, + renderX: (node.x - minX) * scale + offsetX, + renderY: (node.y - minY) * scale + offsetY, + })); +} + +export function resolveConstellationLayout( + team: OfficerTeam, + width: number, + height: number, + padding: number, + verticalBias = 0, +): ResolvedConstellationLayout { + const people = [team.lead, ...team.members]; + const template = pickTemplate(team.id, people.length, team.templateId); + const assignmentOrder = getAssignmentOrder(template); + const selectedNodeIds = assignmentOrder.slice( + 0, + Math.min(people.length, template.nodes.length), + ); + const selectedNodeIdSet = new Set(selectedNodeIds); + const resolvedNodes: ResolvedOfficerNode[] = template.nodes + .filter((node) => selectedNodeIdSet.has(node.id)) + .map((node) => ({ + ...node, + person: + people[ + selectedNodeIds.findIndex((selectedNodeId) => selectedNodeId === node.id) + ], + isLead: node.id === template.leadNodeId, + isOverflow: false, + })); + const resolvedEdges: ConstellationEdge[] = template.edges.filter( + (edge) => + selectedNodeIdSet.has(edge.fromId) && selectedNodeIdSet.has(edge.toId), + ); + + if (people.length > template.nodes.length) { + const anchorNode = template.nodes.find( + (node) => node.id === template.append.toNodeId, + ); + + if (anchorNode) { + let previousNodeId = anchorNode.id; + + for ( + let overflowIndex = 0; + overflowIndex < people.length - template.nodes.length; + overflowIndex += 1 + ) { + const person = people[template.nodes.length + overflowIndex]; + const overflowNodeId = `overflow-${overflowIndex + 1}`; + resolvedNodes.push({ + id: overflowNodeId, + x: anchorNode.x + template.append.stepX * (overflowIndex + 1), + y: anchorNode.y + template.append.stepY * (overflowIndex + 1), + person, + isLead: false, + isOverflow: true, + }); + resolvedEdges.push({ + fromId: previousNodeId, + toId: overflowNodeId, + }); + previousNodeId = overflowNodeId; + } + } + } + + const renderedNodes = fitNodesToBox( + resolvedNodes, + width, + height, + padding, + verticalBias, + ); + const nodeMap = new Map( + renderedNodes.map((node) => [node.id, { x: node.renderX, y: node.renderY }]), + ); + const renderedEdges = resolvedEdges + .map((edge) => { + const from = nodeMap.get(edge.fromId); + const to = nodeMap.get(edge.toId); + + if (!from || !to) { + return null; + } + + return { fromId: edge.fromId, toId: edge.toId, from, to }; + }) + .filter((edge): edge is RenderedConstellationEdge => edge !== null); + + return { + team, + template, + nodes: renderedNodes, + edges: renderedEdges, + }; +} diff --git a/app/components/teams/sceneConfig.ts b/app/components/teams/sceneConfig.ts new file mode 100644 index 0000000..fab8775 --- /dev/null +++ b/app/components/teams/sceneConfig.ts @@ -0,0 +1,102 @@ +export type AmbientStar = { + id: number; + top: number; + left: number; + size: number; + opacity: number; +}; + +export type ConstellationBox = { + width: number; + height: number; + padding: number; + verticalBias: number; + leadNodeSize: number; + nodeSize: number; +}; + +export const TEAMS_COPY = { + eyebrow: "HACKUTD", + heading: ["The", "Constellation"], +} as const; + +export const TEAMS_LAYOUT = { + desktopSectionMinHeight: "min-h-[560vh]", + mobileSectionMinHeight: "min-h-[420vh]", + mobileSectionMinHeightAndroid: "min-h-[480vh]", + mobileSectionPadding: "px-5 py-24 sm:px-6", + mobileViewportHeight: "h-[100svh]", + mobileViewportHeightAndroid: "h-[100dvh]", + desktopViewportHeight: "h-[100svh] md:h-screen", + desktopContainer: "mx-auto flex h-full w-full max-w-[1800px] items-start pt-28 gap-8 px-5 md:px-8 lg:gap-10 lg:px-12", + introWidth: "w-[320px] shrink-0 lg:w-[400px]", + desktopHeading: "mt-6 text-5xl font-semibold leading-none text-foreground lg:text-6xl", + mobileHeading: "mt-5 text-4xl font-semibold leading-none text-foreground sm:text-5xl", + desktopTrackViewport: "relative min-w-0 flex-1 overflow-x-hidden overflow-y-visible", +} as const; + +export const TEAMS_SCROLL = { + smoothing: 0.22, + desktopGap: 10, + desktopTrailingSpace: 180, + desktopPeekWidth: 150, + separatorWidth: 380, + firstConstellationOffset: 160, + tooltipCloseDelayMs: 140, +} as const; + +export const TEAM_CLUSTER_BOX = { + desktop: { + width: 980, + height: 380, + padding: 42, + verticalBias: 22, + leadNodeSize: 72, + nodeSize: 50, + }, + mobile: { + width: 310, + height: 260, + padding: 30, + verticalBias: 14, + leadNodeSize: 62, + nodeSize: 46, + }, +} as const; + +function clamp(value: number, min: number, max: number) { + return Math.min(Math.max(value, min), max); +} + +export function getDesktopConstellationBox( + trackViewportWidth: number, + viewportHeight: number, +): ConstellationBox { + return { + width: clamp(trackViewportWidth * 0.95, 820, 1380), + height: clamp(viewportHeight * 0.66, 420, 600), + padding: clamp(trackViewportWidth * 0.028, 60, 80), + verticalBias: 50, + leadNodeSize: 74, + nodeSize: 56, + }; +} + +function createAmbientStars(count: number): AmbientStar[] { + let seed = 71; + + const next = () => { + seed = (seed * 48271) % 2147483647; + return seed / 2147483647; + }; + + return Array.from({ length: count }, (_, index) => ({ + id: index, + top: 8 + next() * 82, + left: 2 + next() * 96, + size: 1 + next() * 2.8, + opacity: 0.16 + next() * 0.4, + })); +} + +export const TEAMS_BACKGROUND_STARS = createAmbientStars(34); diff --git a/app/data/constellation-templates.json b/app/data/constellation-templates.json new file mode 100644 index 0000000..e8830d4 --- /dev/null +++ b/app/data/constellation-templates.json @@ -0,0 +1,414 @@ +[ + { + "id": "cassiopeia", + "name": "Cassiopeia", + "starCount": 5, + "leadNodeId": "n0", + "nodes": [ + { "id": "n0", "x": 0.48, "y": 0.12 }, + { "id": "n1", "x": 0.16, "y": 0.3 }, + { "id": "n2", "x": 0.04, "y": 0.66 }, + { "id": "n3", "x": 0.7, "y": 0.44 }, + { "id": "n4", "x": 0.96, "y": 0.72 } + ], + "edges": [ + { "fromId": "n1", "toId": "n0" }, + { "fromId": "n0", "toId": "n2" }, + { "fromId": "n2", "toId": "n3" }, + { "fromId": "n3", "toId": "n4" } + ], + "append": { + "fromNodeId": "n3", + "toNodeId": "n4", + "stepX": 0.16, + "stepY": 0.08 + } + }, + { + "id": "lyra", + "name": "Lyra", + "starCount": 5, + "leadNodeId": "n0", + "nodes": [ + { "id": "n0", "x": 0.64, "y": 0.1 }, + { "id": "n1", "x": 0.28, "y": 0.32 }, + { "id": "n2", "x": 0.68, "y": 0.46 }, + { "id": "n3", "x": 0.3, "y": 0.76 }, + { "id": "n4", "x": 0.78, "y": 0.88 } + ], + "edges": [ + { "fromId": "n0", "toId": "n1" }, + { "fromId": "n1", "toId": "n2" }, + { "fromId": "n2", "toId": "n3" }, + { "fromId": "n2", "toId": "n4" } + ], + "append": { + "fromNodeId": "n2", + "toNodeId": "n4", + "stepX": 0.12, + "stepY": 0.1 + } + }, + { + "id": "delphinus", + "name": "Delphinus", + "starCount": 5, + "leadNodeId": "n0", + "nodes": [ + { "id": "n0", "x": 0.52, "y": 0.1 }, + { "id": "n1", "x": 0.18, "y": 0.4 }, + { "id": "n2", "x": 0.5, "y": 0.5 }, + { "id": "n3", "x": 0.82, "y": 0.42 }, + { "id": "n4", "x": 0.64, "y": 0.86 } + ], + "edges": [ + { "fromId": "n0", "toId": "n1" }, + { "fromId": "n1", "toId": "n2" }, + { "fromId": "n2", "toId": "n3" }, + { "fromId": "n3", "toId": "n0" }, + { "fromId": "n2", "toId": "n4" } + ], + "append": { + "fromNodeId": "n2", + "toNodeId": "n4", + "stepX": 0.08, + "stepY": 0.16 + } + }, + { + "id": "orion", + "name": "Orion", + "starCount": 7, + "leadNodeId": "n0", + "nodes": [ + { "id": "n0", "x": 0.24, "y": 0.1 }, + { "id": "n1", "x": 0.76, "y": 0.18 }, + { "id": "n2", "x": 0.4, "y": 0.36 }, + { "id": "n3", "x": 0.5, "y": 0.48 }, + { "id": "n4", "x": 0.6, "y": 0.58 }, + { "id": "n5", "x": 0.28, "y": 0.82 }, + { "id": "n6", "x": 0.78, "y": 0.9 } + ], + "edges": [ + { "fromId": "n0", "toId": "n2" }, + { "fromId": "n1", "toId": "n4" }, + { "fromId": "n2", "toId": "n3" }, + { "fromId": "n3", "toId": "n4" }, + { "fromId": "n2", "toId": "n5" }, + { "fromId": "n4", "toId": "n6" } + ], + "append": { + "fromNodeId": "n4", + "toNodeId": "n6", + "stepX": 0.12, + "stepY": 0.12 + } + }, + { + "id": "aquila", + "name": "Aquila", + "starCount": 8, + "leadNodeId": "n0", + "nodes": [ + { "id": "n0", "x": 0.5, "y": 0.08 }, + { "id": "n1", "x": 0.16, "y": 0.24 }, + { "id": "n2", "x": 0.34, "y": 0.34 }, + { "id": "n3", "x": 0.66, "y": 0.38 }, + { "id": "n4", "x": 0.86, "y": 0.24 }, + { "id": "n5", "x": 0.42, "y": 0.58 }, + { "id": "n6", "x": 0.56, "y": 0.72 }, + { "id": "n7", "x": 0.68, "y": 0.92 } + ], + "edges": [ + { "fromId": "n1", "toId": "n2" }, + { "fromId": "n2", "toId": "n0" }, + { "fromId": "n0", "toId": "n3" }, + { "fromId": "n3", "toId": "n4" }, + { "fromId": "n2", "toId": "n5" }, + { "fromId": "n5", "toId": "n6" }, + { "fromId": "n6", "toId": "n7" }, + { "fromId": "n3", "toId": "n6" } + ], + "append": { + "fromNodeId": "n6", + "toNodeId": "n7", + "stepX": 0.1, + "stepY": 0.14 + } + }, + { + "id": "cepheus", + "name": "Cepheus", + "starCount": 8, + "leadNodeId": "n0", + "nodes": [ + { "id": "n0", "x": 0.56, "y": 0.08 }, + { "id": "n1", "x": 0.28, "y": 0.28 }, + { "id": "n2", "x": 0.76, "y": 0.28 }, + { "id": "n3", "x": 0.12, "y": 0.54 }, + { "id": "n4", "x": 0.68, "y": 0.56 }, + { "id": "n5", "x": 0.3, "y": 0.82 }, + { "id": "n6", "x": 0.58, "y": 0.84 }, + { "id": "n7", "x": 0.94, "y": 0.58 } + ], + "edges": [ + { "fromId": "n1", "toId": "n0" }, + { "fromId": "n0", "toId": "n2" }, + { "fromId": "n1", "toId": "n3" }, + { "fromId": "n3", "toId": "n5" }, + { "fromId": "n5", "toId": "n6" }, + { "fromId": "n6", "toId": "n4" }, + { "fromId": "n4", "toId": "n2" }, + { "fromId": "n4", "toId": "n7" } + ], + "append": { + "fromNodeId": "n4", + "toNodeId": "n7", + "stepX": 0.14, + "stepY": 0.04 + } + }, + { + "id": "cygnus", + "name": "Cygnus", + "starCount": 9, + "leadNodeId": "n0", + "nodes": [ + { "id": "n0", "x": 0.5, "y": 0.06 }, + { "id": "n1", "x": 0.5, "y": 0.24 }, + { "id": "n2", "x": 0.5, "y": 0.44 }, + { "id": "n3", "x": 0.5, "y": 0.68 }, + { "id": "n4", "x": 0.5, "y": 0.92 }, + { "id": "n5", "x": 0.18, "y": 0.42 }, + { "id": "n6", "x": 0.32, "y": 0.5 }, + { "id": "n7", "x": 0.68, "y": 0.5 }, + { "id": "n8", "x": 0.82, "y": 0.42 } + ], + "edges": [ + { "fromId": "n0", "toId": "n1" }, + { "fromId": "n1", "toId": "n2" }, + { "fromId": "n2", "toId": "n3" }, + { "fromId": "n3", "toId": "n4" }, + { "fromId": "n5", "toId": "n6" }, + { "fromId": "n6", "toId": "n2" }, + { "fromId": "n2", "toId": "n7" }, + { "fromId": "n7", "toId": "n8" } + ], + "append": { + "fromNodeId": "n3", + "toNodeId": "n4", + "stepX": 0, + "stepY": 0.14 + } + }, + { + "id": "andromeda", + "name": "Andromeda", + "starCount": 9, + "leadNodeId": "n0", + "nodes": [ + { "id": "n0", "x": 0.18, "y": 0.18 }, + { "id": "n1", "x": 0.34, "y": 0.3 }, + { "id": "n2", "x": 0.5, "y": 0.42 }, + { "id": "n3", "x": 0.68, "y": 0.54 }, + { "id": "n4", "x": 0.88, "y": 0.68 }, + { "id": "n5", "x": 0.32, "y": 0.56 }, + { "id": "n6", "x": 0.44, "y": 0.72 }, + { "id": "n7", "x": 0.62, "y": 0.84 }, + { "id": "n8", "x": 0.82, "y": 0.92 } + ], + "edges": [ + { "fromId": "n0", "toId": "n1" }, + { "fromId": "n1", "toId": "n2" }, + { "fromId": "n2", "toId": "n3" }, + { "fromId": "n3", "toId": "n4" }, + { "fromId": "n1", "toId": "n5" }, + { "fromId": "n5", "toId": "n6" }, + { "fromId": "n6", "toId": "n7" }, + { "fromId": "n7", "toId": "n8" } + ], + "append": { + "fromNodeId": "n7", + "toNodeId": "n8", + "stepX": 0.12, + "stepY": 0.08 + } + }, + { + "id": "perseus", + "name": "Perseus", + "starCount": 7, + "leadNodeId": "n0", + "nodes": [ + { "id": "n0", "x": 0.46, "y": 0.46 }, + { "id": "n1", "x": 0.10, "y": 0.14 }, + { "id": "n2", "x": 0.28, "y": 0.20 }, + { "id": "n3", "x": 0.50, "y": 0.14 }, + { "id": "n4", "x": 0.70, "y": 0.22 }, + { "id": "n5", "x": 0.78, "y": 0.54 }, + { "id": "n6", "x": 0.64, "y": 0.80 } + ], + "edges": [ + { "fromId": "n1", "toId": "n2" }, + { "fromId": "n2", "toId": "n3" }, + { "fromId": "n3", "toId": "n4" }, + { "fromId": "n2", "toId": "n0" }, + { "fromId": "n4", "toId": "n0" }, + { "fromId": "n0", "toId": "n5" }, + { "fromId": "n5", "toId": "n6" } + ], + "append": { + "fromNodeId": "n5", + "toNodeId": "n6", + "stepX": -0.10, + "stepY": 0.14 + } + }, + { + "id": "bootes", + "name": "Boötes", + "starCount": 7, + "leadNodeId": "n0", + "nodes": [ + { "id": "n0", "x": 0.50, "y": 0.90 }, + { "id": "n1", "x": 0.50, "y": 0.60 }, + { "id": "n2", "x": 0.30, "y": 0.76 }, + { "id": "n3", "x": 0.28, "y": 0.28 }, + { "id": "n4", "x": 0.50, "y": 0.08 }, + { "id": "n5", "x": 0.72, "y": 0.28 }, + { "id": "n6", "x": 0.12, "y": 0.48 } + ], + "edges": [ + { "fromId": "n0", "toId": "n2" }, + { "fromId": "n2", "toId": "n1" }, + { "fromId": "n1", "toId": "n0" }, + { "fromId": "n1", "toId": "n3" }, + { "fromId": "n3", "toId": "n4" }, + { "fromId": "n4", "toId": "n5" }, + { "fromId": "n5", "toId": "n1" }, + { "fromId": "n3", "toId": "n6" } + ], + "append": { + "fromNodeId": "n3", + "toNodeId": "n6", + "stepX": -0.12, + "stepY": 0.10 + } + }, + { + "id": "castor", + "name": "Castor System", + "starCount": 6, + "leadNodeId": "n0", + "nodes": [ + { "id": "n0", "x": 0.38, "y": 0.14 }, + { "id": "n1", "x": 0.58, "y": 0.22 }, + { "id": "n2", "x": 0.14, "y": 0.50 }, + { "id": "n3", "x": 0.34, "y": 0.62 }, + { "id": "n4", "x": 0.66, "y": 0.72 }, + { "id": "n5", "x": 0.84, "y": 0.58 } + ], + "edges": [ + { "fromId": "n0", "toId": "n1" }, + { "fromId": "n2", "toId": "n3" }, + { "fromId": "n4", "toId": "n5" }, + { "fromId": "n0", "toId": "n2" }, + { "fromId": "n1", "toId": "n4" }, + { "fromId": "n3", "toId": "n4" } + ], + "append": { + "fromNodeId": "n4", + "toNodeId": "n5", + "stepX": 0.12, + "stepY": -0.10 + } + }, + { + "id": "cetus", + "name": "Cetus", + "starCount": 14, + "leadNodeId": "n0", + "nodes": [ + { "id": "n0", "x": 0.18, "y": 0.10 }, + { "id": "n1", "x": 0.06, "y": 0.26 }, + { "id": "n2", "x": 0.24, "y": 0.36 }, + { "id": "n3", "x": 0.38, "y": 0.22 }, + { "id": "n4", "x": 0.44, "y": 0.36 }, + { "id": "n5", "x": 0.54, "y": 0.46 }, + { "id": "n6", "x": 0.62, "y": 0.28 }, + { "id": "n7", "x": 0.76, "y": 0.38 }, + { "id": "n8", "x": 0.88, "y": 0.52 }, + { "id": "n9", "x": 0.78, "y": 0.66 }, + { "id": "n10", "x": 0.60, "y": 0.72 }, + { "id": "n11", "x": 0.48, "y": 0.80 }, + { "id": "n12", "x": 0.36, "y": 0.90 }, + { "id": "n13", "x": 0.52, "y": 0.94 } + ], + "edges": [ + { "fromId": "n1", "toId": "n0" }, + { "fromId": "n0", "toId": "n3" }, + { "fromId": "n3", "toId": "n2" }, + { "fromId": "n2", "toId": "n1" }, + { "fromId": "n3", "toId": "n4" }, + { "fromId": "n4", "toId": "n5" }, + { "fromId": "n5", "toId": "n6" }, + { "fromId": "n6", "toId": "n7" }, + { "fromId": "n7", "toId": "n8" }, + { "fromId": "n8", "toId": "n9" }, + { "fromId": "n9", "toId": "n10" }, + { "fromId": "n5", "toId": "n10" }, + { "fromId": "n10", "toId": "n11" }, + { "fromId": "n11", "toId": "n12" }, + { "fromId": "n12", "toId": "n13" } + ], + "append": { + "fromNodeId": "n12", + "toNodeId": "n13", + "stepX": 0.10, + "stepY": 0.08 + } + }, + { + "id": "ophiuchus", + "name": "Ophiuchus", + "starCount": 13, + "leadNodeId": "n0", + "nodes": [ + { "id": "n0", "x": 0.50, "y": 0.06 }, + { "id": "n1", "x": 0.72, "y": 0.20 }, + { "id": "n2", "x": 0.28, "y": 0.18 }, + { "id": "n3", "x": 0.68, "y": 0.40 }, + { "id": "n4", "x": 0.32, "y": 0.38 }, + { "id": "n5", "x": 0.50, "y": 0.50 }, + { "id": "n6", "x": 0.64, "y": 0.62 }, + { "id": "n7", "x": 0.36, "y": 0.60 }, + { "id": "n8", "x": 0.74, "y": 0.74 }, + { "id": "n9", "x": 0.24, "y": 0.74 }, + { "id": "n10", "x": 0.78, "y": 0.88 }, + { "id": "n11", "x": 0.18, "y": 0.88 }, + { "id": "n12", "x": 0.50, "y": 0.94 } + ], + "edges": [ + { "fromId": "n0", "toId": "n1" }, + { "fromId": "n0", "toId": "n2" }, + { "fromId": "n1", "toId": "n3" }, + { "fromId": "n2", "toId": "n4" }, + { "fromId": "n3", "toId": "n5" }, + { "fromId": "n4", "toId": "n5" }, + { "fromId": "n5", "toId": "n6" }, + { "fromId": "n5", "toId": "n7" }, + { "fromId": "n6", "toId": "n8" }, + { "fromId": "n7", "toId": "n9" }, + { "fromId": "n8", "toId": "n10" }, + { "fromId": "n9", "toId": "n11" }, + { "fromId": "n10", "toId": "n12" }, + { "fromId": "n11", "toId": "n12" } + ], + "append": { + "fromNodeId": "n10", + "toNodeId": "n12", + "stepX": 0.0, + "stepY": 0.10 + } + } +] diff --git a/app/data/officer-teams.json b/app/data/officer-teams.json new file mode 100644 index 0000000..b13fc9b --- /dev/null +++ b/app/data/officer-teams.json @@ -0,0 +1,407 @@ +[ + { + "id": "marketing", + "label": "Marketing", + "order": 0, + "templateId": "cetus", + "lead": { + "id": "marketing-lead", + "name": "Dhivyesh Prithiviraj", + "role": "Team Lead", + "linkedinUrl": "https://www.linkedin.com/in/dhivyeshprithiviraj/", + "quote": "Building the story behind HackUTD one campaign at a time.", + "imageUrl": "" + }, + "members": [ + { + "id": "marketing-1", + "name": "Maya Patel", + "role": "Content Strategy", + "linkedinUrl": "https://www.linkedin.com/in/maya-patel-hackutd", + "quote": "Good content makes people feel something.", + "imageUrl": "" + }, + { + "id": "marketing-2", + "name": "Ethan Nguyen", + "role": "Brand Design", + "linkedinUrl": "https://www.linkedin.com/in/ethan-nguyen-hackutd", + "quote": "Design is the silent ambassador of your brand.", + "imageUrl": "" + }, + { + "id": "marketing-3", + "name": "Sofia Ramirez", + "role": "Social Media", + "linkedinUrl": "https://www.linkedin.com/in/sofia-ramirez-hackutd", + "quote": "Every post is a chance to spark curiosity.", + "imageUrl": "" + }, + { + "id": "marketing-4", + "name": "Jordan Kim", + "role": "Photo + Video", + "linkedinUrl": "https://www.linkedin.com/in/jordan-kim-hackutd", + "quote": "A frame can capture a feeling words can't.", + "imageUrl": "" + }, + { + "id": "marketing-5", + "name": "Avery Brooks", + "role": "Copywriting", + "linkedinUrl": "https://www.linkedin.com/in/avery-brooks-hackutd", + "quote": "Words are the architecture of ideas.", + "imageUrl": "" + }, + { + "id": "marketing-6", + "name": "Maya Patel", + "role": "Content Strategy", + "linkedinUrl": "https://www.linkedin.com/in/maya-patel-hackutd", + "quote": "Good content makes people feel something.", + "imageUrl": "" + }, + { + "id": "marketing-7", + "name": "Maya Patel", + "role": "Content Strategy", + "linkedinUrl": "https://www.linkedin.com/in/maya-patel-hackutd", + "quote": "Good content makes people feel something.", + "imageUrl": "" + }, + { + "id": "marketing-8", + "name": "Maya Patel", + "role": "Content Strategy", + "linkedinUrl": "https://www.linkedin.com/in/maya-patel-hackutd", + "quote": "Good content makes people feel something.", + "imageUrl": "" + }, + { + "id": "marketing-9", + "name": "Maya Patel", + "role": "Content Strategy", + "linkedinUrl": "https://www.linkedin.com/in/maya-patel-hackutd", + "quote": "Good content makes people feel something.", + "imageUrl": "" + }, + { + "id": "marketing-10", + "name": "Maya Patel", + "role": "Content Strategy", + "linkedinUrl": "https://www.linkedin.com/in/maya-patel-hackutd", + "quote": "Good content makes people feel something.", + "imageUrl": "" + }, + { + "id": "marketing-11", + "name": "Maya Patel", + "role": "Content Strategy", + "linkedinUrl": "https://www.linkedin.com/in/maya-patel-hackutd", + "quote": "Good content makes people feel something.", + "imageUrl": "" + }, + { + "id": "marketing-12", + "name": "Maya Patel", + "role": "Content Strategy", + "linkedinUrl": "https://www.linkedin.com/in/maya-patel-hackutd", + "quote": "Good content makes people feel something.", + "imageUrl": "" + }, + { + "id": "marketing-13", + "name": "Maya Patel", + "role": "Content Strategy", + "linkedinUrl": "https://www.linkedin.com/in/maya-patel-hackutd", + "quote": "Good content makes people feel something.", + "imageUrl": "" + } + ], + "description": "Crafting the visual identity and story of HackUTD — from social campaigns to brand design, they make sure the world knows we exist." + }, + { + "id": "tech", + "label": "Tech", + "order": 1, + "templateId": "orion", + "lead": { + "id": "tech-lead", + "name": "Caleb Bae", + "role": "Team Lead", + "linkedinUrl": "https://www.linkedin.com/in/baecal000/", + "quote": "The best infrastructure is the kind nobody notices.", + "imageUrl": "" + }, + "members": [ + { + "id": "tech-1", + "name": "Aiden Williams", + "role": "Platform Engineering", + "linkedinUrl": "https://www.linkedin.com/in/aiden-williams-hackutd", + "quote": "Scalability isn't an afterthought — it's a mindset.", + "imageUrl": "" + }, + { + "id": "tech-2", + "name": "Priya Shah", + "role": "Infra + DevOps", + "linkedinUrl": "https://www.linkedin.com/in/priya-shah-hackutd", + "quote": "Automate the boring so humans can do the interesting.", + "imageUrl": "" + }, + { + "id": "tech-3", + "name": "Lucas Martin", + "role": "Registration Systems", + "linkedinUrl": "https://www.linkedin.com/in/lucas-martin-hackutd", + "quote": "A smooth check-in sets the tone for everything after.", + "imageUrl": "" + }, + { + "id": "tech-4", + "name": "Grace Lee", + "role": "Website Experience", + "linkedinUrl": "https://www.linkedin.com/in/grace-lee-hackutd", + "quote": "The website is every hacker's first impression of us.", + "imageUrl": "" + }, + { + "id": "tech-5", + "name": "Mateo Garcia", + "role": "Internal Tooling", + "linkedinUrl": "https://www.linkedin.com/in/mateo-garcia-hackutd", + "quote": "Tools that work quietly keep teams moving fast.", + "imageUrl": "" + }, + { + "id": "tech-6", + "name": "Chloe Nguyen", + "role": "Security + Reliability", + "linkedinUrl": "https://www.linkedin.com/in/chloe-nguyen-hackutd", + "quote": "Security is a feature, not a fix.", + "imageUrl": "" + } + ], + "description": "Building the platform that powers HackUTD — registration, infrastructure, and every line of code that keeps 1,000+ hackers online." + }, + { + "id": "industry", + "label": "Industry", + "order": 2, + "templateId": "perseus", + "lead": { + "id": "industry-lead", + "name": "Sachi Hansalia", + "role": "Team Lead", + "linkedinUrl": "https://www.linkedin.com/in/sachi-hansalia/", + "quote": "Great partnerships start with a genuine conversation.", + "imageUrl": "" + }, + "members": [ + { + "id": "industry-1", + "name": "Ryan Park", + "role": "Sponsor Outreach", + "linkedinUrl": "https://www.linkedin.com/in/ryan-park-hackutd", + "quote": "Every cold email is a door waiting to open.", + "imageUrl": "" + }, + { + "id": "industry-2", + "name": "Anika Singh", + "role": "Partner Success", + "linkedinUrl": "https://www.linkedin.com/in/anika-singh-hackutd", + "quote": "Success is when sponsors come back year after year.", + "imageUrl": "" + }, + { + "id": "industry-3", + "name": "Daniel Scott", + "role": "Relationships", + "linkedinUrl": "https://www.linkedin.com/in/daniel-scott-hackutd", + "quote": "Long-term relationships outlast any single event.", + "imageUrl": "" + }, + { + "id": "industry-4", + "name": "Harper Wilson", + "role": "Prospecting", + "linkedinUrl": "https://www.linkedin.com/in/harper-wilson-hackutd", + "quote": "Find the companies that believe in what we build.", + "imageUrl": "" + }, + { + "id": "industry-5", + "name": "Kevin Flores", + "role": "Deliverables", + "linkedinUrl": "https://www.linkedin.com/in/kevin-flores-hackutd", + "quote": "Under-promise, over-deliver — every single time.", + "imageUrl": "" + }, + { + "id": "industry-6", + "name": "Isabella Moore", + "role": "Stewardship", + "linkedinUrl": "https://www.linkedin.com/in/isabella-moore-hackutd", + "quote": "Gratitude is how you turn a sponsor into a partner.", + "imageUrl": "" + } + ], + "description": "The bridge between HackUTD and the companies that believe in what we build. They turn cold outreach into lasting partnerships." + }, + { + "id": "experience", + "label": "Experience", + "order": 3, + "templateId": "bootes", + "lead": { + "id": "experience-lead", + "name": "Liana Forster", + "role": "Team Lead", + "linkedinUrl": "https://www.linkedin.com/in/liana-forster/", + "quote": "The best hackathon is the one people can't stop talking about.", + "imageUrl": "" + }, + "members": [ + { + "id": "experience-1", + "name": "Zoe Hernandez", + "role": "Operations", + "linkedinUrl": "https://www.linkedin.com/in/zoe-hernandez-hackutd", + "quote": "Chaos is just a plan that hasn't been written yet.", + "imageUrl": "" + }, + { + "id": "experience-2", + "name": "Adam Walker", + "role": "Volunteer Coordination", + "linkedinUrl": "https://www.linkedin.com/in/adam-walker-hackutd", + "quote": "Volunteers are the heartbeat of every event.", + "imageUrl": "" + }, + { + "id": "experience-3", + "name": "Nina Robinson", + "role": "Check-In Flow", + "linkedinUrl": "https://www.linkedin.com/in/nina-robinson-hackutd", + "quote": "First impressions are made at the door.", + "imageUrl": "" + }, + { + "id": "experience-4", + "name": "Tyler Hall", + "role": "Venue Logistics", + "linkedinUrl": "https://www.linkedin.com/in/tyler-hall-hackutd", + "quote": "The space shapes the experience.", + "imageUrl": "" + }, + { + "id": "experience-5", + "name": "Claire Lewis", + "role": "Swag + Merch", + "linkedinUrl": "https://www.linkedin.com/in/claire-lewis-hackutd", + "quote": "Good swag is something you actually want to keep.", + "imageUrl": "" + }, + { + "id": "experience-6", + "name": "Isaac Young", + "role": "Food + Hospitality", + "linkedinUrl": "https://www.linkedin.com/in/isaac-young-hackutd", + "quote": "Feed the hackers, fuel the ideas.", + "imageUrl": "" + } + ], + "description": "Turning 24 hours into an unforgettable experience — from the moment hackers walk in to the moment they leave inspired." + }, + { + "id": "logistics", + "label": "Logistics", + "order": 4, + "templateId": "castor", + "lead": { + "id": "logistics-lead", + "name": "Sofia Thomas", + "role": "Team Lead", + "linkedinUrl": "https://www.linkedin.com/in/sofia-thomas-82055832b/", + "quote": "Coming soon.", + "imageUrl": "" + }, + "members": [ + { + "id": "logistics-1", + "name": "TBD", + "role": "TBD", + "linkedinUrl": "https://www.linkedin.com/in/", + "quote": "Coming soon.", + "imageUrl": "" + }, + { + "id": "logistics-2", + "name": "TBD", + "role": "TBD", + "linkedinUrl": "https://www.linkedin.com/in/", + "quote": "Coming soon.", + "imageUrl": "" + }, + { + "id": "logistics-3", + "name": "TBD", + "role": "TBD", + "linkedinUrl": "https://www.linkedin.com/in/", + "quote": "Coming soon.", + "imageUrl": "" + }, + { + "id": "logistics-4", + "name": "TBD", + "role": "TBD", + "linkedinUrl": "https://www.linkedin.com/in/", + "quote": "Coming soon.", + "imageUrl": "" + }, + { + "id": "logistics-5", + "name": "TBD", + "role": "TBD", + "linkedinUrl": "https://www.linkedin.com/in/", + "quote": "Coming soon.", + "imageUrl": "" + } + ], + "description": "The backbone of the event. They handle the details nobody sees so that everything everyone does see runs flawlessly." + }, + { + "id": "finance", + "label": "Finance", + "order": 5, + "lead": { + "id": "finance-lead", + "name": "Aatish Bommisetty", + "role": "Team Lead", + "linkedinUrl": "https://www.linkedin.com/in/aatishbommisetty/", + "quote": "Every dollar we spend is a decision about our priorities.", + "imageUrl": "" + }, + "members": [ + { + "id": "finance-1", + "name": "Caleb Adams", + "role": "Budgeting", + "linkedinUrl": "https://www.linkedin.com/in/caleb-adams-hackutd", + "quote": "A budget is your vision translated into numbers.", + "imageUrl": "" + }, + { + "id": "finance-2", + "name": "Sarah Murphy", + "role": "Procurement", + "linkedinUrl": "https://www.linkedin.com/in/sarah-murphy-hackutd", + "quote": "Getting the right things at the right price is an art.", + "imageUrl": "" + } + ], + "description": "Keeping HackUTD financially sound — from budget planning to vendor payments, they make sure every dollar serves a purpose." + } +] diff --git a/app/data/teams.ts b/app/data/teams.ts deleted file mode 100644 index 3e62e81..0000000 --- a/app/data/teams.ts +++ /dev/null @@ -1 +0,0 @@ -export const teams = ["Marketing", "Tech"]; diff --git a/app/globals.css b/app/globals.css index 8b2cfb1..e7cb4cf 100644 --- a/app/globals.css +++ b/app/globals.css @@ -40,6 +40,23 @@ body { } } +@keyframes constellation-lead-pulse { + 0%, + 100% { + transform: scale(1); + box-shadow: + 0 0 0 4px rgba(243, 22, 103, 0.16), + 0 0 16px rgba(243, 22, 103, 0.42); + } + + 50% { + transform: scale(1.12); + box-shadow: + 0 0 0 8px rgba(243, 22, 103, 0.08), + 0 0 30px rgba(243, 22, 103, 0.68); + } +} + .hero-star { box-shadow: 0 0 8px rgba(255, 162, 31, 0.45); clip-path: polygon( @@ -55,10 +72,18 @@ body { animation: hero-star-twinkle 4s ease-in-out infinite; } +.constellation-lead { + animation: constellation-lead-pulse 4.8s ease-in-out infinite; +} + @media (prefers-reduced-motion: reduce) { .hero-star { animation: none; } + + .constellation-lead { + animation: none; + } } @media (max-width: 767px) { diff --git a/app/hooks/useIsAndroid.ts b/app/hooks/useIsAndroid.ts new file mode 100644 index 0000000..40b8be3 --- /dev/null +++ b/app/hooks/useIsAndroid.ts @@ -0,0 +1,20 @@ +"use client"; + +import { useSyncExternalStore } from "react"; + +function getSnapshot() { + return /android/i.test(navigator.userAgent); +} + +function getServerSnapshot() { + return false; +} + +// No-op subscribe: UA string never changes after mount. +function subscribe(callback: () => void) { + return () => {}; +} + +export function useIsAndroid() { + return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); +} diff --git a/package-lock.json b/package-lock.json index 8f84a5d..4b315b3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -69,7 +69,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1574,7 +1573,6 @@ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1634,7 +1632,6 @@ "integrity": "sha512-rLoGZIf9afaRBYsPUMtvkDWykwXwUPL60HebR4JgTI8mxfFe2cQTu3AGitANp4b9B2QlVru6WzjgB2IzJKiCSA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.58.0", "@typescript-eslint/types": "8.58.0", @@ -2160,7 +2157,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2504,7 +2500,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -3073,7 +3068,6 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3259,7 +3253,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -3838,8 +3831,7 @@ "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.", - "peer": true + "license": "Standard 'no charge' license: https://gsap.com/standard-license." }, "node_modules/has-bigints": { "version": "1.1.0", @@ -5464,7 +5456,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -5474,7 +5465,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -6163,7 +6153,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -6326,7 +6315,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -6602,7 +6590,6 @@ "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" }