From de9638eb154f7ff7ff57cb7a1d2a46451d5ff2d5 Mon Sep 17 00:00:00 2001 From: tomas Date: Thu, 30 May 2024 18:14:16 +0100 Subject: [PATCH] add pixel background animation + "join discord" button --- app/layout.tsx | 14 ++- app/page.tsx | 12 ++- components/JoinDiscord.tsx | 150 ++++++++++++++++++++++++++ components/PixelBackground.tsx | 75 +++++++++++++ constants/discord.ts | 1 + context/AnimatedBackgroundContext.tsx | 41 +++++++ package-lock.json | 38 +++++++ package.json | 12 ++- public/discord.svg | 11 ++ tailwind.config.ts | 1 + tsconfig.json | 3 +- utils/prefersReducedMotion.ts | 8 ++ 12 files changed, 355 insertions(+), 11 deletions(-) create mode 100644 components/JoinDiscord.tsx create mode 100644 components/PixelBackground.tsx create mode 100644 constants/discord.ts create mode 100644 context/AnimatedBackgroundContext.tsx create mode 100644 public/discord.svg create mode 100644 utils/prefersReducedMotion.ts diff --git a/app/layout.tsx b/app/layout.tsx index 489fb6fa2..ea07fcb4b 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,13 +1,15 @@ import type { Metadata, Viewport } from 'next' import './globals.css' import Navbar from '@/components/Navbar' +import { prefix } from '@/utils/prefix' +import PixelBackground from '@/components/PixelBackground' export const metadata: Metadata = { title: 'CompSoc', description: "CompSoc is Edinburgh University's technology society! We're Scotland's best and largest of its kind, and form one of the largest societies within the university.", icons: { - icon: '/compsoc-mini.png', + icon: `${prefix}/compsoc-mini.png`, }, } @@ -22,9 +24,13 @@ export default function RootLayout({ }>) { return ( - - -
{children}
+ + + +
+ {children} +
+
) diff --git a/app/page.tsx b/app/page.tsx index 425192eee..f7bb506d3 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,9 +1,13 @@ +'use client' +import JoinDiscord from '@/components/JoinDiscord' +import { useAnimatedBackground } from '@/context/AnimatedBackgroundContext' import { prefix } from '@/utils/prefix' import Image from 'next/image' export default function Home() { + const { toggleBackground } = useAnimatedBackground() return ( -
+
Wide CompSoc logo + +
) } diff --git a/components/JoinDiscord.tsx b/components/JoinDiscord.tsx new file mode 100644 index 000000000..dfef72cdb --- /dev/null +++ b/components/JoinDiscord.tsx @@ -0,0 +1,150 @@ +import { DISCORD_INVITE_URL } from '@/constants/discord' +import { prefix } from '@/utils/prefix' +import Image from 'next/image' +import { motion } from 'framer-motion' +import { FC, useEffect, useState } from 'react' +import prefersReducedMotion from '@/utils/prefersReducedMotion' + +const PARTICLES_COUNT = Array.from({ length: 20 }, (_, i) => ({ + index: i, + x: Math.random() * 500 - 250, + y: Math.random() * 300 - 150, + delay: Math.random() * 2, + duration: 2 + Math.random() * 2, +})) + +const Particle: FC<{ + x: number + y: number + delay: number + duration: number + isHovered: boolean +}> = ({ x, y, delay, duration, isHovered }) => { + const particleVariants = { + hidden: { + opacity: 0, + x: x, + y: y, + duration: 10, + delay: delay, + }, + visible: { + opacity: [0, 0.8, 0], + x: 0, + y: 0, + transition: { + duration: 2, + delay: Math.random() * 2, + repeat: Infinity, + repeatType: 'loop' as const, + }, + }, + hover: { + opacity: [0, 1, 0], + x: 1, + y: 1, + transition: { + duration: duration, + delay: Math.random() * 2, + repeat: Infinity, + repeatType: 'loop' as const, + }, + }, + } + + return ( + + ) +} + +const Particles: FC<{ isHovered: boolean }> = ({ isHovered }) => { + if (prefersReducedMotion()) return null + return ( + <> + {PARTICLES_COUNT.map(({ index, x, y, delay, duration }) => ( + + ))} + + ) +} + +// TODO: If we ever run this website on server, we should cache this value! +const MemberCount: FC = () => { + const [memberCount, setMemberCount] = useState(null) + const [onlineCount, setOnlineCount] = useState(null) + + useEffect(() => { + const fetchMemberCount = async () => { + const INVITE_CODE = DISCORD_INVITE_URL.split('/').pop() + try { + const response = await fetch( + `https://discord.com/api/v9/invites/${INVITE_CODE}?with_counts=true&with_expiration=true` + ) + const data = await response.json() + setMemberCount(data.approximate_member_count) + setOnlineCount(data.approximate_presence_count) + } catch (error) { + console.error('Error fetching member count:', error) + } + } + + fetchMemberCount() + }, []) + + return ( +
+

Join our Discord!

+ {memberCount !== null && ( +

{memberCount} members

+ )} +
+ ) +} + +const JoinDiscord: FC = () => { + const [isHovered, setIsHovered] = useState(false) + + return ( +
+ + setIsHovered(true)} + onHoverEnd={() => setIsHovered(false)}> + {/* + I would use this icon... but its the ugliest thing I've ever seen + + So lets use a random svg + */} + Discord logo + + + +
+ ) +} + +export default JoinDiscord diff --git a/components/PixelBackground.tsx b/components/PixelBackground.tsx new file mode 100644 index 000000000..d4d35bff3 --- /dev/null +++ b/components/PixelBackground.tsx @@ -0,0 +1,75 @@ +'use client' + +import React, { useEffect, useState, ReactNode } from 'react' +import { + AnimatedBackgroundProvider, + useAnimatedBackground, +} from '@/context/AnimatedBackgroundContext' + +const PixelGrid = () => { + const { isActive } = useAnimatedBackground() + const [numPixels, setNumPixels] = useState({ x: 0, y: 0 }) + const [pixels, setPixels] = useState([]) + const [pixelSize, _] = useState(64) // Smaller values are less performant + + const calculatePixels = () => { + const x = Math.ceil(window.innerWidth / pixelSize) + const y = Math.ceil(window.innerHeight / pixelSize) + return { x, y } + } + + const generatePixels = (x: number, y: number) => { + return Array(x * y) + .fill(0) + .map(() => Math.random()) + } + + useEffect(() => { + const { x, y } = calculatePixels() + setNumPixels({ x, y }) + setPixels(generatePixels(x, y)) + + const handleResize = () => { + const { x, y } = calculatePixels() + setNumPixels({ x, y }) + setPixels(generatePixels(x, y)) + } + + window.addEventListener('resize', handleResize) + + return () => { + window.removeEventListener('resize', handleResize) + } + }, []) + + return ( +
+ {pixels.map((delay, index) => ( +
+ ))} +
+ ) +} + +const PixelBackground = ({ children }: { children: ReactNode }) => { + return ( + + + {children} + + ) +} + +export default PixelBackground diff --git a/constants/discord.ts b/constants/discord.ts new file mode 100644 index 000000000..10df1ef39 --- /dev/null +++ b/constants/discord.ts @@ -0,0 +1 @@ +export const DISCORD_INVITE_URL = 'https://discord.gg/fmp7p9Ca4y' diff --git a/context/AnimatedBackgroundContext.tsx b/context/AnimatedBackgroundContext.tsx new file mode 100644 index 000000000..efb53b3e8 --- /dev/null +++ b/context/AnimatedBackgroundContext.tsx @@ -0,0 +1,41 @@ +'use client' +import { createContext, useContext, useState, ReactNode } from 'react' + +interface AnimatedBackgroundContextProps { + isActive: boolean + toggleBackground: () => void +} + +const AnimatedBackgroundContext = createContext< + AnimatedBackgroundContextProps | undefined +>(undefined) + +export const useAnimatedBackground = (): AnimatedBackgroundContextProps => { + const context = useContext(AnimatedBackgroundContext) + if (!context) { + throw new Error( + 'useAnimatedBackground must be used within an AnimatedBackgroundProvider' + ) + } + return context +} + +interface AnimatedBackgroundProviderProps { + children: ReactNode +} + +export const AnimatedBackgroundProvider = ({ + children, +}: AnimatedBackgroundProviderProps) => { + const [isActive, setIsActive] = useState(false) + + const toggleBackground = () => { + setIsActive(!isActive) + } + + return ( + + {children} + + ) +} diff --git a/package-lock.json b/package-lock.json index 6992f6053..738549b61 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,8 @@ "name": "comp-soc.com", "version": "0.1.0", "dependencies": { + "framer-motion": "^11.2.9", + "iconoir-react": "^7.7.0", "next": "14.2.3", "react": "^18", "react-dom": "^18" @@ -2058,6 +2060,30 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/framer-motion": { + "version": "11.2.9", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-11.2.9.tgz", + "integrity": "sha512-gfxNSkp4dC3vpy2hGNQK3K9bNOKwfasqOhrqvmZzYxCPSJ9Tpv/9JlCkeCMgFdKefgPr8+JiouGjVmaDzu750w==", + "dependencies": { + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0", + "react-dom": "^18.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -2375,6 +2401,18 @@ "node": ">= 0.4" } }, + "node_modules/iconoir-react": { + "version": "7.7.0", + "resolved": "https://registry.npmjs.org/iconoir-react/-/iconoir-react-7.7.0.tgz", + "integrity": "sha512-jKwbCZEJ3PtTDzxYga5pe9Jxg5Zvex0lK43DMS0VeHmJkLl+zSHolp6u5vW+hJzSxxxXE0Wy0P87CJBDGj3H7Q==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/iconoir" + }, + "peerDependencies": { + "react": "^16.8.6 || ^17 || ^18" + } + }, "node_modules/ignore": { "version": "5.3.1", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.1.tgz", diff --git a/package.json b/package.json index eeb72d999..3fc320a8e 100644 --- a/package.json +++ b/package.json @@ -9,18 +9,20 @@ "lint": "next lint" }, "dependencies": { + "framer-motion": "^11.2.9", + "iconoir-react": "^7.7.0", + "next": "14.2.3", "react": "^18", - "react-dom": "^18", - "next": "14.2.3" + "react-dom": "^18" }, "devDependencies": { - "typescript": "^5", "@types/node": "^20", "@types/react": "^18", "@types/react-dom": "^18", + "eslint": "^8", + "eslint-config-next": "14.2.3", "postcss": "^8", "tailwindcss": "^3.4.1", - "eslint": "^8", - "eslint-config-next": "14.2.3" + "typescript": "^5" } } diff --git a/public/discord.svg b/public/discord.svg new file mode 100644 index 000000000..1678046c2 --- /dev/null +++ b/public/discord.svg @@ -0,0 +1,11 @@ + + + \ No newline at end of file diff --git a/tailwind.config.ts b/tailwind.config.ts index c95db73cb..eed5a1a8e 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -21,6 +21,7 @@ const config: Config = { border: '#484848', textPrimary: '#FFFFFF', textSecondary: '#A0A0A0', + discordPurple: '#5865F2', }, fontFamily: { 'space-mono': ['"Space Mono"', 'monospace'], diff --git a/tsconfig.json b/tsconfig.json index 1ee948c47..f55b3692e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -21,7 +21,8 @@ "paths": { "@/*": ["./*"], "@components/*": ["components/*"], - "@utils/*": ["utils/*"] + "@utils/*": ["utils/*"], + "@constants/*": ["constants/*"] } }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], diff --git a/utils/prefersReducedMotion.ts b/utils/prefersReducedMotion.ts new file mode 100644 index 000000000..512987bec --- /dev/null +++ b/utils/prefersReducedMotion.ts @@ -0,0 +1,8 @@ +function prefersReducedMotion(): boolean { + if (typeof window !== 'undefined' && window.matchMedia) { + return window.matchMedia('(prefers-reduced-motion: reduce)').matches + } + return false +} + +export default prefersReducedMotion