feat: add Shows, Songs, Venues Admin pages
Some checks are pending
Deploy Elmeg / deploy (push) Waiting to run

This commit is contained in:
fullsizemalt 2025-12-24 13:01:57 -08:00
parent e0414d1548
commit 730e92f8c9
4 changed files with 763 additions and 1 deletions

View file

@ -4,7 +4,7 @@ import Link from "next/link"
import { usePathname } from "next/navigation"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { LayoutDashboard, MessageSquare, ShieldAlert, Users, Mic2 } from "lucide-react"
import { LayoutDashboard, MessageSquare, ShieldAlert, Users, Mic2, Calendar, Music2, MapPin } from "lucide-react"
export default function AdminLayout({
children,
@ -24,6 +24,21 @@ export default function AdminLayout({
href: "/admin/artists",
icon: Mic2
},
{
title: "Shows",
href: "/admin/shows",
icon: Calendar
},
{
title: "Songs",
href: "/admin/songs",
icon: Music2
},
{
title: "Venues",
href: "/admin/venues",
icon: MapPin
},
{
title: "Nicknames",
href: "/admin/nicknames",

View file

@ -0,0 +1,259 @@
"use client"
import { useEffect, useState } from "react"
import { useAuth } from "@/contexts/auth-context"
import { useRouter } from "next/navigation"
import { Card, CardContent } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import { Badge } from "@/components/ui/badge"
import { Search, Edit, Save, X, Calendar, ExternalLink } from "lucide-react"
import { getApiUrl } from "@/lib/api-config"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog"
import { Label } from "@/components/ui/label"
import Link from "next/link"
interface Show {
id: number
date: string
slug: string
venue_name?: string
venue_city?: string
notes: string | null
nugs_link: string | null
bandcamp_link: string | null
youtube_link: string | null
}
export default function AdminShowsPage() {
const { user, token } = useAuth()
const router = useRouter()
const [shows, setShows] = useState<Show[]>([])
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState("")
const [editingShow, setEditingShow] = useState<Show | null>(null)
const [saving, setSaving] = useState(false)
useEffect(() => {
if (!user) {
router.push("/login")
return
}
if (user.role !== "admin") {
router.push("/")
return
}
fetchShows()
}, [user, router])
const fetchShows = async () => {
if (!token) return
try {
const res = await fetch(`${getApiUrl()}/shows?limit=100&sort=date_desc`, {
headers: { Authorization: `Bearer ${token}` }
})
if (res.ok) {
const data = await res.json()
setShows(data.shows || data)
}
} catch (e) {
console.error("Failed to fetch shows", e)
} finally {
setLoading(false)
}
}
const updateShow = async () => {
if (!token || !editingShow) return
setSaving(true)
try {
const res = await fetch(`${getApiUrl()}/admin/shows/${editingShow.id}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`
},
body: JSON.stringify({
notes: editingShow.notes,
nugs_link: editingShow.nugs_link,
bandcamp_link: editingShow.bandcamp_link,
youtube_link: editingShow.youtube_link
})
})
if (res.ok) {
fetchShows()
setEditingShow(null)
}
} catch (e) {
console.error("Failed to update show", e)
} finally {
setSaving(false)
}
}
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric"
})
}
const filteredShows = shows.filter(s =>
s.venue_name?.toLowerCase().includes(search.toLowerCase()) ||
s.venue_city?.toLowerCase().includes(search.toLowerCase()) ||
s.date.includes(search)
)
if (loading) {
return (
<div className="space-y-4">
<div className="h-8 bg-muted rounded w-48 animate-pulse" />
<div className="grid gap-4">
{[1, 2, 3].map(i => <div key={i} className="h-20 bg-muted rounded animate-pulse" />)}
</div>
</div>
)
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold flex items-center gap-2">
<Calendar className="h-6 w-6" />
Show Management
</h2>
</div>
<div className="relative max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search by venue, city, or date..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9"
/>
</div>
<Card>
<CardContent className="p-0">
<table className="w-full">
<thead className="bg-muted/50">
<tr>
<th className="text-left p-3 font-medium">Date</th>
<th className="text-left p-3 font-medium">Venue</th>
<th className="text-left p-3 font-medium">Links</th>
<th className="text-right p-3 font-medium">Actions</th>
</tr>
</thead>
<tbody>
{filteredShows.slice(0, 50).map(show => (
<tr key={show.id} className="border-t">
<td className="p-3">
<Link href={`/shows/${show.slug}`} className="font-medium hover:underline">
{formatDate(show.date)}
</Link>
</td>
<td className="p-3">
<p className="font-medium">{show.venue_name || "Unknown Venue"}</p>
<p className="text-sm text-muted-foreground">{show.venue_city}</p>
</td>
<td className="p-3 flex gap-1">
{show.nugs_link && <Badge variant="outline" className="text-xs">Nugs</Badge>}
{show.bandcamp_link && <Badge variant="outline" className="text-xs">BC</Badge>}
{show.youtube_link && <Badge variant="outline" className="text-xs">YT</Badge>}
{!show.nugs_link && !show.bandcamp_link && !show.youtube_link && (
<span className="text-xs text-muted-foreground">No links</span>
)}
</td>
<td className="p-3 text-right">
<Button
variant="ghost"
size="sm"
onClick={() => setEditingShow(show)}
>
<Edit className="h-4 w-4" />
</Button>
</td>
</tr>
))}
</tbody>
</table>
{filteredShows.length === 0 && (
<div className="p-8 text-center text-muted-foreground">
No shows found
</div>
)}
</CardContent>
</Card>
<Dialog open={!!editingShow} onOpenChange={() => setEditingShow(null)}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Edit Show: {editingShow && formatDate(editingShow.date)}</DialogTitle>
</DialogHeader>
{editingShow && (
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label>Notes</Label>
<Textarea
placeholder="Show notes..."
value={editingShow.notes || ""}
onChange={(e) => setEditingShow({ ...editingShow, notes: e.target.value })}
rows={3}
/>
</div>
<div className="space-y-2">
<Label>Nugs.net Link</Label>
<Input
placeholder="https://nugs.net/..."
value={editingShow.nugs_link || ""}
onChange={(e) => setEditingShow({ ...editingShow, nugs_link: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label>Bandcamp Link</Label>
<Input
placeholder="https://..."
value={editingShow.bandcamp_link || ""}
onChange={(e) => setEditingShow({ ...editingShow, bandcamp_link: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label>YouTube Link</Label>
<Input
placeholder="https://youtube.com/..."
value={editingShow.youtube_link || ""}
onChange={(e) => setEditingShow({ ...editingShow, youtube_link: e.target.value })}
/>
</div>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setEditingShow(null)}>
<X className="h-4 w-4 mr-2" />
Cancel
</Button>
<Button onClick={updateShow} disabled={saving}>
<Save className="h-4 w-4 mr-2" />
{saving ? "Saving..." : "Save Changes"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View file

@ -0,0 +1,236 @@
"use client"
import { useEffect, useState } from "react"
import { useAuth } from "@/contexts/auth-context"
import { useRouter } from "next/navigation"
import { Card, CardContent } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import { Badge } from "@/components/ui/badge"
import { Search, Edit, Save, X, Music2 } from "lucide-react"
import { getApiUrl } from "@/lib/api-config"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog"
import { Label } from "@/components/ui/label"
import Link from "next/link"
interface Song {
id: number
title: string
slug: string
original_artist: string | null
artist_id: number | null
notes: string | null
youtube_link: string | null
}
export default function AdminSongsPage() {
const { user, token } = useAuth()
const router = useRouter()
const [songs, setSongs] = useState<Song[]>([])
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState("")
const [editingSong, setEditingSong] = useState<Song | null>(null)
const [saving, setSaving] = useState(false)
useEffect(() => {
if (!user) {
router.push("/login")
return
}
if (user.role !== "admin") {
router.push("/")
return
}
fetchSongs()
}, [user, router])
const fetchSongs = async () => {
if (!token) return
try {
const res = await fetch(`${getApiUrl()}/songs?limit=200`, {
headers: { Authorization: `Bearer ${token}` }
})
if (res.ok) {
const data = await res.json()
setSongs(data.songs || data)
}
} catch (e) {
console.error("Failed to fetch songs", e)
} finally {
setLoading(false)
}
}
const updateSong = async () => {
if (!token || !editingSong) return
setSaving(true)
try {
const res = await fetch(`${getApiUrl()}/admin/songs/${editingSong.id}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`
},
body: JSON.stringify({
notes: editingSong.notes,
youtube_link: editingSong.youtube_link,
original_artist: editingSong.original_artist
})
})
if (res.ok) {
fetchSongs()
setEditingSong(null)
}
} catch (e) {
console.error("Failed to update song", e)
} finally {
setSaving(false)
}
}
const filteredSongs = songs.filter(s =>
s.title.toLowerCase().includes(search.toLowerCase()) ||
s.original_artist?.toLowerCase().includes(search.toLowerCase())
)
if (loading) {
return (
<div className="space-y-4">
<div className="h-8 bg-muted rounded w-48 animate-pulse" />
<div className="grid gap-4">
{[1, 2, 3].map(i => <div key={i} className="h-20 bg-muted rounded animate-pulse" />)}
</div>
</div>
)
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold flex items-center gap-2">
<Music2 className="h-6 w-6" />
Song Management
</h2>
</div>
<div className="relative max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search songs..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9"
/>
</div>
<Card>
<CardContent className="p-0">
<table className="w-full">
<thead className="bg-muted/50">
<tr>
<th className="text-left p-3 font-medium">Title</th>
<th className="text-left p-3 font-medium">Original Artist</th>
<th className="text-left p-3 font-medium">Type</th>
<th className="text-right p-3 font-medium">Actions</th>
</tr>
</thead>
<tbody>
{filteredSongs.slice(0, 100).map(song => (
<tr key={song.id} className="border-t">
<td className="p-3">
<Link href={`/songs/${song.slug}`} className="font-medium hover:underline">
{song.title}
</Link>
</td>
<td className="p-3 text-muted-foreground">
{song.original_artist || "—"}
</td>
<td className="p-3">
{song.original_artist && song.original_artist !== "Goose" ? (
<Badge variant="secondary">Cover</Badge>
) : (
<Badge variant="outline">Original</Badge>
)}
</td>
<td className="p-3 text-right">
<Button
variant="ghost"
size="sm"
onClick={() => setEditingSong(song)}
>
<Edit className="h-4 w-4" />
</Button>
</td>
</tr>
))}
</tbody>
</table>
{filteredSongs.length === 0 && (
<div className="p-8 text-center text-muted-foreground">
No songs found
</div>
)}
</CardContent>
</Card>
<Dialog open={!!editingSong} onOpenChange={() => setEditingSong(null)}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Edit Song: {editingSong?.title}</DialogTitle>
</DialogHeader>
{editingSong && (
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label>Original Artist</Label>
<Input
placeholder="Artist name..."
value={editingSong.original_artist || ""}
onChange={(e) => setEditingSong({ ...editingSong, original_artist: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label>Notes</Label>
<Textarea
placeholder="Song notes..."
value={editingSong.notes || ""}
onChange={(e) => setEditingSong({ ...editingSong, notes: e.target.value })}
rows={3}
/>
</div>
<div className="space-y-2">
<Label>YouTube Link</Label>
<Input
placeholder="https://youtube.com/..."
value={editingSong.youtube_link || ""}
onChange={(e) => setEditingSong({ ...editingSong, youtube_link: e.target.value })}
/>
</div>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setEditingSong(null)}>
<X className="h-4 w-4 mr-2" />
Cancel
</Button>
<Button onClick={updateSong} disabled={saving}>
<Save className="h-4 w-4 mr-2" />
{saving ? "Saving..." : "Save Changes"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View file

@ -0,0 +1,252 @@
"use client"
import { useEffect, useState } from "react"
import { useAuth } from "@/contexts/auth-context"
import { useRouter } from "next/navigation"
import { Card, CardContent } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge"
import { Search, Edit, Save, X, MapPin } from "lucide-react"
import { getApiUrl } from "@/lib/api-config"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog"
import { Label } from "@/components/ui/label"
import Link from "next/link"
interface Venue {
id: number
name: string
slug: string
city: string
state: string | null
country: string
capacity: number | null
}
export default function AdminVenuesPage() {
const { user, token } = useAuth()
const router = useRouter()
const [venues, setVenues] = useState<Venue[]>([])
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState("")
const [editingVenue, setEditingVenue] = useState<Venue | null>(null)
const [saving, setSaving] = useState(false)
useEffect(() => {
if (!user) {
router.push("/login")
return
}
if (user.role !== "admin") {
router.push("/")
return
}
fetchVenues()
}, [user, router])
const fetchVenues = async () => {
if (!token) return
try {
const res = await fetch(`${getApiUrl()}/venues?limit=200`, {
headers: { Authorization: `Bearer ${token}` }
})
if (res.ok) {
const data = await res.json()
setVenues(data.venues || data)
}
} catch (e) {
console.error("Failed to fetch venues", e)
} finally {
setLoading(false)
}
}
const updateVenue = async () => {
if (!token || !editingVenue) return
setSaving(true)
try {
const res = await fetch(`${getApiUrl()}/admin/venues/${editingVenue.id}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`
},
body: JSON.stringify({
name: editingVenue.name,
city: editingVenue.city,
state: editingVenue.state,
country: editingVenue.country,
capacity: editingVenue.capacity
})
})
if (res.ok) {
fetchVenues()
setEditingVenue(null)
}
} catch (e) {
console.error("Failed to update venue", e)
} finally {
setSaving(false)
}
}
const filteredVenues = venues.filter(v =>
v.name.toLowerCase().includes(search.toLowerCase()) ||
v.city.toLowerCase().includes(search.toLowerCase())
)
if (loading) {
return (
<div className="space-y-4">
<div className="h-8 bg-muted rounded w-48 animate-pulse" />
<div className="grid gap-4">
{[1, 2, 3].map(i => <div key={i} className="h-20 bg-muted rounded animate-pulse" />)}
</div>
</div>
)
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold flex items-center gap-2">
<MapPin className="h-6 w-6" />
Venue Management
</h2>
</div>
<div className="relative max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search venues..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9"
/>
</div>
<Card>
<CardContent className="p-0">
<table className="w-full">
<thead className="bg-muted/50">
<tr>
<th className="text-left p-3 font-medium">Venue</th>
<th className="text-left p-3 font-medium">Location</th>
<th className="text-left p-3 font-medium">Capacity</th>
<th className="text-right p-3 font-medium">Actions</th>
</tr>
</thead>
<tbody>
{filteredVenues.slice(0, 100).map(venue => (
<tr key={venue.id} className="border-t">
<td className="p-3">
<Link href={`/venues/${venue.slug}`} className="font-medium hover:underline">
{venue.name}
</Link>
</td>
<td className="p-3 text-muted-foreground">
{venue.city}{venue.state ? `, ${venue.state}` : ""}, {venue.country}
</td>
<td className="p-3">
{venue.capacity ? (
<Badge variant="outline">{venue.capacity.toLocaleString()}</Badge>
) : (
<span className="text-muted-foreground text-sm"></span>
)}
</td>
<td className="p-3 text-right">
<Button
variant="ghost"
size="sm"
onClick={() => setEditingVenue(venue)}
>
<Edit className="h-4 w-4" />
</Button>
</td>
</tr>
))}
</tbody>
</table>
{filteredVenues.length === 0 && (
<div className="p-8 text-center text-muted-foreground">
No venues found
</div>
)}
</CardContent>
</Card>
<Dialog open={!!editingVenue} onOpenChange={() => setEditingVenue(null)}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Edit Venue: {editingVenue?.name}</DialogTitle>
</DialogHeader>
{editingVenue && (
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label>Name</Label>
<Input
value={editingVenue.name}
onChange={(e) => setEditingVenue({ ...editingVenue, name: e.target.value })}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>City</Label>
<Input
value={editingVenue.city}
onChange={(e) => setEditingVenue({ ...editingVenue, city: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label>State</Label>
<Input
value={editingVenue.state || ""}
onChange={(e) => setEditingVenue({ ...editingVenue, state: e.target.value })}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Country</Label>
<Input
value={editingVenue.country}
onChange={(e) => setEditingVenue({ ...editingVenue, country: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label>Capacity</Label>
<Input
type="number"
value={editingVenue.capacity || ""}
onChange={(e) => setEditingVenue({ ...editingVenue, capacity: parseInt(e.target.value) || null })}
/>
</div>
</div>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setEditingVenue(null)}>
<X className="h-4 w-4 mr-2" />
Cancel
</Button>
<Button onClick={updateVenue} disabled={saving}>
<Save className="h-4 w-4 mr-2" />
{saving ? "Saving..." : "Save Changes"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}