Compare commits
3 commits
29e3e07141
...
16828b65b0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
16828b65b0 | ||
|
|
45608bfdfb | ||
|
|
8a46000b9d |
7 changed files with 333 additions and 97 deletions
|
|
@ -10,6 +10,15 @@ When deploying changes to elmeg, **ONLY rebuild the backend and frontend contain
|
||||||
|
|
||||||
## Safe deployment command
|
## Safe deployment command
|
||||||
|
|
||||||
|
### Production (`elmeg.xyz`) - tangible-aacorn
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# turbo
|
||||||
|
ssh tangible-aacorn "cd /srv/containers/elmeg-demo && git pull && docker compose up -d --build --no-deps backend frontend"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Staging (`elmeg.runfoo.run`) - nexus-vector
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# turbo
|
# turbo
|
||||||
ssh nexus-vector "cd /srv/containers/elmeg-demo && git pull && docker compose up -d --build --no-deps backend frontend"
|
ssh nexus-vector "cd /srv/containers/elmeg-demo && git pull && docker compose up -d --build --no-deps backend frontend"
|
||||||
|
|
@ -36,3 +45,29 @@ ssh nexus-vector "docker exec elmeg-demo-db-1 pg_dump -U elmeg elmeg > /srv/cont
|
||||||
```bash
|
```bash
|
||||||
ssh nexus-vector "cat /srv/containers/elmeg-demo/backup-YYYYMMDD-HHMMSS.sql | docker exec -i elmeg-demo-db-1 psql -U elmeg elmeg"
|
ssh nexus-vector "cat /srv/containers/elmeg-demo/backup-YYYYMMDD-HHMMSS.sql | docker exec -i elmeg-demo-db-1 psql -U elmeg elmeg"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Data Import (Recovery)
|
||||||
|
|
||||||
|
If the database is wiped or fresh, use the Smart Import script to populate shows and setlists. This script is memory-optimized and checks for infinite loops.
|
||||||
|
|
||||||
|
### Production (tangible-aacorn)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh tangible-aacorn "docker exec elmeg-backend-1 python import_setlists_smart.py"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Staging (nexus-vector)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh nexus-vector "docker exec elmeg-demo-backend-1 python import_setlists_smart.py"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Git Configuration (Production)
|
||||||
|
|
||||||
|
To ensure `git pull` works correctly on production:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# On nexus-vector
|
||||||
|
cd /srv/containers/elmeg-demo
|
||||||
|
git branch --set-upstream-to=origin/main main
|
||||||
|
```
|
||||||
|
|
|
||||||
|
|
@ -37,13 +37,12 @@ def main():
|
||||||
print(f"Mapped {len(song_map)} songs")
|
print(f"Mapped {len(song_map)} songs")
|
||||||
|
|
||||||
# Get existing performances
|
# Get existing performances
|
||||||
existing = set()
|
print("Loading existing performances...")
|
||||||
perfs = session.exec(
|
existing_map = {} # (show_id, song_id, position) -> Performance Object
|
||||||
select(Performance.show_id, Performance.song_id, Performance.position)
|
perfs = session.exec(select(Performance)).all()
|
||||||
).all()
|
|
||||||
for p in perfs:
|
for p in perfs:
|
||||||
existing.add((p[0], p[1], p[2]))
|
existing_map[(p.show_id, p.song_id, p.position)] = p
|
||||||
print(f"Found {len(existing)} existing performances")
|
print(f"Found {len(existing_map)} existing performances")
|
||||||
|
|
||||||
# We need API show IDs. The ElGoose API shows endpoint returns show_id.
|
# We need API show IDs. The ElGoose API shows endpoint returns show_id.
|
||||||
# Let's fetch and correlate by date
|
# Let's fetch and correlate by date
|
||||||
|
|
@ -51,26 +50,39 @@ def main():
|
||||||
api_shows = {} # date_str -> api_show_id
|
api_shows = {} # date_str -> api_show_id
|
||||||
|
|
||||||
page = 1
|
page = 1
|
||||||
|
seen_ids = set()
|
||||||
while True:
|
while True:
|
||||||
url = f"{BASE_URL}/shows.json"
|
url = f"{BASE_URL}/shows.json"
|
||||||
try:
|
try:
|
||||||
resp = requests.get(url, params={"artist": 1, "page": page}, timeout=30)
|
resp = requests.get(url, params={"page": page}, timeout=30)
|
||||||
data = resp.json().get('data', [])
|
data = resp.json().get('data', [])
|
||||||
if not data:
|
if not data:
|
||||||
break
|
break
|
||||||
|
|
||||||
|
# Loop detection
|
||||||
|
first_id = data[0].get('show_id') if data else None
|
||||||
|
if first_id in seen_ids:
|
||||||
|
print(f" Loop detected at page {page}")
|
||||||
|
break
|
||||||
|
if first_id:
|
||||||
|
seen_ids.add(first_id)
|
||||||
|
|
||||||
for s in data:
|
for s in data:
|
||||||
|
# CRITICAL: Only include Goose shows
|
||||||
|
if s.get('artist') != 'Goose':
|
||||||
|
continue
|
||||||
date_str = s['showdate']
|
date_str = s['showdate']
|
||||||
api_shows[date_str] = s['show_id']
|
api_shows[date_str] = s['show_id']
|
||||||
page += 1
|
page += 1
|
||||||
if page > 50:
|
except Exception as e:
|
||||||
break
|
print(f" Error on page {page}: {e}")
|
||||||
except:
|
|
||||||
break
|
break
|
||||||
|
|
||||||
print(f"Got {len(api_shows)} API show IDs")
|
print(f"Got {len(api_shows)} API show IDs")
|
||||||
|
|
||||||
# Now import setlists for each show
|
# Now import setlists for each show
|
||||||
total_added = 0
|
total_added = 0
|
||||||
|
total_updated = 0
|
||||||
processed = 0
|
processed = 0
|
||||||
|
|
||||||
for show in shows:
|
for show in shows:
|
||||||
|
|
@ -80,13 +92,8 @@ def main():
|
||||||
if not api_show_id:
|
if not api_show_id:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Check if we already have performances for this show
|
# REMOVED: Skipping logic. We verify everything.
|
||||||
existing_for_show = session.exec(
|
# existing_for_show = ...
|
||||||
select(Performance).where(Performance.show_id == show.id)
|
|
||||||
).first()
|
|
||||||
|
|
||||||
if existing_for_show:
|
|
||||||
continue # Skip shows that already have performances
|
|
||||||
|
|
||||||
# Fetch setlist
|
# Fetch setlist
|
||||||
setlist = fetch_show_setlist(api_show_id)
|
setlist = fetch_show_setlist(api_show_id)
|
||||||
|
|
@ -94,6 +101,8 @@ def main():
|
||||||
continue
|
continue
|
||||||
|
|
||||||
added = 0
|
added = 0
|
||||||
|
updated = 0
|
||||||
|
|
||||||
for item in setlist:
|
for item in setlist:
|
||||||
song_title = item.get('songname', '').lower()
|
song_title = item.get('songname', '').lower()
|
||||||
song_id = song_map.get(song_title)
|
song_id = song_map.get(song_title)
|
||||||
|
|
@ -104,28 +113,49 @@ def main():
|
||||||
position = item.get('position', 0)
|
position = item.get('position', 0)
|
||||||
key = (show.id, song_id, position)
|
key = (show.id, song_id, position)
|
||||||
|
|
||||||
if key in existing:
|
# Resolve set name
|
||||||
|
set_val = str(item.get('setnumber', '1'))
|
||||||
|
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"
|
||||||
|
else:
|
||||||
|
set_name = f"Set {set_val}"
|
||||||
|
|
||||||
|
if key in existing_map:
|
||||||
|
# Update Check
|
||||||
|
perf = existing_map[key]
|
||||||
|
if not perf.set_name or perf.set_name != set_name:
|
||||||
|
perf.set_name = set_name
|
||||||
|
session.add(perf)
|
||||||
|
updated += 1
|
||||||
|
total_updated += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Create New
|
||||||
perf = Performance(
|
perf = Performance(
|
||||||
show_id=show.id,
|
show_id=show.id,
|
||||||
song_id=song_id,
|
song_id=song_id,
|
||||||
position=position,
|
position=position,
|
||||||
set_name=item.get('set'),
|
set_name=set_name,
|
||||||
segue=bool(item.get('segue', 0)),
|
segue=bool(item.get('segue', 0)),
|
||||||
notes=item.get('footnote')
|
notes=item.get('footnote')
|
||||||
)
|
)
|
||||||
session.add(perf)
|
session.add(perf)
|
||||||
existing.add(key)
|
existing_map[key] = perf # Add to map to prevent dupes in same run
|
||||||
added += 1
|
added += 1
|
||||||
total_added += 1
|
total_added += 1
|
||||||
|
|
||||||
if added > 0:
|
if added > 0 or updated > 0:
|
||||||
session.commit()
|
session.commit()
|
||||||
processed += 1
|
processed += 1
|
||||||
print(f"Show {date_str}: +{added} songs ({total_added} total)")
|
print(f"Show {date_str}: +{added} new, ~{updated} updated")
|
||||||
|
|
||||||
print(f"\\n✓ Added {total_added} performances from {processed} shows")
|
print(f"\nImport Complete! Added: {total_added}, Updated: {total_updated}")
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ def main():
|
||||||
with Session(engine) as session:
|
with Session(engine) as session:
|
||||||
# 1. Build DB Map: Date string -> DB Show ID
|
# 1. Build DB Map: Date string -> DB Show ID
|
||||||
print("\n1. Building DB Map (Date -> Show ID)...")
|
print("\n1. Building DB Map (Date -> Show ID)...")
|
||||||
shows = session.exec(select(Show.id, Show.date)).all() # Only fetch needed fields
|
shows = session.exec(select(Show.id, Show.date)).all()
|
||||||
date_to_db_id = {s.date.strftime('%Y-%m-%d'): s.id for s in shows}
|
date_to_db_id = {s.date.strftime('%Y-%m-%d'): s.id for s in shows}
|
||||||
print(f" Mapped {len(date_to_db_id)} existing shows in DB")
|
print(f" Mapped {len(date_to_db_id)} existing shows in DB")
|
||||||
|
|
||||||
|
|
@ -50,40 +50,34 @@ def main():
|
||||||
gc.collect()
|
gc.collect()
|
||||||
|
|
||||||
# 2. Build API Map: ElGoose ID -> DB ID
|
# 2. Build API Map: ElGoose ID -> DB ID
|
||||||
# Process iteratively to save memory
|
|
||||||
print("\n2. Building ElGoose ID -> DB ID map (Streaming)...")
|
print("\n2. Building ElGoose ID -> DB ID map (Streaming)...")
|
||||||
elgoose_id_to_db_id = {}
|
elgoose_id_to_db_id = {}
|
||||||
matched_count = 0
|
|
||||||
|
|
||||||
page = 1
|
page = 1
|
||||||
seen_ids_in_run = set()
|
seen_show_ids = set()
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
# Fetch batch of shows
|
|
||||||
print(f" Fetching shows page {page}...", end="\r", flush=True)
|
print(f" Fetching shows page {page}...", end="\r", flush=True)
|
||||||
data = fetch_json("shows", {"page": page}) # Fetch all shows (artist filter can be flaky)
|
data = fetch_json("shows", {"page": page})
|
||||||
if not data:
|
if not data:
|
||||||
break
|
break
|
||||||
|
|
||||||
# Check for API loop (if Page X returns same content as Page 1)
|
# Loop Detection (Shows)
|
||||||
first_id_in_batch = data[0].get('show_id') if data else None
|
first_id = data[0].get('show_id') if data else None
|
||||||
if first_id_in_batch and first_id_in_batch in seen_ids_in_run:
|
if first_id and first_id in seen_show_ids:
|
||||||
print(f"\n Loop detected at page {page} (ID {first_id_in_batch} seen before). Breaking.")
|
print(f"\n Loop detected in Shows at page {page} (ID {first_id}). Breaking.")
|
||||||
break
|
break
|
||||||
|
if first_id:
|
||||||
|
seen_show_ids.add(first_id)
|
||||||
|
|
||||||
for s in data:
|
for s in data:
|
||||||
# We only need Goose shows (artist_id=3 usually, but we check date match)
|
|
||||||
s_date = s.get('showdate')
|
s_date = s.get('showdate')
|
||||||
s_id = s.get('show_id')
|
s_id = s.get('show_id')
|
||||||
|
|
||||||
if s_id:
|
|
||||||
seen_ids_in_run.add(s_id)
|
|
||||||
|
|
||||||
if s_date and s_id:
|
if s_date and s_id:
|
||||||
db_id = date_to_db_id.get(s_date)
|
db_id = date_to_db_id.get(s_date)
|
||||||
if db_id:
|
if db_id:
|
||||||
elgoose_id_to_db_id[s_id] = db_id
|
elgoose_id_to_db_id[s_id] = db_id
|
||||||
matched_count += 1
|
|
||||||
|
|
||||||
page += 1
|
page += 1
|
||||||
if page % 10 == 0:
|
if page % 10 == 0:
|
||||||
|
|
@ -93,7 +87,7 @@ def main():
|
||||||
del date_to_db_id
|
del date_to_db_id
|
||||||
gc.collect()
|
gc.collect()
|
||||||
|
|
||||||
# 3. Cache Songs
|
# 3. Caching Songs
|
||||||
print("\n3. Caching Songs...")
|
print("\n3. Caching Songs...")
|
||||||
songs = session.exec(select(Song.id, Song.title)).all()
|
songs = session.exec(select(Song.id, Song.title)).all()
|
||||||
song_map = {s.title.lower().strip(): s.id for s in songs}
|
song_map = {s.title.lower().strip(): s.id for s in songs}
|
||||||
|
|
@ -101,31 +95,34 @@ def main():
|
||||||
gc.collect()
|
gc.collect()
|
||||||
print(f" Cached {len(song_map)} songs")
|
print(f" Cached {len(song_map)} songs")
|
||||||
|
|
||||||
# 4. Process Setlists
|
# 4. Importing Setlists
|
||||||
print("\n4. Importing Setlists...")
|
print("\n4. Importing Setlists...")
|
||||||
page = 1
|
page = 1
|
||||||
total_added = 0
|
total_added = 0
|
||||||
|
seen_batch_signatures = set()
|
||||||
|
|
||||||
|
# Cache existing performance keys (show_id, song_id, position)
|
||||||
|
print(" Caching existing performance keys...")
|
||||||
|
perfs = session.exec(select(Performance.show_id, Performance.song_id, Performance.position)).all()
|
||||||
|
existing_keys = set((p.show_id, p.song_id, p.position) for p in perfs)
|
||||||
|
print(f" Cached {len(existing_keys)} existing performances")
|
||||||
|
del perfs
|
||||||
|
gc.collect()
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
data = fetch_json("setlists", {"page": page})
|
data = fetch_json("setlists", {"page": page})
|
||||||
if not data:
|
if not data:
|
||||||
break
|
break
|
||||||
|
|
||||||
# Prefetch checks for this batch to avoid N+1 SELECTs?
|
# Loop Detection (Setlists)
|
||||||
# Actually with 3600 perfs, one-by-one check is slow.
|
# Use signature of first item: (uniqueid or show_id+position)
|
||||||
# But "existing check" is needed.
|
if data:
|
||||||
# We can cache *existing performances* for the CURRENT batch's shows?
|
first = data[0]
|
||||||
# Or just cache ALL existing performance keys (show_id, song_id, position)?
|
signature = f"{first.get('uniqueid')}-{first.get('show_id')}-{first.get('position')}"
|
||||||
# Performance table might be large (40k rows?).
|
if signature in seen_batch_signatures:
|
||||||
# (show_id, song_id, position) tuples set is ~2MB RAM. Safe.
|
print(f"\n Loop detected in Setlists at page {page} (Sig {signature}). Breaking.")
|
||||||
|
break
|
||||||
if page == 1:
|
seen_batch_signatures.add(signature)
|
||||||
print(" Caching existing performance keys...")
|
|
||||||
perfs = session.exec(select(Performance.show_id, Performance.song_id, Performance.position)).all()
|
|
||||||
existing_keys = set((p.show_id, p.song_id, p.position) for p in perfs)
|
|
||||||
print(f" Cached {len(existing_keys)} performance keys")
|
|
||||||
del perfs
|
|
||||||
gc.collect()
|
|
||||||
|
|
||||||
batch_added = 0
|
batch_added = 0
|
||||||
new_objects = []
|
new_objects = []
|
||||||
|
|
@ -143,11 +140,9 @@ def main():
|
||||||
|
|
||||||
position = perf.get('position', 0)
|
position = perf.get('position', 0)
|
||||||
|
|
||||||
# Check uniqueness
|
|
||||||
if (db_show_id, song_id, position) in existing_keys:
|
if (db_show_id, song_id, position) in existing_keys:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Create
|
|
||||||
set_val = str(perf.get('setnumber', '1'))
|
set_val = str(perf.get('setnumber', '1'))
|
||||||
if set_val.isdigit():
|
if set_val.isdigit():
|
||||||
set_name = f"Set {set_val}"
|
set_name = f"Set {set_val}"
|
||||||
|
|
@ -171,7 +166,7 @@ def main():
|
||||||
slug=f"{generate_slug(song_name)}-{db_show_id}-{position}"
|
slug=f"{generate_slug(song_name)}-{db_show_id}-{position}"
|
||||||
)
|
)
|
||||||
new_objects.append(new_perf)
|
new_objects.append(new_perf)
|
||||||
existing_keys.add((db_show_id, song_id, position)) # Add to cache
|
existing_keys.add((db_show_id, song_id, position))
|
||||||
batch_added += 1
|
batch_added += 1
|
||||||
total_added += 1
|
total_added += 1
|
||||||
|
|
||||||
|
|
|
||||||
57
backend/repro_review_crash.py
Normal file
57
backend/repro_review_crash.py
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlmodel import Session, SQLModel, create_engine
|
||||||
|
from models import User, Review, Show, Rating
|
||||||
|
from schemas import ReviewCreate
|
||||||
|
from services.gamification import award_xp
|
||||||
|
from routers.reviews import create_review
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
# Mock auth
|
||||||
|
def mock_get_current_user():
|
||||||
|
return User(id=1, email="test@test.com", hashed_password="pw", is_active=True)
|
||||||
|
|
||||||
|
# Setup in-memory DB
|
||||||
|
sqlite_file_name = "test_review_debug.db"
|
||||||
|
sqlite_url = f"sqlite:///{sqlite_file_name}"
|
||||||
|
engine = create_engine(sqlite_url)
|
||||||
|
|
||||||
|
def test_repro_review_crash():
|
||||||
|
SQLModel.metadata.create_all(engine)
|
||||||
|
|
||||||
|
with Session(engine) as session:
|
||||||
|
# Create dummy user and show
|
||||||
|
user = User(email="test@test.com", hashed_password="pw")
|
||||||
|
session.add(user)
|
||||||
|
|
||||||
|
show = Show(date="2025-01-01", slug="test-show")
|
||||||
|
session.add(show)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(user)
|
||||||
|
session.refresh(show)
|
||||||
|
|
||||||
|
print(f"User ID: {user.id}, Show ID: {show.id}")
|
||||||
|
|
||||||
|
# Payload
|
||||||
|
review_payload = ReviewCreate(
|
||||||
|
show_id=show.id,
|
||||||
|
content="Test Review Content",
|
||||||
|
blurb="Test Blurb",
|
||||||
|
score=5.0
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
print("Attempting to create review...")
|
||||||
|
result = create_review(
|
||||||
|
review=review_payload,
|
||||||
|
session=session,
|
||||||
|
current_user=user
|
||||||
|
)
|
||||||
|
print("Review created successfully:", result)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"\nCRASH DETECTED: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
test_repro_review_crash()
|
||||||
|
|
@ -4,7 +4,6 @@ from sqlmodel import Session, select, col
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
from database import get_session
|
from database import get_session
|
||||||
from models import Show, Song, Venue, Tour, Group, Performance, PerformanceNickname, Comment, Review
|
from models import Show, Song, Venue, Tour, Group, Performance, PerformanceNickname, Comment, Review
|
||||||
from schemas import ShowRead, SongRead, VenueRead, TourRead, GroupRead
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/search", tags=["search"])
|
router = APIRouter(prefix="/search", tags=["search"])
|
||||||
|
|
||||||
|
|
@ -48,22 +47,52 @@ def global_search(
|
||||||
).all()
|
).all()
|
||||||
|
|
||||||
# Search Nicknames
|
# Search Nicknames
|
||||||
nicknames = session.exec(
|
nicknames_raw = session.exec(
|
||||||
select(PerformanceNickname)
|
select(PerformanceNickname)
|
||||||
.options(selectinload(PerformanceNickname.performance).selectinload(Performance.song))
|
.options(
|
||||||
|
selectinload(PerformanceNickname.performance)
|
||||||
|
.selectinload(Performance.song),
|
||||||
|
selectinload(PerformanceNickname.performance)
|
||||||
|
.selectinload(Performance.show)
|
||||||
|
)
|
||||||
.where(col(PerformanceNickname.nickname).ilike(q_str))
|
.where(col(PerformanceNickname.nickname).ilike(q_str))
|
||||||
.where(PerformanceNickname.status == "approved")
|
.where(PerformanceNickname.status == "approved")
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
).all()
|
).all()
|
||||||
|
|
||||||
|
# Serialize nicknames with nested data
|
||||||
|
nicknames = []
|
||||||
|
for n in nicknames_raw:
|
||||||
|
nicknames.append({
|
||||||
|
"id": n.id,
|
||||||
|
"nickname": n.nickname,
|
||||||
|
"description": n.description,
|
||||||
|
"performance": {
|
||||||
|
"id": n.performance.id if n.performance else None,
|
||||||
|
"song": {"id": n.performance.song.id, "title": n.performance.song.title} if n.performance and n.performance.song else None,
|
||||||
|
"show": {"slug": n.performance.show.slug, "date": str(n.performance.show.date.date()) if n.performance.show else None} if n.performance and n.performance.show else None
|
||||||
|
} if n.performance else None
|
||||||
|
})
|
||||||
|
|
||||||
# Search Performances by notes
|
# Search Performances by notes
|
||||||
performances = session.exec(
|
performances_raw = session.exec(
|
||||||
select(Performance)
|
select(Performance)
|
||||||
.options(selectinload(Performance.song), selectinload(Performance.show))
|
.options(selectinload(Performance.song), selectinload(Performance.show))
|
||||||
.where(col(Performance.notes).ilike(q_str))
|
.where(col(Performance.notes).ilike(q_str))
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
).all()
|
).all()
|
||||||
|
|
||||||
|
# Serialize performances with nested song/show data
|
||||||
|
performances = []
|
||||||
|
for p in performances_raw:
|
||||||
|
performances.append({
|
||||||
|
"id": p.id,
|
||||||
|
"slug": p.slug,
|
||||||
|
"notes": p.notes,
|
||||||
|
"song": {"id": p.song.id, "title": p.song.title, "slug": p.song.slug} if p.song else None,
|
||||||
|
"show": {"slug": p.show.slug, "date": str(p.show.date.date()) if p.show else None} if p.show else None
|
||||||
|
})
|
||||||
|
|
||||||
# Search Reviews
|
# Search Reviews
|
||||||
reviews = session.exec(
|
reviews = session.exec(
|
||||||
select(Review)
|
select(Review)
|
||||||
|
|
|
||||||
|
|
@ -1,43 +1,127 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Home, ArrowLeft, Search } from "lucide-react"
|
import { Home, Search, Shuffle, ArrowLeft, Music, Disc3 } from "lucide-react"
|
||||||
|
import { useState, useEffect } from "react"
|
||||||
|
|
||||||
|
const GOOSE_QUOTES = [
|
||||||
|
"Looks like this page flew the coop!",
|
||||||
|
"Honk if you're lost too!",
|
||||||
|
"This page has migrated to parts unknown.",
|
||||||
|
"The jam you seek is not in this location.",
|
||||||
|
"404: Page not found. But hey, at least the vibes are good.",
|
||||||
|
"This URL took a wrong turn at Tumble.",
|
||||||
|
"Flodown? More like FlowGONE.",
|
||||||
|
"Seems this page is still in the Dripfield.",
|
||||||
|
"The set break got a little too long...",
|
||||||
|
"Whoops! Someone put this page in the wrong set.",
|
||||||
|
]
|
||||||
|
|
||||||
|
const SONG_SUGGESTIONS = [
|
||||||
|
{ title: "Tumble", slug: "tumble" },
|
||||||
|
{ title: "Arcadia", slug: "arcadia" },
|
||||||
|
{ title: "Hungersite", slug: "hungersite" },
|
||||||
|
{ title: "Atlas Dogs", slug: "atlas-dogs" },
|
||||||
|
{ title: "Rockdale", slug: "rockdale" },
|
||||||
|
]
|
||||||
|
|
||||||
export default function NotFound() {
|
export default function NotFound() {
|
||||||
|
const [quote, setQuote] = useState("")
|
||||||
|
const [suggestion, setSuggestion] = useState(SONG_SUGGESTIONS[0])
|
||||||
|
const [isSpinning, setIsSpinning] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Random quote on mount
|
||||||
|
setQuote(GOOSE_QUOTES[Math.floor(Math.random() * GOOSE_QUOTES.length)])
|
||||||
|
setSuggestion(SONG_SUGGESTIONS[Math.floor(Math.random() * SONG_SUGGESTIONS.length)])
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const shuffleQuote = () => {
|
||||||
|
setIsSpinning(true)
|
||||||
|
setTimeout(() => {
|
||||||
|
setQuote(GOOSE_QUOTES[Math.floor(Math.random() * GOOSE_QUOTES.length)])
|
||||||
|
setSuggestion(SONG_SUGGESTIONS[Math.floor(Math.random() * SONG_SUGGESTIONS.length)])
|
||||||
|
setIsSpinning(false)
|
||||||
|
}, 300)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-[60vh] flex flex-col items-center justify-center text-center px-4">
|
<div className="flex flex-col items-center justify-center min-h-[70vh] text-center px-4">
|
||||||
<div className="space-y-6 max-w-md">
|
{/* Animated Goose/Music Icon */}
|
||||||
<div className="text-8xl font-bold text-muted-foreground/30">404</div>
|
<div className="relative mb-6 group cursor-pointer" onClick={shuffleQuote}>
|
||||||
|
<div className={`transition-transform duration-300 ${isSpinning ? 'rotate-180 scale-110' : ''}`}>
|
||||||
<div className="space-y-2">
|
<Disc3 className="h-32 w-32 text-primary/20 group-hover:text-primary/40 transition-colors" />
|
||||||
<h1 className="text-2xl font-bold">Page Not Found</h1>
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
The page you're looking for doesn't exist or may have been moved.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<span className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-6xl group-hover:scale-110 transition-transform">
|
||||||
<div className="flex flex-col sm:flex-row gap-3 justify-center pt-4">
|
🪿
|
||||||
<Button asChild variant="default">
|
</span>
|
||||||
<Link href="/">
|
|
||||||
<Home className="mr-2 h-4 w-4" />
|
|
||||||
Go Home
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
<Button asChild variant="outline">
|
|
||||||
<Link href="/shows">
|
|
||||||
<Search className="mr-2 h-4 w-4" />
|
|
||||||
Browse Shows
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-sm text-muted-foreground pt-4">
|
|
||||||
Looking for a specific show? Try the{" "}
|
|
||||||
<Link href="/shows" className="underline hover:text-foreground">
|
|
||||||
shows page
|
|
||||||
</Link>{" "}
|
|
||||||
or use the search.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 404 Header */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<span className="text-8xl font-black bg-gradient-to-r from-primary via-purple-500 to-pink-500 bg-clip-text text-transparent">
|
||||||
|
404
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Playful Quote */}
|
||||||
|
<p className="text-xl font-medium mb-2 max-w-md min-h-[2em] transition-opacity duration-300"
|
||||||
|
style={{ opacity: isSpinning ? 0 : 1 }}>
|
||||||
|
{quote}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground text-sm mb-8">
|
||||||
|
Click the goose for a new message ☝️
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="flex flex-wrap gap-3 justify-center mb-8">
|
||||||
|
<Link href="/">
|
||||||
|
<Button size="lg" className="gap-2 font-semibold">
|
||||||
|
<Home className="h-4 w-4" />
|
||||||
|
Back to Safety
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<Link href="/shows">
|
||||||
|
<Button variant="outline" size="lg" className="gap-2">
|
||||||
|
<Search className="h-4 w-4" />
|
||||||
|
Find a Show
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<Button variant="ghost" size="lg" className="gap-2" onClick={shuffleQuote}>
|
||||||
|
<Shuffle className="h-4 w-4" />
|
||||||
|
Shuffle Quote
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Song Suggestion Card */}
|
||||||
|
<div className="bg-muted/50 rounded-xl p-6 max-w-sm border border-border/50">
|
||||||
|
<p className="text-sm text-muted-foreground mb-3">
|
||||||
|
While you're here, maybe check out:
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href={`/songs/${suggestion.slug}`}
|
||||||
|
className="flex items-center gap-3 p-3 rounded-lg bg-background hover:bg-primary/5 border border-border/50 transition-all hover:border-primary/20 group"
|
||||||
|
>
|
||||||
|
<div className="h-10 w-10 rounded-lg bg-primary/10 flex items-center justify-center group-hover:bg-primary/20 transition-colors">
|
||||||
|
<Music className="h-5 w-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div className="text-left">
|
||||||
|
<p className="font-semibold group-hover:text-primary transition-colors">{suggestion.title}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Random song suggestion</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Back Link */}
|
||||||
|
<button
|
||||||
|
onClick={() => window.history.back()}
|
||||||
|
className="mt-8 text-sm text-muted-foreground hover:text-primary transition-colors flex items-center gap-1 group"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-3 w-3 group-hover:-translate-x-1 transition-transform" />
|
||||||
|
Take me back where I came from
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -243,7 +243,13 @@ export default async function ShowDetailPage({ params }: { params: Promise<{ slu
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-muted-foreground text-sm">No setlist data available.</p>
|
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||||
|
<Music2 className="h-12 w-12 text-muted-foreground/30 mb-4" />
|
||||||
|
<p className="text-muted-foreground font-medium">No Setlist Documented</p>
|
||||||
|
<p className="text-sm text-muted-foreground/70 mt-1 max-w-sm">
|
||||||
|
This show's setlist hasn't been added yet. Early Goose shows (2014-2016) often weren't documented.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue