first commit
This commit is contained in:
commit
58cfd95600
35 files changed
+5357
No files matched your search
@@ -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);
|
||||
@@ -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 |
@@ -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 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>
|
||||
@@ -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;}
|
||||
Reference in new issue
Block a user