setup features
This commit is contained in:
@@ -1,7 +0,0 @@
|
||||
FROM node:20-alpine
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
COPY . .
|
||||
EXPOSE 5173
|
||||
CMD ["npm", "run", "dev"]
|
||||
@@ -9,9 +9,11 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.20.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"vite": "^5.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
232
frontend/src/App.css
Normal file
232
frontend/src/App.css
Normal file
@@ -0,0 +1,232 @@
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
:root {
|
||||
--primary: #6366f1;
|
||||
--primary-dark: #4f46e5;
|
||||
--secondary: #8b5cf6;
|
||||
--success: #10b981;
|
||||
--danger: #ef4444;
|
||||
--warning: #f59e0b;
|
||||
--bg: #0f172a;
|
||||
--bg-light: #1e293b;
|
||||
--bg-lighter: #334155;
|
||||
--text: #f1f5f9;
|
||||
--text-muted: #94a3b8;
|
||||
--border: #334155;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.75rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: var(--primary-dark);
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background: var(--success);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: var(--danger);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--bg-lighter);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.input, .textarea {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.5rem;
|
||||
background: var(--bg-light);
|
||||
color: var(--text);
|
||||
font-size: 1rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.input:focus, .textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.textarea {
|
||||
resize: vertical;
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--bg-light);
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.5rem;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.header {
|
||||
background: var(--bg-light);
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 1rem 0;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
list-style: none;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.nav-links a {
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.375rem;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.nav-links a:hover, .nav-links a.active {
|
||||
background: var(--bg-lighter);
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.grid-2 {
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--danger);
|
||||
font-size: 0.875rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.success {
|
||||
color: var(--success);
|
||||
font-size: 0.875rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 200px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.nav {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
gap: 0.25rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.nav-links a {
|
||||
padding: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.grid-2 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
[style*="grid-template-columns: minmax(0, 1fr) 300px"] {
|
||||
grid-template-columns: 1fr !important;
|
||||
}
|
||||
}
|
||||
56
frontend/src/AuthContext.jsx
Normal file
56
frontend/src/AuthContext.jsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||
import api from './api';
|
||||
|
||||
const AuthContext = createContext(null);
|
||||
|
||||
export const AuthProvider = ({ children }) => {
|
||||
const [user, setUser] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
api.getMe()
|
||||
.then(data => setUser(data.user))
|
||||
.catch(() => {
|
||||
localStorage.removeItem('token');
|
||||
api.setToken(null);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
} else {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const login = async (email, password) => {
|
||||
const data = await api.login(email, password);
|
||||
setUser(data.user);
|
||||
return data;
|
||||
};
|
||||
|
||||
const register = async (email, username, password) => {
|
||||
const data = await api.register(email, username, password);
|
||||
setUser(data.user);
|
||||
return data;
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem('token');
|
||||
api.setToken(null);
|
||||
setUser(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, login, register, logout, loading }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useAuth = () => {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error('useAuth must be used within AuthProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
158
frontend/src/api.js
Normal file
158
frontend/src/api.js
Normal file
@@ -0,0 +1,158 @@
|
||||
const API_URL = import.meta.env.VITE_API_URL || '/api';
|
||||
|
||||
class API {
|
||||
constructor() {
|
||||
this.token = localStorage.getItem('token');
|
||||
}
|
||||
|
||||
setToken(token) {
|
||||
this.token = token;
|
||||
if (token) {
|
||||
localStorage.setItem('token', token);
|
||||
} else {
|
||||
localStorage.removeItem('token');
|
||||
}
|
||||
}
|
||||
|
||||
async request(endpoint, options = {}) {
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers
|
||||
};
|
||||
|
||||
if (this.token) {
|
||||
headers.Authorization = `Bearer ${this.token}`;
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_URL}${endpoint}`, {
|
||||
...options,
|
||||
headers
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: 'Request failed' }));
|
||||
throw new Error(error.error || 'Request failed');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// Auth
|
||||
async register(email, username, password) {
|
||||
const data = await this.request('/auth/register', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email, username, password })
|
||||
});
|
||||
this.setToken(data.token);
|
||||
return data;
|
||||
}
|
||||
|
||||
async login(email, password) {
|
||||
const data = await this.request('/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email, password })
|
||||
});
|
||||
this.setToken(data.token);
|
||||
return data;
|
||||
}
|
||||
|
||||
async getMe() {
|
||||
return this.request('/auth/me');
|
||||
}
|
||||
|
||||
// Challenges
|
||||
async getChallenges() {
|
||||
return this.request('/challenges');
|
||||
}
|
||||
|
||||
async getChallenge(id) {
|
||||
return this.request(`/challenges/${id}`);
|
||||
}
|
||||
|
||||
async createChallenge(data) {
|
||||
return this.request('/challenges', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
|
||||
async inviteToChallenge(challengeId, data) {
|
||||
return this.request(`/challenges/${challengeId}/invite`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
|
||||
async respondToChallenge(challengeId, status) {
|
||||
return this.request(`/challenges/${challengeId}/respond`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ status })
|
||||
});
|
||||
}
|
||||
|
||||
// Predictions
|
||||
async getPredictions(challengeId) {
|
||||
return this.request(`/predictions/challenge/${challengeId}`);
|
||||
}
|
||||
|
||||
async createPrediction(data) {
|
||||
return this.request('/predictions', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
|
||||
async validatePrediction(predictionId, status) {
|
||||
return this.request(`/predictions/${predictionId}/validate`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ status })
|
||||
});
|
||||
}
|
||||
|
||||
// Friends
|
||||
async getFriends() {
|
||||
return this.request('/friends');
|
||||
}
|
||||
|
||||
async searchUsers(query) {
|
||||
return this.request(`/friends/search?q=${encodeURIComponent(query)}`);
|
||||
}
|
||||
|
||||
async sendFriendRequest(userId) {
|
||||
return this.request('/friends/request', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ user_id: userId })
|
||||
});
|
||||
}
|
||||
|
||||
async respondToFriendRequest(friendshipId, status) {
|
||||
return this.request('/friends/respond', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ friendship_id: friendshipId, status })
|
||||
});
|
||||
}
|
||||
|
||||
async getFriendRequests() {
|
||||
return this.request('/friends/requests');
|
||||
}
|
||||
|
||||
// TMDB
|
||||
async searchShows(query) {
|
||||
return this.request(`/tmdb/search?q=${encodeURIComponent(query)}`);
|
||||
}
|
||||
|
||||
// Leaderboard
|
||||
async getChallengeLeaderboard(challengeId) {
|
||||
return this.request(`/leaderboard/challenge/${challengeId}`);
|
||||
}
|
||||
|
||||
async getGlobalLeaderboard() {
|
||||
return this.request('/leaderboard/global');
|
||||
}
|
||||
|
||||
async getProfile(userId) {
|
||||
return this.request(`/leaderboard/profile${userId ? `/${userId}` : ''}`);
|
||||
}
|
||||
}
|
||||
|
||||
export default new API();
|
||||
@@ -1,9 +1,71 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { BrowserRouter, Routes, Route, Navigate, Link } from 'react-router-dom';
|
||||
import { AuthProvider, useAuth } from './AuthContext';
|
||||
import Login from './pages/Login';
|
||||
import Register from './pages/Register';
|
||||
import ChallengeList from './pages/ChallengeList';
|
||||
import ChallengeDetail from './pages/ChallengeDetail';
|
||||
import Profile from './pages/Profile';
|
||||
import Friends from './pages/Friends';
|
||||
import Leaderboard from './pages/Leaderboard';
|
||||
import './App.css';
|
||||
|
||||
const App = () => <div style={{fontFamily: 'sans-serif', padding: 24}}>
|
||||
<h1>What's The Point</h1>
|
||||
<p>Welcome! The app is running.</p>
|
||||
</div>;
|
||||
function ProtectedRoute({ children }) {
|
||||
const { user, loading } = useAuth();
|
||||
|
||||
if (loading) {
|
||||
return <div className="loading">Loading...</div>;
|
||||
}
|
||||
|
||||
return user ? children : <Navigate to="/login" />;
|
||||
}
|
||||
|
||||
function Header() {
|
||||
const { user, logout } = useAuth();
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<header className="header">
|
||||
<div className="container">
|
||||
<nav className="nav">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '2rem' }}>
|
||||
<h2 style={{ margin: 0 }}>WTP</h2>
|
||||
<ul className="nav-links">
|
||||
<li><Link to="/challenges">Challenges</Link></li>
|
||||
<li><Link to="/leaderboard">Leaderboard</Link></li>
|
||||
<li><Link to="/friends">Friends</Link></li>
|
||||
<li><Link to="/profile">Profile</Link></li>
|
||||
</ul>
|
||||
</div>
|
||||
<button onClick={logout} className="btn btn-secondary btn-sm">
|
||||
Logout
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<Header />
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/register" element={<Register />} />
|
||||
<Route path="/challenges" element={<ProtectedRoute><ChallengeList /></ProtectedRoute>} />
|
||||
<Route path="/challenges/:id" element={<ProtectedRoute><ChallengeDetail /></ProtectedRoute>} />
|
||||
<Route path="/profile" element={<ProtectedRoute><Profile /></ProtectedRoute>} />
|
||||
<Route path="/friends" element={<ProtectedRoute><Friends /></ProtectedRoute>} />
|
||||
<Route path="/leaderboard" element={<ProtectedRoute><Leaderboard /></ProtectedRoute>} />
|
||||
<Route path="/" element={<Navigate to="/challenges" />} />
|
||||
</Routes>
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
|
||||
|
||||
290
frontend/src/pages/ChallengeDetail.jsx
Normal file
290
frontend/src/pages/ChallengeDetail.jsx
Normal file
@@ -0,0 +1,290 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { useAuth } from '../AuthContext';
|
||||
import api from '../api';
|
||||
|
||||
export default function ChallengeDetail() {
|
||||
const { id } = useParams();
|
||||
const { user } = useAuth();
|
||||
const [challenge, setChallenge] = useState(null);
|
||||
const [predictions, setPredictions] = useState([]);
|
||||
const [leaderboard, setLeaderboard] = useState([]);
|
||||
const [newPrediction, setNewPrediction] = useState('');
|
||||
const [inviteQuery, setInviteQuery] = useState('');
|
||||
const [searchResults, setSearchResults] = useState([]);
|
||||
const [showInvite, setShowInvite] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
loadChallenge();
|
||||
loadPredictions();
|
||||
loadLeaderboard();
|
||||
}, [id]);
|
||||
|
||||
const loadChallenge = async () => {
|
||||
try {
|
||||
const data = await api.getChallenge(id);
|
||||
setChallenge(data);
|
||||
} catch (err) {
|
||||
console.error('Failed to load challenge:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadPredictions = async () => {
|
||||
try {
|
||||
const data = await api.getPredictions(id);
|
||||
setPredictions(data.predictions);
|
||||
} catch (err) {
|
||||
console.error('Failed to load predictions:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const loadLeaderboard = async () => {
|
||||
try {
|
||||
const data = await api.getChallengeLeaderboard(id);
|
||||
setLeaderboard(data.leaderboard);
|
||||
} catch (err) {
|
||||
console.error('Failed to load leaderboard:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreatePrediction = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!newPrediction.trim()) return;
|
||||
|
||||
try {
|
||||
await api.createPrediction({
|
||||
challenge_id: id,
|
||||
content: newPrediction
|
||||
});
|
||||
setNewPrediction('');
|
||||
await loadPredictions();
|
||||
} catch (err) {
|
||||
alert('Failed to create prediction: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleValidate = async (predictionId, status) => {
|
||||
try {
|
||||
await api.validatePrediction(predictionId, status);
|
||||
await loadPredictions();
|
||||
await loadLeaderboard();
|
||||
} catch (err) {
|
||||
alert('Failed to validate: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearchUsers = async (query) => {
|
||||
setInviteQuery(query);
|
||||
if (query.trim().length < 2) {
|
||||
setSearchResults([]);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await api.searchUsers(query);
|
||||
setSearchResults(data.users);
|
||||
} catch (err) {
|
||||
console.error('Search failed:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInvite = async (userId) => {
|
||||
try {
|
||||
await api.inviteToChallenge(id, { user_ids: [userId] });
|
||||
setInviteQuery('');
|
||||
setSearchResults([]);
|
||||
alert('Invitation sent!');
|
||||
} catch (err) {
|
||||
alert('Failed to send invite: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="loading">Loading challenge...</div>;
|
||||
}
|
||||
|
||||
if (!challenge) {
|
||||
return <div className="loading">Challenge not found</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: '2rem 0' }}>
|
||||
<div className="container">
|
||||
{/* Header */}
|
||||
<div style={{ marginBottom: '2rem' }}>
|
||||
<Link to="/challenges" style={{ color: 'var(--primary)', textDecoration: 'none' }}>← Back to Challenges</Link>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '2rem', marginBottom: '2rem', flexWrap: 'wrap' }}>
|
||||
{challenge.challenge.cover_image_url && (
|
||||
<img
|
||||
src={challenge.challenge.cover_image_url}
|
||||
alt={challenge.challenge.title}
|
||||
style={{ width: '150px', height: '225px', objectFit: 'cover', borderRadius: '0.5rem' }}
|
||||
/>
|
||||
)}
|
||||
<div style={{ flex: 1 }}>
|
||||
<h1 style={{ marginBottom: '0.5rem' }}>{challenge.challenge.title}</h1>
|
||||
<p style={{ color: 'var(--text-muted)', marginBottom: '1rem' }}>
|
||||
Created by {challenge.challenge.creator_username}
|
||||
</p>
|
||||
<button
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={() => setShowInvite(!showInvite)}
|
||||
>
|
||||
{showInvite ? 'Cancel' : 'Invite Friends'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Invite Section */}
|
||||
{showInvite && (
|
||||
<div className="card" style={{ marginBottom: '2rem', position: 'relative' }}>
|
||||
<h3 style={{ marginBottom: '1rem' }}>Invite Someone</h3>
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
placeholder="Search by username or email..."
|
||||
value={inviteQuery}
|
||||
onChange={(e) => handleSearchUsers(e.target.value)}
|
||||
/>
|
||||
{searchResults.length > 0 && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '100%',
|
||||
left: 0,
|
||||
right: 0,
|
||||
background: 'var(--bg)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: '0.5rem',
|
||||
marginTop: '0.5rem',
|
||||
maxHeight: '300px',
|
||||
overflowY: 'auto',
|
||||
zIndex: 10
|
||||
}}>
|
||||
{searchResults.map(user => (
|
||||
<div
|
||||
key={user.id}
|
||||
onClick={() => handleInvite(user.id)}
|
||||
style={{
|
||||
padding: '1rem',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 500 }}>{user.username}</div>
|
||||
<div style={{ fontSize: '0.875rem', color: 'var(--text-muted)' }}>{user.email}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'grid', gap: '2rem', gridTemplateColumns: 'minmax(0, 1fr) 300px' }}>
|
||||
{/* Main Content */}
|
||||
<div>
|
||||
{/* New Prediction */}
|
||||
<div className="card" style={{ marginBottom: '2rem' }}>
|
||||
<h3 style={{ marginBottom: '1rem' }}>Make a Prediction</h3>
|
||||
<form onSubmit={handleCreatePrediction}>
|
||||
<textarea
|
||||
className="textarea"
|
||||
placeholder="I predict that..."
|
||||
value={newPrediction}
|
||||
onChange={(e) => setNewPrediction(e.target.value)}
|
||||
style={{ minHeight: '80px' }}
|
||||
/>
|
||||
<button type="submit" className="btn btn-primary" style={{ marginTop: '1rem' }}>
|
||||
Submit Prediction
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Predictions Feed */}
|
||||
<h3 style={{ marginBottom: '1rem' }}>Predictions</h3>
|
||||
{predictions.length === 0 ? (
|
||||
<div className="card" style={{ textAlign: 'center', padding: '2rem' }}>
|
||||
<p style={{ color: 'var(--text-muted)' }}>No predictions yet. Be the first!</p>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||||
{predictions.map(pred => (
|
||||
<div key={pred.id} className="card">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '0.5rem' }}>
|
||||
<strong>{pred.username}</strong>
|
||||
<span style={{ fontSize: '0.875rem', color: 'var(--text-muted)' }}>
|
||||
{new Date(pred.created_at).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
<p style={{ marginBottom: '1rem' }}>{pred.content}</p>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
{pred.status === 'pending' && pred.user_id !== user.id && (
|
||||
<>
|
||||
<button
|
||||
className="btn btn-success btn-sm"
|
||||
onClick={() => handleValidate(pred.id, 'validated')}
|
||||
>
|
||||
✓ Validate
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-danger btn-sm"
|
||||
onClick={() => handleValidate(pred.id, 'invalidated')}
|
||||
>
|
||||
✗ Invalidate
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{pred.status === 'validated' && (
|
||||
<span style={{ color: 'var(--success)', fontSize: '0.875rem' }}>
|
||||
✓ Validated by {pred.validated_by_username}
|
||||
</span>
|
||||
)}
|
||||
{pred.status === 'invalidated' && (
|
||||
<span style={{ color: 'var(--danger)', fontSize: '0.875rem' }}>
|
||||
✗ Invalidated by {pred.validated_by_username}
|
||||
</span>
|
||||
)}
|
||||
{pred.status === 'pending' && (
|
||||
<span style={{ color: 'var(--warning)', fontSize: '0.875rem' }}>
|
||||
⏳ Pending validation
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sidebar - Leaderboard */}
|
||||
<div>
|
||||
<div className="card">
|
||||
<h3 style={{ marginBottom: '1rem' }}>Leaderboard</h3>
|
||||
{leaderboard.length === 0 ? (
|
||||
<p style={{ color: 'var(--text-muted)', fontSize: '0.875rem' }}>No points yet</p>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||
{leaderboard.map((entry, index) => (
|
||||
<div key={entry.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div>
|
||||
<span style={{ marginRight: '0.5rem', color: 'var(--text-muted)' }}>{index + 1}.</span>
|
||||
<strong>{entry.username}</strong>
|
||||
</div>
|
||||
<span style={{ color: 'var(--primary)', fontWeight: 600 }}>
|
||||
{entry.validated_points} pts
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
165
frontend/src/pages/ChallengeList.jsx
Normal file
165
frontend/src/pages/ChallengeList.jsx
Normal file
@@ -0,0 +1,165 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import api from '../api';
|
||||
|
||||
export default function ChallengeList() {
|
||||
const [challenges, setChallenges] = useState([]);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [showResults, setShowResults] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadChallenges();
|
||||
}, []);
|
||||
|
||||
const loadChallenges = async () => {
|
||||
try {
|
||||
const data = await api.getChallenges();
|
||||
setChallenges(data.challenges);
|
||||
} catch (err) {
|
||||
console.error('Failed to load challenges:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearch = async (query) => {
|
||||
setSearchQuery(query);
|
||||
if (query.trim().length < 2) {
|
||||
setShowResults([]);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await api.searchShows(query);
|
||||
setShowResults(data.results || []);
|
||||
} catch (err) {
|
||||
console.error('Search failed:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateChallenge = async (show) => {
|
||||
setCreating(true);
|
||||
try {
|
||||
const coverImage = show.poster_path
|
||||
? `https://image.tmdb.org/t/p/w500${show.poster_path}`
|
||||
: null;
|
||||
|
||||
const result = await api.createChallenge({
|
||||
title: show.title,
|
||||
cover_image_url: coverImage,
|
||||
tmdb_id: show.id,
|
||||
media_type: show.media_type
|
||||
});
|
||||
|
||||
setSearchQuery('');
|
||||
setShowResults([]);
|
||||
await loadChallenges();
|
||||
} catch (err) {
|
||||
alert('Failed to create challenge: ' + err.message);
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="loading">Loading challenges...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: '2rem 0' }}>
|
||||
<div className="container">
|
||||
<h1 style={{ marginBottom: '2rem' }}>My Challenges</h1>
|
||||
|
||||
{/* Search/Create */}
|
||||
<div style={{ marginBottom: '2rem', position: 'relative' }}>
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
placeholder="Search for a show or movie to create a challenge..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
/>
|
||||
|
||||
{showResults.length > 0 && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '100%',
|
||||
left: 0,
|
||||
right: 0,
|
||||
background: 'var(--bg-light)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: '0.5rem',
|
||||
marginTop: '0.5rem',
|
||||
maxHeight: '400px',
|
||||
overflowY: 'auto',
|
||||
zIndex: 10
|
||||
}}>
|
||||
{showResults.map(show => (
|
||||
<div
|
||||
key={show.id}
|
||||
onClick={() => handleCreateChallenge(show)}
|
||||
style={{
|
||||
padding: '1rem',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
gap: '1rem',
|
||||
alignItems: 'center'
|
||||
}}
|
||||
>
|
||||
{show.poster_path && (
|
||||
<img
|
||||
src={`https://image.tmdb.org/t/p/w92${show.poster_path}`}
|
||||
alt={show.title}
|
||||
style={{ width: '46px', height: '69px', objectFit: 'cover', borderRadius: '0.25rem' }}
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{show.title}</div>
|
||||
<div style={{ fontSize: '0.875rem', color: 'var(--text-muted)' }}>
|
||||
{show.media_type === 'tv' ? 'TV Show' : 'Movie'} • {show.release_date?.substring(0, 4)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Challenge List */}
|
||||
{challenges.length === 0 ? (
|
||||
<div className="card" style={{ textAlign: 'center', padding: '3rem' }}>
|
||||
<p style={{ color: 'var(--text-muted)' }}>No challenges yet. Search for a show above to create one!</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-2">
|
||||
{challenges.map(challenge => (
|
||||
<Link key={challenge.id} to={`/challenges/${challenge.id}`} style={{ textDecoration: 'none' }}>
|
||||
<div className="card" style={{ height: '100%', display: 'flex', gap: '1rem' }}>
|
||||
{challenge.cover_image_url && (
|
||||
<img
|
||||
src={challenge.cover_image_url}
|
||||
alt={challenge.title}
|
||||
style={{ width: '80px', height: '120px', objectFit: 'cover', borderRadius: '0.5rem' }}
|
||||
/>
|
||||
)}
|
||||
<div style={{ flex: 1 }}>
|
||||
<h3 style={{ marginBottom: '0.5rem' }}>{challenge.title}</h3>
|
||||
<p style={{ fontSize: '0.875rem', color: 'var(--text-muted)' }}>
|
||||
Created by {challenge.creator_username}
|
||||
</p>
|
||||
<p style={{ marginTop: '0.5rem', color: 'var(--primary)' }}>
|
||||
{challenge.my_points} points
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
198
frontend/src/pages/Friends.jsx
Normal file
198
frontend/src/pages/Friends.jsx
Normal file
@@ -0,0 +1,198 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import api from '../api';
|
||||
|
||||
export default function Friends() {
|
||||
const [friends, setFriends] = useState([]);
|
||||
const [challengeFriends, setChallengeFriends] = useState([]);
|
||||
const [requests, setRequests] = useState([]);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchResults, setSearchResults] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const [friendsData, requestsData] = await Promise.all([
|
||||
api.getFriends(),
|
||||
api.getFriendRequests()
|
||||
]);
|
||||
setFriends(friendsData.friends);
|
||||
setChallengeFriends(friendsData.challenge_friends || []);
|
||||
setRequests(requestsData.requests);
|
||||
} catch (err) {
|
||||
console.error('Failed to load data:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearch = async (query) => {
|
||||
setSearchQuery(query);
|
||||
if (query.trim().length < 2) {
|
||||
setSearchResults([]);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await api.searchUsers(query);
|
||||
setSearchResults(data.users);
|
||||
} catch (err) {
|
||||
console.error('Search failed:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendRequest = async (userId) => {
|
||||
try {
|
||||
await api.sendFriendRequest(userId);
|
||||
setSearchQuery('');
|
||||
setSearchResults([]);
|
||||
alert('Friend request sent!');
|
||||
} catch (err) {
|
||||
alert('Failed to send request: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRespond = async (requestId, status) => {
|
||||
try {
|
||||
await api.respondToFriendRequest(requestId, status);
|
||||
await loadData();
|
||||
} catch (err) {
|
||||
alert('Failed to respond: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="loading">Loading...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: '2rem 0' }}>
|
||||
<div className="container">
|
||||
<h1 style={{ marginBottom: '2rem' }}>Friends</h1>
|
||||
|
||||
{/* Search */}
|
||||
<div className="card" style={{ marginBottom: '2rem', position: 'relative' }}>
|
||||
<h3 style={{ marginBottom: '1rem' }}>Add Friends</h3>
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
placeholder="Search by username or email..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
/>
|
||||
{searchResults.length > 0 && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '100%',
|
||||
left: 0,
|
||||
right: 0,
|
||||
background: 'var(--bg)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: '0.5rem',
|
||||
marginTop: '0.5rem',
|
||||
maxHeight: '300px',
|
||||
overflowY: 'auto',
|
||||
zIndex: 10
|
||||
}}>
|
||||
{searchResults.map(user => (
|
||||
<div
|
||||
key={user.id}
|
||||
style={{
|
||||
padding: '1rem',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center'
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{user.username}</div>
|
||||
<div style={{ fontSize: '0.875rem', color: 'var(--text-muted)' }}>{user.email}</div>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={() => handleSendRequest(user.id)}
|
||||
>
|
||||
Add Friend
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pending Requests */}
|
||||
{requests.length > 0 && (
|
||||
<div className="card" style={{ marginBottom: '2rem' }}>
|
||||
<h3 style={{ marginBottom: '1rem' }}>Friend Requests</h3>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||||
{requests.map(req => (
|
||||
<div key={req.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{req.username}</div>
|
||||
<div style={{ fontSize: '0.875rem', color: 'var(--text-muted)' }}>{req.email}</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||
<button
|
||||
className="btn btn-success btn-sm"
|
||||
onClick={() => handleRespond(req.id, 'accepted')}
|
||||
>
|
||||
Accept
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-danger btn-sm"
|
||||
onClick={() => handleRespond(req.id, 'rejected')}
|
||||
>
|
||||
Reject
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Friends List */}
|
||||
<div className="card">
|
||||
<h3 style={{ marginBottom: '1rem' }}>Your Friends</h3>
|
||||
{friends.length === 0 && challengeFriends.length === 0 ? (
|
||||
<p style={{ color: 'var(--text-muted)' }}>No friends yet. Search above to add some!</p>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||||
{friends.map(friend => (
|
||||
<div key={friend.id} style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{friend.username}</div>
|
||||
<div style={{ fontSize: '0.875rem', color: 'var(--text-muted)' }}>{friend.email}</div>
|
||||
</div>
|
||||
<div style={{ color: 'var(--primary)' }}>{friend.total_points} points</div>
|
||||
</div>
|
||||
))}
|
||||
{challengeFriends.length > 0 && (
|
||||
<>
|
||||
<div style={{ borderTop: '1px solid var(--border)', paddingTop: '1rem', marginTop: '0.5rem' }}>
|
||||
<div style={{ fontSize: '0.875rem', color: 'var(--text-muted)', marginBottom: '0.5rem' }}>
|
||||
From Challenges
|
||||
</div>
|
||||
</div>
|
||||
{challengeFriends.map(friend => (
|
||||
<div key={friend.id} style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{friend.username}</div>
|
||||
<div style={{ fontSize: '0.875rem', color: 'var(--text-muted)' }}>{friend.email}</div>
|
||||
</div>
|
||||
<div style={{ color: 'var(--primary)' }}>{friend.total_points} points</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
89
frontend/src/pages/Leaderboard.jsx
Normal file
89
frontend/src/pages/Leaderboard.jsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import api from '../api';
|
||||
|
||||
export default function Leaderboard() {
|
||||
const [leaderboard, setLeaderboard] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
loadLeaderboard();
|
||||
}, []);
|
||||
|
||||
const loadLeaderboard = async () => {
|
||||
try {
|
||||
const data = await api.getGlobalLeaderboard();
|
||||
setLeaderboard(data.leaderboard);
|
||||
} catch (err) {
|
||||
console.error('Failed to load leaderboard:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="loading">Loading leaderboard...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: '2rem 0' }}>
|
||||
<div className="container">
|
||||
<h1 style={{ marginBottom: '2rem' }}>Global Leaderboard</h1>
|
||||
|
||||
<div className="card">
|
||||
{leaderboard.length === 0 ? (
|
||||
<p style={{ textAlign: 'center', color: 'var(--text-muted)', padding: '2rem' }}>
|
||||
No one has any points yet. Start making predictions!
|
||||
</p>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||||
{leaderboard.map((entry, index) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
className="card"
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
background: index < 3 ? 'var(--bg-lighter)' : 'var(--bg-light)'
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
|
||||
<div
|
||||
style={{
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
borderRadius: '50%',
|
||||
background: index === 0 ? '#fbbf24' : index === 1 ? '#94a3b8' : index === 2 ? '#cd7f32' : 'var(--bg)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontWeight: 'bold',
|
||||
fontSize: '1.125rem'
|
||||
}}
|
||||
>
|
||||
{index + 1}
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, fontSize: '1.125rem' }}>{entry.username}</div>
|
||||
<div style={{ fontSize: '0.875rem', color: 'var(--text-muted)' }}>
|
||||
{entry.challenges_participated} challenge{entry.challenges_participated !== 1 ? 's' : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<div style={{ fontSize: '1.5rem', fontWeight: 'bold', color: 'var(--primary)' }}>
|
||||
{entry.total_points}
|
||||
</div>
|
||||
<div style={{ fontSize: '0.875rem', color: 'var(--text-muted)' }}>
|
||||
points
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
64
frontend/src/pages/Login.jsx
Normal file
64
frontend/src/pages/Login.jsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import { useAuth } from '../AuthContext';
|
||||
|
||||
export default function Login() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { login } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
await login(email, password);
|
||||
navigate('/challenges');
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '1rem' }}>
|
||||
<div className="card" style={{ width: '100%', maxWidth: '400px' }}>
|
||||
<h1 style={{ marginBottom: '2rem', textAlign: 'center' }}>What's The Point</h1>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label>Email</label>
|
||||
<input
|
||||
type="email"
|
||||
className="input"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Password</label>
|
||||
<input
|
||||
type="password"
|
||||
className="input"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{error && <div className="error">{error}</div>}
|
||||
<button type="submit" className="btn btn-primary" style={{ width: '100%', marginTop: '1rem' }} disabled={loading}>
|
||||
{loading ? 'Logging in...' : 'Login'}
|
||||
</button>
|
||||
</form>
|
||||
<p style={{ marginTop: '1.5rem', textAlign: 'center', color: 'var(--text-muted)' }}>
|
||||
Don't have an account? <Link to="/register" style={{ color: 'var(--primary)' }}>Register</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
82
frontend/src/pages/Profile.jsx
Normal file
82
frontend/src/pages/Profile.jsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useAuth } from '../AuthContext';
|
||||
import api from '../api';
|
||||
|
||||
export default function Profile() {
|
||||
const { user } = useAuth();
|
||||
const [profile, setProfile] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
loadProfile();
|
||||
}, []);
|
||||
|
||||
const loadProfile = async () => {
|
||||
try {
|
||||
const data = await api.getProfile();
|
||||
setProfile(data.profile);
|
||||
} catch (err) {
|
||||
console.error('Failed to load profile:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="loading">Loading profile...</div>;
|
||||
}
|
||||
|
||||
if (!profile) {
|
||||
return <div className="loading">Profile not found</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: '2rem 0' }}>
|
||||
<div className="container">
|
||||
<h1 style={{ marginBottom: '2rem' }}>Profile</h1>
|
||||
|
||||
<div className="grid grid-2">
|
||||
<div className="card">
|
||||
<h3 style={{ marginBottom: '1rem' }}>User Info</h3>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: '0.875rem', color: 'var(--text-muted)' }}>Username</div>
|
||||
<div style={{ fontWeight: 500 }}>{profile.username}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: '0.875rem', color: 'var(--text-muted)' }}>Email</div>
|
||||
<div>{profile.email}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: '0.875rem', color: 'var(--text-muted)' }}>Member Since</div>
|
||||
<div>{new Date(profile.created_at).toLocaleDateString()}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h3 style={{ marginBottom: '1rem' }}>Stats</h3>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span style={{ color: 'var(--text-muted)' }}>Total Points</span>
|
||||
<strong style={{ color: 'var(--primary)', fontSize: '1.25rem' }}>{profile.total_points}</strong>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span style={{ color: 'var(--text-muted)' }}>Pending Predictions</span>
|
||||
<strong>{profile.pending_predictions}</strong>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span style={{ color: 'var(--text-muted)' }}>Challenges Created</span>
|
||||
<strong>{profile.challenges_created}</strong>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span style={{ color: 'var(--text-muted)' }}>Challenges Joined</span>
|
||||
<strong>{profile.challenges_joined}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
76
frontend/src/pages/Register.jsx
Normal file
76
frontend/src/pages/Register.jsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import { useAuth } from '../AuthContext';
|
||||
|
||||
export default function Register() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { register } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
await register(email, username, password);
|
||||
navigate('/challenges');
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '1rem' }}>
|
||||
<div className="card" style={{ width: '100%', maxWidth: '400px' }}>
|
||||
<h1 style={{ marginBottom: '2rem', textAlign: 'center' }}>Create Account</h1>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label>Email</label>
|
||||
<input
|
||||
type="email"
|
||||
className="input"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Username</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Password</label>
|
||||
<input
|
||||
type="password"
|
||||
className="input"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
</div>
|
||||
{error && <div className="error">{error}</div>}
|
||||
<button type="submit" className="btn btn-primary" style={{ width: '100%', marginTop: '1rem' }} disabled={loading}>
|
||||
{loading ? 'Creating account...' : 'Register'}
|
||||
</button>
|
||||
</form>
|
||||
<p style={{ marginTop: '1.5rem', textAlign: 'center', color: 'var(--text-muted)' }}>
|
||||
Already have an account? <Link to="/login" style={{ color: 'var(--primary)' }}>Login</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
13
frontend/vite.config.js
Normal file
13
frontend/vite.config.js
Normal file
@@ -0,0 +1,13 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 5173,
|
||||
watch: {
|
||||
usePolling: true
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user