Improve player library and admin user stats
This commit is contained in:
+131
-2
@@ -1332,6 +1332,89 @@ tbody tr:hover {
|
||||
.user-stats-grid {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.user-activity-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.user-activity-row {
|
||||
min-width: 0;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-primary);
|
||||
display: grid;
|
||||
grid-template-columns: 52px minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.user-activity-cover {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 4px;
|
||||
background: var(--bg-elevated);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-subdued);
|
||||
}
|
||||
|
||||
.user-activity-cover img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.user-activity-cover svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.user-activity-main {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.user-activity-title,
|
||||
.user-activity-meta,
|
||||
.user-activity-sub {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.user-activity-title {
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.user-activity-meta {
|
||||
margin-top: 3px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.user-activity-sub {
|
||||
margin-top: 3px;
|
||||
color: var(--text-subdued);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.user-activity-time {
|
||||
min-width: 118px;
|
||||
color: var(--text-subdued);
|
||||
font-size: 11px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.user-activity-time strong {
|
||||
display: block;
|
||||
color: var(--text-primary);
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
{% endblock head_extra %}
|
||||
|
||||
@@ -2160,7 +2243,7 @@ tbody tr:hover {
|
||||
<div class="modal-body" x-show="activeUserDetail">
|
||||
<div class="segmented user-modal-tabs">
|
||||
<button class="seg-btn" :class="{active: userModalTab === 'overview'}" @click="userModalTab = 'overview'">Overview</button>
|
||||
<button class="seg-btn" :class="{active: userModalTab === 'activity'}" @click="userModalTab = 'activity'" disabled>Activity</button>
|
||||
<button class="seg-btn" :class="{active: userModalTab === 'activity'}" @click="userModalTab = 'activity'">Activity</button>
|
||||
<button class="seg-btn" :class="{active: userModalTab === 'library'}" @click="userModalTab = 'library'" disabled>Library</button>
|
||||
</div>
|
||||
<div x-show="userModalTab === 'overview'">
|
||||
@@ -2200,6 +2283,32 @@ tbody tr:hover {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div x-show="userModalTab === 'activity'">
|
||||
<div class="user-activity-list">
|
||||
<template x-for="play in (activeUserDetail?.recent_plays || [])" :key="play.history_id">
|
||||
<div class="user-activity-row">
|
||||
<div class="user-activity-cover">
|
||||
<template x-if="play.cover_url">
|
||||
<img :src="play.cover_url" :alt="play.release_title || play.title" loading="lazy">
|
||||
</template>
|
||||
<template x-if="!play.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>
|
||||
</div>
|
||||
<div class="user-activity-main">
|
||||
<div class="user-activity-title" x-text="play.title"></div>
|
||||
<div class="user-activity-meta" x-text="play.artists"></div>
|
||||
<div class="user-activity-sub" x-text="userPlayMeta(play)"></div>
|
||||
</div>
|
||||
<div class="user-activity-time">
|
||||
<strong x-text="userPlayListened(play)"></strong>
|
||||
<span x-text="shortDate(play.played_at)"></span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div class="empty" x-show="activeUserDetail && (!activeUserDetail.recent_plays || activeUserDetail.recent_plays.length === 0)">No play history for this user yet</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
@@ -2837,7 +2946,7 @@ function adminV2() {
|
||||
async openUser(user) {
|
||||
if (!user) return;
|
||||
this.userModalTab = 'overview';
|
||||
this.activeUserDetail = { user, stats: {} };
|
||||
this.activeUserDetail = { user, stats: {}, recent_plays: [] };
|
||||
this.userModalOpen = true;
|
||||
try {
|
||||
this.activeUserDetail = await this.request(`${this.apiBase}/users/${user.id}`);
|
||||
@@ -3788,6 +3897,26 @@ function adminV2() {
|
||||
];
|
||||
},
|
||||
|
||||
userPlayListened(play) {
|
||||
const listened = play?.duration_listened;
|
||||
const duration = play?.track_duration_seconds;
|
||||
const listenedText = listened == null ? 'unknown' : this.durationApprox(listened);
|
||||
const durationText = duration ? this.durationApprox(duration) : null;
|
||||
return durationText ? `${listenedText} / ${durationText}` : listenedText;
|
||||
},
|
||||
|
||||
userPlayMeta(play) {
|
||||
const parts = [];
|
||||
if (play.release_title) {
|
||||
parts.push(play.release_year ? `${play.release_title} (${play.release_year})` : play.release_title);
|
||||
}
|
||||
if (play.audio_format) parts.push(String(play.audio_format).toUpperCase());
|
||||
if (play.audio_bitrate) parts.push(`${play.audio_bitrate} kbps`);
|
||||
if (play.uploader_name) parts.push(`uploaded by ${play.uploader_name}`);
|
||||
parts.push(play.completed ? 'completed' : 'partial');
|
||||
return parts.join(' · ');
|
||||
},
|
||||
|
||||
durationApprox(seconds) {
|
||||
const value = Math.max(0, Number(seconds || 0));
|
||||
if (value < 60) return `${Math.floor(value)}s`;
|
||||
|
||||
@@ -1847,11 +1847,13 @@ document.addEventListener('alpine:init', () => {
|
||||
// -----------------------------------------------------------------------
|
||||
Alpine.store('library', {
|
||||
view: 'artists',
|
||||
artistFilter: 'all',
|
||||
artists: [],
|
||||
artistsPage: 0,
|
||||
artistsTotal: 0,
|
||||
loading: false,
|
||||
_allLoaded: false,
|
||||
_artistsLoadToken: 0,
|
||||
currentArtist: null,
|
||||
currentRelease: null,
|
||||
currentPlaylist: null,
|
||||
@@ -1866,7 +1868,6 @@ document.addEventListener('alpine:init', () => {
|
||||
_hashNav: false, // guard against circular hash updates
|
||||
|
||||
init() {
|
||||
this.loadArtists(1);
|
||||
this._setupScroll();
|
||||
|
||||
// Listen for browser back/forward
|
||||
@@ -1903,7 +1904,10 @@ document.addEventListener('alpine:init', () => {
|
||||
const params = match[3] || '';
|
||||
|
||||
if (view === 'artists' && !id) {
|
||||
if (this.view !== 'artists') this.goArtists(options);
|
||||
if (this.view !== 'artists' || this.artistsPage === 0) this.goArtists(options);
|
||||
else if (options.restoreScroll) this._restoreScrollPosition(hash);
|
||||
} else if (view === 'uploads' && !id) {
|
||||
if (this.view !== 'my_uploads' || this.artistsPage === 0) this.goMyUploads(options);
|
||||
else if (options.restoreScroll) this._restoreScrollPosition(hash);
|
||||
} else if (view === 'artist' && id) {
|
||||
this.openArtist(id, options);
|
||||
@@ -1969,8 +1973,22 @@ document.addEventListener('alpine:init', () => {
|
||||
}
|
||||
},
|
||||
|
||||
_resetArtistList(filter) {
|
||||
this.artistFilter = filter;
|
||||
this.artists = [];
|
||||
this.artistsPage = 0;
|
||||
this.artistsTotal = 0;
|
||||
this._allLoaded = false;
|
||||
this.loading = false;
|
||||
this._artistsLoadToken += 1;
|
||||
},
|
||||
|
||||
goArtists(options = {}) {
|
||||
this._beginNavigation('#artists', options);
|
||||
if (this.artistFilter !== 'all' || this.artistsPage === 0) {
|
||||
this._resetArtistList('all');
|
||||
this.loadArtists(1);
|
||||
}
|
||||
this.view = 'artists';
|
||||
this.currentArtist = null;
|
||||
this.currentRelease = null;
|
||||
@@ -1982,13 +2000,35 @@ document.addEventListener('alpine:init', () => {
|
||||
this._afterNavigation(options);
|
||||
},
|
||||
|
||||
goMyUploads(options = {}) {
|
||||
this._beginNavigation('#uploads', options);
|
||||
if (this.artistFilter !== 'uploads' || this.artistsPage === 0) {
|
||||
this._resetArtistList('uploads');
|
||||
this.loadArtists(1);
|
||||
}
|
||||
this.view = 'my_uploads';
|
||||
this.currentArtist = null;
|
||||
this.currentRelease = null;
|
||||
this.currentPlaylist = null;
|
||||
this.searchQuery = '';
|
||||
this.searchResults = null;
|
||||
this._previousView = 'my_uploads';
|
||||
this.$nextTick(() => { this._setupScroll(); });
|
||||
this._afterNavigation(options);
|
||||
},
|
||||
|
||||
async loadArtists(page) {
|
||||
if (this.loading || this._allLoaded) return;
|
||||
this.loading = true;
|
||||
const filter = this.artistFilter;
|
||||
const token = this._artistsLoadToken + 1;
|
||||
this._artistsLoadToken = token;
|
||||
try {
|
||||
const res = await fetch(`/api/player/artists?page=${page}&limit=60`);
|
||||
const mine = filter === 'uploads' ? '&mine=true' : '';
|
||||
const res = await fetch(`/api/player/artists?page=${page}&limit=60${mine}`);
|
||||
if (!res.ok) throw new Error('failed');
|
||||
const data = await res.json();
|
||||
if (token !== this._artistsLoadToken || filter !== this.artistFilter) return;
|
||||
if (page === 1) {
|
||||
this.artists = data.items;
|
||||
} else {
|
||||
@@ -2000,7 +2040,9 @@ document.addEventListener('alpine:init', () => {
|
||||
this._allLoaded = true;
|
||||
}
|
||||
} catch {}
|
||||
this.loading = false;
|
||||
if (token === this._artistsLoadToken) {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async openArtist(id, options = {}) {
|
||||
@@ -2337,8 +2379,8 @@ document.addEventListener('alpine:init', () => {
|
||||
this.searchLoading = false;
|
||||
if (this.view === 'search') {
|
||||
this.view = this._previousView || 'artists';
|
||||
this._setHash('#artists');
|
||||
if (this.view === 'artists') {
|
||||
this._setHash(this.view === 'my_uploads' ? '#uploads' : '#artists');
|
||||
if (this.view === 'artists' || this.view === 'my_uploads') {
|
||||
this.$nextTick(() => { this._setupScroll(); });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,6 +62,12 @@
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="8" r="4"/><path d="M20 21a8 8 0 10-16 0"/></svg>
|
||||
{{ t.player_artists }}
|
||||
</div>
|
||||
<div class="sidebar-nav-item"
|
||||
:class="{ active: $store.library.view === 'my_uploads' }"
|
||||
@click="$store.library.goMyUploads()">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><path d="M17 8l-5-5-5 5"/><path d="M12 3v12"/></svg>
|
||||
{{ t.player_my_uploads }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="sidebar-section">
|
||||
<div class="sidebar-section-title">
|
||||
@@ -170,6 +176,12 @@
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="8" r="4"/><path d="M20 21a8 8 0 10-16 0"/></svg>
|
||||
{{ t.player_artists }}
|
||||
</div>
|
||||
<div class="sidebar-nav-item"
|
||||
:class="{ active: $store.library.view === 'my_uploads' }"
|
||||
@click="$store.library.goMyUploads(); $store.mobile.closeLibrary()">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><path d="M17 8l-5-5-5 5"/><path d="M12 3v12"/></svg>
|
||||
{{ t.player_my_uploads }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mobile-drawer-section">
|
||||
@@ -507,9 +519,9 @@
|
||||
</template>
|
||||
|
||||
<!-- Artists Grid -->
|
||||
<template x-if="$store.library.view === 'artists'">
|
||||
<template x-if="$store.library.view === 'artists' || $store.library.view === 'my_uploads'">
|
||||
<div>
|
||||
<h1 class="section-title">{{ t.player_artists }}</h1>
|
||||
<h1 class="section-title" x-text="$store.library.view === 'my_uploads' ? '{{ t.player_my_uploads }}' : '{{ t.player_artists }}'"></h1>
|
||||
<div class="card-grid">
|
||||
<template x-for="artist in $store.library.artists" :key="artist.id">
|
||||
<div class="card" @click="$store.library.openArtist(artist.id)">
|
||||
@@ -537,6 +549,11 @@
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<template x-if="!$store.library.loading && $store.library.artists.length === 0">
|
||||
<div class="empty-state">
|
||||
<p x-text="$store.library.view === 'my_uploads' ? '{{ t.player_no_uploaded_tracks }}' : '{{ t.artists_empty }}'"></p>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="$store.library.loading">
|
||||
<div class="loading-spinner"><div class="spinner"></div></div>
|
||||
</template>
|
||||
@@ -548,7 +565,8 @@
|
||||
<template x-if="$store.library.view === 'artist_detail' && $store.library.currentArtist">
|
||||
<div>
|
||||
<div class="breadcrumb">
|
||||
<a @click="$store.library.goArtists()">{{ t.player_artists }}</a>
|
||||
<a @click="$store.library.artistFilter === 'uploads' ? $store.library.goMyUploads() : $store.library.goArtists()"
|
||||
x-text="$store.library.artistFilter === 'uploads' ? '{{ t.player_my_uploads }}' : '{{ t.player_artists }}'"></a>
|
||||
<span>/</span>
|
||||
<span x-text="$store.library.currentArtist.name"></span>
|
||||
</div>
|
||||
@@ -708,7 +726,8 @@
|
||||
<template x-if="$store.library.view === 'release_detail' && $store.library.currentRelease">
|
||||
<div>
|
||||
<div class="breadcrumb">
|
||||
<a @click="$store.library.goArtists()">{{ t.player_artists }}</a>
|
||||
<a @click="$store.library.artistFilter === 'uploads' ? $store.library.goMyUploads() : $store.library.goArtists()"
|
||||
x-text="$store.library.artistFilter === 'uploads' ? '{{ t.player_my_uploads }}' : '{{ t.player_artists }}'"></a>
|
||||
<span>/</span>
|
||||
<template x-if="$store.library.currentRelease.artists.length > 0">
|
||||
<a @click="$store.library.openArtist($store.library.currentRelease.artists[0].id)" x-text="$store.library.currentRelease.artists[0].name"></a>
|
||||
|
||||
Reference in New Issue
Block a user