diff --git a/ui_server.py b/ui_server.py index eb8561b..abc2e1d 100644 --- a/ui_server.py +++ b/ui_server.py @@ -1363,6 +1363,52 @@ HTML = """ @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 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)); } + } @@ -1376,38 +1422,26 @@ HTML = """ - - - - +
+ + +
@@ -2772,6 +2806,7 @@ HTML = """ localStorage.setItem('fpv-accent-custom', base); localStorage.setItem('fpv-accent-intensity', String(Math.round(strength * 100))); } catch (_error) {} + syncAppearanceModule(); } function applyTheme(name) { const theme = Object.prototype.hasOwnProperty.call(THEMES, name) ? name : 'dark'; @@ -2783,6 +2818,67 @@ HTML = """ try { localStorage.setItem('fpv-theme', theme); } catch (_error) {} 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() { const data = await fetch('/api/models', {cache: 'no-store'}).then(r => r.json()); const select = $('modelSelect'); @@ -3400,9 +3496,22 @@ HTML = """ if (e.key !== 'Escape') return; if (!$('playerOverlay').hidden) closePlayer(); else if (!$('logDrawer').hidden) setLogsOpen(false); + else if (!$('appearancePanel').hidden) setAppearanceOpen(false); }); $('logsToggle').addEventListener('click', () => setLogsOpen($('logDrawer').hidden)); $('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 => { btn.addEventListener('click', () => switchTab(btn.dataset.tab)); }); @@ -3636,6 +3745,7 @@ HTML = """ setValue('accentIntensity', localStorage.getItem('fpv-accent-intensity') || '100'); applyTheme(localStorage.getItem('fpv-theme') || 'dark'); } catch (_error) { applyTheme('dark'); } + renderAppearanceModule(); initCollapsibleSections(); initConfigCanvas(); loadArchive();