Compare commits

..

2 commits

Author SHA1 Message Date
fullsizemalt
60456c4737 feat(frontend): Enforce strict mode and refactor pages
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s
2025-12-30 22:29:16 -08:00
fullsizemalt
2941fa482e feat(backend): Implement automation scheduler and pagination envelope 2025-12-30 22:29:04 -08:00
25 changed files with 258 additions and 467 deletions

View file

@ -343,53 +343,67 @@ def import_setlists(session, show_map, song_map):
print(f"✓ Imported {performance_count} new performances") print(f"✓ Imported {performance_count} new performances")
def run_import(session: Session, with_users: bool = False):
"""Run the import process programmatically"""
# 1. Get or create vertical
print("\n🦆 Creating Goose vertical...")
vertical = session.exec(
select(Vertical).where(Vertical.slug == "goose")
).first()
if not vertical:
vertical = Vertical(
name="Goose",
slug="goose",
description="Goose is a jam band from Connecticut"
)
session.add(vertical)
session.commit()
session.refresh(vertical)
print(f"✓ Created vertical (ID: {vertical.id})")
else:
print(f"✓ Using existing vertical (ID: {vertical.id})")
users = []
if with_users:
# 2. Create users
users = create_users(session)
# 3. Import base data
venue_map = import_venues(session)
song_map = import_songs(session, vertical.id)
# 4. Import shows
show_map, tour_map = import_shows(session, vertical.id, venue_map)
# 5. Import setlists
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(): def main():
print("="*60) print("="*60)
print("EL GOOSE DATA IMPORTER") print("EL GOOSE DATA IMPORTER")
print("="*60) print("="*60)
with Session(engine) as session: with Session(engine) as session:
# 1. Get or create vertical stats = run_import(session, with_users=True)
print("\n🦆 Creating Goose vertical...")
vertical = session.exec(
select(Vertical).where(Vertical.slug == "goose")
).first()
if not vertical:
vertical = Vertical(
name="Goose",
slug="goose",
description="Goose is a jam band from Connecticut"
)
session.add(vertical)
session.commit()
session.refresh(vertical)
print(f"✓ Created vertical (ID: {vertical.id})")
else:
print(f"✓ Using existing vertical (ID: {vertical.id})")
# 2. Create users
users = create_users(session)
# 3. Import base data
venue_map = import_venues(session)
song_map = import_songs(session, vertical.id)
# 4. Import shows
show_map, tour_map = import_shows(session, vertical.id, venue_map)
# 5. Import setlists
import_setlists(session, show_map, song_map)
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")

View file

@ -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

View file

@ -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()

View file

@ -13,3 +13,5 @@ requests
beautifulsoup4 beautifulsoup4
boto3 boto3
email-validator email-validator
apscheduler
python-slugify

View file

@ -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)
query = query.order_by(Show.date.asc())
# 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())
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(

View file

@ -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

View 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.")

View file

@ -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>

View file

@ -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)

View file

@ -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 => {

View file

@ -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)

View file

@ -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()

View file

@ -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>

View file

@ -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

View file

@ -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"

View file

@ -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(() => {

View file

@ -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>

View file

@ -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[]

View file

@ -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"

View file

@ -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",
}, },
}, },

View file

@ -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({

View file

@ -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( precision="decimal"
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)`
}}
>
<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 (

View file

@ -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}
> </AvatarFallback>
{displayText} </Avatar>
</div>
) )
} }

View file

@ -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
View 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
}