feat(scrobble stats): navigator lower bound, clickable rows, android button to top
- Navigator can't step before logging began: the endpoint returns firstPlayedAt (MIN(played_at) over all the user's plays, unfiltered by window); web + android disable the back arrow once the shown year/month is at/before that first play. - Top artists/tracks/albums are now clickable → open the artist / album page (web navigates and closes the overlay; android navigates via nav routes). Tracks open their album. - Android: moved the 'Meine Statistiken' button to the top of Settings (was at the bottom near logout). SW cache -> ns-v70. Android compileDebugKotlin + web build green; firstPlayedAt verified server-side. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
86eb893f40
commit
3129333f9a
8 files changed
+91
-24
No files matched your search
@@ -5,6 +5,7 @@ import kotlinx.serialization.Serializable
|
|||||||
// Mirrors the JSON from the scrobble service GET /scrobble/stats.
|
// Mirrors the JSON from the scrobble service GET /scrobble/stats.
|
||||||
@Serializable
|
@Serializable
|
||||||
data class ScrobbleStats(
|
data class ScrobbleStats(
|
||||||
|
val firstPlayedAt: Long? = null,
|
||||||
val totalPlays: Int = 0,
|
val totalPlays: Int = 0,
|
||||||
val totalSeconds: Long = 0,
|
val totalSeconds: Long = 0,
|
||||||
val topArtists: List<StatArtist> = emptyList(),
|
val topArtists: List<StatArtist> = emptyList(),
|
||||||
|
|||||||
@@ -394,6 +394,8 @@ fun AppNavigation() {
|
|||||||
StatsScreen(
|
StatsScreen(
|
||||||
coverUrl = { id -> coverUrl(id) },
|
coverUrl = { id -> coverUrl(id) },
|
||||||
onBack = { navController.popBackStack() },
|
onBack = { navController.popBackStack() },
|
||||||
|
onArtistClick = { id -> navController.navigate("artist/$id") },
|
||||||
|
onAlbumClick = { id -> navController.navigate("album/$id") },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -80,6 +80,14 @@ fun SettingsScreen(onOpenStats: () -> Unit = {}) {
|
|||||||
color = NsOnSurface,
|
color = NsOnSurface,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ── 📊 Statistics (TOP) ──────────────────────────────────────────────
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = onOpenStats,
|
||||||
|
modifier = Modifier.fillMaxWidth().height(48.dp),
|
||||||
|
) {
|
||||||
|
Text("📊 Meine Statistiken", color = NsOnSurface, fontFamily = Outfit, fontWeight = FontWeight.SemiBold)
|
||||||
|
}
|
||||||
|
|
||||||
// ── Save button (TOP) ────────────────────────────────────────────────
|
// ── Save button (TOP) ────────────────────────────────────────────────
|
||||||
SaveButton(vm, state.isSaving, state.savedOk, state.error)
|
SaveButton(vm, state.isSaving, state.savedOk, state.error)
|
||||||
|
|
||||||
@@ -228,15 +236,6 @@ fun SettingsScreen(onOpenStats: () -> Unit = {}) {
|
|||||||
|
|
||||||
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp), color = NsBorder)
|
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp), color = NsBorder)
|
||||||
|
|
||||||
OutlinedButton(
|
|
||||||
onClick = onOpenStats,
|
|
||||||
modifier = Modifier.fillMaxWidth().height(48.dp),
|
|
||||||
) {
|
|
||||||
Text("📊 Meine Statistiken", color = NsOnSurface, fontFamily = Outfit, fontWeight = FontWeight.SemiBold)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
|
|
||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
onClick = { showLogoutDialog = true },
|
onClick = { showLogoutDialog = true },
|
||||||
modifier = Modifier.fillMaxWidth().height(48.dp),
|
modifier = Modifier.fillMaxWidth().height(48.dp),
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ private val MONTHS = listOf(
|
|||||||
fun StatsScreen(
|
fun StatsScreen(
|
||||||
coverUrl: (String?) -> String?,
|
coverUrl: (String?) -> String?,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
|
onArtistClick: (String) -> Unit = {},
|
||||||
|
onAlbumClick: (String) -> Unit = {},
|
||||||
vm: StatsViewModel = viewModel(),
|
vm: StatsViewModel = viewModel(),
|
||||||
) {
|
) {
|
||||||
val state by vm.state.collectAsStateWithLifecycle()
|
val state by vm.state.collectAsStateWithLifecycle()
|
||||||
@@ -82,7 +84,7 @@ fun StatsScreen(
|
|||||||
}
|
}
|
||||||
if (state.view != StatsView.ALL) {
|
if (state.view != StatsView.ALL) {
|
||||||
Spacer(Modifier.weight(1f))
|
Spacer(Modifier.weight(1f))
|
||||||
StepArrow("‹", enabled = true) { vm.step(-1) }
|
StepArrow("‹", enabled = !vm.atStart()) { vm.step(-1) }
|
||||||
Text(
|
Text(
|
||||||
if (state.view == StatsView.YEAR) state.year.toString() else "${MONTHS[state.month]} ${state.year}",
|
if (state.view == StatsView.YEAR) state.year.toString() else "${MONTHS[state.month]} ${state.year}",
|
||||||
fontFamily = Outfit, fontSize = 14.sp, fontWeight = FontWeight.Bold, color = NsOnSurface,
|
fontFamily = Outfit, fontSize = 14.sp, fontWeight = FontWeight.Bold, color = NsOnSurface,
|
||||||
@@ -101,13 +103,18 @@ fun StatsScreen(
|
|||||||
(state.stats?.totalPlays ?: 0) == 0 -> CenterMessage(
|
(state.stats?.totalPlays ?: 0) == 0 -> CenterMessage(
|
||||||
"Noch keine Wiedergaben in diesem Zeitraum.\nSpiel ein paar Songs — sie erscheinen hier automatisch.",
|
"Noch keine Wiedergaben in diesem Zeitraum.\nSpiel ein paar Songs — sie erscheinen hier automatisch.",
|
||||||
)
|
)
|
||||||
else -> StatsContent(state.stats!!, coverUrl)
|
else -> StatsContent(state.stats!!, coverUrl, onArtistClick, onAlbumClick)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun StatsContent(d: ScrobbleStats, coverUrl: (String?) -> String?) {
|
private fun StatsContent(
|
||||||
|
d: ScrobbleStats,
|
||||||
|
coverUrl: (String?) -> String?,
|
||||||
|
onArtistClick: (String) -> Unit,
|
||||||
|
onAlbumClick: (String) -> Unit,
|
||||||
|
) {
|
||||||
Column(
|
Column(
|
||||||
Modifier.fillMaxSize().verticalScroll(rememberScrollState())
|
Modifier.fillMaxSize().verticalScroll(rememberScrollState())
|
||||||
.padding(horizontal = 16.dp).padding(bottom = 24.dp),
|
.padding(horizontal = 16.dp).padding(bottom = 24.dp),
|
||||||
@@ -128,7 +135,8 @@ private fun StatsContent(d: ScrobbleStats, coverUrl: (String?) -> String?) {
|
|||||||
if (d.topArtists.isNotEmpty()) {
|
if (d.topArtists.isNotEmpty()) {
|
||||||
SectionHeader("Top-Künstler")
|
SectionHeader("Top-Künstler")
|
||||||
d.topArtists.forEachIndexed { i, a ->
|
d.topArtists.forEachIndexed { i, a ->
|
||||||
TopRow(i + 1, coverUrl(a.artistId), a.name ?: "—", null, a.plays, circular = true)
|
TopRow(i + 1, coverUrl(a.artistId), a.name ?: "—", null, a.plays, circular = true,
|
||||||
|
onClick = a.artistId?.let { id -> { onArtistClick(id) } })
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(20.dp))
|
Spacer(Modifier.height(20.dp))
|
||||||
}
|
}
|
||||||
@@ -136,7 +144,8 @@ private fun StatsContent(d: ScrobbleStats, coverUrl: (String?) -> String?) {
|
|||||||
if (d.topTracks.isNotEmpty()) {
|
if (d.topTracks.isNotEmpty()) {
|
||||||
SectionHeader("Top-Songs")
|
SectionHeader("Top-Songs")
|
||||||
d.topTracks.forEachIndexed { i, t ->
|
d.topTracks.forEachIndexed { i, t ->
|
||||||
TopRow(i + 1, coverUrl(t.coverArt ?: t.albumId ?: t.songId), t.title ?: "—", t.artist, t.plays)
|
TopRow(i + 1, coverUrl(t.coverArt ?: t.albumId ?: t.songId), t.title ?: "—", t.artist, t.plays,
|
||||||
|
onClick = t.albumId?.let { id -> { onAlbumClick(id) } })
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(20.dp))
|
Spacer(Modifier.height(20.dp))
|
||||||
}
|
}
|
||||||
@@ -144,7 +153,8 @@ private fun StatsContent(d: ScrobbleStats, coverUrl: (String?) -> String?) {
|
|||||||
if (d.topAlbums.isNotEmpty()) {
|
if (d.topAlbums.isNotEmpty()) {
|
||||||
SectionHeader("Top-Alben")
|
SectionHeader("Top-Alben")
|
||||||
d.topAlbums.forEachIndexed { i, a ->
|
d.topAlbums.forEachIndexed { i, a ->
|
||||||
TopRow(i + 1, coverUrl(a.coverArt ?: a.albumId), a.name ?: "—", a.artist, a.plays)
|
TopRow(i + 1, coverUrl(a.coverArt ?: a.albumId), a.name ?: "—", a.artist, a.plays,
|
||||||
|
onClick = a.albumId?.let { id -> { onAlbumClick(id) } })
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(20.dp))
|
Spacer(Modifier.height(20.dp))
|
||||||
}
|
}
|
||||||
@@ -193,10 +203,13 @@ private fun SectionHeader(t: String) {
|
|||||||
@Composable
|
@Composable
|
||||||
private fun TopRow(
|
private fun TopRow(
|
||||||
rank: Int, cover: String?, title: String, subtitle: String?, plays: Int, circular: Boolean = false,
|
rank: Int, cover: String?, title: String, subtitle: String?, plays: Int, circular: Boolean = false,
|
||||||
|
onClick: (() -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier.fillMaxWidth().padding(vertical = 5.dp),
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
|
||||||
|
.padding(vertical = 5.dp),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
rank.toString(), fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 13.sp,
|
rank.toString(), fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 13.sp,
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ data class StatsUiState(
|
|||||||
val view: StatsView = StatsView.ALL,
|
val view: StatsView = StatsView.ALL,
|
||||||
val year: Int = Calendar.getInstance().get(Calendar.YEAR),
|
val year: Int = Calendar.getInstance().get(Calendar.YEAR),
|
||||||
val month: Int = Calendar.getInstance().get(Calendar.MONTH), // 0-11
|
val month: Int = Calendar.getInstance().get(Calendar.MONTH), // 0-11
|
||||||
|
val firstPlayedAt: Long? = null, // earliest logged play → navigator lower bound
|
||||||
val stats: ScrobbleStats? = null,
|
val stats: ScrobbleStats? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -67,6 +68,20 @@ class StatsViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** True when the shown window is at/before the first logged play — caps stepping backwards. */
|
||||||
|
fun atStart(): Boolean {
|
||||||
|
val s = _state.value
|
||||||
|
val first = s.firstPlayedAt ?: return false
|
||||||
|
val c = Calendar.getInstance().apply { timeInMillis = first * 1000 }
|
||||||
|
val fy = c.get(Calendar.YEAR)
|
||||||
|
val fm = c.get(Calendar.MONTH)
|
||||||
|
return when (s.view) {
|
||||||
|
StatsView.YEAR -> s.year <= fy
|
||||||
|
StatsView.MONTH -> s.year < fy || (s.year == fy && s.month <= fm)
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun startOf(year: Int, month: Int, day: Int): Long {
|
private fun startOf(year: Int, month: Int, day: Int): Long {
|
||||||
val c = Calendar.getInstance().apply {
|
val c = Calendar.getInstance().apply {
|
||||||
clear()
|
clear()
|
||||||
@@ -99,7 +114,11 @@ class StatsViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
_state.value = if (stats == null)
|
_state.value = if (stats == null)
|
||||||
_state.value.copy(loading = false, error = "Scrobble-Dienst nicht erreichbar")
|
_state.value.copy(loading = false, error = "Scrobble-Dienst nicht erreichbar")
|
||||||
else
|
else
|
||||||
_state.value.copy(loading = false, configured = true, stats = stats)
|
_state.value.copy(
|
||||||
|
loading = false, configured = true, stats = stats,
|
||||||
|
// Keep the earliest-play bound once known (a windowed query may return null for it).
|
||||||
|
firstPlayedAt = stats.firstPlayedAt ?: _state.value.firstPlayedAt,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
const CACHE = 'ns-v69';
|
const CACHE = 'ns-v70';
|
||||||
const PRECACHE = ['/', 'index.html', 'manifest.json', 'icon.svg'];
|
const PRECACHE = ['/', 'index.html', 'manifest.json', 'icon.svg'];
|
||||||
const SKIP_PATHS = ['/rest/stream', '/rest/download', '/config.js'];
|
const SKIP_PATHS = ['/rest/stream', '/rest/download', '/config.js'];
|
||||||
// Auth token params stripped from cache keys so artwork survives across sessions/token refreshes.
|
// Auth token params stripped from cache keys so artwork survives across sessions/token refreshes.
|
||||||
|
|||||||
@@ -195,6 +195,10 @@ app.get('/scrobble/stats', async (req, res) => {
|
|||||||
`SELECT COUNT(*) AS plays, COALESCE(SUM(duration), 0) AS seconds FROM plays WHERE ${WHERE}`
|
`SELECT COUNT(*) AS plays, COALESCE(SUM(duration), 0) AS seconds FROM plays WHERE ${WHERE}`
|
||||||
).get(win);
|
).get(win);
|
||||||
|
|
||||||
|
// Earliest play across ALL of the user's history (unfiltered by window) — lets the client cap the
|
||||||
|
// navigator so you can't step before logging began.
|
||||||
|
const firstPlayedAt = db.prepare('SELECT MIN(played_at) AS t FROM plays WHERE user = :user').get({ user }).t || null;
|
||||||
|
|
||||||
const topArtists = db.prepare(`
|
const topArtists = db.prepare(`
|
||||||
SELECT MAX(artist) AS name, MAX(artist_id) AS artistId, COUNT(*) AS plays, COALESCE(SUM(duration),0) AS seconds
|
SELECT MAX(artist) AS name, MAX(artist_id) AS artistId, COUNT(*) AS plays, COALESCE(SUM(duration),0) AS seconds
|
||||||
FROM plays WHERE ${WHERE} AND artist IS NOT NULL AND artist <> ''
|
FROM plays WHERE ${WHERE} AND artist IS NOT NULL AND artist <> ''
|
||||||
@@ -231,6 +235,7 @@ app.get('/scrobble/stats', async (req, res) => {
|
|||||||
res.json({
|
res.json({
|
||||||
user,
|
user,
|
||||||
range: { from, to, tz, bucket },
|
range: { from, to, tz, bucket },
|
||||||
|
firstPlayedAt,
|
||||||
totalPlays: totals.plays,
|
totalPlays: totals.plays,
|
||||||
totalSeconds: totals.seconds,
|
totalSeconds: totals.seconds,
|
||||||
topArtists, topAlbums, topTracks, series, byHour,
|
topArtists, topAlbums, topTracks, series, byHour,
|
||||||
|
|||||||
+34
-6
@@ -14,6 +14,7 @@ let _overlay = null;
|
|||||||
let _view = 'all'; // all | year | month
|
let _view = 'all'; // all | year | month
|
||||||
let _year = new Date().getFullYear();
|
let _year = new Date().getFullYear();
|
||||||
let _month = new Date().getMonth(); // 0-11
|
let _month = new Date().getMonth(); // 0-11
|
||||||
|
let _firstPlayedAt = null; // epoch secs of the user's earliest play → navigator lower bound
|
||||||
|
|
||||||
const _MONTHS = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
|
const _MONTHS = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
|
||||||
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'];
|
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'];
|
||||||
@@ -64,6 +65,15 @@ function _atCurrent() {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// True when the shown period is at/before the user's first logged play — caps stepping backwards.
|
||||||
|
function _atStart() {
|
||||||
|
if (!_firstPlayedAt) return false;
|
||||||
|
const f = new Date(_firstPlayedAt * 1000);
|
||||||
|
if (_view === 'year') return _year <= f.getFullYear();
|
||||||
|
if (_view === 'month') return _year < f.getFullYear() || (_year === f.getFullYear() && _month <= f.getMonth());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
function _step(dir) {
|
function _step(dir) {
|
||||||
if (_view === 'year') { _year += dir; }
|
if (_view === 'year') { _year += dir; }
|
||||||
else if (_view === 'month') {
|
else if (_view === 'month') {
|
||||||
@@ -101,6 +111,16 @@ function _buildOverlay() {
|
|||||||
<div class="stats-tabs" id="stats-tabs" style="display:flex;gap:6px;padding:12px 18px 0"></div>
|
<div class="stats-tabs" id="stats-tabs" style="display:flex;gap:6px;padding:12px 18px 0"></div>
|
||||||
<div class="stats-body" id="stats-body" style="padding:16px 18px;overflow-y:auto"></div>
|
<div class="stats-body" id="stats-body" style="padding:16px 18px;overflow-y:auto"></div>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
// Delegated navigation: click an artist / album / track row → open its page.
|
||||||
|
_overlay.querySelector('#stats-body').addEventListener('click', async e => {
|
||||||
|
const row = e.target.closest('[data-nav-type]');
|
||||||
|
if (!row) return;
|
||||||
|
const { navType, navId, navName, navArtist } = row.dataset;
|
||||||
|
closeStats();
|
||||||
|
const { navigate } = await import('../views/views.js');
|
||||||
|
if (navType === 'artist') navigate('artist-detail', { id: navId, name: navName });
|
||||||
|
else if (navType === 'album') navigate('album-detail', { id: navId, name: navName, artist: navArtist || '' });
|
||||||
|
});
|
||||||
document.body.appendChild(_overlay);
|
document.body.appendChild(_overlay);
|
||||||
_renderTabs();
|
_renderTabs();
|
||||||
}
|
}
|
||||||
@@ -121,7 +141,7 @@ function _renderTabs() {
|
|||||||
font-size:16px;line-height:1;display:flex;align-items:center;justify-content:center">${label}</button>`;
|
font-size:16px;line-height:1;display:flex;align-items:center;justify-content:center">${label}</button>`;
|
||||||
const stepper = _view === 'all' ? '' : `
|
const stepper = _view === 'all' ? '' : `
|
||||||
<div style="display:flex;align-items:center;gap:8px;margin-left:auto">
|
<div style="display:flex;align-items:center;gap:8px;margin-left:auto">
|
||||||
${stepBtn(-1, '‹', false)}
|
${stepBtn(-1, '‹', _atStart())}
|
||||||
<div style="min-width:118px;text-align:center;font-weight:700;font-size:14px">${esc(_periodLabel())}</div>
|
<div style="min-width:118px;text-align:center;font-weight:700;font-size:14px">${esc(_periodLabel())}</div>
|
||||||
${stepBtn(1, '›', _atCurrent())}
|
${stepBtn(1, '›', _atCurrent())}
|
||||||
</div>`;
|
</div>`;
|
||||||
@@ -153,6 +173,7 @@ async function _load() {
|
|||||||
const res = await fetch(`${S.cfg.scrobbleUrl}/scrobble/stats?${qs}`);
|
const res = await fetch(`${S.cfg.scrobbleUrl}/scrobble/stats?${qs}`);
|
||||||
if (!res.ok) { body.innerHTML = _errBox(`Fehler ${res.status}`); return; }
|
if (!res.ok) { body.innerHTML = _errBox(`Fehler ${res.status}`); return; }
|
||||||
const d = await res.json();
|
const d = await res.json();
|
||||||
|
if (d.firstPlayedAt) { _firstPlayedAt = d.firstPlayedAt; _renderTabs(); } // apply back-bound
|
||||||
_render(body, d);
|
_render(body, d);
|
||||||
} catch {
|
} catch {
|
||||||
body.innerHTML = _errBox('Scrobble-Dienst nicht erreichbar.');
|
body.innerHTML = _errBox('Scrobble-Dienst nicht erreichbar.');
|
||||||
@@ -172,9 +193,12 @@ function _render(body, d) {
|
|||||||
body.innerHTML = [
|
body.innerHTML = [
|
||||||
_tiles(d),
|
_tiles(d),
|
||||||
_seriesChart(d.series, d.range.bucket),
|
_seriesChart(d.series, d.range.bucket),
|
||||||
_topList('Top-Künstler', d.topArtists, a => a.name, a => a.plays, a => a.artistId, true),
|
_topList('Top-Künstler', d.topArtists, a => a.name, a => a.plays, a => a.artistId, true,
|
||||||
_topList('Top-Songs', d.topTracks, t => `${t.title}${t.artist ? ' · ' + t.artist : ''}`, t => t.plays, t => t.coverArt || t.albumId || t.songId),
|
a => a.artistId ? { type: 'artist', id: a.artistId, name: a.name } : null),
|
||||||
_topList('Top-Alben', d.topAlbums, a => `${a.name}${a.artist ? ' · ' + a.artist : ''}`, a => a.plays, a => a.coverArt || a.albumId),
|
_topList('Top-Songs', d.topTracks, t => `${t.title}${t.artist ? ' · ' + t.artist : ''}`, t => t.plays, t => t.coverArt || t.albumId || t.songId, false,
|
||||||
|
t => t.albumId ? { type: 'album', id: t.albumId, name: t.album, artist: t.artist } : null),
|
||||||
|
_topList('Top-Alben', d.topAlbums, a => `${a.name}${a.artist ? ' · ' + a.artist : ''}`, a => a.plays, a => a.coverArt || a.albumId, false,
|
||||||
|
a => a.albumId ? { type: 'album', id: a.albumId, name: a.name, artist: a.artist } : null),
|
||||||
_hourClock(d.byHour),
|
_hourClock(d.byHour),
|
||||||
].join('');
|
].join('');
|
||||||
}
|
}
|
||||||
@@ -210,7 +234,7 @@ function _seriesChart(series, bucket) {
|
|||||||
<div style="display:flex;gap:3px;align-items:flex-end;margin:6px 0 24px;overflow-x:auto;padding-bottom:8px">${bars}</div>`;
|
<div style="display:flex;gap:3px;align-items:flex-end;margin:6px 0 24px;overflow-x:auto;padding-bottom:8px">${bars}</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function _topList(title, items, labelFn, valFn, idFn = null, circular = false) {
|
function _topList(title, items, labelFn, valFn, idFn = null, circular = false, navFn = null) {
|
||||||
if (!items?.length) return '';
|
if (!items?.length) return '';
|
||||||
const max = Math.max(...items.map(valFn), 1);
|
const max = Math.max(...items.map(valFn), 1);
|
||||||
const radius = circular ? '50%' : '6px';
|
const radius = circular ? '50%' : '6px';
|
||||||
@@ -220,7 +244,11 @@ function _topList(title, items, labelFn, valFn, idFn = null, circular = false) {
|
|||||||
const thumb = `<div style="width:40px;height:40px;border-radius:${radius};background:var(--surface2);flex-shrink:0;overflow:hidden">
|
const thumb = `<div style="width:40px;height:40px;border-radius:${radius};background:var(--surface2);flex-shrink:0;overflow:hidden">
|
||||||
${cover ? `<img src="${esc(cover)}" loading="lazy" style="width:100%;height:100%;object-fit:cover" onerror="this.style.display='none'">` : ''}
|
${cover ? `<img src="${esc(cover)}" loading="lazy" style="width:100%;height:100%;object-fit:cover" onerror="this.style.display='none'">` : ''}
|
||||||
</div>`;
|
</div>`;
|
||||||
return `<div style="display:flex;align-items:center;gap:10px;padding:6px 0">
|
const nav = navFn ? navFn(it) : null;
|
||||||
|
const navAttrs = nav
|
||||||
|
? `data-nav-type="${nav.type}" data-nav-id="${esc(nav.id)}" data-nav-name="${esc(nav.name || '')}" data-nav-artist="${esc(nav.artist || '')}"`
|
||||||
|
: '';
|
||||||
|
return `<div ${navAttrs} style="display:flex;align-items:center;gap:10px;padding:6px 0${nav ? ';cursor:pointer' : ''}">
|
||||||
<div style="width:18px;text-align:right;color:var(--text2);font-size:13px;font-weight:700">${i + 1}</div>
|
<div style="width:18px;text-align:right;color:var(--text2);font-size:13px;font-weight:700">${i + 1}</div>
|
||||||
${thumb}
|
${thumb}
|
||||||
<div style="flex:1;min-width:0">
|
<div style="flex:1;min-width:0">
|
||||||
|
|||||||
Reference in new issue
Block a user