alert() in Electron halts the renderer main thread until the user clicks OK — the upload table, status bar and progress all freeze. During a 170-file batch the dialog popped up mid-upload and froze everything for however long the user took to dismiss it (which is why stats updates lagged to one every 3-5s instead of the usual 1s cadence). Replaced with the same showCopyToast used elsewhere, with an 8s duration so the message is still readable. showCopyToast now accepts an optional durationMs argument.
4000 lines
155 KiB
JavaScript
4000 lines
155 KiB
JavaScript
const HOSTERS = ['doodstream.com', 'voe.sx', 'vidmoly.me', 'byse.sx', 'clouddrop.cc'];
|
||
|
||
// 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' },
|
||
{ value: 'clouddrop.cc', label: 'Clouddrop (API)', hoster: 'clouddrop.cc', 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();
|
||
// Maintained incrementally — avoids O(n) filter() scans every 250ms in the status bar.
|
||
let _sessionDoneCount = 0;
|
||
let _sessionErrorCount = 0;
|
||
// O(1) dedup for maybeAddSessionFile (was O(n) sessionFilesData.some).
|
||
// Huge with thousands of rows × thousands of incoming results.
|
||
const _sessionFileKeys = 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 — debug log only on state transitions; the 'uploading'
|
||
// tick fires 4×/sec per active job and an IPC roundtrip per event would
|
||
// backlog the renderer↔main channel with hundreds of messages/sec.
|
||
window.api.onUploadProgress((data) => {
|
||
if (data.status !== 'uploading') {
|
||
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) => {
|
||
// Stats fire every second per upload session — skip while uploading.
|
||
if (data.state !== 'uploading') {
|
||
window.api.debugLog('RX upload-stats: state=' + data.state + ' active=' + data.activeJobs);
|
||
}
|
||
handleStats(data);
|
||
});
|
||
window.api.onShutdownCountdown(handleShutdownCountdown);
|
||
window.api.onUploadLogFallback((data) => {
|
||
// Non-blocking toast instead of alert() — alert() in Electron halts the
|
||
// entire renderer main thread (including the upload table) until the user
|
||
// clicks OK. During a big batch that freezes everything.
|
||
const path = data && data.fallbackPath ? data.fallbackPath : '(Fallback)';
|
||
showCopyToast(`Log-Pfad nicht beschreibbar — schreibe nach: ${path}`, 8000);
|
||
});
|
||
window.api.onAccountRotationLog((entry) => {
|
||
// Surface only the user-visible rotation events as toasts; full detail
|
||
// goes to account-rotation.log. Keep it quiet otherwise.
|
||
if (!entry || !entry.event) return;
|
||
const hosterLabel = entry.hoster ? getHosterLabel(entry.hoster) : '';
|
||
if (entry.event === 'rotate') {
|
||
showCopyToast(`${hosterLabel}: Account-Wechsel → Fallback`);
|
||
} else if (entry.event === 'rotation-end') {
|
||
showCopyToast(`${hosterLabel}: Keine weiteren Fallback-Accounts verfügbar`);
|
||
} else if (entry.event === 'final-error') {
|
||
showCopyToast(`${hosterLabel}: Alle Accounts ausgeschöpft`);
|
||
}
|
||
});
|
||
|
||
// 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 existing = new Set();
|
||
for (const f of selectedFiles) existing.add(f.path);
|
||
for (const f of _pendingFiles) existing.add(f.path);
|
||
const newFiles = [];
|
||
for (const p of files) {
|
||
if (existing.has(p)) continue;
|
||
existing.add(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 ---
|
||
let _historyDirty = false;
|
||
function _isHistoryTabActive() {
|
||
const tab = document.querySelector('.tab.active');
|
||
return !!(tab && tab.dataset.view === 'history');
|
||
}
|
||
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') {
|
||
_historyDirty = false;
|
||
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',
|
||
'clouddrop.cc': 'Clouddrop'
|
||
};
|
||
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) {
|
||
// Build path-Set once so dedup is O(1) per candidate instead of O(n+m).
|
||
// Matters when the user picks a folder with thousands of files.
|
||
const existing = new Set();
|
||
for (const f of selectedFiles) existing.add(f.path);
|
||
for (const f of _pendingFiles) existing.add(f.path);
|
||
|
||
const newFiles = [];
|
||
for (const p of paths) {
|
||
if (existing.has(p)) continue;
|
||
existing.add(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);
|
||
}
|
||
|
||
const _UPLOAD_SELECTION_STATUSES = new Set(['done', 'error', 'aborted', 'skipped']);
|
||
const _ABORT_SELECTION_STATUSES = new Set(['preview', 'queued', 'getting-server', 'uploading', 'retrying']);
|
||
|
||
function updateQueueActionButtons() {
|
||
updateStartButton();
|
||
|
||
const hasSelection = selectedJobIds.size > 0;
|
||
// Single pass over the (usually small) selection set instead of three O(n)
|
||
// scans over the entire queue. For 1000 jobs × 3 scans this drops the
|
||
// selection-change cost from ~3000 checks to |selection|.
|
||
let hasUploadSelection = false, hasAbortSelection = false, hasStartableSelection = false;
|
||
for (const id of selectedJobIds) {
|
||
const job = _jobIndexById.get(id);
|
||
if (!job) continue;
|
||
const s = job.status;
|
||
if (!hasUploadSelection && _UPLOAD_SELECTION_STATUSES.has(s)) hasUploadSelection = true;
|
||
if (!hasAbortSelection && _ABORT_SELECTION_STATUSES.has(s)) hasAbortSelection = true;
|
||
if (!hasStartableSelection && isStartableQueueStatus(s)) hasStartableSelection = true;
|
||
if (hasUploadSelection && hasAbortSelection && hasStartableSelection) break;
|
||
}
|
||
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(); });
|
||
}
|
||
|
||
let _recentRenderQueued = false;
|
||
function scheduleRecentRender() {
|
||
if (_recentRenderQueued) return;
|
||
_recentRenderQueued = true;
|
||
requestAnimationFrame(() => { _recentRenderQueued = false; renderRecentUploadsPanel(); });
|
||
}
|
||
|
||
// Toggle the .selected class on existing rows without rebuilding the table.
|
||
// Used on click/selection changes — O(rendered rows) instead of O(total rows × sort).
|
||
function applyQueueSelectionClasses() {
|
||
const tbody = document.getElementById('queueBody');
|
||
if (!tbody) return;
|
||
const rows = tbody.querySelectorAll('.queue-row');
|
||
for (const tr of rows) {
|
||
tr.classList.toggle('selected', selectedJobIds.has(tr.dataset.jobId));
|
||
}
|
||
}
|
||
|
||
function applyRecentSelectionClasses() {
|
||
const tbody = document.getElementById('recentFilesBody');
|
||
if (!tbody) return;
|
||
const rows = tbody.querySelectorAll('.recent-file-row');
|
||
for (const tr of rows) {
|
||
const order = parseInt(tr.dataset.order, 10);
|
||
tr.classList.toggle('selected', selectedRecentIds.has(order));
|
||
}
|
||
}
|
||
|
||
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 || '') : '';
|
||
|
||
// Write DOM only when the target value actually changes — a no-op progress
|
||
// tick (same pct, same speed) then performs zero DOM work. Massive saver
|
||
// when most of the visible jobs are idle/queued/done and only a few are
|
||
// actively uploading.
|
||
const newClass = `queue-row ${statusClass}${selectedJobIds.has(job.id) ? ' selected' : ''}`;
|
||
if (tr.className !== newClass) tr.className = newClass;
|
||
if (tr.dataset.link !== link) tr.dataset.link = link;
|
||
|
||
const cells = tr.children;
|
||
if (cells.length < 8) return false; // structure mismatch, needs full rebuild
|
||
|
||
if (cells[1].textContent !== uploadedSize) 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) {
|
||
const badgeClass = `status-badge ${statusClass}`;
|
||
if (badge.className !== badgeClass) badge.className = badgeClass;
|
||
if (badge.textContent !== statusText) badge.textContent = statusText;
|
||
}
|
||
if (cells[4].textContent !== elapsed) cells[4].textContent = elapsed;
|
||
if (cells[5].textContent !== remaining) cells[5].textContent = remaining;
|
||
if (cells[6].textContent !== speed) cells[6].textContent = speed;
|
||
|
||
const fill = cells[7].querySelector('.progress-bar-fill');
|
||
if (fill) {
|
||
const pctStr = pct + '%';
|
||
if (fill.style.width !== pctStr) fill.style.width = pctStr;
|
||
const fillClass = `progress-bar-fill ${statusClass}`;
|
||
if (fill.className !== fillClass) fill.className = fillClass;
|
||
}
|
||
const pctSpan = cells[7].querySelector('.progress-pct');
|
||
if (pctSpan) {
|
||
const pctText = job.status === 'preview' ? '' : pct + '%';
|
||
if (pctSpan.textContent !== pctText) pctSpan.textContent = pctText;
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
// Coalesce rapid scroll events (a fast trackpad fling fires dozens) into one
|
||
// render per frame. rAF keeps the scroll thread cheap.
|
||
let _queueScrollQueued = false;
|
||
function _onQueueScroll() {
|
||
if (_queueScrollQueued) return;
|
||
if (_sortedJobsCache.length < 200) return;
|
||
_queueScrollQueued = true;
|
||
requestAnimationFrame(() => {
|
||
_queueScrollQueued = false;
|
||
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');
|
||
|
||
// Queue sort memoization. Keys that don't change after a job enters the queue
|
||
// (filename, host) reuse the cached result across progress-driven re-renders.
|
||
// Dynamic keys (status/speed/progress) AND size (which goes 0 → actual when
|
||
// previews resolve / upload starts) are recomputed each call — otherwise a
|
||
// queue sorted by size during previews would be stuck in all-zeros order.
|
||
let _queueSortCache = { sig: '', result: [] };
|
||
const _STATIC_SORT_KEYS = new Set(['filename', 'host']);
|
||
|
||
function sortQueueJobs(jobs) {
|
||
const { key, direction } = queueSortState;
|
||
const factor = direction === 'asc' ? 1 : -1;
|
||
const canCache = _STATIC_SORT_KEYS.has(key);
|
||
const sig = canCache ? `${key}|${direction}|${jobs.length}` : '';
|
||
if (sig && _queueSortCache.sig === sig) return _queueSortCache.result;
|
||
|
||
const sorted = 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;
|
||
});
|
||
if (sig) _queueSortCache = { sig, result: sorted };
|
||
return sorted;
|
||
}
|
||
|
||
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 — class-toggle only.
|
||
if (selectedRecentIds.size > 0) { selectedRecentIds.clear(); applyRecentSelectionClasses(); }
|
||
|
||
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 = _jobIndexById.get(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');
|
||
}
|
||
}
|
||
}
|
||
// Selection changes don't change sort order / row content — just toggle classes.
|
||
applyQueueSelectionClasses();
|
||
updateQueueActionButtons();
|
||
}
|
||
|
||
// --- 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);
|
||
applyQueueSelectionClasses();
|
||
updateQueueActionButtons();
|
||
}
|
||
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;
|
||
let removedDone = 0, removedErr = 0;
|
||
sessionFilesData = sessionFilesData.filter(r => {
|
||
if (!selectedRecentIds.has(r.order)) return true;
|
||
if (r.isError) removedErr++; else removedDone++;
|
||
_sessionFileKeys.delete(`${r.link}\u0001${r.filename}\u0001${r.host}`);
|
||
return false;
|
||
});
|
||
_sessionDoneCount = Math.max(0, _sessionDoneCount - removedDone);
|
||
_sessionErrorCount = Math.max(0, _sessionErrorCount - removedErr);
|
||
selectedRecentIds.clear();
|
||
renderRecentUploadsPanel();
|
||
}
|
||
|
||
function clearAllRecentFiles() {
|
||
if (sessionFilesData.length === 0) return;
|
||
if (!confirm(`Wirklich alle ${sessionFilesData.length} Links aus diesem Panel entfernen?`)) return;
|
||
sessionFilesData = [];
|
||
_sessionFileKeys.clear();
|
||
_sessionDoneCount = 0;
|
||
_sessionErrorCount = 0;
|
||
selectedRecentIds.clear();
|
||
renderRecentUploadsPanel();
|
||
}
|
||
|
||
async function exportAllRecentFiles() {
|
||
if (sessionFilesData.length === 0) {
|
||
alert('Keine Einträge zum Exportieren.');
|
||
return;
|
||
}
|
||
const rows = sortRecentFiles(sessionFilesData);
|
||
const header = 'timestamp|hoster|link|filename|status';
|
||
const lines = rows.map(r => {
|
||
const ts = r.timestamp || r.time || '';
|
||
const host = r.host || r.hoster || '';
|
||
const link = r.link || '';
|
||
const name = r.filename || '';
|
||
const status = r.isError ? 'error' : 'ok';
|
||
return [ts, host, link, name, status].map(v => String(v).replace(/[\r\n|]/g, ' ')).join('|');
|
||
});
|
||
const content = [header, ...lines].join('\n') + '\n';
|
||
const defaultName = `uploads-${new Date().toISOString().slice(0, 10)}.log`;
|
||
try {
|
||
const result = await window.api.saveTextFile(defaultName, content, [
|
||
{ name: 'Log-Datei', extensions: ['log', 'txt', 'csv'] }
|
||
]);
|
||
if (result && result.ok) showCopyToast(`${rows.length} Einträge exportiert`);
|
||
} catch (err) {
|
||
alert('Export fehlgeschlagen: ' + (err.message || err));
|
||
}
|
||
}
|
||
|
||
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 export / import ---
|
||
async function doBackupExport() {
|
||
try {
|
||
const result = await window.api.exportBackup();
|
||
if (result && result.ok) showCopyToast('Backup exportiert');
|
||
} catch (err) {
|
||
alert('Export fehlgeschlagen: ' + (err.message || err));
|
||
}
|
||
}
|
||
|
||
function askLegacyBackupPassword() {
|
||
return new Promise((resolve) => {
|
||
const overlay = document.createElement('div');
|
||
overlay.className = 'modal-overlay';
|
||
overlay.style.display = 'flex';
|
||
|
||
const card = document.createElement('div');
|
||
card.className = 'modal-card';
|
||
card.style.width = 'min(380px,100%)';
|
||
|
||
const header = document.createElement('div');
|
||
header.className = 'modal-header';
|
||
const h3 = document.createElement('h3');
|
||
h3.textContent = 'Passwort erforderlich';
|
||
header.appendChild(h3);
|
||
|
||
const body = document.createElement('div');
|
||
body.className = 'modal-body';
|
||
const p = document.createElement('p');
|
||
p.style.margin = '0 0 10px';
|
||
p.style.fontSize = '13px';
|
||
p.textContent = 'Dieses Backup wurde mit einem Passwort verschlüsselt.';
|
||
const input = document.createElement('input');
|
||
input.type = 'password';
|
||
input.className = 'key-input';
|
||
input.placeholder = 'Passwort';
|
||
input.autocomplete = 'off';
|
||
input.style.width = '100%';
|
||
body.appendChild(p);
|
||
body.appendChild(input);
|
||
|
||
const footer = document.createElement('div');
|
||
footer.className = 'modal-footer';
|
||
const cancelBtn = document.createElement('button');
|
||
cancelBtn.className = 'btn btn-secondary';
|
||
cancelBtn.textContent = 'Abbrechen';
|
||
const okBtn = document.createElement('button');
|
||
okBtn.className = 'btn btn-primary';
|
||
okBtn.textContent = 'Importieren';
|
||
footer.appendChild(cancelBtn);
|
||
footer.appendChild(okBtn);
|
||
|
||
card.appendChild(header);
|
||
card.appendChild(body);
|
||
card.appendChild(footer);
|
||
overlay.appendChild(card);
|
||
document.body.appendChild(overlay);
|
||
|
||
const done = (val) => { overlay.remove(); resolve(val); };
|
||
okBtn.onclick = () => done(input.value || null);
|
||
cancelBtn.onclick = () => done(null);
|
||
input.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Enter') done(input.value || null);
|
||
if (e.key === 'Escape') done(null);
|
||
});
|
||
input.focus();
|
||
});
|
||
}
|
||
|
||
async function doBackupImport(legacyPassword) {
|
||
const pw = typeof legacyPassword === 'string' ? legacyPassword : undefined;
|
||
try {
|
||
const result = await window.api.importBackup(pw);
|
||
if (!result || result.canceled) return;
|
||
if (result.needsPassword) {
|
||
const entered = await askLegacyBackupPassword();
|
||
if (entered) doBackupImport(entered);
|
||
return;
|
||
}
|
||
if (result.ok) {
|
||
config = result.config;
|
||
hosterSettings = config.hosterSettings || {};
|
||
alwaysOnTopState = !!(config.globalSettings && config.globalSettings.alwaysOnTop);
|
||
window.api.setAlwaysOnTop(alwaysOnTopState);
|
||
renderSettings();
|
||
renderAccounts();
|
||
renderHosterSummary();
|
||
renderHosterModal();
|
||
loadHistory();
|
||
showCopyToast('Backup importiert');
|
||
} else if (result.error) {
|
||
alert('Import fehlgeschlagen: ' + result.error);
|
||
}
|
||
} catch (err) {
|
||
alert('Import fehlgeschlagen: ' + (err.message || err));
|
||
}
|
||
}
|
||
|
||
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();
|
||
// History is only visible on the Verlauf tab. Mark it dirty and refresh when
|
||
// the user actually switches to it — skips an IPC + full table rebuild per
|
||
// batch-done when the user is watching the upload view.
|
||
_historyDirty = true;
|
||
if (_isHistoryTabActive()) loadHistory();
|
||
|
||
const removeOnDone = config.globalSettings && config.globalSettings.removeFromQueueOnDone;
|
||
if (removeOnDone) {
|
||
// Single pass: build the keep-list and clean up the index for removed jobs.
|
||
const nextJobs = [];
|
||
for (const job of queueJobs) {
|
||
if (job.status === 'done') {
|
||
removeJobFromIndex(job);
|
||
selectedJobIds.delete(job.id);
|
||
} else {
|
||
nextJobs.push(job);
|
||
}
|
||
}
|
||
queueJobs = nextJobs;
|
||
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 = [];
|
||
// Build a Set for O(1) selectedFiles dedup below.
|
||
const existingFilePaths = new Set();
|
||
for (const f of selectedFiles) existingFilePaths.add(f.path);
|
||
|
||
queueJobs.forEach(j => {
|
||
if (selectedJobIds.has(j.id) && ['error', 'done', 'aborted', 'skipped'].includes(j.status)) {
|
||
// Invalidate the old uploadId: retire the index entry and mark it so
|
||
// any late progress event from the previous (cancelled/completed)
|
||
// upload can't overwrite the freshly-reset state.
|
||
if (j.uploadId) {
|
||
_jobIndexByUploadId.delete(j.uploadId);
|
||
_deletedJobIds.add(j.uploadId);
|
||
}
|
||
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 (!existingFilePaths.has(j.file)) {
|
||
selectedFiles.push({ path: j.file, name: j.fileName, size: j.bytesTotal });
|
||
existingFilePaths.add(j.file);
|
||
}
|
||
}
|
||
});
|
||
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;
|
||
const dedupKey = `${link}\u0001${job.fileName}\u0001${job.hoster}`;
|
||
if (!_sessionFileKeys.has(dedupKey)) {
|
||
_sessionFileKeys.add(dedupKey);
|
||
sessionFilesData.push({
|
||
date: dt.text,
|
||
dateTs: dt.ts,
|
||
filename: job.fileName || '',
|
||
host: job.hoster || '',
|
||
link,
|
||
isError: false,
|
||
order: sessionFilesData.length
|
||
});
|
||
_sessionDoneCount++;
|
||
// Coalesce rapid successive adds into one render per frame.
|
||
scheduleRecentRender();
|
||
}
|
||
}
|
||
|
||
}
|
||
|
||
function applySummaryResults(summary) {
|
||
const files = Array.isArray(summary?.files) ? summary.files : [];
|
||
// Build a (fileName + hoster) → job map once so the per-result lookup is O(1)
|
||
// instead of O(|queueJobs|). Big batches (hundreds of files × multiple hosters)
|
||
// otherwise become O(n²).
|
||
const jobByKey = new Map();
|
||
for (const j of queueJobs) {
|
||
jobByKey.set(`${j.fileName}\u0001${j.hoster}`, j);
|
||
}
|
||
for (const file of files) {
|
||
for (const result of file.results || []) {
|
||
const job = jobByKey.get(`${file.name}\u0001${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).
|
||
// Also tracks inProgressBytes so the status bar doesn't need a second scan.
|
||
//
|
||
// Memoized within a single tick: back-to-back calls (updateStatusBar +
|
||
// updateStatsPanel fire together 4×/sec during upload) share one scan. The
|
||
// cache is cleared on microtask so the next tick picks up fresh state.
|
||
let _queueStatsCache = null;
|
||
function _computeQueueStats() {
|
||
if (_queueStatsCache) return _queueStatsCache;
|
||
|
||
let remaining = 0, inProgress = 0, done = 0, errors = 0;
|
||
let bytesRemaining = 0, totalSize = 0, remainingSize = 0, inProgressBytes = 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++;
|
||
inProgressBytes += bu;
|
||
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);
|
||
}
|
||
}
|
||
|
||
_queueStatsCache = { total, remaining, inProgress, done, errors, bytesRemaining, totalSize, remainingSize, inProgressBytes };
|
||
queueMicrotask(() => { _queueStatsCache = null; });
|
||
return _queueStatsCache;
|
||
}
|
||
|
||
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);
|
||
const uploadedSize = _sessionUploadedBytes + stats.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}`;
|
||
document.getElementById('sbDoneCount').textContent = `Done: ${_sessionDoneCount}`;
|
||
document.getElementById('sbErrorCount').textContent = `Error: ${_sessionErrorCount}`;
|
||
}
|
||
|
||
// --- 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">▼</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>
|
||
<button class="btn btn-xs btn-secondary" id="openLogFolderBtn" title="Log-Ordner im Explorer öffnen">Öffnen</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 ? '▶' : '▼';
|
||
});
|
||
|
||
// --- 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">▶</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 ? '▶' : '▼';
|
||
});
|
||
|
||
// 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">▶</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 ? '▶' : '▼';
|
||
});
|
||
|
||
// 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">▶</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 ? '▶' : '▼';
|
||
});
|
||
|
||
document.getElementById('exportBackupBtn').addEventListener('click', () => doBackupExport());
|
||
document.getElementById('importBackupBtn').addEventListener('click', () => doBackupImport());
|
||
|
||
// --- 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">▶</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 ? '▶' : '▼';
|
||
});
|
||
}
|
||
|
||
document.getElementById('chooseLogFilePathBtn')?.addEventListener('click', chooseLogFilePath);
|
||
document.getElementById('openLogFolderBtn')?.addEventListener('click', () => window.api.openLogFolder());
|
||
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';
|
||
}
|
||
|
||
const _STATUS_LABELS = { ok: 'Bereit', warn: 'Warnung', checking: 'Prüfe...', error: 'Fehler', unchecked: 'Nicht geprüft' };
|
||
|
||
function _buildAccountCardHtml(name, account, idx) {
|
||
const isDisabled = account.enabled === false;
|
||
const st = accountStatuses[account.id] || { status: 'unchecked', message: '' };
|
||
const statusLabel = isDisabled ? 'Deaktiviert' : (_STATUS_LABELS[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}`;
|
||
|
||
return `
|
||
<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">☰</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>`;
|
||
}
|
||
|
||
// Replace only the one card for `accountId` instead of re-rendering the whole
|
||
// container. Runs on enable/disable, single health check, priority-badge bumps
|
||
// after a reorder — anywhere we only change one card's state.
|
||
function updateAccountCard(accountId) {
|
||
const container = document.getElementById('accountsList');
|
||
if (!container) return;
|
||
const found = findAccountById(accountId);
|
||
if (!found) return;
|
||
const card = container.querySelector(`.account-card[data-account-id="${accountId}"]`);
|
||
if (!card) return;
|
||
const accounts = config.hosters[found.name] || [];
|
||
const idx = accounts.findIndex(a => a.id === accountId);
|
||
if (idx < 0) return;
|
||
const tmp = document.createElement('div');
|
||
tmp.innerHTML = _buildAccountCardHtml(found.name, found.account, idx);
|
||
card.replaceWith(tmp.firstElementChild);
|
||
}
|
||
|
||
let _accountListenersBound = false;
|
||
|
||
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>`;
|
||
if (!_accountListenersBound) bindAccountListeners(container);
|
||
return;
|
||
}
|
||
|
||
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) => { html += _buildAccountCardHtml(name, account, idx); });
|
||
html += '</div>';
|
||
}
|
||
container.innerHTML = html;
|
||
|
||
if (!_accountListenersBound) bindAccountListeners(container);
|
||
}
|
||
|
||
// Single set of delegated listeners on the accounts container. Bound once on
|
||
// the first render and reused for every subsequent in-place update / card
|
||
// swap. Previously we rebound 4 × N button listeners + 5 × N drag listeners
|
||
// per render — with 20 accounts that's 180 listener create/destroy cycles on
|
||
// every enable/disable click.
|
||
function bindAccountListeners(container) {
|
||
_accountListenersBound = true;
|
||
container.addEventListener('click', (e) => {
|
||
const btn = e.target.closest('button');
|
||
if (!btn) return;
|
||
if (btn.dataset.accountToggle) return toggleAccount(btn.dataset.accountToggle);
|
||
if (btn.dataset.accountEdit) return openAccountModal(btn.dataset.accountEdit);
|
||
if (btn.dataset.accountDelete) return openDeleteAccountModal(btn.dataset.accountDelete);
|
||
if (btn.dataset.accountCheck) return checkSingleAccount(btn.dataset.accountCheck);
|
||
});
|
||
|
||
let draggedCard = null;
|
||
container.addEventListener('dragstart', (e) => {
|
||
const card = e.target.closest('.account-card[draggable]');
|
||
if (!card) return;
|
||
draggedCard = card;
|
||
card.classList.add('dragging');
|
||
if (e.dataTransfer) e.dataTransfer.effectAllowed = 'move';
|
||
});
|
||
container.addEventListener('dragend', () => {
|
||
if (draggedCard) draggedCard.classList.remove('dragging');
|
||
draggedCard = null;
|
||
container.querySelectorAll('.drag-over-above, .drag-over-below').forEach(c => c.classList.remove('drag-over-above', 'drag-over-below'));
|
||
});
|
||
container.addEventListener('dragover', (e) => {
|
||
const card = e.target.closest('.account-card[draggable]');
|
||
if (!card || !draggedCard || draggedCard === card) return;
|
||
if (draggedCard.dataset.accountHoster !== card.dataset.accountHoster) return;
|
||
e.preventDefault();
|
||
if (e.dataTransfer) 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);
|
||
});
|
||
container.addEventListener('dragleave', (e) => {
|
||
const card = e.target.closest('.account-card[draggable]');
|
||
if (card) card.classList.remove('drag-over-above', 'drag-over-below');
|
||
});
|
||
container.addEventListener('drop', (e) => {
|
||
const card = e.target.closest('.account-card[draggable]');
|
||
if (!card || !draggedCard || draggedCard === card) return;
|
||
e.preventDefault();
|
||
card.classList.remove('drag-over-above', 'drag-over-below');
|
||
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);
|
||
if (fromIdx < 0) return;
|
||
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);
|
||
|
||
// Move the DOM node in place — no full re-render.
|
||
if (insertBefore) card.before(draggedCard); else card.after(draggedCard);
|
||
|
||
// The Primär / Fallback #N badges just changed for the whole group.
|
||
for (let i = 0; i < accounts.length; i++) updateAccountCard(accounts[i].id);
|
||
|
||
// Persist in the background. saveConfig is idempotent; we don't need to
|
||
// await here or re-fetch — our in-memory config is already the truth.
|
||
window.api.saveConfig({ hosters: config.hosters }).catch(() => {});
|
||
});
|
||
}
|
||
|
||
async function toggleAccount(accountId) {
|
||
const found = findAccountById(accountId);
|
||
if (!found) return;
|
||
found.account.enabled = !found.account.enabled;
|
||
syncSelectedUploadHosters();
|
||
// In-place: swap only the one affected card. No full re-render, no IPC
|
||
// refetch, no flicker. Rapid click-toggles now feel instant even with 50
|
||
// accounts in the list.
|
||
updateAccountCard(accountId);
|
||
renderHosterSummary();
|
||
window.api.saveConfig({ hosters: config.hosters }).catch(() => {});
|
||
}
|
||
|
||
async function checkSingleAccount(accountId) {
|
||
if (!accountId || healthCheckRunning) return;
|
||
const found = findAccountById(accountId);
|
||
if (!found) return;
|
||
healthCheckRunning = true;
|
||
accountStatuses[accountId] = { status: 'checking', message: '' };
|
||
updateAccountCard(accountId);
|
||
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;
|
||
}
|
||
updateAccountCard(accountId);
|
||
}
|
||
|
||
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">👁</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">👁</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)`);
|
||
}
|
||
|
||
// Memoize sort result: invalidated only when data length changes or sort state changes.
|
||
// Selection changes and re-renders reuse the cached sorted array — a big win when
|
||
// the panel has thousands of rows and the sort is stable.
|
||
let _recentSortCache = { sig: '', result: [] };
|
||
|
||
function sortRecentFiles(data) {
|
||
const { key, direction } = recentSortState;
|
||
const sig = `${key}|${direction}|${data.length}`;
|
||
if (_recentSortCache.sig === sig) return _recentSortCache.result;
|
||
|
||
const sorted = data.slice();
|
||
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;
|
||
});
|
||
_recentSortCache = { sig, result: sorted };
|
||
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 _buildRecentRowHtml(row) {
|
||
const cls = `recent-file-row${row.isError ? ' error' : ''}${selectedRecentIds.has(row.order) ? ' selected' : ''}`;
|
||
return `<tr class="${cls}" 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>`;
|
||
}
|
||
|
||
// Tracks the last rendered dataset so we can append-only when the user is just
|
||
// accumulating new uploads (the default case: sort=date desc, rows only grow).
|
||
let _recentLastRenderedSig = '';
|
||
let _recentLastRenderedLen = 0;
|
||
|
||
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>';
|
||
_recentLastRenderedSig = '';
|
||
_recentLastRenderedLen = 0;
|
||
return;
|
||
}
|
||
|
||
const rows = sortRecentFiles(sessionFilesData);
|
||
const sig = `${recentSortState.key}|${recentSortState.direction}`;
|
||
const dateDescAppendOnly = sig === 'date|desc'
|
||
&& _recentLastRenderedSig === sig
|
||
&& rows.length > _recentLastRenderedLen
|
||
&& tbody.querySelectorAll('.recent-file-row').length === _recentLastRenderedLen;
|
||
|
||
let wasAppendOnly = false;
|
||
if (dateDescAppendOnly) {
|
||
// Fast path: only new rows (date desc puts newest on top) — insert them
|
||
// at the top without rebuilding the 5000-row tbody below.
|
||
const added = rows.length - _recentLastRenderedLen;
|
||
let html = '';
|
||
for (let i = 0; i < added; i++) html += _buildRecentRowHtml(rows[i]);
|
||
tbody.insertAdjacentHTML('afterbegin', html);
|
||
wasAppendOnly = true;
|
||
} else {
|
||
tbody.innerHTML = rows.map(_buildRecentRowHtml).join('');
|
||
}
|
||
_recentLastRenderedSig = sig;
|
||
_recentLastRenderedLen = rows.length;
|
||
|
||
// 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 — class-toggle only.
|
||
if (selectedJobIds.size > 0) { selectedJobIds.clear(); applyQueueSelectionClasses(); 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) {
|
||
// Use already-sorted DOM order (cheap) instead of re-sorting the full array.
|
||
const sortedOrders = Array.from(tbody.querySelectorAll('.recent-file-row'))
|
||
.map(r => parseInt(r.dataset.order, 10));
|
||
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);
|
||
}
|
||
// Selection change — toggle classes, no tbody rebuild.
|
||
applyRecentSelectionClasses();
|
||
});
|
||
|
||
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'); }
|
||
});
|
||
}
|
||
|
||
// Sort headers only change when the sort state changes — skip on appends.
|
||
if (!wasAppendOnly) 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('exportRecentFilesBtn').addEventListener('click', exportAllRecentFiles);
|
||
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);
|
||
|
||
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);
|
||
});
|
||
});
|
||
}
|
||
|
||
// Single-pass escape instead of 4 chained .replace(/x/g, ...) calls.
|
||
// Hot path on large table rebuilds — every text cell runs through one of these.
|
||
const _HTML_ESC_MAP = { '&': '&', '<': '<', '>': '>', '"': '"' };
|
||
const _HTML_ESC_RE = /[&<>"]/g;
|
||
const _ATTR_ESC_MAP = { '&': '&', '"': '"', "'": ''' };
|
||
const _ATTR_ESC_RE = /[&"']/g;
|
||
|
||
function escapeHtml(str) {
|
||
if (!str) return '';
|
||
return String(str).replace(_HTML_ESC_RE, (c) => _HTML_ESC_MAP[c]);
|
||
}
|
||
|
||
function escapeAttr(str) {
|
||
if (!str) return '';
|
||
return String(str).replace(_ATTR_ESC_RE, (c) => _ATTR_ESC_MAP[c]);
|
||
}
|
||
|
||
function showCopyToast(msg, durationMs) {
|
||
const toast = document.getElementById('copyToast');
|
||
toast.textContent = msg;
|
||
toast.classList.add('show');
|
||
clearTimeout(toast._timer);
|
||
toast._timer = setTimeout(() => toast.classList.remove('show'), durationMs || 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();
|