From 1180d6f1e09be10d24539a8489f9eb0ce221537b Mon Sep 17 00:00:00 2001 From: Florian <45694132+flo-bit@users.noreply.github.com> Date: Sat, 7 Feb 2026 01:29:08 +0100 Subject: [PATCH] lastfm cards --- src/lib/cards/index.ts | 10 +- .../LastFMCard/CreateLastFMCardModal.svelte | 60 ++++++ .../media/LastFMCard/LastFMAlbumArt.svelte | 50 +++++ .../LastFMCard/LastFMPeriodSettings.svelte | 36 ++++ .../LastFMProfileCard.svelte | 113 +++++++++++ .../LastFMCard/LastFMProfileCard/index.ts | 40 ++++ .../LastFMRecentTracksCard.svelte | 103 ++++++++++ .../LastFMRecentTracksCard/index.ts | 70 +++++++ .../LastFMTopAlbumsCard.svelte | 103 ++++++++++ .../LastFMTopAlbumsCardSettings.svelte | 63 ++++++ .../LastFMCard/LastFMTopAlbumsCard/index.ts | 47 +++++ .../LastFMTopTracksCard.svelte | 117 ++++++++++++ .../LastFMCard/LastFMTopTracksCard/index.ts | 45 +++++ .../GitHubContributorsCard.svelte | 149 ++------------- .../NpmxLikesLeaderboardCard.svelte | 2 +- src/lib/components/ImageGrid.svelte | 180 ++++++++++++++++++ src/lib/website/ThemeScript.svelte | 19 +- src/routes/+layout.svelte | 5 +- src/routes/api/lastfm/+server.ts | 89 +++++++++ 19 files changed, 1161 insertions(+), 140 deletions(-) create mode 100644 src/lib/cards/media/LastFMCard/CreateLastFMCardModal.svelte create mode 100644 src/lib/cards/media/LastFMCard/LastFMAlbumArt.svelte create mode 100644 src/lib/cards/media/LastFMCard/LastFMPeriodSettings.svelte create mode 100644 src/lib/cards/media/LastFMCard/LastFMProfileCard/LastFMProfileCard.svelte create mode 100644 src/lib/cards/media/LastFMCard/LastFMProfileCard/index.ts create mode 100644 src/lib/cards/media/LastFMCard/LastFMRecentTracksCard/LastFMRecentTracksCard.svelte create mode 100644 src/lib/cards/media/LastFMCard/LastFMRecentTracksCard/index.ts create mode 100644 src/lib/cards/media/LastFMCard/LastFMTopAlbumsCard/LastFMTopAlbumsCard.svelte create mode 100644 src/lib/cards/media/LastFMCard/LastFMTopAlbumsCard/LastFMTopAlbumsCardSettings.svelte create mode 100644 src/lib/cards/media/LastFMCard/LastFMTopAlbumsCard/index.ts create mode 100644 src/lib/cards/media/LastFMCard/LastFMTopTracksCard/LastFMTopTracksCard.svelte create mode 100644 src/lib/cards/media/LastFMCard/LastFMTopTracksCard/index.ts create mode 100644 src/lib/components/ImageGrid.svelte create mode 100644 src/routes/api/lastfm/+server.ts diff --git a/src/lib/cards/index.ts b/src/lib/cards/index.ts index 7e3b663..5e24deb 100644 --- a/src/lib/cards/index.ts +++ b/src/lib/cards/index.ts @@ -42,6 +42,10 @@ import { ProductHuntCardDefinition } from './social/ProductHuntCard'; import { KickstarterCardDefinition } from './social/KickstarterCard'; import { NpmxLikesCardDefinition } from './social/NpmxLikesCard'; import { NpmxLikesLeaderboardCardDefinition } from './social/NpmxLikesLeaderboardCard'; +import { LastFMRecentTracksCardDefinition } from './media/LastFMCard/LastFMRecentTracksCard'; +import { LastFMTopTracksCardDefinition } from './media/LastFMCard/LastFMTopTracksCard'; +import { LastFMTopAlbumsCardDefinition } from './media/LastFMCard/LastFMTopAlbumsCard'; +import { LastFMProfileCardDefinition } from './media/LastFMCard/LastFMProfileCard'; // import { Model3DCardDefinition } from './visual/Model3DCard'; export const AllCardDefinitions = [ @@ -88,7 +92,11 @@ export const AllCardDefinitions = [ ProductHuntCardDefinition, KickstarterCardDefinition, NpmxLikesCardDefinition, - NpmxLikesLeaderboardCardDefinition + NpmxLikesLeaderboardCardDefinition, + LastFMRecentTracksCardDefinition, + LastFMTopTracksCardDefinition, + LastFMTopAlbumsCardDefinition, + LastFMProfileCardDefinition ] as const; export const CardDefinitionsByType = AllCardDefinitions.reduce( diff --git a/src/lib/cards/media/LastFMCard/CreateLastFMCardModal.svelte b/src/lib/cards/media/LastFMCard/CreateLastFMCardModal.svelte new file mode 100644 index 0000000..1ee2ed2 --- /dev/null +++ b/src/lib/cards/media/LastFMCard/CreateLastFMCardModal.svelte @@ -0,0 +1,60 @@ + + + +
{ + let input = item.cardData.href?.trim(); + if (!input) return; + + let username: string | undefined; + + try { + const parsed = new URL(input); + if (/^(www\.)?last\.fm$/.test(parsed.hostname)) { + const segments = parsed.pathname.split('/').filter(Boolean); + if (segments.length >= 2 && segments[0] === 'user') { + username = segments[1]; + } + } + } catch { + if (/^[a-zA-Z0-9_-]{2,15}$/.test(input)) { + username = input; + } + } + + if (!username) { + errorMessage = 'Please enter a valid Last.fm username or profile URL'; + return; + } + + item.cardData.lastfmUsername = username; + item.cardData.href = `https://www.last.fm/user/${username}`; + + oncreate?.(); + }} + class="flex flex-col gap-2" + > + Enter a Last.fm username or profile URL + + + {#if errorMessage} +

{errorMessage}

+ {/if} + +
+ + +
+
+
diff --git a/src/lib/cards/media/LastFMCard/LastFMAlbumArt.svelte b/src/lib/cards/media/LastFMCard/LastFMAlbumArt.svelte new file mode 100644 index 0000000..eb9ed16 --- /dev/null +++ b/src/lib/cards/media/LastFMCard/LastFMAlbumArt.svelte @@ -0,0 +1,50 @@ + + +{#if !imageUrl || hasError} +
+ + + +
+{:else} + {#if isLoading} +
+ {/if} + (isLoading = false)} + onerror={() => { + isLoading = false; + hasError = true; + }} + /> +{/if} diff --git a/src/lib/cards/media/LastFMCard/LastFMPeriodSettings.svelte b/src/lib/cards/media/LastFMCard/LastFMPeriodSettings.svelte new file mode 100644 index 0000000..ca4f034 --- /dev/null +++ b/src/lib/cards/media/LastFMCard/LastFMPeriodSettings.svelte @@ -0,0 +1,36 @@ + + +
+ +
+ {#each periodOptions as opt (opt.value)} + + {/each} +
+
diff --git a/src/lib/cards/media/LastFMCard/LastFMProfileCard/LastFMProfileCard.svelte b/src/lib/cards/media/LastFMCard/LastFMProfileCard/LastFMProfileCard.svelte new file mode 100644 index 0000000..c1fa2e1 --- /dev/null +++ b/src/lib/cards/media/LastFMCard/LastFMProfileCard/LastFMProfileCard.svelte @@ -0,0 +1,113 @@ + + +
+
+
+
+ {@html siLastdotfm.svg} +
+ + {item.cardData.lastfmUsername} + +
+ + {#if userInfo} +
+ {#if avatarUrl} + {userInfo.name} + {/if} +
+
+ {parseInt(userInfo.playcount).toLocaleString()} scrobbles +
+ {#if memberSince} +
+ Since {memberSince} +
+ {/if} +
+
+ {:else} +
Loading profile...
+ {/if} +
+
+ +{#if !isEditing} + + View on Last.fm + +{/if} diff --git a/src/lib/cards/media/LastFMCard/LastFMProfileCard/index.ts b/src/lib/cards/media/LastFMCard/LastFMProfileCard/index.ts new file mode 100644 index 0000000..6bfe2b3 --- /dev/null +++ b/src/lib/cards/media/LastFMCard/LastFMProfileCard/index.ts @@ -0,0 +1,40 @@ +import type { CardDefinition } from '../../../types'; +import CreateLastFMCardModal from '../CreateLastFMCardModal.svelte'; +import LastFMProfileCard from './LastFMProfileCard.svelte'; + +export const LastFMProfileCardDefinition = { + type: 'lastfmProfile', + contentComponent: LastFMProfileCard, + creationModalComponent: CreateLastFMCardModal, + createNew: (card) => { + card.w = 4; + card.mobileW = 8; + card.h = 2; + card.mobileH = 3; + }, + loadData: async (items) => { + const allData: Record = {}; + for (const item of items) { + const username = item.cardData.lastfmUsername; + if (!username) continue; + try { + const response = await fetch( + `https://blento.app/api/lastfm?method=user.getInfo&user=${encodeURIComponent(username)}` + ); + if (response.ok) { + const result = await response.json(); + allData[`lastfmProfile:${username}`] = result?.user; + } + } catch (error) { + console.error('Failed to fetch Last.fm profile:', error); + } + } + return allData; + }, + minW: 2, + minH: 2, + name: 'Last.fm Profile', + keywords: ['music', 'scrobble', 'profile', 'lastfm', 'last.fm'], + groups: ['Media'], + icon: `` +} as CardDefinition & { type: 'lastfmProfile' }; diff --git a/src/lib/cards/media/LastFMCard/LastFMRecentTracksCard/LastFMRecentTracksCard.svelte b/src/lib/cards/media/LastFMCard/LastFMRecentTracksCard/LastFMRecentTracksCard.svelte new file mode 100644 index 0000000..8f99b86 --- /dev/null +++ b/src/lib/cards/media/LastFMCard/LastFMRecentTracksCard/LastFMRecentTracksCard.svelte @@ -0,0 +1,103 @@ + + +
+ {#if tracks && tracks.length > 0} + {#each tracks as track, i (track.url + i)} + +
+ +
+
+
+
+ {track.name} +
+ {#if track['@attr']?.nowplaying === 'true'} +
+ + Now +
+ {:else if track.date?.uts} +
+ ago +
+ {/if} +
+
+ {track.artist?.['#text']} +
+
+
+ {/each} + {:else if error} +
+ Failed to load tracks. +
+ {:else if tracks} +
+ No recent tracks found. +
+ {:else} +
+ Loading tracks... +
+ {/if} +
diff --git a/src/lib/cards/media/LastFMCard/LastFMRecentTracksCard/index.ts b/src/lib/cards/media/LastFMCard/LastFMRecentTracksCard/index.ts new file mode 100644 index 0000000..574c004 --- /dev/null +++ b/src/lib/cards/media/LastFMCard/LastFMRecentTracksCard/index.ts @@ -0,0 +1,70 @@ +import type { CardDefinition } from '../../../types'; +import CreateLastFMCardModal from '../CreateLastFMCardModal.svelte'; +import LastFMRecentTracksCard from './LastFMRecentTracksCard.svelte'; + +export const LastFMRecentTracksCardDefinition = { + type: 'lastfmRecentTracks', + contentComponent: LastFMRecentTracksCard, + creationModalComponent: CreateLastFMCardModal, + createNew: (card) => { + card.w = 4; + card.mobileW = 8; + card.h = 3; + card.mobileH = 6; + }, + loadData: async (items) => { + const allData: Record = {}; + for (const item of items) { + const username = item.cardData.lastfmUsername; + if (!username) continue; + try { + const response = await fetch( + `https://blento.app/api/lastfm?method=user.getRecentTracks&user=${encodeURIComponent(username)}&limit=50` + ); + if (response.ok) { + const result = await response.json(); + allData[`lastfmRecentTracks:${username}`] = result?.recenttracks?.track ?? []; + } + } catch (error) { + console.error('Failed to fetch Last.fm recent tracks:', error); + } + } + return allData; + }, + onUrlHandler: (url, item) => { + const username = getLastFMUsername(url); + if (!username) return null; + + item.cardData.lastfmUsername = username; + item.cardData.href = `https://www.last.fm/user/${username}`; + item.w = 4; + item.mobileW = 8; + item.h = 3; + item.mobileH = 6; + item.cardType = 'lastfmRecentTracks'; + return item; + }, + urlHandlerPriority: 5, + minW: 3, + minH: 2, + canHaveLabel: true, + name: 'Last.fm Recent Tracks', + keywords: ['music', 'scrobble', 'listening', 'songs', 'lastfm', 'last.fm'], + groups: ['Media'], + icon: `` +} as CardDefinition & { type: 'lastfmRecentTracks' }; + +function getLastFMUsername(url: string | undefined): string | undefined { + if (!url) return; + try { + const parsed = new URL(url); + if (!/^(www\.)?last\.fm$/.test(parsed.hostname)) return undefined; + const segments = parsed.pathname.split('/').filter(Boolean); + if (segments.length >= 2 && segments[0] === 'user') { + return segments[1]; + } + return undefined; + } catch { + return undefined; + } +} diff --git a/src/lib/cards/media/LastFMCard/LastFMTopAlbumsCard/LastFMTopAlbumsCard.svelte b/src/lib/cards/media/LastFMCard/LastFMTopAlbumsCard/LastFMTopAlbumsCard.svelte new file mode 100644 index 0000000..ad09996 --- /dev/null +++ b/src/lib/cards/media/LastFMCard/LastFMTopAlbumsCard/LastFMTopAlbumsCard.svelte @@ -0,0 +1,103 @@ + + +{#if error} +
+ + Failed to load albums. + +
+{:else if albums && gridItems.length > 0} + +{:else if loading || !albums} +
+ + Loading albums... + +
+{:else} +
+ + No top albums found. + +
+{/if} diff --git a/src/lib/cards/media/LastFMCard/LastFMTopAlbumsCard/LastFMTopAlbumsCardSettings.svelte b/src/lib/cards/media/LastFMCard/LastFMTopAlbumsCard/LastFMTopAlbumsCardSettings.svelte new file mode 100644 index 0000000..45941dc --- /dev/null +++ b/src/lib/cards/media/LastFMCard/LastFMTopAlbumsCard/LastFMTopAlbumsCardSettings.svelte @@ -0,0 +1,63 @@ + + +
+
+ +
+ {#each periodOptions as opt (opt.value)} + + {/each} +
+
+ +
+ +
+ {#each layoutOptions as opt (opt.value)} + + {/each} +
+
+
diff --git a/src/lib/cards/media/LastFMCard/LastFMTopAlbumsCard/index.ts b/src/lib/cards/media/LastFMCard/LastFMTopAlbumsCard/index.ts new file mode 100644 index 0000000..bef86e0 --- /dev/null +++ b/src/lib/cards/media/LastFMCard/LastFMTopAlbumsCard/index.ts @@ -0,0 +1,47 @@ +import type { CardDefinition } from '../../../types'; +import CreateLastFMCardModal from '../CreateLastFMCardModal.svelte'; +import LastFMTopAlbumsCard from './LastFMTopAlbumsCard.svelte'; +import LastFMTopAlbumsCardSettings from './LastFMTopAlbumsCardSettings.svelte'; + +export const LastFMTopAlbumsCardDefinition = { + type: 'lastfmTopAlbums', + contentComponent: LastFMTopAlbumsCard, + creationModalComponent: CreateLastFMCardModal, + settingsComponent: LastFMTopAlbumsCardSettings, + createNew: (card) => { + card.w = 4; + card.h = 3; + card.mobileW = 8; + card.mobileH = 4; + card.cardData.period = '7day'; + }, + loadData: async (items) => { + const allData: Record = {}; + for (const item of items) { + const username = item.cardData.lastfmUsername; + const period = item.cardData.period ?? '7day'; + if (!username) continue; + try { + const response = await fetch( + `https://blento.app/api/lastfm?method=user.getTopAlbums&user=${encodeURIComponent(username)}&period=${period}&limit=50` + ); + if (response.ok) { + const result = await response.json(); + allData[`lastfmTopAlbums:${username}:${period}`] = result?.topalbums?.album ?? []; + } + } catch (error) { + console.error('Failed to fetch Last.fm top albums:', error); + } + } + return allData; + }, + allowSetColor: true, + defaultColor: 'base', + minW: 2, + minH: 2, + canHaveLabel: true, + name: 'Last.fm Top Albums', + keywords: ['music', 'scrobble', 'albums', 'lastfm', 'last.fm', 'top'], + groups: ['Media'], + icon: `` +} as CardDefinition & { type: 'lastfmTopAlbums' }; diff --git a/src/lib/cards/media/LastFMCard/LastFMTopTracksCard/LastFMTopTracksCard.svelte b/src/lib/cards/media/LastFMCard/LastFMTopTracksCard/LastFMTopTracksCard.svelte new file mode 100644 index 0000000..52320a9 --- /dev/null +++ b/src/lib/cards/media/LastFMCard/LastFMTopTracksCard/LastFMTopTracksCard.svelte @@ -0,0 +1,117 @@ + + +
+ {#if tracks && tracks.length > 0} + {#each tracks as track, i (track.url)} + +
+ {i + 1} +
+
+ +
+
+
+
+ {track.name} +
+
+ {parseInt(track.playcount).toLocaleString()} plays +
+
+
+ {track.artist?.name} +
+
+
+ {/each} + {:else if error} +
+ Failed to load tracks. +
+ {:else if tracks || loading} +
+ {tracks?.length === 0 ? 'No top tracks found.' : 'Loading tracks...'} +
+ {:else} +
+ Loading tracks... +
+ {/if} +
diff --git a/src/lib/cards/media/LastFMCard/LastFMTopTracksCard/index.ts b/src/lib/cards/media/LastFMCard/LastFMTopTracksCard/index.ts new file mode 100644 index 0000000..a82debd --- /dev/null +++ b/src/lib/cards/media/LastFMCard/LastFMTopTracksCard/index.ts @@ -0,0 +1,45 @@ +import type { CardDefinition } from '../../../types'; +import CreateLastFMCardModal from '../CreateLastFMCardModal.svelte'; +import LastFMPeriodSettings from '../LastFMPeriodSettings.svelte'; +import LastFMTopTracksCard from './LastFMTopTracksCard.svelte'; + +export const LastFMTopTracksCardDefinition = { + type: 'lastfmTopTracks', + contentComponent: LastFMTopTracksCard, + creationModalComponent: CreateLastFMCardModal, + settingsComponent: LastFMPeriodSettings, + createNew: (card) => { + card.w = 4; + card.mobileW = 8; + card.h = 3; + card.mobileH = 6; + card.cardData.period = '7day'; + }, + loadData: async (items) => { + const allData: Record = {}; + for (const item of items) { + const username = item.cardData.lastfmUsername; + const period = item.cardData.period ?? '7day'; + if (!username) continue; + try { + const response = await fetch( + `https://blento.app/api/lastfm?method=user.getTopTracks&user=${encodeURIComponent(username)}&period=${period}&limit=50` + ); + if (response.ok) { + const result = await response.json(); + allData[`lastfmTopTracks:${username}:${period}`] = result?.toptracks?.track ?? []; + } + } catch (error) { + console.error('Failed to fetch Last.fm top tracks:', error); + } + } + return allData; + }, + minW: 3, + minH: 2, + canHaveLabel: true, + name: 'Last.fm Top Tracks', + keywords: ['music', 'scrobble', 'songs', 'lastfm', 'last.fm', 'top'], + groups: ['Media'], + icon: `` +} as CardDefinition & { type: 'lastfmTopTracks' }; diff --git a/src/lib/cards/social/GitHubContributorsCard/GitHubContributorsCard.svelte b/src/lib/cards/social/GitHubContributorsCard/GitHubContributorsCard.svelte index 1113881..68b6d31 100644 --- a/src/lib/cards/social/GitHubContributorsCard/GitHubContributorsCard.svelte +++ b/src/lib/cards/social/GitHubContributorsCard/GitHubContributorsCard.svelte @@ -1,12 +1,12 @@ -
- {#if !owner || !repo} - {#if canEdit()} +{#if !owner || !repo} + {#if canEdit()} +
Enter a repository - {/if} - {:else if totalItems > 0} -
-
- {#each rows as row, rowIdx (rowIdx)} - - {/each} -
{/if} -
+{:else} + +{/if} diff --git a/src/lib/cards/social/NpmxLikesLeaderboardCard/NpmxLikesLeaderboardCard.svelte b/src/lib/cards/social/NpmxLikesLeaderboardCard/NpmxLikesLeaderboardCard.svelte index ef532b9..9e2a440 100644 --- a/src/lib/cards/social/NpmxLikesLeaderboardCard/NpmxLikesLeaderboardCard.svelte +++ b/src/lib/cards/social/NpmxLikesLeaderboardCard/NpmxLikesLeaderboardCard.svelte @@ -91,7 +91,7 @@ {/each}
+ import { Tooltip } from 'bits-ui'; + + export type ImageGridItem = { + imageUrl: string | null; + link: string; + label: string; + }; + + let { + items, + layout = 'grid', + shape = 'square', + tooltip = false + }: { + items: ImageGridItem[]; + layout?: 'grid' | 'cinema'; + shape?: 'square' | 'circle'; + tooltip?: boolean; + } = $props(); + + let containerWidth = $state(0); + let containerHeight = $state(0); + + let totalItems = $derived(items.length); + + const GAP = 6; + const MIN_SIZE = 16; + const MAX_SIZE = 120; + + function cinemaCapacity(size: number, availW: number, availH: number): number { + const colsWide = Math.floor((availW + GAP) / (size + GAP)); + if (colsWide < 1) return 0; + const colsNarrow = Math.max(1, colsWide - 1); + const maxRows = Math.floor((availH + GAP) / (size + GAP)); + let capacity = 0; + for (let r = 0; r < maxRows; r++) { + capacity += r % 2 === 0 ? colsNarrow : colsWide; + } + return capacity; + } + + function gridCapacity(size: number, availW: number, availH: number): number { + const cols = Math.floor((availW + GAP) / (size + GAP)); + const rows = Math.floor((availH + GAP) / (size + GAP)); + return cols * rows; + } + + let computedSize = $derived.by(() => { + if (!containerWidth || !containerHeight || totalItems === 0) return 40; + + let lo = MIN_SIZE; + let hi = MAX_SIZE; + const capacityFn = layout === 'cinema' ? cinemaCapacity : gridCapacity; + + while (lo <= hi) { + const mid = Math.floor((lo + hi) / 2); + const availW = containerWidth - (layout === 'cinema' ? mid / 2 : 0); + const availH = containerHeight - (layout === 'cinema' ? mid / 2 : 0); + if (availW <= 0 || availH <= 0) { + hi = mid - 1; + continue; + } + if (capacityFn(mid, availW, availH) >= totalItems) { + lo = mid + 1; + } else { + hi = mid - 1; + } + } + + return Math.max(MIN_SIZE, hi); + }); + + let padding = $derived(layout === 'cinema' ? computedSize / 4 : 0); + + let rows = $derived.by(() => { + const availW = containerWidth - (layout === 'cinema' ? computedSize / 4 : 0); + if (availW <= 0) return [] as ImageGridItem[][]; + + const colsWide = Math.floor((availW + GAP) / (computedSize + GAP)); + const colsNarrow = layout === 'cinema' ? Math.max(1, colsWide - 1) : colsWide; + + const rowSizes: number[] = []; + let remaining = items.length; + let rowNum = 0; + while (remaining > 0) { + const cols = layout === 'cinema' && rowNum % 2 === 0 ? colsNarrow : colsWide; + rowSizes.push(Math.min(cols, remaining)); + remaining -= cols; + rowNum++; + } + rowSizes.reverse(); + + const result: ImageGridItem[][] = []; + let idx = 0; + for (const size of rowSizes) { + result.push(items.slice(idx, idx + size)); + idx += size; + } + return result; + }); + + let textSize = $derived( + computedSize < 24 ? 'text-[10px]' : computedSize < 40 ? 'text-xs' : 'text-sm' + ); + + let shapeClass = $derived(shape === 'circle' ? 'rounded-full' : 'rounded-lg'); + + +{#snippet gridItem(item: ImageGridItem)} + {#if item.imageUrl} + {item.label} + {:else} +
+ + {item.label.charAt(0).toUpperCase()} + +
+ {/if} +{/snippet} + +
+ {#if totalItems > 0} +
+
+ {#each rows as row, rowIdx (rowIdx)} +
+ {#each row as item (item.link)} + {#if tooltip} + + + + {@render gridItem(item)} + + + + + {item.label} + + + + {:else} + + {@render gridItem(item)} + + {/if} + {/each} +
+ {/each} +
+
+ {/if} +
diff --git a/src/lib/website/ThemeScript.svelte b/src/lib/website/ThemeScript.svelte index 0c1c9ab..04922f5 100644 --- a/src/lib/website/ThemeScript.svelte +++ b/src/lib/website/ThemeScript.svelte @@ -10,8 +10,23 @@ } = $props(); const allAccentColors = [ - 'red', 'orange', 'amber', 'yellow', 'lime', 'green', 'emerald', 'teal', - 'cyan', 'sky', 'blue', 'indigo', 'violet', 'purple', 'fuchsia', 'pink', 'rose' + 'red', + 'orange', + 'amber', + 'yellow', + 'lime', + 'green', + 'emerald', + 'teal', + 'cyan', + 'sky', + 'blue', + 'indigo', + 'violet', + 'purple', + 'fuchsia', + 'pink', + 'rose' ]; const allBaseColors = ['gray', 'stone', 'zinc', 'neutral', 'slate']; diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index f76916d..4153bfe 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -1,6 +1,7 @@ -{@render children()} + + {@render children()} + diff --git a/src/routes/api/lastfm/+server.ts b/src/routes/api/lastfm/+server.ts new file mode 100644 index 0000000..522330f --- /dev/null +++ b/src/routes/api/lastfm/+server.ts @@ -0,0 +1,89 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { env } from '$env/dynamic/private'; + +const LASTFM_API_URL = 'https://ws.audioscrobbler.com/2.0/'; + +const ALLOWED_METHODS = [ + 'user.getRecentTracks', + 'user.getTopTracks', + 'user.getTopAlbums', + 'user.getInfo' +]; + +const CACHE_TTL: Record = { + 'user.getRecentTracks': 15 * 60 * 1000, + 'user.getTopTracks': 60 * 60 * 1000, + 'user.getTopAlbums': 60 * 60 * 1000, + 'user.getInfo': 12 * 60 * 60 * 1000 +}; + +export const GET: RequestHandler = async ({ url, platform }) => { + const method = url.searchParams.get('method'); + const user = url.searchParams.get('user'); + const period = url.searchParams.get('period') || '7day'; + const limit = url.searchParams.get('limit') || '50'; + + if (!method || !user) { + return json({ error: 'Missing method or user parameter' }, { status: 400 }); + } + + if (!ALLOWED_METHODS.includes(method)) { + return json({ error: 'Method not allowed' }, { status: 400 }); + } + + const cacheKey = `#lastfm:${method}:${user}:${period}:${limit}`; + const cachedData = await platform?.env?.USER_DATA_CACHE?.get(cacheKey); + + if (cachedData) { + const parsed = JSON.parse(cachedData); + const ttl = CACHE_TTL[method] || 60 * 60 * 1000; + + if (Date.now() - (parsed._cachedAt || 0) < ttl) { + return json(parsed); + } + } + + const apiKey = env?.LASTFM_API_KEY; + if (!apiKey) { + return json({ error: 'Last.fm API key not configured' }, { status: 500 }); + } + + try { + const params = new URLSearchParams({ + method, + user, + api_key: apiKey, + format: 'json', + limit + }); + + if (method === 'user.getTopTracks' || method === 'user.getTopAlbums') { + params.set('period', period); + } + + const response = await fetch(`${LASTFM_API_URL}?${params}`); + + if (!response.ok) { + return json( + { error: 'Failed to fetch Last.fm data: ' + response.statusText }, + { status: response.status } + ); + } + + const data = await response.json(); + + if (data.error) { + return json({ error: data.message || 'Last.fm API error' }, { status: 400 }); + } + + data._cachedAt = Date.now(); + + await platform?.env?.USER_DATA_CACHE?.put(cacheKey, JSON.stringify(data)); + + return json(data); + } catch (error) { + console.error('Error fetching Last.fm data:', error); + return json({ error: 'Failed to fetch Last.fm data' }, { status: 500 }); + } +}; -- 2.51.2