feat(videos): add search, video toggle, and fix links
Some checks are pending
Deploy Elmeg / deploy (push) Waiting to run

This commit is contained in:
fullsizemalt 2025-12-24 10:20:39 -08:00
parent 1e554f553a
commit 4aefad1eff
2 changed files with 174 additions and 102 deletions

View file

@ -31,7 +31,9 @@ def get_all_videos(
Show.slug.label("show_slug"), Show.slug.label("show_slug"),
Venue.name.label("venue_name"), Venue.name.label("venue_name"),
Venue.city.label("venue_city"), Venue.city.label("venue_city"),
Venue.state.label("venue_state") Venue.state.label("venue_state"),
Performance.slug.label("performance_slug"),
Venue.slug.label("venue_slug")
) )
.join(Song, Performance.song_id == Song.id) .join(Song, Performance.song_id == Song.id)
.join(Show, Performance.show_id == Show.id) .join(Show, Performance.show_id == Show.id)
@ -57,7 +59,9 @@ def get_all_videos(
"show_slug": r[7], "show_slug": r[7],
"venue_name": r[8], "venue_name": r[8],
"venue_city": r[9], "venue_city": r[9],
"venue_state": r[10] "venue_state": r[10],
"performance_slug": r[11],
"venue_slug": r[12]
} }
for r in perf_results for r in perf_results
] ]
@ -71,7 +75,8 @@ def get_all_videos(
Show.slug, Show.slug,
Venue.name.label("venue_name"), Venue.name.label("venue_name"),
Venue.city.label("venue_city"), Venue.city.label("venue_city"),
Venue.state.label("venue_state") Venue.state.label("venue_state"),
Venue.slug.label("venue_slug")
) )
.join(Venue, Show.venue_id == Venue.id) .join(Venue, Show.venue_id == Venue.id)
.where(Show.youtube_link != None) .where(Show.youtube_link != None)
@ -91,7 +96,8 @@ def get_all_videos(
"show_slug": r[3], "show_slug": r[3],
"venue_name": r[4], "venue_name": r[4],
"venue_city": r[5], "venue_city": r[5],
"venue_state": r[6] "venue_state": r[6],
"venue_slug": r[7]
} }
for r in show_results for r in show_results
] ]

View file

@ -21,6 +21,8 @@ interface PerformanceVideo {
venue_name: string venue_name: string
venue_city: string venue_city: string
venue_state: string | null venue_state: string | null
performance_slug?: string
venue_slug: string
} }
interface ShowVideo { interface ShowVideo {
@ -32,6 +34,7 @@ interface ShowVideo {
venue_name: string venue_name: string
venue_city: string venue_city: string
venue_state: string | null venue_state: string | null
venue_slug: string
} }
interface VideoStats { interface VideoStats {
@ -46,6 +49,8 @@ export default function VideosPage() {
const [stats, setStats] = useState<VideoStats | null>(null) const [stats, setStats] = useState<VideoStats | null>(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [activeTab, setActiveTab] = useState<"all" | "songs" | "shows">("all") const [activeTab, setActiveTab] = useState<"all" | "songs" | "shows">("all")
const [searchQuery, setSearchQuery] = useState("")
const [expandedVideoId, setExpandedVideoId] = useState<string | null>(null)
useEffect(() => { useEffect(() => {
Promise.all([ Promise.all([
@ -66,6 +71,14 @@ export default function VideosPage() {
return match ? match[1] : null return match ? match[1] : null
} }
const toggleVideo = (uniqueId: string) => {
if (expandedVideoId === uniqueId) {
setExpandedVideoId(null)
} else {
setExpandedVideoId(uniqueId)
}
}
if (loading) { if (loading) {
return ( return (
<div className="container py-10 space-y-6"> <div className="container py-10 space-y-6">
@ -79,14 +92,40 @@ export default function VideosPage() {
) )
} }
const filteredPerformances = activeTab === "shows" ? [] : performances // Filter logic
const filteredShows = activeTab === "songs" ? [] : shows 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 // Combine and sort by date
const allVideos = [ let allVideos = [
...filteredPerformances.map(p => ({ ...p, sortDate: p.date })), ...performances,
...filteredShows.map(s => ({ ...s, sortDate: s.date, song_title: "Full Show" })) ...shows.map(s => ({ ...s, song_title: "Full Show" }))
].sort((a, b) => new Date(b.sortDate).getTime() - new Date(a.sortDate).getTime()) ].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 ( return (
<div className="container py-10 space-y-8"> <div className="container py-10 space-y-8">
@ -102,6 +141,7 @@ export default function VideosPage() {
)} )}
</div> </div>
<div className="flex flex-col md:flex-row gap-4 justify-between items-start md:items-center">
{/* Filter Tabs */} {/* Filter Tabs */}
<div className="flex gap-2"> <div className="flex gap-2">
<button <button
@ -111,7 +151,7 @@ export default function VideosPage() {
: "bg-muted hover:bg-muted/80" : "bg-muted hover:bg-muted/80"
}`} }`}
> >
All Videos All
</button> </button>
<button <button
onClick={() => setActiveTab("songs")} onClick={() => setActiveTab("songs")}
@ -120,8 +160,7 @@ export default function VideosPage() {
: "bg-muted hover:bg-muted/80" : "bg-muted hover:bg-muted/80"
}`} }`}
> >
<Music className="h-4 w-4 inline mr-1" /> Songs
Songs ({stats?.performance_videos})
</button> </button>
<button <button
onClick={() => setActiveTab("shows")} onClick={() => setActiveTab("shows")}
@ -130,79 +169,106 @@ export default function VideosPage() {
: "bg-muted hover:bg-muted/80" : "bg-muted hover:bg-muted/80"
}`} }`}
> >
<Film className="h-4 w-4 inline mr-1" /> Shows
Full Shows ({stats?.full_show_videos})
</button> </button>
</div> </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 */} {/* Video List */}
<Card> <Card>
<CardContent className="p-0"> <CardContent className="p-0">
<div className="divide-y"> <div className="divide-y">
{allVideos.map((video, idx) => ( {allVideos.map((video, idx) => {
<div const uniqueId = `${video.type}-${video.id}`
key={`${video.type}-${video.id}-${idx}`} const isExpanded = expandedVideoId === uniqueId
className="flex items-center gap-4 p-4 hover:bg-muted/50 transition-colors" const videoId = extractVideoId(video.youtube_link)
>
{/* YouTube Icon/Link */} return (
<a <div key={uniqueId} className="flex flex-col">
href={video.youtube_link} <div className="flex items-center gap-4 p-4 hover:bg-muted/50 transition-colors">
target="_blank" {/* Play Icon Trigger */}
rel="noopener noreferrer" <button
onClick={() => toggleVideo(uniqueId)}
className="shrink-0 text-red-600 hover:text-red-500 transition-colors" className="shrink-0 text-red-600 hover:text-red-500 transition-colors"
title="Watch on YouTube" title={isExpanded ? "Close video" : "Watch video"}
> >
<Youtube className="h-6 w-6" /> <Youtube className="h-6 w-6" />
</a> </button>
{/* Content */} {/* Content */}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
{video.type === "full_show" ? ( {video.type === "full_show" ? (
<Link <Link
href={`/shows/${video.show_slug || video.id}`} href={`/shows/${video.show_slug}`}
className="font-medium hover:underline text-primary" className="font-medium hover:underline text-primary text-lg"
> >
Full Show Full Show
</Link> </Link>
) : ( ) : (
<Link <Link
href={`/shows/${(video as PerformanceVideo).show_slug || (video as PerformanceVideo).show_id}`} href={`/performances/${(video as PerformanceVideo).performance_slug}`}
className="font-medium hover:underline" className="font-medium hover:underline text-lg"
> >
{(video as PerformanceVideo).song_title} {(video as PerformanceVideo).song_title}
</Link> </Link>
)} )}
<Badge variant={video.type === "full_show" ? "default" : "secondary"} className="text-xs">
{video.type === "full_show" ? "Full Show" : "Song"}
</Badge>
</div> </div>
<div className="flex items-center gap-3 text-sm text-muted-foreground mt-1"> <div className="flex items-center gap-2 text-sm text-muted-foreground mt-1">
<span className="flex items-center gap-1"> <Link href={`/shows/${video.show_slug}`} className="flex items-center gap-1 hover:underline hover:text-foreground">
<Calendar className="h-3 w-3" /> <Calendar className="h-3 w-3" />
{new Date(video.date).toLocaleDateString(undefined, { {new Date(video.date).toLocaleDateString(undefined, {
year: "numeric", year: "numeric",
month: "short", month: "short",
day: "numeric" day: "numeric"
})} })}
</span> </Link>
<span className="flex items-center gap-1"> <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" /> <MapPin className="h-3 w-3" />
{video.venue_name}, {video.venue_city} {video.venue_name}, {video.venue_city}
{video.venue_state && `, ${video.venue_state}`} </Link>
</span>
</div> </div>
</div> </div>
{/* Show Link */} {/* Watch Button */}
<Link <button
href={`/shows/${video.show_slug || (video.type === "full_show" ? video.id : (video as PerformanceVideo).show_id)}`} onClick={() => toggleVideo(uniqueId)}
className="text-sm text-muted-foreground hover:text-foreground transition-colors shrink-0" className="text-sm font-medium text-primary hover:underline shrink-0"
> >
View Show {isExpanded ? "Close video" : "Watch video"}
</Link> </button>
</div> </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> </div>
</CardContent> </CardContent>
</Card> </Card>