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 { useState, useEffect, useRef } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link, useLocation } from 'react-router-dom'
|
||||||
import { motion } from 'framer-motion'
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
import { HiMenu, HiX } from 'react-icons/hi'
|
import { HiMenu, HiX } from 'react-icons/hi'
|
||||||
|
|
||||||
const Navbar = () => {
|
const Navbar = () => {
|
||||||
const [isOpen, setIsOpen] = useState(false)
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
const menuRef = useRef<HTMLDivElement>(null)
|
||||||
|
const location = useLocation()
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ name: 'Home', path: '/' },
|
{ name: 'Home', path: '/' },
|
||||||
{ name: 'Services', path: '/services' },
|
{ name: 'Services', path: '/services' },
|
||||||
|
|
@ -14,11 +16,31 @@ const Navbar = () => {
|
||||||
{ name: 'Contact', path: '/contact' }
|
{ 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 (
|
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="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<div className="flex items-center justify-between h-24">
|
<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
|
DeafGain
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
|
@ -29,7 +51,11 @@ const Navbar = () => {
|
||||||
<Link
|
<Link
|
||||||
key={item.name}
|
key={item.name}
|
||||||
to={item.path}
|
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}
|
{item.name}
|
||||||
</Link>
|
</Link>
|
||||||
|
|
@ -38,38 +64,51 @@ const Navbar = () => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile menu button */}
|
{/* Mobile menu button */}
|
||||||
<div className="md:hidden">
|
<button
|
||||||
<button
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
className="md:hidden inline-flex items-center justify-center p-2 rounded-md
|
||||||
className="text-accent-snow"
|
text-accent-snow hover:text-accent-lake hover:bg-secondary/20
|
||||||
>
|
transition-colors duration-200"
|
||||||
{isOpen ? <HiX size={24} /> : <HiMenu size={24} />}
|
aria-expanded={isOpen}
|
||||||
</button>
|
aria-controls="mobile-menu"
|
||||||
</div>
|
aria-label={isOpen ? "Close menu" : "Open menu"}
|
||||||
|
>
|
||||||
|
{isOpen ? <HiX size={28} /> : <HiMenu size={28} />}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile Navigation */}
|
{/* Mobile Navigation */}
|
||||||
{isOpen && (
|
<AnimatePresence>
|
||||||
<motion.div
|
{isOpen && (
|
||||||
initial={{ opacity: 0, y: -20 }}
|
<motion.div
|
||||||
animate={{ opacity: 1, y: 0 }}
|
ref={menuRef}
|
||||||
className="md:hidden"
|
id="mobile-menu"
|
||||||
>
|
initial={{ opacity: 0, y: -20 }}
|
||||||
<div className="px-2 pt-2 pb-3 space-y-1 sm:px-3">
|
animate={{ opacity: 1, y: 0 }}
|
||||||
{navItems.map((item) => (
|
exit={{ opacity: 0, y: -20 }}
|
||||||
<Link
|
transition={{ duration: 0.2 }}
|
||||||
key={item.name}
|
className="md:hidden absolute w-full bg-primary shadow-lg"
|
||||||
to={item.path}
|
>
|
||||||
className="block px-3 py-2 hover:bg-secondary rounded-md"
|
<div className="px-4 pt-2 pb-3 space-y-1">
|
||||||
onClick={() => setIsOpen(false)}
|
{navItems.map((item) => (
|
||||||
>
|
<Link
|
||||||
{item.name}
|
key={item.name}
|
||||||
</Link>
|
to={item.path}
|
||||||
))}
|
className={`block px-3 py-4 text-base font-medium rounded-md
|
||||||
</div>
|
transition-colors duration-200
|
||||||
</motion.div>
|
${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>
|
</nav>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
import { motion, AnimatePresence } from 'framer-motion'
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
import { FaTimes } from 'react-icons/fa'
|
import { FaTimes } from 'react-icons/fa'
|
||||||
|
import { useEffect } from 'react'
|
||||||
|
|
||||||
interface ToastProps {
|
interface ToastProps {
|
||||||
message: string
|
message: string
|
||||||
|
|
@ -15,30 +16,49 @@ export default function Toast({ message, type, isVisible, onClose }: ToastProps)
|
||||||
? 'from-primary to-secondary'
|
? 'from-primary to-secondary'
|
||||||
: 'from-red-600 to-red-800'
|
: '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 (
|
return (
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{isVisible && (
|
{isVisible && (
|
||||||
<motion.div
|
<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 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
>
|
>
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, scale: 0.9 }}
|
initial={{ opacity: 0, scale: 0.9, y: 50 }}
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
exit={{ opacity: 0, scale: 0.9 }}
|
exit={{ opacity: 0, scale: 0.9, y: 50 }}
|
||||||
className="w-full max-w-md bg-white rounded-xl shadow-2xl overflow-hidden mx-4"
|
className="w-full max-w-sm pointer-events-auto"
|
||||||
>
|
>
|
||||||
<div className={`h-2 bg-gradient-to-r ${bgColor}`} />
|
<div className="overflow-hidden rounded-lg shadow-2xl">
|
||||||
<div className="p-6 relative">
|
<div className={`h-2 bg-gradient-to-r ${bgColor}`} />
|
||||||
<button
|
<div className="p-4 bg-white relative">
|
||||||
onClick={onClose}
|
<div className="flex items-start">
|
||||||
className="absolute top-2 right-2 text-accent-mountain hover:text-secondary p-2 rounded-full transition-colors"
|
<div className="flex-1 pt-0.5">
|
||||||
>
|
<p className="text-sm sm:text-base text-accent-mountain text-center pr-8">
|
||||||
<FaTimes size={24} />
|
{message}
|
||||||
</button>
|
</p>
|
||||||
<p className="text-lg font-medium text-accent-mountain text-center mt-2">{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>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
|
||||||
|
|
@ -15,9 +15,7 @@ const Contact = () => {
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|
||||||
if (isSubmitting) return
|
if (isSubmitting) return
|
||||||
|
|
||||||
setIsSubmitting(true)
|
setIsSubmitting(true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -49,12 +47,12 @@ const Contact = () => {
|
||||||
|
|
||||||
const contactInfo = [
|
const contactInfo = [
|
||||||
{
|
{
|
||||||
icon: <FaEnvelope />,
|
icon: <FaEnvelope className="text-2xl sm:text-3xl" />,
|
||||||
title: 'Email',
|
title: 'Email',
|
||||||
content: 'contact@deafgain.com'
|
content: 'contact@deafgain.com'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: <FaMapMarkerAlt />,
|
icon: <FaMapMarkerAlt className="text-2xl sm:text-3xl" />,
|
||||||
title: 'Location',
|
title: 'Location',
|
||||||
content: 'Montana, United States'
|
content: 'Montana, United States'
|
||||||
}
|
}
|
||||||
|
|
@ -62,19 +60,20 @@ const Contact = () => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-accent-snow">
|
<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
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.8 }}
|
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>
|
<h1 className="text-3xl sm:text-4xl font-bold text-secondary mb-4">Get in Touch</h1>
|
||||||
<p className="text-accent-mountain text-lg">
|
<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
|
We're here to help and answer any questions you might have
|
||||||
</p>
|
</p>
|
||||||
</motion.div>
|
</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) => (
|
{contactInfo.map((info, index) => (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={index}
|
key={index}
|
||||||
|
|
@ -83,21 +82,22 @@ const Contact = () => {
|
||||||
transition={{ delay: index * 0.2 }}
|
transition={{ delay: index * 0.2 }}
|
||||||
className="bg-white p-6 rounded-lg shadow-lg text-center"
|
className="bg-white p-6 rounded-lg shadow-lg text-center"
|
||||||
>
|
>
|
||||||
<div className="text-primary text-3xl mb-4">{info.icon}</div>
|
<div className="text-primary mb-4">{info.icon}</div>
|
||||||
<h3 className="text-xl font-semibold mb-2">{info.title}</h3>
|
<h3 className="text-lg sm:text-xl font-semibold mb-2">{info.title}</h3>
|
||||||
<p className="text-accent-mountain">{info.content}</p>
|
<p className="text-accent-mountain">{info.content}</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
transition={{ delay: 0.4 }}
|
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}>
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
<div className="mb-6">
|
<div>
|
||||||
<label htmlFor="name" className="block text-accent-mountain mb-2">
|
<label htmlFor="name" className="block text-accent-mountain mb-2 text-sm sm:text-base">
|
||||||
Name
|
Name
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
|
|
@ -105,15 +105,16 @@ const Contact = () => {
|
||||||
id="name"
|
id="name"
|
||||||
value={formData.name}
|
value={formData.name}
|
||||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
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
|
required
|
||||||
minLength={2}
|
minLength={2}
|
||||||
maxLength={50}
|
maxLength={50}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mb-6">
|
<div>
|
||||||
<label htmlFor="email" className="block text-accent-mountain mb-2">
|
<label htmlFor="email" className="block text-accent-mountain mb-2 text-sm sm:text-base">
|
||||||
Email
|
Email
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
|
|
@ -121,21 +122,23 @@ const Contact = () => {
|
||||||
id="email"
|
id="email"
|
||||||
value={formData.email}
|
value={formData.email}
|
||||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
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
|
required
|
||||||
pattern="[^@\s]+@[^@\s]+\.[^@\s]+"
|
pattern="[^@\s]+@[^@\s]+\.[^@\s]+"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mb-6">
|
<div>
|
||||||
<label htmlFor="service" className="block text-accent-mountain mb-2">
|
<label htmlFor="service" className="block text-accent-mountain mb-2 text-sm sm:text-base">
|
||||||
Service Interested In
|
Service Interested In
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
id="service"
|
id="service"
|
||||||
value={formData.service}
|
value={formData.service}
|
||||||
onChange={(e) => setFormData({ ...formData, service: e.target.value })}
|
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
|
required
|
||||||
>
|
>
|
||||||
<option value="">Select a service</option>
|
<option value="">Select a service</option>
|
||||||
|
|
@ -146,15 +149,16 @@ const Contact = () => {
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mb-6">
|
<div>
|
||||||
<label htmlFor="message" className="block text-accent-mountain mb-2">
|
<label htmlFor="message" className="block text-accent-mountain mb-2 text-sm sm:text-base">
|
||||||
Message
|
Message
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
id="message"
|
id="message"
|
||||||
value={formData.message}
|
value={formData.message}
|
||||||
onChange={(e) => setFormData({ ...formData, message: e.target.value })}
|
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
|
required
|
||||||
minLength={10}
|
minLength={10}
|
||||||
maxLength={1000}
|
maxLength={1000}
|
||||||
|
|
@ -166,11 +170,11 @@ const Contact = () => {
|
||||||
whileTap={{ scale: 0.98 }}
|
whileTap={{ scale: 0.98 }}
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
className={`w-full py-3 rounded-lg font-semibold transition-colors ${
|
className={`w-full py-4 rounded-lg font-semibold text-base sm:text-lg transition-colors
|
||||||
isSubmitting
|
${isSubmitting
|
||||||
? 'bg-accent-mountain text-accent-snow cursor-not-allowed'
|
? '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'}
|
{isSubmitting ? 'Sending...' : 'Send Message'}
|
||||||
</motion.button>
|
</motion.button>
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,11 @@ interface Category {
|
||||||
videos: Video[]
|
videos: Video[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const ITEMS_PER_PAGE = 6
|
const ITEMS_PER_PAGE = {
|
||||||
|
mobile: 3,
|
||||||
|
tablet: 4,
|
||||||
|
desktop: 6
|
||||||
|
}
|
||||||
|
|
||||||
const resourceCategories: Category[] = [
|
const resourceCategories: Category[] = [
|
||||||
{
|
{
|
||||||
|
|
@ -73,9 +77,22 @@ const Resources = () => {
|
||||||
const [currentPage, setCurrentPage] = useState<Record<string, number>>({})
|
const [currentPage, setCurrentPage] = useState<Record<string, number>>({})
|
||||||
const [showTranscript, setShowTranscript] = useState(false)
|
const [showTranscript, setShowTranscript] = useState(false)
|
||||||
const [transcriptContent, setTranscriptContent] = useState('')
|
const [transcriptContent, setTranscriptContent] = useState('')
|
||||||
|
const [screenWidth, setScreenWidth] = useState(window.innerWidth)
|
||||||
const popupRef = useRef<HTMLDivElement>(null)
|
const popupRef = useRef<HTMLDivElement>(null)
|
||||||
const transcriptRef = 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(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
if (popupRef.current && !popupRef.current.contains(event.target as Node)) {
|
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 getCurrentPageVideos = (videos: Video[], categoryId: string) => {
|
||||||
const page = currentPage[categoryId] || 1
|
const page = currentPage[categoryId] || 1
|
||||||
const start = (page - 1) * ITEMS_PER_PAGE
|
const itemsPerPage = getItemsPerPage()
|
||||||
return videos.slice(start, start + ITEMS_PER_PAGE)
|
const start = (page - 1) * itemsPerPage
|
||||||
|
return videos.slice(start, start + itemsPerPage)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-accent-snow">
|
<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">
|
||||||
<h1 className="text-4xl font-bold text-secondary mb-16 text-center">
|
<h1 className="text-3xl sm:text-4xl font-bold text-secondary mb-12 sm:mb-16 text-center">
|
||||||
Resource Library
|
Resource Library
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
{resourceCategories.map((category) => (
|
{resourceCategories.map((category) => (
|
||||||
<div key={category.id} className="mb-20">
|
<div key={category.id} className="mb-16">
|
||||||
<div className="mb-8">
|
<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>
|
<p className="text-accent-mountain">{category.description}</p>
|
||||||
</div>
|
</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) => (
|
{getCurrentPageVideos(category.videos, category.id).map((video) => (
|
||||||
<div key={video.id} className="group">
|
<motion.div
|
||||||
<div className="relative aspect-video overflow-hidden rounded-lg shadow-lg">
|
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
|
<img
|
||||||
src={video.thumbnail}
|
src={video.thumbnail}
|
||||||
alt={video.title}
|
alt={video.title}
|
||||||
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
|
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 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex items-center justify-center gap-4">
|
||||||
<div className="absolute inset-0 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
|
<button
|
||||||
onClick={() => setSelectedVideo(video)}
|
onClick={() => fetchTranscript(video.id)}
|
||||||
className="bg-primary/90 hover:bg-primary text-white px-4 py-2 rounded-full flex items-center gap-2 transition-colors"
|
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>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="mt-4 text-lg font-semibold text-secondary">{video.title}</h3>
|
<div className="p-4">
|
||||||
<p className="text-sm text-accent-mountain">{video.description}</p>
|
<h3 className="text-lg font-semibold text-secondary mb-2">{video.title}</h3>
|
||||||
</div>
|
<p className="text-sm text-accent-mountain line-clamp-2">{video.description}</p>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -164,11 +188,13 @@ const Resources = () => {
|
||||||
<button
|
<button
|
||||||
key={idx}
|
key={idx}
|
||||||
onClick={() => setCurrentPage({ ...currentPage, [category.id]: idx + 1 })}
|
onClick={() => setCurrentPage({ ...currentPage, [category.id]: idx + 1 })}
|
||||||
className={`w-10 h-10 rounded-full flex items-center justify-center ${
|
className={`w-10 h-10 rounded-full flex items-center justify-center transition-colors
|
||||||
(currentPage[category.id] || 1) === idx + 1
|
${(currentPage[category.id] || 1) === idx + 1
|
||||||
? 'bg-primary text-white'
|
? '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}
|
{idx + 1}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -181,12 +207,13 @@ const Resources = () => {
|
||||||
|
|
||||||
{/* Video Modal */}
|
{/* Video Modal */}
|
||||||
{selectedVideo && (
|
{selectedVideo && (
|
||||||
<div className="fixed inset-0 bg-black/90 backdrop-blur-sm flex items-center justify-center z-50">
|
<div className="fixed inset-0 bg-black/90 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
||||||
<div className="w-4/5 max-w-6xl" ref={popupRef}>
|
<div className="w-full max-w-6xl" ref={popupRef}>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<button
|
<button
|
||||||
onClick={() => setSelectedVideo(null)}
|
onClick={() => setSelectedVideo(null)}
|
||||||
className="absolute -top-12 right-0 text-white hover:text-accent-lake bg-black/50 p-2 rounded-full transition-colors"
|
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" />
|
<FaTimes className="w-6 h-6" />
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -207,13 +234,13 @@ const Resources = () => {
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
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
|
<motion.div
|
||||||
initial={{ y: 50, opacity: 0 }}
|
initial={{ y: 50, opacity: 0 }}
|
||||||
animate={{ y: 0, opacity: 1 }}
|
animate={{ y: 0, opacity: 1 }}
|
||||||
exit={{ y: 50, opacity: 0 }}
|
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}
|
ref={transcriptRef}
|
||||||
>
|
>
|
||||||
<div className="p-6 flex justify-between items-center border-b border-gray-200">
|
<div className="p-6 flex justify-between items-center border-b border-gray-200">
|
||||||
|
|
@ -221,11 +248,12 @@ const Resources = () => {
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowTranscript(false)}
|
onClick={() => setShowTranscript(false)}
|
||||||
className="text-accent-mountain hover:text-secondary transition-colors bg-accent-snow/50 p-2 rounded-full"
|
className="text-accent-mountain hover:text-secondary transition-colors bg-accent-snow/50 p-2 rounded-full"
|
||||||
|
aria-label="Close transcript"
|
||||||
>
|
>
|
||||||
<FaTimes size={24} />
|
<FaTimes size={24} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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) => (
|
{transcriptContent.split('\n\n').map((paragraph, index) => (
|
||||||
<p key={index} className="mb-4 text-accent-mountain leading-relaxed">
|
<p key={index} className="mb-4 text-accent-mountain leading-relaxed">
|
||||||
{paragraph}
|
{paragraph}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,14 @@ export default {
|
||||||
"./src/**/*.{js,ts,jsx,tsx}",
|
"./src/**/*.{js,ts,jsx,tsx}",
|
||||||
],
|
],
|
||||||
theme: {
|
theme: {
|
||||||
|
screens: {
|
||||||
|
'xs': '375px',
|
||||||
|
'sm': '640px',
|
||||||
|
'md': '768px',
|
||||||
|
'lg': '1024px',
|
||||||
|
'xl': '1280px',
|
||||||
|
'2xl': '1536px',
|
||||||
|
},
|
||||||
extend: {
|
extend: {
|
||||||
colors: {
|
colors: {
|
||||||
'primary': '#647C90', // Slate blue
|
'primary': '#647C90', // Slate blue
|
||||||
|
|
@ -14,7 +22,49 @@ export default {
|
||||||
'mountain': '#4A5568', // Mountain gray
|
'mountain': '#4A5568', // Mountain gray
|
||||||
'lake': '#A4C3D2', // Alpine lake blue
|
'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: [],
|
plugins: [],
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue