Huge v2 uplift, now deployable with real user management and tooling!

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Aaron D. Lee
2026-01-27 11:32:15 -05:00
parent c912a56c2d
commit bea85e6b28
61 changed files with 25153 additions and 362 deletions

633
client/admin.css Normal file
View File

@@ -0,0 +1,633 @@
/* Golf Admin Dashboard Styles */
:root {
--color-primary: #2563eb;
--color-primary-dark: #1d4ed8;
--color-success: #059669;
--color-warning: #d97706;
--color-danger: #dc2626;
--color-bg: #f8fafc;
--color-surface: #ffffff;
--color-border: #e2e8f0;
--color-text: #1e293b;
--color-text-muted: #64748b;
--color-text-light: #94a3b8;
--radius: 8px;
--shadow: 0 1px 3px rgba(0,0,0,0.1), 0 1px 2px rgba(0,0,0,0.06);
--shadow-lg: 0 10px 15px -3px rgba(0,0,0,0.1), 0 4px 6px -2px rgba(0,0,0,0.05);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
background: var(--color-bg);
color: var(--color-text);
line-height: 1.5;
}
/* Screens */
.screen {
min-height: 100vh;
}
.hidden {
display: none !important;
}
/* Login Screen */
.login-container {
max-width: 400px;
margin: 100px auto;
padding: 2rem;
background: var(--color-surface);
border-radius: var(--radius);
box-shadow: var(--shadow-lg);
}
.login-container h1 {
text-align: center;
margin-bottom: 1.5rem;
color: var(--color-primary);
}
/* Forms */
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: var(--color-text);
}
.form-group input,
.form-group textarea,
.form-group select {
width: 100%;
padding: 0.75rem;
border: 1px solid var(--color-border);
border-radius: var(--radius);
font-size: 1rem;
transition: border-color 0.2s;
}
.form-group input:focus,
.form-group textarea:focus,
.form-group select:focus {
outline: none;
border-color: var(--color-primary);
}
.form-group textarea {
min-height: 100px;
resize: vertical;
}
.form-actions {
display: flex;
gap: 1rem;
justify-content: flex-end;
margin-top: 1.5rem;
}
.error {
color: var(--color-danger);
margin-top: 1rem;
text-align: center;
}
/* Buttons */
.btn {
padding: 0.75rem 1.5rem;
border: none;
border-radius: var(--radius);
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s, transform 0.1s;
background: var(--color-border);
color: var(--color-text);
}
.btn:hover {
filter: brightness(0.95);
}
.btn:active {
transform: scale(0.98);
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primary {
background: var(--color-primary);
color: white;
}
.btn-primary:hover {
background: var(--color-primary-dark);
}
.btn-success {
background: var(--color-success);
color: white;
}
.btn-warning {
background: var(--color-warning);
color: white;
}
.btn-danger {
background: var(--color-danger);
color: white;
}
.btn-small {
padding: 0.5rem 1rem;
font-size: 0.75rem;
}
/* Navigation */
.admin-nav {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 2rem;
background: var(--color-surface);
border-bottom: 1px solid var(--color-border);
box-shadow: var(--shadow);
}
.nav-brand h1 {
font-size: 1.5rem;
color: var(--color-primary);
}
.nav-links {
display: flex;
gap: 0.5rem;
}
.nav-link {
padding: 0.5rem 1rem;
text-decoration: none;
color: var(--color-text-muted);
border-radius: var(--radius);
transition: background-color 0.2s, color 0.2s;
}
.nav-link:hover {
background: var(--color-bg);
color: var(--color-text);
}
.nav-link.active {
background: var(--color-primary);
color: white;
}
.nav-user {
display: flex;
align-items: center;
gap: 1rem;
color: var(--color-text-muted);
}
/* Content */
.admin-content {
max-width: 1400px;
margin: 0 auto;
padding: 2rem;
}
/* Panels */
.panel {
background: var(--color-surface);
border-radius: var(--radius);
padding: 1.5rem;
box-shadow: var(--shadow);
}
.panel h2 {
margin-bottom: 1.5rem;
color: var(--color-text);
}
.panel-section {
margin-top: 2rem;
}
.panel-section h3 {
margin-bottom: 1rem;
color: var(--color-text);
}
.panel-toolbar {
display: flex;
gap: 1rem;
align-items: center;
margin-bottom: 1rem;
flex-wrap: wrap;
}
/* Stats Grid */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
}
.stat-card {
background: var(--color-bg);
padding: 1.25rem;
border-radius: var(--radius);
text-align: center;
}
.stat-value {
display: block;
font-size: 2rem;
font-weight: 700;
color: var(--color-primary);
}
.stat-label {
display: block;
font-size: 0.875rem;
color: var(--color-text-muted);
margin-top: 0.25rem;
}
/* Data Tables */
.data-table {
width: 100%;
border-collapse: collapse;
}
.data-table th,
.data-table td {
padding: 0.75rem;
text-align: left;
border-bottom: 1px solid var(--color-border);
}
.data-table th {
background: var(--color-bg);
font-weight: 600;
color: var(--color-text-muted);
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.data-table tbody tr:hover {
background: var(--color-bg);
}
.data-table.small {
font-size: 0.875rem;
}
.data-table.small th,
.data-table.small td {
padding: 0.5rem;
}
/* Search Bar */
.search-bar {
display: flex;
gap: 0.5rem;
}
.search-bar input {
padding: 0.5rem 1rem;
border: 1px solid var(--color-border);
border-radius: var(--radius);
min-width: 250px;
}
.search-bar input:focus {
outline: none;
border-color: var(--color-primary);
}
/* Filter Bar */
.filter-bar {
display: flex;
gap: 0.5rem;
}
.filter-bar select {
padding: 0.5rem 1rem;
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: white;
}
/* Checkbox */
.checkbox-label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
color: var(--color-text-muted);
font-size: 0.875rem;
}
/* Create Invite Form */
.create-invite-form {
display: flex;
gap: 1rem;
align-items: center;
}
.create-invite-form label {
display: flex;
align-items: center;
gap: 0.5rem;
color: var(--color-text-muted);
font-size: 0.875rem;
}
.create-invite-form input {
width: 80px;
padding: 0.5rem;
border: 1px solid var(--color-border);
border-radius: var(--radius);
}
/* Pagination */
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 1rem;
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid var(--color-border);
}
/* Status Badges */
.badge {
display: inline-block;
padding: 0.25rem 0.5rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 500;
}
.badge-success {
background: #dcfce7;
color: #166534;
}
.badge-danger {
background: #fee2e2;
color: #991b1b;
}
.badge-warning {
background: #fef3c7;
color: #92400e;
}
.badge-info {
background: #dbeafe;
color: #1e40af;
}
.badge-muted {
background: #f1f5f9;
color: #64748b;
}
/* Modals */
.modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
background: var(--color-surface);
border-radius: var(--radius);
max-width: 600px;
width: 90%;
max-height: 90vh;
overflow-y: auto;
box-shadow: var(--shadow-lg);
}
.modal-content.modal-small {
max-width: 400px;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--color-border);
}
.modal-header h3 {
margin: 0;
}
.modal-close {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: var(--color-text-muted);
padding: 0;
line-height: 1;
}
.modal-close:hover {
color: var(--color-text);
}
.modal-body {
padding: 1.5rem;
}
/* User Detail Modal */
.user-detail-grid {
display: grid;
gap: 0.75rem;
}
.detail-row {
display: flex;
gap: 1rem;
}
.detail-label {
font-weight: 500;
color: var(--color-text-muted);
min-width: 120px;
}
.detail-value {
color: var(--color-text);
}
.user-actions {
margin-top: 1.5rem;
padding-top: 1.5rem;
border-top: 1px solid var(--color-border);
}
.user-actions h4 {
margin-bottom: 1rem;
}
.action-buttons {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
#ban-history-section {
margin-top: 1.5rem;
padding-top: 1.5rem;
border-top: 1px solid var(--color-border);
}
#ban-history-section h4 {
margin-bottom: 1rem;
}
/* Toast Notifications */
#toast-container {
position: fixed;
bottom: 2rem;
right: 2rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
z-index: 2000;
}
.toast {
padding: 1rem 1.5rem;
border-radius: var(--radius);
background: var(--color-text);
color: white;
box-shadow: var(--shadow-lg);
animation: slideIn 0.3s ease;
}
.toast.success {
background: var(--color-success);
}
.toast.error {
background: var(--color-danger);
}
.toast.warning {
background: var(--color-warning);
}
@keyframes slideIn {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
/* Responsive */
@media (max-width: 768px) {
.admin-nav {
flex-direction: column;
gap: 1rem;
}
.nav-links {
flex-wrap: wrap;
justify-content: center;
}
.admin-content {
padding: 1rem;
}
.panel-toolbar {
flex-direction: column;
align-items: stretch;
}
.search-bar {
flex-direction: column;
}
.search-bar input {
min-width: auto;
width: 100%;
}
.create-invite-form {
flex-direction: column;
align-items: stretch;
}
.data-table {
font-size: 0.875rem;
}
.data-table th,
.data-table td {
padding: 0.5rem;
}
}
/* Utility Classes */
.text-muted {
color: var(--color-text-muted);
}
.text-success {
color: var(--color-success);
}
.text-danger {
color: var(--color-danger);
}
.text-warning {
color: var(--color-warning);
}
.text-small {
font-size: 0.875rem;
}
.mt-1 { margin-top: 0.5rem; }
.mt-2 { margin-top: 1rem; }
.mb-1 { margin-bottom: 0.5rem; }
.mb-2 { margin-bottom: 1rem; }

368
client/admin.html Normal file
View File

@@ -0,0 +1,368 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Golf Admin Dashboard</title>
<link rel="stylesheet" href="admin.css">
</head>
<body>
<!-- Login Screen -->
<div id="login-screen" class="screen">
<div class="login-container">
<h1>Golf Admin</h1>
<form id="login-form">
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" name="username" required>
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" name="password" required>
</div>
<button type="submit" class="btn btn-primary">Login</button>
<p id="login-error" class="error"></p>
</form>
</div>
</div>
<!-- Dashboard Screen -->
<div id="dashboard-screen" class="screen hidden">
<nav class="admin-nav">
<div class="nav-brand">
<h1>Golf Admin</h1>
</div>
<div class="nav-links">
<a href="#" data-panel="dashboard" class="nav-link active">Dashboard</a>
<a href="#" data-panel="users" class="nav-link">Users</a>
<a href="#" data-panel="games" class="nav-link">Games</a>
<a href="#" data-panel="invites" class="nav-link">Invites</a>
<a href="#" data-panel="audit" class="nav-link">Audit Log</a>
</div>
<div class="nav-user">
<span id="admin-username"></span>
<button id="logout-btn" class="btn btn-small">Logout</button>
</div>
</nav>
<main class="admin-content">
<!-- Dashboard Panel -->
<section id="dashboard-panel" class="panel">
<h2>System Overview</h2>
<div class="stats-grid">
<div class="stat-card">
<span class="stat-value" id="stat-active-users">-</span>
<span class="stat-label">Active Users (1h)</span>
</div>
<div class="stat-card">
<span class="stat-value" id="stat-active-games">-</span>
<span class="stat-label">Active Games</span>
</div>
<div class="stat-card">
<span class="stat-value" id="stat-total-users">-</span>
<span class="stat-label">Total Users</span>
</div>
<div class="stat-card">
<span class="stat-value" id="stat-games-today">-</span>
<span class="stat-label">Games Today</span>
</div>
<div class="stat-card">
<span class="stat-value" id="stat-reg-today">-</span>
<span class="stat-label">Registrations Today</span>
</div>
<div class="stat-card">
<span class="stat-value" id="stat-reg-week">-</span>
<span class="stat-label">Registrations (7d)</span>
</div>
<div class="stat-card">
<span class="stat-value" id="stat-total-games">-</span>
<span class="stat-label">Total Games</span>
</div>
<div class="stat-card">
<span class="stat-value" id="stat-events-hour">-</span>
<span class="stat-label">Events (1h)</span>
</div>
</div>
<div class="panel-section">
<h3>Top Players</h3>
<table id="top-players-table" class="data-table">
<thead>
<tr>
<th>#</th>
<th>Username</th>
<th>Wins</th>
<th>Games</th>
<th>Win Rate</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</section>
<!-- Users Panel -->
<section id="users-panel" class="panel hidden">
<h2>User Management</h2>
<div class="panel-toolbar">
<div class="search-bar">
<input type="text" id="user-search" placeholder="Search by username or email...">
<button id="user-search-btn" class="btn">Search</button>
</div>
<label class="checkbox-label">
<input type="checkbox" id="include-banned" checked>
Include banned
</label>
</div>
<table id="users-table" class="data-table">
<thead>
<tr>
<th>Username</th>
<th>Email</th>
<th>Role</th>
<th>Status</th>
<th>Games</th>
<th>Joined</th>
<th>Actions</th>
</tr>
</thead>
<tbody></tbody>
</table>
<div class="pagination">
<button id="users-prev" class="btn btn-small" disabled>Previous</button>
<span id="users-page-info">Page 1</span>
<button id="users-next" class="btn btn-small">Next</button>
</div>
</section>
<!-- Games Panel -->
<section id="games-panel" class="panel hidden">
<h2>Active Games</h2>
<button id="refresh-games-btn" class="btn">Refresh</button>
<table id="games-table" class="data-table">
<thead>
<tr>
<th>Room Code</th>
<th>Players</th>
<th>Phase</th>
<th>Round</th>
<th>Status</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody></tbody>
</table>
</section>
<!-- Invites Panel -->
<section id="invites-panel" class="panel hidden">
<h2>Invite Codes</h2>
<div class="panel-toolbar">
<div class="create-invite-form">
<label>
Max Uses:
<input type="number" id="invite-max-uses" value="1" min="1" max="100">
</label>
<label>
Expires in (days):
<input type="number" id="invite-expires-days" value="7" min="1" max="365">
</label>
<button id="create-invite-btn" class="btn btn-primary">Create Invite</button>
</div>
<label class="checkbox-label">
<input type="checkbox" id="include-expired">
Show expired
</label>
</div>
<table id="invites-table" class="data-table">
<thead>
<tr>
<th>Code</th>
<th>Uses</th>
<th>Remaining</th>
<th>Created By</th>
<th>Expires</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody></tbody>
</table>
</section>
<!-- Audit Log Panel -->
<section id="audit-panel" class="panel hidden">
<h2>Audit Log</h2>
<div class="panel-toolbar">
<div class="filter-bar">
<select id="audit-action-filter">
<option value="">All Actions</option>
<option value="ban_user">Ban User</option>
<option value="unban_user">Unban User</option>
<option value="force_password_reset">Force Password Reset</option>
<option value="change_role">Change Role</option>
<option value="impersonate_user">Impersonate</option>
<option value="view_game">View Game</option>
<option value="end_game">End Game</option>
<option value="create_invite">Create Invite</option>
<option value="revoke_invite">Revoke Invite</option>
</select>
<select id="audit-target-filter">
<option value="">All Targets</option>
<option value="user">Users</option>
<option value="game">Games</option>
<option value="invite_code">Invites</option>
</select>
<button id="audit-filter-btn" class="btn">Filter</button>
</div>
</div>
<table id="audit-table" class="data-table">
<thead>
<tr>
<th>Time</th>
<th>Admin</th>
<th>Action</th>
<th>Target</th>
<th>Details</th>
<th>IP</th>
</tr>
</thead>
<tbody></tbody>
</table>
<div class="pagination">
<button id="audit-prev" class="btn btn-small" disabled>Previous</button>
<span id="audit-page-info">Page 1</span>
<button id="audit-next" class="btn btn-small">Next</button>
</div>
</section>
</main>
</div>
<!-- User Detail Modal -->
<div id="user-modal" class="modal hidden">
<div class="modal-content">
<div class="modal-header">
<h3>User Details</h3>
<button class="modal-close">&times;</button>
</div>
<div class="modal-body">
<div class="user-detail-grid">
<div class="detail-row">
<span class="detail-label">Username:</span>
<span id="detail-username" class="detail-value"></span>
</div>
<div class="detail-row">
<span class="detail-label">Email:</span>
<span id="detail-email" class="detail-value"></span>
</div>
<div class="detail-row">
<span class="detail-label">Role:</span>
<span id="detail-role" class="detail-value"></span>
</div>
<div class="detail-row">
<span class="detail-label">Status:</span>
<span id="detail-status" class="detail-value"></span>
</div>
<div class="detail-row">
<span class="detail-label">Games Played:</span>
<span id="detail-games-played" class="detail-value"></span>
</div>
<div class="detail-row">
<span class="detail-label">Games Won:</span>
<span id="detail-games-won" class="detail-value"></span>
</div>
<div class="detail-row">
<span class="detail-label">Joined:</span>
<span id="detail-joined" class="detail-value"></span>
</div>
<div class="detail-row">
<span class="detail-label">Last Login:</span>
<span id="detail-last-login" class="detail-value"></span>
</div>
</div>
<div class="user-actions">
<h4>Actions</h4>
<div class="action-buttons">
<button id="action-ban" class="btn btn-danger">Ban User</button>
<button id="action-unban" class="btn btn-success hidden">Unban User</button>
<button id="action-reset-pw" class="btn btn-warning">Force Password Reset</button>
<button id="action-make-admin" class="btn">Make Admin</button>
<button id="action-remove-admin" class="btn hidden">Remove Admin</button>
<button id="action-impersonate" class="btn">Impersonate (Read-Only)</button>
</div>
</div>
<div id="ban-history-section">
<h4>Ban History</h4>
<table id="ban-history-table" class="data-table small">
<thead>
<tr>
<th>Date</th>
<th>Reason</th>
<th>By</th>
<th>Status</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Ban User Modal -->
<div id="ban-modal" class="modal hidden">
<div class="modal-content modal-small">
<div class="modal-header">
<h3>Ban User</h3>
<button class="modal-close">&times;</button>
</div>
<div class="modal-body">
<form id="ban-form">
<div class="form-group">
<label for="ban-reason">Reason:</label>
<textarea id="ban-reason" required placeholder="Enter reason for ban..."></textarea>
</div>
<div class="form-group">
<label for="ban-duration">Duration (days, leave empty for permanent):</label>
<input type="number" id="ban-duration" min="1" max="365" placeholder="Permanent">
</div>
<div class="form-actions">
<button type="button" class="btn modal-close">Cancel</button>
<button type="submit" class="btn btn-danger">Ban User</button>
</div>
</form>
</div>
</div>
</div>
<!-- End Game Modal -->
<div id="end-game-modal" class="modal hidden">
<div class="modal-content modal-small">
<div class="modal-header">
<h3>End Game</h3>
<button class="modal-close">&times;</button>
</div>
<div class="modal-body">
<form id="end-game-form">
<div class="form-group">
<label for="end-game-reason">Reason:</label>
<textarea id="end-game-reason" required placeholder="Enter reason for ending game..."></textarea>
</div>
<div class="form-actions">
<button type="button" class="btn modal-close">Cancel</button>
<button type="submit" class="btn btn-danger">End Game</button>
</div>
</form>
</div>
</div>
</div>
<!-- Toast Container -->
<div id="toast-container"></div>
<script src="admin.js"></script>
</body>
</html>

809
client/admin.js Normal file
View File

@@ -0,0 +1,809 @@
/**
* Golf Admin Dashboard
* JavaScript for admin interface functionality
*/
// State
let authToken = null;
let currentUser = null;
let currentPanel = 'dashboard';
let selectedUserId = null;
// Pagination state
let usersPage = 0;
let auditPage = 0;
const PAGE_SIZE = 20;
// =============================================================================
// API Functions
// =============================================================================
async function apiRequest(endpoint, options = {}) {
const headers = {
'Content-Type': 'application/json',
...options.headers,
};
if (authToken) {
headers['Authorization'] = `Bearer ${authToken}`;
}
const response = await fetch(endpoint, {
...options,
headers,
});
if (response.status === 401) {
// Unauthorized - clear auth and show login
logout();
throw new Error('Session expired. Please login again.');
}
if (response.status === 403) {
throw new Error('Admin access required');
}
const data = await response.json();
if (!response.ok) {
throw new Error(data.detail || 'Request failed');
}
return data;
}
// Auth API
async function login(username, password) {
const data = await apiRequest('/api/auth/login', {
method: 'POST',
body: JSON.stringify({ username, password }),
});
return data;
}
// Admin API
async function getStats() {
return apiRequest('/api/admin/stats');
}
async function getUsers(query = '', offset = 0, includeBanned = true) {
const params = new URLSearchParams({
query,
offset,
limit: PAGE_SIZE,
include_banned: includeBanned,
});
return apiRequest(`/api/admin/users?${params}`);
}
async function getUser(userId) {
return apiRequest(`/api/admin/users/${userId}`);
}
async function getUserBanHistory(userId) {
return apiRequest(`/api/admin/users/${userId}/ban-history`);
}
async function banUser(userId, reason, durationDays) {
return apiRequest(`/api/admin/users/${userId}/ban`, {
method: 'POST',
body: JSON.stringify({
reason,
duration_days: durationDays || null,
}),
});
}
async function unbanUser(userId) {
return apiRequest(`/api/admin/users/${userId}/unban`, {
method: 'POST',
});
}
async function forcePasswordReset(userId) {
return apiRequest(`/api/admin/users/${userId}/force-password-reset`, {
method: 'POST',
});
}
async function changeUserRole(userId, role) {
return apiRequest(`/api/admin/users/${userId}/role`, {
method: 'PUT',
body: JSON.stringify({ role }),
});
}
async function impersonateUser(userId) {
return apiRequest(`/api/admin/users/${userId}/impersonate`, {
method: 'POST',
});
}
async function getGames() {
return apiRequest('/api/admin/games');
}
async function getGameDetails(gameId) {
return apiRequest(`/api/admin/games/${gameId}`);
}
async function endGame(gameId, reason) {
return apiRequest(`/api/admin/games/${gameId}/end`, {
method: 'POST',
body: JSON.stringify({ reason }),
});
}
async function getInvites(includeExpired = false) {
const params = new URLSearchParams({ include_expired: includeExpired });
return apiRequest(`/api/admin/invites?${params}`);
}
async function createInvite(maxUses, expiresDays) {
return apiRequest('/api/admin/invites', {
method: 'POST',
body: JSON.stringify({
max_uses: maxUses,
expires_days: expiresDays,
}),
});
}
async function revokeInvite(code) {
return apiRequest(`/api/admin/invites/${code}`, {
method: 'DELETE',
});
}
async function getAuditLog(offset = 0, action = '', targetType = '') {
const params = new URLSearchParams({
offset,
limit: PAGE_SIZE,
});
if (action) params.append('action', action);
if (targetType) params.append('target_type', targetType);
return apiRequest(`/api/admin/audit?${params}`);
}
// =============================================================================
// UI Functions
// =============================================================================
function showScreen(screenId) {
document.querySelectorAll('.screen').forEach(s => s.classList.add('hidden'));
document.getElementById(screenId).classList.remove('hidden');
}
function showPanel(panelId) {
currentPanel = panelId;
document.querySelectorAll('.panel').forEach(p => p.classList.add('hidden'));
document.getElementById(`${panelId}-panel`).classList.remove('hidden');
document.querySelectorAll('.nav-link').forEach(link => {
link.classList.toggle('active', link.dataset.panel === panelId);
});
// Load panel data
switch (panelId) {
case 'dashboard':
loadDashboard();
break;
case 'users':
loadUsers();
break;
case 'games':
loadGames();
break;
case 'invites':
loadInvites();
break;
case 'audit':
loadAuditLog();
break;
}
}
function showModal(modalId) {
document.getElementById(modalId).classList.remove('hidden');
}
function hideModal(modalId) {
document.getElementById(modalId).classList.add('hidden');
}
function hideAllModals() {
document.querySelectorAll('.modal').forEach(m => m.classList.add('hidden'));
}
function showToast(message, type = 'info') {
const container = document.getElementById('toast-container');
const toast = document.createElement('div');
toast.className = `toast ${type}`;
toast.textContent = message;
container.appendChild(toast);
setTimeout(() => {
toast.remove();
}, 4000);
}
function formatDate(isoString) {
if (!isoString) return '-';
const date = new Date(isoString);
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
function formatDateShort(isoString) {
if (!isoString) return '-';
const date = new Date(isoString);
return date.toLocaleDateString();
}
function getStatusBadge(user) {
if (user.is_banned) {
return '<span class="badge badge-danger">Banned</span>';
}
if (!user.is_active) {
return '<span class="badge badge-muted">Inactive</span>';
}
if (user.force_password_reset) {
return '<span class="badge badge-warning">Reset Required</span>';
}
if (!user.email_verified && user.email) {
return '<span class="badge badge-warning">Unverified</span>';
}
return '<span class="badge badge-success">Active</span>';
}
// =============================================================================
// Data Loading
// =============================================================================
async function loadDashboard() {
try {
const stats = await getStats();
document.getElementById('stat-active-users').textContent = stats.active_users_now;
document.getElementById('stat-active-games').textContent = stats.active_games_now;
document.getElementById('stat-total-users').textContent = stats.total_users;
document.getElementById('stat-games-today').textContent = stats.games_today;
document.getElementById('stat-reg-today').textContent = stats.registrations_today;
document.getElementById('stat-reg-week').textContent = stats.registrations_week;
document.getElementById('stat-total-games').textContent = stats.total_games_completed;
document.getElementById('stat-events-hour').textContent = stats.events_last_hour;
// Top players table
const tbody = document.querySelector('#top-players-table tbody');
tbody.innerHTML = '';
stats.top_players.forEach((player, index) => {
const winRate = player.games_played > 0
? Math.round((player.games_won / player.games_played) * 100)
: 0;
tbody.innerHTML += `
<tr>
<td>${index + 1}</td>
<td>${escapeHtml(player.username)}</td>
<td>${player.games_won}</td>
<td>${player.games_played}</td>
<td>${winRate}%</td>
</tr>
`;
});
if (stats.top_players.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" class="text-muted">No players yet</td></tr>';
}
} catch (error) {
showToast('Failed to load dashboard: ' + error.message, 'error');
}
}
async function loadUsers() {
try {
const query = document.getElementById('user-search').value;
const includeBanned = document.getElementById('include-banned').checked;
const data = await getUsers(query, usersPage * PAGE_SIZE, includeBanned);
const tbody = document.querySelector('#users-table tbody');
tbody.innerHTML = '';
data.users.forEach(user => {
tbody.innerHTML += `
<tr>
<td>${escapeHtml(user.username)}</td>
<td>${escapeHtml(user.email || '-')}</td>
<td><span class="badge badge-${user.role === 'admin' ? 'info' : 'muted'}">${user.role}</span></td>
<td>${getStatusBadge(user)}</td>
<td>${user.games_played} (${user.games_won} wins)</td>
<td>${formatDateShort(user.created_at)}</td>
<td>
<button class="btn btn-small" onclick="viewUser('${user.id}')">View</button>
</td>
</tr>
`;
});
if (data.users.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" class="text-muted">No users found</td></tr>';
}
// Update pagination
document.getElementById('users-page-info').textContent = `Page ${usersPage + 1}`;
document.getElementById('users-prev').disabled = usersPage === 0;
document.getElementById('users-next').disabled = data.users.length < PAGE_SIZE;
} catch (error) {
showToast('Failed to load users: ' + error.message, 'error');
}
}
async function viewUser(userId) {
try {
selectedUserId = userId;
const user = await getUser(userId);
const history = await getUserBanHistory(userId);
// Populate details
document.getElementById('detail-username').textContent = user.username;
document.getElementById('detail-email').textContent = user.email || '-';
document.getElementById('detail-role').textContent = user.role;
document.getElementById('detail-status').innerHTML = getStatusBadge(user);
document.getElementById('detail-games-played').textContent = user.games_played;
document.getElementById('detail-games-won').textContent = user.games_won;
document.getElementById('detail-joined').textContent = formatDate(user.created_at);
document.getElementById('detail-last-login').textContent = formatDate(user.last_login);
// Update action buttons visibility
document.getElementById('action-ban').classList.toggle('hidden', user.is_banned);
document.getElementById('action-unban').classList.toggle('hidden', !user.is_banned);
document.getElementById('action-make-admin').classList.toggle('hidden', user.role === 'admin');
document.getElementById('action-remove-admin').classList.toggle('hidden', user.role !== 'admin');
// Ban history
const historyBody = document.querySelector('#ban-history-table tbody');
historyBody.innerHTML = '';
history.history.forEach(ban => {
const status = ban.unbanned_at
? `<span class="badge badge-success">Unbanned</span>`
: (ban.expires_at && new Date(ban.expires_at) < new Date()
? `<span class="badge badge-muted">Expired</span>`
: `<span class="badge badge-danger">Active</span>`);
historyBody.innerHTML += `
<tr>
<td>${formatDateShort(ban.banned_at)}</td>
<td>${escapeHtml(ban.reason || '-')}</td>
<td>${escapeHtml(ban.banned_by)}</td>
<td>${status}</td>
</tr>
`;
});
if (history.history.length === 0) {
historyBody.innerHTML = '<tr><td colspan="4" class="text-muted">No ban history</td></tr>';
}
showModal('user-modal');
} catch (error) {
showToast('Failed to load user: ' + error.message, 'error');
}
}
async function loadGames() {
try {
const data = await getGames();
const tbody = document.querySelector('#games-table tbody');
tbody.innerHTML = '';
data.games.forEach(game => {
tbody.innerHTML += `
<tr>
<td><strong>${escapeHtml(game.room_code)}</strong></td>
<td>${game.player_count}</td>
<td>${game.phase || game.status || '-'}</td>
<td>${game.current_round || '-'}</td>
<td><span class="badge badge-${game.status === 'playing' ? 'success' : 'info'}">${game.status}</span></td>
<td>${formatDate(game.created_at)}</td>
<td>
<button class="btn btn-small btn-danger" onclick="promptEndGame('${game.game_id}')">End</button>
</td>
</tr>
`;
});
if (data.games.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" class="text-muted">No active games</td></tr>';
}
} catch (error) {
showToast('Failed to load games: ' + error.message, 'error');
}
}
let selectedGameId = null;
function promptEndGame(gameId) {
selectedGameId = gameId;
document.getElementById('end-game-reason').value = '';
showModal('end-game-modal');
}
async function loadInvites() {
try {
const includeExpired = document.getElementById('include-expired').checked;
const data = await getInvites(includeExpired);
const tbody = document.querySelector('#invites-table tbody');
tbody.innerHTML = '';
data.codes.forEach(invite => {
const isExpired = new Date(invite.expires_at) < new Date();
const status = !invite.is_active
? '<span class="badge badge-danger">Revoked</span>'
: isExpired
? '<span class="badge badge-muted">Expired</span>'
: invite.remaining_uses <= 0
? '<span class="badge badge-warning">Used Up</span>'
: '<span class="badge badge-success">Active</span>';
tbody.innerHTML += `
<tr>
<td><code>${escapeHtml(invite.code)}</code></td>
<td>${invite.use_count} / ${invite.max_uses}</td>
<td>${invite.remaining_uses}</td>
<td>${escapeHtml(invite.created_by_username)}</td>
<td>${formatDate(invite.expires_at)}</td>
<td>${status}</td>
<td>
${invite.is_active && !isExpired && invite.remaining_uses > 0
? `<button class="btn btn-small btn-danger" onclick="promptRevokeInvite('${invite.code}')">Revoke</button>`
: '-'
}
</td>
</tr>
`;
});
if (data.codes.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" class="text-muted">No invite codes</td></tr>';
}
} catch (error) {
showToast('Failed to load invites: ' + error.message, 'error');
}
}
async function loadAuditLog() {
try {
const action = document.getElementById('audit-action-filter').value;
const targetType = document.getElementById('audit-target-filter').value;
const data = await getAuditLog(auditPage * PAGE_SIZE, action, targetType);
const tbody = document.querySelector('#audit-table tbody');
tbody.innerHTML = '';
data.entries.forEach(entry => {
const details = Object.keys(entry.details).length > 0
? `<code class="text-small">${escapeHtml(JSON.stringify(entry.details))}</code>`
: '-';
tbody.innerHTML += `
<tr>
<td>${formatDate(entry.created_at)}</td>
<td>${escapeHtml(entry.admin_username)}</td>
<td><span class="badge badge-info">${entry.action}</span></td>
<td>${entry.target_type ? `${entry.target_type}: ${entry.target_id || '-'}` : '-'}</td>
<td>${details}</td>
<td class="text-muted text-small">${entry.ip_address || '-'}</td>
</tr>
`;
});
if (data.entries.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" class="text-muted">No audit entries</td></tr>';
}
// Update pagination
document.getElementById('audit-page-info').textContent = `Page ${auditPage + 1}`;
document.getElementById('audit-prev').disabled = auditPage === 0;
document.getElementById('audit-next').disabled = data.entries.length < PAGE_SIZE;
} catch (error) {
showToast('Failed to load audit log: ' + error.message, 'error');
}
}
// =============================================================================
// Actions
// =============================================================================
async function handleBanUser(event) {
event.preventDefault();
const reason = document.getElementById('ban-reason').value;
const duration = document.getElementById('ban-duration').value;
try {
await banUser(selectedUserId, reason, duration ? parseInt(duration) : null);
showToast('User banned successfully', 'success');
hideAllModals();
loadUsers();
} catch (error) {
showToast('Failed to ban user: ' + error.message, 'error');
}
}
async function handleUnbanUser() {
if (!confirm('Are you sure you want to unban this user?')) return;
try {
await unbanUser(selectedUserId);
showToast('User unbanned successfully', 'success');
hideAllModals();
loadUsers();
} catch (error) {
showToast('Failed to unban user: ' + error.message, 'error');
}
}
async function handleForcePasswordReset() {
if (!confirm('Are you sure you want to force a password reset for this user? They will be logged out.')) return;
try {
await forcePasswordReset(selectedUserId);
showToast('Password reset required for user', 'success');
hideAllModals();
loadUsers();
} catch (error) {
showToast('Failed to force password reset: ' + error.message, 'error');
}
}
async function handleMakeAdmin() {
if (!confirm('Are you sure you want to make this user an admin?')) return;
try {
await changeUserRole(selectedUserId, 'admin');
showToast('User is now an admin', 'success');
hideAllModals();
loadUsers();
} catch (error) {
showToast('Failed to change role: ' + error.message, 'error');
}
}
async function handleRemoveAdmin() {
if (!confirm('Are you sure you want to remove admin privileges from this user?')) return;
try {
await changeUserRole(selectedUserId, 'user');
showToast('Admin privileges removed', 'success');
hideAllModals();
loadUsers();
} catch (error) {
showToast('Failed to change role: ' + error.message, 'error');
}
}
async function handleImpersonate() {
try {
const data = await impersonateUser(selectedUserId);
showToast(`Viewing as ${data.user.username} (read-only). Check console for details.`, 'success');
console.log('Impersonation data:', data);
} catch (error) {
showToast('Failed to impersonate: ' + error.message, 'error');
}
}
async function handleEndGame(event) {
event.preventDefault();
const reason = document.getElementById('end-game-reason').value;
try {
await endGame(selectedGameId, reason);
showToast('Game ended successfully', 'success');
hideAllModals();
loadGames();
} catch (error) {
showToast('Failed to end game: ' + error.message, 'error');
}
}
async function handleCreateInvite() {
const maxUses = parseInt(document.getElementById('invite-max-uses').value) || 1;
const expiresDays = parseInt(document.getElementById('invite-expires-days').value) || 7;
try {
const data = await createInvite(maxUses, expiresDays);
showToast(`Invite code created: ${data.code}`, 'success');
loadInvites();
} catch (error) {
showToast('Failed to create invite: ' + error.message, 'error');
}
}
async function promptRevokeInvite(code) {
if (!confirm(`Are you sure you want to revoke invite code ${code}?`)) return;
try {
await revokeInvite(code);
showToast('Invite code revoked', 'success');
loadInvites();
} catch (error) {
showToast('Failed to revoke invite: ' + error.message, 'error');
}
}
// =============================================================================
// Auth
// =============================================================================
async function handleLogin(event) {
event.preventDefault();
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
const errorEl = document.getElementById('login-error');
try {
const data = await login(username, password);
// Check if user is admin
if (data.user.role !== 'admin') {
errorEl.textContent = 'Admin access required';
return;
}
// Store auth
authToken = data.token;
currentUser = data.user;
localStorage.setItem('adminToken', data.token);
localStorage.setItem('adminUser', JSON.stringify(data.user));
// Show dashboard
document.getElementById('admin-username').textContent = currentUser.username;
showScreen('dashboard-screen');
showPanel('dashboard');
} catch (error) {
errorEl.textContent = error.message;
}
}
function logout() {
authToken = null;
currentUser = null;
localStorage.removeItem('adminToken');
localStorage.removeItem('adminUser');
showScreen('login-screen');
}
function checkAuth() {
const savedToken = localStorage.getItem('adminToken');
const savedUser = localStorage.getItem('adminUser');
if (savedToken && savedUser) {
authToken = savedToken;
currentUser = JSON.parse(savedUser);
if (currentUser.role === 'admin') {
document.getElementById('admin-username').textContent = currentUser.username;
showScreen('dashboard-screen');
showPanel('dashboard');
return;
}
}
showScreen('login-screen');
}
// =============================================================================
// Utilities
// =============================================================================
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// =============================================================================
// Event Listeners
// =============================================================================
document.addEventListener('DOMContentLoaded', () => {
// Login form
document.getElementById('login-form').addEventListener('submit', handleLogin);
// Logout button
document.getElementById('logout-btn').addEventListener('click', logout);
// Navigation
document.querySelectorAll('.nav-link').forEach(link => {
link.addEventListener('click', (e) => {
e.preventDefault();
showPanel(link.dataset.panel);
});
});
// Users panel
document.getElementById('user-search-btn').addEventListener('click', () => {
usersPage = 0;
loadUsers();
});
document.getElementById('user-search').addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
usersPage = 0;
loadUsers();
}
});
document.getElementById('include-banned').addEventListener('change', () => {
usersPage = 0;
loadUsers();
});
document.getElementById('users-prev').addEventListener('click', () => {
if (usersPage > 0) {
usersPage--;
loadUsers();
}
});
document.getElementById('users-next').addEventListener('click', () => {
usersPage++;
loadUsers();
});
// User modal actions
document.getElementById('action-ban').addEventListener('click', () => {
document.getElementById('ban-reason').value = '';
document.getElementById('ban-duration').value = '';
showModal('ban-modal');
});
document.getElementById('action-unban').addEventListener('click', handleUnbanUser);
document.getElementById('action-reset-pw').addEventListener('click', handleForcePasswordReset);
document.getElementById('action-make-admin').addEventListener('click', handleMakeAdmin);
document.getElementById('action-remove-admin').addEventListener('click', handleRemoveAdmin);
document.getElementById('action-impersonate').addEventListener('click', handleImpersonate);
// Ban form
document.getElementById('ban-form').addEventListener('submit', handleBanUser);
// Games panel
document.getElementById('refresh-games-btn').addEventListener('click', loadGames);
// End game form
document.getElementById('end-game-form').addEventListener('submit', handleEndGame);
// Invites panel
document.getElementById('create-invite-btn').addEventListener('click', handleCreateInvite);
document.getElementById('include-expired').addEventListener('change', loadInvites);
// Audit panel
document.getElementById('audit-filter-btn').addEventListener('click', () => {
auditPage = 0;
loadAuditLog();
});
document.getElementById('audit-prev').addEventListener('click', () => {
if (auditPage > 0) {
auditPage--;
loadAuditLog();
}
});
document.getElementById('audit-next').addEventListener('click', () => {
auditPage++;
loadAuditLog();
});
// Modal close buttons
document.querySelectorAll('.modal-close').forEach(btn => {
btn.addEventListener('click', hideAllModals);
});
// Close modal on overlay click
document.querySelectorAll('.modal').forEach(modal => {
modal.addEventListener('click', (e) => {
if (e.target === modal) {
hideAllModals();
}
});
});
// Check auth on load
checkAuth();
});

View File

@@ -2257,4 +2257,197 @@ class GolfGame {
// Initialize game when page loads
document.addEventListener('DOMContentLoaded', () => {
window.game = new GolfGame();
window.auth = new AuthManager(window.game);
});
// ===========================================
// AUTH MANAGER
// ===========================================
class AuthManager {
constructor(game) {
this.game = game;
this.token = localStorage.getItem('authToken');
this.user = JSON.parse(localStorage.getItem('authUser') || 'null');
this.initElements();
this.bindEvents();
this.updateUI();
}
initElements() {
this.authBar = document.getElementById('auth-bar');
this.authUsername = document.getElementById('auth-username');
this.logoutBtn = document.getElementById('auth-logout-btn');
this.authButtons = document.getElementById('auth-buttons');
this.loginBtn = document.getElementById('login-btn');
this.signupBtn = document.getElementById('signup-btn');
this.modal = document.getElementById('auth-modal');
this.modalClose = document.getElementById('auth-modal-close');
this.loginFormContainer = document.getElementById('login-form-container');
this.loginForm = document.getElementById('login-form');
this.loginUsername = document.getElementById('login-username');
this.loginPassword = document.getElementById('login-password');
this.loginError = document.getElementById('login-error');
this.signupFormContainer = document.getElementById('signup-form-container');
this.signupForm = document.getElementById('signup-form');
this.signupUsername = document.getElementById('signup-username');
this.signupEmail = document.getElementById('signup-email');
this.signupPassword = document.getElementById('signup-password');
this.signupError = document.getElementById('signup-error');
this.showSignupLink = document.getElementById('show-signup');
this.showLoginLink = document.getElementById('show-login');
}
bindEvents() {
this.loginBtn?.addEventListener('click', () => this.showModal('login'));
this.signupBtn?.addEventListener('click', () => this.showModal('signup'));
this.modalClose?.addEventListener('click', () => this.hideModal());
this.modal?.addEventListener('click', (e) => {
if (e.target === this.modal) this.hideModal();
});
this.showSignupLink?.addEventListener('click', (e) => {
e.preventDefault();
this.showForm('signup');
});
this.showLoginLink?.addEventListener('click', (e) => {
e.preventDefault();
this.showForm('login');
});
this.loginForm?.addEventListener('submit', (e) => this.handleLogin(e));
this.signupForm?.addEventListener('submit', (e) => this.handleSignup(e));
this.logoutBtn?.addEventListener('click', () => this.logout());
}
showModal(form = 'login') {
this.modal.classList.remove('hidden');
this.showForm(form);
this.clearErrors();
}
hideModal() {
this.modal.classList.add('hidden');
this.clearForms();
}
showForm(form) {
if (form === 'login') {
this.loginFormContainer.classList.remove('hidden');
this.signupFormContainer.classList.add('hidden');
this.loginUsername.focus();
} else {
this.loginFormContainer.classList.add('hidden');
this.signupFormContainer.classList.remove('hidden');
this.signupUsername.focus();
}
}
clearForms() {
this.loginForm.reset();
this.signupForm.reset();
this.clearErrors();
}
clearErrors() {
this.loginError.textContent = '';
this.signupError.textContent = '';
}
async handleLogin(e) {
e.preventDefault();
this.clearErrors();
const username = this.loginUsername.value.trim();
const password = this.loginPassword.value;
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
const data = await response.json();
if (!response.ok) {
this.loginError.textContent = data.detail || 'Login failed';
return;
}
this.setAuth(data.token, data.user);
this.hideModal();
if (data.user.username && this.game.playerNameInput) {
this.game.playerNameInput.value = data.user.username;
}
} catch (err) {
this.loginError.textContent = 'Connection error';
}
}
async handleSignup(e) {
e.preventDefault();
this.clearErrors();
const username = this.signupUsername.value.trim();
const email = this.signupEmail.value.trim() || null;
const password = this.signupPassword.value;
try {
const response = await fetch('/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, email, password }),
});
const data = await response.json();
if (!response.ok) {
this.signupError.textContent = data.detail || 'Signup failed';
return;
}
this.setAuth(data.token, data.user);
this.hideModal();
if (data.user.username && this.game.playerNameInput) {
this.game.playerNameInput.value = data.user.username;
}
} catch (err) {
this.signupError.textContent = 'Connection error';
}
}
setAuth(token, user) {
this.token = token;
this.user = user;
localStorage.setItem('authToken', token);
localStorage.setItem('authUser', JSON.stringify(user));
this.updateUI();
}
logout() {
this.token = null;
this.user = null;
localStorage.removeItem('authToken');
localStorage.removeItem('authUser');
this.updateUI();
}
updateUI() {
if (this.user) {
this.authBar?.classList.remove('hidden');
this.authButtons?.classList.add('hidden');
if (this.authUsername) {
this.authUsername.textContent = this.user.username;
}
if (this.game.playerNameInput && !this.game.playerNameInput.value) {
this.game.playerNameInput.value = this.user.username;
}
} else {
this.authBar?.classList.add('hidden');
this.authButtons?.classList.remove('hidden');
}
}
}

View File

@@ -8,10 +8,22 @@
</head>
<body>
<div id="app">
<!-- Auth Bar (shown when logged in) -->
<div id="auth-bar" class="auth-bar hidden">
<span id="auth-username"></span>
<button id="auth-logout-btn" class="btn btn-small">Logout</button>
</div>
<!-- Lobby Screen -->
<div id="lobby-screen" class="screen active">
<h1><span class="golfer-swing">🏌️</span><span class="kicked-ball"></span> <span class="golf-title">Golf</span></h1>
<p class="subtitle">6-Card Golf Card Game <button id="rules-btn" class="btn btn-small btn-rules">Rules</button></p>
<p class="subtitle">6-Card Golf Card Game <button id="rules-btn" class="btn btn-small btn-rules">Rules</button> <button id="leaderboard-btn" class="btn btn-small leaderboard-btn">Leaderboard</button></p>
<!-- Auth buttons for guests -->
<div id="auth-buttons" class="auth-buttons">
<button id="login-btn" class="btn btn-small">Login</button>
<button id="signup-btn" class="btn btn-small btn-primary">Sign Up</button>
</div>
<div class="form-group">
<label for="player-name">Your Name</label>
@@ -637,6 +649,83 @@ TOTAL: 0 + 8 + 16 = 24 points</pre>
</section>
</div>
</div>
<!-- Leaderboard Screen -->
<div id="leaderboard-screen" class="screen">
<div class="leaderboard-container">
<button id="leaderboard-back-btn" class="btn leaderboard-back-btn">&laquo; Back</button>
<div class="leaderboard-header">
<h1>Leaderboard</h1>
<p class="leaderboard-subtitle">Top players ranked by performance</p>
</div>
<div class="leaderboard-tabs" id="leaderboard-tabs">
<button class="leaderboard-tab active" data-metric="wins">Wins</button>
<button class="leaderboard-tab" data-metric="win_rate">Win Rate</button>
<button class="leaderboard-tab" data-metric="avg_score">Avg Score</button>
<button class="leaderboard-tab" data-metric="knockouts">Knockouts</button>
<button class="leaderboard-tab" data-metric="streak">Best Streak</button>
</div>
<div id="leaderboard-content">
<div class="leaderboard-loading">Loading...</div>
</div>
</div>
</div>
<!-- Replay Screen -->
<div id="replay-screen" class="screen">
<header class="replay-header">
<h2 id="replay-title">Game Replay</h2>
<div id="replay-meta" class="replay-meta"></div>
</header>
<div id="replay-board" class="replay-board-container">
<!-- Board renders here -->
</div>
<div id="replay-event-description" class="event-description"></div>
<div id="replay-controls" class="replay-controls">
<button id="replay-btn-start" class="replay-btn" title="Go to start"></button>
<button id="replay-btn-prev" class="replay-btn" title="Previous"></button>
<button id="replay-btn-play" class="replay-btn replay-btn-play" title="Play/Pause"></button>
<button id="replay-btn-next" class="replay-btn" title="Next"></button>
<button id="replay-btn-end" class="replay-btn" title="Go to end"></button>
<div class="timeline">
<input type="range" min="0" max="0" value="0" id="replay-timeline" class="timeline-slider">
<span id="replay-frame-counter" class="frame-counter">0 / 0</span>
</div>
<div class="speed-control">
<label>Speed:</label>
<select id="replay-speed" class="speed-select">
<option value="0.5">0.5x</option>
<option value="1" selected>1x</option>
<option value="2">2x</option>
<option value="4">4x</option>
</select>
</div>
</div>
<div class="replay-actions">
<button id="replay-btn-share" class="btn btn-small">Share Replay</button>
<button id="replay-btn-export" class="btn btn-small">Export JSON</button>
<button id="replay-btn-back" class="btn btn-small btn-secondary">Back to Menu</button>
</div>
</div>
</div>
<!-- Player Stats Modal -->
<div id="player-stats-modal" class="modal player-stats-modal hidden">
<div class="modal-content">
<button class="modal-close-btn" id="player-stats-close">&times;</button>
<div id="player-stats-content">
<div class="leaderboard-loading">Loading...</div>
</div>
</div>
</div>
<!-- CPU Select Modal -->
@@ -651,9 +740,53 @@ TOTAL: 0 + 8 + 16 = 24 points</pre>
</div>
</div>
<!-- Auth Modal -->
<div id="auth-modal" class="modal hidden">
<div class="modal-content modal-auth">
<button id="auth-modal-close" class="modal-close-btn">&times;</button>
<!-- Login Form -->
<div id="login-form-container">
<h3>Login</h3>
<form id="login-form">
<div class="form-group">
<input type="text" id="login-username" placeholder="Username" required>
</div>
<div class="form-group">
<input type="password" id="login-password" placeholder="Password" required>
</div>
<p id="login-error" class="error"></p>
<button type="submit" class="btn btn-primary btn-full">Login</button>
</form>
<p class="auth-switch">Don't have an account? <a href="#" id="show-signup">Sign up</a></p>
</div>
<!-- Signup Form -->
<div id="signup-form-container" class="hidden">
<h3>Sign Up</h3>
<form id="signup-form">
<div class="form-group">
<input type="text" id="signup-username" placeholder="Username" required minlength="3" maxlength="20">
</div>
<div class="form-group">
<input type="email" id="signup-email" placeholder="Email (optional)">
</div>
<div class="form-group">
<input type="password" id="signup-password" placeholder="Password" required minlength="8">
</div>
<p id="signup-error" class="error"></p>
<button type="submit" class="btn btn-primary btn-full">Create Account</button>
</form>
<p class="auth-switch">Already have an account? <a href="#" id="show-login">Login</a></p>
</div>
</div>
</div>
<script src="card-manager.js"></script>
<script src="state-differ.js"></script>
<script src="animation-queue.js"></script>
<script src="leaderboard.js"></script>
<script src="replay.js"></script>
<script src="app.js"></script>
</body>
</html>

314
client/leaderboard.js Normal file
View File

@@ -0,0 +1,314 @@
/**
* Leaderboard component for Golf game.
* Handles leaderboard display, metric switching, and player stats modal.
*/
class LeaderboardComponent {
constructor() {
this.currentMetric = 'wins';
this.cache = new Map();
this.cacheTimeout = 60000; // 1 minute cache
this.elements = {
screen: document.getElementById('leaderboard-screen'),
backBtn: document.getElementById('leaderboard-back-btn'),
openBtn: document.getElementById('leaderboard-btn'),
tabs: document.getElementById('leaderboard-tabs'),
content: document.getElementById('leaderboard-content'),
statsModal: document.getElementById('player-stats-modal'),
statsContent: document.getElementById('player-stats-content'),
statsClose: document.getElementById('player-stats-close'),
};
this.metricLabels = {
wins: 'Total Wins',
win_rate: 'Win Rate',
avg_score: 'Avg Score',
knockouts: 'Knockouts',
streak: 'Best Streak',
};
this.metricFormats = {
wins: (v) => v.toLocaleString(),
win_rate: (v) => `${v.toFixed(1)}%`,
avg_score: (v) => v.toFixed(1),
knockouts: (v) => v.toLocaleString(),
streak: (v) => v.toLocaleString(),
};
this.init();
}
init() {
// Open leaderboard
this.elements.openBtn?.addEventListener('click', () => this.show());
// Back button
this.elements.backBtn?.addEventListener('click', () => this.hide());
// Tab switching
this.elements.tabs?.addEventListener('click', (e) => {
if (e.target.classList.contains('leaderboard-tab')) {
this.switchMetric(e.target.dataset.metric);
}
});
// Close player stats modal
this.elements.statsClose?.addEventListener('click', () => this.closePlayerStats());
this.elements.statsModal?.addEventListener('click', (e) => {
if (e.target === this.elements.statsModal) {
this.closePlayerStats();
}
});
// Handle clicks on player names
this.elements.content?.addEventListener('click', (e) => {
const playerLink = e.target.closest('.player-link');
if (playerLink) {
const userId = playerLink.dataset.userId;
if (userId) {
this.showPlayerStats(userId);
}
}
});
}
show() {
// Hide other screens, show leaderboard
document.querySelectorAll('.screen').forEach(s => s.classList.remove('active'));
this.elements.screen.classList.add('active');
this.loadLeaderboard(this.currentMetric);
}
hide() {
this.elements.screen.classList.remove('active');
document.getElementById('lobby-screen').classList.add('active');
}
switchMetric(metric) {
if (metric === this.currentMetric) return;
this.currentMetric = metric;
// Update tab styling
this.elements.tabs.querySelectorAll('.leaderboard-tab').forEach(tab => {
tab.classList.toggle('active', tab.dataset.metric === metric);
});
this.loadLeaderboard(metric);
}
async loadLeaderboard(metric) {
// Check cache
const cacheKey = `leaderboard_${metric}`;
const cached = this.cache.get(cacheKey);
if (cached && Date.now() - cached.time < this.cacheTimeout) {
this.renderLeaderboard(cached.data, metric);
return;
}
// Show loading
this.elements.content.innerHTML = '<div class="leaderboard-loading">Loading...</div>';
try {
const response = await fetch(`/api/stats/leaderboard?metric=${metric}&limit=50`);
if (!response.ok) throw new Error('Failed to load leaderboard');
const data = await response.json();
// Cache the result
this.cache.set(cacheKey, { data, time: Date.now() });
this.renderLeaderboard(data, metric);
} catch (error) {
console.error('Error loading leaderboard:', error);
this.elements.content.innerHTML = `
<div class="leaderboard-empty">
<p>Failed to load leaderboard</p>
<button class="btn btn-small btn-secondary" onclick="leaderboard.loadLeaderboard('${metric}')">Retry</button>
</div>
`;
}
}
renderLeaderboard(data, metric) {
const entries = data.entries || [];
if (entries.length === 0) {
this.elements.content.innerHTML = `
<div class="leaderboard-empty">
<p>No players on the leaderboard yet.</p>
<p>Play 5+ games to appear here!</p>
</div>
`;
return;
}
const formatValue = this.metricFormats[metric] || (v => v);
const currentUserId = this.getCurrentUserId();
let html = `
<table class="leaderboard-table">
<thead>
<tr>
<th class="rank-col">#</th>
<th class="username-col">Player</th>
<th class="value-col">${this.metricLabels[metric]}</th>
<th class="games-col">Games</th>
</tr>
</thead>
<tbody>
`;
entries.forEach(entry => {
const isMe = entry.user_id === currentUserId;
const medal = this.getMedal(entry.rank);
html += `
<tr class="${isMe ? 'my-row' : ''}">
<td class="rank-col">${medal || entry.rank}</td>
<td class="username-col">
<span class="player-link" data-user-id="${entry.user_id}">
${this.escapeHtml(entry.username)}${isMe ? ' (you)' : ''}
</span>
</td>
<td class="value-col">${formatValue(entry.value)}</td>
<td class="games-col">${entry.games_played}</td>
</tr>
`;
});
html += '</tbody></table>';
this.elements.content.innerHTML = html;
}
getMedal(rank) {
switch (rank) {
case 1: return '<span class="medal">&#x1F947;</span>';
case 2: return '<span class="medal">&#x1F948;</span>';
case 3: return '<span class="medal">&#x1F949;</span>';
default: return null;
}
}
async showPlayerStats(userId) {
this.elements.statsModal.classList.remove('hidden');
this.elements.statsContent.innerHTML = '<div class="leaderboard-loading">Loading...</div>';
try {
const [statsRes, achievementsRes] = await Promise.all([
fetch(`/api/stats/players/${userId}`),
fetch(`/api/stats/players/${userId}/achievements`),
]);
if (!statsRes.ok) throw new Error('Failed to load player stats');
const stats = await statsRes.json();
const achievements = achievementsRes.ok ? await achievementsRes.json() : { achievements: [] };
this.renderPlayerStats(stats, achievements.achievements || []);
} catch (error) {
console.error('Error loading player stats:', error);
this.elements.statsContent.innerHTML = `
<div class="leaderboard-empty">
<p>Failed to load player stats</p>
</div>
`;
}
}
renderPlayerStats(stats, achievements) {
const currentUserId = this.getCurrentUserId();
const isMe = stats.user_id === currentUserId;
let html = `
<div class="player-stats-header">
<h3>${this.escapeHtml(stats.username)}${isMe ? ' (you)' : ''}</h3>
${stats.games_played >= 5 ? '<p class="rank-badge">Ranked Player</p>' : '<p class="rank-badge">Unranked (needs 5+ games)</p>'}
</div>
<div class="stats-grid">
<div class="stat-item">
<div class="stat-value">${stats.games_won}</div>
<div class="stat-label">Wins</div>
</div>
<div class="stat-item">
<div class="stat-value">${stats.win_rate.toFixed(1)}%</div>
<div class="stat-label">Win Rate</div>
</div>
<div class="stat-item">
<div class="stat-value">${stats.games_played}</div>
<div class="stat-label">Games</div>
</div>
<div class="stat-item">
<div class="stat-value">${stats.avg_score.toFixed(1)}</div>
<div class="stat-label">Avg Score</div>
</div>
<div class="stat-item">
<div class="stat-value">${stats.best_round_score ?? '-'}</div>
<div class="stat-label">Best Round</div>
</div>
<div class="stat-item">
<div class="stat-value">${stats.knockouts}</div>
<div class="stat-label">Knockouts</div>
</div>
<div class="stat-item">
<div class="stat-value">${stats.best_win_streak}</div>
<div class="stat-label">Best Streak</div>
</div>
<div class="stat-item">
<div class="stat-value">${stats.rounds_played}</div>
<div class="stat-label">Rounds</div>
</div>
</div>
`;
// Achievements section
if (achievements.length > 0) {
html += `
<div class="achievements-section">
<h4>Achievements (${achievements.length})</h4>
<div class="achievements-grid">
`;
achievements.forEach(a => {
html += `
<div class="achievement-badge" title="${this.escapeHtml(a.description)}">
<span class="icon">${a.icon}</span>
<span class="name">${this.escapeHtml(a.name)}</span>
</div>
`;
});
html += '</div></div>';
}
this.elements.statsContent.innerHTML = html;
}
closePlayerStats() {
this.elements.statsModal.classList.add('hidden');
}
getCurrentUserId() {
// Get user ID from auth state if available
if (window.authState && window.authState.user) {
return window.authState.user.id;
}
return null;
}
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Public method to clear cache (e.g., after game ends)
clearCache() {
this.cache.clear();
}
}
// Initialize global leaderboard instance
const leaderboard = new LeaderboardComponent();

587
client/replay.js Normal file
View File

@@ -0,0 +1,587 @@
// Golf Card Game - Replay Viewer
class ReplayViewer {
constructor() {
this.frames = [];
this.metadata = null;
this.currentFrame = 0;
this.isPlaying = false;
this.playbackSpeed = 1.0;
this.playInterval = null;
this.gameId = null;
this.shareCode = null;
this.initElements();
this.bindEvents();
}
initElements() {
this.replayScreen = document.getElementById('replay-screen');
this.replayTitle = document.getElementById('replay-title');
this.replayMeta = document.getElementById('replay-meta');
this.replayBoard = document.getElementById('replay-board');
this.eventDescription = document.getElementById('replay-event-description');
this.controlsContainer = document.getElementById('replay-controls');
this.frameCounter = document.getElementById('replay-frame-counter');
this.timelineSlider = document.getElementById('replay-timeline');
this.speedSelect = document.getElementById('replay-speed');
// Control buttons
this.btnStart = document.getElementById('replay-btn-start');
this.btnPrev = document.getElementById('replay-btn-prev');
this.btnPlay = document.getElementById('replay-btn-play');
this.btnNext = document.getElementById('replay-btn-next');
this.btnEnd = document.getElementById('replay-btn-end');
// Action buttons
this.btnShare = document.getElementById('replay-btn-share');
this.btnExport = document.getElementById('replay-btn-export');
this.btnBack = document.getElementById('replay-btn-back');
}
bindEvents() {
if (this.btnStart) this.btnStart.onclick = () => this.goToFrame(0);
if (this.btnEnd) this.btnEnd.onclick = () => this.goToFrame(this.frames.length - 1);
if (this.btnPrev) this.btnPrev.onclick = () => this.prevFrame();
if (this.btnNext) this.btnNext.onclick = () => this.nextFrame();
if (this.btnPlay) this.btnPlay.onclick = () => this.togglePlay();
if (this.timelineSlider) {
this.timelineSlider.oninput = (e) => {
this.goToFrame(parseInt(e.target.value));
};
}
if (this.speedSelect) {
this.speedSelect.onchange = (e) => {
this.playbackSpeed = parseFloat(e.target.value);
if (this.isPlaying) {
this.stopPlayback();
this.startPlayback();
}
};
}
if (this.btnShare) {
this.btnShare.onclick = () => this.showShareDialog();
}
if (this.btnExport) {
this.btnExport.onclick = () => this.exportGame();
}
if (this.btnBack) {
this.btnBack.onclick = () => this.hide();
}
// Keyboard controls
document.addEventListener('keydown', (e) => {
if (!this.replayScreen || !this.replayScreen.classList.contains('active')) return;
switch (e.key) {
case 'ArrowLeft':
e.preventDefault();
this.prevFrame();
break;
case 'ArrowRight':
e.preventDefault();
this.nextFrame();
break;
case ' ':
e.preventDefault();
this.togglePlay();
break;
case 'Home':
e.preventDefault();
this.goToFrame(0);
break;
case 'End':
e.preventDefault();
this.goToFrame(this.frames.length - 1);
break;
}
});
}
async loadReplay(gameId) {
this.gameId = gameId;
this.shareCode = null;
try {
const token = localStorage.getItem('authToken');
const headers = token ? { 'Authorization': `Bearer ${token}` } : {};
const response = await fetch(`/api/replay/game/${gameId}`, { headers });
if (!response.ok) {
throw new Error('Failed to load replay');
}
const data = await response.json();
this.frames = data.frames;
this.metadata = data.metadata;
this.currentFrame = 0;
this.show();
this.render();
this.updateControls();
} catch (error) {
console.error('Failed to load replay:', error);
this.showError('Failed to load replay. You may not have permission to view this game.');
}
}
async loadSharedReplay(shareCode) {
this.shareCode = shareCode;
this.gameId = null;
try {
const response = await fetch(`/api/replay/shared/${shareCode}`);
if (!response.ok) {
throw new Error('Replay not found or expired');
}
const data = await response.json();
this.frames = data.frames;
this.metadata = data.metadata;
this.gameId = data.game_id;
this.currentFrame = 0;
// Update title with share info
if (data.title) {
this.replayTitle.textContent = data.title;
}
this.show();
this.render();
this.updateControls();
} catch (error) {
console.error('Failed to load shared replay:', error);
this.showError('Replay not found or has expired.');
}
}
show() {
// Hide other screens
document.querySelectorAll('.screen').forEach(s => s.classList.remove('active'));
this.replayScreen.classList.add('active');
// Update title
if (!this.shareCode && this.metadata) {
this.replayTitle.textContent = 'Game Replay';
}
// Update meta
if (this.metadata) {
const players = this.metadata.players.join(' vs ');
const duration = this.formatDuration(this.metadata.duration);
const rounds = `${this.metadata.total_rounds} hole${this.metadata.total_rounds > 1 ? 's' : ''}`;
this.replayMeta.innerHTML = `<span>${players}</span> | <span>${rounds}</span> | <span>${duration}</span>`;
}
}
hide() {
this.stopPlayback();
this.replayScreen.classList.remove('active');
// Return to lobby
document.getElementById('lobby-screen').classList.add('active');
}
render() {
if (!this.frames.length) return;
const frame = this.frames[this.currentFrame];
const state = frame.state;
this.renderBoard(state);
this.renderEventInfo(frame);
this.updateTimeline();
}
renderBoard(state) {
const currentPlayerId = state.current_player_id;
// Build HTML for all players
let html = '<div class="replay-players">';
state.players.forEach((player, idx) => {
const isCurrent = player.id === currentPlayerId;
html += `
<div class="replay-player ${isCurrent ? 'is-current' : ''}">
<div class="replay-player-header">
<span class="replay-player-name">${this.escapeHtml(player.name)}</span>
<span class="replay-player-score">Score: ${player.score} | Total: ${player.total_score}</span>
</div>
<div class="replay-player-cards">
${this.renderPlayerCards(player.cards)}
</div>
</div>
`;
});
html += '</div>';
// Center area (deck and discard)
html += `
<div class="replay-center">
<div class="replay-deck">
<div class="card card-back">
<span class="deck-count">${state.deck_remaining}</span>
</div>
</div>
<div class="replay-discard">
${state.discard_top ? this.renderCard(state.discard_top, true) : '<div class="card card-empty"></div>'}
</div>
${state.drawn_card ? `
<div class="replay-drawn">
<span class="drawn-label">Drawn:</span>
${this.renderCard(state.drawn_card, true)}
</div>
` : ''}
</div>
`;
// Game info
html += `
<div class="replay-info">
<span>Round ${state.current_round} / ${state.total_rounds}</span>
<span>Phase: ${this.formatPhase(state.phase)}</span>
</div>
`;
this.replayBoard.innerHTML = html;
}
renderPlayerCards(cards) {
let html = '<div class="replay-cards-grid">';
// Render as 2 rows x 3 columns
for (let row = 0; row < 2; row++) {
html += '<div class="replay-cards-row">';
for (let col = 0; col < 3; col++) {
const idx = row * 3 + col;
const card = cards[idx];
if (card) {
html += this.renderCard(card, card.face_up);
} else {
html += '<div class="card card-empty"></div>';
}
}
html += '</div>';
}
html += '</div>';
return html;
}
renderCard(card, revealed = false) {
if (!revealed || !card.face_up) {
return '<div class="card card-back"></div>';
}
const suit = card.suit;
const rank = card.rank;
const isRed = suit === 'hearts' || suit === 'diamonds';
const suitSymbol = { hearts: '♥', diamonds: '♦', clubs: '♣', spades: '♠' }[suit] || '';
return `
<div class="card ${isRed ? 'card-red' : 'card-black'}">
<span class="card-rank">${rank}</span>
<span class="card-suit">${suitSymbol}</span>
</div>
`;
}
renderEventInfo(frame) {
const descriptions = {
'game_created': 'Game created',
'player_joined': `${frame.event_data?.player_name || 'Player'} joined`,
'player_left': `Player left the game`,
'game_started': 'Game started',
'round_started': `Round ${frame.event_data?.round || ''} started`,
'initial_flip': `${this.getPlayerName(frame.player_id)} revealed initial cards`,
'card_drawn': `${this.getPlayerName(frame.player_id)} drew from ${frame.event_data?.source || 'deck'}`,
'card_swapped': `${this.getPlayerName(frame.player_id)} swapped a card`,
'card_discarded': `${this.getPlayerName(frame.player_id)} discarded`,
'card_flipped': `${this.getPlayerName(frame.player_id)} flipped a card`,
'flip_skipped': `${this.getPlayerName(frame.player_id)} skipped flip`,
'knock_early': `${this.getPlayerName(frame.player_id)} knocked early!`,
'round_ended': `Round ended`,
'game_ended': `Game over! ${this.metadata?.winner || 'Winner'} wins!`,
};
const desc = descriptions[frame.event_type] || frame.event_type;
const time = this.formatTimestamp(frame.timestamp);
this.eventDescription.innerHTML = `
<span class="event-time">${time}</span>
<span class="event-text">${desc}</span>
`;
}
getPlayerName(playerId) {
if (!playerId || !this.frames.length) return 'Player';
const currentState = this.frames[this.currentFrame]?.state;
if (!currentState) return 'Player';
const player = currentState.players.find(p => p.id === playerId);
return player?.name || 'Player';
}
updateControls() {
if (this.timelineSlider) {
this.timelineSlider.max = Math.max(0, this.frames.length - 1);
this.timelineSlider.value = this.currentFrame;
}
// Show/hide share button based on whether we own the game
if (this.btnShare) {
this.btnShare.style.display = this.gameId && localStorage.getItem('authToken') ? '' : 'none';
}
}
updateTimeline() {
if (this.timelineSlider) {
this.timelineSlider.value = this.currentFrame;
}
if (this.frameCounter) {
this.frameCounter.textContent = `${this.currentFrame + 1} / ${this.frames.length}`;
}
}
goToFrame(index) {
this.currentFrame = Math.max(0, Math.min(index, this.frames.length - 1));
this.render();
}
nextFrame() {
if (this.currentFrame < this.frames.length - 1) {
this.currentFrame++;
this.render();
} else if (this.isPlaying) {
this.togglePlay(); // Stop at end
}
}
prevFrame() {
if (this.currentFrame > 0) {
this.currentFrame--;
this.render();
}
}
togglePlay() {
this.isPlaying = !this.isPlaying;
if (this.btnPlay) {
this.btnPlay.textContent = this.isPlaying ? '⏸' : '▶';
}
if (this.isPlaying) {
this.startPlayback();
} else {
this.stopPlayback();
}
}
startPlayback() {
const baseInterval = 1000; // 1 second between frames
this.playInterval = setInterval(() => {
this.nextFrame();
}, baseInterval / this.playbackSpeed);
}
stopPlayback() {
if (this.playInterval) {
clearInterval(this.playInterval);
this.playInterval = null;
}
}
async showShareDialog() {
if (!this.gameId) return;
const modal = document.createElement('div');
modal.className = 'modal active';
modal.id = 'share-modal';
modal.innerHTML = `
<div class="modal-content">
<h3>Share This Game</h3>
<div class="form-group">
<label for="share-title">Title (optional)</label>
<input type="text" id="share-title" placeholder="Epic comeback win!">
</div>
<div class="form-group">
<label for="share-expiry">Expires in</label>
<select id="share-expiry">
<option value="">Never</option>
<option value="7">7 days</option>
<option value="30">30 days</option>
<option value="90">90 days</option>
</select>
</div>
<div id="share-result" class="hidden">
<p>Share this link:</p>
<div class="share-link-container">
<input type="text" id="share-link" readonly>
<button class="btn btn-small" id="share-copy-btn">Copy</button>
</div>
</div>
<div class="modal-actions">
<button class="btn btn-primary" id="share-generate-btn">Generate Link</button>
<button class="btn btn-secondary" id="share-cancel-btn">Cancel</button>
</div>
</div>
`;
document.body.appendChild(modal);
const generateBtn = modal.querySelector('#share-generate-btn');
const cancelBtn = modal.querySelector('#share-cancel-btn');
const copyBtn = modal.querySelector('#share-copy-btn');
cancelBtn.onclick = () => modal.remove();
generateBtn.onclick = async () => {
const title = modal.querySelector('#share-title').value || null;
const expiry = modal.querySelector('#share-expiry').value || null;
try {
const token = localStorage.getItem('authToken');
const response = await fetch(`/api/replay/game/${this.gameId}/share`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({
title,
expires_days: expiry ? parseInt(expiry) : null,
}),
});
if (!response.ok) {
throw new Error('Failed to create share link');
}
const data = await response.json();
const fullUrl = `${window.location.origin}/replay/${data.share_code}`;
modal.querySelector('#share-link').value = fullUrl;
modal.querySelector('#share-result').classList.remove('hidden');
generateBtn.classList.add('hidden');
} catch (error) {
console.error('Failed to create share link:', error);
alert('Failed to create share link');
}
};
copyBtn.onclick = () => {
const input = modal.querySelector('#share-link');
input.select();
document.execCommand('copy');
copyBtn.textContent = 'Copied!';
setTimeout(() => copyBtn.textContent = 'Copy', 2000);
};
}
async exportGame() {
if (!this.gameId) return;
try {
const token = localStorage.getItem('authToken');
const response = await fetch(`/api/replay/game/${this.gameId}/export`, {
headers: {
'Authorization': `Bearer ${token}`,
},
});
if (!response.ok) {
throw new Error('Failed to export game');
}
const data = await response.json();
// Download as JSON file
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `golf-game-${this.gameId.substring(0, 8)}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (error) {
console.error('Failed to export game:', error);
alert('Failed to export game');
}
}
showError(message) {
this.show();
this.replayBoard.innerHTML = `
<div class="replay-error">
<p>${this.escapeHtml(message)}</p>
<button class="btn btn-primary" onclick="replayViewer.hide()">Back to Lobby</button>
</div>
`;
}
formatDuration(seconds) {
if (!seconds) return '0:00';
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
formatTimestamp(seconds) {
return this.formatDuration(seconds);
}
formatPhase(phase) {
const phases = {
'waiting': 'Waiting',
'initial_flip': 'Initial Flip',
'playing': 'Playing',
'final_turn': 'Final Turn',
'round_over': 'Round Over',
'game_over': 'Game Over',
};
return phases[phase] || phase;
}
escapeHtml(str) {
if (!str) return '';
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
}
// Global instance
const replayViewer = new ReplayViewer();
// Check URL for replay links
document.addEventListener('DOMContentLoaded', () => {
const path = window.location.pathname;
// Handle /replay/{share_code} URLs
if (path.startsWith('/replay/')) {
const shareCode = path.substring(8);
if (shareCode) {
replayViewer.loadSharedReplay(shareCode);
}
}
});
// Export for use in other modules
if (typeof module !== 'undefined' && module.exports) {
module.exports = { ReplayViewer, replayViewer };
}

View File

@@ -2830,3 +2830,925 @@ input::placeholder {
font-size: 0.8rem;
}
}
/* ===========================================
AUTH COMPONENTS
=========================================== */
/* Auth bar (top right when logged in) */
.auth-bar {
position: fixed;
top: 10px;
right: 15px;
display: flex;
align-items: center;
gap: 10px;
background: rgba(0, 0, 0, 0.4);
padding: 6px 12px;
border-radius: 20px;
font-size: 0.85rem;
z-index: 100;
}
.auth-bar.hidden {
display: none;
}
#auth-username {
color: #f4a460;
font-weight: 500;
}
/* Auth buttons in lobby */
.auth-buttons {
display: flex;
justify-content: center;
gap: 10px;
margin-bottom: 20px;
}
.auth-buttons.hidden {
display: none;
}
/* Auth modal */
.modal-auth {
max-width: 320px;
padding: 25px;
}
.modal-auth h3 {
text-align: center;
margin-bottom: 20px;
color: #f4a460;
font-size: 1.3rem;
}
.modal-auth .form-group {
margin-bottom: 15px;
}
.modal-auth input {
width: 100%;
padding: 12px 15px;
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 8px;
color: white;
font-size: 1rem;
}
.modal-auth input::placeholder {
color: rgba(255, 255, 255, 0.4);
}
.modal-auth input:focus {
outline: none;
border-color: #f4a460;
}
.btn-full {
width: 100%;
}
.auth-switch {
text-align: center;
margin-top: 15px;
font-size: 0.85rem;
color: rgba(255, 255, 255, 0.6);
}
.auth-switch a {
color: #f4a460;
text-decoration: none;
}
.auth-switch a:hover {
text-decoration: underline;
}
.modal-close-btn {
position: absolute;
top: 10px;
right: 12px;
background: none;
border: none;
color: rgba(255, 255, 255, 0.5);
font-size: 1.5rem;
cursor: pointer;
line-height: 1;
padding: 0;
}
.modal-close-btn:hover {
color: white;
}
.modal-auth .error {
color: #f87171;
font-size: 0.85rem;
margin: 10px 0;
text-align: center;
}
/* ===========================================
LEADERBOARD COMPONENTS
=========================================== */
/* Leaderboard button in lobby */
.leaderboard-btn {
background: rgba(244, 164, 96, 0.2);
border: 1px solid #f4a460;
color: #ffb366;
cursor: pointer;
font-size: 0.65rem;
padding: 2px 8px;
margin-left: 8px;
vertical-align: middle;
border-radius: 3px;
font-weight: 600;
transition: background 0.2s, border-color 0.2s;
}
.leaderboard-btn:hover {
background: rgba(244, 164, 96, 0.35);
border-color: #ffb366;
color: #ffc880;
}
/* Leaderboard Screen */
#leaderboard-screen {
max-width: 800px;
margin: 0 auto;
padding: 10px 20px;
}
.leaderboard-container {
background: rgba(0, 0, 0, 0.3);
border-radius: 12px;
padding: 20px 25px;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.leaderboard-header {
text-align: center;
margin-bottom: 20px;
}
.leaderboard-header h1 {
color: #f4a460;
font-size: 1.8rem;
margin-bottom: 5px;
}
.leaderboard-subtitle {
color: rgba(255, 255, 255, 0.6);
font-size: 0.9rem;
margin: 0;
}
/* Leaderboard back button */
.leaderboard-back-btn {
padding: 4px 12px;
font-size: 0.8rem;
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.3);
color: rgba(255, 255, 255, 0.7);
margin-bottom: 15px;
}
.leaderboard-back-btn:hover {
background: rgba(255, 255, 255, 0.1);
color: #fff;
border-color: rgba(255, 255, 255, 0.5);
}
/* Metric tabs */
.leaderboard-tabs {
display: flex;
gap: 8px;
margin-bottom: 20px;
flex-wrap: wrap;
justify-content: center;
}
.leaderboard-tab {
padding: 10px 18px;
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 8px;
color: rgba(255, 255, 255, 0.7);
cursor: pointer;
font-size: 0.9rem;
font-weight: 500;
transition: all 0.2s;
}
.leaderboard-tab:hover {
background: rgba(244, 164, 96, 0.15);
border-color: rgba(244, 164, 96, 0.3);
color: #fff;
}
.leaderboard-tab.active {
background: rgba(244, 164, 96, 0.25);
border-color: #f4a460;
color: #f4a460;
font-weight: 600;
}
/* Leaderboard table */
.leaderboard-table {
width: 100%;
border-collapse: collapse;
}
.leaderboard-table th,
.leaderboard-table td {
padding: 12px 15px;
text-align: left;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.leaderboard-table th {
background: rgba(244, 164, 96, 0.15);
color: #f4a460;
font-weight: 600;
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.leaderboard-table tbody tr:hover {
background: rgba(255, 255, 255, 0.05);
}
.leaderboard-table .rank-col {
width: 50px;
text-align: center;
font-weight: 700;
font-size: 1rem;
}
.leaderboard-table .rank-col .medal {
font-size: 1.2rem;
}
.leaderboard-table .username-col {
font-weight: 500;
}
.leaderboard-table .value-col {
text-align: right;
font-weight: 600;
color: #f4a460;
}
.leaderboard-table .games-col {
text-align: right;
color: rgba(255, 255, 255, 0.6);
font-size: 0.85rem;
}
/* Player profile link */
.player-link {
color: inherit;
text-decoration: none;
cursor: pointer;
}
.player-link:hover {
color: #f4a460;
text-decoration: underline;
}
/* Empty state */
.leaderboard-empty {
text-align: center;
padding: 40px 20px;
color: rgba(255, 255, 255, 0.5);
}
.leaderboard-empty p {
margin-bottom: 10px;
}
/* Loading state */
.leaderboard-loading {
text-align: center;
padding: 40px 20px;
color: rgba(255, 255, 255, 0.6);
}
.leaderboard-loading::after {
content: '';
display: inline-block;
width: 20px;
height: 20px;
border: 2px solid rgba(244, 164, 96, 0.3);
border-top-color: #f4a460;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-left: 10px;
vertical-align: middle;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Player Stats Modal */
.player-stats-modal .modal-content {
max-width: 450px;
}
.player-stats-header {
text-align: center;
margin-bottom: 20px;
padding-bottom: 15px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.player-stats-header h3 {
color: #f4a460;
margin: 0 0 5px 0;
}
.player-stats-header .rank-badge {
font-size: 0.85rem;
color: rgba(255, 255, 255, 0.6);
}
.stats-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
margin-bottom: 20px;
}
.stat-item {
background: rgba(0, 0, 0, 0.2);
border-radius: 8px;
padding: 12px;
text-align: center;
}
.stat-value {
font-size: 1.4rem;
font-weight: 700;
color: #f4a460;
margin-bottom: 4px;
}
.stat-label {
font-size: 0.75rem;
color: rgba(255, 255, 255, 0.6);
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* Achievements section in player stats */
.achievements-section {
margin-top: 15px;
padding-top: 15px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
.achievements-section h4 {
font-size: 0.9rem;
color: rgba(255, 255, 255, 0.7);
margin-bottom: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.achievements-grid {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.achievement-badge {
display: flex;
align-items: center;
gap: 6px;
background: rgba(244, 164, 96, 0.15);
border: 1px solid rgba(244, 164, 96, 0.3);
border-radius: 20px;
padding: 6px 12px;
font-size: 0.85rem;
}
.achievement-badge .icon {
font-size: 1rem;
}
.achievement-badge .name {
color: #f4a460;
font-weight: 500;
}
.achievement-badge.locked {
background: rgba(0, 0, 0, 0.2);
border-color: rgba(255, 255, 255, 0.1);
opacity: 0.5;
}
.achievement-badge.locked .icon {
filter: grayscale(1);
}
.achievement-badge.locked .name {
color: rgba(255, 255, 255, 0.5);
}
/* My stats badge in leaderboard */
.my-row {
background: rgba(244, 164, 96, 0.1) !important;
border-left: 3px solid #f4a460;
}
/* Mobile adjustments */
@media (max-width: 600px) {
#leaderboard-screen {
padding: 10px;
}
.leaderboard-container {
padding: 15px;
}
.leaderboard-tabs {
gap: 6px;
}
.leaderboard-tab {
padding: 8px 14px;
font-size: 0.85rem;
}
.leaderboard-table th,
.leaderboard-table td {
padding: 10px 8px;
font-size: 0.9rem;
}
.stats-grid {
grid-template-columns: repeat(2, 1fr);
gap: 8px;
}
.stat-item {
padding: 10px 8px;
}
.stat-value {
font-size: 1.2rem;
}
}
/* ===========================================
REPLAY VIEWER
=========================================== */
#replay-screen {
max-width: 900px;
margin: 0 auto;
padding: 15px 20px;
}
.replay-header {
text-align: center;
margin-bottom: 20px;
}
#replay-title {
color: #f4a460;
font-size: 1.5rem;
margin-bottom: 8px;
}
.replay-meta {
color: rgba(255, 255, 255, 0.7);
font-size: 0.9rem;
}
.replay-meta span {
display: inline-block;
}
/* Replay Board */
.replay-board-container {
background: rgba(0, 0, 0, 0.3);
border-radius: 12px;
padding: 20px;
margin-bottom: 15px;
min-height: 300px;
}
.replay-players {
display: flex;
flex-wrap: wrap;
gap: 20px;
justify-content: center;
margin-bottom: 20px;
}
.replay-player {
background: rgba(255, 255, 255, 0.05);
border-radius: 10px;
padding: 12px;
min-width: 180px;
border: 2px solid transparent;
transition: border-color 0.2s;
}
.replay-player.is-current {
border-color: #f4a460;
box-shadow: 0 0 15px rgba(244, 164, 96, 0.3);
}
.replay-player-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
padding-bottom: 8px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.replay-player-name {
font-weight: 600;
color: #fff;
}
.replay-player-score {
font-size: 0.8rem;
color: rgba(255, 255, 255, 0.6);
}
.replay-cards-grid {
display: flex;
flex-direction: column;
gap: 4px;
}
.replay-cards-row {
display: flex;
gap: 4px;
justify-content: center;
}
/* Replay cards - smaller version */
.replay-board-container .card {
width: 45px;
height: 63px;
font-size: 0.9rem;
border-radius: 4px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-weight: bold;
}
.replay-board-container .card-back {
background-color: #c41e3a;
background-image:
linear-gradient(45deg, rgba(255,255,255,0.15) 25%, transparent 25%),
linear-gradient(-45deg, rgba(255,255,255,0.15) 25%, transparent 25%);
background-size: 6px 6px;
border: 2px solid #8b1528;
}
.replay-board-container .card-red {
background: linear-gradient(145deg, #fff 0%, #f5f5f5 100%);
border: 1px solid #ddd;
color: #c0392b;
}
.replay-board-container .card-black {
background: linear-gradient(145deg, #fff 0%, #f5f5f5 100%);
border: 1px solid #ddd;
color: #2c3e50;
}
.replay-board-container .card-empty {
background: rgba(255, 255, 255, 0.1);
border: 1px dashed rgba(255, 255, 255, 0.2);
}
/* Replay center area */
.replay-center {
display: flex;
justify-content: center;
align-items: center;
gap: 30px;
padding: 20px;
background: rgba(0, 0, 0, 0.2);
border-radius: 8px;
margin-bottom: 15px;
}
.replay-deck .card,
.replay-discard .card {
width: 55px;
height: 77px;
}
.replay-deck .deck-count {
position: absolute;
bottom: -20px;
left: 50%;
transform: translateX(-50%);
font-size: 0.75rem;
color: rgba(255, 255, 255, 0.6);
}
.replay-deck {
position: relative;
}
.replay-drawn {
display: flex;
align-items: center;
gap: 8px;
}
.drawn-label {
font-size: 0.8rem;
color: #f4a460;
}
/* Replay info */
.replay-info {
display: flex;
justify-content: center;
gap: 20px;
font-size: 0.85rem;
color: rgba(255, 255, 255, 0.6);
}
/* Event description */
.event-description {
text-align: center;
padding: 12px;
margin-bottom: 15px;
background: rgba(0, 0, 0, 0.2);
border-radius: 8px;
min-height: 50px;
display: flex;
align-items: center;
justify-content: center;
gap: 15px;
}
.event-time {
font-family: monospace;
color: rgba(255, 255, 255, 0.5);
font-size: 0.85rem;
}
.event-text {
font-size: 1rem;
color: #fff;
}
/* Replay Controls */
.replay-controls {
display: flex;
align-items: center;
gap: 12px;
padding: 15px;
background: rgba(0, 0, 0, 0.3);
border-radius: 10px;
flex-wrap: wrap;
justify-content: center;
margin-bottom: 15px;
}
.replay-btn {
width: 40px;
height: 40px;
border-radius: 50%;
border: none;
background: rgba(244, 164, 96, 0.2);
color: #f4a460;
cursor: pointer;
font-size: 1rem;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.replay-btn:hover {
background: rgba(244, 164, 96, 0.4);
transform: scale(1.05);
}
.replay-btn-play {
width: 50px;
height: 50px;
font-size: 1.3rem;
background: #f4a460;
color: #1a472a;
}
.replay-btn-play:hover {
background: #ffb366;
}
/* Timeline */
.timeline {
flex: 1;
min-width: 200px;
display: flex;
align-items: center;
gap: 10px;
}
.timeline-slider {
flex: 1;
height: 8px;
-webkit-appearance: none;
appearance: none;
background: rgba(255, 255, 255, 0.2);
border-radius: 4px;
cursor: pointer;
}
.timeline-slider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 18px;
height: 18px;
background: #f4a460;
border-radius: 50%;
cursor: pointer;
transition: transform 0.1s;
}
.timeline-slider::-webkit-slider-thumb:hover {
transform: scale(1.2);
}
.timeline-slider::-moz-range-thumb {
width: 18px;
height: 18px;
background: #f4a460;
border-radius: 50%;
cursor: pointer;
border: none;
}
.frame-counter {
font-family: monospace;
min-width: 70px;
text-align: right;
color: rgba(255, 255, 255, 0.7);
font-size: 0.85rem;
}
/* Speed control */
.speed-control {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.85rem;
color: rgba(255, 255, 255, 0.7);
}
.speed-select {
padding: 6px 10px;
border-radius: 6px;
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(255, 255, 255, 0.2);
color: #fff;
cursor: pointer;
}
/* Replay Actions */
.replay-actions {
display: flex;
justify-content: center;
gap: 12px;
flex-wrap: wrap;
}
/* Replay Error */
.replay-error {
text-align: center;
padding: 60px 20px;
color: rgba(255, 255, 255, 0.7);
}
.replay-error p {
margin-bottom: 20px;
font-size: 1.1rem;
}
/* Share link container */
.share-link-container {
display: flex;
gap: 10px;
margin-top: 10px;
}
.share-link-container input {
flex: 1;
padding: 10px 12px;
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 6px;
color: #fff;
font-size: 0.9rem;
}
/* Modal actions */
.modal-actions {
display: flex;
gap: 12px;
justify-content: center;
margin-top: 20px;
}
/* Spectator badge */
.spectator-count {
position: fixed;
top: 10px;
right: 10px;
background: rgba(0, 0, 0, 0.7);
color: white;
padding: 8px 16px;
border-radius: 20px;
display: flex;
align-items: center;
gap: 8px;
font-size: 0.9rem;
z-index: 100;
}
.spectator-count::before {
content: '👁';
}
/* Mobile adjustments for replay */
@media (max-width: 600px) {
#replay-screen {
padding: 10px;
}
.replay-board-container {
padding: 12px;
}
.replay-players {
gap: 12px;
}
.replay-player {
min-width: 150px;
padding: 10px;
}
.replay-board-container .card {
width: 38px;
height: 53px;
font-size: 0.75rem;
}
.replay-center {
gap: 15px;
padding: 12px;
}
.replay-controls {
padding: 10px;
gap: 8px;
}
.replay-btn {
width: 36px;
height: 36px;
font-size: 0.9rem;
}
.replay-btn-play {
width: 44px;
height: 44px;
font-size: 1.1rem;
}
.timeline {
min-width: 150px;
}
.replay-actions {
flex-direction: column;
align-items: stretch;
}
.replay-actions .btn {
width: 100%;
}
}