Files
ledgrab/server/src/wled_controller/static/index.html
T
alexei.dolgolyov eca81e11cf
Validate / validate (push) Failing after 7s
Improve Web UI with footer, icon buttons, and better modals
- Add footer with author info (name, email, git repository link)
- Replace device action buttons with icons to save space:
  - Start/Stop: ▶️/⏹️, Settings: ⚙️, Calibrate: 📐, Remove: 🗑️
  - Added hover tooltips with translated text
  - Added btn-icon CSS class for compact styling
- Replace native browser confirm() with custom modal dialog:
  - Matches app theme and supports translations
  - Used for logout and device removal confirmations
  - Added confirm.title, confirm.yes, confirm.no translations
- Disable background scrolling when modals are open:
  - Added modal-open class to body when any modal opens
  - Prevents page scroll behind modals for better UX
  - Applied to all modals: login, settings, calibration, confirmation

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-06 18:18:33 +03:00

469 lines
25 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WLED Screen Controller</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/style.css">
</head>
<body style="visibility: hidden;">
<div class="container">
<header>
<h1 data-i18n="app.title">WLED Screen Controller</h1>
<div class="server-info">
<span id="server-version"><span data-i18n="app.version">Version:</span> <span id="version-number">Loading...</span></span>
<span id="server-status" class="status-badge"></span>
<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>
<span id="auth-status" style="margin-left: 10px; display: none; white-space: nowrap;">
<span id="logged-in-user" style="color: #4CAF50;" data-i18n="auth.authenticated">Authenticated</span>
</span>
<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;">
🚪 <span data-i18n="auth.logout">Logout</span>
</button>
</div>
</header>
<section class="displays-section">
<h2 data-i18n="displays.title">Available Displays</h2>
<!-- Visual Layout Preview -->
<div class="display-layout-preview">
<h3 data-i18n="displays.layout">Display Layout</h3>
<div id="display-layout-canvas" class="display-layout-canvas">
<div class="loading" data-i18n="displays.loading">Loading layout...</div>
</div>
<div class="layout-legend">
<span class="legend-item"><span class="legend-dot primary"></span> <span data-i18n="displays.legend.primary">Primary Display</span></span>
<span class="legend-item"><span class="legend-dot secondary"></span> <span data-i18n="displays.legend.secondary">Secondary Display</span></span>
</div>
</div>
<!-- Display Cards -->
<h3 style="margin-top: 30px;" data-i18n="displays.information">Display Information</h3>
<div id="displays-list" class="displays-grid">
<div class="loading" data-i18n="displays.loading">Loading displays...</div>
</div>
</section>
<section class="devices-section">
<h2 data-i18n="devices.title">WLED Devices</h2>
<div id="devices-list" class="devices-grid">
<div class="loading" data-i18n="devices.loading">Loading devices...</div>
</div>
</section>
<section class="add-device-section">
<h2 data-i18n="devices.add">Add New Device</h2>
<div class="info-banner" style="margin-bottom: 20px; padding: 12px; background: rgba(33, 150, 243, 0.1); border-left: 4px solid #2196F3; border-radius: 4px;">
<strong><span data-i18n="devices.wled_config">📱 WLED Configuration:</span></strong> <span data-i18n="devices.wled_note">Configure your WLED device (effects, segments, color order, power limits, etc.) using the</span>
<a href="https://kno.wled.ge/" target="_blank" rel="noopener" style="color: #2196F3; text-decoration: underline;" data-i18n="devices.wled_link">official WLED app</a>.
<span data-i18n="devices.wled_note2">This controller sends pixel color data and controls brightness per device.</span>
</div>
<form id="add-device-form">
<div class="form-group">
<label for="device-name" data-i18n="device.name">Device Name:</label>
<input type="text" id="device-name" data-i18n-placeholder="device.name.placeholder" placeholder="Living Room TV" required>
</div>
<div class="form-group">
<label for="device-url" data-i18n="device.url">WLED URL:</label>
<input type="url" id="device-url" data-i18n-placeholder="device.url.placeholder" placeholder="http://192.168.1.100" required>
</div>
<div class="form-group">
<label for="device-led-count" data-i18n="device.led_count">LED Count:</label>
<input type="number" id="device-led-count" value="150" min="1" required>
<small class="input-hint" data-i18n="device.led_count.hint">Number of LEDs configured in your WLED device</small>
</div>
<button type="submit" class="btn btn-primary" data-i18n="device.button.add">Add Device</button>
</form>
</section>
<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>
<!-- Calibration Modal -->
<div id="calibration-modal" class="modal">
<div class="modal-content" style="max-width: 700px;">
<div class="modal-header">
<h2 data-i18n="calibration.title">📐 LED Calibration</h2>
</div>
<div class="modal-body">
<input type="hidden" id="calibration-device-id">
<p style="margin-bottom: 20px; color: var(--text-secondary);" data-i18n="calibration.description">
Configure how your LED strip is mapped to screen edges. Use test buttons to verify each edge lights up correctly.
</p>
<!-- Visual Preview -->
<div style="margin-bottom: 25px;">
<div style="position: relative; width: 400px; height: 250px; margin: 0 auto; background: var(--card-bg); border: 2px solid var(--border-color); border-radius: 8px;">
<!-- Screen representation -->
<div style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 300px; height: 180px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border-radius: 4px; display: flex; align-items: center; justify-content: center; color: white; font-size: 14px;" data-i18n="calibration.preview.screen">
Screen
</div>
<!-- Edge labels -->
<div style="position: absolute; top: 5px; left: 50%; transform: translateX(-50%); font-size: 12px; color: var(--text-secondary);">
<span data-i18n="calibration.preview.top">Top:</span> <span id="preview-top-count">0</span> <span data-i18n="calibration.preview.leds">LEDs</span>
</div>
<div style="position: absolute; right: 5px; top: 50%; transform: translateY(-50%) rotate(90deg); font-size: 12px; color: var(--text-secondary); white-space: nowrap;">
<span data-i18n="calibration.preview.right">Right:</span> <span id="preview-right-count">0</span> <span data-i18n="calibration.preview.leds">LEDs</span>
</div>
<div style="position: absolute; bottom: 5px; left: 50%; transform: translateX(-50%); font-size: 12px; color: var(--text-secondary);">
<span data-i18n="calibration.preview.bottom">Bottom:</span> <span id="preview-bottom-count">0</span> <span data-i18n="calibration.preview.leds">LEDs</span>
</div>
<div style="position: absolute; left: 5px; top: 50%; transform: translateY(-50%) rotate(-90deg); font-size: 12px; color: var(--text-secondary); white-space: nowrap;">
<span data-i18n="calibration.preview.left">Left:</span> <span id="preview-left-count">0</span> <span data-i18n="calibration.preview.leds">LEDs</span>
</div>
<!-- Starting position indicator -->
<div id="start-indicator" style="position: absolute; bottom: 10px; left: 10px; width: 12px; height: 12px; background: #4CAF50; border-radius: 50%; border: 2px solid white;"></div>
</div>
</div>
<!-- Layout Configuration -->
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 15px; margin-bottom: 20px;">
<div class="form-group">
<label for="cal-start-position" data-i18n="calibration.start_position">Starting Position:</label>
<select id="cal-start-position" onchange="updateCalibrationPreview()">
<option value="bottom_left" data-i18n="calibration.position.bottom_left">Bottom Left</option>
<option value="bottom_right" data-i18n="calibration.position.bottom_right">Bottom Right</option>
<option value="top_left" data-i18n="calibration.position.top_left">Top Left</option>
<option value="top_right" data-i18n="calibration.position.top_right">Top Right</option>
</select>
</div>
<div class="form-group">
<label for="cal-layout" data-i18n="calibration.direction">Direction:</label>
<select id="cal-layout" onchange="updateCalibrationPreview()">
<option value="clockwise" data-i18n="calibration.direction.clockwise">Clockwise</option>
<option value="counterclockwise" data-i18n="calibration.direction.counterclockwise">Counterclockwise</option>
</select>
</div>
</div>
<!-- LED Counts per Edge -->
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 15px; margin-bottom: 20px;">
<div class="form-group">
<label for="cal-top-leds" data-i18n="calibration.leds.top">Top LEDs:</label>
<input type="number" id="cal-top-leds" min="0" value="0" oninput="updateCalibrationPreview()">
</div>
<div class="form-group">
<label for="cal-right-leds" data-i18n="calibration.leds.right">Right LEDs:</label>
<input type="number" id="cal-right-leds" min="0" value="0" oninput="updateCalibrationPreview()">
</div>
<div class="form-group">
<label for="cal-bottom-leds" data-i18n="calibration.leds.bottom">Bottom LEDs:</label>
<input type="number" id="cal-bottom-leds" min="0" value="0" oninput="updateCalibrationPreview()">
</div>
<div class="form-group">
<label for="cal-left-leds" data-i18n="calibration.leds.left">Left LEDs:</label>
<input type="number" id="cal-left-leds" min="0" value="0" oninput="updateCalibrationPreview()">
</div>
</div>
<div style="padding: 10px; background: rgba(255, 193, 7, 0.1); border-left: 4px solid #FFC107; border-radius: 4px; margin-bottom: 20px;">
<strong data-i18n="calibration.total">Total LEDs:</strong> <span id="cal-total-leds">0</span> / <span id="cal-device-led-count">0</span>
</div>
<!-- Test Buttons -->
<div style="margin-bottom: 15px;">
<p style="font-weight: 600; margin-bottom: 10px;" data-i18n="calibration.test">Test Edges (lights up each edge):</p>
<div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px;">
<button class="btn btn-secondary" onclick="testCalibrationEdge('top')" style="font-size: 0.9rem; padding: 8px;">
⬆️ <span data-i18n="calibration.test.top">Top</span>
</button>
<button class="btn btn-secondary" onclick="testCalibrationEdge('right')" style="font-size: 0.9rem; padding: 8px;">
➡️ <span data-i18n="calibration.test.right">Right</span>
</button>
<button class="btn btn-secondary" onclick="testCalibrationEdge('bottom')" style="font-size: 0.9rem; padding: 8px;">
⬇️ <span data-i18n="calibration.test.bottom">Bottom</span>
</button>
<button class="btn btn-secondary" onclick="testCalibrationEdge('left')" style="font-size: 0.9rem; padding: 8px;">
⬅️ <span data-i18n="calibration.test.left">Left</span>
</button>
</div>
</div>
<div id="calibration-error" class="error-message" style="display: none;"></div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeCalibrationModal()" data-i18n="calibration.button.cancel">Cancel</button>
<button class="btn btn-primary" onclick="saveCalibration()" data-i18n="calibration.button.save">Save Calibration</button>
</div>
</div>
</div>
<!-- Device Settings Modal -->
<div id="device-settings-modal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2 data-i18n="settings.title">⚙️ Device Settings</h2>
</div>
<div class="modal-body">
<form id="device-settings-form">
<input type="hidden" id="settings-device-id">
<div class="form-group">
<label for="settings-device-name" data-i18n="device.name">Device Name:</label>
<input type="text" id="settings-device-name" data-i18n-placeholder="device.name.placeholder" placeholder="Living Room TV" required>
</div>
<div class="form-group">
<label for="settings-device-url" data-i18n="device.url">WLED URL:</label>
<input type="url" id="settings-device-url" data-i18n-placeholder="device.url.placeholder" placeholder="http://192.168.1.100" required>
<small class="input-hint" data-i18n="settings.url.hint">IP address or hostname of your WLED device</small>
</div>
<div class="form-group">
<label for="settings-device-led-count" data-i18n="device.led_count">LED Count:</label>
<input type="number" id="settings-device-led-count" min="1" required>
<small class="input-hint" data-i18n="device.led_count.hint">Number of LEDs configured in your WLED device</small>
</div>
<div class="form-group">
<label for="settings-device-brightness"><span data-i18n="settings.brightness">Brightness:</span> <span id="brightness-value">100%</span></label>
<input type="range" id="settings-device-brightness" min="0" max="100" value="100"
oninput="document.getElementById('brightness-value').textContent = this.value + '%'"
style="width: 100%;">
<small class="input-hint" data-i18n="settings.brightness.hint">Global brightness for this WLED device (0-100%)</small>
</div>
<div id="settings-error" class="error-message" style="display: none;"></div>
</form>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeDeviceSettingsModal()" data-i18n="settings.button.cancel">Cancel</button>
<button class="btn btn-primary" onclick="saveDeviceSettings()" data-i18n="settings.button.save">Save Changes</button>
</div>
</div>
</div>
<!-- Login Modal -->
<div id="api-key-modal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2 data-i18n="auth.title">🔑 Login to WLED Controller</h2>
</div>
<div class="modal-body">
<p class="modal-description" data-i18n="auth.message">
Please enter your API key to authenticate and access the WLED Screen Controller.
</p>
<div class="form-group">
<label for="api-key-input" data-i18n="auth.label">API Key:</label>
<div class="password-input-wrapper">
<input
type="password"
id="api-key-input"
data-i18n-placeholder="auth.placeholder"
placeholder="Enter your API key..."
autocomplete="off"
>
<button type="button" class="password-toggle" onclick="togglePasswordVisibility()">
👁️
</button>
</div>
<small class="input-hint" data-i18n="auth.hint">Your API key will be stored securely in your browser's local storage.</small>
</div>
<div id="api-key-error" class="error-message" style="display: none;"></div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeApiKeyModal()" id="modal-cancel-btn" data-i18n="auth.button.cancel">Cancel</button>
<button class="btn btn-primary" onclick="submitApiKey()" data-i18n="auth.button.login">Login</button>
</div>
</div>
</div>
<!-- Confirmation Modal -->
<div id="confirm-modal" class="modal">
<div class="modal-content" style="max-width: 450px;">
<div class="modal-header">
<h2 id="confirm-title">Confirm Action</h2>
</div>
<div class="modal-body">
<p id="confirm-message" class="modal-description"></p>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" id="confirm-no-btn" onclick="closeConfirmModal(false)">No</button>
<button class="btn btn-danger" id="confirm-yes-btn" onclick="closeConfirmModal(true)">Yes</button>
</div>
</div>
</div>
<script src="/static/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 authStatus = document.getElementById('auth-status');
const loggedInUser = document.getElementById('logged-in-user');
if (apiKey) {
// Logged in
loginBtn.style.display = 'none';
logoutBtn.style.display = 'inline-block';
authStatus.style.display = 'inline';
// Show masked key
const masked = apiKey.substring(0, 8) + '...';
loggedInUser.textContent = `● Authenticated`;
loggedInUser.title = `API Key: ${masked}`;
} else {
// Logged out
loginBtn.style.display = 'inline-block';
logoutBtn.style.display = 'none';
authStatus.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');
apiKey = null;
updateAuthUI();
showToast(t('auth.logout.success'), 'info');
// Clear the UI
document.getElementById('devices-list').innerHTML = `<div class="loading">${t('auth.please_login')} devices</div>`;
document.getElementById('displays-list').innerHTML = `<div class="loading">${t('auth.please_login')} displays</div>`;
}
// 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');
if (message) {
description.textContent = message;
}
input.value = '';
input.placeholder = 'Enter your API key...';
error.style.display = 'none';
modal.style.display = 'flex';
document.body.classList.add('modal-open');
// Hide cancel button if this is required login (no existing session)
cancelBtn.style.display = hideCancel ? 'none' : 'inline-block';
setTimeout(() => input.focus(), 100);
}
function closeApiKeyModal() {
const modal = document.getElementById('api-key-modal');
modal.style.display = 'none';
document.body.classList.remove('modal-open');
}
function submitApiKey() {
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);
apiKey = key;
updateAuthUI();
closeApiKeyModal();
showToast(t('auth.success'), 'success');
// Reload data
loadServerInfo();
loadDisplays();
loadDevices();
// Start auto-refresh if not already running
if (!refreshInterval) {
startAutoRefresh();
}
}
// Handle Enter key in modal
document.addEventListener('DOMContentLoaded', () => {
document.getElementById('api-key-input').addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
submitApiKey();
}
});
});
</script>
</body>
</html>