fix(ui): queue re-renders on maximize + settings sub-tabs + null-safe saveSettings

Three changes, investigated and adversarially reviewed via multi-agent workflow.

1. Queue maximize bug: the virtual-scrolled upload queue derived its visible-row
   window from #queueContainer.clientHeight but only recomputed it on 'scroll'.
   Maximizing the window enlarges the container without a scroll event, so rows
   below the old viewport stayed blank until you scrolled. Fix: a ResizeObserver
   on #queueContainer reusing the existing rAF-coalesced _onQueueScroll. Strictly
   stronger than a window 'resize' listener — it also covers the hidden->visible
   view-switch case (container 0 -> real height). No feedback loop (container box
   is flex-sized, not content-sized); early-returns for <200-row queues.

2. Einstellungen tab restructured from 4 dense stacked collapsible panels into
   horizontal sub-tabs: Allgemein / Automatik / Logs & Diagnose / Fernsteuerung /
   Backup. All sub-pages render into the DOM at once (only the active one is
   shown), so every element id stays present and saveSettings keeps working. The
   cluttered "Allgemein" block is split across Allgemein + Automatik + Logs.
   Per-hoster settings already moved to Accounts in v3.3.69.

3. saveSettings hardened to be null-safe (elTxt/elChk/elInt helpers): when a
   settings element is absent from the DOM it now keeps the current config value
   instead of collapsing to a default. Behavior is identical when elements are
   present; this is insurance against a future dropped id silently overwriting a
   real setting (e.g. webhook URL, folder-monitor hoster pre-selection).

Verified: 43 sub-tab ids unique, all 26 saveSettings ids present, lint clean,
boot clean, 284 tests green, 0 confirmed defects from a 3-lens adversarial review.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Administrator 2026-06-15 21:44:38 +02:00
parent 1e3f2ca51a
commit 1aa36cdd8b
2 changed files with 244 additions and 241 deletions

View File

@ -2906,15 +2906,30 @@ function renderSettings() {
const globalSettings = config.globalSettings || {};
const configuredAccounts = getAvailableHosters();
const generalPanel = document.createElement('div');
generalPanel.className = 'hoster-settings-panel';
generalPanel.innerHTML = `
<div class="hoster-panel-header" data-hoster="global">
<span class="panel-arrow">&#9660;</span>
<span class="panel-title">Allgemein</span>
<span class="panel-status active">System</span>
</div>
<div class="hoster-panel-body" data-panel="global" style="display:block">
const fm = globalSettings.folderMonitor || {};
const remoteSettings = globalSettings.remote || {};
const subtabBar = document.createElement('div');
subtabBar.className = 'settings-subtabs';
subtabBar.innerHTML = `
<button class="settings-subtab active" data-subtab="allgemein">Allgemein</button>
<button class="settings-subtab" data-subtab="automatik">Automatik</button>
<button class="settings-subtab" data-subtab="logs">Logs & Diagnose</button>
<button class="settings-subtab" data-subtab="remote">Fernsteuerung</button>
<button class="settings-subtab" data-subtab="backup">Backup</button>
`;
container.appendChild(subtabBar);
const pages = {};
['allgemein', 'automatik', 'logs', 'remote', 'backup'].forEach((id) => {
const page = document.createElement('div');
page.className = id === 'allgemein' ? 'settings-subpage active' : 'settings-subpage';
page.dataset.subpage = id;
pages[id] = page;
container.appendChild(page);
});
pages.allgemein.innerHTML = `
<div class="settings-section-label">Uploads</div>
<div class="settings-row">
<label style="min-width:185px">Globale parallele Uploads</label>
@ -2954,29 +2969,10 @@ function renderSettings() {
<label>Manuell prüfen</label>
<button class="btn btn-xs btn-secondary" id="manualUpdateCheckBtn">Nach Updates suchen</button>
</div>
<div class="settings-section-label">Log</div>
<div class="settings-row">
<label>FileUploader Log</label>
<input type="text" class="key-input settings-autosave" id="logFilePathInput" value="${escapeAttr(globalSettings.logFilePath || '')}" placeholder="Standardpfad verwenden">
<button class="btn btn-xs btn-secondary" id="chooseLogFilePathBtn">Ordner wählen</button>
<button class="btn btn-xs btn-secondary" id="openLogFolderBtn" title="Log-Ordner im Explorer öffnen">Öffnen</button>
</div>
<div class="settings-row">
<label>Log-Datei-Modus</label>
<select class="hs-input settings-autosave" id="logModeInput">
<option value="single" ${(window.LogMode ? window.LogMode.normalizeLogMode(globalSettings) : (globalSettings.logMode || 'single')) === 'single' ? 'selected' : ''}>Eine Datei</option>
<option value="daily" ${(window.LogMode ? window.LogMode.normalizeLogMode(globalSettings) : (globalSettings.logMode || 'single')) === 'daily' ? 'selected' : ''}>Pro Tag</option>
<option value="session" ${(window.LogMode ? window.LogMode.normalizeLogMode(globalSettings) : (globalSettings.logMode || 'single')) === 'session' ? 'selected' : ''}>Pro Session</option>
</select>
<span class="hint">Pro Session = neue Datei bei jedem App-Start; nach komplettem Schließen + erneutem Öffnen beginnt eine neue Session.</span>
</div>
<div class="settings-row">
<label>Verbose Logging</label>
<label class="checkbox-row" style="margin:0">
<input type="checkbox" class="settings-autosave" id="logVerboseInput" ${globalSettings.logVerbose ? 'checked' : ''}>
<span>DEBUG-Einträge in debug.log schreiben (Performance , Diagnostik )</span>
</label>
</div>
<div class="settings-hoster-pointer">Die <strong>Upload-Einstellungen pro Hoster</strong> (Retries, Speed, Parallel, Max-Größe, Log) sind jetzt im <strong>Accounts</strong>-Tab direkt bei den jeweiligen Hostern unter Upload-Einstellungen".</div>
`;
pages.automatik.innerHTML = `
<div class="settings-section-label">Unbeaufsichtigter Betrieb</div>
<div class="settings-row">
<label>Auto-Retry Runden</label>
@ -2999,97 +2995,7 @@ function renderSettings() {
<input type="text" class="key-input settings-autosave" id="webhookMentionInput" value="${escapeAttr(globalSettings.webhookMention || '')}" placeholder="deine User-ID, role:ROLLEN-ID, @here oder @everyone">
<span class="hint">Damit Discord dich wirklich benachrichtigt (Push). User-ID: in Discord Entwicklermodus an Rechtsklick auf deinen Namen 'User-ID kopieren'. Leer = nur posten, kein Ping.</span>
</div>
<div class="settings-section-label">Diagnose</div>
<div class="settings-row" id="logPathsBlock">
<label>Log-Dateien</label>
<div class="log-paths-list" id="logPathsList" style="flex:1;display:flex;flex-direction:column;gap:4px">
<span class="hint">Wird geladen</span>
</div>
</div>
<div class="settings-row">
<label>Support-Paket</label>
<button class="btn btn-xs btn-secondary" id="createSupportBundleBtn" title="Sammelt alle Logs + sanitierte Config (Credentials maskiert) + App-Versionen in eine einzelne .txt-Datei zum Teilen.">Diagnose-Paket exportieren</button>
<span class="hint" id="supportBundleHint">Eine .txt mit Logs + sanitierter Config; Passwörter/API-Keys werden vor dem Speichern maskiert.</span>
</div>
</div>
`;
container.appendChild(generalPanel);
_renderLogPathsList(generalPanel.querySelector('#logPathsList'));
const testWebhookBtn = generalPanel.querySelector('#testWebhookBtn');
if (testWebhookBtn) {
testWebhookBtn.addEventListener('click', async () => {
const url = (document.getElementById('webhookUrlInput')?.value || '').trim();
const mention = (document.getElementById('webhookMentionInput')?.value || '').trim();
const hint = document.getElementById('webhookHint');
if (!url) { if (hint) hint.textContent = 'Keine URL eingetragen.'; return; }
testWebhookBtn.disabled = true;
const prev = testWebhookBtn.textContent;
testWebhookBtn.textContent = 'Sende…';
try {
const res = await window.api.testWebhook({ url, mention });
if (hint) hint.textContent = res && res.ok
? `Test erfolgreich gesendet (HTTP ${res.status}).`
: `Test fehlgeschlagen: ${(res && (res.error || 'HTTP ' + res.status)) || 'unbekannt'}`;
} catch (err) {
if (hint) hint.textContent = `Test fehlgeschlagen: ${err.message || err}`;
} finally {
testWebhookBtn.disabled = false;
testWebhookBtn.textContent = prev;
}
});
}
const verboseInput = generalPanel.querySelector('#logVerboseInput');
if (verboseInput) {
verboseInput.addEventListener('change', () => {
if (window.api && window.api.setLogVerbose) window.api.setLogVerbose(verboseInput.checked).catch(() => {});
});
}
const sbBtn = generalPanel.querySelector('#createSupportBundleBtn');
if (sbBtn) {
sbBtn.addEventListener('click', async () => {
const hint = generalPanel.querySelector('#supportBundleHint');
sbBtn.disabled = true;
const prevText = sbBtn.textContent;
sbBtn.textContent = 'Exportiere…';
try {
const res = await window.api.createSupportBundle();
if (res && res.ok) {
if (hint) hint.textContent = `Gespeichert: ${res.path} (${(res.bytes/1024).toFixed(1)} KB)`;
} else if (res && res.canceled) {
if (hint) hint.textContent = 'Abgebrochen.';
} else {
if (hint) hint.textContent = `Fehler: ${(res && res.error) || 'unbekannt'}`;
}
} catch (err) {
if (hint) hint.textContent = `Fehler: ${err.message || err}`;
} finally {
sbBtn.disabled = false;
sbBtn.textContent = prevText;
}
});
}
// Toggle general panel
generalPanel.querySelector('.hoster-panel-header').addEventListener('click', () => {
const body = generalPanel.querySelector('.hoster-panel-body');
const arrow = generalPanel.querySelector('.panel-arrow');
const isOpen = body.style.display !== 'none';
body.style.display = isOpen ? 'none' : 'block';
arrow.innerHTML = isOpen ? '&#9654;' : '&#9660;';
});
// --- Folder Monitor Panel ---
const fm = globalSettings.folderMonitor || {};
const folderMonitorPanel = document.createElement('div');
folderMonitorPanel.className = 'hoster-settings-panel';
folderMonitorPanel.innerHTML = `
<div class="hoster-panel-header" data-hoster="folderMonitor">
<span class="panel-arrow">&#9654;</span>
<span class="panel-title">Ordnerüberwachung</span>
<span class="panel-status${fm.enabled && fm.folderPath ? ' active' : ''}" id="folderMonitorStatusBadge">${fm.enabled && fm.folderPath ? 'Aktiv' : 'Inaktiv'}</span>
</div>
<div class="hoster-panel-body" data-panel="folderMonitor" style="display:none">
<div class="settings-section-label">Ordner</div>
<div class="settings-section-label">Ordnerüberwachung <span class="panel-status${fm.enabled && fm.folderPath ? ' active' : ''}" id="folderMonitorStatusBadge">${fm.enabled && fm.folderPath ? 'Aktiv' : 'Inaktiv'}</span></div>
<div class="settings-row">
<label>Ordnerpfad</label>
<input type="text" class="key-input settings-autosave" id="fmFolderPathInput" value="${escapeAttr(fm.folderPath || '')}" placeholder="Ordner wählen..." style="flex:1">
@ -3136,52 +3042,48 @@ function renderSettings() {
</div>`).join('')}
</div>
${configuredAccounts.length === 0 ? '<p class="hint" style="margin:0">Erst Accounts anlegen, dann hier auswählen.</p>' : '<p class="hint" style="margin:2px 0 0">Keine Auswahl = Hoster-Modal bei jeder Datei.</p>'}
</div>
`;
container.appendChild(folderMonitorPanel);
// Toggle folder monitor panel
folderMonitorPanel.querySelector('.hoster-panel-header').addEventListener('click', () => {
const body = folderMonitorPanel.querySelector('.hoster-panel-body');
const arrow = folderMonitorPanel.querySelector('.panel-arrow');
const isOpen = body.style.display !== 'none';
body.style.display = isOpen ? 'none' : 'block';
arrow.innerHTML = isOpen ? '&#9654;' : '&#9660;';
});
pages.logs.innerHTML = `
<div class="settings-section-label">Log</div>
<div class="settings-row">
<label>FileUploader Log</label>
<input type="text" class="key-input settings-autosave" id="logFilePathInput" value="${escapeAttr(globalSettings.logFilePath || '')}" placeholder="Standardpfad verwenden">
<button class="btn btn-xs btn-secondary" id="chooseLogFilePathBtn">Ordner wählen</button>
<button class="btn btn-xs btn-secondary" id="openLogFolderBtn" title="Log-Ordner im Explorer öffnen">Öffnen</button>
</div>
<div class="settings-row">
<label>Log-Datei-Modus</label>
<select class="hs-input settings-autosave" id="logModeInput">
<option value="single" ${(window.LogMode ? window.LogMode.normalizeLogMode(globalSettings) : (globalSettings.logMode || 'single')) === 'single' ? 'selected' : ''}>Eine Datei</option>
<option value="daily" ${(window.LogMode ? window.LogMode.normalizeLogMode(globalSettings) : (globalSettings.logMode || 'single')) === 'daily' ? 'selected' : ''}>Pro Tag</option>
<option value="session" ${(window.LogMode ? window.LogMode.normalizeLogMode(globalSettings) : (globalSettings.logMode || 'single')) === 'session' ? 'selected' : ''}>Pro Session</option>
</select>
<span class="hint">Pro Session = neue Datei bei jedem App-Start; nach komplettem Schließen + erneutem Öffnen beginnt eine neue Session.</span>
</div>
<div class="settings-row">
<label>Verbose Logging</label>
<label class="checkbox-row" style="margin:0">
<input type="checkbox" class="settings-autosave" id="logVerboseInput" ${globalSettings.logVerbose ? 'checked' : ''}>
<span>DEBUG-Einträge in debug.log schreiben (Performance , Diagnostik )</span>
</label>
</div>
<div class="settings-section-label">Diagnose</div>
<div class="settings-row" id="logPathsBlock">
<label>Log-Dateien</label>
<div class="log-paths-list" id="logPathsList" style="flex:1;display:flex;flex-direction:column;gap:4px">
<span class="hint">Wird geladen</span>
</div>
</div>
<div class="settings-row">
<label>Support-Paket</label>
<button class="btn btn-xs btn-secondary" id="createSupportBundleBtn" title="Sammelt alle Logs + sanitierte Config (Credentials maskiert) + App-Versionen in eine einzelne .txt-Datei zum Teilen.">Diagnose-Paket exportieren</button>
<span class="hint" id="supportBundleHint">Eine .txt mit Logs + sanitierter Config; Passwörter/API-Keys werden vor dem Speichern maskiert.</span>
</div>
`;
// Update badge immediately on checkbox/path change
const updateFmBadge = () => {
const b = document.getElementById('folderMonitorStatusBadge');
if (!b) return;
const enabled = document.getElementById('fmEnabledInput')?.checked;
const hasPath = (document.getElementById('fmFolderPathInput')?.value || '').trim();
if (enabled && hasPath) { b.textContent = 'Aktiv'; b.className = 'panel-status active'; }
else { b.textContent = 'Inaktiv'; b.className = 'panel-status'; }
};
document.getElementById('fmEnabledInput')?.addEventListener('change', updateFmBadge);
document.getElementById('fmFolderPathInput')?.addEventListener('input', updateFmBadge);
document.getElementById('fmChooseFolderBtn')?.addEventListener('click', async () => {
const folder = await window.api.folderMonitorSelectFolder();
if (folder) {
document.getElementById('fmFolderPathInput').value = folder;
updateFmBadge();
scheduleSettingsSave();
}
});
// --- Remote Control Panel ---
const remoteSettings = globalSettings.remote || {};
const remotePanel = document.createElement('div');
remotePanel.className = 'hoster-settings-panel';
remotePanel.innerHTML = `
<div class="hoster-panel-header" data-hoster="remote">
<span class="panel-arrow">&#9654;</span>
<span class="panel-title">Fernsteuerung</span>
<span class="panel-status${remoteSettings.enabled ? ' active' : ''}" id="remoteStatusBadge">${remoteSettings.enabled ? 'Aktiv' : 'Inaktiv'}</span>
</div>
<div class="hoster-panel-body" data-panel="remote" style="display:none">
<div class="settings-section-label">Server</div>
pages.remote.innerHTML = `
<div class="settings-section-label">Server <span class="panel-status${remoteSettings.enabled ? ' active' : ''}" id="remoteStatusBadge">${remoteSettings.enabled ? 'Aktiv' : 'Inaktiv'}</span></div>
<div class="settings-grid-mini">
<div class="settings-row checkbox-row">
<label>Aktiviert</label>
@ -3206,20 +3108,103 @@ function renderSettings() {
<div class="settings-row">
<span id="remoteConnectionStatus" style="color:#94a3b8">Prüfe...</span>
</div>
</div>
`;
container.appendChild(remotePanel);
// Toggle remote panel
remotePanel.querySelector('.hoster-panel-header').addEventListener('click', () => {
const body = remotePanel.querySelector('.hoster-panel-body');
const arrow = remotePanel.querySelector('.panel-arrow');
const isOpen = body.style.display !== 'none';
body.style.display = isOpen ? 'none' : 'block';
arrow.innerHTML = isOpen ? '&#9654;' : '&#9660;';
pages.backup.innerHTML = `
<p class="hint" style="margin:0 0 10px">Alle Accounts und Einstellungen exportieren oder importieren. Der Upload-Verlauf bleibt lokal und wird nicht übertragen; nach einem Import ist der Verlauf-Tab leer.</p>
<div style="display:flex;gap:8px">
<button class="btn btn-secondary" id="exportBackupBtn">Backup exportieren</button>
<button class="btn btn-secondary" id="importBackupBtn">Backup importieren</button>
</div>
`;
subtabBar.addEventListener('click', (e) => {
const btn = e.target.closest('[data-subtab]');
if (!btn) return;
const target = btn.dataset.subtab;
subtabBar.querySelectorAll('.settings-subtab').forEach((b) => {
b.classList.toggle('active', b === btn);
});
Object.values(pages).forEach((p) => {
p.classList.toggle('active', p.dataset.subpage === target);
});
});
_renderLogPathsList(document.getElementById('logPathsList'));
const testWebhookBtn = document.getElementById('testWebhookBtn');
if (testWebhookBtn) {
testWebhookBtn.addEventListener('click', async () => {
const url = (document.getElementById('webhookUrlInput')?.value || '').trim();
const mention = (document.getElementById('webhookMentionInput')?.value || '').trim();
const hint = document.getElementById('webhookHint');
if (!url) { if (hint) hint.textContent = 'Keine URL eingetragen.'; return; }
testWebhookBtn.disabled = true;
const prev = testWebhookBtn.textContent;
testWebhookBtn.textContent = 'Sende…';
try {
const res = await window.api.testWebhook({ url, mention });
if (hint) hint.textContent = res && res.ok
? `Test erfolgreich gesendet (HTTP ${res.status}).`
: `Test fehlgeschlagen: ${(res && (res.error || 'HTTP ' + res.status)) || 'unbekannt'}`;
} catch (err) {
if (hint) hint.textContent = `Test fehlgeschlagen: ${err.message || err}`;
} finally {
testWebhookBtn.disabled = false;
testWebhookBtn.textContent = prev;
}
});
}
const verboseInput = document.getElementById('logVerboseInput');
if (verboseInput) {
verboseInput.addEventListener('change', () => {
if (window.api && window.api.setLogVerbose) window.api.setLogVerbose(verboseInput.checked).catch(() => {});
});
}
const sbBtn = document.getElementById('createSupportBundleBtn');
if (sbBtn) {
sbBtn.addEventListener('click', async () => {
const hint = document.getElementById('supportBundleHint');
sbBtn.disabled = true;
const prevText = sbBtn.textContent;
sbBtn.textContent = 'Exportiere…';
try {
const res = await window.api.createSupportBundle();
if (res && res.ok) {
if (hint) hint.textContent = `Gespeichert: ${res.path} (${(res.bytes/1024).toFixed(1)} KB)`;
} else if (res && res.canceled) {
if (hint) hint.textContent = 'Abgebrochen.';
} else {
if (hint) hint.textContent = `Fehler: ${(res && res.error) || 'unbekannt'}`;
}
} catch (err) {
if (hint) hint.textContent = `Fehler: ${err.message || err}`;
} finally {
sbBtn.disabled = false;
sbBtn.textContent = prevText;
}
});
}
const updateFmBadge = () => {
const b = document.getElementById('folderMonitorStatusBadge');
if (!b) return;
const enabled = document.getElementById('fmEnabledInput')?.checked;
const hasPath = (document.getElementById('fmFolderPathInput')?.value || '').trim();
if (enabled && hasPath) { b.textContent = 'Aktiv'; b.className = 'panel-status active'; }
else { b.textContent = 'Inaktiv'; b.className = 'panel-status'; }
};
document.getElementById('fmEnabledInput')?.addEventListener('change', updateFmBadge);
document.getElementById('fmFolderPathInput')?.addEventListener('input', updateFmBadge);
document.getElementById('fmChooseFolderBtn')?.addEventListener('click', async () => {
const folder = await window.api.folderMonitorSelectFolder();
if (folder) {
document.getElementById('fmFolderPathInput').value = folder;
updateFmBadge();
scheduleSettingsSave();
}
});
// Copy token
document.getElementById('remoteCopyTokenBtn').addEventListener('click', async () => {
const token = document.getElementById('remoteTokenInput').value;
if (token) {
@ -3229,14 +3214,12 @@ function renderSettings() {
}
});
// Regenerate token
document.getElementById('remoteRegenerateTokenBtn').addEventListener('click', async () => {
const newToken = await window.api.remoteGenerateToken();
document.getElementById('remoteTokenInput').value = newToken;
scheduleSettingsSave();
});
// Update status
window.api.remoteStatus().then(status => {
const el = document.getElementById('remoteConnectionStatus');
if (!el) return;
@ -3249,43 +3232,9 @@ function renderSettings() {
}
}).catch(() => {});
// Live client count updates (listener registered once in init, not here)
// --- Backup Panel ---
const backupPanel = document.createElement('div');
backupPanel.className = 'hoster-settings-panel';
backupPanel.innerHTML = `
<div class="hoster-panel-header" data-hoster="backup">
<span class="panel-arrow">&#9654;</span>
<span class="panel-title">Backup</span>
<span class="panel-status active">System</span>
</div>
<div class="hoster-panel-body" data-panel="backup" style="display:none">
<p class="hint" style="margin:0 0 10px">Alle Accounts und Einstellungen exportieren oder importieren. Der Upload-Verlauf bleibt lokal und wird nicht übertragen; nach einem Import ist der Verlauf-Tab leer.</p>
<div style="display:flex;gap:8px">
<button class="btn btn-secondary" id="exportBackupBtn">Backup exportieren</button>
<button class="btn btn-secondary" id="importBackupBtn">Backup importieren</button>
</div>
</div>
`;
container.appendChild(backupPanel);
backupPanel.querySelector('.hoster-panel-header').addEventListener('click', () => {
const body = backupPanel.querySelector('.hoster-panel-body');
const arrow = backupPanel.querySelector('.panel-arrow');
const isOpen = body.style.display !== 'none';
body.style.display = isOpen ? 'none' : 'block';
arrow.innerHTML = isOpen ? '&#9654;' : '&#9660;';
});
document.getElementById('exportBackupBtn').addEventListener('click', () => doBackupExport());
document.getElementById('importBackupBtn').addEventListener('click', () => doBackupImport());
const hosterPointer = document.createElement('div');
hosterPointer.className = 'settings-hoster-pointer';
hosterPointer.innerHTML = 'Die <strong>Upload-Einstellungen pro Hoster</strong> (Retries, Speed, Parallel, Max-Größe, Log) sind jetzt im <strong>Accounts</strong>-Tab — direkt bei den jeweiligen Hostern unter „Upload-Einstellungen".';
container.appendChild(hosterPointer);
document.getElementById('chooseLogFilePathBtn')?.addEventListener('click', chooseLogFilePath);
document.getElementById('openLogFolderBtn')?.addEventListener('click', () => window.api.openLogFolder());
document.getElementById('manualUpdateCheckBtn')?.addEventListener('click', async (e) => {
@ -3333,42 +3282,62 @@ function scheduleSettingsSave() {
async function saveSettings(options = {}) {
const { feedbackText = 'Gespeichert!' } = options;
const newHosterSettings = { ...(config.hosterSettings || {}) };
const cur = config.globalSettings || {};
const curFm = cur.folderMonitor || {};
const curRemote = cur.remote || {};
const elTxt = (id, fb) => { const el = document.getElementById(id); return el ? el.value : fb; };
const elChk = (id, fb) => { const el = document.getElementById(id); return el ? !!el.checked : fb; };
const elInt = (id, curVal, dflt, lo, hi) => {
const el = document.getElementById(id);
if (!el) return curVal;
const n = parseInt(el.value || String(dflt), 10) || dflt;
return Math.max(lo, Math.min(hi, n));
};
const globalSettings = {
...(config.globalSettings || {}),
logFilePath: (document.getElementById('logFilePathInput')?.value || '').trim(),
...cur,
logFilePath: elTxt('logFilePathInput', cur.logFilePath || '').trim(),
logMode: (() => {
const v = document.getElementById('logModeInput')?.value;
const el = document.getElementById('logModeInput');
if (!el) return cur.logMode || 'single';
const v = el.value;
return (v === 'single' || v === 'daily' || v === 'session') ? v : 'single';
})(),
resumeQueueOnLaunch: !!document.getElementById('resumeQueueOnLaunchInput')?.checked,
parallelUploadCount: Math.max(0, Math.min(100, parseInt(document.getElementById('parallelUploadCountInput')?.value || '0', 10) || 0)),
scaleParallelUploads: !!document.getElementById('scaleParallelUploadsInput')?.checked,
removeFromQueueOnDone: !!document.getElementById('removeFromQueueOnDoneInput')?.checked,
showDropTarget: !!document.getElementById('showDropTargetInput')?.checked,
globalMaxSpeedKbs: Math.max(0, Math.round((parseFloat(document.getElementById('globalMaxSpeedMbsInput')?.value || '0') || 0) * 1024)),
logVerbose: !!document.getElementById('logVerboseInput')?.checked,
webhookUrl: (document.getElementById('webhookUrlInput')?.value || '').trim(),
webhookMention: (document.getElementById('webhookMentionInput')?.value || '').trim(),
autoRetryRounds: Math.max(0, Math.min(5, parseInt(document.getElementById('autoRetryRoundsInput')?.value || '0', 10) || 0)),
autoRetryDelayMin: Math.max(1, Math.min(120, parseInt(document.getElementById('autoRetryDelayMinInput')?.value || '5', 10) || 5)),
resumeQueueOnLaunch: elChk('resumeQueueOnLaunchInput', cur.resumeQueueOnLaunch !== false),
parallelUploadCount: elInt('parallelUploadCountInput', cur.parallelUploadCount ?? 0, 0, 0, 100),
scaleParallelUploads: elChk('scaleParallelUploadsInput', !!cur.scaleParallelUploads),
removeFromQueueOnDone: elChk('removeFromQueueOnDoneInput', !!cur.removeFromQueueOnDone),
showDropTarget: elChk('showDropTargetInput', !!cur.showDropTarget),
globalMaxSpeedKbs: (() => {
const el = document.getElementById('globalMaxSpeedMbsInput');
if (!el) return cur.globalMaxSpeedKbs ?? 0;
return Math.max(0, Math.round((parseFloat(el.value || '0') || 0) * 1024));
})(),
logVerbose: elChk('logVerboseInput', !!cur.logVerbose),
webhookUrl: elTxt('webhookUrlInput', cur.webhookUrl || '').trim(),
webhookMention: elTxt('webhookMentionInput', cur.webhookMention || '').trim(),
autoRetryRounds: elInt('autoRetryRoundsInput', cur.autoRetryRounds ?? 0, 0, 0, 5),
autoRetryDelayMin: elInt('autoRetryDelayMinInput', cur.autoRetryDelayMin ?? 5, 5, 1, 120),
folderMonitor: {
...((config.globalSettings || {}).folderMonitor || {}),
enabled: !!document.getElementById('fmEnabledInput')?.checked,
folderPath: (document.getElementById('fmFolderPathInput')?.value || '').trim(),
recursive: !!document.getElementById('fmRecursiveInput')?.checked,
filterMode: document.getElementById('fmFilterModeInput')?.value || 'include',
extensions: (document.getElementById('fmExtensionsInput')?.value || '').trim(),
skipDuplicates: !!document.getElementById('fmSkipDuplicatesInput')?.checked,
delaySec: Math.max(1, parseInt(document.getElementById('fmDelaySecInput')?.value || '3', 10) || 3),
autoStart: !!document.getElementById('fmAutoStartInput')?.checked,
hosters: Array.from(document.querySelectorAll('.fm-hoster-checkbox:checked')).map(el => el.dataset.fmHoster)
...curFm,
enabled: elChk('fmEnabledInput', !!curFm.enabled),
folderPath: elTxt('fmFolderPathInput', curFm.folderPath || '').trim(),
recursive: elChk('fmRecursiveInput', !!curFm.recursive),
filterMode: (() => { const el = document.getElementById('fmFilterModeInput'); return el ? (el.value || 'include') : (curFm.filterMode || 'include'); })(),
extensions: elTxt('fmExtensionsInput', curFm.extensions || '').trim(),
skipDuplicates: elChk('fmSkipDuplicatesInput', curFm.skipDuplicates !== false),
delaySec: elInt('fmDelaySecInput', curFm.delaySec ?? 3, 3, 1, 300),
autoStart: elChk('fmAutoStartInput', curFm.autoStart !== false),
hosters: document.querySelector('.fm-hoster-checkbox')
? Array.from(document.querySelectorAll('.fm-hoster-checkbox:checked')).map(el => el.dataset.fmHoster)
: (curFm.hosters || [])
},
remote: {
...((config.globalSettings || {}).remote || {}),
enabled: !!document.getElementById('remoteEnabledInput')?.checked,
port: Math.max(1024, Math.min(65535, parseInt(document.getElementById('remotePortInput')?.value || '9100', 10) || 9100)),
token: (document.getElementById('remoteTokenInput')?.value || '').trim(),
allowInput: !!document.getElementById('remoteAllowInputInput')?.checked
...curRemote,
enabled: elChk('remoteEnabledInput', !!curRemote.enabled),
port: elInt('remotePortInput', curRemote.port || 9100, 9100, 1024, 65535),
token: elTxt('remoteTokenInput', curRemote.token || '').trim(),
allowInput: elChk('remoteAllowInputInput', curRemote.allowInput !== false)
}
};
@ -4771,6 +4740,9 @@ function setupListeners() {
// Virtual scroll for large queues
const queueContainer = document.getElementById('queueContainer');
if (queueContainer) queueContainer.addEventListener('scroll', _onQueueScroll, { passive: true });
if (queueContainer && typeof window.ResizeObserver !== 'undefined') {
new window.ResizeObserver(_onQueueScroll).observe(queueContainer);
}
// Queue table sorting
document.querySelectorAll('#queueTable th.sortable').forEach(th => {

View File

@ -842,6 +842,37 @@ body.col-resizing, body.col-resizing * { cursor: col-resize !important; user-sel
.settings-divider { height: 1px; background: var(--border); margin: 12px 0; }
.hoster-panel-body h4 { font-size: 12px; color: var(--text-muted); margin-bottom: 8px; font-weight: 500; }
.settings-subtabs {
display: flex;
gap: 4px;
margin-bottom: 14px;
border-bottom: 1px solid var(--border);
flex-wrap: wrap;
}
.settings-subtab {
background: none;
border: none;
color: var(--text-muted);
font-size: 12px;
font-weight: 600;
letter-spacing: 0.03em;
text-transform: uppercase;
padding: 8px 14px;
cursor: pointer;
border-bottom: 2px solid transparent;
transition: color 0.15s, border-color 0.15s;
}
.settings-subtab:hover { color: var(--text); }
.settings-subtab.active { color: var(--text); border-bottom-color: var(--accent); }
.settings-subpage {
display: none;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
padding: 4px 14px 14px;
}
.settings-subpage.active { display: block; }
.settings-row {
display: flex;
align-items: center;