Multi-Hoster-Upload/renderer/app.js
Administrator 849b1e340b feat: 'Alle entfernen' button for recent files panel
Adds a red 'Alle entfernen' button next to the 'Zuletzt erzeugte
Upload-Links' label that clears all entries from the recent files
panel after confirmation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 23:25:42 +02:00

3708 lines
143 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const HOSTERS = ['doodstream.com', 'voe.sx', 'vidmoly.me', 'byse.sx'];
// Dropdown options for "Add Account" modal: value -> label
const HOSTER_ADD_OPTIONS = [
{ value: 'doodstream.com', label: 'Doodstream (Web Login)', hoster: 'doodstream.com', authType: 'login' },
{ value: 'doodstream.com:api', label: 'Doodstream (API)', hoster: 'doodstream.com', authType: 'api' },
{ value: 'voe.sx', label: 'Voe (Web Login)', hoster: 'voe.sx', authType: 'login' },
{ value: 'voe.sx:api', label: 'Voe (API)', hoster: 'voe.sx', authType: 'api' },
{ value: 'vidmoly.me', label: 'Vidmoly (Web Login)', hoster: 'vidmoly.me', authType: 'login' },
{ value: 'byse.sx', label: 'Byse (API)', hoster: 'byse.sx', authType: 'api' }
];
// --- State ---
let selectedFiles = []; // { path, name, size }
let selectedUploadHosters = [];
let config = { hosters: {}, hosterSettings: {}, globalSettings: {} };
let hosterSettings = {};
let uploading = false;
let healthCheckRunning = false;
let accountStatuses = {}; // { accountId: { status: 'ok'|'warn'|'error'|'checking'|'unchecked', message: '' } }
let editingAccountId = null; // null = adding, string = editing account by ID
let autoHealthCheckEnabled = true;
let queuePersistTimer = null;
let settingsSaveTimer = null;
let lastUploadStats = { state: 'idle', globalSpeedKbs: 0, totalBytes: 0, elapsed: 0, activeJobs: 0 };
const AUTO_CHECK_PREF_KEY = 'autoHealthCheckBeforeUpload';
const QUEUE_COL_WIDTHS_KEY = 'queueColumnWidthsPx';
const STARTABLE_QUEUE_STATUSES = new Set(['preview', 'queued', 'error', 'aborted', 'skipped']);
function isStartableQueueStatus(status) {
return STARTABLE_QUEUE_STATUSES.has(status);
}
function isStartableQueueJob(job) {
return !!job && isStartableQueueStatus(job.status);
}
// Queue state
let queueJobs = []; // { id, file, fileName, hoster, status, bytesUploaded, bytesTotal, speedKbs, elapsed, remaining, error, result, attempt, maxAttempts, link }
const _jobIndexById = new Map(); // id -> job (O(1) lookup)
const _jobIndexByUploadId = new Map(); // uploadId -> job
const selectedJobIds = new Set();
let _sessionTotalBytes = 0; // Total bytes ever added to queue this session
let _sessionUploadedBytes = 0; // Bytes fully uploaded this session (done jobs)
const _sessionTrackedJobs = new Set(); // Job IDs already counted for totalBytes
const _sessionDoneJobs = new Set(); // Job IDs already counted for uploadedBytes
const _completedUploadKeys = new Set(); // 'filepath|hoster' keys for done uploads (survives removeFromQueueOnDone)
const _deletedJobIds = new Set(); // IDs of jobs explicitly deleted by user (prevents re-creation from stale progress callbacks)
const queueSortState = { key: 'filename', direction: 'asc' };
// History state
let historyRowsData = [];
let historySortState = { key: 'date', direction: 'desc' };
// Session-specific files for the "Files" panel (resets each session)
let sessionFilesData = [];
const recentSortState = { key: 'date', direction: 'desc' };
const selectedRecentIds = new Set();
// --- Init ---
async function init() {
config = await window.api.getConfig();
hosterSettings = config.hosterSettings || {};
autoHealthCheckEnabled = loadAutoCheckPreference();
ensureAccountStatusEntries();
syncSelectedUploadHosters();
restoreQueueStateFromConfig();
await _autoDeduplicateFromLog();
renderHosterSummary();
renderHosterModal();
renderSettings();
renderAccounts();
setupListeners();
setupDragDrop();
restoreQueueColumnWidths();
loadHistory();
renderRecentUploadsPanel();
updateUploadView();
updateStatusBar();
// Version display
try {
const version = await window.api.getVersion();
const versionLabel = document.getElementById('versionLabel');
if (versionLabel) versionLabel.textContent = `v${version}`;
} catch {}
// Update listeners
window.api.onUpdateAvailable(showUpdateBanner);
window.api.onUpdateProgress(handleUpdateProgress);
// Upload event listeners — with debug logging to file
window.api.onUploadProgress((data) => {
window.api.debugLog('RX upload-progress: ' + data.status + ' ' + data.hoster + ' ' + (data.fileName || ''));
handleProgress(data);
});
window.api.onUploadBatchDone((data) => {
window.api.debugLog('RX upload-batch-done');
handleBatchDone(data);
});
window.api.onUploadStats((data) => {
window.api.debugLog('RX upload-stats: state=' + data.state + ' active=' + data.activeJobs);
handleStats(data);
});
window.api.onShutdownCountdown(handleShutdownCountdown);
// Folder monitor: auto-queue new files
window.api.onFolderMonitorNewFiles((files) => {
window.api.debugLog('folder-monitor: received ' + files.length + ' file(s)');
const fm = config.globalSettings && config.globalSettings.folderMonitor;
const fmHosters = fm && Array.isArray(fm.hosters) && fm.hosters.length > 0 ? fm.hosters : [];
if (fmHosters.length > 0) {
// Pre-selected hosters: set them as active selection and add directly to queue
selectedUploadHosters = fmHosters.slice();
const newFiles = [];
for (const p of files) {
if (!selectedFiles.find(f => f.path === p) && !_pendingFiles.find(f => f.path === p)) {
const name = p.split('\\').pop().split('/').pop();
newFiles.push({ path: p, name, size: null });
}
}
if (newFiles.length > 0) {
const newPaths = new Set(newFiles.map(f => f.path));
selectedFiles.push(...newFiles);
buildQueuePreview();
updateUploadView();
if (fm.autoStart && !uploading && !healthCheckRunning) {
startUpload();
} else if (uploading) {
// Inject new preview jobs into the running batch
const newJobs = queueJobs.filter(j => j.status === 'preview' && newPaths.has(j.file));
if (newJobs.length > 0) {
newJobs.forEach(j => { j.status = 'queued'; });
renderQueueTable();
window.api.addJobsToBatch({
jobs: newJobs.map(j => ({ id: j.id, file: j.file, fileName: j.fileName, hoster: j.hoster }))
}).then(result => { _markSkippedJobs(result); }).catch(() => {});
persistQueueStateSoon(true);
}
}
}
} else {
// No pre-selected hosters: open modal
addPathsToQueue(files);
}
});
// Account switched notification
window.api.onAccountSwitched((data) => {
window.api.debugLog(`account-switched: ${data.hoster} ${data.fromAccountId} -> ${data.toAccountId}`);
});
// Drop target window: files dropped on the small floating window
window.api.onDropTargetFiles((paths) => {
addPathsToQueue(paths);
});
// Remote client count updates (registered once, not per renderSettings call)
window.api.onRemoteClientCount(() => {
const el = document.getElementById('remoteConnectionStatus');
if (el && el.style.color === 'rgb(16, 185, 129)') {
window.api.remoteStatus().then(status => {
if (status.running) {
el.textContent = `Aktiv auf Port ${status.port}${status.clientCount} Client(s) verbunden`;
}
}).catch(() => {});
}
});
window.api.debugLog('init complete, all listeners registered');
// Restore always-on-top state
try {
const onTop = await window.api.getAlwaysOnTop();
alwaysOnTopState = !!onTop;
} catch {}
scheduleStartupAccountCheck();
}
// --- Tab switching ---
document.querySelectorAll('.tab').forEach(tab => {
tab.addEventListener('click', () => {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.view').forEach(v => v.classList.remove('active'));
tab.classList.add('active');
document.getElementById(`${tab.dataset.view}-view`).classList.add('active');
if (tab.dataset.view === 'history') loadHistory();
});
});
// --- Hoster selection ---
function accountHasCreds(name, account) {
if (!account) return false;
if (account.authType === 'api') return !!account.apiKey;
if (account.authType === 'login') return !!(account.username && account.password);
// Fallback
if (name === 'vidmoly.me') return !!(account.username && account.password);
if (name === 'voe.sx' || name === 'doodstream.com') return !!(account.username && account.password) || !!account.apiKey;
return !!account.apiKey;
}
// Returns hosters that have at least one enabled account with credentials
function getAvailableHosters() {
const result = [];
for (const name of HOSTERS) {
const accounts = config.hosters[name];
if (!Array.isArray(accounts)) continue;
const hasEnabledAccount = accounts.some(a => a.enabled !== false && accountHasCreds(name, a));
if (hasEnabledAccount) result.push({ name });
}
return result;
}
function syncSelectedUploadHosters() {
const available = new Set(getAvailableHosters().map(item => item.name));
selectedUploadHosters = selectedUploadHosters.filter(name => available.has(name));
if (selectedUploadHosters.length === 0) {
selectedUploadHosters = HOSTERS.filter(name => {
const accounts = config.hosters[name];
return Array.isArray(accounts) && accounts.some(a => a.enabled !== false && accountHasCreds(name, a));
});
}
}
function getSelectedHosters() {
return selectedUploadHosters.slice();
}
function getHosterLabel(name) {
const labels = {
'doodstream.com': 'Doodstream',
'voe.sx': 'VOE',
'vidmoly.me': 'Vidmoly',
'byse.sx': 'Byse'
};
return labels[name] || name;
}
function getAccountAuthLabel(account) {
if (!account) return '';
if (account.authType === 'api') return 'API';
if (account.authType === 'login') return 'Web Login';
return '';
}
function getAccountDisplayName(name, account) {
const authLabel = getAccountAuthLabel(account);
return authLabel
? `${getHosterLabel(name)} (${authLabel})`
: getHosterLabel(name);
}
function maskCredential(value, keep = 4) {
const text = String(value || '').trim();
if (!text) return '';
if (text.length <= keep) return text;
return `${text.slice(0, keep)}${text.slice(-2)}`;
}
function ensureAccountStatusEntries() {
const nextStatuses = {};
for (const { account } of getAllAccountsFlat()) {
if (account.id) {
nextStatuses[account.id] = accountStatuses[account.id] || { status: 'unchecked', message: '' };
}
}
accountStatuses = nextStatuses;
}
// Returns flat array of all accounts: [{ name, account, index }]
function getAllAccountsFlat() {
const result = [];
for (const name of HOSTERS) {
const accounts = config.hosters[name];
if (!Array.isArray(accounts)) continue;
accounts.forEach((account, index) => result.push({ name, account, index }));
}
return result;
}
// Returns flat array of accounts with credentials
function getAccountsWithCredsFlat() {
return getAllAccountsFlat().filter(({ name, account }) => accountHasCreds(name, account));
}
// Find account by ID across all hosters
function findAccountById(accountId) {
for (const name of HOSTERS) {
const accounts = config.hosters[name];
if (!Array.isArray(accounts)) continue;
const account = accounts.find(a => a.id === accountId);
if (account) return { name, account };
}
return null;
}
function scheduleStartupAccountCheck() {
const accounts = getAccountsWithCredsFlat();
if (!accounts.length) return;
setTimeout(() => {
runHealthCheck('startup').catch(() => {});
}, 500);
}
function renderHosterSummary() {
const summary = document.getElementById('hosterSummary');
if (!summary) return;
const hosters = getSelectedHosters();
if (hosters.length === 0) {
summary.textContent = 'Keine Upload-Ziele ausgewählt';
} else if (hosters.length === 1) {
summary.textContent = `Aktives Ziel: ${getHosterLabel(hosters[0])}`;
} else {
summary.textContent = `${hosters.length} Ziele aktiv: ${hosters.map((name) => getHosterLabel(name)).join(', ')}`;
}
}
function renderHosterModal() {
const list = document.getElementById('hosterModalList');
const hint = document.getElementById('hosterModalHint');
if (!list || !hint) return;
const available = getAvailableHosters();
if (available.length === 0) {
list.innerHTML = '';
hint.textContent = 'Keine Hoster mit Zugangsdaten vorhanden. Bitte zuerst in den Accounts einen Login oder API-Key hinterlegen.';
return;
}
list.innerHTML = available.map(item => {
const checked = selectedUploadHosters.includes(item.name);
// Get first enabled account's status for subtitle
const accounts = config.hosters[item.name] || [];
const enabledAccounts = accounts.filter(a => a.enabled !== false && accountHasCreds(item.name, a));
const accountCount = enabledAccounts.length;
let subtitle = `${accountCount} Account${accountCount !== 1 ? 's' : ''}`;
// Check if any account has ok status
const hasOk = enabledAccounts.some(a => accountStatuses[a.id] && accountStatuses[a.id].status === 'ok');
const hasError = enabledAccounts.some(a => accountStatuses[a.id] && accountStatuses[a.id].status === 'error');
if (hasOk) subtitle += ' • Bereit';
else if (hasError) subtitle += ' • Fehler';
return `
<label class="hoster-option${checked ? ' selected' : ''}" data-hoster-option="${item.name}">
<input type="checkbox" data-hoster-modal="${item.name}" ${checked ? 'checked' : ''}>
<div class="hoster-option-main">
<div class="hoster-option-title">${escapeHtml(getHosterLabel(item.name))}</div>
<div class="hoster-option-subtitle">${subtitle}</div>
</div>
</label>
`;
}).join('');
hint.textContent = 'Die Auswahl wird für neue Queue-Einträge verwendet.';
list.querySelectorAll('input[data-hoster-modal]').forEach(input => {
input.addEventListener('change', () => {
input.closest('.hoster-option')?.classList.toggle('selected', input.checked);
});
});
}
function openHosterModal() {
syncSelectedUploadHosters();
renderHosterModal();
document.getElementById('hosterModal').style.display = 'flex';
}
function closeHosterModal() {
const modal = document.getElementById('hosterModal');
if (modal) modal.style.display = 'none';
}
function applyHosterSelection() {
selectedUploadHosters = Array.from(document.querySelectorAll('input[data-hoster-modal]:checked'))
.map(input => input.dataset.hosterModal);
// Move pending files to selectedFiles on confirm
const pendingPaths = new Set(_pendingFiles.map(f => f.path));
if (_pendingFiles.length > 0) {
selectedFiles.push(..._pendingFiles);
_pendingFiles = [];
}
renderHosterSummary();
// During an active upload, build preview jobs for the new files and inject
// them into the running batch immediately (otherwise they'd be lost on
// handleBatchDone via syncSelectedFilesFromQueue)
if (uploading && pendingPaths.size > 0) {
buildQueuePreview(); // creates 'preview' jobs for new files
const newJobs = queueJobs.filter(j => j.status === 'preview' && pendingPaths.has(j.file));
if (newJobs.length > 0) {
newJobs.forEach(j => { j.status = 'queued'; });
renderQueueTable();
window.api.addJobsToBatch({
jobs: newJobs.map(j => ({ id: j.id, file: j.file, fileName: j.fileName, hoster: j.hoster }))
}).then(result => { _markSkippedJobs(result); }).catch(() => {});
persistQueueStateSoon(true);
}
}
updateUploadView();
persistQueueStateSoon(true); // immediate persist after adding files
document.getElementById('hosterModal').style.display = 'none';
}
function cancelHosterModal() {
_pendingFiles = [];
closeHosterModal();
}
function normalizeRestoredJobStatus(status) {
if (status === 'done' || status === 'error' || status === 'skipped' || status === 'preview' || status === 'aborted') return status;
return 'queued';
}
function restoreQueueStateFromConfig() {
if (config?.globalSettings?.resumeQueueOnLaunch === false) return;
const pending = config?.globalSettings?.pendingQueue;
if (!pending || typeof pending !== 'object') return;
selectedUploadHosters = Array.isArray(pending.selectedUploadHosters)
? pending.selectedUploadHosters.filter(Boolean)
: selectedUploadHosters;
selectedFiles = Array.isArray(pending.selectedFiles)
? pending.selectedFiles
.filter(file => file && file.path)
.map(file => ({ path: file.path, name: file.name || file.path.split(/[\\/]/).pop(), size: file.size || 0 }))
: [];
const rawJobs = Array.isArray(pending.queueJobs)
? pending.queueJobs
.filter(job => job && job.fileName && job.hoster)
.map(job => ({
id: job.id || `restored-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
uploadId: null,
file: job.file || '',
fileName: job.fileName,
hoster: job.hoster,
status: normalizeRestoredJobStatus(job.status),
bytesUploaded: job.status === 'done' ? (job.bytesTotal || 0) : 0,
bytesTotal: job.bytesTotal || 0,
speedKbs: 0,
elapsed: 0,
remaining: 0,
error: job.error || null,
result: job.result || null,
attempt: 0,
maxAttempts: job.maxAttempts || 0,
link: '',
progress: job.status === 'done' ? 1 : 0
}))
: [];
// Deduplicate: keep the job with the best status for each file+hoster pair
const seen = new Map();
const statusPriority = { done: 0, uploading: 1, queued: 2, preview: 3, error: 4, aborted: 5, skipped: 6 };
for (const job of rawJobs) {
const key = `${job.file}|${job.hoster}`;
const existing = seen.get(key);
if (!existing || (statusPriority[job.status] ?? 9) < (statusPriority[existing.status] ?? 9)) {
seen.set(key, job);
}
}
queueJobs = Array.from(seen.values());
rebuildJobIndex();
}
function buildPersistedQueueState() {
const persistableJobs = queueJobs.filter(job => !['done', 'skipped'].includes(job.status));
const selectedFileMap = new Map(selectedFiles.map(file => [file.path, file]));
for (const job of persistableJobs) {
if (job.file && !selectedFileMap.has(job.file)) {
selectedFileMap.set(job.file, {
path: job.file,
name: job.fileName,
size: job.bytesTotal || 0
});
}
}
if (selectedFileMap.size === 0 && queueJobs.every(job => ['done', 'skipped'].includes(job.status))) {
return null;
}
return {
selectedUploadHosters: getSelectedHosters(),
selectedFiles: Array.from(selectedFileMap.values()),
queueJobs: queueJobs.map(job => ({
id: job.id,
file: job.file,
fileName: job.fileName,
hoster: job.hoster,
// Save aborted jobs as queued so they survive restart
status: job.status === 'aborted' ? 'queued' : job.status,
bytesTotal: job.bytesTotal || 0,
error: job.status === 'aborted' ? null : (job.error || null),
result: job.result || null,
maxAttempts: job.maxAttempts || 0
}))
};
}
async function persistQueueStateNow() {
const globalSettings = {
...(config.globalSettings || {}),
pendingQueue: buildPersistedQueueState()
};
config.globalSettings = globalSettings;
await window.api.saveGlobalSettings(globalSettings);
}
function persistQueueStateSoon(immediate) {
clearTimeout(queuePersistTimer);
if (immediate) {
persistQueueStateNow().catch(() => {});
return;
}
// Use longer debounce during uploads to reduce disk I/O
const delay = uploading ? 10000 : 500;
queuePersistTimer = setTimeout(() => {
persistQueueStateNow().catch(() => {});
}, delay);
}
function clearPersistedQueueStateSoon() {
clearTimeout(queuePersistTimer);
queuePersistTimer = setTimeout(() => {
const globalSettings = {
...(config.globalSettings || {}),
pendingQueue: null
};
config.globalSettings = globalSettings;
window.api.saveGlobalSettings(globalSettings).catch(() => {});
}, 0);
}
// --- File selection ---
function setupDragDrop() {
const dropZone = document.getElementById('dropZone');
// Allow drop on the entire upload view
const uploadView = document.getElementById('upload-view');
let _dragCounter = 0;
dropZone.addEventListener('dragover', (e) => { e.preventDefault(); e.stopPropagation(); });
dropZone.addEventListener('dragenter', (e) => { e.preventDefault(); _dragCounter++; dropZone.classList.add('drag-over'); });
dropZone.addEventListener('dragleave', (e) => { e.preventDefault(); _dragCounter--; if (_dragCounter <= 0) { _dragCounter = 0; dropZone.classList.remove('drag-over'); } });
dropZone.addEventListener('drop', (e) => {
e.preventDefault(); e.stopPropagation(); _dragCounter = 0; dropZone.classList.remove('drag-over');
addDroppedFiles(e.dataTransfer.files).catch(console.error);
});
dropZone.addEventListener('click', () => pickFiles());
// Also handle drops on queue container
uploadView.addEventListener('dragover', (e) => { e.preventDefault(); });
uploadView.addEventListener('drop', (e) => {
e.preventDefault();
if (e.target.closest('.drop-zone')) return; // handled above
addDroppedFiles(e.dataTransfer.files).catch(console.error);
});
}
let _pendingFiles = []; // Files waiting for hoster modal confirmation
let _addingDropped = false;
async function addDroppedFiles(fileList) {
if (_addingDropped) return;
_addingDropped = true;
try {
const files = Array.from(fileList);
const existingPaths = new Set([
...selectedFiles.map(f => f.path),
..._pendingFiles.map(f => f.path)
]);
const newFiles = [];
for (const file of files) {
let filePath = '';
try { filePath = window.api.getPathForFile(file); } catch { filePath = file.path || ''; }
if (!filePath) continue;
// Detect folders: directories report size 0 and empty type in Electron drag-and-drop
if (file.type === '' && file.size === 0) {
try {
const folderFiles = await window.api.resolveFolderFiles(filePath);
if (folderFiles && folderFiles.length > 0) {
for (const fp of folderFiles) {
if (!existingPaths.has(fp)) {
const name = fp.split('\\').pop().split('/').pop();
newFiles.push({ path: fp, name, size: null });
existingPaths.add(fp);
}
}
continue;
}
} catch {}
}
// Regular file
const fileName = file.name || '';
if (!existingPaths.has(filePath)) {
newFiles.push({ path: filePath, name: fileName, size: file.size });
existingPaths.add(filePath);
}
}
if (newFiles.length > 0) {
_pendingFiles.push(...newFiles);
openHosterModal();
}
} finally {
_addingDropped = false;
}
}
async function pickFiles() {
const paths = await window.api.selectFiles();
if (!paths) return;
addPathsToQueue(paths);
}
async function pickFolder() {
const paths = await window.api.selectFolder();
if (!paths) return;
addPathsToQueue(paths);
}
function addPathsToQueue(paths) {
const newFiles = [];
for (const p of paths) {
if (!selectedFiles.find(f => f.path === p) && !_pendingFiles.find(f => f.path === p)) {
const name = p.split('\\').pop().split('/').pop();
newFiles.push({ path: p, name, size: null });
}
}
if (newFiles.length > 0) {
_pendingFiles.push(...newFiles);
openHosterModal();
}
}
function updateUploadView() {
const dropZone = document.getElementById('dropZone');
const queueShell = document.getElementById('queueShell');
const queueActions = document.getElementById('queueActions');
if (selectedFiles.length === 0 && queueJobs.length === 0) {
dropZone.style.display = 'flex';
queueShell.style.display = 'none';
queueActions.style.display = 'none';
} else {
dropZone.style.display = 'none';
queueShell.style.display = 'flex';
queueActions.style.display = 'flex';
if (!uploading && selectedFiles.length > 0) {
buildQueuePreview();
}
}
updateQueueActionButtons();
}
function updateStartButton() {
const btn = document.getElementById('startUploadBtn');
const hosters = getSelectedHosters();
const hasQueuedJobs = queueJobs.some(isStartableQueueJob);
const canBuildQueueFromSelection = selectedFiles.length > 0 && hosters.length > 0;
btn.disabled = uploading || !(hasQueuedJobs || canBuildQueueFromSelection);
}
function updateQueueActionButtons() {
updateStartButton();
const hasSelection = selectedJobIds.size > 0;
const hasUploadSelection = queueJobs.some((job) => selectedJobIds.has(job.id) && ['done', 'error', 'aborted', 'skipped'].includes(job.status));
const hasAbortSelection = queueJobs.some((job) => selectedJobIds.has(job.id) && ['preview', 'queued', 'getting-server', 'uploading', 'retrying'].includes(job.status));
const hasStartableSelection = queueJobs.some((job) => selectedJobIds.has(job.id) && isStartableQueueStatus(job.status));
const hasMovableSelection = hasSelection && !uploading;
const startSelectedBtn = document.getElementById('startSelectedBtn');
const reuploadBtn = document.getElementById('reuploadSelectedBtn');
const abortSelectedBtn = document.getElementById('abortSelectedBtn');
const finishStopBtn = document.getElementById('finishStopBtn');
const abortAllBtn = document.getElementById('abortAllBtn');
const moveTopBtn = document.getElementById('moveTopBtn');
const moveUpBtn = document.getElementById('moveUpBtn');
const moveDownBtn = document.getElementById('moveDownBtn');
const moveBottomBtn = document.getElementById('moveBottomBtn');
if (startSelectedBtn) startSelectedBtn.disabled = uploading || !hasStartableSelection;
if (reuploadBtn) reuploadBtn.disabled = !hasUploadSelection;
if (abortSelectedBtn) abortSelectedBtn.disabled = !hasAbortSelection;
if (finishStopBtn) finishStopBtn.disabled = !uploading;
if (abortAllBtn) abortAllBtn.disabled = !uploading;
if (moveTopBtn) moveTopBtn.disabled = !hasMovableSelection;
if (moveUpBtn) moveUpBtn.disabled = !hasMovableSelection;
if (moveDownBtn) moveDownBtn.disabled = !hasMovableSelection;
if (moveBottomBtn) moveBottomBtn.disabled = !hasMovableSelection;
}
// Build preview jobs from selected files x selected hosters (before upload starts)
function buildQueuePreview() {
const hosters = getSelectedHosters();
if (hosters.length === 0) {
queueJobs = queueJobs.filter(j => j.status !== 'preview');
rebuildJobIndex();
renderQueueTable();
persistQueueStateSoon();
return;
}
// Remove old preview jobs
queueJobs = queueJobs.filter(j => j.status !== 'preview');
// Build a Set for fast existence checks
const existingKeys = new Set();
for (const j of queueJobs) {
if (j.status !== 'error') existingKeys.add(`${j.file}|${j.hoster}`);
}
for (const file of selectedFiles) {
for (const hoster of hosters) {
const key = `${file.path}|${hoster}`;
if (!existingKeys.has(key) && !_completedUploadKeys.has(key)) {
const job = {
id: `preview-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
file: file.path, fileName: file.name, hoster,
status: 'preview', bytesUploaded: 0, bytesTotal: file.size || 0,
speedKbs: 0, elapsed: 0, remaining: 0,
error: null, result: null, attempt: 0, maxAttempts: 0, link: ''
};
queueJobs.push(job);
existingKeys.add(key);
}
}
}
rebuildJobIndex();
renderQueueTable();
persistQueueStateSoon();
}
// --- Job Index Management ---
function rebuildJobIndex() {
_jobIndexById.clear();
_jobIndexByUploadId.clear();
for (const job of queueJobs) {
_jobIndexById.set(job.id, job);
if (job.uploadId) _jobIndexByUploadId.set(job.uploadId, job);
}
}
function indexJob(job) {
_jobIndexById.set(job.id, job);
if (job.uploadId) _jobIndexByUploadId.set(job.uploadId, job);
}
function removeJobFromIndex(job) {
_jobIndexById.delete(job.id);
if (job.uploadId) _jobIndexByUploadId.delete(job.uploadId);
// Track deletion so handleProgress() won't re-create this job from stale callbacks
_deletedJobIds.add(job.id);
if (job.uploadId) _deletedJobIds.add(job.uploadId);
// Allow re-uploading same file+hoster after deletion
if (job.file && job.hoster) _completedUploadKeys.delete(`${job.file}|${job.hoster}`);
}
// --- Queue Table Rendering (debounced with virtual scrolling) ---
let _renderQueued = false;
let _sortedJobsCache = [];
const VIRTUAL_ROW_HEIGHT = 28;
const VIRTUAL_OVERSCAN = 10;
let _lastVisibleRange = { start: -1, end: -1 };
let _queueListenersBound = false;
// Throttled UI update scheduling max one render per 200ms during uploads
let _uiUpdateTimer = null;
const UI_UPDATE_INTERVAL = 200; // ms
function scheduleQueueRender() {
if (_renderQueued) return;
_renderQueued = true;
requestAnimationFrame(() => { _renderQueued = false; renderQueueTable(); });
}
function scheduleThrottledUIUpdate() {
if (_uiUpdateTimer) return;
_uiUpdateTimer = setTimeout(() => {
_uiUpdateTimer = null;
scheduleQueueRender();
updateQueueActionButtons();
updateStatusBar();
updateStatsPanel();
}, UI_UPDATE_INTERVAL);
}
function buildRowHtml(job) {
const statusClass = `status-${job.status}`;
const rowClass = `queue-row ${statusClass}${selectedJobIds.has(job.id) ? ' selected' : ''}`;
const uploadedSize = job.status === 'preview'
? (job.bytesTotal > 0 ? formatSize(job.bytesTotal) : '...')
: `${formatSize(job.bytesUploaded)} / ${formatSize(job.bytesTotal)}`;
const statusText = getStatusText(job);
const elapsed = formatTime(job.elapsed);
const remaining = formatTime(job.remaining);
const speed = job.speedKbs > 0 ? `${formatSpeed(job.speedKbs)}` : '';
const pct = Math.min(100, Math.round((job.progress || 0) * 100));
const link = job.result ? (job.result.download_url || job.result.embed_url || '') : '';
return `<tr class="${rowClass}" data-job-id="${job.id}" data-link="${escapeAttr(link)}" style="height:${VIRTUAL_ROW_HEIGHT}px">
<td class="col-filename" title="${escapeAttr(job.fileName)}">${escapeHtml(job.fileName)}</td>
<td class="col-size">${uploadedSize}</td>
<td class="col-host">${escapeHtml(job.hoster)}</td>
<td class="col-status"><span class="status-badge ${statusClass}">${escapeHtml(statusText)}</span></td>
<td class="col-elapsed">${elapsed}</td>
<td class="col-remaining">${remaining}</td>
<td class="col-speed">${speed}</td>
<td class="col-progress">
<div class="progress-cell">
<div class="progress-bar-bg">
<div class="progress-bar-fill ${statusClass}" style="width:${pct}%"></div>
</div>
<span class="progress-pct">${job.status === 'preview' ? '' : pct + '%'}</span>
</div>
</td>
</tr>`;
}
// In-place update of a single row's cells (avoids full innerHTML rebuild)
function _updateRowInPlace(tr, job) {
const statusClass = `status-${job.status}`;
const uploadedSize = job.status === 'preview'
? (job.bytesTotal > 0 ? formatSize(job.bytesTotal) : '...')
: `${formatSize(job.bytesUploaded)} / ${formatSize(job.bytesTotal)}`;
const statusText = getStatusText(job);
const elapsed = formatTime(job.elapsed);
const remaining = formatTime(job.remaining);
const speed = job.speedKbs > 0 ? `${formatSpeed(job.speedKbs)}` : '';
const pct = Math.min(100, Math.round((job.progress || 0) * 100));
const link = job.result ? (job.result.download_url || job.result.embed_url || '') : '';
// Update row class
tr.className = `queue-row ${statusClass}${selectedJobIds.has(job.id) ? ' selected' : ''}`;
tr.dataset.link = link;
const cells = tr.children;
if (cells.length < 8) return false; // structure mismatch, needs full rebuild
cells[1].textContent = uploadedSize;
// cells[0] (filename) and cells[2] (hoster) don't change during upload
const badge = cells[3].querySelector('.status-badge');
if (badge) { badge.className = `status-badge ${statusClass}`; badge.textContent = statusText; }
cells[4].textContent = elapsed;
cells[5].textContent = remaining;
cells[6].textContent = speed;
const fill = cells[7].querySelector('.progress-bar-fill');
if (fill) { fill.style.width = pct + '%'; fill.className = `progress-bar-fill ${statusClass}`; }
const pctSpan = cells[7].querySelector('.progress-pct');
if (pctSpan) pctSpan.textContent = job.status === 'preview' ? '' : pct + '%';
return true;
}
function renderQueueTable() {
const tbody = document.getElementById('queueBody');
if (!tbody) return;
_sortedJobsCache = sortQueueJobs(queueJobs);
const totalRows = _sortedJobsCache.length;
if (totalRows < 200) {
// Try in-place update if row count matches (fast path)
const existingRows = tbody.querySelectorAll('.queue-row');
if (existingRows.length === totalRows && totalRows > 0) {
// In-place update no DOM destruction
for (let i = 0; i < totalRows; i++) {
const tr = existingRows[i];
const job = _sortedJobsCache[i];
// If row identity changed (different job), fall back to full rebuild
if (tr.dataset.jobId !== job.id) {
tbody.innerHTML = _sortedJobsCache.map(buildRowHtml).join('');
_lastVisibleRange = { start: -1, end: -1 };
break;
}
_updateRowInPlace(tr, job);
}
} else {
// Full rebuild needed (row count changed)
tbody.innerHTML = _sortedJobsCache.map(buildRowHtml).join('');
_lastVisibleRange = { start: -1, end: -1 };
}
} else {
// Virtual scrolling for large queues — in-place update when range unchanged
_renderVirtualRows(tbody);
}
// Bind event delegation once
if (!_queueListenersBound) {
_queueListenersBound = true;
tbody.addEventListener('click', (e) => {
const row = e.target.closest('.queue-row');
if (row) handleRowClick(e, row);
});
tbody.addEventListener('contextmenu', (e) => {
const row = e.target.closest('.queue-row');
if (row) handleRowContextMenu(e, row);
});
}
// Update retry button visibility
const hasFailedJobs = queueJobs.some(j => j.status === 'error');
document.getElementById('retryFailedBtn').style.display = hasFailedJobs ? 'inline-block' : 'none';
updateQueueActionButtons();
}
function _renderVirtualRows(tbody) {
const scrollContainer = document.getElementById('queueContainer');
if (!scrollContainer) return;
const totalRows = _sortedJobsCache.length;
const scrollTop = scrollContainer.scrollTop;
// Use a minimum viewport height to avoid rendering nothing when container is being laid out
const viewportHeight = Math.max(scrollContainer.clientHeight, 200);
const startIdx = Math.max(0, Math.floor(scrollTop / VIRTUAL_ROW_HEIGHT) - VIRTUAL_OVERSCAN);
const endIdx = Math.min(totalRows, Math.ceil((scrollTop + viewportHeight) / VIRTUAL_ROW_HEIGHT) + VIRTUAL_OVERSCAN);
// Same range — try in-place update to avoid hover flicker
if (startIdx === _lastVisibleRange.start && endIdx === _lastVisibleRange.end) {
const rows = tbody.querySelectorAll('.queue-row');
if (rows.length === endIdx - startIdx) {
let allMatch = true;
for (let i = 0; i < rows.length; i++) {
const job = _sortedJobsCache[startIdx + i];
if (rows[i].dataset.jobId !== job.id) { allMatch = false; break; }
_updateRowInPlace(rows[i], job);
}
if (allMatch) return; // all rows updated in-place, no rebuild needed
}
}
_lastVisibleRange = { start: startIdx, end: endIdx };
const topPad = startIdx * VIRTUAL_ROW_HEIGHT;
const bottomPad = Math.max(0, (totalRows - endIdx) * VIRTUAL_ROW_HEIGHT);
let html = '';
if (topPad > 0) html += `<tr class="virtual-spacer" style="height:${topPad}px"><td colspan="8"></td></tr>`;
for (let i = startIdx; i < endIdx; i++) {
html += buildRowHtml(_sortedJobsCache[i]);
}
if (bottomPad > 0) html += `<tr class="virtual-spacer" style="height:${bottomPad}px"><td colspan="8"></td></tr>`;
tbody.innerHTML = html;
}
function _onQueueScroll() {
if (_sortedJobsCache.length >= 200) {
const tbody = document.getElementById('queueBody');
if (tbody) _renderVirtualRows(tbody);
}
}
const _collatorDE = new Intl.Collator('de', { sensitivity: 'base', numeric: true });
const _collatorSimple = new Intl.Collator('de');
function sortQueueJobs(jobs) {
const { key, direction } = queueSortState;
const factor = direction === 'asc' ? 1 : -1;
return jobs.slice().sort((a, b) => {
let cmp = 0;
if (key === 'filename') cmp = _collatorDE.compare(a.fileName, b.fileName);
else if (key === 'size') cmp = (a.bytesTotal || 0) - (b.bytesTotal || 0);
else if (key === 'host') cmp = _collatorSimple.compare(a.hoster, b.hoster);
else if (key === 'status') cmp = getStatusOrder(a.status) - getStatusOrder(b.status);
else if (key === 'speed') cmp = (a.speedKbs || 0) - (b.speedKbs || 0);
else if (key === 'progress') cmp = (a.progress || 0) - (b.progress || 0);
return cmp * factor;
});
}
function getStatusOrder(status) {
const order = { uploading: 0, 'getting-server': 1, retrying: 2, queued: 3, preview: 4, done: 5, aborted: 6, error: 7, skipped: 8 };
return order[status] ?? 4;
}
function getStatusText(job) {
switch (job.status) {
case 'preview': return 'Bereit';
case 'queued': return 'Wartet';
case 'getting-server': return 'Server...';
case 'uploading': return 'Upload';
case 'retrying': return `Retry ${job.attempt}/${job.maxAttempts}`;
case 'done': return 'Fertig';
case 'aborted': return 'Abgebrochen';
case 'error': return 'Fehlgeschlagen';
case 'skipped': return 'Übersprungen';
default: return job.status;
}
}
// --- Queue interactions ---
function handleRowClick(e, row) {
const jobId = row.dataset.jobId;
// Clear recent panel selection when clicking in queue
if (selectedRecentIds.size > 0) { selectedRecentIds.clear(); renderRecentUploadsPanel(); }
if (e.ctrlKey || e.metaKey) {
if (selectedJobIds.has(jobId)) selectedJobIds.delete(jobId);
else selectedJobIds.add(jobId);
} else if (e.shiftKey && selectedJobIds.size > 0) {
// Use sorted jobs cache for correct shift-click with virtual scrolling
const sortedIds = _sortedJobsCache.map(j => j.id);
const lastIdx = sortedIds.findIndex(id => selectedJobIds.has(id));
const curIdx = sortedIds.indexOf(jobId);
if (lastIdx >= 0 && curIdx >= 0) {
const from = Math.min(lastIdx, curIdx);
const to = Math.max(lastIdx, curIdx);
for (let i = from; i <= to; i++) selectedJobIds.add(sortedIds[i]);
}
} else {
selectedJobIds.clear();
selectedJobIds.add(jobId);
// Single click on done job -> copy link
const job = queueJobs.find(j => j.id === jobId);
if (job && job.status === 'done' && job.result) {
const link = job.result.download_url || job.result.embed_url || '';
if (link) {
window.api.copyToClipboard(link);
showCopyToast('Link kopiert');
}
}
}
renderQueueTable();
}
// --- Context menu ---
let alwaysOnTopState = false;
function handleRowContextMenu(e, row) {
e.preventDefault();
const jobId = row.dataset.jobId;
if (!selectedJobIds.has(jobId)) {
selectedJobIds.clear();
selectedJobIds.add(jobId);
renderQueueTable();
}
showContextMenu(e.clientX, e.clientY);
}
function showContextMenu(x, y) {
const menu = document.getElementById('contextMenu');
// Update "Always on top" text
const aotItem = menu.querySelector('[data-action="always-on-top"]');
if (aotItem) aotItem.textContent = alwaysOnTopState ? 'Immer im Vordergrund ✓' : 'Immer im Vordergrund';
// Update labels with selection count
const n = selectedJobIds.size;
const delItem = menu.querySelector('[data-action="delete-selected"]');
if (delItem) delItem.textContent = n > 1 ? `Entfernen (${n})` : 'Entfernen';
const copyItem = menu.querySelector('[data-action="copy-links"]');
if (copyItem) copyItem.textContent = n > 1 ? `Links kopieren (${n})` : 'Link kopieren';
menu.querySelectorAll('[data-action="retry-selected"]').forEach(el => {
el.textContent = n > 1 ? `Erneut versuchen (${n})` : 'Erneut versuchen';
});
const startItem = menu.querySelector('[data-action="start-selected"]');
if (startItem) startItem.textContent = n > 1 ? `Ausgewählte starten (${n})` : 'Ausgewählte starten';
// Dynamic "delete by hoster" submenu
const deleteHosterSubmenu = menu.querySelector('.ctx-hoster-delete-submenu');
const deleteHosterContainer = menu.querySelector('.ctx-hoster-delete-items');
const hosterCounts = new Map();
queueJobs.forEach(j => hosterCounts.set(j.hoster, (hosterCounts.get(j.hoster) || 0) + 1));
deleteHosterContainer.innerHTML = '';
if (hosterCounts.size > 0) {
deleteHosterSubmenu.style.display = '';
hosterCounts.forEach((count, hoster) => {
const item = document.createElement('div');
item.className = 'ctx-item ctx-item-danger';
item.dataset.action = `delete-hoster:${hoster}`;
item.textContent = `${getHosterLabel(hoster)} (${count})`;
deleteHosterContainer.appendChild(item);
});
} else {
deleteHosterSubmenu.style.display = 'none';
}
menu.style.display = 'block';
const menuX = Math.min(x, window.innerWidth - menu.offsetWidth - 5);
menu.style.left = menuX + 'px';
menu.style.top = Math.min(y, window.innerHeight - menu.offsetHeight - 5) + 'px';
// Flip submenus if they would overflow the viewport right edge
menu.querySelectorAll('.ctx-submenu-items').forEach(sub => {
// Temporarily show to measure actual width (display:none → offsetWidth=0)
sub.style.visibility = 'hidden';
sub.style.display = 'block';
sub.classList.toggle('flip-left', menuX + menu.offsetWidth + sub.offsetWidth > window.innerWidth);
sub.style.display = '';
sub.style.visibility = '';
});
}
function hideContextMenu() {
document.getElementById('contextMenu').style.display = 'none';
document.getElementById('recentContextMenu').style.display = 'none';
}
function deleteSelectedRecentFiles() {
if (selectedRecentIds.size === 0) return;
sessionFilesData = sessionFilesData.filter(r => !selectedRecentIds.has(r.order));
selectedRecentIds.clear();
renderRecentUploadsPanel();
}
function clearAllRecentFiles() {
if (sessionFilesData.length === 0) return;
if (!confirm(`Wirklich alle ${sessionFilesData.length} Links aus diesem Panel entfernen?`)) return;
sessionFilesData = [];
selectedRecentIds.clear();
renderRecentUploadsPanel();
}
function copySelectedRecentLinks() {
const links = sessionFilesData
.filter(r => selectedRecentIds.has(r.order) && !r.isError)
.map(r => r.link)
.filter(Boolean);
if (links.length) { window.api.copyToClipboard(links.join('\n')); showCopyToast(`${links.length} Links kopiert`); }
}
// --- Backup modal ---
let _backupMode = null; // 'export' | 'import'
function openBackupModal(mode) {
_backupMode = mode;
const modal = document.getElementById('backupPasswordModal');
const title = document.getElementById('backupModalTitle');
const confirmRow = document.getElementById('backupConfirmRow');
const status = document.getElementById('backupModalStatus');
document.getElementById('backupPassword').value = '';
document.getElementById('backupPasswordConfirm').value = '';
status.textContent = '';
if (mode === 'export') {
title.textContent = 'Backup verschlüsseln';
confirmRow.style.display = '';
document.getElementById('confirmBackupBtn').textContent = 'Exportieren';
} else {
title.textContent = 'Backup entschlüsseln';
confirmRow.style.display = 'none';
document.getElementById('confirmBackupBtn').textContent = 'Importieren';
}
modal.style.display = 'flex';
document.getElementById('backupPassword').focus();
}
function closeBackupModal() {
document.getElementById('backupPasswordModal').style.display = 'none';
_backupMode = null;
}
async function confirmBackupAction() {
const pw = document.getElementById('backupPassword').value;
const status = document.getElementById('backupModalStatus');
if (!pw) { status.textContent = 'Bitte Passwort eingeben.'; return; }
if (_backupMode === 'export') {
const pw2 = document.getElementById('backupPasswordConfirm').value;
if (pw !== pw2) { status.textContent = 'Passwörter stimmen nicht überein.'; return; }
status.textContent = 'Exportiere...';
try {
const result = await window.api.exportBackup(pw);
if (result.canceled) { status.textContent = ''; return; }
if (result.ok) {
closeBackupModal();
showCopyToast('Backup exportiert');
}
} catch (err) {
status.textContent = err.message || 'Export fehlgeschlagen';
}
} else {
status.textContent = 'Importiere...';
try {
const result = await window.api.importBackup(pw);
if (result.canceled) { status.textContent = ''; return; }
if (result.ok) {
config = result.config;
hosterSettings = config.hosterSettings || {};
// Refresh global settings state (always-on-top, etc.)
alwaysOnTopState = !!(config.globalSettings && config.globalSettings.alwaysOnTop);
window.api.setAlwaysOnTop(alwaysOnTopState);
closeBackupModal();
renderSettings();
renderAccounts();
renderHosterSummary();
renderHosterModal();
loadHistory();
showCopyToast('Backup importiert');
}
} catch (err) {
status.textContent = err.message || 'Import fehlgeschlagen';
}
}
}
document.addEventListener('click', (e) => {
if (!e.target.closest('.context-menu')) hideContextMenu();
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
hideContextMenu();
cancelHosterModal();
}
if (e.target.closest('input, textarea, select')) return;
const activeView = document.querySelector('.view.active');
// Ctrl+A
if ((e.ctrlKey || e.metaKey) && e.key === 'a') {
if (activeView && activeView.id === 'upload-view') {
e.preventDefault();
// Select recent files only if user's last interaction was in the recent panel
if (selectedRecentIds.size > 0 && selectedJobIds.size === 0) {
sessionFilesData.forEach(r => selectedRecentIds.add(r.order));
renderRecentUploadsPanel();
} else if (queueJobs.length > 0) {
queueJobs.forEach(j => selectedJobIds.add(j.id));
renderQueueTable();
}
}
}
// Delete
if (e.key === 'Delete') {
if (activeView && activeView.id === 'upload-view') {
e.preventDefault();
if (selectedRecentIds.size > 0) {
deleteSelectedRecentFiles();
} else if (selectedJobIds.size > 0) {
const deletedIds = [...selectedJobIds];
// Cancel active uploads for deleted jobs
const activeIds = deletedIds.filter(id => {
const j = _jobIndexById.get(id);
return j && (j.status === 'uploading' || j.status === 'queued' || j.status === 'retrying' || j.status === 'getting-server');
});
if (activeIds.length > 0) window.api.cancelSelectedJobs(activeIds);
queueJobs = queueJobs.filter(j => {
if (selectedJobIds.has(j.id)) { removeJobFromIndex(j); return false; }
return true;
});
selectedJobIds.clear();
syncSelectedFilesFromQueue();
renderQueueTable();
if (queueJobs.length === 0) { selectedFiles = []; updateUploadView(); }
updateStatusBar();
persistQueueStateSoon(true);
}
}
}
});
document.getElementById('contextMenu').addEventListener('click', (e) => {
const item = e.target.closest('.ctx-item');
if (!item) return;
const action = item.dataset.action;
if (!action) return;
hideContextMenu();
handleContextAction(action);
});
async function handleContextAction(action) {
if (action === 'start-selected') {
startSelectedUpload();
} else if (action === 'copy-links') {
const links = getSelectedJobLinks();
if (links.length) { window.api.copyToClipboard(links.join('\n')); showCopyToast(`${links.length} Links kopiert`); }
} else if (action === 'retry-selected') {
retrySelectedJobs();
} else if (action === 'delete-selected') {
// Cancel active uploads for deleted jobs
const activeIds = [...selectedJobIds].filter(id => {
const j = _jobIndexById.get(id);
return j && (j.status === 'uploading' || j.status === 'queued' || j.status === 'retrying' || j.status === 'getting-server');
});
if (activeIds.length > 0) window.api.cancelSelectedJobs(activeIds);
queueJobs = queueJobs.filter(j => {
if (selectedJobIds.has(j.id)) {
removeJobFromIndex(j);
return false;
}
return true;
});
selectedJobIds.clear();
syncSelectedFilesFromQueue();
renderQueueTable();
if (queueJobs.length === 0) { selectedFiles = []; updateUploadView(); }
updateStatusBar();
persistQueueStateSoon(true);
} else if (action === 'copy-all-links') {
copyAllLinks();
} else if (action === 'delete-all') {
// Cancel all active uploads
const activeIds = queueJobs
.filter(j => j.status === 'uploading' || j.status === 'queued' || j.status === 'retrying' || j.status === 'getting-server')
.map(j => j.id);
if (activeIds.length > 0) window.api.cancelSelectedJobs(activeIds);
queueJobs.forEach(j => removeJobFromIndex(j));
queueJobs = [];
selectedJobIds.clear();
selectedFiles = [];
syncSelectedFilesFromQueue();
renderQueueTable();
updateUploadView();
updateStatusBar();
persistQueueStateSoon(true);
} else if (action === 'always-on-top') {
alwaysOnTopState = !alwaysOnTopState;
await window.api.setAlwaysOnTop(alwaysOnTopState);
} else if (action.startsWith('delete-hoster:')) {
const hoster = action.replace('delete-hoster:', '');
// Cancel active uploads for this hoster
const activeIds = queueJobs
.filter(j => j.hoster === hoster && (j.status === 'uploading' || j.status === 'queued' || j.status === 'retrying' || j.status === 'getting-server' || j.status === 'preview'))
.map(j => j.id);
if (activeIds.length > 0) await window.api.cancelSelectedJobs(activeIds);
// Remove ALL jobs for this hoster
queueJobs = queueJobs.filter(j => {
if (j.hoster === hoster) { removeJobFromIndex(j); return false; }
return true;
});
selectedJobIds.clear();
syncSelectedFilesFromQueue();
renderQueueTable();
if (queueJobs.length === 0) { selectedFiles = []; updateUploadView(); }
updateStatusBar();
updateQueueActionButtons();
persistQueueStateSoon(true);
} else if (action.startsWith('shutdown-')) {
const mode = action.replace('shutdown-', '');
await window.api.setShutdownAfterFinish(mode);
}
}
function getSelectedJobLinks() {
return queueJobs
.filter(j => selectedJobIds.has(j.id) && j.status === 'done' && j.result)
.map(j => j.result.download_url || j.result.embed_url || '')
.filter(Boolean);
}
// --- Upload ---
async function startUpload() {
if (uploading) return;
uploading = true; // set immediately to prevent double-click race
updateQueueActionButtons();
const hosters = getSelectedHosters();
if (queueJobs.length === 0 && selectedFiles.length > 0) {
if (hosters.length === 0) {
alert('Bitte mindestens einen Hoster auswählen.');
uploading = false;
updateQueueActionButtons();
return;
}
buildQueuePreview();
}
const jobsToStart = queueJobs.filter((job) => isStartableQueueStatus(job.status));
if (jobsToStart.length === 0) { uploading = false; updateQueueActionButtons(); return; }
try {
jobsToStart.forEach(j => {
j.status = 'queued';
j.error = null;
j.result = null;
j.bytesUploaded = 0;
j.speedKbs = 0;
j.elapsed = 0;
j.remaining = 0;
j.progress = 0;
j.uploadId = null;
});
updateQueueActionButtons();
renderQueueTable();
updateStatusBar();
const uploadPayload = {
hosters,
jobs: jobsToStart.map((job) => ({
id: job.id,
file: job.file,
fileName: job.fileName,
hoster: job.hoster
}))
};
const result = await window.api.startUpload(uploadPayload);
_markSkippedJobs(result);
persistQueueStateSoon();
if (result && result.error) {
alert(result.error);
uploading = false;
updateQueueActionButtons();
updateStatusBar();
}
} catch (err) {
uploading = false;
updateQueueActionButtons();
updateStatusBar();
alert(`Upload-Start fehlgeschlagen: ${err.message}`);
}
}
function _markSkippedJobs(result) {
if (!result || !Array.isArray(result.skippedJobs) || result.skippedJobs.length === 0) return;
for (const skipped of result.skippedJobs) {
const job = _jobIndexById.get(skipped.jobId);
if (job) {
job.status = 'error';
job.error = skipped.reason || 'Kein gültiger Account';
}
}
renderQueueTable();
}
async function startSelectedUpload() {
if (uploading) {
// Batch already running — add selected jobs (queued/error/aborted/skipped) to running batch
// Upload-manager has duplicate protection (skips jobs already tracked)
const addable = queueJobs.filter(j => selectedJobIds.has(j.id) && ['queued', 'error', 'aborted', 'skipped'].includes(j.status));
if (addable.length > 0) {
addable.forEach(j => {
j.status = 'queued'; j.error = null; j.result = null;
j.bytesUploaded = 0; j.speedKbs = 0; j.progress = 0; j.uploadId = null;
});
renderQueueTable();
let result = null;
try {
result = await window.api.addJobsToBatch({
jobs: addable.map(j => ({ id: j.id, file: j.file, fileName: j.fileName, hoster: j.hoster }))
});
} catch (err) {
showCopyToast(`Jobs konnten nicht hinzugefuegt werden: ${err.message}`);
return;
}
// If the batch ended between UI-state and IPC call, start a fresh batch immediately
if (result && result.error === 'Kein Upload aktiv') {
uploading = false;
updateQueueActionButtons();
updateStatusBar();
await startSelectedUpload();
return;
}
_markSkippedJobs(result);
persistQueueStateSoon();
const added = Number(result && result.added) || 0;
// Use ASCII-only toast text here to avoid encoding artifacts on some systems.
const skipped = Array.isArray(result && result.skippedJobs) ? result.skippedJobs.length : 0;
const alreadyInBatch = Array.isArray(result && result.alreadyInBatchJobIds)
? result.alreadyInBatchJobIds.length
: Math.max(0, addable.length - added - skipped);
const toastParts = [];
if (added > 0) toastParts.push(`${added} hinzugefuegt`);
if (alreadyInBatch > 0) toastParts.push(`${alreadyInBatch} bereits im Batch`);
if (skipped > 0) toastParts.push(`${skipped} ohne gueltigen Account`);
if (result && result.error) {
showCopyToast(`Jobs konnten nicht hinzugefuegt werden: ${result.error}`);
} else if (toastParts.length > 0) {
showCopyToast(`Jobs: ${toastParts.join(', ')}`);
} else {
showCopyToast('Keine Jobs hinzugefuegt');
}
return;
}
return;
}
uploading = true; // set immediately to prevent double-click race
updateQueueActionButtons();
const hosters = getSelectedHosters();
const jobsToStart = queueJobs.filter((job) => selectedJobIds.has(job.id) && isStartableQueueStatus(job.status));
if (jobsToStart.length === 0) { uploading = false; updateQueueActionButtons(); return; }
try {
jobsToStart.forEach(j => {
j.status = 'queued';
j.error = null;
j.result = null;
j.bytesUploaded = 0;
j.speedKbs = 0;
j.progress = 0;
j.uploadId = null;
});
updateQueueActionButtons();
renderQueueTable();
updateStatusBar();
const uploadPayload = {
hosters,
jobs: jobsToStart.map((job) => ({
id: job.id,
file: job.file,
fileName: job.fileName,
hoster: job.hoster
}))
};
const result = await window.api.startUpload(uploadPayload);
_markSkippedJobs(result);
persistQueueStateSoon();
if (result && result.error) {
alert(result.error);
uploading = false;
updateQueueActionButtons();
updateStatusBar();
}
} catch (err) {
uploading = false;
updateQueueActionButtons();
updateStatusBar();
alert(`Upload-Start fehlgeschlagen: ${err.message}`);
}
}
async function cancelUpload() {
await window.api.cancelUpload();
uploading = false;
// Reset all non-finished jobs back to queued state
for (const job of queueJobs) {
if (!['done', 'error', 'skipped'].includes(job.status)) {
job.status = 'queued';
job.progress = 0;
job.bytesUploaded = 0;
job.speedKbs = 0;
job.elapsed = 0;
job.remaining = 0;
job.error = null;
}
}
renderQueueTable();
updateQueueActionButtons();
updateStatusBar();
persistQueueStateSoon();
}
// --- Progress handling ---
function handleProgress(data) {
let job = data.jobId ? _jobIndexById.get(data.jobId) : null;
if (!job && data.uploadId) job = _jobIndexByUploadId.get(data.uploadId);
if (!job) {
job = queueJobs.find(j =>
j.fileName === data.fileName && j.hoster === data.hoster && j.status === 'queued'
) || queueJobs.find(j =>
j.fileName === data.fileName && j.hoster === data.hoster && j.status === 'preview'
);
if (job && data.uploadId) {
job.uploadId = data.uploadId;
_jobIndexByUploadId.set(data.uploadId, job);
}
}
if (!job) {
// Don't re-create jobs that were explicitly deleted by the user
if ((data.jobId && _deletedJobIds.has(data.jobId)) || (data.uploadId && _deletedJobIds.has(data.uploadId))) {
return;
}
job = {
id: data.jobId || data.uploadId || `job-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
uploadId: data.uploadId,
file: '', fileName: data.fileName, hoster: data.hoster,
status: data.status, bytesUploaded: 0, bytesTotal: data.bytesTotal || 0,
speedKbs: 0, elapsed: 0, remaining: 0,
error: null, result: null, attempt: 0, maxAttempts: 0, link: ''
};
queueJobs.push(job);
indexJob(job);
}
// Don't regress from terminal states (stale callbacks can arrive after completion)
if (job.status === 'done' || job.status === 'skipped') return;
// Update job state
job.status = data.status;
job.bytesUploaded = data.bytesUploaded || 0;
job.bytesTotal = data.bytesTotal || job.bytesTotal;
// Track session total bytes (survives removeFromQueueOnDone)
if (job.bytesTotal > 0 && !_sessionTrackedJobs.has(job.id)) {
_sessionTotalBytes += job.bytesTotal;
_sessionTrackedJobs.add(job.id);
}
job.speedKbs = data.speedKbs || 0;
job.elapsed = data.elapsed || 0;
job.remaining = data.remaining || 0;
job.error = data.error || null;
job.result = data.result || job.result;
job.attempt = data.attempt || 0;
job.maxAttempts = data.maxAttempts || 0;
job.progress = data.progress || 0;
if (data.uploadId) {
job.uploadId = data.uploadId;
_jobIndexByUploadId.set(data.uploadId, job);
}
maybeAddSessionFile(job);
// Track session uploaded bytes (survives removeFromQueueOnDone)
if (job.status === 'done' && !_sessionDoneJobs.has(job.id)) {
_sessionUploadedBytes += job.bytesTotal || 0;
_sessionDoneJobs.add(job.id);
}
// Track completed uploads so they don't get re-queued after removal
if (job.status === 'done') {
_completedUploadKeys.add(`${job.file}|${job.hoster}`);
}
// Remove finished jobs from queue immediately if setting is enabled
if (job.status === 'done' && config.globalSettings && config.globalSettings.removeFromQueueOnDone) {
removeJobFromIndex(job);
selectedJobIds.delete(job.id);
queueJobs = queueJobs.filter(j => j !== job);
}
// Status changes (done/error/etc) get immediate render; ongoing progress is throttled
if (data.status === 'uploading') {
scheduleThrottledUIUpdate();
} else {
scheduleQueueRender();
updateQueueActionButtons();
updateStatusBar();
updateStatsPanel();
}
persistQueueStateSoon();
}
function handleBatchDone(summary) {
uploading = false;
applySummaryResults(summary);
_deletedJobIds.clear(); // Free memory — stale IDs no longer needed after batch completes
// Reset aborted jobs back to queued so they can be restarted
for (const job of queueJobs) {
if (job.status === 'aborted') {
job.status = 'queued';
job.progress = 0;
job.bytesUploaded = 0;
job.speedKbs = 0;
job.elapsed = 0;
job.remaining = 0;
job.error = null;
}
}
syncSelectedFilesFromQueue();
updateQueueActionButtons();
renderQueueTable();
renderRecentUploadsPanel();
loadHistory();
const removeOnDone = config.globalSettings && config.globalSettings.removeFromQueueOnDone;
if (removeOnDone) {
const doneJobs = queueJobs.filter(j => j.status === 'done');
for (const job of doneJobs) {
removeJobFromIndex(job);
selectedJobIds.delete(job.id);
}
queueJobs = queueJobs.filter(j => j.status !== 'done');
renderQueueTable();
}
if (queueJobs.some((job) => !['done', 'skipped'].includes(job.status))) persistQueueStateSoon(true);
else clearPersistedQueueStateSoon();
lastUploadStats = { state: 'idle', globalSpeedKbs: 0, totalBytes: lastUploadStats.totalBytes, elapsed: lastUploadStats.elapsed, activeJobs: 0 };
updateStatusBar();
}
function handleStats(data) {
lastUploadStats = {
state: data.state || 'idle',
globalSpeedKbs: data.globalSpeedKbs || 0,
totalBytes: data.totalBytes || 0,
elapsed: data.elapsed || 0,
activeJobs: data.activeJobs || 0
};
updateStatusBar();
updateStatsPanel();
// Track run time
if (data.state === 'uploading' || data.state === 'stopping') {
if (!statsStartTime) {
statsStartTime = Date.now();
statsRunTimer = setInterval(() => {
const el = document.getElementById('statRunTime');
if (el) el.textContent = formatDuration(Math.round((Date.now() - statsStartTime) / 1000));
}, 1000);
}
} else if (data.state === 'idle' && statsRunTimer) {
clearInterval(statsRunTimer);
statsRunTimer = null;
}
}
// --- Retry ---
async function retrySelectedJobs() {
const retryJobs = [];
queueJobs.forEach(j => {
if (selectedJobIds.has(j.id) && ['error', 'done', 'aborted', 'skipped'].includes(j.status)) {
j.status = uploading ? 'queued' : 'preview';
j.error = null;
j.result = null;
j.bytesUploaded = 0;
j.speedKbs = 0;
j.elapsed = 0;
j.remaining = 0;
j.progress = 0;
j.uploadId = null;
retryJobs.push(j);
if (!selectedFiles.find(f => f.path === j.file || f.name === j.fileName)) {
selectedFiles.push({ path: j.file, name: j.fileName, size: j.bytesTotal });
}
}
});
if (retryJobs.length === 0) return;
// Select the retry jobs and start them immediately
selectedJobIds.clear();
retryJobs.forEach(j => selectedJobIds.add(j.id));
renderQueueTable();
updateQueueActionButtons();
updateStatusBar();
persistQueueStateSoon();
await startSelectedUpload();
}
async function abortSelectedJobs() {
const activeJobIds = [];
queueJobs.forEach((job) => {
if (!selectedJobIds.has(job.id)) return;
if (['preview', 'queued'].includes(job.status)) {
job.status = 'aborted';
job.error = 'Abgebrochen';
job.progress = 0;
job.uploadId = null;
} else if (['getting-server', 'uploading', 'retrying'].includes(job.status)) {
activeJobIds.push(job.id);
}
});
if (activeJobIds.length > 0) {
await window.api.cancelSelectedJobs(activeJobIds);
}
selectedJobIds.clear();
syncSelectedFilesFromQueue();
renderQueueTable();
updateQueueActionButtons();
updateStatusBar();
persistQueueStateSoon(true);
}
async function finishUploadsInProgress() {
if (!uploading) return;
await window.api.finishAfterActive();
lastUploadStats.state = 'stopping';
updateStatusBar();
}
async function abortAllUploads() {
await cancelUpload();
}
function moveSelectedJobs(direction) {
if (uploading || selectedJobIds.size === 0) return;
const jobs = queueJobs.slice();
if (direction === 'top') {
queueJobs = jobs.filter((job) => selectedJobIds.has(job.id)).concat(jobs.filter((job) => !selectedJobIds.has(job.id)));
} else if (direction === 'bottom') {
queueJobs = jobs.filter((job) => !selectedJobIds.has(job.id)).concat(jobs.filter((job) => selectedJobIds.has(job.id)));
} else if (direction === 'up') {
for (let i = 1; i < jobs.length; i++) {
if (selectedJobIds.has(jobs[i].id) && !selectedJobIds.has(jobs[i - 1].id)) {
[jobs[i - 1], jobs[i]] = [jobs[i], jobs[i - 1]];
}
}
queueJobs = jobs;
} else if (direction === 'down') {
for (let i = jobs.length - 2; i >= 0; i--) {
if (selectedJobIds.has(jobs[i].id) && !selectedJobIds.has(jobs[i + 1].id)) {
[jobs[i], jobs[i + 1]] = [jobs[i + 1], jobs[i]];
}
}
queueJobs = jobs;
}
rebuildJobIndex();
renderQueueTable();
updateStatusBar();
persistQueueStateSoon(true);
}
function syncSelectedFilesFromQueue() {
const fileMap = new Map();
queueJobs
.filter((job) => !['done', 'skipped', 'aborted'].includes(job.status))
.forEach((job) => {
if (!job.file || fileMap.has(job.file)) return;
fileMap.set(job.file, {
path: job.file,
name: job.fileName,
size: job.bytesTotal || 0
});
});
selectedFiles = Array.from(fileMap.values());
}
function maybeAddSessionFile(job) {
if (!job) return;
const dt = formatDateTime(new Date());
if (job.status === 'done' && job.result) {
const link = job.result.download_url || job.result.embed_url || '';
if (!link) return;
if (!sessionFilesData.some((row) => row.link === link && row.filename === job.fileName && row.host === job.hoster)) {
sessionFilesData.push({
date: dt.text,
dateTs: dt.ts,
filename: job.fileName || '',
host: job.hoster || '',
link,
isError: false,
order: sessionFilesData.length
});
renderRecentUploadsPanel();
}
}
}
function applySummaryResults(summary) {
const files = Array.isArray(summary?.files) ? summary.files : [];
for (const file of files) {
for (const result of file.results || []) {
const job = queueJobs.find((entry) => entry.fileName === file.name && entry.hoster === result.hoster);
if (!job) continue;
if (result.status === 'done') {
job.status = 'done';
job.result = {
download_url: result.download_url || null,
embed_url: result.embed_url || null,
file_code: result.file_code || null
};
job.error = null;
job.progress = 1;
job.bytesUploaded = job.bytesTotal || file.size || 0;
} else if (result.status === 'aborted') {
job.status = 'aborted';
job.error = result.error || 'Abgebrochen';
} else if (result.status === 'error') {
job.status = 'error';
job.error = result.error || 'Fehlgeschlagen';
}
maybeAddSessionFile(job);
}
}
}
// Single-pass queue stats computation (shared by status bar + stats panel)
function _computeQueueStats() {
let remaining = 0, inProgress = 0, done = 0, errors = 0;
let bytesRemaining = 0, totalSize = 0, remainingSize = 0;
const total = queueJobs.length;
for (let i = 0; i < total; i++) {
const job = queueJobs[i];
const s = job.status;
const bt = job.bytesTotal || 0;
const bu = job.bytesUploaded || 0;
totalSize += bt;
if (s === 'uploading' || s === 'getting-server' || s === 'retrying') {
inProgress++;
remaining++;
bytesRemaining += Math.max(0, bt - bu);
remainingSize += Math.max(0, bt - bu);
} else if (s === 'preview' || s === 'queued') {
remaining++;
bytesRemaining += Math.max(0, bt - bu);
remainingSize += Math.max(0, bt - bu);
} else if (s === 'done') {
done++;
} else if (s === 'error') {
errors++;
} else if (s !== 'skipped') {
remainingSize += Math.max(0, bt - bu);
}
}
return { total, remaining, inProgress, done, errors, bytesRemaining, totalSize, remainingSize };
}
function updateStatusBar() {
const stats = _computeQueueStats();
const etaSeconds = lastUploadStats.globalSpeedKbs > 0
? Math.round(stats.bytesRemaining / (lastUploadStats.globalSpeedKbs * 1024))
: 0;
const stateText = lastUploadStats.state === 'uploading'
? 'Upload läuft...'
: lastUploadStats.state === 'stopping'
? 'Stoppt nach aktiven Uploads...'
: uploading
? 'Upload vorbereitet...'
: 'Bereit';
document.getElementById('sbState').textContent = stateText;
document.getElementById('sbSpeed').textContent = formatSpeed(lastUploadStats.globalSpeedKbs || 0);
// Session-based bytes: survive removeFromQueueOnDone
// Uploaded = done jobs (session) + in-progress bytes still in queue
let inProgressBytes = 0;
for (const job of queueJobs) {
if (job.status === 'uploading' || job.status === 'getting-server' || job.status === 'retrying') {
inProgressBytes += job.bytesUploaded || 0;
}
}
const uploadedSize = _sessionUploadedBytes + inProgressBytes;
const totalSize = Math.max(stats.totalSize, _sessionTotalBytes);
document.getElementById('sbTotal').textContent = `${formatSize(uploadedSize)} / ${formatSize(totalSize)}`;
document.getElementById('sbEta').textContent = `ETA ${etaSeconds > 0 ? formatTime(etaSeconds) : '--:--'}`;
document.getElementById('sbConnections').textContent = `Connections: ${lastUploadStats.activeJobs || 0}`;
document.getElementById('sbQueueCount').textContent = `Total: ${stats.total}`;
document.getElementById('sbRemainingCount').textContent = `Remaining: ${stats.remaining}`;
document.getElementById('sbInProgressCount').textContent = `In Progress: ${stats.inProgress}`;
const sessionDone = sessionFilesData.filter(r => !r.isError).length;
const sessionErrors = sessionFilesData.filter(r => r.isError).length;
document.getElementById('sbDoneCount').textContent = `Done: ${sessionDone}`;
document.getElementById('sbErrorCount').textContent = `Error: ${sessionErrors}`;
}
// --- Health Check ---
function renderHealthCheckResults(results) {
const container = document.getElementById('healthCheckResults');
if (!container) return;
if (!results || results.length === 0) { container.innerHTML = ''; return; }
container.innerHTML = results.map(item => {
const status = item.status || 'skipped';
return `<div class="health-badge ${status}">
<span>${escapeHtml(item.hoster ? getHosterLabel(item.hoster) : '')}</span>
<span class="health-tag">[${status.toUpperCase()}]</span>
<span>${escapeHtml(item.message || '')}</span>
</div>`;
}).join('');
}
async function executeHealthCheck(hosters, _mode) {
renderHealthCheckResults([]);
const result = await window.api.runHealthCheck({ hosters });
const rows = result && Array.isArray(result.results) ? result.results : [];
rows.forEach((row) => {
if (!row) return;
const key = row.accountId || row.hoster;
if (key) {
accountStatuses[key] = {
status: row.status || 'unchecked',
message: row.message || ''
};
}
});
renderHealthCheckResults(rows);
renderAccounts();
renderHosterModal();
return rows;
}
async function runHealthCheck(mode = 'manual', requestedHosters = null) {
if (healthCheckRunning || (uploading && mode === 'manual')) return [];
// Build check list: all enabled accounts with creds
let hosters;
if (Array.isArray(requestedHosters) && requestedHosters.length > 0) {
hosters = requestedHosters;
} else {
hosters = getAccountsWithCredsFlat()
.filter(({ account }) => account.enabled !== false)
.map(({ name, account }) => ({ hoster: name, accountId: account.id }));
}
if (hosters.length === 0) {
if (mode === 'manual') alert('Keine Hoster mit Zugangsdaten für einen Check.');
return [];
}
healthCheckRunning = true;
// Mark all accounts as checking
for (const h of hosters) {
const key = typeof h === 'string' ? h : (h.accountId || h.hoster);
accountStatuses[key] = { status: 'checking', message: '' };
}
renderAccounts();
try {
return await executeHealthCheck(hosters, mode);
} catch (err) {
renderHealthCheckResults([{ hoster: 'System', status: 'error', message: err.message }]);
return [];
} finally {
healthCheckRunning = false;
renderAccounts();
}
}
// --- Settings ---
function renderSettings() {
const container = document.getElementById('settingsHosters');
container.innerHTML = '';
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">
<div class="settings-section-label">Uploads</div>
<div class="settings-row">
<label style="min-width:185px">Globale parallele Uploads</label>
<input type="number" class="hs-input settings-autosave" id="parallelUploadCountInput" value="${globalSettings.parallelUploadCount ?? 0}" min="0" max="100" style="width:80px">
<span class="hint">0 = nur pro Hoster</span>
</div>
<div class="settings-row">
<label style="min-width:185px">Globales Speed-Limit (MB/s)</label>
<input type="number" class="hs-input settings-autosave" id="globalMaxSpeedMbsInput" value="${globalSettings.globalMaxSpeedKbs > 0 ? (globalSettings.globalMaxSpeedKbs / 1024).toFixed(2).replace(/\\.00$/, '') : '0'}" min="0" step="0.1" style="width:80px">
<span class="hint">0 = unbegrenzt</span>
</div>
<div class="settings-section-label">Verhalten</div>
<div class="settings-grid-mini">
<div class="settings-row checkbox-row">
<label>Immer im Vordergrund</label>
<input type="checkbox" class="settings-autosave" id="alwaysOnTopInput" ${alwaysOnTopState ? 'checked' : ''}>
</div>
<div class="settings-row checkbox-row">
<label>Hoster-Limits hochskalieren</label>
<input type="checkbox" class="settings-autosave" id="scaleParallelUploadsInput" ${globalSettings.scaleParallelUploads ? 'checked' : ''}>
</div>
<div class="settings-row checkbox-row">
<label>Aus Queue entfernen bei Abschluss</label>
<input type="checkbox" class="settings-autosave" id="removeFromQueueOnDoneInput" ${globalSettings.removeFromQueueOnDone ? 'checked' : ''}>
</div>
<div class="settings-row checkbox-row">
<label>Queue beim Start wiederherstellen</label>
<input type="checkbox" class="settings-autosave" id="resumeQueueOnLaunchInput" ${globalSettings.resumeQueueOnLaunch === false ? '' : 'checked'}>
</div>
<div class="settings-row checkbox-row">
<label>Drop-Target anzeigen</label>
<input type="checkbox" class="settings-autosave" id="showDropTargetInput" ${globalSettings.showDropTarget ? 'checked' : ''}>
</div>
</div>
<div class="settings-section-label">Updates</div>
<div class="settings-row">
<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>
</div>
<div class="settings-row">
<label>Neues Log pro Tag</label>
<input type="checkbox" class="settings-autosave" id="sessionLogInput" ${globalSettings.sessionLog ? 'checked' : ''}>
</div>
</div>
`;
container.appendChild(generalPanel);
// 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-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">
<button class="btn btn-xs btn-secondary" id="fmChooseFolderBtn">Wählen</button>
</div>
<div class="settings-row">
<label>Dateierweiterungen</label>
<select class="hs-input settings-autosave" id="fmFilterModeInput" style="width:auto;margin-right:6px">
<option value="include" ${fm.filterMode === 'include' ? 'selected' : ''}>Nur diese</option>
<option value="exclude" ${fm.filterMode === 'exclude' ? 'selected' : ''}>Alle außer</option>
</select>
<input type="text" class="key-input settings-autosave" id="fmExtensionsInput" value="${escapeAttr(fm.extensions || '')}" placeholder="mp4,mkv,avi" style="flex:1">
</div>
<div class="settings-row">
<label>Verzögerung (Sekunden)</label>
<input type="number" class="hs-input settings-autosave" id="fmDelaySecInput" value="${fm.delaySec ?? 3}" min="1" max="300" style="width:80px">
<span class="hint">Warten bis Datei fertig geschrieben</span>
</div>
<div class="settings-section-label">Verhalten</div>
<div class="settings-grid-mini">
<div class="settings-row checkbox-row">
<label>Aktiviert</label>
<input type="checkbox" class="settings-autosave" id="fmEnabledInput" ${fm.enabled ? 'checked' : ''}>
</div>
<div class="settings-row checkbox-row">
<label>Unterordner einbeziehen</label>
<input type="checkbox" class="settings-autosave" id="fmRecursiveInput" ${fm.recursive ? 'checked' : ''}>
</div>
<div class="settings-row checkbox-row">
<label>Duplikate überspringen</label>
<input type="checkbox" class="settings-autosave" id="fmSkipDuplicatesInput" ${fm.skipDuplicates !== false ? 'checked' : ''}>
</div>
<div class="settings-row checkbox-row">
<label>Auto-Upload starten</label>
<input type="checkbox" class="settings-autosave" id="fmAutoStartInput" ${fm.autoStart !== false ? 'checked' : ''}>
</div>
</div>
<div class="settings-section-label">Hoster-Vorauswahl</div>
<div class="settings-grid-mini">
${configuredAccounts.map(({ name }) => `
<div class="settings-row checkbox-row">
<label>${escapeHtml(name)}</label>
<input type="checkbox" class="settings-autosave fm-hoster-checkbox" data-fm-hoster="${name}" ${(fm.hosters || []).includes(name) ? 'checked' : ''}>
</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;';
});
// 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>
<div class="settings-grid-mini">
<div class="settings-row checkbox-row">
<label>Aktiviert</label>
<input type="checkbox" class="settings-autosave" id="remoteEnabledInput" ${remoteSettings.enabled ? 'checked' : ''}>
</div>
<div class="settings-row checkbox-row">
<label>Input erlauben</label>
<input type="checkbox" class="settings-autosave" id="remoteAllowInputInput" ${remoteSettings.allowInput !== false ? 'checked' : ''}>
</div>
</div>
<div class="settings-row">
<label>Port</label>
<input type="number" class="hs-input settings-autosave" id="remotePortInput" value="${remoteSettings.port || 9100}" min="1024" max="65535" style="width:100px">
</div>
<div class="settings-row">
<label>API-Token</label>
<input type="text" class="key-input" id="remoteTokenInput" value="${escapeAttr(remoteSettings.token || '')}" readonly style="flex:1">
<button class="btn btn-xs btn-secondary" id="remoteCopyTokenBtn" title="Kopieren">Kopieren</button>
<button class="btn btn-xs btn-secondary" id="remoteRegenerateTokenBtn" title="Neu generieren">Neu</button>
</div>
<div class="settings-section-label">Status</div>
<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;';
});
// Copy token
document.getElementById('remoteCopyTokenBtn').addEventListener('click', async () => {
const token = document.getElementById('remoteTokenInput').value;
if (token) {
await window.api.copyToClipboard(token);
document.getElementById('remoteCopyTokenBtn').textContent = 'Kopiert!';
setTimeout(() => { document.getElementById('remoteCopyTokenBtn').textContent = 'Kopieren'; }, 1500);
}
});
// 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;
if (status.running) {
el.textContent = `Aktiv auf Port ${status.port} ${status.clientCount} Client(s) verbunden`;
el.style.color = '#10b981';
} else {
el.textContent = 'Nicht aktiv';
el.style.color = '#94a3b8';
}
}).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, Einstellungen und den Upload-Verlauf verschlüsselt exportieren oder importieren.</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', () => openBackupModal('export'));
document.getElementById('importBackupBtn').addEventListener('click', () => openBackupModal('import'));
// --- Separator before hoster panels ---
const separator = document.createElement('div');
separator.style.cssText = 'height:16px';
container.appendChild(separator);
if (configuredAccounts.length === 0) {
const empty = document.createElement('div');
empty.className = 'settings-empty';
empty.innerHTML = '<p>Noch keine Account-Einstellungen vorhanden.</p><span class="hint">Sobald du einen Account anlegst, erscheinen hier die passenden Upload-Einstellungen.</span>';
container.appendChild(empty);
}
for (const { name } of configuredAccounts) {
const hs = hosterSettings[name] || {};
const maxSpeedMbs = hs.maxSpeedKbs > 0 ? (hs.maxSpeedKbs / 1024).toFixed(2).replace(/\.00$/, '') : '0';
const panel = document.createElement('div');
panel.className = 'hoster-settings-panel';
panel.innerHTML = `
<div class="hoster-panel-header" data-hoster="${name}">
<span class="panel-arrow">&#9654;</span>
<span class="panel-title">${escapeHtml(getHosterLabel(name))}</span>
<span class="panel-status active">Aktiv</span>
</div>
<div class="hoster-panel-body" data-panel="${name}" style="display:none">
<h4>Upload-Einstellungen</h4>
<div class="settings-grid-mini">
<div class="settings-row">
<label>Retries</label>
<input type="number" class="hs-input settings-autosave" data-hoster="${name}" data-hs="retries" value="${hs.retries ?? 3}" min="0" max="500">
</div>
<div class="settings-row">
<label>Max Speed (MB/s)</label>
<input type="number" class="hs-input settings-autosave" data-hoster="${name}" data-hs="maxSpeedMbs" value="${maxSpeedMbs}" min="0" step="0.1">
<span class="hint">0 = unbegrenzt</span>
</div>
<div class="settings-row">
<label>Parallele Uploads</label>
<input type="number" class="hs-input settings-autosave" data-hoster="${name}" data-hs="parallelCount" value="${hs.parallelCount ?? 2}" min="1" max="100">
</div>
<div class="settings-row">
<label>Restart unter (kB/s)</label>
<input type="number" class="hs-input settings-autosave" data-hoster="${name}" data-hs="restartBelowKbs" value="${hs.restartBelowKbs ?? 0}" min="0">
<span class="hint">0 = aus</span>
</div>
<div class="settings-row">
<label>Intervall (s)</label>
<input type="number" class="hs-input settings-autosave" data-hoster="${name}" data-hs="timeIntervalSec" value="${hs.timeIntervalSec ?? 0}" min="0">
</div>
<div class="settings-row">
<label>Max Size (MB)</label>
<input type="number" class="hs-input settings-autosave" data-hoster="${name}" data-hs="maxSizeMb" value="${hs.maxSizeMb ?? 0}" min="0">
<span class="hint">0 = unbegrenzt</span>
</div>
</div>
</div>
`;
container.appendChild(panel);
// Toggle panel
panel.querySelector('.hoster-panel-header').addEventListener('click', () => {
const body = panel.querySelector('.hoster-panel-body');
const arrow = panel.querySelector('.panel-arrow');
const isOpen = body.style.display !== 'none';
body.style.display = isOpen ? 'none' : 'block';
arrow.innerHTML = isOpen ? '&#9654;' : '&#9660;';
});
}
document.getElementById('chooseLogFilePathBtn')?.addEventListener('click', chooseLogFilePath);
document.getElementById('manualUpdateCheckBtn')?.addEventListener('click', async (e) => {
const btn = e.target;
btn.disabled = true;
btn.textContent = 'Prüfe...';
try {
const result = await window.api.checkForUpdate();
if (result && result.available) {
showUpdateBanner(result);
btn.textContent = 'Update gefunden!';
} else {
btn.textContent = 'Kein Update verfügbar';
}
} catch {
btn.textContent = 'Fehler beim Prüfen';
}
setTimeout(() => { btn.disabled = false; btn.textContent = 'Nach Updates suchen'; }, 3000);
});
container.querySelectorAll('.settings-autosave').forEach((input) => {
const eventName = input.type === 'checkbox' ? 'change' : 'input';
input.addEventListener(eventName, scheduleSettingsSave);
});
}
async function chooseLogFilePath() {
const folders = await window.api.selectFolder();
if (!folders || !folders[0]) return;
const normalized = folders[0].replace(/[\\\/]+$/, '');
document.getElementById('logFilePathInput').value = `${normalized}\\fileuploader.log`;
scheduleSettingsSave();
}
function scheduleSettingsSave() {
const feedback = document.getElementById('saveFeedback');
if (feedback) feedback.textContent = 'Speichert...';
clearTimeout(settingsSaveTimer);
settingsSaveTimer = setTimeout(() => {
saveSettings({ feedbackText: 'Automatisch gespeichert' }).catch((err) => {
if (feedback) feedback.textContent = `Speichern fehlgeschlagen: ${err.message}`;
});
}, 350);
}
async function saveSettings(options = {}) {
const { feedbackText = 'Gespeichert!' } = options;
const newHosterSettings = { ...(config.hosterSettings || {}) };
const globalSettings = {
...(config.globalSettings || {}),
logFilePath: (document.getElementById('logFilePathInput')?.value || '').trim(),
sessionLog: !!document.getElementById('sessionLogInput')?.checked,
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)),
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)
},
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
}
};
// Always on top setting
const aotCheckbox = document.getElementById('alwaysOnTopInput');
if (aotCheckbox) {
const newAot = !!aotCheckbox.checked;
if (newAot !== alwaysOnTopState) {
alwaysOnTopState = newAot;
await window.api.setAlwaysOnTop(alwaysOnTopState);
}
}
// Drop target window
const dtCheckbox = document.getElementById('showDropTargetInput');
if (dtCheckbox) {
if (dtCheckbox.checked) await window.api.showDropTarget();
else await window.api.hideDropTarget();
}
for (const name of HOSTERS) {
const hs = { ...(hosterSettings[name] || {}) };
document.querySelectorAll(`.hs-input[data-hoster="${name}"]`).forEach(input => {
const field = input.dataset.hs;
if (field === 'maxSpeedMbs') hs.maxSpeedKbs = Math.max(0, Math.round((parseFloat(input.value) || 0) * 1024));
else hs[field] = parseInt(input.value, 10) || 0;
});
newHosterSettings[name] = hs;
}
await window.api.saveHosterSettings(newHosterSettings);
await window.api.saveGlobalSettings(globalSettings);
config = await window.api.getConfig();
hosterSettings = config.hosterSettings || {};
clearTimeout(settingsSaveTimer);
// Start/stop folder monitor based on settings
const fmSettings = globalSettings.folderMonitor;
const badge = document.getElementById('folderMonitorStatusBadge');
if (fmSettings && fmSettings.enabled && fmSettings.folderPath) {
try {
await window.api.folderMonitorStart(fmSettings);
if (badge) { badge.textContent = 'Aktiv'; badge.className = 'panel-status active'; }
} catch {
if (badge) { badge.textContent = 'Fehler'; badge.className = 'panel-status'; }
}
} else {
await window.api.folderMonitorStop();
if (badge) { badge.textContent = 'Inaktiv'; badge.className = 'panel-status'; }
}
// Start/stop remote server based on settings
const remoteSettings = globalSettings.remote;
const remoteBadge = document.getElementById('remoteStatusBadge');
if (remoteSettings) {
try {
await window.api.remoteSaveSettings(remoteSettings);
if (remoteBadge) {
remoteBadge.textContent = remoteSettings.enabled ? 'Aktiv' : 'Inaktiv';
remoteBadge.className = `panel-status${remoteSettings.enabled ? ' active' : ''}`;
}
// Update status display
const status = await window.api.remoteStatus();
const statusEl = document.getElementById('remoteConnectionStatus');
if (statusEl) {
if (status.running) {
statusEl.textContent = `Aktiv auf Port ${status.port} ${status.clientCount} Client(s) verbunden`;
statusEl.style.color = '#10b981';
} else {
statusEl.textContent = 'Nicht aktiv';
statusEl.style.color = '#94a3b8';
}
}
} catch {}
}
const feedback = document.getElementById('saveFeedback');
feedback.textContent = feedbackText;
setTimeout(() => {
if (feedback.textContent === feedbackText) {
feedback.textContent = 'Änderungen werden automatisch gespeichert.';
}
}, 1800);
}
// --- Accounts ---
function getCredentialLabel(name, account) {
if (!account) return 'Keine Zugangsdaten';
if (account.authType === 'api') return `API: ${maskCredential(account.apiKey) || 'nicht gesetzt'}`;
if (account.authType === 'login') return `Login: ${account.username || 'nicht gesetzt'}`;
// Fallback
if (account.username && account.password) return `Login: ${account.username}`;
if (account.apiKey) return `API: ${maskCredential(account.apiKey)}`;
return 'Keine Zugangsdaten';
}
function renderAccounts() {
const container = document.getElementById('accountsList');
if (!container) return;
ensureAccountStatusEntries();
const allAccounts = getAllAccountsFlat();
const runCheckBtn = document.getElementById('accountsRunHealthCheckBtn');
if (runCheckBtn) runCheckBtn.disabled = healthCheckRunning;
if (allAccounts.length === 0) {
container.innerHTML = `
<div class="accounts-empty">
<p>Keine Accounts vorhanden</p>
<span class="hint">Klicke auf "Account hinzufügen", um einen Hoster einzurichten.</span>
</div>`;
return;
}
// Group by hoster for drag reorder sections
const byHoster = {};
for (const { name, account } of allAccounts) {
if (!byHoster[name]) byHoster[name] = [];
byHoster[name].push(account);
}
let html = '';
for (const name of HOSTERS) {
const accounts = byHoster[name];
if (!accounts || accounts.length === 0) continue;
html += `<div class="account-hoster-group" data-hoster-group="${name}">
<div class="account-hoster-group-title">${escapeHtml(getHosterLabel(name))}</div>`;
accounts.forEach((account, idx) => {
const isDisabled = account.enabled === false;
const st = accountStatuses[account.id] || { status: 'unchecked', message: '' };
const statusLabels = { ok: 'Bereit', warn: 'Warnung', checking: 'Prüfe...', error: 'Fehler', unchecked: 'Nicht geprüft' };
const statusLabel = isDisabled ? 'Deaktiviert' : (statusLabels[st.status] || 'Nicht geprüft');
const statusClass = isDisabled ? 'disabled' : st.status;
const credLabel = getCredentialLabel(name, account);
const toggleLabel = isDisabled ? 'Aktivieren' : 'Deaktivieren';
const priorityLabel = idx === 0 ? 'Primär' : `Fallback #${idx}`;
html += `
<div class="account-card${isDisabled ? ' account-disabled' : ''}" data-account-id="${account.id}" data-account-hoster="${name}" draggable="true">
<div class="account-card-drag-handle" title="Ziehen zum Sortieren">&#9776;</div>
<div class="account-card-info">
<div class="account-card-title">${escapeHtml(getAccountDisplayName(name, account))} <span class="account-priority-badge">${priorityLabel}</span></div>
<div class="account-card-subtitle" title="${escapeAttr(credLabel)}">${escapeHtml(credLabel)}${st.message && !isDisabled ? `${escapeHtml(st.message)}` : ''}</div>
</div>
<span class="account-status status-${statusClass}">
<span class="account-status-dot"></span>
${statusLabel}
</span>
<div class="account-card-actions">
<button class="btn btn-xs btn-secondary" data-account-toggle="${account.id}">${toggleLabel}</button>
<button class="btn btn-xs btn-secondary" data-account-check="${account.id}" ${isDisabled ? 'disabled' : ''}>Prüfen</button>
<button class="btn btn-xs btn-secondary" data-account-edit="${account.id}">Bearbeiten</button>
<button class="btn btn-xs btn-danger" data-account-delete="${account.id}">Löschen</button>
</div>
</div>`;
});
html += '</div>';
}
container.innerHTML = html;
// Wire up buttons
container.querySelectorAll('[data-account-toggle]').forEach(btn => {
btn.addEventListener('click', () => toggleAccount(btn.dataset.accountToggle));
});
container.querySelectorAll('[data-account-edit]').forEach(btn => {
btn.addEventListener('click', () => openAccountModal(btn.dataset.accountEdit));
});
container.querySelectorAll('[data-account-delete]').forEach(btn => {
btn.addEventListener('click', () => openDeleteAccountModal(btn.dataset.accountDelete));
});
container.querySelectorAll('[data-account-check]').forEach(btn => {
btn.addEventListener('click', () => checkSingleAccount(btn.dataset.accountCheck));
});
// Drag-and-drop reorder within each hoster group
setupAccountDragReorder(container);
}
function setupAccountDragReorder(container) {
let draggedCard = null;
container.querySelectorAll('.account-card[draggable]').forEach(card => {
card.addEventListener('dragstart', (e) => {
draggedCard = card;
card.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
});
card.addEventListener('dragend', () => {
if (draggedCard) draggedCard.classList.remove('dragging');
draggedCard = null;
container.querySelectorAll('.account-card').forEach(c => c.classList.remove('drag-over-above', 'drag-over-below'));
});
card.addEventListener('dragover', (e) => {
e.preventDefault();
if (!draggedCard || draggedCard === card) return;
if (draggedCard.dataset.accountHoster !== card.dataset.accountHoster) return;
e.dataTransfer.dropEffect = 'move';
const rect = card.getBoundingClientRect();
const midY = rect.top + rect.height / 2;
card.classList.toggle('drag-over-above', e.clientY < midY);
card.classList.toggle('drag-over-below', e.clientY >= midY);
});
card.addEventListener('dragleave', () => {
card.classList.remove('drag-over-above', 'drag-over-below');
});
card.addEventListener('drop', async (e) => {
e.preventDefault();
card.classList.remove('drag-over-above', 'drag-over-below');
if (!draggedCard || draggedCard === card) return;
const hosterName = card.dataset.accountHoster;
if (draggedCard.dataset.accountHoster !== hosterName) return;
const draggedId = draggedCard.dataset.accountId;
const targetId = card.dataset.accountId;
const accounts = config.hosters[hosterName];
if (!Array.isArray(accounts)) return;
const fromIdx = accounts.findIndex(a => a.id === draggedId);
const toIdx = accounts.findIndex(a => a.id === targetId);
if (fromIdx < 0 || toIdx < 0) return;
// Move account in array
const [moved] = accounts.splice(fromIdx, 1);
const rect = card.getBoundingClientRect();
const insertBefore = e.clientY < rect.top + rect.height / 2;
const newToIdx = accounts.findIndex(a => a.id === targetId);
accounts.splice(insertBefore ? newToIdx : newToIdx + 1, 0, moved);
// Save and re-render
await window.api.saveConfig({ hosters: config.hosters });
config = await window.api.getConfig();
renderAccounts();
});
});
}
async function toggleAccount(accountId) {
const found = findAccountById(accountId);
if (!found) return;
found.account.enabled = found.account.enabled === false ? true : false;
await window.api.saveConfig({ hosters: config.hosters });
config = await window.api.getConfig();
syncSelectedUploadHosters();
renderAccounts();
renderHosterSummary();
renderHosterModal();
renderSettings();
}
async function checkSingleAccount(accountId) {
if (!accountId || healthCheckRunning) return;
const found = findAccountById(accountId);
if (!found) return;
healthCheckRunning = true;
accountStatuses[accountId] = { status: 'checking', message: '' };
renderAccounts();
try {
const result = await window.api.runHealthCheck({ hosters: [{ hoster: found.name, accountId }] });
const rows = result && Array.isArray(result.results) ? result.results : [];
const row = rows.find(r => r.accountId === accountId);
if (row) accountStatuses[accountId] = { status: row.status || 'error', message: row.message || '' };
} catch (err) {
accountStatuses[accountId] = { status: 'error', message: err.message || 'Prüfung fehlgeschlagen' };
} finally {
healthCheckRunning = false;
}
renderAccounts();
}
function getCredsFieldsHtml(authType, account) {
account = account || {};
if (authType === 'login') {
return `
<div class="settings-row">
<label>Username / E-Mail</label>
<input type="text" class="key-input" id="accField_username" value="${escapeAttr(account.username || '')}" placeholder="Username oder E-Mail">
</div>
<div class="settings-row">
<label>Passwort</label>
<input type="password" class="key-input" id="accField_password" value="${escapeAttr(account.password || '')}" placeholder="Passwort">
<button class="toggle-vis" type="button" title="Anzeigen">&#128065;</button>
</div>`;
}
// API key
return `
<div class="settings-row">
<label>API Key</label>
<input type="password" class="key-input" id="accField_apiKey" value="${escapeAttr(account.apiKey || '')}" placeholder="API Key">
<button class="toggle-vis" type="button" title="Anzeigen">&#128065;</button>
</div>`;
}
function openAccountModal(editAccountId) {
editingAccountId = editAccountId || null;
const modal = document.getElementById('accountModal');
const title = document.getElementById('accountModalTitle');
const subtitle = document.getElementById('accountModalSubtitle');
const hosterRow = document.getElementById('accountHosterRow');
const hosterSelect = document.getElementById('accountHosterSelect');
const credsContainer = document.getElementById('accountCredsFields');
const statusEl = document.getElementById('accountModalStatus');
const saveBtn = document.getElementById('saveAccountBtn');
statusEl.textContent = '';
statusEl.className = 'account-modal-status';
if (editingAccountId) {
// Edit mode
const found = findAccountById(editingAccountId);
if (!found) return;
title.textContent = 'Account bearbeiten';
subtitle.textContent = `Zugangsdaten für ${getAccountDisplayName(found.name, found.account)} bearbeiten.`;
hosterRow.style.display = 'none';
saveBtn.textContent = 'Speichern & prüfen';
credsContainer.innerHTML = getCredsFieldsHtml(found.account.authType || 'login', found.account);
} else {
// Add mode — always show all options (multiple accounts per hoster allowed)
title.textContent = 'Account hinzufügen';
subtitle.textContent = 'Wähle einen Hoster und gib deine Zugangsdaten ein.';
hosterRow.style.display = 'flex';
saveBtn.textContent = 'Anlegen & prüfen';
hosterSelect.innerHTML = HOSTER_ADD_OPTIONS.map(opt =>
`<option value="${opt.value}">${escapeHtml(opt.label)}</option>`
).join('');
const firstOpt = HOSTER_ADD_OPTIONS[0];
credsContainer.innerHTML = getCredsFieldsHtml(firstOpt.authType, {});
}
// Toggle visibility buttons
credsContainer.querySelectorAll('.toggle-vis').forEach(btn => {
btn.addEventListener('click', () => {
const input = btn.previousElementSibling;
input.type = input.type === 'password' ? 'text' : 'password';
});
});
modal.style.display = 'flex';
}
function closeAccountModal() {
document.getElementById('accountModal').style.display = 'none';
_hideOtpField();
editingAccountId = null;
}
function openDeleteAccountModal(accountId) {
const found = findAccountById(accountId);
if (!found) return;
const modal = document.getElementById('deleteAccountModal');
const msg = document.getElementById('deleteAccountMessage');
msg.textContent = `Account "${getAccountDisplayName(found.name, found.account)}" wirklich löschen?`;
modal.dataset.accountId = accountId;
modal.style.display = 'flex';
}
function closeDeleteModal() {
document.getElementById('deleteAccountModal').style.display = 'none';
}
async function deleteAccount(accountId) {
const found = findAccountById(accountId);
if (!found) return;
// Remove account from the array
const accounts = config.hosters[found.name];
if (Array.isArray(accounts)) {
config.hosters[found.name] = accounts.filter(a => a.id !== accountId);
}
delete accountStatuses[accountId];
await window.api.saveConfig({ hosters: config.hosters });
config = await window.api.getConfig();
ensureAccountStatusEntries();
syncSelectedUploadHosters();
if (getAllAccountsFlat().length === 0) renderHealthCheckResults([]);
renderAccounts();
renderHosterSummary();
renderHosterModal();
renderSettings();
closeDeleteModal();
}
function readAccountCredsFromModal(authType) {
if (authType === 'login') {
const username = (document.getElementById('accField_username')?.value || '').trim();
const password = (document.getElementById('accField_password')?.value || '').trim();
return { enabled: !!(username && password), authType: 'login', username, password };
}
// API
const apiKey = (document.getElementById('accField_apiKey')?.value || '').trim();
return { enabled: !!apiKey, authType: 'api', apiKey };
}
async function saveAccount() {
let hosterName, authType, accountId;
if (editingAccountId) {
// Edit existing account
const found = findAccountById(editingAccountId);
if (!found) return;
hosterName = found.name;
authType = found.account.authType || 'login';
accountId = editingAccountId;
} else {
// Add new account
const selectValue = document.getElementById('accountHosterSelect')?.value;
if (!selectValue) return;
const opt = HOSTER_ADD_OPTIONS.find(o => o.value === selectValue);
if (!opt) return;
hosterName = opt.hoster;
authType = opt.authType;
accountId = `${hosterName}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
}
const creds = readAccountCredsFromModal(authType);
if (!creds.enabled) {
const statusEl = document.getElementById('accountModalStatus');
statusEl.textContent = 'Bitte Zugangsdaten eingeben.';
statusEl.className = 'account-modal-status error';
return;
}
// Save credentials
if (!Array.isArray(config.hosters[hosterName])) config.hosters[hosterName] = [];
if (editingAccountId) {
// Update existing account in array
const idx = config.hosters[hosterName].findIndex(a => a.id === editingAccountId);
if (idx >= 0) {
config.hosters[hosterName][idx] = { ...config.hosters[hosterName][idx], ...creds };
}
} else {
// Add new account
config.hosters[hosterName].push({ id: accountId, ...creds });
}
await window.api.saveConfig({ hosters: config.hosters });
config = await window.api.getConfig();
// Show checking status
const statusEl = document.getElementById('accountModalStatus');
const saveBtn = document.getElementById('saveAccountBtn');
statusEl.textContent = 'Prüfe Login...';
statusEl.className = 'account-modal-status checking';
saveBtn.disabled = true;
accountStatuses[accountId] = { status: 'checking', message: '' };
syncSelectedUploadHosters();
renderAccounts();
renderHosterSummary();
renderHosterModal();
renderSettings();
// Check if OTP was entered (for retry after OTP prompt)
const otpInput = document.getElementById('accField_otp');
const otp = otpInput ? otpInput.value.trim() : '';
// Run health check for this specific account (include OTP if provided)
const checkPayload = { hoster: hosterName, accountId };
if (otp) checkPayload.otp = otp;
try {
const result = await window.api.runHealthCheck({ hosters: [checkPayload] });
const rows = result && Array.isArray(result.results) ? result.results : [];
const row = rows.find(r => r.accountId === accountId);
if (row && row.status === 'otp_required') {
// Show OTP input field if not already visible
accountStatuses[accountId] = { status: 'error', message: row.message || 'OTP erforderlich' };
statusEl.textContent = row.message || 'OTP wurde an deine E-Mail gesendet.';
statusEl.className = 'account-modal-status error';
_showOtpField();
saveBtn.textContent = 'OTP bestätigen';
} else if (row && (row.status === 'ok' || row.status === 'warn')) {
accountStatuses[accountId] = { status: row.status || 'ok', message: row.message || '' };
statusEl.textContent = row.status === 'warn' ? row.message || 'Prüfung mit Warnung abgeschlossen.' : 'Login erfolgreich!';
statusEl.className = 'account-modal-status ok';
_hideOtpField();
setTimeout(() => closeAccountModal(), 1200);
} else {
const msg = (row && row.message) || 'Login fehlgeschlagen';
accountStatuses[accountId] = { status: 'error', message: msg };
statusEl.textContent = msg;
statusEl.className = 'account-modal-status error';
}
} catch (err) {
accountStatuses[accountId] = { status: 'error', message: err.message || 'Prüfung fehlgeschlagen' };
statusEl.textContent = err.message || 'Prüfung fehlgeschlagen';
statusEl.className = 'account-modal-status error';
} finally {
saveBtn.disabled = false;
ensureAccountStatusEntries();
renderAccounts();
renderHosterSummary();
renderHosterModal();
renderSettings();
}
}
function _showOtpField() {
if (document.getElementById('accField_otp')) return; // already visible
const container = document.getElementById('accountCredsFields');
const otpHtml = `
<div class="settings-row" id="otpFieldRow">
<label>OTP Code</label>
<input type="text" class="key-input" id="accField_otp" placeholder="6-stelliger Code aus E-Mail" autocomplete="one-time-code" inputmode="numeric" maxlength="10">
</div>`;
container.insertAdjacentHTML('beforeend', otpHtml);
// Auto-focus the OTP field
setTimeout(() => document.getElementById('accField_otp')?.focus(), 50);
}
function _hideOtpField() {
const row = document.getElementById('otpFieldRow');
if (row) row.remove();
}
// --- History ---
async function loadHistory() {
const history = await window.api.getHistory();
const container = document.getElementById('historyContainer');
if (!history || history.length === 0) {
historyRowsData = [];
container.innerHTML = '<p class="empty-state">Noch keine Uploads.</p>';
return;
}
historySortState = { key: 'date', direction: 'desc' };
historyRowsData = [];
let order = 0;
for (const batch of history) {
const dt = formatDateTime(batch.timestamp || new Date());
for (const file of (batch.files || [])) {
for (const result of (file.results || [])) {
if (result.status === 'aborted' || result.status === 'error') continue;
historyRowsData.push({
date: dt.text, dateTs: dt.ts,
filename: file.name || '', host: result.hoster || '',
link: result.download_url || result.embed_url || '',
isError: false, order: order++
});
}
}
}
renderHistoryTable(container);
}
async function exportHistory() {
const history = await window.api.getHistory();
if (!history || history.length === 0) {
alert('Kein Verlauf zum Exportieren vorhanden.');
return;
}
const asCsv = confirm('Verlauf als CSV exportieren?\n\nOK = CSV\nAbbrechen = JSON');
const format = asCsv ? 'csv' : 'json';
const result = await window.api.exportHistory(format);
if (!result || result.canceled) return;
if (!result.ok) {
alert(result.error || 'Export fehlgeschlagen.');
return;
}
showCopyToast(`Verlauf exportiert (${result.totalRows || 0} Zeilen)`);
}
function sortRecentFiles(data) {
const sorted = data.slice();
const { key, direction } = recentSortState;
const dir = direction === 'asc' ? 1 : -1;
sorted.sort((a, b) => {
if (key === 'date') return dir * ((a.dateTs - b.dateTs) || (a.order - b.order));
if (key === 'filename') return dir * _collatorDE.compare(a.filename, b.filename);
if (key === 'host') return dir * _collatorDE.compare(a.host, b.host);
if (key === 'link') return dir * _collatorDE.compare(a.link, b.link);
return 0;
});
return sorted;
}
function updateRecentSortHeaders() {
const head = document.getElementById('recentFilesHead');
if (!head) return;
head.querySelectorAll('th[data-recent-sort]').forEach(th => {
const key = th.dataset.recentSort;
const active = recentSortState.key === key;
const arrow = active ? (recentSortState.direction === 'asc' ? '▲' : '▼') : '↕';
th.classList.toggle('active', active);
const indicator = th.querySelector('.sort-indicator');
if (indicator) indicator.textContent = arrow;
});
}
let _recentListenersBound = false;
function renderRecentUploadsPanel() {
const tbody = document.getElementById('recentFilesBody');
if (!tbody) return;
if (!sessionFilesData.length) {
tbody.innerHTML = '<tr><td colspan="4" class="empty-state">Noch keine Uploads in dieser Session.</td></tr>';
return;
}
const rows = sortRecentFiles(sessionFilesData);
tbody.innerHTML = rows.map(row => `
<tr class="recent-file-row${row.isError ? ' error' : ''}${selectedRecentIds.has(row.order) ? ' selected' : ''}" data-order="${row.order}" data-link="${escapeAttr(row.link)}">
<td>${escapeHtml(row.date)}</td>
<td title="${escapeAttr(row.filename)}">${escapeHtml(row.filename)}</td>
<td>${escapeHtml(row.host)}</td>
<td title="${escapeAttr(row.link)}">${escapeHtml(row.link)}</td>
</tr>
`).join('');
// Event delegation bind once, not per-row
if (!_recentListenersBound) {
_recentListenersBound = true;
tbody.addEventListener('click', (e) => {
const tr = e.target.closest('.recent-file-row');
if (!tr) return;
// Clear queue selection when clicking in recent panel
if (selectedJobIds.size > 0) { selectedJobIds.clear(); renderQueueTable(); updateQueueActionButtons(); }
const id = parseInt(tr.dataset.order, 10);
if (e.ctrlKey || e.metaKey) {
if (selectedRecentIds.has(id)) selectedRecentIds.delete(id);
else selectedRecentIds.add(id);
} else if (e.shiftKey && selectedRecentIds.size > 0) {
const sortedOrders = sortRecentFiles(sessionFilesData).map(r => r.order);
const lastIdx = sortedOrders.findIndex(o => selectedRecentIds.has(o));
const curIdx = sortedOrders.indexOf(id);
if (lastIdx >= 0 && curIdx >= 0) {
const from = Math.min(lastIdx, curIdx);
const to = Math.max(lastIdx, curIdx);
for (let i = from; i <= to; i++) selectedRecentIds.add(sortedOrders[i]);
}
} else {
selectedRecentIds.clear();
selectedRecentIds.add(id);
}
renderRecentUploadsPanel();
});
tbody.addEventListener('dblclick', (e) => {
const tr = e.target.closest('.recent-file-row');
if (!tr || tr.classList.contains('error')) return;
const link = tr.dataset.link;
if (link) { window.api.copyToClipboard(link); showCopyToast('Link kopiert'); }
});
}
updateRecentSortHeaders();
}
function renderHistoryTable(container) {
if (!container || !historyRowsData.length) {
if (container) container.innerHTML = '<p class="empty-state">Noch keine Uploads.</p>';
return;
}
const rows = sortHistoryRows(historyRowsData);
const headerCell = (key, label) => {
const active = historySortState.key === key;
const dir = active ? (historySortState.direction === 'asc' ? '▲' : '▼') : '↕';
return `<th class="sortable${active ? ' active' : ''}" data-history-sort="${key}">${label}<span class="sort-indicator">${dir}</span></th>`;
};
let html = `<table class="results-table history-table"><thead><tr>
${headerCell('date', 'Date')}${headerCell('filename', 'Filename')}${headerCell('host', 'Host')}${headerCell('link', 'Link')}
</tr></thead><tbody>`;
rows.forEach(row => {
html += `<tr class="history-row${row.isError ? ' error' : ''}" data-link="${escapeAttr(row.link)}">
<td class="col-date">${escapeHtml(row.date)}</td>
<td class="col-filename">${escapeHtml(row.filename)}</td>
<td class="col-host">${escapeHtml(row.host)}</td>
<td class="col-link">${escapeHtml(row.link)}</td>
</tr>`;
});
html += '</tbody></table>';
container.innerHTML = html;
container.querySelectorAll('th.sortable').forEach(th => {
th.addEventListener('click', () => {
const key = th.dataset.historySort;
if (historySortState.key === key) historySortState.direction = historySortState.direction === 'asc' ? 'desc' : 'asc';
else { historySortState.key = key; historySortState.direction = key === 'date' ? 'desc' : 'asc'; }
renderHistoryTable(container);
});
});
container.querySelectorAll('.history-row').forEach(row => {
row.addEventListener('click', () => {
if (row.classList.contains('error')) return;
const link = row.dataset.link;
if (link) { window.api.copyToClipboard(link); showCopyToast('Link kopiert'); }
});
});
}
function sortHistoryRows(rows) {
const { key, direction } = historySortState;
const factor = direction === 'asc' ? 1 : -1;
return rows.slice().sort((a, b) => {
const cmp = key === 'date' ? a.dateTs - b.dateTs : _collatorDE.compare(String(a[key] || ''), String(b[key] || ''));
return (cmp || a.order - b.order) * factor;
});
}
// Flush pending queue state on window close (sync IPC — blocks until save completes)
window.addEventListener('beforeunload', () => {
// Flush pending settings save if user changed settings right before closing
if (settingsSaveTimer) {
clearTimeout(settingsSaveTimer);
settingsSaveTimer = null;
try { saveSettings(); } catch {}
}
clearTimeout(queuePersistTimer);
queuePersistTimer = null;
const globalSettings = {
...(config.globalSettings || {}),
pendingQueue: buildPersistedQueueState()
};
config.globalSettings = globalSettings;
window.api.saveGlobalSettingsSync(globalSettings);
});
// --- Setup Listeners ---
function setupListeners() {
document.getElementById('addFilesBtn').addEventListener('click', pickFiles);
document.getElementById('addFolderBtn').addEventListener('click', pickFolder);
document.getElementById('startUploadBtn').addEventListener('click', startUpload);
document.getElementById('startSelectedBtn').addEventListener('click', startSelectedUpload);
// Recent files sort headers
document.getElementById('recentFilesHead').addEventListener('click', (e) => {
const th = e.target.closest('th[data-recent-sort]');
if (!th) return;
const key = th.dataset.recentSort;
if (recentSortState.key === key) {
recentSortState.direction = recentSortState.direction === 'desc' ? 'asc' : 'desc';
} else {
recentSortState.key = key;
recentSortState.direction = key === 'date' ? 'desc' : 'asc';
}
renderRecentUploadsPanel();
});
// Recent files context menu
document.getElementById('recentFilesBody').addEventListener('contextmenu', (e) => {
const tr = e.target.closest('.recent-file-row');
if (!tr) return;
e.preventDefault();
e.stopPropagation();
const id = parseInt(tr.dataset.order, 10);
if (!selectedRecentIds.has(id)) {
selectedRecentIds.clear();
selectedRecentIds.add(id);
renderRecentUploadsPanel();
}
const menu = document.getElementById('recentContextMenu');
menu.style.display = 'block';
menu.style.left = Math.min(e.clientX, window.innerWidth - 180) + 'px';
menu.style.top = Math.min(e.clientY, window.innerHeight - 80) + 'px';
});
document.getElementById('recentContextMenu').addEventListener('click', (e) => {
const item = e.target.closest('.ctx-item');
if (!item) return;
hideContextMenu();
const action = item.dataset.action;
if (action === 'recent-copy-links') copySelectedRecentLinks();
else if (action === 'recent-delete') deleteSelectedRecentFiles();
});
document.getElementById('reuploadSelectedBtn').addEventListener('click', retrySelectedJobs);
document.getElementById('abortSelectedBtn').addEventListener('click', abortSelectedJobs);
document.getElementById('finishStopBtn').addEventListener('click', finishUploadsInProgress);
document.getElementById('abortAllBtn').addEventListener('click', abortAllUploads);
document.getElementById('moveTopBtn').addEventListener('click', () => moveSelectedJobs('top'));
document.getElementById('moveUpBtn').addEventListener('click', () => moveSelectedJobs('up'));
document.getElementById('moveDownBtn').addEventListener('click', () => moveSelectedJobs('down'));
document.getElementById('moveBottomBtn').addEventListener('click', () => moveSelectedJobs('bottom'));
document.getElementById('accountsRunHealthCheckBtn').addEventListener('click', () => runHealthCheck('manual'));
document.getElementById('copyAllLinksBtn').addEventListener('click', copyAllLinks);
document.getElementById('clearRecentFilesBtn').addEventListener('click', clearAllRecentFiles);
document.getElementById('retryFailedBtn').addEventListener('click', () => {
queueJobs.forEach(j => { if (j.status === 'error') selectedJobIds.add(j.id); });
retrySelectedJobs();
});
document.getElementById('importLogBtn').addEventListener('click', importUploadLog);
document.getElementById('confirmHosterModalBtn').addEventListener('click', applyHosterSelection);
document.getElementById('cancelHosterModalBtn').addEventListener('click', cancelHosterModal);
document.getElementById('closeHosterModalBtn').addEventListener('click', cancelHosterModal);
document.getElementById('selectAllHostersBtn').addEventListener('click', () => {
document.querySelectorAll('input[data-hoster-modal]').forEach(input => {
input.checked = true;
input.closest('.hoster-option')?.classList.add('selected');
});
});
document.getElementById('clearHostersBtn').addEventListener('click', () => {
document.querySelectorAll('input[data-hoster-modal]').forEach(input => {
input.checked = false;
input.closest('.hoster-option')?.classList.remove('selected');
});
});
document.getElementById('saveSettingsBtn').addEventListener('click', saveSettings);
// --- Backup export / import (modal listeners stay here, button listeners in renderSettings) ---
document.getElementById('closeBackupModalBtn').addEventListener('click', closeBackupModal);
document.getElementById('cancelBackupModalBtn').addEventListener('click', closeBackupModal);
document.getElementById('confirmBackupBtn').addEventListener('click', confirmBackupAction);
document.getElementById('backupPasswordModal').addEventListener('click', (e) => {
if (e.target.id === 'backupPasswordModal') closeBackupModal();
});
document.getElementById('backupPassword').addEventListener('keydown', (e) => {
if (e.key === 'Enter') confirmBackupAction();
});
document.getElementById('backupPasswordConfirm').addEventListener('keydown', (e) => {
if (e.key === 'Enter') confirmBackupAction();
});
document.getElementById('clearHistoryBtn').addEventListener('click', async () => {
if (!confirm('Verlauf wirklich löschen?')) return;
await window.api.clearHistory();
loadHistory();
});
document.getElementById('exportHistoryBtn').addEventListener('click', exportHistory);
// Auto health check toggle
const autoToggle = document.getElementById('autoHealthCheckToggle');
if (autoToggle) {
autoToggle.checked = autoHealthCheckEnabled;
autoToggle.addEventListener('change', (e) => {
autoHealthCheckEnabled = !!e.target.checked;
try { localStorage.setItem(AUTO_CHECK_PREF_KEY, autoHealthCheckEnabled ? '1' : '0'); } catch {}
});
}
// Virtual scroll for large queues
const queueContainer = document.getElementById('queueContainer');
if (queueContainer) queueContainer.addEventListener('scroll', _onQueueScroll, { passive: true });
// Queue table sorting
document.querySelectorAll('#queueTable th.sortable').forEach(th => {
th.addEventListener('click', (e) => {
// Don't sort if click was on the resizer handle
if (e.target.classList.contains('col-resizer')) return;
const key = th.dataset.sort;
if (queueSortState.key === key) queueSortState.direction = queueSortState.direction === 'asc' ? 'desc' : 'asc';
else { queueSortState.key = key; queueSortState.direction = 'asc'; }
_lastVisibleRange = { start: -1, end: -1 }; // force full rebuild after re-sort
renderQueueTable();
});
});
// Queue table column resizing (JDownloader-style)
setupColumnResizing();
// Shutdown cancel
document.getElementById('cancelShutdownBtn').addEventListener('click', async () => {
await window.api.cancelShutdown();
if (shutdownCountdownInterval) { clearInterval(shutdownCountdownInterval); shutdownCountdownInterval = null; }
document.getElementById('shutdownOverlay').style.display = 'none';
});
// Click on empty area in queue → deselect all
document.getElementById('upload-view').addEventListener('click', (e) => {
if (!e.target.closest('.queue-row') && !e.target.closest('.btn') && !e.target.closest('.context-menu') && !e.target.closest('.recent-files-panel')) {
if (selectedJobIds.size > 0) {
selectedJobIds.clear();
renderQueueTable();
updateQueueActionButtons();
}
}
});
// Right-click on upload view background
document.getElementById('upload-view').addEventListener('contextmenu', (e) => {
if (e.target.closest('.queue-row')) return; // handled per row
if (queueJobs.length === 0 && selectedFiles.length === 0) return; // nothing in queue
e.preventDefault();
showContextMenu(e.clientX, e.clientY);
});
document.getElementById('hosterModal').addEventListener('click', (e) => {
if (e.target.id === 'hosterModal') cancelHosterModal();
});
// Account management
document.getElementById('addAccountBtn').addEventListener('click', () => openAccountModal(null));
document.getElementById('closeAccountModalBtn').addEventListener('click', closeAccountModal);
document.getElementById('cancelAccountModalBtn').addEventListener('click', closeAccountModal);
document.getElementById('saveAccountBtn').addEventListener('click', saveAccount);
document.getElementById('accountModal').addEventListener('click', (e) => {
if (e.target.id === 'accountModal') closeAccountModal();
});
// Account hoster select change → update credential fields
document.getElementById('accountHosterSelect').addEventListener('change', (e) => {
const opt = HOSTER_ADD_OPTIONS.find(o => o.value === e.target.value);
const authType = opt ? opt.authType : 'login';
const credsContainer = document.getElementById('accountCredsFields');
credsContainer.innerHTML = getCredsFieldsHtml(authType, {});
credsContainer.querySelectorAll('.toggle-vis').forEach(btn => {
btn.addEventListener('click', () => {
const input = btn.previousElementSibling;
input.type = input.type === 'password' ? 'text' : 'password';
});
});
document.getElementById('accountModalStatus').textContent = '';
document.getElementById('accountModalStatus').className = 'account-modal-status';
});
// Delete account modal
document.getElementById('closeDeleteModalBtn').addEventListener('click', closeDeleteModal);
document.getElementById('cancelDeleteBtn').addEventListener('click', closeDeleteModal);
document.getElementById('confirmDeleteBtn').addEventListener('click', () => {
const modal = document.getElementById('deleteAccountModal');
const accountId = modal.dataset.accountId;
if (accountId) deleteAccount(accountId);
});
document.getElementById('deleteAccountModal').addEventListener('click', (e) => {
if (e.target.id === 'deleteAccountModal') closeDeleteModal();
});
}
// --- Update UI ---
function showUpdateBanner(info) {
const banner = document.getElementById('updateBanner');
const msg = document.getElementById('updateMessage');
if (!banner || !msg) return;
msg.textContent = `Update v${info.remoteVersion} verfügbar`;
banner.style.display = 'flex';
document.getElementById('installUpdateBtn').onclick = async () => {
msg.textContent = 'Update wird heruntergeladen...';
document.getElementById('installUpdateBtn').disabled = true;
await persistQueueStateNow().catch(() => {}); // Save queue before update restart
await window.api.installUpdate();
};
document.getElementById('dismissUpdateBtn').onclick = () => { banner.style.display = 'none'; };
}
function handleUpdateProgress(data) {
const msg = document.getElementById('updateMessage');
if (!msg) return;
if (data.stage === 'downloading') msg.textContent = `Downloading... ${data.percent || 0}%`;
else if (data.stage === 'verifying') msg.textContent = 'Verifiziere...';
else if (data.stage === 'launching') msg.textContent = 'Setup wird gestartet...';
else if (data.stage === 'done') msg.textContent = 'Update installiert. App wird neu gestartet...';
else if (data.stage === 'error') {
msg.textContent = `Update fehlgeschlagen: ${data.error}`;
const btn = document.getElementById('installUpdateBtn');
if (btn) { btn.disabled = false; btn.textContent = 'Erneut versuchen'; }
}
}
// --- Shutdown ---
let shutdownCountdownInterval = null;
function handleShutdownCountdown(data) {
const overlay = document.getElementById('shutdownOverlay');
const msgEl = document.getElementById('shutdownMessage');
const secEl = document.getElementById('shutdownSeconds');
overlay.style.display = 'flex';
const labels = { sleep: 'Ruhezustand', shutdown: 'Herunterfahren', restart: 'Neustart' };
let remaining = data.seconds || 60;
secEl.textContent = remaining;
msgEl.textContent = `${labels[data.mode] || data.mode} in ${remaining}s...`;
if (shutdownCountdownInterval) clearInterval(shutdownCountdownInterval);
shutdownCountdownInterval = setInterval(() => {
remaining--;
secEl.textContent = remaining;
msgEl.textContent = `${labels[data.mode] || data.mode} in ${remaining}s...`;
if (remaining <= 0) { clearInterval(shutdownCountdownInterval); }
}, 1000);
}
// --- Auto-deduplicate restored queue against own upload log on startup ---
async function _autoDeduplicateFromLog() {
if (queueJobs.length === 0) return;
try {
const entries = await window.api.readOwnUploadLog();
if (!entries || entries.length === 0) return;
const logKeys = new Set();
for (const entry of entries) {
logKeys.add(`${entry.fileName.toLowerCase()}|${entry.hoster.toLowerCase()}`);
}
let removed = 0;
queueJobs = queueJobs.filter(job => {
const key = `${job.fileName.toLowerCase()}|${job.hoster.toLowerCase()}`;
if (logKeys.has(key)) {
if (job.file && job.hoster) _completedUploadKeys.add(`${job.file}|${job.hoster}`);
removed++;
return false;
}
return true;
});
if (removed > 0) {
rebuildJobIndex();
syncSelectedFilesFromQueue();
window.api.debugLog(`auto-dedup: removed ${removed} already-uploaded jobs from restored queue (${entries.length} log entries)`);
}
} catch {}
}
// --- Log import: remove already-uploaded file+hoster combos from queue ---
async function importUploadLog() {
const result = await window.api.importUploadLog();
if (!result || result.canceled) return;
const entries = result.entries || [];
if (entries.length === 0) {
showCopyToast('Keine Einträge im Log gefunden');
return;
}
// Build lookup Set: "filename_lower|hoster"
const logKeys = new Set();
for (const entry of entries) {
logKeys.add(`${entry.fileName.toLowerCase()}|${entry.hoster.toLowerCase()}`);
}
// Find queue jobs that match (already uploaded)
let removed = 0;
queueJobs = queueJobs.filter(job => {
const key = `${job.fileName.toLowerCase()}|${job.hoster.toLowerCase()}`;
if (logKeys.has(key) && job.status !== 'done') {
removeJobFromIndex(job);
// Mark as completed so buildQueuePreview won't re-create them
if (job.file && job.hoster) _completedUploadKeys.add(`${job.file}|${job.hoster}`);
removed++;
return false;
}
return true;
});
if (removed > 0) {
selectedJobIds.clear();
syncSelectedFilesFromQueue();
rebuildJobIndex();
renderQueueTable();
updateUploadView();
updateStatusBar();
persistQueueStateSoon(true);
}
showCopyToast(`${removed} bereits hochgeladene Jobs aus Queue entfernt (${entries.length} Log-Einträge gelesen)`);
}
// --- Link operations ---
function copyAllLinks() {
const links = queueJobs
.filter(j => j.status === 'done' && j.result)
.map(j => j.result.download_url || j.result.embed_url || '')
.filter(Boolean);
if (links.length > 0) {
window.api.copyToClipboard(links.join('\n'));
showCopyToast(`${links.length} Links kopiert`);
}
}
// --- Utilities ---
function formatSize(bytes) {
if (!bytes || bytes <= 0) return '0 B';
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' kB';
if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
}
function formatSpeed(kbs) {
if (!kbs || kbs <= 0) return '0 kB/s';
if (kbs >= 1024) return (kbs / 1024).toFixed(1) + ' MB/s';
return Math.round(kbs) + ' kB/s';
}
function formatTime(seconds) {
if (!seconds || seconds <= 0) return '00:00';
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
if (h > 0) return `${pad(h)}:${pad(m)}:${pad(s)}`;
return `${pad(m)}:${pad(s)}`;
}
function pad(n) { return String(Math.floor(n)).padStart(2, '0'); }
function formatDateTime(value) {
const date = value instanceof Date ? value : new Date(value);
const safeDate = Number.isNaN(date.getTime()) ? new Date() : date;
return {
ts: safeDate.getTime(),
text: safeDate.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })
+ ' ' + safeDate.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
};
}
function loadAutoCheckPreference() {
try { const r = localStorage.getItem(AUTO_CHECK_PREF_KEY); return r === null || r === '1'; }
catch { return true; }
}
// --- Queue table column resizing (JDownloader-style) ---
function restoreQueueColumnWidths() {
try {
const raw = localStorage.getItem(QUEUE_COL_WIDTHS_KEY);
if (!raw) return;
const widths = JSON.parse(raw);
if (!widths || typeof widths !== 'object') return;
for (const [col, px] of Object.entries(widths)) {
const th = document.querySelector(`#queueTable th[data-col="${col}"]`);
if (th && typeof px === 'number' && px > 20) {
th.style.width = px + 'px';
}
}
} catch {}
}
function saveQueueColumnWidths() {
try {
const widths = {};
document.querySelectorAll('#queueTable th[data-col]').forEach(th => {
widths[th.dataset.col] = th.getBoundingClientRect().width;
});
localStorage.setItem(QUEUE_COL_WIDTHS_KEY, JSON.stringify(widths));
} catch {}
}
function setupColumnResizing() {
const headers = document.querySelectorAll('#queueTable th[data-col]');
headers.forEach(th => {
const resizer = th.querySelector('.col-resizer');
if (!resizer) return;
resizer.addEventListener('mousedown', (e) => {
e.preventDefault();
e.stopPropagation();
const startX = e.clientX;
const startWidth = th.getBoundingClientRect().width;
resizer.classList.add('dragging');
document.body.classList.add('col-resizing');
const onMove = (ev) => {
const delta = ev.clientX - startX;
const newWidth = Math.max(40, startWidth + delta);
th.style.width = newWidth + 'px';
};
const onUp = () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
resizer.classList.remove('dragging');
document.body.classList.remove('col-resizing');
saveQueueColumnWidths();
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
});
});
}
function escapeHtml(str) {
if (!str) return '';
return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function escapeAttr(str) {
if (!str) return '';
return String(str).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function showCopyToast(msg) {
const toast = document.getElementById('copyToast');
toast.textContent = msg;
toast.classList.add('show');
clearTimeout(toast._timer);
toast._timer = setTimeout(() => toast.classList.remove('show'), 1500);
}
// --- Resize handle for recent-files panel ---
{
const resizer = document.getElementById('recentFilesResizer');
const panel = document.getElementById('recentFilesPanel');
if (resizer && panel) {
let startY = 0;
let startH = 0;
resizer.addEventListener('mousedown', (e) => {
e.preventDefault();
startY = e.clientY;
startH = panel.getBoundingClientRect().height;
resizer.classList.add('dragging');
document.body.style.cursor = 'ns-resize';
document.body.style.userSelect = 'none';
const onMove = (e2) => {
const delta = startY - e2.clientY;
const newH = Math.max(60, Math.min(window.innerHeight * 0.7, startH + delta));
panel.style.flex = `0 0 ${newH}px`;
};
const onUp = () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
resizer.classList.remove('dragging');
document.body.style.cursor = '';
document.body.style.userSelect = '';
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
});
}
}
// --- Recent panel tabs ---
document.querySelectorAll('.recent-tab').forEach(tab => {
tab.addEventListener('click', () => {
document.querySelectorAll('.recent-tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.recent-tab-body').forEach(b => b.classList.remove('active'));
tab.classList.add('active');
const panel = document.getElementById(tab.dataset.panel);
if (panel) panel.classList.add('active');
const hint = document.getElementById('recentFilesHint');
if (hint) hint.textContent = tab.dataset.panel === 'statsTab' ? 'Upload-Statistiken' : 'Zuletzt erzeugte Upload-Links';
});
});
// --- Stats panel update ---
let statsStartTime = 0;
let statsRunTimer = null;
function formatBytes(bytes) {
if (bytes <= 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
return (bytes / Math.pow(1024, i)).toFixed(i > 0 ? 2 : 0) + ' ' + units[i];
}
function formatDuration(seconds) {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
}
function updateStatsPanel() {
const stats = _computeQueueStats();
const remaining = stats.total - stats.done - stats.errors;
const el = (id) => document.getElementById(id);
if (el('statQueueTotal')) el('statQueueTotal').textContent = stats.total;
if (el('statQueueDone')) el('statQueueDone').textContent = stats.done;
if (el('statQueueRemaining')) el('statQueueRemaining').textContent = remaining;
if (el('statQueueInProgress')) el('statQueueInProgress').textContent = stats.inProgress;
if (el('statQueueError')) el('statQueueError').textContent = stats.errors;
if (el('statSizeTotal')) el('statSizeTotal').textContent = formatBytes(stats.totalSize);
if (el('statSizeRemaining')) el('statSizeRemaining').textContent = formatBytes(stats.remainingSize);
const speed = lastUploadStats.globalSpeedKbs || 0;
if (el('statSpeed')) el('statSpeed').textContent = speed > 0 ? formatBytes(speed * 1024) + '/s' : '0 B/s';
if (el('statEta')) {
if (speed > 0 && stats.remainingSize > 0) {
el('statEta').textContent = formatDuration(Math.round(stats.remainingSize / (speed * 1024)));
} else {
el('statEta').textContent = '--:--';
}
}
if (el('statSessionBytes')) el('statSessionBytes').textContent = formatBytes(lastUploadStats.totalBytes || 0);
}
// --- Start ---
init();