feat: User Personalization, Playlists, Recommendations, and DSO Importer
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s

This commit is contained in:
fullsizemalt 2025-12-29 16:28:43 -08:00
parent 413430b700
commit 7b8ba4b54c
32 changed files with 14331 additions and 219 deletions

View file

@ -181,7 +181,7 @@ def upgrade() -> None:
batch_op.add_column(sa.Column('vertical_id', sa.Integer(), nullable=True))
batch_op.add_column(sa.Column('image_url', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
batch_op.create_index(batch_op.f('ix_group_vertical_id'), ['vertical_id'], unique=False)
batch_op.create_foreign_key(None, 'vertical', ['vertical_id'], ['id'])
batch_op.create_foreign_key('fk_group_vertical_id_vertical', 'vertical', ['vertical_id'], ['id'])
with op.batch_alter_table('performance', schema=None) as batch_op:
batch_op.add_column(sa.Column('bandcamp_link', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
@ -193,8 +193,8 @@ def upgrade() -> None:
with op.batch_alter_table('song', schema=None) as batch_op:
batch_op.add_column(sa.Column('canon_id', sa.Integer(), nullable=True))
batch_op.add_column(sa.Column('artist_id', sa.Integer(), nullable=True))
batch_op.create_foreign_key(None, 'songcanon', ['canon_id'], ['id'])
batch_op.create_foreign_key(None, 'artist', ['artist_id'], ['id'])
batch_op.create_foreign_key('fk_song_canon_id_songcanon', 'songcanon', ['canon_id'], ['id'])
batch_op.create_foreign_key('fk_song_artist_id_artist', 'artist', ['artist_id'], ['id'])
with op.batch_alter_table('user', schema=None) as batch_op:
batch_op.add_column(sa.Column('avatar_bg_color', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
@ -227,7 +227,7 @@ def upgrade() -> None:
batch_op.add_column(sa.Column('nugs_url', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
batch_op.add_column(sa.Column('relisten_url', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
batch_op.add_column(sa.Column('spotify_url', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
batch_op.create_foreign_key(None, 'artist', ['primary_artist_id'], ['id'])
batch_op.create_foreign_key('fk_vertical_primary_artist_id_artist', 'artist', ['primary_artist_id'], ['id'])
# ### end Alembic commands ###

View file

@ -0,0 +1,61 @@
"""manual_notification_fix
Revision ID: b1ca95289d88
Revises: b83b61f15175
Create Date: 2025-12-29 13:14:38.291752
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import sqlmodel
# revision identifiers, used by Alembic.
revision: str = 'b1ca95289d88'
down_revision: Union[str, Sequence[str], None] = 'b83b61f15175'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# 1. Create Notification Table if not exists
# Use SQLAlchemy inspector to check table existence
conn = op.get_bind()
inspector = sa.inspect(conn)
tables = inspector.get_table_names()
if 'notification' not in tables:
op.create_table('notification',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('type', sa.Enum('SHOW_ALERT', 'SIT_IN_ALERT', 'CHASE_SONG_ALERT', name='notificationtype'), nullable=False),
sa.Column('title', sa.String(), nullable=False),
sa.Column('message', sa.String(), nullable=False),
sa.Column('link', sa.String(), nullable=True),
sa.Column('is_read', sa.Boolean(), nullable=False, server_default=sa.text('0')),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_notification_user_id'), 'notification', ['user_id'], unique=False)
op.create_foreign_key('fk_notification_user', 'notification', 'user', ['user_id'], ['id'])
# 2. Add missing columns to UserVerticalPreference if they don't exist
columns = [c['name'] for c in inspector.get_columns('userverticalpreference')]
with op.batch_alter_table('userverticalpreference', schema=None) as batch_op:
if 'tier' not in columns:
batch_op.add_column(sa.Column('tier', sa.Enum('HEADLINER', 'MAIN_STAGE', 'SUPPORTING', name='preferencetier'), server_default='MAIN_STAGE', nullable=False))
if 'notify_on_show' not in columns:
batch_op.add_column(sa.Column('notify_on_show', sa.Boolean(), server_default=sa.text('1'), nullable=False))
def downgrade() -> None:
# Downgrade logic
with op.batch_alter_table('userverticalpreference', schema=None) as batch_op:
batch_op.drop_column('notify_on_show')
batch_op.drop_column('tier')
op.drop_index(op.f('ix_notification_user_id'), table_name='notification')
op.drop_table('notification')

View file

@ -0,0 +1,32 @@
"""add notification model
Revision ID: b83b61f15175
Revises: bc26bfdca841
Create Date: 2025-12-29 13:09:49.487765
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'b83b61f15175'
down_revision: Union[str, Sequence[str], None] = 'bc26bfdca841'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View file

@ -0,0 +1,134 @@
"""add venue canon and preferences
Revision ID: bc26bfdca841
Revises: 81e183e75ff5
Create Date: 2025-12-29 12:57:55.838639
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import sqlmodel
# revision identifiers, used by Alembic.
revision: str = 'bc26bfdca841'
down_revision: Union[str, Sequence[str], None] = '81e183e75ff5'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('venuecanon',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('slug', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('city', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('state', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('country', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('latitude', sa.Float(), nullable=True),
sa.Column('longitude', sa.Float(), nullable=True),
sa.Column('capacity', sa.Integer(), nullable=True),
sa.Column('website_url', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('notes', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
with op.batch_alter_table('venuecanon', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_venuecanon_name'), ['name'], unique=False)
batch_op.create_index(batch_op.f('ix_venuecanon_slug'), ['slug'], unique=True)
op.create_table('userplaylist',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('slug', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('description', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('is_public', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id')
)
with op.batch_alter_table('userplaylist', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_userplaylist_name'), ['name'], unique=False)
batch_op.create_index(batch_op.f('ix_userplaylist_slug'), ['slug'], unique=False)
op.create_table('festival',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('slug', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('year', sa.Integer(), nullable=True),
sa.Column('start_date', sa.DateTime(), nullable=True),
sa.Column('end_date', sa.DateTime(), nullable=True),
sa.Column('venue_id', sa.Integer(), nullable=True),
sa.Column('website_url', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('description', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.ForeignKeyConstraint(['venue_id'], ['venue.id'], ),
sa.PrimaryKeyConstraint('id')
)
with op.batch_alter_table('festival', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_festival_name'), ['name'], unique=False)
batch_op.create_index(batch_op.f('ix_festival_slug'), ['slug'], unique=True)
op.create_table('showfestival',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('show_id', sa.Integer(), nullable=False),
sa.Column('festival_id', sa.Integer(), nullable=False),
sa.Column('stage', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('set_time', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.ForeignKeyConstraint(['festival_id'], ['festival.id'], ),
sa.ForeignKeyConstraint(['show_id'], ['show.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('playlistperformance',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('playlist_id', sa.Integer(), nullable=False),
sa.Column('performance_id', sa.Integer(), nullable=False),
sa.Column('position', sa.Integer(), nullable=False),
sa.Column('added_at', sa.DateTime(), nullable=False),
sa.Column('notes', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.ForeignKeyConstraint(['performance_id'], ['performance.id'], ),
sa.ForeignKeyConstraint(['playlist_id'], ['userplaylist.id'], ),
sa.PrimaryKeyConstraint('id')
)
# with op.batch_alter_table('userverticalpreference', schema=None) as batch_op:
# batch_op.add_column(sa.Column('tier', sa.Enum('HEADLINER', 'MAIN_STAGE', 'SUPPORTING', name='preferencetier'), nullable=False))
with op.batch_alter_table('venue', schema=None) as batch_op:
batch_op.add_column(sa.Column('canon_id', sa.Integer(), nullable=True))
batch_op.create_foreign_key('fk_venue_canon_id_venuecanon', 'venuecanon', ['canon_id'], ['id'])
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('venue', schema=None) as batch_op:
batch_op.drop_constraint(None, type_='foreignkey')
batch_op.drop_column('canon_id')
with op.batch_alter_table('userverticalpreference', schema=None) as batch_op:
batch_op.drop_column('tier')
op.drop_table('playlistperformance')
op.drop_table('showfestival')
with op.batch_alter_table('festival', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_festival_slug'))
batch_op.drop_index(batch_op.f('ix_festival_name'))
op.drop_table('festival')
with op.batch_alter_table('userplaylist', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_userplaylist_slug'))
batch_op.drop_index(batch_op.f('ix_userplaylist_name'))
op.drop_table('userplaylist')
with op.batch_alter_table('venuecanon', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_venuecanon_slug'))
batch_op.drop_index(batch_op.f('ix_venuecanon_name'))
op.drop_table('venuecanon')
# ### end Alembic commands ###

Binary file not shown.

12313
backend/elmeg_dump.sql Normal file

File diff suppressed because it is too large Load diff

View file

@ -14,7 +14,7 @@ from models import (
from passlib.context import CryptContext
from slugify import generate_slug, generate_show_slug
BASE_URL = "https://elgoose.net/api/v2"
BASE_URL = "http://elmeg-legacy-api:8000/api/v2"
ARTIST_ID = 1 # Goose
pwd_context = CryptContext(schemes=["argon2"], deprecated="auto")

14
backend/importers/dso.py Normal file
View file

@ -0,0 +1,14 @@
from .setlistfm import SetlistFmImporter
class DsoImporter(SetlistFmImporter):
"""Import Dark Star Orchestra data from Setlist.fm"""
VERTICAL_NAME = "Dark Star Orchestra"
VERTICAL_SLUG = "dark-star-orchestra"
VERTICAL_DESCRIPTION = "Recreating the Grateful Dead concert experience."
# Dark Star Orchestra MusicBrainz ID
ARTIST_MBID = "e477d9c0-1f35-40f7-ad1a-b915d2523b84"
def __init__(self, session):
super().__init__(session)

View file

@ -1,6 +1,6 @@
from fastapi import FastAPI
import os
from routers import auth, shows, venues, songs, social, tours, artists, preferences, reviews, badges, nicknames, moderation, attendance, groups, users, search, performances, notifications, feed, leaderboards, stats, admin, chase, gamification, videos, musicians, sequences, verticals, canon, on_this_day, discover, bands, festivals, playlists, analytics
from routers import auth, shows, venues, songs, social, tours, artists, preferences, reviews, badges, nicknames, moderation, attendance, groups, users, search, performances, notifications, feed, leaderboards, stats, admin, chase, gamification, videos, musicians, sequences, verticals, canon, on_this_day, discover, bands, festivals, playlists, analytics, recommendations
from fastapi.middleware.cors import CORSMiddleware
@ -59,6 +59,8 @@ if ENABLE_BUG_TRACKER:
from routers import tickets
app.include_router(tickets.router)
app.include_router(recommendations.router)
@app.get("/")
def read_root():
return {"Hello": "World"}

View file

@ -1,6 +1,7 @@
from typing import List, Optional
from sqlmodel import Field, Relationship, SQLModel
from datetime import datetime
from enum import Enum
# --- Join Tables ---
class Performance(SQLModel, table=True):
@ -111,6 +112,7 @@ class Vertical(SQLModel, table=True):
shows: List["Show"] = Relationship(back_populates="vertical")
songs: List["Song"] = Relationship(back_populates="vertical")
scenes: List["Scene"] = Relationship(back_populates="verticals", link_model=VerticalScene)
user_preferences: List["UserVerticalPreference"] = Relationship(back_populates="vertical")
class VenueCanon(SQLModel, table=True):
"""Canonical venue independent of band - enables cross-band venue linking (like SongCanon for songs)"""
@ -321,6 +323,28 @@ class Tag(SQLModel, table=True):
name: str = Field(unique=True, index=True)
slug: str = Field(unique=True, index=True)
class PreferenceTier(str, Enum):
HEADLINER = "headliner"
MAIN_STAGE = "main_stage"
SUPPORTING = "supporting"
class UserVerticalPreference(SQLModel, table=True):
"""User preferences for which bands to display prominently vs. attribution-only"""
id: Optional[int] = Field(default=None, primary_key=True)
user_id: int = Field(foreign_key="user.id", index=True)
vertical_id: int = Field(foreign_key="vertical.id", index=True)
# Preferences
display_mode: str = Field(default="standard") # compact, standard, expanded
priority: int = Field(default=0) # 0-100 sorting
tier: PreferenceTier = Field(default=PreferenceTier.MAIN_STAGE)
notify_on_show: bool = Field(default=True)
created_at: datetime = Field(default_factory=datetime.utcnow)
user: "User" = Relationship(back_populates="vertical_preferences")
vertical: "Vertical" = Relationship(back_populates="user_preferences")
class Attendance(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
user_id: int = Field(foreign_key="user.id")
@ -359,6 +383,23 @@ class Rating(SQLModel, table=True):
user: "User" = Relationship(back_populates="ratings")
class NotificationType(str, Enum):
SHOW_ALERT = "SHOW_ALERT"
SIT_IN_ALERT = "SIT_IN_ALERT"
CHASE_SONG_ALERT = "CHASE_SONG_ALERT"
class Notification(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
user_id: int = Field(foreign_key="user.id", index=True)
type: NotificationType
title: str
message: str
link: Optional[str] = None
is_read: bool = Field(default=False)
created_at: datetime = Field(default_factory=datetime.utcnow)
user: "User" = Relationship(back_populates="notifications")
class User(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
email: str = Field(unique=True, index=True)
@ -410,6 +451,7 @@ class User(SQLModel, table=True):
reports: List["Report"] = Relationship(back_populates="user")
notifications: List["Notification"] = Relationship(back_populates="user")
playlists: List["UserPlaylist"] = Relationship(back_populates="user")
vertical_preferences: List["UserVerticalPreference"] = Relationship(back_populates="user")
class UserPlaylist(SQLModel, table=True):
@ -503,18 +545,7 @@ class UserPreferences(SQLModel, table=True):
user: "User" = Relationship(back_populates="preferences")
class UserVerticalPreference(SQLModel, table=True):
"""User preferences for which bands to display prominently vs. attribution-only"""
id: Optional[int] = Field(default=None, primary_key=True)
user_id: int = Field(foreign_key="user.id", index=True)
vertical_id: int = Field(foreign_key="vertical.id", index=True)
display_mode: str = Field(default="primary", description="primary, secondary, attribution_only, hidden")
priority: int = Field(default=0, description="Sort order - lower = higher priority")
notify_on_show: bool = Field(default=True, description="Notify when this band plays a show")
created_at: datetime = Field(default_factory=datetime.utcnow)
user: "User" = Relationship()
vertical: "Vertical" = Relationship()
class Profile(SQLModel, table=True):
"""A user's identity within a specific context or global"""
@ -561,17 +592,6 @@ class GroupPost(SQLModel, table=True):
group: Group = Relationship(back_populates="posts")
user: User = Relationship()
class Notification(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
user_id: int = Field(foreign_key="user.id", index=True)
type: str = Field(description="reply, mention, system")
title: str
message: str
link: Optional[str] = None
is_read: bool = Field(default=False)
created_at: datetime = Field(default_factory=datetime.utcnow)
user: User = Relationship(back_populates="notifications")
class Reaction(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)

View file

@ -0,0 +1,156 @@
"""
Recommendation API - Personalized suggestions for users.
"""
from typing import List, Optional
from fastapi import APIRouter, Depends, Query
from sqlmodel import Session, select, desc, func
from pydantic import BaseModel
from datetime import datetime, timedelta
from database import get_session
from models import Show, Vertical, UserVerticalPreference, Attendance, Rating, Performance, Song, Venue, BandMembership
from auth import get_current_user
from models import User
router = APIRouter(prefix="/recommendations", tags=["recommendations"])
class RecommendedShow(BaseModel):
id: int
date: str
venue_name: str | None
vertical_name: str
vertical_slug: str
reason: str # "Recent Show", "Highly Rated", "Trending"
class RecommendedPerformance(BaseModel):
id: int
song_title: str
show_date: str
vertical_name: str
avg_rating: float
notes: str | None
@router.get("/shows/recent", response_model=List[RecommendedShow])
def get_recent_subscriptions(
limit: int = 10,
session: Session = Depends(get_session),
current_user: User = Depends(get_current_user)
):
"""
Get recent shows from bands the user follows, excluding attended shows.
"""
# 1. Get user preferences
prefs = session.exec(
select(UserVerticalPreference).where(UserVerticalPreference.user_id == current_user.id)
).all()
if not prefs:
# Fallback: Just return recent shows from featured verticals
# For now, return empty or generic
return []
subscribed_vertical_ids = [p.vertical_id for p in prefs]
# 2. Get Attended Show IDs
attended = session.exec(
select(Attendance.show_id).where(Attendance.user_id == current_user.id)
).all()
attended_ids = set(attended)
# 3. Query Recent Shows
query = (
select(Show)
.where(Show.vertical_id.in_(subscribed_vertical_ids))
.where(Show.date <= datetime.now()) # Past shows only
.where(Show.date >= datetime.now() - timedelta(days=90)) # Last 90 days
.order_by(desc(Show.date))
.limit(limit * 2) # Fetch extra to filter
)
shows = session.exec(query).all()
results = []
for show in shows:
if show.id in attended_ids:
continue
vertical = session.get(Vertical, show.vertical_id)
venue = session.get(Venue, show.venue_id) if show.venue_id else None
results.append(RecommendedShow(
id=show.id,
date=show.date.strftime("%Y-%m-%d"),
venue_name=venue.name if venue else "Unknown Venue",
vertical_name=vertical.name,
vertical_slug=vertical.slug,
reason="Recent from your bands"
))
if len(results) >= limit:
break
return results
@router.get("/performances/top", response_model=List[RecommendedPerformance])
def get_top_rated_tracks(
limit: int = 10,
session: Session = Depends(get_session),
current_user: User = Depends(get_current_user)
):
"""
Get top rated performances from bands the user follows.
"""
prefs = session.exec(
select(UserVerticalPreference).where(UserVerticalPreference.user_id == current_user.id)
).all()
if not prefs:
return []
subscribed_vertical_ids = [p.vertical_id for p in prefs]
# Complex query: Join Performance -> Show -> Vertical, Join Rating
# Getting avg rating per performance
# This might be slow on large datasets without materialized view.
# Optimized approach: Query Rating table, group by performance_id, filter by subscribed verticals
results = session.exec(
select(
Rating.performance_id,
func.avg(Rating.score).label("average"),
func.count(Rating.id).label("count")
)
.join(Performance, Rating.performance_id == Performance.id)
.join(Show, Performance.show_id == Show.id)
.where(Show.vertical_id.in_(subscribed_vertical_ids))
.where(Rating.performance_id.isnot(None))
.group_by(Rating.performance_id)
.having(func.count(Rating.id) >= 1) # At least 1 rating
.order_by(desc("average"))
.limit(limit)
).all()
recommendations = []
for row in results:
perf_id, avg, count = row
perf = session.get(Performance, perf_id)
if not perf: continue
show = session.get(Show, perf.show_id)
song = session.get(Song, perf.song_id)
vertical = session.get(Vertical, show.vertical_id)
recommendations.append(RecommendedPerformance(
id=perf.id,
song_title=song.title,
show_date=show.date.strftime("%Y-%m-%d"),
vertical_name=vertical.name,
avg_rating=round(avg, 1),
notes=f"Rated {round(avg, 1)}/10 by {count} fans"
))
return recommendations

View file

@ -2,18 +2,35 @@ from typing import List
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlmodel import Session, select
from database import get_session
from models import Show, Tag, EntityTag, Vertical
from models import Show, Tag, EntityTag, Vertical, UserVerticalPreference
from schemas import ShowCreate, ShowRead, ShowUpdate, TagRead
from auth import get_current_user
from auth import get_current_user, get_current_user_optional
router = APIRouter(prefix="/shows", tags=["shows"])
from services.notification_service import NotificationService
def get_notification_service(session: Session = Depends(get_session)) -> NotificationService:
return NotificationService(session)
@router.post("/", response_model=ShowRead)
def create_show(show: ShowCreate, session: Session = Depends(get_session), current_user = Depends(get_current_user)):
def create_show(
show: ShowCreate,
session: Session = Depends(get_session),
current_user = Depends(get_current_user),
notification_service: NotificationService = Depends(get_notification_service)
):
db_show = Show.model_validate(show)
session.add(db_show)
session.commit()
session.refresh(db_show)
# Trigger notifications
try:
notification_service.check_show_alert(db_show)
except Exception as e:
print(f"Error sending notifications: {e}")
return db_show
@router.get("/", response_model=List[ShowRead])
@ -23,10 +40,29 @@ def read_shows(
venue_id: int = None,
tour_id: int = None,
year: int = None,
vertical_slugs: List[str] = Query(None),
status: str = Query(default=None, regex="^(past|upcoming)$"),
tiers: List[str] = Query(None),
current_user = Depends(get_current_user_optional),
session: Session = Depends(get_session)
):
query = select(Show)
from sqlalchemy.orm import joinedload
query = select(Show).options(joinedload(Show.vertical))
if tiers and current_user:
prefs = session.exec(
select(UserVerticalPreference)
.where(UserVerticalPreference.user_id == current_user.id)
.where(UserVerticalPreference.tier.in_(tiers))
).all()
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 []
query = query.where(Show.vertical_id.in_(allowed_ids))
elif tiers and not current_user:
# Anonymous users can't filter by personal tiers
return []
if venue_id:
query = query.where(Show.venue_id == venue_id)
if tour_id:
@ -35,6 +71,9 @@ def read_shows(
from sqlalchemy import extract
query = query.where(extract('year', Show.date) == year)
if vertical_slugs:
query = query.join(Vertical).where(Vertical.slug.in_(vertical_slugs))
if status:
from datetime import datetime
if status == "past":
@ -48,22 +87,54 @@ def read_shows(
@router.get("/recent", response_model=List[ShowRead])
def read_recent_shows(
limit: int = Query(default=10, le=50),
tiers: List[str] = Query(None),
current_user = Depends(get_current_user_optional),
session: Session = Depends(get_session)
):
"""Get the most recent shows ordered by date descending"""
from datetime import datetime
query = select(Show).where(Show.date <= datetime.now()).order_by(Show.date.desc()).limit(limit)
from sqlalchemy.orm import joinedload
query = select(Show).options(joinedload(Show.vertical)).where(Show.date <= datetime.now())
if tiers and current_user:
prefs = session.exec(
select(UserVerticalPreference)
.where(UserVerticalPreference.user_id == current_user.id)
.where(UserVerticalPreference.tier.in_(tiers))
).all()
allowed_ids = [p.vertical_id for p in prefs]
if not allowed_ids:
return []
query = query.where(Show.vertical_id.in_(allowed_ids))
query = query.order_by(Show.date.desc()).limit(limit)
shows = session.exec(query).all()
return shows
@router.get("/upcoming", response_model=List[ShowRead])
def read_upcoming_shows(
limit: int = Query(default=50, le=100),
tiers: List[str] = Query(None),
current_user = Depends(get_current_user_optional),
session: Session = Depends(get_session)
):
"""Get upcoming shows ordered by date ascending"""
from datetime import datetime
query = select(Show).where(Show.date > datetime.now()).order_by(Show.date.asc()).limit(limit)
from sqlalchemy.orm import joinedload
query = select(Show).options(joinedload(Show.vertical)).where(Show.date > datetime.now())
if tiers and current_user:
prefs = session.exec(
select(UserVerticalPreference)
.where(UserVerticalPreference.user_id == current_user.id)
.where(UserVerticalPreference.tier.in_(tiers))
).all()
allowed_ids = [p.vertical_id for p in prefs]
if not allowed_ids:
return []
query = query.where(Show.vertical_id.in_(allowed_ids))
query = query.order_by(Show.date.asc()).limit(limit)
shows = session.exec(query).all()
return shows

View file

@ -22,6 +22,7 @@ class UserVerticalPreferenceRead(BaseModel):
vertical: VerticalRead
display_mode: str
priority: int
tier: str = "main_stage"
notify_on_show: bool
@ -29,12 +30,14 @@ class UserVerticalPreferenceCreate(BaseModel):
vertical_id: int
display_mode: str = "primary" # primary, secondary, attribution_only, hidden
priority: int = 0
tier: str = "main_stage"
notify_on_show: bool = True
class UserVerticalPreferenceUpdate(BaseModel):
display_mode: str | None = None
priority: int | None = None
tier: str | None = None
notify_on_show: bool | None = None
@ -125,6 +128,7 @@ def get_my_vertical_preferences(
"vertical": vertical,
"display_mode": pref.display_mode,
"priority": pref.priority,
"tier": pref.tier,
"notify_on_show": pref.notify_on_show
})
return result
@ -157,6 +161,7 @@ def add_vertical_preference(
vertical_id=pref.vertical_id,
display_mode=pref.display_mode,
priority=pref.priority,
tier=pref.tier,
notify_on_show=pref.notify_on_show
)
session.add(db_pref)
@ -168,6 +173,7 @@ def add_vertical_preference(
"vertical": vertical,
"display_mode": db_pref.display_mode,
"priority": db_pref.priority,
"tier": db_pref.tier,
"notify_on_show": db_pref.notify_on_show
}
@ -252,6 +258,7 @@ def update_vertical_preference(
"vertical": vertical,
"display_mode": pref.display_mode,
"priority": pref.priority,
"tier": pref.tier,
"notify_on_show": pref.notify_on_show
}

View file

@ -0,0 +1,9 @@
import sys
import os
sys.path.append(os.path.join(os.path.dirname(__file__), ".."))
from sqlmodel import SQLModel
import models
print("Tables in metadata:")
for table in SQLModel.metadata.tables:
print(f"- {table}")

View file

@ -0,0 +1,98 @@
import sys
import os
from datetime import datetime
# Add backend to path
sys.path.append(os.path.join(os.path.dirname(__file__), ".."))
from sqlmodel import Session, select
from database import engine
from models import User, Vertical, UserVerticalPreference, Show, Venue, Notification, NotificationType, PreferenceTier
from services.notification_service import NotificationService
def verify_notifications():
print(f"DEBUG: Using database URL: {engine.url}")
with Session(engine) as session:
print("Setting up test data...")
# 1. Get or create a user
user = session.exec(select(User).where(User.email == "test_notify@example.com")).first()
if not user:
user = User(email="test_notify@example.com", hashed_password="hashed_password", is_active=True)
session.add(user)
session.commit()
session.refresh(user)
print(f"User ID: {user.id}")
# 2. Get a vertical (Phish or Goose)
vertical = session.exec(select(Vertical).where(Vertical.slug == "phish")).first()
if not vertical:
print("Phish vertical not found, creating dummy...")
vertical = Vertical(slug="phish", name="Phish")
session.add(vertical)
session.commit()
session.refresh(vertical)
print(f"Vertical ID: {vertical.id}")
# 3. Create/Update preference
pref = session.exec(
select(UserVerticalPreference)
.where(UserVerticalPreference.user_id == user.id)
.where(UserVerticalPreference.vertical_id == vertical.id)
).first()
if not pref:
pref = UserVerticalPreference(
user_id=user.id,
vertical_id=vertical.id,
tier=PreferenceTier.HEADLINER,
notify_on_show=True
)
session.add(pref)
else:
pref.notify_on_show = True
session.add(pref)
session.commit()
print("User preference set to notify_on_show=True")
# 4. Create a Venue
venue = session.exec(select(Venue).where(Venue.name == "Test Venue")).first()
if not venue:
venue = Venue(name="Test Venue", city="Test City", country="USA")
session.add(venue)
session.commit()
session.refresh(venue)
# 5. Create a Show using Service logic (simulate API call)
# We invoke NotificationService manually on a new show object
print("Creating new show...")
new_show = Show(
date=datetime.now(),
slug=f"phish-test-{int(datetime.now().timestamp())}",
vertical_id=vertical.id,
venue_id=venue.id
)
session.add(new_show)
session.commit()
session.refresh(new_show)
service = NotificationService(session)
service.check_show_alert(new_show)
# 6. Verify Notification
print("Checking for notification...")
notes = service.get_user_notifications(user.id)
found = False
for n in notes:
if n.type == NotificationType.SHOW_ALERT and n.link == f"/{vertical.slug}/shows/{new_show.slug}":
print(f"✅ Notification found: {n.title} - {n.message}")
found = True
break
if not found:
print("❌ Notification NOT found!")
# Clean up test data if needed, but keeping for debug might be fine
if __name__ == "__main__":
verify_notifications()

57
backend/seed_dso.py Normal file
View file

@ -0,0 +1,57 @@
from sqlmodel import Session, select, create_engine
from database import engine
from models import Vertical, Scene, VerticalScene, Artist
from importers.dso import DsoImporter
def seed_dso():
with Session(engine) as session:
# 1. Ensure "Grateful Dead Family" scene exists
scene = session.exec(select(Scene).where(Scene.slug == "grateful-dead-family")).first()
if not scene:
scene = Scene(name="Grateful Dead Family", slug="grateful-dead-family")
session.add(scene)
session.commit()
session.refresh(scene)
# 2. Add Artist
print("Creating Artist...")
artist = session.exec(select(Artist).where(Artist.slug == "dark-star-orchestra")).first()
if not artist:
artist = Artist(name="Dark Star Orchestra", slug="dark-star-orchestra")
session.add(artist)
session.commit()
session.refresh(artist)
# 3. Add Vertical (Band)
print("Creating Vertical...")
vertical = session.exec(select(Vertical).where(Vertical.slug == "dark-star-orchestra")).first()
if not vertical:
vertical = Vertical(
name="Dark Star Orchestra",
slug="dark-star-orchestra",
description="Recreating the Grateful Dead concert experience.",
setlistfm_mbid="e477d9c0-1f35-40f7-ad1a-b915d2523b84",
primary_artist_id=artist.id
)
session.add(vertical)
session.commit()
session.refresh(vertical)
# 4. Link to Scene
link = session.exec(select(VerticalScene).where(
VerticalScene.vertical_id == vertical.id,
VerticalScene.scene_id == scene.id
)).first()
if not link:
session.add(VerticalScene(vertical_id=vertical.id, scene_id=scene.id))
session.commit()
print("✅ Vertical seeded successfully.")
# 5. Run Import (Optional - can be run separately)
print("Starting import...")
importer = DsoImporter(session)
importer.import_all()
if __name__ == "__main__":
seed_dso()

View file

@ -0,0 +1,89 @@
from typing import List, Optional
from sqlmodel import Session, select
from models import Notification, NotificationType, User, UserVerticalPreference, Show, PreferenceTier
class NotificationService:
def __init__(self, session: Session):
self.session = session
def create_notification(
self,
user_id: int,
type: NotificationType,
title: str,
message: str,
link: Optional[str] = None
) -> Notification:
notification = Notification(
user_id=user_id,
type=type,
title=title,
message=message,
link=link
)
self.session.add(notification)
self.session.commit()
self.session.refresh(notification)
return notification
def get_user_notifications(
self,
user_id: int,
limit: int = 50,
offset: int = 0
) -> List[Notification]:
query = select(Notification).where(Notification.user_id == user_id).order_by(Notification.created_at.desc()).offset(offset).limit(limit)
return self.session.exec(query).all()
def mark_as_read(self, notification_id: int, user_id: int) -> bool:
notification = self.session.get(Notification, notification_id)
if notification and notification.user_id == user_id:
notification.is_read = True
self.session.add(notification)
self.session.commit()
return True
return False
def mark_all_as_read(self, user_id: int):
statement = select(Notification).where(Notification.user_id == user_id).where(Notification.is_read == False)
unread = self.session.exec(statement).all()
for note in unread:
note.is_read = True
self.session.add(note)
self.session.commit()
def check_show_alert(self, show: Show):
"""
Check if any users want to be notified about this new show.
This roughly matches users who:
1. Follow the vertical (UserVerticalPreference)
2. Have notify_on_show = True
3. Valid Tier (Usually Headliner/MainStage)
"""
# Find users who subscribe to this band
# Filtering logic:
# - Matches vertical_id
# - notify_on_show is True
# - Tier is HEADLINER (for high priority) or specific preference
# For now, let's alert all who have notify_on_show=True for this vertical
subscriptions = self.session.exec(
select(UserVerticalPreference)
.where(UserVerticalPreference.vertical_id == show.vertical_id)
.where(UserVerticalPreference.notify_on_show == True)
).all()
for sub in subscriptions:
# We can customize message based on tier if needed
title = f"New Show Added: {show.vertical.name}"
date_str = show.date.strftime("%b %d, %Y")
message = f"{show.vertical.name} at {show.venue.name} on {date_str}"
link = f"/{show.vertical.slug}/shows/{show.slug}"
self.create_notification(
user_id=sub.user_id,
type=NotificationType.SHOW_ALERT,
title=title,
message=message,
link=link
)

View file

@ -0,0 +1,97 @@
from datetime import datetime
from fastapi.testclient import TestClient
from sqlmodel import Session, select
from models import Vertical, Musician, BandMembership, UserVerticalPreference, PreferenceTier, Notification, NotificationType, Artist
def test_create_vertical(client: TestClient, session: Session):
vertical = Vertical(name="Test Band", slug="test-band")
session.add(vertical)
session.commit()
response = client.get("/bands/test-band")
assert response.status_code == 200
data = response.json()
# The API returns structured data: { "band": {...}, "stats": {...} }
assert data["band"]["name"] == "Test Band"
assert data["band"]["slug"] == "test-band"
def test_create_musician_and_membership(client: TestClient, session: Session):
# 1. Setup Artist & Vertical
artist = Artist(name="The Testers", slug="the-testers-artist")
session.add(artist)
session.commit()
band = Vertical(name="The Testers", slug="the-testers", primary_artist_id=artist.id)
session.add(band)
session.commit()
# 2. Setup Musician
musician = Musician(name="John Doe", slug="john-doe")
session.add(musician)
session.commit()
# 3. Link them via Artist
membership = BandMembership(
musician_id=musician.id,
artist_id=artist.id,
role="Guitar",
start_date=datetime(2020, 1, 1)
)
session.add(membership)
session.commit()
# 4. Test API
response = client.get("/musicians/john-doe")
assert response.status_code == 200
data = response.json()
assert data["musician"]["name"] == "John Doe"
assert len(data["bands"]) == 1
# The 'bands' list in response contains artist_name/slug
assert data["bands"][0]["artist_name"] == "The Testers"
def test_notification_integration(client: TestClient, session: Session, test_user_token: str):
# 1. Setup Band
band = Vertical(name="Notify Band", slug="notify-band")
session.add(band)
session.commit()
# 2. Setup User & Preference
# We need the user ID. The 'test_user_token' fixture creates a user with email "test@example.com".
from models import User
user = session.exec(select(User).where(User.email == "test@example.com")).first()
assert user is not None
pref = UserVerticalPreference(
user_id=user.id,
vertical_id=band.id,
tier=PreferenceTier.HEADLINER,
notify_on_show=True
)
session.add(pref)
session.commit()
# 3. Create Show via API (triggering notification)
# Ensure venue exists for potential creation
from models import Venue
venue = Venue(name="Notify Venue", city="City", country="Country", slug="notify-venue")
session.add(venue)
session.commit()
response = client.post(
"/shows/",
json={
"date": "2025-01-01T00:00:00",
"vertical_id": band.id,
"venue_id": venue.id,
"slug": "notify-show-1"
},
headers={"Authorization": f"Bearer {test_user_token}"}
)
assert response.status_code == 200, f"Response: {response.text}"
# 4. Verify Notification
notes = session.exec(select(Notification).where(Notification.user_id == user.id)).all()
assert len(notes) > 0, "No notifications found for user"
assert notes[0].type == NotificationType.SHOW_ALERT
assert "Notify Band" in notes[0].title

Binary file not shown.

View file

@ -1,7 +1,4 @@
import Link from "next/link"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { getApiUrl } from "@/lib/api-config"
import { TieredBandList } from "@/components/home/tiered-band-list"
interface Vertical {
id: number
@ -10,13 +7,6 @@ interface Vertical {
description: string | null
}
interface Scene {
id: number
name: string
slug: string
description: string | null
}
async function getVerticals(): Promise<Vertical[]> {
try {
const res = await fetch(`${getApiUrl()}/verticals`, { next: { revalidate: 60 } })
@ -27,118 +17,76 @@ async function getVerticals(): Promise<Vertical[]> {
}
}
async function getScenes(): Promise<Scene[]> {
try {
const res = await fetch(`${getApiUrl()}/verticals/scenes`, { next: { revalidate: 60 } })
if (!res.ok) return []
return res.json()
} catch {
return []
}
}
export default async function HomePage() {
const [verticals, scenes] = await Promise.all([getVerticals(), getScenes()])
const verticals = await getVerticals()
return (
<div className="space-y-16">
<div className="space-y-20 pb-16">
{/* Hero Section */}
<section className="text-center py-16 space-y-6">
<h1 className="text-5xl font-bold tracking-tight">
Fediversion
</h1>
<p className="text-xl text-muted-foreground max-w-2xl mx-auto">
The unified platform for the entire jam scene.
One account, all your favorite bands.
</p>
<div className="flex justify-center gap-4">
<Button asChild size="lg">
<Link href="/onboarding">Get Started</Link>
<section className="text-center pt-20 pb-10 space-y-8 animate-in fade-in zoom-in duration-700">
<div className="space-y-4">
<h1 className="text-6xl font-extrabold tracking-tighter bg-clip-text text-transparent bg-gradient-to-r from-primary to-primary/60">
Fediversion
</h1>
<p className="text-2xl text-muted-foreground max-w-2xl mx-auto font-light leading-relaxed">
The unified platform for the jam scene.
<br />
<span className="text-foreground font-medium">One account. All your bands.</span>
</p>
</div>
<div className="flex justify-center gap-4 pt-4">
<Button asChild size="xl" className="h-14 px-8 text-lg rounded-full">
<Link href="/register">Join the Community</Link>
</Button>
<Button asChild variant="outline" size="lg">
<Link href="/shows">Explore Shows</Link>
<Button asChild variant="outline" size="xl" className="h-14 px-8 text-lg rounded-full">
<Link href="/shows">Find a Show</Link>
</Button>
</div>
</section>
{/* Scenes Section */}
{scenes.length > 0 && (
<section className="space-y-6">
<h2 className="text-2xl font-bold text-center">Browse by Scene</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 max-w-3xl mx-auto">
{scenes.map((scene) => (
<Link
key={scene.slug}
href={`/?scene=${scene.slug}`}
className="block p-4 rounded-lg border bg-card hover:bg-accent transition-colors text-center"
>
<div className="font-semibold">{scene.name}</div>
{scene.description && (
<div className="text-sm text-muted-foreground mt-1 line-clamp-2">
{scene.description}
</div>
)}
</Link>
))}
</div>
</section>
)}
{/* Tiered Band List - The "Meat" */}
<TieredBandList initialVerticals={verticals} />
{/* Bands Grid */}
<section className="space-y-6">
<h2 className="text-2xl font-bold text-center">Featured Bands</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{verticals.map((vertical) => (
<Card key={vertical.slug} className="hover:shadow-lg transition-shadow">
<CardHeader>
<CardTitle>
<Link href={`/${vertical.slug}`} className="hover:underline">
{vertical.name}
</Link>
</CardTitle>
{vertical.description && (
<CardDescription>{vertical.description}</CardDescription>
)}
</CardHeader>
<CardContent>
<div className="flex gap-2">
<Button asChild variant="outline" size="sm">
<Link href={`/${vertical.slug}/shows`}>Shows</Link>
</Button>
<Button asChild variant="outline" size="sm">
<Link href={`/${vertical.slug}/songs`}>Songs</Link>
</Button>
<Button asChild variant="outline" size="sm">
<Link href={`/${vertical.slug}/venues`}>Venues</Link>
</Button>
</div>
</CardContent>
</Card>
))}
</div>
</section>
{/* Community / Stats - Reimagined */}
<section className="max-w-4xl mx-auto px-4">
<div className="bg-muted/50 rounded-3xl p-12 text-center space-y-8 border backdrop-blur-sm">
<div className="space-y-2">
<h2 className="text-3xl font-bold tracking-tight">Community Powered</h2>
<p className="text-lg text-muted-foreground">
Built by fans, for fans. Track your history, rate jams, and find your flock.
</p>
</div>
{/* Stats Section */}
<section className="bg-muted/50 rounded-lg p-8 text-center space-y-4">
<h2 className="text-2xl font-bold">Join the Community</h2>
<p className="text-muted-foreground">
Track shows, rate performances, discover connections across bands.
</p>
<div className="grid grid-cols-3 gap-6 max-w-md mx-auto pt-4">
<div>
<div className="text-3xl font-bold">{verticals.length}</div>
<div className="text-sm text-muted-foreground">Bands</div>
</div>
<div>
<div className="text-3xl font-bold">{scenes.length}</div>
<div className="text-sm text-muted-foreground">Scenes</div>
</div>
<div>
<div className="text-3xl font-bold">1</div>
<div className="text-sm text-muted-foreground">Account</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 pt-4">
<StatItem
value={verticals.length > 0 ? verticals.length.toString() : "25+"}
label="Bands Covered"
/>
<StatItem
value="∞"
label="Jams Indexed"
/>
<StatItem
value="1"
label="Universal Account"
/>
</div>
</div>
</section>
</div>
)
}
function StatItem({ value, label }: { value: string, label: string }) {
return (
<div className="space-y-1">
<div className="text-4xl font-black text-primary tracking-tight">{value}</div>
<div className="text-sm font-medium text-muted-foreground uppercase tracking-wider">{label}</div>
</div>
)
}

View file

@ -0,0 +1,204 @@
"use client"
import { useEffect, useState } from "react"
import { useParams, useRouter } from "next/navigation"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
import { ArrowLeft, Trash2, Calendar, Music, User as UserIcon, PlayCircle, MoreHorizontal } from "lucide-react"
import Link from "next/link"
import { getApiUrl } from "@/lib/api-config"
import { useToast } from "@/components/ui/use-toast"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
export default function PlaylistDetailPage() {
const params = useParams()
const router = useRouter()
const { toast } = useToast()
const [playlist, setPlaylist] = useState<any>(null)
const [loading, setLoading] = useState(true)
const [currentUser, setCurrentUser] = useState<any>(null)
useEffect(() => {
const token = localStorage.getItem("token")
// Fetch current user
if (token) {
fetch(`${getApiUrl()}/auth/users/me`, {
headers: { Authorization: `Bearer ${token}` }
})
.then(res => res.ok ? res.json() : null)
.then(data => setCurrentUser(data))
}
// Fetch playlist
fetch(`${getApiUrl()}/playlists/${params.id}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {}
})
.then(res => {
if (!res.ok) throw new Error("Failed to fetch playlist")
return res.json()
})
.then(data => setPlaylist(data))
.catch(err => {
console.error(err)
toast({
title: "Error",
description: "Could not load playlist",
variant: "destructive"
})
})
.finally(() => setLoading(false))
}, [params.id])
const handleDeletePlaylist = async () => {
if (!confirm("Are you sure you want to delete this playlist?")) return
const token = localStorage.getItem("token")
try {
const res = await fetch(`${getApiUrl()}/playlists/${params.id}`, {
method: "DELETE",
headers: { Authorization: `Bearer ${token}` }
})
if (res.ok) {
toast({ title: "Playlist deleted" })
router.push("/profile")
} else {
throw new Error("Failed to delete")
}
} catch (error) {
toast({ title: "Error", description: "Could not delete playlist", variant: "destructive" })
}
}
const handleRemoveTrack = async (performanceId: number) => {
const token = localStorage.getItem("token")
try {
const res = await fetch(`${getApiUrl()}/playlists/${params.id}/performances/${performanceId}`, {
method: "DELETE",
headers: { Authorization: `Bearer ${token}` }
})
if (res.ok) {
// Optimistic update
setPlaylist((prev: any) => ({
...prev,
performances: prev.performances.filter((p: any) => p.performance_id !== performanceId)
}))
toast({ title: "Track removed" })
}
} catch (error) {
toast({ title: "Error", description: "Could not remove track", variant: "destructive" })
}
}
if (loading) return <div className="container py-20 text-center">Loading playlist...</div>
if (!playlist) return <div className="container py-20 text-center">Playlist not found</div>
const isOwner = currentUser && currentUser.id === playlist.user_id
return (
<div className="container py-10 max-w-4xl space-y-8">
<Link href="/profile" className="flex items-center text-muted-foreground hover:text-foreground mb-4">
<ArrowLeft className="mr-2 h-4 w-4" /> Back to Profile
</Link>
<div className="flex flex-col md:flex-row justify-between items-start gap-4">
<div>
<div className="flex items-center gap-3 mb-2">
<h1 className="text-3xl font-bold tracking-tight">{playlist.name}</h1>
{!playlist.is_public && (
<span className="text-xs uppercase font-bold tracking-wider bg-muted text-muted-foreground px-2 py-1 rounded">Private</span>
)}
</div>
<p className="text-muted-foreground text-lg mb-4">{playlist.description}</p>
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<div className="flex items-center gap-1">
<UserIcon className="h-4 w-4" />
<span>{playlist.username}</span>
</div>
<div className="flex items-center gap-1">
<Calendar className="h-4 w-4" />
<span>{new Date(playlist.created_at).toLocaleDateString()}</span>
</div>
<div className="flex items-center gap-1">
<Music className="h-4 w-4" />
<span>{playlist.performances.length} tracks</span>
</div>
</div>
</div>
{isOwner && (
<Button variant="destructive" size="sm" onClick={handleDeletePlaylist} className="gap-2">
<Trash2 className="h-4 w-4" /> Delete Playlist
</Button>
)}
</div>
<Card>
<CardHeader>
<CardTitle>Tracks</CardTitle>
<CardDescription>
{playlist.performances.length === 0 ? "No tracks added yet." : "Performances in this collection."}
</CardDescription>
</CardHeader>
<CardContent className="p-0">
{playlist.performances.length > 0 && (
<div className="divide-y">
{playlist.performances.map((perf: any, index: number) => (
<div key={perf.performance_id} className="p-4 flex items-center justify-between hover:bg-muted/30 transition-colors">
<div className="flex items-center gap-4">
<span className="text-muted-foreground font-mono w-6 text-center">{index + 1}</span>
<div>
<p className="font-medium">{perf.song_title}</p>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>{new Date(perf.show_date).toLocaleDateString()}</span>
{perf.notes && (
<span className="italic text-muted-foreground/70">- {perf.notes}</span>
)}
</div>
</div>
</div>
<div className="flex items-center gap-2">
{perf.show_slug && (
<Link href={`/shows/${perf.show_slug}`}>
<Button size="icon" variant="ghost" title="Go to Show">
<PlayCircle className="h-4 w-4" />
</Button>
</Link>
)}
{isOwner && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="icon" variant="ghost">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => handleRemoveTrack(perf.performance_id)}
>
Remove from Playlist
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
)
}

View file

@ -13,9 +13,12 @@ import { UserReviewsList } from "@/components/profile/user-reviews-list"
import { UserGroupsList } from "@/components/profile/user-groups-list"
import { ChaseSongsList } from "@/components/profile/chase-songs-list"
import { AttendanceSummary } from "@/components/profile/attendance-summary"
import { UserPlaylistsList } from "@/components/profile/user-playlists-list"
import { LevelProgressCard } from "@/components/gamification/level-progress"
import { UserAvatar } from "@/components/ui/user-avatar"
import { motion } from "framer-motion"
import { RecommendedShows } from "@/components/recommendations/recommended-shows"
import { RecommendedTracks } from "@/components/recommendations/recommended-tracks"
// Types
interface UserProfile {
@ -180,6 +183,7 @@ export default function ProfilePage() {
<TabsTrigger value="overview" className="text-base font-medium">Overview</TabsTrigger>
<TabsTrigger value="attendance" className="text-base font-medium">My Shows</TabsTrigger>
<TabsTrigger value="reviews" className="text-base font-medium">Reviews</TabsTrigger>
<TabsTrigger value="playlists" className="text-base font-medium">Playlists</TabsTrigger>
<TabsTrigger value="groups" className="text-base font-medium">Communities</TabsTrigger>
</TabsList>
@ -193,6 +197,17 @@ export default function ProfilePage() {
<LevelProgressCard />
</motion.div>
{/* Recommendations */}
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2, delay: 0.05 }}
className="grid md:grid-cols-2 gap-6"
>
<RecommendedShows />
<RecommendedTracks />
</motion.div>
{/* Attendance Summary */}
<motion.div
initial={{ opacity: 0, x: -10 }}
@ -253,6 +268,16 @@ export default function ProfilePage() {
</motion.div>
</TabsContent>
<TabsContent value="playlists">
<motion.div
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.2 }}
>
<UserPlaylistsList userId={user.id} isOwner={true} />
</motion.div>
</TabsContent>
<TabsContent value="groups">
<motion.div
initial={{ opacity: 0, x: -10 }}

View file

@ -12,6 +12,7 @@ import { EntityReviews } from "@/components/reviews/entity-reviews"
import { getApiUrl } from "@/lib/api-config"
import { YouTubeEmbed } from "@/components/ui/youtube-embed"
import { MarkCaughtButton } from "@/components/chase/mark-caught-button"
import { AddToPlaylistDialog } from "@/components/playlists/add-to-playlist-dialog"
async function getShow(id: string) {
try {
@ -240,6 +241,14 @@ export default async function ShowDetailPage({ params }: { params: Promise<{ slu
songTitle={perf.song?.title || "Song"}
showId={show.id}
/>
{/* Add to Playlist */}
<div className="opacity-0 group-hover:opacity-100 transition-opacity">
<AddToPlaylistDialog
performanceId={perf.id}
songTitle={perf.song?.title || "Song"}
/>
</div>
</div>
{perf.notes && (
<div className="text-xs text-muted-foreground ml-9 italic mt-0.5">

View file

@ -4,10 +4,11 @@ import { useEffect, useState, Suspense, useMemo } from "react"
import { getApiUrl } from "@/lib/api-config"
import { Loader2, Calendar } from "lucide-react"
import { Skeleton } from "@/components/ui/skeleton"
import { useSearchParams } from "next/navigation"
import { useSearchParams, useRouter, usePathname } from "next/navigation"
import { Button } from "@/components/ui/button"
import Link from "next/link"
import { BandFilter } from "@/components/shows/band-filter"
import { FeedFilter } from "@/components/shows/feed-filter"
import { DateGroupedList } from "@/components/shows/date-grouped-list"
interface Show {
@ -29,18 +30,78 @@ interface Show {
function ShowsContent() {
const searchParams = useSearchParams()
const router = useRouter()
const pathname = usePathname()
// Parse query params
const year = searchParams.get("year")
const bandsParam = searchParams.get("bands")
const tiersParam = searchParams.get("tiers")
const initialBands = bandsParam ? bandsParam.split(",") : []
const initialTiers = tiersParam ? tiersParam.split(",") : []
const [shows, setShows] = useState<Show[]>([])
const [loading, setLoading] = useState(true)
const [selectedBands, setSelectedBands] = useState<string[]>([])
const [selectedBands, setSelectedBands] = useState<string[]>(initialBands)
const [selectedTiers, setSelectedTiers] = useState<string[]>(initialTiers)
// Update URL when filters change
const updateBandFilters = (bands: string[]) => {
setSelectedBands(bands)
updateUrl(bands, selectedTiers)
}
const updateTierFilters = (tiers: string[]) => {
setSelectedTiers(tiers)
updateUrl(selectedBands, tiers)
}
const updateUrl = (bands: string[], tiers: string[]) => {
const params = new URLSearchParams(searchParams.toString())
if (bands.length > 0) {
params.set("bands", bands.join(","))
} else {
params.delete("bands")
}
if (tiers.length > 0) {
params.set("tiers", tiers.join(","))
} else {
params.delete("tiers")
}
router.push(`${pathname}?${params.toString()}`)
}
useEffect(() => {
const url = `${getApiUrl()}/shows/?limit=2000&status=past${year ? `&year=${year}` : ''}`
setLoading(true)
const params = new URLSearchParams()
params.append("limit", "200") // Lower limit for initial partial view, or keep 2000 if needed but likely smaller is better if filtered
params.append("status", "past")
if (year) params.append("year", year)
if (selectedBands.length > 0) {
selectedBands.forEach(slug => params.append("vertical_slugs", slug))
}
// If we had tiers
// if (selectedTiers.length > 0) {
// selectedTiers.forEach(tier => params.append("tiers", tier))
// }
const url = `${getApiUrl()}/shows/?${params.toString()}`
fetch(url)
.then(res => res.json())
.then(data => {
// Sort by date descending
// Backend might not sort perfectly if multiple bands (it sorts by offset usually, or default order).
// Currently backend read_shows does NO sorting (unless I missed it).
// read_shows only does offset/limit.
// So I should sort client side or add sort to backend.
// Assuming backend returns unsorted or DB order.
const sorted = data.sort((a: Show, b: Show) =>
new Date(b.date).getTime() - new Date(a.date).getTime()
)
@ -48,15 +109,7 @@ function ShowsContent() {
})
.catch(console.error)
.finally(() => setLoading(false))
}, [year])
// Filter shows locally based on selection
const filteredShows = useMemo(() => {
if (selectedBands.length === 0) return shows
return shows.filter(show =>
show.vertical && selectedBands.includes(show.vertical.slug)
)
}, [shows, selectedBands])
}, [year, selectedBands, selectedTiers])
if (loading) {
return (
@ -86,9 +139,13 @@ function ShowsContent() {
</p>
</div>
<div className="flex items-center gap-2">
<FeedFilter
selectedTiers={selectedTiers}
onChange={updateTierFilters}
/>
<BandFilter
selected={selectedBands}
onChange={setSelectedBands}
onChange={updateBandFilters}
/>
<Link href="/shows/upcoming">
<Button variant="outline" className="gap-2">
@ -100,7 +157,7 @@ function ShowsContent() {
</div>
</div>
<DateGroupedList shows={filteredShows} />
<DateGroupedList shows={shows} />
</div>
)
}

View file

@ -0,0 +1,156 @@
"use client"
import { useEffect, useState } from "react"
import Link from "next/link"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { useAuth } from "@/contexts/auth-context"
import { getApiUrl } from "@/lib/api-config"
interface Vertical {
id: number
name: string
slug: string
description: string | null
}
interface Preference {
vertical_id: number
tier: string
}
export function TieredBandList({ initialVerticals }: { initialVerticals: Vertical[] }) {
const [verticals, setVerticals] = useState<Vertical[]>(initialVerticals)
const [preferences, setPreferences] = useState<Preference[]>([])
const { token, isAuthenticated } = useAuth()
const [loading, setLoading] = useState(false)
// Default headliners for guests
const defaultHeadliners = ["phish", "grateful-dead", "dead-and-company", "billy-strings", "goose"]
useEffect(() => {
if (isAuthenticated && token) {
setLoading(true)
fetch(`${process.env.NEXT_PUBLIC_API_URL || "https://api.fediversion.org"}/verticals/preferences/me`, {
headers: { Authorization: `Bearer ${token}` }
})
.then(res => {
if (res.ok) return res.json()
return []
})
.then((data: any[]) => {
// Map response to preference format
const prefs = data.map(p => ({
vertical_id: p.vertical_id,
tier: p.tier || "main_stage"
}))
setPreferences(prefs)
})
.catch(err => console.error("Failed to fetch preferences", err))
.finally(() => setLoading(false))
}
}, [isAuthenticated, token])
// Determine tiers
let headliners: Vertical[] = []
let others: Vertical[] = []
if (isAuthenticated && preferences.length > 0) {
// User has preferences
const headlinerIds = preferences.filter(p => p.tier === "headliner").map(p => p.vertical_id)
const subscribedIds = preferences.map(p => p.vertical_id)
headliners = verticals.filter(v => headlinerIds.includes(v.id))
others = verticals.filter(v => !headlinerIds.includes(v.id))
// If user has NO headliners set but HAS preferences, maybe show their top priority?
// Or just defaults.
if (headliners.length === 0) {
// Fallback to top 3 priority?
// For now, let's mix in defaults if empty? No, respect user choice.
// If they selected bands but no headliners, all are "others" (Touring Acts).
}
// Optionally filter "others" to only show subscribed bands?
// The requirement says "Main Stage: Standard following".
// "Supporting: Hidden unless relevant".
// Let's show subscribed bands as "Your Bands" and others as "Discover".
// But for simple "Tiered Band Preferences" UI, let's keep it simple:
// Headliners = Tier 'headliner'
// Touring Acts = Everyone else (or just subscribed 'main_stage'?)
// Let's show All Bands but prioritize Headliners.
} else {
// Guest or no prefs
headliners = verticals.filter(v => defaultHeadliners.includes(v.slug))
others = verticals.filter(v => !defaultHeadliners.includes(v.slug))
}
return (
<section className="space-y-12 max-w-6xl mx-auto px-4">
{/* Headliners */}
{headliners.length > 0 && (
<div className="space-y-6">
<div className="flex items-center gap-4">
<div className="h-px flex-1 bg-border" />
<h2 className="text-2xl font-bold tracking-tight text-muted-foreground uppercase text-sm">
{isAuthenticated && preferences.length > 0 ? "Your Headliners" : "Headliners"}
</h2>
<div className="h-px flex-1 bg-border" />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{headliners.map((vertical) => (
<VerticalCard key={vertical.slug} vertical={vertical} featured />
))}
</div>
</div>
)}
{/* Other Acts */}
{others.length > 0 && (
<div className="space-y-6">
<div className="flex items-center gap-4">
<div className="h-px flex-1 bg-border" />
<h2 className="text-2xl font-bold tracking-tight text-muted-foreground uppercase text-sm">Touring Acts</h2>
<div className="h-px flex-1 bg-border" />
</div>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{others.map((vertical) => (
<VerticalCard key={vertical.slug} vertical={vertical} />
))}
</div>
</div>
)}
</section>
)
}
function VerticalCard({ vertical, featured = false }: { vertical: Vertical, featured?: boolean }) {
return (
<Link href={`/${vertical.slug}`} className="group block h-full">
<Card className={`h-full transition-all duration-300 hover:scale-[1.02] hover:shadow-xl border-muted/60 ${featured ? 'bg-card' : 'bg-muted/30 hover:bg-card'}`}>
<CardHeader>
<CardTitle className={`flex items-center justify-between ${featured ? 'text-2xl' : 'text-lg'}`}>
<span className="capitalize">{vertical.name}</span>
{featured && (
<span className="text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity"></span>
)}
</CardTitle>
{featured && vertical.description && (
<CardDescription className="line-clamp-2 mt-2">
{vertical.description}
</CardDescription>
)}
</CardHeader>
{featured && (
<CardContent>
<div className="flex flex-wrap gap-2">
<span className="text-xs font-medium px-2 py-1 rounded-full bg-primary/10 text-primary">Shows</span>
<span className="text-xs font-medium px-2 py-1 rounded-full bg-primary/10 text-primary">Setlists</span>
<span className="text-xs font-medium px-2 py-1 rounded-full bg-primary/10 text-primary">Stats</span>
</div>
</CardContent>
)}
</Card>
</Link>
)
}

View file

@ -15,11 +15,28 @@ interface Vertical {
description: string | null
}
const StarIcon = ({ filled }: { filled: boolean }) => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill={filled ? "currentColor" : "none"}
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="w-5 h-5"
>
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
</svg>
)
export function BandOnboarding({ onComplete }: { onComplete?: () => void }) {
const [verticals, setVerticals] = useState<Vertical[]>([])
const [selected, setSelected] = useState<number[]>([])
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [selectedBands, setSelectedBands] = useState<number[]>([])
const [headliners, setHeadliners] = useState<number[]>([])
const [step, setStep] = useState<"select" | "tier">("select")
const [submitting, setSubmitting] = useState(false)
const { token } = useAuth()
const router = useRouter()
@ -31,8 +48,8 @@ export function BandOnboarding({ onComplete }: { onComplete?: () => void }) {
const data = await res.json()
setVerticals(data)
}
} catch (err) {
console.error("Failed to fetch verticals:", err)
} catch (error) {
console.error("Failed to fetch verticals", error)
} finally {
setLoading(false)
}
@ -40,48 +57,81 @@ export function BandOnboarding({ onComplete }: { onComplete?: () => void }) {
fetchVerticals()
}, [])
const toggleVertical = (id: number) => {
setSelected(prev =>
prev.includes(id)
? prev.filter(v => v !== id)
: [...prev, id]
const toggleBand = (id: number) => {
setSelectedBands(prev =>
prev.includes(id) ? prev.filter(b => b !== id) : [...prev, id]
)
// Remove from headliners if deselected
if (headliners.includes(id)) {
setHeadliners(prev => prev.filter(h => h !== id))
}
}
const toggleHeadliner = (id: number) => {
setHeadliners(prev => {
if (prev.includes(id)) {
return prev.filter(h => h !== id)
}
if (prev.length >= 3) {
return prev // Limit 3
}
return [...prev, id]
})
}
const handleContinue = () => {
if (step === "select" && selectedBands.length > 0) {
setStep("tier")
} else {
handleSubmit()
}
}
const handleSubmit = async () => {
if (selected.length === 0) return
setSaving(true)
setSubmitting(true)
try {
const res = await fetch(`${getApiUrl()}/verticals/preferences/bulk`, {
// Strategy:
// 1. Bulk add all as 'standard'
// 2. Update headliners to 'headliner'
const bulkRes = await fetch(`${getApiUrl()}/verticals/preferences/bulk`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`
},
body: JSON.stringify({
vertical_ids: selected,
display_mode: "primary"
})
vertical_ids: selectedBands,
display_mode: "standard"
}),
})
if (res.ok) {
if (onComplete) {
onComplete()
} else {
// Navigate to first selected band
const firstVertical = verticals.find(v => v.id === selected[0])
if (firstVertical) {
router.push(`/${firstVertical.slug}`)
} else {
router.push("/")
}
}
if (!bulkRes.ok) throw new Error("Failed to save preferences")
// Now set tiers
await Promise.all(headliners.map(async (vid) => {
await fetch(`${getApiUrl()}/verticals/preferences/${vid}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`
},
body: JSON.stringify({
tier: "headliner",
priority: 100 // High priority
}),
})
}))
if (onComplete) {
onComplete()
} else {
router.push("/")
}
} catch (err) {
console.error("Failed to save preferences:", err)
} catch (error) {
console.error("Error saving preferences", error)
} finally {
setSaving(false)
setSubmitting(false)
}
}
@ -96,48 +146,78 @@ export function BandOnboarding({ onComplete }: { onComplete?: () => void }) {
return (
<div className="max-w-2xl mx-auto space-y-6">
<div className="text-center space-y-2">
<h1 className="text-3xl font-bold">Pick Your Bands</h1>
<h1 className="text-3xl font-bold">
{step === "select" ? "Pick Your Bands" : "Who are your Headliners?"}
</h1>
<p className="text-muted-foreground">
Select the bands you follow. You can change this anytime.
{step === "select"
? "Select the bands you follow. You can change this anytime."
: "Pick up to 3 favorites to feature on your home page."
}
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 mb-8">
{verticals.map((vertical) => (
<Card
<button
key={vertical.id}
className={`cursor-pointer transition-all ${selected.includes(vertical.id)
? "ring-2 ring-primary"
: "hover:bg-accent"
}`}
onClick={() => toggleVertical(vertical.id)}
onClick={() => step === "select" ? toggleBand(vertical.id) : null}
disabled={step === "tier" && !selectedBands.includes(vertical.id)}
className={`
relative p-4 rounded-xl border-2 text-left transition-all
${step === "select"
? (selectedBands.includes(vertical.id)
? "border-primary bg-primary/10"
: "border-border hover:border-primary/50")
: (selectedBands.includes(vertical.id)
? "border-primary/50 opacity-100"
: "border-border opacity-20 grayscale")
}
${step === "tier" && selectedBands.includes(vertical.id) ? "cursor-default" : ""}
`}
>
<CardHeader className="pb-2">
<div className="flex items-center gap-3">
<Checkbox
checked={selected.includes(vertical.id)}
onCheckedChange={() => toggleVertical(vertical.id)}
/>
<CardTitle className="text-lg">{vertical.name}</CardTitle>
<div className="font-bold">{vertical.name}</div>
<div className="text-sm text-muted-foreground">{vertical.description}</div>
{step === "tier" && selectedBands.includes(vertical.id) && (
<div
onClick={(e) => {
e.stopPropagation()
toggleHeadliner(vertical.id)
}}
className={`
absolute top-2 right-2 p-1 rounded-full cursor-pointer transition-colors z-10
${headliners.includes(vertical.id) ? "bg-yellow-500 text-black" : "bg-muted text-muted-foreground hover:bg-muted/80"}
`}
>
<StarIcon filled={headliners.includes(vertical.id)} />
</div>
</CardHeader>
{vertical.description && (
<CardContent>
<CardDescription>{vertical.description}</CardDescription>
</CardContent>
)}
</Card>
</button>
))}
</div>
<div className="flex justify-center pt-4">
<Button
size="lg"
onClick={handleSubmit}
disabled={selected.length === 0 || saving}
>
{saving ? "Saving..." : `Continue with ${selected.length} band${selected.length !== 1 ? "s" : ""}`}
</Button>
<div className="flex justify-between items-center bg-card p-4 rounded-lg border">
<div className="text-sm font-medium">
{step === "select"
? `${selectedBands.length} selected`
: `${headliners.length}/3 Headliners selected`
}
</div>
<div className="flex gap-2">
{step === "tier" && (
<Button variant="ghost" onClick={() => setStep("select")}>
Back
</Button>
)}
<Button
onClick={handleContinue}
disabled={submitting || (step === "select" && selectedBands.length === 0)}
size="lg"
>
{submitting ? "Saving..." : (step === "select" ? "Next: Choose Headliners" : "Finish")}
</Button>
</div>
</div>
</div>
)

View file

@ -0,0 +1,148 @@
"use client"
import { useState, useEffect } from "react"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { ListMusic, Plus, Loader2 } from "lucide-react"
import { useToast } from "@/components/ui/use-toast"
import { getApiUrl } from "@/lib/api-config"
import { Label } from "@/components/ui/label"
import { Input } from "@/components/ui/input"
interface AddToPlaylistDialogProps {
performanceId: number
songTitle: string
}
export function AddToPlaylistDialog({ performanceId, songTitle }: AddToPlaylistDialogProps) {
const [open, setOpen] = useState(false)
const [playlists, setPlaylists] = useState<any[]>([])
const [loading, setLoading] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [selectedPlaylistId, setSelectedPlaylistId] = useState<string>("")
const [notes, setNotes] = useState("")
const { toast } = useToast()
useEffect(() => {
if (open && playlists.length === 0) {
setLoading(true)
const token = localStorage.getItem("token")
if (!token) return
fetch(`${getApiUrl()}/playlists/mine`, {
headers: { Authorization: `Bearer ${token}` }
})
.then(res => res.json())
.then(data => setPlaylists(data))
.catch(err => console.error(err))
.finally(() => setLoading(false))
}
}, [open, playlists.length])
const handleSubmit = async () => {
if (!selectedPlaylistId) return
setSubmitting(true)
const token = localStorage.getItem("token")
try {
const res = await fetch(`${getApiUrl()}/playlists/${selectedPlaylistId}/performances/${performanceId}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`
},
body: JSON.stringify({ notes: notes || undefined })
})
if (res.ok) {
toast({ title: "Added to playlist" })
setOpen(false)
} else if (res.status === 400) {
toast({ title: "Already in playlist", variant: "default" })
} else {
throw new Error("Failed to add")
}
} catch (error) {
toast({ title: "Error", description: "Could not add to playlist", variant: "destructive" })
} finally {
setSubmitting(false)
}
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="ghost" size="icon" className="h-6 w-6 text-muted-foreground hover:text-primary" title="Add to Playlist">
<ListMusic className="h-4 w-4" />
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Add to Playlist</DialogTitle>
<DialogDescription>
Add &quot;{songTitle}&quot; to one of your collections.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="space-y-2">
<Label htmlFor="playlist">Select Playlist</Label>
<Select onValueChange={setSelectedPlaylistId} value={selectedPlaylistId}>
<SelectTrigger>
<SelectValue placeholder="Select a playlist" />
</SelectTrigger>
<SelectContent>
{loading ? (
<div className="flex items-center justify-center p-2">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
) : playlists.length === 0 ? (
<div className="p-2 text-sm text-muted-foreground text-center">No playlists found</div>
) : (
playlists.map((p) => (
<SelectItem key={p.id} value={p.id.toString()}>
{p.name}
</SelectItem>
))
)}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="notes">Notes (Optional)</Label>
<Input
id="notes"
placeholder="Why did you pick this version?"
value={notes}
onChange={(e) => setNotes(e.target.value)}
/>
</div>
</div>
<DialogFooter>
<Button onClick={handleSubmit} disabled={submitting || !selectedPlaylistId}>
{submitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Add to Playlist
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View file

@ -0,0 +1,93 @@
"use client"
import { useEffect, useState } from "react"
import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter } from "@/components/ui/card"
import { ListMusic, Plus, PlayCircle, MoreHorizontal } from "lucide-react"
import Link from "next/link"
import { Button } from "@/components/ui/button"
import { getApiUrl } from "@/lib/api-config"
import { Skeleton } from "@/components/ui/skeleton"
export function UserPlaylistsList({ userId, isOwner = false }: { userId: number, isOwner?: boolean }) {
const [playlists, setPlaylists] = useState<any[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
const token = localStorage.getItem("token")
if (!token) return
// If isOwner, use /mine endpoint to see private playlists too
const endpoint = isOwner ? `${getApiUrl()}/playlists/mine` : `${getApiUrl()}/playlists?user_id=${userId}`
fetch(endpoint, {
headers: { Authorization: `Bearer ${token}` }
})
.then(res => res.json())
.then(data => setPlaylists(data))
.catch(err => console.error(err))
.finally(() => setLoading(false))
}, [userId, isOwner])
if (loading) {
return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{[1, 2, 3].map(i => (
<Skeleton key={i} className="h-40 w-full rounded-lg" />
))}
</div>
)
}
if (playlists.length === 0) {
return (
<div className="text-center py-12 border-2 border-dashed rounded-lg">
<ListMusic className="h-12 w-12 text-muted-foreground/30 mx-auto mb-4" />
<p className="text-muted-foreground font-medium">No playlists created yet</p>
{isOwner && (
<Button variant="outline" className="mt-4 gap-2">
<Plus className="h-4 w-4" /> Create Playlist
</Button>
)}
</div>
)
}
return (
<div className="space-y-4">
{isOwner && (
<div className="flex justify-end">
<Button size="sm" className="gap-2">
<Plus className="h-4 w-4" /> New Playlist
</Button>
</div>
)}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{playlists.map((playlist) => (
<Link key={playlist.id} href={`/playlists/${playlist.id}`} className="group">
<Card className="h-full hover:border-primary/50 transition-colors cursor-pointer flex flex-col">
<CardHeader>
<div className="flex justify-between items-start gap-2">
<div className="bg-primary/10 p-2 rounded-md group-hover:bg-primary/20 transition-colors">
<ListMusic className="h-5 w-5 text-primary" />
</div>
{!playlist.is_public && (
<span className="text-[10px] uppercase font-bold tracking-wider bg-muted text-muted-foreground px-1.5 py-0.5 rounded">Private</span>
)}
</div>
<CardTitle className="mt-2 line-clamp-1">{playlist.name}</CardTitle>
<CardDescription className="line-clamp-2 min-h-[2.5em]">
{playlist.description || "No description"}
</CardDescription>
</CardHeader>
<CardFooter className="mt-auto pt-0 text-xs text-muted-foreground flex justify-between items-center">
<span>{playlist.performance_count} tracks</span>
<span>Updated {new Date(playlist.created_at).toLocaleDateString()}</span>
</CardFooter>
</Card>
</Link>
))}
</div>
</div>
)
}

View file

@ -0,0 +1,66 @@
"use client"
import { useEffect, useState } from "react"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
import { Calendar, MapPin, Ticket } from "lucide-react"
import Link from "next/link"
import { getApiUrl } from "@/lib/api-config"
import { Skeleton } from "@/components/ui/skeleton"
import { Badge } from "@/components/ui/badge"
export function RecommendedShows() {
const [shows, setShows] = useState<any[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
const token = localStorage.getItem("token")
if (!token) return
fetch(`${getApiUrl()}/recommendations/shows/recent?limit=4`, {
headers: { Authorization: `Bearer ${token}` }
})
.then(res => res.json())
.then(data => setShows(data))
.catch(err => console.error(err))
.finally(() => setLoading(false))
}, [])
if (loading) return <Skeleton className="h-48 w-full" />
if (shows.length === 0) return null
return (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-lg flex items-center gap-2">
<Ticket className="h-5 w-5 text-primary" />
Recommended Shows
</CardTitle>
<CardDescription>Recent shows from bands you follow</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{shows.map((show) => (
<div key={show.id} className="flex items-start justify-between border-b last:border-0 pb-3 last:pb-0">
<div className="space-y-1">
<Link
href={`/shows/${show.id}`} // Using ID as slug fallback for now
className="font-medium hover:underline text-primary"
>
{show.date}
</Link>
<div className="text-sm text-muted-foreground flex items-center gap-2">
<MapPin className="h-3 w-3" />
{show.venue_name}
</div>
</div>
<Badge variant="outline" className="text-xs">
{show.vertical_name}
</Badge>
</div>
))}
</div>
</CardContent>
</Card>
)
}

View file

@ -0,0 +1,78 @@
"use client"
import { useEffect, useState } from "react"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
import { Star, Music, PlayCircle } from "lucide-react"
import Link from "next/link"
import { getApiUrl } from "@/lib/api-config"
import { Skeleton } from "@/components/ui/skeleton"
import { AddToPlaylistDialog } from "@/components/playlists/add-to-playlist-dialog"
export function RecommendedTracks() {
const [tracks, setTracks] = useState<any[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
const token = localStorage.getItem("token")
if (!token) return
fetch(`${getApiUrl()}/recommendations/performances/top?limit=5`, {
headers: { Authorization: `Bearer ${token}` }
})
.then(res => res.json())
.then(data => setTracks(data))
.catch(err => console.error(err))
.finally(() => setLoading(false))
}, [])
if (loading) return <Skeleton className="h-48 w-full" />
if (tracks.length === 0) return null
return (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-lg flex items-center gap-2">
<Star className="h-5 w-5 text-yellow-500" />
Top Rated Jams
</CardTitle>
<CardDescription>Fan favorites from your bands</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{tracks.map((track) => (
<div key={track.id} className="group flex items-center justify-between border-b last:border-0 pb-3 last:pb-0">
<div className="flex items-center gap-3 overflow-hidden">
<div className="bg-muted p-2 rounded-full">
<Music className="h-4 w-4 text-muted-foreground" />
</div>
<div className="min-w-0">
<p className="font-medium truncate">{track.song_title}</p>
<div className="text-xs text-muted-foreground flex gap-2">
<span>{track.vertical_name}</span>
<span></span>
<span>{track.show_date}</span>
</div>
</div>
</div>
<div className="flex items-center gap-2 pl-2">
<div className="text-right">
<span className="font-bold text-sm text-yellow-600 dark:text-yellow-400">
{track.avg_rating}
</span>
</div>
<div className="opacity-0 group-hover:opacity-100 transition-opacity">
<AddToPlaylistDialog
performanceId={track.id}
songTitle={track.song_title}
/>
</div>
</div>
</div>
))}
</div>
</CardContent>
</Card>
)
}

View file

@ -0,0 +1,56 @@
"use client"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Layers } from "lucide-react"
interface FeedFilterProps {
selectedTiers: string[]
onChange: (tiers: string[]) => void
}
export function FeedFilter({ selectedTiers, onChange }: FeedFilterProps) {
const isAll = selectedTiers.length === 0
const isHeadliners = selectedTiers.length === 1 && selectedTiers[0] === "HEADLINER"
const isMyBands = selectedTiers.length === 3 // Headliner, Main, Supporting
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-8 gap-2">
<Layers className="h-3.5 w-3.5" />
{isAll ? "All Shows" : isHeadliners ? "Headliners Only" : "My Bands"}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel>Feed View</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuCheckboxItem
checked={isAll}
onCheckedChange={() => onChange([])}
>
All Shows (Default)
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={isHeadliners}
onCheckedChange={() => onChange(["HEADLINER"])}
>
My Headliners
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={isMyBands}
onCheckedChange={() => onChange(["HEADLINER", "MAIN_STAGE", "SUPPORTING"])}
>
My Bands (All Tiers)
</DropdownMenuCheckboxItem>
</DropdownMenuContent>
</DropdownMenu>
)
}

32
verify.sh Executable file
View file

@ -0,0 +1,32 @@
#!/bin/bash
# Colors for output
GREEN='\033[0;32m'
RED='\033[0;31m'
NC='\033[0m' # No Color
echo -e "${GREEN}Starting Quality Assurance Verification...${NC}"
# 1. Run Unit Tests (Pytest)
echo -e "\n${GREEN}[1/2] Running Unit Tests (backend/tests/)...${NC}"
pytest backend/tests/test_bands.py backend/tests/test_shows.py
if [ $? -eq 0 ]; then
echo -e "${GREEN}✅ Unit Tests Passed${NC}"
else
echo -e "${RED}❌ Unit Tests Failed${NC}"
exit 1
fi
# 2. Run Verification Script (Notifications)
echo -e "\n${GREEN}[2/2] Running Notification Verification Script...${NC}"
# Use the backend database explicitly
export DATABASE_URL="sqlite:////Users/ten/DEV/fediversion/backend/database.db"
python3 backend/scripts/verify_notifications.py
if [ $? -eq 0 ]; then
echo -e "${GREEN}✅ Notification Verification Passed${NC}"
else
echo -e "${RED}❌ Notification Verification Failed${NC}"
exit 1
fi
echo -e "\n${GREEN}🎉 All Verifications Passed!${NC}"