mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-04-10 13:02:59 +00:00
3610 lines
194 KiB
HTML
3610 lines
194 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>LED Matrix Control Panel - Enhanced</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>
|
|
:root {
|
|
--primary-color: #2c3e50;
|
|
--secondary-color: #3498db;
|
|
--accent-color: #e74c3c;
|
|
--success-color: #27ae60;
|
|
--warning-color: #f39c12;
|
|
--background-color: #ecf0f1;
|
|
--card-background: #ffffff;
|
|
--text-color: #2c3e50;
|
|
--border-color: #bdc3c7;
|
|
--shadow: 0 2px 10px rgba(0,0,0,0.1);
|
|
--border-radius: 8px;
|
|
}
|
|
|
|
* {
|
|
margin: 0;
|
|
padding: 0;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
body {
|
|
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
min-height: 100vh;
|
|
color: var(--text-color);
|
|
}
|
|
|
|
.container {
|
|
max-width: 1600px;
|
|
margin: 0 auto;
|
|
padding: 20px;
|
|
}
|
|
|
|
.header {
|
|
background: var(--card-background);
|
|
border-radius: var(--border-radius);
|
|
padding: 20px;
|
|
margin-bottom: 20px;
|
|
box-shadow: var(--shadow);
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.header h1 {
|
|
color: var(--primary-color);
|
|
font-size: 2rem;
|
|
font-weight: 300;
|
|
}
|
|
|
|
.system-status {
|
|
display: flex;
|
|
gap: 15px;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.status-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
padding: 8px 12px;
|
|
border-radius: 20px;
|
|
font-size: 0.9rem;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.status-active {
|
|
background: rgba(39, 174, 96, 0.1);
|
|
color: var(--success-color);
|
|
}
|
|
|
|
.status-inactive {
|
|
background: rgba(231, 76, 60, 0.1);
|
|
color: var(--accent-color);
|
|
}
|
|
|
|
.status-warning {
|
|
background: rgba(243, 156, 18, 0.1);
|
|
color: var(--warning-color);
|
|
}
|
|
|
|
.main-grid {
|
|
display: grid;
|
|
grid-template-columns: 1fr;
|
|
gap: 20px;
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.display-panel {
|
|
background: var(--card-background);
|
|
border-radius: var(--border-radius);
|
|
padding: 20px;
|
|
box-shadow: var(--shadow);
|
|
}
|
|
|
|
.display-preview {
|
|
background: #000;
|
|
border-radius: var(--border-radius);
|
|
padding: 20px;
|
|
text-align: center;
|
|
position: relative;
|
|
min-height: 400px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
border: 2px solid #333;
|
|
}
|
|
|
|
/* Mock device frame look */
|
|
.display-preview::before {
|
|
content: "";
|
|
position: absolute;
|
|
inset: 10px;
|
|
border-radius: 12px;
|
|
box-shadow: inset 0 0 0 2px rgba(255,255,255,0.06), inset 0 0 30px rgba(255,255,255,0.05);
|
|
pointer-events: none;
|
|
}
|
|
|
|
.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 #555;
|
|
border-radius: 4px;
|
|
background: #111;
|
|
}
|
|
|
|
.display-controls {
|
|
margin-top: 20px;
|
|
display: flex;
|
|
gap: 10px;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.btn {
|
|
padding: 12px 20px;
|
|
border: none;
|
|
border-radius: var(--border-radius);
|
|
cursor: pointer;
|
|
font-size: 0.9rem;
|
|
font-weight: 500;
|
|
transition: all 0.3s ease;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
text-decoration: none;
|
|
}
|
|
|
|
.btn-primary {
|
|
background: var(--secondary-color);
|
|
color: white;
|
|
}
|
|
|
|
.btn-primary:hover {
|
|
background: #2980b9;
|
|
transform: translateY(-2px);
|
|
}
|
|
|
|
.btn-success {
|
|
background: var(--success-color);
|
|
color: white;
|
|
}
|
|
|
|
.btn-success:hover {
|
|
background: #219a52;
|
|
transform: translateY(-2px);
|
|
}
|
|
|
|
.btn-danger {
|
|
background: var(--accent-color);
|
|
color: white;
|
|
}
|
|
|
|
.btn-danger:hover {
|
|
background: #c0392b;
|
|
transform: translateY(-2px);
|
|
}
|
|
|
|
.btn-warning {
|
|
background: var(--warning-color);
|
|
color: white;
|
|
}
|
|
|
|
.btn-warning:hover {
|
|
background: #d68910;
|
|
transform: translateY(-2px);
|
|
}
|
|
|
|
.control-panel {
|
|
background: var(--card-background);
|
|
border-radius: var(--border-radius);
|
|
padding: 20px;
|
|
box-shadow: var(--shadow);
|
|
max-width: 1200px;
|
|
margin: 0 auto;
|
|
}
|
|
|
|
.quick-controls {
|
|
background: var(--card-background);
|
|
border-radius: var(--border-radius);
|
|
padding: 20px;
|
|
box-shadow: var(--shadow);
|
|
margin-bottom: 20px;
|
|
max-width: 1200px;
|
|
margin-left: auto;
|
|
margin-right: auto;
|
|
}
|
|
|
|
.tabs {
|
|
display: flex;
|
|
border-bottom: 2px solid var(--border-color);
|
|
margin-bottom: 20px;
|
|
overflow-x: auto;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.tab-btn {
|
|
padding: 12px 20px;
|
|
border: none;
|
|
background: none;
|
|
cursor: pointer;
|
|
font-size: 0.9rem;
|
|
font-weight: 500;
|
|
color: var(--text-color);
|
|
border-bottom: 3px solid transparent;
|
|
transition: all 0.3s ease;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.tab-btn.active {
|
|
color: var(--secondary-color);
|
|
border-bottom-color: var(--secondary-color);
|
|
}
|
|
|
|
.tab-btn:hover {
|
|
background: rgba(52, 152, 219, 0.1);
|
|
}
|
|
|
|
.tab-content {
|
|
display: none;
|
|
max-height: 70vh;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.tab-content.active {
|
|
display: block;
|
|
}
|
|
|
|
.form-group {
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.form-group label {
|
|
display: block;
|
|
margin-bottom: 8px;
|
|
font-weight: 500;
|
|
color: var(--text-color);
|
|
}
|
|
|
|
.form-control {
|
|
width: 100%;
|
|
padding: 12px;
|
|
border: 2px solid var(--border-color);
|
|
border-radius: var(--border-radius);
|
|
font-size: 0.9rem;
|
|
transition: border-color 0.3s ease;
|
|
}
|
|
|
|
.form-control:focus {
|
|
outline: none;
|
|
border-color: var(--secondary-color);
|
|
}
|
|
|
|
.form-row {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
|
gap: 20px;
|
|
}
|
|
|
|
.config-section {
|
|
background: #f9f9f9;
|
|
padding: 20px;
|
|
margin-bottom: 20px;
|
|
border-radius: var(--border-radius);
|
|
border-left: 4px solid var(--secondary-color);
|
|
}
|
|
|
|
.config-section h3 {
|
|
color: var(--secondary-color);
|
|
margin-bottom: 15px;
|
|
}
|
|
|
|
.toggle-switch {
|
|
position: relative;
|
|
display: inline-block;
|
|
width: 60px;
|
|
height: 34px;
|
|
}
|
|
|
|
.toggle-switch input {
|
|
opacity: 0;
|
|
width: 0;
|
|
height: 0;
|
|
}
|
|
|
|
.slider {
|
|
position: absolute;
|
|
cursor: pointer;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
background-color: #ccc;
|
|
transition: 0.4s;
|
|
border-radius: 34px;
|
|
}
|
|
|
|
.slider:before {
|
|
position: absolute;
|
|
content: "";
|
|
height: 26px;
|
|
width: 26px;
|
|
left: 4px;
|
|
bottom: 4px;
|
|
background-color: white;
|
|
transition: 0.4s;
|
|
border-radius: 50%;
|
|
}
|
|
|
|
input:checked + .slider {
|
|
background-color: var(--secondary-color);
|
|
}
|
|
|
|
input:checked + .slider:before {
|
|
transform: translateX(26px);
|
|
}
|
|
|
|
.editor-mode {
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
color: white;
|
|
padding: 20px;
|
|
border-radius: var(--border-radius);
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.editor-toolbar {
|
|
display: flex;
|
|
gap: 10px;
|
|
margin-bottom: 20px;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.element-palette {
|
|
background: var(--card-background);
|
|
border-radius: var(--border-radius);
|
|
padding: 15px;
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.palette-item {
|
|
display: inline-block;
|
|
padding: 10px 15px;
|
|
margin: 5px;
|
|
background: var(--secondary-color);
|
|
color: white;
|
|
border-radius: var(--border-radius);
|
|
cursor: pointer;
|
|
transition: all 0.3s ease;
|
|
font-size: 0.9rem;
|
|
}
|
|
|
|
.palette-item:hover {
|
|
background: #2980b9;
|
|
transform: translateY(-2px);
|
|
}
|
|
|
|
.properties-panel {
|
|
background: var(--card-background);
|
|
border-radius: var(--border-radius);
|
|
padding: 15px;
|
|
margin-top: 20px;
|
|
}
|
|
|
|
.color-picker {
|
|
display: flex;
|
|
gap: 10px;
|
|
margin-top: 10px;
|
|
}
|
|
|
|
.color-option {
|
|
width: 30px;
|
|
height: 30px;
|
|
border-radius: 50%;
|
|
cursor: pointer;
|
|
border: 3px solid transparent;
|
|
transition: all 0.3s ease;
|
|
}
|
|
|
|
.color-option.selected {
|
|
border-color: var(--text-color);
|
|
transform: scale(1.1);
|
|
}
|
|
|
|
.notification {
|
|
position: fixed;
|
|
top: 20px;
|
|
right: 20px;
|
|
padding: 15px 20px;
|
|
border-radius: var(--border-radius);
|
|
color: white;
|
|
font-weight: 500;
|
|
z-index: 1000;
|
|
opacity: 0;
|
|
transform: translateX(100%);
|
|
transition: all 0.3s ease;
|
|
}
|
|
|
|
.notification.show {
|
|
opacity: 1;
|
|
transform: translateX(0);
|
|
}
|
|
|
|
.notification.success {
|
|
background: var(--success-color);
|
|
}
|
|
|
|
.notification.error {
|
|
background: var(--accent-color);
|
|
}
|
|
|
|
.notification.warning {
|
|
background: var(--warning-color);
|
|
}
|
|
|
|
.stats-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
gap: 15px;
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.stat-card {
|
|
background: var(--card-background);
|
|
border-radius: var(--border-radius);
|
|
padding: 20px;
|
|
box-shadow: var(--shadow);
|
|
text-align: center;
|
|
}
|
|
|
|
.stat-value {
|
|
font-size: 2rem;
|
|
font-weight: bold;
|
|
color: var(--secondary-color);
|
|
}
|
|
|
|
.stat-label {
|
|
color: var(--text-color);
|
|
font-size: 0.9rem;
|
|
margin-top: 5px;
|
|
}
|
|
|
|
.json-container {
|
|
position: relative;
|
|
margin-bottom: 15px;
|
|
}
|
|
|
|
.json-container textarea {
|
|
width: 100%;
|
|
min-height: 300px;
|
|
padding: 15px;
|
|
border: 2px solid var(--border-color);
|
|
border-radius: var(--border-radius);
|
|
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
|
font-size: 12px;
|
|
line-height: 1.4;
|
|
transition: border-color 0.3s ease;
|
|
}
|
|
|
|
.json-container textarea:focus {
|
|
border-color: var(--secondary-color);
|
|
outline: none;
|
|
}
|
|
|
|
.json-container textarea.error {
|
|
border-color: var(--accent-color);
|
|
}
|
|
|
|
.json-container textarea.valid {
|
|
border-color: var(--success-color);
|
|
}
|
|
|
|
.json-status {
|
|
position: absolute;
|
|
top: 10px;
|
|
right: 10px;
|
|
padding: 4px 8px;
|
|
border-radius: 3px;
|
|
font-size: 11px;
|
|
font-weight: bold;
|
|
color: white;
|
|
}
|
|
|
|
.json-status.valid {
|
|
background-color: var(--success-color);
|
|
}
|
|
|
|
.json-status.error {
|
|
background-color: var(--accent-color);
|
|
}
|
|
|
|
.json-status.warning {
|
|
background-color: var(--warning-color);
|
|
}
|
|
|
|
.json-validation {
|
|
margin-top: 10px;
|
|
padding: 10px;
|
|
border-radius: var(--border-radius);
|
|
font-family: monospace;
|
|
font-size: 12px;
|
|
display: none;
|
|
}
|
|
|
|
.json-validation.success {
|
|
background-color: #d4edda;
|
|
color: #155724;
|
|
border: 1px solid #c3e6cb;
|
|
}
|
|
|
|
.json-validation.error {
|
|
background-color: #f8d7da;
|
|
color: #721c24;
|
|
border: 1px solid #f5c6cb;
|
|
}
|
|
|
|
.json-validation.warning {
|
|
background-color: #fff3cd;
|
|
color: #856404;
|
|
border: 1px solid #ffeaa7;
|
|
}
|
|
|
|
.json-actions {
|
|
margin-top: 15px;
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 10px;
|
|
align-items: center;
|
|
}
|
|
|
|
.description {
|
|
font-size: 12px;
|
|
color: #666;
|
|
margin-top: 5px;
|
|
font-style: italic;
|
|
}
|
|
|
|
.array-input {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 10px;
|
|
align-items: center;
|
|
}
|
|
|
|
.array-input input {
|
|
flex: 1;
|
|
min-width: 120px;
|
|
}
|
|
|
|
.checkbox-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
|
gap: 10px;
|
|
margin: 10px 0;
|
|
}
|
|
|
|
.checkbox-item {
|
|
padding: 8px;
|
|
border: 1px solid #ddd;
|
|
border-radius: 4px;
|
|
background-color: #f9f9f9;
|
|
}
|
|
|
|
.checkbox-item label {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
margin: 0;
|
|
cursor: pointer;
|
|
}
|
|
|
|
@media (max-width: 768px) {
|
|
.main-grid {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
|
|
.header {
|
|
flex-direction: column;
|
|
gap: 15px;
|
|
}
|
|
|
|
.system-status {
|
|
justify-content: center;
|
|
}
|
|
|
|
.display-controls {
|
|
justify-content: center;
|
|
}
|
|
|
|
.tabs {
|
|
flex-wrap: wrap;
|
|
}
|
|
}
|
|
|
|
.loading {
|
|
display: inline-block;
|
|
width: 20px;
|
|
height: 20px;
|
|
border: 3px solid rgba(255,255,255,.3);
|
|
border-radius: 50%;
|
|
border-top-color: #fff;
|
|
animation: spin 1s ease-in-out infinite;
|
|
}
|
|
|
|
@keyframes spin {
|
|
to { transform: rotate(360deg); }
|
|
}
|
|
|
|
.connection-status {
|
|
position: fixed;
|
|
bottom: 20px;
|
|
right: 20px;
|
|
padding: 10px 15px;
|
|
border-radius: 20px;
|
|
font-size: 0.8rem;
|
|
font-weight: 500;
|
|
z-index: 1000;
|
|
}
|
|
|
|
.connected {
|
|
background: rgba(39, 174, 96, 0.9);
|
|
color: white;
|
|
}
|
|
|
|
.disconnected {
|
|
background: rgba(231, 76, 60, 0.9);
|
|
color: white;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<!-- Header -->
|
|
<div class="header">
|
|
<h1><i class="fas fa-tv"></i> LED Matrix Control Panel - Enhanced</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">
|
|
<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-primary" onclick="systemAction('restart_service')"><i class="fas fa-redo"></i> Restart Service</button>
|
|
<button class="btn btn-warning" onclick="systemAction('git_pull')"><i class="fas fa-download"></i> Update Code</button>
|
|
<button class="btn btn-info" onclick="systemAction('migrate_config')"><i class="fas fa-sync-alt"></i> Migrate Config</button>
|
|
<button class="btn btn-danger" onclick="systemAction('reboot_system')"><i class="fas fa-power-off"></i> Reboot</button>
|
|
<button class="btn btn-secondary" onclick="stopOnDemand()"><i class="fas fa-ban"></i> Stop On-Demand</button>
|
|
<span id="ondemand-status" style="margin-left:auto; font-size:12px; color:#333; background:#f3f3f3; padding:6px 10px; border-radius:8px;">On-Demand: None</span>
|
|
</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><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">
|
|
<button class="btn btn-warning" onclick="toggleEditorMode()">
|
|
<i class="fas fa-edit"></i>
|
|
{{ 'Exit Editor' if editor_mode else 'Enter Editor' }}
|
|
</button>
|
|
<button class="btn btn-primary" onclick="takeScreenshot()">
|
|
<i class="fas fa-camera"></i> Screenshot
|
|
</button>
|
|
<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 class="control-panel">
|
|
<div class="tabs">
|
|
<button class="tab-btn active" onclick="showTab('overview')">
|
|
<i class="fas fa-tachometer-alt"></i> Overview
|
|
</button>
|
|
<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('weather')">
|
|
<i class="fas fa-cloud-sun"></i> Weather
|
|
</button>
|
|
<button class="tab-btn" onclick="showTab('stocks')">
|
|
<i class="fas fa-chart-line"></i> Stocks
|
|
</button>
|
|
<button class="tab-btn" onclick="showTab('stocknews')">
|
|
<i class="fas fa-newspaper"></i> Stock News
|
|
</button>
|
|
<button class="tab-btn" onclick="showTab('odds')">
|
|
<i class="fas fa-ticket-alt"></i> Odds Ticker
|
|
</button>
|
|
<button class="tab-btn" onclick="showTab('leaderboard')">
|
|
<i class="fas fa-trophy"></i> Leaderboard
|
|
</button>
|
|
<button class="tab-btn" onclick="showTab('text')">
|
|
<i class="fas fa-font"></i> Text
|
|
</button>
|
|
<button class="tab-btn" onclick="showTab('features')">
|
|
<i class="fas fa-star"></i> Features
|
|
</button>
|
|
<button class="tab-btn" onclick="showTab('of_the_day')">
|
|
<i class="fas fa-calendar-day"></i> Of The Day
|
|
</button>
|
|
<button class="tab-btn" onclick="showTab('music')">
|
|
<i class="fas fa-music"></i> Music
|
|
</button>
|
|
<button class="tab-btn" onclick="showTab('calendar')">
|
|
<i class="fas fa-calendar-alt"></i> Calendar
|
|
</button>
|
|
<button class="tab-btn" onclick="showTab('youtube')">
|
|
<i class="fab fa-youtube"></i> YouTube
|
|
</button>
|
|
<button class="tab-btn" onclick="showTab('news')">
|
|
<i class="fas fa-newspaper"></i> News
|
|
</button>
|
|
<button class="tab-btn" onclick="showTab('secrets')">
|
|
<i class="fas fa-key"></i> API Keys
|
|
</button>
|
|
<button class="tab-btn" onclick="showTab('editor')">
|
|
<i class="fas fa-paint-brush"></i> Editor
|
|
</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>
|
|
|
|
<!-- Overview Tab -->
|
|
<div id="overview" class="tab-content active">
|
|
<h3>System Overview</h3>
|
|
<div class="stats-grid">
|
|
<div class="stat-card">
|
|
<div class="stat-value">{{ system_status.cpu_percent if system_status and system_status.cpu_percent is defined else 0 }}%</div>
|
|
<div class="stat-label">CPU Usage</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-value">{{ system_status.memory_used_percent if system_status and system_status.memory_used_percent is defined else 0 }}%</div>
|
|
<div class="stat-label">Memory Usage</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-value">{{ system_status.cpu_temp if system_status and system_status.cpu_temp is defined else 0 }}°C</div>
|
|
<div class="stat-label">CPU Temperature</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-value">{{ main_config.get('display', {}).get('hardware', {}).get('brightness', 0) }}</div>
|
|
<div class="stat-label">Brightness</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-value">{{ main_config.get('display', {}).get('hardware', {}).get('cols', 0) }}x{{ main_config.get('display', {}).get('hardware', {}).get('rows', 0) }}</div>
|
|
<div class="stat-label">Resolution</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-value">{{ system_status.disk_used_percent if system_status and system_status.disk_used_percent is defined else 0 }}%</div>
|
|
<div class="stat-label">Disk Usage</div>
|
|
</div>
|
|
</div>
|
|
|
|
<h4>API Calls (24h window)</h4>
|
|
<div id="api-metrics" class="stat-card" style="text-align:left;">
|
|
<div>Loading API metrics...</div>
|
|
<div style="margin-top:10px; font-size:12px; color:#666;">If empty, ensure the server is running and /api/metrics is reachable.</div>
|
|
<div style="margin-top:10px;">
|
|
<button class="btn btn-primary" onclick="updateApiMetrics()"><i class="fas fa-sync"></i> Refresh API Metrics</button>
|
|
</div>
|
|
</div>
|
|
|
|
<h4>Quick Actions</h4>
|
|
<div class="display-controls">
|
|
<button class="btn btn-primary" onclick="systemAction('restart_service')">
|
|
<i class="fas fa-redo"></i> Restart Service
|
|
</button>
|
|
<button class="btn btn-warning" onclick="systemAction('git_pull')">
|
|
<i class="fas fa-download"></i> Update Code
|
|
</button>
|
|
<button class="btn btn-info" onclick="systemAction('migrate_config')">
|
|
<i class="fas fa-sync-alt"></i> Migrate Config
|
|
</button>
|
|
<button class="btn btn-danger" onclick="systemAction('reboot_system')">
|
|
<i class="fas fa-power-off"></i> Reboot System
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- General Tab -->
|
|
<div id="general" class="tab-content">
|
|
<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>
|
|
|
|
<!-- Weather Tab -->
|
|
<div id="weather" class="tab-content">
|
|
<div class="config-section">
|
|
<div style="display:flex; justify-content: space-between; align-items:center;">
|
|
<h3>Weather Configuration</h3>
|
|
<div style="display:flex; gap:8px;">
|
|
<button type="button" class="btn btn-info" onclick="startOnDemand('weather_current')"><i class="fas fa-bolt"></i> On-Demand Current</button>
|
|
<button type="button" class="btn btn-info" onclick="startOnDemand('weather_hourly')"><i class="fas fa-bolt"></i> Hourly</button>
|
|
<button type="button" class="btn btn-info" onclick="startOnDemand('weather_daily')"><i class="fas fa-bolt"></i> Daily</button>
|
|
</div>
|
|
</div>
|
|
<form id="weather-form">
|
|
<div class="form-group">
|
|
<label>
|
|
<input type="checkbox" id="weather_enabled" name="weather_enabled" {% if safe_config_get(main_config, 'weather', 'enabled', default=False) %}checked{% endif %}>
|
|
Enable Weather
|
|
</label>
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="weather_city">City:</label>
|
|
<input type="text" class="form-control" id="weather_city" name="weather_city" value="{{ safe_config_get(main_config, 'location', 'city', default='Dallas') }}">
|
|
<div class="description">City name for weather data</div>
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="weather_state">State:</label>
|
|
<input type="text" class="form-control" id="weather_state" name="weather_state" value="{{ safe_config_get(main_config, 'location', 'state', default='Texas') }}">
|
|
<div class="description">State/province name</div>
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="weather_units">Units:</label>
|
|
<select class="form-control" id="weather_units" name="weather_units">
|
|
<option value="imperial" {% if safe_config_get(main_config, 'weather', 'units', default='imperial') == "imperial" %}selected{% endif %}>Fahrenheit</option>
|
|
<option value="metric" {% if safe_config_get(main_config, 'weather', 'units', default='imperial') == "metric" %}selected{% endif %}>Celsius</option>
|
|
</select>
|
|
<div class="description">Temperature units</div>
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="weather_display_format">Display Format</label>
|
|
<textarea id="weather_display_format" class="form-control" rows="2">{{ safe_config_get(main_config, 'weather', 'display_format', default='{temp}°F\n{condition}') }}</textarea>
|
|
<div class="description">Use tokens like {temp}, {condition}. Supports new lines.</div>
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="weather_update_interval">Update Interval (seconds):</label>
|
|
<input type="number" class="form-control" id="weather_update_interval" name="weather_update_interval" value="{{ safe_config_get(main_config, 'weather', 'update_interval', default=1800) }}" min="300" max="3600">
|
|
<div class="description">How often to update weather data (300-3600 seconds)</div>
|
|
</div>
|
|
<button type="submit" class="btn btn-success">Save Weather Settings</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Stocks Tab -->
|
|
<div id="stocks" class="tab-content">
|
|
<div class="config-section">
|
|
<div style="display:flex; justify-content: space-between; align-items:center;">
|
|
<h3>Stocks & Crypto Configuration</h3>
|
|
<div style="display:flex; gap:8px;">
|
|
<button type="button" class="btn btn-info" onclick="startOnDemand('stocks')"><i class="fas fa-bolt"></i> On-Demand Stocks</button>
|
|
</div>
|
|
</div>
|
|
<form id="stocks-form">
|
|
<div class="form-group">
|
|
<label>
|
|
<input type="checkbox" id="stocks_enabled" name="stocks_enabled" {% if safe_config_get(main_config, 'stocks', 'enabled', default=False) %}checked{% endif %}>
|
|
Enable Stocks
|
|
</label>
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="stocks_symbols">Stock Symbols:</label>
|
|
<input type="text" class="form-control" id="stocks_symbols" name="stocks_symbols" value="{{ safe_config_get(main_config, 'stocks', 'symbols', default=['ASTS', 'SCHD', 'INTC', 'NVDA', 'T', 'VOO', 'SMCI'])|join(', ') }}" placeholder="AAPL, GOOGL, MSFT">
|
|
<div class="description">Comma-separated stock symbols</div>
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="stocks_update_interval">Update Interval (seconds):</label>
|
|
<input type="number" class="form-control" id="stocks_update_interval" name="stocks_update_interval" value="{{ safe_config_get(main_config, 'stocks', 'update_interval', default=600) }}" min="60" max="3600">
|
|
<div class="description">How often to update stock data</div>
|
|
</div>
|
|
<div class="form-row">
|
|
<div class="form-group">
|
|
<label for="stocks_scroll_speed">Scroll Speed</label>
|
|
<input type="number" step="0.1" class="form-control" id="stocks_scroll_speed" value="{{ safe_config_get(main_config, 'stocks', 'scroll_speed', default=1) }}">
|
|
<div class="description">Horizontal scroll pixels per step.</div>
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="stocks_scroll_delay">Scroll Delay (seconds)</label>
|
|
<input type="number" step="0.001" class="form-control" id="stocks_scroll_delay" value="{{ safe_config_get(main_config, 'stocks', 'scroll_delay', default=0.01) }}">
|
|
<div class="description">Delay between scroll steps.</div>
|
|
</div>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>
|
|
<input type="checkbox" id="stocks_toggle_chart" name="stocks_toggle_chart" {% if safe_config_get(main_config, 'stocks', 'toggle_chart', default=True) %}checked{% endif %}>
|
|
Show Charts
|
|
</label>
|
|
<div class="description">Display mini charts alongside stock ticker data</div>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>
|
|
<input type="checkbox" id="stocks_dynamic_duration" {% if safe_config_get(main_config, 'stocks', 'dynamic_duration', default=True) %}checked{% endif %}>
|
|
Dynamic Duration
|
|
</label>
|
|
<div class="description">Adjust display duration based on content length.</div>
|
|
</div>
|
|
<div class="form-row">
|
|
<div class="form-group">
|
|
<label for="stocks_min_duration">Min Duration (sec)</label>
|
|
<input type="number" class="form-control" id="stocks_min_duration" value="{{ safe_config_get(main_config, 'stocks', 'min_duration', default=30) }}">
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="stocks_max_duration">Max Duration (sec)</label>
|
|
<input type="number" class="form-control" id="stocks_max_duration" value="{{ safe_config_get(main_config, 'stocks', 'max_duration', default=300) }}">
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="stocks_duration_buffer">Duration Buffer</label>
|
|
<input type="number" step="0.01" class="form-control" id="stocks_duration_buffer" value="{{ safe_config_get(main_config, 'stocks', 'duration_buffer', default=0.1) }}">
|
|
</div>
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="stocks_display_format">Display Format</label>
|
|
<input type="text" class="form-control" id="stocks_display_format" value="{{ safe_config_get(main_config, 'stocks', 'display_format', default='{symbol}: ${price} ({change}%)') }}">
|
|
<div class="description">Use tokens like {symbol}, {price}, {change}.</div>
|
|
</div>
|
|
<button type="submit" class="btn btn-success">Save Stocks Settings</button>
|
|
</form>
|
|
|
|
<h3>Cryptocurrency</h3>
|
|
<form id="crypto-form">
|
|
<div class="form-group">
|
|
<label>
|
|
<input type="checkbox" id="crypto_enabled" name="crypto_enabled" {% if safe_config_get(main_config, 'crypto', 'enabled', default=False) %}checked{% endif %}>
|
|
Enable Crypto
|
|
</label>
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="crypto_symbols">Crypto Symbols:</label>
|
|
<input type="text" class="form-control" id="crypto_symbols" name="crypto_symbols" value="{{ safe_config_get(main_config, 'crypto', 'symbols', default=['BTC-USD', 'ETH-USD'])|join(', ') }}" placeholder="BTC-USD, ETH-USD">
|
|
<div class="description">Comma-separated crypto symbols (e.g., BTC-USD, ETH-USD)</div>
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="crypto_update_interval">Update Interval (seconds):</label>
|
|
<input type="number" class="form-control" id="crypto_update_interval" name="crypto_update_interval" value="{{ safe_config_get(main_config, 'crypto', 'update_interval', default=600) }}" min="60" max="3600">
|
|
<div class="description">How often to update crypto data</div>
|
|
</div>
|
|
<button type="submit" class="btn btn-success">Save Crypto Settings</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Stock News Tab -->
|
|
<div id="stocknews" class="tab-content">
|
|
<div class="config-section">
|
|
<div style="display:flex; justify-content: space-between; align-items:center;">
|
|
<h3>Stock News</h3>
|
|
<div style="display:flex; gap:8px;">
|
|
<button type="button" class="btn btn-info" onclick="startOnDemand('stock_news')"><i class="fas fa-bolt"></i> On-Demand News</button>
|
|
</div>
|
|
</div>
|
|
<form id="stocknews-form">
|
|
<div class="form-group">
|
|
<label>
|
|
<input type="checkbox" id="stocknews_enabled" {% if safe_config_get(main_config, 'stock_news', 'enabled', default=False) %}checked{% endif %}>
|
|
Enable Stock News
|
|
</label>
|
|
</div>
|
|
<div class="form-row">
|
|
<div class="form-group">
|
|
<label for="stocknews_update_interval">Update Interval (sec)</label>
|
|
<input type="number" class="form-control" id="stocknews_update_interval" value="{{ safe_config_get(main_config, 'stock_news', 'update_interval', default=3600) }}">
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="stocknews_scroll_speed">Scroll Speed</label>
|
|
<input type="number" step="0.1" class="form-control" id="stocknews_scroll_speed" value="{{ safe_config_get(main_config, 'stock_news', 'scroll_speed', default=1) }}">
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="stocknews_scroll_delay">Scroll Delay (sec)</label>
|
|
<input type="number" step="0.001" class="form-control" id="stocknews_scroll_delay" value="{{ safe_config_get(main_config, 'stock_news', 'scroll_delay', default=0.01) }}">
|
|
</div>
|
|
</div>
|
|
<div class="form-row">
|
|
<div class="form-group">
|
|
<label for="stocknews_max_headlines_per_symbol">Max Headlines per Symbol</label>
|
|
<input type="number" class="form-control" id="stocknews_max_headlines_per_symbol" value="{{ safe_config_get(main_config, 'stock_news', 'max_headlines_per_symbol', default=1) }}">
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="stocknews_headlines_per_rotation">Headlines per Rotation</label>
|
|
<input type="number" class="form-control" id="stocknews_headlines_per_rotation" value="{{ safe_config_get(main_config, 'stock_news', 'headlines_per_rotation', default=2) }}">
|
|
</div>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>
|
|
<input type="checkbox" id="stocknews_dynamic_duration" {% if safe_config_get(main_config, 'stock_news', 'dynamic_duration', default=True) %}checked{% endif %}>
|
|
Dynamic Duration
|
|
</label>
|
|
</div>
|
|
<div class="form-row">
|
|
<div class="form-group"><label for="stocknews_min_duration">Min Duration</label><input type="number" class="form-control" id="stocknews_min_duration" value="{{ safe_config_get(main_config, 'stock_news', 'min_duration', default=30) }}"></div>
|
|
<div class="form-group"><label for="stocknews_max_duration">Max Duration</label><input type="number" class="form-control" id="stocknews_max_duration" value="{{ safe_config_get(main_config, 'stock_news', 'max_duration', default=300) }}"></div>
|
|
<div class="form-group"><label for="stocknews_duration_buffer">Duration Buffer</label><input type="number" step="0.01" class="form-control" id="stocknews_duration_buffer" value="{{ safe_config_get(main_config, 'stock_news', 'duration_buffer', default=0.1) }}"></div>
|
|
</div>
|
|
<button type="submit" class="btn btn-success">Save Stock News</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Odds Ticker Tab -->
|
|
<div id="odds" class="tab-content">
|
|
<div class="config-section">
|
|
<div style="display:flex; justify-content: space-between; align-items:center;">
|
|
<h3>Odds Ticker</h3>
|
|
<div style="display:flex; gap:8px;">
|
|
<button type="button" class="btn btn-info" onclick="startOnDemand('odds_ticker')"><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="odds-form">
|
|
<div class="form-group">
|
|
<label>
|
|
<input type="checkbox" id="odds_enabled" {% if safe_config_get(main_config, 'odds_ticker', 'enabled', default=True) %}checked{% endif %}>
|
|
Enable Odds Ticker
|
|
</label>
|
|
</div>
|
|
<div class="form-row">
|
|
<div class="form-group"><label for="odds_update_interval">Update Interval (sec)</label><input type="number" class="form-control" id="odds_update_interval" value="{{ safe_config_get(main_config, 'odds_ticker', 'update_interval', default=3600) }}"></div>
|
|
<div class="form-group"><label for="odds_scroll_speed">Scroll Speed</label><input type="number" step="0.1" class="form-control" id="odds_scroll_speed" value="{{ safe_config_get(main_config, 'odds_ticker', 'scroll_speed', default=1) }}"></div>
|
|
<div class="form-group"><label for="odds_scroll_delay">Scroll Delay (sec)</label><input type="number" step="0.001" class="form-control" id="odds_scroll_delay" value="{{ safe_config_get(main_config, 'odds_ticker', 'scroll_delay', default=0.01) }}"></div>
|
|
</div>
|
|
<div class="form-row">
|
|
<div class="form-group"><label for="odds_games_per_favorite_team">Games per Favorite Team</label><input type="number" class="form-control" id="odds_games_per_favorite_team" value="{{ safe_config_get(main_config, 'odds_ticker', 'games_per_favorite_team', default=1) }}"></div>
|
|
<div class="form-group"><label for="odds_max_games_per_league">Max Games per League</label><input type="number" class="form-control" id="odds_max_games_per_league" value="{{ safe_config_get(main_config, 'odds_ticker', 'max_games_per_league', default=5) }}"></div>
|
|
<div class="form-group"><label for="odds_future_fetch_days">Future Fetch Days</label><input type="number" class="form-control" id="odds_future_fetch_days" value="{{ safe_config_get(main_config, 'odds_ticker', 'future_fetch_days', default=50) }}"></div>
|
|
</div>
|
|
<div class="form-row">
|
|
<div class="form-group">
|
|
<label for="odds_enabled_leagues">Enabled Leagues</label>
|
|
<input type="text" class="form-control" id="odds_enabled_leagues" value="{{ safe_config_get(main_config, 'odds_ticker', 'enabled_leagues', default=['nfl', 'mlb', 'ncaa_fb', 'milb']) | join(', ') }}">
|
|
<div class="description">Comma-separated list, e.g., nfl, mlb, ncaa_fb, milb</div>
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="odds_sort_order">Sort Order</label>
|
|
<select id="odds_sort_order" class="form-control">
|
|
<option value="soonest" {% if safe_config_get(main_config, 'odds_ticker', 'sort_order', default='soonest') == 'soonest' %}selected{% endif %}>Soonest</option>
|
|
<option value="league" {% if safe_config_get(main_config, 'odds_ticker', 'sort_order', default='soonest') == 'league' %}selected{% endif %}>By League</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div class="form-row">
|
|
<div class="form-group"><label><input type="checkbox" id="odds_show_favorite_teams_only" {% if safe_config_get(main_config, 'odds_ticker', 'show_favorite_teams_only', default=True) %}checked{% endif %}> Show Favorite Teams Only</label></div>
|
|
<div class="form-group"><label><input type="checkbox" id="odds_show_odds_only" {% if safe_config_get(main_config, 'odds_ticker', 'show_odds_only', default=False) %}checked{% endif %}> Show Odds Only</label></div>
|
|
<div class="form-group"><label><input type="checkbox" id="odds_loop" {% if safe_config_get(main_config, 'odds_ticker', 'loop', default=True) %}checked{% endif %}> Loop</label></div>
|
|
<div class="form-group"><label><input type="checkbox" id="odds_show_channel_logos" {% if safe_config_get(main_config, 'odds_ticker', 'show_channel_logos', default=True) %}checked{% endif %}> Show Channel Logos</label></div>
|
|
</div>
|
|
<div class="form-group"><label><input type="checkbox" id="odds_dynamic_duration" {% if safe_config_get(main_config, 'odds_ticker', 'dynamic_duration', default=True) %}checked{% endif %}> Dynamic Duration</label></div>
|
|
<div class="form-row">
|
|
<div class="form-group"><label for="odds_min_duration">Min Duration</label><input type="number" class="form-control" id="odds_min_duration" value="{{ safe_config_get(main_config, 'odds_ticker', 'min_duration', default=30) }}"></div>
|
|
<div class="form-group"><label for="odds_max_duration">Max Duration</label><input type="number" class="form-control" id="odds_max_duration" value="{{ safe_config_get(main_config, 'odds_ticker', 'max_duration', default=300) }}"></div>
|
|
<div class="form-group"><label for="odds_duration_buffer">Duration Buffer</label><input type="number" step="0.01" class="form-control" id="odds_duration_buffer" value="{{ safe_config_get(main_config, 'odds_ticker', 'duration_buffer', default=0.1) }}"></div>
|
|
</div>
|
|
<button type="submit" class="btn btn-success">Save Odds Settings</button>
|
|
</form>
|
|
</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', 'nfl', 'enabled', default=False) %}checked{% endif %}>
|
|
NFL
|
|
</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>
|
|
<div class="form-group">
|
|
<label>
|
|
<input type="checkbox" id="leaderboard_nba_enabled" {% if safe_config_get(main_config, 'leaderboard', 'enabled_sports', 'nba', 'enabled', default=False) %}checked{% endif %}>
|
|
NBA
|
|
</label>
|
|
<div class="form-group" style="margin-left: 20px;">
|
|
<label for="leaderboard_nba_top_teams">Top Teams</label>
|
|
<input type="number" class="form-control" id="leaderboard_nba_top_teams" value="{{ safe_config_get(main_config, 'leaderboard', 'enabled_sports', 'nba', 'top_teams', default=10) }}" min="1" max="30">
|
|
</div>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>
|
|
<input type="checkbox" id="leaderboard_mlb_enabled" {% if safe_config_get(main_config, 'leaderboard', 'enabled_sports', 'mlb', 'enabled', default=False) %}checked{% endif %}>
|
|
MLB
|
|
</label>
|
|
<div class="form-group" style="margin-left: 20px;">
|
|
<label for="leaderboard_mlb_top_teams">Top Teams</label>
|
|
<input type="number" class="form-control" id="leaderboard_mlb_top_teams" value="{{ safe_config_get(main_config, 'leaderboard', 'enabled_sports', 'mlb', 'top_teams', default=10) }}" min="1" max="30">
|
|
</div>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>
|
|
<input type="checkbox" id="leaderboard_ncaa_fb_enabled" {% if safe_config_get(main_config, 'leaderboard', 'enabled_sports', 'ncaa_fb', 'enabled', default=False) %}checked{% endif %}>
|
|
NCAA Football
|
|
</label>
|
|
<div class="form-group" style="margin-left: 20px;">
|
|
<label for="leaderboard_ncaa_fb_top_teams">Top Teams</label>
|
|
<input type="number" class="form-control" id="leaderboard_ncaa_fb_top_teams" value="{{ safe_config_get(main_config, 'leaderboard', 'enabled_sports', 'ncaa_fb', 'top_teams', default=10) }}" min="1" max="25">
|
|
</div>
|
|
<div class="form-group" style="margin-left: 20px;">
|
|
<label>
|
|
<input type="checkbox" id="leaderboard_ncaa_fb_show_ranking" {% if safe_config_get(main_config, 'leaderboard', 'enabled_sports', 'ncaa_fb', 'show_ranking', default=True) %}checked{% endif %}>
|
|
Show Ranking
|
|
</label>
|
|
</div>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>
|
|
<input type="checkbox" id="leaderboard_nhl_enabled" {% if safe_config_get(main_config, 'leaderboard', 'enabled_sports', 'nhl', 'enabled', default=False) %}checked{% endif %}>
|
|
NHL
|
|
</label>
|
|
<div class="form-group" style="margin-left: 20px;">
|
|
<label for="leaderboard_nhl_top_teams">Top Teams</label>
|
|
<input type="number" class="form-control" id="leaderboard_nhl_top_teams" value="{{ safe_config_get(main_config, 'leaderboard', 'enabled_sports', 'nhl', 'top_teams', default=10) }}" min="1" max="32">
|
|
</div>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>
|
|
<input type="checkbox" id="leaderboard_ncaam_basketball_enabled" {% if safe_config_get(main_config, 'leaderboard', 'enabled_sports', 'ncaam_basketball', 'enabled', default=False) %}checked{% endif %}>
|
|
NCAA Men's Basketball
|
|
</label>
|
|
<div class="form-group" style="margin-left: 20px;">
|
|
<label for="leaderboard_ncaam_basketball_top_teams">Top Teams</label>
|
|
<input type="number" class="form-control" id="leaderboard_ncaam_basketball_top_teams" value="{{ safe_config_get(main_config, 'leaderboard', 'enabled_sports', 'ncaam_basketball', 'top_teams', default=10) }}" min="1" max="25">
|
|
</div>
|
|
</div>
|
|
|
|
<button type="submit" class="btn btn-success">Save Leaderboard Settings</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Text Display Tab -->
|
|
<div id="text" class="tab-content">
|
|
<div class="config-section">
|
|
<div style="display:flex; justify-content: space-between; align-items:center;">
|
|
<h3>Text Display</h3>
|
|
<div style="display:flex; gap:8px;">
|
|
<button type="button" class="btn btn-info" onclick="startOnDemand('text_display')"><i class="fas fa-bolt"></i> On-Demand</button>
|
|
</div>
|
|
</div>
|
|
<form id="text-form">
|
|
<div class="form-group"><label><input type="checkbox" id="text_enabled" {% if safe_config_get(main_config, 'text_display', 'enabled', default=False) %}checked{% endif %}> Enable</label></div>
|
|
<div class="form-group"><label for="text_text">Text</label><input type="text" id="text_text" class="form-control" value="{{ safe_config_get(main_config, 'text_display', 'text', default='Subscribe to ChuckBuilds') }}"></div>
|
|
<div class="form-group"><label for="text_font_path">Font Path</label><input type="text" id="text_font_path" class="form-control" value="{{ safe_config_get(main_config, 'text_display', 'font_path', default='assets/fonts/press-start-2p.ttf') }}"></div>
|
|
<div class="form-row">
|
|
<div class="form-group"><label for="text_font_size">Font Size</label><input type="number" id="text_font_size" class="form-control" value="{{ safe_config_get(main_config, 'text_display', 'font_size', default=8) }}"></div>
|
|
<div class="form-group"><label><input type="checkbox" id="text_scroll" {% if safe_config_get(main_config, 'text_display', 'scroll', default=True) %}checked{% endif %}> Scroll</label></div>
|
|
<div class="form-group"><label for="text_scroll_speed">Scroll Speed</label><input type="number" id="text_scroll_speed" class="form-control" value="{{ safe_config_get(main_config, 'text_display', 'scroll_speed', default=40) }}"></div>
|
|
<div class="form-group"><label for="text_scroll_gap_width">Scroll Gap Width</label><input type="number" id="text_scroll_gap_width" class="form-control" value="{{ safe_config_get(main_config, 'text_display', 'scroll_gap_width', default=32) }}"></div>
|
|
</div>
|
|
<div class="form-row">
|
|
<div class="form-group"><label for="text_text_color">Text Color</label><input type="color" id="text_text_color" class="form-control" data-rgb='{{ safe_config_get(main_config, 'text_display', 'text_color', default=[255, 0, 0]) | tojson }}'></div>
|
|
<div class="form-group"><label for="text_background_color">Background Color</label><input type="color" id="text_background_color" class="form-control" data-rgb='{{ safe_config_get(main_config, 'text_display', 'background_color', default=[0, 0, 0]) | tojson }}'></div>
|
|
</div>
|
|
<button type="submit" class="btn btn-success">Save Text Settings</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Features Tab -->
|
|
<div id="features" class="tab-content">
|
|
<div class="config-section">
|
|
<h3>Additional Features</h3>
|
|
<p>Configure additional features like clock, text display, and more.</p>
|
|
<!-- Features configuration will be populated by JavaScript -->
|
|
<div id="features-config">
|
|
Loading features configuration...
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Of The Day Tab -->
|
|
<div id="of_the_day" class="tab-content">
|
|
<div class="config-section">
|
|
<div style="display:flex; justify-content: space-between; align-items:center;">
|
|
<h3>Of The Day Configuration</h3>
|
|
<div style="display:flex; gap:8px;">
|
|
<button type="button" class="btn btn-info" onclick="startOnDemand('of_the_day')"><i class="fas fa-bolt"></i> On-Demand</button>
|
|
</div>
|
|
</div>
|
|
<form id="of_the_day-form">
|
|
<div class="form-group">
|
|
<label>
|
|
<input type="checkbox" id="of_the_day_enabled" {% if safe_config_get(main_config, 'of_the_day', 'enabled', default=False) %}checked{% endif %}>
|
|
Enable Of The Day
|
|
</label>
|
|
</div>
|
|
<div class="form-row">
|
|
<div class="form-group">
|
|
<label for="of_the_day_update_interval">Update Interval (sec)</label>
|
|
<input type="number" class="form-control" id="of_the_day_update_interval" value="{{ safe_config_get(main_config, 'of_the_day', 'update_interval', default=3600) }}">
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="of_the_day_display_rotate_interval">Display Rotate Interval (sec)</label>
|
|
<input type="number" class="form-control" id="of_the_day_display_rotate_interval" value="{{ safe_config_get(main_config, 'of_the_day', 'display_rotate_interval', default=20) }}">
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="of_the_day_subtitle_rotate_interval">Subtitle Rotate Interval (sec)</label>
|
|
<input type="number" class="form-control" id="of_the_day_subtitle_rotate_interval" value="{{ safe_config_get(main_config, 'of_the_day', 'subtitle_rotate_interval', default=10) }}">
|
|
</div>
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="of_the_day_category_order">Category Order</label>
|
|
<input type="text" class="form-control" id="of_the_day_category_order" value="{{ safe_config_get(main_config, 'of_the_day', 'category_order', default=['word_of_the_day', 'slovenian_word_of_the_day']) | join(', ') }}" placeholder="word_of_the_day, slovenian_word_of_the_day">
|
|
<div class="description">Comma-separated list of category keys in display order</div>
|
|
</div>
|
|
|
|
<h4>Categories</h4>
|
|
<div class="form-group">
|
|
<h5>Word of the Day</h5>
|
|
<div class="form-group" style="margin-left: 20px;">
|
|
<label>
|
|
<input type="checkbox" id="of_the_day_word_enabled" {% if safe_config_get(main_config, 'of_the_day', 'categories', 'word_of_the_day', 'enabled', default=True) %}checked{% endif %}>
|
|
Enable Word of the Day
|
|
</label>
|
|
</div>
|
|
<div class="form-group" style="margin-left: 20px;">
|
|
<label for="of_the_day_word_data_file">Data File</label>
|
|
<input type="text" class="form-control" id="of_the_day_word_data_file" value="{{ safe_config_get(main_config, 'of_the_day', 'categories', 'word_of_the_day', 'data_file', default='of_the_day/word_of_the_day.json') }}">
|
|
</div>
|
|
<div class="form-group" style="margin-left: 20px;">
|
|
<label for="of_the_day_word_display_name">Display Name</label>
|
|
<input type="text" class="form-control" id="of_the_day_word_display_name" value="{{ safe_config_get(main_config, 'of_the_day', 'categories', 'word_of_the_day', 'display_name', default='Word of the Day') }}">
|
|
</div>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<h5>Slovenian Word of the Day</h5>
|
|
<div class="form-group" style="margin-left: 20px;">
|
|
<label>
|
|
<input type="checkbox" id="of_the_day_slovenian_enabled" {% if safe_config_get(main_config, 'of_the_day', 'categories', 'slovenian_word_of_the_day', 'enabled', default=True) %}checked{% endif %}>
|
|
Enable Slovenian Word of the Day
|
|
</label>
|
|
</div>
|
|
<div class="form-group" style="margin-left: 20px;">
|
|
<label for="of_the_day_slovenian_data_file">Data File</label>
|
|
<input type="text" class="form-control" id="of_the_day_slovenian_data_file" value="{{ safe_config_get(main_config, 'of_the_day', 'categories', 'slovenian_word_of_the_day', 'data_file', default='of_the_day/slovenian_word_of_the_day.json') }}">
|
|
</div>
|
|
<div class="form-group" style="margin-left: 20px;">
|
|
<label for="of_the_day_slovenian_display_name">Display Name</label>
|
|
<input type="text" class="form-control" id="of_the_day_slovenian_display_name" value="{{ safe_config_get(main_config, 'of_the_day', 'categories', 'slovenian_word_of_the_day', 'display_name', default='Slovenian Word of the Day') }}">
|
|
</div>
|
|
</div>
|
|
|
|
<button type="submit" class="btn btn-success">Save Of The Day Settings</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Music Tab -->
|
|
<div id="music" class="tab-content">
|
|
<div class="config-section">
|
|
<h3>Music Configuration</h3>
|
|
<form id="music-form">
|
|
<div class="form-group">
|
|
<label>
|
|
<input type="checkbox" id="music_enabled" name="music_enabled" {% if safe_config_get(main_config, 'music', 'enabled', default=False) %}checked{% endif %}>
|
|
Enable Music Display
|
|
</label>
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="music_preferred_source">Preferred Source:</label>
|
|
<select class="form-control" id="music_preferred_source" name="music_preferred_source">
|
|
<option value="ytm" {% if safe_config_get(main_config, 'music', 'preferred_source', default='ytm') == "ytm" %}selected{% endif %}>YouTube Music</option>
|
|
<option value="spotify" {% if safe_config_get(main_config, 'music', 'preferred_source', default='ytm') == "spotify" %}selected{% endif %}>Spotify</option>
|
|
</select>
|
|
<div class="description">Primary music source to display</div>
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="ytm_companion_url">YouTube Music Companion URL:</label>
|
|
<input type="text" class="form-control" id="ytm_companion_url" name="ytm_companion_url" value="{{ safe_config_get(main_config, 'music', 'YTM_COMPANION_URL', default='http://192.168.86.12:9863') }}">
|
|
<div class="description">URL for YouTube Music companion app</div>
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="music_polling_interval">Polling Interval (seconds):</label>
|
|
<input type="number" class="form-control" id="music_polling_interval" name="music_polling_interval" value="{{ safe_config_get(main_config, 'music', 'POLLING_INTERVAL_SECONDS', default=1) }}" min="1" max="60">
|
|
<div class="description">How often to check for music updates</div>
|
|
</div>
|
|
<button type="submit" class="btn btn-success">Save Music Settings</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- YouTube Tab -->
|
|
<div id="youtube" class="tab-content">
|
|
<div class="config-section">
|
|
<div style="display:flex; justify-content: space-between; align-items:center;">
|
|
<h3>YouTube</h3>
|
|
<div style="display:flex; gap:8px;">
|
|
<button type="button" class="btn btn-info" onclick="startOnDemand('youtube')"><i class="fas fa-bolt"></i> On-Demand</button>
|
|
</div>
|
|
</div>
|
|
<form id="youtube-form">
|
|
<div class="form-group"><label><input type="checkbox" id="youtube_enabled" {% if safe_config_get(main_config, 'youtube', 'enabled', default=False) %}checked{% endif %}> Enable YouTube</label></div>
|
|
<div class="form-group"><label for="youtube_update_interval">Update Interval (sec)</label><input type="number" id="youtube_update_interval" class="form-control" value="{{ safe_config_get(main_config, 'youtube', 'update_interval', default=3600) }}"></div>
|
|
<button type="submit" class="btn btn-success">Save YouTube Settings</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Calendar Tab -->
|
|
<div id="calendar" class="tab-content">
|
|
<div class="config-section">
|
|
<div style="display:flex; justify-content: space-between; align-items:center;">
|
|
<h3>Calendar Configuration</h3>
|
|
<div style="display:flex; gap:8px;">
|
|
<button type="button" class="btn btn-info" onclick="startOnDemand('calendar')"><i class="fas fa-bolt"></i> On-Demand</button>
|
|
</div>
|
|
</div>
|
|
<form id="calendar-form">
|
|
<div class="form-group">
|
|
<label>
|
|
<input type="checkbox" id="calendar_enabled" name="calendar_enabled" {% if safe_config_get(main_config, 'calendar', 'enabled', default=False) %}checked{% endif %}>
|
|
Enable Calendar
|
|
</label>
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="calendar_max_events">Max Events to Show:</label>
|
|
<input type="number" class="form-control" id="calendar_max_events" name="calendar_max_events" value="{{ safe_config_get(main_config, 'calendar', 'max_events', default=3) }}" min="1" max="10">
|
|
<div class="description">Maximum number of events to display</div>
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="calendar_update_interval">Update Interval (seconds):</label>
|
|
<input type="number" class="form-control" id="calendar_update_interval" name="calendar_update_interval" value="{{ safe_config_get(main_config, 'calendar', 'update_interval', default=3600) }}" min="300" max="3600">
|
|
<div class="description">How often to update calendar data</div>
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="calendar_calendars">Calendars:</label>
|
|
<input type="text" class="form-control" id="calendar_calendars" name="calendar_calendars" value="{{ safe_config_get(main_config, 'calendar', 'calendars', default=['birthdays'])|join(', ') }}" placeholder="birthdays, work">
|
|
<div class="description">Comma-separated calendar names</div>
|
|
</div>
|
|
<button type="submit" class="btn btn-success">Save Calendar Settings</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- News Tab -->
|
|
<div id="news" class="tab-content">
|
|
<div class="config-section">
|
|
<div style="display:flex; justify-content: space-between; align-items:center;">
|
|
<h3>News Manager Configuration</h3>
|
|
<div style="display:flex; gap:8px;">
|
|
<button type="button" class="btn btn-info" onclick="startOnDemand('news_manager')"><i class="fas fa-bolt"></i> On-Demand</button>
|
|
</div>
|
|
</div>
|
|
<p>Configure RSS news feeds and scrolling ticker settings</p>
|
|
|
|
<div class="form-group">
|
|
<label>
|
|
<input type="checkbox" id="news_enabled" name="news_enabled">
|
|
Enable News Manager
|
|
</label>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label for="headlines_per_feed">Headlines Per Feed:</label>
|
|
<input type="number" class="form-control" id="headlines_per_feed" name="headlines_per_feed" min="1" max="5" value="2">
|
|
<div class="description">Number of headlines to show from each enabled feed</div>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label>Available News Feeds:</label>
|
|
<div class="checkbox-grid" id="news_feeds_grid">
|
|
<!-- Feeds will be populated by JavaScript -->
|
|
</div>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<h4>Custom RSS Feeds</h4>
|
|
<div style="display: flex; gap: 10px; margin-bottom: 15px;">
|
|
<input type="text" class="form-control" id="custom_feed_name" placeholder="Feed Name" style="width: 200px;">
|
|
<input type="text" class="form-control" id="custom_feed_url" placeholder="RSS Feed URL" style="flex: 1;">
|
|
<button type="button" class="btn btn-primary" onclick="addCustomFeed()">Add Feed</button>
|
|
</div>
|
|
<div id="custom_feeds_list">
|
|
<!-- Custom feeds will be populated by JavaScript -->
|
|
</div>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label>
|
|
<input type="checkbox" id="rotation_enabled" name="rotation_enabled" checked>
|
|
Enable Headline Rotation
|
|
</label>
|
|
<div class="description">Rotate through different headlines to avoid repetition</div>
|
|
</div>
|
|
|
|
<div style="display: flex; gap: 10px;">
|
|
<button type="button" class="btn btn-success" onclick="saveNewsSettings()">Save News Settings</button>
|
|
<button type="button" class="btn btn-primary" onclick="refreshNewsStatus()">Refresh Status</button>
|
|
</div>
|
|
|
|
<div id="news_status" style="margin-top: 20px; padding: 15px; background: #f0f8f0; border-radius: var(--border-radius);">
|
|
<!-- Status will be populated by JavaScript -->
|
|
</div>
|
|
|
|
<h4 style="margin-top:20px;">Advanced Settings</h4>
|
|
<div class="form-row">
|
|
<div class="form-group"><label for="news_update_interval">Update Interval (sec)</label><input type="number" id="news_update_interval" class="form-control" value="{{ safe_config_get(main_config, 'news_manager', 'update_interval', default=300) }}"></div>
|
|
<div class="form-group"><label for="news_scroll_speed">Scroll Speed</label><input type="number" step="0.1" id="news_scroll_speed" class="form-control" value="{{ safe_config_get(main_config, 'news_manager', 'scroll_speed', default=1) }}"></div>
|
|
<div class="form-group"><label for="news_scroll_delay">Scroll Delay (sec)</label><input type="number" step="0.001" id="news_scroll_delay" class="form-control" value="{{ safe_config_get(main_config, 'news_manager', 'scroll_delay', default=0.01) }}"></div>
|
|
</div>
|
|
<div class="form-row">
|
|
<div class="form-group"><label for="news_rotation_threshold">Rotation Threshold</label><input type="number" id="news_rotation_threshold" class="form-control" value="{{ safe_config_get(main_config, 'news_manager', 'rotation_threshold', default=3) }}"></div>
|
|
<div class="form-group"><label><input type="checkbox" id="news_dynamic_duration" {% if safe_config_get(main_config, 'news_manager', 'dynamic_duration', default=True) %}checked{% endif %}> Dynamic Duration</label></div>
|
|
<div class="form-group"><label for="news_min_duration">Min Duration</label><input type="number" id="news_min_duration" class="form-control" value="{{ safe_config_get(main_config, 'news_manager', 'min_duration', default=30) }}"></div>
|
|
<div class="form-group"><label for="news_max_duration">Max Duration</label><input type="number" id="news_max_duration" class="form-control" value="{{ safe_config_get(main_config, 'news_manager', 'max_duration', default=300) }}"></div>
|
|
<div class="form-group"><label for="news_duration_buffer">Duration Buffer</label><input type="number" step="0.01" id="news_duration_buffer" class="form-control" value="{{ safe_config_get(main_config, 'news_manager', 'duration_buffer', default=0.1) }}"></div>
|
|
</div>
|
|
<div class="form-row">
|
|
<div class="form-group"><label for="news_font_size">Font Size</label><input type="number" id="news_font_size" class="form-control" value="{{ safe_config_get(main_config, 'news_manager', 'font_size', default=8) }}"></div>
|
|
<div class="form-group"><label for="news_font_path">Font Path</label><input type="text" id="news_font_path" class="form-control" value="{{ safe_config_get(main_config, 'news_manager', 'font_path', default='assets/fonts/PressStart2P-Regular.ttf') }}"></div>
|
|
</div>
|
|
<div class="form-row">
|
|
<div class="form-group"><label for="news_text_color">Text Color</label><input type="color" id="news_text_color" class="form-control" data-rgb='{{ safe_config_get(main_config, 'news_manager', 'text_color', default=[255, 255, 255]) | tojson }}'></div>
|
|
<div class="form-group"><label for="news_separator_color">Separator Color</label><input type="color" id="news_separator_color" class="form-control" data-rgb='{{ safe_config_get(main_config, 'news_manager', 'separator_color', default=[255, 0, 0]) | tojson }}'></div>
|
|
</div>
|
|
<button class="btn btn-success" type="button" onclick="saveNewsAdvancedSettings()">Save Advanced News Settings</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- API Keys Tab -->
|
|
<div id="secrets" class="tab-content">
|
|
<div class="config-section">
|
|
<h3>API Keys Configuration</h3>
|
|
<p>Enter your API keys for various services. These are stored securely and not shared.</p>
|
|
|
|
<form id="secrets-form">
|
|
<h4>Weather API</h4>
|
|
<div class="form-group">
|
|
<label for="weather_api_key">OpenWeatherMap API Key:</label>
|
|
<input type="password" class="form-control" id="weather_api_key" name="weather_api_key" value="{{ (secrets_config.weather.api_key if secrets_config and secrets_config.weather and secrets_config.weather.api_key != 'YOUR_OPENWEATHERMAP_API_KEY' else '') if secrets_config else '' }}" placeholder="Enter your OpenWeatherMap API key">
|
|
<div class="description">Get your free API key from <a href="https://openweathermap.org/api" target="_blank">OpenWeatherMap</a></div>
|
|
</div>
|
|
|
|
<h4>YouTube API</h4>
|
|
<div class="form-group">
|
|
<label for="youtube_api_key">YouTube API Key:</label>
|
|
<input type="password" class="form-control" id="youtube_api_key" name="youtube_api_key" value="{{ secrets_config.youtube.api_key if secrets_config.youtube.api_key != 'YOUR_YOUTUBE_API_KEY' else '' }}" placeholder="Enter your YouTube API key">
|
|
<div class="description">Get your API key from <a href="https://console.developers.google.com/" target="_blank">Google Cloud Console</a></div>
|
|
</div>
|
|
|
|
<h4>Spotify API</h4>
|
|
<div class="form-group">
|
|
<label for="spotify_client_id">Spotify Client ID:</label>
|
|
<input type="password" class="form-control" id="spotify_client_id" name="spotify_client_id" value="{{ secrets_config.music.SPOTIFY_CLIENT_ID if secrets_config.music.SPOTIFY_CLIENT_ID != 'YOUR_SPOTIFY_CLIENT_ID_HERE' else '' }}" placeholder="Enter your Spotify Client ID">
|
|
<div class="description">Get from <a href="https://developer.spotify.com/dashboard" target="_blank">Spotify Developer Dashboard</a></div>
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="spotify_client_secret">Spotify Client Secret:</label>
|
|
<input type="password" class="form-control" id="spotify_client_secret" name="spotify_client_secret" value="{{ secrets_config.music.SPOTIFY_CLIENT_SECRET if secrets_config.music.SPOTIFY_CLIENT_SECRET != 'YOUR_SPOTIFY_CLIENT_SECRET_HERE' else '' }}" placeholder="Enter your Spotify Client Secret">
|
|
<div class="description">Your Spotify Client Secret</div>
|
|
</div>
|
|
|
|
<button type="submit" class="btn btn-success">Save API Keys</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Editor Tab -->
|
|
<div id="editor" class="tab-content">
|
|
<h3>Display Editor</h3>
|
|
|
|
<div class="element-palette">
|
|
<h4>Elements</h4>
|
|
<div class="palette-item" draggable="true" data-type="text">
|
|
<i class="fas fa-font"></i> Text
|
|
</div>
|
|
<div class="palette-item" draggable="true" data-type="weather_icon">
|
|
<i class="fas fa-cloud-sun"></i> Weather Icon
|
|
</div>
|
|
<div class="palette-item" draggable="true" data-type="rectangle">
|
|
<i class="fas fa-square"></i> Rectangle
|
|
</div>
|
|
<div class="palette-item" draggable="true" data-type="line">
|
|
<i class="fas fa-minus"></i> Line
|
|
</div>
|
|
</div>
|
|
|
|
<div class="editor-toolbar">
|
|
<button class="btn btn-primary" onclick="clearEditor()">
|
|
<i class="fas fa-trash"></i> Clear
|
|
</button>
|
|
<button class="btn btn-success" onclick="saveLayout()">
|
|
<i class="fas fa-save"></i> Save Layout
|
|
</button>
|
|
<button class="btn btn-warning" onclick="loadLayout()">
|
|
<i class="fas fa-folder-open"></i> Load Layout
|
|
</button>
|
|
</div>
|
|
|
|
<div class="properties-panel">
|
|
<h4>Element Properties</h4>
|
|
<div id="elementProperties">
|
|
<p>Select an element to edit its properties</p>
|
|
</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();
|
|
showNotification(data.message || 'Requested on-demand start', data.status || 'success');
|
|
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);
|
|
});
|
|
}
|
|
|
|
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>Weather:</strong> ${u.weather || 0} used / ${f.weather || 0} forecast</div>
|
|
<div><strong>Stocks:</strong> ${u.stocks || 0} used / ${f.stocks || 0} forecast</div>
|
|
<div><strong>Sports:</strong> ${u.sports || 0} used / ${f.sports || 0} forecast</div>
|
|
<div><strong>News:</strong> ${u.news || 0} used / ${f.news || 0} forecast</div>
|
|
<div><strong>Odds:</strong> ${u.odds || 0} used / ${f.odds || 0} forecast</div>
|
|
<div><strong>Music:</strong> ${u.music || 0} used / ${f.music || 0} forecast</div>
|
|
<div><strong>YouTube:</strong> ${u.youtube || 0} used / ${f.youtube || 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');
|
|
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) {
|
|
meta.textContent = `${data.width || 128} x ${data.height || 32} @ ${scale}x`;
|
|
}
|
|
|
|
// Once image loads, size the canvas to match
|
|
const width = (data.width || 128) * scale;
|
|
const height = (data.height || 32) * 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 || 128, data.height || 32, 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');
|
|
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');
|
|
// 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 === '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');
|
|
}
|
|
}
|
|
|
|
// 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);
|
|
|
|
try {
|
|
const response = await fetch('/save_config', {
|
|
method: 'POST',
|
|
body: formData
|
|
});
|
|
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);
|
|
});
|
|
|
|
// Weather form submit augmentation
|
|
(function augmentWeatherForm(){
|
|
const form = document.getElementById('weather-form');
|
|
form.addEventListener('submit', async function(e){
|
|
e.preventDefault();
|
|
const payload = {
|
|
weather: {
|
|
enabled: document.getElementById('weather_enabled').checked,
|
|
update_interval: parseInt(document.getElementById('weather_update_interval').value),
|
|
units: document.getElementById('weather_units').value,
|
|
display_format: document.getElementById('weather_display_format').value
|
|
},
|
|
location: {
|
|
city: document.getElementById('weather_city').value,
|
|
state: document.getElementById('weather_state').value
|
|
}
|
|
};
|
|
await saveConfigJson(payload);
|
|
});
|
|
})();
|
|
|
|
// Stocks form submit augmentation
|
|
(function augmentStocksForm(){
|
|
const form = document.getElementById('stocks-form');
|
|
form.addEventListener('submit', async function(e){
|
|
e.preventDefault();
|
|
const symbols = document.getElementById('stocks_symbols').value.split(',').map(s => s.trim()).filter(Boolean);
|
|
const payload = {
|
|
stocks: {
|
|
enabled: document.getElementById('stocks_enabled').checked,
|
|
update_interval: parseInt(document.getElementById('stocks_update_interval').value),
|
|
scroll_speed: parseFloat(document.getElementById('stocks_scroll_speed').value),
|
|
scroll_delay: parseFloat(document.getElementById('stocks_scroll_delay').value),
|
|
toggle_chart: document.getElementById('stocks_toggle_chart').checked,
|
|
dynamic_duration: document.getElementById('stocks_dynamic_duration').checked,
|
|
min_duration: parseInt(document.getElementById('stocks_min_duration').value),
|
|
max_duration: parseInt(document.getElementById('stocks_max_duration').value),
|
|
duration_buffer: parseFloat(document.getElementById('stocks_duration_buffer').value),
|
|
symbols: symbols,
|
|
display_format: document.getElementById('stocks_display_format').value
|
|
}
|
|
};
|
|
await saveConfigJson(payload);
|
|
});
|
|
})();
|
|
|
|
// Crypto form submit
|
|
(function augmentCryptoForm(){
|
|
const form = document.getElementById('crypto-form');
|
|
form.addEventListener('submit', async function(e){
|
|
e.preventDefault();
|
|
const symbols = document.getElementById('crypto_symbols').value.split(',').map(s => s.trim()).filter(Boolean);
|
|
const payload = {
|
|
crypto: {
|
|
enabled: document.getElementById('crypto_enabled').checked,
|
|
update_interval: parseInt(document.getElementById('crypto_update_interval').value),
|
|
symbols: symbols
|
|
}
|
|
};
|
|
await saveConfigJson(payload);
|
|
});
|
|
})();
|
|
|
|
// Stock news form submit
|
|
(function augmentStockNewsForm(){
|
|
const form = document.getElementById('stocknews-form');
|
|
form.addEventListener('submit', async function(e){
|
|
e.preventDefault();
|
|
const payload = {
|
|
stock_news: {
|
|
enabled: document.getElementById('stocknews_enabled').checked,
|
|
update_interval: parseInt(document.getElementById('stocknews_update_interval').value),
|
|
scroll_speed: parseFloat(document.getElementById('stocknews_scroll_speed').value),
|
|
scroll_delay: parseFloat(document.getElementById('stocknews_scroll_delay').value),
|
|
max_headlines_per_symbol: parseInt(document.getElementById('stocknews_max_headlines_per_symbol').value),
|
|
headlines_per_rotation: parseInt(document.getElementById('stocknews_headlines_per_rotation').value),
|
|
dynamic_duration: document.getElementById('stocknews_dynamic_duration').checked,
|
|
min_duration: parseInt(document.getElementById('stocknews_min_duration').value),
|
|
max_duration: parseInt(document.getElementById('stocknews_max_duration').value),
|
|
duration_buffer: parseFloat(document.getElementById('stocknews_duration_buffer').value)
|
|
}
|
|
};
|
|
await saveConfigJson(payload);
|
|
});
|
|
})();
|
|
|
|
// Odds form submit
|
|
(function augmentOddsForm(){
|
|
const form = document.getElementById('odds-form');
|
|
form.addEventListener('submit', async function(e){
|
|
e.preventDefault();
|
|
const leagues = document.getElementById('odds_enabled_leagues').value.split(',').map(s => s.trim()).filter(Boolean);
|
|
const payload = {
|
|
odds_ticker: {
|
|
enabled: document.getElementById('odds_enabled').checked,
|
|
update_interval: parseInt(document.getElementById('odds_update_interval').value),
|
|
scroll_speed: parseFloat(document.getElementById('odds_scroll_speed').value),
|
|
scroll_delay: parseFloat(document.getElementById('odds_scroll_delay').value),
|
|
games_per_favorite_team: parseInt(document.getElementById('odds_games_per_favorite_team').value),
|
|
max_games_per_league: parseInt(document.getElementById('odds_max_games_per_league').value),
|
|
future_fetch_days: parseInt(document.getElementById('odds_future_fetch_days').value),
|
|
enabled_leagues: leagues,
|
|
sort_order: document.getElementById('odds_sort_order').value,
|
|
show_favorite_teams_only: document.getElementById('odds_show_favorite_teams_only').checked,
|
|
show_odds_only: document.getElementById('odds_show_odds_only').checked,
|
|
loop: document.getElementById('odds_loop').checked,
|
|
show_channel_logos: document.getElementById('odds_show_channel_logos').checked,
|
|
dynamic_duration: document.getElementById('odds_dynamic_duration').checked,
|
|
min_duration: parseInt(document.getElementById('odds_min_duration').value),
|
|
max_duration: parseInt(document.getElementById('odds_max_duration').value),
|
|
duration_buffer: parseFloat(document.getElementById('odds_duration_buffer').value)
|
|
}
|
|
};
|
|
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: {
|
|
nfl: {
|
|
enabled: document.getElementById('leaderboard_nfl_enabled').checked,
|
|
top_teams: parseInt(document.getElementById('leaderboard_nfl_top_teams').value)
|
|
},
|
|
nba: {
|
|
enabled: document.getElementById('leaderboard_nba_enabled').checked,
|
|
top_teams: parseInt(document.getElementById('leaderboard_nba_top_teams').value)
|
|
},
|
|
mlb: {
|
|
enabled: document.getElementById('leaderboard_mlb_enabled').checked,
|
|
top_teams: parseInt(document.getElementById('leaderboard_mlb_top_teams').value)
|
|
},
|
|
ncaa_fb: {
|
|
enabled: document.getElementById('leaderboard_ncaa_fb_enabled').checked,
|
|
top_teams: parseInt(document.getElementById('leaderboard_ncaa_fb_top_teams').value),
|
|
show_ranking: document.getElementById('leaderboard_ncaa_fb_show_ranking').checked
|
|
},
|
|
nhl: {
|
|
enabled: document.getElementById('leaderboard_nhl_enabled').checked,
|
|
top_teams: parseInt(document.getElementById('leaderboard_nhl_top_teams').value)
|
|
},
|
|
ncaam_basketball: {
|
|
enabled: document.getElementById('leaderboard_ncaam_basketball_enabled').checked,
|
|
top_teams: parseInt(document.getElementById('leaderboard_ncaam_basketball_top_teams').value)
|
|
}
|
|
}
|
|
}
|
|
};
|
|
await saveConfigJson(payload);
|
|
});
|
|
})();
|
|
|
|
// Of The Day form submit
|
|
(function augmentOfTheDayForm(){
|
|
const form = document.getElementById('of_the_day-form');
|
|
form.addEventListener('submit', async function(e){
|
|
e.preventDefault();
|
|
const categoryOrder = document.getElementById('of_the_day_category_order').value.split(',').map(s => s.trim()).filter(Boolean);
|
|
const payload = {
|
|
of_the_day: {
|
|
enabled: document.getElementById('of_the_day_enabled').checked,
|
|
update_interval: parseInt(document.getElementById('of_the_day_update_interval').value),
|
|
display_rotate_interval: parseInt(document.getElementById('of_the_day_display_rotate_interval').value),
|
|
subtitle_rotate_interval: parseInt(document.getElementById('of_the_day_subtitle_rotate_interval').value),
|
|
category_order: categoryOrder,
|
|
categories: {
|
|
word_of_the_day: {
|
|
enabled: document.getElementById('of_the_day_word_enabled').checked,
|
|
data_file: document.getElementById('of_the_day_word_data_file').value,
|
|
display_name: document.getElementById('of_the_day_word_display_name').value
|
|
},
|
|
slovenian_word_of_the_day: {
|
|
enabled: document.getElementById('of_the_day_slovenian_enabled').checked,
|
|
data_file: document.getElementById('of_the_day_slovenian_data_file').value,
|
|
display_name: document.getElementById('of_the_day_slovenian_display_name').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);
|
|
});
|
|
})();
|
|
|
|
// YouTube form submit
|
|
(function augmentYouTubeForm(){
|
|
const form = document.getElementById('youtube-form');
|
|
form.addEventListener('submit', async function(e){
|
|
e.preventDefault();
|
|
const payload = {
|
|
youtube: {
|
|
enabled: document.getElementById('youtube_enabled').checked,
|
|
update_interval: parseInt(document.getElementById('youtube_update_interval').value)
|
|
}
|
|
};
|
|
await saveConfigJson(payload);
|
|
});
|
|
})();
|
|
|
|
// Music form submit
|
|
(function augmentMusicForm(){
|
|
const form = document.getElementById('music-form');
|
|
form.addEventListener('submit', async function(e){
|
|
e.preventDefault();
|
|
const payload = {
|
|
music: {
|
|
enabled: document.getElementById('music_enabled').checked,
|
|
preferred_source: document.getElementById('music_preferred_source').value,
|
|
YTM_COMPANION_URL: document.getElementById('ytm_companion_url').value,
|
|
POLLING_INTERVAL_SECONDS: parseInt(document.getElementById('music_polling_interval').value)
|
|
}
|
|
};
|
|
await saveConfigJson(payload);
|
|
});
|
|
})();
|
|
|
|
// Calendar form submit
|
|
(function augmentCalendarForm(){
|
|
const form = document.getElementById('calendar-form');
|
|
form.addEventListener('submit', async function(e){
|
|
e.preventDefault();
|
|
const calendars = document.getElementById('calendar_calendars').value.split(',').map(s => s.trim()).filter(Boolean);
|
|
const payload = {
|
|
calendar: {
|
|
enabled: document.getElementById('calendar_enabled').checked,
|
|
max_events: parseInt(document.getElementById('calendar_max_events').value),
|
|
update_interval: parseInt(document.getElementById('calendar_update_interval').value),
|
|
calendars: calendars
|
|
}
|
|
};
|
|
await saveConfigJson(payload);
|
|
});
|
|
})();
|
|
|
|
// News advanced save
|
|
async function saveNewsAdvancedSettings(){
|
|
const payload = {
|
|
news_manager: {
|
|
update_interval: parseInt(document.getElementById('news_update_interval').value),
|
|
scroll_speed: parseFloat(document.getElementById('news_scroll_speed').value),
|
|
scroll_delay: parseFloat(document.getElementById('news_scroll_delay').value),
|
|
rotation_threshold: parseInt(document.getElementById('news_rotation_threshold').value),
|
|
dynamic_duration: document.getElementById('news_dynamic_duration').checked,
|
|
min_duration: parseInt(document.getElementById('news_min_duration').value),
|
|
max_duration: parseInt(document.getElementById('news_max_duration').value),
|
|
duration_buffer: parseFloat(document.getElementById('news_duration_buffer').value),
|
|
font_size: parseInt(document.getElementById('news_font_size').value),
|
|
font_path: document.getElementById('news_font_path').value,
|
|
text_color: hexToRgbArray(document.getElementById('news_text_color').value),
|
|
separator_color: hexToRgbArray(document.getElementById('news_separator_color').value)
|
|
}
|
|
};
|
|
await saveConfigJson(payload);
|
|
}
|
|
|
|
// 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}`;
|
|
}
|
|
}
|
|
|
|
// News Manager Functions
|
|
let newsManagerData = {};
|
|
|
|
async function loadNewsManagerData() {
|
|
try {
|
|
const response = await fetch('/news_manager/status');
|
|
const data = await response.json();
|
|
|
|
if (data.status === 'success') {
|
|
newsManagerData = data.data;
|
|
updateNewsManagerUI();
|
|
} else {
|
|
console.error('Error loading news manager data:', data.message);
|
|
}
|
|
} catch (error) {
|
|
console.error('Error loading news manager data:', error);
|
|
}
|
|
}
|
|
|
|
function updateNewsManagerUI() {
|
|
document.getElementById('news_enabled').checked = newsManagerData.enabled || false;
|
|
document.getElementById('headlines_per_feed').value = newsManagerData.headlines_per_feed || 2;
|
|
document.getElementById('rotation_enabled').checked = newsManagerData.rotation_enabled !== false;
|
|
|
|
// Populate available feeds
|
|
const feedsGrid = document.getElementById('news_feeds_grid');
|
|
feedsGrid.innerHTML = '';
|
|
|
|
if (newsManagerData.available_feeds) {
|
|
newsManagerData.available_feeds.forEach(feed => {
|
|
const isEnabled = newsManagerData.enabled_feeds.includes(feed);
|
|
const feedDiv = document.createElement('div');
|
|
feedDiv.className = 'checkbox-item';
|
|
feedDiv.innerHTML = `
|
|
<label>
|
|
<input type="checkbox" name="news_feed" value="${feed}" ${isEnabled ? 'checked' : ''}>
|
|
${feed}
|
|
</label>
|
|
`;
|
|
feedsGrid.appendChild(feedDiv);
|
|
});
|
|
}
|
|
|
|
updateCustomFeedsList();
|
|
updateNewsStatus();
|
|
}
|
|
|
|
function updateCustomFeedsList() {
|
|
const customFeedsList = document.getElementById('custom_feeds_list');
|
|
customFeedsList.innerHTML = '';
|
|
|
|
if (newsManagerData.custom_feeds) {
|
|
Object.entries(newsManagerData.custom_feeds).forEach(([name, url]) => {
|
|
const feedDiv = document.createElement('div');
|
|
feedDiv.style.cssText = 'margin: 10px 0; padding: 10px; border: 1px solid #ccc; border-radius: 4px; background: white;';
|
|
feedDiv.innerHTML = `
|
|
<div style="display: flex; justify-content: space-between; align-items: center;">
|
|
<div><strong>${name}</strong>: ${url}</div>
|
|
<button type="button" onclick="removeCustomFeed('${name}')"
|
|
style="background: #ff4444; color: white; border: none; padding: 4px 8px; border-radius: 3px; cursor: pointer;">Remove</button>
|
|
</div>
|
|
`;
|
|
customFeedsList.appendChild(feedDiv);
|
|
});
|
|
}
|
|
}
|
|
|
|
function updateNewsStatus() {
|
|
const statusDiv = document.getElementById('news_status');
|
|
const enabledFeeds = newsManagerData.enabled_feeds || [];
|
|
|
|
statusDiv.innerHTML = `
|
|
<h4>Current Status</h4>
|
|
<p><strong>Enabled:</strong> ${newsManagerData.enabled ? 'Yes' : 'No'}</p>
|
|
<p><strong>Active Feeds:</strong> ${enabledFeeds.join(', ') || 'None'}</p>
|
|
<p><strong>Headlines per Feed:</strong> ${newsManagerData.headlines_per_feed || 2}</p>
|
|
<p><strong>Total Custom Feeds:</strong> ${Object.keys(newsManagerData.custom_feeds || {}).length}</p>
|
|
<p><strong>Rotation Enabled:</strong> ${newsManagerData.rotation_enabled !== false ? 'Yes' : 'No'}</p>
|
|
`;
|
|
}
|
|
|
|
async function saveNewsSettings() {
|
|
const enabledFeeds = Array.from(document.querySelectorAll('input[name="news_feed"]:checked'))
|
|
.map(input => input.value);
|
|
|
|
const headlinesPerFeed = parseInt(document.getElementById('headlines_per_feed').value);
|
|
const enabled = document.getElementById('news_enabled').checked;
|
|
|
|
try {
|
|
await fetch('/news_manager/toggle', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ enabled: enabled })
|
|
});
|
|
|
|
const response = await fetch('/news_manager/update_feeds', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
enabled_feeds: enabledFeeds,
|
|
headlines_per_feed: headlinesPerFeed
|
|
})
|
|
});
|
|
|
|
const data = await response.json();
|
|
showNotification(data.message, data.status);
|
|
|
|
if (data.status === 'success') {
|
|
loadNewsManagerData();
|
|
}
|
|
} catch (error) {
|
|
showNotification('Error saving news settings: ' + error, 'error');
|
|
}
|
|
}
|
|
|
|
async function addCustomFeed() {
|
|
const name = document.getElementById('custom_feed_name').value.trim();
|
|
const url = document.getElementById('custom_feed_url').value.trim();
|
|
|
|
if (!name || !url) {
|
|
showNotification('Please enter both feed name and URL', 'error');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch('/news_manager/add_custom_feed', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ name: name, url: url })
|
|
});
|
|
|
|
const data = await response.json();
|
|
showNotification(data.message, data.status);
|
|
|
|
if (data.status === 'success') {
|
|
document.getElementById('custom_feed_name').value = '';
|
|
document.getElementById('custom_feed_url').value = '';
|
|
loadNewsManagerData();
|
|
}
|
|
} catch (error) {
|
|
showNotification('Error adding custom feed: ' + error, 'error');
|
|
}
|
|
}
|
|
|
|
async function removeCustomFeed(name) {
|
|
if (!confirm(`Are you sure you want to remove the feed "${name}"?`)) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch('/news_manager/remove_custom_feed', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ name: name })
|
|
});
|
|
|
|
const data = await response.json();
|
|
showNotification(data.message, data.status);
|
|
|
|
if (data.status === 'success') {
|
|
loadNewsManagerData();
|
|
}
|
|
} catch (error) {
|
|
showNotification('Error removing custom feed: ' + error, 'error');
|
|
}
|
|
}
|
|
|
|
function refreshNewsStatus() {
|
|
loadNewsManagerData();
|
|
showNotification('News status refreshed', 'success');
|
|
}
|
|
|
|
// 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 = {
|
|
'nfl_scoreboard': 'nfl',
|
|
'mlb': 'mlb',
|
|
'milb': 'milb',
|
|
'nhl_scoreboard': 'nhl',
|
|
'nba_scoreboard': 'nba',
|
|
'ncaa_fb_scoreboard': 'ncaa_fb',
|
|
'ncaa_baseball_scoreboard': 'ncaa_baseball',
|
|
'ncaam_basketball_scoreboard': 'ncaam_basketball',
|
|
'soccer_scoreboard': 'soccer'
|
|
};
|
|
const leagues = [
|
|
{ key: 'nfl_scoreboard', label: 'NFL' },
|
|
{ key: 'mlb', label: 'MLB' },
|
|
{ key: 'milb', label: 'MiLB' },
|
|
{ key: 'nhl_scoreboard', label: 'NHL' },
|
|
{ key: 'nba_scoreboard', label: 'NBA' },
|
|
{ key: 'ncaa_fb_scoreboard', label: 'NCAA FB' },
|
|
{ key: 'ncaa_baseball_scoreboard', label: 'NCAA Baseball' },
|
|
{ key: 'ncaam_basketball_scoreboard', label: 'NCAAM Basketball' },
|
|
{ key: 'soccer_scoreboard', label: 'Soccer' }
|
|
];
|
|
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;
|
|
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 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.';
|
|
} 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');
|
|
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
|
|
};
|
|
});
|
|
await saveConfigJson(fragment);
|
|
} catch (err) {
|
|
showNotification('Error saving sports configuration: ' + err, 'error');
|
|
return;
|
|
}
|
|
showNotification('Sports configuration saved', 'success');
|
|
}
|
|
</script>
|
|
</body>
|
|
</html> |