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
def import_setlists(session, show_map, song_map):
"""Import setlists for all shows"""
"""Import setlists for all shows (Paginated)"""
print("\n📋 Importing setlists...")
# Fetch all setlists (this gets all performances across all shows)
setlists_data = fetch_all_json("setlists")
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
]
page = 1
total_processed = 0
performance_count = 0
for perf_data in goose_setlists:
# 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...")
params = {}
session.commit()
print(f"✓ Imported {performance_count} performances")
while True:
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():
print("="*60)

View file

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

View file

@ -1,30 +1,17 @@
export default function AboutPage() {
return (
<div className="container mx-auto px-4 py-8">
<h1 className="text-4xl font-bold mb-6 text-brand-500">About Elmeg</h1>
<div className="prose dark:prose-invert max-w-none">
<p className="text-xl mb-4">
Elmeg is the definitive fan archive for <strong>Goose</strong>, built by fans for fans.
</p>
<p className="mb-4">
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>
<div className="bg-zinc-100 dark:bg-zinc-800 p-6 rounded-lg my-8">
<h2 className="text-2xl font-bold mb-4">Features</h2>
<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 className="max-w-prose mx-auto space-y-6">
<h1 className="text-3xl font-bold">About Elmeg</h1>
<p>
Elmeg is a community-driven archive for Goose, dedicated to documenting every show, song, and performance.
</p>
<p>
Founded in 2025, Elmeg aims to provide the most comprehensive stats and the highest quality data for fans.
</p>
<h2 className="text-xl font-bold">The Archives</h2>
<p>
Our data includes setlists, reviews, and community ratings for shows dating back to 2014.
</p>
</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 { Calendar } from "lucide-react"
// Mock data for now - will fetch from API later
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" },
]
const years = Array.from({ length: 2025 - 2014 + 1 }, (_, i) => 2025 - i)
export default function ArchivePage() {
return (
<div className="flex flex-col gap-6">
<h1 className="text-3xl font-bold tracking-tight">Archive</h1>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{recentShows.map((show) => (
<Link key={show.id} href={`/shows/${show.id}`}>
<Card className="hover:bg-accent/50 transition-colors cursor-pointer">
<CardHeader>
<CardTitle>{show.date}</CardTitle>
</CardHeader>
<p className="text-muted-foreground">Browse shows by year.</p>
<div className="grid gap-4 md:grid-cols-3 lg:grid-cols-4">
{years.map((year) => (
<Link key={year} href={`/shows?year=${year}`}>
<Card className="hover:bg-accent/50 transition-colors cursor-pointer text-center py-6">
<CardContent>
<p className="font-semibold">{show.band}</p>
<p className="text-sm text-muted-foreground">{show.venue}</p>
<p className="text-sm text-muted-foreground">{show.location}</p>
<div className="text-4xl font-bold text-primary">{year}</div>
<div className="flex items-center justify-center gap-2 mt-2 text-muted-foreground">
<Calendar className="h-4 w-4" />
<span>Browse Shows</span>
</div>
</CardContent>
</Card>
</Link>

View file

@ -6,6 +6,8 @@ import { cn } from "@/lib/utils";
import { PreferencesProvider } from "@/contexts/preferences-context";
import { AuthProvider } from "@/contexts/auth-context";
import { Footer } from "@/components/layout/footer";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
@ -20,13 +22,14 @@ export default function RootLayout({
}>) {
return (
<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>
<PreferencesProvider>
<Navbar />
<main className="container py-6">
<main className="container py-6 flex-1">
{children}
</main>
<Footer />
</PreferencesProvider>
</AuthProvider>
</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) {
const loginData = await loginRes.json()
await login(loginData.access_token)
router.push("/")
router.push("/profile")
} else {
router.push("/login")
}

View file

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

View file

@ -3,7 +3,9 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { ArrowLeft, PlayCircle, History } from "lucide-react"
import Link from "next/link"
import { notFound } from "next/navigation"
import { Badge } from "@/components/ui/badge"
import { getApiUrl } from "@/lib/api-config"
import { RatePerformanceDialog } from "@/components/songs/rate-performance-dialog"
import { CommentSection } from "@/components/social/comment-section"
import { EntityRating } from "@/components/social/entity-rating"
import { EntityReviews } from "@/components/reviews/entity-reviews"
@ -117,10 +119,26 @@ export default async function SongDetailPage({ params }: { params: Promise<{ id:
)}
</div>
<div className="flex flex-col items-end gap-1">
{/* Placeholder for Rating UI */}
<span className="text-xs font-semibold bg-secondary px-2 py-1 rounded-full text-secondary-foreground">
{perf.avg_rating > 0 ? perf.avg_rating.toFixed(1) : "Unrated"}
</span>
<div className="flex items-center gap-2">
{perf.avg_rating > 0 && (
<Badge variant="secondary" className="bg-yellow-500/10 text-yellow-600 hover:bg-yellow-500/20 border-yellow-500/20">
{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>
))}

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 }