fix: commit all pending changes (home, leaderboard, slug cleanup)
Some checks are pending
Deploy Elmeg / deploy (push) Waiting to run

This commit is contained in:
fullsizemalt 2025-12-24 12:06:35 -08:00
parent 7a549e15ac
commit 49e025d3bf
33 changed files with 457 additions and 427 deletions

View file

@ -0,0 +1,89 @@
from sqlmodel import Session, create_engine, select
import os
from models import Show, Song, Venue, Performance, Tour
from slugify import generate_slug, generate_show_slug, generate_performance_slug
# Use environment variable or default to local sqlite for testing
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./database.db")
engine = create_engine(DATABASE_URL)
def fix_numeric_slugs():
with Session(engine) as session:
# 1. Songs
songs = session.exec(select(Song)).all()
for song in songs:
if song.slug and song.slug.isdigit():
old_slug = song.slug
new_slug = generate_slug(song.title)
# Check for collisions
base_slug = new_slug
counter = 1
while session.exec(select(Song).where(Song.slug == new_slug).where(Song.id != song.id)).first():
new_slug = f"{base_slug}-{counter}"
counter += 1
print(f"Updating Song slug: {old_slug} -> {new_slug}")
song.slug = new_slug
session.add(song)
# 2. Venues
venues = session.exec(select(Venue)).all()
for venue in venues:
if venue.slug and venue.slug.isdigit():
old_slug = venue.slug
new_slug = generate_slug(venue.name)
# Check for collisions
base_slug = new_slug
counter = 1
while session.exec(select(Venue).where(Venue.slug == new_slug).where(Venue.id != venue.id)).first():
new_slug = f"{base_slug}-{counter}"
counter += 1
print(f"Updating Venue slug: {old_slug} -> {new_slug}")
venue.slug = new_slug
session.add(venue)
# 3. Shows
shows = session.exec(select(Show)).all()
for show in shows:
if show.slug and show.slug.isdigit():
old_slug = show.slug
venue_name = show.venue.name if (show.venue) else "unknown"
new_slug = generate_show_slug(show.date.strftime("%Y-%m-%d"), venue_name)
# Check for collisions
base_slug = new_slug
counter = 1
while session.exec(select(Show).where(Show.slug == new_slug).where(Show.id != show.id)).first():
new_slug = f"{base_slug}-{counter}"
counter += 1
print(f"Updating Show slug: {old_slug} -> {new_slug}")
show.slug = new_slug
session.add(show)
# 4. Performances (checking just in case)
performances = session.exec(select(Performance)).all()
for perf in performances:
if perf.slug and perf.slug.isdigit():
old_slug = perf.slug
song_title = perf.song.title if perf.song else "unknown"
show_date = perf.show.date.strftime("%Y-%m-%d") if perf.show else "unknown"
new_slug = generate_performance_slug(song_title, show_date)
# Check for collisions
base_slug = new_slug
counter = 1
while session.exec(select(Performance).where(Performance.slug == new_slug).where(Performance.id != perf.id)).first():
new_slug = f"{base_slug}-{counter}"
counter += 1
print(f"Updating Performance slug: {old_slug} -> {new_slug}")
perf.slug = new_slug
session.add(perf)
session.commit()
print("Slug fixation complete.")
if __name__ == "__main__":
fix_numeric_slugs()

18
backend/inspect_api.py Normal file
View file

@ -0,0 +1,18 @@
import requests
import json
def fetch_sample_setlists():
url = "https://elgoose.net/api/v2/setlists.json"
try:
response = requests.get(url, params={"page": 1})
data = response.json()
if 'data' in data:
print(json.dumps(data['data'][:5], indent=2))
# Stats on setnumber
setnumbers = [x.get('setnumber') for x in data['data']]
print(f"\nUnique setnumbers in page 1: {set(setnumbers)}")
except Exception as e:
print(f"Error: {e}")
if __name__ == "__main__":
fetch_sample_setlists()

View file

@ -8,17 +8,11 @@ from auth import get_current_user
router = APIRouter(prefix="/performances", tags=["performances"]) router = APIRouter(prefix="/performances", tags=["performances"])
@router.get("/{performance_id_or_slug}", response_model=PerformanceDetailRead) @router.get("/{slug}", response_model=PerformanceDetailRead)
def read_performance(performance_id_or_slug: str, session: Session = Depends(get_session)): def read_performance(slug: str, session: Session = Depends(get_session)):
performance = None performance = session.exec(
if performance_id_or_slug.isdigit(): select(Performance).where(Performance.slug == slug)
performance = session.get(Performance, int(performance_id_or_slug)) ).first()
if not performance:
# Try slug lookup
performance = session.exec(
select(Performance).where(Performance.slug == performance_id_or_slug)
).first()
if not performance: if not performance:
raise HTTPException(status_code=404, detail="Performance not found") raise HTTPException(status_code=404, detail="Performance not found")
@ -43,13 +37,15 @@ def read_performance(performance_id_or_slug: str, session: Session = Depends(get
current_index = i current_index = i
break break
prev_id = None prev_slug = None
next_id = None next_slug = None
gap = 0 gap = 0
times_played = current_index + 1 # 1-based count times_played = current_index + 1 # 1-based count
if current_index > 0: if current_index > 0:
prev_id = all_perfs_data[current_index - 1][0].id prev_perf = all_perfs_data[current_index - 1][0]
prev_id = prev_perf.id
prev_slug = prev_perf.slug
# Calculate Gap # Calculate Gap
prev_date = all_perfs_data[current_index - 1][1].date prev_date = all_perfs_data[current_index - 1][1].date
@ -62,7 +58,9 @@ def read_performance(performance_id_or_slug: str, session: Session = Depends(get
).one() ).one()
if current_index < len(all_perfs_data) - 1: if current_index < len(all_perfs_data) - 1:
next_id = all_perfs_data[current_index + 1][0].id next_perf = all_perfs_data[current_index + 1][0]
next_id = next_perf.id
next_slug = next_perf.slug
# Fetch ratings for all performances of this song # Fetch ratings for all performances of this song
rating_stats = session.exec( rating_stats = session.exec(
@ -106,7 +104,9 @@ def read_performance(performance_id_or_slug: str, session: Session = Depends(get
perf_dict['song'] = performance.song perf_dict['song'] = performance.song
perf_dict['nicknames'] = performance.nicknames perf_dict['nicknames'] = performance.nicknames
perf_dict['previous_performance_id'] = prev_id perf_dict['previous_performance_id'] = prev_id
perf_dict['previous_performance_slug'] = prev_slug
perf_dict['next_performance_id'] = next_id perf_dict['next_performance_id'] = next_id
perf_dict['next_performance_slug'] = next_slug
perf_dict['gap'] = gap perf_dict['gap'] = gap
perf_dict['times_played'] = times_played perf_dict['times_played'] = times_played
perf_dict['other_performances'] = other_performances perf_dict['other_performances'] = other_performances

View file

@ -1,6 +1,7 @@
from typing import List, Optional from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from sqlmodel import Session, select, col from sqlmodel import Session, select, col
from sqlalchemy.orm import selectinload
from database import get_session from database import get_session
from models import Show, Song, Venue, Tour, User, Group, Performance, PerformanceNickname, Comment, Review from models import Show, Song, Venue, Tour, User, Group, Performance, PerformanceNickname, Comment, Review
from schemas import ShowRead, SongRead, VenueRead, TourRead, UserRead, GroupRead from schemas import ShowRead, SongRead, VenueRead, TourRead, UserRead, GroupRead
@ -19,36 +20,53 @@ def global_search(
q_str = f"%{q}%" q_str = f"%{q}%"
# Search Songs # Search Songs
songs = session.exec(select(Song).where(col(Song.title).ilike(q_str)).limit(limit)).all() songs = session.exec(
select(Song)
.where(col(Song.title).ilike(q_str))
.limit(limit)
).all()
# Search Venues # Search Venues
venues = session.exec(select(Venue).where(col(Venue.name).ilike(q_str)).limit(limit)).all() venues = session.exec(
select(Venue)
.where(col(Venue.name).ilike(q_str))
.limit(limit)
).all()
# Search Tours # Search Tours
tours = session.exec(select(Tour).where(col(Tour.name).ilike(q_str)).limit(limit)).all() tours = session.exec(
select(Tour)
.where(col(Tour.name).ilike(q_str))
.limit(limit)
).all()
# Search Groups # Search Groups
groups = session.exec(select(Group).where(col(Group.name).ilike(q_str)).limit(limit)).all() groups = session.exec(
select(Group)
.where(col(Group.name).ilike(q_str))
.limit(limit)
).all()
# Search Users (by username or email) # Search Users (by username or email)
users = session.exec(select(User).where(col(User.email).ilike(q_str)).limit(limit)).all() users = session.exec(
select(User)
.where((col(User.email).ilike(q_str)) | (col(User.username).ilike(q_str)))
.limit(limit)
).all()
# Search Nicknames # Search Nicknames
nicknames = session.exec( nicknames = session.exec(
select(PerformanceNickname) select(PerformanceNickname)
.options(selectinload(PerformanceNickname.performance).selectinload(Performance.song))
.where(col(PerformanceNickname.nickname).ilike(q_str)) .where(col(PerformanceNickname.nickname).ilike(q_str))
.where(PerformanceNickname.status == "approved") .where(PerformanceNickname.status == "approved")
.limit(limit) .limit(limit)
).all() ).all()
# Search Performances (by notes, e.g. "unfinished", "slow version") # Search Performances
# We join with Song and Show to provide context in the frontend if needed,
# but for now let's just return the Performance object and let frontend fetch details
# or we can return a custom schema.
# Actually, let's just search notes for now.
performances = session.exec( performances = session.exec(
select(Performance) select(Performance)
.join(Song) .options(selectinload(Performance.song), selectinload(Performance.show))
.where(col(Performance.notes).ilike(q_str)) .where(col(Performance.notes).ilike(q_str))
.limit(limit) .limit(limit)
).all() ).all()

View file

@ -48,12 +48,9 @@ def read_recent_shows(
shows = session.exec(query).all() shows = session.exec(query).all()
return shows return shows
@router.get("/{show_id}", response_model=ShowRead) @router.get("/{slug}", response_model=ShowRead)
def read_show(show_id: str, session: Session = Depends(get_session)): def read_show(slug: str, session: Session = Depends(get_session)):
if show_id.isdigit(): show = session.exec(select(Show).where(Show.slug == slug)).first()
show = session.get(Show, int(show_id))
else:
show = session.exec(select(Show).where(Show.slug == show_id)).first()
if not show: if not show:
raise HTTPException(status_code=404, detail="Show not found") raise HTTPException(status_code=404, detail="Show not found")

View file

@ -23,16 +23,9 @@ def read_songs(offset: int = 0, limit: int = Query(default=100, le=100), session
songs = session.exec(select(Song).offset(offset).limit(limit)).all() songs = session.exec(select(Song).offset(offset).limit(limit)).all()
return songs return songs
@router.get("/{song_id_or_slug}", response_model=SongReadWithStats) @router.get("/{slug}", response_model=SongReadWithStats)
def read_song(song_id_or_slug: str, session: Session = Depends(get_session)): def read_song(slug: str, session: Session = Depends(get_session)):
# Try to parse as int (ID), otherwise treat as slug song = session.exec(select(Song).where(Song.slug == slug)).first()
song = None
if song_id_or_slug.isdigit():
song = session.get(Song, int(song_id_or_slug))
if not song:
# Try slug lookup
song = session.exec(select(Song).where(Song.slug == song_id_or_slug)).first()
if not song: if not song:
raise HTTPException(status_code=404, detail="Song not found") raise HTTPException(status_code=404, detail="Song not found")

View file

@ -29,9 +29,9 @@ def read_tours(
tours = session.exec(select(Tour).offset(offset).limit(limit)).all() tours = session.exec(select(Tour).offset(offset).limit(limit)).all()
return tours return tours
@router.get("/{tour_id}", response_model=TourRead) @router.get("/{slug}", response_model=TourRead)
def read_tour(tour_id: int, session: Session = Depends(get_session)): def read_tour(slug: str, session: Session = Depends(get_session)):
tour = session.get(Tour, tour_id) tour = session.exec(select(Tour).where(Tour.slug == slug)).first()
if not tour: if not tour:
raise HTTPException(status_code=404, detail="Tour not found") raise HTTPException(status_code=404, detail="Tour not found")
return tour return tour

View file

@ -21,14 +21,9 @@ def read_venues(offset: int = 0, limit: int = Query(default=100, le=100), sessio
venues = session.exec(select(Venue).offset(offset).limit(limit)).all() venues = session.exec(select(Venue).offset(offset).limit(limit)).all()
return venues return venues
@router.get("/{venue_id_or_slug}", response_model=VenueRead) @router.get("/{slug}", response_model=VenueRead)
def read_venue(venue_id_or_slug: str, session: Session = Depends(get_session)): def read_venue(slug: str, session: Session = Depends(get_session)):
venue = None venue = session.exec(select(Venue).where(Venue.slug == slug)).first()
if venue_id_or_slug.isdigit():
venue = session.get(Venue, int(venue_id_or_slug))
if not venue:
venue = session.exec(select(Venue).where(Venue.slug == venue_id_or_slug)).first()
if not venue: if not venue:
raise HTTPException(status_code=404, detail="Venue not found") raise HTTPException(status_code=404, detail="Venue not found")

View file

@ -222,24 +222,31 @@ def check_and_award_badges(session: Session, user: User) -> List[Badge]:
def get_leaderboard(session: Session, limit: int = 10) -> List[dict]: def get_leaderboard(session: Session, limit: int = 10) -> List[dict]:
"""Get top users by XP""" """Get top users by XP"""
# Test accounts to hide until we have real users # Filter out test accounts
TEST_USER_EMAILS = ["tenwest", "testuser"] # Filter out test accounts
MIN_USERS_TO_SHOW_TEST = 12 TEST_USER_EMAILS = [
"tenwest",
# Count total real users "testuser",
total_users = session.exec( "rescue@elmeg.xyz",
select(func.count(User.id)) "admin-rescue@elmeg.xyz",
.where(User.is_active == True) "admin@elmeg.xyz",
).one() or 0 "test@",
"emailtest"
]
# Build query # Build query
query = select(User).where(User.is_active == True) query = select(User).where(User.is_active == True)
# If we don't have enough real users, hide test accounts # Strictly filter out test accounts
if total_users < MIN_USERS_TO_SHOW_TEST: for test_email in TEST_USER_EMAILS:
for test_email in TEST_USER_EMAILS: query = query.where(~User.email.ilike(f"%{test_email}%"))
query = query.where(~User.email.ilike(f"{test_email}@%"))
query = query.where(~User.email.ilike(f"%{test_email}%")) # Also honor the user's preference to hide from leaderboards
# We use != False to include None (defaulting to True) or explicit True
query = query.where(User.appear_in_leaderboards != False)
# Hide users with 0 XP (inactive)
query = query.where(User.xp > 0)
users = session.exec( users = session.exec(
query.order_by(User.xp.desc()).limit(limit) query.order_by(User.xp.desc()).limit(limit)
@ -314,18 +321,18 @@ PURCHASABLE_COLORS = {
"Rainbow": {"hex": "gradient", "cost": 2500, "min_level": 10}, "Rainbow": {"hex": "gradient", "cost": 2500, "min_level": 10},
} }
# Flairs (small text/emoji beside username) # Flairs (small text/icon name beside username)
PURCHASABLE_FLAIRS = { PURCHASABLE_FLAIRS = {
"": {"cost": 100, "min_level": 1}, "Bolt": {"cost": 100, "min_level": 1},
"🎸": {"cost": 100, "min_level": 1}, "Guitar": {"cost": 100, "min_level": 1},
"🎵": {"cost": 100, "min_level": 1}, "Music": {"cost": 100, "min_level": 1},
"🌈": {"cost": 200, "min_level": 3}, "Rainbow": {"cost": 200, "min_level": 3},
"🔥": {"cost": 200, "min_level": 3}, "Fire": {"cost": 200, "min_level": 3},
"": {"cost": 300, "min_level": 5}, "Star": {"cost": 300, "min_level": 5},
"👑": {"cost": 500, "min_level": 7}, "Crown": {"cost": 500, "min_level": 7},
"🚀": {"cost": 400, "min_level": 6}, "Rocket": {"cost": 400, "min_level": 6},
"💎": {"cost": 750, "min_level": 9}, "Diamond": {"cost": 750, "min_level": 9},
"🌟": {"cost": 1000, "min_level": 10}, "Sparkles": {"cost": 1000, "min_level": 10},
} }
# Early adopter perks # Early adopter perks
@ -333,7 +340,7 @@ EARLY_ADOPTER_PERKS = {
"free_title_change": True, # Early adopters can change title for free (once per month) "free_title_change": True, # Early adopters can change title for free (once per month)
"exclusive_titles": ["Pioneer", "Founding Member", "OG User", "Day One"], "exclusive_titles": ["Pioneer", "Founding Member", "OG User", "Day One"],
"exclusive_colors": {"Pioneer Gold": "#FFB347", "Genesis Green": "#50C878"}, "exclusive_colors": {"Pioneer Gold": "#FFB347", "Genesis Green": "#50C878"},
"exclusive_flair": ["🥇", "🏆"], "exclusive_flair": ["Medal", "Trophy"],
"title_color": "#FFB347", # Default gold color for early adopters "title_color": "#FFB347", # Default gold color for early adopters
"bonus_xp_multiplier": 1.1, # 10% XP bonus "bonus_xp_multiplier": 1.1, # 10% XP bonus
} }

View file

@ -0,0 +1,110 @@
import os
import sys
import logging
from datetime import datetime
# Ensure the backend directory is in the path
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
try:
from services.email_service import email_service
except ImportError:
# If running from root, adjust path
sys.path.append(os.path.join(os.getcwd(), 'backend'))
from services.email_service import email_service
def load_env_file():
"""Simple .env loader to avoid extra dependencies for this test script"""
env_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), '.env')
if not os.path.exists(env_path):
print(f"[WARN] No .env file found at {env_path}")
return
print(f"[INFO] Loading environment from {env_path}")
with open(env_path, 'r') as f:
for line in f:
line = line.strip()
if not line or line.startswith('#'):
continue
try:
key, value = line.split('=', 1)
os.environ[key] = value.strip("'").strip('"')
except ValueError:
pass
def test_smtp_connection():
# Load .env first
load_env_file()
# Re-import service to pick up env vars
# We need to re-instantiate or re-import because the original import
# might have happened before env vars were set if this was a module.
# However, since we import inside the function or after setting env in main,
# we should check how it was imported at top level.
# The top level import `from services.email_service import email_service`
# instantiated the class immediately. We need to re-instantiate it.
from services.email_service import EmailService
email_service = EmailService()
print("="*60)
print("ELMEG SMTP CONNECTION TEST")
print("="*60)
# 1. Print Configuration
print("\n[1] Configuration Check:")
print(f" Provider: {email_service.provider}")
print(f" SMTP Host: {email_service.smtp_host}")
print(f" SMTP Port: {email_service.smtp_port}")
print(f" SMTP Username: {email_service.smtp_username}")
print(f" SMTP TLS: {email_service.smtp_use_tls}")
print(f" From Email: {email_service.email_from}")
if email_service.provider != "smtp":
print("\n[!] ERROR: Email service is NOT configured for SMTP.")
print(f" Current provider is: {email_service.provider}")
print(" Please set SMTP_HOST, SMTP_PORT, SMTP_USERNAME, and SMTP_PASSWORD environment variables.")
return
# 2. Ask for recipient
if len(sys.argv) > 1:
recipient = sys.argv[1]
else:
recipient = input("\nEnter recipient email address for test (e.g. your@email.com): ").strip()
if not recipient:
print("No recipient provided. Aborting.")
return
# 3. Send Test Email
print(f"\n[2] Attempting to send test email to: {recipient}")
subject = f"Elmeg Postal SMTP Test - {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"
text_content = f"This is a test email from the Elmeg backend to verify Postal SMTP connectivity.\n\nSent at: {datetime.now()}"
html_content = f"""
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e0e0e0; border-radius: 8px;">
<h2 style="color: #2563eb; border-bottom: 2px solid #f0f0f0; padding-bottom: 10px;">Elmeg SMTP Test</h2>
<p>Aloha!</p>
<p>This email confirms that your <strong>Elmeg</strong> backend is successfully connected to the Postal SMTP server.</p>
<div style="background-color: #f8fafc; padding: 15px; border-radius: 4px; margin: 20px 0;">
<p style="margin: 0; font-family: monospace;">Server: {email_service.smtp_host}</p>
<p style="margin: 0; font-family: monospace;">Port: {email_service.smtp_port}</p>
<p style="margin: 0; font-family: monospace;">User: {email_service.smtp_username}</p>
</div>
<p style="color: #64748b; font-size: 12px; margin-top: 30px;">Sent at {datetime.now()}</p>
</div>
"""
try:
success = email_service.send_email(recipient, subject, html_content, text_content)
if success:
print("\n[SUCCESS] Email accepted by SMTP server!")
print("Check your inbox (and spam folder) for the message.")
else:
print("\n[FAILURE] SMTP server rejected the message or connection failed.")
except Exception as e:
print(f"\n[EXCEPTION] An unexpected error occurred: {e}")
if __name__ == "__main__":
test_smtp_connection()

View file

@ -119,7 +119,7 @@ export default function BugsPage() {
</p> </p>
</div> </div>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
We'll review your submission and get back to you soon. We&apos;ll review your submission and get back to you soon.
</p> </p>
<div className="flex gap-4 justify-center pt-4"> <div className="flex gap-4 justify-center pt-4">
<Link href="/bugs/my-tickets"> <Link href="/bugs/my-tickets">
@ -136,11 +136,10 @@ export default function BugsPage() {
} }
return ( return (
<div className="container max-w-4xl py-8"> <div className="container py-10 space-y-8 animate-in fade-in duration-700">
{/* Header */} <div className="flex flex-col gap-2">
<div className="text-center mb-12"> <h1 className="text-3xl font-bold tracking-tight">How can we help?</h1>
<h1 className="text-4xl font-bold tracking-tight mb-4">How can we help?</h1> <p className="text-muted-foreground">
<p className="text-muted-foreground text-lg">
Report bugs, request features, or ask questions Report bugs, request features, or ask questions
</p> </p>
</div> </div>
@ -155,7 +154,7 @@ export default function BugsPage() {
<button <button
key={type.value} key={type.value}
onClick={() => handleTypeSelect(type.value)} onClick={() => handleTypeSelect(type.value)}
className="text-left p-6 rounded-xl border-2 border-border hover:border-primary transition-colors bg-card hover:bg-accent group" className="text-left p-6 rounded-lg border-2 border-border hover:border-primary transition-colors bg-card hover:bg-accent group"
> >
<div className="flex items-start gap-4"> <div className="flex items-start gap-4">
<div className="p-3 rounded-lg bg-primary/10 text-primary group-hover:bg-primary group-hover:text-primary-foreground transition-colors"> <div className="p-3 rounded-lg bg-primary/10 text-primary group-hover:bg-primary group-hover:text-primary-foreground transition-colors">
@ -249,8 +248,8 @@ export default function BugsPage() {
type="button" type="button"
onClick={() => setFormData({ ...formData, priority: p.value })} onClick={() => setFormData({ ...formData, priority: p.value })}
className={`px-4 py-2 rounded-lg border-2 transition-colors ${formData.priority === p.value className={`px-4 py-2 rounded-lg border-2 transition-colors ${formData.priority === p.value
? "border-primary bg-primary/10" ? "border-primary bg-primary/10"
: "border-border hover:border-muted-foreground" : "border-border hover:border-muted-foreground"
}`} }`}
> >
<span className={`inline-block w-2 h-2 rounded-full ${p.color} mr-2`} /> <span className={`inline-block w-2 h-2 rounded-full ${p.color} mr-2`} />
@ -273,7 +272,7 @@ export default function BugsPage() {
required required
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
We'll use this to send you updates We&apos;ll use this to send you updates
</p> </p>
</div> </div>
)} )}

View file

@ -46,7 +46,7 @@
/* Light Mode - Ersen Style */ /* Light Mode - Ersen Style */
:root { :root {
--radius: 0.3rem; --radius: 0.2rem;
--background: hsl(240, 5%, 98%); --background: hsl(240, 5%, 98%);
--foreground: hsl(240, 10%, 3.9%); --foreground: hsl(240, 10%, 3.9%);
--card: hsl(0, 0%, 100%); --card: hsl(0, 0%, 100%);

View file

@ -11,23 +11,23 @@ import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { motion, AnimatePresence } from "framer-motion" import { motion, AnimatePresence } from "framer-motion"
interface TopShow { interface TopShow {
show: { id: number; date: string; venue_id: number } show: { id: number; date: string; venue_id: number; slug: string }
venue: { id: number; name: string; city: string; state: string } venue: { id: number; name: string; city: string; state: string }
avg_score: number avg_score: number
review_count: number review_count: number
} }
interface TopVenue { interface TopVenue {
venue: { id: number; name: string; city: string; state: string } venue: { id: number; name: string; city: string; state: string; slug: string }
avg_score: number avg_score: number
review_count: number review_count: number
} }
interface TopPerformance { interface TopPerformance {
performance: { id: number; show_id: number; song_id: number; notes: string | null } performance: { id: number; show_id: number; song_id: number; notes: string | null; slug: string }
song: { id: number; title: string; original_artist: string | null } song: { id: number; title: string; original_artist: string | null; slug: string }
show: { id: number; date: string } show: { id: number; date: string; slug: string }
venue: { id: number; name: string; city: string; state: string } venue: { id: number; name: string; city: string; state: string; slug: string }
avg_score: number avg_score: number
rating_count: number rating_count: number
} }
@ -132,7 +132,7 @@ export default function LeaderboardsPage() {
</div> </div>
<div className="flex flex-1 flex-col justify-center gap-1"> <div className="flex flex-1 flex-col justify-center gap-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Link href={`/songs/${item.song.id}`} className="font-semibold text-lg hover:underline decoration-primary decoration-2 underline-offset-2"> <Link href={`/songs/${item.song.slug}`} className="font-semibold text-lg hover:underline decoration-primary decoration-2 underline-offset-2">
{item.song.title} {item.song.title}
</Link> </Link>
{item.performance.notes && ( {item.performance.notes && (
@ -143,7 +143,7 @@ export default function LeaderboardsPage() {
</div> </div>
<div className="flex items-center text-sm text-muted-foreground gap-2"> <div className="flex items-center text-sm text-muted-foreground gap-2">
<Calendar className="h-3 w-3" /> <Calendar className="h-3 w-3" />
<Link href={`/shows/${item.show.id}`} className="hover:text-primary transition-colors"> <Link href={`/shows/${item.show.slug}`} className="hover:text-primary transition-colors">
{new Date(item.show.date).toLocaleDateString(undefined, { {new Date(item.show.date).toLocaleDateString(undefined, {
weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' weekday: 'short', year: 'numeric', month: 'short', day: 'numeric'
})} })}
@ -190,7 +190,7 @@ export default function LeaderboardsPage() {
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="font-mono text-muted-foreground w-6 text-center">{i + 1}</div> <div className="font-mono text-muted-foreground w-6 text-center">{i + 1}</div>
<div> <div>
<Link href={`/shows/${item.show.id}`} className="font-medium text-lg hover:underline block"> <Link href={`/shows/${item.show.slug}`} className="font-medium text-lg hover:underline block">
{new Date(item.show.date).toLocaleDateString(undefined, { {new Date(item.show.date).toLocaleDateString(undefined, {
year: 'numeric', month: 'long', day: 'numeric' year: 'numeric', month: 'long', day: 'numeric'
})} })}
@ -236,7 +236,7 @@ export default function LeaderboardsPage() {
{i + 1} {i + 1}
</div> </div>
<div> <div>
<Link href={`/venues/${item.venue.id}`} className="font-medium hover:underline truncate block"> <Link href={`/venues/${item.venue.slug}`} className="font-medium hover:underline truncate block">
{item.venue.name} {item.venue.name}
</Link> </Link>
<p className="text-xs text-muted-foreground truncate"> <p className="text-xs text-muted-foreground truncate">

View file

@ -335,7 +335,7 @@ export default function ModDashboardPage() {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{pendingReports.length === 0 ? ( {pendingReports.length === 0 ? (
<p className="text-muted-foreground py-8 text-center">No pending reports. Community is safe! 🛡</p> <p className="text-muted-foreground py-8 text-center">No pending reports. Community is safe!</p>
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
{pendingReports.map(report => ( {pendingReports.map(report => (

View file

@ -3,7 +3,7 @@ import { XPLeaderboard } from "@/components/gamification/xp-leaderboard"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import Link from "next/link" import Link from "next/link"
import { Trophy, Music, MapPin, Calendar, ChevronRight, Star } from "lucide-react" import { Trophy, Music, MapPin, Calendar, ChevronRight, Star, Youtube, Route } from "lucide-react"
import { getApiUrl } from "@/lib/api-config" import { getApiUrl } from "@/lib/api-config"
interface Show { interface Show {
@ -84,7 +84,7 @@ export default async function Home() {
return ( return (
<div className="flex flex-col gap-8"> <div className="flex flex-col gap-8">
{/* 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-3xl border"> <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">
<h1 className="text-4xl font-bold tracking-tighter sm:text-5xl md:text-6xl"> <h1 className="text-4xl font-bold tracking-tighter sm:text-5xl md:text-6xl">
Elmeg Elmeg
</h1> </h1>
@ -122,7 +122,7 @@ export default async function Home() {
{recentShows.length > 0 ? ( {recentShows.length > 0 ? (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4"> <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
{recentShows.map((show) => ( {recentShows.map((show) => (
<Link key={show.id} href={`/shows/${show.slug || show.id}`}> <Link key={show.id} href={`/shows/${show.slug}`}>
<Card className="h-full hover:bg-accent/50 transition-colors cursor-pointer"> <Card className="h-full hover:bg-accent/50 transition-colors cursor-pointer">
<CardContent className="p-4"> <CardContent className="p-4">
<div className="font-semibold"> <div className="font-semibold">
@ -179,7 +179,7 @@ export default async function Home() {
{topSongs.map((song, idx) => ( {topSongs.map((song, idx) => (
<li key={song.id}> <li key={song.id}>
<Link <Link
href={`/songs/${song.slug || song.id}`} href={`/songs/${song.slug}`}
className="flex items-center gap-3 p-3 hover:bg-accent/50 transition-colors" 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"> <span className="text-lg font-bold text-muted-foreground w-6 text-center">
@ -224,27 +224,42 @@ export default async function Home() {
</div> </div>
{/* Quick Links */} {/* Quick Links */}
<section className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4"> <section className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Link href="/shows" className="block p-6 border rounded-xl hover:bg-accent transition-colors group"> <Link href="/shows" className="block p-6 border rounded-lg hover:bg-accent transition-colors group">
<Music className="h-8 w-8 mb-2 text-blue-500 group-hover:scale-110 transition-transform" /> <Calendar className="h-8 w-8 mb-2 text-blue-500 group-hover:scale-110 transition-transform" />
<h3 className="font-bold">Shows</h3> <h3 className="font-bold">Shows</h3>
<p className="text-sm text-muted-foreground">Browse the complete archive</p> <p className="text-sm text-muted-foreground">Browse the complete archive</p>
</Link> </Link>
<Link href="/venues" className="block p-6 border rounded-xl hover:bg-accent transition-colors group"> <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" /> <MapPin className="h-8 w-8 mb-2 text-green-500 group-hover:scale-110 transition-transform" />
<h3 className="font-bold">Venues</h3> <h3 className="font-bold">Venues</h3>
<p className="text-sm text-muted-foreground">Find your favorite spots</p> <p className="text-sm text-muted-foreground">Find your favorite spots</p>
</Link> </Link>
<Link href="/songs" className="block p-6 border rounded-xl hover:bg-accent transition-colors group"> <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" /> <Music className="h-8 w-8 mb-2 text-purple-500 group-hover:scale-110 transition-transform" />
<h3 className="font-bold">Songs</h3> <h3 className="font-bold">Songs</h3>
<p className="text-sm text-muted-foreground">Explore the catalog</p> <p className="text-sm text-muted-foreground">Explore the catalog</p>
</Link> </Link>
<Link href="/leaderboards" className="block p-6 border rounded-xl hover:bg-accent transition-colors group"> <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" /> <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> <h3 className="font-bold">Leaderboards</h3>
<p className="text-sm text-muted-foreground">Top rated everything</p> <p className="text-sm text-muted-foreground">Top rated everything</p>
</Link> </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> </div>
) )

View file

@ -22,9 +22,9 @@ async function getPerformance(id: string) {
} }
} }
export default async function PerformanceDetailPage({ params }: { params: Promise<{ id: string }> }) { export default async function PerformanceDetailPage({ params }: { params: Promise<{ slug: string }> }) {
const { id } = await params const { slug } = await params
const performance = await getPerformance(id) const performance = await getPerformance(slug)
if (!performance) { if (!performance) {
notFound() notFound()
@ -44,12 +44,12 @@ export default async function PerformanceDetailPage({ params }: { params: Promis
<div className="relative -mx-4 -mt-4 px-4 pt-6 pb-8 bg-gradient-to-br from-primary/10 via-primary/5 to-transparent border-b"> <div className="relative -mx-4 -mt-4 px-4 pt-6 pb-8 bg-gradient-to-br from-primary/10 via-primary/5 to-transparent border-b">
{/* Breadcrumbs */} {/* Breadcrumbs */}
<nav className="flex items-center gap-1 text-sm text-muted-foreground mb-4"> <nav className="flex items-center gap-1 text-sm text-muted-foreground mb-4">
<Link href={`/shows/${performance.show.id}`} className="hover:text-foreground transition-colors"> <Link href={`/shows/${performance.show.slug}`} className="hover:text-foreground transition-colors">
Show Show
</Link> </Link>
<ChevronRight className="h-4 w-4" /> <ChevronRight className="h-4 w-4" />
<Link <Link
href={`/songs/${performance.song.id}`} href={`/songs/${performance.song.slug}`}
className="hover:text-foreground transition-colors" className="hover:text-foreground transition-colors"
> >
{performance.song.title} {performance.song.title}
@ -60,7 +60,7 @@ export default async function PerformanceDetailPage({ params }: { params: Promis
<div className="flex flex-col md:flex-row md:items-start md:justify-between gap-6"> <div className="flex flex-col md:flex-row md:items-start md:justify-between gap-6">
<div className="flex items-start gap-4"> <div className="flex items-start gap-4">
<Link href={`/shows/${performance.show.id}`}> <Link href={`/shows/${performance.show.slug}`}>
<Button variant="outline" size="icon" className="mt-1"> <Button variant="outline" size="icon" className="mt-1">
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
</Button> </Button>
@ -85,7 +85,7 @@ export default async function PerformanceDetailPage({ params }: { params: Promis
{/* Song Title (links to song page) */} {/* Song Title (links to song page) */}
<h1 className="text-3xl md:text-4xl font-bold tracking-tight"> <h1 className="text-3xl md:text-4xl font-bold tracking-tight">
<Link <Link
href={`/songs/${performance.song.id}`} href={`/songs/${performance.song.slug}`}
className="hover:text-primary transition-colors" className="hover:text-primary transition-colors"
> >
{performance.song.title} {performance.song.title}
@ -101,7 +101,7 @@ export default async function PerformanceDetailPage({ params }: { params: Promis
className="text-lg italic text-yellow-600 dark:text-yellow-400" className="text-lg italic text-yellow-600 dark:text-yellow-400"
title={nick.description} title={nick.description}
> >
"{nick.nickname}" &quot;{nick.nickname}&quot;
</span> </span>
))} ))}
</div> </div>
@ -110,7 +110,7 @@ export default async function PerformanceDetailPage({ params }: { params: Promis
{/* Show Context - THE KEY DIFFERENTIATOR */} {/* Show Context - THE KEY DIFFERENTIATOR */}
<div className="mt-4 p-3 rounded-lg bg-background/80 border inline-flex flex-col gap-1"> <div className="mt-4 p-3 rounded-lg bg-background/80 border inline-flex flex-col gap-1">
<Link <Link
href={`/shows/${performance.show.id}`} href={`/shows/${performance.show.slug}`}
className="font-semibold text-lg hover:text-primary transition-colors flex items-center gap-2" className="font-semibold text-lg hover:text-primary transition-colors flex items-center gap-2"
> >
<Calendar className="h-4 w-4" /> <Calendar className="h-4 w-4" />
@ -118,7 +118,7 @@ export default async function PerformanceDetailPage({ params }: { params: Promis
</Link> </Link>
{performance.show.venue && ( {performance.show.venue && (
<Link <Link
href={`/venues/${performance.show.venue.id}`} href={`/venues/${performance.show.venue.slug}`}
className="text-muted-foreground hover:text-foreground flex items-center gap-2 transition-colors" className="text-muted-foreground hover:text-foreground flex items-center gap-2 transition-colors"
> >
<MapPin className="h-4 w-4" /> <MapPin className="h-4 w-4" />
@ -174,7 +174,7 @@ export default async function PerformanceDetailPage({ params }: { params: Promis
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
{performance.previous_performance_id ? ( {performance.previous_performance_id ? (
<Link <Link
href={`/performances/${performance.previous_performance_id}`} href={`/performances/${performance.previous_performance_slug}`}
className="flex-1" className="flex-1"
> >
<Button variant="outline" className="w-full justify-start gap-2"> <Button variant="outline" className="w-full justify-start gap-2">
@ -198,7 +198,7 @@ export default async function PerformanceDetailPage({ params }: { params: Promis
{performance.next_performance_id ? ( {performance.next_performance_id ? (
<Link <Link
href={`/performances/${performance.next_performance_id}`} href={`/performances/${performance.next_performance_slug}`}
className="flex-1" className="flex-1"
> >
<Button variant="outline" className="w-full justify-end gap-2"> <Button variant="outline" className="w-full justify-end gap-2">
@ -312,7 +312,7 @@ export default async function PerformanceDetailPage({ params }: { params: Promis
{performance.other_performances.slice(0, 5).map((perf: any) => ( {performance.other_performances.slice(0, 5).map((perf: any) => (
<Link <Link
key={perf.id} key={perf.id}
href={`/performances/${perf.slug || perf.id}`} href={`/performances/${perf.slug}`}
className="flex items-start justify-between group" className="flex items-start justify-between group"
> >
<div className="flex flex-col"> <div className="flex flex-col">
@ -332,7 +332,7 @@ export default async function PerformanceDetailPage({ params }: { params: Promis
</Link> </Link>
))} ))}
<Link <Link
href={`/songs/${performance.song.id}`} href={`/songs/${performance.song.slug}`}
className="block text-xs text-center text-muted-foreground hover:text-primary pt-2 border-t mt-2" className="block text-xs text-center text-muted-foreground hover:text-primary pt-2 border-t mt-2"
> >
View all {performance.other_performances.length + 1} versions View all {performance.other_performances.length + 1} versions
@ -350,14 +350,14 @@ export default async function PerformanceDetailPage({ params }: { params: Promis
</CardHeader> </CardHeader>
<CardContent className="space-y-2"> <CardContent className="space-y-2">
<Link <Link
href={`/songs/${performance.song.id}`} href={`/songs/${performance.song.slug}`}
className="flex items-center gap-2 p-2 rounded-md hover:bg-muted transition-colors" className="flex items-center gap-2 p-2 rounded-md hover:bg-muted transition-colors"
> >
<Music className="h-4 w-4 text-muted-foreground" /> <Music className="h-4 w-4 text-muted-foreground" />
<span>All versions of {performance.song.title}</span> <span>All versions of {performance.song.title}</span>
</Link> </Link>
<Link <Link
href={`/shows/${performance.show.id}`} href={`/shows/${performance.show.slug}`}
className="flex items-center gap-2 p-2 rounded-md hover:bg-muted transition-colors" className="flex items-center gap-2 p-2 rounded-md hover:bg-muted transition-colors"
> >
<Calendar className="h-4 w-4 text-muted-foreground" /> <Calendar className="h-4 w-4 text-muted-foreground" />
@ -365,7 +365,7 @@ export default async function PerformanceDetailPage({ params }: { params: Promis
</Link> </Link>
{performance.show.venue && ( {performance.show.venue && (
<Link <Link
href={`/venues/${performance.show.venue.id}`} href={`/venues/${performance.show.venue.slug}`}
className="flex items-center gap-2 p-2 rounded-md hover:bg-muted transition-colors" className="flex items-center gap-2 p-2 rounded-md hover:bg-muted transition-colors"
> >
<MapPin className="h-4 w-4 text-muted-foreground" /> <MapPin className="h-4 w-4 text-muted-foreground" />

View file

@ -34,7 +34,7 @@ interface UserBadge {
awarded_at: string awarded_at: string
} }
export default function PublicProfilePage({ params }: { params: Promise<{ id: string }> }) { export default function PublicProfilePage({ params }: { params: Promise<{ slug: string }> }) {
const [id, setId] = useState<string | null>(null) const [id, setId] = useState<string | null>(null)
const [user, setUser] = useState<UserProfile | null>(null) const [user, setUser] = useState<UserProfile | null>(null)
const [badges, setBadges] = useState<UserBadge[]>([]) const [badges, setBadges] = useState<UserBadge[]>([])
@ -43,7 +43,7 @@ export default function PublicProfilePage({ params }: { params: Promise<{ id: st
const [stats, setStats] = useState({ attendance_count: 0, review_count: 0, group_count: 0 }) const [stats, setStats] = useState({ attendance_count: 0, review_count: 0, group_count: 0 })
useEffect(() => { useEffect(() => {
params.then(p => setId(p.id)) params.then(p => setId(p.slug))
}, [params]) }, [params])
useEffect(() => { useEffect(() => {

View file

@ -1,14 +1,11 @@
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { ArrowLeft, Calendar, MapPin, Music2, Disc, PlayCircle, ExternalLink, Youtube } from "lucide-react" import { ArrowLeft, MapPin, Music2, Disc, PlayCircle, Youtube } from "lucide-react"
import Link from "next/link" import Link from "next/link"
import { CommentSection } from "@/components/social/comment-section" import { CommentSection } from "@/components/social/comment-section"
import { EntityRating } from "@/components/social/entity-rating" import { EntityRating } from "@/components/social/entity-rating"
import { ShowAttendance } from "@/components/shows/show-attendance" import { ShowAttendance } from "@/components/shows/show-attendance"
import { SocialWrapper } from "@/components/social/social-wrapper" import { SocialWrapper } from "@/components/social/social-wrapper"
import { ReviewCard } from "@/components/reviews/review-card"
import { ReviewForm } from "@/components/reviews/review-form"
import { notFound } from "next/navigation" import { notFound } from "next/navigation"
import { SuggestNicknameDialog } from "@/components/shows/suggest-nickname-dialog" import { SuggestNicknameDialog } from "@/components/shows/suggest-nickname-dialog"
import { EntityReviews } from "@/components/reviews/entity-reviews" import { EntityReviews } from "@/components/reviews/entity-reviews"
@ -28,9 +25,9 @@ async function getShow(id: string) {
} }
export default async function ShowDetailPage({ params }: { params: Promise<{ id: string }> }) { export default async function ShowDetailPage({ params }: { params: Promise<{ slug: string }> }) {
const { id } = await params const { slug } = await params
const show = await getShow(id) const show = await getShow(slug)
if (!show) { if (!show) {
notFound() notFound()
@ -175,7 +172,7 @@ export default async function ShowDetailPage({ params }: { params: Promise<{ id:
<span className="text-muted-foreground/60 w-6 text-right text-xs font-mono">{perf.position}.</span> <span className="text-muted-foreground/60 w-6 text-right text-xs font-mono">{perf.position}.</span>
<div className="font-medium flex items-center gap-2"> <div className="font-medium flex items-center gap-2">
<Link <Link
href={`/performances/${perf.slug || perf.id}`} href={`/performances/${perf.slug}`}
className="hover:text-primary hover:underline transition-colors" className="hover:text-primary hover:underline transition-colors"
> >
{perf.song?.title || "Unknown Song"} {perf.song?.title || "Unknown Song"}
@ -229,7 +226,7 @@ export default async function ShowDetailPage({ params }: { params: Promise<{ id:
<div className="flex gap-1 ml-2"> <div className="flex gap-1 ml-2">
{perf.nicknames.map((nick: any) => ( {perf.nicknames.map((nick: any) => (
<span key={nick.id} className="text-[10px] bg-yellow-100/80 text-yellow-800 px-1.5 py-0.5 rounded-full border border-yellow-200" title={nick.description}> <span key={nick.id} className="text-[10px] bg-yellow-100/80 text-yellow-800 px-1.5 py-0.5 rounded-full border border-yellow-200" title={nick.description}>
"{nick.nickname}" &quot;{nick.nickname}&quot;
</span> </span>
))} ))}
</div> </div>
@ -296,7 +293,7 @@ export default async function ShowDetailPage({ params }: { params: Promise<{ id:
<> <>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<MapPin className="h-4 w-4 text-muted-foreground" /> <MapPin className="h-4 w-4 text-muted-foreground" />
<Link href={`/venues/${show.venue.slug || show.venue.id}`} className="font-medium hover:underline hover:text-primary"> <Link href={`/venues/${show.venue.slug}`} className="font-medium hover:underline hover:text-primary">
{show.venue.name} {show.venue.name}
</Link> </Link>
</div> </div>

View file

@ -84,7 +84,7 @@ function ShowsContent() {
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{shows.map((show) => ( {shows.map((show) => (
<Link key={show.id} href={`/shows/${show.slug || show.id}`} className="block group"> <Link key={show.id} href={`/shows/${show.slug}`} className="block group">
<Card className="h-full transition-all duration-300 hover:scale-[1.02] hover:shadow-lg group-hover:border-primary/50 relative"> <Card className="h-full transition-all duration-300 hover:scale-[1.02] hover:shadow-lg group-hover:border-primary/50 relative">
{show.youtube_link && ( {show.youtube_link && (
<div className="absolute top-2 right-2 bg-red-500/10 text-red-500 p-1.5 rounded-full" title="Full show video available"> <div className="absolute top-2 right-2 bg-red-500/10 text-red-500 p-1.5 rounded-full" title="Full show video available">

View file

@ -1,215 +0,0 @@
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { ArrowLeft, PlayCircle, History, Calendar, Trophy, Youtube, Star } from "lucide-react"
import Link from "next/link"
import { notFound } from "next/navigation"
import { Badge } from "@/components/ui/badge"
import { getApiUrl } from "@/lib/api-config"
import { CommentSection } from "@/components/social/comment-section"
import { EntityRating } from "@/components/social/entity-rating"
import { EntityReviews } from "@/components/reviews/entity-reviews"
import { SocialWrapper } from "@/components/social/social-wrapper"
import { PerformanceList } from "@/components/songs/performance-list"
import { SongEvolutionChart } from "@/components/songs/song-evolution-chart"
import { YouTubeEmbed } from "@/components/ui/youtube-embed"
async function getSong(id: string) {
try {
const res = await fetch(`${getApiUrl()}/songs/${id}`, { cache: 'no-store' })
if (!res.ok) return null
return res.json()
} catch (e) {
console.error(e)
return null
}
}
// Get top rated performances for "Heady Version" leaderboard
function getHeadyVersions(performances: any[]) {
if (!performances || performances.length === 0) return []
return [...performances]
.filter(p => p.avg_rating && p.rating_count > 0)
.sort((a, b) => b.avg_rating - a.avg_rating)
.slice(0, 5)
}
export default async function SongDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const song = await getSong(id)
if (!song) {
notFound()
}
const headyVersions = getHeadyVersions(song.performances || [])
const topPerformance = headyVersions[0]
return (
<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 items-center gap-4">
<Link href="/archive">
<Button variant="ghost" size="icon">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<div className="flex items-baseline gap-3">
<h1 className="text-3xl font-bold tracking-tight">{song.title}</h1>
{song.original_artist && (
<span className="text-lg text-muted-foreground font-medium">({song.original_artist})</span>
)}
</div>
{song.tags && song.tags.length > 0 && (
<div className="flex flex-wrap gap-2 mt-2">
{song.tags.map((tag: any) => (
<span key={tag.id} className="bg-secondary text-secondary-foreground px-2 py-0.5 rounded-full text-xs font-medium">
#{tag.name}
</span>
))}
</div>
)}
</div>
</div>
<SocialWrapper type="ratings">
<EntityRating entityType="song" entityId={song.id} />
</SocialWrapper>
</div>
<div className="grid gap-6 md:grid-cols-3">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Times Played</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold flex items-center gap-2">
<PlayCircle className="h-5 w-5 text-primary" />
{song.times_played}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Gap (Shows)</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold flex items-center gap-2">
<History className="h-5 w-5 text-primary" />
{song.gap}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Last Played</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold flex items-center gap-2">
<Calendar className="h-5 w-5 text-primary" />
{song.last_played ? new Date(song.last_played).toLocaleDateString() : "Never"}
</div>
</CardContent>
</Card>
</div>
{/* Heady Version Section */}
{headyVersions.length > 0 && (
<Card className="border-2 border-yellow-500/20 bg-gradient-to-br from-yellow-50/50 to-orange-50/50 dark:from-yellow-900/10 dark:to-orange-900/10">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-yellow-700 dark:text-yellow-400">
<Trophy className="h-6 w-6" />
Heady Version Leaderboard
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Top Performance with YouTube */}
{topPerformance && (
<div className="grid md:grid-cols-2 gap-4">
{topPerformance.youtube_link ? (
<YouTubeEmbed url={topPerformance.youtube_link} />
) : song.youtube_link ? (
<YouTubeEmbed url={song.youtube_link} />
) : (
<div className="aspect-video bg-muted rounded-lg flex items-center justify-center">
<div className="text-center text-muted-foreground">
<Youtube className="h-12 w-12 mx-auto mb-2 opacity-30" />
<p className="text-sm">No video available</p>
</div>
</div>
)}
<div className="space-y-2">
<div className="flex items-center gap-2">
<Badge className="bg-yellow-500 text-yellow-900">🏆 #1 Heady</Badge>
</div>
<p className="font-bold text-lg">
{topPerformance.show?.date ? new Date(topPerformance.show.date).toLocaleDateString() : "Unknown Date"}
</p>
<p className="text-muted-foreground">
{topPerformance.show?.venue?.name || "Unknown Venue"}
</p>
<div className="flex items-center gap-1 text-yellow-600">
<Star className="h-5 w-5 fill-current" />
<span className="font-bold text-xl">{topPerformance.avg_rating?.toFixed(1)}</span>
<span className="text-sm text-muted-foreground">({topPerformance.rating_count} ratings)</span>
</div>
</div>
</div>
)}
{/* Leaderboard List */}
<div className="space-y-2">
{headyVersions.map((perf, index) => (
<div
key={perf.id}
className={`flex items-center justify-between p-3 rounded-lg ${index === 0 ? 'bg-yellow-100/50 dark:bg-yellow-900/20' : 'bg-background/50'
}`}
>
<div className="flex items-center gap-3">
<span className="w-6 text-center font-bold">
{index === 0 ? "🥇" : index === 1 ? "🥈" : index === 2 ? "🥉" : `${index + 1}.`}
</span>
<div>
<p className="font-medium">
{perf.show?.date ? new Date(perf.show.date).toLocaleDateString() : "Unknown"}
</p>
<p className="text-sm text-muted-foreground">
{perf.show?.venue?.name || "Unknown Venue"}
</p>
</div>
</div>
<div className="flex items-center gap-2">
{perf.youtube_link && (
<a href={perf.youtube_link} target="_blank" rel="noopener noreferrer">
<Youtube className="h-4 w-4 text-red-500" />
</a>
)}
<div className="text-right">
<span className="font-bold">{perf.avg_rating?.toFixed(1)}</span>
<span className="text-xs text-muted-foreground ml-1">({perf.rating_count})</span>
</div>
</div>
</div>
))}
</div>
</CardContent>
</Card>
)}
<SongEvolutionChart performances={song.performances || []} />
{/* Performance List Component (Handles Client Sorting) */}
<PerformanceList performances={song.performances || []} songTitle={song.title} />
<div className="grid gap-6 md:grid-cols-2">
<SocialWrapper type="comments">
<CommentSection entityType="song" entityId={song.id} />
</SocialWrapper>
<SocialWrapper type="reviews">
<EntityReviews entityType="song" entityId={song.id} />
</SocialWrapper>
</div>
</div>
)
}

View file

@ -42,7 +42,7 @@ export default function SongsPage() {
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{songs.map((song) => ( {songs.map((song) => (
<Link key={song.id} href={`/songs/${song.slug || song.id}`}> <Link key={song.slug} href={`/songs/${song.slug}`}>
<Card className="h-full hover:bg-accent/50 transition-colors"> <Card className="h-full hover:bg-accent/50 transition-colors">
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">

View file

@ -31,15 +31,16 @@ async function getTourShows(id: string) {
} }
} }
export default async function TourDetailPage({ params }: { params: Promise<{ id: string }> }) { export default async function TourDetailPage({ params }: { params: Promise<{ slug: string }> }) {
const { id } = await params const { slug } = await params
const tour = await getTour(id) const tour = await getTour(slug)
const shows = await getTourShows(id)
if (!tour) { if (!tour) {
notFound() notFound()
} }
const shows = await getTourShows(tour.id)
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div className="flex items-center gap-4 justify-between"> <div className="flex items-center gap-4 justify-between">
@ -75,7 +76,7 @@ export default async function TourDetailPage({ params }: { params: Promise<{ id:
{[...shows] {[...shows]
.sort((a: any, b: any) => new Date(a.date).getTime() - new Date(b.date).getTime()) .sort((a: any, b: any) => new Date(a.date).getTime() - new Date(b.date).getTime())
.map((show: any) => ( .map((show: any) => (
<Link key={show.id} href={`/shows/${show.id}`} className="block group"> <Link key={show.id} href={`/shows/${show.slug}`} className="block group">
<div className="flex items-center justify-between p-2 rounded-md hover:bg-muted/50 transition-colors"> <div className="flex items-center justify-between p-2 rounded-md hover:bg-muted/50 transition-colors">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Calendar className="h-4 w-4 text-muted-foreground" /> <Calendar className="h-4 w-4 text-muted-foreground" />

View file

@ -28,7 +28,7 @@ interface Show {
export default function VenueDetailPage() { export default function VenueDetailPage() {
const params = useParams() const params = useParams()
const id = params.id as string const slug = params.slug as string
const [venue, setVenue] = useState<Venue | null>(null) const [venue, setVenue] = useState<Venue | null>(null)
const [shows, setShows] = useState<Show[]>([]) const [shows, setShows] = useState<Show[]>([])
@ -39,7 +39,7 @@ export default function VenueDetailPage() {
async function fetchData() { async function fetchData() {
try { try {
// Fetch venue // Fetch venue
const venueRes = await fetch(`${getApiUrl()}/venues/${id}`) const venueRes = await fetch(`${getApiUrl()}/venues/${slug}`)
if (!venueRes.ok) { if (!venueRes.ok) {
if (venueRes.status === 404) { if (venueRes.status === 404) {
setError("Venue not found") setError("Venue not found")
@ -69,7 +69,7 @@ export default function VenueDetailPage() {
} }
} }
fetchData() fetchData()
}, [id]) }, [slug])
if (loading) { if (loading) {
return ( return (
@ -137,7 +137,7 @@ export default function VenueDetailPage() {
{shows.length > 0 ? ( {shows.length > 0 ? (
<div className="space-y-2"> <div className="space-y-2">
{shows.map((show) => ( {shows.map((show) => (
<Link key={show.id} href={`/shows/${show.slug || show.id}`} className="block group"> <Link key={show.id} href={`/shows/${show.slug}`} className="block group">
<div className="flex items-center justify-between p-3 rounded-md hover:bg-muted/50 transition-colors border"> <div className="flex items-center justify-between p-3 rounded-md hover:bg-muted/50 transition-colors border">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Calendar className="h-4 w-4 text-muted-foreground" /> <Calendar className="h-4 w-4 text-muted-foreground" />

View file

@ -181,7 +181,7 @@ export default function VenuesPage() {
{/* Venue Grid */} {/* Venue Grid */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{filteredVenues.map((venue) => ( {filteredVenues.map((venue) => (
<Link key={venue.id} href={`/venues/${venue.slug || venue.id}`}> <Link key={venue.id} href={`/venues/${venue.slug}`}>
<Card className="h-full hover:bg-accent/50 transition-colors cursor-pointer group"> <Card className="h-full hover:bg-accent/50 transition-colors cursor-pointer group">
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-lg group-hover:text-primary transition-colors"> <CardTitle className="flex items-center gap-2 text-lg group-hover:text-primary transition-colors">

View file

@ -24,14 +24,11 @@ interface MarkCaughtButtonProps {
export function MarkCaughtButton({ songId, songTitle, showId, className }: MarkCaughtButtonProps) { export function MarkCaughtButton({ songId, songTitle, showId, className }: MarkCaughtButtonProps) {
const { user, token } = useAuth() const { user, token } = useAuth()
const [chaseSong, setChaseSong] = useState<ChaseSong | null>(null) const [chaseSong, setChaseSong] = useState<ChaseSong | null>(null)
const [loading, setLoading] = useState(false)
const [marking, setMarking] = useState(false) const [marking, setMarking] = useState(false)
useEffect(() => { useEffect(() => {
if (!user || !token) return if (!user || !token) return
// Check if this song is in the user's chase list
setLoading(true)
fetch(`${getApiUrl()}/chase/songs`, { fetch(`${getApiUrl()}/chase/songs`, {
headers: { Authorization: `Bearer ${token}` } headers: { Authorization: `Bearer ${token}` }
}) })
@ -41,7 +38,6 @@ export function MarkCaughtButton({ songId, songTitle, showId, className }: MarkC
setChaseSong(match || null) setChaseSong(match || null)
}) })
.catch(() => setChaseSong(null)) .catch(() => setChaseSong(null))
.finally(() => setLoading(false))
}, [user, token, songId]) }, [user, token, songId])
const handleMarkCaught = async () => { const handleMarkCaught = async () => {
@ -74,7 +70,7 @@ export function MarkCaughtButton({ songId, songTitle, showId, className }: MarkC
return ( return (
<span <span
className="inline-flex items-center gap-1 text-xs text-green-600 dark:text-green-400 font-medium" className="inline-flex items-center gap-1 text-xs text-green-600 dark:text-green-400 font-medium"
title={`You caught ${songTitle} at this show! 🎉`} title={`You caught ${songTitle} at this show!`}
> >
<Check className="h-3 w-3" /> <Check className="h-3 w-3" />
Caught! Caught!

View file

@ -22,6 +22,7 @@ const browseLinks = [
{ href: "/performances", label: "Top Performances" }, { href: "/performances", label: "Top Performances" },
{ href: "/tours", label: "Tours" }, { href: "/tours", label: "Tours" },
{ href: "/videos", label: "Videos" }, { href: "/videos", label: "Videos" },
{ href: "/leaderboards", label: "Leaderboards" },
] ]
export function Navbar() { export function Navbar() {

View file

@ -97,7 +97,7 @@ export function EntityRating({ entityType, entityId, compact = false }: EntityRa
)} )}
{hasRated && !loading && ( {hasRated && !loading && (
<p className="text-xs text-green-600 mt-2"> <p className="text-xs text-green-600 mt-2">
Rating saved! Rating saved!
</p> </p>
)} )}
</div> </div>

View file

@ -3,10 +3,9 @@
import { useState } from "react" import { useState } from "react"
import Link from "next/link" import Link from "next/link"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { RatePerformanceDialog } from "@/components/songs/rate-performance-dialog" import { RatePerformanceDialog } from "@/components/songs/rate-performance-dialog"
import { ArrowUpDown, Star, Calendar, Music } from "lucide-react" import { Star, Music } from "lucide-react"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
export interface Performance { export interface Performance {
@ -28,12 +27,11 @@ export interface Performance {
interface PerformanceListProps { interface PerformanceListProps {
performances: Performance[] performances: Performance[]
songTitle?: string
} }
type SortOption = "date_desc" | "date_asc" | "rating_desc" type SortOption = "date_desc" | "date_asc" | "rating_desc"
export function PerformanceList({ performances, songTitle }: PerformanceListProps) { export function PerformanceList({ performances }: PerformanceListProps) {
const [sort, setSort] = useState<SortOption>("date_desc") const [sort, setSort] = useState<SortOption>("date_desc")
const sortedPerformances = [...performances].sort((a, b) => { const sortedPerformances = [...performances].sort((a, b) => {
@ -87,7 +85,7 @@ export function PerformanceList({ performances, songTitle }: PerformanceListProp
<div className="space-y-1 flex-1 min-w-0 pr-4"> <div className="space-y-1 flex-1 min-w-0 pr-4">
<div className="flex items-baseline gap-2 flex-wrap"> <div className="flex items-baseline gap-2 flex-wrap">
<Link <Link
href={`/shows/${perf.show_slug || perf.show_id}`} href={`/shows/${perf.show_slug}`}
className="font-medium hover:underline text-primary truncate" className="font-medium hover:underline text-primary truncate"
> >
{new Date(perf.show_date).toLocaleDateString(undefined, { {new Date(perf.show_date).toLocaleDateString(undefined, {
@ -106,7 +104,7 @@ export function PerformanceList({ performances, songTitle }: PerformanceListProp
</div> </div>
{perf.notes && ( {perf.notes && (
<p className="text-sm italic text-muted-foreground/90 line-clamp-2"> <p className="text-sm italic text-muted-foreground/90 line-clamp-2">
"{perf.notes}" &quot;{perf.notes}&quot;
</p> </p>
)} )}
</div> </div>

View file

@ -7,10 +7,8 @@ import {
Scatter, Scatter,
XAxis, XAxis,
YAxis, YAxis,
ZAxis,
Tooltip, Tooltip,
CartesianGrid, CartesianGrid
TooltipProps
} from "recharts" } from "recharts"
import { format } from "date-fns" import { format } from "date-fns"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
@ -45,7 +43,7 @@ const CustomTooltip = ({ active, payload }: { active?: boolean; payload?: any[]
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
<span className="text-muted-foreground">Rating:</span> <span className="text-muted-foreground">Rating:</span>
<Badge variant="secondary" className="gap-1 bg-yellow-500/10 text-yellow-600 border-yellow-500/20"> <Badge variant="secondary" className="gap-1 bg-yellow-500/10 text-yellow-600 border-yellow-500/20">
{data.rating.toFixed(1)} Rating: {data.rating.toFixed(1)}
</Badge> </Badge>
</div> </div>
</div> </div>
@ -74,8 +72,6 @@ export function SongEvolutionChart({ performances, title = "Rating Evolution" }:
// Calculate trend? Simple linear/avg for now. // Calculate trend? Simple linear/avg for now.
const average = ratedPerfs.reduce((acc, curr) => acc + curr.rating, 0) / ratedPerfs.length const average = ratedPerfs.reduce((acc, curr) => acc + curr.rating, 0) / ratedPerfs.length
const latest = ratedPerfs[ratedPerfs.length - 1].rating
const isTrendingUp = latest >= average
return ( return (
<Card className="col-span-1 shadow-sm"> <Card className="col-span-1 shadow-sm">

View file

@ -29,19 +29,22 @@ function Command({
) )
} }
interface CommandDialogProps extends React.ComponentPropsWithoutRef<typeof Dialog> {
title?: string
description?: string
commandProps?: React.ComponentPropsWithoutRef<typeof Command>
showCloseButton?: boolean
}
function CommandDialog({ function CommandDialog({
title = "Command Palette", title = "Command Palette",
description = "Search for a command to run...", description = "Search for a command to run...",
children, children,
className, className,
showCloseButton = true, showCloseButton = true,
commandProps,
...props ...props
}: React.ComponentProps<typeof Dialog> & { }: CommandDialogProps) {
title?: string
description?: string
className?: string
showCloseButton?: boolean
}) {
return ( return (
<Dialog {...props}> <Dialog {...props}>
<DialogHeader className="sr-only"> <DialogHeader className="sr-only">
@ -52,7 +55,13 @@ function CommandDialog({
className={cn("overflow-hidden p-0", className)} className={cn("overflow-hidden p-0", className)}
showCloseButton={showCloseButton} showCloseButton={showCloseButton}
> >
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5"> <Command
{...commandProps}
className={cn(
"[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5",
commandProps?.className
)}
>
{children} {children}
</Command> </Command>
</DialogContent> </DialogContent>

View file

@ -30,7 +30,6 @@ export function SearchDialog() {
users: [], users: [],
nicknames: [], nicknames: [],
performances: [], performances: [],
shows: [] // Ensure backend sends this or we default to empty
}) })
const router = useRouter() const router = useRouter()
@ -48,7 +47,7 @@ export function SearchDialog() {
React.useEffect(() => { React.useEffect(() => {
if (query.length < 2) { if (query.length < 2) {
setResults({ songs: [], venues: [], tours: [], groups: [], users: [], nicknames: [], performances: [], shows: [] }) setResults({ songs: [], venues: [], tours: [], groups: [], users: [], nicknames: [], performances: [] })
return return
} }
@ -87,8 +86,12 @@ export function SearchDialog() {
<span className="text-xs"></span>K <span className="text-xs"></span>K
</kbd> </kbd>
</button> </button>
<CommandDialog open={open} onOpenChange={setOpen}> <CommandDialog open={open} onOpenChange={setOpen} commandProps={{ shouldFilter: false }}>
<CommandInput placeholder="Type to search songs, venues, tours..." value={query} onValueChange={setQuery} /> <CommandInput
placeholder="Type to search songs, venues, tours..."
value={query}
onValueChange={setQuery}
/>
<CommandList className="max-h-[500px]"> <CommandList className="max-h-[500px]">
<CommandEmpty> <CommandEmpty>
{loading ? ( {loading ? (
@ -96,27 +99,27 @@ export function SearchDialog() {
Searching... Searching...
</div> </div>
) : ( ) : (
"No results found." query.length >= 2 ? "No results found." : "Type at least 2 characters..."
)} )}
</CommandEmpty> </CommandEmpty>
{query.length < 2 && ( {query.length < 2 && (
<CommandGroup heading="Quick Navigation"> <CommandGroup heading="Quick Navigation">
<CommandItem onSelect={() => handleSelect("/archive")}> <CommandItem onSelect={() => handleSelect("/shows")}>
<Library className="mr-2 h-4 w-4" /> <Calendar className="mr-2 h-4 w-4" />
<span>Archive</span> <span>Browse Shows</span>
</CommandItem> </CommandItem>
<CommandItem onSelect={() => handleSelect("/leaderboards")}> <CommandItem onSelect={() => handleSelect("/leaderboards")}>
<Star className="mr-2 h-4 w-4" /> <Star className="mr-2 h-4 w-4" />
<span>Leaderboards</span> <span>Leaderboards</span>
</CommandItem> </CommandItem>
<CommandItem onSelect={() => handleSelect("/shows")}> <CommandItem onSelect={() => handleSelect("/songs")}>
<Calendar className="mr-2 h-4 w-4" /> <Music className="mr-2 h-4 w-4" />
<span>All Shows</span> <span>Song Catalog</span>
</CommandItem> </CommandItem>
<CommandItem onSelect={() => handleSelect("/tours")}> <CommandItem onSelect={() => handleSelect("/venues")}>
<Globe className="mr-2 h-4 w-4" /> <MapPin className="mr-2 h-4 w-4" />
<span>Tours</span> <span>Venues</span>
</CommandItem> </CommandItem>
</CommandGroup> </CommandGroup>
)} )}
@ -124,12 +127,12 @@ export function SearchDialog() {
{results.songs?.length > 0 && ( {results.songs?.length > 0 && (
<CommandGroup heading="Songs"> <CommandGroup heading="Songs">
{results.songs.map((song: any) => ( {results.songs.map((song: any) => (
<CommandItem key={song.id} onSelect={() => handleSelect(`/songs/${song.id}`)}> <CommandItem key={song.slug || song.id} onSelect={() => handleSelect(`/songs/${song.slug}`)}>
<Music className="mr-2 h-4 w-4" /> <Music className="mr-2 h-4 w-4" />
<div className="flex flex-col"> <div className="flex flex-col">
<span>{song.title}</span> <span>{song.title}</span>
{song.original_artist && ( {song.original_artist && (
<span className="text-[10px] text-muted-foreground">{song.original_artist}</span> <span className="text-[10px] text-muted-foreground">Original by {song.original_artist}</span>
)} )}
</div> </div>
</CommandItem> </CommandItem>
@ -140,12 +143,14 @@ export function SearchDialog() {
{results.venues?.length > 0 && ( {results.venues?.length > 0 && (
<CommandGroup heading="Venues"> <CommandGroup heading="Venues">
{results.venues.map((venue: any) => ( {results.venues.map((venue: any) => (
<CommandItem key={venue.id} onSelect={() => handleSelect(`/venues/${venue.id}`)}> <CommandItem key={venue.slug || venue.id} onSelect={() => handleSelect(`/venues/${venue.slug}`)}>
<MapPin className="mr-2 h-4 w-4" /> <MapPin className="mr-2 h-4 w-4" />
<span>{venue.name}</span> <div className="flex flex-col">
<span className="ml-2 text-xs text-muted-foreground hidden sm:inline-block"> <span>{venue.name}</span>
{venue.city}, {venue.state} <span className="text-[10px] text-muted-foreground">
</span> {venue.city}, {venue.state}
</span>
</div>
</CommandItem> </CommandItem>
))} ))}
</CommandGroup> </CommandGroup>
@ -154,8 +159,8 @@ export function SearchDialog() {
{results.tours?.length > 0 && ( {results.tours?.length > 0 && (
<CommandGroup heading="Tours"> <CommandGroup heading="Tours">
{results.tours.map((tour: any) => ( {results.tours.map((tour: any) => (
<CommandItem key={tour.id} onSelect={() => handleSelect(`/tours/${tour.id}`)}> <CommandItem key={tour.slug || tour.id} onSelect={() => handleSelect(`/tours/${tour.slug}`)}>
<Calendar className="mr-2 h-4 w-4" /> <Globe className="mr-2 h-4 w-4" />
<span>{tour.name}</span> <span>{tour.name}</span>
</CommandItem> </CommandItem>
))} ))}
@ -163,15 +168,14 @@ export function SearchDialog() {
)} )}
{results.performances?.length > 0 && ( {results.performances?.length > 0 && (
<CommandGroup heading="Performances"> <CommandGroup heading="Performances & Notes">
{results.performances.map((perf: any) => ( {results.performances.map((perf: any) => (
<CommandItem key={perf.id} onSelect={() => handleSelect(`/shows/${perf.show_id}`)}> <CommandItem key={perf.slug || perf.id} onSelect={() => handleSelect(`/shows/${perf.show?.slug}`)}>
<Music className="mr-2 h-4 w-4" /> <Music className="mr-2 h-4 w-4" />
<div className="flex flex-col"> <div className="flex flex-col">
<span>{perf.song?.title || "Unknown Song"}</span> <span>{perf.song?.title || "Unknown Song"}</span>
<span className="text-[10px] text-muted-foreground"> <span className="text-[10px] text-muted-foreground">
{perf.notes} {perf.show?.date} {perf.notes}
{/* We rely on frontend resolving show date if available, or just link to show */}
</span> </span>
</div> </div>
<Badge variant="outline" className="ml-auto text-[10px] h-5">Performance</Badge> <Badge variant="outline" className="ml-auto text-[10px] h-5">Performance</Badge>
@ -183,12 +187,14 @@ export function SearchDialog() {
{results.nicknames?.length > 0 && ( {results.nicknames?.length > 0 && (
<CommandGroup heading="Heady Nicknames"> <CommandGroup heading="Heady Nicknames">
{results.nicknames.map((nickname: any) => ( {results.nicknames.map((nickname: any) => (
<CommandItem key={nickname.id} onSelect={() => handleSelect(`/shows/${nickname.performance?.show_id}`)}> <CommandItem key={nickname.id} onSelect={() => handleSelect(`/shows/${nickname.performance?.show?.slug}`)}>
<Star className="mr-2 h-4 w-4 text-yellow-500" /> <Star className="mr-2 h-4 w-4 text-yellow-500" />
<span>{nickname.nickname}</span> <div className="flex flex-col">
<span className="ml-2 text-xs text-muted-foreground"> <span>{nickname.nickname}</span>
({nickname.performance?.song?.title}) <span className="text-[10px] text-muted-foreground">
</span> ({nickname.performance?.song?.title} - {nickname.performance?.show?.date})
</span>
</div>
</CommandItem> </CommandItem>
))} ))}
</CommandGroup> </CommandGroup>

View file

@ -1,7 +1,7 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
/* config options here */ devIndicators: false,
}; };
export default nextConfig; export default nextConfig;

View file

@ -3,7 +3,7 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev --turbo",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "eslint", "lint": "eslint",
@ -50,4 +50,4 @@
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"typescript": "^5" "typescript": "^5"
} }
} }