+158
-168
.cspell.json
+158
-168
.cspell.json
···
1
1
{
2
-
"version": "0.2",
3
-
"language": "en",
4
-
"words": [
5
-
"ACTIVITYPUB",
6
-
"apdisk",
7
-
"apos",
8
-
"ardenivanov",
9
-
"atproto",
10
-
"ATPROTOCOL",
11
-
"AWOO",
12
-
"bandcamp",
13
-
"Batarong",
14
-
"Behaviour",
15
-
"bgbsh",
16
-
"blogposts",
17
-
"blueskypost",
18
-
"bradlc",
19
-
"Brainz",
20
-
"bsky",
21
-
"Caddyfile",
22
-
"cailean",
23
-
"Caligraphic",
24
-
"CASL",
25
-
"Centralised",
26
-
"colour",
27
-
"colours",
28
-
"Containerisation",
29
-
"containerised",
30
-
"CRSV",
31
-
"cssnano",
32
-
"customisable",
33
-
"Customisation",
34
-
"customisations",
35
-
"Customise",
36
-
"customised",
37
-
"dbaeumer",
38
-
"Decentralised",
39
-
"Deezer",
40
-
"diddoc",
41
-
"Dids",
42
-
"Dockerised",
43
-
"donotpresent",
44
-
"dotenv",
45
-
"Dragonsbreath",
46
-
"eamodio",
47
-
"esbenp",
48
-
"eslintcache",
49
-
"Ewan",
50
-
"Ewan's",
51
-
"ewanc",
52
-
"ewancroft",
53
-
"Ewans",
54
-
"extralarge",
55
-
"fediverse",
56
-
"Fira",
57
-
"Flexbox",
58
-
"formulahendry",
59
-
"FOUC",
60
-
"fseventsd",
61
-
"fullsize",
62
-
"genericised",
63
-
"greenwerewolf",
64
-
"greenwolf",
65
-
"gwicbkc",
66
-
"icns",
67
-
"initialise",
68
-
"Instgrm",
69
-
"introend",
70
-
"isrc",
71
-
"jscoverage",
72
-
"jspm",
73
-
"jzfcijpj",
74
-
"katex",
75
-
"kohler",
76
-
"kzcijpj",
77
-
"Licence",
78
-
"licences",
79
-
"linecap",
80
-
"linkat",
81
-
"lish",
82
-
"maxage",
83
-
"MBID",
84
-
"Mbps",
85
-
"mdcontent",
86
-
"mdposts",
87
-
"Microbundle",
88
-
"Moonshadow",
89
-
"mpegurl",
90
-
"Multibase",
91
-
"Multikey",
92
-
"musicbrainz",
93
-
"myhandle",
94
-
"nktqepol",
95
-
"nomoji",
96
-
"nosniff",
97
-
"nuxt",
98
-
"ofrbh",
99
-
"ogimage",
100
-
"OKLCH",
101
-
"optimisation",
102
-
"Optimised",
103
-
"organisation",
104
-
"pdsurl",
105
-
"Personalising",
106
-
"pids",
107
-
"precomposed",
108
-
"prioritisation",
109
-
"Prioritised",
110
-
"qimg",
111
-
"recentfm",
112
-
"referrerpolicy",
113
-
"repost",
114
-
"reposters",
115
-
"reposts",
116
-
"resvg",
117
-
"Resvg",
118
-
"rgba",
119
-
"Rickroll",
120
-
"rkey",
121
-
"Rkeys",
122
-
"rknight",
123
-
"Sanitise",
124
-
"scrobbler",
125
-
"scrobbling",
126
-
"searchi",
127
-
"shapeshifting",
128
-
"siteinfo",
129
-
"slnt",
130
-
"Spacedust",
131
-
"steeze",
132
-
"stylelintcache",
133
-
"svelte",
134
-
"timemachine",
135
-
"ttfb",
136
-
"Varepsilon",
137
-
"vercel",
138
-
"vercelignore",
139
-
"Vite",
140
-
"vuepress",
141
-
"vurl",
142
-
"WCAG",
143
-
"wght",
144
-
"whitebreeze",
145
-
"WhiteWind",
146
-
"whtwnd",
147
-
"wscript",
148
-
"Wyrmrest",
149
-
"xrpc"
150
-
],
151
-
"flagWords": [],
152
-
"ignorePaths": [
153
-
"node_modules",
154
-
"package-lock.json",
155
-
"dist",
156
-
"build"
157
-
],
158
-
"ignoreRegExpList": [
159
-
"/(\\w+)'s/g"
160
-
],
161
-
"overrides": [
162
-
{
163
-
"filename": "**/*.svelte",
164
-
"ignoreRegExpList": [
165
-
"/>.*</",
166
-
"/(\\w+)'s/g"
167
-
]
168
-
}
169
-
]
2
+
"version": "0.2",
3
+
"language": "en",
4
+
"words": [
5
+
"ACTIVITYPUB",
6
+
"apdisk",
7
+
"apos",
8
+
"ardenivanov",
9
+
"atproto",
10
+
"ATPROTOCOL",
11
+
"AWOO",
12
+
"bandcamp",
13
+
"Batarong",
14
+
"Behaviour",
15
+
"bgbsh",
16
+
"blogposts",
17
+
"blueskypost",
18
+
"bradlc",
19
+
"Brainz",
20
+
"bsky",
21
+
"Caddyfile",
22
+
"cailean",
23
+
"Caligraphic",
24
+
"CASL",
25
+
"Centralised",
26
+
"colour",
27
+
"colours",
28
+
"Containerisation",
29
+
"containerised",
30
+
"CRSV",
31
+
"cssnano",
32
+
"customisable",
33
+
"Customisation",
34
+
"customisations",
35
+
"Customise",
36
+
"customised",
37
+
"dbaeumer",
38
+
"Decentralised",
39
+
"Deezer",
40
+
"diddoc",
41
+
"Dids",
42
+
"Dockerised",
43
+
"donotpresent",
44
+
"dotenv",
45
+
"Dragonsbreath",
46
+
"eamodio",
47
+
"esbenp",
48
+
"eslintcache",
49
+
"Ewan",
50
+
"Ewan's",
51
+
"ewanc",
52
+
"ewancroft",
53
+
"Ewans",
54
+
"extralarge",
55
+
"fediverse",
56
+
"Fira",
57
+
"Flexbox",
58
+
"formulahendry",
59
+
"FOUC",
60
+
"fseventsd",
61
+
"fullsize",
62
+
"genericised",
63
+
"greenwerewolf",
64
+
"greenwolf",
65
+
"gwicbkc",
66
+
"icns",
67
+
"initialise",
68
+
"Instgrm",
69
+
"introend",
70
+
"isrc",
71
+
"jscoverage",
72
+
"jspm",
73
+
"jzfcijpj",
74
+
"katex",
75
+
"kohler",
76
+
"kzcijpj",
77
+
"Licence",
78
+
"licences",
79
+
"linecap",
80
+
"linkat",
81
+
"lish",
82
+
"maxage",
83
+
"MBID",
84
+
"Mbps",
85
+
"mdcontent",
86
+
"mdposts",
87
+
"Microbundle",
88
+
"Moonshadow",
89
+
"mpegurl",
90
+
"Multibase",
91
+
"Multikey",
92
+
"musicbrainz",
93
+
"myhandle",
94
+
"nktqepol",
95
+
"nomoji",
96
+
"nosniff",
97
+
"nuxt",
98
+
"ofrbh",
99
+
"ogimage",
100
+
"OKLCH",
101
+
"optimisation",
102
+
"Optimised",
103
+
"organisation",
104
+
"pdsurl",
105
+
"Personalising",
106
+
"pids",
107
+
"precomposed",
108
+
"prioritisation",
109
+
"Prioritised",
110
+
"qimg",
111
+
"recentfm",
112
+
"referrerpolicy",
113
+
"repost",
114
+
"reposters",
115
+
"reposts",
116
+
"resvg",
117
+
"Resvg",
118
+
"rgba",
119
+
"Rickroll",
120
+
"rkey",
121
+
"Rkeys",
122
+
"rknight",
123
+
"Sanitise",
124
+
"scrobbler",
125
+
"scrobbling",
126
+
"searchi",
127
+
"shapeshifting",
128
+
"siteinfo",
129
+
"slnt",
130
+
"Spacedust",
131
+
"steeze",
132
+
"stylelintcache",
133
+
"svelte",
134
+
"timemachine",
135
+
"ttfb",
136
+
"Varepsilon",
137
+
"vercel",
138
+
"vercelignore",
139
+
"Vite",
140
+
"vuepress",
141
+
"vurl",
142
+
"WCAG",
143
+
"wght",
144
+
"whitebreeze",
145
+
"WhiteWind",
146
+
"whtwnd",
147
+
"wscript",
148
+
"Wyrmrest",
149
+
"xrpc"
150
+
],
151
+
"flagWords": [],
152
+
"ignorePaths": ["node_modules", "package-lock.json", "dist", "build"],
153
+
"ignoreRegExpList": ["/(\\w+)'s/g"],
154
+
"overrides": [
155
+
{
156
+
"filename": "**/*.svelte",
157
+
"ignoreRegExpList": ["/>.*</", "/(\\w+)'s/g"]
158
+
}
159
+
]
170
160
}
+10
-8
.github/ISSUE_TEMPLATE/bug_report.md
+10
-8
.github/ISSUE_TEMPLATE/bug_report.md
···
4
4
title: ''
5
5
labels: ''
6
6
assignees: ''
7
-
8
7
---
9
8
10
9
**Describe the bug**
···
12
11
13
12
**To Reproduce**
14
13
Steps to reproduce the behavior:
14
+
15
15
1. Go to '...'
16
16
2. Click on '....'
17
17
3. Scroll down to '....'
···
24
24
If applicable, add screenshots to help explain your problem.
25
25
26
26
**Desktop (please complete the following information):**
27
-
- OS: [e.g. iOS]
28
-
- Browser [e.g. chrome, safari]
29
-
- Version [e.g. 22]
27
+
28
+
- OS: [e.g. iOS]
29
+
- Browser [e.g. chrome, safari]
30
+
- Version [e.g. 22]
30
31
31
32
**Smartphone (please complete the following information):**
32
-
- Device: [e.g. iPhone6]
33
-
- OS: [e.g. iOS8.1]
34
-
- Browser [e.g. stock browser, safari]
35
-
- Version [e.g. 22]
33
+
34
+
- Device: [e.g. iPhone6]
35
+
- OS: [e.g. iOS8.1]
36
+
- Browser [e.g. stock browser, safari]
37
+
- Version [e.g. 22]
36
38
37
39
**Additional context**
38
40
Add any other context about the problem here.
+6
-6
.vscode/settings.json
+6
-6
.vscode/settings.json
···
1
1
{
2
-
"css.customData": [".vscode/tailwind.json"],
3
-
"css.validate": false,
4
-
"tailwindCSS.includeLanguages": {
5
-
"svelte": "html"
6
-
}
7
-
}
2
+
"css.customData": [".vscode/tailwind.json"],
3
+
"css.validate": false,
4
+
"tailwindCSS.includeLanguages": {
5
+
"svelte": "html"
6
+
}
7
+
}
+54
-54
.vscode/tailwind.json
+54
-54
.vscode/tailwind.json
···
1
1
{
2
-
"version": 1.1,
3
-
"atDirectives": [
4
-
{
5
-
"name": "@tailwind",
6
-
"description": "Use the @tailwind directive to insert Tailwind's base, components, and utilities styles into your CSS.",
7
-
"references": [
8
-
{
9
-
"name": "Tailwind CSS Documentation",
10
-
"url": "https://tailwindcss.com/docs/functions-and-directives#tailwind"
11
-
}
12
-
]
13
-
},
14
-
{
15
-
"name": "@apply",
16
-
"description": "Use @apply to inline any existing utility classes into your own custom CSS.",
17
-
"references": [
18
-
{
19
-
"name": "Tailwind CSS Documentation",
20
-
"url": "https://tailwindcss.com/docs/functions-and-directives#apply"
21
-
}
22
-
]
23
-
},
24
-
{
25
-
"name": "@responsive",
26
-
"description": "You can generate responsive variants of your own classes by wrapping their definitions in the @responsive directive.",
27
-
"references": [
28
-
{
29
-
"name": "Tailwind CSS Documentation",
30
-
"url": "https://tailwindcss.com/docs/functions-and-directives#responsive"
31
-
}
32
-
]
33
-
},
34
-
{
35
-
"name": "@screen",
36
-
"description": "The @screen directive allows you to create media queries that reference your breakpoints by name instead of duplicating their values in your own CSS.",
37
-
"references": [
38
-
{
39
-
"name": "Tailwind CSS Documentation",
40
-
"url": "https://tailwindcss.com/docs/functions-and-directives#screen"
41
-
}
42
-
]
43
-
},
44
-
{
45
-
"name": "@variants",
46
-
"description": "Generate variants for your own utilities by wrapping their definitions in the @variants directive.",
47
-
"references": [
48
-
{
49
-
"name": "Tailwind CSS Documentation",
50
-
"url": "https://tailwindcss.com/docs/functions-and-directives#variants"
51
-
}
52
-
]
53
-
}
54
-
]
55
-
}
2
+
"version": 1.1,
3
+
"atDirectives": [
4
+
{
5
+
"name": "@tailwind",
6
+
"description": "Use the @tailwind directive to insert Tailwind's base, components, and utilities styles into your CSS.",
7
+
"references": [
8
+
{
9
+
"name": "Tailwind CSS Documentation",
10
+
"url": "https://tailwindcss.com/docs/functions-and-directives#tailwind"
11
+
}
12
+
]
13
+
},
14
+
{
15
+
"name": "@apply",
16
+
"description": "Use @apply to inline any existing utility classes into your own custom CSS.",
17
+
"references": [
18
+
{
19
+
"name": "Tailwind CSS Documentation",
20
+
"url": "https://tailwindcss.com/docs/functions-and-directives#apply"
21
+
}
22
+
]
23
+
},
24
+
{
25
+
"name": "@responsive",
26
+
"description": "You can generate responsive variants of your own classes by wrapping their definitions in the @responsive directive.",
27
+
"references": [
28
+
{
29
+
"name": "Tailwind CSS Documentation",
30
+
"url": "https://tailwindcss.com/docs/functions-and-directives#responsive"
31
+
}
32
+
]
33
+
},
34
+
{
35
+
"name": "@screen",
36
+
"description": "The @screen directive allows you to create media queries that reference your breakpoints by name instead of duplicating their values in your own CSS.",
37
+
"references": [
38
+
{
39
+
"name": "Tailwind CSS Documentation",
40
+
"url": "https://tailwindcss.com/docs/functions-and-directives#screen"
41
+
}
42
+
]
43
+
},
44
+
{
45
+
"name": "@variants",
46
+
"description": "Generate variants for your own utilities by wrapping their definitions in the @variants directive.",
47
+
"references": [
48
+
{
49
+
"name": "Tailwind CSS Documentation",
50
+
"url": "https://tailwindcss.com/docs/functions-and-directives#variants"
51
+
}
52
+
]
53
+
}
54
+
]
55
+
}
+17
-17
README.md
+17
-17
README.md
···
125
125
126
126
```typescript
127
127
export const slugMappings: SlugMapping[] = [
128
-
{ slug: 'blog', publicationRkey: '3m3x4bgbsh22k' },
129
-
{ slug: 'essays', publicationRkey: 'abc123xyz' },
130
-
{ slug: 'notes', publicationRkey: 'def456uvw' }
128
+
{ slug: 'blog', publicationRkey: '3m3x4bgbsh22k' },
129
+
{ slug: 'essays', publicationRkey: 'abc123xyz' },
130
+
{ slug: 'notes', publicationRkey: 'def456uvw' }
131
131
];
132
132
```
133
133
···
250
250
### Usage Examples
251
251
252
252
```typescript
253
-
import {
254
-
fetchProfile,
255
-
fetchBlogPosts,
256
-
fetchLatestBlueskyPost,
257
-
fetchMusicStatus,
258
-
fetchTangledRepos
253
+
import {
254
+
fetchProfile,
255
+
fetchBlogPosts,
256
+
fetchLatestBlueskyPost,
257
+
fetchMusicStatus,
258
+
fetchTangledRepos
259
259
} from '$lib/services/atproto';
260
260
261
261
// Fetch profile data
···
284
284
285
285
```typescript
286
286
export const slugMappings: SlugMapping[] = [
287
-
{
288
-
slug: 'blog', // Access via /blog
289
-
publicationRkey: '3m3x4bgbsh22k' // Leaflet publication rkey
290
-
},
291
-
{
292
-
slug: 'notes', // Access via /notes
293
-
publicationRkey: 'xyz123abc'
294
-
}
287
+
{
288
+
slug: 'blog', // Access via /blog
289
+
publicationRkey: '3m3x4bgbsh22k' // Leaflet publication rkey
290
+
},
291
+
{
292
+
slug: 'notes', // Access via /notes
293
+
publicationRkey: 'xyz123abc'
294
+
}
295
295
];
296
296
```
297
297
+98
-92
src/app.css
+98
-92
src/app.css
···
2
2
@import 'tailwindcss';
3
3
4
4
@theme {
5
-
/* Font Family */
6
-
--font-family-sans: 'Inter', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
5
+
/* Font Family */
6
+
--font-family-sans:
7
+
'Inter', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
8
+
'Segoe UI Symbol', 'Noto Color Emoji';
7
9
8
-
/* Ink - Text colors (adjusted for WCAG AA compliance) */
9
-
--color-ink-50: light-dark(oklch(97.31% 0.015 123.04), oklch(17.39% 0.023 124.58));
10
-
--color-ink-100: light-dark(oklch(93.00% 0.032 124.47), oklch(24.90% 0.042 126.80));
11
-
--color-ink-200: light-dark(oklch(85.00% 0.061 123.88), oklch(38.03% 0.070 126.15));
12
-
--color-ink-300: light-dark(oklch(75.00% 0.093 124.99), oklch(50.28% 0.098 126.82));
13
-
--color-ink-400: light-dark(oklch(65.00% 0.123 125.63), oklch(61.88% 0.124 126.72));
14
-
--color-ink-500: light-dark(oklch(55.00% 0.149 127.03), oklch(72.90% 0.149 127.03));
15
-
--color-ink-600: light-dark(oklch(45.00% 0.124 126.72), oklch(78.19% 0.123 125.63));
16
-
--color-ink-700: light-dark(oklch(35.00% 0.098 126.82), oklch(83.50% 0.093 124.99));
17
-
--color-ink-800: light-dark(oklch(25.00% 0.070 126.15), oklch(88.94% 0.061 123.88));
18
-
--color-ink-900: light-dark(oklch(18.00% 0.042 126.80), oklch(94.52% 0.032 124.47));
19
-
--color-ink-950: light-dark(oklch(12.00% 0.023 124.58), oklch(97.31% 0.015 123.04));
10
+
/* Ink - Text colors (adjusted for WCAG AA compliance) */
11
+
--color-ink-50: light-dark(oklch(97.31% 0.015 123.04), oklch(17.39% 0.023 124.58));
12
+
--color-ink-100: light-dark(oklch(93% 0.032 124.47), oklch(24.9% 0.042 126.8));
13
+
--color-ink-200: light-dark(oklch(85% 0.061 123.88), oklch(38.03% 0.07 126.15));
14
+
--color-ink-300: light-dark(oklch(75% 0.093 124.99), oklch(50.28% 0.098 126.82));
15
+
--color-ink-400: light-dark(oklch(65% 0.123 125.63), oklch(61.88% 0.124 126.72));
16
+
--color-ink-500: light-dark(oklch(55% 0.149 127.03), oklch(72.9% 0.149 127.03));
17
+
--color-ink-600: light-dark(oklch(45% 0.124 126.72), oklch(78.19% 0.123 125.63));
18
+
--color-ink-700: light-dark(oklch(35% 0.098 126.82), oklch(83.5% 0.093 124.99));
19
+
--color-ink-800: light-dark(oklch(25% 0.07 126.15), oklch(88.94% 0.061 123.88));
20
+
--color-ink-900: light-dark(oklch(18% 0.042 126.8), oklch(94.52% 0.032 124.47));
21
+
--color-ink-950: light-dark(oklch(12% 0.023 124.58), oklch(97.31% 0.015 123.04));
20
22
21
-
/* Canvas - Background colors (adjusted for better contrast) */
22
-
--color-canvas-50: light-dark(oklch(98.50% 0.010 123.97), oklch(17.69% 0.027 125.57));
23
-
--color-canvas-100: light-dark(oklch(96.50% 0.020 123.69), oklch(25.56% 0.047 126.44));
24
-
--color-canvas-200: light-dark(oklch(92.00% 0.045 125.14), oklch(39.36% 0.083 127.85));
25
-
--color-canvas-300: light-dark(oklch(86.00% 0.075 125.55), oklch(51.84% 0.112 127.68));
26
-
--color-canvas-400: light-dark(oklch(80.00% 0.105 126.87), oklch(63.78% 0.141 128.14));
27
-
--color-canvas-500: light-dark(oklch(75.25% 0.135 128.13), oklch(75.25% 0.169 128.13));
28
-
--color-canvas-600: light-dark(oklch(63.78% 0.141 128.14), oklch(80.00% 0.105 126.87));
29
-
--color-canvas-700: light-dark(oklch(51.84% 0.112 127.68), oklch(86.00% 0.075 125.55));
30
-
--color-canvas-800: light-dark(oklch(39.36% 0.083 127.85), oklch(92.00% 0.045 125.14));
31
-
--color-canvas-900: light-dark(oklch(25.56% 0.047 126.44), oklch(96.50% 0.020 123.69));
32
-
--color-canvas-950: light-dark(oklch(17.69% 0.027 125.57), oklch(98.50% 0.010 123.97));
23
+
/* Canvas - Background colors (adjusted for better contrast) */
24
+
--color-canvas-50: light-dark(oklch(98.5% 0.01 123.97), oklch(17.69% 0.027 125.57));
25
+
--color-canvas-100: light-dark(oklch(96.5% 0.02 123.69), oklch(25.56% 0.047 126.44));
26
+
--color-canvas-200: light-dark(oklch(92% 0.045 125.14), oklch(39.36% 0.083 127.85));
27
+
--color-canvas-300: light-dark(oklch(86% 0.075 125.55), oklch(51.84% 0.112 127.68));
28
+
--color-canvas-400: light-dark(oklch(80% 0.105 126.87), oklch(63.78% 0.141 128.14));
29
+
--color-canvas-500: light-dark(oklch(75.25% 0.135 128.13), oklch(75.25% 0.169 128.13));
30
+
--color-canvas-600: light-dark(oklch(63.78% 0.141 128.14), oklch(80% 0.105 126.87));
31
+
--color-canvas-700: light-dark(oklch(51.84% 0.112 127.68), oklch(86% 0.075 125.55));
32
+
--color-canvas-800: light-dark(oklch(39.36% 0.083 127.85), oklch(92% 0.045 125.14));
33
+
--color-canvas-900: light-dark(oklch(25.56% 0.047 126.44), oklch(96.5% 0.02 123.69));
34
+
--color-canvas-950: light-dark(oklch(17.69% 0.027 125.57), oklch(98.5% 0.01 123.97));
33
35
34
-
/* Sage - Primary colors (adjusted for WCAG AA compliance) */
35
-
--color-primary-50: light-dark(oklch(97.73% 0.020 121.83), oklch(18.09% 0.031 123.74));
36
-
--color-primary-100: light-dark(oklch(94.00% 0.042 123.12), oklch(26.23% 0.053 126.29));
37
-
--color-primary-200: light-dark(oklch(88.00% 0.082 123.68), oklch(40.39% 0.088 126.72));
38
-
--color-primary-300: light-dark(oklch(78.00% 0.122 124.71), oklch(53.63% 0.122 127.17));
39
-
--color-primary-400: light-dark(oklch(68.00% 0.155 125.79), oklch(65.86% 0.152 127.23));
40
-
--color-primary-500: light-dark(oklch(58.00% 0.182 127.42), oklch(77.77% 0.182 127.42));
41
-
--color-primary-600: light-dark(oklch(48.00% 0.152 127.23), oklch(81.83% 0.155 125.79));
42
-
--color-primary-700: light-dark(oklch(38.00% 0.122 127.17), oklch(86.28% 0.122 124.71));
43
-
--color-primary-800: light-dark(oklch(28.00% 0.088 126.72), oklch(90.67% 0.082 123.68));
44
-
--color-primary-900: light-dark(oklch(20.00% 0.053 126.29), oklch(95.38% 0.042 123.12));
45
-
--color-primary-950: light-dark(oklch(14.00% 0.031 123.74), oklch(97.73% 0.020 121.83));
36
+
/* Sage - Primary colors (adjusted for WCAG AA compliance) */
37
+
--color-primary-50: light-dark(oklch(97.73% 0.02 121.83), oklch(18.09% 0.031 123.74));
38
+
--color-primary-100: light-dark(oklch(94% 0.042 123.12), oklch(26.23% 0.053 126.29));
39
+
--color-primary-200: light-dark(oklch(88% 0.082 123.68), oklch(40.39% 0.088 126.72));
40
+
--color-primary-300: light-dark(oklch(78% 0.122 124.71), oklch(53.63% 0.122 127.17));
41
+
--color-primary-400: light-dark(oklch(68% 0.155 125.79), oklch(65.86% 0.152 127.23));
42
+
--color-primary-500: light-dark(oklch(58% 0.182 127.42), oklch(77.77% 0.182 127.42));
43
+
--color-primary-600: light-dark(oklch(48% 0.152 127.23), oklch(81.83% 0.155 125.79));
44
+
--color-primary-700: light-dark(oklch(38% 0.122 127.17), oklch(86.28% 0.122 124.71));
45
+
--color-primary-800: light-dark(oklch(28% 0.088 126.72), oklch(90.67% 0.082 123.68));
46
+
--color-primary-900: light-dark(oklch(20% 0.053 126.29), oklch(95.38% 0.042 123.12));
47
+
--color-primary-950: light-dark(oklch(14% 0.031 123.74), oklch(97.73% 0.02 121.83));
46
48
47
-
/* Mint - Secondary colors (adjusted for WCAG AA compliance) */
48
-
--color-secondary-50: light-dark(oklch(97.87% 0.024 121.90), oklch(18.72% 0.037 126.20));
49
-
--color-secondary-100: light-dark(oklch(94.50% 0.048 123.90), oklch(26.82% 0.058 127.38));
50
-
--color-secondary-200: light-dark(oklch(89.00% 0.097 124.41), oklch(42.08% 0.101 128.02));
51
-
--color-secondary-300: light-dark(oklch(80.00% 0.141 125.62), oklch(55.72% 0.137 128.49));
52
-
--color-secondary-400: light-dark(oklch(70.00% 0.178 127.04), oklch(68.58% 0.171 128.75));
53
-
--color-secondary-500: light-dark(oklch(60.00% 0.205 129.04), oklch(81.09% 0.205 129.04));
54
-
--color-secondary-600: light-dark(oklch(50.00% 0.171 128.75), oklch(84.30% 0.178 127.04));
55
-
--color-secondary-700: light-dark(oklch(40.00% 0.137 128.49), oklch(87.99% 0.141 125.62));
56
-
--color-secondary-800: light-dark(oklch(30.00% 0.101 128.02), oklch(91.89% 0.097 124.41));
57
-
--color-secondary-900: light-dark(oklch(22.00% 0.058 127.38), oklch(95.73% 0.048 123.90));
58
-
--color-secondary-950: light-dark(oklch(15.00% 0.037 126.20), oklch(97.87% 0.024 121.90));
49
+
/* Mint - Secondary colors (adjusted for WCAG AA compliance) */
50
+
--color-secondary-50: light-dark(oklch(97.87% 0.024 121.9), oklch(18.72% 0.037 126.2));
51
+
--color-secondary-100: light-dark(oklch(94.5% 0.048 123.9), oklch(26.82% 0.058 127.38));
52
+
--color-secondary-200: light-dark(oklch(89% 0.097 124.41), oklch(42.08% 0.101 128.02));
53
+
--color-secondary-300: light-dark(oklch(80% 0.141 125.62), oklch(55.72% 0.137 128.49));
54
+
--color-secondary-400: light-dark(oklch(70% 0.178 127.04), oklch(68.58% 0.171 128.75));
55
+
--color-secondary-500: light-dark(oklch(60% 0.205 129.04), oklch(81.09% 0.205 129.04));
56
+
--color-secondary-600: light-dark(oklch(50% 0.171 128.75), oklch(84.3% 0.178 127.04));
57
+
--color-secondary-700: light-dark(oklch(40% 0.137 128.49), oklch(87.99% 0.141 125.62));
58
+
--color-secondary-800: light-dark(oklch(30% 0.101 128.02), oklch(91.89% 0.097 124.41));
59
+
--color-secondary-900: light-dark(oklch(22% 0.058 127.38), oklch(95.73% 0.048 123.9));
60
+
--color-secondary-950: light-dark(oklch(15% 0.037 126.2), oklch(97.87% 0.024 121.9));
59
61
60
-
/* Jade - Accent colors (adjusted for WCAG AA compliance) */
61
-
--color-accent-50: light-dark(oklch(98.05% 0.027 122.65), oklch(19.03% 0.041 126.73));
62
-
--color-accent-100: light-dark(oklch(95.00% 0.056 123.80), oklch(27.78% 0.066 127.71));
63
-
--color-accent-200: light-dark(oklch(90.00% 0.110 124.83), oklch(43.51% 0.110 128.91));
64
-
--color-accent-300: light-dark(oklch(82.00% 0.159 126.06), oklch(57.90% 0.149 129.35));
65
-
--color-accent-400: light-dark(oklch(72.00% 0.198 127.63), oklch(71.44% 0.186 129.59));
66
-
--color-accent-500: light-dark(oklch(62.00% 0.221 129.75), oklch(84.36% 0.221 129.75));
67
-
--color-accent-600: light-dark(oklch(52.00% 0.186 129.59), oklch(86.93% 0.198 127.63));
68
-
--color-accent-700: light-dark(oklch(42.00% 0.149 129.35), oklch(89.79% 0.159 126.06));
69
-
--color-accent-800: light-dark(oklch(32.00% 0.110 128.91), oklch(92.93% 0.110 124.83));
70
-
--color-accent-900: light-dark(oklch(23.00% 0.066 127.71), oklch(96.35% 0.056 123.80));
71
-
--color-accent-950: light-dark(oklch(16.00% 0.041 126.73), oklch(98.05% 0.027 122.65));
62
+
/* Jade - Accent colors (adjusted for WCAG AA compliance) */
63
+
--color-accent-50: light-dark(oklch(98.05% 0.027 122.65), oklch(19.03% 0.041 126.73));
64
+
--color-accent-100: light-dark(oklch(95% 0.056 123.8), oklch(27.78% 0.066 127.71));
65
+
--color-accent-200: light-dark(oklch(90% 0.11 124.83), oklch(43.51% 0.11 128.91));
66
+
--color-accent-300: light-dark(oklch(82% 0.159 126.06), oklch(57.9% 0.149 129.35));
67
+
--color-accent-400: light-dark(oklch(72% 0.198 127.63), oklch(71.44% 0.186 129.59));
68
+
--color-accent-500: light-dark(oklch(62% 0.221 129.75), oklch(84.36% 0.221 129.75));
69
+
--color-accent-600: light-dark(oklch(52% 0.186 129.59), oklch(86.93% 0.198 127.63));
70
+
--color-accent-700: light-dark(oklch(42% 0.149 129.35), oklch(89.79% 0.159 126.06));
71
+
--color-accent-800: light-dark(oklch(32% 0.11 128.91), oklch(92.93% 0.11 124.83));
72
+
--color-accent-900: light-dark(oklch(23% 0.066 127.71), oklch(96.35% 0.056 123.8));
73
+
--color-accent-950: light-dark(oklch(16% 0.041 126.73), oklch(98.05% 0.027 122.65));
72
74
}
73
75
74
76
@layer base {
75
-
/* Base styles for consistent typography and accessibility */
76
-
html {
77
-
scroll-behavior: smooth;
78
-
overflow-x: hidden;
79
-
width: 100%;
80
-
}
77
+
/* Base styles for consistent typography and accessibility */
78
+
html {
79
+
scroll-behavior: smooth;
80
+
overflow-x: hidden;
81
+
width: 100%;
82
+
}
83
+
84
+
body {
85
+
font-family: var(--font-family-sans);
86
+
text-rendering: optimizeLegibility;
87
+
-webkit-font-smoothing: antialiased;
88
+
-moz-osx-font-smoothing: grayscale;
89
+
overflow-x: hidden;
90
+
width: 100%;
91
+
max-width: 100vw;
92
+
}
93
+
94
+
/* Focus visible styles for accessibility */
95
+
*:focus-visible {
96
+
outline: 2px solid var(--color-primary-600);
97
+
outline-offset: 2px;
98
+
}
81
99
82
-
body {
83
-
font-family: var(--font-family-sans);
84
-
text-rendering: optimizeLegibility;
85
-
-webkit-font-smoothing: antialiased;
86
-
-moz-osx-font-smoothing: grayscale;
87
-
overflow-x: hidden;
88
-
width: 100%;
89
-
max-width: 100vw;
90
-
}
100
+
/* Ensure all elements stay within viewport */
101
+
* {
102
+
min-width: 0;
103
+
}
91
104
92
-
/* Focus visible styles for accessibility */
93
-
*:focus-visible {
94
-
outline: 2px solid var(--color-primary-600);
95
-
outline-offset: 2px;
96
-
}
97
-
98
-
/* Ensure all elements stay within viewport */
99
-
* {
100
-
min-width: 0;
101
-
}
102
-
103
-
img, video, iframe, embed, object {
104
-
max-width: 100%;
105
-
height: auto;
106
-
}
105
+
img,
106
+
video,
107
+
iframe,
108
+
embed,
109
+
object {
110
+
max-width: 100%;
111
+
height: auto;
112
+
}
107
113
}
108
114
109
115
@plugin '@tailwindcss/typography';
+4
-4
src/hooks.server.ts
+4
-4
src/hooks.server.ts
···
3
3
4
4
/**
5
5
* Global request handler with CORS support
6
-
*
6
+
*
7
7
* CORS headers are dynamically configured via the PUBLIC_CORS_ALLOWED_ORIGINS environment variable.
8
8
* Set it to a comma-separated list of allowed origins, or "*" to allow all origins.
9
9
*/
···
11
11
// Handle OPTIONS preflight requests for CORS
12
12
if (event.request.method === 'OPTIONS' && event.url.pathname.startsWith('/api/')) {
13
13
const origin = event.request.headers.get('origin');
14
-
const allowedOrigins = PUBLIC_CORS_ALLOWED_ORIGINS?.split(',').map(o => o.trim()) || [];
14
+
const allowedOrigins = PUBLIC_CORS_ALLOWED_ORIGINS?.split(',').map((o) => o.trim()) || [];
15
15
16
16
const headers: Record<string, string> = {
17
17
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
···
38
38
// Add CORS headers for API routes
39
39
if (event.url.pathname.startsWith('/api/')) {
40
40
const origin = event.request.headers.get('origin');
41
-
const allowedOrigins = PUBLIC_CORS_ALLOWED_ORIGINS?.split(',').map(o => o.trim()) || [];
41
+
const allowedOrigins = PUBLIC_CORS_ALLOWED_ORIGINS?.split(',').map((o) => o.trim()) || [];
42
42
43
43
// If * is specified, allow any origin
44
44
if (allowedOrigins.includes('*')) {
···
55
55
}
56
56
57
57
return response;
58
-
};
58
+
};
+12
-4
src/lib/components/layout/Header.svelte
+12
-4
src/lib/components/layout/Header.svelte
···
9
9
const siteMeta: SiteMetadata = createSiteMeta(defaultSiteMeta);
10
10
</script>
11
11
12
-
<header class="sticky top-0 z-50 w-full border-b border-canvas-200 bg-canvas-50/90 backdrop-blur-md dark:border-canvas-800 dark:bg-canvas-950/90">
13
-
<nav class="container mx-auto flex items-center justify-between px-4 py-4" aria-label="Main navigation">
12
+
<header
13
+
class="sticky top-0 z-50 w-full border-b border-canvas-200 bg-canvas-50/90 backdrop-blur-md dark:border-canvas-800 dark:bg-canvas-950/90"
14
+
>
15
+
<nav
16
+
class="container mx-auto flex items-center justify-between px-4 py-4"
17
+
aria-label="Main navigation"
18
+
>
14
19
<a href="/" class="group flex min-w-0 items-center gap-2">
15
-
<span class="max-w-[200px] truncate text-xl font-bold text-ink-900 dark:text-ink-50" aria-label="{siteMeta.title} - Home">
20
+
<span
21
+
class="max-w-[200px] truncate text-xl font-bold text-ink-900 dark:text-ink-50"
22
+
aria-label="{siteMeta.title} - Home"
23
+
>
16
24
{siteMeta.title}
17
25
</span>
18
26
</a>
···
25
33
</div>
26
34
</div>
27
35
</nav>
28
-
</header>
36
+
</header>
+7
-7
src/lib/components/layout/ThemeToggle.svelte
+7
-7
src/lib/components/layout/ThemeToggle.svelte
···
9
9
// Check localStorage and system preference
10
10
const stored = localStorage.getItem('theme');
11
11
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
12
-
12
+
13
13
isDark = stored === 'dark' || (!stored && prefersDark);
14
14
updateTheme();
15
15
mounted = true;
···
31
31
32
32
function updateTheme() {
33
33
const htmlElement = document.documentElement;
34
-
34
+
35
35
if (isDark) {
36
36
htmlElement.classList.add('dark');
37
37
htmlElement.style.colorScheme = 'dark';
···
58
58
<div class="relative h-5 w-5">
59
59
<Sun
60
60
class="absolute inset-0 h-5 w-5 transition-all duration-300 {isDark
61
-
? 'rotate-90 scale-0 opacity-0'
62
-
: 'rotate-0 scale-100 opacity-100'}"
61
+
? 'scale-0 rotate-90 opacity-0'
62
+
: 'scale-100 rotate-0 opacity-100'}"
63
63
aria-hidden="true"
64
64
/>
65
65
<Moon
66
66
class="absolute inset-0 h-5 w-5 transition-all duration-300 {isDark
67
-
? 'rotate-0 scale-100 opacity-100'
68
-
: '-rotate-90 scale-0 opacity-0'}"
67
+
? 'scale-100 rotate-0 opacity-100'
68
+
: 'scale-0 -rotate-90 opacity-0'}"
69
69
aria-hidden="true"
70
70
/>
71
71
</div>
72
72
{:else}
73
-
<div class="h-5 w-5 animate-pulse bg-canvas-300 dark:bg-canvas-700 rounded"></div>
73
+
<div class="h-5 w-5 animate-pulse rounded bg-canvas-300 dark:bg-canvas-700"></div>
74
74
{/if}
75
75
</button>
+1
-1
src/lib/components/layout/WolfToggle.svelte
+1
-1
src/lib/components/layout/WolfToggle.svelte
+4
-3
src/lib/components/layout/main/DynamicLinks.svelte
+4
-3
src/lib/components/layout/main/DynamicLinks.svelte
···
50
50
{#snippet children()}
51
51
<div class="text-center">
52
52
<p class="text-ink-700 dark:text-ink-300">
53
-
No links available. Create a <code
54
-
class="rounded bg-canvas-200 px-1 dark:bg-canvas-800">blue.linkat.board</code
55
-
> record at
53
+
No links available. Create a <code class="rounded bg-canvas-200 px-1 dark:bg-canvas-800"
54
+
>blue.linkat.board</code
55
+
>
56
+
record at
56
57
<a
57
58
href="https://linkat.blue/"
58
59
class="text-primary-600 hover:underline dark:text-primary-400"
+33
-33
src/lib/components/layout/main/ScrollToTop.svelte
+33
-33
src/lib/components/layout/main/ScrollToTop.svelte
···
1
1
<script lang="ts">
2
-
import { onMount } from "svelte";
3
-
import { ChevronUp } from "@lucide/svelte";
2
+
import { onMount } from 'svelte';
3
+
import { ChevronUp } from '@lucide/svelte';
4
4
5
-
let isVisible = false;
6
-
let scrollY = 0;
5
+
let isVisible = false;
6
+
let scrollY = 0;
7
7
8
-
$: isVisible = scrollY > 300;
8
+
$: isVisible = scrollY > 300;
9
9
10
-
function scrollToTop() {
11
-
window.scrollTo({ top: 0, behavior: "smooth" });
12
-
}
10
+
function scrollToTop() {
11
+
window.scrollTo({ top: 0, behavior: 'smooth' });
12
+
}
13
13
14
-
function handleKeydown(event: KeyboardEvent) {
15
-
if (event.key === "Enter" || event.key === " ") {
16
-
event.preventDefault();
17
-
scrollToTop();
18
-
}
19
-
}
14
+
function handleKeydown(event: KeyboardEvent) {
15
+
if (event.key === 'Enter' || event.key === ' ') {
16
+
event.preventDefault();
17
+
scrollToTop();
18
+
}
19
+
}
20
20
21
-
onMount(() => {
22
-
const updateScrollY = () => (scrollY = window.scrollY);
23
-
window.addEventListener("scroll", updateScrollY, { passive: true });
24
-
return () => window.removeEventListener("scroll", updateScrollY);
25
-
});
21
+
onMount(() => {
22
+
const updateScrollY = () => (scrollY = window.scrollY);
23
+
window.addEventListener('scroll', updateScrollY, { passive: true });
24
+
return () => window.removeEventListener('scroll', updateScrollY);
25
+
});
26
26
</script>
27
27
28
28
<svelte:window bind:scrollY />
29
29
30
30
<!-- just Tailwind fade via opacity -->
31
31
<div
32
-
class="fixed bottom-8 left-8 z-50 sm:bottom-6 sm:left-6 transition-opacity duration-300 motion-reduce:transition-none"
33
-
class:opacity-100={isVisible}
34
-
class:opacity-0={!isVisible}
32
+
class="fixed bottom-8 left-8 z-50 transition-opacity duration-300 motion-reduce:transition-none sm:bottom-6 sm:left-6"
33
+
class:opacity-100={isVisible}
34
+
class:opacity-0={!isVisible}
35
35
>
36
-
<button
37
-
on:click={scrollToTop}
38
-
on:keydown={handleKeydown}
39
-
aria-label="Scroll to top"
40
-
title="Scroll to top"
41
-
type="button"
42
-
class="flex h-12 w-12 items-center justify-center rounded-full border bg-canvas-100 text-ink-900 border-primary-200 shadow-lg transition-all duration-300 ease-out hover:-translate-y-0.5 hover:bg-primary-500 hover:text-ink-50 hover:shadow-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 dark:bg-canvas-900 dark:text-ink-50 dark:border-primary-800 dark:hover:bg-primary-600 motion-reduce:transition-none motion-reduce:hover:translate-y-0 sm:h-11 sm:w-11"
43
-
>
44
-
<ChevronUp width="20" height="20" aria-hidden="true" />
45
-
</button>
46
-
</div>
36
+
<button
37
+
on:click={scrollToTop}
38
+
on:keydown={handleKeydown}
39
+
aria-label="Scroll to top"
40
+
title="Scroll to top"
41
+
type="button"
42
+
class="flex h-12 w-12 items-center justify-center rounded-full border border-primary-200 bg-canvas-100 text-ink-900 shadow-lg transition-all duration-300 ease-out hover:-translate-y-0.5 hover:bg-primary-500 hover:text-ink-50 hover:shadow-xl focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:outline-none motion-reduce:transition-none motion-reduce:hover:translate-y-0 sm:h-11 sm:w-11 dark:border-primary-800 dark:bg-canvas-900 dark:text-ink-50 dark:hover:bg-primary-600"
43
+
>
44
+
<ChevronUp width="20" height="20" aria-hidden="true" />
45
+
</button>
46
+
</div>
+2
-7
src/lib/components/layout/main/TangledRepos.svelte
+2
-7
src/lib/components/layout/main/TangledRepos.svelte
···
11
11
12
12
onMount(async () => {
13
13
try {
14
-
const [reposData, profile] = await Promise.all([
15
-
fetchTangledRepos(),
16
-
fetchProfile()
17
-
]);
14
+
const [reposData, profile] = await Promise.all([fetchTangledRepos(), fetchProfile()]);
18
15
repos = reposData;
19
16
handle = profile.handle;
20
17
} catch (err) {
···
43
40
{@const safeRepos = repos}
44
41
<Card variant="elevated" padding="md">
45
42
{#snippet children()}
46
-
<h2 class="mb-4 text-2xl font-bold text-ink-900 dark:text-ink-50">
47
-
Tangled Repositories
48
-
</h2>
43
+
<h2 class="mb-4 text-2xl font-bold text-ink-900 dark:text-ink-50">Tangled Repositories</h2>
49
44
<div class="space-y-3">
50
45
{#each safeRepos.repos as repo}
51
46
<TangledRepoCard {repo} {handle} />
+93
-58
src/lib/components/layout/main/card/BlueskyPostCard.svelte
+93
-58
src/lib/components/layout/main/card/BlueskyPostCard.svelte
···
93
93
94
94
for (const facet of sortedFacets) {
95
95
const { byteStart, byteEnd } = facet.index;
96
-
96
+
97
97
// Extract text before facet
98
98
if (lastByteIndex < byteStart) {
99
99
const beforeBytes = bytes.slice(lastByteIndex, byteStart);
100
100
result += escapeHtml(decoder.decode(beforeBytes));
101
101
}
102
-
102
+
103
103
// Extract facet text
104
104
const facetBytes = bytes.slice(byteStart, byteEnd);
105
105
const facetText = decoder.decode(facetBytes);
···
198
198
href={getProfileUrl(postData.author.handle)}
199
199
target="_blank"
200
200
rel="noopener noreferrer"
201
-
class="transition-opacity hover:opacity-80 shrink-0"
201
+
class="shrink-0 transition-opacity hover:opacity-80"
202
202
>
203
203
{#if postData.author.avatar}
204
204
<img
205
205
src={postData.author.avatar}
206
206
alt={postData.author.displayName || postData.author.handle}
207
-
class="h-8 w-8 sm:h-10 sm:w-10 rounded-full object-cover"
207
+
class="h-8 w-8 rounded-full object-cover sm:h-10 sm:w-10"
208
208
loading="lazy"
209
209
/>
210
210
{:else}
211
211
<div
212
-
class="flex h-8 w-8 sm:h-10 sm:w-10 items-center justify-center rounded-full bg-primary-200 dark:bg-primary-800"
212
+
class="flex h-8 w-8 items-center justify-center rounded-full bg-primary-200 sm:h-10 sm:w-10 dark:bg-primary-800"
213
213
>
214
-
<span class="text-sm sm:text-base font-semibold text-primary-700 dark:text-primary-300">
214
+
<span
215
+
class="text-sm font-semibold text-primary-700 sm:text-base dark:text-primary-300"
216
+
>
215
217
{(postData.author.displayName || postData.author.handle).charAt(0).toUpperCase()}
216
218
</span>
217
219
</div>
···
222
224
href={getProfileUrl(postData.author.handle)}
223
225
target="_blank"
224
226
rel="noopener noreferrer"
225
-
class="transition-opacity hover:opacity-80 shrink-0"
227
+
class="shrink-0 transition-opacity hover:opacity-80"
226
228
>
227
229
{#if postData.author.avatar}
228
230
<img
229
231
src={postData.author.avatar}
230
232
alt={postData.author.displayName || postData.author.handle}
231
-
class="h-10 w-10 sm:h-12 sm:w-12 rounded-full object-cover"
233
+
class="h-10 w-10 rounded-full object-cover sm:h-12 sm:w-12"
232
234
loading="lazy"
233
235
/>
234
236
{:else}
235
237
<div
236
-
class="flex h-10 w-10 sm:h-12 sm:w-12 items-center justify-center rounded-full bg-primary-200 dark:bg-primary-800"
238
+
class="flex h-10 w-10 items-center justify-center rounded-full bg-primary-200 sm:h-12 sm:w-12 dark:bg-primary-800"
237
239
>
238
-
<span class="text-base sm:text-lg font-semibold text-primary-700 dark:text-primary-300">
240
+
<span
241
+
class="text-base font-semibold text-primary-700 sm:text-lg dark:text-primary-300"
242
+
>
239
243
{(postData.author.displayName || postData.author.handle).charAt(0).toUpperCase()}
240
244
</span>
241
245
</div>
242
246
{/if}
243
247
</a>
244
248
{/if}
245
-
<div class="flex-1 min-w-0">
249
+
<div class="min-w-0 flex-1">
246
250
<!-- Author name and handle -->
247
251
<a
248
252
href={getProfileUrl(postData.author.handle)}
···
251
255
class="inline-block {isReplyParent ? 'mb-1' : 'mb-2'} transition-opacity hover:opacity-80"
252
256
>
253
257
<div class="flex flex-col">
254
-
<span class="text-{isReplyParent ? 'sm' : 'base'} font-semibold text-ink-900 dark:text-ink-50 leading-tight">
258
+
<span
259
+
class="text-{isReplyParent
260
+
? 'sm'
261
+
: 'base'} leading-tight font-semibold text-ink-900 dark:text-ink-50"
262
+
>
255
263
{postData.author.displayName || postData.author.handle}
256
264
</span>
257
-
<span class="text-xs text-ink-600 dark:text-ink-400 leading-tight">
265
+
<span class="text-xs leading-tight text-ink-600 dark:text-ink-400">
258
266
@{postData.author.handle}
259
267
</span>
260
268
</div>
···
262
270
263
271
<!-- Post Text with Rich Text Support -->
264
272
<div
265
-
class="{isReplyParent ? 'mb-2' : 'mb-3'} overflow-wrap-anywhere break-words whitespace-pre-wrap text-{isReplyParent
273
+
class="{isReplyParent
274
+
? 'mb-2'
275
+
: 'mb-3'} overflow-wrap-anywhere break-words whitespace-pre-wrap text-{isReplyParent
266
276
? 'sm'
267
277
: 'base'} leading-relaxed text-ink-900 dark:text-ink-50"
268
278
>
···
271
281
272
282
<!-- Video -->
273
283
{#if postData.hasVideo && postData.videoUrl}
274
-
<div class="{isReplyParent ? 'mb-2' : 'mb-3'} max-w-full overflow-hidden rounded-xl bg-black border border-canvas-300 dark:border-canvas-700">
284
+
<div
285
+
class="{isReplyParent
286
+
? 'mb-2'
287
+
: 'mb-3'} max-w-full overflow-hidden rounded-xl border border-canvas-300 bg-black dark:border-canvas-700"
288
+
>
275
289
<video
276
290
use:setupVideo={postData.videoUrl}
277
291
controls
···
289
303
<!-- Images -->
290
304
{#if postData.hasImages && postData.imageUrls && postData.imageUrls.length > 0}
291
305
<div
292
-
class="{isReplyParent ? 'mb-2' : 'mb-3'} grid max-w-full gap-1 {postData.imageUrls.length === 1
306
+
class="{isReplyParent ? 'mb-2' : 'mb-3'} grid max-w-full gap-1 {postData.imageUrls
307
+
.length === 1
293
308
? 'grid-cols-1'
294
309
: postData.imageUrls.length === 2
295
310
? 'grid-cols-2'
···
299
314
>
300
315
{#each postData.imageUrls as imageUrl, index}
301
316
<button
302
-
type="button"
303
-
onclick={() =>
304
-
openLightbox(imageUrl, postData.imageAlts?.[index] || `Post attachment ${index + 1}`)}
305
-
class="h-auto w-full max-w-full overflow-hidden rounded-lg transition-opacity hover:opacity-90 focus:ring-2 focus:ring-primary-500 focus:outline-none dark:focus:ring-primary-400 border border-canvas-300 dark:border-canvas-700"
306
-
title={postData.imageAlts?.[index] || `Post attachment ${index + 1}`}
317
+
type="button"
318
+
onclick={() =>
319
+
openLightbox(
320
+
imageUrl,
321
+
postData.imageAlts?.[index] || `Post attachment ${index + 1}`
322
+
)}
323
+
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
+
title={postData.imageAlts?.[index] || `Post attachment ${index + 1}`}
307
325
>
308
-
<img
309
-
src={imageUrl}
310
-
alt={postData.imageAlts?.[index] || `Post attachment ${index + 1}`}
311
-
title={postData.imageAlts?.[index] || `Post attachment ${index + 1}`}
312
-
class="h-auto w-full max-w-full object-cover {postData.imageUrls.length === 4
313
-
? 'aspect-square'
314
-
: postData.imageUrls.length > 1
315
-
? 'aspect-video'
316
-
: isReplyParent
317
-
? 'max-h-64'
318
-
: 'max-h-96'}"
319
-
loading="lazy"
320
-
/>
321
-
</button>
326
+
<img
327
+
src={imageUrl}
328
+
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
+
/>
339
+
</button>
322
340
{/each}
323
341
</div>
324
342
{/if}
···
329
347
href={postData.externalLink.uri}
330
348
target="_blank"
331
349
rel="noopener noreferrer"
332
-
class="{isReplyParent ? 'mb-2' : '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"
350
+
class="{isReplyParent
351
+
? 'mb-2'
352
+
: '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"
333
353
>
334
354
{#if postData.externalLink.thumb}
335
355
<img
···
341
361
{/if}
342
362
<div class="p-3">
343
363
<h3
344
-
class="mb-1 overflow-wrap-anywhere break-words text-sm font-semibold text-ink-900 dark:text-ink-50 line-clamp-2"
364
+
class="overflow-wrap-anywhere mb-1 line-clamp-2 text-sm font-semibold break-words text-ink-900 dark:text-ink-50"
345
365
>
346
366
{postData.externalLink.title}
347
367
</h3>
348
368
{#if postData.externalLink.description}
349
369
<p
350
-
class="mb-2 overflow-wrap-anywhere break-words text-xs text-ink-700 dark:text-ink-300 line-clamp-2"
370
+
class="overflow-wrap-anywhere mb-2 line-clamp-2 text-xs break-words text-ink-700 dark:text-ink-300"
351
371
>
352
372
{postData.externalLink.description}
353
373
</p>
354
374
{/if}
355
-
<p class="overflow-wrap-anywhere break-words text-xs text-ink-600 dark:text-ink-400">
375
+
<p class="overflow-wrap-anywhere text-xs break-words text-ink-600 dark:text-ink-400">
356
376
{new URL(postData.externalLink.uri).hostname}
357
377
</p>
358
378
</div>
···
361
381
362
382
<!-- Recursively render quoted post -->
363
383
{#if postData.quotedPost && depth < 3}
364
-
<div class="{isReplyParent ? 'mb-2' : 'mb-3'} rounded-xl border border-canvas-300 bg-canvas-200 p-3 dark:border-canvas-700 dark:bg-canvas-800">
384
+
<div
385
+
class="{isReplyParent
386
+
? 'mb-2'
387
+
: 'mb-3'} rounded-xl border border-canvas-300 bg-canvas-200 p-3 dark:border-canvas-700 dark:bg-canvas-800"
388
+
>
365
389
{@render postContent(postData.quotedPost, depth + 1, depth === 0)}
366
390
</div>
367
391
{/if}
368
392
369
393
<!-- Engagement Stats (only for non-reply-parent posts) -->
370
394
{#if !isReplyParent}
371
-
<div class="flex flex-wrap items-center gap-3 sm:gap-6 text-xs sm:text-sm pt-1">
395
+
<div class="flex flex-wrap items-center gap-3 pt-1 text-xs sm:gap-6 sm:text-sm">
372
396
{#if postData.replyCount !== undefined}
373
-
<div class="flex items-center gap-1 sm:gap-1.5 text-ink-600 dark:text-ink-400">
397
+
<div class="flex items-center gap-1 text-ink-600 sm:gap-1.5 dark:text-ink-400">
374
398
<MessageCircle class="h-3.5 w-3.5 sm:h-4 sm:w-4" aria-hidden="true" />
375
399
<span class="font-medium">{formatCompactNumber(postData.replyCount, locale)}</span>
376
400
</div>
377
401
{/if}
378
402
379
403
{#if postData.repostCount !== undefined}
380
-
<div class="flex items-center gap-1 sm:gap-1.5 text-ink-600 dark:text-ink-400">
404
+
<div class="flex items-center gap-1 text-ink-600 sm:gap-1.5 dark:text-ink-400">
381
405
<Repeat2 class="h-3.5 w-3.5 sm:h-4 sm:w-4" aria-hidden="true" />
382
406
<span class="font-medium">{formatCompactNumber(postData.repostCount, locale)}</span>
383
407
</div>
384
408
{/if}
385
409
386
410
{#if postData.likeCount !== undefined}
387
-
<div class="flex items-center gap-1 sm:gap-1.5 text-ink-600 dark:text-ink-400">
411
+
<div class="flex items-center gap-1 text-ink-600 sm:gap-1.5 dark:text-ink-400">
388
412
<Heart class="h-3.5 w-3.5 sm:h-4 sm:w-4" aria-hidden="true" />
389
413
<span class="font-medium">{formatCompactNumber(postData.likeCount, locale)}</span>
390
414
</div>
···
440
464
{:else if error}
441
465
<Card error={true} errorMessage={error} />
442
466
{:else if post}
443
-
<article class="rounded-xl bg-canvas-100 p-4 sm:p-6 shadow-lg transition-all duration-300 hover:shadow-xl dark:bg-canvas-900">
467
+
<article
468
+
class="rounded-xl bg-canvas-100 p-4 shadow-lg transition-all duration-300 hover:shadow-xl sm:p-6 dark:bg-canvas-900"
469
+
>
444
470
<!-- Header -->
445
-
<div class="mb-3 sm:mb-4 flex items-start sm:items-center justify-between gap-2">
446
-
<div class="flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-2 min-w-0">
447
-
<span class="text-xs font-semibold tracking-wide text-ink-700 uppercase dark:text-ink-300 whitespace-nowrap">
471
+
<div class="mb-3 flex items-start justify-between gap-2 sm:mb-4 sm:items-center">
472
+
<div class="flex min-w-0 flex-col gap-1 sm:flex-row sm:items-center sm:gap-2">
473
+
<span
474
+
class="text-xs font-semibold tracking-wide whitespace-nowrap text-ink-700 uppercase dark:text-ink-300"
475
+
>
448
476
Latest Bluesky Post
449
477
</span>
450
478
{#if post.isRepost && post.repostAuthor}
451
-
<span class="hidden sm:inline text-xs text-ink-600 dark:text-ink-400">·</span>
479
+
<span class="hidden text-xs text-ink-600 sm:inline dark:text-ink-400">·</span>
452
480
<div class="flex items-center gap-1.5 text-xs text-ink-600 dark:text-ink-400">
453
481
<Repeat2 class="h-3 w-3 shrink-0" aria-hidden="true" />
454
482
<a
455
483
href={getProfileUrl(post.repostAuthor.handle)}
456
484
target="_blank"
457
485
rel="noopener noreferrer"
458
-
class="font-medium text-primary-600 transition-colors hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300 truncate"
486
+
class="truncate font-medium text-primary-600 transition-colors hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
459
487
>
460
488
{post.repostAuthor.displayName || post.repostAuthor.handle}
461
489
</a>
462
490
<span class="whitespace-nowrap">reposted</span>
463
491
</div>
464
492
{:else if post.replyParent}
465
-
<span class="hidden sm:inline text-xs text-ink-600 dark:text-ink-400">·</span>
493
+
<span class="hidden text-xs text-ink-600 sm:inline dark:text-ink-400">·</span>
466
494
<div class="flex items-center gap-1.5 text-xs text-ink-600 dark:text-ink-400">
467
495
<MessageCircle class="h-3 w-3 shrink-0" aria-hidden="true" />
468
496
<span class="whitespace-nowrap">Replying to</span>
···
470
498
href={getProfileUrl(post.replyParent.author.handle)}
471
499
target="_blank"
472
500
rel="noopener noreferrer"
473
-
class="font-medium text-primary-600 transition-colors hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300 truncate"
501
+
class="truncate font-medium text-primary-600 transition-colors hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
474
502
>
475
503
@{post.replyParent.author.handle}
476
504
</a>
···
481
509
href={getPostUrl(post.uri)}
482
510
target="_blank"
483
511
rel="noopener noreferrer"
484
-
class="text-ink-600 transition-colors hover:text-primary-600 dark:text-ink-400 dark:hover:text-primary-400 shrink-0"
512
+
class="shrink-0 text-ink-600 transition-colors hover:text-primary-600 dark:text-ink-400 dark:hover:text-primary-400"
485
513
aria-label="View post on Bluesky"
486
514
>
487
515
<ExternalLink class="h-4 w-4" aria-hidden="true" />
···
490
518
491
519
<!-- Reply Context -->
492
520
{#if post.replyParent}
493
-
<div class="mb-3 sm:mb-4 rounded-xl border border-canvas-300 bg-canvas-200 p-2.5 sm:p-3 dark:border-canvas-700 dark:bg-canvas-800">
521
+
<div
522
+
class="mb-3 rounded-xl border border-canvas-300 bg-canvas-200 p-2.5 sm:mb-4 sm:p-3 dark:border-canvas-700 dark:bg-canvas-800"
523
+
>
494
524
{@render postContent(post.replyParent, 0, true)}
495
525
</div>
496
526
{/if}
···
522
552
<button
523
553
type="button"
524
554
onclick={closeLightbox}
525
-
class="absolute top-2 right-2 sm:top-4 sm:right-4 rounded-full bg-black/50 p-2 text-white transition-colors hover:bg-black/70 focus:ring-2 focus:ring-white focus:outline-none z-10"
555
+
class="absolute top-2 right-2 z-10 rounded-full bg-black/50 p-2 text-white transition-colors hover:bg-black/70 focus:ring-2 focus:ring-white focus:outline-none sm:top-4 sm:right-4"
526
556
aria-label="Close"
527
557
>
528
558
<X class="h-5 w-5 sm:h-6 sm:w-6" />
529
559
</button>
530
-
<div class="relative flex max-h-[90vh] w-full max-w-[95vw] sm:max-w-[90vw] flex-col items-center">
560
+
<div
561
+
class="relative flex max-h-[90vh] w-full max-w-[95vw] flex-col items-center sm:max-w-[90vw]"
562
+
>
531
563
<img
532
564
src={lightboxImage.url}
533
565
alt={lightboxImage.alt}
534
566
title={lightboxImage.alt}
535
-
class="max-h-[75vh] sm:max-h-[80vh] w-full object-contain"
567
+
class="max-h-[75vh] w-full object-contain sm:max-h-[80vh]"
536
568
loading="lazy"
537
569
/>
538
570
{#if lightboxImage.alt && lightboxImage.alt !== `Post attachment ${lightboxImage.url.split('/').pop()}`}
539
-
<div class="mt-2 sm:mt-4 w-full max-w-full overflow-y-auto rounded-lg bg-black/70 px-3 py-2 sm:px-4 text-center text-xs sm:text-sm text-white" style="max-height: calc(15vh - 1rem); sm:max-height: calc(10vh - 2rem);">
571
+
<div
572
+
class="mt-2 w-full max-w-full overflow-y-auto rounded-lg bg-black/70 px-3 py-2 text-center text-xs text-white sm:mt-4 sm:px-4 sm:text-sm"
573
+
style="max-height: calc(15vh - 1rem); sm:max-height: calc(10vh - 2rem);"
574
+
>
540
575
{lightboxImage.alt}
541
576
</div>
542
577
{/if}
+85
-77
src/lib/components/layout/main/card/LinkCard.svelte
+85
-77
src/lib/components/layout/main/card/LinkCard.svelte
···
1
1
<script lang="ts">
2
-
import { ExternalLink } from '@lucide/svelte';
2
+
import { ExternalLink } from '@lucide/svelte';
3
3
4
-
interface Badge {
5
-
text: string;
6
-
color?: 'mint' | 'sage';
7
-
}
4
+
interface Badge {
5
+
text: string;
6
+
color?: 'mint' | 'sage';
7
+
}
8
8
9
-
interface Props {
10
-
url: string;
11
-
title: string;
12
-
emoji?: string;
13
-
description?: string;
14
-
badges?: Badge[];
15
-
meta?: string; // e.g., timestamp or extra info
16
-
variant?: 'default' | 'button';
17
-
}
9
+
interface Props {
10
+
url: string;
11
+
title: string;
12
+
emoji?: string;
13
+
description?: string;
14
+
badges?: Badge[];
15
+
meta?: string; // e.g., timestamp or extra info
16
+
variant?: 'default' | 'button';
17
+
}
18
18
19
-
let { url, title, emoji, description, badges, meta, variant = 'default' }: Props = $props();
19
+
let { url, title, emoji, description, badges, meta, variant = 'default' }: Props = $props();
20
20
21
-
function getDomain(url: string): string {
22
-
try {
23
-
const urlObj = new URL(url);
24
-
return urlObj.hostname.replace('www.', '');
25
-
} catch {
26
-
return '';
27
-
}
28
-
}
21
+
function getDomain(url: string): string {
22
+
try {
23
+
const urlObj = new URL(url);
24
+
return urlObj.hostname.replace('www.', '');
25
+
} catch {
26
+
return '';
27
+
}
28
+
}
29
29
30
-
const displayDescription = description || getDomain(url);
30
+
const displayDescription = description || getDomain(url);
31
31
</script>
32
32
33
33
<a
34
-
href={url}
35
-
target="_blank"
36
-
rel="noopener noreferrer"
37
-
class="
34
+
href={url}
35
+
target="_blank"
36
+
rel="noopener noreferrer"
37
+
class="
38
38
{variant === 'button'
39
-
? 'inline-flex items-center justify-center gap-2 rounded-lg bg-canvas-200 px-4 py-3 font-medium text-ink-900 transition-colors duration-200 hover:bg-canvas-300 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-600 dark:bg-canvas-800 dark:text-ink-50 dark:hover:bg-canvas-700'
40
-
: 'flex items-start justify-between gap-3 rounded-lg bg-canvas-200 p-4 transition-colors hover:bg-canvas-300 dark:bg-canvas-800 dark:hover:bg-canvas-700'}
39
+
? 'inline-flex items-center justify-center gap-2 rounded-lg bg-canvas-200 px-4 py-3 font-medium text-ink-900 transition-colors duration-200 hover:bg-canvas-300 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-600 dark:bg-canvas-800 dark:text-ink-50 dark:hover:bg-canvas-700'
40
+
: 'flex items-start justify-between gap-3 rounded-lg bg-canvas-200 p-4 transition-colors hover:bg-canvas-300 dark:bg-canvas-800 dark:hover:bg-canvas-700'}
41
41
"
42
42
>
43
-
{#if variant === 'button'}
44
-
<span class="font-medium">{title}</span>
45
-
<ExternalLink class="h-4 w-4 flex-shrink-0" aria-hidden="true" />
46
-
{:else}
47
-
<div class="flex-1 space-y-1">
48
-
{#if emoji || (badges && badges.length > 0)}
49
-
<div class="flex items-center gap-2 flex-wrap">
50
-
{#if emoji}
51
-
<span class="text-lg leading-none">{emoji}</span>
52
-
{/if}
53
-
{#if badges && badges.length > 0}
54
-
{#each badges as badge}
55
-
{#if badge.color === 'mint'}
56
-
<span class="rounded bg-secondary-100 px-2 py-0.5 text-xs font-medium text-secondary-800 dark:bg-secondary-900 dark:text-secondary-200">
57
-
{badge.text}
58
-
</span>
59
-
{:else if badge.color === 'sage'}
60
-
<span class="rounded bg-primary-100 px-2 py-0.5 text-xs font-medium text-primary-800 dark:bg-primary-900 dark:text-primary-200">
61
-
{badge.text}
62
-
</span>
63
-
{:else}
64
-
<span class="text-xs font-semibold uppercase text-ink-800 dark:text-ink-100">
65
-
{badge.text}
66
-
</span>
67
-
{/if}
68
-
{/each}
69
-
{/if}
70
-
</div>
71
-
{/if}
43
+
{#if variant === 'button'}
44
+
<span class="font-medium">{title}</span>
45
+
<ExternalLink class="h-4 w-4 flex-shrink-0" aria-hidden="true" />
46
+
{:else}
47
+
<div class="flex-1 space-y-1">
48
+
{#if emoji || (badges && badges.length > 0)}
49
+
<div class="flex flex-wrap items-center gap-2">
50
+
{#if emoji}
51
+
<span class="text-lg leading-none">{emoji}</span>
52
+
{/if}
53
+
{#if badges && badges.length > 0}
54
+
{#each badges as badge}
55
+
{#if badge.color === 'mint'}
56
+
<span
57
+
class="rounded bg-secondary-100 px-2 py-0.5 text-xs font-medium text-secondary-800 dark:bg-secondary-900 dark:text-secondary-200"
58
+
>
59
+
{badge.text}
60
+
</span>
61
+
{:else if badge.color === 'sage'}
62
+
<span
63
+
class="rounded bg-primary-100 px-2 py-0.5 text-xs font-medium text-primary-800 dark:bg-primary-900 dark:text-primary-200"
64
+
>
65
+
{badge.text}
66
+
</span>
67
+
{:else}
68
+
<span class="text-xs font-semibold text-ink-800 uppercase dark:text-ink-100">
69
+
{badge.text}
70
+
</span>
71
+
{/if}
72
+
{/each}
73
+
{/if}
74
+
</div>
75
+
{/if}
72
76
73
-
<!-- 👇 Title is always below the badges -->
74
-
<h3 class="overflow-wrap-anywhere break-words font-semibold text-ink-900 dark:text-ink-50">{title}</h3>
77
+
<!-- 👇 Title is always below the badges -->
78
+
<h3 class="overflow-wrap-anywhere font-semibold break-words text-ink-900 dark:text-ink-50">
79
+
{title}
80
+
</h3>
75
81
76
-
{#if displayDescription}
77
-
<p class="overflow-wrap-anywhere break-words text-sm text-ink-700 line-clamp-2 dark:text-ink-200">
78
-
{displayDescription}
79
-
</p>
80
-
{/if}
82
+
{#if displayDescription}
83
+
<p
84
+
class="overflow-wrap-anywhere line-clamp-2 text-sm break-words text-ink-700 dark:text-ink-200"
85
+
>
86
+
{displayDescription}
87
+
</p>
88
+
{/if}
81
89
82
-
{#if meta}
83
-
<p class="text-xs font-medium text-ink-800 dark:text-ink-100">
84
-
{meta}
85
-
</p>
86
-
{/if}
87
-
</div>
90
+
{#if meta}
91
+
<p class="text-xs font-medium text-ink-800 dark:text-ink-100">
92
+
{meta}
93
+
</p>
94
+
{/if}
95
+
</div>
88
96
89
-
<ExternalLink
90
-
class="h-4 w-4 flex-shrink-0 text-ink-700 transition-colors group-hover:text-primary-600 dark:text-ink-200 dark:group-hover:text-primary-400"
91
-
aria-hidden="true"
92
-
/>
93
-
{/if}
97
+
<ExternalLink
98
+
class="h-4 w-4 flex-shrink-0 text-ink-700 transition-colors group-hover:text-primary-600 dark:text-ink-200 dark:group-hover:text-primary-400"
99
+
aria-hidden="true"
100
+
/>
101
+
{/if}
94
102
</a>
+24
-24
src/lib/components/layout/main/card/MusicStatusCard.svelte
+24
-24
src/lib/components/layout/main/card/MusicStatusCard.svelte
···
5
5
import { formatRelativeTime } from '$lib/utils/formatDate';
6
6
7
7
// Icons
8
-
import {
9
-
Music,
10
-
Disc3,
11
-
Users,
12
-
Album,
13
-
Clock,
14
-
Radio
15
-
} from '@lucide/svelte';
8
+
import { Music, Disc3, Users, Album, Clock, Radio } from '@lucide/svelte';
16
9
17
10
let musicStatus: MusicStatusData | null = null;
18
11
let loading = true;
···
37
30
38
31
function formatArtists(artists: { artistName: string }[]): string {
39
32
if (!artists || artists.length === 0) return 'Unknown Artist';
40
-
return artists.map(a => a.artistName).join(', ');
33
+
return artists.map((a) => a.artistName).join(', ');
41
34
}
42
35
43
36
function formatDuration(seconds?: number): string {
···
76
69
</div>
77
70
{/snippet}
78
71
</Card>
79
-
80
72
{:else if error}
81
73
<Card error={true} errorMessage={error} />
82
-
83
74
{:else if musicStatus}
84
75
{@const safeMusicStatus = musicStatus}
85
76
<Card variant="elevated" padding="md">
86
77
{#snippet children()}
87
78
<div class="flex items-start gap-4">
88
-
89
79
<!-- Artwork -->
90
80
<div class="flex-shrink-0">
91
81
{#if safeMusicStatus.artworkUrl && !artworkError}
···
97
87
onerror={handleImageError}
98
88
/>
99
89
{:else}
100
-
<div class="h-20 w-20 rounded-lg bg-canvas-200 dark:bg-canvas-700 flex items-center justify-center shadow-md">
90
+
<div
91
+
class="flex h-20 w-20 items-center justify-center rounded-lg bg-canvas-200 shadow-md dark:bg-canvas-700"
92
+
>
101
93
<Disc3 class="h-10 w-10 text-ink-500 dark:text-ink-400" aria-hidden="true" />
102
94
</div>
103
95
{/if}
104
96
</div>
105
97
106
98
<!-- Info -->
107
-
<div class="flex-1 min-w-0">
99
+
<div class="min-w-0 flex-1">
108
100
<!-- Header (Now Listening / Last Played) -->
109
101
<div class="mb-2 flex items-center gap-2">
110
102
<Music class="h-4 w-4 text-primary-600 dark:text-primary-400" aria-hidden="true" />
111
-
<span class="text-xs font-semibold tracking-wide text-ink-800 uppercase dark:text-ink-100">
103
+
<span
104
+
class="text-xs font-semibold tracking-wide text-ink-800 uppercase dark:text-ink-100"
105
+
>
112
106
{safeMusicStatus.$type === 'fm.teal.alpha.actor.status'
113
107
? 'Now Listening'
114
108
: 'Last Played'}
···
117
111
118
112
<!-- Content -->
119
113
<div class="mb-2">
120
-
121
114
<!-- Track Name -->
122
115
<a
123
116
href={safeMusicStatus.originUrl || '#'}
124
117
target="_blank"
125
118
rel="noopener noreferrer"
126
-
class="block text-lg font-semibold text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300 transition-colors whitespace-normal break-words max-w-full"
119
+
class="block max-w-full text-lg font-semibold break-words whitespace-normal text-primary-600 transition-colors hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
127
120
class:pointer-events-none={!safeMusicStatus.originUrl}
128
121
class:cursor-default={!safeMusicStatus.originUrl}
129
122
class:opacity-70={!safeMusicStatus.originUrl}
···
132
125
</a>
133
126
134
127
<!-- Artists -->
135
-
<p class="mt-1 flex items-start gap-1.5 text-base text-ink-800 dark:text-ink-100 whitespace-normal break-words max-w-full">
136
-
<Users class="h-4 w-4 text-ink-600 dark:text-ink-300 flex-shrink-0 mt-0.5" />
128
+
<p
129
+
class="mt-1 flex max-w-full items-start gap-1.5 text-base break-words whitespace-normal text-ink-800 dark:text-ink-100"
130
+
>
131
+
<Users class="mt-0.5 h-4 w-4 flex-shrink-0 text-ink-600 dark:text-ink-300" />
137
132
{formatArtists(safeMusicStatus.artists)}
138
133
</p>
139
134
140
135
<!-- Album + Duration -->
141
136
{#if safeMusicStatus.releaseName}
142
-
<p class="mt-1 flex items-start gap-1.5 text-sm text-ink-700 dark:text-ink-200 whitespace-normal break-words max-w-full">
143
-
<Album class="h-4 w-4 text-ink-500 dark:text-ink-400 flex-shrink-0 mt-0.5" />
137
+
<p
138
+
class="mt-1 flex max-w-full items-start gap-1.5 text-sm break-words whitespace-normal text-ink-700 dark:text-ink-200"
139
+
>
140
+
<Album class="mt-0.5 h-4 w-4 flex-shrink-0 text-ink-500 dark:text-ink-400" />
144
141
<span>
145
142
{safeMusicStatus.releaseName}
146
143
147
144
{#if safeMusicStatus.duration}
148
-
<span class="inline-flex items-center gap-1 ml-1 text-ink-600 dark:text-ink-300">
149
-
· <Clock class="h-3 w-3" /> {formatDuration(safeMusicStatus.duration)}
145
+
<span
146
+
class="ml-1 inline-flex items-center gap-1 text-ink-600 dark:text-ink-300"
147
+
>
148
+
· <Clock class="h-3 w-3" />
149
+
{formatDuration(safeMusicStatus.duration)}
150
150
</span>
151
151
{/if}
152
152
</span>
···
167
167
href="https://teal.fm"
168
168
target="_blank"
169
169
rel="noopener noreferrer"
170
-
class="inline-flex items-center gap-1 hover:text-primary-600 dark:hover:text-primary-400 transition-colors"
170
+
class="inline-flex items-center gap-1 transition-colors hover:text-primary-600 dark:hover:text-primary-400"
171
171
title="Powered by teal.fm"
172
172
>
173
173
<Radio class="h-3 w-3" />
+10
-4
src/lib/components/layout/main/card/PostCard.svelte
+10
-4
src/lib/components/layout/main/card/PostCard.svelte
···
103
103
{/if}
104
104
105
105
<!-- Title -->
106
-
<h3 class="overflow-wrap-anywhere break-words font-semibold text-ink-900 dark:text-ink-50">{post.title}</h3>
106
+
<h3
107
+
class="overflow-wrap-anywhere font-semibold break-words text-ink-900 dark:text-ink-50"
108
+
>
109
+
{post.title}
110
+
</h3>
107
111
108
112
<!-- Description -->
109
113
{#if post.description}
110
-
<p class="overflow-wrap-anywhere break-words line-clamp-2 text-sm text-ink-700 dark:text-ink-200">
111
-
{post.description}
112
-
</p>
114
+
<p
115
+
class="overflow-wrap-anywhere line-clamp-2 text-sm break-words text-ink-700 dark:text-ink-200"
116
+
>
117
+
{post.description}
118
+
</p>
113
119
{/if}
114
120
115
121
<!-- Timestamp -->
+5
-3
src/lib/components/layout/main/card/ProfileCard.svelte
+5
-3
src/lib/components/layout/main/card/ProfileCard.svelte
···
108
108
<p class="font-medium text-ink-700 dark:text-ink-200">@{safeProfile.handle}</p>
109
109
110
110
{#if safeProfile.description}
111
-
<p class="mb-4 overflow-wrap-anywhere break-words whitespace-pre-wrap text-ink-700 dark:text-ink-200">
112
-
{safeProfile.description}
113
-
</p>
111
+
<p
112
+
class="overflow-wrap-anywhere mb-4 break-words whitespace-pre-wrap text-ink-700 dark:text-ink-200"
113
+
>
114
+
{safeProfile.description}
115
+
</p>
114
116
{/if}
115
117
116
118
<div class="flex gap-6 text-sm font-medium">
+5
-5
src/lib/components/layout/main/card/TangledRepoCard.svelte
+5
-5
src/lib/components/layout/main/card/TangledRepoCard.svelte
···
35
35
rel="noopener noreferrer"
36
36
class="flex items-center justify-between gap-3 rounded-lg bg-canvas-200 p-4 transition-colors hover:bg-canvas-300 dark:bg-canvas-800 dark:hover:bg-canvas-700"
37
37
>
38
-
<div class="flex items-center gap-3 min-w-0 flex-1">
38
+
<div class="flex min-w-0 flex-1 items-center gap-3">
39
39
<GitBranch
40
40
class="h-5 w-5 flex-shrink-0 text-primary-600 dark:text-primary-400"
41
41
aria-hidden="true"
42
42
/>
43
-
<div class="flex flex-col gap-1 min-w-0 flex-1">
44
-
<h3 class="overflow-wrap-anywhere break-words font-semibold text-ink-900 dark:text-ink-50">
43
+
<div class="flex min-w-0 flex-1 flex-col gap-1">
44
+
<h3 class="overflow-wrap-anywhere font-semibold break-words text-ink-900 dark:text-ink-50">
45
45
{repo.name}
46
46
</h3>
47
47
<div class="flex flex-wrap items-center gap-3 text-xs text-ink-700 dark:text-ink-200">
48
-
<div class="flex items-center gap-1 min-w-0">
48
+
<div class="flex min-w-0 items-center gap-1">
49
49
<Server class="h-3 w-3 flex-shrink-0" aria-hidden="true" />
50
50
<span class="truncate">{getKnotServerName(repo.knot)}</span>
51
51
</div>
52
-
<div class="flex items-center gap-1 min-w-0">
52
+
<div class="flex min-w-0 items-center gap-1">
53
53
<User class="h-3 w-3 flex-shrink-0" aria-hidden="true" />
54
54
<span class="truncate">{handle || PUBLIC_ATPROTO_DID}</span>
55
55
</div>
+11
-11
src/lib/config/slugs.ts
+11
-11
src/lib/config/slugs.ts
···
2
2
3
3
/**
4
4
* Normalize a slug to be URI-compatible
5
-
*
5
+
*
6
6
* Transformations:
7
7
* - Convert to lowercase
8
8
* - Replace spaces with hyphens
9
9
* - Remove all characters except alphanumeric, hyphens, and underscores
10
10
* - Collapse multiple hyphens into single hyphen
11
11
* - Remove leading/trailing hyphens
12
-
*
12
+
*
13
13
* @param slug - The slug to normalize
14
14
* @returns URI-compatible slug
15
-
*
15
+
*
16
16
* @example
17
17
* normalizeSlug('My Blog Post!') // 'my-blog-post'
18
18
* normalizeSlug('Hello World') // 'hello-world'
···
31
31
/**
32
32
* Get publication rkey from slug
33
33
* Automatically normalizes the slug before lookup
34
-
*
34
+
*
35
35
* @param slug - The slug to look up (will be normalized)
36
36
* @returns The publication rkey or null if not found
37
37
*/
38
38
export function getPublicationRkeyFromSlug(slug: string): string | null {
39
39
const normalizedSlug = normalizeSlug(slug);
40
-
const mapping = slugMappings.find(m => normalizeSlug(m.slug) === normalizedSlug);
40
+
const mapping = slugMappings.find((m) => normalizeSlug(m.slug) === normalizedSlug);
41
41
return mapping?.publicationRkey || null;
42
42
}
43
43
44
44
/**
45
45
* Get slug from publication rkey
46
-
*
46
+
*
47
47
* @param rkey - The publication rkey
48
48
* @returns The slug or null if not found
49
49
*/
50
50
export function getSlugFromPublicationRkey(rkey: string): string | null {
51
-
const mapping = slugMappings.find(m => m.publicationRkey === rkey);
51
+
const mapping = slugMappings.find((m) => m.publicationRkey === rkey);
52
52
return mapping?.slug || null;
53
53
}
54
54
55
55
/**
56
56
* Get all configured slugs (normalized)
57
-
*
57
+
*
58
58
* @returns Array of normalized slugs
59
59
*/
60
60
export function getAllSlugs(): string[] {
61
-
return slugMappings.map(m => normalizeSlug(m.slug));
61
+
return slugMappings.map((m) => normalizeSlug(m.slug));
62
62
}
63
63
64
64
/**
65
65
* Get all slug mappings with normalized slugs
66
-
*
66
+
*
67
67
* @returns Array of slug mappings with normalized slugs
68
68
*/
69
69
export function getAllSlugMappings(): SlugMapping[] {
70
-
return slugMappings.map(m => ({
70
+
return slugMappings.map((m) => ({
71
71
...m,
72
72
slug: normalizeSlug(m.slug)
73
73
}));
+3
-3
src/lib/data/slug-mappings.ts
+3
-3
src/lib/data/slug-mappings.ts
···
1
1
/**
2
2
* Slug to Leaflet Publication mapping data
3
-
*
3
+
*
4
4
* Maps friendly URL slugs to Leaflet publication rkeys.
5
5
* This allows you to access publications via /{slug} instead of /blog
6
-
*
6
+
*
7
7
* Example:
8
8
* - /blog → maps to publication with rkey "3m3x4bgbsh22k"
9
9
* - /notes → maps to publication with rkey "xyz123abc"
···
19
19
/**
20
20
* Slug to publication rkey mappings
21
21
* Add your custom mappings here
22
-
*
22
+
*
23
23
* Note: Slugs will be automatically normalized to be URI-compatible:
24
24
* - Converted to lowercase
25
25
* - Spaces converted to hyphens
+1
-1
src/lib/helper/index.ts
+1
-1
src/lib/helper/index.ts
+7
-3
src/lib/helper/metaTags.ts
+7
-3
src/lib/helper/metaTags.ts
···
25
25
{ property: 'og:description', content: finalMeta.description },
26
26
{ property: 'og:site_name', content: defaults.title }, // always site title for OG
27
27
{ property: 'og:image', content: finalMeta.image },
28
-
...(finalMeta.imageWidth ? [{ property: 'og:image:width', content: finalMeta.imageWidth.toString() }] : []),
29
-
...(finalMeta.imageHeight ? [{ property: 'og:image:height', content: finalMeta.imageHeight.toString() }] : []),
28
+
...(finalMeta.imageWidth
29
+
? [{ property: 'og:image:width', content: finalMeta.imageWidth.toString() }]
30
+
: []),
31
+
...(finalMeta.imageHeight
32
+
? [{ property: 'og:image:height', content: finalMeta.imageHeight.toString() }]
33
+
: []),
30
34
31
35
{ name: 'twitter:card', content: 'summary_large_image' },
32
36
{ name: 'twitter:url', content: finalMeta.url },
···
34
38
{ name: 'twitter:description', content: finalMeta.description },
35
39
{ name: 'twitter:image', content: finalMeta.image }
36
40
];
37
-
}
41
+
}
+4
-4
src/lib/helper/ogImages.ts
+4
-4
src/lib/helper/ogImages.ts
···
1
1
/**
2
2
* OG (Open Graph) image paths
3
-
*
3
+
*
4
4
* These images are served from the ./static/og/ directory.
5
5
* They are accessible at /og/ URLs in the application.
6
-
*
6
+
*
7
7
* To add new OG images:
8
8
* 1. Place the image in ./static/og/
9
9
* 2. Add the path here as /og/filename.png
10
10
*/
11
11
export const ogImages: Record<string, string> = {
12
-
main: '/og/main.png',
13
-
siteMeta: '/og/site-meta.png'
12
+
main: '/og/main.png',
13
+
siteMeta: '/og/site-meta.png'
14
14
};
+22
-22
src/lib/helper/siteMeta.ts
+22
-22
src/lib/helper/siteMeta.ts
···
1
1
import { ogImages } from '$lib/helper/ogImages';
2
2
import {
3
-
PUBLIC_SITE_TITLE,
4
-
PUBLIC_SITE_DESCRIPTION,
5
-
PUBLIC_SITE_KEYWORDS,
6
-
PUBLIC_SITE_URL
3
+
PUBLIC_SITE_TITLE,
4
+
PUBLIC_SITE_DESCRIPTION,
5
+
PUBLIC_SITE_KEYWORDS,
6
+
PUBLIC_SITE_URL
7
7
} from '$env/static/public';
8
8
9
9
export interface SiteMetadata {
10
-
title: string;
11
-
description: string;
12
-
keywords: string;
13
-
url: string;
14
-
image: string;
15
-
imageWidth?: number;
16
-
imageHeight?: number;
10
+
title: string;
11
+
description: string;
12
+
keywords: string;
13
+
url: string;
14
+
image: string;
15
+
imageWidth?: number;
16
+
imageHeight?: number;
17
17
}
18
18
19
19
/**
···
21
21
* Can be overridden dynamically for each page or component.
22
22
*/
23
23
export const defaultSiteMeta: SiteMetadata = {
24
-
title: PUBLIC_SITE_TITLE,
25
-
description: PUBLIC_SITE_DESCRIPTION,
26
-
keywords: PUBLIC_SITE_KEYWORDS,
27
-
url: PUBLIC_SITE_URL,
28
-
image: ogImages.main,
29
-
imageWidth: 1200,
30
-
imageHeight: 630
24
+
title: PUBLIC_SITE_TITLE,
25
+
description: PUBLIC_SITE_DESCRIPTION,
26
+
keywords: PUBLIC_SITE_KEYWORDS,
27
+
url: PUBLIC_SITE_URL,
28
+
image: ogImages.main,
29
+
imageWidth: 1200,
30
+
imageHeight: 630
31
31
};
32
32
33
33
/**
···
35
35
* Merges defaults with any overrides provided.
36
36
*/
37
37
export function createSiteMeta(overrides: Partial<SiteMetadata> = {}): SiteMetadata {
38
-
return {
39
-
...defaultSiteMeta,
40
-
...overrides
41
-
};
38
+
return {
39
+
...defaultSiteMeta,
40
+
...overrides
41
+
};
42
42
}
+34
-31
src/lib/services/atproto/agents.ts
+34
-31
src/lib/services/atproto/agents.ts
···
5
5
* Creates an AtpAgent with optional fetch function injection
6
6
*/
7
7
export function createAgent(service: string, fetchFn?: typeof fetch): AtpAgent {
8
-
// If we have an injected fetch, wrap it to ensure we handle headers correctly
9
-
const wrappedFetch = fetchFn ? async (url: URL | RequestInfo, init?: RequestInit) => {
10
-
// Convert URL to string if needed
11
-
const urlStr = url instanceof URL ? url.toString() : url;
12
-
13
-
// Make the request with the injected fetch
14
-
const response = await fetchFn(urlStr, init);
8
+
// If we have an injected fetch, wrap it to ensure we handle headers correctly
9
+
const wrappedFetch = fetchFn
10
+
? async (url: URL | RequestInfo, init?: RequestInit) => {
11
+
// Convert URL to string if needed
12
+
const urlStr = url instanceof URL ? url.toString() : url;
13
+
14
+
// Make the request with the injected fetch
15
+
const response = await fetchFn(urlStr, init);
16
+
17
+
// Create a new response with the same body but add content-type if missing
18
+
const headers = new Headers(response.headers);
19
+
if (!headers.has('content-type')) {
20
+
headers.set('content-type', 'application/json');
21
+
}
15
22
16
-
// Create a new response with the same body but add content-type if missing
17
-
const headers = new Headers(response.headers);
18
-
if (!headers.has('content-type')) {
19
-
headers.set('content-type', 'application/json');
20
-
}
21
-
22
-
return new Response(response.body, {
23
-
status: response.status,
24
-
statusText: response.statusText,
25
-
headers
26
-
});
27
-
} : undefined;
23
+
return new Response(response.body, {
24
+
status: response.status,
25
+
statusText: response.statusText,
26
+
headers
27
+
});
28
+
}
29
+
: undefined;
28
30
29
-
return new AtpAgent({
30
-
service,
31
-
...(wrappedFetch && { fetch: wrappedFetch })
32
-
});
31
+
return new AtpAgent({
32
+
service,
33
+
...(wrappedFetch && { fetch: wrappedFetch })
34
+
});
33
35
}
34
36
35
37
// Primary Microcosm Constellation endpoint
···
62
64
63
65
if (!response.ok) {
64
66
console.error(`[Identity] Resolution failed: ${response.status} ${response.statusText}`);
65
-
throw new Error(`Failed to resolve identifier via Slingshot: ${response.status} ${response.statusText}`);
67
+
throw new Error(
68
+
`Failed to resolve identifier via Slingshot: ${response.status} ${response.statusText}`
69
+
);
66
70
}
67
71
68
72
// Some fetch implementations in Node (undici wrappers) can throw when calling Response.clone().
···
108
112
console.warn('[Agent] Constellation endpoint unreachable:', constellationErr);
109
113
}
110
114
111
-
// Then try Slingshot for PDS resolution
112
-
console.info('[Agent] Attempting Slingshot resolution');
113
-
const resolved = await resolveIdentity(did, fetchFn);
115
+
// Then try Slingshot for PDS resolution
116
+
console.info('[Agent] Attempting Slingshot resolution');
117
+
const resolved = await resolveIdentity(did, fetchFn);
114
118
console.info(`[Agent] Resolved PDS endpoint: ${resolved.pds}`);
115
119
resolvedAgent = createAgent(resolved.pds, fetchFn);
116
120
return resolvedAgent;
···
119
123
resolvedAgent = defaultAgent;
120
124
return resolvedAgent;
121
125
}
122
-
}/**
126
+
} /**
123
127
* Gets or creates a PDS-specific agent
124
128
*/
125
129
export async function getPDSAgent(did: string, fetchFn?: typeof fetch): Promise<AtpAgent> {
···
147
151
usePDSFirst = false,
148
152
fetchFn?: typeof fetch
149
153
): Promise<T> {
150
-
const defaultAgentFn = () => fetchFn
151
-
? createAgent('https://public.api.bsky.app', fetchFn)
152
-
: Promise.resolve(defaultAgent);
154
+
const defaultAgentFn = () =>
155
+
fetchFn ? createAgent('https://public.api.bsky.app', fetchFn) : Promise.resolve(defaultAgent);
153
156
154
157
const agents = usePDSFirst
155
158
? [() => getPDSAgent(did, fetchFn), defaultAgentFn]
+58
-61
src/lib/services/atproto/engagement.ts
+58
-61
src/lib/services/atproto/engagement.ts
···
3
3
export type EngagementType = 'app.bsky.feed.like' | 'app.bsky.feed.repost';
4
4
5
5
interface EngagementResponse {
6
-
dids: string[];
7
-
cursor?: string;
6
+
dids: string[];
7
+
cursor?: string;
8
8
}
9
9
10
10
/**
11
11
* Fetches engagement data (likes/reposts) for a post from Constellation as a fallback
12
12
*/
13
13
export async function fetchEngagementFromConstellation(
14
-
uri: string,
15
-
type: EngagementType,
16
-
cursor?: string
14
+
uri: string,
15
+
type: EngagementType,
16
+
cursor?: string
17
17
): Promise<EngagementResponse> {
18
-
console.info(`[Constellation] Fetching ${type} data for ${uri}`);
19
-
20
-
const cacheKey = `engagement:${type}:${uri}:${cursor || 'initial'}`;
21
-
const cached = cache.get<EngagementResponse>(cacheKey);
22
-
if (cached) {
23
-
console.debug('[Constellation] Returning cached engagement data');
24
-
return cached;
25
-
}
18
+
console.info(`[Constellation] Fetching ${type} data for ${uri}`);
26
19
27
-
try {
28
-
const url = new URL('https://constellation.microcosm.blue/links/distinct-dids');
29
-
url.searchParams.append('target', uri);
30
-
url.searchParams.append('collection', type);
31
-
url.searchParams.append('path', '');
32
-
url.searchParams.append('limit', '100');
33
-
if (cursor) {
34
-
url.searchParams.append('cursor', cursor);
35
-
}
20
+
const cacheKey = `engagement:${type}:${uri}:${cursor || 'initial'}`;
21
+
const cached = cache.get<EngagementResponse>(cacheKey);
22
+
if (cached) {
23
+
console.debug('[Constellation] Returning cached engagement data');
24
+
return cached;
25
+
}
36
26
37
-
console.debug(`[Constellation] Requesting: ${url.toString()}`);
38
-
const response = await fetch(url);
39
-
40
-
if (!response.ok) {
41
-
throw new Error(`Constellation HTTP error! Status: ${response.status}`);
42
-
}
27
+
try {
28
+
const url = new URL('https://constellation.microcosm.blue/links/distinct-dids');
29
+
url.searchParams.append('target', uri);
30
+
url.searchParams.append('collection', type);
31
+
url.searchParams.append('path', '');
32
+
url.searchParams.append('limit', '100');
33
+
if (cursor) {
34
+
url.searchParams.append('cursor', cursor);
35
+
}
43
36
44
-
const data = await response.json();
45
-
console.debug('[Constellation] Response received:', data);
37
+
console.debug(`[Constellation] Requesting: ${url.toString()}`);
38
+
const response = await fetch(url);
39
+
40
+
if (!response.ok) {
41
+
throw new Error(`Constellation HTTP error! Status: ${response.status}`);
42
+
}
43
+
44
+
const data = await response.json();
45
+
console.debug('[Constellation] Response received:', data);
46
46
47
-
const result: EngagementResponse = {
48
-
dids: data.dids || [],
49
-
cursor: data.cursor
50
-
};
47
+
const result: EngagementResponse = {
48
+
dids: data.dids || [],
49
+
cursor: data.cursor
50
+
};
51
51
52
-
// Cache the results
53
-
cache.set(cacheKey, result);
54
-
return result;
55
-
} catch (error) {
56
-
console.error('[Constellation] Failed to fetch engagement data:', error);
57
-
throw error;
58
-
}
52
+
// Cache the results
53
+
cache.set(cacheKey, result);
54
+
return result;
55
+
} catch (error) {
56
+
console.error('[Constellation] Failed to fetch engagement data:', error);
57
+
throw error;
58
+
}
59
59
}
60
60
61
61
/**
62
62
* Fetches all engagement data by paginating through results
63
63
*/
64
-
export async function fetchAllEngagement(
65
-
uri: string,
66
-
type: EngagementType
67
-
): Promise<string[]> {
68
-
console.info(`[Constellation] Fetching all ${type} data for ${uri}`);
69
-
70
-
const allDids: Set<string> = new Set();
71
-
let cursor: string | undefined = undefined;
64
+
export async function fetchAllEngagement(uri: string, type: EngagementType): Promise<string[]> {
65
+
console.info(`[Constellation] Fetching all ${type} data for ${uri}`);
66
+
67
+
const allDids: Set<string> = new Set();
68
+
let cursor: string | undefined = undefined;
72
69
73
-
try {
74
-
do {
75
-
const response = await fetchEngagementFromConstellation(uri, type, cursor);
76
-
response.dids.forEach(did => allDids.add(did));
77
-
cursor = response.cursor;
78
-
} while (cursor);
70
+
try {
71
+
do {
72
+
const response = await fetchEngagementFromConstellation(uri, type, cursor);
73
+
response.dids.forEach((did) => allDids.add(did));
74
+
cursor = response.cursor;
75
+
} while (cursor);
79
76
80
-
return Array.from(allDids);
81
-
} catch (error) {
82
-
console.error('[Constellation] Failed to fetch all engagement:', error);
83
-
return Array.from(allDids); // Return what we have so far
84
-
}
85
-
}
77
+
return Array.from(allDids);
78
+
} catch (error) {
79
+
console.error('[Constellation] Failed to fetch all engagement:', error);
80
+
return Array.from(allDids); // Return what we have so far
81
+
}
82
+
}
+67
-48
src/lib/services/atproto/fetch.ts
+67
-48
src/lib/services/atproto/fetch.ts
···
154
154
155
155
try {
156
156
console.info('[MusicStatus] Cache miss, fetching from network');
157
-
157
+
158
158
// Try the actor status collection first (shorter-lived status)
159
159
try {
160
160
const statusRecords = await withFallback(
···
174
174
if (statusRecords && statusRecords.length > 0) {
175
175
const record = statusRecords[0];
176
176
const value = record.value as any;
177
-
177
+
178
178
// Check if status is still valid (not expired)
179
179
if (value.expiry) {
180
-
const expiryTime = parseInt(value.expiry) * 1000;
181
-
if (Date.now() > expiryTime) {
182
-
console.debug('[MusicStatus] Actor status expired, falling back to feed play');
183
-
} else {
184
-
// Build artwork URL - prioritize album art over individual track art
185
-
let artworkUrl: string | undefined;
186
-
const trackName = value.item?.trackName || value.trackName;
187
-
const artists = value.item?.artists || value.artists || [];
188
-
const releaseName = value.item?.releaseName || value.releaseName;
189
-
const artistName = artists[0]?.artistName;
190
-
const releaseMbId = value.item?.releaseMbId || value.releaseMbId;
191
-
192
-
console.debug('[MusicStatus] Looking for artwork:', { trackName, artistName, releaseName, releaseMbId });
193
-
194
-
// Priority 1: If we have album info, search for album art (more accurate)
195
-
if (releaseName && artistName) {
196
-
console.info('[MusicStatus] Prioritizing album artwork search');
197
-
artworkUrl = await findArtwork(releaseName, artistName, releaseName, releaseMbId) || undefined;
198
-
}
199
-
200
-
// Priority 2: Fall back to track-based search if album search failed
201
-
if (!artworkUrl && trackName && artistName) {
202
-
console.info('[MusicStatus] Falling back to track-based artwork search');
203
-
artworkUrl = await findArtwork(trackName, artistName, releaseName, releaseMbId) || undefined;
204
-
}
205
-
206
-
// Priority 3: Final fallback to atproto blob if no external artwork found
207
-
if (!artworkUrl) {
208
-
const artwork = value.item?.artwork || value.artwork;
209
-
console.debug('[MusicStatus] No external artwork found, checking atproto blob:', artwork);
210
-
if (artwork?.ref?.$link) {
211
-
const identity = await resolveIdentity(PUBLIC_ATPROTO_DID, fetchFn);
212
-
artworkUrl = buildPdsBlobUrl(identity.pds, PUBLIC_ATPROTO_DID, artwork.ref.$link);
213
-
console.info('[MusicStatus] Using atproto blob artwork URL:', artworkUrl);
214
-
}
180
+
const expiryTime = parseInt(value.expiry) * 1000;
181
+
if (Date.now() > expiryTime) {
182
+
console.debug('[MusicStatus] Actor status expired, falling back to feed play');
183
+
} else {
184
+
// Build artwork URL - prioritize album art over individual track art
185
+
let artworkUrl: string | undefined;
186
+
const trackName = value.item?.trackName || value.trackName;
187
+
const artists = value.item?.artists || value.artists || [];
188
+
const releaseName = value.item?.releaseName || value.releaseName;
189
+
const artistName = artists[0]?.artistName;
190
+
const releaseMbId = value.item?.releaseMbId || value.releaseMbId;
191
+
192
+
console.debug('[MusicStatus] Looking for artwork:', {
193
+
trackName,
194
+
artistName,
195
+
releaseName,
196
+
releaseMbId
197
+
});
198
+
199
+
// Priority 1: If we have album info, search for album art (more accurate)
200
+
if (releaseName && artistName) {
201
+
console.info('[MusicStatus] Prioritizing album artwork search');
202
+
artworkUrl =
203
+
(await findArtwork(releaseName, artistName, releaseName, releaseMbId)) || undefined;
204
+
}
205
+
206
+
// Priority 2: Fall back to track-based search if album search failed
207
+
if (!artworkUrl && trackName && artistName) {
208
+
console.info('[MusicStatus] Falling back to track-based artwork search');
209
+
artworkUrl =
210
+
(await findArtwork(trackName, artistName, releaseName, releaseMbId)) || undefined;
211
+
}
212
+
213
+
// Priority 3: Final fallback to atproto blob if no external artwork found
214
+
if (!artworkUrl) {
215
+
const artwork = value.item?.artwork || value.artwork;
216
+
console.debug(
217
+
'[MusicStatus] No external artwork found, checking atproto blob:',
218
+
artwork
219
+
);
220
+
if (artwork?.ref?.$link) {
221
+
const identity = await resolveIdentity(PUBLIC_ATPROTO_DID, fetchFn);
222
+
artworkUrl = buildPdsBlobUrl(identity.pds, PUBLIC_ATPROTO_DID, artwork.ref.$link);
223
+
console.info('[MusicStatus] Using atproto blob artwork URL:', artworkUrl);
215
224
}
225
+
}
216
226
217
227
const data: MusicStatusData = {
218
228
trackName: value.item?.trackName || value.trackName,
···
224
234
releaseMbId: value.item?.releaseMbId || value.releaseMbId,
225
235
isrc: value.isrc,
226
236
duration: value.duration,
227
-
musicServiceBaseDomain: value.item?.musicServiceBaseDomain || value.musicServiceBaseDomain,
228
-
submissionClientAgent: value.item?.submissionClientAgent || value.submissionClientAgent,
237
+
musicServiceBaseDomain:
238
+
value.item?.musicServiceBaseDomain || value.musicServiceBaseDomain,
239
+
submissionClientAgent:
240
+
value.item?.submissionClientAgent || value.submissionClientAgent,
229
241
$type: 'fm.teal.alpha.actor.status',
230
242
expiry: value.expiry,
231
243
artwork: value.item?.artwork || value.artwork,
···
259
271
if (playRecords && playRecords.length > 0) {
260
272
const record = playRecords[0];
261
273
const value = record.value as any;
262
-
274
+
263
275
// Build artwork URL - prioritize album art over individual track art
264
276
let artworkUrl: string | undefined;
265
277
const trackName = value.trackName;
···
267
279
const releaseName = value.releaseName;
268
280
const artistName = artists[0]?.artistName;
269
281
const releaseMbId = value.releaseMbId;
270
-
271
-
console.debug('[MusicStatus] Looking for artwork:', { trackName, artistName, releaseName, releaseMbId });
272
-
282
+
283
+
console.debug('[MusicStatus] Looking for artwork:', {
284
+
trackName,
285
+
artistName,
286
+
releaseName,
287
+
releaseMbId
288
+
});
289
+
273
290
// Priority 1: If we have album info, search for album art (more accurate)
274
291
if (releaseName && artistName) {
275
292
console.info('[MusicStatus] Prioritizing album artwork search');
276
-
artworkUrl = await findArtwork(releaseName, artistName, releaseName, releaseMbId) || undefined;
293
+
artworkUrl =
294
+
(await findArtwork(releaseName, artistName, releaseName, releaseMbId)) || undefined;
277
295
}
278
-
296
+
279
297
// Priority 2: Fall back to track-based search if album search failed
280
298
if (!artworkUrl && trackName && artistName) {
281
299
console.info('[MusicStatus] Falling back to track-based artwork search');
282
-
artworkUrl = await findArtwork(trackName, artistName, releaseName, releaseMbId) || undefined;
300
+
artworkUrl =
301
+
(await findArtwork(trackName, artistName, releaseName, releaseMbId)) || undefined;
283
302
}
284
-
303
+
285
304
// Priority 3: Final fallback to atproto blob if no external artwork found
286
305
if (!artworkUrl) {
287
306
const artwork = value.artwork;
···
292
311
console.info('[MusicStatus] Using atproto blob artwork URL:', artworkUrl);
293
312
}
294
313
}
295
-
314
+
296
315
const data: MusicStatusData = {
297
316
trackName: value.trackName,
298
317
artists: value.artists || [],
+5
-5
src/lib/services/atproto/index.ts
+5
-5
src/lib/services/atproto/index.ts
···
1
1
/**
2
2
* Unified AT Protocol service exports
3
-
*
3
+
*
4
4
* This module provides a clean API for interacting with AT Protocol services,
5
5
* including profile data, blog posts, Bluesky posts, and custom lexicons.
6
6
*/
···
51
51
52
52
export { resolveIdentity, withFallback, resetAgents } from './agents';
53
53
54
-
export {
55
-
searchMusicBrainzRelease,
56
-
buildCoverArtUrl,
54
+
export {
55
+
searchMusicBrainzRelease,
56
+
buildCoverArtUrl,
57
57
searchiTunesArtwork,
58
58
searchDeezerArtwork,
59
59
searchLastFmArtwork,
60
-
findArtwork
60
+
findArtwork
61
61
} from './musicbrainz';
62
62
63
63
// Export cache for advanced use cases
+15
-8
src/lib/services/atproto/media.ts
+15
-8
src/lib/services/atproto/media.ts
···
28
28
* and nested structures.
29
29
* - also detects 'app.bsky.embed.video' shapes and returns the video blob URL first
30
30
*/
31
-
export function extractImageUrlsFromValue(
32
-
value: any,
33
-
did: string,
34
-
limit = 4
35
-
): string[] {
31
+
export function extractImageUrlsFromValue(value: any, did: string, limit = 4): string[] {
36
32
const urls: string[] = [];
37
33
38
34
try {
···
93
89
}
94
90
95
91
// Video in recordWithMedia
96
-
if (media && (media.$type === 'app.bsky.embed.video#view' || media.$type === 'app.bsky.embed.video')) {
92
+
if (
93
+
media &&
94
+
(media.$type === 'app.bsky.embed.video#view' || media.$type === 'app.bsky.embed.video')
95
+
) {
97
96
const videoCid = (media as any)?.video?.ref?.$link ?? (media as any)?.video?.cid ?? null;
98
97
if (videoCid) {
99
98
const videoUrl = `https://video.bsky.app/watch/${did}/${videoCid}/playlist.m3u8`;
···
146
145
}
147
146
148
147
if (e.$type === 'app.bsky.embed.video#view' || e.$type === 'app.bsky.embed.video') {
149
-
const videoCid = (e as any)?.jobStatus?.blob ?? (e as any)?.video?.ref?.$link ?? (e as any)?.video?.cid ?? null;
148
+
const videoCid =
149
+
(e as any)?.jobStatus?.blob ??
150
+
(e as any)?.video?.ref?.$link ??
151
+
(e as any)?.video?.cid ??
152
+
null;
150
153
if (videoCid) {
151
154
const videoUrl = `https://video.bsky.app/watch/${did}/${videoCid}/playlist.m3u8`;
152
155
urls.push(videoUrl);
···
156
159
157
160
if (e.$type === 'app.bsky.embed.recordWithMedia#view') {
158
161
const media = e.media;
159
-
if (media && media.$type === 'app.bsky.embed.images#view' && Array.isArray(media.images)) {
162
+
if (
163
+
media &&
164
+
media.$type === 'app.bsky.embed.images#view' &&
165
+
Array.isArray(media.images)
166
+
) {
160
167
for (const img of media.images) {
161
168
const imageUrl = img.fullsize || img.thumb;
162
169
if (imageUrl) {
+13
-9
src/lib/services/atproto/musicbrainz.ts
+13
-9
src/lib/services/atproto/musicbrainz.ts
···
95
95
const response = await fetch(url, {
96
96
headers: {
97
97
'User-Agent': 'ewancroft.uk/1.0.0 (https://ewancroft.uk)',
98
-
'Accept': 'application/json'
98
+
Accept: 'application/json'
99
99
}
100
100
});
101
101
···
134
134
const response = await fetch(url, {
135
135
headers: {
136
136
'User-Agent': 'ewancroft.uk/1.0.0 (https://ewancroft.uk)',
137
-
'Accept': 'application/json'
137
+
Accept: 'application/json'
138
138
}
139
139
});
140
140
···
180
180
181
181
try {
182
182
// Prefer searching by album + artist for better accuracy
183
-
const searchTerm = releaseName
184
-
? `${releaseName} ${artistName}`
185
-
: `${trackName} ${artistName}`;
183
+
const searchTerm = releaseName ? `${releaseName} ${artistName}` : `${trackName} ${artistName}`;
186
184
187
185
const url = `https://itunes.apple.com/search?term=${encodeURIComponent(searchTerm)}&entity=album&limit=5`;
188
186
···
330
328
331
329
// Get the largest image available
332
330
const images = data.album.image;
333
-
const largeImage = images.find((img: any) => img.size === 'extralarge') ||
334
-
images.find((img: any) => img.size === 'large') ||
335
-
images.find((img: any) => img.size === 'medium');
331
+
const largeImage =
332
+
images.find((img: any) => img.size === 'extralarge') ||
333
+
images.find((img: any) => img.size === 'large') ||
334
+
images.find((img: any) => img.size === 'medium');
336
335
337
336
if (largeImage?.['#text']) {
338
337
const artworkUrl = largeImage['#text'];
···
371
370
if (releaseName) params.set('releaseName', releaseName);
372
371
if (releaseMbId) params.set('releaseMbId', releaseMbId);
373
372
374
-
console.info('[Artwork] Fetching via server API:', { trackName, artistName, releaseName, releaseMbId });
373
+
console.info('[Artwork] Fetching via server API:', {
374
+
trackName,
375
+
artistName,
376
+
releaseName,
377
+
releaseMbId
378
+
});
375
379
376
380
// Call our server-side API endpoint
377
381
const response = await fetch(`/api/artwork?${params.toString()}`);
+44
-38
src/lib/services/atproto/posts.ts
+44
-38
src/lib/services/atproto/posts.ts
···
17
17
/**
18
18
* Fetches all Leaflet publications for a user
19
19
*/
20
-
export async function fetchLeafletPublications(fetchFn?: typeof fetch): Promise<LeafletPublicationsData> {
20
+
export async function fetchLeafletPublications(
21
+
fetchFn?: typeof fetch
22
+
): Promise<LeafletPublicationsData> {
21
23
console.info('[Leaflet] Fetching publications');
22
24
const cacheKey = `leaflet:publications:${PUBLIC_ATPROTO_DID}`;
23
25
const cached = cache.get<LeafletPublicationsData>(cacheKey);
···
134
136
// Fetch Leaflet publications and documents
135
137
try {
136
138
// Get all publications first
137
-
const publicationsData = await fetchLeafletPublications(fetchFn);
139
+
const publicationsData = await fetchLeafletPublications(fetchFn);
138
140
const publicationsMap = new Map<string, LeafletPublication>();
139
141
for (const pub of publicationsData.publications) {
140
142
publicationsMap.set(pub.uri, pub);
···
156
158
);
157
159
158
160
for (const record of leafletDocsRecords) {
159
-
const value = record.value as any;
160
-
const rkey = record.uri.split('/').pop() || '';
161
-
const publicationUri = value.publication;
162
-
const publication = publicationsMap.get(publicationUri);
161
+
const value = record.value as any;
162
+
const rkey = record.uri.split('/').pop() || '';
163
+
const publicationUri = value.publication;
164
+
const publication = publicationsMap.get(publicationUri);
163
165
164
-
// Determine URL based on priority: publication base_path → Leaflet /lish format
165
-
let url: string;
166
-
const publicationRkey = publicationUri ? publicationUri.split('/').pop() : '';
166
+
// Determine URL based on priority: publication base_path → Leaflet /lish format
167
+
let url: string;
168
+
const publicationRkey = publicationUri ? publicationUri.split('/').pop() : '';
167
169
168
-
if (publication?.basePath) {
169
-
// Ensure basePath is a complete URL
170
-
const basePath = publication.basePath.startsWith('http')
171
-
? publication.basePath
172
-
: `https://${publication.basePath}`;
173
-
url = `${basePath}/${rkey}`;
174
-
} else if (publicationRkey) {
175
-
url = `https://leaflet.pub/lish/${PUBLIC_ATPROTO_DID}/${publicationRkey}/${rkey}`;
176
-
} else {
177
-
url = `https://leaflet.pub/${PUBLIC_ATPROTO_DID}/${rkey}`;
178
-
}
170
+
if (publication?.basePath) {
171
+
// Ensure basePath is a complete URL
172
+
const basePath = publication.basePath.startsWith('http')
173
+
? publication.basePath
174
+
: `https://${publication.basePath}`;
175
+
url = `${basePath}/${rkey}`;
176
+
} else if (publicationRkey) {
177
+
url = `https://leaflet.pub/lish/${PUBLIC_ATPROTO_DID}/${publicationRkey}/${rkey}`;
178
+
} else {
179
+
url = `https://leaflet.pub/${PUBLIC_ATPROTO_DID}/${rkey}`;
180
+
}
179
181
180
182
posts.push({
181
183
title: value.title || 'Untitled Document',
···
240
242
const latestFeedItem = feed[0];
241
243
const latestPostData = latestFeedItem.post;
242
244
console.log('[fetchLatestBlueskyPost] Found latest feed item:', latestPostData.uri);
243
-
245
+
244
246
// Check if this is a repost
245
247
const isRepost = latestFeedItem.reason?.$type === 'app.bsky.feed.defs#reasonRepost';
246
248
let repostAuthor: PostAuthor | undefined;
247
249
let repostCreatedAt: string | undefined;
248
-
250
+
249
251
if (isRepost && latestFeedItem.reason) {
250
252
const reason = latestFeedItem.reason as any;
251
253
repostAuthor = {
···
257
259
repostCreatedAt = reason.indexedAt;
258
260
console.log('[fetchLatestBlueskyPost] This is a repost by:', repostAuthor.handle);
259
261
}
260
-
262
+
261
263
// Fetch the full post data
262
264
const post = await fetchPostFromUri(latestPostData.uri, 0, fetchFn);
263
-
265
+
264
266
if (!post) {
265
267
console.warn('[fetchLatestBlueskyPost] fetchPostFromUri returned null');
266
268
return null;
267
269
}
268
-
270
+
269
271
// Add repost context if applicable
270
272
if (isRepost) {
271
273
post.isRepost = true;
···
288
290
* Recursively fetches a Bluesky post by URI, supporting quoted posts up to 2 levels deep
289
291
*/
290
292
export async function fetchPostFromUri(
291
-
uri: string,
292
-
depth: number,
293
+
uri: string,
294
+
depth: number,
293
295
fetchFn?: typeof fetch
294
296
): Promise<BlueskyPost | null> {
295
297
console.log(`[fetchPostFromUri] Starting fetch at depth ${depth} for URI:`, uri);
···
406
408
407
409
// Extract images from media
408
410
if (media?.$type === 'app.bsky.embed.images#view' && Array.isArray(media.images)) {
409
-
console.log(`[fetchPostFromUri] Processing images in recordWithMedia, count:`, media.images.length);
411
+
console.log(
412
+
`[fetchPostFromUri] Processing images in recordWithMedia, count:`,
413
+
media.images.length
414
+
);
410
415
hasImages = true;
411
416
imageUrls = [];
412
417
imageAlts = [];
···
455
460
const quotedRecord = embed.record?.record || embed.record;
456
461
console.log(`[fetchPostFromUri] Quoted record in recordWithMedia:`, quotedRecord?.uri);
457
462
if (quotedRecord && typeof quotedRecord.uri === 'string') {
458
-
quotedPostUri = quotedRecord.uri;
459
-
console.log(
460
-
`[fetchPostFromUri] Recursively fetching quoted post at depth ${depth + 1}:`,
461
-
quotedPostUri
462
-
);
463
-
if (quotedPostUri) {
464
-
quotedPost = (await fetchPostFromUri(quotedPostUri, depth + 1, fetchFn)) ?? undefined;
463
+
quotedPostUri = quotedRecord.uri;
464
+
console.log(
465
+
`[fetchPostFromUri] Recursively fetching quoted post at depth ${depth + 1}:`,
466
+
quotedPostUri
467
+
);
468
+
if (quotedPostUri) {
469
+
quotedPost = (await fetchPostFromUri(quotedPostUri, depth + 1, fetchFn)) ?? undefined;
465
470
console.log(`[fetchPostFromUri] Quoted post fetched:`, quotedPost ? 'success' : 'failed');
466
471
}
467
472
}
···
491
496
if (value.reply) {
492
497
console.log(`[fetchPostFromUri] Post is a reply, fetching parent...`);
493
498
if (value.reply.parent?.uri) {
494
-
replyParent = (await fetchPostFromUri(value.reply.parent.uri, depth + 1, fetchFn)) ?? undefined;
499
+
replyParent =
500
+
(await fetchPostFromUri(value.reply.parent.uri, depth + 1, fetchFn)) ?? undefined;
495
501
}
496
502
if (value.reply.root?.uri && value.reply.root.uri !== value.reply.parent?.uri) {
497
503
replyRoot = (await fetchPostFromUri(value.reply.root.uri, depth + 1, fetchFn)) ?? undefined;
···
501
507
// Get engagement data from Constellation as a fallback
502
508
let finalLikeCount = postData.likeCount;
503
509
let finalRepostCount = postData.repostCount;
504
-
510
+
505
511
try {
506
512
const [likers, reposters] = await Promise.all([
507
513
fetchAllEngagement(postData.uri, 'app.bsky.feed.like'),
···
548
554
console.error(`[fetchPostFromUri] Failed to fetch post at depth ${depth}:`, err);
549
555
return null;
550
556
}
551
-
}
557
+
}
+20
-24
src/lib/stores/wolfMode.ts
+20
-24
src/lib/stores/wolfMode.ts
···
67
67
function getWolfSoundForWord(word: string, position: number): string {
68
68
// Normalize the word to lowercase for consistent mapping
69
69
const normalizedWord = word.toLowerCase();
70
-
70
+
71
71
// If we've seen this word before, return the same sound
72
72
if (wordToSoundMap.has(normalizedWord)) {
73
73
return wordToSoundMap.get(normalizedWord)!;
74
74
}
75
-
75
+
76
76
// Otherwise, assign a new sound based on position and store it
77
77
const wolfSound = getWolfSoundByPosition(position);
78
78
wordToSoundMap.set(normalizedWord, wolfSound);
···
94
94
if (!hasAlphabeticalCharacters(word)) {
95
95
return false;
96
96
}
97
-
97
+
98
98
// Don't transform if it's a number abbreviation
99
99
if (isNumberAbbreviation(word)) {
100
100
return false;
101
101
}
102
-
102
+
103
103
return true;
104
104
}
105
105
106
106
function splitWordAndPunctuation(token: string): { prefix: string; word: string; suffix: string } {
107
107
// Match leading punctuation, word, and trailing punctuation
108
108
const match = token.match(/^([^a-zA-Z0-9]*)([a-zA-Z0-9]+)([^a-zA-Z0-9]*)$/);
109
-
109
+
110
110
if (match) {
111
111
return {
112
112
prefix: match[1],
···
114
114
suffix: match[3]
115
115
};
116
116
}
117
-
117
+
118
118
// If no match, treat entire token as word
119
119
return {
120
120
prefix: '',
···
127
127
// Split by words and replace each with a wolf sound
128
128
const words = text.split(/(\s+)/); // Keep whitespace
129
129
let currentPosition = startPosition;
130
-
130
+
131
131
return words
132
132
.map((token) => {
133
133
if (token.trim().length === 0) {
134
134
return token; // Preserve whitespace
135
135
}
136
-
136
+
137
137
// Split word from surrounding punctuation
138
138
const { prefix, word, suffix } = splitWordAndPunctuation(token);
139
-
139
+
140
140
// Only transform words that should be transformed
141
141
if (!shouldTransform(word)) {
142
142
return token; // Keep numbers, abbreviations, punctuation, etc. as-is
143
143
}
144
-
144
+
145
145
const wolfSound = getWolfSoundForWord(word, currentPosition);
146
146
currentPosition++;
147
-
147
+
148
148
// Apply capitalization pattern to the wolf sound
149
149
let transformedWord = wolfSound;
150
150
if (word === word.toUpperCase() && word.length > 1) {
···
152
152
} else if (word[0] === word[0].toUpperCase()) {
153
153
transformedWord = wolfSound.charAt(0).toUpperCase() + wolfSound.slice(1);
154
154
}
155
-
155
+
156
156
// Reconstruct with original punctuation
157
157
return prefix + transformedWord + suffix;
158
158
})
···
167
167
return true;
168
168
}
169
169
}
170
-
170
+
171
171
// Skip buttons in the header navigation
172
172
if (element.closest('header button')) {
173
173
return true;
174
174
}
175
-
175
+
176
176
// Skip nav elements
177
177
if (element.tagName === 'NAV' || element.closest('nav')) {
178
178
return true;
179
179
}
180
-
180
+
181
181
return false;
182
182
}
183
183
···
186
186
callback(node as Text);
187
187
} else if (node.nodeType === Node.ELEMENT_NODE) {
188
188
const element = node as Element;
189
-
189
+
190
190
// Skip script, style tags, and navigation elements
191
-
if (
192
-
element.tagName === 'SCRIPT' ||
193
-
element.tagName === 'STYLE' ||
194
-
shouldSkipElement(element)
195
-
) {
191
+
if (element.tagName === 'SCRIPT' || element.tagName === 'STYLE' || shouldSkipElement(element)) {
196
192
return;
197
193
}
198
-
194
+
199
195
for (const child of Array.from(node.childNodes)) {
200
196
walkTextNodes(child, callback);
201
197
}
···
206
202
originalTexts.clear();
207
203
wordToSoundMap.clear();
208
204
wordCounter = 0;
209
-
205
+
210
206
walkTextNodes(document.body, (textNode) => {
211
207
const originalText = textNode.textContent || '';
212
208
if (originalText.trim().length > 0) {
···
214
210
const transformedText = convertToWolfSpeak(originalText, wordCounter);
215
211
textNode.textContent = transformedText;
216
212
// Update counter based on number of transformable words processed
217
-
wordCounter += originalText.split(/\s+/).filter(w => {
213
+
wordCounter += originalText.split(/\s+/).filter((w) => {
218
214
const { word } = splitWordAndPunctuation(w);
219
215
return shouldTransform(word);
220
216
}).length;
+3
-7
src/lib/utils/formatNumber.ts
+3
-7
src/lib/utils/formatNumber.ts
···
6
6
* Determines the effective locale, preferring system locale with fallback to 'en-GB'.
7
7
*/
8
8
function getLocale(locale?: string): string {
9
-
return (
10
-
locale ||
11
-
(typeof navigator !== 'undefined' && navigator.language) ||
12
-
'en-GB'
13
-
);
9
+
return locale || (typeof navigator !== 'undefined' && navigator.language) || 'en-GB';
14
10
}
15
11
16
12
/**
···
33
29
const roundedDown = Math.floor((num / divisor) * 10) / 10;
34
30
// Re-multiply to get the actual number to format
35
31
const adjustedNum = roundedDown * divisor;
36
-
32
+
37
33
return new Intl.NumberFormat(effectiveLocale, {
38
34
notation: 'compact',
39
35
compactDisplay: 'short',
···
59
55
export function formatNumber(num: number, locale?: string): string {
60
56
const effectiveLocale = getLocale(locale);
61
57
return new Intl.NumberFormat(effectiveLocale).format(num);
62
-
}
58
+
}
+1
-1
src/lib/utils/url.ts
+1
-1
src/lib/utils/url.ts
+5
-3
src/routes/+error.svelte
+5
-3
src/routes/+error.svelte
···
48
48
<div class="text-center">
49
49
<!-- Large status code number -->
50
50
<div class="mb-6">
51
-
<h1 class="text-8xl font-bold text-primary-500 dark:text-primary-400 md:text-9xl">
51
+
<h1 class="text-8xl font-bold text-primary-500 md:text-9xl dark:text-primary-400">
52
52
{status}
53
53
</h1>
54
54
</div>
···
65
65
66
66
<!-- Show additional error message if it's different from the description -->
67
67
{#if errorMessage && errorMessage !== errorDetails.description && status !== 404}
68
-
<p class="mb-6 rounded-lg bg-canvas-200 p-4 text-sm text-ink-600 dark:bg-canvas-800 dark:text-ink-300">
68
+
<p
69
+
class="mb-6 rounded-lg bg-canvas-200 p-4 text-sm text-ink-600 dark:bg-canvas-800 dark:text-ink-300"
70
+
>
69
71
{errorMessage}
70
72
</p>
71
73
{/if}
···
78
80
>
79
81
Return to Home
80
82
</a>
81
-
83
+
82
84
{#if status !== 404}
83
85
<button
84
86
onclick={() => window.location.reload()}
+14
-10
src/routes/+layout.svelte
+14
-10
src/routes/+layout.svelte
···
22
22
23
23
onMount(() => {
24
24
console.info('[App] Application mounted');
25
-
25
+
26
26
// Setup global error handler
27
27
window.onerror = (msg, url, lineNo, columnNo, error) => {
28
28
console.error('[App] Global error:', {
···
40
40
console.error('[App] Unhandled promise rejection:', event.reason);
41
41
};
42
42
});
43
-
43
+
44
44
// Reactive meta updates on navigation
45
-
let headMeta = $derived(createSiteMeta({
46
-
...data.siteMeta,
47
-
...data.meta
48
-
}));
45
+
let headMeta = $derived(
46
+
createSiteMeta({
47
+
...data.siteMeta,
48
+
...data.meta
49
+
})
50
+
);
49
51
</script>
50
52
51
53
<svelte:head>
52
54
<script>
53
55
// Prevent flash of unstyled content (FOUC) by applying theme before page renders
54
-
(function() {
56
+
(function () {
55
57
const stored = localStorage.getItem('theme');
56
58
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
57
59
const isDark = stored === 'dark' || (!stored && prefersDark);
58
60
const htmlElement = document.documentElement;
59
-
61
+
60
62
if (isDark) {
61
63
htmlElement.classList.add('dark');
62
64
htmlElement.style.colorScheme = 'dark';
···
76
78
<!-- Bespoke MetaTags component -->
77
79
<MetaTags meta={headMeta} siteMeta={data.siteMeta} />
78
80
79
-
<div class="flex min-h-screen flex-col overflow-x-hidden bg-canvas-50 text-ink-900 dark:bg-canvas-950 dark:text-ink-50">
81
+
<div
82
+
class="flex min-h-screen flex-col overflow-x-hidden bg-canvas-50 text-ink-900 dark:bg-canvas-950 dark:text-ink-50"
83
+
>
80
84
<Header />
81
-
85
+
82
86
<main class="container mx-auto flex-grow px-4 py-8">
83
87
<ScrollToTop />
84
88
{@render children()}
+12
-3
src/routes/+page.svelte
+12
-3
src/routes/+page.svelte
···
1
1
<script lang="ts">
2
2
import { DynamicLinks, TangledRepos } from '$lib/components/layout';
3
-
import { ProfileCard, PostCard, BlueskyPostCard, MusicStatusCard } from '$lib/components/layout/main/card';
3
+
import {
4
+
ProfileCard,
5
+
PostCard,
6
+
BlueskyPostCard,
7
+
MusicStatusCard
8
+
} from '$lib/components/layout/main/card';
4
9
import { createSiteMeta, type SiteMetadata } from '$lib/helper/siteMeta';
5
10
6
11
// The `data` object includes merged layout/page load data.
···
17
22
18
23
<div class="mx-auto max-w-6xl">
19
24
<div class="mb-8 text-center">
20
-
<h1 class="mb-4 overflow-wrap-anywhere break-words text-4xl font-bold text-ink-900 md:text-5xl dark:text-ink-50">
25
+
<h1
26
+
class="overflow-wrap-anywhere mb-4 text-4xl font-bold break-words text-ink-900 md:text-5xl dark:text-ink-50"
27
+
>
21
28
Welcome to {meta.title}
22
29
</h1>
23
-
<p class="mx-auto max-w-2xl overflow-wrap-anywhere break-words text-lg text-ink-700 dark:text-ink-200">
30
+
<p
31
+
class="overflow-wrap-anywhere mx-auto max-w-2xl text-lg break-words text-ink-700 dark:text-ink-200"
32
+
>
24
33
{meta.description}
25
34
</p>
26
35
</div>
+10
-10
src/routes/[slug=slug]/+server.ts
+10
-10
src/routes/[slug=slug]/+server.ts
···
5
5
6
6
/**
7
7
* Dynamic slug root redirect handler
8
-
*
8
+
*
9
9
* Redirects /{slug} to the appropriate Leaflet publication:
10
10
* - Uses the slug mapping config to find the publication rkey
11
11
* - Priority 1: Publication base_path from Leaflet API
12
12
* - Priority 2: Leaflet /lish format
13
-
*
13
+
*
14
14
* Individual posts are handled by the [rkey] route.
15
15
*/
16
16
export const GET: RequestHandler = async ({ params, url }) => {
17
17
const slug = params.slug;
18
-
18
+
19
19
// If there's a path after /{slug}, let it fall through to other routes
20
20
const slugPath = url.pathname.replace(new RegExp(`^/${slug}/?`), '');
21
-
21
+
22
22
if (slugPath && !['rss', 'atom'].includes(slugPath)) {
23
23
// This will be caught by the [rkey] route
24
24
return new Response(null, {
···
40
40
}
41
41
});
42
42
}
43
-
43
+
44
44
const publicationRkey = getPublicationRkeyFromSlug(slug);
45
-
45
+
46
46
if (!publicationRkey) {
47
47
return new Response(
48
48
`Slug not configured: ${slug}\n\nPlease add this slug to src/lib/config/slugs.ts`,
···
60
60
try {
61
61
// Fetch publications to get base path
62
62
const { publications } = await fetchLeafletPublications();
63
-
const publication = publications.find(p => p.rkey === publicationRkey);
64
-
63
+
const publication = publications.find((p) => p.rkey === publicationRkey);
64
+
65
65
if (publication?.basePath) {
66
66
// Ensure basePath is a complete URL
67
-
redirectUrl = publication.basePath.startsWith('http')
68
-
? publication.basePath
67
+
redirectUrl = publication.basePath.startsWith('http')
68
+
? publication.basePath
69
69
: `https://${publication.basePath}`;
70
70
} else {
71
71
// Use Leaflet /lish format
+19
-20
src/routes/[slug=slug]/[rkey]/+server.ts
+19
-20
src/routes/[slug=slug]/[rkey]/+server.ts
···
69
69
70
70
if (publication?.basePath) {
71
71
// Ensure basePath is a complete URL
72
-
const basePath = publication.basePath.startsWith('http')
73
-
? publication.basePath
72
+
const basePath = publication.basePath.startsWith('http')
73
+
? publication.basePath
74
74
: `https://${publication.basePath}`;
75
75
url = `${basePath}/${rkey}`;
76
76
} else if (docPublicationRkey) {
···
88
88
// Check WhiteWind as fallback (only if enabled)
89
89
if (PUBLIC_ENABLE_WHITEWIND === 'true') {
90
90
const whiteWindRecord = await withFallback(
91
-
PUBLIC_ATPROTO_DID,
92
-
async (agent) => {
93
-
try {
94
-
const response = await agent.com.atproto.repo.getRecord({
95
-
repo: PUBLIC_ATPROTO_DID,
96
-
collection: 'com.whtwnd.blog.entry',
97
-
rkey
98
-
});
99
-
return response.data;
100
-
} catch (err) {
101
-
// Record not found
102
-
return null;
103
-
}
104
-
},
91
+
PUBLIC_ATPROTO_DID,
92
+
async (agent) => {
93
+
try {
94
+
const response = await agent.com.atproto.repo.getRecord({
95
+
repo: PUBLIC_ATPROTO_DID,
96
+
collection: 'com.whtwnd.blog.entry',
97
+
rkey
98
+
});
99
+
return response.data;
100
+
} catch (err) {
101
+
// Record not found
102
+
return null;
103
+
}
104
+
},
105
105
true // Use PDS first for custom collections
106
106
);
107
107
···
140
140
141
141
// Get the publication rkey from the slug
142
142
const publicationRkey = getPublicationRkeyFromSlug(slug);
143
-
143
+
144
144
if (!publicationRkey) {
145
145
return new Response(
146
146
`Slug not configured: ${slug}\n\nPlease add this slug to src/lib/config/slugs.ts`,
···
180
180
} else {
181
181
// No fallback configured, return 404
182
182
const publicationNote = `\n\nNote: Only checking Leaflet publication with rkey: ${publicationRkey}`;
183
-
const whiteWindNote = PUBLIC_ENABLE_WHITEWIND === 'true'
184
-
? '\n- WhiteWind: https://whtwnd.com'
185
-
: '';
183
+
const whiteWindNote =
184
+
PUBLIC_ENABLE_WHITEWIND === 'true' ? '\n- WhiteWind: https://whtwnd.com' : '';
186
185
187
186
return new Response(
188
187
`Document not found: ${rkey}
+3
-3
src/routes/[slug=slug]/atom/+server.ts
+3
-3
src/routes/[slug=slug]/atom/+server.ts
···
14
14
*/
15
15
export const GET: RequestHandler = ({ params }) => {
16
16
const slug = params.slug;
17
-
17
+
18
18
// Validate slug
19
19
if (!slug) {
20
20
return new Response('Invalid slug', {
···
24
24
}
25
25
});
26
26
}
27
-
27
+
28
28
// Validate slug exists in config
29
29
const publicationRkey = getPublicationRkeyFromSlug(slug);
30
-
30
+
31
31
if (!publicationRkey) {
32
32
return new Response(
33
33
`Slug not configured: ${slug}\n\nPlease add this slug to src/lib/config/slugs.ts`,
+12
-15
src/routes/[slug=slug]/rss/+server.ts
+12
-15
src/routes/[slug=slug]/rss/+server.ts
···
19
19
*/
20
20
export const GET: RequestHandler = async ({ params }) => {
21
21
const slug = params.slug;
22
-
22
+
23
23
// Validate slug
24
24
if (!slug) {
25
25
return new Response('Invalid slug', {
···
29
29
}
30
30
});
31
31
}
32
-
32
+
33
33
// Get the publication rkey from the slug
34
34
const publicationRkey = getPublicationRkeyFromSlug(slug);
35
-
35
+
36
36
if (!publicationRkey) {
37
37
return new Response(
38
38
`Slug not configured: ${slug}\n\nPlease add this slug to src/lib/config/slugs.ts`,
···
50
50
51
51
// Filter posts for this specific publication
52
52
const publicationPosts = posts.filter(
53
-
p => p.publicationRkey === publicationRkey || p.platform === 'WhiteWind'
53
+
(p) => p.publicationRkey === publicationRkey || p.platform === 'WhiteWind'
54
54
);
55
55
56
56
// Separate WhiteWind and Leaflet posts
···
134
134
135
135
// Find the specific publication
136
136
const publication = publications.find((p) => p.rkey === publicationRkey);
137
-
137
+
138
138
if (publication) {
139
139
const rssUrl = getLeafletRSSUrl(publication);
140
140
return Response.redirect(rssUrl, 307); // Temporary redirect
141
141
}
142
142
143
143
// Publication not found
144
-
return new Response(
145
-
`Leaflet publication not found for rkey: ${publicationRkey}`,
146
-
{
147
-
status: 404,
148
-
headers: {
149
-
'Content-Type': 'text/plain; charset=utf-8'
150
-
}
144
+
return new Response(`Leaflet publication not found for rkey: ${publicationRkey}`, {
145
+
status: 404,
146
+
headers: {
147
+
'Content-Type': 'text/plain; charset=utf-8'
151
148
}
152
-
);
149
+
});
153
150
} catch (error) {
154
151
console.error('Error redirecting to Leaflet RSS:', error);
155
152
return new Response('Error finding Leaflet RSS feed', {
···
167
164
function getLeafletRSSUrl(publication: { basePath?: string; rkey: string }): string {
168
165
if (publication.basePath) {
169
166
// Ensure basePath is a complete URL
170
-
const basePath = publication.basePath.startsWith('http')
171
-
? publication.basePath
167
+
const basePath = publication.basePath.startsWith('http')
168
+
? publication.basePath
172
169
: `https://${publication.basePath}`;
173
170
return `${basePath}/rss`;
174
171
}
+3
-8
src/routes/api/artwork/+server.ts
+3
-8
src/routes/api/artwork/+server.ts
···
143
143
releaseName?: string
144
144
): Promise<string | null> {
145
145
try {
146
-
const searchTerm = releaseName
147
-
? `${releaseName} ${artistName}`
148
-
: `${trackName} ${artistName}`;
146
+
const searchTerm = releaseName ? `${releaseName} ${artistName}` : `${trackName} ${artistName}`;
149
147
150
148
const url = `https://itunes.apple.com/search?term=${encodeURIComponent(searchTerm)}&entity=album&limit=5`;
151
149
···
196
194
/**
197
195
* Search Last.fm for artwork
198
196
*/
199
-
async function searchLastFm(
200
-
artistName: string,
201
-
releaseName?: string
202
-
): Promise<string | null> {
197
+
async function searchLastFm(artistName: string, releaseName?: string): Promise<string | null> {
203
198
if (!releaseName) return null;
204
199
205
200
try {
···
229
224
/**
230
225
* GET /api/artwork
231
226
* Query params: trackName, artistName, releaseName?, releaseMbId?
232
-
*
227
+
*
233
228
* Features:
234
229
* - Intelligent caching (1 hour TTL)
235
230
* - Multiple fallback sources (MusicBrainz, iTunes, Deezer, Last.fm)
+2
-2
src/routes/favicon.ico/+server.ts
+2
-2
src/routes/favicon.ico/+server.ts
···
2
2
3
3
/**
4
4
* Redirects /favicon.ico to /favicon/favicon.ico
5
-
*
5
+
*
6
6
* This handles browsers that request favicon.ico from the root
7
7
* and redirects them to the actual location in the /favicon/ directory.
8
8
*/
···
14
14
'Cache-Control': 'public, max-age=31536000, immutable'
15
15
}
16
16
});
17
-
};
17
+
};
+23
-13
src/routes/site/meta/+page.svelte
+23
-13
src/routes/site/meta/+page.svelte
···
31
31
</div>
32
32
{:else if siteInfo}
33
33
<div class="space-y-8">
34
-
{#each [
35
-
{ title: 'Purpose', content: siteInfo.additionalInfo?.purpose },
36
-
{ title: 'History', content: siteInfo.additionalInfo?.websiteBirthYear ? `This website was first launched in ${siteInfo.additionalInfo.websiteBirthYear}.` : null },
37
-
{ title: 'Privacy', content: siteInfo.privacyStatement }
38
-
] as section}
34
+
{#each [{ title: 'Purpose', content: siteInfo.additionalInfo?.purpose }, { title: 'History', content: siteInfo.additionalInfo?.websiteBirthYear ? `This website was first launched in ${siteInfo.additionalInfo.websiteBirthYear}.` : null }, { title: 'Privacy', content: siteInfo.privacyStatement }] as section}
39
35
{#if section.content}
40
-
<section class="rounded-xl bg-canvas-100 p-6 shadow-lg transition-all duration-300 hover:shadow-xl dark:bg-canvas-900">
36
+
<section
37
+
class="rounded-xl bg-canvas-100 p-6 shadow-lg transition-all duration-300 hover:shadow-xl dark:bg-canvas-900"
38
+
>
41
39
<h2 class="mb-4 text-2xl font-bold text-ink-900 dark:text-ink-50">{section.title}</h2>
42
40
<p class="whitespace-pre-wrap text-ink-700 dark:text-ink-300">{section.content}</p>
43
41
</section>
···
45
43
{/each}
46
44
47
45
{#if siteInfo.technologyStack?.length}
48
-
<section class="rounded-xl bg-canvas-100 p-6 shadow-lg transition-all duration-300 hover:shadow-xl dark:bg-canvas-900">
46
+
<section
47
+
class="rounded-xl bg-canvas-100 p-6 shadow-lg transition-all duration-300 hover:shadow-xl dark:bg-canvas-900"
48
+
>
49
49
<h2 class="mb-4 text-2xl font-bold text-ink-900 dark:text-ink-50">Technology Stack</h2>
50
50
<div class="space-y-2">
51
51
{#each siteInfo.technologyStack as tech}
···
56
56
{/if}
57
57
58
58
{#if siteInfo.openSourceInfo}
59
-
<section class="rounded-xl bg-canvas-100 p-6 shadow-lg transition-all duration-300 hover:shadow-xl dark:bg-canvas-900">
59
+
<section
60
+
class="rounded-xl bg-canvas-100 p-6 shadow-lg transition-all duration-300 hover:shadow-xl dark:bg-canvas-900"
61
+
>
60
62
<h2 class="mb-4 text-2xl font-bold text-ink-900 dark:text-ink-50">Open Source</h2>
61
63
{#if siteInfo.openSourceInfo.description}
62
-
<p class="mb-4 whitespace-pre-wrap text-ink-700 dark:text-ink-300">{siteInfo.openSourceInfo.description}</p>
64
+
<p class="mb-4 whitespace-pre-wrap text-ink-700 dark:text-ink-300">
65
+
{siteInfo.openSourceInfo.description}
66
+
</p>
63
67
{/if}
64
68
{#if siteInfo.openSourceInfo.repositories?.length}
65
69
<div class="space-y-2">
···
80
84
{/if}
81
85
82
86
{#if siteInfo.credits?.length}
83
-
<section class="rounded-xl bg-canvas-100 p-6 shadow-lg transition-all duration-300 hover:shadow-xl dark:bg-canvas-900">
87
+
<section
88
+
class="rounded-xl bg-canvas-100 p-6 shadow-lg transition-all duration-300 hover:shadow-xl dark:bg-canvas-900"
89
+
>
84
90
<h2 class="mb-4 text-2xl font-bold text-ink-900 dark:text-ink-50">Credits</h2>
85
91
<div class="grid gap-4 md:grid-cols-2">
86
92
{#each siteInfo.credits as credit}
87
93
<div class="rounded-lg bg-canvas-200 p-4 dark:bg-canvas-800">
88
94
<h4 class="font-medium text-ink-900 dark:text-ink-50">{credit.name}</h4>
89
-
{#if credit.author}<p class="text-sm text-ink-600 dark:text-ink-400">by {credit.author}</p>{/if}
90
-
{#if credit.description}<p class="mt-1 text-sm text-ink-700 dark:text-ink-300">{credit.description}</p>{/if}
95
+
{#if credit.author}<p class="text-sm text-ink-600 dark:text-ink-400">
96
+
by {credit.author}
97
+
</p>{/if}
98
+
{#if credit.description}<p class="mt-1 text-sm text-ink-700 dark:text-ink-300">
99
+
{credit.description}
100
+
</p>{/if}
91
101
</div>
92
102
{/each}
93
103
</div>
···
99
109
<p class="text-ink-700 dark:text-ink-300">No site information available.</p>
100
110
</div>
101
111
{/if}
102
-
</div>
112
+
</div>
+15
-15
src/routes/site/meta/+page.ts
+15
-15
src/routes/site/meta/+page.ts
···
4
4
import { ogImages } from '$lib/helper/ogImages';
5
5
6
6
export const load: PageLoad = async ({ parent, fetch }) => {
7
-
const { siteMeta } = await parent();
7
+
const { siteMeta } = await parent();
8
8
9
-
let siteInfo: SiteInfoData | null = null;
10
-
let error: string | null = null;
9
+
let siteInfo: SiteInfoData | null = null;
10
+
let error: string | null = null;
11
11
12
-
try {
13
-
siteInfo = await fetchSiteInfo(fetch);
14
-
} catch (err) {
15
-
error = err instanceof Error ? err.message : 'Failed to load site information';
16
-
}
12
+
try {
13
+
siteInfo = await fetchSiteInfo(fetch);
14
+
} catch (err) {
15
+
error = err instanceof Error ? err.message : 'Failed to load site information';
16
+
}
17
17
18
-
const meta: SiteMetadata = createSiteMeta({
19
-
...siteMeta,
20
-
title: `Site Meta - ${defaultSiteMeta.title}`,
21
-
description: 'Information about this website, its technology stack, and credits.',
22
-
image: ogImages.siteMeta,
23
-
});
18
+
const meta: SiteMetadata = createSiteMeta({
19
+
...siteMeta,
20
+
title: `Site Meta - ${defaultSiteMeta.title}`,
21
+
description: 'Information about this website, its technology stack, and credits.',
22
+
image: ogImages.siteMeta
23
+
});
24
24
25
-
return { siteInfo, error, meta };
25
+
return { siteInfo, error, meta };
26
26
};
+12
-12
tsconfig.json
+12
-12
tsconfig.json
···
1
1
{
2
-
"extends": "./.svelte-kit/tsconfig.json",
3
-
"compilerOptions": {
4
-
"allowJs": true,
5
-
"checkJs": true,
6
-
"esModuleInterop": true,
7
-
"forceConsistentCasingInFileNames": true,
8
-
"resolveJsonModule": true,
9
-
"skipLibCheck": true,
10
-
"sourceMap": true,
11
-
"strict": true,
12
-
"moduleResolution": "bundler"
13
-
}
2
+
"extends": "./.svelte-kit/tsconfig.json",
3
+
"compilerOptions": {
4
+
"allowJs": true,
5
+
"checkJs": true,
6
+
"esModuleInterop": true,
7
+
"forceConsistentCasingInFileNames": true,
8
+
"resolveJsonModule": true,
9
+
"skipLibCheck": true,
10
+
"sourceMap": true,
11
+
"strict": true,
12
+
"moduleResolution": "bundler"
13
+
}
14
14
}