From ff37cca46430ed714015647469f88ce06781457a Mon Sep 17 00:00:00 2001 From: Pinapelz Date: Sun, 29 Jun 2025 01:28:39 -0700 Subject: scaffold register,login,and auth endpoints --- frontend/src/App.css | 42 ------- frontend/src/App.tsx | 46 +++---- frontend/src/assets/react.svg | 1 - frontend/src/contexts/AuthContext.tsx | 139 ++++++++++++++++++++ frontend/src/index.css | 69 +--------- frontend/src/main.tsx | 7 +- frontend/src/pages/Home.tsx | 119 ++++++++++++++++++ frontend/src/pages/Landing.tsx | 154 +++++++++++++++++++++++ frontend/src/pages/Login.tsx | 207 ++++++++++++++++++++++++++++++ frontend/src/pages/Register.tsx | 230 ++++++++++++++++++++++++++++++++++ frontend/src/utils/api.ts | 161 ++++++++++++++++++++++++ 11 files changed, 1032 insertions(+), 143 deletions(-) delete mode 100644 frontend/src/App.css delete mode 100644 frontend/src/assets/react.svg create mode 100644 frontend/src/contexts/AuthContext.tsx create mode 100644 frontend/src/pages/Home.tsx create mode 100644 frontend/src/pages/Landing.tsx create mode 100644 frontend/src/pages/Login.tsx create mode 100644 frontend/src/pages/Register.tsx create mode 100644 frontend/src/utils/api.ts (limited to 'frontend/src') diff --git a/frontend/src/App.css b/frontend/src/App.css deleted file mode 100644 index b9d355d..0000000 --- a/frontend/src/App.css +++ /dev/null @@ -1,42 +0,0 @@ -#root { - max-width: 1280px; - margin: 0 auto; - padding: 2rem; - text-align: center; -} - -.logo { - height: 6em; - padding: 1.5em; - will-change: filter; - transition: filter 300ms; -} -.logo:hover { - filter: drop-shadow(0 0 2em #646cffaa); -} -.logo.react:hover { - filter: drop-shadow(0 0 2em #61dafbaa); -} - -@keyframes logo-spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } -} - -@media (prefers-reduced-motion: no-preference) { - a:nth-of-type(2) .logo { - animation: logo-spin infinite 20s linear; - } -} - -.card { - padding: 2em; -} - -.read-the-docs { - color: #888; -} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3d7ded3..d317805 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,35 +1,21 @@ -import { useState } from 'react' -import reactLogo from './assets/react.svg' -import viteLogo from '/vite.svg' -import './App.css' +import { Routes, Route } from 'react-router'; +import { AuthProvider } from './contexts/AuthContext'; +import Landing from './pages/Landing'; +import Login from './pages/Login'; +import Register from './pages/Register'; +import Home from './pages/Home'; function App() { - const [count, setCount] = useState(0) - return ( - <> -
- - Vite logo - - - React logo - -
-

Vite + React

-
- -

- Edit src/App.tsx and save to test HMR -

-
-

- Click on the Vite and React logos to learn more -

- - ) + + + } /> + } /> + } /> + } /> + + + ); } -export default App +export default App; \ No newline at end of file diff --git a/frontend/src/assets/react.svg b/frontend/src/assets/react.svg deleted file mode 100644 index 6c87de9..0000000 --- a/frontend/src/assets/react.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/contexts/AuthContext.tsx new file mode 100644 index 0000000..7e2668d --- /dev/null +++ b/frontend/src/contexts/AuthContext.tsx @@ -0,0 +1,139 @@ +import React, { createContext, useContext, useState, useEffect } from 'react'; +import type { ReactNode } from 'react'; +import { authApi } from '../utils/api'; +import type { User as ApiUser, SessionResponse } from '../utils/api'; + +interface User { + id: string; + username: string; + email: string; +} + +interface AuthContextType { + user: User | null; + isLoading: boolean; + isAuthenticated: boolean; + login: (username: string, password: string) => Promise<{ success: boolean; error?: string }>; + register: (userData: { username: string; email: string; password: string }) => Promise<{ success: boolean; error?: string }>; + logout: () => Promise; + checkAuth: () => Promise; +} + +const AuthContext = createContext(undefined); + +export const useAuth = () => { + const context = useContext(AuthContext); + if (context === undefined) { + throw new Error('useAuth must be used within an AuthProvider'); + } + return context; +}; + +interface AuthProviderProps { + children: ReactNode; +} + +export const AuthProvider: React.FC = ({ children }) => { + const [user, setUser] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + const isAuthenticated = user !== null; + + const transformApiUser = (apiUser: ApiUser): User => ({ + id: apiUser.id.toString(), + username: apiUser.username, + email: apiUser.email, + }); + + const checkAuth = async () => { + try { + const response = await authApi.getSession(); + + if (response.error || !response.data) { + setUser(null); + return; + } + + const sessionData = response.data as SessionResponse; + + if (sessionData.authenticated && sessionData.user) { + setUser(transformApiUser(sessionData.user)); + } else { + setUser(null); + } + } catch (error) { + console.error('Auth check failed:', error); + setUser(null); + } finally { + setIsLoading(false); + } + }; + + const login = async (username: string, password: string) => { + try { + const response = await authApi.login({ username, password }); + + if (response.error) { + return { success: false, error: response.error }; + } + + if (response.data) { + setUser(transformApiUser(response.data as ApiUser)); + } + + return { success: true }; + } catch (error) { + console.error('Login failed:', error); + return { success: false, error: 'Network error. Please try again.' }; + } + }; + + const register = async (userData: { username: string; email: string; password: string }) => { + try { + const response = await authApi.register(userData); + + if (response.error) { + return { success: false, error: response.error }; + } + + if (response.data) { + setUser(transformApiUser(response.data as ApiUser)); + } + + return { success: true }; + } catch (error) { + console.error('Registration failed:', error); + return { success: false, error: 'Network error. Please try again.' }; + } + }; + + const logout = async () => { + try { + await authApi.logout(); + } catch (error) { + console.error('Logout error:', error); + } finally { + setUser(null); + } + }; + + useEffect(() => { + checkAuth(); + }, []); + + const value: AuthContextType = { + user, + isLoading, + isAuthenticated, + login, + register, + logout, + checkAuth, + }; + + return ( + + {children} + + ); +}; diff --git a/frontend/src/index.css b/frontend/src/index.css index 08a3ac9..f1d8c73 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,68 +1 @@ -:root { - font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; - line-height: 1.5; - font-weight: 400; - - color-scheme: light dark; - color: rgba(255, 255, 255, 0.87); - background-color: #242424; - - font-synthesis: none; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} - -a { - font-weight: 500; - color: #646cff; - text-decoration: inherit; -} -a:hover { - color: #535bf2; -} - -body { - margin: 0; - display: flex; - place-items: center; - min-width: 320px; - min-height: 100vh; -} - -h1 { - font-size: 3.2em; - line-height: 1.1; -} - -button { - border-radius: 8px; - border: 1px solid transparent; - padding: 0.6em 1.2em; - font-size: 1em; - font-weight: 500; - font-family: inherit; - background-color: #1a1a1a; - cursor: pointer; - transition: border-color 0.25s; -} -button:hover { - border-color: #646cff; -} -button:focus, -button:focus-visible { - outline: 4px auto -webkit-focus-ring-color; -} - -@media (prefers-color-scheme: light) { - :root { - color: #213547; - background-color: #ffffff; - } - a:hover { - color: #747bff; - } - button { - background-color: #f9f9f9; - } -} +@import "tailwindcss"; diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index bef5202..0467d26 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,10 +1,13 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import './index.css' -import App from './App.tsx' +import App from './App' +import { BrowserRouter } from "react-router"; createRoot(document.getElementById('root')!).render( - + + + , ) diff --git a/frontend/src/pages/Home.tsx b/frontend/src/pages/Home.tsx new file mode 100644 index 0000000..1d87c8a --- /dev/null +++ b/frontend/src/pages/Home.tsx @@ -0,0 +1,119 @@ +import { Link, useNavigate } from 'react-router'; +import { useAuth } from '../contexts/AuthContext'; + +const Home = () => { + const { user, isLoading, logout } = useAuth(); + const navigate = useNavigate(); + + const handleLogout = async () => { + try { + await logout(); + navigate('/'); + } catch (error) { + console.error('Logout failed:', error); + alert('Network error during logout. Please try again.'); + } + }; + + if (isLoading) { + return ( +
+
+
+

Loading dashboard...

+
+
+ ); + } + + if (!user) { + return ( +
+
+
+

Session Expired

+

Please sign in to access your dashboard.

+
+ + Sign In + + + Back to Home + +
+
+
+
+ ); + } + + return ( +
+ {/* Navigation */} + + + {/* Main Content */} +
+ {/* Header */} +
+

Dashboard

+

Track your rhythm game progress and performance

+
+ + {/* Coming Soon Card */} +
+
+ + + +
+

Dashboard Coming Soon

+

+ We're working hard to bring you an amazing dashboard experience. Track your scores, + analyze your performance, and compete with friends - all coming soon! +

+
+
+

+ User ID: {user.id} +

+
+
+

+ Email: {user.email} +

+
+
+
+
+
+ ); +}; + +export default Home; diff --git a/frontend/src/pages/Landing.tsx b/frontend/src/pages/Landing.tsx new file mode 100644 index 0000000..7cd0f43 --- /dev/null +++ b/frontend/src/pages/Landing.tsx @@ -0,0 +1,154 @@ +import { Link } from 'react-router'; + +const Landing = () => { + return ( +
+ {/* Navigation */} + + + {/* Hero Section with Banner */} +
+ {/* Background Image */} +
+ + {/* Overlay */} +
+ + {/* Content */} +
+
+

+ Welcome to Mirage! +

+

+ Looks like you're not logged in. If you've got an account, Login! +

+
+
+
+ + {/* Introduction Section */} +
+
+
+

+ I'm New Around Here, What is this? +

+

+ Mirage is a Rhythm Game Score Tracker. That means we... +

+
+
+
+ + {/* Track Your Scores Section */} +
+
+
+

+ Track Your Scores. +

+

+ Mirage supports a bunch of your favourite games, and integrates with many existing services to + make sure no score is lost to the void. Furthermore, it's backed by an Open-Source API, so your + scores are always available! +

+
+
+
+ + {/* Analyse Your Scores Section */} +
+
+
+

+ Analyse Your Scores. +

+

+ Mirage analyses your scores for you, breaking them down into all the statistics you'll ever need. + No more spreadsheets! +

+
+
+
+ + {/* Provide Cool Features Section */} +
+
+
+

+ Provide Cool Features. +

+

+ Mirage implements the features rhythm gamers already talk about. Break your scores down + into sessions, showcase your best metrics on your profile, study your progress on folders - it's all + there, and done for you! +

+
+
+
+ + {/* Call to Action Section */} +
+
+
+

+ Ready to Start Tracking? +

+
+ + Create Account + + + Log In + +
+
+
+
+ + {/* Footer */} + +
+ ); +}; + +export default Landing; diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx new file mode 100644 index 0000000..5bbdefc --- /dev/null +++ b/frontend/src/pages/Login.tsx @@ -0,0 +1,207 @@ +import { useState } from "react"; +import { Link, useNavigate } from "react-router"; +import { useAuth } from "../contexts/AuthContext"; + +const Login = () => { + const [formData, setFormData] = useState({ + username: "", + password: "", + }); + const [errors, setErrors] = useState>({}); + const [isLoading, setIsLoading] = useState(false); + + const handleChange = (e: React.ChangeEvent) => { + const { name, value } = e.target; + setFormData((prev) => ({ + ...prev, + [name]: value, + })); + // Clear error when user starts typing + if (errors[name]) { + setErrors((prev) => ({ + ...prev, + [name]: "", + })); + } + }; + + const validateForm = () => { + const newErrors: Record = {}; + + if (!formData.username.trim()) { + newErrors.username = "Username is required"; + } else if (formData.username.length < 3) { + newErrors.username = "Username must be at least 3 characters"; + } + + if (!formData.password) { + newErrors.password = "Password is required"; + } + + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + const { login } = useAuth(); + const navigate = useNavigate(); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!validateForm()) return; + + setIsLoading(true); + try { + const result = await login(formData.username, formData.password); + + if (!result.success) { + setErrors({ + general: result.error || "Login failed. Please try again.", + }); + return; + } + + // Redirect to home page on successful login + navigate("/home"); + } catch (error) { + console.error("Login failed:", error); + setErrors({ general: "Network error. Please try again." }); + } finally { + setIsLoading(false); + } + }; + + return ( +
+
+ {/* Header */} +
+ +
+ M +
+ Mirage + +

+ Sign in to your account +

+

+ Or{" "} + + create a new account + +

+
+ + {/* Form */} +
+ {errors.general && ( +
+
+
+ + + +
+
+

{errors.general}

+
+
+
+ )} + +
+
+ + + {errors.username && ( +

{errors.username}

+ )} +
+ +
+ + + {errors.password && ( +

{errors.password}

+ )} +
+ + +
+
+
+
+ ); +}; + +export default Login; diff --git a/frontend/src/pages/Register.tsx b/frontend/src/pages/Register.tsx new file mode 100644 index 0000000..1f8dde8 --- /dev/null +++ b/frontend/src/pages/Register.tsx @@ -0,0 +1,230 @@ +import { useState } from 'react'; +import { Link, useNavigate } from 'react-router'; +import { useAuth } from '../contexts/AuthContext'; + +const Register = () => { + const [formData, setFormData] = useState({ + username: '', + email: '', + password: '', + confirmPassword: '' + }); + const [errors, setErrors] = useState>({}); + const [isLoading, setIsLoading] = useState(false); + + const handleChange = (e: React.ChangeEvent) => { + const { name, value } = e.target; + setFormData(prev => ({ + ...prev, + [name]: value + })); + if (errors[name]) { + setErrors(prev => ({ + ...prev, + [name]: '' + })); + } + }; + + const validateForm = () => { + const newErrors: Record = {}; + + if (!formData.username.trim()) { + newErrors.username = 'Username is required'; + } else if (formData.username.length < 3) { + newErrors.username = 'Username must be at least 3 characters'; + } + + if (!formData.email.trim()) { + newErrors.email = 'Email is required'; + } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) { + newErrors.email = 'Please enter a valid email address'; + } + + if (!formData.password) { + newErrors.password = 'Password is required'; + } else if (formData.password.length < 6) { + newErrors.password = 'Password must be at least 6 characters'; + } + + if (!formData.confirmPassword) { + newErrors.confirmPassword = 'Please confirm your password'; + } else if (formData.password !== formData.confirmPassword) { + newErrors.confirmPassword = 'Passwords do not match'; + } + + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + const { register } = useAuth(); + const navigate = useNavigate(); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!validateForm()) return; + + setIsLoading(true); + try { + const result = await register({ + username: formData.username, + email: formData.email, + password: formData.password, + }); + + if (!result.success) { + setErrors({ general: result.error || 'Registration failed. Please try again.' }); + return; + } + + // Redirect to home page on successful registration + navigate('/home'); + } catch (error) { + console.error('Registration failed:', error); + setErrors({ general: 'Network error. Please try again.' }); + } finally { + setIsLoading(false); + } + }; + + return ( +
+
+ {/* Header */} +
+ +
+ M +
+ Mirage + +

+ Create your account +

+

+ Already have an account?{' '} + + Sign in here + +

+
+ + {/* Form */} +
+ {errors.general && ( +
+
+
+ + + +
+
+

{errors.general}

+
+
+
+ )} + +
+
+ + + {errors.username && ( +

{errors.username}

+ )} +
+ +
+ + + {errors.email && ( +

{errors.email}

+ )} +
+ +
+ + + {errors.password && ( +

{errors.password}

+ )} +
+ +
+ + + {errors.confirmPassword && ( +

{errors.confirmPassword}

+ )} +
+ + +
+
+
+
+ ); +}; + +export default Register; diff --git a/frontend/src/utils/api.ts b/frontend/src/utils/api.ts new file mode 100644 index 0000000..528c170 --- /dev/null +++ b/frontend/src/utils/api.ts @@ -0,0 +1,161 @@ +const API_BASE_URL = import.meta.env.VITE_API_URL; + +// Auth API functions +export const authApi = { + async login(credentials: { username: string; password: string }) { + try { + const response = await fetch(`${API_BASE_URL}/authenticate`, { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(credentials), + }); + + const data = await response.json(); + + if (!response.ok) { + return { error: data.error || `HTTP ${response.status}` }; + } + + return { data }; + } catch (error) { + console.error('Login failed:', error); + return { error: 'Network error. Please check your connection.' }; + } + }, + + async register(userData: { + username: string; + email: string; + password: string; + }) { + try { + const response = await fetch(`${API_BASE_URL}/register`, { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(userData), + }); + + const data = await response.json(); + + if (!response.ok) { + return { error: data.error || `HTTP ${response.status}` }; + } + + return { data }; + } catch (error) { + console.error('Registration failed:', error); + return { error: 'Network error. Please check your connection.' }; + } + }, + + async logout() { + try { + const response = await fetch(`${API_BASE_URL}/logout`, { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + }, + }); + + const data = await response.json(); + + if (!response.ok) { + return { error: data.error || `HTTP ${response.status}` }; + } + + return { data }; + } catch (error) { + console.error('Logout failed:', error); + return { error: 'Network error. Please check your connection.' }; + } + }, + + async getSession() { + try { + const response = await fetch(`${API_BASE_URL}/session`, { + method: 'GET', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + }, + }); + + const data = await response.json(); + + if (!response.ok) { + return { error: data.error || `HTTP ${response.status}` }; + } + + return { data }; + } catch (error) { + console.error('Session check failed:', error); + return { error: 'Network error. Please check your connection.' }; + } + }, + + async getCurrentUser() { + try { + const response = await fetch(`${API_BASE_URL}/me`, { + method: 'GET', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + }, + }); + + const data = await response.json(); + + if (!response.ok) { + return { error: data.error || `HTTP ${response.status}` }; + } + + return { data }; + } catch (error) { + console.error('Get current user failed:', error); + return { error: 'Network error. Please check your connection.' }; + } + }, +}; + +export const infoApi = { + async getUsers() { + try { + const response = await fetch(`${API_BASE_URL}/users`, { + method: 'GET', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + }, + }); + + const data = await response.json(); + + if (!response.ok) { + return { error: data.error || `HTTP ${response.status}` }; + } + + return { data }; + } catch (error) { + console.error('Get users failed:', error); + return { error: 'Network error. Please check your connection.' }; + } + }, +}; + +export interface User { + id: number; + username: string; + email: string; +} + +export interface SessionResponse { + authenticated: boolean; + user?: User; +} -- cgit v1.2.3