From 58f077268f6a67ef4f07a1fef0b7fe90f49d5b8c Mon Sep 17 00:00:00 2001 From: fullsizemalt <106900403+fullsizemalt@users.noreply.github.com> Date: Mon, 29 Dec 2025 21:05:34 -0800 Subject: [PATCH] feat(social): add profile poster, social handles, remove X --- .../409112776ded_add_social_handles.py | 45 ++++ .../versions/ad5a56553d20_remove_x_handle.py | 36 +++ backend/models.py | 6 + backend/routers/users.py | 70 +++++- backend/schemas.py | 35 +++ frontend/app/layout.tsx | 2 + frontend/app/profile/[username]/page.tsx | 41 ++++ frontend/components/layout/navbar.tsx | 4 +- .../components/profile/profile-poster.tsx | 207 ++++++++++++++++++ frontend/components/ui/toast.tsx | 129 +++++++++++ frontend/components/ui/toaster.tsx | 35 +++ frontend/components/ui/use-toast.ts | 197 +++++++++++++++++ frontend/package-lock.json | 35 +++ frontend/package.json | 3 +- 14 files changed, 841 insertions(+), 4 deletions(-) create mode 100644 backend/alembic/versions/409112776ded_add_social_handles.py create mode 100644 backend/alembic/versions/ad5a56553d20_remove_x_handle.py create mode 100644 frontend/app/profile/[username]/page.tsx create mode 100644 frontend/components/profile/profile-poster.tsx create mode 100644 frontend/components/ui/toast.tsx create mode 100644 frontend/components/ui/toaster.tsx create mode 100644 frontend/components/ui/use-toast.ts diff --git a/backend/alembic/versions/409112776ded_add_social_handles.py b/backend/alembic/versions/409112776ded_add_social_handles.py new file mode 100644 index 0000000..86752f5 --- /dev/null +++ b/backend/alembic/versions/409112776ded_add_social_handles.py @@ -0,0 +1,45 @@ +"""add_social_handles + +Revision ID: 409112776ded +Revises: b1ca95289d88 +Create Date: 2025-12-29 20:46:30.443972 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes + + +# revision identifiers, used by Alembic. +revision: str = '409112776ded' +down_revision: Union[str, Sequence[str], None] = 'b1ca95289d88' +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! ### + with op.batch_alter_table('user', schema=None) as batch_op: + batch_op.add_column(sa.Column('bluesky_handle', sqlmodel.sql.sqltypes.AutoString(), nullable=True)) + batch_op.add_column(sa.Column('mastodon_handle', sqlmodel.sql.sqltypes.AutoString(), nullable=True)) + batch_op.add_column(sa.Column('instagram_handle', sqlmodel.sql.sqltypes.AutoString(), nullable=True)) + batch_op.add_column(sa.Column('x_handle', sqlmodel.sql.sqltypes.AutoString(), nullable=True)) + batch_op.add_column(sa.Column('location', sqlmodel.sql.sqltypes.AutoString(), nullable=True)) + + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('user', schema=None) as batch_op: + batch_op.drop_column('location') + batch_op.drop_column('x_handle') + batch_op.drop_column('instagram_handle') + batch_op.drop_column('mastodon_handle') + batch_op.drop_column('bluesky_handle') + + # ### end Alembic commands ### diff --git a/backend/alembic/versions/ad5a56553d20_remove_x_handle.py b/backend/alembic/versions/ad5a56553d20_remove_x_handle.py new file mode 100644 index 0000000..fc34750 --- /dev/null +++ b/backend/alembic/versions/ad5a56553d20_remove_x_handle.py @@ -0,0 +1,36 @@ +"""remove_x_handle + +Revision ID: ad5a56553d20 +Revises: 409112776ded +Create Date: 2025-12-29 21:01:08.011913 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'ad5a56553d20' +down_revision: Union[str, Sequence[str], None] = '409112776ded' +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! ### + with op.batch_alter_table('user', schema=None) as batch_op: + batch_op.drop_column('x_handle') + + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('user', schema=None) as batch_op: + batch_op.add_column(sa.Column('x_handle', sa.VARCHAR(), nullable=True)) + + # ### end Alembic commands ### diff --git a/backend/models.py b/backend/models.py index f6580c5..209887d 100644 --- a/backend/models.py +++ b/backend/models.py @@ -423,6 +423,12 @@ class User(SQLModel, table=True): streak_days: int = Field(default=0, description="Consecutive days active") last_activity: Optional[datetime] = Field(default=None) + # Social Identity + bluesky_handle: Optional[str] = Field(default=None) + mastodon_handle: Optional[str] = Field(default=None) + instagram_handle: Optional[str] = Field(default=None) + location: Optional[str] = Field(default=None, description="User's local scene/city") + # Custom Titles & Flair (tracker forum style) custom_title: Optional[str] = Field(default=None, description="Custom title chosen by user") title_color: Optional[str] = Field(default=None, description="Hex color for username display") diff --git a/backend/routers/users.py b/backend/routers/users.py index 6737649..5b365ed 100644 --- a/backend/routers/users.py +++ b/backend/routers/users.py @@ -4,7 +4,7 @@ from sqlmodel import Session, select, func from pydantic import BaseModel from database import get_session from models import User, Review, Attendance, Group, GroupMember, Show, UserPreferences, Profile -from schemas import UserRead, ReviewRead, ShowRead, GroupRead, UserPreferencesUpdate +from schemas import UserRead, ReviewRead, ShowRead, GroupRead, UserPreferencesUpdate, PublicProfileRead, SocialHandles, HeadlinerBand from auth import get_current_user router = APIRouter(prefix="/users", tags=["users"]) @@ -402,6 +402,74 @@ def delete_my_account( # --- Dynamic ID Routes (must be last to avoid conflicts with /me, /avatar) --- +@router.get("/profile/{username}", response_model=PublicProfileRead) +def get_public_profile(username: str, session: Session = Depends(get_session)): + """Get rich public profile for poster view""" + # 1. Find profile by username + profile = session.exec(select(Profile).where(Profile.username == username)).first() + + # Fallback: check if username matches a user email prefix (legacy/fallback) + # or just 404. Let's start with strict Profile lookup. + if not profile: + # Try to find by User ID if it looks like an int? No, username is string. + raise HTTPException(status_code=404, detail="User not found") + + user = session.get(User, profile.user_id) + if not user: + raise HTTPException(status_code=404, detail="User not found") + + # 2. Get Stats + attendance_count = session.exec(select(func.count(Attendance.id)).where(Attendance.user_id == user.id)).one() + review_count = session.exec(select(func.count(Review.id)).where(Review.user_id == user.id)).one() + unique_venues = session.exec(select(func.count(func.distinct(Show.venue_id))).join(Attendance).where(Attendance.user_id == user.id)).one() + + # 3. Get Headliners (Preferences) + headliners = [] + supporting = [] + + # Sort prefs by priority/tier + # We need to eager load vertical + if user.vertical_preferences: + for pref in user.vertical_preferences: + band_data = HeadlinerBand( + name=pref.vertical.name, + slug=pref.vertical.slug, + tier=pref.tier, + logo_url=pref.vertical.logo_url + ) + if pref.tier == "headliner": + headliners.append(band_data) + else: + supporting.append(band_data) + + # Social Handles + socials = SocialHandles( + bluesky=user.bluesky_handle, + mastodon=user.mastodon_handle, + instagram=user.instagram_handle + ) + + return PublicProfileRead( + id=user.id, + username=profile.username, + display_name=profile.display_name or profile.username, + bio=user.bio, + avatar=user.avatar, + avatar_bg_color=user.avatar_bg_color, + avatar_text=user.avatar_text, + location=user.location, + social_handles=socials, + headliners=headliners, + supporting_acts=supporting, + stats={ + "shows_attended": attendance_count, + "reviews_written": review_count, + "venues_visited": unique_venues + }, + joined_at=user.joined_at + ) + + @router.get("/{user_id}", response_model=UserRead) def get_user_public(user_id: int, session: Session = Depends(get_session)): """Get public user profile""" diff --git a/backend/schemas.py b/backend/schemas.py index 646a789..d32d40c 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -409,3 +409,38 @@ class ReactionRead(ReactionBase): id: int user_id: int created_at: datetime + +# --- Profile Schemas --- + +class SocialHandles(SQLModel): + bluesky: Optional[str] = None + mastodon: Optional[str] = None + instagram: Optional[str] = None + +class HeadlinerBand(SQLModel): + name: str + slug: str + tier: str # headliner, main_stage, supporting + logo_url: Optional[str] = None + +class PublicProfileRead(SQLModel): + id: int + username: str + display_name: str + bio: Optional[str] = None + avatar: Optional[str] = None + avatar_bg_color: Optional[str] = None + avatar_text: Optional[str] = None + location: Optional[str] = None + + # Socials + social_handles: SocialHandles + + # The Lineup + headliners: List[HeadlinerBand] + supporting_acts: List[HeadlinerBand] + + # Stats + stats: Dict[str, int] + + joined_at: datetime diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index cd7300d..332e5d3 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -8,6 +8,7 @@ import { AuthProvider } from "@/contexts/auth-context"; import { VerticalProvider } from "@/contexts/vertical-context"; import { ThemeProvider } from "@/components/theme-provider"; import { Footer } from "@/components/layout/footer"; +import { Toaster } from "@/components/ui/toaster"; import Script from "next/script"; const spaceGrotesk = Space_Grotesk({ @@ -59,6 +60,7 @@ export default function RootLayout({ {children}