@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/README.md +116 -0
- package/assets/styles.css +453 -0
- package/includes/@indiekit-endpoint-funkwhale-now-playing.njk +83 -0
- package/includes/@indiekit-endpoint-funkwhale-stats.njk +75 -0
- package/includes/@indiekit-endpoint-funkwhale-widget.njk +12 -0
- package/index.js +108 -0
- package/lib/controllers/dashboard.js +122 -0
- package/lib/controllers/favorites.js +110 -0
- package/lib/controllers/listenings.js +109 -0
- package/lib/controllers/now-playing.js +58 -0
- package/lib/controllers/stats.js +138 -0
- package/lib/funkwhale-client.js +187 -0
- package/lib/stats.js +228 -0
- package/lib/sync.js +160 -0
- package/lib/utils.js +242 -0
- package/locales/en.json +29 -0
- package/package.json +54 -0
- package/views/favorites.njk +60 -0
- package/views/funkwhale.njk +145 -0
- package/views/listenings.njk +65 -0
- package/views/partials/stats-summary.njk +18 -0
- package/views/partials/top-albums.njk +20 -0
- package/views/partials/top-artists.njk +13 -0
- package/views/stats.njk +130 -0
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
|
+
}
|
package/locales/en.json
ADDED
|
@@ -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 %}
|