This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "furumusic"
|
||||
version = "0.1.12"
|
||||
version = "0.1.13"
|
||||
edition = "2024"
|
||||
description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL"
|
||||
|
||||
|
||||
@@ -357,6 +357,8 @@ translations! {
|
||||
player_saved_torrents: "Saved torrents" , "Сохранённые торренты";
|
||||
player_refresh: "Refresh" , "Обновить";
|
||||
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_magnet_link: "Magnet link" , "Magnet-ссылка";
|
||||
player_preview_content: "Preview content" , "Предпросмотр";
|
||||
@@ -372,6 +374,8 @@ translations! {
|
||||
player_failed: "Failed" , "Ошибка";
|
||||
player_paused: "Paused" , "Пауза";
|
||||
player_no_torrent_selected: "No torrent selected" , "Торрент не выбран";
|
||||
player_downloaded: "Downloaded" , "Загружено";
|
||||
player_speed: "Speed" , "Скорость";
|
||||
player_down: "down" , "вниз";
|
||||
player_up: "up" , "вверх";
|
||||
player_peers: "peers" , "пиры";
|
||||
|
||||
+12
-4
@@ -194,10 +194,14 @@ impl TorrentSessionRow {
|
||||
self.status.as_str()
|
||||
};
|
||||
let stats = handle.map(|h| h.stats());
|
||||
let downloaded_bytes = stats
|
||||
let mut downloaded_bytes = stats
|
||||
.as_ref()
|
||||
.map(|s| s.progress_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
|
||||
.as_ref()
|
||||
.map(|s| s.uploaded_bytes)
|
||||
@@ -225,7 +229,7 @@ impl TorrentSessionRow {
|
||||
status: status.to_string(),
|
||||
client_state: stats.as_ref().map(|s| s.state.to_string()),
|
||||
total_size: i64_to_u64(self.total_size),
|
||||
selected_size: i64_to_u64(self.selected_size),
|
||||
selected_size,
|
||||
downloaded_bytes,
|
||||
uploaded_bytes,
|
||||
progress_percent,
|
||||
@@ -313,10 +317,14 @@ impl TorrentJob {
|
||||
|
||||
fn dto(&self) -> TorrentJobDto {
|
||||
let stats = self.handle.as_ref().map(|h| h.stats());
|
||||
let downloaded_bytes = stats
|
||||
let mut downloaded_bytes = stats
|
||||
.as_ref()
|
||||
.map(|s| s.progress_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
|
||||
.as_ref()
|
||||
.map(|s| s.uploaded_bytes)
|
||||
@@ -336,7 +344,7 @@ impl TorrentJob {
|
||||
status: self.status.as_str().to_string(),
|
||||
client_state: stats.as_ref().map(|s| s.state.to_string()),
|
||||
total_size: self.total_size(),
|
||||
selected_size: self.selected_size(),
|
||||
selected_size,
|
||||
downloaded_bytes,
|
||||
uploaded_bytes,
|
||||
progress_percent: if self.status == TorrentJobStatus::Complete {
|
||||
|
||||
@@ -74,7 +74,7 @@
|
||||
<span x-text="$store.torrents.agentSummary()"></span>
|
||||
</span>
|
||||
<span class="torrent-status-pill"
|
||||
x-text="$store.torrents.sessions.length + ' saved'"></span>
|
||||
x-text="$store.torrents.sessions.length + ' ' + T.saved"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -94,7 +94,7 @@
|
||||
</template>
|
||||
<template x-for="job in $store.torrents.sessions" :key="job.id">
|
||||
<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)">
|
||||
<div class="torrent-session-main">
|
||||
<div class="torrent-session-topline">
|
||||
@@ -113,29 +113,46 @@
|
||||
@click.stop="$store.torrents.removeSession(job.id)">{{ t.player_delete }}</button>
|
||||
</div>
|
||||
</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>
|
||||
</aside>
|
||||
|
||||
<section class="torrent-workspace">
|
||||
<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">
|
||||
<template x-if="$store.torrents.workspaceMode === 'empty'">
|
||||
<div class="empty-state torrent-workspace-empty">
|
||||
<p x-text="T.chooseSavedOrAddTorrent"></p>
|
||||
</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:...">
|
||||
</template>
|
||||
|
||||
<template x-if="$store.torrents.isImporting()">
|
||||
<div class="torrent-import-panel">
|
||||
<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 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>
|
||||
|
||||
<template x-if="$store.torrents.currentJob">
|
||||
<div class="torrent-progress-card">
|
||||
@@ -147,10 +164,32 @@
|
||||
<div class="torrent-progress-bar"
|
||||
:style="'width:' + $store.torrents.progressValue($store.torrents.currentJob) + '%'"></div>
|
||||
</div>
|
||||
<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>
|
||||
<span x-text="$store.torrents.speedText($store.torrents.currentJob)"></span>
|
||||
<span x-text="$store.torrents.peerText($store.torrents.currentJob)"></span>
|
||||
<div class="torrent-progress-details"
|
||||
:class="{ completed: $store.torrents.isCompleted($store.torrents.currentJob) }">
|
||||
<span class="torrent-progress-metric">
|
||||
<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>
|
||||
</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>
|
||||
</div>
|
||||
<button class="modal-btn"
|
||||
:class="$store.torrents.isCurrentDownloading() ? 'modal-btn-pause' : 'modal-btn-primary'"
|
||||
@click="$store.torrents.isCurrentDownloading() ? $store.torrents.pause() : $store.torrents.start()"
|
||||
:disabled="$store.torrents.loading">
|
||||
<span x-text="$store.torrents.isCurrentDownloading() ? '{{ t.player_pause_download }}' : '{{ t.player_download_selected }}'"></span>
|
||||
:class="$store.torrents.actionButtonClass()"
|
||||
@click="$store.torrents.toggleDownloadAction()"
|
||||
:disabled="$store.torrents.actionButtonDisabled()">
|
||||
<span x-text="$store.torrents.actionButtonText()"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="torrent-tree-toolbar">
|
||||
|
||||
+130
-18
@@ -29,6 +29,7 @@ const T = {
|
||||
processing: "{{ t.player_processing }}",
|
||||
queued: "{{ t.player_queued }}",
|
||||
saved: "{{ t.player_saved }}",
|
||||
chooseSavedOrAddTorrent: "{{ t.player_choose_saved_or_add_torrent }}",
|
||||
preview: "{{ t.player_preview }}",
|
||||
downloading: "{{ t.player_downloading }}",
|
||||
moving: "{{ t.player_moving }}",
|
||||
@@ -36,6 +37,8 @@ const T = {
|
||||
failed: "{{ t.player_failed }}",
|
||||
paused: "{{ t.player_paused }}",
|
||||
noTorrentSelected: "{{ t.player_no_torrent_selected }}",
|
||||
downloaded: "{{ t.player_downloaded }}",
|
||||
speed: "{{ t.player_speed }}",
|
||||
down: "{{ t.player_down }}",
|
||||
up: "{{ t.player_up }}",
|
||||
peers: "{{ t.player_peers }}",
|
||||
@@ -43,6 +46,8 @@ const T = {
|
||||
seen: "{{ t.player_seen }}",
|
||||
eta: "{{ t.player_eta }}",
|
||||
selected: "{{ t.player_selected }}",
|
||||
downloadSelected: "{{ t.player_download_selected }}",
|
||||
pauseDownload: "{{ t.player_pause_download }}",
|
||||
chooseTorrent: "{{ t.player_choose_torrent }}",
|
||||
readingTorrent: "{{ t.player_reading_torrent }}",
|
||||
resolvingMagnet: "{{ t.player_resolving_magnet }}",
|
||||
@@ -1083,6 +1088,7 @@ document.addEventListener('alpine:init', () => {
|
||||
loadingSessions: false,
|
||||
currentJob: null,
|
||||
previewData: null,
|
||||
workspaceMode: 'empty',
|
||||
treeRoot: null,
|
||||
selected: new Set(),
|
||||
expanded: new Set(),
|
||||
@@ -1090,6 +1096,7 @@ document.addEventListener('alpine:init', () => {
|
||||
message: '',
|
||||
error: false,
|
||||
_pollTimer: null,
|
||||
_pollJobId: null,
|
||||
_refreshTimer: null,
|
||||
queuedTasks: 0,
|
||||
processingTasks: 0,
|
||||
@@ -1107,6 +1114,31 @@ document.addEventListener('alpine:init', () => {
|
||||
close() {
|
||||
this.modal = false;
|
||||
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) {
|
||||
@@ -1138,6 +1170,35 @@ document.addEventListener('alpine:init', () => {
|
||||
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) {
|
||||
const status = String(job?.status || 'preview').toLowerCase();
|
||||
if (status === 'complete') return 'completed';
|
||||
@@ -1195,15 +1256,45 @@ document.addEventListener('alpine:init', () => {
|
||||
speedText(job) {
|
||||
if (!job) return '0 B/s';
|
||||
const down = Number(job.download_speed_mbps || 0);
|
||||
const up = Number(job.upload_speed_mbps || 0);
|
||||
return T.down + ' ' + down.toFixed(2) + ' MiB/s - ' + T.up + ' ' + up.toFixed(2) + ' MiB/s';
|
||||
return down.toFixed(2) + ' MiB/s';
|
||||
},
|
||||
|
||||
peerText(job) {
|
||||
if (!job) return T.peers + ' n/a';
|
||||
if (!job) return 'n/a';
|
||||
const live = job.peers_live == null ? '?' : job.peers_live;
|
||||
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) {
|
||||
@@ -1249,9 +1340,26 @@ document.addEventListener('alpine:init', () => {
|
||||
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) {
|
||||
const preview = data.preview || data;
|
||||
const job = data.job || null;
|
||||
this.workspaceMode = 'session';
|
||||
this.file = null;
|
||||
this.magnet = '';
|
||||
this.previewData = preview;
|
||||
this.currentJob = job;
|
||||
const selected = Array.isArray(data.selected_files) && data.selected_files.length
|
||||
@@ -1269,6 +1377,7 @@ document.addEventListener('alpine:init', () => {
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || T.loadTorrentsFailed);
|
||||
this.sessions = Array.isArray(data) ? data : [];
|
||||
this._syncCurrentJobFromSessions();
|
||||
} catch (err) {
|
||||
this._setMessage(err.message || String(err), true);
|
||||
} finally {
|
||||
@@ -1278,6 +1387,7 @@ document.addEventListener('alpine:init', () => {
|
||||
|
||||
async openSession(id) {
|
||||
if (!id || this.loading) return;
|
||||
this._stopPoll();
|
||||
this.loading = true;
|
||||
this._setMessage(T.openingSavedTorrent);
|
||||
try {
|
||||
@@ -1310,6 +1420,7 @@ document.addEventListener('alpine:init', () => {
|
||||
this.currentJob = null;
|
||||
this.treeRoot = null;
|
||||
this.selected = new Set();
|
||||
this.workspaceMode = this.sessions.length ? 'empty' : 'new';
|
||||
}
|
||||
this._setMessage(T.torrentRemoved);
|
||||
} catch (err) {
|
||||
@@ -1343,6 +1454,7 @@ document.addEventListener('alpine:init', () => {
|
||||
this.previewData = null;
|
||||
this.treeRoot = null;
|
||||
this.currentJob = null;
|
||||
this.workspaceMode = 'new';
|
||||
this.selected = new Set();
|
||||
this.expanded = new Set();
|
||||
this._setMessage(this.file ? T.readingTorrent : T.resolvingMagnet);
|
||||
@@ -1554,7 +1666,7 @@ document.addEventListener('alpine:init', () => {
|
||||
|
||||
async start() {
|
||||
if (!this.previewData || this.loading) return;
|
||||
const selected = [...this.selected];
|
||||
const selected = this.selectedArray();
|
||||
if (selected.length === 0) {
|
||||
this._setMessage(T.selectOneFile, true);
|
||||
return;
|
||||
@@ -1596,8 +1708,7 @@ document.addEventListener('alpine:init', () => {
|
||||
this.currentJob = data;
|
||||
this._rememberJob(data);
|
||||
this._setMessage(T.downloadPaused);
|
||||
if (this._pollTimer) clearInterval(this._pollTimer);
|
||||
this._pollTimer = null;
|
||||
this._stopPoll();
|
||||
await this.loadSessions();
|
||||
} catch (err) {
|
||||
this._setMessage(err.message || String(err), true);
|
||||
@@ -1607,28 +1718,29 @@ document.addEventListener('alpine:init', () => {
|
||||
},
|
||||
|
||||
_poll(id) {
|
||||
if (this._pollTimer) clearInterval(this._pollTimer);
|
||||
this._stopPoll();
|
||||
this._pollJobId = id;
|
||||
this._pollTimer = setInterval(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/player/torrents/${id}/status`);
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || T.statusFailed);
|
||||
this.currentJob = data;
|
||||
this._rememberJob(data);
|
||||
this._setMessage(
|
||||
this.statusLabel(data) + ' - ' + this.progressValue(data).toFixed(1) + '% - ' + this.bytes(data.downloaded_bytes),
|
||||
data.status === 'failed'
|
||||
);
|
||||
if (this._isSelectedJob(id)) {
|
||||
this.currentJob = data;
|
||||
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') {
|
||||
clearInterval(this._pollTimer);
|
||||
this._pollTimer = null;
|
||||
this._stopPoll();
|
||||
this.loadSessions();
|
||||
this.loadAgentStatus();
|
||||
}
|
||||
} catch (err) {
|
||||
this._setMessage(err.message || String(err), true);
|
||||
clearInterval(this._pollTimer);
|
||||
this._pollTimer = null;
|
||||
if (this._isSelectedJob(id)) this._setMessage(err.message || String(err), true);
|
||||
this._stopPoll();
|
||||
}
|
||||
}, 2000);
|
||||
},
|
||||
|
||||
@@ -1864,6 +1864,38 @@ button.user-stat:hover {
|
||||
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:hover,
|
||||
.torrent-session-row.active { background: var(--bg-hover); }
|
||||
@@ -2005,12 +2037,44 @@ button.user-stat:hover {
|
||||
}
|
||||
|
||||
.torrent-progress-details {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 12px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
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;
|
||||
line-height: 14px;
|
||||
font-weight: 700;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.history-modal {
|
||||
@@ -2074,6 +2138,11 @@ button.user-stat:hover {
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.torrent-import-panel,
|
||||
.torrent-workspace-empty {
|
||||
min-height: 150px;
|
||||
}
|
||||
|
||||
.torrent-modal label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
@@ -2753,6 +2822,14 @@ button.user-stat:hover {
|
||||
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 {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user