Add reusable icon-grid type selector for CSS source editor

Replaces the plain <select> dropdown with a visual grid popup showing
icon, label, and description for each source type. The IconSelect
component is generic and reusable for other type selectors.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-08 23:15:39 +03:00
parent d6bda9afed
commit d95eb683e1
7 changed files with 350 additions and 2 deletions

View File

@@ -462,6 +462,124 @@ textarea:focus-visible {
padding: 6px 10px; padding: 6px 10px;
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: var(--radius); border-radius: var(--radius);
background: var(--bg-primary); background: var(--bg-secondary);
font-size: 0.9rem; font-size: 0.9rem;
} }
/* ── Icon Select (reusable type picker) ──────────────────────── */
.icon-select-trigger {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 10px;
border: 1px solid var(--border-color);
border-radius: 4px;
background: var(--bg-color);
color: var(--text-color);
font-size: 1rem;
cursor: pointer;
transition: border-color 0.15s;
text-align: left;
}
.icon-select-trigger:hover {
border-color: var(--primary-color);
}
.icon-select-trigger-icon {
display: flex;
align-items: center;
flex-shrink: 0;
}
.icon-select-trigger-icon .icon {
width: 18px;
height: 18px;
}
.icon-select-trigger-label {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.icon-select-trigger-arrow {
flex-shrink: 0;
font-size: 0.8rem;
opacity: 0.5;
margin-left: auto;
}
.icon-select-popup {
position: relative;
max-height: 0;
overflow: hidden;
opacity: 0;
transition: max-height 0.2s ease, opacity 0.15s ease, margin 0.2s ease;
margin-top: 0;
}
.icon-select-popup.open {
max-height: 600px;
opacity: 1;
margin-top: 6px;
}
.icon-select-grid {
display: grid;
gap: 6px;
padding: 6px;
border: 1px solid var(--border-color);
border-radius: var(--radius);
background: var(--bg-color);
}
.icon-select-cell {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 10px 6px;
border: 2px solid transparent;
border-radius: var(--radius);
background: var(--bg-secondary);
cursor: pointer;
transition: border-color 0.15s, background 0.15s, transform 0.1s;
text-align: center;
}
.icon-select-cell:hover {
border-color: var(--primary-color);
transform: scale(1.03);
}
.icon-select-cell.active {
border-color: var(--primary-color);
background: color-mix(in srgb, var(--primary-color) 12%, var(--bg-secondary));
}
.icon-select-cell-icon {
display: flex;
align-items: center;
justify-content: center;
}
.icon-select-cell-icon .icon {
width: 24px;
height: 24px;
}
.icon-select-cell.active .icon-select-cell-icon .icon {
stroke: var(--primary-color);
}
.icon-select-cell-label {
font-size: 0.85rem;
font-weight: 600;
line-height: 1.2;
}
.icon-select-cell-desc {
font-size: 0.72rem;
opacity: 0.6;
line-height: 1.3;
}
/* Hide descriptions on narrow screens */
@media (max-width: 480px) {
.icon-select-cell-desc { display: none; }
.icon-select-cell { padding: 8px 4px; }
.icon-select-grid { gap: 4px; padding: 4px; }
}

View File

@@ -0,0 +1,160 @@
/**
* Reusable icon-grid selector (replaces a plain <select>).
*
* Usage:
* import { IconSelect } from '../core/icon-select.js';
*
* const sel = new IconSelect({
* target: document.getElementById('my-select'), // the <select> to enhance
* items: [
* { value: 'fire', icon: '<svg>…</svg>', label: 'Fire', desc: 'Warm flickering effect' },
* { value: 'water', icon: '<svg>…</svg>', label: 'Water', desc: 'Cool flowing colors' },
* ],
* onChange: (value) => { … }, // optional callback after selection
* columns: 2, // grid columns (default: 2)
* });
*
* The original <select> is hidden but stays in the DOM (value kept in sync).
* Call sel.setValue(v) to change programmatically, sel.destroy() to remove.
*/
import { t } from './i18n.js';
const POPUP_CLASS = 'icon-select-popup';
/** Close every open icon-select popup. */
export function closeAllIconSelects() {
document.querySelectorAll(`.${POPUP_CLASS}`).forEach(p => {
p.classList.remove('open');
});
}
// Global click-away listener (registered once)
let _globalListenerAdded = false;
function _ensureGlobalListener() {
if (_globalListenerAdded) return;
_globalListenerAdded = true;
document.addEventListener('click', (e) => {
if (!e.target.closest(`.${POPUP_CLASS}`) && !e.target.closest('.icon-select-trigger')) {
closeAllIconSelects();
}
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeAllIconSelects();
});
}
export class IconSelect {
/**
* @param {Object} opts
* @param {HTMLSelectElement} opts.target - the <select> element to enhance
* @param {Array<{value:string, icon:string, label:string, desc?:string}>} opts.items
* @param {Function} [opts.onChange] - called with (value) after user picks
* @param {number} [opts.columns=2] - grid column count
*/
constructor({ target, items, onChange, columns = 2 }) {
_ensureGlobalListener();
this._select = target;
this._items = items;
this._onChange = onChange;
this._columns = columns;
// Hide the native select
this._select.style.display = 'none';
// Build trigger button
this._trigger = document.createElement('button');
this._trigger.type = 'button';
this._trigger.className = 'icon-select-trigger';
this._trigger.addEventListener('click', (e) => {
e.stopPropagation();
this._toggle();
});
this._select.parentNode.insertBefore(this._trigger, this._select.nextSibling);
// Build popup
this._popup = document.createElement('div');
this._popup.className = POPUP_CLASS;
this._popup.addEventListener('click', (e) => e.stopPropagation());
this._popup.innerHTML = this._buildGrid();
this._select.parentNode.insertBefore(this._popup, this._trigger.nextSibling);
// Bind item clicks
this._popup.querySelectorAll('.icon-select-cell').forEach(cell => {
cell.addEventListener('click', () => {
this.setValue(cell.dataset.value, true);
this._popup.classList.remove('open');
});
});
// Sync to current select value
this._syncTrigger();
}
_buildGrid() {
const cells = this._items.map(item => {
return `<div class="icon-select-cell" data-value="${item.value}">
<span class="icon-select-cell-icon">${item.icon}</span>
<span class="icon-select-cell-label">${item.label}</span>
${item.desc ? `<span class="icon-select-cell-desc">${item.desc}</span>` : ''}
</div>`;
}).join('');
return `<div class="icon-select-grid" style="grid-template-columns:repeat(${this._columns},1fr)">${cells}</div>`;
}
_syncTrigger() {
const val = this._select.value;
const item = this._items.find(i => i.value === val);
if (item) {
this._trigger.innerHTML =
`<span class="icon-select-trigger-icon">${item.icon}</span>` +
`<span class="icon-select-trigger-label">${item.label}</span>` +
`<span class="icon-select-trigger-arrow">&#x25BE;</span>`;
}
// Update active state in grid
this._popup.querySelectorAll('.icon-select-cell').forEach(cell => {
cell.classList.toggle('active', cell.dataset.value === val);
});
}
_toggle() {
const wasOpen = this._popup.classList.contains('open');
closeAllIconSelects();
if (!wasOpen) {
this._popup.classList.add('open');
}
}
/** Change the value programmatically. */
setValue(value, fireChange = false) {
this._select.value = value;
this._syncTrigger();
if (fireChange) {
// Fire native change event so existing onchange handlers work
this._select.dispatchEvent(new Event('change', { bubbles: true }));
if (this._onChange) this._onChange(value);
}
}
/** Refresh labels (e.g. after language change). Call with new items array. */
updateItems(items) {
this._items = items;
this._popup.innerHTML = this._buildGrid();
this._popup.querySelectorAll('.icon-select-cell').forEach(cell => {
cell.addEventListener('click', () => {
this.setValue(cell.dataset.value, true);
this._popup.classList.remove('open');
});
});
this._syncTrigger();
}
/** Remove the enhancement, restore native <select>. */
destroy() {
this._trigger.remove();
this._popup.remove();
this._select.style.display = '';
}
}

View File

@@ -16,6 +16,7 @@ import {
} from '../core/icons.js'; } from '../core/icons.js';
import { wrapCard } from '../core/card-colors.js'; import { wrapCard } from '../core/card-colors.js';
import { attachProcessPicker } from '../core/process-picker.js'; import { attachProcessPicker } from '../core/process-picker.js';
import { IconSelect } from '../core/icon-select.js';
class CSSEditorModal extends Modal { class CSSEditorModal extends Modal {
constructor() { constructor() {
@@ -70,10 +71,46 @@ class CSSEditorModal extends Modal {
const cssEditorModal = new CSSEditorModal(); const cssEditorModal = new CSSEditorModal();
/* ── Icon-grid type selector ──────────────────────────────────── */
const CSS_TYPE_KEYS = [
'picture', 'static', 'gradient', 'color_cycle',
'effect', 'composite', 'mapped', 'audio',
'api_input', 'notification',
];
function _buildCSSTypeItems() {
return CSS_TYPE_KEYS.map(key => ({
value: key,
icon: getColorStripIcon(key),
label: t(`color_strip.type.${key}`),
desc: t(`color_strip.type.${key}.desc`),
}));
}
let _cssTypeIconSelect = null;
function _ensureCSSTypeIconSelect() {
const sel = document.getElementById('css-editor-type');
if (!sel) return;
if (_cssTypeIconSelect) {
// Refresh labels (language may have changed)
_cssTypeIconSelect.updateItems(_buildCSSTypeItems());
return;
}
_cssTypeIconSelect = new IconSelect({
target: sel,
items: _buildCSSTypeItems(),
columns: 2,
});
}
/* ── Type-switch helper ───────────────────────────────────────── */ /* ── Type-switch helper ───────────────────────────────────────── */
export function onCSSTypeChange() { export function onCSSTypeChange() {
const type = document.getElementById('css-editor-type').value; const type = document.getElementById('css-editor-type').value;
// Sync icon-select trigger display
if (_cssTypeIconSelect) _cssTypeIconSelect.setValue(type);
document.getElementById('css-editor-picture-section').style.display = type === 'picture' ? '' : 'none'; document.getElementById('css-editor-picture-section').style.display = type === 'picture' ? '' : 'none';
document.getElementById('css-editor-static-section').style.display = type === 'static' ? '' : 'none'; document.getElementById('css-editor-static-section').style.display = type === 'static' ? '' : 'none';
document.getElementById('css-editor-color-cycle-section').style.display = type === 'color_cycle' ? '' : 'none'; document.getElementById('css-editor-color-cycle-section').style.display = type === 'color_cycle' ? '' : 'none';
@@ -967,6 +1004,9 @@ export async function showCSSEditor(cssId = null, cloneData = null) {
document.getElementById('css-editor-led-count').value = css.led_count ?? 0; document.getElementById('css-editor-led-count').value = css.led_count ?? 0;
}; };
// Initialize icon-grid type selector (idempotent)
_ensureCSSTypeIconSelect();
// Hide type selector in edit mode (type is immutable) // Hide type selector in edit mode (type is immutable)
document.getElementById('css-editor-type-group').style.display = cssId ? 'none' : ''; document.getElementById('css-editor-type-group').style.display = cssId ? 'none' : '';

View File

@@ -733,9 +733,13 @@
"color_strip.type": "Type:", "color_strip.type": "Type:",
"color_strip.type.hint": "Picture Source derives LED colors from a screen capture. Static Color fills all LEDs with a single constant color. Gradient distributes a color gradient across all LEDs. Color Cycle smoothly cycles through a user-defined list of colors. Composite stacks multiple sources as blended layers. Audio Reactive drives LEDs from real-time audio input. API Input receives raw LED colors from external clients via REST or WebSocket.", "color_strip.type.hint": "Picture Source derives LED colors from a screen capture. Static Color fills all LEDs with a single constant color. Gradient distributes a color gradient across all LEDs. Color Cycle smoothly cycles through a user-defined list of colors. Composite stacks multiple sources as blended layers. Audio Reactive drives LEDs from real-time audio input. API Input receives raw LED colors from external clients via REST or WebSocket.",
"color_strip.type.picture": "Picture Source", "color_strip.type.picture": "Picture Source",
"color_strip.type.picture.desc": "Colors from screen capture",
"color_strip.type.static": "Static Color", "color_strip.type.static": "Static Color",
"color_strip.type.static.desc": "Single solid color fill",
"color_strip.type.gradient": "Gradient", "color_strip.type.gradient": "Gradient",
"color_strip.type.gradient.desc": "Smooth color transition across LEDs",
"color_strip.type.color_cycle": "Color Cycle", "color_strip.type.color_cycle": "Color Cycle",
"color_strip.type.color_cycle.desc": "Cycle through a list of colors",
"color_strip.static_color": "Color:", "color_strip.static_color": "Color:",
"color_strip.static_color.hint": "The solid color that will be sent to all LEDs on the strip.", "color_strip.static_color.hint": "The solid color that will be sent to all LEDs on the strip.",
"color_strip.gradient.preview": "Gradient:", "color_strip.gradient.preview": "Gradient:",
@@ -793,14 +797,19 @@
"color_strip.color_cycle.speed.hint": "Cycle speed multiplier. 1.0 ≈ one full cycle every 20 seconds; higher values cycle faster.", "color_strip.color_cycle.speed.hint": "Cycle speed multiplier. 1.0 ≈ one full cycle every 20 seconds; higher values cycle faster.",
"color_strip.color_cycle.min_colors": "Color cycle must have at least 2 colors", "color_strip.color_cycle.min_colors": "Color cycle must have at least 2 colors",
"color_strip.type.effect": "Effect", "color_strip.type.effect": "Effect",
"color_strip.type.effect.desc": "Procedural effects like fire, plasma, aurora",
"color_strip.type.effect.hint": "Procedural LED effects (fire, meteor, plasma, noise, aurora) generated in real time.", "color_strip.type.effect.hint": "Procedural LED effects (fire, meteor, plasma, noise, aurora) generated in real time.",
"color_strip.type.composite": "Composite", "color_strip.type.composite": "Composite",
"color_strip.type.composite.desc": "Stack and blend multiple sources",
"color_strip.type.composite.hint": "Stack multiple color strip sources as layers with blend modes and opacity.", "color_strip.type.composite.hint": "Stack multiple color strip sources as layers with blend modes and opacity.",
"color_strip.type.mapped": "Mapped", "color_strip.type.mapped": "Mapped",
"color_strip.type.mapped.desc": "Assign sources to LED zones",
"color_strip.type.mapped.hint": "Assign different color strip sources to different LED ranges (zones). Unlike composite which blends layers, mapped places sources side-by-side.", "color_strip.type.mapped.hint": "Assign different color strip sources to different LED ranges (zones). Unlike composite which blends layers, mapped places sources side-by-side.",
"color_strip.type.audio": "Audio Reactive", "color_strip.type.audio": "Audio Reactive",
"color_strip.type.audio.desc": "LEDs driven by audio input",
"color_strip.type.audio.hint": "LED colors driven by real-time audio input — system audio or microphone.", "color_strip.type.audio.hint": "LED colors driven by real-time audio input — system audio or microphone.",
"color_strip.type.api_input": "API Input", "color_strip.type.api_input": "API Input",
"color_strip.type.api_input.desc": "Receive colors from external apps",
"color_strip.type.api_input.hint": "Receives raw LED color arrays from external clients via REST POST or WebSocket. Use this to integrate with custom software, home automation, or any system that can send HTTP requests.", "color_strip.type.api_input.hint": "Receives raw LED color arrays from external clients via REST POST or WebSocket. Use this to integrate with custom software, home automation, or any system that can send HTTP requests.",
"color_strip.api_input.fallback_color": "Fallback Color:", "color_strip.api_input.fallback_color": "Fallback Color:",
"color_strip.api_input.fallback_color.hint": "Color to display when no data has been received within the timeout period. LEDs will show this color on startup and after the connection is lost.", "color_strip.api_input.fallback_color.hint": "Color to display when no data has been received within the timeout period. LEDs will show this color on startup and after the connection is lost.",
@@ -810,6 +819,7 @@
"color_strip.api_input.endpoints.hint": "Use these URLs to push LED color data from your external application. REST accepts JSON, WebSocket accepts both JSON and raw binary frames.", "color_strip.api_input.endpoints.hint": "Use these URLs to push LED color data from your external application. REST accepts JSON, WebSocket accepts both JSON and raw binary frames.",
"color_strip.api_input.save_first": "Save the source first to see the push endpoint URLs.", "color_strip.api_input.save_first": "Save the source first to see the push endpoint URLs.",
"color_strip.type.notification": "Notification", "color_strip.type.notification": "Notification",
"color_strip.type.notification.desc": "One-shot effect on webhook trigger",
"color_strip.type.notification.hint": "Fires a one-shot visual effect (flash, pulse, sweep) when triggered via a webhook. Designed for use as a composite layer over a persistent base source.", "color_strip.type.notification.hint": "Fires a one-shot visual effect (flash, pulse, sweep) when triggered via a webhook. Designed for use as a composite layer over a persistent base source.",
"color_strip.notification.effect": "Effect:", "color_strip.notification.effect": "Effect:",
"color_strip.notification.effect.hint": "Visual effect when a notification fires. Flash fades linearly, Pulse uses a smooth bell curve, Sweep fills LEDs left-to-right then fades.", "color_strip.notification.effect.hint": "Visual effect when a notification fires. Flash fades linearly, Pulse uses a smooth bell curve, Sweep fills LEDs left-to-right then fades.",

View File

@@ -733,9 +733,13 @@
"color_strip.type": "Тип:", "color_strip.type": "Тип:",
"color_strip.type.hint": "Источник изображения получает цвета светодиодов из захвата экрана. Статический цвет заполняет все светодиоды одним постоянным цветом. Градиент распределяет цветовой градиент по всем светодиодам. Смена цвета плавно циклически переключается между заданными цветами. Композит накладывает несколько источников как смешанные слои. Аудиореактив управляет LED от аудиосигнала в реальном времени. API-ввод принимает массивы цветов LED от внешних клиентов через REST или WebSocket.", "color_strip.type.hint": "Источник изображения получает цвета светодиодов из захвата экрана. Статический цвет заполняет все светодиоды одним постоянным цветом. Градиент распределяет цветовой градиент по всем светодиодам. Смена цвета плавно циклически переключается между заданными цветами. Композит накладывает несколько источников как смешанные слои. Аудиореактив управляет LED от аудиосигнала в реальном времени. API-ввод принимает массивы цветов LED от внешних клиентов через REST или WebSocket.",
"color_strip.type.picture": "Источник изображения", "color_strip.type.picture": "Источник изображения",
"color_strip.type.picture.desc": "Цвета из захвата экрана",
"color_strip.type.static": "Статический цвет", "color_strip.type.static": "Статический цвет",
"color_strip.type.static.desc": "Заливка одним цветом",
"color_strip.type.gradient": "Градиент", "color_strip.type.gradient": "Градиент",
"color_strip.type.gradient.desc": "Плавный переход цветов по ленте",
"color_strip.type.color_cycle": "Смена цвета", "color_strip.type.color_cycle": "Смена цвета",
"color_strip.type.color_cycle.desc": "Циклическая смена списка цветов",
"color_strip.static_color": "Цвет:", "color_strip.static_color": "Цвет:",
"color_strip.static_color.hint": "Статический цвет, который будет отправлен на все светодиоды полосы.", "color_strip.static_color.hint": "Статический цвет, который будет отправлен на все светодиоды полосы.",
"color_strip.gradient.preview": "Градиент:", "color_strip.gradient.preview": "Градиент:",
@@ -793,14 +797,19 @@
"color_strip.color_cycle.speed.hint": "Множитель скорости смены. 1.0 ≈ один полный цикл за 20 секунд; большие значения ускоряют смену.", "color_strip.color_cycle.speed.hint": "Множитель скорости смены. 1.0 ≈ один полный цикл за 20 секунд; большие значения ускоряют смену.",
"color_strip.color_cycle.min_colors": "Смена цвета должна содержать не менее 2 цветов", "color_strip.color_cycle.min_colors": "Смена цвета должна содержать не менее 2 цветов",
"color_strip.type.effect": "Эффект", "color_strip.type.effect": "Эффект",
"color_strip.type.effect.desc": "Процедурные эффекты: огонь, плазма, аврора",
"color_strip.type.effect.hint": "Процедурные LED-эффекты (огонь, метеор, плазма, шум, аврора), генерируемые в реальном времени.", "color_strip.type.effect.hint": "Процедурные LED-эффекты (огонь, метеор, плазма, шум, аврора), генерируемые в реальном времени.",
"color_strip.type.composite": "Композит", "color_strip.type.composite": "Композит",
"color_strip.type.composite.desc": "Наложение и смешивание источников",
"color_strip.type.composite.hint": "Наложение нескольких источников цветовой ленты как слоёв с режимами смешивания и прозрачностью.", "color_strip.type.composite.hint": "Наложение нескольких источников цветовой ленты как слоёв с режимами смешивания и прозрачностью.",
"color_strip.type.mapped": "Маппинг", "color_strip.type.mapped": "Маппинг",
"color_strip.type.mapped.desc": "Назначение источников на зоны LED",
"color_strip.type.mapped.hint": "Назначает разные источники цветовой полосы на разные диапазоны LED (зоны). В отличие от композита, маппинг размещает источники рядом друг с другом.", "color_strip.type.mapped.hint": "Назначает разные источники цветовой полосы на разные диапазоны LED (зоны). В отличие от композита, маппинг размещает источники рядом друг с другом.",
"color_strip.type.audio": "Аудиореактив", "color_strip.type.audio": "Аудиореактив",
"color_strip.type.audio.desc": "LED от аудиосигнала",
"color_strip.type.audio.hint": "Цвета LED управляются аудиосигналом в реальном времени — системный звук или микрофон.", "color_strip.type.audio.hint": "Цвета LED управляются аудиосигналом в реальном времени — системный звук или микрофон.",
"color_strip.type.api_input": "API-ввод", "color_strip.type.api_input": "API-ввод",
"color_strip.type.api_input.desc": "Приём цветов от внешних приложений",
"color_strip.type.api_input.hint": "Принимает массивы цветов LED от внешних клиентов через REST POST или WebSocket. Используйте для интеграции с собственным ПО, домашней автоматизацией или любой системой, способной отправлять HTTP-запросы.", "color_strip.type.api_input.hint": "Принимает массивы цветов LED от внешних клиентов через REST POST или WebSocket. Используйте для интеграции с собственным ПО, домашней автоматизацией или любой системой, способной отправлять HTTP-запросы.",
"color_strip.api_input.fallback_color": "Цвет по умолчанию:", "color_strip.api_input.fallback_color": "Цвет по умолчанию:",
"color_strip.api_input.fallback_color.hint": "Цвет для отображения, когда данные не получены в течение периода ожидания. LED покажут этот цвет при запуске и после потери соединения.", "color_strip.api_input.fallback_color.hint": "Цвет для отображения, когда данные не получены в течение периода ожидания. LED покажут этот цвет при запуске и после потери соединения.",
@@ -810,6 +819,7 @@
"color_strip.api_input.endpoints.hint": "Используйте эти URL для отправки данных о цветах LED из вашего внешнего приложения. REST принимает JSON, WebSocket принимает как JSON, так и бинарные кадры.", "color_strip.api_input.endpoints.hint": "Используйте эти URL для отправки данных о цветах LED из вашего внешнего приложения. REST принимает JSON, WebSocket принимает как JSON, так и бинарные кадры.",
"color_strip.api_input.save_first": "Сначала сохраните источник, чтобы увидеть URL эндпоинтов.", "color_strip.api_input.save_first": "Сначала сохраните источник, чтобы увидеть URL эндпоинтов.",
"color_strip.type.notification": "Уведомления", "color_strip.type.notification": "Уведомления",
"color_strip.type.notification.desc": "Разовый эффект по вебхуку",
"color_strip.type.notification.hint": "Вспышка, пульс или волна при срабатывании через вебхук. Предназначен для использования как слой в композитном источнике.", "color_strip.type.notification.hint": "Вспышка, пульс или волна при срабатывании через вебхук. Предназначен для использования как слой в композитном источнике.",
"color_strip.notification.effect": "Эффект:", "color_strip.notification.effect": "Эффект:",
"color_strip.notification.effect.hint": "Визуальный эффект при уведомлении. Вспышка — линейное затухание, Пульс — плавная волна, Волна — заполнение и затухание.", "color_strip.notification.effect.hint": "Визуальный эффект при уведомлении. Вспышка — линейное затухание, Пульс — плавная волна, Волна — заполнение и затухание.",

View File

@@ -733,9 +733,13 @@
"color_strip.type": "类型:", "color_strip.type": "类型:",
"color_strip.type.hint": "图片源从屏幕采集推导 LED 颜色。静态颜色用单一颜色填充所有 LED。渐变在所有 LED 上分布颜色渐变。颜色循环平滑循环用户定义的颜色列表。组合将多个源作为混合图层叠加。音频响应从实时音频输入驱动 LED。API 输入通过 REST 或 WebSocket 从外部客户端接收原始 LED 颜色。", "color_strip.type.hint": "图片源从屏幕采集推导 LED 颜色。静态颜色用单一颜色填充所有 LED。渐变在所有 LED 上分布颜色渐变。颜色循环平滑循环用户定义的颜色列表。组合将多个源作为混合图层叠加。音频响应从实时音频输入驱动 LED。API 输入通过 REST 或 WebSocket 从外部客户端接收原始 LED 颜色。",
"color_strip.type.picture": "图片源", "color_strip.type.picture": "图片源",
"color_strip.type.picture.desc": "从屏幕捕获获取颜色",
"color_strip.type.static": "静态颜色", "color_strip.type.static": "静态颜色",
"color_strip.type.static.desc": "单色填充",
"color_strip.type.gradient": "渐变", "color_strip.type.gradient": "渐变",
"color_strip.type.gradient.desc": "LED上的平滑颜色过渡",
"color_strip.type.color_cycle": "颜色循环", "color_strip.type.color_cycle": "颜色循环",
"color_strip.type.color_cycle.desc": "循环切换颜色列表",
"color_strip.static_color": "颜色:", "color_strip.static_color": "颜色:",
"color_strip.static_color.hint": "将发送到灯带上所有 LED 的纯色。", "color_strip.static_color.hint": "将发送到灯带上所有 LED 的纯色。",
"color_strip.gradient.preview": "渐变:", "color_strip.gradient.preview": "渐变:",
@@ -793,14 +797,19 @@
"color_strip.color_cycle.speed.hint": "循环速度倍数。1.0 ≈ 每 20 秒一个完整循环;更高值循环更快。", "color_strip.color_cycle.speed.hint": "循环速度倍数。1.0 ≈ 每 20 秒一个完整循环;更高值循环更快。",
"color_strip.color_cycle.min_colors": "颜色循环至少需要 2 种颜色", "color_strip.color_cycle.min_colors": "颜色循环至少需要 2 种颜色",
"color_strip.type.effect": "效果", "color_strip.type.effect": "效果",
"color_strip.type.effect.desc": "程序化效果:火焰、等离子、极光",
"color_strip.type.effect.hint": "实时生成的程序化 LED 效果(火焰、流星、等离子、噪声、极光)。", "color_strip.type.effect.hint": "实时生成的程序化 LED 效果(火焰、流星、等离子、噪声、极光)。",
"color_strip.type.composite": "组合", "color_strip.type.composite": "组合",
"color_strip.type.composite.desc": "叠加和混合多个源",
"color_strip.type.composite.hint": "将多个色带源作为图层叠加,支持混合模式和不透明度。", "color_strip.type.composite.hint": "将多个色带源作为图层叠加,支持混合模式和不透明度。",
"color_strip.type.mapped": "映射", "color_strip.type.mapped": "映射",
"color_strip.type.mapped.desc": "为LED区域分配源",
"color_strip.type.mapped.hint": "将不同色带源分配到不同 LED 范围(区域)。与组合的图层混合不同,映射将源并排放置。", "color_strip.type.mapped.hint": "将不同色带源分配到不同 LED 范围(区域)。与组合的图层混合不同,映射将源并排放置。",
"color_strip.type.audio": "音频响应", "color_strip.type.audio": "音频响应",
"color_strip.type.audio.desc": "由音频输入驱动LED",
"color_strip.type.audio.hint": "LED 颜色由实时音频输入驱动 — 系统音频或麦克风。", "color_strip.type.audio.hint": "LED 颜色由实时音频输入驱动 — 系统音频或麦克风。",
"color_strip.type.api_input": "API 输入", "color_strip.type.api_input": "API 输入",
"color_strip.type.api_input.desc": "从外部应用接收颜色",
"color_strip.type.api_input.hint": "通过 REST POST 或 WebSocket 从外部客户端接收原始 LED 颜色数组。用于与自定义软件、家庭自动化或任何能发送 HTTP 请求的系统集成。", "color_strip.type.api_input.hint": "通过 REST POST 或 WebSocket 从外部客户端接收原始 LED 颜色数组。用于与自定义软件、家庭自动化或任何能发送 HTTP 请求的系统集成。",
"color_strip.api_input.fallback_color": "备用颜色:", "color_strip.api_input.fallback_color": "备用颜色:",
"color_strip.api_input.fallback_color.hint": "超时未收到数据时显示的颜色。启动时和连接丢失后 LED 将显示此颜色。", "color_strip.api_input.fallback_color.hint": "超时未收到数据时显示的颜色。启动时和连接丢失后 LED 将显示此颜色。",
@@ -810,6 +819,7 @@
"color_strip.api_input.endpoints.hint": "使用这些 URL 从外部应用程序推送 LED 颜色数据。REST 接受 JSONWebSocket 接受 JSON 和原始二进制帧。", "color_strip.api_input.endpoints.hint": "使用这些 URL 从外部应用程序推送 LED 颜色数据。REST 接受 JSONWebSocket 接受 JSON 和原始二进制帧。",
"color_strip.api_input.save_first": "请先保存源以查看推送端点 URL。", "color_strip.api_input.save_first": "请先保存源以查看推送端点 URL。",
"color_strip.type.notification": "通知", "color_strip.type.notification": "通知",
"color_strip.type.notification.desc": "通过Webhook触发的一次性效果",
"color_strip.type.notification.hint": "通过 Webhook 触发时显示一次性视觉效果(闪烁、脉冲、扫描)。设计为组合源中的叠加层。", "color_strip.type.notification.hint": "通过 Webhook 触发时显示一次性视觉效果(闪烁、脉冲、扫描)。设计为组合源中的叠加层。",
"color_strip.notification.effect": "效果:", "color_strip.notification.effect": "效果:",
"color_strip.notification.effect.hint": "通知触发时的视觉效果。闪烁线性衰减,脉冲平滑钟形曲线,扫描从左到右填充后衰减。", "color_strip.notification.effect.hint": "通知触发时的视觉效果。闪烁线性衰减,脉冲平滑钟形曲线,扫描从左到右填充后衰减。",

View File

@@ -7,7 +7,7 @@
* - Navigation: network-first with offline fallback * - Navigation: network-first with offline fallback
*/ */
const CACHE_NAME = 'ledgrab-v14'; const CACHE_NAME = 'ledgrab-v15';
// Only pre-cache static assets (no auth required). // Only pre-cache static assets (no auth required).
// Do NOT pre-cache '/' — it requires API key auth and would cache an error page. // Do NOT pre-cache '/' — it requires API key auth and would cache an error page.