317 lines
13 KiB
TypeScript
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
|