diff --git a/backend/fix_tours.py b/backend/fix_tours.py new file mode 100644 index 0000000..9ef8995 --- /dev/null +++ b/backend/fix_tours.py @@ -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() diff --git a/backend/import_elgoose.py b/backend/import_elgoose.py index cc89a0d..a275947 100644 --- a/backend/import_elgoose.py +++ b/backend/import_elgoose.py @@ -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) diff --git a/backend/routers/shows.py b/backend/routers/shows.py index c7e37bf..9dd4e57 100644 --- a/backend/routers/shows.py +++ b/backend/routers/shows.py @@ -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 diff --git a/frontend/app/about/page.tsx b/frontend/app/about/page.tsx index 85e6f2c..3f5b721 100644 --- a/frontend/app/about/page.tsx +++ b/frontend/app/about/page.tsx @@ -1,30 +1,17 @@ export default function AboutPage() { return ( -
-

About Elmeg

-
-

- Elmeg is the definitive fan archive for Goose, built by fans for fans. -

-

- Our mission is to track every show, every song, and every stat. Whether you're chasing your first Arcadia or looking for that deep cut Factory Fiction, Elmeg has the data you need. -

- -
-

Features

-
    -
  • Comprehensive Show Archive
  • -
  • Detailed Setlists & Song Stats
  • -
  • Community Ratings & Reviews
  • -
  • Venue Leaderboards
  • -
  • Personal Attendance Tracking
  • -
-
- -

- Elmeg is a demo project showcasing advanced full-stack capabilities. Powered by FastAPI, Next.js, and SQLModel. -

-
+
+

About Elmeg

+

+ Elmeg is a community-driven archive for Goose, dedicated to documenting every show, song, and performance. +

+

+ Founded in 2025, Elmeg aims to provide the most comprehensive stats and the highest quality data for fans. +

+

The Archives

+

+ Our data includes setlists, reviews, and community ratings for shows dating back to 2014. +

- ); + ) } diff --git a/frontend/app/archive/page.tsx b/frontend/app/archive/page.tsx index c8437cd..d5c6110 100644 --- a/frontend/app/archive/page.tsx +++ b/frontend/app/archive/page.tsx @@ -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 (

Archive

-
- {recentShows.map((show) => ( - - - - {show.date} - +

Browse shows by year.

+
+ {years.map((year) => ( + + -

{show.band}

-

{show.venue}

-

{show.location}

+
{year}
+
+ + Browse Shows +
diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index ec3c3e3..9d27afe 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -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 ( - + -
+
{children}
+
diff --git a/frontend/app/privacy/page.tsx b/frontend/app/privacy/page.tsx new file mode 100644 index 0000000..4e8e8bd --- /dev/null +++ b/frontend/app/privacy/page.tsx @@ -0,0 +1 @@ +export default function PrivacyPage() { return

Privacy Policy

We respect your privacy. We do not sell your data.

} diff --git a/frontend/app/register/page.tsx b/frontend/app/register/page.tsx index 049956c..d90b504 100644 --- a/frontend/app/register/page.tsx +++ b/frontend/app/register/page.tsx @@ -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") } diff --git a/frontend/app/shows/page.tsx b/frontend/app/shows/page.tsx index 0469391..021df3a 100644 --- a/frontend/app/shows/page.tsx +++ b/frontend/app/shows/page.tsx @@ -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([]) 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 ( diff --git a/frontend/app/songs/[id]/page.tsx b/frontend/app/songs/[id]/page.tsx index 1911abb..8d1f3d3 100644 --- a/frontend/app/songs/[id]/page.tsx +++ b/frontend/app/songs/[id]/page.tsx @@ -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: )}
- {/* Placeholder for Rating UI */} - - {perf.avg_rating > 0 ? perf.avg_rating.toFixed(1) : "Unrated"} - +
+ {perf.avg_rating > 0 && ( + + ā˜… {perf.avg_rating.toFixed(1)} + + )} + { + // Trigger refresh if possible, or just ignore. + // Ideal: reload song data. + window.location.reload() + }} + /> +
))} diff --git a/frontend/app/terms/page.tsx b/frontend/app/terms/page.tsx new file mode 100644 index 0000000..701fb7f --- /dev/null +++ b/frontend/app/terms/page.tsx @@ -0,0 +1 @@ +export default function TermsPage() { return

Terms of Service

Welcome to Elmeg. By using this site, you agree to be excellent to each other.

} diff --git a/frontend/components/layout/footer.tsx b/frontend/components/layout/footer.tsx new file mode 100644 index 0000000..695d8ce --- /dev/null +++ b/frontend/components/layout/footer.tsx @@ -0,0 +1,20 @@ +import Link from "next/link" + +export function Footer() { + return ( +
+
+
+ Elmeg + The community archive. +

Ā© {new Date().getFullYear()} Elmeg. All rights reserved.

+
+
+ About + Terms + Privacy +
+
+
+ ) +} diff --git a/frontend/components/songs/rate-performance-dialog.tsx b/frontend/components/songs/rate-performance-dialog.tsx new file mode 100644 index 0000000..48a4cec --- /dev/null +++ b/frontend/components/songs/rate-performance-dialog.tsx @@ -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 ( + + ) + } + + return ( + + + + + + + Rate Performance + + {performanceDate} at {venue} + + +
+ {[1, 2, 3, 4, 5].map((star) => ( + + ))} +
+
+ {rating > 0 ? `${rating} Stars` : "Tap to rate"} +
+ + + +
+
+ ) +} diff --git a/frontend/components/ui/badge.tsx b/frontend/components/ui/badge.tsx new file mode 100644 index 0000000..3481738 --- /dev/null +++ b/frontend/components/ui/badge.tsx @@ -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, + VariantProps { } + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ) +} + +export { Badge, badgeVariants }