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:
parent
159cbc853c
commit
35ce12bc84
2 changed files with 121 additions and 214 deletions
|
|
@ -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
|
id: number
|
||||||
slug?: string
|
name: string
|
||||||
date: string
|
slug: string
|
||||||
venue?: {
|
description: string | null
|
||||||
id: number
|
|
||||||
name: string
|
|
||||||
slug?: string
|
|
||||||
city?: string
|
|
||||||
state?: string
|
|
||||||
}
|
|
||||||
tour?: {
|
|
||||||
id: number
|
|
||||||
name: string
|
|
||||||
slug?: string
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Song {
|
interface Scene {
|
||||||
id: number
|
id: number
|
||||||
title: string
|
name: string
|
||||||
slug?: string
|
slug: string
|
||||||
performance_count?: number
|
description: string | null
|
||||||
avg_rating?: number
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getRecentShows(): Promise<Show[]> {
|
async function getVerticals(): Promise<Vertical[]> {
|
||||||
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" />
|
</Button>
|
||||||
Top Performances
|
<Button asChild variant="outline" size="lg">
|
||||||
</Button>
|
<Link href="/goose">Explore Shows</Link>
|
||||||
</Link>
|
</Button>
|
||||||
<Link href="/shows">
|
|
||||||
<Button variant="outline" size="lg" className="w-full sm:w-auto">
|
|
||||||
Browse Shows
|
|
||||||
</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
|
||||||
<Link href="/shows" className="text-sm text-muted-foreground hover:underline flex items-center gap-1">
|
key={scene.slug}
|
||||||
View all shows <ChevronRight className="h-4 w-4" />
|
href={`/?scene=${scene.slug}`}
|
||||||
</Link>
|
className="block p-4 rounded-lg border bg-card hover:bg-accent transition-colors text-center"
|
||||||
</div>
|
>
|
||||||
{recentShows.length > 0 ? (
|
<div className="font-semibold">{scene.name}</div>
|
||||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
{scene.description && (
|
||||||
{recentShows.map((show) => (
|
<div className="text-sm text-muted-foreground mt-1 line-clamp-2">
|
||||||
<Link key={show.id} href={`/shows/${show.slug}`}>
|
{scene.description}
|
||||||
<Card className="h-full hover:bg-accent/50 transition-colors cursor-pointer">
|
</div>
|
||||||
<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>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
</section>
|
||||||
<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">
|
{/* Bands Grid */}
|
||||||
{/* Top Songs */}
|
<section className="space-y-6">
|
||||||
<section className="space-y-4 lg:col-span-1">
|
<h2 className="text-2xl font-bold text-center">Featured Bands</h2>
|
||||||
<div className="flex items-center justify-between">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
<h2 className="text-xl font-bold flex items-center gap-2">
|
{verticals.map((vertical) => (
|
||||||
<Star className="h-5 w-5 text-yellow-500" />
|
<Card key={vertical.slug} className="hover:shadow-lg transition-shadow">
|
||||||
Top Songs
|
<CardHeader>
|
||||||
</h2>
|
<CardTitle>
|
||||||
<Link href="/songs" className="text-sm text-muted-foreground hover:underline flex items-center gap-1">
|
<Link href={`/${vertical.slug}`} className="hover:underline">
|
||||||
All songs <ChevronRight className="h-4 w-4" />
|
{vertical.name}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</CardTitle>
|
||||||
<Card>
|
{vertical.description && (
|
||||||
<CardContent className="p-0">
|
<CardDescription>{vertical.description}</CardDescription>
|
||||||
{topSongs.length > 0 ? (
|
)}
|
||||||
<ul className="divide-y">
|
</CardHeader>
|
||||||
{topSongs.map((song, idx) => (
|
<CardContent>
|
||||||
<li key={song.id}>
|
<div className="flex gap-2">
|
||||||
<Link
|
<Button asChild variant="outline" size="sm">
|
||||||
href={`/songs/${song.slug}`}
|
<Link href={`/${vertical.slug}/shows`}>Shows</Link>
|
||||||
className="flex items-center gap-3 p-3 hover:bg-accent/50 transition-colors"
|
</Button>
|
||||||
>
|
<Button asChild variant="outline" size="sm">
|
||||||
<span className="text-lg font-bold text-muted-foreground w-6 text-center">
|
<Link href={`/${vertical.slug}/songs`}>Songs</Link>
|
||||||
{idx + 1}
|
</Button>
|
||||||
</span>
|
<Button asChild variant="outline" size="sm">
|
||||||
<div className="flex-1 min-w-0">
|
<Link href={`/${vertical.slug}/venues`}>Venues</Link>
|
||||||
<div className="font-medium truncate">{song.title}</div>
|
</Button>
|
||||||
{song.performance_count && (
|
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
{song.performance_count} performances
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
) : (
|
|
||||||
<div className="p-4 text-center text-muted-foreground text-sm">
|
|
||||||
No songs yet
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
</CardContent>
|
||||||
</CardContent>
|
</Card>
|
||||||
</Card>
|
))}
|
||||||
</section>
|
</div>
|
||||||
|
|
||||||
{/* 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>
|
|
||||||
<ActivityFeed />
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* XP Leaderboard */}
|
|
||||||
<section className="space-y-4 lg:col-span-1">
|
|
||||||
<XPLeaderboard />
|
|
||||||
</section>
|
|
||||||
</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>
|
</section>
|
||||||
</div >
|
|
||||||
|
{/* Stats Section */}
|
||||||
|
<section className="bg-muted/50 rounded-lg p-8 text-center space-y-4">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue