my website at ewancroft.uk
6
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: introduce MusicStatusCard and replace status card on homepage

+163 -3
+161
src/lib/components/layout/main/card/MusicStatusCard.svelte
··· 1 + <script lang="ts"> 2 + import { onMount } from 'svelte'; 3 + import { Card } from '$lib/components/ui'; 4 + import { fetchMusicStatus, type MusicStatusData } from '$lib/services/atproto'; 5 + import { formatRelativeTime } from '$lib/utils/formatDate'; 6 + import { Music, Disc3 } from '@lucide/svelte'; 7 + 8 + let musicStatus: MusicStatusData | null = null; 9 + let loading = true; 10 + let error: string | null = null; 11 + let artworkError = false; 12 + 13 + onMount(async () => { 14 + try { 15 + musicStatus = await fetchMusicStatus(); 16 + if (musicStatus) { 17 + console.log('[MusicStatusCard] Music status loaded:', musicStatus); 18 + console.log('[MusicStatusCard] Artwork URL:', musicStatus.artworkUrl); 19 + console.log('[MusicStatusCard] Release MBID:', musicStatus.releaseMbId); 20 + } 21 + } catch (err) { 22 + console.error('[MusicStatusCard] Error loading music status:', err); 23 + error = err instanceof Error ? err.message : 'Failed to load music status'; 24 + } finally { 25 + loading = false; 26 + } 27 + }); 28 + 29 + function formatArtists(artists: { artistName: string }[]): string { 30 + if (!artists || artists.length === 0) return 'Unknown Artist'; 31 + return artists.map(a => a.artistName).join(', '); 32 + } 33 + 34 + function formatDuration(seconds?: number): string { 35 + if (!seconds) return ''; 36 + const minutes = Math.floor(seconds / 60); 37 + const remainingSeconds = seconds % 60; 38 + return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`; 39 + } 40 + 41 + function formatServiceName(domain?: string): string { 42 + if (!domain) return ''; 43 + return domain.replace('lastfm', 'Last.fm').replace('last.fm', 'Last.fm'); 44 + } 45 + 46 + function handleImageError(event: Event) { 47 + console.error('[MusicStatusCard] Artwork failed to load'); 48 + artworkError = true; 49 + } 50 + </script> 51 + 52 + <div class="mx-auto w-full max-w-2xl"> 53 + {#if loading} 54 + <Card loading={true} variant="elevated" padding="md"> 55 + {#snippet skeleton()} 56 + <div class="mb-3 flex items-start gap-4"> 57 + <div class="h-20 w-20 flex-shrink-0 rounded-lg bg-canvas-300 dark:bg-canvas-700"></div> 58 + <div class="flex-1"> 59 + <div class="mb-2 flex items-center gap-2"> 60 + <div class="h-4 w-4 rounded bg-canvas-300 dark:bg-canvas-700"></div> 61 + <div class="h-3 w-32 rounded bg-canvas-300 dark:bg-canvas-700"></div> 62 + </div> 63 + <div class="mb-1 h-5 w-3/4 rounded bg-canvas-300 dark:bg-canvas-700"></div> 64 + <div class="mb-2 h-4 w-1/2 rounded bg-canvas-300 dark:bg-canvas-700"></div> 65 + <div class="h-3 w-40 rounded bg-canvas-300 dark:bg-canvas-700"></div> 66 + </div> 67 + </div> 68 + {/snippet} 69 + </Card> 70 + {:else if error} 71 + <Card error={true} errorMessage={error} /> 72 + {:else if musicStatus} 73 + {@const safeMusicStatus = musicStatus} 74 + <Card variant="elevated" padding="md"> 75 + {#snippet children()} 76 + <div class="flex items-start gap-4"> 77 + <!-- Artwork Section --> 78 + <div class="flex-shrink-0"> 79 + {#if safeMusicStatus.artworkUrl && !artworkError} 80 + <img 81 + src={safeMusicStatus.artworkUrl} 82 + alt="Album artwork for {safeMusicStatus.releaseName || safeMusicStatus.trackName}" 83 + class="h-20 w-20 rounded-lg object-cover shadow-md" 84 + loading="lazy" 85 + onerror={handleImageError} 86 + /> 87 + {:else} 88 + <!-- Fallback icon when no artwork or artwork fails to load --> 89 + <div class="h-20 w-20 rounded-lg bg-canvas-200 dark:bg-canvas-700 flex items-center justify-center shadow-md"> 90 + <Disc3 class="h-10 w-10 text-ink-500 dark:text-ink-400" aria-hidden="true" /> 91 + </div> 92 + {/if} 93 + </div> 94 + 95 + <div class="flex-1 min-w-0"> 96 + <div class="mb-2 flex items-center gap-2"> 97 + <Music class="h-4 w-4 text-primary-600 dark:text-primary-400" aria-hidden="true" /> 98 + <span 99 + class="text-xs font-semibold tracking-wide text-ink-800 uppercase dark:text-ink-100" 100 + > 101 + {safeMusicStatus.$type === 'fm.teal.alpha.actor.status' 102 + ? 'Now Listening' 103 + : 'Last Played'} 104 + </span> 105 + </div> 106 + 107 + <div class="mb-2"> 108 + {#if safeMusicStatus.originUrl} 109 + <a 110 + href={safeMusicStatus.originUrl} 111 + target="_blank" 112 + rel="noopener noreferrer" 113 + class="overflow-wrap-anywhere break-words text-lg font-semibold text-ink-900 hover:text-primary-600 dark:text-ink-50 dark:hover:text-primary-400 transition-colors" 114 + > 115 + {safeMusicStatus.trackName} 116 + </a> 117 + {:else} 118 + <p class="overflow-wrap-anywhere break-words text-lg font-semibold text-ink-900 dark:text-ink-50"> 119 + {safeMusicStatus.trackName} 120 + </p> 121 + {/if} 122 + 123 + <p class="text-base text-ink-800 dark:text-ink-100"> 124 + {formatArtists(safeMusicStatus.artists)} 125 + </p> 126 + 127 + {#if safeMusicStatus.releaseName} 128 + <p class="text-sm text-ink-700 dark:text-ink-200"> 129 + {safeMusicStatus.releaseName} 130 + {#if safeMusicStatus.duration} 131 + <span class="text-ink-600 dark:text-ink-300"> 132 + · {formatDuration(safeMusicStatus.duration)} 133 + </span> 134 + {/if} 135 + </p> 136 + {/if} 137 + </div> 138 + 139 + <div class="flex items-center gap-2 text-xs text-ink-700 dark:text-ink-200"> 140 + <time datetime={safeMusicStatus.playedTime}> 141 + {formatRelativeTime(safeMusicStatus.playedTime)} 142 + </time> 143 + {#if safeMusicStatus.musicServiceBaseDomain} 144 + <span class="text-ink-600 dark:text-ink-300">·</span> 145 + <a 146 + href="https://teal.fm" 147 + target="_blank" 148 + rel="noopener noreferrer" 149 + class="hover:text-primary-600 dark:hover:text-primary-400 transition-colors" 150 + title="Powered by teal.fm" 151 + > 152 + {formatServiceName(safeMusicStatus.musicServiceBaseDomain)} via teal.fm 153 + </a> 154 + {/if} 155 + </div> 156 + </div> 157 + </div> 158 + {/snippet} 159 + </Card> 160 + {/if} 161 + </div>
+2 -3
src/routes/+page.svelte
··· 1 1 <script lang="ts"> 2 2 import { DynamicLinks, TangledRepos } from '$lib/components/layout'; 3 - import { ProfileCard, StatusCard, PostCard } from '$lib/components/layout/main/card'; 4 - import BlueskyPostCard from '$lib/components/layout/main/card/BlueskyPostCard.svelte'; 3 + import { ProfileCard, PostCard, BlueskyPostCard, MusicStatusCard } from '$lib/components/layout/main/card'; 5 4 import { createSiteMeta, type SiteMetadata } from '$lib/helper/siteMeta'; 6 5 7 6 // The `data` object includes merged layout/page load data. ··· 29 28 <div class="grid gap-6 lg:grid-cols-2"> 30 29 <div class="space-y-6"> 31 30 <ProfileCard /> 32 - <StatusCard /> 31 + <MusicStatusCard /> 33 32 <BlueskyPostCard /> 34 33 </div> 35 34 <div class="space-y-6">