PLAYER: fixe i8n
Build and Publish / Build and Publish Docker Image (push) Successful in 3m23s

This commit is contained in:
Ultradesu
2026-06-01 18:33:39 +03:00
parent 27ee56c5b7
commit c244b3d4d8
7 changed files with 427 additions and 129 deletions
Generated
+1 -1
View File
@@ -1418,7 +1418,7 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
[[package]] [[package]]
name = "furumusic" name = "furumusic"
version = "0.2.11" version = "0.2.12"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "furumusic" name = "furumusic"
version = "0.2.12" version = "0.2.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"
+64
View File
@@ -386,7 +386,71 @@ 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_import: "Import" , "Импорт";
player_upload: "Upload" , "Загрузить"; player_upload: "Upload" , "Загрузить";
player_my_uploads: "My uploads" , "Мои загрузки";
player_my_uploaded_tracks: "My uploaded tracks" , "Мои загруженные треки";
player_no_uploaded_tracks: "No uploaded tracks yet" , "Загруженных треков пока нет";
player_needs_approval: "Needs approval" , "Нужно подтверждение";
player_pending_or_failed: "pending or failed" , "ожидают или с ошибкой";
player_no_tracks_need_approval: "No tracks need approval" , "Нет треков для подтверждения";
player_queued_processing: "Queued / processing" , "В очереди / обработке";
player_showing: "Showing" , "Показано";
player_status: "Status" , "Статус";
player_file: "File" , "Файл";
player_created: "Created" , "Создано";
player_updated: "Updated" , "Обновлено";
player_error: "Error" , "Ошибка";
player_pending: "Pending" , "Ожидает";
player_artist: "Artist" , "Артист";
player_album: "Album" , "Альбом";
player_album_artists: "Album artists" , "Артисты альбома";
player_featured: "Featured" , "При участии";
player_featured_short: "feat." , "уч.";
player_track_number: "Track #" , "Трек #";
player_disc_number: "Disc #" , "Диск #";
player_genre: "Genre" , "Жанр";
player_notes: "Notes" , "Заметки";
player_type_unchanged: "Type unchanged" , "Тип без изменений";
player_visibility_unchanged: "Visibility unchanged" , "Видимость без изменений";
player_visible: "Visible" , "Видимый";
player_hidden: "Hidden" , "Скрыт";
player_no_year: "no year" , "год неизвестен";
player_apply: "Apply" , "Применить";
player_edit: "Edit" , "Редактировать";
player_edit_release: "Edit release" , "Редактировать релиз";
player_edit_track: "Edit track" , "Редактировать трек";
player_edit_metadata: "Edit metadata" , "Редактировать метаданные";
player_metadata: "Metadata" , "Метаданные";
player_release_metadata: "Release metadata" , "Метаданные релиза";
player_track_metadata: "Track metadata" , "Метаданные трека";
player_approve_metadata: "Approve metadata" , "Подтвердить метаданные";
player_delete_review: "Delete review" , "Удалить проверку";
player_approve: "Approve" , "Подтвердить";
player_save_track: "Save track" , "Сохранить трек";
player_save_release: "Save release" , "Сохранить релиз";
player_artists_placeholder: "Artist, Artist" , "Артист, артист";
player_artist_featured_placeholder: "Artist, Featured Artist" , "Артист, приглашённый артист";
player_release_type_album: "Album" , "Альбом";
player_release_type_single: "Single" , "Сингл";
player_release_type_ep: "EP" , "EP";
player_release_type_compilation: "Compilation" , "Сборник";
player_release_type_mixtape: "Mixtape" , "Микстейп";
player_release_type_live: "Live" , "Концерт";
player_release_type_soundtrack: "Soundtrack" , "Саундтрек";
player_release_type_remix: "Remix" , "Ремикс";
player_release_type_demo: "Demo" , "Демо";
player_failed_load_uploaded_tracks: "Failed to load uploaded tracks" , "Не удалось загрузить загруженные треки";
player_failed_save_track: "Failed to save track" , "Не удалось сохранить трек";
player_track_metadata_saved: "Track metadata saved" , "Метаданные трека сохранены";
player_failed_save_release: "Failed to save release" , "Не удалось сохранить релиз";
player_release_metadata_saved: "Release metadata saved" , "Метаданные релиза сохранены";
player_failed_delete_review: "Failed to delete review" , "Не удалось удалить проверку";
player_review_deleted: "Review deleted" , "Проверка удалена";
player_failed_approve_review: "Failed to approve review" , "Не удалось подтвердить проверку";
player_track_approved_imported: "Track approved and imported" , "Трек подтверждён и импортирован";
player_failed_update_selected_tracks: "Failed to update selected tracks" , "Не удалось обновить выбранные треки";
player_selected_tracks_updated: "Selected tracks updated" , "Выбранные треки обновлены";
player_choose_saved_or_add_torrent: "Choose a saved item or upload new files." , "Выберите сохранённый элемент или загрузите новые файлы."; player_choose_saved_or_add_torrent: "Choose a saved item or upload new files." , "Выберите сохранённый элемент или загрузите новые файлы.";
player_local_files: "Local audio files" , "Локальные аудиофайлы"; player_local_files: "Local audio files" , "Локальные аудиофайлы";
player_torrent_file: "Torrent file" , "Torrent-файл"; player_torrent_file: "Torrent file" , "Torrent-файл";
+1 -1
View File
@@ -2360,7 +2360,7 @@ async fn load_user_upload_queue(
ORDER BY ORDER BY
CASE status WHEN 'processing' THEN 0 ELSE 1 END, CASE status WHEN 'processing' THEN 0 ELSE 1 END,
created_at DESC created_at DESC
LIMIT 20"#, LIMIT 100"#,
) )
.bind(uploaded_by_pattern) .bind(uploaded_by_pattern)
.fetch_all(pool) .fetch_all(pool)
+128 -101
View File
@@ -122,11 +122,11 @@
<div class="torrent-tabs"> <div class="torrent-tabs">
<button class="torrent-tab-btn" <button class="torrent-tab-btn"
:class="{ active: $store.torrents.activeTab === 'import' }" :class="{ active: $store.torrents.activeTab === 'import' }"
@click="$store.torrents.showImportTab()">Import</button> @click="$store.torrents.showImportTab()">{{ t.player_import }}</button>
<button class="torrent-tab-btn" <button class="torrent-tab-btn"
:class="{ active: $store.torrents.activeTab === 'uploads' }" :class="{ active: $store.torrents.activeTab === 'uploads' }"
@click="$store.torrents.showUploadsTab()"> @click="$store.torrents.showUploadsTab()">
<span>My uploads</span> <span>{{ t.player_my_uploads }}</span>
<span class="torrent-tab-count" <span class="torrent-tab-count"
x-show="$store.torrents.uploadPendingTotal + $store.torrents.uploadQueuedTotal > 0" x-show="$store.torrents.uploadPendingTotal + $store.torrents.uploadQueuedTotal > 0"
x-text="$store.torrents.uploadPendingTotal + $store.torrents.uploadQueuedTotal"></span> x-text="$store.torrents.uploadPendingTotal + $store.torrents.uploadQueuedTotal"></span>
@@ -347,7 +347,7 @@
<section class="upload-manager-panel"> <section class="upload-manager-panel">
<div class="upload-manager-head"> <div class="upload-manager-head">
<div> <div>
<h4>My uploaded tracks</h4> <h4>{{ t.player_my_uploaded_tracks }}</h4>
<p x-text="$store.torrents.uploadSummary()"></p> <p x-text="$store.torrents.uploadSummary()"></p>
</div> </div>
<button class="modal-btn modal-btn-ghost" <button class="modal-btn modal-btn-ghost"
@@ -357,22 +357,22 @@
<template x-if="$store.torrents.uploadLoaded && $store.torrents.uploadTracks.length === 0 && $store.torrents.uploadPending.length === 0 && $store.torrents.uploadQueued.length === 0"> <template x-if="$store.torrents.uploadLoaded && $store.torrents.uploadTracks.length === 0 && $store.torrents.uploadPending.length === 0 && $store.torrents.uploadQueued.length === 0">
<div class="empty-state torrent-workspace-empty"> <div class="empty-state torrent-workspace-empty">
<p>No uploaded tracks yet</p> <p>{{ t.player_no_uploaded_tracks }}</p>
</div> </div>
</template> </template>
<div class="upload-manager-grid"> <div class="upload-manager-grid">
<aside class="upload-review-column"> <aside class="upload-review-column">
<div class="upload-panel-card"> <div class="upload-panel-card">
<div class="upload-panel-title">Needs approval</div> <div class="upload-panel-title">{{ t.player_needs_approval }}</div>
<p class="upload-panel-subtitle" x-text="$store.torrents.uploadPendingTotal + ' pending or failed'"></p> <p class="upload-panel-subtitle" x-text="$store.torrents.uploadPendingTotal + ' {{ t.player_pending_or_failed }}'"></p>
<template x-if="$store.torrents.uploadPending.length === 0"> <template x-if="$store.torrents.uploadPending.length === 0">
<div class="upload-mini-empty">No tracks need approval</div> <div class="upload-mini-empty">{{ t.player_no_tracks_need_approval }}</div>
</template> </template>
<div class="upload-review-list"> <div class="upload-review-list">
<template x-for="item in $store.torrents.uploadPending" :key="item.id"> <template x-for="item in $store.torrents.uploadPending" :key="item.id">
<button class="upload-review-row" :class="{ active: $store.torrents.uploadReviewEditId === item.id, failed: item.status === 'failed' }" @click="$store.torrents.editUploadReview(item)"> <button class="upload-review-row" :class="{ active: $store.torrents.uploadReviewEditId === item.id, failed: item.status === 'failed' }" @click="$store.torrents.editUploadReview(item)">
<span class="torrent-status-badge" :class="'status-' + item.status" x-text="item.status"></span> <span class="torrent-status-badge" :class="'status-' + item.status" x-text="$store.torrents.uploadStatusLabel(item.status)"></span>
<span class="upload-review-name" x-text="item.filename"></span> <span class="upload-review-name" x-text="item.filename"></span>
<span class="upload-review-error" x-show="item.error_message" x-text="item.error_message"></span> <span class="upload-review-error" x-show="item.error_message" x-text="item.error_message"></span>
</button> </button>
@@ -381,67 +381,94 @@
</div> </div>
<template x-if="$store.torrents.uploadQueuedTotal > 0"> <template x-if="$store.torrents.uploadQueuedTotal > 0">
<div class="upload-panel-card upload-queue-panel"> <div class="upload-panel-card upload-queue-panel">
<div class="upload-panel-title">Queued / processing</div> <div class="upload-panel-title upload-panel-title-row">
<span>{{ t.player_queued_processing }}</span>
<div class="upload-queue-nav" x-show="$store.torrents.uploadQueued.length > $store.torrents.uploadQueuePageSize">
<span class="upload-queue-range" x-text="$store.torrents.uploadQueueRangeText()"></span>
<button type="button"
class="upload-queue-nav-btn"
@click="$store.torrents.uploadQueuePrev()"
:disabled="!$store.torrents.uploadQueueCanPrev()"
title="{{ t.player_previous }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4"><polyline points="15 18 9 12 15 6"/></svg>
</button>
<button type="button"
class="upload-queue-nav-btn"
@click="$store.torrents.uploadQueueNext()"
:disabled="!$store.torrents.uploadQueueCanNext()"
title="{{ t.player_next }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4"><polyline points="9 18 15 12 9 6"/></svg>
</button>
</div>
</div>
<template x-for="item in $store.torrents.compactQueuedUploads()" :key="item.id"> <template x-for="item in $store.torrents.compactQueuedUploads()" :key="item.id">
<div class="upload-queue-row"> <div class="upload-queue-row" :title="$store.torrents.uploadQueueTooltip(item)">
<span class="torrent-status-badge" :class="'status-' + item.status" x-text="item.status"></span> <span class="torrent-status-badge" :class="'status-' + item.status" x-text="$store.torrents.uploadStatusLabel(item.status)"></span>
<span class="upload-queue-name" x-text="item.filename"></span> <span class="upload-queue-name" x-text="item.filename"></span>
</div> </div>
</template> </template>
<div class="upload-mini-empty" x-show="$store.torrents.uploadQueuedTotal > $store.torrents.uploadQueued.length" x-text="'Showing ' + $store.torrents.uploadQueued.length + ' of ' + $store.torrents.uploadQueuedTotal"></div> <div class="upload-mini-empty" x-show="$store.torrents.uploadQueuedTotal > $store.torrents.uploadQueued.length" x-text="$store.torrents.uploadQueueOverflowText()"></div>
</div> </div>
</template> </template>
</aside> </aside>
<section class="upload-library-column"> <section class="upload-library-column">
<div class="upload-bulk-bar" x-show="$store.torrents.uploadSelectedCount() > 0"> <div class="upload-bulk-bar" x-show="$store.torrents.uploadSelectedCount() > 0">
<div class="upload-bulk-title" x-text="$store.torrents.uploadSelectedCount() + ' selected'"></div> <div class="upload-bulk-title" x-text="$store.torrents.uploadSelectedCount() + ' {{ t.player_selected }}'"></div>
<input type="text" placeholder="Artists" x-model="$store.torrents.uploadBulkDraft.artists"> <input type="text" placeholder="{{ t.player_artists }}" x-model="$store.torrents.uploadBulkDraft.artists">
<input type="text" placeholder="Featured" x-model="$store.torrents.uploadBulkDraft.featured_artists"> <input type="text" placeholder="{{ t.player_featured }}" x-model="$store.torrents.uploadBulkDraft.featured_artists">
<input type="text" placeholder="Album" x-model="$store.torrents.uploadBulkDraft.release_title"> <input type="text" placeholder="{{ t.player_album }}" x-model="$store.torrents.uploadBulkDraft.release_title">
<input type="number" placeholder="Year" x-model="$store.torrents.uploadBulkDraft.release_year"> <input type="number" placeholder="{{ t.player_year }}" x-model="$store.torrents.uploadBulkDraft.release_year">
<select x-model="$store.torrents.uploadBulkDraft.release_type"> <select x-model="$store.torrents.uploadBulkDraft.release_type">
<option value="">Type unchanged</option><option value="album">Album</option><option value="single">Single</option><option value="ep">EP</option><option value="compilation">Compilation</option><option value="mixtape">Mixtape</option><option value="live">Live</option><option value="soundtrack">Soundtrack</option><option value="remix">Remix</option><option value="demo">Demo</option> <option value="">{{ t.player_type_unchanged }}</option><option value="album">{{ t.player_release_type_album }}</option><option value="single">{{ t.player_release_type_single }}</option><option value="ep">{{ t.player_release_type_ep }}</option><option value="compilation">{{ t.player_release_type_compilation }}</option><option value="mixtape">{{ t.player_release_type_mixtape }}</option><option value="live">{{ t.player_release_type_live }}</option><option value="soundtrack">{{ t.player_release_type_soundtrack }}</option><option value="remix">{{ t.player_release_type_remix }}</option><option value="demo">{{ t.player_release_type_demo }}</option>
</select> </select>
<select x-model="$store.torrents.uploadBulkDraft.hidden"> <select x-model="$store.torrents.uploadBulkDraft.hidden">
<option value="">Visibility unchanged</option><option value="false">Visible</option><option value="true">Hidden</option> <option value="">{{ t.player_visibility_unchanged }}</option><option value="false">{{ t.player_visible }}</option><option value="true">{{ t.player_hidden }}</option>
</select> </select>
<button class="modal-btn modal-btn-primary" @click="$store.torrents.saveUploadBulkEdit()" :disabled="$store.torrents.uploadBulkSaving">Apply</button> <button class="modal-btn modal-btn-primary" @click="$store.torrents.saveUploadBulkEdit()" :disabled="$store.torrents.uploadBulkSaving">{{ t.player_apply }}</button>
<button class="modal-btn modal-btn-ghost" @click="$store.torrents.clearUploadSelection()">Clear</button> <button class="modal-btn modal-btn-ghost" @click="$store.torrents.clearUploadSelection()">{{ t.player_clear }}</button>
</div> </div>
<div class="upload-release-tree"> <div class="upload-release-tree">
<template x-for="release in $store.torrents.uploadReleases" :key="release.id"> <template x-for="group in $store.torrents.uploadArtistGroups()" :key="group.key">
<div class="upload-release-node" :class="{ hidden: release.is_hidden }"> <section class="upload-artist-group">
<div class="upload-release-row"> <div class="upload-artist-row">
<button class="torrent-tree-check" :class="$store.torrents.uploadReleaseSelectionState(release)" @click="$store.torrents.toggleUploadReleaseSelection(release)"> <div class="upload-artist-name" x-text="group.name"></div>
<template x-if="$store.torrents.uploadReleaseSelectionState(release) === 'checked'"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><polyline points="20 6 9 17 4 12"/></svg></template> <div class="upload-artist-meta" x-text="group.releases.length + ' {{ t.player_releases_count }} - ' + group.trackCount + ' {{ t.player_tracks_count }}'"></div>
<template x-if="$store.torrents.uploadReleaseSelectionState(release) === 'partial'"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><line x1="5" y1="12" x2="19" y2="12"/></svg></template>
</button>
<button class="torrent-tree-toggle" :class="{ expanded: $store.torrents.uploadReleaseExpanded(release.id) }" @click="$store.torrents.toggleUploadRelease(release.id)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg></button>
<div class="upload-release-main">
<div class="upload-release-title"><span x-text="release.title"></span><span class="upload-hidden-pill" x-show="release.is_hidden">hidden</span></div>
<div class="upload-track-meta"><span x-text="$store.torrents.uploadReleaseArtistsText(release)"></span><span>-</span><span x-text="release.year || 'no year'"></span><span>-</span><span x-text="release.tracks.length + ' tracks'"></span></div>
</div>
<button class="modal-btn modal-btn-ghost" @click="$store.torrents.editUploadRelease(release)">Edit release</button>
</div> </div>
<div class="upload-track-children" x-show="$store.torrents.uploadReleaseExpanded(release.id)"> <template x-for="release in group.releases" :key="release.id">
<template x-for="item in release.tracks" :key="item.track.id"> <div class="upload-release-node" :class="{ hidden: release.is_hidden }">
<div class="upload-tree-track" :class="{ hidden: item.is_hidden, selected: $store.torrents.selectedUploadTracks.has(item.track.id) }"> <div class="upload-release-row">
<button class="torrent-tree-check" :class="{ checked: $store.torrents.selectedUploadTracks.has(item.track.id) }" @click="$store.torrents.toggleUploadTrackSelection(item.track.id)"> <button class="torrent-tree-check" :class="$store.torrents.uploadReleaseSelectionState(release)" @click="$store.torrents.toggleUploadReleaseSelection(release)">
<template x-if="$store.torrents.selectedUploadTracks.has(item.track.id)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><polyline points="20 6 9 17 4 12"/></svg></template> <template x-if="$store.torrents.uploadReleaseSelectionState(release) === 'checked'"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><polyline points="20 6 9 17 4 12"/></svg></template>
<template x-if="$store.torrents.uploadReleaseSelectionState(release) === 'partial'"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><line x1="5" y1="12" x2="19" y2="12"/></svg></template>
</button> </button>
<div class="upload-track-main"> <button class="torrent-tree-toggle" :class="{ expanded: $store.torrents.uploadReleaseExpanded(release.id) }" @click="$store.torrents.toggleUploadRelease(release.id)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg></button>
<div class="upload-track-title"><span x-text="item.track.track_number ? item.track.track_number + '. ' + item.track.title : item.track.title"></span><span class="upload-hidden-pill" x-show="item.is_hidden">hidden</span></div> <div class="upload-release-main">
<div class="upload-track-meta"><span x-text="$store.torrents.uploadArtistsText(item)"></span><span x-show="$store.torrents.uploadFeaturedArtistsText(item)">feat.</span><span x-show="$store.torrents.uploadFeaturedArtistsText(item)" x-text="$store.torrents.uploadFeaturedArtistsText(item)"></span></div> <div class="upload-release-title"><span x-text="release.title"></span><span class="upload-hidden-pill" x-show="release.is_hidden">{{ t.player_hidden }}</span></div>
</div> <div class="upload-track-meta"><span x-text="$store.torrents.uploadReleaseArtistsText(release)"></span><span>-</span><span x-text="release.year || T.noYear"></span><span>-</span><span x-text="release.tracks.length + ' {{ t.player_tracks_count }}'"></span></div>
<div class="upload-track-actions">
<button class="track-action-btn queue-insert-btn queue-next-btn" @click="$store.queue.addNextInQueue([item.track])" title="{{ t.player_play_next }}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 6h10M5 12h7M5 18h10"/><path d="M17 9l4 3-4 3" fill="currentColor" stroke="none"/></svg></button>
<button class="track-action-btn queue-insert-btn queue-end-btn" @click="$store.queue.addToEnd([item.track])" title="{{ t.player_add_to_queue }}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 6h14M5 12h14M5 18h7"/><path d="M17 15l4 3-4 3" fill="currentColor" stroke="none"/></svg></button>
<button class="modal-btn modal-btn-ghost" @click="$store.torrents.editUpload(item)">Edit</button>
</div> </div>
<button class="modal-btn modal-btn-ghost" @click="$store.torrents.editUploadRelease(release)">{{ t.player_edit_release }}</button>
</div> </div>
</template> <div class="upload-track-children" x-show="$store.torrents.uploadReleaseExpanded(release.id)">
</div> <template x-for="item in release.tracks" :key="item.track.id">
</div> <div class="upload-tree-track" :class="{ hidden: item.is_hidden, selected: $store.torrents.selectedUploadTracks.has(item.track.id) }">
<button class="torrent-tree-check" :class="{ checked: $store.torrents.selectedUploadTracks.has(item.track.id) }" @click="$store.torrents.toggleUploadTrackSelection(item.track.id)">
<template x-if="$store.torrents.selectedUploadTracks.has(item.track.id)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><polyline points="20 6 9 17 4 12"/></svg></template>
</button>
<div class="upload-track-main">
<div class="upload-track-title"><span x-text="item.track.track_number ? item.track.track_number + '. ' + item.track.title : item.track.title"></span><span class="upload-hidden-pill" x-show="item.is_hidden">{{ t.player_hidden }}</span></div>
<div class="upload-track-meta"><span x-text="$store.torrents.uploadArtistsText(item)"></span><span x-show="$store.torrents.uploadFeaturedArtistsText(item)" x-text="T.featuredShort"></span><span x-show="$store.torrents.uploadFeaturedArtistsText(item)" x-text="$store.torrents.uploadFeaturedArtistsText(item)"></span></div>
</div>
<div class="upload-track-actions">
<button class="track-action-btn queue-insert-btn queue-next-btn" @click="$store.queue.addNextInQueue([item.track])" title="{{ t.player_play_next }}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 6h10M5 12h7M5 18h10"/><path d="M17 9l4 3-4 3" fill="currentColor" stroke="none"/></svg></button>
<button class="track-action-btn queue-insert-btn queue-end-btn" @click="$store.queue.addToEnd([item.track])" title="{{ t.player_add_to_queue }}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 6h14M5 12h14M5 18h7"/><path d="M17 15l4 3-4 3" fill="currentColor" stroke="none"/></svg></button>
<button class="modal-btn modal-btn-ghost" @click="$store.torrents.editUpload(item)">{{ t.player_edit }}</button>
</div>
</div>
</template>
</div>
</div>
</template>
</section>
</template> </template>
</div> </div>
</section> </section>
@@ -455,7 +482,7 @@
<div class="upload-panel-title" x-text="$store.torrents.uploadEditorKicker()"></div> <div class="upload-panel-title" x-text="$store.torrents.uploadEditorKicker()"></div>
<h4 x-text="$store.torrents.uploadEditorTitle()"></h4> <h4 x-text="$store.torrents.uploadEditorTitle()"></h4>
</div> </div>
<button class="track-action-btn" @click="$store.torrents.closeUploadEditor()" title="Close"> <button class="track-action-btn" @click="$store.torrents.closeUploadEditor()" title="{{ t.player_close }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4">
<path d="M18 6L6 18M6 6l12 12"/> <path d="M18 6L6 18M6 6l12 12"/>
</svg> </svg>
@@ -464,53 +491,53 @@
<template x-if="$store.torrents.uploadReviewDraft"> <template x-if="$store.torrents.uploadReviewDraft">
<div class="upload-editor-form"> <div class="upload-editor-form">
<label class="upload-field upload-field-half"><span>Title</span><input type="text" x-model="$store.torrents.uploadReviewDraft.title"></label> <label class="upload-field upload-field-half"><span>{{ t.player_title }}</span><input type="text" x-model="$store.torrents.uploadReviewDraft.title"></label>
<label class="upload-field upload-field-half"><span>Artist</span><input type="text" x-model="$store.torrents.uploadReviewDraft.artist"></label> <label class="upload-field upload-field-half"><span>{{ t.player_artist }}</span><input type="text" x-model="$store.torrents.uploadReviewDraft.artist"></label>
<label class="upload-field upload-field-half"><span>Album</span><input type="text" x-model="$store.torrents.uploadReviewDraft.album"></label> <label class="upload-field upload-field-half"><span>{{ t.player_album }}</span><input type="text" x-model="$store.torrents.uploadReviewDraft.album"></label>
<label class="upload-field upload-field-half"><span>Featured</span><input type="text" x-model="$store.torrents.uploadReviewDraft.featured_artists" placeholder="Artist, Artist"></label> <label class="upload-field upload-field-half"><span>{{ t.player_featured }}</span><input type="text" x-model="$store.torrents.uploadReviewDraft.featured_artists" placeholder="{{ t.player_artists_placeholder }}"></label>
<label class="upload-field upload-field-compact"><span>Year</span><input type="number" x-model="$store.torrents.uploadReviewDraft.year"></label> <label class="upload-field upload-field-compact"><span>{{ t.player_year }}</span><input type="number" x-model="$store.torrents.uploadReviewDraft.year"></label>
<label class="upload-field upload-field-compact"><span>Track #</span><input type="number" min="1" x-model="$store.torrents.uploadReviewDraft.track_number"></label> <label class="upload-field upload-field-compact"><span>{{ t.player_track_number }}</span><input type="number" min="1" x-model="$store.torrents.uploadReviewDraft.track_number"></label>
<label class="upload-field upload-field-compact"><span>Genre</span><input type="text" x-model="$store.torrents.uploadReviewDraft.genre"></label> <label class="upload-field upload-field-compact"><span>{{ t.player_genre }}</span><input type="text" x-model="$store.torrents.uploadReviewDraft.genre"></label>
<label class="upload-field upload-field-compact"> <label class="upload-field upload-field-compact">
<span>Type</span> <span>{{ t.player_type }}</span>
<select x-model="$store.torrents.uploadReviewDraft.release_type"> <select x-model="$store.torrents.uploadReviewDraft.release_type">
<option value="album">Album</option><option value="single">Single</option><option value="ep">EP</option><option value="compilation">Compilation</option><option value="mixtape">Mixtape</option><option value="live">Live</option><option value="soundtrack">Soundtrack</option><option value="remix">Remix</option><option value="demo">Demo</option> <option value="album">{{ t.player_release_type_album }}</option><option value="single">{{ t.player_release_type_single }}</option><option value="ep">{{ t.player_release_type_ep }}</option><option value="compilation">{{ t.player_release_type_compilation }}</option><option value="mixtape">{{ t.player_release_type_mixtape }}</option><option value="live">{{ t.player_release_type_live }}</option><option value="soundtrack">{{ t.player_release_type_soundtrack }}</option><option value="remix">{{ t.player_release_type_remix }}</option><option value="demo">{{ t.player_release_type_demo }}</option>
</select> </select>
</label> </label>
<label class="upload-field upload-field-wide"><span>Notes</span><textarea rows="4" x-model="$store.torrents.uploadReviewDraft.notes"></textarea></label> <label class="upload-field upload-field-wide"><span>{{ t.player_notes }}</span><textarea rows="4" x-model="$store.torrents.uploadReviewDraft.notes"></textarea></label>
<div class="upload-editor-actions"> <div class="upload-editor-actions">
<button class="modal-btn modal-btn-danger" @click="$store.torrents.deleteUploadReview()" :disabled="$store.torrents.uploadReviewSavingId === $store.torrents.uploadReviewEditId">Delete review</button> <button class="modal-btn modal-btn-danger" @click="$store.torrents.deleteUploadReview()" :disabled="$store.torrents.uploadReviewSavingId === $store.torrents.uploadReviewEditId">{{ t.player_delete_review }}</button>
<button class="modal-btn modal-btn-primary" @click="$store.torrents.approveUploadReview()" :disabled="$store.torrents.uploadReviewSavingId === $store.torrents.uploadReviewEditId">Approve</button> <button class="modal-btn modal-btn-primary" @click="$store.torrents.approveUploadReview()" :disabled="$store.torrents.uploadReviewSavingId === $store.torrents.uploadReviewEditId">{{ t.player_approve }}</button>
</div> </div>
</div> </div>
</template> </template>
<template x-if="$store.torrents.uploadReleaseDraft"> <template x-if="$store.torrents.uploadReleaseDraft">
<div class="upload-editor-form"> <div class="upload-editor-form">
<label class="upload-field upload-field-wide"><span>Album</span><input type="text" x-model="$store.torrents.uploadReleaseDraft.title"></label> <label class="upload-field upload-field-wide"><span>{{ t.player_album }}</span><input type="text" x-model="$store.torrents.uploadReleaseDraft.title"></label>
<label class="upload-field upload-field-wide"><span>Album artists</span><input type="text" x-model="$store.torrents.uploadReleaseDraft.artists"></label> <label class="upload-field upload-field-wide"><span>{{ t.player_album_artists }}</span><input type="text" x-model="$store.torrents.uploadReleaseDraft.artists"></label>
<label class="upload-field upload-field-half"><span>Year</span><input type="number" x-model="$store.torrents.uploadReleaseDraft.year"></label> <label class="upload-field upload-field-half"><span>{{ t.player_year }}</span><input type="number" x-model="$store.torrents.uploadReleaseDraft.year"></label>
<label class="upload-field upload-field-half"><span>Type</span><select x-model="$store.torrents.uploadReleaseDraft.release_type"><option value="album">Album</option><option value="single">Single</option><option value="ep">EP</option><option value="compilation">Compilation</option><option value="mixtape">Mixtape</option><option value="live">Live</option><option value="soundtrack">Soundtrack</option><option value="remix">Remix</option><option value="demo">Demo</option></select></label> <label class="upload-field upload-field-half"><span>{{ t.player_type }}</span><select x-model="$store.torrents.uploadReleaseDraft.release_type"><option value="album">{{ t.player_release_type_album }}</option><option value="single">{{ t.player_release_type_single }}</option><option value="ep">{{ t.player_release_type_ep }}</option><option value="compilation">{{ t.player_release_type_compilation }}</option><option value="mixtape">{{ t.player_release_type_mixtape }}</option><option value="live">{{ t.player_release_type_live }}</option><option value="soundtrack">{{ t.player_release_type_soundtrack }}</option><option value="remix">{{ t.player_release_type_remix }}</option><option value="demo">{{ t.player_release_type_demo }}</option></select></label>
<label class="upload-field upload-field-toggle"><input type="checkbox" x-model="$store.torrents.uploadReleaseDraft.is_hidden"><span>Hidden</span></label> <label class="upload-field upload-field-toggle"><input type="checkbox" x-model="$store.torrents.uploadReleaseDraft.is_hidden"><span>{{ t.player_hidden }}</span></label>
<div class="upload-editor-actions"> <div class="upload-editor-actions">
<button class="modal-btn modal-btn-primary" @click="$store.torrents.saveUploadReleaseEdit()" :disabled="$store.torrents.uploadReleaseSavingId === $store.torrents.uploadReleaseEditId">Save release</button> <button class="modal-btn modal-btn-primary" @click="$store.torrents.saveUploadReleaseEdit()" :disabled="$store.torrents.uploadReleaseSavingId === $store.torrents.uploadReleaseEditId">{{ t.player_save_release }}</button>
</div> </div>
</div> </div>
</template> </template>
<template x-if="$store.torrents.uploadDraft"> <template x-if="$store.torrents.uploadDraft">
<div class="upload-editor-form"> <div class="upload-editor-form">
<label class="upload-field upload-field-wide"><span>Title</span><input type="text" x-model="$store.torrents.uploadDraft.title"></label> <label class="upload-field upload-field-wide"><span>{{ t.player_title }}</span><input type="text" x-model="$store.torrents.uploadDraft.title"></label>
<label class="upload-field upload-field-half"><span>Artists</span><input type="text" x-model="$store.torrents.uploadDraft.artists"></label> <label class="upload-field upload-field-half"><span>{{ t.player_artists }}</span><input type="text" x-model="$store.torrents.uploadDraft.artists"></label>
<label class="upload-field upload-field-half"><span>Featured</span><input type="text" x-model="$store.torrents.uploadDraft.featured_artists"></label> <label class="upload-field upload-field-half"><span>{{ t.player_featured }}</span><input type="text" x-model="$store.torrents.uploadDraft.featured_artists"></label>
<label class="upload-field upload-field-half"><span>Album</span><input type="text" x-model="$store.torrents.uploadDraft.release_title"></label> <label class="upload-field upload-field-half"><span>{{ t.player_album }}</span><input type="text" x-model="$store.torrents.uploadDraft.release_title"></label>
<label class="upload-field upload-field-half"><span>Type</span><select x-model="$store.torrents.uploadDraft.release_type"><option value="album">Album</option><option value="single">Single</option><option value="ep">EP</option><option value="compilation">Compilation</option><option value="mixtape">Mixtape</option><option value="live">Live</option><option value="soundtrack">Soundtrack</option><option value="remix">Remix</option><option value="demo">Demo</option></select></label> <label class="upload-field upload-field-half"><span>{{ t.player_type }}</span><select x-model="$store.torrents.uploadDraft.release_type"><option value="album">{{ t.player_release_type_album }}</option><option value="single">{{ t.player_release_type_single }}</option><option value="ep">{{ t.player_release_type_ep }}</option><option value="compilation">{{ t.player_release_type_compilation }}</option><option value="mixtape">{{ t.player_release_type_mixtape }}</option><option value="live">{{ t.player_release_type_live }}</option><option value="soundtrack">{{ t.player_release_type_soundtrack }}</option><option value="remix">{{ t.player_release_type_remix }}</option><option value="demo">{{ t.player_release_type_demo }}</option></select></label>
<label class="upload-field upload-field-compact"><span>Year</span><input type="number" x-model="$store.torrents.uploadDraft.release_year"></label> <label class="upload-field upload-field-compact"><span>{{ t.player_year }}</span><input type="number" x-model="$store.torrents.uploadDraft.release_year"></label>
<label class="upload-field upload-field-compact"><span>Track #</span><input type="number" min="1" x-model="$store.torrents.uploadDraft.track_number"></label> <label class="upload-field upload-field-compact"><span>{{ t.player_track_number }}</span><input type="number" min="1" x-model="$store.torrents.uploadDraft.track_number"></label>
<label class="upload-field upload-field-compact"><span>Disc #</span><input type="number" min="1" x-model="$store.torrents.uploadDraft.disc_number"></label> <label class="upload-field upload-field-compact"><span>{{ t.player_disc_number }}</span><input type="number" min="1" x-model="$store.torrents.uploadDraft.disc_number"></label>
<label class="upload-field upload-field-toggle"><input type="checkbox" x-model="$store.torrents.uploadDraft.is_hidden"><span>Hidden</span></label> <label class="upload-field upload-field-toggle"><input type="checkbox" x-model="$store.torrents.uploadDraft.is_hidden"><span>{{ t.player_hidden }}</span></label>
<div class="upload-editor-actions"> <div class="upload-editor-actions">
<button class="modal-btn modal-btn-primary" @click="$store.torrents.saveUploadEdit()" :disabled="$store.torrents.uploadSavingId === $store.torrents.uploadEditId">Save track</button> <button class="modal-btn modal-btn-primary" @click="$store.torrents.saveUploadEdit()" :disabled="$store.torrents.uploadSavingId === $store.torrents.uploadEditId">{{ t.player_save_track }}</button>
</div> </div>
</div> </div>
</template> </template>
@@ -526,7 +553,7 @@
<div class="upload-track-main"> <div class="upload-track-main">
<div class="upload-track-title"> <div class="upload-track-title">
<span x-text="item.track.title"></span> <span x-text="item.track.title"></span>
<span class="upload-hidden-pill" x-show="item.is_hidden">hidden</span> <span class="upload-hidden-pill" x-show="item.is_hidden">{{ t.player_hidden }}</span>
</div> </div>
<div class="upload-track-meta"> <div class="upload-track-meta">
<span x-text="$store.torrents.uploadArtistsText(item)"></span> <span x-text="$store.torrents.uploadArtistsText(item)"></span>
@@ -543,7 +570,7 @@
<button class="track-action-btn queue-insert-btn queue-end-btn" @click="$store.queue.addToEnd([item.track])" title="{{ t.player_add_to_queue }}"> <button class="track-action-btn queue-insert-btn queue-end-btn" @click="$store.queue.addToEnd([item.track])" title="{{ t.player_add_to_queue }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 6h14M5 12h14M5 18h7"/><path d="M17 15l4 3-4 3" fill="currentColor" stroke="none"/></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 6h14M5 12h14M5 18h7"/><path d="M17 15l4 3-4 3" fill="currentColor" stroke="none"/></svg>
</button> </button>
<button class="modal-btn modal-btn-ghost" @click="$store.torrents.editUpload(item)">Edit</button> <button class="modal-btn modal-btn-ghost" @click="$store.torrents.editUpload(item)">{{ t.player_edit }}</button>
</div> </div>
</div> </div>
</template> </template>
@@ -551,52 +578,52 @@
<template x-if="$store.torrents.uploadEditId === item.track.id"> <template x-if="$store.torrents.uploadEditId === item.track.id">
<div class="upload-edit-form"> <div class="upload-edit-form">
<label> <label>
<span>Title</span> <span>{{ t.player_title }}</span>
<input type="text" x-model="$store.torrents.uploadDraft.title"> <input type="text" x-model="$store.torrents.uploadDraft.title">
</label> </label>
<label> <label>
<span>Artists</span> <span>{{ t.player_artists }}</span>
<input type="text" x-model="$store.torrents.uploadDraft.artists" placeholder="Artist, Featured Artist"> <input type="text" x-model="$store.torrents.uploadDraft.artists" placeholder="{{ t.player_artist_featured_placeholder }}">
</label> </label>
<label> <label>
<span>Release</span> <span>{{ t.player_release }}</span>
<input type="text" x-model="$store.torrents.uploadDraft.release_title"> <input type="text" x-model="$store.torrents.uploadDraft.release_title">
</label> </label>
<label> <label>
<span>Type</span> <span>{{ t.player_type }}</span>
<select x-model="$store.torrents.uploadDraft.release_type"> <select x-model="$store.torrents.uploadDraft.release_type">
<option value="album">Album</option> <option value="album">{{ t.player_release_type_album }}</option>
<option value="single">Single</option> <option value="single">{{ t.player_release_type_single }}</option>
<option value="ep">EP</option> <option value="ep">{{ t.player_release_type_ep }}</option>
<option value="compilation">Compilation</option> <option value="compilation">{{ t.player_release_type_compilation }}</option>
<option value="mixtape">Mixtape</option> <option value="mixtape">{{ t.player_release_type_mixtape }}</option>
<option value="live">Live</option> <option value="live">{{ t.player_release_type_live }}</option>
<option value="soundtrack">Soundtrack</option> <option value="soundtrack">{{ t.player_release_type_soundtrack }}</option>
<option value="remix">Remix</option> <option value="remix">{{ t.player_release_type_remix }}</option>
<option value="demo">Demo</option> <option value="demo">{{ t.player_release_type_demo }}</option>
</select> </select>
</label> </label>
<label> <label>
<span>Year</span> <span>{{ t.player_year }}</span>
<input type="number" x-model="$store.torrents.uploadDraft.release_year"> <input type="number" x-model="$store.torrents.uploadDraft.release_year">
</label> </label>
<label> <label>
<span>Track #</span> <span>{{ t.player_track_number }}</span>
<input type="number" min="1" x-model="$store.torrents.uploadDraft.track_number"> <input type="number" min="1" x-model="$store.torrents.uploadDraft.track_number">
</label> </label>
<label> <label>
<span>Disc #</span> <span>{{ t.player_disc_number }}</span>
<input type="number" min="1" x-model="$store.torrents.uploadDraft.disc_number"> <input type="number" min="1" x-model="$store.torrents.uploadDraft.disc_number">
</label> </label>
<label class="upload-hidden-toggle"> <label class="upload-hidden-toggle">
<input type="checkbox" x-model="$store.torrents.uploadDraft.is_hidden"> <input type="checkbox" x-model="$store.torrents.uploadDraft.is_hidden">
<span>Hidden</span> <span>{{ t.player_hidden }}</span>
</label> </label>
<div class="upload-edit-actions"> <div class="upload-edit-actions">
<button class="modal-btn modal-btn-primary" <button class="modal-btn modal-btn-primary"
@click="$store.torrents.saveUploadEdit()" @click="$store.torrents.saveUploadEdit()"
:disabled="$store.torrents.uploadSavingId === item.track.id">Save</button> :disabled="$store.torrents.uploadSavingId === item.track.id">{{ t.player_save }}</button>
<button class="modal-btn modal-btn-ghost" @click="$store.torrents.cancelUploadEdit()">Cancel</button> <button class="modal-btn modal-btn-ghost" @click="$store.torrents.cancelUploadEdit()">{{ t.player_cancel }}</button>
</div> </div>
</div> </div>
</template> </template>
+144 -24
View File
@@ -47,6 +47,36 @@ const T = {
connectionLost: "{{ t.player_connection_lost }}", connectionLost: "{{ t.player_connection_lost }}",
connectionLostDetail: "{{ t.player_connection_lost_detail }}", connectionLostDetail: "{{ t.player_connection_lost_detail }}",
trackWord: "{{ t.player_tracks_count }}", trackWord: "{{ t.player_tracks_count }}",
releaseWord: "{{ t.player_releases_count }}",
ofWord: "{{ t.player_of }}",
needsApproval: "{{ t.player_needs_approval }}",
showing: "{{ t.player_showing }}",
statusLabelText: "{{ t.player_status }}",
fileLabel: "{{ t.player_file }}",
createdLabel: "{{ t.player_created }}",
updatedLabel: "{{ t.player_updated }}",
errorLabel: "{{ t.player_error }}",
pending: "{{ t.player_pending }}",
featuredShort: "{{ t.player_featured_short }}",
noYear: "{{ t.player_no_year }}",
loadUploadsFailed: "{{ t.player_failed_load_uploaded_tracks }}",
releaseMetadata: "{{ t.player_release_metadata }}",
trackMetadata: "{{ t.player_track_metadata }}",
metadata: "{{ t.player_metadata }}",
approveMetadata: "{{ t.player_approve_metadata }}",
editRelease: "{{ t.player_edit_release }}",
editTrack: "{{ t.player_edit_track }}",
editMetadata: "{{ t.player_edit_metadata }}",
failedSaveTrack: "{{ t.player_failed_save_track }}",
trackMetadataSaved: "{{ t.player_track_metadata_saved }}",
failedSaveRelease: "{{ t.player_failed_save_release }}",
releaseMetadataSaved: "{{ t.player_release_metadata_saved }}",
failedDeleteReview: "{{ t.player_failed_delete_review }}",
reviewDeleted: "{{ t.player_review_deleted }}",
failedApproveReview: "{{ t.player_failed_approve_review }}",
trackApprovedImported: "{{ t.player_track_approved_imported }}",
failedUpdateSelectedTracks: "{{ t.player_failed_update_selected_tracks }}",
selectedTracksUpdated: "{{ t.player_selected_tracks_updated }}",
clientIdle: "{{ t.player_client_idle }}", clientIdle: "{{ t.player_client_idle }}",
active: "{{ t.player_active }}", active: "{{ t.player_active }}",
aiIdle: "{{ t.player_ai_idle }}", aiIdle: "{{ t.player_ai_idle }}",
@@ -2475,6 +2505,8 @@ document.addEventListener('alpine:init', () => {
uploadQueued: [], uploadQueued: [],
uploadPendingTotal: 0, uploadPendingTotal: 0,
uploadQueuedTotal: 0, uploadQueuedTotal: 0,
uploadQueueOffset: 0,
uploadQueuePageSize: 6,
uploadLoaded: false, uploadLoaded: false,
uploadLoading: false, uploadLoading: false,
uploadEditId: null, uploadEditId: null,
@@ -2745,7 +2777,7 @@ document.addEventListener('alpine:init', () => {
try { try {
const res = await fetch('/api/player/uploads/tracks'); const res = await fetch('/api/player/uploads/tracks');
const data = await res.json(); const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Failed to load uploaded tracks'); if (!res.ok) throw new Error(data.error || T.loadUploadsFailed);
this.applyUploadPage(data); this.applyUploadPage(data);
this.uploadLoaded = true; this.uploadLoaded = true;
if (this.uploadEditId && !this.uploadTracks.some(item => item.track.id === this.uploadEditId)) { if (this.uploadEditId && !this.uploadTracks.some(item => item.track.id === this.uploadEditId)) {
@@ -2769,9 +2801,9 @@ document.addEventListener('alpine:init', () => {
uploadSummary() { uploadSummary() {
const trackCount = this.uploadTracks.length; const trackCount = this.uploadTracks.length;
const releaseCount = this.uploadReleases.length; const releaseCount = this.uploadReleases.length;
const parts = [trackCount + ' tracks', releaseCount + ' releases']; const parts = [trackCount + ' ' + T.trackWord, releaseCount + ' ' + T.releaseWord];
if (this.uploadPendingTotal > 0) parts.push(this.uploadPendingTotal + ' need approval'); if (this.uploadPendingTotal > 0) parts.push(this.uploadPendingTotal + ' ' + T.needsApproval);
if (this.uploadQueuedTotal > 0) parts.push(this.uploadQueuedTotal + ' queued'); if (this.uploadQueuedTotal > 0) parts.push(this.uploadQueuedTotal + ' ' + T.queued);
return parts.join(' / '); return parts.join(' / ');
}, },
@@ -2779,7 +2811,7 @@ document.addEventListener('alpine:init', () => {
const track = item?.track || item; const track = item?.track || item;
const names = [ const names = [
...((track?.artists || []).map(artist => artist.name)), ...((track?.artists || []).map(artist => artist.name)),
...((track?.featured_artists || []).map(artist => 'ft. ' + artist.name)), ...((track?.featured_artists || []).map(artist => T.featuredShort + ' ' + artist.name)),
]; ];
return names.join(', ') || T.unknown; return names.join(', ') || T.unknown;
}, },
@@ -2789,13 +2821,99 @@ document.addEventListener('alpine:init', () => {
return names.join(', ') || T.unknown; return names.join(', ') || T.unknown;
}, },
uploadArtistGroups() {
const collator = new Intl.Collator(undefined, { sensitivity: 'base', numeric: true });
const groups = new Map();
for (const release of this.uploadReleases) {
const artists = Array.isArray(release?.artists) ? release.artists : [];
const artistKey = artists
.map(artist => artist?.id != null ? 'id:' + artist.id : 'name:' + String(artist?.name || '').trim().toLowerCase())
.filter(Boolean)
.join('|') || 'unknown';
if (!groups.has(artistKey)) {
groups.set(artistKey, {
key: artistKey,
name: this.uploadReleaseArtistsText(release),
releases: [],
trackCount: 0,
});
}
const group = groups.get(artistKey);
group.releases.push(release);
group.trackCount += Array.isArray(release?.tracks) ? release.tracks.length : 0;
}
return [...groups.values()]
.map(group => ({
...group,
releases: group.releases.slice().sort((a, b) => {
const aDate = String(a?.tracks?.[0]?.uploaded_at || '');
const bDate = String(b?.tracks?.[0]?.uploaded_at || '');
return bDate.localeCompare(aDate) || collator.compare(a?.title || '', b?.title || '');
}),
}))
.sort((a, b) => collator.compare(a.name, b.name));
},
uploadFeaturedArtistsText(item) { uploadFeaturedArtistsText(item) {
const track = item?.track || item; const track = item?.track || item;
return (track?.featured_artists || []).map(artist => artist.name).join(', '); return (track?.featured_artists || []).map(artist => artist.name).join(', ');
}, },
compactQueuedUploads() { compactQueuedUploads() {
return this.uploadQueued.slice(0, 6); const maxOffset = Math.max(0, this.uploadQueued.length - this.uploadQueuePageSize);
this.uploadQueueOffset = Math.max(0, Math.min(this.uploadQueueOffset, maxOffset));
return this.uploadQueued.slice(this.uploadQueueOffset, this.uploadQueueOffset + this.uploadQueuePageSize);
},
uploadQueueCanPrev() {
return this.uploadQueueOffset > 0;
},
uploadQueueCanNext() {
return this.uploadQueueOffset + this.uploadQueuePageSize < this.uploadQueued.length;
},
uploadQueuePrev() {
this.uploadQueueOffset = Math.max(0, this.uploadQueueOffset - this.uploadQueuePageSize);
},
uploadQueueNext() {
const maxOffset = Math.max(0, this.uploadQueued.length - this.uploadQueuePageSize);
this.uploadQueueOffset = Math.min(maxOffset, this.uploadQueueOffset + this.uploadQueuePageSize);
},
uploadQueueRangeText() {
if (this.uploadQueued.length === 0) return '';
const start = this.uploadQueueOffset + 1;
const end = Math.min(this.uploadQueueOffset + this.uploadQueuePageSize, this.uploadQueued.length);
const total = Math.max(this.uploadQueuedTotal || 0, this.uploadQueued.length);
return start + '-' + end + ' / ' + total;
},
uploadQueueOverflowText() {
const total = Math.max(this.uploadQueuedTotal || 0, this.uploadQueued.length);
return T.showing + ' ' + this.uploadQueued.length + ' ' + T.ofWord + ' ' + total;
},
uploadQueueTooltip(item) {
if (!item) return '';
const lines = [];
if (item.filename) lines.push(T.fileLabel + ': ' + item.filename);
if (item.status) lines.push(T.statusLabelText + ': ' + item.status);
if (item.created_at) lines.push(T.createdLabel + ': ' + item.created_at);
if (item.updated_at) lines.push(T.updatedLabel + ': ' + item.updated_at);
if (item.error_message) lines.push(T.errorLabel + ': ' + item.error_message);
return lines.join('\n');
},
uploadStatusLabel(status) {
const labels = {
pending: T.pending,
queued: T.queued,
processing: T.processing,
failed: T.failed,
};
return labels[String(status || '').toLowerCase()] || status || T.unknown;
}, },
uploadHasEditorOpen() { uploadHasEditorOpen() {
@@ -2803,17 +2921,17 @@ document.addEventListener('alpine:init', () => {
}, },
uploadEditorKicker() { uploadEditorKicker() {
if (this.uploadReviewDraft) return 'Needs approval'; if (this.uploadReviewDraft) return T.needsApproval;
if (this.uploadReleaseDraft) return 'Release metadata'; if (this.uploadReleaseDraft) return T.releaseMetadata;
if (this.uploadDraft) return 'Track metadata'; if (this.uploadDraft) return T.trackMetadata;
return 'Metadata'; return T.metadata;
}, },
uploadEditorTitle() { uploadEditorTitle() {
if (this.uploadReviewDraft) return 'Approve metadata'; if (this.uploadReviewDraft) return T.approveMetadata;
if (this.uploadReleaseDraft) return this.uploadReleaseDraft.title || 'Edit release'; if (this.uploadReleaseDraft) return this.uploadReleaseDraft.title || T.editRelease;
if (this.uploadDraft) return this.uploadDraft.title || 'Edit track'; if (this.uploadDraft) return this.uploadDraft.title || T.editTrack;
return 'Edit metadata'; return T.editMetadata;
}, },
closeUploadEditor() { closeUploadEditor() {
@@ -2927,11 +3045,11 @@ document.addEventListener('alpine:init', () => {
}), }),
}); });
const data = await res.json(); const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Failed to save track'); if (!res.ok) throw new Error(data.error || T.failedSaveTrack);
this.uploadTracks = this.uploadTracks.map(item => item.track.id === id ? data : item); this.uploadTracks = this.uploadTracks.map(item => item.track.id === id ? data : item);
this.cancelUploadEdit(); this.cancelUploadEdit();
this.loadUploads({ silent: true, preserveEditor: false }); this.loadUploads({ silent: true, preserveEditor: false });
this._setMessage('Track metadata saved'); this._setMessage(T.trackMetadataSaved);
} catch (err) { } catch (err) {
this._setMessage(err.message || String(err), true); this._setMessage(err.message || String(err), true);
} finally { } finally {
@@ -2977,10 +3095,10 @@ document.addEventListener('alpine:init', () => {
}), }),
}); });
const data = await res.json(); const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Failed to save release'); if (!res.ok) throw new Error(data.error || T.failedSaveRelease);
this.applyUploadPage(data); this.applyUploadPage(data);
this.cancelUploadReleaseEdit(); this.cancelUploadReleaseEdit();
this._setMessage('Release metadata saved'); this._setMessage(T.releaseMetadataSaved);
} catch (err) { } catch (err) {
this._setMessage(err.message || String(err), true); this._setMessage(err.message || String(err), true);
} finally { } finally {
@@ -3036,10 +3154,10 @@ document.addEventListener('alpine:init', () => {
method: 'DELETE', method: 'DELETE',
}); });
const data = await res.json(); const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Failed to delete review'); if (!res.ok) throw new Error(data.error || T.failedDeleteReview);
this.applyUploadPage(data); this.applyUploadPage(data);
this.cancelUploadReviewEdit(); this.cancelUploadReviewEdit();
this._setMessage('Review deleted'); this._setMessage(T.reviewDeleted);
} catch (err) { } catch (err) {
this._setMessage(err.message || String(err), true); this._setMessage(err.message || String(err), true);
} finally { } finally {
@@ -3058,10 +3176,10 @@ document.addEventListener('alpine:init', () => {
body: JSON.stringify(this.uploadReviewPayload()), body: JSON.stringify(this.uploadReviewPayload()),
}); });
const data = await res.json(); const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Failed to approve review'); if (!res.ok) throw new Error(data.error || T.failedApproveReview);
this.applyUploadPage(data); this.applyUploadPage(data);
this.cancelUploadReviewEdit(); this.cancelUploadReviewEdit();
this._setMessage('Track approved and imported'); this._setMessage(T.trackApprovedImported);
} catch (err) { } catch (err) {
this._setMessage(err.message || String(err), true); this._setMessage(err.message || String(err), true);
} finally { } finally {
@@ -3092,10 +3210,10 @@ document.addEventListener('alpine:init', () => {
}), }),
}); });
const data = await res.json(); const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Failed to update selected tracks'); if (!res.ok) throw new Error(data.error || T.failedUpdateSelectedTracks);
this.applyUploadPage(data); this.applyUploadPage(data);
this.clearUploadSelection(); this.clearUploadSelection();
this._setMessage('Selected tracks updated'); this._setMessage(T.selectedTracksUpdated);
} catch (err) { } catch (err) {
this._setMessage(err.message || String(err), true); this._setMessage(err.message || String(err), true);
} finally { } finally {
@@ -3110,6 +3228,8 @@ document.addEventListener('alpine:init', () => {
this.uploadQueued = Array.isArray(data.queued) ? data.queued : []; this.uploadQueued = Array.isArray(data.queued) ? data.queued : [];
this.uploadPendingTotal = Number(data.pending_total || this.uploadPending.length || 0); this.uploadPendingTotal = Number(data.pending_total || this.uploadPending.length || 0);
this.uploadQueuedTotal = Number(data.queued_total || this.uploadQueued.length || 0); this.uploadQueuedTotal = Number(data.queued_total || this.uploadQueued.length || 0);
const maxQueueOffset = Math.max(0, this.uploadQueued.length - this.uploadQueuePageSize);
this.uploadQueueOffset = Math.max(0, Math.min(this.uploadQueueOffset, maxQueueOffset));
this.pruneUploadSelection(); this.pruneUploadSelection();
}, },
+88 -1
View File
@@ -3239,7 +3239,40 @@ button.user-stat:hover {
.upload-release-tree { .upload-release-tree {
display: grid; display: grid;
gap: 8px; gap: 12px;
}
.upload-artist-group {
display: grid;
gap: 7px;
min-width: 0;
}
.upload-artist-row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
min-width: 0;
padding: 2px 2px 0;
}
.upload-artist-name {
min-width: 0;
overflow: hidden;
color: var(--text-primary);
font-size: 13px;
font-weight: 950;
text-overflow: ellipsis;
white-space: nowrap;
}
.upload-artist-meta {
flex: 0 0 auto;
color: var(--text-subdued);
font-size: 11px;
font-weight: 750;
white-space: nowrap;
} }
.upload-release-node { .upload-release-node {
@@ -3333,6 +3366,60 @@ button.user-stat:hover {
text-transform: uppercase; text-transform: uppercase;
} }
.upload-panel-title-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 8px;
}
.upload-queue-nav {
display: inline-flex;
align-items: center;
gap: 5px;
margin-left: auto;
}
.upload-queue-range {
color: var(--text-subdued);
font-size: 11px;
font-weight: 850;
text-transform: none;
white-space: nowrap;
}
.upload-queue-nav-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
padding: 0;
border: 1px solid var(--border-color);
border-radius: 6px;
background: var(--bg-secondary);
color: var(--text-secondary);
cursor: pointer;
}
.upload-queue-nav-btn:hover:not(:disabled) {
border-color: var(--text-subdued);
background: var(--bg-elevated);
color: var(--text-primary);
}
.upload-queue-nav-btn:disabled {
cursor: default;
opacity: 0.42;
}
.upload-queue-nav-btn svg {
width: 14px;
height: 14px;
}
.upload-queue-row { .upload-queue-row {
display: grid; display: grid;
grid-template-columns: auto minmax(0, 1fr); grid-template-columns: auto minmax(0, 1fr);