Initial commit

This commit is contained in:
Nick 2026-06-27 01:56:15 -05:00
commit 3dc3bc75ca
20 changed files with 1862 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
__pycache__/
*.pyc
.DS_Store

283
app.py Normal file
View File

@ -0,0 +1,283 @@
#!/usr/bin/env python3
import os
import sys
import json
import queue
import threading
import socket
import time
try:
import tomllib
except ModuleNotFoundError:
try:
import tomli as tomllib
except ModuleNotFoundError:
print("tomli/tomllib not found. Install with: pip install tomli")
sys.exit(1)
try:
import tomli_w
except ModuleNotFoundError:
tomli_w = None
from flask import Flask, render_template, jsonify, Response, request
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
CONFIG_PATH = os.path.join(BASE_DIR, "config.toml")
with open(CONFIG_PATH, "rb") as f:
config = tomllib.load(f)
PORT = 50743
app = Flask(__name__)
scores = {"left": 0, "right": 0}
subscribers = []
sub_lock = threading.Lock()
def get_local_ip():
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
s.connect(("10.255.255.255", 1))
ip = s.getsockname()[0]
except:
ip = "127.0.0.1"
finally:
s.close()
return ip
def udp_beacon():
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
while True:
try:
payload = json.dumps({
"display_name": config.get("display_name", ""),
"ip": get_local_ip(),
"port": PORT,
})
sock.sendto(payload.encode(), ("255.255.255.255", 50744))
except:
pass
time.sleep(10)
threading.Thread(target=udp_beacon, daemon=True).start()
def broadcast(data):
dead = []
with sub_lock:
for q in subscribers:
try:
q.put_nowait(json.dumps(data))
except:
dead.append(q)
for q in dead:
subscribers.remove(q)
def cors(resp):
resp.headers["Access-Control-Allow-Origin"] = "*"
resp.headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, DELETE, OPTIONS"
resp.headers["Access-Control-Allow-Headers"] = "Content-Type"
return resp
def save_config():
if tomli_w is None:
return
with open(CONFIG_PATH, "wb") as f:
tomli_w.dump(config, f)
@app.route("/")
def index():
return render_template(
"index.html",
display_id=config["display_id"],
header=config.get("header", ""),
header_font_size=config.get("header_font_size", 0),
content_padding_top=config.get("content_padding_top", 0),
content_gap=config.get("content_gap", 1),
left_name=config["left"]["name"],
left_color=config["left"]["color"],
left_subtitle=config["left"].get("subtitle", ""),
left_name_font_size=config["left"].get("name_font_size", 0),
left_subtitle_font_size=config["left"].get("subtitle_font_size", 0),
left_score_font_size=config["left"].get("score_font_size", 0),
right_name=config["right"]["name"],
right_color=config["right"]["color"],
right_subtitle=config["right"].get("subtitle", ""),
right_name_font_size=config["right"].get("name_font_size", 0),
right_subtitle_font_size=config["right"].get("subtitle_font_size", 0),
right_score_font_size=config["right"].get("score_font_size", 0),
left_score=scores["left"],
right_score=scores["right"],
)
@app.route("/events")
def sse():
def stream():
q = queue.Queue()
with sub_lock:
subscribers.append(q)
try:
while True:
data = q.get()
yield f"data: {data}\n\n"
except GeneratorExit:
with sub_lock:
if q in subscribers:
subscribers.remove(q)
return Response(stream(), mimetype="text/event-stream")
@app.route("/api/status")
def api_status():
return cors(jsonify({
"display_id": config["display_id"],
"display_name": config.get("display_name", ""),
"header": config.get("header", ""),
"header_font_size": config.get("header_font_size", 0),
"content_padding_top": config.get("content_padding_top", 0),
"content_gap": config.get("content_gap", 1),
"ip": get_local_ip(),
"port": PORT,
"scores": dict(scores),
"config": {
"left": {
"name": config["left"]["name"],
"subtitle": config["left"].get("subtitle", ""),
"color": config["left"]["color"],
"name_font_size": config["left"].get("name_font_size", 0),
"subtitle_font_size": config["left"].get("subtitle_font_size", 0),
"score_font_size": config["left"].get("score_font_size", 0),
},
"right": {
"name": config["right"]["name"],
"subtitle": config["right"].get("subtitle", ""),
"color": config["right"]["color"],
"name_font_size": config["right"].get("name_font_size", 0),
"subtitle_font_size": config["right"].get("subtitle_font_size", 0),
"score_font_size": config["right"].get("score_font_size", 0),
},
},
}))
@app.route("/api/score/<side>", methods=["PUT", "OPTIONS"])
def api_set_score(side):
if request.method == "OPTIONS":
return cors(jsonify({}))
if side not in scores:
return cors((jsonify({"error": "invalid side"}), 400))
data = request.get_json(silent=True) or {}
value = data.get("value")
if value is None or not isinstance(value, int) or value < 0:
return cors((jsonify({"error": "value must be a non-negative integer"}), 400))
scores[side] = value
broadcast({"type": "score", "scores": dict(scores), "side": side})
return cors(jsonify(scores))
@app.route("/api/score/<side>/reset", methods=["POST", "OPTIONS"])
def api_reset_side(side):
if request.method == "OPTIONS":
return cors(jsonify({}))
if side in scores:
scores[side] = 0
broadcast({"type": "score", "scores": dict(scores), "side": side})
return cors(jsonify(scores))
@app.route("/api/score/reset", methods=["POST", "OPTIONS"])
def api_reset_all():
if request.method == "OPTIONS":
return cors(jsonify({}))
scores["left"] = 0
scores["right"] = 0
broadcast({"type": "score", "scores": dict(scores), "side": None})
return cors(jsonify(scores))
@app.route("/api/score/<side>/increment", methods=["POST", "OPTIONS"])
def api_increment(side):
if request.method == "OPTIONS":
return cors(jsonify({}))
if side in scores:
scores[side] += 1
broadcast({"type": "score", "scores": dict(scores), "side": side})
return cors(jsonify(scores))
@app.route("/api/score/<side>/decrement", methods=["POST", "OPTIONS"])
def api_decrement(side):
if request.method == "OPTIONS":
return cors(jsonify({}))
if side in scores and scores[side] > 0:
scores[side] -= 1
broadcast({"type": "score", "scores": dict(scores), "side": side})
return cors(jsonify(scores))
@app.route("/api/config", methods=["PUT", "OPTIONS"])
def api_update_config():
if request.method == "OPTIONS":
return cors(jsonify({}))
data = request.get_json(silent=True) or {}
if "display_name" in data:
config["display_name"] = data["display_name"]
if "header" in data:
config["header"] = data["header"]
if "header_font_size" in data:
config["header_font_size"] = data["header_font_size"]
if "content_padding_top" in data:
config["content_padding_top"] = data["content_padding_top"]
if "content_gap" in data:
config["content_gap"] = data["content_gap"]
for side in ("left", "right"):
if side in data:
for key in ("name", "subtitle", "color", "name_font_size", "subtitle_font_size", "score_font_size"):
if key in data[side]:
config[side][key] = data[side][key]
save_config()
broadcast({"type": "config", "config": data})
return cors(jsonify({"status": "ok"}))
@app.route("/increment/<side>", methods=["GET", "POST"])
def increment(side):
if side in scores:
scores[side] += 1
broadcast({"type": "score", "scores": dict(scores), "side": side})
return jsonify(scores)
@app.route("/reset", methods=["GET", "POST"])
def reset():
scores["left"] = 0
scores["right"] = 0
broadcast({"type": "score", "scores": dict(scores), "side": None})
return jsonify(scores)
@app.route("/reset/<side>", methods=["GET", "POST"])
def reset_side(side):
if side in scores:
scores[side] = 0
broadcast({"type": "score", "scores": dict(scores), "side": side})
return jsonify(scores)
if __name__ == "__main__":
print(f" CuteBoard Display #{config['display_id']}")
print(f" {config['left']['name']} ({config['left']['color']}) vs {config['right']['name']} ({config['right']['color']})")
print(f" URL: http://0.0.0.0:{PORT}")
app.run(host="0.0.0.0", port=PORT, debug=False, threaded=True)

22
config.toml Normal file
View File

@ -0,0 +1,22 @@
display_id = 1
display_name = "Test"
header = "Grand Finals"
header_font_size = 4
content_padding_top = 4
content_gap = 1
[left]
name = "Tim"
subtitle = "Test 456"
color = "#dc2626"
name_font_size = 3.75
subtitle_font_size = 1.25
score_font_size = 30
[right]
name = "Nick"
subtitle = "Test 123"
color = "#1a5fb4"
name_font_size = 3.75
subtitle_font_size = 1.25
score_font_size = 30

228
controller/app.py Normal file
View File

@ -0,0 +1,228 @@
#!/usr/bin/env python3
import json
import socket
import struct
import threading
import time
import concurrent.futures
from collections import OrderedDict
import requests
from flask import Flask, jsonify, render_template, request
CTRL_PORT = 50742
DISCOVERY_PORT = 50744
BOARD_PORT = 50743
SCAN_TIMEOUT = 1.5
POLL_INTERVAL = 3
app = Flask(__name__)
boards = OrderedDict()
boards_lock = threading.Lock()
offline_threshold = POLL_INTERVAL * 3
def get_local_ip():
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
s.connect(("10.255.255.255", 1))
ip = s.getsockname()[0]
except:
ip = "127.0.0.1"
finally:
s.close()
return ip
def get_subnet():
ip = get_local_ip()
parts = ip.split(".")
return ".".join(parts[:3])
def check_port(ip, port, timeout=SCAN_TIMEOUT):
try:
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.settimeout(timeout)
s.connect((ip, port))
s.close()
return ip
except:
return None
def udp_listener():
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.settimeout(1)
sock.bind(("0.0.0.0", DISCOVERY_PORT))
while True:
try:
data, addr = sock.recvfrom(2048)
msg = json.loads(data)
ip = msg.get("ip", addr[0])
port = msg.get("port", BOARD_PORT)
with boards_lock:
if ip not in boards:
print(f" Discovered board: {msg.get('display_name', ip)} at {ip}:{port}")
boards[ip] = {
"display_name": msg.get("display_name", ""),
"ip": ip,
"port": port,
"last_seen": time.time(),
"online": True,
"status": boards.get(ip, {}).get("status"),
}
except socket.timeout:
pass
except:
pass
def scan_subnet():
subnet = get_subnet()
found = []
with concurrent.futures.ThreadPoolExecutor(max_workers=100) as pool:
futures = {pool.submit(check_port, f"{subnet}.{i}", BOARD_PORT): i for i in range(1, 255)}
for future in concurrent.futures.as_completed(futures):
ip = future.result()
if ip:
found.append(ip)
with boards_lock:
for ip in found:
if ip not in boards:
boards[ip] = {
"display_name": f"Board ({ip})",
"ip": ip,
"port": BOARD_PORT,
"last_seen": time.time(),
"online": True,
"status": None,
}
print(f" Scanned board: {ip}")
return found
def refresh_board_cache(ip, port):
try:
r = requests.get(f"http://{ip}:{port}/api/status", timeout=3)
if r.status_code == 200:
data = r.json()
with boards_lock:
if ip in boards:
boards[ip]["status"] = data
boards[ip]["online"] = True
boards[ip]["last_seen"] = time.time()
if data.get("display_name"):
boards[ip]["display_name"] = data["display_name"]
except:
pass
def poll_boards():
while True:
with boards_lock:
snapshot = list(boards.items())
for ip, board in snapshot:
try:
r = requests.get(f"http://{ip}:{board['port']}/api/status", timeout=3)
if r.status_code == 200:
data = r.json()
with boards_lock:
if ip in boards:
boards[ip]["status"] = data
boards[ip]["online"] = True
boards[ip]["last_seen"] = time.time()
if data.get("display_name"):
boards[ip]["display_name"] = data["display_name"]
else:
with boards_lock:
if ip in boards:
boards[ip]["online"] = False
except:
with boards_lock:
if ip in boards:
boards[ip]["online"] = False
time.sleep(POLL_INTERVAL)
threading.Thread(target=udp_listener, daemon=True).start()
threading.Thread(target=poll_boards, daemon=True).start()
@app.route("/")
def index():
return render_template("index.html")
@app.route("/api/boards")
def api_boards():
now = time.time()
with boards_lock:
result = []
for ip, board in list(boards.items()):
stale = (now - board["last_seen"]) > offline_threshold
status = board.get("status") or {}
scores = status.get("scores") or {}
config_data = status.get("config") or {}
result.append({
"display_name": board["display_name"],
"ip": ip,
"port": board["port"],
"online": board["online"] and not stale,
"scores": scores,
"config": config_data,
})
return jsonify(result)
@app.route("/api/scan", methods=["POST"])
def api_scan():
def do_scan():
found = scan_subnet()
print(f" Scan complete: {len(found)} boards found")
threading.Thread(target=do_scan, daemon=True).start()
return jsonify({"status": "scanning"})
@app.route("/api/boards/<ip>/<path:endpoint>", methods=["GET", "POST", "PUT", "OPTIONS"])
def api_proxy(ip, endpoint):
port = BOARD_PORT
with boards_lock:
if ip in boards:
port = boards[ip]["port"]
url = f"http://{ip}:{port}/{endpoint}"
method = request.method
headers = {"Content-Type": "application/json"} if request.is_json else {}
try:
if method == "GET":
r = requests.get(url, timeout=5, headers=headers)
elif method == "POST":
r = requests.post(url, timeout=5, json=request.get_json(silent=True), headers=headers)
elif method == "PUT":
r = requests.put(url, timeout=5, json=request.get_json(silent=True), headers=headers)
else:
r = requests.options(url, timeout=5, headers=headers)
resp = app.make_response((r.content, r.status_code, dict(r.headers)))
resp.headers["Access-Control-Allow-Origin"] = "*"
# Immediately refresh cached status after any score-modifying action
if method in ("POST", "PUT"):
threading.Thread(target=refresh_board_cache, args=(ip, port), daemon=True).start()
return resp
except Exception as e:
return jsonify({"error": str(e)}), 502
@app.after_request
def add_cors(resp):
resp.headers["Access-Control-Allow-Origin"] = "*"
resp.headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, DELETE, OPTIONS"
resp.headers["Access-Control-Allow-Headers"] = "Content-Type"
return resp
if __name__ == "__main__":
print(f" CuteBoard Controller")
print(f" URL: http://0.0.0.0:{CTRL_PORT}")
app.run(host="0.0.0.0", port=CTRL_PORT, debug=False, threaded=True)

View File

@ -0,0 +1,2 @@
flask>=3.0
requests>=2.31

View File

@ -0,0 +1,271 @@
(function () {
'use strict';
var grid = document.getElementById('board-grid');
var emptyMsg = document.getElementById('empty-msg');
var modalOverlay = document.getElementById('config-modal');
var configForm = document.getElementById('config-form');
var editingBoard = null;
function esc(s) {
if (typeof s !== 'string') return '';
var d = document.createElement('div');
d.appendChild(document.createTextNode(s));
return d.innerHTML;
}
// ---- Fetch boards ----
function fetchBoards() {
fetch('/api/boards')
.then(function (r) { return r.json(); })
.then(function (boards) { renderBoards(boards); })
.catch(function () {});
}
// ---- Render board cards ----
function renderBoards(boards) {
if (boards.length === 0) {
emptyMsg.style.display = 'block';
grid.innerHTML = '';
grid.appendChild(emptyMsg);
return;
}
emptyMsg.style.display = 'none';
var html = '';
boards.forEach(function (board) {
var statusClass = board.online ? 'online' : 'offline';
var badgeClass = board.online ? 'badge-online' : 'badge-offline';
var left = board.config && board.config.left || {};
var right = board.config && board.config.right || {};
var scores = board.scores || {};
var leftScore = scores.left != null ? scores.left : '-';
var rightScore = scores.right != null ? scores.right : '-';
var leftColor = left.color || '#dc2626';
var rightColor = right.color || '#2563eb';
html +=
'<div class="card ' + statusClass + ' p-4" data-board="' + board.ip + '">' +
'<div class="flex items-center justify-between mb-3">' +
'<div class="flex items-center gap-2">' +
'<span class="' + badgeClass + '"></span>' +
'<span class="font-semibold text-base">' + esc(board.display_name || board.ip) + '</span>' +
'<span class="text-xs text-slate-600">' + board.ip + '</span>' +
'</div>' +
'</div>' +
'<div class="flex gap-4">' +
// Left team
'<div class="flex-1 bg-black/20 rounded-lg p-3 text-center team-column"' +
' style="border-left: 3px solid ' + leftColor + ';" data-side="left">' +
'<div class="team-name text-sm mb-1" style="color:' + leftColor + ';">' + esc(left.name || '???') + '</div>' +
'<div class="score text-3xl font-black mb-2" style="color:' + leftColor + ';">' + leftScore + '</div>' +
'<div class="flex gap-1 justify-center">' +
'<button class="btn btn-dec" data-side="left">1</button>' +
'<button class="btn btn-inc" data-side="left">+1</button>' +
'<button class="btn btn-reset" data-side="left">↺</button>' +
'</div>' +
'</div>' +
// Right team
'<div class="flex-1 bg-black/20 rounded-lg p-3 text-center team-column"' +
' style="border-right: 3px solid ' + rightColor + ';" data-side="right">' +
'<div class="team-name text-sm mb-1" style="color:' + rightColor + ';">' + esc(right.name || '???') + '</div>' +
'<div class="score text-3xl font-black mb-2" style="color:' + rightColor + ';">' + rightScore + '</div>' +
'<div class="flex gap-1 justify-center">' +
'<button class="btn btn-dec" data-side="right">1</button>' +
'<button class="btn btn-inc" data-side="right">+1</button>' +
'<button class="btn btn-reset" data-side="right">↺</button>' +
'</div>' +
'</div>' +
'</div>' +
'<div class="flex gap-2 mt-3">' +
'<button class="btn btn-edit flex-1" onclick="window.openConfig(\'' + board.ip + '\')">⚙ Edit Config</button>' +
'<button class="btn btn-all-reset flex-1">⟳ Reset All</button>' +
'</div>' +
'</div>';
});
grid.innerHTML = html;
}
// ---- Optimistic score updates ----
function updateScoreLocal(card, side, delta) {
var col = card.querySelector('.team-column[data-side="' + side + '"]');
if (!col) return;
var el = col.querySelector('.score');
if (!el) return;
var cur = parseInt(el.textContent);
if (isNaN(cur)) return;
var next = Math.max(0, cur + delta);
el.textContent = next;
}
function setScoreLocal(card, side, value) {
var col = card.querySelector('.team-column[data-side="' + side + '"]');
if (!col) return;
var el = col.querySelector('.score');
if (!el) return;
el.textContent = value;
}
// ---- Re-poll single board directly (bypasses controller cache) ----
function refetchBoard(ip) {
fetch('/api/boards/' + ip + '/api/status')
.then(function (r) { return r.json(); })
.then(function (data) {
var card = document.querySelector('.card[data-board="' + ip + '"]');
if (!card) return;
var scores = data.scores || {};
var leftEl = card.querySelector('.team-column[data-side="left"] .score');
var rightEl = card.querySelector('.team-column[data-side="right"] .score');
if (leftEl) leftEl.textContent = scores.left != null ? scores.left : '-';
if (rightEl) rightEl.textContent = scores.right != null ? scores.right : '-';
})
.catch(function () {});
}
// ---- Actions (optimistic + re-poll board directly) ----
function increment(ip, side, card) {
updateScoreLocal(card, side, 1);
fetch('/api/boards/' + ip + '/api/score/' + side + '/increment', { method: 'POST' }).then(function () { refetchBoard(ip); }).catch(function () {});
}
function decrement(ip, side, card) {
updateScoreLocal(card, side, -1);
fetch('/api/boards/' + ip + '/api/score/' + side + '/decrement', { method: 'POST' }).then(function () { refetchBoard(ip); }).catch(function () {});
}
function resetSide(ip, side, card) {
setScoreLocal(card, side, 0);
fetch('/api/boards/' + ip + '/api/score/' + side + '/reset', { method: 'POST' }).then(function () { refetchBoard(ip); }).catch(function () {});
}
function resetAll(ip, card) {
setScoreLocal(card, 'left', 0);
setScoreLocal(card, 'right', 0);
fetch('/api/boards/' + ip + '/api/score/reset', { method: 'POST' }).then(function () { refetchBoard(ip); }).catch(function () {});
}
function scanNetwork() {
fetch('/api/scan', { method: 'POST' }).catch(function () {});
}
// ---- Event delegation ----
grid.addEventListener('click', function (e) {
var btn = e.target.closest('button');
if (!btn) return;
var card = btn.closest('.card');
if (!card) return;
var ip = card.dataset.board;
if (!ip) return;
var side = btn.dataset.side;
if (btn.classList.contains('btn-inc') && side) {
increment(ip, side, card);
} else if (btn.classList.contains('btn-dec') && side) {
decrement(ip, side, card);
} else if (btn.classList.contains('btn-reset') && side) {
resetSide(ip, side, card);
} else if (btn.classList.contains('btn-all-reset')) {
resetAll(ip, card);
}
});
// ---- Edit Config Modal ----
window.openConfig = function (ip) {
editingBoard = ip;
document.getElementById('modal-title').textContent = 'Edit Config — ' + ip;
fetch('/api/boards/' + ip + '/api/status')
.then(function (r) { return r.json(); })
.then(function (data) {
var cfg = data.config || {};
document.getElementById('edit-display-name').value = data.display_name || '';
document.getElementById('edit-header').value = data.header || '';
document.getElementById('edit-header-font-size').value = data.header_font_size || '';
document.getElementById('edit-content-padding-top').value = data.content_padding_top || '';
document.getElementById('edit-content-gap').value = data.content_gap || '';
document.getElementById('edit-left-name').value = (cfg.left && cfg.left.name) || '';
document.getElementById('edit-left-subtitle').value = (cfg.left && cfg.left.subtitle) || '';
document.getElementById('edit-left-color').value = (cfg.left && cfg.left.color) || '#dc2626';
document.getElementById('edit-left-font-size').value = (cfg.left && cfg.left.name_font_size) || '';
document.getElementById('edit-left-subtitle-font-size').value = (cfg.left && cfg.left.subtitle_font_size) || '';
document.getElementById('edit-left-score-font-size').value = (cfg.left && cfg.left.score_font_size) || '';
document.getElementById('edit-right-name').value = (cfg.right && cfg.right.name) || '';
document.getElementById('edit-right-subtitle').value = (cfg.right && cfg.right.subtitle) || '';
document.getElementById('edit-right-color').value = (cfg.right && cfg.right.color) || '#2563eb';
document.getElementById('edit-right-font-size').value = (cfg.right && cfg.right.name_font_size) || '';
document.getElementById('edit-right-subtitle-font-size').value = (cfg.right && cfg.right.subtitle_font_size) || '';
document.getElementById('edit-right-score-font-size').value = (cfg.right && cfg.right.score_font_size) || '';
modalOverlay.classList.add('open');
})
.catch(function () { alert('Could not fetch board config'); });
};
function closeModal() {
modalOverlay.classList.remove('open');
editingBoard = null;
}
window.closeModal = closeModal;
configForm.addEventListener('submit', function (e) {
e.preventDefault();
if (!editingBoard) return;
var payload = {
display_name: document.getElementById('edit-display-name').value,
header: document.getElementById('edit-header').value,
header_font_size: parseFloat(document.getElementById('edit-header-font-size').value) || 0,
content_padding_top: parseFloat(document.getElementById('edit-content-padding-top').value) || 0,
content_gap: parseFloat(document.getElementById('edit-content-gap').value) || 1,
left: {
name: document.getElementById('edit-left-name').value,
subtitle: document.getElementById('edit-left-subtitle').value,
color: document.getElementById('edit-left-color').value,
name_font_size: parseFloat(document.getElementById('edit-left-font-size').value) || 0,
subtitle_font_size: parseFloat(document.getElementById('edit-left-subtitle-font-size').value) || 0,
score_font_size: parseFloat(document.getElementById('edit-left-score-font-size').value) || 0,
},
right: {
name: document.getElementById('edit-right-name').value,
subtitle: document.getElementById('edit-right-subtitle').value,
color: document.getElementById('edit-right-color').value,
name_font_size: parseFloat(document.getElementById('edit-right-font-size').value) || 0,
subtitle_font_size: parseFloat(document.getElementById('edit-right-subtitle-font-size').value) || 0,
score_font_size: parseFloat(document.getElementById('edit-right-score-font-size').value) || 0,
},
};
var saveBtn = configForm.querySelector('.btn-save');
saveBtn.textContent = 'Saving...';
saveBtn.disabled = true;
fetch('/api/boards/' + editingBoard + '/api/config', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
.then(function (r) {
if (r.ok) closeModal();
else alert('Failed to save config');
})
.catch(function () { alert('Failed to save config'); })
.finally(function () {
saveBtn.textContent = 'Save Config';
saveBtn.disabled = false;
});
});
modalOverlay.addEventListener('click', function (e) {
if (e.target === modalOverlay) closeModal();
});
// ---- Polling ----
fetchBoards();
setInterval(fetchBoards, 3000);
window.scanNetwork = scanNetwork;
})();

View File

@ -0,0 +1,129 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CuteBoard Controller</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>
* { -webkit-user-select: none; user-select: none; }
body { background: #0a0a0f; color: #e2e8f0; font-family: system-ui, sans-serif; }
.team-name { font-weight: 700; letter-spacing: 0.15em; text-transform: uppercase; }
.score { font-weight: 900; font-size: 1.75rem; }
.card { background: #13131f; border: 1px solid #1e1e30; border-radius: 1rem; transition: border-color 0.3s; }
.card.online { border-color: #2d2d50; }
.card.offline { opacity: 0.5; }
.badge-online { width: 10px; height: 10px; border-radius: 50%; display: inline-block; background: #22c55e; }
.badge-offline { width: 10px; height: 10px; border-radius: 50%; display: inline-block; background: #ef4444; }
.btn { padding: 0.4rem 0.8rem; border-radius: 0.5rem; font-size: 0.85rem; font-weight: 600; cursor: pointer; border: none; transition: filter 0.15s; }
.btn:hover { filter: brightness(1.2); }
.btn:active { filter: brightness(0.9); }
.btn-inc { background: #1e40af; color: white; }
.btn-dec { background: #1e40af; color: white; }
.btn-reset { background: #7f1d1d; color: white; }
.btn-edit { background: #713f12; color: white; }
.btn-scan { background: #14532d; color: white; font-size: 1rem; padding: 0.6rem 1.5rem; }
.btn-all-reset { background: #3b0764; color: white; }
.modal-overlay { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.7); z-index: 100; justify-content: center; align-items: center; }
.modal-overlay.open { display: flex; }
.modal { background: #13131f; border: 1px solid #2d2d50; border-radius: 1rem; padding: 2rem; width: 90%; max-width: 600px; max-height: 90vh; overflow-y: auto; }
.modal h2 { font-size: 1.5rem; margin-bottom: 1.5rem; }
.modal label { display: block; font-size: 0.8rem; color: #94a3b8; margin-bottom: 0.3rem; margin-top: 1rem; text-transform: uppercase; letter-spacing: 0.1em; }
.modal input, .modal select { width: 100%; padding: 0.5rem 0.75rem; border-radius: 0.5rem; background: #1e1e30; border: 1px solid #2d2d50; color: white; font-size: 0.95rem; outline: none; }
.modal input:focus { border-color: #6366f1; }
.modal .btn-save { background: #22c55e; color: white; padding: 0.6rem 2rem; font-size: 1rem; margin-top: 1.5rem; }
input[type="color"] { height: 2.5rem; padding: 0.25rem; cursor: pointer; }
.tech-details { border-top: 1px solid #1e1e30; padding-top: 0.25rem; }
.tech-details summary { outline: none; }
.tech-details summary::-webkit-details-marker { color: #94a3b8; }
.tech-details[open] { padding-bottom: 0.5rem; }
</style>
</head>
<body>
<div class="container mx-auto px-4 py-6">
<div class="flex items-center justify-between mb-8">
<div>
<h1 class="text-2xl font-bold tracking-wider">CuteBoard Controller</h1>
<p class="text-sm text-slate-500 mt-1">Discovered boards are updated automatically</p>
</div>
<div class="flex gap-3">
<button class="btn btn-scan" onclick="scanNetwork()">⟳ Scan Network</button>
</div>
</div>
<div id="board-grid" class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
<div class="col-span-full text-center text-slate-600 py-16" id="empty-msg">
Waiting for boards to appear...<br>
<span class="text-sm">Make sure CuteBoard is running on your Pis</span>
</div>
</div>
</div>
<!-- Edit Config Modal -->
<div class="modal-overlay" id="config-modal">
<div class="modal">
<h2 id="modal-title">Edit Config</h2>
<form id="config-form">
<label>Header Text</label>
<input type="text" id="edit-header" placeholder="Leave blank to hide">
<div class="grid grid-cols-2 gap-4" style="margin-top: 1rem;">
<div>
<label>Left Team Name</label>
<input type="text" id="edit-left-name">
<label>Left Subtitle</label>
<input type="text" id="edit-left-subtitle">
<label>Left Color</label>
<input type="color" id="edit-left-color">
</div>
<div>
<label>Right Team Name</label>
<input type="text" id="edit-right-name">
<label>Right Subtitle</label>
<input type="text" id="edit-right-subtitle">
<label>Right Color</label>
<input type="color" id="edit-right-color">
</div>
</div>
<details class="tech-details" style="margin-top: 1.5rem;">
<summary style="cursor:pointer;font-size:0.8rem;color:#94a3b8;text-transform:uppercase;letter-spacing:0.1em;padding:0.5rem 0;user-select:none;">Technical Settings</summary>
<label>Display Name</label>
<input type="text" id="edit-display-name" placeholder="e.g. Main Scoreboard">
<label>Header Font Size (rem)</label>
<input type="number" id="edit-header-font-size" min="0" step="0.25" placeholder="0 = auto-fit">
<div class="grid grid-cols-2 gap-4">
<div>
<label>Left Name Font Size (rem)</label>
<input type="number" id="edit-left-font-size" min="0" step="0.25" placeholder="0 = auto-fit">
<label>Left Subtitle Font Size (rem)</label>
<input type="number" id="edit-left-subtitle-font-size" min="0" step="0.25" placeholder="0 = relative">
<label>Left Score Font Size (rem)</label>
<input type="number" id="edit-left-score-font-size" min="0" step="0.25" placeholder="0 = auto-fit">
</div>
<div>
<label>Right Name Font Size (rem)</label>
<input type="number" id="edit-right-font-size" min="0" step="0.25" placeholder="0 = auto-fit">
<label>Right Subtitle Font Size (rem)</label>
<input type="number" id="edit-right-subtitle-font-size" min="0" step="0.25" placeholder="0 = relative">
<label>Right Score Font Size (rem)</label>
<input type="number" id="edit-right-score-font-size" min="0" step="0.25" placeholder="0 = auto-fit">
</div>
</div>
<label>Content Padding Top (rem)</label>
<input type="number" id="edit-content-padding-top" min="0" step="0.25" placeholder="0 = none">
<label>Content Gap (rem)</label>
<input type="number" id="edit-content-gap" min="0" step="0.25" placeholder="1 = default">
</details>
<div class="flex gap-3 justify-end mt-6">
<button type="button" class="btn" style="background:#374151;color:white;padding:0.6rem 1.5rem;" onclick="closeModal()">Cancel</button>
<button type="submit" class="btn btn-save" style="margin-top:0">Save Config</button>
</div>
</form>
</div>
</div>
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
</body>
</html>

3
requirements.txt Normal file
View File

@ -0,0 +1,3 @@
flask>=3.0
tomli>=1.1.0
tomli-w>=1.0.0

13
start.sh Executable file
View File

@ -0,0 +1,13 @@
#!/usr/bin/env bash
set -e
cd "$(dirname "$0")"
python3 app.py &
APP_PID=$!
sleep 2
chromium-browser --kiosk http://localhost:50743 &
wait $APP_PID

47
static/css/fonts.css Normal file
View File

@ -0,0 +1,47 @@
@font-face {
font-family: 'Cinzel';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('../fonts/Cinzel-Regular.ttf') format('truetype');
}
@font-face {
font-family: 'Cinzel';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('../fonts/Cinzel-Bold.ttf') format('truetype');
}
@font-face {
font-family: 'Orbitron';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('../fonts/Orbitron-Regular.ttf') format('truetype');
}
@font-face {
font-family: 'Orbitron';
font-style: normal;
font-weight: 900;
font-display: swap;
src: url('../fonts/Orbitron-Black.ttf') format('truetype');
}
@font-face {
font-family: 'Playfair Display';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('../fonts/PlayfairDisplay-Bold.ttf') format('truetype');
}
@font-face {
font-family: 'Playfair Display';
font-style: italic;
font-weight: 700;
font-display: swap;
src: url('../fonts/PlayfairDisplay-BoldItalic.ttf') format('truetype');
}

325
static/css/style.css Normal file
View File

@ -0,0 +1,325 @@
* {
-webkit-user-select: none;
user-select: none;
-webkit-tap-highlight-color: transparent;
}
html, body {
margin: 0;
padding: 0;
width: 100vw;
height: 100vh;
overflow: hidden;
background: #0a0a0f;
}
/* ============ SIDES ============ */
.side {
cursor: pointer;
position: relative;
touch-action: none;
transition: filter 0.15s ease;
}
.side:active {
filter: brightness(1.15);
}
/* Hold to reset progress bar */
.side::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
height: 5px;
z-index: 30;
pointer-events: none;
background: var(--team-color);
box-shadow: 0 0 12px var(--team-color);
width: 0;
transition: none;
}
.side.holding::after {
animation: hold-progress 1.5s linear forwards;
}
.side.hold-cancel::after {
animation: hold-fade 0.2s ease-out forwards;
width: 100%;
}
@keyframes hold-progress {
0% { width: 0; }
100% { width: 100%; }
}
@keyframes hold-fade {
0% { opacity: 1; }
100% { opacity: 0; width: 100%; }
}
.side.holding {
filter: brightness(0.7) !important;
}
.side-glow {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
pointer-events: none;
z-index: 0;
}
.side-content {
position: relative;
z-index: 2;
}
/* ============ TEAM NAME ============ */
.team-name {
letter-spacing: 0.3em;
text-transform: uppercase;
color: #c9a84c;
text-shadow:
0 0 20px rgba(201, 168, 76, 0.3),
0 0 40px rgba(201, 168, 76, 0.1);
}
/* ============ TEAM SUBTITLE ============ */
.team-subtitle {
font-size: 0.55em;
letter-spacing: 0.25em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.5);
margin-top: 0.3em;
text-shadow: 0 0 12px rgba(255, 255, 255, 0.1);
}
/* ============ SCORE ============ */
.score {
position: relative;
color: inherit;
text-shadow:
0 0 0.04em currentColor,
0 0 0.1em currentColor,
0 0 0.2em color-mix(in srgb, currentColor 40%, transparent);
transition: text-shadow 0.3s ease;
}
/* ============ SCORE POP ============ */
@keyframes score-pop {
0% { transform: scale(1); }
25% { transform: scale(1.3); }
50% { transform: scale(0.95); }
75% { transform: scale(1.05); }
100% { transform: scale(1); }
}
.score-pop {
animation: score-pop 0.6s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
}
/* ============ SWEEP OVERLAY ============ */
.sweep-overlay {
position: fixed;
top: 0;
height: 100vh;
z-index: 5;
pointer-events: none;
will-change: transform, opacity;
transform: scaleX(0);
opacity: 0;
}
.side-left .sweep-overlay {
left: 0;
width: 50vw;
transform-origin: right center;
}
.side-right .sweep-overlay {
right: 0;
width: 50vw;
transform-origin: left center;
}
/* Sweep expand + hold then fade */
.sweep-overlay.sweeping {
animation:
sweep-expand 0.45s cubic-bezier(0.22, 1, 0.36, 1) forwards,
sweep-hold 0.15s ease-out 0.45s forwards,
sweep-fade 0.4s ease-out 0.6s forwards;
}
@keyframes sweep-expand {
0% { transform: scaleX(0); opacity: 1; }
100% { transform: scaleX(1); opacity: 1; }
}
@keyframes sweep-hold {
0% { opacity: 1; }
100% { opacity: 1; }
}
@keyframes sweep-fade {
0% { opacity: 1; }
100% { opacity: 0; }
}
/* ============ SWEEP GRADIENT ============ */
.sweep-gradient {
position: absolute;
inset: 0;
opacity: 0.35;
}
.side-left .sweep-gradient {
background: linear-gradient(
90deg,
var(--team-color) 0%,
var(--team-color) 40%,
var(--team-color) 60%,
color-mix(in srgb, var(--team-color) 40%, transparent) 85%,
transparent 100%
);
}
.side-right .sweep-gradient {
background: linear-gradient(
90deg,
transparent 0%,
color-mix(in srgb, var(--team-color) 40%, transparent) 15%,
var(--team-color) 40%,
var(--team-color) 60%,
var(--team-color) 100%
);
}
/* ============ LIQUID WAVES ============ */
.wave-container {
position: absolute;
inset: 0;
}
.wave {
position: absolute;
left: -25%;
width: 150%;
border-radius: 42%;
animation: wave-spin linear infinite;
will-change: transform;
}
.wave-front {
height: 160%;
top: -15%;
background: var(--team-color);
opacity: 0.2;
animation-duration: 3s;
}
.wave-back {
height: 200%;
top: -40%;
background: var(--team-color);
opacity: 0.1;
animation-duration: 5s;
animation-direction: reverse;
}
@keyframes wave-spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* ============ HAND WAVE COUNTDOWN ============ */
.side.counting {
box-shadow:
0 0 60px rgba(59, 130, 246, 0.9),
0 0 120px rgba(59, 130, 246, 0.5),
0 0 200px rgba(59, 130, 246, 0.25),
inset 0 0 80px rgba(59, 130, 246, 0.2);
animation: counting-pulse 0.6s ease-in-out infinite alternate;
outline: 4px solid rgba(59, 130, 246, 0.7);
outline-offset: -4px;
}
@keyframes counting-pulse {
0% { box-shadow: 0 0 50px rgba(59,130,246,0.7), 0 0 100px rgba(59,130,246,0.3), 0 0 160px rgba(59,130,246,0.15), inset 0 0 60px rgba(59,130,246,0.12); outline-color: rgba(59,130,246,0.5); }
100% { box-shadow: 0 0 80px rgba(59,130,246,1.0), 0 0 160px rgba(59,130,246,0.6), 0 0 260px rgba(59,130,246,0.35), inset 0 0 120px rgba(59,130,246,0.3); outline-color: rgba(59,130,246,0.9); }
}
.hw-bar {
position: absolute;
top: 0;
left: 0;
height: 8px;
z-index: 31;
pointer-events: none;
background: #60a5fa;
box-shadow: 0 0 20px #3b82f6, 0 0 40px #3b82f6;
width: 0;
}
.hw-bar.active {
animation: hw-progress 5s linear forwards;
}
@keyframes hw-progress {
0% { width: 0; }
100% { width: 100%; }
}
/* ============ RESET CONFIRM OVERLAY ============ */
#reset-confirm {
position: fixed;
inset: 0;
background: rgba(180, 0, 0, 0.85);
display: none;
align-items: center;
justify-content: center;
z-index: 100;
pointer-events: none;
}
#reset-confirm.show {
display: flex;
animation: confirm-fadein 0.3s ease-out forwards;
}
@keyframes confirm-fadein {
0% { opacity: 0; }
100% { opacity: 1; }
}
#reset-confirm span {
color: white;
font-family: 'Orbitron', sans-serif;
font-size: 4rem;
font-weight: 900;
letter-spacing: 0.1em;
text-transform: uppercase;
text-shadow: 0 0 40px rgba(255,255,255,0.3);
}
#reset-confirm .confirm-bar {
position: absolute;
bottom: 0;
left: 0;
height: 8px;
background: white;
box-shadow: 0 0 20px white, 0 0 40px white;
width: 0;
}
#reset-confirm.show .confirm-bar {
animation: confirm-progress 10s linear forwards;
}
@keyframes confirm-progress {
0% { width: 0; }
100% { width: 100%; }
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

329
static/js/main.js Normal file
View File

@ -0,0 +1,329 @@
(function () {
'use strict';
const scores = {
left: parseInt(document.getElementById('score-left').textContent) || 0,
right: parseInt(document.getElementById('score-right').textContent) || 0,
};
let debounce = false;
// ---- Auto-fit text ----
function fitText(el, maxWidth, maxHeight) {
el.style.fontSize = '10px';
if (el.scrollWidth === 0) return;
var lo = 8, hi = 600;
while (lo < hi) {
var mid = Math.ceil((lo + hi) / 2);
el.style.fontSize = mid + 'px';
if (el.scrollWidth <= maxWidth && el.scrollHeight <= maxHeight) {
lo = mid;
} else {
hi = mid - 1;
}
}
el.style.fontSize = lo + 'px';
}
function fitTextHeight(el, maxHeight) {
el.style.fontSize = '10px';
if (el.scrollHeight === 0) return;
var lo = 8, hi = 600;
while (lo < hi) {
var mid = Math.ceil((lo + hi) / 2);
el.style.fontSize = mid + 'px';
if (el.scrollHeight <= maxHeight) {
lo = mid;
} else {
hi = mid - 1;
}
}
el.style.fontSize = lo + 'px';
}
function fitAll() {
var headerRow = document.querySelector('.header-row');
if (headerRow) {
var span = headerRow.querySelector('span');
var hfs = parseFloat(headerRow.dataset.headerFontSize);
if (hfs > 0) {
span.style.fontSize = hfs + 'rem';
} else {
var w = window.innerWidth * 0.9;
fitText(span, w, 80);
}
}
document.querySelectorAll('.side').forEach(function (side) {
var rect = side.getBoundingClientRect();
var w = rect.width * 0.88;
var h = rect.height;
var name = side.querySelector('.team-name');
if (name) {
var fs = parseFloat(name.dataset.nameFontSize);
if (fs > 0) {
name.style.fontSize = fs + 'rem';
} else {
fitText(name, w, h * 0.14);
}
}
var subtitle = side.querySelector('.team-subtitle');
if (subtitle) {
var sfs = parseFloat(subtitle.dataset.subtitleFontSize);
if (sfs > 0) {
subtitle.style.fontSize = sfs + 'rem';
} else {
subtitle.style.fontSize = '';
}
}
var score = side.querySelector('.score');
if (score) {
var sfs = parseFloat(score.dataset.scoreFontSize);
if (sfs > 0) {
score.style.fontSize = sfs + 'rem';
} else {
fitTextHeight(score, h * 0.55);
}
}
var content = side.querySelector('.side-content');
if (content) {
var cpt = parseFloat(document.querySelector('.scoreboard').dataset.contentPaddingTop);
content.style.paddingTop = cpt > 0 ? cpt + 'rem' : '';
}
});
}
window.addEventListener('load', fitAll);
window.addEventListener('resize', fitAll);
// ---- Tap / Hold handlers ----
document.querySelectorAll('.side').forEach(function (side) {
var sideName = side.dataset.side;
var holdTimer = null;
var holdFadeTimer = null;
var holdFired = false;
function onStart(e) {
e.preventDefault();
if (debounce) return;
holdFired = false;
side.classList.remove('hold-cancel');
holdFadeTimer = setTimeout(function () {
void side.offsetWidth;
side.classList.add('holding');
}, 500);
holdTimer = setTimeout(function () {
holdFired = true;
side.classList.remove('holding');
side.classList.remove('hold-cancel');
resetSide(sideName);
}, 2000);
}
function onEnd(e) {
if (holdFadeTimer) { clearTimeout(holdFadeTimer); holdFadeTimer = null; }
if (holdTimer) { clearTimeout(holdTimer); holdTimer = null; }
side.classList.remove('holding');
if (holdFired) {
side.classList.remove('hold-cancel');
return;
}
side.classList.add('hold-cancel');
if (debounce) return;
debounce = true;
setTimeout(function () { debounce = false; }, 400);
selectedSide = sideName;
increment(sideName);
}
function onCancel() {
if (holdFadeTimer) { clearTimeout(holdFadeTimer); holdFadeTimer = null; }
if (holdTimer) { clearTimeout(holdTimer); holdTimer = null; }
side.classList.remove('holding');
side.classList.remove('hold-cancel');
}
side.addEventListener('pointerdown', onStart);
side.addEventListener('pointerup', onEnd);
side.addEventListener('pointercancel', onCancel);
side.addEventListener('pointerleave', onCancel);
});
var selectedSide = 'left';
var counting = false;
var countdownTimer = null;
var switchCount = 0;
var originalWaveSide = 'left';
var confirming = false;
var confirmTimer = null;
function stopCountdown() {
if (countdownTimer) { clearTimeout(countdownTimer); countdownTimer = null; }
counting = false;
document.querySelectorAll('.side').forEach(function (s) {
s.classList.remove('counting');
var bar = s.querySelector('.hw-bar');
if (bar) {
bar.classList.remove('active');
bar.style.width = '0';
}
});
}
function startCountdown(side) {
stopCountdown();
counting = true;
selectedSide = side;
var el = document.querySelector('.side-' + side);
if (el) el.classList.add('counting');
var bar = document.querySelector('.side-' + side + ' .hw-bar');
if (bar) {
bar.style.width = '0';
void bar.offsetWidth;
bar.classList.add('active');
}
countdownTimer = setTimeout(function () {
stopCountdown();
switchCount = 0;
increment(side);
}, 5000);
}
function handleWave() {
if (confirming) {
clearTimeout(confirmTimer);
confirmTimer = null;
confirming = false;
document.getElementById('reset-confirm').classList.remove('show');
switchCount = 0;
fetch('/reset', { method: 'POST' }).catch(function () {});
return;
}
if (counting) {
switchCount++;
if (switchCount >= 6) {
stopCountdown();
switchCount = 0;
confirming = true;
document.getElementById('reset-confirm').classList.add('show');
confirmTimer = setTimeout(function () {
confirming = false;
document.getElementById('reset-confirm').classList.remove('show');
}, 10000);
return;
}
var mode = (switchCount - 1) % 3;
if (mode === 0) {
// Show opposite side
var next = selectedSide === 'left' ? 'right' : 'left';
startCountdown(next);
} else if (mode === 1) {
// Hidden — mistake cancel, timer keeps running silently
var next = selectedSide === 'left' ? 'right' : 'left';
selectedSide = next;
stopCountdown();
counting = true;
countdownTimer = setTimeout(function () {
stopCountdown();
switchCount = 0;
increment(selectedSide);
}, 5000);
} else {
// Show original side again
startCountdown(originalWaveSide);
}
} else {
switchCount = 0;
originalWaveSide = selectedSide;
startCountdown(selectedSide);
}
}
document.addEventListener('keydown', function (e) {
if (e.code !== 'Space') return;
e.preventDefault();
handleWave();
});
var eventSource = new EventSource('/events');
eventSource.onmessage = function (e) {
var data = JSON.parse(e.data);
if (data.type === 'score') {
var prevLeft = scores.left;
var prevRight = scores.right;
scores.left = data.scores.left;
scores.right = data.scores.right;
if (scores.left !== prevLeft) {
selectedSide = 'left';
updateDisplay('left', scores.left);
triggerSweep('left');
}
if (scores.right !== prevRight) {
selectedSide = 'right';
updateDisplay('right', scores.right);
triggerSweep('right');
}
debounce = false;
} else if (data.type === 'config') {
location.reload();
}
};
// ---- Increment ----
function increment(side) {
if (confirming) {
clearTimeout(confirmTimer);
confirmTimer = null;
confirming = false;
document.getElementById('reset-confirm').classList.remove('show');
switchCount = 0;
}
fetch('/increment/' + side, { method: 'POST' }).catch(function () {});
}
// ---- Update display ----
function updateDisplay(side, value) {
var el = document.getElementById('score-' + side);
el.textContent = value;
var sfs = parseFloat(el.dataset.scoreFontSize);
if (sfs > 0) {
el.style.fontSize = sfs + 'rem';
} else {
var sideEl = document.querySelector('.side-' + side);
var rect = sideEl.getBoundingClientRect();
fitTextHeight(el, rect.height * 0.55);
}
el.classList.remove('score-pop');
void el.offsetWidth;
el.classList.add('score-pop');
}
// ---- Trigger sweep animation ----
function triggerSweep(side) {
var overlay = document.getElementById('sweep-' + side);
if (!overlay) return;
overlay.classList.remove('sweeping');
overlay.style.animation = 'none';
void overlay.offsetHeight;
overlay.style.animation = '';
overlay.classList.add('sweeping');
}
// ---- Reset single side ----
function resetSide(side) {
fetch('/reset/' + side, { method: 'POST' }).catch(function () {});
}
})();

83
static/js/tailwind.js Normal file

File diff suppressed because one or more lines are too long

124
templates/index.html Normal file
View File

@ -0,0 +1,124 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=1920, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>CuteBoard — Display {{ display_id }}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/fonts.css') }}">
<script src="{{ url_for('static', filename='js/tailwind.js') }}"></script>
<script>
tailwind.config = {
theme: {
extend: {
fontFamily: {
playfair: ['"Playfair Display"', 'serif'],
orbitron: ['Orbitron', 'sans-serif'],
cinzel: ['Cinzel', 'serif'],
},
},
},
};
</script>
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
</head>
<body>
<div class="scoreboard flex flex-col h-screen w-screen" data-content-padding-top="{{ content_padding_top }}">
{% if header %}
<div class="header-row text-center pt-6 pointer-events-none" style="position: absolute; inset-inline: 0; top: 0; z-index: 20;"
data-header-font-size="{{ header_font_size }}">
<span class="font-cinzel tracking-widest uppercase whitespace-nowrap" style="color: white;">{{ header }}</span>
</div>
{% endif %}
<div class="sides-row flex flex-1">
<!-- ====== LEFT SIDE ====== -->
<div class="side side-left flex-1 flex flex-col items-center justify-center relative"
style="--team-color: {{ left_color }};"
data-side="left">
<div class="hw-bar"></div>
<div class="side-glow"
style="background: linear-gradient(90deg, {{ left_color }}18 0%, transparent 50%);">
</div>
<div class="side-content flex flex-col items-center justify-center" style="gap: {{ content_gap }}rem;">
<div class="score font-orbitron leading-none font-black whitespace-nowrap"
id="score-left"
data-score-font-size="{{ left_score_font_size }}"
style="color: {{ left_color }};">
{{ left_score }}
</div>
<div class="team-info text-center">
<div class="team-name font-playfair font-bold italic whitespace-nowrap"
data-name-font-size="{{ left_name_font_size }}">
{{ left_name }}
</div>
{% if left_subtitle %}
<div class="team-subtitle font-cinzel whitespace-nowrap" data-subtitle-font-size="{{ left_subtitle_font_size }}">{{ left_subtitle }}</div>
{% endif %}
</div>
</div>
<div class="sweep-overlay" id="sweep-left">
<div class="sweep-gradient"></div>
<div class="wave-container">
<div class="wave wave-front"></div>
<div class="wave wave-back"></div>
</div>
</div>
</div>
<!-- ====== RIGHT SIDE ====== -->
<div class="side side-right flex-1 flex flex-col items-center justify-center relative"
style="--team-color: {{ right_color }};"
data-side="right">
<div class="hw-bar"></div>
<div class="side-glow"
style="background: linear-gradient(270deg, {{ right_color }}18 0%, transparent 50%);">
</div>
<div class="side-content flex flex-col items-center justify-center" style="gap: {{ content_gap }}rem;">
<div class="score font-orbitron leading-none font-black whitespace-nowrap"
id="score-right"
data-score-font-size="{{ right_score_font_size }}"
style="color: {{ right_color }};">
{{ right_score }}
</div>
<div class="team-info text-center">
<div class="team-name font-playfair font-bold italic whitespace-nowrap"
data-name-font-size="{{ right_name_font_size }}">
{{ right_name }}
</div>
{% if right_subtitle %}
<div class="team-subtitle font-cinzel whitespace-nowrap" data-subtitle-font-size="{{ right_subtitle_font_size }}">{{ right_subtitle }}</div>
{% endif %}
</div>
</div>
<div class="sweep-overlay" id="sweep-right">
<div class="sweep-gradient"></div>
<div class="wave-container">
<div class="wave wave-front"></div>
<div class="wave wave-back"></div>
</div>
</div>
</div> <!-- /sides-row -->
</div>
<div id="reset-confirm">
<span>Reset Scores?</span>
<div class="confirm-bar"></div>
</div>
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
</body>
</html>