diff --git a/client/README.md b/client/README.md index c02209c..bacff64 100644 --- a/client/README.md +++ b/client/README.md @@ -7,6 +7,7 @@ This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next - Axios for data fetching - shadcn/ui for components - lucide for icons +- react-social-icons for social media/brand icons ## Getting Started diff --git a/client/package-lock.json b/client/package-lock.json index f9eb1ea..0f41719 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -15,6 +15,7 @@ "axios": "^1.12.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "framer-motion": "^12.23.24", "is-inside-container": "^1.0.0", "lucide-react": "^0.516.0", "next": "15.4.8", @@ -3677,6 +3678,33 @@ "url": "https://github.com/sponsors/rawify" } }, + "node_modules/framer-motion": { + "version": "12.23.24", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.24.tgz", + "integrity": "sha512-HMi5HRoRCTou+3fb3h9oTLyJGBxHfW+HnNE25tAXOvVx/IvwMHK0cx7IR4a2ZU6sh3IX1Z+4ts32PcYBOqka8w==", + "license": "MIT", + "dependencies": { + "motion-dom": "^12.23.23", + "motion-utils": "^12.23.6", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -4973,6 +5001,21 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/motion-dom": { + "version": "12.23.23", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.23.tgz", + "integrity": "sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA==", + "license": "MIT", + "dependencies": { + "motion-utils": "^12.23.6" + } + }, + "node_modules/motion-utils": { + "version": "12.23.6", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.23.6.tgz", + "integrity": "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", diff --git a/client/package.json b/client/package.json index 548bb50..31f6208 100644 --- a/client/package.json +++ b/client/package.json @@ -25,6 +25,7 @@ "axios": "^1.12.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "framer-motion": "^12.23.24", "is-inside-container": "^1.0.0", "lucide-react": "^0.516.0", "next": "15.4.8", diff --git a/client/public/navbar_arr.png b/client/public/navbar_arr.png new file mode 100644 index 0000000..557f7c1 Binary files /dev/null and b/client/public/navbar_arr.png differ diff --git a/client/src/components/footer/FooterLinkList.tsx b/client/src/components/footer/FooterLinkList.tsx new file mode 100644 index 0000000..f828e8c --- /dev/null +++ b/client/src/components/footer/FooterLinkList.tsx @@ -0,0 +1,77 @@ +"use client"; + +import { ChevronRight } from "lucide-react"; +import Link from "next/link"; +import type { ReactNode } from "react"; +import { useState } from "react"; + +interface FooterLink { + label: string; + href: string; + icon?: ReactNode; +} + +interface FooterLinkListProps { + title: string; + titleIcon: ReactNode; + links: FooterLink[]; + useChevron?: boolean; // If true, uses ChevronRight; if false, uses icon from link +} + +/** + * Reusable footer link list component + * Supports both icon-based links (mainLinks) and chevron-based links (quickLinks) + * Provides consistent hover states and styling + */ +export default function FooterLinkList({ + title, + titleIcon, + links, + useChevron = false, +}: FooterLinkListProps) { + const [isHovered, setIsHovered] = useState(null); + + return ( +
+

+ {titleIcon} + {title} +

+ +
+ ); +} diff --git a/client/src/components/footer/SocialIconButton.tsx b/client/src/components/footer/SocialIconButton.tsx new file mode 100644 index 0000000..336b967 --- /dev/null +++ b/client/src/components/footer/SocialIconButton.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { motion } from "framer-motion"; +import { SocialIcon } from "react-social-icons"; + +import { + MOTION_COLOUR_SOCIAL_BG_HOV_ALPHA, + MOTION_COLOUR_SOCIAL_BORDER_HOV_ALPHA, + SOCIAL_ICON_HOVER_SCALE, + SOCIAL_ICON_HOVER_Y, + SOCIAL_ICON_ROTATE_DEGREES, + SOCIAL_ICON_SPRING_DAMPING, + SOCIAL_ICON_SPRING_STIFFNESS, + SOCIAL_ICON_TAP_SCALE, +} from "@/lib/footer-constants"; +import { cssVarAsHSL } from "@/lib/utils"; + +interface SocialIconButtonProps { + url: string; + label: string; + motionColours: { + socialBGHov: string; + socialBorderHov: string; + }; +} + +/** + * Reusable social media icon button component + * Handles hover animations and styling with Motion values for colors + */ +export default function SocialIconButton({ + url, + label, + motionColours, +}: SocialIconButtonProps) { + return ( + + + + + + ); +} + +/** + * Helper function to create motion colours for social icons + * This ensures colors use Motion values instead of hard-coded rgba + */ +export function createSocialMotionColours() { + return { + socialBGHov: cssVarAsHSL("--light-1", MOTION_COLOUR_SOCIAL_BG_HOV_ALPHA), + socialBorderHov: cssVarAsHSL( + "--light-alt", + MOTION_COLOUR_SOCIAL_BORDER_HOV_ALPHA, + ), + }; +} diff --git a/client/src/components/main/Footer.tsx b/client/src/components/main/Footer.tsx new file mode 100644 index 0000000..427ac53 --- /dev/null +++ b/client/src/components/main/Footer.tsx @@ -0,0 +1,527 @@ +"use client"; +import { + motion, + MotionValue, + useMotionTemplate, + useMotionValue, + useSpring, + useTransform, +} from "framer-motion"; +import { Code2, Gamepad2, Heart, Sparkles, Zap } from "lucide-react"; +import Image from "next/image"; +import Link from "next/link"; +import { useEffect, useRef, useState } from "react"; + +import FooterLinkList from "@/components/footer/FooterLinkList"; +import SocialIconButton, { + createSocialMotionColours, +} from "@/components/footer/SocialIconButton"; +import { + DEFAULT_FOOTER_HEIGHT, + DEFAULT_FOOTER_WIDTH, + GRADIENT_CIRCLE_SIZE, + GRADIENT_OPACITY_DEFAULT, + GRADIENT_OPACITY_HOVERING, + GRADIENT_TRANSITION_DURATION, + MOTION_COLOUR_MOUSE_GRAD_END_ALPHA, + MOTION_COLOUR_MOUSE_GRAD_START_ALPHA, + MOUSE_CENTER_OFFSET, + MOUSE_OFFSET_MULTIPLIER, + NETWORK_CONNECTION_DISTANCE, + NETWORK_CONNECTION_MAX_PER_PARTICLE, + NETWORK_CONNECTION_OPACITY_BASE, + NETWORK_CONNECTION_OPACITY_MULTIPLIER, + NETWORK_LINE_WIDTH, + NETWORK_MOUSE_CONNECTION_DISTANCE, + NETWORK_MOUSE_CONNECTION_OPACITY_BASE, + NETWORK_MOUSE_CONNECTION_OPACITY_MULTIPLIER, + NETWORK_MOUSE_LINE_WIDTH, + NETWORK_PARTICLE_SIZE_MAX, + NETWORK_PARTICLE_SIZE_MIN, + NETWORK_PARTICLE_VELOCITY_MULTIPLIER, + PARTICLE_COLOUR_ALPHA_LIGHT_1, + PARTICLE_COLOUR_ALPHA_LIGHT_2, + PARTICLE_COLOUR_ALPHA_LIGHT_ALT, + PARTICLE_COUNT, + PARTICLE_DEFAULT_ALPHA, + PARTICLE_DELAY_MAX, + PARTICLE_DURATION_MAX, + PARTICLE_DURATION_MIN, + PARTICLE_SIZE_MAX, + PARTICLE_SIZE_MIN, + SPRING_DAMPING, + SPRING_STIFFNESS, +} from "@/lib/footer-constants"; +import { cssVarAsHSL } from "@/lib/utils"; +import { + mainLinks, + quickLinks, + type SocialLink, + socialLinks, +} from "@/static-data/footer-data"; + +// Type definitions for particle system +type ParticleConfig = { + baseX: number; + baseY: number; + size: number; + delay: number; + duration: number; + color?: string; +}; +type NetworkParticle = { + x: number; + y: number; + vx: number; + vy: number; + size: number; +}; + +// Gradient that follows mouse cursor position for interactive effect +function MouseGradient({ + smoothX, + smoothY, + isHovering, + motionColours, +}: { + smoothX: MotionValue; + smoothY: MotionValue; + isHovering: boolean; + motionColours: { + mouseGradStart: string; + mouseGradEnd: string; + }; +}) { + // Use Motion values for colors instead of hard-coded rgba + const background = useMotionTemplate`radial-gradient(circle ${GRADIENT_CIRCLE_SIZE}px at ${smoothX}% ${smoothY}%, ${motionColours.mouseGradStart} 0%, ${motionColours.mouseGradEnd} 40%, transparent 70%)`; + return ( + + ); +} + +// Individual particle component with pulsing animation and mouse interaction +function SimpleParticle({ + baseX, + baseY, + size, + delay, + duration, + smoothX, + smoothY, + isHovering, + color = cssVarAsHSL("--light-1", PARTICLE_DEFAULT_ALPHA), +}: ParticleConfig & { + smoothX: MotionValue; + smoothY: MotionValue; + isHovering: boolean; +}) { + // Particles react to mouse movement when hovering + const offsetX = useTransform(smoothX, (mx) => + isHovering ? (mx - MOUSE_CENTER_OFFSET) * MOUSE_OFFSET_MULTIPLIER : 0, + ); + const offsetY = useTransform(smoothY, (my) => + isHovering ? (my - MOUSE_CENTER_OFFSET) * MOUSE_OFFSET_MULTIPLIER : 0, + ); + return ( + + +
+ + + ); +} + +// Canvas-based network visualization with particle connections +function NetworkCanvas({ + width, + height, + smoothX, + smoothY, + isHovering, +}: { + width: number; + height: number; + smoothX: MotionValue; + smoothY: MotionValue; + isHovering: boolean; +}) { + const canvasRef = useRef(null); + const particlesRef = useRef([]); + const initializedRef = useRef(false); + + useEffect(() => { + // Initialize particles only once to prevent regeneration on re-render + if (!initializedRef.current) { + particlesRef.current = Array.from({ length: PARTICLE_COUNT }, () => ({ + x: Math.random() * width, + y: Math.random() * height, + vx: (Math.random() - 0.5) * NETWORK_PARTICLE_VELOCITY_MULTIPLIER, + vy: (Math.random() - 0.5) * NETWORK_PARTICLE_VELOCITY_MULTIPLIER, + size: + NETWORK_PARTICLE_SIZE_MIN + Math.random() * NETWORK_PARTICLE_SIZE_MAX, + })); + initializedRef.current = true; + } + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + let id: number; + + const render = () => { + ctx.clearRect(0, 0, width, height); + const pts = particlesRef.current; + const mx = (smoothX.get() / 100) * width; // Convert percentage to pixel coordinates + const my = (smoothY.get() / 100) * height; + + // Update particle positions with boundary collision + pts.forEach((p) => { + p.x += p.vx; + p.y += p.vy; + if (p.x < 0 || p.x > width) p.vx *= -1; + if (p.y < 0 || p.y > height) p.vy *= -1; + p.x = Math.max(0, Math.min(width, p.x)); + p.y = Math.max(0, Math.min(height, p.y)); + }); + + // Draw connections between nearby particles (max 2 connections per particle) + for (let i = 0; i < pts.length; i++) { + const pA = pts[i]; + const dists = pts + .slice(i + 1) + .map((pB, j) => { + const dx = pA.x - pB.x; + const dy = pA.y - pB.y; + return { index: i + j + 1, dist: Math.sqrt(dx * dx + dy * dy) }; + }) + .filter((d) => d.dist < NETWORK_CONNECTION_DISTANCE) + .sort((a, b) => a.dist - b.dist) + .slice(0, NETWORK_CONNECTION_MAX_PER_PARTICLE); + + dists.forEach(({ index, dist }) => { + const pB = pts[index]; + const op = + (1 - dist / NETWORK_CONNECTION_DISTANCE) * + NETWORK_CONNECTION_OPACITY_BASE; + const grad = ctx.createLinearGradient(pA.x, pA.y, pB.x, pB.y); + grad.addColorStop(0, cssVarAsHSL("--logo-blue-1", op)); + grad.addColorStop( + 0.5, + cssVarAsHSL( + "--light-2", + op * NETWORK_CONNECTION_OPACITY_MULTIPLIER, + ), + ); + grad.addColorStop(1, cssVarAsHSL("--light-alt", op)); + ctx.strokeStyle = grad; + ctx.lineWidth = NETWORK_LINE_WIDTH; + ctx.beginPath(); + ctx.moveTo(pA.x, pA.y); + ctx.lineTo(pB.x, pB.y); + ctx.stroke(); + }); + } + + // Draw connections from particles to mouse cursor when hovering + if (isHovering) { + pts.forEach((p) => { + const dx = p.x - mx; + const dy = p.y - my; + const dist = Math.sqrt(dx * dx + dy * dy); + if (dist < NETWORK_MOUSE_CONNECTION_DISTANCE) { + const op = + (1 - dist / NETWORK_MOUSE_CONNECTION_DISTANCE) * + NETWORK_MOUSE_CONNECTION_OPACITY_BASE; + const grad = ctx.createLinearGradient(p.x, p.y, mx, my); + grad.addColorStop(0, cssVarAsHSL("--light-alt", op)); + grad.addColorStop( + 1, + cssVarAsHSL( + "--light-1", + op * NETWORK_MOUSE_CONNECTION_OPACITY_MULTIPLIER, + ), + ); + ctx.strokeStyle = grad; + ctx.lineWidth = NETWORK_MOUSE_LINE_WIDTH; + ctx.beginPath(); + ctx.moveTo(p.x, p.y); + ctx.lineTo(mx, my); + ctx.stroke(); + } + }); + } + id = requestAnimationFrame(render); + }; + render(); + return () => cancelAnimationFrame(id); + }, [width, height, smoothX, smoothY, isHovering]); + return ( + + ); +} + +export default function Footer() { + const footerRef = useRef(null); + const [isHovering, setIsHovering] = useState(false); + const [dimensions, setDimensions] = useState({ + width: DEFAULT_FOOTER_WIDTH, + height: DEFAULT_FOOTER_HEIGHT, + }); + const [isClient, setIsClient] = useState(false); // Prevent SSR issues with canvas/animations + const [particleConfigs, setParticleConfigs] = useState([]); + + // Mouse position tracking with spring physics for smooth movement + const mouseX = useMotionValue(50); + const mouseY = useMotionValue(50); + const smoothX = useSpring(mouseX, { + damping: SPRING_DAMPING, + stiffness: SPRING_STIFFNESS, + }); + const smoothY = useSpring(mouseY, { + damping: SPRING_DAMPING, + stiffness: SPRING_STIFFNESS, + }); + const motionColoursRef = useRef>({}); + + // Initialize particles on client-side only (prevents hydration mismatch) + useEffect(() => { + setIsClient(true); + // British spelling: particlecolours + const particlecolours = [ + cssVarAsHSL("--light-1", PARTICLE_COLOUR_ALPHA_LIGHT_1), + cssVarAsHSL("--light-2", PARTICLE_COLOUR_ALPHA_LIGHT_2), + cssVarAsHSL("--light-alt", PARTICLE_COLOUR_ALPHA_LIGHT_ALT), + ]; + setParticleConfigs( + Array.from({ length: PARTICLE_COUNT }, () => ({ + baseX: Math.random() * 100, + baseY: Math.random() * 100, + size: PARTICLE_SIZE_MIN + Math.random() * PARTICLE_SIZE_MAX, + delay: Math.random() * PARTICLE_DELAY_MAX, + duration: PARTICLE_DURATION_MIN + Math.random() * PARTICLE_DURATION_MAX, + color: + particlecolours[Math.floor(Math.random() * particlecolours.length)], + })), + ); + // Unfortunately, motion cannot animate named colours. + // One readable solution is to construct a handful of constants on the client instead. + motionColoursRef.current = { + mouseGradStart: cssVarAsHSL( + "--light-alt", + MOTION_COLOUR_MOUSE_GRAD_START_ALPHA, + ), + mouseGradEnd: cssVarAsHSL( + "--light-2", + MOTION_COLOUR_MOUSE_GRAD_END_ALPHA, + ), + ...createSocialMotionColours(), + }; + }, []); + + // Update canvas dimensions on window resize + useEffect(() => { + const update = () => + footerRef.current && + setDimensions({ + width: footerRef.current.offsetWidth, + height: footerRef.current.offsetHeight, + }); + update(); + window.addEventListener("resize", update); + return () => window.removeEventListener("resize", update); + }, []); + + // Convert mouse coordinates to percentage for gradient positioning + const handleMouseMove = (e: React.MouseEvent) => { + const rect = e.currentTarget.getBoundingClientRect(); + mouseX.set(((e.clientX - rect.left) / rect.width) * 100); + mouseY.set(((e.clientY - rect.top) / rect.height) * 100); + setIsHovering(true); + }; + + return ( +
setIsHovering(false)} + > +
+ + {isClient && ( + + )} + {isClient && ( +
+ {particleConfigs.map((cfg, i) => ( + + ))} +
+ )} +
+
+
+
+
+ + CFC Game Development Logo + + + + +
+

+ Game Development +

+

+ Create • Play • Inspire +

+
+
+ + UWAgamedev@gmail.com + +

+ Building the next generation of game developers at UWA game + development club +

+
+ {socialLinks.map((social: SocialLink, index: number) => ( + + ))} +
+
+ } + links={quickLinks} + useChevron + /> + } + links={mainLinks} + /> +
+
+
+
+
+
+ + + +
+
+
+
+ © {new Date().getFullYear()} CFC Game Dev + + All rights reserved +
+ + + Constitution + + +
+ Made with + + + + in Perth, UWA +
+
+
+
+
+ ); +} diff --git a/client/src/lib/footer-constants.ts b/client/src/lib/footer-constants.ts new file mode 100644 index 0000000..81d1004 --- /dev/null +++ b/client/src/lib/footer-constants.ts @@ -0,0 +1,64 @@ +/** + * Constants for Footer component + * Centralized location for magic numbers to improve maintainability + */ + +// Gradient and visual effects +export const GRADIENT_CIRCLE_SIZE = 15; // px - size of radial gradient circle +export const GRADIENT_OPACITY_HOVERING = 0.3; +export const GRADIENT_OPACITY_DEFAULT = 0.2; +export const GRADIENT_TRANSITION_DURATION = 0.5; // seconds + +// Mouse interaction +export const MOUSE_OFFSET_MULTIPLIER = 0.3; // How much particles react to mouse movement +export const MOUSE_CENTER_OFFSET = 50; // Percentage offset for centering calculations + +// Particle system +export const PARTICLE_COUNT = 22; +export const PARTICLE_SIZE_MIN = 2; +export const PARTICLE_SIZE_MAX = 3; +export const PARTICLE_DELAY_MAX = 4; // seconds +export const PARTICLE_DURATION_MIN = 3; // seconds +export const PARTICLE_DURATION_MAX = 3; // seconds (added to min for range) +export const PARTICLE_DEFAULT_ALPHA = 0.6; + +// Particle colour opacities +export const PARTICLE_COLOUR_ALPHA_LIGHT_1 = 0.5; +export const PARTICLE_COLOUR_ALPHA_LIGHT_2 = 0.4; +export const PARTICLE_COLOUR_ALPHA_LIGHT_ALT = 0.4; + +// Network canvas particles +export const NETWORK_PARTICLE_VELOCITY_MULTIPLIER = 0.15; +export const NETWORK_PARTICLE_SIZE_MIN = 1.5; +export const NETWORK_PARTICLE_SIZE_MAX = 1.5; // Added to min for range +export const NETWORK_CONNECTION_DISTANCE = 150; // px - max distance for particle connections +export const NETWORK_CONNECTION_MAX_PER_PARTICLE = 2; +export const NETWORK_CONNECTION_OPACITY_BASE = 0.25; +export const NETWORK_CONNECTION_OPACITY_MULTIPLIER = 1.5; +export const NETWORK_MOUSE_CONNECTION_DISTANCE = 120; // px +export const NETWORK_MOUSE_CONNECTION_OPACITY_BASE = 0.4; +export const NETWORK_MOUSE_CONNECTION_OPACITY_MULTIPLIER = 0.5; +export const NETWORK_LINE_WIDTH = 1.5; +export const NETWORK_MOUSE_LINE_WIDTH = 2; + +// Motion colour opacities +export const MOTION_COLOUR_MOUSE_GRAD_START_ALPHA = 0.3; +export const MOTION_COLOUR_MOUSE_GRAD_END_ALPHA = 0.3; +export const MOTION_COLOUR_SOCIAL_BG_HOV_ALPHA = 0.1; +export const MOTION_COLOUR_SOCIAL_BORDER_HOV_ALPHA = 0.5; + +// Spring physics for mouse tracking +export const SPRING_DAMPING = 50; +export const SPRING_STIFFNESS = 100; + +// Animation values +export const SOCIAL_ICON_HOVER_SCALE = 1.1; +export const SOCIAL_ICON_HOVER_Y = -4; +export const SOCIAL_ICON_TAP_SCALE = 0.95; +export const SOCIAL_ICON_ROTATE_DEGREES = 12; +export const SOCIAL_ICON_SPRING_STIFFNESS = 400; +export const SOCIAL_ICON_SPRING_DAMPING = 17; + +// Default dimensions +export const DEFAULT_FOOTER_WIDTH = 1920; +export const DEFAULT_FOOTER_HEIGHT = 400; diff --git a/client/src/lib/utils.ts b/client/src/lib/utils.ts index 365058c..7450c3b 100644 --- a/client/src/lib/utils.ts +++ b/client/src/lib/utils.ts @@ -4,3 +4,19 @@ import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } + +/** + * Converts a CSS variable to HSL format with optional alpha channel + * Client-side only function - requires window object + * @param cssvar - CSS variable name (e.g., "--light-1") + * @param alpha - Optional alpha value (0-1) + * @returns HSL color string (e.g., "hsl(0 0% 100% / 0.6)") + */ +export function cssVarAsHSL(cssvar: string, alpha?: number): string { + // Client side only + if (typeof window === "undefined") { + return ""; + } + const col = window.getComputedStyle(document.body).getPropertyValue(cssvar); + return alpha !== undefined ? `hsl(${col} / ${alpha})` : `hsl(${col})`; +} diff --git a/client/src/pages/_app.tsx b/client/src/pages/_app.tsx index eb47676..ca3770d 100644 --- a/client/src/pages/_app.tsx +++ b/client/src/pages/_app.tsx @@ -5,6 +5,7 @@ import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; import type { AppProps } from "next/app"; import { Fira_Code, Inter as FontSans, Jersey_10 } from "next/font/google"; +import Footer from "@/components/main/Footer"; import Navbar from "@/components/main/Navbar"; const fontSans = FontSans({ @@ -36,6 +37,7 @@ export default function App({ Component, pageProps }: AppProps) { > +