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}
+
diff --git a/frontend/app/profile/[username]/page.tsx b/frontend/app/profile/[username]/page.tsx
new file mode 100644
index 0000000..6409e91
--- /dev/null
+++ b/frontend/app/profile/[username]/page.tsx
@@ -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 (
+
+ )
+}
diff --git a/frontend/components/layout/navbar.tsx b/frontend/components/layout/navbar.tsx
index afe21dc..8a0d661 100644
--- a/frontend/components/layout/navbar.tsx
+++ b/frontend/components/layout/navbar.tsx
@@ -96,7 +96,7 @@ export function Navbar() {
{user.email}
-
+
Profile
@@ -189,7 +189,7 @@ export function Navbar() {
)}
setMobileMenuOpen(false)}
>
diff --git a/frontend/components/profile/profile-poster.tsx b/frontend/components/profile/profile-poster.tsx
new file mode 100644
index 0000000..c2eef05
--- /dev/null
+++ b/frontend/components/profile/profile-poster.tsx
@@ -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 (
+
+ {/* THE POSTER */}
+
+ {/* Background Texture/Gradient */}
+
+
+
+
+
+ {/* LEFT: The "Lineup" (Typography Hierarchy) */}
+
+
+ {/* Top Billing: User Name */}
+
+
+ {display_name}
+
+
+ Presents
+
+
+
+
+
+ {/* Headliners */}
+ {headliners && headliners.length > 0 ? (
+
+ {headliners.map((band: HeadlinerBand) => (
+
+
+ {band.name}
+
+
+ ))}
+
+ ) : (
+
+ No Headliners Announced
+
+ )}
+
+ {/* Supporting Acts */}
+ {supporting_acts && supporting_acts.length > 0 && (
+
+ {supporting_acts.map((band: HeadlinerBand) => (
+
+
+ {band.name}
+
+
+ ))}
+
+ )}
+
+ {/* Location / Date Line */}
+
+ {location && (
+
+ {location}
+
+ )}
+
+ Est. {memberSince}
+
+
+
+
+
+ {/* RIGHT: The "Pass" (Avatar) */}
+
+
+ {/* Lanyard String Mockup */}
+
+
+
+ {/* Pass Header */}
+
+ ALL ACCESS
+
+
+ {/* Pass Image */}
+
+ {avatar ? (
+

+ ) : (
+
+ {profile.avatar_text || username.substring(0, 2).toUpperCase()}
+
+ )}
+
+
+ {/* Pass Footer */}
+
+
@{username}
+
+
{/* Barcode mockup */}
+
+
+
+
+
+
+
+
+
+ {/* LOWER BIO SECTION */}
+
+ {/* Stats Card */}
+
+ Tour Stats
+
+
+ Shows
+ {stats.shows_attended}
+
+
+
+ Venues
+ {stats.venues_visited}
+
+
+
+ Reviews
+ {stats.reviews_written}
+
+
+
+
+ {/* Bio & Socials */}
+
+
+
About
+
+ {bio || "This fan hasn't written a bio yet. They're probably at a show."}
+
+
+
+
+ {social_handles.bluesky && (
+
+ )}
+ {social_handles.mastodon && (
+
+ )}
+ {social_handles.instagram && (
+
+ )}
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/frontend/components/ui/toast.tsx b/frontend/components/ui/toast.tsx
new file mode 100644
index 0000000..fb356cd
--- /dev/null
+++ b/frontend/components/ui/toast.tsx
@@ -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,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+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,
+ React.ComponentPropsWithoutRef &
+ VariantProps
+>(({ className, variant, ...props }, ref) => {
+ return (
+
+ )
+})
+Toast.displayName = ToastPrimitives.Root.displayName
+
+const ToastAction = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+ToastAction.displayName = ToastPrimitives.Action.displayName
+
+const ToastClose = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+))
+ToastClose.displayName = ToastPrimitives.Close.displayName
+
+const ToastTitle = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+ToastTitle.displayName = ToastPrimitives.Title.displayName
+
+const ToastDescription = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+ToastDescription.displayName = ToastPrimitives.Description.displayName
+
+type ToastProps = React.ComponentPropsWithoutRef
+
+type ToastActionElement = React.ReactElement
+
+export {
+ type ToastProps,
+ type ToastActionElement,
+ ToastProvider,
+ ToastViewport,
+ Toast,
+ ToastTitle,
+ ToastDescription,
+ ToastClose,
+ ToastAction,
+}
diff --git a/frontend/components/ui/toaster.tsx b/frontend/components/ui/toaster.tsx
new file mode 100644
index 0000000..9d44e82
--- /dev/null
+++ b/frontend/components/ui/toaster.tsx
@@ -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 (
+
+ {toasts.map(function ({ id, title, description, action, ...props }) {
+ return (
+
+
+ {title && {title}}
+ {description && (
+ {description}
+ )}
+
+ {action}
+
+
+ )
+ })}
+
+
+ )
+}
diff --git a/frontend/components/ui/use-toast.ts b/frontend/components/ui/use-toast.ts
new file mode 100644
index 0000000..84fbf2f
--- /dev/null
+++ b/frontend/components/ui/use-toast.ts
@@ -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
+ }
+ | {
+ type: ActionType["DISMISS_TOAST"]
+ toastId?: ToasterToast["id"]
+ }
+ | {
+ type: ActionType["REMOVE_TOAST"]
+ toastId?: ToasterToast["id"]
+ }
+
+interface State {
+ toasts: ToasterToast[]
+}
+
+const toastTimeouts = new Map>()
+
+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
+
+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(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 }
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index f5004e5..5393d8c 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -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",
diff --git a/frontend/package.json b/frontend/package.json
index b202d5f..b1a08d5 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -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",
@@ -50,4 +51,4 @@
"tw-animate-css": "^1.4.0",
"typescript": "^5"
}
-}
\ No newline at end of file
+}