your personal website on atproto - mirror blento.app

Compare changes

Choose any two refs to compare.

+1178 -140
+9 -1
src/lib/cards/index.ts
··· 42 42 import { KickstarterCardDefinition } from './social/KickstarterCard'; 43 43 import { NpmxLikesCardDefinition } from './social/NpmxLikesCard'; 44 44 import { NpmxLikesLeaderboardCardDefinition } from './social/NpmxLikesLeaderboardCard'; 45 + import { LastFMRecentTracksCardDefinition } from './media/LastFMCard/LastFMRecentTracksCard'; 46 + import { LastFMTopTracksCardDefinition } from './media/LastFMCard/LastFMTopTracksCard'; 47 + import { LastFMTopAlbumsCardDefinition } from './media/LastFMCard/LastFMTopAlbumsCard'; 48 + import { LastFMProfileCardDefinition } from './media/LastFMCard/LastFMProfileCard'; 45 49 // import { Model3DCardDefinition } from './visual/Model3DCard'; 46 50 47 51 export const AllCardDefinitions = [ ··· 88 92 ProductHuntCardDefinition, 89 93 KickstarterCardDefinition, 90 94 NpmxLikesCardDefinition, 91 - NpmxLikesLeaderboardCardDefinition 95 + NpmxLikesLeaderboardCardDefinition, 96 + LastFMRecentTracksCardDefinition, 97 + LastFMTopTracksCardDefinition, 98 + LastFMTopAlbumsCardDefinition, 99 + LastFMProfileCardDefinition 92 100 ] as const; 93 101 94 102 export const CardDefinitionsByType = AllCardDefinitions.reduce(
+60
src/lib/cards/media/LastFMCard/CreateLastFMCardModal.svelte
··· 1 + <script lang="ts"> 2 + import { Button, Input, Modal, Subheading } from '@foxui/core'; 3 + import type { CreationModalComponentProps } from '../../types'; 4 + 5 + let { item = $bindable(), oncreate, oncancel }: CreationModalComponentProps = $props(); 6 + 7 + let errorMessage = $state(''); 8 + </script> 9 + 10 + <Modal open={true} closeButton={false}> 11 + <form 12 + onsubmit={() => { 13 + let input = item.cardData.href?.trim(); 14 + if (!input) return; 15 + 16 + let username: string | undefined; 17 + 18 + try { 19 + const parsed = new URL(input); 20 + if (/^(www\.)?last\.fm$/.test(parsed.hostname)) { 21 + const segments = parsed.pathname.split('/').filter(Boolean); 22 + if (segments.length >= 2 && segments[0] === 'user') { 23 + username = segments[1]; 24 + } 25 + } 26 + } catch { 27 + if (/^[a-zA-Z0-9_-]{2,15}$/.test(input)) { 28 + username = input; 29 + } 30 + } 31 + 32 + if (!username) { 33 + errorMessage = 'Please enter a valid Last.fm username or profile URL'; 34 + return; 35 + } 36 + 37 + item.cardData.lastfmUsername = username; 38 + item.cardData.href = `https://www.last.fm/user/${username}`; 39 + 40 + oncreate?.(); 41 + }} 42 + class="flex flex-col gap-2" 43 + > 44 + <Subheading>Enter a Last.fm username or profile URL</Subheading> 45 + <Input 46 + bind:value={item.cardData.href} 47 + placeholder="username or https://www.last.fm/user/username" 48 + class="mt-4" 49 + /> 50 + 51 + {#if errorMessage} 52 + <p class="mt-2 text-sm text-red-600">{errorMessage}</p> 53 + {/if} 54 + 55 + <div class="mt-4 flex justify-end gap-2"> 56 + <Button onclick={oncancel} variant="ghost">Cancel</Button> 57 + <Button type="submit">Create</Button> 58 + </div> 59 + </form> 60 + </Modal>
+50
src/lib/cards/media/LastFMCard/LastFMAlbumArt.svelte
··· 1 + <script lang="ts"> 2 + let { 3 + images, 4 + alt = '', 5 + size = 'medium' 6 + }: { images?: { '#text': string; size: string }[]; alt?: string; size?: string } = $props(); 7 + 8 + let isLoading = $state(true); 9 + let hasError = $state(false); 10 + 11 + const imageUrl = $derived.by(() => { 12 + if (!images || images.length === 0) return ''; 13 + const preferred = ['extralarge', 'large', 'medium', 'small']; 14 + for (const pref of preferred) { 15 + const img = images.find((i) => i.size === pref); 16 + if (img?.['#text']) return img['#text']; 17 + } 18 + return images[images.length - 1]?.['#text'] || ''; 19 + }); 20 + </script> 21 + 22 + {#if !imageUrl || hasError} 23 + <div 24 + class="bg-base-200 dark:bg-base-700 accent:bg-accent-700/50 flex h-full w-full items-center justify-center rounded-lg" 25 + > 26 + <svg 27 + class="text-base-500 dark:text-base-400 accent:text-accent-200 h-5 w-5" 28 + fill="currentColor" 29 + viewBox="0 0 20 20" 30 + > 31 + <path 32 + d="M18 3a1 1 0 00-1.196-.98l-10 2A1 1 0 006 5v9.114A4.369 4.369 0 005 14c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V7.82l8-1.6v5.894A4.37 4.37 0 0015 12c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V3z" 33 + /> 34 + </svg> 35 + </div> 36 + {:else} 37 + {#if isLoading} 38 + <div class="bg-base-200 dark:bg-base-800 h-full w-full animate-pulse rounded-lg"></div> 39 + {/if} 40 + <img 41 + src={imageUrl} 42 + {alt} 43 + class="h-full w-full rounded-lg object-cover {isLoading && 'hidden'}" 44 + onload={() => (isLoading = false)} 45 + onerror={() => { 46 + isLoading = false; 47 + hasError = true; 48 + }} 49 + /> 50 + {/if}
+36
src/lib/cards/media/LastFMCard/LastFMPeriodSettings.svelte
··· 1 + <script lang="ts"> 2 + import type { SettingsComponentProps } from '../../types'; 3 + import { Label } from '@foxui/core'; 4 + 5 + let { item = $bindable() }: SettingsComponentProps = $props(); 6 + 7 + const periodOptions = [ 8 + { value: '7day', label: '7 Days' }, 9 + { value: '1month', label: '1 Month' }, 10 + { value: '3month', label: '3 Months' }, 11 + { value: '6month', label: '6 Months' }, 12 + { value: '12month', label: '12 Months' }, 13 + { value: 'overall', label: 'All Time' } 14 + ]; 15 + 16 + let period = $derived(item.cardData.period ?? '7day'); 17 + </script> 18 + 19 + <div class="flex flex-col gap-2"> 20 + <Label>Time Period</Label> 21 + <div class="flex flex-wrap gap-2"> 22 + {#each periodOptions as opt (opt.value)} 23 + <button 24 + class={[ 25 + 'rounded-xl border px-3 py-2 text-sm transition-colors', 26 + period === opt.value 27 + ? 'bg-accent-500 border-accent-500 text-white' 28 + : 'bg-base-100 dark:bg-base-800 border-base-300 dark:border-base-700 text-base-900 dark:text-base-100 hover:border-accent-400' 29 + ]} 30 + onclick={() => (item.cardData.period = opt.value)} 31 + > 32 + {opt.label} 33 + </button> 34 + {/each} 35 + </div> 36 + </div>
+113
src/lib/cards/media/LastFMCard/LastFMProfileCard/LastFMProfileCard.svelte
··· 1 + <script lang="ts"> 2 + import { onMount } from 'svelte'; 3 + import { siLastdotfm } from 'simple-icons'; 4 + import { getAdditionalUserData } from '$lib/website/context'; 5 + import type { ContentComponentProps } from '../../../types'; 6 + import { qrOverlay } from '$lib/components/qr/qrOverlay.svelte'; 7 + 8 + interface UserInfo { 9 + name: string; 10 + realname: string; 11 + url: string; 12 + image: { '#text': string; size: string }[]; 13 + playcount: string; 14 + registered: { unixtime: string }; 15 + } 16 + 17 + let { item, isEditing }: ContentComponentProps = $props(); 18 + 19 + const data = getAdditionalUserData(); 20 + const cacheKey = $derived(`lastfmProfile:${item.cardData.lastfmUsername}`); 21 + 22 + // svelte-ignore state_referenced_locally 23 + let userInfo = $state(data[cacheKey] as UserInfo | undefined); 24 + 25 + onMount(async () => { 26 + if (userInfo) return; 27 + if (!item.cardData.lastfmUsername) return; 28 + 29 + try { 30 + const response = await fetch( 31 + `/api/lastfm?method=user.getInfo&user=${encodeURIComponent(item.cardData.lastfmUsername)}` 32 + ); 33 + if (response.ok) { 34 + const result = await response.json(); 35 + userInfo = result?.user; 36 + data[cacheKey] = userInfo; 37 + } 38 + } catch (error) { 39 + console.error('Failed to fetch Last.fm profile:', error); 40 + } 41 + }); 42 + 43 + const profileUrl = $derived(`https://www.last.fm/user/${item.cardData.lastfmUsername}`); 44 + 45 + const avatarUrl = $derived.by(() => { 46 + if (!userInfo?.image) return ''; 47 + const preferred = ['extralarge', 'large', 'medium']; 48 + for (const pref of preferred) { 49 + const img = userInfo.image.find((i) => i.size === pref); 50 + if (img?.['#text']) return img['#text']; 51 + } 52 + return ''; 53 + }); 54 + 55 + const memberSince = $derived.by(() => { 56 + if (!userInfo?.registered?.unixtime) return ''; 57 + const date = new Date(parseInt(userInfo.registered.unixtime) * 1000); 58 + return date.toLocaleDateString('en-US', { month: 'long', year: 'numeric' }); 59 + }); 60 + </script> 61 + 62 + <div class="h-full overflow-hidden p-4"> 63 + <div class="flex h-full flex-col justify-between"> 64 + <div class="flex items-center gap-3"> 65 + <div 66 + class="fill-base-950 accent:fill-white size-6 shrink-0 dark:fill-white [&_svg]:size-full" 67 + > 68 + {@html siLastdotfm.svg} 69 + </div> 70 + <span class="truncate text-2xl font-bold"> 71 + {item.cardData.lastfmUsername} 72 + </span> 73 + </div> 74 + 75 + {#if userInfo} 76 + <div class="flex items-center gap-4"> 77 + {#if avatarUrl} 78 + <img src={avatarUrl} alt={userInfo.name} class="size-12 rounded-full object-cover" /> 79 + {/if} 80 + <div class="min-w-0 flex-1"> 81 + <div class="text-lg font-semibold"> 82 + {parseInt(userInfo.playcount).toLocaleString()} scrobbles 83 + </div> 84 + {#if memberSince} 85 + <div class="text-sm opacity-60"> 86 + Since {memberSince} 87 + </div> 88 + {/if} 89 + </div> 90 + </div> 91 + {:else} 92 + <div class="text-sm opacity-60">Loading profile...</div> 93 + {/if} 94 + </div> 95 + </div> 96 + 97 + {#if !isEditing} 98 + <a 99 + href={profileUrl} 100 + class="absolute inset-0 h-full w-full" 101 + target="_blank" 102 + rel="noopener noreferrer" 103 + use:qrOverlay={{ 104 + context: { 105 + title: item.cardData.lastfmUsername, 106 + icon: siLastdotfm.svg, 107 + iconColor: '#' + siLastdotfm.hex 108 + } 109 + }} 110 + > 111 + <span class="sr-only">View on Last.fm</span> 112 + </a> 113 + {/if}
+40
src/lib/cards/media/LastFMCard/LastFMProfileCard/index.ts
··· 1 + import type { CardDefinition } from '../../../types'; 2 + import CreateLastFMCardModal from '../CreateLastFMCardModal.svelte'; 3 + import LastFMProfileCard from './LastFMProfileCard.svelte'; 4 + 5 + export const LastFMProfileCardDefinition = { 6 + type: 'lastfmProfile', 7 + contentComponent: LastFMProfileCard, 8 + creationModalComponent: CreateLastFMCardModal, 9 + createNew: (card) => { 10 + card.w = 4; 11 + card.mobileW = 8; 12 + card.h = 2; 13 + card.mobileH = 3; 14 + }, 15 + loadData: async (items) => { 16 + const allData: Record<string, unknown> = {}; 17 + for (const item of items) { 18 + const username = item.cardData.lastfmUsername; 19 + if (!username) continue; 20 + try { 21 + const response = await fetch( 22 + `https://blento.app/api/lastfm?method=user.getInfo&user=${encodeURIComponent(username)}` 23 + ); 24 + if (!response.ok) continue; 25 + const text = await response.text(); 26 + const result = JSON.parse(text); 27 + allData[`lastfmProfile:${username}`] = result?.user; 28 + } catch (error) { 29 + console.error('Failed to fetch Last.fm profile:', error); 30 + } 31 + } 32 + return allData; 33 + }, 34 + minW: 2, 35 + minH: 2, 36 + name: 'Last.fm Profile', 37 + keywords: ['music', 'scrobble', 'profile', 'lastfm', 'last.fm'], 38 + groups: ['Media'], 39 + icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="m9 9 10.5-3m0 6.553v3.75a2.25 2.25 0 0 1-1.632 2.163l-1.32.377a1.803 1.803 0 1 1-.99-3.467l2.31-.66a2.25 2.25 0 0 0 1.632-2.163Zm0 0V2.25L9 5.25v10.303m0 0v3.75a2.25 2.25 0 0 1-1.632 2.163l-1.32.377a1.803 1.803 0 0 1-.99-3.467l2.31-.66A2.25 2.25 0 0 0 9 15.553Z" /></svg>` 40 + } as CardDefinition & { type: 'lastfmProfile' };
+103
src/lib/cards/media/LastFMCard/LastFMRecentTracksCard/LastFMRecentTracksCard.svelte
··· 1 + <script lang="ts"> 2 + import { onMount } from 'svelte'; 3 + import { getAdditionalUserData } from '$lib/website/context'; 4 + import type { ContentComponentProps } from '../../../types'; 5 + import LastFMAlbumArt from '../LastFMAlbumArt.svelte'; 6 + import { RelativeTime } from '@foxui/time'; 7 + 8 + interface Track { 9 + name: string; 10 + artist: { '#text': string }; 11 + album: { '#text': string }; 12 + image: { '#text': string; size: string }[]; 13 + url: string; 14 + date?: { uts: string }; 15 + '@attr'?: { nowplaying: string }; 16 + } 17 + 18 + let { item }: ContentComponentProps = $props(); 19 + 20 + const data = getAdditionalUserData(); 21 + const cacheKey = $derived(`lastfmRecentTracks:${item.cardData.lastfmUsername}`); 22 + 23 + // svelte-ignore state_referenced_locally 24 + let tracks = $state(data[cacheKey] as Track[] | undefined); 25 + let error = $state(false); 26 + 27 + onMount(async () => { 28 + if (tracks) return; 29 + if (!item.cardData.lastfmUsername) return; 30 + 31 + try { 32 + const response = await fetch( 33 + `/api/lastfm?method=user.getRecentTracks&user=${encodeURIComponent(item.cardData.lastfmUsername)}&limit=50` 34 + ); 35 + if (response.ok) { 36 + const result = await response.json(); 37 + tracks = result?.recenttracks?.track ?? []; 38 + data[cacheKey] = tracks; 39 + } else { 40 + error = true; 41 + } 42 + } catch { 43 + error = true; 44 + } 45 + }); 46 + </script> 47 + 48 + <div class="z-10 flex h-full w-full flex-col gap-3 overflow-y-scroll p-4"> 49 + {#if tracks && tracks.length > 0} 50 + {#each tracks as track, i (track.url + i)} 51 + <a 52 + href={track.url} 53 + target="_blank" 54 + rel="noopener noreferrer" 55 + class="flex w-full items-center gap-3" 56 + > 57 + <div class="size-10 shrink-0"> 58 + <LastFMAlbumArt images={track.image} alt={track.album?.['#text']} /> 59 + </div> 60 + <div class="min-w-0 flex-1"> 61 + <div class="inline-flex w-full max-w-full justify-between gap-2"> 62 + <div 63 + class="text-accent-500 accent:text-accent-950 min-w-0 flex-1 shrink truncate font-semibold" 64 + > 65 + {track.name} 66 + </div> 67 + {#if track['@attr']?.nowplaying === 'true'} 68 + <div class="flex shrink-0 items-center gap-1 text-xs text-green-500"> 69 + <span class="inline-block size-2 animate-pulse rounded-full bg-green-500"></span> 70 + Now 71 + </div> 72 + {:else if track.date?.uts} 73 + <div class="shrink-0 text-xs"> 74 + <RelativeTime date={new Date(parseInt(track.date.uts) * 1000)} locale="en-US" /> ago 75 + </div> 76 + {/if} 77 + </div> 78 + <div class="my-1 min-w-0 truncate text-xs whitespace-nowrap"> 79 + {track.artist?.['#text']} 80 + </div> 81 + </div> 82 + </a> 83 + {/each} 84 + {:else if error} 85 + <div 86 + class="text-base-500 dark:text-base-400 accent:text-white/60 flex h-full items-center justify-center text-center text-sm" 87 + > 88 + Failed to load tracks. 89 + </div> 90 + {:else if tracks} 91 + <div 92 + class="text-base-500 dark:text-base-400 accent:text-white/60 flex h-full items-center justify-center text-center text-sm" 93 + > 94 + No recent tracks found. 95 + </div> 96 + {:else} 97 + <div 98 + class="text-base-500 dark:text-base-400 accent:text-white/60 flex h-full items-center justify-center text-center text-sm" 99 + > 100 + Loading tracks... 101 + </div> 102 + {/if} 103 + </div>
+70
src/lib/cards/media/LastFMCard/LastFMRecentTracksCard/index.ts
··· 1 + import type { CardDefinition } from '../../../types'; 2 + import CreateLastFMCardModal from '../CreateLastFMCardModal.svelte'; 3 + import LastFMRecentTracksCard from './LastFMRecentTracksCard.svelte'; 4 + 5 + export const LastFMRecentTracksCardDefinition = { 6 + type: 'lastfmRecentTracks', 7 + contentComponent: LastFMRecentTracksCard, 8 + creationModalComponent: CreateLastFMCardModal, 9 + createNew: (card) => { 10 + card.w = 4; 11 + card.mobileW = 8; 12 + card.h = 3; 13 + card.mobileH = 6; 14 + }, 15 + loadData: async (items) => { 16 + const allData: Record<string, unknown> = {}; 17 + for (const item of items) { 18 + const username = item.cardData.lastfmUsername; 19 + if (!username) continue; 20 + try { 21 + const response = await fetch( 22 + `https://blento.app/api/lastfm?method=user.getRecentTracks&user=${encodeURIComponent(username)}&limit=50` 23 + ); 24 + if (!response.ok) continue; 25 + const text = await response.text(); 26 + const result = JSON.parse(text); 27 + allData[`lastfmRecentTracks:${username}`] = result?.recenttracks?.track ?? []; 28 + } catch (error) { 29 + console.error('Failed to fetch Last.fm recent tracks:', error); 30 + } 31 + } 32 + return allData; 33 + }, 34 + onUrlHandler: (url, item) => { 35 + const username = getLastFMUsername(url); 36 + if (!username) return null; 37 + 38 + item.cardData.lastfmUsername = username; 39 + item.cardData.href = `https://www.last.fm/user/${username}`; 40 + item.w = 4; 41 + item.mobileW = 8; 42 + item.h = 3; 43 + item.mobileH = 6; 44 + item.cardType = 'lastfmRecentTracks'; 45 + return item; 46 + }, 47 + urlHandlerPriority: 5, 48 + minW: 3, 49 + minH: 2, 50 + canHaveLabel: true, 51 + name: 'Last.fm Recent Tracks', 52 + keywords: ['music', 'scrobble', 'listening', 'songs', 'lastfm', 'last.fm'], 53 + groups: ['Media'], 54 + icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="m9 9 10.5-3m0 6.553v3.75a2.25 2.25 0 0 1-1.632 2.163l-1.32.377a1.803 1.803 0 1 1-.99-3.467l2.31-.66a2.25 2.25 0 0 0 1.632-2.163Zm0 0V2.25L9 5.25v10.303m0 0v3.75a2.25 2.25 0 0 1-1.632 2.163l-1.32.377a1.803 1.803 0 0 1-.99-3.467l2.31-.66A2.25 2.25 0 0 0 9 15.553Z" /></svg>` 55 + } as CardDefinition & { type: 'lastfmRecentTracks' }; 56 + 57 + function getLastFMUsername(url: string | undefined): string | undefined { 58 + if (!url) return; 59 + try { 60 + const parsed = new URL(url); 61 + if (!/^(www\.)?last\.fm$/.test(parsed.hostname)) return undefined; 62 + const segments = parsed.pathname.split('/').filter(Boolean); 63 + if (segments.length >= 2 && segments[0] === 'user') { 64 + return segments[1]; 65 + } 66 + return undefined; 67 + } catch { 68 + return undefined; 69 + } 70 + }
+103
src/lib/cards/media/LastFMCard/LastFMTopAlbumsCard/LastFMTopAlbumsCard.svelte
··· 1 + <script lang="ts"> 2 + import { onMount } from 'svelte'; 3 + import type { ContentComponentProps } from '../../../types'; 4 + import { getAdditionalUserData } from '$lib/website/context'; 5 + import ImageGrid from '$lib/components/ImageGrid.svelte'; 6 + 7 + interface Album { 8 + name: string; 9 + playcount: string; 10 + url: string; 11 + artist: { name: string; url: string }; 12 + image: { '#text': string; size: string }[]; 13 + } 14 + 15 + let { item }: ContentComponentProps = $props(); 16 + 17 + const data = getAdditionalUserData(); 18 + 19 + let period = $derived(item.cardData.period ?? '7day'); 20 + let layout: 'grid' | 'cinema' = $derived(item.cardData.layout ?? 'grid'); 21 + const cacheKey = $derived(`lastfmTopAlbums:${item.cardData.lastfmUsername}:${period}`); 22 + 23 + // svelte-ignore state_referenced_locally 24 + let albums = $state(data[cacheKey] as Album[] | undefined); 25 + let loading = $state(false); 26 + let error = $state(false); 27 + 28 + async function fetchAlbums() { 29 + if (!item.cardData.lastfmUsername) return; 30 + loading = true; 31 + 32 + try { 33 + const response = await fetch( 34 + `/api/lastfm?method=user.getTopAlbums&user=${encodeURIComponent(item.cardData.lastfmUsername)}&period=${period}&limit=50` 35 + ); 36 + if (response.ok) { 37 + const result = await response.json(); 38 + albums = result?.topalbums?.album ?? []; 39 + data[cacheKey] = albums; 40 + } else { 41 + error = true; 42 + } 43 + } catch { 44 + error = true; 45 + } finally { 46 + loading = false; 47 + } 48 + } 49 + 50 + onMount(() => { 51 + if (!albums) fetchAlbums(); 52 + }); 53 + 54 + $effect(() => { 55 + const _period = period; 56 + const cached = data[cacheKey] as Album[] | undefined; 57 + if (cached) { 58 + albums = cached; 59 + } else { 60 + fetchAlbums(); 61 + } 62 + }); 63 + 64 + function getImageUrl(album: Album): string | null { 65 + if (!album.image || album.image.length === 0) return null; 66 + const preferred = ['extralarge', 'large', 'medium', 'small']; 67 + for (const pref of preferred) { 68 + const img = album.image.find((i) => i.size === pref); 69 + if (img?.['#text']) return img['#text']; 70 + } 71 + return album.image[album.image.length - 1]?.['#text'] || null; 72 + } 73 + 74 + let gridItems = $derived( 75 + (albums ?? []).map((album) => ({ 76 + imageUrl: getImageUrl(album), 77 + link: album.url, 78 + label: `${album.name} - ${album.artist.name}` 79 + })) 80 + ); 81 + </script> 82 + 83 + {#if error} 84 + <div class="flex h-full w-full items-center justify-center"> 85 + <span class="text-base-500 dark:text-base-400 accent:text-white/60 text-sm"> 86 + Failed to load albums. 87 + </span> 88 + </div> 89 + {:else if albums && gridItems.length > 0} 90 + <ImageGrid items={gridItems} {layout} tooltip /> 91 + {:else if loading || !albums} 92 + <div class="flex h-full w-full items-center justify-center"> 93 + <span class="text-base-500 dark:text-base-400 accent:text-white/60 text-sm"> 94 + Loading albums... 95 + </span> 96 + </div> 97 + {:else} 98 + <div class="flex h-full w-full items-center justify-center"> 99 + <span class="text-base-500 dark:text-base-400 accent:text-white/60 text-sm"> 100 + No top albums found. 101 + </span> 102 + </div> 103 + {/if}
+63
src/lib/cards/media/LastFMCard/LastFMTopAlbumsCard/LastFMTopAlbumsCardSettings.svelte
··· 1 + <script lang="ts"> 2 + import type { SettingsComponentProps } from '../../../types'; 3 + import { Label } from '@foxui/core'; 4 + 5 + let { item = $bindable() }: SettingsComponentProps = $props(); 6 + 7 + const periodOptions = [ 8 + { value: '7day', label: '7 Days' }, 9 + { value: '1month', label: '1 Month' }, 10 + { value: '3month', label: '3 Months' }, 11 + { value: '6month', label: '6 Months' }, 12 + { value: '12month', label: '12 Months' }, 13 + { value: 'overall', label: 'All Time' } 14 + ]; 15 + 16 + const layoutOptions = [ 17 + { value: 'grid', label: 'Grid' }, 18 + { value: 'cinema', label: 'Cinema' } 19 + ]; 20 + 21 + let period = $derived(item.cardData.period ?? '7day'); 22 + let layout = $derived(item.cardData.layout ?? 'grid'); 23 + </script> 24 + 25 + <div class="flex flex-col gap-4"> 26 + <div class="flex flex-col gap-2"> 27 + <Label>Time Period</Label> 28 + <div class="flex flex-wrap gap-2"> 29 + {#each periodOptions as opt (opt.value)} 30 + <button 31 + class={[ 32 + 'rounded-xl border px-3 py-2 text-sm transition-colors', 33 + period === opt.value 34 + ? 'bg-accent-500 border-accent-500 text-white' 35 + : 'bg-base-100 dark:bg-base-800 border-base-300 dark:border-base-700 text-base-900 dark:text-base-100 hover:border-accent-400' 36 + ]} 37 + onclick={() => (item.cardData.period = opt.value)} 38 + > 39 + {opt.label} 40 + </button> 41 + {/each} 42 + </div> 43 + </div> 44 + 45 + <div class="flex flex-col gap-2"> 46 + <Label>Layout</Label> 47 + <div class="flex gap-2"> 48 + {#each layoutOptions as opt (opt.value)} 49 + <button 50 + class={[ 51 + 'flex-1 rounded-xl border px-3 py-2 text-sm transition-colors', 52 + layout === opt.value 53 + ? 'bg-accent-500 border-accent-500 text-white' 54 + : 'bg-base-100 dark:bg-base-800 border-base-300 dark:border-base-700 text-base-900 dark:text-base-100 hover:border-accent-400' 55 + ]} 56 + onclick={() => (item.cardData.layout = opt.value)} 57 + > 58 + {opt.label} 59 + </button> 60 + {/each} 61 + </div> 62 + </div> 63 + </div>
+47
src/lib/cards/media/LastFMCard/LastFMTopAlbumsCard/index.ts
··· 1 + import type { CardDefinition } from '../../../types'; 2 + import CreateLastFMCardModal from '../CreateLastFMCardModal.svelte'; 3 + import LastFMTopAlbumsCard from './LastFMTopAlbumsCard.svelte'; 4 + import LastFMTopAlbumsCardSettings from './LastFMTopAlbumsCardSettings.svelte'; 5 + 6 + export const LastFMTopAlbumsCardDefinition = { 7 + type: 'lastfmTopAlbums', 8 + contentComponent: LastFMTopAlbumsCard, 9 + creationModalComponent: CreateLastFMCardModal, 10 + settingsComponent: LastFMTopAlbumsCardSettings, 11 + createNew: (card) => { 12 + card.w = 4; 13 + card.h = 3; 14 + card.mobileW = 8; 15 + card.mobileH = 4; 16 + card.cardData.period = '7day'; 17 + }, 18 + loadData: async (items) => { 19 + const allData: Record<string, unknown> = {}; 20 + for (const item of items) { 21 + const username = item.cardData.lastfmUsername; 22 + const period = item.cardData.period ?? '7day'; 23 + if (!username) continue; 24 + try { 25 + const response = await fetch( 26 + `https://blento.app/api/lastfm?method=user.getTopAlbums&user=${encodeURIComponent(username)}&period=${period}&limit=50` 27 + ); 28 + if (!response.ok) continue; 29 + const text = await response.text(); 30 + const result = JSON.parse(text); 31 + allData[`lastfmTopAlbums:${username}:${period}`] = result?.topalbums?.album ?? []; 32 + } catch (error) { 33 + console.error('Failed to fetch Last.fm top albums:', error); 34 + } 35 + } 36 + return allData; 37 + }, 38 + allowSetColor: true, 39 + defaultColor: 'base', 40 + minW: 2, 41 + minH: 2, 42 + canHaveLabel: true, 43 + name: 'Last.fm Top Albums', 44 + keywords: ['music', 'scrobble', 'albums', 'lastfm', 'last.fm', 'top'], 45 + groups: ['Media'], 46 + icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="m9 9 10.5-3m0 6.553v3.75a2.25 2.25 0 0 1-1.632 2.163l-1.32.377a1.803 1.803 0 1 1-.99-3.467l2.31-.66a2.25 2.25 0 0 0 1.632-2.163Zm0 0V2.25L9 5.25v10.303m0 0v3.75a2.25 2.25 0 0 1-1.632 2.163l-1.32.377a1.803 1.803 0 0 1-.99-3.467l2.31-.66A2.25 2.25 0 0 0 9 15.553Z" /></svg>` 47 + } as CardDefinition & { type: 'lastfmTopAlbums' };
+117
src/lib/cards/media/LastFMCard/LastFMTopTracksCard/LastFMTopTracksCard.svelte
··· 1 + <script lang="ts"> 2 + import { onMount } from 'svelte'; 3 + import { getAdditionalUserData } from '$lib/website/context'; 4 + import type { ContentComponentProps } from '../../../types'; 5 + import LastFMAlbumArt from '../LastFMAlbumArt.svelte'; 6 + 7 + interface Track { 8 + name: string; 9 + playcount: string; 10 + artist: { name: string; url: string }; 11 + image: { '#text': string; size: string }[]; 12 + url: string; 13 + } 14 + 15 + let { item }: ContentComponentProps = $props(); 16 + 17 + const data = getAdditionalUserData(); 18 + 19 + let period = $derived(item.cardData.period ?? '7day'); 20 + const cacheKey = $derived(`lastfmTopTracks:${item.cardData.lastfmUsername}:${period}`); 21 + 22 + // svelte-ignore state_referenced_locally 23 + let tracks = $state(data[cacheKey] as Track[] | undefined); 24 + let error = $state(false); 25 + let loading = $state(false); 26 + 27 + async function fetchTracks() { 28 + if (!item.cardData.lastfmUsername) return; 29 + loading = true; 30 + 31 + try { 32 + const response = await fetch( 33 + `/api/lastfm?method=user.getTopTracks&user=${encodeURIComponent(item.cardData.lastfmUsername)}&period=${period}&limit=50` 34 + ); 35 + if (response.ok) { 36 + const result = await response.json(); 37 + tracks = result?.toptracks?.track ?? []; 38 + data[cacheKey] = tracks; 39 + } else { 40 + error = true; 41 + } 42 + } catch { 43 + error = true; 44 + } finally { 45 + loading = false; 46 + } 47 + } 48 + 49 + onMount(() => { 50 + if (!tracks) fetchTracks(); 51 + }); 52 + 53 + $effect(() => { 54 + const _period = period; 55 + const cached = data[cacheKey] as Track[] | undefined; 56 + if (cached) { 57 + tracks = cached; 58 + } else { 59 + fetchTracks(); 60 + } 61 + }); 62 + </script> 63 + 64 + <div class="z-10 flex h-full w-full flex-col gap-3 overflow-y-scroll p-4"> 65 + {#if tracks && tracks.length > 0} 66 + {#each tracks as track, i (track.url)} 67 + <a 68 + href={track.url} 69 + target="_blank" 70 + rel="noopener noreferrer" 71 + class="flex w-full items-center gap-3" 72 + > 73 + <div 74 + class="text-base-400 dark:text-base-500 accent:text-white/40 w-5 shrink-0 text-right text-xs font-bold" 75 + > 76 + {i + 1} 77 + </div> 78 + <div class="size-10 shrink-0"> 79 + <LastFMAlbumArt images={track.image} alt={track.name} /> 80 + </div> 81 + <div class="min-w-0 flex-1"> 82 + <div class="inline-flex w-full max-w-full justify-between gap-2"> 83 + <div 84 + class="text-accent-500 accent:text-accent-950 min-w-0 flex-1 shrink truncate font-semibold" 85 + > 86 + {track.name} 87 + </div> 88 + <div class="shrink-0 text-xs"> 89 + {parseInt(track.playcount).toLocaleString()} plays 90 + </div> 91 + </div> 92 + <div class="my-1 min-w-0 truncate text-xs whitespace-nowrap"> 93 + {track.artist?.name} 94 + </div> 95 + </div> 96 + </a> 97 + {/each} 98 + {:else if error} 99 + <div 100 + class="text-base-500 dark:text-base-400 accent:text-white/60 flex h-full items-center justify-center text-center text-sm" 101 + > 102 + Failed to load tracks. 103 + </div> 104 + {:else if tracks || loading} 105 + <div 106 + class="text-base-500 dark:text-base-400 accent:text-white/60 flex h-full items-center justify-center text-center text-sm" 107 + > 108 + {tracks?.length === 0 ? 'No top tracks found.' : 'Loading tracks...'} 109 + </div> 110 + {:else} 111 + <div 112 + class="text-base-500 dark:text-base-400 accent:text-white/60 flex h-full items-center justify-center text-center text-sm" 113 + > 114 + Loading tracks... 115 + </div> 116 + {/if} 117 + </div>
+45
src/lib/cards/media/LastFMCard/LastFMTopTracksCard/index.ts
··· 1 + import type { CardDefinition } from '../../../types'; 2 + import CreateLastFMCardModal from '../CreateLastFMCardModal.svelte'; 3 + import LastFMPeriodSettings from '../LastFMPeriodSettings.svelte'; 4 + import LastFMTopTracksCard from './LastFMTopTracksCard.svelte'; 5 + 6 + export const LastFMTopTracksCardDefinition = { 7 + type: 'lastfmTopTracks', 8 + contentComponent: LastFMTopTracksCard, 9 + creationModalComponent: CreateLastFMCardModal, 10 + settingsComponent: LastFMPeriodSettings, 11 + createNew: (card) => { 12 + card.w = 4; 13 + card.mobileW = 8; 14 + card.h = 3; 15 + card.mobileH = 6; 16 + card.cardData.period = '7day'; 17 + }, 18 + loadData: async (items) => { 19 + const allData: Record<string, unknown> = {}; 20 + for (const item of items) { 21 + const username = item.cardData.lastfmUsername; 22 + const period = item.cardData.period ?? '7day'; 23 + if (!username) continue; 24 + try { 25 + const response = await fetch( 26 + `https://blento.app/api/lastfm?method=user.getTopTracks&user=${encodeURIComponent(username)}&period=${period}&limit=50` 27 + ); 28 + if (!response.ok) continue; 29 + const text = await response.text(); 30 + const result = JSON.parse(text); 31 + allData[`lastfmTopTracks:${username}:${period}`] = result?.toptracks?.track ?? []; 32 + } catch (error) { 33 + console.error('Failed to fetch Last.fm top tracks:', error); 34 + } 35 + } 36 + return allData; 37 + }, 38 + minW: 3, 39 + minH: 2, 40 + canHaveLabel: true, 41 + name: 'Last.fm Top Tracks', 42 + keywords: ['music', 'scrobble', 'songs', 'lastfm', 'last.fm', 'top'], 43 + groups: ['Media'], 44 + icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="m9 9 10.5-3m0 6.553v3.75a2.25 2.25 0 0 1-1.632 2.163l-1.32.377a1.803 1.803 0 1 1-.99-3.467l2.31-.66a2.25 2.25 0 0 0 1.632-2.163Zm0 0V2.25L9 5.25v10.303m0 0v3.75a2.25 2.25 0 0 1-1.632 2.163l-1.32.377a1.803 1.803 0 0 1-.99-3.467l2.31-.66A2.25 2.25 0 0 0 9 15.553Z" /></svg>` 45 + } as CardDefinition & { type: 'lastfmTopTracks' };
+14 -135
src/lib/cards/social/GitHubContributorsCard/GitHubContributorsCard.svelte
··· 1 1 <script lang="ts"> 2 2 import { onMount } from 'svelte'; 3 3 import type { ContentComponentProps } from '../../types'; 4 - import { getAdditionalUserData, getCanEdit, getIsMobile } from '$lib/website/context'; 4 + import { getAdditionalUserData, getCanEdit } from '$lib/website/context'; 5 5 import type { GitHubContributor, GitHubContributorsLoadedData } from '.'; 6 + import ImageGrid from '$lib/components/ImageGrid.svelte'; 6 7 7 8 let { item }: ContentComponentProps = $props(); 8 9 9 - const isMobile = getIsMobile(); 10 10 const canEdit = getCanEdit(); 11 11 const additionalData = getAdditionalUserData(); 12 12 ··· 53 53 } 54 54 } 55 55 56 - let containerWidth = $state(0); 57 - let containerHeight = $state(0); 58 - 59 - let totalItems = $derived(namedContributors.length); 60 - 61 - const GAP = 6; 62 - const MIN_SIZE = 16; 63 - const MAX_SIZE = 120; 64 - 65 - function cinemaCapacity(size: number, availW: number, availH: number): number { 66 - const colsWide = Math.floor((availW + GAP) / (size + GAP)); 67 - if (colsWide < 1) return 0; 68 - const colsNarrow = Math.max(1, colsWide - 1); 69 - const maxRows = Math.floor((availH + GAP) / (size + GAP)); 70 - let capacity = 0; 71 - // Pattern: narrow, wide, narrow, wide... (row 0 is narrow) 72 - for (let r = 0; r < maxRows; r++) { 73 - capacity += r % 2 === 0 ? colsNarrow : colsWide; 74 - } 75 - return capacity; 76 - } 77 - 78 - function gridCapacity(size: number, availW: number, availH: number): number { 79 - const cols = Math.floor((availW + GAP) / (size + GAP)); 80 - const rows = Math.floor((availH + GAP) / (size + GAP)); 81 - return cols * rows; 82 - } 83 - 84 - let computedSize = $derived.by(() => { 85 - if (!containerWidth || !containerHeight || totalItems === 0) return 40; 86 - 87 - let lo = MIN_SIZE; 88 - let hi = MAX_SIZE; 89 - const capacityFn = layout === 'cinema' ? cinemaCapacity : gridCapacity; 90 - 91 - while (lo <= hi) { 92 - const mid = Math.floor((lo + hi) / 2); 93 - const availW = containerWidth - (layout === 'cinema' ? mid / 2 : 0); 94 - const availH = containerHeight - (layout === 'cinema' ? mid / 2 : 0); 95 - if (availW <= 0 || availH <= 0) { 96 - hi = mid - 1; 97 - continue; 98 - } 99 - if (capacityFn(mid, availW, availH) >= totalItems) { 100 - lo = mid + 1; 101 - } else { 102 - hi = mid - 1; 103 - } 104 - } 105 - 106 - return Math.max(MIN_SIZE, hi); 107 - }); 108 - 109 - let padding = $derived(layout === 'cinema' ? computedSize / 4 : 0); 110 - 111 - let rows = $derived.by(() => { 112 - const availW = containerWidth - (layout === 'cinema' ? computedSize / 4 : 0); 113 - if (availW <= 0) return [] as GitHubContributor[][]; 114 - 115 - const colsWide = Math.floor((availW + GAP) / (computedSize + GAP)); 116 - const colsNarrow = layout === 'cinema' ? Math.max(1, colsWide - 1) : colsWide; 117 - 118 - // Calculate row sizes from bottom up, then reverse for incomplete row at top 119 - const rowSizes: number[] = []; 120 - let remaining = namedContributors.length; 121 - let rowNum = 0; 122 - while (remaining > 0) { 123 - const cols = layout === 'cinema' && rowNum % 2 === 0 ? colsNarrow : colsWide; 124 - rowSizes.push(Math.min(cols, remaining)); 125 - remaining -= cols; 126 - rowNum++; 127 - } 128 - rowSizes.reverse(); 129 - 130 - // Fill rows with contributors in order 131 - const result: GitHubContributor[][] = []; 132 - let idx = 0; 133 - for (const size of rowSizes) { 134 - result.push(namedContributors.slice(idx, idx + size)); 135 - idx += size; 136 - } 137 - return result; 138 - }); 139 - 140 - let textSize = $derived( 141 - computedSize < 24 ? 'text-[10px]' : computedSize < 40 ? 'text-xs' : 'text-sm' 56 + let gridItems = $derived( 57 + namedContributors.map((c) => ({ 58 + imageUrl: c.avatarUrl, 59 + link: `https://github.com/${c.username}`, 60 + label: c.username 61 + })) 142 62 ); 143 - 144 - let shapeClass = $derived(shape === 'circle' ? 'rounded-full' : 'rounded-lg'); 145 63 </script> 146 64 147 - <div 148 - class="flex h-full w-full items-center justify-center overflow-hidden px-2" 149 - bind:clientWidth={containerWidth} 150 - bind:clientHeight={containerHeight} 151 - > 152 - {#if !owner || !repo} 153 - {#if canEdit()} 65 + {#if !owner || !repo} 66 + {#if canEdit()} 67 + <div class="flex h-full w-full items-center justify-center"> 154 68 <span class="text-base-400 dark:text-base-500 accent:text-accent-300 text-sm"> 155 69 Enter a repository 156 70 </span> 157 - {/if} 158 - {:else if totalItems > 0} 159 - <div style="padding: {padding}px;"> 160 - <div class="flex flex-col items-center" style="gap: {GAP}px;"> 161 - {#each rows as row, rowIdx (rowIdx)} 162 - <div class="flex justify-center" style="gap: {GAP}px;"> 163 - {#each row as contributor (contributor.username)} 164 - <a 165 - href="https://github.com/{contributor.username}" 166 - target="_blank" 167 - rel="noopener noreferrer" 168 - class="accent:ring-accent-500 block {shapeClass} ring-2 ring-white transition-transform hover:scale-110 dark:ring-neutral-900" 169 - > 170 - {#if contributor.avatarUrl} 171 - <img 172 - src={contributor.avatarUrl} 173 - alt={contributor.username} 174 - class="{shapeClass} object-cover" 175 - style="width: {computedSize}px; height: {computedSize}px;" 176 - /> 177 - {:else} 178 - <div 179 - class="bg-base-200 dark:bg-base-700 accent:bg-accent-400 flex items-center justify-center {shapeClass}" 180 - style="width: {computedSize}px; height: {computedSize}px;" 181 - > 182 - <span 183 - class="text-base-500 dark:text-base-400 accent:text-accent-100 {textSize} font-medium" 184 - > 185 - {contributor.username.charAt(0).toUpperCase()} 186 - </span> 187 - </div> 188 - {/if} 189 - </a> 190 - {/each} 191 - </div> 192 - {/each} 193 - </div> 194 71 </div> 195 72 {/if} 196 - </div> 73 + {:else} 74 + <ImageGrid items={gridItems} {layout} {shape} tooltip /> 75 + {/if}
+2 -2
src/lib/cards/social/NpmxLikesCard/NpmxLikesCard.svelte
··· 2 2 import type { Item } from '$lib/types'; 3 3 import { onMount } from 'svelte'; 4 4 import { getAdditionalUserData, getDidContext, getHandleContext } from '$lib/website/context'; 5 - import { CardDefinitionsByType } from '../..'; 5 + import { NpmxLikesCardDefinition } from '.'; 6 6 import { RelativeTime } from '@foxui/time'; 7 7 8 8 interface NpmxLike { ··· 25 25 onMount(async () => { 26 26 if (feed) return; 27 27 28 - feed = (await CardDefinitionsByType[item.cardType]?.loadData?.([], { 28 + feed = (await NpmxLikesCardDefinition.loadData?.([], { 29 29 did, 30 30 handle 31 31 })) as NpmxLike[] | undefined;
+1 -1
src/lib/cards/social/NpmxLikesLeaderboardCard/NpmxLikesLeaderboardCard.svelte
··· 91 91 {/each} 92 92 </div> 93 93 <div 94 - class="pointer-events-none absolute inset-x-0 bottom-0 z-10 h-12 bg-linear-to-t from-base-200 from-40% to-transparent dark:from-base-950 accent:from-accent-500" 94 + class="from-base-200 dark:from-base-950 accent:from-accent-500 pointer-events-none absolute inset-x-0 bottom-0 z-10 h-12 bg-linear-to-t from-40% to-transparent" 95 95 ></div> 96 96 <div 97 97 class="text-base-500 dark:text-base-400 accent:text-white/60 bg-base-200 dark:bg-base-950/50 accent:bg-accent-500/20 relative z-10 flex shrink-0 items-center justify-center gap-3 px-4 pb-3 text-xs"
+180
src/lib/components/ImageGrid.svelte
··· 1 + <script lang="ts"> 2 + import { Tooltip } from 'bits-ui'; 3 + 4 + export type ImageGridItem = { 5 + imageUrl: string | null; 6 + link: string; 7 + label: string; 8 + }; 9 + 10 + let { 11 + items, 12 + layout = 'grid', 13 + shape = 'square', 14 + tooltip = false 15 + }: { 16 + items: ImageGridItem[]; 17 + layout?: 'grid' | 'cinema'; 18 + shape?: 'square' | 'circle'; 19 + tooltip?: boolean; 20 + } = $props(); 21 + 22 + let containerWidth = $state(0); 23 + let containerHeight = $state(0); 24 + 25 + let totalItems = $derived(items.length); 26 + 27 + const GAP = 6; 28 + const MIN_SIZE = 16; 29 + const MAX_SIZE = 120; 30 + 31 + function cinemaCapacity(size: number, availW: number, availH: number): number { 32 + const colsWide = Math.floor((availW + GAP) / (size + GAP)); 33 + if (colsWide < 1) return 0; 34 + const colsNarrow = Math.max(1, colsWide - 1); 35 + const maxRows = Math.floor((availH + GAP) / (size + GAP)); 36 + let capacity = 0; 37 + for (let r = 0; r < maxRows; r++) { 38 + capacity += r % 2 === 0 ? colsNarrow : colsWide; 39 + } 40 + return capacity; 41 + } 42 + 43 + function gridCapacity(size: number, availW: number, availH: number): number { 44 + const cols = Math.floor((availW + GAP) / (size + GAP)); 45 + const rows = Math.floor((availH + GAP) / (size + GAP)); 46 + return cols * rows; 47 + } 48 + 49 + let computedSize = $derived.by(() => { 50 + if (!containerWidth || !containerHeight || totalItems === 0) return 40; 51 + 52 + let lo = MIN_SIZE; 53 + let hi = MAX_SIZE; 54 + const capacityFn = layout === 'cinema' ? cinemaCapacity : gridCapacity; 55 + 56 + while (lo <= hi) { 57 + const mid = Math.floor((lo + hi) / 2); 58 + const availW = containerWidth - (layout === 'cinema' ? mid / 2 : 0); 59 + const availH = containerHeight - (layout === 'cinema' ? mid / 2 : 0); 60 + if (availW <= 0 || availH <= 0) { 61 + hi = mid - 1; 62 + continue; 63 + } 64 + if (capacityFn(mid, availW, availH) >= totalItems) { 65 + lo = mid + 1; 66 + } else { 67 + hi = mid - 1; 68 + } 69 + } 70 + 71 + return Math.max(MIN_SIZE, hi); 72 + }); 73 + 74 + let padding = $derived(layout === 'cinema' ? computedSize / 4 : 0); 75 + 76 + let rows = $derived.by(() => { 77 + const availW = containerWidth - (layout === 'cinema' ? computedSize / 4 : 0); 78 + if (availW <= 0) return [] as ImageGridItem[][]; 79 + 80 + const colsWide = Math.floor((availW + GAP) / (computedSize + GAP)); 81 + const colsNarrow = layout === 'cinema' ? Math.max(1, colsWide - 1) : colsWide; 82 + 83 + const rowSizes: number[] = []; 84 + let remaining = items.length; 85 + let rowNum = 0; 86 + while (remaining > 0) { 87 + const cols = layout === 'cinema' && rowNum % 2 === 0 ? colsNarrow : colsWide; 88 + rowSizes.push(Math.min(cols, remaining)); 89 + remaining -= cols; 90 + rowNum++; 91 + } 92 + rowSizes.reverse(); 93 + 94 + const result: ImageGridItem[][] = []; 95 + let idx = 0; 96 + for (const size of rowSizes) { 97 + result.push(items.slice(idx, idx + size)); 98 + idx += size; 99 + } 100 + return result; 101 + }); 102 + 103 + let textSize = $derived( 104 + computedSize < 24 ? 'text-[10px]' : computedSize < 40 ? 'text-xs' : 'text-sm' 105 + ); 106 + 107 + let shapeClass = $derived(shape === 'circle' ? 'rounded-full' : 'rounded-lg'); 108 + </script> 109 + 110 + {#snippet gridItem(item: ImageGridItem)} 111 + {#if item.imageUrl} 112 + <img 113 + src={item.imageUrl} 114 + alt={item.label} 115 + class="{shapeClass} object-cover" 116 + style="width: {computedSize}px; height: {computedSize}px;" 117 + /> 118 + {:else} 119 + <div 120 + class="bg-base-200 dark:bg-base-700 accent:bg-accent-400 flex items-center justify-center {shapeClass}" 121 + style="width: {computedSize}px; height: {computedSize}px;" 122 + > 123 + <span class="text-base-500 dark:text-base-400 accent:text-accent-100 {textSize} font-medium"> 124 + {item.label.charAt(0).toUpperCase()} 125 + </span> 126 + </div> 127 + {/if} 128 + {/snippet} 129 + 130 + <div 131 + class="flex h-full w-full items-center justify-center overflow-hidden px-2" 132 + bind:clientWidth={containerWidth} 133 + bind:clientHeight={containerHeight} 134 + > 135 + {#if totalItems > 0} 136 + <div style="padding: {padding}px;"> 137 + <div class="flex flex-col items-center" style="gap: {GAP}px;"> 138 + {#each rows as row, rowIdx (rowIdx)} 139 + <div class="flex justify-center" style="gap: {GAP}px;"> 140 + {#each row as item (item.link)} 141 + {#if tooltip} 142 + <Tooltip.Root> 143 + <Tooltip.Trigger> 144 + <a 145 + href={item.link} 146 + target="_blank" 147 + rel="noopener noreferrer" 148 + class="accent:ring-accent-500 block {shapeClass} ring-2 ring-white transition-transform hover:scale-110 dark:ring-neutral-900" 149 + > 150 + {@render gridItem(item)} 151 + </a> 152 + </Tooltip.Trigger> 153 + <Tooltip.Portal> 154 + <Tooltip.Content 155 + side="top" 156 + sideOffset={4} 157 + class="bg-base-900 dark:bg-base-800 text-base-100 z-50 rounded-lg px-3 py-1.5 text-xs font-medium shadow-md" 158 + > 159 + {item.label} 160 + </Tooltip.Content> 161 + </Tooltip.Portal> 162 + </Tooltip.Root> 163 + {:else} 164 + <a 165 + href={item.link} 166 + target="_blank" 167 + rel="noopener noreferrer" 168 + class="accent:ring-accent-500 block {shapeClass} ring-2 ring-white transition-transform hover:scale-110 dark:ring-neutral-900" 169 + title={item.label} 170 + > 171 + {@render gridItem(item)} 172 + </a> 173 + {/if} 174 + {/each} 175 + </div> 176 + {/each} 177 + </div> 178 + </div> 179 + {/if} 180 + </div>
+32
src/lib/website/ThemeScript.svelte
··· 1 1 <script lang="ts"> 2 + import { browser } from '$app/environment'; 3 + 2 4 let { 3 5 accentColor = 'pink', 4 6 baseColor = 'stone' ··· 7 9 baseColor?: string; 8 10 } = $props(); 9 11 12 + const allAccentColors = [ 13 + 'red', 14 + 'orange', 15 + 'amber', 16 + 'yellow', 17 + 'lime', 18 + 'green', 19 + 'emerald', 20 + 'teal', 21 + 'cyan', 22 + 'sky', 23 + 'blue', 24 + 'indigo', 25 + 'violet', 26 + 'purple', 27 + 'fuchsia', 28 + 'pink', 29 + 'rose' 30 + ]; 31 + const allBaseColors = ['gray', 'stone', 'zinc', 'neutral', 'slate']; 32 + 10 33 const safeJson = (v: string) => JSON.stringify(v).replace(/</g, '\\u003c'); 11 34 35 + // SSR: inline script for initial page load (no FOUC) 12 36 let script = $derived( 13 37 `<script>(function(){document.documentElement.classList.add(${safeJson(accentColor)},${safeJson(baseColor)});})();<` + 14 38 '/script>' 15 39 ); 40 + 41 + // Client: reactive effect for client-side navigations 42 + $effect(() => { 43 + if (!browser) return; 44 + const el = document.documentElement; 45 + el.classList.remove(...allAccentColors, ...allBaseColors); 46 + el.classList.add(accentColor, baseColor); 47 + }); 16 48 </script> 17 49 18 50 <svelte:head>
+4 -1
src/routes/+layout.svelte
··· 1 1 <script lang="ts"> 2 2 import '../app.css'; 3 3 4 + import { Tooltip } from 'bits-ui'; 4 5 import { ThemeToggle, Toaster, toast } from '@foxui/core'; 5 6 import { onMount } from 'svelte'; 6 7 import { initClient } from '$lib/atproto'; ··· 28 29 }); 29 30 </script> 30 31 31 - {@render children()} 32 + <Tooltip.Provider delayDuration={300}> 33 + {@render children()} 34 + </Tooltip.Provider> 32 35 33 36 <ThemeToggle class="fixed top-2 left-2 z-10" /> 34 37 <Toaster />
+89
src/routes/api/lastfm/+server.ts
··· 1 + import { json } from '@sveltejs/kit'; 2 + import type { RequestHandler } from './$types'; 3 + import { env } from '$env/dynamic/private'; 4 + 5 + const LASTFM_API_URL = 'https://ws.audioscrobbler.com/2.0/'; 6 + 7 + const ALLOWED_METHODS = [ 8 + 'user.getRecentTracks', 9 + 'user.getTopTracks', 10 + 'user.getTopAlbums', 11 + 'user.getInfo' 12 + ]; 13 + 14 + const CACHE_TTL: Record<string, number> = { 15 + 'user.getRecentTracks': 15 * 60 * 1000, 16 + 'user.getTopTracks': 60 * 60 * 1000, 17 + 'user.getTopAlbums': 60 * 60 * 1000, 18 + 'user.getInfo': 12 * 60 * 60 * 1000 19 + }; 20 + 21 + export const GET: RequestHandler = async ({ url, platform }) => { 22 + const method = url.searchParams.get('method'); 23 + const user = url.searchParams.get('user'); 24 + const period = url.searchParams.get('period') || '7day'; 25 + const limit = url.searchParams.get('limit') || '50'; 26 + 27 + if (!method || !user) { 28 + return json({ error: 'Missing method or user parameter' }, { status: 400 }); 29 + } 30 + 31 + if (!ALLOWED_METHODS.includes(method)) { 32 + return json({ error: 'Method not allowed' }, { status: 400 }); 33 + } 34 + 35 + const cacheKey = `#lastfm:${method}:${user}:${period}:${limit}`; 36 + const cachedData = await platform?.env?.USER_DATA_CACHE?.get(cacheKey); 37 + 38 + if (cachedData) { 39 + const parsed = JSON.parse(cachedData); 40 + const ttl = CACHE_TTL[method] || 60 * 60 * 1000; 41 + 42 + if (Date.now() - (parsed._cachedAt || 0) < ttl) { 43 + return json(parsed); 44 + } 45 + } 46 + 47 + const apiKey = env?.LASTFM_API_KEY; 48 + if (!apiKey) { 49 + return json({ error: 'Last.fm API key not configured' }, { status: 500 }); 50 + } 51 + 52 + try { 53 + const params = new URLSearchParams({ 54 + method, 55 + user, 56 + api_key: apiKey, 57 + format: 'json', 58 + limit 59 + }); 60 + 61 + if (method === 'user.getTopTracks' || method === 'user.getTopAlbums') { 62 + params.set('period', period); 63 + } 64 + 65 + const response = await fetch(`${LASTFM_API_URL}?${params}`); 66 + 67 + if (!response.ok) { 68 + return json( 69 + { error: 'Failed to fetch Last.fm data: ' + response.statusText }, 70 + { status: response.status } 71 + ); 72 + } 73 + 74 + const data = await response.json(); 75 + 76 + if (data.error) { 77 + return json({ error: data.message || 'Last.fm API error' }, { status: 400 }); 78 + } 79 + 80 + data._cachedAt = Date.now(); 81 + 82 + await platform?.env?.USER_DATA_CACHE?.put(cacheKey, JSON.stringify(data)); 83 + 84 + return json(data); 85 + } catch (error) { 86 + console.error('Error fetching Last.fm data:', error); 87 + return json({ error: 'Failed to fetch Last.fm data' }, { status: 500 }); 88 + } 89 + };