updated editors + new parameter in service file
This commit is contained in:
Binary file not shown.
+41
-11
@@ -11,6 +11,21 @@ from app.utils.helpers import load_configs, load_default_sounds, load_soundboard
|
|||||||
admin_bp = Blueprint('admin', __name__)
|
admin_bp = Blueprint('admin', __name__)
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _list_sound_files(subdir=None):
|
||||||
|
sound_dir = current_app.config['SOUNDS_DIR']
|
||||||
|
search_dir = os.path.join(sound_dir, subdir) if subdir else sound_dir
|
||||||
|
extensions = {'.mp3', '.ogg', '.wav', '.flac'}
|
||||||
|
files = []
|
||||||
|
if not os.path.isdir(search_dir):
|
||||||
|
return files
|
||||||
|
for root, _, filenames in os.walk(search_dir):
|
||||||
|
for filename in filenames:
|
||||||
|
if os.path.splitext(filename)[1].lower() in extensions:
|
||||||
|
full_path = os.path.join(root, filename)
|
||||||
|
files.append(os.path.relpath(full_path, search_dir))
|
||||||
|
return sorted(files, key=str.lower)
|
||||||
|
|
||||||
@admin_bp.route('/')
|
@admin_bp.route('/')
|
||||||
def admin():
|
def admin():
|
||||||
configs = load_configs()
|
configs = load_configs()
|
||||||
@@ -33,7 +48,7 @@ def admin_edit_config(filename):
|
|||||||
raise ValueError("Kein Inhalt übermittelt")
|
raise ValueError("Kein Inhalt übermittelt")
|
||||||
|
|
||||||
# Validierung: versuche zu parsen
|
# Validierung: versuche zu parsen
|
||||||
json.loads(new_content)
|
config_data = json.loads(new_content)
|
||||||
|
|
||||||
with open(path, 'w', encoding='utf-8') as f:
|
with open(path, 'w', encoding='utf-8') as f:
|
||||||
f.write(new_content)
|
f.write(new_content)
|
||||||
@@ -49,9 +64,14 @@ def admin_edit_config(filename):
|
|||||||
error_msg = f"Fehler beim Speichern: {str(e)}"
|
error_msg = f"Fehler beim Speichern: {str(e)}"
|
||||||
|
|
||||||
# Bei Fehler: zurück zum Formular mit Fehlermeldung
|
# Bei Fehler: zurück zum Formular mit Fehlermeldung
|
||||||
with open(path, 'r', encoding='utf-8') as f:
|
content = request.form.get('config_content', '')
|
||||||
content = f.read()
|
try:
|
||||||
return render_template('admin_edit.html', filename=filename, content=content, error=error_msg)
|
config_data = json.loads(content)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
config_data = {}
|
||||||
|
return render_template('admin_edit.html', filename=filename, content=content,
|
||||||
|
config_data=config_data, sound_files=_list_sound_files(),
|
||||||
|
error=error_msg)
|
||||||
|
|
||||||
# GET: Formular laden
|
# GET: Formular laden
|
||||||
if not os.path.exists(path):
|
if not os.path.exists(path):
|
||||||
@@ -59,9 +79,9 @@ def admin_edit_config(filename):
|
|||||||
|
|
||||||
with open(path, 'r', encoding='utf-8') as f:
|
with open(path, 'r', encoding='utf-8') as f:
|
||||||
content = f.read()
|
content = f.read()
|
||||||
|
config_data = json.loads(content)
|
||||||
return render_template('admin_edit.html', filename=filename, content=content)
|
return render_template('admin_edit.html', filename=filename, content=content,
|
||||||
pass
|
config_data=config_data, sound_files=_list_sound_files())
|
||||||
|
|
||||||
|
|
||||||
@admin_bp.route('/theme/edit/<path:filename>', methods=['GET', 'POST'])
|
@admin_bp.route('/theme/edit/<path:filename>', methods=['GET', 'POST'])
|
||||||
@@ -87,15 +107,25 @@ def admin_edit_theme(filename):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Speicherfehler Theme {filename}: {e}")
|
logger.error(f"Speicherfehler Theme {filename}: {e}")
|
||||||
error_msg = f"Fehler beim Speichern: {str(e)}"
|
error_msg = f"Fehler beim Speichern: {str(e)}"
|
||||||
with open(path, 'r', encoding='utf-8') as f:
|
content = request.form.get('config_content', '')
|
||||||
content = f.read()
|
try:
|
||||||
return render_template('admin_edit_theme.html', filename=filename, content=content, error=error_msg)
|
theme_data = json.loads(content)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
theme_data = {}
|
||||||
|
theme_sound_dir = os.path.dirname(filename)
|
||||||
|
return render_template('admin_edit_theme.html', filename=filename, content=content,
|
||||||
|
theme_data=theme_data, sound_files=_list_sound_files(theme_sound_dir),
|
||||||
|
error=error_msg)
|
||||||
|
|
||||||
if not os.path.exists(path):
|
if not os.path.exists(path):
|
||||||
return "Theme nicht gefunden", 404
|
return "Theme nicht gefunden", 404
|
||||||
with open(path, 'r', encoding='utf-8') as f:
|
with open(path, 'r', encoding='utf-8') as f:
|
||||||
content = f.read()
|
content = f.read()
|
||||||
return render_template('admin_edit_theme.html', filename=filename, content=content)
|
theme_data = json.loads(content)
|
||||||
|
theme_sound_dir = os.path.dirname(filename)
|
||||||
|
return render_template('admin_edit_theme.html', filename=filename, content=content,
|
||||||
|
theme_data=theme_data, sound_files=_list_sound_files(theme_sound_dir),
|
||||||
|
theme_sound_dir=theme_sound_dir)
|
||||||
|
|
||||||
@admin_bp.route('/delete/<filename>', methods=['POST'])
|
@admin_bp.route('/delete/<filename>', methods=['POST'])
|
||||||
def admin_delete_config(filename):
|
def admin_delete_config(filename):
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ WorkingDirectory=/opt/mkcontrol-app
|
|||||||
ExecStart=/opt/mkcontrol-app/.venv/bin/python3 /opt/mkcontrol-app/app.py
|
ExecStart=/opt/mkcontrol-app/.venv/bin/python3 /opt/mkcontrol-app/app.py
|
||||||
Restart=always
|
Restart=always
|
||||||
|
|
||||||
|
Environment="MKCONTROL_AUDIO_DEVICE=default"
|
||||||
|
|
||||||
StandardOutput=file:/var/log/mkcontrol/mkcontrol.log
|
StandardOutput=file:/var/log/mkcontrol/mkcontrol.log
|
||||||
StandardError=file:/var/log/mkcontrol/mkcontrol_error.log
|
StandardError=file:/var/log/mkcontrol/mkcontrol_error.log
|
||||||
|
|
||||||
|
|||||||
@@ -59,3 +59,38 @@ pre code::before {
|
|||||||
width: 3em;
|
width: 3em;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Visuelle Konfigurationseditoren */
|
||||||
|
.editor-shell { --ink:#20201d; --paper:#fffdf8; --red:#9b2c20; max-width:1320px; margin:0 auto; }
|
||||||
|
.editor-hero { display:flex; justify-content:space-between; align-items:center; gap:2rem; padding:2.2rem; margin-bottom:1.5rem; border-radius:1.25rem; color:var(--ink); background:radial-gradient(circle at 90% 10%,rgba(255,255,255,.8),transparent 32%),linear-gradient(135deg,#f1c86b,#e98e62); box-shadow:0 18px 45px rgba(72,47,24,.16); }
|
||||||
|
.editor-hero.soundboard-hero { color:white; background:radial-gradient(circle at 85% 15%,rgba(255,255,255,.16),transparent 28%),linear-gradient(135deg,#173f48,#276f68 55%,#d89b46); }
|
||||||
|
.editor-hero h1 { margin:.15rem 0; font-size:clamp(2rem,4vw,3.4rem); font-family:Georgia,'Times New Roman',serif; }
|
||||||
|
.editor-hero p { margin:0; opacity:.78; }
|
||||||
|
.editor-kicker { text-transform:uppercase; letter-spacing:.16em; font-size:.74rem; font-weight:800; }
|
||||||
|
.editor-panel { padding:1.5rem; border:1px solid #dedbd2; border-radius:1.1rem; background:var(--paper); box-shadow:0 8px 24px rgba(39,37,31,.07); }
|
||||||
|
.editor-section-title { display:flex; align-items:center; gap:.85rem; margin-bottom:1.25rem; }
|
||||||
|
.editor-section-title.compact { margin-bottom:1rem; }
|
||||||
|
.editor-section-title>span { display:grid; place-items:center; flex:0 0 2.4rem; height:2.4rem; border-radius:50%; color:white; background:var(--red); font-weight:800; }
|
||||||
|
.editor-section-title h2,.lane-heading h2 { margin:0; font-size:1.25rem; font-weight:800; }
|
||||||
|
.editor-section-title p,.lane-heading p { margin:0; color:#77736a; font-size:.9rem; }
|
||||||
|
.component-palette { display:grid; gap:.75rem; }
|
||||||
|
.component-chip { padding:1rem; border:1px solid transparent; border-radius:.9rem; cursor:grab; box-shadow:0 5px 12px rgba(0,0,0,.08); transition:transform .15s,border-color .15s; }
|
||||||
|
.component-chip:hover { transform:translateY(-2px); border-color:#333; }
|
||||||
|
.component-chip strong,.component-chip small,.sound-kind strong,.sound-kind small { display:block; }
|
||||||
|
.component-chip.motor { background:#f5c86e; }.component-chip.light { background:#fff4b8; }.component-chip.fogger { background:#d8e5e5; }.component-chip.switch { background:#e7ded2; }
|
||||||
|
.channel-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:1rem; }
|
||||||
|
.channel-slot { min-height:190px; padding:1rem; border:2px dashed #cfcac0; border-radius:1rem; background:#faf8f2; transition:border-color .15s,background .15s,transform .15s; }
|
||||||
|
.channel-slot.drag-over,.lane-dropzone.drag-over { border-color:#9b2c20; background:#fff2df; transform:scale(1.01); }
|
||||||
|
.channel-slot.occupied { border-style:solid; }.channel-slot.occupied.motor { border-color:#d79b2e; }.channel-slot.occupied.light { border-color:#e6c84d; }.channel-slot.occupied.fogger { border-color:#7ca7aa; }.channel-slot.occupied.switch { border-color:#9a8874; }
|
||||||
|
.empty-channel { min-height:155px; display:flex; flex-direction:column; align-items:center; justify-content:center; color:#8a867d; }
|
||||||
|
.empty-channel small { margin-top:.35rem; }.port-badge { display:grid; place-items:center; width:2.25rem; height:2.25rem; border-radius:.65rem; color:white; background:#282722; font-weight:900; }
|
||||||
|
.channel-slot-head { display:flex; align-items:center; gap:.65rem; margin-bottom:1rem; }.channel-slot-head .btn-close { margin-left:auto; }.component-type { text-transform:uppercase; letter-spacing:.08em; font-size:.75rem; font-weight:800; color:#666; }
|
||||||
|
.channel-fields { display:grid; gap:.65rem; }.channel-options { display:flex; flex-wrap:wrap; gap:.75rem; font-size:.85rem; }
|
||||||
|
.editor-field { display:block; }.editor-field>span { display:block; margin-bottom:.28rem; font-size:.78rem; font-weight:700; color:#656158; }
|
||||||
|
.sound-entry-list { display:grid; gap:.8rem; }.sound-entry-card { display:flex; align-items:flex-start; gap:1rem; padding:1rem; border:1px solid #dedbd2; border-radius:.9rem; background:white; }.sound-entry-index { display:grid; place-items:center; flex:0 0 2rem; height:2rem; border-radius:50%; background:#292823; color:white; font-weight:800; }.sound-entry-fields { flex:1; display:grid; gap:.6rem; }.editor-empty { padding:2rem; border:2px dashed #d8d4ca; border-radius:.9rem; text-align:center; color:#888278; }
|
||||||
|
.sound-palette { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:1rem; }.sound-kind { padding:1rem; border:0; border-radius:1rem; text-align:left; cursor:grab; }.sound-kind.background { background:#cce8df; }.sound-kind.effect { background:#f7d083; }.sound-kind.random { background:#d9d7ef; }
|
||||||
|
.theme-lanes { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:1rem; align-items:start; }.sound-lane { overflow:hidden; border:1px solid #d9d5cb; border-radius:1rem; background:#f8f6f0; }.lane-heading { display:flex; gap:.8rem; align-items:center; padding:1rem; }.lane-icon { display:grid; place-items:center; width:2.5rem; height:2.5rem; border-radius:.8rem; background:#292823; color:white; font-size:1.25rem; }.lane-dropzone { min-height:230px; padding:.8rem; border:2px dashed transparent; transition:.15s; }.lane-empty { display:grid; place-items:center; min-height:190px; border:2px dashed #d4d0c6; border-radius:.8rem; color:#928d82; }
|
||||||
|
.theme-sound-card { padding:1rem; margin-bottom:.8rem; border:1px solid #d7d3ca; border-radius:.85rem; background:white; box-shadow:0 5px 14px rgba(0,0,0,.06); cursor:grab; }.theme-card-head { display:flex; align-items:center; gap:.5rem; margin-bottom:.8rem; }.theme-card-head strong { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }.theme-card-head .btn-close { margin-left:auto; }.drag-handle { color:#989287; font-size:1.2rem; }
|
||||||
|
|
||||||
|
@media (max-width:991px) { .editor-hero { align-items:flex-start; flex-direction:column; }.theme-lanes { grid-template-columns:1fr; }.sound-palette { grid-template-columns:1fr; } }
|
||||||
|
@media (max-width:767px) { .channel-grid { grid-template-columns:1fr; }.editor-panel,.editor-hero { padding:1.1rem; }.sound-entry-card { flex-wrap:wrap; }.sound-entry-fields { flex-basis:calc(100% - 3rem); } }
|
||||||
|
|||||||
@@ -0,0 +1,308 @@
|
|||||||
|
(() => {
|
||||||
|
'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();
|
||||||
|
})();
|
||||||
@@ -232,8 +232,7 @@
|
|||||||
let json;
|
let json;
|
||||||
try { json = JSON.parse(text); } catch (_) { throw new Error('Serverantwort ist kein JSON:\n'+text); }
|
try { json = JSON.parse(text); } catch (_) { throw new Error('Serverantwort ist kein JSON:\n'+text); }
|
||||||
if (!json.success) throw new Error(json.message || 'Fehler');
|
if (!json.success) throw new Error(json.message || 'Fehler');
|
||||||
alert('Konfiguration angelegt: ' + json.filename);
|
location.href = `/admin/edit/${encodeURIComponent(json.filename)}`;
|
||||||
location.reload();
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert('Fehler: ' + e.message);
|
alert('Fehler: ' + e.message);
|
||||||
}
|
}
|
||||||
@@ -255,8 +254,7 @@
|
|||||||
let json;
|
let json;
|
||||||
try { json = JSON.parse(text); } catch (_) { throw new Error('Serverantwort ist kein JSON:\n'+text); }
|
try { json = JSON.parse(text); } catch (_) { throw new Error('Serverantwort ist kein JSON:\n'+text); }
|
||||||
if (!json.success) throw new Error(json.message || 'Fehler');
|
if (!json.success) throw new Error(json.message || 'Fehler');
|
||||||
alert('Theme angelegt: ' + json.path);
|
location.href = `/admin/theme/edit/${json.path.split('/').map(encodeURIComponent).join('/')}`;
|
||||||
location.reload();
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert('Fehler: ' + e.message);
|
alert('Fehler: ' + e.message);
|
||||||
}
|
}
|
||||||
|
|||||||
+64
-33
@@ -1,44 +1,75 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block title %}Bearbeite {{ filename }}{% endblock %}
|
{% block title %}Lok-Editor – {{ filename }}{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="container my-5">
|
<div class="editor-shell" data-editor="locomotive">
|
||||||
<h1>Bearbeite Konfiguration</h1>
|
<div class="editor-hero">
|
||||||
<h4 class="text-muted mb-4">{{ filename }}</h4>
|
<div>
|
||||||
|
<span class="editor-kicker">MK Hub Konfiguration</span>
|
||||||
{% if error %}
|
<h1>Lok zusammenstellen</h1>
|
||||||
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
<p>{{ filename }} · Bauteile auf die gewünschten Kanäle ziehen und anschließend konfigurieren.</p>
|
||||||
{{ error }}
|
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
<div class="d-flex gap-2 flex-wrap">
|
||||||
|
<button type="button" class="btn btn-outline-dark" id="show-raw-json">JSON anzeigen</button>
|
||||||
<form method="POST">
|
<button type="submit" form="config-form" class="btn btn-danger px-4">Konfiguration speichern</button>
|
||||||
<div class="mb-3">
|
|
||||||
<label for="config_content" class="form-label">JSON-Inhalt (achte auf gültiges Format):</label>
|
|
||||||
<textarea class="form-control font-monospace" id="config_content" name="config_content" rows="20">{{ content }}</textarea>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex gap-3">
|
</div>
|
||||||
<button type="submit" class="btn btn-primary btn-lg px-5">Speichern</button>
|
|
||||||
<a href="{{ url_for('admin.admin') }}" class="btn btn-outline-secondary btn-lg px-5">Abbrechen</a>
|
{% if error %}<div class="alert alert-danger">{{ error }}</div>{% endif %}
|
||||||
|
|
||||||
|
<form id="config-form" method="POST">
|
||||||
|
<textarea class="d-none" id="config_content" name="config_content">{{ content }}</textarea>
|
||||||
|
|
||||||
|
<section class="editor-panel mb-4">
|
||||||
|
<div class="editor-section-title"><span>01</span><div><h2>Lok & Hub</h2><p>Grunddaten der Steuerung</p></div></div>
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-5"><label class="form-label">Name der Lok</label><input id="cfg-name" class="form-control form-control-lg"></div>
|
||||||
|
<div class="col-md-2"><label class="form-label">Hub-ID</label><input id="cfg-hub-id" type="number" min="0" max="2" class="form-control form-control-lg"></div>
|
||||||
|
<div class="col-md-2"><label class="form-label">Hub-Typ</label><select id="cfg-hub-type" class="form-select form-select-lg"><option value="4channel">4 Kanäle</option><option value="6channel">6 Kanäle</option></select></div>
|
||||||
|
<div class="col-md-3"><label class="form-label">Bilddatei</label><input id="cfg-image" class="form-control form-control-lg" placeholder="lok.jpg"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="row g-4">
|
||||||
|
<div class="col-xl-3">
|
||||||
|
<section class="editor-panel h-100">
|
||||||
|
<div class="editor-section-title compact"><span>02</span><div><h2>Bauteile</h2><p>Auf einen Kanal ziehen</p></div></div>
|
||||||
|
<div id="channel-palette" class="component-palette">
|
||||||
|
<div class="component-chip motor" draggable="true" data-component="motor"><strong>Motor</strong><small>Richtung & Tempo</small></div>
|
||||||
|
<div class="component-chip light" draggable="true" data-component="light"><strong>Licht</strong><small>Ein- und ausschaltbar</small></div>
|
||||||
|
<div class="component-chip fogger" draggable="true" data-component="fogger"><strong>Dampf</strong><small>Nebler / Raucherzeuger</small></div>
|
||||||
|
<div class="component-chip switch" draggable="true" data-component="switch"><strong>Schalter</strong><small>Universeller Ausgang</small></div>
|
||||||
|
</div>
|
||||||
|
<p class="small text-muted mt-3 mb-0">Ein belegter Kanal kann ersetzt oder über „Entfernen“ geleert werden.</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<div class="col-xl-9">
|
||||||
|
<section class="editor-panel">
|
||||||
|
<div class="editor-section-title compact"><span>03</span><div><h2>Hub-Belegung</h2><p id="channel-count-label">Kanäle A–D</p></div></div>
|
||||||
|
<div id="channel-grid" class="channel-grid"></div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="editor-panel mt-4">
|
||||||
|
<div class="editor-section-title"><span>04</span><div><h2>Lok-Sounds</h2><p>Optionale Tasten für Pfeifen, Motorgeräusche und Signale</p></div><button type="button" id="add-loco-sound" class="btn btn-outline-danger ms-auto">+ Sound</button></div>
|
||||||
|
<div id="loco-sound-list" class="sound-entry-list"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-between align-items-center mt-4">
|
||||||
|
<a href="{{ url_for('admin.admin') }}" class="btn btn-outline-secondary">Abbrechen</a>
|
||||||
|
<button type="submit" class="btn btn-danger btn-lg px-5">Speichern</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div class="mt-5">
|
<div class="modal fade" id="rawJsonModal" tabindex="-1"><div class="modal-dialog modal-xl"><div class="modal-content"><div class="modal-header"><h5 class="modal-title">Erweiterte JSON-Ansicht</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div><div class="modal-body"><p class="text-muted">Änderungen hier werden beim Übernehmen wieder in den visuellen Editor geladen.</p><textarea id="raw-json-editor" class="form-control font-monospace" rows="24"></textarea><div id="raw-json-error" class="text-danger mt-2"></div></div><div class="modal-footer"><button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Schließen</button><button type="button" id="apply-raw-json" class="btn btn-dark">JSON übernehmen</button></div></div></div></div>
|
||||||
<h5>Hilfe – Beispielstruktur</h5>
|
|
||||||
<pre class="bg-light p-3 rounded">
|
|
||||||
{
|
|
||||||
"name": "BR 01 Dampflok",
|
|
||||||
"image": "br01.jpg",
|
|
||||||
"hub_id": 0,
|
|
||||||
"hub_type": "6channel",
|
|
||||||
"channels": [
|
|
||||||
{"port": "A", "type": "motor", "name": "Fahrtrichtung", "invert": false},
|
|
||||||
{"port": "C", "type": "light", "name": "Spitzenlicht", "on_value": 1.0}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script type="application/json" id="editor-data">{{ config_data|tojson }}</script>
|
||||||
|
<script type="application/json" id="sound-files-data">{{ sound_files|tojson }}</script>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script src="{{ url_for('static', filename='js/admin-config-editors.js') }}"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -1,48 +1,53 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block title %}Bearbeite Theme {{ filename }}{% endblock %}
|
{% block title %}Soundboard-Editor – {{ filename }}{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="container my-5">
|
<div class="editor-shell" data-editor="soundboard">
|
||||||
<h1>Bearbeite Soundboard-Theme</h1>
|
<div class="editor-hero soundboard-hero">
|
||||||
<h4 class="text-muted mb-4">{{ filename }}</h4>
|
<div><span class="editor-kicker">Soundboard Theme</span><h1>Klangwelt gestalten</h1><p>{{ filename }} · Sounds hinzufügen, verschieben und ihre Wiedergabe festlegen.</p></div>
|
||||||
|
<div class="d-flex gap-2 flex-wrap"><button type="button" class="btn btn-outline-light" id="show-raw-json">JSON anzeigen</button><button type="submit" form="theme-form" class="btn btn-warning px-4">Theme speichern</button></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{% if error %}
|
{% if error %}<div class="alert alert-danger">{{ error }}</div>{% endif %}
|
||||||
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
|
||||||
{{ error }}
|
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<form method="POST">
|
<form id="theme-form" method="POST">
|
||||||
<div class="mb-3">
|
<textarea class="d-none" id="config_content" name="config_content">{{ content }}</textarea>
|
||||||
<label for="config_content" class="form-label">JSON-Inhalt (Theme):</label>
|
|
||||||
<textarea class="form-control font-monospace" id="config_content" name="config_content" rows="24">{{ content }}</textarea>
|
<section class="editor-panel mb-4">
|
||||||
</div>
|
<div class="editor-section-title"><span>01</span><div><h2>Theme-Einstellungen</h2><p>Name und Standardwerte für die Zufallswiedergabe</p></div></div>
|
||||||
<div class="d-flex gap-3">
|
<div class="row g-3">
|
||||||
<button type="submit" class="btn btn-primary btn-lg px-5">Speichern</button>
|
<div class="col-md-6"><label class="form-label">Theme-Name</label><input id="theme-name" class="form-control form-control-lg"></div>
|
||||||
<a href="{{ url_for('admin.admin') }}" class="btn btn-outline-secondary btn-lg px-5">Abbrechen</a>
|
<div class="col-md-3"><label class="form-label">Max. Wiederholungen</label><input id="theme-limit" type="number" min="1" class="form-control form-control-lg"></div>
|
||||||
|
<div class="col-md-3"><label class="form-label">Zeitraum in Minuten</label><input id="theme-window" type="number" min="1" class="form-control form-control-lg"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="editor-panel mb-4">
|
||||||
|
<div class="editor-section-title compact"><span>02</span><div><h2>Neues Klangelement</h2><p>In einen Bereich ziehen oder zum Hinzufügen anklicken</p></div></div>
|
||||||
|
<div id="sound-palette" class="sound-palette">
|
||||||
|
<button type="button" draggable="true" data-sound-kind="background" class="sound-kind background"><strong>Hintergrund</strong><small>Endlosschleife</small></button>
|
||||||
|
<button type="button" draggable="true" data-sound-kind="sound" class="sound-kind effect"><strong>Einzelsound</strong><small>Manuell abspielen</small></button>
|
||||||
|
<button type="button" draggable="true" data-sound-kind="random" class="sound-kind random"><strong>Zufallssound</strong><small>Automatischer Pool</small></button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="theme-lanes">
|
||||||
|
<section class="sound-lane background-lane"><div class="lane-heading"><span class="lane-icon">∞</span><div><h2>Hintergrund</h2><p>Läuft als Loop auf einem festen Kanal</p></div></div><div class="lane-dropzone" data-dropzone="backgrounds"></div></section>
|
||||||
|
<section class="sound-lane effect-lane"><div class="lane-heading"><span class="lane-icon">▶</span><div><h2>Einzelsounds</h2><p>Gezielt über einen Button abspielen</p></div></div><div class="lane-dropzone" data-dropzone="sounds"></div></section>
|
||||||
|
<section class="sound-lane random-lane"><div class="lane-heading"><span class="lane-icon">⤨</span><div><h2>Zufallspool</h2><p>Wird durch die Soundboard-Automatik gewählt</p></div></div><div class="lane-dropzone" data-dropzone="random_pool"></div></section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-between align-items-center mt-4"><a href="{{ url_for('admin.admin') }}" class="btn btn-outline-secondary">Abbrechen</a><button type="submit" class="btn btn-warning btn-lg px-5">Theme speichern</button></div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div class="mt-4">
|
<div class="modal fade" id="rawJsonModal" tabindex="-1"><div class="modal-dialog modal-xl"><div class="modal-content"><div class="modal-header"><h5 class="modal-title">Erweiterte JSON-Ansicht</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div><div class="modal-body"><p class="text-muted">Die JSON-Ansicht bleibt für Sonderfelder und spätere Erweiterungen verfügbar.</p><textarea id="raw-json-editor" class="form-control font-monospace" rows="24"></textarea><div id="raw-json-error" class="text-danger mt-2"></div></div><div class="modal-footer"><button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Schließen</button><button type="button" id="apply-raw-json" class="btn btn-dark">JSON übernehmen</button></div></div></div></div>
|
||||||
<h5>Beispielstruktur (Theme)</h5>
|
|
||||||
<pre class="bg-light p-3 rounded">
|
|
||||||
{
|
|
||||||
"name": "Wilder Westen",
|
|
||||||
"backgrounds": [
|
|
||||||
{"id": "wind", "file": "wind.mp3", "loop": true, "channel": 0}
|
|
||||||
],
|
|
||||||
"sounds": [
|
|
||||||
{"id": "pfeife", "file": "pfeife.mp3", "channel": 2}
|
|
||||||
],
|
|
||||||
"random_pool": [
|
|
||||||
{"id": "durchsage1", "file": "durchsage1.mp3"}
|
|
||||||
],
|
|
||||||
"random_limit_per_hour": 2,
|
|
||||||
"random_window_minutes": 60
|
|
||||||
}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script type="application/json" id="editor-data">{{ theme_data|tojson }}</script>
|
||||||
|
<script type="application/json" id="sound-files-data">{{ sound_files|tojson }}</script>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script src="{{ url_for('static', filename='js/admin-config-editors.js') }}"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user