Add media carousel section to homepage
Display past event photos in an auto-playing carousel between the "What is Spanglish?" and "Stay Updated" sections. Images are loaded dynamically from /images/carrousel/ folder with support for jpg, png, and webp formats.
BIN
frontend/public/images/carrousel/2026-01-29 13.09.59.jpg
Normal file
|
After Width: | Height: | Size: 168 KiB |
BIN
frontend/public/images/carrousel/2026-01-29 13.10.12.jpg
Normal file
|
After Width: | Height: | Size: 229 KiB |
BIN
frontend/public/images/carrousel/2026-01-29 13.10.16.jpg
Normal file
|
After Width: | Height: | Size: 207 KiB |
BIN
frontend/public/images/carrousel/2026-01-29 13.10.20.jpg
Normal file
|
After Width: | Height: | Size: 141 KiB |
BIN
frontend/public/images/carrousel/2026-02-01 02.53.00.jpg
Normal file
|
After Width: | Height: | Size: 131 KiB |
BIN
frontend/public/images/carrousel/2026-02-01 02.53.30.jpg
Normal file
|
After Width: | Height: | Size: 146 KiB |
BIN
frontend/public/images/carrousel/2026-02-01 02.53.36.jpg
Normal file
|
After Width: | Height: | Size: 119 KiB |
BIN
frontend/public/images/carrousel/2026-02-01 02.53.41.jpg
Normal file
|
After Width: | Height: | Size: 143 KiB |
BIN
frontend/public/images/carrousel/2026-02-01 02.53.45.jpg
Normal file
|
After Width: | Height: | Size: 106 KiB |
BIN
frontend/public/images/carrousel/2026-02-01 02.53.48.jpg
Normal file
|
After Width: | Height: | Size: 116 KiB |
199
frontend/src/app/(public)/components/MediaCarouselSection.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import Button from '@/components/ui/Button';
|
||||
import { ChevronLeftIcon, ChevronRightIcon, ArrowRightIcon } from '@heroicons/react/24/outline';
|
||||
|
||||
export interface CarouselImage {
|
||||
src: string;
|
||||
alt: string;
|
||||
}
|
||||
|
||||
interface MediaCarouselSectionProps {
|
||||
images: CarouselImage[];
|
||||
}
|
||||
|
||||
export default function MediaCarouselSection({ images }: MediaCarouselSectionProps) {
|
||||
const { t } = useLanguage();
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [isAutoPlaying, setIsAutoPlaying] = useState(true);
|
||||
const [touchStart, setTouchStart] = useState<number | null>(null);
|
||||
const [touchEnd, setTouchEnd] = useState<number | null>(null);
|
||||
|
||||
const goToNext = useCallback(() => {
|
||||
setCurrentIndex((prev) => (prev + 1) % images.length);
|
||||
}, [images.length]);
|
||||
|
||||
const goToPrevious = useCallback(() => {
|
||||
setCurrentIndex((prev) => (prev - 1 + images.length) % images.length);
|
||||
}, [images.length]);
|
||||
|
||||
const goToSlide = (index: number) => {
|
||||
setCurrentIndex(index);
|
||||
setIsAutoPlaying(false);
|
||||
// Resume auto-play after 5 seconds of inactivity
|
||||
setTimeout(() => setIsAutoPlaying(true), 5000);
|
||||
};
|
||||
|
||||
// Auto-play functionality
|
||||
useEffect(() => {
|
||||
if (!isAutoPlaying) return;
|
||||
|
||||
const interval = setInterval(goToNext, 4000);
|
||||
return () => clearInterval(interval);
|
||||
}, [isAutoPlaying, goToNext]);
|
||||
|
||||
// Touch handlers for swipe gestures
|
||||
const handleTouchStart = (e: React.TouchEvent) => {
|
||||
setTouchEnd(null);
|
||||
setTouchStart(e.targetTouches[0].clientX);
|
||||
};
|
||||
|
||||
const handleTouchMove = (e: React.TouchEvent) => {
|
||||
setTouchEnd(e.targetTouches[0].clientX);
|
||||
};
|
||||
|
||||
const handleTouchEnd = () => {
|
||||
if (!touchStart || !touchEnd) return;
|
||||
|
||||
const distance = touchStart - touchEnd;
|
||||
const minSwipeDistance = 50;
|
||||
|
||||
if (Math.abs(distance) > minSwipeDistance) {
|
||||
if (distance > 0) {
|
||||
goToNext();
|
||||
} else {
|
||||
goToPrevious();
|
||||
}
|
||||
setIsAutoPlaying(false);
|
||||
setTimeout(() => setIsAutoPlaying(true), 5000);
|
||||
}
|
||||
};
|
||||
|
||||
// Keyboard navigation
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'ArrowLeft') {
|
||||
goToPrevious();
|
||||
setIsAutoPlaying(false);
|
||||
setTimeout(() => setIsAutoPlaying(true), 5000);
|
||||
} else if (e.key === 'ArrowRight') {
|
||||
goToNext();
|
||||
setIsAutoPlaying(false);
|
||||
setTimeout(() => setIsAutoPlaying(true), 5000);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [goToNext, goToPrevious]);
|
||||
|
||||
// Don't render if no images
|
||||
if (images.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="section-padding bg-white">
|
||||
<div className="container-page">
|
||||
{/* Header */}
|
||||
<div className="text-center max-w-2xl mx-auto mb-10">
|
||||
<h2 className="section-title text-primary-dark">
|
||||
{t('home.carousel.title')}
|
||||
</h2>
|
||||
<p className="section-subtitle text-gray-600">
|
||||
{t('home.carousel.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Carousel Container */}
|
||||
<div
|
||||
className="relative max-w-4xl mx-auto"
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
>
|
||||
{/* Main Image Container */}
|
||||
<div className="relative aspect-[16/10] rounded-2xl overflow-hidden bg-gray-100 shadow-lg">
|
||||
{images.map((image, index) => (
|
||||
<div
|
||||
key={image.src}
|
||||
className={`absolute inset-0 transition-opacity duration-700 ease-in-out ${
|
||||
index === currentIndex ? 'opacity-100' : 'opacity-0'
|
||||
}`}
|
||||
>
|
||||
<Image
|
||||
src={image.src}
|
||||
alt={image.alt}
|
||||
fill
|
||||
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 80vw, 900px"
|
||||
className="object-cover"
|
||||
priority={index === 0}
|
||||
loading={index === 0 ? 'eager' : 'lazy'}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Soft gradient overlay for polish */}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/10 via-transparent to-transparent pointer-events-none" />
|
||||
</div>
|
||||
|
||||
{/* Navigation Arrows */}
|
||||
<button
|
||||
onClick={() => {
|
||||
goToPrevious();
|
||||
setIsAutoPlaying(false);
|
||||
setTimeout(() => setIsAutoPlaying(true), 5000);
|
||||
}}
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 w-10 h-10 rounded-full bg-white/90 shadow-md flex items-center justify-center text-gray-700 hover:bg-white hover:scale-105 transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||
aria-label={t('home.carousel.previous')}
|
||||
>
|
||||
<ChevronLeftIcon className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
goToNext();
|
||||
setIsAutoPlaying(false);
|
||||
setTimeout(() => setIsAutoPlaying(true), 5000);
|
||||
}}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 w-10 h-10 rounded-full bg-white/90 shadow-md flex items-center justify-center text-gray-700 hover:bg-white hover:scale-105 transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||
aria-label={t('home.carousel.next')}
|
||||
>
|
||||
<ChevronRightIcon className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
{/* Dots Navigation */}
|
||||
<div className="flex justify-center gap-2 mt-6">
|
||||
{images.map((_, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => goToSlide(index)}
|
||||
className={`w-2.5 h-2.5 rounded-full transition-all duration-300 focus:outline-none focus:ring-2 focus:ring-primary-yellow focus:ring-offset-2 ${
|
||||
index === currentIndex
|
||||
? 'bg-primary-yellow w-8'
|
||||
: 'bg-gray-300 hover:bg-gray-400'
|
||||
}`}
|
||||
aria-label={`Go to slide ${index + 1}`}
|
||||
aria-current={index === currentIndex ? 'true' : 'false'}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CTA Section - Outside carousel */}
|
||||
<div className="text-center mt-10">
|
||||
<Link href="/events">
|
||||
<Button variant="outline" size="lg" className="group">
|
||||
{t('home.carousel.cta')}
|
||||
<ArrowRightIcon className="w-4 h-4 ml-2 group-hover:translate-x-1 transition-transform" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,19 @@
|
||||
import HeroSection from './components/HeroSection';
|
||||
import NextEventSectionWrapper from './components/NextEventSectionWrapper';
|
||||
import AboutSection from './components/AboutSection';
|
||||
import MediaCarouselSection from './components/MediaCarouselSection';
|
||||
import NewsletterSection from './components/NewsletterSection';
|
||||
import { getCarouselImages } from '@/lib/carouselImages';
|
||||
|
||||
export default function HomePage() {
|
||||
const carouselImages = getCarouselImages();
|
||||
|
||||
return (
|
||||
<>
|
||||
<HeroSection />
|
||||
<NextEventSectionWrapper />
|
||||
<AboutSection />
|
||||
<MediaCarouselSection images={carouselImages} />
|
||||
<NewsletterSection />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -53,6 +53,13 @@
|
||||
"gallery": {
|
||||
"title": "Our Community"
|
||||
},
|
||||
"carousel": {
|
||||
"title": "Moments from past events",
|
||||
"subtitle": "A glimpse of our language exchange meetups",
|
||||
"cta": "See upcoming events",
|
||||
"previous": "Previous image",
|
||||
"next": "Next image"
|
||||
},
|
||||
"newsletter": {
|
||||
"title": "Stay Updated",
|
||||
"description": "Subscribe to get notified about upcoming events",
|
||||
|
||||
@@ -53,6 +53,13 @@
|
||||
"gallery": {
|
||||
"title": "Nuestra Comunidad"
|
||||
},
|
||||
"carousel": {
|
||||
"title": "Momentos de eventos pasados",
|
||||
"subtitle": "Un vistazo a nuestros encuentros de intercambio de idiomas",
|
||||
"cta": "Ver próximos eventos",
|
||||
"previous": "Imagen anterior",
|
||||
"next": "Imagen siguiente"
|
||||
},
|
||||
"newsletter": {
|
||||
"title": "Mantente Informado",
|
||||
"description": "Suscríbete para recibir notificaciones sobre próximos eventos",
|
||||
|
||||
37
frontend/src/lib/carouselImages.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
export interface CarouselImage {
|
||||
src: string;
|
||||
alt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads all images from the carrousel folder at build/request time.
|
||||
* Supports jpg, jpeg, png, webp, and gif formats.
|
||||
*/
|
||||
export function getCarouselImages(): CarouselImage[] {
|
||||
const carrouselDir = path.join(process.cwd(), 'public', 'images', 'carrousel');
|
||||
|
||||
try {
|
||||
const files = fs.readdirSync(carrouselDir);
|
||||
|
||||
// Filter for supported image formats
|
||||
const supportedExtensions = ['.jpg', '.jpeg', '.png', '.webp', '.gif'];
|
||||
const imageFiles = files.filter((file) => {
|
||||
const ext = path.extname(file).toLowerCase();
|
||||
return supportedExtensions.includes(ext);
|
||||
});
|
||||
|
||||
// Sort by filename (which includes date) for consistent ordering
|
||||
imageFiles.sort();
|
||||
|
||||
return imageFiles.map((file) => ({
|
||||
src: `/images/carrousel/${file}`,
|
||||
alt: 'Spanglish language exchange event moment',
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Error reading carrousel directory:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||