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 (
-
+
+
+
+ TEST: Toggle background
+
)
}
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
+ */}
+
+
+
+
+
+ )
+}
+
+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