Files
2026-02-13 12:22:06 -05:00

3240 lines
153 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>EOJHL Matrix Control Panel</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.0.1/socket.io.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/Sortable/1.15.0/Sortable.min.js"></script>
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
<style>
/* ============================================
/* ============================================
LED Matrix Control Panel - Modern Styles
============================================
This stylesheet provides a modern, dark-themed design
with glassmorphism effects and smooth animations.
To use: Replace the existing <style> block content
in your index.html with this CSS.
============================================ */
/* CSS Variables - Modern Color Scheme */
:root {
--primary: #3b82f6;
--primary-dark: #2563eb;
--primary-light: #60a5fa;
--secondary: #8b5cf6;
--accent: #f59e0b;
--success: #10b981;
--warning: #f59e0b;
--danger: #ef4444;
--dark: #0f172a;
--dark-surface: #1e293b;
--dark-elevated: #2c3e50;
--border: #334155;
--text-primary: #f1f5f9;
--text-secondary: #94a3b8;
--text-muted: #64748b;
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
--shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
--shadow-lg: 0 20px 25px -5px rgba(0, 0, 0, 0.2);
--radius: 12px;
--radius-sm: 8px;
--radius-lg: 16px;
}
/* Base Styles */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background: linear-gradient(135deg, #0f172a 0%, #1e293b 50%, #0f172a 100%);
min-height: 100vh;
color: var(--text-primary);
line-height: 1.6;
}
/* Container */
.container {
max-width: 1600px;
margin: 0 auto;
padding: 2rem;
}
/* Header */
.header {
background: rgba(30, 41, 59, 0.8);
backdrop-filter: blur(20px);
border-bottom: 1px solid var(--border);
padding: 1.5rem 2rem;
position: sticky;
top: 0;
z-index: 1000;
box-shadow: var(--shadow-lg);
}
.header h1 {
font-size: 1.75rem;
font-weight: 700;
background: linear-gradient(135deg, var(--primary-light) 0%, var(--secondary) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin-bottom: 0;
display: inline-flex;
align-items: center;
gap: 0.75rem;
}
/* System Status Badges */
.system-status {
display: flex;
gap: 1rem;
flex-wrap: wrap;
align-items: center;
}
.status-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
border-radius: 20px;
font-size: 0.875rem;
font-weight: 500;
backdrop-filter: blur(10px);
transition: all 0.3s ease;
}
.status-item:hover {
transform: translateY(-2px);
}
.status-active {
background: rgba(16, 185, 129, 0.15);
border: 1px solid rgba(16, 185, 129, 0.3);
color: var(--success);
}
.status-inactive {
background: rgba(239, 68, 68, 0.15);
border: 1px solid rgba(239, 68, 68, 0.3);
color: var(--danger);
}
.status-warning {
background: rgba(245, 158, 11, 0.15);
border: 1px solid rgba(245, 158, 11, 0.3);
color: var(--warning);
}
/* Cards & Panels */
.control-panel,
.quick-controls,
.display-panel,
.config-section {
background: rgba(30, 41, 59, 0.5);
backdrop-filter: blur(20px);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 2rem;
margin-bottom: 2rem;
box-shadow: var(--shadow-lg);
transition: all 0.3s ease;
}
.config-section:hover,
.display-panel:hover {
border-color: var(--primary);
box-shadow: 0 0 20px rgba(59, 130, 246, 0.15);
}
/* Display Preview */
.display-preview {
background: #000;
border-radius: var(--radius);
padding: 2rem;
text-align: center;
position: relative;
min-height: 400px;
display: flex;
align-items: center;
justify-content: center;
border: 2px solid var(--border);
box-shadow: inset 0 2px 20px rgba(0, 0, 0, 0.5);
}
.display-preview::before {
content: "";
position: absolute;
inset: 1rem;
border-radius: var(--radius-sm);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.05);
pointer-events: none;
z-index: 0;
}
.preview-stage {
position: relative;
display: inline-block;
}
.grid-overlay {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.led-canvas {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
pointer-events: none;
background: #000;
}
.display-image {
max-width: 100%;
max-height: 100%;
image-rendering: pixelated;
border: 1px solid #333;
}
/* Buttons */
.btn {
padding: 0.625rem 1.25rem;
border-radius: var(--radius-sm);
border: none;
font-weight: 500;
font-size: 0.875rem;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 0.5rem;
transition: all 0.3s ease;
text-decoration: none;
font-family: inherit;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none !important;
}
.btn-primary {
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
color: white;
box-shadow: var(--shadow);
}
.btn-primary:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
}
.btn-secondary {
background: rgba(139, 92, 246, 0.1);
border: 1px solid rgba(139, 92, 246, 0.3);
color: var(--secondary);
}
.btn-secondary:hover:not(:disabled) {
background: rgba(139, 92, 246, 0.2);
border-color: var(--secondary);
}
.btn-success {
background: linear-gradient(135deg, var(--success) 0%, #059669 100%);
color: white;
box-shadow: var(--shadow);
}
.btn-success:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
}
.btn-danger {
background: linear-gradient(135deg, var(--danger) 0%, #dc2626 100%);
color: white;
box-shadow: var(--shadow);
}
.btn-danger:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
}
.btn-warning {
background: linear-gradient(135deg, var(--warning) 0%, #d97706 100%);
color: white;
box-shadow: var(--shadow);
}
.btn-warning:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
}
.btn-info {
background: rgba(59, 130, 246, 0.1);
border: 1px solid rgba(59, 130, 246, 0.3);
color: var(--primary);
}
.btn-info:hover:not(:disabled) {
background: rgba(59, 130, 246, 0.2);
border-color: var(--primary);
}
/* Form Controls */
.form-control {
width: 100%;
padding: 0.75rem 1rem;
background: rgba(15, 23, 42, 0.5);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-primary);
font-size: 0.9375rem;
transition: all 0.3s ease;
font-family: inherit;
}
.form-control:focus {
outline: none;
border-color: var(--primary);
background: rgba(15, 23, 42, 0.7);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.form-control:hover:not(:focus) {
border-color: var(--primary-light);
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-size: 0.875rem;
font-weight: 500;
color: var(--text-secondary);
}
.form-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1.5rem;
}
textarea.form-control {
min-height: 400px;
resize: vertical;
font-family: 'Courier New', Consolas, Monaco, monospace;
font-size: 0.875rem;
line-height: 1.5;
white-space: pre;
overflow-wrap: normal;
overflow-x: auto;
}
/* Specific fix for JSON editor textareas */
#main-config-json,
#secrets-config-json {
min-height: 500px;
height: 500px;
min-width: 500px;
width: 1200px;
font-family: 'Courier New', Consolas, Monaco, monospace;
font-size: 0.875rem;
line-height: 1.6;
tab-size: 2;
}
select.form-control {
cursor: pointer;
}
/* Toggle Switch */
.toggle-switch {
position: relative;
display: inline-block;
width: 48px;
height: 26px;
background: rgba(100, 116, 139, 0.3);
border-radius: 13px;
cursor: pointer;
transition: all 0.3s ease;
border: 1px solid var(--border);
}
.toggle-switch input {
display: none;
}
.slider {
position: absolute;
top: 2px;
left: 2px;
width: 20px;
height: 20px;
background: var(--text-muted);
border-radius: 50%;
transition: all 0.3s ease;
}
.toggle-switch input:checked ~ .slider {
transform: translateX(22px);
background: white;
}
.toggle-switch:has(input:checked) {
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
border-color: var(--primary);
}
/* Tabs */
.tabs {
display: flex;
gap: 0.25rem;
padding: 1rem;
background: rgba(15, 23, 42, 0.5);
border-bottom: 1px solid var(--border);
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
overflow-x: auto;
scrollbar-width: thin;
}
.tabs::-webkit-scrollbar {
height: 4px;
}
.tabs::-webkit-scrollbar-track {
background: transparent;
}
.tabs::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 2px;
}
.tab-btn {
padding: 0.75rem 1.5rem;
background: transparent;
border: none;
color: var(--text-secondary);
font-weight: 500;
font-size: 0.9375rem;
cursor: pointer;
border-radius: var(--radius-sm);
transition: all 0.3s ease;
white-space: nowrap;
display: flex;
align-items: center;
gap: 0.5rem;
}
.tab-btn:hover {
color: var(--text-primary);
background: rgba(59, 130, 246, 0.1);
}
.tab-btn.active {
color: var(--primary);
background: rgba(59, 130, 246, 0.15);
border-bottom: 2px solid var(--primary);
}
.tab-content {
display: none;
padding: 2rem;
animation: fadeIn 0.3s ease;
}
.tab-content.active {
display: block;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Dropdown */
.dropdown-group {
position: relative;
}
.dropdown-toggle {
background: var(--secondary);
color: white;
padding: 0.625rem 1.25rem;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
font-weight: 500;
font-size: 0.875rem;
display: flex;
align-items: center;
gap: 0.5rem;
transition: all 0.3s ease;
}
.dropdown-toggle:hover {
transform: translateY(-2px);
box-shadow: var(--shadow);
}
.dropdown-menu {
display: none;
position: absolute;
top: 100%;
left: 0;
background: rgba(30, 41, 59, 0.95);
backdrop-filter: blur(20px);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
box-shadow: var(--shadow-lg);
z-index: 100;
min-width: 200px;
margin-top: 0.5rem;
}
.dropdown-menu button {
display: block;
width: 100%;
padding: 0.75rem 1rem;
background: none;
border: none;
text-align: left;
cursor: pointer;
font-size: 0.9rem;
color: var(--text-primary);
transition: all 0.2s ease;
}
.dropdown-menu button:hover {
background: rgba(59, 130, 246, 0.1);
color: var(--primary);
}
/* Notifications */
.notification {
position: fixed;
top: 2rem;
right: 2rem;
padding: 1rem 1.5rem;
border-radius: var(--radius);
color: white;
font-weight: 500;
font-size: 0.9375rem;
box-shadow: var(--shadow-lg);
z-index: 9999;
animation: slideInRight 0.3s ease, slideOutRight 0.3s ease 2.7s;
display: flex;
align-items: center;
gap: 0.75rem;
max-width: 400px;
}
@keyframes slideInRight {
from {
transform: translateX(400px);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@keyframes slideOutRight {
from {
transform: translateX(0);
opacity: 1;
}
to {
transform: translateX(400px);
opacity: 0;
}
}
.notification.success {
background: linear-gradient(135deg, var(--success) 0%, #059669 100%);
}
.notification.error {
background: linear-gradient(135deg, var(--danger) 0%, #dc2626 100%);
}
.notification.warning {
background: linear-gradient(135deg, var(--warning) 0%, #d97706 100%);
}
.notification i {
font-size: 1.25rem;
}
/* Connection Status */
.connection-status {
position: fixed;
bottom: 2rem;
right: 2rem;
padding: 0.75rem 1.25rem;
border-radius: 20px;
font-size: 0.875rem;
font-weight: 500;
z-index: 1000;
display: flex;
align-items: center;
gap: 0.5rem;
backdrop-filter: blur(10px);
box-shadow: var(--shadow-lg);
}
.connection-status.connected {
background: rgba(16, 185, 129, 0.9);
color: white;
}
.connection-status.disconnected {
background: rgba(239, 68, 68, 0.9);
color: white;
}
/* Stats */
.stat-card {
background: rgba(15, 23, 42, 0.4);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.5rem;
transition: all 0.3s ease;
}
.stat-card:hover {
border-color: var(--primary);
transform: translateY(-4px);
box-shadow: 0 8px 16px rgba(59, 130, 246, 0.1);
}
.stat-value {
font-size: 2rem;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 0.25rem;
}
.stat-label {
font-size: 0.875rem;
color: var(--text-secondary);
font-weight: 500;
}
/* Utility Classes */
.text-muted {
color: var(--text-muted);
}
.text-small {
font-size: 0.875rem;
}
.mb-0 { margin-bottom: 0 !important; }
.mb-1 { margin-bottom: 0.5rem !important; }
.mb-2 { margin-bottom: 1rem !important; }
.mb-3 { margin-bottom: 1.5rem !important; }
.mb-4 { margin-bottom: 2rem !important; }
.mt-0 { margin-top: 0 !important; }
.mt-1 { margin-top: 0.5rem !important; }
.mt-2 { margin-top: 1rem !important; }
.mt-3 { margin-top: 1.5rem !important; }
.mt-4 { margin-top: 2rem !important; }
/* Responsive Design */
@media (max-width: 768px) {
.container {
padding: 1rem;
}
.header {
padding: 1rem;
}
.header h1 {
font-size: 1.5rem;
}
.tabs {
flex-direction: column;
}
.form-row {
grid-template-columns: 1fr;
}
.system-status {
flex-direction: column;
align-items: flex-start;
}
.notification {
top: 1rem;
right: 1rem;
left: 1rem;
max-width: none;
}
.connection-status {
bottom: 1rem;
right: 1rem;
}
}
/* Loading Spinner */
.spinner {
border: 3px solid rgba(59, 130, 246, 0.1);
border-top: 3px solid var(--primary);
border-radius: 50%;
width: 24px;
height: 24px;
animation: spin 0.8s linear infinite;
display: inline-block;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* Scrollbar Styling */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: rgba(15, 23, 42, 0.5);
border-radius: 4px;
}
::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--primary);
}
/* JSON Editor Specific Styles */
.json-container {
position: relative;
margin-bottom: 1rem;
}
.json-status {
position: absolute;
top: 10px;
right: 10px;
padding: 4px 12px;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 600;
}
.json-status.valid {
background: rgba(16, 185, 129, 0.15);
color: var(--success);
}
.json-status.invalid {
background: rgba(239, 68, 68, 0.15);
color: var(--danger);
}
.json-validation {
margin-top: 10px;
padding: 10px;
border-radius: 8px;
font-family: monospace;
font-size: 12px;
display: none;
}
.json-validation.success {
background: rgba(16, 185, 129, 0.15);
color: var(--success);
border: 1px solid rgba(16, 185, 129, 0.3);
}
.json-validation.error {
background: rgba(239, 68, 68, 0.15);
color: var(--danger);
border: 1px solid rgba(239, 68, 68, 0.3);
}
/* Alert Component */
.alert {
padding: 1rem 1.25rem;
border-radius: var(--radius-sm);
margin-bottom: 1rem;
display: flex;
align-items: center;
gap: 0.75rem;
font-size: 0.9375rem;
}
.alert-warning {
background: rgba(245, 158, 11, 0.15);
border: 1px solid rgba(245, 158, 11, 0.3);
color: var(--warning);
}
.alert-info {
background: rgba(59, 130, 246, 0.15);
border: 1px solid rgba(59, 130, 246, 0.3);
color: var(--primary);
}
.alert i {
font-size: 1.25rem;
}
</style>
</head>
<body>
<div class="container">
<!-- Header -->
<div class="header">
<h1 style="margin-bottom:12px;"><i class="fas fa-tv"></i> EOJHL Matrix Control Panel </i></h1>
<div class="system-status">
<div class="status-item {{ 'status-active' if system_status.service_active else 'status-inactive' }}">
<i class="fas fa-{{ 'play' if system_status.service_active else 'stop' }}"></i>
Service {{ 'Active' if system_status.service_active else 'Inactive' }}
</div>
<div class="status-item {{ 'status-warning' if system_status and system_status.cpu_percent and system_status.cpu_percent > 80 else 'status-active' }}">
<i class="fas fa-microchip"></i>
{{ system_status.cpu_percent if system_status and system_status.cpu_percent is defined else 0 }}% CPU
</div>
<div class="status-item {{ 'status-warning' if system_status and system_status.memory_used_percent and system_status.memory_used_percent > 80 else 'status-active' }}">
<i class="fas fa-memory"></i>
{{ system_status.memory_used_percent if system_status and system_status.memory_used_percent is defined else 0 }}% RAM
</div>
<div class="status-item {{ 'status-warning' if system_status and system_status.cpu_temp and system_status.cpu_temp > 70 else 'status-active' }}">
<i class="fas fa-thermometer-half"></i>
{{ system_status.cpu_temp if system_status and system_status.cpu_temp is defined else 0 }}°C
</div>
<div class="status-item status-active">
<i class="fas fa-clock"></i>
{{ system_status.uptime }}
</div>
</div>
</div>
<!-- Quick Controls -->
<div class="quick-controls" style="margin-top:12px;">
<h2 style="margin-bottom:12px;"><i class="fas fa-bolt"></i> Quick Controls</h2>
<div class="display-controls">
<button class="btn btn-success" onclick="runAction('start_display')"><i class="fas fa-play"></i> Start Display</button>
<button class="btn btn-danger" onclick="runAction('stop_display')"><i class="fas fa-stop"></i> Stop Display</button>
<button class="btn btn-secondary" onclick="systemAction('update_data')"><i class="fas fa-database"></i> Update Data</button>
<button class="btn btn-primary" onclick="systemAction('restart_service')"><i class="fas fa-redo"></i> Restart Service</button>
<button class="btn btn-danger" onclick="systemAction('reboot_system')"><i class="fas fa-power-off"></i> Reboot</button>
</div>
<div style="margin-top:12px; color:#666; font-size:12px;">Service actions may require sudo privileges on the Pi. Migrate Config adds new options with defaults while preserving your settings.</div>
</div>
<!-- Editor Mode Banner -->
{% if editor_mode %}
<div class="editor-mode">
<h2><i class="fas fa-edit"></i> Display Editor Mode Active</h2>
<p>Normal display operation is paused. Use the tools below to customize your display layout.</p>
</div>
{% endif %}
<!-- Main Grid -->
<div class="main-grid">
<!-- Display Panel -->
<div class="display-panel" style="max-width: 1200px; margin: 0 auto;">
<h2 style="margin-bottom:12px;"><i class="fas fa-desktop"></i> Live Display Preview</h2>
<div class="display-preview" id="displayPreview">
<div id="previewStage" class="preview-stage" style="display:none;">
<div id="previewMeta" style="position:absolute; top:-28px; left:0; color:#ddd; font-size:12px; opacity:0.85;"></div>
<img id="displayImage" class="display-image" alt="LED Matrix Display">
<canvas id="ledCanvas" class="led-canvas" style="display:none;"></canvas>
<canvas id="gridOverlay" class="grid-overlay"></canvas>
</div>
<div id="displayPlaceholder" style="color: #666; font-size: 1.2rem;">
<i class="fas fa-spinner fa-spin"></i>
Connecting to display...
</div>
</div>
<div class="display-controls" style="margin-top:12px;">
<div style="display:flex; align-items:center; gap:12px; flex-wrap:wrap; margin-left:auto;">
<label style="color:#333; background:#f3f3f3; padding:6px 10px; border-radius:8px;">
Scale
<input type="range" id="scaleRange" min="2" max="16" value="8" style="vertical-align:middle;">
<span id="scaleValue">8x</span>
</label>
<label style="color:#333; background:#f3f3f3; padding:6px 10px; border-radius:8px; display:inline-flex; align-items:center; gap:8px;">
<input type="checkbox" id="toggleGrid">
Show pixel grid
</label>
<label style="color:#333; background:#f3f3f3; padding:6px 10px; border-radius:8px; display:inline-flex; align-items:center; gap:8px;">
<input type="checkbox" id="toggleLedDots" checked>
LED dot mode
</label>
<label style="color:#333; background:#f3f3f3; padding:6px 10px; border-radius:8px; display:inline-flex; align-items:center; gap:8px;">
Dot fill
<input type="range" id="dotFillRange" min="40" max="95" value="75">
<span id="dotFillValue">75%</span>
</label>
</div>
</div>
</div>
<!-- Control Panel under the preview, wider -->
<div style="margin-top:25px;" class="control-panel">
<div class="tabs">
<button class="tab-btn" onclick="showTab('general')">
<i class="fas fa-sliders-h"></i> General
</button>
<button class="tab-btn" onclick="showTab('schedule')">
<i class="fas fa-calendar"></i> Schedule
</button>
<button class="tab-btn" onclick="showTab('display')">
<i class="fas fa-cog"></i> Display
</button>
<button class="tab-btn" onclick="showTab('clock')">
<i class="fas fa-clock"></i> Clock
</button>
<button class="tab-btn" onclick="showTab('durations')">
<i class="fas fa-hourglass-half"></i> Durations
</button>
<button class="tab-btn" onclick="showTab('sports')">
<i class="fas fa-football-ball"></i> Sports
</button>
<button class="tab-btn" onclick="showTab('leaderboard')">
<i class="fas fa-trophy"></i> Leaderboard
</button>
<button class="tab-btn" onclick="showTab('sponsors')">
<i class="fas fa-handshake"></i> Sponsors
</button>
<button class="tab-btn" onclick="showTab('actions')">
<i class="fas fa-tools"></i> Actions
</button>
<button class="tab-btn" onclick="showTab('raw-json')">
<i class="fas fa-code"></i> Raw JSON
</button>
<button class="tab-btn" onclick="showTab('logs')">
<i class="fas fa-file-alt"></i> Logs
</button>
</div>
<!-- General Tab -->
<div id="general" class="tab-content active">
<div class="config-section">
<h3>General Settings</h3>
<form id="general-form">
<div class="form-group">
<label>
<input type="checkbox" id="web_display_autostart" name="web_display_autostart" {% if safe_config_get(main_config, 'web_display_autostart', default=True) %}checked{% endif %}>
Web Display Autostart
</label>
<div class="description">Start the web interface on boot for easier access.</div>
</div>
<div class="form-group">
<label for="timezone">Timezone</label>
<input type="text" class="form-control" id="timezone" name="timezone" value="{{ safe_config_get(main_config, 'timezone', default='America/Chicago') }}" placeholder="e.g., America/Chicago">
<div class="description">IANA timezone, affects time-based features and scheduling.</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="city">City</label>
<input type="text" class="form-control" id="city" name="city" value="{{ safe_config_get(main_config, 'location', 'city', default='Dallas') }}">
</div>
<div class="form-group">
<label for="state">State</label>
<input type="text" class="form-control" id="state" name="state" value="{{ safe_config_get(main_config, 'location', 'state', default='Texas') }}">
</div>
<div class="form-group">
<label for="country">Country</label>
<input type="text" class="form-control" id="country" name="country" value="{{ safe_config_get(main_config, 'location', 'country', default='US') }}">
</div>
</div>
<button type="submit" class="btn btn-success">Save General Settings</button>
</form>
</div>
</div>
<!-- Schedule Tab -->
<div id="schedule" class="tab-content">
<div class="config-section">
<h3>Display Schedule</h3>
<p>Set the time for the display to be active. A restart is needed for changes to take effect.</p>
<form id="schedule-form">
<div class="form-group">
<label for="schedule_enabled">Enable Schedule:</label>
<div style="display: flex; align-items: center; gap: 10px;">
<label class="toggle-switch">
<input type="checkbox" id="schedule_enabled" name="schedule_enabled" {% if schedule_config.enabled %}checked{% endif %}>
<span class="slider"></span>
</label>
<span>Turn display on/off automatically</span>
</div>
</div>
<div class="form-group">
<label for="start_time">Display On Time:</label>
<input type="time" class="form-control" id="start_time" name="start_time" value="{{ schedule_config.start_time }}">
<div class="description">Time when the display should turn on</div>
</div>
<div class="form-group">
<label for="end_time">Display Off Time:</label>
<input type="time" class="form-control" id="end_time" name="end_time" value="{{ schedule_config.end_time }}">
<div class="description">Time when the display should turn off</div>
</div>
<button type="submit" class="btn btn-success">Save Schedule</button>
</form>
</div>
</div>
<!-- Display Settings Tab -->
<div id="display" class="tab-content">
<div class="config-section">
<h3>LED Matrix Hardware Settings</h3>
<form id="display-form">
<div class="form-row">
<div>
<div class="form-group">
<label for="rows">Rows:</label>
<input type="number" class="form-control" id="rows" name="rows" value="{{ safe_config_get(main_config, 'display', 'hardware', 'rows', default=32) }}" min="1" max="64">
<div class="description">Number of LED rows</div>
</div>
<div class="form-group">
<label for="cols">Columns:</label>
<input type="number" class="form-control" id="cols" name="cols" value="{{ safe_config_get(main_config, 'display', 'hardware', 'cols', default=64) }}" min="1" max="128">
<div class="description">Number of LED columns</div>
</div>
<div class="form-group">
<label for="chain_length">Chain Length:</label>
<input type="number" class="form-control" id="chain_length" name="chain_length" value="{{ safe_config_get(main_config, 'display', 'hardware', 'chain_length', default=2) }}" min="1" max="8">
<div class="description">Number of LED panels chained together</div>
</div>
<div class="form-group">
<label for="parallel">Parallel:</label>
<input type="number" class="form-control" id="parallel" name="parallel" value="{{ safe_config_get(main_config, 'display', 'hardware', 'parallel', default=1) }}" min="1" max="4">
<div class="description">Number of parallel chains</div>
</div>
<div class="form-group">
<label for="brightness">Brightness:</label>
<input type="range" class="form-control" id="brightness" name="brightness" value="{{ safe_config_get(main_config, 'display', 'hardware', 'brightness', default=95) }}" min="1" max="100" oninput="updateBrightnessDisplay(this.value)">
<div class="description">LED brightness: <span id="brightness-value">{{ safe_config_get(main_config, 'display', 'hardware', 'brightness', default=95) }}</span>%</div>
</div>
<div class="form-group">
<label for="hardware_mapping">Hardware Mapping:</label>
<select class="form-control" id="hardware_mapping" name="hardware_mapping">
<option value="adafruit-hat-pwm" {% if safe_config_get(main_config, 'display', 'hardware', 'hardware_mapping', default='adafruit-hat-pwm') == "adafruit-hat-pwm" %}selected{% endif %}>Adafruit HAT PWM</option>
<option value="adafruit-hat" {% if safe_config_get(main_config, 'display', 'hardware', 'hardware_mapping', default='adafruit-hat-pwm') == "adafruit-hat" %}selected{% endif %}>Adafruit HAT</option>
<option value="regular" {% if safe_config_get(main_config, 'display', 'hardware', 'hardware_mapping', default='adafruit-hat-pwm') == "regular" %}selected{% endif %}>Regular</option>
<option value="regular-pi1" {% if safe_config_get(main_config, 'display', 'hardware', 'hardware_mapping', default='adafruit-hat-pwm') == "regular-pi1" %}selected{% endif %}>Regular Pi1</option>
</select>
<div class="description">Hardware mapping type</div>
</div>
</div>
<div>
<div class="form-group">
<label for="gpio_slowdown">GPIO Slowdown:</label>
<input type="number" class="form-control" id="gpio_slowdown" name="gpio_slowdown" value="{{ safe_config_get(main_config, 'display', 'runtime', 'gpio_slowdown', default=3) }}" min="0" max="5">
<div class="description">GPIO slowdown factor (0-5)</div>
</div>
<div class="form-group">
<label for="scan_mode">Scan Mode:</label>
<input type="number" class="form-control" id="scan_mode" name="scan_mode" value="{{ safe_config_get(main_config, 'display', 'hardware', 'scan_mode', default=0) }}" min="0" max="1">
<div class="description">Scan mode for LED matrix (0-1)</div>
</div>
<div class="form-group">
<label for="pwm_bits">PWM Bits:</label>
<input type="number" class="form-control" id="pwm_bits" name="pwm_bits" value="{{ safe_config_get(main_config, 'display', 'hardware', 'pwm_bits', default=9) }}" min="1" max="11">
<div class="description">PWM bits for brightness control (1-11)</div>
</div>
<div class="form-group">
<label for="pwm_dither_bits">PWM Dither Bits:</label>
<input type="number" class="form-control" id="pwm_dither_bits" name="pwm_dither_bits" value="{{ safe_config_get(main_config, 'display', 'hardware', 'pwm_dither_bits', default=1) }}" min="0" max="4">
<div class="description">PWM dither bits (0-4)</div>
</div>
<div class="form-group">
<label for="pwm_lsb_nanoseconds">PWM LSB Nanoseconds:</label>
<input type="number" class="form-control" id="pwm_lsb_nanoseconds" name="pwm_lsb_nanoseconds" value="{{ safe_config_get(main_config, 'display', 'hardware', 'pwm_lsb_nanoseconds', default=130) }}" min="50" max="500">
<div class="description">PWM LSB nanoseconds (50-500)</div>
</div>
<div class="form-group">
<label for="limit_refresh_rate_hz">Limit Refresh Rate (Hz):</label>
<input type="number" class="form-control" id="limit_refresh_rate_hz" name="limit_refresh_rate_hz" value="{{ safe_config_get(main_config, 'display', 'hardware', 'limit_refresh_rate_hz', default=120) }}" min="1" max="1000">
<div class="description">Limit refresh rate in Hz (1-1000)</div>
</div>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>
<input type="checkbox" id="disable_hardware_pulsing" name="disable_hardware_pulsing" {% if safe_config_get(main_config, 'display', 'hardware', 'disable_hardware_pulsing', default=False) %}checked{% endif %}>
Disable Hardware Pulsing
</label>
<div class="description">Disable hardware pulsing</div>
</div>
<div class="form-group">
<label>
<input type="checkbox" id="inverse_colors" name="inverse_colors" {% if safe_config_get(main_config, 'display', 'hardware', 'inverse_colors', default=False) %}checked{% endif %}>
Inverse Colors
</label>
<div class="description">Inverse color display</div>
</div>
<div class="form-group">
<label>
<input type="checkbox" id="show_refresh_rate" name="show_refresh_rate" {% if safe_config_get(main_config, 'display', 'hardware', 'show_refresh_rate', default=False) %}checked{% endif %}>
Show Refresh Rate
</label>
<div class="description">Show refresh rate on display</div>
</div>
<div class="form-group">
<label>
<input type="checkbox" id="use_short_date_format" name="use_short_date_format" {% if safe_config_get(main_config, 'display', 'use_short_date_format', default=True) %}checked{% endif %}>
Use Short Date Format
</label>
<div class="description">Use short date format for display</div>
</div>
</div>
<button type="submit" class="btn btn-success">Save Display Settings</button>
</form>
</div>
</div>
<!-- Clock Tab -->
<div id="clock" class="tab-content">
<div class="config-section">
<h3>Clock</h3>
<form id="clock-form">
<div class="form-group">
<label>
<input type="checkbox" id="clock_enabled" {% if safe_config_get(main_config, 'clock', 'enabled', default=True) %}checked{% endif %}>
Enable Clock
</label>
</div>
<div class="form-group">
<label for="clock_format">Format</label>
<input type="text" id="clock_format" class="form-control" value="{{ safe_config_get(main_config, 'clock', 'format', default='%I:%M %p') }}">
<div class="description">Python strftime format. Example: %I:%M %p for 12-hour time.</div>
</div>
<div class="form-group">
<label for="clock_update_interval">Update Interval (seconds)</label>
<input type="number" id="clock_update_interval" class="form-control" value="{{ safe_config_get(main_config, 'clock', 'update_interval', default=1) }}" min="1" max="60">
</div>
<button type="submit" class="btn btn-success">Save Clock Settings</button>
</form>
</div>
</div>
<!-- Durations Tab -->
<div id="durations" class="tab-content">
<div class="config-section">
<h3>Rotation Durations</h3>
<p class="description">How long each screen is shown before switching. Values in seconds.</p>
<form id="durations-form">
<div class="form-row">
{% for key, value in main_config.get('display', {}).get('display_durations', {}).items() %}
<div class="form-group">
<label for="duration_{{ key }}">{{ key | replace('_', ' ') | title }}</label>
<input type="number" class="form-control duration-input" id="duration_{{ key }}" data-name="{{ key }}" value="{{ value }}" min="5" max="600">
</div>
{% endfor %}
</div>
<button type="submit" class="btn btn-success">Save Durations</button>
</form>
</div>
</div>
<!-- Sports Tab -->
<div id="sports" class="tab-content">
<div class="config-section">
<h3>Sports Configuration</h3>
<p>Configure which sports leagues to display and their settings.</p>
<!-- Sports configuration will be populated by JavaScript -->
<div id="sports-config">
Loading sports configuration...
</div>
<div style="margin-top:10px; display:flex; gap:10px;">
<button type="button" class="btn btn-primary" onclick="refreshSportsConfig()">Refresh</button>
<button type="button" class="btn btn-success" onclick="saveSportsConfig()">Save Sports Settings</button>
</div>
</div>
</div>
<!-- Leaderboard Tab -->
<div id="leaderboard" class="tab-content">
<div class="config-section">
<div style="display:flex; justify-content: space-between; align-items:center;">
<h3>Leaderboard Configuration</h3>
<div style="display:flex; gap:8px;">
<button type="button" class="btn btn-info" onclick="startOnDemand('leaderboard')"><i class="fas fa-bolt"></i> On-Demand</button>
<button type="button" class="btn btn-secondary" onclick="stopOnDemand()"><i class="fas fa-ban"></i> Stop</button>
</div>
</div>
<form id="leaderboard-form">
<div class="form-group">
<label>
<input type="checkbox" id="leaderboard_enabled" {% if safe_config_get(main_config, 'leaderboard', 'enabled', default=False) %}checked{% endif %}>
Enable Leaderboard
</label>
</div>
<div class="form-row">
<div class="form-group">
<label for="leaderboard_update_interval">Update Interval (sec)</label>
<input type="number" class="form-control" id="leaderboard_update_interval" value="{{ safe_config_get(main_config, 'leaderboard', 'update_interval', default=3600) }}">
</div>
<div class="form-group">
<label for="leaderboard_scroll_speed">Scroll Speed</label>
<input type="number" step="0.1" class="form-control" id="leaderboard_scroll_speed" value="{{ safe_config_get(main_config, 'leaderboard', 'scroll_speed', default=1) }}">
</div>
<div class="form-group">
<label for="leaderboard_scroll_delay">Scroll Delay (sec)</label>
<input type="number" step="0.001" class="form-control" id="leaderboard_scroll_delay" value="{{ safe_config_get(main_config, 'leaderboard', 'scroll_delay', default=0.01) }}">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="leaderboard_display_duration">Display Duration (sec)</label>
<input type="number" class="form-control" id="leaderboard_display_duration" value="{{ safe_config_get(main_config, 'leaderboard', 'display_duration', default=30) }}">
</div>
<div class="form-group">
<label for="leaderboard_request_timeout">Request Timeout (sec)</label>
<input type="number" class="form-control" id="leaderboard_request_timeout" value="{{ safe_config_get(main_config, 'leaderboard', 'request_timeout', default=10) }}">
</div>
</div>
<div class="form-group">
<label>
<input type="checkbox" id="leaderboard_loop" {% if safe_config_get(main_config, 'leaderboard', 'loop', default=True) %}checked{% endif %}>
Loop
</label>
</div>
<div class="form-group">
<label>
<input type="checkbox" id="leaderboard_dynamic_duration" {% if safe_config_get(main_config, 'leaderboard', 'dynamic_duration', default=True) %}checked{% endif %}>
Dynamic Duration
</label>
</div>
<div class="form-row">
<div class="form-group">
<label for="leaderboard_min_duration">Min Duration (sec)</label>
<input type="number" class="form-control" id="leaderboard_min_duration" value="{{ safe_config_get(main_config, 'leaderboard', 'min_duration', default=30) }}">
</div>
<div class="form-group">
<label for="leaderboard_max_duration">Max Duration (sec)</label>
<input type="number" class="form-control" id="leaderboard_max_duration" value="{{ safe_config_get(main_config, 'leaderboard', 'max_duration', default=300) }}">
</div>
<div class="form-group">
<label for="leaderboard_duration_buffer">Duration Buffer</label>
<input type="number" step="0.01" class="form-control" id="leaderboard_duration_buffer" value="{{ safe_config_get(main_config, 'leaderboard', 'duration_buffer', default=0.1) }}">
</div>
</div>
<h4>Enabled Sports</h4>
<div class="form-group">
<label>
<input type="checkbox" id="leaderboard_nfl_enabled" {% if safe_config_get(main_config, 'leaderboard', 'enabled_sports', 'eojhl', 'enabled', default=False) %}checked{% endif %}>
EOJHL
</label>
<div class="form-group" style="margin-left: 20px;">
<label for="leaderboard_nfl_top_teams">Top Teams</label>
<input type="number" class="form-control" id="leaderboard_nfl_top_teams" value="{{ safe_config_get(main_config, 'leaderboard', 'enabled_sports', 'nfl', 'top_teams', default=10) }}" min="1" max="32">
</div>
</div>
<button type="submit" class="btn btn-success">Save Leaderboard Settings</button>
</form>
</div>
</div>
<!-- Sponsors Tab -->
<div id="sponsors" class="tab-content">
<div class="config-section">
<h3>Sponsor Management</h3>
<!-- Sponsor List -->
<h4>Current Sponsors</h4>
<div id="sponsor-list" style="margin-bottom: 2rem;">
<div style="color: var(--text-muted); padding: 1rem;">Loading sponsors...</div>
</div>
<!-- Add/Edit Sponsor Form -->
<div class="config-section" style="background: rgba(15, 23, 42, 0.3);">
<h4 id="sponsor-form-title">Add New Sponsor</h4>
<form id="sponsor-form">
<input type="hidden" id="sponsor_editing_key" value="">
<div class="form-group">
<label for="sponsor_abbr">Sponsor Abbreviation (Key Name) *</label>
<input type="text" class="form-control" id="sponsor_abbr" required pattern="[a-zA-Z0-9_]+" title="No spaces allowed">
<div class="description">No spaces allowed. This will be used as the unique identifier.</div>
</div>
<div class="form-row">
<div class="form-group">
<label>
<input type="checkbox" id="sponsor_enabled">
Enabled
</label>
</div>
<div class="form-group">
<label for="sponsor_show_details">Show Details</label>
<select class="form-control" id="sponsor_show_details">
<option value="true">True</option>
<option value="false" selected>False</option>
</select>
</div>
</div>
<div class="form-group">
<label for="sponsor_pngabbr">PNG Abbreviation</label>
<input type="text" class="form-control" id="sponsor_pngabbr" readonly>
<div class="description">Auto-filled from sponsor abbreviation</div>
</div>
<div class="form-group">
<label for="sponsor_image_upload">Sponsor Logo Image</label>
<input type="file" class="form-control" id="sponsor_image_upload" accept="image/*">
<div class="description">Upload a logo for this sponsor (PNG, JPG, etc.)</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="sponsor_details_row1">Details Row 1</label>
<input type="text" class="form-control" id="sponsor_details_row1">
</div>
<div class="form-group">
<label for="sponsor_details_row2">Details Row 2</label>
<input type="text" class="form-control" id="sponsor_details_row2">
</div>
</div>
<div class="display-controls">
<button type="submit" class="btn btn-success">
<i class="fas fa-save"></i> Save Sponsor
</button>
<button type="button" class="btn btn-secondary" onclick="cancelSponsorEdit()">
<i class="fas fa-times"></i> Cancel
</button>
</div>
</form>
</div>
<!-- Global Sponsor Settings Form -->
<div class="config-section" style="background: rgba(15, 23, 42, 0.3); margin-top: 2rem;">
<h4>Global Sponsor Settings</h4>
<form id="sponsor-global-form">
<div class="form-row">
<div class="form-group">
<label for="sponsors_enabled">Enabled</label>
<select class="form-control" id="sponsors_enabled">
<option value="true">True</option>
<option value="false">False</option>
</select>
</div>
<div class="form-group">
<label for="sponsors_show_all">Show All</label>
<select class="form-control" id="sponsors_show_all">
<option value="true">True</option>
<option value="false">False</option>
</select>
</div>
<div class="form-group">
<label for="sponsors_show_num">Show Number of Sponsors</label>
<select class="form-control" id="sponsors_show_num">
<option value="1">1</option>
<option value="2">2</option>
<option value="3">3</option>
</select>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="sponsors_title_row1">Title Row 1</label>
<input type="text" class="form-control" id="sponsors_title_row1">
</div>
<div class="form-group">
<label for="sponsors_title_row2">Title Row 2</label>
<input type="text" class="form-control" id="sponsors_title_row2">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="sponsors_show_details">Show Details</label>
<select class="form-control" id="sponsors_show_details">
<option value="true">True</option>
<option value="false">False</option>
</select>
</div>
<div class="form-group">
<label for="sponsors_loop">Loop</label>
<select class="form-control" id="sponsors_loop">
<option value="true">True</option>
<option value="false">False</option>
</select>
</div>
</div>
<h5>Title Row 1 Styling</h5>
<div class="form-row">
<div class="form-group">
<label for="sponsors_title_row1_font_size">Font Size</label>
<select class="form-control" id="sponsors_title_row1_font_size">
<option value="4">4</option>
<option value="6">6</option>
<option value="8">8</option>
<option value="10">10</option>
<option value="12">12</option>
</select>
</div>
<div class="form-group">
<label for="sponsors_title_row1_colour">Color</label>
<input type="color" class="form-control" id="sponsors_title_row1_colour">
</div>
</div>
<h5>Title Row 2 Styling</h5>
<div class="form-row">
<div class="form-group">
<label for="sponsors_title_row2_font_size">Font Size</label>
<select class="form-control" id="sponsors_title_row2_font_size">
<option value="4">4</option>
<option value="6">6</option>
<option value="8">8</option>
<option value="10">10</option>
<option value="12">12</option>
</select>
</div>
<div class="form-group">
<label for="sponsors_title_row2_colour">Color</label>
<input type="color" class="form-control" id="sponsors_title_row2_colour">
</div>
</div>
<h5>Details Row 1 Styling</h5>
<div class="form-row">
<div class="form-group">
<label for="sponsors_details_row1_font_size">Font Size</label>
<select class="form-control" id="sponsors_details_row1_font_size">
<option value="4">4</option>
<option value="6">6</option>
<option value="8">8</option>
<option value="10">10</option>
<option value="12">12</option>
</select>
</div>
<div class="form-group">
<label for="sponsors_details_row1_colour">Color</label>
<input type="color" class="form-control" id="sponsors_details_row1_colour">
</div>
</div>
<h5>Details Row 2 Styling</h5>
<div class="form-row">
<div class="form-group">
<label for="sponsors_details_row2_font_size">Font Size</label>
<select class="form-control" id="sponsors_details_row2_font_size">
<option value="4">4</option>
<option value="6">6</option>
<option value="8">8</option>
<option value="10">10</option>
<option value="12">12</option>
</select>
</div>
<div class="form-group">
<label for="sponsors_details_row2_colour">Color</label>
<input type="color" class="form-control" id="sponsors_details_row2_colour">
</div>
</div>
<h5>Duration Settings</h5>
<div class="form-row">
<div class="form-group">
<label for="sponsors_duration">Duration (default: 15)</label>
<input type="number" class="form-control" id="sponsors_duration" placeholder="15">
</div>
<div class="form-group">
<label for="sponsors_title_duration">Title Duration (default: 5)</label>
<input type="number" class="form-control" id="sponsors_title_duration" placeholder="5">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="sponsors_logo_duration">Logo Duration (default: 5)</label>
<input type="number" class="form-control" id="sponsors_logo_duration" placeholder="5">
</div>
<div class="form-group">
<label for="sponsors_details_duration">Details Duration (default: 5)</label>
<input type="number" class="form-control" id="sponsors_details_duration" placeholder="5">
</div>
</div>
<button type="submit" class="btn btn-success">
<i class="fas fa-save"></i> Save Global Settings
</button>
</form>
</div>
</div>
</div>
<!-- Actions Tab -->
<div id="actions" class="tab-content">
<div class="config-section">
<h3>System Actions</h3>
<p>Control the display service and system operations.</p>
<h4>Display Control</h4>
<div class="display-controls">
<button type="button" class="btn btn-success" onclick="runAction('start_display')">
<i class="fas fa-play"></i> Start Display
</button>
<button type="button" class="btn btn-danger" onclick="runAction('stop_display')">
<i class="fas fa-stop"></i> Stop Display
</button>
</div>
<h4>Auto-Start Settings</h4>
<div class="display-controls">
<button type="button" class="btn btn-success" onclick="runAction('enable_autostart')">
<i class="fas fa-check"></i> Enable Auto-Start
</button>
<button type="button" class="btn btn-warning" onclick="runAction('disable_autostart')">
<i class="fas fa-times"></i> Disable Auto-Start
</button>
</div>
<h4>System Operations</h4>
<div class="display-controls">
<button type="button" class="btn btn-warning" onclick="runAction('git_pull')">
<i class="fas fa-download"></i> Download Latest Update
</button>
<button type="button" class="btn btn-danger" onclick="runAction('reboot_system')" onclick="return confirm('Are you sure you want to reboot?')">
<i class="fas fa-power-off"></i> Reboot System
</button>
<button type="button" class="btn btn-danger" onclick="confirmShutdown()">
<i class="fas fa-power-off"></i> Shutdown System
</button>
</div>
<h4>Action Output</h4>
<div style="margin-top: 20px;">
<pre id="action_output" style="background: #f5f5f5; padding: 15px; border-radius: var(--border-radius); min-height: 100px; white-space: pre-wrap;">No action run yet.</pre>
</div>
</div>
</div>
<!-- Raw JSON Tab -->
<div id="raw-json" class="tab-content">
<div class="config-section">
<h3>Raw Configuration JSON</h3>
<p>View, edit, and save the complete configuration files directly. <strong>⚠️ Warning:</strong> Be careful when editing raw JSON - invalid syntax will prevent saving.</p>
<h4>Main Configuration (config.json)</h4>
<div class="form-group">
<label>Configuration Path:</label>
<span>{{ main_config_path }}</span>
</div>
<div class="json-container">
<textarea id="main-config-json">{{ main_config_json }}</textarea>
<div id="main-config-status" class="json-status valid">VALID</div>
<div id="main-config-validation" class="json-validation"></div>
</div>
<div class="json-actions">
<button type="button" class="btn btn-primary" onclick="formatJson('main-config-json')">Format JSON</button>
<button type="button" class="btn btn-warning" onclick="validateJson('main-config-json', 'main-config-validation')">Validate JSON</button>
<button type="button" class="btn btn-success" onclick="saveRawJson('main')">Save Main Config</button>
</div>
<h4>Secrets Configuration (config_secrets.json)</h4>
<div class="form-group">
<label>Configuration Path:</label>
<span>{{ secrets_config_path }}</span>
</div>
<div class="json-container">
<textarea id="secrets-config-json">{{ secrets_config_json }}</textarea>
<div id="secrets-config-status" class="json-status valid">VALID</div>
<div id="secrets-config-validation" class="json-validation"></div>
</div>
<div class="json-actions">
<button type="button" class="btn btn-primary" onclick="formatJson('secrets-config-json')">Format JSON</button>
<button type="button" class="btn btn-warning" onclick="validateJson('secrets-config-json', 'secrets-config-validation')">Validate JSON</button>
<button type="button" class="btn btn-success" onclick="saveRawJson('secrets')">Save Secrets Config</button>
</div>
</div>
</div>
<!-- Logs Tab -->
<div id="logs" class="tab-content">
<div class="config-section">
<h3>System Logs</h3>
<p>View logs for the LED matrix service. Useful for debugging.</p>
<button class="btn btn-primary" onclick="fetchLogs()">
<i class="fas fa-refresh"></i> Refresh Logs
</button>
<pre id="log-content" style="background-color: #1e1e1e; color: #d4d4d4; padding: 15px; border-radius: var(--border-radius); max-height: 600px; overflow-y: auto; white-space: pre-wrap; word-wrap: break-word; margin-top: 15px;"></pre>
</div>
</div>
</div>
</div>
</div>
<!-- Connection Status -->
<div id="connectionStatus" class="connection-status disconnected">
<i class="fas fa-wifi"></i> Disconnected
</div>
<!-- Notification -->
<div id="notification" class="notification"></div>
<!-- Server-provided data for JS (avoids inline Jinja in JS) -->
<script id="serverData" type="application/json">{{ {'main_config': main_config_data, 'editor_mode': editor_mode} | tojson }}</script>
<script>
// Global variables
let socket;
const __serverDataEl = document.getElementById('serverData');
const __serverData = __serverDataEl ? JSON.parse(__serverDataEl.textContent) : { main_config: {}, editor_mode: false };
let currentConfig = __serverData.main_config || {};
let editorMode = !!__serverData.editor_mode;
let currentElements = [];
let selectedElement = null;
// Function to refresh the current config from the server
async function refreshCurrentConfig() {
try {
const response = await fetch('/api/config/main');
if (response.ok) {
const configData = await response.json();
currentConfig = configData;
}
} catch (error) {
console.warn('Failed to refresh current config:', error);
}
}
async function refreshOnDemandStatus(){
try{
const res = await fetch('/api/ondemand/status');
const data = await res.json();
if(data && data.on_demand){
const s = data.on_demand;
const el = document.getElementById('ondemand-status');
if(el){ el.textContent = `On-Demand: ${s.running && s.mode ? s.mode : 'None'}`; }
}
}catch(e){ /* ignore */ }
}
async function startOnDemand(mode){
try{
const res = await fetch('/api/ondemand/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mode })
});
const data = await res.json();
if(data.status === 'success'){
showNotification(data.message || 'On-demand started successfully', 'success');
} else {
showNotification(data.message || 'Failed to start on-demand', 'error');
}
refreshOnDemandStatus();
}catch(err){
showNotification('Error starting on-demand: ' + err, 'error');
}
}
async function stopOnDemand(){
try{
const res = await fetch('/api/ondemand/stop', { method: 'POST' });
const data = await res.json();
showNotification(data.message || 'On-Demand stopped', data.status || 'success');
refreshOnDemandStatus();
}catch(err){
showNotification('Error stopping on-demand: ' + err, 'error');
}
}
// Initialize on page load
document.addEventListener('DOMContentLoaded', function() {
initializeSocket();
initializeEditor();
updateSystemStats();
loadNewsManagerData();
updateApiMetrics();
refreshOnDemandStatus();
// UI controls for grid & scale
const scaleRange = document.getElementById('scaleRange');
const scaleValue = document.getElementById('scaleValue');
const toggleGrid = document.getElementById('toggleGrid');
const gridCanvas = document.getElementById('gridOverlay');
if (scaleRange && scaleValue) {
scaleRange.addEventListener('input', () => {
scaleValue.textContent = `${scaleRange.value}x`;
// Repaint grid at new scale using latest known dimensions
fetch('/api/display/current')
.then(r => r.json())
.then(data => {
if (!data || !data.width || !data.height) return;
// Resize image and canvas
const img = document.getElementById('displayImage');
const scale = parseInt(scaleRange.value || '8');
img.style.width = `${data.width * scale}px`;
img.style.height = `${data.height * scale}px`;
gridCanvas.width = data.width * scale;
gridCanvas.height = data.height * scale;
drawGrid(gridCanvas, data.width, data.height, scale);
renderLedDots();
})
.catch(() => {});
});
}
if (toggleGrid && gridCanvas) {
toggleGrid.addEventListener('change', () => {
gridCanvas.style.display = toggleGrid.checked ? 'block' : 'none';
if (toggleGrid.checked) {
// Redraw grid with current size
fetch('/api/display/current')
.then(r => r.json())
.then(data => {
if (!data || !data.width || !data.height) return;
const scale = parseInt((document.getElementById('scaleRange')?.value) || '8');
drawGrid(gridCanvas, data.width, data.height, scale);
})
.catch(() => {});
}
});
// default hidden
gridCanvas.style.display = 'none';
}
// LED dot mode controls
const toggleLedDots = document.getElementById('toggleLedDots');
const dotFillRange = document.getElementById('dotFillRange');
const dotFillValue = document.getElementById('dotFillValue');
if (dotFillRange && dotFillValue) {
dotFillRange.addEventListener('input', () => {
dotFillValue.textContent = `${dotFillRange.value}%`;
renderLedDots();
});
}
if (toggleLedDots) {
toggleLedDots.addEventListener('change', renderLedDots);
// Ensure dot mode is rendered on load if enabled by default
if (toggleLedDots.checked) {
setTimeout(renderLedDots, 200);
}
}
// Update stats every 30 seconds
setInterval(updateSystemStats, 30000);
setInterval(updateApiMetrics, 60000);
});
// Socket.IO connection
function initializeSocket() {
socket = io({
path: '/socket.io',
transports: ['websocket', 'polling'],
reconnection: true,
reconnectionAttempts: Infinity,
reconnectionDelay: 1000,
reconnectionDelayMax: 10000
});
socket.on('connect', function() {
updateConnectionStatus(true);
showNotification('Connected to LED Matrix', 'success');
stopPreviewPolling();
});
socket.on('disconnect', function() {
updateConnectionStatus(false);
showNotification('Disconnected from LED Matrix', 'error');
// Try to reconnect with exponential backoff
let attempt = 0;
const retry = () => {
attempt++;
const delay = Math.min(30000, 1000 * Math.pow(2, attempt));
setTimeout(() => {
if (socket.connected) return;
socket.connect();
}, delay);
};
retry();
startPreviewPolling();
});
socket.on('connect_error', function(_err){
updateConnectionStatus(false);
startPreviewPolling();
});
socket.on('display_update', function(data) {
updateDisplayPreview(data);
});
socket.on('ondemand_error', function(data) {
showNotification(`On-demand error: ${data.error}`, 'error');
refreshOnDemandStatus();
});
}
async function updateApiMetrics(){
try {
const res = await fetch('/api/metrics');
const data = await res.json();
if (data.status !== 'success') return;
const el = document.getElementById('api-metrics');
const w = Math.round((data.window_seconds || 86400) / 3600);
const f = data.forecast || {};
const u = data.used || {};
el.innerHTML = `
<div><strong>Window:</strong> ${w} hours</div>
<div><strong>Sports:</strong> ${u.sports || 0} used / ${f.sports || 0} forecast</div>
`;
} catch (e) {
// ignore
}
}
// Fallback polling when websocket is disconnected
let __previewPollTimer = null;
function startPreviewPolling(){
if (__previewPollTimer) return;
__previewPollTimer = setInterval(fetchCurrentDisplayOnce, 1000);
}
function stopPreviewPolling(){
if (__previewPollTimer) clearInterval(__previewPollTimer);
__previewPollTimer = null;
}
async function fetchCurrentDisplayOnce(){
try {
const res = await fetch('/api/display/current');
const data = await res.json();
if (data && data.image) updateDisplayPreview(data);
} catch (_) {}
}
// Draw pixel grid lines on top of scaled image
function drawGrid(canvas, logicalWidth, logicalHeight, scale) {
const show = document.getElementById('toggleGrid')?.checked;
if (!canvas || !show) return;
const ctx = canvas.getContext('2d', { willReadFrequently: true });
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.strokeStyle = 'rgba(255,255,255,0.12)';
ctx.lineWidth = 1;
// Vertical lines
for (let x = 0; x <= logicalWidth; x++) {
const px = Math.floor(x * scale) + 0.5;
ctx.beginPath();
ctx.moveTo(px, 0);
ctx.lineTo(px, logicalHeight * scale);
ctx.stroke();
}
// Horizontal lines
for (let y = 0; y <= logicalHeight; y++) {
const py = Math.floor(y * scale) + 0.5;
ctx.beginPath();
ctx.moveTo(0, py);
ctx.lineTo(logicalWidth * scale, py);
ctx.stroke();
}
}
// Update connection status
function updateConnectionStatus(connected) {
const status = document.getElementById('connectionStatus');
if (connected) {
status.className = 'connection-status connected';
status.innerHTML = '<i class="fas fa-wifi"></i> Connected';
} else {
status.className = 'connection-status disconnected';
status.innerHTML = '<i class="fas fa-wifi"></i> Disconnected';
}
}
// Update display preview with better scaling and error handling
function updateDisplayPreview(data) {
const preview = document.getElementById('displayPreview');
const stage = document.getElementById('previewStage');
const img = document.getElementById('displayImage');
const canvas = document.getElementById('gridOverlay');
const ledCanvas = document.getElementById('ledCanvas');
const placeholder = document.getElementById('displayPlaceholder');
if (data.image) {
// Show stage
placeholder.style.display = 'none';
stage.style.display = 'inline-block';
// Current scale from slider
const scale = parseInt(document.getElementById('scaleRange').value || '8');
// Update image and meta label
img.style.imageRendering = 'pixelated';
img.onload = () => {
// Ensure LED dot overlay samples after image is ready
renderLedDots();
};
img.src = `data:image/png;base64,${data.image}`;
const meta = document.getElementById('previewMeta');
if (meta) {
// Use config dimensions as fallback instead of hardcoded values
const configWidth = {{ main_config.get('display', {}).get('hardware', {}).get('cols', 64) * main_config.get('display', {}).get('hardware', {}).get('chain_length', 1) }};
const configHeight = {{ main_config.get('display', {}).get('hardware', {}).get('rows', 32) * main_config.get('display', {}).get('hardware', {}).get('parallel', 1) }};
meta.textContent = `${data.width || configWidth} x ${data.height || configHeight} @ ${scale}x`;
}
// Once image loads, size the canvas to match
// Use config dimensions as fallback instead of hardcoded values
const configWidth = {{ main_config.get('display', {}).get('hardware', {}).get('cols', 64) * main_config.get('display', {}).get('hardware', {}).get('chain_length', 1) }};
const configHeight = {{ main_config.get('display', {}).get('hardware', {}).get('rows', 32) * main_config.get('display', {}).get('hardware', {}).get('parallel', 1) }};
const width = (data.width || configWidth) * scale;
const height = (data.height || configHeight) * scale;
img.style.width = width + 'px';
img.style.height = height + 'px';
ledCanvas.width = width;
ledCanvas.height = height;
canvas.width = width;
canvas.height = height;
drawGrid(canvas, data.width || configWidth, data.height || configHeight, scale);
renderLedDots();
} else {
stage.style.display = 'none';
placeholder.style.display = 'block';
placeholder.innerHTML = `<div style="color: #666; font-size: 1.2rem;">
<i class="fas fa-exclamation-triangle"></i>
No display data available
</div>`;
}
}
function renderLedDots(){
const ledCanvas = document.getElementById('ledCanvas');
const img = document.getElementById('displayImage');
const toggle = document.getElementById('toggleLedDots');
if (!ledCanvas || !img || !toggle) return;
const show = toggle.checked;
ledCanvas.style.display = show ? 'block' : 'none';
if (!show) return;
const scale = parseInt(document.getElementById('scaleRange').value || '8');
const fillPct = parseInt(document.getElementById('dotFillRange').value || '75');
const dotRadius = Math.max(1, Math.floor((scale * fillPct) / 200)); // radius in px
const ctx = ledCanvas.getContext('2d', { willReadFrequently: true });
ctx.clearRect(0, 0, ledCanvas.width, ledCanvas.height);
// Clear previous overlay; do not forcibly black-out if sampling fails
ctx.clearRect(0, 0, ledCanvas.width, ledCanvas.height);
// Create an offscreen canvas to sample pixel colors
const off = document.createElement('canvas');
const logicalWidth = Math.floor(ledCanvas.width / scale);
const logicalHeight = Math.floor(ledCanvas.height / scale);
off.width = logicalWidth;
off.height = logicalHeight;
const offCtx = off.getContext('2d', { willReadFrequently: true });
// Draw the current image scaled down to logical LEDs to sample colors
try {
offCtx.drawImage(img, 0, 0, logicalWidth, logicalHeight);
} catch (_) { /* draw failures ignored */ }
// Draw circular dots for each LED pixel
let drawn = 0;
for (let y = 0; y < logicalHeight; y++) {
for (let x = 0; x < logicalWidth; x++) {
const pixel = offCtx.getImageData(x, y, 1, 1).data;
const r = pixel[0], g = pixel[1], b = pixel[2], a = pixel[3];
// Skip fully black to reduce overdraw
if (a === 0 || (r|g|b) === 0) continue;
ctx.fillStyle = `rgb(${r},${g},${b})`;
const cx = Math.floor(x * scale + scale / 2);
const cy = Math.floor(y * scale + scale / 2);
ctx.beginPath();
ctx.arc(cx, cy, dotRadius, 0, Math.PI * 2);
ctx.fill();
drawn++;
}
}
// If nothing was drawn (e.g., image not ready), hide overlay to show base image
if (drawn === 0) {
ledCanvas.style.display = 'none';
}
}
// Tab functionality
function showTab(tabName) {
// Hide all tab contents
const contents = document.querySelectorAll('.tab-content');
contents.forEach(content => content.classList.remove('active'));
// Remove active class from all tab buttons
const buttons = document.querySelectorAll('.tab-btn');
buttons.forEach(btn => btn.classList.remove('active'));
// Show selected tab content
document.getElementById(tabName).classList.add('active');
// Add active class to clicked button
if (event && event.target) {
event.target.classList.add('active');
} else {
// Fallback: match tabName to button by data
const btns = document.querySelectorAll('.tab-btn');
btns.forEach(btn => {
if (btn.getAttribute('onclick') && btn.getAttribute('onclick').includes(`'${tabName}'`)) {
btn.classList.add('active');
}
});
}
// Load specific data when tabs are opened
if (tabName === 'news') {
loadNewsManagerData();
} else if (tabName === 'sports') {
refreshSportsConfig();
} else if (tabName === 'sponsors') {
loadSponsors();
loadSponsorGlobalSettings();
} else if (tabName === 'logs') {
fetchLogs();
} else if (tabName === 'raw-json') {
setTimeout(() => {
validateJson('main-config-json', 'main-config-validation');
validateJson('secrets-config-json', 'secrets-config-validation');
}, 100);
}
refreshOnDemandStatus();
}
// Display control functions
async function startDisplay() {
// Use system service like Web UI v1 for reliability
await runAction('start_display');
}
async function stopDisplay() {
await runAction('stop_display');
}
async function toggleEditorMode() {
try {
const response = await fetch('/api/editor/toggle', {
method: 'POST',
headers: {'Content-Type': 'application/json'}
});
const result = await response.json();
showNotification(result.message, result.status);
if (result.status === 'success') {
editorMode = result.editor_mode;
location.reload(); // Reload to update UI
}
} catch (error) {
showNotification('Error toggling editor mode: ' + error.message, 'error');
}
}
async function takeScreenshot() {
try {
const response = await fetch('/api/display/current');
const data = await response.json();
if (data.image) {
// Create download link
const link = document.createElement('a');
link.href = 'data:image/png;base64,' + data.image;
link.download = 'led_matrix_screenshot_' + new Date().getTime() + '.png';
link.click();
showNotification('Screenshot saved', 'success');
} else {
showNotification('No display data available', 'warning');
}
} catch (error) {
showNotification('Error taking screenshot: ' + error.message, 'error');
}
}
// System action functions
async function systemAction(action) {
if (action === 'reboot_system' && !confirm('Are you sure you want to reboot the system?')) {
return;
}
if (action === 'migrate_config' && !confirm('This will migrate your configuration to add any new options with default values. A backup will be created automatically. Continue?')) {
return;
}
try {
const response = await fetch('/api/system/action', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({action: action})
});
const result = await response.json();
showNotification(result.message, result.status);
} catch (error) {
showNotification('Error executing action: ' + error.message, 'error');
}
}
// Editor functions
function initializeEditor() {
// Initialize drag and drop for editor elements
const paletteItems = document.querySelectorAll('.palette-item');
paletteItems.forEach(item => {
item.addEventListener('dragstart', function(e) {
e.dataTransfer.setData('text/plain', this.dataset.type);
});
});
// Make display preview a drop zone
const preview = document.getElementById('displayPreview');
preview.addEventListener('dragover', function(e) {
e.preventDefault();
});
preview.addEventListener('drop', function(e) {
e.preventDefault();
const elementType = e.dataTransfer.getData('text/plain');
const rect = preview.getBoundingClientRect();
const scaleInput = document.getElementById('scaleRange');
const scale = scaleInput ? parseInt(scaleInput.value || '8') : 8;
const x = Math.floor((e.clientX - rect.left) / scale);
const y = Math.floor((e.clientY - rect.top) / scale);
addElement(elementType, x, y);
});
}
function addElement(type, x, y) {
const element = {
id: Date.now(),
type: type,
x: x,
y: y,
properties: getDefaultProperties(type, x, y)
};
currentElements.push(element);
updatePreview();
selectElement(element);
}
function getDefaultProperties(type, baseX, baseY) {
switch (type) {
case 'text':
return {
text: 'Sample Text',
color: [255, 255, 255],
font_size: 'normal'
};
case 'weather_icon':
return {
condition: 'sunny',
size: 16
};
case 'rectangle':
return {
width: 20,
height: 10,
color: [255, 255, 255]
};
case 'line':
return {
x2: (baseX || 0) + 20,
y2: baseY || 0,
color: [255, 255, 255]
};
default:
return {};
}
}
async function updatePreview() {
if (!editorMode) return;
try {
const response = await fetch('/api/editor/preview', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
elements: currentElements
})
});
const result = await response.json();
if (result.status !== 'success') {
showNotification(result.message, 'error');
}
} catch (error) {
showNotification('Error updating preview: ' + error.message, 'error');
}
}
function selectElement(element) {
selectedElement = element;
updatePropertiesPanel();
}
function updatePropertiesPanel() {
const panel = document.getElementById('elementProperties');
if (!selectedElement) {
panel.innerHTML = '<p>Select an element to edit its properties</p>';
return;
}
let html = `<h5>${selectedElement.type.toUpperCase()} Properties</h5>`;
html += `<div class="form-group">
<label>X Position</label>
<input type="number" class="form-control" value="${selectedElement.x}"
onchange="updateElementProperty('x', parseInt(this.value))">
</div>`;
html += `<div class="form-group">
<label>Y Position</label>
<input type="number" class="form-control" value="${selectedElement.y}"
onchange="updateElementProperty('y', parseInt(this.value))">
</div>`;
// Add type-specific properties
if (selectedElement.type === 'text') {
html += `<div class="form-group">
<label>Text</label>
<input type="text" class="form-control" value="${selectedElement.properties.text}"
onchange="updateElementProperty('properties.text', this.value)">
</div>`;
}
panel.innerHTML = html;
}
function updateElementProperty(path, value) {
if (!selectedElement) return;
const keys = path.split('.');
let obj = selectedElement;
for (let i = 0; i < keys.length - 1; i++) {
if (!obj[keys[i]]) obj[keys[i]] = {};
obj = obj[keys[i]];
}
obj[keys[keys.length - 1]] = value;
updatePreview();
}
function clearEditor() {
currentElements = [];
selectedElement = null;
updatePreview();
updatePropertiesPanel();
}
async function saveLayout() {
try {
const response = await fetch('/api/config/save', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
type: 'layout',
data: {
elements: currentElements,
timestamp: Date.now()
}
})
});
const result = await response.json();
showNotification(result.message, result.status);
} catch (error) {
showNotification('Error saving layout: ' + error.message, 'error');
}
}
async function loadLayout(){
try {
const res = await fetch('/api/editor/layouts');
const data = await res.json();
if (data.status === 'success' && data.data && data.data.elements) {
currentElements = data.data.elements;
selectedElement = null;
updatePreview();
updatePropertiesPanel();
showNotification('Layout loaded', 'success');
} else {
showNotification('No saved layout found', 'warning');
}
} catch (err) {
showNotification('Error loading layout: ' + err, 'error');
}
}
// Sponsor Management Functions
async function loadSponsors() {
const listEl = document.getElementById('sponsor-list');
try {
const response = await fetch('/api/sponsors/list');
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
if (data.status === 'success') {
renderSponsorList(data.sponsors);
} else {
listEl.innerHTML = `<div style="color: var(--danger); padding: 1rem;">Error: ${data.message}</div>`;
showNotification('Error loading sponsors: ' + data.message, 'error');
}
} catch (error) {
console.error('Error loading sponsors:', error);
listEl.innerHTML = `<div style="color: var(--danger); padding: 1rem;">Error loading sponsors: ${error.message}</div>`;
showNotification('Error loading sponsors: ' + error.message, 'error');
}
}
function renderSponsorList(sponsors) {
const listEl = document.getElementById('sponsor-list');
if (!sponsors || Object.keys(sponsors).length === 0) {
listEl.innerHTML = '<div style="color: var(--text-muted); padding: 1rem;">No sponsors configured</div>';
return;
}
let html = '<div style="display: flex; flex-direction: column; gap: 0.5rem;">';
for (const [key, sponsor] of Object.entries(sponsors)) {
const statusClass = sponsor.enabled ? 'status-active' : 'status-inactive';
const statusIcon = sponsor.enabled ? 'check-circle' : 'times-circle';
html += `
<div style="display: flex; align-items: center; gap: 1rem; padding: 0.75rem; background: rgba(15, 23, 42, 0.3); border-radius: var(--radius-sm); border: 1px solid var(--border);">
<i class="fas fa-${statusIcon}" style="color: ${sponsor.enabled ? 'var(--success)' : 'var(--danger)'}"></i>
<span style="flex: 1; font-weight: 500;">${key}</span>
<button class="btn btn-info btn-sm" onclick="editSponsor('${key}')">
<i class="fas fa-edit"></i> Edit
</button>
<button class="btn btn-danger btn-sm" onclick="deleteSponsor('${key}')">
<i class="fas fa-trash"></i> Delete
</button>
</div>
`;
}
html += '</div>';
listEl.innerHTML = html;
}
async function editSponsor(key) {
try {
const response = await fetch(`/api/sponsors/get/${key}`);
const data = await response.json();
if (data.status === 'success') {
const sponsor = data.sponsor;
document.getElementById('sponsor-form-title').textContent = `Edit Sponsor: ${key}`;
document.getElementById('sponsor_editing_key').value = key;
document.getElementById('sponsor_abbr').value = key;
document.getElementById('sponsor_abbr').readOnly = true;
document.getElementById('sponsor_enabled').checked = sponsor.enabled || false;
document.getElementById('sponsor_show_details').value = sponsor.show_details ? 'true' : 'false';
document.getElementById('sponsor_pngabbr').value = sponsor.pngabbr || key;
document.getElementById('sponsor_details_row1').value = sponsor.details_row1 || '';
document.getElementById('sponsor_details_row2').value = sponsor.details_row2 || '';
// Scroll to form
document.getElementById('sponsor-form').scrollIntoView({ behavior: 'smooth' });
} else {
showNotification('Error loading sponsor: ' + data.message, 'error');
}
} catch (error) {
showNotification('Error loading sponsor: ' + error.message, 'error');
}
}
async function deleteSponsor(key) {
if (!confirm(`Are you sure you want to delete sponsor "${key}"?`)) {
return;
}
try {
const response = await fetch(`/api/sponsors/delete/${key}`, {
method: 'DELETE'
});
const data = await response.json();
if (data.status === 'success') {
showNotification('Sponsor deleted successfully', 'success');
loadSponsors();
} else {
showNotification('Error deleting sponsor: ' + data.message, 'error');
}
} catch (error) {
showNotification('Error deleting sponsor: ' + error.message, 'error');
}
}
function cancelSponsorEdit() {
document.getElementById('sponsor-form').reset();
document.getElementById('sponsor-form-title').textContent = 'Add New Sponsor';
document.getElementById('sponsor_editing_key').value = '';
document.getElementById('sponsor_abbr').readOnly = false;
document.getElementById('sponsor_pngabbr').value = '';
}
// Auto-fill pngabbr from sponsor_abbr
document.addEventListener('DOMContentLoaded', function() {
const abbrInput = document.getElementById('sponsor_abbr');
const pngabbrInput = document.getElementById('sponsor_pngabbr');
if (abbrInput && pngabbrInput) {
abbrInput.addEventListener('input', function() {
if (!document.getElementById('sponsor_editing_key').value) {
pngabbrInput.value = this.value;
}
});
}
// Sponsor form submission
const sponsorForm = document.getElementById('sponsor-form');
if (sponsorForm) {
sponsorForm.addEventListener('submit', async function(e) {
e.preventDefault();
const editingKey = document.getElementById('sponsor_editing_key').value;
const abbr = document.getElementById('sponsor_abbr').value.trim();
const enabled = document.getElementById('sponsor_enabled').checked;
const showDetails = document.getElementById('sponsor_show_details').value === 'true';
const pngabbr = document.getElementById('sponsor_pngabbr').value.trim();
const detailsRow1 = document.getElementById('sponsor_details_row1').value.trim();
const detailsRow2 = document.getElementById('sponsor_details_row2').value.trim();
const imageFile = document.getElementById('sponsor_image_upload').files[0];
// Validation
if (!abbr) {
showNotification('Sponsor abbreviation is required', 'error');
return;
}
// Check enable criteria
if (enabled && !imageFile && !editingKey && !detailsRow1) {
showNotification('Cannot enable sponsor without an image or details_row1 value', 'warning');
return;
}
const formData = new FormData();
formData.append('abbr', abbr);
formData.append('enabled', enabled);
formData.append('show_details', showDetails);
formData.append('pngabbr', pngabbr);
formData.append('details_row1', detailsRow1);
formData.append('details_row2', detailsRow2);
if (editingKey) {
formData.append('editing_key', editingKey);
}
if (imageFile) {
formData.append('image', imageFile);
}
try {
const response = await fetch('/api/sponsors/save', {
method: 'POST',
body: formData
});
const data = await response.json();
if (data.status === 'success') {
showNotification(editingKey ? 'Sponsor updated successfully' : 'Sponsor added successfully', 'success');
cancelSponsorEdit();
loadSponsors();
} else {
showNotification('Error saving sponsor: ' + data.message, 'error');
}
} catch (error) {
showNotification('Error saving sponsor: ' + error.message, 'error');
}
});
}
// Global sponsor settings form
const globalForm = document.getElementById('sponsor-global-form');
if (globalForm) {
globalForm.addEventListener('submit', async function(e) {
e.preventDefault();
const settings = {
enabled: document.getElementById('sponsors_enabled').value === 'true',
show_all: document.getElementById('sponsors_show_all').value === 'true',
show_num_sponsors: parseInt(document.getElementById('sponsors_show_num').value),
title_row1: document.getElementById('sponsors_title_row1').value,
title_row2: document.getElementById('sponsors_title_row2').value,
show_details: document.getElementById('sponsors_show_details').value === 'true',
loop: document.getElementById('sponsors_loop').value === 'true',
title_row1_font_size: parseInt(document.getElementById('sponsors_title_row1_font_size').value),
title_row1_colour: hexToColorName(document.getElementById('sponsors_title_row1_colour').value),
title_row2_font_size: parseInt(document.getElementById('sponsors_title_row2_font_size').value),
title_row2_colour: hexToColorName(document.getElementById('sponsors_title_row2_colour').value),
details_row1_font_size: parseInt(document.getElementById('sponsors_details_row1_font_size').value),
details_row1_colour: hexToColorName(document.getElementById('sponsors_details_row1_colour').value),
details_row2_font_size: parseInt(document.getElementById('sponsors_details_row2_font_size').value),
details_row2_colour: hexToColorName(document.getElementById('sponsors_details_row2_colour').value),
duration: parseInt(document.getElementById('sponsors_duration').value) || 15,
title_duration: parseInt(document.getElementById('sponsors_title_duration').value) || 5,
logo_duration: parseInt(document.getElementById('sponsors_logo_duration').value) || 5,
details_duration: parseInt(document.getElementById('sponsors_details_duration').value) || 5
};
try {
const response = await fetch('/api/sponsors/global', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(settings)
});
const data = await response.json();
if (data.status === 'success') {
showNotification('Global settings saved successfully', 'success');
} else {
showNotification('Error saving settings: ' + data.message, 'error');
}
} catch (error) {
showNotification('Error saving settings: ' + error.message, 'error');
}
});
}
});
async function loadSponsorGlobalSettings() {
try {
const response = await fetch('/api/sponsors/global');
const data = await response.json();
if (data.status === 'success') {
const settings = data.settings;
document.getElementById('sponsors_enabled').value = settings.enabled ? 'true' : 'false';
document.getElementById('sponsors_show_all').value = settings.show_all ? 'true' : 'false';
document.getElementById('sponsors_show_num').value = settings.show_num_sponsors || 3;
document.getElementById('sponsors_title_row1').value = settings.title_row1 || '';
document.getElementById('sponsors_title_row2').value = settings.title_row2 || '';
document.getElementById('sponsors_show_details').value = settings.show_details ? 'true' : 'false';
document.getElementById('sponsors_loop').value = settings.loop ? 'true' : 'false';
document.getElementById('sponsors_title_row1_font_size').value = settings.title_row1_font_size || 8;
document.getElementById('sponsors_title_row2_font_size').value = settings.title_row2_font_size || 10;
document.getElementById('sponsors_details_row1_font_size').value = settings.details_row1_font_size || 10;
document.getElementById('sponsors_details_row2_font_size').value = settings.details_row2_font_size || 8;
document.getElementById('sponsors_duration').value = settings.duration || 15;
document.getElementById('sponsors_title_duration').value = settings.title_duration || 5;
document.getElementById('sponsors_logo_duration').value = settings.logo_duration || 5;
document.getElementById('sponsors_details_duration').value = settings.details_duration || 5;
// Set color pickers
document.getElementById('sponsors_title_row1_colour').value = colorNameToHex(settings.title_row1_colour);
document.getElementById('sponsors_title_row2_colour').value = colorNameToHex(settings.title_row2_colour);
document.getElementById('sponsors_details_row1_colour').value = colorNameToHex(settings.details_row1_colour);
document.getElementById('sponsors_details_row2_colour').value = colorNameToHex(settings.details_row2_colour);
}
} catch (error) {
console.error('Error loading global settings:', error);
}
}
function hexToColorName(hex) {
// Simple color name mapping - extend as needed
const colorMap = {
'#0000ff': 'blue',
'#ffff00': 'yellow',
'#ffffff': 'white',
'#ff0000': 'red',
'#00ff00': 'green',
'#000000': 'black'
};
return colorMap[hex.toLowerCase()] || hex;
}
function colorNameToHex(name) {
// Simple color name to hex mapping
const colorMap = {
'blue': '#0000ff',
'yellow': '#ffff00',
'white': '#ffffff',
'red': '#ff0000',
'green': '#00ff00',
'black': '#000000'
};
return colorMap[name] || '#ffffff';
}
// Utility functions
function showNotification(message, type) {
const notification = document.getElementById('notification');
notification.textContent = message;
notification.className = `notification ${type} show`;
setTimeout(() => {
notification.classList.remove('show');
}, 3000);
}
async function updateSystemStats() {
try {
const response = await fetch('/api/system/status');
const stats = await response.json();
// Update stats in the overview tab if they exist
const cpuUsage = document.querySelector('.stat-card .stat-value');
if (cpuUsage) {
document.querySelectorAll('.stat-card .stat-value')[0].textContent = stats.cpu_percent + '%';
document.querySelectorAll('.stat-card .stat-value')[1].textContent = stats.memory_used_percent + '%';
document.querySelectorAll('.stat-card .stat-value')[2].textContent = stats.cpu_temp + '°C';
document.querySelectorAll('.stat-card .stat-value')[5].textContent = stats.disk_used_percent + '%';
}
refreshOnDemandStatus();
} catch (error) {
console.error('Error updating system stats:', error);
}
}
function updateBrightnessDisplay(value) {
document.getElementById('brightness-value').textContent = value;
}
// Form submission handlers
document.getElementById('schedule-form').addEventListener('submit', async function(e) {
e.preventDefault();
const formData = new FormData(this);
try {
const response = await fetch('/save_schedule', {
method: 'POST',
body: formData
});
const result = await response.json();
showNotification(result.message, result.status);
} catch (error) {
showNotification('Error saving schedule: ' + error.message, 'error');
}
});
document.getElementById('display-form').addEventListener('submit', async function(e) {
e.preventDefault();
const formData = new FormData(this);
formData.append('config_type', 'main');
try {
const response = await fetch('/save_config', {
method: 'POST',
body: formData
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const result = await response.json();
showNotification(result.message, result.status);
} catch (error) {
showNotification('Error saving display settings: ' + error.message, 'error');
}
});
// General form submit
document.getElementById('general-form').addEventListener('submit', async function(e) {
e.preventDefault();
const payload = {
web_display_autostart: document.getElementById('web_display_autostart').checked,
timezone: document.getElementById('timezone').value,
location: {
city: document.getElementById('city').value,
state: document.getElementById('state').value,
country: document.getElementById('country').value
}
};
await saveConfigJson(payload);
});
// Clock form submit
document.getElementById('clock-form').addEventListener('submit', async function(e) {
e.preventDefault();
const payload = {
clock: {
enabled: document.getElementById('clock_enabled').checked,
format: document.getElementById('clock_format').value,
update_interval: parseInt(document.getElementById('clock_update_interval').value)
}
};
await saveConfigJson(payload);
});
// Durations form submit
document.getElementById('durations-form').addEventListener('submit', async function(e) {
e.preventDefault();
const inputs = document.querySelectorAll('.duration-input');
const durations = {};
inputs.forEach(inp => {
durations[inp.dataset.name] = parseInt(inp.value);
});
const payload = { display: { display_durations: durations } };
await saveConfigJson(payload);
});
// Leaderboard form submit
(function augmentLeaderboardForm(){
const form = document.getElementById('leaderboard-form');
form.addEventListener('submit', async function(e){
e.preventDefault();
const payload = {
leaderboard: {
enabled: document.getElementById('leaderboard_enabled').checked,
update_interval: parseInt(document.getElementById('leaderboard_update_interval').value),
scroll_speed: parseFloat(document.getElementById('leaderboard_scroll_speed').value),
scroll_delay: parseFloat(document.getElementById('leaderboard_scroll_delay').value),
display_duration: parseInt(document.getElementById('leaderboard_display_duration').value),
loop: document.getElementById('leaderboard_loop').checked,
request_timeout: parseInt(document.getElementById('leaderboard_request_timeout').value),
dynamic_duration: document.getElementById('leaderboard_dynamic_duration').checked,
min_duration: parseInt(document.getElementById('leaderboard_min_duration').value),
max_duration: parseInt(document.getElementById('leaderboard_max_duration').value),
duration_buffer: parseFloat(document.getElementById('leaderboard_duration_buffer').value),
enabled_sports: {
eojhl: {
enabled: document.getElementById('leaderboard_eojhl_enabled').checked,
top_teams: parseInt(document.getElementById('leaderboard_eojhl_top_teams').value)
}
}
}
};
await saveConfigJson(payload);
});
})();
// Text form submit
(function augmentTextForm(){
const form = document.getElementById('text-form');
const initColor = (input) => {
try { const rgb = JSON.parse(input.dataset.rgb || '[255,255,255]'); input.value = rgbToHex(rgb); } catch {}
};
initColor(document.getElementById('text_text_color'));
initColor(document.getElementById('text_background_color'));
form.addEventListener('submit', async function(e){
e.preventDefault();
const payload = {
text_display: {
enabled: document.getElementById('text_enabled').checked,
text: document.getElementById('text_text').value,
font_path: document.getElementById('text_font_path').value,
font_size: parseInt(document.getElementById('text_font_size').value),
scroll: document.getElementById('text_scroll').checked,
scroll_speed: parseInt(document.getElementById('text_scroll_speed').value),
scroll_gap_width: parseInt(document.getElementById('text_scroll_gap_width').value),
text_color: hexToRgbArray(document.getElementById('text_text_color').value),
background_color: hexToRgbArray(document.getElementById('text_background_color').value)
}
};
await saveConfigJson(payload);
});
})();
// Static Image form submit
(function augmentStaticImageForm(){
const form = document.getElementById('static_image-form');
const initColor = (input) => {
try { const rgb = JSON.parse(input.dataset.rgb || '[255,255,255]'); input.value = rgbToHex(rgb); } catch {}
};
initColor(document.getElementById('static_image_background_color'));
form.addEventListener('submit', async function(e){
e.preventDefault();
const payload = {
static_image: {
enabled: document.getElementById('static_image_enabled').checked,
image_path: document.getElementById('static_image_path').value,
fit_to_display: document.getElementById('static_image_fit_to_display').checked,
preserve_aspect_ratio: document.getElementById('static_image_preserve_aspect_ratio').checked,
background_color: hexToRgbArray(document.getElementById('static_image_background_color').value)
}
};
await saveConfigJson(payload);
});
})();
// Image upload handler
function handleImageUpload(input) {
const file = input.files[0];
if (file) {
const formData = new FormData();
formData.append('image', file);
fetch('/upload_image', {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
if (data.success) {
document.getElementById('static_image_path').value = data.path;
showNotification('Image uploaded successfully!', 'success');
} else {
showNotification('Failed to upload image: ' + data.error, 'error');
}
})
.catch(error => {
showNotification('Error uploading image: ' + error, 'error');
});
}
}
// Helpers for RGB <-> hex
function hexToRgbArray(hex){
const m = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return m ? [parseInt(m[1],16), parseInt(m[2],16), parseInt(m[3],16)] : [255,255,255];
}
function rgbToHex(arr){
const [r,g,b] = arr || [255,255,255];
const toHex = v => ('0' + Math.max(0, Math.min(255, v)).toString(16)).slice(-2);
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
}
// Generic JSON save using existing endpoint
async function saveConfigJson(fragment){
try {
const response = await fetch('/save_config', {
method: 'POST',
body: makeFormData({
config_type: 'main',
config_data: JSON.stringify(fragment)
})
});
const result = await response.json();
showNotification(result.message || 'Saved', result.status || 'success');
} catch (error) {
showNotification('Error saving configuration: ' + error, 'error');
}
}
function makeFormData(obj){
const fd = new FormData();
Object.entries(obj).forEach(([k,v]) => fd.append(k, v));
return fd;
}
// JSON validation and formatting functions
function formatJson(elementId) {
const textarea = document.getElementById(elementId);
const jsonText = textarea.value;
try {
const parsed = JSON.parse(jsonText);
const formatted = JSON.stringify(parsed, null, 4);
textarea.value = formatted;
textarea.classList.remove('error');
textarea.classList.add('valid');
showNotification('JSON formatted successfully!', 'success');
} catch (error) {
showNotification(`Cannot format invalid JSON: ${error.message}`, 'error');
textarea.classList.remove('valid');
textarea.classList.add('error');
}
}
function validateJson(textareaId, validationId) {
const textarea = document.getElementById(textareaId);
const validationDiv = document.getElementById(validationId);
const jsonText = textarea.value;
validationDiv.innerHTML = '';
validationDiv.className = 'json-validation';
validationDiv.style.display = 'block';
const statusId = validationId.replace('-validation', '-status');
const statusElement = document.getElementById(statusId);
try {
const parsed = JSON.parse(jsonText);
validationDiv.className = 'json-validation success';
if (statusElement) {
statusElement.textContent = 'VALID';
statusElement.className = 'json-status valid';
}
validationDiv.innerHTML = `
<div><strong>✅ JSON is valid!</strong></div>
<div>✓ Valid JSON syntax<br>✓ Proper structure<br>✓ No obvious issues detected</div>
`;
} catch (error) {
validationDiv.className = 'json-validation error';
if (statusElement) {
statusElement.textContent = 'INVALID';
statusElement.className = 'json-status error';
}
validationDiv.innerHTML = `
<div><strong>❌ Invalid JSON syntax</strong></div>
<div><strong>Error:</strong> ${error.message}</div>
`;
}
}
async function saveRawJson(configType) {
const textareaId = configType === 'main' ? 'main-config-json' : 'secrets-config-json';
const textarea = document.getElementById(textareaId);
const jsonText = textarea.value;
try {
JSON.parse(jsonText);
} catch (error) {
showNotification(`Invalid JSON format: ${error.message}`, 'error');
return;
}
const configName = configType === 'main' ? 'Main Configuration' : 'Secrets Configuration';
if (!confirm(`Are you sure you want to save changes to the ${configName}?`)) {
return;
}
try {
const response = await fetch('/save_raw_json', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
config_type: configType,
config_data: jsonText
})
});
const data = await response.json();
showNotification(data.message, data.status);
} catch (error) {
showNotification(`Error saving configuration: ${error}`, 'error');
}
}
// Action functions
async function runAction(actionName) {
const outputElement = document.getElementById('action_output');
outputElement.textContent = `Running ${actionName.replace(/_/g, ' ')}...`;
try {
const response = await fetch('/run_action', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: actionName })
});
const data = await response.json();
let outputText = `Status: ${data.status}\nMessage: ${data.message}\n`;
if (data.stdout) outputText += `\n--- STDOUT ---\n${data.stdout}`;
if (data.stderr) outputText += `\n--- STDERR ---\n${data.stderr}`;
outputElement.textContent = outputText;
showNotification(data.message, data.status);
} catch (error) {
outputElement.textContent = `Error: ${error}`;
showNotification(`Error running action: ${error}`, 'error');
}
}
function confirmShutdown(){
if (!confirm('Are you sure you want to shut down the system? This will power off the Raspberry Pi.')) return;
runAction('shutdown_system');
}
async function fetchLogs() {
const logContent = document.getElementById('log-content');
logContent.textContent = 'Loading logs...';
try {
const response = await fetch('/get_logs');
const data = await response.json();
if (data.status === 'success') {
logContent.textContent = data.logs;
} else {
logContent.textContent = `Error loading logs: ${data.message}`;
}
} catch (error) {
logContent.textContent = `Error loading logs: ${error}`;
}
}
// Sports configuration
async function refreshSportsConfig(){
try {
// Refresh the current config to ensure we have the latest data
await refreshCurrentConfig();
const res = await fetch('/api/system/status');
const stats = await res.json();
// Build a minimal sports UI off current config
const cfg = currentConfig;
const leaguePrefixes = {
'eojhl_scoreboard': 'eojhl'
};
const leagues = [
{ key: 'eojhl_scoreboard', label: 'EOJHL' }
];
const container = document.getElementById('sports-config');
const html = leagues.map(l => {
const sec = cfg[l.key] || {};
const p = leaguePrefixes[l.key] || l.key;
const fav = (sec.favorite_teams || []).join(', ');
const recentToShow = sec.recent_games_to_show ?? 1;
const upcomingToShow = sec.upcoming_games_to_show ?? 1;
const liveUpd = sec.live_update_interval ?? 30;
const recentUpd = sec.recent_update_interval ?? 3600;
const upcomingUpd = sec.upcoming_update_interval ?? 3600;
const displayModes = sec.display_modes || {};
const liveModeEnabled = displayModes[`${p}_live`] ?? true;
const recentModeEnabled = displayModes[`${p}_recent`] ?? true;
const upcomingModeEnabled = displayModes[`${p}_upcoming`] ?? true;
return `
<div style="border:1px solid #ddd; border-radius:6px; padding:12px; margin:10px 0;">
<div style="display:flex; justify-content: space-between; align-items:center; margin-bottom:8px;">
<label style="display:flex; align-items:center; gap:8px; margin:0;">
<input type="checkbox" data-league="${l.key}" class="sp-enabled" ${sec.enabled ? 'checked' : ''}>
<strong>${l.label}</strong>
</label>
<div style="display:flex; gap:6px;">
<button type="button" class="btn btn-info" onclick="startOnDemand('${p}_live')"><i class="fas fa-bolt"></i> Live</button>
<button type="button" class="btn btn-info" onclick="startOnDemand('${p}_recent')"><i class="fas fa-bolt"></i> Recent</button>
<button type="button" class="btn btn-info" onclick="startOnDemand('${p}_upcoming')"><i class="fas fa-bolt"></i> Upcoming</button>
<button type="button" class="btn btn-secondary" onclick="stopOnDemand()"><i class="fas fa-ban"></i> Stop</button>
</div>
</div>
<div style="margin-top:15px;">
<h4 style="margin: 0 0 10px 0; color: var(--primary-color); font-size: 16px;">
<i class="fas fa-toggle-on"></i> Display Modes
</h4>
<div class="display-mode-toggle">
<label>
<input type="checkbox" data-league="${l.key}" class="sp-display-mode" data-mode="live" ${liveModeEnabled ? 'checked' : ''}>
<i class="fas fa-circle mode-icon mode-live"></i>
<span class="mode-label mode-live">Live Mode</span>
</label>
</div>
<div class="display-mode-toggle">
<label>
<input type="checkbox" data-league="${l.key}" class="sp-display-mode" data-mode="recent" ${recentModeEnabled ? 'checked' : ''}>
<i class="fas fa-history mode-icon mode-recent"></i>
<span class="mode-label mode-recent">Recent Mode</span>
</label>
</div>
<div class="display-mode-toggle">
<label>
<input type="checkbox" data-league="${l.key}" class="sp-display-mode" data-mode="upcoming" ${upcomingModeEnabled ? 'checked' : ''}>
<i class="fas fa-clock mode-icon mode-upcoming"></i>
<span class="mode-label mode-upcoming">Upcoming Mode</span>
</label>
</div>
</div>
<div class="form-row" style="margin-top:10px;">
<div class="form-group">
<label>Live Priority</label>
<input type="checkbox" data-league="${l.key}" class="sp-live-priority" ${sec.live_priority ? 'checked' : ''}>
</div>
<div class="form-group">
<label>Show Odds</label>
<input type="checkbox" data-league="${l.key}" class="sp-show-odds" ${sec.show_odds ? 'checked' : ''}>
</div>
<div class="form-group">
<label>Favorites Only</label>
<input type="checkbox" data-league="${l.key}" class="sp-favorites-only" ${sec.show_favorite_teams_only ? 'checked' : ''}>
</div>
<div class="form-group">
<label>Favorite Teams</label>
<input type="text" data-league="${l.key}" class="form-control sp-favorites" value="${fav}">
<div class="description">Comma-separated abbreviations</div>
</div>
</div>
<div class="form-row" style="margin-top:10px;">
<div class="form-group">
<label>Live Update Interval (sec)</label>
<input type="number" min="10" class="form-control sp-live-update" data-league="${l.key}" value="${liveUpd}">
</div>
<div class="form-group">
<label>Recent Update Interval (sec)</label>
<input type="number" min="60" class="form-control sp-recent-update" data-league="${l.key}" value="${recentUpd}">
</div>
<div class="form-group">
<label>Upcoming Update Interval (sec)</label>
<input type="number" min="60" class="form-control sp-upcoming-update" data-league="${l.key}" value="${upcomingUpd}">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Recent Games to Show</label>
<input type="number" min="0" class="form-control sp-recent-count" data-league="${l.key}" value="${recentToShow}">
</div>
<div class="form-group">
<label>Upcoming Games to Show</label>
<input type="number" min="0" class="form-control sp-upcoming-count" data-league="${l.key}" value="${upcomingToShow}">
</div>
</div>
</div>
`;
}).join('');
container.innerHTML = html || 'No sports configuration found.';
// Add event listeners for display mode toggles
const displayModeCheckboxes = container.querySelectorAll('.sp-display-mode');
displayModeCheckboxes.forEach(checkbox => {
checkbox.addEventListener('change', function() {
const league = this.getAttribute('data-league');
const mode = this.getAttribute('data-mode');
const isEnabled = this.checked;
// Visual feedback
const label = this.closest('label');
const toggle = this.closest('.display-mode-toggle');
if (isEnabled) {
toggle.style.backgroundColor = 'rgba(46, 204, 113, 0.1)';
toggle.style.borderColor = '#2ecc71';
} else {
toggle.style.backgroundColor = 'rgba(231, 76, 60, 0.1)';
toggle.style.borderColor = '#e74c3c';
}
// Reset after a short delay
setTimeout(() => {
toggle.style.backgroundColor = '';
toggle.style.borderColor = '';
}, 1000);
showNotification(`${league.toUpperCase()} ${mode} mode ${isEnabled ? 'enabled' : 'disabled'}`, 'success');
});
});
} catch (err) {
document.getElementById('sports-config').textContent = 'Failed to load sports configuration';
}
}
async function saveSportsConfig(){
try {
const leagues = document.querySelectorAll('.sp-enabled');
const fragment = {};
leagues.forEach(chk => {
const key = chk.getAttribute('data-league');
const enabled = chk.checked;
const livePriority = document.querySelector(`.sp-live-priority[data-league="${key}"]`)?.checked || false;
const showOdds = document.querySelector(`.sp-show-odds[data-league="${key}"]`)?.checked || false;
const favoritesOnly = document.querySelector(`.sp-favorites-only[data-league="${key}"]`)?.checked || false;
const favs = document.querySelector(`.sp-favorites[data-league="${key}"]`)?.value || '';
const favorite_teams = favs.split(',').map(s => s.trim()).filter(Boolean);
const liveUpd = parseInt(document.querySelector(`.sp-live-update[data-league="${key}"]`)?.value || '30');
const recentUpd = parseInt(document.querySelector(`.sp-recent-update[data-league="${key}"]`)?.value || '3600');
const upcomingUpd = parseInt(document.querySelector(`.sp-upcoming-update[data-league="${key}"]`)?.value || '3600');
const recentCount = parseInt(document.querySelector(`.sp-recent-count[data-league="${key}"]`)?.value || '1');
const upcomingCount = parseInt(document.querySelector(`.sp-upcoming-count[data-league="${key}"]`)?.value || '1');
// Get display modes
const leaguePrefixes = {
'eojhl_scoreboard': 'eojhl'
};
const p = leaguePrefixes[key] || key;
const liveModeEnabled = document.querySelector(`.sp-display-mode[data-league="${key}"][data-mode="live"]`)?.checked || false;
const recentModeEnabled = document.querySelector(`.sp-display-mode[data-league="${key}"][data-mode="recent"]`)?.checked || false;
const upcomingModeEnabled = document.querySelector(`.sp-display-mode[data-league="${key}"][data-mode="upcoming"]`)?.checked || false;
fragment[key] = {
enabled,
live_priority: livePriority,
show_odds: showOdds,
show_favorite_teams_only: favoritesOnly,
favorite_teams,
live_update_interval: liveUpd,
recent_update_interval: recentUpd,
upcoming_update_interval: upcomingUpd,
recent_games_to_show: recentCount,
upcoming_games_to_show: upcomingCount,
display_modes: {
[`${p}_live`]: liveModeEnabled,
[`${p}_recent`]: recentModeEnabled,
[`${p}_upcoming`]: upcomingModeEnabled
}
};
});
await saveConfigJson(fragment);
} catch (err) {
showNotification('Error saving sports configuration: ' + err, 'error');
return;
}
showNotification('Sports configuration saved', 'success');
}
function showTab(id) {
document.querySelectorAll('.tab-content').forEach(el => el.classList.remove('active'));
document.querySelectorAll('.tab-btn').forEach(el => el.classList.remove('active'));
document.getElementById(id).classList.add('active');
event.currentTarget.classList.add('active');
}
document.addEventListener("DOMContentLoaded", function() {
const sponsorList = document.getElementById("sponsorList");
if (sponsorList) {
new Sortable(sponsorList, {
animation: 150,
onEnd: function () {
const order = Array.from(sponsorList.querySelectorAll("li"))
.map(li => li.dataset.name);
fetch("/reorder_sponsors", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ order: order })
})
.then(resp => resp.json())
.then(data => {
if (data.status === "ok") {
showNotification("Sponsor order saved", "success");
} else {
showNotification("Error saving order", "error");
}
})
.catch(() => showNotification("Network error", "error"));
}
});
}
});
function showNotification(message, type) {
const note = document.createElement("div");
note.className = "notification " + type + " show";
note.textContent = message;
document.body.appendChild(note);
setTimeout(() => note.remove(), 3000);
}
</script>
</body>
</html>