Compare commits
2 commits
3aaf35d43b
...
60456c4737
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
60456c4737 | ||
|
|
2941fa482e |
25 changed files with 258 additions and 467 deletions
|
|
@ -343,12 +343,8 @@ def import_setlists(session, show_map, song_map):
|
||||||
|
|
||||||
print(f"✓ Imported {performance_count} new performances")
|
print(f"✓ Imported {performance_count} new performances")
|
||||||
|
|
||||||
def main():
|
def run_import(session: Session, with_users: bool = False):
|
||||||
print("="*60)
|
"""Run the import process programmatically"""
|
||||||
print("EL GOOSE DATA IMPORTER")
|
|
||||||
print("="*60)
|
|
||||||
|
|
||||||
with Session(engine) as session:
|
|
||||||
# 1. Get or create vertical
|
# 1. Get or create vertical
|
||||||
print("\n🦆 Creating Goose vertical...")
|
print("\n🦆 Creating Goose vertical...")
|
||||||
vertical = session.exec(
|
vertical = session.exec(
|
||||||
|
|
@ -368,6 +364,8 @@ def main():
|
||||||
else:
|
else:
|
||||||
print(f"✓ Using existing vertical (ID: {vertical.id})")
|
print(f"✓ Using existing vertical (ID: {vertical.id})")
|
||||||
|
|
||||||
|
users = []
|
||||||
|
if with_users:
|
||||||
# 2. Create users
|
# 2. Create users
|
||||||
users = create_users(session)
|
users = create_users(session)
|
||||||
|
|
||||||
|
|
@ -381,15 +379,31 @@ def main():
|
||||||
# 5. Import setlists
|
# 5. Import setlists
|
||||||
import_setlists(session, show_map, song_map)
|
import_setlists(session, show_map, song_map)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"venues": len(venue_map),
|
||||||
|
"tours": len(tour_map),
|
||||||
|
"songs": len(song_map),
|
||||||
|
"shows": len(show_map),
|
||||||
|
"users": len(users)
|
||||||
|
}
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("="*60)
|
||||||
|
print("EL GOOSE DATA IMPORTER")
|
||||||
|
print("="*60)
|
||||||
|
|
||||||
|
with Session(engine) as session:
|
||||||
|
stats = run_import(session, with_users=True)
|
||||||
|
|
||||||
print("\n" + "="*60)
|
print("\n" + "="*60)
|
||||||
print("✓ IMPORT COMPLETE!")
|
print("✓ IMPORT COMPLETE!")
|
||||||
print("="*60)
|
print("="*60)
|
||||||
print(f"\nImported:")
|
print(f"\nImported:")
|
||||||
print(f" • {len(venue_map)} venues")
|
print(f" • {stats['venues']} venues")
|
||||||
print(f" • {len(tour_map)} tours")
|
print(f" • {stats['tours']} tours")
|
||||||
print(f" • {len(song_map)} songs")
|
print(f" • {stats['songs']} songs")
|
||||||
print(f" • {len(show_map)} shows")
|
print(f" • {stats['shows']} shows")
|
||||||
print(f" • {len(users)} demo users")
|
print(f" • {stats['users']} demo users")
|
||||||
print(f"\nAll passwords: demo123")
|
print(f"\nAll passwords: demo123")
|
||||||
print(f"\nStart demo servers:")
|
print(f"\nStart demo servers:")
|
||||||
print(f" Backend: DATABASE_URL='sqlite:///./elmeg-demo.db' uvicorn main:app --reload --port 8001")
|
print(f" Backend: DATABASE_URL='sqlite:///./elmeg-demo.db' uvicorn main:app --reload --port 8001")
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,14 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||||
# Feature flags - set to False to disable features
|
# Feature flags - set to False to disable features
|
||||||
ENABLE_BUG_TRACKER = os.getenv("ENABLE_BUG_TRACKER", "true").lower() == "true"
|
ENABLE_BUG_TRACKER = os.getenv("ENABLE_BUG_TRACKER", "true").lower() == "true"
|
||||||
|
|
||||||
|
from services.scheduler import start_scheduler
|
||||||
|
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
|
|
||||||
|
@app.on_event("startup")
|
||||||
|
def on_startup():
|
||||||
|
start_scheduler()
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=["*"], # In production, set this to the frontend domain
|
allow_origins=["*"], # In production, set this to the frontend domain
|
||||||
|
|
|
||||||
|
|
@ -1,288 +0,0 @@
|
||||||
import sys
|
|
||||||
import os
|
|
||||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
||||||
|
|
||||||
from sqlmodel import Session, select
|
|
||||||
from database import engine
|
|
||||||
from models import Venue, Song, Show, Tour, Performance
|
|
||||||
from slugify import generate_slug, generate_show_slug
|
|
||||||
import requests
|
|
||||||
import time
|
|
||||||
|
|
||||||
BASE_URL = "https://elgoose.net/api/v2"
|
|
||||||
|
|
||||||
def fetch_all_json(endpoint, params=None):
|
|
||||||
all_data = []
|
|
||||||
page = 1
|
|
||||||
params = params.copy() if params else {}
|
|
||||||
print(f"Fetching {endpoint}...")
|
|
||||||
|
|
||||||
seen_ids = set()
|
|
||||||
|
|
||||||
while True:
|
|
||||||
params['page'] = page
|
|
||||||
url = f"{BASE_URL}/{endpoint}.json"
|
|
||||||
try:
|
|
||||||
resp = requests.get(url, params=params)
|
|
||||||
if resp.status_code != 200:
|
|
||||||
print(f" Failed with status {resp.status_code}")
|
|
||||||
break
|
|
||||||
|
|
||||||
# API can return a dict with 'data' or just a list sometimes, handling both
|
|
||||||
json_resp = resp.json()
|
|
||||||
if isinstance(json_resp, dict):
|
|
||||||
items = json_resp.get('data', [])
|
|
||||||
elif isinstance(json_resp, list):
|
|
||||||
items = json_resp
|
|
||||||
else:
|
|
||||||
items = []
|
|
||||||
|
|
||||||
if not items:
|
|
||||||
print(" No more items found.")
|
|
||||||
break
|
|
||||||
|
|
||||||
# Check for cycles / infinite loop by checking if we've seen these IDs before
|
|
||||||
# Assuming items have 'id' or 'show_id' etc.
|
|
||||||
# If not, we hash the string representation.
|
|
||||||
new_items_count = 0
|
|
||||||
for item in items:
|
|
||||||
# Try to find a unique identifier
|
|
||||||
uid = item.get('id') or item.get('show_id') or str(item)
|
|
||||||
if uid not in seen_ids:
|
|
||||||
seen_ids.add(uid)
|
|
||||||
all_data.append(item)
|
|
||||||
new_items_count += 1
|
|
||||||
|
|
||||||
if new_items_count == 0:
|
|
||||||
print(f" Page {page} returned {len(items)} items but all were duplicates. Stopping.")
|
|
||||||
break
|
|
||||||
|
|
||||||
print(f" Page {page} done ({new_items_count} new items)")
|
|
||||||
page += 1
|
|
||||||
time.sleep(0.5)
|
|
||||||
|
|
||||||
# Safety break
|
|
||||||
if page > 1000:
|
|
||||||
print(" Hit 1000 pages safety limit.")
|
|
||||||
break
|
|
||||||
if page > 200: # Safety break
|
|
||||||
print(" Safety limit reached.")
|
|
||||||
break
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Error fetching {endpoint}: {e}")
|
|
||||||
break
|
|
||||||
|
|
||||||
return all_data
|
|
||||||
|
|
||||||
def fix_data():
|
|
||||||
with Session(engine) as session:
|
|
||||||
# 1. Fix Venues Slugs
|
|
||||||
print("Fixing Venue Slugs...")
|
|
||||||
venues = session.exec(select(Venue)).all()
|
|
||||||
existing_venue_slugs = {v.slug for v in venues if v.slug}
|
|
||||||
for v in venues:
|
|
||||||
if not v.slug:
|
|
||||||
new_slug = generate_slug(v.name)
|
|
||||||
# Ensure unique
|
|
||||||
original_slug = new_slug
|
|
||||||
counter = 1
|
|
||||||
while new_slug in existing_venue_slugs:
|
|
||||||
counter += 1
|
|
||||||
new_slug = f"{original_slug}-{counter}"
|
|
||||||
v.slug = new_slug
|
|
||||||
existing_venue_slugs.add(new_slug)
|
|
||||||
session.add(v)
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
# 2. Fix Songs Slugs
|
|
||||||
print("Fixing Song Slugs...")
|
|
||||||
songs = session.exec(select(Song)).all()
|
|
||||||
existing_song_slugs = {s.slug for s in songs if s.slug}
|
|
||||||
for s in songs:
|
|
||||||
if not s.slug:
|
|
||||||
new_slug = generate_slug(s.title)
|
|
||||||
original_slug = new_slug
|
|
||||||
counter = 1
|
|
||||||
while new_slug in existing_song_slugs:
|
|
||||||
counter += 1
|
|
||||||
new_slug = f"{original_slug}-{counter}"
|
|
||||||
s.slug = new_slug
|
|
||||||
existing_song_slugs.add(new_slug)
|
|
||||||
session.add(s)
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
# 3. Fix Tours Slugs
|
|
||||||
print("Fixing Tour Slugs...")
|
|
||||||
tours = session.exec(select(Tour)).all()
|
|
||||||
existing_tour_slugs = {t.slug for t in tours if t.slug}
|
|
||||||
for t in tours:
|
|
||||||
if not t.slug:
|
|
||||||
new_slug = generate_slug(t.name)
|
|
||||||
original_slug = new_slug
|
|
||||||
counter = 1
|
|
||||||
while new_slug in existing_tour_slugs:
|
|
||||||
counter += 1
|
|
||||||
new_slug = f"{original_slug}-{counter}"
|
|
||||||
t.slug = new_slug
|
|
||||||
existing_tour_slugs.add(new_slug)
|
|
||||||
session.add(t)
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
# 4. Fix Shows Slugs
|
|
||||||
print("Fixing Show Slugs...")
|
|
||||||
shows = session.exec(select(Show)).all()
|
|
||||||
existing_show_slugs = {s.slug for s in shows if s.slug}
|
|
||||||
venue_map = {v.id: v for v in venues} # Cache venues for naming
|
|
||||||
|
|
||||||
for show in shows:
|
|
||||||
if not show.slug:
|
|
||||||
date_str = show.date.strftime("%Y-%m-%d") if show.date else "unknown"
|
|
||||||
venue_name = "unknown"
|
|
||||||
if show.venue_id and show.venue_id in venue_map:
|
|
||||||
venue_name = venue_map[show.venue_id].name
|
|
||||||
|
|
||||||
new_slug = generate_show_slug(date_str, venue_name)
|
|
||||||
# Ensure unique
|
|
||||||
original_slug = new_slug
|
|
||||||
counter = 1
|
|
||||||
while new_slug in existing_show_slugs:
|
|
||||||
counter += 1
|
|
||||||
new_slug = f"{original_slug}-{counter}"
|
|
||||||
|
|
||||||
show.slug = new_slug
|
|
||||||
existing_show_slugs.add(new_slug)
|
|
||||||
session.add(show)
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
# 4b. Fix Performance Slugs
|
|
||||||
print("Fixing Performance Slugs...")
|
|
||||||
from slugify import generate_performance_slug
|
|
||||||
perfs = session.exec(select(Performance)).all()
|
|
||||||
existing_perf_slugs = {p.slug for p in perfs if p.slug}
|
|
||||||
|
|
||||||
# We need song titles and show dates
|
|
||||||
# Efficient way: build maps
|
|
||||||
song_map = {s.id: s.title for s in songs}
|
|
||||||
show_map = {s.id: s.date.strftime("%Y-%m-%d") for s in shows}
|
|
||||||
|
|
||||||
for p in perfs:
|
|
||||||
if not p.slug:
|
|
||||||
song_title = song_map.get(p.song_id, "unknown")
|
|
||||||
show_date = show_map.get(p.show_id, "unknown")
|
|
||||||
|
|
||||||
new_slug = generate_performance_slug(song_title, show_date)
|
|
||||||
|
|
||||||
# Ensure unique (for reprises etc)
|
|
||||||
original_slug = new_slug
|
|
||||||
counter = 1
|
|
||||||
while new_slug in existing_perf_slugs:
|
|
||||||
counter += 1
|
|
||||||
new_slug = f"{original_slug}-{counter}"
|
|
||||||
|
|
||||||
p.slug = new_slug
|
|
||||||
existing_perf_slugs.add(new_slug)
|
|
||||||
session.add(p)
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
# 5. Fix Set Names (Fetch API)
|
|
||||||
print("Fixing Set Names (fetching setlists)...")
|
|
||||||
# We need to map El Goose show_id/song_id to our IDs to find the record.
|
|
||||||
# But we don't store El Goose IDs in our models?
|
|
||||||
# Checked models.py: we don't store ex_id.
|
|
||||||
# We match by show date/venue and song title.
|
|
||||||
|
|
||||||
# This is hard to do reliably without external IDs.
|
|
||||||
# Alternatively, we can infer set name from 'position'?
|
|
||||||
# No, position 1 could be Set 1 or Encore if short show? No.
|
|
||||||
|
|
||||||
# Wait, import_elgoose mappings are local var.
|
|
||||||
# If we re-run import logic but UPDATE instead of SKIP, we can fix it.
|
|
||||||
# But matching is tricky.
|
|
||||||
|
|
||||||
# Let's try to match by Show Date and Song Title.
|
|
||||||
# Build map: (show_id, song_id, position) -> Performance
|
|
||||||
|
|
||||||
# Refresh perfs from DB since we might have added slugs
|
|
||||||
# perfs = session.exec(select(Performance)).all() # Already have them, but maybe stale?
|
|
||||||
# Re-querying is safer but PERFS list object is updated by session.add? Yes.
|
|
||||||
|
|
||||||
perf_map = {} # (show_id, song_id, position) -> perf object
|
|
||||||
for p in perfs:
|
|
||||||
perf_map[(p.show_id, p.song_id, p.position)] = p
|
|
||||||
|
|
||||||
# We need show map: el_goose_show_id -> our_show_id
|
|
||||||
# We need song map: el_goose_song_id -> our_song_id
|
|
||||||
|
|
||||||
# We have to re-fetch shows and songs to rebuild this map.
|
|
||||||
print(" Re-building ID maps...")
|
|
||||||
|
|
||||||
# Map Shows
|
|
||||||
el_shows = fetch_all_json("shows", {"artist": 1})
|
|
||||||
if not el_shows: el_shows = fetch_all_json("shows") # fallback
|
|
||||||
|
|
||||||
el_show_map = {} # el_id -> our_id
|
|
||||||
for s in el_shows:
|
|
||||||
# Find our show
|
|
||||||
dt = s['showdate'] # YYYY-MM-DD
|
|
||||||
# We need to match precise Show.
|
|
||||||
# Simplified: match by date.
|
|
||||||
# Convert string to datetime
|
|
||||||
from datetime import datetime
|
|
||||||
s_date = datetime.strptime(dt, "%Y-%m-%d")
|
|
||||||
|
|
||||||
# Find show in our DB
|
|
||||||
# We can optimise this but for now linear search or query is fine for one-off script
|
|
||||||
found = session.exec(select(Show).where(Show.date == s_date)).first()
|
|
||||||
if found:
|
|
||||||
el_show_map[s['show_id']] = found.id
|
|
||||||
|
|
||||||
# Map Songs
|
|
||||||
el_songs = fetch_all_json("songs")
|
|
||||||
el_song_map = {} # el_id -> our_id
|
|
||||||
for s in el_songs:
|
|
||||||
found = session.exec(select(Song).where(Song.title == s['name'])).first()
|
|
||||||
if found:
|
|
||||||
el_song_map[s['id']] = found.id
|
|
||||||
|
|
||||||
# Now fetch setlists
|
|
||||||
el_setlists = fetch_all_json("setlists")
|
|
||||||
|
|
||||||
count = 0
|
|
||||||
for item in el_setlists:
|
|
||||||
our_show_id = el_show_map.get(item['show_id'])
|
|
||||||
our_song_id = el_song_map.get(item['song_id'])
|
|
||||||
position = item.get('position', 0)
|
|
||||||
|
|
||||||
if our_show_id and our_song_id:
|
|
||||||
# Find existing perf
|
|
||||||
perf = perf_map.get((our_show_id, our_song_id, position))
|
|
||||||
if perf:
|
|
||||||
# Logic to fix set_name
|
|
||||||
set_val = str(item.get('setnumber', '1'))
|
|
||||||
set_name = f"Set {set_val}"
|
|
||||||
if set_val.isdigit():
|
|
||||||
set_name = f"Set {set_val}"
|
|
||||||
elif set_val.lower() == 'e':
|
|
||||||
set_name = "Encore"
|
|
||||||
elif set_val.lower() == 'e2':
|
|
||||||
set_name = "Encore 2"
|
|
||||||
elif set_val.lower() == 's':
|
|
||||||
set_name = "Soundcheck"
|
|
||||||
|
|
||||||
if perf.set_name != set_name:
|
|
||||||
perf.set_name = set_name
|
|
||||||
session.add(perf)
|
|
||||||
count += 1
|
|
||||||
else:
|
|
||||||
# Debug only first few failures to avoid spam
|
|
||||||
if count < 5:
|
|
||||||
print(f"Match failed for el_show_id={item.get('show_id')} el_song_id={item.get('song_id')}")
|
|
||||||
if not our_show_id: print(f" -> Show ID not found in map (Map size: {len(el_show_map)})")
|
|
||||||
if not our_song_id: print(f" -> Song ID not found in map (Map size: {len(el_song_map)})")
|
|
||||||
|
|
||||||
session.commit()
|
|
||||||
print(f"Fixed {count} performance set names.")
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
fix_data()
|
|
||||||
|
|
@ -13,3 +13,5 @@ requests
|
||||||
beautifulsoup4
|
beautifulsoup4
|
||||||
boto3
|
boto3
|
||||||
email-validator
|
email-validator
|
||||||
|
apscheduler
|
||||||
|
python-slugify
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
from typing import List
|
from typing import List
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
|
from sqlalchemy import func
|
||||||
from database import get_session
|
from database import get_session
|
||||||
from models import Show, Tag, EntityTag, Vertical, UserVerticalPreference
|
from models import Show, Tag, EntityTag, Vertical, UserVerticalPreference
|
||||||
from schemas import ShowCreate, ShowRead, ShowUpdate, TagRead
|
from schemas import ShowCreate, ShowRead, ShowUpdate, TagRead, PaginatedResponse, PaginationMeta
|
||||||
from auth import get_current_user, get_current_user_optional
|
from auth import get_current_user, get_current_user_optional
|
||||||
|
|
||||||
router = APIRouter(prefix="/shows", tags=["shows"])
|
router = APIRouter(prefix="/shows", tags=["shows"])
|
||||||
|
|
@ -33,7 +34,7 @@ def create_show(
|
||||||
|
|
||||||
return db_show
|
return db_show
|
||||||
|
|
||||||
@router.get("/", response_model=List[ShowRead])
|
@router.get("/", response_model=PaginatedResponse[ShowRead])
|
||||||
def read_shows(
|
def read_shows(
|
||||||
offset: int = 0,
|
offset: int = 0,
|
||||||
limit: int = Query(default=2000, le=5000),
|
limit: int = Query(default=2000, le=5000),
|
||||||
|
|
@ -49,6 +50,8 @@ def read_shows(
|
||||||
session: Session = Depends(get_session)
|
session: Session = Depends(get_session)
|
||||||
):
|
):
|
||||||
from sqlalchemy.orm import joinedload
|
from sqlalchemy.orm import joinedload
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
query = select(Show).options(
|
query = select(Show).options(
|
||||||
joinedload(Show.vertical),
|
joinedload(Show.vertical),
|
||||||
joinedload(Show.venue),
|
joinedload(Show.venue),
|
||||||
|
|
@ -64,11 +67,12 @@ def read_shows(
|
||||||
allowed_ids = [p.vertical_id for p in prefs]
|
allowed_ids = [p.vertical_id for p in prefs]
|
||||||
# If user selected tiers but has no bands in them, return empty
|
# If user selected tiers but has no bands in them, return empty
|
||||||
if not allowed_ids:
|
if not allowed_ids:
|
||||||
return []
|
return PaginatedResponse(data=[], meta=PaginationMeta(total=0, limit=limit, offset=offset))
|
||||||
query = query.where(Show.vertical_id.in_(allowed_ids))
|
query = query.where(Show.vertical_id.in_(allowed_ids))
|
||||||
elif tiers and not current_user:
|
elif tiers and not current_user:
|
||||||
# Anonymous users can't filter by personal tiers
|
# Anonymous users can't filter by personal tiers
|
||||||
return []
|
return PaginatedResponse(data=[], meta=PaginationMeta(total=0, limit=limit, offset=offset))
|
||||||
|
|
||||||
if venue_id:
|
if venue_id:
|
||||||
query = query.where(Show.venue_id == venue_id)
|
query = query.where(Show.venue_id == venue_id)
|
||||||
if tour_id:
|
if tour_id:
|
||||||
|
|
@ -87,20 +91,28 @@ def read_shows(
|
||||||
query = query.where(Show.vertical_id == vertical_id)
|
query = query.where(Show.vertical_id == vertical_id)
|
||||||
|
|
||||||
if status:
|
if status:
|
||||||
from datetime import datetime
|
|
||||||
today = datetime.now()
|
today = datetime.now()
|
||||||
if status == "past":
|
if status == "past":
|
||||||
query = query.where(Show.date <= today)
|
query = query.where(Show.date <= today)
|
||||||
query = query.order_by(Show.date.desc())
|
|
||||||
elif status == "upcoming":
|
elif status == "upcoming":
|
||||||
query = query.where(Show.date > today)
|
query = query.where(Show.date > today)
|
||||||
|
|
||||||
|
# Calculate total count before pagination
|
||||||
|
total = session.exec(select(func.count()).select_from(query.subquery())).one()
|
||||||
|
|
||||||
|
# Apply sorting and pagination
|
||||||
|
if status == "upcoming":
|
||||||
query = query.order_by(Show.date.asc())
|
query = query.order_by(Show.date.asc())
|
||||||
else:
|
else:
|
||||||
# Default sort by date descending so we get recent shows first
|
# Default sort by date descending so we get recent shows first
|
||||||
query = query.order_by(Show.date.desc())
|
query = query.order_by(Show.date.desc())
|
||||||
|
|
||||||
shows = session.exec(query.offset(offset).limit(limit)).all()
|
shows = session.exec(query.offset(offset).limit(limit)).all()
|
||||||
return shows
|
|
||||||
|
return PaginatedResponse(
|
||||||
|
data=shows,
|
||||||
|
meta=PaginationMeta(total=total, limit=limit, offset=offset)
|
||||||
|
)
|
||||||
|
|
||||||
@router.get("/recent", response_model=List[ShowRead])
|
@router.get("/recent", response_model=List[ShowRead])
|
||||||
def read_recent_shows(
|
def read_recent_shows(
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
from typing import Optional, List, Dict
|
from typing import Optional, List, Dict, Generic, TypeVar
|
||||||
from sqlmodel import SQLModel
|
from sqlmodel import SQLModel
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
@ -452,3 +452,17 @@ class PublicProfileRead(SQLModel):
|
||||||
stats: Dict[str, int]
|
stats: Dict[str, int]
|
||||||
|
|
||||||
joined_at: datetime
|
joined_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
# --- Pagination ---
|
||||||
|
|
||||||
|
T = TypeVar('T')
|
||||||
|
|
||||||
|
class PaginationMeta(SQLModel):
|
||||||
|
total: int
|
||||||
|
limit: int
|
||||||
|
offset: int
|
||||||
|
|
||||||
|
class PaginatedResponse(SQLModel, Generic[T]):
|
||||||
|
data: List[T]
|
||||||
|
meta: PaginationMeta
|
||||||
|
|
|
||||||
23
backend/services/scheduler.py
Normal file
23
backend/services/scheduler.py
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
from apscheduler.schedulers.background import BackgroundScheduler
|
||||||
|
import import_elgoose
|
||||||
|
from sqlmodel import Session
|
||||||
|
from database import engine
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
scheduler = BackgroundScheduler()
|
||||||
|
|
||||||
|
def daily_import_job():
|
||||||
|
logger.info("Starting daily Goose data import...")
|
||||||
|
try:
|
||||||
|
with Session(engine) as session:
|
||||||
|
stats = import_elgoose.run_import(session, with_users=False)
|
||||||
|
logger.info(f"Daily import complete. Stats: {stats}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Daily import failed: {e}")
|
||||||
|
|
||||||
|
def start_scheduler():
|
||||||
|
scheduler.add_job(daily_import_job, 'interval', hours=12, id='goose_import')
|
||||||
|
scheduler.start()
|
||||||
|
logger.info("Scheduler started with daily import job.")
|
||||||
|
|
@ -2,10 +2,11 @@ import { notFound } from "next/navigation"
|
||||||
import { VERTICALS } from "@/config/verticals"
|
import { VERTICALS } from "@/config/verticals"
|
||||||
import { getApiUrl } from "@/lib/api-config"
|
import { getApiUrl } from "@/lib/api-config"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { Calendar, MapPin, Music, Trophy, Video, Ticket, Building, ChevronRight, Play } from "lucide-react"
|
import { Calendar, MapPin, Music, Trophy, Video, Ticket, Building, ChevronRight } from "lucide-react"
|
||||||
import { Card, CardContent } from "@/components/ui/card"
|
import { Card, CardContent } from "@/components/ui/card"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { VideoGallery } from "@/components/videos/video-gallery"
|
import { VideoGallery } from "@/components/videos/video-gallery"
|
||||||
|
import { Show, Song, PaginatedResponse } from "@/types/models"
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
params: Promise<{ vertical: string }>
|
params: Promise<{ vertical: string }>
|
||||||
|
|
@ -17,25 +18,26 @@ export function generateStaticParams() {
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getRecentShows(verticalSlug: string) {
|
async function getRecentShows(verticalSlug: string): Promise<Show[]> {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${getApiUrl()}/shows/?vertical_slugs=${verticalSlug}&limit=8&status=past`, {
|
const res = await fetch(`${getApiUrl()}/shows/?vertical_slugs=${verticalSlug}&limit=8&status=past`, {
|
||||||
next: { revalidate: 60 }
|
next: { revalidate: 60 }
|
||||||
})
|
})
|
||||||
if (!res.ok) return []
|
if (!res.ok) return []
|
||||||
return res.json()
|
const data: PaginatedResponse<Show> = await res.json()
|
||||||
|
return data.data || []
|
||||||
} catch {
|
} catch {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getTopSongs(verticalSlug: string) {
|
async function getTopSongs(verticalSlug: string): Promise<Song[]> {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${getApiUrl()}/songs/?vertical=${verticalSlug}&limit=5&sort=times_played`, {
|
const res = await fetch(`${getApiUrl()}/songs/?vertical=${verticalSlug}&limit=5&sort=times_played`, {
|
||||||
next: { revalidate: 60 }
|
next: { revalidate: 60 }
|
||||||
})
|
})
|
||||||
if (!res.ok) return []
|
if (!res.ok) return []
|
||||||
const data = await res.json()
|
const data: PaginatedResponse<Song> = await res.json()
|
||||||
return data.data || []
|
return data.data || []
|
||||||
} catch {
|
} catch {
|
||||||
return []
|
return []
|
||||||
|
|
@ -104,7 +106,7 @@ export default async function VerticalPage({ params }: Props) {
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
{recentShows.slice(0, 8).map((show: any) => (
|
{recentShows.slice(0, 8).map((show) => (
|
||||||
<Link
|
<Link
|
||||||
key={show.id}
|
key={show.id}
|
||||||
href={`/${verticalSlug}/shows/${show.slug}`}
|
href={`/${verticalSlug}/shows/${show.slug}`}
|
||||||
|
|
@ -127,11 +129,12 @@ export default async function VerticalPage({ params }: Props) {
|
||||||
<MapPin className="h-3 w-3" />
|
<MapPin className="h-3 w-3" />
|
||||||
{show.venue?.city}, {show.venue?.state || show.venue?.country}
|
{show.venue?.city}, {show.venue?.state || show.venue?.country}
|
||||||
</div>
|
</div>
|
||||||
{show.tour?.name && (
|
{/* Tour is not in strict Show model yet, omitting for strictness or need to add to model */}
|
||||||
|
{/* {show.tour?.name && (
|
||||||
<div className="text-xs text-muted-foreground/80 pt-1">
|
<div className="text-xs text-muted-foreground/80 pt-1">
|
||||||
{show.tour.name}
|
{show.tour.name}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)} */}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
@ -150,7 +153,7 @@ export default async function VerticalPage({ params }: Props) {
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{topSongs.slice(0, 5).map((song: any, index: number) => (
|
{topSongs.slice(0, 5).map((song, index) => (
|
||||||
<Link
|
<Link
|
||||||
key={song.id}
|
key={song.id}
|
||||||
href={`/${verticalSlug}/songs/${song.slug}`}
|
href={`/${verticalSlug}/songs/${song.slug}`}
|
||||||
|
|
@ -166,7 +169,7 @@ export default async function VerticalPage({ params }: Props) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-muted-foreground whitespace-nowrap">
|
<div className="text-sm text-muted-foreground whitespace-nowrap">
|
||||||
{song.times_played || song.performance_count || 0} performances
|
{song.times_played || 0} performances
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,7 @@ export default function AdminShowsPage() {
|
||||||
})
|
})
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
setShows(data.shows || data)
|
setShows(data.data || [])
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to fetch shows", e)
|
console.error("Failed to fetch shows", e)
|
||||||
|
|
|
||||||
|
|
@ -9,32 +9,7 @@ import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"
|
||||||
import { DateGroupedList } from "@/components/shows/date-grouped-list"
|
import { DateGroupedList } from "@/components/shows/date-grouped-list"
|
||||||
import { FilterPills } from "@/components/shows/filter-pills"
|
import { FilterPills } from "@/components/shows/filter-pills"
|
||||||
import { BandGrid } from "@/components/shows/band-grid"
|
import { BandGrid } from "@/components/shows/band-grid"
|
||||||
|
import { Show, Vertical, PaginatedResponse, Venue } from "@/types/models"
|
||||||
interface Show {
|
|
||||||
id: number
|
|
||||||
slug?: string
|
|
||||||
date: string
|
|
||||||
youtube_link?: string
|
|
||||||
vertical?: {
|
|
||||||
name: string
|
|
||||||
slug: string
|
|
||||||
}
|
|
||||||
venue: {
|
|
||||||
id: number
|
|
||||||
name: string
|
|
||||||
city: string
|
|
||||||
state: string
|
|
||||||
}
|
|
||||||
performances?: any[] // Simplified
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Vertical {
|
|
||||||
id: number
|
|
||||||
slug: string
|
|
||||||
name: string
|
|
||||||
show_count: number
|
|
||||||
logo_url?: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
function ShowsContent() {
|
function ShowsContent() {
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
|
|
@ -111,15 +86,13 @@ function ShowsContent() {
|
||||||
.then(res => {
|
.then(res => {
|
||||||
// If 401 (Unauthorized) for My Feed, we might get empty list or error
|
// If 401 (Unauthorized) for My Feed, we might get empty list or error
|
||||||
if (res.status === 401 && activeView === "my-feed") {
|
if (res.status === 401 && activeView === "my-feed") {
|
||||||
// Redirect to login or handle?
|
return { data: [] as Show[], meta: { total: 0, limit: 0, offset: 0 } }
|
||||||
// For now, shows API returns [] if anon try to filter by tiers.
|
|
||||||
return []
|
|
||||||
}
|
}
|
||||||
if (!res.ok) throw new Error("Failed to fetch shows")
|
if (!res.ok) throw new Error("Failed to fetch shows")
|
||||||
return res.json()
|
return res.json()
|
||||||
})
|
})
|
||||||
.then(data => {
|
.then((data: PaginatedResponse<Show>) => {
|
||||||
setShows(data)
|
setShows(data.data || [])
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
})
|
})
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
|
|
|
||||||
|
|
@ -5,13 +5,7 @@ import { getApiUrl } from "@/lib/api-config"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { Music } from "lucide-react"
|
import { Music } from "lucide-react"
|
||||||
|
import { Song, PaginatedResponse } from "@/types/models"
|
||||||
interface Song {
|
|
||||||
id: number
|
|
||||||
title: string
|
|
||||||
slug?: string
|
|
||||||
original_artist?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function SongsPage() {
|
export default function SongsPage() {
|
||||||
const [songs, setSongs] = useState<Song[]>([])
|
const [songs, setSongs] = useState<Song[]>([])
|
||||||
|
|
@ -20,11 +14,11 @@ export default function SongsPage() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch(`${getApiUrl()}/songs/?limit=1000`)
|
fetch(`${getApiUrl()}/songs/?limit=1000`)
|
||||||
.then(res => res.json())
|
.then(res => res.json())
|
||||||
.then(data => {
|
.then((data: PaginatedResponse<Song>) => {
|
||||||
// Handle envelope
|
// Handle envelope
|
||||||
const songData = data.data || []
|
const songData = data.data || []
|
||||||
// Sort alphabetically
|
// Sort alphabetically
|
||||||
const sorted = songData.sort((a: Song, b: Song) => a.title.localeCompare(b.title))
|
const sorted = songData.sort((a, b) => a.title.localeCompare(b.title))
|
||||||
setSongs(sorted)
|
setSongs(sorted)
|
||||||
})
|
})
|
||||||
.catch(console.error)
|
.catch(console.error)
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,8 @@ export default function VenueDetailPage() {
|
||||||
// Fetch shows at this venue using numeric ID
|
// Fetch shows at this venue using numeric ID
|
||||||
const showsRes = await fetch(`${getApiUrl()}/shows/?venue_id=${venueData.id}&limit=100`)
|
const showsRes = await fetch(`${getApiUrl()}/shows/?venue_id=${venueData.id}&limit=100`)
|
||||||
if (showsRes.ok) {
|
if (showsRes.ok) {
|
||||||
const showsData = await showsRes.json()
|
const showsEnvelope = await showsRes.json()
|
||||||
|
const showsData = showsEnvelope.data || []
|
||||||
// Sort by date descending
|
// Sort by date descending
|
||||||
showsData.sort((a: Show, b: Show) =>
|
showsData.sort((a: Show, b: Show) =>
|
||||||
new Date(b.date).getTime() - new Date(a.date).getTime()
|
new Date(b.date).getTime() - new Date(a.date).getTime()
|
||||||
|
|
|
||||||
|
|
@ -180,7 +180,7 @@ export default function WelcomePage() {
|
||||||
<Switch
|
<Switch
|
||||||
id="wiki-mode"
|
id="wiki-mode"
|
||||||
checked={wikiMode}
|
checked={wikiMode}
|
||||||
onChange={(e) => setWikiMode(e.target.checked)}
|
onCheckedChange={(checked) => setWikiMode(checked)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -75,11 +75,11 @@ const DEFAULT_LINKS = {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Footer() {
|
export function Footer() {
|
||||||
const { currentVertical } = useVertical()
|
const { current } = useVertical()
|
||||||
|
|
||||||
// Get links for current vertical or fallback
|
// Get links for current vertical or fallback
|
||||||
const links = (currentVertical && VERTICAL_LINKS[currentVertical.slug])
|
const links = (current && VERTICAL_LINKS[current.slug])
|
||||||
? VERTICAL_LINKS[currentVertical.slug]
|
? VERTICAL_LINKS[current.slug]
|
||||||
: DEFAULT_LINKS
|
: DEFAULT_LINKS
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -142,7 +142,7 @@ export function Footer() {
|
||||||
{/* Brand & Copyright */}
|
{/* Brand & Copyright */}
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<span className="text-lg font-bold">
|
<span className="text-lg font-bold">
|
||||||
{currentVertical ? currentVertical.name : "Fediversion"}
|
{current ? current.name : "Fediversion"}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
© {new Date().getFullYear()} All rights reserved
|
© {new Date().getFullYear()} All rights reserved
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { PublicProfileRead } from "@/lib/types" // We'll need to define this or import generic
|
// import { PublicProfileRead } from "@/lib/types" // We'll need to define this or import generic
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,10 @@ import Link from "next/link"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { getApiUrl } from "@/lib/api-config"
|
import { getApiUrl } from "@/lib/api-config"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
import { Playlist } from "@/types/models"
|
||||||
|
|
||||||
export function UserPlaylistsList({ userId, isOwner = false }: { userId: number, isOwner?: boolean }) {
|
export function UserPlaylistsList({ userId, isOwner = false }: { userId: number, isOwner?: boolean }) {
|
||||||
const [playlists, setPlaylists] = useState<any[]>([])
|
const [playlists, setPlaylists] = useState<Playlist[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
|
||||||
|
|
@ -3,14 +3,7 @@
|
||||||
import { Card, CardContent } from "@/components/ui/card"
|
import { Card, CardContent } from "@/components/ui/card"
|
||||||
import { Music2, Check } from "lucide-react"
|
import { Music2, Check } from "lucide-react"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Vertical } from "@/types/models"
|
||||||
interface Vertical {
|
|
||||||
id: number
|
|
||||||
slug: string
|
|
||||||
name: string
|
|
||||||
show_count: number
|
|
||||||
logo_url?: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
interface BandGridProps {
|
interface BandGridProps {
|
||||||
verticals: Vertical[]
|
verticals: Vertical[]
|
||||||
|
|
@ -54,7 +47,7 @@ export function BandGrid({ verticals, selectedBands, onToggle }: BandGridProps)
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<h3 className="font-semibold leading-tight">{v.name}</h3>
|
<h3 className="font-semibold leading-tight">{v.name}</h3>
|
||||||
<p className="text-xs text-muted-foreground font-medium">
|
<p className="text-xs text-muted-foreground font-medium">
|
||||||
{v.show_count.toLocaleString()} shows
|
{(v.show_count || 0).toLocaleString()} shows
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
|
||||||
|
|
@ -4,24 +4,7 @@
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { Calendar, MapPin, Youtube, Music2 } from "lucide-react"
|
import { Calendar, MapPin, Youtube, Music2 } from "lucide-react"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Show } from "@/types/models"
|
||||||
|
|
||||||
interface Show {
|
|
||||||
id: number
|
|
||||||
slug?: string
|
|
||||||
date: string
|
|
||||||
youtube_link?: string
|
|
||||||
vertical?: {
|
|
||||||
name: string
|
|
||||||
slug: string
|
|
||||||
}
|
|
||||||
venue: {
|
|
||||||
id: number
|
|
||||||
name: string
|
|
||||||
city: string
|
|
||||||
state: string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DateGroupedListProps {
|
interface DateGroupedListProps {
|
||||||
shows: Show[]
|
shows: Show[]
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ export interface Performance {
|
||||||
|
|
||||||
interface PerformanceListProps {
|
interface PerformanceListProps {
|
||||||
performances: Performance[]
|
performances: Performance[]
|
||||||
|
songTitle?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
type SortOption = "date_desc" | "date_asc" | "rating_desc"
|
type SortOption = "date_desc" | "date_asc" | "rating_desc"
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ const buttonVariants = cva(
|
||||||
default: "h-10 px-4 py-2",
|
default: "h-10 px-4 py-2",
|
||||||
sm: "h-9 rounded-md px-3",
|
sm: "h-9 rounded-md px-3",
|
||||||
lg: "h-11 rounded-md px-8",
|
lg: "h-11 rounded-md px-8",
|
||||||
|
xl: "h-14 px-8 text-lg rounded-full",
|
||||||
icon: "h-10 w-10",
|
icon: "h-10 w-10",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ interface CommandDialogProps extends React.ComponentPropsWithoutRef<typeof Dialo
|
||||||
description?: string
|
description?: string
|
||||||
commandProps?: React.ComponentPropsWithoutRef<typeof Command>
|
commandProps?: React.ComponentPropsWithoutRef<typeof Command>
|
||||||
showCloseButton?: boolean
|
showCloseButton?: boolean
|
||||||
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function CommandDialog({
|
function CommandDialog({
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState, useRef, useEffect } from "react"
|
import { useState, useRef, useEffect } from "react"
|
||||||
|
import { StarRating } from "@/components/ui/star-rating"
|
||||||
import { Star } from "lucide-react"
|
import { Star } from "lucide-react"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
|
@ -62,40 +63,21 @@ export function RatingInput({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Visual star representation (readonly display)
|
// Use StarRating for visual display
|
||||||
const renderStars = () => {
|
const renderStars = () => (
|
||||||
const stars = []
|
<StarRating
|
||||||
const fullStars = Math.floor(localValue)
|
value={localValue}
|
||||||
const partialFill = (localValue - fullStars) * 100
|
size={size}
|
||||||
|
readonly={readonly}
|
||||||
for (let i = 0; i < 10; i++) {
|
onChange={(v) => {
|
||||||
const isFull = i < fullStars
|
if (!readonly) {
|
||||||
const isPartial = i === fullStars && partialFill > 0
|
setLocalValue(v)
|
||||||
|
onChange?.(v)
|
||||||
stars.push(
|
}
|
||||||
<div key={i} className="relative">
|
|
||||||
<Star className={cn(
|
|
||||||
size === "sm" ? "h-3 w-3" : size === "lg" ? "h-5 w-5" : "h-4 w-4",
|
|
||||||
"fill-muted text-muted-foreground/50"
|
|
||||||
)} />
|
|
||||||
{(isFull || isPartial) && (
|
|
||||||
<div
|
|
||||||
className="absolute inset-0 overflow-hidden"
|
|
||||||
style={{
|
|
||||||
clipPath: `inset(0 ${isFull ? 0 : 100 - partialFill}% 0 0)`
|
|
||||||
}}
|
}}
|
||||||
>
|
precision="decimal"
|
||||||
<Star className={cn(
|
/>
|
||||||
size === "sm" ? "h-3 w-3" : size === "lg" ? "h-5 w-5" : "h-4 w-4",
|
|
||||||
"fill-yellow-500 text-yellow-500"
|
|
||||||
)} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
|
||||||
return stars
|
|
||||||
}
|
|
||||||
|
|
||||||
if (readonly) {
|
if (readonly) {
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
"use client"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||||
|
|
||||||
interface UserAvatarProps {
|
interface UserAvatarProps {
|
||||||
bgColor?: string
|
bgColor?: string
|
||||||
|
|
@ -8,6 +7,7 @@ interface UserAvatarProps {
|
||||||
username?: string
|
username?: string
|
||||||
size?: "sm" | "md" | "lg" | "xl"
|
size?: "sm" | "md" | "lg" | "xl"
|
||||||
className?: string
|
className?: string
|
||||||
|
src?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const sizeClasses = {
|
const sizeClasses = {
|
||||||
|
|
@ -22,21 +22,21 @@ export function UserAvatar({
|
||||||
text,
|
text,
|
||||||
username = "",
|
username = "",
|
||||||
size = "md",
|
size = "md",
|
||||||
className
|
className,
|
||||||
|
src
|
||||||
}: UserAvatarProps) {
|
}: UserAvatarProps) {
|
||||||
// If no custom text, use first letter of username
|
// If no custom text, use first letter of username
|
||||||
const displayText = text || username.charAt(0).toUpperCase() || "?"
|
const displayText = text || username.charAt(0).toUpperCase() || "?"
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Avatar className={cn(sizeClasses[size], className)}>
|
||||||
className={cn(
|
<AvatarImage src={src || undefined} alt={username} />
|
||||||
"rounded-full flex items-center justify-center font-bold text-white shadow-md select-none",
|
<AvatarFallback
|
||||||
sizeClasses[size],
|
className="font-bold text-white"
|
||||||
className
|
|
||||||
)}
|
|
||||||
style={{ backgroundColor: bgColor }}
|
style={{ backgroundColor: bgColor }}
|
||||||
>
|
>
|
||||||
{displayText}
|
{displayText}
|
||||||
</div>
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,8 @@ interface AuthContextType {
|
||||||
loading: boolean
|
loading: boolean
|
||||||
login: (token: string) => Promise<void>
|
login: (token: string) => Promise<void>
|
||||||
logout: () => void
|
logout: () => void
|
||||||
|
refreshUser: () => Promise<void>
|
||||||
|
isAuthenticated: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthContext = createContext<AuthContextType>({
|
const AuthContext = createContext<AuthContextType>({
|
||||||
|
|
@ -25,6 +27,8 @@ const AuthContext = createContext<AuthContextType>({
|
||||||
loading: true,
|
loading: true,
|
||||||
login: async () => { },
|
login: async () => { },
|
||||||
logout: () => { },
|
logout: () => { },
|
||||||
|
refreshUser: async () => { },
|
||||||
|
isAuthenticated: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
|
@ -80,8 +84,16 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
setUser(null)
|
setUser(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const refreshUser = async () => {
|
||||||
|
if (token) {
|
||||||
|
await fetchUser(token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAuthenticated = !!user
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider value={{ user, token, loading, login, logout }}>
|
<AuthContext.Provider value={{ user, token, loading, login, logout, refreshUser, isAuthenticated }}>
|
||||||
{children}
|
{children}
|
||||||
</AuthContext.Provider>
|
</AuthContext.Provider>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
63
frontend/types/models.ts
Normal file
63
frontend/types/models.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
export interface PaginationMeta {
|
||||||
|
total: number
|
||||||
|
limit: number
|
||||||
|
offset: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginatedResponse<T> {
|
||||||
|
data: T[]
|
||||||
|
meta: PaginationMeta
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Vertical {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
slug: string
|
||||||
|
show_count?: number
|
||||||
|
logo_url?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Venue {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
slug: string
|
||||||
|
city: string
|
||||||
|
state: string | null
|
||||||
|
country: string
|
||||||
|
capacity?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Song {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
slug: string
|
||||||
|
original_artist?: string | null
|
||||||
|
times_played?: number
|
||||||
|
last_played?: string | null
|
||||||
|
gap?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Performance {
|
||||||
|
id: number
|
||||||
|
song: Song
|
||||||
|
set_name?: string | null
|
||||||
|
position?: number
|
||||||
|
seg_audience?: boolean
|
||||||
|
transition?: boolean
|
||||||
|
notes?: string | null
|
||||||
|
youtube_link?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Show {
|
||||||
|
id: number
|
||||||
|
date: string
|
||||||
|
slug: string
|
||||||
|
venue_id: number
|
||||||
|
vertical_id: number
|
||||||
|
venue?: Venue
|
||||||
|
vertical?: Vertical
|
||||||
|
performances?: Performance[]
|
||||||
|
notes?: string | null
|
||||||
|
likes_count?: number
|
||||||
|
youtube_link?: string | null
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue