feat: Sprint 1 frontend polish

- Add landing page with hero, scenes, featured bands (FR-004)
- Add cross-band versions fetch to song page (FR-008)
- Create sprint plan artifact

Aligns with Specify spec fediversion-multi-band.md
This commit is contained in:
fullsizemalt 2025-12-28 16:38:52 -08:00
parent 159cbc853c
commit 35ce12bc84
2 changed files with 121 additions and 214 deletions

View file

@ -1,253 +1,144 @@
import { ActivityFeed } from "@/components/feed/activity-feed"
import { XPLeaderboard } from "@/components/gamification/xp-leaderboard"
import { Button } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card"
import Link from "next/link" import Link from "next/link"
import { Trophy, Music, MapPin, Calendar, ChevronRight, Star, Youtube, Route } from "lucide-react" import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { getApiUrl } from "@/lib/api-config" import { getApiUrl } from "@/lib/api-config"
interface Show { interface Vertical {
id: number
slug?: string
date: string
venue?: {
id: number id: number
name: string name: string
slug?: string slug: string
city?: string description: string | null
state?: string }
}
tour?: { interface Scene {
id: number id: number
name: string name: string
slug?: string slug: string
} description: string | null
} }
interface Song { async function getVerticals(): Promise<Vertical[]> {
id: number
title: string
slug?: string
performance_count?: number
avg_rating?: number
}
async function getRecentShows(): Promise<Show[]> {
try { try {
const res = await fetch(`${getApiUrl()}/shows/recent?limit=8`, { const res = await fetch(`${getApiUrl()}/verticals`, { next: { revalidate: 60 } })
cache: 'no-store',
next: { revalidate: 60 }
})
if (!res.ok) return [] if (!res.ok) return []
return res.json() return res.json()
} catch (e) { } catch {
console.error('Failed to fetch recent shows:', e)
return [] return []
} }
} }
async function getTopSongs(): Promise<Song[]> { async function getScenes(): Promise<Scene[]> {
try { try {
const res = await fetch(`${getApiUrl()}/stats/top-songs?limit=5`, { const res = await fetch(`${getApiUrl()}/verticals/scenes`, { next: { revalidate: 60 } })
cache: 'no-store',
next: { revalidate: 300 }
})
if (!res.ok) return [] if (!res.ok) return []
return res.json() return res.json()
} catch (e) { } catch {
console.error('Failed to fetch top songs:', e)
return [] return []
} }
} }
export default async function HomePage() {
const [verticals, scenes] = await Promise.all([getVerticals(), getScenes()])
export default async function Home() {
const [recentShows, topSongs] = await Promise.all([
getRecentShows(),
getTopSongs()
])
return ( return (
<div className="flex flex-col gap-8"> <div className="space-y-16">
{/* Hero Section */} {/* Hero Section */}
<section className="flex flex-col items-center gap-4 py-12 text-center md:py-20 bg-gradient-to-b from-background to-accent/20 rounded-lg border"> <section className="text-center py-16 space-y-6">
<h1 className="text-4xl font-bold tracking-tighter sm:text-5xl md:text-6xl"> <h1 className="text-5xl font-bold tracking-tight">
Elmeg Fediversion
</h1> </h1>
<p className="max-w-[600px] text-lg text-muted-foreground"> <p className="text-xl text-muted-foreground max-w-2xl mx-auto">
A comprehensive community-driven archive for Goose history. The unified platform for the entire jam scene.
<br /> One account, all your favorite bands.
Discover shows, share ratings, and explore the music together.
</p> </p>
<div className="flex flex-col sm:flex-row gap-3 sm:gap-4"> <div className="flex justify-center gap-4">
<Link href="/performances"> <Button asChild size="lg">
<Button size="lg" className="gap-2 w-full sm:w-auto"> <Link href="/onboarding">Get Started</Link>
<Trophy className="h-4 w-4" />
Top Performances
</Button> </Button>
</Link> <Button asChild variant="outline" size="lg">
<Link href="/shows"> <Link href="/goose">Explore Shows</Link>
<Button variant="outline" size="lg" className="w-full sm:w-auto">
Browse Shows
</Button> </Button>
</Link>
</div> </div>
</section> </section>
{/* Recent Shows */} {/* Scenes Section */}
<section className="space-y-4"> {scenes.length > 0 && (
<div className="flex items-center justify-between"> <section className="space-y-6">
<h2 className="text-2xl font-bold flex items-center gap-2"> <h2 className="text-2xl font-bold text-center">Browse by Scene</h2>
<Calendar className="h-6 w-6 text-blue-500" /> <div className="grid grid-cols-2 md:grid-cols-4 gap-4 max-w-3xl mx-auto">
Recent Shows {scenes.map((scene) => (
</h2>
<Link href="/shows" className="text-sm text-muted-foreground hover:underline flex items-center gap-1">
View all shows <ChevronRight className="h-4 w-4" />
</Link>
</div>
{recentShows.length > 0 ? (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
{recentShows.map((show) => (
<Link key={show.id} href={`/shows/${show.slug}`}>
<Card className="h-full hover:bg-accent/50 transition-colors cursor-pointer">
<CardContent className="p-4">
<div className="font-semibold">
{new Date(show.date).toLocaleDateString('en-US', {
weekday: 'short',
year: 'numeric',
month: 'short',
day: 'numeric'
})}
</div>
{show.venue && (
<div className="text-sm text-muted-foreground mt-1">
{show.venue.name}
</div>
)}
{show.venue?.city && (
<div className="text-xs text-muted-foreground">
{show.venue.city}{show.venue.state ? `, ${show.venue.state}` : ''}
</div>
)}
{show.tour && (
<div className="text-xs text-primary mt-2">
{show.tour.name}
</div>
)}
</CardContent>
</Card>
</Link>
))}
</div>
) : (
<Card className="p-8 text-center text-muted-foreground">
<p>No shows yet. Check back soon!</p>
</Card>
)}
</section>
<div className="grid gap-8 lg:grid-cols-3">
{/* Top Songs */}
<section className="space-y-4 lg:col-span-1">
<div className="flex items-center justify-between">
<h2 className="text-xl font-bold flex items-center gap-2">
<Star className="h-5 w-5 text-yellow-500" />
Top Songs
</h2>
<Link href="/songs" className="text-sm text-muted-foreground hover:underline flex items-center gap-1">
All songs <ChevronRight className="h-4 w-4" />
</Link>
</div>
<Card>
<CardContent className="p-0">
{topSongs.length > 0 ? (
<ul className="divide-y">
{topSongs.map((song, idx) => (
<li key={song.id}>
<Link <Link
href={`/songs/${song.slug}`} key={scene.slug}
className="flex items-center gap-3 p-3 hover:bg-accent/50 transition-colors" href={`/?scene=${scene.slug}`}
className="block p-4 rounded-lg border bg-card hover:bg-accent transition-colors text-center"
> >
<span className="text-lg font-bold text-muted-foreground w-6 text-center"> <div className="font-semibold">{scene.name}</div>
{idx + 1} {scene.description && (
</span> <div className="text-sm text-muted-foreground mt-1 line-clamp-2">
<div className="flex-1 min-w-0"> {scene.description}
<div className="font-medium truncate">{song.title}</div>
{song.performance_count && (
<div className="text-xs text-muted-foreground">
{song.performance_count} performances
</div> </div>
)} )}
</div>
</Link> </Link>
</li>
))} ))}
</ul>
) : (
<div className="p-4 text-center text-muted-foreground text-sm">
No songs yet
</div> </div>
</section>
)} )}
{/* Bands Grid */}
<section className="space-y-6">
<h2 className="text-2xl font-bold text-center">Featured Bands</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{verticals.map((vertical) => (
<Card key={vertical.slug} className="hover:shadow-lg transition-shadow">
<CardHeader>
<CardTitle>
<Link href={`/${vertical.slug}`} className="hover:underline">
{vertical.name}
</Link>
</CardTitle>
{vertical.description && (
<CardDescription>{vertical.description}</CardDescription>
)}
</CardHeader>
<CardContent>
<div className="flex gap-2">
<Button asChild variant="outline" size="sm">
<Link href={`/${vertical.slug}/shows`}>Shows</Link>
</Button>
<Button asChild variant="outline" size="sm">
<Link href={`/${vertical.slug}/songs`}>Songs</Link>
</Button>
<Button asChild variant="outline" size="sm">
<Link href={`/${vertical.slug}/venues`}>Venues</Link>
</Button>
</div>
</CardContent> </CardContent>
</Card> </Card>
</section> ))}
{/* Activity Feed */}
<section className="space-y-4 lg:col-span-1">
<div className="flex items-center justify-between">
<h2 className="text-xl font-bold">Recent Activity</h2>
<Link href="/leaderboards" className="text-sm text-muted-foreground hover:underline flex items-center gap-1">
View all <ChevronRight className="h-4 w-4" />
</Link>
</div> </div>
<ActivityFeed />
</section> </section>
{/* XP Leaderboard */} {/* Stats Section */}
<section className="space-y-4 lg:col-span-1"> <section className="bg-muted/50 rounded-lg p-8 text-center space-y-4">
<XPLeaderboard /> <h2 className="text-2xl font-bold">Join the Community</h2>
<p className="text-muted-foreground">
Track shows, rate performances, discover connections across bands.
</p>
<div className="grid grid-cols-3 gap-6 max-w-md mx-auto pt-4">
<div>
<div className="text-3xl font-bold">{verticals.length}</div>
<div className="text-sm text-muted-foreground">Bands</div>
</div>
<div>
<div className="text-3xl font-bold">{scenes.length}</div>
<div className="text-sm text-muted-foreground">Scenes</div>
</div>
<div>
<div className="text-3xl font-bold">1</div>
<div className="text-sm text-muted-foreground">Account</div>
</div>
</div>
</section> </section>
</div> </div>
{/* Quick Links */}
<section className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Link href="/shows" className="block p-6 border rounded-lg hover:bg-accent transition-colors group">
<Calendar className="h-8 w-8 mb-2 text-blue-500 group-hover:scale-110 transition-transform" />
<h3 className="font-bold">Shows</h3>
<p className="text-sm text-muted-foreground">Browse the complete archive</p>
</Link>
<Link href="/venues" className="block p-6 border rounded-lg hover:bg-accent transition-colors group">
<MapPin className="h-8 w-8 mb-2 text-green-500 group-hover:scale-110 transition-transform" />
<h3 className="font-bold">Venues</h3>
<p className="text-sm text-muted-foreground">Find your favorite spots</p>
</Link>
<Link href="/songs" className="block p-6 border rounded-lg hover:bg-accent transition-colors group">
<Music className="h-8 w-8 mb-2 text-purple-500 group-hover:scale-110 transition-transform" />
<h3 className="font-bold">Songs</h3>
<p className="text-sm text-muted-foreground">Explore the catalog</p>
</Link>
<Link href="/performances" className="block p-6 border rounded-lg hover:bg-accent transition-colors group">
<Trophy className="h-8 w-8 mb-2 text-yellow-500 group-hover:scale-110 transition-transform" />
<h3 className="font-bold">Top Performances</h3>
<p className="text-sm text-muted-foreground">Highest rated jams</p>
</Link>
<Link href="/leaderboards" className="block p-6 border rounded-lg hover:bg-accent transition-colors group">
<Star className="h-8 w-8 mb-2 text-yellow-500 group-hover:scale-110 transition-transform" />
<h3 className="font-bold">Leaderboards</h3>
<p className="text-sm text-muted-foreground">Top rated everything</p>
</Link>
<Link href="/tours" className="block p-6 border rounded-lg hover:bg-accent transition-colors group">
<Route className="h-8 w-8 mb-2 text-orange-500 group-hover:scale-110 transition-transform" />
<h3 className="font-bold">Tours</h3>
<p className="text-sm text-muted-foreground">Browse by tour</p>
</Link>
<Link href="/videos" className="block p-6 border rounded-lg hover:bg-accent transition-colors group">
<Youtube className="h-8 w-8 mb-2 text-red-500 group-hover:scale-110 transition-transform" />
<h3 className="font-bold">Videos</h3>
<p className="text-sm text-muted-foreground">Watch full shows and songs</p>
</Link>
</section>
</div >
) )
} }

View file

@ -25,6 +25,19 @@ async function getSong(id: string) {
} }
} }
// Fetch cross-band versions of this song via SongCanon
async function getRelatedVersions(songId: number) {
try {
const res = await fetch(`${getApiUrl()}/canon/song/${songId}/related`, {
next: { revalidate: 60 }
})
if (!res.ok) return []
return res.json()
} catch {
return []
}
}
// Get top rated performances for "Heady Version" leaderboard // Get top rated performances for "Heady Version" leaderboard
function getHeadyVersions(performances: any[]) { function getHeadyVersions(performances: any[]) {
if (!performances || performances.length === 0) return [] if (!performances || performances.length === 0) return []
@ -45,6 +58,9 @@ export default async function SongDetailPage({ params }: { params: Promise<{ slu
const headyVersions = getHeadyVersions(song.performances || []) const headyVersions = getHeadyVersions(song.performances || [])
const topPerformance = headyVersions[0] const topPerformance = headyVersions[0]
// Fetch cross-band versions
const relatedVersions = await getRelatedVersions(song.id)
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between"> <div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">