feat: Add band profile and musician profile pages with API endpoints and database support
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
762d2b81ff
commit
cf7748a980
6 changed files with 1025 additions and 1 deletions
|
|
@ -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 ###
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
112
backend/routers/bands.py
Normal file
112
backend/routers/bands.py
Normal file
|
|
@ -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,
|
||||
}
|
||||
306
frontend/app/bands/[slug]/page.tsx
Normal file
306
frontend/app/bands/[slug]/page.tsx
Normal file
|
|
@ -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<Metadata> {
|
||||
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 (
|
||||
<div className="container py-8 space-y-8">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
{band.logo_url ? (
|
||||
<img
|
||||
src={band.logo_url}
|
||||
alt={band.name}
|
||||
className="w-24 h-24 rounded-lg object-cover border-2 border-primary/20"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="w-24 h-24 rounded-lg flex items-center justify-center text-3xl font-bold text-white"
|
||||
style={{ backgroundColor: band.accent_color || '#6366f1' }}
|
||||
>
|
||||
{band.name[0]}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold tracking-tight">{band.name}</h1>
|
||||
<div className="flex flex-wrap gap-3 mt-2 text-sm text-muted-foreground">
|
||||
{originLocation && (
|
||||
<span className="flex items-center gap-1">
|
||||
<MapPin className="h-4 w-4" />
|
||||
{originLocation}
|
||||
</span>
|
||||
)}
|
||||
{band.formed_year && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="h-4 w-4" />
|
||||
Formed {band.formed_year}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{band.description && (
|
||||
<p className="max-w-3xl text-lg text-muted-foreground leading-relaxed">
|
||||
{band.long_description || band.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* External Links */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{band.website_url && (
|
||||
<Link href={band.website_url} target="_blank" className="inline-flex items-center gap-1 px-3 py-1 rounded-full bg-accent hover:bg-accent/80 text-sm">
|
||||
<Globe className="h-3 w-3" /> Website
|
||||
</Link>
|
||||
)}
|
||||
{band.nugs_url && (
|
||||
<Link href={band.nugs_url} target="_blank" className="inline-flex items-center gap-1 px-3 py-1 rounded-full bg-orange-500/20 hover:bg-orange-500/30 text-sm text-orange-600 dark:text-orange-400">
|
||||
<Music className="h-3 w-3" /> Nugs.net
|
||||
</Link>
|
||||
)}
|
||||
{band.relisten_url && (
|
||||
<Link href={band.relisten_url} target="_blank" className="inline-flex items-center gap-1 px-3 py-1 rounded-full bg-blue-500/20 hover:bg-blue-500/30 text-sm text-blue-600 dark:text-blue-400">
|
||||
<Music className="h-3 w-3" /> Relisten
|
||||
</Link>
|
||||
)}
|
||||
{band.spotify_url && (
|
||||
<Link href={band.spotify_url} target="_blank" className="inline-flex items-center gap-1 px-3 py-1 rounded-full bg-green-500/20 hover:bg-green-500/30 text-sm text-green-600 dark:text-green-400">
|
||||
<Music className="h-3 w-3" /> Spotify
|
||||
</Link>
|
||||
)}
|
||||
{band.wikipedia_url && (
|
||||
<Link href={band.wikipedia_url} target="_blank" className="inline-flex items-center gap-1 px-3 py-1 rounded-full bg-gray-500/20 hover:bg-gray-500/30 text-sm">
|
||||
<ExternalLink className="h-3 w-3" /> Wikipedia
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<Card>
|
||||
<CardContent className="pt-6 text-center">
|
||||
<div className="text-3xl font-bold">{stats.total_shows.toLocaleString()}</div>
|
||||
<div className="text-sm text-muted-foreground">Shows</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6 text-center">
|
||||
<div className="text-3xl font-bold">{stats.total_songs.toLocaleString()}</div>
|
||||
<div className="text-sm text-muted-foreground">Songs</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6 text-center">
|
||||
<div className="text-3xl font-bold">{stats.total_venues.toLocaleString()}</div>
|
||||
<div className="text-sm text-muted-foreground">Venues</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6 text-center">
|
||||
<div className="text-3xl font-bold">
|
||||
{stats.first_show && stats.last_show
|
||||
? new Date(stats.last_show).getFullYear() - new Date(stats.first_show).getFullYear() + 1
|
||||
: '—'
|
||||
}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">Active Years</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Members Section */}
|
||||
{(current_members.length > 0 || past_members.length > 0) && (
|
||||
<>
|
||||
<h2 className="text-2xl font-bold flex items-center gap-2">
|
||||
<Users className="h-6 w-6" /> Members
|
||||
</h2>
|
||||
<Tabs defaultValue="current" className="space-y-6">
|
||||
<TabsList>
|
||||
<TabsTrigger value="current">
|
||||
Current
|
||||
<Badge variant="secondary" className="ml-2">{current_members.length}</Badge>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="past">
|
||||
Past
|
||||
<Badge variant="secondary" className="ml-2">{past_members.length}</Badge>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="current" className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{current_members.map((member: any) => (
|
||||
<Link
|
||||
key={member.id}
|
||||
href={`/musicians/${member.slug}`}
|
||||
className="block group"
|
||||
>
|
||||
<Card className="h-full transition-colors group-hover:bg-accent/50">
|
||||
<CardContent className="pt-6 flex items-center gap-4">
|
||||
{member.image_url ? (
|
||||
<img
|
||||
src={member.image_url}
|
||||
alt={member.name}
|
||||
className="w-16 h-16 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-16 h-16 rounded-full bg-accent flex items-center justify-center text-xl font-bold">
|
||||
{member.name[0]}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<div className="font-semibold group-hover:text-primary transition-colors">
|
||||
{member.name}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{member.role || member.primary_instrument}
|
||||
</div>
|
||||
{member.start_date && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Since {new Date(member.start_date).getFullYear()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
{current_members.length === 0 && (
|
||||
<div className="col-span-full py-12 text-center text-muted-foreground">
|
||||
No current members listed.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="past" className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{past_members.map((member: any) => (
|
||||
<Link
|
||||
key={member.id}
|
||||
href={`/musicians/${member.slug}`}
|
||||
className="block group"
|
||||
>
|
||||
<Card className="h-full transition-colors group-hover:bg-accent/50">
|
||||
<CardContent className="pt-6 flex items-center gap-4">
|
||||
{member.image_url ? (
|
||||
<img
|
||||
src={member.image_url}
|
||||
alt={member.name}
|
||||
className="w-16 h-16 rounded-full object-cover opacity-75"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-16 h-16 rounded-full bg-accent flex items-center justify-center text-xl font-bold opacity-75">
|
||||
{member.name[0]}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<div className="font-semibold group-hover:text-primary transition-colors">
|
||||
{member.name}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{member.role || member.primary_instrument}
|
||||
</div>
|
||||
{(member.start_date || member.end_date) && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{member.start_date ? new Date(member.start_date).getFullYear() : '?'}
|
||||
{' - '}
|
||||
{member.end_date ? new Date(member.end_date).getFullYear() : '?'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
{past_members.length === 0 && (
|
||||
<div className="col-span-full py-12 text-center text-muted-foreground">
|
||||
No past members listed.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Quick Links */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<Link href={`/${band.slug}/shows`} className="block">
|
||||
<Card className="hover:bg-accent/50 transition-colors">
|
||||
<CardContent className="pt-6 text-center">
|
||||
<span className="font-semibold">All Shows</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
<Link href={`/${band.slug}/songs`} className="block">
|
||||
<Card className="hover:bg-accent/50 transition-colors">
|
||||
<CardContent className="pt-6 text-center">
|
||||
<span className="font-semibold">All Songs</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
<Link href={`/${band.slug}/venues`} className="block">
|
||||
<Card className="hover:bg-accent/50 transition-colors">
|
||||
<CardContent className="pt-6 text-center">
|
||||
<span className="font-semibold">All Venues</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
<Link href={`/${band.slug}/performances`} className="block">
|
||||
<Card className="hover:bg-accent/50 transition-colors">
|
||||
<CardContent className="pt-6 text-center">
|
||||
<span className="font-semibold">Top Performances</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
239
frontend/app/musicians/[slug]/page.tsx
Normal file
239
frontend/app/musicians/[slug]/page.tsx
Normal file
|
|
@ -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<Metadata> {
|
||||
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 (
|
||||
<div className="container py-8 space-y-8">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
{musician.image_url ? (
|
||||
<img
|
||||
src={musician.image_url}
|
||||
alt={musician.name}
|
||||
className="w-24 h-24 rounded-full object-cover border-2 border-primary/20"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-24 h-24 rounded-full bg-accent flex items-center justify-center text-3xl font-bold text-muted-foreground">
|
||||
{musician.name[0]}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold tracking-tight">{musician.name}</h1>
|
||||
{musician.primary_instrument && (
|
||||
<p className="text-muted-foreground flex items-center gap-1">
|
||||
<Music className="h-4 w-4" />
|
||||
{musician.primary_instrument}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{musician.bio && (
|
||||
<p className="max-w-3xl text-lg text-muted-foreground leading-relaxed">
|
||||
{musician.bio}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* External Links */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{musician.website_url && (
|
||||
<Link href={musician.website_url} target="_blank" className="inline-flex items-center gap-1 px-3 py-1 rounded-full bg-accent hover:bg-accent/80 text-sm">
|
||||
<Globe className="h-3 w-3" /> Website
|
||||
</Link>
|
||||
)}
|
||||
{musician.instagram_url && (
|
||||
<Link href={musician.instagram_url} target="_blank" className="inline-flex items-center gap-1 px-3 py-1 rounded-full bg-pink-500/20 hover:bg-pink-500/30 text-sm text-pink-600 dark:text-pink-400">
|
||||
<Instagram className="h-3 w-3" /> Instagram
|
||||
</Link>
|
||||
)}
|
||||
{musician.wikipedia_url && (
|
||||
<Link href={musician.wikipedia_url} target="_blank" className="inline-flex items-center gap-1 px-3 py-1 rounded-full bg-gray-500/20 hover:bg-gray-500/30 text-sm">
|
||||
<ExternalLink className="h-3 w-3" /> Wikipedia
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<Card>
|
||||
<CardContent className="pt-6 text-center">
|
||||
<div className="text-3xl font-bold">{stats?.total_bands || 0}</div>
|
||||
<div className="text-sm text-muted-foreground">Bands</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6 text-center">
|
||||
<div className="text-3xl font-bold">{stats?.current_bands || 0}</div>
|
||||
<div className="text-sm text-muted-foreground">Current</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6 text-center">
|
||||
<div className="text-3xl font-bold">{stats?.total_sit_ins || 0}</div>
|
||||
<div className="text-sm text-muted-foreground">Sit-Ins</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6 text-center">
|
||||
<div className="text-3xl font-bold">{stats?.bands_sat_in_with || 0}</div>
|
||||
<div className="text-sm text-muted-foreground">Bands Sat In With</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Band History */}
|
||||
{bands && bands.length > 0 && (
|
||||
<>
|
||||
<h2 className="text-2xl font-bold flex items-center gap-2">
|
||||
<Users className="h-6 w-6" /> Band History
|
||||
</h2>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{bands.map((band: any, i: number) => (
|
||||
<Link
|
||||
key={i}
|
||||
href={`/bands/${band.artist_slug || band.band_slug}`}
|
||||
className="block group"
|
||||
>
|
||||
<Card className={`h-full transition-colors group-hover:bg-accent/50 ${band.is_current ? 'border-primary/50' : ''}`}>
|
||||
<CardContent className="pt-6 flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-semibold group-hover:text-primary transition-colors flex items-center gap-2">
|
||||
{band.artist_name}
|
||||
{band.is_current && (
|
||||
<Badge variant="default" className="text-xs">Current</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{band.role}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground text-right">
|
||||
{band.start_date?.split('-')[0] || '?'}
|
||||
{' - '}
|
||||
{band.is_current ? 'Present' : (band.end_date?.split('-')[0] || '?')}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Sit-In Summary */}
|
||||
{sit_in_summary && sit_in_summary.length > 0 && (
|
||||
<>
|
||||
<h2 className="text-2xl font-bold flex items-center gap-2">
|
||||
<Music className="h-6 w-6" /> Sit-In Summary
|
||||
</h2>
|
||||
<div className="grid gap-4 md:grid-cols-3 lg:grid-cols-4">
|
||||
{sit_in_summary.map((band: any, i: number) => (
|
||||
<Link
|
||||
key={i}
|
||||
href={`/${band.vertical_slug}`}
|
||||
className="block group"
|
||||
>
|
||||
<Card className="h-full transition-colors group-hover:bg-accent/50">
|
||||
<CardContent className="pt-6 text-center">
|
||||
<div className="font-semibold group-hover:text-primary transition-colors">
|
||||
{band.vertical_name}
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-primary">
|
||||
{band.count}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
sit-in{band.count !== 1 ? 's' : ''}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Recent Guest Appearances */}
|
||||
{guest_appearances && guest_appearances.length > 0 && (
|
||||
<>
|
||||
<h2 className="text-2xl font-bold">Recent Guest Appearances</h2>
|
||||
<div className="border rounded-lg">
|
||||
<div className="divide-y">
|
||||
{guest_appearances.slice(0, 20).map((appearance: any, i: number) => (
|
||||
<div key={i} className="p-4 flex flex-col sm:flex-row sm:items-center justify-between gap-4 hover:bg-accent/50 transition-colors">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline">{appearance.vertical_name}</Badge>
|
||||
<Link
|
||||
href={`/shows/${appearance.performance_slug?.split('-').slice(0, 4).join('-') || '#'}`}
|
||||
className="font-semibold hover:underline"
|
||||
>
|
||||
{appearance.show_date}
|
||||
</Link>
|
||||
</div>
|
||||
{appearance.instrument && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Playing: {appearance.instrument}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
<span className="text-muted-foreground">Sat in on: </span>
|
||||
<span className="font-medium">{appearance.song_title}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{guest_appearances.length > 20 && (
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
Showing 20 of {guest_appearances.length} appearances
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue