@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 ADDED
@@ -0,0 +1,116 @@
1
+ # @rmdes/indiekit-endpoint-funkwhale
2
+
3
+ Funkwhale listening activity endpoint for [Indiekit](https://getindiekit.com/).
4
+
5
+ Display your Funkwhale listening history, favorite tracks, and listening statistics on your IndieWeb site.
6
+
7
+ ## Features
8
+
9
+ - **Now Playing Widget** - Shows currently playing or recently played tracks
10
+ - **Listening History** - Browse your listening history with album art
11
+ - **Favorites** - Display your favorite tracks
12
+ - **Statistics** - View listening stats with tabbed interface:
13
+ - All Time / This Month / This Week views
14
+ - Top Artists and Albums
15
+ - Listening trend charts
16
+ - **Public JSON API** - For integration with static site generators like Eleventy
17
+
18
+ ## Installation
19
+
20
+ ```bash
21
+ npm install @rmdes/indiekit-endpoint-funkwhale
22
+ ```
23
+
24
+ ## Configuration
25
+
26
+ Add to your `indiekit.config.js`:
27
+
28
+ ```javascript
29
+ export default {
30
+ plugins: [
31
+ "@rmdes/indiekit-endpoint-funkwhale",
32
+ ],
33
+
34
+ "@rmdes/indiekit-endpoint-funkwhale": {
35
+ mountPath: "/funkwhale",
36
+ instanceUrl: process.env.FUNKWHALE_INSTANCE,
37
+ username: process.env.FUNKWHALE_USERNAME,
38
+ token: process.env.FUNKWHALE_TOKEN,
39
+ cacheTtl: 900_000, // 15 minutes
40
+ syncInterval: 300_000, // 5 minutes
41
+ limits: {
42
+ listenings: 20,
43
+ favorites: 20,
44
+ topArtists: 10,
45
+ topAlbums: 10
46
+ }
47
+ },
48
+ };
49
+ ```
50
+
51
+ ## Environment Variables
52
+
53
+ | Variable | Required | Description |
54
+ |----------|----------|-------------|
55
+ | `FUNKWHALE_INSTANCE` | Yes | Your Funkwhale instance URL (e.g., `https://funkwhale.example.com`) |
56
+ | `FUNKWHALE_TOKEN` | Yes | API access token (Bearer token) |
57
+ | `FUNKWHALE_USERNAME` | Yes | Your username on the Funkwhale instance |
58
+
59
+ ### Getting an API Token
60
+
61
+ 1. Log in to your Funkwhale instance
62
+ 2. Go to Settings > Applications
63
+ 3. Create a new application with read permissions
64
+ 4. Copy the access token
65
+
66
+ ## Routes
67
+
68
+ ### Protected Routes (require authentication)
69
+
70
+ | Route | Description |
71
+ |-------|-------------|
72
+ | `GET /funkwhale/` | Dashboard with overview |
73
+ | `GET /funkwhale/listenings` | Full listening history |
74
+ | `GET /funkwhale/favorites` | Favorite tracks |
75
+ | `GET /funkwhale/stats` | Statistics with tabs |
76
+
77
+ ### Public API Routes (JSON)
78
+
79
+ | Route | Description |
80
+ |-------|-------------|
81
+ | `GET /funkwhale/api/now-playing` | Current/recent track |
82
+ | `GET /funkwhale/api/listenings` | Recent listenings |
83
+ | `GET /funkwhale/api/favorites` | Favorites list |
84
+ | `GET /funkwhale/api/stats` | All statistics |
85
+ | `GET /funkwhale/api/stats/trends` | Trend data for charts |
86
+
87
+ ## Options
88
+
89
+ | Option | Default | Description |
90
+ |--------|---------|-------------|
91
+ | `mountPath` | `/funkwhale` | URL path for the endpoint |
92
+ | `instanceUrl` | - | Funkwhale instance URL |
93
+ | `token` | - | API access token |
94
+ | `username` | - | User to track |
95
+ | `cacheTtl` | `900000` | Cache TTL in ms (15 min) |
96
+ | `syncInterval` | `300000` | Background sync interval in ms (5 min) |
97
+ | `limits.listenings` | `20` | Listenings per page |
98
+ | `limits.favorites` | `20` | Favorites per page |
99
+ | `limits.topArtists` | `10` | Top artists to show |
100
+ | `limits.topAlbums` | `10` | Top albums to show |
101
+
102
+ ## Now Playing Logic
103
+
104
+ - **Now Playing**: Track listened to within the last 60 minutes
105
+ - **Recently Played**: Track listened to within the last 24 hours
106
+ - **Last Played**: Older tracks show timestamp only
107
+
108
+ ## Requirements
109
+
110
+ - Indiekit >= 1.0.0-beta.25
111
+ - MongoDB (for statistics aggregation)
112
+ - Funkwhale instance with API v2
113
+
114
+ ## License
115
+
116
+ MIT
@@ -0,0 +1,453 @@
1
+ /* Funkwhale Endpoint Styles */
2
+
3
+ /* Sections */
4
+ .funkwhale-section {
5
+ margin-block-end: 2rem;
6
+ }
7
+
8
+ .funkwhale-section h2 {
9
+ margin-block-end: 1rem;
10
+ }
11
+
12
+ /* Now Playing Animation */
13
+ .funkwhale-status {
14
+ display: inline-flex;
15
+ align-items: center;
16
+ gap: 0.5rem;
17
+ }
18
+
19
+ .funkwhale-status--playing {
20
+ color: var(--color-accent, #22c55e);
21
+ }
22
+
23
+ .funkwhale-bars {
24
+ display: flex;
25
+ gap: 2px;
26
+ align-items: flex-end;
27
+ height: 16px;
28
+ }
29
+
30
+ .funkwhale-bars span {
31
+ width: 3px;
32
+ background: currentColor;
33
+ animation: funkwhale-bar 0.5s infinite ease-in-out alternate;
34
+ }
35
+
36
+ .funkwhale-bars span:nth-child(2) {
37
+ animation-delay: 0.2s;
38
+ }
39
+
40
+ .funkwhale-bars span:nth-child(3) {
41
+ animation-delay: 0.4s;
42
+ }
43
+
44
+ @keyframes funkwhale-bar {
45
+ from { height: 4px; }
46
+ to { height: 16px; }
47
+ }
48
+
49
+ /* Featured Track */
50
+ .funkwhale-track--featured {
51
+ display: flex;
52
+ gap: 1rem;
53
+ padding: 1rem;
54
+ background: var(--color-offset, #f5f5f5);
55
+ border-radius: 0.5rem;
56
+ }
57
+
58
+ .funkwhale-track__cover {
59
+ width: 80px;
60
+ height: 80px;
61
+ object-fit: cover;
62
+ border-radius: 0.25rem;
63
+ }
64
+
65
+ .funkwhale-track__cover--placeholder {
66
+ background: var(--color-border, #ddd);
67
+ }
68
+
69
+ .funkwhale-track__info {
70
+ flex: 1;
71
+ display: flex;
72
+ flex-direction: column;
73
+ gap: 0.25rem;
74
+ }
75
+
76
+ .funkwhale-track__title {
77
+ font-weight: 600;
78
+ text-decoration: none;
79
+ color: var(--color-text, inherit);
80
+ }
81
+
82
+ .funkwhale-track__title:hover {
83
+ text-decoration: underline;
84
+ }
85
+
86
+ .funkwhale-track__album {
87
+ margin: 0;
88
+ color: var(--color-text-muted, #666);
89
+ font-size: 0.875rem;
90
+ }
91
+
92
+ /* Stats Grid */
93
+ .funkwhale-stats-grid {
94
+ display: grid;
95
+ grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
96
+ gap: 1rem;
97
+ margin-block-end: 1rem;
98
+ }
99
+
100
+ .funkwhale-stat {
101
+ display: flex;
102
+ flex-direction: column;
103
+ align-items: center;
104
+ padding: 1rem;
105
+ background: var(--color-offset, #f5f5f5);
106
+ border-radius: 0.5rem;
107
+ text-align: center;
108
+ }
109
+
110
+ .funkwhale-stat__value {
111
+ font-size: 1.5rem;
112
+ font-weight: 700;
113
+ color: var(--color-accent, #3b82f6);
114
+ }
115
+
116
+ .funkwhale-stat__label {
117
+ font-size: 0.75rem;
118
+ color: var(--color-text-muted, #666);
119
+ text-transform: uppercase;
120
+ letter-spacing: 0.05em;
121
+ }
122
+
123
+ /* List */
124
+ .funkwhale-list {
125
+ list-style: none;
126
+ padding: 0;
127
+ margin: 0;
128
+ }
129
+
130
+ .funkwhale-list__item {
131
+ display: flex;
132
+ align-items: center;
133
+ gap: 0.75rem;
134
+ padding: 0.75rem 0;
135
+ border-bottom: 1px solid var(--color-border, #eee);
136
+ }
137
+
138
+ .funkwhale-list__item:last-child {
139
+ border-bottom: none;
140
+ }
141
+
142
+ .funkwhale-list__cover {
143
+ width: 48px;
144
+ height: 48px;
145
+ object-fit: cover;
146
+ border-radius: 0.25rem;
147
+ flex-shrink: 0;
148
+ }
149
+
150
+ .funkwhale-list__cover--placeholder {
151
+ background: var(--color-border, #ddd);
152
+ }
153
+
154
+ .funkwhale-list__info {
155
+ flex: 1;
156
+ min-width: 0;
157
+ }
158
+
159
+ .funkwhale-list__title {
160
+ display: block;
161
+ font-weight: 500;
162
+ text-decoration: none;
163
+ color: var(--color-text, inherit);
164
+ white-space: nowrap;
165
+ overflow: hidden;
166
+ text-overflow: ellipsis;
167
+ }
168
+
169
+ .funkwhale-list__title:hover {
170
+ text-decoration: underline;
171
+ }
172
+
173
+ .funkwhale-list__album {
174
+ margin: 0;
175
+ font-size: 0.875rem;
176
+ color: var(--color-text-muted, #666);
177
+ }
178
+
179
+ .funkwhale-meta {
180
+ display: flex;
181
+ gap: 0.5rem;
182
+ color: var(--color-text-muted, #666);
183
+ font-size: 0.75rem;
184
+ }
185
+
186
+ /* Pagination */
187
+ .funkwhale-pagination {
188
+ display: flex;
189
+ align-items: center;
190
+ justify-content: center;
191
+ gap: 1rem;
192
+ margin-block: 1.5rem;
193
+ }
194
+
195
+ .funkwhale-pagination__info {
196
+ color: var(--color-text-muted, #666);
197
+ }
198
+
199
+ /* Tabs */
200
+ .funkwhale-tabs {
201
+ display: flex;
202
+ gap: 0.25rem;
203
+ margin-block-end: 1.5rem;
204
+ border-bottom: 1px solid var(--color-border, #ddd);
205
+ }
206
+
207
+ .funkwhale-tab {
208
+ padding: 0.75rem 1rem;
209
+ border: none;
210
+ background: none;
211
+ color: var(--color-text-muted, #666);
212
+ cursor: pointer;
213
+ border-bottom: 2px solid transparent;
214
+ margin-bottom: -1px;
215
+ font-size: 0.875rem;
216
+ font-weight: 500;
217
+ }
218
+
219
+ .funkwhale-tab:hover {
220
+ color: var(--color-text, inherit);
221
+ }
222
+
223
+ .funkwhale-tab--active {
224
+ color: var(--color-accent, #3b82f6);
225
+ border-bottom-color: var(--color-accent, #3b82f6);
226
+ }
227
+
228
+ .funkwhale-tab-content {
229
+ display: none;
230
+ }
231
+
232
+ .funkwhale-tab-content--active {
233
+ display: block;
234
+ }
235
+
236
+ /* Top List */
237
+ .funkwhale-top-list {
238
+ list-style: none;
239
+ padding: 0;
240
+ margin: 0 0 1.5rem 0;
241
+ counter-reset: rank;
242
+ }
243
+
244
+ .funkwhale-top-list__item {
245
+ display: flex;
246
+ align-items: center;
247
+ gap: 0.75rem;
248
+ padding: 0.5rem 0;
249
+ border-bottom: 1px solid var(--color-border, #eee);
250
+ }
251
+
252
+ .funkwhale-top-list__rank {
253
+ width: 1.5rem;
254
+ height: 1.5rem;
255
+ display: flex;
256
+ align-items: center;
257
+ justify-content: center;
258
+ font-size: 0.75rem;
259
+ font-weight: 600;
260
+ background: var(--color-offset, #f5f5f5);
261
+ border-radius: 50%;
262
+ color: var(--color-text-muted, #666);
263
+ }
264
+
265
+ .funkwhale-top-list__name {
266
+ flex: 1;
267
+ font-weight: 500;
268
+ }
269
+
270
+ .funkwhale-top-list__count {
271
+ font-size: 0.875rem;
272
+ color: var(--color-text-muted, #666);
273
+ }
274
+
275
+ /* Album Grid */
276
+ .funkwhale-album-grid {
277
+ display: grid;
278
+ grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
279
+ gap: 1rem;
280
+ list-style: none;
281
+ padding: 0;
282
+ margin: 0 0 1.5rem 0;
283
+ }
284
+
285
+ .funkwhale-album {
286
+ display: flex;
287
+ flex-direction: column;
288
+ gap: 0.5rem;
289
+ }
290
+
291
+ .funkwhale-album__cover {
292
+ width: 100%;
293
+ aspect-ratio: 1;
294
+ object-fit: cover;
295
+ border-radius: 0.25rem;
296
+ }
297
+
298
+ .funkwhale-album__cover--placeholder {
299
+ background: var(--color-border, #ddd);
300
+ }
301
+
302
+ .funkwhale-album__info {
303
+ display: flex;
304
+ flex-direction: column;
305
+ gap: 0.125rem;
306
+ }
307
+
308
+ .funkwhale-album__title {
309
+ font-weight: 500;
310
+ font-size: 0.875rem;
311
+ white-space: nowrap;
312
+ overflow: hidden;
313
+ text-overflow: ellipsis;
314
+ }
315
+
316
+ .funkwhale-album__artist {
317
+ font-size: 0.75rem;
318
+ color: var(--color-text-muted, #666);
319
+ white-space: nowrap;
320
+ overflow: hidden;
321
+ text-overflow: ellipsis;
322
+ }
323
+
324
+ /* Trend Chart */
325
+ .funkwhale-chart {
326
+ margin: 1rem 0;
327
+ }
328
+
329
+ .funkwhale-chart__bars {
330
+ display: flex;
331
+ align-items: flex-end;
332
+ gap: 2px;
333
+ height: 120px;
334
+ padding: 0.5rem 0;
335
+ }
336
+
337
+ .funkwhale-chart__bar-wrapper {
338
+ flex: 1;
339
+ height: 100%;
340
+ display: flex;
341
+ align-items: flex-end;
342
+ }
343
+
344
+ .funkwhale-chart__bar {
345
+ width: 100%;
346
+ background: var(--color-accent, #3b82f6);
347
+ border-radius: 2px 2px 0 0;
348
+ min-height: 2px;
349
+ transition: height 0.3s ease;
350
+ }
351
+
352
+ .funkwhale-chart__bar-wrapper:hover .funkwhale-chart__bar {
353
+ background: var(--color-accent-hover, #2563eb);
354
+ }
355
+
356
+ .funkwhale-chart__labels {
357
+ display: flex;
358
+ justify-content: space-between;
359
+ font-size: 0.75rem;
360
+ color: var(--color-text-muted, #666);
361
+ margin-top: 0.5rem;
362
+ }
363
+
364
+ /* Widget Styles (for embeddable widgets) */
365
+ .funkwhale-widget {
366
+ display: flex;
367
+ flex-wrap: wrap;
368
+ align-items: center;
369
+ gap: 0.5rem;
370
+ padding: 0.75rem;
371
+ background: var(--color-offset, #f5f5f5);
372
+ border-radius: 0.5rem;
373
+ }
374
+
375
+ .funkwhale-widget--playing {
376
+ background: linear-gradient(135deg, #22c55e10, #22c55e05);
377
+ border: 1px solid #22c55e30;
378
+ }
379
+
380
+ .funkwhale-widget__status {
381
+ font-size: 0.625rem;
382
+ text-transform: uppercase;
383
+ letter-spacing: 0.1em;
384
+ color: var(--color-text-muted, #666);
385
+ width: 100%;
386
+ }
387
+
388
+ .funkwhale-widget--playing .funkwhale-widget__status {
389
+ color: #22c55e;
390
+ }
391
+
392
+ .funkwhale-widget__cover {
393
+ width: 48px;
394
+ height: 48px;
395
+ object-fit: cover;
396
+ border-radius: 0.25rem;
397
+ }
398
+
399
+ .funkwhale-widget__info {
400
+ flex: 1;
401
+ min-width: 0;
402
+ }
403
+
404
+ .funkwhale-widget__title {
405
+ display: block;
406
+ font-weight: 500;
407
+ font-size: 0.875rem;
408
+ text-decoration: none;
409
+ color: var(--color-text, inherit);
410
+ white-space: nowrap;
411
+ overflow: hidden;
412
+ text-overflow: ellipsis;
413
+ }
414
+
415
+ .funkwhale-widget__time {
416
+ font-size: 0.75rem;
417
+ color: var(--color-text-muted, #666);
418
+ }
419
+
420
+ .funkwhale-widget__empty,
421
+ .funkwhale-widget__error {
422
+ font-size: 0.875rem;
423
+ color: var(--color-text-muted, #666);
424
+ margin: 0;
425
+ }
426
+
427
+ /* Stats Widget */
428
+ .funkwhale-stats-widget__grid {
429
+ display: grid;
430
+ grid-template-columns: repeat(3, 1fr);
431
+ gap: 0.5rem;
432
+ }
433
+
434
+ .funkwhale-stats-widget__stat {
435
+ display: flex;
436
+ flex-direction: column;
437
+ align-items: center;
438
+ padding: 0.5rem;
439
+ background: var(--color-offset, #f5f5f5);
440
+ border-radius: 0.25rem;
441
+ }
442
+
443
+ .funkwhale-stats-widget__value {
444
+ font-size: 1.25rem;
445
+ font-weight: 700;
446
+ color: var(--color-accent, #3b82f6);
447
+ }
448
+
449
+ .funkwhale-stats-widget__label {
450
+ font-size: 0.625rem;
451
+ color: var(--color-text-muted, #666);
452
+ text-transform: uppercase;
453
+ }
@@ -0,0 +1,83 @@
1
+ {#
2
+ Now Playing Widget
3
+ Fetches data from /funkwhale/api/now-playing
4
+ Include this in your Eleventy templates
5
+ #}
6
+ <div class="funkwhale-now-playing-widget" id="funkwhale-now-playing">
7
+ <div class="funkwhale-now-playing-widget__loading">
8
+ Loading...
9
+ </div>
10
+ </div>
11
+
12
+ <script>
13
+ (function() {
14
+ const container = document.getElementById('funkwhale-now-playing');
15
+ const endpoint = '{{ application.funkwhaleEndpoint or "/funkwhale" }}/api/now-playing';
16
+
17
+ fetch(endpoint)
18
+ .then(res => res.json())
19
+ .then(data => {
20
+ container.textContent = '';
21
+
22
+ if (!data.track) {
23
+ const empty = document.createElement('p');
24
+ empty.className = 'funkwhale-widget__empty';
25
+ empty.textContent = 'No recent plays';
26
+ container.appendChild(empty);
27
+ return;
28
+ }
29
+
30
+ const widget = document.createElement('div');
31
+ widget.className = 'funkwhale-widget' + (data.status === 'now-playing' ? ' funkwhale-widget--playing' : '');
32
+
33
+ if (data.status === 'now-playing') {
34
+ const bars = document.createElement('div');
35
+ bars.className = 'funkwhale-bars';
36
+ for (let i = 0; i < 3; i++) {
37
+ bars.appendChild(document.createElement('span'));
38
+ }
39
+ widget.appendChild(bars);
40
+ }
41
+
42
+ const status = document.createElement('span');
43
+ status.className = 'funkwhale-widget__status';
44
+ status.textContent = data.status === 'now-playing' ? 'Now Playing' :
45
+ data.status === 'recently-played' ? 'Recently Played' : 'Last Played';
46
+ widget.appendChild(status);
47
+
48
+ if (data.coverUrl) {
49
+ const img = document.createElement('img');
50
+ img.src = data.coverUrl;
51
+ img.alt = '';
52
+ img.className = 'funkwhale-widget__cover';
53
+ widget.appendChild(img);
54
+ }
55
+
56
+ const info = document.createElement('div');
57
+ info.className = 'funkwhale-widget__info';
58
+
59
+ const link = document.createElement('a');
60
+ link.href = data.trackUrl;
61
+ link.className = 'funkwhale-widget__title';
62
+ link.target = '_blank';
63
+ link.rel = 'noopener';
64
+ link.textContent = data.artist + ' - ' + data.track;
65
+ info.appendChild(link);
66
+
67
+ const time = document.createElement('span');
68
+ time.className = 'funkwhale-widget__time';
69
+ time.textContent = data.relativeTime;
70
+ info.appendChild(time);
71
+
72
+ widget.appendChild(info);
73
+ container.appendChild(widget);
74
+ })
75
+ .catch(err => {
76
+ container.textContent = '';
77
+ const error = document.createElement('p');
78
+ error.className = 'funkwhale-widget__error';
79
+ error.textContent = 'Could not load';
80
+ container.appendChild(error);
81
+ });
82
+ })();
83
+ </script>
@@ -0,0 +1,75 @@
1
+ {#
2
+ Stats Widget
3
+ Fetches data from /funkwhale/api/stats
4
+ Include this in your Eleventy templates for a sidebar widget
5
+ #}
6
+ <div class="funkwhale-stats-widget" id="funkwhale-stats">
7
+ <div class="funkwhale-stats-widget__loading">
8
+ Loading stats...
9
+ </div>
10
+ </div>
11
+
12
+ <script>
13
+ (function() {
14
+ const container = document.getElementById('funkwhale-stats');
15
+ const endpoint = '{{ application.funkwhaleEndpoint or "/funkwhale" }}/api/stats';
16
+
17
+ fetch(endpoint)
18
+ .then(res => res.json())
19
+ .then(data => {
20
+ container.textContent = '';
21
+ const all = data.summary?.all || {};
22
+
23
+ const grid = document.createElement('div');
24
+ grid.className = 'funkwhale-stats-widget__grid';
25
+
26
+ // Plays stat
27
+ const playsStat = document.createElement('div');
28
+ playsStat.className = 'funkwhale-stats-widget__stat';
29
+ const playsValue = document.createElement('span');
30
+ playsValue.className = 'funkwhale-stats-widget__value';
31
+ playsValue.textContent = all.totalPlays || 0;
32
+ const playsLabel = document.createElement('span');
33
+ playsLabel.className = 'funkwhale-stats-widget__label';
34
+ playsLabel.textContent = 'plays';
35
+ playsStat.appendChild(playsValue);
36
+ playsStat.appendChild(playsLabel);
37
+ grid.appendChild(playsStat);
38
+
39
+ // Artists stat
40
+ const artistsStat = document.createElement('div');
41
+ artistsStat.className = 'funkwhale-stats-widget__stat';
42
+ const artistsValue = document.createElement('span');
43
+ artistsValue.className = 'funkwhale-stats-widget__value';
44
+ artistsValue.textContent = all.uniqueArtists || 0;
45
+ const artistsLabel = document.createElement('span');
46
+ artistsLabel.className = 'funkwhale-stats-widget__label';
47
+ artistsLabel.textContent = 'artists';
48
+ artistsStat.appendChild(artistsValue);
49
+ artistsStat.appendChild(artistsLabel);
50
+ grid.appendChild(artistsStat);
51
+
52
+ // Duration stat
53
+ const durationStat = document.createElement('div');
54
+ durationStat.className = 'funkwhale-stats-widget__stat';
55
+ const durationValue = document.createElement('span');
56
+ durationValue.className = 'funkwhale-stats-widget__value';
57
+ durationValue.textContent = all.totalDurationFormatted || '0h';
58
+ const durationLabel = document.createElement('span');
59
+ durationLabel.className = 'funkwhale-stats-widget__label';
60
+ durationLabel.textContent = 'listened';
61
+ durationStat.appendChild(durationValue);
62
+ durationStat.appendChild(durationLabel);
63
+ grid.appendChild(durationStat);
64
+
65
+ container.appendChild(grid);
66
+ })
67
+ .catch(err => {
68
+ container.textContent = '';
69
+ const error = document.createElement('p');
70
+ error.className = 'funkwhale-widget__error';
71
+ error.textContent = 'Could not load stats';
72
+ container.appendChild(error);
73
+ });
74
+ })();
75
+ </script>