fediversion/frontend/components/videos/video-gallery.tsx
fullsizemalt 6d3b30ed6f
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s
feat: add VideoGallery component to band pages
- VideoGallery component with modal playback
- YouTube thumbnail extraction
- Responsive grid layout
- Added to band home pages
- Import script for video entities
2025-12-30 19:32:35 -08:00

205 lines
7.7 KiB
TypeScript

"use client"
import { useState, useEffect } from "react"
import { Card, CardContent } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Play, ExternalLink, Video, Music } from "lucide-react"
import { getApiUrl } from "@/lib/api-config"
interface VideoItem {
id: number
url: string
title: string | null
description: string | null
platform: string
video_type: string
duration_seconds: number | null
thumbnail_url: string | null
external_id: string | null
}
interface VideoGalleryProps {
bandSlug?: string
showId?: number
songId?: number
limit?: number
title?: string
}
function extractYouTubeId(url: string): string | null {
const patterns = [
/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([a-zA-Z0-9_-]{11})/,
]
for (const pattern of patterns) {
const match = url.match(pattern)
if (match) return match[1]
}
return null
}
function getThumbnailUrl(video: VideoItem): string {
if (video.thumbnail_url) return video.thumbnail_url
if (video.platform === "youtube") {
const videoId = extractYouTubeId(video.url)
if (videoId) return `https://img.youtube.com/vi/${videoId}/mqdefault.jpg`
}
return "/placeholder-video.jpg"
}
export function VideoGallery({
bandSlug,
showId,
songId,
limit = 12,
title = "Videos"
}: VideoGalleryProps) {
const [videos, setVideos] = useState<VideoItem[]>([])
const [loading, setLoading] = useState(true)
const [selectedVideo, setSelectedVideo] = useState<VideoItem | null>(null)
useEffect(() => {
async function fetchVideos() {
try {
let endpoint = ""
if (bandSlug) {
endpoint = `${getApiUrl()}/videos/by-band/${bandSlug}?limit=${limit}`
} else if (showId) {
endpoint = `${getApiUrl()}/videos/by-show/${showId}`
} else if (songId) {
endpoint = `${getApiUrl()}/videos/by-song/${songId}`
} else {
endpoint = `${getApiUrl()}/videos/?limit=${limit}`
}
const res = await fetch(endpoint)
if (res.ok) {
const data = await res.json()
setVideos(data)
}
} catch (error) {
console.error("Failed to fetch videos:", error)
} finally {
setLoading(false)
}
}
fetchVideos()
}, [bandSlug, showId, songId, limit])
if (loading) {
return (
<div className="space-y-4">
<h2 className="text-xl font-semibold flex items-center gap-2">
<Video className="h-5 w-5 text-primary" />
{title}
</h2>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{[...Array(4)].map((_, i) => (
<div key={i} className="aspect-video bg-muted animate-pulse rounded-lg" />
))}
</div>
</div>
)
}
if (videos.length === 0) {
return null // Don't show empty section
}
return (
<div className="space-y-4">
<h2 className="text-xl font-semibold flex items-center gap-2">
<Video className="h-5 w-5 text-primary" />
{title}
<span className="text-sm font-normal text-muted-foreground">({videos.length})</span>
</h2>
{/* Video modal */}
{selectedVideo && (
<div
className="fixed inset-0 bg-black/80 z-50 flex items-center justify-center p-4"
onClick={() => setSelectedVideo(null)}
>
<div className="w-full max-w-4xl" onClick={(e) => e.stopPropagation()}>
<div className="aspect-video bg-black rounded-lg overflow-hidden">
{selectedVideo.platform === "youtube" && (
<iframe
src={`https://www.youtube.com/embed/${extractYouTubeId(selectedVideo.url)}?autoplay=1`}
className="w-full h-full"
allowFullScreen
allow="autoplay"
/>
)}
</div>
<div className="mt-4 text-white">
<h3 className="text-lg font-semibold">{selectedVideo.title || "Video"}</h3>
<Button
variant="ghost"
className="mt-2 text-white hover:text-white/80"
onClick={() => setSelectedVideo(null)}
>
Close
</Button>
</div>
</div>
</div>
)}
{/* Video grid */}
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{videos.map((video) => (
<Card
key={video.id}
className="group cursor-pointer hover:border-primary/50 transition-colors overflow-hidden"
onClick={() => setSelectedVideo(video)}
>
<div className="aspect-video relative bg-muted">
{/* Thumbnail */}
<img
src={getThumbnailUrl(video)}
alt={video.title || "Video"}
className="w-full h-full object-cover"
/>
{/* Play overlay */}
<div className="absolute inset-0 bg-black/30 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<div className="w-12 h-12 rounded-full bg-primary/90 flex items-center justify-center">
<Play className="h-6 w-6 text-white fill-white" />
</div>
</div>
{/* Platform badge */}
<div className="absolute top-2 right-2 px-2 py-1 rounded text-xs font-medium bg-black/60 text-white">
{video.platform === "youtube" ? "YouTube" : video.platform}
</div>
</div>
<CardContent className="p-3">
<h3 className="text-sm font-medium line-clamp-2">
{video.title || "Untitled Video"}
</h3>
<div className="flex items-center gap-1 mt-1 text-xs text-muted-foreground">
<Music className="h-3 w-3" />
<span>{video.video_type === "single_song" ? "Song" : video.video_type}</span>
</div>
</CardContent>
</Card>
))}
</div>
{/* View all link */}
{videos.length >= limit && bandSlug && (
<div className="text-center">
<Button variant="outline" asChild>
<a href={`/${bandSlug}/videos`}>
View All Videos
<ExternalLink className="ml-2 h-4 w-4" />
</a>
</Button>
</div>
)}
</div>
)
}