my website at ewancroft.uk
6
fork

Configure Feed

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

feat(ui): use locale-aware compact number formatting

- Replace custom number formatting in BlueskyPostCard and ProfileCard with `formatCompactNumber` util
- Add automatic locale detection with fallback to 'en-GB'
- Update post, follower, and engagement counts to use compact, locale-aware formatting
- Refactor `formatNumber.ts` to include `formatCompactNumber` and updated `formatNumber` using Intl.NumberFormat

+51 -51
+9 -24
src/lib/components/layout/main/card/BlueskyPostCard.svelte
··· 3 3 import { Card } from '$lib/components/ui'; 4 4 import { fetchLatestBlueskyPost, type BlueskyPost } from '$lib/services/atproto'; 5 5 import { formatRelativeTime } from '$lib/utils/formatDate'; 6 + import { formatCompactNumber } from '$lib/utils/formatNumber'; 6 7 import { Heart, Repeat2, MessageCircle, ExternalLink, X } from '@lucide/svelte'; 7 8 8 9 let post: BlueskyPost | null = null; 9 10 let loading = true; 10 11 let error: string | null = null; 11 12 let lightboxImage: { url: string; alt: string } | null = null; 13 + 14 + // Detect system locale, fallback to en-GB 15 + const locale = typeof navigator !== 'undefined' ? navigator.language || 'en-GB' : 'en-GB'; 12 16 13 17 onMount(async () => { 14 18 try { ··· 20 24 } 21 25 }); 22 26 23 - function formatNumber(num?: number): string { 24 - if (!num) return '0'; 25 - if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(1)}M`; 26 - if (num >= 1_000) return `${(num / 1_000).toFixed(1)}K`; 27 - return num.toString(); 28 - } 29 - 30 27 function getPostUrl(uri: string): string { 31 - // Convert AT URI to bsky.app URL 32 - // Format: at://did:plc:xxx/app.bsky.feed.post/rkey 33 28 const parts = uri.split('/'); 34 29 const did = parts[2]; 35 30 const rkey = parts[4]; ··· 50 45 document.body.style.overflow = ''; 51 46 } 52 47 53 - // Render rich text with facets (links, mentions, hashtags) 54 48 function renderRichText(text: string, facets?: any[]): string { 55 - if (!facets || facets.length === 0) { 56 - return escapeHtml(text); 57 - } 58 - 59 - // Sort facets by byteStart to process them in order 49 + if (!facets || facets.length === 0) return escapeHtml(text); 60 50 const sortedFacets = [...facets].sort((a, b) => a.index.byteStart - b.index.byteStart); 61 51 62 52 let result = ''; ··· 64 54 65 55 for (const facet of sortedFacets) { 66 56 const { byteStart, byteEnd } = facet.index; 67 - 68 - // Add text before this facet 69 57 result += escapeHtml(text.slice(lastIndex, byteStart)); 70 - 71 58 const facetText = text.slice(byteStart, byteEnd); 72 59 const feature = facet.features?.[0]; 73 60 ··· 88 75 lastIndex = byteEnd; 89 76 } 90 77 91 - // Add remaining text after last facet 92 78 result += escapeHtml(text.slice(lastIndex)); 93 - 94 79 return result; 95 80 } 96 81 ··· 284 269 </div> 285 270 {/if} 286 271 287 - <!-- Engagement Stats (only show on root post and first-level quoted posts) --> 272 + <!-- Engagement Stats --> 288 273 {#if depth === 0 || (depth === 1 && !postData.quotedPost)} 289 274 <div class="flex items-center gap-{isQuoted ? '4' : '6'} text-{isQuoted ? 'xs' : 'sm'}"> 290 275 {#if postData.replyCount !== undefined} ··· 293 278 class="h-{isQuoted ? '3' : '4'} w-{isQuoted ? '3' : '4'}" 294 279 aria-hidden="true" 295 280 /> 296 - <span class="font-medium">{formatNumber(postData.replyCount)}</span> 281 + <span class="font-medium">{formatCompactNumber(postData.replyCount, locale)}</span> 297 282 </div> 298 283 {/if} 299 284 300 285 {#if postData.repostCount !== undefined} 301 286 <div class="flex items-center gap-1.5 text-ink-700 dark:text-ink-200"> 302 287 <Repeat2 class="h-{isQuoted ? '3' : '4'} w-{isQuoted ? '3' : '4'}" aria-hidden="true" /> 303 - <span class="font-medium">{formatNumber(postData.repostCount)}</span> 288 + <span class="font-medium">{formatCompactNumber(postData.repostCount, locale)}</span> 304 289 </div> 305 290 {/if} 306 291 307 292 {#if postData.likeCount !== undefined} 308 293 <div class="flex items-center gap-1.5 text-ink-700 dark:text-ink-200"> 309 294 <Heart class="h-{isQuoted ? '3' : '4'} w-{isQuoted ? '3' : '4'}" aria-hidden="true" /> 310 - <span class="font-medium">{formatNumber(postData.likeCount)}</span> 295 + <span class="font-medium">{formatCompactNumber(postData.likeCount, locale)}</span> 311 296 </div> 312 297 {/if} 313 298
+13 -16
src/lib/components/layout/main/card/ProfileCard.svelte
··· 3 3 import { Card } from '$lib/components/ui'; 4 4 import { fetchProfile, type ProfileData } from '$lib/services/atproto'; 5 5 import LinkCard from './LinkCard.svelte'; 6 + import { formatCompactNumber } from '$lib/utils/formatNumber'; 6 7 7 8 let profile: ProfileData | null = null; 8 9 let loading = true; 9 10 let error: string | null = null; 10 11 let imageLoaded = false; 11 12 let bannerLoaded = false; 13 + 14 + // Detect system locale, fallback to en-GB 15 + const locale = typeof navigator !== 'undefined' ? navigator.language || 'en-GB' : 'en-GB'; 12 16 13 17 onMount(async () => { 14 18 try { ··· 19 23 loading = false; 20 24 } 21 25 }); 22 - 23 - function formatNumber(num?: number): string { 24 - if (!num) return '0'; 25 - if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(1)}M`; 26 - if (num >= 1_000) return `${(num / 1_000).toFixed(1)}K`; 27 - return num.toString(); 28 - } 29 26 </script> 30 27 31 28 <div class="mx-auto w-full max-w-2xl"> ··· 118 115 119 116 <div class="flex gap-6 text-sm font-medium"> 120 117 <div class="flex items-center gap-1"> 121 - <span class="font-bold text-ink-900 dark:text-ink-50" 122 - >{formatNumber(safeProfile.postsCount)}</span 123 - > 118 + <span class="font-bold text-ink-900 dark:text-ink-50"> 119 + {formatCompactNumber(safeProfile.postsCount, locale)} 120 + </span> 124 121 <span class="text-ink-700 dark:text-ink-200">Posts</span> 125 122 </div> 126 123 <div class="flex items-center gap-1"> 127 - <span class="font-bold text-ink-900 dark:text-ink-50" 128 - >{formatNumber(safeProfile.followersCount)}</span 129 - > 124 + <span class="font-bold text-ink-900 dark:text-ink-50"> 125 + {formatCompactNumber(safeProfile.followersCount, locale)} 126 + </span> 130 127 <span class="text-ink-700 dark:text-ink-200">Followers</span> 131 128 </div> 132 129 <div class="flex items-center gap-1"> 133 - <span class="font-bold text-ink-900 dark:text-ink-50" 134 - >{formatNumber(safeProfile.followsCount)}</span 135 - > 130 + <span class="font-bold text-ink-900 dark:text-ink-50"> 131 + {formatCompactNumber(safeProfile.followsCount, locale)} 132 + </span> 136 133 <span class="text-ink-700 dark:text-ink-200">Following</span> 137 134 </div> 138 135 </div>
+29 -11
src/lib/utils/formatNumber.ts
··· 3 3 */ 4 4 5 5 /** 6 - * Formats large numbers into compact human-readable format 6 + * Determines the effective locale, preferring system locale with fallback to 'en-GB'. 7 + */ 8 + function getLocale(locale?: string): string { 9 + return ( 10 + locale || 11 + (typeof navigator !== 'undefined' && navigator.language) || 12 + 'en-GB' 13 + ); 14 + } 15 + 16 + /** 17 + * Formats large numbers into a compact, human-readable format. 18 + * Automatically adapts to the given or system locale. 7 19 * @param num - The number to format 20 + * @param locale - Optional locale string (defaults to system or 'en-GB') 8 21 * @returns Formatted string (e.g., "1.2K", "3.4M") 9 22 */ 10 - export function formatCompactNumber(num?: number): string { 11 - if (!num) return '0'; 12 - if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(1)}M`; 13 - if (num >= 1_000) return `${(num / 1_000).toFixed(1)}K`; 14 - return num.toString(); 23 + export function formatCompactNumber(num?: number, locale?: string): string { 24 + if (num === undefined || num === null) return '0'; 25 + const effectiveLocale = getLocale(locale); 26 + 27 + return new Intl.NumberFormat(effectiveLocale, { 28 + notation: 'compact', 29 + compactDisplay: 'short', 30 + maximumFractionDigits: 1 31 + }).format(num); 15 32 } 16 33 17 34 /** 18 - * Formats a number with thousand separators 35 + * Formats a number with thousand separators. 36 + * Automatically adapts to the given or system locale. 19 37 * @param num - The number to format 20 - * @param locale - The locale to use (default: system locale) 38 + * @param locale - Optional locale string (defaults to system or 'en-GB') 21 39 * @returns Formatted string (e.g., "1,234,567") 22 40 */ 23 41 export function formatNumber(num: number, locale?: string): string { 24 - const userLocale = locale || (typeof navigator !== 'undefined' ? navigator.language : 'en-GB'); 25 - return new Intl.NumberFormat(userLocale).format(num); 26 - } 42 + const effectiveLocale = getLocale(locale); 43 + return new Intl.NumberFormat(effectiveLocale).format(num); 44 + }