ADMIN: Added track management
Build and Publish / Build and Publish Docker Image (push) Successful in 3m5s
Build and Publish / Build and Publish Docker Image (push) Successful in 3m5s
This commit is contained in:
Generated
+1
-1
@@ -1418,7 +1418,7 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "furumusic"
|
name = "furumusic"
|
||||||
version = "0.2.5"
|
version = "0.2.6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "furumusic"
|
name = "furumusic"
|
||||||
version = "0.2.6"
|
version = "0.2.7"
|
||||||
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"
|
||||||
|
|
||||||
|
|||||||
+34
-8
@@ -9,7 +9,7 @@ use cot::response::IntoResponse;
|
|||||||
use cot::session::Session;
|
use cot::session::Session;
|
||||||
use cot::{Body, Template};
|
use cot::{Body, Template};
|
||||||
use schemars::JsonSchema;
|
use schemars::JsonSchema;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Deserializer, Serialize};
|
||||||
use sqlx::{PgPool, Postgres, QueryBuilder};
|
use sqlx::{PgPool, Postgres, QueryBuilder};
|
||||||
|
|
||||||
use super::BUILD_INFO;
|
use super::BUILD_INFO;
|
||||||
@@ -68,9 +68,12 @@ pub(super) struct UpdateLibraryItemRequest {
|
|||||||
title: String,
|
title: String,
|
||||||
hidden: bool,
|
hidden: bool,
|
||||||
release_type: Option<String>,
|
release_type: Option<String>,
|
||||||
|
#[serde(default, deserialize_with = "deserialize_optional_stringish")]
|
||||||
year: Option<String>,
|
year: Option<String>,
|
||||||
release_id: Option<i64>,
|
release_id: Option<i64>,
|
||||||
|
#[serde(default, deserialize_with = "deserialize_optional_stringish")]
|
||||||
track_number: Option<String>,
|
track_number: Option<String>,
|
||||||
|
#[serde(default, deserialize_with = "deserialize_optional_stringish")]
|
||||||
disc_number: Option<String>,
|
disc_number: Option<String>,
|
||||||
artist_ids: Option<Vec<i64>>,
|
artist_ids: Option<Vec<i64>>,
|
||||||
}
|
}
|
||||||
@@ -1876,7 +1879,7 @@ async fn fetch_library_item(
|
|||||||
"releases" => {
|
"releases" => {
|
||||||
sqlx::query_as::<_, LibraryItemRow>(
|
sqlx::query_as::<_, LibraryItemRow>(
|
||||||
"SELECT r.id, r.title::text AS title, \
|
"SELECT r.id, r.title::text AS title, \
|
||||||
CONCAT(r.release_type::text, COALESCE(' · ' || r.year::text, '')) AS subtitle, \
|
(COALESCE(NULLIF(STRING_AGG(DISTINCT a.name::text, ', '), ''), 'Unknown artist') || COALESCE(' / ' || r.year::text, '')) AS subtitle, \
|
||||||
r.is_hidden, COUNT(DISTINCT t.id)::bigint AS primary_count, \
|
r.is_hidden, COUNT(DISTINCT t.id)::bigint AS primary_count, \
|
||||||
COUNT(DISTINCT ra.artist_id)::bigint AS secondary_count, \
|
COUNT(DISTINCT ra.artist_id)::bigint AS secondary_count, \
|
||||||
COUNT(DISTINCT ph.id)::bigint AS tertiary_count, \
|
COUNT(DISTINCT ph.id)::bigint AS tertiary_count, \
|
||||||
@@ -1884,6 +1887,7 @@ async fn fetch_library_item(
|
|||||||
FROM furumusic__release r \
|
FROM furumusic__release r \
|
||||||
LEFT JOIN furumusic__track t ON t.release_id = r.id \
|
LEFT JOIN furumusic__track t ON t.release_id = r.id \
|
||||||
LEFT JOIN furumusic__release_artist ra ON ra.release_id = r.id \
|
LEFT JOIN furumusic__release_artist ra ON ra.release_id = r.id \
|
||||||
|
LEFT JOIN furumusic__artist a ON a.id = ra.artist_id \
|
||||||
LEFT JOIN furumusic__play_history ph ON ph.track_id = t.id \
|
LEFT JOIN furumusic__play_history ph ON ph.track_id = t.id \
|
||||||
WHERE r.id = $1 \
|
WHERE r.id = $1 \
|
||||||
GROUP BY r.id",
|
GROUP BY r.id",
|
||||||
@@ -2055,8 +2059,11 @@ async fn load_artist_options(pool: &PgPool) -> anyhow::Result<Vec<ArtistOptionDt
|
|||||||
async fn load_release_options(pool: &PgPool) -> anyhow::Result<Vec<ReleaseOptionDto>> {
|
async fn load_release_options(pool: &PgPool) -> anyhow::Result<Vec<ReleaseOptionDto>> {
|
||||||
let rows = sqlx::query_as::<_, (i64, String, Option<String>)>(
|
let rows = sqlx::query_as::<_, (i64, String, Option<String>)>(
|
||||||
"SELECT r.id, r.title::text AS title, \
|
"SELECT r.id, r.title::text AS title, \
|
||||||
CONCAT(r.release_type::text, COALESCE(' / ' || r.year::text, '')) AS subtitle \
|
(COALESCE(NULLIF(STRING_AGG(DISTINCT a.name::text, ', '), ''), 'Unknown artist') || COALESCE(' / ' || r.year::text, '')) AS subtitle \
|
||||||
FROM furumusic__release r \
|
FROM furumusic__release r \
|
||||||
|
LEFT JOIN furumusic__release_artist ra ON ra.release_id = r.id \
|
||||||
|
LEFT JOIN furumusic__artist a ON a.id = ra.artist_id \
|
||||||
|
GROUP BY r.id \
|
||||||
ORDER BY r.title_sort ASC, r.year NULLS LAST, r.id ASC",
|
ORDER BY r.title_sort ASC, r.year NULLS LAST, r.id ASC",
|
||||||
)
|
)
|
||||||
.fetch_all(pool)
|
.fetch_all(pool)
|
||||||
@@ -2452,7 +2459,7 @@ async fn load_release_items(
|
|||||||
) -> anyhow::Result<Vec<LibraryItemRow>> {
|
) -> anyhow::Result<Vec<LibraryItemRow>> {
|
||||||
let mut qb = QueryBuilder::<Postgres>::new(
|
let mut qb = QueryBuilder::<Postgres>::new(
|
||||||
"SELECT r.id, r.title::text AS title, \
|
"SELECT r.id, r.title::text AS title, \
|
||||||
CONCAT(r.release_type::text, COALESCE(' · ' || r.year::text, '')) AS subtitle, \
|
(COALESCE(NULLIF(STRING_AGG(DISTINCT a.name::text, ', '), ''), 'Unknown artist') || COALESCE(' / ' || r.year::text, '')) AS subtitle, \
|
||||||
r.is_hidden, COUNT(DISTINCT t.id)::bigint AS primary_count, \
|
r.is_hidden, COUNT(DISTINCT t.id)::bigint AS primary_count, \
|
||||||
COUNT(DISTINCT ra.artist_id)::bigint AS secondary_count, \
|
COUNT(DISTINCT ra.artist_id)::bigint AS secondary_count, \
|
||||||
COUNT(DISTINCT ph.id)::bigint AS tertiary_count, \
|
COUNT(DISTINCT ph.id)::bigint AS tertiary_count, \
|
||||||
@@ -2657,10 +2664,12 @@ fn optional_job_time(value: &str) -> Option<String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn normalize_library_kind(kind: Option<&str>) -> String {
|
fn normalize_library_kind(kind: Option<&str>) -> String {
|
||||||
match kind {
|
let kind = kind.unwrap_or_default().trim().to_ascii_lowercase();
|
||||||
Some("releases") => "releases",
|
match kind.as_str() {
|
||||||
Some("tracks") => "tracks",
|
"release" | "releases" => "releases",
|
||||||
Some("playlists") => "playlists",
|
"track" | "tracks" => "tracks",
|
||||||
|
"playlist" | "playlists" => "playlists",
|
||||||
|
"artist" | "artists" => "artists",
|
||||||
_ => "artists",
|
_ => "artists",
|
||||||
}
|
}
|
||||||
.to_owned()
|
.to_owned()
|
||||||
@@ -2696,6 +2705,23 @@ fn parse_optional_admin_i32(value: Option<&str>, min: i32, max: i32) -> Option<i
|
|||||||
.map(|parsed| parsed.clamp(min, max))
|
.map(|parsed| parsed.clamp(min, max))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn deserialize_optional_stringish<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
|
||||||
|
where
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
{
|
||||||
|
let Some(value) = Option::<serde_json::Value>::deserialize(deserializer)? else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
match value {
|
||||||
|
serde_json::Value::Null => Ok(None),
|
||||||
|
serde_json::Value::String(value) => Ok(Some(value)),
|
||||||
|
serde_json::Value::Number(value) => Ok(Some(value.to_string())),
|
||||||
|
other => Err(serde::de::Error::custom(format!(
|
||||||
|
"expected string, number, or null, got {other}"
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn now_string() -> String {
|
fn now_string() -> String {
|
||||||
chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string()
|
chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string()
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-2
@@ -1934,7 +1934,7 @@ tbody tr:hover {
|
|||||||
<div class="empty" x-show="editorLoading">Loading editor...</div>
|
<div class="empty" x-show="editorLoading">Loading editor...</div>
|
||||||
<div x-show="!editorLoading">
|
<div x-show="!editorLoading">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label x-text="isArtistEditor() ? 'Artist name' : 'Title'"></label>
|
<label x-text="isArtistEditor() ? 'Artist name' : (isReleaseEditor() ? 'Release title' : (isTrackEditor() ? 'Track title' : 'Title'))"></label>
|
||||||
<input x-model="editorDraft.title" />
|
<input x-model="editorDraft.title" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -2829,8 +2829,10 @@ function adminV2() {
|
|||||||
selectEditorRelease(release = null) {
|
selectEditorRelease(release = null) {
|
||||||
const candidates = this.filteredEditorReleases();
|
const candidates = this.filteredEditorReleases();
|
||||||
release = release || candidates[0];
|
release = release || candidates[0];
|
||||||
if (release) this.editorDraft.release_id = Number(release.id);
|
if (!release) return false;
|
||||||
|
this.editorDraft.release_id = Number(release.id);
|
||||||
this.editorReleaseToAdd = '';
|
this.editorReleaseToAdd = '';
|
||||||
|
return true;
|
||||||
},
|
},
|
||||||
|
|
||||||
setEditorImageFile(event) {
|
setEditorImageFile(event) {
|
||||||
@@ -2948,6 +2950,12 @@ function adminV2() {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async saveLibraryItem() {
|
async saveLibraryItem() {
|
||||||
|
if (this.isTrackEditor() && String(this.editorReleaseToAdd || '').trim()) {
|
||||||
|
if (!this.selectEditorRelease()) {
|
||||||
|
this.showToast('Choose a release from search results');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (!this.editorCanSave()) return;
|
if (!this.editorCanSave()) return;
|
||||||
this.editorSaving = true;
|
this.editorSaving = true;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -422,13 +422,13 @@
|
|||||||
<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="{ 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>
|
<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>
|
</button>
|
||||||
<button class="track-action-btn play-btn" @click="$store.player.play(item.track)" title="{{ t.player_play }}"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg></button>
|
|
||||||
<div class="upload-track-main">
|
<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">hidden</span></div>
|
<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-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-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>
|
</div>
|
||||||
<div class="upload-track-actions">
|
<div class="upload-track-actions">
|
||||||
<button class="track-action-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 6h14M5 12h8M5 18h14"/><path d="M17 10l4 3-4 3" fill="currentColor" stroke="none"/></svg></button>
|
<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>
|
<button class="modal-btn modal-btn-ghost" @click="$store.torrents.editUpload(item)">Edit</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -516,11 +516,6 @@
|
|||||||
<div class="upload-track-card" :class="{ hidden: item.is_hidden }">
|
<div class="upload-track-card" :class="{ hidden: item.is_hidden }">
|
||||||
<template x-if="$store.torrents.uploadEditId !== item.track.id">
|
<template x-if="$store.torrents.uploadEditId !== item.track.id">
|
||||||
<div class="upload-track-display">
|
<div class="upload-track-display">
|
||||||
<button class="track-action-btn play-btn"
|
|
||||||
@click="$store.player.play(item.track)"
|
|
||||||
title="{{ t.player_play }}">
|
|
||||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
|
|
||||||
</button>
|
|
||||||
<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>
|
||||||
@@ -535,8 +530,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="upload-track-actions">
|
<div class="upload-track-actions">
|
||||||
<button class="track-action-btn" @click="$store.queue.addNextInQueue([item.track])" title="{{ t.player_play_next }}">
|
<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 6h14M5 12h8M5 18h14"/><path d="M17 10l4 3-4 3" fill="currentColor" stroke="none"/></svg>
|
<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>
|
||||||
<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)">Edit</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+20
-32
@@ -483,19 +483,16 @@
|
|||||||
<span x-show="$store.library.hasPopularity(track)" x-text="$store.library.popularityLabel(track)"></span>
|
<span x-show="$store.library.hasPopularity(track)" x-text="$store.library.popularityLabel(track)"></span>
|
||||||
<span x-show="!$store.library.hasPopularity(track)" class="info-letter">i</span>
|
<span x-show="!$store.library.hasPopularity(track)" class="info-letter">i</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="track-action-btn play-btn" @click.stop="$store.library.playSearchTrack(idx)" title="{{ t.player_play }}">
|
|
||||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
|
|
||||||
</button>
|
|
||||||
<button class="like-btn" :class="{ liked: $store.likes.has(track.id) }" @click.stop="$store.likes.toggle(track.id)" title="{{ t.player_like }}">
|
<button class="like-btn" :class="{ liked: $store.likes.has(track.id) }" @click.stop="$store.likes.toggle(track.id)" title="{{ t.player_like }}">
|
||||||
<svg viewBox="0 0 24 24" :fill="$store.likes.has(track.id) ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="2"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78L12 21.23l8.84-8.84a5.5 5.5 0 000-7.78z"/></svg>
|
<svg viewBox="0 0 24 24" :fill="$store.likes.has(track.id) ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="2"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78L12 21.23l8.84-8.84a5.5 5.5 0 000-7.78z"/></svg>
|
||||||
</button>
|
</button>
|
||||||
<button class="track-action-btn" @click.stop="$store.queue.addNextInQueue([track])" title="{{ t.player_play_next }}">
|
<button class="track-action-btn queue-insert-btn queue-next-btn" @click.stop="$store.queue.addNextInQueue([track])" title="{{ t.player_play_next }}">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 6h14M5 12h8M5 18h14"/><path d="M17 10l4 3-4 3" fill="currentColor" stroke="none"/></svg>
|
<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>
|
||||||
<button class="track-action-btn" @click.stop="$store.queue.addToEnd([track])" title="{{ t.player_add_to_queue }}">
|
<button class="track-action-btn queue-insert-btn queue-end-btn" @click.stop="$store.queue.addToEnd([track])" title="{{ t.player_add_to_queue }}">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></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="track-action-btn" @click.stop="$store.playlists.showPicker([track.id])" title="{{ t.player_add_to_playlist }}">
|
<button class="track-action-btn playlist-add-btn" @click.stop="$store.playlists.showPicker([track.id])" title="{{ t.player_add_to_playlist }}">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"/></svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -663,19 +660,16 @@
|
|||||||
<span x-show="$store.library.hasPopularity(track)" x-text="$store.library.popularityLabel(track)"></span>
|
<span x-show="$store.library.hasPopularity(track)" x-text="$store.library.popularityLabel(track)"></span>
|
||||||
<span x-show="!$store.library.hasPopularity(track)" class="info-letter">i</span>
|
<span x-show="!$store.library.hasPopularity(track)" class="info-letter">i</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="track-action-btn play-btn" @click.stop="$store.queue.playRelease($store.library.currentArtist.featured_tracks, idx)" title="{{ t.player_play }}">
|
|
||||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
|
|
||||||
</button>
|
|
||||||
<button class="like-btn" :class="{ liked: $store.likes.has(track.id) }" @click.stop="$store.likes.toggle(track.id)" title="{{ t.player_like }}">
|
<button class="like-btn" :class="{ liked: $store.likes.has(track.id) }" @click.stop="$store.likes.toggle(track.id)" title="{{ t.player_like }}">
|
||||||
<svg viewBox="0 0 24 24" :fill="$store.likes.has(track.id) ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="2"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78L12 21.23l8.84-8.84a5.5 5.5 0 000-7.78z"/></svg>
|
<svg viewBox="0 0 24 24" :fill="$store.likes.has(track.id) ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="2"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78L12 21.23l8.84-8.84a5.5 5.5 0 000-7.78z"/></svg>
|
||||||
</button>
|
</button>
|
||||||
<button class="track-action-btn" @click.stop="$store.queue.addNextInQueue([track])" title="{{ t.player_play_next }}">
|
<button class="track-action-btn queue-insert-btn queue-next-btn" @click.stop="$store.queue.addNextInQueue([track])" title="{{ t.player_play_next }}">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 6h14M5 12h8M5 18h14"/><path d="M17 10l4 3-4 3" fill="currentColor" stroke="none"/></svg>
|
<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>
|
||||||
<button class="track-action-btn" @click.stop="$store.queue.addToEnd([track])" title="{{ t.player_add_to_queue }}">
|
<button class="track-action-btn queue-insert-btn queue-end-btn" @click.stop="$store.queue.addToEnd([track])" title="{{ t.player_add_to_queue }}">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></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="track-action-btn" @click.stop="$store.playlists.showPicker([track.id])" title="{{ t.player_add_to_playlist }}">
|
<button class="track-action-btn playlist-add-btn" @click.stop="$store.playlists.showPicker([track.id])" title="{{ t.player_add_to_playlist }}">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"/></svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -786,19 +780,16 @@
|
|||||||
<span x-show="$store.library.hasPopularity(track)" x-text="$store.library.popularityLabel(track)"></span>
|
<span x-show="$store.library.hasPopularity(track)" x-text="$store.library.popularityLabel(track)"></span>
|
||||||
<span x-show="!$store.library.hasPopularity(track)" class="info-letter">i</span>
|
<span x-show="!$store.library.hasPopularity(track)" class="info-letter">i</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="track-action-btn play-btn" @click.stop="$store.queue.playRelease([track], 0)" title="{{ t.player_play }}">
|
|
||||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
|
|
||||||
</button>
|
|
||||||
<button class="like-btn" :class="{ liked: $store.likes.has(track.id) }" @click.stop="$store.likes.toggle(track.id)" title="{{ t.player_like }}">
|
<button class="like-btn" :class="{ liked: $store.likes.has(track.id) }" @click.stop="$store.likes.toggle(track.id)" title="{{ t.player_like }}">
|
||||||
<svg viewBox="0 0 24 24" :fill="$store.likes.has(track.id) ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="2"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78L12 21.23l8.84-8.84a5.5 5.5 0 000-7.78z"/></svg>
|
<svg viewBox="0 0 24 24" :fill="$store.likes.has(track.id) ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="2"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78L12 21.23l8.84-8.84a5.5 5.5 0 000-7.78z"/></svg>
|
||||||
</button>
|
</button>
|
||||||
<button class="track-action-btn" @click.stop="$store.queue.addNextInQueue([track])" title="{{ t.player_play_next }}">
|
<button class="track-action-btn queue-insert-btn queue-next-btn" @click.stop="$store.queue.addNextInQueue([track])" title="{{ t.player_play_next }}">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 6h14M5 12h8M5 18h14"/><path d="M17 10l4 3-4 3" fill="currentColor" stroke="none"/></svg>
|
<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>
|
||||||
<button class="track-action-btn" @click.stop="$store.queue.addToEnd([track])" title="{{ t.player_add_to_queue }}">
|
<button class="track-action-btn queue-insert-btn queue-end-btn" @click.stop="$store.queue.addToEnd([track])" title="{{ t.player_add_to_queue }}">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></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="track-action-btn" @click.stop="$store.playlists.showPicker([track.id])" title="{{ t.player_add_to_playlist }}">
|
<button class="track-action-btn playlist-add-btn" @click.stop="$store.playlists.showPicker([track.id])" title="{{ t.player_add_to_playlist }}">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"/></svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -862,19 +853,16 @@
|
|||||||
<span x-show="$store.library.hasPopularity(track)" x-text="$store.library.popularityLabel(track)"></span>
|
<span x-show="$store.library.hasPopularity(track)" x-text="$store.library.popularityLabel(track)"></span>
|
||||||
<span x-show="!$store.library.hasPopularity(track)" class="info-letter">i</span>
|
<span x-show="!$store.library.hasPopularity(track)" class="info-letter">i</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="track-action-btn play-btn" @click.stop="$store.queue.playRelease($store.library.currentPlaylist.tracks, idx)" title="{{ t.player_play }}">
|
|
||||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
|
|
||||||
</button>
|
|
||||||
<button class="like-btn" :class="{ liked: $store.likes.has(track.id) }" @click.stop="$store.likes.toggle(track.id)" title="{{ t.player_like }}">
|
<button class="like-btn" :class="{ liked: $store.likes.has(track.id) }" @click.stop="$store.likes.toggle(track.id)" title="{{ t.player_like }}">
|
||||||
<svg viewBox="0 0 24 24" :fill="$store.likes.has(track.id) ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="2"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78L12 21.23l8.84-8.84a5.5 5.5 0 000-7.78z"/></svg>
|
<svg viewBox="0 0 24 24" :fill="$store.likes.has(track.id) ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="2"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78L12 21.23l8.84-8.84a5.5 5.5 0 000-7.78z"/></svg>
|
||||||
</button>
|
</button>
|
||||||
<button class="track-action-btn" @click.stop="$store.queue.addNextInQueue([track])" title="{{ t.player_play_next }}">
|
<button class="track-action-btn queue-insert-btn queue-next-btn" @click.stop="$store.queue.addNextInQueue([track])" title="{{ t.player_play_next }}">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 6h14M5 12h8M5 18h14"/><path d="M17 10l4 3-4 3" fill="currentColor" stroke="none"/></svg>
|
<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>
|
||||||
<button class="track-action-btn" @click.stop="$store.queue.addToEnd([track])" title="{{ t.player_add_to_queue }}">
|
<button class="track-action-btn queue-insert-btn queue-end-btn" @click.stop="$store.queue.addToEnd([track])" title="{{ t.player_add_to_queue }}">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></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="track-action-btn" @click.stop="$store.playlists.showPicker([track.id])" title="{{ t.player_add_to_playlist }}">
|
<button class="track-action-btn playlist-add-btn" @click.stop="$store.playlists.showPicker([track.id])" title="{{ t.player_add_to_playlist }}">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"/></svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -687,7 +687,7 @@ button.user-stat:hover {
|
|||||||
|
|
||||||
.track-list-header {
|
.track-list-header {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 40px 1fr 1fr 120px 60px;
|
grid-template-columns: 40px minmax(0, 1fr) minmax(0, 1fr) 154px 60px;
|
||||||
padding: 8px 16px;
|
padding: 8px 16px;
|
||||||
border-bottom: 1px solid var(--border-color);
|
border-bottom: 1px solid var(--border-color);
|
||||||
color: var(--text-subdued);
|
color: var(--text-subdued);
|
||||||
@@ -699,7 +699,7 @@ button.user-stat:hover {
|
|||||||
|
|
||||||
.track-row {
|
.track-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 40px 1fr 1fr 120px 60px;
|
grid-template-columns: 40px minmax(0, 1fr) minmax(0, 1fr) 154px 60px;
|
||||||
padding: 8px 16px;
|
padding: 8px 16px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
cursor: default;
|
cursor: default;
|
||||||
@@ -736,14 +736,18 @@ button.user-stat:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.track-album { font-size: 13px; color: var(--text-subdued); }
|
.track-album { font-size: 13px; color: var(--text-subdued); }
|
||||||
.track-duration { font-size: 13px; color: var(--text-subdued); text-align: right; }
|
.track-duration { font-size: 13px; color: var(--text-subdued); text-align: right; pointer-events: none; }
|
||||||
|
|
||||||
/* Track action buttons */
|
/* Track action buttons */
|
||||||
.track-actions {
|
.track-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
|
min-width: 0;
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.track-action-btn {
|
.track-action-btn {
|
||||||
@@ -759,9 +763,22 @@ button.user-stat:hover {
|
|||||||
transition: color 0.15s, background 0.15s;
|
transition: color 0.15s, background 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.track-actions .track-action-btn,
|
||||||
|
.track-actions .like-btn {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
flex: 0 0 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-actions .popularity-info-btn {
|
||||||
|
width: auto;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
.track-action-btn:hover { color: var(--text-primary); background: var(--bg-active); }
|
.track-action-btn:hover { color: var(--text-primary); background: var(--bg-active); }
|
||||||
.track-action-btn.play-btn:hover { color: var(--accent); }
|
.track-action-btn.play-btn:hover { color: var(--accent); }
|
||||||
.track-action-btn svg { width: 16px; height: 16px; }
|
.track-action-btn svg { width: 16px; height: 16px; }
|
||||||
|
.track-action-btn.queue-insert-btn svg { width: 17px; height: 17px; }
|
||||||
|
|
||||||
.info-btn {
|
.info-btn {
|
||||||
color: var(--text-subdued);
|
color: var(--text-subdued);
|
||||||
@@ -3052,7 +3069,7 @@ button.user-stat:hover {
|
|||||||
|
|
||||||
.upload-tree-track {
|
.upload-tree-track {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 24px 30px minmax(0, 1fr) auto;
|
grid-template-columns: 24px minmax(0, 1fr) auto;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
min-height: 44px;
|
min-height: 44px;
|
||||||
@@ -3151,7 +3168,7 @@ button.user-stat:hover {
|
|||||||
|
|
||||||
.upload-track-display {
|
.upload-track-display {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 34px minmax(0, 1fr) auto;
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
@@ -3960,7 +3977,7 @@ button.user-stat:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.upload-tree-track {
|
.upload-tree-track {
|
||||||
grid-template-columns: 24px 30px minmax(0, 1fr);
|
grid-template-columns: 24px minmax(0, 1fr);
|
||||||
padding-left: 12px;
|
padding-left: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3976,7 +3993,7 @@ button.user-stat:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.upload-track-display {
|
.upload-track-display {
|
||||||
grid-template-columns: 32px minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
.upload-track-actions {
|
.upload-track-actions {
|
||||||
|
|||||||
Reference in New Issue
Block a user