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:
+8
-148
@@ -1,159 +1,19 @@
|
||||
/**
|
||||
* Funkwhale Activity Data
|
||||
* Fetches from Indiekit's endpoint-funkwhale public API
|
||||
* Funkwhale Activity Data — build-time stub
|
||||
*
|
||||
* 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";
|
||||
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");
|
||||
export default function () {
|
||||
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",
|
||||
instanceUrl: process.env.FUNKWHALE_INSTANCE || "",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+11
-88
@@ -1,99 +1,22 @@
|
||||
/**
|
||||
* Last.fm Activity Data
|
||||
* Fetches from Indiekit's endpoint-lastfm public API
|
||||
* Last.fm Activity Data — build-time stub
|
||||
*
|
||||
* 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";
|
||||
|
||||
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");
|
||||
export default function () {
|
||||
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",
|
||||
username: process.env.LASTFM_USERNAME || "",
|
||||
profileUrl: process.env.LASTFM_USERNAME
|
||||
? `https://www.last.fm/user/${process.env.LASTFM_USERNAME}`
|
||||
: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{# Listening Widget — combined Funkwhale + Last.fm recent tracks #}
|
||||
{% set hasListening = (funkwhaleActivity and (funkwhaleActivity.nowPlaying or funkwhaleActivity.listenings.length)) or (lastfmActivity and (lastfmActivity.nowPlaying or lastfmActivity.scrobbles.length)) %}
|
||||
{# Listening Widget — combined Funkwhale + Last.fm recent tracks (client-side) #}
|
||||
{% set hasListening = (funkwhaleActivity and funkwhaleActivity.source == 'indiekit') or (lastfmActivity and lastfmActivity.source == 'indiekit') %}
|
||||
{% if hasListening %}
|
||||
<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">
|
||||
<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"/>
|
||||
@@ -10,14 +10,18 @@
|
||||
Listening
|
||||
</h3>
|
||||
|
||||
{# Now Playing — show if either source is actively playing #}
|
||||
{% set fwNow = funkwhaleActivity.nowPlaying if funkwhaleActivity and funkwhaleActivity.nowPlaying and funkwhaleActivity.nowPlaying.status == 'now-playing' else null %}
|
||||
{% set lfmNow = lastfmActivity.nowPlaying if lastfmActivity and lastfmActivity.nowPlaying and lastfmActivity.nowPlaying.status == 'now-playing' else null %}
|
||||
{# Loading skeleton #}
|
||||
<div x-show="loading" class="space-y-2 animate-pulse">
|
||||
<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 %}
|
||||
{% set np = fwNow or lfmNow %}
|
||||
{% set npSource = "Funkwhale" if fwNow else "Last.fm" %}
|
||||
{% set npColor = "purple" if fwNow else "red" %}
|
||||
{# Error state #}
|
||||
<p x-show="!loading && error" class="text-xs text-surface-500 dark:text-surface-400">Unavailable</p>
|
||||
|
||||
{# 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="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">
|
||||
@@ -26,84 +30,60 @@
|
||||
<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>
|
||||
<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 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 %}
|
||||
<template x-if="nowPlaying?.coverUrl">
|
||||
<img :src="nowPlaying.coverUrl" alt="" class="w-10 h-10 rounded object-cover flex-shrink-0 shadow-lg" loading="lazy">
|
||||
</template>
|
||||
<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 %}
|
||||
<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>
|
||||
</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>
|
||||
{% endif %}
|
||||
</template>
|
||||
|
||||
{# Recent tracks — 2 from each source #}
|
||||
<ul class="space-y-2">
|
||||
{% if funkwhaleActivity and funkwhaleActivity.listenings.length %}
|
||||
{% for listening in funkwhaleActivity.listenings | head(2) %}
|
||||
{# 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">
|
||||
{% 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">
|
||||
<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>
|
||||
{% endif %}
|
||||
</template>
|
||||
<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 %}
|
||||
<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">♥</span>
|
||||
</template>
|
||||
</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 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>
|
||||
{% endfor %}
|
||||
{% 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">♥</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 %}
|
||||
</template>
|
||||
</ul>
|
||||
|
||||
<a href="/listening/" class="text-sm text-purple-600 dark:text-purple-400 hover:underline flex items-center gap-1 mt-3">
|
||||
|
||||
@@ -162,6 +162,7 @@
|
||||
<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) #}
|
||||
<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/fediverse-interact.js?v={{ '/js/fediverse-interact.js' | hash }}" defer></script>
|
||||
<script src="/js/lightbox.js?v={{ '/js/lightbox.js' | hash }}" defer></script>
|
||||
|
||||
+164
-160
@@ -4,76 +4,82 @@ title: Funkwhale Listening Activity
|
||||
permalink: /funkwhale/
|
||||
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">
|
||||
<h1 class="text-2xl sm:text-3xl md:text-4xl font-bold text-surface-900 dark:text-surface-100 mb-2">Listening Activity</h1>
|
||||
<p class="text-surface-600 dark:text-surface-400">
|
||||
What I've been listening to.
|
||||
</p>
|
||||
<p class="text-surface-600 dark:text-surface-400">What I've been listening to.</p>
|
||||
</header>
|
||||
|
||||
{# Now Playing / Recently Played Hero #}
|
||||
{% if funkwhaleActivity.nowPlaying and funkwhaleActivity.nowPlaying.status %}
|
||||
{# Loading skeleton #}
|
||||
<div x-show="loading" class="space-y-6 animate-pulse">
|
||||
<div class="h-32 bg-surface-200 dark:bg-surface-700 rounded-xl"></div>
|
||||
<div class="h-48 bg-surface-200 dark:bg-surface-700 rounded-xl"></div>
|
||||
<div class="space-y-3">
|
||||
<div class="h-16 bg-surface-200 dark:bg-surface-700 rounded-lg"></div>
|
||||
<div class="h-16 bg-surface-200 dark:bg-surface-700 rounded-lg"></div>
|
||||
<div class="h-16 bg-surface-200 dark:bg-surface-700 rounded-lg"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Error #}
|
||||
<p x-show="!loading && error" class="text-surface-600 dark:text-surface-400">Could not load listening data.</p>
|
||||
|
||||
{# Now Playing #}
|
||||
<template x-if="!loading && nowPlaying?.status">
|
||||
<section class="mb-12">
|
||||
<div class="relative p-4 sm:p-6 rounded-xl sm:rounded-2xl overflow-hidden {% 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">
|
||||
{% if funkwhaleActivity.nowPlaying.coverUrl %}
|
||||
<img
|
||||
src="{{ funkwhaleActivity.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"
|
||||
eleventy:ignore
|
||||
>
|
||||
{% else %}
|
||||
<template x-if="nowPlaying.coverUrl">
|
||||
<img :src="nowPlaying.coverUrl" alt="" class="w-20 h-20 sm:w-24 sm:h-24 rounded-lg shadow-lg object-cover flex-shrink-0" loading="lazy">
|
||||
</template>
|
||||
<template x-if="!nowPlaying.coverUrl">
|
||||
<div class="w-20 h-20 sm:w-24 sm:h-24 rounded-lg bg-surface-200 dark:bg-surface-700 flex items-center justify-center flex-shrink-0">
|
||||
<svg class="w-8 h-8 sm:w-10 sm:h-10 text-surface-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"/>
|
||||
</svg>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</template>
|
||||
<div class="flex-1 min-w-0 w-full sm:w-auto">
|
||||
<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="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: 50%; animation-delay: 0.4s;"></span>
|
||||
</span>
|
||||
Now Playing
|
||||
</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">
|
||||
<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>
|
||||
{% endif %}
|
||||
</template>
|
||||
</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 %}
|
||||
<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">{{ 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>
|
||||
<p class="text-surface-600 dark:text-surface-400" x-text="nowPlaying.artist"></p>
|
||||
<p x-show="nowPlaying.album" class="text-sm text-surface-600 dark:text-surface-400 mt-1" x-text="nowPlaying.album"></p>
|
||||
<p class="text-xs text-surface-600 dark:text-surface-400 mt-2" x-text="nowPlaying.relativeTime"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
</template>
|
||||
|
||||
{# Stats Section with Tabs #}
|
||||
{% if funkwhaleActivity.stats %}
|
||||
{# Stats Section #}
|
||||
<template x-if="!loading && stats">
|
||||
<section class="mb-12">
|
||||
<h2 class="text-xl sm:text-2xl font-bold text-surface-900 dark:text-surface-100 mb-4 sm:mb-6 flex items-center gap-2">
|
||||
<svg class="w-6 h-6 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -83,100 +89,108 @@ withSidebar: true
|
||||
</h2>
|
||||
|
||||
{# 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
|
||||
@click="activeTab = 'all'"
|
||||
: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'"
|
||||
:aria-selected="(activeTab === 'all').toString()"
|
||||
role="tab" id="fw-tab-all" aria-controls="fw-panel-all"
|
||||
class="px-4 py-2 text-sm font-medium transition-colors -mb-px whitespace-nowrap"
|
||||
>
|
||||
All Time
|
||||
</button>
|
||||
<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>
|
||||
@click="activeTab = tab"
|
||||
:class="activeTab === tab ? 'border-b-2 border-purple-500 text-purple-600 dark:text-purple-400' : 'text-surface-600 dark:text-surface-400 hover:text-surface-700 dark:hover:text-surface-300'"
|
||||
:aria-selected="(activeTab === tab).toString()"
|
||||
role="tab"
|
||||
class="px-4 py-2 text-sm font-medium transition-colors -mb-px whitespace-nowrap capitalize"
|
||||
x-text="tab === 'all' ? 'All Time' : tab === 'month' ? 'This Month' : tab === 'week' ? 'This Week' : 'Trends'"
|
||||
></button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
{# 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" %}
|
||||
{# Stats panel — All / Month / Week share same markup via computed props #}
|
||||
<div x-show="activeTab !== 'trends'" x-cloak>
|
||||
{# Summary cards #}
|
||||
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3 sm:gap-4 mb-6 sm:mb-8">
|
||||
<div class="p-4 bg-surface-50 dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700 shadow-sm text-center">
|
||||
<span class="text-2xl font-bold font-mono text-purple-600 dark:text-purple-400 block" x-text="summary.totalPlays || 0"></span>
|
||||
<span class="text-xs text-surface-600 dark:text-surface-400 uppercase tracking-wide">Plays</span>
|
||||
</div>
|
||||
<div class="p-4 bg-surface-50 dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700 shadow-sm text-center">
|
||||
<span class="text-2xl font-bold font-mono text-purple-600 dark:text-purple-400 block" x-text="summary.uniqueTracks || 0"></span>
|
||||
<span class="text-xs text-surface-600 dark:text-surface-400 uppercase tracking-wide">Tracks</span>
|
||||
</div>
|
||||
<div class="p-4 bg-surface-50 dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700 shadow-sm text-center">
|
||||
<span class="text-2xl font-bold font-mono text-purple-600 dark:text-purple-400 block" x-text="summary.uniqueArtists || 0"></span>
|
||||
<span class="text-xs text-surface-600 dark:text-surface-400 uppercase tracking-wide">Artists</span>
|
||||
</div>
|
||||
<div class="p-4 bg-surface-50 dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700 shadow-sm text-center">
|
||||
<span class="text-2xl font-bold font-mono text-purple-600 dark:text-purple-400 block" x-text="totalDurationFormatted"></span>
|
||||
<span class="text-xs text-surface-600 dark:text-surface-400 uppercase tracking-wide">Listened</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# 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" %}
|
||||
{# 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>
|
||||
|
||||
{# 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 %}
|
||||
{# 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">
|
||||
{% 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 %}
|
||||
<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: {{ (day.count / maxCount * 100) if maxCount > 0 else 0 }}%; min-height: 2px;"
|
||||
title="{{ day.date }}: {{ day.count }} plays"
|
||||
:style="'height: ' + (trendsMax > 0 ? Math.round(day.count / trendsMax * 100) : 0) + '%; min-height: 2px;'"
|
||||
:title="day.date + ': ' + day.count + ' plays'"
|
||||
></div>
|
||||
{% endfor %}
|
||||
</template>
|
||||
</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>
|
||||
<span x-text="trendsData[0]?.date"></span>
|
||||
<span x-text="trendsData[trendsData.length - 1]?.date"></span>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-surface-600 dark:text-surface-400">No trend data available yet.</p>
|
||||
{% endif %}
|
||||
</template>
|
||||
<p x-show="!trendsData.length" class="text-surface-600 dark:text-surface-400">No trend data available yet.</p>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
</template>
|
||||
|
||||
{# Recent Listenings #}
|
||||
<section class="mb-12">
|
||||
@@ -186,50 +200,44 @@ withSidebar: true
|
||||
</svg>
|
||||
Recent Listens
|
||||
</h2>
|
||||
|
||||
{% if funkwhaleActivity.listenings.length %}
|
||||
<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">
|
||||
{% if listening.coverUrl %}
|
||||
<img src="{{ listening.coverUrl }}" alt="" class="w-12 h-12 rounded object-cover flex-shrink-0" loading="lazy" eleventy:ignore>
|
||||
{% else %}
|
||||
<template x-if="listening.coverUrl">
|
||||
<img :src="listening.coverUrl" alt="" class="w-12 h-12 rounded object-cover flex-shrink-0" loading="lazy">
|
||||
</template>
|
||||
<template x-if="!listening.coverUrl">
|
||||
<div class="w-12 h-12 rounded bg-surface-200 dark:bg-surface-700 flex items-center justify-center flex-shrink-0">
|
||||
<svg class="w-6 h-6 text-surface-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2z"/>
|
||||
</svg>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</template>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="font-medium 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 %}
|
||||
<template x-if="listening.trackUrl">
|
||||
<a :href="listening.trackUrl" class="hover:text-purple-600 dark:hover:text-purple-400" target="_blank" rel="noopener" x-text="listening.track"></a>
|
||||
</template>
|
||||
<template x-if="!listening.trackUrl">
|
||||
<span x-text="listening.track"></span>
|
||||
</template>
|
||||
</h3>
|
||||
<p class="text-sm text-surface-600 dark:text-surface-400 truncate">{{ listening.artist }}</p>
|
||||
<p class="text-sm text-surface-600 dark:text-surface-400 truncate" x-text="listening.artist"></p>
|
||||
</div>
|
||||
|
||||
<div class="text-right flex-shrink-0">
|
||||
<span class="text-xs text-surface-600 dark:text-surface-400">{{ listening.relativeTime }}</span>
|
||||
{% if listening.duration %}
|
||||
<span class="text-xs text-surface-600 dark:text-surface-400 block">{{ listening.duration }}</span>
|
||||
{% endif %}
|
||||
<span class="text-xs text-surface-600 dark:text-surface-400" x-text="listening.relativeTime"></span>
|
||||
<span x-show="listening.duration" class="text-xs text-surface-600 dark:text-surface-400 block" x-text="listening.duration"></span>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</template>
|
||||
</template>
|
||||
<p x-show="!loading && !listenings.length" class="text-surface-600 dark:text-surface-400">No recent listening history available.</p>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-surface-600 dark:text-surface-400">No recent listening history available.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
{# Favorites #}
|
||||
{% if funkwhaleActivity.favorites.length %}
|
||||
<template x-if="!loading && favorites.length">
|
||||
<section class="mb-12">
|
||||
<h2 class="text-xl sm:text-2xl font-bold text-surface-900 dark:text-surface-100 mb-4 sm:mb-6 flex items-center gap-2">
|
||||
<svg class="w-6 h-6 text-red-500" fill="currentColor" viewBox="0 0 24 24">
|
||||
@@ -237,38 +245,34 @@ withSidebar: true
|
||||
</svg>
|
||||
Favorite Tracks
|
||||
</h2>
|
||||
|
||||
<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">
|
||||
{% if favorite.coverUrl %}
|
||||
<img src="{{ favorite.coverUrl }}" alt="" class="w-14 h-14 rounded object-cover flex-shrink-0" loading="lazy" eleventy:ignore>
|
||||
{% else %}
|
||||
<template x-if="favorite.coverUrl">
|
||||
<img :src="favorite.coverUrl" alt="" class="w-14 h-14 rounded object-cover flex-shrink-0" loading="lazy">
|
||||
</template>
|
||||
<template x-if="!favorite.coverUrl">
|
||||
<div class="w-14 h-14 rounded bg-surface-200 dark:bg-surface-700 flex items-center justify-center flex-shrink-0">
|
||||
<svg class="w-6 h-6 text-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"/>
|
||||
</svg>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</template>
|
||||
<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 %}
|
||||
<template x-if="favorite.trackUrl">
|
||||
<a :href="favorite.trackUrl" class="hover:text-purple-600 dark:hover:text-purple-400" target="_blank" rel="noopener" x-text="favorite.track"></a>
|
||||
</template>
|
||||
<template x-if="!favorite.trackUrl">
|
||||
<span x-text="favorite.track"></span>
|
||||
</template>
|
||||
</h3>
|
||||
<p class="text-sm text-surface-600 dark:text-surface-400 truncate">{{ favorite.artist }}</p>
|
||||
{% if favorite.album %}
|
||||
<p class="text-xs text-surface-600 dark:text-surface-400 truncate">{{ favorite.album }}</p>
|
||||
{% endif %}
|
||||
<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>
|
||||
{% endfor %}
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
</template>
|
||||
</div>
|
||||
|
||||
+197
@@ -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
@@ -4,20 +4,10 @@ title: Listening Activity
|
||||
permalink: /listening/
|
||||
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">
|
||||
<h1 class="text-2xl sm:text-3xl md:text-4xl font-bold text-surface-900 dark:text-surface-100 mb-2">Listening Activity</h1>
|
||||
<p class="text-surface-600 dark:text-surface-400">
|
||||
What I've been listening to.
|
||||
{# {% 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>
|
||||
<p class="text-surface-600 dark:text-surface-400">What I've been listening to.</p>
|
||||
</header>
|
||||
|
||||
{# Source Filter Tabs #}
|
||||
@@ -26,148 +16,135 @@ withSidebar: true
|
||||
@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="px-4 py-2 rounded-full text-sm font-medium transition-colors"
|
||||
>
|
||||
All Sources
|
||||
</button>
|
||||
{% if funkwhaleActivity.source == 'indiekit' %}
|
||||
>All Sources</button>
|
||||
<button
|
||||
@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="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>
|
||||
{% endif %}
|
||||
{% if lastfmActivity.source == 'indiekit' %}
|
||||
><span class="w-2 h-2 rounded-full bg-purple-500"></span>Funkwhale</button>
|
||||
<button
|
||||
@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="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>
|
||||
{% endif %}
|
||||
><span class="w-2 h-2 rounded-full bg-red-500"></span>Last.fm</button>
|
||||
</div>
|
||||
|
||||
{# Now Playing Section - Combined #}
|
||||
{% set fwNowPlaying = funkwhaleActivity.nowPlaying if funkwhaleActivity.nowPlaying and funkwhaleActivity.nowPlaying.status else null %}
|
||||
{% set lfmNowPlaying = lastfmActivity.nowPlaying if lastfmActivity.nowPlaying and lastfmActivity.nowPlaying.status else null %}
|
||||
{# Loading skeleton #}
|
||||
<div x-show="loading" class="space-y-4 animate-pulse">
|
||||
<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 %}
|
||||
<section class="mb-12">
|
||||
{# Funkwhale Now Playing #}
|
||||
{% if fwNowPlaying %}
|
||||
<div x-show="activeSource === 'all' || activeSource === 'funkwhale'" class="mb-4">
|
||||
<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 %}">
|
||||
{# Now Playing — Funkwhale #}
|
||||
<template x-if="!loading && fwNowPlaying && (activeSource === 'all' || activeSource === 'funkwhale')">
|
||||
<section class="mb-6">
|
||||
<div class="relative p-4 sm:p-6 rounded-xl sm:rounded-2xl overflow-hidden"
|
||||
:class="fwNowPlaying.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">
|
||||
{% 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>
|
||||
{% else %}
|
||||
<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">
|
||||
</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">
|
||||
<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 %}
|
||||
|
||||
</template>
|
||||
<div class="flex-1 min-w-0 w-full sm:w-auto">
|
||||
<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>
|
||||
{% 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="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: 50%; animation-delay: 0.4s;"></span>
|
||||
</span>
|
||||
Now Playing
|
||||
</span>
|
||||
{% 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>
|
||||
{% endif %}
|
||||
</template>
|
||||
<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">Recently Played</span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<h2 class="text-lg sm:text-xl font-bold text-surface-900 dark:text-surface-100 truncate">
|
||||
{% if fwNowPlaying.trackUrl %}
|
||||
<a href="{{ fwNowPlaying.trackUrl }}" class="hover:text-purple-600 dark:hover:text-purple-400" target="_blank" rel="noopener">{{ fwNowPlaying.track }}</a>
|
||||
{% else %}
|
||||
{{ fwNowPlaying.track }}
|
||||
{% endif %}
|
||||
<template x-if="fwNowPlaying.trackUrl">
|
||||
<a :href="fwNowPlaying.trackUrl" class="hover:text-purple-600 dark:hover:text-purple-400" target="_blank" rel="noopener" x-text="fwNowPlaying.track"></a>
|
||||
</template>
|
||||
<template x-if="!fwNowPlaying.trackUrl"><span x-text="fwNowPlaying.track"></span></template>
|
||||
</h2>
|
||||
<p class="text-surface-600 dark:text-surface-400">{{ fwNowPlaying.artist }}</p>
|
||||
{% if fwNowPlaying.album %}
|
||||
<p class="text-sm text-surface-600 dark:text-surface-400 mt-1">{{ fwNowPlaying.album }}</p>
|
||||
{% endif %}
|
||||
<p class="text-xs text-surface-600 dark:text-surface-400 mt-2">{{ fwNowPlaying.relativeTime }}</p>
|
||||
<p class="text-surface-600 dark:text-surface-400" x-text="fwNowPlaying.artist"></p>
|
||||
<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-xs text-surface-600 dark:text-surface-400 mt-2" x-text="fwNowPlaying.relativeTime"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
</template>
|
||||
|
||||
{# Last.fm Now Playing #}
|
||||
{% if lfmNowPlaying %}
|
||||
<div x-show="activeSource === 'all' || activeSource === 'lastfm'" class="mb-4">
|
||||
<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 %}">
|
||||
{# Now Playing — Last.fm #}
|
||||
<template x-if="!loading && lfmNowPlaying && (activeSource === 'all' || activeSource === 'lastfm')">
|
||||
<section class="mb-6">
|
||||
<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">
|
||||
{% 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>
|
||||
{% else %}
|
||||
<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">
|
||||
</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">
|
||||
<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 %}
|
||||
|
||||
</template>
|
||||
<div class="flex-1 min-w-0 w-full sm:w-auto">
|
||||
<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>
|
||||
{% 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 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>
|
||||
<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-red-500/20 text-red-700 dark:text-red-400 rounded-full">
|
||||
<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: 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: 30%;"></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-red-500 animate-pulse" style="height: 50%; animation-delay: 0.4s;"></span>
|
||||
</span>
|
||||
Now Playing
|
||||
</span>
|
||||
{% 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>
|
||||
{% endif %}
|
||||
{% if lfmNowPlaying.loved %}
|
||||
<span class="text-purple-500" title="Loved">♥</span>
|
||||
{% endif %}
|
||||
</template>
|
||||
<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-red-500/20 text-red-700 dark:text-red-400 rounded-full">Recently Played</span>
|
||||
</template>
|
||||
<template x-if="lfmNowPlaying.loved">
|
||||
<span class="text-red-500" title="Loved">♥</span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<h2 class="text-lg sm:text-xl font-bold text-surface-900 dark:text-surface-100 truncate">
|
||||
{% if lfmNowPlaying.trackUrl %}
|
||||
<a href="{{ lfmNowPlaying.trackUrl }}" class="hover:text-purple-600 dark:hover:text-purple-400" target="_blank" rel="noopener">{{ lfmNowPlaying.track }}</a>
|
||||
{% else %}
|
||||
{{ lfmNowPlaying.track }}
|
||||
{% endif %}
|
||||
<template x-if="lfmNowPlaying.trackUrl">
|
||||
<a :href="lfmNowPlaying.trackUrl" class="hover:text-red-600 dark:hover:text-red-400" target="_blank" rel="noopener" x-text="lfmNowPlaying.track"></a>
|
||||
</template>
|
||||
<template x-if="!lfmNowPlaying.trackUrl"><span x-text="lfmNowPlaying.track"></span></template>
|
||||
</h2>
|
||||
<p class="text-surface-600 dark:text-surface-400">{{ lfmNowPlaying.artist }}</p>
|
||||
{% if lfmNowPlaying.album %}
|
||||
<p class="text-sm text-surface-600 dark:text-surface-400 mt-1">{{ lfmNowPlaying.album }}</p>
|
||||
{% endif %}
|
||||
<p class="text-xs text-surface-600 dark:text-surface-400 mt-2">{{ lfmNowPlaying.relativeTime }}</p>
|
||||
<p class="text-surface-600 dark:text-surface-400" x-text="lfmNowPlaying.artist"></p>
|
||||
<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-xs text-surface-600 dark:text-surface-400 mt-2" x-text="lfmNowPlaying.relativeTime"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endif %}
|
||||
</template>
|
||||
|
||||
{# Combined Stats Section #}
|
||||
{% if funkwhaleActivity.stats or lastfmActivity.stats %}
|
||||
<template x-if="!loading && (fw.stats || lfm.stats)">
|
||||
<section class="mb-12">
|
||||
<h2 class="text-xl sm:text-2xl font-bold text-surface-900 dark:text-surface-100 mb-4 sm:mb-6 flex items-center gap-2">
|
||||
<svg class="w-6 h-6 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -175,89 +152,85 @@ withSidebar: true
|
||||
</svg>
|
||||
Listening Statistics
|
||||
</h2>
|
||||
|
||||
{# Stats Cards Grid - Side by Side #}
|
||||
<div class="grid gap-4 sm:gap-6 md:grid-cols-2">
|
||||
{# Funkwhale Stats #}
|
||||
{% if funkwhaleActivity.stats %}
|
||||
<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">
|
||||
|
||||
{# Funkwhale Stats Card #}
|
||||
<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">
|
||||
<span class="w-3 h-3 rounded-full bg-purple-500"></span>
|
||||
Funkwhale
|
||||
<span class="w-3 h-3 rounded-full bg-purple-500"></span>Funkwhale
|
||||
</h3>
|
||||
<div class="grid grid-cols-3 gap-4 text-center">
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
{# Top Artists #}
|
||||
{% if funkwhaleActivity.stats.topArtists.all.length %}
|
||||
<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">
|
||||
{% 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">
|
||||
<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>
|
||||
<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>
|
||||
{% endfor %}
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</template>
|
||||
</div>
|
||||
{% endif %}
|
||||
</template>
|
||||
|
||||
{# Last.fm Stats #}
|
||||
{% if lastfmActivity.stats %}
|
||||
<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">
|
||||
<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>
|
||||
Last.fm
|
||||
{# Last.fm Stats Card #}
|
||||
<template x-if="lfm.stats && (activeSource === 'all' || activeSource === 'lastfm')">
|
||||
<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-red-700 dark:text-red-400 mb-4 flex items-center gap-2">
|
||||
<span class="w-3 h-3 rounded-full bg-red-500"></span>Last.fm
|
||||
</h3>
|
||||
<div class="grid grid-cols-3 gap-4 text-center">
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
{# Top Artists from Last.fm #}
|
||||
{% if lastfmActivity.stats.topArtists.all.length %}
|
||||
<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">
|
||||
{% 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">
|
||||
<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>
|
||||
<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>
|
||||
{% endfor %}
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</template>
|
||||
</div>
|
||||
{% endif %}
|
||||
</template>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
</template>
|
||||
|
||||
{# Recent Listens - Combined Timeline #}
|
||||
{# Combined Recent Listens Timeline #}
|
||||
<section class="mb-12">
|
||||
<h2 class="text-xl sm:text-2xl font-bold text-surface-900 dark:text-surface-100 mb-4 sm:mb-6 flex items-center gap-2">
|
||||
<svg class="w-6 h-6 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -265,195 +238,127 @@ withSidebar: true
|
||||
</svg>
|
||||
Recent Listens
|
||||
</h2>
|
||||
|
||||
{% set combinedListens = funkwhaleActivity.listenings | mergeListens(lastfmActivity.scrobbles) | head(20) %}
|
||||
|
||||
<div class="space-y-3">
|
||||
{% if combinedListens.length %}
|
||||
{% for item in combinedListens %}
|
||||
<div
|
||||
x-show="activeSource === 'all' || activeSource === '{{ item._source }}'"
|
||||
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 item.coverUrl %}
|
||||
<img src="{{ item.coverUrl }}" alt="" class="w-12 h-12 rounded object-cover flex-shrink-0" loading="lazy" eleventy:ignore>
|
||||
{% else %}
|
||||
<template x-if="!loading && combinedListens.length">
|
||||
<template x-for="item in combinedListens.filter(i => activeSource === 'all' || i._source === activeSource)" :key="item._ts + item.track">
|
||||
<div class="flex items-center gap-4 p-3 bg-surface-50 dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700 hover:border-purple-400 dark:hover:border-purple-600 transition-colors shadow-sm">
|
||||
<template x-if="item.coverUrl">
|
||||
<img :src="item.coverUrl" alt="" class="w-12 h-12 rounded object-cover flex-shrink-0" loading="lazy">
|
||||
</template>
|
||||
<template x-if="!item.coverUrl">
|
||||
<div class="w-12 h-12 rounded bg-surface-200 dark:bg-surface-700 flex items-center justify-center flex-shrink-0">
|
||||
<svg class="w-6 h-6 text-surface-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2z"/>
|
||||
</svg>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</template>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="font-medium text-surface-900 dark:text-surface-100 truncate">
|
||||
{% if item.trackUrl %}
|
||||
<a href="{{ item.trackUrl }}" class="hover:text-purple-600 dark:hover:text-purple-400" target="_blank" rel="noopener">{{ item.track }}</a>
|
||||
{% else %}
|
||||
{{ item.track }}
|
||||
{% endif %}
|
||||
{% if item.favorite or item.loved %}
|
||||
<span class="text-purple-500 ml-1" title="{% if item._source == 'funkwhale' %}Favorite{% else %}Loved{% endif %}">♥</span>
|
||||
{% endif %}
|
||||
<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.favorite || item.loved">
|
||||
<span class="text-purple-500 ml-1" :title="item._source === 'funkwhale' ? 'Favorite' : 'Loved'">♥</span>
|
||||
</template>
|
||||
</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 class="text-right flex-shrink-0">
|
||||
{% if item._source == 'funkwhale' %}
|
||||
<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>
|
||||
{% else %}
|
||||
<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>
|
||||
{% endif %}
|
||||
<span class="text-xs text-surface-600 dark:text-surface-400 block">{{ item.relativeTime }}</span>
|
||||
<span class="inline-block px-2 py-0.5 text-xs font-medium rounded-full mb-1"
|
||||
: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'"
|
||||
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>
|
||||
<template x-if="item.trackUrl">
|
||||
<button
|
||||
class="share-post-btn mt-1"
|
||||
data-share-url="{{ item.trackUrl }}"
|
||||
data-share-title="{{ item.track }} — {{ item.artist }}"
|
||||
:data-share-url="item.trackUrl"
|
||||
:data-share-title="item.track + ' — ' + item.artist"
|
||||
title="Create post"
|
||||
aria-label="Create post"
|
||||
>
|
||||
<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>
|
||||
><span class="share-post-icon">✏️</span></button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<p class="text-surface-600 dark:text-surface-400">No recent listening history available.</p>
|
||||
{% endif %}
|
||||
</template>
|
||||
</template>
|
||||
<p x-show="!loading && !combinedListens.length" class="text-surface-600 dark:text-surface-400">No recent listening history available.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{# Loved Tracks from Last.fm #}
|
||||
{% if lastfmActivity.loved.length %}
|
||||
<section class="mb-12" x-show="activeSource === 'all' || activeSource === 'lastfm'">
|
||||
{# Loved Tracks — Last.fm #}
|
||||
<template x-if="!loading && lfm.loved.length && (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">♥</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">
|
||||
<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>
|
||||
Loved Tracks
|
||||
<span class="text-sm font-normal text-surface-600 dark:text-surface-400">(Last.fm)</span>
|
||||
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 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">
|
||||
{% if track.coverUrl %}
|
||||
<img src="{{ track.coverUrl }}" alt="" class="w-14 h-14 rounded object-cover flex-shrink-0" loading="lazy" eleventy:ignore>
|
||||
{% else %}
|
||||
<template x-if="favorite.coverUrl">
|
||||
<img :src="favorite.coverUrl" alt="" class="w-14 h-14 rounded object-cover flex-shrink-0" loading="lazy">
|
||||
</template>
|
||||
<template x-if="!favorite.coverUrl">
|
||||
<div class="w-14 h-14 rounded bg-surface-200 dark:bg-surface-700 flex items-center justify-center flex-shrink-0">
|
||||
<svg class="w-6 h-6 text-purple-400" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/>
|
||||
</svg>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</template>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="font-medium text-surface-900 dark:text-surface-100 truncate">
|
||||
{% if track.trackUrl %}
|
||||
<a href="{{ track.trackUrl }}" class="hover:text-purple-600 dark:hover:text-purple-400" target="_blank" rel="noopener">{{ track.track }}</a>
|
||||
{% else %}
|
||||
{{ track.track }}
|
||||
{% endif %}
|
||||
<template x-if="favorite.trackUrl">
|
||||
<a :href="favorite.trackUrl" class="hover:text-purple-600 dark:hover:text-purple-400" target="_blank" rel="noopener" x-text="favorite.track"></a>
|
||||
</template>
|
||||
<template x-if="!favorite.trackUrl"><span x-text="favorite.track"></span></template>
|
||||
</h3>
|
||||
<p class="text-sm text-surface-600 dark:text-surface-400 truncate">{{ 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>
|
||||
|
||||
<span class="text-purple-500 flex-shrink-0">♥</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>
|
||||
{% endfor %}
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{# 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 %}
|
||||
</template>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user