feat: User Personalization, Playlists, Recommendations, and DSO Importer
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s
This commit is contained in:
parent
413430b700
commit
7b8ba4b54c
32 changed files with 14331 additions and 219 deletions
|
|
@ -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 ###
|
||||
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
@ -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 ###
|
||||
|
|
@ -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 ###
|
||||
BIN
backend/database.db.bak_1767032775
Normal file
BIN
backend/database.db.bak_1767032775
Normal file
Binary file not shown.
12313
backend/elmeg_dump.sql
Normal file
12313
backend/elmeg_dump.sql
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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
14
backend/importers/dso.py
Normal 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)
|
||||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
156
backend/routers/recommendations.py
Normal file
156
backend/routers/recommendations.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
9
backend/scripts/check_metadata.py
Normal file
9
backend/scripts/check_metadata.py
Normal 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}")
|
||||
98
backend/scripts/verify_notifications.py
Normal file
98
backend/scripts/verify_notifications.py
Normal 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
57
backend/seed_dso.py
Normal 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()
|
||||
89
backend/services/notification_service.py
Normal file
89
backend/services/notification_service.py
Normal 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
|
||||
)
|
||||
97
backend/tests/test_bands.py
Normal file
97
backend/tests/test_bands.py
Normal 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
|
||||
BIN
backend/update_package.tar.gz
Normal file
BIN
backend/update_package.tar.gz
Normal file
Binary file not shown.
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
204
frontend/app/playlists/[id]/page.tsx
Normal file
204
frontend/app/playlists/[id]/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
156
frontend/components/home/tiered-band-list.tsx
Normal file
156
frontend/components/home/tiered-band-list.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
148
frontend/components/playlists/add-to-playlist-dialog.tsx
Normal file
148
frontend/components/playlists/add-to-playlist-dialog.tsx
Normal 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 "{songTitle}" 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>
|
||||
)
|
||||
}
|
||||
93
frontend/components/profile/user-playlists-list.tsx
Normal file
93
frontend/components/profile/user-playlists-list.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
66
frontend/components/recommendations/recommended-shows.tsx
Normal file
66
frontend/components/recommendations/recommended-shows.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
78
frontend/components/recommendations/recommended-tracks.tsx
Normal file
78
frontend/components/recommendations/recommended-tracks.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
56
frontend/components/shows/feed-filter.tsx
Normal file
56
frontend/components/shows/feed-filter.tsx
Normal 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
32
verify.sh
Executable 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}"
|
||||
Loading…
Add table
Reference in a new issue