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.get("/{performance_id_or_slug}", response_model=PerformanceDetailRead)
def read_performance(performance_id_or_slug: str, session: Session = Depends(get_session)):
performance = None
if performance_id_or_slug.isdigit():
performance = session.get(Performance, int(performance_id_or_slug))
if not performance:
# Try slug lookup
performance = session.exec(
select(Performance).where(Performance.slug == performance_id_or_slug)
).first()
@router.get("/{slug}", response_model=PerformanceDetailRead)
def read_performance(slug: str, session: Session = Depends(get_session)):
performance = session.exec(
select(Performance).where(Performance.slug == slug)
).first()
if not performance:
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
break
prev_id = None
next_id = None
prev_slug = None
next_slug = None
gap = 0
times_played = current_index + 1 # 1-based count
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
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()
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
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['nicknames'] = performance.nicknames
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_slug'] = next_slug
perf_dict['gap'] = gap
perf_dict['times_played'] = times_played
perf_dict['other_performances'] = other_performances

View file

@ -1,6 +1,7 @@
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlmodel import Session, select, col
from sqlalchemy.orm import selectinload
from database import get_session
from models import Show, Song, Venue, Tour, User, Group, Performance, PerformanceNickname, Comment, Review
from schemas import ShowRead, SongRead, VenueRead, TourRead, UserRead, GroupRead
@ -19,36 +20,53 @@ def global_search(
q_str = f"%{q}%"
# 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
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
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
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)
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
nicknames = session.exec(
select(PerformanceNickname)
.options(selectinload(PerformanceNickname.performance).selectinload(Performance.song))
.where(col(PerformanceNickname.nickname).ilike(q_str))
.where(PerformanceNickname.status == "approved")
.limit(limit)
).all()
# Search Performances (by notes, e.g. "unfinished", "slow version")
# 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.
# Search Performances
performances = session.exec(
select(Performance)
.join(Song)
.options(selectinload(Performance.song), selectinload(Performance.show))
.where(col(Performance.notes).ilike(q_str))
.limit(limit)
).all()

View file

@ -48,12 +48,9 @@ def read_recent_shows(
shows = session.exec(query).all()
return shows
@router.get("/{show_id}", response_model=ShowRead)
def read_show(show_id: str, session: Session = Depends(get_session)):
if show_id.isdigit():
show = session.get(Show, int(show_id))
else:
show = session.exec(select(Show).where(Show.slug == show_id)).first()
@router.get("/{slug}", response_model=ShowRead)
def read_show(slug: str, session: Session = Depends(get_session)):
show = session.exec(select(Show).where(Show.slug == slug)).first()
if not show:
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()
return songs
@router.get("/{song_id_or_slug}", response_model=SongReadWithStats)
def read_song(song_id_or_slug: str, session: Session = Depends(get_session)):
# Try to parse as int (ID), otherwise treat as slug
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()
@router.get("/{slug}", response_model=SongReadWithStats)
def read_song(slug: str, session: Session = Depends(get_session)):
song = session.exec(select(Song).where(Song.slug == slug)).first()
if not song:
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()
return tours
@router.get("/{tour_id}", response_model=TourRead)
def read_tour(tour_id: int, session: Session = Depends(get_session)):
tour = session.get(Tour, tour_id)
@router.get("/{slug}", response_model=TourRead)
def read_tour(slug: str, session: Session = Depends(get_session)):
tour = session.exec(select(Tour).where(Tour.slug == slug)).first()
if not tour:
raise HTTPException(status_code=404, detail="Tour not found")
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()
return venues
@router.get("/{venue_id_or_slug}", response_model=VenueRead)
def read_venue(venue_id_or_slug: str, session: Session = Depends(get_session)):
venue = None
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()
@router.get("/{slug}", response_model=VenueRead)
def read_venue(slug: str, session: Session = Depends(get_session)):
venue = session.exec(select(Venue).where(Venue.slug == slug)).first()
if not venue:
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]:
"""Get top users by XP"""
# Test accounts to hide until we have real users
TEST_USER_EMAILS = ["tenwest", "testuser"]
MIN_USERS_TO_SHOW_TEST = 12
# Count total real users
total_users = session.exec(
select(func.count(User.id))
.where(User.is_active == True)
).one() or 0
# Filter out test accounts
# Filter out test accounts
TEST_USER_EMAILS = [
"tenwest",
"testuser",
"rescue@elmeg.xyz",
"admin-rescue@elmeg.xyz",
"admin@elmeg.xyz",
"test@",
"emailtest"
]
# Build query
query = select(User).where(User.is_active == True)
# If we don't have enough real users, hide test accounts
if total_users < MIN_USERS_TO_SHOW_TEST:
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}%"))
# Strictly filter out test accounts
for test_email in TEST_USER_EMAILS:
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(
query.order_by(User.xp.desc()).limit(limit)
@ -314,18 +321,18 @@ PURCHASABLE_COLORS = {
"Rainbow": {"hex": "gradient", "cost": 2500, "min_level": 10},
}
# Flairs (small text/emoji beside username)
# Flairs (small text/icon name beside username)
PURCHASABLE_FLAIRS = {
"": {"cost": 100, "min_level": 1},
"🎸": {"cost": 100, "min_level": 1},
"🎵": {"cost": 100, "min_level": 1},
"🌈": {"cost": 200, "min_level": 3},
"🔥": {"cost": 200, "min_level": 3},
"": {"cost": 300, "min_level": 5},
"👑": {"cost": 500, "min_level": 7},
"🚀": {"cost": 400, "min_level": 6},
"💎": {"cost": 750, "min_level": 9},
"🌟": {"cost": 1000, "min_level": 10},
"Bolt": {"cost": 100, "min_level": 1},
"Guitar": {"cost": 100, "min_level": 1},
"Music": {"cost": 100, "min_level": 1},
"Rainbow": {"cost": 200, "min_level": 3},
"Fire": {"cost": 200, "min_level": 3},
"Star": {"cost": 300, "min_level": 5},
"Crown": {"cost": 500, "min_level": 7},
"Rocket": {"cost": 400, "min_level": 6},
"Diamond": {"cost": 750, "min_level": 9},
"Sparkles": {"cost": 1000, "min_level": 10},
}
# Early adopter perks
@ -333,7 +340,7 @@ EARLY_ADOPTER_PERKS = {
"free_title_change": True, # Early adopters can change title for free (once per month)
"exclusive_titles": ["Pioneer", "Founding Member", "OG User", "Day One"],
"exclusive_colors": {"Pioneer Gold": "#FFB347", "Genesis Green": "#50C878"},
"exclusive_flair": ["🥇", "🏆"],
"exclusive_flair": ["Medal", "Trophy"],
"title_color": "#FFB347", # Default gold color for early adopters
"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>
</div>
<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>
<div className="flex gap-4 justify-center pt-4">
<Link href="/bugs/my-tickets">
@ -136,11 +136,10 @@ export default function BugsPage() {
}
return (
<div className="container max-w-4xl py-8">
{/* Header */}
<div className="text-center mb-12">
<h1 className="text-4xl font-bold tracking-tight mb-4">How can we help?</h1>
<p className="text-muted-foreground text-lg">
<div className="container py-10 space-y-8 animate-in fade-in duration-700">
<div className="flex flex-col gap-2">
<h1 className="text-3xl font-bold tracking-tight">How can we help?</h1>
<p className="text-muted-foreground">
Report bugs, request features, or ask questions
</p>
</div>
@ -155,7 +154,7 @@ export default function BugsPage() {
<button
key={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="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"
onClick={() => setFormData({ ...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-border hover:border-muted-foreground"
? "border-primary bg-primary/10"
: "border-border hover:border-muted-foreground"
}`}
>
<span className={`inline-block w-2 h-2 rounded-full ${p.color} mr-2`} />
@ -273,7 +272,7 @@ export default function BugsPage() {
required
/>
<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>
</div>
)}

View file

@ -46,7 +46,7 @@
/* Light Mode - Ersen Style */
:root {
--radius: 0.3rem;
--radius: 0.2rem;
--background: hsl(240, 5%, 98%);
--foreground: hsl(240, 10%, 3.9%);
--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"
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 }
avg_score: number
review_count: number
}
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
review_count: number
}
interface TopPerformance {
performance: { id: number; show_id: number; song_id: number; notes: string | null }
song: { id: number; title: string; original_artist: string | null }
show: { id: number; date: string }
venue: { id: number; name: string; city: string; state: string }
performance: { id: number; show_id: number; song_id: number; notes: string | null; slug: string }
song: { id: number; title: string; original_artist: string | null; slug: string }
show: { id: number; date: string; slug: string }
venue: { id: number; name: string; city: string; state: string; slug: string }
avg_score: number
rating_count: number
}
@ -132,7 +132,7 @@ export default function LeaderboardsPage() {
</div>
<div className="flex flex-1 flex-col justify-center gap-1">
<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}
</Link>
{item.performance.notes && (
@ -143,7 +143,7 @@ export default function LeaderboardsPage() {
</div>
<div className="flex items-center text-sm text-muted-foreground gap-2">
<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, {
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="font-mono text-muted-foreground w-6 text-center">{i + 1}</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, {
year: 'numeric', month: 'long', day: 'numeric'
})}
@ -236,7 +236,7 @@ export default function LeaderboardsPage() {
{i + 1}
</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}
</Link>
<p className="text-xs text-muted-foreground truncate">

View file

@ -335,7 +335,7 @@ export default function ModDashboardPage() {
</CardHeader>
<CardContent>
{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">
{pendingReports.map(report => (

View file

@ -3,7 +3,7 @@ import { XPLeaderboard } from "@/components/gamification/xp-leaderboard"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
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"
interface Show {
@ -84,7 +84,7 @@ export default async function Home() {
return (
<div className="flex flex-col gap-8">
{/* 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">
Elmeg
</h1>
@ -122,7 +122,7 @@ export default async function Home() {
{recentShows.length > 0 ? (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
{recentShows.map((show) => (
<Link key={show.id} href={`/shows/${show.slug || show.id}`}>
<Link key={show.id} href={`/shows/${show.slug}`}>
<Card className="h-full hover:bg-accent/50 transition-colors cursor-pointer">
<CardContent className="p-4">
<div className="font-semibold">
@ -179,7 +179,7 @@ export default async function Home() {
{topSongs.map((song, idx) => (
<li key={song.id}>
<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"
>
<span className="text-lg font-bold text-muted-foreground w-6 text-center">
@ -224,27 +224,42 @@ export default async function Home() {
</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" />
<section className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Link href="/shows" className="block p-6 border rounded-lg hover:bg-accent transition-colors group">
<Calendar className="h-8 w-8 mb-2 text-blue-500 group-hover:scale-110 transition-transform" />
<h3 className="font-bold">Shows</h3>
<p className="text-sm text-muted-foreground">Browse the complete archive</p>
</Link>
<Link href="/venues" className="block p-6 border rounded-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" />
<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">
<Link href="/songs" className="block p-6 border rounded-lg hover:bg-accent transition-colors group">
<Music className="h-8 w-8 mb-2 text-purple-500 group-hover:scale-110 transition-transform" />
<h3 className="font-bold">Songs</h3>
<p className="text-sm text-muted-foreground">Explore the catalog</p>
</Link>
<Link href="/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" />
<h3 className="font-bold">Top Performances</h3>
<p className="text-sm text-muted-foreground">Highest rated jams</p>
</Link>
<Link href="/leaderboards" className="block p-6 border rounded-lg hover:bg-accent transition-colors group">
<Star className="h-8 w-8 mb-2 text-yellow-500 group-hover:scale-110 transition-transform" />
<h3 className="font-bold">Leaderboards</h3>
<p className="text-sm text-muted-foreground">Top rated everything</p>
</Link>
<Link href="/tours" className="block p-6 border rounded-lg hover:bg-accent transition-colors group">
<Route className="h-8 w-8 mb-2 text-orange-500 group-hover:scale-110 transition-transform" />
<h3 className="font-bold">Tours</h3>
<p className="text-sm text-muted-foreground">Browse by tour</p>
</Link>
<Link href="/videos" className="block p-6 border rounded-lg hover:bg-accent transition-colors group">
<Youtube className="h-8 w-8 mb-2 text-red-500 group-hover:scale-110 transition-transform" />
<h3 className="font-bold">Videos</h3>
<p className="text-sm text-muted-foreground">Watch full shows and songs</p>
</Link>
</section>
</div>
)

View file

@ -22,9 +22,9 @@ async function getPerformance(id: string) {
}
}
export default async function PerformanceDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const performance = await getPerformance(id)
export default async function PerformanceDetailPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params
const performance = await getPerformance(slug)
if (!performance) {
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">
{/* Breadcrumbs */}
<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
</Link>
<ChevronRight className="h-4 w-4" />
<Link
href={`/songs/${performance.song.id}`}
href={`/songs/${performance.song.slug}`}
className="hover:text-foreground transition-colors"
>
{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 items-start gap-4">
<Link href={`/shows/${performance.show.id}`}>
<Link href={`/shows/${performance.show.slug}`}>
<Button variant="outline" size="icon" className="mt-1">
<ArrowLeft className="h-4 w-4" />
</Button>
@ -85,7 +85,7 @@ export default async function PerformanceDetailPage({ params }: { params: Promis
{/* Song Title (links to song page) */}
<h1 className="text-3xl md:text-4xl font-bold tracking-tight">
<Link
href={`/songs/${performance.song.id}`}
href={`/songs/${performance.song.slug}`}
className="hover:text-primary transition-colors"
>
{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"
title={nick.description}
>
"{nick.nickname}"
&quot;{nick.nickname}&quot;
</span>
))}
</div>
@ -110,7 +110,7 @@ export default async function PerformanceDetailPage({ params }: { params: Promis
{/* Show Context - THE KEY DIFFERENTIATOR */}
<div className="mt-4 p-3 rounded-lg bg-background/80 border inline-flex flex-col gap-1">
<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"
>
<Calendar className="h-4 w-4" />
@ -118,7 +118,7 @@ export default async function PerformanceDetailPage({ params }: { params: Promis
</Link>
{performance.show.venue && (
<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"
>
<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">
{performance.previous_performance_id ? (
<Link
href={`/performances/${performance.previous_performance_id}`}
href={`/performances/${performance.previous_performance_slug}`}
className="flex-1"
>
<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 ? (
<Link
href={`/performances/${performance.next_performance_id}`}
href={`/performances/${performance.next_performance_slug}`}
className="flex-1"
>
<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) => (
<Link
key={perf.id}
href={`/performances/${perf.slug || perf.id}`}
href={`/performances/${perf.slug}`}
className="flex items-start justify-between group"
>
<div className="flex flex-col">
@ -332,7 +332,7 @@ export default async function PerformanceDetailPage({ params }: { params: Promis
</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"
>
View all {performance.other_performances.length + 1} versions
@ -350,14 +350,14 @@ export default async function PerformanceDetailPage({ params }: { params: Promis
</CardHeader>
<CardContent className="space-y-2">
<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"
>
<Music className="h-4 w-4 text-muted-foreground" />
<span>All versions of {performance.song.title}</span>
</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"
>
<Calendar className="h-4 w-4 text-muted-foreground" />
@ -365,7 +365,7 @@ export default async function PerformanceDetailPage({ params }: { params: Promis
</Link>
{performance.show.venue && (
<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"
>
<MapPin className="h-4 w-4 text-muted-foreground" />

View file

@ -34,7 +34,7 @@ interface UserBadge {
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 [user, setUser] = useState<UserProfile | null>(null)
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 })
useEffect(() => {
params.then(p => setId(p.id))
params.then(p => setId(p.slug))
}, [params])
useEffect(() => {

View file

@ -1,14 +1,11 @@
import { Button } from "@/components/ui/button"
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 { CommentSection } from "@/components/social/comment-section"
import { EntityRating } from "@/components/social/entity-rating"
import { ShowAttendance } from "@/components/shows/show-attendance"
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 { SuggestNicknameDialog } from "@/components/shows/suggest-nickname-dialog"
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 }> }) {
const { id } = await params
const show = await getShow(id)
export default async function ShowDetailPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params
const show = await getShow(slug)
if (!show) {
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>
<div className="font-medium flex items-center gap-2">
<Link
href={`/performances/${perf.slug || perf.id}`}
href={`/performances/${perf.slug}`}
className="hover:text-primary hover:underline transition-colors"
>
{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">
{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}>
"{nick.nickname}"
&quot;{nick.nickname}&quot;
</span>
))}
</div>
@ -296,7 +293,7 @@ export default async function ShowDetailPage({ params }: { params: Promise<{ id:
<>
<div className="flex items-center gap-2">
<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}
</Link>
</div>

View file

@ -84,7 +84,7 @@ function ShowsContent() {
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{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">
{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">

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">
{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">
<CardHeader>
<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 }> }) {
const { id } = await params
const tour = await getTour(id)
const shows = await getTourShows(id)
export default async function TourDetailPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params
const tour = await getTour(slug)
if (!tour) {
notFound()
}
const shows = await getTourShows(tour.id)
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-4 justify-between">
@ -75,7 +76,7 @@ export default async function TourDetailPage({ params }: { params: Promise<{ id:
{[...shows]
.sort((a: any, b: any) => new Date(a.date).getTime() - new Date(b.date).getTime())
.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 gap-3">
<Calendar className="h-4 w-4 text-muted-foreground" />

View file

@ -28,7 +28,7 @@ interface Show {
export default function VenueDetailPage() {
const params = useParams()
const id = params.id as string
const slug = params.slug as string
const [venue, setVenue] = useState<Venue | null>(null)
const [shows, setShows] = useState<Show[]>([])
@ -39,7 +39,7 @@ export default function VenueDetailPage() {
async function fetchData() {
try {
// Fetch venue
const venueRes = await fetch(`${getApiUrl()}/venues/${id}`)
const venueRes = await fetch(`${getApiUrl()}/venues/${slug}`)
if (!venueRes.ok) {
if (venueRes.status === 404) {
setError("Venue not found")
@ -69,7 +69,7 @@ export default function VenueDetailPage() {
}
}
fetchData()
}, [id])
}, [slug])
if (loading) {
return (
@ -137,7 +137,7 @@ export default function VenueDetailPage() {
{shows.length > 0 ? (
<div className="space-y-2">
{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 gap-3">
<Calendar className="h-4 w-4 text-muted-foreground" />

View file

@ -181,7 +181,7 @@ export default function VenuesPage() {
{/* Venue Grid */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{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">
<CardHeader className="pb-2">
<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) {
const { user, token } = useAuth()
const [chaseSong, setChaseSong] = useState<ChaseSong | null>(null)
const [loading, setLoading] = useState(false)
const [marking, setMarking] = useState(false)
useEffect(() => {
if (!user || !token) return
// Check if this song is in the user's chase list
setLoading(true)
fetch(`${getApiUrl()}/chase/songs`, {
headers: { Authorization: `Bearer ${token}` }
})
@ -41,7 +38,6 @@ export function MarkCaughtButton({ songId, songTitle, showId, className }: MarkC
setChaseSong(match || null)
})
.catch(() => setChaseSong(null))
.finally(() => setLoading(false))
}, [user, token, songId])
const handleMarkCaught = async () => {
@ -74,7 +70,7 @@ export function MarkCaughtButton({ songId, songTitle, showId, className }: MarkC
return (
<span
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" />
Caught!

View file

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

View file

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

View file

@ -3,10 +3,9 @@
import { useState } from "react"
import Link from "next/link"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
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"
export interface Performance {
@ -28,12 +27,11 @@ export interface Performance {
interface PerformanceListProps {
performances: Performance[]
songTitle?: string
}
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 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="flex items-baseline gap-2 flex-wrap">
<Link
href={`/shows/${perf.show_slug || perf.show_id}`}
href={`/shows/${perf.show_slug}`}
className="font-medium hover:underline text-primary truncate"
>
{new Date(perf.show_date).toLocaleDateString(undefined, {
@ -106,7 +104,7 @@ export function PerformanceList({ performances, songTitle }: PerformanceListProp
</div>
{perf.notes && (
<p className="text-sm italic text-muted-foreground/90 line-clamp-2">
"{perf.notes}"
&quot;{perf.notes}&quot;
</p>
)}
</div>

View file

@ -7,10 +7,8 @@ import {
Scatter,
XAxis,
YAxis,
ZAxis,
Tooltip,
CartesianGrid,
TooltipProps
CartesianGrid
} from "recharts"
import { format } from "date-fns"
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">
<span className="text-muted-foreground">Rating:</span>
<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>
</div>
</div>
@ -74,8 +72,6 @@ export function SongEvolutionChart({ performances, title = "Rating Evolution" }:
// Calculate trend? Simple linear/avg for now.
const average = ratedPerfs.reduce((acc, curr) => acc + curr.rating, 0) / ratedPerfs.length
const latest = ratedPerfs[ratedPerfs.length - 1].rating
const isTrendingUp = latest >= average
return (
<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({
title = "Command Palette",
description = "Search for a command to run...",
children,
className,
showCloseButton = true,
commandProps,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string
description?: string
className?: string
showCloseButton?: boolean
}) {
}: CommandDialogProps) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
@ -52,7 +55,13 @@ function CommandDialog({
className={cn("overflow-hidden p-0", className)}
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}
</Command>
</DialogContent>

View file

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

View file

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

View file

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