Improve mobile and small screen experiences
This commit is contained in:
parent
185c70e76e
commit
195aeea56b
7 changed files with 257 additions and 272 deletions
110
create-readme.sh
110
create-readme.sh
|
|
@ -1,110 +0,0 @@
|
|||
#!/bin/bash
|
||||
|
||||
cat > README.md << 'EOL'
|
||||
# DeafGain LLC Website 🌐
|
||||
|
||||
[](https://pnpm.io/)
|
||||
[](https://www.typescriptlang.org/)
|
||||
[](https://reactjs.org/)
|
||||
|
||||
## Overview
|
||||
|
||||
A dynamic web platform dedicated to empowering the Deaf community through innovative communication solutions, training, and accessibility services. This website serves as the digital presence for DeafGain LLC, showcasing our comprehensive services including ADA-compliant transcripts, universal website design, board communication protocols, and leadership training.
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
\`\`\`bash
|
||||
pnpm install
|
||||
pnpm dev
|
||||
\`\`\`
|
||||
|
||||
Visit \`localhost:3000\` in your browser.
|
||||
|
||||
## 🛠 Technical Stack
|
||||
|
||||
| Technology | Version | Purpose |
|
||||
|------------|---------|---------|
|
||||
| React | 18.2.0 | Core Framework |
|
||||
| TypeScript | 4.9.5 | Type Safety |
|
||||
| Tailwind CSS | 3.x | Styling |
|
||||
| Framer Motion | 10.x | Animations |
|
||||
| React Simple Maps | 3.0.0 | Geographic Visualization |
|
||||
| Vite | 4.x | Build System |
|
||||
| Docker | - | Containerization |
|
||||
| Nginx | - | Web Server |
|
||||
|
||||
## 📁 Project Structure
|
||||
|
||||
\`\`\`
|
||||
src/
|
||||
├── components/ # Reusable UI components
|
||||
│ ├── layout/ # Layout components
|
||||
│ ├── ui/ # UI elements
|
||||
│ └── maps/ # Map visualization components
|
||||
├── pages/ # Main application pages
|
||||
├── hooks/ # Custom React hooks
|
||||
├── utils/ # Utility functions
|
||||
├── types/ # TypeScript definitions
|
||||
├── assets/ # Static assets
|
||||
└── styles/ # Global styles
|
||||
\`\`\`
|
||||
|
||||
## 🎯 Core Features
|
||||
|
||||
- ⚡️ Intuitive user interface with responsive design
|
||||
- 🗺️ Interactive USA region map displaying network and relationships
|
||||
- 🎥 Video resources with accessible playback controls and captions
|
||||
- 📱 Mobile-first responsive design
|
||||
- 🔒 Secure contact system
|
||||
- 🌐 Key sections: Home, Services, About, Resources, and Contact
|
||||
|
||||
## 🔧 Development
|
||||
|
||||
### Environment Setup
|
||||
- Development: Vite dev server with hot reload
|
||||
- Production: Nginx configuration with optimized cache settings
|
||||
- Docker containers for consistent deployment
|
||||
|
||||
### Scripts
|
||||
\`\`\`bash
|
||||
pnpm dev # Start development server
|
||||
pnpm build # Build for production
|
||||
pnpm preview # Preview production build
|
||||
pnpm lint # Run ESLint
|
||||
pnpm test # Run tests
|
||||
\`\`\`
|
||||
|
||||
## 🚢 Production Deployment
|
||||
|
||||
\`\`\`bash
|
||||
docker-compose up --build
|
||||
\`\`\`
|
||||
|
||||
Application will be served on port 80.
|
||||
|
||||
## <20><> Contributing
|
||||
|
||||
We welcome contributions that align with our mission of improving accessibility and empowerment for the Deaf community. Follow our established patterns for:
|
||||
|
||||
- Component architecture
|
||||
- Styling conventions (Tailwind CSS)
|
||||
- Accessibility standards (WCAG 2.1)
|
||||
- TypeScript typing
|
||||
- Git commit messages
|
||||
|
||||
## 📄 License
|
||||
|
||||
Proprietary - DeafGain LLC
|
||||
|
||||
## 📞 Contact
|
||||
|
||||
For questions or support, reach out through our [contact form](https://deafgain.com/contact).
|
||||
|
||||
---
|
||||
|
||||
> This website represents DeafGain LLC's commitment to creating accessible, user-friendly digital experiences while maintaining high technical standards and professional quality.
|
||||
EOL
|
||||
|
||||
chmod +x create-readme.sh
|
||||
echo "README.md has been created successfully!"
|
||||
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
services:
|
||||
web:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
pull_policy: build
|
||||
ports:
|
||||
- "804:804"
|
||||
depends_on:
|
||||
- api
|
||||
volumes:
|
||||
- ./logs:/var/log/nginx
|
||||
- type: bind
|
||||
source: /docker/websites/deafgain/video_content
|
||||
target: /usr/share/nginx/html/videos
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=804
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- caddy_network
|
||||
|
||||
api:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.api
|
||||
volumes:
|
||||
- ./logs:/app/logs
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=3000
|
||||
- SMTP_HOST=${SMTP_HOST}
|
||||
- SMTP_PORT=${SMTP_PORT}
|
||||
- SMTP_SECURE=${SMTP_SECURE}
|
||||
- RECIPIENT_EMAIL=${RECIPIENT_EMAIL}
|
||||
- GOOGLE_EMAIL=${GOOGLE_EMAIL}
|
||||
- GOOGLE_APP_PASSWORD=${GOOGLE_APP_PASSWORD}
|
||||
- UPSTASH_REDIS_REST_URL=${UPSTASH_REDIS_REST_URL}
|
||||
- UPSTASH_REDIS_REST_TOKEN=${UPSTASH_REDIS_REST_TOKEN}
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- caddy_network
|
||||
|
||||
networks:
|
||||
caddy_network:
|
||||
external: true
|
||||
|
|
@ -1,11 +1,13 @@
|
|||
import { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { motion } from 'framer-motion'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { HiMenu, HiX } from 'react-icons/hi'
|
||||
|
||||
const Navbar = () => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
const location = useLocation()
|
||||
|
||||
const navItems = [
|
||||
{ name: 'Home', path: '/' },
|
||||
{ name: 'Services', path: '/services' },
|
||||
|
|
@ -14,11 +16,31 @@ const Navbar = () => {
|
|||
{ name: 'Contact', path: '/contact' }
|
||||
]
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [])
|
||||
|
||||
// Close mobile menu when route changes
|
||||
useEffect(() => {
|
||||
setIsOpen(false)
|
||||
}, [location])
|
||||
|
||||
return (
|
||||
<nav className="bg-primary text-accent-snow">
|
||||
<nav className="bg-primary text-accent-snow relative z-50">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center justify-between h-24">
|
||||
<Link to="/" className="font-black text-3xl tracking-wider text-accent-snow drop-shadow-lg">
|
||||
<Link
|
||||
to="/"
|
||||
className="font-black text-2xl sm:text-3xl tracking-wider text-accent-snow drop-shadow-lg"
|
||||
aria-label="DeafGain Home"
|
||||
>
|
||||
DeafGain
|
||||
</Link>
|
||||
|
||||
|
|
@ -29,7 +51,11 @@ const Navbar = () => {
|
|||
<Link
|
||||
key={item.name}
|
||||
to={item.path}
|
||||
className="text-xl font-bold tracking-wide text-accent-snow hover:text-accent-lake transition-colors drop-shadow-md"
|
||||
className={`text-lg font-bold tracking-wide transition-colors drop-shadow-md
|
||||
${location.pathname === item.path
|
||||
? 'text-accent-lake'
|
||||
: 'text-accent-snow hover:text-accent-lake'}`}
|
||||
aria-current={location.pathname === item.path ? 'page' : undefined}
|
||||
>
|
||||
{item.name}
|
||||
</Link>
|
||||
|
|
@ -38,38 +64,51 @@ const Navbar = () => {
|
|||
</div>
|
||||
|
||||
{/* Mobile menu button */}
|
||||
<div className="md:hidden">
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="text-accent-snow"
|
||||
>
|
||||
{isOpen ? <HiX size={24} /> : <HiMenu size={24} />}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="md:hidden inline-flex items-center justify-center p-2 rounded-md
|
||||
text-accent-snow hover:text-accent-lake hover:bg-secondary/20
|
||||
transition-colors duration-200"
|
||||
aria-expanded={isOpen}
|
||||
aria-controls="mobile-menu"
|
||||
aria-label={isOpen ? "Close menu" : "Open menu"}
|
||||
>
|
||||
{isOpen ? <HiX size={28} /> : <HiMenu size={28} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Navigation */}
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="md:hidden"
|
||||
>
|
||||
<div className="px-2 pt-2 pb-3 space-y-1 sm:px-3">
|
||||
{navItems.map((item) => (
|
||||
<Link
|
||||
key={item.name}
|
||||
to={item.path}
|
||||
className="block px-3 py-2 hover:bg-secondary rounded-md"
|
||||
onClick={() => setIsOpen(false)}
|
||||
>
|
||||
{item.name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
ref={menuRef}
|
||||
id="mobile-menu"
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="md:hidden absolute w-full bg-primary shadow-lg"
|
||||
>
|
||||
<div className="px-4 pt-2 pb-3 space-y-1">
|
||||
{navItems.map((item) => (
|
||||
<Link
|
||||
key={item.name}
|
||||
to={item.path}
|
||||
className={`block px-3 py-4 text-base font-medium rounded-md
|
||||
transition-colors duration-200
|
||||
${location.pathname === item.path
|
||||
? 'bg-secondary text-accent-lake'
|
||||
: 'text-accent-snow hover:bg-secondary/20 hover:text-accent-lake'}`}
|
||||
aria-current={location.pathname === item.path ? 'page' : undefined}
|
||||
>
|
||||
{item.name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { FaTimes } from 'react-icons/fa'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
interface ToastProps {
|
||||
message: string
|
||||
|
|
@ -15,30 +16,49 @@ export default function Toast({ message, type, isVisible, onClose }: ToastProps)
|
|||
? 'from-primary to-secondary'
|
||||
: 'from-red-600 to-red-800'
|
||||
|
||||
// Auto-dismiss after 5 seconds
|
||||
useEffect(() => {
|
||||
if (isVisible) {
|
||||
const timer = setTimeout(onClose, 5000)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [isVisible, onClose])
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isVisible && (
|
||||
<motion.div
|
||||
className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center"
|
||||
className="fixed inset-0 z-50 px-4 py-6 pointer-events-none sm:p-6 sm:items-start flex items-center justify-center"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.9 }}
|
||||
className="w-full max-w-md bg-white rounded-xl shadow-2xl overflow-hidden mx-4"
|
||||
initial={{ opacity: 0, scale: 0.9, y: 50 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.9, y: 50 }}
|
||||
className="w-full max-w-sm pointer-events-auto"
|
||||
>
|
||||
<div className={`h-2 bg-gradient-to-r ${bgColor}`} />
|
||||
<div className="p-6 relative">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-2 right-2 text-accent-mountain hover:text-secondary p-2 rounded-full transition-colors"
|
||||
>
|
||||
<FaTimes size={24} />
|
||||
</button>
|
||||
<p className="text-lg font-medium text-accent-mountain text-center mt-2">{message}</p>
|
||||
<div className="overflow-hidden rounded-lg shadow-2xl">
|
||||
<div className={`h-2 bg-gradient-to-r ${bgColor}`} />
|
||||
<div className="p-4 bg-white relative">
|
||||
<div className="flex items-start">
|
||||
<div className="flex-1 pt-0.5">
|
||||
<p className="text-sm sm:text-base text-accent-mountain text-center pr-8">
|
||||
{message}
|
||||
</p>
|
||||
</div>
|
||||
<div className="ml-4 flex-shrink-0 flex">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="inline-flex text-accent-mountain rounded-md focus:outline-none focus:ring-2 focus:ring-primary hover:text-secondary transition-colors p-1.5"
|
||||
aria-label="Close notification"
|
||||
>
|
||||
<FaTimes className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
|
|
|||
|
|
@ -15,9 +15,7 @@ const Contact = () => {
|
|||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (isSubmitting) return
|
||||
|
||||
setIsSubmitting(true)
|
||||
|
||||
try {
|
||||
|
|
@ -49,12 +47,12 @@ const Contact = () => {
|
|||
|
||||
const contactInfo = [
|
||||
{
|
||||
icon: <FaEnvelope />,
|
||||
icon: <FaEnvelope className="text-2xl sm:text-3xl" />,
|
||||
title: 'Email',
|
||||
content: 'contact@deafgain.com'
|
||||
},
|
||||
{
|
||||
icon: <FaMapMarkerAlt />,
|
||||
icon: <FaMapMarkerAlt className="text-2xl sm:text-3xl" />,
|
||||
title: 'Location',
|
||||
content: 'Montana, United States'
|
||||
}
|
||||
|
|
@ -62,19 +60,20 @@ const Contact = () => {
|
|||
|
||||
return (
|
||||
<div className="min-h-screen bg-accent-snow">
|
||||
<div className="max-w-7xl mx-auto px-4 py-20">
|
||||
<div className="max-w-7xl mx-auto px-4 py-12 sm:py-20">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="text-center mb-16"
|
||||
className="text-center mb-8 sm:mb-16"
|
||||
>
|
||||
<h1 className="text-4xl font-bold text-secondary mb-4">Get in Touch</h1>
|
||||
<p className="text-accent-mountain text-lg">
|
||||
<h1 className="text-3xl sm:text-4xl font-bold text-secondary mb-4">Get in Touch</h1>
|
||||
<p className="text-accent-mountain text-base sm:text-lg max-w-2xl mx-auto px-4">
|
||||
We're here to help and answer any questions you might have
|
||||
</p>
|
||||
</motion.div>
|
||||
<div className="grid md:grid-cols-2 gap-8 mb-16 max-w-2xl mx-auto">
|
||||
|
||||
<div className="grid sm:grid-cols-2 gap-6 mb-12 max-w-2xl mx-auto">
|
||||
{contactInfo.map((info, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
|
|
@ -83,21 +82,22 @@ const Contact = () => {
|
|||
transition={{ delay: index * 0.2 }}
|
||||
className="bg-white p-6 rounded-lg shadow-lg text-center"
|
||||
>
|
||||
<div className="text-primary text-3xl mb-4">{info.icon}</div>
|
||||
<h3 className="text-xl font-semibold mb-2">{info.title}</h3>
|
||||
<div className="text-primary mb-4">{info.icon}</div>
|
||||
<h3 className="text-lg sm:text-xl font-semibold mb-2">{info.title}</h3>
|
||||
<p className="text-accent-mountain">{info.content}</p>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.4 }}
|
||||
className="max-w-2xl mx-auto bg-white rounded-lg shadow-lg p-8"
|
||||
className="max-w-2xl mx-auto bg-white rounded-lg shadow-lg p-6 sm:p-8"
|
||||
>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="mb-6">
|
||||
<label htmlFor="name" className="block text-accent-mountain mb-2">
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-accent-mountain mb-2 text-sm sm:text-base">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
|
|
@ -105,15 +105,16 @@ const Contact = () => {
|
|||
id="name"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-accent-lake rounded-lg focus:outline-none focus:border-primary"
|
||||
className="w-full px-4 py-3 border border-accent-lake rounded-lg focus:outline-none focus:border-primary
|
||||
text-base sm:text-lg"
|
||||
required
|
||||
minLength={2}
|
||||
maxLength={50}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<label htmlFor="email" className="block text-accent-mountain mb-2">
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-accent-mountain mb-2 text-sm sm:text-base">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
|
|
@ -121,21 +122,23 @@ const Contact = () => {
|
|||
id="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-accent-lake rounded-lg focus:outline-none focus:border-primary"
|
||||
className="w-full px-4 py-3 border border-accent-lake rounded-lg focus:outline-none focus:border-primary
|
||||
text-base sm:text-lg"
|
||||
required
|
||||
pattern="[^@\s]+@[^@\s]+\.[^@\s]+"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<label htmlFor="service" className="block text-accent-mountain mb-2">
|
||||
<div>
|
||||
<label htmlFor="service" className="block text-accent-mountain mb-2 text-sm sm:text-base">
|
||||
Service Interested In
|
||||
</label>
|
||||
<select
|
||||
id="service"
|
||||
value={formData.service}
|
||||
onChange={(e) => setFormData({ ...formData, service: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-accent-lake rounded-lg focus:outline-none focus:border-primary"
|
||||
className="w-full px-4 py-3 border border-accent-lake rounded-lg focus:outline-none focus:border-primary
|
||||
text-base sm:text-lg bg-white"
|
||||
required
|
||||
>
|
||||
<option value="">Select a service</option>
|
||||
|
|
@ -146,15 +149,16 @@ const Contact = () => {
|
|||
</select>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<label htmlFor="message" className="block text-accent-mountain mb-2">
|
||||
<div>
|
||||
<label htmlFor="message" className="block text-accent-mountain mb-2 text-sm sm:text-base">
|
||||
Message
|
||||
</label>
|
||||
<textarea
|
||||
id="message"
|
||||
value={formData.message}
|
||||
onChange={(e) => setFormData({ ...formData, message: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-accent-lake rounded-lg focus:outline-none focus:border-primary h-32"
|
||||
className="w-full px-4 py-3 border border-accent-lake rounded-lg focus:outline-none focus:border-primary
|
||||
text-base sm:text-lg min-h-[120px] sm:min-h-[150px]"
|
||||
required
|
||||
minLength={10}
|
||||
maxLength={1000}
|
||||
|
|
@ -166,11 +170,11 @@ const Contact = () => {
|
|||
whileTap={{ scale: 0.98 }}
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className={`w-full py-3 rounded-lg font-semibold transition-colors ${
|
||||
isSubmitting
|
||||
className={`w-full py-4 rounded-lg font-semibold text-base sm:text-lg transition-colors
|
||||
${isSubmitting
|
||||
? 'bg-accent-mountain text-accent-snow cursor-not-allowed'
|
||||
: 'bg-primary text-accent-snow hover:bg-secondary'
|
||||
}`}
|
||||
: 'bg-primary text-accent-snow hover:bg-secondary active:bg-secondary'
|
||||
}`}
|
||||
>
|
||||
{isSubmitting ? 'Sending...' : 'Send Message'}
|
||||
</motion.button>
|
||||
|
|
|
|||
|
|
@ -20,7 +20,11 @@ interface Category {
|
|||
videos: Video[]
|
||||
}
|
||||
|
||||
const ITEMS_PER_PAGE = 6
|
||||
const ITEMS_PER_PAGE = {
|
||||
mobile: 3,
|
||||
tablet: 4,
|
||||
desktop: 6
|
||||
}
|
||||
|
||||
const resourceCategories: Category[] = [
|
||||
{
|
||||
|
|
@ -73,9 +77,22 @@ const Resources = () => {
|
|||
const [currentPage, setCurrentPage] = useState<Record<string, number>>({})
|
||||
const [showTranscript, setShowTranscript] = useState(false)
|
||||
const [transcriptContent, setTranscriptContent] = useState('')
|
||||
const [screenWidth, setScreenWidth] = useState(window.innerWidth)
|
||||
const popupRef = useRef<HTMLDivElement>(null)
|
||||
const transcriptRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => setScreenWidth(window.innerWidth)
|
||||
window.addEventListener('resize', handleResize)
|
||||
return () => window.removeEventListener('resize', handleResize)
|
||||
}, [])
|
||||
|
||||
const getItemsPerPage = () => {
|
||||
if (screenWidth < 640) return ITEMS_PER_PAGE.mobile
|
||||
if (screenWidth < 1024) return ITEMS_PER_PAGE.tablet
|
||||
return ITEMS_PER_PAGE.desktop
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (popupRef.current && !popupRef.current.contains(event.target as Node)) {
|
||||
|
|
@ -102,59 +119,66 @@ const Resources = () => {
|
|||
}
|
||||
}
|
||||
|
||||
const getPageCount = (videoCount: number) => Math.ceil(videoCount / ITEMS_PER_PAGE)
|
||||
const getPageCount = (videoCount: number) => Math.ceil(videoCount / getItemsPerPage())
|
||||
|
||||
const getCurrentPageVideos = (videos: Video[], categoryId: string) => {
|
||||
const page = currentPage[categoryId] || 1
|
||||
const start = (page - 1) * ITEMS_PER_PAGE
|
||||
return videos.slice(start, start + ITEMS_PER_PAGE)
|
||||
const itemsPerPage = getItemsPerPage()
|
||||
const start = (page - 1) * itemsPerPage
|
||||
return videos.slice(start, start + itemsPerPage)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-accent-snow">
|
||||
<div className="max-w-7xl mx-auto px-4 py-20">
|
||||
<h1 className="text-4xl font-bold text-secondary mb-16 text-center">
|
||||
<div className="max-w-7xl mx-auto px-4 py-12 sm:py-20">
|
||||
<h1 className="text-3xl sm:text-4xl font-bold text-secondary mb-12 sm:mb-16 text-center">
|
||||
Resource Library
|
||||
</h1>
|
||||
|
||||
{resourceCategories.map((category) => (
|
||||
<div key={category.id} className="mb-20">
|
||||
<div key={category.id} className="mb-16">
|
||||
<div className="mb-8">
|
||||
<h2 className="text-2xl font-bold text-secondary mb-2">{category.title}</h2>
|
||||
<h2 className="text-xl sm:text-2xl font-bold text-secondary mb-2">{category.title}</h2>
|
||||
<p className="text-accent-mountain">{category.description}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{getCurrentPageVideos(category.videos, category.id).map((video) => (
|
||||
<div key={video.id} className="group">
|
||||
<div className="relative aspect-video overflow-hidden rounded-lg shadow-lg">
|
||||
<motion.div
|
||||
key={video.id}
|
||||
layout
|
||||
className="group bg-white rounded-lg shadow-md overflow-hidden hover:shadow-xl transition-shadow duration-300"
|
||||
>
|
||||
<div className="relative aspect-video">
|
||||
<img
|
||||
src={video.thumbnail}
|
||||
alt={video.title}
|
||||
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity duration-300">
|
||||
<div className="absolute inset-0 flex items-center justify-center gap-4">
|
||||
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex items-center justify-center gap-4">
|
||||
<button
|
||||
onClick={() => setSelectedVideo(video)}
|
||||
className="bg-primary/90 hover:bg-primary text-white px-4 py-2 rounded-full flex items-center gap-2 transition-colors"
|
||||
aria-label={`Watch ${video.title}`}
|
||||
>
|
||||
<FaVideo /> Watch
|
||||
</button>
|
||||
{video.transcriptSrc && (
|
||||
<button
|
||||
onClick={() => setSelectedVideo(video)}
|
||||
className="bg-primary/90 hover:bg-primary text-white px-4 py-2 rounded-full flex items-center gap-2 transition-colors"
|
||||
onClick={() => fetchTranscript(video.id)}
|
||||
className="bg-secondary/90 hover:bg-secondary text-white px-4 py-2 rounded-full flex items-center gap-2 transition-colors"
|
||||
aria-label={`Read transcript of ${video.title}`}
|
||||
>
|
||||
<FaVideo /> Watch Video
|
||||
<FaBookReader /> Transcript
|
||||
</button>
|
||||
{video.transcriptSrc && (
|
||||
<button
|
||||
onClick={() => fetchTranscript(video.id)}
|
||||
className="bg-secondary/90 hover:bg-secondary text-white px-4 py-2 rounded-full flex items-center gap-2 transition-colors"
|
||||
>
|
||||
<FaBookReader /> Read Transcript
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="mt-4 text-lg font-semibold text-secondary">{video.title}</h3>
|
||||
<p className="text-sm text-accent-mountain">{video.description}</p>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<h3 className="text-lg font-semibold text-secondary mb-2">{video.title}</h3>
|
||||
<p className="text-sm text-accent-mountain line-clamp-2">{video.description}</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
|
|
@ -164,11 +188,13 @@ const Resources = () => {
|
|||
<button
|
||||
key={idx}
|
||||
onClick={() => setCurrentPage({ ...currentPage, [category.id]: idx + 1 })}
|
||||
className={`w-10 h-10 rounded-full flex items-center justify-center ${
|
||||
(currentPage[category.id] || 1) === idx + 1
|
||||
className={`w-10 h-10 rounded-full flex items-center justify-center transition-colors
|
||||
${(currentPage[category.id] || 1) === idx + 1
|
||||
? 'bg-primary text-white'
|
||||
: 'bg-white text-primary border border-primary'
|
||||
}`}
|
||||
: 'bg-white text-primary border border-primary hover:bg-primary/10'
|
||||
}`}
|
||||
aria-label={`Page ${idx + 1}`}
|
||||
aria-current={(currentPage[category.id] || 1) === idx + 1 ? 'page' : undefined}
|
||||
>
|
||||
{idx + 1}
|
||||
</button>
|
||||
|
|
@ -181,12 +207,13 @@ const Resources = () => {
|
|||
|
||||
{/* Video Modal */}
|
||||
{selectedVideo && (
|
||||
<div className="fixed inset-0 bg-black/90 backdrop-blur-sm flex items-center justify-center z-50">
|
||||
<div className="w-4/5 max-w-6xl" ref={popupRef}>
|
||||
<div className="fixed inset-0 bg-black/90 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
||||
<div className="w-full max-w-6xl" ref={popupRef}>
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setSelectedVideo(null)}
|
||||
className="absolute -top-12 right-0 text-white hover:text-accent-lake bg-black/50 p-2 rounded-full transition-colors"
|
||||
aria-label="Close video"
|
||||
>
|
||||
<FaTimes className="w-6 h-6" />
|
||||
</button>
|
||||
|
|
@ -207,13 +234,13 @@ const Resources = () => {
|
|||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 bg-black/90 backdrop-blur-sm flex items-center justify-center z-50"
|
||||
className="fixed inset-0 bg-black/90 backdrop-blur-sm flex items-center justify-center z-50 p-4"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ y: 50, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={{ y: 50, opacity: 0 }}
|
||||
className="w-4/5 max-w-4xl bg-white rounded-xl shadow-2xl"
|
||||
className="w-full max-w-4xl bg-white rounded-xl shadow-2xl"
|
||||
ref={transcriptRef}
|
||||
>
|
||||
<div className="p-6 flex justify-between items-center border-b border-gray-200">
|
||||
|
|
@ -221,11 +248,12 @@ const Resources = () => {
|
|||
<button
|
||||
onClick={() => setShowTranscript(false)}
|
||||
className="text-accent-mountain hover:text-secondary transition-colors bg-accent-snow/50 p-2 rounded-full"
|
||||
aria-label="Close transcript"
|
||||
>
|
||||
<FaTimes size={24} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-6 max-h-[70vh] overflow-y-auto prose prose-slate">
|
||||
<div className="p-6 max-h-[60vh] overflow-y-auto prose prose-slate">
|
||||
{transcriptContent.split('\n\n').map((paragraph, index) => (
|
||||
<p key={index} className="mb-4 text-accent-mountain leading-relaxed">
|
||||
{paragraph}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,14 @@ export default {
|
|||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
screens: {
|
||||
'xs': '375px',
|
||||
'sm': '640px',
|
||||
'md': '768px',
|
||||
'lg': '1024px',
|
||||
'xl': '1280px',
|
||||
'2xl': '1536px',
|
||||
},
|
||||
extend: {
|
||||
colors: {
|
||||
'primary': '#647C90', // Slate blue
|
||||
|
|
@ -14,7 +22,49 @@ export default {
|
|||
'mountain': '#4A5568', // Mountain gray
|
||||
'lake': '#A4C3D2', // Alpine lake blue
|
||||
}
|
||||
}
|
||||
},
|
||||
fontSize: {
|
||||
'xs': ['0.75rem', { lineHeight: '1rem' }],
|
||||
'sm': ['0.875rem', { lineHeight: '1.25rem' }],
|
||||
'base': ['1rem', { lineHeight: '1.5rem' }],
|
||||
'lg': ['1.125rem', { lineHeight: '1.75rem' }],
|
||||
'xl': ['1.25rem', { lineHeight: '1.75rem' }],
|
||||
'2xl': ['1.5rem', { lineHeight: '2rem' }],
|
||||
'3xl': ['1.875rem', { lineHeight: '2.25rem' }],
|
||||
'4xl': ['2.25rem', { lineHeight: '2.5rem' }],
|
||||
},
|
||||
spacing: {
|
||||
'18': '4.5rem',
|
||||
'22': '5.5rem',
|
||||
},
|
||||
maxWidth: {
|
||||
'xxs': '16rem',
|
||||
'mobile': '20rem',
|
||||
},
|
||||
minHeight: {
|
||||
'screen-75': '75vh',
|
||||
},
|
||||
padding: {
|
||||
'safe': 'env(safe-area-inset-bottom)',
|
||||
},
|
||||
zIndex: {
|
||||
'60': '60',
|
||||
'70': '70',
|
||||
},
|
||||
animation: {
|
||||
'fade-in': 'fadeIn 0.3s ease-in-out',
|
||||
'slide-up': 'slideUp 0.4s ease-out',
|
||||
},
|
||||
keyframes: {
|
||||
fadeIn: {
|
||||
'0%': { opacity: '0' },
|
||||
'100%': { opacity: '1' },
|
||||
},
|
||||
slideUp: {
|
||||
'0%': { transform: 'translateY(10px)', opacity: '0' },
|
||||
'100%': { transform: 'translateY(0)', opacity: '1' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue