diff --git a/lib/config-store.js b/lib/config-store.js index 96ee419..c990f92 100644 --- a/lib/config-store.js +++ b/lib/config-store.js @@ -26,6 +26,9 @@ const DEFAULTS = { globalSettings: { alwaysOnTop: false, shutdownAfterFinish: 'nothing', // nothing | sleep | shutdown | restart + logFilePath: '', + resumeQueueOnLaunch: true, + pendingQueue: null, scramble: { active: false, prefix: '', diff --git a/lib/hosters.js b/lib/hosters.js index c1d52c6..0421acb 100644 --- a/lib/hosters.js +++ b/lib/hosters.js @@ -41,7 +41,7 @@ const HOSTER_CONFIGS = { }, 'voe.sx': { apiBase: 'https://voe.sx', - serverEndpoints: ['/api/upload/server'], + serverEndpoints: ['/api/upload/server', '/api/v1/upload/server'], buildUploadUrl: (url, key) => appendKeyParam(url, key), formFields: () => ({}), parseResult: parseVoeResult @@ -176,9 +176,15 @@ function parseDoodstreamResult(payload) { // VOE: { file: { file_code } } function parseVoeResult(payload) { - const file_code = (payload.file && typeof payload.file === 'object') - ? payload.file.file_code - : null; + const source = payload && typeof payload === 'object' && payload.result && typeof payload.result === 'object' + ? payload.result + : payload; + const file = source && typeof source.file === 'object' ? source.file : null; + const file_code = file?.file_code + || file?.filecode + || source?.file_code + || source?.filecode + || null; return { download_url: file_code ? `https://voe.sx/${file_code}` : null, @@ -353,26 +359,64 @@ async function uploadFile(hosterName, filePath, apiKey, onProgress, signal, thro const { iterable, boundary, totalSize } = createUploadBody(filePath, formFields, onProgress, throttle, signal); - const { body, statusCode } = await request(targetUrl, { + const { body, statusCode, headers } = await request(targetUrl, { method: 'POST', body: iterable, signal, headers: { 'Content-Type': `multipart/form-data; boundary=${boundary}`, - 'Content-Length': String(totalSize) + 'Content-Length': String(totalSize), + 'Accept': 'application/json, text/plain;q=0.9, */*;q=0.8', + 'User-Agent': 'multi-hoster-uploader/1.1' }, headersTimeout: UPLOAD_TIMEOUT, bodyTimeout: UPLOAD_TIMEOUT }); - const payload = await body.json(); + const rawBody = await body.text(); + let payload = null; + try { + payload = rawBody ? JSON.parse(rawBody) : {}; + } catch { + const snippet = rawBody ? rawBody.slice(0, 240).replace(/\s+/g, ' ').trim() : ''; + throw new Error( + `Upload-Antwort von ${hosterName} war kein JSON (HTTP ${statusCode}${snippet ? `): ${snippet}` : ')'}` + ); + } + + if (statusCode < 200 || statusCode >= 300) { + throw new Error( + payload.msg + || payload.message + || `Upload fehlgeschlagen (HTTP ${statusCode}${headers?.['content-type'] ? `, ${headers['content-type']}` : ''})` + ); + } if (payload.status && [401, 403, 429, 500].includes(payload.status)) { throw new Error(payload.msg || payload.message || JSON.stringify(payload)); } - // Step 3: Parse result - return config.parseResult(payload); + const result = config.parseResult(payload); + if (result?.file_code || result?.download_url || result?.embed_url) { + return result; + } + + if (payload.success === false) { + throw new Error(payload.msg || payload.message || `Upload zu ${hosterName} wurde vom Server abgelehnt.`); + } + + throw new Error( + payload.msg + || payload.message + || `Upload zu ${hosterName} lieferte keine verwendbaren Dateidaten zurueck.` + ); } -module.exports = { uploadFile, HOSTER_CONFIGS }; +module.exports = { + uploadFile, + HOSTER_CONFIGS, + __test: { + extractUploadServerUrl, + parseVoeResult + } +}; diff --git a/main.js b/main.js index 7cd8ff0..d429b25 100644 --- a/main.js +++ b/main.js @@ -58,24 +58,64 @@ function normalizeApiError(payload, fallback) { return fallback; } -function getLogFilePath() { - // Next to the EXE when packaged, next to project root when dev +function getDefaultLogFilePath() { const baseDir = app.isPackaged ? path.dirname(process.execPath) : path.join(__dirname); return path.join(baseDir, 'fileuploader.log'); } +function getLogFilePath() { + const config = configStore.load(); + const customPath = config && config.globalSettings + ? String(config.globalSettings.logFilePath || '').trim() + : ''; + return customPath || getDefaultLogFilePath(); +} + function appendUploadLog(hoster, link, fileName) { try { + const logPath = getLogFilePath(); + fs.mkdirSync(path.dirname(logPath), { recursive: true }); const now = new Date(); const pad = (n) => String(n).padStart(2, '0'); const dateStr = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`; const line = `${dateStr}|${hoster}|${link}||${fileName}|\n`; - fs.appendFileSync(getLogFilePath(), line, 'utf-8'); + fs.appendFileSync(logPath, line, 'utf-8'); } catch {} } +function buildUploadTasks(config, files, hosters) { + const tasks = []; + + for (const file of files) { + for (const hoster of hosters) { + const hosterConfig = config.hosters[hoster]; + if (!hosterConfig) { + debugLog(` skip ${hoster}: no config`); + continue; + } + + if (hoster === 'vidmoly.me') { + if (!hosterConfig.username || !hosterConfig.password) { + debugLog(` skip ${hoster}: missing username/password`); + continue; + } + tasks.push({ file, hoster, username: hosterConfig.username, password: hosterConfig.password }); + } else { + if (!hosterConfig.apiKey) { + debugLog(` skip ${hoster}: missing apiKey`); + continue; + } + tasks.push({ file, hoster, apiKey: hosterConfig.apiKey }); + debugLog(` task: ${hoster} key=${hosterConfig.apiKey.slice(0, 6)}...`); + } + } + } + + return tasks; +} + async function checkDoodstreamHealth(hosterConfig) { const apiKey = hosterConfig && hosterConfig.apiKey ? String(hosterConfig.apiKey).trim() @@ -216,7 +256,7 @@ function createWindow() { height: 750, minWidth: 800, minHeight: 550, - backgroundColor: '#0f0f1a', + backgroundColor: '#16181c', autoHideMenuBar: true, webPreferences: { contextIsolation: true, @@ -322,34 +362,7 @@ ipcMain.handle('start-upload', (_event, payload) => { debugLog(`start-upload: files=${JSON.stringify(files)}, hosters=${JSON.stringify(hosters)}`); - // Build tasks with credentials - const tasks = []; - for (const file of files) { - for (const hoster of hosters) { - const hosterConfig = config.hosters[hoster]; - if (!hosterConfig) { - debugLog(` skip ${hoster}: no config`); - continue; - } - - if (hoster === 'vidmoly.me') { - // Vidmoly uses username/password login - if (!hosterConfig.username || !hosterConfig.password) { - debugLog(` skip ${hoster}: missing username/password`); - continue; - } - tasks.push({ file, hoster, username: hosterConfig.username, password: hosterConfig.password }); - } else { - // Other hosters use API key - if (!hosterConfig.apiKey) { - debugLog(` skip ${hoster}: missing apiKey`); - continue; - } - tasks.push({ file, hoster, apiKey: hosterConfig.apiKey }); - debugLog(` task: ${hoster} key=${hosterConfig.apiKey.slice(0, 6)}...`); - } - } - } + const tasks = buildUploadTasks(config, files, hosters); debugLog(` tasks built: ${tasks.length}`); diff --git a/renderer/app.js b/renderer/app.js index 6d7fd41..3fb523e 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -2,11 +2,13 @@ const HOSTERS = ['doodstream.com', 'voe.sx', 'vidmoly.me', 'byse.sx']; // --- State --- let selectedFiles = []; // { path, name, size } +let selectedUploadHosters = []; let config = { hosters: {}, hosterSettings: {}, globalSettings: {} }; let hosterSettings = {}; let uploading = false; let healthCheckRunning = false; let autoHealthCheckEnabled = true; +let queuePersistTimer = null; const AUTO_CHECK_PREF_KEY = 'autoHealthCheckBeforeUpload'; // Queue state @@ -23,11 +25,21 @@ async function init() { config = await window.api.getConfig(); hosterSettings = config.hosterSettings || {}; autoHealthCheckEnabled = loadAutoCheckPreference(); - renderHosterChips(); + syncSelectedUploadHosters(); + restoreQueueStateFromConfig(); + renderHosterSummary(); + renderHosterModal(); renderSettings(); setupListeners(); setupDragDrop(); loadHistory(); + updateUploadView(); + + if (shouldAutoResumeQueue()) { + setTimeout(() => { + if (!uploading) startUpload(); + }, 350); + } // Version display try { @@ -75,37 +87,215 @@ document.querySelectorAll('.tab').forEach(tab => { }); }); -// --- Hoster chips --- +// --- Hoster selection --- function hosterHasCredentials(name, hoster) { if (name === 'vidmoly.me') return !!(hoster.username && hoster.password); return !!hoster.apiKey; } -function renderHosterChips() { - const container = document.getElementById('hosterSelect'); - container.innerHTML = ''; - for (const name of HOSTERS) { - const hoster = config.hosters[name] || {}; - const hasCreds = hosterHasCredentials(name, hoster); - const chip = document.createElement('label'); - chip.className = 'hoster-chip' + (hoster.enabled && hasCreds ? ' selected' : '') + (!hasCreds ? ' no-key' : ''); - chip.innerHTML = ` - - - ${name} - `; - chip.querySelector('input').addEventListener('change', (e) => { - chip.classList.toggle('selected', e.target.checked); - if (!uploading && selectedFiles.length > 0) buildQueuePreview(); - updateStartButton(); +function getAvailableHosters() { + return HOSTERS + .map(name => { + const hoster = config.hosters[name] || {}; + return { name, hoster, hasCreds: hosterHasCredentials(name, hoster) }; + }) + .filter(item => item.hasCreds); +} + +function syncSelectedUploadHosters() { + const available = new Set(getAvailableHosters().map(item => item.name)); + selectedUploadHosters = selectedUploadHosters.filter(name => available.has(name)); + if (selectedUploadHosters.length === 0) { + selectedUploadHosters = HOSTERS.filter(name => { + const hoster = config.hosters[name] || {}; + return !!hoster.enabled && hosterHasCredentials(name, hoster); }); - container.appendChild(chip); } } function getSelectedHosters() { - return Array.from(document.querySelectorAll('#hosterSelect input:checked')) - .map(cb => cb.dataset.hoster); + return selectedUploadHosters.slice(); +} + +function renderHosterSummary() { + const summary = document.getElementById('hosterSummary'); + if (!summary) return; + const hosters = getSelectedHosters(); + if (hosters.length === 0) { + summary.textContent = 'Keine Upload-Ziele ausgewaehlt'; + } else if (hosters.length === 1) { + summary.textContent = `Aktives Ziel: ${hosters[0]}`; + } else { + summary.textContent = `${hosters.length} Ziele aktiv: ${hosters.join(', ')}`; + } +} + +function renderHosterModal() { + const list = document.getElementById('hosterModalList'); + const hint = document.getElementById('hosterModalHint'); + if (!list || !hint) return; + + const available = getAvailableHosters(); + if (available.length === 0) { + list.innerHTML = ''; + hint.textContent = 'Keine Hoster mit Zugangsdaten vorhanden. Bitte zuerst in den Einstellungen API-Key oder Login eintragen.'; + return; + } + + list.innerHTML = available.map(item => { + const checked = selectedUploadHosters.includes(item.name); + const subtitle = item.name === 'vidmoly.me' ? 'Login hinterlegt' : 'API-Key hinterlegt'; + return ` + + `; + }).join(''); + + hint.textContent = 'Die Auswahl wird fuer neue Queue-Eintraege verwendet.'; + + list.querySelectorAll('input[data-hoster-modal]').forEach(input => { + input.addEventListener('change', () => { + input.closest('.hoster-option')?.classList.toggle('selected', input.checked); + }); + }); +} + +function openHosterModal() { + syncSelectedUploadHosters(); + renderHosterModal(); + document.getElementById('hosterModal').style.display = 'flex'; +} + +function closeHosterModal() { + const modal = document.getElementById('hosterModal'); + if (modal) modal.style.display = 'none'; +} + +function applyHosterSelection() { + selectedUploadHosters = Array.from(document.querySelectorAll('input[data-hoster-modal]:checked')) + .map(input => input.dataset.hosterModal); + renderHosterSummary(); + if (!uploading && selectedFiles.length > 0) buildQueuePreview(); + updateStartButton(); + persistQueueStateSoon(); + closeHosterModal(); +} + +function normalizeRestoredJobStatus(status) { + if (status === 'done' || status === 'error' || status === 'skipped' || status === 'preview') return status; + return 'queued'; +} + +function restoreQueueStateFromConfig() { + const pending = config?.globalSettings?.pendingQueue; + if (!pending || typeof pending !== 'object') return; + + selectedUploadHosters = Array.isArray(pending.selectedUploadHosters) + ? pending.selectedUploadHosters.filter(Boolean) + : selectedUploadHosters; + + selectedFiles = Array.isArray(pending.selectedFiles) + ? pending.selectedFiles + .filter(file => file && file.path) + .map(file => ({ path: file.path, name: file.name || file.path.split(/[\\/]/).pop(), size: file.size || 0 })) + : []; + + queueJobs = Array.isArray(pending.queueJobs) + ? pending.queueJobs + .filter(job => job && job.fileName && job.hoster) + .map(job => ({ + id: job.id || `restored-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + uploadId: null, + file: job.file || '', + fileName: job.fileName, + hoster: job.hoster, + status: normalizeRestoredJobStatus(job.status), + bytesUploaded: job.status === 'done' ? (job.bytesTotal || 0) : 0, + bytesTotal: job.bytesTotal || 0, + speedKbs: 0, + elapsed: 0, + remaining: 0, + error: job.error || null, + result: job.result || null, + attempt: 0, + maxAttempts: job.maxAttempts || 0, + link: '', + progress: job.status === 'done' ? 1 : 0 + })) + : []; +} + +function buildPersistedQueueState() { + const unfinishedJobs = queueJobs.filter(job => job.status !== 'done' && job.status !== 'skipped'); + const selectedFileMap = new Map(selectedFiles.map(file => [file.path, file])); + + for (const job of unfinishedJobs) { + if (job.file && !selectedFileMap.has(job.file)) { + selectedFileMap.set(job.file, { + path: job.file, + name: job.fileName, + size: job.bytesTotal || 0 + }); + } + } + + if (selectedFileMap.size === 0 && queueJobs.every(job => job.status === 'done' || job.status === 'skipped')) { + return null; + } + + return { + selectedUploadHosters: getSelectedHosters(), + selectedFiles: Array.from(selectedFileMap.values()), + queueJobs: queueJobs.map(job => ({ + id: job.id, + file: job.file, + fileName: job.fileName, + hoster: job.hoster, + status: job.status, + bytesTotal: job.bytesTotal || 0, + error: job.error || null, + result: job.result || null, + maxAttempts: job.maxAttempts || 0 + })) + }; +} + +async function persistQueueStateNow() { + const globalSettings = { + ...(config.globalSettings || {}), + pendingQueue: buildPersistedQueueState() + }; + config.globalSettings = globalSettings; + await window.api.saveGlobalSettings(globalSettings); +} + +function persistQueueStateSoon() { + clearTimeout(queuePersistTimer); + queuePersistTimer = setTimeout(() => { + persistQueueStateNow().catch(() => {}); + }, 250); +} + +function clearPersistedQueueStateSoon() { + clearTimeout(queuePersistTimer); + queuePersistTimer = setTimeout(() => { + const globalSettings = { + ...(config.globalSettings || {}), + pendingQueue: null + }; + config.globalSettings = globalSettings; + window.api.saveGlobalSettings(globalSettings).catch(() => {}); + }, 0); +} + +function shouldAutoResumeQueue() { + if (!config?.globalSettings?.resumeQueueOnLaunch) return false; + return queueJobs.some(job => !['done', 'skipped'].includes(job.status)); } // --- File selection --- @@ -132,39 +322,47 @@ function setupDragDrop() { } function addDroppedFiles(fileList) { + let added = 0; 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 }); + added++; } } updateUploadView(); + persistQueueStateSoon(); + if (added > 0) openHosterModal(); } async function pickFiles() { const paths = await window.api.selectFiles(); if (!paths) return; + let added = 0; 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: null }); // size resolved by upload-manager + added++; } } updateUploadView(); + persistQueueStateSoon(); + if (added > 0) openHosterModal(); } function updateUploadView() { const dropZone = document.getElementById('dropZone'); - const queueContainer = document.getElementById('queueContainer'); + const queueShell = document.getElementById('queueShell'); const queueActions = document.getElementById('queueActions'); if (selectedFiles.length === 0 && queueJobs.length === 0) { dropZone.style.display = 'flex'; - queueContainer.style.display = 'none'; + queueShell.style.display = 'none'; queueActions.style.display = 'none'; } else { dropZone.style.display = 'none'; - queueContainer.style.display = 'block'; + queueShell.style.display = 'flex'; queueActions.style.display = 'flex'; if (!uploading && selectedFiles.length > 0) { buildQueuePreview(); @@ -183,6 +381,12 @@ function updateStartButton() { // Build preview jobs from selected files x selected hosters (before upload starts) function buildQueuePreview() { const hosters = getSelectedHosters(); + if (hosters.length === 0) { + queueJobs = queueJobs.filter(j => j.status !== 'preview'); + renderQueueTable(); + persistQueueStateSoon(); + return; + } // Remove old preview jobs (status 'preview') queueJobs = queueJobs.filter(j => j.status !== 'preview'); @@ -202,6 +406,7 @@ function buildQueuePreview() { } } renderQueueTable(); + persistQueueStateSoon(); } // --- Queue Table Rendering (debounced) --- @@ -371,7 +576,10 @@ document.addEventListener('click', (e) => { if (!e.target.closest('.context-menu')) hideContextMenu(); }); document.addEventListener('keydown', (e) => { - if (e.key === 'Escape') hideContextMenu(); + if (e.key === 'Escape') { + hideContextMenu(); + closeHosterModal(); + } }); document.getElementById('contextMenu').addEventListener('click', (e) => { @@ -394,10 +602,17 @@ async function handleContextAction(action) { selectedJobIds.clear(); renderQueueTable(); if (queueJobs.length === 0) { selectedFiles = []; updateUploadView(); } + persistQueueStateSoon(); } else if (action === 'copy-all-links') { copyAllLinks(); } else if (action === 'delete-all') { - if (!uploading) { queueJobs = []; selectedFiles = []; selectedJobIds.clear(); updateUploadView(); } + if (!uploading) { + queueJobs = []; + selectedFiles = []; + selectedJobIds.clear(); + updateUploadView(); + clearPersistedQueueStateSoon(); + } } else if (action === 'always-on-top') { alwaysOnTopState = !alwaysOnTopState; await window.api.setAlwaysOnTop(alwaysOnTopState); @@ -471,6 +686,7 @@ async function startUpload() { console.log('[startUpload] sending payload:', uploadPayload); const result = await window.api.startUpload(uploadPayload); console.log('[startUpload] response:', result); + persistQueueStateSoon(); if (result && result.error) { alert(result.error); @@ -486,6 +702,7 @@ async function cancelUpload() { document.getElementById('startUploadBtn').style.display = 'inline-block'; document.getElementById('cancelUploadBtn').style.display = 'none'; updateStartButton(); + persistQueueStateSoon(); } // --- Progress handling --- @@ -529,6 +746,7 @@ function handleProgress(data) { job.progress = data.progress || 0; scheduleQueueRender(); + persistQueueStateSoon(); } function handleBatchDone(summary) { @@ -539,6 +757,8 @@ function handleBatchDone(summary) { document.getElementById('cancelUploadBtn').style.display = 'none'; updateStartButton(); renderQueueTable(); + loadHistory(); + clearPersistedQueueStateSoon(); // Final stats update document.getElementById('sbState').textContent = 'Fertig'; @@ -574,6 +794,7 @@ function retrySelectedJobs() { selectedJobIds.clear(); renderQueueTable(); updateStartButton(); + persistQueueStateSoon(); } // --- Health Check --- @@ -623,6 +844,29 @@ function renderSettings() { const container = document.getElementById('settingsHosters'); container.innerHTML = ''; + const globalSettings = config.globalSettings || {}; + const generalPanel = document.createElement('div'); + generalPanel.className = 'hoster-settings-panel'; + generalPanel.innerHTML = ` +
+ + Allgemein + System +
+
+
+ + + +
+
+ + +
+
+ `; + container.appendChild(generalPanel); + for (const name of HOSTERS) { const hoster = config.hosters[name] || {}; const hs = hosterSettings[name] || {}; @@ -712,11 +956,25 @@ function renderSettings() { }); }); } + + document.getElementById('chooseLogFilePathBtn')?.addEventListener('click', chooseLogFilePath); +} + +async function chooseLogFilePath() { + const folders = await window.api.selectFolder(); + if (!folders || !folders[0]) return; + const normalized = folders[0].replace(/[\\\/]+$/, ''); + document.getElementById('logFilePathInput').value = `${normalized}\\fileuploader.log`; } async function saveSettings() { const hosters = {}; const newHosterSettings = {}; + const globalSettings = { + ...(config.globalSettings || {}), + logFilePath: (document.getElementById('logFilePathInput')?.value || '').trim(), + resumeQueueOnLaunch: !!document.getElementById('resumeQueueOnLaunchInput')?.checked + }; for (const name of HOSTERS) { // Credentials @@ -740,9 +998,12 @@ async function saveSettings() { await window.api.saveConfig({ hosters }); await window.api.saveHosterSettings(newHosterSettings); + await window.api.saveGlobalSettings(globalSettings); config = await window.api.getConfig(); hosterSettings = config.hosterSettings || {}; - renderHosterChips(); + syncSelectedUploadHosters(); + renderHosterSummary(); + renderHosterModal(); renderSettings(); renderHealthCheckResults([]); @@ -759,6 +1020,7 @@ async function loadHistory() { if (!history || history.length === 0) { historyRowsData = []; container.innerHTML = '

Noch keine Uploads.

'; + renderRecentUploadsPanel(); return; } @@ -781,6 +1043,41 @@ async function loadHistory() { } renderHistoryTable(container); + renderRecentUploadsPanel(); +} + +function renderRecentUploadsPanel() { + const tbody = document.getElementById('recentFilesBody'); + if (!tbody) return; + if (!historyRowsData.length) { + tbody.innerHTML = 'Noch keine Uploads.'; + return; + } + + const rows = historyRowsData + .slice() + .sort((a, b) => b.dateTs - a.dateTs || b.order - a.order) + .slice(0, 20); + + tbody.innerHTML = rows.map(row => ` + + ${escapeHtml(row.date)} + ${escapeHtml(row.filename)} + ${escapeHtml(row.host)} + ${escapeHtml(row.link)} + + `).join(''); + + tbody.querySelectorAll('.recent-file-row').forEach(row => { + row.addEventListener('click', () => { + if (row.classList.contains('error')) return; + const link = row.dataset.link; + if (link) { + window.api.copyToClipboard(link); + showCopyToast('Link kopiert'); + } + }); + }); } function renderHistoryTable(container) { @@ -842,6 +1139,7 @@ function sortHistoryRows(rows) { // --- Setup Listeners --- function setupListeners() { document.getElementById('addFilesBtn').addEventListener('click', pickFiles); + document.getElementById('chooseHostersBtn').addEventListener('click', openHosterModal); document.getElementById('startUploadBtn').addEventListener('click', startUpload); document.getElementById('cancelUploadBtn').addEventListener('click', cancelUpload); document.getElementById('runHealthCheckBtn').addEventListener('click', runHealthCheck); @@ -851,7 +1149,28 @@ function setupListeners() { retrySelectedJobs(); }); document.getElementById('clearQueueBtn').addEventListener('click', () => { - if (!uploading) { queueJobs = []; selectedFiles = []; selectedJobIds.clear(); updateUploadView(); } + if (!uploading) { + queueJobs = []; + selectedFiles = []; + selectedJobIds.clear(); + updateUploadView(); + clearPersistedQueueStateSoon(); + } + }); + document.getElementById('confirmHosterModalBtn').addEventListener('click', applyHosterSelection); + document.getElementById('cancelHosterModalBtn').addEventListener('click', closeHosterModal); + document.getElementById('closeHosterModalBtn').addEventListener('click', closeHosterModal); + document.getElementById('selectAllHostersBtn').addEventListener('click', () => { + document.querySelectorAll('input[data-hoster-modal]').forEach(input => { + input.checked = true; + input.closest('.hoster-option')?.classList.add('selected'); + }); + }); + document.getElementById('clearHostersBtn').addEventListener('click', () => { + document.querySelectorAll('input[data-hoster-modal]').forEach(input => { + input.checked = false; + input.closest('.hoster-option')?.classList.remove('selected'); + }); }); document.getElementById('saveSettingsBtn').addEventListener('click', saveSettings); document.getElementById('clearHistoryBtn').addEventListener('click', async () => { @@ -893,6 +1212,10 @@ function setupListeners() { e.preventDefault(); showContextMenu(e.clientX, e.clientY); }); + + document.getElementById('hosterModal').addEventListener('click', (e) => { + if (e.target.id === 'hosterModal') closeHosterModal(); + }); } // --- Update UI --- diff --git a/renderer/index.html b/renderer/index.html index 80f06e2..6c26de4 100644 --- a/renderer/index.html +++ b/renderer/index.html @@ -25,7 +25,8 @@
-
+ + Keine Upload-Ziele ausgewaehlt
@@ -44,36 +45,60 @@
- -
-
📁
-

Dateien hierher ziehen oder klicken

-
+
+ +
+
📁
+

Dateien hierher ziehen oder klicken

+
- - + +
@@ -142,6 +167,30 @@
+ + diff --git a/renderer/styles.css b/renderer/styles.css index 2596d7d..e0dae43 100644 --- a/renderer/styles.css +++ b/renderer/styles.css @@ -1,28 +1,31 @@ :root { - --bg-primary: #0f0f1a; - --bg-secondary: #1a1a2e; - --bg-card: #1e1e2e; - --bg-card-hover: #2a2a3e; - --bg-input: #2a2a3e; - --border: rgba(255, 255, 255, 0.1); - --border-hover: rgba(255, 255, 255, 0.2); - --text: #fff; - --text-muted: #888; - --text-dim: #666; - --accent: #667eea; - --accent-end: #764ba2; - --success: #00b894; - --success-end: #00cec9; - --danger: #e74c3c; - --warning: #fdcb6e; - --link-color: #00cec9; + --bg-primary: #16181c; + --bg-secondary: #20242b; + --bg-card: #262b33; + --bg-card-hover: #2e3440; + --bg-input: #1b2027; + --border: rgba(255, 255, 255, 0.08); + --border-hover: rgba(255, 255, 255, 0.18); + --text: #edf1f7; + --text-muted: #9ea7b3; + --text-dim: #727b88; + --accent: #3ea7ff; + --accent-end: #65d8ff; + --success: #43c788; + --success-end: #89e0b0; + --danger: #f06f5a; + --warning: #f0c36c; + --link-color: #7edcff; + --panel-shadow: 0 14px 30px rgba(0, 0, 0, 0.22); } * { margin: 0; padding: 0; box-sizing: border-box; } body { - font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; - background: linear-gradient(135deg, var(--bg-primary) 0%, var(--bg-secondary) 50%, #16213e 100%); + font-family: 'Aptos', 'Segoe UI Variable Text', 'Bahnschrift', sans-serif; + background: + radial-gradient(circle at top right, rgba(62, 167, 255, 0.08), transparent 28%), + linear-gradient(180deg, #14171b 0%, #191d23 100%); min-height: 100vh; color: var(--text); user-select: none; @@ -36,22 +39,24 @@ body { gap: 2px; padding: 8px 16px 0; border-bottom: 1px solid var(--border); - background: rgba(0, 0, 0, 0.2); + background: linear-gradient(180deg, rgba(255, 255, 255, 0.03), rgba(0, 0, 0, 0.08)); flex-shrink: 0; } .tab { - padding: 8px 20px; + padding: 10px 18px 9px; background: transparent; border: none; color: var(--text-muted); - font-size: 13px; + font-size: 12px; + letter-spacing: 0.04em; + text-transform: uppercase; 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.active { color: var(--text); border-bottom-color: var(--accent); background: rgba(255, 255, 255, 0.03); } .version-label { margin-left: auto; @@ -82,59 +87,41 @@ body { /* Buttons */ .btn { padding: 8px 16px; - border: none; - border-radius: 6px; + border: 1px solid transparent; + border-radius: 8px; cursor: pointer; - font-size: 13px; - font-weight: 500; + font-size: 12px; + font-weight: 600; + letter-spacing: 0.02em; 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 { background: linear-gradient(180deg, var(--accent-end), var(--accent)); color: #092033; box-shadow: inset 0 1px 0 rgba(255,255,255,0.28); } +.btn-primary:hover { filter: brightness(1.05); transform: translateY(-1px); } .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-secondary { background: linear-gradient(180deg, rgba(255,255,255,0.02), rgba(0,0,0,0.08)); color: var(--text-muted); border: 1px solid var(--border); } +.btn-secondary:hover { border-color: var(--border-hover); color: var(--text); background: rgba(255,255,255,0.05); } +.btn-success { background: linear-gradient(180deg, var(--success-end), var(--success)); color: #082616; box-shadow: inset 0 1px 0 rgba(255,255,255,0.22); } +.btn-success:hover { filter: brightness(1.05); transform: translateY(-1px); } .btn-success:disabled { opacity: 0.5; cursor: default; filter: none; } -.btn-danger { background: var(--danger); color: #fff; } -.btn-danger:hover { filter: brightness(1.1); } +.btn-danger { background: linear-gradient(180deg, #ff8e74, var(--danger)); color: #fff; } +.btn-danger:hover { filter: brightness(1.05); transform: translateY(-1px); } /* Upload Toolbar */ .upload-toolbar { display: flex; align-items: center; gap: 8px; - padding: 8px 16px; + padding: 10px 16px; border-bottom: 1px solid var(--border); - background: rgba(0, 0, 0, 0.15); + background: linear-gradient(180deg, rgba(255, 255, 255, 0.04), rgba(0, 0, 0, 0.08)); 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; } +.hoster-summary { font-size: 12px; color: var(--text-muted); } /* Health check */ .health-check-inline { display: flex; align-items: center; gap: 4px; } @@ -153,6 +140,12 @@ body { .health-tag { font-weight: 600; } /* Drop Zone */ +.upload-workspace { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; +} .drop-zone { display: flex; flex-direction: column; @@ -160,21 +153,33 @@ body { justify-content: center; flex: 1; margin: 16px; - border: 2px dashed var(--border); - border-radius: 12px; + border: 1px dashed rgba(126, 220, 255, 0.28); + border-radius: 18px; cursor: pointer; transition: all 0.3s; color: var(--text-muted); min-height: 200px; + background: + linear-gradient(180deg, rgba(255,255,255,0.02), rgba(0,0,0,0.08)), + repeating-linear-gradient(135deg, rgba(255,255,255,0.015), rgba(255,255,255,0.015) 12px, transparent 12px, transparent 24px); + box-shadow: var(--panel-shadow); } -.drop-zone:hover, .drop-zone.drag-over { border-color: var(--accent); background: rgba(102, 126, 234, 0.05); } +.drop-zone:hover, .drop-zone.drag-over { border-color: rgba(126, 220, 255, 0.6); background-color: rgba(62, 167, 255, 0.06); } .drop-icon { font-size: 40px; margin-bottom: 8px; } /* Queue Container */ +.queue-shell { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; +} .queue-container { flex: 1; + min-height: 220px; overflow: auto; padding: 0; + background: rgba(255, 255, 255, 0.02); } /* Queue Table */ @@ -192,7 +197,7 @@ body { .queue-table th { padding: 5px 8px; text-align: left; - background: #12121f; + background: linear-gradient(180deg, #2a313b, #20252d); color: var(--text-muted); font-weight: 600; font-size: 10px; @@ -216,7 +221,7 @@ body { .queue-table td { padding: 4px 8px; - border-bottom: 1px solid rgba(255, 255, 255, 0.03); + border-bottom: 1px solid rgba(255, 255, 255, 0.035); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -224,7 +229,7 @@ body { /* Queue Row States */ .queue-row { transition: background 0.15s; cursor: pointer; } -.queue-row:hover { background: rgba(255, 255, 255, 0.03); } +.queue-row:hover { background: rgba(255, 255, 255, 0.04); } .queue-row.selected { background: rgba(102, 126, 234, 0.12) !important; } .queue-row.status-uploading { background: rgba(102, 126, 234, 0.08); } @@ -278,10 +283,180 @@ body { gap: 6px; padding: 6px 16px; border-top: 1px solid var(--border); - background: rgba(0, 0, 0, 0.1); + background: rgba(0, 0, 0, 0.14); flex-shrink: 0; } +.recent-files-panel { + min-height: 220px; + max-height: 280px; + display: flex; + flex-direction: column; + border-top: 1px solid var(--border); + background: linear-gradient(180deg, rgba(255,255,255,0.015), rgba(0,0,0,0.12)); +} +.recent-files-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 8px 16px; + border-bottom: 1px solid var(--border); + background: rgba(0, 0, 0, 0.12); +} +.recent-files-header h3 { + font-size: 13px; + font-weight: 600; +} +.recent-files-hint { + font-size: 11px; + color: var(--text-dim); +} +.recent-files-table-wrap { + flex: 1; + min-height: 0; + overflow: auto; +} +.recent-files-table { + width: 100%; + border-collapse: collapse; + font-size: 11px; + table-layout: fixed; +} +.recent-files-table th { + position: sticky; + top: 0; + z-index: 2; + padding: 6px 8px; + text-align: left; + background: linear-gradient(180deg, #2a313b, #20252d); + color: var(--text-muted); + font-weight: 600; + font-size: 10px; + text-transform: uppercase; + border-bottom: 1px solid var(--border); +} +.recent-files-table td { + padding: 5px 8px; + border-bottom: 1px solid rgba(255, 255, 255, 0.03); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.recent-file-row { + cursor: pointer; + transition: background 0.15s; +} +.recent-file-row:hover { + background: rgba(255, 255, 255, 0.03); +} +.recent-file-row.error { + color: var(--danger); + opacity: 0.75; +} + +.modal-overlay { + position: fixed; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: rgba(5, 7, 16, 0.72); + z-index: 2500; + padding: 24px; +} +.modal-card { + width: min(560px, 100%); + max-height: min(80vh, 700px); + display: flex; + flex-direction: column; + background: linear-gradient(180deg, rgba(30, 30, 46, 0.98), rgba(20, 20, 32, 0.98)); + border: 1px solid var(--border-hover); + border-radius: 16px; + box-shadow: 0 24px 60px rgba(0, 0, 0, 0.45); + overflow: hidden; +} +.modal-header, +.modal-footer { + padding: 14px 16px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} +.modal-header { + border-bottom: 1px solid var(--border); +} +.modal-header h3 { + font-size: 18px; + margin-bottom: 4px; +} +.modal-header p, +.modal-hint { + font-size: 12px; + color: var(--text-muted); +} +.modal-body { + padding: 14px 16px 10px; + overflow: auto; +} +.modal-actions-inline { + display: flex; + gap: 8px; + margin-bottom: 12px; +} +.hoster-modal-list { + display: grid; + gap: 10px; +} +.hoster-option { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 14px; + border: 1px solid var(--border); + border-radius: 10px; + background: rgba(255, 255, 255, 0.02); +} +.hoster-option.selected { + border-color: rgba(102, 126, 234, 0.65); + background: rgba(102, 126, 234, 0.12); +} +.hoster-option.disabled { + opacity: 0.45; +} +.hoster-option input { + width: 16px; + height: 16px; +} +.hoster-option-main { + flex: 1; +} +.hoster-option-title { + font-size: 13px; + font-weight: 600; +} +.hoster-option-subtitle { + font-size: 11px; + color: var(--text-muted); + margin-top: 2px; +} +.icon-btn { + border: 1px solid var(--border); + background: transparent; + color: var(--text-muted); + width: 32px; + height: 32px; + border-radius: 8px; + cursor: pointer; + font-size: 20px; + line-height: 1; +} +.icon-btn:hover { + color: var(--text); + border-color: var(--border-hover); +} + /* Context Menu */ .context-menu { position: fixed; @@ -323,6 +498,7 @@ body { /* Settings View */ .settings-container { padding: 16px; overflow: auto; flex: 1; } +.settings-container { background: linear-gradient(180deg, rgba(255,255,255,0.015), transparent 24%); } .settings-container h2 { margin-bottom: 4px; font-size: 18px; } .settings-hint { color: var(--text-muted); font-size: 12px; margin-bottom: 12px; } @@ -358,6 +534,10 @@ body { gap: 8px; margin-bottom: 6px; } +.checkbox-row input[type="checkbox"] { + width: 16px; + height: 16px; +} .settings-row label { font-size: 12px; color: var(--text-muted); @@ -398,7 +578,7 @@ body { } /* History View */ -.history-container { padding: 16px; overflow: auto; flex: 1; } +.history-container { padding: 16px; overflow: auto; flex: 1; background: linear-gradient(180deg, rgba(255,255,255,0.015), transparent 24%); } .history-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; } .history-header h2 { font-size: 18px; } @@ -494,6 +674,15 @@ body { } .shutdown-box p { margin-bottom: 16px; font-size: 16px; } +@media (max-width: 900px) { + .recent-files-panel { + max-height: 220px; + } + .modal-overlay { + padding: 12px; + } +} + /* Scrollbars */ ::-webkit-scrollbar { width: 8px; height: 8px; } ::-webkit-scrollbar-track { background: transparent; } diff --git a/tests/config-store.test.js b/tests/config-store.test.js index 212cd66..3cec227 100644 --- a/tests/config-store.test.js +++ b/tests/config-store.test.js @@ -42,6 +42,9 @@ describe('ConfigStore', () => { assert.equal(config.hosterSettings['doodstream.com'].parallelCount, 2); assert.equal(config.globalSettings.alwaysOnTop, false); assert.equal(config.globalSettings.shutdownAfterFinish, 'nothing'); + assert.equal(config.globalSettings.logFilePath, ''); + assert.equal(config.globalSettings.resumeQueueOnLaunch, true); + assert.equal(config.globalSettings.pendingQueue, null); assert.deepEqual(config.history, []); }); @@ -119,5 +122,7 @@ describe('ConfigStore', () => { const config = store.load(); assert.equal(config.globalSettings.alwaysOnTop, true); assert.equal(config.globalSettings.shutdownAfterFinish, 'nothing'); // default + assert.equal(config.globalSettings.resumeQueueOnLaunch, true); + assert.equal(config.globalSettings.logFilePath, ''); }); }); diff --git a/tests/hosters.test.js b/tests/hosters.test.js new file mode 100644 index 0000000..9a54b2f --- /dev/null +++ b/tests/hosters.test.js @@ -0,0 +1,34 @@ +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); + +const { __test } = require('../lib/hosters'); + +describe('hosters helpers', () => { + it('extracts VOE file_code from nested result payloads', () => { + assert.deepEqual(__test.parseVoeResult({ result: { file: { file_code: 'abc123' } } }), { + download_url: 'https://voe.sx/abc123', + embed_url: 'https://voe.sx/e/abc123', + file_code: 'abc123' + }); + }); + + it('extracts VOE file_code from flat fallback payloads', () => { + assert.deepEqual(__test.parseVoeResult({ file_code: 'xyz789' }), { + download_url: 'https://voe.sx/xyz789', + embed_url: 'https://voe.sx/e/xyz789', + file_code: 'xyz789' + }); + }); + + it('extracts upload server URLs from nested API responses', () => { + const url = __test.extractUploadServerUrl({ + result: { + server: { + upload_url: 'https://delivery-hydra.voe-network.net/upload/01' + } + } + }, 'https://voe.sx'); + + assert.equal(url, 'https://delivery-hydra.voe-network.net/upload/01'); + }); +});