feat: add VideoGallery component to band pages
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s
- VideoGallery component with modal playback - YouTube thumbnail extraction - Responsive grid layout - Added to band home pages - Import script for video entities
This commit is contained in:
parent
1cb08bc778
commit
6d3b30ed6f
3 changed files with 294 additions and 0 deletions
79
backend/scripts/import_videos.py
Normal file
79
backend/scripts/import_videos.py
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
"""
|
||||||
|
Import YouTube links from elmeg as Video entities
|
||||||
|
"""
|
||||||
|
import csv
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, '/Users/ten/DEV/fediversion/backend')
|
||||||
|
|
||||||
|
import psycopg2
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
# Connect to fediversion database via SSH tunnel
|
||||||
|
# Run: ssh -L 5433:localhost:5432 nexus-vector
|
||||||
|
conn = psycopg2.connect(
|
||||||
|
host="localhost",
|
||||||
|
port=5433,
|
||||||
|
database="fediversion",
|
||||||
|
user="fediversion",
|
||||||
|
password="fediversion_password"
|
||||||
|
)
|
||||||
|
conn.autocommit = True
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
# Get Goose vertical_id
|
||||||
|
cur.execute("SELECT id FROM vertical WHERE slug = 'goose'")
|
||||||
|
goose_vertical_id = cur.fetchone()[0]
|
||||||
|
print(f"Goose vertical_id: {goose_vertical_id}")
|
||||||
|
|
||||||
|
# Read CSV
|
||||||
|
videos_created = 0
|
||||||
|
links_created = 0
|
||||||
|
skipped = 0
|
||||||
|
|
||||||
|
with open('/tmp/perf_youtube_full.csv', 'r') as f:
|
||||||
|
reader = csv.DictReader(f)
|
||||||
|
for row in reader:
|
||||||
|
youtube_url = row['youtube_link']
|
||||||
|
show_slug = row['show_slug']
|
||||||
|
song_title = row['song_title']
|
||||||
|
show_date = row['show_date']
|
||||||
|
|
||||||
|
# Check if video already exists
|
||||||
|
cur.execute("SELECT id FROM video WHERE url = %s", (youtube_url,))
|
||||||
|
existing = cur.fetchone()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
video_id = existing[0]
|
||||||
|
else:
|
||||||
|
# Create video
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO video (url, title, platform, video_type, vertical_id, created_at)
|
||||||
|
VALUES (%s, %s, 'youtube', 'single_song', %s, NOW())
|
||||||
|
RETURNING id
|
||||||
|
""", (youtube_url, f"{song_title} - {show_date}", goose_vertical_id))
|
||||||
|
video_id = cur.fetchone()[0]
|
||||||
|
videos_created += 1
|
||||||
|
|
||||||
|
# Find the show in fediversion
|
||||||
|
cur.execute("SELECT id FROM show WHERE slug = %s AND vertical_id = %s", (show_slug, goose_vertical_id))
|
||||||
|
show_result = cur.fetchone()
|
||||||
|
|
||||||
|
if show_result:
|
||||||
|
show_id = show_result[0]
|
||||||
|
# Link video to show
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO videoshow (video_id, show_id)
|
||||||
|
VALUES (%s, %s)
|
||||||
|
ON CONFLICT DO NOTHING
|
||||||
|
""", (video_id, show_id))
|
||||||
|
links_created += 1
|
||||||
|
else:
|
||||||
|
skipped += 1
|
||||||
|
|
||||||
|
print(f"\nResults:")
|
||||||
|
print(f" Videos created: {videos_created}")
|
||||||
|
print(f" Show links created: {links_created}")
|
||||||
|
print(f" Shows not found: {skipped}")
|
||||||
|
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
@ -5,6 +5,7 @@ import Link from "next/link"
|
||||||
import { Calendar, MapPin, Music, Trophy, Video, Ticket, Building, ChevronRight, Play } from "lucide-react"
|
import { Calendar, MapPin, Music, Trophy, Video, Ticket, Building, ChevronRight, Play } from "lucide-react"
|
||||||
import { Card, CardContent } from "@/components/ui/card"
|
import { Card, CardContent } from "@/components/ui/card"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { VideoGallery } from "@/components/videos/video-gallery"
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
params: Promise<{ vertical: string }>
|
params: Promise<{ vertical: string }>
|
||||||
|
|
@ -173,6 +174,15 @@ export default async function VerticalPage({ params }: Props) {
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Videos Section */}
|
||||||
|
<section className="space-y-4">
|
||||||
|
<VideoGallery
|
||||||
|
bandSlug={verticalSlug}
|
||||||
|
limit={8}
|
||||||
|
title="Recent Videos"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
{/* Navigation Cards */}
|
{/* Navigation Cards */}
|
||||||
<section className="space-y-4">
|
<section className="space-y-4">
|
||||||
<h2 className="text-2xl font-bold tracking-tight">Explore</h2>
|
<h2 className="text-2xl font-bold tracking-tight">Explore</h2>
|
||||||
|
|
|
||||||
205
frontend/components/videos/video-gallery.tsx
Normal file
205
frontend/components/videos/video-gallery.tsx
Normal file
|
|
@ -0,0 +1,205 @@
|
||||||
|
"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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue