diff --git a/backend/fix_numeric_slugs.py b/backend/fix_numeric_slugs.py new file mode 100644 index 0000000..acba199 --- /dev/null +++ b/backend/fix_numeric_slugs.py @@ -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() diff --git a/backend/inspect_api.py b/backend/inspect_api.py new file mode 100644 index 0000000..52abf01 --- /dev/null +++ b/backend/inspect_api.py @@ -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() diff --git a/backend/routers/performances.py b/backend/routers/performances.py index 6d48d10..8c96938 100644 --- a/backend/routers/performances.py +++ b/backend/routers/performances.py @@ -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 diff --git a/backend/routers/search.py b/backend/routers/search.py index 45edd32..4e57960 100644 --- a/backend/routers/search.py +++ b/backend/routers/search.py @@ -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() diff --git a/backend/routers/shows.py b/backend/routers/shows.py index 12c5287..2da4246 100644 --- a/backend/routers/shows.py +++ b/backend/routers/shows.py @@ -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") diff --git a/backend/routers/songs.py b/backend/routers/songs.py index 72698b1..fb4558a 100644 --- a/backend/routers/songs.py +++ b/backend/routers/songs.py @@ -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") diff --git a/backend/routers/tours.py b/backend/routers/tours.py index 8a1c279..38e2f5d 100644 --- a/backend/routers/tours.py +++ b/backend/routers/tours.py @@ -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 diff --git a/backend/routers/venues.py b/backend/routers/venues.py index ed0d8d2..2d23dea 100644 --- a/backend/routers/venues.py +++ b/backend/routers/venues.py @@ -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") diff --git a/backend/services/gamification.py b/backend/services/gamification.py index dcff809..b45dd47 100644 --- a/backend/services/gamification.py +++ b/backend/services/gamification.py @@ -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 } diff --git a/backend/test_smtp_connection.py b/backend/test_smtp_connection.py new file mode 100644 index 0000000..22b5884 --- /dev/null +++ b/backend/test_smtp_connection.py @@ -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""" +
+

Elmeg SMTP Test

+

Aloha!

+

This email confirms that your Elmeg backend is successfully connected to the Postal SMTP server.

+
+

Server: {email_service.smtp_host}

+

Port: {email_service.smtp_port}

+

User: {email_service.smtp_username}

+
+

Sent at {datetime.now()}

+
+ """ + + 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() diff --git a/frontend/app/bugs/page.tsx b/frontend/app/bugs/page.tsx index 95f95d0..16cde86 100644 --- a/frontend/app/bugs/page.tsx +++ b/frontend/app/bugs/page.tsx @@ -119,7 +119,7 @@ export default function BugsPage() {

- We'll review your submission and get back to you soon. + We'll review your submission and get back to you soon.

@@ -136,11 +136,10 @@ export default function BugsPage() { } return ( -
- {/* Header */} -
-

How can we help?

-

+

+
+

How can we help?

+

Report bugs, request features, or ask questions

@@ -155,7 +154,7 @@ export default function BugsPage() { @@ -85,7 +85,7 @@ export default async function PerformanceDetailPage({ params }: { params: Promis {/* Song Title (links to song page) */}

{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}" + "{nick.nickname}" ))}

@@ -110,7 +110,7 @@ export default async function PerformanceDetailPage({ params }: { params: Promis {/* Show Context - THE KEY DIFFERENTIATOR */}
@@ -118,7 +118,7 @@ export default async function PerformanceDetailPage({ params }: { params: Promis {performance.show.venue && ( @@ -174,7 +174,7 @@ export default async function PerformanceDetailPage({ params }: { params: Promis
{performance.previous_performance_id ? ( - -
-
-

{song.title}

- {song.original_artist && ( - ({song.original_artist}) - )} -
- {song.tags && song.tags.length > 0 && ( -
- {song.tags.map((tag: any) => ( - - #{tag.name} - - ))} -
- )} -
-
- - - -
- -
- - - Times Played - - -
- - {song.times_played} -
-
-
- - - Gap (Shows) - - -
- - {song.gap} -
-
-
- - - Last Played - - -
- - {song.last_played ? new Date(song.last_played).toLocaleDateString() : "Never"} -
-
-
-
- - {/* Heady Version Section */} - {headyVersions.length > 0 && ( - - - - - Heady Version Leaderboard - - - - {/* Top Performance with YouTube */} - {topPerformance && ( -
- {topPerformance.youtube_link ? ( - - ) : song.youtube_link ? ( - - ) : ( -
-
- -

No video available

-
-
- )} -
-
- 🏆 #1 Heady -
-

- {topPerformance.show?.date ? new Date(topPerformance.show.date).toLocaleDateString() : "Unknown Date"} -

-

- {topPerformance.show?.venue?.name || "Unknown Venue"} -

-
- - {topPerformance.avg_rating?.toFixed(1)} - ({topPerformance.rating_count} ratings) -
-
-
- )} - - {/* Leaderboard List */} -
- {headyVersions.map((perf, index) => ( -
-
- - {index === 0 ? "🥇" : index === 1 ? "🥈" : index === 2 ? "🥉" : `${index + 1}.`} - -
-

- {perf.show?.date ? new Date(perf.show.date).toLocaleDateString() : "Unknown"} -

-

- {perf.show?.venue?.name || "Unknown Venue"} -

-
-
-
- {perf.youtube_link && ( - - - - )} -
- {perf.avg_rating?.toFixed(1)}★ - ({perf.rating_count}) -
-
-
- ))} -
-
-
- )} - - - - {/* Performance List Component (Handles Client Sorting) */} - - -
- - - - - - - -
-
- ) -} diff --git a/frontend/app/songs/page.tsx b/frontend/app/songs/page.tsx index 82be028..10097ce 100644 --- a/frontend/app/songs/page.tsx +++ b/frontend/app/songs/page.tsx @@ -42,7 +42,7 @@ export default function SongsPage() {
{songs.map((song) => ( - + diff --git a/frontend/app/tours/[id]/page.tsx b/frontend/app/tours/[slug]/page.tsx similarity index 96% rename from frontend/app/tours/[id]/page.tsx rename to frontend/app/tours/[slug]/page.tsx index bdbc151..bceab0d 100644 --- a/frontend/app/tours/[id]/page.tsx +++ b/frontend/app/tours/[slug]/page.tsx @@ -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 (
@@ -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) => ( - +
diff --git a/frontend/app/venues/[id]/page.tsx b/frontend/app/venues/[slug]/page.tsx similarity index 99% rename from frontend/app/venues/[id]/page.tsx rename to frontend/app/venues/[slug]/page.tsx index 1763326..bd13eda 100644 --- a/frontend/app/venues/[id]/page.tsx +++ b/frontend/app/venues/[slug]/page.tsx @@ -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(null) const [shows, setShows] = useState([]) @@ -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 ? (
{shows.map((show) => ( - +
diff --git a/frontend/app/venues/page.tsx b/frontend/app/venues/page.tsx index 72b6933..1264ddb 100644 --- a/frontend/app/venues/page.tsx +++ b/frontend/app/venues/page.tsx @@ -181,7 +181,7 @@ export default function VenuesPage() { {/* Venue Grid */}
{filteredVenues.map((venue) => ( - + diff --git a/frontend/components/chase/mark-caught-button.tsx b/frontend/components/chase/mark-caught-button.tsx index 8f2eb27..e0a6b95 100644 --- a/frontend/components/chase/mark-caught-button.tsx +++ b/frontend/components/chase/mark-caught-button.tsx @@ -24,14 +24,11 @@ interface MarkCaughtButtonProps { export function MarkCaughtButton({ songId, songTitle, showId, className }: MarkCaughtButtonProps) { const { user, token } = useAuth() const [chaseSong, setChaseSong] = useState(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 ( Caught! diff --git a/frontend/components/layout/navbar.tsx b/frontend/components/layout/navbar.tsx index da4ed99..07d9d4c 100644 --- a/frontend/components/layout/navbar.tsx +++ b/frontend/components/layout/navbar.tsx @@ -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() { diff --git a/frontend/components/social/entity-rating.tsx b/frontend/components/social/entity-rating.tsx index 4780f32..6518357 100644 --- a/frontend/components/social/entity-rating.tsx +++ b/frontend/components/social/entity-rating.tsx @@ -97,7 +97,7 @@ export function EntityRating({ entityType, entityId, compact = false }: EntityRa )} {hasRated && !loading && (

- ✓ Rating saved! + Rating saved!

)}
diff --git a/frontend/components/songs/performance-list.tsx b/frontend/components/songs/performance-list.tsx index bd10568..da963c0 100644 --- a/frontend/components/songs/performance-list.tsx +++ b/frontend/components/songs/performance-list.tsx @@ -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("date_desc") const sortedPerformances = [...performances].sort((a, b) => { @@ -87,7 +85,7 @@ export function PerformanceList({ performances, songTitle }: PerformanceListProp
{new Date(perf.show_date).toLocaleDateString(undefined, { @@ -106,7 +104,7 @@ export function PerformanceList({ performances, songTitle }: PerformanceListProp
{perf.notes && (

- "{perf.notes}" + "{perf.notes}"

)}
diff --git a/frontend/components/songs/song-evolution-chart.tsx b/frontend/components/songs/song-evolution-chart.tsx index e44a946..d55a958 100644 --- a/frontend/components/songs/song-evolution-chart.tsx +++ b/frontend/components/songs/song-evolution-chart.tsx @@ -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[]
Rating: - ★ {data.rating.toFixed(1)} + Rating: {data.rating.toFixed(1)}
@@ -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 ( diff --git a/frontend/components/ui/command.tsx b/frontend/components/ui/command.tsx index 8cb4ca7..7fe3881 100644 --- a/frontend/components/ui/command.tsx +++ b/frontend/components/ui/command.tsx @@ -29,19 +29,22 @@ function Command({ ) } +interface CommandDialogProps extends React.ComponentPropsWithoutRef { + title?: string + description?: string + commandProps?: React.ComponentPropsWithoutRef + showCloseButton?: boolean +} + function CommandDialog({ title = "Command Palette", description = "Search for a command to run...", children, className, showCloseButton = true, + commandProps, ...props -}: React.ComponentProps & { - title?: string - description?: string - className?: string - showCloseButton?: boolean -}) { +}: CommandDialogProps) { return ( @@ -52,7 +55,13 @@ function CommandDialog({ className={cn("overflow-hidden p-0", className)} showCloseButton={showCloseButton} > - + {children} diff --git a/frontend/components/ui/search-dialog.tsx b/frontend/components/ui/search-dialog.tsx index 478e6fb..936b121 100644 --- a/frontend/components/ui/search-dialog.tsx +++ b/frontend/components/ui/search-dialog.tsx @@ -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() { K - - + + {loading ? ( @@ -96,27 +99,27 @@ export function SearchDialog() { Searching...
) : ( - "No results found." + query.length >= 2 ? "No results found." : "Type at least 2 characters..." )} {query.length < 2 && ( - handleSelect("/archive")}> - - Archive + handleSelect("/shows")}> + + Browse Shows handleSelect("/leaderboards")}> Leaderboards - handleSelect("/shows")}> - - All Shows + handleSelect("/songs")}> + + Song Catalog - handleSelect("/tours")}> - - Tours + handleSelect("/venues")}> + + Venues )} @@ -124,12 +127,12 @@ export function SearchDialog() { {results.songs?.length > 0 && ( {results.songs.map((song: any) => ( - handleSelect(`/songs/${song.id}`)}> + handleSelect(`/songs/${song.slug}`)}>
{song.title} {song.original_artist && ( - {song.original_artist} + Original by {song.original_artist} )}
@@ -140,12 +143,14 @@ export function SearchDialog() { {results.venues?.length > 0 && ( {results.venues.map((venue: any) => ( - handleSelect(`/venues/${venue.id}`)}> + handleSelect(`/venues/${venue.slug}`)}> - {venue.name} - - {venue.city}, {venue.state} - +
+ {venue.name} + + {venue.city}, {venue.state} + +
))}
@@ -154,8 +159,8 @@ export function SearchDialog() { {results.tours?.length > 0 && ( {results.tours.map((tour: any) => ( - handleSelect(`/tours/${tour.id}`)}> - + handleSelect(`/tours/${tour.slug}`)}> + {tour.name} ))} @@ -163,15 +168,14 @@ export function SearchDialog() { )} {results.performances?.length > 0 && ( - + {results.performances.map((perf: any) => ( - handleSelect(`/shows/${perf.show_id}`)}> + handleSelect(`/shows/${perf.show?.slug}`)}>
{perf.song?.title || "Unknown Song"} - {perf.notes} - {/* We rely on frontend resolving show date if available, or just link to show */} + {perf.show?.date} • {perf.notes}
Performance @@ -183,12 +187,14 @@ export function SearchDialog() { {results.nicknames?.length > 0 && ( {results.nicknames.map((nickname: any) => ( - handleSelect(`/shows/${nickname.performance?.show_id}`)}> + handleSelect(`/shows/${nickname.performance?.show?.slug}`)}> - {nickname.nickname} - - ({nickname.performance?.song?.title}) - +
+ {nickname.nickname} + + ({nickname.performance?.song?.title} - {nickname.performance?.show?.date}) + +
))}
diff --git a/frontend/next.config.ts b/frontend/next.config.ts index e9ffa30..ca6c939 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -1,7 +1,7 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { - /* config options here */ + devIndicators: false, }; export default nextConfig; diff --git a/frontend/package.json b/frontend/package.json index e83d93e..b202d5f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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" } -} +} \ No newline at end of file