setup features

This commit is contained in:
2026-01-29 00:24:10 -05:00
parent 787c97a52f
commit 4a6e2c307c
34 changed files with 2891 additions and 71 deletions

View File

@@ -1,7 +0,0 @@
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 5173
CMD ["npm", "run", "dev"]

View File

@@ -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
View 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;
}
}

View 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
View 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();

View File

@@ -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 />);

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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
View 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
}
}
});