Files
mkcontrol-app/static/js/admin-config-editors.js

309 lines
17 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(() => {
'use strict';
const shell = document.querySelector('[data-editor]');
if (!shell) return;
const parseScript = (id, fallback) => {
try { return JSON.parse(document.getElementById(id)?.textContent || ''); }
catch (_) { return fallback; }
};
const clone = value => JSON.parse(JSON.stringify(value));
const editorType = shell.dataset.editor;
const initialData = parseScript('editor-data', {});
const soundFiles = parseScript('sound-files-data', []);
const hiddenJson = document.getElementById('config_content');
const rawEditor = document.getElementById('raw-json-editor');
const rawError = document.getElementById('raw-json-error');
const rawModal = new bootstrap.Modal(document.getElementById('rawJsonModal'));
let state = clone(initialData);
const escapeHtml = value => String(value ?? '')
.replaceAll('&', '&amp;').replaceAll('<', '&lt;')
.replaceAll('>', '&gt;').replaceAll('"', '&quot;');
const soundOptions = () => soundFiles
.map(file => `<option value="${escapeHtml(file)}"></option>`).join('');
function cleanState(value) {
if (Array.isArray(value)) return value.map(cleanState);
if (value && typeof value === 'object') {
return Object.fromEntries(Object.entries(value)
.filter(([key]) => !key.startsWith('__'))
.map(([key, child]) => [key, cleanState(child)]));
}
return value;
}
function syncHidden() {
hiddenJson.value = JSON.stringify(cleanState(state), null, 2);
}
function field(label, value, key, type = 'text', extra = '') {
return `<label class="editor-field"><span>${label}</span><input class="form-control" type="${type}" data-field="${key}" value="${escapeHtml(value)}" ${extra}></label>`;
}
function initRawEditor(render) {
document.getElementById('show-raw-json').addEventListener('click', () => {
syncHidden();
rawEditor.value = hiddenJson.value;
rawError.textContent = '';
rawModal.show();
});
document.getElementById('apply-raw-json').addEventListener('click', () => {
try {
state = JSON.parse(rawEditor.value);
rawError.textContent = '';
render();
syncHidden();
rawModal.hide();
} catch (error) {
rawError.textContent = `Ungültiges JSON: ${error.message}`;
}
});
}
function initLocomotiveEditor() {
const grid = document.getElementById('channel-grid');
const soundList = document.getElementById('loco-sound-list');
const ports = ['A', 'B', 'C', 'D', 'E', 'F'];
let draggedComponent = null;
const componentDefaults = {
motor: { type: 'motor', name: 'Motor', invert: false, negative_only: false },
light: { type: 'light', name: 'Licht', on_value: 1, off_value: 0, negative_only: false },
fogger: { type: 'fogger', name: 'Dampf', on_value: -1, off_value: 0, negative_only: true },
switch: { type: 'switch', name: 'Schalter', on_value: 1, off_value: 0, negative_only: false }
};
const activePorts = () => state.hub_type === '6channel' ? ports : ports.slice(0, 4);
const channelFor = port => (state.channels || []).find(channel => channel.port === port);
function setComponent(port, type) {
state.channels ||= [];
const old = channelFor(port);
const next = { ...(componentDefaults[type] || componentDefaults.switch), port };
if (old?.type === type) Object.assign(next, old);
state.channels = state.channels.filter(channel => channel.port !== port);
state.channels.push(next);
renderChannels();
}
function renderChannelFields(channel) {
const binary = channel.type !== 'motor';
return `
${field('Bezeichnung', channel.name || '', 'name')}
<div class="channel-options">
<label class="form-check"><input class="form-check-input" type="checkbox" data-field="invert" ${channel.invert ? 'checked' : ''}><span>Richtung umkehren</span></label>
<label class="form-check"><input class="form-check-input" type="checkbox" data-field="negative_only" ${channel.negative_only ? 'checked' : ''}><span>Nur negative Werte</span></label>
</div>
${binary ? `<div class="row g-2"><div class="col-6">${field('Wert EIN', channel.on_value ?? 1, 'on_value', 'number', 'step="0.1"')}</div><div class="col-6">${field('Wert AUS', channel.off_value ?? 0, 'off_value', 'number', 'step="0.1"')}</div></div>` : ''}
`;
}
function renderChannels() {
document.getElementById('channel-count-label').textContent = state.hub_type === '6channel' ? 'Kanäle A–F' : 'Kanäle A–D';
grid.innerHTML = '';
activePorts().forEach(port => {
const channel = channelFor(port);
const card = document.createElement('article');
card.className = `channel-slot ${channel ? `occupied ${channel.type}` : ''}`;
card.dataset.port = port;
card.innerHTML = channel ? `
<div class="channel-slot-head"><span class="port-badge">${port}</span><span class="component-type">${escapeHtml(channel.type)}</span><button type="button" class="btn-close remove-channel" aria-label="Entfernen"></button></div>
<div class="channel-fields">${renderChannelFields(channel)}</div>
` : `<div class="empty-channel"><span class="port-badge">${port}</span><strong>Kanal ${port}</strong><small>Bauteil hier ablegen</small></div>`;
card.addEventListener('dragover', event => { event.preventDefault(); card.classList.add('drag-over'); });
card.addEventListener('dragleave', () => card.classList.remove('drag-over'));
card.addEventListener('drop', event => {
event.preventDefault(); card.classList.remove('drag-over');
const type = event.dataTransfer.getData('text/component') || draggedComponent;
if (type) setComponent(port, type);
});
card.querySelector('.remove-channel')?.addEventListener('click', () => {
state.channels = (state.channels || []).filter(item => item.port !== port);
renderChannels();
});
card.querySelectorAll('[data-field]').forEach(input => input.addEventListener('input', () => {
const key = input.dataset.field;
channel[key] = input.type === 'checkbox' ? input.checked : input.type === 'number' ? Number(input.value) : input.value;
}));
grid.appendChild(card);
});
}
function addLocoSound(sound = {}) {
state.sounds ||= [];
state.sounds.push({ id: '', file: '', name: 'Neuer Sound', description: '', ...sound });
renderSounds();
}
function renderSounds() {
state.sounds ||= [];
soundList.innerHTML = state.sounds.length ? '' : '<div class="editor-empty">Noch keine Lok-Sounds. Über „+ Sound“ einen Eintrag hinzufügen.</div>';
state.sounds.forEach((sound, index) => {
const row = document.createElement('article');
row.className = 'sound-entry-card';
row.innerHTML = `<div class="sound-entry-index">${index + 1}</div><div class="sound-entry-fields">
<div class="row g-2"><div class="col-md-4">${field('Name', sound.name || '', 'name')}</div><div class="col-md-3">${field('ID', sound.id || '', 'id')}</div><div class="col-md-5"><label class="editor-field"><span>Sounddatei</span><input class="form-control" list="sound-files" data-field="file" value="${escapeHtml(sound.file || '')}"></label></div></div>
${field('Beschreibung', sound.description || '', 'description')}
</div><button type="button" class="btn btn-sm btn-outline-danger remove-sound">Entfernen</button>`;
row.querySelectorAll('[data-field]').forEach(input => input.addEventListener('input', () => { sound[input.dataset.field] = input.value; }));
row.querySelector('.remove-sound').addEventListener('click', () => { state.sounds.splice(index, 1); renderSounds(); });
soundList.appendChild(row);
});
if (!document.getElementById('sound-files')) soundList.insertAdjacentHTML('beforeend', `<datalist id="sound-files">${soundOptions()}</datalist>`);
}
function render() {
state.channels ||= [];
state.sounds ||= [];
document.getElementById('cfg-name').value = state.name || '';
document.getElementById('cfg-hub-id').value = state.hub_id ?? 0;
document.getElementById('cfg-hub-type').value = state.hub_type || '4channel';
document.getElementById('cfg-image').value = state.image || '';
renderChannels(); renderSounds();
}
document.querySelectorAll('[data-component]').forEach(chip => {
chip.addEventListener('dragstart', event => { draggedComponent = chip.dataset.component; event.dataTransfer.setData('text/component', draggedComponent); });
chip.addEventListener('dragend', () => { draggedComponent = null; });
chip.addEventListener('click', () => {
const freePort = activePorts().find(port => !channelFor(port));
if (freePort) setComponent(freePort, chip.dataset.component);
});
});
document.getElementById('cfg-name').addEventListener('input', event => { state.name = event.target.value; });
document.getElementById('cfg-hub-id').addEventListener('input', event => { state.hub_id = Number(event.target.value); });
document.getElementById('cfg-image').addEventListener('input', event => { state.image = event.target.value; });
document.getElementById('cfg-hub-type').addEventListener('change', event => { state.hub_type = event.target.value; renderChannels(); });
document.getElementById('add-loco-sound').addEventListener('click', () => addLocoSound());
document.getElementById('config-form').addEventListener('submit', () => {
const valid = new Set(activePorts());
state.channels = (state.channels || []).filter(channel => valid.has(channel.port)).sort((a, b) => a.port.localeCompare(b.port));
syncHidden();
});
initRawEditor(render); render(); syncHidden();
}
function initSoundboardEditor() {
const laneKeys = ['backgrounds', 'sounds', 'random_pool'];
let dragged = null;
let sequence = 0;
const ensureIds = () => laneKeys.forEach(key => {
state[key] ||= [];
state[key].forEach(item => { if (!item.__editorId) item.__editorId = `sound-${++sequence}`; });
});
function defaultSound(kind) {
const base = { __editorId: `sound-${++sequence}`, id: '', name: 'Neuer Sound', file: '', description: '' };
if (kind === 'background') return { ...base, loop: true, channel: 0 };
if (kind === 'sound') return { ...base, channel: 1 };
return { ...base };
}
function addSound(kind, targetKey) {
const key = targetKey || (kind === 'background' ? 'backgrounds' : kind === 'random' ? 'random_pool' : 'sounds');
state[key].push(defaultSound(kind)); renderLanes();
}
function findItem(id) {
for (const key of laneKeys) {
const index = state[key].findIndex(item => item.__editorId === id);
if (index >= 0) return { key, index, item: state[key][index] };
}
return null;
}
function moveItem(id, targetKey, targetIndex = null) {
const found = findItem(id); if (!found) return;
state[found.key].splice(found.index, 1);
if (targetKey === 'backgrounds') found.item.loop = true;
else delete found.item.loop;
if (targetIndex === null || targetIndex >= state[targetKey].length) state[targetKey].push(found.item);
else state[targetKey].splice(Math.max(0, targetIndex), 0, found.item);
renderLanes();
}
function renderCard(item, key, index) {
const card = document.createElement('article');
card.className = 'theme-sound-card'; card.draggable = true; card.dataset.id = item.__editorId;
const optional = key === 'random_pool' ? `<div class="row g-2"><div class="col-6">${field('Eigenes Limit', item.max_per_hour ?? '', 'max_per_hour', 'number', 'min="1" placeholder="Theme"')}</div><div class="col-6">${field('Zeitraum (Min.)', item.window_minutes ?? '', 'window_minutes', 'number', 'min="1" placeholder="Theme"')}</div></div>` : '';
card.innerHTML = `<div class="theme-card-head"><span class="drag-handle" title="Ziehen">⠿</span><strong>${escapeHtml(item.name || item.id || 'Neuer Sound')}</strong><button type="button" class="btn-close remove-theme-sound"></button></div>
<div class="row g-2"><div class="col-md-6">${field('Anzeigename', item.name || '', 'name')}</div><div class="col-md-6">${field('ID', item.id || '', 'id')}</div></div>
<label class="editor-field"><span>Sounddatei</span><input class="form-control" list="theme-sound-files" data-field="file" value="${escapeHtml(item.file || '')}"></label>
${field('Beschreibung', item.description || '', 'description')}
${key !== 'random_pool' ? field('Audio-Kanal', item.channel ?? (key === 'backgrounds' ? 0 : 1), 'channel', 'number', 'min="0" max="15"') : ''}
${optional}`;
card.addEventListener('dragstart', event => { dragged = { type: 'existing', id: item.__editorId }; event.dataTransfer.setData('text/sound-id', item.__editorId); });
card.addEventListener('dragend', () => { dragged = null; });
card.addEventListener('dragover', event => { event.preventDefault(); event.stopPropagation(); card.classList.add('drag-over'); });
card.addEventListener('dragleave', () => card.classList.remove('drag-over'));
card.addEventListener('drop', event => {
event.preventDefault(); event.stopPropagation(); card.classList.remove('drag-over');
const existingId = event.dataTransfer.getData('text/sound-id');
const kind = event.dataTransfer.getData('text/sound-kind');
if (existingId && existingId !== item.__editorId) moveItem(existingId, key, index);
else if (kind) {
const created = defaultSound(kind);
state[key].splice(index, 0, created);
renderLanes();
}
});
card.querySelectorAll('[data-field]').forEach(input => input.addEventListener('input', () => {
const fieldName = input.dataset.field;
if (input.type === 'number') item[fieldName] = input.value === '' ? undefined : Number(input.value);
else item[fieldName] = input.value;
if (fieldName === 'name') card.querySelector('.theme-card-head strong').textContent = input.value || item.id || 'Neuer Sound';
}));
card.querySelector('.remove-theme-sound').addEventListener('click', () => { state[key].splice(index, 1); renderLanes(); });
return card;
}
function renderLanes() {
ensureIds();
document.querySelectorAll('[data-dropzone]').forEach(zone => {
const key = zone.dataset.dropzone; zone.innerHTML = '';
if (!state[key].length) zone.innerHTML = '<div class="lane-empty">Klangelement hier ablegen</div>';
state[key].forEach((item, index) => zone.appendChild(renderCard(item, key, index)));
});
if (!document.getElementById('theme-sound-files')) document.body.insertAdjacentHTML('beforeend', `<datalist id="theme-sound-files">${soundOptions()}</datalist>`);
}
function render() {
ensureIds();
document.getElementById('theme-name').value = state.name || '';
document.getElementById('theme-limit').value = state.random_limit_per_hour ?? 2;
document.getElementById('theme-window').value = state.random_window_minutes ?? 60;
renderLanes();
}
document.querySelectorAll('[data-sound-kind]').forEach(button => {
button.addEventListener('dragstart', event => { dragged = { type: 'new', kind: button.dataset.soundKind }; event.dataTransfer.setData('text/sound-kind', button.dataset.soundKind); });
button.addEventListener('dragend', () => { dragged = null; });
button.addEventListener('click', () => addSound(button.dataset.soundKind));
});
document.querySelectorAll('[data-dropzone]').forEach(zone => {
zone.addEventListener('dragover', event => { event.preventDefault(); zone.classList.add('drag-over'); });
zone.addEventListener('dragleave', () => zone.classList.remove('drag-over'));
zone.addEventListener('drop', event => {
event.preventDefault(); zone.classList.remove('drag-over');
const target = zone.dataset.dropzone;
const existingId = event.dataTransfer.getData('text/sound-id');
const kind = event.dataTransfer.getData('text/sound-kind');
if (existingId) moveItem(existingId, target); else if (kind) addSound(kind, target);
});
});
document.getElementById('theme-name').addEventListener('input', event => { state.name = event.target.value; });
document.getElementById('theme-limit').addEventListener('input', event => { state.random_limit_per_hour = Number(event.target.value); });
document.getElementById('theme-window').addEventListener('input', event => { state.random_window_minutes = Number(event.target.value); });
document.getElementById('theme-form').addEventListener('submit', syncHidden);
initRawEditor(render); render(); syncHidden();
}
if (editorType === 'locomotive') initLocomotiveEditor();
if (editorType === 'soundboard') initSoundboardEditor();
})();