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")
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():
print("="*60)
print("EL GOOSE DATA IMPORTER")
print("="*60)
with Session(engine) as session:
# 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})")
# 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)
stats = run_import(session, with_users=True)
print("\n" + "="*60)
print("✓ IMPORT COMPLETE!")
print("="*60)
print(f"\nImported:")
print(f"{len(venue_map)} venues")
print(f"{len(tour_map)} tours")
print(f"{len(song_map)} songs")
print(f"{len(show_map)} shows")
print(f"{len(users)} demo users")
print(f"{stats['venues']} venues")
print(f"{stats['tours']} tours")
print(f"{stats['songs']} songs")
print(f"{stats['shows']} shows")
print(f"{stats['users']} demo users")
print(f"\nAll passwords: demo123")
print(f"\nStart demo servers:")
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
ENABLE_BUG_TRACKER = os.getenv("ENABLE_BUG_TRACKER", "true").lower() == "true"
from services.scheduler import start_scheduler
app = FastAPI()
@app.on_event("startup")
def on_startup():
start_scheduler()
app.add_middleware(
CORSMiddleware,
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
boto3
email-validator
apscheduler
python-slugify

View file

@ -1,9 +1,10 @@
from typing import List
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlmodel import Session, select
from sqlalchemy import func
from database import get_session
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
router = APIRouter(prefix="/shows", tags=["shows"])
@ -33,7 +34,7 @@ def create_show(
return db_show
@router.get("/", response_model=List[ShowRead])
@router.get("/", response_model=PaginatedResponse[ShowRead])
def read_shows(
offset: int = 0,
limit: int = Query(default=2000, le=5000),
@ -49,6 +50,8 @@ def read_shows(
session: Session = Depends(get_session)
):
from sqlalchemy.orm import joinedload
from datetime import datetime
query = select(Show).options(
joinedload(Show.vertical),
joinedload(Show.venue),
@ -64,11 +67,12 @@ def read_shows(
allowed_ids = [p.vertical_id for p in prefs]
# If user selected tiers but has no bands in them, return empty
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))
elif tiers and not current_user:
# Anonymous users can't filter by personal tiers
return []
return PaginatedResponse(data=[], meta=PaginationMeta(total=0, limit=limit, offset=offset))
if venue_id:
query = query.where(Show.venue_id == venue_id)
if tour_id:
@ -87,20 +91,28 @@ def read_shows(
query = query.where(Show.vertical_id == vertical_id)
if status:
from datetime import datetime
today = datetime.now()
if status == "past":
query = query.where(Show.date <= today)
query = query.order_by(Show.date.desc())
elif status == "upcoming":
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:
# Default sort by date descending so we get recent shows first
query = query.order_by(Show.date.desc())
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])
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 datetime import datetime
@ -452,3 +452,17 @@ class PublicProfileRead(SQLModel):
stats: Dict[str, int]
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 { getApiUrl } from "@/lib/api-config"
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 { Button } from "@/components/ui/button"
import { VideoGallery } from "@/components/videos/video-gallery"
import { Show, Song, PaginatedResponse } from "@/types/models"
interface Props {
params: Promise<{ vertical: string }>
@ -17,25 +18,26 @@ export function generateStaticParams() {
}))
}
async function getRecentShows(verticalSlug: string) {
async function getRecentShows(verticalSlug: string): Promise<Show[]> {
try {
const res = await fetch(`${getApiUrl()}/shows/?vertical_slugs=${verticalSlug}&limit=8&status=past`, {
next: { revalidate: 60 }
})
if (!res.ok) return []
return res.json()
const data: PaginatedResponse<Show> = await res.json()
return data.data || []
} catch {
return []
}
}
async function getTopSongs(verticalSlug: string) {
async function getTopSongs(verticalSlug: string): Promise<Song[]> {
try {
const res = await fetch(`${getApiUrl()}/songs/?vertical=${verticalSlug}&limit=5&sort=times_played`, {
next: { revalidate: 60 }
})
if (!res.ok) return []
const data = await res.json()
const data: PaginatedResponse<Song> = await res.json()
return data.data || []
} catch {
return []
@ -104,7 +106,7 @@ export default async function VerticalPage({ params }: Props) {
</Link>
</div>
<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
key={show.id}
href={`/${verticalSlug}/shows/${show.slug}`}
@ -127,11 +129,12 @@ export default async function VerticalPage({ params }: Props) {
<MapPin className="h-3 w-3" />
{show.venue?.city}, {show.venue?.state || show.venue?.country}
</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">
{show.tour.name}
</div>
)}
)} */}
</CardContent>
</Card>
</Link>
@ -150,7 +153,7 @@ export default async function VerticalPage({ params }: Props) {
</Link>
</div>
<div className="space-y-2">
{topSongs.slice(0, 5).map((song: any, index: number) => (
{topSongs.slice(0, 5).map((song, index) => (
<Link
key={song.id}
href={`/${verticalSlug}/songs/${song.slug}`}
@ -166,7 +169,7 @@ export default async function VerticalPage({ params }: Props) {
</div>
</div>
<div className="text-sm text-muted-foreground whitespace-nowrap">
{song.times_played || song.performance_count || 0} performances
{song.times_played || 0} performances
</div>
</div>
</Link>

View file

@ -69,7 +69,7 @@ export default function AdminShowsPage() {
})
if (res.ok) {
const data = await res.json()
setShows(data.shows || data)
setShows(data.data || [])
}
} catch (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 { FilterPills } from "@/components/shows/filter-pills"
import { BandGrid } from "@/components/shows/band-grid"
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
}
import { Show, Vertical, PaginatedResponse, Venue } from "@/types/models"
function ShowsContent() {
const searchParams = useSearchParams()
@ -111,15 +86,13 @@ function ShowsContent() {
.then(res => {
// If 401 (Unauthorized) for My Feed, we might get empty list or error
if (res.status === 401 && activeView === "my-feed") {
// Redirect to login or handle?
// For now, shows API returns [] if anon try to filter by tiers.
return []
return { data: [] as Show[], meta: { total: 0, limit: 0, offset: 0 } }
}
if (!res.ok) throw new Error("Failed to fetch shows")
return res.json()
})
.then(data => {
setShows(data)
.then((data: PaginatedResponse<Show>) => {
setShows(data.data || [])
setLoading(false)
})
.catch(err => {

View file

@ -5,13 +5,7 @@ import { getApiUrl } from "@/lib/api-config"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import Link from "next/link"
import { Music } from "lucide-react"
interface Song {
id: number
title: string
slug?: string
original_artist?: string
}
import { Song, PaginatedResponse } from "@/types/models"
export default function SongsPage() {
const [songs, setSongs] = useState<Song[]>([])
@ -20,11 +14,11 @@ export default function SongsPage() {
useEffect(() => {
fetch(`${getApiUrl()}/songs/?limit=1000`)
.then(res => res.json())
.then(data => {
.then((data: PaginatedResponse<Song>) => {
// Handle envelope
const songData = data.data || []
// 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)
})
.catch(console.error)

View file

@ -54,7 +54,8 @@ export default function VenueDetailPage() {
// Fetch shows at this venue using numeric ID
const showsRes = await fetch(`${getApiUrl()}/shows/?venue_id=${venueData.id}&limit=100`)
if (showsRes.ok) {
const showsData = await showsRes.json()
const showsEnvelope = await showsRes.json()
const showsData = showsEnvelope.data || []
// Sort by date descending
showsData.sort((a: Show, b: Show) =>
new Date(b.date).getTime() - new Date(a.date).getTime()

View file

@ -180,7 +180,7 @@ export default function WelcomePage() {
<Switch
id="wiki-mode"
checked={wikiMode}
onChange={(e) => setWikiMode(e.target.checked)}
onCheckedChange={(checked) => setWikiMode(checked)}
/>
</div>

View file

@ -75,11 +75,11 @@ const DEFAULT_LINKS = {
}
export function Footer() {
const { currentVertical } = useVertical()
const { current } = useVertical()
// Get links for current vertical or fallback
const links = (currentVertical && VERTICAL_LINKS[currentVertical.slug])
? VERTICAL_LINKS[currentVertical.slug]
const links = (current && VERTICAL_LINKS[current.slug])
? VERTICAL_LINKS[current.slug]
: DEFAULT_LINKS
return (
@ -142,7 +142,7 @@ export function Footer() {
{/* Brand & Copyright */}
<div className="flex items-center gap-4">
<span className="text-lg font-bold">
{currentVertical ? currentVertical.name : "Fediversion"}
{current ? current.name : "Fediversion"}
</span>
<span className="text-xs text-muted-foreground">
© {new Date().getFullYear()} All rights reserved

View file

@ -1,6 +1,6 @@
"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 { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"

View file

@ -7,9 +7,10 @@ import Link from "next/link"
import { Button } from "@/components/ui/button"
import { getApiUrl } from "@/lib/api-config"
import { Skeleton } from "@/components/ui/skeleton"
import { Playlist } from "@/types/models"
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)
useEffect(() => {

View file

@ -3,14 +3,7 @@
import { Card, CardContent } from "@/components/ui/card"
import { Music2, Check } from "lucide-react"
import { Badge } from "@/components/ui/badge"
interface Vertical {
id: number
slug: string
name: string
show_count: number
logo_url?: string | null
}
import { Vertical } from "@/types/models"
interface BandGridProps {
verticals: Vertical[]
@ -54,7 +47,7 @@ export function BandGrid({ verticals, selectedBands, onToggle }: BandGridProps)
<div className="space-y-1">
<h3 className="font-semibold leading-tight">{v.name}</h3>
<p className="text-xs text-muted-foreground font-medium">
{v.show_count.toLocaleString()} shows
{(v.show_count || 0).toLocaleString()} shows
</p>
</div>
</CardContent>

View file

@ -4,24 +4,7 @@
import Link from "next/link"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Calendar, MapPin, Youtube, Music2 } from "lucide-react"
import { Button } from "@/components/ui/button"
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
}
}
import { Show } from "@/types/models"
interface DateGroupedListProps {
shows: Show[]

View file

@ -28,6 +28,7 @@ export interface Performance {
interface PerformanceListProps {
performances: Performance[]
songTitle?: string
}
type SortOption = "date_desc" | "date_asc" | "rating_desc"

View file

@ -26,6 +26,7 @@ const buttonVariants = cva(
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
xl: "h-14 px-8 text-lg rounded-full",
icon: "h-10 w-10",
},
},

View file

@ -34,6 +34,7 @@ interface CommandDialogProps extends React.ComponentPropsWithoutRef<typeof Dialo
description?: string
commandProps?: React.ComponentPropsWithoutRef<typeof Command>
showCloseButton?: boolean
className?: string
}
function CommandDialog({

View file

@ -1,6 +1,7 @@
"use client"
import { useState, useRef, useEffect } from "react"
import { StarRating } from "@/components/ui/star-rating"
import { Star } from "lucide-react"
import { cn } from "@/lib/utils"
@ -62,40 +63,21 @@ export function RatingInput({
}
}
// Visual star representation (readonly display)
const renderStars = () => {
const stars = []
const fullStars = Math.floor(localValue)
const partialFill = (localValue - fullStars) * 100
for (let i = 0; i < 10; i++) {
const isFull = i < fullStars
const isPartial = i === fullStars && partialFill > 0
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)`
}}
>
<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
}
// Use StarRating for visual display
const renderStars = () => (
<StarRating
value={localValue}
size={size}
readonly={readonly}
onChange={(v) => {
if (!readonly) {
setLocalValue(v)
onChange?.(v)
}
}}
precision="decimal"
/>
)
if (readonly) {
return (

View file

@ -1,6 +1,5 @@
"use client"
import { cn } from "@/lib/utils"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
interface UserAvatarProps {
bgColor?: string
@ -8,6 +7,7 @@ interface UserAvatarProps {
username?: string
size?: "sm" | "md" | "lg" | "xl"
className?: string
src?: string | null
}
const sizeClasses = {
@ -22,21 +22,21 @@ export function UserAvatar({
text,
username = "",
size = "md",
className
className,
src
}: UserAvatarProps) {
// If no custom text, use first letter of username
const displayText = text || username.charAt(0).toUpperCase() || "?"
return (
<div
className={cn(
"rounded-full flex items-center justify-center font-bold text-white shadow-md select-none",
sizeClasses[size],
className
)}
style={{ backgroundColor: bgColor }}
>
{displayText}
</div>
<Avatar className={cn(sizeClasses[size], className)}>
<AvatarImage src={src || undefined} alt={username} />
<AvatarFallback
className="font-bold text-white"
style={{ backgroundColor: bgColor }}
>
{displayText}
</AvatarFallback>
</Avatar>
)
}

View file

@ -17,6 +17,8 @@ interface AuthContextType {
loading: boolean
login: (token: string) => Promise<void>
logout: () => void
refreshUser: () => Promise<void>
isAuthenticated: boolean
}
const AuthContext = createContext<AuthContextType>({
@ -25,6 +27,8 @@ const AuthContext = createContext<AuthContextType>({
loading: true,
login: async () => { },
logout: () => { },
refreshUser: async () => { },
isAuthenticated: false,
})
export function AuthProvider({ children }: { children: React.ReactNode }) {
@ -80,8 +84,16 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
setUser(null)
}
const refreshUser = async () => {
if (token) {
await fetchUser(token)
}
}
const isAuthenticated = !!user
return (
<AuthContext.Provider value={{ user, token, loading, login, logout }}>
<AuthContext.Provider value={{ user, token, loading, login, logout, refreshUser, isAuthenticated }}>
{children}
</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
}