fediversion/frontend/app/videos/page.tsx
fullsizemalt b4cddf41ea feat: Initialize Fediversion multi-band platform
- Fork elmeg-demo codebase for multi-band support
- Add data importer infrastructure with base class
- Create band-specific importers:
  - phish.py: Phish.net API v5
  - grateful_dead.py: Grateful Stats API
  - setlistfm.py: Dead & Company, Billy Strings (Setlist.fm)
- Add spec-kit configuration for Gemini
- Update README with supported bands and architecture
2025-12-28 12:39:28 -08:00

315 lines
15 KiB
TypeScript

"use client"
import { useEffect, useState } from "react"
import { getApiUrl } from "@/lib/api-config"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Skeleton } from "@/components/ui/skeleton"
import { Youtube, Calendar, MapPin, Music, Film } from "lucide-react"
import Link from "next/link"
interface PerformanceVideo {
type: "performance"
id: number
youtube_link: string
show_id: number
song_id: number
song_title: string
song_slug: string
date: string
show_slug: string
venue_name: string
venue_city: string
venue_state: string | null
performance_slug?: string
venue_slug: string
}
interface ShowVideo {
type: "full_show"
id: number
youtube_link: string
date: string
show_slug: string
venue_name: string
venue_city: string
venue_state: string | null
venue_slug: string
}
interface VideoStats {
performance_videos: number
full_show_videos: number
total: number
}
export default function VideosPage() {
const [performances, setPerformances] = useState<PerformanceVideo[]>([])
const [shows, setShows] = useState<ShowVideo[]>([])
const [stats, setStats] = useState<VideoStats | null>(null)
const [loading, setLoading] = useState(true)
const [activeTab, setActiveTab] = useState<"all" | "songs" | "shows">("all")
const [searchQuery, setSearchQuery] = useState("")
const [expandedVideoId, setExpandedVideoId] = useState<string | null>(null)
useEffect(() => {
Promise.all([
fetch(`${getApiUrl()}/videos/?limit=500`).then(r => r.json()),
fetch(`${getApiUrl()}/videos/stats`).then(r => r.json())
])
.then(([videoData, statsData]) => {
setPerformances(videoData.performances)
setShows(videoData.shows)
setStats(statsData)
})
.catch(console.error)
.finally(() => setLoading(false))
}, [])
const extractVideoId = (url: string) => {
const match = url.match(/[?&]v=([^&]+)/)
return match ? match[1] : null
}
const toggleVideo = (uniqueId: string) => {
if (expandedVideoId === uniqueId) {
setExpandedVideoId(null)
} else {
setExpandedVideoId(uniqueId)
}
}
if (loading) {
return (
<div className="container py-10 space-y-6">
<Skeleton className="h-10 w-48" />
<div className="space-y-2">
{Array.from({ length: 10 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
</div>
)
}
// Filter logic
const query = searchQuery.toLowerCase()
const filterVideo = (v: PerformanceVideo | ShowVideo) => {
if (v.type === "full_show") {
const show = v as ShowVideo
return show.venue_name.toLowerCase().includes(query) ||
show.venue_city.toLowerCase().includes(query) ||
show.date.includes(query)
} else {
const perf = v as PerformanceVideo
return perf.song_title.toLowerCase().includes(query) ||
perf.venue_name.toLowerCase().includes(query) ||
perf.venue_city.toLowerCase().includes(query) ||
perf.date.includes(query)
}
}
// Combine and sort by date
let allVideos = [
...performances,
...shows.map(s => ({ ...s, song_title: "Full Show" }))
].sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
// Apply Tab Filter
if (activeTab === "songs") {
allVideos = allVideos.filter(v => v.type === "performance")
} else if (activeTab === "shows") {
allVideos = allVideos.filter(v => v.type === "full_show")
}
// Apply Search Filter
if (searchQuery) {
allVideos = allVideos.filter(filterVideo)
}
return (
<div className="container py-10 space-y-8">
<div className="flex flex-col gap-4">
<div className="flex items-center gap-3">
<Youtube className="h-8 w-8 text-red-600" />
<h1 className="text-3xl font-bold tracking-tight">Videos</h1>
</div>
{stats && (
<p className="text-muted-foreground">
{stats.total} videos available {stats.full_show_videos} full shows {stats.performance_videos} song performances
</p>
)}
</div>
{/* Special Features Section */}
<div className="bg-muted/30 rounded-lg p-6 border border-primary/20">
<div className="flex items-center gap-2 mb-4">
<Film className="h-5 w-5 text-purple-600" />
<h2 className="text-xl font-semibold">Special Features</h2>
</div>
<div className="grid md:grid-cols-2 gap-4">
<div className="flex flex-col gap-2 p-4 bg-background rounded-md border hover:border-primary/50 transition-colors">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-lg">Show Upon Time</h3>
<Badge variant="secondary">Documentary</Badge>
</div>
<p className="text-sm text-muted-foreground">
An intimate look behind the scenes of Goose's journey.
</p>
<button
onClick={() => toggleVideo("doc-show-upon-time")}
className="text-primary hover:underline text-sm font-medium text-left mt-2 flex items-center gap-2"
>
<Youtube className="h-4 w-4" />
Watch Now
</button>
{expandedVideoId === "doc-show-upon-time" && (
<div className="mt-4 aspect-video w-full rounded-lg overflow-hidden shadow-lg bg-black">
<iframe
width="100%"
height="100%"
src={`https://www.youtube.com/embed/JdWnhOIWh-I?autoplay=1`}
title="Show Upon Time"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
/>
</div>
)}
</div>
</div>
</div>
<div className="flex flex-col md:flex-row gap-4 justify-between items-start md:items-center">
{/* Filter Tabs */}
<div className="flex gap-2">
<button
onClick={() => setActiveTab("all")}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${activeTab === "all"
? "bg-primary text-primary-foreground"
: "bg-muted hover:bg-muted/80"
}`}
>
All
</button>
<button
onClick={() => setActiveTab("songs")}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${activeTab === "songs"
? "bg-primary text-primary-foreground"
: "bg-muted hover:bg-muted/80"
}`}
>
Songs
</button>
<button
onClick={() => setActiveTab("shows")}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${activeTab === "shows"
? "bg-primary text-primary-foreground"
: "bg-muted hover:bg-muted/80"
}`}
>
Shows
</button>
</div>
{/* Search Input */}
<div className="w-full md:w-72">
<input
type="text"
placeholder="Search videos..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
/>
</div>
</div>
{/* Video List */}
<Card>
<CardContent className="p-0">
<div className="divide-y">
{allVideos.map((video, idx) => {
const uniqueId = `${video.type}-${video.id}`
const isExpanded = expandedVideoId === uniqueId
const videoId = extractVideoId(video.youtube_link)
return (
<div key={uniqueId} className="flex flex-col">
<div className="flex items-center gap-4 p-4 hover:bg-muted/50 transition-colors">
{/* Play Icon Trigger */}
<button
onClick={() => toggleVideo(uniqueId)}
className="shrink-0 text-red-600 hover:text-red-500 transition-colors"
title={isExpanded ? "Close video" : "Watch video"}
>
<Youtube className="h-6 w-6" />
</button>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
{video.type === "full_show" ? (
<Link
href={`/shows/${video.show_slug}`}
className="font-medium hover:underline text-primary text-lg"
>
Full Show
</Link>
) : (
<Link
href={`/performances/${(video as PerformanceVideo).performance_slug}`}
className="font-medium hover:underline text-lg"
>
{(video as PerformanceVideo).song_title}
</Link>
)}
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground mt-1">
<Link href={`/shows/${video.show_slug}`} className="flex items-center gap-1 hover:underline hover:text-foreground">
<Calendar className="h-3 w-3" />
{new Date(video.date).toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric"
})}
</Link>
<span></span>
<Link href={`/venues/${video.venue_slug}`} className="flex items-center gap-1 hover:underline hover:text-foreground">
<MapPin className="h-3 w-3" />
{video.venue_name}, {video.venue_city}
</Link>
</div>
</div>
{/* Watch Button */}
<button
onClick={() => toggleVideo(uniqueId)}
className="text-sm font-medium text-primary hover:underline shrink-0"
>
{isExpanded ? "Close video" : "Watch video"}
</button>
</div>
{/* Video Embed Accordion */}
{isExpanded && videoId && (
<div className="bg-black/5 p-4 border-t border-b">
<div className="aspect-video w-full max-w-4xl mx-auto rounded-lg overflow-hidden shadow-xl">
<iframe
width="100%"
height="100%"
src={`https://www.youtube.com/embed/${videoId}?autoplay=1`}
title="YouTube video player"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
/>
</div>
</div>
)}
</div>
)
})}
</div>
</CardContent>
</Card>
</div>
)
}