4 Commits

Author SHA1 Message Date
adlee-was-taken
d7631ec671 Fix CI: remove checkout step, runner can't resolve gitea hostname
All checks were successful
Build & Deploy Staging / build-and-deploy (release) Successful in 1m27s
The build happens on the staging server via SSH, not in the runner
container, so checkout is unnecessary.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 19:51:54 -04:00
adlee-was-taken
f6eeaed97d Fix CI/CD: use SSH-based build instead of Docker-in-Docker
Some checks failed
Build & Deploy Staging / build-and-deploy (release) Failing after 30s
act_runner doesn't reliably support docker/build-push-action.
Build the image on the staging server and push to registry from
there instead.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 19:49:35 -04:00
adlee-was-taken
ef54ac201a Add invite request system and Gitea Actions CI/CD pipeline
Some checks failed
Build & Deploy Staging / build (release) Waiting to run
Build & Deploy Staging / deploy (release) Has been cancelled
Invite request feature:
- Public form to request an invite when INVITE_REQUEST_ENABLED=true
- Stores requests in new invite_requests DB table
- Emails admins on new request, emails requester on approve/deny
- Admin panel tab to review, approve, and deny requests
- Approval auto-creates invite code and sends signup link

CI/CD pipeline:
- Build & push Docker image to Gitea registry on release
- Auto-deploy to staging with health check
- Manual workflow_dispatch for production deploys

Also includes client layout/sizing improvements for card grid
and opponent spacing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 19:38:52 -04:00
adlee-was-taken
0c0588f920 TUI: add double-escape quit and minor layout tweaks
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 19:38:38 -04:00
20 changed files with 1010 additions and 79 deletions

View File

@@ -75,6 +75,9 @@ SECRET_KEY=
# Enable invite-only mode (requires invitation to register) # Enable invite-only mode (requires invitation to register)
INVITE_ONLY=true INVITE_ONLY=true
# Allow visitors to request an invite from the login page (only relevant when INVITE_ONLY=true)
INVITE_REQUEST_ENABLED=false
# Metered open signups (public beta) # Metered open signups (public beta)
# 0 = disabled (invite-only enforced), -1 = unlimited, N = max open signups per day # 0 = disabled (invite-only enforced), -1 = unlimited, N = max open signups per day
# When set > 0, users can register without an invite code up to the daily limit. # When set > 0, users can register without an invite code up to the daily limit.

View File

@@ -0,0 +1,50 @@
name: Deploy Production
on:
workflow_dispatch:
inputs:
tag:
description: 'Release tag to deploy (e.g. v3.3.0)'
required: true
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Deploy to production
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.PROD_HOST }}
username: root
key: ${{ secrets.DEPLOY_SSH_KEY }}
script: |
set -e
TAG="${{ github.event.inputs.tag }}"
IMAGE="git.adlee.work/alee/golfgame"
cd /opt/golfgame
# Pull the image that passed staging
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.adlee.work -u "${{ secrets.REGISTRY_USER }}" --password-stdin
docker pull "$IMAGE:$TAG"
docker tag "$IMAGE:$TAG" golfgame-app:latest
# Update code for compose/env changes
git fetch origin
git checkout "$TAG"
# Restart app
docker compose -f docker-compose.prod.yml up -d app
# Wait for healthy
echo "Waiting for health check..."
for i in $(seq 1 30); do
if docker compose -f docker-compose.prod.yml ps app | grep -q "healthy"; then
echo "Production deploy successful — $TAG"
exit 0
fi
sleep 2
done
echo "CRITICAL: app not healthy after 60s"
docker compose -f docker-compose.prod.yml logs --tail=30 app
exit 1

View File

@@ -0,0 +1,50 @@
name: Build & Deploy Staging
on:
release:
types: [published]
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- name: Build, push, and deploy to staging
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.STAGING_HOST }}
username: root
key: ${{ secrets.DEPLOY_SSH_KEY }}
script: |
set -e
TAG="${{ github.ref_name }}"
IMAGE="git.adlee.work/alee/golfgame"
cd /opt/golfgame
# Pull latest code and checkout the release tag
git fetch origin
git checkout "$TAG"
# Build the image
docker build -t "$IMAGE:$TAG" -t "$IMAGE:latest" -t golfgame-app:latest .
# Push to Gitea container registry
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.adlee.work -u "${{ secrets.REGISTRY_USER }}" --password-stdin
docker push "$IMAGE:$TAG"
docker push "$IMAGE:latest"
# Restart app (no --build, image already tagged)
docker compose -f docker-compose.staging.yml up -d app
# Wait for healthy
echo "Waiting for health check..."
for i in $(seq 1 30); do
if docker compose -f docker-compose.staging.yml ps app | grep -q "healthy"; then
echo "Staging deploy successful — $TAG"
exit 0
fi
sleep 2
done
echo "WARNING: app not healthy after 60s"
docker compose -f docker-compose.staging.yml logs --tail=20 app
exit 1

View File

@@ -37,6 +37,7 @@
<a href="#" data-panel="users" class="nav-link">Users</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="games" class="nav-link">Games</a>
<a href="#" data-panel="invites" class="nav-link">Invites</a> <a href="#" data-panel="invites" class="nav-link">Invites</a>
<a href="#" data-panel="invite-requests" class="nav-link">Requests</a>
<a href="#" data-panel="audit" class="nav-link">Audit Log</a> <a href="#" data-panel="audit" class="nav-link">Audit Log</a>
</div> </div>
<div class="nav-user"> <div class="nav-user">
@@ -191,6 +192,35 @@
</table> </table>
</section> </section>
<!-- Invite Requests Panel -->
<section id="invite-requests-panel" class="panel hidden">
<h2>Invite Requests</h2>
<div class="panel-toolbar">
<div class="filter-bar">
<select id="request-status-filter">
<option value="pending">Pending</option>
<option value="">All</option>
<option value="approved">Approved</option>
<option value="denied">Denied</option>
</select>
<button id="request-filter-btn" class="btn">Filter</button>
</div>
</div>
<table id="invite-requests-table" class="data-table">
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Message</th>
<th>Submitted</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody></tbody>
</table>
</section>
<!-- Audit Log Panel --> <!-- Audit Log Panel -->
<section id="audit-panel" class="panel hidden"> <section id="audit-panel" class="panel hidden">
<h2>Audit Log</h2> <h2>Audit Log</h2>
@@ -207,12 +237,15 @@
<option value="end_game">End Game</option> <option value="end_game">End Game</option>
<option value="create_invite">Create Invite</option> <option value="create_invite">Create Invite</option>
<option value="revoke_invite">Revoke Invite</option> <option value="revoke_invite">Revoke Invite</option>
<option value="approve_invite_request">Approve Request</option>
<option value="deny_invite_request">Deny Request</option>
</select> </select>
<select id="audit-target-filter"> <select id="audit-target-filter">
<option value="">All Targets</option> <option value="">All Targets</option>
<option value="user">Users</option> <option value="user">Users</option>
<option value="game">Games</option> <option value="game">Games</option>
<option value="invite_code">Invites</option> <option value="invite_code">Invites</option>
<option value="invite_request">Invite Requests</option>
</select> </select>
<button id="audit-filter-btn" class="btn">Filter</button> <button id="audit-filter-btn" class="btn">Filter</button>
</div> </div>

View File

@@ -198,6 +198,9 @@ function showPanel(panelId) {
case 'invites': case 'invites':
loadInvites(); loadInvites();
break; break;
case 'invite-requests':
loadInviteRequests();
break;
case 'audit': case 'audit':
loadAuditLog(); loadAuditLog();
break; break;
@@ -643,6 +646,80 @@ async function promptRevokeInvite(code) {
} }
} }
// =============================================================================
// Invite Requests
// =============================================================================
async function loadInviteRequests() {
const status = document.getElementById('request-status-filter').value;
const params = new URLSearchParams();
if (status) params.set('status', status);
try {
const data = await apiRequest(`/api/admin/invite-requests?${params}`);
const tbody = document.querySelector('#invite-requests-table tbody');
tbody.innerHTML = '';
if (data.requests.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" class="text-muted">No invite requests</td></tr>';
return;
}
data.requests.forEach(req => {
const statusBadge = req.status === 'approved'
? '<span class="badge badge-success">Approved</span>'
: req.status === 'denied'
? '<span class="badge badge-danger">Denied</span>'
: '<span class="badge badge-warning">Pending</span>';
const actions = req.status === 'pending'
? `<button class="btn btn-small btn-primary" data-action="approve-request" data-id="${req.id}">Approve</button>
<button class="btn btn-small btn-danger" data-action="deny-request" data-id="${req.id}">Deny</button>`
: `<span class="text-muted">${req.reviewed_by_username || '-'}</span>`;
tbody.innerHTML += `
<tr>
<td>${escapeHtml(req.name)}</td>
<td>${escapeHtml(req.email)}</td>
<td>${req.message ? escapeHtml(req.message).substring(0, 80) : '<span class="text-muted">-</span>'}</td>
<td>${formatDate(req.created_at)}</td>
<td>${statusBadge}</td>
<td>${actions}</td>
</tr>`;
});
} catch (error) {
showToast('Failed to load invite requests: ' + error.message, 'error');
}
}
async function handleApproveRequest(requestId) {
if (!confirm('Approve this invite request? An invite code will be created and emailed to the requester.')) return;
try {
const data = await apiRequest(`/api/admin/invite-requests/${requestId}/approve`, {
method: 'POST',
});
showToast(`Request approved! Invite code: ${data.code}`, 'success');
loadInviteRequests();
} catch (error) {
showToast('Failed to approve request: ' + error.message, 'error');
}
}
async function handleDenyRequest(requestId) {
if (!confirm('Deny this invite request? The requester will be notified.')) return;
try {
await apiRequest(`/api/admin/invite-requests/${requestId}/deny`, {
method: 'POST',
});
showToast('Request denied', 'success');
loadInviteRequests();
} catch (error) {
showToast('Failed to deny request: ' + error.message, 'error');
}
}
// ============================================================================= // =============================================================================
// Auth // Auth
// ============================================================================= // =============================================================================
@@ -786,6 +863,9 @@ document.addEventListener('DOMContentLoaded', () => {
document.getElementById('create-invite-btn').addEventListener('click', handleCreateInvite); document.getElementById('create-invite-btn').addEventListener('click', handleCreateInvite);
document.getElementById('include-expired').addEventListener('change', loadInvites); document.getElementById('include-expired').addEventListener('change', loadInvites);
// Invite requests panel
document.getElementById('request-filter-btn').addEventListener('click', loadInviteRequests);
// Audit panel // Audit panel
document.getElementById('audit-filter-btn').addEventListener('click', () => { document.getElementById('audit-filter-btn').addEventListener('click', () => {
auditPage = 0; auditPage = 0;
@@ -826,6 +906,8 @@ document.addEventListener('DOMContentLoaded', () => {
else if (action === 'end-game') promptEndGame(btn.dataset.id); else if (action === 'end-game') promptEndGame(btn.dataset.id);
else if (action === 'copy-invite') copyInviteLink(btn.dataset.code); else if (action === 'copy-invite') copyInviteLink(btn.dataset.code);
else if (action === 'revoke-invite') promptRevokeInvite(btn.dataset.code); else if (action === 'revoke-invite') promptRevokeInvite(btn.dataset.code);
else if (action === 'approve-request') handleApproveRequest(parseInt(btn.dataset.id));
else if (action === 'deny-request') handleDenyRequest(parseInt(btn.dataset.id));
}); });
// Check auth on load // Check auth on load

View File

@@ -1,4 +1,3 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Golf Card Game - Client Application // Golf Card Game - Client Application
// Debug logging - set to true to see detailed state/animation logs // Debug logging - set to true to see detailed state/animation logs
@@ -4868,6 +4867,15 @@ class AuthManager {
this.resetPasswordConfirm = document.getElementById('reset-password-confirm'); this.resetPasswordConfirm = document.getElementById('reset-password-confirm');
this.resetError = document.getElementById('reset-error'); this.resetError = document.getElementById('reset-error');
this.resetSuccess = document.getElementById('reset-success'); this.resetSuccess = document.getElementById('reset-success');
this.requestInviteContainer = document.getElementById('request-invite-container');
this.requestInviteForm = document.getElementById('request-invite-form');
this.requestInviteName = document.getElementById('request-invite-name');
this.requestInviteEmail = document.getElementById('request-invite-email');
this.requestInviteMessage = document.getElementById('request-invite-message');
this.requestInviteError = document.getElementById('request-invite-error');
this.requestInviteSuccess = document.getElementById('request-invite-success');
this.requestBackSignup = document.getElementById('request-back-signup');
this.requestBackLogin = document.getElementById('request-back-login');
} }
bindEvents() { bindEvents() {
@@ -4898,6 +4906,15 @@ class AuthManager {
}); });
this.forgotForm?.addEventListener('submit', (e) => this.handleForgotPassword(e)); this.forgotForm?.addEventListener('submit', (e) => this.handleForgotPassword(e));
this.resetForm?.addEventListener('submit', (e) => this.handleResetPassword(e)); this.resetForm?.addEventListener('submit', (e) => this.handleResetPassword(e));
this.requestInviteForm?.addEventListener('submit', (e) => this.handleRequestInvite(e));
this.requestBackSignup?.addEventListener('click', (e) => {
e.preventDefault();
this.showForm('signup');
});
this.requestBackLogin?.addEventListener('click', (e) => {
e.preventDefault();
this.showForm('login');
});
// Check URL for reset token or invite code on page load // Check URL for reset token or invite code on page load
this.checkResetToken(); this.checkResetToken();
@@ -4923,6 +4940,7 @@ class AuthManager {
this.signupFormContainer.classList.add('hidden'); this.signupFormContainer.classList.add('hidden');
this.forgotFormContainer?.classList.add('hidden'); this.forgotFormContainer?.classList.add('hidden');
this.resetFormContainer?.classList.add('hidden'); this.resetFormContainer?.classList.add('hidden');
this.requestInviteContainer?.classList.add('hidden');
this.clearErrors(); this.clearErrors();
if (form === 'login') { if (form === 'login') {
@@ -4937,6 +4955,9 @@ class AuthManager {
} else if (form === 'reset') { } else if (form === 'reset') {
this.resetFormContainer?.classList.remove('hidden'); this.resetFormContainer?.classList.remove('hidden');
this.resetPassword?.focus(); this.resetPassword?.focus();
} else if (form === 'request-invite') {
this.requestInviteContainer?.classList.remove('hidden');
this.requestInviteName?.focus();
} }
} }
@@ -4953,6 +4974,8 @@ class AuthManager {
if (this.forgotSuccess) this.forgotSuccess.textContent = ''; if (this.forgotSuccess) this.forgotSuccess.textContent = '';
if (this.resetError) this.resetError.textContent = ''; if (this.resetError) this.resetError.textContent = '';
if (this.resetSuccess) this.resetSuccess.textContent = ''; if (this.resetSuccess) this.resetSuccess.textContent = '';
if (this.requestInviteError) this.requestInviteError.textContent = '';
if (this.requestInviteSuccess) this.requestInviteSuccess.textContent = '';
} }
async handleLogin(e) { async handleLogin(e) {
@@ -5089,7 +5112,17 @@ class AuthManager {
if (invite_required) { if (invite_required) {
this.signupInviteCode.required = true; this.signupInviteCode.required = true;
this.signupInviteCode.placeholder = 'Invite Code (required)'; this.signupInviteCode.placeholder = 'Invite Code (required)';
if (this.inviteCodeHint) this.inviteCodeHint.textContent = ''; if (this.inviteCodeHint) {
if (this.signupInfo.invite_request_enabled) {
this.inviteCodeHint.innerHTML = 'Don\'t have one? <a href="#" id="show-request-invite">Request an invite</a>';
document.getElementById('show-request-invite')?.addEventListener('click', (e) => {
e.preventDefault();
this.showForm('request-invite');
});
} else {
this.inviteCodeHint.textContent = '';
}
}
} else if (open_signups_enabled) { } else if (open_signups_enabled) {
this.signupInviteCode.required = false; this.signupInviteCode.required = false;
this.signupInviteCode.placeholder = 'Invite Code (optional)'; this.signupInviteCode.placeholder = 'Invite Code (optional)';
@@ -5166,4 +5199,33 @@ class AuthManager {
this.resetError.textContent = 'Connection error'; this.resetError.textContent = 'Connection error';
} }
} }
async handleRequestInvite(e) {
e.preventDefault();
this.clearErrors();
const name = this.requestInviteName.value.trim();
const email = this.requestInviteEmail.value.trim();
const message = this.requestInviteMessage.value.trim() || null;
try {
const response = await fetch('/api/auth/request-invite', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, email, message }),
});
const data = await response.json();
if (!response.ok) {
this.requestInviteError.textContent = data.detail || 'Request failed';
return;
}
this.requestInviteSuccess.textContent = data.message;
this.requestInviteForm.reset();
} catch (err) {
this.requestInviteError.textContent = 'Connection error';
}
}
} }

View File

@@ -322,7 +322,6 @@
<div class="game-table"> <div class="game-table">
<div id="opponents-row" class="opponents-row"></div> <div id="opponents-row" class="opponents-row"></div>
<div class="player-row">
<div class="table-center"> <div class="table-center">
<div class="deck-area"> <div class="deck-area">
<!-- Held card slot (left of deck) --> <!-- Held card slot (left of deck) -->
@@ -354,6 +353,7 @@
</div> </div>
</div> </div>
<div class="player-row">
<div class="player-section"> <div class="player-section">
<div class="player-area"> <div class="player-area">
<h4 id="player-header"><span class="player-name">You</span><span id="your-score" class="player-showing">0</span></h4> <h4 id="player-header"><span class="player-name">You</span><span id="your-score" class="player-showing">0</span></h4>
@@ -427,6 +427,26 @@
<!-- Drawer backdrop for mobile --> <!-- Drawer backdrop for mobile -->
<div id="drawer-backdrop" class="drawer-backdrop"></div> <div id="drawer-backdrop" class="drawer-backdrop"></div>
<!-- Desktop scorecard button + overlay -->
<button id="desktop-scorecard-btn">Scorecard</button>
<div id="desktop-scorecard-overlay" class="side-panel desktop-scorecard-overlay">
<h4>Current Standings</h4>
<div id="desktop-standings-list" class="standings-list"></div>
<h4>Scores</h4>
<table id="desktop-score-table">
<thead>
<tr>
<th>Player</th>
<th>Hole</th>
<th>Tot</th>
<th>W</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
<div id="desktop-scorecard-backdrop" class="desktop-scorecard-backdrop"></div>
</div> </div>
<!-- Rules Screen --> <!-- Rules Screen -->
@@ -911,6 +931,28 @@ TOTAL: 0 + 8 + 16 = 24 points</pre>
</form> </form>
<p class="auth-switch">Already have an account? <a href="#" id="show-login">Login</a></p> <p class="auth-switch">Already have an account? <a href="#" id="show-login">Login</a></p>
</div> </div>
<!-- Request Invite Form -->
<div id="request-invite-container" class="hidden">
<h3>Request an Invite</h3>
<p class="auth-hint">Registration is invite-only. Request access and we'll get back to you.</p>
<form id="request-invite-form">
<div class="form-group">
<input type="text" id="request-invite-name" placeholder="Your name" required maxlength="100">
</div>
<div class="form-group">
<input type="email" id="request-invite-email" placeholder="Email" required>
</div>
<div class="form-group">
<textarea id="request-invite-message" placeholder="Why do you want to join? (optional)" rows="3" maxlength="500"></textarea>
</div>
<p id="request-invite-error" class="error"></p>
<p id="request-invite-success" class="success"></p>
<button type="submit" class="btn btn-primary btn-full">Request Invite</button>
</form>
<p class="auth-switch">Already have an invite? <a href="#" id="request-back-signup">Sign up</a></p>
<p class="auth-switch">Already have an account? <a href="#" id="request-back-login">Login</a></p>
</div>
</div> </div>
</div> </div>

View File

@@ -1024,13 +1024,13 @@ input::placeholder {
/* Card Styles */ /* Card Styles */
.card { .card {
width: clamp(65px, 5.5vw, 100px); width: clamp(65px, 7vw, 135px);
height: clamp(91px, 7.7vw, 140px); height: clamp(91px, 9.8vw, 189px);
border-radius: 6px; border-radius: 6px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: clamp(2rem, 2.5vw, 3.2rem); font-size: clamp(2rem, 3vw, 3.8rem);
font-weight: bold; font-weight: bold;
cursor: pointer; cursor: pointer;
/* No CSS transition - hover effects handled by anime.js */ /* No CSS transition - hover effects handled by anime.js */
@@ -1151,7 +1151,7 @@ input::placeholder {
/* Card Grid */ /* Card Grid */
.card-grid { .card-grid {
display: grid; display: grid;
grid-template-columns: repeat(3, clamp(65px, 5.5vw, 100px)); grid-template-columns: repeat(3, clamp(65px, 7vw, 135px));
gap: clamp(8px, 0.8vw, 14px); gap: clamp(8px, 0.8vw, 14px);
justify-content: center; justify-content: center;
} }
@@ -1161,18 +1161,22 @@ input::placeholder {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 25px; justify-content: space-between;
gap: 15px;
width: 100%; width: 100%;
flex: 1;
min-height: 0;
} }
/* Player row - deck/discard and player cards side by side */ /* Player row - local player cards */
.player-row { .player-row {
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
gap: 25px; gap: clamp(15px, 2vh, 35px);
width: 100%; width: 100%;
flex-wrap: wrap; flex-wrap: wrap;
padding-bottom: clamp(10px, 2vh, 30px);
} }
.opponents-row { .opponents-row {
@@ -1180,9 +1184,9 @@ input::placeholder {
flex-wrap: nowrap; flex-wrap: nowrap;
justify-content: center; justify-content: center;
align-items: flex-end; align-items: flex-end;
gap: clamp(12px, 1.8vw, 35px); gap: clamp(12px, 6vw, 120px);
min-height: clamp(120px, 14vw, 200px); min-height: clamp(120px, 18vw, 280px);
padding: 8px 20px 0; padding: 15px 20px 0;
width: 100%; width: 100%;
} }
@@ -1234,7 +1238,62 @@ input::placeholder {
transform: rotate(8deg); transform: rotate(8deg);
} }
/* 5 opponents: deeper arch with graduated rotation toward center */ /* 5 opponents: tighter spacing to fit single row on wide screens */
.opponents-row:has(.opponent-area:first-child:nth-last-child(5)) {
gap: clamp(6px, 2vw, 50px);
}
.opponents-row .opponent-area:first-child:nth-last-child(5),
.opponents-row .opponent-area:first-child:nth-last-child(5) ~ .opponent-area {
flex-shrink: 1;
}
.opponents-row .opponent-area:first-child:nth-last-child(5) .card-grid,
.opponents-row .opponent-area:first-child:nth-last-child(5) ~ .opponent-area .card-grid {
grid-template-columns: repeat(3, clamp(38px, 4vw, 85px));
gap: clamp(2px, 0.4vw, 6px);
}
.opponents-row .opponent-area:first-child:nth-last-child(5) .card,
.opponents-row .opponent-area:first-child:nth-last-child(5) ~ .opponent-area .card {
width: clamp(38px, 4vw, 85px);
height: clamp(53px, 5.6vw, 119px);
font-size: clamp(0.9rem, 1.3vw, 2.2rem);
}
/* 5 opponents mid-width: wrap into 2 arch rows (3 + 2) */
@media (min-width: 750px) and (max-width: 1220px) {
.opponents-row:has(.opponent-area:first-child:nth-last-child(5)) {
flex-wrap: wrap;
gap: clamp(6px, 1.5vw, 20px);
row-gap: clamp(4px, 1vw, 16px);
}
/* Force 3+2 split: each item ~30% so 3 fit per row */
.opponents-row .opponent-area:first-child:nth-last-child(5),
.opponents-row .opponent-area:first-child:nth-last-child(5) ~ .opponent-area {
flex: 0 0 auto;
}
/* Row 1 arch: 3 opponents */
.opponents-row .opponent-area:first-child:nth-last-child(5) {
margin-bottom: 0;
transform: rotate(-5deg);
}
.opponents-row .opponent-area:first-child:nth-last-child(5) ~ .opponent-area:nth-child(2) {
margin-bottom: 20px;
transform: rotate(0deg);
}
.opponents-row .opponent-area:first-child:nth-last-child(5) ~ .opponent-area:nth-child(3) {
margin-bottom: 0;
transform: rotate(5deg);
}
/* Row 2 arch: 2 opponents */
.opponents-row .opponent-area:first-child:nth-last-child(5) ~ .opponent-area:nth-child(4) {
margin-bottom: 8px;
transform: rotate(-3deg);
}
.opponents-row .opponent-area:first-child:nth-last-child(5) ~ .opponent-area:nth-child(5) {
margin-bottom: 8px;
transform: rotate(3deg);
}
}
.opponents-row .opponent-area:first-child:nth-last-child(5) { .opponents-row .opponent-area:first-child:nth-last-child(5) {
margin-bottom: 0; margin-bottom: 0;
transform: rotate(-10deg); transform: rotate(-10deg);
@@ -1367,9 +1426,9 @@ input::placeholder {
} }
.deck-area .card { .deck-area .card {
width: clamp(80px, 7vw, 120px); width: clamp(80px, 8.5vw, 150px);
height: clamp(112px, 9.8vw, 168px); height: clamp(112px, 11.9vw, 210px);
font-size: clamp(2.4rem, 3.2vw, 4rem); font-size: clamp(2.4rem, 3.5vw, 4.5rem);
} }
#discard { #discard {
@@ -1771,14 +1830,14 @@ input::placeholder {
.opponent-area .card-grid { .opponent-area .card-grid {
display: grid; display: grid;
grid-template-columns: repeat(3, clamp(45px, 4vw, 75px)); grid-template-columns: repeat(3, clamp(45px, 5vw, 100px));
gap: clamp(4px, 0.4vw, 8px); gap: clamp(4px, 0.5vw, 8px);
} }
.opponent-area .card { .opponent-area .card {
width: clamp(45px, 4vw, 75px); width: clamp(45px, 5vw, 100px);
height: clamp(63px, 5.6vw, 105px); height: clamp(63px, 7vw, 140px);
font-size: clamp(1.3rem, 1.5vw, 2.2rem); font-size: clamp(1.3rem, 1.8vw, 2.6rem);
border-radius: 5px; border-radius: 5px;
} }
@@ -1929,6 +1988,7 @@ input::placeholder {
align-items: center; align-items: center;
gap: 8px; gap: 8px;
width: 100%; width: 100%;
min-height: calc(100vh - 50px);
} }
/* Side Panels - positioned in bottom corners */ /* Side Panels - positioned in bottom corners */
@@ -1953,6 +2013,88 @@ input::placeholder {
right: 15px; right: 15px;
} }
/* Desktop: hide side panels by default, show via scorecard button */
.side-panel.left-panel,
.side-panel.right-panel {
display: none;
}
/* Desktop scorecard button - bottom right corner */
#desktop-scorecard-btn {
position: fixed;
bottom: 15px;
right: 15px;
z-index: 99;
background: linear-gradient(145deg, rgba(15, 50, 35, 0.92) 0%, rgba(8, 30, 20, 0.95) 100%);
border: 1px solid rgba(244, 164, 96, 0.35);
color: #f4a460;
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.15em;
text-transform: uppercase;
padding: 8px 16px;
border-radius: 8px;
cursor: pointer;
backdrop-filter: blur(10px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
transition: all 0.2s ease;
}
#desktop-scorecard-btn:hover {
background: linear-gradient(145deg, rgba(20, 60, 40, 0.95) 0%, rgba(12, 40, 28, 0.97) 100%);
border-color: rgba(244, 164, 96, 0.6);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5), 0 0 10px rgba(244, 164, 96, 0.15);
}
#desktop-scorecard-btn:active {
transform: scale(0.96);
}
#desktop-scorecard-btn.active {
background: linear-gradient(135deg, #f4a460, #e8935a);
color: #1a472a;
border-color: transparent;
box-shadow: 0 2px 12px rgba(244, 164, 96, 0.4);
}
/* Desktop scorecard overlay — combines standings + scores */
.side-panel.desktop-scorecard-overlay {
display: none;
position: fixed;
bottom: 55px;
right: 15px;
left: auto;
width: 280px;
max-height: 70vh;
overflow-y: auto;
z-index: 100;
border-radius: 10px;
padding: 10px 12px;
background: linear-gradient(145deg, rgba(15, 50, 35, 0.95) 0%, rgba(8, 30, 20, 0.97) 100%);
backdrop-filter: blur(10px);
border: 1px solid rgba(244, 164, 96, 0.25);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
}
.side-panel.desktop-scorecard-overlay.open {
display: block;
}
/* Desktop scorecard backdrop */
.desktop-scorecard-backdrop {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 98;
}
.desktop-scorecard-backdrop.visible {
display: block;
}
.side-panel > h4 { .side-panel > h4 {
font-size: 0.7rem; font-size: 0.7rem;
text-align: center; text-align: center;
@@ -3596,7 +3738,24 @@ input::placeholder {
color: rgba(255, 255, 255, 0.4); color: rgba(255, 255, 255, 0.4);
} }
.modal-auth input:focus { .modal-auth textarea {
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;
font-family: inherit;
resize: vertical;
}
.modal-auth textarea::placeholder {
color: rgba(255, 255, 255, 0.4);
}
.modal-auth input:focus,
.modal-auth textarea:focus {
outline: none; outline: none;
border-color: #f4a460; border-color: #f4a460;
} }
@@ -3612,6 +3771,15 @@ input::placeholder {
color: rgba(255, 255, 255, 0.45); color: rgba(255, 255, 255, 0.45);
} }
.form-hint a {
color: #f4a460;
text-decoration: none;
}
.form-hint a:hover {
text-decoration: underline;
}
.auth-switch { .auth-switch {
text-align: center; text-align: center;
margin-top: 15px; margin-top: 15px;
@@ -5336,12 +5504,16 @@ body.mobile-portrait .opponents-row {
flex-shrink: 0; flex-shrink: 0;
} }
/* --- Mobile: Player row gets remaining space, centered vertically --- */ /* --- Mobile: Table center and player row share remaining space --- */
body.mobile-portrait .table-center {
flex-shrink: 0;
}
body.mobile-portrait .player-row { body.mobile-portrait .player-row {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: space-evenly; justify-content: center;
gap: 10px; gap: 10px;
width: 100%; width: 100%;
flex: 1 1 0%; flex: 1 1 0%;
@@ -5503,6 +5675,18 @@ body.mobile-portrait .real-card .card-face-back {
line-height: 1; line-height: 1;
} }
/* Hide desktop scorecard button and backdrop on mobile */
body.mobile-portrait #desktop-scorecard-btn,
body.mobile-portrait .desktop-scorecard-backdrop {
display: none !important;
}
/* Re-enable side panels on mobile (overrides desktop hide) */
body.mobile-portrait .side-panel.left-panel,
body.mobile-portrait .side-panel.right-panel {
display: block;
}
/* --- Mobile: Side panels become bottom drawers --- */ /* --- Mobile: Side panels become bottom drawers --- */
body.mobile-portrait .side-panel { body.mobile-portrait .side-panel {
position: fixed; position: fixed;

View File

@@ -1,4 +1,3 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Centralized timing configuration for all animations and pauses // Centralized timing configuration for all animations and pauses
// Edit these values to tune the feel of card animations and CPU gameplay // Edit these values to tune the feel of card animations and CPU gameplay

View File

@@ -40,6 +40,7 @@ services:
- BASE_URL=${BASE_URL:-https://golf.example.com} - BASE_URL=${BASE_URL:-https://golf.example.com}
- RATE_LIMIT_ENABLED=true - RATE_LIMIT_ENABLED=true
- INVITE_ONLY=true - INVITE_ONLY=true
- INVITE_REQUEST_ENABLED=true
- DAILY_OPEN_SIGNUPS=${DAILY_OPEN_SIGNUPS:-0} - DAILY_OPEN_SIGNUPS=${DAILY_OPEN_SIGNUPS:-0}
- DAILY_SIGNUPS_PER_IP=${DAILY_SIGNUPS_PER_IP:-3} - DAILY_SIGNUPS_PER_IP=${DAILY_SIGNUPS_PER_IP:-3}
- BOOTSTRAP_ADMIN_USERNAME=${BOOTSTRAP_ADMIN_USERNAME:-} - BOOTSTRAP_ADMIN_USERNAME=${BOOTSTRAP_ADMIN_USERNAME:-}

View File

@@ -29,6 +29,7 @@ services:
- BASE_URL=${BASE_URL:-https://staging.golfcards.club} - BASE_URL=${BASE_URL:-https://staging.golfcards.club}
- RATE_LIMIT_ENABLED=false - RATE_LIMIT_ENABLED=false
- INVITE_ONLY=true - INVITE_ONLY=true
- INVITE_REQUEST_ENABLED=false
- DAILY_OPEN_SIGNUPS=${DAILY_OPEN_SIGNUPS:-0} - DAILY_OPEN_SIGNUPS=${DAILY_OPEN_SIGNUPS:-0}
- DAILY_SIGNUPS_PER_IP=${DAILY_SIGNUPS_PER_IP:-3} - DAILY_SIGNUPS_PER_IP=${DAILY_SIGNUPS_PER_IP:-3}
- BOOTSTRAP_ADMIN_USERNAME=${BOOTSTRAP_ADMIN_USERNAME:-} - BOOTSTRAP_ADMIN_USERNAME=${BOOTSTRAP_ADMIN_USERNAME:-}

View File

@@ -149,6 +149,9 @@ class ServerConfig:
SECRET_KEY: str = "" SECRET_KEY: str = ""
INVITE_ONLY: bool = True INVITE_ONLY: bool = True
# Allow visitors to request an invite (shown on login page when invite-only)
INVITE_REQUEST_ENABLED: bool = False
# Metered open signups (public beta) # Metered open signups (public beta)
# 0 = disabled (invite-only), -1 = unlimited, N = max per day # 0 = disabled (invite-only), -1 = unlimited, N = max per day
DAILY_OPEN_SIGNUPS: int = 0 DAILY_OPEN_SIGNUPS: int = 0
@@ -203,6 +206,7 @@ class ServerConfig:
ROOM_IDLE_TIMEOUT_SECONDS=get_env_int("ROOM_IDLE_TIMEOUT_SECONDS", 300), ROOM_IDLE_TIMEOUT_SECONDS=get_env_int("ROOM_IDLE_TIMEOUT_SECONDS", 300),
SECRET_KEY=get_env("SECRET_KEY", ""), SECRET_KEY=get_env("SECRET_KEY", ""),
INVITE_ONLY=get_env_bool("INVITE_ONLY", True), INVITE_ONLY=get_env_bool("INVITE_ONLY", True),
INVITE_REQUEST_ENABLED=get_env_bool("INVITE_REQUEST_ENABLED", False),
DAILY_OPEN_SIGNUPS=get_env_int("DAILY_OPEN_SIGNUPS", 0), DAILY_OPEN_SIGNUPS=get_env_int("DAILY_OPEN_SIGNUPS", 0),
DAILY_SIGNUPS_PER_IP=get_env_int("DAILY_SIGNUPS_PER_IP", 3), DAILY_SIGNUPS_PER_IP=get_env_int("DAILY_SIGNUPS_PER_IP", 3),
BOOTSTRAP_ADMIN_USERNAME=get_env("BOOTSTRAP_ADMIN_USERNAME", ""), BOOTSTRAP_ADMIN_USERNAME=get_env("BOOTSTRAP_ADMIN_USERNAME", ""),

View File

@@ -418,3 +418,76 @@ async def revoke_invite_code(
if not success: if not success:
raise HTTPException(status_code=404, detail="Invite code not found") raise HTTPException(status_code=404, detail="Invite code not found")
return {"message": "Invite code revoked successfully"} return {"message": "Invite code revoked successfully"}
# =============================================================================
# Invite Request Endpoints
# =============================================================================
@router.get("/invite-requests")
async def list_invite_requests(
status: Optional[str] = None,
admin: User = Depends(require_admin_v2),
service: AdminService = Depends(get_admin_service_dep),
):
"""List invite requests, optionally filtered by status (pending, approved, denied)."""
requests = await service.get_invite_requests(status=status)
return {"requests": [r.to_dict() for r in requests]}
@router.post("/invite-requests/{request_id}/approve")
async def approve_invite_request(
request_id: int,
request: Request,
admin: User = Depends(require_admin_v2),
service: AdminService = Depends(get_admin_service_dep),
):
"""Approve an invite request — creates a code and emails the requester."""
code = await service.approve_invite_request(
request_id=request_id,
admin_id=admin.id,
ip_address=get_client_ip(request),
)
if not code:
raise HTTPException(status_code=404, detail="Request not found or already handled")
# Get the request details to send the approval email
requests = await service.get_invite_requests()
req = next((r for r in requests if r.id == request_id), None)
if req:
from services.email_service import get_email_service
email_service = get_email_service()
await email_service.send_invite_approved_email(
to=req.email,
name=req.name,
invite_code=code,
)
return {"code": code, "message": "Request approved and invite sent"}
@router.post("/invite-requests/{request_id}/deny")
async def deny_invite_request(
request_id: int,
request: Request,
admin: User = Depends(require_admin_v2),
service: AdminService = Depends(get_admin_service_dep),
):
"""Deny an invite request — optionally emails the requester."""
result = await service.deny_invite_request(
request_id=request_id,
admin_id=admin.id,
ip_address=get_client_ip(request),
)
if not result:
raise HTTPException(status_code=404, detail="Request not found or already handled")
from services.email_service import get_email_service
email_service = get_email_service()
await email_service.send_invite_denied_email(
to=result["email"],
name=result["name"],
)
return {"message": "Request denied"}

View File

@@ -75,6 +75,13 @@ class UpdatePreferencesRequest(BaseModel):
preferences: dict preferences: dict
class InviteRequestBody(BaseModel):
"""Invite request body."""
name: str
email: str
message: Optional[str] = None
class ConvertGuestRequest(BaseModel): class ConvertGuestRequest(BaseModel):
"""Convert guest to user request.""" """Convert guest to user request."""
guest_id: str guest_id: str
@@ -332,6 +339,7 @@ async def signup_info():
return { return {
"invite_required": invite_required, "invite_required": invite_required,
"invite_request_enabled": config.INVITE_REQUEST_ENABLED,
"open_signups_enabled": open_signups_enabled, "open_signups_enabled": open_signups_enabled,
"daily_limit": config.DAILY_OPEN_SIGNUPS if not unlimited else None, "daily_limit": config.DAILY_OPEN_SIGNUPS if not unlimited else None,
"remaining_today": remaining, "remaining_today": remaining,
@@ -339,6 +347,55 @@ async def signup_info():
} }
@router.post("/request-invite")
async def request_invite(
request_body: InviteRequestBody,
request: Request,
):
"""
Public endpoint: submit a request for an invite code.
Stores the request in the database and notifies admins via email.
"""
if not config.INVITE_REQUEST_ENABLED:
raise HTTPException(status_code=404, detail="Invite requests are not enabled")
if not _admin_service:
raise HTTPException(status_code=503, detail="Service not initialized")
name = request_body.name.strip()
email = request_body.email.strip().lower()
message = request_body.message.strip() if request_body.message else None
if not name or len(name) > 100:
raise HTTPException(status_code=400, detail="Name is required (max 100 characters)")
if not email or "@" not in email:
raise HTTPException(status_code=400, detail="Valid email is required")
client_ip = get_client_ip(request)
request_id = await _admin_service.create_invite_request(
name=name,
email=email,
message=message,
ip_address=client_ip,
)
# Notify admin emails
if config.ADMIN_EMAILS:
from services.email_service import get_email_service
email_service = get_email_service()
for admin_email in config.ADMIN_EMAILS:
await email_service.send_invite_request_admin_notification(
to=admin_email,
requester_name=name,
requester_email=email,
message=message or "",
)
return {"status": "ok", "message": "Your request has been submitted. We'll be in touch!"}
@router.post("/verify-email") @router.post("/verify-email")
async def verify_email( async def verify_email(
request_body: VerifyEmailRequest, request_body: VerifyEmailRequest,

View File

@@ -138,6 +138,35 @@ class InviteCode:
} }
@dataclass
class InviteRequest:
"""Invite request details."""
id: int
name: str
email: str
message: Optional[str]
status: str
ip_address: Optional[str]
created_at: datetime
reviewed_at: Optional[datetime]
reviewed_by: Optional[str]
reviewed_by_username: Optional[str] = None
def to_dict(self) -> dict:
return {
"id": self.id,
"name": self.name,
"email": self.email,
"message": self.message,
"status": self.status,
"ip_address": self.ip_address,
"created_at": self.created_at.isoformat() if self.created_at else None,
"reviewed_at": self.reviewed_at.isoformat() if self.reviewed_at else None,
"reviewed_by": self.reviewed_by,
"reviewed_by_username": self.reviewed_by_username,
}
class AdminService: class AdminService:
""" """
Admin operations and moderation service. Admin operations and moderation service.
@@ -1211,6 +1240,183 @@ class AdminService:
return result != "UPDATE 0" return result != "UPDATE 0"
# -------------------------------------------------------------------------
# Invite Requests
# -------------------------------------------------------------------------
async def create_invite_request(
self,
name: str,
email: str,
message: Optional[str] = None,
ip_address: Optional[str] = None,
) -> int:
"""
Create a new invite request.
Returns:
The request ID.
"""
async with self.pool.acquire() as conn:
# Check for existing pending request from same email
existing = await conn.fetchval(
"SELECT id FROM invite_requests WHERE email = $1 AND status = 'pending'",
email,
)
if existing:
return existing
row_id = await conn.fetchval(
"""
INSERT INTO invite_requests (name, email, message, ip_address)
VALUES ($1, $2, $3, $4::inet)
RETURNING id
""",
name,
email,
message,
ip_address,
)
logger.info(f"New invite request #{row_id} from {email}")
return row_id
async def get_invite_requests(self, status: Optional[str] = None) -> List[InviteRequest]:
"""Get invite requests, optionally filtered by status."""
async with self.pool.acquire() as conn:
query = """
SELECT r.id, r.name, r.email, r.message, r.status, r.ip_address,
r.created_at, r.reviewed_at, r.reviewed_by,
u.username as reviewed_by_username
FROM invite_requests r
LEFT JOIN users_v2 u ON r.reviewed_by = u.id
"""
params = []
if status:
query += " WHERE r.status = $1"
params.append(status)
query += " ORDER BY r.created_at DESC"
rows = await conn.fetch(query, *params)
return [
InviteRequest(
id=row["id"],
name=row["name"],
email=row["email"],
message=row["message"],
status=row["status"],
ip_address=str(row["ip_address"]) if row["ip_address"] else None,
created_at=row["created_at"],
reviewed_at=row["reviewed_at"],
reviewed_by=str(row["reviewed_by"]) if row["reviewed_by"] else None,
reviewed_by_username=row["reviewed_by_username"],
)
for row in rows
]
async def approve_invite_request(
self,
request_id: int,
admin_id: str,
ip_address: Optional[str] = None,
) -> Optional[str]:
"""
Approve an invite request: create an invite code and update the request.
Returns:
The generated invite code, or None if request not found/already handled.
"""
async with self.pool.acquire() as conn:
# Verify request exists and is pending
row = await conn.fetchrow(
"SELECT id, email, name FROM invite_requests WHERE id = $1 AND status = 'pending'",
request_id,
)
if not row:
return None
# Create an invite code for this request
code = secrets.token_urlsafe(6).upper()[:8]
expires_at = datetime.now(timezone.utc) + timedelta(days=7)
invite_id = await conn.fetchval(
"""
INSERT INTO invite_codes (code, created_by, expires_at, max_uses)
VALUES ($1, $2, $3, 1)
RETURNING id
""",
code,
admin_id,
expires_at,
)
# Update the request
await conn.execute(
"""
UPDATE invite_requests
SET status = 'approved', reviewed_at = NOW(), reviewed_by = $1, invite_code_id = $2
WHERE id = $3
""",
admin_id,
invite_id,
request_id,
)
await self.audit(
admin_id,
"approve_invite_request",
"invite_request",
str(request_id),
{"email": row["email"], "invite_code": code},
ip_address,
)
logger.info(f"Admin {admin_id} approved invite request #{request_id}, code={code}")
return code
async def deny_invite_request(
self,
request_id: int,
admin_id: str,
ip_address: Optional[str] = None,
) -> Optional[dict]:
"""
Deny an invite request.
Returns:
The request info (name, email) or None if not found/already handled.
"""
async with self.pool.acquire() as conn:
row = await conn.fetchrow(
"SELECT id, email, name FROM invite_requests WHERE id = $1 AND status = 'pending'",
request_id,
)
if not row:
return None
await conn.execute(
"""
UPDATE invite_requests
SET status = 'denied', reviewed_at = NOW(), reviewed_by = $1
WHERE id = $2
""",
admin_id,
request_id,
)
await self.audit(
admin_id,
"deny_invite_request",
"invite_request",
str(request_id),
{"email": row["email"]},
ip_address,
)
logger.info(f"Admin {admin_id} denied invite request #{request_id}")
return {"name": row["name"], "email": row["email"]}
# Global admin service instance # Global admin service instance
_admin_service: Optional[AdminService] = None _admin_service: Optional[AdminService] = None

View File

@@ -165,6 +165,76 @@ class EmailService:
return await self._send_email(to, subject, html) return await self._send_email(to, subject, html)
async def send_invite_request_admin_notification(
self,
to: str,
requester_name: str,
requester_email: str,
message: str,
) -> Optional[str]:
"""Notify admin of a new invite request."""
if not self.is_configured():
logger.info(f"Email not configured. Would send invite request notification to {to}")
return None
admin_url = f"{self.base_url}/admin.html"
message_html = f"<p><strong>Message:</strong> {message}</p>" if message else ""
subject = f"Golf Game invite request from {requester_name}"
html = f"""
<h2>New Invite Request</h2>
<p><strong>Name:</strong> {requester_name}</p>
<p><strong>Email:</strong> {requester_email}</p>
{message_html}
<p><a href="{admin_url}">Review in Admin Panel</a></p>
"""
return await self._send_email(to, subject, html)
async def send_invite_approved_email(
self,
to: str,
name: str,
invite_code: str,
) -> Optional[str]:
"""Notify requester that their invite was approved."""
if not self.is_configured():
logger.info(f"Email not configured. Would send invite approval to {to}")
return None
signup_url = f"{self.base_url}/?invite={invite_code}"
subject = "Your Golf Game invite is ready!"
html = f"""
<h2>You're In, {name}!</h2>
<p>Your request to join Golf Game has been approved.</p>
<p>Use this link to create your account:</p>
<p><a href="{signup_url}">{signup_url}</a></p>
<p>Or sign up manually with invite code: <strong>{invite_code}</strong></p>
<p>This invite is single-use and expires in 7 days.</p>
"""
return await self._send_email(to, subject, html)
async def send_invite_denied_email(
self,
to: str,
name: str,
) -> Optional[str]:
"""Notify requester that their invite was denied."""
if not self.is_configured():
logger.info(f"Email not configured. Would send invite denial to {to}")
return None
subject = "Golf Game invite request update"
html = f"""
<h2>Hi {name},</h2>
<p>Thanks for your interest in Golf Game. Unfortunately, we're not able to approve your invite request at this time.</p>
<p>We may open up registrations in the future — stay tuned!</p>
"""
return await self._send_email(to, subject, html)
async def _send_email( async def _send_email(
self, self,
to: str, to: str,

View File

@@ -133,6 +133,20 @@ CREATE TABLE IF NOT EXISTS invite_codes (
is_active BOOLEAN DEFAULT TRUE is_active BOOLEAN DEFAULT TRUE
); );
-- Invite requests table
CREATE TABLE IF NOT EXISTS invite_requests (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
email VARCHAR(255) NOT NULL,
message TEXT,
status VARCHAR(20) DEFAULT 'pending',
ip_address INET,
created_at TIMESTAMPTZ DEFAULT NOW(),
reviewed_at TIMESTAMPTZ,
reviewed_by UUID REFERENCES users_v2(id),
invite_code_id BIGINT REFERENCES invite_codes(id)
);
-- Player stats table (extended for V2 leaderboards) -- Player stats table (extended for V2 leaderboards)
CREATE TABLE IF NOT EXISTS player_stats ( CREATE TABLE IF NOT EXISTS player_stats (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,

View File

@@ -2,6 +2,8 @@
from __future__ import annotations from __future__ import annotations
import time
from textual.app import ComposeResult from textual.app import ComposeResult
from textual.containers import Container, Horizontal, Vertical from textual.containers import Container, Horizontal, Vertical
from textual.screen import Screen from textual.screen import Screen
@@ -23,6 +25,7 @@ class ConnectScreen(Screen):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self._mode: str = "login" # "login" or "signup" self._mode: str = "login" # "login" or "signup"
self._last_esc: float = 0.0
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
with Container(id="connect-container"): with Container(id="connect-container"):
@@ -30,7 +33,7 @@ class ConnectScreen(Screen):
# Login form # Login form
with Vertical(id="login-form"): with Vertical(id="login-form"):
yield Static("Log in to play") yield Static("Log in to play\n")
yield Input(placeholder="Username", id="input-username") yield Input(placeholder="Username", id="input-username")
yield Input(placeholder="Password", password=True, id="input-password") yield Input(placeholder="Password", password=True, id="input-password")
with Horizontal(id="connect-buttons"): with Horizontal(id="connect-buttons"):
@@ -64,7 +67,7 @@ class ConnectScreen(Screen):
with Horizontal(classes="screen-footer"): with Horizontal(classes="screen-footer"):
yield Static("", id="connect-footer-left", classes="screen-footer-left") yield Static("", id="connect-footer-left", classes="screen-footer-left")
yield Static("\\[q] quit", id="connect-footer-right", classes="screen-footer-right") yield Static("\\[q]uit or \\[esc]x2", id="connect-footer-right", classes="screen-footer-right")
def on_mount(self) -> None: def on_mount(self) -> None:
self._update_form_visibility() self._update_form_visibility()
@@ -103,11 +106,17 @@ class ConnectScreen(Screen):
self._update_form_visibility() self._update_form_visibility()
def handle_escape(self) -> None: def handle_escape(self) -> None:
"""Escape goes back to login if on signup form.""" """Escape goes back to login if on signup form. Double-escape quits."""
if self._mode == "signup": if self._mode == "signup":
self._mode = "login" self._mode = "login"
self._set_status("") self._set_status("")
self._update_form_visibility() self._update_form_visibility()
self._last_esc = 0.0
else:
now = time.monotonic()
if now - self._last_esc < 0.5:
self.app.exit()
self._last_esc = now
def on_input_submitted(self, event: Input.Submitted) -> None: def on_input_submitted(self, event: Input.Submitted) -> None:
if event.input.id == "input-password": if event.input.id == "input-password":

View File

@@ -72,7 +72,7 @@ class LobbyScreen(Screen):
# In-room: player list + controls + settings # In-room: player list + controls + settings
with Vertical(id="in-room"): with Vertical(id="in-room"):
yield Static("", id="room-info") yield Static("", id="room-info")
yield Static("[bold]Players[/bold]", id="player-list-label") yield Static("[bold]Players[/bold]\n", id="player-list-label")
yield Static("", id="player-list") yield Static("", id="player-list")
# CPU controls: compact [+] [-] # CPU controls: compact [+] [-]
@@ -185,8 +185,8 @@ class LobbyScreen(Screen):
yield Label("Wolfpack") yield Label("Wolfpack")
yield Switch(id="sw-wolfpack") yield Switch(id="sw-wolfpack")
with Collapsible(title="Deck Style", collapsed=True, id="coll-deck"):
with Horizontal(classes="setting-row"): with Horizontal(classes="setting-row"):
yield Label("Deck Style")
yield Select( yield Select(
[(name.replace("-", " ").title(), name) for name in DECK_PRESETS], [(name.replace("-", " ").title(), name) for name in DECK_PRESETS],
value="classic", value="classic",
@@ -398,15 +398,7 @@ class LobbyScreen(Screen):
) )
line3 = "".join(parts3) line3 = "".join(parts3)
parts4: list[str] = [] return f"{line1}\n{line2}\n{line3}"
for color_name in seen:
hc = BACK_COLORS.get(color_name, BACK_COLORS["red"])
parts4.append(
f"[{bc}]└───┘[/{bc}] "
)
line4 = "".join(parts4)
return f"{line1}\n{line2}\n{line3}\n{line4}"
def _add_random_cpu(self) -> None: def _add_random_cpu(self) -> None:
"""Add a random CPU (server picks the profile).""" """Add a random CPU (server picks the profile)."""

View File

@@ -205,9 +205,8 @@ LobbyScreen {
#deck-preview { #deck-preview {
width: auto; width: auto;
height: auto; height: 3;
padding: 1 1 0 1; padding: 0 1;
text-align: center;
} }
.rule-row { .rule-row {