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 >")
|
segue: bool = Field(default=False, description="Transition to next song >")
|
||||||
notes: Optional[str] = Field(default=None)
|
notes: Optional[str] = Field(default=None)
|
||||||
track_url: Optional[str] = Field(default=None, description="Deep link to track audio")
|
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")
|
nicknames: List["PerformanceNickname"] = Relationship(back_populates="performance")
|
||||||
show: "Show" = Relationship(back_populates="performances")
|
show: "Show" = Relationship(back_populates="performances")
|
||||||
|
|
@ -97,6 +98,7 @@ class Show(SQLModel, table=True):
|
||||||
# External Links
|
# External Links
|
||||||
bandcamp_link: Optional[str] = Field(default=None)
|
bandcamp_link: Optional[str] = Field(default=None)
|
||||||
nugs_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")
|
vertical: Vertical = Relationship(back_populates="shows")
|
||||||
venue: Optional[Venue] = 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)
|
original_artist: Optional[str] = Field(default=None)
|
||||||
vertical_id: int = Field(foreign_key="vertical.id")
|
vertical_id: int = Field(foreign_key="vertical.id")
|
||||||
notes: Optional[str] = Field(default=None)
|
notes: Optional[str] = Field(default=None)
|
||||||
|
youtube_link: Optional[str] = Field(default=None)
|
||||||
|
|
||||||
vertical: Vertical = Relationship(back_populates="songs")
|
vertical: Vertical = Relationship(back_populates="songs")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
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 Link from "next/link"
|
||||||
import { CommentSection } from "@/components/social/comment-section"
|
import { CommentSection } from "@/components/social/comment-section"
|
||||||
import { EntityRating } from "@/components/social/entity-rating"
|
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 { SuggestNicknameDialog } from "@/components/shows/suggest-nickname-dialog"
|
||||||
import { EntityReviews } from "@/components/reviews/entity-reviews"
|
import { EntityReviews } from "@/components/reviews/entity-reviews"
|
||||||
import { getApiUrl } from "@/lib/api-config"
|
import { getApiUrl } from "@/lib/api-config"
|
||||||
|
import { YouTubeEmbed } from "@/components/ui/youtube-embed"
|
||||||
|
|
||||||
async function getShow(id: string) {
|
async function getShow(id: string) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -136,6 +137,20 @@ export default async function ShowDetailPage({ params }: { params: Promise<{ id:
|
||||||
</div>
|
</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="grid gap-6 md:grid-cols-[2fr_1fr]">
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<Card>
|
<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