From cf7748a980fdf3f638816107565bc95c2a4504c0 Mon Sep 17 00:00:00 2001 From: fullsizemalt <106900403+fullsizemalt@users.noreply.github.com> Date: Sun, 28 Dec 2025 23:00:30 -0800 Subject: [PATCH] feat: Add band profile and musician profile pages with API endpoints and database support --- ..._add_band_profile_and_musician_profile_.py | 340 ++++++++++++++++++ backend/main.py | 3 +- backend/models.py | 26 ++ backend/routers/bands.py | 112 ++++++ frontend/app/bands/[slug]/page.tsx | 306 ++++++++++++++++ frontend/app/musicians/[slug]/page.tsx | 239 ++++++++++++ 6 files changed, 1025 insertions(+), 1 deletion(-) create mode 100644 backend/alembic/versions/81e183e75ff5_add_band_profile_and_musician_profile_.py create mode 100644 backend/routers/bands.py create mode 100644 frontend/app/bands/[slug]/page.tsx create mode 100644 frontend/app/musicians/[slug]/page.tsx diff --git a/backend/alembic/versions/81e183e75ff5_add_band_profile_and_musician_profile_.py b/backend/alembic/versions/81e183e75ff5_add_band_profile_and_musician_profile_.py new file mode 100644 index 0000000..42ca499 --- /dev/null +++ b/backend/alembic/versions/81e183e75ff5_add_band_profile_and_musician_profile_.py @@ -0,0 +1,340 @@ +"""add_band_profile_and_musician_profile_fields + +Revision ID: 81e183e75ff5 +Revises: 65c515b4722a +Create Date: 2025-12-28 22:53:06.940185 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel + + +# revision identifiers, used by Alembic. +revision: str = '81e183e75ff5' +down_revision: Union[str, Sequence[str], None] = '65c515b4722a' +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('musician', + 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('bio', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('image_url', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('primary_instrument', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('notes', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('birth_year', sa.Integer(), nullable=True), + sa.Column('origin_city', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('origin_state', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('origin_country', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('website_url', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('wikipedia_url', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('instagram_url', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('musician', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_musician_name'), ['name'], unique=False) + batch_op.create_index(batch_op.f('ix_musician_slug'), ['slug'], unique=True) + + op.create_table('scene', + 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('description', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('scene', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_scene_name'), ['name'], unique=True) + batch_op.create_index(batch_op.f('ix_scene_slug'), ['slug'], unique=True) + + op.create_table('sequence', + 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('description', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('notes', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('sequence', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_sequence_name'), ['name'], unique=False) + batch_op.create_index(batch_op.f('ix_sequence_slug'), ['slug'], unique=True) + + op.create_table('bandmembership', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('musician_id', sa.Integer(), nullable=False), + sa.Column('artist_id', sa.Integer(), nullable=False), + sa.Column('role', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('start_date', sa.DateTime(), nullable=True), + sa.Column('end_date', sa.DateTime(), nullable=True), + sa.Column('notes', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.ForeignKeyConstraint(['artist_id'], ['artist.id'], ), + sa.ForeignKeyConstraint(['musician_id'], ['musician.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('reaction', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('entity_type', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('entity_id', sa.Integer(), nullable=False), + sa.Column('emoji', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('reaction', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_reaction_entity_id'), ['entity_id'], unique=False) + batch_op.create_index(batch_op.f('ix_reaction_entity_type'), ['entity_type'], unique=False) + + op.create_table('songcanon', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('title', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('slug', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('original_artist', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('original_artist_id', sa.Integer(), nullable=True), + sa.Column('notes', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.ForeignKeyConstraint(['original_artist_id'], ['artist.id'], ), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('songcanon', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_songcanon_slug'), ['slug'], unique=True) + batch_op.create_index(batch_op.f('ix_songcanon_title'), ['title'], unique=False) + + op.create_table('userverticalpreference', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('vertical_id', sa.Integer(), nullable=False), + sa.Column('display_mode', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('priority', sa.Integer(), nullable=False), + sa.Column('notify_on_show', sa.Boolean(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ), + sa.ForeignKeyConstraint(['vertical_id'], ['vertical.id'], ), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('userverticalpreference', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_userverticalpreference_user_id'), ['user_id'], unique=False) + batch_op.create_index(batch_op.f('ix_userverticalpreference_vertical_id'), ['vertical_id'], unique=False) + + op.create_table('verticalscene', + sa.Column('vertical_id', sa.Integer(), nullable=False), + sa.Column('scene_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['scene_id'], ['scene.id'], ), + sa.ForeignKeyConstraint(['vertical_id'], ['vertical.id'], ), + sa.PrimaryKeyConstraint('vertical_id', 'scene_id') + ) + op.create_table('chasesong', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('song_id', sa.Integer(), nullable=False), + sa.Column('priority', sa.Integer(), nullable=False), + sa.Column('notes', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('caught_at', sa.DateTime(), nullable=True), + sa.Column('caught_show_id', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['caught_show_id'], ['show.id'], ), + sa.ForeignKeyConstraint(['song_id'], ['song.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('chasesong', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_chasesong_song_id'), ['song_id'], unique=False) + batch_op.create_index(batch_op.f('ix_chasesong_user_id'), ['user_id'], unique=False) + + op.create_table('sequencesong', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('sequence_id', sa.Integer(), nullable=False), + sa.Column('song_id', sa.Integer(), nullable=False), + sa.Column('position', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['sequence_id'], ['sequence.id'], ), + sa.ForeignKeyConstraint(['song_id'], ['song.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('performanceguest', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('performance_id', sa.Integer(), nullable=False), + sa.Column('musician_id', sa.Integer(), nullable=False), + sa.Column('instrument', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('notes', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.ForeignKeyConstraint(['musician_id'], ['musician.id'], ), + sa.ForeignKeyConstraint(['performance_id'], ['performance.id'], ), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('artist', schema=None) as batch_op: + batch_op.add_column(sa.Column('slug', sqlmodel.sql.sqltypes.AutoString(), nullable=False)) + batch_op.add_column(sa.Column('bio', sqlmodel.sql.sqltypes.AutoString(), nullable=True)) + batch_op.add_column(sa.Column('image_url', sqlmodel.sql.sqltypes.AutoString(), nullable=True)) + batch_op.create_index(batch_op.f('ix_artist_slug'), ['slug'], unique=True) + + with op.batch_alter_table('badge', schema=None) as batch_op: + batch_op.add_column(sa.Column('tier', sqlmodel.sql.sqltypes.AutoString(), nullable=False)) + batch_op.add_column(sa.Column('category', sqlmodel.sql.sqltypes.AutoString(), nullable=False)) + batch_op.add_column(sa.Column('xp_reward', sa.Integer(), nullable=False)) + + with op.batch_alter_table('group', schema=None) as batch_op: + 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']) + + 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)) + batch_op.add_column(sa.Column('nugs_link', sqlmodel.sql.sqltypes.AutoString(), nullable=True)) + + with op.batch_alter_table('show', schema=None) as batch_op: + batch_op.add_column(sa.Column('relisten_link', sqlmodel.sql.sqltypes.AutoString(), nullable=True)) + + 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']) + + 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)) + batch_op.add_column(sa.Column('avatar_text', sqlmodel.sql.sqltypes.AutoString(), nullable=True)) + batch_op.add_column(sa.Column('profile_public', sa.Boolean(), nullable=False)) + batch_op.add_column(sa.Column('show_attendance_public', sa.Boolean(), nullable=False)) + batch_op.add_column(sa.Column('appear_in_leaderboards', sa.Boolean(), nullable=False)) + + with op.batch_alter_table('userpreferences', schema=None) as batch_op: + batch_op.add_column(sa.Column('theme', sqlmodel.sql.sqltypes.AutoString(), nullable=False)) + batch_op.add_column(sa.Column('email_on_reply', sa.Boolean(), nullable=False)) + batch_op.add_column(sa.Column('email_on_chase', sa.Boolean(), nullable=False)) + batch_op.add_column(sa.Column('email_digest', sa.Boolean(), nullable=False)) + + with op.batch_alter_table('vertical', schema=None) as batch_op: + batch_op.add_column(sa.Column('primary_artist_id', sa.Integer(), nullable=True)) + batch_op.add_column(sa.Column('setlistfm_mbid', sqlmodel.sql.sqltypes.AutoString(), nullable=True)) + batch_op.add_column(sa.Column('is_active', sa.Boolean(), nullable=False)) + batch_op.add_column(sa.Column('is_featured', sa.Boolean(), nullable=False)) + batch_op.add_column(sa.Column('logo_url', sqlmodel.sql.sqltypes.AutoString(), nullable=True)) + batch_op.add_column(sa.Column('accent_color', sqlmodel.sql.sqltypes.AutoString(), nullable=True)) + batch_op.add_column(sa.Column('formed_year', sa.Integer(), nullable=True)) + batch_op.add_column(sa.Column('origin_city', sqlmodel.sql.sqltypes.AutoString(), nullable=True)) + batch_op.add_column(sa.Column('origin_state', sqlmodel.sql.sqltypes.AutoString(), nullable=True)) + batch_op.add_column(sa.Column('origin_country', sqlmodel.sql.sqltypes.AutoString(), nullable=True)) + batch_op.add_column(sa.Column('long_description', sqlmodel.sql.sqltypes.AutoString(), nullable=True)) + batch_op.add_column(sa.Column('website_url', sqlmodel.sql.sqltypes.AutoString(), nullable=True)) + batch_op.add_column(sa.Column('wikipedia_url', sqlmodel.sql.sqltypes.AutoString(), nullable=True)) + batch_op.add_column(sa.Column('bandcamp_url', sqlmodel.sql.sqltypes.AutoString(), nullable=True)) + 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']) + + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('vertical', schema=None) as batch_op: + batch_op.drop_constraint(None, type_='foreignkey') + batch_op.drop_column('spotify_url') + batch_op.drop_column('relisten_url') + batch_op.drop_column('nugs_url') + batch_op.drop_column('bandcamp_url') + batch_op.drop_column('wikipedia_url') + batch_op.drop_column('website_url') + batch_op.drop_column('long_description') + batch_op.drop_column('origin_country') + batch_op.drop_column('origin_state') + batch_op.drop_column('origin_city') + batch_op.drop_column('formed_year') + batch_op.drop_column('accent_color') + batch_op.drop_column('logo_url') + batch_op.drop_column('is_featured') + batch_op.drop_column('is_active') + batch_op.drop_column('setlistfm_mbid') + batch_op.drop_column('primary_artist_id') + + with op.batch_alter_table('userpreferences', schema=None) as batch_op: + batch_op.drop_column('email_digest') + batch_op.drop_column('email_on_chase') + batch_op.drop_column('email_on_reply') + batch_op.drop_column('theme') + + with op.batch_alter_table('user', schema=None) as batch_op: + batch_op.drop_column('appear_in_leaderboards') + batch_op.drop_column('show_attendance_public') + batch_op.drop_column('profile_public') + batch_op.drop_column('avatar_text') + batch_op.drop_column('avatar_bg_color') + + with op.batch_alter_table('song', schema=None) as batch_op: + batch_op.drop_constraint(None, type_='foreignkey') + batch_op.drop_constraint(None, type_='foreignkey') + batch_op.drop_column('artist_id') + batch_op.drop_column('canon_id') + + with op.batch_alter_table('show', schema=None) as batch_op: + batch_op.drop_column('relisten_link') + + with op.batch_alter_table('performance', schema=None) as batch_op: + batch_op.drop_column('nugs_link') + batch_op.drop_column('bandcamp_link') + + with op.batch_alter_table('group', schema=None) as batch_op: + batch_op.drop_constraint(None, type_='foreignkey') + batch_op.drop_index(batch_op.f('ix_group_vertical_id')) + batch_op.drop_column('image_url') + batch_op.drop_column('vertical_id') + + with op.batch_alter_table('badge', schema=None) as batch_op: + batch_op.drop_column('xp_reward') + batch_op.drop_column('category') + batch_op.drop_column('tier') + + with op.batch_alter_table('artist', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_artist_slug')) + batch_op.drop_column('image_url') + batch_op.drop_column('bio') + batch_op.drop_column('slug') + + op.drop_table('performanceguest') + op.drop_table('sequencesong') + with op.batch_alter_table('chasesong', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_chasesong_user_id')) + batch_op.drop_index(batch_op.f('ix_chasesong_song_id')) + + op.drop_table('chasesong') + op.drop_table('verticalscene') + with op.batch_alter_table('userverticalpreference', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_userverticalpreference_vertical_id')) + batch_op.drop_index(batch_op.f('ix_userverticalpreference_user_id')) + + op.drop_table('userverticalpreference') + with op.batch_alter_table('songcanon', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_songcanon_title')) + batch_op.drop_index(batch_op.f('ix_songcanon_slug')) + + op.drop_table('songcanon') + with op.batch_alter_table('reaction', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_reaction_entity_type')) + batch_op.drop_index(batch_op.f('ix_reaction_entity_id')) + + op.drop_table('reaction') + op.drop_table('bandmembership') + with op.batch_alter_table('sequence', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_sequence_slug')) + batch_op.drop_index(batch_op.f('ix_sequence_name')) + + op.drop_table('sequence') + with op.batch_alter_table('scene', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_scene_slug')) + batch_op.drop_index(batch_op.f('ix_scene_name')) + + op.drop_table('scene') + with op.batch_alter_table('musician', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_musician_slug')) + batch_op.drop_index(batch_op.f('ix_musician_name')) + + op.drop_table('musician') + # ### end Alembic commands ### diff --git a/backend/main.py b/backend/main.py index 5a29fda..3b1a7ff 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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 +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 from fastapi.middleware.cors import CORSMiddleware @@ -48,6 +48,7 @@ app.include_router(verticals.router) app.include_router(canon.router) app.include_router(on_this_day.router) app.include_router(discover.router) +app.include_router(bands.router) # Optional features - can be disabled via env vars diff --git a/backend/models.py b/backend/models.py index b55305e..c253f54 100644 --- a/backend/models.py +++ b/backend/models.py @@ -92,6 +92,21 @@ class Vertical(SQLModel, table=True): logo_url: Optional[str] = Field(default=None, description="Band logo URL for UI") accent_color: Optional[str] = Field(default=None, description="Hex color for accents") + # Rich profile fields + formed_year: Optional[int] = Field(default=None, description="Year band was formed") + origin_city: Optional[str] = Field(default=None, description="City of origin") + origin_state: Optional[str] = Field(default=None, description="State/province") + origin_country: Optional[str] = Field(default=None, description="Country") + long_description: Optional[str] = Field(default=None, description="Full band biography") + + # Social/external links + website_url: Optional[str] = Field(default=None) + wikipedia_url: Optional[str] = Field(default=None) + bandcamp_url: Optional[str] = Field(default=None) + nugs_url: Optional[str] = Field(default=None) + relisten_url: Optional[str] = Field(default=None) + spotify_url: Optional[str] = Field(default=None) + # Relationships shows: List["Show"] = Relationship(back_populates="vertical") songs: List["Song"] = Relationship(back_populates="vertical") @@ -140,6 +155,17 @@ class Musician(SQLModel, table=True): primary_instrument: Optional[str] = Field(default=None) notes: Optional[str] = Field(default=None) + # Rich profile fields + birth_year: Optional[int] = Field(default=None) + origin_city: Optional[str] = Field(default=None) + origin_state: Optional[str] = Field(default=None) + origin_country: Optional[str] = Field(default=None) + + # Social links + website_url: Optional[str] = Field(default=None) + wikipedia_url: Optional[str] = Field(default=None) + instagram_url: Optional[str] = Field(default=None) + # Relationships memberships: List["BandMembership"] = Relationship(back_populates="musician") guest_appearances: List["PerformanceGuest"] = Relationship(back_populates="musician") diff --git a/backend/routers/bands.py b/backend/routers/bands.py new file mode 100644 index 0000000..dcfa145 --- /dev/null +++ b/backend/routers/bands.py @@ -0,0 +1,112 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlmodel import Session, select, func +from typing import List, Optional +from database import get_session +from models import ( + Vertical, Artist, Musician, BandMembership, + PerformanceGuest, Performance, Show, Song, Venue +) + +router = APIRouter(prefix="/bands", tags=["bands"]) + + +@router.get("") +async def list_bands( + scene: Optional[str] = None, + is_featured: Optional[bool] = None, + session: Session = Depends(get_session) +): + """List all active bands, optionally filtered""" + query = select(Vertical).where(Vertical.is_active == True) + + if is_featured is not None: + query = query.where(Vertical.is_featured == is_featured) + + bands = session.exec(query.order_by(Vertical.name)).all() + return bands + + +@router.get("/{slug}") +async def get_band_profile(slug: str, session: Session = Depends(get_session)): + """Get comprehensive band profile including members and stats""" + vertical = session.exec( + select(Vertical).where(Vertical.slug == slug) + ).first() + + if not vertical: + raise HTTPException(status_code=404, detail="Band not found") + + # Get members via BandMembership if primary_artist_id exists + current_members = [] + past_members = [] + + if vertical.primary_artist_id: + # Get all memberships for this band's artist + memberships = session.exec( + select(BandMembership, Musician) + .join(Musician, BandMembership.musician_id == Musician.id) + .where(BandMembership.artist_id == vertical.primary_artist_id) + .order_by(BandMembership.start_date) + ).all() + + for membership, musician in memberships: + member_data = { + "id": musician.id, + "name": musician.name, + "slug": musician.slug, + "image_url": musician.image_url, + "role": membership.role, + "primary_instrument": musician.primary_instrument, + "start_date": membership.start_date, + "end_date": membership.end_date, + "notes": membership.notes, + } + if membership.end_date is None: + current_members.append(member_data) + else: + past_members.append(member_data) + + # Get stats + show_count = session.exec( + select(func.count(Show.id)).where(Show.vertical_id == vertical.id) + ).one() + + song_count = session.exec( + select(func.count(Song.id)).where(Song.vertical_id == vertical.id) + ).one() + + # Get venue count (distinct venues from shows) + venue_count = session.exec( + select(func.count(func.distinct(Show.venue_id))) + .where(Show.vertical_id == vertical.id) + ).one() + + # Get first and last show dates + first_show = session.exec( + select(Show.date) + .where(Show.vertical_id == vertical.id) + .order_by(Show.date.asc()) + .limit(1) + ).first() + + last_show = session.exec( + select(Show.date) + .where(Show.vertical_id == vertical.id) + .order_by(Show.date.desc()) + .limit(1) + ).first() + + stats = { + "total_shows": show_count, + "total_songs": song_count, + "total_venues": venue_count, + "first_show": first_show, + "last_show": last_show, + } + + return { + "band": vertical, + "current_members": current_members, + "past_members": past_members, + "stats": stats, + } diff --git a/frontend/app/bands/[slug]/page.tsx b/frontend/app/bands/[slug]/page.tsx new file mode 100644 index 0000000..c7a2b84 --- /dev/null +++ b/frontend/app/bands/[slug]/page.tsx @@ -0,0 +1,306 @@ +import { Metadata } from "next" +import { notFound } from "next/navigation" +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Badge } from "@/components/ui/badge" +import { Separator } from "@/components/ui/separator" +import Link from "next/link" +import { Music, Calendar, MapPin, Users, ExternalLink, Globe } from "lucide-react" + +interface BandPageProps { + params: { + slug: string + } +} + +async function getBand(slug: string) { + const res = await fetch(`${process.env.INTERNAL_API_URL}/bands/${slug}`, { + next: { revalidate: 60 }, + }) + + if (!res.ok) { + if (res.status === 404) return null + throw new Error("Failed to fetch band") + } + + return res.json() +} + +export async function generateMetadata({ params }: BandPageProps): Promise { + const data = await getBand(params.slug) + if (!data) return { title: "Band Not Found" } + + return { + title: `${data.band.name} | Fediversion`, + description: data.band.description || `Band profile for ${data.band.name} on Fediversion.`, + } +} + +export default async function BandPage({ params }: BandPageProps) { + const data = await getBand(params.slug) + if (!data) return notFound() + + const { band, current_members, past_members, stats } = data + + // Format origin location + const originParts = [band.origin_city, band.origin_state, band.origin_country].filter(Boolean) + const originLocation = originParts.join(", ") + + return ( +
+ {/* Header */} +
+
+ {band.logo_url ? ( + {band.name} + ) : ( +
+ {band.name[0]} +
+ )} +
+

{band.name}

+
+ {originLocation && ( + + + {originLocation} + + )} + {band.formed_year && ( + + + Formed {band.formed_year} + + )} +
+
+
+ + {band.description && ( +

+ {band.long_description || band.description} +

+ )} + + {/* External Links */} +
+ {band.website_url && ( + + Website + + )} + {band.nugs_url && ( + + Nugs.net + + )} + {band.relisten_url && ( + + Relisten + + )} + {band.spotify_url && ( + + Spotify + + )} + {band.wikipedia_url && ( + + Wikipedia + + )} +
+
+ + + + {/* Stats Grid */} +
+ + +
{stats.total_shows.toLocaleString()}
+
Shows
+
+
+ + +
{stats.total_songs.toLocaleString()}
+
Songs
+
+
+ + +
{stats.total_venues.toLocaleString()}
+
Venues
+
+
+ + +
+ {stats.first_show && stats.last_show + ? new Date(stats.last_show).getFullYear() - new Date(stats.first_show).getFullYear() + 1 + : '—' + } +
+
Active Years
+
+
+
+ + {/* Members Section */} + {(current_members.length > 0 || past_members.length > 0) && ( + <> +

+ Members +

+ + + + Current + {current_members.length} + + + Past + {past_members.length} + + + + +
+ {current_members.map((member: any) => ( + + + + {member.image_url ? ( + {member.name} + ) : ( +
+ {member.name[0]} +
+ )} +
+
+ {member.name} +
+
+ {member.role || member.primary_instrument} +
+ {member.start_date && ( +
+ Since {new Date(member.start_date).getFullYear()} +
+ )} +
+
+
+ + ))} + {current_members.length === 0 && ( +
+ No current members listed. +
+ )} +
+
+ + +
+ {past_members.map((member: any) => ( + + + + {member.image_url ? ( + {member.name} + ) : ( +
+ {member.name[0]} +
+ )} +
+
+ {member.name} +
+
+ {member.role || member.primary_instrument} +
+ {(member.start_date || member.end_date) && ( +
+ {member.start_date ? new Date(member.start_date).getFullYear() : '?'} + {' - '} + {member.end_date ? new Date(member.end_date).getFullYear() : '?'} +
+ )} +
+
+
+ + ))} + {past_members.length === 0 && ( +
+ No past members listed. +
+ )} +
+
+
+ + )} + + {/* Quick Links */} +
+ + + + All Shows + + + + + + + All Songs + + + + + + + All Venues + + + + + + + Top Performances + + + +
+
+ ) +} diff --git a/frontend/app/musicians/[slug]/page.tsx b/frontend/app/musicians/[slug]/page.tsx new file mode 100644 index 0000000..5693bc7 --- /dev/null +++ b/frontend/app/musicians/[slug]/page.tsx @@ -0,0 +1,239 @@ +import { Metadata } from "next" +import { notFound } from "next/navigation" +import { Card, CardContent } from "@/components/ui/card" +import { Badge } from "@/components/ui/badge" +import { Separator } from "@/components/ui/separator" +import Link from "next/link" +import { Music, Calendar, MapPin, Users, ExternalLink, Globe, Instagram } from "lucide-react" + +interface MusicianPageProps { + params: { + slug: string + } +} + +async function getMusician(slug: string) { + const res = await fetch(`${process.env.INTERNAL_API_URL}/musicians/${slug}`, { + next: { revalidate: 60 }, + }) + + if (!res.ok) { + if (res.status === 404) return null + throw new Error("Failed to fetch musician") + } + + return res.json() +} + +export async function generateMetadata({ params }: MusicianPageProps): Promise { + const data = await getMusician(params.slug) + if (!data) return { title: "Musician Not Found" } + + return { + title: `${data.musician.name} | Fediversion`, + description: data.musician.bio || `Musician profile for ${data.musician.name} on Fediversion.`, + } +} + +export default async function MusicianPage({ params }: MusicianPageProps) { + const data = await getMusician(params.slug) + if (!data) return notFound() + + const { musician, bands, guest_appearances, sit_in_summary, stats } = data + + return ( +
+ {/* Header */} +
+
+ {musician.image_url ? ( + {musician.name} + ) : ( +
+ {musician.name[0]} +
+ )} +
+

{musician.name}

+ {musician.primary_instrument && ( +

+ + {musician.primary_instrument} +

+ )} +
+
+ + {musician.bio && ( +

+ {musician.bio} +

+ )} + + {/* External Links */} +
+ {musician.website_url && ( + + Website + + )} + {musician.instagram_url && ( + + Instagram + + )} + {musician.wikipedia_url && ( + + Wikipedia + + )} +
+
+ + + + {/* Stats */} +
+ + +
{stats?.total_bands || 0}
+
Bands
+
+
+ + +
{stats?.current_bands || 0}
+
Current
+
+
+ + +
{stats?.total_sit_ins || 0}
+
Sit-Ins
+
+
+ + +
{stats?.bands_sat_in_with || 0}
+
Bands Sat In With
+
+
+
+ + {/* Band History */} + {bands && bands.length > 0 && ( + <> +

+ Band History +

+
+ {bands.map((band: any, i: number) => ( + + + +
+
+ {band.artist_name} + {band.is_current && ( + Current + )} +
+
+ {band.role} +
+
+
+ {band.start_date?.split('-')[0] || '?'} + {' - '} + {band.is_current ? 'Present' : (band.end_date?.split('-')[0] || '?')} +
+
+
+ + ))} +
+ + )} + + {/* Sit-In Summary */} + {sit_in_summary && sit_in_summary.length > 0 && ( + <> +

+ Sit-In Summary +

+
+ {sit_in_summary.map((band: any, i: number) => ( + + + +
+ {band.vertical_name} +
+
+ {band.count} +
+
+ sit-in{band.count !== 1 ? 's' : ''} +
+
+
+ + ))} +
+ + )} + + {/* Recent Guest Appearances */} + {guest_appearances && guest_appearances.length > 0 && ( + <> +

Recent Guest Appearances

+
+
+ {guest_appearances.slice(0, 20).map((appearance: any, i: number) => ( +
+
+
+ {appearance.vertical_name} + + {appearance.show_date} + +
+ {appearance.instrument && ( +

+ Playing: {appearance.instrument} +

+ )} +
+
+ Sat in on: + {appearance.song_title} +
+
+ ))} +
+
+ {guest_appearances.length > 20 && ( +

+ Showing 20 of {guest_appearances.length} appearances +

+ )} + + )} +
+ ) +}