Initial commit

This commit is contained in:
Allen
2026-04-30 02:14:25 +00:00
commit 7090e6f01d
243 changed files with 115122 additions and 0 deletions
+284
View File
@@ -0,0 +1,284 @@
.achievements-page {
min-height: calc(100vh - 60px);
padding: 2rem 1rem;
background: linear-gradient(180deg, #f8fafc 0%, #e2e8f0 100%);
max-width: 1000px;
margin: 0 auto;
}
.achievements-header {
text-align: center;
margin-bottom: 2rem;
background: white;
border-radius: 1rem;
padding: 2rem;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.06);
}
.achievements-header h1 {
margin: 0;
font-size: 2rem;
color: #1e293b;
}
.achievements-subtitle {
color: #64748b;
margin: 0.5rem 0 1.5rem;
}
.achievements-stats {
display: flex;
justify-content: center;
gap: 2rem;
margin-bottom: 1.5rem;
}
.stat-box {
text-align: center;
}
.stat-box .stat-value {
font-size: 1.75rem;
font-weight: 700;
color: #1e293b;
}
.stat-box .stat-label {
font-size: 0.85rem;
color: #64748b;
}
.stat-box.highlight .stat-value {
color: #6366f1;
}
.progress-bar-container {
background: #e2e8f0;
border-radius: 1rem;
height: 12px;
overflow: hidden;
max-width: 400px;
margin: 0 auto;
}
.progress-bar-fill {
height: 100%;
background: linear-gradient(90deg, #6366f1 0%, #8b5cf6 100%);
border-radius: 1rem;
transition: width 0.5s ease;
}
/* Category Filters */
.category-filters {
display: flex;
justify-content: center;
gap: 0.5rem;
margin-bottom: 2rem;
flex-wrap: wrap;
}
.filter-btn {
background: white;
border: 2px solid #e2e8f0;
padding: 0.5rem 1rem;
border-radius: 2rem;
font-size: 0.9rem;
cursor: pointer;
transition: all 0.2s;
}
.filter-btn:hover {
border-color: #6366f1;
}
.filter-btn.active {
background: #6366f1;
color: white;
border-color: #6366f1;
}
/* Achievements Grid */
.achievements-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1rem;
}
.achievement-card {
display: flex;
gap: 1rem;
background: white;
border-radius: 1rem;
padding: 1.25rem;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
transition: all 0.2s;
}
.achievement-card.earned {
border-left: 4px solid #10b981;
}
.achievement-card.locked {
opacity: 0.6;
filter: grayscale(30%);
}
.achievement-card.locked:hover {
opacity: 0.8;
}
.achievement-icon-wrap {
position: relative;
flex-shrink: 0;
width: 60px;
height: 60px;
display: flex;
align-items: center;
justify-content: center;
background: #f8fafc;
border-radius: 50%;
}
.achievement-card.earned .achievement-icon-wrap {
background: linear-gradient(135deg, #dcfce7 0%, #bbf7d0 100%);
}
.achievement-card.locked .achievement-icon-wrap {
background: #f1f5f9;
}
.achievement-icon {
font-size: 1.75rem;
}
.earned-check {
position: absolute;
bottom: -2px;
right: -2px;
background: #10b981;
color: white;
width: 20px;
height: 20px;
border-radius: 50%;
font-size: 0.75rem;
display: flex;
align-items: center;
justify-content: center;
border: 2px solid white;
}
.achievement-info {
flex: 1;
min-width: 0;
}
.achievement-name {
margin: 0 0 0.25rem;
font-size: 1rem;
color: #1e293b;
}
.achievement-description {
margin: 0 0 0.5rem;
font-size: 0.85rem;
color: #64748b;
line-height: 1.4;
}
.achievement-meta {
display: flex;
gap: 1rem;
align-items: center;
flex-wrap: wrap;
}
.xp-reward {
background: #f0f9ff;
color: #0369a1;
padding: 0.2rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.75rem;
font-weight: 600;
}
.earned-date {
font-size: 0.75rem;
color: #94a3b8;
}
/* Progress for unearned achievements */
.achievement-progress {
margin-top: 0.5rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.mini-progress-bar {
flex: 1;
height: 6px;
background: #e2e8f0;
border-radius: 3px;
overflow: hidden;
}
.mini-progress-fill {
height: 100%;
background: #6366f1;
border-radius: 3px;
transition: width 0.3s;
}
.progress-text {
font-size: 0.75rem;
color: #64748b;
white-space: nowrap;
}
.empty-state {
text-align: center;
padding: 3rem;
color: #64748b;
}
.loading {
text-align: center;
padding: 4rem;
color: #64748b;
}
@media (max-width: 640px) {
.achievements-page {
padding: 1rem;
}
.achievements-header {
padding: 1.5rem;
}
.achievements-header h1 {
font-size: 1.5rem;
}
.achievements-stats {
gap: 1rem;
}
.stat-box .stat-value {
font-size: 1.25rem;
}
.category-filters {
justify-content: flex-start;
overflow-x: auto;
padding-bottom: 0.5rem;
}
.filter-btn {
white-space: nowrap;
}
.achievements-grid {
grid-template-columns: 1fr;
}
}
+143
View File
@@ -0,0 +1,143 @@
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import api from '../api';
import './Achievements.css';
export default function Achievements() {
const navigate = useNavigate();
const { user, isAuthenticated } = useAuth();
const [achievements, setAchievements] = useState([]);
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState('all');
useEffect(() => {
if (!isAuthenticated) {
navigate('/login');
return;
}
loadAchievements();
}, [isAuthenticated]);
const loadAchievements = async () => {
try {
const data = await api.getAllAchievements();
setAchievements(data.achievements || []);
} catch (error) {
console.error('Failed to load achievements:', error);
} finally {
setLoading(false);
}
};
const categories = [
{ id: 'all', label: 'All' },
{ id: 'creation', label: 'Creation' },
{ id: 'play', label: 'Playing' },
{ id: 'social', label: 'Social' },
{ id: 'special', label: 'Special' },
{ id: 'onboarding', label: 'Getting Started' }
];
const filteredAchievements = filter === 'all'
? achievements
: achievements.filter(a => a.category === filter);
const earnedCount = achievements.filter(a => a.earnedAt).length;
const totalCount = achievements.length;
const totalXP = achievements.filter(a => a.earnedAt).reduce((sum, a) => sum + (a.xpReward || 0), 0);
if (loading) {
return (
<div className="achievements-page">
<div className="loading">Loading achievements...</div>
</div>
);
}
return (
<div className="achievements-page">
<div className="achievements-header">
<h1>Achievements</h1>
<p className="achievements-subtitle">Complete challenges and earn XP!</p>
<div className="achievements-stats">
<div className="stat-box">
<div className="stat-value">{earnedCount}/{totalCount}</div>
<div className="stat-label">Unlocked</div>
</div>
<div className="stat-box">
<div className="stat-value">{Math.round((earnedCount / totalCount) * 100)}%</div>
<div className="stat-label">Complete</div>
</div>
<div className="stat-box highlight">
<div className="stat-value">+{totalXP}</div>
<div className="stat-label">XP Earned</div>
</div>
</div>
<div className="progress-bar-container">
<div
className="progress-bar-fill"
style={{ width: `${(earnedCount / totalCount) * 100}%` }}
/>
</div>
</div>
<div className="category-filters">
{categories.map(cat => (
<button
key={cat.id}
className={`filter-btn ${filter === cat.id ? 'active' : ''}`}
onClick={() => setFilter(cat.id)}
>
{cat.label}
</button>
))}
</div>
<div className="achievements-grid">
{filteredAchievements.map(achievement => (
<div
key={achievement.code}
className={`achievement-card ${achievement.earnedAt ? 'earned' : 'locked'}`}
>
<div className="achievement-icon-wrap">
<span className="achievement-icon">{achievement.icon}</span>
{achievement.earnedAt && <span className="earned-check"></span>}
</div>
<div className="achievement-info">
<h3 className="achievement-name">{achievement.name}</h3>
<p className="achievement-description">{achievement.description}</p>
<div className="achievement-meta">
<span className="xp-reward">+{achievement.xpReward} XP</span>
{achievement.earnedAt && (
<span className="earned-date">
Earned {new Date(achievement.earnedAt).toLocaleDateString()}
</span>
)}
</div>
{achievement.progress !== undefined && !achievement.earnedAt && (
<div className="achievement-progress">
<div className="mini-progress-bar">
<div
className="mini-progress-fill"
style={{ width: `${Math.min(100, (achievement.progress / achievement.threshold) * 100)}%` }}
/>
</div>
<span className="progress-text">{achievement.progress}/{achievement.threshold}</span>
</div>
)}
</div>
</div>
))}
</div>
{filteredAchievements.length === 0 && (
<div className="empty-state">
<p>No achievements in this category yet!</p>
</div>
)}
</div>
);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+263
View File
@@ -0,0 +1,263 @@
.arcade {
max-width: 1200px;
margin: 0 auto;
padding: 2rem 1.5rem;
}
.arcade-header {
margin-bottom: 2rem;
}
.arcade-header h1 {
font-size: 2rem;
margin: 0 0 0.5rem;
color: #1e293b;
}
.arcade-subtitle {
color: #475569; /* WCAG AA: 7.0:1 contrast on #f8fafc */
margin: 0 0 1.5rem;
font-size: 0.95rem;
}
.arcade-controls {
display: flex;
flex-wrap: wrap;
gap: 1rem;
align-items: center;
justify-content: space-between;
}
.search-form {
display: flex;
gap: 0.5rem;
flex: 1;
max-width: 400px;
}
.search-input {
flex: 1;
padding: 0.6rem 1rem;
border: 2px solid #e2e8f0;
border-radius: 0.5rem;
font-size: 1rem;
outline: none;
transition: border-color 0.2s;
}
.search-input:focus {
border-color: #6366f1;
}
.sort-tabs {
display: flex;
gap: 0.5rem;
background: #f1f5f9;
padding: 0.25rem;
border-radius: 0.5rem;
}
.tab {
padding: 0.5rem 1rem;
border: none;
background: transparent;
border-radius: 0.375rem;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
color: #475569; /* WCAG AA: 5.9:1 contrast on #f1f5f9 */
transition: all 0.2s;
}
.tab:hover {
color: #1e293b;
}
.tab.active {
background: white;
color: #6366f1;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
/* Difficulty filters */
.difficulty-filters {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.filter-label {
font-size: 0.85rem;
color: #64748b;
font-weight: 500;
}
.difficulty-tab {
padding: 0.4rem 0.75rem;
border: 2px solid #e2e8f0;
background: white;
border-radius: 2rem;
font-size: 0.85rem;
font-weight: 500;
cursor: pointer;
color: #64748b;
transition: all 0.2s;
}
.difficulty-tab:hover {
border-color: #cbd5e1;
color: #1e293b;
}
.difficulty-tab.active {
border-color: #6366f1;
color: #6366f1;
background: #eef2ff;
}
.difficulty-tab.difficulty-easy.active {
border-color: #10b981;
color: #065f46;
background: #d1fae5;
}
.difficulty-tab.difficulty-medium.active {
border-color: #f59e0b;
color: #92400e;
background: #fef3c7;
}
.difficulty-tab.difficulty-hard.active {
border-color: #ef4444;
color: #991b1b;
background: #fee2e2;
}
.games-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 1.5rem;
}
.loading,
.empty {
text-align: center;
padding: 4rem 1rem;
color: #475569; /* WCAG AA: 7.0:1 contrast on #f8fafc */
}
.load-more {
text-align: center;
margin-top: 2rem;
}
.token-info {
margin-top: 3rem;
padding: 1.5rem;
background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
border-radius: 1rem;
}
.token-info h3 {
margin: 0 0 1rem;
color: #92400e;
}
.token-info ul {
margin: 0;
padding-left: 1.5rem;
color: #78350f;
}
.token-info li {
margin-bottom: 0.5rem;
}
/* Mobile Responsive */
@media (max-width: 768px) {
.arcade {
padding: 1rem;
}
.arcade-header h1 {
font-size: 1.5rem;
}
.arcade-subtitle {
font-size: 0.875rem;
}
.arcade-controls {
flex-direction: column;
align-items: stretch;
gap: 0.75rem;
}
.search-form {
max-width: none;
}
.search-input {
padding: 0.75rem 1rem;
font-size: 16px; /* Prevent iOS zoom */
}
.sort-tabs {
justify-content: center;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
-ms-overflow-style: none;
}
.sort-tabs::-webkit-scrollbar {
display: none;
}
.tab {
min-height: 44px;
padding: 0.625rem 1rem;
white-space: nowrap;
}
.difficulty-filters {
justify-content: flex-start;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
padding-bottom: 0.5rem;
}
.difficulty-tab {
min-height: 40px;
padding: 0.5rem 1rem;
white-space: nowrap;
}
.games-grid {
grid-template-columns: 1fr;
gap: 1rem;
}
}
/* Small mobile screens */
@media (max-width: 480px) {
.arcade {
padding: 0.75rem;
}
.games-grid {
grid-template-columns: 1fr;
gap: 0.75rem;
}
}
/* Touch-friendly targets */
@media (hover: none), (pointer: coarse) {
.tab,
.difficulty-tab,
.search-input,
.btn {
min-height: 44px;
}
}
+186
View File
@@ -0,0 +1,186 @@
import { useState, useEffect } from 'react';
import GameCard from '../components/GameCard';
import OnboardingTutorial from '../components/OnboardingTutorial';
import { useAuth } from '../context/AuthContext';
import api from '../api';
import './Arcade.css';
export default function Arcade() {
const { isAuthenticated, isGuest } = useAuth();
const [showGuestOnboarding, setShowGuestOnboarding] = useState(false);
const [games, setGames] = useState([]);
const [loading, setLoading] = useState(true);
const [sort, setSort] = useState('trending');
const [search, setSearch] = useState('');
const [difficulty, setDifficulty] = useState('');
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(false);
useEffect(() => {
if (!isAuthenticated || isGuest) {
if (!localStorage.getItem('gamercomp_guest_onboarding')) {
setShowGuestOnboarding(true);
}
}
}, [isAuthenticated, isGuest]);
useEffect(() => {
loadGames();
}, [sort, difficulty]);
const loadGames = async (reset = true) => {
setLoading(true);
try {
const newPage = reset ? 1 : page;
const data = await api.getGames({ sort, search, difficulty, page: newPage, limit: 20 });
if (reset) {
setGames(data.games);
setPage(1);
} else {
setGames(prev => [...prev, ...data.games]);
}
setHasMore(data.hasMore);
} catch (error) {
console.error('Failed to load games:', error);
} finally {
setLoading(false);
}
};
const handleSearch = (e) => {
e.preventDefault();
loadGames(true);
};
const loadMore = () => {
setPage(p => p + 1);
loadGames(false);
};
const handleToggleFavorite = async (gameId) => {
if (!isAuthenticated) return;
await api.toggleFavorite(gameId);
};
return (
<div className="arcade">
{showGuestOnboarding && (
<OnboardingTutorial onComplete={() => {
setShowGuestOnboarding(false);
localStorage.setItem('gamercomp_guest_onboarding', 'true');
}} />
)}
<div className="arcade-header">
<h1>Game Arcade</h1>
<p className="arcade-subtitle">Live game previews - click to play!</p>
<div className="arcade-controls">
<form onSubmit={handleSearch} className="search-form">
<input
type="text"
placeholder="Search games..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="search-input"
/>
<button type="submit" className="btn btn-primary">Search</button>
</form>
<div className="sort-tabs">
<button
className={`tab ${sort === 'trending' ? 'active' : ''}`}
onClick={() => setSort('trending')}
>
🔥 Trending
</button>
<button
className={`tab ${sort === 'new' ? 'active' : ''}`}
onClick={() => setSort('new')}
>
New
</button>
<button
className={`tab ${sort === 'top-rated' ? 'active' : ''}`}
onClick={() => setSort('top-rated')}
>
Top Rated
</button>
</div>
<div className="difficulty-filters">
<span className="filter-label">Difficulty:</span>
<button
className={`difficulty-tab ${difficulty === '' ? 'active' : ''}`}
onClick={() => setDifficulty('')}
>
All
</button>
<button
className={`difficulty-tab difficulty-easy ${difficulty === 'easy' ? 'active' : ''}`}
onClick={() => setDifficulty('easy')}
>
Easy
</button>
<button
className={`difficulty-tab difficulty-medium ${difficulty === 'medium' ? 'active' : ''}`}
onClick={() => setDifficulty('medium')}
>
Medium
</button>
<button
className={`difficulty-tab difficulty-hard ${difficulty === 'hard' ? 'active' : ''}`}
onClick={() => setDifficulty('hard')}
>
Hard
</button>
</div>
</div>
</div>
{loading && games.length === 0 ? (
<div className="loading">Loading games...</div>
) : games.length === 0 ? (
<div className="empty">
<p>No games found. Be the first to create one!</p>
</div>
) : (
<>
<div className="games-grid">
{games.map(game => (
<GameCard
key={game.id}
game={game}
showPreview={true}
gameCode={game.gameCode}
onToggleFavorite={isAuthenticated ? handleToggleFavorite : null}
/>
))}
</div>
{hasMore && (
<div className="load-more">
<button
onClick={loadMore}
className="btn btn-secondary"
disabled={loading}
>
{loading ? 'Loading...' : 'Load More'}
</button>
</div>
)}
</>
)}
<div className="token-info">
<h3>🪙 Token System</h3>
<ul>
<li>Get <strong>50 free tokens</strong> every day</li>
<li>Tokens are only charged after <strong>1 minute</strong> of play</li>
<li>1 token per minute of gameplay</li>
<li>Game creators earn tokens when you play their games!</li>
</ul>
</div>
</div>
);
}
+330
View File
@@ -0,0 +1,330 @@
.auth-page {
min-height: calc(100vh - 60px);
display: flex;
align-items: center;
justify-content: center;
padding: 2rem 1rem;
background: linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%);
}
.auth-card {
background: white;
border-radius: 1rem;
padding: 2.5rem 2rem;
width: 100%;
max-width: 400px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
}
.auth-card h1 {
font-size: 1.75rem;
margin: 0 0 0.5rem;
text-align: center;
color: #1e293b;
}
.auth-subtitle {
text-align: center;
color: #64748b;
margin: 0 0 2rem;
}
.auth-form {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.form-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.form-group label {
font-weight: 500;
color: #374151;
font-size: 0.95rem;
}
.form-group input[type="text"],
.form-group input[type="email"],
.form-group input[type="password"],
.form-group textarea {
padding: 0.75rem 1rem;
border: 2px solid #e5e7eb;
border-radius: 0.5rem;
font-size: 1rem;
outline: none;
transition: border-color 0.2s;
}
.form-group input:focus,
.form-group textarea:focus {
border-color: #6366f1;
}
.checkbox-group {
flex-direction: row;
}
.checkbox-label {
display: flex;
align-items: flex-start;
gap: 0.5rem;
font-weight: normal;
cursor: pointer;
}
.checkbox-label input {
margin-top: 0.25rem;
width: 1rem;
height: 1rem;
}
.checkbox-label span {
font-size: 0.9rem;
color: #475569;
line-height: 1.4;
}
.checkbox-label span a {
color: #6366f1;
text-decoration: none;
}
.checkbox-label span a:hover {
text-decoration: underline;
}
.error-alert {
background: #fef2f2;
border: 1px solid #fecaca;
color: #dc2626;
padding: 0.75rem 1rem;
border-radius: 0.5rem;
font-size: 0.9rem;
}
.btn-block {
width: 100%;
padding: 0.875rem;
font-size: 1rem;
}
.auth-footer {
text-align: center;
margin: 1.5rem 0 0;
color: #64748b;
font-size: 0.95rem;
}
.auth-footer a {
color: #6366f1;
text-decoration: none;
font-weight: 500;
}
.auth-footer a:hover {
text-decoration: underline;
}
/* Username suggestions */
.username-notice {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.5rem;
padding: 0.5rem 0.75rem;
background: #fef3c7;
border-radius: 0.375rem;
font-size: 0.85rem;
color: #92400e;
}
.notice-icon {
font-size: 1rem;
}
.field-error {
color: #dc2626;
font-size: 0.85rem;
margin-top: 0.25rem;
}
.field-hint {
color: #64748b;
font-size: 0.8rem;
margin-top: 0.25rem;
}
.username-suggestions {
margin-top: 0.75rem;
padding: 0.75rem;
background: #f0f9ff;
border-radius: 0.5rem;
}
.suggestions-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
font-size: 0.85rem;
color: #0369a1;
}
.refresh-btn {
background: none;
border: none;
color: #0369a1;
cursor: pointer;
font-size: 0.85rem;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
}
.refresh-btn:hover {
background: rgba(3, 105, 161, 0.1);
}
.suggestions-list {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.suggestion-btn {
padding: 0.375rem 0.75rem;
background: white;
border: 1px solid #bae6fd;
border-radius: 1rem;
font-size: 0.85rem;
color: #0369a1;
cursor: pointer;
transition: all 0.2s;
}
.suggestion-btn:hover {
background: #0369a1;
color: white;
border-color: #0369a1;
}
/* Forgot password link */
.forgot-password-link {
text-align: right;
margin-top: -0.5rem;
}
.forgot-password-link a {
color: #6366f1;
font-size: 0.875rem;
text-decoration: none;
}
.forgot-password-link a:hover {
text-decoration: underline;
}
/* Auth container (for password reset pages) */
.auth-container {
background: white;
border-radius: 1rem;
padding: 2.5rem 2rem;
width: 100%;
max-width: 400px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
text-align: center;
}
.auth-header {
margin-bottom: 1.5rem;
}
.auth-icon {
font-size: 3rem;
display: block;
margin-bottom: 1rem;
}
.auth-header h1 {
font-size: 1.5rem;
margin: 0 0 0.5rem;
color: #1e293b;
}
.auth-header p {
color: #64748b;
margin: 0;
}
.auth-container .auth-form {
text-align: left;
}
.auth-error {
background: #fef2f2;
border: 1px solid #fecaca;
color: #dc2626;
padding: 0.75rem 1rem;
border-radius: 0.5rem;
font-size: 0.9rem;
margin-bottom: 1rem;
}
.auth-error-box {
background: #fef2f2;
border: 1px solid #fecaca;
color: #dc2626;
padding: 1.5rem;
border-radius: 0.5rem;
margin-bottom: 1.5rem;
}
.auth-success {
background: #f0fdf4;
border: 1px solid #bbf7d0;
color: #166534;
padding: 1.5rem;
border-radius: 0.5rem;
margin-bottom: 1.5rem;
}
.auth-success p {
margin: 0 0 0.5rem;
}
.auth-success p:last-child {
margin-bottom: 0;
}
.auth-hint {
font-size: 0.875rem;
color: #64748b;
}
.auth-links {
margin-top: 1.5rem;
}
.auth-links p {
margin: 0.5rem 0;
color: #64748b;
font-size: 0.9rem;
}
.auth-links a {
color: #6366f1;
text-decoration: none;
font-weight: 500;
}
.auth-links a:hover {
text-decoration: underline;
}
.btn-full {
width: 100%;
padding: 0.875rem;
}
+505
View File
@@ -0,0 +1,505 @@
.classrooms-page {
max-width: 1000px;
margin: 0 auto;
padding: 2rem 1.5rem;
}
.classrooms-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
flex-wrap: wrap;
gap: 1rem;
}
.classrooms-header h1 {
margin: 0;
color: #1e293b;
}
.header-actions {
display: flex;
gap: 0.5rem;
}
.error-alert {
background: #fef2f2;
color: #dc2626;
padding: 1rem;
border-radius: 0.5rem;
margin-bottom: 1rem;
border: 1px solid #fecaca;
}
/* Join/Create Forms */
.join-form,
.create-form {
display: flex;
gap: 0.75rem;
margin-bottom: 1.5rem;
padding: 1rem;
background: #f8fafc;
border-radius: 0.75rem;
border: 1px solid #e2e8f0;
}
.join-form input,
.create-form input {
flex: 1;
padding: 0.75rem 1rem;
border: 1px solid #e2e8f0;
border-radius: 0.5rem;
font-size: 1rem;
}
.join-form input {
text-transform: uppercase;
font-family: monospace;
font-size: 1.1rem;
letter-spacing: 2px;
}
/* Classrooms Sections */
.classrooms-section {
margin-bottom: 2rem;
}
.classrooms-section h2 {
font-size: 1.1rem;
color: #64748b;
margin: 0 0 1rem;
}
.classrooms-grid {
display: grid;
gap: 1rem;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
}
/* Classroom Cards */
.classroom-card {
display: flex;
gap: 1rem;
padding: 1.25rem;
background: white;
border-radius: 1rem;
text-decoration: none;
color: inherit;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
border: 2px solid transparent;
transition: all 0.2s;
}
.classroom-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
}
.classroom-card.teacher-card {
border-color: #6366f1;
}
.classroom-card.student-card {
border-color: #10b981;
}
.classroom-icon {
font-size: 2rem;
}
.classroom-info {
flex: 1;
}
.classroom-info h3 {
margin: 0 0 0.25rem;
color: #1e293b;
font-size: 1.1rem;
}
.classroom-meta {
font-size: 0.85rem;
color: #64748b;
}
.classroom-meta .dot {
margin: 0 0.35rem;
}
.join-code-preview {
margin-top: 0.5rem;
font-size: 0.85rem;
color: #6366f1;
font-family: monospace;
}
/* Empty State */
.empty-state {
text-align: center;
padding: 3rem 1rem;
color: #64748b;
}
.empty-icon {
font-size: 3rem;
display: block;
margin-bottom: 1rem;
}
.empty-subtitle {
font-size: 0.9rem;
color: #94a3b8;
}
/* Classroom Detail */
.classroom-detail-header {
margin-bottom: 1.5rem;
}
.back-link {
color: #6366f1;
text-decoration: none;
font-size: 0.9rem;
display: inline-block;
margin-bottom: 1rem;
}
.back-link:hover {
text-decoration: underline;
}
.classroom-title-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
flex-wrap: wrap;
}
.classroom-title-row h1 {
margin: 0;
color: #1e293b;
}
.classroom-subtitle {
margin: 0.25rem 0 0;
color: #64748b;
}
.classroom-actions {
display: flex;
gap: 0.5rem;
}
/* Join Code Box */
.join-code-box {
display: flex;
align-items: center;
gap: 0.75rem;
margin-top: 1rem;
padding: 0.75rem 1rem;
background: #f0fdf4;
border-radius: 0.5rem;
border: 1px solid #86efac;
flex-wrap: wrap;
}
.join-code-label {
font-size: 0.9rem;
color: #166534;
}
.join-code-value {
font-family: monospace;
font-size: 1.25rem;
font-weight: 700;
color: #15803d;
letter-spacing: 2px;
}
/* Tabs */
.classroom-tabs {
display: flex;
gap: 0.5rem;
margin-bottom: 1.5rem;
background: white;
padding: 0.5rem;
border-radius: 1rem;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
}
.tab-btn {
flex: 1;
padding: 0.75rem 1rem;
border: none;
background: transparent;
border-radius: 0.75rem;
font-size: 0.95rem;
font-weight: 500;
color: #64748b;
cursor: pointer;
transition: all 0.2s;
}
.tab-btn:hover {
background: #f1f5f9;
}
.tab-btn.active {
background: #6366f1;
color: white;
}
/* Classroom Section */
.classroom-section {
background: white;
border-radius: 1rem;
padding: 1.5rem;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
}
/* Students List */
.students-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.student-row {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem;
background: #f8fafc;
border-radius: 0.5rem;
}
.student-info {
flex: 1;
display: flex;
align-items: center;
gap: 0.5rem;
}
.student-name {
font-weight: 500;
color: #1e293b;
}
.student-badge {
font-size: 0.75rem;
background: #e0e7ff;
color: #4f46e5;
padding: 0.125rem 0.5rem;
border-radius: 1rem;
}
.student-level {
font-size: 0.8rem;
color: #64748b;
}
.student-stats {
display: flex;
gap: 1rem;
font-size: 0.85rem;
color: #64748b;
}
/* Leaderboard */
.leaderboard-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.leaderboard-row {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem;
background: #f8fafc;
border-radius: 0.5rem;
}
.leaderboard-row.top-three {
background: #fef3c7;
}
.leaderboard-row .rank {
font-weight: 700;
color: #6366f1;
width: 2.5rem;
}
.leaderboard-row .name {
flex: 1;
font-weight: 500;
}
.leaderboard-row .badge {
font-size: 0.75rem;
background: #e0e7ff;
color: #4f46e5;
padding: 0.125rem 0.5rem;
border-radius: 1rem;
}
.leaderboard-row .xp {
font-weight: 600;
color: #10b981;
}
.leaderboard-row .games {
font-size: 0.85rem;
color: #64748b;
}
/* Assignments */
.assignment-actions {
margin-bottom: 1rem;
}
.assignment-form {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-bottom: 1.5rem;
padding: 1rem;
background: #f8fafc;
border-radius: 0.75rem;
}
.assignment-form input,
.assignment-form textarea {
padding: 0.75rem;
border: 1px solid #e2e8f0;
border-radius: 0.5rem;
font-size: 1rem;
font-family: inherit;
}
.assignment-form .form-row {
display: flex;
gap: 1rem;
align-items: flex-end;
flex-wrap: wrap;
}
.assignment-form label {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.9rem;
color: #64748b;
}
.assignments-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.assignment-card {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
padding: 1rem 1.25rem;
background: #f8fafc;
border-radius: 0.75rem;
border-left: 4px solid #6366f1;
}
.assignment-card.overdue {
border-left-color: #dc2626;
background: #fef2f2;
}
.assignment-info h3 {
margin: 0 0 0.25rem;
color: #1e293b;
font-size: 1rem;
}
.assignment-info p {
margin: 0 0 0.5rem;
font-size: 0.9rem;
color: #64748b;
}
.due-date {
font-size: 0.85rem;
color: #64748b;
}
.overdue-badge {
margin-left: 0.5rem;
background: #dc2626;
color: white;
font-size: 0.7rem;
padding: 0.125rem 0.4rem;
border-radius: 0.25rem;
}
/* Loading/Error States */
.loading,
.error-state {
text-align: center;
padding: 3rem 1rem;
color: #64748b;
}
.error-state h2 {
color: #dc2626;
margin-bottom: 0.5rem;
}
/* Mobile */
@media (max-width: 640px) {
.classrooms-page {
padding: 1rem;
}
.classrooms-header {
flex-direction: column;
align-items: stretch;
}
.header-actions {
flex-direction: column;
}
.join-form,
.create-form {
flex-direction: column;
}
.classroom-tabs {
flex-wrap: wrap;
}
.tab-btn {
flex: 1 1 45%;
}
.student-row {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
.student-stats {
font-size: 0.8rem;
}
.leaderboard-row {
flex-wrap: wrap;
}
.join-code-box {
flex-direction: column;
align-items: flex-start;
}
}
+546
View File
@@ -0,0 +1,546 @@
import { useState, useEffect } from 'react';
import { Link, useParams, useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import api from '../api';
import './Classrooms.css';
export default function Classrooms() {
const { classroomId } = useParams();
if (classroomId) {
return <ClassroomDetail classroomId={classroomId} />;
}
return <ClassroomsList />;
}
function ClassroomsList() {
const { user } = useAuth();
const navigate = useNavigate();
const [classrooms, setClassrooms] = useState({ asTeacher: [], asStudent: [] });
const [loading, setLoading] = useState(true);
const [showCreate, setShowCreate] = useState(false);
const [showJoin, setShowJoin] = useState(false);
const [newName, setNewName] = useState('');
const [joinCode, setJoinCode] = useState('');
const [creating, setCreating] = useState(false);
const [joining, setJoining] = useState(false);
const [error, setError] = useState('');
const isTeacher = user?.role === 'teacher' || user?.role === 'admin';
useEffect(() => {
loadClassrooms();
}, []);
async function loadClassrooms() {
try {
const data = await api.getMyClassrooms();
setClassrooms(data);
} catch (err) {
console.error('Failed to load classrooms:', err);
} finally {
setLoading(false);
}
}
async function handleCreate(e) {
e.preventDefault();
if (!newName.trim()) return;
setCreating(true);
setError('');
try {
const data = await api.createClassroom(newName);
setClassrooms(prev => ({
...prev,
asTeacher: [...prev.asTeacher, data.classroom]
}));
setNewName('');
setShowCreate(false);
navigate(`/classrooms/${data.classroom.id}`);
} catch (err) {
setError(err.message);
} finally {
setCreating(false);
}
}
async function handleJoin(e) {
e.preventDefault();
if (!joinCode.trim()) return;
setJoining(true);
setError('');
try {
const data = await api.joinClassroom(joinCode);
setClassrooms(prev => ({
...prev,
asStudent: [...prev.asStudent, data.classroom]
}));
setJoinCode('');
setShowJoin(false);
navigate(`/classrooms/${data.classroom.id}`);
} catch (err) {
setError(err.message);
} finally {
setJoining(false);
}
}
if (loading) {
return (
<div className="classrooms-page">
<div className="loading">Loading classrooms...</div>
</div>
);
}
return (
<div className="classrooms-page">
<div className="classrooms-header">
<h1>My Classrooms</h1>
<div className="header-actions">
<button
className="btn btn-secondary"
onClick={() => { setShowJoin(!showJoin); setShowCreate(false); }}
>
{showJoin ? 'Cancel' : 'Join Classroom'}
</button>
<button
className="btn btn-primary"
onClick={() => { setShowCreate(!showCreate); setShowJoin(false); }}
>
{showCreate ? 'Cancel' : '+ Create Classroom'}
</button>
</div>
</div>
{error && <div className="error-alert">{error}</div>}
{showJoin && (
<form className="join-form" onSubmit={handleJoin}>
<input
type="text"
placeholder="Enter join code (e.g., ABC123)"
value={joinCode}
onChange={(e) => setJoinCode(e.target.value.toUpperCase())}
maxLength={10}
required
/>
<button type="submit" className="btn btn-primary" disabled={joining}>
{joining ? 'Joining...' : 'Join'}
</button>
</form>
)}
{showCreate && (
<form className="create-form" onSubmit={handleCreate}>
<input
type="text"
placeholder="Classroom name (e.g., Game Design 101)"
value={newName}
onChange={(e) => setNewName(e.target.value)}
maxLength={100}
required
/>
<button type="submit" className="btn btn-primary" disabled={creating}>
{creating ? 'Creating...' : 'Create'}
</button>
</form>
)}
{/* Created by me */}
{classrooms.asTeacher.length > 0 && (
<section className="classrooms-section">
<h2>Classrooms I Created</h2>
<div className="classrooms-grid">
{classrooms.asTeacher.map(c => (
<Link to={`/classrooms/${c.id}`} key={c.id} className="classroom-card teacher-card">
<div className="classroom-icon">🏫</div>
<div className="classroom-info">
<h3>{c.name}</h3>
<div className="classroom-meta">
<span>{c.studentCount} students</span>
<span className="dot">·</span>
<span>{c.assignmentCount} assignments</span>
</div>
<div className="join-code-preview">
Code: <strong>{c.joinCode}</strong>
</div>
</div>
</Link>
))}
</div>
</section>
)}
{/* Enrolled */}
{classrooms.asStudent.length > 0 && (
<section className="classrooms-section">
<h2>Classrooms I'm In</h2>
<div className="classrooms-grid">
{classrooms.asStudent.map(c => (
<Link to={`/classrooms/${c.id}`} key={c.id} className="classroom-card student-card">
<div className="classroom-icon">📚</div>
<div className="classroom-info">
<h3>{c.name}</h3>
<div className="classroom-meta">
<span>Teacher: {c.teacherDisplayName || c.teacherUsername}</span>
<span className="dot">·</span>
<span>{c.studentCount} students</span>
</div>
</div>
</Link>
))}
</div>
</section>
)}
{classrooms.asTeacher.length === 0 && classrooms.asStudent.length === 0 && (
<div className="empty-state">
<span className="empty-icon">🏫</span>
<p>You're not in any classrooms yet</p>
<p className="empty-subtitle">
Create a classroom to get started, or join one with a code!
</p>
</div>
)}
</div>
);
}
function ClassroomDetail({ classroomId }) {
const { user } = useAuth();
const navigate = useNavigate();
const [classroom, setClassroom] = useState(null);
const [students, setStudents] = useState([]);
const [assignments, setAssignments] = useState([]);
const [leaderboard, setLeaderboard] = useState([]);
const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState('students');
const [error, setError] = useState('');
// Assignment form
const [showAssignmentForm, setShowAssignmentForm] = useState(false);
const [newAssignment, setNewAssignment] = useState({ title: '', description: '', dueDate: '' });
const [creatingAssignment, setCreatingAssignment] = useState(false);
useEffect(() => {
loadClassroom();
}, [classroomId]);
async function loadClassroom() {
try {
const [classroomData, studentsData, assignmentsData, leaderboardData] = await Promise.all([
api.getClassroom(classroomId),
api.getClassroomStudents(classroomId),
api.getClassroomAssignments(classroomId),
api.getClassroomLeaderboard(classroomId)
]);
setClassroom(classroomData.classroom);
setStudents(studentsData.students);
setAssignments(assignmentsData.assignments);
setLeaderboard(leaderboardData.leaderboard);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}
async function handleRemoveStudent(studentId) {
if (!confirm('Remove this student from the classroom?')) return;
try {
await api.removeStudentFromClassroom(classroomId, studentId);
setStudents(prev => prev.filter(s => s.id !== studentId));
} catch (err) {
alert(err.message);
}
}
async function handleCreateAssignment(e) {
e.preventDefault();
if (!newAssignment.title.trim()) return;
setCreatingAssignment(true);
try {
const data = await api.createAssignment(classroomId, newAssignment);
setAssignments(prev => [...prev, data.assignment]);
setNewAssignment({ title: '', description: '', dueDate: '' });
setShowAssignmentForm(false);
} catch (err) {
alert(err.message);
} finally {
setCreatingAssignment(false);
}
}
async function handleDeleteAssignment(assignmentId) {
if (!confirm('Delete this assignment?')) return;
try {
await api.deleteAssignment(classroomId, assignmentId);
setAssignments(prev => prev.filter(a => a.id !== assignmentId));
} catch (err) {
alert(err.message);
}
}
async function handleLeaveClassroom() {
if (!confirm('Are you sure you want to leave this classroom?')) return;
try {
await api.leaveClassroom(classroomId);
navigate('/classrooms');
} catch (err) {
alert(err.message);
}
}
async function handleRegenerateCode() {
if (!confirm('Regenerate the join code? The old code will stop working.')) return;
try {
const data = await api.regenerateJoinCode(classroomId);
setClassroom(prev => ({ ...prev, joinCode: data.joinCode }));
} catch (err) {
alert(err.message);
}
}
async function handleDeleteClassroom() {
if (!confirm('Delete this classroom? This cannot be undone.')) return;
try {
await api.deleteClassroom(classroomId);
navigate('/classrooms');
} catch (err) {
alert(err.message);
}
}
if (loading) {
return (
<div className="classrooms-page">
<div className="loading">Loading classroom...</div>
</div>
);
}
if (error || !classroom) {
return (
<div className="classrooms-page">
<div className="error-state">
<h2>Classroom not found</h2>
<p>{error}</p>
<Link to="/classrooms" className="btn btn-primary">Back to Classrooms</Link>
</div>
</div>
);
}
return (
<div className="classrooms-page">
<div className="classroom-detail-header">
<Link to="/classrooms" className="back-link"> Back to Classrooms</Link>
<div className="classroom-title-row">
<div>
<h1>{classroom.isTeacher ? '🏫' : '📚'} {classroom.name}</h1>
<p className="classroom-subtitle">
{classroom.isTeacher
? `${classroom.studentCount} students enrolled`
: `Teacher: ${classroom.teacherDisplayName || classroom.teacherUsername}`}
</p>
</div>
<div className="classroom-actions">
{classroom.isTeacher ? (
<>
<button className="btn btn-danger btn-sm" onClick={handleDeleteClassroom}>
Delete
</button>
</>
) : (
<button className="btn btn-secondary btn-sm" onClick={handleLeaveClassroom}>
Leave Classroom
</button>
)}
</div>
</div>
{classroom.isTeacher && (
<div className="join-code-box">
<span className="join-code-label">Join Code:</span>
<span className="join-code-value">{classroom.joinCode}</span>
<button className="btn btn-sm" onClick={() => navigator.clipboard.writeText(classroom.joinCode)}>
Copy
</button>
<button className="btn btn-sm" onClick={handleRegenerateCode}>
New Code
</button>
</div>
)}
</div>
<div className="classroom-tabs">
<button
className={`tab-btn ${activeTab === 'students' ? 'active' : ''}`}
onClick={() => setActiveTab('students')}
>
Students ({students.length})
</button>
<button
className={`tab-btn ${activeTab === 'leaderboard' ? 'active' : ''}`}
onClick={() => setActiveTab('leaderboard')}
>
Leaderboard
</button>
<button
className={`tab-btn ${activeTab === 'assignments' ? 'active' : ''}`}
onClick={() => setActiveTab('assignments')}
>
Assignments ({assignments.length})
</button>
</div>
{activeTab === 'students' && (
<div className="classroom-section">
{students.length === 0 ? (
<div className="empty-state">
<p>No students yet</p>
{classroom.isTeacher && (
<p className="empty-subtitle">Share the join code <strong>{classroom.joinCode}</strong> with your students!</p>
)}
</div>
) : (
<div className="students-list">
{students.map(s => (
<div key={s.id} className="student-row">
<div className="student-info">
<span className="student-name">{s.displayName || s.username}</span>
<span className="student-badge">{s.badge}</span>
<span className="student-level">Lv.{s.level}</span>
</div>
<div className="student-stats">
<span>{s.gamesCreated} games</span>
<span>{s.xpTotal} XP</span>
</div>
{classroom.isTeacher && (
<button
className="btn btn-sm btn-danger"
onClick={() => handleRemoveStudent(s.id)}
>
Remove
</button>
)}
</div>
))}
</div>
)}
</div>
)}
{activeTab === 'leaderboard' && (
<div className="classroom-section">
{leaderboard.length === 0 ? (
<div className="empty-state">
<p>No students yet</p>
</div>
) : (
<div className="leaderboard-list">
{leaderboard.map((s, i) => (
<div key={s.id} className={`leaderboard-row ${i < 3 ? 'top-three' : ''}`}>
<span className="rank">#{s.rank}</span>
<span className="name">{s.displayName || s.username}</span>
<span className="badge">{s.badge}</span>
<span className="xp">{s.xpTotal} XP</span>
<span className="games">{s.gamesCreated} games</span>
</div>
))}
</div>
)}
</div>
)}
{activeTab === 'assignments' && (
<div className="classroom-section">
{classroom.isTeacher && (
<div className="assignment-actions">
<button
className="btn btn-primary"
onClick={() => setShowAssignmentForm(!showAssignmentForm)}
>
{showAssignmentForm ? 'Cancel' : '+ New Assignment'}
</button>
</div>
)}
{showAssignmentForm && (
<form className="assignment-form" onSubmit={handleCreateAssignment}>
<input
type="text"
placeholder="Assignment title"
value={newAssignment.title}
onChange={(e) => setNewAssignment(prev => ({ ...prev, title: e.target.value }))}
required
/>
<textarea
placeholder="Description (optional)"
value={newAssignment.description}
onChange={(e) => setNewAssignment(prev => ({ ...prev, description: e.target.value }))}
rows={3}
/>
<div className="form-row">
<label>
Due Date (optional):
<input
type="datetime-local"
value={newAssignment.dueDate}
onChange={(e) => setNewAssignment(prev => ({ ...prev, dueDate: e.target.value }))}
/>
</label>
<button type="submit" className="btn btn-primary" disabled={creatingAssignment}>
{creatingAssignment ? 'Creating...' : 'Create Assignment'}
</button>
</div>
</form>
)}
{assignments.length === 0 ? (
<div className="empty-state">
<p>No assignments yet</p>
</div>
) : (
<div className="assignments-list">
{assignments.map(a => (
<div key={a.id} className={`assignment-card ${a.isOverdue ? 'overdue' : ''}`}>
<div className="assignment-info">
<h3>{a.title}</h3>
{a.description && <p>{a.description}</p>}
{a.dueDate && (
<span className="due-date">
Due: {new Date(a.dueDate).toLocaleDateString()}
{a.isOverdue && <span className="overdue-badge">Overdue</span>}
</span>
)}
</div>
{classroom.isTeacher && (
<button
className="btn btn-sm btn-danger"
onClick={() => handleDeleteAssignment(a.id)}
>
Delete
</button>
)}
</div>
))}
</div>
)}
</div>
)}
</div>
);
}
+338
View File
@@ -0,0 +1,338 @@
.collections-page {
min-height: calc(100vh - 60px);
padding: 2rem 1rem;
background: #f8fafc;
max-width: 1000px;
margin: 0 auto;
}
.collections-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.collections-header h1 {
margin: 0;
color: #1e293b;
}
/* Create form */
.create-collection-form {
background: white;
padding: 1.5rem;
border-radius: 1rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
margin-bottom: 1.5rem;
}
.form-row {
display: flex;
gap: 1rem;
margin-bottom: 1rem;
}
.form-row:last-child {
margin-bottom: 0;
}
.form-row input[type="text"] {
flex: 1;
padding: 0.75rem 1rem;
border: 2px solid #e2e8f0;
border-radius: 0.5rem;
font-size: 1rem;
}
.form-row input[type="text"]:focus {
outline: none;
border-color: #6366f1;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
}
.checkbox-label input[type="checkbox"] {
width: 18px;
height: 18px;
cursor: pointer;
}
.error-alert {
background: #fef2f2;
border: 1px solid #fecaca;
color: #dc2626;
padding: 0.75rem 1rem;
border-radius: 0.5rem;
margin-bottom: 1rem;
}
/* Collections grid */
.collections-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1.5rem;
}
.collection-card {
display: flex;
gap: 1rem;
background: white;
padding: 1.25rem;
border-radius: 1rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
text-decoration: none;
transition: transform 0.2s, box-shadow 0.2s;
}
.collection-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
}
.collection-icon {
font-size: 2.5rem;
flex-shrink: 0;
}
.collection-info h3 {
margin: 0 0 0.25rem;
color: #1e293b;
font-size: 1.1rem;
}
.collection-description {
margin: 0 0 0.5rem;
color: #64748b;
font-size: 0.9rem;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.collection-meta {
font-size: 0.8rem;
color: #94a3b8;
}
.collection-meta .dot {
margin: 0 0.25rem;
}
/* Collection detail */
.collection-header {
margin-bottom: 2rem;
}
.back-link {
display: inline-block;
margin-bottom: 1rem;
color: #6366f1;
text-decoration: none;
font-weight: 500;
}
.back-link:hover {
text-decoration: underline;
}
.collection-title-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
}
.collection-title-row h1 {
margin: 0 0 0.5rem;
color: #1e293b;
}
.edit-form {
background: white;
padding: 1.5rem;
border-radius: 1rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
}
.edit-form input {
width: 100%;
padding: 0.75rem 1rem;
border: 2px solid #e2e8f0;
border-radius: 0.5rem;
font-size: 1rem;
margin-bottom: 1rem;
}
.edit-form input:focus {
outline: none;
border-color: #6366f1;
}
.edit-actions {
display: flex;
gap: 0.75rem;
margin-top: 1rem;
}
/* Search in collection */
.collection-search {
position: relative;
margin-bottom: 1.5rem;
max-width: 400px;
}
.collection-search .search-input {
width: 100%;
padding: 0.875rem 2.5rem 0.875rem 1rem;
border: 2px solid #e2e8f0;
border-radius: 0.75rem;
font-size: 1rem;
background: white;
transition: border-color 0.2s, box-shadow 0.2s;
}
.collection-search .search-input:focus {
outline: none;
border-color: #6366f1;
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.15);
}
.collection-search .search-input::placeholder {
color: #94a3b8;
}
.clear-search-btn {
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
width: 24px;
height: 24px;
background: #e2e8f0;
border: none;
border-radius: 50%;
color: #64748b;
font-size: 0.875rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background-color 0.2s;
}
.clear-search-btn:hover {
background: #cbd5e1;
color: #475569;
}
/* Games in collection */
.collection-games {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 1.5rem;
}
.collection-game-item {
position: relative;
}
.remove-btn {
position: absolute;
top: 8px;
right: 8px;
width: 28px;
height: 28px;
background: rgba(239, 68, 68, 0.9);
border: none;
border-radius: 50%;
color: white;
font-size: 1.25rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.2s;
z-index: 10;
}
.collection-game-item:hover .remove-btn {
opacity: 1;
}
.remove-btn:hover {
background: #dc2626;
}
/* Empty state */
.empty-state {
text-align: center;
padding: 3rem;
background: white;
border-radius: 1rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
}
.empty-icon {
font-size: 3rem;
display: block;
margin-bottom: 1rem;
}
.empty-state p {
color: #64748b;
margin: 0 0 0.5rem;
}
.empty-subtitle {
font-size: 0.9rem;
color: #94a3b8;
}
.error-state {
text-align: center;
padding: 3rem;
}
.error-state h2 {
margin: 0 0 0.5rem;
color: #1e293b;
}
.error-state p {
color: #64748b;
margin: 0 0 1rem;
}
/* Responsive */
@media (max-width: 640px) {
.collections-header {
flex-direction: column;
align-items: stretch;
gap: 1rem;
}
.form-row {
flex-direction: column;
}
.collections-grid,
.collection-games {
grid-template-columns: 1fr;
}
.collection-title-row {
flex-direction: column;
}
.edit-actions {
flex-direction: column;
}
}
+354
View File
@@ -0,0 +1,354 @@
import { useState, useEffect } from 'react';
import { Link, useParams } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import api from '../api';
import GameCard from '../components/GameCard';
import './Collections.css';
export default function Collections() {
const { collectionId } = useParams();
const { user } = useAuth();
if (collectionId) {
return <CollectionDetail collectionId={collectionId} />;
}
return <CollectionsList />;
}
function CollectionsList() {
const [collections, setCollections] = useState([]);
const [loading, setLoading] = useState(true);
const [showCreate, setShowCreate] = useState(false);
const [newName, setNewName] = useState('');
const [newDescription, setNewDescription] = useState('');
const [isPublic, setIsPublic] = useState(true);
const [creating, setCreating] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
loadCollections();
}, []);
async function loadCollections() {
try {
const data = await api.getMyCollections();
setCollections(data.collections || []);
} catch (err) {
console.error('Failed to load collections:', err);
} finally {
setLoading(false);
}
}
async function handleCreate(e) {
e.preventDefault();
if (!newName.trim()) return;
setCreating(true);
setError('');
try {
const data = await api.createCollection(newName, newDescription, isPublic);
setCollections(prev => [...prev, data.collection]);
setNewName('');
setNewDescription('');
setShowCreate(false);
} catch (err) {
setError(err.message);
} finally {
setCreating(false);
}
}
if (loading) {
return (
<div className="collections-page">
<div className="loading">Loading collections...</div>
</div>
);
}
return (
<div className="collections-page">
<div className="collections-header">
<h1>My Collections</h1>
<button
className="btn btn-primary"
onClick={() => setShowCreate(!showCreate)}
>
{showCreate ? 'Cancel' : '+ New Collection'}
</button>
</div>
{showCreate && (
<form className="create-collection-form" onSubmit={handleCreate}>
{error && <div className="error-alert">{error}</div>}
<div className="form-row">
<input
type="text"
placeholder="Collection name"
value={newName}
onChange={(e) => setNewName(e.target.value)}
maxLength={50}
required
/>
<input
type="text"
placeholder="Description (optional)"
value={newDescription}
onChange={(e) => setNewDescription(e.target.value)}
maxLength={200}
/>
</div>
<div className="form-row">
<label className="checkbox-label">
<input
type="checkbox"
checked={isPublic}
onChange={(e) => setIsPublic(e.target.checked)}
/>
<span>Make this collection public</span>
</label>
<button type="submit" className="btn btn-primary" disabled={creating}>
{creating ? 'Creating...' : 'Create Collection'}
</button>
</div>
</form>
)}
{collections.length === 0 ? (
<div className="empty-state">
<span className="empty-icon">📁</span>
<p>You don't have any collections yet</p>
<p className="empty-subtitle">Create a collection to organize your favorite games!</p>
</div>
) : (
<div className="collections-grid">
{collections.map(collection => (
<Link
to={`/collections/${collection.id}`}
key={collection.id}
className="collection-card"
>
<div className="collection-icon">
{collection.is_public ? '📁' : '🔒'}
</div>
<div className="collection-info">
<h3>{collection.name}</h3>
{collection.description && (
<p className="collection-description">{collection.description}</p>
)}
<div className="collection-meta">
<span>{collection.game_count || 0} games</span>
<span className="dot">·</span>
<span>{collection.is_public ? 'Public' : 'Private'}</span>
</div>
</div>
</Link>
))}
</div>
)}
</div>
);
}
function CollectionDetail({ collectionId }) {
const { user } = useAuth();
const [collection, setCollection] = useState(null);
const [games, setGames] = useState([]);
const [loading, setLoading] = useState(true);
const [editing, setEditing] = useState(false);
const [editName, setEditName] = useState('');
const [editDescription, setEditDescription] = useState('');
const [editPublic, setEditPublic] = useState(true);
const [error, setError] = useState('');
const [searchTerm, setSearchTerm] = useState('');
useEffect(() => {
loadCollection();
}, [collectionId]);
async function loadCollection() {
try {
const data = await api.getCollection(collectionId);
setCollection(data.collection);
setGames(data.games || []);
setEditName(data.collection.name);
setEditDescription(data.collection.description || '');
setEditPublic(data.collection.is_public);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}
async function handleUpdate() {
try {
await api.updateCollection(collectionId, {
name: editName,
description: editDescription,
isPublic: editPublic
});
setCollection(prev => ({
...prev,
name: editName,
description: editDescription,
is_public: editPublic
}));
setEditing(false);
} catch (err) {
setError(err.message);
}
}
async function handleRemoveGame(gameId) {
try {
await api.removeFromCollection(collectionId, gameId);
setGames(prev => prev.filter(g => g.id !== gameId));
} catch (err) {
console.error('Failed to remove game:', err);
}
}
if (loading) {
return (
<div className="collections-page">
<div className="loading">Loading collection...</div>
</div>
);
}
if (error || !collection) {
return (
<div className="collections-page">
<div className="error-state">
<h2>Collection not found</h2>
<p>{error}</p>
<Link to="/collections" className="btn btn-primary">Back to Collections</Link>
</div>
</div>
);
}
const isOwner = user && collection.user_id === user.id;
// Filter games by search term (client-side)
const filteredGames = searchTerm.trim()
? games.filter(game =>
game.title.toLowerCase().includes(searchTerm.toLowerCase())
)
: games;
return (
<div className="collections-page">
<div className="collection-header">
<Link to="/collections" className="back-link">← Back to Collections</Link>
{editing ? (
<div className="edit-form">
<input
type="text"
value={editName}
onChange={(e) => setEditName(e.target.value)}
placeholder="Collection name"
/>
<input
type="text"
value={editDescription}
onChange={(e) => setEditDescription(e.target.value)}
placeholder="Description"
/>
<label className="checkbox-label">
<input
type="checkbox"
checked={editPublic}
onChange={(e) => setEditPublic(e.target.checked)}
/>
<span>Public</span>
</label>
<div className="edit-actions">
<button className="btn btn-secondary" onClick={() => setEditing(false)}>Cancel</button>
<button className="btn btn-primary" onClick={handleUpdate}>Save</button>
</div>
</div>
) : (
<div className="collection-title-row">
<div>
<h1>
{collection.is_public ? '📁' : '🔒'} {collection.name}
</h1>
{collection.description && (
<p className="collection-description">{collection.description}</p>
)}
</div>
{isOwner && (
<button className="btn btn-secondary" onClick={() => setEditing(true)}>
Edit
</button>
)}
</div>
)}
</div>
{games.length > 0 && (
<div className="collection-search">
<input
type="text"
placeholder="Search games in this collection..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="search-input"
/>
{searchTerm && (
<button
className="clear-search-btn"
onClick={() => setSearchTerm('')}
title="Clear search"
>
x
</button>
)}
</div>
)}
{games.length === 0 ? (
<div className="empty-state">
<span className="empty-icon">🎮</span>
<p>This collection is empty</p>
{isOwner && (
<Link to="/arcade" className="btn btn-primary">Browse Games to Add</Link>
)}
</div>
) : filteredGames.length === 0 ? (
<div className="empty-state">
<span className="empty-icon">🔍</span>
<p>No games found matching "{searchTerm}"</p>
<button
className="btn btn-secondary"
onClick={() => setSearchTerm('')}
>
Clear Search
</button>
</div>
) : (
<div className="collection-games">
{filteredGames.map(game => (
<div key={game.id} className="collection-game-item">
<GameCard game={game} />
{isOwner && (
<button
className="remove-btn"
onClick={() => handleRemoveGame(game.id)}
title="Remove from collection"
>
×
</button>
)}
</div>
))}
</div>
)}
</div>
);
}
+530
View File
@@ -0,0 +1,530 @@
.create-page {
min-height: calc(100vh - 60px);
padding: 2rem 1rem;
background: #f8fafc;
}
.create-card {
background: white;
border-radius: 1rem;
padding: 2.5rem 2rem;
max-width: 600px;
margin: 0 auto;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.06);
}
.create-card h1 {
font-size: 1.75rem;
margin: 0 0 0.5rem;
color: #1e293b;
}
.create-subtitle {
color: #64748b;
margin: 0 0 2rem;
}
.create-form {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.char-count {
font-size: 0.8rem;
color: #94a3b8;
text-align: right;
margin-top: 0.25rem;
}
.tips {
background: #f0f9ff;
border-radius: 0.5rem;
padding: 1rem 1.25rem;
}
.tips h4 {
margin: 0 0 0.5rem;
color: #0369a1;
font-size: 0.9rem;
}
.tips ul {
margin: 0;
padding-left: 1.25rem;
color: #475569;
font-size: 0.9rem;
}
.tips li {
margin-bottom: 0.25rem;
}
/* Preview section */
.preview-section {
max-width: 1000px;
margin: 0 auto;
}
.preview-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
flex-wrap: wrap;
gap: 1rem;
}
.preview-header h1 {
margin: 0;
color: #1e293b;
}
.preview-actions {
display: flex;
gap: 0.75rem;
}
.preview-container {
background: white;
border-radius: 1rem;
overflow: hidden;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
margin-bottom: 1.5rem;
}
.preview-iframe {
width: 100%;
height: 60vh;
max-height: 500px;
border: none;
display: block;
}
.feedback-section {
background: white;
border-radius: 1rem;
padding: 1.5rem;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
}
.feedback-section h3 {
margin: 0 0 1rem;
color: #1e293b;
}
.feedback-section textarea {
width: 100%;
padding: 0.75rem 1rem;
border: 2px solid #e5e7eb;
border-radius: 0.5rem;
font-size: 1rem;
resize: vertical;
margin-bottom: 1rem;
}
.feedback-section textarea:focus {
outline: none;
border-color: #6366f1;
}
/* Success */
.success-card {
text-align: center;
}
.success-icon {
font-size: 4rem;
margin-bottom: 1rem;
}
.success-card .note {
color: #64748b;
font-size: 0.95rem;
}
.success-actions {
display: flex;
gap: 1rem;
justify-content: center;
margin-top: 1.5rem;
flex-wrap: wrap;
}
/* Edit page additions */
.edit-title-section {
display: flex;
align-items: center;
gap: 0.75rem;
}
.edit-form-row {
display: flex;
gap: 1rem;
align-items: flex-end;
margin-bottom: 1rem;
background: white;
padding: 1rem;
border-radius: 0.75rem;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
}
.edit-form-row .form-group {
flex: 1;
margin: 0;
}
.rejection-notice {
background: #fef2f2;
color: #991b1b;
padding: 1rem;
border-radius: 0.5rem;
margin-bottom: 1rem;
border: 1px solid #fecaca;
}
.success-alert {
background: #d1fae5;
color: #065f46;
padding: 0.75rem 1rem;
border-radius: 0.5rem;
margin-bottom: 1rem;
}
.feedback-hint {
color: #64748b;
font-size: 0.9rem;
margin: 0 0 1rem;
}
/* Game preview placeholder when no code exists */
.preview-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 300px;
padding: 3rem;
text-align: center;
background: linear-gradient(135deg, #1e293b 0%, #334155 100%);
color: white;
}
.preview-placeholder .placeholder-icon {
font-size: 4rem;
margin-bottom: 1rem;
animation: pulse 2s ease-in-out infinite;
}
.preview-placeholder h3 {
margin: 0 0 0.5rem;
font-size: 1.5rem;
}
.preview-placeholder p {
margin: 0;
color: #94a3b8;
max-width: 400px;
}
.preview-placeholder .btn {
margin-top: 1.5rem;
}
.preview-placeholder.generating {
background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%);
}
.preview-placeholder .loading-spinner {
width: 60px;
height: 60px;
border: 4px solid rgba(255, 255, 255, 0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 1.5rem;
}
.btn-large {
padding: 1rem 2rem;
font-size: 1.1rem;
}
/* Original Prompt Section */
.original-prompt-section {
background: white;
border-radius: 0.75rem;
margin-bottom: 1rem;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
overflow: hidden;
}
.prompt-toggle {
width: 100%;
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem 1.25rem;
background: none;
border: none;
font-size: 1rem;
font-weight: 600;
color: #1e293b;
cursor: pointer;
text-align: left;
transition: background 0.2s;
}
.prompt-toggle:hover {
background: #f8fafc;
}
.toggle-icon {
color: #6366f1;
font-size: 0.8rem;
}
.style-badge {
margin-left: auto;
background: linear-gradient(135deg, #6366f1, #8b5cf6);
color: white;
padding: 0.25rem 0.75rem;
border-radius: 1rem;
font-size: 0.8rem;
font-weight: 500;
}
.prompt-content {
padding: 0 1.25rem 1.25rem;
border-top: 1px solid #e5e7eb;
}
.prompt-answers h4,
.full-prompt h4 {
font-size: 0.9rem;
color: #64748b;
margin: 1rem 0 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.answers-list {
margin: 0;
}
.answer-item {
display: flex;
gap: 1rem;
padding: 0.5rem 0;
border-bottom: 1px solid #f1f5f9;
}
.answer-item:last-child {
border-bottom: none;
}
.answer-item dt {
flex: 0 0 150px;
font-weight: 500;
color: #475569;
}
.answer-item dd {
flex: 1;
margin: 0;
color: #1e293b;
}
.prompt-text {
background: #1e293b;
color: #e2e8f0;
padding: 1rem;
border-radius: 0.5rem;
font-size: 0.85rem;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
max-height: 300px;
overflow-y: auto;
margin: 0;
}
@media (max-width: 640px) {
.answer-item {
flex-direction: column;
gap: 0.25rem;
}
.answer-item dt {
flex: none;
}
}
.status-badge {
display: inline-block;
padding: 0.25rem 0.75rem;
border-radius: 1rem;
font-size: 0.8rem;
font-weight: 500;
text-transform: capitalize;
}
.status-draft {
background: #f1f5f9;
color: #64748b;
}
.status-pending_review {
background: #fef3c7;
color: #92400e;
}
.status-rejected {
background: #fee2e2;
color: #991b1b;
}
.loading {
padding: 4rem;
text-align: center;
color: #64748b;
}
/* Loading animation card */
.loading-card {
text-align: center;
padding: 3rem 2rem;
}
.loading-animation {
position: relative;
width: 120px;
height: 120px;
margin: 0 auto 2rem;
}
.loading-emoji {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 3rem;
animation: pulse 1s ease-in-out infinite;
}
.loading-spinner {
width: 100%;
height: 100%;
border: 4px solid #e2e8f0;
border-top-color: #6366f1;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@keyframes pulse {
0%, 100% { transform: translate(-50%, -50%) scale(1); }
50% { transform: translate(-50%, -50%) scale(1.1); }
}
.loading-title {
font-size: 1.75rem;
color: #6366f1;
margin: 0 0 1rem;
animation: fadeIn 0.5s ease;
}
.loading-message {
font-size: 1.25rem;
color: #1e293b;
margin: 0 0 1.5rem;
min-height: 2rem;
animation: fadeIn 0.3s ease;
}
.loading-progress {
margin-bottom: 1.5rem;
}
.progress-dots {
display: flex;
justify-content: center;
gap: 0.5rem;
}
.dot {
width: 12px;
height: 12px;
border-radius: 50%;
background: #e2e8f0;
transition: all 0.3s ease;
}
.dot.active {
background: #6366f1;
transform: scale(1.2);
}
.loading-subtitle {
color: #64748b;
font-size: 0.95rem;
margin: 0;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(-10px); }
to { opacity: 1; transform: translateY(0); }
}
/* Tokens used display */
.tokens-info {
display: inline-flex;
align-items: center;
gap: 0.5rem;
background: #f0f9ff;
padding: 0.5rem 1rem;
border-radius: 2rem;
color: #0369a1;
font-size: 0.9rem;
margin-top: 1rem;
}
.tokens-info strong {
font-weight: 600;
}
@media (max-width: 640px) {
.create-page {
padding: 1rem;
}
.create-card {
padding: 1.5rem;
}
.preview-header {
flex-direction: column;
align-items: stretch;
}
.preview-actions {
flex-wrap: wrap;
}
.preview-actions .btn {
flex: 1;
text-align: center;
}
.edit-form-row {
flex-direction: column;
align-items: stretch;
}
.preview-iframe {
height: 50vh;
}
}
+321
View File
@@ -0,0 +1,321 @@
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import api from '../api';
import './Create.css';
const LOADING_MESSAGES = [
{ text: "Initializing AI game engine...", emoji: "🚀" },
{ text: "Analyzing your awesome idea...", emoji: "💡" },
{ text: "This is going to be SO COOL!", emoji: "🔥" },
{ text: "Designing game mechanics...", emoji: "⚙️" },
{ text: "Your friends will love this!", emoji: "🎮" },
{ text: "Adding colorful graphics...", emoji: "🎨" },
{ text: "Making it super fun to play...", emoji: "✨" },
{ text: "Composing epic music...", emoji: "🎵" },
{ text: "Almost there, this is exciting!", emoji: "🎉" },
{ text: "Adding the finishing touches...", emoji: "💫" },
{ text: "Your game is going to be AMAZING!", emoji: "🌟" },
{ text: "Just a few more seconds...", emoji: "⏳" },
];
export default function Create() {
const navigate = useNavigate();
const { user, isAuthenticated } = useAuth();
const [step, setStep] = useState(1);
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [gameCode, setGameCode] = useState('');
const [gameId, setGameId] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [feedback, setFeedback] = useState('');
const [loadingMessage, setLoadingMessage] = useState(LOADING_MESSAGES[0]);
const [loadingIndex, setLoadingIndex] = useState(0);
const [tokensUsed, setTokensUsed] = useState(0);
const loadingIntervalRef = useRef(null);
// Cycle through loading messages
useEffect(() => {
if (loading) {
setLoadingIndex(0);
setLoadingMessage(LOADING_MESSAGES[0]);
loadingIntervalRef.current = setInterval(() => {
setLoadingIndex(prev => {
const next = (prev + 1) % LOADING_MESSAGES.length;
setLoadingMessage(LOADING_MESSAGES[next]);
return next;
});
}, 2500);
} else {
if (loadingIntervalRef.current) {
clearInterval(loadingIntervalRef.current);
}
}
return () => {
if (loadingIntervalRef.current) {
clearInterval(loadingIntervalRef.current);
}
};
}, [loading]);
if (!isAuthenticated || user?.isGuest) {
return (
<div className="create-page">
<div className="create-card">
<h1>Create a Game</h1>
<p>You need an account to create games.</p>
<a href="/register" className="btn btn-primary">Create Account</a>
</div>
</div>
);
}
const handleGenerate = async (e) => {
e.preventDefault();
setError('');
setLoading(true);
setTokensUsed(0);
try {
const data = await api.createGame(description, title);
setGameCode(data.previewCode);
setGameId(data.game.id);
setTokensUsed(data.tokensUsed || 0);
setStep(2);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
const handleRegenerate = async () => {
if (!feedback.trim()) {
setError('Please describe what you want to change');
return;
}
if (!gameId) {
setError('Game ID not found. Please try creating a new game.');
return;
}
setError('');
setLoading(true);
try {
const data = await api.updateGame(gameId, { feedback });
if (data.game?.gameCode) {
setGameCode(data.game.gameCode);
setFeedback('');
if (data.tokensUsed) {
setTokensUsed(prev => prev + data.tokensUsed);
}
} else {
setError('Failed to get updated game code');
}
} catch (err) {
// Handle HTML error responses
if (err.message?.includes('Unexpected token')) {
setError('Server error. Please try again or create a new game.');
} else {
setError(err.message || 'Failed to update game');
}
} finally {
setLoading(false);
}
};
const handleSubmit = async () => {
setLoading(true);
try {
await api.submitGame(gameId);
setStep(3);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
return (
<div className="create-page">
{step === 1 && !loading && (
<div className="create-card">
<h1>Create a Game with AI</h1>
<p className="create-subtitle">
Describe your game idea and our AI will create it for you!
</p>
<form onSubmit={handleGenerate} className="create-form">
{error && <div className="error-alert">{error}</div>}
<div className="form-group">
<label htmlFor="title">Game Title</label>
<input
type="text"
id="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="My Awesome Game"
maxLength={100}
/>
</div>
<div className="form-group">
<label htmlFor="description">Describe Your Game Idea</label>
<textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="A fun game where you catch falling stars while avoiding meteors. The player moves left and right with arrow keys. Each star is worth 10 points..."
rows={8}
required
minLength={10}
maxLength={1000}
/>
<span className="char-count">{description.length}/1000</span>
</div>
<div className="tips">
<h4>Tips for great games:</h4>
<ul>
<li>Be specific about game mechanics</li>
<li>Describe the controls (keyboard, mouse, touch)</li>
<li>Mention scoring system</li>
<li>Keep it simple and fun!</li>
</ul>
</div>
<button
type="submit"
className="btn btn-primary btn-lg btn-block"
disabled={loading || description.length < 10}
>
Generate Game
</button>
</form>
</div>
)}
{step === 1 && loading && (
<div className="create-card loading-card">
<div className="loading-animation">
<div className="loading-emoji">{loadingMessage.emoji}</div>
<div className="loading-spinner"></div>
</div>
<h2 className="loading-title">Creating Your Game!</h2>
<p className="loading-message">{loadingMessage.text}</p>
<div className="loading-progress">
<div className="progress-dots">
{LOADING_MESSAGES.slice(0, 6).map((_, i) => (
<span key={i} className={`dot ${i <= loadingIndex ? 'active' : ''}`}></span>
))}
</div>
</div>
<p className="loading-subtitle">Our AI is building something special just for you...</p>
</div>
)}
{step === 2 && !loading && (
<div className="preview-section">
<div className="preview-header">
<div>
<h1>{title || 'Your Game'}</h1>
{tokensUsed > 0 && (
<div className="tokens-info">
🤖 <strong>{tokensUsed.toLocaleString()}</strong> AI tokens used to create this game!
</div>
)}
</div>
<div className="preview-actions">
<button
onClick={() => setStep(1)}
className="btn btn-secondary"
>
Start Over
</button>
<button
onClick={handleSubmit}
className="btn btn-primary"
disabled={loading}
>
Submit for Review
</button>
</div>
</div>
<div className="preview-container">
{gameCode && (
<iframe
srcDoc={gameCode}
title="Game Preview"
sandbox="allow-scripts"
className="preview-iframe"
/>
)}
</div>
<div className="feedback-section">
<h3>Want changes?</h3>
{error && <div className="error-alert">{error}</div>}
<textarea
value={feedback}
onChange={(e) => setFeedback(e.target.value)}
placeholder="Describe what you'd like to change... (e.g., 'make the player faster' or 'add more enemies')"
rows={3}
/>
<button
onClick={handleRegenerate}
className="btn btn-secondary"
disabled={loading || !feedback.trim()}
>
Update Game
</button>
</div>
</div>
)}
{step === 2 && loading && (
<div className="create-card loading-card">
<div className="loading-animation">
<div className="loading-emoji">{loadingMessage.emoji}</div>
<div className="loading-spinner"></div>
</div>
<h2 className="loading-title">Updating Your Game!</h2>
<p className="loading-message">{loadingMessage.text}</p>
<div className="loading-progress">
<div className="progress-dots">
{LOADING_MESSAGES.slice(0, 6).map((_, i) => (
<span key={i} className={`dot ${i <= loadingIndex ? 'active' : ''}`}></span>
))}
</div>
</div>
<p className="loading-subtitle">Making your game even better...</p>
</div>
)}
{step === 3 && (
<div className="create-card success-card">
<div className="success-icon">🎉</div>
<h1>Game Submitted!</h1>
<p>Your game has been submitted for review. We'll check it soon!</p>
<p className="note">Once approved, it will appear in the arcade and you'll earn tokens when others play.</p>
<div className="success-actions">
<button onClick={() => navigate('/dashboard')} className="btn btn-primary">
Go to Dashboard
</button>
<button onClick={() => { setStep(1); setGameCode(''); setGameId(null); }} className="btn btn-secondary">
Create Another Game
</button>
</div>
</div>
)}
</div>
);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+270
View File
@@ -0,0 +1,270 @@
.creator-profile-page {
min-height: calc(100vh - 60px);
background: var(--bg-color);
}
/* Loading & Error States */
.creator-profile-page .loading-state,
.creator-profile-page .error-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 400px;
text-align: center;
padding: 2rem;
}
.creator-profile-page .loading-spinner {
width: 48px;
height: 48px;
border: 4px solid rgba(255, 255, 255, 0.1);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 1s linear infinite;
}
.error-state .error-icon {
font-size: 4rem;
margin-bottom: 1rem;
}
.error-state h2 {
margin-bottom: 0.5rem;
}
.error-state p {
color: var(--text-secondary);
margin-bottom: 1.5rem;
}
/* Creator Banner */
.creator-banner {
background: linear-gradient(135deg, var(--card-bg) 0%, #1a1a2e 100%);
padding: 2rem;
border-bottom: 1px solid var(--border-color);
}
.banner-content {
max-width: 1200px;
margin: 0 auto;
display: flex;
align-items: flex-start;
gap: 2rem;
}
.creator-avatar-section {
position: relative;
flex-shrink: 0;
}
.level-badge {
position: absolute;
bottom: -8px;
left: 50%;
transform: translateX(-50%);
background: var(--card-bg);
padding: 4px 12px;
border-radius: 20px;
border: 2px solid var(--primary);
display: flex;
align-items: center;
gap: 4px;
white-space: nowrap;
}
.level-icon {
font-size: 1rem;
}
.level-name {
font-size: 0.75rem;
font-weight: 600;
color: var(--primary);
}
.creator-info {
flex: 1;
}
.creator-name {
font-size: 2rem;
margin: 0 0 0.25rem 0;
color: var(--text-primary);
}
.creator-username {
color: var(--text-secondary);
margin: 0 0 0.75rem 0;
font-size: 1rem;
}
.creator-bio {
color: var(--text-primary);
margin: 0 0 1rem 0;
max-width: 600px;
line-height: 1.5;
}
.creator-stats {
display: flex;
gap: 2rem;
margin-bottom: 1.5rem;
}
.stat-item {
display: flex;
flex-direction: column;
}
.stat-value {
font-size: 1.5rem;
font-weight: 700;
color: var(--text-primary);
}
.stat-label {
font-size: 0.75rem;
color: var(--text-secondary);
text-transform: uppercase;
}
.follow-btn {
background: var(--primary);
color: white;
padding: 0.5rem 1.5rem;
border-radius: 20px;
font-weight: 600;
transition: all 0.2s;
}
.follow-btn:hover {
transform: scale(1.05);
}
.follow-btn.following {
background: transparent;
border: 2px solid var(--primary);
color: var(--primary);
}
/* Creator Arcade */
.creator-arcade {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
.arcade-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
flex-wrap: wrap;
gap: 1rem;
}
.arcade-header h2 {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0;
font-size: 1.5rem;
}
.arcade-icon {
font-size: 1.5rem;
}
.sort-controls {
display: flex;
gap: 0.5rem;
}
.sort-btn {
background: var(--card-bg);
border: 1px solid var(--border-color);
color: var(--text-secondary);
padding: 0.5rem 1rem;
border-radius: 8px;
font-size: 0.875rem;
cursor: pointer;
transition: all 0.2s;
}
.sort-btn:hover {
border-color: var(--primary);
color: var(--text-primary);
}
.sort-btn.active {
background: var(--primary);
border-color: var(--primary);
color: white;
}
/* Empty State */
.empty-arcade {
text-align: center;
padding: 4rem 2rem;
background: var(--card-bg);
border-radius: 16px;
}
.empty-arcade .empty-icon {
font-size: 4rem;
display: block;
margin-bottom: 1rem;
}
.empty-arcade h3 {
margin: 0 0 0.5rem 0;
}
.empty-arcade p {
color: var(--text-secondary);
margin: 0 0 1.5rem 0;
}
/* Games Grid */
.games-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1.5rem;
}
/* Mobile Responsive */
@media (max-width: 768px) {
.banner-content {
flex-direction: column;
align-items: center;
text-align: center;
}
.creator-stats {
justify-content: center;
}
.creator-info {
display: flex;
flex-direction: column;
align-items: center;
}
.creator-bio {
text-align: center;
}
.arcade-header {
flex-direction: column;
align-items: stretch;
}
.sort-controls {
justify-content: center;
}
.games-grid {
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
}
}
+225
View File
@@ -0,0 +1,225 @@
import { useState, useEffect } from 'react';
import { useParams, Link } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import api from '../api';
import GameCard from '../components/GameCard';
import { AvatarDisplay, DEFAULT_AVATAR } from '../components/AvatarBuilder';
import './CreatorProfile.css';
const LEVEL_ICONS = {
1: '🌱', 2: '🎮', 3: '🎯', 4: '⭐', 5: '🔥',
6: '💎', 7: '🏆', 8: '👑', 9: '🌟', 10: '🎖️'
};
const SORT_OPTIONS = [
{ id: 'popular', label: 'Most Played' },
{ id: 'recent', label: 'Newest First' },
{ id: 'rating', label: 'Highest Rated' }
];
export default function CreatorProfile() {
const { username } = useParams();
const { user: currentUser, isAuthenticated } = useAuth();
const [creator, setCreator] = useState(null);
const [games, setGames] = useState([]);
const [isFollowing, setIsFollowing] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [sortBy, setSortBy] = useState('popular');
const [followLoading, setFollowLoading] = useState(false);
const isOwnProfile = currentUser && creator && currentUser.id === creator.id;
useEffect(() => {
loadCreator();
}, [username]);
async function loadCreator() {
setLoading(true);
setError('');
try {
const [profileData, gamesData] = await Promise.all([
api.getCreatorByUsername(username),
api.getCreatorGames(username)
]);
setCreator(profileData.user);
setGames(gamesData.games || []);
setIsFollowing(profileData.isFollowing || false);
} catch (err) {
setError(err.message || 'Creator not found');
} finally {
setLoading(false);
}
}
async function handleFollowToggle() {
if (!isAuthenticated || !creator) return;
setFollowLoading(true);
try {
if (isFollowing) {
await api.unfollowUser(creator.id);
setIsFollowing(false);
setCreator(prev => ({
...prev,
followersCount: prev.followersCount - 1
}));
} else {
await api.followUser(creator.id);
setIsFollowing(true);
setCreator(prev => ({
...prev,
followersCount: prev.followersCount + 1
}));
}
} catch (err) {
console.error('Follow error:', err);
} finally {
setFollowLoading(false);
}
}
const sortedGames = [...games].sort((a, b) => {
switch (sortBy) {
case 'popular':
return b.playCount - a.playCount;
case 'recent':
return new Date(b.publishedAt) - new Date(a.publishedAt);
case 'rating':
return (parseFloat(b.averageRating) || 0) - (parseFloat(a.averageRating) || 0);
default:
return 0;
}
});
if (loading) {
return (
<div className="creator-profile-page">
<div className="loading-state">
<div className="loading-spinner"></div>
<p>Loading creator profile...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="creator-profile-page">
<div className="error-state">
<span className="error-icon">😕</span>
<h2>Creator Not Found</h2>
<p>{error}</p>
<Link to="/arcade" className="btn btn-primary">Browse Arcade</Link>
</div>
</div>
);
}
return (
<div className="creator-profile-page">
{/* Header Banner */}
<div className="creator-banner">
<div className="banner-content">
<div className="creator-avatar-section">
<AvatarDisplay
data={creator.avatarData || DEFAULT_AVATAR}
size={120}
/>
<div className="level-badge">
<span className="level-icon">{LEVEL_ICONS[creator.level] || '🎮'}</span>
<span className="level-name">{creator.badge}</span>
</div>
</div>
<div className="creator-info">
<h1 className="creator-name">
{creator.displayName || creator.username}
</h1>
<p className="creator-username">@{creator.username}</p>
{creator.bio && <p className="creator-bio">{creator.bio}</p>}
<div className="creator-stats">
<div className="stat-item">
<span className="stat-value">{creator.gamesCount}</span>
<span className="stat-label">Games</span>
</div>
<div className="stat-item">
<span className="stat-value">{creator.totalPlays?.toLocaleString() || 0}</span>
<span className="stat-label">Total Plays</span>
</div>
<div className="stat-item">
<span className="stat-value">{creator.followersCount}</span>
<span className="stat-label">Followers</span>
</div>
<div className="stat-item">
<span className="stat-value">{creator.xp?.toLocaleString() || 0}</span>
<span className="stat-label">XP</span>
</div>
</div>
{!isOwnProfile && isAuthenticated && (
<button
onClick={handleFollowToggle}
className={`btn follow-btn ${isFollowing ? 'following' : ''}`}
disabled={followLoading}
>
{followLoading ? '...' : isFollowing ? 'Following' : 'Follow'}
</button>
)}
{isOwnProfile && (
<Link to="/profile" className="btn btn-secondary">
Edit Profile
</Link>
)}
</div>
</div>
</div>
{/* Games Section */}
<div className="creator-arcade">
<div className="arcade-header">
<h2>
<span className="arcade-icon">🎮</span>
{isOwnProfile ? 'My Arcade' : `${creator.displayName || creator.username}'s Arcade`}
</h2>
<div className="sort-controls">
{SORT_OPTIONS.map(opt => (
<button
key={opt.id}
className={`sort-btn ${sortBy === opt.id ? 'active' : ''}`}
onClick={() => setSortBy(opt.id)}
>
{opt.label}
</button>
))}
</div>
</div>
{sortedGames.length === 0 ? (
<div className="empty-arcade">
<span className="empty-icon">🎮</span>
<h3>No games yet</h3>
<p>
{isOwnProfile
? "You haven't published any games yet. Create your first game!"
: "This creator hasn't published any games yet."}
</p>
{isOwnProfile && (
<Link to="/create" className="btn btn-primary">Create a Game</Link>
)}
</div>
) : (
<div className="games-grid">
{sortedGames.map(game => (
<GameCard key={game.id} game={game} showCreator={false} />
))}
</div>
)}
</div>
</div>
);
}
+688
View File
@@ -0,0 +1,688 @@
.dashboard {
max-width: 1100px;
margin: 0 auto;
padding: 2rem 1.5rem;
}
.dashboard-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
flex-wrap: wrap;
gap: 1rem;
}
.dashboard-header h1 {
margin: 0;
color: #1e293b;
}
/* XP Section */
.xp-section {
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
border-radius: 1rem;
padding: 1.5rem;
margin-bottom: 1.5rem;
box-shadow: 0 4px 15px rgba(99, 102, 241, 0.3);
}
.xp-section .xp-display {
background: transparent;
color: white;
}
.xp-section .level-badge {
background: rgba(255, 255, 255, 0.2);
color: white;
}
.xp-section .level-name {
color: rgba(255, 255, 255, 0.9);
}
.xp-section .xp-bar {
background: rgba(255, 255, 255, 0.2);
}
.xp-section .xp-fill {
background: white;
}
.xp-section .xp-text {
color: rgba(255, 255, 255, 0.9);
}
.xp-section .xp-needed {
color: rgba(255, 255, 255, 0.7);
}
/* Dashboard Grid */
.dashboard-grid {
display: grid;
gap: 1.5rem;
margin-bottom: 1.5rem;
}
@media (min-width: 768px) {
.dashboard-grid {
grid-template-columns: 1fr 1fr;
}
}
/* Stats Section */
.stats-section {
grid-column: 1 / -1;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 1rem;
}
.stat-card {
background: white;
border-radius: 1rem;
padding: 1.25rem;
text-align: center;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
}
.stat-card.highlight {
background: linear-gradient(135deg, #f59e0b 0%, #f97316 100%);
color: white;
}
.stat-card.highlight .stat-value,
.stat-card.highlight .stat-label {
color: white;
}
.stat-card.spending {
background: linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%);
color: white;
}
.stat-card.spending .stat-value,
.stat-card.spending .stat-label {
color: white;
}
.stat-sublabel {
font-size: 0.7rem;
opacity: 0.7;
margin-top: 0.25rem;
}
.stat-value {
font-size: 1.75rem;
font-weight: 700;
margin-bottom: 0.25rem;
}
.stat-label {
font-size: 0.85rem;
opacity: 0.8;
}
/* Streaks Section */
.streaks-section {
grid-column: 1 / -1;
}
.streaks-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
}
.streak-card {
display: flex;
align-items: center;
gap: 1rem;
background: white;
border-radius: 1rem;
padding: 1.25rem 1.5rem;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
border: 2px solid #e2e8f0;
transition: all 0.3s ease;
}
.streak-card.on-fire {
background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
border-color: #f59e0b;
box-shadow: 0 4px 15px rgba(245, 158, 11, 0.3);
}
.streak-card.on-fire .streak-icon {
animation: pulse-fire 1s ease-in-out infinite;
}
@keyframes pulse-fire {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.1); }
}
.streak-icon {
font-size: 2.5rem;
line-height: 1;
}
.streak-info {
flex: 1;
}
.streak-value {
font-size: 2rem;
font-weight: 700;
color: #1e293b;
line-height: 1;
}
.streak-label {
font-size: 0.85rem;
color: #64748b;
margin-top: 0.25rem;
}
.streak-milestone {
display: inline-block;
font-size: 0.7rem;
font-weight: 600;
color: #f59e0b;
background: rgba(245, 158, 11, 0.15);
padding: 0.2rem 0.5rem;
border-radius: 0.5rem;
margin-top: 0.5rem;
}
.streak-card.on-fire .streak-milestone {
background: rgba(0, 0, 0, 0.1);
color: #92400e;
}
/* Section Headers */
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.section-header h3 {
margin: 0;
font-size: 1rem;
color: #1e293b;
}
.view-all {
font-size: 0.85rem;
color: #6366f1;
text-decoration: none;
}
.view-all:hover {
text-decoration: underline;
}
/* Achievements Section */
.achievements-section {
background: white;
border-radius: 1rem;
padding: 1.25rem;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
}
.achievements-row {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
}
.achievement-badge {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
background: #f8fafc;
border-radius: 0.75rem;
padding: 0.75rem 1rem;
min-width: 80px;
border: 1px solid #e2e8f0;
cursor: default;
transition: transform 0.2s, box-shadow 0.2s;
}
.achievement-badge:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.achievement-icon {
font-size: 1.5rem;
}
.achievement-name {
font-size: 0.7rem;
color: #64748b;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 80px;
}
/* XP Activity Section */
.xp-activity-section {
background: white;
border-radius: 1rem;
padding: 1.25rem;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
}
.xp-activity-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.xp-activity-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.5rem 0;
border-bottom: 1px solid #f1f5f9;
}
.xp-activity-item:last-child {
border-bottom: none;
}
.xp-activity-label {
flex: 1;
font-size: 0.85rem;
color: #475569;
}
.xp-activity-amount {
font-weight: 600;
color: #10b981;
font-size: 0.85rem;
}
.xp-activity-time {
font-size: 0.75rem;
color: #94a3b8;
min-width: 60px;
text-align: right;
}
/* Collections Section */
.collections-section {
background: white;
border-radius: 1rem;
padding: 1.25rem;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
}
.empty-collections {
text-align: center;
padding: 1.5rem 1rem;
color: #64748b;
}
.empty-collections .empty-icon {
font-size: 2.5rem;
display: block;
margin-bottom: 0.75rem;
}
.empty-collections p {
margin: 0 0 1rem;
font-size: 0.9rem;
}
.collections-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.75rem;
}
.collection-card-mini {
display: flex;
align-items: center;
gap: 0.75rem;
background: #f8fafc;
border-radius: 0.75rem;
padding: 0.75rem 1rem;
border: 1px solid #e2e8f0;
text-decoration: none;
transition: all 0.2s;
}
.collection-card-mini:hover {
border-color: #6366f1;
background: #f1f5f9;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.15);
}
.collection-card-mini .collection-icon {
font-size: 1.5rem;
flex-shrink: 0;
}
.collection-card-mini .collection-details {
display: flex;
flex-direction: column;
min-width: 0;
}
.collection-card-mini .collection-name {
font-weight: 600;
color: #1e293b;
font-size: 0.9rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.collection-card-mini .collection-count {
font-size: 0.75rem;
color: #64748b;
}
@media (max-width: 640px) {
.collections-grid {
grid-template-columns: 1fr;
}
}
/* Dashboard tabs */
.dashboard-tabs {
display: flex;
gap: 0.5rem;
margin-bottom: 1.5rem;
background: white;
padding: 0.5rem;
border-radius: 1rem;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
}
.tab-btn {
flex: 1;
padding: 0.75rem 1rem;
border: none;
background: transparent;
border-radius: 0.75rem;
font-size: 0.95rem;
font-weight: 500;
color: #64748b;
cursor: pointer;
transition: all 0.2s;
}
.tab-btn:hover {
background: #f1f5f9;
}
.tab-btn.active {
background: #6366f1;
color: white;
}
.games-section {
background: white;
border-radius: 1rem;
padding: 1.5rem;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
}
/* Play history */
.play-history-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.play-history-row {
display: flex;
justify-content: space-between;
align-items: center;
background: #f8fafc;
border-radius: 0.75rem;
padding: 1rem 1.25rem;
border: 1px solid #e2e8f0;
gap: 1rem;
}
.play-history-info {
flex: 1;
min-width: 0;
}
.play-history-title {
display: block;
font-size: 1.1rem;
font-weight: 600;
color: #1e293b;
text-decoration: none;
margin-bottom: 0.35rem;
}
.play-history-title:hover {
color: #6366f1;
}
.play-history-stats {
display: flex;
gap: 1rem;
font-size: 0.85rem;
color: #64748b;
flex-wrap: wrap;
}
.play-history-stats strong {
color: #16a34a;
}
.play-history-date {
color: #94a3b8;
}
.games-section h2 {
margin: 0 0 1.5rem;
color: #1e293b;
}
.empty-state {
text-align: center;
padding: 3rem 1rem;
color: #64748b;
}
.empty-state p {
margin-bottom: 1rem;
}
.games-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.game-row {
background: #f8fafc;
border-radius: 0.75rem;
padding: 1rem 1.25rem;
border: 1px solid #e2e8f0;
}
.game-main-info {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
margin-bottom: 0.5rem;
}
.game-row .game-title {
margin: 0;
font-size: 1.1rem;
color: #1e293b;
}
.game-stats-row {
display: flex;
gap: 1rem;
font-size: 0.85rem;
color: #64748b;
margin-bottom: 0.75rem;
flex-wrap: wrap;
}
.game-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.status-badge {
display: inline-block;
padding: 0.2rem 0.6rem;
border-radius: 1rem;
font-size: 0.75rem;
font-weight: 500;
text-transform: capitalize;
}
.status-draft {
background: #f1f5f9;
color: #64748b;
}
.status-pending_review {
background: #fef3c7;
color: #92400e;
}
.status-published {
background: #d1fae5;
color: #065f46;
}
.status-rejected {
background: #fee2e2;
color: #991b1b;
}
.status-testing {
background: #dbeafe;
color: #1e40af;
}
.status-generating {
background: #ede9fe;
color: #6d28d9;
}
.rejection-reason {
font-size: 0.8rem;
color: #dc2626;
background: #fef2f2;
padding: 0.5rem 0.75rem;
border-radius: 0.5rem;
margin-bottom: 0.75rem;
}
.btn-sm {
padding: 0.4rem 0.8rem;
font-size: 0.85rem;
}
.btn-danger {
background: #dc2626;
color: white;
}
.btn-danger:hover {
background: #b91c1c;
}
.btn-danger:disabled {
background: #f87171;
cursor: not-allowed;
}
.btn-analytics {
background: linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%);
color: white;
}
.btn-analytics:hover {
background: linear-gradient(135deg, #7c3aed 0%, #4f46e5 100%);
}
.loading {
text-align: center;
padding: 4rem;
color: #64748b;
}
@media (max-width: 640px) {
.dashboard {
padding: 1rem;
}
.dashboard-header {
flex-direction: column;
align-items: stretch;
text-align: center;
}
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
.stat-value {
font-size: 1.5rem;
}
.game-actions {
margin-top: 0.5rem;
}
.game-actions .btn-sm {
flex: 1;
text-align: center;
}
.xp-section {
padding: 1rem;
}
.achievements-row {
justify-content: center;
}
.dashboard-grid {
grid-template-columns: 1fr;
}
.streaks-grid {
grid-template-columns: 1fr;
}
.streak-card {
padding: 1rem 1.25rem;
}
.streak-icon {
font-size: 2rem;
}
.streak-value {
font-size: 1.5rem;
}
}
+207
View File
@@ -0,0 +1,207 @@
import { useState, useEffect } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import api from '../api';
import './Dashboard.css';
export default function Dashboard() {
const { user, isAuthenticated } = useAuth();
const navigate = useNavigate();
const [games, setGames] = useState([]);
const [collections, setCollections] = useState([]);
const [loading, setLoading] = useState(true);
const [deleting, setDeleting] = useState(null);
useEffect(() => {
if (!isAuthenticated || user?.isGuest) {
navigate('/login');
return;
}
loadDashboard();
}, [isAuthenticated, user]);
const loadDashboard = async () => {
try {
const [gamesData, collectionsData] = await Promise.all([
api.getMyGames(),
api.getMyCollections()
]);
setGames(gamesData.games);
setCollections(collectionsData.collections || []);
} catch (error) {
console.error('Failed to load dashboard:', error);
} finally {
setLoading(false);
}
};
const handleDelete = async (gameId, gameTitle) => {
if (!confirm(`Are you sure you want to delete "${gameTitle}"? This cannot be undone.`)) {
return;
}
setDeleting(gameId);
try {
await api.deleteGame(gameId);
setGames(games.filter(g => g.id !== gameId));
} catch (error) {
alert('Failed to delete game: ' + error.message);
} finally {
setDeleting(null);
}
};
const handleSubmit = async (gameId) => {
try {
await api.submitGame(gameId);
loadDashboard();
} catch (error) {
alert('Failed to submit game: ' + error.message);
}
};
const handleUnpublish = async (gameId, gameTitle) => {
if (!confirm(`Are you sure you want to unpublish "${gameTitle}"? It will be moved back to testing status.`)) {
return;
}
try {
await api.unpublishGame(gameId);
loadDashboard();
} catch (error) {
alert('Failed to unpublish game: ' + error.message);
}
};
if (loading) {
return <div className="dashboard"><div className="loading">Loading...</div></div>;
}
return (
<div className="dashboard">
<div className="dashboard-header">
<h1>My Games</h1>
<Link to="/create" className="btn btn-primary">+ Create New Game</Link>
</div>
{/* My Collections */}
<div className="collections-section" style={{ marginBottom: '1.5rem' }}>
<div className="section-header">
<h3>My Collections</h3>
<Link to="/collections" className="view-all">Manage All</Link>
</div>
{collections.length === 0 ? (
<div className="empty-collections">
<span className="empty-icon">📁</span>
<p>Organize your favorite games into collections!</p>
<Link to="/collections" className="btn btn-secondary btn-sm">Create Collection</Link>
</div>
) : (
<div className="collections-grid">
{collections.slice(0, 4).map(c => (
<Link key={c.id} to={`/collections/${c.id}`} className="collection-card-mini">
<span className="collection-icon">📁</span>
<div className="collection-details">
<span className="collection-name">{c.name}</span>
<span className="collection-count">{c.gameCount} games</span>
</div>
</Link>
))}
</div>
)}
</div>
{/* Games I've Created */}
<div className="games-section">
<h2>Your Games</h2>
{games.length === 0 ? (
<div className="empty-state">
<p>You haven't created any games yet.</p>
<Link to="/create" className="btn btn-primary">Create Your First Game</Link>
</div>
) : (
<div className="games-list">
{games.map(game => (
<div key={game.id} className="game-row">
<div className="game-main-info">
<h3 className="game-title">{game.title}</h3>
<span className={`status-badge status-${game.status}`}>
{game.status.replace('_', ' ')}
</span>
</div>
<div className="game-stats-row">
<span>▶ {game.playCount} plays</span>
<span>⭐ {game.averageRating ? game.averageRating.toFixed(1) : ''}</span>
<span>🪙 {game.tokensEarned} earned</span>
</div>
{game.status === 'rejected' && game.rejectionReason && (
<div className="rejection-reason">
Rejected: {game.rejectionReason}
</div>
)}
<div className="game-actions">
{game.status === 'published' && (
<>
<Link to={`/play/${game.id}`} className="btn btn-sm btn-primary">
View
</Link>
<Link to={`/edit/${game.id}`} className="btn btn-sm btn-secondary">
Edit
</Link>
<button
onClick={() => handleUnpublish(game.id, game.title)}
className="btn btn-sm btn-warning"
>
Unpublish
</button>
</>
)}
{game.status === 'draft' && (
<>
<Link to={`/play/${game.id}?newGame=true`} className="btn btn-sm btn-secondary">
Edit
</Link>
<button
onClick={() => handleSubmit(game.id)}
className="btn btn-sm btn-primary"
>
Submit for Review
</button>
</>
)}
{game.status === 'rejected' && (
<Link to={`/play/${game.id}?newGame=true`} className="btn btn-sm btn-secondary">
Edit & Resubmit
</Link>
)}
{game.status === 'testing' && (
<Link to={`/play/${game.id}?newGame=true`} className="btn btn-sm btn-primary">
Test & Edit
</Link>
)}
{game.status !== 'published' && (
<button
onClick={() => handleDelete(game.id, game.title)}
className="btn btn-sm btn-danger"
disabled={deleting === game.id}
>
{deleting === game.id ? 'Deleting...' : 'Delete'}
</button>
)}
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}
+31
View File
@@ -0,0 +1,31 @@
import { useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
// Edit page now redirects to Play page where DevToolbar handles editing
export default function Edit() {
const { gameId } = useParams();
const navigate = useNavigate();
const { user, isAuthenticated } = useAuth();
useEffect(() => {
if (!isAuthenticated || user?.isGuest) {
navigate('/login');
return;
}
// Redirect to play page - DevToolbar will show for the creator
navigate(`/play/${gameId}`, { replace: true });
}, [gameId, isAuthenticated, user, navigate]);
return (
<div style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '50vh',
color: '#64748b'
}}>
Redirecting to game...
</div>
);
}
+84
View File
@@ -0,0 +1,84 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import api from '../api';
import './Auth.css';
export default function ForgotPassword() {
const [email, setEmail] = useState('');
const [submitted, setSubmitted] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
setLoading(true);
try {
await api.forgotPassword(email);
setSubmitted(true);
} catch (err) {
setError(err.message || 'Failed to send reset email');
} finally {
setLoading(false);
}
};
if (submitted) {
return (
<div className="auth-page">
<div className="auth-container">
<div className="auth-header">
<span className="auth-icon">📧</span>
<h1>Check Your Email</h1>
</div>
<div className="auth-success">
<p>If an account exists with <strong>{email}</strong>, we've sent a password reset link.</p>
<p className="auth-hint">The link expires in 1 hour. Check your spam folder if you don't see it.</p>
</div>
<div className="auth-links">
<Link to="/login" className="btn btn-primary">Back to Login</Link>
</div>
</div>
</div>
);
}
return (
<div className="auth-page">
<div className="auth-container">
<div className="auth-header">
<span className="auth-icon">🔐</span>
<h1>Forgot Password</h1>
<p>Enter your email and we'll send you a reset link</p>
</div>
{error && <div className="auth-error">{error}</div>}
<form onSubmit={handleSubmit} className="auth-form">
<div className="form-group">
<label htmlFor="email">Email</label>
<input
type="email"
id="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="your@email.com"
required
autoFocus
/>
</div>
<button type="submit" className="btn btn-primary btn-full" disabled={loading}>
{loading ? 'Sending...' : 'Send Reset Link'}
</button>
</form>
<div className="auth-links">
<p>Remember your password? <Link to="/login">Login</Link></p>
<p>Don't have an account? <Link to="/register">Sign Up Free</Link></p>
</div>
</div>
</div>
);
}
+511
View File
@@ -0,0 +1,511 @@
.analytics-page {
max-width: 1200px;
margin: 0 auto;
padding: 2rem 1.5rem;
}
/* Header */
.analytics-header {
margin-bottom: 2rem;
}
.back-link {
display: inline-block;
color: #6366f1;
text-decoration: none;
font-size: 0.9rem;
margin-bottom: 0.5rem;
}
.back-link:hover {
text-decoration: underline;
}
.analytics-header h1 {
margin: 0 0 0.5rem;
color: #1e293b;
font-size: 1.75rem;
}
.game-meta {
display: flex;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
}
.publish-date {
font-size: 0.9rem;
color: #64748b;
}
.status-badge {
display: inline-block;
padding: 0.2rem 0.6rem;
border-radius: 1rem;
font-size: 0.75rem;
font-weight: 500;
text-transform: capitalize;
}
.status-published {
background: #d1fae5;
color: #065f46;
}
.status-draft {
background: #f1f5f9;
color: #64748b;
}
.status-pending_review {
background: #fef3c7;
color: #92400e;
}
/* Summary Cards */
.summary-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
}
.summary-card {
display: flex;
align-items: center;
gap: 1rem;
background: white;
border-radius: 1rem;
padding: 1.25rem;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
border-left: 4px solid;
}
.summary-card.plays {
border-color: #6366f1;
}
.summary-card.players {
border-color: #10b981;
}
.summary-card.session {
border-color: #f59e0b;
}
.summary-card.favorites {
border-color: #ef4444;
}
.card-icon {
font-size: 2rem;
width: 50px;
height: 50px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 0.75rem;
background: #f1f5f9;
}
.summary-card.plays .card-icon {
background: #eef2ff;
color: #6366f1;
}
.summary-card.players .card-icon {
background: #ecfdf5;
color: #10b981;
}
.summary-card.session .card-icon {
background: #fffbeb;
color: #f59e0b;
}
.summary-card.favorites .card-icon {
background: #fef2f2;
color: #ef4444;
}
.card-content {
flex: 1;
}
.card-value {
font-size: 1.5rem;
font-weight: 700;
color: #1e293b;
line-height: 1.2;
}
.card-label {
font-size: 0.85rem;
color: #64748b;
}
/* Charts Grid */
.charts-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.chart-card {
background: white;
border-radius: 1rem;
padding: 1.5rem;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
}
.chart-card h3 {
margin: 0 0 1.25rem;
font-size: 1rem;
color: #1e293b;
}
/* Daily Plays Bar Chart */
.daily-plays-card {
grid-column: 1 / -1;
}
.bar-chart {
display: flex;
align-items: flex-end;
height: 200px;
gap: 4px;
padding-bottom: 24px;
overflow-x: auto;
overflow-y: hidden;
}
.bar-chart.horizontal-scroll {
scroll-behavior: smooth;
-webkit-overflow-scrolling: touch;
}
.bar-container {
flex: 1;
min-width: 24px;
max-width: 40px;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-end;
position: relative;
}
.bar {
width: 100%;
min-height: 4px;
border-radius: 4px 4px 0 0;
transition: height 0.3s ease;
position: relative;
cursor: pointer;
}
.bar:hover {
opacity: 0.85;
transform: scaleY(1.02);
}
.bar-value {
position: absolute;
top: -20px;
left: 50%;
transform: translateX(-50%);
font-size: 0.7rem;
font-weight: 600;
color: #475569;
white-space: nowrap;
}
.bar-label {
position: absolute;
bottom: -20px;
font-size: 0.65rem;
color: #94a3b8;
white-space: nowrap;
}
/* Rating Distribution */
.rating-chart {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.rating-row {
display: flex;
align-items: center;
gap: 0.75rem;
}
.rating-label {
width: 60px;
font-size: 0.8rem;
color: #64748b;
}
.rating-bar-container {
flex: 1;
height: 20px;
background: #f1f5f9;
border-radius: 10px;
overflow: hidden;
}
.rating-bar {
height: 100%;
border-radius: 10px;
transition: width 0.5s ease;
min-width: 4px;
}
.rating-count {
width: 30px;
text-align: right;
font-size: 0.85rem;
font-weight: 600;
color: #475569;
}
.avg-rating {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid #e2e8f0;
text-align: center;
font-size: 0.9rem;
color: #64748b;
}
.avg-rating strong {
color: #1e293b;
font-size: 1.25rem;
}
/* Peak Hours Chart */
.hours-chart {
display: flex;
align-items: flex-end;
height: 150px;
gap: 2px;
padding-bottom: 24px;
}
.hour-bar-container {
flex: 1;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-end;
position: relative;
}
.hour-bar {
width: 100%;
min-height: 2px;
border-radius: 3px 3px 0 0;
transition: height 0.3s ease, background-color 0.3s ease;
cursor: pointer;
}
.hour-bar:hover {
opacity: 0.85;
}
.hour-label {
position: absolute;
bottom: -20px;
font-size: 0.55rem;
color: #94a3b8;
}
/* Score Distribution */
.score-distribution {
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.score-row {
display: flex;
align-items: center;
gap: 0.75rem;
}
.score-range {
width: 80px;
font-size: 0.75rem;
color: #64748b;
white-space: nowrap;
}
.score-bar-container {
flex: 1;
height: 16px;
background: #f1f5f9;
border-radius: 8px;
overflow: hidden;
}
.score-bar {
height: 100%;
background: linear-gradient(90deg, #6366f1, #8b5cf6);
border-radius: 8px;
transition: width 0.5s ease;
min-width: 4px;
}
.score-count {
width: 30px;
text-align: right;
font-size: 0.8rem;
font-weight: 600;
color: #475569;
}
/* No Data State */
.no-data {
text-align: center;
padding: 2rem;
color: #94a3b8;
font-size: 0.9rem;
}
/* Additional Stats */
.additional-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 1rem;
background: white;
border-radius: 1rem;
padding: 1.5rem;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
}
.stat-item {
text-align: center;
padding: 0.75rem;
}
.stat-item .stat-label {
display: block;
font-size: 0.8rem;
color: #64748b;
margin-bottom: 0.25rem;
}
.stat-item .stat-value {
font-size: 1.25rem;
font-weight: 700;
color: #1e293b;
}
/* Error State */
.error-state {
text-align: center;
padding: 4rem 2rem;
background: white;
border-radius: 1rem;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
}
.error-state h2 {
color: #dc2626;
margin-bottom: 1rem;
}
.error-state p {
color: #64748b;
margin-bottom: 1.5rem;
}
/* Loading State */
.loading {
text-align: center;
padding: 4rem;
color: #64748b;
}
/* Responsive */
@media (max-width: 768px) {
.analytics-page {
padding: 1rem;
}
.analytics-header h1 {
font-size: 1.4rem;
}
.summary-cards {
grid-template-columns: repeat(2, 1fr);
}
.summary-card {
padding: 1rem;
}
.card-icon {
width: 40px;
height: 40px;
font-size: 1.5rem;
}
.card-value {
font-size: 1.25rem;
}
.charts-grid {
grid-template-columns: 1fr;
}
.bar-chart {
height: 160px;
}
.hours-chart {
height: 120px;
}
.hour-label {
display: none;
}
.hour-bar-container:nth-child(6n+1) .hour-label {
display: block;
}
.bar-container {
min-width: 16px;
}
.bar-label {
display: none;
}
.bar-container:nth-child(5n+1) .bar-label {
display: block;
}
.additional-stats {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 480px) {
.summary-cards {
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
}
.summary-card {
flex-direction: column;
text-align: center;
gap: 0.5rem;
}
}
+246
View File
@@ -0,0 +1,246 @@
import { useState, useEffect } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import api from '../api';
import './GameAnalytics.css';
export default function GameAnalytics() {
const { gameId } = useParams();
const { user, isAuthenticated } = useAuth();
const navigate = useNavigate();
const [analytics, setAnalytics] = useState(null);
const [game, setGame] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
if (!isAuthenticated || user?.isGuest) {
navigate('/login');
return;
}
loadAnalytics();
}, [isAuthenticated, user, gameId]);
const loadAnalytics = async () => {
try {
const [analyticsData, gameData] = await Promise.all([
api.getGameAnalytics(gameId),
api.getMyGame(gameId)
]);
setAnalytics(analyticsData);
setGame(gameData.game);
} catch (err) {
console.error('Failed to load analytics:', err);
setError(err.message);
} finally {
setLoading(false);
}
};
const formatTime = (seconds) => {
if (!seconds) return '0m';
const hours = Math.floor(seconds / 3600);
const mins = Math.floor((seconds % 3600) / 60);
if (hours > 0) return `${hours}h ${mins}m`;
return `${mins}m`;
};
const getMaxValue = (arr) => Math.max(...arr, 1);
if (loading) {
return <div className="analytics-page"><div className="loading">Loading analytics...</div></div>;
}
if (error) {
return (
<div className="analytics-page">
<div className="error-state">
<h2>Error Loading Analytics</h2>
<p>{error}</p>
<Link to="/dashboard" className="btn btn-primary">Back to Dashboard</Link>
</div>
</div>
);
}
const { dailyPlays, ratingDistribution, peakHours, scoreDistribution, summary } = analytics;
// Calculate max values for scaling
const maxDailyPlays = getMaxValue(dailyPlays.map(d => d.plays));
const maxRating = getMaxValue(Object.values(ratingDistribution));
const maxHour = getMaxValue(Object.values(peakHours));
const maxScore = getMaxValue(Object.values(scoreDistribution));
return (
<div className="analytics-page">
{/* Header */}
<div className="analytics-header">
<div className="header-info">
<Link to="/dashboard" className="back-link">Back to Dashboard</Link>
<h1>{game?.title || 'Game'} Analytics</h1>
<div className="game-meta">
<span className={`status-badge status-${game?.status}`}>
{game?.status?.replace('_', ' ')}
</span>
<span className="publish-date">
Published: {game?.publishedAt ? new Date(game.publishedAt).toLocaleDateString() : 'Not published'}
</span>
</div>
</div>
</div>
{/* Summary Cards */}
<div className="summary-cards">
<div className="summary-card plays">
<div className="card-icon">&#9654;</div>
<div className="card-content">
<div className="card-value">{summary.totalPlays.toLocaleString()}</div>
<div className="card-label">Total Plays</div>
</div>
</div>
<div className="summary-card players">
<div className="card-icon">&#128100;</div>
<div className="card-content">
<div className="card-value">{summary.uniquePlayers.toLocaleString()}</div>
<div className="card-label">Unique Players</div>
</div>
</div>
<div className="summary-card session">
<div className="card-icon">&#9201;</div>
<div className="card-content">
<div className="card-value">{formatTime(summary.avgSessionDuration)}</div>
<div className="card-label">Avg Session</div>
</div>
</div>
<div className="summary-card favorites">
<div className="card-icon">&#9829;</div>
<div className="card-content">
<div className="card-value">{summary.favoriteCount.toLocaleString()}</div>
<div className="card-label">Favorites</div>
</div>
</div>
</div>
{/* Charts Grid */}
<div className="charts-grid">
{/* Daily Plays Chart */}
<div className="chart-card daily-plays-card">
<h3>Daily Plays (Last 30 Days)</h3>
<div className="bar-chart horizontal-scroll">
{dailyPlays.map((day, i) => (
<div key={i} className="bar-container">
<div
className="bar"
style={{
height: `${(day.plays / maxDailyPlays) * 100}%`,
backgroundColor: `hsl(${220 + (i * 4)}, 70%, 55%)`
}}
title={`${day.date}: ${day.plays} plays`}
>
{day.plays > 0 && <span className="bar-value">{day.plays}</span>}
</div>
<span className="bar-label">{day.label}</span>
</div>
))}
</div>
{dailyPlays.every(d => d.plays === 0) && (
<div className="no-data">No plays recorded yet</div>
)}
</div>
{/* Rating Distribution */}
<div className="chart-card rating-card">
<h3>Rating Distribution</h3>
<div className="rating-chart">
{[5, 4, 3, 2, 1].map(star => (
<div key={star} className="rating-row">
<span className="rating-label">{star} star{star !== 1 ? 's' : ''}</span>
<div className="rating-bar-container">
<div
className="rating-bar"
style={{
width: `${maxRating > 0 ? (ratingDistribution[star] / maxRating) * 100 : 0}%`,
backgroundColor: star >= 4 ? '#10b981' : star === 3 ? '#f59e0b' : '#ef4444'
}}
/>
</div>
<span className="rating-count">{ratingDistribution[star]}</span>
</div>
))}
</div>
<div className="avg-rating">
Average: <strong>{summary.avgRating ? summary.avgRating.toFixed(1) : 'N/A'}</strong> / 5
</div>
</div>
{/* Peak Hours Chart */}
<div className="chart-card hours-card">
<h3>Peak Play Hours (24h)</h3>
<div className="hours-chart">
{Object.entries(peakHours).map(([hour, count]) => (
<div key={hour} className="hour-bar-container">
<div
className="hour-bar"
style={{
height: `${maxHour > 0 ? (count / maxHour) * 100 : 0}%`,
backgroundColor: count === maxHour && count > 0 ? '#6366f1' : '#94a3b8'
}}
title={`${hour}:00 - ${count} plays`}
/>
<span className="hour-label">{hour}</span>
</div>
))}
</div>
{Object.values(peakHours).every(v => v === 0) && (
<div className="no-data">No play time data yet</div>
)}
</div>
{/* High Score Distribution */}
<div className="chart-card scores-card">
<h3>High Score Distribution</h3>
<div className="score-distribution">
{Object.entries(scoreDistribution).map(([range, count]) => (
<div key={range} className="score-row">
<span className="score-range">{range}</span>
<div className="score-bar-container">
<div
className="score-bar"
style={{
width: `${maxScore > 0 ? (count / maxScore) * 100 : 0}%`
}}
/>
</div>
<span className="score-count">{count}</span>
</div>
))}
</div>
{Object.values(scoreDistribution).every(v => v === 0) && (
<div className="no-data">No high scores recorded yet</div>
)}
</div>
</div>
{/* Additional Stats */}
<div className="additional-stats">
<div className="stat-item">
<span className="stat-label">Highest Score</span>
<span className="stat-value">{summary.highestScore?.toLocaleString() || 'N/A'}</span>
</div>
<div className="stat-item">
<span className="stat-label">Total Ratings</span>
<span className="stat-value">{summary.totalRatings}</span>
</div>
<div className="stat-item">
<span className="stat-label">Remix Count</span>
<span className="stat-value">{summary.remixCount}</span>
</div>
<div className="stat-item">
<span className="stat-label">Tokens Earned</span>
<span className="stat-value">{summary.tokensEarned}</span>
</div>
</div>
</div>
);
}
+488
View File
@@ -0,0 +1,488 @@
.game-stats-page {
min-height: calc(100vh - 60px);
background: var(--bg-color);
padding-bottom: 2rem;
}
/* Loading & Error States */
.game-stats-page .loading-state,
.game-stats-page .error-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 400px;
text-align: center;
padding: 2rem;
}
.game-stats-page .loading-spinner {
width: 48px;
height: 48px;
border: 4px solid rgba(255, 255, 255, 0.1);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 1s linear infinite;
}
.error-state .error-icon {
font-size: 4rem;
margin-bottom: 1rem;
}
/* Stats Header */
.stats-header {
background: linear-gradient(135deg, var(--card-bg) 0%, #1a1a2e 100%);
padding: 2rem;
display: flex;
gap: 2rem;
border-bottom: 1px solid var(--border-color);
}
.game-preview {
flex-shrink: 0;
}
.game-thumbnail {
width: 200px;
height: 150px;
object-fit: cover;
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
}
.game-placeholder {
width: 200px;
height: 150px;
background: linear-gradient(135deg, #2a2a4a, #1a1a2e);
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 4rem;
}
.game-info {
flex: 1;
}
.game-info h1 {
font-size: 2rem;
margin: 0 0 0.5rem 0;
}
.creator-link {
color: var(--primary);
text-decoration: none;
font-size: 1rem;
display: inline-block;
margin-bottom: 0.75rem;
}
.creator-link:hover {
text-decoration: underline;
}
.game-description {
color: var(--text-secondary);
margin: 0 0 1rem 0;
max-width: 600px;
line-height: 1.5;
}
.quick-stats {
display: flex;
gap: 2rem;
margin-bottom: 1.5rem;
}
.quick-stat {
display: flex;
align-items: center;
gap: 0.5rem;
}
.quick-stat .stat-icon {
font-size: 1.25rem;
}
.quick-stat .stat-value {
font-size: 1.25rem;
font-weight: 700;
}
.quick-stat .stat-label {
color: var(--text-secondary);
font-size: 0.875rem;
}
.header-actions {
display: flex;
gap: 1rem;
}
/* Tabs */
.stats-tabs {
display: flex;
gap: 0.5rem;
padding: 1rem 2rem;
background: var(--card-bg);
border-bottom: 1px solid var(--border-color);
}
.tab-btn {
background: transparent;
border: none;
color: var(--text-secondary);
padding: 0.75rem 1.5rem;
border-radius: 8px;
cursor: pointer;
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 1rem;
transition: all 0.2s;
}
.tab-btn:hover {
background: rgba(255, 255, 255, 0.05);
color: var(--text-primary);
}
.tab-btn.active {
background: var(--primary);
color: white;
}
.tab-icon {
font-size: 1.25rem;
}
/* Tab Content */
.tab-content {
max-width: 1000px;
margin: 0 auto;
padding: 2rem;
}
/* Overview Tab */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.stat-card {
background: var(--card-bg);
border-radius: 12px;
padding: 1.5rem;
text-align: center;
border: 1px solid var(--border-color);
}
.stat-card h3 {
margin: 0 0 0.5rem 0;
color: var(--text-secondary);
font-size: 0.875rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.big-number {
font-size: 2.5rem;
font-weight: 700;
color: var(--text-primary);
display: flex;
align-items: center;
justify-content: center;
gap: 0.25rem;
}
.big-number .star {
font-size: 2rem;
}
.stat-subtitle {
margin: 0.5rem 0 0 0;
color: var(--text-secondary);
font-size: 0.875rem;
}
.style-badge {
display: inline-block;
background: var(--primary);
color: white;
padding: 0.5rem 1rem;
border-radius: 20px;
font-weight: 600;
text-transform: capitalize;
}
/* Preview Section */
.preview-section {
background: var(--card-bg);
border-radius: 12px;
padding: 1.5rem;
border: 1px solid var(--border-color);
}
.preview-section h3 {
margin: 0 0 1rem 0;
}
.top-players-preview {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-bottom: 1rem;
}
.top-player {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.75rem;
background: rgba(255, 255, 255, 0.03);
border-radius: 8px;
}
.top-player.rank-1 {
background: rgba(255, 215, 0, 0.1);
}
.top-player.rank-2 {
background: rgba(192, 192, 192, 0.1);
}
.top-player.rank-3 {
background: rgba(205, 127, 50, 0.1);
}
.rank-badge {
font-size: 1.5rem;
}
.player-name {
flex: 1;
font-weight: 500;
}
.player-score {
font-weight: 700;
color: var(--primary);
}
/* Leaderboard Tab */
.leaderboard-tab h3 {
margin: 0 0 1.5rem 0;
}
.leaderboard-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.leaderboard-item {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem;
background: var(--card-bg);
border-radius: 8px;
border: 1px solid var(--border-color);
transition: all 0.2s;
}
.leaderboard-item:hover {
border-color: var(--primary);
}
.leaderboard-item.top-three {
border-width: 2px;
}
.leaderboard-item.top-three:nth-child(1) {
border-color: gold;
background: rgba(255, 215, 0, 0.05);
}
.leaderboard-item.top-three:nth-child(2) {
border-color: silver;
background: rgba(192, 192, 192, 0.05);
}
.leaderboard-item.top-three:nth-child(3) {
border-color: #cd7f32;
background: rgba(205, 127, 50, 0.05);
}
.leaderboard-item .rank {
width: 40px;
text-align: center;
font-size: 1.25rem;
font-weight: 700;
}
.leaderboard-item .player-info {
flex: 1;
}
.leaderboard-item .player-name {
font-weight: 600;
}
.leaderboard-item .score-info {
text-align: right;
}
.leaderboard-item .score {
font-size: 1.25rem;
font-weight: 700;
color: var(--primary);
display: block;
}
.leaderboard-item .date {
font-size: 0.75rem;
color: var(--text-secondary);
}
/* Ratings Tab */
.rating-summary {
background: var(--card-bg);
border-radius: 12px;
padding: 2rem;
border: 1px solid var(--border-color);
margin-bottom: 2rem;
}
.avg-rating {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
margin-bottom: 2rem;
}
.big-star {
font-size: 3rem;
}
.big-rating {
font-size: 4rem;
font-weight: 700;
}
.rating-count {
color: var(--text-secondary);
margin-left: 0.5rem;
}
.rating-bars {
max-width: 400px;
margin: 0 auto;
}
.rating-bar-row {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 0.75rem;
}
.star-label {
width: 50px;
text-align: right;
}
.bar-container {
flex: 1;
height: 8px;
background: rgba(255, 255, 255, 0.1);
border-radius: 4px;
overflow: hidden;
}
.bar-fill {
height: 100%;
background: linear-gradient(90deg, var(--primary), #fbbf24);
border-radius: 4px;
transition: width 0.3s;
}
.percentage {
width: 40px;
text-align: right;
color: var(--text-secondary);
font-size: 0.875rem;
}
.rate-cta {
text-align: center;
padding: 2rem;
background: var(--card-bg);
border-radius: 12px;
border: 1px solid var(--border-color);
}
.rate-cta p {
margin: 0 0 1rem 0;
color: var(--text-secondary);
}
/* Empty State */
.empty-state {
text-align: center;
padding: 3rem 2rem;
background: var(--card-bg);
border-radius: 12px;
}
.empty-state .empty-icon {
font-size: 4rem;
display: block;
margin-bottom: 1rem;
}
/* Mobile Responsive */
@media (max-width: 768px) {
.stats-header {
flex-direction: column;
align-items: center;
text-align: center;
}
.game-info {
display: flex;
flex-direction: column;
align-items: center;
}
.quick-stats {
flex-wrap: wrap;
justify-content: center;
}
.stats-tabs {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.tab-btn {
white-space: nowrap;
}
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
.big-number {
font-size: 2rem;
}
.big-rating {
font-size: 3rem;
}
}
+256
View File
@@ -0,0 +1,256 @@
import { useState, useEffect } from 'react';
import { useParams, Link } from 'react-router-dom';
import api from '../api';
import { AvatarMini, DEFAULT_AVATAR } from '../components/AvatarBuilder';
import './GameStats.css';
const TABS = [
{ id: 'overview', label: 'Overview', icon: '📊' },
{ id: 'leaderboard', label: 'Leaderboard', icon: '🏆' },
{ id: 'ratings', label: 'Ratings', icon: '⭐' }
];
export default function GameStats() {
const { gameId } = useParams();
const [game, setGame] = useState(null);
const [highScores, setHighScores] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [activeTab, setActiveTab] = useState('overview');
useEffect(() => {
loadGameStats();
}, [gameId]);
async function loadGameStats() {
setLoading(true);
try {
const [gameData, scoresData] = await Promise.all([
api.getGame(gameId),
api.getHighScores(gameId)
]);
setGame(gameData.game);
setHighScores(scoresData.highScores || []);
} catch (err) {
setError(err.message || 'Failed to load game stats');
} finally {
setLoading(false);
}
}
if (loading) {
return (
<div className="game-stats-page">
<div className="loading-state">
<div className="loading-spinner"></div>
<p>Loading game stats...</p>
</div>
</div>
);
}
if (error || !game) {
return (
<div className="game-stats-page">
<div className="error-state">
<span className="error-icon">😕</span>
<h2>Game Not Found</h2>
<p>{error}</p>
<Link to="/arcade" className="btn btn-primary">Browse Arcade</Link>
</div>
</div>
);
}
const avgRating = game.totalRatings > 0
? (game.averageRating || 0).toFixed(1)
: '0.0';
const ratingDistribution = [5, 4, 3, 2, 1].map(stars => {
// Simulated distribution (in a real app, this would come from API)
const total = game.totalRatings || 1;
const percentage = game.totalRatings > 0
? Math.max(0, Math.floor((stars === 5 ? 0.6 : stars === 4 ? 0.25 : stars === 3 ? 0.1 : 0.05) * 100))
: 0;
return { stars, percentage };
});
return (
<div className="game-stats-page">
{/* Game Header */}
<div className="stats-header">
<div className="game-preview">
{game.thumbnailData ? (
<img src={game.thumbnailData} alt={game.title} className="game-thumbnail" />
) : (
<div className="game-placeholder">🎮</div>
)}
</div>
<div className="game-info">
<h1>{game.title}</h1>
<Link to={`/creator/${game.creatorUsername}`} className="creator-link">
by {game.creatorDisplayName || game.creatorUsername}
</Link>
<p className="game-description">{game.description}</p>
<div className="quick-stats">
<div className="quick-stat">
<span className="stat-icon"></span>
<span className="stat-value">{game.playCount?.toLocaleString() || 0}</span>
<span className="stat-label">Plays</span>
</div>
<div className="quick-stat">
<span className="stat-icon"></span>
<span className="stat-value">{avgRating}</span>
<span className="stat-label">Rating</span>
</div>
<div className="quick-stat">
<span className="stat-icon"></span>
<span className="stat-value">{game.favoriteCount?.toLocaleString() || 0}</span>
<span className="stat-label">Favorites</span>
</div>
</div>
<div className="header-actions">
<Link to={`/play/${game.id}`} className="btn btn-primary">Play Game</Link>
</div>
</div>
</div>
{/* Tabs */}
<div className="stats-tabs">
{TABS.map(tab => (
<button
key={tab.id}
className={`tab-btn ${activeTab === tab.id ? 'active' : ''}`}
onClick={() => setActiveTab(tab.id)}
>
<span className="tab-icon">{tab.icon}</span>
<span className="tab-label">{tab.label}</span>
</button>
))}
</div>
{/* Tab Content */}
<div className="tab-content">
{activeTab === 'overview' && (
<div className="overview-tab">
<div className="stats-grid">
<div className="stat-card">
<h3>Total Plays</h3>
<div className="big-number">{game.playCount?.toLocaleString() || 0}</div>
<p className="stat-subtitle">All-time plays</p>
</div>
<div className="stat-card">
<h3>Average Rating</h3>
<div className="big-number">
<span className="star"></span>
{avgRating}
</div>
<p className="stat-subtitle">{game.totalRatings || 0} ratings</p>
</div>
<div className="stat-card">
<h3>Favorites</h3>
<div className="big-number">{game.favoriteCount?.toLocaleString() || 0}</div>
<p className="stat-subtitle">Players love this game</p>
</div>
<div className="stat-card">
<h3>Game Style</h3>
<div className="style-badge">{game.gameStyle || 'Classic'}</div>
<p className="stat-subtitle">Published {new Date(game.publishedAt).toLocaleDateString()}</p>
</div>
</div>
{/* Top Players Preview */}
{highScores.length > 0 && (
<div className="preview-section">
<h3>Top Players</h3>
<div className="top-players-preview">
{highScores.slice(0, 3).map((score, i) => (
<div key={i} className={`top-player rank-${i + 1}`}>
<span className="rank-badge">
{i === 0 ? '🥇' : i === 1 ? '🥈' : '🥉'}
</span>
<span className="player-name">{score.displayName || score.username}</span>
<span className="player-score">{score.score.toLocaleString()}</span>
</div>
))}
</div>
<button
className="btn btn-secondary"
onClick={() => setActiveTab('leaderboard')}
>
View Full Leaderboard
</button>
</div>
)}
</div>
)}
{activeTab === 'leaderboard' && (
<div className="leaderboard-tab">
<h3>High Scores</h3>
{highScores.length === 0 ? (
<div className="empty-state">
<span className="empty-icon">🏆</span>
<p>No high scores yet. Be the first!</p>
<Link to={`/play/${game.id}`} className="btn btn-primary">Play Now</Link>
</div>
) : (
<div className="leaderboard-list">
{highScores.map((score, i) => (
<div key={i} className={`leaderboard-item ${i < 3 ? 'top-three' : ''}`}>
<div className="rank">
{i === 0 ? '🥇' : i === 1 ? '🥈' : i === 2 ? '🥉' : `#${i + 1}`}
</div>
<div className="player-info">
<span className="player-name">{score.displayName || score.username}</span>
</div>
<div className="score-info">
<span className="score">{score.score.toLocaleString()}</span>
<span className="date">{new Date(score.achievedAt).toLocaleDateString()}</span>
</div>
</div>
))}
</div>
)}
</div>
)}
{activeTab === 'ratings' && (
<div className="ratings-tab">
<div className="rating-summary">
<div className="avg-rating">
<span className="big-star"></span>
<span className="big-rating">{avgRating}</span>
<span className="rating-count">({game.totalRatings || 0} ratings)</span>
</div>
<div className="rating-bars">
{ratingDistribution.map(({ stars, percentage }) => (
<div key={stars} className="rating-bar-row">
<span className="star-label">{stars} </span>
<div className="bar-container">
<div className="bar-fill" style={{ width: `${percentage}%` }}></div>
</div>
<span className="percentage">{percentage}%</span>
</div>
))}
</div>
</div>
<div className="rate-cta">
<p>Have you played this game?</p>
<Link to={`/play/${game.id}`} className="btn btn-primary">Play & Rate</Link>
</div>
</div>
)}
</div>
</div>
);
}
+494
View File
@@ -0,0 +1,494 @@
.home {
min-height: calc(100vh - 60px);
}
.hero {
background: linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%);
padding: 3rem 1.5rem;
display: flex;
flex-wrap: wrap;
gap: 2rem;
justify-content: center;
align-items: center;
}
.hero-content {
max-width: 550px;
}
.hero-tagline {
font-size: 0.9rem;
font-weight: 600;
color: #6366f1;
text-transform: uppercase;
letter-spacing: 1px;
margin: 0 0 0.75rem;
}
.hero-title {
font-size: 2.25rem;
line-height: 1.2;
margin: 0 0 1rem;
color: #1e293b;
}
.highlight {
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.hero-subtitle {
font-size: 1.05rem;
color: #475569;
margin: 0 0 1.5rem;
line-height: 1.6;
}
.hero-actions {
display: flex;
gap: 1rem;
flex-wrap: wrap;
margin-bottom: 1.25rem;
}
.hero-badges {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.hero-badges .badge {
background: rgba(99, 102, 241, 0.1);
color: #4f46e5;
padding: 0.35rem 0.75rem;
border-radius: 2rem;
font-size: 0.8rem;
font-weight: 500;
}
.hero-badges .badge.badge-rating {
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
color: white;
font-weight: 600;
}
.hero-visual {
width: 250px;
height: 250px;
position: relative;
}
.floating-games {
width: 100%;
height: 100%;
position: relative;
}
.float-item {
position: absolute;
font-size: 2.5rem;
animation: float 3s ease-in-out infinite;
}
.float-item:nth-child(1) { top: 10%; left: 20%; animation-delay: 0s; }
.float-item:nth-child(2) { top: 20%; right: 15%; animation-delay: 0.5s; }
.float-item:nth-child(3) { bottom: 25%; left: 10%; animation-delay: 1s; }
.float-item:nth-child(4) { bottom: 15%; right: 25%; animation-delay: 1.5s; }
@keyframes float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-15px); }
}
/* Theme section */
.theme-section {
max-width: 900px;
margin: 0 auto;
padding: 0 1.5rem;
}
/* Mission section */
.mission {
background: #f8fafc;
padding: 2.5rem 1.5rem;
text-align: center;
}
.mission-content {
max-width: 700px;
margin: 0 auto;
}
.mission .section-title {
margin-bottom: 1rem;
}
.mission-text {
font-size: 1.1rem;
color: #475569;
line-height: 1.7;
margin: 0;
}
/* Features section */
.features {
padding: 3rem 1.5rem;
max-width: 1000px;
margin: 0 auto;
}
.section-title {
text-align: center;
font-size: 1.75rem;
margin: 0 0 2rem;
color: #1e293b;
}
.feature-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1.25rem;
}
.feature-card {
background: white;
border-radius: 1rem;
padding: 1.25rem;
text-align: center;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
border: 1px solid #e2e8f0;
}
.feature-icon {
font-size: 2rem;
margin-bottom: 0.5rem;
}
.feature-card h3 {
font-size: 1rem;
margin: 0 0 0.5rem;
color: #1e293b;
}
.feature-card p {
font-size: 0.9rem;
color: #64748b;
margin: 0;
line-height: 1.5;
}
/* How it works section */
.how-it-works {
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
padding: 3rem 1.5rem;
color: white;
}
.how-it-works .section-title {
color: white;
}
.steps-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1.5rem;
max-width: 1000px;
margin: 0 auto;
}
.step-card {
background: rgba(255, 255, 255, 0.15);
border-radius: 1rem;
padding: 1.5rem;
text-align: center;
}
.step-number {
width: 40px;
height: 40px;
background: white;
color: #6366f1;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 1.25rem;
margin: 0 auto 0.75rem;
}
.step-card h3 {
font-size: 1.1rem;
margin: 0 0 0.5rem;
}
.step-card p {
font-size: 0.9rem;
opacity: 0.9;
margin: 0;
line-height: 1.5;
}
/* For educators section */
.for-educators {
padding: 3rem 1.5rem;
background: #f8fafc;
}
.educators-content {
max-width: 1000px;
margin: 0 auto;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2rem;
align-items: center;
}
.educators-text h2 {
font-size: 1.5rem;
color: #1e293b;
margin: 0 0 1.25rem;
}
.benefits-list {
list-style: none;
padding: 0;
margin: 0 0 1.5rem;
}
.benefits-list li {
padding: 0.5rem 0;
padding-left: 1.5rem;
position: relative;
color: #475569;
font-size: 0.95rem;
line-height: 1.5;
}
.benefits-list li::before {
content: "✓";
position: absolute;
left: 0;
color: #10b981;
font-weight: 700;
}
.benefits-list strong {
color: #1e293b;
}
.educators-actions {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
}
.btn-outline {
background: transparent;
color: #6366f1;
border: 2px solid #6366f1;
padding: 0.6rem 1.25rem;
border-radius: 0.5rem;
font-weight: 600;
text-decoration: none;
transition: all 0.2s;
}
.btn-outline:hover {
background: #6366f1;
color: white;
}
.testimonial {
background: white;
border-radius: 1rem;
padding: 1.5rem;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.08);
border-left: 4px solid #6366f1;
}
.testimonial p {
font-size: 1.05rem;
color: #475569;
font-style: italic;
line-height: 1.6;
margin: 0 0 0.75rem;
}
.testimonial-author {
font-size: 0.9rem;
color: #94a3b8;
font-weight: 500;
}
/* Safety section */
.safety {
padding: 3rem 1.5rem;
max-width: 1000px;
margin: 0 auto;
}
.safety-rating-badge {
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
margin-bottom: 1.5rem;
}
.rating-icon {
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
color: white;
font-size: 1.5rem;
font-weight: 800;
border-radius: 0.5rem;
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.3);
}
.rating-text {
font-size: 1.25rem;
font-weight: 700;
color: #10b981;
}
.safety-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1.25rem;
}
.safety-item {
text-align: center;
padding: 1rem;
}
.safety-icon {
font-size: 2rem;
display: block;
margin-bottom: 0.5rem;
}
.safety-item h4 {
font-size: 1rem;
color: #1e293b;
margin: 0 0 0.5rem;
}
.safety-item p {
font-size: 0.9rem;
color: #64748b;
margin: 0;
line-height: 1.5;
}
/* CTA section */
.cta-section {
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
padding: 3rem 1.5rem;
text-align: center;
color: white;
}
.cta-section h2 {
font-size: 1.75rem;
margin: 0 0 0.5rem;
}
.cta-section p {
font-size: 1rem;
margin: 0 0 1.25rem;
opacity: 0.9;
}
.cta-buttons {
display: flex;
gap: 1rem;
justify-content: center;
flex-wrap: wrap;
}
.cta-section .btn-primary {
background: white;
color: #6366f1;
}
.cta-section .btn-primary:hover {
background: #f8fafc;
}
.cta-section .btn-secondary {
background: rgba(255, 255, 255, 0.2);
color: white;
border: 2px solid white;
}
.cta-section .btn-secondary:hover {
background: rgba(255, 255, 255, 0.3);
}
@media (max-width: 768px) {
.hero {
padding: 2rem 1rem;
}
.hero-title {
font-size: 1.75rem;
}
.hero-visual {
width: 180px;
height: 180px;
}
.float-item {
font-size: 2rem;
}
.educators-content {
grid-template-columns: 1fr;
}
.educators-visual {
order: -1;
}
.section-title {
font-size: 1.5rem;
}
.mission,
.features,
.how-it-works,
.for-educators,
.safety,
.cta-section {
padding: 2rem 1rem;
}
/* Safety rating badge mobile */
.safety-rating-badge {
gap: 0.5rem;
}
.rating-icon {
width: 40px;
height: 40px;
font-size: 1.25rem;
}
.rating-text {
font-size: 1.1rem;
}
/* Hero badges wrap gracefully */
.hero-badges .badge {
flex-shrink: 0;
}
}
+205
View File
@@ -0,0 +1,205 @@
import { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import FingerprintJS from '@fingerprintjs/fingerprintjs';
import { useAuth } from '../context/AuthContext';
import './Home.css';
export default function Home() {
const { isAuthenticated, guestLogin } = useAuth();
const navigate = useNavigate();
const [loading, setLoading] = useState(false);
const handlePlayAsGuest = async () => {
if (isAuthenticated) {
navigate('/arcade');
return;
}
setLoading(true);
try {
const fp = await FingerprintJS.load();
const result = await fp.get();
await guestLogin(result.visitorId);
navigate('/arcade');
} catch (error) {
console.error('Guest login failed:', error);
alert('Failed to start guest session. Please try again.');
} finally {
setLoading(false);
}
};
return (
<div className="home">
<section className="hero">
<div className="hero-content">
<p className="hero-tagline">Teaching AI Literacy Through Play</p>
<h1 className="hero-title">
Where Everyone Learns to
<br />
<span className="highlight">Compose Games with AI</span>
</h1>
<p className="hero-subtitle">
GamerComp teaches effective AI communication through game creation.
Learn prompt engineering by building your own video games - a skill that
prepares you for the AI-powered future.
</p>
<div className="hero-actions">
<Link to="/arcade" className="btn btn-primary btn-lg">
Browse Games
</Link>
</div>
<div className="hero-badges">
<span className="badge badge-rating">Rated E for Everyone</span>
<span className="badge">No Chat - No Strangers</span>
<span className="badge">All Games Moderated</span>
</div>
</div>
<div className="hero-visual">
<div className="floating-games">
<div className="float-item">🎮</div>
<div className="float-item">🤖</div>
<div className="float-item">💡</div>
<div className="float-item"></div>
</div>
</div>
</section>
<section className="mission">
<div className="mission-content">
<h2 className="section-title">Preparing for an AI-Powered World</h2>
<p className="mission-text">
AI isn't going away. By learning to communicate clearly with AI systems now,
you develop critical thinking skills you'll use your entire life.
GamerComp makes this learning fun through game creation.
</p>
</div>
</section>
<section className="features">
<h2 className="section-title">What You'll Learn</h2>
<div className="feature-grid">
<div className="feature-card">
<div className="feature-icon">💬</div>
<h3>Clear Communication</h3>
<p>Practice expressing ideas precisely. Vague prompts create vague games - learn why specificity matters.</p>
</div>
<div className="feature-card">
<div className="feature-icon">🔄</div>
<h3>Iterative Thinking</h3>
<p>Games rarely come out perfect the first time. Learn to give feedback and refine your creations.</p>
</div>
<div className="feature-card">
<div className="feature-icon">🧩</div>
<h3>Problem Decomposition</h3>
<p>Breaking down a game idea into theme, characters, goals, and mechanics teaches structured thinking.</p>
</div>
<div className="feature-card">
<div className="feature-icon">🎯</div>
<h3>Goal Setting</h3>
<p>Defining what makes a game fun helps you understand how to set and communicate clear objectives.</p>
</div>
</div>
</section>
<section className="how-it-works">
<h2 className="section-title">How GamerComp Works</h2>
<div className="steps-grid">
<div className="step-card">
<div className="step-number">1</div>
<h3>Answer Questions</h3>
<p>Our guided wizard asks about theme, characters, goals, and challenges. No blank page intimidation.</p>
</div>
<div className="step-card">
<div className="step-number">2</div>
<h3>AI Creates the Game</h3>
<p>Their answers become a detailed prompt. The AI generates a fully playable game in seconds.</p>
</div>
<div className="step-card">
<div className="step-number">3</div>
<h3>Test & Refine</h3>
<p>Play the game, then give feedback. Learn how specific feedback creates better results.</p>
</div>
<div className="step-card">
<div className="step-number">4</div>
<h3>Share & Earn</h3>
<p>Publish games for others to play. Earn XP and level up as a Game Composer.</p>
</div>
</div>
</section>
<section className="for-educators">
<div className="educators-content">
<div className="educators-text">
<h2>For Teachers & Parents</h2>
<ul className="benefits-list">
<li><strong>Classroom-ready:</strong> Teacher accounts can create student groups and track progress</li>
<li><strong>Safe environment:</strong> All content is moderated. No personal info collection.</li>
<li><strong>Curriculum aligned:</strong> Supports computational thinking standards (ISTE, CS)</li>
<li><strong>Progress tracking:</strong> See what students create and how their skills develop</li>
<li><strong>No cost:</strong> Free for educational use with generous daily limits</li>
</ul>
<div className="educators-actions">
<Link to="/register" className="btn btn-primary">Create Teacher Account</Link>
<Link to="/arcade" className="btn btn-outline">Preview Games</Link>
</div>
</div>
<div className="educators-visual">
<div className="testimonial">
<p>"My students are finally excited about learning to communicate clearly. They don't even realize they're learning!"</p>
<span className="testimonial-author">- Middle School Teacher</span>
</div>
</div>
</div>
</section>
<section className="safety">
<div className="safety-rating-badge">
<span className="rating-icon">E</span>
<span className="rating-text">Rated E for Everyone</span>
</div>
<h2 className="section-title">Built for Child Safety</h2>
<div className="safety-grid">
<div className="safety-item">
<span className="safety-icon">🚫</span>
<h4>No Chat or Messaging</h4>
<p>Players cannot communicate with each other. No strangers, no predators, no bullying.</p>
</div>
<div className="safety-item">
<span className="safety-icon">🛡️</span>
<h4>All Games Moderated</h4>
<p>Every game is reviewed before publishing. AI filters block inappropriate content automatically.</p>
</div>
<div className="safety-item">
<span className="safety-icon">🔒</span>
<h4>Privacy First</h4>
<p>No real names required. Minimal data collection. COPPA-aware design.</p>
</div>
<div className="safety-item">
<span className="safety-icon">👨‍👩‍👧‍👦</span>
<h4>Family Friendly</h4>
<p>Designed for learners of all ages. Clean, friendly content throughout.</p>
</div>
</div>
</section>
<section className="cta-section">
<h2>Ready to Try It?</h2>
<p>No signup required to play. Create an account to save progress and publish games.</p>
<div className="cta-buttons">
<button onClick={handlePlayAsGuest} className="btn btn-primary btn-lg" disabled={loading}>
{loading ? 'Starting...' : 'Start Playing Free'}
</button>
{!isAuthenticated && (
<Link to="/register" className="btn btn-secondary btn-lg">
Create Free Account
</Link>
)}
</div>
</section>
</div>
);
}
+806
View File
@@ -0,0 +1,806 @@
.leaderboard-page {
min-height: calc(100vh - 60px);
padding: 2rem 1rem;
background: linear-gradient(180deg, #fef3c7 0%, #fde68a 30%, #fbbf24 100%);
max-width: 900px;
margin: 0 auto;
}
/* Header */
.leaderboard-header {
text-align: center;
margin-bottom: 2rem;
position: relative;
}
.header-decoration {
position: absolute;
top: -10px;
left: 50%;
transform: translateX(-50%);
width: 200px;
height: 60px;
pointer-events: none;
}
.decoration-star {
position: absolute;
font-size: 1.5rem;
animation: twinkle 2s ease-in-out infinite;
}
.star-1 {
left: 10%;
animation-delay: 0s;
}
.star-2 {
left: 50%;
transform: translateX(-50%);
animation-delay: 0.5s;
}
.star-3 {
right: 10%;
animation-delay: 1s;
}
@keyframes twinkle {
0%, 100% {
opacity: 0.5;
transform: scale(0.8);
}
50% {
opacity: 1;
transform: scale(1.1);
}
}
.leaderboard-title {
font-size: 2.5rem;
color: #78350f;
margin: 0;
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
text-shadow: 2px 2px 0 rgba(255, 255, 255, 0.5);
}
.title-trophy {
animation: bounce 2s ease-in-out infinite;
}
.title-trophy:last-child {
animation-delay: 0.5s;
}
@keyframes bounce {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-8px);
}
}
.leaderboard-subtitle {
color: #92400e;
margin: 0.5rem 0 0;
font-size: 1.1rem;
}
/* Tabs */
.tabs-container {
display: flex;
justify-content: center;
gap: 0.5rem;
margin-bottom: 1.5rem;
flex-wrap: wrap;
}
.tab-btn {
display: flex;
align-items: center;
gap: 0.5rem;
background: white;
border: 3px solid #f59e0b;
padding: 0.75rem 1.25rem;
border-radius: 2rem;
font-size: 1rem;
font-weight: 600;
color: #78350f;
cursor: pointer;
transition: all 0.3s ease;
box-shadow: 0 4px 0 #d97706;
}
.tab-btn:hover {
transform: translateY(-2px);
box-shadow: 0 6px 0 #d97706;
}
.tab-btn.active {
background: linear-gradient(135deg, #f59e0b 0%, #fbbf24 100%);
color: white;
transform: translateY(2px);
box-shadow: 0 2px 0 #d97706;
}
.tab-icon {
font-size: 1.25rem;
}
/* Period Filters */
.period-filters {
display: flex;
justify-content: center;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
.period-btn {
background: rgba(255, 255, 255, 0.7);
border: 2px solid rgba(120, 53, 15, 0.2);
padding: 0.5rem 1rem;
border-radius: 1rem;
font-size: 0.9rem;
color: #78350f;
cursor: pointer;
transition: all 0.2s;
}
.period-btn:hover {
background: white;
border-color: #f59e0b;
}
.period-btn.active {
background: #78350f;
color: white;
border-color: #78350f;
}
/* Podium */
.podium-section {
margin-bottom: 2rem;
}
.podium {
display: flex;
justify-content: center;
align-items: flex-end;
gap: 0.5rem;
padding: 1rem;
}
.podium-spot {
display: flex;
flex-direction: column;
align-items: center;
position: relative;
animation: podiumEntrance 0.6s ease-out backwards;
}
.podium-1 {
animation-delay: 0.3s;
}
.podium-2 {
animation-delay: 0.1s;
}
.podium-3 {
animation-delay: 0.2s;
}
@keyframes podiumEntrance {
from {
opacity: 0;
transform: translateY(50px) scale(0.8);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.podium-medal {
font-size: 2rem;
position: absolute;
top: -20px;
animation: medalPulse 2s ease-in-out infinite;
}
.podium-1 .podium-medal {
font-size: 2.5rem;
top: -30px;
}
@keyframes medalPulse {
0%, 100% {
transform: scale(1) rotate(-5deg);
}
50% {
transform: scale(1.1) rotate(5deg);
}
}
.podium-avatar-link {
display: block;
margin-bottom: 0.5rem;
transition: transform 0.3s;
}
.podium-avatar-link:hover {
transform: scale(1.1);
}
.podium-game-thumb {
width: 60px;
height: 60px;
border-radius: 12px;
background: white;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
border: 3px solid white;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.podium-1 .podium-game-thumb {
width: 80px;
height: 80px;
}
.podium-game-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
}
.podium-game-thumb .game-icon {
font-size: 2rem;
}
.podium-info {
text-align: center;
margin-bottom: 0.5rem;
}
.podium-name {
display: block;
font-weight: 700;
color: #78350f;
text-decoration: none;
font-size: 0.9rem;
max-width: 100px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.podium-name:hover {
text-decoration: underline;
}
.podium-stat {
font-size: 0.75rem;
color: #92400e;
display: flex;
align-items: center;
justify-content: center;
gap: 0.25rem;
flex-wrap: wrap;
}
.podium-stand {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
font-weight: 800;
color: white;
border-radius: 8px 8px 0 0;
position: relative;
overflow: hidden;
}
.podium-stand::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 10px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.3) 0%, transparent 100%);
}
.podium-stand-1 {
width: 120px;
height: 100px;
background: linear-gradient(135deg, #ffd700 0%, #ffb700 50%, #cc9500 100%);
font-size: 2rem;
box-shadow: 0 8px 20px rgba(255, 183, 0, 0.4);
}
.podium-stand-2 {
width: 100px;
height: 70px;
background: linear-gradient(135deg, #e8e8e8 0%, #c0c0c0 50%, #a0a0a0 100%);
font-size: 1.5rem;
box-shadow: 0 8px 20px rgba(192, 192, 192, 0.4);
}
.podium-stand-3 {
width: 100px;
height: 50px;
background: linear-gradient(135deg, #cd7f32 0%, #b8702c 50%, #8b5a2b 100%);
font-size: 1.25rem;
box-shadow: 0 8px 20px rgba(205, 127, 50, 0.4);
}
.podium-rank-num {
text-shadow: 2px 2px 0 rgba(0, 0, 0, 0.2);
}
/* Leaderboard List */
.leaderboard-list {
background: white;
border-radius: 1.5rem;
padding: 1.5rem;
box-shadow: 0 8px 30px rgba(120, 53, 15, 0.15);
}
.list-header {
text-align: center;
color: #78350f;
margin: 0 0 1rem;
font-size: 1.25rem;
}
.leaderboard-item {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem;
border-radius: 1rem;
margin-bottom: 0.5rem;
background: #fefce8;
transition: all 0.3s ease;
position: relative;
overflow: hidden;
animation: slideIn 0.4s ease-out backwards;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateX(-20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.leaderboard-item:hover {
transform: translateX(5px);
box-shadow: 0 4px 12px rgba(120, 53, 15, 0.1);
}
.leaderboard-item.gold {
background: linear-gradient(90deg, #fef9c3 0%, #fef3c7 100%);
border: 2px solid #fcd34d;
}
.leaderboard-item.silver {
background: linear-gradient(90deg, #f5f5f5 0%, #e5e5e5 100%);
border: 2px solid #d4d4d4;
}
.leaderboard-item.bronze {
background: linear-gradient(90deg, #fef3e6 0%, #fed7aa 100%);
border: 2px solid #fdba74;
}
.item-shine {
position: absolute;
top: 0;
left: -100%;
width: 50%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.4), transparent);
animation: shine 3s ease-in-out infinite;
}
@keyframes shine {
0% {
left: -100%;
}
50%, 100% {
left: 150%;
}
}
/* Rank Badge */
.rank-badge {
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.rank-badge.top-three {
background: none;
}
.rank-medal {
font-size: 2rem;
}
.rank-number {
font-size: 1.1rem;
font-weight: 700;
color: #92400e;
background: white;
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
border: 2px solid #fcd34d;
}
/* Item Avatar */
.item-avatar-link {
display: block;
flex-shrink: 0;
transition: transform 0.2s;
}
.item-avatar-link:hover {
transform: scale(1.1);
}
.item-game-thumb {
width: 48px;
height: 48px;
border-radius: 10px;
background: white;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
border: 2px solid #fcd34d;
}
.item-game-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
}
.item-game-thumb .game-icon {
font-size: 1.5rem;
}
/* Item Info */
.item-info {
flex: 1;
min-width: 0;
}
.item-name {
display: block;
font-weight: 700;
color: #78350f;
text-decoration: none;
font-size: 1rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.item-name:hover {
text-decoration: underline;
}
.item-creator,
.item-level {
display: block;
font-size: 0.8rem;
color: #92400e;
margin-top: 0.125rem;
}
/* Item Stats */
.item-stats {
display: flex;
align-items: center;
gap: 0.25rem;
flex-wrap: wrap;
justify-content: flex-end;
font-size: 0.9rem;
color: #78350f;
}
.stat-icon {
font-size: 1rem;
}
.stat-num {
font-weight: 700;
font-size: 1.1rem;
}
.stat-label {
color: #92400e;
font-size: 0.85rem;
}
.stat-secondary {
color: #a16207;
font-size: 0.8rem;
margin-left: 0.5rem;
padding-left: 0.5rem;
border-left: 1px solid #fcd34d;
}
/* Loading State */
.loading-state {
text-align: center;
padding: 4rem 2rem;
background: white;
border-radius: 1.5rem;
box-shadow: 0 8px 30px rgba(120, 53, 15, 0.15);
}
.loading-spinner {
width: 50px;
height: 50px;
border: 4px solid #fef3c7;
border-top-color: #f59e0b;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 1rem;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.loading-state p {
color: #92400e;
font-size: 1.1rem;
}
/* Empty State */
.empty-state {
text-align: center;
padding: 4rem 2rem;
background: white;
border-radius: 1.5rem;
box-shadow: 0 8px 30px rgba(120, 53, 15, 0.15);
}
.empty-icon {
font-size: 4rem;
display: block;
margin-bottom: 1rem;
}
.empty-state h3 {
color: #78350f;
margin: 0 0 0.5rem;
font-size: 1.5rem;
}
.empty-state p {
color: #92400e;
margin: 0;
}
/* Load More */
.load-more-section {
text-align: center;
margin-top: 1.5rem;
}
.load-more-btn {
background: white;
border: 3px solid #f59e0b;
padding: 0.75rem 2rem;
border-radius: 2rem;
font-size: 1rem;
font-weight: 600;
color: #78350f;
cursor: pointer;
transition: all 0.3s ease;
display: inline-flex;
align-items: center;
gap: 0.5rem;
box-shadow: 0 4px 0 #d97706;
}
.load-more-btn:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 6px 0 #d97706;
}
.load-more-btn:active:not(:disabled) {
transform: translateY(2px);
box-shadow: 0 2px 0 #d97706;
}
.load-more-btn:disabled {
opacity: 0.7;
cursor: not-allowed;
}
.btn-spinner {
width: 16px;
height: 16px;
border: 2px solid #fef3c7;
border-top-color: #f59e0b;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.btn-arrow {
font-size: 1.2rem;
animation: bounceArrow 1s ease-in-out infinite;
}
@keyframes bounceArrow {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(3px);
}
}
/* Mobile Responsive */
@media (max-width: 640px) {
.leaderboard-page {
padding: 1rem;
}
.leaderboard-title {
font-size: 1.75rem;
}
.tabs-container {
gap: 0.25rem;
}
.tab-btn {
padding: 0.5rem 0.75rem;
font-size: 0.85rem;
}
.tab-label {
display: none;
}
.tab-icon {
font-size: 1.5rem;
}
.period-filters {
gap: 0.25rem;
}
.period-btn {
padding: 0.4rem 0.75rem;
font-size: 0.8rem;
}
.podium {
gap: 0.25rem;
padding: 0.5rem;
}
.podium-stand-1 {
width: 90px;
height: 80px;
font-size: 1.5rem;
}
.podium-stand-2 {
width: 75px;
height: 55px;
font-size: 1.25rem;
}
.podium-stand-3 {
width: 75px;
height: 40px;
font-size: 1rem;
}
.podium-name {
font-size: 0.75rem;
max-width: 70px;
}
.podium-medal {
font-size: 1.5rem;
}
.podium-1 .podium-medal {
font-size: 2rem;
top: -25px;
}
.leaderboard-list {
padding: 1rem;
}
.leaderboard-item {
padding: 0.75rem;
gap: 0.75rem;
}
.rank-badge {
width: 36px;
height: 36px;
}
.rank-medal {
font-size: 1.5rem;
}
.rank-number {
font-size: 0.9rem;
width: 32px;
height: 32px;
}
.item-name {
font-size: 0.9rem;
}
.item-stats {
font-size: 0.8rem;
}
.stat-num {
font-size: 0.95rem;
}
.stat-secondary {
display: none;
}
}
/* Fun hover effects for kids */
.leaderboard-item:nth-child(even):hover {
background: #fef9c3;
}
.podium-spot:hover .podium-medal {
animation: wobble 0.5s ease-in-out;
}
@keyframes wobble {
0%, 100% {
transform: rotate(-5deg);
}
25% {
transform: rotate(10deg);
}
50% {
transform: rotate(-10deg);
}
75% {
transform: rotate(5deg);
}
}
+347
View File
@@ -0,0 +1,347 @@
import { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import api from '../api';
import { AvatarMini, DEFAULT_AVATAR } from '../components/AvatarBuilder';
import './Leaderboard.css';
const TABS = [
{ id: 'players', label: 'Top Players', icon: '🎮' },
{ id: 'creators', label: 'Top Creators', icon: '🛠️' },
{ id: 'games', label: 'Top Games', icon: '🏆' }
];
const PERIODS = [
{ id: 'week', label: 'This Week' },
{ id: 'month', label: 'This Month' },
{ id: 'all', label: 'All Time' }
];
const RANK_STYLES = {
1: { icon: '🥇', class: 'gold', label: '1st' },
2: { icon: '🥈', class: 'silver', label: '2nd' },
3: { icon: '🥉', class: 'bronze', label: '3rd' }
};
export default function Leaderboard() {
const [activeTab, setActiveTab] = useState('players');
const [period, setPeriod] = useState('all');
const [data, setData] = useState([]);
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const limit = 10;
useEffect(() => {
setPage(1);
setData([]);
loadData(1, true);
}, [activeTab, period]);
const loadData = async (pageNum, reset = false) => {
if (reset) {
setLoading(true);
} else {
setLoadingMore(true);
}
try {
let result;
switch (activeTab) {
case 'players':
result = await api.getTopPlayers(pageNum, limit);
break;
case 'creators':
result = await api.getTopCreators(pageNum, limit, period);
break;
case 'games':
result = await api.getTopGames(pageNum, limit, period);
break;
default:
result = { leaderboard: [] };
}
// Normalize the response - API returns 'leaderboard' array
const items = (result.leaderboard || result.items || []).map(item => ({
...item,
// Map API field names to what the component expects
xpTotal: item.xp || item.xpTotal || 0,
levelName: item.badge || item.levelName,
gamesCreated: item.gamesCount || item.gamesCreated || 0,
totalPlays: item.totalPlays || item.periodPlays || 0,
playCount: item.periodPlays || item.allTimePlays || item.playCount || 0,
rating: item.avgRating ? parseFloat(item.avgRating) : (item.rating || 0),
thumbnail: item.thumbnailData || item.thumbnailUrl || item.thumbnail,
creatorName: item.creator?.displayName || item.creator?.username || item.creatorName
}));
setData(prev => reset ? items : [...prev, ...items]);
setHasMore(result.pagination?.hasMore ?? items.length === limit);
setPage(pageNum);
} catch (error) {
console.error('Failed to load leaderboard:', error);
} finally {
setLoading(false);
setLoadingMore(false);
}
};
const handleLoadMore = () => {
if (!loadingMore && hasMore) {
loadData(page + 1);
}
};
const renderPodium = () => {
if (data.length < 3) return null;
const top3 = data.slice(0, 3);
const podiumOrder = [top3[1], top3[0], top3[2]]; // 2nd, 1st, 3rd for visual
return (
<div className="podium-section">
<div className="podium">
{podiumOrder.map((item, idx) => {
const actualRank = idx === 0 ? 2 : idx === 1 ? 1 : 3;
const rankStyle = RANK_STYLES[actualRank];
return (
<div
key={item.id}
className={`podium-spot podium-${actualRank} ${rankStyle.class}`}
>
<div className="podium-medal">{rankStyle.icon}</div>
<Link
to={activeTab === 'games' ? `/play/${item.id}` : `/profile/${item.id}`}
className="podium-avatar-link"
>
{activeTab === 'games' ? (
<div className="podium-game-thumb">
{item.thumbnail ? (
<img src={item.thumbnail} alt={item.title} />
) : (
<span className="game-icon">🎮</span>
)}
</div>
) : (
<AvatarMini
data={item.avatarData || DEFAULT_AVATAR}
size={actualRank === 1 ? 80 : 60}
/>
)}
</Link>
<div className="podium-info">
<Link
to={activeTab === 'games' ? `/play/${item.id}` : `/profile/${item.id}`}
className="podium-name"
>
{activeTab === 'games' ? item.title : (item.displayName || item.username)}
</Link>
<div className="podium-stat">
{renderStatValue(item)}
</div>
</div>
<div className={`podium-stand podium-stand-${actualRank}`}>
<span className="podium-rank-num">{actualRank}</span>
</div>
</div>
);
})}
</div>
</div>
);
};
const renderStatValue = (item) => {
switch (activeTab) {
case 'players':
return (
<>
<span className="stat-icon"></span>
<span className="stat-num">{item.xpTotal?.toLocaleString() || 0}</span>
<span className="stat-label">XP</span>
</>
);
case 'creators':
return (
<>
<span className="stat-icon">🎮</span>
<span className="stat-num">{item.gamesCreated || 0}</span>
<span className="stat-label">games</span>
<span className="stat-secondary">
{(item.totalPlays || 0).toLocaleString()} plays
</span>
</>
);
case 'games':
return (
<>
<span className="stat-icon"></span>
<span className="stat-num">{(item.playCount || 0).toLocaleString()}</span>
<span className="stat-label">plays</span>
{item.rating && (
<span className="stat-secondary">
{item.rating.toFixed(1)}
</span>
)}
</>
);
default:
return null;
}
};
const renderListItem = (item, index) => {
const rank = index + 1;
const rankStyle = RANK_STYLES[rank];
const isTopThree = rank <= 3;
return (
<div
key={item.id}
className={`leaderboard-item ${isTopThree ? rankStyle.class : ''}`}
style={{ animationDelay: `${index * 0.05}s` }}
>
<div className={`rank-badge ${isTopThree ? 'top-three' : ''}`}>
{isTopThree ? (
<span className="rank-medal">{rankStyle.icon}</span>
) : (
<span className="rank-number">#{rank}</span>
)}
</div>
<Link
to={activeTab === 'games' ? `/play/${item.id}` : `/profile/${item.id}`}
className="item-avatar-link"
>
{activeTab === 'games' ? (
<div className="item-game-thumb">
{item.thumbnail ? (
<img src={item.thumbnail} alt={item.title} />
) : (
<span className="game-icon">🎮</span>
)}
</div>
) : (
<AvatarMini data={item.avatarData || DEFAULT_AVATAR} size={48} />
)}
</Link>
<div className="item-info">
<Link
to={activeTab === 'games' ? `/play/${item.id}` : `/profile/${item.id}`}
className="item-name"
>
{activeTab === 'games' ? item.title : (item.displayName || item.username)}
</Link>
{activeTab === 'games' && item.creatorName && (
<span className="item-creator">by {item.creatorName}</span>
)}
{activeTab !== 'games' && item.levelName && (
<span className="item-level">{item.levelName}</span>
)}
</div>
<div className="item-stats">
{renderStatValue(item)}
</div>
{isTopThree && <div className="item-shine" />}
</div>
);
};
return (
<div className="leaderboard-page">
<div className="leaderboard-header">
<div className="header-decoration">
<span className="decoration-star star-1"></span>
<span className="decoration-star star-2">🌟</span>
<span className="decoration-star star-3"></span>
</div>
<h1 className="leaderboard-title">
<span className="title-trophy">🏆</span>
Leaderboard
<span className="title-trophy">🏆</span>
</h1>
<p className="leaderboard-subtitle">See who's at the top!</p>
</div>
<div className="tabs-container">
{TABS.map(tab => (
<button
key={tab.id}
className={`tab-btn ${activeTab === tab.id ? 'active' : ''}`}
onClick={() => setActiveTab(tab.id)}
>
<span className="tab-icon">{tab.icon}</span>
<span className="tab-label">{tab.label}</span>
</button>
))}
</div>
{activeTab !== 'players' && (
<div className="period-filters">
{PERIODS.map(p => (
<button
key={p.id}
className={`period-btn ${period === p.id ? 'active' : ''}`}
onClick={() => setPeriod(p.id)}
>
{p.label}
</button>
))}
</div>
)}
{loading ? (
<div className="loading-state">
<div className="loading-spinner" />
<p>Loading rankings...</p>
</div>
) : data.length === 0 ? (
<div className="empty-state">
<span className="empty-icon">📊</span>
<h3>No data yet!</h3>
<p>Be the first to make it to the leaderboard!</p>
</div>
) : (
<>
{renderPodium()}
<div className="leaderboard-list">
<h3 className="list-header">
{activeTab === 'players' && 'All Players'}
{activeTab === 'creators' && 'All Creators'}
{activeTab === 'games' && 'All Games'}
</h3>
{data.map((item, index) => renderListItem(item, index))}
</div>
{hasMore && (
<div className="load-more-section">
<button
className="load-more-btn"
onClick={handleLoadMore}
disabled={loadingMore}
>
{loadingMore ? (
<>
<span className="btn-spinner" />
Loading...
</>
) : (
<>
Load More
<span className="btn-arrow"></span>
</>
)}
</button>
</div>
)}
</>
)}
</div>
);
}
+196
View File
@@ -0,0 +1,196 @@
.legal-page {
min-height: calc(100vh - 60px);
padding: 2rem 1rem;
background: #f8fafc;
}
.legal-content {
max-width: 800px;
margin: 0 auto;
background: white;
border-radius: 1rem;
padding: 2.5rem;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.06);
}
.legal-content h1 {
font-size: 2.25rem;
color: #1e293b;
margin: 0 0 0.5rem;
text-align: center;
}
.legal-subtitle {
text-align: center;
color: #64748b;
font-size: 1.1rem;
margin: 0 0 2rem;
}
/* Kid-Friendly Section */
.kid-friendly {
background: linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%);
border-radius: 1rem;
padding: 2rem;
margin-bottom: 2.5rem;
}
.friendly-header {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1.5rem;
}
.friendly-icon {
font-size: 2rem;
}
.friendly-header h2 {
margin: 0;
font-size: 1.5rem;
color: #0369a1;
}
.rule-card {
display: flex;
gap: 1rem;
background: white;
border-radius: 0.75rem;
padding: 1.25rem;
margin-bottom: 1rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
.rule-card:last-child {
margin-bottom: 0;
}
.rule-card.highlight {
background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
border: 2px solid #f59e0b;
}
.rule-emoji {
font-size: 2rem;
flex-shrink: 0;
}
.rule-content h3 {
margin: 0 0 0.5rem;
font-size: 1.1rem;
color: #1e293b;
}
.rule-content p {
margin: 0;
color: #475569;
line-height: 1.6;
}
/* Legal Section */
.legal-section {
border-top: 2px solid #e5e7eb;
padding-top: 2rem;
margin-top: 2rem;
}
.legal-section h2 {
font-size: 1.5rem;
color: #1e293b;
margin: 0 0 0.5rem;
}
.legal-updated {
color: #64748b;
font-size: 0.9rem;
margin: 0 0 2rem;
}
.legal-section h3 {
font-size: 1.1rem;
color: #1e293b;
margin: 2rem 0 0.75rem;
}
.legal-section h4 {
font-size: 1rem;
color: #475569;
margin: 1.5rem 0 0.5rem;
}
.legal-section p {
color: #475569;
line-height: 1.7;
margin: 0 0 1rem;
}
.legal-section ul {
color: #475569;
line-height: 1.7;
margin: 0 0 1rem;
padding-left: 1.5rem;
}
.legal-section li {
margin-bottom: 0.5rem;
}
.legal-section strong {
color: #1e293b;
}
/* Footer Links */
.legal-footer {
display: flex;
justify-content: center;
gap: 2rem;
margin-top: 2.5rem;
padding-top: 1.5rem;
border-top: 1px solid #e5e7eb;
}
.legal-link {
color: #6366f1;
text-decoration: none;
font-weight: 500;
}
.legal-link:hover {
text-decoration: underline;
}
/* Mobile Responsive */
@media (max-width: 640px) {
.legal-page {
padding: 1rem;
}
.legal-content {
padding: 1.5rem;
border-radius: 0.75rem;
}
.legal-content h1 {
font-size: 1.75rem;
}
.kid-friendly {
padding: 1.25rem;
}
.rule-card {
flex-direction: column;
text-align: center;
}
.rule-emoji {
font-size: 2.5rem;
}
.legal-footer {
flex-direction: column;
gap: 1rem;
text-align: center;
}
}
+78
View File
@@ -0,0 +1,78 @@
import { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import './Auth.css';
export default function Login() {
const navigate = useNavigate();
const { login } = useAuth();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
setLoading(true);
try {
await login(email, password);
navigate('/arcade');
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
return (
<div className="auth-page">
<div className="auth-card">
<h1>Welcome Back!</h1>
<p className="auth-subtitle">Login to continue playing and creating</p>
<form onSubmit={handleSubmit} className="auth-form">
{error && <div className="error-alert">{error}</div>}
<div className="form-group">
<label htmlFor="email">Email</label>
<input
type="email"
id="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoComplete="email"
/>
</div>
<div className="form-group">
<label htmlFor="password">Password</label>
<input
type="password"
id="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoComplete="current-password"
/>
</div>
<div className="forgot-password-link">
<Link to="/forgot-password">Forgot your password?</Link>
</div>
<button type="submit" className="btn btn-primary btn-block" disabled={loading}>
{loading ? 'Logging in...' : 'Login'}
</button>
</form>
<p className="auth-footer">
Don't have an account? <Link to="/register">Sign up</Link>
</p>
</div>
</div>
);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+250
View File
@@ -0,0 +1,250 @@
import { Link } from 'react-router-dom';
import './Legal.css';
export default function Privacy() {
return (
<div className="legal-page">
<div className="legal-content">
<h1>Privacy Policy</h1>
<p className="legal-subtitle">
Your privacy matters to us. Here's how we protect it.
</p>
{/* Kid-Friendly Version */}
<section className="kid-friendly">
<div className="friendly-header">
<span className="friendly-icon">🛡️</span>
<h2>For Players (The Simple Version)</h2>
</div>
<div className="rule-card">
<span className="rule-emoji">🔐</span>
<div className="rule-content">
<h3>We Keep Your Info Private</h3>
<p>
We only collect what we need to make the games work. We don't share
your information with other companies or advertisers.
</p>
</div>
</div>
<div className="rule-card">
<span className="rule-emoji">👤</span>
<div className="rule-content">
<h3>Your Username is Your Identity</h3>
<p>
Other players only see your username - never your real name, email, or
any other personal details. Pick a fun username that doesn't reveal who
you really are!
</p>
</div>
</div>
<div className="rule-card">
<span className="rule-emoji">🎮</span>
<div className="rule-content">
<h3>Guest Mode is Anonymous</h3>
<p>
If you play as a guest, we use a special code from your device to
remember your progress. We don't know who you are - just that the
same device is playing.
</p>
</div>
</div>
<div className="rule-card">
<span className="rule-emoji">👨👩👧</span>
<div className="rule-content">
<h3>Ask a Parent</h3>
<p>
If you're under 13, please ask a parent or guardian to help you create
an account. They need to say it's okay for you to use GamerComp!
</p>
</div>
</div>
</section>
{/* Full Legal Privacy Policy */}
<section className="legal-section">
<h2>Full Privacy Policy</h2>
<p className="legal-updated">Last updated: February 2026</p>
<h3>1. Introduction</h3>
<p>
GamerComp.com ("we," "us," or "our") is committed to protecting the privacy of our
users, especially children. This Privacy Policy explains how we collect, use, and
protect information when you use our Service.
</p>
<p>
<strong>GamerComp is designed to be compliant with the Children's Online Privacy
Protection Act (COPPA).</strong> We take special precautions to protect users
under 13 years of age.
</p>
<h3>2. Information We Collect</h3>
<h4>2.1 For Registered Accounts</h4>
<ul>
<li><strong>Username:</strong> A chosen display name (we encourage fun, non-identifying usernames)</li>
<li><strong>Email address:</strong> Used for account recovery and important notifications</li>
<li><strong>Password:</strong> Stored securely using industry-standard encryption</li>
<li><strong>Game activity:</strong> Games created, games played, scores, achievements</li>
</ul>
<h4>2.2 For Guest Users</h4>
<ul>
<li><strong>Device fingerprint:</strong> A technical identifier generated from browser characteristics to maintain session continuity</li>
<li><strong>Game activity:</strong> Limited tracking of games played during the session</li>
</ul>
<p>
<strong>Device fingerprinting explained:</strong> When you play as a guest, we create a
unique code based on your browser and device settings (like screen size and browser
version). This lets us save your progress without knowing who you are. We cannot
identify you personally from this code.
</p>
<h4>2.3 Automatically Collected Information</h4>
<ul>
<li>IP address (for security and approximate location for content)</li>
<li>Browser type and version</li>
<li>Pages visited and time spent</li>
<li>Referring website</li>
</ul>
<h4>2.4 Information We Do NOT Collect</h4>
<ul>
<li>Real names (usernames should not be real names)</li>
<li>Physical addresses</li>
<li>Phone numbers</li>
<li>Photos or images of users</li>
<li>School names or locations</li>
<li>Social media accounts</li>
</ul>
<h3>3. COPPA Compliance (Children Under 13)</h3>
<p>
We are committed to complying with the Children's Online Privacy Protection Act (COPPA):
</p>
<ul>
<li>We do not knowingly collect personal information from children under 13 without verifiable parental consent</li>
<li>Parents may review their child's information by contacting us</li>
<li>Parents may request deletion of their child's information at any time</li>
<li>Parents may refuse to allow further collection of their child's information</li>
<li>We do not condition a child's participation on providing more information than necessary</li>
</ul>
<p>
<strong>For users under 13:</strong> Please ask your parent or guardian to create an
account for you or to provide consent. If we discover that we have collected personal
information from a child under 13 without parental consent, we will delete that
information immediately.
</p>
<h3>4. How We Use Information</h3>
<p>We use collected information to:</p>
<ul>
<li>Provide and maintain the Service</li>
<li>Save game progress and achievements</li>
<li>Ensure platform safety through content moderation</li>
<li>Improve the Service based on usage patterns</li>
<li>Communicate important updates (account holders only)</li>
<li>Prevent fraud and abuse</li>
</ul>
<p>
<strong>We do NOT use your information for:</strong>
</p>
<ul>
<li>Targeted advertising</li>
<li>Selling to third parties</li>
<li>Creating marketing profiles</li>
<li>Behavioral tracking across other websites</li>
</ul>
<h3>5. Information Sharing</h3>
<p>We do not sell, rent, or share personal information with third parties except:</p>
<ul>
<li><strong>Service providers:</strong> Companies that help us operate (hosting, email delivery) under strict confidentiality agreements</li>
<li><strong>Legal requirements:</strong> When required by law or to protect safety</li>
<li><strong>Business transfers:</strong> In the event of a merger or acquisition, with continued privacy protections</li>
</ul>
<h3>6. Public Information</h3>
<p>The following information may be visible to other users:</p>
<ul>
<li>Username</li>
<li>Games created (titles, descriptions, content)</li>
<li>Game statistics (play count, ratings)</li>
<li>Achievements earned</li>
<li>Public collections</li>
</ul>
<p>
<strong>Real names and email addresses are NEVER displayed publicly.</strong>
</p>
<h3>7. Data Security</h3>
<p>We protect your information using:</p>
<ul>
<li>Encrypted connections (HTTPS)</li>
<li>Secure password hashing (bcrypt)</li>
<li>Regular security audits</li>
<li>Access controls for our team</li>
<li>Secure cloud infrastructure</li>
</ul>
<h3>8. Data Retention</h3>
<ul>
<li>Account data is retained while the account is active</li>
<li>Games remain available under GPL even after account deletion</li>
<li>Guest session data is retained for 30 days of inactivity</li>
<li>Server logs are retained for 90 days</li>
</ul>
<h3>9. Your Rights</h3>
<p>You (or your parent/guardian if under 13) have the right to:</p>
<ul>
<li>Access your personal information</li>
<li>Correct inaccurate information</li>
<li>Delete your account and personal data</li>
<li>Export your data</li>
<li>Opt out of non-essential communications</li>
</ul>
<h3>10. Cookies and Tracking</h3>
<p>We use essential cookies to:</p>
<ul>
<li>Keep you logged in</li>
<li>Remember your preferences</li>
<li>Ensure security</li>
</ul>
<p>
We do not use advertising cookies or third-party tracking cookies.
</p>
<h3>11. Changes to This Policy</h3>
<p>
We may update this Privacy Policy periodically. Significant changes will be
communicated via email (for account holders) or a prominent notice on the site.
Continued use after changes constitutes acceptance.
</p>
<h3>12. Contact Us</h3>
<p>
For privacy-related questions or to exercise your rights, contact us at:
</p>
<p>
<strong>Email:</strong> privacy@gamercomp.com<br />
<strong>Subject line:</strong> Privacy Request
</p>
<p>
Parents wishing to review, modify, or delete their child's information should
email us with "Parental Request" in the subject line.
</p>
</section>
<div className="legal-footer">
<Link to="/terms" className="legal-link">Terms of Service</Link>
<Link to="/" className="legal-link">Back to Home</Link>
</div>
</div>
</div>
);
}
+704
View File
@@ -0,0 +1,704 @@
.profile-page {
min-height: calc(100vh - 60px);
padding: 2rem 1rem;
background: #f8fafc;
max-width: 900px;
margin: 0 auto;
}
.profile-header {
display: flex;
gap: 2rem;
background: white;
padding: 2rem;
border-radius: 1rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
margin-bottom: 1.5rem;
}
.profile-avatar {
flex-shrink: 0;
}
.profile-avatar img,
.avatar-placeholder {
width: 120px;
height: 120px;
border-radius: 50%;
object-fit: cover;
}
.avatar-placeholder {
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: 3rem;
font-weight: 700;
}
.profile-avatar-wrapper {
position: relative;
flex-shrink: 0;
}
.profile-avatar-wrapper .avatar-svg {
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.15);
}
.edit-avatar-btn {
position: absolute;
bottom: 0;
right: 0;
width: 36px;
height: 36px;
border-radius: 50%;
background: white;
border: 2px solid #6366f1;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 1rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
transition: transform 0.2s, box-shadow 0.2s;
}
.edit-avatar-btn:hover {
transform: scale(1.1);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
.profile-info {
flex: 1;
}
.profile-info h1 {
margin: 0 0 0.25rem;
color: #1e293b;
font-size: 1.5rem;
}
.username {
color: #64748b;
margin: 0 0 0.5rem;
font-size: 0.95rem;
}
.bio {
color: #475569;
margin: 0 0 1rem;
font-size: 0.95rem;
line-height: 1.5;
}
.profile-stats-row {
display: flex;
gap: 1.5rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.stat {
text-align: center;
}
.stat-value {
display: block;
font-size: 1.25rem;
font-weight: 700;
color: #1e293b;
}
.stat-label {
font-size: 0.75rem;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.025em;
}
.profile-xp {
margin-bottom: 1.5rem;
display: flex;
align-items: center;
gap: 1.5rem;
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
padding: 1.5rem;
border-radius: 1rem;
box-shadow: 0 4px 15px rgba(99, 102, 241, 0.3);
}
.profile-xp .xp-display {
flex: 1;
background: transparent;
color: white;
}
.profile-xp .level-badge {
background: rgba(255, 255, 255, 0.2);
color: white;
}
.profile-xp .level-name {
color: rgba(255, 255, 255, 0.9);
}
.profile-xp .xp-bar {
background: rgba(255, 255, 255, 0.2);
}
.profile-xp .xp-fill {
background: white;
}
.profile-xp .xp-text {
color: rgba(255, 255, 255, 0.9);
}
.profile-xp .xp-needed {
color: rgba(255, 255, 255, 0.7);
}
.level-icon-display {
flex-shrink: 0;
}
.level-icon-large {
font-size: 3.5rem;
display: block;
filter: drop-shadow(0 4px 8px rgba(0, 0, 0, 0.2));
}
/* Profile Stats Section */
.profile-stats-section {
margin-bottom: 1.5rem;
}
/* Quick Stats Grid */
.quick-stats-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0.75rem;
margin-bottom: 1rem;
}
.quick-stat-card {
background: white;
border-radius: 0.75rem;
padding: 1rem;
text-align: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
.quick-stat-card.highlight {
background: linear-gradient(135deg, #f59e0b 0%, #f97316 100%);
color: white;
}
.quick-stat-card.highlight .quick-stat-value,
.quick-stat-card.highlight .quick-stat-label {
color: white;
}
.quick-stat-value {
font-size: 1.5rem;
font-weight: 700;
color: #1e293b;
margin-bottom: 0.25rem;
}
.quick-stat-label {
font-size: 0.75rem;
color: #64748b;
}
/* Streaks Row */
.streaks-row {
display: flex;
gap: 1rem;
margin-bottom: 1rem;
}
.streak-badge {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
background: white;
border-radius: 0.75rem;
padding: 1rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
border: 2px solid #e2e8f0;
transition: all 0.3s ease;
}
.streak-badge.on-fire {
background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
border-color: #f59e0b;
box-shadow: 0 4px 15px rgba(245, 158, 11, 0.3);
}
.streak-badge.on-fire .streak-icon {
animation: pulse-fire 1s ease-in-out infinite;
}
@keyframes pulse-fire {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.1); }
}
.streak-badge .streak-icon {
font-size: 1.75rem;
}
.streak-badge .streak-value {
font-size: 1.5rem;
font-weight: 700;
color: #1e293b;
}
.streak-badge .streak-label {
font-size: 0.75rem;
color: #64748b;
}
.streak-badge .streak-milestone {
font-size: 0.65rem;
font-weight: 600;
color: #f59e0b;
background: rgba(245, 158, 11, 0.15);
padding: 0.15rem 0.4rem;
border-radius: 0.5rem;
margin-top: 0.25rem;
}
.streak-badge.on-fire .streak-milestone {
background: rgba(0, 0, 0, 0.1);
color: #92400e;
}
/* XP Activity Card */
.xp-activity-card {
background: white;
border-radius: 0.75rem;
padding: 1rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
.xp-activity-card h3 {
margin: 0 0 0.75rem;
font-size: 0.9rem;
color: #1e293b;
}
.xp-activity-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.xp-activity-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.5rem 0;
border-bottom: 1px solid #f1f5f9;
}
.xp-activity-item:last-child {
border-bottom: none;
}
.xp-activity-label {
flex: 1;
font-size: 0.85rem;
color: #475569;
}
.xp-activity-amount {
font-weight: 600;
color: #10b981;
font-size: 0.85rem;
}
.xp-activity-time {
font-size: 0.75rem;
color: #94a3b8;
min-width: 60px;
text-align: right;
}
/* Achievements */
.profile-section {
background: white;
padding: 1.5rem;
border-radius: 1rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
margin-bottom: 1.5rem;
}
.profile-section h2 {
margin: 0;
font-size: 1.25rem;
color: #1e293b;
}
/* Achievement Showcase */
.achievements-showcase .section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.toggle-btn {
background: transparent;
border: 1px solid #e2e8f0;
border-radius: 0.5rem;
padding: 0.4rem 0.75rem;
font-size: 0.8rem;
color: #64748b;
cursor: pointer;
transition: all 0.2s;
}
.toggle-btn:hover {
background: #f1f5f9;
color: #1e293b;
}
.achievements-summary {
display: flex;
gap: 2rem;
margin-bottom: 1rem;
padding-bottom: 1rem;
border-bottom: 1px solid #f1f5f9;
}
.earned-count {
font-weight: 600;
color: #7c3aed;
}
.xp-from-achievements {
color: #10b981;
font-weight: 500;
}
.achievements-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 0.75rem;
}
.achievement-card {
background: linear-gradient(135deg, #faf5ff 0%, #f5f3ff 100%);
border: 1px solid #e9d5ff;
border-radius: 0.75rem;
padding: 0.875rem 0.75rem;
text-align: center;
cursor: help;
transition: transform 0.2s, box-shadow 0.2s;
min-height: 100px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.achievement-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(124, 58, 237, 0.15);
}
.achievement-icon {
font-size: 1.75rem;
display: block;
margin-bottom: 0.375rem;
line-height: 1;
}
.achievement-name {
font-weight: 600;
color: #6d28d9;
font-size: 0.8rem;
display: block;
line-height: 1.2;
word-wrap: break-word;
overflow-wrap: break-word;
max-width: 100%;
}
.achievement-date {
font-size: 0.7rem;
color: #a78bfa;
display: block;
margin-top: 0.25rem;
}
.achievement-card.locked {
background: #f8fafc;
border-color: #e2e8f0;
opacity: 0.6;
}
.achievement-card.locked .achievement-icon {
filter: grayscale(1);
}
.achievement-card.locked .achievement-name {
color: #94a3b8;
}
.achievement-reward {
font-size: 0.7rem;
color: #10b981;
display: block;
margin-top: 0.25rem;
font-weight: 600;
}
/* Tabs */
.profile-tabs {
display: flex;
gap: 0.5rem;
margin-bottom: 1.5rem;
background: white;
padding: 0.5rem;
border-radius: 0.75rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
.tab {
flex: 1;
background: transparent;
border: none;
padding: 0.75rem 1rem;
border-radius: 0.5rem;
font-weight: 500;
color: #64748b;
cursor: pointer;
transition: all 0.2s;
}
.tab:hover {
background: #f1f5f9;
}
.tab.active {
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
color: white;
}
/* Content */
.profile-content {
min-height: 200px;
}
.games-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 1.5rem;
}
.empty-state {
text-align: center;
padding: 3rem;
background: white;
border-radius: 1rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
}
.empty-icon {
font-size: 3rem;
display: block;
margin-bottom: 1rem;
}
.empty-state p {
color: #64748b;
margin: 0 0 1rem;
}
/* Play History */
.play-history {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.history-item {
display: flex;
justify-content: space-between;
align-items: center;
background: white;
padding: 1rem 1.25rem;
border-radius: 0.75rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
text-decoration: none;
transition: transform 0.2s, box-shadow 0.2s;
}
.history-item:hover {
transform: translateX(4px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.history-game strong {
display: block;
color: #1e293b;
margin-bottom: 0.25rem;
}
.history-date {
font-size: 0.8rem;
color: #94a3b8;
}
.history-stats {
text-align: right;
}
.history-score {
display: block;
font-weight: 600;
color: #10b981;
}
.history-duration {
font-size: 0.8rem;
color: #64748b;
}
/* Error */
.profile-error {
text-align: center;
padding: 3rem;
}
.profile-error h2 {
margin: 0 0 1rem;
color: #64748b;
}
/* Responsive */
@media (max-width: 640px) {
.profile-page {
padding: 1rem;
}
.profile-header {
flex-direction: column;
align-items: center;
text-align: center;
padding: 1.5rem;
gap: 1rem;
}
.profile-info h1 {
font-size: 1.35rem;
}
.profile-stats-row {
justify-content: center;
gap: 1rem;
}
.stat-value {
font-size: 1.1rem;
}
.stat-label {
font-size: 0.7rem;
}
.profile-xp {
padding: 1rem;
gap: 1rem;
flex-direction: row;
align-items: center;
}
.level-icon-large {
font-size: 2.5rem;
}
/* Quick Stats - 2 columns on mobile */
.quick-stats-grid {
grid-template-columns: repeat(2, 1fr);
gap: 0.5rem;
}
.quick-stat-card {
padding: 0.75rem;
}
.quick-stat-value {
font-size: 1.25rem;
}
.quick-stat-label {
font-size: 0.7rem;
}
/* Streaks */
.streaks-row {
gap: 0.5rem;
}
.streak-badge {
padding: 0.75rem;
}
.streak-badge .streak-icon {
font-size: 1.5rem;
}
.streak-badge .streak-value {
font-size: 1.25rem;
}
.streak-badge .streak-label {
font-size: 0.7rem;
}
.profile-tabs {
flex-wrap: wrap;
}
.tab {
flex-basis: calc(50% - 0.25rem);
padding: 0.6rem 0.75rem;
font-size: 0.9rem;
}
.games-grid {
grid-template-columns: 1fr;
}
.achievements-grid {
grid-template-columns: repeat(3, 1fr);
gap: 0.5rem;
}
.achievement-card {
padding: 0.625rem 0.5rem;
min-height: 85px;
}
.achievement-icon {
font-size: 1.5rem;
margin-bottom: 0.25rem;
}
.achievement-name {
font-size: 0.7rem;
}
.achievement-date,
.achievement-reward {
font-size: 0.6rem;
}
}
+461
View File
@@ -0,0 +1,461 @@
import { useState, useEffect } from 'react';
import { useParams, Link } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import api from '../api';
import XPDisplay from '../components/XPDisplay';
import GameCard from '../components/GameCard';
import AvatarBuilder, { AvatarDisplay, DEFAULT_AVATAR } from '../components/AvatarBuilder';
import './Profile.css';
const LEVEL_ICONS = {
1: '🌱', 2: '🎮', 3: '🎯', 4: '⭐', 5: '🔥',
6: '💎', 7: '🏆', 8: '👑', 9: '🌟', 10: '🎖️'
};
export default function Profile() {
const { userId } = useParams();
const { user: currentUser } = useAuth();
const [profile, setProfile] = useState(null);
const [stats, setStats] = useState(null);
const [games, setGames] = useState([]);
const [achievements, setAchievements] = useState([]);
const [favorites, setFavorites] = useState([]);
const [playStats, setPlayStats] = useState(null);
const [xpHistory, setXpHistory] = useState([]);
const [isFollowing, setIsFollowing] = useState(false);
const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState('games');
const [showAllAchievements, setShowAllAchievements] = useState(false);
const [showAvatarBuilder, setShowAvatarBuilder] = useState(false);
const [savingAvatar, setSavingAvatar] = useState(false);
const isOwnProfile = !userId || userId === String(currentUser?.id);
const profileId = isOwnProfile ? currentUser?.id : userId;
useEffect(() => {
if (profileId) {
loadProfile();
}
}, [profileId]);
async function loadProfile() {
setLoading(true);
try {
if (isOwnProfile) {
const [meData, statsData, achievementsData, favoritesData, gamesData, historyData, xpHistoryData] = await Promise.all([
api.getMe(),
api.getMyStats(),
api.getMyAchievements(),
api.getMyFavorites(),
api.getMyGames(),
api.getPlayHistory(),
api.getXPHistory(10)
]);
setProfile(meData.user);
setStats(statsData.stats);
setAchievements(achievementsData.achievements || []);
setFavorites(favoritesData.favorites || []);
setGames((gamesData.games || []).filter(g => g.status === 'published'));
setPlayStats(historyData.stats || {});
setXpHistory(xpHistoryData.transactions || []);
} else {
const [profileData, gamesData] = await Promise.all([
api.getUserProfile(profileId),
api.request(`/users/${profileId}/games`)
]);
setProfile(profileData.user);
setStats({
gamesPlayed: profileData.user?.totalPlays || 0,
gamesCreated: profileData.user?.gamesCount || 0
});
setAchievements([]);
setIsFollowing(profileData.isFollowing || false);
setGames(gamesData.games || []);
}
} catch (err) {
console.error('Failed to load profile:', err);
} finally {
setLoading(false);
}
}
async function handleFollow() {
try {
if (isFollowing) {
await api.unfollowUser(profileId);
setIsFollowing(false);
setProfile(prev => ({ ...prev, followerCount: (prev.followerCount || 1) - 1 }));
} else {
await api.followUser(profileId);
setIsFollowing(true);
setProfile(prev => ({ ...prev, followerCount: (prev.followerCount || 0) + 1 }));
}
} catch (err) {
console.error('Failed to follow/unfollow:', err);
}
}
async function handleSaveAvatar(newAvatarData) {
setSavingAvatar(true);
try {
await api.updateAvatar(newAvatarData);
setProfile(prev => ({ ...prev, avatarData: newAvatarData }));
setShowAvatarBuilder(false);
} catch (err) {
console.error('Failed to save avatar:', err);
} finally {
setSavingAvatar(false);
}
}
const formatTime = (seconds) => {
if (!seconds) return '0m';
const hours = Math.floor(seconds / 3600);
const mins = Math.floor((seconds % 3600) / 60);
if (hours > 0) return `${hours}h ${mins}m`;
return `${mins}m`;
};
const formatTimeAgo = (dateStr) => {
const date = new Date(dateStr);
const now = new Date();
const diffMs = now - date;
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return 'just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString();
};
if (loading) {
return (
<div className="profile-page">
<div className="loading">Loading profile...</div>
</div>
);
}
if (!profile) {
return (
<div className="profile-page">
<div className="profile-error">
<h2>Profile not found</h2>
<Link to="/arcade" className="btn btn-primary">Back to Arcade</Link>
</div>
</div>
);
}
return (
<div className="profile-page">
<div className="profile-header">
<div className="profile-avatar-wrapper">
<AvatarDisplay
data={profile.avatarData || DEFAULT_AVATAR}
size={120}
/>
{isOwnProfile && (
<button
className="edit-avatar-btn"
onClick={() => setShowAvatarBuilder(true)}
title="Edit Avatar"
>
</button>
)}
</div>
<div className="profile-info">
<h1>{profile.display_name || profile.username}</h1>
<p className="username">@{profile.username}</p>
{profile.bio && <p className="bio">{profile.bio}</p>}
<div className="profile-stats-row">
<div className="stat">
<span className="stat-value">{stats?.totalPlays || stats?.gamesPlayed || 0}</span>
<span className="stat-label">Total Plays</span>
</div>
<div className="stat">
<span className="stat-value">{stats?.gamesPublished || stats?.gamesCreated || games.length || 0}</span>
<span className="stat-label">Games Created</span>
</div>
<div className="stat">
<span className="stat-value">{stats?.followers || profile.followersCount || profile.followerCount || 0}</span>
<span className="stat-label">Followers</span>
</div>
<div className="stat">
<span className="stat-value">{profile.followingCount || 0}</span>
<span className="stat-label">Following</span>
</div>
</div>
{!isOwnProfile && currentUser && (
<button
className={`btn ${isFollowing ? 'btn-secondary' : 'btn-primary'}`}
onClick={handleFollow}
>
{isFollowing ? 'Unfollow' : 'Follow'}
</button>
)}
</div>
</div>
<div className="profile-xp">
<div className="level-icon-display">
<span className="level-icon-large">{LEVEL_ICONS[profile.xp?.level || profile.level || 1]}</span>
</div>
<XPDisplay
xp={typeof profile.xp === 'object' ? (profile.xp?.xp || 0) : (profile.xp || 0)}
level={profile.xp?.level || profile.level || 1}
levelName={profile.xp?.badge || profile.levelName || profile.badge || 'Newbie'}
/>
</div>
{/* Stats & Streaks - own profile only */}
{isOwnProfile && (
<div className="profile-stats-section">
{/* Quick Stats */}
<div className="quick-stats-grid">
<div className="quick-stat-card">
<div className="quick-stat-value">{stats?.gamesPublished || games.length || 0}</div>
<div className="quick-stat-label">Games Created</div>
</div>
<div className="quick-stat-card">
<div className="quick-stat-value">{playStats?.gamesPlayed || 0}</div>
<div className="quick-stat-label">Games Played</div>
</div>
<div className="quick-stat-card">
<div className="quick-stat-value">{formatTime(playStats?.totalTime)}</div>
<div className="quick-stat-label">Time Played</div>
</div>
<div className="quick-stat-card highlight">
<div className="quick-stat-value"> {typeof profile.xp === 'object' ? (profile.xp?.xp || 0) : (profile.xp || 0)}</div>
<div className="quick-stat-label">Total XP</div>
</div>
</div>
{/* Streaks */}
<div className="streaks-row">
<div className={`streak-badge ${profile.loginStreak >= 7 ? 'on-fire' : ''}`}>
<span className="streak-icon">🔥</span>
<span className="streak-value">{profile.loginStreak || 0}</span>
<span className="streak-label">Day Streak</span>
{profile.loginStreak >= 7 && <span className="streak-milestone">Week Warrior!</span>}
</div>
<div className={`streak-badge ${profile.creationStreak >= 4 ? 'on-fire' : ''}`}>
<span className="streak-icon"></span>
<span className="streak-value">{profile.creationStreak || 0}</span>
<span className="streak-label">Week Creation</span>
{profile.creationStreak >= 4 && <span className="streak-milestone">Creative Machine!</span>}
</div>
</div>
{/* XP Activity */}
{xpHistory.length > 0 && (
<div className="xp-activity-card">
<h3>Recent XP</h3>
<div className="xp-activity-list">
{xpHistory.slice(0, 5).map(t => (
<div key={t.id} className="xp-activity-item">
<span className="xp-activity-label">{t.reasonLabel}</span>
<span className="xp-activity-amount">+{t.amount} XP</span>
<span className="xp-activity-time">{formatTimeAgo(t.createdAt)}</span>
</div>
))}
</div>
</div>
)}
</div>
)}
{isOwnProfile && achievements.length > 0 && (
<div className="profile-section achievements-showcase">
<div className="section-header">
<h2>Achievements</h2>
<button
className="toggle-btn"
onClick={() => setShowAllAchievements(!showAllAchievements)}
>
{showAllAchievements ? 'Show Earned Only' : 'Show All'}
</button>
</div>
{/* Summary stats */}
<div className="achievements-summary">
<span className="earned-count">
{achievements.filter(a => a.earnedAt).length} / {achievements.length} Unlocked
</span>
<span className="xp-from-achievements">
+{achievements.filter(a => a.earnedAt).reduce((sum, a) => sum + (a.xpReward || 0), 0)} XP earned
</span>
</div>
<div className="achievements-grid">
{achievements
.filter(a => showAllAchievements || a.earnedAt)
.map(a => (
<div
key={a.code}
className={`achievement-card ${a.earnedAt ? 'earned' : 'locked'}`}
title={a.description}
>
<span className="achievement-icon">{a.earnedAt ? a.icon : '🔒'}</span>
<span className="achievement-name">{a.name}</span>
{a.earnedAt ? (
<span className="achievement-date">
{new Date(a.earnedAt).toLocaleDateString()}
</span>
) : (
<span className="achievement-reward">+{a.xpReward} XP</span>
)}
</div>
))}
</div>
</div>
)}
<div className="profile-tabs">
<button
className={`tab ${activeTab === 'games' ? 'active' : ''}`}
onClick={() => setActiveTab('games')}
>
Created Games
</button>
{isOwnProfile && (
<>
<button
className={`tab ${activeTab === 'favorites' ? 'active' : ''}`}
onClick={() => setActiveTab('favorites')}
>
Favorites
</button>
<button
className={`tab ${activeTab === 'history' ? 'active' : ''}`}
onClick={() => setActiveTab('history')}
>
Play History
</button>
</>
)}
</div>
<div className="profile-content">
{activeTab === 'games' && (
<div className="games-grid">
{games.length > 0 ? (
games.map(game => (
<GameCard key={game.id} game={{
id: game.id,
title: game.title,
playCount: game.playCount || 0,
averageRating: game.averageRating,
totalRatings: game.totalRatings || 0,
creatorUsername: profile?.username,
creatorDisplayName: profile?.displayName
}} />
))
) : (
<div className="empty-state">
<span className="empty-icon">🎮</span>
<p>No games created yet</p>
{isOwnProfile && (
<Link to="/create" className="btn btn-primary">Create Your First Game</Link>
)}
</div>
)}
</div>
)}
{activeTab === 'favorites' && isOwnProfile && (
<div className="games-grid">
{favorites.length > 0 ? (
favorites.map(game => (
<GameCard key={game.id} game={game} />
))
) : (
<div className="empty-state">
<span className="empty-icon">💖</span>
<p>No favorite games yet</p>
<Link to="/arcade" className="btn btn-primary">Browse Games</Link>
</div>
)}
</div>
)}
{activeTab === 'history' && isOwnProfile && (
<PlayHistory />
)}
</div>
{showAvatarBuilder && (
<div className="avatar-modal">
<AvatarBuilder
initialData={profile.avatarData || DEFAULT_AVATAR}
onSave={handleSaveAvatar}
onCancel={() => setShowAvatarBuilder(false)}
/>
</div>
)}
</div>
);
}
function PlayHistory() {
const [history, setHistory] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
loadHistory();
}, []);
async function loadHistory() {
try {
const data = await api.getPlayHistory();
setHistory(data.history || []);
} catch (err) {
console.error('Failed to load history:', err);
} finally {
setLoading(false);
}
}
if (loading) {
return <div className="loading">Loading history...</div>;
}
if (history.length === 0) {
return (
<div className="empty-state">
<span className="empty-icon">🎯</span>
<p>No games played yet</p>
<Link to="/arcade" className="btn btn-primary">Start Playing</Link>
</div>
);
}
return (
<div className="play-history">
{history.map(play => (
<Link to={`/play/${play.game_id}`} key={play.id} className="history-item">
<div className="history-game">
<strong>{play.title}</strong>
<span className="history-date">
{new Date(play.started_at).toLocaleDateString()}
</span>
</div>
<div className="history-stats">
<span className="history-score">Score: {play.score?.toLocaleString() || 0}</span>
<span className="history-duration">
{Math.floor((play.duration || 0) / 60)}:{String((play.duration || 0) % 60).padStart(2, '0')}
</span>
</div>
</Link>
))}
</div>
);
}
+207
View File
@@ -0,0 +1,207 @@
import { useState, useEffect } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import './Auth.css';
export default function Register() {
const navigate = useNavigate();
const { register } = useAuth();
const [username, setUsername] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [ageConfirm, setAgeConfirm] = useState(false);
const [termsAccepted, setTermsAccepted] = useState(false);
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const [suggestions, setSuggestions] = useState([]);
const [usernameError, setUsernameError] = useState('');
// Load initial username suggestions
useEffect(() => {
fetch('/api/auth/username-suggestions')
.then(res => res.json())
.then(data => setSuggestions(data.suggestions))
.catch(() => {});
}, []);
const handleUsernameChange = (e) => {
const value = e.target.value;
setUsername(value);
setUsernameError('');
};
const selectSuggestion = (name) => {
setUsername(name);
setUsernameError('');
setSuggestions([]);
};
const refreshSuggestions = async () => {
try {
const res = await fetch('/api/auth/username-suggestions');
const data = await res.json();
setSuggestions(data.suggestions);
} catch {
// ignore
}
};
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
setUsernameError('');
if (!ageConfirm) {
setError('Please confirm your age to continue');
return;
}
if (!termsAccepted) {
setError('Please accept the Terms of Service and Privacy Policy to continue');
return;
}
if (password.length < 6) {
setError('Password must be at least 6 characters');
return;
}
setLoading(true);
try {
await register(username, email, password);
navigate('/arcade');
} catch (err) {
// Check if response has suggestions
const errorData = err.response?.data || {};
if (errorData.suggestions) {
setSuggestions(errorData.suggestions);
setUsernameError(errorData.message || errorData.error);
} else {
setError(err.message);
}
} finally {
setLoading(false);
}
};
return (
<div className="auth-page">
<div className="auth-card">
<h1>Create Account</h1>
<p className="auth-subtitle">Join to create games and earn tokens!</p>
<form onSubmit={handleSubmit} className="auth-form">
{error && <div className="error-alert">{error}</div>}
<div className="form-group">
<label htmlFor="username">Username</label>
<input
type="text"
id="username"
value={username}
onChange={handleUsernameChange}
required
minLength={3}
maxLength={30}
pattern="[a-zA-Z0-9_]+"
title="Letters, numbers, and underscores only"
autoComplete="username"
placeholder="Choose a fun gaming name"
/>
{usernameError && (
<div className="field-error">{usernameError}</div>
)}
<div className="username-notice">
<span className="notice-icon">🔒</span>
Real names are not allowed to protect your privacy!
</div>
{suggestions.length > 0 && (
<div className="username-suggestions">
<div className="suggestions-header">
<span>Try one of these:</span>
<button type="button" onClick={refreshSuggestions} className="refresh-btn">
🔄 More
</button>
</div>
<div className="suggestions-list">
{suggestions.map((name, i) => (
<button
key={i}
type="button"
className="suggestion-btn"
onClick={() => selectSuggestion(name)}
>
{name}
</button>
))}
</div>
</div>
)}
</div>
<div className="form-group">
<label htmlFor="email">Email</label>
<input
type="email"
id="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoComplete="email"
/>
<div className="field-hint">Your email is private and will never be shown publicly.</div>
</div>
<div className="form-group">
<label htmlFor="password">Password</label>
<input
type="password"
id="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={6}
autoComplete="new-password"
/>
</div>
<div className="form-group checkbox-group">
<label className="checkbox-label">
<input
type="checkbox"
checked={ageConfirm}
onChange={(e) => setAgeConfirm(e.target.checked)}
/>
<span>I am over 13 or I have my parent's permission</span>
</label>
</div>
<div className="form-group checkbox-group">
<label className="checkbox-label">
<input
type="checkbox"
checked={termsAccepted}
onChange={(e) => setTermsAccepted(e.target.checked)}
/>
<span>
I agree to the <Link to="/terms" target="_blank">Terms of Service</Link> and{' '}
<Link to="/privacy" target="_blank">Privacy Policy</Link>
</span>
</label>
</div>
<button type="submit" className="btn btn-primary btn-block" disabled={loading}>
{loading ? 'Creating account...' : 'Create Account'}
</button>
</form>
<p className="auth-footer">
Already have an account? <Link to="/login">Login</Link>
</p>
</div>
</div>
);
}
+226
View File
@@ -0,0 +1,226 @@
.release-notes {
max-width: 800px;
margin: 0 auto;
padding: 2rem 1.5rem;
}
.release-notes-header {
text-align: center;
margin-bottom: 2.5rem;
}
.release-notes-header h1 {
font-size: 2rem;
color: #1e293b;
margin: 0 0 0.5rem;
}
.release-notes-subtitle {
color: #64748b;
font-size: 1.1rem;
margin: 0;
}
/* Release cards */
.releases-list {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.release-card {
background: white;
border-radius: 1rem;
padding: 1.5rem;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
border: 1px solid #e2e8f0;
}
.release-card.latest {
border-color: #6366f1;
box-shadow: 0 4px 20px rgba(99, 102, 241, 0.15);
}
.release-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
}
.release-version-info {
display: flex;
align-items: center;
gap: 0.75rem;
}
.release-version {
font-size: 0.9rem;
font-weight: 700;
color: #6366f1;
background: rgba(99, 102, 241, 0.1);
padding: 0.25rem 0.75rem;
border-radius: 2rem;
}
.latest-badge {
font-size: 0.7rem;
font-weight: 600;
color: white;
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
padding: 0.2rem 0.5rem;
border-radius: 0.25rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.release-date {
font-size: 0.85rem;
color: #94a3b8;
}
.release-title {
font-size: 1.25rem;
color: #1e293b;
margin: 0 0 1rem;
font-weight: 600;
}
/* Changes list */
.release-changes {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.change-item {
display: flex;
align-items: flex-start;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border-radius: 0.5rem;
background: #f8fafc;
font-size: 0.9rem;
line-height: 1.4;
}
.change-icon {
flex-shrink: 0;
font-size: 1rem;
}
.change-type {
flex-shrink: 0;
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
padding: 0.15rem 0.4rem;
border-radius: 0.25rem;
letter-spacing: 0.3px;
}
.change-text {
color: #475569;
}
/* Change type colors */
.change-feature .change-type {
background: rgba(99, 102, 241, 0.15);
color: #4f46e5;
}
.change-improvement .change-type {
background: rgba(59, 130, 246, 0.15);
color: #2563eb;
}
.change-fix .change-type {
background: rgba(251, 191, 36, 0.2);
color: #b45309;
}
.change-security .change-type {
background: rgba(16, 185, 129, 0.15);
color: #059669;
}
/* Footer */
.release-notes-footer {
margin-top: 3rem;
text-align: center;
padding: 1.5rem;
background: #f8fafc;
border-radius: 1rem;
}
.release-notes-footer p {
margin: 0;
color: #64748b;
}
.release-notes-footer a {
color: #6366f1;
text-decoration: none;
font-weight: 500;
}
.release-notes-footer a:hover {
text-decoration: underline;
}
/* Mobile responsive */
@media (max-width: 768px) {
.release-notes {
padding: 1.5rem 1rem;
}
.release-notes-header h1 {
font-size: 1.5rem;
}
.release-notes-subtitle {
font-size: 1rem;
}
.release-card {
padding: 1.25rem;
}
.release-header {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
.release-title {
font-size: 1.1rem;
}
.change-item {
flex-wrap: wrap;
padding: 0.5rem;
}
.change-type {
order: -1;
margin-left: 1.5rem;
}
.change-text {
width: 100%;
padding-left: 1.5rem;
}
}
/* Touch-friendly */
@media (hover: none), (pointer: coarse) {
.release-notes-footer a {
display: inline-block;
padding: 0.5rem 1rem;
min-height: 44px;
line-height: 1.5;
}
}
+155
View File
@@ -0,0 +1,155 @@
import './ReleaseNotes.css';
const releases = [
{
version: '1.4.0',
date: '2026-02-07',
title: 'Safety Messaging & Mobile Fixes',
changes: [
{ type: 'feature', text: 'Added "Rated E for Everyone" safety messaging to home page' },
{ type: 'feature', text: 'Added safety badges: No Chat, No Strangers, All Games Moderated' },
{ type: 'feature', text: 'Added safety footer to all email communications' },
{ type: 'fix', text: 'Fixed header layout on ultra-narrow screens (Galaxy Fold outer screen)' },
{ type: 'fix', text: 'Fixed CSS media query ordering for proper mobile cascade' },
{ type: 'fix', text: 'Token display now shown in mobile menu on narrow screens' },
{ type: 'security', text: 'Added HTML escaping to prevent XSS in forwarded emails' },
]
},
{
version: '1.3.0',
date: '2026-02-05',
title: 'Streaming Generation & Push Notifications',
changes: [
{ type: 'feature', text: 'Real-time progress streaming for game generation via SSE' },
{ type: 'feature', text: 'Background generation - safe to close browser while generating' },
{ type: 'feature', text: 'Push notifications when your game is ready' },
{ type: 'feature', text: 'In-app notifications for game completion' },
{ type: 'feature', text: 'Email notifications when generation finishes' },
{ type: 'improvement', text: 'Replaced Ollama with Claude Haiku for faster AI responses' },
{ type: 'improvement', text: 'All lightweight AI tasks now use Haiku (tweaks, refinement, summaries)' },
{ type: 'improvement', text: 'DevToolbar shows real-time progress during refinement' },
]
},
{
version: '1.2.0',
date: '2026-02-04',
title: 'Streamlined Creation & Profile Improvements',
changes: [
{ type: 'feature', text: 'Simplified game creation wizard from 5 to 3 steps' },
{ type: 'feature', text: 'Added avatar customization during game creation' },
{ type: 'feature', text: 'New Profile page with stats, streaks, and XP activity' },
{ type: 'feature', text: 'Report Issue now includes AI-powered analysis' },
{ type: 'improvement', text: 'Combined Avatar + Level badge in header (links to profile)' },
{ type: 'improvement', text: 'Merged game toolbar into single unified row' },
{ type: 'improvement', text: 'Dashboard simplified to focus on collections and games' },
{ type: 'fix', text: 'Fixed iOS keyboard issues with game edit textarea' },
{ type: 'fix', text: 'Fixed sticky header on mobile Safari' },
]
},
{
version: '1.1.0',
date: '2026-02-01',
title: 'Classrooms & Education Features',
changes: [
{ type: 'feature', text: 'Teacher Console for managing student groups' },
{ type: 'feature', text: 'Classroom assignments and progress tracking' },
{ type: 'feature', text: 'Student progress reports for educators' },
{ type: 'feature', text: 'Leaderboard with players, creators, and games rankings' },
{ type: 'improvement', text: 'COPPA-compliant design for ages 8-16' },
{ type: 'improvement', text: 'Enhanced content moderation system' },
]
},
{
version: '1.0.0',
date: '2026-01-15',
title: 'Public Launch',
changes: [
{ type: 'feature', text: 'AI-powered game creation wizard' },
{ type: 'feature', text: 'Play games in browser with touch/keyboard support' },
{ type: 'feature', text: 'Game collections for organizing favorites' },
{ type: 'feature', text: 'XP and achievement system' },
{ type: 'feature', text: 'Creator profiles and game statistics' },
{ type: 'feature', text: 'Guest play without account required' },
{ type: 'feature', text: 'PWA support - install as app' },
{ type: 'feature', text: 'High score tracking and leaderboards' },
]
},
{
version: '0.9.0',
date: '2026-01-01',
title: 'Beta Release',
changes: [
{ type: 'feature', text: 'Initial game arcade with AI generation' },
{ type: 'feature', text: 'User accounts and authentication' },
{ type: 'feature', text: 'Game publishing workflow' },
{ type: 'feature', text: 'DevToolbar for game tweaking (P key)' },
{ type: 'feature', text: 'Mobile-responsive design' },
]
}
];
function getTypeIcon(type) {
switch (type) {
case 'feature': return '✨';
case 'improvement': return '🔧';
case 'fix': return '🐛';
case 'security': return '🔒';
default: return '📝';
}
}
function getTypeLabel(type) {
switch (type) {
case 'feature': return 'New';
case 'improvement': return 'Improved';
case 'fix': return 'Fixed';
case 'security': return 'Security';
default: return 'Changed';
}
}
export default function ReleaseNotes() {
return (
<div className="release-notes">
<div className="release-notes-header">
<h1>Release Notes</h1>
<p className="release-notes-subtitle">
What's new in GamerComp - track all updates and improvements
</p>
</div>
<div className="releases-list">
{releases.map((release, index) => (
<article key={release.version} className={`release-card ${index === 0 ? 'latest' : ''}`}>
<header className="release-header">
<div className="release-version-info">
<span className="release-version">v{release.version}</span>
{index === 0 && <span className="latest-badge">Latest</span>}
</div>
<time className="release-date">{release.date}</time>
</header>
<h2 className="release-title">{release.title}</h2>
<ul className="release-changes">
{release.changes.map((change, i) => (
<li key={i} className={`change-item change-${change.type}`}>
<span className="change-icon">{getTypeIcon(change.type)}</span>
<span className="change-type">{getTypeLabel(change.type)}</span>
<span className="change-text">{change.text}</span>
</li>
))}
</ul>
</article>
))}
</div>
<div className="release-notes-footer">
<p>
Have feedback or feature requests?{' '}
<a href="mailto:feedback@gamercomp.com">Let us know!</a>
</p>
</div>
</div>
);
}
+134
View File
@@ -0,0 +1,134 @@
import { useState } from 'react';
import { Link, useSearchParams, useNavigate } from 'react-router-dom';
import api from '../api';
import './Auth.css';
export default function ResetPassword() {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const token = searchParams.get('token');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [success, setSuccess] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
if (password !== confirmPassword) {
setError('Passwords do not match');
return;
}
if (password.length < 6) {
setError('Password must be at least 6 characters');
return;
}
setLoading(true);
try {
await api.resetPassword(token, password);
setSuccess(true);
// Redirect to login after 3 seconds
setTimeout(() => navigate('/login'), 3000);
} catch (err) {
setError(err.message || 'Failed to reset password');
} finally {
setLoading(false);
}
};
if (!token) {
return (
<div className="auth-page">
<div className="auth-container">
<div className="auth-header">
<span className="auth-icon"></span>
<h1>Invalid Link</h1>
</div>
<div className="auth-error-box">
<p>This password reset link is invalid or has expired.</p>
</div>
<div className="auth-links">
<Link to="/forgot-password" className="btn btn-primary">Request New Link</Link>
</div>
</div>
</div>
);
}
if (success) {
return (
<div className="auth-page">
<div className="auth-container">
<div className="auth-header">
<span className="auth-icon"></span>
<h1>Password Reset!</h1>
</div>
<div className="auth-success">
<p>Your password has been reset successfully.</p>
<p className="auth-hint">Redirecting to login...</p>
</div>
<div className="auth-links">
<Link to="/login" className="btn btn-primary">Login Now</Link>
</div>
</div>
</div>
);
}
return (
<div className="auth-page">
<div className="auth-container">
<div className="auth-header">
<span className="auth-icon">🔐</span>
<h1>Reset Password</h1>
<p>Enter your new password</p>
</div>
{error && <div className="auth-error">{error}</div>}
<form onSubmit={handleSubmit} className="auth-form">
<div className="form-group">
<label htmlFor="password">New Password</label>
<input
type="password"
id="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="At least 6 characters"
required
minLength={6}
autoFocus
/>
</div>
<div className="form-group">
<label htmlFor="confirmPassword">Confirm Password</label>
<input
type="password"
id="confirmPassword"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="Re-enter your password"
required
minLength={6}
/>
</div>
<button type="submit" className="btn btn-primary btn-full" disabled={loading}>
{loading ? 'Resetting...' : 'Reset Password'}
</button>
</form>
<div className="auth-links">
<p>Remember your password? <Link to="/login">Login</Link></p>
</div>
</div>
</div>
);
}
+835
View File
@@ -0,0 +1,835 @@
.teacher-console {
max-width: 1200px;
margin: 0 auto;
padding: 2rem 1.5rem;
}
/* Header */
.console-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 2rem;
flex-wrap: wrap;
gap: 1rem;
}
.header-title h1 {
margin: 0;
color: #1e293b;
font-size: 2rem;
}
.header-subtitle {
margin: 0.5rem 0 0;
color: #64748b;
font-size: 1rem;
}
.header-actions {
display: flex;
gap: 0.75rem;
}
/* Empty Console State */
.empty-console {
text-align: center;
padding: 4rem 2rem;
background: white;
border-radius: 1.5rem;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.05);
}
.empty-console .empty-icon {
font-size: 4rem;
display: block;
margin-bottom: 1.5rem;
}
.empty-console h2 {
margin: 0 0 0.5rem;
color: #1e293b;
}
.empty-console p {
margin: 0 0 1.5rem;
color: #64748b;
}
.btn-lg {
padding: 1rem 2rem;
font-size: 1.1rem;
}
/* Classroom Selector */
.classroom-selector {
background: white;
border-radius: 1rem;
padding: 1.25rem;
margin-bottom: 1.5rem;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
}
.classroom-selector label {
display: block;
font-size: 0.9rem;
font-weight: 600;
color: #64748b;
margin-bottom: 0.75rem;
}
.classroom-pills {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
}
.classroom-pill {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.25rem;
background: #f8fafc;
border: 2px solid #e2e8f0;
border-radius: 2rem;
cursor: pointer;
transition: all 0.2s;
}
.classroom-pill:hover {
border-color: #6366f1;
background: #f0f0ff;
}
.classroom-pill.active {
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
border-color: transparent;
color: white;
}
.pill-icon {
font-size: 1.25rem;
}
.pill-name {
font-weight: 600;
}
.pill-count {
font-size: 0.8rem;
opacity: 0.8;
}
/* Quick Actions */
.quick-actions {
margin-bottom: 1.5rem;
}
.quick-actions h3 {
margin: 0 0 1rem;
font-size: 1rem;
color: #64748b;
}
.actions-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 1rem;
}
.action-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
padding: 1.5rem 1rem;
background: white;
border: 2px solid #e2e8f0;
border-radius: 1rem;
cursor: pointer;
transition: all 0.2s;
text-decoration: none;
color: inherit;
}
.action-card:hover {
border-color: #6366f1;
transform: translateY(-2px);
box-shadow: 0 4px 15px rgba(99, 102, 241, 0.15);
}
.action-icon {
font-size: 2rem;
}
.action-label {
font-weight: 500;
color: #1e293b;
text-align: center;
}
/* Class Overview */
.class-overview {
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
border-radius: 1.5rem;
padding: 1.5rem;
margin-bottom: 1.5rem;
color: white;
box-shadow: 0 4px 20px rgba(99, 102, 241, 0.3);
}
.class-overview h3 {
margin: 0 0 1.25rem;
font-size: 1.1rem;
font-weight: 500;
opacity: 0.9;
}
.overview-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 1rem;
}
.overview-stats .stat-card {
display: flex;
align-items: center;
gap: 0.75rem;
background: rgba(255, 255, 255, 0.15);
border-radius: 1rem;
padding: 1rem;
backdrop-filter: blur(10px);
}
.overview-stats .stat-icon {
font-size: 2rem;
}
.overview-stats .stat-info {
flex: 1;
}
.overview-stats .stat-value {
font-size: 1.5rem;
font-weight: 700;
line-height: 1.2;
}
.overview-stats .stat-label {
font-size: 0.8rem;
opacity: 0.85;
}
/* View Tabs */
.view-tabs {
display: flex;
gap: 0.5rem;
margin-bottom: 1.5rem;
background: white;
padding: 0.5rem;
border-radius: 1rem;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
}
.tab-btn {
flex: 1;
padding: 0.75rem 1rem;
border: none;
background: transparent;
border-radius: 0.75rem;
font-size: 0.95rem;
font-weight: 500;
color: #64748b;
cursor: pointer;
transition: all 0.2s;
}
.tab-btn:hover {
background: #f1f5f9;
}
.tab-btn.active {
background: #6366f1;
color: white;
}
/* Console Content */
.console-content {
background: white;
border-radius: 1rem;
padding: 1.5rem;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
min-height: 300px;
}
/* Progress Table */
.progress-table-container {
overflow-x: auto;
}
.progress-table {
width: 100%;
border-collapse: collapse;
}
.progress-table th,
.progress-table td {
padding: 1rem 0.75rem;
text-align: left;
border-bottom: 1px solid #f1f5f9;
}
.progress-table th {
font-size: 0.85rem;
font-weight: 600;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.progress-table tbody tr:hover {
background: #f8fafc;
}
.student-cell {
display: flex;
align-items: center;
gap: 0.5rem;
}
.student-name {
font-weight: 500;
color: #1e293b;
}
.student-badge {
font-size: 0.7rem;
background: #e0e7ff;
color: #4f46e5;
padding: 0.125rem 0.5rem;
border-radius: 1rem;
}
.level-badge {
display: inline-block;
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
color: white;
padding: 0.25rem 0.6rem;
border-radius: 1rem;
font-size: 0.8rem;
font-weight: 600;
}
.xp-value {
font-weight: 600;
color: #10b981;
}
.stat-value {
color: #64748b;
}
.achievement-count {
display: inline-flex;
align-items: center;
gap: 0.25rem;
color: #f59e0b;
font-weight: 500;
}
.achievement-count::before {
content: '🏆';
font-size: 0.9rem;
}
/* Mini Progress Bar */
.mini-progress {
display: flex;
align-items: center;
gap: 0.5rem;
min-width: 100px;
}
.mini-progress-bg {
flex: 1;
height: 8px;
background: #e2e8f0;
border-radius: 4px;
overflow: hidden;
}
.mini-progress-fill {
height: 100%;
background: linear-gradient(90deg, #6366f1, #8b5cf6);
border-radius: 4px;
transition: width 0.3s ease;
}
.mini-progress-text {
font-size: 0.75rem;
color: #64748b;
min-width: 35px;
}
/* Leaderboard Container */
.leaderboard-container {
padding: 1rem 0;
}
/* Podium */
.podium {
display: flex;
justify-content: center;
align-items: flex-end;
gap: 1rem;
margin-bottom: 2rem;
padding: 1rem;
}
.podium-spot {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
position: relative;
}
.podium-crown {
font-size: 1.5rem;
margin-bottom: 0.25rem;
animation: bounce 2s ease-in-out infinite;
}
@keyframes bounce {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-5px); }
}
.podium-avatar {
font-size: 2.5rem;
margin-bottom: 0.5rem;
}
.podium-name {
font-weight: 600;
color: #1e293b;
font-size: 0.95rem;
max-width: 100px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.podium-xp {
font-size: 0.8rem;
color: #10b981;
font-weight: 600;
margin-bottom: 0.5rem;
}
.podium-bar {
width: 80px;
border-radius: 8px 8px 0 0;
}
.podium-spot.first .podium-bar {
height: 100px;
background: linear-gradient(180deg, #fbbf24 0%, #f59e0b 100%);
}
.podium-spot.second .podium-bar {
height: 70px;
background: linear-gradient(180deg, #94a3b8 0%, #64748b 100%);
}
.podium-spot.third .podium-bar {
height: 50px;
background: linear-gradient(180deg, #d97706 0%, #b45309 100%);
}
/* Leaderboard List */
.leaderboard-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.leaderboard-row {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem;
background: #f8fafc;
border-radius: 0.75rem;
transition: all 0.2s;
}
.leaderboard-row:hover {
background: #f1f5f9;
}
.leaderboard-row.top-three {
background: linear-gradient(90deg, #fef3c7 0%, #fff 100%);
}
.leaderboard-row .rank {
font-weight: 700;
color: #6366f1;
width: 3rem;
font-size: 1.1rem;
}
.leaderboard-row .name {
flex: 1;
font-weight: 500;
color: #1e293b;
}
.leaderboard-row .badge {
font-size: 0.75rem;
background: #e0e7ff;
color: #4f46e5;
padding: 0.125rem 0.5rem;
border-radius: 1rem;
}
.leaderboard-row .level {
font-size: 0.85rem;
color: #64748b;
}
.leaderboard-row .xp {
font-weight: 600;
color: #10b981;
min-width: 80px;
}
.leaderboard-row .games {
font-size: 0.85rem;
color: #64748b;
min-width: 70px;
}
/* Assignment Status */
.assignments-status {
display: flex;
flex-direction: column;
gap: 1rem;
}
.assignment-status-card {
background: #f8fafc;
border-radius: 1rem;
padding: 1.25rem;
border-left: 4px solid #6366f1;
}
.assignment-status-card.overdue {
border-left-color: #dc2626;
background: #fef2f2;
}
.assignment-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
margin-bottom: 0.75rem;
flex-wrap: wrap;
}
.assignment-header h4 {
margin: 0;
color: #1e293b;
font-size: 1.1rem;
}
.due-badge {
font-size: 0.8rem;
padding: 0.25rem 0.75rem;
background: #dbeafe;
color: #1e40af;
border-radius: 1rem;
}
.due-badge.overdue {
background: #fee2e2;
color: #dc2626;
}
.assignment-description {
margin: 0 0 1rem;
color: #64748b;
font-size: 0.9rem;
}
.assignment-progress {
background: white;
border-radius: 0.75rem;
padding: 1rem;
}
.progress-info {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
}
.progress-label {
font-weight: 500;
color: #1e293b;
}
.progress-count {
font-size: 0.85rem;
color: #64748b;
}
.progress-bar-container {
margin-bottom: 0.5rem;
}
.progress-bar-bg {
height: 12px;
background: #e2e8f0;
border-radius: 6px;
overflow: hidden;
}
.progress-bar-fill {
height: 100%;
background: linear-gradient(90deg, #10b981, #059669);
border-radius: 6px;
transition: width 0.5s ease;
}
.progress-note {
margin: 0;
font-size: 0.85rem;
color: #64748b;
}
/* Empty State */
.empty-state {
text-align: center;
padding: 3rem 1rem;
color: #64748b;
}
.empty-state .empty-icon {
font-size: 3rem;
display: block;
margin-bottom: 1rem;
}
.empty-subtitle {
font-size: 0.9rem;
color: #94a3b8;
margin-top: 0.5rem;
}
/* Modals */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1rem;
}
.modal-content {
background: white;
border-radius: 1rem;
padding: 1.5rem;
width: 100%;
max-width: 500px;
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.2);
}
.modal-content h2 {
margin: 0 0 1.5rem;
color: #1e293b;
}
.form-group {
margin-bottom: 1.25rem;
}
.form-group label {
display: block;
font-size: 0.9rem;
font-weight: 500;
color: #475569;
margin-bottom: 0.5rem;
}
.form-group input,
.form-group textarea,
.form-group select {
width: 100%;
padding: 0.75rem 1rem;
border: 1px solid #e2e8f0;
border-radius: 0.5rem;
font-size: 1rem;
font-family: inherit;
transition: border-color 0.2s;
}
.form-group input:focus,
.form-group textarea:focus,
.form-group select:focus {
outline: none;
border-color: #6366f1;
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
}
.modal-actions {
display: flex;
gap: 0.75rem;
justify-content: flex-end;
margin-top: 1.5rem;
padding-top: 1rem;
border-top: 1px solid #f1f5f9;
}
.message-info {
text-align: center;
padding: 1rem;
}
.message-info p {
margin: 0.5rem 0;
color: #64748b;
}
.message-info .coming-soon {
font-size: 1.25rem;
font-weight: 600;
color: #6366f1;
margin: 1rem 0;
}
/* Loading and Error States */
.loading,
.error-state {
text-align: center;
padding: 3rem 1rem;
color: #64748b;
}
.error-state h2 {
color: #dc2626;
margin-bottom: 0.5rem;
}
/* Responsive */
@media (max-width: 768px) {
.teacher-console {
padding: 1rem;
}
.console-header {
flex-direction: column;
align-items: stretch;
}
.header-title {
text-align: center;
}
.header-actions {
justify-content: center;
}
.classroom-pills {
flex-direction: column;
}
.classroom-pill {
justify-content: center;
}
.actions-grid {
grid-template-columns: repeat(2, 1fr);
}
.overview-stats {
grid-template-columns: repeat(2, 1fr);
}
.view-tabs {
flex-wrap: wrap;
}
.tab-btn {
flex: 1 1 45%;
}
.progress-table {
font-size: 0.85rem;
}
.progress-table th,
.progress-table td {
padding: 0.75rem 0.5rem;
}
.podium {
flex-direction: column;
align-items: center;
gap: 1.5rem;
}
.podium-spot {
width: 100%;
flex-direction: row;
justify-content: center;
gap: 1rem;
}
.podium-bar {
display: none;
}
.leaderboard-row {
flex-wrap: wrap;
}
.leaderboard-row .name {
flex-basis: 100%;
order: -1;
}
.modal-content {
margin: 0.5rem;
padding: 1rem;
}
}
@media (max-width: 480px) {
.actions-grid {
grid-template-columns: 1fr;
}
.overview-stats {
grid-template-columns: 1fr;
}
.overview-stats .stat-card {
justify-content: center;
}
.assignment-header {
flex-direction: column;
align-items: flex-start;
}
}
+691
View File
@@ -0,0 +1,691 @@
import { useState, useEffect } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import api from '../api';
import './TeacherConsole.css';
export default function TeacherConsole() {
const { user, isAuthenticated } = useAuth();
const navigate = useNavigate();
const [classrooms, setClassrooms] = useState([]);
const [selectedClassroom, setSelectedClassroom] = useState(null);
const [studentProgress, setStudentProgress] = useState([]);
const [loading, setLoading] = useState(true);
const [progressLoading, setProgressLoading] = useState(false);
const [activeView, setActiveView] = useState('overview');
const [error, setError] = useState('');
// Quick action states
const [showAssignmentModal, setShowAssignmentModal] = useState(false);
const [showMessageModal, setShowMessageModal] = useState(false);
const [newAssignment, setNewAssignment] = useState({ title: '', description: '', dueDate: '', classroomId: '' });
const [creatingAssignment, setCreatingAssignment] = useState(false);
const [broadcastMessage, setBroadcastMessage] = useState({ title: '', message: '' });
const [sendingBroadcast, setSendingBroadcast] = useState(false);
useEffect(() => {
if (!isAuthenticated) {
navigate('/login');
return;
}
if (user?.role !== 'teacher' && user?.role !== 'admin' && !user?.isTeacher) {
navigate('/dashboard');
return;
}
loadTeacherData();
}, [isAuthenticated, user]);
async function loadTeacherData() {
try {
const data = await api.getMyClassrooms();
setClassrooms(data.asTeacher || []);
// Auto-select first classroom if exists
if (data.asTeacher?.length > 0) {
setSelectedClassroom(data.asTeacher[0]);
loadStudentProgress(data.asTeacher[0].id);
}
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}
async function loadStudentProgress(classroomId) {
setProgressLoading(true);
try {
const data = await api.getClassroomStudentProgress(classroomId);
setStudentProgress(data.students || []);
} catch (err) {
console.error('Failed to load student progress:', err);
// Fallback to basic student data
try {
const studentsData = await api.getClassroomStudents(classroomId);
setStudentProgress(studentsData.students || []);
} catch (e) {
console.error('Fallback failed:', e);
}
} finally {
setProgressLoading(false);
}
}
function handleClassroomChange(classroom) {
setSelectedClassroom(classroom);
loadStudentProgress(classroom.id);
}
async function handleCreateAssignment(e) {
e.preventDefault();
if (!newAssignment.title.trim() || !newAssignment.classroomId) return;
setCreatingAssignment(true);
try {
await api.createAssignment(newAssignment.classroomId, {
title: newAssignment.title,
description: newAssignment.description,
dueDate: newAssignment.dueDate || null
});
setShowAssignmentModal(false);
setNewAssignment({ title: '', description: '', dueDate: '', classroomId: '' });
// Refresh data
loadTeacherData();
} catch (err) {
alert('Failed to create assignment: ' + err.message);
} finally {
setCreatingAssignment(false);
}
}
function generateReport() {
if (!selectedClassroom || studentProgress.length === 0) {
alert('No data available to generate report');
return;
}
// Generate CSV report
const headers = ['Student', 'Level', 'XP Total', 'Games Created', 'Games Played', 'Achievements'];
const rows = studentProgress.map(s => [
s.displayName || s.username,
s.level || 1,
s.xpTotal || 0,
s.gamesCreated || 0,
s.gamesPlayed || 0,
s.achievementCount || 0
]);
const csvContent = [
headers.join(','),
...rows.map(row => row.join(','))
].join('\n');
const blob = new Blob([csvContent], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${selectedClassroom.name}-progress-report.csv`;
a.click();
URL.revokeObjectURL(url);
}
// Calculate class stats
const classStats = {
totalStudents: studentProgress.length,
totalGamesCreated: studentProgress.reduce((sum, s) => sum + (s.gamesCreated || 0), 0),
totalXPEarned: studentProgress.reduce((sum, s) => sum + (s.xpTotal || 0), 0),
averageLevel: studentProgress.length > 0
? (studentProgress.reduce((sum, s) => sum + (s.level || 1), 0) / studentProgress.length).toFixed(1)
: 0,
totalAchievements: studentProgress.reduce((sum, s) => sum + (s.achievementCount || 0), 0)
};
if (loading) {
return (
<div className="teacher-console">
<div className="loading">Loading Teacher Console...</div>
</div>
);
}
if (error) {
return (
<div className="teacher-console">
<div className="error-state">
<h2>Error Loading Data</h2>
<p>{error}</p>
<button className="btn btn-primary" onClick={loadTeacherData}>Retry</button>
</div>
</div>
);
}
return (
<div className="teacher-console">
{/* Header */}
<div className="console-header">
<div className="header-title">
<h1>Teacher Console</h1>
<p className="header-subtitle">Manage classrooms and track student progress</p>
</div>
<div className="header-actions">
<Link to="/classrooms" className="btn btn-secondary">
View All Classrooms
</Link>
</div>
</div>
{classrooms.length === 0 ? (
<div className="empty-console">
<div className="empty-icon">🏫</div>
<h2>No Classrooms Yet</h2>
<p>Create your first classroom to start tracking student progress</p>
<Link to="/classrooms" className="btn btn-primary btn-lg">
Create Classroom
</Link>
</div>
) : (
<>
{/* Classroom Selector */}
<div className="classroom-selector">
<label>Select Classroom:</label>
<div className="classroom-pills">
{classrooms.map(c => (
<button
key={c.id}
className={`classroom-pill ${selectedClassroom?.id === c.id ? 'active' : ''}`}
onClick={() => handleClassroomChange(c)}
>
<span className="pill-icon">🏫</span>
<span className="pill-name">{c.name}</span>
<span className="pill-count">{c.studentCount} students</span>
</button>
))}
</div>
</div>
{/* Quick Actions */}
<div className="quick-actions">
<h3>Quick Actions</h3>
<div className="actions-grid">
<button
className="action-card"
onClick={() => {
setNewAssignment(prev => ({ ...prev, classroomId: selectedClassroom?.id || '' }));
setShowAssignmentModal(true);
}}
>
<span className="action-icon">📝</span>
<span className="action-label">Create Assignment</span>
</button>
<button
className="action-card"
onClick={() => setShowMessageModal(true)}
>
<span className="action-icon">💬</span>
<span className="action-label">Message Students</span>
</button>
<button
className="action-card"
onClick={generateReport}
>
<span className="action-icon">📊</span>
<span className="action-label">Generate Report</span>
</button>
<Link
to={selectedClassroom ? `/classrooms/${selectedClassroom.id}` : '/classrooms'}
className="action-card"
>
<span className="action-icon"></span>
<span className="action-label">Manage Classroom</span>
</Link>
</div>
</div>
{/* Class Overview Stats */}
<div className="class-overview">
<h3>Class Overview: {selectedClassroom?.name}</h3>
<div className="overview-stats">
<div className="stat-card">
<div className="stat-icon">👥</div>
<div className="stat-info">
<div className="stat-value">{classStats.totalStudents}</div>
<div className="stat-label">Students</div>
</div>
</div>
<div className="stat-card">
<div className="stat-icon">🎮</div>
<div className="stat-info">
<div className="stat-value">{classStats.totalGamesCreated}</div>
<div className="stat-label">Games Created</div>
</div>
</div>
<div className="stat-card">
<div className="stat-icon"></div>
<div className="stat-info">
<div className="stat-value">{classStats.totalXPEarned.toLocaleString()}</div>
<div className="stat-label">Total XP</div>
</div>
</div>
<div className="stat-card">
<div className="stat-icon">📈</div>
<div className="stat-info">
<div className="stat-value">{classStats.averageLevel}</div>
<div className="stat-label">Avg Level</div>
</div>
</div>
<div className="stat-card">
<div className="stat-icon">🏆</div>
<div className="stat-info">
<div className="stat-value">{classStats.totalAchievements}</div>
<div className="stat-label">Achievements</div>
</div>
</div>
</div>
</div>
{/* View Tabs */}
<div className="view-tabs">
<button
className={`tab-btn ${activeView === 'overview' ? 'active' : ''}`}
onClick={() => setActiveView('overview')}
>
Student Progress
</button>
<button
className={`tab-btn ${activeView === 'leaderboard' ? 'active' : ''}`}
onClick={() => setActiveView('leaderboard')}
>
Class Leaderboard
</button>
<button
className={`tab-btn ${activeView === 'assignments' ? 'active' : ''}`}
onClick={() => setActiveView('assignments')}
>
Assignment Status
</button>
</div>
{/* Content Views */}
<div className="console-content">
{progressLoading ? (
<div className="loading">Loading student data...</div>
) : (
<>
{activeView === 'overview' && (
<StudentProgressView
students={studentProgress}
classroomId={selectedClassroom?.id}
/>
)}
{activeView === 'leaderboard' && (
<LeaderboardView
students={studentProgress}
classroomId={selectedClassroom?.id}
/>
)}
{activeView === 'assignments' && (
<AssignmentStatusView
classroomId={selectedClassroom?.id}
students={studentProgress}
/>
)}
</>
)}
</div>
</>
)}
{/* Assignment Modal */}
{showAssignmentModal && (
<div className="modal-overlay" onClick={() => setShowAssignmentModal(false)}>
<div className="modal-content" onClick={e => e.stopPropagation()}>
<h2>Create Assignment</h2>
<form onSubmit={handleCreateAssignment}>
<div className="form-group">
<label>Classroom</label>
<select
value={newAssignment.classroomId}
onChange={e => setNewAssignment(prev => ({ ...prev, classroomId: e.target.value }))}
required
>
<option value="">Select a classroom</option>
{classrooms.map(c => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
</div>
<div className="form-group">
<label>Assignment Title</label>
<input
type="text"
value={newAssignment.title}
onChange={e => setNewAssignment(prev => ({ ...prev, title: e.target.value }))}
placeholder="e.g., Create a Puzzle Game"
required
/>
</div>
<div className="form-group">
<label>Description (optional)</label>
<textarea
value={newAssignment.description}
onChange={e => setNewAssignment(prev => ({ ...prev, description: e.target.value }))}
placeholder="Describe what students should create..."
rows={3}
/>
</div>
<div className="form-group">
<label>Due Date (optional)</label>
<input
type="datetime-local"
value={newAssignment.dueDate}
onChange={e => setNewAssignment(prev => ({ ...prev, dueDate: e.target.value }))}
/>
</div>
<div className="modal-actions">
<button type="button" className="btn btn-secondary" onClick={() => setShowAssignmentModal(false)}>
Cancel
</button>
<button type="submit" className="btn btn-primary" disabled={creatingAssignment}>
{creatingAssignment ? 'Creating...' : 'Create Assignment'}
</button>
</div>
</form>
</div>
</div>
)}
{/* Message Modal */}
{showMessageModal && (
<div className="modal-overlay" onClick={() => setShowMessageModal(false)}>
<div className="modal-content" onClick={e => e.stopPropagation()}>
<h2>Message Students</h2>
<p className="modal-subtitle">Send a notification to all {selectedClassroom?.studentCount || 0} students in {selectedClassroom?.name}</p>
<form onSubmit={async (e) => {
e.preventDefault();
if (!broadcastMessage.message.trim() || !selectedClassroom) return;
setSendingBroadcast(true);
try {
await api.broadcastToClassroom(selectedClassroom.id, broadcastMessage.message, broadcastMessage.title);
alert(`Message sent to ${selectedClassroom.studentCount} students!`);
setBroadcastMessage({ title: '', message: '' });
setShowMessageModal(false);
} catch (err) {
alert('Failed to send message: ' + err.message);
} finally {
setSendingBroadcast(false);
}
}}>
<div className="form-group">
<label>Subject (optional)</label>
<input
type="text"
value={broadcastMessage.title}
onChange={(e) => setBroadcastMessage(prev => ({ ...prev, title: e.target.value }))}
placeholder="Announcement subject..."
maxLength={100}
/>
</div>
<div className="form-group">
<label>Message *</label>
<textarea
value={broadcastMessage.message}
onChange={(e) => setBroadcastMessage(prev => ({ ...prev, message: e.target.value }))}
placeholder="Write your message to all students..."
rows={4}
maxLength={500}
required
/>
<span className="char-count">{broadcastMessage.message.length}/500</span>
</div>
<div className="modal-actions">
<button type="button" className="btn btn-secondary" onClick={() => setShowMessageModal(false)}>
Cancel
</button>
<button type="submit" className="btn btn-primary" disabled={sendingBroadcast || !broadcastMessage.message.trim()}>
{sendingBroadcast ? 'Sending...' : '📤 Send to All Students'}
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
}
// Student Progress View Component
function StudentProgressView({ students, classroomId }) {
if (students.length === 0) {
return (
<div className="empty-state">
<span className="empty-icon">👥</span>
<p>No students enrolled yet</p>
<p className="empty-subtitle">Share the classroom join code with your students to get started!</p>
</div>
);
}
return (
<div className="progress-table-container">
<table className="progress-table">
<thead>
<tr>
<th>Student</th>
<th>Level</th>
<th>XP</th>
<th>Games Created</th>
<th>Games Played</th>
<th>Achievements</th>
<th>Progress</th>
</tr>
</thead>
<tbody>
{students.map(student => (
<tr key={student.id}>
<td>
<div className="student-cell">
<span className="student-name">{student.displayName || student.username}</span>
<span className="student-badge">{student.badge || 'Newbie'}</span>
</div>
</td>
<td>
<span className="level-badge">Lv.{student.level || 1}</span>
</td>
<td>
<span className="xp-value">{(student.xpTotal || 0).toLocaleString()}</span>
</td>
<td>
<span className="stat-value">{student.gamesCreated || 0}</span>
</td>
<td>
<span className="stat-value">{student.gamesPlayed || 0}</span>
</td>
<td>
<span className="achievement-count">{student.achievementCount || 0}</span>
</td>
<td>
<ProgressBar value={student.xpTotal || 0} max={getXPForLevel((student.level || 1) + 1)} />
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
// Leaderboard View Component
function LeaderboardView({ students, classroomId }) {
const sortedStudents = [...students].sort((a, b) => (b.xpTotal || 0) - (a.xpTotal || 0));
if (students.length === 0) {
return (
<div className="empty-state">
<span className="empty-icon">🏆</span>
<p>No students to rank yet</p>
</div>
);
}
return (
<div className="leaderboard-container">
{/* Top 3 Podium */}
{sortedStudents.length >= 1 && (
<div className="podium">
{sortedStudents.length >= 2 && (
<div className="podium-spot second">
<div className="podium-avatar">🥈</div>
<div className="podium-name">{sortedStudents[1].displayName || sortedStudents[1].username}</div>
<div className="podium-xp">{(sortedStudents[1].xpTotal || 0).toLocaleString()} XP</div>
<div className="podium-bar"></div>
</div>
)}
<div className="podium-spot first">
<div className="podium-crown">👑</div>
<div className="podium-avatar">🥇</div>
<div className="podium-name">{sortedStudents[0].displayName || sortedStudents[0].username}</div>
<div className="podium-xp">{(sortedStudents[0].xpTotal || 0).toLocaleString()} XP</div>
<div className="podium-bar"></div>
</div>
{sortedStudents.length >= 3 && (
<div className="podium-spot third">
<div className="podium-avatar">🥉</div>
<div className="podium-name">{sortedStudents[2].displayName || sortedStudents[2].username}</div>
<div className="podium-xp">{(sortedStudents[2].xpTotal || 0).toLocaleString()} XP</div>
<div className="podium-bar"></div>
</div>
)}
</div>
)}
{/* Full Leaderboard List */}
<div className="leaderboard-list">
{sortedStudents.map((student, index) => (
<div key={student.id} className={`leaderboard-row ${index < 3 ? 'top-three' : ''}`}>
<span className="rank">#{index + 1}</span>
<span className="name">{student.displayName || student.username}</span>
<span className="badge">{student.badge || 'Newbie'}</span>
<span className="level">Lv.{student.level || 1}</span>
<span className="xp">{(student.xpTotal || 0).toLocaleString()} XP</span>
<span className="games">{student.gamesCreated || 0} games</span>
</div>
))}
</div>
</div>
);
}
// Assignment Status View Component
function AssignmentStatusView({ classroomId, students }) {
const [assignments, setAssignments] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (classroomId) {
loadAssignments();
}
}, [classroomId]);
async function loadAssignments() {
try {
const data = await api.getClassroomAssignments(classroomId);
setAssignments(data.assignments || []);
} catch (err) {
console.error('Failed to load assignments:', err);
} finally {
setLoading(false);
}
}
if (loading) {
return <div className="loading">Loading assignments...</div>;
}
if (assignments.length === 0) {
return (
<div className="empty-state">
<span className="empty-icon">📝</span>
<p>No assignments created yet</p>
<p className="empty-subtitle">Create an assignment to track student progress!</p>
</div>
);
}
return (
<div className="assignments-status">
{assignments.map(assignment => (
<div key={assignment.id} className={`assignment-status-card ${assignment.isOverdue ? 'overdue' : ''}`}>
<div className="assignment-header">
<h4>{assignment.title}</h4>
{assignment.dueDate && (
<span className={`due-badge ${assignment.isOverdue ? 'overdue' : ''}`}>
{assignment.isOverdue ? 'Overdue' : 'Due'}: {new Date(assignment.dueDate).toLocaleDateString()}
</span>
)}
</div>
{assignment.description && (
<p className="assignment-description">{assignment.description}</p>
)}
<div className="assignment-progress">
<div className="progress-info">
<span className="progress-label">Completion Status</span>
<span className="progress-count">
Tracking {students.length} students
</span>
</div>
<div className="progress-bar-container">
<div className="progress-bar-bg">
<div
className="progress-bar-fill"
style={{ width: `${students.length > 0 ? Math.min(100, (students.filter(s => (s.gamesCreated || 0) > 0).length / students.length) * 100) : 0}%` }}
></div>
</div>
</div>
<p className="progress-note">
{students.filter(s => (s.gamesCreated || 0) > 0).length} of {students.length} students have created games
</p>
</div>
</div>
))}
</div>
);
}
// Progress Bar Component
function ProgressBar({ value, max }) {
const percentage = max > 0 ? Math.min(100, (value / max) * 100) : 0;
return (
<div className="mini-progress">
<div className="mini-progress-bg">
<div
className="mini-progress-fill"
style={{ width: `${percentage}%` }}
></div>
</div>
<span className="mini-progress-text">{Math.round(percentage)}%</span>
</div>
);
}
// Helper function to get XP required for next level
function getXPForLevel(level) {
const levelXP = {
1: 0,
2: 100,
3: 300,
4: 600,
5: 1000,
6: 1500,
7: 2500,
8: 4000,
9: 6000,
10: 10000,
11: 15000
};
return levelXP[level] || level * 2000;
}
+195
View File
@@ -0,0 +1,195 @@
import { Link } from 'react-router-dom';
import './Legal.css';
export default function Terms() {
return (
<div className="legal-page">
<div className="legal-content">
<h1>The Rules</h1>
<p className="legal-subtitle">
Welcome to GamerComp! Here's what you need to know about using our site.
</p>
{/* Kid-Friendly Version */}
<section className="kid-friendly">
<div className="friendly-header">
<span className="friendly-icon">🎮</span>
<h2>For Players (The Fun Version)</h2>
</div>
<div className="rule-card">
<span className="rule-emoji">🌟</span>
<div className="rule-content">
<h3>Your Games Help Everyone!</h3>
<p>
When you create a game on GamerComp, it becomes part of our awesome game library
that everyone can play and learn from. Think of it like donating a toy to a
playground - now everyone gets to enjoy it!
</p>
</div>
</div>
<div className="rule-card">
<span className="rule-emoji">🤝</span>
<div className="rule-content">
<h3>Sharing is Caring</h3>
<p>
All games on GamerComp are free for everyone to play, remix, and learn from.
This means you can take ideas from other games to make your own even better!
</p>
</div>
</div>
<div className="rule-card">
<span className="rule-emoji">😊</span>
<div className="rule-content">
<h3>Be Kind</h3>
<p>
Use nice words in your game titles and descriptions. No mean words, no
personal information, and nothing that would make other players uncomfortable.
</p>
</div>
</div>
<div className="rule-card">
<span className="rule-emoji">🔒</span>
<div className="rule-content">
<h3>Stay Safe</h3>
<p>
Never share your real name, address, phone number, or school name.
Your username is your secret identity - keep it that way!
</p>
</div>
</div>
<div className="rule-card highlight">
<span className="rule-emoji">🎁</span>
<div className="rule-content">
<h3>Open Source = Open Awesome!</h3>
<p>
Games you create are released under the GPL license. This means your
game becomes a gift to the world! Other people can play it, learn from
your code, and even improve it. You're contributing to something bigger
than yourself - a free library of games for everyone!
</p>
</div>
</div>
</section>
{/* Parent/Legal Version */}
<section className="legal-section">
<h2>Terms of Service (Full Legal Version)</h2>
<p className="legal-updated">Last updated: February 2026</p>
<h3>1. Acceptance of Terms</h3>
<p>
By accessing or using GamerComp.com ("the Service"), you agree to be bound by these
Terms of Service. If you are under 18, your parent or guardian must agree to these
terms on your behalf. If you do not agree, please do not use the Service.
</p>
<h3>2. Description of Service</h3>
<p>
GamerComp is an educational gaming platform designed for users ages 8-16. The Service
allows users to play AI-generated games, create their own games using guided prompts,
and learn about game design and prompt engineering.
</p>
<h3>3. User Accounts</h3>
<p>
Users may access the Service as guests or create registered accounts. Guest accounts
have limited features. Registered accounts require a valid email address. Users must
not share account credentials or create multiple accounts.
</p>
<h3>4. Content Ownership and Licensing</h3>
<p>
<strong>All games created on GamerComp are released under the GNU General Public
License (GPL) v3.0.</strong> By creating a game on GamerComp, you agree that:
</p>
<ul>
<li>Your game becomes open source and freely available to all users</li>
<li>Other users may play, copy, modify, and distribute your game</li>
<li>You grant GamerComp a perpetual, worldwide license to host and display your game</li>
<li>You retain attribution as the original creator</li>
<li>Derivative works (remixes) must also be released under GPL</li>
</ul>
<p>
<strong>You do not retain exclusive ownership of games created on this platform.</strong>
This is intentional - GamerComp is designed as a collaborative learning environment
where everyone benefits from shared creativity.
</p>
<h3>5. Content Guidelines</h3>
<p>All content must be appropriate for users ages 8-16. Prohibited content includes:</p>
<ul>
<li>Profanity, hate speech, or discriminatory language</li>
<li>Sexual or adult content</li>
<li>Graphic violence or gore</li>
<li>Personal information (names, addresses, phone numbers, etc.)</li>
<li>Copyrighted material without permission</li>
<li>Content promoting illegal activities</li>
</ul>
<p>
GamerComp uses automated content filtering and human moderation. Violations may result
in content removal and account suspension.
</p>
<h3>6. User Conduct</h3>
<p>Users agree not to:</p>
<ul>
<li>Harass, bully, or intimidate other users</li>
<li>Attempt to circumvent content filters</li>
<li>Use the Service for any illegal purpose</li>
<li>Attempt to access other users' accounts</li>
<li>Interfere with the Service's operation</li>
</ul>
<h3>7. Tokens and Virtual Currency</h3>
<p>
GamerComp uses a virtual token system for game creation and rewards. Tokens have no
monetary value and cannot be exchanged for real currency. GamerComp reserves the right
to modify token values and distribution at any time.
</p>
<h3>8. Disclaimer of Warranties</h3>
<p>
The Service is provided "as is" without warranties of any kind. GamerComp does not
guarantee that games will function perfectly or that the Service will be available
without interruption.
</p>
<h3>9. Limitation of Liability</h3>
<p>
GamerComp shall not be liable for any indirect, incidental, or consequential damages
arising from use of the Service. Our total liability shall not exceed the amount paid
by you (if any) in the past 12 months.
</p>
<h3>10. Changes to Terms</h3>
<p>
GamerComp may modify these Terms at any time. Continued use of the Service after
changes constitutes acceptance of the new Terms. Significant changes will be
communicated via email or site notification.
</p>
<h3>11. Termination</h3>
<p>
GamerComp may terminate accounts that violate these Terms. Users may delete their
accounts at any time. Games created prior to termination remain available under GPL.
</p>
<h3>12. Contact</h3>
<p>
For questions about these Terms, please contact us at support@gamercomp.com.
</p>
</section>
<div className="legal-footer">
<Link to="/privacy" className="legal-link">Privacy Policy</Link>
<Link to="/" className="legal-link">Back to Home</Link>
</div>
</div>
</div>
);
}