338 lines
14 KiB
TypeScript
338 lines
14 KiB
TypeScript
"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>
|
|
)
|
|
}
|