Tauri 2 / Rust rewrite — initial 2.0 scaffold

Working:
  - Core: config, secret encryption, events, throttle
  - Upload manager with full rotation/classifier parity to v1
  - Clouddrop uploader (simple + chunked upload.clouddrop.cc)
  - Byse uploader with file-list polling for empty-filecode case
  - Vidmoly uploader (new /api/auth/login + /api/upload/config + X-Progress-ID)
  - Minimal frontend (accounts, settings, upload table, rotation log)
  - Release build: exe 6.9 MB, NSIS installer 2.5 MB, MSI 3.4 MB

Stubs (return 'not yet ported' error):
  - Doodstream (web login + CSRF — v1 scraper needs careful port)
  - VOE (web login + CSRF + delivery-node negotiation)

Not yet migrated from v1:
  - Queue persistence on restart
  - Folder monitor
  - Remote-control server
  - Drop-target floating window
  - Auto-updater
This commit is contained in:
Claude
2026-04-20 17:08:00 +02:00
commit 8627a8e694
28 changed files with 10540 additions and 0 deletions
+463
View File
@@ -0,0 +1,463 @@
// Multi-Hoster-Upload 2.0 — minimal frontend demonstrating the Tauri bridge.
// Uses the global Tauri runtime (withGlobalTauri: true).
const { invoke } = window.__TAURI__.core;
const { listen } = window.__TAURI__.event;
const dialog = window.__TAURI__.dialog;
let config = null;
let selectedFiles = [];
let selectedHosters = [];
let queueJobs = [];
const jobById = new Map();
let uploading = false;
function $(id) { return document.getElementById(id); }
function escHtml(s) { return String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;' })[c]); }
function uuid() { return 'j-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8); }
function showToast(msg, ms) {
const el = $('toast');
el.textContent = msg;
el.classList.add('show');
clearTimeout(showToast._t);
showToast._t = setTimeout(() => el.classList.remove('show'), ms || 2200);
}
// Tab switching
(function () {
const tabs = document.querySelectorAll('.tab');
const views = document.querySelectorAll('.view');
let active = document.querySelector('.tab.active');
tabs.forEach(function (t) {
t.addEventListener('click', function () {
if (t === active) return;
if (active) active.classList.remove('active');
t.classList.add('active');
views.forEach(function (v) { v.classList.toggle('active', v.id === t.dataset.view + '-view'); });
active = t;
if (t.dataset.view === 'log') refreshLog();
});
});
})();
async function loadConfig() {
config = await invoke('get_config');
renderAccounts();
renderHosterCheckboxes();
renderSettings();
}
function hosterLabel(h) {
const map = { 'clouddrop.cc': 'Clouddrop', 'byse.sx': 'Byse', 'vidmoly.me': 'Vidmoly',
'doodstream.com': 'Doodstream', 'voe.sx': 'VOE' };
return map[h] || h;
}
function accountLabel(h, a) {
return a.label || a.username || (a.api_key ? 'API ' + String(a.api_key).slice(0, 8) + String.fromCharCode(8230) : 'Account ' + String(a.id).slice(-6));
}
function hasCreds(_h, a) {
if (a.auth_type === 'api') return !!a.api_key;
if (a.auth_type === 'login') return !!(a.username && a.password);
return !!a.api_key || !!(a.username && a.password);
}
function statusLabel(s) {
const map = { preview: 'Vorschau', queued: 'Wartet', 'getting-server': 'Server...', uploading: 'Upload',
retrying: 'Retry', done: 'Fertig', error: 'Fehler', aborted: 'Abgebrochen' };
return map[s] || s;
}
// Accounts
function renderAccounts() {
const list = $('accountsList');
while (list.firstChild) list.removeChild(list.firstChild);
if (!config || !config.hosters) {
const p = document.createElement('p');
p.textContent = 'Kein Config geladen.';
list.appendChild(p);
return;
}
let anyAccount = false;
for (const hoster of Object.keys(config.hosters)) {
const accounts = config.hosters[hoster] || [];
if (!accounts.length) continue;
anyAccount = true;
const h = document.createElement('h3');
h.style.margin = '12px 0 6px';
h.textContent = hosterLabel(hoster);
list.appendChild(h);
accounts.forEach(function (a, idx) {
list.appendChild(buildAccountCard(hoster, a, idx));
});
}
if (!anyAccount) {
const p = document.createElement('p');
p.textContent = 'Keine Accounts. Klicke oben "+ Account hinzufügen".';
list.appendChild(p);
}
}
function buildAccountCard(hoster, a, idx) {
const disabled = a.enabled === false;
const card = document.createElement('div');
card.className = 'account-card' + (disabled ? ' disabled' : '');
card.dataset.hoster = hoster;
card.dataset.id = a.id;
const info = document.createElement('div');
info.className = 'account-info';
const title = document.createElement('div');
title.className = 'title';
title.textContent = accountLabel(hoster, a);
const prio = document.createElement('span');
prio.style.color = 'var(--text-dim)';
prio.style.fontSize = '11px';
prio.style.marginLeft = '6px';
prio.textContent = '#' + (idx + 1);
title.appendChild(prio);
const sub = document.createElement('div');
sub.className = 'sub';
sub.textContent = a.auth_type === 'api' ? 'API Key' : ('Login ' + (a.username || ''));
info.appendChild(title);
info.appendChild(sub);
const status = document.createElement('span');
status.className = 'account-status';
status.textContent = disabled ? 'Deaktiviert' : 'Aktiv';
const toggleBtn = document.createElement('button');
toggleBtn.className = 'btn';
toggleBtn.dataset.act = 'toggle';
toggleBtn.textContent = disabled ? 'Aktivieren' : 'Deaktivieren';
toggleBtn.addEventListener('click', onAccountAction);
const delBtn = document.createElement('button');
delBtn.className = 'btn';
delBtn.dataset.act = 'delete';
delBtn.textContent = 'Löschen';
delBtn.addEventListener('click', onAccountAction);
card.appendChild(info);
card.appendChild(status);
card.appendChild(toggleBtn);
card.appendChild(delBtn);
return card;
}
async function onAccountAction(e) {
const card = e.currentTarget.closest('.account-card');
const hoster = card.dataset.hoster;
const id = card.dataset.id;
const act = e.currentTarget.dataset.act;
if (!config.hosters[hoster]) return;
if (act === 'toggle') {
const acc = config.hosters[hoster].find(function (a) { return a.id === id; });
if (acc) acc.enabled = !acc.enabled;
} else if (act === 'delete') {
if (!confirm('Account wirklich löschen?')) return;
config.hosters[hoster] = config.hosters[hoster].filter(function (a) { return a.id !== id; });
}
await invoke('save_config', { config: config });
renderAccounts();
renderHosterCheckboxes();
}
$('addAccountBtn').addEventListener('click', function () {
$('accUsername').value = '';
$('accPassword').value = '';
$('accApiKey').value = '';
onAccHosterChange();
$('accountModal').style.display = 'flex';
});
function onAccHosterChange() {
const h = $('accHoster').value;
const needsLogin = h === 'vidmoly.me' || h === 'doodstream.com' || h === 'voe.sx';
$('accLoginRow').style.display = needsLogin ? '' : 'none';
$('accPasswordRow').style.display = needsLogin ? '' : 'none';
$('accApiKeyRow').style.display = needsLogin ? 'none' : '';
}
$('accHoster').addEventListener('change', onAccHosterChange);
$('accCancelBtn').addEventListener('click', function () { $('accountModal').style.display = 'none'; });
$('accSaveBtn').addEventListener('click', async function () {
const hoster = $('accHoster').value;
const needsLogin = $('accLoginRow').style.display !== 'none';
const acc = {
id: hoster + '-' + Date.now() + '-' + Math.random().toString(36).slice(2, 6),
enabled: true,
auth_type: needsLogin ? 'login' : 'api',
username: needsLogin ? $('accUsername').value.trim() : '',
password: needsLogin ? $('accPassword').value : '',
api_key: needsLogin ? '' : $('accApiKey').value.trim(),
label: null,
};
if (!config.hosters[hoster]) config.hosters[hoster] = [];
config.hosters[hoster].push(acc);
await invoke('save_config', { config: config });
$('accountModal').style.display = 'none';
renderAccounts();
renderHosterCheckboxes();
showToast('Account gespeichert');
});
// Hoster selection
function renderHosterCheckboxes() {
const container = $('hosterCheckboxes');
while (container.firstChild) container.removeChild(container.firstChild);
const available = ['clouddrop.cc', 'byse.sx', 'vidmoly.me', 'doodstream.com', 'voe.sx']
.filter(function (h) {
return config && config.hosters[h] && config.hosters[h].some(function (a) { return a.enabled !== false && hasCreds(h, a); });
});
if (!available.length) {
const s = document.createElement('span');
s.style.color = 'var(--text-dim)';
s.textContent = 'Keine Accounts mit Credentials';
container.appendChild(s);
return;
}
available.forEach(function (h) {
const lbl = document.createElement('label');
lbl.className = 'hoster-checkbox' + (selectedHosters.indexOf(h) >= 0 ? ' checked' : '');
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.value = h;
cb.checked = selectedHosters.indexOf(h) >= 0;
cb.addEventListener('change', function () {
selectedHosters = Array.from(container.querySelectorAll('input:checked')).map(function (c) { return c.value; });
renderHosterCheckboxes();
updateStartBtn();
});
lbl.appendChild(cb);
lbl.appendChild(document.createTextNode(' ' + hosterLabel(h)));
container.appendChild(lbl);
});
}
function updateStartBtn() {
$('startBtn').disabled = uploading || !selectedFiles.length || !selectedHosters.length;
}
// File picker
$('pickFilesBtn').addEventListener('click', async function () {
const picked = await dialog.open({ multiple: true, directory: false });
if (!picked) return;
const arr = Array.isArray(picked) ? picked : [picked];
arr.forEach(function (p) {
if (!selectedFiles.find(function (f) { return f.path === p; })) {
selectedFiles.push({ path: p, name: p.split(/[\\/]/).pop() });
}
});
renderQueuePreview();
updateStartBtn();
});
function renderQueuePreview() {
const tbody = $('queueBody');
while (tbody.firstChild) tbody.removeChild(tbody.firstChild);
if (!selectedFiles.length && !queueJobs.length) {
const tr = document.createElement('tr');
const td = document.createElement('td');
td.colSpan = 6;
td.style.color = 'var(--text-dim)';
td.style.textAlign = 'center';
td.style.padding = '20px';
td.textContent = 'Keine Dateien';
tr.appendChild(td);
tbody.appendChild(tr);
return;
}
if (queueJobs.length) {
queueJobs.forEach(function (j) { tbody.appendChild(buildQueueRow(j)); });
} else {
selectedFiles.forEach(function (f) {
selectedHosters.forEach(function (h) {
tbody.appendChild(buildPreviewRow(f, h));
});
});
}
}
function buildQueueRow(j) {
const tr = document.createElement('tr');
tr.className = 'queue-row status-' + j.status;
tr.dataset.id = j.id;
const pct = Math.round((j.progress || 0) * 100);
const link = j.result ? (j.result.download_url || '') : '';
const td1 = document.createElement('td'); td1.textContent = j.fileName || j.file_name;
const td2 = document.createElement('td'); td2.textContent = hosterLabel(j.hoster);
const td3 = document.createElement('td'); td3.textContent = statusLabel(j.status);
const td4 = document.createElement('td');
const bg = document.createElement('span'); bg.className = 'progress-bar-bg';
const fill = document.createElement('span'); fill.className = 'progress-bar-fill status-' + j.status; fill.style.width = pct + '%';
bg.appendChild(fill);
const pctSpan = document.createElement('span'); pctSpan.className = 'progress-pct'; pctSpan.textContent = pct + '%';
td4.appendChild(bg); td4.appendChild(pctSpan);
const td5 = document.createElement('td');
td5.textContent = j.speedKbs ? (j.speedKbs > 1024 ? (j.speedKbs/1024).toFixed(1) + ' MB/s' : j.speedKbs + ' KB/s') : '';
const td6 = document.createElement('td');
if (link) {
const a = document.createElement('a'); a.href = link; a.target = '_blank'; a.rel = 'noreferrer'; a.textContent = link;
td6.appendChild(a);
}
tr.appendChild(td1); tr.appendChild(td2); tr.appendChild(td3); tr.appendChild(td4); tr.appendChild(td5); tr.appendChild(td6);
return tr;
}
function buildPreviewRow(f, h) {
const tr = document.createElement('tr');
tr.className = 'queue-row status-preview';
['name','hoster','Vorschau','','',''].forEach(function () {});
const td1 = document.createElement('td'); td1.textContent = f.name;
const td2 = document.createElement('td'); td2.textContent = hosterLabel(h);
const td3 = document.createElement('td'); td3.textContent = 'Vorschau';
const td4 = document.createElement('td');
const td5 = document.createElement('td');
const td6 = document.createElement('td');
tr.appendChild(td1); tr.appendChild(td2); tr.appendChild(td3); tr.appendChild(td4); tr.appendChild(td5); tr.appendChild(td6);
return tr;
}
// Start batch
$('startBtn').addEventListener('click', async function () {
if (!selectedFiles.length || !selectedHosters.length) return;
const jobs = [];
queueJobs = [];
jobById.clear();
selectedFiles.forEach(function (file) {
selectedHosters.forEach(function (hoster) {
const accounts = config.hosters[hoster] || [];
const primary = accounts.find(function (a) { return a.enabled && hasCreds(hoster, a); });
if (!primary) return;
const job = {
id: uuid(),
upload_id: uuid(),
file: file.path,
file_name: file.name,
hoster: hoster,
account_id: primary.id,
username: primary.username || '',
password: primary.password || '',
api_key: primary.api_key || '',
max_attempts: 3,
};
jobs.push(job);
const jobCopy = Object.assign({}, job, { fileName: job.file_name, status: 'queued', progress: 0 });
queueJobs.push(jobCopy);
jobById.set(job.id, jobCopy);
});
});
if (!jobs.length) { showToast('Keine gültigen Jobs — prüfe Accounts'); return; }
uploading = true;
updateStartBtn();
$('cancelBtn').disabled = false;
renderQueuePreview();
try {
await invoke('start_batch', {
payload: {
jobs: jobs,
hoster_settings: config.hosterSettings || {},
global_settings: config.globalSettings || {},
accounts: config.hosters,
},
});
showToast('Batch abgeschlossen');
} catch (err) {
showToast('Batch-Fehler: ' + err);
} finally {
uploading = false;
updateStartBtn();
$('cancelBtn').disabled = true;
}
});
$('cancelBtn').addEventListener('click', async function () {
await invoke('cancel_batch');
showToast('Abgebrochen');
});
// Events
listen('upload-progress', function (ev) {
const p = ev.payload;
const job = jobById.get(p.jobId);
if (!job) return;
job.status = p.status;
job.progress = p.progress;
job.speedKbs = p.speedKbs;
job.bytesUploaded = p.bytesUploaded;
job.bytesTotal = p.bytesTotal;
if (p.result) job.result = p.result;
if (p.error) job.error = p.error;
renderQueuePreview();
updateQueueStats();
});
listen('upload-stats', function (ev) {
const s = ev.payload;
$('queueStats').textContent =
'Aktiv: ' + s.activeJobs + ' | Wartet: ' + s.pendingJobs + ' | ' +
(s.globalSpeedKbs/1024).toFixed(1) + ' MB/s';
});
listen('upload-batch-done', function (ev) {
const s = ev.payload;
uploading = false;
updateStartBtn();
$('cancelBtn').disabled = true;
showToast('Batch fertig: ' + s.succeeded + '/' + s.total + ' erfolgreich');
});
listen('account-rotation-log', function (ev) {
const p = ev.payload;
const el = $('rotLog');
const extras = Object.keys(p).filter(function (k) { return k !== 'ts' && k !== 'event'; })
.map(function (k) { return k + '=' + (typeof p[k] === 'string' ? p[k] : JSON.stringify(p[k])); }).join(' ');
const line = '[' + new Date(p.ts).toISOString() + '] [' + p.event + '] ' + extras;
el.textContent = line + '\n' + el.textContent;
if (p.event === 'rotate') showToast(hosterLabel(p.hoster) + ': Account-Wechsel → Fallback');
if (p.event === 'final-error') showToast(hosterLabel(p.hoster) + ': Alle Accounts ausgeschöpft');
});
listen('account-switched', function (ev) {
const p = ev.payload;
showToast(hosterLabel(p.hoster) + ': ' + String(p.toAccountId).slice(-6) + ' aktiv');
});
function updateQueueStats() {
const total = queueJobs.length;
const done = queueJobs.filter(function (j) { return j.status === 'done'; }).length;
const err = queueJobs.filter(function (j) { return j.status === 'error'; }).length;
$('queueStats').textContent = done + '/' + total + ' fertig' + (err ? (' • ' + err + ' Fehler') : '');
}
// Settings
function renderSettings() {
if (!config) return;
$('globalSpeed').value = config.globalSettings.globalMaxSpeedKbs || 0;
$('globalParallel').value = config.globalSettings.parallelUploadCount || 0;
}
$('saveSettingsBtn').addEventListener('click', async function () {
config.globalSettings.globalMaxSpeedKbs = parseInt($('globalSpeed').value) || 0;
config.globalSettings.parallelUploadCount = parseInt($('globalParallel').value) || 0;
await invoke('save_config', { config: config });
showToast('Gespeichert');
});
// Log
async function refreshLog() {
try {
const content = await invoke('read_rotation_log');
$('rotLog').textContent = content || '(noch keine Rotation-Events)';
} catch (e) { $('rotLog').textContent = 'Fehler: ' + e; }
}
$('refreshLogBtn').addEventListener('click', refreshLog);
$('openLogFolderBtn').addEventListener('click', function () { invoke('open_log_folder').catch(function () {}); });
// Init
loadConfig().catch(function (err) {
const div = document.createElement('div');
div.style.padding = '20px';
div.style.color = 'var(--danger)';
div.textContent = 'Config-Fehler: ' + err;
document.body.insertBefore(div, document.body.firstChild);
});
renderQueuePreview();
+122
View File
@@ -0,0 +1,122 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<title>Multi-Hoster-Upload 2.0</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<header class="app-header">
<div class="app-title">Multi-Hoster-Upload <span class="ver">v2.0</span></div>
<nav class="tabs">
<button class="tab active" data-view="upload">Upload</button>
<button class="tab" data-view="accounts">Accounts</button>
<button class="tab" data-view="settings">Einstellungen</button>
<button class="tab" data-view="log">Rotation-Log</button>
</nav>
</header>
<main>
<section class="view active" id="upload-view">
<div class="drop-zone" id="dropZone">
<div class="drop-hint">Dateien hierher ziehen oder Button klicken</div>
<button class="btn btn-primary" id="pickFilesBtn">+ Dateien wählen</button>
</div>
<div class="hoster-picker">
<label>Upload zu:</label>
<div id="hosterCheckboxes"></div>
</div>
<div class="queue-shell">
<div class="queue-toolbar">
<button class="btn btn-primary" id="startBtn" disabled>▶ Upload starten</button>
<button class="btn" id="cancelBtn" disabled>✕ Abbrechen</button>
<span class="queue-stats" id="queueStats"></span>
</div>
<table class="queue-table">
<thead>
<tr>
<th>Datei</th>
<th>Hoster</th>
<th>Status</th>
<th>Progress</th>
<th>Speed</th>
<th>Link</th>
</tr>
</thead>
<tbody id="queueBody"></tbody>
</table>
</div>
</section>
<section class="view" id="accounts-view">
<div class="actions-bar">
<button class="btn btn-primary" id="addAccountBtn">+ Account hinzufügen</button>
</div>
<div id="accountsList" class="accounts-list"></div>
</section>
<section class="view" id="settings-view">
<div class="settings">
<h2>Globale Einstellungen</h2>
<div class="setting-row">
<label>Gesamt-Upload-Limit (KB/s, 0 = unlimitiert)</label>
<input type="number" id="globalSpeed" min="0" value="0" />
</div>
<div class="setting-row">
<label>Parallele Uploads über alle Hoster (0 = nur pro Hoster)</label>
<input type="number" id="globalParallel" min="0" max="100" value="0" />
</div>
<button class="btn btn-primary" id="saveSettingsBtn">Speichern</button>
</div>
</section>
<section class="view" id="log-view">
<div class="actions-bar">
<button class="btn" id="refreshLogBtn">Aktualisieren</button>
<button class="btn" id="openLogFolderBtn">Log-Ordner öffnen</button>
</div>
<pre id="rotLog" class="log-pre"></pre>
</section>
</main>
<!-- Account modal -->
<div class="modal-overlay" id="accountModal" style="display:none">
<div class="modal-card">
<h3>Account hinzufügen</h3>
<div class="setting-row">
<label>Hoster</label>
<select id="accHoster">
<option value="clouddrop.cc">Clouddrop (API)</option>
<option value="byse.sx">Byse (API)</option>
<option value="vidmoly.me">Vidmoly (Login)</option>
<option value="doodstream.com">Doodstream (nicht in 2.0 POC)</option>
<option value="voe.sx">VOE (nicht in 2.0 POC)</option>
</select>
</div>
<div class="setting-row" id="accLoginRow" style="display:none">
<label>Username</label>
<input type="text" id="accUsername" />
</div>
<div class="setting-row" id="accPasswordRow" style="display:none">
<label>Passwort</label>
<input type="password" id="accPassword" />
</div>
<div class="setting-row" id="accApiKeyRow">
<label>API Key</label>
<input type="password" id="accApiKey" />
</div>
<div class="modal-footer">
<button class="btn" id="accCancelBtn">Abbrechen</button>
<button class="btn btn-primary" id="accSaveBtn">Speichern</button>
</div>
</div>
</div>
<div class="toast" id="toast"></div>
<script type="module" src="app.js"></script>
</body>
</html>
+334
View File
@@ -0,0 +1,334 @@
* { box-sizing: border-box; }
:root {
--bg: #16181c;
--bg-alt: #1c1f24;
--bg-hover: #24282e;
--border: #2d3139;
--text: #e6e8eb;
--text-dim: #9aa3ae;
--accent: #4aa3ff;
--accent-hover: #6ab5ff;
--danger: #ff5a5f;
--success: #3ece78;
--warn: #ffbf4a;
}
html, body {
margin: 0;
padding: 0;
height: 100%;
background: var(--bg);
color: var(--text);
font: 13px/1.5 "Segoe UI", system-ui, sans-serif;
overflow: hidden;
}
body {
display: flex;
flex-direction: column;
}
.app-header {
display: flex;
align-items: center;
gap: 24px;
padding: 8px 16px;
background: var(--bg-alt);
border-bottom: 1px solid var(--border);
}
.app-title {
font-weight: 600;
font-size: 14px;
}
.app-title .ver {
color: var(--accent);
font-weight: normal;
margin-left: 4px;
}
.tabs {
display: flex;
gap: 4px;
}
.tab {
background: transparent;
color: var(--text-dim);
border: none;
padding: 8px 14px;
cursor: pointer;
border-radius: 4px;
font-size: 13px;
}
.tab:hover { background: var(--bg-hover); color: var(--text); }
.tab.active { background: var(--accent); color: #fff; }
main {
flex: 1;
overflow: hidden;
position: relative;
}
.view {
display: none;
height: 100%;
overflow: auto;
padding: 16px;
}
.view.active { display: block; }
.drop-zone {
border: 2px dashed var(--border);
border-radius: 6px;
padding: 24px;
text-align: center;
margin-bottom: 16px;
transition: border-color 0.15s;
}
.drop-zone.hover { border-color: var(--accent); background: rgba(74, 163, 255, 0.05); }
.drop-hint {
color: var(--text-dim);
margin-bottom: 12px;
}
.hoster-picker {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.hoster-picker label { font-weight: 500; }
.hoster-checkbox {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
border: 1px solid var(--border);
border-radius: 4px;
cursor: pointer;
margin-right: 6px;
background: var(--bg-alt);
}
.hoster-checkbox input { margin: 0; }
.hoster-checkbox.checked { border-color: var(--accent); background: rgba(74, 163, 255, 0.12); }
.queue-shell {
background: var(--bg-alt);
border: 1px solid var(--border);
border-radius: 6px;
overflow: hidden;
}
.queue-toolbar {
display: flex;
gap: 8px;
padding: 8px 12px;
align-items: center;
border-bottom: 1px solid var(--border);
}
.queue-stats {
margin-left: auto;
font-size: 12px;
color: var(--text-dim);
}
.queue-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.queue-table th, .queue-table td {
padding: 6px 10px;
border-bottom: 1px solid var(--border);
text-align: left;
}
.queue-table th {
background: var(--bg);
color: var(--text-dim);
font-weight: 500;
position: sticky;
top: 0;
}
.queue-row.status-done td { color: var(--success); }
.queue-row.status-error td { color: var(--danger); }
.queue-row.status-uploading td { color: var(--accent); }
.progress-bar-bg {
width: 120px;
height: 10px;
background: var(--bg);
border-radius: 5px;
overflow: hidden;
display: inline-block;
vertical-align: middle;
}
.progress-bar-fill {
height: 100%;
background: var(--accent);
transition: width 0.2s ease-out;
}
.progress-bar-fill.status-done { background: var(--success); }
.progress-bar-fill.status-error { background: var(--danger); }
.progress-pct { margin-left: 6px; font-size: 11px; color: var(--text-dim); }
.actions-bar {
margin-bottom: 12px;
display: flex;
gap: 8px;
}
.accounts-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.account-card {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 14px;
background: var(--bg-alt);
border: 1px solid var(--border);
border-radius: 6px;
}
.account-card.disabled { opacity: 0.5; }
.account-info {
flex: 1;
}
.account-info .title { font-weight: 600; }
.account-info .sub { color: var(--text-dim); font-size: 12px; }
.account-status {
padding: 3px 8px;
border-radius: 3px;
font-size: 11px;
background: rgba(255,255,255,0.06);
}
.account-status.ok { background: rgba(62, 206, 120, 0.2); color: var(--success); }
.account-status.error { background: rgba(255, 90, 95, 0.2); color: var(--danger); }
.settings {
max-width: 600px;
}
.setting-row {
margin: 10px 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.setting-row label {
color: var(--text-dim);
font-size: 12px;
}
.setting-row input, .setting-row select {
background: var(--bg);
color: var(--text);
border: 1px solid var(--border);
border-radius: 4px;
padding: 6px 10px;
font-size: 13px;
}
.setting-row input:focus, .setting-row select:focus {
outline: none;
border-color: var(--accent);
}
.btn {
background: var(--bg-alt);
color: var(--text);
border: 1px solid var(--border);
padding: 7px 14px;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
transition: background 0.1s;
}
.btn:hover { background: var(--bg-hover); }
.btn:disabled { opacity: 0.45; cursor: not-allowed; }
.btn-primary {
background: var(--accent);
color: #fff;
border-color: var(--accent);
}
.btn-primary:hover { background: var(--accent-hover); }
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
}
.modal-card {
background: var(--bg-alt);
border: 1px solid var(--border);
border-radius: 8px;
padding: 20px;
width: min(420px, 92vw);
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
}
.modal-card h3 { margin-top: 0; }
.modal-footer {
margin-top: 16px;
display: flex;
gap: 8px;
justify-content: flex-end;
}
.log-pre {
background: var(--bg-alt);
border: 1px solid var(--border);
border-radius: 6px;
padding: 12px;
overflow: auto;
font-family: "Cascadia Mono", Consolas, monospace;
font-size: 11px;
color: var(--text-dim);
white-space: pre-wrap;
max-height: calc(100vh - 140px);
}
.toast {
position: fixed;
bottom: 16px;
left: 50%;
transform: translateX(-50%) translateY(40px);
background: var(--bg-alt);
color: var(--text);
border: 1px solid var(--border);
padding: 10px 16px;
border-radius: 6px;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s, transform 0.2s;
z-index: 200;
}
.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }