Compare commits

..

No commits in common. "730e92f8c9731027442eddf3915481b57f0e3a0d" and "d276cdbd766e79b1e2ed595d6c23072c322e73d6" have entirely different histories.

5 changed files with 1 additions and 867 deletions

View file

@ -1,104 +0,0 @@
"""
Sync original_artist data from elgoose.net API to local Songs.
Then create Artist records and link Songs.
"""
import httpx
from sqlmodel import Session, select
from database import engine
from models import Song, Artist
from slugify import generate_slug as slugify
BASE_URL = "https://elgoose.net/api/v2"
def fetch_all_songs():
"""Fetch all songs from elgoose API"""
url = f"{BASE_URL}/songs"
print(f"Fetching songs from {url}...")
response = httpx.get(url, timeout=30)
response.raise_for_status()
data = response.json()
return data.get("data", [])
def sync_original_artists():
"""Sync original_artist from API to local Songs"""
api_songs = fetch_all_songs()
print(f"Fetched {len(api_songs)} songs from API")
with Session(engine) as session:
updated = 0
for api_song in api_songs:
# Skip originals (isoriginal=1 means Goose original)
if api_song.get("isoriginal") == 1:
continue
slug = api_song.get("slug")
original_artist = api_song.get("original_artist")
if not slug or not original_artist or original_artist == "Goose":
continue
# Find matching local Song by slug
local_song = session.exec(select(Song).where(Song.slug == slug)).first()
if local_song and not local_song.original_artist:
local_song.original_artist = original_artist
session.add(local_song)
updated += 1
print(f"Updated: {local_song.title} -> {original_artist}")
session.commit()
print(f"\nSynced original_artist for {updated} cover songs")
return updated
def create_artist_records():
"""Create Artist records from original_artist strings and link Songs"""
with Session(engine) as session:
songs = session.exec(select(Song).where(Song.original_artist != None)).all()
print(f"Found {len(songs)} songs with original_artist")
created = 0
linked = 0
for song in songs:
if not song.original_artist or song.artist_id:
continue
artist_name = song.original_artist.strip()
if not artist_name or artist_name == "Goose":
continue
artist_slug = slugify(artist_name)
# Find or Create Artist
artist = session.exec(select(Artist).where(Artist.slug == artist_slug)).first()
if not artist:
artist = Artist(name=artist_name, slug=artist_slug)
session.add(artist)
session.commit()
session.refresh(artist)
created += 1
print(f"Created Artist: {artist_name}")
# Link Song
song.artist_id = artist.id
session.add(song)
linked += 1
session.commit()
print(f"\nCreated {created} artists, linked {linked} songs")
return created, linked
if __name__ == "__main__":
print("=" * 50)
print("ARTIST SYNC FROM ELGOOSE API")
print("=" * 50)
# Step 1: Sync original_artist strings
updated_count = sync_original_artists()
# Step 2: Create Artist records and link
created, linked = create_artist_records()
print("=" * 50)
print(f"COMPLETE: Updated {updated_count} songs, Created {created} artists, Linked {linked} songs")
print("=" * 50)

View file

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

View file

@ -1,259 +0,0 @@
"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

@ -1,236 +0,0 @@
"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

@ -1,252 +0,0 @@
"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>
)
}