feat: Add container max-width, revamp homepage with real data, add setlist import scripts
This commit is contained in:
parent
f9cdd626f4
commit
8fa04e9690
5 changed files with 587 additions and 35 deletions
128
backend/fast_import_setlists.py
Normal file
128
backend/fast_import_setlists.py
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
"""
|
||||
Fast Setlist Importer - Skips validation checks for speed
|
||||
Run after main import if it times out
|
||||
"""
|
||||
import requests
|
||||
from datetime import datetime
|
||||
from sqlmodel import Session, select
|
||||
from database import engine
|
||||
from models import Show, Song, Performance
|
||||
|
||||
BASE_URL = "https://elgoose.net/api/v2"
|
||||
|
||||
def fetch_json(endpoint, params=None):
|
||||
url = f"{BASE_URL}/{endpoint}.json"
|
||||
try:
|
||||
response = requests.get(url, params=params, timeout=30)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
if data.get('error') == 1:
|
||||
return None
|
||||
return data.get('data', [])
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
return None
|
||||
|
||||
def main():
|
||||
print("FAST SETLIST IMPORTER")
|
||||
print("=" * 40)
|
||||
|
||||
with Session(engine) as session:
|
||||
# Build lookup maps
|
||||
print("Building show map...")
|
||||
shows = session.exec(select(Show)).all()
|
||||
# Map API show_id to our show_id
|
||||
# We need the API show_id. The 'notes' field might have showtitle but not API id.
|
||||
# Problem: We don't store API show_id in our DB!
|
||||
# We need to re-fetch shows and match by date+venue_id
|
||||
|
||||
show_map = {} # api_show_id -> our_show_id
|
||||
song_map = {} # api_song_id -> our_song_id
|
||||
|
||||
# Fetch shows from API to build map
|
||||
print("Fetching API shows to build map...")
|
||||
page = 1
|
||||
while True:
|
||||
data = fetch_json("shows", {"artist": 1, "page": page})
|
||||
if not data:
|
||||
break
|
||||
for s in data:
|
||||
api_show_id = s['show_id']
|
||||
show_date = datetime.strptime(s['showdate'], '%Y-%m-%d')
|
||||
# Find matching show in our DB
|
||||
our_show = session.exec(
|
||||
select(Show).where(Show.date == show_date)
|
||||
).first()
|
||||
if our_show:
|
||||
show_map[api_show_id] = our_show.id
|
||||
page += 1
|
||||
if page > 50:
|
||||
break
|
||||
print(f"Mapped {len(show_map)} shows")
|
||||
|
||||
# Build song map
|
||||
print("Building song map...")
|
||||
songs_data = fetch_json("songs")
|
||||
if songs_data:
|
||||
for s in songs_data:
|
||||
api_song_id = s['id']
|
||||
our_song = session.exec(
|
||||
select(Song).where(Song.title == s['name'])
|
||||
).first()
|
||||
if our_song:
|
||||
song_map[api_song_id] = our_song.id
|
||||
print(f"Mapped {len(song_map)} songs")
|
||||
|
||||
# Get existing performances to skip
|
||||
print("Loading existing performances...")
|
||||
existing = set()
|
||||
perfs = session.exec(select(Performance.show_id, Performance.song_id, Performance.position)).all()
|
||||
for p in perfs:
|
||||
existing.add((p[0], p[1], p[2]))
|
||||
print(f"Found {len(existing)} existing performances")
|
||||
|
||||
# Import setlists page by page
|
||||
print("\\nImporting setlists...")
|
||||
page = 1
|
||||
total_added = 0
|
||||
|
||||
while True:
|
||||
data = fetch_json("setlists", {"page": page})
|
||||
if not data:
|
||||
print(f"No data on page {page}, done.")
|
||||
break
|
||||
|
||||
added_this_page = 0
|
||||
for perf_data in data:
|
||||
our_show_id = show_map.get(perf_data.get('show_id'))
|
||||
our_song_id = song_map.get(perf_data.get('song_id'))
|
||||
position = perf_data.get('position', 0)
|
||||
|
||||
if not our_show_id or not our_song_id:
|
||||
continue
|
||||
|
||||
key = (our_show_id, our_song_id, position)
|
||||
if key in existing:
|
||||
continue
|
||||
|
||||
perf = Performance(
|
||||
show_id=our_show_id,
|
||||
song_id=our_song_id,
|
||||
position=position,
|
||||
set_name=perf_data.get('set'),
|
||||
segue=bool(perf_data.get('segue', 0)),
|
||||
notes=perf_data.get('notes')
|
||||
)
|
||||
session.add(perf)
|
||||
existing.add(key)
|
||||
added_this_page += 1
|
||||
total_added += 1
|
||||
|
||||
session.commit()
|
||||
print(f"Page {page}: +{added_this_page} ({total_added} total)")
|
||||
page += 1
|
||||
|
||||
print(f"\\n✓ Added {total_added} performances")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
103
backend/import_by_date.py
Normal file
103
backend/import_by_date.py
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
"""
|
||||
Direct Date-Based Setlist Importer
|
||||
Matches setlists to shows by date
|
||||
"""
|
||||
import requests
|
||||
from datetime import datetime
|
||||
from sqlmodel import Session, select
|
||||
from database import engine
|
||||
from models import Show, Song, Performance
|
||||
|
||||
BASE_URL = "https://elgoose.net/api/v2"
|
||||
|
||||
def main():
|
||||
print("DATE-BASED SETLIST IMPORTER")
|
||||
print("=" * 40)
|
||||
|
||||
with Session(engine) as session:
|
||||
# Build show map by date
|
||||
shows = session.exec(select(Show)).all()
|
||||
show_by_date = {}
|
||||
for s in shows:
|
||||
date_str = s.date.strftime('%Y-%m-%d')
|
||||
show_by_date[date_str] = s.id
|
||||
print(f"Mapped {len(show_by_date)} shows by date")
|
||||
|
||||
# Build song map by title (lowercase)
|
||||
songs = session.exec(select(Song)).all()
|
||||
song_map = {s.title.lower().strip(): s.id for s in songs}
|
||||
print(f"Mapped {len(song_map)} songs")
|
||||
|
||||
# Get existing performances for dedup
|
||||
existing = set()
|
||||
perfs = session.exec(
|
||||
select(Performance.show_id, Performance.song_id, Performance.position)
|
||||
).all()
|
||||
for p in perfs:
|
||||
existing.add((p[0], p[1], p[2]))
|
||||
print(f"Found {len(existing)} existing performances")
|
||||
|
||||
# Fetch setlists page by page
|
||||
print("\\nImporting setlists...")
|
||||
page = 1
|
||||
total_added = 0
|
||||
|
||||
while page <= 200: # Safety limit
|
||||
print(f" Page {page}...", end="", flush=True)
|
||||
|
||||
url = f"{BASE_URL}/setlists.json"
|
||||
try:
|
||||
resp = requests.get(url, params={"page": page}, timeout=60)
|
||||
data = resp.json().get('data', [])
|
||||
except Exception as e:
|
||||
print(f" Error: {e}")
|
||||
break
|
||||
|
||||
if not data:
|
||||
print(" Done (no data)")
|
||||
break
|
||||
|
||||
added_this_page = 0
|
||||
for item in data:
|
||||
# Get showdate and match to our shows
|
||||
showdate = item.get('showdate')
|
||||
if not showdate:
|
||||
continue
|
||||
|
||||
our_show_id = show_by_date.get(showdate)
|
||||
if not our_show_id:
|
||||
continue
|
||||
|
||||
# Match song
|
||||
song_name = (item.get('songname') or '').lower().strip()
|
||||
song_id = song_map.get(song_name)
|
||||
if not song_id:
|
||||
continue
|
||||
|
||||
position = item.get('position', 0)
|
||||
key = (our_show_id, song_id, position)
|
||||
|
||||
if key in existing:
|
||||
continue
|
||||
|
||||
perf = Performance(
|
||||
show_id=our_show_id,
|
||||
song_id=song_id,
|
||||
position=position,
|
||||
set_name=item.get('setnumber'),
|
||||
segue=(item.get('transition', ', ') != ', '),
|
||||
notes=item.get('footnote')
|
||||
)
|
||||
session.add(perf)
|
||||
existing.add(key)
|
||||
added_this_page += 1
|
||||
total_added += 1
|
||||
|
||||
session.commit()
|
||||
print(f" +{added_this_page} ({total_added} total)")
|
||||
page += 1
|
||||
|
||||
print(f"\\n✓ Added {total_added} performances")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
131
backend/import_per_show.py
Normal file
131
backend/import_per_show.py
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
"""
|
||||
Per-Show Setlist Importer
|
||||
Fetches setlist for each show individually
|
||||
"""
|
||||
import requests
|
||||
from sqlmodel import Session, select
|
||||
from database import engine
|
||||
from models import Show, Song, Performance
|
||||
|
||||
BASE_URL = "https://elgoose.net/api/v2"
|
||||
|
||||
def fetch_show_setlist(api_show_id):
|
||||
"""Fetch setlist for a specific show"""
|
||||
url = f"{BASE_URL}/setlists/showid/{api_show_id}.json"
|
||||
try:
|
||||
response = requests.get(url, timeout=30)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
if data.get('error') == 1:
|
||||
return None
|
||||
return data.get('data', [])
|
||||
except Exception as e:
|
||||
return None
|
||||
|
||||
def main():
|
||||
print("PER-SHOW SETLIST IMPORTER")
|
||||
print("=" * 40)
|
||||
|
||||
with Session(engine) as session:
|
||||
# Get all shows
|
||||
shows = session.exec(select(Show)).all()
|
||||
print(f"Found {len(shows)} shows in database")
|
||||
|
||||
# Build song map by title
|
||||
songs = session.exec(select(Song)).all()
|
||||
song_map = {s.title.lower(): s.id for s in songs}
|
||||
print(f"Mapped {len(song_map)} songs")
|
||||
|
||||
# Get existing performances
|
||||
existing = set()
|
||||
perfs = session.exec(
|
||||
select(Performance.show_id, Performance.song_id, Performance.position)
|
||||
).all()
|
||||
for p in perfs:
|
||||
existing.add((p[0], p[1], p[2]))
|
||||
print(f"Found {len(existing)} existing performances")
|
||||
|
||||
# We need API show IDs. The ElGoose API shows endpoint returns show_id.
|
||||
# Let's fetch and correlate by date
|
||||
print("Fetching API shows to get API IDs...")
|
||||
api_shows = {} # date_str -> api_show_id
|
||||
|
||||
page = 1
|
||||
while True:
|
||||
url = f"{BASE_URL}/shows.json"
|
||||
try:
|
||||
resp = requests.get(url, params={"artist": 1, "page": page}, timeout=30)
|
||||
data = resp.json().get('data', [])
|
||||
if not data:
|
||||
break
|
||||
for s in data:
|
||||
date_str = s['showdate']
|
||||
api_shows[date_str] = s['show_id']
|
||||
page += 1
|
||||
if page > 50:
|
||||
break
|
||||
except:
|
||||
break
|
||||
|
||||
print(f"Got {len(api_shows)} API show IDs")
|
||||
|
||||
# Now import setlists for each show
|
||||
total_added = 0
|
||||
processed = 0
|
||||
|
||||
for show in shows:
|
||||
date_str = show.date.strftime('%Y-%m-%d')
|
||||
api_show_id = api_shows.get(date_str)
|
||||
|
||||
if not api_show_id:
|
||||
continue
|
||||
|
||||
# Check if we already have performances for this show
|
||||
existing_for_show = session.exec(
|
||||
select(Performance).where(Performance.show_id == show.id)
|
||||
).first()
|
||||
|
||||
if existing_for_show:
|
||||
continue # Skip shows that already have performances
|
||||
|
||||
# Fetch setlist
|
||||
setlist = fetch_show_setlist(api_show_id)
|
||||
if not setlist:
|
||||
continue
|
||||
|
||||
added = 0
|
||||
for item in setlist:
|
||||
song_title = item.get('songname', '').lower()
|
||||
song_id = song_map.get(song_title)
|
||||
|
||||
if not song_id:
|
||||
continue
|
||||
|
||||
position = item.get('position', 0)
|
||||
key = (show.id, song_id, position)
|
||||
|
||||
if key in existing:
|
||||
continue
|
||||
|
||||
perf = Performance(
|
||||
show_id=show.id,
|
||||
song_id=song_id,
|
||||
position=position,
|
||||
set_name=item.get('set'),
|
||||
segue=bool(item.get('segue', 0)),
|
||||
notes=item.get('footnote')
|
||||
)
|
||||
session.add(perf)
|
||||
existing.add(key)
|
||||
added += 1
|
||||
total_added += 1
|
||||
|
||||
if added > 0:
|
||||
session.commit()
|
||||
processed += 1
|
||||
print(f"Show {date_str}: +{added} songs ({total_added} total)")
|
||||
|
||||
print(f"\\n✓ Added {total_added} performances from {processed} shows")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -120,3 +120,27 @@
|
|||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
/* Container constraints for large screens */
|
||||
.container {
|
||||
width: 100%;
|
||||
max-width: 1280px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
padding-left: 1.5rem;
|
||||
padding-right: 1.5rem;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.container {
|
||||
padding-left: 2rem;
|
||||
padding-right: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.container {
|
||||
padding-left: 4rem;
|
||||
padding-right: 4rem;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,81 @@
|
|||
import { ActivityFeed } from "@/components/feed/activity-feed"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import Link from "next/link"
|
||||
import { Trophy, Music, MapPin, Users } from "lucide-react"
|
||||
import { Trophy, Music, MapPin, Calendar, ChevronRight, Star } from "lucide-react"
|
||||
import { getApiUrl } from "@/lib/api-config"
|
||||
|
||||
interface Show {
|
||||
id: number
|
||||
date: string
|
||||
venue?: {
|
||||
id: number
|
||||
name: string
|
||||
city?: string
|
||||
state?: string
|
||||
}
|
||||
tour?: {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
}
|
||||
|
||||
interface Song {
|
||||
id: number
|
||||
title: string
|
||||
times_played?: number
|
||||
avg_rating?: number
|
||||
}
|
||||
|
||||
async function getRecentShows(): Promise<Show[]> {
|
||||
try {
|
||||
const res = await fetch(`${getApiUrl()}/shows?limit=8&sort=date&order=desc`, {
|
||||
cache: 'no-store',
|
||||
next: { revalidate: 60 }
|
||||
})
|
||||
if (!res.ok) return []
|
||||
return res.json()
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch recent shows:', e)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
async function getTopSongs(): Promise<Song[]> {
|
||||
try {
|
||||
const res = await fetch(`${getApiUrl()}/songs?limit=5&sort=times_played&order=desc`, {
|
||||
cache: 'no-store',
|
||||
next: { revalidate: 300 }
|
||||
})
|
||||
if (!res.ok) return []
|
||||
return res.json()
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch top songs:', e)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
async function getStats() {
|
||||
try {
|
||||
const [showsRes, songsRes, venuesRes] = await Promise.all([
|
||||
fetch(`${getApiUrl()}/shows?limit=1`, { cache: 'no-store' }),
|
||||
fetch(`${getApiUrl()}/songs?limit=1`, { cache: 'no-store' }),
|
||||
fetch(`${getApiUrl()}/venues?limit=1`, { cache: 'no-store' })
|
||||
])
|
||||
// These endpoints return arrays, we need to get counts differently
|
||||
// For now we'll just show the data we have
|
||||
return { shows: 0, songs: 0, venues: 0 }
|
||||
} catch (e) {
|
||||
return { shows: 0, songs: 0, venues: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
export default async function Home() {
|
||||
const [recentShows, topSongs] = await Promise.all([
|
||||
getRecentShows(),
|
||||
getTopSongs()
|
||||
])
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
{/* Hero Section */}
|
||||
|
|
@ -31,45 +103,139 @@ export default function Home() {
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<div className="grid gap-8 md:grid-cols-2">
|
||||
{/* Activity Feed */}
|
||||
<section className="space-y-4">
|
||||
{/* Recent Shows */}
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-2xl font-bold flex items-center gap-2">
|
||||
<Calendar className="h-6 w-6 text-blue-500" />
|
||||
Recent Shows
|
||||
</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.id}`}>
|
||||
<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-2xl font-bold">Recent Activity</h2>
|
||||
<Link href="/leaderboards" className="text-sm text-muted-foreground hover:underline">
|
||||
View all activity
|
||||
<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
|
||||
href={`/songs/${song.id}`}
|
||||
className="flex items-center gap-3 p-3 hover:bg-accent/50 transition-colors"
|
||||
>
|
||||
<span className="text-lg font-bold text-muted-foreground w-6 text-center">
|
||||
{idx + 1}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium truncate">{song.title}</div>
|
||||
{song.times_played && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{song.times_played} performances
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<div className="p-4 text-center text-muted-foreground text-sm">
|
||||
No songs yet
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* Activity Feed */}
|
||||
<section className="space-y-4 lg:col-span-2">
|
||||
<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>
|
||||
|
||||
{/* Quick Stats / Leaderboard Preview */}
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-2xl font-bold">Explore</h2>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Link href="/shows" className="block p-6 border rounded-xl hover:bg-accent transition-colors">
|
||||
<Music className="h-8 w-8 mb-2 text-blue-500" />
|
||||
<h3 className="font-bold">Shows</h3>
|
||||
<p className="text-sm text-muted-foreground">Browse the archive</p>
|
||||
</Link>
|
||||
<Link href="/venues" className="block p-6 border rounded-xl hover:bg-accent transition-colors">
|
||||
<MapPin className="h-8 w-8 mb-2 text-green-500" />
|
||||
<h3 className="font-bold">Venues</h3>
|
||||
<p className="text-sm text-muted-foreground">Find your favorite spots</p>
|
||||
</Link>
|
||||
<Link href="/leaderboards" className="block p-6 border rounded-xl hover:bg-accent transition-colors">
|
||||
<Users className="h-8 w-8 mb-2 text-purple-500" />
|
||||
<h3 className="font-bold">Community</h3>
|
||||
<p className="text-sm text-muted-foreground">Join the conversation</p>
|
||||
</Link>
|
||||
<Link href="/leaderboards" className="block p-6 border rounded-xl hover:bg-accent transition-colors">
|
||||
<Trophy className="h-8 w-8 mb-2 text-yellow-500" />
|
||||
<h3 className="font-bold">Leaderboards</h3>
|
||||
<p className="text-sm text-muted-foreground">Top rated everything</p>
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Quick Links */}
|
||||
<section className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Link href="/shows" className="block p-6 border rounded-xl hover:bg-accent transition-colors group">
|
||||
<Music 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-xl 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-xl 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="/leaderboards" className="block p-6 border rounded-xl 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">Leaderboards</h3>
|
||||
<p className="text-sm text-muted-foreground">Top rated everything</p>
|
||||
</Link>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue