02f13db22c
Replaces build-time Eleventy data fetches with live client-side fetches so listening data is always current without requiring a blog rebuild. - Add js/listening.js with three Alpine.data components: listeningWidget, funkwhalePage, listeningPage - Replace _data/funkwhaleActivity.js and lastfmActivity.js with sync stubs (source: 'indiekit') — keeps slashes.njk links and widget guard working at build time - Rewrite widgets/funkwhale.njk, funkwhale.njk, listening.njk as Alpine-driven templates with loading skeletons and error states - Load listening.js globally in base.njk before Alpine core Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
279 lines
17 KiB
Plaintext
279 lines
17 KiB
Plaintext
---
|
|
layout: layouts/base.njk
|
|
title: Funkwhale Listening Activity
|
|
permalink: /funkwhale/
|
|
withSidebar: true
|
|
---
|
|
<div class="funkwhale-page" x-data="funkwhalePage()" x-init="init()">
|
|
<header class="mb-6 sm:mb-8">
|
|
<h1 class="text-2xl sm:text-3xl md:text-4xl font-bold text-surface-900 dark:text-surface-100 mb-2">Listening Activity</h1>
|
|
<p class="text-surface-600 dark:text-surface-400">What I've been listening to.</p>
|
|
</header>
|
|
|
|
{# Loading skeleton #}
|
|
<div x-show="loading" class="space-y-6 animate-pulse">
|
|
<div class="h-32 bg-surface-200 dark:bg-surface-700 rounded-xl"></div>
|
|
<div class="h-48 bg-surface-200 dark:bg-surface-700 rounded-xl"></div>
|
|
<div class="space-y-3">
|
|
<div class="h-16 bg-surface-200 dark:bg-surface-700 rounded-lg"></div>
|
|
<div class="h-16 bg-surface-200 dark:bg-surface-700 rounded-lg"></div>
|
|
<div class="h-16 bg-surface-200 dark:bg-surface-700 rounded-lg"></div>
|
|
</div>
|
|
</div>
|
|
|
|
{# Error #}
|
|
<p x-show="!loading && error" class="text-surface-600 dark:text-surface-400">Could not load listening data.</p>
|
|
|
|
{# Now Playing #}
|
|
<template x-if="!loading && nowPlaying?.status">
|
|
<section class="mb-12">
|
|
<div class="relative p-4 sm:p-6 rounded-xl sm:rounded-2xl overflow-hidden"
|
|
:class="nowPlaying.status === 'now-playing'
|
|
? 'bg-gradient-to-br from-green-500/10 to-green-600/5 border-2 border-green-500/30'
|
|
: 'bg-gradient-to-br from-purple-500/10 to-purple-600/5 border border-purple-500/20'">
|
|
<div class="flex flex-col sm:flex-row items-start sm:items-center gap-4 sm:gap-5">
|
|
<template x-if="nowPlaying.coverUrl">
|
|
<img :src="nowPlaying.coverUrl" alt="" class="w-20 h-20 sm:w-24 sm:h-24 rounded-lg shadow-lg object-cover flex-shrink-0" loading="lazy">
|
|
</template>
|
|
<template x-if="!nowPlaying.coverUrl">
|
|
<div class="w-20 h-20 sm:w-24 sm:h-24 rounded-lg bg-surface-200 dark:bg-surface-700 flex items-center justify-center flex-shrink-0">
|
|
<svg class="w-8 h-8 sm:w-10 sm:h-10 text-surface-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"/>
|
|
</svg>
|
|
</div>
|
|
</template>
|
|
<div class="flex-1 min-w-0 w-full sm:w-auto">
|
|
<div class="flex items-center gap-2 mb-2">
|
|
<template x-if="nowPlaying.status === 'now-playing'">
|
|
<span class="inline-flex items-center gap-1.5 px-2 py-1 text-xs font-medium bg-green-500/20 text-green-700 dark:text-green-400 rounded-full">
|
|
<span class="flex gap-0.5 items-end h-3">
|
|
<span class="w-0.5 bg-green-500 animate-pulse" style="height: 30%;"></span>
|
|
<span class="w-0.5 bg-green-500 animate-pulse" style="height: 70%; animation-delay: 0.2s;"></span>
|
|
<span class="w-0.5 bg-green-500 animate-pulse" style="height: 50%; animation-delay: 0.4s;"></span>
|
|
</span>
|
|
Now Playing
|
|
</span>
|
|
</template>
|
|
<template x-if="nowPlaying.status !== 'now-playing'">
|
|
<span class="inline-flex items-center gap-1.5 px-2 py-1 text-xs font-medium bg-purple-500/20 text-purple-700 dark:text-purple-400 rounded-full">
|
|
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 24 24"><path d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
|
Recently Played
|
|
</span>
|
|
</template>
|
|
</div>
|
|
<h2 class="text-lg sm:text-xl font-bold text-surface-900 dark:text-surface-100 truncate">
|
|
<template x-if="nowPlaying.trackUrl">
|
|
<a :href="nowPlaying.trackUrl" class="hover:text-purple-600 dark:hover:text-purple-400" target="_blank" rel="noopener" x-text="nowPlaying.track"></a>
|
|
</template>
|
|
<template x-if="!nowPlaying.trackUrl">
|
|
<span x-text="nowPlaying.track"></span>
|
|
</template>
|
|
</h2>
|
|
<p class="text-surface-600 dark:text-surface-400" x-text="nowPlaying.artist"></p>
|
|
<p x-show="nowPlaying.album" class="text-sm text-surface-600 dark:text-surface-400 mt-1" x-text="nowPlaying.album"></p>
|
|
<p class="text-xs text-surface-600 dark:text-surface-400 mt-2" x-text="nowPlaying.relativeTime"></p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</template>
|
|
|
|
{# Stats Section #}
|
|
<template x-if="!loading && stats">
|
|
<section class="mb-12">
|
|
<h2 class="text-xl sm:text-2xl font-bold text-surface-900 dark:text-surface-100 mb-4 sm:mb-6 flex items-center gap-2">
|
|
<svg class="w-6 h-6 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/>
|
|
</svg>
|
|
Listening Statistics
|
|
</h2>
|
|
|
|
{# Tab buttons #}
|
|
<div class="flex gap-1 mb-6 border-b border-surface-200 dark:border-surface-700 overflow-x-auto" role="tablist">
|
|
<template x-for="tab in ['all','month','week','trends']" :key="tab">
|
|
<button
|
|
@click="activeTab = tab"
|
|
:class="activeTab === tab ? 'border-b-2 border-purple-500 text-purple-600 dark:text-purple-400' : 'text-surface-600 dark:text-surface-400 hover:text-surface-700 dark:hover:text-surface-300'"
|
|
:aria-selected="(activeTab === tab).toString()"
|
|
role="tab"
|
|
class="px-4 py-2 text-sm font-medium transition-colors -mb-px whitespace-nowrap capitalize"
|
|
x-text="tab === 'all' ? 'All Time' : tab === 'month' ? 'This Month' : tab === 'week' ? 'This Week' : 'Trends'"
|
|
></button>
|
|
</template>
|
|
</div>
|
|
|
|
{# Stats panel — All / Month / Week share same markup via computed props #}
|
|
<div x-show="activeTab !== 'trends'" x-cloak>
|
|
{# Summary cards #}
|
|
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3 sm:gap-4 mb-6 sm:mb-8">
|
|
<div class="p-4 bg-surface-50 dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700 shadow-sm text-center">
|
|
<span class="text-2xl font-bold font-mono text-purple-600 dark:text-purple-400 block" x-text="summary.totalPlays || 0"></span>
|
|
<span class="text-xs text-surface-600 dark:text-surface-400 uppercase tracking-wide">Plays</span>
|
|
</div>
|
|
<div class="p-4 bg-surface-50 dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700 shadow-sm text-center">
|
|
<span class="text-2xl font-bold font-mono text-purple-600 dark:text-purple-400 block" x-text="summary.uniqueTracks || 0"></span>
|
|
<span class="text-xs text-surface-600 dark:text-surface-400 uppercase tracking-wide">Tracks</span>
|
|
</div>
|
|
<div class="p-4 bg-surface-50 dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700 shadow-sm text-center">
|
|
<span class="text-2xl font-bold font-mono text-purple-600 dark:text-purple-400 block" x-text="summary.uniqueArtists || 0"></span>
|
|
<span class="text-xs text-surface-600 dark:text-surface-400 uppercase tracking-wide">Artists</span>
|
|
</div>
|
|
<div class="p-4 bg-surface-50 dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700 shadow-sm text-center">
|
|
<span class="text-2xl font-bold font-mono text-purple-600 dark:text-purple-400 block" x-text="totalDurationFormatted"></span>
|
|
<span class="text-xs text-surface-600 dark:text-surface-400 uppercase tracking-wide">Listened</span>
|
|
</div>
|
|
</div>
|
|
|
|
{# Top Artists #}
|
|
<template x-if="topArtists.length">
|
|
<div class="mb-8">
|
|
<h3 class="text-lg font-semibold text-surface-900 dark:text-surface-100 mb-4">Top Artists</h3>
|
|
<div class="space-y-2">
|
|
<template x-for="(artist, i) in topArtists" :key="artist.name">
|
|
<div class="flex items-center gap-3 p-3 bg-surface-50 dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700 shadow-sm">
|
|
<span class="w-6 h-6 flex items-center justify-center text-sm font-bold text-surface-600 dark:text-surface-400 bg-surface-100 dark:bg-surface-700 rounded-full" x-text="i + 1"></span>
|
|
<span class="flex-1 font-medium text-surface-900 dark:text-surface-100" x-text="artist.name"></span>
|
|
<span class="text-sm text-surface-600 dark:text-surface-400" x-text="artist.playCount + ' plays'"></span>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
{# Top Albums #}
|
|
<template x-if="topAlbums.length">
|
|
<div>
|
|
<h3 class="text-lg font-semibold text-surface-900 dark:text-surface-100 mb-4">Top Albums</h3>
|
|
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3 sm:gap-4">
|
|
<template x-for="album in topAlbums" :key="album.title + album.artist">
|
|
<div class="text-center">
|
|
<template x-if="album.coverUrl">
|
|
<img :src="album.coverUrl" alt="" class="w-full aspect-square object-cover rounded-lg mb-2 shadow-lg" loading="lazy">
|
|
</template>
|
|
<template x-if="!album.coverUrl">
|
|
<div class="w-full aspect-square bg-surface-200 dark:bg-surface-700 rounded-lg mb-2 flex items-center justify-center">
|
|
<svg class="w-8 h-8 text-surface-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2z"/>
|
|
</svg>
|
|
</div>
|
|
</template>
|
|
<p class="text-sm font-medium text-surface-900 dark:text-surface-100 truncate" x-text="album.title"></p>
|
|
<p class="text-xs text-surface-600 dark:text-surface-400 truncate" x-text="album.artist"></p>
|
|
<p class="text-xs text-surface-600 dark:text-surface-400" x-text="album.playCount + ' plays'"></p>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
|
|
{# Trends panel #}
|
|
<div x-show="activeTab === 'trends'" x-cloak>
|
|
<template x-if="trendsData.length">
|
|
<div class="bg-surface-50 dark:bg-surface-800 rounded-lg p-6 border border-surface-200 dark:border-surface-700 shadow-sm">
|
|
<h3 class="text-lg font-semibold text-surface-900 dark:text-surface-100 mb-4">Daily Listening (Last 30 Days)</h3>
|
|
<div class="flex items-end gap-1 h-32">
|
|
<template x-for="day in trendsData" :key="day.date">
|
|
<div
|
|
class="flex-1 bg-purple-500 hover:bg-purple-600 rounded-t transition-colors cursor-pointer"
|
|
:style="'height: ' + (trendsMax > 0 ? Math.round(day.count / trendsMax * 100) : 0) + '%; min-height: 2px;'"
|
|
:title="day.date + ': ' + day.count + ' plays'"
|
|
></div>
|
|
</template>
|
|
</div>
|
|
<div class="flex justify-between text-xs text-surface-600 dark:text-surface-400 mt-2">
|
|
<span x-text="trendsData[0]?.date"></span>
|
|
<span x-text="trendsData[trendsData.length - 1]?.date"></span>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
<p x-show="!trendsData.length" class="text-surface-600 dark:text-surface-400">No trend data available yet.</p>
|
|
</div>
|
|
</section>
|
|
</template>
|
|
|
|
{# Recent Listenings #}
|
|
<section class="mb-12">
|
|
<h2 class="text-xl sm:text-2xl font-bold text-surface-900 dark:text-surface-100 mb-4 sm:mb-6 flex items-center gap-2">
|
|
<svg class="w-6 h-6 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
|
</svg>
|
|
Recent Listens
|
|
</h2>
|
|
<div class="space-y-3">
|
|
<template x-if="!loading && listenings.length">
|
|
<template x-for="listening in listenings.slice(0,15)" :key="listening._ts + listening.track">
|
|
<div class="flex items-center gap-4 p-3 bg-surface-50 dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700 hover:border-purple-400 dark:hover:border-purple-600 transition-colors shadow-sm">
|
|
<template x-if="listening.coverUrl">
|
|
<img :src="listening.coverUrl" alt="" class="w-12 h-12 rounded object-cover flex-shrink-0" loading="lazy">
|
|
</template>
|
|
<template x-if="!listening.coverUrl">
|
|
<div class="w-12 h-12 rounded bg-surface-200 dark:bg-surface-700 flex items-center justify-center flex-shrink-0">
|
|
<svg class="w-6 h-6 text-surface-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2z"/>
|
|
</svg>
|
|
</div>
|
|
</template>
|
|
<div class="flex-1 min-w-0">
|
|
<h3 class="font-medium text-surface-900 dark:text-surface-100 truncate">
|
|
<template x-if="listening.trackUrl">
|
|
<a :href="listening.trackUrl" class="hover:text-purple-600 dark:hover:text-purple-400" target="_blank" rel="noopener" x-text="listening.track"></a>
|
|
</template>
|
|
<template x-if="!listening.trackUrl">
|
|
<span x-text="listening.track"></span>
|
|
</template>
|
|
</h3>
|
|
<p class="text-sm text-surface-600 dark:text-surface-400 truncate" x-text="listening.artist"></p>
|
|
</div>
|
|
<div class="text-right flex-shrink-0">
|
|
<span class="text-xs text-surface-600 dark:text-surface-400" x-text="listening.relativeTime"></span>
|
|
<span x-show="listening.duration" class="text-xs text-surface-600 dark:text-surface-400 block" x-text="listening.duration"></span>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</template>
|
|
<p x-show="!loading && !listenings.length" class="text-surface-600 dark:text-surface-400">No recent listening history available.</p>
|
|
</div>
|
|
</section>
|
|
|
|
{# Favorites #}
|
|
<template x-if="!loading && favorites.length">
|
|
<section class="mb-12">
|
|
<h2 class="text-xl sm:text-2xl font-bold text-surface-900 dark:text-surface-100 mb-4 sm:mb-6 flex items-center gap-2">
|
|
<svg class="w-6 h-6 text-red-500" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/>
|
|
</svg>
|
|
Favorite Tracks
|
|
</h2>
|
|
<div class="grid gap-3 sm:gap-4 md:grid-cols-2">
|
|
<template x-for="favorite in favorites.slice(0,10)" :key="favorite.track + favorite.artist">
|
|
<div class="flex items-center gap-3 p-3 bg-surface-50 dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700 shadow-sm">
|
|
<template x-if="favorite.coverUrl">
|
|
<img :src="favorite.coverUrl" alt="" class="w-14 h-14 rounded object-cover flex-shrink-0" loading="lazy">
|
|
</template>
|
|
<template x-if="!favorite.coverUrl">
|
|
<div class="w-14 h-14 rounded bg-surface-200 dark:bg-surface-700 flex items-center justify-center flex-shrink-0">
|
|
<svg class="w-6 h-6 text-purple-400" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/>
|
|
</svg>
|
|
</div>
|
|
</template>
|
|
<div class="flex-1 min-w-0">
|
|
<h3 class="font-medium text-surface-900 dark:text-surface-100 truncate">
|
|
<template x-if="favorite.trackUrl">
|
|
<a :href="favorite.trackUrl" class="hover:text-purple-600 dark:hover:text-purple-400" target="_blank" rel="noopener" x-text="favorite.track"></a>
|
|
</template>
|
|
<template x-if="!favorite.trackUrl">
|
|
<span x-text="favorite.track"></span>
|
|
</template>
|
|
</h3>
|
|
<p class="text-sm text-surface-600 dark:text-surface-400 truncate" x-text="favorite.artist"></p>
|
|
<p x-show="favorite.album" class="text-xs text-surface-600 dark:text-surface-400 truncate" x-text="favorite.album"></p>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</section>
|
|
</template>
|
|
</div>
|