my website at ewancroft.uk

Compare changes

Choose any two refs to compare.

+25
.env.example
··· 50 50 # Use "*" to allow all origins (not recommended for production) 51 51 # Example: https://example.com,https://app.example.com 52 52 PUBLIC_CORS_ALLOWED_ORIGINS="https://your-site-url.com" 53 + 54 + # Cache TTL Configuration (optional) 55 + # Configure how long different types of data are cached (in minutes) 56 + # Longer TTLs reduce API calls and prevent timeouts, but data may be less fresh 57 + # Leave empty to use defaults (optimized for production) 58 + # Profile data (default: 5 min dev, 60 min prod) 59 + # CACHE_TTL_PROFILE=60 60 + # Site info (default: 5 min dev, 120 min prod) 61 + # CACHE_TTL_SITE_INFO=120 62 + # Links (default: 5 min dev, 60 min prod) 63 + # CACHE_TTL_LINKS=60 64 + # Music status (default: 2 min dev, 10 min prod) 65 + # CACHE_TTL_MUSIC_STATUS=10 66 + # Kibun status (default: 2 min dev, 15 min prod) 67 + # CACHE_TTL_KIBUN_STATUS=15 68 + # Tangled repos (default: 5 min dev, 60 min prod) 69 + # CACHE_TTL_TANGLED_REPOS=60 70 + # Blog posts (default: 5 min dev, 30 min prod) 71 + # CACHE_TTL_BLOG_POSTS=30 72 + # Publications (default: 5 min dev, 60 min prod) 73 + # CACHE_TTL_PUBLICATIONS=60 74 + # Individual posts (default: 5 min dev, 60 min prod) 75 + # CACHE_TTL_INDIVIDUAL_POST=60 76 + # Identity resolution (default: 30 min dev, 1440 min/24h prod) 77 + # CACHE_TTL_IDENTITY=1440
+2 -2
package-lock.json
··· 1 1 { 2 2 "name": "website", 3 - "version": "10.3.2", 3 + "version": "10.5.0", 4 4 "lockfileVersion": 3, 5 5 "requires": true, 6 6 "packages": { 7 7 "": { 8 8 "name": "website", 9 - "version": "10.3.2", 9 + "version": "10.5.0", 10 10 "dependencies": { 11 11 "@atproto/api": "^0.18.1", 12 12 "@lucide/svelte": "^0.554.0",
+1 -1
package.json
··· 1 1 { 2 2 "name": "website", 3 3 "private": true, 4 - "version": "10.3.2", 4 + "version": "10.5.0", 5 5 "type": "module", 6 6 "scripts": { 7 7 "dev": "vite dev",
+23 -1
src/hooks.server.ts
··· 1 1 import type { Handle } from '@sveltejs/kit'; 2 2 import { PUBLIC_CORS_ALLOWED_ORIGINS } from '$env/static/public'; 3 + import { HTTP_CACHE_HEADERS } from '$lib/config/cache.config'; 3 4 4 5 /** 5 6 * Global request handler with CORS support ··· 31 32 32 33 const response = await resolve(event, { 33 34 filterSerializedResponseHeaders: (name) => { 34 - return name === 'content-type' || name.startsWith('x-'); 35 + return name === 'content-type' || name === 'cache-control' || name.startsWith('x-'); 35 36 } 36 37 }); 38 + 39 + // Add HTTP caching headers for better performance and reduced timeouts 40 + // Layout data (root route) is cached aggressively since profile/site info changes infrequently 41 + if (!event.url.pathname.startsWith('/api/')) { 42 + // Root layout loads profile and site info - cache aggressively 43 + if (event.url.pathname === '/' || event.url.pathname === '') { 44 + response.headers.set('Cache-Control', HTTP_CACHE_HEADERS.LAYOUT); 45 + } 46 + // Blog listing pages 47 + else if (event.url.pathname.startsWith('/blog') || event.url.pathname.startsWith('/archive')) { 48 + response.headers.set('Cache-Control', HTTP_CACHE_HEADERS.BLOG_LISTING); 49 + } 50 + // Individual blog post pages 51 + else if (event.url.pathname.match(/^\/[a-z0-9-]+$/)) { 52 + response.headers.set('Cache-Control', HTTP_CACHE_HEADERS.BLOG_POST); 53 + } 54 + // Other pages get moderate caching 55 + else { 56 + response.headers.set('Cache-Control', HTTP_CACHE_HEADERS.LAYOUT); 57 + } 58 + } 37 59 38 60 // Add CORS headers for API routes 39 61 if (event.url.pathname.startsWith('/api/')) {
+171
src/lib/components/HappyMacEasterEgg.svelte
··· 1 + <script lang="ts"> 2 + import { happyMacStore } from '$lib/stores'; 3 + 4 + let isVisible = $state(false); 5 + let position = $state(-100); 6 + 7 + // Watch the store for when it's triggered (24 clicks) 8 + $effect(() => { 9 + const state = $happyMacStore; 10 + if (state.isTriggered && !isVisible) { 11 + startAnimation(); 12 + } 13 + }); 14 + 15 + function playBeep() { 16 + try { 17 + const audioContext = new AudioContext(); 18 + const now = audioContext.currentTime; 19 + 20 + // Tributary recreation of the classic Mac startup chord 21 + // This is NOT the original sound - it's an approximation using Web Audio API 22 + // The original Mac beep was a major chord: F4, A4, C5 23 + // Frequencies: ~349 Hz, ~440 Hz, ~523 Hz 24 + const frequencies = [349, 440, 523]; 25 + const masterGain = audioContext.createGain(); 26 + masterGain.connect(audioContext.destination); 27 + masterGain.gain.value = 0.15; 28 + 29 + // Create three oscillators for the chord 30 + frequencies.forEach((freq) => { 31 + const oscillator = audioContext.createOscillator(); 32 + const gainNode = audioContext.createGain(); 33 + 34 + oscillator.type = 'sine'; // Original Mac used sine waves 35 + oscillator.frequency.value = freq; 36 + 37 + // ADSR envelope for a more authentic sound 38 + gainNode.gain.setValueAtTime(0, now); 39 + gainNode.gain.linearRampToValueAtTime(0.3, now + 0.02); // Attack 40 + gainNode.gain.exponentialRampToValueAtTime(0.01, now + 1.0); // Decay 41 + 42 + oscillator.connect(gainNode); 43 + gainNode.connect(masterGain); 44 + 45 + oscillator.start(now); 46 + oscillator.stop(now + 1.0); 47 + }); 48 + } catch (e) { 49 + // Fail silently if audio context isn't available 50 + console.log('Audio playback not available'); 51 + } 52 + } 53 + 54 + function startAnimation() { 55 + // Play the beep first 56 + playBeep(); 57 + 58 + isVisible = true; 59 + position = -100; 60 + 61 + // Animate across screen (takes about 15 seconds) 62 + const duration = 15000; 63 + const startTime = Date.now(); 64 + 65 + function animate() { 66 + const elapsed = Date.now() - startTime; 67 + const progress = Math.min(elapsed / duration, 1); 68 + 69 + // Move from -100 to window width + 100 70 + position = -100 + (window.innerWidth + 200) * progress; 71 + 72 + if (progress < 1) { 73 + requestAnimationFrame(animate); 74 + } else { 75 + isVisible = false; 76 + // Reset the store so it can be triggered again 77 + happyMacStore.reset(); 78 + } 79 + } 80 + 81 + requestAnimationFrame(animate); 82 + } 83 + </script> 84 + 85 + {#if isVisible} 86 + <div 87 + class="happy-mac" 88 + style="left: {position}px" 89 + > 90 + <!-- 91 + Happy Mac SVG 92 + Original by NiloGlock at Italian Wikipedia 93 + License: CC BY-SA 4.0 (https://creativecommons.org/licenses/by-sa/4.0/) 94 + Source: https://commons.wikimedia.org/wiki/File:Happy_Mac.svg 95 + --> 96 + <svg 97 + width="60" 98 + height="78" 99 + viewBox="0 0 8.4710464 10.9614" 100 + xmlns="http://www.w3.org/2000/svg" 101 + class="mac-icon" 102 + > 103 + <g transform="translate(-5.3090212,-4.3002038)"> 104 + <g transform="matrix(0.06455006,0,0,0.06455006,7.6050574,7.0900779)"> 105 + <path d="m -30.937651,99.78759 h 122 v 26.80449 h -122 z" style="fill:#000000;fill-opacity:1;stroke-width:2.38412714"/> 106 + <g transform="translate(-56.456402,-31.41017)"> 107 + <path style="fill:#555555;fill-opacity:1;stroke:none;stroke-width:0.17674622" d="m 33.668747,136.75006 v 4.69998 h 31.950504 v -4.69998 z m 41.740088,4.69998 V 146.15 h 11.145573 v -4.69996 z M 91.152059,146.15 v 6.29987 H 102.47075 V 146.15 Z"/> 108 + <path style="fill:#444444;fill-opacity:1;stroke:none;stroke-width:0.15800072" d="m 65.619251,136.75006 v 4.69998 H 86.554408 V 146.15 h 15.916342 v 6.29987 h 20.86023 V 146.15 h -15.87449 v -4.69996 H 91.152059 v -4.69998 z"/> 109 + <path style="fill:#222222;fill-opacity:1;stroke:none;stroke-width:0.21712606" d="m 91.152059,136.75006 v 4.69998 H 107.45649 V 146.15 h 15.87449 v 6.29987 h 16.03777 v -6.29987 -4.69996 -4.69998 z"/> 110 + <path style="fill:#777777;fill-opacity:1;stroke:none;stroke-width:0.20201708" d="M 33.668747,141.45004 V 146.15 h 41.740088 v -4.69996 z M 75.408835,146.15 v 6.29987 H 91.152059 V 146.15 Z"/> 111 + <path d="m 33.668823,146.14999 h 41.74001 v 6.3 h -41.74001 z" style="fill:#888888;fill-opacity:1;stroke:none;stroke-width:0.23388879"/> 112 + </g> 113 + <path d="M -30.969854,-37.120319 H 91.062349 V 99.787579 H -30.969854 Z" style="fill:#cccccc;fill-opacity:1;stroke-width:0.26458332"/> 114 + <path d="M -15.075892,-21.040775 H 74.98512 v 67.75 h -90.061012 z" style="fill:#ccccff;fill-opacity:1;stroke-width:0.26458332"/> 115 + <path transform="scale(0.26458333)" d="M 102.17383,-23.402344 V 59.882812 H 83.148438 V 78.779297 H 102.17383 120 120.0508 V -23.402344 Z" style="fill:#000000;fill-opacity:1;stroke-width:0.93718952"/> 116 + <path d="M -30.969856,-43.220318 H 91.062347 v 6.1 H -30.969856 Z" style="fill:#000000;fill-opacity:1;stroke-width:1.13749063"/> 117 + <path d="M -15.075892,-27.140776 H 74.98512 v 6.1 h -90.061012 z" style="fill:#444444;fill-opacity:1;stroke-width:0.97719014"/> 118 + <path d="m -21.040775,15.075892 h 67.75 v 6.1 h -67.75 z" style="fill:#444444;fill-opacity:1;stroke-width:0.84755003" transform="rotate(90)"/> 119 + <path d="m -21.040775,-81.085121 h 67.75 v 6.1 h -67.75 z" style="fill:#ffffff;fill-opacity:1;stroke-width:0.84755009" transform="rotate(90)"/> 120 + <path d="m -15.07589,46.709225 h 90.061013 v 6.1 H -15.07589 Z" style="fill:#ffffff;fill-opacity:1;stroke-width:0.9771902"/> 121 + <path d="m 31.655506,73.81324 h 43.400002 v 5 H 31.655506 Z" style="fill:#000000;fill-opacity:1;stroke-width:0.26445001"/> 122 + <path d="m 31.655506,78.81324 h 43.400005 v 6 H 31.655506 Z" style="fill:#ffffff;fill-opacity:1;stroke-width:0.28969046"/> 123 + <path d="m -21.133041,73.785721 h 11.060395 v 5 h -11.060395 z" style="fill:#00bb00;fill-opacity:1;stroke-width:0.13350084"/> 124 + <path d="m -21.133041,78.785721 h 11.060396 v 6 h -11.060396 z" style="fill:#dd0000;fill-opacity:1;stroke-width:0.14624284"/> 125 + <path d="M 5.8799295,-6.1919641 H 10.87993 V 5.0080357 H 5.8799295 Z" style="fill:#000000;fill-opacity:1;stroke-width:0.26576424"/> 126 + <path d="m 47.880306,-6.1919641 h 6.1 V 5.0080357 h -6.1 z" style="fill:#000000;fill-opacity:1;stroke-width:0.29354623"/> 127 + <path d="m 10.8871,25.947487 h 5 v 6 h -5 z" style="fill:#000000;fill-opacity:1;stroke-width:0.19451953"/> 128 + <path d="m 38.149635,25.944651 h 4.75 v 6.002836 h -4.75 z" style="fill:#000000;fill-opacity:1;stroke-width:0.18963902"/> 129 + <path d="m 15.8871,31.947487 h 22.262533 v 5.011021 H 15.8871 Z" style="fill:#000000;fill-opacity:1;stroke-width:11.12128639"/> 130 + <path d="M -37.120319,30.969854 H 99.787579 v 4.6 H -37.120319 Z" style="fill:#000000;fill-opacity:1;stroke-width:1.04625833" transform="rotate(90)"/> 131 + <path d="M -37.120331,-95.662346 H 99.787582 v 4.6 H -37.120331 Z" style="fill:#000000;fill-opacity:1;stroke-width:1.04625833" transform="rotate(90)"/> 132 + </g> 133 + </g> 134 + </svg> 135 + </div> 136 + {/if} 137 + 138 + <style> 139 + .happy-mac { 140 + position: fixed; 141 + bottom: 0; 142 + z-index: 9999; 143 + pointer-events: none; 144 + animation: hop 0.6s ease-in-out infinite; 145 + } 146 + 147 + .mac-icon { 148 + filter: drop-shadow(2px 2px 4px rgba(0, 0, 0, 0.3)); 149 + } 150 + 151 + @keyframes hop { 152 + 0%, 153 + 100% { 154 + transform: translateY(0) rotate(0deg) scaleY(1) scaleX(1); 155 + } 156 + 25% { 157 + transform: translateY(-10px) rotate(2deg) scaleY(1.15) scaleX(0.9); 158 + } 159 + 50% { 160 + transform: translateY(-20px) rotate(5deg) scaleY(1) scaleX(1); 161 + } 162 + 75% { 163 + transform: translateY(-10px) rotate(2deg) scaleY(0.85) scaleX(1.1); 164 + } 165 + } 166 + 167 + /* Add a little tilt alternation */ 168 + .happy-mac:hover { 169 + animation: hop 0.3s ease-in-out infinite; 170 + } 171 + </style>
+43 -22
src/lib/components/layout/Footer.svelte
··· 1 1 <script lang="ts"> 2 + import { onMount } from 'svelte'; 3 + import { fetchProfile, fetchSiteInfo } from '$lib/services/atproto'; 2 4 import type { ProfileData, SiteInfoData } from '$lib/services/atproto'; 3 5 import DecimalClock from './DecimalClock.svelte'; 6 + import { happyMacStore } from '$lib/stores'; 4 7 5 - export let profile: ProfileData | null = null; 6 - export let siteInfo: SiteInfoData | null = null; 7 - let loading = false; 8 - let error: string | null = null; 9 - let copyrightText: string; 8 + let profile: ProfileData | null = $state(null); 9 + let siteInfo: SiteInfoData | null = $state(null); 10 + let loading = $state(true); 11 + let error: string | null = $state(null); 10 12 11 13 const currentYear = new Date().getFullYear(); 12 14 13 - $: { 14 - console.log('[Footer] Reactive: siteInfo updated:', siteInfo); 15 + // Show click count hint after 3 clicks 16 + let showHint = $derived($happyMacStore.clickCount >= 3 && $happyMacStore.clickCount < 24); 17 + 18 + // Compute copyright text reactively 19 + let copyrightText = $derived.by(() => { 15 20 const birthYear = siteInfo?.additionalInfo?.websiteBirthYear; 16 - console.log('[Footer] Current year:', currentYear); 17 - console.log('[Footer] Birth year:', birthYear); 18 - console.log('[Footer] Birth year type:', typeof birthYear); 19 21 20 22 if (!birthYear || typeof birthYear !== 'number') { 21 - console.log('[Footer] Using current year (invalid/missing birth year)'); 22 - copyrightText = `${currentYear}`; 23 + return `${currentYear}`; 23 24 } else if (birthYear > currentYear) { 24 - console.log('[Footer] Using current year (birth year in future)'); 25 - copyrightText = `${currentYear}`; 25 + return `${currentYear}`; 26 26 } else if (birthYear === currentYear) { 27 - console.log('[Footer] Using current year (birth year equals current)'); 28 - copyrightText = `${currentYear}`; 27 + return `${currentYear}`; 29 28 } else { 30 - console.log('[Footer] Using year range'); 31 - copyrightText = `${birthYear} - ${currentYear}`; 29 + return `${birthYear} - ${currentYear}`; 32 30 } 33 - } 31 + }); 34 32 35 - // Data is provided by layout load; no client-side fetch here to avoid using window.fetch during navigation. 33 + // Fetch data client-side for non-blocking layout 34 + onMount(async () => { 35 + try { 36 + // Fetch both in parallel 37 + const [profileData, siteInfoData] = await Promise.all([ 38 + fetchProfile().catch(() => null), 39 + fetchSiteInfo().catch(() => null) 40 + ]); 41 + profile = profileData; 42 + siteInfo = siteInfoData; 43 + } catch (err) { 44 + error = err instanceof Error ? err.message : 'Failed to load footer data'; 45 + } finally { 46 + loading = false; 47 + } 48 + }); 36 49 </script> 37 50 38 51 <footer ··· 79 92 class="underline hover:text-primary-500 focus-visible:text-primary-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-600 dark:hover:text-primary-400 dark:focus-visible:text-primary-400" 80 93 aria-label="View source code on GitHub">code</a 81 94 > 82 - <!-- Line 3: Version number --> 83 - <span aria-label="Version 10.3.2">v10.3.2</span> 95 + <!-- Line 3: Version number (click 24 times for easter egg!) --> 96 + <button 97 + type="button" 98 + onclick={() => happyMacStore.incrementClick()} 99 + class="cursor-default select-none transition-colors hover:text-ink-600 dark:hover:text-ink-300" 100 + aria-label="Version 10.5.0{showHint ? ` - ${$happyMacStore.clickCount} of 24 clicks` : ''}" 101 + title={showHint ? `${$happyMacStore.clickCount}/24` : ''} 102 + > 103 + v10.5.0{#if showHint}<span class="ml-1 text-xs opacity-60">({$happyMacStore.clickCount}/24)</span>{/if} 104 + </button> 84 105 </div> 85 106 </div> 86 107
+3
src/lib/components/layout/main/card/ProfileCard.svelte
··· 112 112 {safeProfile.displayName || safeProfile.handle} 113 113 </h2> 114 114 <p class="font-medium text-ink-700 dark:text-ink-200">@{safeProfile.handle}</p> 115 + {#if safeProfile.pronouns} 116 + <p class="text-sm italic text-ink-600 dark:text-ink-300">{safeProfile.pronouns}</p> 117 + {/if} 115 118 116 119 {#if safeProfile.description} 117 120 <p
+95
src/lib/config/cache.config.ts
··· 1 + import { dev } from '$app/environment'; 2 + 3 + /** 4 + * Cache configuration with environment-aware TTL values 5 + * 6 + * Development: Shorter TTLs for faster iteration 7 + * Production: Longer TTLs to reduce API calls and prevent timeouts 8 + */ 9 + 10 + // Parse environment variable or use default (in milliseconds) 11 + const getEnvTTL = (key: string, defaultMinutes: number): number => { 12 + if (typeof process !== 'undefined' && process.env?.[key]) { 13 + const minutes = parseInt(process.env[key], 10); 14 + return isNaN(minutes) ? defaultMinutes * 60 * 1000 : minutes * 60 * 1000; 15 + } 16 + return defaultMinutes * 60 * 1000; 17 + }; 18 + 19 + /** 20 + * Default TTL values (in minutes) for different data types 21 + * 22 + * Profile data changes infrequently, so we can cache it longer 23 + * Music and Kibun statuses change frequently, so shorter cache 24 + */ 25 + const DEFAULT_TTL = { 26 + // Profile data: 60 minutes (changes infrequently) 27 + PROFILE: dev ? 5 : 60, 28 + 29 + // Site info: 120 minutes (rarely changes) 30 + SITE_INFO: dev ? 5 : 120, 31 + 32 + // Links: 60 minutes (changes occasionally) 33 + LINKS: dev ? 5 : 60, 34 + 35 + // Music status: 10 minutes (changes frequently) 36 + MUSIC_STATUS: dev ? 2 : 10, 37 + 38 + // Kibun status: 15 minutes (changes occasionally) 39 + KIBUN_STATUS: dev ? 2 : 15, 40 + 41 + // Tangled repos: 60 minutes (changes occasionally) 42 + TANGLED_REPOS: dev ? 5 : 60, 43 + 44 + // Blog posts: 30 minutes (balance between freshness and performance) 45 + BLOG_POSTS: dev ? 5 : 30, 46 + 47 + // Publications: 60 minutes (rarely changes) 48 + PUBLICATIONS: dev ? 5 : 60, 49 + 50 + // Individual posts: 60 minutes (content doesn't change) 51 + INDIVIDUAL_POST: dev ? 5 : 60, 52 + 53 + // Identity resolution: 1440 minutes (24 hours - DIDs are stable) 54 + IDENTITY: dev ? 30 : 1440 55 + }; 56 + 57 + /** 58 + * Cache TTL configuration 59 + * Values are loaded from environment variables with fallbacks to defaults 60 + */ 61 + export const CACHE_TTL = { 62 + PROFILE: getEnvTTL('CACHE_TTL_PROFILE', DEFAULT_TTL.PROFILE), 63 + SITE_INFO: getEnvTTL('CACHE_TTL_SITE_INFO', DEFAULT_TTL.SITE_INFO), 64 + LINKS: getEnvTTL('CACHE_TTL_LINKS', DEFAULT_TTL.LINKS), 65 + MUSIC_STATUS: getEnvTTL('CACHE_TTL_MUSIC_STATUS', DEFAULT_TTL.MUSIC_STATUS), 66 + KIBUN_STATUS: getEnvTTL('CACHE_TTL_KIBUN_STATUS', DEFAULT_TTL.KIBUN_STATUS), 67 + TANGLED_REPOS: getEnvTTL('CACHE_TTL_TANGLED_REPOS', DEFAULT_TTL.TANGLED_REPOS), 68 + BLOG_POSTS: getEnvTTL('CACHE_TTL_BLOG_POSTS', DEFAULT_TTL.BLOG_POSTS), 69 + PUBLICATIONS: getEnvTTL('CACHE_TTL_PUBLICATIONS', DEFAULT_TTL.PUBLICATIONS), 70 + INDIVIDUAL_POST: getEnvTTL('CACHE_TTL_INDIVIDUAL_POST', DEFAULT_TTL.INDIVIDUAL_POST), 71 + IDENTITY: getEnvTTL('CACHE_TTL_IDENTITY', DEFAULT_TTL.IDENTITY) 72 + } as const; 73 + 74 + /** 75 + * HTTP Cache-Control header values for different routes 76 + * These tell browsers and CDNs how long to cache responses 77 + * 78 + * Format: max-age=X (browser cache), s-maxage=Y (CDN cache), stale-while-revalidate=Z 79 + */ 80 + export const HTTP_CACHE_HEADERS = { 81 + // Layout data (profile, site info) - cache aggressively 82 + LAYOUT: `public, max-age=${CACHE_TTL.PROFILE / 1000}, s-maxage=${CACHE_TTL.PROFILE / 1000}, stale-while-revalidate=${CACHE_TTL.PROFILE / 1000}`, 83 + 84 + // Blog posts listing - moderate caching 85 + BLOG_LISTING: `public, max-age=${CACHE_TTL.BLOG_POSTS / 1000}, s-maxage=${CACHE_TTL.BLOG_POSTS / 1000}, stale-while-revalidate=${CACHE_TTL.BLOG_POSTS / 1000}`, 86 + 87 + // Individual blog post - cache aggressively (content doesn't change) 88 + BLOG_POST: `public, max-age=${CACHE_TTL.INDIVIDUAL_POST / 1000}, s-maxage=${CACHE_TTL.INDIVIDUAL_POST / 1000}, stale-while-revalidate=${CACHE_TTL.INDIVIDUAL_POST / 1000}`, 89 + 90 + // Music status - short cache (changes frequently) 91 + MUSIC_STATUS: `public, max-age=${CACHE_TTL.MUSIC_STATUS / 1000}, s-maxage=${CACHE_TTL.MUSIC_STATUS / 1000}, stale-while-revalidate=${CACHE_TTL.MUSIC_STATUS / 1000}`, 92 + 93 + // API endpoints - moderate caching 94 + API: `public, max-age=300, s-maxage=300, stale-while-revalidate=600` 95 + } as const;
+1
src/lib/config/index.ts
··· 1 1 export * from './slugs'; 2 + export * from './cache.config';
+14
src/lib/services/atproto/agents.ts
··· 1 1 import { AtpAgent } from '@atproto/api'; 2 2 import type { ResolvedIdentity } from './types'; 3 + import { cache } from './cache'; 3 4 4 5 /** 5 6 * Creates an AtpAgent with optional fetch function injection ··· 46 47 47 48 /** 48 49 * Resolves a DID to find its PDS endpoint using Slingshot. 50 + * Results are cached to reduce resolution calls. 49 51 */ 50 52 export async function resolveIdentity( 51 53 did: string, ··· 53 55 ): Promise<ResolvedIdentity> { 54 56 console.info(`[Identity] Resolving DID: ${did}`); 55 57 58 + // Check cache first 59 + const cacheKey = `identity:${did}`; 60 + const cached = cache.get<ResolvedIdentity>(cacheKey); 61 + if (cached) { 62 + console.info('[Identity] Using cached identity resolution'); 63 + return cached; 64 + } 65 + 56 66 // Prefer an injected fetch (from SvelteKit load), fall back to global fetch 57 67 const _fetch = fetchFn ?? globalThis.fetch; 58 68 ··· 84 94 if (!data.did || !data.pds) { 85 95 throw new Error('Invalid response from identity resolver'); 86 96 } 97 + 98 + // Cache the resolved identity 99 + console.info('[Identity] Caching resolved identity'); 100 + cache.set(cacheKey, data); 87 101 88 102 return data; 89 103 }
+35 -9
src/lib/services/atproto/cache.ts
··· 1 1 import type { CacheEntry } from './types'; 2 + import { CACHE_TTL } from '$lib/config/cache.config'; 2 3 3 4 /** 4 - * Simple in-memory cache with TTL support 5 + * Simple in-memory cache with configurable TTL support 6 + * 7 + * TTL values are configured per data type in cache.config.ts 8 + * and can be overridden via environment variables 5 9 */ 6 10 export class ATProtoCache { 7 11 private cache = new Map<string, CacheEntry<any>>(); 8 - private readonly TTL = 5 * 60 * 1000; // 5 minutes 12 + 13 + /** 14 + * Get TTL for a cache key based on its prefix 15 + */ 16 + private getTTL(key: string): number { 17 + if (key.startsWith('profile:')) return CACHE_TTL.PROFILE; 18 + if (key.startsWith('siteinfo:')) return CACHE_TTL.SITE_INFO; 19 + if (key.startsWith('links:')) return CACHE_TTL.LINKS; 20 + if (key.startsWith('music-status:')) return CACHE_TTL.MUSIC_STATUS; 21 + if (key.startsWith('kibun-status:')) return CACHE_TTL.KIBUN_STATUS; 22 + if (key.startsWith('tangled:')) return CACHE_TTL.TANGLED_REPOS; 23 + if (key.startsWith('blog-posts:')) return CACHE_TTL.BLOG_POSTS; 24 + if (key.startsWith('publications:')) return CACHE_TTL.PUBLICATIONS; 25 + if (key.startsWith('post:')) return CACHE_TTL.INDIVIDUAL_POST; 26 + if (key.startsWith('identity:')) return CACHE_TTL.IDENTITY; 27 + 28 + // Default fallback (30 minutes) 29 + return 30 * 60 * 1000; 30 + } 9 31 10 32 get<T>(key: string): T | null { 11 - console.debug(`[Cache] Getting key: ${key}`); 33 + console.info(`[Cache] Getting key: ${key}`); 12 34 const entry = this.cache.get(key); 13 35 if (!entry) { 14 - console.debug(`[Cache] Cache miss for key: ${key}`); 36 + console.info(`[Cache] Cache miss for key: ${key}`); 15 37 return null; 16 38 } 17 39 18 - if (Date.now() - entry.timestamp > this.TTL) { 19 - console.debug(`[Cache] Entry expired for key: ${key}`); 40 + const ttl = this.getTTL(key); 41 + const age = Date.now() - entry.timestamp; 42 + 43 + if (age > ttl) { 44 + console.info(`[Cache] Entry expired for key: ${key} (age: ${Math.round(age / 1000)}s, ttl: ${Math.round(ttl / 1000)}s)`); 20 45 this.cache.delete(key); 21 46 return null; 22 47 } 23 48 24 - console.debug(`[Cache] Cache hit for key: ${key}`); 49 + console.info(`[Cache] Cache hit for key: ${key} (age: ${Math.round(age / 1000)}s, ttl: ${Math.round(ttl / 1000)}s)`); 25 50 return entry.data; 26 51 } 27 52 28 53 set<T>(key: string, data: T): void { 29 - console.debug(`[Cache] Setting key: ${key}`, data); 54 + const ttl = this.getTTL(key); 55 + console.info(`[Cache] Setting key: ${key} (ttl: ${Math.round(ttl / 1000)}s)`); 30 56 this.cache.set(key, { 31 57 data, 32 58 timestamp: Date.now() ··· 34 60 } 35 61 36 62 delete(key: string): void { 37 - console.debug(`[Cache] Deleting key: ${key}`); 63 + console.info(`[Cache] Deleting key: ${key}`); 38 64 this.cache.delete(key); 39 65 } 40 66
+27 -1
src/lib/services/atproto/fetch.ts
··· 40 40 fetchFn 41 41 ); 42 42 43 + // Fetch the actual profile record to get pronouns and other fields 44 + // The profile view doesn't include pronouns, so we need the record 45 + let pronouns: string | undefined; 46 + try { 47 + console.debug('[Profile] Attempting to fetch profile record for pronouns'); 48 + const recordResponse = await withFallback( 49 + PUBLIC_ATPROTO_DID, 50 + async (agent) => { 51 + const response = await agent.com.atproto.repo.getRecord({ 52 + repo: PUBLIC_ATPROTO_DID, 53 + collection: 'app.bsky.actor.profile', 54 + rkey: 'self' 55 + }); 56 + return response.data; 57 + }, 58 + false, 59 + fetchFn 60 + ); 61 + pronouns = (recordResponse.value as any).pronouns; 62 + console.debug('[Profile] Successfully fetched pronouns:', pronouns); 63 + } catch (error) { 64 + console.debug('[Profile] Could not fetch profile record for pronouns:', error); 65 + // Continue without pronouns if record fetch fails 66 + } 67 + 43 68 const data: ProfileData = { 44 69 did: profile.did, 45 70 handle: profile.handle, ··· 49 74 banner: profile.banner, 50 75 followersCount: profile.followersCount, 51 76 followsCount: profile.followsCount, 52 - postsCount: profile.postsCount 77 + postsCount: profile.postsCount, 78 + pronouns: pronouns 53 79 }; 54 80 55 81 console.info('[Profile] Successfully fetched profile data');
+2
src/lib/services/atproto/types.ts
··· 12 12 followersCount?: number; 13 13 followsCount?: number; 14 14 postsCount?: number; 15 + pronouns?: string; 15 16 } 16 17 17 18 export interface StatusData { ··· 150 151 handle: string; 151 152 displayName?: string; 152 153 avatar?: string; 154 + pronouns?: string; 153 155 } 154 156 155 157 export interface BlueskyPost {
+29
src/lib/stores/happyMac.ts
··· 1 + import { writable } from 'svelte/store'; 2 + 3 + interface HappyMacState { 4 + clickCount: number; 5 + isTriggered: boolean; 6 + } 7 + 8 + function createHappyMacStore() { 9 + const { subscribe, set, update } = writable<HappyMacState>({ 10 + clickCount: 0, 11 + isTriggered: false 12 + }); 13 + 14 + return { 15 + subscribe, 16 + incrementClick: () => 17 + update((state) => { 18 + const newCount = state.clickCount + 1; 19 + // Trigger when reaching 24 clicks (Mac announcement date: 24/01/1984) 20 + if (newCount === 24) { 21 + return { clickCount: newCount, isTriggered: true }; 22 + } 23 + return { ...state, clickCount: newCount }; 24 + }), 25 + reset: () => set({ clickCount: 0, isTriggered: false }) 26 + }; 27 + } 28 + 29 + export const happyMacStore = createHappyMacStore();
+1
src/lib/stores/index.ts
··· 1 1 export { wolfMode } from './wolfMode'; 2 2 export { colorThemeDropdownOpen } from './dropdownState'; 3 + export { happyMacStore } from './happyMac';
+5 -1
src/routes/+layout.svelte
··· 1 1 <script lang="ts"> 2 2 import '../app.css'; 3 3 import { Header, Footer, ScrollToTop } from '$lib/components/layout'; 4 + import HappyMacEasterEgg from '$lib/components/HappyMacEasterEgg.svelte'; 4 5 import { MetaTags } from '$lib/components/seo'; 5 6 import { createSiteMeta, type SiteMetadata } from '$lib/helper/siteMeta'; 6 7 import type { ProfileData, SiteInfoData } from '$lib/services/atproto'; ··· 92 93 {@render children()} 93 94 </main> 94 95 95 - <Footer profile={data.profile} siteInfo={data.siteInfo} /> 96 + <Footer /> 97 + 98 + <!-- Easter egg: Happy Mac walks across the screen (click version number 24 times!) --> 99 + <HappyMacEasterEgg /> 96 100 </div>
+13 -20
src/routes/+layout.ts
··· 1 1 import type { LayoutLoad } from './$types'; 2 2 import { createSiteMeta, type SiteMetadata, defaultSiteMeta } from '$lib/helper/siteMeta'; 3 - import { fetchProfile, fetchSiteInfo } from '$lib/services/atproto'; 4 3 5 - export const load: LayoutLoad = async ({ url, fetch }) => { 4 + /** 5 + * Non-blocking layout load 6 + * Returns immediately with default site metadata 7 + * All data fetching happens client-side in components for faster initial page load 8 + */ 9 + export const load: LayoutLoad = async ({ url }) => { 6 10 // Provide the default site metadata 7 11 const siteMeta: SiteMetadata = createSiteMeta({ 8 12 title: defaultSiteMeta.title, ··· 10 14 url: url.href // Include current URL for proper OG tags 11 15 }); 12 16 13 - // Fetch lightweight public data for layout using injected fetch 14 - let profile = null; 15 - let siteInfo = null; 16 - 17 - try { 18 - profile = await fetchProfile(fetch); 19 - } catch (err) { 20 - // Non-fatal: layout should still render even if profile fails 21 - console.warn('Layout: failed to fetch profile in load', err); 22 - } 23 - 24 - try { 25 - siteInfo = await fetchSiteInfo(fetch); 26 - } catch (err) { 27 - console.warn('Layout: failed to fetch siteInfo in load', err); 28 - } 29 - 30 - return { siteMeta, profile, siteInfo }; 17 + // Return immediately - no blocking data fetches 18 + // Components will fetch their own data client-side with skeletons 19 + return { 20 + siteMeta, 21 + profile: null, 22 + siteInfo: null 23 + }; 31 24 };