diff --git a/src/components/events/EventCard.tsx b/src/components/events/EventCard.tsx new file mode 100644 index 0000000..830936d --- /dev/null +++ b/src/components/events/EventCard.tsx @@ -0,0 +1,141 @@ +/** + * EventCard Component + * Displays a single event with image, title, date/time/location, and description. + * Accent color is assigned cyclically from the site palette. + */ + +import RevealOnScroll from '../universal/RevealOnScroll'; + +export interface Event { + id: string; + title: string; + description: string; + date: string; // ISO date string e.g. "2026-03-10" + time: string; // Display string e.g. "6:00 PM – 8:00 PM" + location: string; + picture_url: string | null; +} + +interface EventCardProps { + event: Event; + index: number; +} + +const ACCENT_COLORS = [ + { border: '#FF9FC4', shadow: 'rgba(255,159,196,0.25)', tag: 'bg-[#FF9FC4]' }, + { border: '#FF9770', shadow: 'rgba(255,151,112,0.25)', tag: 'bg-[#FF9770]' }, + { border: '#FFD670', shadow: 'rgba(255,214,112,0.25)', tag: 'bg-[#FFD670]' }, + { border: '#268AF9', shadow: 'rgba(38,138,249,0.25)', tag: 'bg-[#268AF9]' }, +]; + +const CalendarIcon = () => ( + + + + + + +); + +const ClockIcon = () => ( + + + + +); + +const PinIcon = () => ( + + + + +); + +/** Formats "2026-03-10" β†’ "March 10, 2026" */ +function formatDate(iso: string): string { + const [year, month, day] = iso.split('-').map(Number); + const d = new Date(year, month - 1, day); + return d.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }); +} + +export function EventCard({ event, index }: EventCardProps) { + const accent = ACCENT_COLORS[index % ACCENT_COLORS.length]; + + return ( + +
+ ((e.currentTarget as HTMLDivElement).style.boxShadow = `0 8px 40px ${accent.shadow}`) + } + onMouseLeave={(e) => + ((e.currentTarget as HTMLDivElement).style.boxShadow = '0 4px 24px rgba(0,0,0,0.3)') + } + > + {/* Accent Top Bar */} +
+ + {/* Event Image */} +
+ {event.picture_url ? ( + {event.title} + ) : ( +
+ πŸ“… +
+ )} +
+ + {/* Content */} +
+ {/* Title */} +

((e.currentTarget as HTMLHeadingElement).style.color = accent.border)} + onMouseLeave={(e) => ((e.currentTarget as HTMLHeadingElement).style.color = 'white')} + > + {event.title} +

+ + {/* Meta info */} +
+ + + {formatDate(event.date)} + + + + {event.time} + + + + {event.location} + +
+ + {/* Description */} +

+ {event.description} +

+
+
+ + ); +} + +export default EventCard; diff --git a/src/components/events/EventDetails.tsx b/src/components/events/EventDetails.tsx deleted file mode 100644 index f969c1d..0000000 --- a/src/components/events/EventDetails.tsx +++ /dev/null @@ -1,106 +0,0 @@ -/** - * EventDetails Component - * Displays event information with dynamic text scaling - */ - -import { useEffect, useState, useRef } from 'react'; -import type { EventData } from '../../data/eventsData'; -import '../styles/eventTransitions.css'; - -const accentColors = ['#FF9FC4', '#FF9770', '#FFD670', '#268AF9', '#B1E0FF']; - -interface EventDetailsProps { - event: EventData; - eventIndex: number; -} - -export default function EventDetails({ event, eventIndex }: EventDetailsProps) { - const [displayEvent, setDisplayEvent] = useState(event); - const [isTransitioning, setIsTransitioning] = useState(false); - const transitionTimerRef = useRef | null>(null); - const accentColor = accentColors[eventIndex % accentColors.length]; - - useEffect(() => { - if (event.id !== displayEvent.id) { - if (transitionTimerRef.current) clearTimeout(transitionTimerRef.current); - - transitionTimerRef.current = setTimeout(() => { - setIsTransitioning(true); - transitionTimerRef.current = setTimeout(() => { - setDisplayEvent(event); - setIsTransitioning(false); - }, 300); - }, 0); - } - - return () => { - if (transitionTimerRef.current) clearTimeout(transitionTimerRef.current); - }; - }, [event.id, displayEvent.id, event]); - - return ( -
-

- {displayEvent.title.toUpperCase()} -

- -

- {displayEvent.description} -

- -
-
-
- - - -
- {displayEvent.date} -
- -
-
- - - -
- {displayEvent.time} -
- -
-
- - - -
- {displayEvent.location} -
-
-
- ); -} diff --git a/src/components/events/EventGallery.tsx b/src/components/events/EventGallery.tsx deleted file mode 100644 index dad28ad..0000000 --- a/src/components/events/EventGallery.tsx +++ /dev/null @@ -1,128 +0,0 @@ -/** - * EventGallery Component - * Wrapper around ImageGallery that syncs with event data - * Notifies parent when the active event changes - */ - -import { useState, useEffect } from 'react'; -import type { EventData } from '../../data/eventsData'; - -// Accent colors matching the site -const accentColors = ['#FF9FC4', '#FF9770', '#FFD670', '#268AF9', '#B1E0FF']; - -interface EventGalleryProps { - events: EventData[]; - currentIndex: number; - onIndexChange: (index: number) => void; - autoScrollInterval?: number; -} - -export default function EventGallery({ - events, - currentIndex, - onIndexChange, - autoScrollInterval = 0, -}: EventGalleryProps) { - const [isHovered, setIsHovered] = useState(false); - - // Handle keyboard navigation - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === 'ArrowLeft') { - onIndexChange((currentIndex - 1 + events.length) % events.length); - } else if (e.key === 'ArrowRight') { - onIndexChange((currentIndex + 1) % events.length); - } - }; - window.addEventListener('keydown', handleKeyDown); - return () => window.removeEventListener('keydown', handleKeyDown); - }, [currentIndex, events.length, onIndexChange]); - - // Auto-scroll (paused on hover) - useEffect(() => { - if (autoScrollInterval <= 0 || events.length <= 1 || isHovered) return; - const interval = setInterval(() => { - onIndexChange((currentIndex + 1) % events.length); - }, autoScrollInterval); - return () => clearInterval(interval); - }, [autoScrollInterval, events.length, currentIndex, onIndexChange, isHovered]); - - if (!events || events.length === 0) { - return
No events to display
; - } - - return ( -
setIsHovered(true)} - onMouseLeave={() => setIsHovered(false)} - > - {/* Image Container */} -
- {events.map((event, index) => { - const isActive = index === currentIndex; - const isPrev = index === (currentIndex - 1 + events.length) % events.length; - - let transform = 'translateX(100%) scale(0.9)'; - let opacity = 0; - - if (isActive) { - transform = 'translateX(0) scale(1)'; - opacity = 1; - } else if (isPrev) { - transform = 'translateX(-100%) scale(0.9)'; - opacity = 0; - } - - return ( - {event.title} - ); - })} - - {/* Date Badge Overlay */} -
-
- {new Date(events[currentIndex].date).toLocaleDateString('en-US', { month: 'short' })} -
-
- {new Date(events[currentIndex].date).getDate()} -
-
- - {/* Image Counter */} -
- {currentIndex + 1} / {events.length} -
-
- - {/* Dot Navigation */} -
- {events.map((_, index) => ( -
-
- ); -} diff --git a/src/components/events/EventsList.tsx b/src/components/events/EventsList.tsx new file mode 100644 index 0000000..a9e3709 --- /dev/null +++ b/src/components/events/EventsList.tsx @@ -0,0 +1,159 @@ +/** + * EventsList Component + * Fetches events from Supabase and renders them as a responsive grid of EventCards. + * Handles loading, error, and empty states gracefully. + */ + +import { useEffect, useState, useCallback } from 'react'; +import { supabase } from '../../services/supabase'; +import { EventCard, type Event } from './EventCard'; +import RevealOnScroll from '../universal/RevealOnScroll'; +import ScrapbookText from '../universal/ScrapbookText'; +import '../../components/styles/fadeSlideUpAnimation.css'; + +type SortOrder = 'upcoming' | 'past'; + +export function EventsList() { + const [events, setEvents] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [filter, setFilter] = useState('upcoming'); + + const fetchEvents = useCallback(async () => { + try { + setLoading(true); + setError(null); + + const { data, error: sbError } = await supabase + .from('events') + .select('id, title, description, date, time, location, picture_url') + .order('date', { ascending: true }); + + if (sbError) throw sbError; + setEvents((data as Event[]) ?? []); + } catch (err) { + setError(err instanceof Error ? err : new Error(String(err))); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchEvents(); + }, [fetchEvents]); + + const today = new Date().toISOString().split('T')[0]; + + const filteredEvents = events.filter((e) => + filter === 'upcoming' ? e.date >= today : e.date < today + ); + + const upcomingCount = events.filter((e) => e.date >= today).length; + const pastCount = events.filter((e) => e.date < today).length; + + return ( +
+
+ + {/* ── Section Header ── */} + + + + + {/* ── Filter Tabs ── */} + +
+ {(['upcoming', 'past'] as SortOrder[]).map((tab) => { + const count = tab === 'upcoming' ? upcomingCount : pastCount; + const isActive = filter === tab; + return ( + + ); + })} +
+
+ + {/* ── Loading State ── */} + {loading && ( +
+
+
+

Loading events…

+
+
+ )} + + {/* ── Error State ── */} + {error && !loading && ( +
+ Failed to load events. Please try again later. +
+ )} + + {/* ── Events Grid ── */} + {!loading && !error && filteredEvents.length > 0 && ( +
+ {filteredEvents.map((event, idx) => ( + + ))} +
+ )} + + {/* ── Empty State ── */} + {!loading && !error && filteredEvents.length === 0 && ( + +
+

πŸ“…

+

+ No {filter} events right now +

+

+ {filter === 'upcoming' + ? "Check back soon β€” we've got exciting things planned!" + : "Past events will appear here once they've wrapped up."} +

+
+
+ )} +
+
+ ); +} + +export default EventsList; diff --git a/src/components/hero/HTMLBox.tsx b/src/components/hero/HTMLBox.tsx index e496111..ef90eee 100644 --- a/src/components/hero/HTMLBox.tsx +++ b/src/components/hero/HTMLBox.tsx @@ -1,8 +1,14 @@ import "../styles/fadeSlideUpAnimation.css"; +import RevealOnScroll from "../universal/RevealOnScroll"; const HTMLBox = () => { return ( -
+ {/* Bracket SVG - Left */}
bracket @@ -74,7 +80,7 @@ const HTMLBox = () => {
bracket
-
+
); }; diff --git a/src/components/hero/Stats.tsx b/src/components/hero/Stats.tsx index 16aa5fb..2d904ec 100644 --- a/src/components/hero/Stats.tsx +++ b/src/components/hero/Stats.tsx @@ -1,10 +1,12 @@ /** * Stats Component * Displays key club statistics with animated count-up effects + * Now with scroll-reveal - animations trigger when scrolled into view! */ import { useEffect, useState } from 'react'; import CountUp from 'react-countup'; +import { useIntersectionObserver } from '../../hooks/useIntersectionObserver'; /** * Individual stat item configuration @@ -43,18 +45,27 @@ const statsData: StatItem[] = [ export const Stats = () => { const [startAnimation, setStartAnimation] = useState(false); - // Trigger animation on component mount + // Detect when Stats component scrolls into view + const { ref, isVisible } = useIntersectionObserver({ + rootMargin: '50px', + threshold: 0.1, + once: true, + }); + + // Trigger animation when component is visible useEffect(() => { - // Small delay for smoother visual experience - const timer = setTimeout(() => { - setStartAnimation(true); - }, 100); + if (isVisible) { + // Small delay for smoother visual experience + const timer = setTimeout(() => { + setStartAnimation(true); + }, 100); - return () => clearTimeout(timer); - }, []); + return () => clearTimeout(timer); + } + }, [isVisible]); return ( -
+
{/* Stats Grid - flex row mobile, 3 columns desktop */}
{statsData.map((stat, index) => ( diff --git a/src/components/hero/Welcome.tsx b/src/components/hero/Welcome.tsx index 0176966..76bb33f 100644 --- a/src/components/hero/Welcome.tsx +++ b/src/components/hero/Welcome.tsx @@ -2,10 +2,13 @@ * Welcome Component * Displays "WELCOME TO" using decorative letter assets arranged in a scrapbook style, * followed by "Laurier Computing Society" + * + * Now with scroll-reveal animations! */ import ScrapbookText from '../universal/ScrapbookText'; import Mascots from '../universal/Mascots'; +import RevealOnScroll from '../universal/RevealOnScroll'; import '../styles/fadeSlideUpAnimation.css'; /** @@ -22,12 +25,18 @@ export const Welcome: React.FC = () => {
{/* Laurier Computing Society Text */} -

- LAURIER COMPUTING SOCIETY -

+

+ LAURIER COMPUTING SOCIETY +

+
{/* Mascots Section */}
diff --git a/src/components/initiatives/MeetThePros.tsx b/src/components/initiatives/MeetThePros.tsx index 0f46ead..1969f40 100644 --- a/src/components/initiatives/MeetThePros.tsx +++ b/src/components/initiatives/MeetThePros.tsx @@ -1,5 +1,6 @@ import ScrapbookText from "../universal/ScrapbookText"; import ImageGallery from "../universal/ImageGallery"; +import RevealOnScroll from "../universal/RevealOnScroll"; export default function MeetThePros() { // Image gallery data for Meet the Pros events @@ -17,30 +18,47 @@ export default function MeetThePros() {
-

+ +

PROFESSIONALS -

+

+ -
+ +

Our flagship event bringing together professionals from different fields to give students a first hand retelling of the field and provide them with advice.

We bring out the coolest guest speakers from the tech industry and giving you a chance to ask questions, hear their experience, and even make meaningful connections.

-
+
+
{/* Image Gallery */} -
- -
+ + +
); diff --git a/src/components/initiatives/SignUpForm.tsx b/src/components/initiatives/SignUpForm.tsx deleted file mode 100644 index f83cdae..0000000 --- a/src/components/initiatives/SignUpForm.tsx +++ /dev/null @@ -1,201 +0,0 @@ -import { useState } from 'react'; -import FormField from './FormField'; -import { EXPERIENCE_OPTIONS, FORM_COLORS } from './formConstants'; - -interface SignUpFormProps { - onClose: () => void; - isOpen: boolean; -} - -export default function SignUpForm({ onClose, isOpen }: SignUpFormProps) { - const [formData, setFormData] = useState({ - fullName: '', - email: '', - phone: '', - topic: '', - experience: '', - bio: '', - }); - - const [isSubmitting, setIsSubmitting] = useState(false); - const [submitMessage, setSubmitMessage] = useState(''); - - const handleChange = (e: React.ChangeEvent) => { - const { name, value } = e.target; - setFormData(prev => ({ - ...prev, - [name]: value - })); - }; - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - setIsSubmitting(true); - setSubmitMessage(''); - - try { - // Here you can add your Firebase submission logic - console.log('Form submitted:', formData); - setSubmitMessage('Thank you for signing up! We will contact you soon.'); - setTimeout(() => { - setFormData({ - fullName: '', - email: '', - phone: '', - topic: '', - experience: '', - bio: '', - }); - onClose(); - }, 2000); - } catch (error) { - console.error('Error submitting form:', error); - setSubmitMessage('An error occurred. Please try again.'); - } finally { - setIsSubmitting(false); - } - }; - - if (!isOpen) return null; - - return ( - <> - {/* Modal */} -
-
- {/* Header */} -
-

- SPEAKER SIGN UP -

- -
- - {/* Form Content */} -
- - - - - - - - - - - {EXPERIENCE_OPTIONS.map(option => ( - - ))} - - - - - {/* Submit Message */} - {submitMessage && ( -
- {submitMessage} -
- )} - - {/* Actions */} -
- - -
- -
-
- - - - ); -} \ No newline at end of file diff --git a/src/components/initiatives/SpeakerSignUp.tsx b/src/components/initiatives/SpeakerSignUp.tsx index 78a7b7e..8ec4f4c 100644 --- a/src/components/initiatives/SpeakerSignUp.tsx +++ b/src/components/initiatives/SpeakerSignUp.tsx @@ -1,14 +1,12 @@ -import { useState } from 'react'; +import RevealOnScroll from '../universal/RevealOnScroll'; import { Mascots } from '../universal/Mascots'; -import SignUpForm from './SignUpForm'; export default function SpeakerSignup() { - const [isFormOpen, setIsFormOpen] = useState(false); - return ( <> -
-
+ +
+
{/* Doug Mascot positioned to pop out of top and clip at bottom - trying */}
@@ -21,11 +19,13 @@ export default function SpeakerSignup() { WANT TO BE A SPEAKER AT OUR NEXT MTP SESSION?

- We'd love to hear from anyone eager to inform the youth of today about the dangers of the outside world [employement]. Click the button below and sign up to attend our next session! + We'd love to hear from anyone eager to inform the youth of today about the dangers of the outside world [employment]. Click the button below and sign up to attend our next session!

- +
- - {/* Popup Form Modal */} - setIsFormOpen(false)} /> +
); } diff --git a/src/components/styles/fadeSlideUpAnimation.css b/src/components/styles/fadeSlideUpAnimation.css index 04a264a..47d96d7 100644 --- a/src/components/styles/fadeSlideUpAnimation.css +++ b/src/components/styles/fadeSlideUpAnimation.css @@ -17,4 +17,21 @@ transform: translateY(20px); animation: fadeSlideUpFromBottom 0.8s ease-out forwards; animation-delay: 0.3s; +} + +/* Faster version for scroll-reveal components (no delay, quicker animation) */ +@keyframes fadeSlideUpFast { + from { + opacity: 0; + transform: translateY(20px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +.fadeSlideUpFast { + animation: fadeSlideUpFast 0.5s ease-out forwards; } \ No newline at end of file diff --git a/src/components/team/TeamCard.tsx b/src/components/team/TeamCard.tsx index 8f26c4b..1e5703a 100644 --- a/src/components/team/TeamCard.tsx +++ b/src/components/team/TeamCard.tsx @@ -4,6 +4,8 @@ * Styled with dark theme and accent color hover effects */ +import RevealOnScroll from '../universal/RevealOnScroll'; + interface TeamMember { id: string; name: string; @@ -50,12 +52,13 @@ export function TeamCard({ member }: TeamCardProps) { const accentColor = accentColorMap[colorIndex]; return ( -
- {/* Image Container - Circular with colored border */} -
+ +
+ {/* Image Container - Circular with colored border */} +
{member.picture_url ? (
)} -
+
- {/* Info Section */} -
- {/* Member Name */} -

- {member.name} -

- - {/* Member Role */} - {member.role && ( -

- {member.role} -

- )} + {/* Info Section */} +
+ {/* Member Name */} +

+ {member.name} +

+ + {/* Member Role */} + {member.role && ( +

+ {member.role} +

+ )} - {/* Social Links Container */} - {(member.github_url || member.linkedin_url) && ( -
- {/* GitHub Link */} - {showGithub && member.github_url && ( - - - - )} + {/* Social Links Container */} + {(member.github_url || member.linkedin_url) && ( +
+ {/* GitHub Link */} + {showGithub && member.github_url && ( + + + + )} - {/* LinkedIn Link */} - {showLinkedin && member.linkedin_url && ( - - - - )} -
- )} + {/* LinkedIn Link */} + {showLinkedin && member.linkedin_url && ( + + + + )} +
+ )} +
-
+ ); } diff --git a/src/components/universal/Mascots.tsx b/src/components/universal/Mascots.tsx index 970a8b6..e99b26d 100644 --- a/src/components/universal/Mascots.tsx +++ b/src/components/universal/Mascots.tsx @@ -2,9 +2,12 @@ * Mascots Component * Displays the three mascot characters (Coco, Doug, Krill) in an overlapping arrangement * Similar to ScrapbookText but with mascot SVGs instead of letters + * + * Now supports scroll-reveal animations - mascots animate when scrolled into view! */ import React, { useMemo } from 'react'; +import { useIntersectionObserver } from '../../hooks/useIntersectionObserver'; import '../styles/scrapbookAnimations.css'; interface MascotsProps { @@ -46,6 +49,7 @@ interface MascotItemProps { size: number; overlapAmount: number; mascotIndex: number; + isVisible?: boolean; // Whether element is visible in viewport } const MascotItem: React.FC = ({ @@ -58,6 +62,7 @@ const MascotItem: React.FC = ({ size, overlapAmount, mascotIndex, + isVisible = true, }) => { const staggerDelay = mascotIndex * 120; // 120ms stagger between mascots @@ -69,7 +74,7 @@ const MascotItem: React.FC = ({ transition: 'transform 0.2s ease-out', marginRight: `-${overlapAmount}px`, zIndex, - animation: `mascotAppear 0.7s cubic-bezier(0.34, 1.56, 0.64, 1) ${staggerDelay}ms both`, + animation: isVisible ? `mascotAppear 0.7s cubic-bezier(0.34, 1.56, 0.64, 1) ${staggerDelay}ms both` : 'none', }} > = ({ // Track window width for responsive sizing const [isMobile, setIsMobile] = React.useState(false); + // Observer for scroll-reveal animation + const { ref, isVisible } = useIntersectionObserver({ + rootMargin: '50px', + threshold: 0.1, + once: true, + }); + React.useEffect(() => { const checkMobile = () => setIsMobile(window.innerWidth < 768); checkMobile(); @@ -122,7 +134,7 @@ export const Mascots: React.FC = ({ }, []); return ( -
+
{displayedMascots.map((mascot, index) => ( = ({ zIndex={index + 1} size={responsiveMascotSize} overlapAmount={overlapAmount} + isVisible={isVisible} /> ))}
diff --git a/src/components/universal/RevealOnScroll.tsx b/src/components/universal/RevealOnScroll.tsx new file mode 100644 index 0000000..78d4391 --- /dev/null +++ b/src/components/universal/RevealOnScroll.tsx @@ -0,0 +1,92 @@ +/** + * RevealOnScroll Wrapper Component + * + * Wraps animated content and only applies animation classes when scrolled into view. + * Works seamlessly with existing animation classes like fadeSlideUpFromBottom, + * scrapbookLetterAppear, mascotAppear, etc. + * + * @example + * + *

Heading

+ *
+ */ + +import React, { type ReactNode } from 'react'; +import { useIntersectionObserver } from '../../hooks/useIntersectionObserver'; + +interface RevealOnScrollProps { + /** + * The content to reveal on scroll + */ + children: ReactNode; + + /** + * CSS class(es) to apply when element is visible + * Can include animation classes like 'fadeSlideUpFromBottom' + */ + visibleClassName?: string; + + /** + * CSS class to apply when element is not visible (for paused state) + */ + hiddenClassName?: string; + + /** + * Additional CSS classes for the container + */ + className?: string; + + /** + * How close to viewport edge should animation trigger (default: '0px') + * Examples: '100px' triggers 100px before entering, '-50px' triggers 50px after entering + */ + rootMargin?: string; + + /** + * Visibility threshold (0-1, default: 0.1) + * 0 = any part visible, 1 = entire element visible + */ + threshold?: number | number[]; + + /** + * If true, animation plays only once on first scroll into view (default: true) + */ + once?: boolean; + + /** + * Wrapper element type (default: 'div') + */ + as?: React.ElementType; +} + +/** + * RevealOnScroll Component + * Utility wrapper for scroll-reveal animations + */ +export const RevealOnScroll: React.FC = ({ + children, + visibleClassName = '', + hiddenClassName = '', + className = '', + rootMargin = '0px', + threshold = 0.1, + once = true, + as: Component = 'div', +}) => { + const { ref, isVisible } = useIntersectionObserver({ + rootMargin, + threshold, + once, + }); + + const animationClass = isVisible ? visibleClassName : hiddenClassName; + const combinedClassName = `${className} ${animationClass}`.trim(); + + return ( + + {children} + + ); +}; + +export default RevealOnScroll; diff --git a/src/components/universal/ScrapbookText.tsx b/src/components/universal/ScrapbookText.tsx index 9d06d78..3e48d0a 100644 --- a/src/components/universal/ScrapbookText.tsx +++ b/src/components/universal/ScrapbookText.tsx @@ -2,9 +2,12 @@ * ScrapbookText Component * Universal component for displaying text in scrapbook style using letter assets * Supports letters: A, C, D, E, F, G, H, I, K, L, M, N, O, P, R, S, T, U, V, W + * + * Now supports scroll-reveal animations - letters animate when scrolled into view! */ import React, { useMemo } from 'react'; +import { useIntersectionObserver } from '../../hooks/useIntersectionObserver'; import '../styles/scrapbookAnimations.css'; interface ScrapbookTextProps { @@ -83,6 +86,7 @@ interface ScrapbookLetterProps { zIndex: number; size: number; letterClassName?: string; + isVisible?: boolean; // Whether element is visible in viewport } const ScrapbookLetter: React.FC = ({ @@ -94,6 +98,7 @@ const ScrapbookLetter: React.FC size, letterClassName, letterIndex, + isVisible = true, }) => { const assetPath = letterAssets[letter]; const staggerDelay = letterIndex * 60; // 60ms stagger between letters @@ -110,7 +115,7 @@ const ScrapbookLetter: React.FC >
= ({ // Track window width for responsive sizing const [isMobile, setIsMobile] = React.useState(false); + // Observer for scroll-reveal animation + const { ref, isVisible } = useIntersectionObserver({ + rootMargin: '50px', + threshold: 0.1, + once: true, + }); + React.useEffect(() => { const checkMobile = () => setIsMobile(window.innerWidth < 768); checkMobile(); @@ -164,7 +176,7 @@ export const ScrapbookText: React.FC = ({ }, [uppercaseText]); return ( -
+
{uppercaseText.split('').map((char, index) => { if (char === ' ') { return ( @@ -191,6 +203,7 @@ export const ScrapbookText: React.FC = ({ scale={transform.scale} size={responsiveLetterSize} letterClassName={letterClassName} + isVisible={isVisible} /> ); })} diff --git a/src/data/eventsData.ts b/src/data/eventsData.ts deleted file mode 100644 index 32bf03b..0000000 --- a/src/data/eventsData.ts +++ /dev/null @@ -1,67 +0,0 @@ -/** - * Sample Events Data - * Edit this file to add/remove/modify events - */ - -export interface EventData { - id: string; - title: string; - description: string; - date: string; - time: string; - location: string; - image: string; -} - -// Placeholder images from Unsplash (tech/coding themed) -const placeholderImages = { - coding: 'https://images.unsplash.com/photo-1517694712202-14dd9538aa97?w=800&q=80', - lecture: 'https://images.unsplash.com/photo-1524178232363-1fb2b075b655?w=800&q=80', - networking: 'https://images.unsplash.com/photo-1515187029135-18ee286d815b?w=800&q=80', - workshop: 'https://images.unsplash.com/photo-1531482615713-2afd69097998?w=800&q=80', -}; - -export const eventsData: EventData[] = [ - { - id: 'cp164-review', - title: 'CP164 Midterm Review', - description: - 'An interactive midterm review session for computer science students taking the essential first year course, Data Structures (CP164). Refresh your knowledge of key concepts in a lively and interactive format!', - date: 'February 11, 2026', - time: '7:30 PM - 9:00 PM', - location: 'Lazaridis Hall, Waterloo Campus', - image: placeholderImages.coding, - }, - { - id: 'cp264-review', - title: 'CP264 Midterm Review', - description: - 'A comprehensive review session for Data Structures II (CP264). Engage with the material through mini quizzes and win exciting prizes while preparing for your midterm!', - date: 'February 18, 2026', - time: '7:30 PM - 9:00 PM', - location: 'Science Building, Waterloo Campus', - image: placeholderImages.lecture, - }, - { - id: 'meet-the-pros', - title: 'Meet The Professionals', - description: - 'Our flagship event bringing together industry professionals from different fields. Get first-hand insights about the tech industry and make meaningful connections.', - date: 'March 5, 2026', - time: '6:00 PM - 8:30 PM', - location: 'Arts Building Atrium', - image: placeholderImages.networking, - }, - { - id: 'coding-workshop', - title: 'React Workshop', - description: - 'Hands-on workshop covering React fundamentals, hooks, and best practices. Perfect for beginners looking to level up their frontend development skills.', - date: 'March 12, 2026', - time: '5:00 PM - 7:00 PM', - location: 'Computer Lab, Lazaridis Hall', - image: placeholderImages.workshop, - }, -]; - -export default eventsData; diff --git a/src/hooks/useIntersectionObserver.ts b/src/hooks/useIntersectionObserver.ts new file mode 100644 index 0000000..5a253f7 --- /dev/null +++ b/src/hooks/useIntersectionObserver.ts @@ -0,0 +1,70 @@ +import { useEffect, useRef, useState } from 'react'; + +interface UseIntersectionObserverOptions { + /** + * Margin around the target element to trigger the callback (default: '0px') + * Example: '100px' would trigger when element is 100px away from viewport edge + */ + rootMargin?: string; + /** + * Threshold of visibility required to trigger (0-1, default: 0.1) + * 0 = any part visible, 1 = entire element visible + */ + threshold?: number | number[]; + /** + * If true, observer will disconnect after first intersection (default: false) + * Useful if you only want animation to play once on scroll into view + */ + once?: boolean; +} + +/** + * Custom hook to detect when an element enters the viewport + * Perfect for scroll-reveal animations and lazy loading + * + * @example + * const { ref, isVisible } = useIntersectionObserver(); + * return ( + *
+ * Content here + *
+ * ); + */ +export function useIntersectionObserver(options: UseIntersectionObserverOptions = {}) { + const { rootMargin = '0px', threshold = 0.1, once = false } = options; + const ref = useRef(null); + const [isVisible, setIsVisible] = useState(false); + + useEffect(() => { + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) { + setIsVisible(true); + // Disconnect after first intersection if 'once' is true + if (once && ref.current) { + observer.unobserve(ref.current); + } + } else if (!once) { + // If not in 'once' mode, allow animation to replay when scrolling back + setIsVisible(false); + } + }, + { + rootMargin, + threshold, + } + ); + + if (ref.current) { + observer.observe(ref.current); + } + + return () => { + if (ref.current) { + observer.unobserve(ref.current); + } + }; + }, [rootMargin, threshold, once]); + + return { ref, isVisible }; +} diff --git a/src/pages/Events.tsx b/src/pages/Events.tsx index 4173bf7..52e90f5 100644 --- a/src/pages/Events.tsx +++ b/src/pages/Events.tsx @@ -1,53 +1,10 @@ /** * Events Page - * Displays upcoming events with interactive gallery and synchronized details + * Displays events fetched from Supabase via EventsList component. */ -import { useState } from 'react'; -import EventDetails from '../components/events/EventDetails'; -import EventGallery from '../components/events/EventGallery'; -import ScrapbookText from '../components/universal/ScrapbookText'; -import { eventsData } from '../data/eventsData'; -import '../components/styles/fadeSlideUpAnimation.css'; +import { EventsList } from '../components/events/EventsList'; export default function Events() { - const [currentEventIndex, setCurrentEventIndex] = useState(0); - const currentEvent = eventsData[currentEventIndex]; - - return ( -
-
- {/* Section Header */} -
- -
- - {/* Main Content: Two-column layout */} -
- {/* Left: Event Details */} -
- -
- - {/* Right: Event Gallery */} -
- -
-
-
-
- ); + return ; }