fix: commit all pending changes (home, leaderboard, slug cleanup)
Some checks are pending
Deploy Elmeg / deploy (push) Waiting to run
Some checks are pending
Deploy Elmeg / deploy (push) Waiting to run
This commit is contained in:
parent
7a549e15ac
commit
49e025d3bf
33 changed files with 457 additions and 427 deletions
89
backend/fix_numeric_slugs.py
Normal file
89
backend/fix_numeric_slugs.py
Normal 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
18
backend/inspect_api.py
Normal 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()
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
110
backend/test_smtp_connection.py
Normal file
110
backend/test_smtp_connection.py
Normal 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()
|
||||
|
|
@ -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'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'll use this to send you updates
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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%);
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 => (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
"{nick.nickname}"
|
||||
</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" />
|
||||
|
|
@ -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(() => {
|
||||
|
|
@ -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}"
|
||||
"{nick.nickname}"
|
||||
</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>
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
@ -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" />
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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!
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
"{perf.notes}"
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
devIndicators: false,
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue