Initial commit
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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">▶</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">👤</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">⏱</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">♥</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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user