Merge branch 'main' into production
Some checks are pending
Deploy Elmeg / deploy (push) Waiting to run
Some checks are pending
Deploy Elmeg / deploy (push) Waiting to run
This commit is contained in:
commit
96aafce53f
4 changed files with 88 additions and 1 deletions
24
backend/migrations/add_youtube_links.py
Normal file
24
backend/migrations/add_youtube_links.py
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
"""
|
||||
Migration to add youtube_link column to show, song, and performance tables.
|
||||
"""
|
||||
from sqlmodel import Session, create_engine, text
|
||||
from database import DATABASE_URL
|
||||
|
||||
def add_youtube_link_columns():
|
||||
engine = create_engine(DATABASE_URL)
|
||||
|
||||
tables = ['show', 'song', 'performance']
|
||||
|
||||
with Session(engine) as session:
|
||||
for table in tables:
|
||||
try:
|
||||
session.exec(text(f"""
|
||||
ALTER TABLE "{table}" ADD COLUMN IF NOT EXISTS youtube_link VARCHAR
|
||||
"""))
|
||||
session.commit()
|
||||
print(f"✅ Added youtube_link to {table}")
|
||||
except Exception as e:
|
||||
print(f"⚠️ {table}: {e}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
add_youtube_link_columns()
|
||||
|
|
@ -13,6 +13,7 @@ class Performance(SQLModel, table=True):
|
|||
segue: bool = Field(default=False, description="Transition to next song >")
|
||||
notes: Optional[str] = Field(default=None)
|
||||
track_url: Optional[str] = Field(default=None, description="Deep link to track audio")
|
||||
youtube_link: Optional[str] = Field(default=None, description="YouTube video URL")
|
||||
|
||||
nicknames: List["PerformanceNickname"] = Relationship(back_populates="performance")
|
||||
show: "Show" = Relationship(back_populates="performances")
|
||||
|
|
@ -97,6 +98,7 @@ class Show(SQLModel, table=True):
|
|||
# External Links
|
||||
bandcamp_link: Optional[str] = Field(default=None)
|
||||
nugs_link: Optional[str] = Field(default=None)
|
||||
youtube_link: Optional[str] = Field(default=None)
|
||||
|
||||
vertical: Vertical = Relationship(back_populates="shows")
|
||||
venue: Optional[Venue] = Relationship(back_populates="shows")
|
||||
|
|
@ -110,6 +112,7 @@ class Song(SQLModel, table=True):
|
|||
original_artist: Optional[str] = Field(default=None)
|
||||
vertical_id: int = Field(foreign_key="vertical.id")
|
||||
notes: Optional[str] = Field(default=None)
|
||||
youtube_link: Optional[str] = Field(default=None)
|
||||
|
||||
vertical: Vertical = Relationship(back_populates="songs")
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { ArrowLeft, Calendar, MapPin, Music2, Disc, PlayCircle, ExternalLink } from "lucide-react"
|
||||
import { ArrowLeft, Calendar, MapPin, Music2, Disc, PlayCircle, ExternalLink, Youtube } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import { CommentSection } from "@/components/social/comment-section"
|
||||
import { EntityRating } from "@/components/social/entity-rating"
|
||||
|
|
@ -13,6 +13,7 @@ import { notFound } from "next/navigation"
|
|||
import { SuggestNicknameDialog } from "@/components/shows/suggest-nickname-dialog"
|
||||
import { EntityReviews } from "@/components/reviews/entity-reviews"
|
||||
import { getApiUrl } from "@/lib/api-config"
|
||||
import { YouTubeEmbed } from "@/components/ui/youtube-embed"
|
||||
|
||||
async function getShow(id: string) {
|
||||
try {
|
||||
|
|
@ -136,6 +137,20 @@ export default async function ShowDetailPage({ params }: { params: Promise<{ id:
|
|||
</div>
|
||||
)}
|
||||
|
||||
{show.youtube_link && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Youtube className="h-5 w-5 text-red-500" />
|
||||
Video
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<YouTubeEmbed url={show.youtube_link} title={`${show.date?.split('T')[0]} - ${show.venue?.name}`} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-[2fr_1fr]">
|
||||
<div className="flex flex-col gap-6">
|
||||
<Card>
|
||||
|
|
|
|||
45
frontend/components/ui/youtube-embed.tsx
Normal file
45
frontend/components/ui/youtube-embed.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
|
||||
interface YouTubeEmbedProps {
|
||||
url: string
|
||||
title?: string
|
||||
}
|
||||
|
||||
function extractVideoId(url: string): string | null {
|
||||
// Handle various YouTube URL formats
|
||||
const patterns = [
|
||||
/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([^&?/]+)/,
|
||||
/youtube\.com\/v\/([^&?/]+)/,
|
||||
/youtube\.com\/shorts\/([^&?/]+)/
|
||||
]
|
||||
|
||||
for (const pattern of patterns) {
|
||||
const match = url.match(pattern)
|
||||
if (match && match[1]) {
|
||||
return match[1]
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function YouTubeEmbed({ url, title = "YouTube video" }: YouTubeEmbedProps) {
|
||||
const videoId = useMemo(() => extractVideoId(url), [url])
|
||||
|
||||
if (!videoId) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative w-full aspect-video rounded-lg overflow-hidden bg-muted">
|
||||
<iframe
|
||||
src={`https://www.youtube.com/embed/${videoId}`}
|
||||
title={title}
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowFullScreen
|
||||
className="absolute inset-0 w-full h-full"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue