+152
-28
src/App.tsx
+152
-28
src/App.tsx
···
1
-
import { type Component, createResource, For, Suspense, Show } from "solid-js";
1
+
import {
2
+
type Component,
3
+
createResource,
4
+
createSignal,
5
+
For,
6
+
Show,
7
+
Suspense,
8
+
} from "solid-js";
9
+
10
+
export type Settings = {
11
+
username: string;
12
+
range: Range;
13
+
};
2
14
3
15
export default () => {
4
-
const [artistRes] = createResource<ArtistStats>(() => artists("karitham"));
5
-
const [groupsRes] = createResource<ReleaseGroupsStats>(() =>
6
-
release_groups("karitham"),
16
+
const [settings, setSettings] = createSignal<Settings>({
17
+
username: "karitham",
18
+
range: "all_time",
19
+
});
20
+
const [artistRes] = createResource<ArtistStats, Settings>(settings, (set) =>
21
+
artists(set.username, undefined, set.range),
22
+
);
23
+
const [groupsRes] = createResource<ReleaseGroupsStats, Settings>(
24
+
settings,
25
+
(set) => releaseGroups(set.username, undefined, set.range),
7
26
);
8
27
9
28
return (
10
-
<Suspense fallback={<p>Loading...</p>}>
11
-
<Artists artists={artistRes()?.artists || []} />
12
-
<ReleaseGroups groups={groupsRes()?.release_groups || []} />
13
-
</Suspense>
29
+
<div class="bg-black min-h-screen text-white p-8">
30
+
<h1 class="text-4xl font-bold mb-8 text-center">
31
+
Your ListenBrainz Stats
32
+
</h1>
33
+
<div class="flex flex-col sm:flex-row items-center justify-center gap-4 mb-8">
34
+
<input
35
+
type="text"
36
+
value={settings().username}
37
+
onChange={(e) => {
38
+
setSettings(() => ({
39
+
...settings(),
40
+
username: e.target.value,
41
+
}));
42
+
}}
43
+
placeholder="Enter username"
44
+
class="p-4 rounded-lg bg-gray-800 text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500 w-full sm:w-auto"
45
+
/>
46
+
<select
47
+
value={settings().range}
48
+
onChange={(e) => {
49
+
setSettings(() => ({
50
+
...settings(),
51
+
range: e.target.value as Range,
52
+
}));
53
+
}}
54
+
class="p-4 rounded-lg bg-gray-800 text-white focus:outline-none focus:ring-2 focus:ring-green-500 w-full sm:w-auto"
55
+
>
56
+
{ranges.map((r) => (
57
+
<option value={r}>{r}</option>
58
+
))}
59
+
</select>
60
+
</div>
61
+
<Suspense fallback={<p class="text-center text-gray-400">Loading...</p>}>
62
+
<h2 class="text-2xl font-semibold my-6 text-center">Top Artists</h2>
63
+
<Artists artists={artistRes()?.artists || []} />
64
+
<h2 class="text-2xl font-semibold my-6 text-center">Top Albums</h2>
65
+
<ReleaseGroups groups={groupsRes()?.release_groups || []} />
66
+
</Suspense>
67
+
</div>
14
68
);
15
69
};
16
70
17
71
const Artists: Component<{ artists: ArtistStatsArtist[] }> = (props) => {
18
72
return (
19
-
<div class="flex flex-wrap justify-center">
73
+
<div class="flex flex-wrap justify-center gap-4">
20
74
<For each={props.artists}>
21
75
{(artist) => <ArtistsItem artist={artist} />}
22
76
</For>
···
30
84
return null;
31
85
}
32
86
33
-
return await getArtistImageURL(props.artist.artist_mbid);
87
+
return await getWikidataURL(props.artist.artist_mbid).then((s) =>
88
+
s ? getWikidataThumbnail(s) : null,
89
+
);
34
90
});
35
91
36
92
return (
37
-
<div class="flex flex-col items-center p-4 m-2 bg-gray-200 rounded-lg shadow-lg">
93
+
<div class="group relative w-40 p-4 rounded-lg bg-gray-900 transition-all duration-300 hover:bg-gray-800 cursor-pointer">
38
94
<Show
39
95
when={!image.loading}
40
96
fallback={
41
-
<div class="w-32 h-32 bg-gray-300 rounded-md mb-6 animate-pulse"></div>
97
+
<div class="relative w-32 h-32 mx-auto rounded-full mb-4 bg-gray-700 animate-pulse"></div>
42
98
}
43
99
>
44
100
<img
45
-
src={image() || "/fallback"}
101
+
src={
102
+
image() ||
103
+
`https://placehold.co/1000x1000/000000/ffffff?text=${props.artist.artist_name}`
104
+
}
46
105
alt={`Thumbnail for ${props.artist.artist_name}`}
47
-
class="w-32 h-32 object-cover rounded-md mb-6 transition-transform duration-300 hover:scale-105"
106
+
class="w-32 h-32 mx-auto object-cover rounded-full shadow-lg mb-4 transition-all duration-300 group-hover:scale-105"
48
107
/>
49
108
</Show>
50
-
<p class="text-black text-center font-bold truncate w-32">
109
+
<p class="text-white text-center font-bold text-base truncate mb-1">
51
110
{props.artist.artist_name}
52
111
</p>
53
-
<p class="text-gray-800 text-sm">{props.artist.listen_count} listens</p>
112
+
<p class="text-gray-500 text-base truncate mb-1">
113
+
{props.artist.listen_count} listens
114
+
</p>
54
115
</div>
55
116
);
56
117
};
57
118
58
119
const ReleaseGroups: Component<{ groups: ReleaseGroupsGroup[] }> = (props) => {
59
120
return (
60
-
<div class="flex flex-wrap justify-center">
121
+
<div class="flex flex-wrap justify-center gap-4">
61
122
<For each={props.groups}>
62
123
{(group) => <ReleaseGroupItem group={group} />}
63
124
</For>
···
74
135
"release",
75
136
props.group.caa_release_mbid,
76
137
);
77
-
return result[0]?.image;
138
+
return result[0]?.image.replace("http://", "https://");
78
139
});
79
140
80
141
return (
81
-
<div class="flex flex-col items-center p-4 m-2 bg-gray-200 rounded-lg shadow-lg">
142
+
<div class="group relative w-40 p-4 rounded-lg bg-gray-900 transition-all duration-300 hover:bg-gray-800 cursor-pointer">
82
143
<Show
83
144
when={!image.loading}
84
145
fallback={
85
-
<div class="w-32 h-32 bg-gray-300 rounded-md mb-6 animate-pulse"></div>
146
+
<div class="relative w-32 h-32 mx-auto rounded-full mb-4 bg-gray-700 animate-pulse"></div>
86
147
}
87
148
>
88
149
<img
89
-
src={image() || "/fallback"}
150
+
src={
151
+
image() ||
152
+
`https://placehold.co/1000x1000/000000/ffffff?text=${props.group.release_group_name}`
153
+
}
90
154
alt={`Cover for ${props.group.release_group_name}`}
91
-
class="w-32 h-32 object-cover rounded-md mb-6 transition-transform duration-300 hover:scale-105"
155
+
class="w-32 h-32 mx-auto object-cover rounded-full shadow-lg mb-4 transition-all duration-300 group-hover:scale-105"
92
156
/>
93
157
</Show>
94
-
<p class="text-black text-center font-bold truncate w-32">
158
+
<p class="text-white text-center font-bold text-base truncate mb-1">
95
159
{props.group.release_group_name}
96
160
</p>
97
-
<p class="text-gray-800 text-sm">{props.group.listen_count} listens</p>
161
+
<p class="text-gray-500 text-base truncate mb-1">
162
+
{props.group.listen_count} listens
163
+
</p>
98
164
</div>
99
165
);
100
166
};
···
103
169
const COVERARTARCHIVE_URL = "https://coverartarchive.org";
104
170
const MB_API_URL = "https://musicbrainz.org";
105
171
106
-
export type Range = "this_week" | "this_month" | "this_year" | "all_time";
172
+
const ranges = ["this_week", "this_month", "this_year", "all_time"] as const;
173
+
export type Range = (typeof ranges)[number];
107
174
108
175
export type ArtistStats = {
109
176
artists: ArtistStatsArtist[];
···
167
234
return getPayload(url);
168
235
}
169
236
170
-
async function release_groups(
237
+
async function releaseGroups(
171
238
user: string,
172
239
offset: number = 0,
173
240
range: Range = "this_week",
···
214
281
small: string;
215
282
}
216
283
284
+
const defaultHeaders = {
285
+
"User-Agent": "listenframe/0.1",
286
+
};
287
+
217
288
async function getReleaseImageURL(
218
289
kind: "release" | "release-group",
219
290
mbid: string,
···
231
302
return (data as { images: Image[] }).images;
232
303
}
233
304
234
-
async function getArtistImageURL(mbid: string): Promise<string | null> {
305
+
async function getWikidataURL(mbid: string): Promise<string | null> {
235
306
const url = new URL(`${MB_API_URL}/ws/2/artist/${mbid}`);
236
307
237
308
url.searchParams.set("inc", "url-rels");
···
246
317
247
318
const doc = parser.parseFromString(await response.text(), "text/xml");
248
319
249
-
return doc?.querySelector("type=image")?.textContent || null;
320
+
return doc?.querySelector("[type=wikidata] > target")?.textContent || null;
321
+
}
322
+
323
+
async function getWikidataThumbnail(
324
+
wikidataUrl: string,
325
+
): Promise<string | null> {
326
+
try {
327
+
const urlParts = wikidataUrl.split("/");
328
+
const wikidataId = urlParts[urlParts.length - 1];
329
+
if (!wikidataId || !wikidataId.startsWith("Q")) {
330
+
console.error("Invalid Wikidata URL.");
331
+
return null;
332
+
}
333
+
334
+
const wikidataApiUrl = `https://www.wikidata.org/w/api.php?action=wbgetentities&ids=${wikidataId}&props=claims&format=json&origin=*`;
335
+
const wikidataResponse = await fetch(wikidataApiUrl, {
336
+
headers: defaultHeaders,
337
+
});
338
+
const wikidataData = await wikidataResponse.json();
339
+
340
+
const claims = wikidataData.entities[wikidataId]?.claims;
341
+
const imageClaim = claims?.P18?.[0]; // P18 is the property ID for 'image'
342
+
343
+
if (!imageClaim) {
344
+
console.log("No image found for this Wikidata item.");
345
+
return null;
346
+
}
347
+
348
+
const imageFilename = imageClaim.mainsnak.datavalue.value;
349
+
if (!imageFilename) {
350
+
console.error("Could not extract image filename.");
351
+
return null;
352
+
}
353
+
354
+
const commonsApiUrl = `https://commons.wikimedia.org/w/api.php?action=query&titles=File:${imageFilename}&prop=imageinfo&iiprop=url&iiurlwidth=300&format=json&origin=*`;
355
+
const commonsResponse = await fetch(commonsApiUrl, {
356
+
headers: defaultHeaders,
357
+
});
358
+
const commonsData = await commonsResponse.json();
359
+
360
+
const pages = commonsData.query.pages;
361
+
const pageId = Object.keys(pages)[0];
362
+
const imageUrl = pages[pageId]?.imageinfo?.[0]?.thumburl;
363
+
364
+
if (!imageUrl) {
365
+
console.error("Could not find image URL.");
366
+
return null;
367
+
}
368
+
369
+
return imageUrl;
370
+
} catch (error) {
371
+
console.error("An error occurred:", error);
372
+
return null;
373
+
}
250
374
}