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 +

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