Improved upload UI
Build and Publish / Build and Publish Docker Image (push) Successful in 3m2s

This commit is contained in:
Ultradesu
2026-05-26 16:59:36 +03:00
parent 82923c871e
commit d425bf3087
8 changed files with 738 additions and 97 deletions
+44 -13
View File
@@ -64,6 +64,15 @@
:class="{ error: $store.torrents.error }"
x-text="$store.torrents.message"></p>
</div>
<button class="torrent-modal-close"
@click="$store.torrents.close()"
title="{{ t.player_close }}"
aria-label="{{ t.player_close }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
<div class="torrent-client-status">
<span class="torrent-status-pill"
:class="{ active: $store.torrents.activeCount() > 0 }"
@@ -109,8 +118,6 @@
:style="'width:' + $store.torrents.progressValue(job) + '%'"></div>
</div>
</div>
<button class="torrent-session-remove"
@click.stop="$store.torrents.removeSession(job.id)">{{ t.player_delete }}</button>
</div>
</template>
<button type="button"
@@ -119,7 +126,7 @@
@click="$store.torrents.addNew()"
:disabled="$store.torrents.loading">
<span class="torrent-session-add-icon">+</span>
<span>{{ t.player_add_torrent }}</span>
<span>{{ t.player_upload }}</span>
</button>
</div>
</aside>
@@ -135,9 +142,10 @@
<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">
<label for="local-file-input">{{ t.player_local_files }}</label>
<input id="local-file-input" type="file" multiple accept="audio/*,.mp3,.flac,.wav,.m4a,.ogg,.opus,.aac"
@change="$store.torrents.setLocalFiles($event.target.files)">
<div class="torrent-upload-summary" x-text="$store.torrents.localUploadSummary()"></div>
</div>
<div>
<label for="torrent-magnet-input">{{ t.player_magnet_link }}</label>
@@ -145,10 +153,26 @@
x-model="$store.torrents.magnet"
placeholder="magnet:?xt=urn:btih:...">
</div>
<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>
<div class="torrent-upload-progress"
x-show="$store.torrents.uploadProgress > 0 || ($store.torrents.localFiles.length > 0 && $store.torrents.loading)">
<div class="torrent-progress-head">
<span x-text="$store.torrents.uploadProgress >= 100 ? T.uploadComplete : T.uploadingFiles"></span>
<span x-text="$store.torrents.uploadProgressText"></span>
</div>
<div class="torrent-progress-track">
<div class="torrent-progress-bar"
:style="'width:' + $store.torrents.uploadProgress + '%'"></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 }}
{{ t.player_upload_content }}
</button>
</div>
</div>
@@ -201,12 +225,19 @@
<div class="torrent-preview-meta"
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.actionButtonClass()"
@click="$store.torrents.toggleDownloadAction()"
:disabled="$store.torrents.actionButtonDisabled()">
<span x-text="$store.torrents.actionButtonText()"></span>
</button>
<div class="torrent-preview-actions">
<button class="modal-btn"
:class="$store.torrents.actionButtonClass()"
@click="$store.torrents.toggleDownloadAction()"
:disabled="$store.torrents.actionButtonDisabled()">
<span x-text="$store.torrents.actionButtonText()"></span>
</button>
<button class="modal-btn modal-btn-danger"
@click="$store.torrents.removeSession($store.torrents.previewData.id)"
:disabled="$store.torrents.loading">
{{ t.player_delete }}
</button>
</div>
</div>
<div class="torrent-tree-toolbar">
<div class="torrent-selected-summary"
+105 -3
View File
@@ -30,7 +30,11 @@ const T = {
queued: "{{ t.player_queued }}",
saved: "{{ t.player_saved }}",
chooseSavedOrAddTorrent: "{{ t.player_choose_saved_or_add_torrent }}",
uploadFailed: "{{ t.player_upload_failed }}",
uploadComplete: "{{ t.player_upload_complete }}",
uploadingFiles: "{{ t.player_uploading_files }}",
preview: "{{ t.player_preview }}",
resolving: "{{ t.player_resolving }}",
downloading: "{{ t.player_downloading }}",
moving: "{{ t.player_moving }}",
completed: "{{ t.player_completed }}",
@@ -1083,6 +1087,7 @@ document.addEventListener('alpine:init', () => {
Alpine.store('torrents', {
modal: false,
file: null,
localFiles: [],
magnet: '',
sessions: [],
loadingSessions: false,
@@ -1101,6 +1106,8 @@ document.addEventListener('alpine:init', () => {
queuedTasks: 0,
processingTasks: 0,
loadingAgentStatus: false,
uploadProgress: 0,
uploadProgressText: '',
open() {
this.modal = true;
@@ -1132,7 +1139,10 @@ document.addEventListener('alpine:init', () => {
this._stopPoll();
this.workspaceMode = 'new';
this.file = null;
this.localFiles = [];
this.magnet = '';
this.uploadProgress = 0;
this.uploadProgressText = '';
this.currentJob = null;
this.previewData = null;
this.treeRoot = null;
@@ -1208,6 +1218,7 @@ document.addEventListener('alpine:init', () => {
statusLabel(job) {
const labels = {
preview: T.preview,
resolving: T.resolving,
downloading: T.downloading,
moving: T.moving,
completed: T.completed,
@@ -1283,12 +1294,18 @@ document.addEventListener('alpine:init', () => {
},
actionButtonText() {
if (this.normalizedStatus(this.currentJob) === 'resolving') return T.resolving;
if (this.isCurrentCompletedLocked()) return T.completed;
return this.isCurrentDownloading() ? T.pauseDownload : T.downloadSelected;
},
actionButtonDisabled() {
return this.loading || this.isCurrentCompletedLocked();
return this.loading
|| this.isCurrentCompletedLocked()
|| this.normalizedStatus(this.currentJob) === 'resolving'
|| !this.previewData
|| !Array.isArray(this.previewData.files)
|| this.previewData.files.length === 0;
},
toggleDownloadAction() {
@@ -1336,7 +1353,7 @@ document.addEventListener('alpine:init', () => {
_rememberJob(job) {
if (!job || !job.id) return;
const rest = this.sessions.filter(item => item.id !== job.id);
this.sessions = [job, ...rest].sort((a, b) => String(b.updated_at || '').localeCompare(String(a.updated_at || '')));
this.sessions = [job, ...rest].sort((a, b) => String(b.created_at || '').localeCompare(String(a.created_at || '')));
if (this.currentJob && this.currentJob.id === job.id) this.currentJob = job;
},
@@ -1359,7 +1376,10 @@ document.addEventListener('alpine:init', () => {
const job = data.job || null;
this.workspaceMode = 'session';
this.file = null;
this.localFiles = [];
this.magnet = '';
this.uploadProgress = 0;
this.uploadProgressText = '';
this.previewData = preview;
this.currentJob = job;
const selected = Array.isArray(data.selected_files) && data.selected_files.length
@@ -1378,6 +1398,7 @@ document.addEventListener('alpine:init', () => {
if (!res.ok) throw new Error(data.error || T.loadTorrentsFailed);
this.sessions = Array.isArray(data) ? data : [];
this._syncCurrentJobFromSessions();
await this._refreshResolvedSelection();
} catch (err) {
this._setMessage(err.message || String(err), true);
} finally {
@@ -1385,6 +1406,19 @@ document.addEventListener('alpine:init', () => {
}
},
async _refreshResolvedSelection() {
if (!this.currentJob || !this.previewData || (this.previewData.files || []).length > 0) return;
const selected = this.sessions.find(job => job.id === this.currentJob.id);
if (!selected || this.normalizedStatus(selected) === 'resolving') return;
try {
const res = await fetch(`/api/player/torrents/session/${selected.id}`);
const data = await res.json();
if (!res.ok) return;
this._applySession(data);
this._setMessage(T.allFilesSelected);
} catch {}
},
async openSession(id) {
if (!id || this.loading) return;
this._stopPoll();
@@ -1442,8 +1476,76 @@ document.addEventListener('alpine:init', () => {
});
},
setLocalFiles(files) {
this.localFiles = Array.from(files || []);
},
localUploadBytes() {
return this.localFiles.reduce((sum, file) => sum + Number(file.size || 0), 0);
},
localUploadSummary() {
const count = this.localFiles.length;
if (count === 0) return '';
return count + ' ' + T.selected + ' - ' + this.bytes(this.localUploadBytes());
},
uploadLocalFile(file, loadedBefore, totalBytes) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', '/api/player/uploads/local');
xhr.setRequestHeader('Content-Type', file.type || 'application/octet-stream');
xhr.setRequestHeader('X-Furumusic-Filename', encodeURIComponent(file.name || 'upload.mp3'));
xhr.upload.onprogress = event => {
if (!event.lengthComputable || totalBytes <= 0) return;
const loaded = loadedBefore + event.loaded;
this.uploadProgress = Math.max(0, Math.min(100, loaded / totalBytes * 100));
this.uploadProgressText = this.uploadProgress.toFixed(1) + '%';
};
xhr.onload = () => {
let data = {};
try { data = JSON.parse(xhr.responseText || '{}'); } catch {}
if (xhr.status >= 200 && xhr.status < 300) resolve(data);
else reject(new Error(data.error || T.uploadFailed));
};
xhr.onerror = () => reject(new Error(T.uploadFailed));
xhr.send(file);
});
},
async uploadLocalFiles() {
if (this.loading || this.localFiles.length === 0) return;
this.loading = true;
this.uploadProgress = 0;
this.uploadProgressText = '0.0%';
this._setMessage(T.uploadingFiles);
const totalBytes = this.localUploadBytes();
let loadedBefore = 0;
try {
for (const file of this.localFiles) {
await this.uploadLocalFile(file, loadedBefore, totalBytes);
loadedBefore += Number(file.size || 0);
this.uploadProgress = totalBytes > 0 ? Math.min(100, loadedBefore / totalBytes * 100) : 100;
this.uploadProgressText = this.uploadProgress.toFixed(1) + '%';
}
this.localFiles = [];
this.uploadProgress = 100;
this.uploadProgressText = '100.0%';
this._setMessage(T.uploadComplete);
await this.loadAgentStatus();
} catch (err) {
this._setMessage(err.message || String(err), true);
} finally {
this.loading = false;
}
},
async preview() {
if (this.loading) return;
if (this.localFiles.length > 0) {
await this.uploadLocalFiles();
return;
}
const magnet = this.magnet.trim();
if (!this.file && !magnet) {
this._setMessage(T.chooseTorrent, true);
@@ -1472,7 +1574,7 @@ document.addEventListener('alpine:init', () => {
if (!res.ok) throw new Error(data.error || T.previewFailed);
this._applySession(data);
this._setMessage(T.allFilesSelected);
this._setMessage((data.preview?.files || []).length ? T.allFilesSelected : T.resolving);
await this.loadSessions();
} catch (err) {
this._setMessage(err.message || String(err), true);
+98 -40
View File
@@ -1741,6 +1741,11 @@ button.user-stat:hover {
color: #111;
}
.modal-btn-ghost { background: transparent; color: var(--text-secondary); }
.modal-btn-danger {
background: rgba(229,96,96,0.16);
color: #ffb9b9;
border: 1px solid rgba(229,96,96,0.32);
}
.modal-footer {
display: flex;
@@ -1749,9 +1754,10 @@ button.user-stat:hover {
}
.torrent-modal {
width: min(860px, calc(100vw - 32px));
max-width: 860px;
max-height: min(88dvh, 760px);
width: min(1180px, calc(100vw - 48px));
max-width: 1180px;
height: min(820px, calc(100dvh - 64px));
max-height: calc(100dvh - 64px);
overflow: hidden;
}
@@ -1761,6 +1767,8 @@ button.user-stat:hover {
justify-content: space-between;
gap: 14px;
margin-bottom: 12px;
flex: 0 0 auto;
position: relative;
}
.torrent-modal-head h3 {
@@ -1792,6 +1800,25 @@ button.user-stat:hover {
white-space: nowrap;
}
.torrent-modal-close {
display: none;
align-items: center;
justify-content: center;
flex: 0 0 auto;
width: 34px;
height: 34px;
border: 1px solid var(--border-color);
border-radius: 999px;
background: var(--bg-primary);
color: var(--text-secondary);
cursor: pointer;
}
.torrent-modal-close svg {
width: 18px;
height: 18px;
}
.torrent-status-pill.active {
border-color: rgba(29,185,84,0.42);
color: #9ff0b9;
@@ -1816,9 +1843,10 @@ button.user-stat:hover {
.torrent-manager-layout {
display: grid;
grid-template-columns: minmax(210px, 260px) minmax(0, 1fr);
grid-template-columns: minmax(260px, 320px) minmax(0, 1fr);
gap: 14px;
min-height: 0;
flex: 1 1 auto;
}
.torrent-manager-sidebar,
@@ -1851,13 +1879,14 @@ button.user-stat:hover {
.torrent-session-list {
overflow-y: auto;
min-height: 150px;
max-height: min(52vh, 470px);
min-height: 0;
max-height: none;
flex: 1 1 auto;
}
.torrent-session-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
grid-template-columns: minmax(0, 1fr);
gap: 8px;
padding: 10px 12px;
border-bottom: 1px solid var(--border-color);
@@ -1935,19 +1964,28 @@ button.user-stat:hover {
}
.torrent-status-badge.status-preview {
background: rgba(122,162,255,0.14);
color: #a8c0ff;
background: rgba(122,162,255,0.16);
color: #adc3ff;
}
.torrent-status-badge.status-downloading,
.torrent-status-badge.status-moving {
.torrent-status-badge.status-resolving {
background: rgba(182,141,255,0.16);
color: #d0b6ff;
}
.torrent-status-badge.status-downloading {
background: rgba(29,185,84,0.16);
color: #9ff0b9;
}
.torrent-status-badge.status-moving {
background: rgba(75,198,240,0.16);
color: #a8e8ff;
}
.torrent-status-badge.status-completed {
background: rgba(105,214,161,0.2);
color: #b8ffd2;
background: rgba(110,211,123,0.16);
color: #b8f7be;
}
.torrent-status-badge.status-paused {
@@ -1985,23 +2023,6 @@ button.user-stat:hover {
transition: width 0.25s ease;
}
.torrent-session-remove {
align-self: flex-start;
border: 1px solid rgba(229,96,96,0.24);
background: rgba(229,96,96,0.12);
color: #ffb9b9;
border-radius: 5px;
padding: 4px 7px;
font-size: 11px;
font-weight: 800;
cursor: pointer;
}
.torrent-session-remove:hover {
background: rgba(229,96,96,0.2);
color: #ffd7d7;
}
.torrent-progress-card {
margin-top: 10px;
padding: 10px 12px;
@@ -2143,6 +2164,21 @@ button.user-stat:hover {
min-height: 150px;
}
.torrent-upload-summary {
min-height: 16px;
margin-top: 5px;
color: var(--text-subdued);
font-size: 11px;
}
.torrent-upload-progress {
margin-top: 10px;
padding: 10px 12px;
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-primary);
}
.torrent-modal label {
display: block;
margin-bottom: 6px;
@@ -2187,6 +2223,13 @@ button.user-stat:hover {
margin-top: 16px;
}
.torrent-preview-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
}
.torrent-preview-title {
min-width: 0;
font-size: 14px;
@@ -2243,7 +2286,7 @@ button.user-stat:hover {
margin-top: 10px;
overflow-y: auto;
min-height: 140px;
max-height: min(46vh, 420px);
max-height: none;
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-primary);
@@ -2442,7 +2485,7 @@ button.user-stat:hover {
}
.torrent-modal {
width: calc(100vw - 24px);
width: min(1180px, calc(100vw - 32px));
}
.card-grid {
@@ -2786,34 +2829,49 @@ button.user-stat:hover {
}
.info-modal,
.torrent-modal,
.history-modal {
width: min(400px, calc(100vw - 24px));
max-width: 400px;
}
.torrent-modal {
max-height: min(82dvh, 640px);
padding: 20px;
width: 100vw;
max-width: none;
height: 100dvh;
max-height: none;
border-radius: 0;
padding: calc(14px + env(safe-area-inset-top)) 14px calc(14px + env(safe-area-inset-bottom));
overflow: hidden;
}
.torrent-modal-head {
flex-direction: column;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
align-items: start;
}
.torrent-client-status {
grid-column: 1 / -1;
justify-content: flex-start;
}
.torrent-modal-close {
display: inline-flex;
}
.torrent-manager-layout {
grid-template-columns: 1fr;
gap: 10px;
}
.torrent-session-list {
max-height: 148px;
min-height: 96px;
max-height: none;
min-height: 0;
}
.torrent-manager-sidebar {
flex: 0 0 178px;
}
.torrent-progress-head {
@@ -2867,8 +2925,8 @@ button.user-stat:hover {
}
.torrent-file-tree {
min-height: 120px;
max-height: min(32dvh, 260px);
min-height: 0;
max-height: none;
}
.torrent-tree-row {