deafgain-website/src/pages/Resources.tsx

317 lines
13 KiB
TypeScript

import { useState, useRef, useEffect } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { useNotification } from '../context/notification-context'
import { motion, AnimatePresence } from 'framer-motion'
import { FaBookReader, FaTimes, FaVideo, FaLink } from 'react-icons/fa'
import VideoPlayer from '../components/VideoPlayer'
interface Video {
id: string
title: string
description: string
thumbnail: string
videoSrc: string
vttSrc: string
transcriptSrc?: string
}
interface Category {
id: string
title: string
description: string
videos: Video[]
}
const ITEMS_PER_PAGE = {
mobile: 3,
tablet: 4,
desktop: 6
}
const resourceCategories: Category[] = [
{
id: 'governance',
title: 'Professional Development & Governance',
description: 'Essential training materials for effective organizational management and parliamentary procedures',
videos: [
{
id: 'secretary-role-responsibilities',
title: 'Secretary Role & Responsibilities: Comprehensive Guide',
description: 'Expert parliamentarian Mark Apodaca provides a detailed overview of a secretary\'s essential duties, including keeping meeting minutes, maintaining member records, and managing organizational documentation',
thumbnail: '/images/thumbnails/secretary-role-responsibilities.png',
videoSrc: '/videos/secretary-role-responsibilities.mp4',
vttSrc: '/subtitles/secretary-role-responsibilities.vtt',
transcriptSrc: '/transcriptions/secretary-role-responsibilities.txt'
},
{
id: 'minutes-approval-procedure',
title: 'Meeting Minutes Approval: Expert Guidelines',
description: 'Expert parliamentarian Mark Apodaca explains the proper procedures for reviewing and accepting meeting minutes in non-profit organizations',
thumbnail: '/images/thumbnails/minutes-approval-procedure.png',
videoSrc: '/videos/minutes-approval-procedure.mp4',
vttSrc: '/subtitles/minutes-approval-procedure.vtt',
transcriptSrc: '/transcriptions/minutes-approval-procedure.txt'
},
{
id: 'board-participation-guidelines',
title: 'Board Meeting Participation: Guidelines & Procedures',
description: 'Expert parliamentarian Mark Apodaca explains proper protocols for member participation in board meetings and channels for expressing concerns',
thumbnail: '/images/thumbnails/board-participation-guidelines.png',
videoSrc: '/videos/board-participation-guidelines.mp4',
vttSrc: '/subtitles/board-participation-guidelines.vtt',
transcriptSrc: '/transcriptions/board-participation-guidelines.txt'
},
{
id: 'board-member-reprimands',
title: 'Board Member Reprimands: Due Process Guidelines',
description: 'Expert parliamentarian Mark Apodaca explains proper procedures for handling board member reprimands and the importance of due process in organizational governance',
thumbnail: '/images/thumbnails/board-member-reprimands.png',
videoSrc: '/videos/board-member-reprimands.mp4',
vttSrc: '/subtitles/board-member-reprimands.vtt',
transcriptSrc: '/transcriptions/board-member-reprimands.txt'
},
{
id: 'meeting-minutes-access-rights',
title: 'Meeting Minutes Access Rights: Member Guidelines',
description: 'Expert parliamentarian Mark Apodaca explains members\' rights to access meeting minutes and proper procedures for requesting organizational records',
thumbnail: '/images/thumbnails/meeting-minutes-access-rights.png',
videoSrc: '/videos/meeting-minutes-access-rights.mp4',
vttSrc: '/subtitles/meeting-minutes-access-rights.vtt',
transcriptSrc: '/transcriptions/meeting-minutes-access-rights.txt'
}
]
}
]
const Resources = () => {
const { videoId } = useParams<{ videoId?: string }>()
const navigate = useNavigate()
const { showNotification } = useNotification()
const [selectedVideo, setSelectedVideo] = useState<Video | null>(null)
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)
// Handle direct video links
useEffect(() => {
if (videoId) {
const video = resourceCategories
.flatMap(category => category.videos)
.find(v => v.id === videoId)
if (video) {
setSelectedVideo(video)
}
}
}, [videoId])
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)) {
setSelectedVideo(null)
}
if (transcriptRef.current && !transcriptRef.current.contains(event.target as Node)) {
setShowTranscript(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
const fetchTranscript = async (videoId: string) => {
try {
const response = await fetch(`/transcriptions/${videoId}.txt`)
const text = await response.text()
setTranscriptContent(text)
setShowTranscript(true)
} catch (error) {
console.error('Error fetching transcript:', error)
setTranscriptContent('Transcript not available.')
}
}
const getPageCount = (videoCount: number) => Math.ceil(videoCount / getItemsPerPage())
const getCurrentPageVideos = (videos: Video[], categoryId: string) => {
const page = currentPage[categoryId] || 1
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-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-16">
<div className="mb-8">
<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 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{getCurrentPageVideos(category.videos, category.id).map((video) => (
<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 flex items-center justify-center gap-4">
<div className="flex items-center gap-4">
<button
onClick={() => {
setSelectedVideo(video)
navigate(`/resources/video/${video.id}`, { replace: true })
}}
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>
<button
onClick={() => {
const url = `${window.location.origin}/resources/video/${video.id}`
navigator.clipboard.writeText(url)
showNotification('Link copied to clipboard!', 'success')
}}
className="bg-accent-lake/90 hover:bg-accent-lake text-white px-4 py-2 rounded-full flex items-center gap-2 transition-colors"
aria-label={`Copy link to ${video.title}`}
>
<FaLink /> Copy Link
</button>
</div>
{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"
aria-label={`Read transcript of ${video.title}`}
>
<FaBookReader /> Transcript
</button>
)}
</div>
</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>
{getPageCount(category.videos.length) > 1 && (
<div className="mt-8 flex justify-center items-center space-x-4">
{Array.from({ length: getPageCount(category.videos.length) }).map((_, idx) => (
<button
key={idx}
onClick={() => setCurrentPage({ ...currentPage, [category.id]: 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 hover:bg-primary/10'
}`}
aria-label={`Page ${idx + 1}`}
aria-current={(currentPage[category.id] || 1) === idx + 1 ? 'page' : undefined}
>
{idx + 1}
</button>
))}
</div>
)}
</div>
))}
</div>
{/* Video Modal */}
{selectedVideo && (
<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)
navigate('/resources', { replace: true })
}}
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>
<VideoPlayer
src={selectedVideo.videoSrc}
poster={selectedVideo.thumbnail}
vttSrc={selectedVideo.vttSrc}
/>
</div>
</div>
</div>
)}
{/* Transcript Modal */}
<AnimatePresence>
{showTranscript && (
<motion.div
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 p-4"
>
<motion.div
initial={{ y: 50, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: 50, opacity: 0 }}
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">
<h3 className="text-2xl font-bold text-secondary">Video Transcript</h3>
<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-[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}
</p>
))}
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</div>
)
}
export default Resources