+15
.gitignore
+15
.gitignore
+1
.npmrc
+1
.npmrc
···
1
+
engine-strict=true
+4
.prettierignore
+4
.prettierignore
+31
.prettierrc
+31
.prettierrc
···
1
+
{
2
+
"trailingComma": "all",
3
+
"useTabs": true,
4
+
"tabWidth": 2,
5
+
"printWidth": 110,
6
+
"semi": true,
7
+
"singleQuote": true,
8
+
"bracketSpacing": true,
9
+
"plugins": ["prettier-plugin-svelte", "prettier-plugin-css-order"],
10
+
"overrides": [
11
+
{
12
+
"files": "*.svelte",
13
+
"options": {
14
+
"parser": "svelte"
15
+
}
16
+
},
17
+
{
18
+
"files": ["tsconfig.json", "jsconfig.json", "tsconfig.*.json"],
19
+
"options": {
20
+
"parser": "jsonc"
21
+
}
22
+
},
23
+
{
24
+
"files": ["*.md"],
25
+
"options": {
26
+
"printWidth": 100,
27
+
"proseWrap": "always"
28
+
}
29
+
}
30
+
]
31
+
}
+35
package.json
+35
package.json
···
1
+
{
2
+
"private": true,
3
+
"name": "anartia",
4
+
"type": "module",
5
+
"scripts": {
6
+
"dev": "vite dev",
7
+
"build": "vite build",
8
+
"preview": "vite preview",
9
+
"prepare": "svelte-kit sync || echo ''",
10
+
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
11
+
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
12
+
"format": "prettier --write .",
13
+
"lint": "prettier --check ."
14
+
},
15
+
"devDependencies": {
16
+
"@sveltejs/adapter-cloudflare": "^5.0.2",
17
+
"@sveltejs/kit": "^2.17.1",
18
+
"@sveltejs/vite-plugin-svelte": "^5.0.3",
19
+
"prettier": "^3.4.2",
20
+
"prettier-plugin-css-order": "^2.1.2",
21
+
"prettier-plugin-svelte": "^3.3.3",
22
+
"svelte": "^5.19.8",
23
+
"svelte-check": "^4.1.4",
24
+
"typescript": "~5.7.3",
25
+
"vite": "^6.1.0",
26
+
"wrangler": "^3.107.3"
27
+
},
28
+
"dependencies": {
29
+
"@atcute/bluesky": "^1.0.12",
30
+
"@atcute/bluesky-richtext-parser": "^1.0.7",
31
+
"@atcute/bluesky-richtext-segmenter": "^1.0.5",
32
+
"@atcute/client": "^2.0.7",
33
+
"@badrap/valita": "^0.4.2"
34
+
}
35
+
}
+1706
pnpm-lock.yaml
+1706
pnpm-lock.yaml
···
1
+
lockfileVersion: '9.0'
2
+
3
+
settings:
4
+
autoInstallPeers: true
5
+
excludeLinksFromLockfile: false
6
+
7
+
importers:
8
+
9
+
.:
10
+
dependencies:
11
+
'@atcute/bluesky':
12
+
specifier: ^1.0.12
13
+
version: 1.0.12(@atcute/client@2.0.7)
14
+
'@atcute/bluesky-richtext-parser':
15
+
specifier: ^1.0.7
16
+
version: 1.0.7
17
+
'@atcute/bluesky-richtext-segmenter':
18
+
specifier: ^1.0.5
19
+
version: 1.0.5(@atcute/bluesky@1.0.12(@atcute/client@2.0.7))(@atcute/client@2.0.7)
20
+
'@atcute/client':
21
+
specifier: ^2.0.7
22
+
version: 2.0.7
23
+
'@badrap/valita':
24
+
specifier: ^0.4.2
25
+
version: 0.4.2
26
+
devDependencies:
27
+
'@sveltejs/adapter-cloudflare':
28
+
specifier: ^5.0.2
29
+
version: 5.0.2(@sveltejs/kit@2.17.1(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.19.8)(vite@6.1.0))(svelte@5.19.8)(vite@6.1.0))(wrangler@3.107.3(@cloudflare/workers-types@4.20250204.0))
30
+
'@sveltejs/kit':
31
+
specifier: ^2.17.1
32
+
version: 2.17.1(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.19.8)(vite@6.1.0))(svelte@5.19.8)(vite@6.1.0)
33
+
'@sveltejs/vite-plugin-svelte':
34
+
specifier: ^5.0.3
35
+
version: 5.0.3(svelte@5.19.8)(vite@6.1.0)
36
+
prettier:
37
+
specifier: ^3.4.2
38
+
version: 3.4.2
39
+
prettier-plugin-css-order:
40
+
specifier: ^2.1.2
41
+
version: 2.1.2(postcss@8.5.1)(prettier@3.4.2)
42
+
prettier-plugin-svelte:
43
+
specifier: ^3.3.3
44
+
version: 3.3.3(prettier@3.4.2)(svelte@5.19.8)
45
+
svelte:
46
+
specifier: ^5.19.8
47
+
version: 5.19.8
48
+
svelte-check:
49
+
specifier: ^4.1.4
50
+
version: 4.1.4(svelte@5.19.8)(typescript@5.7.3)
51
+
typescript:
52
+
specifier: ~5.7.3
53
+
version: 5.7.3
54
+
vite:
55
+
specifier: ^6.1.0
56
+
version: 6.1.0
57
+
wrangler:
58
+
specifier: ^3.107.3
59
+
version: 3.107.3(@cloudflare/workers-types@4.20250204.0)
60
+
61
+
packages:
62
+
63
+
'@ampproject/remapping@2.3.0':
64
+
resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==}
65
+
engines: {node: '>=6.0.0'}
66
+
67
+
'@atcute/bluesky-richtext-parser@1.0.7':
68
+
resolution: {integrity: sha512-nOvU699OXiGMbyswao7JJnY0C9WkwE7PVC/m5WWt0UN9fsXSOor9IZWw+v9SATp+94BTJoG38XyUomUaJnoQRA==}
69
+
70
+
'@atcute/bluesky-richtext-segmenter@1.0.5':
71
+
resolution: {integrity: sha512-D0FfmJVwppky9naL1OGKcQjtgO0lDLhkG4iCQHpShuHhEZ9FUdf3eUb/eQpVRNJGaJ4ShjEOHA6FlAizcjMkGQ==}
72
+
peerDependencies:
73
+
'@atcute/bluesky': ^1.0.0
74
+
'@atcute/client': ^1.0.0 || ^2.0.0
75
+
76
+
'@atcute/bluesky@1.0.12':
77
+
resolution: {integrity: sha512-oUM+MxD5asGYyQDOHBGay7f9ryhsBpQ8LTUmsEZvp4t/WG0ZV2AcFRWsG0DxB+CsmSTbP2DHLMZCatE3usmt+g==}
78
+
peerDependencies:
79
+
'@atcute/client': ^1.0.0 || ^2.0.0
80
+
81
+
'@atcute/client@2.0.7':
82
+
resolution: {integrity: sha512-bvNahrCGvhZw/EIx0HU/GOoKZEnUaAppbuZh7cu+VsOFA2tdFLnZJed9Hagh5Yz/eUX7QUh5NB4dRTRUdggSLQ==}
83
+
84
+
'@badrap/valita@0.4.2':
85
+
resolution: {integrity: sha512-Mwmr7k2iK0Yy0POLnAFUgab2mxKYeIsYXHY7sg3jo8XFsFHbG0SBmTcktXD0uW8N4WZePKf8s68QV7QDTGSdHA==}
86
+
engines: {node: '>= 18'}
87
+
88
+
'@cloudflare/kv-asset-handler@0.3.4':
89
+
resolution: {integrity: sha512-YLPHc8yASwjNkmcDMQMY35yiWjoKAKnhUbPRszBRS0YgH+IXtsMp61j+yTcnCE3oO2DgP0U3iejLC8FTtKDC8Q==}
90
+
engines: {node: '>=16.13'}
91
+
92
+
'@cloudflare/workerd-darwin-64@1.20250129.0':
93
+
resolution: {integrity: sha512-M+xETVnl+xy2dfDDWmp0XXr2rttl70a6bljQygl0EmYmNswFTcYbQWCaBuNBo9kabU59rLKr4a/b3QZ07NoL/g==}
94
+
engines: {node: '>=16'}
95
+
cpu: [x64]
96
+
os: [darwin]
97
+
98
+
'@cloudflare/workerd-darwin-arm64@1.20250129.0':
99
+
resolution: {integrity: sha512-c4PQUyIMp+bCMxZkAMBzXgTHjRZxeYCujDbb3staestqgRbenzcfauXsMd6np35ng+EE1uBgHNPV4+7fC0ZBfg==}
100
+
engines: {node: '>=16'}
101
+
cpu: [arm64]
102
+
os: [darwin]
103
+
104
+
'@cloudflare/workerd-linux-64@1.20250129.0':
105
+
resolution: {integrity: sha512-xJx8LwWFxsm5U3DETJwRuOmT5RWBqm4FmA4itYXvcEICca9pWJDB641kT4PnpypwDNmYOebhU7A+JUrCRucG0w==}
106
+
engines: {node: '>=16'}
107
+
cpu: [x64]
108
+
os: [linux]
109
+
110
+
'@cloudflare/workerd-linux-arm64@1.20250129.0':
111
+
resolution: {integrity: sha512-dR//npbaX5p323huBVNIy5gaWubQx6CC3aiXeK0yX4aD5ar8AjxQFb2U/Sgjeo65Rkt53hJWqC7IwRpK/eOxrA==}
112
+
engines: {node: '>=16'}
113
+
cpu: [arm64]
114
+
os: [linux]
115
+
116
+
'@cloudflare/workerd-windows-64@1.20250129.0':
117
+
resolution: {integrity: sha512-OeO+1nPj/ocAE3adFar/tRFGRkbCrBnrOYXq0FUBSpyNHpDdA9/U3PAw5CN4zvjfTnqXZfTxTFeqoruqzRzbtg==}
118
+
engines: {node: '>=16'}
119
+
cpu: [x64]
120
+
os: [win32]
121
+
122
+
'@cloudflare/workers-types@4.20250204.0':
123
+
resolution: {integrity: sha512-mWoQbYaP+nYztx9I7q9sgaiNlT54Cypszz0RfzMxYnT5W3NXDuwGcjGB+5B5H5VB8tEC2dYnBRpa70lX94ueaQ==}
124
+
125
+
'@cspotcode/source-map-support@0.8.1':
126
+
resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==}
127
+
engines: {node: '>=12'}
128
+
129
+
'@esbuild-plugins/node-globals-polyfill@0.2.3':
130
+
resolution: {integrity: sha512-r3MIryXDeXDOZh7ih1l/yE9ZLORCd5e8vWg02azWRGj5SPTuoh69A2AIyn0Z31V/kHBfZ4HgWJ+OK3GTTwLmnw==}
131
+
peerDependencies:
132
+
esbuild: '*'
133
+
134
+
'@esbuild-plugins/node-modules-polyfill@0.2.2':
135
+
resolution: {integrity: sha512-LXV7QsWJxRuMYvKbiznh+U1ilIop3g2TeKRzUxOG5X3YITc8JyyTa90BmLwqqv0YnX4v32CSlG+vsziZp9dMvA==}
136
+
peerDependencies:
137
+
esbuild: '*'
138
+
139
+
'@esbuild/aix-ppc64@0.24.2':
140
+
resolution: {integrity: sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==}
141
+
engines: {node: '>=18'}
142
+
cpu: [ppc64]
143
+
os: [aix]
144
+
145
+
'@esbuild/android-arm64@0.17.19':
146
+
resolution: {integrity: sha512-KBMWvEZooR7+kzY0BtbTQn0OAYY7CsiydT63pVEaPtVYF0hXbUaOyZog37DKxK7NF3XacBJOpYT4adIJh+avxA==}
147
+
engines: {node: '>=12'}
148
+
cpu: [arm64]
149
+
os: [android]
150
+
151
+
'@esbuild/android-arm64@0.24.2':
152
+
resolution: {integrity: sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==}
153
+
engines: {node: '>=18'}
154
+
cpu: [arm64]
155
+
os: [android]
156
+
157
+
'@esbuild/android-arm@0.17.19':
158
+
resolution: {integrity: sha512-rIKddzqhmav7MSmoFCmDIb6e2W57geRsM94gV2l38fzhXMwq7hZoClug9USI2pFRGL06f4IOPHHpFNOkWieR8A==}
159
+
engines: {node: '>=12'}
160
+
cpu: [arm]
161
+
os: [android]
162
+
163
+
'@esbuild/android-arm@0.24.2':
164
+
resolution: {integrity: sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==}
165
+
engines: {node: '>=18'}
166
+
cpu: [arm]
167
+
os: [android]
168
+
169
+
'@esbuild/android-x64@0.17.19':
170
+
resolution: {integrity: sha512-uUTTc4xGNDT7YSArp/zbtmbhO0uEEK9/ETW29Wk1thYUJBz3IVnvgEiEwEa9IeLyvnpKrWK64Utw2bgUmDveww==}
171
+
engines: {node: '>=12'}
172
+
cpu: [x64]
173
+
os: [android]
174
+
175
+
'@esbuild/android-x64@0.24.2':
176
+
resolution: {integrity: sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==}
177
+
engines: {node: '>=18'}
178
+
cpu: [x64]
179
+
os: [android]
180
+
181
+
'@esbuild/darwin-arm64@0.17.19':
182
+
resolution: {integrity: sha512-80wEoCfF/hFKM6WE1FyBHc9SfUblloAWx6FJkFWTWiCoht9Mc0ARGEM47e67W9rI09YoUxJL68WHfDRYEAvOhg==}
183
+
engines: {node: '>=12'}
184
+
cpu: [arm64]
185
+
os: [darwin]
186
+
187
+
'@esbuild/darwin-arm64@0.24.2':
188
+
resolution: {integrity: sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==}
189
+
engines: {node: '>=18'}
190
+
cpu: [arm64]
191
+
os: [darwin]
192
+
193
+
'@esbuild/darwin-x64@0.17.19':
194
+
resolution: {integrity: sha512-IJM4JJsLhRYr9xdtLytPLSH9k/oxR3boaUIYiHkAawtwNOXKE8KoU8tMvryogdcT8AU+Bflmh81Xn6Q0vTZbQw==}
195
+
engines: {node: '>=12'}
196
+
cpu: [x64]
197
+
os: [darwin]
198
+
199
+
'@esbuild/darwin-x64@0.24.2':
200
+
resolution: {integrity: sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==}
201
+
engines: {node: '>=18'}
202
+
cpu: [x64]
203
+
os: [darwin]
204
+
205
+
'@esbuild/freebsd-arm64@0.17.19':
206
+
resolution: {integrity: sha512-pBwbc7DufluUeGdjSU5Si+P3SoMF5DQ/F/UmTSb8HXO80ZEAJmrykPyzo1IfNbAoaqw48YRpv8shwd1NoI0jcQ==}
207
+
engines: {node: '>=12'}
208
+
cpu: [arm64]
209
+
os: [freebsd]
210
+
211
+
'@esbuild/freebsd-arm64@0.24.2':
212
+
resolution: {integrity: sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==}
213
+
engines: {node: '>=18'}
214
+
cpu: [arm64]
215
+
os: [freebsd]
216
+
217
+
'@esbuild/freebsd-x64@0.17.19':
218
+
resolution: {integrity: sha512-4lu+n8Wk0XlajEhbEffdy2xy53dpR06SlzvhGByyg36qJw6Kpfk7cp45DR/62aPH9mtJRmIyrXAS5UWBrJT6TQ==}
219
+
engines: {node: '>=12'}
220
+
cpu: [x64]
221
+
os: [freebsd]
222
+
223
+
'@esbuild/freebsd-x64@0.24.2':
224
+
resolution: {integrity: sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==}
225
+
engines: {node: '>=18'}
226
+
cpu: [x64]
227
+
os: [freebsd]
228
+
229
+
'@esbuild/linux-arm64@0.17.19':
230
+
resolution: {integrity: sha512-ct1Tg3WGwd3P+oZYqic+YZF4snNl2bsnMKRkb3ozHmnM0dGWuxcPTTntAF6bOP0Sp4x0PjSF+4uHQ1xvxfRKqg==}
231
+
engines: {node: '>=12'}
232
+
cpu: [arm64]
233
+
os: [linux]
234
+
235
+
'@esbuild/linux-arm64@0.24.2':
236
+
resolution: {integrity: sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==}
237
+
engines: {node: '>=18'}
238
+
cpu: [arm64]
239
+
os: [linux]
240
+
241
+
'@esbuild/linux-arm@0.17.19':
242
+
resolution: {integrity: sha512-cdmT3KxjlOQ/gZ2cjfrQOtmhG4HJs6hhvm3mWSRDPtZ/lP5oe8FWceS10JaSJC13GBd4eH/haHnqf7hhGNLerA==}
243
+
engines: {node: '>=12'}
244
+
cpu: [arm]
245
+
os: [linux]
246
+
247
+
'@esbuild/linux-arm@0.24.2':
248
+
resolution: {integrity: sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==}
249
+
engines: {node: '>=18'}
250
+
cpu: [arm]
251
+
os: [linux]
252
+
253
+
'@esbuild/linux-ia32@0.17.19':
254
+
resolution: {integrity: sha512-w4IRhSy1VbsNxHRQpeGCHEmibqdTUx61Vc38APcsRbuVgK0OPEnQ0YD39Brymn96mOx48Y2laBQGqgZ0j9w6SQ==}
255
+
engines: {node: '>=12'}
256
+
cpu: [ia32]
257
+
os: [linux]
258
+
259
+
'@esbuild/linux-ia32@0.24.2':
260
+
resolution: {integrity: sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==}
261
+
engines: {node: '>=18'}
262
+
cpu: [ia32]
263
+
os: [linux]
264
+
265
+
'@esbuild/linux-loong64@0.17.19':
266
+
resolution: {integrity: sha512-2iAngUbBPMq439a+z//gE+9WBldoMp1s5GWsUSgqHLzLJ9WoZLZhpwWuym0u0u/4XmZ3gpHmzV84PonE+9IIdQ==}
267
+
engines: {node: '>=12'}
268
+
cpu: [loong64]
269
+
os: [linux]
270
+
271
+
'@esbuild/linux-loong64@0.24.2':
272
+
resolution: {integrity: sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==}
273
+
engines: {node: '>=18'}
274
+
cpu: [loong64]
275
+
os: [linux]
276
+
277
+
'@esbuild/linux-mips64el@0.17.19':
278
+
resolution: {integrity: sha512-LKJltc4LVdMKHsrFe4MGNPp0hqDFA1Wpt3jE1gEyM3nKUvOiO//9PheZZHfYRfYl6AwdTH4aTcXSqBerX0ml4A==}
279
+
engines: {node: '>=12'}
280
+
cpu: [mips64el]
281
+
os: [linux]
282
+
283
+
'@esbuild/linux-mips64el@0.24.2':
284
+
resolution: {integrity: sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==}
285
+
engines: {node: '>=18'}
286
+
cpu: [mips64el]
287
+
os: [linux]
288
+
289
+
'@esbuild/linux-ppc64@0.17.19':
290
+
resolution: {integrity: sha512-/c/DGybs95WXNS8y3Ti/ytqETiW7EU44MEKuCAcpPto3YjQbyK3IQVKfF6nbghD7EcLUGl0NbiL5Rt5DMhn5tg==}
291
+
engines: {node: '>=12'}
292
+
cpu: [ppc64]
293
+
os: [linux]
294
+
295
+
'@esbuild/linux-ppc64@0.24.2':
296
+
resolution: {integrity: sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==}
297
+
engines: {node: '>=18'}
298
+
cpu: [ppc64]
299
+
os: [linux]
300
+
301
+
'@esbuild/linux-riscv64@0.17.19':
302
+
resolution: {integrity: sha512-FC3nUAWhvFoutlhAkgHf8f5HwFWUL6bYdvLc/TTuxKlvLi3+pPzdZiFKSWz/PF30TB1K19SuCxDTI5KcqASJqA==}
303
+
engines: {node: '>=12'}
304
+
cpu: [riscv64]
305
+
os: [linux]
306
+
307
+
'@esbuild/linux-riscv64@0.24.2':
308
+
resolution: {integrity: sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==}
309
+
engines: {node: '>=18'}
310
+
cpu: [riscv64]
311
+
os: [linux]
312
+
313
+
'@esbuild/linux-s390x@0.17.19':
314
+
resolution: {integrity: sha512-IbFsFbxMWLuKEbH+7sTkKzL6NJmG2vRyy6K7JJo55w+8xDk7RElYn6xvXtDW8HCfoKBFK69f3pgBJSUSQPr+4Q==}
315
+
engines: {node: '>=12'}
316
+
cpu: [s390x]
317
+
os: [linux]
318
+
319
+
'@esbuild/linux-s390x@0.24.2':
320
+
resolution: {integrity: sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==}
321
+
engines: {node: '>=18'}
322
+
cpu: [s390x]
323
+
os: [linux]
324
+
325
+
'@esbuild/linux-x64@0.17.19':
326
+
resolution: {integrity: sha512-68ngA9lg2H6zkZcyp22tsVt38mlhWde8l3eJLWkyLrp4HwMUr3c1s/M2t7+kHIhvMjglIBrFpncX1SzMckomGw==}
327
+
engines: {node: '>=12'}
328
+
cpu: [x64]
329
+
os: [linux]
330
+
331
+
'@esbuild/linux-x64@0.24.2':
332
+
resolution: {integrity: sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==}
333
+
engines: {node: '>=18'}
334
+
cpu: [x64]
335
+
os: [linux]
336
+
337
+
'@esbuild/netbsd-arm64@0.24.2':
338
+
resolution: {integrity: sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==}
339
+
engines: {node: '>=18'}
340
+
cpu: [arm64]
341
+
os: [netbsd]
342
+
343
+
'@esbuild/netbsd-x64@0.17.19':
344
+
resolution: {integrity: sha512-CwFq42rXCR8TYIjIfpXCbRX0rp1jo6cPIUPSaWwzbVI4aOfX96OXY8M6KNmtPcg7QjYeDmN+DD0Wp3LaBOLf4Q==}
345
+
engines: {node: '>=12'}
346
+
cpu: [x64]
347
+
os: [netbsd]
348
+
349
+
'@esbuild/netbsd-x64@0.24.2':
350
+
resolution: {integrity: sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==}
351
+
engines: {node: '>=18'}
352
+
cpu: [x64]
353
+
os: [netbsd]
354
+
355
+
'@esbuild/openbsd-arm64@0.24.2':
356
+
resolution: {integrity: sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==}
357
+
engines: {node: '>=18'}
358
+
cpu: [arm64]
359
+
os: [openbsd]
360
+
361
+
'@esbuild/openbsd-x64@0.17.19':
362
+
resolution: {integrity: sha512-cnq5brJYrSZ2CF6c35eCmviIN3k3RczmHz8eYaVlNasVqsNY+JKohZU5MKmaOI+KkllCdzOKKdPs762VCPC20g==}
363
+
engines: {node: '>=12'}
364
+
cpu: [x64]
365
+
os: [openbsd]
366
+
367
+
'@esbuild/openbsd-x64@0.24.2':
368
+
resolution: {integrity: sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==}
369
+
engines: {node: '>=18'}
370
+
cpu: [x64]
371
+
os: [openbsd]
372
+
373
+
'@esbuild/sunos-x64@0.17.19':
374
+
resolution: {integrity: sha512-vCRT7yP3zX+bKWFeP/zdS6SqdWB8OIpaRq/mbXQxTGHnIxspRtigpkUcDMlSCOejlHowLqII7K2JKevwyRP2rg==}
375
+
engines: {node: '>=12'}
376
+
cpu: [x64]
377
+
os: [sunos]
378
+
379
+
'@esbuild/sunos-x64@0.24.2':
380
+
resolution: {integrity: sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==}
381
+
engines: {node: '>=18'}
382
+
cpu: [x64]
383
+
os: [sunos]
384
+
385
+
'@esbuild/win32-arm64@0.17.19':
386
+
resolution: {integrity: sha512-yYx+8jwowUstVdorcMdNlzklLYhPxjniHWFKgRqH7IFlUEa0Umu3KuYplf1HUZZ422e3NU9F4LGb+4O0Kdcaag==}
387
+
engines: {node: '>=12'}
388
+
cpu: [arm64]
389
+
os: [win32]
390
+
391
+
'@esbuild/win32-arm64@0.24.2':
392
+
resolution: {integrity: sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==}
393
+
engines: {node: '>=18'}
394
+
cpu: [arm64]
395
+
os: [win32]
396
+
397
+
'@esbuild/win32-ia32@0.17.19':
398
+
resolution: {integrity: sha512-eggDKanJszUtCdlVs0RB+h35wNlb5v4TWEkq4vZcmVt5u/HiDZrTXe2bWFQUez3RgNHwx/x4sk5++4NSSicKkw==}
399
+
engines: {node: '>=12'}
400
+
cpu: [ia32]
401
+
os: [win32]
402
+
403
+
'@esbuild/win32-ia32@0.24.2':
404
+
resolution: {integrity: sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==}
405
+
engines: {node: '>=18'}
406
+
cpu: [ia32]
407
+
os: [win32]
408
+
409
+
'@esbuild/win32-x64@0.17.19':
410
+
resolution: {integrity: sha512-lAhycmKnVOuRYNtRtatQR1LPQf2oYCkRGkSFnseDAKPl8lu5SOsK/e1sXe5a0Pc5kHIHe6P2I/ilntNv2xf3cA==}
411
+
engines: {node: '>=12'}
412
+
cpu: [x64]
413
+
os: [win32]
414
+
415
+
'@esbuild/win32-x64@0.24.2':
416
+
resolution: {integrity: sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==}
417
+
engines: {node: '>=18'}
418
+
cpu: [x64]
419
+
os: [win32]
420
+
421
+
'@fastify/busboy@2.1.1':
422
+
resolution: {integrity: sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==}
423
+
engines: {node: '>=14'}
424
+
425
+
'@jridgewell/gen-mapping@0.3.8':
426
+
resolution: {integrity: sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==}
427
+
engines: {node: '>=6.0.0'}
428
+
429
+
'@jridgewell/resolve-uri@3.1.2':
430
+
resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
431
+
engines: {node: '>=6.0.0'}
432
+
433
+
'@jridgewell/set-array@1.2.1':
434
+
resolution: {integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==}
435
+
engines: {node: '>=6.0.0'}
436
+
437
+
'@jridgewell/sourcemap-codec@1.5.0':
438
+
resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==}
439
+
440
+
'@jridgewell/trace-mapping@0.3.25':
441
+
resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==}
442
+
443
+
'@jridgewell/trace-mapping@0.3.9':
444
+
resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==}
445
+
446
+
'@polka/url@1.0.0-next.28':
447
+
resolution: {integrity: sha512-8LduaNlMZGwdZ6qWrKlfa+2M4gahzFkprZiAt2TF8uS0qQgBizKXpXURqvTJ4WtmupWxaLqjRb2UCTe72mu+Aw==}
448
+
449
+
'@rollup/rollup-android-arm-eabi@4.34.4':
450
+
resolution: {integrity: sha512-gGi5adZWvjtJU7Axs//CWaQbQd/vGy8KGcnEaCWiyCqxWYDxwIlAHFuSe6Guoxtd0SRvSfVTDMPd5H+4KE2kKA==}
451
+
cpu: [arm]
452
+
os: [android]
453
+
454
+
'@rollup/rollup-android-arm64@4.34.4':
455
+
resolution: {integrity: sha512-1aRlh1gqtF7vNPMnlf1vJKk72Yshw5zknR/ZAVh7zycRAGF2XBMVDAHmFQz/Zws5k++nux3LOq/Ejj1WrDR6xg==}
456
+
cpu: [arm64]
457
+
os: [android]
458
+
459
+
'@rollup/rollup-darwin-arm64@4.34.4':
460
+
resolution: {integrity: sha512-drHl+4qhFj+PV/jrQ78p9ch6A0MfNVZScl/nBps5a7u01aGf/GuBRrHnRegA9bP222CBDfjYbFdjkIJ/FurvSQ==}
461
+
cpu: [arm64]
462
+
os: [darwin]
463
+
464
+
'@rollup/rollup-darwin-x64@4.34.4':
465
+
resolution: {integrity: sha512-hQqq/8QALU6t1+fbNmm6dwYsa0PDD4L5r3TpHx9dNl+aSEMnIksHZkSO3AVH+hBMvZhpumIGrTFj8XCOGuIXjw==}
466
+
cpu: [x64]
467
+
os: [darwin]
468
+
469
+
'@rollup/rollup-freebsd-arm64@4.34.4':
470
+
resolution: {integrity: sha512-/L0LixBmbefkec1JTeAQJP0ETzGjFtNml2gpQXA8rpLo7Md+iXQzo9kwEgzyat5Q+OG/C//2B9Fx52UxsOXbzw==}
471
+
cpu: [arm64]
472
+
os: [freebsd]
473
+
474
+
'@rollup/rollup-freebsd-x64@4.34.4':
475
+
resolution: {integrity: sha512-6Rk3PLRK+b8L/M6m/x6Mfj60LhAUcLJ34oPaxufA+CfqkUrDoUPQYFdRrhqyOvtOKXLJZJwxlOLbQjNYQcRQfw==}
476
+
cpu: [x64]
477
+
os: [freebsd]
478
+
479
+
'@rollup/rollup-linux-arm-gnueabihf@4.34.4':
480
+
resolution: {integrity: sha512-kmT3x0IPRuXY/tNoABp2nDvI9EvdiS2JZsd4I9yOcLCCViKsP0gB38mVHOhluzx+SSVnM1KNn9k6osyXZhLoCA==}
481
+
cpu: [arm]
482
+
os: [linux]
483
+
484
+
'@rollup/rollup-linux-arm-musleabihf@4.34.4':
485
+
resolution: {integrity: sha512-3iSA9tx+4PZcJH/Wnwsvx/BY4qHpit/u2YoZoXugWVfc36/4mRkgGEoRbRV7nzNBSCOgbWMeuQ27IQWgJ7tRzw==}
486
+
cpu: [arm]
487
+
os: [linux]
488
+
489
+
'@rollup/rollup-linux-arm64-gnu@4.34.4':
490
+
resolution: {integrity: sha512-7CwSJW+sEhM9sESEk+pEREF2JL0BmyCro8UyTq0Kyh0nu1v0QPNY3yfLPFKChzVoUmaKj8zbdgBxUhBRR+xGxg==}
491
+
cpu: [arm64]
492
+
os: [linux]
493
+
494
+
'@rollup/rollup-linux-arm64-musl@4.34.4':
495
+
resolution: {integrity: sha512-GZdafB41/4s12j8Ss2izofjeFXRAAM7sHCb+S4JsI9vaONX/zQ8cXd87B9MRU/igGAJkKvmFmJJBeeT9jJ5Cbw==}
496
+
cpu: [arm64]
497
+
os: [linux]
498
+
499
+
'@rollup/rollup-linux-loongarch64-gnu@4.34.4':
500
+
resolution: {integrity: sha512-uuphLuw1X6ur11675c2twC6YxbzyLSpWggvdawTUamlsoUv81aAXRMPBC1uvQllnBGls0Qt5Siw8reSIBnbdqQ==}
501
+
cpu: [loong64]
502
+
os: [linux]
503
+
504
+
'@rollup/rollup-linux-powerpc64le-gnu@4.34.4':
505
+
resolution: {integrity: sha512-KvLEw1os2gSmD6k6QPCQMm2T9P2GYvsMZMRpMz78QpSoEevHbV/KOUbI/46/JRalhtSAYZBYLAnT9YE4i/l4vg==}
506
+
cpu: [ppc64]
507
+
os: [linux]
508
+
509
+
'@rollup/rollup-linux-riscv64-gnu@4.34.4':
510
+
resolution: {integrity: sha512-wcpCLHGM9yv+3Dql/CI4zrY2mpQ4WFergD3c9cpRowltEh5I84pRT/EuHZsG0In4eBPPYthXnuR++HrFkeqwkA==}
511
+
cpu: [riscv64]
512
+
os: [linux]
513
+
514
+
'@rollup/rollup-linux-s390x-gnu@4.34.4':
515
+
resolution: {integrity: sha512-nLbfQp2lbJYU8obhRQusXKbuiqm4jSJteLwfjnunDT5ugBKdxqw1X9KWwk8xp1OMC6P5d0WbzxzhWoznuVK6XA==}
516
+
cpu: [s390x]
517
+
os: [linux]
518
+
519
+
'@rollup/rollup-linux-x64-gnu@4.34.4':
520
+
resolution: {integrity: sha512-JGejzEfVzqc/XNiCKZj14eb6s5w8DdWlnQ5tWUbs99kkdvfq9btxxVX97AaxiUX7xJTKFA0LwoS0KU8C2faZRg==}
521
+
cpu: [x64]
522
+
os: [linux]
523
+
524
+
'@rollup/rollup-linux-x64-musl@4.34.4':
525
+
resolution: {integrity: sha512-/iFIbhzeyZZy49ozAWJ1ZR2KW6ZdYUbQXLT4O5n1cRZRoTpwExnHLjlurDXXPKEGxiAg0ujaR9JDYKljpr2fDg==}
526
+
cpu: [x64]
527
+
os: [linux]
528
+
529
+
'@rollup/rollup-win32-arm64-msvc@4.34.4':
530
+
resolution: {integrity: sha512-qORc3UzoD5UUTneiP2Afg5n5Ti1GAW9Gp5vHPxzvAFFA3FBaum9WqGvYXGf+c7beFdOKNos31/41PRMUwh1tpA==}
531
+
cpu: [arm64]
532
+
os: [win32]
533
+
534
+
'@rollup/rollup-win32-ia32-msvc@4.34.4':
535
+
resolution: {integrity: sha512-5g7E2PHNK2uvoD5bASBD9aelm44nf1w4I5FEI7MPHLWcCSrR8JragXZWgKPXk5i2FU3JFfa6CGZLw2RrGBHs2Q==}
536
+
cpu: [ia32]
537
+
os: [win32]
538
+
539
+
'@rollup/rollup-win32-x64-msvc@4.34.4':
540
+
resolution: {integrity: sha512-p0scwGkR4kZ242xLPBuhSckrJ734frz6v9xZzD+kHVYRAkSUmdSLCIJRfql6H5//aF8Q10K+i7q8DiPfZp0b7A==}
541
+
cpu: [x64]
542
+
os: [win32]
543
+
544
+
'@sveltejs/adapter-cloudflare@5.0.2':
545
+
resolution: {integrity: sha512-jlNRYFQ5mfmHmBqF79GhbFty/BTU+HZvgaT1uDREQmUcngT5j8yV85pxCOORl1r7rIVd0silRNBD5RJFsU5UUg==}
546
+
peerDependencies:
547
+
'@sveltejs/kit': ^2.0.0
548
+
wrangler: ^3.87.0
549
+
550
+
'@sveltejs/kit@2.17.1':
551
+
resolution: {integrity: sha512-CpoGSLqE2MCmcQwA2CWJvOsZ9vW+p/1H3itrFykdgajUNAEyQPbsaSn7fZb6PLHQwe+07njxje9ss0fjZoCAyw==}
552
+
engines: {node: '>=18.13'}
553
+
hasBin: true
554
+
peerDependencies:
555
+
'@sveltejs/vite-plugin-svelte': ^3.0.0 || ^4.0.0-next.1 || ^5.0.0
556
+
svelte: ^4.0.0 || ^5.0.0-next.0
557
+
vite: ^5.0.3 || ^6.0.0
558
+
559
+
'@sveltejs/vite-plugin-svelte-inspector@4.0.1':
560
+
resolution: {integrity: sha512-J/Nmb2Q2y7mck2hyCX4ckVHcR5tu2J+MtBEQqpDrrgELZ2uvraQcK/ioCV61AqkdXFgriksOKIceDcQmqnGhVw==}
561
+
engines: {node: ^18.0.0 || ^20.0.0 || >=22}
562
+
peerDependencies:
563
+
'@sveltejs/vite-plugin-svelte': ^5.0.0
564
+
svelte: ^5.0.0
565
+
vite: ^6.0.0
566
+
567
+
'@sveltejs/vite-plugin-svelte@5.0.3':
568
+
resolution: {integrity: sha512-MCFS6CrQDu1yGwspm4qtli0e63vaPCehf6V7pIMP15AsWgMKrqDGCPFF/0kn4SP0ii4aySu4Pa62+fIRGFMjgw==}
569
+
engines: {node: ^18.0.0 || ^20.0.0 || >=22}
570
+
peerDependencies:
571
+
svelte: ^5.0.0
572
+
vite: ^6.0.0
573
+
574
+
'@types/cookie@0.6.0':
575
+
resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==}
576
+
577
+
'@types/estree@1.0.6':
578
+
resolution: {integrity: sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==}
579
+
580
+
acorn-typescript@1.4.13:
581
+
resolution: {integrity: sha512-xsc9Xv0xlVfwp2o7sQ+GCQ1PgbkdcpWdTzrwXxO3xDMTAywVS3oXVOcOHuRjAPkS4P9b+yc/qNF15460v+jp4Q==}
582
+
peerDependencies:
583
+
acorn: '>=8.9.0'
584
+
585
+
acorn-walk@8.3.4:
586
+
resolution: {integrity: sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==}
587
+
engines: {node: '>=0.4.0'}
588
+
589
+
acorn@8.14.0:
590
+
resolution: {integrity: sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==}
591
+
engines: {node: '>=0.4.0'}
592
+
hasBin: true
593
+
594
+
aria-query@5.3.2:
595
+
resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==}
596
+
engines: {node: '>= 0.4'}
597
+
598
+
as-table@1.0.55:
599
+
resolution: {integrity: sha512-xvsWESUJn0JN421Xb9MQw6AsMHRCUknCe0Wjlxvjud80mU4E6hQf1A6NzQKcYNmYw62MfzEtXc+badstZP3JpQ==}
600
+
601
+
axobject-query@4.1.0:
602
+
resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==}
603
+
engines: {node: '>= 0.4'}
604
+
605
+
blake3-wasm@2.1.5:
606
+
resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==}
607
+
608
+
chokidar@4.0.3:
609
+
resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
610
+
engines: {node: '>= 14.16.0'}
611
+
612
+
clsx@2.1.1:
613
+
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
614
+
engines: {node: '>=6'}
615
+
616
+
confbox@0.1.8:
617
+
resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==}
618
+
619
+
cookie@0.6.0:
620
+
resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==}
621
+
engines: {node: '>= 0.6'}
622
+
623
+
cookie@0.7.2:
624
+
resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==}
625
+
engines: {node: '>= 0.6'}
626
+
627
+
css-declaration-sorter@7.2.0:
628
+
resolution: {integrity: sha512-h70rUM+3PNFuaBDTLe8wF/cdWu+dOZmb7pJt8Z2sedYbAcQVQV/tEchueg3GWxwqS0cxtbxmaHEdkNACqcvsow==}
629
+
engines: {node: ^14 || ^16 || >=18}
630
+
peerDependencies:
631
+
postcss: ^8.0.9
632
+
633
+
data-uri-to-buffer@2.0.2:
634
+
resolution: {integrity: sha512-ND9qDTLc6diwj+Xe5cdAgVTbLVdXbtxTJRXRhli8Mowuaan+0EJOtdqJ0QCHNSSPyoXGx9HX2/VMnKeC34AChA==}
635
+
636
+
debug@4.4.0:
637
+
resolution: {integrity: sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==}
638
+
engines: {node: '>=6.0'}
639
+
peerDependencies:
640
+
supports-color: '*'
641
+
peerDependenciesMeta:
642
+
supports-color:
643
+
optional: true
644
+
645
+
deepmerge@4.3.1:
646
+
resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==}
647
+
engines: {node: '>=0.10.0'}
648
+
649
+
defu@6.1.4:
650
+
resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==}
651
+
652
+
devalue@5.1.1:
653
+
resolution: {integrity: sha512-maua5KUiapvEwiEAe+XnlZ3Rh0GD+qI1J/nb9vrJc3muPXvcF/8gXYTWF76+5DAqHyDUtOIImEuo0YKE9mshVw==}
654
+
655
+
esbuild@0.17.19:
656
+
resolution: {integrity: sha512-XQ0jAPFkK/u3LcVRcvVHQcTIqD6E2H1fvZMA5dQPSOWb3suUbWbfbRf94pjc0bNzRYLfIrDRQXr7X+LHIm5oHw==}
657
+
engines: {node: '>=12'}
658
+
hasBin: true
659
+
660
+
esbuild@0.24.2:
661
+
resolution: {integrity: sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==}
662
+
engines: {node: '>=18'}
663
+
hasBin: true
664
+
665
+
escape-string-regexp@4.0.0:
666
+
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
667
+
engines: {node: '>=10'}
668
+
669
+
esm-env@1.2.2:
670
+
resolution: {integrity: sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==}
671
+
672
+
esrap@1.4.3:
673
+
resolution: {integrity: sha512-Xddc1RsoFJ4z9nR7W7BFaEPIp4UXoeQ0+077UdWLxbafMQFyU79sQJMk7kxNgRwQ9/aVgaKacCHC2pUACGwmYw==}
674
+
675
+
estree-walker@0.6.1:
676
+
resolution: {integrity: sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w==}
677
+
678
+
exit-hook@2.2.1:
679
+
resolution: {integrity: sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw==}
680
+
engines: {node: '>=6'}
681
+
682
+
fdir@6.4.3:
683
+
resolution: {integrity: sha512-PMXmW2y1hDDfTSRc9gaXIuCCRpuoz3Kaz8cUelp3smouvfT632ozg2vrT6lJsHKKOF59YLbOGfAWGUcKEfRMQw==}
684
+
peerDependencies:
685
+
picomatch: ^3 || ^4
686
+
peerDependenciesMeta:
687
+
picomatch:
688
+
optional: true
689
+
690
+
fsevents@2.3.3:
691
+
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
692
+
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
693
+
os: [darwin]
694
+
695
+
get-source@2.0.12:
696
+
resolution: {integrity: sha512-X5+4+iD+HoSeEED+uwrQ07BOQr0kEDFMVqqpBuI+RaZBpBpHCuXxo70bjar6f0b0u/DQJsJ7ssurpP0V60Az+w==}
697
+
698
+
glob-to-regexp@0.4.1:
699
+
resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==}
700
+
701
+
import-meta-resolve@4.1.0:
702
+
resolution: {integrity: sha512-I6fiaX09Xivtk+THaMfAwnA3MVA5Big1WHF1Dfx9hFuvNIWpXnorlkzhcQf6ehrqQiiZECRt1poOAkPmer3ruw==}
703
+
704
+
is-reference@3.0.3:
705
+
resolution: {integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==}
706
+
707
+
kleur@4.1.5:
708
+
resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==}
709
+
engines: {node: '>=6'}
710
+
711
+
locate-character@3.0.0:
712
+
resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==}
713
+
714
+
magic-string@0.25.9:
715
+
resolution: {integrity: sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==}
716
+
717
+
magic-string@0.30.17:
718
+
resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==}
719
+
720
+
mime@3.0.0:
721
+
resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==}
722
+
engines: {node: '>=10.0.0'}
723
+
hasBin: true
724
+
725
+
miniflare@3.20250129.0:
726
+
resolution: {integrity: sha512-qYlGEjMl/2kJdgNaztj4hpA64d6Dl79Lx/NL61p/v5XZRiWanBOTgkQqdPxCKZOj6KQnioqhC7lfd6jDXKSs2A==}
727
+
engines: {node: '>=16.13'}
728
+
hasBin: true
729
+
730
+
mlly@1.7.4:
731
+
resolution: {integrity: sha512-qmdSIPC4bDJXgZTCR7XosJiNKySV7O215tsPtDN9iEO/7q/76b/ijtgRu/+epFXSJhijtTCCGp3DWS549P3xKw==}
732
+
733
+
mri@1.2.0:
734
+
resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==}
735
+
engines: {node: '>=4'}
736
+
737
+
mrmime@2.0.0:
738
+
resolution: {integrity: sha512-eu38+hdgojoyq63s+yTpN4XMBdt5l8HhMhc4VKLO9KM5caLIBvUm4thi7fFaxyTmCKeNnXZ5pAlBwCUnhA09uw==}
739
+
engines: {node: '>=10'}
740
+
741
+
ms@2.1.3:
742
+
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
743
+
744
+
mustache@4.2.0:
745
+
resolution: {integrity: sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==}
746
+
hasBin: true
747
+
748
+
nanoid@3.3.8:
749
+
resolution: {integrity: sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==}
750
+
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
751
+
hasBin: true
752
+
753
+
ohash@1.1.4:
754
+
resolution: {integrity: sha512-FlDryZAahJmEF3VR3w1KogSEdWX3WhA5GPakFx4J81kEAiHyLMpdLLElS8n8dfNadMgAne/MywcvmogzscVt4g==}
755
+
756
+
path-to-regexp@6.3.0:
757
+
resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==}
758
+
759
+
pathe@1.1.2:
760
+
resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==}
761
+
762
+
pathe@2.0.2:
763
+
resolution: {integrity: sha512-15Ztpk+nov8DR524R4BF7uEuzESgzUEAV4Ah7CUMNGXdE5ELuvxElxGXndBl32vMSsWa1jpNf22Z+Er3sKwq+w==}
764
+
765
+
picocolors@1.1.1:
766
+
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
767
+
768
+
pkg-types@1.3.1:
769
+
resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==}
770
+
771
+
postcss-less@6.0.0:
772
+
resolution: {integrity: sha512-FPX16mQLyEjLzEuuJtxA8X3ejDLNGGEG503d2YGZR5Ask1SpDN8KmZUMpzCvyalWRywAn1n1VOA5dcqfCLo5rg==}
773
+
engines: {node: '>=12'}
774
+
peerDependencies:
775
+
postcss: ^8.3.5
776
+
777
+
postcss-scss@4.0.9:
778
+
resolution: {integrity: sha512-AjKOeiwAitL/MXxQW2DliT28EKukvvbEWx3LBmJIRN8KfBGZbRTxNYW0kSqi1COiTZ57nZ9NW06S6ux//N1c9A==}
779
+
engines: {node: '>=12.0'}
780
+
peerDependencies:
781
+
postcss: ^8.4.29
782
+
783
+
postcss@8.5.1:
784
+
resolution: {integrity: sha512-6oz2beyjc5VMn/KV1pPw8fliQkhBXrVn1Z3TVyqZxU8kZpzEKhBdmCFqI6ZbmGtamQvQGuU1sgPTk8ZrXDD7jQ==}
785
+
engines: {node: ^10 || ^12 || >=14}
786
+
787
+
prettier-plugin-css-order@2.1.2:
788
+
resolution: {integrity: sha512-vomxPjHI6pOMYcBuouSJHxxQClJXaUpU9rsV9IAO2wrSTZILRRlrxAAR8t9UF6wtczLkLfNRFUwM+ZbGXOONUA==}
789
+
engines: {node: '>=16'}
790
+
peerDependencies:
791
+
prettier: 3.x
792
+
793
+
prettier-plugin-svelte@3.3.3:
794
+
resolution: {integrity: sha512-yViK9zqQ+H2qZD1w/bH7W8i+bVfKrD8GIFjkFe4Thl6kCT9SlAsXVNmt3jCvQOCsnOhcvYgsoVlRV/Eu6x5nNw==}
795
+
peerDependencies:
796
+
prettier: ^3.0.0
797
+
svelte: ^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0
798
+
799
+
prettier@3.4.2:
800
+
resolution: {integrity: sha512-e9MewbtFo+Fevyuxn/4rrcDAaq0IYxPGLvObpQjiZBMAzB9IGmzlnG9RZy3FFas+eBMu2vA0CszMeduow5dIuQ==}
801
+
engines: {node: '>=14'}
802
+
hasBin: true
803
+
804
+
printable-characters@1.0.42:
805
+
resolution: {integrity: sha512-dKp+C4iXWK4vVYZmYSd0KBH5F/h1HoZRsbJ82AVKRO3PEo8L4lBS/vLwhVtpwwuYcoIsVY+1JYKR268yn480uQ==}
806
+
807
+
readdirp@4.1.1:
808
+
resolution: {integrity: sha512-h80JrZu/MHUZCyHu5ciuoI0+WxsCxzxJTILn6Fs8rxSnFPh+UVHYfeIxK1nVGugMqkfC4vJcBOYbkfkwYK0+gw==}
809
+
engines: {node: '>= 14.18.0'}
810
+
811
+
regexparam@3.0.0:
812
+
resolution: {integrity: sha512-RSYAtP31mvYLkAHrOlh25pCNQ5hWnT106VukGaaFfuJrZFkGRX5GhUAdPqpSDXxOhA2c4akmRuplv1mRqnBn6Q==}
813
+
engines: {node: '>=8'}
814
+
815
+
rollup-plugin-inject@3.0.2:
816
+
resolution: {integrity: sha512-ptg9PQwzs3orn4jkgXJ74bfs5vYz1NCZlSQMBUA0wKcGp5i5pA1AO3fOUEte8enhGUC+iapTCzEWw2jEFFUO/w==}
817
+
deprecated: This package has been deprecated and is no longer maintained. Please use @rollup/plugin-inject.
818
+
819
+
rollup-plugin-node-polyfills@0.2.1:
820
+
resolution: {integrity: sha512-4kCrKPTJ6sK4/gLL/U5QzVT8cxJcofO0OU74tnB19F40cmuAKSzH5/siithxlofFEjwvw1YAhPmbvGNA6jEroA==}
821
+
822
+
rollup-pluginutils@2.8.2:
823
+
resolution: {integrity: sha512-EEp9NhnUkwY8aif6bxgovPHMoMoNr2FulJziTndpt5H9RdwC47GSGuII9XxpSdzVGM0GWrNPHV6ie1LTNJPaLQ==}
824
+
825
+
rollup@4.34.4:
826
+
resolution: {integrity: sha512-spF66xoyD7rz3o08sHP7wogp1gZ6itSq22SGa/IZTcUDXDlOyrShwMwkVSB+BUxFRZZCUYqdb3KWDEOMVQZxuw==}
827
+
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
828
+
hasBin: true
829
+
830
+
sade@1.8.1:
831
+
resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==}
832
+
engines: {node: '>=6'}
833
+
834
+
set-cookie-parser@2.7.1:
835
+
resolution: {integrity: sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==}
836
+
837
+
sirv@3.0.0:
838
+
resolution: {integrity: sha512-BPwJGUeDaDCHihkORDchNyyTvWFhcusy1XMmhEVTQTwGeybFbp8YEmB+njbPnth1FibULBSBVwCQni25XlCUDg==}
839
+
engines: {node: '>=18'}
840
+
841
+
source-map-js@1.2.1:
842
+
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
843
+
engines: {node: '>=0.10.0'}
844
+
845
+
source-map@0.6.1:
846
+
resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
847
+
engines: {node: '>=0.10.0'}
848
+
849
+
sourcemap-codec@1.4.8:
850
+
resolution: {integrity: sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==}
851
+
deprecated: Please use @jridgewell/sourcemap-codec instead
852
+
853
+
stacktracey@2.1.8:
854
+
resolution: {integrity: sha512-Kpij9riA+UNg7TnphqjH7/CzctQ/owJGNbFkfEeve4Z4uxT5+JapVLFXcsurIfN34gnTWZNJ/f7NMG0E8JDzTw==}
855
+
856
+
stoppable@1.1.0:
857
+
resolution: {integrity: sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw==}
858
+
engines: {node: '>=4', npm: '>=6'}
859
+
860
+
svelte-check@4.1.4:
861
+
resolution: {integrity: sha512-v0j7yLbT29MezzaQJPEDwksybTE2Ups9rUxEXy92T06TiA0cbqcO8wAOwNUVkFW6B0hsYHA+oAX3BS8b/2oHtw==}
862
+
engines: {node: '>= 18.0.0'}
863
+
hasBin: true
864
+
peerDependencies:
865
+
svelte: ^4.0.0 || ^5.0.0-next.0
866
+
typescript: '>=5.0.0'
867
+
868
+
svelte@5.19.8:
869
+
resolution: {integrity: sha512-56Vd/nwJrljV0w7RCV1A8sB4/yjSbWW5qrGDTAzp7q42OxwqEWT+6obWzDt41tHjIW+C9Fs2ygtejjJrXR+ZPA==}
870
+
engines: {node: '>=18'}
871
+
872
+
totalist@3.0.1:
873
+
resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==}
874
+
engines: {node: '>=6'}
875
+
876
+
typescript@5.7.3:
877
+
resolution: {integrity: sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==}
878
+
engines: {node: '>=14.17'}
879
+
hasBin: true
880
+
881
+
ufo@1.5.4:
882
+
resolution: {integrity: sha512-UsUk3byDzKd04EyoZ7U4DOlxQaD14JUKQl6/P7wiX4FNvUfm3XL246n9W5AmqwW5RSFJ27NAuM0iLscAOYUiGQ==}
883
+
884
+
undici@5.28.5:
885
+
resolution: {integrity: sha512-zICwjrDrcrUE0pyyJc1I2QzBkLM8FINsgOrt6WjA+BgajVq9Nxu2PbFFXUrAggLfDXlZGZBVZYw7WNV5KiBiBA==}
886
+
engines: {node: '>=14.0'}
887
+
888
+
unenv@2.0.0-rc.1:
889
+
resolution: {integrity: sha512-PU5fb40H8X149s117aB4ytbORcCvlASdtF97tfls4BPIyj4PeVxvpSuy1jAptqYHqB0vb2w2sHvzM0XWcp2OKg==}
890
+
891
+
vite@6.1.0:
892
+
resolution: {integrity: sha512-RjjMipCKVoR4hVfPY6GQTgveinjNuyLw+qruksLDvA5ktI1150VmcMBKmQaEWJhg/j6Uaf6dNCNA0AfdzUb/hQ==}
893
+
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
894
+
hasBin: true
895
+
peerDependencies:
896
+
'@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0
897
+
jiti: '>=1.21.0'
898
+
less: '*'
899
+
lightningcss: ^1.21.0
900
+
sass: '*'
901
+
sass-embedded: '*'
902
+
stylus: '*'
903
+
sugarss: '*'
904
+
terser: ^5.16.0
905
+
tsx: ^4.8.1
906
+
yaml: ^2.4.2
907
+
peerDependenciesMeta:
908
+
'@types/node':
909
+
optional: true
910
+
jiti:
911
+
optional: true
912
+
less:
913
+
optional: true
914
+
lightningcss:
915
+
optional: true
916
+
sass:
917
+
optional: true
918
+
sass-embedded:
919
+
optional: true
920
+
stylus:
921
+
optional: true
922
+
sugarss:
923
+
optional: true
924
+
terser:
925
+
optional: true
926
+
tsx:
927
+
optional: true
928
+
yaml:
929
+
optional: true
930
+
931
+
vitefu@1.0.5:
932
+
resolution: {integrity: sha512-h4Vflt9gxODPFNGPwp4zAMZRpZR7eslzwH2c5hn5kNZ5rhnKyRJ50U+yGCdc2IRaBs8O4haIgLNGrV5CrpMsCA==}
933
+
peerDependencies:
934
+
vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0
935
+
peerDependenciesMeta:
936
+
vite:
937
+
optional: true
938
+
939
+
workerd@1.20250129.0:
940
+
resolution: {integrity: sha512-Rprz8rxKTF4l6q/nYYI07lBetJnR19mGipx+u/a27GZOPKMG5SLIzA2NciZlJaB2Qd5YY+4p/eHOeKqo5keVWA==}
941
+
engines: {node: '>=16'}
942
+
hasBin: true
943
+
944
+
worktop@0.8.0-next.18:
945
+
resolution: {integrity: sha512-+TvsA6VAVoMC3XDKR5MoC/qlLqDixEfOBysDEKnPIPou/NvoPWCAuXHXMsswwlvmEuvX56lQjvELLyLuzTKvRw==}
946
+
engines: {node: '>=12'}
947
+
948
+
wrangler@3.107.3:
949
+
resolution: {integrity: sha512-N9ZMDHZ+DI5/B0yclr3bG57U/Zw7wSzGdpO2l7j6+3q8yUf+4Fk0Rvneo2t8rjLewKlvqgt9D9siFuo8MXJ55Q==}
950
+
engines: {node: '>=16.17.0'}
951
+
hasBin: true
952
+
peerDependencies:
953
+
'@cloudflare/workers-types': ^4.20250129.0
954
+
peerDependenciesMeta:
955
+
'@cloudflare/workers-types':
956
+
optional: true
957
+
958
+
ws@8.18.0:
959
+
resolution: {integrity: sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==}
960
+
engines: {node: '>=10.0.0'}
961
+
peerDependencies:
962
+
bufferutil: ^4.0.1
963
+
utf-8-validate: '>=5.0.2'
964
+
peerDependenciesMeta:
965
+
bufferutil:
966
+
optional: true
967
+
utf-8-validate:
968
+
optional: true
969
+
970
+
youch@3.3.4:
971
+
resolution: {integrity: sha512-UeVBXie8cA35DS6+nBkls68xaBBXCye0CNznrhszZjTbRVnJKQuNsyLKBTTL4ln1o1rh2PKtv35twV7irj5SEg==}
972
+
973
+
zimmerframe@1.1.2:
974
+
resolution: {integrity: sha512-rAbqEGa8ovJy4pyBxZM70hg4pE6gDgaQ0Sl9M3enG3I0d6H4XSAM3GeNGLKnsBpuijUow064sf7ww1nutC5/3w==}
975
+
976
+
zod@3.24.1:
977
+
resolution: {integrity: sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A==}
978
+
979
+
snapshots:
980
+
981
+
'@ampproject/remapping@2.3.0':
982
+
dependencies:
983
+
'@jridgewell/gen-mapping': 0.3.8
984
+
'@jridgewell/trace-mapping': 0.3.25
985
+
986
+
'@atcute/bluesky-richtext-parser@1.0.7': {}
987
+
988
+
'@atcute/bluesky-richtext-segmenter@1.0.5(@atcute/bluesky@1.0.12(@atcute/client@2.0.7))(@atcute/client@2.0.7)':
989
+
dependencies:
990
+
'@atcute/bluesky': 1.0.12(@atcute/client@2.0.7)
991
+
'@atcute/client': 2.0.7
992
+
993
+
'@atcute/bluesky@1.0.12(@atcute/client@2.0.7)':
994
+
dependencies:
995
+
'@atcute/client': 2.0.7
996
+
997
+
'@atcute/client@2.0.7': {}
998
+
999
+
'@badrap/valita@0.4.2': {}
1000
+
1001
+
'@cloudflare/kv-asset-handler@0.3.4':
1002
+
dependencies:
1003
+
mime: 3.0.0
1004
+
1005
+
'@cloudflare/workerd-darwin-64@1.20250129.0':
1006
+
optional: true
1007
+
1008
+
'@cloudflare/workerd-darwin-arm64@1.20250129.0':
1009
+
optional: true
1010
+
1011
+
'@cloudflare/workerd-linux-64@1.20250129.0':
1012
+
optional: true
1013
+
1014
+
'@cloudflare/workerd-linux-arm64@1.20250129.0':
1015
+
optional: true
1016
+
1017
+
'@cloudflare/workerd-windows-64@1.20250129.0':
1018
+
optional: true
1019
+
1020
+
'@cloudflare/workers-types@4.20250204.0': {}
1021
+
1022
+
'@cspotcode/source-map-support@0.8.1':
1023
+
dependencies:
1024
+
'@jridgewell/trace-mapping': 0.3.9
1025
+
1026
+
'@esbuild-plugins/node-globals-polyfill@0.2.3(esbuild@0.17.19)':
1027
+
dependencies:
1028
+
esbuild: 0.17.19
1029
+
1030
+
'@esbuild-plugins/node-modules-polyfill@0.2.2(esbuild@0.17.19)':
1031
+
dependencies:
1032
+
esbuild: 0.17.19
1033
+
escape-string-regexp: 4.0.0
1034
+
rollup-plugin-node-polyfills: 0.2.1
1035
+
1036
+
'@esbuild/aix-ppc64@0.24.2':
1037
+
optional: true
1038
+
1039
+
'@esbuild/android-arm64@0.17.19':
1040
+
optional: true
1041
+
1042
+
'@esbuild/android-arm64@0.24.2':
1043
+
optional: true
1044
+
1045
+
'@esbuild/android-arm@0.17.19':
1046
+
optional: true
1047
+
1048
+
'@esbuild/android-arm@0.24.2':
1049
+
optional: true
1050
+
1051
+
'@esbuild/android-x64@0.17.19':
1052
+
optional: true
1053
+
1054
+
'@esbuild/android-x64@0.24.2':
1055
+
optional: true
1056
+
1057
+
'@esbuild/darwin-arm64@0.17.19':
1058
+
optional: true
1059
+
1060
+
'@esbuild/darwin-arm64@0.24.2':
1061
+
optional: true
1062
+
1063
+
'@esbuild/darwin-x64@0.17.19':
1064
+
optional: true
1065
+
1066
+
'@esbuild/darwin-x64@0.24.2':
1067
+
optional: true
1068
+
1069
+
'@esbuild/freebsd-arm64@0.17.19':
1070
+
optional: true
1071
+
1072
+
'@esbuild/freebsd-arm64@0.24.2':
1073
+
optional: true
1074
+
1075
+
'@esbuild/freebsd-x64@0.17.19':
1076
+
optional: true
1077
+
1078
+
'@esbuild/freebsd-x64@0.24.2':
1079
+
optional: true
1080
+
1081
+
'@esbuild/linux-arm64@0.17.19':
1082
+
optional: true
1083
+
1084
+
'@esbuild/linux-arm64@0.24.2':
1085
+
optional: true
1086
+
1087
+
'@esbuild/linux-arm@0.17.19':
1088
+
optional: true
1089
+
1090
+
'@esbuild/linux-arm@0.24.2':
1091
+
optional: true
1092
+
1093
+
'@esbuild/linux-ia32@0.17.19':
1094
+
optional: true
1095
+
1096
+
'@esbuild/linux-ia32@0.24.2':
1097
+
optional: true
1098
+
1099
+
'@esbuild/linux-loong64@0.17.19':
1100
+
optional: true
1101
+
1102
+
'@esbuild/linux-loong64@0.24.2':
1103
+
optional: true
1104
+
1105
+
'@esbuild/linux-mips64el@0.17.19':
1106
+
optional: true
1107
+
1108
+
'@esbuild/linux-mips64el@0.24.2':
1109
+
optional: true
1110
+
1111
+
'@esbuild/linux-ppc64@0.17.19':
1112
+
optional: true
1113
+
1114
+
'@esbuild/linux-ppc64@0.24.2':
1115
+
optional: true
1116
+
1117
+
'@esbuild/linux-riscv64@0.17.19':
1118
+
optional: true
1119
+
1120
+
'@esbuild/linux-riscv64@0.24.2':
1121
+
optional: true
1122
+
1123
+
'@esbuild/linux-s390x@0.17.19':
1124
+
optional: true
1125
+
1126
+
'@esbuild/linux-s390x@0.24.2':
1127
+
optional: true
1128
+
1129
+
'@esbuild/linux-x64@0.17.19':
1130
+
optional: true
1131
+
1132
+
'@esbuild/linux-x64@0.24.2':
1133
+
optional: true
1134
+
1135
+
'@esbuild/netbsd-arm64@0.24.2':
1136
+
optional: true
1137
+
1138
+
'@esbuild/netbsd-x64@0.17.19':
1139
+
optional: true
1140
+
1141
+
'@esbuild/netbsd-x64@0.24.2':
1142
+
optional: true
1143
+
1144
+
'@esbuild/openbsd-arm64@0.24.2':
1145
+
optional: true
1146
+
1147
+
'@esbuild/openbsd-x64@0.17.19':
1148
+
optional: true
1149
+
1150
+
'@esbuild/openbsd-x64@0.24.2':
1151
+
optional: true
1152
+
1153
+
'@esbuild/sunos-x64@0.17.19':
1154
+
optional: true
1155
+
1156
+
'@esbuild/sunos-x64@0.24.2':
1157
+
optional: true
1158
+
1159
+
'@esbuild/win32-arm64@0.17.19':
1160
+
optional: true
1161
+
1162
+
'@esbuild/win32-arm64@0.24.2':
1163
+
optional: true
1164
+
1165
+
'@esbuild/win32-ia32@0.17.19':
1166
+
optional: true
1167
+
1168
+
'@esbuild/win32-ia32@0.24.2':
1169
+
optional: true
1170
+
1171
+
'@esbuild/win32-x64@0.17.19':
1172
+
optional: true
1173
+
1174
+
'@esbuild/win32-x64@0.24.2':
1175
+
optional: true
1176
+
1177
+
'@fastify/busboy@2.1.1': {}
1178
+
1179
+
'@jridgewell/gen-mapping@0.3.8':
1180
+
dependencies:
1181
+
'@jridgewell/set-array': 1.2.1
1182
+
'@jridgewell/sourcemap-codec': 1.5.0
1183
+
'@jridgewell/trace-mapping': 0.3.25
1184
+
1185
+
'@jridgewell/resolve-uri@3.1.2': {}
1186
+
1187
+
'@jridgewell/set-array@1.2.1': {}
1188
+
1189
+
'@jridgewell/sourcemap-codec@1.5.0': {}
1190
+
1191
+
'@jridgewell/trace-mapping@0.3.25':
1192
+
dependencies:
1193
+
'@jridgewell/resolve-uri': 3.1.2
1194
+
'@jridgewell/sourcemap-codec': 1.5.0
1195
+
1196
+
'@jridgewell/trace-mapping@0.3.9':
1197
+
dependencies:
1198
+
'@jridgewell/resolve-uri': 3.1.2
1199
+
'@jridgewell/sourcemap-codec': 1.5.0
1200
+
1201
+
'@polka/url@1.0.0-next.28': {}
1202
+
1203
+
'@rollup/rollup-android-arm-eabi@4.34.4':
1204
+
optional: true
1205
+
1206
+
'@rollup/rollup-android-arm64@4.34.4':
1207
+
optional: true
1208
+
1209
+
'@rollup/rollup-darwin-arm64@4.34.4':
1210
+
optional: true
1211
+
1212
+
'@rollup/rollup-darwin-x64@4.34.4':
1213
+
optional: true
1214
+
1215
+
'@rollup/rollup-freebsd-arm64@4.34.4':
1216
+
optional: true
1217
+
1218
+
'@rollup/rollup-freebsd-x64@4.34.4':
1219
+
optional: true
1220
+
1221
+
'@rollup/rollup-linux-arm-gnueabihf@4.34.4':
1222
+
optional: true
1223
+
1224
+
'@rollup/rollup-linux-arm-musleabihf@4.34.4':
1225
+
optional: true
1226
+
1227
+
'@rollup/rollup-linux-arm64-gnu@4.34.4':
1228
+
optional: true
1229
+
1230
+
'@rollup/rollup-linux-arm64-musl@4.34.4':
1231
+
optional: true
1232
+
1233
+
'@rollup/rollup-linux-loongarch64-gnu@4.34.4':
1234
+
optional: true
1235
+
1236
+
'@rollup/rollup-linux-powerpc64le-gnu@4.34.4':
1237
+
optional: true
1238
+
1239
+
'@rollup/rollup-linux-riscv64-gnu@4.34.4':
1240
+
optional: true
1241
+
1242
+
'@rollup/rollup-linux-s390x-gnu@4.34.4':
1243
+
optional: true
1244
+
1245
+
'@rollup/rollup-linux-x64-gnu@4.34.4':
1246
+
optional: true
1247
+
1248
+
'@rollup/rollup-linux-x64-musl@4.34.4':
1249
+
optional: true
1250
+
1251
+
'@rollup/rollup-win32-arm64-msvc@4.34.4':
1252
+
optional: true
1253
+
1254
+
'@rollup/rollup-win32-ia32-msvc@4.34.4':
1255
+
optional: true
1256
+
1257
+
'@rollup/rollup-win32-x64-msvc@4.34.4':
1258
+
optional: true
1259
+
1260
+
'@sveltejs/adapter-cloudflare@5.0.2(@sveltejs/kit@2.17.1(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.19.8)(vite@6.1.0))(svelte@5.19.8)(vite@6.1.0))(wrangler@3.107.3(@cloudflare/workers-types@4.20250204.0))':
1261
+
dependencies:
1262
+
'@cloudflare/workers-types': 4.20250204.0
1263
+
'@sveltejs/kit': 2.17.1(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.19.8)(vite@6.1.0))(svelte@5.19.8)(vite@6.1.0)
1264
+
esbuild: 0.24.2
1265
+
worktop: 0.8.0-next.18
1266
+
wrangler: 3.107.3(@cloudflare/workers-types@4.20250204.0)
1267
+
1268
+
'@sveltejs/kit@2.17.1(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.19.8)(vite@6.1.0))(svelte@5.19.8)(vite@6.1.0)':
1269
+
dependencies:
1270
+
'@sveltejs/vite-plugin-svelte': 5.0.3(svelte@5.19.8)(vite@6.1.0)
1271
+
'@types/cookie': 0.6.0
1272
+
cookie: 0.6.0
1273
+
devalue: 5.1.1
1274
+
esm-env: 1.2.2
1275
+
import-meta-resolve: 4.1.0
1276
+
kleur: 4.1.5
1277
+
magic-string: 0.30.17
1278
+
mrmime: 2.0.0
1279
+
sade: 1.8.1
1280
+
set-cookie-parser: 2.7.1
1281
+
sirv: 3.0.0
1282
+
svelte: 5.19.8
1283
+
vite: 6.1.0
1284
+
1285
+
'@sveltejs/vite-plugin-svelte-inspector@4.0.1(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.19.8)(vite@6.1.0))(svelte@5.19.8)(vite@6.1.0)':
1286
+
dependencies:
1287
+
'@sveltejs/vite-plugin-svelte': 5.0.3(svelte@5.19.8)(vite@6.1.0)
1288
+
debug: 4.4.0
1289
+
svelte: 5.19.8
1290
+
vite: 6.1.0
1291
+
transitivePeerDependencies:
1292
+
- supports-color
1293
+
1294
+
'@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.19.8)(vite@6.1.0)':
1295
+
dependencies:
1296
+
'@sveltejs/vite-plugin-svelte-inspector': 4.0.1(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.19.8)(vite@6.1.0))(svelte@5.19.8)(vite@6.1.0)
1297
+
debug: 4.4.0
1298
+
deepmerge: 4.3.1
1299
+
kleur: 4.1.5
1300
+
magic-string: 0.30.17
1301
+
svelte: 5.19.8
1302
+
vite: 6.1.0
1303
+
vitefu: 1.0.5(vite@6.1.0)
1304
+
transitivePeerDependencies:
1305
+
- supports-color
1306
+
1307
+
'@types/cookie@0.6.0': {}
1308
+
1309
+
'@types/estree@1.0.6': {}
1310
+
1311
+
acorn-typescript@1.4.13(acorn@8.14.0):
1312
+
dependencies:
1313
+
acorn: 8.14.0
1314
+
1315
+
acorn-walk@8.3.4:
1316
+
dependencies:
1317
+
acorn: 8.14.0
1318
+
1319
+
acorn@8.14.0: {}
1320
+
1321
+
aria-query@5.3.2: {}
1322
+
1323
+
as-table@1.0.55:
1324
+
dependencies:
1325
+
printable-characters: 1.0.42
1326
+
1327
+
axobject-query@4.1.0: {}
1328
+
1329
+
blake3-wasm@2.1.5: {}
1330
+
1331
+
chokidar@4.0.3:
1332
+
dependencies:
1333
+
readdirp: 4.1.1
1334
+
1335
+
clsx@2.1.1: {}
1336
+
1337
+
confbox@0.1.8: {}
1338
+
1339
+
cookie@0.6.0: {}
1340
+
1341
+
cookie@0.7.2: {}
1342
+
1343
+
css-declaration-sorter@7.2.0(postcss@8.5.1):
1344
+
dependencies:
1345
+
postcss: 8.5.1
1346
+
1347
+
data-uri-to-buffer@2.0.2: {}
1348
+
1349
+
debug@4.4.0:
1350
+
dependencies:
1351
+
ms: 2.1.3
1352
+
1353
+
deepmerge@4.3.1: {}
1354
+
1355
+
defu@6.1.4: {}
1356
+
1357
+
devalue@5.1.1: {}
1358
+
1359
+
esbuild@0.17.19:
1360
+
optionalDependencies:
1361
+
'@esbuild/android-arm': 0.17.19
1362
+
'@esbuild/android-arm64': 0.17.19
1363
+
'@esbuild/android-x64': 0.17.19
1364
+
'@esbuild/darwin-arm64': 0.17.19
1365
+
'@esbuild/darwin-x64': 0.17.19
1366
+
'@esbuild/freebsd-arm64': 0.17.19
1367
+
'@esbuild/freebsd-x64': 0.17.19
1368
+
'@esbuild/linux-arm': 0.17.19
1369
+
'@esbuild/linux-arm64': 0.17.19
1370
+
'@esbuild/linux-ia32': 0.17.19
1371
+
'@esbuild/linux-loong64': 0.17.19
1372
+
'@esbuild/linux-mips64el': 0.17.19
1373
+
'@esbuild/linux-ppc64': 0.17.19
1374
+
'@esbuild/linux-riscv64': 0.17.19
1375
+
'@esbuild/linux-s390x': 0.17.19
1376
+
'@esbuild/linux-x64': 0.17.19
1377
+
'@esbuild/netbsd-x64': 0.17.19
1378
+
'@esbuild/openbsd-x64': 0.17.19
1379
+
'@esbuild/sunos-x64': 0.17.19
1380
+
'@esbuild/win32-arm64': 0.17.19
1381
+
'@esbuild/win32-ia32': 0.17.19
1382
+
'@esbuild/win32-x64': 0.17.19
1383
+
1384
+
esbuild@0.24.2:
1385
+
optionalDependencies:
1386
+
'@esbuild/aix-ppc64': 0.24.2
1387
+
'@esbuild/android-arm': 0.24.2
1388
+
'@esbuild/android-arm64': 0.24.2
1389
+
'@esbuild/android-x64': 0.24.2
1390
+
'@esbuild/darwin-arm64': 0.24.2
1391
+
'@esbuild/darwin-x64': 0.24.2
1392
+
'@esbuild/freebsd-arm64': 0.24.2
1393
+
'@esbuild/freebsd-x64': 0.24.2
1394
+
'@esbuild/linux-arm': 0.24.2
1395
+
'@esbuild/linux-arm64': 0.24.2
1396
+
'@esbuild/linux-ia32': 0.24.2
1397
+
'@esbuild/linux-loong64': 0.24.2
1398
+
'@esbuild/linux-mips64el': 0.24.2
1399
+
'@esbuild/linux-ppc64': 0.24.2
1400
+
'@esbuild/linux-riscv64': 0.24.2
1401
+
'@esbuild/linux-s390x': 0.24.2
1402
+
'@esbuild/linux-x64': 0.24.2
1403
+
'@esbuild/netbsd-arm64': 0.24.2
1404
+
'@esbuild/netbsd-x64': 0.24.2
1405
+
'@esbuild/openbsd-arm64': 0.24.2
1406
+
'@esbuild/openbsd-x64': 0.24.2
1407
+
'@esbuild/sunos-x64': 0.24.2
1408
+
'@esbuild/win32-arm64': 0.24.2
1409
+
'@esbuild/win32-ia32': 0.24.2
1410
+
'@esbuild/win32-x64': 0.24.2
1411
+
1412
+
escape-string-regexp@4.0.0: {}
1413
+
1414
+
esm-env@1.2.2: {}
1415
+
1416
+
esrap@1.4.3:
1417
+
dependencies:
1418
+
'@jridgewell/sourcemap-codec': 1.5.0
1419
+
1420
+
estree-walker@0.6.1: {}
1421
+
1422
+
exit-hook@2.2.1: {}
1423
+
1424
+
fdir@6.4.3: {}
1425
+
1426
+
fsevents@2.3.3:
1427
+
optional: true
1428
+
1429
+
get-source@2.0.12:
1430
+
dependencies:
1431
+
data-uri-to-buffer: 2.0.2
1432
+
source-map: 0.6.1
1433
+
1434
+
glob-to-regexp@0.4.1: {}
1435
+
1436
+
import-meta-resolve@4.1.0: {}
1437
+
1438
+
is-reference@3.0.3:
1439
+
dependencies:
1440
+
'@types/estree': 1.0.6
1441
+
1442
+
kleur@4.1.5: {}
1443
+
1444
+
locate-character@3.0.0: {}
1445
+
1446
+
magic-string@0.25.9:
1447
+
dependencies:
1448
+
sourcemap-codec: 1.4.8
1449
+
1450
+
magic-string@0.30.17:
1451
+
dependencies:
1452
+
'@jridgewell/sourcemap-codec': 1.5.0
1453
+
1454
+
mime@3.0.0: {}
1455
+
1456
+
miniflare@3.20250129.0:
1457
+
dependencies:
1458
+
'@cspotcode/source-map-support': 0.8.1
1459
+
acorn: 8.14.0
1460
+
acorn-walk: 8.3.4
1461
+
exit-hook: 2.2.1
1462
+
glob-to-regexp: 0.4.1
1463
+
stoppable: 1.1.0
1464
+
undici: 5.28.5
1465
+
workerd: 1.20250129.0
1466
+
ws: 8.18.0
1467
+
youch: 3.3.4
1468
+
zod: 3.24.1
1469
+
transitivePeerDependencies:
1470
+
- bufferutil
1471
+
- utf-8-validate
1472
+
1473
+
mlly@1.7.4:
1474
+
dependencies:
1475
+
acorn: 8.14.0
1476
+
pathe: 2.0.2
1477
+
pkg-types: 1.3.1
1478
+
ufo: 1.5.4
1479
+
1480
+
mri@1.2.0: {}
1481
+
1482
+
mrmime@2.0.0: {}
1483
+
1484
+
ms@2.1.3: {}
1485
+
1486
+
mustache@4.2.0: {}
1487
+
1488
+
nanoid@3.3.8: {}
1489
+
1490
+
ohash@1.1.4: {}
1491
+
1492
+
path-to-regexp@6.3.0: {}
1493
+
1494
+
pathe@1.1.2: {}
1495
+
1496
+
pathe@2.0.2: {}
1497
+
1498
+
picocolors@1.1.1: {}
1499
+
1500
+
pkg-types@1.3.1:
1501
+
dependencies:
1502
+
confbox: 0.1.8
1503
+
mlly: 1.7.4
1504
+
pathe: 2.0.2
1505
+
1506
+
postcss-less@6.0.0(postcss@8.5.1):
1507
+
dependencies:
1508
+
postcss: 8.5.1
1509
+
1510
+
postcss-scss@4.0.9(postcss@8.5.1):
1511
+
dependencies:
1512
+
postcss: 8.5.1
1513
+
1514
+
postcss@8.5.1:
1515
+
dependencies:
1516
+
nanoid: 3.3.8
1517
+
picocolors: 1.1.1
1518
+
source-map-js: 1.2.1
1519
+
1520
+
prettier-plugin-css-order@2.1.2(postcss@8.5.1)(prettier@3.4.2):
1521
+
dependencies:
1522
+
css-declaration-sorter: 7.2.0(postcss@8.5.1)
1523
+
postcss-less: 6.0.0(postcss@8.5.1)
1524
+
postcss-scss: 4.0.9(postcss@8.5.1)
1525
+
prettier: 3.4.2
1526
+
transitivePeerDependencies:
1527
+
- postcss
1528
+
1529
+
prettier-plugin-svelte@3.3.3(prettier@3.4.2)(svelte@5.19.8):
1530
+
dependencies:
1531
+
prettier: 3.4.2
1532
+
svelte: 5.19.8
1533
+
1534
+
prettier@3.4.2: {}
1535
+
1536
+
printable-characters@1.0.42: {}
1537
+
1538
+
readdirp@4.1.1: {}
1539
+
1540
+
regexparam@3.0.0: {}
1541
+
1542
+
rollup-plugin-inject@3.0.2:
1543
+
dependencies:
1544
+
estree-walker: 0.6.1
1545
+
magic-string: 0.25.9
1546
+
rollup-pluginutils: 2.8.2
1547
+
1548
+
rollup-plugin-node-polyfills@0.2.1:
1549
+
dependencies:
1550
+
rollup-plugin-inject: 3.0.2
1551
+
1552
+
rollup-pluginutils@2.8.2:
1553
+
dependencies:
1554
+
estree-walker: 0.6.1
1555
+
1556
+
rollup@4.34.4:
1557
+
dependencies:
1558
+
'@types/estree': 1.0.6
1559
+
optionalDependencies:
1560
+
'@rollup/rollup-android-arm-eabi': 4.34.4
1561
+
'@rollup/rollup-android-arm64': 4.34.4
1562
+
'@rollup/rollup-darwin-arm64': 4.34.4
1563
+
'@rollup/rollup-darwin-x64': 4.34.4
1564
+
'@rollup/rollup-freebsd-arm64': 4.34.4
1565
+
'@rollup/rollup-freebsd-x64': 4.34.4
1566
+
'@rollup/rollup-linux-arm-gnueabihf': 4.34.4
1567
+
'@rollup/rollup-linux-arm-musleabihf': 4.34.4
1568
+
'@rollup/rollup-linux-arm64-gnu': 4.34.4
1569
+
'@rollup/rollup-linux-arm64-musl': 4.34.4
1570
+
'@rollup/rollup-linux-loongarch64-gnu': 4.34.4
1571
+
'@rollup/rollup-linux-powerpc64le-gnu': 4.34.4
1572
+
'@rollup/rollup-linux-riscv64-gnu': 4.34.4
1573
+
'@rollup/rollup-linux-s390x-gnu': 4.34.4
1574
+
'@rollup/rollup-linux-x64-gnu': 4.34.4
1575
+
'@rollup/rollup-linux-x64-musl': 4.34.4
1576
+
'@rollup/rollup-win32-arm64-msvc': 4.34.4
1577
+
'@rollup/rollup-win32-ia32-msvc': 4.34.4
1578
+
'@rollup/rollup-win32-x64-msvc': 4.34.4
1579
+
fsevents: 2.3.3
1580
+
1581
+
sade@1.8.1:
1582
+
dependencies:
1583
+
mri: 1.2.0
1584
+
1585
+
set-cookie-parser@2.7.1: {}
1586
+
1587
+
sirv@3.0.0:
1588
+
dependencies:
1589
+
'@polka/url': 1.0.0-next.28
1590
+
mrmime: 2.0.0
1591
+
totalist: 3.0.1
1592
+
1593
+
source-map-js@1.2.1: {}
1594
+
1595
+
source-map@0.6.1: {}
1596
+
1597
+
sourcemap-codec@1.4.8: {}
1598
+
1599
+
stacktracey@2.1.8:
1600
+
dependencies:
1601
+
as-table: 1.0.55
1602
+
get-source: 2.0.12
1603
+
1604
+
stoppable@1.1.0: {}
1605
+
1606
+
svelte-check@4.1.4(svelte@5.19.8)(typescript@5.7.3):
1607
+
dependencies:
1608
+
'@jridgewell/trace-mapping': 0.3.25
1609
+
chokidar: 4.0.3
1610
+
fdir: 6.4.3
1611
+
picocolors: 1.1.1
1612
+
sade: 1.8.1
1613
+
svelte: 5.19.8
1614
+
typescript: 5.7.3
1615
+
transitivePeerDependencies:
1616
+
- picomatch
1617
+
1618
+
svelte@5.19.8:
1619
+
dependencies:
1620
+
'@ampproject/remapping': 2.3.0
1621
+
'@jridgewell/sourcemap-codec': 1.5.0
1622
+
'@types/estree': 1.0.6
1623
+
acorn: 8.14.0
1624
+
acorn-typescript: 1.4.13(acorn@8.14.0)
1625
+
aria-query: 5.3.2
1626
+
axobject-query: 4.1.0
1627
+
clsx: 2.1.1
1628
+
esm-env: 1.2.2
1629
+
esrap: 1.4.3
1630
+
is-reference: 3.0.3
1631
+
locate-character: 3.0.0
1632
+
magic-string: 0.30.17
1633
+
zimmerframe: 1.1.2
1634
+
1635
+
totalist@3.0.1: {}
1636
+
1637
+
typescript@5.7.3: {}
1638
+
1639
+
ufo@1.5.4: {}
1640
+
1641
+
undici@5.28.5:
1642
+
dependencies:
1643
+
'@fastify/busboy': 2.1.1
1644
+
1645
+
unenv@2.0.0-rc.1:
1646
+
dependencies:
1647
+
defu: 6.1.4
1648
+
mlly: 1.7.4
1649
+
ohash: 1.1.4
1650
+
pathe: 1.1.2
1651
+
ufo: 1.5.4
1652
+
1653
+
vite@6.1.0:
1654
+
dependencies:
1655
+
esbuild: 0.24.2
1656
+
postcss: 8.5.1
1657
+
rollup: 4.34.4
1658
+
optionalDependencies:
1659
+
fsevents: 2.3.3
1660
+
1661
+
vitefu@1.0.5(vite@6.1.0):
1662
+
optionalDependencies:
1663
+
vite: 6.1.0
1664
+
1665
+
workerd@1.20250129.0:
1666
+
optionalDependencies:
1667
+
'@cloudflare/workerd-darwin-64': 1.20250129.0
1668
+
'@cloudflare/workerd-darwin-arm64': 1.20250129.0
1669
+
'@cloudflare/workerd-linux-64': 1.20250129.0
1670
+
'@cloudflare/workerd-linux-arm64': 1.20250129.0
1671
+
'@cloudflare/workerd-windows-64': 1.20250129.0
1672
+
1673
+
worktop@0.8.0-next.18:
1674
+
dependencies:
1675
+
mrmime: 2.0.0
1676
+
regexparam: 3.0.0
1677
+
1678
+
wrangler@3.107.3(@cloudflare/workers-types@4.20250204.0):
1679
+
dependencies:
1680
+
'@cloudflare/kv-asset-handler': 0.3.4
1681
+
'@esbuild-plugins/node-globals-polyfill': 0.2.3(esbuild@0.17.19)
1682
+
'@esbuild-plugins/node-modules-polyfill': 0.2.2(esbuild@0.17.19)
1683
+
blake3-wasm: 2.1.5
1684
+
esbuild: 0.17.19
1685
+
miniflare: 3.20250129.0
1686
+
path-to-regexp: 6.3.0
1687
+
unenv: 2.0.0-rc.1
1688
+
workerd: 1.20250129.0
1689
+
optionalDependencies:
1690
+
'@cloudflare/workers-types': 4.20250204.0
1691
+
fsevents: 2.3.3
1692
+
transitivePeerDependencies:
1693
+
- bufferutil
1694
+
- utf-8-validate
1695
+
1696
+
ws@8.18.0: {}
1697
+
1698
+
youch@3.3.4:
1699
+
dependencies:
1700
+
cookie: 0.7.2
1701
+
mustache: 4.2.0
1702
+
stacktracey: 2.1.8
1703
+
1704
+
zimmerframe@1.1.2: {}
1705
+
1706
+
zod@3.24.1: {}
+15
src/app.d.ts
+15
src/app.d.ts
···
1
+
import '@atcute/bluesky/lexicons';
2
+
3
+
// See https://svelte.dev/docs/kit/types#app.d.ts
4
+
// for information about these interfaces
5
+
declare global {
6
+
namespace App {
7
+
// interface Error {}
8
+
// interface Locals {}
9
+
// interface PageData {}
10
+
// interface PageState {}
11
+
// interface Platform {}
12
+
}
13
+
}
14
+
15
+
export {};
+18
src/app.html
+18
src/app.html
···
1
+
<!doctype html>
2
+
<html lang="en">
3
+
<head>
4
+
<meta charset="utf-8" />
5
+
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
6
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
7
+
<style>
8
+
.sv-img-blurred {
9
+
scale: 125%;
10
+
filter: blur(4px);
11
+
}
12
+
</style>
13
+
%sveltekit.head%
14
+
</head>
15
+
<body data-sveltekit-preload-data="hover" data-sveltekit-reload>
16
+
%sveltekit.body%
17
+
</body>
18
+
</html>
+9
src/lib/components/central-icons/arrows-repeat-right-left-outlined.svelte
+9
src/lib/components/central-icons/arrows-repeat-right-left-outlined.svelte
+9
src/lib/components/central-icons/bubble-2-outlined.svelte
+9
src/lib/components/central-icons/bubble-2-outlined.svelte
+9
src/lib/components/central-icons/dot-grid-1x3-horizontal-outlined.svelte
+9
src/lib/components/central-icons/dot-grid-1x3-horizontal-outlined.svelte
···
1
+
<svg class="sv-icon" fill="none" viewBox="0 0 24 24">
2
+
<path
3
+
stroke="currentColor"
4
+
stroke-linecap="round"
5
+
stroke-linejoin="round"
6
+
stroke-width="2"
7
+
d="M12 13a1 1 0 1 0 0-2 1 1 0 0 0 0 2ZM20 13a1 1 0 1 0 0-2 1 1 0 0 0 0 2ZM4 13a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z"
8
+
/>
9
+
</svg>
+6
src/lib/components/central-icons/earth-outlined.svelte
+6
src/lib/components/central-icons/earth-outlined.svelte
···
1
+
<svg class="sv-icon" fill="none" viewBox="0 0 24 24">
2
+
<path
3
+
fill="currentColor"
4
+
d="M14.922 16.865v1a1 1 0 0 0 .894-.553l-.894-.447Zm.973-1.946.894.447a1 1 0 0 0-.337-1.277l-.557.83Zm-2.869-1.928.558-.83a1 1 0 0 0-.494-.168l-.064.998Zm-1.89-.12.064-.998a1 1 0 0 0-.77.29l.706.708Zm-1.08 1.075-.705-.708a1 1 0 0 0-.126 1.263l.832-.555Zm1.947 2.919-.832.555a1 1 0 0 0 .832.445v-1ZM4.772 7.27a1 1 0 1 0-1.2 1.6l1.2-1.6Zm3.34 3.757-.601.8a1 1 0 0 0 1.493-.35l-.893-.45Zm.977-1.941-.244-.97a1 1 0 0 0-.65.52l.894.45Zm3.887-.978.244.97a1 1 0 0 0 .726-.727l-.97-.243Zm2.12-4.357a1 1 0 0 0-1.94-.485l1.94.485ZM20 12a8 8 0 0 1-8 8v2c5.523 0 10-4.477 10-10h-2Zm-8 8a8 8 0 0 1-8-8H2c0 5.523 4.477 10 10 10v-2Zm-8-8a8 8 0 0 1 8-8V2C6.477 2 2 6.477 2 12h2Zm8-8a8 8 0 0 1 8 8h2c0-5.523-4.477-10-10-10v2Zm3.816 13.312.973-1.946L15 14.472l-.973 1.946 1.79.894Zm.636-3.223-2.868-1.928-1.115 1.66 2.868 1.928 1.116-1.66Zm-3.362-2.096-1.89-.12-.128 1.996 1.89.12.128-1.996Zm-2.66.17-1.079 1.075 1.412 1.416 1.079-1.075-1.412-1.417ZM9.225 14.5l1.946 2.919 1.664-1.11-1.946-2.919-1.664 1.11Zm2.778 3.364h2.919v-2h-2.92v2ZM3.57 8.87l3.94 2.957 1.2-1.6-3.94-2.957-1.2 1.6Zm5.433 2.607.978-1.941-1.786-.9-.978 1.941 1.786.9Zm.329-1.421 3.887-.978-.488-1.94-3.887.978.488 1.94Zm4.613-1.705 1.15-4.6-1.94-.485-1.15 4.6 1.94.485Z"
5
+
/>
6
+
</svg>
+8
src/lib/components/central-icons/heart-outlined.svelte
+8
src/lib/components/central-icons/heart-outlined.svelte
+9
src/lib/components/central-icons/pin-outlined.svelte
+9
src/lib/components/central-icons/pin-outlined.svelte
+6
src/lib/components/central-icons/play-solid.svelte
+6
src/lib/components/central-icons/play-solid.svelte
+118
src/lib/components/embeds/embeds.svelte
+118
src/lib/components/embeds/embeds.svelte
···
1
+
<script lang="ts" module>
2
+
const collectionToLabel = (collection: string): string | null => {
3
+
switch (collection) {
4
+
case 'app.bsky.feed.post':
5
+
return 'post';
6
+
case 'app.bsky.feed.generator':
7
+
return 'feed';
8
+
case 'app.bsky.graph.list':
9
+
return 'list';
10
+
case 'app.bsky.graph.starterpack':
11
+
return 'starter pack';
12
+
case 'app.bsky.labeler.service':
13
+
return 'labeler';
14
+
}
15
+
16
+
return null;
17
+
};
18
+
</script>
19
+
20
+
<script lang="ts">
21
+
import type {
22
+
AppBskyEmbedExternal,
23
+
AppBskyEmbedImages,
24
+
AppBskyEmbedRecord,
25
+
AppBskyEmbedVideo,
26
+
AppBskyFeedDefs,
27
+
Brand,
28
+
} from '@atcute/client/lexicons';
29
+
30
+
import { parseAtUri } from '$lib/types/at-uri';
31
+
32
+
import ExternalEmbed from './external-embed.svelte';
33
+
import FeedEmbed from './feed-embed.svelte';
34
+
import ImageEmbed from './image-embed.svelte';
35
+
import ListEmbed from './list-embed.svelte';
36
+
import QuoteEmbed from './quote-embed.svelte';
37
+
import StarterpackEmbed from './starterpack-embed.svelte';
38
+
import VideoEmbed from './video-embed.svelte';
39
+
40
+
type Embed = NonNullable<AppBskyFeedDefs.PostView['embed']>;
41
+
type MediaEmbed = Brand.Union<AppBskyEmbedExternal.View | AppBskyEmbedImages.View | AppBskyEmbedVideo.View>;
42
+
type RecordEmbed = AppBskyEmbedRecord.View;
43
+
44
+
interface Props {
45
+
embed: Embed;
46
+
large?: boolean;
47
+
}
48
+
49
+
const { embed, large = false }: Props = $props();
50
+
</script>
51
+
52
+
<div class="embeds">
53
+
{#if embed.$type === 'app.bsky.embed.recordWithMedia#view'}
54
+
{@render Media(embed.media)}
55
+
{@render Record(embed.record)}
56
+
{:else if embed.$type === 'app.bsky.embed.record#view'}
57
+
{@render Record(embed)}
58
+
{:else}
59
+
{@render Media(embed)}
60
+
{/if}
61
+
</div>
62
+
63
+
{#snippet Media(embed: MediaEmbed)}
64
+
{#if embed.$type === 'app.bsky.embed.external#view'}
65
+
<ExternalEmbed {embed} />
66
+
{:else if embed.$type === 'app.bsky.embed.images#view'}
67
+
<ImageEmbed {embed} standalone />
68
+
{:else if embed.$type === 'app.bsky.embed.video#view'}
69
+
<VideoEmbed {embed} standalone />
70
+
{:else}
71
+
{@render Message(`Unsupported media embed`)}
72
+
{/if}
73
+
{/snippet}
74
+
75
+
{#snippet Record(embed: RecordEmbed)}
76
+
{@const record = embed.record}
77
+
78
+
{#if record.$type === 'app.bsky.embed.record#viewRecord'}
79
+
<QuoteEmbed embed={record} {large} />
80
+
{:else if record.$type === 'app.bsky.feed.defs#generatorView'}
81
+
<FeedEmbed embed={record} />
82
+
{:else if record.$type === 'app.bsky.graph.defs#listView'}
83
+
<ListEmbed embed={record} />
84
+
{:else if record.$type === 'app.bsky.graph.defs#starterPackViewBasic'}
85
+
<StarterpackEmbed embed={record} {large} />
86
+
{:else}
87
+
{@const uri = parseAtUri(record.uri)}
88
+
{@const resource = collectionToLabel(uri.collection)}
89
+
90
+
{@const isUnavailable =
91
+
resource &&
92
+
(record.$type === 'app.bsky.embed.record#viewNotFound' ||
93
+
record.$type === 'app.bsky.embed.record#viewBlocked' ||
94
+
record.$type === 'app.bsky.embed.record#viewDetached')}
95
+
96
+
{@render Message(isUnavailable ? `This ${resource} is unavailable` : `Unsupported record embed`)}
97
+
{/if}
98
+
{/snippet}
99
+
100
+
{#snippet Message(message: string)}
101
+
<div class="message">{message}</div>
102
+
{/snippet}
103
+
104
+
<style>
105
+
.embeds {
106
+
display: flex;
107
+
flex-direction: column;
108
+
gap: 12px;
109
+
margin: 12px 0 0 0;
110
+
}
111
+
112
+
.message {
113
+
border: 1px solid var(--divider-sm);
114
+
border-radius: 6px;
115
+
padding: 12px;
116
+
color: var(--text-blurb);
117
+
}
118
+
</style>
+121
src/lib/components/embeds/external-embed.svelte
+121
src/lib/components/embeds/external-embed.svelte
···
1
+
<script lang="ts" module>
2
+
const safeParseUrl = (str: string): URL | null => {
3
+
let url: URL | null | undefined;
4
+
if ('parse' in URL) {
5
+
url = URL.parse(str);
6
+
} else {
7
+
try {
8
+
// @ts-expect-error: `'parse' in URL` is giving truthy
9
+
url = new URL(str);
10
+
} catch {}
11
+
}
12
+
13
+
if (url && (url.protocol === 'https:' || url.protocol === 'http:')) {
14
+
return url;
15
+
}
16
+
17
+
return null;
18
+
};
19
+
</script>
20
+
21
+
<script lang="ts">
22
+
import type { AppBskyEmbedExternal } from '@atcute/client/lexicons';
23
+
24
+
import EarthOutlined from '$lib/components/central-icons/earth-outlined.svelte';
25
+
26
+
interface Props {
27
+
embed: AppBskyEmbedExternal.View;
28
+
}
29
+
30
+
const { embed }: Props = $props();
31
+
32
+
const external = embed.external;
33
+
34
+
const domain = safeParseUrl(external.uri)?.host;
35
+
</script>
36
+
37
+
<a target="_blank" href={domain && external.uri} rel="noopener noreferrer nofollow" class="external-embed">
38
+
{#if external.thumb}
39
+
<img loading="lazy" src={external.thumb} alt="" class="thumbnail" />
40
+
{/if}
41
+
42
+
<div class="meta">
43
+
<p class="title">{external.title}</p>
44
+
<p class="description">{external.description}</p>
45
+
46
+
{#if domain}
47
+
<div class="domain">
48
+
<EarthOutlined />
49
+
50
+
<span class="domain-name">{domain}</span>
51
+
</div>
52
+
{/if}
53
+
</div>
54
+
</a>
55
+
56
+
<style>
57
+
.external-embed {
58
+
display: block;
59
+
border: 1px solid var(--divider-md);
60
+
border-radius: 6px;
61
+
overflow: hidden;
62
+
color: var(--text-primary);
63
+
64
+
&:hover {
65
+
background: var(--tap-sm);
66
+
}
67
+
}
68
+
69
+
.thumbnail {
70
+
display: block;
71
+
border-bottom: 1px solid var(--divider-md);
72
+
background: var(--bg-primary);
73
+
aspect-ratio: 1.91;
74
+
width: 100%;
75
+
object-fit: cover;
76
+
}
77
+
78
+
.meta {
79
+
padding: 12px;
80
+
}
81
+
82
+
.title {
83
+
display: -webkit-box;
84
+
overflow: hidden;
85
+
font-weight: 700;
86
+
white-space: pre-wrap;
87
+
-webkit-box-orient: vertical;
88
+
-webkit-line-clamp: 2;
89
+
line-clamp: 2;
90
+
overflow-wrap: break-word;
91
+
92
+
&:empty {
93
+
display: none;
94
+
}
95
+
}
96
+
.description {
97
+
display: -webkit-box;
98
+
overflow: hidden;
99
+
color: var(--text-blurb);
100
+
font-size: 0.8125rem;
101
+
white-space: pre-wrap;
102
+
-webkit-box-orient: vertical;
103
+
-webkit-line-clamp: 2;
104
+
line-clamp: 2;
105
+
overflow-wrap: break-word;
106
+
107
+
&:empty {
108
+
display: none;
109
+
}
110
+
}
111
+
112
+
.domain {
113
+
display: flex;
114
+
align-items: center;
115
+
gap: 6px;
116
+
margin: 6px 0 0 0;
117
+
color: var(--text-blurb);
118
+
font-weight: 500;
119
+
font-size: 0.75rem;
120
+
}
121
+
</style>
+101
src/lib/components/embeds/feed-embed.svelte
+101
src/lib/components/embeds/feed-embed.svelte
···
1
+
<script lang="ts">
2
+
import type { AppBskyFeedDefs } from '@atcute/client/lexicons';
3
+
4
+
import { base } from '$app/paths';
5
+
6
+
import { parseAtUri } from '$lib/types/at-uri';
7
+
8
+
interface Props {
9
+
embed: AppBskyFeedDefs.GeneratorView;
10
+
}
11
+
12
+
const { embed: feed }: Props = $props();
13
+
14
+
const creator = $derived(feed.creator);
15
+
</script>
16
+
17
+
<a href="{base}/{creator.did}/feeds/{parseAtUri(feed.uri).rkey}" class="feed-embed">
18
+
<div class="main">
19
+
<div class="avatar-wrapper">
20
+
{#if feed.avatar}
21
+
<img loading="lazy" src={feed.avatar} alt="" class="avatar" />
22
+
{:else}
23
+
<svg viewBox="0 0 32 32" class="avatar">
24
+
<path fill="#0070FF" d="M0 0h32v32H0z" />
25
+
<path
26
+
fill="#fff"
27
+
d="M22.153 22.354a9.328 9.328 0 0 0 3.837-.491 3.076 3.076 0 0 0-4.802-2.79m.965 3.281a6.128 6.128 0 0 0-.965-3.28Zm-11.342-3.28a3.077 3.077 0 0 0-4.801 2.79 9.21 9.21 0 0 0 3.835.49m.966-3.28a6.127 6.127 0 0 0-.966 3.28Zm8.265-8.997a3.076 3.076 0 1 1-6.153 0 3.076 3.076 0 0 1 6.153 0Zm6.154 3.077a2.307 2.307 0 1 1-4.615 0 2.307 2.307 0 0 1 4.615 0Zm-13.847 0a2.307 2.307 0 1 1-4.614 0 2.307 2.307 0 0 1 4.614 0Z"
28
+
/>
29
+
<path fill="#fff" d="M22 22c0 3.314-2.686 3.5-6 3.5s-6-.186-6-3.5a6 6 0 0 1 12 0Z" />
30
+
</svg>
31
+
{/if}
32
+
</div>
33
+
34
+
<div class="info">
35
+
<p class="name">{feed.displayName}</p>
36
+
<p class="creator">Feed by @{creator.handle}</p>
37
+
</div>
38
+
</div>
39
+
40
+
<p class="description">{feed.description}</p>
41
+
</a>
42
+
43
+
<style>
44
+
.feed-embed {
45
+
display: flex;
46
+
flex-direction: column;
47
+
gap: 12px;
48
+
border: 1px solid var(--divider-md);
49
+
border-radius: 6px;
50
+
padding: 12px;
51
+
color: var(--text-primary);
52
+
53
+
&:hover {
54
+
background: var(--tap-sm);
55
+
}
56
+
}
57
+
58
+
.main {
59
+
display: flex;
60
+
gap: 12px;
61
+
}
62
+
63
+
.avatar-wrapper {
64
+
margin: 2px 0 0 0;
65
+
border-radius: 6px;
66
+
background: var(--bg-secondary);
67
+
width: 36px;
68
+
height: 36px;
69
+
overflow: hidden;
70
+
}
71
+
.avatar {
72
+
width: 100%;
73
+
height: 100%;
74
+
object-fit: cover;
75
+
font-size: 0;
76
+
}
77
+
78
+
.name {
79
+
font-weight: 700;
80
+
}
81
+
82
+
.creator {
83
+
color: var(--text-blurb);
84
+
font-size: 0.8125rem;
85
+
}
86
+
87
+
.description {
88
+
display: -webkit-box;
89
+
overflow: hidden;
90
+
font-size: 0.8125rem;
91
+
white-space: pre-wrap;
92
+
-webkit-box-orient: vertical;
93
+
-webkit-line-clamp: 2;
94
+
line-clamp: 2;
95
+
overflow-wrap: break-word;
96
+
97
+
&:empty {
98
+
display: none;
99
+
}
100
+
}
101
+
</style>
+178
src/lib/components/embeds/image-embed.svelte
+178
src/lib/components/embeds/image-embed.svelte
···
1
+
<script lang="ts" module>
2
+
const DEFAULT_RATIO = { width: 16, height: 9 };
3
+
</script>
4
+
5
+
<script lang="ts">
6
+
import type { AppBskyEmbedImages } from '@atcute/client/lexicons';
7
+
8
+
interface Props {
9
+
embed: AppBskyEmbedImages.View;
10
+
borderless?: boolean;
11
+
standalone?: boolean;
12
+
blur?: boolean;
13
+
}
14
+
15
+
const { embed, borderless, standalone, blur }: Props = $props();
16
+
17
+
const images = $derived(embed.images);
18
+
const length = $derived(images.length);
19
+
</script>
20
+
21
+
<div class={['image-embed', !borderless && 'is-bordered', standalone && length === 1 && 'is-aligned']}>
22
+
{#if length === 4}
23
+
<div class="grid">
24
+
<div class="col">
25
+
<div class="item wide tl">
26
+
{@render Image(0)}
27
+
</div>
28
+
<div class="item wide bl">
29
+
{@render Image(2)}
30
+
</div>
31
+
</div>
32
+
<div class="col">
33
+
<div class="item wide tr">
34
+
{@render Image(1)}
35
+
</div>
36
+
<div class="item wide br">
37
+
{@render Image(3)}
38
+
</div>
39
+
</div>
40
+
</div>
41
+
{:else if length === 3}
42
+
<div class="grid">
43
+
<div class="col square">
44
+
<div class="item tl bl">
45
+
{@render Image(0)}
46
+
</div>
47
+
</div>
48
+
<div class="col square">
49
+
<div class="item tr">
50
+
{@render Image(1)}
51
+
</div>
52
+
<div class="item br">
53
+
{@render Image(2)}
54
+
</div>
55
+
</div>
56
+
</div>
57
+
{:else if length === 2}
58
+
<div class="grid">
59
+
<div class="col">
60
+
<div class="item square tl bl">
61
+
{@render Image(0)}
62
+
</div>
63
+
</div>
64
+
<div class="col">
65
+
<div class="item square tr br">
66
+
{@render Image(1)}
67
+
</div>
68
+
</div>
69
+
</div>
70
+
{:else if length === 1}
71
+
{@const ratio = standalone && (images[0].aspectRatio || DEFAULT_RATIO)}
72
+
73
+
<div
74
+
class={['single-item tl tr bl br', ratio && 'is-standalone', ratio === DEFAULT_RATIO && 'is-defaulted']}
75
+
style={ratio ? `aspect-ratio: ${ratio.width}/${ratio.height}` : ``}
76
+
>
77
+
{@render Image(0)}
78
+
79
+
{#if ratio}
80
+
<div class="placeholder"></div>
81
+
{/if}
82
+
</div>
83
+
{/if}
84
+
</div>
85
+
86
+
{#snippet Image(index: number)}
87
+
{@const image = images[index]}
88
+
89
+
<img loading="lazy" src={image.thumb} alt={image.alt} class={`image` + (blur ? ` is-blurred` : ``)} />
90
+
{/snippet}
91
+
92
+
<style>
93
+
.is-aligned {
94
+
align-self: baseline;
95
+
max-width: 100%;
96
+
}
97
+
98
+
.grid {
99
+
display: flex;
100
+
gap: 2px;
101
+
}
102
+
.col {
103
+
display: flex;
104
+
flex: 1;
105
+
flex-direction: column;
106
+
gap: 2px;
107
+
}
108
+
109
+
.square {
110
+
aspect-ratio: 1;
111
+
}
112
+
.wide {
113
+
aspect-ratio: 1.5;
114
+
}
115
+
116
+
.item {
117
+
position: relative;
118
+
flex-grow: 1;
119
+
flex-shrink: 0;
120
+
overflow: hidden;
121
+
}
122
+
123
+
.is-bordered {
124
+
.tl,
125
+
.tr,
126
+
.bl,
127
+
.br {
128
+
border: 1px solid var(--divider-md);
129
+
}
130
+
131
+
.tl {
132
+
border-top-left-radius: 6px;
133
+
}
134
+
.tr {
135
+
border-top-right-radius: 6px;
136
+
}
137
+
.bl {
138
+
border-bottom-left-radius: 6px;
139
+
}
140
+
.br {
141
+
border-bottom-right-radius: 6px;
142
+
}
143
+
}
144
+
145
+
.single-item {
146
+
position: relative;
147
+
aspect-ratio: 16 / 9;
148
+
overflow: hidden;
149
+
}
150
+
.is-standalone {
151
+
min-width: 64px;
152
+
max-width: 100%;
153
+
min-height: 64px;
154
+
max-height: 320px;
155
+
}
156
+
157
+
.image {
158
+
position: absolute;
159
+
inset: 0;
160
+
background: var(--bg-secondary);
161
+
width: 100%;
162
+
height: 100%;
163
+
object-fit: cover;
164
+
font-size: 0px;
165
+
}
166
+
.is-defaulted .image {
167
+
object-fit: contain;
168
+
}
169
+
.is-blurred {
170
+
scale: 125%;
171
+
filter: blur(24px);
172
+
}
173
+
174
+
.placeholder {
175
+
width: 100vw;
176
+
height: 100vh;
177
+
}
178
+
</style>
+114
src/lib/components/embeds/list-embed.svelte
+114
src/lib/components/embeds/list-embed.svelte
···
1
+
<script lang="ts" module>
2
+
const getPurpose = (purpose: AppBskyGraphDefs.ListPurpose) => {
3
+
switch (purpose) {
4
+
case 'app.bsky.graph.defs#curatelist':
5
+
return `User list`;
6
+
case 'app.bsky.graph.defs#modlist':
7
+
return `Moderation list`;
8
+
}
9
+
10
+
return `Unknown list`;
11
+
};
12
+
</script>
13
+
14
+
<script lang="ts">
15
+
import type { AppBskyGraphDefs } from '@atcute/client/lexicons';
16
+
17
+
import { base } from '$app/paths';
18
+
19
+
import { parseAtUri } from '$lib/types/at-uri';
20
+
21
+
interface Props {
22
+
embed: AppBskyGraphDefs.ListView;
23
+
}
24
+
25
+
const { embed: list }: Props = $props();
26
+
27
+
const creator = $derived(list.creator);
28
+
</script>
29
+
30
+
<a href="{base}/{creator.did}/lists/{parseAtUri(list.uri).rkey}" class="list-embed">
31
+
<div class="main">
32
+
<div class="avatar-wrapper">
33
+
{#if list.avatar}
34
+
<img loading="lazy" src={list.avatar} alt="" class="avatar" />
35
+
{:else}
36
+
<svg viewBox="0 0 32 32" class="avatar">
37
+
<path fill="#0070FF" d="M0 0h32v32H0z" />
38
+
<path
39
+
fill="#fff"
40
+
d="M22.153 22.354a9.328 9.328 0 0 0 3.837-.491 3.076 3.076 0 0 0-4.802-2.79m.965 3.281a6.128 6.128 0 0 0-.965-3.28Zm-11.342-3.28a3.077 3.077 0 0 0-4.801 2.79 9.21 9.21 0 0 0 3.835.49m.966-3.28a6.127 6.127 0 0 0-.966 3.28Zm8.265-8.997a3.076 3.076 0 1 1-6.153 0 3.076 3.076 0 0 1 6.153 0Zm6.154 3.077a2.307 2.307 0 1 1-4.615 0 2.307 2.307 0 0 1 4.615 0Zm-13.847 0a2.307 2.307 0 1 1-4.614 0 2.307 2.307 0 0 1 4.614 0Z"
41
+
/>
42
+
<path fill="#fff" d="M22 22c0 3.314-2.686 3.5-6 3.5s-6-.186-6-3.5a6 6 0 0 1 12 0Z" />
43
+
</svg>
44
+
{/if}
45
+
</div>
46
+
47
+
<div class="info">
48
+
<p class="name">{list.name}</p>
49
+
<p class="creator">{getPurpose(list.purpose)} by @{creator.handle}</p>
50
+
</div>
51
+
</div>
52
+
53
+
<p class="description">{list.description}</p>
54
+
</a>
55
+
56
+
<style>
57
+
.list-embed {
58
+
display: flex;
59
+
flex-direction: column;
60
+
gap: 12px;
61
+
border: 1px solid var(--divider-md);
62
+
border-radius: 6px;
63
+
padding: 12px;
64
+
color: var(--text-primary);
65
+
66
+
&:hover {
67
+
background: var(--tap-sm);
68
+
}
69
+
}
70
+
71
+
.main {
72
+
display: flex;
73
+
gap: 12px;
74
+
}
75
+
76
+
.avatar-wrapper {
77
+
margin: 2px 0 0 0;
78
+
border-radius: 6px;
79
+
background: var(--bg-secondary);
80
+
width: 36px;
81
+
height: 36px;
82
+
overflow: hidden;
83
+
}
84
+
.avatar {
85
+
width: 100%;
86
+
height: 100%;
87
+
object-fit: cover;
88
+
font-size: 0;
89
+
}
90
+
91
+
.name {
92
+
font-weight: 700;
93
+
}
94
+
95
+
.creator {
96
+
color: var(--text-blurb);
97
+
font-size: 0.8125rem;
98
+
}
99
+
100
+
.description {
101
+
display: -webkit-box;
102
+
overflow: hidden;
103
+
font-size: 0.8125rem;
104
+
white-space: pre-wrap;
105
+
-webkit-box-orient: vertical;
106
+
-webkit-line-clamp: 2;
107
+
line-clamp: 2;
108
+
overflow-wrap: break-word;
109
+
110
+
&:empty {
111
+
display: none;
112
+
}
113
+
}
114
+
</style>
+212
src/lib/components/embeds/quote-embed.svelte
+212
src/lib/components/embeds/quote-embed.svelte
···
1
+
<script lang="ts" module>
2
+
const getPostImage = (embed: AppBskyFeedDefs.PostView['embed']): AppBskyEmbedImages.View | undefined => {
3
+
if (embed) {
4
+
if (embed.$type === 'app.bsky.embed.images#view') {
5
+
return embed;
6
+
}
7
+
8
+
if (embed.$type === 'app.bsky.embed.recordWithMedia#view') {
9
+
return getPostImage(embed.media);
10
+
}
11
+
}
12
+
};
13
+
14
+
const getPostVideo = (embed: AppBskyFeedDefs.PostView['embed']): AppBskyEmbedVideo.View | undefined => {
15
+
if (embed) {
16
+
if (embed.$type === 'app.bsky.embed.video#view') {
17
+
return embed;
18
+
}
19
+
20
+
if (embed.$type === 'app.bsky.embed.recordWithMedia#view') {
21
+
return getPostVideo(embed.media);
22
+
}
23
+
}
24
+
};
25
+
</script>
26
+
27
+
<script lang="ts">
28
+
import type {
29
+
AppBskyEmbedImages,
30
+
AppBskyEmbedRecord,
31
+
AppBskyEmbedVideo,
32
+
AppBskyFeedDefs,
33
+
AppBskyFeedPost,
34
+
} from '@atcute/client/lexicons';
35
+
36
+
import { base } from '$app/paths';
37
+
38
+
import { parseAtUri } from '$lib/types/at-uri';
39
+
import { formatRelativeTime } from '$lib/utils/intl/date';
40
+
41
+
import ImageEmbed from './image-embed.svelte';
42
+
import VideoEmbed from './video-embed.svelte';
43
+
44
+
interface Props {
45
+
embed: AppBskyEmbedRecord.ViewRecord;
46
+
large?: boolean;
47
+
}
48
+
49
+
const { embed: quote, large = false }: Props = $props();
50
+
51
+
const record = $derived(quote.value as AppBskyFeedPost.Record);
52
+
const text = $derived(record.text.trim());
53
+
54
+
const author = $derived(quote.author);
55
+
const authorName = $derived(author.displayName?.trim());
56
+
57
+
const embed = $derived(quote.embeds?.[0]);
58
+
const image = $derived(getPostImage(embed));
59
+
const video = $derived(getPostVideo(embed));
60
+
</script>
61
+
62
+
<a href="{base}/{author.did}/{parseAtUri(quote.uri).rkey}#main" class="quote-embed">
63
+
<div class="meta">
64
+
<div class="avatar-wrapper">
65
+
{#if author.avatar}
66
+
<img loading="lazy" src={author.avatar} alt="" class="avatar" />
67
+
{/if}
68
+
</div>
69
+
70
+
<span class="name-wrapper">
71
+
{#if authorName}
72
+
<bdi class="display-name-wrapper">
73
+
<span class="display-name">{authorName}</span>
74
+
</bdi>
75
+
{/if}
76
+
77
+
<span class="handle">@{author.handle}</span>
78
+
</span>
79
+
80
+
<span aria-hidden="true" class="dot">·</span>
81
+
82
+
<time datetime={record.createdAt} class="date">
83
+
{formatRelativeTime(record.createdAt)}
84
+
</time>
85
+
</div>
86
+
87
+
{#if text}
88
+
<div class="body">
89
+
{#if !large}
90
+
{#if image}
91
+
<div class="aside">
92
+
<ImageEmbed embed={image} blur={false} />
93
+
</div>
94
+
{:else if video}
95
+
<div class="aside">
96
+
<VideoEmbed embed={video} blur={false} />
97
+
</div>
98
+
{/if}
99
+
{/if}
100
+
101
+
<p class="text">{text}</p>
102
+
</div>
103
+
{:else}
104
+
<div class="divide"></div>
105
+
{/if}
106
+
107
+
{#if large || !text}
108
+
{#if image}
109
+
<ImageEmbed embed={image} borderless blur={false} />
110
+
{:else if video}
111
+
<VideoEmbed embed={video} borderless blur={false} />
112
+
{/if}
113
+
{/if}
114
+
</a>
115
+
116
+
<style>
117
+
.quote-embed {
118
+
display: block;
119
+
border: 1px solid var(--divider-md);
120
+
border-radius: 6px;
121
+
overflow: hidden;
122
+
color: var(--text-primary);
123
+
124
+
&:hover {
125
+
background: var(--tap-sm);
126
+
}
127
+
}
128
+
129
+
.meta {
130
+
display: flex;
131
+
padding: 12px 12px 0 12px;
132
+
color: var(--text-blurb);
133
+
134
+
.avatar-wrapper {
135
+
flex-shrink: 0;
136
+
margin: 0 8px 0 0;
137
+
border-radius: 9999px;
138
+
background: var(--bg-secondary);
139
+
width: 20px;
140
+
height: 20px;
141
+
overflow: hidden;
142
+
}
143
+
.avatar {
144
+
width: 100%;
145
+
height: 100%;
146
+
object-fit: cover;
147
+
font-size: 0;
148
+
}
149
+
150
+
.name-wrapper {
151
+
display: flex;
152
+
gap: 4px;
153
+
max-width: 100%;
154
+
overflow: hidden;
155
+
text-overflow: ellipsis;
156
+
white-space: nowrap;
157
+
}
158
+
.display-name-wrapper {
159
+
overflow: hidden;
160
+
text-overflow: ellipsis;
161
+
}
162
+
.display-name {
163
+
color: var(--text-primary);
164
+
font-weight: 700;
165
+
}
166
+
.handle {
167
+
display: block;
168
+
overflow: hidden;
169
+
text-overflow: ellipsis;
170
+
white-space: nowrap;
171
+
}
172
+
173
+
.dot {
174
+
flex-shrink: 0;
175
+
margin: 0 6px;
176
+
}
177
+
178
+
.date {
179
+
white-space: nowrap;
180
+
}
181
+
}
182
+
183
+
.body {
184
+
display: flex;
185
+
align-items: flex-start;
186
+
}
187
+
188
+
.aside {
189
+
flex-grow: 1;
190
+
flex-basis: 0;
191
+
margin: 8px 0 12px 12px;
192
+
max-width: 20%;
193
+
}
194
+
195
+
.text {
196
+
display: -webkit-box;
197
+
margin: 8px 12px 12px 12px;
198
+
overflow: hidden;
199
+
-webkit-box-orient: vertical;
200
+
-webkit-line-clamp: 6;
201
+
line-clamp: 6;
202
+
flex-grow: 4;
203
+
flex-basis: 0px;
204
+
min-width: 0px;
205
+
white-space: pre-wrap;
206
+
overflow-wrap: break-word;
207
+
}
208
+
209
+
.divide {
210
+
padding: 6px 0;
211
+
}
212
+
</style>
+125
src/lib/components/embeds/starterpack-embed.svelte
+125
src/lib/components/embeds/starterpack-embed.svelte
···
1
+
<script lang="ts">
2
+
import type { AppBskyGraphDefs, AppBskyGraphStarterpack } from '@atcute/client/lexicons';
3
+
4
+
import { base } from '$app/paths';
5
+
6
+
import { parseAtUri } from '$lib/types/at-uri';
7
+
8
+
interface Props {
9
+
embed: AppBskyGraphDefs.StarterPackViewBasic;
10
+
large?: boolean;
11
+
}
12
+
13
+
const { embed: pack, large = false }: Props = $props();
14
+
15
+
const record = pack.record as AppBskyGraphStarterpack.Record;
16
+
17
+
const creator = $derived(pack.creator);
18
+
19
+
const rkey = $derived(parseAtUri(pack.uri).rkey);
20
+
</script>
21
+
22
+
<a href="{base}/{creator.did}/packs/{rkey}" class="starterpack-embed">
23
+
{#if large}
24
+
<img
25
+
loading="lazy"
26
+
src="https://ogcard.cdn.bsky.app/start/${creator.did}/${rkey}"
27
+
alt=""
28
+
class="banner"
29
+
/>
30
+
{/if}
31
+
32
+
<div class="meta">
33
+
<div class="main">
34
+
<svg fill="none" viewBox="0 0 24 24" class="avatar">
35
+
<defs>
36
+
<linearGradient id="a" x1="0" x2="100%" y1="0" y2="0" gradientTransform="rotate(45)">
37
+
<stop offset="0" stop-color="#0A7AFF" />
38
+
<stop offset="1" stop-color="#59B9FF" />
39
+
</linearGradient>
40
+
</defs>
41
+
<path
42
+
fill="url(#a)"
43
+
fill-rule="evenodd"
44
+
d="M11.26 5.227 5.02 6.899c-.734.197-1.17.95-.973 1.685l1.672 6.24c.197.734.951 1.17 1.685.973l6.24-1.672a1.376 1.376 0 0 0 .973-1.685L12.945 6.2a1.375 1.375 0 0 0-1.685-.973Zm-6.566.459a2.632 2.632 0 0 0-1.86 3.223l1.672 6.24a2.632 2.632 0 0 0 3.223 1.861l6.24-1.672a2.631 2.631 0 0 0 1.861-3.223l-1.672-6.24a2.632 2.632 0 0 0-3.223-1.861l-6.24 1.672Z"
45
+
clip-rule="evenodd"
46
+
/>
47
+
<path
48
+
fill="url(#a)"
49
+
fill-rule="evenodd"
50
+
d="M15.138 18.411a4.606 4.606 0 1 0 0-9.211 4.606 4.606 0 0 0 0 9.211Zm0 1.257a5.862 5.862 0 1 0 0-11.724 5.862 5.862 0 0 0 0 11.724Z"
51
+
clip-rule="evenodd"
52
+
/>
53
+
</svg>
54
+
55
+
<div class="info">
56
+
<p class="name">{record.name}</p>
57
+
<p class="creator">Starter pack by @{creator.handle}</p>
58
+
</div>
59
+
</div>
60
+
61
+
<p class="description">{record.description}</p>
62
+
</div>
63
+
</a>
64
+
65
+
<style>
66
+
.starterpack-embed {
67
+
display: block;
68
+
border: 1px solid var(--divider-md);
69
+
border-radius: 6px;
70
+
overflow: hidden;
71
+
color: var(--text-primary);
72
+
73
+
&:hover {
74
+
background: var(--tap-sm);
75
+
}
76
+
}
77
+
78
+
.banner {
79
+
display: block;
80
+
aspect-ratio: 1.91;
81
+
width: 100%;
82
+
}
83
+
84
+
.meta {
85
+
display: flex;
86
+
flex-direction: column;
87
+
gap: 12px;
88
+
padding: 12px;
89
+
}
90
+
91
+
.main {
92
+
display: flex;
93
+
gap: 12px;
94
+
}
95
+
96
+
.avatar {
97
+
margin: 2px;
98
+
width: 36px;
99
+
height: 36px;
100
+
}
101
+
102
+
.name {
103
+
font-weight: 700;
104
+
}
105
+
106
+
.creator {
107
+
color: var(--text-blurb);
108
+
font-size: 0.8125rem;
109
+
}
110
+
111
+
.description {
112
+
display: -webkit-box;
113
+
overflow: hidden;
114
+
font-size: 0.8125rem;
115
+
white-space: pre-wrap;
116
+
-webkit-box-orient: vertical;
117
+
-webkit-line-clamp: 2;
118
+
line-clamp: 2;
119
+
overflow-wrap: break-word;
120
+
121
+
&:empty {
122
+
display: none;
123
+
}
124
+
}
125
+
</style>
+108
src/lib/components/embeds/video-embed.svelte
+108
src/lib/components/embeds/video-embed.svelte
···
1
+
<script lang="ts">
2
+
import type { AppBskyEmbedVideo } from '@atcute/client/lexicons';
3
+
4
+
import PlaySolid from '$lib/components/central-icons/play-solid.svelte';
5
+
6
+
interface Props {
7
+
embed: AppBskyEmbedVideo.View;
8
+
borderless?: boolean;
9
+
standalone?: boolean;
10
+
blur?: boolean;
11
+
}
12
+
13
+
const { embed: video, borderless, standalone, blur }: Props = $props();
14
+
15
+
const ratio = standalone && video.aspectRatio;
16
+
</script>
17
+
18
+
{#if standalone}
19
+
<div class={['video-embed', !borderless && 'is-bordered', standalone && 'is-standalone']}>
20
+
<div class="constrainer" style={ratio ? `aspect-ratio: ${ratio.width}/${ratio.height}` : ``}>
21
+
{@render Content()}
22
+
</div>
23
+
</div>
24
+
{:else}
25
+
<div
26
+
class={['video-embed', !borderless && 'is-bordered']}
27
+
style={ratio ? `aspect-ratio: ${ratio.width}/${ratio.height}` : ``}
28
+
>
29
+
{@render Content()}
30
+
</div>
31
+
{/if}
32
+
33
+
{#snippet Content()}
34
+
<img loading="lazy" src={video.thumbnail} alt="" class={['thumbnail', blur && 'is-blurred']} />
35
+
36
+
{#if ratio}
37
+
<div class="placeholder"></div>
38
+
{/if}
39
+
40
+
<div class="play">
41
+
<PlaySolid />
42
+
</div>
43
+
{/snippet}
44
+
45
+
<style>
46
+
.video-embed {
47
+
position: relative;
48
+
background: var(--bg-secondary);
49
+
aspect-ratio: 16 / 9;
50
+
overflow: hidden;
51
+
}
52
+
.is-bordered {
53
+
border: 1px solid var(--divider-md);
54
+
border-radius: 6px;
55
+
}
56
+
.is-standalone {
57
+
align-self: baseline;
58
+
aspect-ratio: auto;
59
+
max-width: 100%;
60
+
}
61
+
62
+
.constrainer {
63
+
min-width: 64px;
64
+
max-width: 100%;
65
+
min-height: 64px;
66
+
max-height: 320px;
67
+
}
68
+
69
+
.thumbnail {
70
+
width: 100%;
71
+
height: 100%;
72
+
object-fit: contain;
73
+
}
74
+
.is-blurred {
75
+
scale: 125%;
76
+
filter: blur(24px);
77
+
}
78
+
79
+
.placeholder {
80
+
width: 100vw;
81
+
height: 100vh;
82
+
}
83
+
84
+
.play {
85
+
display: grid;
86
+
position: absolute;
87
+
top: 50%;
88
+
left: 50%;
89
+
place-items: center;
90
+
translate: -50% -50%;
91
+
border-radius: 50%;
92
+
background: rgba(64, 64, 64, 0.6);
93
+
aspect-ratio: 1 / 1;
94
+
height: 40%;
95
+
max-height: 48px;
96
+
color: #ffffff;
97
+
font-size: 20px;
98
+
99
+
:global(.sv-icon) {
100
+
width: 40%;
101
+
height: 40%;
102
+
}
103
+
104
+
.is-standalone &:hover {
105
+
background: rgba(64, 64, 64, 0.8);
106
+
}
107
+
}
108
+
</style>
+23
src/lib/components/page/page-container.svelte
+23
src/lib/components/page/page-container.svelte
···
1
+
<script lang="ts">
2
+
import type { Snippet } from 'svelte';
3
+
4
+
interface Props {
5
+
children: Snippet<[]>;
6
+
}
7
+
8
+
const { children }: Props = $props();
9
+
</script>
10
+
11
+
<div class="page-container">
12
+
{@render children()}
13
+
</div>
14
+
15
+
<style>
16
+
.page-container {
17
+
display: flex;
18
+
flex-direction: column;
19
+
margin: 24px auto;
20
+
width: 100%;
21
+
max-width: 600px;
22
+
}
23
+
</style>
+28
src/lib/components/page/page-header.svelte
+28
src/lib/components/page/page-header.svelte
···
1
+
<script lang="ts">
2
+
interface Props {
3
+
title: string;
4
+
}
5
+
6
+
const { title }: Props = $props();
7
+
</script>
8
+
9
+
<div class="page-header">
10
+
<h2 class="title">{title}</h2>
11
+
</div>
12
+
13
+
<style>
14
+
.page-header {
15
+
position: sticky;
16
+
top: 0;
17
+
border-bottom: 1px solid var(--divider-sm);
18
+
background: var(--bg-primary);
19
+
padding: 12px 16px;
20
+
z-index: 1;
21
+
}
22
+
23
+
.title {
24
+
font-weight: 600;
25
+
font-size: 1rem;
26
+
line-height: 1.5rem;
27
+
}
28
+
</style>
+59
src/lib/components/page/page-listing.svelte
+59
src/lib/components/page/page-listing.svelte
···
1
+
<script lang="ts">
2
+
import type { Snippet } from 'svelte';
3
+
4
+
interface Props {
5
+
subject: 'timeline' | 'posts' | 'profiles' | 'reposts' | 'likes';
6
+
root: boolean;
7
+
cursor: string | undefined;
8
+
children: Snippet<[]>;
9
+
}
10
+
11
+
const { subject, root, cursor, children }: Props = $props();
12
+
</script>
13
+
14
+
<div class="page-listing">
15
+
{#if !root}
16
+
<a href="?" class="button latest-button">Show latest {subject}</a>
17
+
{/if}
18
+
19
+
{@render children()}
20
+
21
+
{#if cursor}
22
+
<a href="?cursor={encodeURIComponent(cursor)}" class="button more-button">
23
+
{subject === 'timeline' ? `Show older posts` : `Show more ${subject}`}
24
+
</a>
25
+
{:else}
26
+
<div class="end-marker">
27
+
{subject === 'timeline' ? `No more posts.` : `No more ${subject}.`}
28
+
</div>
29
+
{/if}
30
+
</div>
31
+
32
+
<style>
33
+
.page-listing {
34
+
background: var(--bg-primary);
35
+
}
36
+
37
+
.button {
38
+
display: grid;
39
+
place-items: center;
40
+
height: 53px;
41
+
font-weight: 500;
42
+
43
+
&:hover {
44
+
background: var(--tap-sm);
45
+
}
46
+
}
47
+
.latest-button {
48
+
border-bottom: 1px solid var(--divider-sm);
49
+
}
50
+
51
+
.end-marker {
52
+
display: grid;
53
+
place-items: center;
54
+
height: 53px;
55
+
color: var(--text-blurb);
56
+
font-style: italic;
57
+
font-weight: 500;
58
+
}
59
+
</style>
+127
src/lib/components/profiles/profile-item.svelte
+127
src/lib/components/profiles/profile-item.svelte
···
1
+
<script lang="ts">
2
+
import type { AppBskyActorDefs } from '@atcute/client/lexicons';
3
+
4
+
import { base } from '$app/paths';
5
+
6
+
interface Props {
7
+
item: AppBskyActorDefs.ProfileView | AppBskyActorDefs.ProfileViewBasic;
8
+
}
9
+
10
+
const { item: profile }: Props = $props();
11
+
12
+
const href = $derived(`${base}/${profile.did}`);
13
+
</script>
14
+
15
+
<div class="profile-item">
16
+
<div class="aside">
17
+
<a {href} tabindex={-1} class="avatar-wrapper">
18
+
{#if profile.avatar}
19
+
<img loading="lazy" src={profile.avatar} alt="" class="avatar" />
20
+
{/if}
21
+
</a>
22
+
</div>
23
+
24
+
<div class="main">
25
+
<div class="content">
26
+
<a {href} class="name-wrapper">
27
+
<p class="display-name">{profile.displayName}</p>
28
+
<p class="handle">@{profile.handle}</p>
29
+
</a>
30
+
</div>
31
+
32
+
{#if 'description' in profile && profile.description?.trim()}
33
+
<p class="bio">{profile.description}</p>
34
+
{/if}
35
+
</div>
36
+
</div>
37
+
38
+
<style>
39
+
.profile-item {
40
+
display: flex;
41
+
gap: 12px;
42
+
contain: content;
43
+
padding: 12px 16px;
44
+
45
+
@media (hover: hover) {
46
+
&:hover {
47
+
background: var(--tap-sm);
48
+
}
49
+
}
50
+
}
51
+
52
+
.aside {
53
+
display: flex;
54
+
flex-shrink: 0;
55
+
flex-direction: column;
56
+
align-items: center;
57
+
}
58
+
59
+
.avatar-wrapper {
60
+
display: block;
61
+
margin: 2px 0 0;
62
+
border-radius: 9999px;
63
+
background: var(--bg-secondary);
64
+
width: 36px;
65
+
height: 36px;
66
+
overflow: hidden;
67
+
68
+
&:hover {
69
+
filter: brightness(0.85);
70
+
}
71
+
}
72
+
.avatar {
73
+
width: 100%;
74
+
height: 100%;
75
+
object-fit: cover;
76
+
font-size: 0;
77
+
}
78
+
79
+
.main {
80
+
display: flex;
81
+
flex-grow: 1;
82
+
flex-direction: column;
83
+
gap: 4px;
84
+
min-width: 0;
85
+
}
86
+
87
+
.content {
88
+
display: flex;
89
+
justify-content: space-between;
90
+
align-items: center;
91
+
gap: 12px;
92
+
margin: auto 0;
93
+
height: 40px;
94
+
}
95
+
96
+
.name-wrapper {
97
+
min-width: 0;
98
+
}
99
+
.display-name {
100
+
overflow: hidden;
101
+
color: var(--text-primary);
102
+
font-weight: 600;
103
+
text-overflow: ellipsis;
104
+
white-space: nowrap;
105
+
106
+
&:empty {
107
+
color: var(--text-muted);
108
+
}
109
+
110
+
.name-wrapper:hover & {
111
+
text-decoration: underline;
112
+
}
113
+
}
114
+
.handle {
115
+
overflow: hidden;
116
+
color: var(--text-blurb);
117
+
text-overflow: ellipsis;
118
+
white-space: nowrap;
119
+
}
120
+
121
+
.bio {
122
+
display: -webkit-box;
123
+
overflow: hidden;
124
+
-webkit-line-clamp: 2;
125
+
-webkit-box-orient: vertical;
126
+
}
127
+
</style>
+56
src/lib/components/richtext-raw-renderer.svelte
+56
src/lib/components/richtext-raw-renderer.svelte
···
1
+
<script lang="ts" module>
2
+
const HTTP_RE = /^https?:\/\//;
3
+
</script>
4
+
5
+
<script lang="ts">
6
+
import { tokenize } from '@atcute/bluesky-richtext-parser';
7
+
8
+
interface Props {
9
+
text: string;
10
+
large?: boolean;
11
+
}
12
+
13
+
const { text, large }: Props = $props();
14
+
</script>
15
+
16
+
<p class={`rich-text` + (large ? ` is-large` : ` is-small`)}>
17
+
{#each tokenize(text) as token}
18
+
{#if token.type === 'autolink'}
19
+
<a target="_blank" href={token.url} rel="noopener nofollow" class="link">
20
+
{token.raw.replace(HTTP_RE, '')}
21
+
</a>
22
+
{:else if token.type === 'mention'}
23
+
<a href="/{token.handle}" class="mention">{token.raw}</a>
24
+
{:else if token.type === 'topic'}
25
+
<span class="hashtag">{token.raw}</span>
26
+
{:else}
27
+
{token.raw}
28
+
{/if}
29
+
{/each}
30
+
</p>
31
+
32
+
<style>
33
+
.rich-text {
34
+
overflow: hidden;
35
+
white-space: pre-wrap;
36
+
overflow-wrap: break-word;
37
+
38
+
&:empty {
39
+
display: none;
40
+
}
41
+
}
42
+
.is-large {
43
+
font-size: 1rem;
44
+
line-height: 1.5rem;
45
+
}
46
+
47
+
.link,
48
+
.mention,
49
+
.hashtag {
50
+
color: var(--text-link);
51
+
52
+
&:hover {
53
+
text-decoration: underline;
54
+
}
55
+
}
56
+
</style>
+66
src/lib/components/richtext-renderer.svelte
+66
src/lib/components/richtext-renderer.svelte
···
1
+
<script lang="ts" module>
2
+
import { segmentize, type Facet, type FacetFeature } from '@atcute/bluesky-richtext-segmenter';
3
+
4
+
const grabFirstSupported = (features: FacetFeature[] | undefined): FacetFeature | undefined => {
5
+
return features?.find(
6
+
(feature) =>
7
+
feature.$type === 'app.bsky.richtext.facet#link' ||
8
+
feature.$type === 'app.bsky.richtext.facet#mention' ||
9
+
feature.$type === 'app.bsky.richtext.facet#tag',
10
+
);
11
+
};
12
+
</script>
13
+
14
+
<script lang="ts">
15
+
import { base } from '$app/paths';
16
+
17
+
interface Props {
18
+
text: string;
19
+
facets?: Facet[];
20
+
large?: boolean;
21
+
}
22
+
23
+
const { text, facets, large }: Props = $props();
24
+
</script>
25
+
26
+
<p class={`rich-text` + (large ? ` is-large` : ` is-small`)}>
27
+
{#each segmentize(text, facets) as segment}
28
+
{@const feature = grabFirstSupported(segment.features)}
29
+
30
+
{#if !feature}
31
+
{segment.text}
32
+
{:else if feature.$type === 'app.bsky.richtext.facet#link'}
33
+
<a target="_blank" href={feature.uri} rel="noopener nofollow" class="link">{segment.text}</a>
34
+
{:else if feature.$type === 'app.bsky.richtext.facet#mention'}
35
+
<a href="{base}/{feature.did}" class="mention">{segment.text}</a>
36
+
{:else if feature.$type === 'app.bsky.richtext.facet#tag'}
37
+
<span class="hashtag">{segment.text}</span>
38
+
{/if}
39
+
{/each}
40
+
</p>
41
+
42
+
<style>
43
+
.rich-text {
44
+
overflow: hidden;
45
+
white-space: pre-wrap;
46
+
overflow-wrap: break-word;
47
+
48
+
&:empty {
49
+
display: none;
50
+
}
51
+
}
52
+
.is-large {
53
+
font-size: 1rem;
54
+
line-height: 1.5rem;
55
+
}
56
+
57
+
.link,
58
+
.mention,
59
+
.hashtag {
60
+
color: var(--text-link);
61
+
62
+
&:hover {
63
+
text-decoration: underline;
64
+
}
65
+
}
66
+
</style>
+62
src/lib/components/threads/main-post-metrics.svelte
+62
src/lib/components/threads/main-post-metrics.svelte
···
1
+
<script lang="ts">
2
+
import type { AppBskyFeedDefs } from '@atcute/client/lexicons';
3
+
4
+
import { base } from '$app/paths';
5
+
6
+
import { parseAtUri } from '$lib/types/at-uri';
7
+
import { formatCompactNumber } from '$lib/utils/intl/number';
8
+
9
+
interface Props {
10
+
post: AppBskyFeedDefs.PostView;
11
+
}
12
+
13
+
const { post }: Props = $props();
14
+
15
+
const baseUrl = $derived(`${base}/${post.author.did}/${parseAtUri(post.uri).rkey}`);
16
+
</script>
17
+
18
+
{#snippet Stat(count: number | undefined, one: string, many: string, href: string)}
19
+
{#if count !== undefined && count > 0}
20
+
<a {href} class="stat">
21
+
<span class="count">{formatCompactNumber(count)}</span>
22
+
<span class="label"> {count === 1 ? one : many}</span>
23
+
</a>
24
+
{/if}
25
+
{/snippet}
26
+
27
+
{#if post.repostCount || post.quoteCount || post.likeCount}
28
+
<div class="main-post-metrics">
29
+
{@render Stat(post.repostCount, 'repost', 'reposts', `${baseUrl}/reposts`)}
30
+
{@render Stat(post.quoteCount, 'quote', 'quotes', `${baseUrl}/quotes`)}
31
+
{@render Stat(post.likeCount, 'like', 'likes', `${baseUrl}/likes`)}
32
+
</div>
33
+
{/if}
34
+
35
+
<style>
36
+
.main-post-metrics {
37
+
display: flex;
38
+
flex-wrap: wrap;
39
+
gap: 16px;
40
+
border-top: 1px solid var(--divider-md);
41
+
padding: 16px 0;
42
+
43
+
&:empty {
44
+
display: none;
45
+
}
46
+
}
47
+
48
+
.stat {
49
+
color: var(--text-primary);
50
+
51
+
&:hover {
52
+
text-decoration: underline;
53
+
}
54
+
}
55
+
56
+
.count {
57
+
font-weight: 600;
58
+
}
59
+
.label {
60
+
color: var(--text-blurb);
61
+
}
62
+
</style>
+167
src/lib/components/threads/main-post.svelte
+167
src/lib/components/threads/main-post.svelte
···
1
+
<script lang="ts">
2
+
import type { AppBskyFeedDefs, AppBskyFeedPost } from '@atcute/client/lexicons';
3
+
4
+
import { base } from '$app/paths';
5
+
6
+
import { parseAtUri } from '$lib/types/at-uri';
7
+
import { formatLongDate } from '$lib/utils/intl/date';
8
+
9
+
import Embeds from '../embeds/embeds.svelte';
10
+
import RichTextRenderer from '../richtext-renderer.svelte';
11
+
12
+
import MainPostMetrics from './main-post-metrics.svelte';
13
+
14
+
interface Props {
15
+
post: AppBskyFeedDefs.PostView;
16
+
prev?: boolean;
17
+
}
18
+
19
+
const { post, prev = false }: Props = $props();
20
+
21
+
const author = $derived(post.author);
22
+
const authorUrl = $derived(`/${author.did}`);
23
+
const authorName = $derived(author.displayName?.trim());
24
+
25
+
const record = $derived(post.record as AppBskyFeedPost.Record);
26
+
const postUrl = $derived(`${base}/${author.did}/${parseAtUri(post.uri).rkey}#main`);
27
+
</script>
28
+
29
+
<div class="highlighted-post">
30
+
<div class="meta">
31
+
{#if prev}
32
+
<div class="ancestor-line-wrapper">
33
+
<div class="ancestor-line"></div>
34
+
</div>
35
+
{/if}
36
+
37
+
<a href={authorUrl} class="avatar-wrapper">
38
+
{#if author.avatar}
39
+
<img loading="lazy" src={author.avatar} alt="" class={`avatar`} />
40
+
{/if}
41
+
</a>
42
+
43
+
<a href={authorUrl} class="name-wrapper">
44
+
{#if authorName}
45
+
<bdi class="display-name-wrapper">
46
+
<span class="display-name">{authorName}</span>
47
+
</bdi>
48
+
{/if}
49
+
50
+
<span class="handle">@{author.handle}</span>
51
+
</a>
52
+
</div>
53
+
54
+
<RichTextRenderer text={record.text} facets={record.facets} large />
55
+
56
+
{#if post.embed}
57
+
<Embeds embed={post.embed} large />
58
+
{/if}
59
+
60
+
<div class="footer">
61
+
<a href={postUrl} class="date">
62
+
<time datetime={record.createdAt}>
63
+
{formatLongDate(record.createdAt)}
64
+
</time>
65
+
</a>
66
+
</div>
67
+
68
+
<MainPostMetrics {post} />
69
+
</div>
70
+
71
+
<style>
72
+
.highlighted-post {
73
+
padding: 12px 16px 0 16px;
74
+
}
75
+
76
+
.meta {
77
+
display: flex;
78
+
position: relative;
79
+
align-items: center;
80
+
gap: 12px;
81
+
margin: 0 0 12px 0;
82
+
color: var(--text-blurb);
83
+
}
84
+
85
+
.avatar-wrapper {
86
+
display: block;
87
+
flex-shrink: 0;
88
+
border-radius: 9999px;
89
+
background: var(--bg-secondary);
90
+
width: 40px;
91
+
height: 40px;
92
+
overflow: hidden;
93
+
94
+
&:hover {
95
+
filter: brightness(0.85);
96
+
}
97
+
}
98
+
99
+
.avatar {
100
+
width: 100%;
101
+
height: 100%;
102
+
object-fit: cover;
103
+
}
104
+
.is-blurred {
105
+
scale: 125%;
106
+
filter: blur(4px);
107
+
}
108
+
109
+
.name-wrapper {
110
+
display: block;
111
+
flex-grow: 1;
112
+
max-width: 100%;
113
+
overflow: hidden;
114
+
color: inherit;
115
+
text-overflow: ellipsis;
116
+
white-space: nowrap;
117
+
}
118
+
.display-name-wrapper {
119
+
overflow: hidden;
120
+
text-overflow: ellipsis;
121
+
122
+
.name-wrapper:hover & {
123
+
text-decoration: underline;
124
+
}
125
+
}
126
+
.display-name {
127
+
color: var(--text-primary);
128
+
font-weight: 700;
129
+
}
130
+
.handle {
131
+
display: block;
132
+
overflow: hidden;
133
+
text-overflow: ellipsis;
134
+
white-space: nowrap;
135
+
}
136
+
137
+
.footer {
138
+
display: flex;
139
+
flex-wrap: wrap;
140
+
align-items: center;
141
+
gap: 8px;
142
+
margin: 12px 0 0;
143
+
padding: 0 0 12px 0;
144
+
}
145
+
.date {
146
+
color: var(--text-blurb);
147
+
148
+
&:hover {
149
+
text-decoration: underline;
150
+
}
151
+
}
152
+
153
+
.ancestor-line-wrapper {
154
+
display: flex;
155
+
position: absolute;
156
+
bottom: 100%;
157
+
flex-direction: column;
158
+
align-items: center;
159
+
margin: 0 0 4px 0;
160
+
width: 36px;
161
+
height: 12px;
162
+
}
163
+
.ancestor-line {
164
+
flex-grow: 1;
165
+
border-left: 2px solid var(--divider-md);
166
+
}
167
+
</style>
+107
src/lib/components/threads/overflow-thread-item.svelte
+107
src/lib/components/threads/overflow-thread-item.svelte
···
1
+
<script lang="ts">
2
+
import type { OverflowAncestorItem, OverflowDescendantItem } from '$lib/models/thread';
3
+
import { parseAtUri } from '$lib/types/at-uri';
4
+
5
+
import { base } from '$app/paths';
6
+
7
+
import DotGrid_1x3HorizontalOutlined from '$lib/components/central-icons/dot-grid-1x3-horizontal-outlined.svelte';
8
+
9
+
import TreeLines from './tree-lines.svelte';
10
+
11
+
interface Props {
12
+
item: OverflowAncestorItem | OverflowDescendantItem;
13
+
treeView: boolean;
14
+
descendant: boolean;
15
+
}
16
+
17
+
const { item, treeView, descendant }: Props = $props();
18
+
19
+
const postUrl = $derived.by(() => {
20
+
const uri = parseAtUri(item.uri);
21
+
return `${base}/${uri.repo}/${uri.rkey}#main`;
22
+
});
23
+
</script>
24
+
25
+
<a
26
+
href={postUrl}
27
+
class={['overflow-thread-item', treeView ? 'is-tree' : 'is-flat', !descendant && 'has-next']}
28
+
>
29
+
{#if treeView}
30
+
<TreeLines lines={item.lines} />
31
+
{:else}
32
+
<div class="dots">
33
+
<div class="dot"></div>
34
+
<div class="dot"></div>
35
+
<div class="dot"></div>
36
+
</div>
37
+
{/if}
38
+
39
+
<div class="content">
40
+
{#if treeView}
41
+
<div class="circle">
42
+
<DotGrid_1x3HorizontalOutlined />
43
+
</div>
44
+
{/if}
45
+
46
+
<span class="label">
47
+
{!descendant ? `See parent replies` : `Continue thread`}
48
+
</span>
49
+
</div>
50
+
</a>
51
+
52
+
<style>
53
+
.overflow-thread-item {
54
+
display: flex;
55
+
user-select: none;
56
+
57
+
@media (hover: hover) {
58
+
&:hover {
59
+
background: var(--tap-sm);
60
+
}
61
+
}
62
+
}
63
+
.is-tree {
64
+
padding: 0 12px;
65
+
}
66
+
67
+
.is-flat {
68
+
padding: 0 16px;
69
+
70
+
&:not(.has-next) {
71
+
border-bottom: 2px solid var(--divider-sm);
72
+
}
73
+
}
74
+
75
+
.dots {
76
+
display: flex;
77
+
flex-shrink: 0;
78
+
flex-direction: column;
79
+
justify-content: center;
80
+
align-items: center;
81
+
gap: 6px;
82
+
margin: 0 12px 0 0;
83
+
width: 36px;
84
+
}
85
+
.dot {
86
+
border-left: 2px solid var(--divider-md);
87
+
height: 2px;
88
+
}
89
+
90
+
.content {
91
+
display: flex;
92
+
align-items: center;
93
+
gap: 12px;
94
+
padding: 12px 0;
95
+
}
96
+
97
+
.circle {
98
+
display: grid;
99
+
place-items: center;
100
+
border-radius: 9999px;
101
+
background: var(--divider-md);
102
+
width: 20px;
103
+
height: 20px;
104
+
color: var(--text-blurb);
105
+
font-size: 12px;
106
+
}
107
+
</style>
+168
src/lib/components/threads/post-thread-item.svelte
+168
src/lib/components/threads/post-thread-item.svelte
···
1
+
<script lang="ts">
2
+
import type { AppBskyFeedPost } from '@atcute/client/lexicons';
3
+
4
+
import { base } from '$app/paths';
5
+
6
+
import { type PostAncestorItem, type PostDescendantItem } from '$lib/models/thread';
7
+
import { parseAtUri } from '$lib/types/at-uri';
8
+
9
+
import Embeds from '../embeds/embeds.svelte';
10
+
import RichtextRenderer from '../richtext-renderer.svelte';
11
+
import PostMeta from '../timeline/post-meta.svelte';
12
+
import PostMetrics from '../timeline/post-metrics.svelte';
13
+
14
+
import TreeLines from './tree-lines.svelte';
15
+
16
+
interface Props {
17
+
item: PostAncestorItem | PostDescendantItem;
18
+
treeView: boolean;
19
+
}
20
+
21
+
const { item, treeView }: Props = $props();
22
+
23
+
const post = $derived(item.post);
24
+
25
+
const author = $derived(post.author);
26
+
const authorUrl = $derived(`${base}/${author.did}`);
27
+
28
+
const record = $derived(post.record as AppBskyFeedPost.Record);
29
+
const postUrl = $derived(`${base}/${author.did}/${parseAtUri(post.uri).rkey}#main`);
30
+
</script>
31
+
32
+
<div
33
+
class={[
34
+
'post-thread-item',
35
+
treeView ? 'is-tree' : 'is-flat',
36
+
item.prev && 'has-prev',
37
+
item.next && 'has-next',
38
+
]}
39
+
>
40
+
{#if treeView}
41
+
<TreeLines lines={item.lines} />
42
+
{/if}
43
+
44
+
<div class="content">
45
+
<div class="aside">
46
+
<div class="ascendant-line"></div>
47
+
48
+
<a href={authorUrl} class="avatar-wrapper">
49
+
{#if author.avatar}
50
+
<img loading="lazy" src={author.avatar} alt="" class="avatar" />
51
+
{/if}
52
+
</a>
53
+
54
+
<div class="descendant-line"></div>
55
+
</div>
56
+
57
+
<div class="main">
58
+
<PostMeta {post} {postUrl} {authorUrl} gutterBottom />
59
+
60
+
<RichtextRenderer text={record.text} facets={record.facets} />
61
+
62
+
{#if post.embed}
63
+
<Embeds embed={post.embed} />
64
+
{/if}
65
+
66
+
<PostMetrics {post} />
67
+
</div>
68
+
</div>
69
+
</div>
70
+
71
+
<style>
72
+
.post-thread-item {
73
+
display: flex;
74
+
contain: content;
75
+
76
+
@media (hover: hover) {
77
+
&:hover {
78
+
background: var(--tap-sm);
79
+
}
80
+
}
81
+
}
82
+
.is-tree {
83
+
padding: 0 12px;
84
+
}
85
+
86
+
.is-flat {
87
+
padding: 0 16px;
88
+
89
+
&:not(.has-next) {
90
+
border-bottom: 2px solid var(--divider-sm);
91
+
}
92
+
}
93
+
94
+
.content {
95
+
display: flex;
96
+
flex-grow: 1;
97
+
gap: 12px;
98
+
min-width: 0;
99
+
100
+
.is-tree & {
101
+
gap: 8px;
102
+
}
103
+
}
104
+
105
+
.ascendant-line {
106
+
display: none;
107
+
position: absolute;
108
+
top: 0;
109
+
border-left: 2px solid var(--divider-md);
110
+
height: 8px;
111
+
112
+
.is-flat.has-prev & {
113
+
display: block;
114
+
}
115
+
}
116
+
.descendant-line {
117
+
display: none;
118
+
flex-grow: 1;
119
+
margin: 4px 0 0 0;
120
+
border-left: 2px solid var(--divider-md);
121
+
122
+
.is-tree & {
123
+
margin: 2px 0 0 0;
124
+
}
125
+
.has-next & {
126
+
display: block;
127
+
}
128
+
}
129
+
130
+
.aside {
131
+
display: flex;
132
+
position: relative;
133
+
flex-shrink: 0;
134
+
flex-direction: column;
135
+
align-items: center;
136
+
padding-top: 12px;
137
+
}
138
+
139
+
.avatar-wrapper {
140
+
display: block;
141
+
border-radius: 9999px;
142
+
background: var(--bg-secondary);
143
+
width: 36px;
144
+
height: 36px;
145
+
overflow: hidden;
146
+
147
+
.is-tree & {
148
+
width: 20px;
149
+
height: 20px;
150
+
}
151
+
152
+
&:hover {
153
+
filter: brightness(0.85);
154
+
}
155
+
}
156
+
.avatar {
157
+
width: 100%;
158
+
height: 100%;
159
+
object-fit: cover;
160
+
font-size: 0;
161
+
}
162
+
163
+
.main {
164
+
flex-grow: 1;
165
+
padding: 12px 0;
166
+
min-width: 0;
167
+
}
168
+
</style>
+51
src/lib/components/threads/tree-lines.svelte
+51
src/lib/components/threads/tree-lines.svelte
···
1
+
<script lang="ts">
2
+
import { LineType } from '$lib/models/thread';
3
+
4
+
interface Props {
5
+
lines: LineType[] | undefined;
6
+
}
7
+
8
+
const { lines = [] }: Props = $props();
9
+
</script>
10
+
11
+
<div class="tree-lines">
12
+
{#each lines as line}
13
+
<div class="line">
14
+
{#if line === LineType.UP_RIGHT || line === LineType.VERTICAL_RIGHT}
15
+
<div class="line-draw-right"></div>
16
+
{/if}
17
+
{#if line === LineType.VERTICAL || line === LineType.VERTICAL_RIGHT}
18
+
<div class="line-draw-vertical"></div>
19
+
{/if}
20
+
</div>
21
+
{/each}
22
+
</div>
23
+
24
+
<style>
25
+
.tree-lines {
26
+
display: contents;
27
+
}
28
+
29
+
.line {
30
+
position: relative;
31
+
flex-shrink: 0;
32
+
width: 20px;
33
+
}
34
+
.line-draw-right {
35
+
position: absolute;
36
+
top: 0;
37
+
right: 2px;
38
+
border-bottom: 2px solid var(--divider-md);
39
+
border-left: 2px solid var(--divider-md);
40
+
border-bottom-left-radius: 9px;
41
+
width: 9px;
42
+
height: 22px;
43
+
}
44
+
.line-draw-vertical {
45
+
position: absolute;
46
+
top: 0;
47
+
bottom: 0;
48
+
left: 9px;
49
+
border-left: 2px solid var(--divider-md);
50
+
}
51
+
</style>
+245
src/lib/components/timeline/post-feed-item.svelte
+245
src/lib/components/timeline/post-feed-item.svelte
···
1
+
<script lang="ts">
2
+
import type { AppBskyFeedPost } from '@atcute/client/lexicons';
3
+
4
+
import { base } from '$app/paths';
5
+
6
+
import type { UiTimelineItem } from '$lib/models/timeline';
7
+
import { parseAtUri } from '$lib/types/at-uri';
8
+
9
+
import ArrowsRepeatRightLeftOutlined from '$lib/components/central-icons/arrows-repeat-right-left-outlined.svelte';
10
+
import PinOutlined from '$lib/components/central-icons/pin-outlined.svelte';
11
+
import Embeds from '$lib/components/embeds/embeds.svelte';
12
+
import RichtextRenderer from '$lib/components/richtext-renderer.svelte';
13
+
14
+
import PostMeta from './post-meta.svelte';
15
+
import PostMetrics from './post-metrics.svelte';
16
+
17
+
interface Props {
18
+
item: UiTimelineItem;
19
+
}
20
+
21
+
const { item }: Props = $props();
22
+
23
+
const post = $derived(item.post);
24
+
25
+
const author = $derived(post.author);
26
+
const authorUrl = $derived(`${base}/${author.did}`);
27
+
28
+
const record = $derived(post.record as AppBskyFeedPost.Record);
29
+
const postUrl = $derived(`${base}/${author.did}/${parseAtUri(post.uri).rkey}#main`);
30
+
</script>
31
+
32
+
<div class={['post-feed-item', !item.next && `is-leaf`]}>
33
+
<div class="contexts">
34
+
{#if item.prev}
35
+
<div class="ascendant-line-wrapper">
36
+
<div class="line"></div>
37
+
</div>
38
+
{/if}
39
+
40
+
{#if item.reason}
41
+
{@const reason = item.reason}
42
+
43
+
{#if reason.$type === 'app.bsky.feed.defs#reasonRepost'}
44
+
{@const by = reason.by}
45
+
46
+
<div class="context">
47
+
<div class="aside">
48
+
<ArrowsRepeatRightLeftOutlined />
49
+
</div>
50
+
<a href="/{by.did}" class="main">
51
+
<span dir="auto" class="name">{by.displayName?.trim() || by.handle.slice(0, 64)}</span>
52
+
<span class="affix">{' '}reposted</span>
53
+
</a>
54
+
</div>
55
+
{:else if reason.$type === 'app.bsky.feed.defs#reasonPin'}
56
+
<div class="context">
57
+
<div class="aside">
58
+
<PinOutlined />
59
+
</div>
60
+
<span class="main">Pinned</span>
61
+
</div>
62
+
{/if}
63
+
{/if}
64
+
</div>
65
+
66
+
<div class="content">
67
+
<div class="aside">
68
+
<a tabindex={-1} href={authorUrl} class="avatar-wrapper">
69
+
{#if author.avatar}
70
+
<img loading="lazy" src={author.avatar} alt="" class="avatar" />
71
+
{/if}
72
+
</a>
73
+
74
+
{#if item.next}
75
+
<div class="descendant-line"></div>
76
+
{/if}
77
+
</div>
78
+
79
+
<div class="main">
80
+
<PostMeta {post} {postUrl} {authorUrl} gutterBottom />
81
+
82
+
{#if !item.prev && record.reply}
83
+
{@const parent = item.reply?.parent}
84
+
85
+
<p class="reply-context">
86
+
{#if parent && parent.$type === 'app.bsky.feed.defs#postView'}
87
+
{@const author = parent.author}
88
+
89
+
Replying to
90
+
<a href="/{author.did}" dir="auto">
91
+
{author.displayName?.trim() || `@${author.handle}`}
92
+
</a>
93
+
{:else}
94
+
Replying to an unknown post
95
+
{/if}
96
+
</p>
97
+
{/if}
98
+
99
+
<RichtextRenderer text={record.text} facets={record.facets} />
100
+
101
+
{#if post.embed}
102
+
<Embeds embed={post.embed} />
103
+
{/if}
104
+
105
+
<PostMetrics {post} />
106
+
</div>
107
+
</div>
108
+
</div>
109
+
110
+
<style>
111
+
.post-feed-item {
112
+
contain: content;
113
+
padding: 0 16px;
114
+
115
+
/* content-visibility: auto; */
116
+
/* contain-intrinsic-height: 99px; */
117
+
118
+
@media (hover: hover) {
119
+
&:hover {
120
+
background: var(--tap-sm);
121
+
}
122
+
}
123
+
}
124
+
.is-leaf {
125
+
border-bottom: 1px solid var(--divider-sm);
126
+
}
127
+
128
+
.ascendant-line-wrapper {
129
+
display: flex;
130
+
flex-direction: column;
131
+
align-items: center;
132
+
width: 36px;
133
+
134
+
.line {
135
+
position: absolute;
136
+
top: 0;
137
+
bottom: 4px;
138
+
flex-grow: 1;
139
+
border-left: 2px solid var(--divider-md);
140
+
}
141
+
}
142
+
.descendant-line {
143
+
flex-grow: 1;
144
+
margin-top: 4px;
145
+
border-left: 2px solid var(--divider-md);
146
+
}
147
+
148
+
.contexts {
149
+
display: flex;
150
+
position: relative;
151
+
flex-direction: column;
152
+
padding: 8px 0 4px 0;
153
+
}
154
+
.context {
155
+
display: flex;
156
+
align-items: center;
157
+
gap: 12px;
158
+
color: var(--text-blurb);
159
+
font-size: 0.8125rem;
160
+
line-height: 1.25rem;
161
+
162
+
.aside {
163
+
display: flex;
164
+
flex-shrink: 0;
165
+
justify-content: flex-end;
166
+
width: 36px;
167
+
}
168
+
169
+
.main {
170
+
display: flex;
171
+
min-width: 0px;
172
+
color: inherit;
173
+
174
+
&[href]:hover {
175
+
text-decoration-line: underline;
176
+
}
177
+
}
178
+
179
+
.name {
180
+
overflow: hidden;
181
+
font-weight: 500;
182
+
text-overflow: ellipsis;
183
+
white-space: nowrap;
184
+
}
185
+
186
+
.affix {
187
+
flex-shrink: 0;
188
+
white-space: pre;
189
+
}
190
+
}
191
+
192
+
.content {
193
+
display: flex;
194
+
gap: 12px;
195
+
196
+
.aside {
197
+
display: flex;
198
+
flex-shrink: 0;
199
+
flex-direction: column;
200
+
align-items: center;
201
+
}
202
+
203
+
.main {
204
+
flex-grow: 1;
205
+
padding-bottom: 12px;
206
+
min-width: 0;
207
+
}
208
+
}
209
+
210
+
.avatar-wrapper {
211
+
display: block;
212
+
border-radius: 9999px;
213
+
background: var(--bg-secondary);
214
+
width: 36px;
215
+
height: 36px;
216
+
overflow: hidden;
217
+
218
+
&:hover {
219
+
filter: brightness(0.85);
220
+
}
221
+
}
222
+
.avatar {
223
+
width: 100%;
224
+
height: 100%;
225
+
object-fit: cover;
226
+
font-size: 0;
227
+
}
228
+
229
+
.reply-context {
230
+
overflow: hidden;
231
+
color: var(--text-blurb);
232
+
font-size: 0.8125rem;
233
+
text-overflow: ellipsis;
234
+
white-space: nowrap;
235
+
236
+
a {
237
+
color: inherit;
238
+
font-weight: 500;
239
+
240
+
&:hover {
241
+
text-decoration: underline;
242
+
}
243
+
}
244
+
}
245
+
</style>
+94
src/lib/components/timeline/post-meta.svelte
+94
src/lib/components/timeline/post-meta.svelte
···
1
+
<script lang="ts">
2
+
import type { AppBskyFeedDefs, AppBskyFeedPost } from '@atcute/client/lexicons';
3
+
4
+
import { formatLongDate, formatRelativeTime } from '$lib/utils/intl/date';
5
+
6
+
interface Props {
7
+
post: AppBskyFeedDefs.PostView;
8
+
postUrl: string;
9
+
authorUrl: string;
10
+
gutterBottom?: boolean;
11
+
}
12
+
13
+
const { post, postUrl, authorUrl, gutterBottom = false }: Props = $props();
14
+
15
+
const author = $derived(post.author);
16
+
const authorName = $derived(author.displayName?.trim());
17
+
18
+
const createdAt = $derived((post.record as AppBskyFeedPost.Record).createdAt);
19
+
</script>
20
+
21
+
<div class={['post-meta', gutterBottom && 'has-bottom-gutter']}>
22
+
<a href={authorUrl} class="name-wrapper">
23
+
{#if authorName}
24
+
<bdi class="display-name-wrapper">
25
+
<span class="display-name">{authorName}</span>
26
+
</bdi>
27
+
{/if}
28
+
29
+
<span class="handle">@{author.handle}</span>
30
+
</a>
31
+
32
+
<span aria-hidden="true" class="dot"> · </span>
33
+
34
+
<a href={postUrl} title={formatLongDate(createdAt)} class="date">
35
+
<time datetime={createdAt}>{formatRelativeTime(createdAt)}</time>
36
+
</a>
37
+
</div>
38
+
39
+
<style>
40
+
.post-meta {
41
+
display: flex;
42
+
align-items: center;
43
+
color: var(--text-blurb);
44
+
}
45
+
.has-bottom-gutter {
46
+
margin-bottom: 2px;
47
+
}
48
+
49
+
.name-wrapper {
50
+
display: flex;
51
+
gap: 4px;
52
+
max-width: 100%;
53
+
overflow: hidden;
54
+
color: inherit;
55
+
text-decoration: none;
56
+
text-overflow: ellipsis;
57
+
white-space: nowrap;
58
+
}
59
+
60
+
.display-name-wrapper {
61
+
overflow: hidden;
62
+
text-overflow: ellipsis;
63
+
64
+
.name-wrapper:hover & {
65
+
text-decoration: underline;
66
+
}
67
+
}
68
+
.display-name {
69
+
color: var(--text-primary);
70
+
font-weight: 700;
71
+
}
72
+
73
+
.handle {
74
+
display: block;
75
+
overflow: hidden;
76
+
text-overflow: ellipsis;
77
+
white-space: nowrap;
78
+
}
79
+
80
+
.dot {
81
+
flex-shrink: 0;
82
+
margin: 0 6px;
83
+
}
84
+
85
+
.date {
86
+
color: inherit;
87
+
text-decoration: none;
88
+
white-space: nowrap;
89
+
90
+
&:hover {
91
+
text-decoration: underline;
92
+
}
93
+
}
94
+
</style>
+67
src/lib/components/timeline/post-metrics.svelte
+67
src/lib/components/timeline/post-metrics.svelte
···
1
+
<script lang="ts">
2
+
import type { Component } from 'svelte';
3
+
4
+
import type { AppBskyFeedDefs } from '@atcute/client/lexicons';
5
+
6
+
import { formatCompactNumber, formatLongNumber } from '$lib/utils/intl/number';
7
+
8
+
import ArrowsRepeatRightLeftOutlined from '$lib/components/central-icons/arrows-repeat-right-left-outlined.svelte';
9
+
import Bubble_2Outlined from '$lib/components/central-icons/bubble-2-outlined.svelte';
10
+
import HeartOutlined from '$lib/components/central-icons/heart-outlined.svelte';
11
+
12
+
interface Props {
13
+
post: AppBskyFeedDefs.PostView;
14
+
}
15
+
16
+
const { post }: Props = $props();
17
+
18
+
const replyCount = $derived(post.replyCount || 0);
19
+
const likeCount = $derived(post.likeCount || 0);
20
+
const repostCount = $derived((post.repostCount || 0) + (post.quoteCount || 0));
21
+
</script>
22
+
23
+
{#snippet Stat(count: number, Icon: Component, one: string, many: string)}
24
+
<div
25
+
title={count === 1 ? `${formatLongNumber(count)} ${one}` : `${formatLongNumber(count)} ${many}`}
26
+
class="stat"
27
+
>
28
+
<Icon />
29
+
30
+
<span class="count">
31
+
{formatCompactNumber(count)}
32
+
</span>
33
+
</div>
34
+
{/snippet}
35
+
36
+
<div class="post-metrics">
37
+
{@render Stat(replyCount, Bubble_2Outlined, 'reply', 'replies')}
38
+
{@render Stat(repostCount, ArrowsRepeatRightLeftOutlined, 'repost', 'reposts')}
39
+
{@render Stat(likeCount, HeartOutlined, 'like', 'likes')}
40
+
</div>
41
+
42
+
<style>
43
+
.post-metrics {
44
+
display: flex;
45
+
align-items: center;
46
+
gap: 16px;
47
+
margin-top: 12px;
48
+
color: var(--text-blurb);
49
+
}
50
+
51
+
.stat {
52
+
display: flex;
53
+
align-items: center;
54
+
gap: 8px;
55
+
min-width: 0px;
56
+
max-width: 100%;
57
+
}
58
+
59
+
.count {
60
+
padding-right: 8px;
61
+
overflow: hidden;
62
+
font-size: 0.8125rem;
63
+
line-height: 1.25rem;
64
+
text-overflow: ellipsis;
65
+
white-space: nowrap;
66
+
}
67
+
</style>
+269
src/lib/models/thread.ts
+269
src/lib/models/thread.ts
···
1
+
import type { Brand, AppBskyFeedDefs, AppBskyFeedPost } from '@atcute/client/lexicons';
2
+
3
+
export const enum LineType {
4
+
// <empty>
5
+
NONE,
6
+
// │
7
+
VERTICAL,
8
+
// ├
9
+
VERTICAL_RIGHT,
10
+
// └
11
+
UP_RIGHT,
12
+
}
13
+
14
+
interface BaseAncestor {
15
+
id: string;
16
+
lines?: undefined;
17
+
}
18
+
19
+
export interface BlockedAncestorItem extends BaseAncestor {
20
+
type: 'blocked';
21
+
uri: string;
22
+
}
23
+
export interface NonexistentAncestorItem extends BaseAncestor {
24
+
type: 'nonexistent';
25
+
uri: string;
26
+
}
27
+
export interface OverflowAncestorItem extends BaseAncestor {
28
+
type: 'overflow';
29
+
uri: string;
30
+
}
31
+
export interface PostAncestorItem extends BaseAncestor {
32
+
type: 'post';
33
+
post: AppBskyFeedDefs.PostView;
34
+
prev: boolean;
35
+
next: boolean;
36
+
}
37
+
38
+
export type AncestorItem =
39
+
| BlockedAncestorItem
40
+
| NonexistentAncestorItem
41
+
| OverflowAncestorItem
42
+
| PostAncestorItem;
43
+
44
+
interface BaseDescendant {
45
+
id: string;
46
+
lines: LineType[];
47
+
}
48
+
49
+
export interface BlockedDescendantItem extends BaseDescendant {
50
+
type: 'blocked';
51
+
uri: string;
52
+
}
53
+
export interface OverflowDescendantItem extends BaseDescendant {
54
+
type: 'overflow';
55
+
uri: string;
56
+
}
57
+
export interface PostDescendantItem extends BaseDescendant {
58
+
type: 'post';
59
+
post: AppBskyFeedDefs.PostView;
60
+
prev: boolean;
61
+
next: boolean;
62
+
}
63
+
64
+
export type DescendantItem = BlockedDescendantItem | OverflowDescendantItem | PostDescendantItem;
65
+
66
+
export interface ThreadData {
67
+
post: AppBskyFeedDefs.PostView;
68
+
ancestors: AncestorItem[];
69
+
descendants: DescendantItem[];
70
+
}
71
+
72
+
export const createThreadData = ({
73
+
thread,
74
+
treeView,
75
+
}: {
76
+
thread: Brand.Union<AppBskyFeedDefs.ThreadViewPost>;
77
+
treeView?: boolean;
78
+
}): ThreadData => {
79
+
let ancestors: AncestorItem[];
80
+
let descendants: DescendantItem[];
81
+
82
+
{
83
+
let parent = thread.parent;
84
+
85
+
ancestors = [];
86
+
87
+
while (parent) {
88
+
const type = parent.$type;
89
+
if (type === 'app.bsky.feed.defs#blockedPost') {
90
+
const uri = parent.uri;
91
+
92
+
ancestors.push({ id: uri, type: 'blocked', uri: uri });
93
+
} else if (type === 'app.bsky.feed.defs#notFoundPost') {
94
+
const uri = parent.uri;
95
+
96
+
ancestors.push({ id: uri, type: 'nonexistent', uri: uri });
97
+
} else if (type === 'app.bsky.feed.defs#threadViewPost') {
98
+
const post = parent.post;
99
+
100
+
ancestors.push({ id: post.uri, type: 'post', post: post, prev: true, next: true });
101
+
parent = parent.parent;
102
+
103
+
continue;
104
+
}
105
+
106
+
break;
107
+
}
108
+
109
+
{
110
+
const last = ancestors[ancestors.length - 1];
111
+
112
+
if (last && last.type === 'post') {
113
+
const post = last.post;
114
+
const reply = (post.record as AppBskyFeedPost.Record).reply;
115
+
116
+
if (reply) {
117
+
const uri = reply.parent.uri;
118
+
119
+
ancestors.push({ id: uri, type: 'overflow', uri: uri });
120
+
} else {
121
+
last.prev = false;
122
+
}
123
+
}
124
+
}
125
+
126
+
ancestors.reverse();
127
+
}
128
+
129
+
{
130
+
const traverse = (
131
+
parent: AppBskyFeedDefs.PostView,
132
+
replies: AppBskyFeedDefs.ThreadViewPost['replies'] | undefined,
133
+
depth: number,
134
+
lines: LineType[],
135
+
): DescendantItem[] => {
136
+
if (!replies || replies.length === 0) {
137
+
if (depth !== 0 && parent.replyCount) {
138
+
return [
139
+
{
140
+
id: 'overflow-' + parent.uri,
141
+
type: 'overflow',
142
+
uri: parent.uri,
143
+
lines: treeView ? lines.concat(LineType.UP_RIGHT) : lines,
144
+
},
145
+
];
146
+
}
147
+
148
+
return [];
149
+
}
150
+
151
+
// Filter the replies to only what we want
152
+
const items = replies.filter(
153
+
(x): x is Brand.Union<AppBskyFeedDefs.ThreadViewPost | AppBskyFeedDefs.BlockedPost> => {
154
+
const type = x.$type;
155
+
156
+
return type === 'app.bsky.feed.defs#threadViewPost' || type === 'app.bsky.feed.defs#blockedPost';
157
+
},
158
+
);
159
+
160
+
// Sort the replies
161
+
const did = parent.author.did;
162
+
items.sort((a, b) => {
163
+
if (a.$type !== 'app.bsky.feed.defs#threadViewPost') {
164
+
return 1;
165
+
}
166
+
if (b.$type !== 'app.bsky.feed.defs#threadViewPost') {
167
+
return -1;
168
+
}
169
+
170
+
const aPost = a.post;
171
+
const aAuthor = aPost.author;
172
+
const aIndexed = new Date(aPost.indexedAt).getTime();
173
+
174
+
const bPost = b.post;
175
+
const bAuthor = bPost.author;
176
+
const bIndexed = new Date(aPost.indexedAt).getTime();
177
+
178
+
// Prioritize replies from parent's author
179
+
{
180
+
const aIsByOp = aAuthor.did === did;
181
+
const bIsByOp = bAuthor.did === did;
182
+
183
+
if (aIsByOp && bIsByOp) {
184
+
// Prioritize oldest first for own reply
185
+
return aIndexed - bIndexed;
186
+
} else if (aIsByOp) {
187
+
return -1;
188
+
} else if (bIsByOp) {
189
+
return 1;
190
+
}
191
+
}
192
+
193
+
return getHotness(bPost, bIndexed) - getHotness(aPost, aIndexed);
194
+
});
195
+
196
+
// Iterate through the replies
197
+
const array: DescendantItem[] = [];
198
+
for (let idx = 0, len = items.length; idx < len; idx++) {
199
+
const reply = items[idx];
200
+
const type = reply.$type;
201
+
202
+
const end = idx === len - 1;
203
+
const nlines =
204
+
treeView && depth !== 0 ? lines.concat(end ? LineType.UP_RIGHT : LineType.VERTICAL_RIGHT) : lines;
205
+
206
+
if (type === 'app.bsky.feed.defs#threadViewPost') {
207
+
const post = reply.post;
208
+
const children = traverse(
209
+
post,
210
+
reply.replies,
211
+
depth + 1,
212
+
treeView && depth !== 0 ? lines.concat(end ? LineType.NONE : LineType.VERTICAL) : lines,
213
+
);
214
+
215
+
array.push({
216
+
id: post.uri,
217
+
type: 'post',
218
+
post: post,
219
+
prev: depth !== 0,
220
+
next: children.length !== 0,
221
+
lines: nlines,
222
+
});
223
+
224
+
push(array, children);
225
+
} else if (type === 'app.bsky.feed.defs#blockedPost') {
226
+
array.push({
227
+
id: reply.uri,
228
+
type: 'blocked',
229
+
uri: reply.uri,
230
+
lines: nlines,
231
+
});
232
+
}
233
+
234
+
if (!treeView && depth !== 0) {
235
+
break;
236
+
}
237
+
}
238
+
239
+
return array;
240
+
};
241
+
242
+
descendants = traverse(thread.post, thread.replies, 0, []);
243
+
}
244
+
245
+
return {
246
+
post: thread.post,
247
+
ancestors: ancestors,
248
+
descendants: descendants,
249
+
};
250
+
};
251
+
252
+
const push = <T>(target: T[], source: T[]) => {
253
+
for (let idx = 0, len = source.length; idx < len; idx++) {
254
+
const item = source[idx];
255
+
target.push(item);
256
+
}
257
+
};
258
+
259
+
// https://github.com/bluesky-social/social-app/blob/e9a792e4c1e85760fd073def21aa9e921e3afa3c/src/state/queries/post-thread.ts#L276
260
+
const getHotness = (post: AppBskyFeedDefs.PostView, indexedAt: number) => {
261
+
const hoursAgo = (Date.now() - indexedAt) / (1000 * 60 * 60);
262
+
263
+
const likeCount = post.likeCount ?? 0;
264
+
const likeOrder = Math.log(3 + likeCount);
265
+
const timePenaltyExponent = 1.5 + 1.5 / (1 + Math.log(1 + likeCount));
266
+
const timePenalty = Math.pow(hoursAgo + 2, timePenaltyExponent);
267
+
268
+
return likeOrder / timePenalty;
269
+
};
+155
src/lib/models/timeline.ts
+155
src/lib/models/timeline.ts
···
1
+
import type { AppBskyFeedDefs } from '@atcute/client/lexicons';
2
+
3
+
export type TimelineItem = AppBskyFeedDefs.FeedViewPost;
4
+
5
+
// #region TimelineSlice
6
+
export interface TimelineSlice {
7
+
items: TimelineItem[];
8
+
}
9
+
10
+
// #region UiTimelineItem
11
+
const enum TimelineFlags {
12
+
HAS_PREV = 1 << 0,
13
+
HAS_NEXT = 1 << 1,
14
+
IS_REPOSTED = 1 << 2,
15
+
IS_PINNED = 1 << 3,
16
+
}
17
+
18
+
export interface UiTimelineItem extends TimelineItem {
19
+
id: string;
20
+
prev: boolean;
21
+
next: boolean;
22
+
}
23
+
24
+
// #region Filters
25
+
export type SliceFilter = (slice: TimelineSlice) => boolean | TimelineSlice[];
26
+
export type PostFilter = (item: TimelineItem) => boolean;
27
+
28
+
const isNextInThread = (slice: TimelineSlice, item: TimelineItem) => {
29
+
const items = slice.items;
30
+
const last = items[items.length - 1];
31
+
32
+
const parent = item.reply?.parent;
33
+
34
+
return parent?.$type === 'app.bsky.feed.defs#postView' && last.post.cid == parent.cid;
35
+
};
36
+
37
+
const isFirstInThread = (slice: TimelineSlice, item: TimelineItem) => {
38
+
const items = slice.items;
39
+
const first = items[0];
40
+
41
+
const parent = first.reply?.parent;
42
+
43
+
return parent?.$type === 'app.bsky.feed.defs#postView' && parent.cid === item.post.cid;
44
+
};
45
+
46
+
export const createJoinedItems = (
47
+
arr: TimelineItem[],
48
+
filterSlice?: SliceFilter,
49
+
filterPost?: PostFilter,
50
+
): UiTimelineItem[] => {
51
+
let slices: TimelineSlice[] = [];
52
+
let jlen = 0;
53
+
54
+
// arrange the posts into connected slices
55
+
loop: for (let i = arr.length - 1; i >= 0; i--) {
56
+
const item = arr[i];
57
+
58
+
if (filterPost && !filterPost(item)) {
59
+
continue;
60
+
}
61
+
62
+
// if we find a matching slice and it's currently not in front, then bump
63
+
// it to the front. this is so that new reply don't get buried away because
64
+
// there's multiple posts separating it and the parent post.
65
+
for (let j = 0; j < jlen; j++) {
66
+
const slice = slices[j];
67
+
68
+
// skip, we already have too much.
69
+
if (slice.items.length >= 7) {
70
+
continue;
71
+
}
72
+
73
+
if (isFirstInThread(slice, item)) {
74
+
slice.items.unshift(item);
75
+
76
+
if (j !== 0) {
77
+
slices.splice(j, 1);
78
+
slices.unshift(slice);
79
+
}
80
+
81
+
continue loop;
82
+
} else if (isNextInThread(slice, item)) {
83
+
slice.items.push(item);
84
+
85
+
if (j !== 0) {
86
+
slices.splice(j, 1);
87
+
slices.unshift(slice);
88
+
}
89
+
90
+
continue loop;
91
+
}
92
+
}
93
+
94
+
slices.unshift({ items: [item] });
95
+
jlen++;
96
+
}
97
+
98
+
if (filterSlice && jlen > 0) {
99
+
const unfiltered = slices;
100
+
slices = [];
101
+
102
+
for (let j = 0; j < jlen; j++) {
103
+
const slice = unfiltered[j];
104
+
const result = filterSlice(slice);
105
+
106
+
if (result) {
107
+
if (Array.isArray(result)) {
108
+
for (let k = 0, klen = result.length; k < klen; k++) {
109
+
const slice = result[k];
110
+
slices.push(slice);
111
+
}
112
+
} else {
113
+
slices.push(slice);
114
+
}
115
+
}
116
+
}
117
+
}
118
+
119
+
return slices.flatMap((slice) => {
120
+
const arr = slice.items;
121
+
const len = arr.length;
122
+
123
+
return arr.map((item, idx): UiTimelineItem => {
124
+
const post = item.post;
125
+
const reason = item.reason;
126
+
127
+
let flags = 0;
128
+
129
+
if (idx !== 0) {
130
+
flags |= TimelineFlags.HAS_PREV;
131
+
}
132
+
if (idx !== len - 1) {
133
+
flags |= TimelineFlags.HAS_NEXT;
134
+
}
135
+
136
+
switch (reason?.$type) {
137
+
case 'app.bsky.feed.defs#reasonRepost': {
138
+
flags |= TimelineFlags.IS_REPOSTED;
139
+
break;
140
+
}
141
+
case 'app.bsky.feed.defs#reasonPin': {
142
+
flags |= TimelineFlags.IS_PINNED;
143
+
break;
144
+
}
145
+
}
146
+
147
+
return {
148
+
...item,
149
+
id: `${post.author.did}-${post.cid}-${flags}`,
150
+
prev: !!(flags & TimelineFlags.HAS_PREV),
151
+
next: !!(flags & TimelineFlags.HAS_NEXT),
152
+
};
153
+
});
154
+
});
155
+
};
+12
src/lib/queries/handle.ts
+12
src/lib/queries/handle.ts
···
1
+
import type { XRPC } from '@atcute/client';
2
+
3
+
import type { Did } from '$lib/types/identity';
4
+
5
+
export const resolveHandle = async ({ rpc, handle }: { rpc: XRPC; handle: string }): Promise<Did> => {
6
+
const { data } = await rpc.get('com.atproto.identity.resolveHandle', {
7
+
params: { handle },
8
+
});
9
+
10
+
// because my types are stricter than atcute's
11
+
return data.did as Did;
12
+
};
+236
src/lib/queries/timeline.ts
+236
src/lib/queries/timeline.ts
···
1
+
import type { XRPC } from '@atcute/client';
2
+
import type {
3
+
AppBskyActorDefs,
4
+
AppBskyEmbedRecord,
5
+
AppBskyFeedDefs,
6
+
AppBskyFeedGetTimeline,
7
+
AppBskyFeedPost,
8
+
} from '@atcute/client/lexicons';
9
+
10
+
import {
11
+
createJoinedItems,
12
+
type PostFilter,
13
+
type SliceFilter,
14
+
type TimelineItem,
15
+
type TimelineSlice,
16
+
type UiTimelineItem,
17
+
} from '$lib/models/timeline';
18
+
import type { AtUri } from '$lib/types/at-uri';
19
+
import type { Did } from '$lib/types/identity';
20
+
import { assertNever } from '$lib/utils/invariant';
21
+
22
+
type PostRecord = AppBskyFeedPost.Record;
23
+
24
+
export const enum TimelineType {
25
+
PROFILE,
26
+
CUSTOM_FEED,
27
+
USER_LIST,
28
+
}
29
+
30
+
export const enum ProfileFilter {
31
+
POSTS,
32
+
POSTS_WITH_REPLIES,
33
+
MEDIA,
34
+
}
35
+
36
+
export interface ProfileTimelineParams {
37
+
type: TimelineType.PROFILE;
38
+
actor: Did;
39
+
filter: ProfileFilter;
40
+
cursor?: string;
41
+
}
42
+
43
+
export interface CustomFeedTimelineParams {
44
+
type: TimelineType.CUSTOM_FEED;
45
+
feed: AtUri;
46
+
cursor?: string;
47
+
}
48
+
49
+
export interface UserListTimelineParams {
50
+
type: TimelineType.USER_LIST;
51
+
list: AtUri;
52
+
cursor?: string;
53
+
}
54
+
55
+
export type TimelineParams = ProfileTimelineParams | CustomFeedTimelineParams | UserListTimelineParams;
56
+
57
+
export interface TimelinePage {
58
+
cursor: string | undefined;
59
+
items: UiTimelineItem[];
60
+
}
61
+
62
+
const PAGE_LIMIT = 50;
63
+
64
+
export const fetchTimeline = async ({
65
+
rpc,
66
+
params,
67
+
}: {
68
+
rpc: XRPC;
69
+
params: TimelineParams;
70
+
}): Promise<TimelinePage> => {
71
+
let sliceFilter: SliceFilter | undefined;
72
+
let postFilter: PostFilter | undefined;
73
+
74
+
let timeline: AppBskyFeedGetTimeline.Output;
75
+
76
+
switch (params.type) {
77
+
case TimelineType.PROFILE: {
78
+
const { data } = await rpc.get('app.bsky.feed.getAuthorFeed', {
79
+
params: {
80
+
actor: params.actor,
81
+
cursor: params.cursor,
82
+
limit: PAGE_LIMIT,
83
+
includePins: params.filter !== ProfileFilter.MEDIA,
84
+
filter:
85
+
params.filter === ProfileFilter.MEDIA
86
+
? 'posts_with_media'
87
+
: params.filter === ProfileFilter.POSTS_WITH_REPLIES
88
+
? 'posts_with_replies'
89
+
: 'posts_and_author_threads',
90
+
},
91
+
});
92
+
93
+
timeline = data;
94
+
95
+
if (params.filter === ProfileFilter.POSTS) {
96
+
sliceFilter = createProfileSliceFilter(params.actor);
97
+
}
98
+
99
+
break;
100
+
}
101
+
case TimelineType.CUSTOM_FEED: {
102
+
const { data } = await rpc.get('app.bsky.feed.getFeed', {
103
+
params: {
104
+
feed: params.feed,
105
+
cursor: params.cursor,
106
+
limit: PAGE_LIMIT,
107
+
},
108
+
});
109
+
110
+
timeline = {
111
+
// Discover feed, wooo.
112
+
cursor: data.cursor && data.cursor.length <= 5_000 ? data.cursor : undefined,
113
+
feed: data.feed,
114
+
};
115
+
116
+
break;
117
+
}
118
+
case TimelineType.USER_LIST: {
119
+
const { data } = await rpc.get('app.bsky.feed.getListFeed', {
120
+
params: {
121
+
list: params.list,
122
+
cursor: params.cursor,
123
+
limit: PAGE_LIMIT,
124
+
},
125
+
});
126
+
127
+
timeline = data;
128
+
break;
129
+
}
130
+
default: {
131
+
assertNever(params);
132
+
}
133
+
}
134
+
135
+
const page: TimelinePage = {
136
+
// Prevent fetching the same data over and over
137
+
cursor: timeline.cursor !== params.cursor ? timeline.cursor : undefined,
138
+
items: createJoinedItems(timeline.feed, sliceFilter, postFilter),
139
+
};
140
+
141
+
return page;
142
+
};
143
+
144
+
// #region Post filters
145
+
146
+
// #region Slice filters
147
+
const createProfileSliceFilter = (did: Did): SliceFilter | undefined => {
148
+
return (slice) => {
149
+
const items = slice.items;
150
+
const first = items[0];
151
+
152
+
const reply = first.reply;
153
+
const reason = first.reason;
154
+
155
+
// Skip any posts that doesn't seem to look like a self-thread
156
+
if (reply && (!reason || reason.$type !== 'app.bsky.feed.defs#reasonRepost')) {
157
+
for (const author of getReplyAuthors(reply)) {
158
+
if (!author) {
159
+
continue;
160
+
}
161
+
162
+
if (author.did !== did) {
163
+
return yankReposts(items);
164
+
}
165
+
}
166
+
}
167
+
168
+
return true;
169
+
};
170
+
};
171
+
172
+
// #region Utilities
173
+
/** Get the reposts out of the gutter */
174
+
const yankReposts = (items: TimelineItem[]): TimelineSlice[] | false => {
175
+
let slices: TimelineSlice[] | false = false;
176
+
let last: TimelineItem[] | undefined;
177
+
178
+
for (let idx = 0, len = items.length; idx < len; idx++) {
179
+
const item = items[idx];
180
+
const reason = item.reason;
181
+
182
+
if (reason && reason.$type === 'app.bsky.feed.defs#reasonRepost') {
183
+
if (last) {
184
+
last.push(item);
185
+
} else {
186
+
(slices ||= []).push({ items: (last = [item]) });
187
+
}
188
+
} else {
189
+
last = undefined;
190
+
}
191
+
}
192
+
193
+
return slices;
194
+
};
195
+
196
+
const getReplyAuthors = ({ root, grandparentAuthor, parent }: AppBskyFeedDefs.ReplyRef) => {
197
+
const authors: AppBskyActorDefs.ProfileViewBasic[] = [];
198
+
199
+
if (root.$type === 'app.bsky.feed.defs#postView') {
200
+
authors.push(root.author);
201
+
}
202
+
203
+
if (grandparentAuthor) {
204
+
authors.push(grandparentAuthor);
205
+
}
206
+
207
+
if (parent.$type === 'app.bsky.feed.defs#postView') {
208
+
authors.push(parent.author);
209
+
}
210
+
211
+
return authors;
212
+
};
213
+
214
+
const getRecordEmbed = (embed: PostRecord['embed']): AppBskyEmbedRecord.Main | undefined => {
215
+
if (embed) {
216
+
if (embed.$type === 'app.bsky.embed.record') {
217
+
return embed;
218
+
}
219
+
220
+
if (embed.$type === 'app.bsky.embed.recordWithMedia') {
221
+
return embed.record;
222
+
}
223
+
}
224
+
};
225
+
226
+
const getRecordEmbedView = (embed: AppBskyFeedDefs.PostView['embed']) => {
227
+
if (embed) {
228
+
if (embed.$type === 'app.bsky.embed.record#view') {
229
+
return embed.record;
230
+
}
231
+
232
+
if (embed.$type === 'app.bsky.embed.recordWithMedia#view') {
233
+
return embed.record.record;
234
+
}
235
+
}
236
+
};
+50
src/lib/styles/app.css
+50
src/lib/styles/app.css
···
1
+
:root {
2
+
--accent: #1d9bf0;
3
+
--accent-text: #ffffff;
4
+
5
+
--text-primary: #0f1419;
6
+
--text-blurb: #536471;
7
+
--text-link: var(--accent);
8
+
9
+
--bg-slate: #e6ecf0;
10
+
--bg-primary: #ffffff;
11
+
--bg-secondary: #cfd9de;
12
+
13
+
--divider-sm: #eff3f4;
14
+
--divider-md: #cfd9de;
15
+
16
+
--tap: var(--text-primary);
17
+
}
18
+
19
+
:root {
20
+
--tap-sm: rgb(from var(--tap) r g b / 0.03);
21
+
--tap-sm-pressed: rgb(from var(--tap) r g b / 0.07);
22
+
--tap-md: rgb(from var(--tap) r g b / 0.1);
23
+
--tap-md-pressed: rgb(from var(--tap) r g b / 0.2);
24
+
}
25
+
26
+
body {
27
+
background: var(--bg-slate);
28
+
overflow-y: scroll;
29
+
color: var(--text-primary);
30
+
font-size: 0.875rem;
31
+
line-height: 1.25rem;
32
+
font-family: sans-serif;
33
+
}
34
+
35
+
:where(*, *::before, *::after) {
36
+
box-sizing: border-box;
37
+
margin: 0;
38
+
padding: 0;
39
+
}
40
+
41
+
:where(a) {
42
+
color: var(--text-link);
43
+
text-decoration: none;
44
+
}
45
+
46
+
.sv-icon {
47
+
flex-shrink: 0;
48
+
width: 1em;
49
+
height: 1em;
50
+
}
+33
src/lib/types/at-uri.ts
+33
src/lib/types/at-uri.ts
···
1
+
import type { Records } from '@atcute/client/lexicons';
2
+
3
+
import { assert } from '$lib/utils/invariant';
4
+
5
+
import type { Did } from './identity';
6
+
7
+
export type AtUri = `at://${string}`;
8
+
9
+
export const ATURI_RE =
10
+
/^at:\/\/(did:[a-z]+:[a-zA-Z0-9._:%\-]*[a-zA-Z0-9._\-]|(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z][a-zA-Z0-9-]{0,61}[a-zA-Z0-9])\/([a-zA-Z0-9-.]+)\/((?!\.{1,2}$)[a-zA-Z0-9_~.:-]{1,512})(?:#(\/[a-zA-Z0-9._~:@!$&%')(*+,;=\-[\]/\\]*))?$/;
11
+
12
+
export interface ParsedAtUri {
13
+
repo: string;
14
+
collection: string;
15
+
rkey: string;
16
+
fragment: string | undefined;
17
+
}
18
+
19
+
export const parseAtUri = (str: string): ParsedAtUri => {
20
+
const match = ATURI_RE.exec(str);
21
+
assert(match !== null, `failed to parse at-uri for ${str}`);
22
+
23
+
return {
24
+
repo: match[1] as Did,
25
+
collection: match[2],
26
+
rkey: match[3],
27
+
fragment: match[4],
28
+
};
29
+
};
30
+
31
+
export const makeAtUri = (repo: string, collection: keyof Records | (string & {}), rkey: string) => {
32
+
return `at://${repo}/${collection}/${rkey}`;
33
+
};
+17
src/lib/types/identity.ts
+17
src/lib/types/identity.ts
···
1
+
export type Handle = `${string}.${string}`;
2
+
3
+
const HANDLE_RE =
4
+
/^(?=.{4,253}$)(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+([a-zA-Z][a-zA-Z0-9-]{0,61}[a-zA-Z0-9])$/;
5
+
6
+
export const isHandle = (input: string): input is Handle => {
7
+
return input.length >= 3 && input.length <= 253 && HANDLE_RE.test(input);
8
+
};
9
+
10
+
export type Did<TMethod extends string = string> = `did:${TMethod}:${string}`;
11
+
export type AtprotoDid = Did<'plc' | 'web'>;
12
+
13
+
export const DID_RE = /^(?=.{7,2048}$)did:([a-z]+):([a-zA-Z0-9._:%\-]*[a-zA-Z0-9._\-])$/;
14
+
15
+
export const isDid = (input: string): input is Did => {
16
+
return input.length >= 7 && input.length <= 2048 && DID_RE.test(input);
17
+
};
+11
src/lib/types/rkey.ts
+11
src/lib/types/rkey.ts
···
1
+
export const RECORD_KEY_RE = /(?!\.{1,2}$)[a-zA-Z0-9_~.:-]{1,512}/;
2
+
3
+
export const isRecordKey = (input: string) => {
4
+
return input.length >= 1 && input.length <= 512 && RECORD_KEY_RE.test(input);
5
+
};
6
+
7
+
export const TID_RE = /^[234567abcdefghij][234567abcdefghijklmnopqrstuvwxyz]{12}$/;
8
+
9
+
export const isTid = (input: string) => {
10
+
return input.length === 13 && TID_RE.test(input);
11
+
};
+142
src/lib/utils/intl/date.ts
+142
src/lib/utils/intl/date.ts
···
1
+
import { createSubscriber } from 'svelte/reactivity';
2
+
3
+
const reactiveNow = (() => {
4
+
let subscribed = false;
5
+
let now = 0;
6
+
7
+
const subscribe = createSubscriber((update) => {
8
+
// updates every ~minute
9
+
const interval = setInterval(() => {
10
+
now = Date.now();
11
+
update();
12
+
}, 60_000);
13
+
14
+
subscribed = true;
15
+
now = Date.now();
16
+
17
+
return () => {
18
+
clearInterval(interval);
19
+
subscribed = false;
20
+
};
21
+
});
22
+
23
+
return () => {
24
+
subscribe();
25
+
return subscribed ? now : Date.now();
26
+
};
27
+
})();
28
+
29
+
let startOfYear = 0;
30
+
let endOfYear = 0;
31
+
32
+
const fmtAbsoluteLong = new Intl.DateTimeFormat('en-US', { dateStyle: 'long', timeStyle: 'short' });
33
+
const fmtAbsShortWithYear = new Intl.DateTimeFormat('en-US', { dateStyle: 'medium' });
34
+
const fmtAbsShort = new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric' });
35
+
36
+
export const formatShortDate = (date: string | number): string => {
37
+
const inst = new Date(date);
38
+
const time = inst.getTime();
39
+
40
+
if (isNaN(time)) {
41
+
return 'N/A';
42
+
}
43
+
44
+
const now = Date.now();
45
+
if (now > endOfYear) {
46
+
const date = new Date(now);
47
+
48
+
date.setMonth(0, 1);
49
+
date.setHours(0, 0, 0);
50
+
startOfYear = date.getTime();
51
+
52
+
date.setFullYear(date.getFullYear() + 1, 0, 0);
53
+
date.setHours(23, 59, 59, 999);
54
+
endOfYear = date.getTime();
55
+
}
56
+
57
+
if (time >= startOfYear && time <= endOfYear) {
58
+
return fmtAbsShort.format(inst);
59
+
}
60
+
61
+
return fmtAbsShortWithYear.format(inst);
62
+
};
63
+
64
+
export const formatLongDate = (date: string | number): string => {
65
+
const inst = new Date(date);
66
+
67
+
if (isNaN(inst.getTime())) {
68
+
return 'N/A';
69
+
}
70
+
71
+
return fmtAbsoluteLong.format(inst);
72
+
};
73
+
74
+
const relativeFormatters: Record<string, Intl.NumberFormat> = {};
75
+
76
+
const SECOND = 1e3;
77
+
const NOW = SECOND * 10;
78
+
const MINUTE = SECOND * 60;
79
+
const HOUR = MINUTE * 60;
80
+
const DAY = HOUR * 24;
81
+
const WEEK = DAY * 7;
82
+
83
+
export const formatRelativeTime = (date: string | number): string => {
84
+
const time = new Date(date).getTime();
85
+
86
+
const now = reactiveNow();
87
+
const delta = now - time;
88
+
89
+
if (delta < -NOW || delta > WEEK) {
90
+
if (now > endOfYear) {
91
+
const date = new Date();
92
+
93
+
date.setMonth(0, 1);
94
+
date.setHours(0, 0, 0);
95
+
startOfYear = date.getTime();
96
+
97
+
date.setFullYear(date.getFullYear() + 1, 0, 0);
98
+
date.setHours(23, 59, 59, 999);
99
+
endOfYear = date.getTime();
100
+
}
101
+
102
+
// if it happened this year, don't show the year.
103
+
if (time >= startOfYear && time <= endOfYear) {
104
+
return fmtAbsShort.format(time);
105
+
}
106
+
107
+
return fmtAbsShortWithYear.format(time);
108
+
}
109
+
110
+
if (delta < NOW) {
111
+
return `now`;
112
+
}
113
+
114
+
{
115
+
let value: number;
116
+
let unit: Intl.RelativeTimeFormatUnit;
117
+
118
+
if (delta < MINUTE) {
119
+
value = Math.floor(delta / SECOND);
120
+
unit = 'second';
121
+
} else if (delta < HOUR) {
122
+
value = Math.floor(delta / MINUTE);
123
+
unit = 'minute';
124
+
} else if (delta < DAY) {
125
+
value = Math.floor(delta / HOUR);
126
+
unit = 'hour';
127
+
} else {
128
+
// use rounding, this handles the following scenario:
129
+
// - 2024-02-13T09:00Z <- 2024-02-15T07:00Z = 2d
130
+
value = Math.round(delta / DAY);
131
+
unit = 'day';
132
+
}
133
+
134
+
const formatter = (relativeFormatters[unit] ||= new Intl.NumberFormat('en-US', {
135
+
style: 'unit',
136
+
unit: unit,
137
+
unitDisplay: 'narrow',
138
+
}));
139
+
140
+
return formatter.format(Math.abs(value));
141
+
}
142
+
};
+18
src/lib/utils/intl/number.ts
+18
src/lib/utils/intl/number.ts
···
1
+
const long = new Intl.NumberFormat('en-US');
2
+
const compact = new Intl.NumberFormat('en-US', { notation: 'compact' });
3
+
4
+
export const formatCompactNumber = (value: number) => {
5
+
if (value < 1_000) {
6
+
return '' + value;
7
+
}
8
+
9
+
if (value < 100_000) {
10
+
return long.format(value);
11
+
}
12
+
13
+
return compact.format(value);
14
+
};
15
+
16
+
export const formatLongNumber = (value: number) => {
17
+
return long.format(value);
18
+
};
+13
src/lib/utils/invariant.ts
+13
src/lib/utils/invariant.ts
···
1
+
export function assert(condition: any, message?: string): asserts condition {
2
+
if (!condition) {
3
+
if (import.meta.env.DEV) {
4
+
throw new Error(`Assertion failed` + (message ? `: ${message}` : ``));
5
+
}
6
+
7
+
throw new Error(`Assertion failed`);
8
+
}
9
+
}
10
+
11
+
export function assertNever(value: never, message?: string): never {
12
+
assert(false, message);
13
+
}
+20
src/lib/utils/strings.ts
+20
src/lib/utils/strings.ts
···
1
+
export const truncateMiddle = (text: string, max: number): string => {
2
+
const len = text.length;
3
+
4
+
if (len <= max) {
5
+
return text;
6
+
}
7
+
8
+
const left = Math.ceil((max - 1) / 2);
9
+
const right = Math.floor((max - 1) / 2);
10
+
11
+
return text.slice(0, left) + '…' + text.slice(len - right);
12
+
};
13
+
14
+
export const truncateRight = (text: string, max: number): string => {
15
+
if (text.length <= max) {
16
+
return text;
17
+
}
18
+
19
+
return text.slice(0, max - 1) + '…';
20
+
};
+5
src/params/did.ts
+5
src/params/did.ts
+7
src/params/didOrHandle.ts
+7
src/params/didOrHandle.ts
+5
src/params/handle.ts
+5
src/params/handle.ts
+5
src/params/rkey.ts
+5
src/params/rkey.ts
+5
src/params/tid.ts
+5
src/params/tid.ts
+53
src/routes/(app)/(profile)/[actor=didOrHandle]/(timeline)/+layout.svelte
+53
src/routes/(app)/(profile)/[actor=didOrHandle]/(timeline)/+layout.svelte
···
1
+
<script lang="ts">
2
+
import type { ClassValue } from 'svelte/elements';
3
+
4
+
import { base } from '$app/paths';
5
+
import { page } from '$app/state';
6
+
7
+
import type { LayoutProps } from './$types';
8
+
9
+
const { data, children }: LayoutProps = $props();
10
+
11
+
const did = $derived(data.profile.did);
12
+
const currentRouteId = $derived(page.route.id);
13
+
14
+
const cn = (routeId: string): ClassValue => {
15
+
const id = `/(app)/(profile)/[actor=didOrHandle]/(timeline)${routeId}`;
16
+
return ['tab', currentRouteId === id && 'is-active'];
17
+
};
18
+
</script>
19
+
20
+
<div class="profile-tabs" data-sveltekit-keepfocus>
21
+
<a class={cn('')} href="{base}/{did}">Posts</a>
22
+
<a class={cn('/with_replies')} href="{base}/{did}/with_replies">Replies</a>
23
+
<a class={cn('/media')} href="{base}/{did}/media">Media</a>
24
+
</div>
25
+
26
+
{@render children()}
27
+
28
+
<style>
29
+
.profile-tabs {
30
+
display: flex;
31
+
position: sticky;
32
+
top: 0;
33
+
flex-wrap: wrap;
34
+
z-index: 1;
35
+
border-bottom: 1px solid var(--divider-sm);
36
+
background: var(--bg-primary);
37
+
}
38
+
39
+
.tab {
40
+
padding: 12px 16px;
41
+
font-weight: 600;
42
+
font-size: 1rem;
43
+
line-height: 1.5rem;
44
+
45
+
&:hover {
46
+
text-decoration: underline;
47
+
}
48
+
49
+
&.is-active {
50
+
color: var(--text-primary);
51
+
}
52
+
}
53
+
</style>
+20
src/routes/(app)/(profile)/[actor=didOrHandle]/(timeline)/+page.svelte
+20
src/routes/(app)/(profile)/[actor=didOrHandle]/(timeline)/+page.svelte
···
1
+
<script lang="ts">
2
+
import { page } from '$app/state';
3
+
import { PUBLIC_APP_NAME } from '$env/static/public';
4
+
import type { PageProps } from './$types';
5
+
6
+
import PageListing from '$lib/components/page/page-listing.svelte';
7
+
import PostFeedItem from '$lib/components/timeline/post-feed-item.svelte';
8
+
9
+
const { data }: PageProps = $props();
10
+
</script>
11
+
12
+
<svelte:head>
13
+
<title>@{data.profile.handle} — {PUBLIC_APP_NAME}</title>
14
+
</svelte:head>
15
+
16
+
<PageListing subject="timeline" root={!page.url.searchParams.get('cursor')} cursor={data.timeline.cursor}>
17
+
{#each data.timeline.items as item (item.id)}
18
+
<PostFeedItem {item} />
19
+
{/each}
20
+
</PageListing>
+31
src/routes/(app)/(profile)/[actor=didOrHandle]/(timeline)/+page.ts
+31
src/routes/(app)/(profile)/[actor=didOrHandle]/(timeline)/+page.ts
···
1
+
import { simpleFetchHandler, XRPC } from '@atcute/client';
2
+
3
+
import { PUBLIC_APPVIEW_URL } from '$env/static/public';
4
+
import { fetchTimeline, ProfileFilter, TimelineType } from '$lib/queries/timeline';
5
+
import { isDid, type Did } from '$lib/types/identity';
6
+
7
+
import type { PageLoad } from './$types';
8
+
9
+
export const load: PageLoad = async ({ url, params, fetch, parent }) => {
10
+
const rpc = new XRPC({ handler: simpleFetchHandler({ service: PUBLIC_APPVIEW_URL }) });
11
+
12
+
let did: Did;
13
+
if (isDid(params.actor)) {
14
+
did = params.actor;
15
+
} else {
16
+
const parentData = await parent();
17
+
did = parentData.profile.did as Did;
18
+
}
19
+
20
+
const timeline = await fetchTimeline({
21
+
rpc,
22
+
params: {
23
+
type: TimelineType.PROFILE,
24
+
actor: did,
25
+
filter: ProfileFilter.POSTS,
26
+
cursor: url.searchParams.get('cursor') || undefined,
27
+
},
28
+
});
29
+
30
+
return { timeline };
31
+
};
+20
src/routes/(app)/(profile)/[actor=didOrHandle]/(timeline)/media/+page.svelte
+20
src/routes/(app)/(profile)/[actor=didOrHandle]/(timeline)/media/+page.svelte
···
1
+
<script lang="ts">
2
+
import { page } from '$app/state';
3
+
import { PUBLIC_APP_NAME } from '$env/static/public';
4
+
import type { PageProps } from './$types';
5
+
6
+
import PageListing from '$lib/components/page/page-listing.svelte';
7
+
import PostFeedItem from '$lib/components/timeline/post-feed-item.svelte';
8
+
9
+
const { data }: PageProps = $props();
10
+
</script>
11
+
12
+
<svelte:head>
13
+
<title>@{data.profile.handle} — {PUBLIC_APP_NAME}</title>
14
+
</svelte:head>
15
+
16
+
<PageListing subject="timeline" root={!page.url.searchParams.get('cursor')} cursor={data.timeline.cursor}>
17
+
{#each data.timeline.items as item (item.id)}
18
+
<PostFeedItem {item} />
19
+
{/each}
20
+
</PageListing>
+31
src/routes/(app)/(profile)/[actor=didOrHandle]/(timeline)/media/+page.ts
+31
src/routes/(app)/(profile)/[actor=didOrHandle]/(timeline)/media/+page.ts
···
1
+
import { simpleFetchHandler, XRPC } from '@atcute/client';
2
+
3
+
import { PUBLIC_APPVIEW_URL } from '$env/static/public';
4
+
import { fetchTimeline, ProfileFilter, TimelineType } from '$lib/queries/timeline';
5
+
import { isDid, type Did } from '$lib/types/identity';
6
+
7
+
import type { PageLoad } from './$types';
8
+
9
+
export const load: PageLoad = async ({ url, params, fetch, parent }) => {
10
+
const rpc = new XRPC({ handler: simpleFetchHandler({ service: PUBLIC_APPVIEW_URL }) });
11
+
12
+
let did: Did;
13
+
if (isDid(params.actor)) {
14
+
did = params.actor;
15
+
} else {
16
+
const parentData = await parent();
17
+
did = parentData.profile.did as Did;
18
+
}
19
+
20
+
const timeline = await fetchTimeline({
21
+
rpc,
22
+
params: {
23
+
type: TimelineType.PROFILE,
24
+
actor: did,
25
+
filter: ProfileFilter.MEDIA,
26
+
cursor: url.searchParams.get('cursor') || undefined,
27
+
},
28
+
});
29
+
30
+
return { timeline };
31
+
};
+20
src/routes/(app)/(profile)/[actor=didOrHandle]/(timeline)/with_replies/+page.svelte
+20
src/routes/(app)/(profile)/[actor=didOrHandle]/(timeline)/with_replies/+page.svelte
···
1
+
<script lang="ts">
2
+
import { page } from '$app/state';
3
+
import { PUBLIC_APP_NAME } from '$env/static/public';
4
+
import type { PageProps } from './$types';
5
+
6
+
import PageListing from '$lib/components/page/page-listing.svelte';
7
+
import PostFeedItem from '$lib/components/timeline/post-feed-item.svelte';
8
+
9
+
const { data }: PageProps = $props();
10
+
</script>
11
+
12
+
<svelte:head>
13
+
<title>@{data.profile.handle} — {PUBLIC_APP_NAME}</title>
14
+
</svelte:head>
15
+
16
+
<PageListing subject="timeline" root={!page.url.searchParams.get('cursor')} cursor={data.timeline.cursor}>
17
+
{#each data.timeline.items as item (item.id)}
18
+
<PostFeedItem {item} />
19
+
{/each}
20
+
</PageListing>
+31
src/routes/(app)/(profile)/[actor=didOrHandle]/(timeline)/with_replies/+page.ts
+31
src/routes/(app)/(profile)/[actor=didOrHandle]/(timeline)/with_replies/+page.ts
···
1
+
import { simpleFetchHandler, XRPC } from '@atcute/client';
2
+
3
+
import { PUBLIC_APPVIEW_URL } from '$env/static/public';
4
+
import { fetchTimeline, ProfileFilter, TimelineType } from '$lib/queries/timeline';
5
+
import { isDid, type Did } from '$lib/types/identity';
6
+
7
+
import type { PageLoad } from './$types';
8
+
9
+
export const load: PageLoad = async ({ url, params, fetch, parent }) => {
10
+
const rpc = new XRPC({ handler: simpleFetchHandler({ service: PUBLIC_APPVIEW_URL }) });
11
+
12
+
let did: Did;
13
+
if (isDid(params.actor)) {
14
+
did = params.actor;
15
+
} else {
16
+
const parentData = await parent();
17
+
did = parentData.profile.did as Did;
18
+
}
19
+
20
+
const timeline = await fetchTimeline({
21
+
rpc,
22
+
params: {
23
+
type: TimelineType.PROFILE,
24
+
actor: did,
25
+
filter: ProfileFilter.POSTS_WITH_REPLIES,
26
+
cursor: url.searchParams.get('cursor') || undefined,
27
+
},
28
+
});
29
+
30
+
return { timeline };
31
+
};
+135
src/routes/(app)/(profile)/[actor=didOrHandle]/+layout.svelte
+135
src/routes/(app)/(profile)/[actor=didOrHandle]/+layout.svelte
···
1
+
<script lang="ts">
2
+
import { base } from '$app/paths';
3
+
import { formatCompactNumber } from '$lib/utils/intl/number';
4
+
import type { LayoutProps } from './$types';
5
+
6
+
import ProfileAside from './components/profile-aside.svelte';
7
+
8
+
const { children, data }: LayoutProps = $props();
9
+
10
+
const profile = $derived(data.profile);
11
+
const did = $derived(profile.did);
12
+
13
+
const postCount = $derived(profile.postsCount ?? 0);
14
+
</script>
15
+
16
+
{#key profile.did}
17
+
<div class="profile-layout">
18
+
<div class="banner">
19
+
{#if profile.banner}
20
+
<img loading="lazy" src={profile.banner} alt="" class="banner-image" />
21
+
{/if}
22
+
</div>
23
+
24
+
<div class="aside">
25
+
<ProfileAside {profile} />
26
+
27
+
<div class="associations">
28
+
<a class="association" href="{base}/{did}">
29
+
<span class="association-count">{formatCompactNumber(postCount)}</span>
30
+
{postCount === 1 ? `post` : `posts`}
31
+
</a>
32
+
33
+
{#if profile.associated?.feedgens}
34
+
{@const count = profile.associated.feedgens}
35
+
36
+
<a class="association" href="{base}/{did}/feeds">
37
+
<span class="association-count">{formatCompactNumber(count)}</span>
38
+
{count === 1 ? `feed` : `feeds`}
39
+
</a>
40
+
{/if}
41
+
{#if profile.associated?.lists}
42
+
{@const count = profile.associated.lists}
43
+
44
+
<a class="association" href="{base}/{did}/lists">
45
+
<span class="association-count">{formatCompactNumber(count)}</span>
46
+
{count === 1 ? `list` : `lists`}
47
+
</a>
48
+
{/if}
49
+
{#if profile.associated?.starterPacks}
50
+
{@const count = profile.associated.starterPacks}
51
+
52
+
<a class="association" href="{base}/{did}/packs">
53
+
<span class="association-count">{formatCompactNumber(count)}</span>
54
+
{count === 1 ? `starter pack` : `starter packs`}
55
+
</a>
56
+
{/if}
57
+
</div>
58
+
</div>
59
+
60
+
<div class="main">
61
+
{@render children()}
62
+
</div>
63
+
</div>
64
+
{/key}
65
+
66
+
<style>
67
+
.profile-layout {
68
+
display: grid;
69
+
grid-template-columns: minmax(0, 1fr);
70
+
grid-template-areas: 'banner' 'aside' 'main';
71
+
justify-content: center;
72
+
gap: 8px;
73
+
margin: 24px auto 0;
74
+
max-width: 480px;
75
+
76
+
@media (width >= 640px) {
77
+
grid-template-columns: minmax(255px, 320px) minmax(0, 600px);
78
+
grid-template-areas: 'banner banner' 'aside main';
79
+
max-width: 960px;
80
+
}
81
+
}
82
+
83
+
.banner {
84
+
grid-area: banner;
85
+
background: var(--bg-secondary);
86
+
aspect-ratio: 3 / 1;
87
+
overflow: hidden;
88
+
}
89
+
.banner-image {
90
+
width: 100%;
91
+
height: 100%;
92
+
font-size: 0;
93
+
}
94
+
95
+
.aside {
96
+
display: flex;
97
+
grid-area: aside;
98
+
flex-direction: column;
99
+
gap: 8px;
100
+
101
+
@media (width >= 640px) {
102
+
position: sticky;
103
+
top: 0;
104
+
max-height: 100dvh;
105
+
overflow-y: auto;
106
+
}
107
+
}
108
+
109
+
.associations {
110
+
display: flex;
111
+
flex-direction: column;
112
+
background: var(--bg-primary);
113
+
}
114
+
.association {
115
+
padding: 10px 16px;
116
+
color: var(--text-blurb);
117
+
118
+
& + & {
119
+
border-top: 1px solid var(--divider-sm);
120
+
}
121
+
122
+
&:hover {
123
+
background: var(--tap-sm);
124
+
}
125
+
}
126
+
.association-count {
127
+
color: var(--text-primary);
128
+
font-weight: 600;
129
+
}
130
+
131
+
.main {
132
+
grid-area: main;
133
+
padding-bottom: 24px;
134
+
}
135
+
</style>
+19
src/routes/(app)/(profile)/[actor=didOrHandle]/+layout.ts
+19
src/routes/(app)/(profile)/[actor=didOrHandle]/+layout.ts
···
1
+
import { XRPC, simpleFetchHandler } from '@atcute/client';
2
+
3
+
import { PUBLIC_APPVIEW_URL } from '$env/static/public';
4
+
5
+
import type { LayoutLoad } from './$types';
6
+
7
+
export const load: LayoutLoad = async ({ params, fetch }) => {
8
+
const rpc = new XRPC({ handler: simpleFetchHandler({ service: PUBLIC_APPVIEW_URL }) });
9
+
10
+
const { data } = await rpc.get('app.bsky.actor.getProfile', {
11
+
params: {
12
+
actor: params.actor,
13
+
},
14
+
});
15
+
16
+
return {
17
+
profile: data,
18
+
};
19
+
};
+99
src/routes/(app)/(profile)/[actor=didOrHandle]/components/profile-aside.svelte
+99
src/routes/(app)/(profile)/[actor=didOrHandle]/components/profile-aside.svelte
···
1
+
<script lang="ts">
2
+
import type { AppBskyActorDefs } from '@atcute/client/lexicons';
3
+
4
+
import { base } from '$app/paths';
5
+
6
+
import RichtextRawRenderer from '$lib/components/richtext-raw-renderer.svelte';
7
+
import { formatCompactNumber } from '$lib/utils/intl/number';
8
+
9
+
interface Props {
10
+
profile: AppBskyActorDefs.ProfileViewDetailed;
11
+
}
12
+
13
+
const { profile }: Props = $props();
14
+
15
+
const did = $derived(profile.did);
16
+
</script>
17
+
18
+
<div class="profile-aside">
19
+
<div class="avatar-wrapper">
20
+
<img loading="lazy" src={profile.avatar} alt="" class="avatar" />
21
+
</div>
22
+
23
+
<div class="name-wrapper">
24
+
<p dir="auto" class="display-name">{profile.displayName?.trim() || profile.handle.slice(0, 64)}</p>
25
+
<p class="handle">@{profile.handle}</p>
26
+
</div>
27
+
28
+
{#if profile.description?.trim()}
29
+
<RichtextRawRenderer text={profile.description} />
30
+
{/if}
31
+
32
+
<div class="stats">
33
+
<a class="stat-entry" href="{base}/{did}/followers">
34
+
<span class="stat-count">{formatCompactNumber(profile.followersCount || 0)}</span>
35
+
<span> {profile.followersCount === 1 ? `Follower` : `Followers`}</span>
36
+
</a>
37
+
38
+
<a class="stat-entry" href="{base}/{did}/following">
39
+
<span class="stat-count">{formatCompactNumber(profile.followsCount || 0)}</span>
40
+
<span> Following</span>
41
+
</a>
42
+
</div>
43
+
</div>
44
+
45
+
<style>
46
+
.profile-aside {
47
+
display: flex;
48
+
flex-direction: column;
49
+
gap: 8px;
50
+
background: var(--bg-primary);
51
+
padding: 16px;
52
+
min-width: 0;
53
+
}
54
+
55
+
.avatar-wrapper {
56
+
flex-shrink: 0;
57
+
border-radius: 50%;
58
+
background: var(--bg-secondary);
59
+
aspect-ratio: 1 / 1;
60
+
width: 100%;
61
+
max-width: 90px;
62
+
overflow: hidden;
63
+
}
64
+
.avatar {
65
+
width: 100%;
66
+
height: 100%;
67
+
object-fit: cover;
68
+
}
69
+
70
+
.display-name {
71
+
font-weight: 700;
72
+
font-size: 1.25rem;
73
+
line-height: 1.75rem;
74
+
overflow-wrap: break-word;
75
+
}
76
+
.handle {
77
+
color: var(--text-blurb);
78
+
overflow-wrap: break-word;
79
+
}
80
+
81
+
.stats {
82
+
display: flex;
83
+
flex-wrap: wrap;
84
+
gap: 20px;
85
+
86
+
min-width: 0;
87
+
}
88
+
.stat-entry {
89
+
color: var(--text-blurb);
90
+
91
+
&:hover {
92
+
text-decoration: underline;
93
+
}
94
+
}
95
+
.stat-count {
96
+
color: var(--text-primary);
97
+
font-weight: 700;
98
+
}
99
+
</style>
+23
src/routes/(app)/(profile)/[actor=didOrHandle]/followers/+page.svelte
+23
src/routes/(app)/(profile)/[actor=didOrHandle]/followers/+page.svelte
···
1
+
<script lang="ts">
2
+
import { page } from '$app/state';
3
+
import { PUBLIC_APP_NAME } from '$env/static/public';
4
+
import type { PageProps } from './$types';
5
+
6
+
import PageHeader from '$lib/components/page/page-header.svelte';
7
+
import PageListing from '$lib/components/page/page-listing.svelte';
8
+
import ProfileItem from '$lib/components/profiles/profile-item.svelte';
9
+
10
+
const { data }: PageProps = $props();
11
+
</script>
12
+
13
+
<svelte:head>
14
+
<title>Users following @{data.profile.handle} — {PUBLIC_APP_NAME}</title>
15
+
</svelte:head>
16
+
17
+
<PageHeader title="Followers" />
18
+
19
+
<PageListing subject="profiles" root={!page.url.searchParams.get('cursor')} cursor={data.followers.cursor}>
20
+
{#each data.followers.items as profile (profile.did)}
21
+
<ProfileItem item={profile} />
22
+
{/each}
23
+
</PageListing>
+18
src/routes/(app)/(profile)/[actor=didOrHandle]/followers/+page.ts
+18
src/routes/(app)/(profile)/[actor=didOrHandle]/followers/+page.ts
···
1
+
import { simpleFetchHandler, XRPC } from '@atcute/client';
2
+
3
+
import { PUBLIC_APPVIEW_URL } from '$env/static/public';
4
+
import type { PageLoad } from './$types';
5
+
6
+
export const load: PageLoad = async ({ url, params, fetch }) => {
7
+
const rpc = new XRPC({ handler: simpleFetchHandler({ service: PUBLIC_APPVIEW_URL }) });
8
+
9
+
const { data } = await rpc.get('app.bsky.graph.getFollowers', {
10
+
params: {
11
+
actor: params.actor,
12
+
limit: 50,
13
+
cursor: url.searchParams.get('cursor') || undefined,
14
+
},
15
+
});
16
+
17
+
return { followers: { cursor: data.cursor, items: data.followers } };
18
+
};
+23
src/routes/(app)/(profile)/[actor=didOrHandle]/following/+page.svelte
+23
src/routes/(app)/(profile)/[actor=didOrHandle]/following/+page.svelte
···
1
+
<script lang="ts">
2
+
import { page } from '$app/state';
3
+
import { PUBLIC_APP_NAME } from '$env/static/public';
4
+
import type { PageProps } from './$types';
5
+
6
+
import PageHeader from '$lib/components/page/page-header.svelte';
7
+
import PageListing from '$lib/components/page/page-listing.svelte';
8
+
import ProfileItem from '$lib/components/profiles/profile-item.svelte';
9
+
10
+
const { data }: PageProps = $props();
11
+
</script>
12
+
13
+
<svelte:head>
14
+
<title>Users followed by @{data.profile.handle} — {PUBLIC_APP_NAME}</title>
15
+
</svelte:head>
16
+
17
+
<PageHeader title="Following" />
18
+
19
+
<PageListing subject="profiles" root={!page.url.searchParams.get('cursor')} cursor={data.following.cursor}>
20
+
{#each data.following.items as profile (profile.did)}
21
+
<ProfileItem item={profile} />
22
+
{/each}
23
+
</PageListing>
+18
src/routes/(app)/(profile)/[actor=didOrHandle]/following/+page.ts
+18
src/routes/(app)/(profile)/[actor=didOrHandle]/following/+page.ts
···
1
+
import { simpleFetchHandler, XRPC } from '@atcute/client';
2
+
3
+
import { PUBLIC_APPVIEW_URL } from '$env/static/public';
4
+
import type { PageLoad } from './$types';
5
+
6
+
export const load: PageLoad = async ({ url, params, fetch }) => {
7
+
const rpc = new XRPC({ handler: simpleFetchHandler({ service: PUBLIC_APPVIEW_URL }) });
8
+
9
+
const { data } = await rpc.get('app.bsky.graph.getFollows', {
10
+
params: {
11
+
actor: params.actor,
12
+
limit: 50,
13
+
cursor: url.searchParams.get('cursor') || undefined,
14
+
},
15
+
});
16
+
17
+
return { following: { cursor: data.cursor, items: data.follows } };
18
+
};
+13
src/routes/(app)/+layout.svelte
+13
src/routes/(app)/+layout.svelte
+1
src/routes/(app)/+page.svelte
+1
src/routes/(app)/+page.svelte
···
1
+
<div>Hello</div>
+107
src/routes/(app)/[actor=didOrHandle]/[rkey=tid]/+page.svelte
+107
src/routes/(app)/[actor=didOrHandle]/[rkey=tid]/+page.svelte
···
1
+
<script lang="ts">
2
+
import type { AppBskyFeedPost } from '@atcute/client/lexicons';
3
+
4
+
import { PUBLIC_APP_NAME } from '$env/static/public';
5
+
import type { PageProps } from './$types';
6
+
7
+
import { createThreadData } from '$lib/models/thread';
8
+
import { truncateMiddle, truncateRight } from '$lib/utils/strings';
9
+
10
+
import MainPost from '$lib/components/threads/main-post.svelte';
11
+
import OverflowThreadItem from '$lib/components/threads/overflow-thread-item.svelte';
12
+
import PostThreadItem from '$lib/components/threads/post-thread-item.svelte';
13
+
14
+
const { data }: PageProps = $props();
15
+
16
+
const treeView = true;
17
+
const thread = $derived.by(() => {
18
+
return createThreadData({ thread: data.thread, treeView });
19
+
});
20
+
21
+
const title = $derived.by(() => {
22
+
const post = thread.post;
23
+
24
+
const author = `@${truncateMiddle(post.author.handle, 29)}`;
25
+
const content = truncateRight((post.record as AppBskyFeedPost.Record).text.trim(), 70);
26
+
27
+
return `${author}: "${content}" — ${PUBLIC_APP_NAME}`;
28
+
});
29
+
</script>
30
+
31
+
<svelte:head>
32
+
<title>{title}</title>
33
+
</svelte:head>
34
+
35
+
<div class={['thread-page', treeView ? 'is-tree' : 'is-flat']}>
36
+
<div class="ancestors">
37
+
{#each thread.ancestors as item (item.id)}
38
+
{#if item.type === 'post'}
39
+
<PostThreadItem {item} treeView={false} />
40
+
{:else if item.type === 'blocked'}
41
+
<div>blocked</div>
42
+
{:else if item.type === 'overflow'}
43
+
<OverflowThreadItem {item} treeView={false} descendant={false} />
44
+
{/if}
45
+
{/each}
46
+
</div>
47
+
48
+
<div class="thread">
49
+
<div class="main" id="main">
50
+
<MainPost post={thread.post} prev={thread.ancestors.length > 0} />
51
+
</div>
52
+
53
+
<div class="descendants">
54
+
{#each thread.descendants as item (item.id)}
55
+
{#if item.type === 'post'}
56
+
<PostThreadItem {item} {treeView} />
57
+
{:else if item.type === 'blocked'}
58
+
<div>blocked</div>
59
+
{:else if item.type === 'overflow'}
60
+
<OverflowThreadItem {item} {treeView} descendant={true} />
61
+
{/if}
62
+
{/each}
63
+
</div>
64
+
</div>
65
+
</div>
66
+
67
+
<style>
68
+
.thread-page {
69
+
display: flex;
70
+
flex-direction: column;
71
+
margin: 24px auto;
72
+
width: 100%;
73
+
max-width: 600px;
74
+
}
75
+
76
+
.thread {
77
+
min-height: calc(100dvh - (24px * 2));
78
+
}
79
+
80
+
.main,
81
+
.ancestors,
82
+
.descendants {
83
+
background: var(--bg-primary);
84
+
}
85
+
86
+
.main {
87
+
scroll-margin: 24px;
88
+
}
89
+
90
+
.ancestors {
91
+
&:empty {
92
+
display: none;
93
+
}
94
+
}
95
+
96
+
.descendants {
97
+
border-top: 1px solid var(--divider-md);
98
+
99
+
&:empty {
100
+
display: none;
101
+
}
102
+
103
+
.is-tree & {
104
+
padding: 4px 0;
105
+
}
106
+
}
107
+
</style>
+53
src/routes/(app)/[actor=didOrHandle]/[rkey=tid]/+page.ts
+53
src/routes/(app)/[actor=didOrHandle]/[rkey=tid]/+page.ts
···
1
+
import { simpleFetchHandler, XRPC, XRPCError } from '@atcute/client';
2
+
3
+
import { PUBLIC_APPVIEW_URL } from '$env/static/public';
4
+
import type { PageLoad } from './$types';
5
+
6
+
import { resolveHandle } from '$lib/queries/handle';
7
+
import { makeAtUri } from '$lib/types/at-uri';
8
+
import { isDid, type Did } from '$lib/types/identity';
9
+
10
+
export const load: PageLoad = async ({ params }) => {
11
+
const rpc = new XRPC({ handler: simpleFetchHandler({ service: PUBLIC_APPVIEW_URL }) });
12
+
13
+
let did: Did;
14
+
if (!isDid(params.actor)) {
15
+
did = await resolveHandle({ rpc, handle: params.actor });
16
+
} else {
17
+
did = params.actor;
18
+
}
19
+
20
+
const uri = makeAtUri(did, 'app.bsky.feed.post', params.rkey);
21
+
22
+
// TODO: look previous pages for an existing post
23
+
{
24
+
}
25
+
26
+
const { data } = await rpc.get('app.bsky.feed.getPostThread', {
27
+
params: {
28
+
uri: uri,
29
+
depth: 4,
30
+
parentHeight: 10,
31
+
},
32
+
});
33
+
34
+
const thread = data.thread;
35
+
36
+
switch (thread.$type) {
37
+
case 'app.bsky.feed.defs#notFoundPost': {
38
+
throw new XRPCError(400, {
39
+
kind: 'NotFound',
40
+
description: `Post not found: ${uri}`,
41
+
});
42
+
}
43
+
case 'app.bsky.feed.defs#blockedPost': {
44
+
// shouldn't happen?
45
+
throw new XRPCError(400, {
46
+
kind: 'NotFound',
47
+
description: `Blocked post: ${uri}`,
48
+
});
49
+
}
50
+
}
51
+
52
+
return { thread };
53
+
};
+1
src/routes/(app)/[actor=didOrHandle]/feeds/[rkey=rkey]/+page.svelte
+1
src/routes/(app)/[actor=didOrHandle]/feeds/[rkey=rkey]/+page.svelte
···
1
+
<div>feeds</div>
+1
src/routes/(app)/[actor=didOrHandle]/lists/[rkey=rkey]/+page.svelte
+1
src/routes/(app)/[actor=didOrHandle]/lists/[rkey=rkey]/+page.svelte
···
1
+
<div>lists</div>
+26
src/routes/(app)/[actor=did]/[rkey=tid]/likes/+page.svelte
+26
src/routes/(app)/[actor=did]/[rkey=tid]/likes/+page.svelte
···
1
+
<script lang="ts">
2
+
import { page } from '$app/state';
3
+
import { PUBLIC_APP_NAME } from '$env/static/public';
4
+
import type { PageProps } from './$types';
5
+
6
+
import PageContainer from '$lib/components/page/page-container.svelte';
7
+
import PageHeader from '$lib/components/page/page-header.svelte';
8
+
import PageListing from '$lib/components/page/page-listing.svelte';
9
+
import ProfileItem from '$lib/components/profiles/profile-item.svelte';
10
+
11
+
const { data }: PageProps = $props();
12
+
</script>
13
+
14
+
<svelte:head>
15
+
<title>Post liked by — {PUBLIC_APP_NAME}</title>
16
+
</svelte:head>
17
+
18
+
<PageContainer>
19
+
<PageHeader title="Liked by" />
20
+
21
+
<PageListing subject="profiles" root={!page.url.searchParams.get('cursor')} cursor={data.likes.cursor}>
22
+
{#each data.likes.items as profile (profile.did)}
23
+
<ProfileItem item={profile} />
24
+
{/each}
25
+
</PageListing>
26
+
</PageContainer>
+22
src/routes/(app)/[actor=did]/[rkey=tid]/likes/+page.ts
+22
src/routes/(app)/[actor=did]/[rkey=tid]/likes/+page.ts
···
1
+
import { simpleFetchHandler, XRPC } from '@atcute/client';
2
+
3
+
import { PUBLIC_APPVIEW_URL } from '$env/static/public';
4
+
import type { PageLoad } from './$types';
5
+
6
+
import { makeAtUri } from '$lib/types/at-uri';
7
+
8
+
export const load: PageLoad = async ({ url, params, fetch }) => {
9
+
const rpc = new XRPC({ handler: simpleFetchHandler({ service: PUBLIC_APPVIEW_URL }) });
10
+
11
+
const uri = makeAtUri(params.actor, 'app.bsky.feed.post', params.rkey);
12
+
13
+
const { data } = await rpc.get('app.bsky.feed.getLikes', {
14
+
params: {
15
+
uri,
16
+
limit: 50,
17
+
cursor: url.searchParams.get('cursor') || undefined,
18
+
},
19
+
});
20
+
21
+
return { likes: { cursor: data.cursor, items: data.likes.map((like) => like.actor) } };
22
+
};
+35
src/routes/(app)/[actor=did]/[rkey=tid]/quotes/+page.svelte
+35
src/routes/(app)/[actor=did]/[rkey=tid]/quotes/+page.svelte
···
1
+
<script lang="ts">
2
+
import { page } from '$app/state';
3
+
import { PUBLIC_APP_NAME } from '$env/static/public';
4
+
import type { PageProps } from './$types';
5
+
6
+
import PageContainer from '$lib/components/page/page-container.svelte';
7
+
import PageHeader from '$lib/components/page/page-header.svelte';
8
+
import PageListing from '$lib/components/page/page-listing.svelte';
9
+
import PostFeedItem from '$lib/components/timeline/post-feed-item.svelte';
10
+
11
+
const { data }: PageProps = $props();
12
+
</script>
13
+
14
+
<svelte:head>
15
+
<title>Quotes — {PUBLIC_APP_NAME}</title>
16
+
</svelte:head>
17
+
18
+
<PageContainer>
19
+
<PageHeader title="Quotes" />
20
+
21
+
<PageListing subject="posts" root={!page.url.searchParams.get('cursor')} cursor={data.quotes.cursor}>
22
+
{#each data.quotes.items as post (post.uri)}
23
+
<PostFeedItem
24
+
item={{
25
+
id: post.uri,
26
+
post,
27
+
reply: undefined,
28
+
reason: undefined,
29
+
next: false,
30
+
prev: false,
31
+
}}
32
+
/>
33
+
{/each}
34
+
</PageListing>
35
+
</PageContainer>
+22
src/routes/(app)/[actor=did]/[rkey=tid]/quotes/+page.ts
+22
src/routes/(app)/[actor=did]/[rkey=tid]/quotes/+page.ts
···
1
+
import { simpleFetchHandler, XRPC } from '@atcute/client';
2
+
3
+
import { PUBLIC_APPVIEW_URL } from '$env/static/public';
4
+
import type { PageLoad } from './$types';
5
+
6
+
import { makeAtUri } from '$lib/types/at-uri';
7
+
8
+
export const load: PageLoad = async ({ url, params, fetch }) => {
9
+
const rpc = new XRPC({ handler: simpleFetchHandler({ service: PUBLIC_APPVIEW_URL }) });
10
+
11
+
const uri = makeAtUri(params.actor, 'app.bsky.feed.post', params.rkey);
12
+
13
+
const { data } = await rpc.get('app.bsky.feed.getQuotes', {
14
+
params: {
15
+
uri,
16
+
limit: 50,
17
+
cursor: url.searchParams.get('cursor') || undefined,
18
+
},
19
+
});
20
+
21
+
return { quotes: { cursor: data.cursor, items: data.posts } };
22
+
};
+26
src/routes/(app)/[actor=did]/[rkey=tid]/reposts/+page.svelte
+26
src/routes/(app)/[actor=did]/[rkey=tid]/reposts/+page.svelte
···
1
+
<script lang="ts">
2
+
import { page } from '$app/state';
3
+
import { PUBLIC_APP_NAME } from '$env/static/public';
4
+
import type { PageProps } from './$types';
5
+
6
+
import PageContainer from '$lib/components/page/page-container.svelte';
7
+
import PageHeader from '$lib/components/page/page-header.svelte';
8
+
import PageListing from '$lib/components/page/page-listing.svelte';
9
+
import ProfileItem from '$lib/components/profiles/profile-item.svelte';
10
+
11
+
const { data }: PageProps = $props();
12
+
</script>
13
+
14
+
<svelte:head>
15
+
<title>Post reposted by — {PUBLIC_APP_NAME}</title>
16
+
</svelte:head>
17
+
18
+
<PageContainer>
19
+
<PageHeader title="Reposted by" />
20
+
21
+
<PageListing subject="profiles" root={!page.url.searchParams.get('cursor')} cursor={data.reposts.cursor}>
22
+
{#each data.reposts.items as profile (profile.did)}
23
+
<ProfileItem item={profile} />
24
+
{/each}
25
+
</PageListing>
26
+
</PageContainer>
+22
src/routes/(app)/[actor=did]/[rkey=tid]/reposts/+page.ts
+22
src/routes/(app)/[actor=did]/[rkey=tid]/reposts/+page.ts
···
1
+
import { simpleFetchHandler, XRPC } from '@atcute/client';
2
+
3
+
import { PUBLIC_APPVIEW_URL } from '$env/static/public';
4
+
import type { PageLoad } from './$types';
5
+
6
+
import { makeAtUri } from '$lib/types/at-uri';
7
+
8
+
export const load: PageLoad = async ({ url, params, fetch }) => {
9
+
const rpc = new XRPC({ handler: simpleFetchHandler({ service: PUBLIC_APPVIEW_URL }) });
10
+
11
+
const uri = makeAtUri(params.actor, 'app.bsky.feed.post', params.rkey);
12
+
13
+
const { data } = await rpc.get('app.bsky.feed.getRepostedBy', {
14
+
params: {
15
+
uri,
16
+
limit: 50,
17
+
cursor: url.searchParams.get('cursor') || undefined,
18
+
},
19
+
});
20
+
21
+
return { reposts: { cursor: data.cursor, items: data.repostedBy } };
22
+
};
+3
src/routes/+layout.ts
+3
src/routes/+layout.ts
static/favicon.png
static/favicon.png
This is a binary file and will not be displayed.
+21
svelte.config.js
+21
svelte.config.js
···
1
+
import adapter from '@sveltejs/adapter-cloudflare';
2
+
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
3
+
4
+
/** @type {import('@sveltejs/kit').Config} */
5
+
const config = {
6
+
// Consult https://svelte.dev/docs/kit/integrations
7
+
// for more information about preprocessors
8
+
preprocess: vitePreprocess(),
9
+
compilerOptions: {
10
+
runes: true,
11
+
},
12
+
13
+
kit: {
14
+
// adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
15
+
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
16
+
// See https://svelte.dev/docs/kit/adapters for more information about adapters.
17
+
adapter: adapter(),
18
+
},
19
+
};
20
+
21
+
export default config;
+19
tsconfig.json
+19
tsconfig.json
···
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
+
}
14
+
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
15
+
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
16
+
//
17
+
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
18
+
// from the referenced tsconfig.json - TypeScript does not merge them in
19
+
}