feat(ui): modularize appearance controls

main
AlexsandrSnytkin 17 hours ago
parent 465d1bb6bb
commit 819afa853e

@ -1363,6 +1363,52 @@ HTML = """<!doctype html>
@keyframes form-group-in { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: none; } } @keyframes form-group-in { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: none; } }
@keyframes form-group-out { from { opacity: 1; transform: none; } to { opacity: 0; transform: translateY(-4px); } } @keyframes form-group-out { from { opacity: 1; transform: none; } to { opacity: 0; transform: translateY(-4px); } }
@keyframes trash-in { from { opacity: 0; transform: translate(-50%, 16px) scale(.9); } to { opacity: .96; transform: translate(-50%, 0) scale(1); } } @keyframes trash-in { from { opacity: 0; transform: translate(-50%, 16px) scale(.9); } to { opacity: .96; transform: translate(-50%, 0) scale(1); } }
/* Theme controls stay in one small module instead of crowding the header. */
.appearance-module { position: relative; flex: 0 0 auto; z-index: 30; }
.appearance-trigger { display: inline-flex; align-items: center; gap: 8px; min-width: 148px; height: 34px; padding: 0 9px; border: 1px solid var(--ui-border); border-radius: 10px; background: color-mix(in srgb, var(--ui-surface) 76%, var(--ui-input)); color: var(--ui-text); cursor: pointer; transition: border-color .2s ease, background .2s ease, transform .2s ease, box-shadow .2s ease; }
.appearance-trigger:hover, .appearance-trigger[aria-expanded="true"] { border-color: var(--ui-accent); background: color-mix(in srgb, var(--ui-accent) 7%, var(--ui-surface)); box-shadow: 0 5px 18px color-mix(in srgb, var(--ui-accent) 10%, transparent); transform: translateY(-1px); }
.appearance-trigger:focus-visible, .appearance-close:focus-visible, .appearance-options button:focus-visible, .appearance-foot button:focus-visible { outline: 2px solid var(--ui-accent); outline-offset: 2px; }
.appearance-trigger-dot { width: 11px; height: 11px; flex: 0 0 auto; border-radius: 50%; background: var(--ui-accent); box-shadow: 0 0 0 4px color-mix(in srgb, var(--ui-accent) 13%, transparent); transition: background .2s ease, box-shadow .2s ease; }
.appearance-trigger-copy { min-width: 0; display: grid; gap: 1px; text-align: left; line-height: 1.05; }
.appearance-trigger-copy b { font-size: 10px; letter-spacing: .07em; text-transform: uppercase; }
.appearance-trigger-copy small { min-width: 0; overflow: hidden; color: var(--ui-muted); font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
.appearance-trigger-chevron { margin-left: auto; color: var(--ui-muted); font-size: 17px; line-height: 1; transform: translateY(-1px); transition: transform .2s ease, color .2s ease; }
.appearance-trigger[aria-expanded="true"] .appearance-trigger-chevron { color: var(--ui-accent); transform: rotate(180deg) translateY(1px); }
.appearance-panel { position: absolute; top: calc(100% + 9px); right: 0; width: min(370px, calc(100vw - 20px)); max-height: min(78vh, 620px); overflow: auto; padding: 13px; border: 1px solid var(--ui-border); border-radius: 14px; background: color-mix(in srgb, var(--ui-surface) 97%, var(--ui-input)); box-shadow: 0 18px 46px rgba(0, 0, 0, .24); color: var(--ui-text); animation: appearance-in .2s ease both; }
.appearance-panel[hidden] { display: none !important; }
.appearance-head, .appearance-group-head, .appearance-intensity > span, .appearance-foot { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.appearance-head { padding-bottom: 11px; border-bottom: 1px solid var(--ui-border); }
.appearance-head > div { display: grid; gap: 2px; }
.appearance-head strong { font-size: 13px; }
.appearance-head small, .appearance-group-head small, .appearance-foot span { color: var(--ui-muted); font-size: 10px; }
.appearance-close { width: 26px; height: 26px; border: 1px solid transparent; border-radius: 7px; background: transparent; color: var(--ui-muted); font-size: 19px; line-height: 1; cursor: pointer; }
.appearance-close:hover { color: var(--ui-text); border-color: var(--ui-border); background: var(--ui-input); }
.appearance-group { display: grid; gap: 8px; padding: 12px 0; border-bottom: 1px solid var(--ui-border); }
.appearance-group-head span { font-size: 11px; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; }
.appearance-options { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 6px; }
.appearance-options button { min-width: 0; display: grid; grid-template-columns: 18px minmax(0, 1fr); align-items: center; gap: 6px; height: 34px; padding: 0 7px; border: 1px solid var(--ui-border); border-radius: 8px; background: var(--ui-input); color: var(--ui-muted); font: 10px/1.05 ui-sans-serif, system-ui, sans-serif; text-align: left; cursor: pointer; transition: border-color .18s ease, background .18s ease, color .18s ease, transform .18s ease; }
.appearance-options button:hover { color: var(--ui-text); border-color: color-mix(in srgb, var(--ui-accent) 62%, var(--ui-border)); transform: translateY(-1px); }
.appearance-options button.is-active { color: var(--ui-text); border-color: var(--ui-accent); background: color-mix(in srgb, var(--ui-accent) 12%, var(--ui-input)); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--ui-accent) 20%, transparent); }
.appearance-swatch { width: 18px; height: 18px; border: 1px solid color-mix(in srgb, var(--ui-text) 18%, transparent); border-radius: 6px; background: var(--appearance-swatch); box-shadow: inset 0 0 0 4px var(--appearance-swatch-2, transparent); }
.accent-options button { grid-template-columns: 14px minmax(0, 1fr); }
.accent-options .appearance-swatch { width: 14px; height: 14px; border: 0; border-radius: 50%; background: var(--appearance-swatch); box-shadow: 0 0 0 3px color-mix(in srgb, var(--appearance-swatch) 17%, transparent); }
.appearance-custom { display: flex !important; align-items: center; justify-content: space-between; gap: 8px; color: var(--ui-muted) !important; font-size: 10px !important; }
.appearance-custom .accent-custom { width: 42px; height: 25px; padding: 2px; border: 1px solid var(--ui-border) !important; border-radius: 7px; background: var(--ui-input) !important; cursor: pointer; }
.appearance-intensity { display: grid !important; gap: 7px !important; padding: 12px 0 10px; color: var(--ui-muted) !important; font-size: 10px !important; }
.appearance-intensity output { color: var(--ui-text); font-variant-numeric: tabular-nums; }
.appearance-intensity .accent-intensity { width: 100%; height: 18px; margin: 0; accent-color: var(--ui-accent); cursor: pointer; }
.appearance-foot { padding-top: 2px; }
.appearance-foot button { min-height: 25px; padding: 0 8px; border: 1px solid var(--ui-border); border-radius: 7px; background: transparent; color: var(--ui-muted); font: 700 10px ui-sans-serif, system-ui, sans-serif; cursor: pointer; transition: color .18s ease, border-color .18s ease, background .18s ease; }
.appearance-foot button:hover { color: var(--ui-accent); border-color: var(--ui-accent); background: color-mix(in srgb, var(--ui-accent) 8%, transparent); }
.appearance-native { position: absolute; width: 1px; height: 1px; overflow: hidden; opacity: 0; pointer-events: none; }
@keyframes appearance-in { from { opacity: 0; transform: translateY(-5px) scale(.98); } to { opacity: 1; transform: none; } }
@media (max-width: 760px) {
.appearance-trigger { min-width: 38px; width: 38px; padding: 0; justify-content: center; }
.appearance-trigger-copy { display: none; }
.appearance-trigger-chevron { display: none; }
.appearance-panel { position: fixed; top: 56px; right: 8px; width: min(370px, calc(100vw - 16px)); }
.appearance-options { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
</style> </style>
</head> </head>
<body> <body>
@ -1376,38 +1422,26 @@ HTML = """<!doctype html>
<button class="tab" type="button" data-tab="settings">Настройки</button> <button class="tab" type="button" data-tab="settings">Настройки</button>
<button class="tab" type="button" data-tab="model">Сеть</button> <button class="tab" type="button" data-tab="model">Сеть</button>
</nav> </nav>
<select id="themeSelect" class="theme-select" aria-label="Тема"> <div class="appearance-module">
<option value="dark">Тёмная</option> <button id="appearanceToggle" class="appearance-trigger" type="button" aria-expanded="false" aria-controls="appearancePanel">
<option value="light">Светлая</option> <span class="appearance-trigger-dot" aria-hidden="true"></span>
<option value="amber">Янтарная</option> <span class="appearance-trigger-copy"><b>Оформление</b><small id="appearanceValue">Тёмная · Синий</small></span>
<option value="midnight">Полночь</option> <span class="appearance-trigger-chevron" aria-hidden="true"></span>
<option value="forest">Лесная</option> </button>
<option value="rose">Розовая</option> <section id="appearancePanel" class="appearance-panel" hidden aria-label="Настройки оформления">
<option value="graphite">Графитовая</option> <div class="appearance-head"><div><strong>Оформление</strong><small>Тема, акцент и насыщенность</small></div><button id="appearanceClose" class="appearance-close" type="button" aria-label="Закрыть">×</button></div>
<option value="solarized">Solarized</option> <div class="appearance-group"><div class="appearance-group-head"><span>Тема</span><small id="themePreview">Тёмная</small></div><div id="themeOptions" class="appearance-options" role="listbox" aria-label="Выбор темы"></div></div>
<option value="ocean">Океан</option> <div class="appearance-group"><div class="appearance-group-head"><span>Акцент</span><small id="accentPreview">Синий</small></div><div id="accentOptions" class="appearance-options accent-options" role="listbox" aria-label="Выбор акцента"></div><label class="appearance-custom"><span>Свой цвет</span><input id="accentCustom" class="accent-custom" type="color" value="#8fb8ff" aria-label="Произвольный акцент" title="Произвольный акцент"></label></div>
<option value="neon">Неон</option> <label class="appearance-intensity"><span><span>Насыщенность</span><output id="accentIntensityValue">100%</output></span><input id="accentIntensity" class="accent-intensity" type="range" min="55" max="100" step="5" value="100" aria-label="Интенсивность акцента" title="Интенсивность акцента"></label>
<option value="copper">Медь</option> <div class="appearance-foot"><span>Настройки сохраняются автоматически</span><button id="appearanceReset" type="button">Сбросить</button></div>
<option value="mono">Монохром</option> <select id="themeSelect" class="appearance-native" aria-label="Тема">
<option value="dark">Тёмная</option><option value="light">Светлая</option><option value="amber">Янтарная</option><option value="midnight">Полночь</option><option value="forest">Лесная</option><option value="rose">Розовая</option><option value="graphite">Графитовая</option><option value="solarized">Solarized</option><option value="ocean">Океан</option><option value="neon">Неон</option><option value="copper">Медь</option><option value="mono">Монохром</option>
</select> </select>
<select id="accentSelect" class="accent-select" aria-label="Акцент"> <select id="accentSelect" class="appearance-native" aria-label="Акцент">
<option value="blue">Синий</option> <option value="blue">Синий</option><option value="cyan">Бирюзовый</option><option value="green">Зелёный</option><option value="violet">Фиолетовый</option><option value="orange">Оранжевый</option><option value="red">Красный</option><option value="pink">Розовый</option><option value="lime">Лайм</option><option value="indigo">Индиго</option><option value="teal">Бирюза</option><option value="gold">Золото</option><option value="sky">Небесный</option><option value="coral">Коралл</option><option value="custom">Свой цвет</option>
<option value="cyan">Бирюзовый</option>
<option value="green">Зелёный</option>
<option value="violet">Фиолетовый</option>
<option value="orange">Оранжевый</option>
<option value="red">Красный</option>
<option value="pink">Розовый</option>
<option value="lime">Лайм</option>
<option value="indigo">Индиго</option>
<option value="teal">Бирюза</option>
<option value="gold">Золото</option>
<option value="sky">Небесный</option>
<option value="coral">Коралл</option>
<option value="custom">Свой цвет</option>
</select> </select>
<input id="accentCustom" class="accent-custom" type="color" value="#8fb8ff" aria-label="Произвольный акцент" title="Произвольный акцент"> </section>
<input id="accentIntensity" class="accent-intensity" type="range" min="55" max="100" step="5" value="100" aria-label="Интенсивность акцента" title="Интенсивность акцента"> </div>
<button id="logsToggle" class="header-command" type="button" title="Логи" aria-label="Открыть логи">&#8801;</button> <button id="logsToggle" class="header-command" type="button" title="Логи" aria-label="Открыть логи">&#8801;</button>
</header> </header>
<main> <main>
@ -2772,6 +2806,7 @@ HTML = """<!doctype html>
localStorage.setItem('fpv-accent-custom', base); localStorage.setItem('fpv-accent-custom', base);
localStorage.setItem('fpv-accent-intensity', String(Math.round(strength * 100))); localStorage.setItem('fpv-accent-intensity', String(Math.round(strength * 100)));
} catch (_error) {} } catch (_error) {}
syncAppearanceModule();
} }
function applyTheme(name) { function applyTheme(name) {
const theme = Object.prototype.hasOwnProperty.call(THEMES, name) ? name : 'dark'; const theme = Object.prototype.hasOwnProperty.call(THEMES, name) ? name : 'dark';
@ -2783,6 +2818,67 @@ HTML = """<!doctype html>
try { localStorage.setItem('fpv-theme', theme); } catch (_error) {} try { localStorage.setItem('fpv-theme', theme); } catch (_error) {}
applyAccent($('accentSelect').value || 'blue', $('accentCustom').value); applyAccent($('accentSelect').value || 'blue', $('accentCustom').value);
} }
function setAppearanceOpen(open) {
const panel = $('appearancePanel');
const toggle = $('appearanceToggle');
if (!panel || !toggle) return;
panel.hidden = !open;
toggle.setAttribute('aria-expanded', open ? 'true' : 'false');
}
function syncAppearanceModule() {
const theme = $('themeSelect')?.value || 'dark';
const accent = $('accentSelect')?.value || 'blue';
const themeOption = [...($('themeSelect')?.options || [])].find(option => option.value === theme);
const accentOption = [...($('accentSelect')?.options || [])].find(option => option.value === accent);
const themeLabel = themeOption?.textContent || theme;
const accentLabel = accentOption?.textContent || (accent === 'custom' ? 'Свой цвет' : accent);
setText('appearanceValue', `${themeLabel} · ${accentLabel}`);
setText('themePreview', themeLabel);
setText('accentPreview', accentLabel);
setText('accentIntensityValue', `${$('accentIntensity')?.value || 100}%`);
const dot = $('appearanceToggle')?.querySelector('.appearance-trigger-dot');
if (dot) dot.style.background = document.documentElement.style.getPropertyValue('--ui-accent') || ACCENTS.blue;
document.querySelectorAll('#themeOptions [data-theme-value]').forEach(button => {
const active = button.dataset.themeValue === theme;
button.classList.toggle('is-active', active);
button.setAttribute('aria-selected', active ? 'true' : 'false');
});
document.querySelectorAll('#accentOptions [data-accent-value]').forEach(button => {
const active = button.dataset.accentValue === accent;
button.classList.toggle('is-active', active);
button.setAttribute('aria-selected', active ? 'true' : 'false');
});
}
function renderAppearanceModule() {
const makeOption = (value, label, kind, colors) => {
const button = document.createElement('button');
button.type = 'button';
button.dataset[`${kind}Value`] = value;
button.setAttribute('role', 'option');
button.setAttribute('aria-label', label);
const swatch = document.createElement('span');
swatch.className = 'appearance-swatch';
swatch.style.setProperty('--appearance-swatch', colors[0]);
if (colors[1]) swatch.style.setProperty('--appearance-swatch-2', colors[1]);
const text = document.createElement('span');
text.textContent = label;
button.append(swatch, text);
button.addEventListener('click', () => {
if (kind === 'theme') applyTheme(value);
else applyAccent(value, $('accentCustom').value);
});
return button;
};
const themeOptions = $('themeOptions');
const accentOptions = $('accentOptions');
if (!themeOptions || !accentOptions) return;
themeOptions.replaceChildren(...[...$('themeSelect').options].map(option => {
const palette = THEMES[option.value] || THEMES.dark;
return makeOption(option.value, option.textContent, 'theme', [palette.surface, palette.bg]);
}));
accentOptions.replaceChildren(...[...$('accentSelect').options].filter(option => option.value !== 'custom').map(option => makeOption(option.value, option.textContent, 'accent', [ACCENTS[option.value] || ACCENTS.blue])));
syncAppearanceModule();
}
async function loadModels() { async function loadModels() {
const data = await fetch('/api/models', {cache: 'no-store'}).then(r => r.json()); const data = await fetch('/api/models', {cache: 'no-store'}).then(r => r.json());
const select = $('modelSelect'); const select = $('modelSelect');
@ -3400,9 +3496,22 @@ HTML = """<!doctype html>
if (e.key !== 'Escape') return; if (e.key !== 'Escape') return;
if (!$('playerOverlay').hidden) closePlayer(); if (!$('playerOverlay').hidden) closePlayer();
else if (!$('logDrawer').hidden) setLogsOpen(false); else if (!$('logDrawer').hidden) setLogsOpen(false);
else if (!$('appearancePanel').hidden) setAppearanceOpen(false);
}); });
$('logsToggle').addEventListener('click', () => setLogsOpen($('logDrawer').hidden)); $('logsToggle').addEventListener('click', () => setLogsOpen($('logDrawer').hidden));
$('logsClose').addEventListener('click', () => setLogsOpen(false)); $('logsClose').addEventListener('click', () => setLogsOpen(false));
$('appearanceToggle').addEventListener('click', () => setAppearanceOpen($('appearancePanel').hidden));
$('appearanceClose').addEventListener('click', () => setAppearanceOpen(false));
$('appearanceReset').addEventListener('click', () => {
setValue('themeSelect', 'dark');
setValue('accentSelect', 'blue');
$('accentCustom').value = ACCENTS.blue;
setValue('accentIntensity', '100');
applyTheme('dark');
});
document.addEventListener('pointerdown', e => {
if (!$('appearancePanel').hidden && !e.target.closest('.appearance-module')) setAppearanceOpen(false);
});
document.querySelectorAll('.tab').forEach(btn => { document.querySelectorAll('.tab').forEach(btn => {
btn.addEventListener('click', () => switchTab(btn.dataset.tab)); btn.addEventListener('click', () => switchTab(btn.dataset.tab));
}); });
@ -3636,6 +3745,7 @@ HTML = """<!doctype html>
setValue('accentIntensity', localStorage.getItem('fpv-accent-intensity') || '100'); setValue('accentIntensity', localStorage.getItem('fpv-accent-intensity') || '100');
applyTheme(localStorage.getItem('fpv-theme') || 'dark'); applyTheme(localStorage.getItem('fpv-theme') || 'dark');
} catch (_error) { applyTheme('dark'); } } catch (_error) { applyTheme('dark'); }
renderAppearanceModule();
initCollapsibleSections(); initCollapsibleSections();
initConfigCanvas(); initConfigCanvas();
loadArchive(); loadArchive();

Loading…
Cancel
Save