From 25b2afbf115b1ef790a59ed020c08a74e436dc4d Mon Sep 17 00:00:00 2001 From: Administrator Date: Tue, 10 Mar 2026 05:57:00 +0100 Subject: [PATCH] feat: add queue system, per-hoster settings, retry logic, and full UI overhaul - Add FIFO semaphore for per-hoster concurrency control - Add token-bucket speed limiter with abort signal support - Rewrite upload-manager with retry loop, speed monitoring, and rich progress events - Add per-hoster settings: retries, max speed, parallel count, restart below speed, time interval, max size - Add context menu with shutdown-after-finish (sleep/shutdown/restart), always-on-top - Add z-o-o-m-style queue table with 8 columns, status-colored rows, progress bars - Add debounced queue rendering with scroll position preservation - Add statusbar with global speed, total bytes, elapsed time - Fix speedMonitor interval leak on error and scoping bug - Fix throttle not respecting abort signal during cancellation - Fix combined signal listener cleanup - Bump version to 1.1.0 Co-Authored-By: Claude Opus 4.6 --- lib/config-store.js | 41 +- lib/hosters.js | 7 +- lib/semaphore.js | 48 ++ lib/throttle.js | 42 ++ lib/upload-manager.js | 421 +++++++++--- lib/vidmoly-upload.js | 3 +- main.js | 102 ++- package.json | 2 +- preload.js | 26 +- renderer/app.js | 1429 ++++++++++++++++++++--------------------- renderer/index.html | 127 ++-- renderer/styles.css | 1184 +++++++++++++--------------------- 12 files changed, 1802 insertions(+), 1630 deletions(-) create mode 100644 lib/semaphore.js create mode 100644 lib/throttle.js diff --git a/lib/config-store.js b/lib/config-store.js index d9f64eb..7009f2a 100644 --- a/lib/config-store.js +++ b/lib/config-store.js @@ -1,6 +1,15 @@ const fs = require('fs'); const path = require('path'); +const HOSTER_SETTINGS_DEFAULTS = { + retries: 3, + maxSpeedKbs: 0, // 0 = unlimited + parallelCount: 2, // 1-10 + restartBelowKbs: 0, // 0 = off + timeIntervalSec: 0, // delay between jobs + maxSizeMb: 0 // 0 = unlimited +}; + const DEFAULTS = { hosters: { 'doodstream.com': { enabled: true, apiKey: '' }, @@ -8,6 +17,16 @@ const DEFAULTS = { 'vidmoly.me': { enabled: true, authType: 'login', username: '', password: '' }, 'byse.sx': { enabled: true, apiKey: '' } }, + hosterSettings: { + 'doodstream.com': { ...HOSTER_SETTINGS_DEFAULTS }, + 'voe.sx': { ...HOSTER_SETTINGS_DEFAULTS }, + 'vidmoly.me': { ...HOSTER_SETTINGS_DEFAULTS }, + 'byse.sx': { ...HOSTER_SETTINGS_DEFAULTS } + }, + globalSettings: { + alwaysOnTop: false, + shutdownAfterFinish: 'nothing' // nothing | sleep | shutdown | restart + }, history: [] }; @@ -32,7 +51,19 @@ class ConfigStore { hosters[name] = { ...hosters[name], ...val }; } } - return { hosters, history: data.history || [] }; + // Merge hoster settings with defaults + const hosterSettings = {}; + for (const name of Object.keys(DEFAULTS.hosterSettings)) { + hosterSettings[name] = { + ...HOSTER_SETTINGS_DEFAULTS, + ...(data.hosterSettings && data.hosterSettings[name] || {}) + }; + } + const globalSettings = { + ...DEFAULTS.globalSettings, + ...(data.globalSettings || {}) + }; + return { hosters, hosterSettings, globalSettings, history: data.history || [] }; } catch { return JSON.parse(JSON.stringify(DEFAULTS)); } @@ -40,8 +71,12 @@ class ConfigStore { save(config) { const current = this.load(); - // Only update hosters, keep history - current.hosters = config.hosters || current.hosters; + // Update hosters credentials + if (config.hosters) current.hosters = config.hosters; + // Update hoster settings + if (config.hosterSettings) current.hosterSettings = config.hosterSettings; + // Update global settings + if (config.globalSettings) current.globalSettings = config.globalSettings; fs.writeFileSync(this.filePath, JSON.stringify(current, null, 2), 'utf-8'); } diff --git a/lib/hosters.js b/lib/hosters.js index e8e21d0..c7ae07b 100644 --- a/lib/hosters.js +++ b/lib/hosters.js @@ -238,7 +238,7 @@ function buildMultipart(filePath, formFields) { return { boundary, preambleBuf, epilogueBuf, totalSize, fileSize }; } -function createUploadBody(filePath, formFields, onProgress) { +function createUploadBody(filePath, formFields, onProgress, throttle, signal) { const { boundary, preambleBuf, epilogueBuf, totalSize, fileSize } = buildMultipart(filePath, formFields); let bytesRead = 0; @@ -248,6 +248,7 @@ function createUploadBody(filePath, formFields, onProgress) { yield preambleBuf; const fileStream = fs.createReadStream(filePath, { highWaterMark: CHUNK_SIZE }); for await (const chunk of fileStream) { + if (throttle) await throttle.consume(chunk.length, signal); bytesRead += chunk.length; yield chunk; if (onProgress) onProgress(bytesRead, fileSize); @@ -337,7 +338,7 @@ async function getUploadServer(hosterName, hosterConfig, apiKey, signal) { throw new Error('Kein Upload-Server erhalten. API-Key pruefen.'); } -async function uploadFile(hosterName, filePath, apiKey, onProgress, signal) { +async function uploadFile(hosterName, filePath, apiKey, onProgress, signal, throttle) { const config = HOSTER_CONFIGS[hosterName]; if (!config) throw new Error(`Unbekannter Hoster: ${hosterName}`); @@ -348,7 +349,7 @@ async function uploadFile(hosterName, filePath, apiKey, onProgress, signal) { const targetUrl = config.buildUploadUrl(uploadUrl, apiKey); const formFields = config.formFields(apiKey); - const { iterable, boundary, totalSize } = createUploadBody(filePath, formFields, onProgress); + const { iterable, boundary, totalSize } = createUploadBody(filePath, formFields, onProgress, throttle, signal); const { body, statusCode } = await request(targetUrl, { method: 'POST', diff --git a/lib/semaphore.js b/lib/semaphore.js new file mode 100644 index 0000000..6f58688 --- /dev/null +++ b/lib/semaphore.js @@ -0,0 +1,48 @@ +/** + * FIFO Semaphore for per-hoster concurrency control. + * acquire() blocks until a slot is available, release() frees it. + */ +class Semaphore { + constructor(limit) { + this.limit = Math.max(1, limit || 1); + this.active = 0; + this.queue = []; + } + + acquire() { + return new Promise((resolve) => { + if (this.active < this.limit) { + this.active++; + resolve(); + } else { + this.queue.push(resolve); + } + }); + } + + release() { + if (this.queue.length > 0) { + // Don't decrement active — hand slot directly to next waiter + const next = this.queue.shift(); + next(); + } else { + this.active = Math.max(0, this.active - 1); + } + } + + updateLimit(newLimit) { + this.limit = Math.max(1, newLimit || 1); + // If new limit is higher, wake up waiting tasks + while (this.active < this.limit && this.queue.length > 0) { + this.active++; + const next = this.queue.shift(); + next(); + } + } + + get pending() { + return this.queue.length; + } +} + +module.exports = Semaphore; diff --git a/lib/throttle.js b/lib/throttle.js new file mode 100644 index 0000000..c7bc2ac --- /dev/null +++ b/lib/throttle.js @@ -0,0 +1,42 @@ +/** + * Token-bucket speed limiter for bandwidth throttling. + * maxBytesPerSec = 0 means unlimited (passthrough). + */ +class Throttle { + constructor(maxBytesPerSec) { + this.maxBps = maxBytesPerSec || 0; + this.tokens = this.maxBps; + this.lastRefill = Date.now(); + } + + async consume(bytes, signal) { + if (this.maxBps <= 0) return; // unlimited + + while (bytes > 0) { + if (signal && signal.aborted) return; + this._refill(); + const available = Math.min(bytes, Math.floor(this.tokens)); + if (available > 0) { + this.tokens -= available; + bytes -= available; + } + if (bytes > 0) { + // Wait 50ms for tokens to refill + await new Promise((r) => setTimeout(r, 50)); + } + } + } + + _refill() { + const now = Date.now(); + const elapsed = (now - this.lastRefill) / 1000; + this.tokens = Math.min(this.maxBps, this.tokens + elapsed * this.maxBps); + this.lastRefill = now; + } + + updateRate(maxBytesPerSec) { + this.maxBps = maxBytesPerSec || 0; + } +} + +module.exports = Throttle; diff --git a/lib/upload-manager.js b/lib/upload-manager.js index e4b60ec..2ff544c 100644 --- a/lib/upload-manager.js +++ b/lib/upload-manager.js @@ -4,21 +4,53 @@ const fs = require('fs'); const crypto = require('crypto'); const { uploadFile } = require('./hosters'); const VidmolyUploader = require('./vidmoly-upload'); +const Semaphore = require('./semaphore'); +const Throttle = require('./throttle'); + +const DEFAULT_SETTINGS = { + retries: 3, + maxSpeedKbs: 0, + parallelCount: 2, + restartBelowKbs: 0, + timeIntervalSec: 0, + maxSizeMb: 0 +}; class UploadManager extends EventEmitter { - constructor() { + constructor(hosterSettings) { super(); + this.hosterSettings = hosterSettings || {}; + this.semaphores = {}; this.abortController = new AbortController(); this.running = false; + this.statsInterval = null; + this.startTime = 0; + this.activeJobs = new Map(); // uploadId -> { speedKbs, bytesUploaded } + this.sessionBytes = 0; + } + + _getSettings(hoster) { + return { ...DEFAULT_SETTINGS, ...(this.hosterSettings[hoster] || {}) }; + } + + _getSemaphore(hoster) { + if (!this.semaphores[hoster]) { + const settings = this._getSettings(hoster); + this.semaphores[hoster] = new Semaphore(settings.parallelCount); + } + return this.semaphores[hoster]; } async startBatch(tasks) { this.running = true; this.abortController = new AbortController(); + this.startTime = Date.now(); + this.sessionBytes = 0; + this.activeJobs.clear(); const { signal } = this.abortController; const batchId = `batch-${Date.now()}`; - const results = new Map(); // fileName -> { name, size, results: [] } + const results = new Map(); // filePath -> { name, size, results: [] } // Initialize result map per file for (const task of tasks) { @@ -30,96 +62,14 @@ class UploadManager extends EventEmitter { } } - // Build upload promises - const promises = tasks.map(async (task) => { - const uploadId = crypto.randomBytes(8).toString('hex'); - const fileName = path.basename(task.file); - let fileSize = 0; - try { fileSize = fs.statSync(task.file).size; } catch {} - - // Emit initial status - this.emit('progress', { - uploadId, - fileName, - hoster: task.hoster, - status: 'getting-server', - progress: 0, - bytesUploaded: 0, - bytesTotal: fileSize, - error: null, - result: null - }); - - try { - let result; - const progressCb = (bytesUploaded, bytesTotal) => { - this.emit('progress', { - uploadId, - fileName, - hoster: task.hoster, - status: 'uploading', - progress: bytesTotal > 0 ? bytesUploaded / bytesTotal : 0, - bytesUploaded, - bytesTotal, - error: null, - result: null - }); - }; - - if (task.hoster === 'vidmoly.me' && task.username) { - // Vidmoly: login-based upload - const vidmoly = new VidmolyUploader(); - await vidmoly.login(task.username, task.password); - result = await vidmoly.upload(task.file, progressCb, signal); - } else { - result = await uploadFile(task.hoster, task.file, task.apiKey, progressCb, signal); - } - - this.emit('progress', { - uploadId, - fileName, - hoster: task.hoster, - status: 'done', - progress: 1, - bytesUploaded: fileSize, - bytesTotal: fileSize, - error: null, - result - }); - - results.get(task.file).results.push({ - hoster: task.hoster, - status: 'done', - ...result - }); - - } catch (err) { - const errorMsg = err.name === 'AbortError' ? 'Abgebrochen' : err.message; - - this.emit('progress', { - uploadId, - fileName, - hoster: task.hoster, - status: 'error', - progress: 0, - bytesUploaded: 0, - bytesTotal: fileSize, - error: errorMsg, - result: null - }); - - results.get(task.file).results.push({ - hoster: task.hoster, - status: 'error', - error: errorMsg, - download_url: null, - embed_url: null, - file_code: null - }); - } - }); + // Start global stats emitter + this._startStatsTimer(); + // Create job promises — semaphore controls concurrency per hoster + const promises = tasks.map((task) => this._runJob(task, results, signal)); await Promise.allSettled(promises); + + this._stopStatsTimer(); this.running = false; const files = Array.from(results.values()); @@ -138,10 +88,301 @@ class UploadManager extends EventEmitter { this.emit('batch-done', summary); } + async _runJob(task, results, signal) { + const settings = this._getSettings(task.hoster); + const semaphore = this._getSemaphore(task.hoster); + const uploadId = crypto.randomBytes(8).toString('hex'); + const fileName = path.basename(task.file); + let fileSize = 0; + try { fileSize = fs.statSync(task.file).size; } catch {} + + const maxAttempts = Math.max(1, (settings.retries || 0) + 1); + + // File size filter + if (settings.maxSizeMb > 0 && fileSize > settings.maxSizeMb * 1024 * 1024) { + const errMsg = `Datei zu gross (Max: ${settings.maxSizeMb} MB)`; + this._emitProgress(uploadId, fileName, task.hoster, { + status: 'skipped', progress: 0, + bytesUploaded: 0, bytesTotal: fileSize, + speedKbs: 0, elapsed: 0, remaining: 0, + error: errMsg, result: null, attempt: 0, maxAttempts + }); + results.get(task.file).results.push({ + hoster: task.hoster, status: 'error', error: errMsg, + download_url: null, embed_url: null, file_code: null + }); + return; + } + + // Emit queued status + this._emitProgress(uploadId, fileName, task.hoster, { + status: 'queued', progress: 0, + bytesUploaded: 0, bytesTotal: fileSize, + speedKbs: 0, elapsed: 0, remaining: 0, + error: null, result: null, attempt: 0, maxAttempts + }); + + // Wait for semaphore slot + await semaphore.acquire(); + if (signal.aborted) { + semaphore.release(); + return; + } + + // Time interval delay between jobs + if (settings.timeIntervalSec > 0) { + await this._sleep(settings.timeIntervalSec * 1000, signal).catch(() => {}); + if (signal.aborted) { + semaphore.release(); + return; + } + } + + let lastError = null; + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + if (signal.aborted) break; + + // Retry delay + if (attempt > 1) { + this._emitProgress(uploadId, fileName, task.hoster, { + status: 'retrying', progress: 0, + bytesUploaded: 0, bytesTotal: fileSize, + speedKbs: 0, elapsed: 0, remaining: 0, + error: lastError ? lastError.message : null, + result: null, attempt, maxAttempts + }); + await this._sleep(2500, signal).catch(() => {}); + if (signal.aborted) break; + } + + const jobStart = Date.now(); + let lastBytes = 0; + let lastSpeedTime = jobStart; + let currentSpeedKbs = 0; + let lowSpeedSince = 0; + let speedAbort = null; + + // Register active job for global stats + this.activeJobs.set(uploadId, { speedKbs: 0, bytesUploaded: 0 }); + + // Speed monitor interval (declared outside try for cleanup in catch) + let speedMonitor = null; + + try { + // Getting server + this._emitProgress(uploadId, fileName, task.hoster, { + status: 'getting-server', progress: 0, + bytesUploaded: 0, bytesTotal: fileSize, + speedKbs: 0, elapsed: 0, remaining: 0, + error: null, result: null, attempt, maxAttempts + }); + + // Create per-job throttle + const throttle = settings.maxSpeedKbs > 0 + ? new Throttle(settings.maxSpeedKbs * 1024) + : null; + + // Speed monitor: abort if too slow + if (settings.restartBelowKbs > 0) { + speedAbort = new AbortController(); + } + + // Combined signal + const jobSignal = speedAbort + ? this._combineSignals(signal, speedAbort.signal) + : signal; + if (settings.restartBelowKbs > 0) { + speedMonitor = setInterval(() => { + if (currentSpeedKbs > 0 && currentSpeedKbs < settings.restartBelowKbs) { + if (!lowSpeedSince) lowSpeedSince = Date.now(); + if (Date.now() - lowSpeedSince > 6000) { + if (speedAbort) speedAbort.abort(); + clearInterval(speedMonitor); + } + } else { + lowSpeedSince = 0; + } + }, 2000); + } + + // Progress callback with speed tracking + const progressCb = (bytesUploaded, bytesTotal) => { + const now = Date.now(); + const elapsed = Math.round((now - jobStart) / 1000); + + // Speed calculation (update every ~1s) + const timeDelta = (now - lastSpeedTime) / 1000; + if (timeDelta >= 1) { + const bytesDelta = bytesUploaded - lastBytes; + currentSpeedKbs = Math.round(bytesDelta / timeDelta / 1024); + lastBytes = bytesUploaded; + lastSpeedTime = now; + } + + const remaining = currentSpeedKbs > 0 + ? Math.round((bytesTotal - bytesUploaded) / (currentSpeedKbs * 1024)) + : 0; + + // Update active job stats for global aggregation + this.activeJobs.set(uploadId, { speedKbs: currentSpeedKbs, bytesUploaded }); + + this._emitProgress(uploadId, fileName, task.hoster, { + status: 'uploading', progress: bytesTotal > 0 ? bytesUploaded / bytesTotal : 0, + bytesUploaded, bytesTotal: bytesTotal, + speedKbs: currentSpeedKbs, elapsed, remaining, + error: null, result: null, attempt, maxAttempts + }); + }; + + let result; + if (task.hoster === 'vidmoly.me' && task.username) { + const vidmoly = new VidmolyUploader(); + await vidmoly.login(task.username, task.password); + result = await vidmoly.upload(task.file, progressCb, jobSignal, throttle); + } else { + result = await uploadFile(task.hoster, task.file, task.apiKey, progressCb, jobSignal, throttle); + } + + // Clear speed monitor + if (speedMonitor) clearInterval(speedMonitor); + + // Track session bytes + this.sessionBytes += fileSize; + this.activeJobs.delete(uploadId); + + // Success + const elapsed = Math.round((Date.now() - jobStart) / 1000); + this._emitProgress(uploadId, fileName, task.hoster, { + status: 'done', progress: 1, + bytesUploaded: fileSize, bytesTotal: fileSize, + speedKbs: currentSpeedKbs, elapsed, remaining: 0, + error: null, result, attempt, maxAttempts + }); + + results.get(task.file).results.push({ + hoster: task.hoster, status: 'done', ...result + }); + + semaphore.release(); + return; // Success — exit retry loop + + } catch (err) { + // Clear speed monitor interval on error + if (speedMonitor) { clearInterval(speedMonitor); speedMonitor = null; } + if (speedAbort) { + // Check if this was a speed restart + try { speedAbort.abort(); } catch {} + } + this.activeJobs.delete(uploadId); + + if (signal.aborted) { + lastError = err; + break; + } + + // Check if speed restart (not user abort) + const isSpeedRestart = speedAbort && speedAbort.signal.aborted && !signal.aborted; + if (isSpeedRestart && attempt < maxAttempts) { + lastError = new Error('Geschwindigkeit zu niedrig - Neustart'); + continue; + } + + lastError = err; + if (attempt >= maxAttempts) break; + } + } + + // All attempts exhausted + this.activeJobs.delete(uploadId); + const errorMsg = signal.aborted + ? 'Abgebrochen' + : (lastError ? lastError.message : 'Unbekannter Fehler'); + + this._emitProgress(uploadId, fileName, task.hoster, { + status: 'error', progress: 0, + bytesUploaded: 0, bytesTotal: fileSize, + speedKbs: 0, elapsed: 0, remaining: 0, + error: errorMsg, result: null, + attempt: maxAttempts, maxAttempts + }); + + results.get(task.file).results.push({ + hoster: task.hoster, status: 'error', error: errorMsg, + download_url: null, embed_url: null, file_code: null + }); + + semaphore.release(); + } + + _emitProgress(uploadId, fileName, hoster, data) { + this.emit('progress', { uploadId, fileName, hoster, ...data }); + } + + _startStatsTimer() { + this.statsInterval = setInterval(() => { + let globalSpeedKbs = 0; + let activeCount = 0; + for (const job of this.activeJobs.values()) { + globalSpeedKbs += job.speedKbs || 0; + activeCount++; + } + + const elapsed = Math.round((Date.now() - this.startTime) / 1000); + + // Sum in-progress bytes for live total + let inProgressBytes = 0; + for (const job of this.activeJobs.values()) { + inProgressBytes += job.bytesUploaded || 0; + } + + this.emit('stats', { + state: this.running ? 'uploading' : 'idle', + globalSpeedKbs, + totalBytes: this.sessionBytes + inProgressBytes, + elapsed, + activeJobs: activeCount, + pendingJobs: Object.values(this.semaphores).reduce((sum, s) => sum + s.pending, 0) + }); + }, 1000); + } + + _stopStatsTimer() { + if (this.statsInterval) { + clearInterval(this.statsInterval); + this.statsInterval = null; + } + } + + _combineSignals(signal1, signal2) { + const controller = new AbortController(); + if (signal1.aborted || signal2.aborted) { controller.abort(); return controller.signal; } + const cleanup = () => { + signal1.removeEventListener('abort', onAbort); + signal2.removeEventListener('abort', onAbort); + }; + const onAbort = () => { controller.abort(); cleanup(); }; + signal1.addEventListener('abort', onAbort, { once: true }); + signal2.addEventListener('abort', onAbort, { once: true }); + return controller.signal; + } + + _sleep(ms, signal) { + return new Promise((resolve, reject) => { + const timer = setTimeout(resolve, ms); + if (signal) { + if (signal.aborted) { clearTimeout(timer); reject(new Error('Aborted')); return; } + signal.addEventListener('abort', () => { clearTimeout(timer); reject(new Error('Aborted')); }, { once: true }); + } + }); + } + cancel() { if (this.running) { this.abortController.abort(); this.running = false; + this._stopStatsTimer(); + this.activeJobs.clear(); } } } diff --git a/lib/vidmoly-upload.js b/lib/vidmoly-upload.js index 15aa3b7..cfda9e0 100644 --- a/lib/vidmoly-upload.js +++ b/lib/vidmoly-upload.js @@ -169,7 +169,7 @@ class VidmolyUploader { /** * Upload a file to Vidmoly (uses undici.request for streaming progress) */ - async upload(filePath, onProgress, signal) { + async upload(filePath, onProgress, signal, throttle) { const fileName = path.basename(filePath); const fileSize = fs.statSync(filePath).size; const baselineCodes = await this._captureVmFileCodes(); @@ -210,6 +210,7 @@ class VidmolyUploader { yield preambleBuf; const fileStream = fs.createReadStream(filePath, { highWaterMark: CHUNK_SIZE }); for await (const chunk of fileStream) { + if (throttle) await throttle.consume(chunk.length, signal); bytesRead += chunk.length; yield chunk; if (onProgress) onProgress(bytesRead, fileSize); diff --git a/main.js b/main.js index 2d33409..215d2e1 100644 --- a/main.js +++ b/main.js @@ -282,7 +282,8 @@ ipcMain.handle('start-upload', (_event, payload) => { if (tasks.length === 0) return { error: 'Keine gueltigen Zugangsdaten fuer die gewaehlten Hoster.' }; - uploadManager = new UploadManager(); + // Pass hoster settings to the upload manager + uploadManager = new UploadManager(config.hosterSettings || {}); uploadManager.on('progress', (data) => { if (mainWindow && !mainWindow.isDestroyed()) { @@ -290,6 +291,12 @@ ipcMain.handle('start-upload', (_event, payload) => { } }); + uploadManager.on('stats', (data) => { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('upload-stats', data); + } + }); + uploadManager.on('batch-done', (summary) => { configStore.appendHistory(summary); // Write successful uploads to fileuploader.log @@ -307,6 +314,9 @@ ipcMain.handle('start-upload', (_event, payload) => { if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('upload-batch-done', summary); } + + // Shutdown after finish + handleShutdownAfterFinish(); }); uploadManager.startBatch(tasks); @@ -360,3 +370,93 @@ ipcMain.handle('app:abort-update', () => { ipcMain.handle('app:get-version', () => { return app.getVersion(); }); + +// --- Hoster settings --- +ipcMain.handle('get-hoster-settings', () => { + const config = configStore.load(); + return config.hosterSettings || {}; +}); + +ipcMain.handle('save-hoster-settings', (_event, hosterSettings) => { + configStore.save({ hosterSettings }); + return true; +}); + +// --- Global settings --- +ipcMain.handle('get-global-settings', () => { + const config = configStore.load(); + return config.globalSettings || {}; +}); + +ipcMain.handle('save-global-settings', (_event, globalSettings) => { + configStore.save({ globalSettings }); + return true; +}); + +// --- Always on top --- +ipcMain.handle('set-always-on-top', (_event, value) => { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.setAlwaysOnTop(!!value); + } + configStore.save({ globalSettings: { ...configStore.load().globalSettings, alwaysOnTop: !!value } }); + return true; +}); + +ipcMain.handle('get-always-on-top', () => { + if (mainWindow && !mainWindow.isDestroyed()) { + return mainWindow.isAlwaysOnTop(); + } + return false; +}); + +// --- Shutdown after finish --- +let shutdownMode = 'nothing'; +let shutdownTimer = null; + +ipcMain.handle('set-shutdown-after-finish', (_event, mode) => { + shutdownMode = mode || 'nothing'; + return true; +}); + +ipcMain.handle('get-shutdown-after-finish', () => { + return shutdownMode; +}); + +ipcMain.handle('cancel-shutdown', () => { + if (shutdownTimer) { + clearTimeout(shutdownTimer); + shutdownTimer = null; + } + shutdownMode = 'nothing'; + return true; +}); + +function handleShutdownAfterFinish() { + if (shutdownMode === 'nothing') return; + + const { exec } = require('child_process'); + const mode = shutdownMode; + + // Notify renderer + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('shutdown-countdown', { mode, seconds: 60 }); + } + + shutdownTimer = setTimeout(() => { + if (mode === 'shutdown') { + exec('shutdown /s /t 0'); + } else if (mode === 'restart') { + exec('shutdown /r /t 0'); + } else if (mode === 'sleep') { + exec('rundll32.exe powrprof.dll,SetSuspendState 0,1,0'); + } + }, 60000); +} + +// Restore always-on-top from config on window creation +app.on('browser-window-created', () => { + const config = configStore.load(); + if (config.globalSettings && config.globalSettings.alwaysOnTop && mainWindow) { + mainWindow.setAlwaysOnTop(true); + } +}); diff --git a/package.json b/package.json index 48e89c1..0c25f30 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "multi-hoster-uploader", - "version": "1.0.0", + "version": "1.1.0", "description": "Upload files to doodstream, voe, vidmoly, byse simultaneously", "main": "main.js", "scripts": { diff --git a/preload.js b/preload.js index f533482..824a581 100644 --- a/preload.js +++ b/preload.js @@ -5,9 +5,25 @@ contextBridge.exposeInMainWorld('api', { getConfig: () => ipcRenderer.invoke('get-config'), saveConfig: (config) => ipcRenderer.invoke('save-config', config), getHistory: () => ipcRenderer.invoke('get-history'), - clearHistory: () => ipcRenderer.invoke('clear-history'), + // Hoster settings + getHosterSettings: () => ipcRenderer.invoke('get-hoster-settings'), + saveHosterSettings: (settings) => ipcRenderer.invoke('save-hoster-settings', settings), + + // Global settings + getGlobalSettings: () => ipcRenderer.invoke('get-global-settings'), + saveGlobalSettings: (settings) => ipcRenderer.invoke('save-global-settings', settings), + + // Always on top + setAlwaysOnTop: (value) => ipcRenderer.invoke('set-always-on-top', value), + getAlwaysOnTop: () => ipcRenderer.invoke('get-always-on-top'), + + // Shutdown after finish + setShutdownAfterFinish: (mode) => ipcRenderer.invoke('set-shutdown-after-finish', mode), + getShutdownAfterFinish: () => ipcRenderer.invoke('get-shutdown-after-finish'), + cancelShutdown: () => ipcRenderer.invoke('cancel-shutdown'), + // File selection selectFiles: () => ipcRenderer.invoke('select-files'), @@ -38,10 +54,18 @@ contextBridge.exposeInMainWorld('api', { onUploadBatchDone: (callback) => { ipcRenderer.on('upload-batch-done', (_event, data) => callback(data)); }, + onUploadStats: (callback) => { + ipcRenderer.on('upload-stats', (_event, data) => callback(data)); + }, + onShutdownCountdown: (callback) => { + ipcRenderer.on('shutdown-countdown', (_event, data) => callback(data)); + }, removeAllListeners: () => { ipcRenderer.removeAllListeners('upload-progress'); ipcRenderer.removeAllListeners('upload-batch-done'); + ipcRenderer.removeAllListeners('upload-stats'); ipcRenderer.removeAllListeners('app:update-available'); ipcRenderer.removeAllListeners('app:update-progress'); + ipcRenderer.removeAllListeners('shutdown-countdown'); } }); diff --git a/renderer/app.js b/renderer/app.js index 017a231..a3e746a 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -1,43 +1,31 @@ const HOSTERS = ['doodstream.com', 'voe.sx', 'vidmoly.me', 'byse.sx']; +// --- State --- let selectedFiles = []; // { path, name, size } -let config = { hosters: {} }; -let progressElements = new Map(); // uploadId -> DOM refs +let config = { hosters: {}, hosterSettings: {}, globalSettings: {} }; +let hosterSettings = {}; let uploading = false; let healthCheckRunning = false; -const AUTO_CHECK_PREF_KEY = 'autoHealthCheckBeforeUpload'; let autoHealthCheckEnabled = true; -const SORT_DEFAULT_DIRECTION = { - date: 'desc', - filename: 'asc', - host: 'asc', - link: 'asc' -}; +const AUTO_CHECK_PREF_KEY = 'autoHealthCheckBeforeUpload'; -function getDefaultSortDirection(key) { - return SORT_DEFAULT_DIRECTION[key] || 'asc'; -} +// Queue state +let queueJobs = []; // { id, file, fileName, hoster, status, bytesUploaded, bytesTotal, speedKbs, elapsed, remaining, error, result, attempt, maxAttempts, link } +let selectedJobIds = new Set(); +let queueSortState = { key: 'filename', direction: 'asc' }; -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' }) - }; -} +// History state +let historyRowsData = []; +let historySortState = { key: 'date', direction: 'desc' }; // --- Init --- async function init() { config = await window.api.getConfig(); + hosterSettings = config.hosterSettings || {}; autoHealthCheckEnabled = loadAutoCheckPreference(); renderHosterChips(); renderSettings(); - setHealthCheckStatus('Bereit fuer Check'); - renderHealthCheckResults([]); setupListeners(); - syncAutoCheckToggle(); setupDragDrop(); loadHistory(); @@ -51,6 +39,18 @@ async function init() { // Update listeners window.api.onUpdateAvailable(showUpdateBanner); window.api.onUpdateProgress(handleUpdateProgress); + + // Upload event listeners + window.api.onUploadProgress(handleProgress); + window.api.onUploadBatchDone(handleBatchDone); + window.api.onUploadStats(handleStats); + window.api.onShutdownCountdown(handleShutdownCountdown); + + // Restore always-on-top state + try { + const onTop = await window.api.getAlwaysOnTop(); + alwaysOnTopState = !!onTop; + } catch {} } // --- Tab switching --- @@ -64,7 +64,7 @@ document.querySelectorAll('.tab').forEach(tab => { }); }); -// --- Hoster chips on upload page --- +// --- Hoster chips --- function hosterHasCredentials(name, hoster) { if (name === 'vidmoly.me') return !!(hoster.username && hoster.password); return !!hoster.apiKey; @@ -85,6 +85,8 @@ function renderHosterChips() { `; chip.querySelector('input').addEventListener('change', (e) => { chip.classList.toggle('selected', e.target.checked); + if (!uploading && selectedFiles.length > 0) buildQueuePreview(); + updateStartButton(); }); container.appendChild(chip); } @@ -98,32 +100,34 @@ function getSelectedHosters() { // --- File selection --- function setupDragDrop() { const dropZone = document.getElementById('dropZone'); + // Allow drop on the entire upload view + const uploadView = document.getElementById('upload-view'); - dropZone.addEventListener('dragover', (e) => { - e.preventDefault(); - e.stopPropagation(); - dropZone.classList.add('drag-over'); - }); - - dropZone.addEventListener('dragleave', (e) => { - e.preventDefault(); - dropZone.classList.remove('drag-over'); - }); - + dropZone.addEventListener('dragover', (e) => { e.preventDefault(); e.stopPropagation(); dropZone.classList.add('drag-over'); }); + dropZone.addEventListener('dragleave', (e) => { e.preventDefault(); dropZone.classList.remove('drag-over'); }); dropZone.addEventListener('drop', (e) => { - e.preventDefault(); - e.stopPropagation(); - dropZone.classList.remove('drag-over'); - const files = Array.from(e.dataTransfer.files); - for (const file of files) { - if (!selectedFiles.find(f => f.path === file.path)) { - selectedFiles.push({ path: file.path, name: file.name, size: file.size }); - } - } - renderFileList(); + e.preventDefault(); e.stopPropagation(); dropZone.classList.remove('drag-over'); + addDroppedFiles(e.dataTransfer.files); }); - 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); + }); +} + +function addDroppedFiles(fileList) { + const files = Array.from(fileList); + for (const file of files) { + if (!selectedFiles.find(f => f.path === file.path)) { + selectedFiles.push({ path: file.path, name: file.name, size: file.size }); + } + } + updateUploadView(); } async function pickFiles() { @@ -132,266 +136,316 @@ async function pickFiles() { for (const p of paths) { if (!selectedFiles.find(f => f.path === p)) { const name = p.split('\\').pop().split('/').pop(); - selectedFiles.push({ path: p, name, size: 0 }); + selectedFiles.push({ path: p, name, size: null }); // size resolved by upload-manager } } - renderFileList(); + updateUploadView(); } -function renderFileList() { - const container = document.getElementById('fileList'); - const actions = document.getElementById('uploadActions'); +function updateUploadView() { + const dropZone = document.getElementById('dropZone'); + const queueContainer = document.getElementById('queueContainer'); + const queueActions = document.getElementById('queueActions'); - if (selectedFiles.length === 0) { - container.innerHTML = ''; - actions.style.display = 'none'; - document.getElementById('dropZone').classList.remove('hidden'); - return; + if (selectedFiles.length === 0 && queueJobs.length === 0) { + dropZone.style.display = 'flex'; + queueContainer.style.display = 'none'; + queueActions.style.display = 'none'; + } else { + dropZone.style.display = 'none'; + queueContainer.style.display = 'block'; + queueActions.style.display = 'flex'; + if (!uploading && selectedFiles.length > 0) { + buildQueuePreview(); + } } - - document.getElementById('dropZone').classList.add('hidden'); - actions.style.display = 'flex'; - - container.innerHTML = selectedFiles.map((f, i) => { - const sizeStr = f.size > 0 ? formatSize(f.size) : ''; - return `
- ${escapeHtml(f.name)} - ${sizeStr} - -
`; - }).join(''); - - container.querySelectorAll('.remove-btn').forEach(btn => { - btn.addEventListener('click', () => { - selectedFiles.splice(parseInt(btn.dataset.index), 1); - renderFileList(); - }); - }); + updateStartButton(); } -function setupListeners() { - document.getElementById('pickFilesBtn').addEventListener('click', (e) => { - e.stopPropagation(); - pickFiles(); - }); +function updateStartButton() { + const btn = document.getElementById('startUploadBtn'); + const hosters = getSelectedHosters(); + const hasFiles = selectedFiles.length > 0 || queueJobs.some(j => j.status === 'queued' || j.status === 'error' || j.status === 'preview'); + btn.disabled = uploading || hosters.length === 0 || !hasFiles; +} - document.getElementById('startUploadBtn').addEventListener('click', startUpload); - document.getElementById('cancelUploadBtn').addEventListener('click', cancelUpload); - document.getElementById('clearFilesBtn').addEventListener('click', () => { - selectedFiles = []; - renderFileList(); - }); - document.getElementById('copyAllLinksBtn').addEventListener('click', copyAllLinks); - document.getElementById('newUploadBtn').addEventListener('click', resetUploadView); - document.getElementById('saveSettingsBtn').addEventListener('click', saveSettings); - document.getElementById('clearHistoryBtn').addEventListener('click', clearHistory); - document.getElementById('runHealthCheckBtn').addEventListener('click', runHealthCheck); - const autoToggle = document.getElementById('autoHealthCheckToggle'); - if (autoToggle) { - autoToggle.addEventListener('change', (e) => { - autoHealthCheckEnabled = !!e.target.checked; - saveAutoCheckPreference(autoHealthCheckEnabled); - }); - } +// Build preview jobs from selected files x selected hosters (before upload starts) +function buildQueuePreview() { + const hosters = getSelectedHosters(); + // Remove old preview jobs (status 'preview') + queueJobs = queueJobs.filter(j => j.status !== 'preview'); - // Upload progress events - window.api.onUploadProgress(handleProgress); - window.api.onUploadBatchDone(handleBatchDone); - - // Copy buttons (delegated) - document.addEventListener('click', (e) => { - if (e.target.classList.contains('copy-btn')) { - const url = e.target.dataset.url; - if (url) { - window.api.copyToClipboard(url); - e.target.textContent = 'Kopiert!'; - e.target.classList.add('copied'); - setTimeout(() => { - e.target.textContent = 'Kopieren'; - e.target.classList.remove('copied'); - }, 1500); + for (const file of selectedFiles) { + for (const hoster of hosters) { + // Don't add if already in queue (from a previous upload) + const exists = queueJobs.find(j => j.file === file.path && j.hoster === hoster && j.status !== 'error'); + if (!exists) { + queueJobs.push({ + 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: '' + }); } } + } + renderQueueTable(); +} + +// --- Queue Table Rendering (debounced) --- +let _renderQueued = false; +function scheduleQueueRender() { + if (_renderQueued) return; + _renderQueued = true; + requestAnimationFrame(() => { _renderQueued = false; renderQueueTable(); }); +} + +function renderQueueTable() { + const tbody = document.getElementById('queueBody'); + if (!tbody) return; + + // Preserve scroll position + const scrollContainer = document.getElementById('queueContainer'); + const scrollTop = scrollContainer ? scrollContainer.scrollTop : 0; + + const sorted = sortQueueJobs(queueJobs); + + tbody.innerHTML = sorted.map((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.round((job.progress || 0) * 100); + const link = job.result ? (job.result.download_url || job.result.embed_url || '') : ''; + + return ` + ${escapeHtml(job.fileName)} + ${uploadedSize} + ${escapeHtml(job.hoster)} + ${statusText} + ${elapsed} + ${remaining} + ${speed} + +
+
+
+
+ ${job.status === 'preview' ? '' : pct + '%'} +
+ + `; + }).join(''); + + // Restore scroll position + if (scrollContainer) scrollContainer.scrollTop = scrollTop; + + // Attach click handlers + tbody.querySelectorAll('.queue-row').forEach(row => { + row.addEventListener('click', (e) => handleRowClick(e, row)); + row.addEventListener('contextmenu', (e) => handleRowContextMenu(e, row)); + }); + + // Update retry button visibility + const hasFailedJobs = queueJobs.some(j => j.status === 'error'); + document.getElementById('retryFailedBtn').style.display = hasFailedJobs ? 'inline-block' : 'none'; +} + +function sortQueueJobs(jobs) { + const { key, direction } = queueSortState; + const factor = direction === 'asc' ? 1 : -1; + + return jobs.slice().sort((a, b) => { + let cmp = 0; + if (key === 'filename') cmp = a.fileName.localeCompare(b.fileName, 'de', { sensitivity: 'base', numeric: true }); + else if (key === 'size') cmp = (a.bytesTotal || 0) - (b.bytesTotal || 0); + else if (key === 'host') cmp = a.hoster.localeCompare(b.hoster); + else if (key === 'status') cmp = getStatusOrder(a.status) - getStatusOrder(b.status); + else if (key === 'speed') cmp = (a.speedKbs || 0) - (b.speedKbs || 0); + return cmp * factor; }); } -function loadAutoCheckPreference() { - try { - const raw = window.localStorage.getItem(AUTO_CHECK_PREF_KEY); - if (raw === null) return true; - return raw === '1'; - } catch { - return true; +function getStatusOrder(status) { + const order = { uploading: 0, 'getting-server': 1, retrying: 2, queued: 3, preview: 4, done: 5, error: 6, skipped: 7 }; + return order[status] ?? 4; +} + +function getStatusText(job) { + switch (job.status) { + case 'preview': return 'Ready'; + case 'queued': return 'Queued'; + case 'getting-server': return 'Server...'; + case 'uploading': return 'Process'; + case 'retrying': return `Retry ${job.attempt}/${job.maxAttempts}`; + case 'done': return 'Done'; + case 'error': return 'Failed'; + case 'skipped': return 'Skipped'; + default: return job.status; } } -function saveAutoCheckPreference(enabled) { - try { - window.localStorage.setItem(AUTO_CHECK_PREF_KEY, enabled ? '1' : '0'); - } catch {} -} +// --- Queue interactions --- +function handleRowClick(e, row) { + const jobId = row.dataset.jobId; -function syncAutoCheckToggle() { - const autoToggle = document.getElementById('autoHealthCheckToggle'); - if (!autoToggle) return; - autoToggle.checked = !!autoHealthCheckEnabled; -} - -function setHealthCheckButtonBusy(isBusy, label) { - const btn = document.getElementById('runHealthCheckBtn'); - if (!btn) return; - btn.disabled = !!isBusy; - btn.textContent = isBusy ? (label || 'Pruefe...') : 'Hoster Check'; -} - -function getHealthCheckHosters() { - const selected = getSelectedHosters().filter(name => name === 'doodstream.com' || name === 'vidmoly.me'); - if (selected.length > 0) return selected; - - return ['doodstream.com', 'vidmoly.me'] - .filter((name) => hosterHasCredentials(name, config.hosters[name] || {})); -} - -function normalizeHealthStatus(status) { - if (status === 'ok' || status === 'warn' || status === 'error' || status === 'skipped') { - return status; + if (e.ctrlKey || e.metaKey) { + if (selectedJobIds.has(jobId)) selectedJobIds.delete(jobId); + else selectedJobIds.add(jobId); + } else if (e.shiftKey && selectedJobIds.size > 0) { + const allRows = Array.from(document.querySelectorAll('.queue-row')); + const lastIdx = allRows.findIndex(r => selectedJobIds.has(r.dataset.jobId)); + const curIdx = allRows.indexOf(row); + const from = Math.min(lastIdx, curIdx); + const to = Math.max(lastIdx, curIdx); + for (let i = from; i <= to; i++) selectedJobIds.add(allRows[i].dataset.jobId); + } else { + selectedJobIds.clear(); + selectedJobIds.add(jobId); + // Single click on done job -> copy link + const job = queueJobs.find(j => j.id === jobId); + if (job && job.status === 'done' && job.result) { + const link = job.result.download_url || job.result.embed_url || ''; + if (link) { + window.api.copyToClipboard(link); + showCopyToast('Link kopiert'); + } + } } - return 'skipped'; + renderQueueTable(); } -function healthStatusLabel(status) { - if (status === 'ok') return 'OK'; - if (status === 'warn') return 'WARN'; - if (status === 'error') return 'ERR'; - return 'SKIP'; -} +// --- Context menu --- +let alwaysOnTopState = false; -function setHealthCheckStatus(text) { - const statusEl = document.getElementById('healthCheckStatus'); - if (!statusEl) return; - statusEl.textContent = text || ''; -} - -function renderHealthCheckResults(results) { - const container = document.getElementById('healthCheckResults'); - if (!container) return; - - if (!results || results.length === 0) { - container.innerHTML = ''; - return; +function handleRowContextMenu(e, row) { + e.preventDefault(); + const jobId = row.dataset.jobId; + if (!selectedJobIds.has(jobId)) { + selectedJobIds.clear(); + selectedJobIds.add(jobId); + renderQueueTable(); } - - container.innerHTML = results.map((item) => { - const status = normalizeHealthStatus(item.status); - const hoster = escapeHtml(item.hoster || 'unbekannt'); - const message = escapeHtml(item.message || ''); - const tag = healthStatusLabel(status); - return `
- ${hoster} - [${tag}] - ${message} -
`; - }).join(''); + showContextMenu(e.clientX, e.clientY); } -async function executeHealthCheck(hosters, mode) { - const label = mode === 'auto' ? 'Auto-Check' : 'Check'; - setHealthCheckStatus(`Pruefe ${hosters.join(', ')} ...`); - renderHealthCheckResults([]); +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'; - const result = await window.api.runHealthCheck({ hosters }); - const rows = result && Array.isArray(result.results) ? result.results : []; - renderHealthCheckResults(rows); - - const okCount = rows.filter((r) => r.status === 'ok').length; - const warnCount = rows.filter((r) => r.status === 'warn').length; - const errCount = rows.filter((r) => r.status === 'error').length; - setHealthCheckStatus(`${label} fertig: ${okCount} OK, ${warnCount} Warnung, ${errCount} Fehler`); - - return rows; + menu.style.display = 'block'; + menu.style.left = Math.min(x, window.innerWidth - menu.offsetWidth - 5) + 'px'; + menu.style.top = Math.min(y, window.innerHeight - menu.offsetHeight - 5) + 'px'; } -async function runHealthCheck() { - if (healthCheckRunning || uploading) return; +function hideContextMenu() { + document.getElementById('contextMenu').style.display = 'none'; +} - const hosters = getHealthCheckHosters(); - if (hosters.length === 0) { - alert('Bitte doodstream.com und/oder vidmoly.me mit Zugangsdaten aktivieren.'); - return; +document.addEventListener('click', (e) => { + if (!e.target.closest('.context-menu')) hideContextMenu(); +}); +document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') hideContextMenu(); +}); + +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 === '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') { + queueJobs = queueJobs.filter(j => !selectedJobIds.has(j.id)); + selectedJobIds.clear(); + renderQueueTable(); + if (queueJobs.length === 0) { selectedFiles = []; updateUploadView(); } + } else if (action === 'copy-all-links') { + copyAllLinks(); + } else if (action === 'delete-all') { + if (!uploading) { queueJobs = []; selectedFiles = []; selectedJobIds.clear(); updateUploadView(); } + } else if (action === 'always-on-top') { + alwaysOnTopState = !alwaysOnTopState; + await window.api.setAlwaysOnTop(alwaysOnTopState); + } else if (action.startsWith('shutdown-')) { + const mode = action.replace('shutdown-', ''); + await window.api.setShutdownAfterFinish(mode); } +} - healthCheckRunning = true; - setHealthCheckButtonBusy(true, 'Pruefe...'); - - try { - await executeHealthCheck(hosters, 'manual'); - } catch (err) { - setHealthCheckStatus('Health-Check fehlgeschlagen'); - renderHealthCheckResults([{ hoster: 'system', status: 'error', message: err.message || 'Unbekannter Fehler' }]); - } finally { - healthCheckRunning = false; - setHealthCheckButtonBusy(false); - } +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 (healthCheckRunning) { - alert('Bitte warten, bis der laufende Hoster-Check fertig ist.'); - return; - } + if (healthCheckRunning || uploading) return; const hosters = getSelectedHosters(); - if (hosters.length === 0) { - alert('Bitte mindestens einen Hoster auswaehlen.'); - return; - } - if (selectedFiles.length === 0) return; + if (hosters.length === 0) { alert('Bitte mindestens einen Hoster auswaehlen.'); return; } + // Include files from preview/queued jobs that may not be in selectedFiles (e.g. retries) + const previewFiles = queueJobs + .filter(j => j.status === 'preview' || j.status === 'queued') + .map(j => j.file) + .filter(Boolean); + for (const fp of previewFiles) { + if (!selectedFiles.find(f => f.path === fp)) { + const job = queueJobs.find(j => j.file === fp); + selectedFiles.push({ path: fp, name: job ? job.fileName : fp.split(/[\\/]/).pop(), size: job ? job.bytesTotal : null }); + } + } + + if (selectedFiles.length === 0 && previewFiles.length === 0) return; + + // Auto health check if (autoHealthCheckEnabled) { - const checkHosters = hosters.filter((name) => name === 'doodstream.com' || name === 'vidmoly.me'); + const checkHosters = hosters.filter(name => name === 'doodstream.com' || name === 'vidmoly.me'); if (checkHosters.length > 0) { healthCheckRunning = true; - setHealthCheckButtonBusy(true, 'Auto-Check...'); - try { const rows = await executeHealthCheck(checkHosters, 'auto'); - const errors = rows.filter((r) => r.status === 'error'); + const errors = rows.filter(r => r.status === 'error'); if (errors.length > 0) { - const details = errors - .map((r) => `${r.hoster || 'hoster'}: ${r.message || 'Fehler'}`) - .join('\n'); - alert(`Auto-Check fehlgeschlagen:\n${details}\n\nUpload wurde nicht gestartet.`); + alert(`Auto-Check fehlgeschlagen:\n${errors.map(r => `${r.hoster}: ${r.message}`).join('\n')}\n\nUpload wurde nicht gestartet.`); return; } } catch (err) { - const msg = err && err.message ? err.message : 'Unbekannter Fehler'; - setHealthCheckStatus('Auto-Check fehlgeschlagen'); - renderHealthCheckResults([{ hoster: 'system', status: 'error', message: msg }]); - alert(`Auto-Check fehlgeschlagen: ${msg}\nUpload wurde nicht gestartet.`); + alert(`Auto-Check fehlgeschlagen: ${err.message}\nUpload wurde nicht gestartet.`); return; } finally { healthCheckRunning = false; - setHealthCheckButtonBusy(false); } } } uploading = true; - document.getElementById('uploadActions').style.display = 'none'; - document.getElementById('cancelActions').style.display = 'flex'; - document.getElementById('resultsSection').style.display = 'block'; - const resultsTitle = document.getElementById('resultsTitle'); - if (resultsTitle) resultsTitle.textContent = 'Ergebnisse (live)'; + // Convert preview jobs to queued + queueJobs.forEach(j => { if (j.status === 'preview') j.status = 'queued'; }); + renderQueueTable(); - resetLiveResultsState(); - renderResultsTable(); - - const newUploadBtn = document.getElementById('newUploadBtn'); - if (newUploadBtn) newUploadBtn.disabled = true; - - buildProgressUI(selectedFiles, hosters); - document.getElementById('progressSection').style.display = 'flex'; + document.getElementById('startUploadBtn').style.display = 'none'; + document.getElementById('cancelUploadBtn').style.display = 'inline-block'; const result = await window.api.startUpload({ files: selectedFiles.map(f => f.path), @@ -400,456 +454,273 @@ async function startUpload() { if (result && result.error) { alert(result.error); - resetUploadView(); + uploading = false; + document.getElementById('startUploadBtn').style.display = 'inline-block'; + document.getElementById('cancelUploadBtn').style.display = 'none'; } } async function cancelUpload() { await window.api.cancelUpload(); uploading = false; - document.getElementById('cancelActions').style.display = 'none'; -} - -function resetUploadView() { - uploading = false; - selectedFiles = []; - progressElements.clear(); - resetLiveResultsState(); - document.getElementById('fileList').innerHTML = ''; - document.getElementById('progressSection').style.display = 'none'; - document.getElementById('progressSection').innerHTML = ''; - document.getElementById('resultsSection').style.display = 'none'; - document.getElementById('cancelActions').style.display = 'none'; - document.getElementById('uploadActions').style.display = 'none'; - document.getElementById('dropZone').classList.remove('hidden'); - const newUploadBtn = document.getElementById('newUploadBtn'); - if (newUploadBtn) newUploadBtn.disabled = false; - const resultsTitle = document.getElementById('resultsTitle'); - if (resultsTitle) resultsTitle.textContent = 'Ergebnisse'; -} - -// --- Progress UI --- -function buildProgressUI(files, hosters) { - const section = document.getElementById('progressSection'); - section.innerHTML = ''; - progressElements.clear(); - - for (const file of files) { - const card = document.createElement('div'); - card.className = 'progress-card'; - - let html = `
${escapeHtml(file.name)}
`; - for (const hoster of hosters) { - const uid = `${file.path}__${hoster}`; - html += ` -
- ${hoster} -
- 0% - Warte... -
`; - } - card.innerHTML = html; - section.appendChild(card); - } + document.getElementById('startUploadBtn').style.display = 'inline-block'; + document.getElementById('cancelUploadBtn').style.display = 'none'; + updateStartButton(); } +// --- Progress handling --- function handleProgress(data) { - // Find matching progress row - const rows = document.querySelectorAll('.progress-row'); - for (const row of rows) { - const hoster = row.querySelector('.progress-hoster').textContent; - const fileName = row.closest('.progress-card').querySelector('.file-title').textContent; - if (hoster === data.hoster && fileName === data.fileName) { - const fill = row.querySelector('.progress-fill'); - const pct = row.querySelector('.progress-percent'); - const stat = row.querySelector('.progress-status'); - - if (data.status === 'getting-server') { - stat.textContent = 'Server...'; - stat.className = 'progress-status'; - } else if (data.status === 'uploading') { - const percent = Math.round(data.progress * 100); - fill.style.width = `${percent}%`; - pct.textContent = `${percent}%`; - stat.textContent = 'Uploading...'; - stat.className = 'progress-status'; - } else if (data.status === 'done') { - fill.style.width = '100%'; - fill.classList.add('done'); - pct.textContent = '100%'; - stat.textContent = 'Fertig'; - stat.className = 'progress-status done'; - } else if (data.status === 'error') { - fill.classList.add('error'); - fill.style.width = '100%'; - pct.textContent = ''; - stat.textContent = data.error || 'Fehler'; - stat.className = 'progress-status error'; - stat.title = data.error || 'Fehler'; - } - break; - } + // Find matching job by fileName + hoster, or by uploadId + let job = data.uploadId ? queueJobs.find(j => j.uploadId === data.uploadId) : null; + if (!job) { + // Match by file+hoster for queued/preview jobs (prefer queued, then preview) + 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; + } + if (!job) { + // Create new job entry + job = { + id: 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); } - if (data && (data.status === 'done' || data.status === 'error')) { - upsertLiveResultRow(data); - } + // Update job state + job.status = data.status; + job.bytesUploaded = data.bytesUploaded || 0; + job.bytesTotal = data.bytesTotal || job.bytesTotal; + 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; + + scheduleQueueRender(); } function handleBatchDone(summary) { uploading = false; - document.getElementById('cancelActions').style.display = 'none'; - mergeSummaryIntoResults(summary); - renderResultsTable(); - const resultsTitle = document.getElementById('resultsTitle'); - if (resultsTitle) resultsTitle.textContent = 'Ergebnisse'; - const newUploadBtn = document.getElementById('newUploadBtn'); - if (newUploadBtn) newUploadBtn.disabled = false; - document.getElementById('resultsSection').style.display = 'block'; + selectedFiles = []; // Clear selected files after batch + document.getElementById('startUploadBtn').style.display = 'inline-block'; + document.getElementById('cancelUploadBtn').style.display = 'none'; + updateStartButton(); + renderQueueTable(); + + // Final stats update + document.getElementById('sbState').textContent = 'Fertig'; } -// --- Results UI (table like z-o-o-m) --- -let selectedRows = new Set(); -let resultsRowsData = []; -let resultsSortState = { key: 'date', direction: getDefaultSortDirection('date') }; -let resultsOrderCounter = 0; -let resultRowIndexByUploadId = new Map(); -let historyRowsData = []; -let historySortState = { key: 'date', direction: getDefaultSortDirection('date') }; - -function resetLiveResultsState() { - selectedRows.clear(); - resultsRowsData = []; - resultsSortState = { key: 'date', direction: getDefaultSortDirection('date') }; - resultsOrderCounter = 0; - resultRowIndexByUploadId = new Map(); +function handleStats(data) { + document.getElementById('sbState').textContent = data.state === 'uploading' ? 'Upload laeuft...' : 'Bereit'; + document.getElementById('sbSpeed').textContent = formatSpeed(data.globalSpeedKbs || 0); + document.getElementById('sbTotal').textContent = formatSize(data.totalBytes || 0); + document.getElementById('sbElapsed').textContent = formatTime(data.elapsed || 0); } -function createResultRow({ dateTs, dateText, filename, host, link, isError, uploadId }) { - return { - date: dateText, - dateTs, - filename: filename || '', - host: host || '', - link: link || '', - isError: !!isError, - order: resultsOrderCounter++, - uploadId: uploadId || null - }; -} - -function upsertLiveResultRow(data) { - const { ts, text } = formatDateTime(new Date()); - const result = data && data.result && typeof data.result === 'object' ? data.result : {}; - - const rowData = createResultRow({ - dateTs: ts, - dateText: text, - filename: data.fileName || '', - host: data.hoster || '', - link: data.status === 'error' - ? `[Fehler] ${data.error || 'Fehler'}` - : (result.download_url || result.embed_url || ''), - isError: data.status === 'error', - uploadId: data.uploadId - }); - - const existingIndex = resultRowIndexByUploadId.get(data.uploadId); - if (typeof existingIndex === 'number' && resultsRowsData[existingIndex]) { - const existingOrder = resultsRowsData[existingIndex].order; - resultsRowsData[existingIndex] = { ...rowData, order: existingOrder }; - } else { - const insertedIndex = resultsRowsData.push(rowData) - 1; - if (data.uploadId) resultRowIndexByUploadId.set(data.uploadId, insertedIndex); - } - - renderResultsTable(); -} - -function mergeSummaryIntoResults(summary) { - if (!summary || !Array.isArray(summary.files)) return; - - const { ts, text } = formatDateTime(summary.timestamp || new Date()); - - for (const file of summary.files) { - for (const r of (file.results || [])) { - const link = r.status === 'error' - ? `[Fehler] ${r.error || 'Fehler'}` - : (r.download_url || r.embed_url || ''); - const isError = r.status === 'error'; - - const existingIndex = resultsRowsData.findIndex((row) => - row.filename === (file.name || '') && - row.host === (r.hoster || '') && - row.link === link && - row.isError === isError - ); - - if (existingIndex === -1) { - resultsRowsData.push(createResultRow({ - dateTs: ts, - dateText: text, - filename: file.name || '', - host: r.hoster || '', - link, - isError - })); +// --- Retry --- +function retrySelectedJobs() { + // For now just mark failed jobs back to preview so user can restart + queueJobs.forEach(j => { + if (selectedJobIds.has(j.id) && j.status === 'error') { + j.status = 'preview'; + j.error = null; + j.bytesUploaded = 0; + j.speedKbs = 0; + j.elapsed = 0; + j.remaining = 0; + j.progress = 0; + j.uploadId = null; + // Re-add to selectedFiles if not present + if (!selectedFiles.find(f => f.path === j.file || f.name === j.fileName)) { + selectedFiles.push({ path: j.file, name: j.fileName, size: j.bytesTotal }); } } - } -} - -function getResultsSortIndicator(columnKey) { - if (resultsSortState.key !== columnKey) return '↕'; - return resultsSortState.direction === 'asc' ? '▲' : '▼'; -} - -function sortResultsRows(rows) { - const sortKey = resultsSortState.key; - const factor = resultsSortState.direction === 'asc' ? 1 : -1; - - return rows.slice().sort((a, b) => { - let cmp = 0; - - if (sortKey === 'date') { - cmp = a.dateTs - b.dateTs; - } else { - const aVal = String(a[sortKey] || ''); - const bVal = String(b[sortKey] || ''); - cmp = aVal.localeCompare(bVal, 'de', { sensitivity: 'base', numeric: true }); - } - - if (cmp !== 0) return cmp * factor; - return a.order - b.order; }); + selectedJobIds.clear(); + renderQueueTable(); + updateStartButton(); } -function renderResultsTable() { - const container = document.getElementById('resultsContainer'); +// --- Health Check --- +function setHealthCheckStatus(text) { + // Minimal inline status +} + +function renderHealthCheckResults(results) { + const container = document.getElementById('healthCheckResults'); if (!container) return; + if (!results || results.length === 0) { container.innerHTML = ''; return; } - if (!resultsRowsData.length) { - container.innerHTML = '

Warte auf erste Upload-Ergebnisse...

'; - return; - } - - const sortedRows = sortResultsRows(resultsRowsData); - - const headerCell = (key, label) => { - const active = resultsSortState.key === key; - const indicator = getResultsSortIndicator(key); - return `${label}${indicator}`; - }; - - let html = ` - - - ${headerCell('date', 'Date')} - ${headerCell('filename', 'Filename')} - ${headerCell('host', 'Host')} - ${headerCell('link', 'Link')} - - - `; - - sortedRows.forEach((row, index) => { - html += ` - - - - - `; - }); - - html += '
${escapeHtml(row.date)}${escapeHtml(row.filename)}${escapeHtml(row.host)}
'; - container.innerHTML = html; - - container.querySelectorAll('th.sortable').forEach((th) => { - th.addEventListener('click', () => { - const key = th.dataset.sortKey; - if (!key) return; - - if (resultsSortState.key === key) { - resultsSortState.direction = resultsSortState.direction === 'asc' ? 'desc' : 'asc'; - } else { - resultsSortState.key = key; - resultsSortState.direction = getDefaultSortDirection(key); - } - - selectedRows.clear(); - renderResultsTable(); - }); - }); - - // Click handler: select row + copy link - container.querySelectorAll('.result-row').forEach(tr => { - tr.addEventListener('click', (e) => { - const idx = tr.dataset.index; - const link = tr.dataset.link; - const isError = tr.classList.contains('error'); - - if (e.ctrlKey || e.metaKey) { - // Ctrl+Click: toggle selection - if (selectedRows.has(idx)) { - selectedRows.delete(idx); - tr.classList.remove('selected'); - } else { - selectedRows.add(idx); - tr.classList.add('selected'); - } - // Copy all selected links - const links = []; - container.querySelectorAll('.result-row.selected').forEach(r => { - if (!r.classList.contains('error')) links.push(r.dataset.link); - }); - if (links.length > 0) { - window.api.copyToClipboard(links.join('\n')); - showCopyToast(`${links.length} Links kopiert`); - } - } else if (e.shiftKey && selectedRows.size > 0) { - // Shift+Click: range select - const allRows = Array.from(container.querySelectorAll('.result-row')); - const lastSelected = Math.max(...Array.from(selectedRows).map(Number)); - const current = parseInt(idx); - const from = Math.min(lastSelected, current); - const to = Math.max(lastSelected, current); - for (let i = from; i <= to; i++) { - selectedRows.add(String(i)); - allRows[i].classList.add('selected'); - } - const links = []; - container.querySelectorAll('.result-row.selected').forEach(r => { - if (!r.classList.contains('error')) links.push(r.dataset.link); - }); - if (links.length > 0) { - window.api.copyToClipboard(links.join('\n')); - showCopyToast(`${links.length} Links kopiert`); - } - } else { - // Normal click: select only this row, copy its link - container.querySelectorAll('.result-row').forEach(r => r.classList.remove('selected')); - selectedRows.clear(); - selectedRows.add(idx); - tr.classList.add('selected'); - if (!isError && link) { - window.api.copyToClipboard(link); - showCopyToast('Link kopiert'); - } - } - }); - }); + container.innerHTML = results.map(item => { + const status = item.status || 'skipped'; + return `
+ ${escapeHtml(item.hoster || '')} + [${status.toUpperCase()}] + ${escapeHtml(item.message || '')} +
`; + }).join(''); } -function buildResultsUI(summary) { - resetLiveResultsState(); - mergeSummaryIntoResults(summary); - renderResultsTable(); +async function executeHealthCheck(hosters, mode) { + renderHealthCheckResults([]); + const result = await window.api.runHealthCheck({ hosters }); + const rows = result && Array.isArray(result.results) ? result.results : []; + renderHealthCheckResults(rows); + return rows; } -function showCopyToast(msg) { - let toast = document.getElementById('copyToast'); - if (!toast) { - toast = document.createElement('div'); - toast.id = 'copyToast'; - toast.className = 'copy-toast'; - document.body.appendChild(toast); - } - toast.textContent = msg; - toast.classList.add('show'); - clearTimeout(toast._timer); - toast._timer = setTimeout(() => toast.classList.remove('show'), 1500); -} - -function copyAllLinks() { - const links = []; - document.querySelectorAll('#resultsContainer .result-row:not(.error)').forEach(r => { - links.push(r.dataset.link); - }); - if (links.length > 0) { - window.api.copyToClipboard(links.join('\n')); - const btn = document.getElementById('copyAllLinksBtn'); - btn.textContent = 'Kopiert!'; - setTimeout(() => { btn.textContent = 'Alle Links kopieren'; }, 1500); +async function runHealthCheck() { + if (healthCheckRunning || uploading) return; + const hosters = getSelectedHosters().filter(n => n === 'doodstream.com' || n === 'vidmoly.me'); + if (hosters.length === 0) { + const allHosters = ['doodstream.com', 'vidmoly.me'].filter(n => hosterHasCredentials(n, config.hosters[n] || {})); + if (allHosters.length === 0) { alert('Keine Hoster mit Zugangsdaten fuer Health-Check.'); return; } + hosters.push(...allHosters); } + healthCheckRunning = true; + try { await executeHealthCheck(hosters, 'manual'); } + catch (err) { renderHealthCheckResults([{ hoster: 'system', status: 'error', message: err.message }]); } + finally { healthCheckRunning = false; } } // --- Settings --- function renderSettings() { - const grid = document.getElementById('settingsGrid'); - grid.innerHTML = ''; + const container = document.getElementById('settingsHosters'); + container.innerHTML = ''; for (const name of HOSTERS) { const hoster = config.hosters[name] || {}; + const hs = hosterSettings[name] || {}; + const panel = document.createElement('div'); + panel.className = 'hoster-settings-panel'; + + let credsHtml = ''; if (name === 'vidmoly.me') { - // Vidmoly uses username/password - const block = document.createElement('div'); - block.className = 'settings-block'; - block.innerHTML = ` + credsHtml = `
- ${name} +
- + - -
- `; - block.querySelector('.toggle-vis').addEventListener('click', () => { - const pwInput = block.querySelector('[data-field="password"]'); - pwInput.type = pwInput.type === 'password' ? 'text' : 'password'; - }); - grid.appendChild(block); + + `; } else { - // API key hosters - const row = document.createElement('div'); - row.className = 'settings-row'; - row.innerHTML = ` - ${name} - - - `; - row.querySelector('.toggle-vis').addEventListener('click', () => { - const input = row.querySelector('.key-input'); + credsHtml = ` +
+ + + +
`; + } + + panel.innerHTML = ` +
+ + ${name} + ${hosterHasCredentials(name, hoster) ? 'Aktiv' : 'Inaktiv'} +
+ + `; + + 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 ? '▶' : '▼'; + }); + + // Toggle visibility + panel.querySelectorAll('.toggle-vis').forEach(btn => { + btn.addEventListener('click', () => { + const input = btn.previousElementSibling; input.type = input.type === 'password' ? 'text' : 'password'; }); - grid.appendChild(row); - } + }); } } async function saveSettings() { const hosters = {}; + const newHosterSettings = {}; for (const name of HOSTERS) { + // Credentials if (name === 'vidmoly.me') { - const usernameInput = document.querySelector(`.key-input[data-hoster="${name}"][data-field="username"]`); - const passwordInput = document.querySelector(`.key-input[data-hoster="${name}"][data-field="password"]`); - const username = usernameInput ? usernameInput.value.trim() : ''; - const password = passwordInput ? passwordInput.value.trim() : ''; - hosters[name] = { - enabled: !!(username && password), - authType: 'login', - username, - password - }; + const username = (document.querySelector(`.key-input[data-hoster="${name}"][data-field="username"]`)?.value || '').trim(); + const password = (document.querySelector(`.key-input[data-hoster="${name}"][data-field="password"]`)?.value || '').trim(); + hosters[name] = { enabled: !!(username && password), authType: 'login', username, password }; } else { - const input = document.querySelector(`.key-input[data-hoster="${name}"]`); - const apiKey = input ? input.value.trim() : ''; - hosters[name] = { - enabled: !!apiKey, - apiKey - }; + const apiKey = (document.querySelector(`.key-input[data-hoster="${name}"][data-field="apiKey"]`)?.value || '').trim(); + hosters[name] = { enabled: !!apiKey, apiKey }; } + + // Upload settings + const hs = {}; + document.querySelectorAll(`.hs-input[data-hoster="${name}"]`).forEach(input => { + const field = input.dataset.hs; + hs[field] = parseInt(input.value) || 0; + }); + newHosterSettings[name] = hs; } await window.api.saveConfig({ hosters }); + await window.api.saveHosterSettings(newHosterSettings); config = await window.api.getConfig(); + hosterSettings = config.hosterSettings || {}; renderHosterChips(); renderHealthCheckResults([]); - setHealthCheckStatus('Bereit fuer Check'); const feedback = document.getElementById('saveFeedback'); feedback.textContent = 'Gespeichert!'; @@ -867,25 +738,19 @@ async function loadHistory() { return; } - historySortState = { key: 'date', direction: getDefaultSortDirection('date') }; + historySortState = { key: 'date', direction: 'desc' }; historyRowsData = []; - let order = 0; - for (const batch of history) { - const formattedDate = formatDateTime(batch && batch.timestamp ? batch.timestamp : new Date()); + for (const batch of history) { + const dt = formatDateTime(batch.timestamp || new Date()); for (const file of (batch.files || [])) { for (const result of (file.results || [])) { historyRowsData.push({ - date: formattedDate.text, - dateTs: formattedDate.ts, - filename: file.name || '', - host: result.hoster || '', - link: result.status === 'error' - ? `[Fehler] ${result.error || 'Fehler'}` - : (result.download_url || result.embed_url || ''), - isError: result.status === 'error', - order: order++ + date: dt.text, dateTs: dt.ts, + filename: file.name || '', host: result.hoster || '', + link: result.status === 'error' ? `[Fehler] ${result.error || ''}` : (result.download_url || result.embed_url || ''), + isError: result.status === 'error', order: order++ }); } } @@ -894,59 +759,24 @@ async function loadHistory() { renderHistoryTable(container); } -function getHistorySortIndicator(columnKey) { - if (historySortState.key !== columnKey) return '↕'; - return historySortState.direction === 'asc' ? '▲' : '▼'; -} - -function sortHistoryRows(rows) { - const sortKey = historySortState.key; - const factor = historySortState.direction === 'asc' ? 1 : -1; - - return rows.slice().sort((a, b) => { - let cmp = 0; - - if (sortKey === 'date') { - cmp = a.dateTs - b.dateTs; - } else { - const aVal = String(a[sortKey] || ''); - const bVal = String(b[sortKey] || ''); - cmp = aVal.localeCompare(bVal, 'de', { sensitivity: 'base', numeric: true }); - } - - if (cmp !== 0) return cmp * factor; - return a.order - b.order; - }); -} - function renderHistoryTable(container) { - if (!container) return; - - if (!historyRowsData.length) { - container.innerHTML = '

Noch keine Uploads.

'; + if (!container || !historyRowsData.length) { + if (container) container.innerHTML = '

Noch keine Uploads.

'; return; } const rows = sortHistoryRows(historyRowsData); - const headerCell = (key, label) => { const active = historySortState.key === key; - const indicator = getHistorySortIndicator(key); - return `${label}${indicator}`; + const dir = active ? (historySortState.direction === 'asc' ? '▲' : '▼') : '↕'; + return `${label}${dir}`; }; - let html = ` - - - ${headerCell('date', 'Date')} - ${headerCell('filename', 'Filename')} - ${headerCell('host', 'Host')} - ${headerCell('link', 'Link')} - - - `; + let html = `
+ ${headerCell('date', 'Date')}${headerCell('filename', 'Filename')}${headerCell('host', 'Host')}${headerCell('link', 'Link')} + `; - rows.forEach((row) => { + rows.forEach(row => { html += ` @@ -958,59 +788,87 @@ function renderHistoryTable(container) { html += '
${escapeHtml(row.date)} ${escapeHtml(row.filename)}
'; container.innerHTML = html; - container.querySelectorAll('th.sortable').forEach((th) => { + container.querySelectorAll('th.sortable').forEach(th => { th.addEventListener('click', () => { - const key = th.dataset.historySortKey; - if (!key) return; - - if (historySortState.key === key) { - historySortState.direction = historySortState.direction === 'asc' ? 'desc' : 'asc'; - } else { - historySortState.key = key; - historySortState.direction = getDefaultSortDirection(key); - } - + 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) => { + container.querySelectorAll('.history-row').forEach(row => { row.addEventListener('click', () => { if (row.classList.contains('error')) return; - const link = row.dataset.link; - if (!link) return; - - container.querySelectorAll('.history-row').forEach((r) => r.classList.remove('selected')); - row.classList.add('selected'); - window.api.copyToClipboard(link); - showCopyToast('Link kopiert'); + if (link) { window.api.copyToClipboard(link); showCopyToast('Link kopiert'); } }); }); } -async function clearHistory() { - if (!confirm('Verlauf wirklich loeschen?')) return; - await window.api.clearHistory(); - loadHistory(); +function sortHistoryRows(rows) { + const { key, direction } = historySortState; + const factor = direction === 'asc' ? 1 : -1; + return rows.slice().sort((a, b) => { + let cmp = key === 'date' ? a.dateTs - b.dateTs : String(a[key] || '').localeCompare(String(b[key] || ''), 'de', { sensitivity: 'base', numeric: true }); + return (cmp || a.order - b.order) * factor; + }); } -// --- Utilities --- -function formatSize(bytes) { - 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'; -} +// --- Setup Listeners --- +function setupListeners() { + document.getElementById('addFilesBtn').addEventListener('click', pickFiles); + document.getElementById('startUploadBtn').addEventListener('click', startUpload); + document.getElementById('cancelUploadBtn').addEventListener('click', cancelUpload); + document.getElementById('runHealthCheckBtn').addEventListener('click', runHealthCheck); + document.getElementById('copyAllLinksBtn').addEventListener('click', copyAllLinks); + document.getElementById('retryFailedBtn').addEventListener('click', () => { + queueJobs.forEach(j => { if (j.status === 'error') selectedJobIds.add(j.id); }); + retrySelectedJobs(); + }); + document.getElementById('clearQueueBtn').addEventListener('click', () => { + if (!uploading) { queueJobs = []; selectedFiles = []; selectedJobIds.clear(); updateUploadView(); } + }); + document.getElementById('saveSettingsBtn').addEventListener('click', saveSettings); + document.getElementById('clearHistoryBtn').addEventListener('click', async () => { + if (!confirm('Verlauf wirklich loeschen?')) return; + await window.api.clearHistory(); + loadHistory(); + }); -function escapeHtml(str) { - if (!str) return ''; - return str.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); -} + // 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 {} + }); + } -function escapeAttr(str) { - if (!str) return ''; - return str.replace(/&/g, '&').replace(/"/g, '"').replace(/'/g, '''); + // Queue table sorting + document.querySelectorAll('#queueTable th.sortable').forEach(th => { + th.addEventListener('click', () => { + const key = th.dataset.sort; + if (queueSortState.key === key) queueSortState.direction = queueSortState.direction === 'asc' ? 'desc' : 'asc'; + else { queueSortState.key = key; queueSortState.direction = 'asc'; } + renderQueueTable(); + }); + }); + + // Shutdown cancel + document.getElementById('cancelShutdownBtn').addEventListener('click', async () => { + await window.api.cancelShutdown(); + if (shutdownCountdownInterval) { clearInterval(shutdownCountdownInterval); shutdownCountdownInterval = null; } + document.getElementById('shutdownOverlay').style.display = 'none'; + }); + + // Right-click on upload view background + document.getElementById('upload-view').addEventListener('contextmenu', (e) => { + if (e.target.closest('.queue-row')) return; // handled per row + e.preventDefault(); + showContextMenu(e.clientX, e.clientY); + }); } // --- Update UI --- @@ -1020,35 +878,120 @@ function showUpdateBanner(info) { if (!banner || !msg) return; msg.textContent = `Update v${info.remoteVersion} verfuegbar`; banner.style.display = 'flex'; - document.getElementById('installUpdateBtn').onclick = async () => { msg.textContent = 'Update wird heruntergeladen...'; document.getElementById('installUpdateBtn').disabled = true; await window.api.installUpdate(); }; - document.getElementById('dismissUpdateBtn').onclick = () => { - banner.style.display = 'none'; - }; + 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') { + 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); +} + +// --- 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; } +} + +function escapeHtml(str) { + if (!str) return ''; + return String(str).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} + +function escapeAttr(str) { + if (!str) return ''; + return String(str).replace(/&/g, '&').replace(/"/g, '"').replace(/'/g, '''); +} + +function showCopyToast(msg) { + const toast = document.getElementById('copyToast'); + toast.textContent = msg; + toast.classList.add('show'); + clearTimeout(toast._timer); + toast._timer = setTimeout(() => toast.classList.remove('show'), 1500); +} + // --- Start --- init(); diff --git a/renderer/index.html b/renderer/index.html index ca2c56a..80f06e2 100644 --- a/renderer/index.html +++ b/renderer/index.html @@ -3,7 +3,7 @@ - Multi Hoster Uploader + Multi-Hoster-Upload @@ -22,58 +22,68 @@
-
- -
-
- - - + +
+
+
+
+
+
+ + +
+ + +
-
+ +
+ +
📁

Dateien hierher ziehen oder klicken

-
-
- -
+ + + + +
+ Bereit + | + 0 kB/s + | + 0 B + | + 00:00:00 +
+ + +
+ + + + diff --git a/renderer/styles.css b/renderer/styles.css index 754d70b..0fd09a5 100644 --- a/renderer/styles.css +++ b/renderer/styles.css @@ -18,11 +18,7 @@ --link-color: #00cec9; } -* { - margin: 0; - padding: 0; - box-sizing: border-box; -} +* { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; @@ -30,764 +26,39 @@ body { min-height: 100vh; color: var(--text); user-select: none; + display: flex; + flex-direction: column; } /* Tab Bar */ .tab-bar { display: flex; gap: 2px; - padding: 12px 20px 0; + padding: 8px 16px 0; border-bottom: 1px solid var(--border); background: rgba(0, 0, 0, 0.2); + flex-shrink: 0; } .tab { - padding: 10px 24px; + padding: 8px 20px; background: transparent; border: none; color: var(--text-muted); - font-size: 14px; + font-size: 13px; cursor: pointer; border-bottom: 2px solid transparent; transition: all 0.2s; } +.tab:hover { color: var(--text); } +.tab.active { color: var(--text); border-bottom-color: var(--accent); } -.tab:hover { - color: var(--text); -} - -.tab.active { - color: var(--accent); - border-bottom-color: var(--accent); -} - -/* Views */ -.view { - display: none; - padding: 24px 28px; - max-width: 960px; - margin: 0 auto; -} - -.view.active { - display: block; -} - -/* Hoster Select */ -.hoster-select { - display: flex; - gap: 12px; - flex-wrap: wrap; - margin-bottom: 20px; -} - -.hoster-chip { - display: flex; - align-items: center; - gap: 8px; - padding: 8px 16px; - background: var(--bg-card); - border: 1px solid var(--border); - border-radius: 10px; - cursor: pointer; - transition: all 0.2s; - font-size: 13px; -} - -.hoster-chip:hover { - border-color: var(--border-hover); - background: var(--bg-card-hover); -} - -.hoster-chip.selected { - border-color: var(--accent); - background: rgba(102, 126, 234, 0.15); -} - -.hoster-chip input[type="checkbox"] { - accent-color: var(--accent); - width: 16px; - height: 16px; -} - -.hoster-chip .hoster-dot { - width: 8px; - height: 8px; - border-radius: 50%; - background: var(--text-dim); -} - -.hoster-chip.selected .hoster-dot { - background: var(--success); -} - -.hoster-chip.no-key { - opacity: 0.5; -} - -.hoster-chip.no-key::after { - content: '(kein Key)'; - font-size: 11px; - color: var(--warning); -} - -/* Health Check */ -.health-check-panel { - margin-bottom: 18px; - padding: 12px 14px; - background: rgba(255, 255, 255, 0.03); - border: 1px solid var(--border); - border-radius: 10px; -} - -.health-check-actions { - display: flex; - align-items: center; - gap: 10px; - flex-wrap: wrap; -} - -.health-check-status { - font-size: 12px; - color: var(--text-muted); -} - -.auto-health-check { - display: inline-flex; - align-items: center; - gap: 6px; +.version-label { margin-left: auto; - font-size: 12px; - color: var(--text-muted); -} - -.auto-health-check input[type="checkbox"] { - width: 15px; - height: 15px; - accent-color: var(--accent); - cursor: pointer; -} - -.health-check-results { - display: flex; - flex-wrap: wrap; - gap: 8px; - margin-top: 10px; -} - -.health-check-badge { - display: inline-flex; - align-items: center; - gap: 6px; - max-width: 100%; - padding: 7px 10px; - border-radius: 8px; - border: 1px solid var(--border); - font-size: 12px; - line-height: 1.3; - color: var(--text); - background: rgba(255, 255, 255, 0.02); -} - -.health-check-badge .health-check-hoster { - font-weight: 600; -} - -.health-check-badge .health-check-msg { - color: var(--text-muted); -} - -.health-check-badge.ok { - border-color: rgba(0, 184, 148, 0.5); - background: rgba(0, 184, 148, 0.12); -} - -.health-check-badge.warn { - border-color: rgba(253, 203, 110, 0.5); - background: rgba(253, 203, 110, 0.12); -} - -.health-check-badge.error { - border-color: rgba(231, 76, 60, 0.5); - background: rgba(231, 76, 60, 0.12); -} - -.health-check-badge.skipped { - border-color: rgba(255, 255, 255, 0.2); - background: rgba(255, 255, 255, 0.06); -} - -/* Drop Zone */ -.drop-zone { - border: 2px dashed rgba(255, 255, 255, 0.12); - border-radius: 16px; - padding: 50px 40px; - text-align: center; - transition: all 0.2s; - cursor: pointer; - margin-bottom: 20px; -} - -.drop-zone:hover { - border-color: rgba(255, 255, 255, 0.25); - background: rgba(255, 255, 255, 0.02); -} - -.drop-zone.drag-over { - border-color: var(--accent); - background: rgba(102, 126, 234, 0.08); -} - -.drop-zone.hidden { - display: none; -} - -.drop-icon { - font-size: 40px; - margin-bottom: 12px; - opacity: 0.6; -} - -.drop-zone p { - color: var(--text-muted); - margin-bottom: 16px; - font-size: 14px; -} - -/* Buttons */ -.btn { - padding: 10px 22px; - border: none; - border-radius: 8px; - font-size: 14px; - cursor: pointer; - transition: all 0.2s; - font-weight: 500; -} - -.btn:disabled { - opacity: 0.6; - cursor: not-allowed; - transform: none; -} - -.btn-primary { - background: linear-gradient(90deg, var(--accent), var(--accent-end)); - color: #fff; -} - -.btn-primary:hover { - opacity: 0.9; - transform: translateY(-1px); -} - -.btn-secondary { - background: rgba(255, 255, 255, 0.08); - color: var(--text-muted); - border: 1px solid var(--border); -} - -.btn-secondary:hover { - background: rgba(255, 255, 255, 0.12); - color: var(--text); -} - -.btn-danger { - background: var(--danger); - color: #fff; -} - -.btn-danger:hover { - opacity: 0.9; -} - -.btn-sm { - padding: 5px 12px; - font-size: 12px; - border-radius: 6px; -} - -/* File List */ -.file-list { - display: flex; - flex-direction: column; - gap: 6px; - margin-bottom: 16px; -} - -.file-item { - display: flex; - align-items: center; - justify-content: space-between; - padding: 10px 16px; - background: var(--bg-card); - border: 1px solid var(--border); - border-radius: 10px; - font-size: 13px; -} - -.file-item .file-name { - flex: 1; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.file-item .file-size { - color: var(--text-muted); - margin: 0 16px; - font-size: 12px; - flex-shrink: 0; -} - -.file-item .remove-btn { - background: none; - border: none; + font-size: 0.7rem; color: var(--text-dim); - font-size: 18px; - cursor: pointer; - padding: 0 4px; - line-height: 1; -} - -.file-item .remove-btn:hover { - color: var(--danger); -} - -/* Upload Actions */ -.upload-actions { - display: flex; - gap: 10px; - margin-bottom: 20px; -} - -/* Progress Section */ -.progress-section { - display: flex; - flex-direction: column; - gap: 16px; -} - -.progress-card { - background: var(--bg-card); - border: 1px solid var(--border); - border-radius: 12px; - padding: 16px; -} - -.progress-card .file-title { - font-weight: 600; - margin-bottom: 12px; - font-size: 14px; -} - -.progress-row { - display: flex; - align-items: center; - gap: 12px; - margin-bottom: 8px; -} - -.progress-row:last-child { - margin-bottom: 0; -} - -.progress-hoster { - width: 130px; - font-size: 12px; - color: var(--text-muted); - flex-shrink: 0; -} - -.progress-track { - flex: 1; - height: 8px; - background: rgba(255, 255, 255, 0.08); - border-radius: 4px; - overflow: hidden; -} - -.progress-fill { - height: 100%; - background: linear-gradient(90deg, var(--accent), var(--accent-end)); - border-radius: 4px; - transition: width 0.15s ease; - width: 0%; -} - -.progress-fill.done { - background: linear-gradient(90deg, var(--success), var(--success-end)); -} - -.progress-fill.error { - background: var(--danger); -} - -.progress-percent { - width: 42px; - text-align: right; - font-size: 12px; - color: var(--text-muted); - flex-shrink: 0; -} - -.progress-status { - width: 100px; - font-size: 11px; - color: var(--text-dim); - flex-shrink: 0; - text-align: right; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.progress-status.done { - color: var(--success); -} - -.progress-status.error { - color: var(--danger); -} - -/* Results Section - Table like z-o-o-m */ -.results-section { - margin-top: 8px; -} - -.results-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 16px; -} - -.results-header h2 { - font-size: 18px; -} - -.results-buttons { - display: flex; - gap: 8px; -} - -.results-table { - width: 100%; - border-collapse: collapse; - font-size: 12px; - background: var(--bg-card); - border: 1px solid var(--border); - border-radius: 8px; - overflow: hidden; -} - -.results-table thead th { - background: rgba(0, 0, 0, 0.3); - padding: 8px 12px; - text-align: left; - font-weight: 500; - color: var(--text-muted); - font-size: 11px; - text-transform: uppercase; - letter-spacing: 0.5px; - border-bottom: 1px solid var(--border); - user-select: none; -} - -.results-table thead th.sortable { - cursor: pointer; -} - -.results-table thead th.sortable .sort-indicator { - margin-left: 6px; - font-size: 10px; - opacity: 0.55; -} - -.results-table thead th.sortable.active { - color: var(--accent); -} - -.results-table thead th.sortable.active .sort-indicator { - opacity: 1; -} - -.results-table tbody tr { - cursor: pointer; - transition: background 0.1s; - border-bottom: 1px solid rgba(255, 255, 255, 0.04); -} - -.results-table tbody tr:last-child { - border-bottom: none; -} - -.results-table tbody tr:hover { - background: rgba(255, 255, 255, 0.04); -} - -.results-table tbody tr.selected { - background: rgba(102, 126, 234, 0.15); -} - -.results-table tbody tr.selected:hover { - background: rgba(102, 126, 234, 0.2); -} - -.results-table tbody tr.error { - opacity: 0.6; -} - -.results-table tbody tr.error .col-link { - color: var(--danger); -} - -.results-table td { - padding: 7px 12px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.col-date { - width: 140px; - color: var(--text-muted); -} - -.col-filename { - max-width: 300px; - color: var(--text); -} - -.col-host { - width: 120px; - color: var(--text-muted); -} - -.col-link { - color: var(--link-color); - font-family: 'Cascadia Code', 'Consolas', monospace; - font-size: 11px; -} - -/* Copy button (used in history) */ -.copy-btn { - padding: 4px 10px; - background: rgba(102, 126, 234, 0.2); - color: var(--accent); - border: none; - border-radius: 6px; - cursor: pointer; - flex-shrink: 0; - font-size: 11px; - transition: all 0.15s; -} - -.copy-btn:hover { - background: rgba(102, 126, 234, 0.35); -} - -/* Copy toast */ -.copy-toast { - position: fixed; - bottom: 24px; - left: 50%; - transform: translateX(-50%) translateY(20px); - background: rgba(0, 184, 148, 0.9); - color: #fff; - padding: 8px 20px; - border-radius: 8px; - font-size: 13px; - opacity: 0; - pointer-events: none; - transition: all 0.2s ease; - z-index: 999; -} - -.copy-toast.show { - opacity: 1; - transform: translateX(-50%) translateY(0); -} - -/* Settings */ -.settings-container { - max-width: 600px; -} - -.settings-container h2 { - font-size: 18px; - margin-bottom: 6px; -} - -.settings-hint { - color: var(--text-muted); - font-size: 13px; - margin-bottom: 20px; -} - -.settings-grid { - display: flex; - flex-direction: column; - gap: 14px; - margin-bottom: 20px; -} - -.settings-row { - display: flex; - align-items: center; - gap: 12px; -} - -.settings-row .hoster-label { - width: 140px; - font-size: 13px; - font-weight: 500; - flex-shrink: 0; -} - -.settings-row .key-input { - flex: 1; - padding: 10px 14px; - background: var(--bg-input); - border: 1px solid var(--border); - border-radius: 8px; - color: var(--text); - font-size: 13px; - font-family: 'Cascadia Code', 'Consolas', monospace; - outline: none; - transition: border-color 0.2s; -} - -.settings-row .key-input:focus { - border-color: var(--accent); -} - -.settings-row .key-input::placeholder { - color: var(--text-dim); -} - -.toggle-vis { - background: none; - border: none; - color: var(--text-dim); - cursor: pointer; - font-size: 16px; - padding: 4px; -} - -.toggle-vis:hover { - color: var(--text); -} - -.settings-block { - display: flex; - flex-direction: column; - gap: 8px; -} - -.save-feedback { - margin-left: 12px; - font-size: 13px; - color: var(--success); - transition: opacity 0.3s; -} - -/* History */ -.history-container h2 { - font-size: 18px; -} - -.history-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 16px; -} - -.empty-state { - color: var(--text-muted); - text-align: center; - padding: 40px; - font-size: 14px; -} - -.history-card { - background: var(--bg-card); - border: 1px solid var(--border); - border-radius: 12px; - padding: 16px; - margin-bottom: 12px; -} - -.history-meta { - display: flex; - justify-content: space-between; - margin-bottom: 10px; - font-size: 12px; - color: var(--text-muted); -} - -.history-file { - margin-bottom: 10px; -} - -.history-file:last-child { - margin-bottom: 0; -} - -.history-file-name { - font-size: 13px; - font-weight: 500; - margin-bottom: 6px; -} - -.history-result { - display: flex; - align-items: center; - gap: 8px; - padding: 4px 10px; - background: rgba(0, 0, 0, 0.2); - border-radius: 6px; - margin-bottom: 3px; - font-size: 12px; -} - -.history-hoster { - width: 110px; - color: var(--text-muted); - flex-shrink: 0; -} - -.history-url { - color: var(--link-color); - flex: 1; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - font-family: 'Cascadia Code', 'Consolas', monospace; - font-size: 11px; -} - -.history-result.error .history-url { - color: var(--danger); -} - -/* Scrollbar */ -::-webkit-scrollbar { - width: 8px; -} - -::-webkit-scrollbar-track { - background: transparent; -} - -::-webkit-scrollbar-thumb { - background: rgba(255, 255, 255, 0.1); - border-radius: 4px; -} - -::-webkit-scrollbar-thumb:hover { - background: rgba(255, 255, 255, 0.2); + padding: 4px 8px; + align-self: center; } /* Update Banner */ @@ -795,20 +66,433 @@ body { display: flex; align-items: center; gap: 12px; - padding: 8px 20px; + padding: 6px 16px; background: linear-gradient(135deg, #667eea22, #764ba222); border-bottom: 1px solid rgba(102, 126, 234, 0.3); - font-size: 0.85rem; + font-size: 0.8rem; color: #e0e0e0; + flex-shrink: 0; } +.update-banner span { flex: 1; } -.update-banner span { +/* Views */ +.view { display: none; flex: 1; overflow: hidden; flex-direction: column; } +.view.active { display: flex; } + +/* Buttons */ +.btn { + padding: 8px 16px; + border: none; + border-radius: 6px; + cursor: pointer; + font-size: 13px; + font-weight: 500; + transition: all 0.2s; +} +.btn-xs { padding: 4px 10px; font-size: 11px; border-radius: 4px; } +.btn-sm { padding: 5px 12px; font-size: 12px; border-radius: 5px; } +.btn-primary { background: linear-gradient(135deg, var(--accent), var(--accent-end)); color: #fff; } +.btn-primary:hover { filter: brightness(1.1); } +.btn-primary:disabled { opacity: 0.5; cursor: default; filter: none; } +.btn-secondary { background: var(--bg-input); color: var(--text-muted); border: 1px solid var(--border); } +.btn-secondary:hover { border-color: var(--border-hover); color: var(--text); } +.btn-success { background: linear-gradient(135deg, var(--success), var(--success-end)); color: #fff; } +.btn-success:hover { filter: brightness(1.1); } +.btn-success:disabled { opacity: 0.5; cursor: default; filter: none; } +.btn-danger { background: var(--danger); color: #fff; } +.btn-danger:hover { filter: brightness(1.1); } + +/* Upload Toolbar */ +.upload-toolbar { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 16px; + border-bottom: 1px solid var(--border); + background: rgba(0, 0, 0, 0.15); + flex-shrink: 0; + flex-wrap: wrap; +} +.toolbar-left { display: flex; align-items: center; gap: 6px; flex: 1; min-width: 200px; } +.toolbar-right { display: flex; align-items: center; gap: 6px; flex-shrink: 0; } + +/* Hoster Chips */ +.hoster-select { display: flex; gap: 4px; flex-wrap: wrap; } +.hoster-chip { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 3px 8px; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 4px; + cursor: pointer; + font-size: 11px; + transition: all 0.2s; +} +.hoster-chip input { display: none; } +.hoster-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--text-dim); } +.hoster-chip.selected { border-color: var(--accent); background: rgba(102, 126, 234, 0.15); } +.hoster-chip.selected .hoster-dot { background: var(--accent); } +.hoster-chip.no-key { opacity: 0.4; cursor: default; } + +/* Health check */ +.health-check-inline { display: flex; align-items: center; gap: 4px; } +.auto-check-label { display: flex; align-items: center; gap: 3px; font-size: 10px; color: var(--text-muted); cursor: pointer; } +.auto-check-label input { width: 12px; height: 12px; } + +.health-check-results { display: flex; gap: 4px; padding: 0 16px; flex-wrap: wrap; flex-shrink: 0; } +.health-badge { + display: inline-flex; gap: 4px; padding: 2px 8px; + font-size: 10px; border-radius: 3px; align-items: center; +} +.health-badge.ok { background: rgba(0, 184, 148, 0.2); color: var(--success); } +.health-badge.warn { background: rgba(253, 203, 110, 0.2); color: var(--warning); } +.health-badge.error { background: rgba(231, 76, 60, 0.2); color: var(--danger); } +.health-badge.skipped { background: rgba(255,255,255,0.05); color: var(--text-dim); } +.health-tag { font-weight: 600; } + +/* Drop Zone */ +.drop-zone { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; flex: 1; + margin: 16px; + border: 2px dashed var(--border); + border-radius: 12px; + cursor: pointer; + transition: all 0.3s; + color: var(--text-muted); + min-height: 200px; +} +.drop-zone:hover, .drop-zone.drag-over { border-color: var(--accent); background: rgba(102, 126, 234, 0.05); } +.drop-icon { font-size: 40px; margin-bottom: 8px; } + +/* Queue Container */ +.queue-container { + flex: 1; + overflow: auto; + padding: 0; } -.version-label { - margin-left: auto; - font-size: 0.75rem; - color: #888; - padding: 4px 8px; +/* Queue Table */ +.queue-table { + width: 100%; + border-collapse: collapse; + font-size: 11px; + table-layout: fixed; } +.queue-table thead { + position: sticky; + top: 0; + z-index: 5; +} +.queue-table th { + padding: 5px 8px; + text-align: left; + background: #12121f; + color: var(--text-muted); + font-weight: 600; + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.5px; + border-bottom: 1px solid var(--border); + white-space: nowrap; + cursor: default; +} +.queue-table th.sortable { cursor: pointer; } +.queue-table th.sortable:hover { color: var(--text); } + +.col-filename { width: 30%; } +.col-size { width: 12%; } +.col-host { width: 12%; } +.col-status { width: 10%; } +.col-elapsed { width: 7%; } +.col-remaining { width: 7%; } +.col-speed { width: 8%; } +.col-progress { width: 14%; } + +.queue-table td { + padding: 4px 8px; + border-bottom: 1px solid rgba(255, 255, 255, 0.03); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Queue Row States */ +.queue-row { transition: background 0.15s; cursor: pointer; } +.queue-row:hover { background: rgba(255, 255, 255, 0.03); } +.queue-row.selected { background: rgba(102, 126, 234, 0.12) !important; } + +.queue-row.status-uploading { background: rgba(102, 126, 234, 0.08); } +.queue-row.status-getting-server { background: rgba(102, 126, 234, 0.05); } +.queue-row.status-retrying { background: rgba(253, 203, 110, 0.08); } +.queue-row.status-done { background: rgba(0, 184, 148, 0.06); } +.queue-row.status-error { background: rgba(231, 76, 60, 0.1); } +.queue-row.status-skipped { background: rgba(255, 255, 255, 0.02); opacity: 0.6; } + +/* Status Badge */ +.status-badge { + display: inline-block; + padding: 1px 6px; + border-radius: 3px; + font-size: 10px; + font-weight: 600; +} +.status-badge.status-preview { color: var(--text-muted); } +.status-badge.status-queued { color: var(--text-muted); background: rgba(255,255,255,0.05); } +.status-badge.status-getting-server { color: var(--accent); } +.status-badge.status-uploading { color: #5dabf7; background: rgba(93, 171, 247, 0.15); } +.status-badge.status-retrying { color: var(--warning); background: rgba(253, 203, 110, 0.15); } +.status-badge.status-done { color: var(--success); background: rgba(0, 184, 148, 0.15); } +.status-badge.status-error { color: var(--danger); background: rgba(231, 76, 60, 0.15); } +.status-badge.status-skipped { color: var(--text-dim); } + +/* Progress in table cell */ +.progress-cell { display: flex; align-items: center; gap: 4px; } +.progress-bar-bg { + flex: 1; + height: 14px; + background: rgba(255, 255, 255, 0.05); + border-radius: 2px; + overflow: hidden; +} +.progress-bar-fill { + height: 100%; + transition: width 0.3s ease; + border-radius: 2px; +} +.progress-bar-fill.status-uploading { background: linear-gradient(90deg, #4a90d9, #5dabf7); } +.progress-bar-fill.status-getting-server { background: var(--accent); } +.progress-bar-fill.status-retrying { background: var(--warning); } +.progress-bar-fill.status-done { background: linear-gradient(90deg, var(--success), var(--success-end)); } +.progress-bar-fill.status-error { background: var(--danger); } +.progress-pct { font-size: 10px; color: var(--text-muted); min-width: 28px; text-align: right; } + +/* Queue Actions */ +.queue-actions { + display: flex; + gap: 6px; + padding: 6px 16px; + border-top: 1px solid var(--border); + background: rgba(0, 0, 0, 0.1); + flex-shrink: 0; +} + +/* Context Menu */ +.context-menu { + position: fixed; + z-index: 1000; + background: #1e1e30; + border: 1px solid var(--border-hover); + border-radius: 6px; + padding: 4px 0; + min-width: 200px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.6); +} +.ctx-item { + padding: 6px 16px; + font-size: 12px; + color: var(--text); + cursor: pointer; + transition: background 0.1s; + position: relative; +} +.ctx-item:hover { background: rgba(102, 126, 234, 0.2); } +.ctx-separator { height: 1px; margin: 4px 8px; background: var(--border); } +.ctx-submenu { position: relative; } +.ctx-submenu-items { + display: none; + position: absolute; + left: 100%; + top: -4px; + background: #1e1e30; + border: 1px solid var(--border-hover); + border-radius: 6px; + padding: 4px 0; + min-width: 160px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.6); +} +.ctx-submenu:hover .ctx-submenu-items { display: block; } + +/* Settings View */ +.settings-container { padding: 16px; overflow: auto; flex: 1; } +.settings-container h2 { margin-bottom: 4px; font-size: 18px; } +.settings-hint { color: var(--text-muted); font-size: 12px; margin-bottom: 12px; } + +.hoster-settings-panel { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 8px; + margin-bottom: 8px; + overflow: hidden; +} +.hoster-panel-header { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 14px; + cursor: pointer; + transition: background 0.2s; +} +.hoster-panel-header:hover { background: var(--bg-card-hover); } +.panel-arrow { font-size: 10px; color: var(--text-muted); width: 12px; } +.panel-title { font-weight: 600; font-size: 13px; flex: 1; } +.panel-status { font-size: 10px; padding: 2px 8px; border-radius: 3px; } +.panel-status.active { background: rgba(0, 184, 148, 0.2); color: var(--success); } +.panel-status.inactive { background: rgba(255, 255, 255, 0.05); color: var(--text-dim); } + +.hoster-panel-body { padding: 0 14px 14px; } +.settings-divider { height: 1px; background: var(--border); margin: 12px 0; } +.hoster-panel-body h4 { font-size: 12px; color: var(--text-muted); margin-bottom: 8px; font-weight: 500; } + +.settings-row { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 6px; +} +.settings-row label { + font-size: 12px; + color: var(--text-muted); + min-width: 130px; + flex-shrink: 0; +} +.key-input, .hs-input { + flex: 1; + padding: 6px 10px; + background: var(--bg-input); + border: 1px solid var(--border); + border-radius: 4px; + color: var(--text); + font-size: 12px; + max-width: 300px; +} +.key-input:focus, .hs-input:focus { border-color: var(--accent); outline: none; } +.hs-input { max-width: 100px; } +.hint { font-size: 10px; color: var(--text-dim); } + +.toggle-vis { + background: transparent; + border: 1px solid var(--border); + color: var(--text-muted); + padding: 4px 8px; + border-radius: 4px; + cursor: pointer; + font-size: 12px; +} +.toggle-vis:hover { border-color: var(--border-hover); } + +.save-feedback { font-size: 12px; color: var(--success); margin-left: 8px; } + +.settings-grid-mini { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 4px 16px; +} + +/* History View */ +.history-container { padding: 16px; overflow: auto; flex: 1; } +.history-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; } +.history-header h2 { font-size: 18px; } + +.results-table, .history-table { + width: 100%; + border-collapse: collapse; + font-size: 12px; +} +.results-table th, .history-table th { + padding: 6px 8px; + text-align: left; + background: var(--bg-card); + color: var(--text-muted); + font-weight: 600; + font-size: 11px; + border-bottom: 1px solid var(--border); + cursor: pointer; + white-space: nowrap; +} +.results-table th.active, .history-table th.active { color: var(--text); } +.sort-indicator { margin-left: 4px; font-size: 10px; } + +.history-row { + cursor: pointer; + transition: background 0.15s; +} +.history-row:hover { background: rgba(255, 255, 255, 0.03); } +.history-row.selected { background: rgba(102, 126, 234, 0.12); } +.history-row.error { color: var(--danger); opacity: 0.6; } +.history-row td { + padding: 5px 8px; + border-bottom: 1px solid rgba(255, 255, 255, 0.03); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 300px; +} + +.empty-state { color: var(--text-dim); text-align: center; padding: 40px; font-size: 14px; } + +/* Statusbar */ +.statusbar { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 16px; + background: #0a0a14; + border-top: 1px solid var(--border); + font-size: 11px; + color: var(--text-muted); + flex-shrink: 0; +} +.sb-separator { color: var(--text-dim); } +.sb-speed { color: var(--link-color); font-weight: 500; } +.sb-total { color: var(--text); } +.sb-elapsed { color: var(--text-muted); } +.sb-state { flex: 1; } + +/* Copy toast */ +.copy-toast { + position: fixed; + bottom: 40px; + left: 50%; + transform: translateX(-50%) translateY(20px); + background: rgba(0, 184, 148, 0.9); + color: #fff; + padding: 6px 16px; + border-radius: 6px; + font-size: 12px; + opacity: 0; + pointer-events: none; + transition: all 0.3s; + z-index: 2000; +} +.copy-toast.show { opacity: 1; transform: translateX(-50%) translateY(0); } + +/* Shutdown overlay */ +.shutdown-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.8); + display: flex; + align-items: center; + justify-content: center; + z-index: 3000; +} +.shutdown-box { + background: var(--bg-card); + border: 1px solid var(--border-hover); + border-radius: 12px; + padding: 24px 32px; + text-align: center; +} +.shutdown-box p { margin-bottom: 16px; font-size: 16px; } + +/* Scrollbars */ +::-webkit-scrollbar { width: 8px; height: 8px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.1); border-radius: 4px; } +::-webkit-scrollbar-thumb:hover { background: rgba(255, 255, 255, 0.2); }