first commit

This commit is contained in:
Mateusz Gruszczyński committed 2026-10-15 14:15:10 +02:00
commit 58cfd95600
35 files changed
+5357

No files matched your search

+577
View File
@@ -0,0 +1,577 @@
'use strict';
const $ = (id) => document.getElementById(id);
const NS = 'http://www.w3.org/2000/svg';
const UNITS = ['B', 'KiB', 'MiB', 'GiB', 'TiB'];
const ACTIVE = new Set(['downloading', 'preparing', 'extracting', 'converting', 'vimm_browser']);
const LABELS = {
queued: 'W kolejce', downloading: 'Pobieranie', paused: 'Wstrzymano',
preparing: 'Przygotowanie', extracting: 'Rozpakowanie', converting: 'Konwersja',
done: 'Gotowe', error: 'Błąd', cancelled: 'Anulowano', vimm_browser: 'Vimm'
};
const S = { archiveId: '', archiveTitle: '', archiveFiles: [], selected: new Set(),
jobs: [], filter: 'all', connected: false, hostRoot: '/xbox', magnetPreview: null,
magnetFiles: [], magnetSelected: new Set(), magnetName: '', magnetTitle: '', magnetFormat: 'xex' };
function normalizeMagnet(raw) {
return raw.trim()
.replace(/^[\'"`]*\*{0,2}magnet\*{0,2}:\?/i, 'magnet:?')
.replace(/&|&|&/gi, '&')
.replace(/\\\s*/g, '')
.replace(/\s+/g, '')
.replace(/[\'"`]+$/g, '');
}
function bytes(n) {
n = Math.max(0, Number(n) || 0);
let i = 0;
while (n >= 1024 && i < UNITS.length - 1) { n /= 1024; i++; }
return (i ? n.toFixed(1) : n.toFixed(0)) + ' ' + UNITS[i];
}
function el(tag, cls, text) {
const node = document.createElement(tag);
if (cls) node.className = cls;
if (text !== undefined && text !== null) node.textContent = text;
return node;
}
function icon(name) {
const svg = document.createElementNS(NS, 'svg');
svg.setAttribute('class', 'icon');
svg.setAttribute('aria-hidden', 'true');
const use = document.createElementNS(NS, 'use');
use.setAttribute('href', '#i-' + name);
svg.append(use);
return svg;
}
function makeButton(label, glyph, cls = 'secondary') {
const button = el('button', cls);
button.type = 'button';
button.append(icon(glyph), document.createTextNode(label));
return button;
}
async function api(path, options = {}) {
const response = await fetch('/api' + path, {
...options,
headers: { 'Content-Type': 'application/json', ...(options.headers || {}) }
});
let result;
try { result = await response.json(); }
catch { result = { detail: 'Nieprawidłowa odpowiedź serwera' }; }
if (!response.ok) {
throw new Error(typeof result.detail === 'string' ? result.detail : JSON.stringify(result.detail || result));
}
return result;
}
function connection(online) {
S.connected = online;
$('connectionState').classList.toggle('online', online);
$('connectionState').classList.toggle('offline', !online);
$('connectionText').textContent = online ? 'Połączono' : 'Brak połączenia';
}
function flash(message, bad = false) {
const t = $('toast');
t.textContent = message;
t.classList.toggle('bad', bad);
t.style.display = 'block';
clearTimeout(window._toastTimeout);
window._toastTimeout = setTimeout(() => { t.style.display = 'none'; }, 5000);
}
function filterJobs(jobs) {
if (S.filter === 'active') return jobs.filter(j => !['done', 'error', 'cancelled'].includes(j.state));
if (S.filter === 'done') return jobs.filter(j => j.state === 'done');
if (S.filter === 'error') return jobs.filter(j => j.state === 'error');
return jobs;
}
function sourceDescription(job) {
if (job.source_type === 'archive_many') {
try { return 'Internet Archive · ' + JSON.parse(job.source).length + ' plików'; }
catch { return 'Internet Archive'; }
}
if (job.source_type === 'import') return 'Import · ' + job.source;
if (job.source_type === 'magnet') return 'Magnet';
if (job.source_type === 'torrent_selected') {
try { return 'Torrent · ' + JSON.parse(job.source).count + ' plików'; }
catch { return 'Torrent'; }
}
if (job.source_type === 'vimm') return 'Vimm · ' + job.source;
return job.source;
}
function jobAction(label, glyph, action, job, danger = false) {
const button = makeButton(label, glyph, 'action' + (danger ? ' danger' : ''));
button.addEventListener('click', async () => {
if (action === 'remove' && !confirm('Usunąć wpis? Pobrane pliki zostaną na dysku.')) return;
if (action === 'cancel' && !confirm('Anulować to zadanie?')) return;
button.disabled = true;
try {
await api('/jobs/' + encodeURIComponent(job.id) + '/' + action, { method: 'POST' });
await refreshJobs();
} catch (e) { flash(e.message, true); }
finally { button.disabled = false; }
});
return button;
}
async function showLog(job) {
try {
const response = await fetch('/api/jobs/' + encodeURIComponent(job.id) + '/log');
if (!response.ok) throw new Error('HTTP ' + response.status);
$('logContent').textContent = await response.text();
$('logDialog').showModal();
} catch (e) { flash(e.message, true); }
}
function hostPath(path) {
if (typeof path !== 'string') return '';
if (path === '/data/raw') return S.hostRoot + '/downloads';
if (path.startsWith('/data/raw/')) return S.hostRoot + '/downloads/' + path.slice('/data/raw/'.length);
if (path === '/data') return S.hostRoot;
if (path.startsWith('/data/')) return S.hostRoot + '/' + path.slice('/data/'.length);
return path;
}
function jobRow(job) {
const card = el('article', 'job');
const header = el('div', 'job-top');
const heading = el('div', 'job-heading');
heading.append(el('div', 'job-name', job.title), el('div', 'job-sub', sourceDescription(job)));
header.append(heading);
const statusClass = job.state === 'done' ? 'done' : job.state === 'error' ? 'error' : ACTIVE.has(job.state) ? 'running' : '';
header.append(el('span', 'job-status ' + statusClass, LABELS[job.state] || job.state));
card.append(header);
const pct = Math.max(0, Math.min(100, Number(job.progress) || 0));
const unknown = job.state === 'vimm_browser';
const progress = el('div', 'job-progress');
const track = el('div', 'progressbar' + (job.state === 'error' ? ' is-error' : ''));
const fill = el('span');
fill.style.width = pct + '%';
track.append(fill);
progress.append(track, el('span', 'progress-text', unknown ? '—' : pct.toFixed(1) + '%'));
card.append(progress);
const meta = el('div', 'job-meta');
meta.append(el('span', 'note-text', job.note || 'Oczekiwanie'));
meta.append(el('span', 'speed', Number(job.speed) > 0 ? bytes(job.speed) + '/s' : ''));
card.append(meta);
if (job.state === 'error' && job.error) card.append(el('div', 'job-error', job.error));
if (job.output) card.append(el('div', 'job-output', 'Zapisano: ' + hostPath(job.output)));
const bottom = el('div', 'job-bottom');
const chips = el('div', 'job-chips');
chips.append(el('span', 'job-chip', (job.format || 'raw').toUpperCase()));
bottom.append(chips);
const actions = el('div', 'job-actions');
if (['queued', 'downloading'].includes(job.state)) {
actions.append(jobAction('Pauza', 'pause', 'pause', job), jobAction('Anuluj', 'x', 'cancel', job, true));
}
if (job.state === 'paused') {
actions.append(jobAction('Wznów', 'play', 'resume', job), jobAction('Anuluj', 'x', 'cancel', job, true));
}
if (['error', 'cancelled'].includes(job.state)) actions.append(jobAction('Ponów', 'retry', 'retry', job));
if (['error', 'done', 'cancelled', 'paused'].includes(job.state)) actions.append(jobAction('Usuń', 'trash', 'remove', job, true));
const log = makeButton('Log', 'file', 'action');
log.addEventListener('click', () => showLog(job));
actions.append(log);
bottom.append(actions);
card.append(bottom);
return card;
}
function renderJobs() {
$('jobsCount').textContent = String(S.jobs.length);
$('countQueued').textContent = String(S.jobs.filter(j => j.state === 'queued' || j.state === 'paused').length);
$('countActive').textContent = String(S.jobs.filter(j => ACTIVE.has(j.state)).length);
$('countDone').textContent = String(S.jobs.filter(j => j.state === 'done').length);
const list = $('jobsList');
list.replaceChildren();
const filtered = filterJobs(S.jobs);
if (!filtered.length) {
list.append(el('div', 'empty-state', S.filter === 'all' ? 'Brak zadań. Dodaj link powyżej.' : 'Brak zadań w tym widoku.'));
return;
}
for (const job of filtered) list.append(jobRow(job));
}
async function refreshJobs(showError = false) {
try {
S.jobs = await api('/jobs');
renderJobs();
connection(true);
} catch (e) {
connection(false);
if (showError) flash('Nie można odczytać kolejki: ' + e.message, true);
}
}
async function refreshSystem() {
try {
const info = await api('/system');
$('storageNum').textContent = bytes(info.free);
const percent = info.total > 0 ? (100 * (1 - info.free / info.total)) : 0;
$('storageFill').style.width = Math.max(0, Math.min(100, percent)) + '%';
$('storageText').textContent = bytes(info.total) + ' łącznie';
const hostRoot = String(info.host_data_dir || '/xbox').replace(/\/+$/, '');
S.hostRoot = hostRoot;
$('importHostPath').textContent = hostRoot + '/imports/';
$('readyXexPath').textContent = hostRoot + '/ready/xex/';
$('readyGodPath').textContent = hostRoot + '/ready/god/';
$('downloadHostPath').textContent = hostRoot + '/downloads/';
$('workHostPath').textContent = hostRoot + '/work/';
connection(true);
} catch {
$('storageText').textContent = 'Nie udało się odczytać dysku';
connection(false);
}
}
for (const tab of document.querySelectorAll('.tab')) {
tab.addEventListener('click', () => {
for (const other of document.querySelectorAll('.tab')) {
const selected = other === tab;
other.classList.toggle('active', selected);
other.setAttribute('aria-selected', String(selected));
}
for (const name of ['links', 'archive', 'import']) {
$('pane-' + name).classList.toggle('hidden', name !== tab.dataset.tab);
}
if (tab.dataset.tab === 'import') refreshImports();
});
}
for (const filter of document.querySelectorAll('.filter')) {
filter.addEventListener('click', () => {
S.filter = filter.dataset.filter;
for (const other of document.querySelectorAll('.filter')) {
const selected = other === filter;
other.classList.toggle('active', selected);
other.setAttribute('aria-pressed', String(selected));
}
renderJobs();
});
}
async function refreshArchiveSession() {
try {
const result = await api('/archive/session');
$('archiveSessionText').textContent = result.authenticated ? ('Sesja zapisana · ' + result.email) : 'Niezalogowano';
$('archiveLoginForm').classList.toggle('hidden', result.authenticated);
$('archiveLogout').classList.toggle('hidden', !result.authenticated);
} catch (e) { $('archiveSessionText').textContent = 'Błąd sprawdzania sesji'; }
}
$('archiveLoginForm').addEventListener('submit', async (event) => {
event.preventDefault();
const button = $('archiveLoginForm').querySelector('[type=submit]');
button.disabled = true;
try {
const result = await api('/archive/session', { method: 'POST', body: JSON.stringify({
email: $('archiveEmail').value.trim(), password: $('archivePassword').value
}) });
$('archivePassword').value = '';
flash('Zapisano sesję Archive.org: ' + result.email);
await refreshArchiveSession();
} catch (e) { $('archivePassword').value = ''; flash('Archive.org: ' + e.message, true); }
finally { button.disabled = false; }
});
$('archiveLogout').addEventListener('click', async () => {
try {
await api('/archive/session', { method: 'DELETE' });
await refreshArchiveSession();
flash('Usunięto zapisaną sesję Archive.org.');
} catch (e) { flash(e.message, true); }
});
function renderMagnet() {
const list = $('magnetFiles');
list.replaceChildren();
const text = $('magnetFilter').value.toLowerCase();
let displayed = 0;
for (const file of S.magnetFiles) {
if (!file.name.toLowerCase().includes(text)) continue;
if (displayed >= 500) break;
displayed++;
const row = el('label', 'file-row');
const box = el('input');
box.type = 'checkbox';
box.checked = S.magnetSelected.has(file.index);
box.disabled = Boolean(file.padding);
box.addEventListener('change', () => {
if (box.checked) S.magnetSelected.add(file.index);
else S.magnetSelected.delete(file.index);
$('magnetSelectedCount').textContent = 'Wybrano: ' + S.magnetSelected.size;
});
row.append(box, el('span', 'file-name', file.name),
el('span', 'size', file.padding ? 'Systemowy' : bytes(file.size)));
list.append(row);
}
if (!displayed) list.append(el('div', 'empty-state', 'Brak plików.'));
if (displayed >= 500) list.append(el('div', 'empty-state', 'Pierwsze 500 plików. Zawęź filtr, aby wyszukać kolejne.'));
$('magnetSelectedCount').textContent = 'Wybrano: ' + S.magnetSelected.size;
}
async function pollMagnet(previewId) {
if (S.magnetPreview !== previewId || !$('magnetDialog').open) return;
try {
const data = await api('/magnet/inspect/' + encodeURIComponent(previewId));
if (S.magnetPreview !== previewId || !$('magnetDialog').open) return;
if (data.status === 'ready') {
S.magnetName = data.name;
S.magnetFiles = data.files;
S.magnetSelected = new Set((data.suggested || []).filter(i => !data.files.find(f => f.index === i)?.padding));
$('magnetName').textContent = data.name + ' (' + data.files.length + ' plików)';
$('magnetStatus').textContent = 'Metadane pobrane. Wybierz pliki.';
$('magnetResults').classList.remove('hidden');
renderMagnet();
return;
}
if (data.status === 'loading') {
const seconds = Number(data.elapsed) || 0;
const connections = Number(data.connections) || 0;
const known = Number(data.known_peers) || 0;
let notice = 'Metadane: ' + seconds + ' s | połączenia: ' + connections + ' | znani peerzy: ' + known;
if (seconds >= 25 && connections === 0)
notice += ' | Brak połączeń. Sprawdź UDP/DHT lub wczytaj .torrent.';
$('magnetStatus').textContent = notice;
}
if (data.status === 'error') {
$('magnetStatus').textContent = 'Błąd: ' + data.error;
return;
}
} catch (e) {
$('magnetStatus').textContent = 'Błąd: ' + e.message;
return;
}
setTimeout(() => pollMagnet(previewId), 3000);
}
async function inspectMagnet(magnet, title, format) {
S.magnetPreview = null;
S.magnetTitle = title;
S.magnetFormat = format;
S.magnetFiles = [];
S.magnetSelected.clear();
$('magnetStatus').textContent = 'Oczekiwanie na metadane z sieci BitTorrent…';
$('magnetResults').classList.add('hidden');
$('magnetFilter').value = '';
$('magnetDialog').showModal();
try {
const data = await api('/magnet/inspect', { method: 'POST', body: JSON.stringify({ magnet }) });
if (!$('magnetDialog').open) return;
S.magnetPreview = data.preview_id;
pollMagnet(data.preview_id);
} catch (e) { $('magnetStatus').textContent = 'Błąd: ' + e.message; }
}
async function uploadTorrent(file, fromLinks) {
if (!file) return;
if (file.size > 32 * 1024 * 1024) return flash('Limit pliku .torrent: 32 MiB.', true);
if (fromLinks) {
S.magnetTitle = $('linkTitle').value.trim();
S.magnetFormat = $('linkFormat').value;
}
const previous = S.magnetPreview;
S.magnetPreview = null;
S.magnetFiles = [];
S.magnetSelected.clear();
$('magnetResults').classList.add('hidden');
$('magnetFilter').value = '';
$('magnetStatus').textContent = 'Wczytywanie pliku .torrent...';
if (!$('magnetDialog').open) $('magnetDialog').showModal();
if (previous) await api('/magnet/inspect/' + encodeURIComponent(previous) + '/cancel', { method: 'POST' }).catch(() => {});
try {
const response = await fetch('/api/magnet/upload', {
method: 'POST', headers: { 'Content-Type': 'application/x-bittorrent' }, body: file
});
const data = await response.json();
if (!response.ok) throw new Error(data.detail || 'HTTP ' + response.status);
if (!$('magnetDialog').open) return;
S.magnetPreview = data.preview_id;
await pollMagnet(data.preview_id);
} catch (error) {
$('magnetStatus').textContent = 'Błąd: ' + error.message;
}
}
for (const id of ['torrentFileLinks', 'torrentFileDialog']) {
$(id).addEventListener('change', async (event) => {
const file = event.target.files?.[0];
event.target.value = '';
await uploadTorrent(file, id === 'torrentFileLinks');
});
}
$('magnetFilter').addEventListener('input', renderMagnet);
$('magnetToggleAll').addEventListener('click', () => {
const visible = S.magnetFiles.filter(f => !f.padding && f.name.toLowerCase().includes($('magnetFilter').value.toLowerCase())).slice(0, 500);
const allSelected = visible.every(f => S.magnetSelected.has(f.index));
for (const file of visible) {
if (allSelected) S.magnetSelected.delete(file.index);
else S.magnetSelected.add(file.index);
}
renderMagnet();
});
$('closeMagnet').addEventListener('click', () => $('magnetDialog').close());
$('magnetDialog').addEventListener('close', () => {
const preview = S.magnetPreview;
S.magnetPreview = null;
if (preview) api('/magnet/inspect/' + encodeURIComponent(preview) + '/cancel', { method: 'POST' }).catch(() => {});
});
$('magnetAdd').addEventListener('click', async () => {
if (!S.magnetSelected.size) return flash('Zaznacz co najmniej jeden plik.', true);
const button = $('magnetAdd');
button.disabled = true;
try {
await api('/magnet/jobs', { method: 'POST', body: JSON.stringify({
preview_id: S.magnetPreview, files: [...S.magnetSelected],
title: S.magnetTitle, format: S.magnetFormat
}) });
$('urls').value = '';
S.magnetPreview = null;
$('magnetDialog').close();
flash('Wybrane pliki dodano do kolejki.');
await refreshJobs();
} catch (e) { flash(e.message, true); }
finally { button.disabled = false; }
});
$('linksForm').addEventListener('submit', async (event) => {
event.preventDefault();
const raw = $('urls').value.trim();
if (!raw) return flash('Podaj co najmniej jeden link.', true);
const button = $('linksForm').querySelector('[type=submit]');
const title = $('linkTitle').value.trim();
const format = $('linkFormat').value;
if (/^[\'"`]*\*{0,2}magnet\*{0,2}:\?/i.test(raw)) {
const magnet = normalizeMagnet(raw);
if ((magnet.match(/magnet:\?/gi) || []).length !== 1)
return flash('Magnet wklej pojedynczo, aby wybrać pliki.', true);
await inspectMagnet(magnet, title, format);
return;
}
const items = raw.split(/\r?\n/).map(s => s.trim()).filter(Boolean);
let ok = 0;
const errors = [];
button.disabled = true;
try {
for (const url of items) {
try { await api('/jobs', { method: 'POST', body: JSON.stringify({ url, title, format }) }); ok++; }
catch (e) { errors.push(url.slice(0, 65) + ': ' + e.message); }
}
if (ok) {
$('urls').value = '';
flash('Dodano zadań: ' + ok);
await refreshJobs();
}
if (errors.length) flash(errors.join('\n'), true);
} finally { button.disabled = false; }
});
$('archiveForm').addEventListener('submit', async (event) => {
event.preventDefault();
const button = $('archiveForm').querySelector('[type=submit]');
button.disabled = true;
$('archiveResults').classList.add('hidden');
try {
const data = await api('/archive/lookup', { method: 'POST', body: JSON.stringify({ identifier: $('archiveId').value }) });
S.archiveId = data.identifier;
S.archiveTitle = data.title;
S.archiveFiles = data.files;
S.selected.clear();
$('archiveName').textContent = data.title + ' (' + data.files.length + ')';
$('archiveResults').classList.remove('hidden');
renderArchive();
} catch (e) { flash('Internet Archive: ' + e.message, true); }
finally { button.disabled = false; }
});
function renderArchive() {
const list = $('archiveFiles');
list.replaceChildren();
const text = $('archiveFilter').value.toLowerCase();
let displayed = 0;
for (const file of S.archiveFiles) {
if (!file.name.toLowerCase().includes(text)) continue;
if (displayed >= 400) break;
displayed++;
const row = el('label', 'file-row');
const checkbox = el('input');
checkbox.type = 'checkbox';
checkbox.checked = S.selected.has(file.name);
checkbox.addEventListener('change', () => {
if (checkbox.checked) S.selected.add(file.name);
else S.selected.delete(file.name);
$('selectedCount').textContent = 'Wybrano: ' + S.selected.size;
});
row.append(checkbox, el('span', 'file-name', file.name), el('span', 'size', bytes(file.size)));
list.append(row);
}
if (!displayed) list.append(el('div', 'empty-state', 'Brak plików.'));
$('selectedCount').textContent = 'Wybrano: ' + S.selected.size;
}
$('archiveFilter').addEventListener('input', renderArchive);
$('archiveAdd').addEventListener('click', async () => {
if (!S.selected.size) return flash('Zaznacz pliki.', true);
const button = $('archiveAdd');
button.disabled = true;
try {
await api('/archive/jobs', { method: 'POST', body: JSON.stringify({
identifier: S.archiveId, files: [...S.selected], title: S.archiveTitle, format: $('archiveFormat').value
}) });
S.selected.clear();
renderArchive();
flash('Dodano do kolejki.');
await refreshJobs();
} catch (e) { flash(e.message, true); }
finally { button.disabled = false; }
});
async function refreshImports() {
try {
const files = await api('/imports');
const list = $('importList');
list.replaceChildren();
if (!files.length) {
list.append(el('div', 'empty-state', 'Katalog data/imports/ jest pusty.'));
return;
}
for (const file of files) {
const row = el('div', 'file-row');
row.append(icon('file'), el('span', 'file-name', file.name), el('span', 'size', bytes(file.size)));
const button = makeButton('Przetwórz', 'play');
button.addEventListener('click', async () => {
button.disabled = true;
try {
await api('/imports/jobs', { method: 'POST', body: JSON.stringify({
filename: file.name, format: $('importFormat').value
}) });
flash('Dodano: ' + file.name);
await refreshJobs();
} catch (e) { flash(e.message, true); }
finally { button.disabled = false; }
});
row.append(button);
list.append(row);
}
} catch (e) { flash('Import: ' + e.message, true); }
}
$('refreshImports').addEventListener('click', refreshImports);
$('refreshJobs').addEventListener('click', () => refreshJobs(true));
$('closeLog').addEventListener('click', () => $('logDialog').close());
refreshJobs();
refreshSystem();
refreshArchiveSession();
setInterval(() => refreshJobs(), 3000);
setInterval(refreshSystem, 30000);
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" rx="10" fill="#1b1e21"/>
<path d="M17 16 32 32 47 16M17 48 32 32 47 48" fill="none" stroke="#91c99a" stroke-width="6" stroke-linecap="square"/>
</svg>

After

Width:  |  Height:  |  Size: 245 B

+151
View File
@@ -0,0 +1,151 @@
<!doctype html>
<html lang="pl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark">
<meta name="theme-color" content="#141619">
<title>X360 Queue</title>
<link rel="icon" href="/static/icon.svg" type="image/svg+xml">
<link rel="stylesheet" href="/static/style.css">
<script src="/static/app.js" defer></script>
</head>
<body>
<svg class="svg-defs" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<symbol id="i-download" viewBox="0 0 24 24"><path d="M12 3v12m-5-5 5 5 5-5M5 17v3h14v-3"/></symbol>
<symbol id="i-link" viewBox="0 0 24 24"><path d="M10 13a5 5 0 0 0 7.4.4l2-2a5 5 0 0 0-7.1-7.1l-1.1 1.1M14 11a5 5 0 0 0-7.4-.4l-2 2a5 5 0 0 0 7.1 7.1l1.1-1.1"/></symbol>
<symbol id="i-archive" viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="4" rx="1"/><path d="M5 8v12h14V8M10 12h4"/></symbol>
<symbol id="i-folder" viewBox="0 0 24 24"><path d="M3 7a2 2 0 0 1 2-2h5l2 2h7a2 2 0 0 1 2 2v10H3z"/></symbol>
<symbol id="i-plus" viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"/></symbol>
<symbol id="i-refresh" viewBox="0 0 24 24"><path d="M20 7v5h-5M4 17v-5h5"/><path d="M5.6 9A7 7 0 0 1 18 6l2 6M4 12l2 6a7 7 0 0 0 12.4-3"/></symbol>
<symbol id="i-pause" viewBox="0 0 24 24"><path d="M8 5v14M16 5v14"/></symbol>
<symbol id="i-play" viewBox="0 0 24 24"><path d="m8 5 11 7-11 7z"/></symbol>
<symbol id="i-x" viewBox="0 0 24 24"><path d="M6 6l12 12M18 6 6 18"/></symbol>
<symbol id="i-retry" viewBox="0 0 24 24"><path d="M3 12a9 9 0 1 0 3-6.7M3 4v5h5"/></symbol>
<symbol id="i-trash" viewBox="0 0 24 24"><path d="M4 7h16M10 4h4M6 7l1 13h10l1-13M10 11v5m4-5v5"/></symbol>
<symbol id="i-file" viewBox="0 0 24 24"><path d="M6 3h8l4 4v14H6zM14 3v5h5M9 12h6M9 16h6"/></symbol>
<symbol id="i-harddrive" viewBox="0 0 24 24"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="M3 13h18M7 16h.01M11 16h.01"/></symbol>
<symbol id="i-activity" viewBox="0 0 24 24"><path d="M3 12h4l3-7 4 14 3-7h4"/></symbol>
<symbol id="i-check" viewBox="0 0 24 24"><path d="m5 12 5 5L19 7"/></symbol>
<symbol id="i-clock" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 7v6l4 2"/></symbol>
<symbol id="i-alert" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 7v6m0 4v.1"/></symbol>
<symbol id="i-list" viewBox="0 0 24 24"><path d="M9 6h12M9 12h12M9 18h12M4 6h.01M4 12h.01M4 18h.01"/></symbol>
</svg>
<div class="shell">
<header class="topbar">
<div class="brand">
<div class="brand-mark" aria-hidden="true">X</div>
<div class="brand-text"><strong>X360 Queue</strong><span>Linux / RGH</span></div>
</div>
<div class="topbar-right">
<span class="connection" id="connectionState"><span class="connection-dot"></span><span id="connectionText">Łączenie…</span></span>
<span class="version">LOCAL</span>
</div>
</header>
<main>
<div class="page-title">
<div><h1>Pobieranie</h1><p>Dodawanie plików i konwersja XEX / GOD.</p></div>
</div>
<section class="metrics" aria-label="Stan aplikacji">
<div class="metric"><span class="metric-label"><svg class="icon"><use href="#i-clock"/></svg> W kolejce</span><strong id="countQueued">0</strong></div>
<div class="metric"><span class="metric-label"><svg class="icon"><use href="#i-activity"/></svg> W toku</span><strong id="countActive">0</strong></div>
<div class="metric"><span class="metric-label"><svg class="icon"><use href="#i-check"/></svg> Gotowe</span><strong id="countDone">0</strong></div>
<div class="metric disk-metric">
<span class="metric-label"><svg class="icon"><use href="#i-harddrive"/></svg> Wolne miejsce</span>
<strong id="storageNum">—</strong>
<div class="meter"><span id="storageFill" style="width:0%"></span></div>
<small id="storageText">—</small>
</div>
</section>
<section class="panel" aria-labelledby="addHeading">
<div class="panel-heading"><h2 id="addHeading">Dodaj zadanie</h2><span class="muted-info">Zadania działają po zamknięciu przeglądarki</span></div>
<nav class="tabs" aria-label="Wybierz źródło">
<button class="tab active" type="button" data-tab="links" aria-selected="true"><svg class="icon"><use href="#i-link"/></svg>Link / magnet</button>
<button class="tab" type="button" data-tab="archive" aria-selected="false"><svg class="icon"><use href="#i-archive"/></svg>Internet Archive</button>
<button class="tab" type="button" data-tab="import" aria-selected="false"><svg class="icon"><use href="#i-folder"/></svg>Import</button>
</nav>
<div class="tab-body" id="pane-links">
<form id="linksForm">
<div class="field">
<label for="urls">Linki <span>— jeden adres w wierszu</span></label>
<textarea id="urls" rows="3" spellcheck="false" placeholder="https://example.org/obraz.iso&#10;magnet:?xt=urn:btih:..."></textarea>
</div>
<div class="input-row">
<div class="field title-field"><label for="linkTitle">Nazwa <span>(opcjonalnie)</span></label><input id="linkTitle" placeholder="Automatyczna" autocomplete="off"></div>
<div class="field format-field"><label for="linkFormat">Format wyjściowy</label><select id="linkFormat"><option value="xex">XEX</option><option value="god">GOD</option><option value="raw">Bez konwersji</option></select></div>
<button class="primary submit-button" type="submit"><svg class="icon"><use href="#i-plus"/></svg>Dodaj do kolejki</button>
</div>
</form>
<div class="torrent-upload-inline"><label for="torrentFileLinks" class="secondary file-label">Wczytaj .torrent</label><input id="torrentFileLinks" type="file" accept=".torrent,application/x-bittorrent" hidden><span>Alternatywa, gdy magnet nie zwraca metadanych.</span></div>
<p class="note">Magnet: najpierw lista plików, potem wybór i pobieranie. Vimm: tylko dostępny przycisk pobierania. Limity i zabezpieczenia serwisu pozostają aktywne.</p>
</div>
<div class="tab-body hidden" id="pane-archive">
<section class="archive-auth" aria-label="Konto Internet Archive">
<div class="auth-heading"><strong>Konto Archive.org</strong><span id="archiveSessionText">Sprawdzanie sesji…</span></div>
<form id="archiveLoginForm" class="auth-login hidden" autocomplete="on">
<div class="field"><label for="archiveEmail">E-mail</label><input id="archiveEmail" type="email" autocomplete="username" required placeholder="konto@example.org"></div>
<div class="field"><label for="archivePassword">Hasło</label><input id="archivePassword" type="password" autocomplete="current-password" required></div>
<button type="submit" class="secondary"><svg class="icon"><use href="#i-link"/></svg>Zaloguj</button>
</form>
<button id="archiveLogout" class="secondary hidden" type="button">Wyloguj</button>
<p class="note">Hasło nie jest zapisywane. Pobieranie używa sesji Archive.org; konto nie odblokowuje plików niedostępnych dla użytkownika. Panel udostępniaj przez HTTPS lub VPN.</p>
</section>
<form id="archiveForm">
<div class="field"><label for="archiveId">Adres lub identyfikator</label>
<div class="inline-input"><input id="archiveId" placeholder="archive.org/details/identyfikator" spellcheck="false"><button class="secondary" type="submit"><svg class="icon"><use href="#i-list"/></svg>Pokaż pliki</button></div>
</div>
</form>
<div class="archive-results hidden" id="archiveResults">
<div class="file-heading"><h3 id="archiveName"></h3><input id="archiveFilter" placeholder="Filtruj pliki…" aria-label="Filtruj pliki"></div>
<div class="file-table" id="archiveFiles"></div>
<div class="file-bottom"><span id="selectedCount">Wybrano: 0</span><div class="selection-controls"><select id="archiveFormat" aria-label="Format wyjściowy"><option value="xex">XEX</option><option value="god">GOD</option><option value="raw">Bez konwersji</option></select><button id="archiveAdd" class="primary" type="button"><svg class="icon"><use href="#i-download"/></svg>Pobierz wybrane</button></div></div>
<p class="note">Części jednego archiwum zaznacz razem.</p>
</div>
</div>
<div class="tab-body hidden" id="pane-import">
<div class="import-toolbar"><div><p class="import-hint">Skopiuj pliki do <code id="importHostPath">/xbox/imports/</code>.</p></div><div class="selection-controls"><select id="importFormat" aria-label="Format wyjściowy"><option value="xex">XEX</option><option value="god">GOD</option><option value="raw">Bez konwersji</option></select><button id="refreshImports" class="secondary" type="button"><svg class="icon"><use href="#i-refresh"/></svg>Odśwież</button></div></div>
<div id="importList" class="file-table"></div>
</div>
</section>
<section class="queue-section" aria-labelledby="queueHeading">
<div class="queue-header">
<div class="queue-title"><h2 id="queueHeading">Kolejka <span id="jobsCount" class="count">0</span></h2><span class="muted-info">Odświeżanie co 3 s</span></div>
<div class="queue-controls">
<div class="filters" aria-label="Filtruj zadania">
<button type="button" class="filter active" data-filter="all" aria-pressed="true">Wszystkie</button>
<button type="button" class="filter" data-filter="active" aria-pressed="false">W toku</button>
<button type="button" class="filter" data-filter="done" aria-pressed="false">Gotowe</button>
<button type="button" class="filter" data-filter="error" aria-pressed="false">Błędy</button>
</div>
<button id="refreshJobs" class="secondary icon-button" type="button" title="Odśwież kolejkę" aria-label="Odśwież kolejkę"><svg class="icon"><use href="#i-refresh"/></svg></button>
</div>
</div>
<div id="jobsList" class="jobs"><div class="empty-state">Brak zadań.</div></div>
</section>
<footer class="footer"><span>Wyniki: <code id="readyXexPath">/xbox/ready/xex/</code> lub <code id="readyGodPath">/xbox/ready/god/</code></span><span>Pobieranie: <code id="downloadHostPath">/xbox/downloads/</code> · Robocze: <code id="workHostPath">/xbox/work/</code></span></footer>
</main>
</div>
<div id="toast" role="status" aria-live="polite"></div>
<dialog id="magnetDialog" aria-labelledby="magnetHeading">
<div class="dialog-heading"><h2 id="magnetHeading">Pliki w torrencie</h2><button type="button" class="secondary" id="closeMagnet"><svg class="icon"><use href="#i-x"/></svg>Zamknij</button></div>
<p id="magnetStatus" class="preview-status">Pobieranie metadanych torrenta…</p>
<div class="torrent-upload-inline"><label for="torrentFileDialog" class="secondary file-label">Wczytaj .torrent</label><input id="torrentFileDialog" type="file" accept=".torrent,application/x-bittorrent" hidden><span>Nie wymaga peerów ani oczekiwania na DHT.</span></div>
<div id="magnetResults" class="hidden">
<div class="file-heading"><h3 id="magnetName"></h3><input id="magnetFilter" placeholder="Filtruj pliki…" aria-label="Filtruj pliki"></div>
<div class="file-table" id="magnetFiles"></div>
<div class="file-bottom"><span id="magnetSelectedCount">Wybrano: 0</span><div class="selection-controls"><button type="button" id="magnetToggleAll" class="secondary">Zaznacz widoczne</button><button type="button" id="magnetAdd" class="primary"><svg class="icon"><use href="#i-download"/></svg>Pobierz wybrane</button></div></div>
<p class="note">Pobieranie rozpocznie się dopiero po wybraniu plików. Przy archiwach wieloczęściowych zaznacz wszystkie części.</p>
</div>
</dialog>
<dialog id="logDialog" aria-labelledby="logHeading"><div class="dialog-heading"><h2 id="logHeading">Log zadania</h2><button type="button" class="secondary" id="closeLog"><svg class="icon"><use href="#i-x"/></svg>Zamknij</button></div><pre id="logContent"></pre></dialog>
</body>
</html>
+214
View File
@@ -0,0 +1,214 @@
:root {
color-scheme: dark;
font-family: system-ui, -apple-system, "Segoe UI", Arial, sans-serif;
font-size: 14px;
font-synthesis: none;
--bg: #121416;
--panel: #1b1e21;
--panel-hover: #21252a;
--input: #151719;
--line: #34383d;
--line-soft: #2a2e32;
--text: #e4e7e9;
--muted: #a2a9af;
--faint: #788189;
--accent: #91c99a;
--accent-hover: #afe0b5;
--success-bg: #203427;
--danger: #e8a09b;
--danger-bg: #362324;
}
*, *::before, *::after { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--text); }
button, input, textarea, select { font: inherit; }
button { cursor: pointer; }
button:disabled { opacity: .45; cursor: not-allowed; }
button:focus-visible, input:focus-visible, textarea:focus-visible, select:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.hidden, .svg-defs { display: none !important; }
.icon { width: 16px; height: 16px; flex: 0 0 auto; fill: none; stroke: currentColor; stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round; }
.shell { max-width: 1120px; margin: auto; padding: 0 24px; }
.topbar { height: 66px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--line); }
.brand { display: flex; align-items: center; gap: 11px; }
.brand-mark { display: grid; place-items: center; width: 33px; height: 33px; border: 1px solid #72977c; color: #a9d6ad; background: #1d2a21; border-radius: 5px; font-weight: 800; font-size: 19px; line-height: 1; }
.brand-text { display: flex; flex-direction: column; gap: 2px; }
.brand-text strong { font-size: 14px; font-weight: 700; letter-spacing: .1px; }
.brand-text span { color: var(--faint); font-size: 11px; }
.topbar-right { display: flex; align-items: center; gap: 17px; }
.connection { display: inline-flex; align-items: center; gap: 7px; font-size: 12px; color: var(--muted); }
.connection-dot { width: 7px; height: 7px; background: var(--faint); border-radius: 50%; }
.connection.online .connection-dot { background: var(--accent); }
.connection.offline .connection-dot { background: var(--danger); }
.version { border: 1px solid var(--line); color: var(--faint); font: 600 10px ui-monospace, SFMono-Regular, Consolas, monospace; padding: 4px 7px; letter-spacing: .5px; border-radius: 3px; }
.page-title { padding: 31px 0 22px; }
h1, h2, h3, p { margin-top: 0; }
h1 { margin-bottom: 5px; font-size: 25px; font-weight: 680; letter-spacing: -.65px; line-height: 1.3; }
.page-title p { margin-bottom: 0; color: var(--muted); font-size: 13px; }
.metrics { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); border: 1px solid var(--line); border-radius: 5px; background: var(--panel); overflow: hidden; }
.metric { display: flex; flex-direction: column; gap: 9px; padding: 16px 19px; min-height: 112px; }
.metric + .metric { border-left: 1px solid var(--line); }
.metric-label { display: inline-flex; align-items: center; gap: 8px; color: var(--muted); font-size: 12px; }
.metric-label .icon { width: 14px; height: 14px; color: var(--faint); }
.metric strong { color: var(--text); font-weight: 650; font-size: 25px; font-variant-numeric: tabular-nums; line-height: 1; }
.disk-metric { gap: 7px; }
.disk-metric strong { font-size: 20px; }
.disk-metric small { font-size: 10px; color: var(--faint); }
.meter { height: 4px; background: #353a3e; overflow: hidden; width: 100%; }
.meter span { display: block; height: 100%; background: var(--accent); transition: width .2s linear; }
.panel { background: var(--panel); border: 1px solid var(--line); border-radius: 5px; margin-top: 26px; }
.panel-heading { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 18px 20px 15px; }
h2 { margin: 0; font-size: 16px; font-weight: 670; letter-spacing: -.2px; }
.muted-info { font-size: 11px; color: var(--faint); }
.tabs { display: flex; gap: 3px; padding: 0 15px; border-bottom: 1px solid var(--line); }
.tab { display: inline-flex; align-items: center; gap: 8px; background: transparent; border: none; border-bottom: 2px solid transparent; padding: 11px 13px 12px; color: var(--muted); font-size: 12px; font-weight: 550; }
.tab:hover { color: var(--text); background: #25292d; }
.tab.active { color: var(--accent); border-bottom-color: var(--accent); }
.tab .icon { width: 15px; height: 15px; }
.tab-body { padding: 21px 20px 20px; }
.field { min-width: 0; }
label { display: block; margin-bottom: 7px; font-size: 12px; color: #cbd0d4; font-weight: 600; }
label span { color: var(--faint); font-weight: 400; }
input, select, textarea { display: block; width: 100%; min-width: 0; color: var(--text); background: var(--input); border: 1px solid #42474c; border-radius: 4px; padding: 9px 11px; outline: none; font-size: 12px; }
input, select { height: 38px; }
textarea { min-height: 89px; resize: vertical; line-height: 1.6; }
input::placeholder, textarea::placeholder { color: #737c84; }
select { appearance: auto; }
.input-row { display: flex; gap: 12px; align-items: flex-end; margin-top: 13px; }
.input-row .title-field { flex: 1 1 auto; }
.input-row .format-field { flex: 0 0 172px; }
button.primary, button.secondary, button.action { border-radius: 4px; display: inline-flex; align-items: center; justify-content: center; gap: 8px; font-size: 12px; font-weight: 620; white-space: nowrap; min-height: 38px; padding: 9px 12px; }
button.primary { border: 1px solid #9ccc9e; color: #18231b; background: var(--accent); }
button.primary:hover:not(:disabled) { background: var(--accent-hover); }
button.secondary, button.action { border: 1px solid #444a50; background: #292d31; color: #d9dee1; }
button.secondary:hover:not(:disabled), button.action:hover:not(:disabled) { background: #34393f; }
button.action { min-height: 29px; padding: 5px 9px; font-size: 11px; }
button.action.danger { color: #e3a6a4; }
button.action.danger:hover { background: #3a2829; }
button.icon-button { min-width: 36px; width: 36px; padding: 8px; }
.note { color: var(--faint); font-size: 11px; line-height: 1.55; margin: 13px 0 0; }
.inline-input { display: flex; align-items: center; gap: 9px; }
.inline-input input { flex: 1 1 auto; }
.file-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 24px; }
.file-heading h3 { font-size: 13px; font-weight: 600; margin: 0; max-width: 62%; overflow-wrap: anywhere; }
.file-heading input { max-width: 250px; }
.file-table { max-height: 320px; overflow: auto; margin-top: 12px; border: 1px solid var(--line); border-radius: 4px; }
.file-row { display: flex; gap: 12px; align-items: center; min-height: 44px; padding: 9px 11px; margin: 0; border-bottom: 1px solid var(--line-soft); font-size: 12px; color: #d2d7da; font-weight: 400; }
.file-row:last-child { border-bottom: 0; }
.file-row:hover { background: var(--panel-hover); }
.file-row input[type=checkbox] { margin: 0; min-height: 0; width: 15px; height: 15px; accent-color: var(--accent); flex-shrink: 0; }
.file-name { flex: 1; min-width: 0; overflow-wrap: anywhere; }
.size { color: var(--faint); font-size: 11px; white-space: nowrap; }
.file-bottom { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 13px; color: var(--muted); font-size: 12px; }
.selection-controls { display: flex; align-items: center; gap: 9px; }
.selection-controls select { width: 145px; }
.import-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 15px; }
.import-hint { font-size: 12px; color: var(--muted); margin: 0; }
code { color: #b7d1bd; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 11px; }
.queue-section { padding-top: 29px; }
.queue-header { display: flex; justify-content: space-between; align-items: center; gap: 14px; margin-bottom: 14px; }
.queue-title { display: flex; align-items: baseline; gap: 13px; }
.queue-title h2 { display: flex; align-items: center; gap: 9px; }
.count { font-size: 11px; color: #bdc4c9; background: #2c3034; border: 1px solid #3d4247; min-width: 21px; padding: 2px 6px; text-align: center; border-radius: 3px; font-variant-numeric: tabular-nums; }
.queue-controls { display: flex; gap: 9px; align-items: center; }
.filters { display: flex; border: 1px solid var(--line); border-radius: 4px; overflow: hidden; }
.filter { background: #1c1f22; color: var(--muted); border: 0; border-right: 1px solid var(--line); padding: 9px 13px; font-size: 11px; min-height: 36px; }
.filter:last-child { border-right: 0; }
.filter:hover { color: var(--text); background: #272b30; }
.filter.active { color: var(--accent); background: #29342c; font-weight: 650; }
.jobs { display: flex; flex-direction: column; gap: 8px; }
.job { background: var(--panel); border: 1px solid var(--line); border-radius: 5px; padding: 14px 17px; }
.job-top { display: flex; justify-content: space-between; gap: 20px; align-items: flex-start; }
.job-heading { min-width: 0; flex: 1; }
.job-name { font-weight: 650; font-size: 13px; line-height: 1.45; overflow-wrap: anywhere; }
.job-sub { color: var(--faint); font-size: 11px; margin-top: 3px; overflow-wrap: anywhere; max-height: 2.8em; overflow: hidden; }
.job-status { flex-shrink: 0; display: inline-flex; align-items: center; gap: 6px; border: 1px solid #4a5156; color: #bbc4ca; background: #272c30; border-radius: 3px; font-size: 10px; line-height: 1; padding: 6px 8px; white-space: nowrap; }
.job-status::before { content: ''; width: 6px; height: 6px; border-radius: 50%; background: #9ca5ac; }
.job-status.running { background: #233126; color: #add7af; border-color: #39563e; }
.job-status.running::before { background: var(--accent); }
.job-status.done { background: var(--success-bg); color: #a8d9ac; border-color: #3b5942; }
.job-status.done::before { background: #8fcd95; }
.job-status.error { background: var(--danger-bg); color: #f1aaa3; border-color: #704446; }
.job-status.error::before { background: var(--danger); }
.job-progress { display: flex; align-items: center; gap: 12px; margin-top: 12px; }
.progressbar { width: 100%; height: 5px; background: #373d41; overflow: hidden; }
.progressbar span { display: block; height: 100%; background: #8aba94; transition: width .25s linear; }
.progressbar.is-error span { background: #c47e78; }
.progress-text { color: #c5cbd0; text-align: right; font-variant-numeric: tabular-nums; font-size: 11px; flex: 0 0 50px; }
.job-meta { display: flex; align-items: center; justify-content: space-between; gap: 20px; margin-top: 8px; font-size: 11px; color: var(--faint); }
.job-meta .note-text { overflow-wrap: anywhere; }
.job-meta .speed { flex-shrink: 0; font-variant-numeric: tabular-nums; }
.job-bottom { display: flex; justify-content: space-between; gap: 10px; align-items: center; flex-wrap: wrap; margin-top: 11px; }
.job-chips { display: inline-flex; gap: 7px; align-items: center; }
.job-chip { color: #aab3ba; font: 11px ui-monospace, SFMono-Regular, Consolas, monospace; border: 1px solid #3c4449; padding: 3px 7px; border-radius: 3px; }
.job-actions { display: flex; flex-wrap: wrap; gap: 6px; margin-left: auto; }
.job-error { margin-top: 11px; padding: 9px 11px; border-left: 2px solid #c6857e; color: #ebaba6; background: #2b2122; white-space: pre-wrap; overflow-wrap: anywhere; font-size: 11px; line-height: 1.5; }
.job-output { color: #accdb5; overflow-wrap: anywhere; margin-top: 9px; font-size: 11px; }
.empty-state { color: var(--faint); text-align: center; border: 1px dashed #42484e; background: var(--panel); padding: 34px 12px; border-radius: 4px; font-size: 12px; }
.footer { display: flex; justify-content: space-between; gap: 20px; padding: 23px 0 36px; color: var(--faint); font-size: 11px; }
#toast { display: none; position: fixed; right: 20px; bottom: 20px; max-width: min(420px, calc(100vw - 40px)); z-index: 20; padding: 13px 16px; border: 1px solid #6e9e75; border-radius: 4px; background: #24402a; color: #d3f1d6; font-size: 12px; line-height: 1.45; white-space: pre-line; }
#toast.bad { background: #48282a; color: #ffd7d3; border-color: #955b60; }
dialog { width: min(760px, calc(100vw - 30px)); max-height: 85vh; background: var(--panel); color: var(--text); border: 1px solid #545a60; border-radius: 5px; padding: 18px; }
dialog::backdrop { background: rgba(0,0,0,.75); }
.dialog-heading { display: flex; justify-content: space-between; align-items: center; gap: 20px; padding-bottom: 15px; border-bottom: 1px solid var(--line); }
.dialog-heading h2 { font-size: 15px; }
pre { overflow: auto; max-height: 66vh; white-space: pre-wrap; overflow-wrap: anywhere; font-size: 11px; line-height: 1.65; color: #c8cdd0; }
@media (max-width: 760px) {
.shell { padding: 0 15px; }
.topbar { height: 59px; }
.version { display: none; }
.page-title { padding: 22px 0 16px; }
.metrics { grid-template-columns: repeat(2, minmax(0,1fr)); }
.metric:nth-child(3) { border-left: 0; border-top: 1px solid var(--line); }
.metric:nth-child(4) { border-top: 1px solid var(--line); }
.metric { min-height: 99px; padding: 15px; }
.panel { margin-top: 19px; }
.panel-heading { padding: 16px 15px 13px; }
.panel-heading .muted-info { display: none; }
.tabs { padding: 0 6px; }
.tab { padding: 11px 9px; font-size: 11px; gap: 6px; }
.tab-body { padding: 16px 15px; }
.input-row { flex-wrap: wrap; }
.input-row .title-field { flex: 1 1 100%; }
.input-row .format-field { flex: 1 1 110px; }
.input-row .submit-button { flex: 1 1 auto; }
.file-heading { align-items: stretch; flex-direction: column; }
.file-heading h3 { max-width: 100%; }
.file-heading input { max-width: none; }
.queue-header { align-items: stretch; flex-direction: column; gap: 13px; }
.queue-controls { justify-content: space-between; }
.queue-title { justify-content: space-between; }
.filter { padding: 9px; }
.job { padding: 13px; }
.footer { flex-direction: column; gap: 7px; }
}
@media (max-width: 440px) {
.inline-input, .import-toolbar { align-items: stretch; flex-direction: column; }
.selection-controls { width: 100%; }
.selection-controls select { flex: 1; min-width: 0; }
.file-bottom { align-items: stretch; flex-direction: column; }
.job-top { gap: 9px; }
.job-status { max-width: 130px; white-space: normal; }
.queue-title .muted-info { display: none; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { transition-duration: 0ms !important; animation-duration: 0ms !important; }
}
/* Archive login and torrent metadata selection: compact, flat controls. */
.archive-auth { border: 1px solid var(--line); padding: 13px; margin-bottom: 20px; background: var(--input); border-radius: 4px; }
.auth-heading { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 12px; font-size: 12px; }
.auth-heading span { color: var(--muted); overflow-wrap: anywhere; font-size: 11px; }
.auth-login { display: flex; align-items: end; gap: 10px; flex-wrap: wrap; margin-top: 13px; }
.auth-login .field { flex: 1 1 190px; }
.archive-auth .note { margin-top: 10px; }
#archiveLogout { margin-top: 12px; }
#magnetDialog { width: min(840px, calc(100vw - 24px)); }
.preview-status { color: var(--muted); font-size: 12px; margin: 14px 0; }
#magnetDialog .file-table { max-height: 45vh; }
#magnetDialog .file-row input:disabled { opacity: .3; }
@media(max-width: 540px) { .auth-login .field, .auth-login button { flex: 1 1 100%; } }
/* Flat torrent metadata fallback controls. */
.torrent-upload-inline {display:flex; align-items:center; flex-wrap:wrap; gap:10px; margin:10px 0; color:var(--muted, #8d939d); font-size:12px;}
.file-label {display:inline-flex; align-items:center; justify-content:center; cursor:pointer; border:1px solid #434a51; background:#292e33; color:#d7dce1; padding:7px 10px; border-radius:3px; font-size:12px; font-weight:600;}
.file-label:hover {background:#32383e;}
#torrentFileLinks, #torrentFileDialog {display:none !important;}