From 3873ddfe8fb702a873ff8b80b9748e033e58024b Mon Sep 17 00:00:00 2001 From: fullsizemalt <106900403+fullsizemalt@users.noreply.github.com> Date: Sat, 20 Dec 2025 00:39:53 -0800 Subject: [PATCH] feat: add auth pages, update navbar, fix leaderboards venue display, and fix api-config --- backend/routers/leaderboards.py | 7 +- frontend/app/layout.tsx | 15 +-- frontend/app/leaderboards/page.tsx | 23 +++-- frontend/app/login/page.tsx | 105 +++++++++++++++++++++ frontend/app/register/page.tsx | 130 ++++++++++++++++++++++++++ frontend/components/layout/navbar.tsx | 66 ++++++++++--- frontend/components/ui/card.tsx | 26 +++++- frontend/contexts/auth-context.tsx | 79 ++++++++++++++++ frontend/lib/api-config.ts | 3 + 9 files changed, 425 insertions(+), 29 deletions(-) create mode 100644 frontend/app/login/page.tsx create mode 100644 frontend/app/register/page.tsx create mode 100644 frontend/contexts/auth-context.tsx diff --git a/backend/routers/leaderboards.py b/backend/routers/leaderboards.py index 8bd2e43..91f8477 100644 --- a/backend/routers/leaderboards.py +++ b/backend/routers/leaderboards.py @@ -21,11 +21,13 @@ def get_top_shows(limit: int = 10, session: Session = Depends(get_session)): query = ( select( Show, + Venue, func.avg(Review.score).label("avg_score"), func.count(Review.id).label("review_count") ) .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) .order_by(desc("avg_score"), desc("review_count")) .limit(limit) @@ -36,10 +38,11 @@ def get_top_shows(limit: int = 10, session: Session = Depends(get_session)): return [ { "show": show, + "venue": venue, "avg_score": round(score, 2), "review_count": count } - for show, score, count in results + for show, venue, score, count in results ] @router.get("/venues/top") diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index 7e1b571..ec3c3e3 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -4,6 +4,7 @@ import "./globals.css"; import { Navbar } from "@/components/layout/navbar"; import { cn } from "@/lib/utils"; import { PreferencesProvider } from "@/contexts/preferences-context"; +import { AuthProvider } from "@/contexts/auth-context"; const inter = Inter({ subsets: ["latin"] }); @@ -20,12 +21,14 @@ export default function RootLayout({ return ( - - -
- {children} -
-
+ + + +
+ {children} +
+
+
); diff --git a/frontend/app/leaderboards/page.tsx b/frontend/app/leaderboards/page.tsx index 9a3374e..61f2f8f 100644 --- a/frontend/app/leaderboards/page.tsx +++ b/frontend/app/leaderboards/page.tsx @@ -13,6 +13,12 @@ interface TopShow { date: string venue_id: number } + venue: { + id: number + name: string + city: string + state: string + } avg_score: number review_count: number } @@ -91,13 +97,16 @@ export default function LeaderboardsPage() {
{i + 1} - + {new Date(item.show.date).toLocaleDateString()} + + {item.venue?.name ? `${item.venue.name} (${item.venue.city}, ${item.venue.state})` : ""} +
@@ -125,8 +134,8 @@ export default function LeaderboardsPage() {
{i + 1} @@ -159,8 +168,8 @@ export default function LeaderboardsPage() {
{i + 1} diff --git a/frontend/app/login/page.tsx b/frontend/app/login/page.tsx new file mode 100644 index 0000000..63a8915 --- /dev/null +++ b/frontend/app/login/page.tsx @@ -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 ( +
+ + + Sign In + Enter your email and password to access your account + +
+ + {error && ( +
+ {error} +
+ )} +
+ + setEmail(e.target.value)} + required + /> +
+
+ + setPassword(e.target.value)} + required + /> +
+
+ + +

+ Don't have an account?{" "} + + Sign up + +

+
+
+
+
+ ) +} diff --git a/frontend/app/register/page.tsx b/frontend/app/register/page.tsx new file mode 100644 index 0000000..049956c --- /dev/null +++ b/frontend/app/register/page.tsx @@ -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 ( +
+ + + Create Account + Join the community to track shows and rate sets + +
+ + {error && ( +
+ {error} +
+ )} +
+ + setEmail(e.target.value)} + required + /> +
+
+ + setUsername(e.target.value)} + required + /> +
+
+ + setPassword(e.target.value)} + required + /> +
+
+ + +

+ Already have an account?{" "} + + Sign in + +

+
+
+
+
+ ) +} diff --git a/frontend/components/layout/navbar.tsx b/frontend/components/layout/navbar.tsx index 1122591..de70361 100644 --- a/frontend/components/layout/navbar.tsx +++ b/frontend/components/layout/navbar.tsx @@ -1,5 +1,6 @@ +"use client" 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 { SearchDialog } from "@/components/ui/search-dialog" import { NotificationBell } from "@/components/layout/notification-bell" @@ -10,8 +11,10 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" +import { useAuth } from "@/contexts/auth-context" export function Navbar() { + const { user, logout } = useAuth() return (
@@ -62,18 +65,55 @@ export function Navbar() {
diff --git a/frontend/components/ui/card.tsx b/frontend/components/ui/card.tsx index 475eaa9..21fd509 100644 --- a/frontend/components/ui/card.tsx +++ b/frontend/components/ui/card.tsx @@ -51,4 +51,28 @@ const CardContent = React.forwardRef< )) CardContent.displayName = "CardContent" -export { Card, CardHeader, CardTitle, CardContent } +const CardDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardDescription.displayName = "CardDescription" + +const CardFooter = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardFooter.displayName = "CardFooter" + +export { Card, CardHeader, CardTitle, CardContent, CardDescription, CardFooter } diff --git a/frontend/contexts/auth-context.tsx b/frontend/contexts/auth-context.tsx new file mode 100644 index 0000000..89975b7 --- /dev/null +++ b/frontend/contexts/auth-context.tsx @@ -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 + logout: () => void +} + +const AuthContext = createContext({ + user: null, + loading: true, + login: async () => { }, + logout: () => { }, +}) + +export function AuthProvider({ children }: { children: React.ReactNode }) { + const [user, setUser] = useState(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 ( + + {children} + + ) +} + +export const useAuth = () => useContext(AuthContext) diff --git a/frontend/lib/api-config.ts b/frontend/lib/api-config.ts index dbc5b74..6eaf8ad 100644 --- a/frontend/lib/api-config.ts +++ b/frontend/lib/api-config.ts @@ -4,5 +4,8 @@ export function getApiUrl() { return process.env.INTERNAL_API_URL || 'http://localhost:8000' } // Client-side + if (window.location.hostname === 'elmeg.xyz') { + return 'https://elmeg.xyz/api' + } return process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000' }