+25
.env.example
+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
+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
package.json
+23
-1
src/hooks.server.ts
+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
+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>
+3
src/lib/components/layout/main/card/ProfileCard.svelte
+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
+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;
+14
src/lib/services/atproto/agents.ts
+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
+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
+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
+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
+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
src/lib/stores/index.ts
+5
-1
src/routes/+layout.svelte
+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
+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
};