feat(bands): add My Bands page with tier management and IGNORED tier
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s

This commit is contained in:
fullsizemalt 2025-12-29 21:49:28 -08:00
parent e07c23aceb
commit 212082050c
3 changed files with 342 additions and 0 deletions

View file

@ -327,6 +327,7 @@ class PreferenceTier(str, Enum):
HEADLINER = "headliner" HEADLINER = "headliner"
MAIN_STAGE = "main_stage" MAIN_STAGE = "main_stage"
SUPPORTING = "supporting" SUPPORTING = "supporting"
IGNORED = "ignored" # Exclude from feeds, keep attribution mentions
class UserVerticalPreference(SQLModel, table=True): class UserVerticalPreference(SQLModel, table=True):
"""User preferences for which bands to display prominently vs. attribution-only""" """User preferences for which bands to display prominently vs. attribution-only"""

View file

@ -0,0 +1,338 @@
"use client"
import { useState, useEffect } from "react"
import { useRouter } from "next/navigation"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { useAuth } from "@/contexts/auth-context"
import { getApiUrl } from "@/lib/api-config"
import { Star, Check, Ban, Loader2, Music2 } from "lucide-react"
import Link from "next/link"
interface Vertical {
id: number
name: string
slug: string
description: string | null
color: string | null
emoji: string | null
}
interface UserPreference {
vertical_id: number
tier: "headliner" | "main_stage" | "supporting" | "ignored"
priority: number
}
type TierType = "headliner" | "main_stage" | "supporting" | "ignored" | null
export default function MyBandsPage() {
const [verticals, setVerticals] = useState<Vertical[]>([])
const [preferences, setPreferences] = useState<Map<number, UserPreference>>(new Map())
const [loading, setLoading] = useState(true)
const [updating, setUpdating] = useState<number | null>(null)
const { user, token } = useAuth()
const router = useRouter()
useEffect(() => {
async function fetchData() {
try {
// Fetch all verticals
const verticalsRes = await fetch(`${getApiUrl()}/verticals`)
if (verticalsRes.ok) {
setVerticals(await verticalsRes.json())
}
// Fetch user preferences if logged in
if (token) {
const prefsRes = await fetch(`${getApiUrl()}/verticals/preferences`, {
headers: { Authorization: `Bearer ${token}` }
})
if (prefsRes.ok) {
const prefsData = await prefsRes.json()
const prefsMap = new Map<number, UserPreference>()
prefsData.forEach((p: UserPreference) => {
prefsMap.set(p.vertical_id, p)
})
setPreferences(prefsMap)
}
}
} catch (error) {
console.error("Failed to fetch data", error)
} finally {
setLoading(false)
}
}
fetchData()
}, [token])
const getTier = (verticalId: number): TierType => {
const pref = preferences.get(verticalId)
return pref?.tier || null
}
const setTier = async (verticalId: number, tier: TierType) => {
if (!token) {
router.push("/login")
return
}
setUpdating(verticalId)
try {
if (tier === null) {
// Remove preference
await fetch(`${getApiUrl()}/verticals/preferences/${verticalId}`, {
method: "DELETE",
headers: { Authorization: `Bearer ${token}` }
})
setPreferences(prev => {
const next = new Map(prev)
next.delete(verticalId)
return next
})
} else {
// Set or update preference
const res = await fetch(`${getApiUrl()}/verticals/preferences/${verticalId}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`
},
body: JSON.stringify({
tier,
priority: tier === "headliner" ? 100 : tier === "main_stage" ? 50 : 0
})
})
if (res.ok) {
setPreferences(prev => {
const next = new Map(prev)
next.set(verticalId, { vertical_id: verticalId, tier, priority: 0 })
return next
})
}
}
} catch (error) {
console.error("Failed to update preference", error)
} finally {
setUpdating(null)
}
}
const headliners = verticals.filter(v => getTier(v.id) === "headliner")
const following = verticals.filter(v => ["main_stage", "supporting"].includes(getTier(v.id) || ""))
const ignored = verticals.filter(v => getTier(v.id) === "ignored")
const unfollowed = verticals.filter(v => getTier(v.id) === null)
if (!user) {
return (
<div className="container max-w-4xl py-20 text-center space-y-4">
<Music2 className="h-16 w-16 mx-auto text-muted-foreground" />
<h1 className="text-2xl font-bold">Sign in to manage your bands</h1>
<p className="text-muted-foreground">Track your favorite artists and customize your feed.</p>
<Link href="/login">
<Button size="lg">Sign In</Button>
</Link>
</div>
)
}
if (loading) {
return (
<div className="flex items-center justify-center min-h-[50vh]">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
)
}
return (
<div className="container max-w-5xl py-8 space-y-8">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">My Bands</h1>
<p className="text-muted-foreground">Manage which bands appear in your feed</p>
</div>
<Link href="/onboarding">
<Button variant="outline">Quick Setup</Button>
</Link>
</div>
<Tabs defaultValue="all" className="w-full">
<TabsList className="grid w-full grid-cols-4 mb-6">
<TabsTrigger value="headliners" className="flex items-center gap-2">
<Star className="h-4 w-4" />
<span className="hidden sm:inline">Headliners</span>
<span className="text-xs bg-primary/20 px-1.5 rounded">{headliners.length}</span>
</TabsTrigger>
<TabsTrigger value="following" className="flex items-center gap-2">
<Check className="h-4 w-4" />
<span className="hidden sm:inline">Following</span>
<span className="text-xs bg-primary/20 px-1.5 rounded">{following.length}</span>
</TabsTrigger>
<TabsTrigger value="all">
All Bands
</TabsTrigger>
<TabsTrigger value="ignored" className="flex items-center gap-2">
<Ban className="h-4 w-4" />
<span className="hidden sm:inline">Ignored</span>
<span className="text-xs bg-muted px-1.5 rounded">{ignored.length}</span>
</TabsTrigger>
</TabsList>
<TabsContent value="headliners" className="space-y-4">
{headliners.length === 0 ? (
<EmptyState
title="No headliners yet"
description="Star your favorite bands to feature them prominently"
/>
) : (
<BandGrid
bands={headliners}
getTier={getTier}
setTier={setTier}
updating={updating}
/>
)}
</TabsContent>
<TabsContent value="following" className="space-y-4">
{following.length === 0 ? (
<EmptyState
title="Not following any bands"
description="Click on bands below to start following"
/>
) : (
<BandGrid
bands={following}
getTier={getTier}
setTier={setTier}
updating={updating}
/>
)}
</TabsContent>
<TabsContent value="all" className="space-y-4">
<BandGrid
bands={verticals}
getTier={getTier}
setTier={setTier}
updating={updating}
/>
</TabsContent>
<TabsContent value="ignored" className="space-y-4">
{ignored.length === 0 ? (
<EmptyState
title="No ignored bands"
description="Ignored bands won't appear in your feed but will still show in attribution"
/>
) : (
<BandGrid
bands={ignored}
getTier={getTier}
setTier={setTier}
updating={updating}
/>
)}
</TabsContent>
</Tabs>
</div>
)
}
function EmptyState({ title, description }: { title: string; description: string }) {
return (
<Card className="border-dashed">
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<Music2 className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="font-semibold text-lg">{title}</h3>
<p className="text-sm text-muted-foreground">{description}</p>
</CardContent>
</Card>
)
}
function BandGrid({
bands,
getTier,
setTier,
updating
}: {
bands: Vertical[]
getTier: (id: number) => TierType
setTier: (id: number, tier: TierType) => Promise<void>
updating: number | null
}) {
return (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">
{bands.map((band) => {
const tier = getTier(band.id)
const isUpdating = updating === band.id
return (
<Card
key={band.id}
className={`relative overflow-hidden transition-all ${tier === "headliner" ? "ring-2 ring-yellow-500 bg-yellow-500/5" :
tier === "ignored" ? "opacity-60 grayscale" :
tier ? "ring-1 ring-primary/30" : ""
}`}
>
<CardHeader className="p-4 pb-2">
<div className="flex items-start justify-between">
<div>
<span className="text-2xl">{band.emoji || "🎵"}</span>
<CardTitle className="text-base mt-1">{band.name}</CardTitle>
</div>
{isUpdating && (
<Loader2 className="h-4 w-4 animate-spin" />
)}
</div>
</CardHeader>
<CardContent className="p-4 pt-0">
<div className="flex gap-1 mt-2">
{/* Headliner */}
<button
onClick={() => setTier(band.id, tier === "headliner" ? "main_stage" : "headliner")}
disabled={isUpdating}
className={`flex-1 p-2 rounded-lg text-xs font-medium transition-all ${tier === "headliner"
? "bg-yellow-500 text-black"
: "bg-muted hover:bg-yellow-500/20"
}`}
title="Headliner"
>
<Star className="h-4 w-4 mx-auto" />
</button>
{/* Follow */}
<button
onClick={() => setTier(band.id, tier === "main_stage" ? null : "main_stage")}
disabled={isUpdating}
className={`flex-1 p-2 rounded-lg text-xs font-medium transition-all ${tier === "main_stage" || tier === "supporting"
? "bg-primary text-primary-foreground"
: "bg-muted hover:bg-primary/20"
}`}
title="Follow"
>
<Check className="h-4 w-4 mx-auto" />
</button>
{/* Ignore */}
<button
onClick={() => setTier(band.id, tier === "ignored" ? null : "ignored")}
disabled={isUpdating}
className={`flex-1 p-2 rounded-lg text-xs font-medium transition-all ${tier === "ignored"
? "bg-destructive text-destructive-foreground"
: "bg-muted hover:bg-destructive/20"
}`}
title="Ignore"
>
<Ban className="h-4 w-4 mx-auto" />
</button>
</div>
</CardContent>
</Card>
)
})}
</div>
)
}

View file

@ -99,6 +99,9 @@ export function Navbar() {
<Link href={`/profile/${user.id}`}> <Link href={`/profile/${user.id}`}>
<DropdownMenuItem>Profile</DropdownMenuItem> <DropdownMenuItem>Profile</DropdownMenuItem>
</Link> </Link>
<Link href="/my-bands">
<DropdownMenuItem>My Bands</DropdownMenuItem>
</Link>
<Link href="/settings"> <Link href="/settings">
<DropdownMenuItem>Settings</DropdownMenuItem> <DropdownMenuItem>Settings</DropdownMenuItem>
</Link> </Link>