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:
633
client/admin.css
Normal file
633
client/admin.css
Normal 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
368
client/admin.html
Normal 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">×</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">×</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">×</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
809
client/admin.js
Normal 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();
|
||||
});
|
||||
193
client/app.js
193
client/app.js
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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">« 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">×</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">×</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
314
client/leaderboard.js
Normal 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">🥇</span>';
|
||||
case 2: return '<span class="medal">🥈</span>';
|
||||
case 3: return '<span class="medal">🥉</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
587
client/replay.js
Normal 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 };
|
||||
}
|
||||
922
client/style.css
922
client/style.css
@@ -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%;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user