@rmdes/indiekit-endpoint-funkwhale 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/utils.js ADDED
@@ -0,0 +1,242 @@
1
+ /**
2
+ * Truncate text with ellipsis
3
+ * @param {string} text - Text to truncate
4
+ * @param {number} [maxLength] - Maximum length
5
+ * @returns {string} - Truncated text
6
+ */
7
+ export function truncate(text, maxLength = 80) {
8
+ if (!text || text.length <= maxLength) return text || "";
9
+ return text.slice(0, maxLength - 1) + "...";
10
+ }
11
+
12
+ /**
13
+ * Get the primary artist name from a track
14
+ * @param {object} track - Funkwhale track object
15
+ * @returns {string} - Artist name
16
+ */
17
+ export function getArtistName(track) {
18
+ if (!track) return "Unknown Artist";
19
+
20
+ const credit = track.artist_credit?.[0];
21
+ if (credit?.artist?.name) {
22
+ return credit.artist.name;
23
+ }
24
+ if (credit?.credit) {
25
+ return credit.credit;
26
+ }
27
+
28
+ return "Unknown Artist";
29
+ }
30
+
31
+ /**
32
+ * Get the best available cover URL from a track
33
+ * Prefers medium square crop (200x200) for consistent display
34
+ * @param {object} track - Funkwhale track object
35
+ * @param {string} [size] - Size preference: 'small', 'medium', 'large', 'original'
36
+ * @returns {string|null} - Cover URL or null
37
+ */
38
+ export function getCoverUrl(track, size = "medium") {
39
+ if (!track) return null;
40
+
41
+ const sizeKey = `${size}_square_crop`;
42
+
43
+ // Check track cover
44
+ if (track.cover?.urls) {
45
+ return track.cover.urls[sizeKey] || track.cover.urls.original || null;
46
+ }
47
+
48
+ // Check album cover
49
+ if (track.album?.cover?.urls) {
50
+ return track.album.cover.urls[sizeKey] || track.album.cover.urls.original || null;
51
+ }
52
+
53
+ // Check artist cover
54
+ const artist = track.artist_credit?.[0]?.artist;
55
+ if (artist?.cover?.urls) {
56
+ return artist.cover.urls[sizeKey] || artist.cover.urls.original || null;
57
+ }
58
+
59
+ return null;
60
+ }
61
+
62
+ /**
63
+ * Get the track URL (federation ID)
64
+ * @param {object} track - Funkwhale track object
65
+ * @returns {string|null} - Track URL or null
66
+ */
67
+ export function getTrackUrl(track) {
68
+ return track?.fid || null;
69
+ }
70
+
71
+ /**
72
+ * Format duration in seconds to human-readable string
73
+ * @param {number} seconds - Duration in seconds
74
+ * @returns {string} - Formatted duration (e.g., "3:45" or "1h 23m")
75
+ */
76
+ export function formatDuration(seconds) {
77
+ if (!seconds || seconds < 0) return "0:00";
78
+
79
+ const hours = Math.floor(seconds / 3600);
80
+ const minutes = Math.floor((seconds % 3600) / 60);
81
+ const secs = Math.floor(seconds % 60);
82
+
83
+ if (hours > 0) {
84
+ return `${hours}h ${minutes}m`;
85
+ }
86
+
87
+ return `${minutes}:${secs.toString().padStart(2, "0")}`;
88
+ }
89
+
90
+ /**
91
+ * Format total listening time for stats display
92
+ * @param {number} seconds - Total seconds
93
+ * @returns {string} - Human-readable duration
94
+ */
95
+ export function formatTotalTime(seconds) {
96
+ if (!seconds || seconds < 0) return "0 minutes";
97
+
98
+ const hours = Math.floor(seconds / 3600);
99
+ const minutes = Math.floor((seconds % 3600) / 60);
100
+
101
+ if (hours > 24) {
102
+ const days = Math.floor(hours / 24);
103
+ const remainingHours = hours % 24;
104
+ if (remainingHours > 0) {
105
+ return `${days}d ${remainingHours}h`;
106
+ }
107
+ return `${days} days`;
108
+ }
109
+
110
+ if (hours > 0) {
111
+ if (minutes > 0) {
112
+ return `${hours}h ${minutes}m`;
113
+ }
114
+ return `${hours} hours`;
115
+ }
116
+
117
+ return `${minutes} minutes`;
118
+ }
119
+
120
+ /**
121
+ * Format date for display
122
+ * @param {string|Date} dateInput - ISO date string or Date object
123
+ * @param {string} [locale] - Locale for formatting
124
+ * @returns {string} - Formatted date
125
+ */
126
+ export function formatDate(dateInput, locale = "en") {
127
+ const date = new Date(dateInput);
128
+ return date.toLocaleDateString(locale, {
129
+ year: "numeric",
130
+ month: "short",
131
+ day: "numeric",
132
+ });
133
+ }
134
+
135
+ /**
136
+ * Format relative time
137
+ * @param {string|Date} dateInput - ISO date string or Date object
138
+ * @returns {string} - Relative time string
139
+ */
140
+ export function formatRelativeTime(dateInput) {
141
+ const date = new Date(dateInput);
142
+ const now = new Date();
143
+ const diffMs = now - date;
144
+ const diffMins = Math.floor(diffMs / 60_000);
145
+ const diffHours = Math.floor(diffMs / 3_600_000);
146
+ const diffDays = Math.floor(diffMs / 86_400_000);
147
+
148
+ if (diffMins < 1) return "just now";
149
+ if (diffMins < 60) return `${diffMins}m ago`;
150
+ if (diffHours < 24) return `${diffHours}h ago`;
151
+ if (diffDays < 7) return `${diffDays}d ago`;
152
+
153
+ return formatDate(dateInput);
154
+ }
155
+
156
+ /**
157
+ * Determine the playing status based on when track was listened
158
+ * @param {string|Date} dateInput - When the track was listened
159
+ * @returns {string|null} - 'now-playing', 'recently-played', or null
160
+ */
161
+ export function getPlayingStatus(dateInput) {
162
+ const date = new Date(dateInput);
163
+ const now = new Date();
164
+ const diffMs = now - date;
165
+ const diffMins = diffMs / 60_000;
166
+
167
+ if (diffMins < 60) {
168
+ return "now-playing";
169
+ }
170
+
171
+ if (diffMins < 24 * 60) {
172
+ return "recently-played";
173
+ }
174
+
175
+ return null;
176
+ }
177
+
178
+ /**
179
+ * Format a listening entry for API response
180
+ * @param {object} listening - Funkwhale listening object or MongoDB document
181
+ * @param {boolean} [fromDb] - Whether the listening is from MongoDB
182
+ * @returns {object} - Formatted listening
183
+ */
184
+ export function formatListening(listening, fromDb = false) {
185
+ if (fromDb) {
186
+ // From MongoDB
187
+ const listenedAt = listening.listenedAt;
188
+ return {
189
+ id: listening.funkwhaleId,
190
+ track: listening.trackTitle,
191
+ artist: listening.artistName,
192
+ album: listening.albumTitle,
193
+ coverUrl: listening.coverUrl,
194
+ trackUrl: listening.trackFid,
195
+ duration: formatDuration(listening.duration),
196
+ durationSeconds: listening.duration,
197
+ listenedAt: listenedAt.toISOString(),
198
+ relativeTime: formatRelativeTime(listenedAt),
199
+ status: getPlayingStatus(listenedAt),
200
+ };
201
+ }
202
+
203
+ // From API
204
+ const track = listening.track;
205
+ const listenedAt = listening.creation_date;
206
+
207
+ return {
208
+ id: listening.id,
209
+ track: track.title,
210
+ artist: getArtistName(track),
211
+ album: track.album?.title || null,
212
+ coverUrl: getCoverUrl(track),
213
+ trackUrl: getTrackUrl(track),
214
+ duration: formatDuration(track.uploads?.[0]?.duration),
215
+ durationSeconds: track.uploads?.[0]?.duration || 0,
216
+ listenedAt,
217
+ relativeTime: formatRelativeTime(listenedAt),
218
+ status: getPlayingStatus(listenedAt),
219
+ };
220
+ }
221
+
222
+ /**
223
+ * Format a favorite entry for API response
224
+ * @param {object} favorite - Funkwhale favorite object
225
+ * @returns {object} - Formatted favorite
226
+ */
227
+ export function formatFavorite(favorite) {
228
+ const track = favorite.track;
229
+
230
+ return {
231
+ id: favorite.id,
232
+ track: track.title,
233
+ artist: getArtistName(track),
234
+ album: track.album?.title || null,
235
+ coverUrl: getCoverUrl(track),
236
+ trackUrl: getTrackUrl(track),
237
+ duration: formatDuration(track.uploads?.[0]?.duration),
238
+ durationSeconds: track.uploads?.[0]?.duration || 0,
239
+ favoritedAt: favorite.creation_date,
240
+ relativeTime: formatRelativeTime(favorite.creation_date),
241
+ };
242
+ }
@@ -0,0 +1,29 @@
1
+ {
2
+ "funkwhale.title": "Funkwhale",
3
+ "funkwhale.listenings": "Listening History",
4
+ "funkwhale.favorites": "Favorites",
5
+ "funkwhale.stats": "Statistics",
6
+ "funkwhale.nowPlaying": "Now Playing",
7
+ "funkwhale.recentlyPlayed": "Recently Played",
8
+ "funkwhale.lastPlayed": "Last Played",
9
+ "funkwhale.allTime": "All Time",
10
+ "funkwhale.thisWeek": "This Week",
11
+ "funkwhale.thisMonth": "This Month",
12
+ "funkwhale.trends": "Trends",
13
+ "funkwhale.listeningTrend": "Listening Trend (30 days)",
14
+ "funkwhale.topArtists": "Top Artists",
15
+ "funkwhale.topAlbums": "Top Albums",
16
+ "funkwhale.plays": "plays",
17
+ "funkwhale.tracks": "tracks",
18
+ "funkwhale.artists": "artists",
19
+ "funkwhale.albums": "albums",
20
+ "funkwhale.listeningTime": "Listening Time",
21
+ "funkwhale.noRecentPlays": "No music played recently",
22
+ "funkwhale.noFavorites": "No favorite tracks yet",
23
+ "funkwhale.viewAll": "View All",
24
+ "funkwhale.viewStats": "View Statistics",
25
+ "funkwhale.error.connection": "Could not connect to Funkwhale",
26
+ "funkwhale.error.noConfig": "Funkwhale endpoint not configured correctly",
27
+ "funkwhale.widget.description": "View your Funkwhale listening activity",
28
+ "funkwhale.widget.view": "View Activity"
29
+ }
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "@rmdes/indiekit-endpoint-funkwhale",
3
+ "version": "1.0.0",
4
+ "description": "Funkwhale listening activity endpoint for Indiekit. Display listening history, favorites, and statistics.",
5
+ "keywords": [
6
+ "indiekit",
7
+ "indiekit-plugin",
8
+ "indieweb",
9
+ "funkwhale",
10
+ "music",
11
+ "scrobble",
12
+ "listening",
13
+ "activity"
14
+ ],
15
+ "homepage": "https://github.com/rmdes/indiekit-endpoint-funkwhale",
16
+ "bugs": {
17
+ "url": "https://github.com/rmdes/indiekit-endpoint-funkwhale/issues"
18
+ },
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/rmdes/indiekit-endpoint-funkwhale.git"
22
+ },
23
+ "author": {
24
+ "name": "Ricardo Mendes",
25
+ "url": "https://rmendes.net"
26
+ },
27
+ "license": "MIT",
28
+ "engines": {
29
+ "node": ">=20"
30
+ },
31
+ "type": "module",
32
+ "main": "index.js",
33
+ "exports": {
34
+ ".": "./index.js"
35
+ },
36
+ "files": [
37
+ "assets",
38
+ "includes",
39
+ "lib",
40
+ "locales",
41
+ "views",
42
+ "index.js"
43
+ ],
44
+ "dependencies": {
45
+ "@indiekit/error": "^1.0.0-beta.25",
46
+ "express": "^5.0.0"
47
+ },
48
+ "peerDependencies": {
49
+ "@indiekit/indiekit": ">=1.0.0-beta.25"
50
+ },
51
+ "publishConfig": {
52
+ "access": "public"
53
+ }
54
+ }
@@ -0,0 +1,60 @@
1
+ {% extends "document.njk" %}
2
+
3
+ {% block content %}
4
+ {% if error %}
5
+ {{ prose({ text: error.message }) }}
6
+ {% else %}
7
+ <section class="funkwhale-section">
8
+ {% if favorites and favorites.length > 0 %}
9
+ <ul class="funkwhale-list funkwhale-list--full">
10
+ {% for favorite in favorites %}
11
+ <li class="funkwhale-list__item">
12
+ {% if favorite.coverUrl %}
13
+ <img src="{{ favorite.coverUrl }}" alt="" class="funkwhale-list__cover" loading="lazy">
14
+ {% else %}
15
+ <div class="funkwhale-list__cover funkwhale-list__cover--placeholder"></div>
16
+ {% endif %}
17
+ <div class="funkwhale-list__info">
18
+ <a href="{{ favorite.trackUrl }}" class="funkwhale-list__title" target="_blank" rel="noopener">
19
+ {{ favorite.artist }} - {{ favorite.track }}
20
+ </a>
21
+ {% if favorite.album %}
22
+ <p class="funkwhale-list__album">{{ favorite.album }}</p>
23
+ {% endif %}
24
+ <small class="funkwhale-meta">
25
+ <span>{{ favorite.duration }}</span>
26
+ <span>Favorited {{ favorite.relativeTime }}</span>
27
+ </small>
28
+ </div>
29
+ </li>
30
+ {% endfor %}
31
+ </ul>
32
+
33
+ {# Pagination #}
34
+ {% if pagination %}
35
+ <nav class="funkwhale-pagination">
36
+ {% if pagination.hasPrev %}
37
+ <a href="?page={{ pagination.current - 1 }}" class="button button--secondary">Previous</a>
38
+ {% endif %}
39
+ <span class="funkwhale-pagination__info">
40
+ Page {{ pagination.current }} of {{ pagination.total }}
41
+ </span>
42
+ {% if pagination.hasNext %}
43
+ <a href="?page={{ pagination.current + 1 }}" class="button button--secondary">Next</a>
44
+ {% endif %}
45
+ </nav>
46
+ {% endif %}
47
+ {% else %}
48
+ {{ prose({ text: __("funkwhale.noFavorites") }) }}
49
+ {% endif %}
50
+ </section>
51
+
52
+ <div class="button-grid">
53
+ {{ button({
54
+ classes: "button--secondary",
55
+ href: mountPath,
56
+ text: "Back to Dashboard"
57
+ }) }}
58
+ </div>
59
+ {% endif %}
60
+ {% endblock %}
@@ -0,0 +1,145 @@
1
+ {% extends "document.njk" %}
2
+
3
+ {% block content %}
4
+ {% if error %}
5
+ {{ prose({ text: error.message }) }}
6
+ {% else %}
7
+ {# Now Playing / Recently Played #}
8
+ {% if nowPlaying %}
9
+ <section class="funkwhale-section funkwhale-now-playing">
10
+ <h2>
11
+ {% if nowPlaying.status == "now-playing" %}
12
+ <span class="funkwhale-status funkwhale-status--playing">
13
+ <span class="funkwhale-bars">
14
+ <span></span><span></span><span></span>
15
+ </span>
16
+ {{ __("funkwhale.nowPlaying") }}
17
+ </span>
18
+ {% else %}
19
+ {{ __("funkwhale.recentlyPlayed") }}
20
+ {% endif %}
21
+ </h2>
22
+ <article class="funkwhale-track funkwhale-track--featured">
23
+ {% if nowPlaying.coverUrl %}
24
+ <img src="{{ nowPlaying.coverUrl }}" alt="" class="funkwhale-track__cover" loading="lazy">
25
+ {% else %}
26
+ <div class="funkwhale-track__cover funkwhale-track__cover--placeholder"></div>
27
+ {% endif %}
28
+ <div class="funkwhale-track__info">
29
+ <a href="{{ nowPlaying.trackUrl }}" class="funkwhale-track__title" target="_blank" rel="noopener">
30
+ {{ nowPlaying.artist }} - {{ nowPlaying.track }}
31
+ </a>
32
+ {% if nowPlaying.album %}
33
+ <p class="funkwhale-track__album">{{ nowPlaying.album }}</p>
34
+ {% endif %}
35
+ <small class="funkwhale-meta">
36
+ <span>{{ nowPlaying.duration }}</span>
37
+ <span>{{ nowPlaying.relativeTime }}</span>
38
+ </small>
39
+ </div>
40
+ </article>
41
+ </section>
42
+ {% endif %}
43
+
44
+ {# Quick Stats Summary #}
45
+ {% if summary %}
46
+ <section class="funkwhale-section funkwhale-summary">
47
+ <h2>{{ __("funkwhale.stats") }}</h2>
48
+ <div class="funkwhale-stats-grid">
49
+ <div class="funkwhale-stat">
50
+ <span class="funkwhale-stat__value">{{ summary.totalPlays }}</span>
51
+ <span class="funkwhale-stat__label">{{ __("funkwhale.plays") }}</span>
52
+ </div>
53
+ <div class="funkwhale-stat">
54
+ <span class="funkwhale-stat__value">{{ summary.uniqueTracks }}</span>
55
+ <span class="funkwhale-stat__label">{{ __("funkwhale.tracks") }}</span>
56
+ </div>
57
+ <div class="funkwhale-stat">
58
+ <span class="funkwhale-stat__value">{{ summary.uniqueArtists }}</span>
59
+ <span class="funkwhale-stat__label">{{ __("funkwhale.artists") }}</span>
60
+ </div>
61
+ </div>
62
+ <div class="button-grid">
63
+ {{ button({
64
+ classes: "button--secondary",
65
+ href: mountPath + "/stats",
66
+ text: __("funkwhale.viewStats")
67
+ }) }}
68
+ </div>
69
+ </section>
70
+ {% endif %}
71
+
72
+ {# Recent Listenings #}
73
+ <section class="funkwhale-section">
74
+ <h2>{{ __("funkwhale.listenings") }}</h2>
75
+ {% if listenings and listenings.length > 0 %}
76
+ <ul class="funkwhale-list">
77
+ {% for listening in listenings %}
78
+ <li class="funkwhale-list__item">
79
+ {% if listening.coverUrl %}
80
+ <img src="{{ listening.coverUrl }}" alt="" class="funkwhale-list__cover" loading="lazy">
81
+ {% else %}
82
+ <div class="funkwhale-list__cover funkwhale-list__cover--placeholder"></div>
83
+ {% endif %}
84
+ <div class="funkwhale-list__info">
85
+ <a href="{{ listening.trackUrl }}" class="funkwhale-list__title" target="_blank" rel="noopener">
86
+ {{ listening.artist }} - {{ listening.track }}
87
+ </a>
88
+ <small class="funkwhale-meta">{{ listening.relativeTime }}</small>
89
+ </div>
90
+ {% if listening.status == "now-playing" %}
91
+ {{ badge({ color: "green", text: __("funkwhale.nowPlaying") }) }}
92
+ {% elif listening.status == "recently-played" %}
93
+ {{ badge({ color: "blue", text: __("funkwhale.recentlyPlayed") }) }}
94
+ {% endif %}
95
+ </li>
96
+ {% endfor %}
97
+ </ul>
98
+ <div class="button-grid">
99
+ {{ button({
100
+ classes: "button--secondary",
101
+ href: mountPath + "/listenings",
102
+ text: __("funkwhale.viewAll")
103
+ }) }}
104
+ </div>
105
+ {% else %}
106
+ {{ prose({ text: __("funkwhale.noRecentPlays") }) }}
107
+ {% endif %}
108
+ </section>
109
+
110
+ {# Favorites #}
111
+ <section class="funkwhale-section">
112
+ <h2>{{ __("funkwhale.favorites") }}</h2>
113
+ {% if favorites and favorites.length > 0 %}
114
+ <ul class="funkwhale-list">
115
+ {% for favorite in favorites %}
116
+ <li class="funkwhale-list__item">
117
+ {% if favorite.coverUrl %}
118
+ <img src="{{ favorite.coverUrl }}" alt="" class="funkwhale-list__cover" loading="lazy">
119
+ {% else %}
120
+ <div class="funkwhale-list__cover funkwhale-list__cover--placeholder"></div>
121
+ {% endif %}
122
+ <div class="funkwhale-list__info">
123
+ <a href="{{ favorite.trackUrl }}" class="funkwhale-list__title" target="_blank" rel="noopener">
124
+ {{ favorite.artist }} - {{ favorite.track }}
125
+ </a>
126
+ {% if favorite.album %}
127
+ <small class="funkwhale-meta">{{ favorite.album }}</small>
128
+ {% endif %}
129
+ </div>
130
+ </li>
131
+ {% endfor %}
132
+ </ul>
133
+ <div class="button-grid">
134
+ {{ button({
135
+ classes: "button--secondary",
136
+ href: mountPath + "/favorites",
137
+ text: __("funkwhale.viewAll")
138
+ }) }}
139
+ </div>
140
+ {% else %}
141
+ {{ prose({ text: __("funkwhale.noFavorites") }) }}
142
+ {% endif %}
143
+ </section>
144
+ {% endif %}
145
+ {% endblock %}
@@ -0,0 +1,65 @@
1
+ {% extends "document.njk" %}
2
+
3
+ {% block content %}
4
+ {% if error %}
5
+ {{ prose({ text: error.message }) }}
6
+ {% else %}
7
+ <section class="funkwhale-section">
8
+ {% if listenings and listenings.length > 0 %}
9
+ <ul class="funkwhale-list funkwhale-list--full">
10
+ {% for listening in listenings %}
11
+ <li class="funkwhale-list__item">
12
+ {% if listening.coverUrl %}
13
+ <img src="{{ listening.coverUrl }}" alt="" class="funkwhale-list__cover" loading="lazy">
14
+ {% else %}
15
+ <div class="funkwhale-list__cover funkwhale-list__cover--placeholder"></div>
16
+ {% endif %}
17
+ <div class="funkwhale-list__info">
18
+ <a href="{{ listening.trackUrl }}" class="funkwhale-list__title" target="_blank" rel="noopener">
19
+ {{ listening.artist }} - {{ listening.track }}
20
+ </a>
21
+ {% if listening.album %}
22
+ <p class="funkwhale-list__album">{{ listening.album }}</p>
23
+ {% endif %}
24
+ <small class="funkwhale-meta">
25
+ <span>{{ listening.duration }}</span>
26
+ <span>{{ listening.relativeTime }}</span>
27
+ </small>
28
+ </div>
29
+ {% if listening.status == "now-playing" %}
30
+ {{ badge({ color: "green", text: __("funkwhale.nowPlaying") }) }}
31
+ {% elif listening.status == "recently-played" %}
32
+ {{ badge({ color: "blue", text: __("funkwhale.recentlyPlayed") }) }}
33
+ {% endif %}
34
+ </li>
35
+ {% endfor %}
36
+ </ul>
37
+
38
+ {# Pagination #}
39
+ {% if pagination %}
40
+ <nav class="funkwhale-pagination">
41
+ {% if pagination.hasPrev %}
42
+ <a href="?page={{ pagination.current - 1 }}" class="button button--secondary">Previous</a>
43
+ {% endif %}
44
+ <span class="funkwhale-pagination__info">
45
+ Page {{ pagination.current }} of {{ pagination.total }}
46
+ </span>
47
+ {% if pagination.hasNext %}
48
+ <a href="?page={{ pagination.current + 1 }}" class="button button--secondary">Next</a>
49
+ {% endif %}
50
+ </nav>
51
+ {% endif %}
52
+ {% else %}
53
+ {{ prose({ text: __("funkwhale.noRecentPlays") }) }}
54
+ {% endif %}
55
+ </section>
56
+
57
+ <div class="button-grid">
58
+ {{ button({
59
+ classes: "button--secondary",
60
+ href: mountPath,
61
+ text: "Back to Dashboard"
62
+ }) }}
63
+ </div>
64
+ {% endif %}
65
+ {% endblock %}
@@ -0,0 +1,18 @@
1
+ <div class="funkwhale-stats-grid">
2
+ <div class="funkwhale-stat">
3
+ <span class="funkwhale-stat__value">{{ summary.totalPlays }}</span>
4
+ <span class="funkwhale-stat__label">{{ __("funkwhale.plays") }}</span>
5
+ </div>
6
+ <div class="funkwhale-stat">
7
+ <span class="funkwhale-stat__value">{{ summary.uniqueTracks }}</span>
8
+ <span class="funkwhale-stat__label">{{ __("funkwhale.tracks") }}</span>
9
+ </div>
10
+ <div class="funkwhale-stat">
11
+ <span class="funkwhale-stat__value">{{ summary.uniqueArtists }}</span>
12
+ <span class="funkwhale-stat__label">{{ __("funkwhale.artists") }}</span>
13
+ </div>
14
+ <div class="funkwhale-stat">
15
+ <span class="funkwhale-stat__value">{{ summary.totalDurationFormatted }}</span>
16
+ <span class="funkwhale-stat__label">{{ __("funkwhale.listeningTime") }}</span>
17
+ </div>
18
+ </div>
@@ -0,0 +1,20 @@
1
+ {% if topAlbums and topAlbums.length > 0 %}
2
+ <ul class="funkwhale-album-grid">
3
+ {% for album in topAlbums %}
4
+ <li class="funkwhale-album">
5
+ {% if album.coverUrl %}
6
+ <img src="{{ album.coverUrl }}" alt="" class="funkwhale-album__cover" loading="lazy">
7
+ {% else %}
8
+ <div class="funkwhale-album__cover funkwhale-album__cover--placeholder"></div>
9
+ {% endif %}
10
+ <div class="funkwhale-album__info">
11
+ <span class="funkwhale-album__title">{{ album.title }}</span>
12
+ <span class="funkwhale-album__artist">{{ album.artist }}</span>
13
+ <small class="funkwhale-meta">{{ album.playCount }} {{ __("funkwhale.plays") }}</small>
14
+ </div>
15
+ </li>
16
+ {% endfor %}
17
+ </ul>
18
+ {% else %}
19
+ {{ prose({ text: "No data yet" }) }}
20
+ {% endif %}
@@ -0,0 +1,13 @@
1
+ {% if topArtists and topArtists.length > 0 %}
2
+ <ol class="funkwhale-top-list">
3
+ {% for artist in topArtists %}
4
+ <li class="funkwhale-top-list__item">
5
+ <span class="funkwhale-top-list__rank">{{ loop.index }}</span>
6
+ <span class="funkwhale-top-list__name">{{ artist.name }}</span>
7
+ <span class="funkwhale-top-list__count">{{ artist.playCount }} {{ __("funkwhale.plays") }}</span>
8
+ </li>
9
+ {% endfor %}
10
+ </ol>
11
+ {% else %}
12
+ {{ prose({ text: "No data yet" }) }}
13
+ {% endif %}