feat(bands): add My Bands page with tier management and IGNORED tier
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
e07c23aceb
commit
212082050c
3 changed files with 342 additions and 0 deletions
|
|
@ -327,6 +327,7 @@ class PreferenceTier(str, Enum):
|
|||
HEADLINER = "headliner"
|
||||
MAIN_STAGE = "main_stage"
|
||||
SUPPORTING = "supporting"
|
||||
IGNORED = "ignored" # Exclude from feeds, keep attribution mentions
|
||||
|
||||
class UserVerticalPreference(SQLModel, table=True):
|
||||
"""User preferences for which bands to display prominently vs. attribution-only"""
|
||||
|
|
|
|||
338
frontend/app/my-bands/page.tsx
Normal file
338
frontend/app/my-bands/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -99,6 +99,9 @@ export function Navbar() {
|
|||
<Link href={`/profile/${user.id}`}>
|
||||
<DropdownMenuItem>Profile</DropdownMenuItem>
|
||||
</Link>
|
||||
<Link href="/my-bands">
|
||||
<DropdownMenuItem>My Bands</DropdownMenuItem>
|
||||
</Link>
|
||||
<Link href="/settings">
|
||||
<DropdownMenuItem>Settings</DropdownMenuItem>
|
||||
</Link>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue