feat: add auto-name generation to all remaining creation modals
Some checks failed
Lint & Test / test (push) Failing after 27s

Audio sources: type + device/parent/channel/band detail
Weather sources: provider + coordinates (updates on geolocation)
Sync clocks: "Sync Clocks · Nx" (updates on speed slider)
Automations: scene name + condition count/logic
Scene presets: "Scenes · N targets" (updates on add/remove)
Pattern templates: "Pattern Templates · N rects" (updates on add/remove)

All follow the same pattern: name auto-generates on create, stops
when user manually edits the name field.
This commit is contained in:
2026-03-24 21:44:28 +03:00
parent d6f796a499
commit 347b252f06
7 changed files with 163 additions and 6 deletions

View File

@@ -70,6 +70,8 @@ For `EntitySelect` with `allowNone: true`, pass the same i18n string as `noneLab
### Enhanced selectors (IconSelect & EntitySelect)
**IMPORTANT:** Always use icon grid or entity pickers instead of plain `<select>` dropdowns wherever appropriate. Plain HTML selects break the visual consistency of the UI. Any selector with a small fixed set of options (types, modes, presets, bands) should use `IconSelect`; any selector referencing dynamic entities should use `EntitySelect`.
Plain `<select>` dropdowns should be enhanced with visual selectors depending on the data type:
- **Predefined options** (source types, effect types, palettes, waveforms, viz modes) → use `IconSelect` from `js/core/icon-select.ts`. This replaces the `<select>` with a visual grid of icon+label+description cells. See `_ensureCSSTypeIconSelect()`, `_ensureEffectTypeIconSelect()`, `_ensureInterpolationIconSelect()` in `color-strips.ts` for examples.

View File

@@ -70,6 +70,37 @@ function _buildBandItems() {
];
}
// ── Auto-name generation ──────────────────────────────────────
let _asNameManuallyEdited = false;
function _autoGenerateAudioSourceName() {
if (_asNameManuallyEdited) return;
if ((document.getElementById('audio-source-id') as HTMLInputElement).value) return;
const type = (document.getElementById('audio-source-type') as HTMLSelectElement).value;
let name = '';
if (type === 'multichannel') {
const devSel = document.getElementById('audio-source-device') as HTMLSelectElement | null;
const devName = devSel?.selectedOptions[0]?.textContent?.trim();
name = devName || t('audio_source.type.multichannel');
} else if (type === 'mono') {
const parentSel = document.getElementById('audio-source-parent') as HTMLSelectElement | null;
const parentName = parentSel?.selectedOptions[0]?.textContent?.trim() || '';
const ch = (document.getElementById('audio-source-channel') as HTMLSelectElement).value;
const chLabel = ch === 'left' ? 'L' : ch === 'right' ? 'R' : 'M';
name = parentName ? `${parentName} · ${chLabel}` : t('audio_source.type.mono');
} else if (type === 'band_extract') {
const parentSel = document.getElementById('audio-source-band-parent') as HTMLSelectElement | null;
const parentName = parentSel?.selectedOptions[0]?.textContent?.trim() || '';
const band = (document.getElementById('audio-source-band') as HTMLSelectElement).value;
const bandLabel = band === 'custom'
? `${(document.getElementById('audio-source-freq-low') as HTMLInputElement).value}${(document.getElementById('audio-source-freq-high') as HTMLInputElement).value} Hz`
: t(`audio_source.band.${band}`);
name = parentName ? `${parentName} · ${bandLabel}` : bandLabel;
}
(document.getElementById('audio-source-name') as HTMLInputElement).value = name;
}
// ── Modal ─────────────────────────────────────────────────────
const _titleKeys: Record<string, Record<string, string>> = {
@@ -99,12 +130,13 @@ export async function showAudioSourceModal(sourceType: any, editData?: any) {
if (editData.source_type === 'multichannel') {
_loadAudioTemplates(editData.audio_template_id);
(document.getElementById('audio-source-audio-template') as HTMLSelectElement).onchange = _filterDevicesBySelectedTemplate;
(document.getElementById('audio-source-audio-template') as HTMLSelectElement).onchange = () => { _filterDevicesBySelectedTemplate(); _autoGenerateAudioSourceName(); };
await _loadAudioDevices();
_selectAudioDevice(editData.device_index, editData.is_loopback);
} else if (editData.source_type === 'mono') {
_loadMultichannelSources(editData.audio_source_id);
(document.getElementById('audio-source-channel') as HTMLSelectElement).value = editData.channel || 'mono';
(document.getElementById('audio-source-channel') as HTMLSelectElement).onchange = () => _autoGenerateAudioSourceName();
} else if (editData.source_type === 'band_extract') {
_loadBandParentSources(editData.audio_source_id);
(document.getElementById('audio-source-band') as HTMLSelectElement).value = editData.band || 'bass';
@@ -119,10 +151,11 @@ export async function showAudioSourceModal(sourceType: any, editData?: any) {
if (sourceType === 'multichannel') {
_loadAudioTemplates();
(document.getElementById('audio-source-audio-template') as HTMLSelectElement).onchange = _filterDevicesBySelectedTemplate;
(document.getElementById('audio-source-audio-template') as HTMLSelectElement).onchange = () => { _filterDevicesBySelectedTemplate(); _autoGenerateAudioSourceName(); };
await _loadAudioDevices();
} else if (sourceType === 'mono') {
_loadMultichannelSources();
(document.getElementById('audio-source-channel') as HTMLSelectElement).onchange = () => _autoGenerateAudioSourceName();
} else if (sourceType === 'band_extract') {
_loadBandParentSources();
(document.getElementById('audio-source-band') as HTMLSelectElement).value = 'bass';
@@ -138,6 +171,11 @@ export async function showAudioSourceModal(sourceType: any, editData?: any) {
_audioSourceTagsInput = new TagInput(document.getElementById('audio-source-tags-container'), { placeholder: t('tags.placeholder') });
_audioSourceTagsInput.setValue(isEdit ? (editData.tags || []) : []);
// Auto-name wiring
_asNameManuallyEdited = isEdit;
(document.getElementById('audio-source-name') as HTMLElement).oninput = () => { _asNameManuallyEdited = true; };
if (!isEdit) _autoGenerateAudioSourceName();
audioSourceModal.open();
audioSourceModal.snapshot();
}
@@ -384,7 +422,7 @@ function _ensureBandIconSelect() {
target: sel,
items: _buildBandItems(),
columns: 2,
onChange: () => onBandPresetChange(),
onChange: () => { onBandPresetChange(); _autoGenerateAudioSourceName(); },
});
}

View File

@@ -23,6 +23,27 @@ import type { Automation } from '../types.ts';
let _automationTagsInput: any = null;
// ── Auto-name ──
let _autoNameManuallyEdited = false;
function _autoGenerateAutomationName() {
if (_autoNameManuallyEdited) return;
if ((document.getElementById('automation-editor-id') as HTMLInputElement).value) return;
const sceneSel = document.getElementById('automation-scene-id') as HTMLSelectElement | null;
const sceneName = sceneSel?.selectedOptions[0]?.textContent?.trim() || '';
const logic = (document.getElementById('automation-editor-logic') as HTMLSelectElement).value;
const condCount = document.querySelectorAll('#automation-conditions-list .condition-row').length;
let name = '';
if (sceneName) name = sceneName;
if (condCount > 0) {
const logicLabel = logic === 'and' ? 'AND' : 'OR';
const suffix = `${condCount} ${logicLabel}`;
name = name ? `${name} · ${suffix}` : suffix;
}
(document.getElementById('automation-editor-name') as HTMLInputElement).value = name || t('automations.add');
}
class AutomationEditorModal extends Modal {
constructor() { super('automation-editor-modal'); }
@@ -395,6 +416,12 @@ export async function openAutomationEditor(automationId?: any, cloneData?: any)
// Wire up deactivation mode change
(document.getElementById('automation-deactivation-mode') as HTMLSelectElement).onchange = _onDeactivationModeChange;
// Auto-name wiring
_autoNameManuallyEdited = !!(automationId || cloneData);
nameInput.oninput = () => { _autoNameManuallyEdited = true; };
(window as any)._autoGenerateAutomationName = _autoGenerateAutomationName;
if (!automationId && !cloneData) _autoGenerateAutomationName();
automationModal.open();
modal!.querySelectorAll('[data-i18n]').forEach(el => {
el.textContent = t(el.getAttribute('data-i18n')!);
@@ -453,7 +480,12 @@ function _initSceneSelector(selectId: any, selectedId: any) {
allowNone: true,
noneLabel: t('automations.scene.none_selected'),
} as any);
if (isMain) _sceneEntitySelect = es; else _fallbackSceneEntitySelect = es;
if (isMain) {
_sceneEntitySelect = es;
sel.onchange = () => _autoGenerateAutomationName();
} else {
_fallbackSceneEntitySelect = es;
}
}
// ===== Deactivation mode IconSelect =====
@@ -480,6 +512,7 @@ function _ensureDeactivationModeIconSelect() {
export function addAutomationCondition() {
addAutomationConditionRow({ condition_type: 'application', apps: [], match_type: 'running' });
_autoGenerateAutomationName();
}
const CONDITION_TYPE_KEYS = ['always', 'startup', 'application', 'time_of_day', 'system_idle', 'display_state', 'mqtt', 'webhook'];
@@ -577,7 +610,7 @@ function addAutomationConditionRow(condition: any) {
<select class="condition-type-select">
${CONDITION_TYPE_KEYS.map(k => `<option value="${k}" ${condType === k ? 'selected' : ''}>${t('automations.condition.' + k)}</option>`).join('')}
</select>
<button type="button" class="btn-remove-condition" onclick="this.closest('.automation-condition-row').remove()" title="Remove">&#x2715;</button>
<button type="button" class="btn-remove-condition" onclick="this.closest('.automation-condition-row').remove(); if(window._autoGenerateAutomationName) window._autoGenerateAutomationName();" title="Remove">&#x2715;</button>
</div>
<div class="condition-fields-container"></div>
`;

View File

@@ -29,6 +29,20 @@ import type { PatternTemplate } from '../types.ts';
let _patternBgEntitySelect: EntitySelect | null = null;
let _patternTagsInput: TagInput | null = null;
// ── Auto-name ──
let _ptNameManuallyEdited = false;
function _autoGeneratePatternName() {
if (_ptNameManuallyEdited) return;
if ((document.getElementById('pattern-template-id') as HTMLInputElement).value) return;
const count = patternEditorRects.length;
const label = count > 0
? `${t('targets.section.pattern_templates')} · ${count} ${count === 1 ? 'rect' : 'rects'}`
: t('targets.section.pattern_templates');
(document.getElementById('pattern-template-name') as HTMLInputElement).value = label;
}
class PatternTemplateModal extends Modal {
constructor() {
super('pattern-template-modal');
@@ -149,6 +163,11 @@ export async function showPatternTemplateEditor(templateId: string | null = null
_patternTagsInput = new TagInput(document.getElementById('pattern-tags-container'), { placeholder: t('tags.placeholder') });
_patternTagsInput.setValue(_editorTags);
// Auto-name wiring
_ptNameManuallyEdited = !!(templateId || cloneData);
(document.getElementById('pattern-template-name') as HTMLElement).oninput = () => { _ptNameManuallyEdited = true; };
if (!templateId && !cloneData) _autoGeneratePatternName();
patternModal.snapshot();
renderPatternRectList();
@@ -317,6 +336,7 @@ export function addPatternRect(): void {
setPatternEditorSelectedIdx(patternEditorRects.length - 1);
renderPatternRectList();
renderPatternCanvas();
_autoGeneratePatternName();
}
export function deleteSelectedPatternRect(): void {
@@ -325,6 +345,7 @@ export function deleteSelectedPatternRect(): void {
setPatternEditorSelectedIdx(-1);
renderPatternRectList();
renderPatternCanvas();
_autoGeneratePatternName();
}
export function removePatternRect(index: number): void {
@@ -333,6 +354,7 @@ export function removePatternRect(index: number): void {
else if (patternEditorSelectedIdx > index) setPatternEditorSelectedIdx(patternEditorSelectedIdx - 1);
renderPatternRectList();
renderPatternCanvas();
_autoGeneratePatternName();
}
// ----- Pattern Canvas Visual Editor -----

View File

@@ -22,6 +22,19 @@ let _editingId: string | null = null;
let _allTargets: any[] = []; // fetched on capture open
let _sceneTagsInput: TagInput | null = null;
// ── Auto-name ──
let _spNameManuallyEdited = false;
function _autoGenerateScenePresetName() {
if (_spNameManuallyEdited) return;
if ((document.getElementById('scene-preset-editor-id') as HTMLInputElement).value) return;
const items = document.querySelectorAll('#scene-target-list .scene-target-item');
const count = items.length;
const label = count > 0 ? `${t('scenes.title')} · ${count} ${count === 1 ? 'target' : 'targets'}` : t('scenes.title');
(document.getElementById('scene-preset-editor-name') as HTMLInputElement).value = label;
}
class ScenePresetEditorModal extends Modal {
constructor() { super('scene-preset-editor-modal'); }
onForceClose() {
@@ -165,6 +178,11 @@ export async function openScenePresetCapture(): Promise<void> {
_sceneTagsInput = new TagInput(document.getElementById('scene-tags-container'), { placeholder: t('tags.placeholder') });
_sceneTagsInput.setValue([]);
// Auto-name wiring
_spNameManuallyEdited = false;
(document.getElementById('scene-preset-editor-name') as HTMLElement).oninput = () => { _spNameManuallyEdited = true; };
_autoGenerateScenePresetName();
scenePresetModal.open();
scenePresetModal.snapshot();
}
@@ -319,7 +337,10 @@ export async function addSceneTarget(): Promise<void> {
if (!picked) return;
const tgt = _allTargets.find(t => t.id === picked);
if (tgt) _addTargetToList(tgt.id, tgt.name);
if (tgt) {
_addTargetToList(tgt.id, tgt.name);
_autoGenerateScenePresetName();
}
}
// removeSceneTarget is now handled via event delegation on the modal
@@ -492,6 +513,7 @@ export function initScenePresetDelegation(container: HTMLElement): void {
if (item) {
item.remove();
_refreshTargetSelect();
_autoGenerateScenePresetName();
}
return;
}
@@ -528,6 +550,7 @@ if (_sceneEditorModal) {
if (item) {
item.remove();
_refreshTargetSelect();
_autoGenerateScenePresetName();
}
});
}

View File

@@ -13,6 +13,17 @@ import { TagInput, renderTagChips } from '../core/tag-input.ts';
import { loadPictureSources } from './streams.ts';
import type { SyncClock } from '../types.ts';
// ── Auto-name ──
let _scNameManuallyEdited = false;
function _autoGenerateSyncClockName() {
if (_scNameManuallyEdited) return;
if ((document.getElementById('sync-clock-id') as HTMLInputElement).value) return;
const speed = parseFloat((document.getElementById('sync-clock-speed') as HTMLInputElement).value) || 1.0;
(document.getElementById('sync-clock-name') as HTMLInputElement).value = `${t('sync_clock.group.title')} · ${speed}x`;
}
// ── Modal ──
let _syncClockTagsInput: TagInput | null = null;
@@ -62,6 +73,12 @@ export async function showSyncClockModal(editData: SyncClock | null): Promise<vo
_syncClockTagsInput = new TagInput(document.getElementById('sync-clock-tags-container'), { placeholder: t('tags.placeholder') });
_syncClockTagsInput.setValue(isEdit ? (editData.tags || []) : []);
// Auto-name wiring
_scNameManuallyEdited = isEdit;
(document.getElementById('sync-clock-name') as HTMLElement).oninput = () => { _scNameManuallyEdited = true; };
(document.getElementById('sync-clock-speed') as HTMLElement).oninput = () => _autoGenerateSyncClockName();
if (!isEdit) _autoGenerateSyncClockName();
syncClockModal.open();
syncClockModal.snapshot();
}

View File

@@ -24,6 +24,20 @@ function _getProviderItems() {
];
}
// ── Auto-name ──
let _wsNameManuallyEdited = false;
function _autoGenerateWeatherSourceName() {
if (_wsNameManuallyEdited) return;
if ((document.getElementById('weather-source-id') as HTMLInputElement).value) return;
const provider = (document.getElementById('weather-source-provider') as HTMLSelectElement).value;
const lat = (document.getElementById('weather-source-latitude') as HTMLInputElement).value;
const lon = (document.getElementById('weather-source-longitude') as HTMLInputElement).value;
const providerLabel = provider === 'open_meteo' ? 'Open-Meteo' : provider;
(document.getElementById('weather-source-name') as HTMLInputElement).value = `${providerLabel} · ${lat}, ${lon}`;
}
// ── Modal ──
let _weatherSourceTagsInput: TagInput | null = null;
@@ -92,6 +106,13 @@ export async function showWeatherSourceModal(editData: WeatherSource | null = nu
columns: 1,
});
// Auto-name wiring
_wsNameManuallyEdited = isEdit;
(document.getElementById('weather-source-name') as HTMLElement).oninput = () => { _wsNameManuallyEdited = true; };
(document.getElementById('weather-source-latitude') as HTMLElement).oninput = () => _autoGenerateWeatherSourceName();
(document.getElementById('weather-source-longitude') as HTMLElement).oninput = () => _autoGenerateWeatherSourceName();
if (!isEdit) _autoGenerateWeatherSourceName();
// Show/hide test button based on edit mode
const testBtn = document.getElementById('weather-source-test-btn');
if (testBtn) testBtn.style.display = isEdit ? '' : 'none';
@@ -230,6 +251,7 @@ export function weatherSourceGeolocate(): void {
(pos) => {
(document.getElementById('weather-source-latitude') as HTMLInputElement).value = pos.coords.latitude.toFixed(4);
(document.getElementById('weather-source-longitude') as HTMLInputElement).value = pos.coords.longitude.toFixed(4);
_autoGenerateWeatherSourceName();
if (btn) btn.classList.remove('loading');
showToast(t('weather_source.geo.success'), 'success');
},