feat: add auth pages, update navbar, fix leaderboards venue display, and fix api-config

This commit is contained in:
fullsizemalt 2025-12-20 00:39:53 -08:00
parent 2c9301ae14
commit 3873ddfe8f
9 changed files with 425 additions and 29 deletions

View file

@ -21,11 +21,13 @@ def get_top_shows(limit: int = 10, session: Session = Depends(get_session)):
query = ( query = (
select( select(
Show, Show,
Venue,
func.avg(Review.score).label("avg_score"), func.avg(Review.score).label("avg_score"),
func.count(Review.id).label("review_count") func.count(Review.id).label("review_count")
) )
.join(Review, Review.show_id == Show.id) .join(Review, Review.show_id == Show.id)
.group_by(Show.id) .join(Venue, Show.venue_id == Venue.id)
.group_by(Show.id, Venue.id)
.having(func.count(Review.id) >= 1) .having(func.count(Review.id) >= 1)
.order_by(desc("avg_score"), desc("review_count")) .order_by(desc("avg_score"), desc("review_count"))
.limit(limit) .limit(limit)
@ -36,10 +38,11 @@ def get_top_shows(limit: int = 10, session: Session = Depends(get_session)):
return [ return [
{ {
"show": show, "show": show,
"venue": venue,
"avg_score": round(score, 2), "avg_score": round(score, 2),
"review_count": count "review_count": count
} }
for show, score, count in results for show, venue, score, count in results
] ]
@router.get("/venues/top") @router.get("/venues/top")

View file

@ -4,6 +4,7 @@ import "./globals.css";
import { Navbar } from "@/components/layout/navbar"; import { Navbar } from "@/components/layout/navbar";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { PreferencesProvider } from "@/contexts/preferences-context"; import { PreferencesProvider } from "@/contexts/preferences-context";
import { AuthProvider } from "@/contexts/auth-context";
const inter = Inter({ subsets: ["latin"] }); const inter = Inter({ subsets: ["latin"] });
@ -20,12 +21,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")}>
<PreferencesProvider> <AuthProvider>
<Navbar /> <PreferencesProvider>
<main className="container py-6"> <Navbar />
{children} <main className="container py-6">
</main> {children}
</PreferencesProvider> </main>
</PreferencesProvider>
</AuthProvider>
</body> </body>
</html> </html>
); );

View file

@ -13,6 +13,12 @@ interface TopShow {
date: string date: string
venue_id: number venue_id: number
} }
venue: {
id: number
name: string
city: string
state: string
}
avg_score: number avg_score: number
review_count: number review_count: number
} }
@ -91,13 +97,16 @@ export default function LeaderboardsPage() {
<div key={item.show.id} className="flex items-center justify-between"> <div key={item.show.id} className="flex items-center justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className={`flex h-6 w-6 items-center justify-center rounded-full text-xs font-bold ${i === 0 ? "bg-yellow-100 text-yellow-700" : <span className={`flex h-6 w-6 items-center justify-center rounded-full text-xs font-bold ${i === 0 ? "bg-yellow-100 text-yellow-700" :
i === 1 ? "bg-gray-100 text-gray-700" : i === 1 ? "bg-gray-100 text-gray-700" :
i === 2 ? "bg-orange-100 text-orange-700" : "text-muted-foreground" i === 2 ? "bg-orange-100 text-orange-700" : "text-muted-foreground"
}`}> }`}>
{i + 1} {i + 1}
</span> </span>
<Link href={`/shows/${item.show.id}`} className="font-medium hover:underline"> <Link href={`/shows/${item.show.id}`} className="font-medium hover:underline block">
{new Date(item.show.date).toLocaleDateString()} {new Date(item.show.date).toLocaleDateString()}
<span className="text-sm font-normal text-muted-foreground ml-2">
{item.venue?.name ? `${item.venue.name} (${item.venue.city}, ${item.venue.state})` : ""}
</span>
</Link> </Link>
</div> </div>
<div className="flex items-center gap-1 text-sm"> <div className="flex items-center gap-1 text-sm">
@ -125,8 +134,8 @@ export default function LeaderboardsPage() {
<div key={item.venue.id} className="flex items-center justify-between"> <div key={item.venue.id} className="flex items-center justify-between">
<div className="flex items-center gap-3 overflow-hidden"> <div className="flex items-center gap-3 overflow-hidden">
<span className={`flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-xs font-bold ${i === 0 ? "bg-yellow-100 text-yellow-700" : <span className={`flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-xs font-bold ${i === 0 ? "bg-yellow-100 text-yellow-700" :
i === 1 ? "bg-gray-100 text-gray-700" : i === 1 ? "bg-gray-100 text-gray-700" :
i === 2 ? "bg-orange-100 text-orange-700" : "text-muted-foreground" i === 2 ? "bg-orange-100 text-orange-700" : "text-muted-foreground"
}`}> }`}>
{i + 1} {i + 1}
</span> </span>
@ -159,8 +168,8 @@ export default function LeaderboardsPage() {
<div key={item.profile.id} className="flex items-center justify-between"> <div key={item.profile.id} className="flex items-center justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className={`flex h-6 w-6 items-center justify-center rounded-full text-xs font-bold ${i === 0 ? "bg-yellow-100 text-yellow-700" : <span className={`flex h-6 w-6 items-center justify-center rounded-full text-xs font-bold ${i === 0 ? "bg-yellow-100 text-yellow-700" :
i === 1 ? "bg-gray-100 text-gray-700" : i === 1 ? "bg-gray-100 text-gray-700" :
i === 2 ? "bg-orange-100 text-orange-700" : "text-muted-foreground" i === 2 ? "bg-orange-100 text-orange-700" : "text-muted-foreground"
}`}> }`}>
{i + 1} {i + 1}
</span> </span>

105
frontend/app/login/page.tsx Normal file
View file

@ -0,0 +1,105 @@
"use client"
import { useState } from "react"
import { useAuth } from "@/contexts/auth-context"
import { useRouter } from "next/navigation"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter } from "@/components/ui/card"
import { Label } from "@/components/ui/label"
import { getApiUrl } from "@/lib/api-config"
import Link from "next/link"
export default function LoginPage() {
const [email, setEmail] = useState("")
const [password, setPassword] = useState("")
const [error, setError] = useState("")
const { login } = useAuth()
const router = useRouter()
const [loading, setLoading] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
setError("")
try {
const formData = new URLSearchParams()
formData.append('username', email)
formData.append('password', password)
const res = await fetch(`${getApiUrl()}/auth/token`, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: formData,
})
if (!res.ok) {
const data = await res.json()
throw new Error(data.detail || "Login failed")
}
const data = await res.json()
await login(data.access_token)
router.push("/")
} catch (err: any) {
setError(err.message)
} finally {
setLoading(false)
}
}
return (
<div className="flex items-center justify-center min-h-[60vh]">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>Sign In</CardTitle>
<CardDescription>Enter your email and password to access your account</CardDescription>
</CardHeader>
<form onSubmit={handleSubmit}>
<CardContent className="space-y-4">
{error && (
<div className="bg-destructive/15 text-destructive text-sm p-3 rounded-md">
{error}
</div>
)}
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="name@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
</CardContent>
<CardFooter className="flex flex-col gap-4">
<Button type="submit" className="w-full" disabled={loading}>
{loading ? "Signing in..." : "Sign In"}
</Button>
<p className="text-sm text-center text-muted-foreground">
Don't have an account?{" "}
<Link href="/register" className="text-primary hover:underline">
Sign up
</Link>
</p>
</CardFooter>
</form>
</Card>
</div>
)
}

View file

@ -0,0 +1,130 @@
"use client"
import { useState } from "react"
import { useRouter } from "next/navigation"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter } from "@/components/ui/card"
import { Label } from "@/components/ui/label"
import { getApiUrl } from "@/lib/api-config"
import Link from "next/link"
import { useAuth } from "@/contexts/auth-context"
export default function RegisterPage() {
const [email, setEmail] = useState("")
const [username, setUsername] = useState("")
const [password, setPassword] = useState("")
const [error, setError] = useState("")
const router = useRouter()
const { login } = useAuth()
const [loading, setLoading] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
setError("")
try {
// 1. Register
const res = await fetch(`${getApiUrl()}/auth/register`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email, username, password }),
})
if (!res.ok) {
const data = await res.json()
throw new Error(data.detail || "Registration failed")
}
// 2. Login automatically
const formData = new URLSearchParams()
formData.append('username', email)
formData.append('password', password)
const loginRes = await fetch(`${getApiUrl()}/auth/token`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: formData,
})
if (loginRes.ok) {
const loginData = await loginRes.json()
await login(loginData.access_token)
router.push("/")
} else {
router.push("/login")
}
} catch (err: any) {
setError(err.message)
} finally {
setLoading(false)
}
}
return (
<div className="flex items-center justify-center min-h-[60vh]">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>Create Account</CardTitle>
<CardDescription>Join the community to track shows and rate sets</CardDescription>
</CardHeader>
<form onSubmit={handleSubmit}>
<CardContent className="space-y-4">
{error && (
<div className="bg-destructive/15 text-destructive text-sm p-3 rounded-md">
{error}
</div>
)}
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="name@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="username">Username</Label>
<Input
id="username"
type="text"
placeholder="GooseFan123"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
</CardContent>
<CardFooter className="flex flex-col gap-4">
<Button type="submit" className="w-full" disabled={loading}>
{loading ? "Creating account..." : "Sign Up"}
</Button>
<p className="text-sm text-center text-muted-foreground">
Already have an account?{" "}
<Link href="/login" className="text-primary hover:underline">
Sign in
</Link>
</p>
</CardFooter>
</form>
</Card>
</div>
)
}

View file

@ -1,5 +1,6 @@
"use client"
import Link from "next/link" import Link from "next/link"
import { Music, Search, User, ChevronDown } from "lucide-react" import { Music, User, ChevronDown } from "lucide-react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { SearchDialog } from "@/components/ui/search-dialog" import { SearchDialog } from "@/components/ui/search-dialog"
import { NotificationBell } from "@/components/layout/notification-bell" import { NotificationBell } from "@/components/layout/notification-bell"
@ -10,8 +11,10 @@ import {
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu" } from "@/components/ui/dropdown-menu"
import { useAuth } from "@/contexts/auth-context"
export function Navbar() { export function Navbar() {
const { user, logout } = useAuth()
return ( return (
<header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60"> <header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="container flex h-14 items-center"> <div className="container flex h-14 items-center">
@ -62,18 +65,55 @@ export function Navbar() {
<SearchDialog /> <SearchDialog />
</div> </div>
<nav className="flex items-center gap-2"> <nav className="flex items-center gap-2">
<Link href="/mod"> {user ? (
<Button variant="ghost" size="sm"> <>
Mod {(user.role === 'admin' || user.role === 'moderator') && (
</Button> <Link href="/mod">
</Link> <Button variant="ghost" size="sm">
<NotificationBell /> Mod
<Link href="/profile"> </Button>
<Button variant="ghost" size="icon"> </Link>
<User className="h-5 w-5" /> )}
<span className="sr-only">User</span> <NotificationBell />
</Button> <DropdownMenu>
</Link> <DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<User className="h-5 w-5" />
<span className="sr-only">User</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem disabled className="font-semibold">
{user.email}
</DropdownMenuItem>
<DropdownMenuSeparator />
<Link href="/profile">
<DropdownMenuItem>Profile</DropdownMenuItem>
</Link>
<Link href="/settings">
<DropdownMenuItem>Settings</DropdownMenuItem>
</Link>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={logout} className="text-red-500 focus:text-red-500">
Sign Out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
) : (
<div className="flex items-center gap-2">
<Link href="/login">
<Button variant="ghost" size="sm">
Sign In
</Button>
</Link>
<Link href="/register">
<Button size="sm">
Sign Up
</Button>
</Link>
</div>
)}
</nav> </nav>
</div> </div>
</div> </div>

View file

@ -51,4 +51,28 @@ const CardContent = React.forwardRef<
)) ))
CardContent.displayName = "CardContent" CardContent.displayName = "CardContent"
export { Card, CardHeader, CardTitle, CardContent } const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardTitle, CardContent, CardDescription, CardFooter }

View file

@ -0,0 +1,79 @@
"use client"
import React, { createContext, useContext, useState, useEffect } from "react"
import { getApiUrl } from "@/lib/api-config"
interface User {
id: number
email: string
is_active: boolean
is_superuser: boolean
role: string
}
interface AuthContextType {
user: User | null
loading: boolean
login: (token: string) => Promise<void>
logout: () => void
}
const AuthContext = createContext<AuthContextType>({
user: null,
loading: true,
login: async () => { },
logout: () => { },
})
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
const initAuth = async () => {
const token = localStorage.getItem("token")
if (token) {
try {
await fetchUser(token)
} catch (err) {
console.error("Auth init failed", err)
localStorage.removeItem("token")
}
}
setLoading(false)
}
initAuth()
}, [])
const fetchUser = async (token: string) => {
const res = await fetch(`${getApiUrl()}/auth/users/me`, {
headers: {
Authorization: `Bearer ${token}`
}
})
if (res.ok) {
const userData = await res.json()
setUser(userData)
} else {
throw new Error("Failed to fetch user")
}
}
const login = async (token: string) => {
localStorage.setItem("token", token)
await fetchUser(token)
}
const logout = () => {
localStorage.removeItem("token")
setUser(null)
}
return (
<AuthContext.Provider value={{ user, loading, login, logout }}>
{children}
</AuthContext.Provider>
)
}
export const useAuth = () => useContext(AuthContext)

View file

@ -4,5 +4,8 @@ export function getApiUrl() {
return process.env.INTERNAL_API_URL || 'http://localhost:8000' return process.env.INTERNAL_API_URL || 'http://localhost:8000'
} }
// Client-side // Client-side
if (window.location.hostname === 'elmeg.xyz') {
return 'https://elmeg.xyz/api'
}
return process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000' return process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000'
} }