Improved torrent UI
Build and Publish / Build and Publish Docker Image (push) Successful in 2m45s

This commit is contained in:
Ultradesu
2026-05-26 16:21:21 +03:00
parent 3878d746d2
commit 82923c871e
6 changed files with 293 additions and 53 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "furumusic" name = "furumusic"
version = "0.1.12" version = "0.1.13"
edition = "2024" edition = "2024"
description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL" description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL"
+4
View File
@@ -357,6 +357,8 @@ translations! {
player_saved_torrents: "Saved torrents" , "Сохранённые торренты"; player_saved_torrents: "Saved torrents" , "Сохранённые торренты";
player_refresh: "Refresh" , "Обновить"; player_refresh: "Refresh" , "Обновить";
player_no_saved_torrents: "No saved torrents" , "Сохранённых торрентов нет"; player_no_saved_torrents: "No saved torrents" , "Сохранённых торрентов нет";
player_add_torrent: "Add torrent" , "Добавить торрент";
player_choose_saved_or_add_torrent: "Choose a saved torrent or add a new one." , "Выберите сохранённый торрент или добавьте новый.";
player_torrent_file: "Torrent file" , "Torrent-файл"; player_torrent_file: "Torrent file" , "Torrent-файл";
player_magnet_link: "Magnet link" , "Magnet-ссылка"; player_magnet_link: "Magnet link" , "Magnet-ссылка";
player_preview_content: "Preview content" , "Предпросмотр"; player_preview_content: "Preview content" , "Предпросмотр";
@@ -372,6 +374,8 @@ translations! {
player_failed: "Failed" , "Ошибка"; player_failed: "Failed" , "Ошибка";
player_paused: "Paused" , "Пауза"; player_paused: "Paused" , "Пауза";
player_no_torrent_selected: "No torrent selected" , "Торрент не выбран"; player_no_torrent_selected: "No torrent selected" , "Торрент не выбран";
player_downloaded: "Downloaded" , "Загружено";
player_speed: "Speed" , "Скорость";
player_down: "down" , "вниз"; player_down: "down" , "вниз";
player_up: "up" , "вверх"; player_up: "up" , "вверх";
player_peers: "peers" , "пиры"; player_peers: "peers" , "пиры";
+12 -4
View File
@@ -194,10 +194,14 @@ impl TorrentSessionRow {
self.status.as_str() self.status.as_str()
}; };
let stats = handle.map(|h| h.stats()); let stats = handle.map(|h| h.stats());
let downloaded_bytes = stats let mut downloaded_bytes = stats
.as_ref() .as_ref()
.map(|s| s.progress_bytes) .map(|s| s.progress_bytes)
.unwrap_or_else(|| i64_to_u64(self.downloaded_bytes)); .unwrap_or_else(|| i64_to_u64(self.downloaded_bytes));
let selected_size = i64_to_u64(self.selected_size);
if status == "complete" {
downloaded_bytes = selected_size;
}
let uploaded_bytes = stats let uploaded_bytes = stats
.as_ref() .as_ref()
.map(|s| s.uploaded_bytes) .map(|s| s.uploaded_bytes)
@@ -225,7 +229,7 @@ impl TorrentSessionRow {
status: status.to_string(), status: status.to_string(),
client_state: stats.as_ref().map(|s| s.state.to_string()), client_state: stats.as_ref().map(|s| s.state.to_string()),
total_size: i64_to_u64(self.total_size), total_size: i64_to_u64(self.total_size),
selected_size: i64_to_u64(self.selected_size), selected_size,
downloaded_bytes, downloaded_bytes,
uploaded_bytes, uploaded_bytes,
progress_percent, progress_percent,
@@ -313,10 +317,14 @@ impl TorrentJob {
fn dto(&self) -> TorrentJobDto { fn dto(&self) -> TorrentJobDto {
let stats = self.handle.as_ref().map(|h| h.stats()); let stats = self.handle.as_ref().map(|h| h.stats());
let downloaded_bytes = stats let mut downloaded_bytes = stats
.as_ref() .as_ref()
.map(|s| s.progress_bytes) .map(|s| s.progress_bytes)
.unwrap_or(self.downloaded_bytes); .unwrap_or(self.downloaded_bytes);
let selected_size = self.selected_size();
if self.status == TorrentJobStatus::Complete {
downloaded_bytes = selected_size;
}
let uploaded_bytes = stats let uploaded_bytes = stats
.as_ref() .as_ref()
.map(|s| s.uploaded_bytes) .map(|s| s.uploaded_bytes)
@@ -336,7 +344,7 @@ impl TorrentJob {
status: self.status.as_str().to_string(), status: self.status.as_str().to_string(),
client_state: stats.as_ref().map(|s| s.state.to_string()), client_state: stats.as_ref().map(|s| s.state.to_string()),
total_size: self.total_size(), total_size: self.total_size(),
selected_size: self.selected_size(), selected_size,
downloaded_bytes, downloaded_bytes,
uploaded_bytes, uploaded_bytes,
progress_percent: if self.status == TorrentJobStatus::Complete { progress_percent: if self.status == TorrentJobStatus::Complete {
+66 -27
View File
@@ -74,7 +74,7 @@
<span x-text="$store.torrents.agentSummary()"></span> <span x-text="$store.torrents.agentSummary()"></span>
</span> </span>
<span class="torrent-status-pill" <span class="torrent-status-pill"
x-text="$store.torrents.sessions.length + ' saved'"></span> x-text="$store.torrents.sessions.length + ' ' + T.saved"></span>
</div> </div>
</div> </div>
@@ -94,7 +94,7 @@
</template> </template>
<template x-for="job in $store.torrents.sessions" :key="job.id"> <template x-for="job in $store.torrents.sessions" :key="job.id">
<div class="torrent-session-row" <div class="torrent-session-row"
:class="{ active: $store.torrents.previewData && $store.torrents.previewData.id === job.id }" :class="{ active: $store.torrents.workspaceMode === 'session' && $store.torrents.previewData && $store.torrents.previewData.id === job.id }"
@click="$store.torrents.openSession(job.id)"> @click="$store.torrents.openSession(job.id)">
<div class="torrent-session-main"> <div class="torrent-session-main">
<div class="torrent-session-topline"> <div class="torrent-session-topline">
@@ -113,29 +113,46 @@
@click.stop="$store.torrents.removeSession(job.id)">{{ t.player_delete }}</button> @click.stop="$store.torrents.removeSession(job.id)">{{ t.player_delete }}</button>
</div> </div>
</template> </template>
<button type="button"
class="torrent-session-row torrent-session-add"
:class="{ active: $store.torrents.isImporting() }"
@click="$store.torrents.addNew()"
:disabled="$store.torrents.loading">
<span class="torrent-session-add-icon">+</span>
<span>{{ t.player_add_torrent }}</span>
</button>
</div> </div>
</aside> </aside>
<section class="torrent-workspace"> <section class="torrent-workspace">
<div class="torrent-modal-grid"> <template x-if="$store.torrents.workspaceMode === 'empty'">
<div> <div class="empty-state torrent-workspace-empty">
<label for="torrent-file-input">{{ t.player_torrent_file }}</label> <p x-text="T.chooseSavedOrAddTorrent"></p>
<input id="torrent-file-input" type="file" accept=".torrent,application/x-bittorrent"
@change="$store.torrents.file = $event.target.files[0] || null">
</div> </div>
<div> </template>
<label for="torrent-magnet-input">{{ t.player_magnet_link }}</label>
<input id="torrent-magnet-input" type="text" <template x-if="$store.torrents.isImporting()">
x-model="$store.torrents.magnet" <div class="torrent-import-panel">
placeholder="magnet:?xt=urn:btih:..."> <div class="torrent-modal-grid">
<div>
<label for="torrent-file-input">{{ t.player_torrent_file }}</label>
<input id="torrent-file-input" type="file" accept=".torrent,application/x-bittorrent"
@change="$store.torrents.file = $event.target.files[0] || null">
</div>
<div>
<label for="torrent-magnet-input">{{ t.player_magnet_link }}</label>
<input id="torrent-magnet-input" type="text"
x-model="$store.torrents.magnet"
placeholder="magnet:?xt=urn:btih:...">
</div>
</div>
<div class="torrent-actions">
<button class="modal-btn modal-btn-primary" @click="$store.torrents.preview()" :disabled="$store.torrents.loading">
{{ t.player_preview_content }}
</button>
</div>
</div> </div>
</div> </template>
<div class="torrent-actions">
<button class="modal-btn modal-btn-primary" @click="$store.torrents.preview()" :disabled="$store.torrents.loading">
{{ t.player_preview_content }}
</button>
<button class="modal-btn modal-btn-ghost" @click="$store.torrents.clearSelection()" :disabled="!$store.torrents.previewData">{{ t.player_clear }}</button>
</div>
<template x-if="$store.torrents.currentJob"> <template x-if="$store.torrents.currentJob">
<div class="torrent-progress-card"> <div class="torrent-progress-card">
@@ -147,10 +164,32 @@
<div class="torrent-progress-bar" <div class="torrent-progress-bar"
:style="'width:' + $store.torrents.progressValue($store.torrents.currentJob) + '%'"></div> :style="'width:' + $store.torrents.progressValue($store.torrents.currentJob) + '%'"></div>
</div> </div>
<div class="torrent-progress-details"> <div class="torrent-progress-details"
<span x-text="$store.torrents.bytes($store.torrents.currentJob.downloaded_bytes) + ' / ' + $store.torrents.bytes($store.torrents.currentJob.selected_size || $store.torrents.currentJob.total_size)"></span> :class="{ completed: $store.torrents.isCompleted($store.torrents.currentJob) }">
<span x-text="$store.torrents.speedText($store.torrents.currentJob)"></span> <span class="torrent-progress-metric">
<span x-text="$store.torrents.peerText($store.torrents.currentJob)"></span> <span class="torrent-progress-label"
x-text="$store.torrents.isCompleted($store.torrents.currentJob) ? T.size : T.downloaded"></span>
<span class="torrent-progress-value"
x-text="$store.torrents.progressDetailText($store.torrents.currentJob)"></span>
</span>
<span class="torrent-progress-metric"
x-show="!$store.torrents.isCompleted($store.torrents.currentJob)">
<span class="torrent-progress-label" x-text="T.speed"></span>
<span class="torrent-progress-value"
x-text="$store.torrents.speedText($store.torrents.currentJob)"></span>
</span>
<span class="torrent-progress-metric"
x-show="!$store.torrents.isCompleted($store.torrents.currentJob)">
<span class="torrent-progress-label" x-text="T.peers"></span>
<span class="torrent-progress-value"
x-text="$store.torrents.peerText($store.torrents.currentJob)"></span>
</span>
<span class="torrent-progress-metric"
x-show="!$store.torrents.isCompleted($store.torrents.currentJob) && $store.torrents.etaText($store.torrents.currentJob)">
<span class="torrent-progress-label" x-text="T.eta"></span>
<span class="torrent-progress-value"
x-text="$store.torrents.etaText($store.torrents.currentJob)"></span>
</span>
</div> </div>
</div> </div>
</template> </template>
@@ -163,10 +202,10 @@
x-text="$store.torrents.previewData.files.length + ' {{ t.player_files_count }} - ' + $store.torrents.bytes($store.torrents.previewData.total_size)"></div> x-text="$store.torrents.previewData.files.length + ' {{ t.player_files_count }} - ' + $store.torrents.bytes($store.torrents.previewData.total_size)"></div>
</div> </div>
<button class="modal-btn" <button class="modal-btn"
:class="$store.torrents.isCurrentDownloading() ? 'modal-btn-pause' : 'modal-btn-primary'" :class="$store.torrents.actionButtonClass()"
@click="$store.torrents.isCurrentDownloading() ? $store.torrents.pause() : $store.torrents.start()" @click="$store.torrents.toggleDownloadAction()"
:disabled="$store.torrents.loading"> :disabled="$store.torrents.actionButtonDisabled()">
<span x-text="$store.torrents.isCurrentDownloading() ? '{{ t.player_pause_download }}' : '{{ t.player_download_selected }}'"></span> <span x-text="$store.torrents.actionButtonText()"></span>
</button> </button>
</div> </div>
<div class="torrent-tree-toolbar"> <div class="torrent-tree-toolbar">
+130 -18
View File
@@ -29,6 +29,7 @@ const T = {
processing: "{{ t.player_processing }}", processing: "{{ t.player_processing }}",
queued: "{{ t.player_queued }}", queued: "{{ t.player_queued }}",
saved: "{{ t.player_saved }}", saved: "{{ t.player_saved }}",
chooseSavedOrAddTorrent: "{{ t.player_choose_saved_or_add_torrent }}",
preview: "{{ t.player_preview }}", preview: "{{ t.player_preview }}",
downloading: "{{ t.player_downloading }}", downloading: "{{ t.player_downloading }}",
moving: "{{ t.player_moving }}", moving: "{{ t.player_moving }}",
@@ -36,6 +37,8 @@ const T = {
failed: "{{ t.player_failed }}", failed: "{{ t.player_failed }}",
paused: "{{ t.player_paused }}", paused: "{{ t.player_paused }}",
noTorrentSelected: "{{ t.player_no_torrent_selected }}", noTorrentSelected: "{{ t.player_no_torrent_selected }}",
downloaded: "{{ t.player_downloaded }}",
speed: "{{ t.player_speed }}",
down: "{{ t.player_down }}", down: "{{ t.player_down }}",
up: "{{ t.player_up }}", up: "{{ t.player_up }}",
peers: "{{ t.player_peers }}", peers: "{{ t.player_peers }}",
@@ -43,6 +46,8 @@ const T = {
seen: "{{ t.player_seen }}", seen: "{{ t.player_seen }}",
eta: "{{ t.player_eta }}", eta: "{{ t.player_eta }}",
selected: "{{ t.player_selected }}", selected: "{{ t.player_selected }}",
downloadSelected: "{{ t.player_download_selected }}",
pauseDownload: "{{ t.player_pause_download }}",
chooseTorrent: "{{ t.player_choose_torrent }}", chooseTorrent: "{{ t.player_choose_torrent }}",
readingTorrent: "{{ t.player_reading_torrent }}", readingTorrent: "{{ t.player_reading_torrent }}",
resolvingMagnet: "{{ t.player_resolving_magnet }}", resolvingMagnet: "{{ t.player_resolving_magnet }}",
@@ -1083,6 +1088,7 @@ document.addEventListener('alpine:init', () => {
loadingSessions: false, loadingSessions: false,
currentJob: null, currentJob: null,
previewData: null, previewData: null,
workspaceMode: 'empty',
treeRoot: null, treeRoot: null,
selected: new Set(), selected: new Set(),
expanded: new Set(), expanded: new Set(),
@@ -1090,6 +1096,7 @@ document.addEventListener('alpine:init', () => {
message: '', message: '',
error: false, error: false,
_pollTimer: null, _pollTimer: null,
_pollJobId: null,
_refreshTimer: null, _refreshTimer: null,
queuedTasks: 0, queuedTasks: 0,
processingTasks: 0, processingTasks: 0,
@@ -1107,6 +1114,31 @@ document.addEventListener('alpine:init', () => {
close() { close() {
this.modal = false; this.modal = false;
this._stopRefresh(); this._stopRefresh();
this._stopPoll();
},
_stopPoll() {
if (this._pollTimer) clearInterval(this._pollTimer);
this._pollTimer = null;
this._pollJobId = null;
},
isImporting() {
return this.workspaceMode === 'new';
},
addNew() {
if (this.loading) return;
this._stopPoll();
this.workspaceMode = 'new';
this.file = null;
this.magnet = '';
this.currentJob = null;
this.previewData = null;
this.treeRoot = null;
this.selected = new Set();
this.expanded = new Set();
this._setMessage(T.chooseTorrent);
}, },
_setMessage(message, error = false) { _setMessage(message, error = false) {
@@ -1138,6 +1170,35 @@ document.addEventListener('alpine:init', () => {
return this.isDownloading(this.currentJob); return this.isDownloading(this.currentJob);
}, },
isCompleted(job) {
return this.normalizedStatus(job) === 'completed';
},
isCurrentCompleted() {
return this.isCompleted(this.currentJob);
},
selectedArray() {
return [...this.selected].sort((a, b) => Number(a) - Number(b));
},
selectionMatchesJob(job) {
if (!job) return false;
const saved = Array.isArray(job.selected_files) ? job.selected_files : [];
const selected = this.selectedArray();
if (saved.length !== selected.length) return false;
const savedSet = new Set(saved.map(index => Number(index)));
return selected.every(index => savedSet.has(Number(index)));
},
hasCurrentSelectionChanges() {
return !!this.currentJob && !this.selectionMatchesJob(this.currentJob);
},
isCurrentCompletedLocked() {
return this.isCurrentCompleted() && !this.hasCurrentSelectionChanges();
},
normalizedStatus(job) { normalizedStatus(job) {
const status = String(job?.status || 'preview').toLowerCase(); const status = String(job?.status || 'preview').toLowerCase();
if (status === 'complete') return 'completed'; if (status === 'complete') return 'completed';
@@ -1195,15 +1256,45 @@ document.addEventListener('alpine:init', () => {
speedText(job) { speedText(job) {
if (!job) return '0 B/s'; if (!job) return '0 B/s';
const down = Number(job.download_speed_mbps || 0); const down = Number(job.download_speed_mbps || 0);
const up = Number(job.upload_speed_mbps || 0); return down.toFixed(2) + ' MiB/s';
return T.down + ' ' + down.toFixed(2) + ' MiB/s - ' + T.up + ' ' + up.toFixed(2) + ' MiB/s';
}, },
peerText(job) { peerText(job) {
if (!job) return T.peers + ' n/a'; if (!job) return 'n/a';
const live = job.peers_live == null ? '?' : job.peers_live; const live = job.peers_live == null ? '?' : job.peers_live;
const seen = job.peers_seen == null ? '?' : job.peers_seen; const seen = job.peers_seen == null ? '?' : job.peers_seen;
return T.peers + ' ' + live + ' ' + T.live + ' / ' + seen + ' ' + T.seen + (job.eta ? ' - ' + T.eta + ' ' + job.eta : ''); return live + ' ' + T.live + ' / ' + seen + ' ' + T.seen;
},
etaText(job) {
return job && job.eta ? job.eta : '';
},
progressDetailText(job) {
if (!job) return '';
const size = this.bytes(job.selected_size || job.total_size);
if (this.isCompleted(job)) return size;
return this.bytes(job.downloaded_bytes) + ' / ' + size;
},
actionButtonClass() {
if (this.isCurrentCompletedLocked()) return 'modal-btn-ghost';
return this.isCurrentDownloading() ? 'modal-btn-pause' : 'modal-btn-primary';
},
actionButtonText() {
if (this.isCurrentCompletedLocked()) return T.completed;
return this.isCurrentDownloading() ? T.pauseDownload : T.downloadSelected;
},
actionButtonDisabled() {
return this.loading || this.isCurrentCompletedLocked();
},
toggleDownloadAction() {
if (this.isCurrentCompletedLocked()) return;
if (this.isCurrentDownloading()) this.pause();
else this.start();
}, },
sessionMeta(job) { sessionMeta(job) {
@@ -1249,9 +1340,26 @@ document.addEventListener('alpine:init', () => {
if (this.currentJob && this.currentJob.id === job.id) this.currentJob = job; if (this.currentJob && this.currentJob.id === job.id) this.currentJob = job;
}, },
_isSelectedJob(id) {
return this.workspaceMode === 'session'
&& this.currentJob
&& this.currentJob.id === id
&& this.previewData
&& this.previewData.id === id;
},
_syncCurrentJobFromSessions() {
if (!this.currentJob || !this.previewData) return;
const selected = this.sessions.find(job => job.id === this.currentJob.id && job.id === this.previewData.id);
if (selected) this.currentJob = selected;
},
_applySession(data) { _applySession(data) {
const preview = data.preview || data; const preview = data.preview || data;
const job = data.job || null; const job = data.job || null;
this.workspaceMode = 'session';
this.file = null;
this.magnet = '';
this.previewData = preview; this.previewData = preview;
this.currentJob = job; this.currentJob = job;
const selected = Array.isArray(data.selected_files) && data.selected_files.length const selected = Array.isArray(data.selected_files) && data.selected_files.length
@@ -1269,6 +1377,7 @@ document.addEventListener('alpine:init', () => {
const data = await res.json(); const data = await res.json();
if (!res.ok) throw new Error(data.error || T.loadTorrentsFailed); if (!res.ok) throw new Error(data.error || T.loadTorrentsFailed);
this.sessions = Array.isArray(data) ? data : []; this.sessions = Array.isArray(data) ? data : [];
this._syncCurrentJobFromSessions();
} catch (err) { } catch (err) {
this._setMessage(err.message || String(err), true); this._setMessage(err.message || String(err), true);
} finally { } finally {
@@ -1278,6 +1387,7 @@ document.addEventListener('alpine:init', () => {
async openSession(id) { async openSession(id) {
if (!id || this.loading) return; if (!id || this.loading) return;
this._stopPoll();
this.loading = true; this.loading = true;
this._setMessage(T.openingSavedTorrent); this._setMessage(T.openingSavedTorrent);
try { try {
@@ -1310,6 +1420,7 @@ document.addEventListener('alpine:init', () => {
this.currentJob = null; this.currentJob = null;
this.treeRoot = null; this.treeRoot = null;
this.selected = new Set(); this.selected = new Set();
this.workspaceMode = this.sessions.length ? 'empty' : 'new';
} }
this._setMessage(T.torrentRemoved); this._setMessage(T.torrentRemoved);
} catch (err) { } catch (err) {
@@ -1343,6 +1454,7 @@ document.addEventListener('alpine:init', () => {
this.previewData = null; this.previewData = null;
this.treeRoot = null; this.treeRoot = null;
this.currentJob = null; this.currentJob = null;
this.workspaceMode = 'new';
this.selected = new Set(); this.selected = new Set();
this.expanded = new Set(); this.expanded = new Set();
this._setMessage(this.file ? T.readingTorrent : T.resolvingMagnet); this._setMessage(this.file ? T.readingTorrent : T.resolvingMagnet);
@@ -1554,7 +1666,7 @@ document.addEventListener('alpine:init', () => {
async start() { async start() {
if (!this.previewData || this.loading) return; if (!this.previewData || this.loading) return;
const selected = [...this.selected]; const selected = this.selectedArray();
if (selected.length === 0) { if (selected.length === 0) {
this._setMessage(T.selectOneFile, true); this._setMessage(T.selectOneFile, true);
return; return;
@@ -1596,8 +1708,7 @@ document.addEventListener('alpine:init', () => {
this.currentJob = data; this.currentJob = data;
this._rememberJob(data); this._rememberJob(data);
this._setMessage(T.downloadPaused); this._setMessage(T.downloadPaused);
if (this._pollTimer) clearInterval(this._pollTimer); this._stopPoll();
this._pollTimer = null;
await this.loadSessions(); await this.loadSessions();
} catch (err) { } catch (err) {
this._setMessage(err.message || String(err), true); this._setMessage(err.message || String(err), true);
@@ -1607,28 +1718,29 @@ document.addEventListener('alpine:init', () => {
}, },
_poll(id) { _poll(id) {
if (this._pollTimer) clearInterval(this._pollTimer); this._stopPoll();
this._pollJobId = id;
this._pollTimer = setInterval(async () => { this._pollTimer = setInterval(async () => {
try { try {
const res = await fetch(`/api/player/torrents/${id}/status`); const res = await fetch(`/api/player/torrents/${id}/status`);
const data = await res.json(); const data = await res.json();
if (!res.ok) throw new Error(data.error || T.statusFailed); if (!res.ok) throw new Error(data.error || T.statusFailed);
this.currentJob = data;
this._rememberJob(data); this._rememberJob(data);
this._setMessage( if (this._isSelectedJob(id)) {
this.statusLabel(data) + ' - ' + this.progressValue(data).toFixed(1) + '% - ' + this.bytes(data.downloaded_bytes), this.currentJob = data;
data.status === 'failed' this._setMessage(
); this.statusLabel(data) + ' - ' + this.progressValue(data).toFixed(1) + '% - ' + this.bytes(data.downloaded_bytes),
data.status === 'failed'
);
}
if (data.status === 'complete' || data.status === 'failed') { if (data.status === 'complete' || data.status === 'failed') {
clearInterval(this._pollTimer); this._stopPoll();
this._pollTimer = null;
this.loadSessions(); this.loadSessions();
this.loadAgentStatus(); this.loadAgentStatus();
} }
} catch (err) { } catch (err) {
this._setMessage(err.message || String(err), true); if (this._isSelectedJob(id)) this._setMessage(err.message || String(err), true);
clearInterval(this._pollTimer); this._stopPoll();
this._pollTimer = null;
} }
}, 2000); }, 2000);
}, },
+80 -3
View File
@@ -1864,6 +1864,38 @@ button.user-stat:hover {
cursor: pointer; cursor: pointer;
} }
.torrent-session-add {
width: 100%;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
border: 0;
border-bottom: 1px solid var(--border-color);
background: transparent;
color: var(--text-secondary);
text-align: left;
font: inherit;
font-size: 13px;
font-weight: 800;
}
.torrent-session-add:disabled {
cursor: default;
opacity: 0.6;
}
.torrent-session-add-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 999px;
border: 1px solid rgba(29,185,84,0.38);
color: #9ff0b9;
font-size: 16px;
line-height: 1;
}
.torrent-session-row:last-child { border-bottom: 0; } .torrent-session-row:last-child { border-bottom: 0; }
.torrent-session-row:hover, .torrent-session-row:hover,
.torrent-session-row.active { background: var(--bg-hover); } .torrent-session-row.active { background: var(--bg-hover); }
@@ -2005,12 +2037,44 @@ button.user-stat:hover {
} }
.torrent-progress-details { .torrent-progress-details {
display: flex; display: grid;
flex-wrap: wrap; grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px 12px; gap: 8px;
margin-top: 8px; margin-top: 8px;
color: var(--text-subdued); color: var(--text-subdued);
min-height: 38px;
}
.torrent-progress-details.completed {
grid-template-columns: minmax(0, 1fr);
}
.torrent-progress-metric {
min-width: 0;
min-height: 38px;
padding: 5px 7px;
border-radius: 6px;
background: var(--bg-secondary);
display: flex;
flex-direction: column;
justify-content: center;
gap: 2px;
}
.torrent-progress-label {
color: var(--text-muted);
font-size: 10px;
line-height: 12px;
text-transform: uppercase;
font-weight: 800;
}
.torrent-progress-value {
color: var(--text-secondary);
font-size: 12px; font-size: 12px;
line-height: 14px;
font-weight: 700;
overflow-wrap: anywhere;
} }
.history-modal { .history-modal {
@@ -2074,6 +2138,11 @@ button.user-stat:hover {
max-width: 360px; max-width: 360px;
} }
.torrent-import-panel,
.torrent-workspace-empty {
min-height: 150px;
}
.torrent-modal label { .torrent-modal label {
display: block; display: block;
margin-bottom: 6px; margin-bottom: 6px;
@@ -2753,6 +2822,14 @@ button.user-stat:hover {
gap: 4px; gap: 4px;
} }
.torrent-progress-details {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.torrent-progress-details.completed {
grid-template-columns: minmax(0, 1fr);
}
.torrent-modal h3 { .torrent-modal h3 {
margin-bottom: 12px; margin-bottom: 12px;
} }