feat: frontend artist page and song linking
Some checks are pending
Deploy Elmeg / deploy (push) Waiting to run

This commit is contained in:
fullsizemalt 2025-12-24 12:22:36 -08:00
parent 61715a119c
commit b67d4929a4
3 changed files with 168 additions and 3 deletions

View file

@ -7,7 +7,7 @@ Migration script to refactor Artists and link Songs.
from sqlmodel import Session, select, text from sqlmodel import Session, select, text
from database import engine from database import engine
from models import Artist, Song from models import Artist, Song
from slugify import slugify from slugify import generate_slug as slugify
def migrate_artists(): def migrate_artists():
with Session(engine) as session: with Session(engine) as session:

View file

@ -0,0 +1,161 @@
import { Metadata } from "next"
import { notFound } from "next/navigation"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Separator } from "@/components/ui/separator"
import Link from "next/link"
interface ArtistPageProps {
params: {
slug: string
}
}
async function getArtist(slug: string) {
const res = await fetch(`${process.env.INTERNAL_API_URL}/artists/${slug}`, {
next: { revalidate: 60 },
})
if (!res.ok) {
if (res.status === 404) return null
throw new Error("Failed to fetch artist")
}
return res.json()
}
export async function generateMetadata({ params }: ArtistPageProps): Promise<Metadata> {
const data = await getArtist(params.slug)
if (!data) return { title: "Artist Not Found" }
return {
title: `${data.artist.name} | Elmeg`,
description: data.artist.bio || `Artist profile for ${data.artist.name} on Elmeg.`,
}
}
export default async function ArtistPage({ params }: ArtistPageProps) {
const data = await getArtist(params.slug)
if (!data) return notFound()
const { artist, covers, guest_appearances } = data
return (
<div className="container py-8 space-y-8">
{/* Header */}
<div className="flex flex-col gap-4">
<div className="flex items-center gap-4">
{artist.image_url ? (
<img
src={artist.image_url}
alt={artist.name}
className="w-24 h-24 rounded-full object-cover border-2 border-primary/20"
/>
) : (
<div className="w-24 h-24 rounded-full bg-accent flex items-center justify-center text-3xl font-bold text-muted-foreground">
{artist.name[0]}
</div>
)}
<div>
<h1 className="text-4xl font-bold tracking-tight">{artist.name}</h1>
{artist.instrument && (
<p className="text-muted-foreground">{artist.instrument}</p>
)}
</div>
</div>
{artist.bio && (
<p className="max-w-3xl text-lg text-muted-foreground leading-relaxed">
{artist.bio}
</p>
)}
</div>
<Separator />
<Tabs defaultValue="covers" className="space-y-6">
<TabsList>
<TabsTrigger value="covers">
Covers
<Badge variant="secondary" className="ml-2">{covers.length}</Badge>
</TabsTrigger>
<TabsTrigger value="guests">
Guest Appearances
<Badge variant="secondary" className="ml-2">{guest_appearances.length}</Badge>
</TabsTrigger>
</TabsList>
<TabsContent value="covers" className="space-y-4">
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{covers.map((song: any) => (
<Link
key={song.id}
href={`/songs/${song.slug}`}
className="block group"
>
<Card className="h-full transition-colors group-hover:bg-accent/50">
<CardHeader>
<CardTitle className="group-hover:text-primary transition-colors">
{song.title}
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Covered by Goose
</p>
</CardContent>
</Card>
</Link>
))}
{covers.length === 0 && (
<div className="col-span-full py-12 text-center text-muted-foreground">
No known covers by this artist.
</div>
)}
</div>
</TabsContent>
<TabsContent value="guests" className="space-y-4">
<div className="border rounded-lg">
<div className="divide-y">
{guest_appearances.map((perf: any, i: number) => (
<div key={i} className="p-4 flex flex-col sm:flex-row sm:items-center justify-between gap-4 hover:bg-accent/50 transition-colors">
<div>
<Link
href={`/shows/${perf.show_slug}`}
className="font-semibold hover:underline"
>
{new Date(perf.date).toLocaleDateString(undefined, {
year: 'numeric',
month: 'long',
day: 'numeric'
})}
</Link>
<p className="text-sm text-muted-foreground">
{perf.venue} {perf.city}
</p>
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">Sat in on:</span>
<Link
href={`/songs/${perf.song_slug}`}
className="font-medium hover:text-primary transition-colors"
>
{perf.song_title}
</Link>
</div>
</div>
))}
{guest_appearances.length === 0 && (
<div className="p-12 text-center text-muted-foreground">
No recorded guest appearances.
</div>
)}
</div>
</div>
</TabsContent>
</Tabs>
</div>
)
}

View file

@ -57,9 +57,13 @@ export default async function SongDetailPage({ params }: { params: Promise<{ slu
<div> <div>
<div className="flex items-baseline gap-3"> <div className="flex items-baseline gap-3">
<h1 className="text-3xl font-bold tracking-tight">{song.title}</h1> <h1 className="text-3xl font-bold tracking-tight">{song.title}</h1>
{song.original_artist && ( {song.artist ? (
<Link href={`/artists/${song.artist.slug}`} className="text-lg text-muted-foreground font-medium hover:text-primary transition-colors">
({song.artist.name})
</Link>
) : song.original_artist ? (
<span className="text-lg text-muted-foreground font-medium">({song.original_artist})</span> <span className="text-lg text-muted-foreground font-medium">({song.original_artist})</span>
)} ) : null}
</div> </div>
{song.tags && song.tags.length > 0 && ( {song.tags && song.tags.length > 0 && (
<div className="flex flex-wrap gap-2 mt-2"> <div className="flex flex-wrap gap-2 mt-2">