my website at ewancroft.uk
6
fork

Configure Feed

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

refactor: extract NoiseImage component, remove all inline fallback boilerplate

+124 -158
+1 -1
README.md
··· 811 811 812 812 Built with ❤️ using SvelteKit, AT Protocol, and open-source tools 813 813 814 - **Version**: 11.2.1 814 + **Version**: 11.3.0
+2 -2
package-lock.json
··· 1 1 { 2 2 "name": "website", 3 - "version": "11.2.1", 3 + "version": "11.3.0", 4 4 "lockfileVersion": 3, 5 5 "requires": true, 6 6 "packages": { 7 7 "": { 8 8 "name": "website", 9 - "version": "11.2.1", 9 + "version": "11.3.0", 10 10 "dependencies": { 11 11 "@atproto/api": "^0.18.21", 12 12 "@ewanc26/atproto": "^0.2.2",
+1 -1
package.json
··· 1 1 { 2 2 "name": "website", 3 3 "private": true, 4 - "version": "11.2.1", 4 + "version": "11.3.0", 5 5 "type": "module", 6 6 "scripts": { 7 7 "dev": "vite dev",
+2 -2
src/lib/components/layout/Footer.svelte
··· 104 104 type="button" 105 105 onclick={() => happyMacStore.incrementClick()} 106 106 class="cursor-default transition-colors select-none hover:text-ink-600 dark:hover:text-ink-300" 107 - aria-label="Version 11.2.1{showHint 107 + aria-label="Version 11.3.0{showHint 108 108 ? ` - ${$happyMacStore.clickCount} of 24 clicks` 109 109 : ''}" 110 110 title={showHint ? `${$happyMacStore.clickCount}/24` : ''} 111 111 > 112 - v11.2.1{#if showHint}<span class="ml-1 text-xs opacity-60" 112 + v11.3.0{#if showHint}<span class="ml-1 text-xs opacity-60" 113 113 >({$happyMacStore.clickCount}/24)</span 114 114 >{/if} 115 115 </button>
+5 -13
src/lib/components/layout/Header.svelte
··· 8 8 import ColorThemeToggle from './ColorThemeToggle.svelte'; 9 9 import { navItems } from '$lib/data/navItems'; 10 10 import type { ProfileData } from '$lib/services/atproto'; 11 + import { NoiseImage } from '$lib/components/ui'; 11 12 import { defaultSiteMeta, createSiteMeta, type SiteMetadata } from '$lib/helper/siteMeta'; 12 13 import { colorThemeDropdownOpen } from '$lib/stores/dropdownState'; 13 14 import { colorTheme, type ColorTheme } from '$lib/stores/colorTheme'; ··· 22 23 const siteMeta: SiteMetadata = createSiteMeta(defaultSiteMeta); 23 24 const { page } = getStores(); 24 25 25 - let imageLoaded = $state(false); 26 26 let mobileMenuOpen = $state(false); 27 27 let colorThemeOpen = $state(false); 28 28 let currentTheme = $state<ColorTheme>('slate'); ··· 116 116 aria-label="Home - {siteMeta.title}" 117 117 > 118 118 <div class="relative flex items-center"> 119 - {#if profile?.avatar} 120 - <img 119 + {#if profile} 120 + <NoiseImage 121 121 src={profile.avatar} 122 - alt="" 122 + seed={`${profile.did || profile.handle}|avatar`} 123 123 class="h-10 w-10 rounded-full object-cover" 124 - onload={() => (imageLoaded = true)} 124 + alt="{profile.displayName || profile.handle}'s avatar" 125 125 /> 126 - {:else if profile} 127 - <div 128 - class="flex h-10 w-10 items-center justify-center rounded-full bg-primary-200 font-bold text-primary-800 dark:bg-primary-800 dark:text-primary-200" 129 - role="img" 130 - aria-label="{profile.displayName || profile.handle} avatar" 131 - > 132 - {(profile.displayName || profile.handle).charAt(0).toUpperCase()} 133 - </div> 134 126 {:else} 135 127 <div 136 128 class="h-10 w-10 animate-pulse rounded-full bg-canvas-300 dark:bg-canvas-700"
+26 -52
src/lib/components/layout/main/card/BlueskyPostCard.svelte
··· 1 1 <script lang="ts"> 2 - import { Card } from '$lib/components/ui'; 2 + import { Card, NoiseImage } from '$lib/components/ui'; 3 3 import { fetchLatestBlueskyPost, type BlueskyPost } from '$lib/services/atproto'; 4 - import { noiseAvatarAction } from '@ewanc26/noise-avatar'; 5 4 import { formatRelativeTime } from '$lib/utils/formatDate'; 6 5 import { formatCompactNumber } from '$lib/utils/formatNumber'; 7 6 import { Heart, Repeat2, MessageCircle, ExternalLink, X } from '@lucide/svelte'; ··· 210 209 <div class="relative flex gap-3"> 211 210 {#if isReplyParent} 212 211 <a 213 - href={getProfileUrl(postData.author.handle)} 214 - target="_blank" 215 - rel="noopener noreferrer" 216 - class="shrink-0 transition-opacity hover:opacity-80" 212 + href={getProfileUrl(postData.author.handle)} 213 + target="_blank" 214 + rel="noopener noreferrer" 215 + class="shrink-0 transition-opacity hover:opacity-80" 217 216 > 218 - {#if postData.author.avatar} 219 - <img 220 - src={postData.author.avatar} 221 - alt={postData.author.displayName || postData.author.handle} 222 - class="h-8 w-8 rounded-full object-cover sm:h-10 sm:w-10" 223 - loading="lazy" 224 - /> 225 - {:else} 226 - <canvas 227 - use:noiseAvatarAction={`${postData.author.did || postData.author.handle}|avatar`} 228 - class="h-8 w-8 rounded-full sm:h-10 sm:w-10" 229 - aria-label="{postData.author.displayName || postData.author.handle}'s avatar placeholder" 230 - ></canvas> 231 - {/if} 217 + <NoiseImage 218 + src={postData.author.avatar} 219 + seed={`${postData.author.did || postData.author.handle}|avatar`} 220 + class="h-8 w-8 rounded-full object-cover sm:h-10 sm:w-10" 221 + alt="{postData.author.displayName || postData.author.handle}'s avatar" 222 + /> 232 223 </a> 233 224 {:else} 234 225 <a ··· 237 228 rel="noopener noreferrer" 238 229 class="shrink-0 transition-opacity hover:opacity-80" 239 230 > 240 - {#if postData.author.avatar} 241 - <img 242 - src={postData.author.avatar} 243 - alt={postData.author.displayName || postData.author.handle} 244 - class="h-10 w-10 rounded-full object-cover sm:h-12 sm:w-12" 245 - loading="lazy" 246 - /> 247 - {:else} 248 - <canvas 249 - use:noiseAvatarAction={`${postData.author.did || postData.author.handle}|avatar`} 250 - class="h-10 w-10 rounded-full sm:h-12 sm:w-12" 251 - aria-label="{postData.author.displayName || postData.author.handle}'s avatar placeholder" 252 - ></canvas> 253 - {/if} 231 + <NoiseImage 232 + src={postData.author.avatar} 233 + seed={`${postData.author.did || postData.author.handle}|avatar`} 234 + class="h-10 w-10 rounded-full object-cover sm:h-12 sm:w-12" 235 + alt="{postData.author.displayName || postData.author.handle}'s avatar" 236 + /> 254 237 </a> 255 238 {/if} 256 239 <div class="min-w-0 flex-1"> ··· 313 296 : 'grid-cols-2'}" 314 297 > 315 298 {#each postData.imageUrls as imageUrl, index} 299 + {@const imgClass = `h-auto w-full max-w-full object-cover ${postData.imageUrls.length === 4 ? 'aspect-square' : postData.imageUrls.length > 1 ? 'aspect-video' : isReplyParent ? 'max-h-64' : 'max-h-96'}`} 316 300 <button 317 301 type="button" 318 302 onclick={() => ··· 323 307 class="h-auto w-full max-w-full overflow-hidden rounded-lg border border-canvas-300 transition-opacity hover:opacity-90 focus:ring-2 focus:ring-primary-500 focus:outline-none dark:border-canvas-700 dark:focus:ring-primary-400" 324 308 title={postData.imageAlts?.[index] || `Post attachment ${index + 1}`} 325 309 > 326 - <img 310 + <NoiseImage 327 311 src={imageUrl} 312 + seed={`${postData.uri}|image|${index}`} 313 + class={imgClass} 328 314 alt={postData.imageAlts?.[index] || `Post attachment ${index + 1}`} 329 - title={postData.imageAlts?.[index] || `Post attachment ${index + 1}`} 330 - class="h-auto w-full max-w-full object-cover {postData.imageUrls.length === 4 331 - ? 'aspect-square' 332 - : postData.imageUrls.length > 1 333 - ? 'aspect-video' 334 - : isReplyParent 335 - ? 'max-h-64' 336 - : 'max-h-96'}" 337 - loading="lazy" 338 315 /> 339 316 </button> 340 317 {/each} ··· 349 326 rel="noopener noreferrer" 350 327 class="mb-3 flex max-w-full flex-col overflow-hidden rounded-xl border border-canvas-300 bg-canvas-200 transition-colors hover:bg-canvas-300 dark:border-canvas-700 dark:bg-canvas-800 dark:hover:bg-canvas-700" 351 328 > 352 - {#if postData.externalLink.thumb} 353 - <img 354 - src={postData.externalLink.thumb} 355 - alt={postData.externalLink.title} 356 - class="h-48 w-full max-w-full object-cover" 357 - loading="lazy" 358 - /> 359 - {/if} 329 + <NoiseImage 330 + src={postData.externalLink.thumb} 331 + seed={`${postData.externalLink.uri}|thumb`} 332 + class="h-48 w-full max-w-full object-cover" 333 + /> 360 334 <div class="p-3"> 361 335 <h3 362 336 class="overflow-wrap-anywhere mb-1 line-clamp-2 text-sm font-semibold wrap-break-word text-ink-900 dark:text-ink-50"
+7 -23
src/lib/components/layout/main/card/MusicStatusCard.svelte
··· 1 1 <script lang="ts"> 2 - import { Card } from '$lib/components/ui'; 2 + import { Card, NoiseImage } from '$lib/components/ui'; 3 3 import type { MusicStatusData } from '$lib/services/atproto'; 4 4 import { formatRelativeTime } from '$lib/utils/formatDate'; 5 5 6 6 // Icons 7 7 import { Music, Users, Album, Clock, Radio } from '@lucide/svelte'; 8 - import { noiseAvatarAction } from '@ewanc26/noise-avatar'; 9 8 10 9 interface Props { 11 10 musicStatus?: MusicStatusData | null; 12 11 } 13 12 14 13 let { musicStatus = null }: Props = $props(); 15 - 16 - let artworkError = $state(false); 17 14 18 15 function formatArtists(artists: { artistName: string }[]): string { 19 16 if (!artists || artists.length === 0) return 'Unknown Artist'; ··· 32 29 return domain.replace('lastfm', 'Last.fm').replace('last.fm', 'Last.fm'); 33 30 } 34 31 35 - function handleImageError() { 36 - console.error('[MusicStatusCard] Artwork failed to load'); 37 - artworkError = true; 38 - } 39 32 </script> 40 33 41 34 <div class="mx-auto w-full max-w-2xl"> ··· 76 69 <div class="flex items-start gap-3"> 77 70 <!-- Artwork --> 78 71 <div class="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 - <canvas 89 - use:noiseAvatarAction={`${safeMusicStatus.trackName}|${formatArtists(safeMusicStatus.artists)}`} 90 - class="h-20 w-20 rounded-lg shadow-md" 91 - aria-label="Album artwork placeholder for {safeMusicStatus.trackName}" 92 - ></canvas> 93 - {/if} 72 + <NoiseImage 73 + src={safeMusicStatus.artworkUrl} 74 + seed={`${safeMusicStatus.trackName}|${formatArtists(safeMusicStatus.artists)}`} 75 + class="h-20 w-20 rounded-lg object-cover shadow-md" 76 + alt="Album artwork for {safeMusicStatus.releaseName || safeMusicStatus.trackName}" 77 + /> 94 78 </div> 95 79 96 80 <!-- Info -->
+10 -27
src/lib/components/layout/main/card/ProfileCard.svelte
··· 1 1 <script lang="ts"> 2 - import { Card } from '$lib/components/ui'; 2 + import { Card, NoiseImage } from '$lib/components/ui'; 3 3 import type { ProfileData } from '$lib/services/atproto'; 4 4 import LinkCard from './LinkCard.svelte'; 5 5 import { formatCompactNumber } from '$lib/utils/formatNumber'; 6 - import { noiseAvatarAction } from '@ewanc26/noise-avatar'; 7 6 8 7 interface Props { 9 8 profile?: ProfileData | null; 10 9 } 11 10 12 11 let { profile = null }: Props = $props(); 13 - 14 - let imageLoaded = $state(false); 15 - let bannerLoaded = $state(false); 16 12 17 13 // Detect system locale, fallback to en-GB 18 14 const locale = typeof navigator !== 'undefined' ? navigator.language || 'en-GB' : 'en-GB'; ··· 54 50 <!-- Banner --> 55 51 <div class="relative h-32 w-full overflow-hidden rounded-t-xl"> 56 52 {#if safeProfile.banner} 57 - <img 53 + <NoiseImage 58 54 src={safeProfile.banner} 59 - alt="" 60 - class="h-full w-full object-cover opacity-0 transition-opacity duration-300" 61 - class:opacity-100={bannerLoaded} 62 - onload={() => (bannerLoaded = true)} 63 - loading="lazy" 55 + seed={`${safeProfile.did || safeProfile.handle}|banner`} 56 + class="h-full w-full object-cover" 64 57 role="presentation" 65 58 /> 66 59 {:else} ··· 76 69 <div 77 70 class="h-32 w-32 overflow-hidden rounded-full border-4 border-white bg-canvas-200 dark:border-canvas-900" 78 71 > 79 - {#if safeProfile.avatar} 80 - <img 81 - src={safeProfile.avatar} 82 - alt="{safeProfile.displayName || safeProfile.handle}'s profile picture" 83 - class="h-full w-full object-cover opacity-0 transition-opacity duration-300" 84 - class:opacity-100={imageLoaded} 85 - onload={() => (imageLoaded = true)} 86 - loading="lazy" 87 - /> 88 - {:else} 89 - <canvas 90 - use:noiseAvatarAction={`${safeProfile.did || safeProfile.handle}|avatar`} 91 - class="h-full w-full" 92 - aria-label="{safeProfile.displayName || safeProfile.handle}'s avatar placeholder" 93 - ></canvas> 94 - {/if} 72 + <NoiseImage 73 + src={safeProfile.avatar} 74 + seed={`${safeProfile.did || safeProfile.handle}|avatar`} 75 + class="h-full w-full object-cover" 76 + alt="{safeProfile.displayName || safeProfile.handle}'s avatar" 77 + /> 95 78 </div> 96 79 </div> 97 80
+5 -7
src/lib/components/layout/main/card/SupportersCard.svelte
··· 1 1 <script lang="ts"> 2 2 import { Heart } from '@lucide/svelte'; 3 - import { Card } from '$lib/components/ui'; 4 - import { noiseAvatarAction } from '@ewanc26/noise-avatar'; 3 + import { Card, NoiseImage } from '$lib/components/ui'; 5 4 import type { KofiSupportEvent, KofiEventType } from '$lib/services/atproto'; 6 5 7 6 interface Props { ··· 57 56 <ol class="space-y-3" aria-label="Ko-fi support timeline"> 58 57 {#each supporters as event (event.rkey)} 59 58 <li class="flex items-start gap-3"> 60 - <canvas 61 - use:noiseAvatarAction={`${event.name}|${event.type}`} 62 - class="mt-0.5 h-8 w-8 shrink-0 rounded-full" 63 - aria-hidden="true" 64 - ></canvas> 59 + <NoiseImage 60 + seed={`${event.name}|${event.type}`} 61 + class="mt-0.5 h-8 w-8 shrink-0 rounded-full" 62 + /> 65 63 <div class="flex flex-col"> 66 64 <p class="text-sm text-ink-900 dark:text-ink-100"> 67 65 <span class="font-semibold">{event.name}</span>
+7 -15
src/lib/components/ui/BlogPostCard.svelte
··· 1 1 <script lang="ts"> 2 2 import { ExternalLink, Tag } from '@lucide/svelte'; 3 3 import type { BlogPost } from '$lib/services/atproto'; 4 - import { InternalCard } from '$lib/components/ui'; 4 + import { InternalCard, NoiseImage } from '$lib/components/ui'; 5 5 import { getPostBadges, getBadgeClasses } from '$lib/helper/badges'; 6 6 import { formatLocalizedDate } from '$lib/utils/locale'; 7 - import { noiseAvatarAction } from '@ewanc26/noise-avatar'; 8 7 9 8 interface Props { 10 9 post: BlogPost; ··· 20 19 {#snippet children()} 21 20 <!-- Cover Image (Standard.site only) --> 22 21 <div class="mb-3 overflow-hidden rounded-lg"> 23 - {#if post.coverImage} 24 - <img 25 - src={post.coverImage} 26 - alt={post.title} 27 - class="h-48 w-full object-cover transition-transform duration-300 hover:scale-105" 28 - /> 29 - {:else} 30 - <canvas 31 - use:noiseAvatarAction={`${post.title}|cover`} 32 - class="h-48 w-full" 33 - aria-hidden="true" 34 - ></canvas> 35 - {/if} 22 + <NoiseImage 23 + src={post.coverImage} 24 + seed={`${post.title}|cover`} 25 + class="h-48 w-full object-cover" 26 + alt={post.title} 27 + /> 36 28 </div> 37 29 38 30 <div class="relative min-w-0 flex-1 space-y-2">
+7 -15
src/lib/components/ui/DocumentCard.svelte
··· 1 1 <script lang="ts"> 2 2 import { ExternalLink, Tag } from '@lucide/svelte'; 3 3 import type { StandardSiteDocument } from '$lib/services/atproto'; 4 - import { InternalCard } from '$lib/components/ui'; 4 + import { InternalCard, NoiseImage } from '$lib/components/ui'; 5 5 import { formatLocalizedDate } from '$lib/utils/locale'; 6 - import { noiseAvatarAction } from '@ewanc26/noise-avatar'; 7 6 8 7 interface Props { 9 8 document: StandardSiteDocument; ··· 17 16 {#snippet children()} 18 17 <!-- Cover Image --> 19 18 <div class="mb-3 overflow-hidden rounded-lg"> 20 - {#if document.coverImage} 21 - <img 22 - src={document.coverImage} 23 - alt={document.title} 24 - class="h-48 w-full object-cover transition-transform duration-300 hover:scale-105" 25 - /> 26 - {:else} 27 - <canvas 28 - use:noiseAvatarAction={`${document.title}|cover`} 29 - class="h-48 w-full" 30 - aria-hidden="true" 31 - ></canvas> 32 - {/if} 19 + <NoiseImage 20 + src={document.coverImage} 21 + seed={`${document.title}|cover`} 22 + class="h-48 w-full object-cover" 23 + alt={document.title} 24 + /> 33 25 </div> 34 26 35 27 <div class="relative min-w-0 flex-1 space-y-2">
+50
src/lib/components/ui/NoiseImage.svelte
··· 1 + <script lang="ts"> 2 + import { noiseAvatarAction } from '@ewanc26/noise-avatar'; 3 + 4 + /** 5 + * Renders an `<img>` when `src` is present and loads successfully, 6 + * otherwise falls back to a `<canvas>` filled with deterministic noise 7 + * keyed on `seed`. Both elements receive the same `class` string so 8 + * layout is identical regardless of which branch is active. 9 + * 10 + * Accessibility: 11 + * - Canvas with a non-empty `alt` → `aria-label={alt}` 12 + * - Canvas with no `alt` → `aria-hidden="true"` 13 + */ 14 + 15 + interface Props { 16 + /** Image URL. When absent the canvas is rendered immediately. */ 17 + src?: string | null | undefined; 18 + /** Deterministic seed passed to `noiseAvatarAction`. */ 19 + seed: string; 20 + /** Alt text for the `<img>`; also used as `aria-label` on the canvas. */ 21 + alt?: string; 22 + /** CSS classes applied to both `<img>` and `<canvas>`. */ 23 + class?: string; 24 + loading?: 'lazy' | 'eager'; 25 + role?: string; 26 + } 27 + 28 + let { src, seed, alt = '', class: className = '', loading = 'lazy', role }: Props = $props(); 29 + 30 + let failed = $state(false); 31 + </script> 32 + 33 + {#if src && !failed} 34 + <img 35 + {src} 36 + {alt} 37 + class={className} 38 + {loading} 39 + {role} 40 + onerror={() => (failed = true)} 41 + /> 42 + {:else} 43 + <canvas 44 + use:noiseAvatarAction={seed} 45 + class={className} 46 + {role} 47 + aria-label={alt || undefined} 48 + aria-hidden={alt ? undefined : 'true'} 49 + ></canvas> 50 + {/if}
+1
src/lib/components/ui/index.ts
··· 1 1 export { Card, InternalCard, Dropdown, Pagination, SearchBar, Tabs, PostsGroupedView, DocumentCard, BlogPostCard } from '@ewanc26/ui'; 2 + export { default as NoiseImage } from './NoiseImage.svelte';