3240 lines
153 KiB
HTML
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> |