Split monolithic index.html and style.css for maintainability

- Extract 15 modals and 3 partials from index.html into Jinja2 templates
  (templates/modals/*.html, templates/partials/*.html)
- Split style.css (3,712 lines) into 11 feature-scoped CSS files under
  static/css/ (base, layout, components, cards, modal, calibration,
  dashboard, streams, patterns, profiles, tutorials)
- Switch root route from FileResponse to Jinja2Templates
- Add jinja2 dependency
- Consolidate duplicate @keyframes spin definition
- Browser receives identical assembled HTML — zero JS changes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-20 00:42:50 +03:00
parent 755077607a
commit 2b90fafb9c
35 changed files with 4986 additions and 4990 deletions

View File

@@ -0,0 +1,276 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LED Grab</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='0.9em' font-size='90'>💡</text></svg>">
<link rel="stylesheet" href="/static/css/base.css">
<link rel="stylesheet" href="/static/css/layout.css">
<link rel="stylesheet" href="/static/css/components.css">
<link rel="stylesheet" href="/static/css/cards.css">
<link rel="stylesheet" href="/static/css/modal.css">
<link rel="stylesheet" href="/static/css/calibration.css">
<link rel="stylesheet" href="/static/css/dashboard.css">
<link rel="stylesheet" href="/static/css/streams.css">
<link rel="stylesheet" href="/static/css/patterns.css">
<link rel="stylesheet" href="/static/css/profiles.css">
<link rel="stylesheet" href="/static/css/tutorials.css">
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
</head>
<body style="visibility: hidden;">
<div class="container">
<header>
<div class="header-title">
<span id="server-status" class="status-badge"></span>
<h1 data-i18n="app.title">LED Grab</h1>
<span id="server-version"><span id="version-number"></span></span>
</div>
<div class="server-info">
<a href="/docs" target="_blank" class="header-link" data-i18n-title="app.api_docs" title="API Docs">API</a>
<button class="theme-toggle" onclick="toggleTheme()" data-i18n-title="theme.toggle" title="Toggle theme">
<span id="theme-icon">🌙</span>
</button>
<select id="locale-select" onchange="changeLocale()" data-i18n-title="locale.change" title="Change language" style="margin-left: 10px; padding: 6px 12px; border: 1px solid var(--border-color); border-radius: 4px; background: var(--bg-color); color: var(--text-color); font-size: 0.9rem; cursor: pointer;">
<option value="en">English</option>
<option value="ru">Русский</option>
</select>
<button id="login-btn" class="btn btn-primary" onclick="showLogin()" style="display: none; padding: 6px 16px; font-size: 0.85rem; margin-left: 10px;">
🔑 <span data-i18n="auth.login">Login</span>
</button>
<button id="logout-btn" class="btn btn-danger" onclick="logout()" style="display: none; padding: 6px 16px; font-size: 0.85rem; margin-left: 10px;">
🚪
</button>
</div>
</header>
<div class="tabs">
<div class="tab-bar">
<button class="tab-btn" data-tab="dashboard" onclick="switchTab('dashboard')"><span data-i18n="dashboard.title">📊 Dashboard</span></button>
<button class="tab-btn" data-tab="profiles" onclick="switchTab('profiles')"><span data-i18n="profiles.title">📋 Profiles</span></button>
<button class="tab-btn" data-tab="targets" onclick="switchTab('targets')"><span data-i18n="targets.title">⚡ Targets</span></button>
<button class="tab-btn" data-tab="streams" onclick="switchTab('streams')"><span data-i18n="streams.title">📺 Sources</span></button>
</div>
<div class="tab-panel" id="tab-dashboard">
<div id="dashboard-content">
<div class="loading-spinner"></div>
</div>
</div>
<div class="tab-panel" id="tab-profiles">
<div id="profiles-content">
<div class="loading-spinner"></div>
</div>
</div>
<div class="tab-panel" id="tab-targets">
<div id="targets-panel-content">
<div class="loading-spinner"></div>
</div>
</div>
<div class="tab-panel" id="tab-streams">
<div id="streams-list">
<div class="loading-spinner"></div>
</div>
</div>
<script>
// Apply saved tab immediately during parse to prevent visible jump
(function() {
var saved = localStorage.getItem('activeTab');
if (saved === 'devices') saved = 'targets';
if (!saved || !document.getElementById('tab-' + saved)) saved = 'dashboard';
document.querySelectorAll('.tab-btn').forEach(function(btn) { btn.classList.toggle('active', btn.dataset.tab === saved); });
document.querySelectorAll('.tab-panel').forEach(function(panel) { panel.classList.toggle('active', panel.id === 'tab-' + saved); });
})();
</script>
</div>
<footer class="app-footer">
<div class="footer-content">
<p>
Created by <strong>Alexei Dolgolyov</strong>
<a href="mailto:dolgolyov.alexei@gmail.com">dolgolyov.alexei@gmail.com</a>
<a href="https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed" target="_blank" rel="noopener">Source Code</a>
</p>
</div>
</footer>
</div>
<div id="toast" class="toast"></div>
{% include 'modals/calibration.html' %}
{% include 'modals/device-settings.html' %}
{% include 'modals/target-editor.html' %}
{% include 'modals/kc-editor.html' %}
{% include 'modals/pattern-template.html' %}
{% include 'modals/api-key.html' %}
{% include 'modals/confirm.html' %}
{% include 'modals/add-device.html' %}
{% include 'modals/capture-template.html' %}
{% include 'modals/test-template.html' %}
{% include 'modals/test-stream.html' %}
{% include 'modals/test-pp-template.html' %}
{% include 'modals/stream.html' %}
{% include 'modals/pp-template.html' %}
{% include 'modals/profile-editor.html' %}
{% include 'partials/tutorial-overlay.html' %}
{% include 'partials/image-lightbox.html' %}
{% include 'partials/display-picker.html' %}
<script type="module" src="/static/js/app.js"></script>
<script>
// Initialize theme
const savedTheme = localStorage.getItem('theme') || 'dark';
document.documentElement.setAttribute('data-theme', savedTheme);
updateThemeIcon(savedTheme);
function updateThemeIcon(theme) {
const icon = document.getElementById('theme-icon');
icon.textContent = theme === 'dark' ? '☀️' : '🌙';
}
function toggleTheme() {
const currentTheme = document.documentElement.getAttribute('data-theme');
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', newTheme);
localStorage.setItem('theme', newTheme);
updateThemeIcon(newTheme);
showToast(`Switched to ${newTheme} theme`, 'info');
}
// Initialize auth state
function updateAuthUI() {
const apiKey = localStorage.getItem('wled_api_key');
const loginBtn = document.getElementById('login-btn');
const logoutBtn = document.getElementById('logout-btn');
const tabBar = document.querySelector('.tab-bar');
if (apiKey) {
loginBtn.style.display = 'none';
logoutBtn.style.display = 'inline-block';
if (tabBar) tabBar.style.display = '';
} else {
loginBtn.style.display = 'inline-block';
logoutBtn.style.display = 'none';
if (tabBar) tabBar.style.display = 'none';
}
}
function showLogin() {
showApiKeyModal(t('auth.message'));
document.getElementById('modal-cancel-btn').style.display = 'inline-block';
}
async function logout() {
const confirmed = await showConfirm(t('auth.logout.confirm'));
if (!confirmed) {
return;
}
localStorage.removeItem('wled_api_key');
if (window.setApiKey) window.setApiKey(null);
updateAuthUI();
showToast(t('auth.logout.success'), 'info');
// Stop background activity
if (window.stopDashboardWS) window.stopDashboardWS();
if (window.stopPerfPolling) window.stopPerfPolling();
if (window.stopUptimeTimer) window.stopUptimeTimer();
if (window.disconnectAllKCWebSockets) window.disconnectAllKCWebSockets();
// Clear all tab panels
const loginMsg = `<div class="loading">${t('auth.please_login')}</div>`;
document.getElementById('dashboard-content').innerHTML = loginMsg;
document.getElementById('profiles-content').innerHTML = loginMsg;
document.getElementById('targets-panel-content').innerHTML = loginMsg;
document.getElementById('streams-list').innerHTML = loginMsg;
}
// Initialize on load
updateAuthUI();
// Modal functions
function togglePasswordVisibility() {
const input = document.getElementById('api-key-input');
const button = document.querySelector('.password-toggle');
if (input.type === 'password') {
input.type = 'text';
button.textContent = '🙈';
} else {
input.type = 'password';
button.textContent = '👁️';
}
}
function showApiKeyModal(message, hideCancel = false) {
const modal = document.getElementById('api-key-modal');
const description = document.querySelector('.modal-description');
const input = document.getElementById('api-key-input');
const error = document.getElementById('api-key-error');
const cancelBtn = document.getElementById('modal-cancel-btn');
description.textContent = message || t('auth.message');
input.value = '';
input.placeholder = t('auth.placeholder');
error.style.display = 'none';
modal.style.display = 'flex';
lockBody();
// Hide cancel button and close X if this is required login (no existing session)
cancelBtn.style.display = hideCancel ? 'none' : 'inline-block';
const closeXBtn = document.getElementById('modal-close-x-btn');
if (closeXBtn) closeXBtn.style.display = hideCancel ? 'none' : '';
// Hide login button while modal is open
document.getElementById('login-btn').style.display = 'none';
setTimeout(() => input.focus(), 100);
}
function closeApiKeyModal() {
const modal = document.getElementById('api-key-modal');
modal.style.display = 'none';
unlockBody();
updateAuthUI();
}
function submitApiKey(event) {
if (event) {
event.preventDefault();
}
const input = document.getElementById('api-key-input');
const error = document.getElementById('api-key-error');
const key = input.value.trim();
if (!key) {
error.textContent = t('auth.error.required');
error.style.display = 'block';
return;
}
// Store the key
localStorage.setItem('wled_api_key', key);
if (window.setApiKey) window.setApiKey(key);
updateAuthUI();
closeApiKeyModal();
showToast(t('auth.success'), 'success');
// Reload data
loadServerInfo();
loadDisplays();
loadTargetsTab();
// Start auto-refresh
startAutoRefresh();
}
</script>
</body>
</html>