feat: frontend artist page and song linking
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:
parent
61715a119c
commit
b67d4929a4
3 changed files with 168 additions and 3 deletions
|
|
@ -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:
|
||||||
|
|
|
||||||
161
frontend/app/artists/[slug]/page.tsx
Normal file
161
frontend/app/artists/[slug]/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue