ADMIN: added releases and artists management form
Build and Publish / Build and Publish Docker Image (push) Successful in 2m50s

This commit is contained in:
Ultradesu
2026-05-27 15:56:57 +03:00
parent 65da460c0c
commit 1c70349df8
13 changed files with 1151 additions and 53 deletions
+26 -1
View File
@@ -11,7 +11,32 @@
</svg>
</button>
</div>
<pre class="info-modal-body" x-text="$store.info.modal.body"></pre>
<div class="info-modal-body">
<template x-if="$store.info.modal.rows && $store.info.modal.rows.length">
<table class="info-table">
<tbody>
<template x-for="(row, idx) in $store.info.modal.rows" :key="row.label + '-' + idx">
<tr>
<th x-text="row.label"></th>
<td>
<template x-if="row.links && row.links.length">
<div class="info-link-list">
<template x-for="link in row.links" :key="link.type + '-' + link.id + '-' + link.label">
<button class="info-link" type="button" @click="$store.info.navigate(link)" x-text="link.label"></button>
</template>
</div>
</template>
<template x-if="!row.links || !row.links.length">
<span x-text="row.value"></span>
</template>
</td>
</tr>
</template>
</tbody>
</table>
</template>
<pre class="info-modal-plain" x-show="!$store.info.modal.rows || !$store.info.modal.rows.length" x-text="$store.info.modal.body"></pre>
</div>
</div>
</div>
</template>
+82 -1
View File
@@ -4,11 +4,14 @@ const T = {
info: "{{ t.player_info }}",
noDetails: "{{ t.player_no_details }}",
trackInfoTitle: "{{ t.player_track_info }}",
releaseInfoTitle: "{{ t.player_release_info }}",
loadingHistory: "{{ t.player_loading_history }}",
failedLoadHistory: "{{ t.player_failed_load_history }}",
totalPlays: "{{ t.player_total_plays }}",
unknown: "{{ t.player_unknown }}",
unknownSize: "{{ t.player_unknown_size }}",
title: "{{ t.player_title }}",
release: "{{ t.player_release }}",
unknownRelease: "{{ t.player_unknown_release }}",
unknownTrack: "{{ t.player_unknown_track }}",
unknownAudio: "{{ t.player_unknown_audio }}",
@@ -124,14 +127,33 @@ document.addEventListener('alpine:init', () => {
Alpine.store('info', {
modal: null,
open(title, body) {
if (Array.isArray(body)) {
this.openRows(title, body);
return;
}
this.modal = {
title: title || T.info,
body: body || T.noDetails,
rows: null,
};
},
openRows(title, rows) {
this.modal = {
title: title || T.info,
body: '',
rows: (rows || []).filter(row => row && ((row.value !== undefined && row.value !== null && row.value !== '') || (row.links && row.links.length))),
};
},
close() {
this.modal = null;
},
navigate(link) {
if (!link || !link.id) return;
this.close();
const library = Alpine.store('library');
if (link.type === 'release') library.openRelease(link.id);
if (link.type === 'artist') library.openArtist(link.id);
},
});
// -----------------------------------------------------------------------
@@ -892,7 +914,7 @@ document.addEventListener('alpine:init', () => {
},
openTrackInfo(track) {
Alpine.store('info').open(T.trackInfoTitle, this.trackInfo(track));
Alpine.store('info').openRows(T.trackInfoTitle, this.trackInfoRows(track));
},
uploadersInfo(uploaders) {
@@ -915,6 +937,31 @@ document.addEventListener('alpine:init', () => {
return lines.join('\n');
},
openReleaseInfo(release) {
Alpine.store('info').openRows(T.releaseInfoTitle, this.releaseInfoRows(release));
},
infoLinks(items, type) {
const seen = new Set();
return (items || [])
.filter(item => item && item.id && !seen.has(Number(item.id)) && seen.add(Number(item.id)))
.map(item => ({ type, id: item.id, label: item.label || item.name || item.title || String(item.id) }));
},
releaseInfoRows(release) {
if (!release) return [];
const rows = [
{ label: T.release, value: release.title || T.unknownRelease },
{ label: T.type, value: release.release_type || T.unknown },
{ label: T.year, value: release.year || T.unknown },
{ label: T.tracks, value: release.track_count || release.tracks?.length || 0 },
{ label: T.uploaders, value: this.uploadersInfo(release.uploaders || []) },
];
const artistLinks = this.infoLinks(release.artists || [], 'artist');
if (artistLinks.length) rows.splice(1, 0, { label: T.artists, links: artistLinks });
return rows;
},
trackInfo(track) {
if (!track) return '';
const artists = this.trackArtistLinks(track).map(artist => artist.label).join(', ') || T.unknown;
@@ -945,6 +992,40 @@ document.addEventListener('alpine:init', () => {
return lines.join('\n');
},
trackInfoRows(track) {
if (!track) return [];
const artistLinks = this.infoLinks(this.trackArtistLinks(track), 'artist');
const releaseLinks = track.release_id
? [{ type: 'release', id: track.release_id, label: track.release_title || T.unknownRelease }]
: [];
const audio = [
track.audio_format || null,
track.audio_bitrate ? `${track.audio_bitrate} kbps` : null,
track.audio_sample_rate ? `${track.audio_sample_rate} Hz` : null,
track.audio_bit_depth ? `${track.audio_bit_depth}-bit` : null,
].filter(Boolean).join(' · ') || T.unknownAudio;
const rows = [
{ label: T.title, value: track.title || T.unknownTrack },
{ label: T.release, links: releaseLinks },
{ label: T.artists, links: artistLinks },
{ label: T.releaseYear, value: track.release_year || T.unknown },
{ label: T.duration, value: formatTime(track.duration_seconds) },
{ label: T.audio, value: audio },
{ label: T.size, value: this.bytes(track.file_size_bytes) },
{ label: T.uploader, value: track.uploader_name || 'UFO' },
];
if (track.lastfm_rating != null || track.lastfm_listeners != null || track.lastfm_playcount != null) {
const rating = Number(track.lastfm_rating || 0);
rows.push({ label: T.lastfmRating, value: Number.isFinite(rating) ? Math.round(rating) : T.unknown });
rows.push({ label: T.lastfmListeners, value: new Intl.NumberFormat().format(track.lastfm_listeners || 0) });
rows.push({ label: T.lastfmPlaycount, value: new Intl.NumberFormat().format(track.lastfm_playcount || 0) });
if (track.lastfm_updated_at) rows.push({ label: T.lastfmUpdated, value: track.lastfm_updated_at });
} else {
rows.push({ label: T.lastfmRating, value: T.lastfmNotLoaded });
}
return rows;
},
async openRelease(id, options = {}) {
this._beginNavigation('#release/' + id, options);
this.searchQuery = '';
+13 -4
View File
@@ -391,7 +391,7 @@
<template x-if="!release.cover_url">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="4"/></svg>
</template>
<button class="card-info-btn" @click.stop="$store.info.open('{{ t.player_release_info }}', $store.library.releaseInfo(release))" :title="$store.library.releaseInfo(release)" aria-label="{{ t.player_release_info }}">
<button class="card-info-btn" @click.stop="$store.library.openReleaseInfo(release)" :title="$store.library.releaseInfo(release)" aria-label="{{ t.player_release_info }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>
</button>
</div>
@@ -562,7 +562,7 @@
<template x-if="!release.cover_url">
<span class="placeholder-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="4"/></svg></span>
</template>
<button class="card-info-btn" @click.stop="$store.info.open('{{ t.player_release_info }}', $store.library.releaseInfo(release))" :title="$store.library.releaseInfo(release)" aria-label="{{ t.player_release_info }}">
<button class="card-info-btn" @click.stop="$store.library.openReleaseInfo(release)" :title="$store.library.releaseInfo(release)" aria-label="{{ t.player_release_info }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>
</button>
<button class="card-enqueue-btn" @click.stop="$store.library.enqueueRelease(release.id)" title="{{ t.player_add_to_queue }}">
@@ -690,7 +690,7 @@
<div class="release-year" x-text="$store.library.currentRelease.year || ''"></div>
<div class="release-actions">
<button class="release-action-btn secondary"
@click.stop="$store.info.open('{{ t.player_release_info }}', $store.library.releaseInfo($store.library.currentRelease))"
@click.stop="$store.library.openReleaseInfo($store.library.currentRelease)"
:title="$store.library.releaseInfo($store.library.currentRelease)"
aria-label="{{ t.player_release_info }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>
@@ -928,7 +928,16 @@
</template>
</div>
<div class="player-track-info">
<div class="player-track-title" x-text="$store.player.currentTrack.title"></div>
<div class="player-track-title-row">
<div class="player-track-title" x-text="$store.player.currentTrack.title"></div>
<button class="like-btn player-current-like"
:class="{ liked: $store.likes.has($store.player.currentTrack.id) }"
@click.stop="$store.likes.toggle($store.player.currentTrack.id)"
title="{{ t.player_like }}"
aria-label="{{ t.player_like }}">
<svg viewBox="0 0 24 24" :fill="$store.likes.has($store.player.currentTrack.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>
</div>
<div class="player-track-artist">
<template x-for="(artist, artistIdx) in $store.library.trackArtistLinks($store.player.currentTrack)" :key="artist.label + '-' + artist.id + '-' + artistIdx">
<span>
+93 -3
View File
@@ -1079,12 +1079,28 @@ button.user-stat:hover {
overflow: hidden;
min-width: 0;
}
.player-track-title-row {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.player-track-title {
font-size: 13px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.player-current-like {
width: 24px;
height: 24px;
padding: 4px;
flex: 0 0 auto;
}
.player-track-artist {
@@ -1763,7 +1779,7 @@ button.user-stat:hover {
.modal-playlist-item svg { width: 16px; height: 16px; flex-shrink: 0; }
.info-modal {
max-width: min(520px, calc(100vw - 24px));
max-width: min(620px, calc(100vw - 24px));
}
.info-modal-head {
@@ -1785,14 +1801,72 @@ button.user-stat:hover {
border-radius: 8px;
background: var(--bg-primary);
color: var(--text-secondary);
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
font-size: 13px;
line-height: 1.55;
white-space: pre-wrap;
overflow: auto;
max-height: min(58dvh, 520px);
}
.info-modal-plain {
margin: 0;
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
white-space: pre-wrap;
}
.info-table {
width: 100%;
border-collapse: collapse;
}
.info-table th,
.info-table td {
padding: 9px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.07);
vertical-align: top;
}
.info-table tr:last-child th,
.info-table tr:last-child td {
border-bottom: 0;
}
.info-table th {
width: 34%;
padding-right: 18px;
color: var(--text-subdued);
font-size: 11px;
font-weight: 700;
text-align: left;
text-transform: uppercase;
}
.info-table td {
color: var(--text-primary);
}
.info-link-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.info-link {
border: 1px solid rgba(29, 185, 84, 0.36);
border-radius: 999px;
background: rgba(29, 185, 84, 0.1);
color: var(--text-primary);
cursor: pointer;
padding: 3px 8px;
font: inherit;
line-height: 1.3;
}
.info-link:hover {
border-color: rgba(29, 185, 84, 0.7);
background: rgba(29, 185, 84, 0.18);
color: var(--accent);
}
.modal-btn {
padding: 8px 16px;
border-radius: 20px;
@@ -2921,6 +2995,22 @@ button.user-stat:hover {
max-width: 400px;
}
.info-table th,
.info-table td {
display: block;
width: 100%;
padding-right: 0;
}
.info-table th {
padding-bottom: 2px;
border-bottom: 0;
}
.info-table td {
padding-top: 0;
}
.torrent-modal {
width: 100vw;
max-width: none;