This commit is contained in:
Eduardo Figueroa 2025-08-28 21:32:08 -07:00
commit 586014e23c
8 changed files with 690 additions and 0 deletions

134
app.py Normal file
View file

@ -0,0 +1,134 @@
#!/usr/bin/env python3
"""
Fig Fam Fantasy Plinko Score Tracker
A local web app for tracking player scores with real-time updates
"""
import sqlite3
import json
from flask import Flask, render_template, request, jsonify, Response
from threading import Lock
import time
app = Flask(__name__)
db_lock = Lock()
# Database Setup
def init_db():
"""Initialize SQLite database with players table"""
with sqlite3.connect('database.db') as conn:
conn.execute('''
CREATE TABLE IF NOT EXISTS players (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL DEFAULT '',
score INTEGER DEFAULT 0
)
''')
# Initialize 14 players if empty
count = conn.execute('SELECT COUNT(*) FROM players').fetchone()[0]
if count == 0:
for i in range(1, 15):
conn.execute(
'INSERT INTO players (id, name, score) VALUES (?, ?, ?)',
(i, f'Player {i}', 0)
)
conn.commit()
# Database Operations
def get_all_players():
"""Retrieve all players sorted by ID for input page"""
with sqlite3.connect('database.db') as conn:
conn.row_factory = sqlite3.Row
return [dict(row) for row in conn.execute(
'SELECT * FROM players ORDER BY id'
).fetchall()]
def get_players_by_score():
"""Retrieve all players sorted by score (highest first) for scoreboard"""
with sqlite3.connect('database.db') as conn:
conn.row_factory = sqlite3.Row
return [dict(row) for row in conn.execute(
'SELECT * FROM players ORDER BY score DESC, name ASC'
).fetchall()]
def update_player(player_id, name=None, score=None):
"""Update player name and/or score"""
with db_lock:
with sqlite3.connect('database.db') as conn:
if name is not None and score is not None:
conn.execute(
'UPDATE players SET name = ?, score = ? WHERE id = ?',
(name, score, player_id)
)
elif name is not None:
conn.execute(
'UPDATE players SET name = ? WHERE id = ?',
(name, player_id)
)
elif score is not None:
conn.execute(
'UPDATE players SET score = ? WHERE id = ?',
(score, player_id)
)
conn.commit()
# Web Routes
@app.route('/')
def input_page():
"""Mobile-friendly score input page"""
players = get_all_players()
return render_template('input.html', players=players)
@app.route('/scoreboard')
def scoreboard():
"""TV display scoreboard page"""
players = get_players_by_score() # Sort by score for display
return render_template('scoreboard.html', players=players)
@app.route('/api/players')
def api_get_players():
"""API endpoint to get all players (sorted by ID for input)"""
return jsonify(get_all_players())
@app.route('/api/update', methods=['POST'])
def api_update_player():
"""API endpoint to update player data"""
data = request.get_json()
player_id = data.get('id')
name = data.get('name')
score = data.get('score')
if player_id and (name is not None or score is not None):
update_player(player_id, name, score)
return jsonify({'success': True})
return jsonify({'success': False, 'error': 'Invalid data'})
@app.route('/events')
def events():
"""Server-Sent Events for real-time scoreboard updates"""
def event_stream():
last_data = None
while True:
players = get_players_by_score() # Sort by score for real-time updates
current_data = json.dumps(players)
# Send update only if data changed
if current_data != last_data:
yield f"data: {current_data}\n\n"
last_data = current_data
time.sleep(1) # Check every second
return Response(
event_stream(),
mimetype='text/plain',
headers={
'Cache-Control': 'no-cache',
'Connection': 'keep-alive'
}
)
if __name__ == '__main__':
init_db()
app.run(host='0.0.0.0', port=8080, debug=True)

52
nginx.conf Normal file
View file

@ -0,0 +1,52 @@
# nginx.conf
server {
listen 8080;
server_name localhost;
# Static file serving
location /static/ {
alias /path/to/your/project/static/;
expires 1d;
add_header Cache-Control "public, no-transform";
}
# Proxy all other requests to Flask
location / {
proxy_pass http://127.0.0.1:5000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# SSE specific headers
proxy_buffering off;
proxy_cache off;
proxy_set_header Connection '';
proxy_http_version 1.1;
chunked_transfer_encoding off;
}
# SSE endpoint optimization
location /events {
proxy_pass http://127.0.0.1:5000/events;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Disable buffering for real-time updates
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 24h;
proxy_send_timeout 24h;
proxy_set_header Connection '';
proxy_http_version 1.1;
chunked_transfer_encoding off;
# CORS headers if needed
add_header Access-Control-Allow-Origin *;
add_header Access-Control-Allow-Methods "GET, OPTIONS";
add_header Access-Control-Allow-Headers "Origin, Content-Type";
}
}

BIN
static/pricedown-b1.ttf Normal file

Binary file not shown.

69
static/scoreboard.js Normal file
View file

@ -0,0 +1,69 @@
// static/scoreboard.js - Real-time scoreboard updates
document.addEventListener('DOMContentLoaded', function() {
// Server-Sent Events for Real-time Updates
const eventSource = new EventSource('/events');
eventSource.onmessage = function(event) {
try {
const players = JSON.parse(event.data);
updateScoreboard(players);
} catch (error) {
console.error('Error parsing player data:', error);
}
};
eventSource.onerror = function(error) {
console.error('EventSource error:', error);
// Attempt to reconnect after 5 seconds
setTimeout(() => {
location.reload();
}, 5000);
};
// Update scoreboard display with sorted players
function updateScoreboard(players) {
const columns = document.querySelectorAll('.column');
// Clear existing rows
columns.forEach(col => col.innerHTML = '');
// Rebuild grid with current rankings
players.forEach((player, index) => {
const rank = index + 1;
const columnIndex = Math.floor(index / 7);
if (columnIndex < 2) {
const scoreRow = createScoreRow(player, rank);
columns[columnIndex].appendChild(scoreRow);
}
});
}
// Create a score row element
function createScoreRow(player, rank) {
const row = document.createElement('div');
row.className = 'score-row';
row.dataset.id = player.id;
// Highlight leader (position 1)
if (rank === 1) {
row.classList.add('leader');
}
row.innerHTML = `
<span class="player-rank">${rank}</span>
<span class="player-name">${player.name}</span>
<span class="player-score">${player.score}</span>
`;
return row;
}
// Prevent screen sleep for TV display
if ('wakeLock' in navigator) {
navigator.wakeLock.request('screen').catch(err => {
console.log('Wake lock failed:', err);
});
}
});

65
static/script.js Normal file
View file

@ -0,0 +1,65 @@
// static/script.js - Input page functionality
document.addEventListener('DOMContentLoaded', function() {
// Mobile Input Controls
const playerCards = document.querySelectorAll('.player-card');
playerCards.forEach(card => {
const updateBtn = card.querySelector('.update-btn');
const nameInput = card.querySelector('.name-input');
const scoreInput = card.querySelector('.score-input');
const playerId = card.dataset.id;
// Update player data on button click
updateBtn.addEventListener('click', function() {
const name = nameInput.value.trim();
const score = parseInt(scoreInput.value) || 0;
updatePlayer(playerId, name, score);
});
// Update on Enter key press
[nameInput, scoreInput].forEach(input => {
input.addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
updateBtn.click();
}
});
});
// Auto-select score input content for easy editing
scoreInput.addEventListener('focus', function() {
this.select();
});
});
// API call to update player
async function updatePlayer(id, name, score) {
try {
const response = await fetch('/api/update', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
id: parseInt(id),
name: name,
score: score
})
});
const result = await response.json();
if (result.success) {
// Visual feedback for successful update
const card = document.querySelector(`[data-id="${id}"]`);
card.style.background = '#90EE90';
setTimeout(() => {
card.style.background = '';
}, 500);
}
} catch (error) {
console.error('Update failed:', error);
alert('Failed to update player data');
}
}
});

299
static/style.css Normal file
View file

@ -0,0 +1,299 @@
/* static/style.css */
/* Font Face Declaration */
@font-face {
font-family: 'PlinkoFont';
src: url('pricedown-b1.ttf') format('truetype');
font-display: swap;
}
/* static/style.css */
/* Price Is Right Color Palette */
:root {
--pir-blue: #0066CC;
--pir-yellow: #FFD700;
--pir-red: #FF4444;
--pir-green: #00AA44;
--pir-orange: #FF8800;
--pir-white: #FFFFFF;
--pir-light-blue: #66AAFF;
--gradient-bg: linear-gradient(135deg, var(--pir-blue), var(--pir-light-blue));
}
/* Global Styles */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
color: var(--pir-white);
overflow-x: hidden;
}
/* Input Page Styles - Mobile Optimized */
.input-page {
min-height: 100vh;
padding: 20px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;
background: var(--gradient-bg);
}
/* Input Page Styles - Mobile Optimized */
.input-page {
min-height: 100vh;
padding: 20px;
}
.input-page .container {
max-width: 600px;
margin: 0 auto;
}
.input-page h1 {
text-align: center;
font-size: 2.5rem;
font-weight: bold;
color: var(--pir-yellow);
text-shadow: 3px 3px 6px rgba(0,0,0,0.5);
margin-bottom: 30px;
text-transform: uppercase;
}
.input-grid {
display: grid;
grid-template-columns: 1fr;
gap: 15px;
margin-bottom: 30px;
}
.player-card {
background: var(--pir-white);
color: var(--pir-blue);
border-radius: 15px;
padding: 20px;
border: 4px solid var(--pir-yellow);
box-shadow: 0 6px 12px rgba(0,0,0,0.3);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;
}
.player-card label {
display: block;
font-weight: bold;
font-size: 1.2rem;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;
margin-bottom: 10px;
text-transform: uppercase;
}
.name-input, .score-input {
width: 100%;
padding: 15px;
font-size: 1.1rem;
border: 3px solid var(--pir-blue);
border-radius: 8px;
margin-bottom: 10px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;
font-weight: normal;
}
.score-input {
text-align: center;
font-size: 1.5rem;
}
.update-btn {
width: 100%;
background: var(--pir-red);
color: var(--pir-white);
border: none;
padding: 15px;
font-size: 1.2rem;
font-weight: bold;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;
border-radius: 8px;
cursor: pointer;
text-transform: uppercase;
transition: all 0.2s ease;
}
.update-btn:hover {
background: #CC2222;
transform: translateY(-2px);
}
.actions {
text-align: center;
}
.view-board-btn {
display: inline-block;
background: var(--pir-green);
color: var(--pir-white);
text-decoration: none;
padding: 20px 40px;
font-size: 1.3rem;
font-weight: bold;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;
border-radius: 12px;
text-transform: uppercase;
transition: all 0.2s ease;
}
.view-board-btn:hover {
background: #008833;
transform: translateY(-2px);
}
/* Scoreboard Page Styles - TV Optimized */
.scoreboard-page {
height: 100vh;
padding: 40px;
font-family: 'PlinkoFont', 'Arial Black', Arial, sans-serif;
background: linear-gradient(135deg,
var(--pir-blue) 0%,
var(--pir-light-blue) 50%,
#4488DD 100%);
}
.scoreboard-container {
height: 100%;
display: flex;
flex-direction: column;
max-width: 1200px;
margin: 0 auto;
}
.scoreboard-page .title {
text-align: center;
font-size: 4rem;
color: var(--pir-yellow);
text-shadow: 4px 4px 0px var(--pir-red),
8px 8px 16px rgba(0,0,0,0.7);
margin-bottom: 40px;
text-transform: uppercase;
letter-spacing: 3px;
}
.scoreboard-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 30px;
flex-grow: 1;
align-items: start;
}
.column {
display: flex;
flex-direction: column;
gap: 15px;
}
.score-row {
display: grid;
grid-template-columns: auto 1fr auto;
gap: 20px;
background: var(--pir-white);
color: var(--pir-blue);
padding: 25px;
border-radius: 15px;
border: 6px solid var(--pir-yellow);
box-shadow: 0 8px 16px rgba(0,0,0,0.4);
align-items: center;
transition: all 0.5s ease;
min-height: 80px;
animation: slideIn 0.3s ease-out;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateX(-20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.score-row:hover {
transform: scale(1.02);
box-shadow: 0 12px 24px rgba(0,0,0,0.5);
}
.score-row.leader {
border-color: var(--pir-orange);
background: linear-gradient(135deg, var(--pir-white), #FFF8DC);
box-shadow: 0 12px 24px rgba(255, 136, 0, 0.4);
}
.player-rank {
font-size: 2rem;
font-weight: bold;
background: var(--pir-red);
color: var(--pir-white);
width: 50px;
height: 50px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
text-shadow: 2px 2px 4px rgba(0,0,0,0.3);
}
.player-name {
font-size: 1.8rem;
font-weight: bold;
text-align: left;
text-transform: uppercase;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.player-score {
font-size: 2.5rem;
font-weight: bold;
color: var(--pir-red);
text-shadow: 2px 2px 4px rgba(0,0,0,0.2);
min-width: 80px;
text-align: right;
}
/* Responsive Design */
@media (max-width: 768px) {
.input-grid {
grid-template-columns: 1fr;
}
.input-page h1 {
font-size: 2rem;
}
}
@media (max-width: 1024px) {
.scoreboard-page .title {
font-size: 3rem;
}
.score-row {
padding: 20px;
min-height: 70px;
}
.player-rank {
width: 40px;
height: 40px;
font-size: 1.5rem;
}
.player-name {
font-size: 1.5rem;
}
.player-score {
font-size: 2rem;
}
}

35
templates/input.html Normal file
View file

@ -0,0 +1,35 @@
<!-- templates/input.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Plinko Score Input</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body class="input-page">
<div class="container">
<h1>Fig Fam Fantasy Plinko</h1>
<div class="input-grid">
{% for player in players %}
<div class="player-card" data-id="{{ player.id }}">
<label>Player {{ player.id }}</label>
<input type="text"
class="name-input"
value="{{ player.name }}"
placeholder="Player Name">
<input type="number"
class="score-input"
value="{{ player.score }}"
placeholder="0">
<button class="update-btn">Update</button>
</div>
{% endfor %}
</div>
<div class="actions">
<a href="/scoreboard" class="view-board-btn">View Scoreboard</a>
</div>
</div>
<script src="/static/script.js"></script>
</body>
</html>

36
templates/scoreboard.html Normal file
View file

@ -0,0 +1,36 @@
<!-- templates/scoreboard.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Plinko Scoreboard</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body class="scoreboard-page">
<div class="scoreboard-container">
<h1 class="title">Fig Fam Fantasy Plinko</h1>
<div class="scoreboard-grid">
<div class="column">
{% for player in players[:7] %}
<div class="score-row" data-id="{{ player.id }}">
<span class="player-rank">{{ loop.index }}</span>
<span class="player-name">{{ player.name }}</span>
<span class="player-score">{{ player.score }}</span>
</div>
{% endfor %}
</div>
<div class="column">
{% for player in players[7:14] %}
<div class="score-row" data-id="{{ player.id }}">
<span class="player-rank">{{ loop.index + 7 }}</span>
<span class="player-name">{{ player.name }}</span>
<span class="player-score">{{ player.score }}</span>
</div>
{% endfor %}
</div>
</div>
</div>
<script src="/static/scoreboard.js"></script>
</body>
</html>