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")
|
||||
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")
|
||||
|
|
|
|||
|
|
@ -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"""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
</main>
|
||||
<Footer />
|
||||
<Toaster />
|
||||
</PreferencesProvider>
|
||||
</VerticalProvider>
|
||||
</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}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<Link href="/profile">
|
||||
<Link href={`/profile/${user.profile?.username || user.username || user.id}`}>
|
||||
<DropdownMenuItem>Profile</DropdownMenuItem>
|
||||
</Link>
|
||||
<Link href="/settings">
|
||||
|
|
@ -189,7 +189,7 @@ export function Navbar() {
|
|||
</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"
|
||||
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-switch": "^1.1.5",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-toast": "^1.2.15",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.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": {
|
||||
"version": "1.1.1",
|
||||
"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-switch": "^1.1.5",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-toast": "^1.2.15",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue