feat: convert Funkwhale/Last.fm to client-side Alpine.js

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>
This commit is contained in:
svemagie
2026-04-15 08:23:05 +02:00
parent e6e21298d1
commit 02f13db22c
7 changed files with 839 additions and 969 deletions
+15 -155
View File
@@ -1,159 +1,19 @@
/** /**
* Funkwhale Activity Data * Funkwhale Activity Data — build-time stub
* Fetches from Indiekit's endpoint-funkwhale public API *
* Data is now fetched client-side via Alpine.js (js/listening.js).
* This stub returns the minimal shape needed so Nunjucks templates
* (slashes.njk, widget guard) continue to render the listening sections,
* while the actual data is populated by the browser after page load.
*/ */
import { cachedFetch } from "../lib/data-fetch.js"; export default function () {
import { cacheCoverUrls, cacheFunkwhaleImage, gcFunkwhaleImages } from "../lib/cache-funkwhale-image.js"; return {
source: "indiekit",
const INDIEKIT_URL = nowPlaying: null,
process.env.INDIEKIT_URL || process.env.SITE_URL || "https://example.com"; listenings: [],
const FUNKWHALE_INSTANCE = process.env.FUNKWHALE_INSTANCE || ""; favorites: [],
const DEFAULT_FETCH_CACHE_DURATION = "5m"; stats: null,
const LISTENING_FETCH_CACHE_DURATION = instanceUrl: process.env.FUNKWHALE_INSTANCE || "",
(process.env.LISTENING_FETCH_CACHE_DURATION || "").trim() || DEFAULT_FETCH_CACHE_DURATION; };
const FUNKWHALE_FETCH_CACHE_DURATION =
(process.env.FUNKWHALE_FETCH_CACHE_DURATION || "").trim() || LISTENING_FETCH_CACHE_DURATION;
/**
* Fetch from Indiekit's public Funkwhale API endpoint
*/
async function fetchFromIndiekit(endpoint) {
const urls = [
`${INDIEKIT_URL}/funkwhale/api/${endpoint}`,
`${INDIEKIT_URL}/funkwhaleapi/api/${endpoint}`,
];
for (const url of urls) {
try {
console.log(`[funkwhaleActivity] Fetching from Indiekit: ${url}`);
const data = await cachedFetch(url, {
duration: FUNKWHALE_FETCH_CACHE_DURATION,
type: "json",
});
console.log(`[funkwhaleActivity] Indiekit ${endpoint} success via ${url}`);
return data;
} catch (error) {
console.log(
`[funkwhaleActivity] Indiekit API unavailable for ${endpoint} at ${url}: ${error.message}`
);
}
}
return null;
}
/**
* Format duration in seconds to human-readable string
*/
function formatDuration(seconds) {
if (!seconds || seconds < 0) return "0:00";
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (hours > 24) {
const days = Math.floor(hours / 24);
return `${days}d`;
}
if (hours > 0) {
return `${hours}h ${minutes}m`;
}
return `${minutes}m`;
}
export default async function () {
try {
console.log("[funkwhaleActivity] Fetching Funkwhale data...");
console.log(
`[funkwhaleActivity] EleventyFetch cache duration: ${FUNKWHALE_FETCH_CACHE_DURATION}`
);
// Fetch all data from Indiekit API
const [nowPlaying, listenings, favorites, stats] = await Promise.all([
fetchFromIndiekit("now-playing"),
fetchFromIndiekit("listenings"),
fetchFromIndiekit("favorites"),
fetchFromIndiekit("stats"),
]);
// Check if we got data
const hasData = nowPlaying || listenings?.listenings?.length || stats?.summary;
if (!hasData) {
console.log("[funkwhaleActivity] No data available from Indiekit");
return {
nowPlaying: null,
listenings: [],
favorites: [],
stats: null,
instanceUrl: FUNKWHALE_INSTANCE,
source: "unavailable",
};
}
console.log("[funkwhaleActivity] Using Indiekit API data");
// Format stats with human-readable durations
let formattedStats = null;
if (stats?.summary) {
formattedStats = {
...stats,
summary: {
all: {
...stats.summary.all,
totalDurationFormatted: formatDuration(stats.summary.all?.totalDuration || 0),
},
month: {
...stats.summary.month,
totalDurationFormatted: formatDuration(stats.summary.month?.totalDuration || 0),
},
week: {
...stats.summary.week,
totalDurationFormatted: formatDuration(stats.summary.week?.totalDuration || 0),
},
},
};
}
// Mark listenings that appear in favorites
const favSet = new Set(
(favorites?.favorites || []).map((f) => `${f.track}\0${f.artist}`)
);
const enrichedListenings = (listenings?.listenings || []).map((l) => ({
...l,
favorite: favSet.has(`${l.track}\0${l.artist}`),
}));
// Cache cover images locally to avoid serving expiring presigned S3 URLs
const [cachedNowPlaying, cachedListenings, cachedFavorites] = await Promise.all([
nowPlaying ? { ...nowPlaying, coverUrl: await cacheFunkwhaleImage(nowPlaying.coverUrl) } : null,
cacheCoverUrls(enrichedListenings),
cacheCoverUrls(favorites?.favorites || []),
]);
// Remove cached images that are no longer referenced by any current item
gcFunkwhaleImages();
return {
nowPlaying: cachedNowPlaying,
listenings: cachedListenings,
favorites: cachedFavorites,
stats: formattedStats,
instanceUrl: FUNKWHALE_INSTANCE,
source: "indiekit",
};
} catch (error) {
console.error("[funkwhaleActivity] Error:", error.message);
return {
nowPlaying: null,
listenings: [],
favorites: [],
stats: null,
instanceUrl: FUNKWHALE_INSTANCE,
source: "error",
};
}
} }
+18 -95
View File
@@ -1,99 +1,22 @@
/** /**
* Last.fm Activity Data * Last.fm Activity Data — build-time stub
* Fetches from Indiekit's endpoint-lastfm public API *
* Data is now fetched client-side via Alpine.js (js/listening.js).
* This stub returns the minimal shape needed so Nunjucks templates
* (slashes.njk, widget guard) continue to render the listening sections,
* while the actual data is populated by the browser after page load.
*/ */
import { cachedFetch } from "../lib/data-fetch.js"; export default function () {
return {
const INDIEKIT_URL = source: "indiekit",
process.env.INDIEKIT_URL || process.env.SITE_URL || "https://example.com"; nowPlaying: null,
const LASTFM_USERNAME = process.env.LASTFM_USERNAME || ""; scrobbles: [],
const DEFAULT_FETCH_CACHE_DURATION = "5m"; loved: [],
const LISTENING_FETCH_CACHE_DURATION = stats: null,
(process.env.LISTENING_FETCH_CACHE_DURATION || "").trim() || DEFAULT_FETCH_CACHE_DURATION; username: process.env.LASTFM_USERNAME || "",
const LASTFM_FETCH_CACHE_DURATION = profileUrl: process.env.LASTFM_USERNAME
(process.env.LASTFM_FETCH_CACHE_DURATION || "").trim() || LISTENING_FETCH_CACHE_DURATION; ? `https://www.last.fm/user/${process.env.LASTFM_USERNAME}`
: null,
/** };
* Fetch from Indiekit's public Last.fm API endpoint
*/
async function fetchFromIndiekit(path) {
const urls = [
`${INDIEKIT_URL}/lastfmapi/api/${path}`,
`${INDIEKIT_URL}/lastfm/api/${path}`,
];
for (const url of urls) {
try {
console.log(`[lastfmActivity] Fetching from Indiekit: ${url}`);
const data = await cachedFetch(url, {
duration: LASTFM_FETCH_CACHE_DURATION,
type: "json",
});
console.log(`[lastfmActivity] Indiekit ${path} success via ${url}`);
return data;
} catch (error) {
console.log(
`[lastfmActivity] Indiekit API unavailable for ${path} at ${url}: ${error.message}`
);
}
}
return null;
}
export default async function () {
try {
console.log("[lastfmActivity] Fetching Last.fm data...");
console.log(
`[lastfmActivity] EleventyFetch cache duration: ${LASTFM_FETCH_CACHE_DURATION}`
);
// Fetch all data from Indiekit API
const [nowPlaying, scrobbles, loved, stats] = await Promise.all([
fetchFromIndiekit("now-playing"),
fetchFromIndiekit("scrobbles?period=alltime&limit=10"),
fetchFromIndiekit("loved?limit=10"),
fetchFromIndiekit("stats?period=alltime"),
]);
// Check if we got data
const hasData = nowPlaying || scrobbles?.scrobbles?.length || stats?.summary;
if (!hasData) {
console.log("[lastfmActivity] No data available from Indiekit");
return {
nowPlaying: null,
scrobbles: [],
loved: [],
stats: null,
username: LASTFM_USERNAME,
profileUrl: LASTFM_USERNAME ? `https://www.last.fm/user/${LASTFM_USERNAME}` : null,
source: "unavailable",
};
}
console.log("[lastfmActivity] Using Indiekit API data");
return {
nowPlaying: nowPlaying || null,
scrobbles: scrobbles?.scrobbles || [],
loved: loved?.loved || [],
stats: stats || null,
username: LASTFM_USERNAME,
profileUrl: LASTFM_USERNAME ? `https://www.last.fm/user/${LASTFM_USERNAME}` : null,
source: "indiekit",
};
} catch (error) {
console.error("[lastfmActivity] Error:", error.message);
return {
nowPlaying: null,
scrobbles: [],
loved: [],
stats: null,
username: LASTFM_USERNAME,
profileUrl: null,
source: "error",
};
}
} }
+72 -92
View File
@@ -1,8 +1,8 @@
{# Listening Widget — combined Funkwhale + Last.fm recent tracks #} {# Listening Widget — combined Funkwhale + Last.fm recent tracks (client-side) #}
{% set hasListening = (funkwhaleActivity and (funkwhaleActivity.nowPlaying or funkwhaleActivity.listenings.length)) or (lastfmActivity and (lastfmActivity.nowPlaying or lastfmActivity.scrobbles.length)) %} {% set hasListening = (funkwhaleActivity and funkwhaleActivity.source == 'indiekit') or (lastfmActivity and lastfmActivity.source == 'indiekit') %}
{% if hasListening %} {% if hasListening %}
<is-land on:visible> <is-land on:visible>
<div class="widget"> <div class="widget" x-data="listeningWidget()" x-init="init()">
<h3 class="widget-title flex items-center gap-2"> <h3 class="widget-title flex items-center gap-2">
<svg class="w-5 h-5 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" 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"/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" 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"/>
@@ -10,100 +10,80 @@
Listening Listening
</h3> </h3>
{# Now Playing — show if either source is actively playing #} {# Loading skeleton #}
{% set fwNow = funkwhaleActivity.nowPlaying if funkwhaleActivity and funkwhaleActivity.nowPlaying and funkwhaleActivity.nowPlaying.status == 'now-playing' else null %} <div x-show="loading" class="space-y-2 animate-pulse">
{% set lfmNow = lastfmActivity.nowPlaying if lastfmActivity and lastfmActivity.nowPlaying and lastfmActivity.nowPlaying.status == 'now-playing' else null %} <div class="h-4 bg-surface-200 dark:bg-surface-700 rounded w-3/4"></div>
<div class="h-4 bg-surface-200 dark:bg-surface-700 rounded w-1/2"></div>
{% if fwNow or lfmNow %} <div class="h-4 bg-surface-200 dark:bg-surface-700 rounded w-2/3"></div>
{% set np = fwNow or lfmNow %}
{% set npSource = "Funkwhale" if fwNow else "Last.fm" %}
{% set npColor = "purple" if fwNow else "red" %}
<div class="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-3 mb-3">
<div class="flex items-center gap-1.5 text-xs text-green-600 dark:text-green-400 mb-2">
<span class="flex gap-0.5 items-end h-2.5" aria-hidden="true">
<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 class="text-{{ npColor }}-600 dark:text-{{ npColor }}-400 ml-1">({{ npSource }})</span>
</div>
<div class="flex items-center gap-3">
{% if np.coverUrl %}
<img src="{{ np.coverUrl }}" alt="" class="w-10 h-10 rounded object-cover flex-shrink-0 shadow-lg" loading="lazy" eleventy:ignore>
{% endif %}
<div class="min-w-0 flex-1">
<p class="font-medium text-sm text-surface-900 dark:text-surface-100 truncate">
{% if np.trackUrl %}
<a href="{{ np.trackUrl }}" class="hover:text-purple-600 dark:hover:text-purple-400" target="_blank" rel="noopener">{{ np.track }}</a>
{% else %}
{{ np.track }}
{% endif %}
</p>
<p class="text-xs text-surface-600 dark:text-surface-400 truncate">{{ np.artist }}</p>
</div>
</div>
</div> </div>
{% endif %}
{# Recent tracks — 2 from each source #} {# Error state #}
<ul class="space-y-2"> <p x-show="!loading && error" class="text-xs text-surface-500 dark:text-surface-400">Unavailable</p>
{% if funkwhaleActivity and funkwhaleActivity.listenings.length %}
{% for listening in funkwhaleActivity.listenings | head(2) %}
<li class="flex items-center gap-2">
{% if listening.coverUrl %}
<img src="{{ listening.coverUrl }}" alt="" class="w-8 h-8 rounded object-cover flex-shrink-0 shadow-lg" loading="lazy" eleventy:ignore>
{% else %}
<div class="w-8 h-8 rounded bg-purple-100 dark:bg-purple-900/30 flex items-center justify-center flex-shrink-0">
<svg class="w-4 h-4 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19V6l12-3v13"/>
</svg>
</div>
{% endif %}
<div class="min-w-0 flex-1">
<p class="text-sm text-surface-900 dark:text-surface-100 truncate">
{% if listening.trackUrl %}
<a href="{{ listening.trackUrl }}" class="hover:text-purple-600 dark:hover:text-purple-400" target="_blank" rel="noopener">{{ listening.track }}</a>
{% else %}
{{ listening.track }}
{% endif %}
</p>
<p class="text-xs text-surface-600 dark:text-surface-400 truncate">{{ listening.artist }}
<span class="text-purple-500 ml-1">Funkwhale</span>
</p>
</div>
</li>
{% endfor %}
{% endif %}
{% if lastfmActivity and lastfmActivity.scrobbles.length %} {# Now Playing #}
{% for scrobble in lastfmActivity.scrobbles | head(2) %} <template x-if="!loading && nowPlaying">
<li class="flex items-center gap-2"> <div class="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-3 mb-3">
{% if scrobble.coverUrl %} <div class="flex items-center gap-1.5 text-xs text-green-600 dark:text-green-400 mb-2">
<img src="{{ scrobble.coverUrl }}" alt="" class="w-8 h-8 rounded object-cover flex-shrink-0 shadow-lg" loading="lazy" eleventy:ignore> <span class="flex gap-0.5 items-end h-2.5" aria-hidden="true">
{% else %} <span class="w-0.5 bg-green-500 animate-pulse" style="height: 30%;"></span>
<div class="w-8 h-8 rounded bg-red-100 dark:bg-red-900/30 flex items-center justify-center flex-shrink-0"> <span class="w-0.5 bg-green-500 animate-pulse" style="height: 70%; animation-delay: 0.2s;"></span>
<svg class="w-4 h-4 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <span class="w-0.5 bg-green-500 animate-pulse" style="height: 50%; animation-delay: 0.4s;"></span>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19V6l12-3v13"/> </span>
</svg> Now Playing
<span class="ml-1" :class="nowPlaying?._source === 'funkwhale' ? 'text-purple-600 dark:text-purple-400' : 'text-red-600 dark:text-red-400'" x-text="nowPlaying?._source === 'funkwhale' ? '(Funkwhale)' : '(Last.fm)'"></span>
</div> </div>
{% endif %} <div class="flex items-center gap-3">
<div class="min-w-0 flex-1"> <template x-if="nowPlaying?.coverUrl">
<p class="text-sm text-surface-900 dark:text-surface-100 truncate"> <img :src="nowPlaying.coverUrl" alt="" class="w-10 h-10 rounded object-cover flex-shrink-0 shadow-lg" loading="lazy">
{% if scrobble.trackUrl %} </template>
<a href="{{ scrobble.trackUrl }}" class="hover:text-red-600 dark:hover:text-red-400" target="_blank" rel="noopener">{{ scrobble.track }}</a> <div class="min-w-0 flex-1">
{% else %} <p class="font-medium text-sm text-surface-900 dark:text-surface-100 truncate">
{{ scrobble.track }} <template x-if="nowPlaying?.trackUrl">
{% endif %} <a :href="nowPlaying.trackUrl" class="hover:text-purple-600 dark:hover:text-purple-400" target="_blank" rel="noopener" x-text="nowPlaying.track"></a>
{% if scrobble.loved %}<span class="text-red-500 ml-0.5">&#9829;</span>{% endif %} </template>
</p> <template x-if="!nowPlaying?.trackUrl">
<p class="text-xs text-surface-600 dark:text-surface-400 truncate">{{ scrobble.artist }} <span x-text="nowPlaying?.track"></span>
<span class="text-red-500 ml-1">Last.fm</span> </template>
</p> </p>
<p class="text-xs text-surface-600 dark:text-surface-400 truncate" x-text="nowPlaying?.artist"></p>
</div>
</div> </div>
</li> </div>
{% endfor %} </template>
{% endif %}
{# Recent tracks #}
<ul x-show="!loading && recentTracks.length" class="space-y-2">
<template x-for="item in recentTracks" :key="item._ts + item.track">
<li class="flex items-center gap-2">
<template x-if="item.coverUrl">
<img :src="item.coverUrl" alt="" class="w-8 h-8 rounded object-cover flex-shrink-0 shadow-lg" loading="lazy">
</template>
<template x-if="!item.coverUrl">
<div :class="item._source === 'funkwhale' ? 'bg-purple-100 dark:bg-purple-900/30' : 'bg-red-100 dark:bg-red-900/30'" class="w-8 h-8 rounded flex items-center justify-center flex-shrink-0">
<svg class="w-4 h-4" :class="item._source === 'funkwhale' ? 'text-purple-400' : 'text-red-400'" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19V6l12-3v13"/>
</svg>
</div>
</template>
<div class="min-w-0 flex-1">
<p class="text-sm text-surface-900 dark:text-surface-100 truncate">
<template x-if="item.trackUrl">
<a :href="item.trackUrl" class="hover:text-purple-600 dark:hover:text-purple-400" target="_blank" rel="noopener" x-text="item.track"></a>
</template>
<template x-if="!item.trackUrl">
<span x-text="item.track"></span>
</template>
<template x-if="item.loved">
<span class="text-red-500 ml-0.5">&#9829;</span>
</template>
</p>
<p class="text-xs text-surface-600 dark:text-surface-400 truncate">
<span x-text="item.artist"></span>
<span class="ml-1" :class="item._source === 'funkwhale' ? 'text-purple-500' : 'text-red-500'" x-text="item._source === 'funkwhale' ? 'Funkwhale' : 'Last.fm'"></span>
</p>
</div>
</li>
</template>
</ul> </ul>
<a href="/listening/" class="text-sm text-purple-600 dark:text-purple-400 hover:underline flex items-center gap-1 mt-3"> <a href="/listening/" class="text-sm text-purple-600 dark:text-purple-400 hover:underline flex items-center gap-1 mt-3">
+1
View File
@@ -162,6 +162,7 @@
<script src="/js/vendor/lite-yt-embed.js?v={{ '/js/vendor/lite-yt-embed.js' | hash }}" defer></script> <script src="/js/vendor/lite-yt-embed.js?v={{ '/js/vendor/lite-yt-embed.js' | hash }}" defer></script>
{# Alpine.js components — MUST load before Alpine core (Alpine.data() registration via alpine:init) #} {# Alpine.js components — MUST load before Alpine core (Alpine.data() registration via alpine:init) #}
<script src="/js/toc-scanner.js?v={{ '/js/toc-scanner.js' | hash }}" defer></script> <script src="/js/toc-scanner.js?v={{ '/js/toc-scanner.js' | hash }}" defer></script>
<script src="/js/listening.js?v={{ '/js/listening.js' | hash }}" defer></script>
<script src="/js/comments.js?v={{ '/js/comments.js' | hash }}" defer></script> <script src="/js/comments.js?v={{ '/js/comments.js' | hash }}" defer></script>
<script src="/js/fediverse-interact.js?v={{ '/js/fediverse-interact.js' | hash }}" defer></script> <script src="/js/fediverse-interact.js?v={{ '/js/fediverse-interact.js' | hash }}" defer></script>
<script src="/js/lightbox.js?v={{ '/js/lightbox.js' | hash }}" defer></script> <script src="/js/lightbox.js?v={{ '/js/lightbox.js' | hash }}" defer></script>
+245 -241
View File
@@ -4,179 +4,193 @@ title: Funkwhale Listening Activity
permalink: /funkwhale/ permalink: /funkwhale/
withSidebar: true withSidebar: true
--- ---
<div class="funkwhale-page" x-data="{ activeTab: 'all' }"> <div class="funkwhale-page" x-data="funkwhalePage()" x-init="init()">
<header class="mb-6 sm:mb-8"> <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> <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"> <p class="text-surface-600 dark:text-surface-400">What I've been listening to.</p>
What I've been listening to.
</p>
</header> </header>
{# Now Playing / Recently Played Hero #} {# Loading skeleton #}
{% if funkwhaleActivity.nowPlaying and funkwhaleActivity.nowPlaying.status %} <div x-show="loading" class="space-y-6 animate-pulse">
<section class="mb-12"> <div class="h-32 bg-surface-200 dark:bg-surface-700 rounded-xl"></div>
<div class="relative p-4 sm:p-6 rounded-xl sm:rounded-2xl overflow-hidden {% if funkwhaleActivity.nowPlaying.status == 'now-playing' %}bg-gradient-to-br from-green-500/10 to-green-600/5 border-2 border-green-500/30{% else %}bg-gradient-to-br from-purple-500/10 to-purple-600/5 border border-purple-500/20{% endif %}"> <div class="h-48 bg-surface-200 dark:bg-surface-700 rounded-xl"></div>
<div class="flex flex-col sm:flex-row items-start sm:items-center gap-4 sm:gap-5"> <div class="space-y-3">
{% if funkwhaleActivity.nowPlaying.coverUrl %} <div class="h-16 bg-surface-200 dark:bg-surface-700 rounded-lg"></div>
<img <div class="h-16 bg-surface-200 dark:bg-surface-700 rounded-lg"></div>
src="{{ funkwhaleActivity.nowPlaying.coverUrl }}" <div class="h-16 bg-surface-200 dark:bg-surface-700 rounded-lg"></div>
alt="" </div>
class="w-20 h-20 sm:w-24 sm:h-24 rounded-lg shadow-lg object-cover flex-shrink-0" </div>
loading="lazy"
eleventy:ignore
>
{% else %}
<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>
{% endif %}
<div class="flex-1 min-w-0 w-full sm:w-auto"> {# Error #}
<div class="flex items-center gap-2 mb-2"> <p x-show="!loading && error" class="text-surface-600 dark:text-surface-400">Could not load listening data.</p>
{% if funkwhaleActivity.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"> {# Now Playing #}
<span class="flex gap-0.5 items-end h-3"> <template x-if="!loading && nowPlaying?.status">
<span class="w-0.5 bg-green-500 animate-pulse" style="height: 30%; animation-delay: 0s;"></span> <section class="mb-12">
<span class="w-0.5 bg-green-500 animate-pulse" style="height: 70%; animation-delay: 0.2s;"></span> <div class="relative p-4 sm:p-6 rounded-xl sm:rounded-2xl overflow-hidden"
<span class="w-0.5 bg-green-500 animate-pulse" style="height: 50%; animation-delay: 0.4s;"></span> :class="nowPlaying.status === 'now-playing'
</span> ? 'bg-gradient-to-br from-green-500/10 to-green-600/5 border-2 border-green-500/30'
Now Playing : 'bg-gradient-to-br from-purple-500/10 to-purple-600/5 border border-purple-500/20'">
</span> <div class="flex flex-col sm:flex-row items-start sm:items-center gap-4 sm:gap-5">
{% else %} <template x-if="nowPlaying.coverUrl">
<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"> <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">
<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> </template>
Recently Played <template x-if="!nowPlaying.coverUrl">
</span> <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">
{% endif %} <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>
<h2 class="text-lg sm:text-xl font-bold text-surface-900 dark:text-surface-100 truncate">
{% if funkwhaleActivity.nowPlaying.trackUrl %}
<a href="{{ funkwhaleActivity.nowPlaying.trackUrl }}" class="hover:text-purple-600 dark:hover:text-purple-400" target="_blank" rel="noopener">
{{ funkwhaleActivity.nowPlaying.track }}
</a>
{% else %}
{{ funkwhaleActivity.nowPlaying.track }}
{% endif %}
</h2>
<p class="text-surface-600 dark:text-surface-400">{{ funkwhaleActivity.nowPlaying.artist }}</p>
{% if funkwhaleActivity.nowPlaying.album %}
<p class="text-sm text-surface-600 dark:text-surface-400 mt-1">{{ funkwhaleActivity.nowPlaying.album }}</p>
{% endif %}
<p class="text-xs text-surface-600 dark:text-surface-400 mt-2">{{ funkwhaleActivity.nowPlaying.relativeTime }}</p>
</div> </div>
</div> </div>
</div> </section>
</section> </template>
{% endif %}
{# Stats Section with Tabs #} {# Stats Section #}
{% if funkwhaleActivity.stats %} <template x-if="!loading && stats">
<section class="mb-12"> <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"> <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"> <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"/> <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> </svg>
Listening Statistics Listening Statistics
</h2> </h2>
{# Tab buttons #} {# Tab buttons #}
<div class="flex gap-1 mb-6 border-b border-surface-200 dark:border-surface-700 overflow-x-auto" role="tablist" aria-label="Listening statistics period"> <div class="flex gap-1 mb-6 border-b border-surface-200 dark:border-surface-700 overflow-x-auto" role="tablist">
<button <template x-for="tab in ['all','month','week','trends']" :key="tab">
@click="activeTab = 'all'" <button
:class="activeTab === 'all' ? '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'" @click="activeTab = tab"
:aria-selected="(activeTab === 'all').toString()" :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'"
role="tab" id="fw-tab-all" aria-controls="fw-panel-all" :aria-selected="(activeTab === tab).toString()"
class="px-4 py-2 text-sm font-medium transition-colors -mb-px whitespace-nowrap" role="tab"
> class="px-4 py-2 text-sm font-medium transition-colors -mb-px whitespace-nowrap capitalize"
All Time x-text="tab === 'all' ? 'All Time' : tab === 'month' ? 'This Month' : tab === 'week' ? 'This Week' : 'Trends'"
</button> ></button>
<button </template>
@click="activeTab = 'month'"
:class="activeTab === 'month' ? '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 === 'month').toString()"
role="tab" id="fw-tab-month" aria-controls="fw-panel-month"
class="px-4 py-2 text-sm font-medium transition-colors -mb-px whitespace-nowrap"
>
This Month
</button>
<button
@click="activeTab = 'week'"
:class="activeTab === 'week' ? '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 === 'week').toString()"
role="tab" id="fw-tab-week" aria-controls="fw-panel-week"
class="px-4 py-2 text-sm font-medium transition-colors -mb-px whitespace-nowrap"
>
This Week
</button>
<button
@click="activeTab = 'trends'"
:class="activeTab === 'trends' ? '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 === 'trends').toString()"
role="tab" id="fw-tab-trends" aria-controls="fw-panel-trends"
class="px-4 py-2 text-sm font-medium transition-colors -mb-px whitespace-nowrap"
>
Trends
</button>
</div>
{# All Time Tab #}
<div x-show="activeTab === 'all'" x-cloak role="tabpanel" id="fw-panel-all" aria-labelledby="fw-tab-all">
{% set summary = funkwhaleActivity.stats.summary.all %}
{% set topArtists = funkwhaleActivity.stats.topArtists.all %}
{% set topAlbums = funkwhaleActivity.stats.topAlbums.all %}
{% include "components/funkwhale-stats-content.njk" %}
</div>
{# This Month Tab #}
<div x-show="activeTab === 'month'" x-cloak role="tabpanel" id="fw-panel-month" aria-labelledby="fw-tab-month">
{% set summary = funkwhaleActivity.stats.summary.month %}
{% set topArtists = funkwhaleActivity.stats.topArtists.month %}
{% set topAlbums = funkwhaleActivity.stats.topAlbums.month %}
{% include "components/funkwhale-stats-content.njk" %}
</div>
{# This Week Tab #}
<div x-show="activeTab === 'week'" x-cloak role="tabpanel" id="fw-panel-week" aria-labelledby="fw-tab-week">
{% set summary = funkwhaleActivity.stats.summary.week %}
{% set topArtists = funkwhaleActivity.stats.topArtists.week %}
{% set topAlbums = funkwhaleActivity.stats.topAlbums.week %}
{% include "components/funkwhale-stats-content.njk" %}
</div>
{# Trends Tab #}
<div x-show="activeTab === 'trends'" x-cloak role="tabpanel" id="fw-panel-trends" aria-labelledby="fw-tab-trends">
{% if funkwhaleActivity.stats.trends and funkwhaleActivity.stats.trends.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">
{% set maxCount = 1 %}
{% for day in funkwhaleActivity.stats.trends %}
{% if day.count > maxCount %}
{% set maxCount = day.count %}
{% endif %}
{% endfor %}
{% for day in funkwhaleActivity.stats.trends %}
<div
class="flex-1 bg-purple-500 hover:bg-purple-600 rounded-t transition-colors cursor-pointer"
style="height: {{ (day.count / maxCount * 100) if maxCount > 0 else 0 }}%; min-height: 2px;"
title="{{ day.date }}: {{ day.count }} plays"
></div>
{% endfor %}
</div>
<div class="flex justify-between text-xs text-surface-600 dark:text-surface-400 mt-2">
<span>{{ funkwhaleActivity.stats.trends[0].date }}</span>
<span>{{ funkwhaleActivity.stats.trends[funkwhaleActivity.stats.trends.length - 1].date }}</span>
</div>
</div> </div>
{% else %}
<p class="text-surface-600 dark:text-surface-400">No trend data available yet.</p> {# Stats panel — All / Month / Week share same markup via computed props #}
{% endif %} <div x-show="activeTab !== 'trends'" x-cloak>
</div> {# Summary cards #}
</section> <div class="grid grid-cols-2 sm:grid-cols-4 gap-3 sm:gap-4 mb-6 sm:mb-8">
{% endif %} <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 #} {# Recent Listenings #}
<section class="mb-12"> <section class="mb-12">
@@ -186,89 +200,79 @@ withSidebar: true
</svg> </svg>
Recent Listens Recent Listens
</h2> </h2>
{% if funkwhaleActivity.listenings.length %}
<div class="space-y-3"> <div class="space-y-3">
{% for listening in funkwhaleActivity.listenings | head(15) %} <template x-if="!loading && listenings.length">
<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-for="listening in listenings.slice(0,15)" :key="listening._ts + listening.track">
{% if listening.coverUrl %} <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">
<img src="{{ listening.coverUrl }}" alt="" class="w-12 h-12 rounded object-cover flex-shrink-0" loading="lazy" eleventy:ignore> <template x-if="listening.coverUrl">
{% else %} <img :src="listening.coverUrl" alt="" class="w-12 h-12 rounded object-cover flex-shrink-0" loading="lazy">
<div class="w-12 h-12 rounded bg-surface-200 dark:bg-surface-700 flex items-center justify-center flex-shrink-0"> </template>
<svg class="w-6 h-6 text-surface-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <template x-if="!listening.coverUrl">
<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"/> <div class="w-12 h-12 rounded bg-surface-200 dark:bg-surface-700 flex items-center justify-center flex-shrink-0">
</svg> <svg class="w-6 h-6 text-surface-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
</div> <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"/>
{% endif %} </svg>
</div>
<div class="flex-1 min-w-0"> </template>
<h3 class="font-medium text-surface-900 dark:text-surface-100 truncate"> <div class="flex-1 min-w-0">
{% if listening.trackUrl %} <h3 class="font-medium text-surface-900 dark:text-surface-100 truncate">
<a href="{{ listening.trackUrl }}" class="hover:text-purple-600 dark:hover:text-purple-400" target="_blank" rel="noopener"> <template x-if="listening.trackUrl">
{{ listening.track }} <a :href="listening.trackUrl" class="hover:text-purple-600 dark:hover:text-purple-400" target="_blank" rel="noopener" x-text="listening.track"></a>
</a> </template>
{% else %} <template x-if="!listening.trackUrl">
{{ listening.track }} <span x-text="listening.track"></span>
{% endif %} </template>
</h3> </h3>
<p class="text-sm text-surface-600 dark:text-surface-400 truncate">{{ listening.artist }}</p> <p class="text-sm text-surface-600 dark:text-surface-400 truncate" x-text="listening.artist"></p>
</div> </div>
<div class="text-right flex-shrink-0">
<div class="text-right flex-shrink-0"> <span class="text-xs text-surface-600 dark:text-surface-400" x-text="listening.relativeTime"></span>
<span class="text-xs text-surface-600 dark:text-surface-400">{{ listening.relativeTime }}</span> <span x-show="listening.duration" class="text-xs text-surface-600 dark:text-surface-400 block" x-text="listening.duration"></span>
{% if listening.duration %} </div>
<span class="text-xs text-surface-600 dark:text-surface-400 block">{{ listening.duration }}</span> </div>
{% endif %} </template>
</div> </template>
</div> <p x-show="!loading && !listenings.length" class="text-surface-600 dark:text-surface-400">No recent listening history available.</p>
{% endfor %}
</div> </div>
{% else %}
<p class="text-surface-600 dark:text-surface-400">No recent listening history available.</p>
{% endif %}
</section> </section>
{# Favorites #} {# Favorites #}
{% if funkwhaleActivity.favorites.length %} <template x-if="!loading && favorites.length">
<section class="mb-12"> <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"> <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"> <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"/> <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> </svg>
Favorite Tracks Favorite Tracks
</h2> </h2>
<div class="grid gap-3 sm:gap-4 md:grid-cols-2">
<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">
{% for favorite in funkwhaleActivity.favorites | head(10) %} <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">
<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">
{% if favorite.coverUrl %} <img :src="favorite.coverUrl" alt="" class="w-14 h-14 rounded object-cover flex-shrink-0" loading="lazy">
<img src="{{ favorite.coverUrl }}" alt="" class="w-14 h-14 rounded object-cover flex-shrink-0" loading="lazy" eleventy:ignore> </template>
{% else %} <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"> <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-surface-400" fill="currentColor" viewBox="0 0 24 24"> <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"/> <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> </svg>
</div> </div>
{% endif %} </template>
<div class="flex-1 min-w-0">
<div class="flex-1 min-w-0"> <h3 class="font-medium text-surface-900 dark:text-surface-100 truncate">
<h3 class="font-medium text-surface-900 dark:text-surface-100 truncate"> <template x-if="favorite.trackUrl">
{% 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>
<a href="{{ favorite.trackUrl }}" class="hover:text-purple-600 dark:hover:text-purple-400" target="_blank" rel="noopener"> </template>
{{ favorite.track }} <template x-if="!favorite.trackUrl">
</a> <span x-text="favorite.track"></span>
{% else %} </template>
{{ favorite.track }} </h3>
{% endif %} <p class="text-sm text-surface-600 dark:text-surface-400 truncate" x-text="favorite.artist"></p>
</h3> <p x-show="favorite.album" class="text-xs text-surface-600 dark:text-surface-400 truncate" x-text="favorite.album"></p>
<p class="text-sm text-surface-600 dark:text-surface-400 truncate">{{ favorite.artist }}</p> </div>
{% if favorite.album %} </div>
<p class="text-xs text-surface-600 dark:text-surface-400 truncate">{{ favorite.album }}</p> </template>
{% endif %}
</div>
</div> </div>
{% endfor %} </section>
</div> </template>
</section>
{% endif %}
</div> </div>
+197
View File
@@ -0,0 +1,197 @@
/**
* Client-side listening components (Alpine.js)
* Replaces build-time Eleventy data fetches for Funkwhale and Last.fm.
* Data is fetched live from the Indiekit API on the same origin.
*/
document.addEventListener("alpine:init", () => {
// ── helpers ──────────────────────────────────────────────────────────────
async function apiFetch(path) {
const r = await fetch(path);
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json();
}
function mergeListens(fw = [], lfm = []) {
const fwItems = fw.map((l) => ({
...l,
_source: "funkwhale",
_ts: new Date(l.listenedAt || l.creation_date || l.listened_at || 0).getTime(),
}));
const lfmItems = lfm.map((s) => ({
...s,
_source: "lastfm",
_ts: new Date(s.scrobbledAt || 0).getTime(),
}));
return [...fwItems, ...lfmItems].sort((a, b) => b._ts - a._ts).slice(0, 20);
}
function formatDuration(seconds) {
if (!seconds || seconds < 0) return "0:00";
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (hours > 24) return `${Math.floor(hours / 24)}d`;
if (hours > 0) return `${hours}h ${minutes}m`;
return `${minutes}m`;
}
// ── sidebar widget ────────────────────────────────────────────────────────
// Used on every page that includes widgets/funkwhale.njk.
// <is-land on:visible> defers init until the widget scrolls into view.
Alpine.data("listeningWidget", () => ({
loading: true,
error: false,
fwNowPlaying: null,
fwListenings: [],
lfmNowPlaying: null,
lfmScrobbles: [],
async init() {
try {
const [fwNp, fwList, lfmNp, lfmList] = await Promise.allSettled([
apiFetch("/funkwhale/api/now-playing"),
apiFetch("/funkwhale/api/listenings?limit=2"),
apiFetch("/lastfmapi/api/now-playing"),
apiFetch("/lastfmapi/api/scrobbles?limit=2"),
]);
if (fwNp.status === "fulfilled") this.fwNowPlaying = fwNp.value;
if (fwList.status === "fulfilled") this.fwListenings = fwList.value.listenings || [];
if (lfmNp.status === "fulfilled") this.lfmNowPlaying = lfmNp.value;
if (lfmList.status === "fulfilled") this.lfmScrobbles = lfmList.value.scrobbles || [];
} catch {
this.error = true;
} finally {
this.loading = false;
}
},
get nowPlaying() {
if (this.fwNowPlaying?.status === "now-playing")
return { ...this.fwNowPlaying, _source: "funkwhale" };
if (this.lfmNowPlaying?.status === "now-playing")
return { ...this.lfmNowPlaying, _source: "lastfm" };
return null;
},
get recentTracks() {
const fw = this.fwListenings.slice(0, 2).map((l) => ({ ...l, _source: "funkwhale" }));
const lfm = this.lfmScrobbles.slice(0, 2).map((s) => ({ ...s, _source: "lastfm" }));
return [...fw, ...lfm].slice(0, 4);
},
}));
// ── Funkwhale full page ───────────────────────────────────────────────────
Alpine.data("funkwhalePage", () => ({
loading: true,
error: false,
nowPlaying: null,
listenings: [],
favorites: [],
stats: null,
activeTab: "all",
async init() {
try {
const [np, list, fav, st] = await Promise.allSettled([
apiFetch("/funkwhale/api/now-playing"),
apiFetch("/funkwhale/api/listenings"),
apiFetch("/funkwhale/api/favorites"),
apiFetch("/funkwhale/api/stats"),
]);
if (np.status === "fulfilled") this.nowPlaying = np.value;
if (list.status === "fulfilled") this.listenings = list.value.listenings || [];
if (fav.status === "fulfilled") this.favorites = fav.value.favorites || [];
if (st.status === "fulfilled") this.stats = st.value;
} catch {
this.error = true;
} finally {
this.loading = false;
}
},
// computed stats helpers
get summary() {
const m = { all: this.stats?.summary?.all, month: this.stats?.summary?.month, week: this.stats?.summary?.week };
return m[this.activeTab] || {};
},
get topArtists() {
const m = { all: this.stats?.topArtists?.all, month: this.stats?.topArtists?.month, week: this.stats?.topArtists?.week };
return (m[this.activeTab] || []).slice(0, 5);
},
get topAlbums() {
const m = { all: this.stats?.topAlbums?.all, month: this.stats?.topAlbums?.month, week: this.stats?.topAlbums?.week };
return (m[this.activeTab] || []).slice(0, 5);
},
get trendsData() { return this.stats?.trends || []; },
get trendsMax() { return Math.max(1, ...this.trendsData.map((d) => d.count)); },
get totalDurationFormatted() {
return formatDuration(this.summary.totalDuration || 0);
},
}));
// ── combined listening page ───────────────────────────────────────────────
Alpine.data("listeningPage", () => ({
fwLoading: true,
lfmLoading: true,
fw: { nowPlaying: null, listenings: [], favorites: [], stats: null },
lfm: { nowPlaying: null, scrobbles: [], loved: [], stats: null },
activeSource: "all",
async init() {
await Promise.all([this._fetchFunkwhale(), this._fetchLastfm()]);
},
async _fetchFunkwhale() {
try {
const [np, list, fav, st] = await Promise.allSettled([
apiFetch("/funkwhale/api/now-playing"),
apiFetch("/funkwhale/api/listenings"),
apiFetch("/funkwhale/api/favorites"),
apiFetch("/funkwhale/api/stats"),
]);
if (np.status === "fulfilled") this.fw.nowPlaying = np.value;
if (list.status === "fulfilled") this.fw.listenings = list.value.listenings || [];
if (fav.status === "fulfilled") this.fw.favorites = fav.value.favorites || [];
if (st.status === "fulfilled") this.fw.stats = st.value;
} finally {
this.fwLoading = false;
}
},
async _fetchLastfm() {
try {
const [np, sc, lv, st] = await Promise.allSettled([
apiFetch("/lastfmapi/api/now-playing"),
apiFetch("/lastfmapi/api/scrobbles?period=alltime&limit=20"),
apiFetch("/lastfmapi/api/loved?limit=10"),
apiFetch("/lastfmapi/api/stats?period=alltime"),
]);
if (np.status === "fulfilled") this.lfm.nowPlaying = np.value;
if (sc.status === "fulfilled") this.lfm.scrobbles = sc.value.scrobbles || [];
if (lv.status === "fulfilled") this.lfm.loved = lv.value.loved || [];
if (st.status === "fulfilled") this.lfm.stats = st.value;
} finally {
this.lfmLoading = false;
}
},
get loading() { return this.fwLoading || this.lfmLoading; },
get fwNowPlaying() { return this.fw.nowPlaying?.status ? this.fw.nowPlaying : null; },
get lfmNowPlaying() { return this.lfm.nowPlaying?.status ? this.lfm.nowPlaying : null; },
get hasFunkwhale() { return !this.fwLoading; },
get hasLastfm() { return !this.lfmLoading; },
get combinedListens() {
return mergeListens(this.fw.listenings, this.lfm.scrobbles);
},
get fwStatsAll() { return this.fw.stats?.summary?.all || {}; },
get lfmStatsAll() { return this.lfm.stats?.summary?.all || {}; },
get fwTopArtists() { return (this.fw.stats?.topArtists?.all || []).slice(0, 5); },
get lfmTopArtists() { return (this.lfm.stats?.topArtists?.all || []).slice(0, 5); },
}));
});
+291 -386
View File
@@ -4,20 +4,10 @@ title: Listening Activity
permalink: /listening/ permalink: /listening/
withSidebar: true withSidebar: true
--- ---
<div class="listening-page" x-data="{ activeTab: 'all', activeSource: 'all' }"> <div class="listening-page" x-data="listeningPage()" x-init="init()">
<header class="mb-6 sm:mb-8"> <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> <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"> <p class="text-surface-600 dark:text-surface-400">What I've been listening to.</p>
What I've been listening to.
{# {% if funkwhaleActivity.instanceUrl %}
<a href="{{ funkwhaleActivity.instanceUrl }}" class="text-purple-600 dark:text-purple-400 hover:underline" target="_blank" rel="noopener">Funkwhale</a>
{% endif %}
{% if funkwhaleActivity.instanceUrl and lastfmActivity.profileUrl %} and {% endif %}
{% if lastfmActivity.profileUrl %}
<a href="{{ lastfmActivity.profileUrl }}" class="text-purple-600 dark:text-purple-400 hover:underline" target="_blank" rel="noopener">Last.fm</a>
{% endif %}
#}
</p>
</header> </header>
{# Source Filter Tabs #} {# Source Filter Tabs #}
@@ -26,238 +16,221 @@ withSidebar: true
@click="activeSource = 'all'" @click="activeSource = 'all'"
:class="activeSource === 'all' ? 'bg-purple-600 text-white' : 'bg-surface-100 dark:bg-surface-800 text-surface-700 dark:text-surface-300 hover:bg-surface-200 dark:hover:bg-surface-700'" :class="activeSource === 'all' ? 'bg-purple-600 text-white' : 'bg-surface-100 dark:bg-surface-800 text-surface-700 dark:text-surface-300 hover:bg-surface-200 dark:hover:bg-surface-700'"
class="px-4 py-2 rounded-full text-sm font-medium transition-colors" class="px-4 py-2 rounded-full text-sm font-medium transition-colors"
> >All Sources</button>
All Sources
</button>
{% if funkwhaleActivity.source == 'indiekit' %}
<button <button
@click="activeSource = 'funkwhale'" @click="activeSource = 'funkwhale'"
:class="activeSource === 'funkwhale' ? 'bg-purple-600 text-white' : 'bg-surface-100 dark:bg-surface-800 text-surface-700 dark:text-surface-300 hover:bg-surface-200 dark:hover:bg-surface-700'" :class="activeSource === 'funkwhale' ? 'bg-purple-600 text-white' : 'bg-surface-100 dark:bg-surface-800 text-surface-700 dark:text-surface-300 hover:bg-surface-200 dark:hover:bg-surface-700'"
class="px-4 py-2 rounded-full text-sm font-medium transition-colors flex items-center gap-2" class="px-4 py-2 rounded-full text-sm font-medium transition-colors flex items-center gap-2"
> ><span class="w-2 h-2 rounded-full bg-purple-500"></span>Funkwhale</button>
<span class="w-2 h-2 rounded-full bg-purple-500"></span>
Funkwhale
</button>
{% endif %}
{% if lastfmActivity.source == 'indiekit' %}
<button <button
@click="activeSource = 'lastfm'" @click="activeSource = 'lastfm'"
:class="activeSource === 'lastfm' ? 'bg-red-600 text-white' : 'bg-surface-100 dark:bg-surface-800 text-surface-700 dark:text-surface-300 hover:bg-surface-200 dark:hover:bg-surface-700'" :class="activeSource === 'lastfm' ? 'bg-red-600 text-white' : 'bg-surface-100 dark:bg-surface-800 text-surface-700 dark:text-surface-300 hover:bg-surface-200 dark:hover:bg-surface-700'"
class="px-4 py-2 rounded-full text-sm font-medium transition-colors flex items-center gap-2" class="px-4 py-2 rounded-full text-sm font-medium transition-colors flex items-center gap-2"
> ><span class="w-2 h-2 rounded-full bg-red-500"></span>Last.fm</button>
<span class="w-2 h-2 rounded-full bg-red-500"></span>
Last.fm
</button>
{% endif %}
</div> </div>
{# Now Playing Section - Combined #} {# Loading skeleton #}
{% set fwNowPlaying = funkwhaleActivity.nowPlaying if funkwhaleActivity.nowPlaying and funkwhaleActivity.nowPlaying.status else null %} <div x-show="loading" class="space-y-4 animate-pulse">
{% set lfmNowPlaying = lastfmActivity.nowPlaying if lastfmActivity.nowPlaying and lastfmActivity.nowPlaying.status else null %} <div class="h-32 bg-surface-200 dark:bg-surface-700 rounded-xl"></div>
<div class="h-40 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>
{% if fwNowPlaying or lfmNowPlaying %} {# Now Playing — Funkwhale #}
<section class="mb-12"> <template x-if="!loading && fwNowPlaying && (activeSource === 'all' || activeSource === 'funkwhale')">
{# Funkwhale Now Playing #} <section class="mb-6">
{% if fwNowPlaying %} <div class="relative p-4 sm:p-6 rounded-xl sm:rounded-2xl overflow-hidden"
<div x-show="activeSource === 'all' || activeSource === 'funkwhale'" class="mb-4"> :class="fwNowPlaying.status === 'now-playing'
<div class="relative p-4 sm:p-6 rounded-xl sm:rounded-2xl overflow-hidden {% if fwNowPlaying.status == 'now-playing' %}bg-gradient-to-br from-green-500/10 to-green-600/5 border-2 border-green-500/30{% else %}bg-gradient-to-br from-purple-500/10 to-purple-600/5 border border-purple-500/20{% endif %}"> ? '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"> <div class="flex flex-col sm:flex-row items-start sm:items-center gap-4 sm:gap-5">
{% if fwNowPlaying.coverUrl %} <template x-if="fwNowPlaying.coverUrl">
<img src="{{ fwNowPlaying.coverUrl }}" alt="" class="w-20 h-20 sm:w-24 sm:h-24 rounded-lg shadow-lg object-cover flex-shrink-0" loading="lazy" eleventy:ignore> <img :src="fwNowPlaying.coverUrl" alt="" class="w-20 h-20 sm:w-24 sm:h-24 rounded-lg shadow-lg object-cover flex-shrink-0" loading="lazy">
{% else %} </template>
<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"> <template x-if="!fwNowPlaying.coverUrl">
<svg class="w-8 h-8 sm:w-10 sm:h-10 text-surface-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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">
<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 class="w-8 h-8 sm:w-10 sm:h-10 text-surface-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
</svg> <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"/>
</div> </svg>
{% endif %} </div>
</template>
<div class="flex-1 min-w-0 w-full sm:w-auto"> <div class="flex-1 min-w-0 w-full sm:w-auto">
<div class="flex items-center gap-2 mb-2"> <div class="flex items-center gap-2 mb-2">
<span class="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium bg-purple-500/20 text-purple-700 dark:text-purple-400 rounded-full">Funkwhale</span> <span class="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium bg-purple-500/20 text-purple-700 dark:text-purple-400 rounded-full">Funkwhale</span>
{% if fwNowPlaying.status == 'now-playing' %} <template x-if="fwNowPlaying.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"> <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">
<span class="flex gap-0.5 items-end h-3"> <span class="flex gap-0.5 items-end h-3">
<span class="w-0.5 bg-purple-500 animate-pulse" style="height: 30%; animation-delay: 0s;"></span> <span class="w-0.5 bg-purple-500 animate-pulse" style="height: 30%;"></span>
<span class="w-0.5 bg-purple-500 animate-pulse" style="height: 70%; animation-delay: 0.2s;"></span> <span class="w-0.5 bg-purple-500 animate-pulse" style="height: 70%; animation-delay: 0.2s;"></span>
<span class="w-0.5 bg-purple-500 animate-pulse" style="height: 50%; animation-delay: 0.4s;"></span> <span class="w-0.5 bg-purple-500 animate-pulse" style="height: 50%; animation-delay: 0.4s;"></span>
</span>
Now Playing
</span> </span>
Now Playing </template>
</span> <template x-if="fwNowPlaying.status !== 'now-playing'">
{% else %} <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">Recently Played</span>
<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"> </template>
Recently Played
</span>
{% endif %}
</div> </div>
<h2 class="text-lg sm:text-xl font-bold text-surface-900 dark:text-surface-100 truncate"> <h2 class="text-lg sm:text-xl font-bold text-surface-900 dark:text-surface-100 truncate">
{% if fwNowPlaying.trackUrl %} <template x-if="fwNowPlaying.trackUrl">
<a href="{{ fwNowPlaying.trackUrl }}" class="hover:text-purple-600 dark:hover:text-purple-400" target="_blank" rel="noopener">{{ fwNowPlaying.track }}</a> <a :href="fwNowPlaying.trackUrl" class="hover:text-purple-600 dark:hover:text-purple-400" target="_blank" rel="noopener" x-text="fwNowPlaying.track"></a>
{% else %} </template>
{{ fwNowPlaying.track }} <template x-if="!fwNowPlaying.trackUrl"><span x-text="fwNowPlaying.track"></span></template>
{% endif %}
</h2> </h2>
<p class="text-surface-600 dark:text-surface-400">{{ fwNowPlaying.artist }}</p> <p class="text-surface-600 dark:text-surface-400" x-text="fwNowPlaying.artist"></p>
{% if fwNowPlaying.album %} <p x-show="fwNowPlaying.album" class="text-sm text-surface-600 dark:text-surface-400 mt-1" x-text="fwNowPlaying.album"></p>
<p class="text-sm text-surface-600 dark:text-surface-400 mt-1">{{ fwNowPlaying.album }}</p> <p class="text-xs text-surface-600 dark:text-surface-400 mt-2" x-text="fwNowPlaying.relativeTime"></p>
{% endif %}
<p class="text-xs text-surface-600 dark:text-surface-400 mt-2">{{ fwNowPlaying.relativeTime }}</p>
</div> </div>
</div> </div>
</div> </div>
</div> </section>
{% endif %} </template>
{# Last.fm Now Playing #} {# Now Playing — Last.fm #}
{% if lfmNowPlaying %} <template x-if="!loading && lfmNowPlaying && (activeSource === 'all' || activeSource === 'lastfm')">
<div x-show="activeSource === 'all' || activeSource === 'lastfm'" class="mb-4"> <section class="mb-6">
<div class="relative p-4 sm:p-6 rounded-xl sm:rounded-2xl overflow-hidden {% if lfmNowPlaying.status == 'now-playing' %}bg-gradient-to-br from-green-500/10 to-green-600/5 border-2 border-green-500/30{% else %}bg-gradient-to-br from-red-500/10 to-red-600/5 border border-red-500/20{% endif %}"> <div class="relative p-4 sm:p-6 rounded-xl sm:rounded-2xl overflow-hidden"
:class="lfmNowPlaying.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-red-500/10 to-red-600/5 border border-red-500/20'">
<div class="flex flex-col sm:flex-row items-start sm:items-center gap-4 sm:gap-5"> <div class="flex flex-col sm:flex-row items-start sm:items-center gap-4 sm:gap-5">
{% if lfmNowPlaying.coverUrl %} <template x-if="lfmNowPlaying.coverUrl">
<img src="{{ lfmNowPlaying.coverUrl }}" alt="" class="w-20 h-20 sm:w-24 sm:h-24 rounded-lg shadow-lg object-cover flex-shrink-0" loading="lazy" eleventy:ignore> <img :src="lfmNowPlaying.coverUrl" alt="" class="w-20 h-20 sm:w-24 sm:h-24 rounded-lg shadow-lg object-cover flex-shrink-0" loading="lazy">
{% else %} </template>
<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"> <template x-if="!lfmNowPlaying.coverUrl">
<svg class="w-8 h-8 sm:w-10 sm:h-10 text-surface-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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">
<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 class="w-8 h-8 sm:w-10 sm:h-10 text-surface-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
</svg> <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"/>
</div> </svg>
{% endif %} </div>
</template>
<div class="flex-1 min-w-0 w-full sm:w-auto"> <div class="flex-1 min-w-0 w-full sm:w-auto">
<div class="flex items-center gap-2 mb-2"> <div class="flex items-center gap-2 mb-2">
<span class="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium bg-purple-500/20 text-purple-700 dark:text-purple-400 rounded-full">Last.fm</span> <span class="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium bg-red-500/20 text-red-700 dark:text-red-400 rounded-full">Last.fm</span>
{% if lfmNowPlaying.status == 'now-playing' %} <template x-if="lfmNowPlaying.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"> <span class="inline-flex items-center gap-1.5 px-2 py-1 text-xs font-medium bg-red-500/20 text-red-700 dark:text-red-400 rounded-full">
<span class="flex gap-0.5 items-end h-3"> <span class="flex gap-0.5 items-end h-3">
<span class="w-0.5 bg-purple-500 animate-pulse" style="height: 30%; animation-delay: 0s;"></span> <span class="w-0.5 bg-red-500 animate-pulse" style="height: 30%;"></span>
<span class="w-0.5 bg-purple-500 animate-pulse" style="height: 70%; animation-delay: 0.2s;"></span> <span class="w-0.5 bg-red-500 animate-pulse" style="height: 70%; animation-delay: 0.2s;"></span>
<span class="w-0.5 bg-purple-500 animate-pulse" style="height: 50%; animation-delay: 0.4s;"></span> <span class="w-0.5 bg-red-500 animate-pulse" style="height: 50%; animation-delay: 0.4s;"></span>
</span>
Now Playing
</span> </span>
Now Playing </template>
</span> <template x-if="lfmNowPlaying.status !== 'now-playing'">
{% else %} <span class="inline-flex items-center gap-1.5 px-2 py-1 text-xs font-medium bg-red-500/20 text-red-700 dark:text-red-400 rounded-full">Recently Played</span>
<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"> </template>
Recently Played <template x-if="lfmNowPlaying.loved">
</span> <span class="text-red-500" title="Loved">&#9829;</span>
{% endif %} </template>
{% if lfmNowPlaying.loved %}
<span class="text-purple-500" title="Loved">&#9829;</span>
{% endif %}
</div> </div>
<h2 class="text-lg sm:text-xl font-bold text-surface-900 dark:text-surface-100 truncate"> <h2 class="text-lg sm:text-xl font-bold text-surface-900 dark:text-surface-100 truncate">
{% if lfmNowPlaying.trackUrl %} <template x-if="lfmNowPlaying.trackUrl">
<a href="{{ lfmNowPlaying.trackUrl }}" class="hover:text-purple-600 dark:hover:text-purple-400" target="_blank" rel="noopener">{{ lfmNowPlaying.track }}</a> <a :href="lfmNowPlaying.trackUrl" class="hover:text-red-600 dark:hover:text-red-400" target="_blank" rel="noopener" x-text="lfmNowPlaying.track"></a>
{% else %} </template>
{{ lfmNowPlaying.track }} <template x-if="!lfmNowPlaying.trackUrl"><span x-text="lfmNowPlaying.track"></span></template>
{% endif %}
</h2> </h2>
<p class="text-surface-600 dark:text-surface-400">{{ lfmNowPlaying.artist }}</p> <p class="text-surface-600 dark:text-surface-400" x-text="lfmNowPlaying.artist"></p>
{% if lfmNowPlaying.album %} <p x-show="lfmNowPlaying.album" class="text-sm text-surface-600 dark:text-surface-400 mt-1" x-text="lfmNowPlaying.album"></p>
<p class="text-sm text-surface-600 dark:text-surface-400 mt-1">{{ lfmNowPlaying.album }}</p> <p class="text-xs text-surface-600 dark:text-surface-400 mt-2" x-text="lfmNowPlaying.relativeTime"></p>
{% endif %}
<p class="text-xs text-surface-600 dark:text-surface-400 mt-2">{{ lfmNowPlaying.relativeTime }}</p>
</div> </div>
</div> </div>
</div> </div>
</div> </section>
{% endif %} </template>
</section>
{% endif %}
{# Combined Stats Section #} {# Combined Stats Section #}
{% if funkwhaleActivity.stats or lastfmActivity.stats %} <template x-if="!loading && (fw.stats || lfm.stats)">
<section class="mb-12"> <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"> <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"> <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"/> <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> </svg>
Listening Statistics Listening Statistics
</h2> </h2>
<div class="grid gap-4 sm:gap-6 md:grid-cols-2">
{# Stats Cards Grid - Side by Side #} {# Funkwhale Stats Card #}
<div class="grid gap-4 sm:gap-6 md:grid-cols-2"> <template x-if="fw.stats && (activeSource === 'all' || activeSource === 'funkwhale')">
{# Funkwhale Stats #} <div class="bg-surface-50 dark:bg-surface-800 rounded-xl p-6 border border-purple-200 dark:border-purple-800 shadow-sm">
{% if funkwhaleActivity.stats %} <h3 class="text-lg font-semibold text-purple-700 dark:text-purple-400 mb-4 flex items-center gap-2">
<div x-show="activeSource === 'all' || activeSource === 'funkwhale'" class="bg-surface-50 dark:bg-surface-800 rounded-xl p-6 border border-purple-200 dark:border-purple-800 shadow-sm"> <span class="w-3 h-3 rounded-full bg-purple-500"></span>Funkwhale
<h3 class="text-lg font-semibold text-purple-700 dark:text-purple-400 mb-4 flex items-center gap-2"> </h3>
<span class="w-3 h-3 rounded-full bg-purple-500"></span> <div class="grid grid-cols-3 gap-4 text-center">
Funkwhale <div>
</h3> <div class="text-2xl font-bold text-surface-900 dark:text-surface-100" x-text="fwStatsAll.totalPlays || 0"></div>
<div class="grid grid-cols-3 gap-4 text-center"> <div class="text-xs text-surface-600 dark:text-surface-400 uppercase">Plays</div>
<div> </div>
<div class="text-2xl font-bold text-surface-900 dark:text-surface-100">{{ funkwhaleActivity.stats.summary.all.totalPlays | default(0) }}</div> <div>
<div class="text-xs text-surface-600 dark:text-surface-400 uppercase">Plays</div> <div class="text-2xl font-bold text-surface-900 dark:text-surface-100" x-text="fwStatsAll.uniqueArtists || 0"></div>
</div> <div class="text-xs text-surface-600 dark:text-surface-400 uppercase">Artists</div>
<div> </div>
<div class="text-2xl font-bold text-surface-900 dark:text-surface-100">{{ funkwhaleActivity.stats.summary.all.uniqueArtists | default(0) }}</div> <div>
<div class="text-xs text-surface-600 dark:text-surface-400 uppercase">Artists</div> <div class="text-2xl font-bold text-surface-900 dark:text-surface-100" x-text="fwStatsAll.uniqueTracks || 0"></div>
</div> <div class="text-xs text-surface-600 dark:text-surface-400 uppercase">Tracks</div>
<div> </div>
<div class="text-2xl font-bold text-surface-900 dark:text-surface-100">{{ funkwhaleActivity.stats.summary.all.uniqueTracks | default(0) }}</div>
<div class="text-xs text-surface-600 dark:text-surface-400 uppercase">Tracks</div>
</div>
</div>
{# Top Artists #}
{% if funkwhaleActivity.stats.topArtists.all.length %}
<div class="mt-4 pt-4 border-t border-surface-200 dark:border-surface-700">
<h4 class="text-sm font-medium text-surface-700 dark:text-surface-300 mb-2">Top Artists</h4>
<div class="space-y-1">
{% for artist in funkwhaleActivity.stats.topArtists.all | head(5) %}
<div class="flex justify-between text-sm">
<span class="text-surface-600 dark:text-surface-400 truncate">{{ artist.name }}</span>
<span class="text-surface-600 dark:text-surface-400 ml-2">{{ artist.playCount }}</span>
</div> </div>
{% endfor %} <template x-if="fwTopArtists.length">
<div class="mt-4 pt-4 border-t border-surface-200 dark:border-surface-700">
<h4 class="text-sm font-medium text-surface-700 dark:text-surface-300 mb-2">Top Artists</h4>
<div class="space-y-1">
<template x-for="artist in fwTopArtists" :key="artist.name">
<div class="flex justify-between text-sm">
<span class="text-surface-600 dark:text-surface-400 truncate" x-text="artist.name"></span>
<span class="text-surface-600 dark:text-surface-400 ml-2" x-text="artist.playCount"></span>
</div>
</template>
</div>
</div>
</template>
</div> </div>
</div> </template>
{% endif %}
</div>
{% endif %}
{# Last.fm Stats #} {# Last.fm Stats Card #}
{% if lastfmActivity.stats %} <template x-if="lfm.stats && (activeSource === 'all' || activeSource === 'lastfm')">
<div x-show="activeSource === 'all' || activeSource === 'lastfm'" class="bg-surface-50 dark:bg-surface-800 rounded-xl p-6 border border-purple-200 dark:border-purple-800 shadow-sm"> <div class="bg-surface-50 dark:bg-surface-800 rounded-xl p-6 border border-red-200 dark:border-red-800 shadow-sm">
<h3 class="text-lg font-semibold text-purple-700 dark:text-purple-400 mb-4 flex items-center gap-2"> <h3 class="text-lg font-semibold text-red-700 dark:text-red-400 mb-4 flex items-center gap-2">
<span class="w-3 h-3 rounded-full bg-purple-500"></span> <span class="w-3 h-3 rounded-full bg-red-500"></span>Last.fm
Last.fm </h3>
</h3> <div class="grid grid-cols-3 gap-4 text-center">
<div class="grid grid-cols-3 gap-4 text-center"> <div>
<div> <div class="text-2xl font-bold text-surface-900 dark:text-surface-100" x-text="lfmStatsAll.totalPlays || 0"></div>
<div class="text-2xl font-bold text-surface-900 dark:text-surface-100">{{ lastfmActivity.stats.summary.all.totalPlays | default(0) }}</div> <div class="text-xs text-surface-600 dark:text-surface-400 uppercase">Scrobbles</div>
<div class="text-xs text-surface-600 dark:text-surface-400 uppercase">Scrobbles</div> </div>
</div> <div>
<div> <div class="text-2xl font-bold text-surface-900 dark:text-surface-100" x-text="lfmStatsAll.uniqueArtists || 0"></div>
<div class="text-2xl font-bold text-surface-900 dark:text-surface-100">{{ lastfmActivity.stats.summary.all.uniqueArtists | default(0) }}</div> <div class="text-xs text-surface-600 dark:text-surface-400 uppercase">Artists</div>
<div class="text-xs text-surface-600 dark:text-surface-400 uppercase">Artists</div> </div>
</div> <div>
<div> <div class="text-2xl font-bold text-surface-900 dark:text-surface-100" x-text="lfmStatsAll.lovedCount || 0"></div>
<div class="text-2xl font-bold text-surface-900 dark:text-surface-100">{{ lastfmActivity.stats.summary.all.lovedCount | default(0) }}</div> <div class="text-xs text-surface-600 dark:text-surface-400 uppercase">Loved</div>
<div class="text-xs text-surface-600 dark:text-surface-400 uppercase">Loved</div> </div>
</div>
</div>
{# Top Artists from Last.fm #}
{% if lastfmActivity.stats.topArtists.all.length %}
<div class="mt-4 pt-4 border-t border-surface-200 dark:border-surface-700">
<h4 class="text-sm font-medium text-surface-700 dark:text-surface-300 mb-2">Top Artists</h4>
<div class="space-y-1">
{% for artist in lastfmActivity.stats.topArtists.all | head(5) %}
<div class="flex justify-between text-sm">
<span class="text-surface-600 dark:text-surface-400 truncate">{{ artist.name }}</span>
<span class="text-surface-600 dark:text-surface-400 ml-2">{{ artist.playCount }}</span>
</div> </div>
{% endfor %} <template x-if="lfmTopArtists.length">
<div class="mt-4 pt-4 border-t border-surface-200 dark:border-surface-700">
<h4 class="text-sm font-medium text-surface-700 dark:text-surface-300 mb-2">Top Artists</h4>
<div class="space-y-1">
<template x-for="artist in lfmTopArtists" :key="artist.name">
<div class="flex justify-between text-sm">
<span class="text-surface-600 dark:text-surface-400 truncate" x-text="artist.name"></span>
<span class="text-surface-600 dark:text-surface-400 ml-2" x-text="artist.playCount"></span>
</div>
</template>
</div>
</div>
</template>
</div> </div>
</div> </template>
{% endif %}
</div>
{% endif %}
</div>
</section>
{% endif %}
{# Recent Listens - Combined Timeline #} </div>
</section>
</template>
{# Combined Recent Listens Timeline #}
<section class="mb-12"> <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"> <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"> <svg class="w-6 h-6 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -265,195 +238,127 @@ withSidebar: true
</svg> </svg>
Recent Listens Recent Listens
</h2> </h2>
{% set combinedListens = funkwhaleActivity.listenings | mergeListens(lastfmActivity.scrobbles) | head(20) %}
<div class="space-y-3"> <div class="space-y-3">
{% if combinedListens.length %} <template x-if="!loading && combinedListens.length">
{% for item in combinedListens %} <template x-for="item in combinedListens.filter(i => activeSource === 'all' || i._source === activeSource)" :key="item._ts + item.track">
<div <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">
x-show="activeSource === 'all' || activeSource === '{{ item._source }}'" <template x-if="item.coverUrl">
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" <img :src="item.coverUrl" alt="" class="w-12 h-12 rounded object-cover flex-shrink-0" loading="lazy">
> </template>
{% if item.coverUrl %} <template x-if="!item.coverUrl">
<img src="{{ item.coverUrl }}" alt="" class="w-12 h-12 rounded object-cover flex-shrink-0" loading="lazy" eleventy:ignore> <div class="w-12 h-12 rounded bg-surface-200 dark:bg-surface-700 flex items-center justify-center flex-shrink-0">
{% else %} <svg class="w-6 h-6 text-surface-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<div class="w-12 h-12 rounded bg-surface-200 dark:bg-surface-700 flex items-center justify-center flex-shrink-0"> <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 class="w-6 h-6 text-surface-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> </svg>
<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"/> </div>
</svg> </template>
</div> <div class="flex-1 min-w-0">
{% endif %} <h3 class="font-medium text-surface-900 dark:text-surface-100 truncate">
<template x-if="item.trackUrl">
<div class="flex-1 min-w-0"> <a :href="item.trackUrl" class="hover:text-purple-600 dark:hover:text-purple-400" target="_blank" rel="noopener" x-text="item.track"></a>
<h3 class="font-medium text-surface-900 dark:text-surface-100 truncate"> </template>
{% if item.trackUrl %} <template x-if="!item.trackUrl"><span x-text="item.track"></span></template>
<a href="{{ item.trackUrl }}" class="hover:text-purple-600 dark:hover:text-purple-400" target="_blank" rel="noopener">{{ item.track }}</a> <template x-if="item.favorite || item.loved">
{% else %} <span class="text-purple-500 ml-1" :title="item._source === 'funkwhale' ? 'Favorite' : 'Loved'">&#9829;</span>
{{ item.track }} </template>
{% endif %} </h3>
{% if item.favorite or item.loved %} <p class="text-sm text-surface-600 dark:text-surface-400 truncate" x-text="item.artist"></p>
<span class="text-purple-500 ml-1" title="{% if item._source == 'funkwhale' %}Favorite{% else %}Loved{% endif %}">&#9829;</span> </div>
{% endif %} <div class="text-right flex-shrink-0">
</h3> <span class="inline-block px-2 py-0.5 text-xs font-medium rounded-full mb-1"
<p class="text-sm text-surface-600 dark:text-surface-400 truncate">{{ item.artist }}</p> :class="item._source === 'funkwhale' ? 'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-400' : 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400'"
</div> x-text="item._source === 'funkwhale' ? 'Funkwhale' : 'Last.fm'"></span>
<span class="text-xs text-surface-600 dark:text-surface-400 block" x-text="item.relativeTime"></span>
<div class="text-right flex-shrink-0"> <template x-if="item.trackUrl">
{% if item._source == 'funkwhale' %} <button
<span class="inline-block px-2 py-0.5 text-xs font-medium bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-400 rounded-full mb-1">Funkwhale</span> class="share-post-btn mt-1"
{% else %} :data-share-url="item.trackUrl"
<span class="inline-block px-2 py-0.5 text-xs font-medium bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400 rounded-full mb-1">Last.fm</span> :data-share-title="item.track + ' — ' + item.artist"
{% endif %} title="Create post"
<span class="text-xs text-surface-600 dark:text-surface-400 block">{{ item.relativeTime }}</span> aria-label="Create post"
<button ><span class="share-post-icon">✏️</span></button>
class="share-post-btn mt-1" </template>
data-share-url="{{ item.trackUrl }}" </div>
data-share-title="{{ item.track }} — {{ item.artist }}" </div>
title="Create post" </template>
aria-label="Create post" </template>
> <p x-show="!loading && !combinedListens.length" class="text-surface-600 dark:text-surface-400">No recent listening history available.</p>
<span class="share-post-icon">✏️</span>
</button>
<button
class="save-later-btn mt-1"
data-save-url="{{ item.trackUrl }}"
data-save-title="{{ item.track }} — {{ item.artist }}"
data-save-source="listening"
title="Save for later"
aria-label="Save for later"
>
<span class="save-later-icon">📑</span>
</button>
</div>
</div>
{% endfor %}
{% else %}
<p class="text-surface-600 dark:text-surface-400">No recent listening history available.</p>
{% endif %}
</div> </div>
</section> </section>
{# Loved Tracks from Last.fm #} {# Loved Tracks Last.fm #}
{% if lastfmActivity.loved.length %} <template x-if="!loading && lfm.loved.length && (activeSource === 'all' || activeSource === 'lastfm')">
<section class="mb-12" x-show="activeSource === 'all' || activeSource === 'lastfm'"> <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"> <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="currentColor" viewBox="0 0 24 24"> <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"/> <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> </svg>
Loved Tracks Loved Tracks <span class="text-sm font-normal text-surface-600 dark:text-surface-400">(Last.fm)</span>
<span class="text-sm font-normal text-surface-600 dark:text-surface-400">(Last.fm)</span> </h2>
</h2> <div class="grid gap-3 sm:gap-4 md:grid-cols-2">
<template x-for="track in lfm.loved.slice(0,10)" :key="track.track + track.artist">
<div class="grid gap-3 sm:gap-4 md:grid-cols-2"> <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">
{% for track in lastfmActivity.loved | head(10) %} <template x-if="track.coverUrl">
<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"> <img :src="track.coverUrl" alt="" class="w-14 h-14 rounded object-cover flex-shrink-0" loading="lazy">
{% if track.coverUrl %} </template>
<img src="{{ track.coverUrl }}" alt="" class="w-14 h-14 rounded object-cover flex-shrink-0" loading="lazy" eleventy:ignore> <template x-if="!track.coverUrl">
{% else %} <div class="w-14 h-14 rounded bg-surface-200 dark:bg-surface-700 flex items-center justify-center flex-shrink-0">
<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-red-400" fill="currentColor" viewBox="0 0 24 24">
<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"/>
<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>
</svg> </div>
</div> </template>
{% endif %} <div class="flex-1 min-w-0">
<h3 class="font-medium text-surface-900 dark:text-surface-100 truncate">
<div class="flex-1 min-w-0"> <template x-if="track.trackUrl">
<h3 class="font-medium text-surface-900 dark:text-surface-100 truncate"> <a :href="track.trackUrl" class="hover:text-red-600 dark:hover:text-red-400" target="_blank" rel="noopener" x-text="track.track"></a>
{% if track.trackUrl %} </template>
<a href="{{ track.trackUrl }}" class="hover:text-purple-600 dark:hover:text-purple-400" target="_blank" rel="noopener">{{ track.track }}</a> <template x-if="!track.trackUrl"><span x-text="track.track"></span></template>
{% else %} </h3>
{{ track.track }} <p class="text-sm text-surface-600 dark:text-surface-400 truncate" x-text="track.artist"></p>
{% endif %} </div>
</h3> <span class="text-red-500 flex-shrink-0">&#9829;</span>
<p class="text-sm text-surface-600 dark:text-surface-400 truncate">{{ track.artist }}</p> </div>
</div> </template>
<span class="text-purple-500 flex-shrink-0">&#9829;</span>
<button
class="share-post-btn flex-shrink-0"
data-share-url="{{ track.trackUrl }}"
data-share-title="{{ track.track }} — {{ track.artist }}"
title="Create post"
aria-label="Create post"
>
<span class="share-post-icon">✏️</span>
</button>
<button
class="save-later-btn flex-shrink-0"
data-save-url="{{ track.trackUrl }}"
data-save-title="{{ track.track }} — {{ track.artist }}"
data-save-source="listening"
title="Save for later"
aria-label="Save for later"
>
<span class="save-later-icon">📑</span>
</button>
</div> </div>
{% endfor %} </section>
</div> </template>
</section>
{% endif %}
{# Funkwhale Favorites #} {# Favorites — Funkwhale #}
{% if funkwhaleActivity.favorites.length %} <template x-if="!loading && fw.favorites.length && (activeSource === 'all' || activeSource === 'funkwhale')">
<section class="mb-12" x-show="activeSource === 'all' || activeSource === 'funkwhale'"> <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"> <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="currentColor" viewBox="0 0 24 24"> <svg class="w-6 h-6 text-purple-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"/> <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> </svg>
Favorite Tracks Favorite Tracks <span class="text-sm font-normal text-surface-600 dark:text-surface-400">(Funkwhale)</span>
<span class="text-sm font-normal text-surface-600 dark:text-surface-400">(Funkwhale)</span> </h2>
</h2> <div class="grid gap-3 sm:gap-4 md:grid-cols-2">
<template x-for="favorite in fw.favorites.slice(0,10)" :key="favorite.track + favorite.artist">
<div class="grid gap-3 sm:gap-4 md:grid-cols-2"> <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">
{% for favorite in funkwhaleActivity.favorites | head(10) %} <template x-if="favorite.coverUrl">
<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"> <img :src="favorite.coverUrl" alt="" class="w-14 h-14 rounded object-cover flex-shrink-0" loading="lazy">
{% if favorite.coverUrl %} </template>
<img src="{{ favorite.coverUrl }}" alt="" class="w-14 h-14 rounded object-cover flex-shrink-0" loading="lazy" eleventy:ignore> <template x-if="!favorite.coverUrl">
{% else %} <div class="w-14 h-14 rounded bg-surface-200 dark:bg-surface-700 flex items-center justify-center flex-shrink-0">
<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">
<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"/>
<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>
</svg> </div>
</div> </template>
{% endif %} <div class="flex-1 min-w-0">
<h3 class="font-medium text-surface-900 dark:text-surface-100 truncate">
<div class="flex-1 min-w-0"> <template x-if="favorite.trackUrl">
<h3 class="font-medium text-surface-900 dark:text-surface-100 truncate"> <a :href="favorite.trackUrl" class="hover:text-purple-600 dark:hover:text-purple-400" target="_blank" rel="noopener" x-text="favorite.track"></a>
{% if favorite.trackUrl %} </template>
<a href="{{ favorite.trackUrl }}" class="hover:text-purple-600 dark:hover:text-purple-400" target="_blank" rel="noopener">{{ favorite.track }}</a> <template x-if="!favorite.trackUrl"><span x-text="favorite.track"></span></template>
{% else %} </h3>
{{ favorite.track }} <p class="text-sm text-surface-600 dark:text-surface-400 truncate" x-text="favorite.artist"></p>
{% endif %} <p x-show="favorite.album" class="text-xs text-surface-600 dark:text-surface-400 truncate" x-text="favorite.album"></p>
</h3> </div>
<p class="text-sm text-surface-600 dark:text-surface-400 truncate">{{ favorite.artist }}</p> </div>
{% if favorite.album %} </template>
<p class="text-xs text-surface-600 dark:text-surface-400 truncate">{{ favorite.album }}</p>
{% endif %}
</div>
<button
class="share-post-btn flex-shrink-0"
data-share-url="{{ favorite.trackUrl }}"
data-share-title="{{ favorite.track }} — {{ favorite.artist }}"
title="Create post"
aria-label="Create post"
>
<span class="share-post-icon">✏️</span>
</button>
<button
class="save-later-btn flex-shrink-0"
data-save-url="{{ favorite.trackUrl }}"
data-save-title="{{ favorite.track }} — {{ favorite.artist }}"
data-save-source="listening"
title="Save for later"
aria-label="Save for later"
>
<span class="save-later-icon">📑</span>
</button>
</div> </div>
{% endfor %} </section>
</div> </template>
</section>
{% endif %}
</div> </div>