309 lines
17 KiB
JavaScript
309 lines
17 KiB
JavaScript
(() => {
|
||
'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('&', '&').replaceAll('<', '<')
|
||
.replaceAll('>', '>').replaceAll('"', '"');
|
||
|
||
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();
|
||
})();
|