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
+8 -148
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";
const INDIEKIT_URL =
process.env.INDIEKIT_URL || process.env.SITE_URL || "https://example.com";
const FUNKWHALE_INSTANCE = process.env.FUNKWHALE_INSTANCE || "";
const DEFAULT_FETCH_CACHE_DURATION = "5m";
const LISTENING_FETCH_CACHE_DURATION =
(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 { 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", source: "indiekit",
};
} catch (error) {
console.error("[funkwhaleActivity] Error:", error.message);
return {
nowPlaying: null, nowPlaying: null,
listenings: [], listenings: [],
favorites: [], favorites: [],
stats: null, stats: null,
instanceUrl: FUNKWHALE_INSTANCE, instanceUrl: process.env.FUNKWHALE_INSTANCE || "",
source: "error",
}; };
}
} }
+11 -88
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 () {
const INDIEKIT_URL =
process.env.INDIEKIT_URL || process.env.SITE_URL || "https://example.com";
const LASTFM_USERNAME = process.env.LASTFM_USERNAME || "";
const DEFAULT_FETCH_CACHE_DURATION = "5m";
const LISTENING_FETCH_CACHE_DURATION =
(process.env.LISTENING_FETCH_CACHE_DURATION || "").trim() || DEFAULT_FETCH_CACHE_DURATION;
const LASTFM_FETCH_CACHE_DURATION =
(process.env.LASTFM_FETCH_CACHE_DURATION || "").trim() || LISTENING_FETCH_CACHE_DURATION;
/**
* 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 { 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", source: "indiekit",
};
} catch (error) {
console.error("[lastfmActivity] Error:", error.message);
return {
nowPlaying: null, nowPlaying: null,
scrobbles: [], scrobbles: [],
loved: [], loved: [],
stats: null, stats: null,
username: LASTFM_USERNAME, username: process.env.LASTFM_USERNAME || "",
profileUrl: null, profileUrl: process.env.LASTFM_USERNAME
source: "error", ? `https://www.last.fm/user/${process.env.LASTFM_USERNAME}`
: null,
}; };
}
} }
+49 -69
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,14 +10,18 @@
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>
<div class="h-4 bg-surface-200 dark:bg-surface-700 rounded w-2/3"></div>
</div>
{% if fwNow or lfmNow %} {# Error state #}
{% set np = fwNow or lfmNow %} <p x-show="!loading && error" class="text-xs text-surface-500 dark:text-surface-400">Unavailable</p>
{% set npSource = "Funkwhale" if fwNow else "Last.fm" %}
{% set npColor = "purple" if fwNow else "red" %} {# Now Playing #}
<template x-if="!loading && nowPlaying">
<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="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"> <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="flex gap-0.5 items-end h-2.5" aria-hidden="true">
@@ -26,84 +30,60 @@
<span class="w-0.5 bg-green-500 animate-pulse" style="height: 50%; animation-delay: 0.4s;"></span> <span class="w-0.5 bg-green-500 animate-pulse" style="height: 50%; animation-delay: 0.4s;"></span>
</span> </span>
Now Playing Now Playing
<span class="text-{{ npColor }}-600 dark:text-{{ npColor }}-400 ml-1">({{ npSource }})</span> <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>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
{% if np.coverUrl %} <template x-if="nowPlaying?.coverUrl">
<img src="{{ np.coverUrl }}" alt="" class="w-10 h-10 rounded object-cover flex-shrink-0 shadow-lg" loading="lazy" eleventy:ignore> <img :src="nowPlaying.coverUrl" alt="" class="w-10 h-10 rounded object-cover flex-shrink-0 shadow-lg" loading="lazy">
{% endif %} </template>
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<p class="font-medium text-sm text-surface-900 dark:text-surface-100 truncate"> <p class="font-medium text-sm text-surface-900 dark:text-surface-100 truncate">
{% if np.trackUrl %} <template x-if="nowPlaying?.trackUrl">
<a href="{{ np.trackUrl }}" class="hover:text-purple-600 dark:hover:text-purple-400" target="_blank" rel="noopener">{{ np.track }}</a> <a :href="nowPlaying.trackUrl" class="hover:text-purple-600 dark:hover:text-purple-400" target="_blank" rel="noopener" x-text="nowPlaying.track"></a>
{% else %} </template>
{{ np.track }} <template x-if="!nowPlaying?.trackUrl">
{% endif %} <span x-text="nowPlaying?.track"></span>
</template>
</p> </p>
<p class="text-xs text-surface-600 dark:text-surface-400 truncate">{{ np.artist }}</p> <p class="text-xs text-surface-600 dark:text-surface-400 truncate" x-text="nowPlaying?.artist"></p>
</div> </div>
</div> </div>
</div> </div>
{% endif %} </template>
{# Recent tracks — 2 from each source #} {# Recent tracks #}
<ul class="space-y-2"> <ul x-show="!loading && recentTracks.length" class="space-y-2">
{% if funkwhaleActivity and funkwhaleActivity.listenings.length %} <template x-for="item in recentTracks" :key="item._ts + item.track">
{% for listening in funkwhaleActivity.listenings | head(2) %}
<li class="flex items-center gap-2"> <li class="flex items-center gap-2">
{% if listening.coverUrl %} <template x-if="item.coverUrl">
<img src="{{ listening.coverUrl }}" alt="" class="w-8 h-8 rounded object-cover flex-shrink-0 shadow-lg" loading="lazy" eleventy:ignore> <img :src="item.coverUrl" alt="" class="w-8 h-8 rounded object-cover flex-shrink-0 shadow-lg" loading="lazy">
{% else %} </template>
<div class="w-8 h-8 rounded bg-purple-100 dark:bg-purple-900/30 flex items-center justify-center flex-shrink-0"> <template x-if="!item.coverUrl">
<svg class="w-4 h-4 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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"/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19V6l12-3v13"/>
</svg> </svg>
</div> </div>
{% endif %} </template>
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<p class="text-sm text-surface-900 dark:text-surface-100 truncate"> <p class="text-sm text-surface-900 dark:text-surface-100 truncate">
{% if listening.trackUrl %} <template x-if="item.trackUrl">
<a href="{{ listening.trackUrl }}" class="hover:text-purple-600 dark:hover:text-purple-400" target="_blank" rel="noopener">{{ listening.track }}</a> <a :href="item.trackUrl" class="hover:text-purple-600 dark:hover:text-purple-400" target="_blank" rel="noopener" x-text="item.track"></a>
{% else %} </template>
{{ listening.track }} <template x-if="!item.trackUrl">
{% endif %} <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>
<p class="text-xs text-surface-600 dark:text-surface-400 truncate">{{ listening.artist }} <p class="text-xs text-surface-600 dark:text-surface-400 truncate">
<span class="text-purple-500 ml-1">Funkwhale</span> <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> </p>
</div> </div>
</li> </li>
{% endfor %} </template>
{% endif %}
{% if lastfmActivity and lastfmActivity.scrobbles.length %}
{% for scrobble in lastfmActivity.scrobbles | head(2) %}
<li class="flex items-center gap-2">
{% if scrobble.coverUrl %}
<img src="{{ scrobble.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-red-100 dark:bg-red-900/30 flex items-center justify-center flex-shrink-0">
<svg class="w-4 h-4 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>
{% endif %}
<div class="min-w-0 flex-1">
<p class="text-sm text-surface-900 dark:text-surface-100 truncate">
{% if scrobble.trackUrl %}
<a href="{{ scrobble.trackUrl }}" class="hover:text-red-600 dark:hover:text-red-400" target="_blank" rel="noopener">{{ scrobble.track }}</a>
{% else %}
{{ scrobble.track }}
{% endif %}
{% if scrobble.loved %}<span class="text-red-500 ml-0.5">&#9829;</span>{% endif %}
</p>
<p class="text-xs text-surface-600 dark:text-surface-400 truncate">{{ scrobble.artist }}
<span class="text-red-500 ml-1">Last.fm</span>
</p>
</div>
</li>
{% endfor %}
{% endif %}
</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>
+164 -160
View File
@@ -4,76 +4,82 @@ 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">
<div class="h-32 bg-surface-200 dark:bg-surface-700 rounded-xl"></div>
<div class="h-48 bg-surface-200 dark:bg-surface-700 rounded-xl"></div>
<div class="space-y-3">
<div class="h-16 bg-surface-200 dark:bg-surface-700 rounded-lg"></div>
<div class="h-16 bg-surface-200 dark:bg-surface-700 rounded-lg"></div>
<div class="h-16 bg-surface-200 dark:bg-surface-700 rounded-lg"></div>
</div>
</div>
{# Error #}
<p x-show="!loading && error" class="text-surface-600 dark:text-surface-400">Could not load listening data.</p>
{# Now Playing #}
<template x-if="!loading && nowPlaying?.status">
<section class="mb-12"> <section class="mb-12">
<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="relative p-4 sm:p-6 rounded-xl sm:rounded-2xl overflow-hidden"
:class="nowPlaying.status === 'now-playing'
? 'bg-gradient-to-br from-green-500/10 to-green-600/5 border-2 border-green-500/30'
: 'bg-gradient-to-br from-purple-500/10 to-purple-600/5 border border-purple-500/20'">
<div class="flex flex-col sm:flex-row items-start sm:items-center gap-4 sm:gap-5"> <div class="flex flex-col sm:flex-row items-start sm:items-center gap-4 sm:gap-5">
{% if funkwhaleActivity.nowPlaying.coverUrl %} <template x-if="nowPlaying.coverUrl">
<img <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">
src="{{ funkwhaleActivity.nowPlaying.coverUrl }}" </template>
alt="" <template x-if="!nowPlaying.coverUrl">
class="w-20 h-20 sm:w-24 sm:h-24 rounded-lg shadow-lg object-cover flex-shrink-0"
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"> <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"> <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"/> <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> </svg>
</div> </div>
{% endif %} </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">
{% if funkwhaleActivity.nowPlaying.status == 'now-playing' %} <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="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="flex gap-0.5 items-end h-3">
<span class="w-0.5 bg-green-500 animate-pulse" style="height: 30%; animation-delay: 0s;"></span> <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: 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 class="w-0.5 bg-green-500 animate-pulse" style="height: 50%; animation-delay: 0.4s;"></span>
</span> </span>
Now Playing Now Playing
</span> </span>
{% else %} </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"> <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> <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 Recently Played
</span> </span>
{% endif %} </template>
</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 funkwhaleActivity.nowPlaying.trackUrl %} <template x-if="nowPlaying.trackUrl">
<a href="{{ funkwhaleActivity.nowPlaying.trackUrl }}" class="hover:text-purple-600 dark:hover:text-purple-400" target="_blank" rel="noopener"> <a :href="nowPlaying.trackUrl" class="hover:text-purple-600 dark:hover:text-purple-400" target="_blank" rel="noopener" x-text="nowPlaying.track"></a>
{{ funkwhaleActivity.nowPlaying.track }} </template>
</a> <template x-if="!nowPlaying.trackUrl">
{% else %} <span x-text="nowPlaying.track"></span>
{{ funkwhaleActivity.nowPlaying.track }} </template>
{% endif %}
</h2> </h2>
<p class="text-surface-600 dark:text-surface-400">{{ funkwhaleActivity.nowPlaying.artist }}</p> <p class="text-surface-600 dark:text-surface-400" x-text="nowPlaying.artist"></p>
{% if funkwhaleActivity.nowPlaying.album %} <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-sm text-surface-600 dark:text-surface-400 mt-1">{{ funkwhaleActivity.nowPlaying.album }}</p> <p class="text-xs text-surface-600 dark:text-surface-400 mt-2" x-text="nowPlaying.relativeTime"></p>
{% endif %}
<p class="text-xs text-surface-600 dark:text-surface-400 mt-2">{{ funkwhaleActivity.nowPlaying.relativeTime }}</p>
</div> </div>
</div> </div>
</div> </div>
</section> </section>
{% endif %} </template>
{# 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">
@@ -83,100 +89,108 @@ withSidebar: true
</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">
<template x-for="tab in ['all','month','week','trends']" :key="tab">
<button <button
@click="activeTab = 'all'" @click="activeTab = tab"
: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'" :class="activeTab === tab ? 'border-b-2 border-purple-500 text-purple-600 dark:text-purple-400' : 'text-surface-600 dark:text-surface-400 hover:text-surface-700 dark:hover:text-surface-300'"
:aria-selected="(activeTab === 'all').toString()" :aria-selected="(activeTab === tab).toString()"
role="tab" id="fw-tab-all" aria-controls="fw-panel-all" role="tab"
class="px-4 py-2 text-sm font-medium transition-colors -mb-px whitespace-nowrap" class="px-4 py-2 text-sm font-medium transition-colors -mb-px whitespace-nowrap capitalize"
> x-text="tab === 'all' ? 'All Time' : tab === 'month' ? 'This Month' : tab === 'week' ? 'This Week' : 'Trends'"
All Time ></button>
</button> </template>
<button
@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> </div>
{# All Time Tab #} {# Stats panel — All / Month / Week share same markup via computed props #}
<div x-show="activeTab === 'all'" x-cloak role="tabpanel" id="fw-panel-all" aria-labelledby="fw-tab-all"> <div x-show="activeTab !== 'trends'" x-cloak>
{% set summary = funkwhaleActivity.stats.summary.all %} {# Summary cards #}
{% set topArtists = funkwhaleActivity.stats.topArtists.all %} <div class="grid grid-cols-2 sm:grid-cols-4 gap-3 sm:gap-4 mb-6 sm:mb-8">
{% set topAlbums = funkwhaleActivity.stats.topAlbums.all %} <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">
{% include "components/funkwhale-stats-content.njk" %} <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> </div>
{# This Month Tab #} {# Top Artists #}
<div x-show="activeTab === 'month'" x-cloak role="tabpanel" id="fw-panel-month" aria-labelledby="fw-tab-month"> <template x-if="topArtists.length">
{% set summary = funkwhaleActivity.stats.summary.month %} <div class="mb-8">
{% set topArtists = funkwhaleActivity.stats.topArtists.month %} <h3 class="text-lg font-semibold text-surface-900 dark:text-surface-100 mb-4">Top Artists</h3>
{% set topAlbums = funkwhaleActivity.stats.topAlbums.month %} <div class="space-y-2">
{% include "components/funkwhale-stats-content.njk" %} <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> </div>
{# This Week Tab #} {# Trends panel #}
<div x-show="activeTab === 'week'" x-cloak role="tabpanel" id="fw-panel-week" aria-labelledby="fw-tab-week"> <div x-show="activeTab === 'trends'" x-cloak>
{% set summary = funkwhaleActivity.stats.summary.week %} <template x-if="trendsData.length">
{% 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"> <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> <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"> <div class="flex items-end gap-1 h-32">
{% set maxCount = 1 %} <template x-for="day in trendsData" :key="day.date">
{% for day in funkwhaleActivity.stats.trends %}
{% if day.count > maxCount %}
{% set maxCount = day.count %}
{% endif %}
{% endfor %}
{% for day in funkwhaleActivity.stats.trends %}
<div <div
class="flex-1 bg-purple-500 hover:bg-purple-600 rounded-t transition-colors cursor-pointer" 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;" :style="'height: ' + (trendsMax > 0 ? Math.round(day.count / trendsMax * 100) : 0) + '%; min-height: 2px;'"
title="{{ day.date }}: {{ day.count }} plays" :title="day.date + ': ' + day.count + ' plays'"
></div> ></div>
{% endfor %} </template>
</div> </div>
<div class="flex justify-between text-xs text-surface-600 dark:text-surface-400 mt-2"> <div class="flex justify-between text-xs text-surface-600 dark:text-surface-400 mt-2">
<span>{{ funkwhaleActivity.stats.trends[0].date }}</span> <span x-text="trendsData[0]?.date"></span>
<span>{{ funkwhaleActivity.stats.trends[funkwhaleActivity.stats.trends.length - 1].date }}</span> <span x-text="trendsData[trendsData.length - 1]?.date"></span>
</div> </div>
</div> </div>
{% else %} </template>
<p class="text-surface-600 dark:text-surface-400">No trend data available yet.</p> <p x-show="!trendsData.length" class="text-surface-600 dark:text-surface-400">No trend data available yet.</p>
{% endif %}
</div> </div>
</section> </section>
{% endif %} </template>
{# Recent Listenings #} {# Recent Listenings #}
<section class="mb-12"> <section class="mb-12">
@@ -186,50 +200,44 @@ 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">
<template x-for="listening in listenings.slice(0,15)" :key="listening._ts + listening.track">
<div class="flex items-center gap-4 p-3 bg-surface-50 dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700 hover:border-purple-400 dark:hover:border-purple-600 transition-colors shadow-sm"> <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">
{% if listening.coverUrl %} <template x-if="listening.coverUrl">
<img src="{{ listening.coverUrl }}" alt="" class="w-12 h-12 rounded object-cover flex-shrink-0" loading="lazy" eleventy:ignore> <img :src="listening.coverUrl" alt="" class="w-12 h-12 rounded object-cover flex-shrink-0" loading="lazy">
{% else %} </template>
<template x-if="!listening.coverUrl">
<div class="w-12 h-12 rounded bg-surface-200 dark:bg-surface-700 flex items-center justify-center flex-shrink-0"> <div class="w-12 h-12 rounded bg-surface-200 dark:bg-surface-700 flex items-center justify-center flex-shrink-0">
<svg class="w-6 h-6 text-surface-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-6 h-6 text-surface-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2z"/> <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> </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">
{% if listening.trackUrl %} <template x-if="listening.trackUrl">
<a href="{{ listening.trackUrl }}" class="hover:text-purple-600 dark:hover:text-purple-400" target="_blank" rel="noopener"> <a :href="listening.trackUrl" class="hover:text-purple-600 dark:hover:text-purple-400" target="_blank" rel="noopener" x-text="listening.track"></a>
{{ listening.track }} </template>
</a> <template x-if="!listening.trackUrl">
{% else %} <span x-text="listening.track"></span>
{{ listening.track }} </template>
{% endif %}
</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">{{ listening.relativeTime }}</span> <span class="text-xs text-surface-600 dark:text-surface-400" x-text="listening.relativeTime"></span>
{% if listening.duration %} <span x-show="listening.duration" class="text-xs text-surface-600 dark:text-surface-400 block" x-text="listening.duration"></span>
<span class="text-xs text-surface-600 dark:text-surface-400 block">{{ listening.duration }}</span>
{% endif %}
</div> </div>
</div> </div>
{% endfor %} </template>
</template>
<p x-show="!loading && !listenings.length" class="text-surface-600 dark:text-surface-400">No recent listening history available.</p>
</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">
@@ -237,38 +245,34 @@ withSidebar: true
</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">
{% for favorite in funkwhaleActivity.favorites | head(10) %} <template x-for="favorite in favorites.slice(0,10)" :key="favorite.track + favorite.artist">
<div class="flex items-center gap-3 p-3 bg-surface-50 dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700 shadow-sm"> <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">
{% if favorite.coverUrl %} <template x-if="favorite.coverUrl">
<img src="{{ favorite.coverUrl }}" alt="" class="w-14 h-14 rounded object-cover flex-shrink-0" loading="lazy" eleventy:ignore> <img :src="favorite.coverUrl" alt="" class="w-14 h-14 rounded object-cover flex-shrink-0" loading="lazy">
{% else %} </template>
<template x-if="!favorite.coverUrl">
<div class="w-14 h-14 rounded bg-surface-200 dark:bg-surface-700 flex items-center justify-center flex-shrink-0"> <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">
{% if favorite.trackUrl %} <template x-if="favorite.trackUrl">
<a href="{{ favorite.trackUrl }}" class="hover:text-purple-600 dark:hover:text-purple-400" target="_blank" rel="noopener"> <a :href="favorite.trackUrl" class="hover:text-purple-600 dark:hover:text-purple-400" target="_blank" rel="noopener" x-text="favorite.track"></a>
{{ favorite.track }} </template>
</a> <template x-if="!favorite.trackUrl">
{% else %} <span x-text="favorite.track"></span>
{{ favorite.track }} </template>
{% endif %}
</h3> </h3>
<p class="text-sm text-surface-600 dark:text-surface-400 truncate">{{ favorite.artist }}</p> <p class="text-sm text-surface-600 dark:text-surface-400 truncate" x-text="favorite.artist"></p>
{% if favorite.album %} <p x-show="favorite.album" class="text-xs text-surface-600 dark:text-surface-400 truncate" x-text="favorite.album"></p>
<p class="text-xs text-surface-600 dark:text-surface-400 truncate">{{ favorite.album }}</p>
{% endif %}
</div> </div>
</div> </div>
{% endfor %} </template>
</div> </div>
</section> </section>
{% endif %} </template>
</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); },
}));
});
+193 -288
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,148 +16,135 @@ 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>
<template x-if="!fwNowPlaying.coverUrl">
<div class="w-20 h-20 sm:w-24 sm:h-24 rounded-lg bg-surface-200 dark:bg-surface-700 flex items-center justify-center flex-shrink-0"> <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"> <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"/> <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> </svg>
</div> </div>
{% endif %} </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> </span>
Now Playing Now Playing
</span> </span>
{% else %} </template>
<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 x-if="fwNowPlaying.status !== 'now-playing'">
Recently Played <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> </template>
{% 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>
<template x-if="!lfmNowPlaying.coverUrl">
<div class="w-20 h-20 sm:w-24 sm:h-24 rounded-lg bg-surface-200 dark:bg-surface-700 flex items-center justify-center flex-shrink-0"> <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"> <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"/> <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> </svg>
</div> </div>
{% endif %} </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> </span>
Now Playing Now Playing
</span> </span>
{% else %} </template>
<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 x-if="lfmNowPlaying.status !== 'now-playing'">
Recently Played <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> </template>
{% endif %} <template x-if="lfmNowPlaying.loved">
{% if lfmNowPlaying.loved %} <span class="text-red-500" title="Loved">&#9829;</span>
<span class="text-purple-500" title="Loved">&#9829;</span> </template>
{% 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>
{% endif %}
</section> </section>
{% endif %} </template>
{# 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">
@@ -175,89 +152,85 @@ withSidebar: true
</svg> </svg>
Listening Statistics Listening Statistics
</h2> </h2>
{# Stats Cards Grid - Side by Side #}
<div class="grid gap-4 sm:gap-6 md:grid-cols-2"> <div class="grid gap-4 sm:gap-6 md:grid-cols-2">
{# Funkwhale Stats #}
{% if funkwhaleActivity.stats %} {# Funkwhale Stats Card #}
<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"> <template x-if="fw.stats && (activeSource === 'all' || activeSource === 'funkwhale')">
<div class="bg-surface-50 dark:bg-surface-800 rounded-xl p-6 border border-purple-200 dark:border-purple-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-purple-700 dark:text-purple-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-purple-500"></span>Funkwhale
Funkwhale
</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">{{ funkwhaleActivity.stats.summary.all.totalPlays | default(0) }}</div> <div class="text-2xl font-bold text-surface-900 dark:text-surface-100" x-text="fwStatsAll.totalPlays || 0"></div>
<div class="text-xs text-surface-600 dark:text-surface-400 uppercase">Plays</div> <div class="text-xs text-surface-600 dark:text-surface-400 uppercase">Plays</div>
</div> </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 class="text-2xl font-bold text-surface-900 dark:text-surface-100" x-text="fwStatsAll.uniqueArtists || 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">{{ funkwhaleActivity.stats.summary.all.uniqueTracks | default(0) }}</div> <div class="text-2xl font-bold text-surface-900 dark:text-surface-100" x-text="fwStatsAll.uniqueTracks || 0"></div>
<div class="text-xs text-surface-600 dark:text-surface-400 uppercase">Tracks</div> <div class="text-xs text-surface-600 dark:text-surface-400 uppercase">Tracks</div>
</div> </div>
</div> </div>
{# Top Artists #} <template x-if="fwTopArtists.length">
{% if funkwhaleActivity.stats.topArtists.all.length %}
<div class="mt-4 pt-4 border-t border-surface-200 dark:border-surface-700"> <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> <h4 class="text-sm font-medium text-surface-700 dark:text-surface-300 mb-2">Top Artists</h4>
<div class="space-y-1"> <div class="space-y-1">
{% for artist in funkwhaleActivity.stats.topArtists.all | head(5) %} <template x-for="artist in fwTopArtists" :key="artist.name">
<div class="flex justify-between text-sm"> <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 truncate" x-text="artist.name"></span>
<span class="text-surface-600 dark:text-surface-400 ml-2">{{ artist.playCount }}</span> <span class="text-surface-600 dark:text-surface-400 ml-2" x-text="artist.playCount"></span>
</div> </div>
{% endfor %} </template>
</div> </div>
</div> </div>
{% endif %} </template>
</div> </div>
{% endif %} </template>
{# 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">{{ lastfmActivity.stats.summary.all.totalPlays | default(0) }}</div> <div class="text-2xl font-bold text-surface-900 dark:text-surface-100" x-text="lfmStatsAll.totalPlays || 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">{{ lastfmActivity.stats.summary.all.uniqueArtists | default(0) }}</div> <div class="text-2xl font-bold text-surface-900 dark:text-surface-100" x-text="lfmStatsAll.uniqueArtists || 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">{{ lastfmActivity.stats.summary.all.lovedCount | default(0) }}</div> <div class="text-2xl font-bold text-surface-900 dark:text-surface-100" x-text="lfmStatsAll.lovedCount || 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> </div>
{# Top Artists from Last.fm #} <template x-if="lfmTopArtists.length">
{% if lastfmActivity.stats.topArtists.all.length %}
<div class="mt-4 pt-4 border-t border-surface-200 dark:border-surface-700"> <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> <h4 class="text-sm font-medium text-surface-700 dark:text-surface-300 mb-2">Top Artists</h4>
<div class="space-y-1"> <div class="space-y-1">
{% for artist in lastfmActivity.stats.topArtists.all | head(5) %} <template x-for="artist in lfmTopArtists" :key="artist.name">
<div class="flex justify-between text-sm"> <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 truncate" x-text="artist.name"></span>
<span class="text-surface-600 dark:text-surface-400 ml-2">{{ artist.playCount }}</span> <span class="text-surface-600 dark:text-surface-400 ml-2" x-text="artist.playCount"></span>
</div> </div>
{% endfor %} </template>
</div> </div>
</div> </div>
{% endif %} </template>
</div> </div>
{% endif %} </template>
</div> </div>
</section> </section>
{% endif %} </template>
{# Recent Listens - Combined Timeline #} {# 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>
{% else %}
<div class="w-12 h-12 rounded bg-surface-200 dark:bg-surface-700 flex items-center justify-center flex-shrink-0"> <div class="w-12 h-12 rounded bg-surface-200 dark:bg-surface-700 flex items-center justify-center flex-shrink-0">
<svg class="w-6 h-6 text-surface-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-6 h-6 text-surface-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2z"/> <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> </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">
{% if item.trackUrl %} <template x-if="item.trackUrl">
<a href="{{ item.trackUrl }}" class="hover:text-purple-600 dark:hover:text-purple-400" target="_blank" rel="noopener">{{ item.track }}</a> <a :href="item.trackUrl" class="hover:text-purple-600 dark:hover:text-purple-400" target="_blank" rel="noopener" x-text="item.track"></a>
{% else %} </template>
{{ item.track }} <template x-if="!item.trackUrl"><span x-text="item.track"></span></template>
{% endif %} <template x-if="item.favorite || item.loved">
{% if item.favorite or item.loved %} <span class="text-purple-500 ml-1" :title="item._source === 'funkwhale' ? 'Favorite' : 'Loved'">&#9829;</span>
<span class="text-purple-500 ml-1" title="{% if item._source == 'funkwhale' %}Favorite{% else %}Loved{% endif %}">&#9829;</span> </template>
{% endif %}
</h3> </h3>
<p class="text-sm text-surface-600 dark:text-surface-400 truncate">{{ item.artist }}</p> <p class="text-sm text-surface-600 dark:text-surface-400 truncate" x-text="item.artist"></p>
</div> </div>
<div class="text-right flex-shrink-0"> <div class="text-right flex-shrink-0">
{% if item._source == 'funkwhale' %} <span class="inline-block px-2 py-0.5 text-xs font-medium rounded-full mb-1"
<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="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'"
{% else %} x-text="item._source === 'funkwhale' ? 'Funkwhale' : 'Last.fm'"></span>
<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> <span class="text-xs text-surface-600 dark:text-surface-400 block" x-text="item.relativeTime"></span>
{% endif %} <template x-if="item.trackUrl">
<span class="text-xs text-surface-600 dark:text-surface-400 block">{{ item.relativeTime }}</span>
<button <button
class="share-post-btn mt-1" class="share-post-btn mt-1"
data-share-url="{{ item.trackUrl }}" :data-share-url="item.trackUrl"
data-share-title="{{ item.track }} — {{ item.artist }}" :data-share-title="item.track + ' — ' + item.artist"
title="Create post" title="Create post"
aria-label="Create post" aria-label="Create post"
> ><span class="share-post-icon">✏️</span></button>
<span class="share-post-icon">✏️</span> </template>
</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>
</div> </div>
{% endfor %} </template>
{% else %} </template>
<p class="text-surface-600 dark:text-surface-400">No recent listening history available.</p> <p x-show="!loading && !combinedListens.length" 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">
<svg class="w-6 h-6 text-red-500" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/>
</svg>
Loved Tracks <span class="text-sm font-normal text-surface-600 dark:text-surface-400">(Last.fm)</span>
</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="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="track.coverUrl">
<img :src="track.coverUrl" alt="" class="w-14 h-14 rounded object-cover flex-shrink-0" loading="lazy">
</template>
<template x-if="!track.coverUrl">
<div class="w-14 h-14 rounded bg-surface-200 dark:bg-surface-700 flex items-center justify-center flex-shrink-0">
<svg class="w-6 h-6 text-red-400" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/>
</svg>
</div>
</template>
<div class="flex-1 min-w-0">
<h3 class="font-medium text-surface-900 dark:text-surface-100 truncate">
<template x-if="track.trackUrl">
<a :href="track.trackUrl" class="hover:text-red-600 dark:hover:text-red-400" target="_blank" rel="noopener" x-text="track.track"></a>
</template>
<template x-if="!track.trackUrl"><span x-text="track.track"></span></template>
</h3>
<p class="text-sm text-surface-600 dark:text-surface-400 truncate" x-text="track.artist"></p>
</div>
<span class="text-red-500 flex-shrink-0">&#9829;</span>
</div>
</template>
</div>
</section>
</template>
{# Favorites — Funkwhale #}
<template x-if="!loading && fw.favorites.length && (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>
Loved 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">(Last.fm)</span>
</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">
{% for track in lastfmActivity.loved | head(10) %} <template x-for="favorite in fw.favorites.slice(0,10)" :key="favorite.track + favorite.artist">
<div class="flex items-center gap-3 p-3 bg-surface-50 dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700 shadow-sm"> <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">
{% if track.coverUrl %} <template x-if="favorite.coverUrl">
<img src="{{ track.coverUrl }}" alt="" class="w-14 h-14 rounded object-cover flex-shrink-0" loading="lazy" eleventy:ignore> <img :src="favorite.coverUrl" alt="" class="w-14 h-14 rounded object-cover flex-shrink-0" loading="lazy">
{% else %} </template>
<template x-if="!favorite.coverUrl">
<div class="w-14 h-14 rounded bg-surface-200 dark:bg-surface-700 flex items-center justify-center flex-shrink-0"> <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>
{% 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">
{% if track.trackUrl %} <template x-if="favorite.trackUrl">
<a href="{{ track.trackUrl }}" class="hover:text-purple-600 dark:hover:text-purple-400" target="_blank" rel="noopener">{{ track.track }}</a> <a :href="favorite.trackUrl" class="hover:text-purple-600 dark:hover:text-purple-400" target="_blank" rel="noopener" x-text="favorite.track"></a>
{% else %} </template>
{{ track.track }} <template x-if="!favorite.trackUrl"><span x-text="favorite.track"></span></template>
{% endif %}
</h3> </h3>
<p class="text-sm text-surface-600 dark:text-surface-400 truncate">{{ track.artist }}</p> <p class="text-sm text-surface-600 dark:text-surface-400 truncate" x-text="favorite.artist"></p>
<p x-show="favorite.album" class="text-xs text-surface-600 dark:text-surface-400 truncate" x-text="favorite.album"></p>
</div> </div>
<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 %} </template>
</div> </div>
</section> </section>
{% endif %} </template>
{# Funkwhale Favorites #}
{% if funkwhaleActivity.favorites.length %}
<section class="mb-12" x-show="activeSource === 'all' || activeSource === 'funkwhale'">
<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">
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/>
</svg>
Favorite Tracks
<span class="text-sm font-normal text-surface-600 dark:text-surface-400">(Funkwhale)</span>
</h2>
<div class="grid gap-3 sm:gap-4 md:grid-cols-2">
{% 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">
{% if favorite.coverUrl %}
<img src="{{ favorite.coverUrl }}" alt="" class="w-14 h-14 rounded object-cover flex-shrink-0" loading="lazy" eleventy:ignore>
{% else %}
<div class="w-14 h-14 rounded bg-surface-200 dark:bg-surface-700 flex items-center justify-center flex-shrink-0">
<svg class="w-6 h-6 text-purple-400" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/>
</svg>
</div>
{% endif %}
<div class="flex-1 min-w-0">
<h3 class="font-medium text-surface-900 dark:text-surface-100 truncate">
{% if favorite.trackUrl %}
<a href="{{ favorite.trackUrl }}" class="hover:text-purple-600 dark:hover:text-purple-400" target="_blank" rel="noopener">{{ favorite.track }}</a>
{% else %}
{{ favorite.track }}
{% endif %}
</h3>
<p class="text-sm text-surface-600 dark:text-surface-400 truncate">{{ favorite.artist }}</p>
{% if favorite.album %}
<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>
{% endfor %}
</div>
</section>
{% endif %}
</div> </div>