commit 586014e23c439c6bc56d1ed7810aa006b44975f5 Author: Eduardo Figueroa Date: Thu Aug 28 21:32:08 2025 -0700 init diff --git a/app.py b/app.py new file mode 100644 index 0000000..76272ea --- /dev/null +++ b/app.py @@ -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) diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..af22977 --- /dev/null +++ b/nginx.conf @@ -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"; + } +} + diff --git a/static/pricedown-b1.ttf b/static/pricedown-b1.ttf new file mode 100644 index 0000000..8ba3a97 Binary files /dev/null and b/static/pricedown-b1.ttf differ diff --git a/static/scoreboard.js b/static/scoreboard.js new file mode 100644 index 0000000..2c9bae6 --- /dev/null +++ b/static/scoreboard.js @@ -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 = ` + ${rank} + ${player.name} + ${player.score} + `; + + return row; + } + + // Prevent screen sleep for TV display + if ('wakeLock' in navigator) { + navigator.wakeLock.request('screen').catch(err => { + console.log('Wake lock failed:', err); + }); + } +}); diff --git a/static/script.js b/static/script.js new file mode 100644 index 0000000..11d61a2 --- /dev/null +++ b/static/script.js @@ -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'); + } + } +}); diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..541dc54 --- /dev/null +++ b/static/style.css @@ -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; + } +} diff --git a/templates/input.html b/templates/input.html new file mode 100644 index 0000000..0578a1d --- /dev/null +++ b/templates/input.html @@ -0,0 +1,35 @@ + + + + + + + Plinko Score Input + + + +
+

Fig Fam Fantasy Plinko

+
+ {% for player in players %} +
+ + + + +
+ {% endfor %} +
+ +
+ + + diff --git a/templates/scoreboard.html b/templates/scoreboard.html new file mode 100644 index 0000000..3307f61 --- /dev/null +++ b/templates/scoreboard.html @@ -0,0 +1,36 @@ + + + + + + + Plinko Scoreboard + + + +
+

Fig Fam Fantasy Plinko

+
+
+ {% for player in players[:7] %} +
+ {{ loop.index }} + {{ player.name }} + {{ player.score }} +
+ {% endfor %} +
+
+ {% for player in players[7:14] %} +
+ {{ loop.index + 7 }} + {{ player.name }} + {{ player.score }} +
+ {% endfor %} +
+
+
+ + +