feat: Add footer, static pages, rating dialog, and improve register flow

This commit is contained in:
fullsizemalt 2025-12-20 02:30:17 -08:00
parent e3092afa9e
commit 3575ac4700
14 changed files with 313 additions and 104 deletions

29
backend/fix_tours.py Normal file
View file

@ -0,0 +1,29 @@
from sqlmodel import Session, select, func
from database import engine
from models import Tour, Show
def fix_tour_dates():
with Session(engine) as session:
tours = session.exec(select(Tour)).all()
print(f"Fixing dates for {len(tours)} tours...")
for tour in tours:
# Find min/max show date
result = session.exec(
select(func.min(Show.date), func.max(Show.date))
.where(Show.tour_id == tour.id)
).first()
if result and result[0]:
tour.start_date = result[0]
tour.end_date = result[1]
session.add(tour)
# print(f" {tour.name}: {tour.start_date.date()} - {tour.end_date.date()}")
# else:
# print(f" {tour.name}: No shows found")
session.commit()
print("Done.")
if __name__ == "__main__":
fix_tour_dates()

View file

@ -252,57 +252,63 @@ def import_shows(session, vertical_id, venue_map):
return show_map, tour_map return show_map, tour_map
def import_setlists(session, show_map, song_map): def import_setlists(session, show_map, song_map):
"""Import setlists for all shows""" """Import setlists for all shows (Paginated)"""
print("\n📋 Importing setlists...") print("\n📋 Importing setlists...")
# Fetch all setlists (this gets all performances across all shows) page = 1
setlists_data = fetch_all_json("setlists") total_processed = 0
if not setlists_data:
print("❌ No setlist data found")
return
# Filter for Goose shows
goose_setlists = [
s for s in setlists_data
if s.get('show_id') in show_map
]
performance_count = 0 performance_count = 0
for perf_data in goose_setlists: params = {}
# Map to our show and song IDs
our_show_id = show_map.get(perf_data['show_id'])
our_song_id = song_map.get(perf_data['song_id'])
if not our_show_id or not our_song_id:
continue
# Check existing performance
existing_perf = session.exec(
select(Performance).where(
Performance.show_id == our_show_id,
Performance.song_id == our_song_id,
Performance.position == perf_data.get('position', 0)
)
).first()
if not existing_perf:
perf = Performance(
show_id=our_show_id,
song_id=our_song_id,
position=perf_data.get('position', 0),
set_name=perf_data.get('set'),
segue=bool(perf_data.get('segue', 0)),
notes=perf_data.get('notes')
)
session.add(perf)
performance_count += 1
if performance_count % 100 == 0:
session.commit()
print(f" Progress: {performance_count} performances...")
session.commit() while True:
print(f"✓ Imported {performance_count} performances") params['page'] = page
print(f" Fetching setlists page {page}...", end="", flush=True)
data = fetch_json("setlists", params)
if not data:
print(" Done (No Data/End).")
break
print(f" Processing {len(data)} items...", end="", flush=True)
count_in_page = 0
for perf_data in data:
# Map to our show and song IDs
our_show_id = show_map.get(perf_data.get('show_id'))
our_song_id = song_map.get(perf_data.get('song_id'))
if not our_show_id or not our_song_id:
continue
# Check existing performance validation is expensive.
# We trust idempotency or assume clean run?
# User idempotency request requires checking.
existing_perf = session.exec(
select(Performance).where(
Performance.show_id == our_show_id,
Performance.song_id == our_song_id,
Performance.position == perf_data.get('position', 0)
)
).first()
if not existing_perf:
perf = Performance(
show_id=our_show_id,
song_id=our_song_id,
position=perf_data.get('position', 0),
set_name=perf_data.get('set'),
segue=bool(perf_data.get('segue', 0)),
notes=perf_data.get('notes')
)
session.add(perf)
count_in_page += 1
performance_count += 1
session.commit()
print(f" Validated/Added {count_in_page} items.")
page += 1
print(f"✓ Imported {performance_count} new performances")
def main(): def main():
print("="*60) print("="*60)

View file

@ -30,11 +30,9 @@ def read_shows(
query = query.where(Show.venue_id == venue_id) query = query.where(Show.venue_id == venue_id)
if tour_id: if tour_id:
query = query.where(Show.tour_id == tour_id) query = query.where(Show.tour_id == tour_id)
# if year: if year:
# # SQLite/Postgres specific year extraction might differ, from sqlalchemy import extract
# # but usually we can filter by date range or extract year. query = query.where(extract('year', Show.date) == year)
# # For simplicity let's skip year for now or use a range if needed.
# pass
shows = session.exec(query.offset(offset).limit(limit)).all() shows = session.exec(query.offset(offset).limit(limit)).all()
return shows return shows

View file

@ -1,30 +1,17 @@
export default function AboutPage() { export default function AboutPage() {
return ( return (
<div className="container mx-auto px-4 py-8"> <div className="max-w-prose mx-auto space-y-6">
<h1 className="text-4xl font-bold mb-6 text-brand-500">About Elmeg</h1> <h1 className="text-3xl font-bold">About Elmeg</h1>
<div className="prose dark:prose-invert max-w-none"> <p>
<p className="text-xl mb-4"> Elmeg is a community-driven archive for Goose, dedicated to documenting every show, song, and performance.
Elmeg is the definitive fan archive for <strong>Goose</strong>, built by fans for fans. </p>
</p> <p>
<p className="mb-4"> Founded in 2025, Elmeg aims to provide the most comprehensive stats and the highest quality data for fans.
Our mission is to track every show, every song, and every stat. Whether you're chasing your first <em>Arcadia</em> or looking for that deep cut <em>Factory Fiction</em>, Elmeg has the data you need. </p>
</p> <h2 className="text-xl font-bold">The Archives</h2>
<p>
<div className="bg-zinc-100 dark:bg-zinc-800 p-6 rounded-lg my-8"> Our data includes setlists, reviews, and community ratings for shows dating back to 2014.
<h2 className="text-2xl font-bold mb-4">Features</h2> </p>
<ul className="list-disc pl-6 space-y-2">
<li>Comprehensive Show Archive</li>
<li>Detailed Setlists & Song Stats</li>
<li>Community Ratings & Reviews</li>
<li>Venue Leaderboards</li>
<li>Personal Attendance Tracking</li>
</ul>
</div>
<p className="text-sm text-zinc-500 mt-8">
Elmeg is a demo project showcasing advanced full-stack capabilities. Powered by FastAPI, Next.js, and SQLModel.
</p>
</div>
</div> </div>
); )
} }

View file

@ -1,28 +1,24 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent } from "@/components/ui/card"
import Link from "next/link" import Link from "next/link"
import { Calendar } from "lucide-react"
// Mock data for now - will fetch from API later const years = Array.from({ length: 2025 - 2014 + 1 }, (_, i) => 2025 - i)
const recentShows = [
{ id: 1, date: "2023-12-31", venue: "Madison Square Garden", location: "New York, NY", band: "Phish" },
{ id: 2, date: "2023-12-30", venue: "Madison Square Garden", location: "New York, NY", band: "Phish" },
{ id: 3, date: "2023-12-29", venue: "Madison Square Garden", location: "New York, NY", band: "Phish" },
]
export default function ArchivePage() { export default function ArchivePage() {
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<h1 className="text-3xl font-bold tracking-tight">Archive</h1> <h1 className="text-3xl font-bold tracking-tight">Archive</h1>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> <p className="text-muted-foreground">Browse shows by year.</p>
{recentShows.map((show) => ( <div className="grid gap-4 md:grid-cols-3 lg:grid-cols-4">
<Link key={show.id} href={`/shows/${show.id}`}> {years.map((year) => (
<Card className="hover:bg-accent/50 transition-colors cursor-pointer"> <Link key={year} href={`/shows?year=${year}`}>
<CardHeader> <Card className="hover:bg-accent/50 transition-colors cursor-pointer text-center py-6">
<CardTitle>{show.date}</CardTitle>
</CardHeader>
<CardContent> <CardContent>
<p className="font-semibold">{show.band}</p> <div className="text-4xl font-bold text-primary">{year}</div>
<p className="text-sm text-muted-foreground">{show.venue}</p> <div className="flex items-center justify-center gap-2 mt-2 text-muted-foreground">
<p className="text-sm text-muted-foreground">{show.location}</p> <Calendar className="h-4 w-4" />
<span>Browse Shows</span>
</div>
</CardContent> </CardContent>
</Card> </Card>
</Link> </Link>

View file

@ -6,6 +6,8 @@ import { cn } from "@/lib/utils";
import { PreferencesProvider } from "@/contexts/preferences-context"; import { PreferencesProvider } from "@/contexts/preferences-context";
import { AuthProvider } from "@/contexts/auth-context"; import { AuthProvider } from "@/contexts/auth-context";
import { Footer } from "@/components/layout/footer";
const inter = Inter({ subsets: ["latin"] }); const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = { export const metadata: Metadata = {
@ -20,13 +22,14 @@ export default function RootLayout({
}>) { }>) {
return ( return (
<html lang="en"> <html lang="en">
<body className={cn(inter.className, "min-h-screen bg-background font-sans antialiased")}> <body className={cn(inter.className, "min-h-screen bg-background font-sans antialiased flex flex-col")}>
<AuthProvider> <AuthProvider>
<PreferencesProvider> <PreferencesProvider>
<Navbar /> <Navbar />
<main className="container py-6"> <main className="container py-6 flex-1">
{children} {children}
</main> </main>
<Footer />
</PreferencesProvider> </PreferencesProvider>
</AuthProvider> </AuthProvider>
</body> </body>

View file

@ -0,0 +1 @@
export default function PrivacyPage() { return <div className="max-w-prose mx-auto"><h1 className="text-3xl font-bold mb-4">Privacy Policy</h1><p>We respect your privacy. We do not sell your data.</p></div> }

View file

@ -53,7 +53,7 @@ export default function RegisterPage() {
if (loginRes.ok) { if (loginRes.ok) {
const loginData = await loginRes.json() const loginData = await loginRes.json()
await login(loginData.access_token) await login(loginData.access_token)
router.push("/") router.push("/profile")
} else { } else {
router.push("/login") router.push("/login")
} }

View file

@ -7,6 +7,8 @@ import Link from "next/link"
import { Calendar, MapPin } from "lucide-react" import { Calendar, MapPin } from "lucide-react"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { useSearchParams } from "next/navigation"
interface Show { interface Show {
id: number id: number
date: string date: string
@ -19,11 +21,15 @@ interface Show {
} }
export default function ShowsPage() { export default function ShowsPage() {
const searchParams = useSearchParams()
const year = searchParams.get("year")
const [shows, setShows] = useState<Show[]>([]) const [shows, setShows] = useState<Show[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
useEffect(() => { useEffect(() => {
fetch(`${getApiUrl()}/shows/?limit=2000`) const url = `${getApiUrl()}/shows/?limit=2000${year ? `&year=${year}` : ''}`
fetch(url)
.then(res => res.json()) .then(res => res.json())
.then(data => { .then(data => {
// Sort by date descending // Sort by date descending
@ -34,7 +40,7 @@ export default function ShowsPage() {
}) })
.catch(console.error) .catch(console.error)
.finally(() => setLoading(false)) .finally(() => setLoading(false))
}, []) }, [year])
if (loading) { if (loading) {
return ( return (

View file

@ -3,7 +3,9 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { ArrowLeft, PlayCircle, History } from "lucide-react" import { ArrowLeft, PlayCircle, History } from "lucide-react"
import Link from "next/link" import Link from "next/link"
import { notFound } from "next/navigation" import { notFound } from "next/navigation"
import { Badge } from "@/components/ui/badge"
import { getApiUrl } from "@/lib/api-config" import { getApiUrl } from "@/lib/api-config"
import { RatePerformanceDialog } from "@/components/songs/rate-performance-dialog"
import { CommentSection } from "@/components/social/comment-section" import { CommentSection } from "@/components/social/comment-section"
import { EntityRating } from "@/components/social/entity-rating" import { EntityRating } from "@/components/social/entity-rating"
import { EntityReviews } from "@/components/reviews/entity-reviews" import { EntityReviews } from "@/components/reviews/entity-reviews"
@ -117,10 +119,26 @@ export default async function SongDetailPage({ params }: { params: Promise<{ id:
)} )}
</div> </div>
<div className="flex flex-col items-end gap-1"> <div className="flex flex-col items-end gap-1">
{/* Placeholder for Rating UI */} <div className="flex items-center gap-2">
<span className="text-xs font-semibold bg-secondary px-2 py-1 rounded-full text-secondary-foreground"> {perf.avg_rating > 0 && (
{perf.avg_rating > 0 ? perf.avg_rating.toFixed(1) : "Unrated"} <Badge variant="secondary" className="bg-yellow-500/10 text-yellow-600 hover:bg-yellow-500/20 border-yellow-500/20">
</span> {perf.avg_rating.toFixed(1)}
</Badge>
)}
<RatePerformanceDialog
performanceId={perf.id}
performanceDate={new Date(perf.show_date).toLocaleDateString()}
venue={perf.venue_name}
// We don't have *user's* rating in 'perf' object yet, only avg.
// So currentRating prop logic needs fetching user rating.
// For now, pass 0 or undefined.
onRatingSubmit={() => {
// Trigger refresh if possible, or just ignore.
// Ideal: reload song data.
window.location.reload()
}}
/>
</div>
</div> </div>
</div> </div>
))} ))}

View file

@ -0,0 +1 @@
export default function TermsPage() { return <div className="max-w-prose mx-auto"><h1 className="text-3xl font-bold mb-4">Terms of Service</h1><p>Welcome to Elmeg. By using this site, you agree to be excellent to each other.</p></div> }

View file

@ -0,0 +1,20 @@
import Link from "next/link"
export function Footer() {
return (
<footer className="border-t py-6 md:py-8 mt-12 bg-muted/30">
<div className="container flex flex-col md:flex-row items-center justify-between gap-4">
<div className="flex flex-col gap-1 items-center md:items-start text-center md:text-left">
<span className="font-bold">Elmeg</span>
<span className="text-sm text-muted-foreground">The community archive.</span>
<p className="text-xs text-muted-foreground mt-2">© {new Date().getFullYear()} Elmeg. All rights reserved.</p>
</div>
<div className="flex gap-6 text-sm text-muted-foreground">
<Link href="/about" className="hover:underline hover:text-foreground">About</Link>
<Link href="/terms" className="hover:underline hover:text-foreground">Terms</Link>
<Link href="/privacy" className="hover:underline hover:text-foreground">Privacy</Link>
</div>
</div>
</footer>
)
}

View file

@ -0,0 +1,109 @@
"use client"
import { useState } from "react"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { Star } from "lucide-react"
import { getApiUrl } from "@/lib/api-config"
import { useAuth } from "@/contexts/auth-context"
interface RatePerformanceProps {
performanceId: number
performanceDate: string
venue: string
currentRating?: number
onRatingSubmit?: () => void
}
export function RatePerformanceDialog({ performanceId, performanceDate, venue, currentRating, onRatingSubmit }: RatePerformanceProps) {
const [rating, setRating] = useState(currentRating || 0)
const [open, setOpen] = useState(false)
const [loading, setLoading] = useState(false)
const { user } = useAuth()
const handleRate = async () => {
if (!user) return
setLoading(true)
const token = localStorage.getItem("token")
try {
const res = await fetch(`${getApiUrl()}/social/ratings`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`
},
body: JSON.stringify({
performance_id: performanceId,
score: rating
})
})
if (res.ok) {
setOpen(false)
if (onRatingSubmit) onRatingSubmit()
}
} catch (e) {
console.error(e)
} finally {
setLoading(false)
}
}
// If not logged in, just show static star or nothing?
// We'll let Button trigger login flow or disabled.
if (!user) {
return (
<Button variant="ghost" size="sm" className="h-8 w-8 p-0 disabled:opacity-50" disabled title="Log in to rate">
<Star className="h-4 w-4 text-muted-foreground" />
</Button>
)
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" title="Rate this version">
<Star className={`h-4 w-4 ${currentRating ? "fill-yellow-500 text-yellow-500" : "text-muted-foreground"}`} />
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Rate Performance</DialogTitle>
<DialogDescription>
{performanceDate} at {venue}
</DialogDescription>
</DialogHeader>
<div className="flex justify-center gap-2 py-6">
{[1, 2, 3, 4, 5].map((star) => (
<button
key={star}
type="button"
onClick={() => setRating(star)}
className={`p-1 transition-transform hover:scale-110 focus:outline-none ${rating >= star ? "text-yellow-500" : "text-muted-foreground/30 hover:text-yellow-500/70"
}`}
>
<Star className={`h-8 w-8 ${rating >= star ? "fill-current" : ""}`} />
</button>
))}
</div>
<div className="text-center font-bold text-lg mb-4 text-muted-foreground">
{rating > 0 ? `${rating} Stars` : "Tap to rate"}
</div>
<DialogFooter>
<Button type="submit" onClick={handleRate} disabled={loading || rating === 0}>
{loading ? "Submitting..." : "Submit Rating"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View file

@ -0,0 +1,35 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> { }
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge, badgeVariants }