feat(social): add profile poster, social handles, remove X
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
bd4c5bf215
commit
58f077268f
14 changed files with 841 additions and 4 deletions
45
backend/alembic/versions/409112776ded_add_social_handles.py
Normal file
45
backend/alembic/versions/409112776ded_add_social_handles.py
Normal file
|
|
@ -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 ###
|
||||||
36
backend/alembic/versions/ad5a56553d20_remove_x_handle.py
Normal file
36
backend/alembic/versions/ad5a56553d20_remove_x_handle.py
Normal file
|
|
@ -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 ###
|
||||||
|
|
@ -423,6 +423,12 @@ class User(SQLModel, table=True):
|
||||||
streak_days: int = Field(default=0, description="Consecutive days active")
|
streak_days: int = Field(default=0, description="Consecutive days active")
|
||||||
last_activity: Optional[datetime] = Field(default=None)
|
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 Titles & Flair (tracker forum style)
|
||||||
custom_title: Optional[str] = Field(default=None, description="Custom title chosen by user")
|
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")
|
title_color: Optional[str] = Field(default=None, description="Hex color for username display")
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ from sqlmodel import Session, select, func
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from database import get_session
|
from database import get_session
|
||||||
from models import User, Review, Attendance, Group, GroupMember, Show, UserPreferences, Profile
|
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
|
from auth import get_current_user
|
||||||
|
|
||||||
router = APIRouter(prefix="/users", tags=["users"])
|
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) ---
|
# --- 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)
|
@router.get("/{user_id}", response_model=UserRead)
|
||||||
def get_user_public(user_id: int, session: Session = Depends(get_session)):
|
def get_user_public(user_id: int, session: Session = Depends(get_session)):
|
||||||
"""Get public user profile"""
|
"""Get public user profile"""
|
||||||
|
|
|
||||||
|
|
@ -409,3 +409,38 @@ class ReactionRead(ReactionBase):
|
||||||
id: int
|
id: int
|
||||||
user_id: int
|
user_id: int
|
||||||
created_at: datetime
|
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
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import { AuthProvider } from "@/contexts/auth-context";
|
||||||
import { VerticalProvider } from "@/contexts/vertical-context";
|
import { VerticalProvider } from "@/contexts/vertical-context";
|
||||||
import { ThemeProvider } from "@/components/theme-provider";
|
import { ThemeProvider } from "@/components/theme-provider";
|
||||||
import { Footer } from "@/components/layout/footer";
|
import { Footer } from "@/components/layout/footer";
|
||||||
|
import { Toaster } from "@/components/ui/toaster";
|
||||||
import Script from "next/script";
|
import Script from "next/script";
|
||||||
|
|
||||||
const spaceGrotesk = Space_Grotesk({
|
const spaceGrotesk = Space_Grotesk({
|
||||||
|
|
@ -59,6 +60,7 @@ export default function RootLayout({
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
<Footer />
|
<Footer />
|
||||||
|
<Toaster />
|
||||||
</PreferencesProvider>
|
</PreferencesProvider>
|
||||||
</VerticalProvider>
|
</VerticalProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
|
|
|
||||||
41
frontend/app/profile/[username]/page.tsx
Normal file
41
frontend/app/profile/[username]/page.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
|
||||||
|
import { getApiUrl } from "@/lib/api-config"
|
||||||
|
import { ProfilePoster } from "@/components/profile/profile-poster"
|
||||||
|
import { notFound } from "next/navigation"
|
||||||
|
|
||||||
|
interface ProfilePageProps {
|
||||||
|
params: Promise<{
|
||||||
|
username: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getProfile(username: string) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${getApiUrl()}/users/profile/${username}`, {
|
||||||
|
next: { revalidate: 60 } // Cache for 1 minute
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.status === 404) return null
|
||||||
|
if (!res.ok) throw new Error("Failed to fetch profile")
|
||||||
|
|
||||||
|
return res.json()
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Profile fetch error:", error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function ProfilePage({ params }: ProfilePageProps) {
|
||||||
|
const { username } = await params
|
||||||
|
const profile = await getProfile(username)
|
||||||
|
|
||||||
|
if (!profile) {
|
||||||
|
notFound()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container py-8">
|
||||||
|
<ProfilePoster profile={profile} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -96,7 +96,7 @@ export function Navbar() {
|
||||||
{user.email}
|
{user.email}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<Link href="/profile">
|
<Link href={`/profile/${user.profile?.username || user.username || user.id}`}>
|
||||||
<DropdownMenuItem>Profile</DropdownMenuItem>
|
<DropdownMenuItem>Profile</DropdownMenuItem>
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/settings">
|
<Link href="/settings">
|
||||||
|
|
@ -189,7 +189,7 @@ export function Navbar() {
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
<Link
|
<Link
|
||||||
href="/profile"
|
href={`/profile/${user.profile?.username || user.username || user.id}`}
|
||||||
className="px-3 py-2 rounded-md hover:bg-muted transition-colors"
|
className="px-3 py-2 rounded-md hover:bg-muted transition-colors"
|
||||||
onClick={() => setMobileMenuOpen(false)}
|
onClick={() => setMobileMenuOpen(false)}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
207
frontend/components/profile/profile-poster.tsx
Normal file
207
frontend/components/profile/profile-poster.tsx
Normal file
|
|
@ -0,0 +1,207 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { PublicProfileRead } from "@/lib/types" // We'll need to define this or import generic
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Card } from "@/components/ui/card"
|
||||||
|
import { Separator } from "@/components/ui/separator"
|
||||||
|
import { MapPin, CalendarDays, Ticket, Music2, Share2 } from "lucide-react"
|
||||||
|
import Link from "next/link"
|
||||||
|
|
||||||
|
// Temporary type definition until we sync shared types
|
||||||
|
interface HeadlinerBand {
|
||||||
|
name: string
|
||||||
|
slug: string
|
||||||
|
tier: string
|
||||||
|
logo_url?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SocialHandles {
|
||||||
|
bluesky?: string | null
|
||||||
|
mastodon?: string | null
|
||||||
|
instagram?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProfilePosterProps {
|
||||||
|
profile: any // Typing loosely first to get the visual structure up
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProfilePoster({ profile }: ProfilePosterProps) {
|
||||||
|
const { username, display_name, bio, avatar, avatar_bg_color, location, social_handles, headliners, supporting_acts, stats, joined_at } = profile
|
||||||
|
|
||||||
|
// Poster Date (Joined At)
|
||||||
|
const memberSince = new Date(joined_at).toLocaleDateString('en-US', { month: 'long', year: 'numeric' })
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto space-y-8">
|
||||||
|
{/* THE POSTER */}
|
||||||
|
<div className="relative overflow-hidden rounded-xl border bg-card text-card-foreground shadow-2xl">
|
||||||
|
{/* Background Texture/Gradient */}
|
||||||
|
<div className="absolute inset-0 z-0 bg-gradient-to-br from-background via-background to-primary/5 opacity-50" />
|
||||||
|
<div className="absolute inset-0 z-0 bg-[url('/noise.png')] opacity-[0.03] mix-blend-overlay" />
|
||||||
|
|
||||||
|
<div className="relative z-10 p-8 md:p-12 flex flex-col md:flex-row gap-12 items-start md:items-center">
|
||||||
|
|
||||||
|
{/* LEFT: The "Lineup" (Typography Hierarchy) */}
|
||||||
|
<div className="flex-1 text-center md:text-left space-y-8 w-full">
|
||||||
|
|
||||||
|
{/* Top Billing: User Name */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h1 className="text-5xl md:text-7xl font-black tracking-tighter uppercase leading-none bg-clip-text text-transparent bg-gradient-to-b from-foreground to-foreground/70 filter drop-shadow-sm">
|
||||||
|
{display_name}
|
||||||
|
</h1>
|
||||||
|
<p className="text-xl md:text-2xl text-muted-foreground font-light tracking-widest uppercase">
|
||||||
|
Presents
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator className="bg-primary/20" />
|
||||||
|
|
||||||
|
{/* Headliners */}
|
||||||
|
{headliners && headliners.length > 0 ? (
|
||||||
|
<div className="flex flex-wrap justify-center md:justify-start gap-x-6 gap-y-2">
|
||||||
|
{headliners.map((band: HeadlinerBand) => (
|
||||||
|
<Link key={band.slug} href={`/${band.slug}`} className="hover:scale-105 transition-transform">
|
||||||
|
<span className="text-3xl md:text-5xl font-bold tracking-tight text-primary hover:text-primary/80 uppercase">
|
||||||
|
{band.name}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-3xl font-bold text-muted-foreground/30 uppercase opacity-50 py-4">
|
||||||
|
No Headliners Announced
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Supporting Acts */}
|
||||||
|
{supporting_acts && supporting_acts.length > 0 && (
|
||||||
|
<div className="flex flex-wrap justify-center md:justify-start gap-x-4 gap-y-1 pt-4">
|
||||||
|
{supporting_acts.map((band: HeadlinerBand) => (
|
||||||
|
<Link key={band.slug} href={`/${band.slug}`}>
|
||||||
|
<span className="text-lg md:text-2xl font-medium text-foreground/80 hover:text-foreground uppercase">
|
||||||
|
{band.name}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Location / Date Line */}
|
||||||
|
<div className="flex flex-wrap items-center justify-center md:justify-start gap-4 text-sm font-mono text-muted-foreground pt-6 uppercase tracking-wider">
|
||||||
|
{location && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<MapPin className="w-4 h-4" /> {location}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<CalendarDays className="w-4 h-4" /> Est. {memberSince}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* RIGHT: The "Pass" (Avatar) */}
|
||||||
|
<div className="flex-shrink-0 mx-auto md:mx-0">
|
||||||
|
<div className="relative group perspective-1000">
|
||||||
|
{/* Lanyard String Mockup */}
|
||||||
|
<div className="absolute -top-32 left-1/2 -translate-x-1/2 w-4 h-32 bg-primary/20 z-0 hidden md:block" />
|
||||||
|
|
||||||
|
<div className="relative w-64 h-80 bg-black rounded-xl border-4 border-primary/20 shadow-2xl overflow-hidden flex flex-col transform transition-transform group-hover:rotate-y-12 duration-500">
|
||||||
|
{/* Pass Header */}
|
||||||
|
<div className="h-12 bg-primary/90 flex items-center justify-center">
|
||||||
|
<span className="font-bold text-primary-foreground tracking-[0.2em] text-xs">ALL ACCESS</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pass Image */}
|
||||||
|
<div className="flex-1 relative bg-muted">
|
||||||
|
{avatar ? (
|
||||||
|
<img src={avatar} alt={username} className="w-full h-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full flex items-center justify-center bg-zinc-900 text-6xl font-bold text-zinc-700" style={{ backgroundColor: avatar_bg_color || '#333' }}>
|
||||||
|
{profile.avatar_text || username.substring(0, 2).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pass Footer */}
|
||||||
|
<div className="h-16 bg-white/5 backdrop-blur-md p-2 flex flex-col items-center justify-center gap-1">
|
||||||
|
<span className="font-mono text-xs text-muted-foreground">@{username}</span>
|
||||||
|
<div className="flex gap-2 text-xs opacity-50">
|
||||||
|
<div className="w-16 h-4 bg-foreground/20 rounded-sm" /> {/* Barcode mockup */}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* LOWER BIO SECTION */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||||
|
{/* Stats Card */}
|
||||||
|
<Card className="p-6 space-y-4">
|
||||||
|
<h3 className="font-mono text-sm text-muted-foreground uppercase">Tour Stats</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="flex items-center gap-2"><Ticket className="w-4 h-4" /> Shows</span>
|
||||||
|
<span className="font-bold text-xl">{stats.shows_attended}</span>
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="flex items-center gap-2"><MapPin className="w-4 h-4" /> Venues</span>
|
||||||
|
<span className="font-bold text-xl">{stats.venues_visited}</span>
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="flex items-center gap-2"><Music2 className="w-4 h-4" /> Reviews</span>
|
||||||
|
<span className="font-bold text-xl">{stats.reviews_written}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Bio & Socials */}
|
||||||
|
<Card className="md:col-span-2 p-6 flex flex-col justify-between gap-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="font-mono text-sm text-muted-foreground uppercase">About</h3>
|
||||||
|
<p className="text-lg leading-relaxed whitespace-pre-wrap">
|
||||||
|
{bio || "This fan hasn't written a bio yet. They're probably at a show."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
{social_handles.bluesky && (
|
||||||
|
<Button variant="outline" size="sm" asChild>
|
||||||
|
<a href={`https://bsky.app/profile/${social_handles.bluesky}`} target="_blank" rel="noopener noreferrer">
|
||||||
|
BlueSky
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{social_handles.mastodon && (
|
||||||
|
<Button variant="outline" size="sm" asChild>
|
||||||
|
<a href={social_handles.mastodon} target="_blank" rel="noopener noreferrer">
|
||||||
|
Mastodon
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{social_handles.instagram && (
|
||||||
|
<Button variant="outline" size="sm" asChild>
|
||||||
|
<a href={`https://instagram.com/${social_handles.instagram}`} target="_blank" rel="noopener noreferrer">
|
||||||
|
Instagram
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex-1" />
|
||||||
|
|
||||||
|
<Button variant="ghost" size="sm">
|
||||||
|
<Share2 className="w-4 h-4 mr-2" /> Share Profile
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
129
frontend/components/ui/toast.tsx
Normal file
129
frontend/components/ui/toast.tsx
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as ToastPrimitives from "@radix-ui/react-toast"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
import { X } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const ToastProvider = ToastPrimitives.Provider
|
||||||
|
|
||||||
|
const ToastViewport = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Viewport>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Viewport
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
|
||||||
|
|
||||||
|
const toastVariants = cva(
|
||||||
|
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "border bg-background text-foreground",
|
||||||
|
destructive:
|
||||||
|
"destructive group border-destructive bg-destructive text-destructive-foreground",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const Toast = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
|
||||||
|
VariantProps<typeof toastVariants>
|
||||||
|
>(({ className, variant, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<ToastPrimitives.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(toastVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
Toast.displayName = ToastPrimitives.Root.displayName
|
||||||
|
|
||||||
|
const ToastAction = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Action>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Action
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
ToastAction.displayName = ToastPrimitives.Action.displayName
|
||||||
|
|
||||||
|
const ToastClose = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Close>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Close
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
toast-close=""
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</ToastPrimitives.Close>
|
||||||
|
))
|
||||||
|
ToastClose.displayName = ToastPrimitives.Close.displayName
|
||||||
|
|
||||||
|
const ToastTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
ToastTitle.displayName = ToastPrimitives.Title.displayName
|
||||||
|
|
||||||
|
const ToastDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm opacity-90", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
ToastDescription.displayName = ToastPrimitives.Description.displayName
|
||||||
|
|
||||||
|
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
|
||||||
|
|
||||||
|
type ToastActionElement = React.ReactElement<typeof ToastAction>
|
||||||
|
|
||||||
|
export {
|
||||||
|
type ToastProps,
|
||||||
|
type ToastActionElement,
|
||||||
|
ToastProvider,
|
||||||
|
ToastViewport,
|
||||||
|
Toast,
|
||||||
|
ToastTitle,
|
||||||
|
ToastDescription,
|
||||||
|
ToastClose,
|
||||||
|
ToastAction,
|
||||||
|
}
|
||||||
35
frontend/components/ui/toaster.tsx
Normal file
35
frontend/components/ui/toaster.tsx
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import {
|
||||||
|
Toast,
|
||||||
|
ToastClose,
|
||||||
|
ToastDescription,
|
||||||
|
ToastProvider,
|
||||||
|
ToastTitle,
|
||||||
|
ToastViewport,
|
||||||
|
} from "@/components/ui/toast"
|
||||||
|
import { useToast } from "@/components/ui/use-toast"
|
||||||
|
|
||||||
|
export function Toaster() {
|
||||||
|
const { toasts } = useToast()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToastProvider>
|
||||||
|
{toasts.map(function ({ id, title, description, action, ...props }) {
|
||||||
|
return (
|
||||||
|
<Toast key={id} {...props}>
|
||||||
|
<div className="grid gap-1">
|
||||||
|
{title && <ToastTitle>{title}</ToastTitle>}
|
||||||
|
{description && (
|
||||||
|
<ToastDescription>{description}</ToastDescription>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{action}
|
||||||
|
<ToastClose />
|
||||||
|
</Toast>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
<ToastViewport />
|
||||||
|
</ToastProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
197
frontend/components/ui/use-toast.ts
Normal file
197
frontend/components/ui/use-toast.ts
Normal file
|
|
@ -0,0 +1,197 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ToastActionElement,
|
||||||
|
ToastProps,
|
||||||
|
} from "@/components/ui/toast"
|
||||||
|
|
||||||
|
const TOAST_LIMIT = 1
|
||||||
|
const TOAST_REMOVE_DELAY = 1000000
|
||||||
|
|
||||||
|
type ToasterToast = ToastProps & {
|
||||||
|
id: string
|
||||||
|
title?: React.ReactNode
|
||||||
|
description?: React.ReactNode
|
||||||
|
action?: ToastActionElement
|
||||||
|
}
|
||||||
|
|
||||||
|
const actionTypes = {
|
||||||
|
ADD_TOAST: "ADD_TOAST",
|
||||||
|
UPDATE_TOAST: "UPDATE_TOAST",
|
||||||
|
DISMISS_TOAST: "DISMISS_TOAST",
|
||||||
|
REMOVE_TOAST: "REMOVE_TOAST",
|
||||||
|
} as const
|
||||||
|
|
||||||
|
let count = 0
|
||||||
|
|
||||||
|
function genId() {
|
||||||
|
count = (count + 1) % Number.MAX_SAFE_INTEGER
|
||||||
|
return count.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
type ActionType = typeof actionTypes
|
||||||
|
|
||||||
|
type Action =
|
||||||
|
| {
|
||||||
|
type: ActionType["ADD_TOAST"]
|
||||||
|
toast: ToasterToast
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: ActionType["UPDATE_TOAST"]
|
||||||
|
toast: Partial<ToasterToast>
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: ActionType["DISMISS_TOAST"]
|
||||||
|
toastId?: ToasterToast["id"]
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: ActionType["REMOVE_TOAST"]
|
||||||
|
toastId?: ToasterToast["id"]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
toasts: ToasterToast[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
|
||||||
|
|
||||||
|
const addToRemoveQueue = (toastId: string) => {
|
||||||
|
if (toastTimeouts.has(toastId)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
toastTimeouts.delete(toastId)
|
||||||
|
dispatch({
|
||||||
|
type: "REMOVE_TOAST",
|
||||||
|
toastId,
|
||||||
|
})
|
||||||
|
}, TOAST_REMOVE_DELAY)
|
||||||
|
|
||||||
|
toastTimeouts.set(toastId, timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const reducer = (state: State, action: Action): State => {
|
||||||
|
switch (action.type) {
|
||||||
|
case "ADD_TOAST":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
|
||||||
|
}
|
||||||
|
|
||||||
|
case "UPDATE_TOAST":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: state.toasts.map((t) =>
|
||||||
|
t.id === action.toast.id ? { ...t, ...action.toast } : t
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
case "DISMISS_TOAST": {
|
||||||
|
const { toastId } = action
|
||||||
|
|
||||||
|
// ! Side effects ! - This could be extracted into a dismissToast() action,
|
||||||
|
// but I'll keep it here for simplicity
|
||||||
|
if (toastId) {
|
||||||
|
addToRemoveQueue(toastId)
|
||||||
|
} else {
|
||||||
|
state.toasts.forEach((toast) => {
|
||||||
|
addToRemoveQueue(toast.id)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: state.toasts.map((t) =>
|
||||||
|
t.id === toastId || action.toastId === undefined
|
||||||
|
? {
|
||||||
|
...t,
|
||||||
|
open: false,
|
||||||
|
}
|
||||||
|
: t
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "REMOVE_TOAST":
|
||||||
|
if (action.toastId === undefined) {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: state.toasts.filter((t) => t.id !== action.toastId),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const listeners: Array<(state: State) => void> = []
|
||||||
|
|
||||||
|
let memoryState: State = { toasts: [] }
|
||||||
|
|
||||||
|
function dispatch(action: Action) {
|
||||||
|
memoryState = reducer(memoryState, action)
|
||||||
|
listeners.forEach((listener) => {
|
||||||
|
listener(memoryState)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type Toast = Omit<ToasterToast, "id">
|
||||||
|
|
||||||
|
function toast({ ...props }: Toast) {
|
||||||
|
const id = genId()
|
||||||
|
|
||||||
|
const update = (props: ToasterToast) =>
|
||||||
|
dispatch({
|
||||||
|
type: "UPDATE_TOAST",
|
||||||
|
toast: { ...props, id },
|
||||||
|
})
|
||||||
|
const dismiss = () =>
|
||||||
|
dispatch({
|
||||||
|
type: "DISMISS_TOAST",
|
||||||
|
toastId: id,
|
||||||
|
})
|
||||||
|
|
||||||
|
dispatch({
|
||||||
|
type: "ADD_TOAST",
|
||||||
|
toast: {
|
||||||
|
...props,
|
||||||
|
id,
|
||||||
|
open: true,
|
||||||
|
onOpenChange: (open) => {
|
||||||
|
if (!open) dismiss()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
dismiss,
|
||||||
|
update,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function useToast() {
|
||||||
|
const [state, setState] = React.useState<State>(memoryState)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
listeners.push(setState)
|
||||||
|
return () => {
|
||||||
|
const index = listeners.indexOf(setState)
|
||||||
|
if (index > -1) {
|
||||||
|
listeners.splice(index, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [state])
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toast,
|
||||||
|
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { useToast, toast }
|
||||||
35
frontend/package-lock.json
generated
35
frontend/package-lock.json
generated
|
|
@ -19,6 +19,7 @@
|
||||||
"@radix-ui/react-select": "^2.1.7",
|
"@radix-ui/react-select": "^2.1.7",
|
||||||
"@radix-ui/react-switch": "^1.1.5",
|
"@radix-ui/react-switch": "^1.1.5",
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
|
"@radix-ui/react-toast": "^1.2.15",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
|
|
@ -3111,6 +3112,40 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-toast": {
|
||||||
|
"version": "1.2.15",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.15.tgz",
|
||||||
|
"integrity": "sha512-3OSz3TacUWy4WtOXV38DggwxoqJK4+eDkNMl5Z/MJZaoUPaP4/9lf81xXMe1I2ReTAptverZUpbPY4wWwWyL5g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/primitive": "1.1.3",
|
||||||
|
"@radix-ui/react-collection": "1.1.7",
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2",
|
||||||
|
"@radix-ui/react-context": "1.1.2",
|
||||||
|
"@radix-ui/react-dismissable-layer": "1.1.11",
|
||||||
|
"@radix-ui/react-portal": "1.1.9",
|
||||||
|
"@radix-ui/react-presence": "1.1.5",
|
||||||
|
"@radix-ui/react-primitive": "2.1.3",
|
||||||
|
"@radix-ui/react-use-callback-ref": "1.1.1",
|
||||||
|
"@radix-ui/react-use-controllable-state": "1.2.2",
|
||||||
|
"@radix-ui/react-use-layout-effect": "1.1.1",
|
||||||
|
"@radix-ui/react-visually-hidden": "1.2.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-use-callback-ref": {
|
"node_modules/@radix-ui/react-use-callback-ref": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@
|
||||||
"@radix-ui/react-select": "^2.1.7",
|
"@radix-ui/react-select": "^2.1.7",
|
||||||
"@radix-ui/react-switch": "^1.1.5",
|
"@radix-ui/react-switch": "^1.1.5",
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
|
"@radix-ui/react-toast": "^1.2.15",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue