fix: ShowsPage pagination, strict mode, and component standardization
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s
This commit is contained in:
parent
dd5d513534
commit
de2dd0a69d
10 changed files with 489 additions and 41 deletions
111
.agent/workflows/dev-environment.md
Normal file
111
.agent/workflows/dev-environment.md
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
---
|
||||||
|
description: fediversion development environment and workflow requirements
|
||||||
|
---
|
||||||
|
|
||||||
|
# Fediversion Development Workflow
|
||||||
|
|
||||||
|
## CRITICAL: Development Environment
|
||||||
|
|
||||||
|
**All development and testing happens on nexus-vector.** Do NOT use local SQLite for dev/testing.
|
||||||
|
|
||||||
|
### Why nexus-vector?
|
||||||
|
|
||||||
|
- Production-like PostgreSQL database
|
||||||
|
- All imported band data lives there (10,605+ shows, 139k+ performances)
|
||||||
|
- Proper Docker environment matching production
|
||||||
|
- Local SQLite is stale/empty and should NOT be used
|
||||||
|
|
||||||
|
### Development Servers
|
||||||
|
|
||||||
|
| Environment | Server | Path | URL |
|
||||||
|
|-------------|--------|------|-----|
|
||||||
|
| **Staging** | nexus-vector | `/srv/containers/fediversion` | fediversion.runfoo.run |
|
||||||
|
| **Production** | tangible-aacorn | `/srv/containers/fediversion` | (domain TBD) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SSH Access
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Connect to staging dev environment
|
||||||
|
ssh nexus-vector
|
||||||
|
|
||||||
|
# Navigate to project
|
||||||
|
cd /srv/containers/fediversion
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Database
|
||||||
|
|
||||||
|
### Query Data
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# On nexus-vector
|
||||||
|
docker compose exec db psql -U fediversion -d fediversion
|
||||||
|
```
|
||||||
|
|
||||||
|
### Check Band Data
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec db psql -U fediversion -d fediversion -c "
|
||||||
|
SELECT v.name, COUNT(s.id) as shows
|
||||||
|
FROM vertical v
|
||||||
|
LEFT JOIN show s ON s.vertical_id = v.id
|
||||||
|
GROUP BY v.id
|
||||||
|
ORDER BY shows DESC;"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Running Importers
|
||||||
|
|
||||||
|
Importers run inside the backend container to access the PostgreSQL database:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# On nexus-vector
|
||||||
|
docker compose exec backend python -m importers.setlistfm deadco
|
||||||
|
docker compose exec backend python -m importers.setlistfm bmfs
|
||||||
|
docker compose exec backend python -m importers.phish
|
||||||
|
docker compose exec backend python -m importers.grateful_dead
|
||||||
|
```
|
||||||
|
|
||||||
|
### API Keys
|
||||||
|
|
||||||
|
Importers require API keys set in `.env`:
|
||||||
|
|
||||||
|
- `SETLISTFM_API_KEY` - For Dead & Co, Billy Strings, JRAD, Eggy, etc.
|
||||||
|
- `PHISHNET_API_KEY` - For Phish data
|
||||||
|
- `GRATEFULSTATS_API_KEY` - For Grateful Dead (may not be required)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cache
|
||||||
|
|
||||||
|
API responses are cached in `backend/importers/.cache/` (4,800+ files).
|
||||||
|
|
||||||
|
- Cache TTL: 1 hour
|
||||||
|
- Cache persists across runs
|
||||||
|
- Re-import uses cache first → no API calls wasted
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Imported Band Data (Dec 2025)
|
||||||
|
|
||||||
|
| Band | Shows | Performances |
|
||||||
|
|------|-------|--------------|
|
||||||
|
| DSO | 4,414 | 65,172 |
|
||||||
|
| SCI | 1,916 | 27,225 |
|
||||||
|
| Disco Biscuits | 1,860 | 19,935 |
|
||||||
|
| Phish | 4,266 | (in progress) |
|
||||||
|
| MSI | 758 | 9,501 |
|
||||||
|
| Eggy | 666 | 4,705 |
|
||||||
|
| Dogs in a Pile | 601 | 7,558 |
|
||||||
|
| JRAD | 390 | 5,452 |
|
||||||
|
|
||||||
|
### Still Need Import
|
||||||
|
|
||||||
|
- Goose (El Goose API)
|
||||||
|
- Grateful Dead (Grateful Stats API)
|
||||||
|
- Dead & Company (Setlist.fm)
|
||||||
|
- Billy Strings (Setlist.fm)
|
||||||
288
backend/migrations/99_fix_db_data.py.disabled
Normal file
288
backend/migrations/99_fix_db_data.py.disabled
Normal file
|
|
@ -0,0 +1,288 @@
|
||||||
|
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()
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
import { VERTICALS } from "@/config/verticals"
|
import { VERTICALS } from "@/config/verticals"
|
||||||
import { notFound } from "next/navigation"
|
import { notFound } from "next/navigation"
|
||||||
import { getApiUrl } from "@/lib/api-config"
|
import { getApiUrl } from "@/lib/api-config"
|
||||||
|
import { Show, PaginatedResponse } from "@/types/models"
|
||||||
|
import { Card } from "@/components/ui/card"
|
||||||
|
import { Calendar, MapPin } from "lucide-react"
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
params: Promise<{ vertical: string }>
|
params: Promise<{ vertical: string }>
|
||||||
|
|
@ -12,15 +15,15 @@ export function generateStaticParams() {
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getShows(verticalSlug: string) {
|
async function getShows(verticalSlug: string): Promise<PaginatedResponse<Show> | null> {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${getApiUrl()}/shows/?vertical=${verticalSlug}`, {
|
const res = await fetch(`${getApiUrl()}/shows/?vertical=${verticalSlug}`, {
|
||||||
next: { revalidate: 60 }
|
next: { revalidate: 60 }
|
||||||
})
|
})
|
||||||
if (!res.ok) return []
|
if (!res.ok) return null
|
||||||
return res.json()
|
return res.json()
|
||||||
} catch {
|
} catch {
|
||||||
return []
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -32,7 +35,8 @@ export default async function ShowsPage({ params }: Props) {
|
||||||
notFound()
|
notFound()
|
||||||
}
|
}
|
||||||
|
|
||||||
const shows = await getShows(vertical.slug)
|
const data = await getShows(vertical.slug)
|
||||||
|
const shows = data?.items || []
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
|
@ -46,29 +50,55 @@ export default async function ShowsPage({ params }: Props) {
|
||||||
<p className="text-sm mt-2">Run the data importer to populate shows.</p>
|
<p className="text-sm mt-2">Run the data importer to populate shows.</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="grid gap-4">
|
||||||
{shows.map((show: any) => (
|
{shows.map((show) => (
|
||||||
<a
|
<a
|
||||||
key={show.id}
|
key={show.id}
|
||||||
href={`/${vertical.slug}/shows/${show.slug}`}
|
href={`/${vertical.slug}/shows/${show.slug}`}
|
||||||
className="block p-4 rounded-lg border bg-card hover:bg-accent transition-colors"
|
className="block group"
|
||||||
>
|
>
|
||||||
|
<Card className="p-4 hover:bg-accent transition-colors">
|
||||||
<div className="flex justify-between items-start">
|
<div className="flex justify-between items-start">
|
||||||
<div>
|
<div>
|
||||||
<div className="font-semibold">{show.venue?.name || "Unknown Venue"}</div>
|
<div className="font-semibold flex items-center gap-2">
|
||||||
<div className="text-sm text-muted-foreground">
|
{show.venue?.name || "Unknown Venue"}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-muted-foreground flex items-center gap-1 mt-1">
|
||||||
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<div className="font-mono">{new Date(show.date).toLocaleDateString()}</div>
|
<div className="font-mono text-sm font-bold flex items-center gap-1 justify-end">
|
||||||
<div className="text-sm text-muted-foreground">{show.tour?.name}</div>
|
<Calendar className="h-3 w-3" />
|
||||||
|
{new Date(show.date).toLocaleDateString()}
|
||||||
|
</div>
|
||||||
|
{show.performances && show.performances.length > 0 && (
|
||||||
|
<div className="text-xs text-muted-foreground mt-1">
|
||||||
|
{show.performances.length} songs
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Card>
|
||||||
</a>
|
</a>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
</div >
|
||||||
|
</div >
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="font-mono">{new Date(show.date).toLocaleDateString()}</div>
|
||||||
|
<div className="text-sm text-muted-foreground">{show.tour?.name}</div>
|
||||||
|
</div>
|
||||||
|
</div >
|
||||||
|
</a >
|
||||||
|
))}
|
||||||
|
</div >
|
||||||
|
)}
|
||||||
|
</div >
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -339,7 +339,7 @@ export default function ModDashboardPage() {
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{pendingReports.map(report => (
|
{pendingReports.map(report => (
|
||||||
<div key={report.id} className="flex flex-col md:flex-row gap-4 justify-between border p-4 rounded-lg bg-red-50/10 border-red-100 dark:border-red-900/20">
|
<Card key={report.id} className="flex flex-col md:flex-row gap-4 justify-between p-4 bg-red-50/10 border-red-100 dark:border-red-900/20 shadow-none">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={selectedReports.includes(report.id)}
|
checked={selectedReports.includes(report.id)}
|
||||||
|
|
@ -383,7 +383,7 @@ export default function ModDashboardPage() {
|
||||||
<X className="h-4 w-4 mr-1" /> Dismiss
|
<X className="h-4 w-4 mr-1" /> Dismiss
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -412,7 +412,7 @@ export default function ModDashboardPage() {
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{pendingNicknames.map((item) => (
|
{pendingNicknames.map((item) => (
|
||||||
<div key={item.id} className="flex items-center justify-between border p-4 rounded-lg">
|
<Card key={item.id} className="flex items-center justify-between p-4 shadow-sm">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={selectedNicknames.includes(item.id)}
|
checked={selectedNicknames.includes(item.id)}
|
||||||
|
|
@ -449,7 +449,7 @@ export default function ModDashboardPage() {
|
||||||
<X className="h-4 w-4 mr-1" /> Reject
|
<X className="h-4 w-4 mr-1" /> Reject
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -480,7 +480,7 @@ export default function ModDashboardPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{lookupUser && (
|
{lookupUser && (
|
||||||
<div className="border rounded-lg p-4 space-y-4">
|
<Card className="p-4 space-y-4">
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="font-bold text-lg">{lookupUser.username || "No username"}</p>
|
<p className="font-bold text-lg">{lookupUser.username || "No username"}</p>
|
||||||
|
|
@ -538,7 +538,7 @@ export default function ModDashboardPage() {
|
||||||
<p className="text-xs text-muted-foreground">Reports</p>
|
<p className="text-xs text-muted-foreground">Reports</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Card>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,21 @@
|
||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Card } from "@/components/ui/card"
|
||||||
import { Textarea } from "@/components/ui/textarea"
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { getApiUrl } from "@/lib/api-config"
|
import { getApiUrl } from "@/lib/api-config"
|
||||||
|
|
||||||
interface GroupFeedProps {
|
interface GroupFeedProps {
|
||||||
groupId: number
|
groupId: number
|
||||||
initialPosts?: any[]
|
initialPosts?: Post[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Post {
|
||||||
|
id: number
|
||||||
|
user_id: number
|
||||||
|
content: string
|
||||||
|
created_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GroupFeed({ groupId, initialPosts = [] }: GroupFeedProps) {
|
export function GroupFeed({ groupId, initialPosts = [] }: GroupFeedProps) {
|
||||||
|
|
@ -63,8 +71,8 @@ export function GroupFeed({ groupId, initialPosts = [] }: GroupFeedProps) {
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{posts.map((post: any) => (
|
{posts.map((post: Post) => (
|
||||||
<div key={post.id} className="p-4 border rounded-lg bg-card text-card-foreground shadow-sm">
|
<Card key={post.id} className="p-4 shadow-sm">
|
||||||
<div className="flex justify-between items-start mb-2">
|
<div className="flex justify-between items-start mb-2">
|
||||||
<span className="font-semibold text-sm">User #{post.user_id}</span>
|
<span className="font-semibold text-sm">User #{post.user_id}</span>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
|
|
@ -72,7 +80,7 @@ export function GroupFeed({ groupId, initialPosts = [] }: GroupFeedProps) {
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm whitespace-pre-wrap">{post.content}</p>
|
<p className="text-sm whitespace-pre-wrap">{post.content}</p>
|
||||||
</div>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -18,12 +18,14 @@ import {
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select"
|
} from "@/components/ui/select"
|
||||||
import { ListMusic, Plus, Loader2 } from "lucide-react"
|
import { ListMusic, Loader2 } from "lucide-react"
|
||||||
import { useToast } from "@/components/ui/use-toast"
|
import { useToast } from "@/components/ui/use-toast"
|
||||||
import { getApiUrl } from "@/lib/api-config"
|
import { getApiUrl } from "@/lib/api-config"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
|
|
||||||
|
import { Playlist } from "@/types/models"
|
||||||
|
|
||||||
interface AddToPlaylistDialogProps {
|
interface AddToPlaylistDialogProps {
|
||||||
performanceId: number
|
performanceId: number
|
||||||
songTitle: string
|
songTitle: string
|
||||||
|
|
@ -31,7 +33,7 @@ interface AddToPlaylistDialogProps {
|
||||||
|
|
||||||
export function AddToPlaylistDialog({ performanceId, songTitle }: AddToPlaylistDialogProps) {
|
export function AddToPlaylistDialog({ performanceId, songTitle }: AddToPlaylistDialogProps) {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [playlists, setPlaylists] = useState<any[]>([])
|
const [playlists, setPlaylists] = useState<Playlist[]>([])
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
const [selectedPlaylistId, setSelectedPlaylistId] = useState<string>("")
|
const [selectedPlaylistId, setSelectedPlaylistId] = useState<string>("")
|
||||||
|
|
@ -48,7 +50,7 @@ export function AddToPlaylistDialog({ performanceId, songTitle }: AddToPlaylistD
|
||||||
headers: { Authorization: `Bearer ${token}` }
|
headers: { Authorization: `Bearer ${token}` }
|
||||||
})
|
})
|
||||||
.then(res => res.json())
|
.then(res => res.json())
|
||||||
.then(data => setPlaylists(data))
|
.then((data: Playlist[]) => setPlaylists(data))
|
||||||
.catch(err => console.error(err))
|
.catch(err => console.error(err))
|
||||||
.finally(() => setLoading(false))
|
.finally(() => setLoading(false))
|
||||||
}
|
}
|
||||||
|
|
@ -79,6 +81,7 @@ export function AddToPlaylistDialog({ performanceId, songTitle }: AddToPlaylistD
|
||||||
throw new Error("Failed to add")
|
throw new Error("Failed to add")
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
toast({ title: "Error", description: "Could not add to playlist", variant: "destructive" })
|
toast({ title: "Error", description: "Could not add to playlist", variant: "destructive" })
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false)
|
setSubmitting(false)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { Badge as BadgeIcon, Trophy } from "lucide-react"
|
import { Badge as BadgeIcon } from "lucide-react"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card } from "@/components/ui/card"
|
||||||
|
|
||||||
interface Badge {
|
interface Badge {
|
||||||
id: number
|
id: number
|
||||||
|
|
@ -25,7 +25,7 @@ export function BadgeList({ badges }: BadgeListProps) {
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
{badges.map((badge) => (
|
{badges.map((badge) => (
|
||||||
<div key={badge.id} className="flex flex-col items-center p-4 border rounded-lg bg-muted/20 text-center gap-2 transition-all hover:bg-muted/40">
|
<Card key={badge.id} className="flex flex-col items-center p-4 bg-muted/20 text-center gap-2 transition-all hover:bg-muted/40">
|
||||||
<div className="h-12 w-12 rounded-full bg-primary/10 flex items-center justify-center">
|
<div className="h-12 w-12 rounded-full bg-primary/10 flex items-center justify-center">
|
||||||
{/* We could dynamically map icons here based on badge.icon string */}
|
{/* We could dynamically map icons here based on badge.icon string */}
|
||||||
<BadgeIcon className="h-6 w-6 text-primary" />
|
<BadgeIcon className="h-6 w-6 text-primary" />
|
||||||
|
|
@ -36,7 +36,7 @@ export function BadgeList({ badges }: BadgeListProps) {
|
||||||
{badge.description}
|
{badge.description}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,16 @@ import { getApiUrl } from "@/lib/api-config"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { AddToPlaylistDialog } from "@/components/playlists/add-to-playlist-dialog"
|
import { AddToPlaylistDialog } from "@/components/playlists/add-to-playlist-dialog"
|
||||||
|
|
||||||
|
interface RecommendedTrack {
|
||||||
|
id: number
|
||||||
|
song_title: string
|
||||||
|
vertical_name: string
|
||||||
|
show_date: string
|
||||||
|
avg_rating: number
|
||||||
|
}
|
||||||
|
|
||||||
export function RecommendedTracks() {
|
export function RecommendedTracks() {
|
||||||
const [tracks, setTracks] = useState<any[]>([])
|
const [tracks, setTracks] = useState<RecommendedTrack[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,8 @@
|
||||||
|
|
||||||
import { useState, useEffect } from "react"
|
import { useState, useEffect } from "react"
|
||||||
import { RatingInput, RatingBadge } from "@/components/ui/rating-input"
|
import { RatingInput, RatingBadge } from "@/components/ui/rating-input"
|
||||||
|
import { Card } from "@/components/ui/card"
|
||||||
import { getApiUrl } from "@/lib/api-config"
|
import { getApiUrl } from "@/lib/api-config"
|
||||||
import { useAuth } from "@/contexts/auth-context"
|
|
||||||
import { Sparkles, TrendingUp } from "lucide-react"
|
import { Sparkles, TrendingUp } from "lucide-react"
|
||||||
|
|
||||||
interface EntityRatingProps {
|
interface EntityRatingProps {
|
||||||
|
|
@ -23,7 +23,7 @@ export function EntityRating({
|
||||||
rank,
|
rank,
|
||||||
isHeady = false
|
isHeady = false
|
||||||
}: EntityRatingProps) {
|
}: EntityRatingProps) {
|
||||||
const { user, token } = useAuth()
|
// const { user, token } = useAuth() // Unused, keeping hook for context if needed but removing vars to fix lint
|
||||||
const [userRating, setUserRating] = useState<number | null>(null)
|
const [userRating, setUserRating] = useState<number | null>(null)
|
||||||
const [averageRating, setAverageRating] = useState(0)
|
const [averageRating, setAverageRating] = useState(0)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
@ -114,7 +114,7 @@ export function EntityRating({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border rounded-lg p-4 bg-card">
|
<Card className="p-4">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm font-medium">Your Rating</span>
|
<span className="text-sm font-medium">Your Rating</span>
|
||||||
|
|
@ -157,6 +157,6 @@ export function EntityRating({
|
||||||
Your rating: {userRating.toFixed(1)}/10
|
Your rating: {userRating.toFixed(1)}/10
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ const CustomTooltip = ({ active, payload }: { active?: boolean; payload?: any[]
|
||||||
if (active && payload && payload.length) {
|
if (active && payload && payload.length) {
|
||||||
const data = payload[0].payload
|
const data = payload[0].payload
|
||||||
return (
|
return (
|
||||||
<div className="bg-background border rounded-lg shadow-xl p-3 text-sm min-w-[200px]">
|
<Card className="p-3 shadow-xl text-sm min-w-[200px]">
|
||||||
<div className="font-semibold mb-1">
|
<div className="font-semibold mb-1">
|
||||||
{format(new Date(data.date), "MMM d, yyyy")}
|
{format(new Date(data.date), "MMM d, yyyy")}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -46,7 +46,7 @@ const CustomTooltip = ({ active, payload }: { active?: boolean; payload?: any[]
|
||||||
Rating: {data.rating.toFixed(1)}
|
Rating: {data.rating.toFixed(1)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue