+1
-1
LEXICON_INTEGRATION.md
+1
-1
LEXICON_INTEGRATION.md
···
140
140
```astro
141
141
---
142
142
import ContentDisplay from '../../components/content/ContentDisplay.astro';
143
-
import type { AtprotoRecord } from '../../lib/types/atproto';
143
+
import type { AtprotoRecord } from '../../lib/atproto/atproto-browser';
144
144
145
145
const records: AtprotoRecord[] = await fetchRecords();
146
146
---
+277
package-lock.json
+277
package-lock.json
···
9
9
"version": "0.0.1",
10
10
"dependencies": {
11
11
"@astrojs/check": "^0.9.4",
12
+
"@atcute/jetstream": "^1.0.2",
12
13
"@atproto/api": "^0.16.2",
13
14
"@atproto/xrpc": "^0.7.1",
15
+
"@nulfrost/leaflet-loader-astro": "^1.1.0",
14
16
"@tailwindcss/typography": "^0.5.16",
15
17
"@tailwindcss/vite": "^4.1.11",
16
18
"@types/node": "^24.2.0",
···
177
179
"yaml": "^2.5.0"
178
180
}
179
181
},
182
+
"node_modules/@atcute/client": {
183
+
"version": "4.0.3",
184
+
"resolved": "https://registry.npmjs.org/@atcute/client/-/client-4.0.3.tgz",
185
+
"integrity": "sha512-RIOZWFVLca/HiPAAUDqQPOdOreCxTbL5cb+WUf5yqQOKIu5yEAP3eksinmlLmgIrlr5qVOE7brazUUzaskFCfw==",
186
+
"license": "MIT",
187
+
"dependencies": {
188
+
"@atcute/identity": "^1.0.2",
189
+
"@atcute/lexicons": "^1.0.3"
190
+
}
191
+
},
192
+
"node_modules/@atcute/identity": {
193
+
"version": "1.0.3",
194
+
"resolved": "https://registry.npmjs.org/@atcute/identity/-/identity-1.0.3.tgz",
195
+
"integrity": "sha512-mNMxbKHFGys03A8JXKk0KfMBzdd0vrYMzZZWjpw1nYTs0+ea6bo5S1hwqVUZxHdo1gFHSe/t63jxQIF4yL9aKw==",
196
+
"license": "0BSD",
197
+
"dependencies": {
198
+
"@atcute/lexicons": "^1.0.4",
199
+
"@badrap/valita": "^0.4.5"
200
+
}
201
+
},
202
+
"node_modules/@atcute/jetstream": {
203
+
"version": "1.0.2",
204
+
"resolved": "https://registry.npmjs.org/@atcute/jetstream/-/jetstream-1.0.2.tgz",
205
+
"integrity": "sha512-ZtdNNxl4zq9cgUpXSL9F+AsXUZt0Zuyj0V7974D7LxdMxfTItPnMZ9dRG8GoFkkGz3+pszdsG888Ix8C0F2+mA==",
206
+
"license": "MIT",
207
+
"dependencies": {
208
+
"@atcute/lexicons": "^1.0.2",
209
+
"@badrap/valita": "^0.4.2",
210
+
"@mary-ext/event-iterator": "^1.0.0",
211
+
"@mary-ext/simple-event-emitter": "^1.0.0",
212
+
"partysocket": "^1.1.4",
213
+
"type-fest": "^4.41.0",
214
+
"yocto-queue": "^1.2.1"
215
+
}
216
+
},
217
+
"node_modules/@atcute/lexicons": {
218
+
"version": "1.1.0",
219
+
"resolved": "https://registry.npmjs.org/@atcute/lexicons/-/lexicons-1.1.0.tgz",
220
+
"integrity": "sha512-LFqwnria78xLYb62Ri/+WwQpUTgZp2DuyolNGIIOV1dpiKhFFFh//nscHMA6IExFLQRqWDs3tTjy7zv0h3sf1Q==",
221
+
"license": "0BSD",
222
+
"dependencies": {
223
+
"esm-env": "^1.2.2"
224
+
}
225
+
},
180
226
"node_modules/@atproto/api": {
181
227
"version": "0.16.2",
182
228
"resolved": "https://registry.npmjs.org/@atproto/api/-/api-0.16.2.tgz",
···
334
380
},
335
381
"engines": {
336
382
"node": ">=6.9.0"
383
+
}
384
+
},
385
+
"node_modules/@badrap/valita": {
386
+
"version": "0.4.6",
387
+
"resolved": "https://registry.npmjs.org/@badrap/valita/-/valita-0.4.6.tgz",
388
+
"integrity": "sha512-4kdqcjyxo/8RQ8ayjms47HCWZIF5981oE5nIenbfThKDxWXtEHKipAOWlflpPJzZx9y/JWYQkp18Awr7VuepFg==",
389
+
"license": "MIT",
390
+
"engines": {
391
+
"node": ">= 18"
337
392
}
338
393
},
339
394
"node_modules/@capsizecss/unpack": {
···
1236
1291
"@jridgewell/sourcemap-codec": "^1.4.14"
1237
1292
}
1238
1293
},
1294
+
"node_modules/@mary-ext/event-iterator": {
1295
+
"version": "1.0.0",
1296
+
"resolved": "https://registry.npmjs.org/@mary-ext/event-iterator/-/event-iterator-1.0.0.tgz",
1297
+
"integrity": "sha512-l6gCPsWJ8aRCe/s7/oCmero70kDHgIK5m4uJvYgwEYTqVxoBOIXbKr5tnkLqUHEg6mNduB4IWvms3h70Hp9ADQ==",
1298
+
"license": "BSD-3-Clause",
1299
+
"dependencies": {
1300
+
"yocto-queue": "^1.2.1"
1301
+
}
1302
+
},
1303
+
"node_modules/@mary-ext/simple-event-emitter": {
1304
+
"version": "1.0.0",
1305
+
"resolved": "https://registry.npmjs.org/@mary-ext/simple-event-emitter/-/simple-event-emitter-1.0.0.tgz",
1306
+
"integrity": "sha512-meA/zJZKIN1RVBNEYIbjufkUrW7/tRjHH60FjolpG1ixJKo76TB208qefQLNdOVDA7uIG0CGEDuhmMirtHKLAg==",
1307
+
"license": "BSD-3-Clause"
1308
+
},
1239
1309
"node_modules/@nodelib/fs.scandir": {
1240
1310
"version": "2.1.5",
1241
1311
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
···
1269
1339
},
1270
1340
"engines": {
1271
1341
"node": ">= 8"
1342
+
}
1343
+
},
1344
+
"node_modules/@nulfrost/leaflet-loader-astro": {
1345
+
"version": "1.1.0",
1346
+
"resolved": "https://registry.npmjs.org/@nulfrost/leaflet-loader-astro/-/leaflet-loader-astro-1.1.0.tgz",
1347
+
"integrity": "sha512-A6ONOmds3/3pVFfa+YdpC5YMfOF1shvczAOnSWfVtUYz3bl3NRz26KieUrGW+26iVPgUtHPRLQOfygImrLrhYw==",
1348
+
"license": "MIT",
1349
+
"dependencies": {
1350
+
"@atcute/client": "^4.0.3",
1351
+
"@atcute/lexicons": "^1.1.0",
1352
+
"@atproto/api": "^0.16.2",
1353
+
"katex": "^0.16.22",
1354
+
"sanitize-html": "^2.17.0"
1272
1355
}
1273
1356
},
1274
1357
"node_modules/@oslojs/encoding": {
···
2850
2933
"url": "https://github.com/sponsors/wooorm"
2851
2934
}
2852
2935
},
2936
+
"node_modules/deepmerge": {
2937
+
"version": "4.3.1",
2938
+
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
2939
+
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
2940
+
"license": "MIT",
2941
+
"engines": {
2942
+
"node": ">=0.10.0"
2943
+
}
2944
+
},
2853
2945
"node_modules/defu": {
2854
2946
"version": "6.1.4",
2855
2947
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz",
···
2932
3024
"integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
2933
3025
"license": "MIT"
2934
3026
},
3027
+
"node_modules/dom-serializer": {
3028
+
"version": "2.0.0",
3029
+
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
3030
+
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
3031
+
"license": "MIT",
3032
+
"dependencies": {
3033
+
"domelementtype": "^2.3.0",
3034
+
"domhandler": "^5.0.2",
3035
+
"entities": "^4.2.0"
3036
+
},
3037
+
"funding": {
3038
+
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
3039
+
}
3040
+
},
3041
+
"node_modules/dom-serializer/node_modules/entities": {
3042
+
"version": "4.5.0",
3043
+
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
3044
+
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
3045
+
"license": "BSD-2-Clause",
3046
+
"engines": {
3047
+
"node": ">=0.12"
3048
+
},
3049
+
"funding": {
3050
+
"url": "https://github.com/fb55/entities?sponsor=1"
3051
+
}
3052
+
},
3053
+
"node_modules/domelementtype": {
3054
+
"version": "2.3.0",
3055
+
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
3056
+
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
3057
+
"funding": [
3058
+
{
3059
+
"type": "github",
3060
+
"url": "https://github.com/sponsors/fb55"
3061
+
}
3062
+
],
3063
+
"license": "BSD-2-Clause"
3064
+
},
3065
+
"node_modules/domhandler": {
3066
+
"version": "5.0.3",
3067
+
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
3068
+
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
3069
+
"license": "BSD-2-Clause",
3070
+
"dependencies": {
3071
+
"domelementtype": "^2.3.0"
3072
+
},
3073
+
"engines": {
3074
+
"node": ">= 4"
3075
+
},
3076
+
"funding": {
3077
+
"url": "https://github.com/fb55/domhandler?sponsor=1"
3078
+
}
3079
+
},
3080
+
"node_modules/domutils": {
3081
+
"version": "3.2.2",
3082
+
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
3083
+
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
3084
+
"license": "BSD-2-Clause",
3085
+
"dependencies": {
3086
+
"dom-serializer": "^2.0.0",
3087
+
"domelementtype": "^2.3.0",
3088
+
"domhandler": "^5.0.3"
3089
+
},
3090
+
"funding": {
3091
+
"url": "https://github.com/fb55/domutils?sponsor=1"
3092
+
}
3093
+
},
2935
3094
"node_modules/dotenv": {
2936
3095
"version": "17.2.1",
2937
3096
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.1.tgz",
···
3068
3227
"url": "https://github.com/sponsors/sindresorhus"
3069
3228
}
3070
3229
},
3230
+
"node_modules/esm-env": {
3231
+
"version": "1.2.2",
3232
+
"resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz",
3233
+
"integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==",
3234
+
"license": "MIT"
3235
+
},
3071
3236
"node_modules/estree-walker": {
3072
3237
"version": "3.0.3",
3073
3238
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
···
3076
3241
"dependencies": {
3077
3242
"@types/estree": "^1.0.0"
3078
3243
}
3244
+
},
3245
+
"node_modules/event-target-polyfill": {
3246
+
"version": "0.0.4",
3247
+
"resolved": "https://registry.npmjs.org/event-target-polyfill/-/event-target-polyfill-0.0.4.tgz",
3248
+
"integrity": "sha512-Gs6RLjzlLRdT8X9ZipJdIZI/Y6/HhRLyq9RdDlCsnpxr/+Nn6bU2EFGuC94GjxqhM+Nmij2Vcq98yoHrU8uNFQ==",
3249
+
"license": "MIT"
3079
3250
},
3080
3251
"node_modules/eventemitter3": {
3081
3252
"version": "5.0.1",
···
3505
3676
"url": "https://github.com/sponsors/wooorm"
3506
3677
}
3507
3678
},
3679
+
"node_modules/htmlparser2": {
3680
+
"version": "8.0.2",
3681
+
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz",
3682
+
"integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==",
3683
+
"funding": [
3684
+
"https://github.com/fb55/htmlparser2?sponsor=1",
3685
+
{
3686
+
"type": "github",
3687
+
"url": "https://github.com/sponsors/fb55"
3688
+
}
3689
+
],
3690
+
"license": "MIT",
3691
+
"dependencies": {
3692
+
"domelementtype": "^2.3.0",
3693
+
"domhandler": "^5.0.3",
3694
+
"domutils": "^3.0.1",
3695
+
"entities": "^4.4.0"
3696
+
}
3697
+
},
3698
+
"node_modules/htmlparser2/node_modules/entities": {
3699
+
"version": "4.5.0",
3700
+
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
3701
+
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
3702
+
"license": "BSD-2-Clause",
3703
+
"engines": {
3704
+
"node": ">=0.12"
3705
+
},
3706
+
"funding": {
3707
+
"url": "https://github.com/fb55/entities?sponsor=1"
3708
+
}
3709
+
},
3508
3710
"node_modules/http-cache-semantics": {
3509
3711
"version": "4.2.0",
3510
3712
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
···
3621
3823
"url": "https://github.com/sponsors/sindresorhus"
3622
3824
}
3623
3825
},
3826
+
"node_modules/is-plain-object": {
3827
+
"version": "5.0.0",
3828
+
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz",
3829
+
"integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==",
3830
+
"license": "MIT",
3831
+
"engines": {
3832
+
"node": ">=0.10.0"
3833
+
}
3834
+
},
3624
3835
"node_modules/is-wsl": {
3625
3836
"version": "3.1.0",
3626
3837
"resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.0.tgz",
···
3674
3885
"resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-2.3.1.tgz",
3675
3886
"integrity": "sha512-H8jvkz1O50L3dMZCsLqiuB2tA7muqbSg1AtGEkN0leAqGjsUzDJir3Zwr02BhqdcITPg3ei3mZ+HjMocAknhhg==",
3676
3887
"license": "MIT"
3888
+
},
3889
+
"node_modules/katex": {
3890
+
"version": "0.16.22",
3891
+
"resolved": "https://registry.npmjs.org/katex/-/katex-0.16.22.tgz",
3892
+
"integrity": "sha512-XCHRdUw4lf3SKBaJe4EvgqIuWwkPSo9XoeO8GjQW94Bp7TWv9hNhzZjZ+OH9yf1UmLygb7DIT5GSFQiyt16zYg==",
3893
+
"funding": [
3894
+
"https://opencollective.com/katex",
3895
+
"https://github.com/sponsors/katex"
3896
+
],
3897
+
"license": "MIT",
3898
+
"dependencies": {
3899
+
"commander": "^8.3.0"
3900
+
},
3901
+
"bin": {
3902
+
"katex": "cli.js"
3903
+
}
3904
+
},
3905
+
"node_modules/katex/node_modules/commander": {
3906
+
"version": "8.3.0",
3907
+
"resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz",
3908
+
"integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==",
3909
+
"license": "MIT",
3910
+
"engines": {
3911
+
"node": ">= 12"
3912
+
}
3677
3913
},
3678
3914
"node_modules/kleur": {
3679
3915
"version": "4.1.5",
···
5089
5325
"url": "https://github.com/sponsors/wooorm"
5090
5326
}
5091
5327
},
5328
+
"node_modules/parse-srcset": {
5329
+
"version": "1.0.2",
5330
+
"resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz",
5331
+
"integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==",
5332
+
"license": "MIT"
5333
+
},
5092
5334
"node_modules/parse5": {
5093
5335
"version": "7.3.0",
5094
5336
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
···
5099
5341
},
5100
5342
"funding": {
5101
5343
"url": "https://github.com/inikulin/parse5?sponsor=1"
5344
+
}
5345
+
},
5346
+
"node_modules/partysocket": {
5347
+
"version": "1.1.5",
5348
+
"resolved": "https://registry.npmjs.org/partysocket/-/partysocket-1.1.5.tgz",
5349
+
"integrity": "sha512-8uw9foq9bij4sKLCtTSHvyqMrMTQ5FJjrHc7BjoM2s95Vu7xYCN63ABpI7OZHC7ZMP5xaom/A+SsoFPXmTV6ZQ==",
5350
+
"license": "MIT",
5351
+
"dependencies": {
5352
+
"event-target-polyfill": "^0.0.4"
5102
5353
}
5103
5354
},
5104
5355
"node_modules/path-browserify": {
···
5598
5849
"license": "MIT",
5599
5850
"dependencies": {
5600
5851
"queue-microtask": "^1.2.2"
5852
+
}
5853
+
},
5854
+
"node_modules/sanitize-html": {
5855
+
"version": "2.17.0",
5856
+
"resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.0.tgz",
5857
+
"integrity": "sha512-dLAADUSS8rBwhaevT12yCezvioCA+bmUTPH/u57xKPT8d++voeYE6HeluA/bPbQ15TwDBG2ii+QZIEmYx8VdxA==",
5858
+
"license": "MIT",
5859
+
"dependencies": {
5860
+
"deepmerge": "^4.2.2",
5861
+
"escape-string-regexp": "^4.0.0",
5862
+
"htmlparser2": "^8.0.0",
5863
+
"is-plain-object": "^5.0.0",
5864
+
"parse-srcset": "^1.0.2",
5865
+
"postcss": "^8.3.11"
5866
+
}
5867
+
},
5868
+
"node_modules/sanitize-html/node_modules/escape-string-regexp": {
5869
+
"version": "4.0.0",
5870
+
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
5871
+
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
5872
+
"license": "MIT",
5873
+
"engines": {
5874
+
"node": ">=10"
5875
+
},
5876
+
"funding": {
5877
+
"url": "https://github.com/sponsors/sindresorhus"
5601
5878
}
5602
5879
},
5603
5880
"node_modules/semver": {
+2
package.json
+2
package.json
···
12
12
},
13
13
"dependencies": {
14
14
"@astrojs/check": "^0.9.4",
15
+
"@atcute/jetstream": "^1.0.2",
15
16
"@atproto/api": "^0.16.2",
16
17
"@atproto/xrpc": "^0.7.1",
18
+
"@nulfrost/leaflet-loader-astro": "^1.1.0",
17
19
"@tailwindcss/typography": "^0.5.16",
18
20
"@tailwindcss/vite": "^4.1.11",
19
21
"@types/node": "^24.2.0",
+1
-3
src/components/content/BlueskyFeed.astro
+1
-3
src/components/content/BlueskyFeed.astro
···
6
6
interface Props {
7
7
feedUri: string;
8
8
limit?: number;
9
-
showAuthor?: boolean;
10
9
showTimestamp?: boolean;
11
10
}
12
11
13
-
const { feedUri, limit = 10, showAuthor = true, showTimestamp = true } = Astro.props;
12
+
const { feedUri, limit = 10, showTimestamp = true } = Astro.props;
14
13
15
14
const config = loadConfig();
16
15
const browser = new AtprotoBrowser();
···
34
33
blueskyPosts.map((record) => (
35
34
<BlueskyPost
36
35
post={record.value}
37
-
showAuthor={showAuthor}
38
36
showTimestamp={showTimestamp}
39
37
/>
40
38
))
+7
-22
src/components/content/BlueskyPost.astro
+7
-22
src/components/content/BlueskyPost.astro
···
1
1
---
2
-
import type { BlueskyPost } from '../../lib/types/atproto';
2
+
import type { AppBskyFeedPost } from '@atproto/api';
3
3
import { extractCidFromBlobRef, blobCdnUrl } from '../../lib/atproto/blob-url';
4
4
import { loadConfig } from '../../lib/config/site';
5
5
6
6
interface Props {
7
-
post: BlueskyPost;
8
-
showAuthor?: boolean;
7
+
post: AppBskyFeedPost.Record;
9
8
showTimestamp?: boolean;
10
9
}
11
10
12
-
const { post, showAuthor = false, showTimestamp = true } = Astro.props;
11
+
const { post, showTimestamp = true } = Astro.props;
13
12
14
13
// Validate post data
15
14
if (!post || !post.text) {
···
57
56
---
58
57
59
58
<article class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4 mb-4">
60
-
{showAuthor && post.author && (
61
-
<div class="flex items-center mb-3">
62
-
<div class="w-8 h-8 bg-blue-500 rounded-full flex items-center justify-center text-white text-sm font-medium">
63
-
{post.author.displayName?.[0] || 'U'}
64
-
</div>
65
-
<div class="ml-3">
66
-
<div class="text-sm font-medium text-gray-900 dark:text-white">
67
-
{post.author.displayName || 'Unknown'}
68
-
</div>
69
-
<div class="text-xs text-gray-500 dark:text-gray-400">
70
-
@{post.author.handle || 'unknown'}
71
-
</div>
72
-
</div>
73
-
</div>
74
-
)}
59
+
75
60
76
61
<div class="text-gray-900 dark:text-white mb-3">
77
62
{post.text}
···
80
65
{post.embed && (
81
66
<div class="mb-3">
82
67
{/* Handle image embeds */}
83
-
{post.embed.$type === 'app.bsky.embed.images' && post.embed.images && (
68
+
{post.embed.$type === 'app.bsky.embed.images' && 'images' in post.embed && post.embed.images && (
84
69
renderImages(post.embed.images)
85
70
)}
86
71
87
72
{/* Handle external link embeds */}
88
-
{post.embed.$type === 'app.bsky.embed.external' && post.embed.external && (
73
+
{post.embed.$type === 'app.bsky.embed.external' && 'external' in post.embed && post.embed.external && (
89
74
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-3">
90
75
<div class="text-sm text-gray-600 dark:text-gray-400 mb-1">
91
76
{post.embed.external.uri}
···
102
87
)}
103
88
104
89
{/* Handle record embeds (quotes/reposts) */}
105
-
{post.embed.$type === 'app.bsky.embed.record' && post.embed.record && (
90
+
{post.embed.$type === 'app.bsky.embed.record' && 'record' in post.embed && post.embed.record && (
106
91
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-3 bg-gray-50 dark:bg-gray-700">
107
92
<div class="text-sm text-gray-600 dark:text-gray-400">
108
93
Quoted post
+1
-1
src/components/content/ContentDisplay.astro
+1
-1
src/components/content/ContentDisplay.astro
···
1
1
---
2
-
import type { AtprotoRecord } from '../../lib/types/atproto';
2
+
import type { AtprotoRecord } from '../../lib/atproto/atproto-browser';
3
3
import type { GeneratedLexiconUnion } from '../../lib/generated/lexicon-types';
4
4
import { getComponentInfo, autoAssignComponent } from '../../lib/components/registry';
5
5
import { loadConfig } from '../../lib/config/site';
+18
-23
src/components/content/ContentFeed.astro
+18
-23
src/components/content/ContentFeed.astro
···
1
1
---
2
2
import { AtprotoBrowser } from '../../lib/atproto/atproto-browser';
3
3
import { loadConfig } from '../../lib/config/site';
4
-
import type { AtprotoRecord } from '../../lib/types/atproto';
5
-
import { extractCidFromBlobRef, blobCdnUrl } from '../../lib/atproto/blob';
4
+
import type { AtprotoRecord } from '../../lib/atproto/atproto-browser';
5
+
import { extractCidFromBlobRef, blobCdnUrl } from '../../lib/atproto/blob-url';
6
6
7
7
8
8
interface Props {
9
9
collection?: string;
10
10
limit?: number;
11
11
feedUri?: string;
12
-
showAuthor?: boolean;
13
12
showTimestamp?: boolean;
14
13
live?: boolean;
15
14
}
···
18
17
collection = 'app.bsky.feed.post',
19
18
limit = 10,
20
19
feedUri,
21
-
showAuthor = true,
22
20
showTimestamp = true,
23
21
live = false,
24
22
} = Astro.props;
···
213
211
}
214
212
215
213
try {
216
-
const endpoint = 'wss://jetstream1.us-east.bsky.network/subscribe';
217
-
const url = new URL(endpoint);
218
-
if (DID) url.searchParams.append('wantedDids', DID);
219
-
const ws = new WebSocket(url.toString());
220
-
221
-
ws.onmessage = (event) => {
222
-
try {
223
-
const data = JSON.parse(event.data);
224
-
if (data?.kind !== 'commit') return;
225
-
const commit = data.commit;
226
-
const record = commit?.record || {};
227
-
if (commit?.operation !== 'create') return;
228
-
if (record?.$type !== 'app.bsky.feed.post') return;
229
-
const el = buildPostEl(record, data.did);
214
+
// Use shared jetstream instead of creating a new connection
215
+
const { startSharedStream, subscribeToPosts } = await import('../../lib/atproto/jetstream-client');
216
+
217
+
// Start the shared stream
218
+
await startSharedStream();
219
+
220
+
// Subscribe to new posts
221
+
const unsubscribe = subscribeToPosts((event) => {
222
+
if (event.commit.operation === 'create') {
223
+
const el = buildPostEl(event.commit.record, event.did);
230
224
// @ts-ignore
231
225
container.insertBefore(el, container.firstChild);
232
226
const posts = container.children;
233
227
if (posts.length > maxPrepend + INITIAL_LIMIT) {
234
228
if (container.lastElementChild) container.removeChild(container.lastElementChild);
235
229
}
236
-
} catch (e) {
237
-
console.error('jetstream msg error', e);
238
230
}
239
-
};
240
-
241
-
ws.onerror = (e) => console.error('jetstream ws error', e);
231
+
});
232
+
233
+
// Cleanup on page unload
234
+
window.addEventListener('beforeunload', () => {
235
+
unsubscribe();
236
+
});
242
237
} catch (e) {
243
238
console.error('jetstream start error', e);
244
239
}
-69
src/components/content/GrainImageGallery.astro
-69
src/components/content/GrainImageGallery.astro
···
1
-
---
2
-
import type { GrainImageGallery } from '../../lib/types/atproto';
3
-
import type { SocialGrainGallery } from '../../lib/generated/social-grain-gallery';
4
-
5
-
interface Props {
6
-
gallery: GrainImageGallery;
7
-
showDescription?: boolean;
8
-
showTimestamp?: boolean;
9
-
columns?: number;
10
-
}
11
-
12
-
const { gallery, showDescription = true, showTimestamp = true, columns = 3 } = Astro.props;
13
-
14
-
const formatDate = (dateString: string) => {
15
-
return new Date(dateString).toLocaleDateString('en-US', {
16
-
year: 'numeric',
17
-
month: 'long',
18
-
day: 'numeric',
19
-
});
20
-
};
21
-
22
-
const gridCols = {
23
-
1: 'grid-cols-1',
24
-
2: 'grid-cols-2',
25
-
3: 'grid-cols-3',
26
-
4: 'grid-cols-4',
27
-
5: 'grid-cols-5',
28
-
6: 'grid-cols-6',
29
-
}[columns] || 'grid-cols-3';
30
-
---
31
-
32
-
<article class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6 mb-6">
33
-
<header class="mb-4">
34
-
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-2">
35
-
{gallery.title}
36
-
</h2>
37
-
38
-
{showDescription && gallery.description && (
39
-
<div class="text-gray-600 dark:text-gray-400 mb-3">
40
-
{gallery.description}
41
-
</div>
42
-
)}
43
-
44
-
{showTimestamp && (
45
-
<div class="text-sm text-gray-500 dark:text-gray-400 mb-4">
46
-
Created on {formatDate(gallery.createdAt)}
47
-
</div>
48
-
)}
49
-
</header>
50
-
51
-
{gallery.images && gallery.images.length > 0 && (
52
-
<div class={`grid ${gridCols} gap-4`}>
53
-
{gallery.images.map((image) => (
54
-
<div class="relative group">
55
-
<img
56
-
src={image.url}
57
-
alt={image.alt || 'Gallery image'}
58
-
class="w-full h-48 object-cover rounded-lg transition-transform duration-200 group-hover:scale-105"
59
-
/>
60
-
{image.alt && (
61
-
<div class="absolute bottom-0 left-0 right-0 bg-black bg-opacity-50 text-white text-sm p-2 rounded-b-lg opacity-0 group-hover:opacity-100 transition-opacity duration-200">
62
-
{image.alt}
63
-
</div>
64
-
)}
65
-
</div>
66
-
))}
67
-
</div>
68
-
)}
69
-
</article>
-47
src/components/content/LeafletPublication.astro
-47
src/components/content/LeafletPublication.astro
···
1
-
---
2
-
import type { LeafletPublication } from '../../lib/types/atproto';
3
-
4
-
interface Props {
5
-
publication: LeafletPublication;
6
-
showCategory?: boolean;
7
-
showTimestamp?: boolean;
8
-
}
9
-
10
-
const { publication, showCategory = true, showTimestamp = true } = Astro.props;
11
-
12
-
const formatDate = (dateString: string) => {
13
-
return new Date(dateString).toLocaleDateString('en-US', {
14
-
year: 'numeric',
15
-
month: 'long',
16
-
day: 'numeric',
17
-
});
18
-
};
19
-
---
20
-
21
-
<article class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6 mb-6">
22
-
<header class="mb-4">
23
-
<div class="flex items-center justify-between mb-2">
24
-
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">
25
-
{publication.title}
26
-
</h2>
27
-
28
-
{showCategory && publication.category && (
29
-
<span class="px-3 py-1 bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 text-sm rounded-full">
30
-
{publication.category}
31
-
</span>
32
-
)}
33
-
</div>
34
-
35
-
{showTimestamp && (
36
-
<div class="text-sm text-gray-500 dark:text-gray-400 mb-3">
37
-
Published on {formatDate(publication.publishedAt)}
38
-
</div>
39
-
)}
40
-
</header>
41
-
42
-
<div class="prose prose-gray dark:prose-invert max-w-none">
43
-
<div class="text-gray-700 dark:text-gray-300 leading-relaxed">
44
-
{publication.content}
45
-
</div>
46
-
</div>
47
-
</article>
+115
src/components/content/StatusUpdate.astro
+115
src/components/content/StatusUpdate.astro
···
1
+
---
2
+
import type { AStatusUpdateRecord } from '../../lib/generated/a-status-update';
3
+
import { AtprotoBrowser } from '../../lib/atproto/atproto-browser';
4
+
import { loadConfig } from '../../lib/config/site';
5
+
6
+
const config = loadConfig();
7
+
const client = new AtprotoBrowser();
8
+
9
+
// Fetch the latest status update
10
+
let latestStatus: AStatusUpdateRecord | null = null;
11
+
try {
12
+
const records = await client.getAllCollectionRecords(config.atproto.handle, 'a.status.update', 1);
13
+
14
+
if (records.length > 0) {
15
+
latestStatus = records[0].value as AStatusUpdateRecord;
16
+
}
17
+
} catch (error) {
18
+
console.error('Failed to fetch status update:', error);
19
+
}
20
+
---
21
+
22
+
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4" id="status-update-container">
23
+
{latestStatus ? (
24
+
<div class="space-y-2">
25
+
<p class="text-lg font-medium text-gray-900 dark:text-white leading-relaxed" id="status-text">
26
+
{latestStatus.text}
27
+
</p>
28
+
<time class="text-sm text-gray-500 dark:text-gray-400 block" id="status-time" datetime={latestStatus.createdAt}>
29
+
{new Date(latestStatus.createdAt).toLocaleDateString('en-US', {
30
+
year: 'numeric',
31
+
month: 'short',
32
+
day: 'numeric',
33
+
hour: '2-digit',
34
+
minute: '2-digit'
35
+
})}
36
+
</time>
37
+
</div>
38
+
) : (
39
+
<div class="text-center py-4" id="status-placeholder">
40
+
<p class="text-gray-500 italic">No status updates available</p>
41
+
</div>
42
+
)}
43
+
</div>
44
+
45
+
<script>
46
+
import { startSharedStream, subscribeToStatusUpdates } from '../../lib/atproto/jetstream-client';
47
+
48
+
// Start the shared stream
49
+
startSharedStream();
50
+
51
+
// Subscribe to status updates
52
+
const unsubscribe = subscribeToStatusUpdates((event) => {
53
+
if (event.commit.operation === 'create') {
54
+
updateStatusDisplay(event.commit.record);
55
+
}
56
+
});
57
+
58
+
function updateStatusDisplay(statusData: any) {
59
+
const container = document.getElementById('status-update-container');
60
+
const textEl = document.getElementById('status-text');
61
+
const timeEl = document.getElementById('status-time');
62
+
const placeholderEl = document.getElementById('status-placeholder');
63
+
64
+
if (!container) return;
65
+
66
+
// Remove placeholder if it exists
67
+
if (placeholderEl) {
68
+
placeholderEl.remove();
69
+
}
70
+
71
+
// Update or create text element
72
+
if (textEl) {
73
+
textEl.textContent = statusData.text;
74
+
} else {
75
+
const newTextEl = document.createElement('p');
76
+
newTextEl.className = 'text-lg font-medium text-gray-900 dark:text-white leading-relaxed';
77
+
newTextEl.id = 'status-text';
78
+
newTextEl.textContent = statusData.text;
79
+
container.appendChild(newTextEl);
80
+
}
81
+
82
+
// Update or create time element
83
+
const formattedTime = new Date(statusData.createdAt).toLocaleDateString('en-US', {
84
+
year: 'numeric',
85
+
month: 'short',
86
+
day: 'numeric',
87
+
hour: '2-digit',
88
+
minute: '2-digit'
89
+
});
90
+
91
+
if (timeEl) {
92
+
timeEl.textContent = formattedTime;
93
+
timeEl.setAttribute('datetime', statusData.createdAt);
94
+
} else {
95
+
const newTimeEl = document.createElement('time');
96
+
newTimeEl.className = 'text-sm text-gray-500 dark:text-gray-400 block';
97
+
newTimeEl.id = 'status-time';
98
+
newTimeEl.setAttribute('datetime', statusData.createdAt);
99
+
newTimeEl.textContent = formattedTime;
100
+
container.appendChild(newTimeEl);
101
+
}
102
+
103
+
// Add a subtle animation to indicate the update
104
+
container.style.transition = 'all 0.3s ease';
105
+
container.style.transform = 'scale(1.02)';
106
+
setTimeout(() => {
107
+
container.style.transform = 'scale(1)';
108
+
}, 300);
109
+
}
110
+
111
+
// Cleanup on page unload
112
+
window.addEventListener('beforeunload', () => {
113
+
unsubscribe();
114
+
});
115
+
</script>
+13
src/content.config.ts
+13
src/content.config.ts
···
1
+
import { defineCollection, z } from "astro:content";
2
+
import { leafletStaticLoader } from "@nulfrost/leaflet-loader-astro";
3
+
import { loadConfig } from "./lib/config/site";
4
+
5
+
const config = loadConfig();
6
+
7
+
const documents = defineCollection({
8
+
loader: leafletStaticLoader({
9
+
repo: config.atproto.did!
10
+
}),
11
+
});
12
+
13
+
export const collections = { documents };
+3
src/layouts/Layout.astro
+3
src/layouts/Layout.astro
···
38
38
<a href="/blog" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition-colors">
39
39
Blog
40
40
</a>
41
+
<a href="/now" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition-colors">
42
+
Now
43
+
</a>
41
44
</div>
42
45
</div>
43
46
</div>
+26
src/lexicons/a.status.update.json
+26
src/lexicons/a.status.update.json
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "a.status.update",
4
+
"defs": {
5
+
"main": {
6
+
"type": "record",
7
+
"description": "A simple status update record",
8
+
"key": "self",
9
+
"record": {
10
+
"type": "object",
11
+
"required": ["text", "createdAt"],
12
+
"properties": {
13
+
"text": {
14
+
"type": "string",
15
+
"description": "The status update text"
16
+
},
17
+
"createdAt": {
18
+
"type": "string",
19
+
"format": "datetime",
20
+
"description": "When the status was created"
21
+
}
22
+
}
23
+
}
24
+
}
25
+
}
26
+
}
+29
src/lib/atproto/blob-url.ts
+29
src/lib/atproto/blob-url.ts
···
1
+
import { loadConfig } from '../config/site'
2
+
3
+
export type BlobVariant = 'full' | 'avatar' | 'feed'
4
+
5
+
export function blobCdnUrl(did: string, cid: string, _variant: BlobVariant = 'full'): string {
6
+
const base = 'https://bsky.social/xrpc/com.atproto.sync.getBlob'
7
+
const params = new URLSearchParams({ did, cid })
8
+
return `${base}?${params.toString()}`
9
+
}
10
+
11
+
export function extractCidFromBlobRef(ref: unknown): string | null {
12
+
if (typeof ref === 'string') return ref
13
+
if (ref && typeof ref === 'object') {
14
+
const anyRef = ref as any
15
+
if (typeof anyRef.$link === 'string') return anyRef.$link
16
+
if (typeof anyRef.toString === 'function') {
17
+
const s = anyRef.toString()
18
+
if (s && typeof s === 'string') return s
19
+
}
20
+
}
21
+
return null
22
+
}
23
+
24
+
export function didFromConfig(): string {
25
+
const cfg = loadConfig()
26
+
return cfg.atproto.did || ''
27
+
}
28
+
29
+
+187
-168
src/lib/atproto/jetstream-client.ts
+187
-168
src/lib/atproto/jetstream-client.ts
···
1
-
// Jetstream-based repository streaming with DID filtering (based on atptools)
1
+
// Complete Jetstream implementation using documented @atcute/jetstream approach
2
+
import { JetstreamSubscription, type CommitEvent } from '@atcute/jetstream';
2
3
import { loadConfig } from '../config/site';
3
4
4
-
export interface JetstreamRecord {
5
-
uri: string;
6
-
cid: string;
7
-
value: any;
8
-
indexedAt: string;
9
-
collection: string;
10
-
$type: string;
11
-
service: string;
12
-
did: string;
13
-
time_us: number;
14
-
operation: 'create' | 'update' | 'delete';
15
-
}
16
-
17
5
export interface JetstreamConfig {
18
6
handle: string;
19
7
did?: string;
···
24
12
}
25
13
26
14
export class JetstreamClient {
27
-
private ws: WebSocket | null = null;
15
+
private subscription: JetstreamSubscription | null = null;
28
16
private config: JetstreamConfig;
29
-
private targetDid: string | null = null;
30
17
private isStreaming = false;
31
18
private listeners: {
32
-
onRecord?: (record: JetstreamRecord) => void;
19
+
onRecord?: (event: CommitEvent) => void;
33
20
onError?: (error: Error) => void;
34
21
onConnect?: () => void;
35
22
onDisconnect?: () => void;
···
40
27
this.config = {
41
28
handle: config?.handle || siteConfig.atproto.handle,
42
29
did: config?.did || siteConfig.atproto.did,
43
-
endpoint: config?.endpoint || 'wss://jetstream1.us-east.bsky.network/subscribe',
30
+
endpoint: config?.endpoint || 'wss://jetstream2.us-east.bsky.network',
44
31
wantedCollections: config?.wantedCollections || [],
45
32
wantedDids: config?.wantedDids || [],
46
33
cursor: config?.cursor,
47
34
};
48
-
this.targetDid = this.config.did || null;
49
35
50
-
console.log('๐ง JetstreamClient initialized with handle:', this.config.handle);
51
-
console.log('๐ฏ Target DID for filtering:', this.targetDid);
52
-
console.log('๐ Endpoint:', this.config.endpoint);
36
+
console.log('๐ง JetstreamClient initialized');
53
37
}
54
38
55
-
// Start streaming all repository activity
56
39
async startStreaming(): Promise<void> {
57
40
if (this.isStreaming) {
58
-
console.log('โ ๏ธ Already streaming repository');
41
+
console.log('โ ๏ธ Already streaming');
59
42
return;
60
43
}
61
44
62
-
console.log('๐ Starting jetstream repository streaming...');
45
+
console.log('๐ Starting jetstream streaming...');
63
46
this.isStreaming = true;
64
47
65
48
try {
66
-
// Resolve handle to DID if needed
67
-
if (!this.targetDid) {
68
-
this.targetDid = await this.resolveHandle(this.config.handle);
69
-
if (!this.targetDid) {
70
-
throw new Error(`Could not resolve handle: ${this.config.handle}`);
71
-
}
72
-
console.log('โ
Resolved DID:', this.targetDid);
49
+
// Add our DID to wanted DIDs if specified
50
+
const wantedDids = [...(this.config.wantedDids || [])];
51
+
if (this.config.did && !wantedDids.includes(this.config.did)) {
52
+
wantedDids.push(this.config.did);
73
53
}
74
54
75
-
// Add target DID to wanted DIDs
76
-
if (this.targetDid && !this.config.wantedDids!.includes(this.targetDid)) {
77
-
this.config.wantedDids!.push(this.targetDid);
78
-
}
55
+
this.subscription = new JetstreamSubscription({
56
+
url: this.config.endpoint!,
57
+
wantedCollections: this.config.wantedCollections,
58
+
wantedDids: wantedDids as any,
59
+
cursor: this.config.cursor,
60
+
onConnectionOpen: () => {
61
+
console.log('โ
Connected to jetstream');
62
+
this.listeners.onConnect?.();
63
+
},
64
+
onConnectionClose: () => {
65
+
console.log('๐ Disconnected from jetstream');
66
+
this.isStreaming = false;
67
+
this.listeners.onDisconnect?.();
68
+
},
69
+
onConnectionError: (error) => {
70
+
console.error('โ Jetstream connection error:', error);
71
+
this.listeners.onError?.(new Error('Connection error'));
72
+
},
73
+
});
79
74
80
-
// Start WebSocket connection
81
-
this.connect();
75
+
// Process events using async iteration as documented
76
+
this.processEvents();
82
77
83
78
} catch (error) {
84
79
this.isStreaming = false;
···
86
81
}
87
82
}
88
83
89
-
// Stop streaming
90
-
stopStreaming(): void {
91
-
if (this.ws) {
92
-
this.ws.close();
93
-
this.ws = null;
94
-
}
95
-
this.isStreaming = false;
96
-
console.log('๐ Stopped jetstream streaming');
97
-
this.listeners.onDisconnect?.();
98
-
}
84
+
private async processEvents(): Promise<void> {
85
+
if (!this.subscription) return;
99
86
100
-
// Connect to jetstream WebSocket
101
-
private connect(): void {
102
87
try {
103
-
const url = new URL(this.config.endpoint!);
104
-
105
-
// Add query parameters for filtering (using atptools' parameter names)
106
-
this.config.wantedCollections!.forEach((collection) => {
107
-
url.searchParams.append('wantedCollections', collection);
108
-
});
109
-
this.config.wantedDids!.forEach((did) => {
110
-
url.searchParams.append('wantedDids', did);
111
-
});
112
-
if (this.config.cursor) {
113
-
url.searchParams.set('cursor', this.config.cursor.toString());
88
+
// Use the documented async iteration approach
89
+
for await (const event of this.subscription) {
90
+
if (event.kind === 'commit') {
91
+
console.log('๐ New commit:', {
92
+
collection: event.commit.collection,
93
+
operation: event.commit.operation,
94
+
did: event.did,
95
+
});
96
+
97
+
this.listeners.onRecord?.(event);
98
+
}
114
99
}
115
-
116
-
console.log('๐ Connecting to jetstream:', url.toString());
117
-
118
-
this.ws = new WebSocket(url.toString());
119
-
120
-
this.ws.onopen = () => {
121
-
console.log('โ
Connected to jetstream');
122
-
this.listeners.onConnect?.();
123
-
};
124
-
125
-
this.ws.onmessage = (event) => {
126
-
try {
127
-
const data = JSON.parse(event.data);
128
-
this.handleMessage(data);
129
-
} catch (error) {
130
-
console.error('Error parsing jetstream message:', error);
131
-
}
132
-
};
133
-
134
-
this.ws.onerror = (error) => {
135
-
console.error('โ Jetstream WebSocket error:', error);
136
-
this.listeners.onError?.(new Error('WebSocket error'));
137
-
};
138
-
139
-
this.ws.onclose = () => {
140
-
console.log('๐ Disconnected from jetstream');
141
-
this.isStreaming = false;
142
-
this.listeners.onDisconnect?.();
143
-
};
144
-
145
100
} catch (error) {
146
-
console.error('Error connecting to jetstream:', error);
101
+
console.error('Error processing jetstream events:', error);
147
102
this.listeners.onError?.(error as Error);
103
+
} finally {
104
+
this.isStreaming = false;
105
+
this.listeners.onDisconnect?.();
148
106
}
149
107
}
150
108
151
-
// Handle incoming jetstream messages
152
-
private handleMessage(data: any): void {
153
-
try {
154
-
// Handle different message types based on atptools' format
155
-
if (data.kind === 'commit') {
156
-
this.handleCommit(data);
157
-
} else if (data.kind === 'account') {
158
-
console.log('Account event:', data);
159
-
} else if (data.kind === 'identity') {
160
-
console.log('Identity event:', data);
161
-
} else {
162
-
console.log('Unknown message type:', data);
163
-
}
164
-
} catch (error) {
165
-
console.error('Error handling jetstream message:', error);
166
-
}
167
-
}
168
-
169
-
// Handle commit events (record changes)
170
-
private handleCommit(data: any): void {
171
-
try {
172
-
const commit = data.commit;
173
-
const event = data;
174
-
175
-
// Filter by DID if specified
176
-
if (this.targetDid && event.did !== this.targetDid) {
177
-
return;
178
-
}
179
-
180
-
const jetstreamRecord: JetstreamRecord = {
181
-
uri: `at://${event.did}/${commit.collection}/${commit.rkey}`,
182
-
cid: commit.cid || '',
183
-
value: commit.record || {},
184
-
indexedAt: new Date(event.time_us / 1000).toISOString(),
185
-
collection: commit.collection,
186
-
$type: (commit.record?.$type as string) || 'unknown',
187
-
service: this.inferService((commit.record?.$type as string) || '', commit.collection),
188
-
did: event.did,
189
-
time_us: event.time_us,
190
-
operation: commit.operation,
191
-
};
192
-
193
-
console.log('๐ New record from jetstream:', {
194
-
collection: jetstreamRecord.collection,
195
-
$type: jetstreamRecord.$type,
196
-
operation: jetstreamRecord.operation,
197
-
uri: jetstreamRecord.uri,
198
-
service: jetstreamRecord.service
199
-
});
200
-
201
-
this.listeners.onRecord?.(jetstreamRecord);
202
-
} catch (error) {
203
-
console.error('Error handling commit:', error);
204
-
}
205
-
}
206
-
207
-
// Infer service from record type and collection
208
-
private inferService($type: string, collection: string): string {
209
-
if (collection.startsWith('grain.social')) return 'grain.social';
210
-
if (collection.startsWith('app.bsky')) return 'bsky.app';
211
-
if ($type.includes('grain')) return 'grain.social';
212
-
return 'unknown';
213
-
}
214
-
215
-
// Resolve handle to DID
216
-
private async resolveHandle(handle: string): Promise<string | null> {
217
-
try {
218
-
// For now, use the configured DID
219
-
// In a real implementation, you'd call the ATProto API
220
-
return this.config.did || null;
221
-
} catch (error) {
222
-
console.error('Error resolving handle:', error);
223
-
return null;
224
-
}
109
+
stopStreaming(): void {
110
+
this.subscription = null;
111
+
this.isStreaming = false;
112
+
console.log('๐ Stopped jetstream streaming');
113
+
this.listeners.onDisconnect?.();
225
114
}
226
115
227
116
// Event listeners
228
-
onRecord(callback: (record: JetstreamRecord) => void): void {
117
+
onRecord(callback: (event: CommitEvent) => void): void {
229
118
this.listeners.onRecord = callback;
230
119
}
231
120
···
241
130
this.listeners.onDisconnect = callback;
242
131
}
243
132
244
-
// Get streaming status
245
133
getStatus(): 'streaming' | 'stopped' {
246
134
return this.isStreaming ? 'streaming' : 'stopped';
247
135
}
136
+
}
137
+
138
+
// Shared Jetstream functionality
139
+
let sharedJetstream: JetstreamClient | null = null;
140
+
let connectionCount = 0;
141
+
const listeners: Map<string, Set<(event: CommitEvent) => void>> = new Map();
142
+
143
+
export function getSharedJetstream(): JetstreamClient {
144
+
if (!sharedJetstream) {
145
+
// Create a shared client with common collections
146
+
sharedJetstream = new JetstreamClient({
147
+
wantedCollections: [
148
+
'app.bsky.feed.post',
149
+
'a.status.update',
150
+
'social.grain.gallery',
151
+
'social.grain.gallery.item',
152
+
'social.grain.photo',
153
+
'com.whtwnd.blog.entry'
154
+
]
155
+
});
156
+
157
+
// Set up the main record handler that distributes to filtered listeners
158
+
sharedJetstream.onRecord((event) => {
159
+
// Distribute to all listeners that match the filter
160
+
listeners.forEach((listenerSet, filterKey) => {
161
+
if (matchesFilter(event, filterKey)) {
162
+
listenerSet.forEach(callback => callback(event));
163
+
}
164
+
});
165
+
});
166
+
}
167
+
return sharedJetstream;
168
+
}
169
+
170
+
// Start the shared stream (call once when first component needs it)
171
+
export async function startSharedStream(): Promise<void> {
172
+
const jetstream = getSharedJetstream();
173
+
if (connectionCount === 0) {
174
+
await jetstream.startStreaming();
175
+
}
176
+
connectionCount++;
177
+
}
178
+
179
+
// Stop the shared stream (call when last component is done)
180
+
export function stopSharedStream(): void {
181
+
connectionCount--;
182
+
if (connectionCount <= 0 && sharedJetstream) {
183
+
sharedJetstream.stopStreaming();
184
+
connectionCount = 0;
185
+
}
186
+
}
187
+
188
+
// Subscribe to filtered records
189
+
export function subscribeToRecords(
190
+
filter: string | ((event: CommitEvent) => boolean),
191
+
callback: (event: CommitEvent) => void
192
+
): () => void {
193
+
const filterKey = typeof filter === 'string' ? filter : filter.toString();
194
+
195
+
if (!listeners.has(filterKey)) {
196
+
listeners.set(filterKey, new Set());
197
+
}
198
+
199
+
const listenerSet = listeners.get(filterKey)!;
200
+
listenerSet.add(callback);
201
+
202
+
// Return unsubscribe function
203
+
return () => {
204
+
const set = listeners.get(filterKey);
205
+
if (set) {
206
+
set.delete(callback);
207
+
if (set.size === 0) {
208
+
listeners.delete(filterKey);
209
+
}
210
+
}
211
+
};
212
+
}
213
+
214
+
// Helper to check if a record matches a filter
215
+
function matchesFilter(event: CommitEvent, filterKey: string): boolean {
216
+
// Handle delete operations (no record property)
217
+
if (event.commit.operation === 'delete') {
218
+
// For delete operations, only support collection and operation matching
219
+
if (filterKey.startsWith('collection:')) {
220
+
const expectedCollection = filterKey.substring(11);
221
+
return event.commit.collection === expectedCollection;
222
+
}
223
+
if (filterKey.startsWith('operation:')) {
224
+
const expectedOperation = filterKey.substring(10);
225
+
return event.commit.operation === expectedOperation;
226
+
}
227
+
return false;
228
+
}
229
+
230
+
// For create/update operations, we have record data
231
+
const record = event.commit.record;
232
+
const $type = record?.$type as string;
233
+
234
+
// Support simple $type matching
235
+
if (filterKey.startsWith('$type:')) {
236
+
const expectedType = filterKey.substring(6);
237
+
return $type === expectedType;
238
+
}
239
+
240
+
// Support collection matching
241
+
if (filterKey.startsWith('collection:')) {
242
+
const expectedCollection = filterKey.substring(11);
243
+
return event.commit.collection === expectedCollection;
244
+
}
245
+
246
+
// Support operation matching
247
+
if (filterKey.startsWith('operation:')) {
248
+
const expectedOperation = filterKey.substring(10);
249
+
return event.commit.operation === expectedOperation;
250
+
}
251
+
252
+
// Default to exact match
253
+
return $type === filterKey;
254
+
}
255
+
256
+
// Convenience functions for common filters
257
+
export function subscribeToStatusUpdates(callback: (event: CommitEvent) => void): () => void {
258
+
return subscribeToRecords('$type:a.status.update', callback);
259
+
}
260
+
261
+
export function subscribeToPosts(callback: (event: CommitEvent) => void): () => void {
262
+
return subscribeToRecords('$type:app.bsky.feed.post', callback);
263
+
}
264
+
265
+
export function subscribeToGalleryUpdates(callback: (event: CommitEvent) => void): () => void {
266
+
return subscribeToRecords('collection:social.grain.gallery', callback);
248
267
}
-113
src/lib/components/discovered-registry.ts
-113
src/lib/components/discovered-registry.ts
···
1
-
import type { DiscoveredTypes } from '../generated/discovered-types';
2
-
import type { AnyRecordByType } from '../atproto/record-types';
3
-
4
-
export type DiscoveredComponent<T extends string = DiscoveredTypes> = {
5
-
$type: T;
6
-
component: string;
7
-
props: Record<string, unknown>;
8
-
}
9
-
10
-
export type ComponentRegistry = Record<string, { component: string; props?: Record<string, unknown> }>
11
-
12
-
export class DiscoveredComponentRegistry {
13
-
private registry: ComponentRegistry = {};
14
-
private discoveredTypes: DiscoveredTypes[] = [];
15
-
16
-
constructor() {
17
-
this.initializeRegistry();
18
-
}
19
-
20
-
// Initialize the registry with discovered types
21
-
private initializeRegistry(): void {
22
-
// This will be populated with discovered types
23
-
// For now, we'll use a basic mapping
24
-
this.registry = {
25
-
'app.bsky.feed.post': {
26
-
component: 'BlueskyPost',
27
-
props: { showAuthor: false, showTimestamp: true }
28
-
},
29
-
'app.bsky.actor.profile': {
30
-
component: 'ProfileDisplay',
31
-
props: { showHandle: true }
32
-
},
33
-
'social.grain.gallery': {
34
-
component: 'GrainGalleryDisplay',
35
-
props: { showCollections: true, columns: 3 }
36
-
},
37
-
'grain.social.feed.gallery': {
38
-
component: 'GrainGalleryDisplay',
39
-
props: { showCollections: true, columns: 3 }
40
-
}
41
-
};
42
-
}
43
-
44
-
// Register a component for a specific $type
45
-
registerComponent($type: DiscoveredTypes, component: string, props?: Record<string, unknown>): void {
46
-
this.registry[$type] = {
47
-
component,
48
-
props
49
-
};
50
-
}
51
-
52
-
// Get component info for a $type
53
-
getComponent($type: DiscoveredTypes): { component: string; props?: Record<string, unknown> } | null {
54
-
return this.registry[$type] || null;
55
-
}
56
-
57
-
// Get all registered $types
58
-
getRegisteredTypes(): DiscoveredTypes[] {
59
-
return Object.keys(this.registry) as DiscoveredTypes[];
60
-
}
61
-
62
-
// Check if a $type has a registered component
63
-
hasComponent($type: DiscoveredTypes): boolean {
64
-
return $type in this.registry;
65
-
}
66
-
67
-
// Get component mapping for rendering
68
-
getComponentMapping(): ComponentRegistry {
69
-
return this.registry;
70
-
}
71
-
72
-
// Update discovered types (called after build-time discovery)
73
-
updateDiscoveredTypes(types: DiscoveredTypes[]): void {
74
-
this.discoveredTypes = types;
75
-
76
-
// Auto-register components for discovered types that don't have explicit mappings
77
-
for (const $type of types) {
78
-
if (!this.hasComponent($type)) {
79
-
// Auto-assign based on service/collection
80
-
const component = this.autoAssignComponent($type);
81
-
if (component) {
82
-
this.registerComponent($type, component);
83
-
}
84
-
}
85
-
}
86
-
}
87
-
88
-
// Auto-assign component based on $type
89
-
private autoAssignComponent($type: DiscoveredTypes): string | null {
90
-
if ($type.includes('grain') || $type.includes('gallery')) {
91
-
return 'GrainGalleryDisplay';
92
-
}
93
-
if ($type.includes('post') || $type.includes('feed')) {
94
-
return 'BlueskyPost';
95
-
}
96
-
if ($type.includes('profile') || $type.includes('actor')) {
97
-
return 'ProfileDisplay';
98
-
}
99
-
return 'GenericContentDisplay';
100
-
}
101
-
102
-
// Get component info for rendering
103
-
getComponentInfo<T extends DiscoveredTypes>($type: T): DiscoveredComponent<T> | null {
104
-
const componentInfo = this.getComponent($type);
105
-
if (!componentInfo) return null;
106
-
107
-
return {
108
-
$type,
109
-
component: componentInfo.component,
110
-
props: componentInfo.props || {}
111
-
};
112
-
}
113
-
}
-23
src/lib/components/register.ts
-23
src/lib/components/register.ts
···
1
-
import { registerComponent } from './registry';
2
-
import BlueskyPost from '../../components/content/BlueskyPost.astro';
3
-
import WhitewindBlogPost from '../../components/content/WhitewindBlogPost.astro';
4
-
import LeafletPublication from '../../components/content/LeafletPublication.astro';
5
-
import GrainImageGallery from '../../components/content/GrainImageGallery.astro';
6
-
7
-
// Register all content components
8
-
export function registerAllComponents() {
9
-
// Register Bluesky post component
10
-
registerComponent('app.bsky.feed.post', BlueskyPost);
11
-
12
-
// Register Whitewind blog post component
13
-
registerComponent('app.bsky.actor.profile#whitewindBlogPost', WhitewindBlogPost);
14
-
15
-
// Register Leaflet publication component
16
-
registerComponent('app.bsky.actor.profile#leafletPublication', LeafletPublication);
17
-
18
-
// Register Grain image gallery component
19
-
registerComponent('app.bsky.actor.profile#grainImageGallery', GrainImageGallery);
20
-
}
21
-
22
-
// Auto-register components when this module is imported
23
-
registerAllComponents();
+10
-5
src/lib/components/registry.ts
+10
-5
src/lib/components/registry.ts
···
18
18
component: 'WhitewindBlogPost',
19
19
props: {}
20
20
},
21
-
// Add more mappings as you create components
22
-
// 'ComExampleRecord': {
23
-
// component: 'ExampleComponent',
24
-
// props: {}
25
-
// }
21
+
'AStatusUpdate': {
22
+
component: 'StatusUpdate',
23
+
props: {}
24
+
},
25
+
// Bluesky posts (not in generated types, but used by components)
26
+
'app.bsky.feed.post': {
27
+
component: 'BlueskyPost',
28
+
props: {}
29
+
},
30
+
26
31
};
27
32
28
33
// Type-safe component lookup
+15
src/lib/generated/a-status-update.ts
+15
src/lib/generated/a-status-update.ts
···
1
+
// Generated from lexicon schema: a.status.update
2
+
// Do not edit manually - regenerate with: npm run gen:types
3
+
4
+
export interface AStatusUpdateRecord {
5
+
text: string;
6
+
createdAt: string;
7
+
}
8
+
9
+
export interface AStatusUpdate {
10
+
$type: 'a.status.update';
11
+
value: AStatusUpdateRecord;
12
+
}
13
+
14
+
// Helper type for discriminated unions
15
+
export type AStatusUpdateUnion = AStatusUpdate;
+22
src/lib/generated/com-whtwnd-blog-entry.ts
+22
src/lib/generated/com-whtwnd-blog-entry.ts
···
1
+
// Generated from lexicon schema: com.whtwnd.blog.entry
2
+
// Do not edit manually - regenerate with: npm run gen:types
3
+
4
+
export interface ComWhtwndBlogEntryRecord {
5
+
content: string;
6
+
createdAt?: string;
7
+
title?: string;
8
+
subtitle?: string;
9
+
ogp?: any;
10
+
theme?: 'github-light';
11
+
blobs?: any[];
12
+
isDraft?: boolean;
13
+
visibility?: 'public' | 'url' | 'author';
14
+
}
15
+
16
+
export interface ComWhtwndBlogEntry {
17
+
$type: 'com.whtwnd.blog.entry';
18
+
value: ComWhtwndBlogEntryRecord;
19
+
}
20
+
21
+
// Helper type for discriminated unions
22
+
export type ComWhtwndBlogEntryUnion = ComWhtwndBlogEntry;
+2360
src/lib/generated/discovered-types.json
+2360
src/lib/generated/discovered-types.json
···
1
+
{
2
+
"collections": [
3
+
{
4
+
"name": "app.bsky.actor.profile",
5
+
"description": "Bluesky profile information",
6
+
"service": "bsky.app",
7
+
"sampleRecords": [
8
+
{
9
+
"$type": "app.bsky.actor.profile",
10
+
"avatar": {
11
+
"$type": "blob",
12
+
"ref": {
13
+
"$link": "bafkreig6momh2fkdfhhqwkcjsw4vycubptufe6aeolsddtgg6felh4bvoe"
14
+
},
15
+
"mimeType": "image/jpeg",
16
+
"size": 915425
17
+
},
18
+
"banner": {
19
+
"$type": "blob",
20
+
"ref": {
21
+
"$link": "bafkreicyefphti37yc4grac2q47eedkdwj67tnbomr5rwz62ikvrqvvcg4"
22
+
},
23
+
"mimeType": "image/jpeg",
24
+
"size": 806361
25
+
},
26
+
"description": "Experience designer for the decentralized web\n\n๐ Boston ๐บ๐ธ\n\nmy work: tynanpurdy.com\nmy writing: blog.tynanpurdy.com\n\nI'm on Germ DM ๐ and Signal @tynanpurdy.95\nhttps://ger.mx/A1CM6J3FugcfRh4NCNElrRdMsA9tLViN0QqqcMoMxkdS#did:plc:6ayddqghxhciedbaofoxkcbs",
27
+
"displayName": "Tynan Purdy"
28
+
}
29
+
],
30
+
"generatedTypes": "export interface AppBskyActorProfile {\n $type: 'app.bsky.actor.profile';\n avatar?: Record<string, any>;\n banner?: Record<string, any>;\n description?: string;\n displayName?: string;\n}\n",
31
+
"$types": [
32
+
"app.bsky.actor.profile"
33
+
]
34
+
},
35
+
{
36
+
"name": "app.bsky.feed.like",
37
+
"description": "Bluesky like records",
38
+
"service": "bsky.app",
39
+
"sampleRecords": [
40
+
{
41
+
"$type": "app.bsky.feed.like",
42
+
"subject": {
43
+
"cid": "bafyreigdkn5773gshisfjoawof5aahcetkmwvc4pjjgo4xv3wu23ownss4",
44
+
"uri": "at://did:plc:x56l2n7i7babgdzqul4bd433/app.bsky.feed.post/3lvqn6sjvtc2u"
45
+
},
46
+
"createdAt": "2025-08-06T17:07:51.363Z"
47
+
},
48
+
{
49
+
"$type": "app.bsky.feed.like",
50
+
"subject": {
51
+
"cid": "bafyreihunfswi26wp276moerpjfyaoxa3qfcyxfk5aiohk5kitkhjw2nye",
52
+
"uri": "at://did:plc:vc7f4oafdgxsihk4cry2xpze/app.bsky.feed.post/3lvqfhz6tyk2z"
53
+
},
54
+
"createdAt": "2025-08-06T15:09:06.729Z"
55
+
},
56
+
{
57
+
"$type": "app.bsky.feed.like",
58
+
"subject": {
59
+
"cid": "bafyreibcvqmt6cnhx3gwhji7ljltsncah2lf6rlqc4wwopxqz7r5oyyryy",
60
+
"uri": "at://did:plc:pkeo4mm3uwnik45y3tgjc5cs/app.bsky.feed.post/3lvqicp2fvb2z"
61
+
},
62
+
"createdAt": "2025-08-06T15:00:41.448Z"
63
+
}
64
+
],
65
+
"generatedTypes": "export interface AppBskyFeedLike {\n $type: 'app.bsky.feed.like';\n subject?: Record<string, any>;\n createdAt?: string;\n}\n",
66
+
"$types": [
67
+
"app.bsky.feed.like"
68
+
]
69
+
},
70
+
{
71
+
"name": "app.bsky.feed.post",
72
+
"description": "Bluesky posts",
73
+
"service": "bsky.app",
74
+
"sampleRecords": [
75
+
{
76
+
"text": "yep shoulda realized the graze turbostream would only include bluesky records",
77
+
"$type": "app.bsky.feed.post",
78
+
"langs": [
79
+
"en"
80
+
],
81
+
"createdAt": "2025-08-06T14:08:17.583Z"
82
+
},
83
+
{
84
+
"text": "this is a test",
85
+
"$type": "app.bsky.feed.post",
86
+
"langs": [
87
+
"en"
88
+
],
89
+
"createdAt": "2025-08-06T14:04:21.632Z"
90
+
},
91
+
{
92
+
"text": "Is there not merit to 'sign in with atproto'? The decentralized identity is a major selling point of the protocol, not just the usage of lexicon.",
93
+
"$type": "app.bsky.feed.post",
94
+
"langs": [
95
+
"en"
96
+
],
97
+
"reply": {
98
+
"root": {
99
+
"cid": "bafyreigxgudy4k2zvpikpnpqxwbwmkb6whfzkefr2rovd4y7pcppoggj7y",
100
+
"uri": "at://did:plc:nmc77zslrwafxn75j66mep6o/app.bsky.feed.post/3lvpyn2gacc2e"
101
+
},
102
+
"parent": {
103
+
"cid": "bafyreigxgudy4k2zvpikpnpqxwbwmkb6whfzkefr2rovd4y7pcppoggj7y",
104
+
"uri": "at://did:plc:nmc77zslrwafxn75j66mep6o/app.bsky.feed.post/3lvpyn2gacc2e"
105
+
}
106
+
},
107
+
"createdAt": "2025-08-06T13:28:03.405Z"
108
+
}
109
+
],
110
+
"generatedTypes": "export interface AppBskyFeedPost {\n $type: 'app.bsky.feed.post';\n text?: string;\n langs?: string[];\n createdAt?: string;\n}\n",
111
+
"$types": [
112
+
"app.bsky.feed.post"
113
+
]
114
+
},
115
+
{
116
+
"name": "app.bsky.feed.postgate",
117
+
"description": "app.bsky.feed.postgate records",
118
+
"service": "bsky.app",
119
+
"sampleRecords": [
120
+
{
121
+
"post": "at://did:plc:6ayddqghxhciedbaofoxkcbs/app.bsky.feed.post/3laub4w4obc2a",
122
+
"$type": "app.bsky.feed.postgate",
123
+
"createdAt": "2024-11-13T21:11:08.065Z",
124
+
"embeddingRules": [
125
+
{
126
+
"$type": "app.bsky.feed.postgate#disableRule"
127
+
}
128
+
],
129
+
"detachedEmbeddingUris": []
130
+
}
131
+
],
132
+
"generatedTypes": "export interface AppBskyFeedPostgate {\n $type: 'app.bsky.feed.postgate';\n post?: string;\n createdAt?: string;\n embeddingRules?: Record<string, any>[];\n detachedEmbeddingUris?: any[];\n}\n",
133
+
"$types": [
134
+
"app.bsky.feed.postgate"
135
+
]
136
+
},
137
+
{
138
+
"name": "app.bsky.feed.repost",
139
+
"description": "Bluesky repost records",
140
+
"service": "bsky.app",
141
+
"sampleRecords": [
142
+
{
143
+
"$type": "app.bsky.feed.repost",
144
+
"subject": {
145
+
"cid": "bafyreihwclfpjxtgkjdempzvvwo4ayj5ynm3aezry4ym3its6tdiy5kz3i",
146
+
"uri": "at://did:plc:i6y3jdklpvkjvynvsrnqfdoq/app.bsky.feed.post/3lvofxjoqt22g"
147
+
},
148
+
"createdAt": "2025-08-05T19:50:46.238Z"
149
+
},
150
+
{
151
+
"$type": "app.bsky.feed.repost",
152
+
"subject": {
153
+
"cid": "bafyreibcfwujyfz2fnpks62tujff5qxlqtfrndz7bllv3u4l7zp6xes6ka",
154
+
"uri": "at://did:plc:sfjxpxxyvewb2zlxwoz2vduw/app.bsky.feed.post/3lvldmqr7fs2q"
155
+
},
156
+
"createdAt": "2025-08-04T14:10:40.273Z"
157
+
},
158
+
{
159
+
"$type": "app.bsky.feed.repost",
160
+
"subject": {
161
+
"cid": "bafyreicxla5m4mw2ocmxwtsodwijcy7hlgedml2gbohpgvc5ftf2j2z3sa",
162
+
"uri": "at://did:plc:tpg43qhh4lw4ksiffs4nbda3/app.bsky.feed.post/3lvizvvkvhk2d"
163
+
},
164
+
"createdAt": "2025-08-03T21:17:44.210Z"
165
+
}
166
+
],
167
+
"generatedTypes": "export interface AppBskyFeedRepost {\n $type: 'app.bsky.feed.repost';\n subject?: Record<string, any>;\n createdAt?: string;\n}\n",
168
+
"$types": [
169
+
"app.bsky.feed.repost"
170
+
]
171
+
},
172
+
{
173
+
"name": "app.bsky.feed.threadgate",
174
+
"description": "app.bsky.feed.threadgate records",
175
+
"service": "bsky.app",
176
+
"sampleRecords": [
177
+
{
178
+
"post": "at://did:plc:6ayddqghxhciedbaofoxkcbs/app.bsky.feed.post/3laub4w4obc2a",
179
+
"$type": "app.bsky.feed.threadgate",
180
+
"allow": [
181
+
{
182
+
"$type": "app.bsky.feed.threadgate#mentionRule"
183
+
}
184
+
],
185
+
"createdAt": "2024-11-13T21:11:08.361Z",
186
+
"hiddenReplies": []
187
+
}
188
+
],
189
+
"generatedTypes": "export interface AppBskyFeedThreadgate {\n $type: 'app.bsky.feed.threadgate';\n post?: string;\n allow?: Record<string, any>[];\n createdAt?: string;\n hiddenReplies?: any[];\n}\n",
190
+
"$types": [
191
+
"app.bsky.feed.threadgate"
192
+
]
193
+
},
194
+
{
195
+
"name": "app.bsky.graph.block",
196
+
"description": "Bluesky block relationships",
197
+
"service": "bsky.app",
198
+
"sampleRecords": [
199
+
{
200
+
"$type": "app.bsky.graph.block",
201
+
"subject": "did:plc:7tw5kcf6375mlghspilpmpix",
202
+
"createdAt": "2025-07-23T22:11:19.502Z"
203
+
},
204
+
{
205
+
"$type": "app.bsky.graph.block",
206
+
"subject": "did:plc:f6y7lzl5jslkfn7ta47e7to3",
207
+
"createdAt": "2025-07-23T20:01:13.114Z"
208
+
},
209
+
{
210
+
"$type": "app.bsky.graph.block",
211
+
"subject": "did:plc:efwg4pw7qdyuh3qgr5x454fd",
212
+
"createdAt": "2025-06-27T18:04:36.550Z"
213
+
}
214
+
],
215
+
"generatedTypes": "export interface AppBskyGraphBlock {\n $type: 'app.bsky.graph.block';\n subject?: string;\n createdAt?: string;\n}\n",
216
+
"$types": [
217
+
"app.bsky.graph.block"
218
+
]
219
+
},
220
+
{
221
+
"name": "app.bsky.graph.follow",
222
+
"description": "Bluesky follow relationships",
223
+
"service": "bsky.app",
224
+
"sampleRecords": [
225
+
{
226
+
"$type": "app.bsky.graph.follow",
227
+
"subject": "did:plc:mdailwqaetwpqnysw6qllqwl",
228
+
"createdAt": "2025-08-06T14:57:20.859Z"
229
+
},
230
+
{
231
+
"$type": "app.bsky.graph.follow",
232
+
"subject": "did:plc:f2np526hugxvamu25t6l4y6e",
233
+
"createdAt": "2025-08-05T21:41:26.560Z"
234
+
},
235
+
{
236
+
"$type": "app.bsky.graph.follow",
237
+
"subject": "did:plc:7axcqwj4roha6mqpdhpdwczx",
238
+
"createdAt": "2025-08-05T21:37:20.051Z"
239
+
}
240
+
],
241
+
"generatedTypes": "export interface AppBskyGraphFollow {\n $type: 'app.bsky.graph.follow';\n subject?: string;\n createdAt?: string;\n}\n",
242
+
"$types": [
243
+
"app.bsky.graph.follow"
244
+
]
245
+
},
246
+
{
247
+
"name": "app.bsky.graph.list",
248
+
"description": "app.bsky.graph.list records",
249
+
"service": "bsky.app",
250
+
"sampleRecords": [
251
+
{
252
+
"name": "hehe comics",
253
+
"$type": "app.bsky.graph.list",
254
+
"purpose": "app.bsky.graph.defs#curatelist",
255
+
"createdAt": "2025-08-04T15:44:09.398Z",
256
+
"description": "these are fun"
257
+
},
258
+
{
259
+
"name": "What's up Boston",
260
+
"$type": "app.bsky.graph.list",
261
+
"purpose": "app.bsky.graph.defs#referencelist",
262
+
"createdAt": "2025-08-04T15:15:39.837Z"
263
+
},
264
+
{
265
+
"name": "My domain is proof of my identity",
266
+
"$type": "app.bsky.graph.list",
267
+
"avatar": {
268
+
"$type": "blob",
269
+
"ref": {
270
+
"$link": "bafkreie56qtqmtp5iu56zqqjkvwhvzqcudtsbetgs2joaowvuljbcluvze"
271
+
},
272
+
"mimeType": "image/jpeg",
273
+
"size": 941683
274
+
},
275
+
"purpose": "app.bsky.graph.defs#curatelist",
276
+
"createdAt": "2025-07-31T15:57:34.609Z",
277
+
"description": "You know this is me because its tynanpurdy.com",
278
+
"descriptionFacets": [
279
+
{
280
+
"index": {
281
+
"byteEnd": 46,
282
+
"byteStart": 32
283
+
},
284
+
"features": [
285
+
{
286
+
"uri": "https://tynanpurdy.com",
287
+
"$type": "app.bsky.richtext.facet#link"
288
+
}
289
+
]
290
+
}
291
+
]
292
+
}
293
+
],
294
+
"generatedTypes": "export interface AppBskyGraphList {\n $type: 'app.bsky.graph.list';\n name?: string;\n purpose?: string;\n createdAt?: string;\n description?: string;\n}\n",
295
+
"$types": [
296
+
"app.bsky.graph.list"
297
+
]
298
+
},
299
+
{
300
+
"name": "app.bsky.graph.listitem",
301
+
"description": "app.bsky.graph.listitem records",
302
+
"service": "bsky.app",
303
+
"sampleRecords": [
304
+
{
305
+
"list": "at://did:plc:6ayddqghxhciedbaofoxkcbs/app.bsky.graph.list/3lquir4dvpk2v",
306
+
"$type": "app.bsky.graph.listitem",
307
+
"subject": "did:plc:mdailwqaetwpqnysw6qllqwl",
308
+
"createdAt": "2025-08-06T14:45:30.004Z"
309
+
},
310
+
{
311
+
"list": "at://did:plc:6ayddqghxhciedbaofoxkcbs/app.bsky.graph.list/3lquir4dvpk2v",
312
+
"$type": "app.bsky.graph.listitem",
313
+
"subject": "did:plc:k2r4d4exuuord4sos4wavcoj",
314
+
"createdAt": "2025-08-05T13:39:59.770Z"
315
+
},
316
+
{
317
+
"list": "at://did:plc:6ayddqghxhciedbaofoxkcbs/app.bsky.graph.list/3lvljxc7e2r2j",
318
+
"$type": "app.bsky.graph.listitem",
319
+
"subject": "did:plc:v3n5wr27y6flohaycmcgsrij",
320
+
"createdAt": "2025-08-04T19:01:10.319Z"
321
+
}
322
+
],
323
+
"generatedTypes": "export interface AppBskyGraphListitem {\n $type: 'app.bsky.graph.listitem';\n list?: string;\n subject?: string;\n createdAt?: string;\n}\n",
324
+
"$types": [
325
+
"app.bsky.graph.listitem"
326
+
]
327
+
},
328
+
{
329
+
"name": "app.bsky.graph.starterpack",
330
+
"description": "app.bsky.graph.starterpack records",
331
+
"service": "bsky.app",
332
+
"sampleRecords": [
333
+
{
334
+
"list": "at://did:plc:6ayddqghxhciedbaofoxkcbs/app.bsky.graph.list/3lvliedprgp2t",
335
+
"name": "What's up Boston",
336
+
"$type": "app.bsky.graph.starterpack",
337
+
"feeds": [
338
+
{
339
+
"cid": "bafyreigd75aawyao7dikal4bm634dxtgf7xp43msbywcx4ctcyqojjubni",
340
+
"did": "did:web:skyfeed.me",
341
+
"uri": "at://did:plc:r2mpjf3gz2ygfaodkzzzfddg/app.bsky.feed.generator/aaag6jgz6tfou",
342
+
"labels": [],
343
+
"viewer": {},
344
+
"creator": {
345
+
"did": "did:plc:r2mpjf3gz2ygfaodkzzzfddg",
346
+
"avatar": "https://cdn.bsky.app/img/avatar/plain/did:plc:r2mpjf3gz2ygfaodkzzzfddg/bafkreidvm7mnr3mwg7lnktrpeebsr7tz5u4ic3acrdlnte7ojp45vrtmjm@jpeg",
347
+
"handle": "boston.gov",
348
+
"labels": [
349
+
{
350
+
"cts": "2025-02-12T02:03:24.048Z",
351
+
"src": "did:plc:m6adptn62dcahfaq34tce3j5",
352
+
"uri": "did:plc:r2mpjf3gz2ygfaodkzzzfddg",
353
+
"val": "joined-feb-c",
354
+
"ver": 1
355
+
},
356
+
{
357
+
"cts": "2025-06-06T17:54:44.623Z",
358
+
"src": "did:plc:fqfzpua2rp5io5nmxcixvdvm",
359
+
"uri": "did:plc:r2mpjf3gz2ygfaodkzzzfddg",
360
+
"val": "voting-for-kodos",
361
+
"ver": 1
362
+
}
363
+
],
364
+
"viewer": {
365
+
"muted": false,
366
+
"blockedBy": false,
367
+
"following": "at://did:plc:6ayddqghxhciedbaofoxkcbs/app.bsky.graph.follow/3lvlj4yyafh2w"
368
+
},
369
+
"createdAt": "2024-02-09T16:18:11.855Z",
370
+
"indexedAt": "2025-04-29T23:55:10.143Z",
371
+
"associated": {
372
+
"activitySubscription": {
373
+
"allowSubscriptions": "followers"
374
+
}
375
+
},
376
+
"description": "The official Bluesky account of the City of Boston. For non-emergency services, please call 311. \n\nBoston.gov",
377
+
"displayName": "City of Boston",
378
+
"verification": {
379
+
"verifications": [
380
+
{
381
+
"uri": "at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.graph.verification/3lu23uowywx2m",
382
+
"issuer": "did:plc:z72i7hdynmk6r22z27h6tvur",
383
+
"isValid": true,
384
+
"createdAt": "2025-07-15T23:51:42.492Z"
385
+
}
386
+
],
387
+
"verifiedStatus": "valid",
388
+
"trustedVerifierStatus": "none"
389
+
}
390
+
},
391
+
"indexedAt": "2024-11-27T19:48:59.425Z",
392
+
"likeCount": 21,
393
+
"description": "Official City of Boston accounts representing departments, cabinets, commissions and more teams working to make Boston a home for everyone.",
394
+
"displayName": "City of Boston"
395
+
}
396
+
],
397
+
"createdAt": "2025-08-04T15:15:40.040Z",
398
+
"updatedAt": "2025-08-04T15:36:11.792Z"
399
+
}
400
+
],
401
+
"generatedTypes": "export interface AppBskyGraphStarterpack {\n $type: 'app.bsky.graph.starterpack';\n list?: string;\n name?: string;\n feeds?: Record<string, any>[];\n createdAt?: string;\n updatedAt?: string;\n}\n",
402
+
"$types": [
403
+
"app.bsky.graph.starterpack"
404
+
]
405
+
},
406
+
{
407
+
"name": "app.bsky.graph.verification",
408
+
"description": "app.bsky.graph.verification records",
409
+
"service": "bsky.app",
410
+
"sampleRecords": [
411
+
{
412
+
"$type": "app.bsky.graph.verification",
413
+
"handle": "goodwillhinton.com",
414
+
"subject": "did:plc:ek7ua4ev2a35cdgehdxo4jsg",
415
+
"createdAt": "2025-07-10T19:57:12.524Z",
416
+
"displayName": "Good Will Hinton"
417
+
},
418
+
{
419
+
"$type": "app.bsky.graph.verification",
420
+
"handle": "jacknicolaus.bsky.social",
421
+
"subject": "did:plc:5ldqrqjevj5q5s5dyc3w6q2a",
422
+
"createdAt": "2025-07-10T19:55:00.870Z",
423
+
"displayName": "Jack Purdy"
424
+
},
425
+
{
426
+
"$type": "app.bsky.graph.verification",
427
+
"handle": "jennpurdy.bsky.social",
428
+
"subject": "did:plc:7tai7ouufljuzlhjajpiojyw",
429
+
"createdAt": "2025-07-10T19:54:44.028Z",
430
+
"displayName": "Jenn Purdy"
431
+
}
432
+
],
433
+
"generatedTypes": "export interface AppBskyGraphVerification {\n $type: 'app.bsky.graph.verification';\n handle?: string;\n subject?: string;\n createdAt?: string;\n displayName?: string;\n}\n",
434
+
"$types": [
435
+
"app.bsky.graph.verification"
436
+
]
437
+
},
438
+
{
439
+
"name": "app.popsky.list",
440
+
"description": "app.popsky.list records",
441
+
"service": "unknown",
442
+
"sampleRecords": [
443
+
{
444
+
"name": "Played Games",
445
+
"$type": "app.popsky.list",
446
+
"authorDid": "did:plc:6ayddqghxhciedbaofoxkcbs",
447
+
"createdAt": "2025-07-29T18:45:43.158Z",
448
+
"indexedAt": "2025-07-29T18:45:43.158Z",
449
+
"description": ""
450
+
},
451
+
{
452
+
"name": "The good life",
453
+
"tags": [],
454
+
"$type": "app.popsky.list",
455
+
"ordered": false,
456
+
"createdAt": "2025-07-29T18:42:13.706Z",
457
+
"description": ""
458
+
},
459
+
{
460
+
"name": "Listened Albums & EPs",
461
+
"$type": "app.popsky.list",
462
+
"authorDid": "did:plc:6ayddqghxhciedbaofoxkcbs",
463
+
"createdAt": "2025-07-29T18:40:45.138Z",
464
+
"indexedAt": "2025-07-29T18:40:45.138Z",
465
+
"description": ""
466
+
}
467
+
],
468
+
"generatedTypes": "export interface AppPopskyList {\n $type: 'app.popsky.list';\n name?: string;\n authorDid?: string;\n createdAt?: string;\n indexedAt?: string;\n description?: string;\n}\n",
469
+
"$types": [
470
+
"app.popsky.list"
471
+
]
472
+
},
473
+
{
474
+
"name": "app.popsky.listItem",
475
+
"description": "app.popsky.listItem records",
476
+
"service": "unknown",
477
+
"sampleRecords": [
478
+
{
479
+
"$type": "app.popsky.listItem",
480
+
"addedAt": "2025-08-04T20:56:17.962Z",
481
+
"listUri": "at://did:plc:6ayddqghxhciedbaofoxkcbs/app.popsky.list/3lv4qhdmaoc2f",
482
+
"identifiers": {
483
+
"isbn10": "1991152310",
484
+
"isbn13": "9781991152312"
485
+
},
486
+
"creativeWorkType": "book"
487
+
},
488
+
{
489
+
"$type": "app.popsky.listItem",
490
+
"addedAt": "2025-08-04T20:56:14.809Z",
491
+
"listUri": "at://did:plc:6ayddqghxhciedbaofoxkcbs/app.popsky.list/3lv4r46l6ck2f",
492
+
"identifiers": {
493
+
"isbn10": "1991152310",
494
+
"isbn13": "9781991152312"
495
+
},
496
+
"creativeWorkType": "book"
497
+
},
498
+
{
499
+
"$type": "app.popsky.listItem",
500
+
"addedAt": "2025-08-04T20:55:30.197Z",
501
+
"listUri": "at://did:plc:6ayddqghxhciedbaofoxkcbs/app.popsky.list/3ltyvzvrlkk2k",
502
+
"identifiers": {
503
+
"isbn10": "1988575060",
504
+
"isbn13": "9781988575063"
505
+
},
506
+
"creativeWorkType": "book"
507
+
}
508
+
],
509
+
"generatedTypes": "export interface AppPopskyListItem {\n $type: 'app.popsky.listItem';\n addedAt?: string;\n listUri?: string;\n identifiers?: Record<string, any>;\n creativeWorkType?: string;\n}\n",
510
+
"$types": [
511
+
"app.popsky.listItem"
512
+
]
513
+
},
514
+
{
515
+
"name": "app.popsky.profile",
516
+
"description": "app.popsky.profile records",
517
+
"service": "unknown",
518
+
"sampleRecords": [
519
+
{
520
+
"$type": "app.popsky.profile",
521
+
"createdAt": "2025-07-29T18:29:43.645Z",
522
+
"description": "",
523
+
"displayName": "Tynan Purdy"
524
+
}
525
+
],
526
+
"generatedTypes": "export interface AppPopskyProfile {\n $type: 'app.popsky.profile';\n createdAt?: string;\n description?: string;\n displayName?: string;\n}\n",
527
+
"$types": [
528
+
"app.popsky.profile"
529
+
]
530
+
},
531
+
{
532
+
"name": "app.popsky.review",
533
+
"description": "app.popsky.review records",
534
+
"service": "unknown",
535
+
"sampleRecords": [
536
+
{
537
+
"tags": [],
538
+
"$type": "app.popsky.review",
539
+
"facets": [],
540
+
"rating": 10,
541
+
"createdAt": "2025-07-29T18:45:40.559Z",
542
+
"isRevisit": false,
543
+
"reviewText": "",
544
+
"identifiers": {
545
+
"igdbId": "28512"
546
+
},
547
+
"containsSpoilers": false,
548
+
"creativeWorkType": "video_game"
549
+
},
550
+
{
551
+
"tags": [],
552
+
"$type": "app.popsky.review",
553
+
"facets": [],
554
+
"rating": 9,
555
+
"createdAt": "2024-01-05T00:00:00.000Z",
556
+
"isRevisit": false,
557
+
"reviewText": "",
558
+
"identifiers": {
559
+
"isbn13": "9781797114613"
560
+
},
561
+
"containsSpoilers": false,
562
+
"creativeWorkType": "book"
563
+
},
564
+
{
565
+
"tags": [],
566
+
"$type": "app.popsky.review",
567
+
"facets": [],
568
+
"rating": 8,
569
+
"createdAt": "2024-05-02T00:00:00.000Z",
570
+
"isRevisit": false,
571
+
"reviewText": "",
572
+
"identifiers": {
573
+
"isbn13": "9781415959138"
574
+
},
575
+
"containsSpoilers": false,
576
+
"creativeWorkType": "book"
577
+
}
578
+
],
579
+
"generatedTypes": "export interface AppPopskyReview {\n $type: 'app.popsky.review';\n tags?: any[];\n facets?: any[];\n rating?: number;\n createdAt?: string;\n isRevisit?: boolean;\n reviewText?: string;\n identifiers?: Record<string, any>;\n containsSpoilers?: boolean;\n creativeWorkType?: string;\n}\n",
580
+
"$types": [
581
+
"app.popsky.review"
582
+
]
583
+
},
584
+
{
585
+
"name": "app.rocksky.album",
586
+
"description": "app.rocksky.album records",
587
+
"service": "unknown",
588
+
"sampleRecords": [
589
+
{
590
+
"year": 2025,
591
+
"$type": "app.rocksky.album",
592
+
"title": "Heartbreak Hysteria",
593
+
"artist": "Sawyer Hill",
594
+
"albumArt": {
595
+
"$type": "blob",
596
+
"ref": {
597
+
"$link": "bafkreibaol5obcwhudbcli2o33nh7ats764hqnkpfxjxqn3ykjeb65ht5q"
598
+
},
599
+
"mimeType": "image/jpeg",
600
+
"size": 38011
601
+
},
602
+
"createdAt": "2025-07-12T20:41:54.087Z",
603
+
"releaseDate": "2025-04-18T00:00:00.000Z"
604
+
},
605
+
{
606
+
"year": 2023,
607
+
"$type": "app.rocksky.album",
608
+
"title": "I Love You, Iโm Trying",
609
+
"artist": "grandson",
610
+
"albumArt": {
611
+
"$type": "blob",
612
+
"ref": {
613
+
"$link": "bafkreih72c6ye2vd7scwbedjbfoqzue26fnsfs42dc2quqououlvqzdly4"
614
+
},
615
+
"mimeType": "image/jpeg",
616
+
"size": 266870
617
+
},
618
+
"createdAt": "2025-07-12T20:39:46.979Z",
619
+
"releaseDate": "2023-05-05T00:00:00.000Z"
620
+
},
621
+
{
622
+
"year": 2025,
623
+
"$type": "app.rocksky.album",
624
+
"title": "june",
625
+
"artist": "DE'WAYNE",
626
+
"albumArt": {
627
+
"$type": "blob",
628
+
"ref": {
629
+
"$link": "bafkreihiydajfy66mbmk4g2cc4ymvcj5jkba2rxrnucxjcxl63rvch37ee"
630
+
},
631
+
"mimeType": "image/jpeg",
632
+
"size": 78882
633
+
},
634
+
"createdAt": "2025-07-12T20:36:54.769Z",
635
+
"releaseDate": "2025-05-14T00:00:00.000Z"
636
+
}
637
+
],
638
+
"generatedTypes": "export interface AppRockskyAlbum {\n $type: 'app.rocksky.album';\n year?: number;\n title?: string;\n artist?: string;\n albumArt?: Record<string, any>;\n createdAt?: string;\n releaseDate?: string;\n}\n",
639
+
"$types": [
640
+
"app.rocksky.album"
641
+
]
642
+
},
643
+
{
644
+
"name": "app.rocksky.artist",
645
+
"description": "app.rocksky.artist records",
646
+
"service": "unknown",
647
+
"sampleRecords": [
648
+
{
649
+
"name": "Sawyer Hill",
650
+
"$type": "app.rocksky.artist",
651
+
"picture": {
652
+
"$type": "blob",
653
+
"ref": {
654
+
"$link": "bafkreibndm5e5idr7o26wlci5twbcavtjrfatg4eeal7beieqxvqyipfnu"
655
+
},
656
+
"mimeType": "image/jpeg",
657
+
"size": 92121
658
+
},
659
+
"createdAt": "2025-07-12T20:41:51.867Z"
660
+
},
661
+
{
662
+
"name": "DE'WAYNE",
663
+
"$type": "app.rocksky.artist",
664
+
"picture": {
665
+
"$type": "blob",
666
+
"ref": {
667
+
"$link": "bafkreihvir6gp4ls2foh7grfx7cgouc4scxc7ubhk535ig7nbrvtpscf7u"
668
+
},
669
+
"mimeType": "image/jpeg",
670
+
"size": 167229
671
+
},
672
+
"createdAt": "2025-07-12T20:36:52.320Z"
673
+
},
674
+
{
675
+
"name": "Bryce Fox",
676
+
"$type": "app.rocksky.artist",
677
+
"picture": {
678
+
"$type": "blob",
679
+
"ref": {
680
+
"$link": "bafkreigx4a5reezucwsujfuyv27lun5xkavjtgkzvifknrsblh3jnvznhi"
681
+
},
682
+
"mimeType": "image/jpeg",
683
+
"size": 119015
684
+
},
685
+
"createdAt": "2025-07-12T20:34:10.891Z"
686
+
}
687
+
],
688
+
"generatedTypes": "export interface AppRockskyArtist {\n $type: 'app.rocksky.artist';\n name?: string;\n picture?: Record<string, any>;\n createdAt?: string;\n}\n",
689
+
"$types": [
690
+
"app.rocksky.artist"
691
+
]
692
+
},
693
+
{
694
+
"name": "app.rocksky.like",
695
+
"description": "app.rocksky.like records",
696
+
"service": "unknown",
697
+
"sampleRecords": [
698
+
{
699
+
"$type": "app.rocksky.like",
700
+
"subject": {
701
+
"cid": "bafyreiejzq3p7bqjuo6sgt4x3rwojnvu4jajrolzread7eqpzo7jryoega",
702
+
"uri": "at://did:plc:6ayddqghxhciedbaofoxkcbs/app.rocksky.song/3lmhvldhhgs2r"
703
+
},
704
+
"createdAt": "2025-04-17T14:30:30.388Z"
705
+
},
706
+
{
707
+
"$type": "app.rocksky.like",
708
+
"subject": {
709
+
"cid": "bafyreihvboqggljg677rxl6xxr2mqudlefmixhkrvf2gltmpzbhyzduqba",
710
+
"uri": "at://did:plc:6ayddqghxhciedbaofoxkcbs/app.rocksky.song/3lmhqwxjxek2m"
711
+
},
712
+
"createdAt": "2025-04-15T19:25:44.473Z"
713
+
},
714
+
{
715
+
"$type": "app.rocksky.like",
716
+
"subject": {
717
+
"cid": "bafyreiah4sm2jjrgcnqhmfaeze44th3svwlqbftpqepi67c2i6khzjqwya",
718
+
"uri": "at://did:plc:6ayddqghxhciedbaofoxkcbs/app.rocksky.song/3lloqnprwwk2c"
719
+
},
720
+
"createdAt": "2025-03-31T16:38:14.223Z"
721
+
}
722
+
],
723
+
"generatedTypes": "export interface AppRockskyLike {\n $type: 'app.rocksky.like';\n subject?: Record<string, any>;\n createdAt?: string;\n}\n",
724
+
"$types": [
725
+
"app.rocksky.like"
726
+
]
727
+
},
728
+
{
729
+
"name": "app.rocksky.scrobble",
730
+
"description": "app.rocksky.scrobble records",
731
+
"service": "unknown",
732
+
"sampleRecords": [
733
+
{
734
+
"year": 2025,
735
+
"$type": "app.rocksky.scrobble",
736
+
"album": "Heartbreak Hysteria",
737
+
"title": "One Shot",
738
+
"artist": "Sawyer Hill",
739
+
"albumArt": {
740
+
"$type": "blob",
741
+
"ref": {
742
+
"$link": "bafkreibaol5obcwhudbcli2o33nh7ats764hqnkpfxjxqn3ykjeb65ht5q"
743
+
},
744
+
"mimeType": "image/jpeg",
745
+
"size": 38011
746
+
},
747
+
"duration": 198380,
748
+
"createdAt": "2025-07-12T20:41:58.353Z",
749
+
"discNumber": 1,
750
+
"albumArtist": "Sawyer Hill",
751
+
"releaseDate": "2025-04-18T00:00:00.000Z",
752
+
"spotifyLink": "https://open.spotify.com/track/4yiiRbfHkqhCj0ChW62ASx",
753
+
"trackNumber": 2
754
+
},
755
+
{
756
+
"year": 2023,
757
+
"$type": "app.rocksky.scrobble",
758
+
"album": "I Love You, Iโm Trying",
759
+
"title": "Something To Hide",
760
+
"artist": "grandson",
761
+
"albumArt": {
762
+
"$type": "blob",
763
+
"ref": {
764
+
"$link": "bafkreih72c6ye2vd7scwbedjbfoqzue26fnsfs42dc2quqououlvqzdly4"
765
+
},
766
+
"mimeType": "image/jpeg",
767
+
"size": 266870
768
+
},
769
+
"duration": 119047,
770
+
"createdAt": "2025-07-12T20:39:51.055Z",
771
+
"discNumber": 1,
772
+
"albumArtist": "grandson",
773
+
"releaseDate": "2023-05-05T00:00:00.000Z",
774
+
"spotifyLink": "https://open.spotify.com/track/1rjZicKSpIJ3WYffIK9Fuy",
775
+
"trackNumber": 3
776
+
},
777
+
{
778
+
"year": 2025,
779
+
"$type": "app.rocksky.scrobble",
780
+
"album": "june",
781
+
"title": "june",
782
+
"artist": "DE'WAYNE",
783
+
"albumArt": {
784
+
"$type": "blob",
785
+
"ref": {
786
+
"$link": "bafkreihiydajfy66mbmk4g2cc4ymvcj5jkba2rxrnucxjcxl63rvch37ee"
787
+
},
788
+
"mimeType": "image/jpeg",
789
+
"size": 78882
790
+
},
791
+
"duration": 168000,
792
+
"createdAt": "2025-07-12T20:36:58.633Z",
793
+
"discNumber": 1,
794
+
"albumArtist": "DE'WAYNE",
795
+
"releaseDate": "2025-05-14T00:00:00.000Z",
796
+
"spotifyLink": "https://open.spotify.com/track/6PBJfoq40a8gsUULbn0oyG",
797
+
"trackNumber": 1
798
+
}
799
+
],
800
+
"generatedTypes": "export interface AppRockskyScrobble {\n $type: 'app.rocksky.scrobble';\n year?: number;\n album?: string;\n title?: string;\n artist?: string;\n albumArt?: Record<string, any>;\n duration?: number;\n createdAt?: string;\n discNumber?: number;\n albumArtist?: string;\n releaseDate?: string;\n spotifyLink?: string;\n trackNumber?: number;\n}\n",
801
+
"$types": [
802
+
"app.rocksky.scrobble"
803
+
]
804
+
},
805
+
{
806
+
"name": "app.rocksky.song",
807
+
"description": "app.rocksky.song records",
808
+
"service": "unknown",
809
+
"sampleRecords": [
810
+
{
811
+
"year": 2023,
812
+
"$type": "app.rocksky.song",
813
+
"album": "I Love You, Iโm Trying",
814
+
"title": "Stuck Here With Me",
815
+
"artist": "grandson",
816
+
"albumArt": {
817
+
"$type": "blob",
818
+
"ref": {
819
+
"$link": "bafkreih72c6ye2vd7scwbedjbfoqzue26fnsfs42dc2quqououlvqzdly4"
820
+
},
821
+
"mimeType": "image/jpeg",
822
+
"size": 266870
823
+
},
824
+
"duration": 235930,
825
+
"createdAt": "2025-07-12T20:50:45.291Z",
826
+
"discNumber": 1,
827
+
"albumArtist": "grandson",
828
+
"releaseDate": "2023-05-05T00:00:00.000Z",
829
+
"spotifyLink": "https://open.spotify.com/track/29FvUqoMbmQ50fdHlgs5om",
830
+
"trackNumber": 12
831
+
},
832
+
{
833
+
"year": 2023,
834
+
"$type": "app.rocksky.song",
835
+
"album": "I Love You, Iโm Trying",
836
+
"title": "Heather",
837
+
"artist": "grandson",
838
+
"albumArt": {
839
+
"$type": "blob",
840
+
"ref": {
841
+
"$link": "bafkreih72c6ye2vd7scwbedjbfoqzue26fnsfs42dc2quqououlvqzdly4"
842
+
},
843
+
"mimeType": "image/jpeg",
844
+
"size": 266870
845
+
},
846
+
"duration": 198468,
847
+
"createdAt": "2025-07-12T20:49:46.259Z",
848
+
"discNumber": 1,
849
+
"albumArtist": "grandson",
850
+
"releaseDate": "2023-05-05T00:00:00.000Z",
851
+
"spotifyLink": "https://open.spotify.com/track/05jgkkHC2o5edhP92u9pgU",
852
+
"trackNumber": 11
853
+
},
854
+
{
855
+
"year": 2023,
856
+
"$type": "app.rocksky.song",
857
+
"album": "I Love You, Iโm Trying",
858
+
"title": "I Will Be Here When Youโre Ready To Wake Up (feat. Wafia)",
859
+
"artist": "grandson, Wafia",
860
+
"albumArt": {
861
+
"$type": "blob",
862
+
"ref": {
863
+
"$link": "bafkreih72c6ye2vd7scwbedjbfoqzue26fnsfs42dc2quqououlvqzdly4"
864
+
},
865
+
"mimeType": "image/jpeg",
866
+
"size": 266870
867
+
},
868
+
"duration": 60604,
869
+
"createdAt": "2025-07-12T20:48:46.161Z",
870
+
"discNumber": 1,
871
+
"albumArtist": "grandson",
872
+
"releaseDate": "2023-05-05T00:00:00.000Z",
873
+
"spotifyLink": "https://open.spotify.com/track/2fQQZQpze9c44ebtCYx8Jl",
874
+
"trackNumber": 10
875
+
}
876
+
],
877
+
"generatedTypes": "export interface AppRockskySong {\n $type: 'app.rocksky.song';\n year?: number;\n album?: string;\n title?: string;\n artist?: string;\n albumArt?: Record<string, any>;\n duration?: number;\n createdAt?: string;\n discNumber?: number;\n albumArtist?: string;\n releaseDate?: string;\n spotifyLink?: string;\n trackNumber?: number;\n}\n",
878
+
"$types": [
879
+
"app.rocksky.song"
880
+
]
881
+
},
882
+
{
883
+
"name": "blue.flashes.actor.profile",
884
+
"description": "blue.flashes.actor.profile records",
885
+
"service": "unknown",
886
+
"sampleRecords": [
887
+
{
888
+
"$type": "blue.flashes.actor.profile",
889
+
"createdAt": "2025-07-28T18:24:43.072Z",
890
+
"showFeeds": true,
891
+
"showLikes": false,
892
+
"showLists": true,
893
+
"showMedia": true,
894
+
"enablePortfolio": false,
895
+
"portfolioLayout": "grid",
896
+
"allowRawDownload": false
897
+
}
898
+
],
899
+
"generatedTypes": "export interface BlueFlashesActorProfile {\n $type: 'blue.flashes.actor.profile';\n createdAt?: string;\n showFeeds?: boolean;\n showLikes?: boolean;\n showLists?: boolean;\n showMedia?: boolean;\n enablePortfolio?: boolean;\n portfolioLayout?: string;\n allowRawDownload?: boolean;\n}\n",
900
+
"$types": [
901
+
"blue.flashes.actor.profile"
902
+
]
903
+
},
904
+
{
905
+
"name": "blue.linkat.board",
906
+
"description": "blue.linkat.board records",
907
+
"service": "unknown",
908
+
"sampleRecords": [
909
+
{
910
+
"$type": "blue.linkat.board",
911
+
"cards": [
912
+
{
913
+
"url": "https://tynanpurdy.com",
914
+
"text": "Portfolio",
915
+
"emoji": "๐"
916
+
},
917
+
{
918
+
"url": "https://blog.tynanpurdy.com",
919
+
"text": "Blog",
920
+
"emoji": "๐ฐ"
921
+
},
922
+
{
923
+
"url": "https://github.com/tynanpurdy",
924
+
"text": "GitHub",
925
+
"emoji": ""
926
+
},
927
+
{
928
+
"url": "https://www.linkedin.com/in/tynanpurdy",
929
+
"text": "LinkedIn",
930
+
"emoji": "๐"
931
+
},
932
+
{
933
+
"url": "https://mastodon.social/@tynanpurdy.com@bsky.brid.gy",
934
+
"text": "Mastodon (Bridged)",
935
+
"emoji": ""
936
+
}
937
+
]
938
+
}
939
+
],
940
+
"generatedTypes": "export interface BlueLinkatBoard {\n $type: 'blue.linkat.board';\n cards?: Record<string, any>[];\n}\n",
941
+
"$types": [
942
+
"blue.linkat.board"
943
+
]
944
+
},
945
+
{
946
+
"name": "buzz.bookhive.book",
947
+
"description": "buzz.bookhive.book records",
948
+
"service": "unknown",
949
+
"sampleRecords": [
950
+
{
951
+
"$type": "buzz.bookhive.book",
952
+
"cover": {
953
+
"$type": "blob",
954
+
"ref": {
955
+
"$link": "bafkreih7cdewtoxyo7fpl4x6bsnf3edjs2jg26jdy23opkzz7eug6zqff4"
956
+
},
957
+
"mimeType": "image/jpeg",
958
+
"size": 52491
959
+
},
960
+
"title": "A โCourt of Silver Flames",
961
+
"hiveId": "bk_J1U6l2ckLEM4sALVBxUp",
962
+
"status": "buzz.bookhive.defs#finished",
963
+
"authors": "Sarah J. Maas",
964
+
"createdAt": "2025-07-15T03:45:29.303Z"
965
+
},
966
+
{
967
+
"$type": "buzz.bookhive.book",
968
+
"cover": {
969
+
"$type": "blob",
970
+
"ref": {
971
+
"$link": "bafkreigsicpiwolxv7ap2iaaljy356bzjwf6dtuvgzwahz4uokz5kz6k6m"
972
+
},
973
+
"mimeType": "image/jpeg",
974
+
"size": 124025
975
+
},
976
+
"title": "When We're in Charge: The Next Generationโs Guide to Leadership",
977
+
"hiveId": "bk_1D28ImhUcffLrWt8G9UW",
978
+
"status": "buzz.bookhive.defs#wantToRead",
979
+
"authors": "Amanda Litman",
980
+
"createdAt": "2025-05-17T20:03:38.336Z"
981
+
},
982
+
{
983
+
"$type": "buzz.bookhive.book",
984
+
"cover": {
985
+
"$type": "blob",
986
+
"ref": {
987
+
"$link": "bafkreig5s2k5s42ccbdren2sfdasxyq2e2er7qcb6qs2escdhlt7lsuxtm"
988
+
},
989
+
"mimeType": "image/jpeg",
990
+
"size": 127596
991
+
},
992
+
"title": "Dune",
993
+
"hiveId": "bk_GUShjG8U9l93XqIrGiKV",
994
+
"status": "buzz.bookhive.defs#finished",
995
+
"authors": "Frank Herbert",
996
+
"createdAt": "2025-05-17T19:24:44.640Z"
997
+
}
998
+
],
999
+
"generatedTypes": "export interface BuzzBookhiveBook {\n $type: 'buzz.bookhive.book';\n cover?: Record<string, any>;\n title?: string;\n hiveId?: string;\n status?: string;\n authors?: string;\n createdAt?: string;\n}\n",
1000
+
"$types": [
1001
+
"buzz.bookhive.book"
1002
+
]
1003
+
},
1004
+
{
1005
+
"name": "chat.bsky.actor.declaration",
1006
+
"description": "chat.bsky.actor.declaration records",
1007
+
"service": "unknown",
1008
+
"sampleRecords": [
1009
+
{
1010
+
"$type": "chat.bsky.actor.declaration",
1011
+
"allowIncoming": "following"
1012
+
}
1013
+
],
1014
+
"generatedTypes": "export interface ChatBskyActorDeclaration {\n $type: 'chat.bsky.actor.declaration';\n allowIncoming?: string;\n}\n",
1015
+
"$types": [
1016
+
"chat.bsky.actor.declaration"
1017
+
]
1018
+
},
1019
+
{
1020
+
"name": "chat.roomy.01JPNX7AA9BSM6TY2GWW1TR5V7.catalog",
1021
+
"description": "chat.roomy.01JPNX7AA9BSM6TY2GWW1TR5V7.catalog records",
1022
+
"service": "unknown",
1023
+
"sampleRecords": [
1024
+
{
1025
+
"id": "leaf:ypcj310ntdfmzpg670b5j29xe3034dnh3kcvk76p5amwwy35hqt0",
1026
+
"$type": "chat.roomy.01JPNX7AA9BSM6TY2GWW1TR5V7.catalog"
1027
+
}
1028
+
],
1029
+
"generatedTypes": "export interface ChatRoomy01JPNX7AA9BSM6TY2GWW1TR5V7Catalog {\n $type: 'chat.roomy.01JPNX7AA9BSM6TY2GWW1TR5V7.catalog';\n id?: string;\n}\n",
1030
+
"$types": [
1031
+
"chat.roomy.01JPNX7AA9BSM6TY2GWW1TR5V7.catalog"
1032
+
]
1033
+
},
1034
+
{
1035
+
"name": "chat.roomy.profile",
1036
+
"description": "chat.roomy.profile records",
1037
+
"service": "unknown",
1038
+
"sampleRecords": [
1039
+
{
1040
+
"$type": "chat.roomy.profile",
1041
+
"accountId": "co_zhgWve43YBnn266mcVACjUuVG3d",
1042
+
"profileId": "co_zDFNXMtNLkqorucsoFX89zb9xKq"
1043
+
}
1044
+
],
1045
+
"generatedTypes": "export interface ChatRoomyProfile {\n $type: 'chat.roomy.profile';\n accountId?: string;\n profileId?: string;\n}\n",
1046
+
"$types": [
1047
+
"chat.roomy.profile"
1048
+
]
1049
+
},
1050
+
{
1051
+
"name": "com.germnetwork.keypackage",
1052
+
"description": "com.germnetwork.keypackage records",
1053
+
"service": "unknown",
1054
+
"sampleRecords": [
1055
+
{
1056
+
"$type": "com.germnetwork.keypackage",
1057
+
"anchorHello": "AHHqxtKPJc9RLZyPZVexLVS3trjVRRE/JYDxDyB71KyTBYB9vQL1yyHSlNytf+5OWb0S1h5WM5F43uJuty8XTAT/AaUAAAOKVay8GnbTuqQtVPA11MVoVY61HW8e5vnxDQmdmVT9+wICAAAB/wE5AAEABQABAAMgVyrTJ4GcogoE02O68mMrGrmhuRXIPxYmzJ4GoUaVPDMgiVabYjh9CTZ/Kiefa/IQT9htiTEzvxAzR+hrw0b4qgUgGALXa4Kf0riu5j1k+gqYxQOvpzihscrm7IIAoaVdo5cAASEDilWsvBp207qkLVTwNdTFaFWOtR1vHub58Q0JnZlU/fsCAAEKAAIABwAFAAEAAwAAAgABAQAAAABokkwRAAAAAGpzf5EAQEDrZKju/Dqxnmcnpfc/tbClFyaA7ojWK9uVH7ZFieHdcEwNSRwtdi3pYJcSselbyUpJXRsc0jlOq428bTAQIx0IAEBAk2n8vRJ32A+09QEbh7gIjAdarFwAXH0JUBf3h2fY7LcTpopvo68IaNPDtf9aZBgJukfWZKzrQzSK8pWFAZUiCQBvVL5Mcq1KiCMH26+O2m/3+6XaKedsXm8BhHxzM9rfzUVJe1YbNMxv+SqRaDUaX7AACEaTw0m4KSjNVj7OPyYC"
1058
+
}
1059
+
],
1060
+
"generatedTypes": "export interface ComGermnetworkKeypackage {\n $type: 'com.germnetwork.keypackage';\n anchorHello?: string;\n}\n",
1061
+
"$types": [
1062
+
"com.germnetwork.keypackage"
1063
+
]
1064
+
},
1065
+
{
1066
+
"name": "com.whtwnd.blog.entry",
1067
+
"description": "com.whtwnd.blog.entry records",
1068
+
"service": "unknown",
1069
+
"sampleRecords": [
1070
+
{
1071
+
"$type": "com.whtwnd.blog.entry",
1072
+
"theme": "github-light",
1073
+
"title": "Historic Atlanta Neighborhood Brand (Project Test)",
1074
+
"content": "\n\n> ๐ The team at Design Bloc worked closely with the residents of the historic Hunter Hills community to design a neighborhood brand that accurately represents their rich history. Design Bloc staff conducted extensive ethnographic research to understand the social, environmental, and material considerations for a representative brand.\n\n\n\n# Ethnographic Research\n\nOur journey begins with empathy. As one of the first planned black neighborhoods in Atlanta, Hunter Hills carries a long and significant history that must be preserved in its representation.\n\n> _The Hunter Hills brand should_ **reinforce the historic values of a unified community.**\n\n`literature reviews` helped us understand the context.\n\n`neighborhood walks` helped us understand the landscape.\n\n`resident interviews` helped us understand the experience.\n\n\n\n# Designing With the Community\n\nWe met with the Hunter Hills Neighborhood Association after each of several rounds of logo ideation, gathering feedback to lead us to the best final logo.\n\n\n\nStaff generated dozens of sketch thumbnails.\n\n\n\nI iterated on another staffโs sketch to arrive at the final logo on the right.\n\n# Collaborative InDesign\n\nI prepared a template file for our staff to create assigned pages of the final book.\n\n\n\nPage plan with proportional grid fields and measured type\n\n\n\nPredefined text styles designed to fit in the grid and integrate with InDesignโs TOC feature\n\n\n\nStandard page layouts for collaborative consistency\n\n# The Handoff\n\n\n\n# Acknowledgements\n\nThanks to my team mates: Mars Lovelace, Hannan Abdi, Cole Campbell, Jordan Lym, Hunter Schaufel, Margaret Lu\nThanks to our leads: Shawn Harris, Michael Flanigan, Wayne Li\nThanks to the residents: Char Johnson, Lisa Reyes, Alfred Tucker, everyone from the Hunter Hills Neighborhood Association, and all the neighbors that contributed via interviews and workshops",
1075
+
"createdAt": "2025-07-21T20:00:36.906Z",
1076
+
"visibility": "public"
1077
+
},
1078
+
{
1079
+
"$type": "com.whtwnd.blog.entry",
1080
+
"theme": "github-light",
1081
+
"title": "The experiment continues!",
1082
+
"content": "I do want to see how layouts behave when there are multiple posts to handle. Nothing to see here yet!",
1083
+
"createdAt": "2025-07-12T19:57:03.240Z",
1084
+
"visibility": "public"
1085
+
},
1086
+
{
1087
+
"$type": "com.whtwnd.blog.entry",
1088
+
"theme": "github-light",
1089
+
"title": "An ongoing experiment",
1090
+
"content": "I want to own my website. I want it's content to live on the ATProtocol. Claude is giving me a hand with the implementation. Currently the stack uses Astro. I need demo content on WhiteWind to test it's ability to display my blog posts. This is the aforementioned demo content.",
1091
+
"createdAt": "2025-07-11T21:04:33.022Z",
1092
+
"visibility": "public"
1093
+
}
1094
+
],
1095
+
"generatedTypes": "export interface ComWhtwndBlogEntry {\n $type: 'com.whtwnd.blog.entry';\n theme?: string;\n title?: string;\n content?: string;\n createdAt?: string;\n visibility?: string;\n}\n",
1096
+
"$types": [
1097
+
"com.whtwnd.blog.entry"
1098
+
]
1099
+
},
1100
+
{
1101
+
"name": "community.lexicon.calendar.rsvp",
1102
+
"description": "community.lexicon.calendar.rsvp records",
1103
+
"service": "unknown",
1104
+
"sampleRecords": [
1105
+
{
1106
+
"$type": "community.lexicon.calendar.rsvp",
1107
+
"status": "community.lexicon.calendar.rsvp#interested",
1108
+
"subject": {
1109
+
"cid": "bafyreic6ev7ulowb7il4egk7kr5vwfgw5nweyj5dhkzlkid5sf3aqsvfji",
1110
+
"uri": "at://did:plc:lehcqqkwzcwvjvw66uthu5oq/community.lexicon.calendar.event/3ltl5aficno2m"
1111
+
},
1112
+
"createdAt": "2025-07-15T17:51:44.366Z"
1113
+
},
1114
+
{
1115
+
"$type": "community.lexicon.calendar.rsvp",
1116
+
"status": "community.lexicon.calendar.rsvp#going",
1117
+
"subject": {
1118
+
"cid": "bafyreiapk47atkjb326wafy4z55ty4hdezmjmr57vf7korqfq7h2bcbhki",
1119
+
"uri": "at://did:plc:stznz7qsokto2345qtdzogjb/community.lexicon.calendar.event/3lu3t4qnkqv2s"
1120
+
},
1121
+
"createdAt": "2025-08-04T16:09:00.435Z"
1122
+
}
1123
+
],
1124
+
"generatedTypes": "export interface CommunityLexiconCalendarRsvp {\n $type: 'community.lexicon.calendar.rsvp';\n status?: string;\n subject?: Record<string, any>;\n createdAt?: string;\n}\n",
1125
+
"$types": [
1126
+
"community.lexicon.calendar.rsvp"
1127
+
]
1128
+
},
1129
+
{
1130
+
"name": "events.smokesignal.app.profile",
1131
+
"description": "events.smokesignal.app.profile records",
1132
+
"service": "unknown",
1133
+
"sampleRecords": [
1134
+
{
1135
+
"tz": "America/New_York",
1136
+
"$type": "events.smokesignal.app.profile"
1137
+
}
1138
+
],
1139
+
"generatedTypes": "export interface EventsSmokesignalAppProfile {\n $type: 'events.smokesignal.app.profile';\n tz?: string;\n}\n",
1140
+
"$types": [
1141
+
"events.smokesignal.app.profile"
1142
+
]
1143
+
},
1144
+
{
1145
+
"name": "events.smokesignal.calendar.event",
1146
+
"description": "events.smokesignal.calendar.event records",
1147
+
"service": "unknown",
1148
+
"sampleRecords": [
1149
+
{
1150
+
"mode": "events.smokesignal.calendar.event#inperson",
1151
+
"name": "IDSA Boston Open Studio Series - Motiv",
1152
+
"text": "This May we're cohosting a series of open studios across Boston. Join us for a peek into your favorite studios and lots of socializing!\r\n\r\nJoin us for the second studio tour in our May Studio Series! Motiv is generously opening their doors to our community for a special peek into their studio, their work, and their culture.\r\n\r\nAfter the studio tour we'll head down the street to The Broadway (726 E Broadway, Boston) for food and friendship (BYO$).\r\n\r\nThis event is free to the public and open to both IDSA Members and Non-Members.",
1153
+
"$type": "events.smokesignal.calendar.event",
1154
+
"endsAt": "2025-05-16T00:00:00.000Z",
1155
+
"status": "events.smokesignal.calendar.event#scheduled",
1156
+
"location": {
1157
+
"name": "Motiv Design",
1158
+
"$type": "events.smokesignal.calendar.location#place",
1159
+
"region": "MA",
1160
+
"street": "803 Summer Street, #2nd floor",
1161
+
"country": "US",
1162
+
"locality": "Boston",
1163
+
"postalCode": "02127"
1164
+
},
1165
+
"startsAt": "2025-05-15T22:00:00.000Z",
1166
+
"createdAt": "2025-05-13T20:11:16.764Z"
1167
+
}
1168
+
],
1169
+
"generatedTypes": "export interface EventsSmokesignalCalendarEvent {\n $type: 'events.smokesignal.calendar.event';\n mode?: string;\n name?: string;\n text?: string;\n endsAt?: string;\n status?: string;\n location?: Record<string, any>;\n startsAt?: string;\n createdAt?: string;\n}\n",
1170
+
"$types": [
1171
+
"events.smokesignal.calendar.event"
1172
+
]
1173
+
},
1174
+
{
1175
+
"name": "farm.smol.games.skyrdle.score",
1176
+
"description": "farm.smol.games.skyrdle.score records",
1177
+
"service": "unknown",
1178
+
"sampleRecords": [
1179
+
{
1180
+
"hash": "8a77a61dcb3a7f8f41314d2ab411bdfccf1643f2ea3ce43315ae07b5d20b9210",
1181
+
"$type": "farm.smol.games.skyrdle.score",
1182
+
"isWin": true,
1183
+
"score": 5,
1184
+
"guesses": [
1185
+
{
1186
+
"letters": [
1187
+
"P",
1188
+
"O",
1189
+
"I",
1190
+
"N",
1191
+
"T"
1192
+
],
1193
+
"evaluation": [
1194
+
"present",
1195
+
"present",
1196
+
"absent",
1197
+
"absent",
1198
+
"absent"
1199
+
]
1200
+
},
1201
+
{
1202
+
"letters": [
1203
+
"S",
1204
+
"L",
1205
+
"O",
1206
+
"P",
1207
+
"E"
1208
+
],
1209
+
"evaluation": [
1210
+
"absent",
1211
+
"absent",
1212
+
"present",
1213
+
"present",
1214
+
"absent"
1215
+
]
1216
+
},
1217
+
{
1218
+
"letters": [
1219
+
"C",
1220
+
"R",
1221
+
"O",
1222
+
"G",
1223
+
"A"
1224
+
],
1225
+
"evaluation": [
1226
+
"absent",
1227
+
"present",
1228
+
"present",
1229
+
"absent",
1230
+
"present"
1231
+
]
1232
+
},
1233
+
{
1234
+
"letters": [
1235
+
"R",
1236
+
"A",
1237
+
"P",
1238
+
"O",
1239
+
"R"
1240
+
],
1241
+
"evaluation": [
1242
+
"absent",
1243
+
"correct",
1244
+
"correct",
1245
+
"correct",
1246
+
"correct"
1247
+
]
1248
+
},
1249
+
{
1250
+
"letters": [
1251
+
"V",
1252
+
"A",
1253
+
"P",
1254
+
"O",
1255
+
"R"
1256
+
],
1257
+
"evaluation": [
1258
+
"correct",
1259
+
"correct",
1260
+
"correct",
1261
+
"correct",
1262
+
"correct"
1263
+
]
1264
+
}
1265
+
],
1266
+
"timestamp": "2025-06-21T17:50:53.767Z",
1267
+
"gameNumber": 9
1268
+
},
1269
+
{
1270
+
"hash": "2c3312d4eee2bb032d59b67676588cab5b72035c47f1696ab42845b6c0a36fa2",
1271
+
"$type": "farm.smol.games.skyrdle.score",
1272
+
"isWin": true,
1273
+
"score": 5,
1274
+
"guesses": [
1275
+
{
1276
+
"letters": [
1277
+
"P",
1278
+
"R",
1279
+
"I",
1280
+
"C",
1281
+
"K"
1282
+
],
1283
+
"evaluation": [
1284
+
"absent",
1285
+
"correct",
1286
+
"absent",
1287
+
"absent",
1288
+
"absent"
1289
+
]
1290
+
},
1291
+
{
1292
+
"letters": [
1293
+
"D",
1294
+
"R",
1295
+
"O",
1296
+
"N",
1297
+
"E"
1298
+
],
1299
+
"evaluation": [
1300
+
"absent",
1301
+
"correct",
1302
+
"correct",
1303
+
"absent",
1304
+
"absent"
1305
+
]
1306
+
},
1307
+
{
1308
+
"letters": [
1309
+
"F",
1310
+
"R",
1311
+
"O",
1312
+
"G",
1313
+
"S"
1314
+
],
1315
+
"evaluation": [
1316
+
"correct",
1317
+
"correct",
1318
+
"correct",
1319
+
"absent",
1320
+
"present"
1321
+
]
1322
+
},
1323
+
{
1324
+
"letters": [
1325
+
"F",
1326
+
"F",
1327
+
"O",
1328
+
"S",
1329
+
"T"
1330
+
],
1331
+
"evaluation": [
1332
+
"correct",
1333
+
"absent",
1334
+
"correct",
1335
+
"correct",
1336
+
"correct"
1337
+
]
1338
+
},
1339
+
{
1340
+
"letters": [
1341
+
"F",
1342
+
"R",
1343
+
"O",
1344
+
"S",
1345
+
"T"
1346
+
],
1347
+
"evaluation": [
1348
+
"correct",
1349
+
"correct",
1350
+
"correct",
1351
+
"correct",
1352
+
"correct"
1353
+
]
1354
+
}
1355
+
],
1356
+
"timestamp": "2025-06-20T13:33:55.182Z",
1357
+
"gameNumber": 8
1358
+
},
1359
+
{
1360
+
"hash": "6e536c0ae04b57f9afe595541cf41844672d5eae9e59de1d707784748feba5a1",
1361
+
"$type": "farm.smol.games.skyrdle.score",
1362
+
"isWin": true,
1363
+
"score": 3,
1364
+
"guesses": [
1365
+
{
1366
+
"letters": [
1367
+
"P",
1368
+
"R",
1369
+
"I",
1370
+
"M",
1371
+
"E"
1372
+
],
1373
+
"evaluation": [
1374
+
"absent",
1375
+
"absent",
1376
+
"present",
1377
+
"absent",
1378
+
"absent"
1379
+
]
1380
+
},
1381
+
{
1382
+
"letters": [
1383
+
"S",
1384
+
"C",
1385
+
"H",
1386
+
"I",
1387
+
"T"
1388
+
],
1389
+
"evaluation": [
1390
+
"correct",
1391
+
"present",
1392
+
"absent",
1393
+
"correct",
1394
+
"present"
1395
+
]
1396
+
},
1397
+
{
1398
+
"letters": [
1399
+
"S",
1400
+
"T",
1401
+
"O",
1402
+
"I",
1403
+
"C"
1404
+
],
1405
+
"evaluation": [
1406
+
"correct",
1407
+
"correct",
1408
+
"correct",
1409
+
"correct",
1410
+
"correct"
1411
+
]
1412
+
}
1413
+
],
1414
+
"timestamp": "2025-06-19T14:30:27.746Z",
1415
+
"gameNumber": 7
1416
+
}
1417
+
],
1418
+
"generatedTypes": "export interface FarmSmolGamesSkyrdleScore {\n $type: 'farm.smol.games.skyrdle.score';\n hash?: string;\n isWin?: boolean;\n score?: number;\n guesses?: Record<string, any>[];\n timestamp?: string;\n gameNumber?: number;\n}\n",
1419
+
"$types": [
1420
+
"farm.smol.games.skyrdle.score"
1421
+
]
1422
+
},
1423
+
{
1424
+
"name": "fyi.bluelinks.links",
1425
+
"description": "fyi.bluelinks.links records",
1426
+
"service": "unknown",
1427
+
"sampleRecords": [
1428
+
{
1429
+
"$type": "fyi.bluelinks.links",
1430
+
"links": [
1431
+
{
1432
+
"id": "4fc54af2-46ad-4f89-aa94-a14bbfd60afb",
1433
+
"url": "https://tynanpurdy.com",
1434
+
"name": "Portfolio",
1435
+
"$type": "fyi.bluelinks.links#link",
1436
+
"order": 1,
1437
+
"createdAt": "2025-06-16T20:23:31.823Z",
1438
+
"description": "My past work"
1439
+
},
1440
+
{
1441
+
"id": "8d864819-c69c-43da-8d7b-b9635e36f67f",
1442
+
"url": "https://blog.tynanpurdy.com",
1443
+
"name": "Blog",
1444
+
"$type": "fyi.bluelinks.links#link",
1445
+
"order": 2,
1446
+
"createdAt": "2025-06-16T20:24:07.424Z",
1447
+
"description": "My writing"
1448
+
}
1449
+
]
1450
+
}
1451
+
],
1452
+
"generatedTypes": "export interface FyiBluelinksLinks {\n $type: 'fyi.bluelinks.links';\n links?: Record<string, any>[];\n}\n",
1453
+
"$types": [
1454
+
"fyi.bluelinks.links"
1455
+
]
1456
+
},
1457
+
{
1458
+
"name": "fyi.unravel.frontpage.comment",
1459
+
"description": "fyi.unravel.frontpage.comment records",
1460
+
"service": "unknown",
1461
+
"sampleRecords": [
1462
+
{
1463
+
"post": {
1464
+
"cid": "bafyreicxjsuwe7thbqcu3qh5biliuxyou26nbmac6hhxv74u2jeuexx334",
1465
+
"uri": "at://did:plc:vro3sykit2gjemuza2pwvxwy/fyi.unravel.frontpage.post/3lvbcvpm3js2c"
1466
+
},
1467
+
"$type": "fyi.unravel.frontpage.comment",
1468
+
"content": "I can confirm my vibecoded app has like 3 approaches to the same thing and its a mess to untangle. Esp for a noob who doesn't really know what is the right way to converge on",
1469
+
"createdAt": "2025-08-02T01:06:19.685Z"
1470
+
},
1471
+
{
1472
+
"post": {
1473
+
"cid": "bafyreibiy36sr55cyjd7d6kn7yuuadxk242cm5yqsyw7strdpdbhesoxga",
1474
+
"uri": "at://did:plc:ofrbh253gwicbkc5nktqepol/fyi.unravel.frontpage.post/3luxczcviqk2h"
1475
+
},
1476
+
"$type": "fyi.unravel.frontpage.comment",
1477
+
"parent": {
1478
+
"cid": "bafyreicoezztd45k677sfqtuevgg3zqi5dhdheevry5qcb2cydrfp72uuq",
1479
+
"uri": "at://did:plc:6ayddqghxhciedbaofoxkcbs/fyi.unravel.frontpage.comment/3lv2echl6xk2e"
1480
+
},
1481
+
"content": "https://tangled.sh/@tynanpurdy.com/at-home",
1482
+
"createdAt": "2025-07-28T19:48:17.572Z"
1483
+
},
1484
+
{
1485
+
"post": {
1486
+
"cid": "bafyreibiy36sr55cyjd7d6kn7yuuadxk242cm5yqsyw7strdpdbhesoxga",
1487
+
"uri": "at://did:plc:ofrbh253gwicbkc5nktqepol/fyi.unravel.frontpage.post/3luxczcviqk2h"
1488
+
},
1489
+
"$type": "fyi.unravel.frontpage.comment",
1490
+
"content": "My version of this has a slightly different stack but largely the same idea. I'd love to collab on what additional lexicon might be useful for the personal website category. Portfolio projects are still a struggle to me.",
1491
+
"createdAt": "2025-07-28T19:47:46.719Z"
1492
+
}
1493
+
],
1494
+
"generatedTypes": "export interface FyiUnravelFrontpageComment {\n $type: 'fyi.unravel.frontpage.comment';\n post?: Record<string, any>;\n content?: string;\n createdAt?: string;\n}\n",
1495
+
"$types": [
1496
+
"fyi.unravel.frontpage.comment"
1497
+
]
1498
+
},
1499
+
{
1500
+
"name": "fyi.unravel.frontpage.post",
1501
+
"description": "fyi.unravel.frontpage.post records",
1502
+
"service": "unknown",
1503
+
"sampleRecords": [
1504
+
{
1505
+
"url": "https://brittanyellich.com/bluesky-comments-likes/",
1506
+
"$type": "fyi.unravel.frontpage.post",
1507
+
"title": "I finally added Bluesky comments and likes to my blog (and you can too!)",
1508
+
"createdAt": "2025-08-06T13:38:34.417Z"
1509
+
},
1510
+
{
1511
+
"url": "https://www.citationneeded.news/curate-with-rss/",
1512
+
"$type": "fyi.unravel.frontpage.post",
1513
+
"title": "Curate your own newspaper with RSS",
1514
+
"createdAt": "2025-07-31T17:18:08.281Z"
1515
+
},
1516
+
{
1517
+
"url": "https://baileytownsend.dev/articles/host-a-pds-with-a-cloudflare-tunnel",
1518
+
"$type": "fyi.unravel.frontpage.post",
1519
+
"title": "Host a PDS via a Cloudflare Tunnel",
1520
+
"createdAt": "2025-07-29T13:47:49.739Z"
1521
+
}
1522
+
],
1523
+
"generatedTypes": "export interface FyiUnravelFrontpagePost {\n $type: 'fyi.unravel.frontpage.post';\n url?: string;\n title?: string;\n createdAt?: string;\n}\n",
1524
+
"$types": [
1525
+
"fyi.unravel.frontpage.post"
1526
+
]
1527
+
},
1528
+
{
1529
+
"name": "fyi.unravel.frontpage.vote",
1530
+
"description": "fyi.unravel.frontpage.vote records",
1531
+
"service": "unknown",
1532
+
"sampleRecords": [
1533
+
{
1534
+
"$type": "fyi.unravel.frontpage.vote",
1535
+
"subject": {
1536
+
"cid": "bafyreicxjsuwe7thbqcu3qh5biliuxyou26nbmac6hhxv74u2jeuexx334",
1537
+
"uri": "at://did:plc:vro3sykit2gjemuza2pwvxwy/fyi.unravel.frontpage.post/3lvbcvpm3js2c"
1538
+
},
1539
+
"createdAt": "2025-08-06T15:01:18.710Z"
1540
+
},
1541
+
{
1542
+
"$type": "fyi.unravel.frontpage.vote",
1543
+
"subject": {
1544
+
"cid": "bafyreihrgrqftgwr6u37p3tkoa23bh6z7vecc44hivvtichrar4rnl55ti",
1545
+
"uri": "at://did:plc:mdjhvva6vlrswsj26cftjttd/fyi.unravel.frontpage.post/3lvbqj5kpm22v"
1546
+
},
1547
+
"createdAt": "2025-08-06T14:29:08.506Z"
1548
+
},
1549
+
{
1550
+
"$type": "fyi.unravel.frontpage.vote",
1551
+
"subject": {
1552
+
"cid": "bafyreicbdf5lbimyqo6d2hgj2z7y3v2ligwqlczjzp2fmgl2s7vci5yqj4",
1553
+
"uri": "at://did:plc:mdjhvva6vlrswsj26cftjttd/fyi.unravel.frontpage.post/3lu6mwifrmk2x"
1554
+
},
1555
+
"createdAt": "2025-08-06T14:06:38.568Z"
1556
+
}
1557
+
],
1558
+
"generatedTypes": "export interface FyiUnravelFrontpageVote {\n $type: 'fyi.unravel.frontpage.vote';\n subject?: Record<string, any>;\n createdAt?: string;\n}\n",
1559
+
"$types": [
1560
+
"fyi.unravel.frontpage.vote"
1561
+
]
1562
+
},
1563
+
{
1564
+
"name": "im.flushing.right.now",
1565
+
"description": "im.flushing.right.now records",
1566
+
"service": "unknown",
1567
+
"sampleRecords": [
1568
+
{
1569
+
"text": "is flushing",
1570
+
"$type": "im.flushing.right.now",
1571
+
"emoji": "๐ฉ",
1572
+
"createdAt": "2025-06-17T16:53:24-04:00"
1573
+
}
1574
+
],
1575
+
"generatedTypes": "export interface ImFlushingRightNow {\n $type: 'im.flushing.right.now';\n text?: string;\n emoji?: string;\n createdAt?: string;\n}\n",
1576
+
"$types": [
1577
+
"im.flushing.right.now"
1578
+
]
1579
+
},
1580
+
{
1581
+
"name": "link.woosh.linkPage",
1582
+
"description": "link.woosh.linkPage records",
1583
+
"service": "unknown",
1584
+
"sampleRecords": [
1585
+
{
1586
+
"$type": "link.woosh.linkPage",
1587
+
"collections": [
1588
+
{
1589
+
"label": "Socials",
1590
+
"links": [
1591
+
{
1592
+
"uri": "https://bsky.app/profile/tynanpurdy.com",
1593
+
"title": "Bluesky"
1594
+
},
1595
+
{
1596
+
"uri": "https://github.com/tynanpurdy",
1597
+
"title": "GitHub"
1598
+
},
1599
+
{
1600
+
"uri": "https://www.linkedin.com/in/tynanpurdy",
1601
+
"title": "LinkedIn"
1602
+
}
1603
+
]
1604
+
}
1605
+
]
1606
+
}
1607
+
],
1608
+
"generatedTypes": "export interface LinkWooshLinkPage {\n $type: 'link.woosh.linkPage';\n collections?: Record<string, any>[];\n}\n",
1609
+
"$types": [
1610
+
"link.woosh.linkPage"
1611
+
]
1612
+
},
1613
+
{
1614
+
"name": "my.skylights.rel",
1615
+
"description": "my.skylights.rel records",
1616
+
"service": "unknown",
1617
+
"sampleRecords": [
1618
+
{
1619
+
"item": {
1620
+
"ref": "tmdb:m",
1621
+
"value": "861"
1622
+
},
1623
+
"note": {
1624
+
"value": "Great LOL moments. Fantastic special effects, especially in the makeup and prosthetics dept. ",
1625
+
"createdAt": "2025-05-21T21:24:37.174Z",
1626
+
"updatedAt": "2025-05-21T21:24:37.174Z"
1627
+
},
1628
+
"$type": "my.skylights.rel",
1629
+
"rating": {
1630
+
"value": 8,
1631
+
"createdAt": "2025-05-21T21:23:21.661Z"
1632
+
}
1633
+
}
1634
+
],
1635
+
"generatedTypes": "export interface MySkylightsRel {\n $type: 'my.skylights.rel';\n item?: Record<string, any>;\n note?: Record<string, any>;\n rating?: Record<string, any>;\n}\n",
1636
+
"$types": [
1637
+
"my.skylights.rel"
1638
+
]
1639
+
},
1640
+
{
1641
+
"name": "org.owdproject.application.windows",
1642
+
"description": "org.owdproject.application.windows records",
1643
+
"service": "unknown",
1644
+
"sampleRecords": [
1645
+
{
1646
+
"$type": "org.owdproject.application.windows",
1647
+
"windows": {}
1648
+
},
1649
+
{
1650
+
"$type": "org.owdproject.application.windows",
1651
+
"windows": {}
1652
+
}
1653
+
],
1654
+
"generatedTypes": "export interface OrgOwdprojectApplicationWindows {\n $type: 'org.owdproject.application.windows';\n}\n",
1655
+
"$types": [
1656
+
"org.owdproject.application.windows"
1657
+
]
1658
+
},
1659
+
{
1660
+
"name": "org.owdproject.desktop",
1661
+
"description": "org.owdproject.desktop records",
1662
+
"service": "unknown",
1663
+
"sampleRecords": [
1664
+
{
1665
+
"$type": "org.owdproject.desktop",
1666
+
"state": {
1667
+
"volume": {
1668
+
"master": 100
1669
+
},
1670
+
"window": {
1671
+
"positionZ": 2
1672
+
},
1673
+
"workspace": {
1674
+
"list": [
1675
+
"cNOD12iO",
1676
+
"MjCjyI3o"
1677
+
],
1678
+
"active": "cNOD12iO",
1679
+
"overview": false
1680
+
}
1681
+
}
1682
+
}
1683
+
],
1684
+
"generatedTypes": "export interface OrgOwdprojectDesktop {\n $type: 'org.owdproject.desktop';\n state?: Record<string, any>;\n}\n",
1685
+
"$types": [
1686
+
"org.owdproject.desktop"
1687
+
]
1688
+
},
1689
+
{
1690
+
"name": "org.scrapboard.list",
1691
+
"description": "org.scrapboard.list records",
1692
+
"service": "unknown",
1693
+
"sampleRecords": [
1694
+
{
1695
+
"name": "",
1696
+
"$type": "org.scrapboard.list",
1697
+
"createdAt": "2025-08-04T14:48:50.207Z",
1698
+
"description": ""
1699
+
}
1700
+
],
1701
+
"generatedTypes": "export interface OrgScrapboardList {\n $type: 'org.scrapboard.list';\n name?: string;\n createdAt?: string;\n description?: string;\n}\n",
1702
+
"$types": [
1703
+
"org.scrapboard.list"
1704
+
]
1705
+
},
1706
+
{
1707
+
"name": "org.scrapboard.listitem",
1708
+
"description": "org.scrapboard.listitem records",
1709
+
"service": "unknown",
1710
+
"sampleRecords": [
1711
+
{
1712
+
"url": "at://did:plc:gerrk3zpej5oloffu5cqtnly/app.bsky.feed.post/3lvj6fteywk2u?image=0",
1713
+
"list": "at://did:plc:6ayddqghxhciedbaofoxkcbs/org.scrapboard.list/3lvlguetmjw2i",
1714
+
"$type": "org.scrapboard.listitem",
1715
+
"createdAt": "2025-08-04T14:48:58.704Z"
1716
+
}
1717
+
],
1718
+
"generatedTypes": "export interface OrgScrapboardListitem {\n $type: 'org.scrapboard.listitem';\n url?: string;\n list?: string;\n createdAt?: string;\n}\n",
1719
+
"$types": [
1720
+
"org.scrapboard.listitem"
1721
+
]
1722
+
},
1723
+
{
1724
+
"name": "place.stream.chat.message",
1725
+
"description": "place.stream.chat.message records",
1726
+
"service": "unknown",
1727
+
"sampleRecords": [
1728
+
{
1729
+
"text": "thanks yall!",
1730
+
"$type": "place.stream.chat.message",
1731
+
"streamer": "did:plc:stznz7qsokto2345qtdzogjb",
1732
+
"createdAt": "2025-08-04T17:59:34.556Z"
1733
+
},
1734
+
{
1735
+
"text": "thanks yall!",
1736
+
"$type": "place.stream.chat.message",
1737
+
"streamer": "did:plc:stznz7qsokto2345qtdzogjb",
1738
+
"createdAt": "2025-08-04T17:59:34.555Z"
1739
+
},
1740
+
{
1741
+
"text": "spark, skyswipe, et al",
1742
+
"$type": "place.stream.chat.message",
1743
+
"streamer": "did:plc:stznz7qsokto2345qtdzogjb",
1744
+
"createdAt": "2025-08-04T17:52:24.424Z"
1745
+
}
1746
+
],
1747
+
"generatedTypes": "export interface PlaceStreamChatMessage {\n $type: 'place.stream.chat.message';\n text?: string;\n streamer?: string;\n createdAt?: string;\n}\n",
1748
+
"$types": [
1749
+
"place.stream.chat.message"
1750
+
]
1751
+
},
1752
+
{
1753
+
"name": "place.stream.chat.profile",
1754
+
"description": "place.stream.chat.profile records",
1755
+
"service": "unknown",
1756
+
"sampleRecords": [
1757
+
{
1758
+
"$type": "place.stream.chat.profile",
1759
+
"color": {
1760
+
"red": 76,
1761
+
"blue": 118,
1762
+
"green": 175
1763
+
}
1764
+
}
1765
+
],
1766
+
"generatedTypes": "export interface PlaceStreamChatProfile {\n $type: 'place.stream.chat.profile';\n color?: Record<string, any>;\n}\n",
1767
+
"$types": [
1768
+
"place.stream.chat.profile"
1769
+
]
1770
+
},
1771
+
{
1772
+
"name": "pub.leaflet.document",
1773
+
"description": "pub.leaflet.document records",
1774
+
"service": "unknown",
1775
+
"sampleRecords": [
1776
+
{
1777
+
"$type": "pub.leaflet.document",
1778
+
"pages": [
1779
+
{
1780
+
"$type": "pub.leaflet.pages.linearDocument",
1781
+
"blocks": [
1782
+
{
1783
+
"$type": "pub.leaflet.pages.linearDocument#block",
1784
+
"block": {
1785
+
"$type": "pub.leaflet.blocks.text",
1786
+
"facets": [
1787
+
{
1788
+
"index": {
1789
+
"byteEnd": 521,
1790
+
"byteStart": 508
1791
+
},
1792
+
"features": [
1793
+
{
1794
+
"$type": "pub.leaflet.richtext.facet#italic"
1795
+
}
1796
+
]
1797
+
}
1798
+
],
1799
+
"plaintext": "I love all this community-led development of the open social web. Tech infra architecture and business models can break down the oppressive and monopolistic internet giants we have grown up with. This blog post is published over the AT protocol, an open standard for all kinds of social experiences. The data, followers, and interactions are stored on a Personal Data Server. I control the data. I can move to a new PDS if I wish. I can even host my PDS myself. My account hosted on my PDS is my account for any and every ATproto app in the world. "
1800
+
}
1801
+
},
1802
+
{
1803
+
"$type": "pub.leaflet.pages.linearDocument#block",
1804
+
"block": {
1805
+
"$type": "pub.leaflet.blocks.text",
1806
+
"facets": [],
1807
+
"plaintext": "You can read this post on the leaflet website, which I have connected to the domain I own. You can also read it on Bluesky in a custom feed. You can also use any other client you want that can read an ATproto feed. There are dozens of clients for browsing and interacting with ATproto posts. You don't have to use Bluesky or it's infrastructure if you wish not to."
1808
+
}
1809
+
},
1810
+
{
1811
+
"$type": "pub.leaflet.pages.linearDocument#block",
1812
+
"block": {
1813
+
"$type": "pub.leaflet.blocks.text",
1814
+
"facets": [],
1815
+
"plaintext": "The hype around Bluesky is great to see. It is also somewhat shortsighted to the broader vision of the future of social. It's the protocol that matters. The things it enables. It can be hard to realize how restricted the big platforms are until you see an alternative. The projects any indie dev can spin up in no time with ATproto are incredible. I encourage you to check some of them out. "
1816
+
}
1817
+
},
1818
+
{
1819
+
"$type": "pub.leaflet.pages.linearDocument#block",
1820
+
"block": {
1821
+
"$type": "pub.leaflet.blocks.text",
1822
+
"facets": [],
1823
+
"plaintext": ""
1824
+
}
1825
+
},
1826
+
{
1827
+
"$type": "pub.leaflet.pages.linearDocument#block",
1828
+
"block": {
1829
+
"$type": "pub.leaflet.blocks.text",
1830
+
"facets": [
1831
+
{
1832
+
"index": {
1833
+
"byteEnd": 66,
1834
+
"byteStart": 0
1835
+
},
1836
+
"features": [
1837
+
{
1838
+
"$type": "pub.leaflet.richtext.facet#italic"
1839
+
}
1840
+
]
1841
+
}
1842
+
],
1843
+
"plaintext": "I know I did not address the subtitle, saving for a future post ;)"
1844
+
}
1845
+
}
1846
+
]
1847
+
}
1848
+
],
1849
+
"title": "Blogging directly on ATproto",
1850
+
"author": "did:plc:6ayddqghxhciedbaofoxkcbs",
1851
+
"postRef": {
1852
+
"cid": "bafyreihwmgzema3jpptki4jod3skf4bmsjjikqgbkgkz7qreggjbiwwkpa",
1853
+
"uri": "at://did:plc:6ayddqghxhciedbaofoxkcbs/app.bsky.feed.post/3lrquu5rnsk2w",
1854
+
"commit": {
1855
+
"cid": "bafyreib2y34ku7v5gzfrjcgibg3qoitwgwbxtgo2azl2mnw3xz35bzh2lq",
1856
+
"rev": "3lrquu5ured2l"
1857
+
},
1858
+
"validationStatus": "valid"
1859
+
},
1860
+
"description": "Which is somehow different from micro.blog...",
1861
+
"publication": "at://did:plc:6ayddqghxhciedbaofoxkcbs/pub.leaflet.publication/3lptvotm3ms2o",
1862
+
"publishedAt": "2025-06-16T21:01:42.095Z"
1863
+
}
1864
+
],
1865
+
"generatedTypes": "export interface PubLeafletDocument {\n $type: 'pub.leaflet.document';\n pages?: Record<string, any>[];\n title?: string;\n author?: string;\n postRef?: Record<string, any>;\n description?: string;\n publication?: string;\n publishedAt?: string;\n}\n",
1866
+
"$types": [
1867
+
"pub.leaflet.document"
1868
+
]
1869
+
},
1870
+
{
1871
+
"name": "pub.leaflet.graph.subscription",
1872
+
"description": "pub.leaflet.graph.subscription records",
1873
+
"service": "unknown",
1874
+
"sampleRecords": [
1875
+
{
1876
+
"$type": "pub.leaflet.graph.subscription",
1877
+
"publication": "at://did:plc:2cxgdrgtsmrbqnjkwyplmp43/pub.leaflet.publication/3lpqbbzc7x224"
1878
+
},
1879
+
{
1880
+
"$type": "pub.leaflet.graph.subscription",
1881
+
"publication": "at://did:plc:u2grpouz5553mrn4x772pyfa/pub.leaflet.publication/3lve2jmb7c22j"
1882
+
},
1883
+
{
1884
+
"$type": "pub.leaflet.graph.subscription",
1885
+
"publication": "at://did:plc:e3tv2pzlnuppocnc3wirsvl4/pub.leaflet.publication/3lrgwj6ytis2k"
1886
+
}
1887
+
],
1888
+
"generatedTypes": "export interface PubLeafletGraphSubscription {\n $type: 'pub.leaflet.graph.subscription';\n publication?: string;\n}\n",
1889
+
"$types": [
1890
+
"pub.leaflet.graph.subscription"
1891
+
]
1892
+
},
1893
+
{
1894
+
"name": "pub.leaflet.publication",
1895
+
"description": "pub.leaflet.publication records",
1896
+
"service": "unknown",
1897
+
"sampleRecords": [
1898
+
{
1899
+
"icon": {
1900
+
"$type": "blob",
1901
+
"ref": {
1902
+
"$link": "bafkreihz6y3xxbl5xasgrsfykyh6zizc63uv276lzengbvslikkqgndabe"
1903
+
},
1904
+
"mimeType": "image/png",
1905
+
"size": 75542
1906
+
},
1907
+
"name": "Tynan's Leaflets",
1908
+
"$type": "pub.leaflet.publication",
1909
+
"base_path": "leaflets.tynanpurdy.com",
1910
+
"description": "I'll play around with any ATproto gizmo"
1911
+
}
1912
+
],
1913
+
"generatedTypes": "export interface PubLeafletPublication {\n $type: 'pub.leaflet.publication';\n icon?: Record<string, any>;\n name?: string;\n base_path?: string;\n description?: string;\n}\n",
1914
+
"$types": [
1915
+
"pub.leaflet.publication"
1916
+
]
1917
+
},
1918
+
{
1919
+
"name": "sh.tangled.actor.profile",
1920
+
"description": "sh.tangled.actor.profile records",
1921
+
"service": "sh.tangled",
1922
+
"sampleRecords": [
1923
+
{
1924
+
"$type": "sh.tangled.actor.profile",
1925
+
"links": [
1926
+
"https://tynanpurdy.com",
1927
+
"https://blog.tynanpurdy.com",
1928
+
"",
1929
+
"",
1930
+
""
1931
+
],
1932
+
"stats": [
1933
+
"",
1934
+
""
1935
+
],
1936
+
"bluesky": true,
1937
+
"location": "",
1938
+
"description": "",
1939
+
"pinnedRepositories": [
1940
+
"",
1941
+
"",
1942
+
"",
1943
+
"",
1944
+
"",
1945
+
""
1946
+
]
1947
+
}
1948
+
],
1949
+
"generatedTypes": "export interface ShTangledActorProfile {\n $type: 'sh.tangled.actor.profile';\n links?: string[];\n stats?: string[];\n bluesky?: boolean;\n location?: string;\n description?: string;\n pinnedRepositories?: string[];\n}\n",
1950
+
"$types": [
1951
+
"sh.tangled.actor.profile"
1952
+
]
1953
+
},
1954
+
{
1955
+
"name": "sh.tangled.feed.star",
1956
+
"description": "sh.tangled.feed.star records",
1957
+
"service": "sh.tangled",
1958
+
"sampleRecords": [
1959
+
{
1960
+
"$type": "sh.tangled.feed.star",
1961
+
"subject": "at://did:plc:tgudj2fjm77pzkuawquqhsxm/sh.tangled.repo/3lnkvfhpcz422",
1962
+
"createdAt": "2025-07-31T01:08:31Z"
1963
+
}
1964
+
],
1965
+
"generatedTypes": "export interface ShTangledFeedStar {\n $type: 'sh.tangled.feed.star';\n subject?: string;\n createdAt?: string;\n}\n",
1966
+
"$types": [
1967
+
"sh.tangled.feed.star"
1968
+
]
1969
+
},
1970
+
{
1971
+
"name": "sh.tangled.publicKey",
1972
+
"description": "sh.tangled.publicKey records",
1973
+
"service": "sh.tangled",
1974
+
"sampleRecords": [
1975
+
{
1976
+
"key": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIN0U2oHBrZAOYUO0klCU7HpwgGEAJprdrI3Nk8H0YzOo",
1977
+
"name": "ray",
1978
+
"$type": "sh.tangled.publicKey",
1979
+
"createdAt": "2025-07-07T23:52:11Z"
1980
+
}
1981
+
],
1982
+
"generatedTypes": "export interface ShTangledPublicKey {\n $type: 'sh.tangled.publicKey';\n key?: string;\n name?: string;\n createdAt?: string;\n}\n",
1983
+
"$types": [
1984
+
"sh.tangled.publicKey"
1985
+
]
1986
+
},
1987
+
{
1988
+
"name": "sh.tangled.repo",
1989
+
"description": "sh.tangled.repo records",
1990
+
"service": "sh.tangled",
1991
+
"sampleRecords": [
1992
+
{
1993
+
"knot": "knot1.tangled.sh",
1994
+
"name": "at-home",
1995
+
"$type": "sh.tangled.repo",
1996
+
"owner": "did:plc:6ayddqghxhciedbaofoxkcbs",
1997
+
"createdAt": "2025-07-11T22:07:14Z"
1998
+
},
1999
+
{
2000
+
"knot": "knot1.tangled.sh",
2001
+
"name": "atprofile",
2002
+
"$type": "sh.tangled.repo",
2003
+
"owner": "did:plc:6ayddqghxhciedbaofoxkcbs",
2004
+
"createdAt": "2025-07-07T23:27:33Z"
2005
+
}
2006
+
],
2007
+
"generatedTypes": "export interface ShTangledRepo {\n $type: 'sh.tangled.repo';\n knot?: string;\n name?: string;\n owner?: string;\n createdAt?: string;\n}\n",
2008
+
"$types": [
2009
+
"sh.tangled.repo"
2010
+
]
2011
+
},
2012
+
{
2013
+
"name": "so.sprk.actor.profile",
2014
+
"description": "so.sprk.actor.profile records",
2015
+
"service": "unknown",
2016
+
"sampleRecords": [
2017
+
{
2018
+
"$type": "so.sprk.actor.profile",
2019
+
"avatar": {
2020
+
"$type": "blob",
2021
+
"ref": {
2022
+
"$link": "bafkreig6momh2fkdfhhqwkcjsw4vycubptufe6aeolsddtgg6felh4bvoe"
2023
+
},
2024
+
"mimeType": "image/jpeg",
2025
+
"size": 915425
2026
+
},
2027
+
"description": "he/him\nExperience Designer | Tech nerd | Curious Creative\n๐ Boston ๐บ๐ธ\n๐ acotar, a different kind of power\n\nmy work: tynanpurdy.com\nmy writing: blog.tynanpurdy.com",
2028
+
"displayName": "Tynan Purdy"
2029
+
}
2030
+
],
2031
+
"generatedTypes": "export interface SoSprkActorProfile {\n $type: 'so.sprk.actor.profile';\n avatar?: Record<string, any>;\n description?: string;\n displayName?: string;\n}\n",
2032
+
"$types": [
2033
+
"so.sprk.actor.profile"
2034
+
]
2035
+
},
2036
+
{
2037
+
"name": "so.sprk.feed.like",
2038
+
"description": "so.sprk.feed.like records",
2039
+
"service": "unknown",
2040
+
"sampleRecords": [
2041
+
{
2042
+
"$type": "so.sprk.feed.like",
2043
+
"subject": {
2044
+
"cid": "bafyreibezk3pahyxmgt32xf6bdox6ycpx4uxeetzj2ol5xtat6nyf3uw5i",
2045
+
"uri": "at://did:plc:owhabhwzxfp2zxh6nxszkzmg/app.bsky.feed.post/3lv7kobh6js2w"
2046
+
},
2047
+
"createdAt": "2025-07-30T21:26:26.637994Z"
2048
+
}
2049
+
],
2050
+
"generatedTypes": "export interface SoSprkFeedLike {\n $type: 'so.sprk.feed.like';\n subject?: Record<string, any>;\n createdAt?: string;\n}\n",
2051
+
"$types": [
2052
+
"so.sprk.feed.like"
2053
+
]
2054
+
},
2055
+
{
2056
+
"name": "so.sprk.feed.story",
2057
+
"description": "so.sprk.feed.story records",
2058
+
"service": "unknown",
2059
+
"sampleRecords": [
2060
+
{
2061
+
"tags": [],
2062
+
"$type": "so.sprk.feed.story",
2063
+
"media": {
2064
+
"$type": "so.sprk.embed.images",
2065
+
"images": [
2066
+
{
2067
+
"alt": "Schnauzer dog on couch. Fresh hair cut and wearing a green bowtie.",
2068
+
"image": {
2069
+
"$type": "blob",
2070
+
"ref": {
2071
+
"$link": "bafkreifdfejeiitg46ec2wijg7mjmna4hyne2c5m45435jm4yiiw3nids4"
2072
+
},
2073
+
"mimeType": "image/jpeg",
2074
+
"size": 2651327
2075
+
}
2076
+
}
2077
+
]
2078
+
},
2079
+
"createdAt": "2025-07-08T17:35:01.830824",
2080
+
"selfLabels": []
2081
+
}
2082
+
],
2083
+
"generatedTypes": "export interface SoSprkFeedStory {\n $type: 'so.sprk.feed.story';\n tags?: any[];\n media?: Record<string, any>;\n createdAt?: string;\n selfLabels?: any[];\n}\n",
2084
+
"$types": [
2085
+
"so.sprk.feed.story"
2086
+
]
2087
+
},
2088
+
{
2089
+
"name": "social.grain.actor.profile",
2090
+
"description": "social.grain.actor.profile records",
2091
+
"service": "grain.social",
2092
+
"sampleRecords": [
2093
+
{
2094
+
"$type": "social.grain.actor.profile",
2095
+
"avatar": {
2096
+
"$type": "blob",
2097
+
"ref": {
2098
+
"$link": "bafkreias2logev3efvxo6kvplme2s6j2whvmhxpwpnsaq4mo52kqy7ktay"
2099
+
},
2100
+
"mimeType": "image/jpeg",
2101
+
"size": 992154
2102
+
},
2103
+
"description": "he/him\r\nExperience Designer | Tech nerd | Curious Creative\r\n๐ Boston ๐บ๐ธ\r\n\r\nmy work: tynanpurdy.com\r\nmy writing: blog.tynanpurdy.com",
2104
+
"displayName": "Tynan Purdy"
2105
+
}
2106
+
],
2107
+
"generatedTypes": "export interface SocialGrainActorProfile {\n $type: 'social.grain.actor.profile';\n avatar?: Record<string, any>;\n description?: string;\n displayName?: string;\n}\n",
2108
+
"$types": [
2109
+
"social.grain.actor.profile"
2110
+
]
2111
+
},
2112
+
{
2113
+
"name": "social.grain.favorite",
2114
+
"description": "social.grain.favorite records",
2115
+
"service": "grain.social",
2116
+
"sampleRecords": [
2117
+
{
2118
+
"$type": "social.grain.favorite",
2119
+
"subject": "at://did:plc:njgakmquzxdmz6t32j27hgee/social.grain.gallery/3lrpq72tqo22d",
2120
+
"createdAt": "2025-06-17T21:19:47.133Z"
2121
+
}
2122
+
],
2123
+
"generatedTypes": "export interface SocialGrainFavorite {\n $type: 'social.grain.favorite';\n subject?: string;\n createdAt?: string;\n}\n",
2124
+
"$types": [
2125
+
"social.grain.favorite"
2126
+
]
2127
+
},
2128
+
{
2129
+
"name": "social.grain.gallery",
2130
+
"description": "Grain.social image galleries",
2131
+
"service": "grain.social",
2132
+
"sampleRecords": [
2133
+
{
2134
+
"$type": "social.grain.gallery",
2135
+
"title": "Zuko the dog",
2136
+
"createdAt": "2025-07-28T14:43:35.815Z",
2137
+
"updatedAt": "2025-07-28T14:43:35.815Z",
2138
+
"description": ""
2139
+
},
2140
+
{
2141
+
"$type": "social.grain.gallery",
2142
+
"title": "Opensauce 2025",
2143
+
"createdAt": "2025-07-23T22:18:41.069Z",
2144
+
"updatedAt": "2025-07-23T22:18:41.069Z",
2145
+
"description": "I truly do not understand Bay Area summer. Why is it cold!?"
2146
+
},
2147
+
{
2148
+
"$type": "social.grain.gallery",
2149
+
"title": "Engagement shoot",
2150
+
"createdAt": "2025-06-17T21:45:24.826Z",
2151
+
"description": "The date is set! Stay tuned. \n๐ Harvard Arboretum\n๐ท Karina Bhattacharya"
2152
+
}
2153
+
],
2154
+
"generatedTypes": "export interface SocialGrainGallery {\n $type: 'social.grain.gallery';\n title?: string;\n createdAt?: string;\n updatedAt?: string;\n description?: string;\n}\n",
2155
+
"$types": [
2156
+
"social.grain.gallery"
2157
+
]
2158
+
},
2159
+
{
2160
+
"name": "social.grain.gallery.item",
2161
+
"description": "social.grain.gallery.item records",
2162
+
"service": "grain.social",
2163
+
"sampleRecords": [
2164
+
{
2165
+
"item": "at://did:plc:6ayddqghxhciedbaofoxkcbs/social.grain.photo/3luztcsssrs2h",
2166
+
"$type": "social.grain.gallery.item",
2167
+
"gallery": "at://did:plc:6ayddqghxhciedbaofoxkcbs/social.grain.gallery/3luztckj2us2h",
2168
+
"position": 0,
2169
+
"createdAt": "2025-07-28T14:43:45.462Z"
2170
+
},
2171
+
{
2172
+
"item": "at://did:plc:6ayddqghxhciedbaofoxkcbs/social.grain.photo/3luo2mrz2v22s",
2173
+
"$type": "social.grain.gallery.item",
2174
+
"gallery": "at://did:plc:6ayddqghxhciedbaofoxkcbs/social.grain.gallery/3luo2fpjjyc2s",
2175
+
"position": 13,
2176
+
"createdAt": "2025-07-23T22:22:39.555Z"
2177
+
},
2178
+
{
2179
+
"item": "at://did:plc:6ayddqghxhciedbaofoxkcbs/social.grain.photo/3luo2miqkm22s",
2180
+
"$type": "social.grain.gallery.item",
2181
+
"gallery": "at://did:plc:6ayddqghxhciedbaofoxkcbs/social.grain.gallery/3luo2fpjjyc2s",
2182
+
"position": 12,
2183
+
"createdAt": "2025-07-23T22:22:29.750Z"
2184
+
}
2185
+
],
2186
+
"generatedTypes": "export interface SocialGrainGalleryItem {\n $type: 'social.grain.gallery.item';\n item?: string;\n gallery?: string;\n position?: number;\n createdAt?: string;\n}\n",
2187
+
"$types": [
2188
+
"social.grain.gallery.item"
2189
+
]
2190
+
},
2191
+
{
2192
+
"name": "social.grain.graph.follow",
2193
+
"description": "social.grain.graph.follow records",
2194
+
"service": "grain.social",
2195
+
"sampleRecords": [
2196
+
{
2197
+
"$type": "social.grain.graph.follow",
2198
+
"subject": "did:plc:qttsv4e7pu2jl3ilanfgc3zn",
2199
+
"createdAt": "2025-07-25T20:28:17.255Z"
2200
+
},
2201
+
{
2202
+
"$type": "social.grain.graph.follow",
2203
+
"subject": "did:plc:qttsv4e7pu2jl3ilanfgc3zn",
2204
+
"createdAt": "2025-07-25T20:28:16.699Z"
2205
+
}
2206
+
],
2207
+
"generatedTypes": "export interface SocialGrainGraphFollow {\n $type: 'social.grain.graph.follow';\n subject?: string;\n createdAt?: string;\n}\n",
2208
+
"$types": [
2209
+
"social.grain.graph.follow"
2210
+
]
2211
+
},
2212
+
{
2213
+
"name": "social.grain.photo",
2214
+
"description": "social.grain.photo records",
2215
+
"service": "grain.social",
2216
+
"sampleRecords": [
2217
+
{
2218
+
"alt": "Grey miniature schnauzer plopped on a wood fenced porch overlooking trees and a morning sky",
2219
+
"cid": "bafyreibigenfwakuv3rohdzoi4rsypqtjuvuguyxbzepvpltbc6srvumeu",
2220
+
"did": "did:plc:6ayddqghxhciedbaofoxkcbs",
2221
+
"uri": "at://did:plc:6ayddqghxhciedbaofoxkcbs/social.grain.photo/3luztcsssrs2h",
2222
+
"$type": "social.grain.photo",
2223
+
"photo": {
2224
+
"$type": "blob",
2225
+
"ref": {
2226
+
"$link": "bafkreibnepqn6itqpukdr776mm4qi2afbmzw7gwbs6i56pogvlnzr7tt5q"
2227
+
},
2228
+
"mimeType": "image/jpeg",
2229
+
"size": 965086
2230
+
},
2231
+
"createdAt": "2025-07-28T14:43:44.523Z",
2232
+
"indexedAt": "2025-07-28T14:43:45.223Z",
2233
+
"aspectRatio": {
2234
+
"width": 2000,
2235
+
"height": 2667
2236
+
}
2237
+
},
2238
+
{
2239
+
"alt": "",
2240
+
"$type": "social.grain.photo",
2241
+
"photo": {
2242
+
"$type": "blob",
2243
+
"ref": {
2244
+
"$link": "bafkreiangeamxp4rlmc66fheiosnk2odfpw4h5dzynfv7nhj6sy2jcyxzu"
2245
+
},
2246
+
"mimeType": "image/jpeg",
2247
+
"size": 981537
2248
+
},
2249
+
"createdAt": "2025-07-23T22:22:38.556Z",
2250
+
"aspectRatio": {
2251
+
"width": 2000,
2252
+
"height": 2667
2253
+
}
2254
+
},
2255
+
{
2256
+
"alt": "",
2257
+
"$type": "social.grain.photo",
2258
+
"photo": {
2259
+
"$type": "blob",
2260
+
"ref": {
2261
+
"$link": "bafkreiarwsd5ksvgzt5ydexepqfdq54sthj2mzone3d5hgou66qv333yi4"
2262
+
},
2263
+
"mimeType": "image/jpeg",
2264
+
"size": 963879
2265
+
},
2266
+
"createdAt": "2025-07-23T22:22:28.840Z",
2267
+
"aspectRatio": {
2268
+
"width": 2000,
2269
+
"height": 2667
2270
+
}
2271
+
}
2272
+
],
2273
+
"generatedTypes": "export interface SocialGrainPhoto {\n $type: 'social.grain.photo';\n alt?: string;\n cid?: string;\n did?: string;\n uri?: string;\n photo?: Record<string, any>;\n createdAt?: string;\n indexedAt?: string;\n aspectRatio?: Record<string, any>;\n}\n",
2274
+
"$types": [
2275
+
"social.grain.photo"
2276
+
]
2277
+
},
2278
+
{
2279
+
"name": "social.grain.photo.exif",
2280
+
"description": "social.grain.photo.exif records",
2281
+
"service": "grain.social",
2282
+
"sampleRecords": [
2283
+
{
2284
+
"iSO": 25000000,
2285
+
"make": "Apple",
2286
+
"$type": "social.grain.photo.exif",
2287
+
"flash": "Flash did not fire, compulsory flash mode",
2288
+
"model": "iPhone 14 Pro",
2289
+
"photo": "at://did:plc:6ayddqghxhciedbaofoxkcbs/social.grain.photo/3luztcsssrs2h",
2290
+
"fNumber": 2800000,
2291
+
"lensMake": "Apple",
2292
+
"createdAt": "2025-07-28T14:43:45.239Z",
2293
+
"lensModel": "iPhone 14 Pro back camera 9mm f/2.8",
2294
+
"exposureTime": 3378,
2295
+
"dateTimeOriginal": "2025-07-28T09:50:08",
2296
+
"focalLengthIn35mmFormat": 77000000
2297
+
},
2298
+
{
2299
+
"iSO": 80000000,
2300
+
"make": "Apple",
2301
+
"$type": "social.grain.photo.exif",
2302
+
"flash": "Flash did not fire, compulsory flash mode",
2303
+
"model": "iPhone 14 Pro",
2304
+
"photo": "at://did:plc:6ayddqghxhciedbaofoxkcbs/social.grain.photo/3luo2mrz2v22s",
2305
+
"fNumber": 1780000,
2306
+
"lensMake": "Apple",
2307
+
"createdAt": "2025-07-23T22:22:39.342Z",
2308
+
"lensModel": "iPhone 14 Pro back triple camera 6.86mm f/1.78",
2309
+
"exposureTime": 347,
2310
+
"dateTimeOriginal": "2025-07-19T16:11:52",
2311
+
"focalLengthIn35mmFormat": 24000000
2312
+
},
2313
+
{
2314
+
"iSO": 800000000,
2315
+
"make": "Apple",
2316
+
"$type": "social.grain.photo.exif",
2317
+
"flash": "Flash did not fire, compulsory flash mode",
2318
+
"model": "iPhone 14 Pro",
2319
+
"photo": "at://did:plc:6ayddqghxhciedbaofoxkcbs/social.grain.photo/3luo2miqkm22s",
2320
+
"fNumber": 1780000,
2321
+
"lensMake": "Apple",
2322
+
"createdAt": "2025-07-23T22:22:29.512Z",
2323
+
"lensModel": "iPhone 14 Pro back triple camera 6.86mm f/1.78",
2324
+
"exposureTime": 16667,
2325
+
"dateTimeOriginal": "2025-07-17T20:15:51",
2326
+
"focalLengthIn35mmFormat": 24000000
2327
+
}
2328
+
],
2329
+
"generatedTypes": "export interface SocialGrainPhotoExif {\n $type: 'social.grain.photo.exif';\n iSO?: number;\n make?: string;\n flash?: string;\n model?: string;\n photo?: string;\n fNumber?: number;\n lensMake?: string;\n createdAt?: string;\n lensModel?: string;\n exposureTime?: number;\n dateTimeOriginal?: string;\n focalLengthIn35mmFormat?: number;\n}\n",
2330
+
"$types": [
2331
+
"social.grain.photo.exif"
2332
+
]
2333
+
},
2334
+
{
2335
+
"name": "social.pinksky.app.preference",
2336
+
"description": "social.pinksky.app.preference records",
2337
+
"service": "unknown",
2338
+
"sampleRecords": [
2339
+
{
2340
+
"slug": "onboarding",
2341
+
"$type": "social.pinksky.app.preference",
2342
+
"value": "completed",
2343
+
"createdAt": "2025-05-21T21:27:01.789Z"
2344
+
}
2345
+
],
2346
+
"generatedTypes": "export interface SocialPinkskyAppPreference {\n $type: 'social.pinksky.app.preference';\n slug?: string;\n value?: string;\n createdAt?: string;\n}\n",
2347
+
"$types": [
2348
+
"social.pinksky.app.preference"
2349
+
]
2350
+
}
2351
+
],
2352
+
"totalCollections": 64,
2353
+
"totalRecords": 124,
2354
+
"generatedAt": "2025-08-06T17:33:43.119Z",
2355
+
"repository": {
2356
+
"handle": "tynanpurdy.com",
2357
+
"did": "did:plc:6ayddqghxhciedbaofoxkcbs",
2358
+
"recordCount": 0
2359
+
}
2360
+
}
+759
src/lib/generated/discovered-types.ts
+759
src/lib/generated/discovered-types.ts
···
1
+
// Auto-generated types from collection discovery
2
+
// Generated at: 2025-08-06T17:33:43.119Z
3
+
// Repository: tynanpurdy.com (did:plc:6ayddqghxhciedbaofoxkcbs)
4
+
// Collections: 64, Records: 124
5
+
6
+
// Collection: app.bsky.actor.profile
7
+
// Service: bsky.app
8
+
// Types: app.bsky.actor.profile
9
+
export interface AppBskyActorProfile {
10
+
$type: 'app.bsky.actor.profile';
11
+
avatar?: Record<string, any>;
12
+
banner?: Record<string, any>;
13
+
description?: string;
14
+
displayName?: string;
15
+
}
16
+
17
+
18
+
// Collection: app.bsky.feed.like
19
+
// Service: bsky.app
20
+
// Types: app.bsky.feed.like
21
+
export interface AppBskyFeedLike {
22
+
$type: 'app.bsky.feed.like';
23
+
subject?: Record<string, any>;
24
+
createdAt?: string;
25
+
}
26
+
27
+
28
+
// Collection: app.bsky.feed.post
29
+
// Service: bsky.app
30
+
// Types: app.bsky.feed.post
31
+
export interface AppBskyFeedPost {
32
+
$type: 'app.bsky.feed.post';
33
+
text?: string;
34
+
langs?: string[];
35
+
createdAt?: string;
36
+
}
37
+
38
+
39
+
// Collection: app.bsky.feed.postgate
40
+
// Service: bsky.app
41
+
// Types: app.bsky.feed.postgate
42
+
export interface AppBskyFeedPostgate {
43
+
$type: 'app.bsky.feed.postgate';
44
+
post?: string;
45
+
createdAt?: string;
46
+
embeddingRules?: Record<string, any>[];
47
+
detachedEmbeddingUris?: any[];
48
+
}
49
+
50
+
51
+
// Collection: app.bsky.feed.repost
52
+
// Service: bsky.app
53
+
// Types: app.bsky.feed.repost
54
+
export interface AppBskyFeedRepost {
55
+
$type: 'app.bsky.feed.repost';
56
+
subject?: Record<string, any>;
57
+
createdAt?: string;
58
+
}
59
+
60
+
61
+
// Collection: app.bsky.feed.threadgate
62
+
// Service: bsky.app
63
+
// Types: app.bsky.feed.threadgate
64
+
export interface AppBskyFeedThreadgate {
65
+
$type: 'app.bsky.feed.threadgate';
66
+
post?: string;
67
+
allow?: Record<string, any>[];
68
+
createdAt?: string;
69
+
hiddenReplies?: any[];
70
+
}
71
+
72
+
73
+
// Collection: app.bsky.graph.block
74
+
// Service: bsky.app
75
+
// Types: app.bsky.graph.block
76
+
export interface AppBskyGraphBlock {
77
+
$type: 'app.bsky.graph.block';
78
+
subject?: string;
79
+
createdAt?: string;
80
+
}
81
+
82
+
83
+
// Collection: app.bsky.graph.follow
84
+
// Service: bsky.app
85
+
// Types: app.bsky.graph.follow
86
+
export interface AppBskyGraphFollow {
87
+
$type: 'app.bsky.graph.follow';
88
+
subject?: string;
89
+
createdAt?: string;
90
+
}
91
+
92
+
93
+
// Collection: app.bsky.graph.list
94
+
// Service: bsky.app
95
+
// Types: app.bsky.graph.list
96
+
export interface AppBskyGraphList {
97
+
$type: 'app.bsky.graph.list';
98
+
name?: string;
99
+
purpose?: string;
100
+
createdAt?: string;
101
+
description?: string;
102
+
}
103
+
104
+
105
+
// Collection: app.bsky.graph.listitem
106
+
// Service: bsky.app
107
+
// Types: app.bsky.graph.listitem
108
+
export interface AppBskyGraphListitem {
109
+
$type: 'app.bsky.graph.listitem';
110
+
list?: string;
111
+
subject?: string;
112
+
createdAt?: string;
113
+
}
114
+
115
+
116
+
// Collection: app.bsky.graph.starterpack
117
+
// Service: bsky.app
118
+
// Types: app.bsky.graph.starterpack
119
+
export interface AppBskyGraphStarterpack {
120
+
$type: 'app.bsky.graph.starterpack';
121
+
list?: string;
122
+
name?: string;
123
+
feeds?: Record<string, any>[];
124
+
createdAt?: string;
125
+
updatedAt?: string;
126
+
}
127
+
128
+
129
+
// Collection: app.bsky.graph.verification
130
+
// Service: bsky.app
131
+
// Types: app.bsky.graph.verification
132
+
export interface AppBskyGraphVerification {
133
+
$type: 'app.bsky.graph.verification';
134
+
handle?: string;
135
+
subject?: string;
136
+
createdAt?: string;
137
+
displayName?: string;
138
+
}
139
+
140
+
141
+
// Collection: app.popsky.list
142
+
// Service: unknown
143
+
// Types: app.popsky.list
144
+
export interface AppPopskyList {
145
+
$type: 'app.popsky.list';
146
+
name?: string;
147
+
authorDid?: string;
148
+
createdAt?: string;
149
+
indexedAt?: string;
150
+
description?: string;
151
+
}
152
+
153
+
154
+
// Collection: app.popsky.listItem
155
+
// Service: unknown
156
+
// Types: app.popsky.listItem
157
+
export interface AppPopskyListItem {
158
+
$type: 'app.popsky.listItem';
159
+
addedAt?: string;
160
+
listUri?: string;
161
+
identifiers?: Record<string, any>;
162
+
creativeWorkType?: string;
163
+
}
164
+
165
+
166
+
// Collection: app.popsky.profile
167
+
// Service: unknown
168
+
// Types: app.popsky.profile
169
+
export interface AppPopskyProfile {
170
+
$type: 'app.popsky.profile';
171
+
createdAt?: string;
172
+
description?: string;
173
+
displayName?: string;
174
+
}
175
+
176
+
177
+
// Collection: app.popsky.review
178
+
// Service: unknown
179
+
// Types: app.popsky.review
180
+
export interface AppPopskyReview {
181
+
$type: 'app.popsky.review';
182
+
tags?: any[];
183
+
facets?: any[];
184
+
rating?: number;
185
+
createdAt?: string;
186
+
isRevisit?: boolean;
187
+
reviewText?: string;
188
+
identifiers?: Record<string, any>;
189
+
containsSpoilers?: boolean;
190
+
creativeWorkType?: string;
191
+
}
192
+
193
+
194
+
// Collection: app.rocksky.album
195
+
// Service: unknown
196
+
// Types: app.rocksky.album
197
+
export interface AppRockskyAlbum {
198
+
$type: 'app.rocksky.album';
199
+
year?: number;
200
+
title?: string;
201
+
artist?: string;
202
+
albumArt?: Record<string, any>;
203
+
createdAt?: string;
204
+
releaseDate?: string;
205
+
}
206
+
207
+
208
+
// Collection: app.rocksky.artist
209
+
// Service: unknown
210
+
// Types: app.rocksky.artist
211
+
export interface AppRockskyArtist {
212
+
$type: 'app.rocksky.artist';
213
+
name?: string;
214
+
picture?: Record<string, any>;
215
+
createdAt?: string;
216
+
}
217
+
218
+
219
+
// Collection: app.rocksky.like
220
+
// Service: unknown
221
+
// Types: app.rocksky.like
222
+
export interface AppRockskyLike {
223
+
$type: 'app.rocksky.like';
224
+
subject?: Record<string, any>;
225
+
createdAt?: string;
226
+
}
227
+
228
+
229
+
// Collection: app.rocksky.scrobble
230
+
// Service: unknown
231
+
// Types: app.rocksky.scrobble
232
+
export interface AppRockskyScrobble {
233
+
$type: 'app.rocksky.scrobble';
234
+
year?: number;
235
+
album?: string;
236
+
title?: string;
237
+
artist?: string;
238
+
albumArt?: Record<string, any>;
239
+
duration?: number;
240
+
createdAt?: string;
241
+
discNumber?: number;
242
+
albumArtist?: string;
243
+
releaseDate?: string;
244
+
spotifyLink?: string;
245
+
trackNumber?: number;
246
+
}
247
+
248
+
249
+
// Collection: app.rocksky.song
250
+
// Service: unknown
251
+
// Types: app.rocksky.song
252
+
export interface AppRockskySong {
253
+
$type: 'app.rocksky.song';
254
+
year?: number;
255
+
album?: string;
256
+
title?: string;
257
+
artist?: string;
258
+
albumArt?: Record<string, any>;
259
+
duration?: number;
260
+
createdAt?: string;
261
+
discNumber?: number;
262
+
albumArtist?: string;
263
+
releaseDate?: string;
264
+
spotifyLink?: string;
265
+
trackNumber?: number;
266
+
}
267
+
268
+
269
+
// Collection: blue.flashes.actor.profile
270
+
// Service: unknown
271
+
// Types: blue.flashes.actor.profile
272
+
export interface BlueFlashesActorProfile {
273
+
$type: 'blue.flashes.actor.profile';
274
+
createdAt?: string;
275
+
showFeeds?: boolean;
276
+
showLikes?: boolean;
277
+
showLists?: boolean;
278
+
showMedia?: boolean;
279
+
enablePortfolio?: boolean;
280
+
portfolioLayout?: string;
281
+
allowRawDownload?: boolean;
282
+
}
283
+
284
+
285
+
// Collection: blue.linkat.board
286
+
// Service: unknown
287
+
// Types: blue.linkat.board
288
+
export interface BlueLinkatBoard {
289
+
$type: 'blue.linkat.board';
290
+
cards?: Record<string, any>[];
291
+
}
292
+
293
+
294
+
// Collection: buzz.bookhive.book
295
+
// Service: unknown
296
+
// Types: buzz.bookhive.book
297
+
export interface BuzzBookhiveBook {
298
+
$type: 'buzz.bookhive.book';
299
+
cover?: Record<string, any>;
300
+
title?: string;
301
+
hiveId?: string;
302
+
status?: string;
303
+
authors?: string;
304
+
createdAt?: string;
305
+
}
306
+
307
+
308
+
// Collection: chat.bsky.actor.declaration
309
+
// Service: unknown
310
+
// Types: chat.bsky.actor.declaration
311
+
export interface ChatBskyActorDeclaration {
312
+
$type: 'chat.bsky.actor.declaration';
313
+
allowIncoming?: string;
314
+
}
315
+
316
+
317
+
// Collection: chat.roomy.01JPNX7AA9BSM6TY2GWW1TR5V7.catalog
318
+
// Service: unknown
319
+
// Types: chat.roomy.01JPNX7AA9BSM6TY2GWW1TR5V7.catalog
320
+
export interface ChatRoomy01JPNX7AA9BSM6TY2GWW1TR5V7Catalog {
321
+
$type: 'chat.roomy.01JPNX7AA9BSM6TY2GWW1TR5V7.catalog';
322
+
id?: string;
323
+
}
324
+
325
+
326
+
// Collection: chat.roomy.profile
327
+
// Service: unknown
328
+
// Types: chat.roomy.profile
329
+
export interface ChatRoomyProfile {
330
+
$type: 'chat.roomy.profile';
331
+
accountId?: string;
332
+
profileId?: string;
333
+
}
334
+
335
+
336
+
// Collection: com.germnetwork.keypackage
337
+
// Service: unknown
338
+
// Types: com.germnetwork.keypackage
339
+
export interface ComGermnetworkKeypackage {
340
+
$type: 'com.germnetwork.keypackage';
341
+
anchorHello?: string;
342
+
}
343
+
344
+
345
+
// Collection: com.whtwnd.blog.entry
346
+
// Service: unknown
347
+
// Types: com.whtwnd.blog.entry
348
+
export interface ComWhtwndBlogEntry {
349
+
$type: 'com.whtwnd.blog.entry';
350
+
theme?: string;
351
+
title?: string;
352
+
content?: string;
353
+
createdAt?: string;
354
+
visibility?: string;
355
+
}
356
+
357
+
358
+
// Collection: community.lexicon.calendar.rsvp
359
+
// Service: unknown
360
+
// Types: community.lexicon.calendar.rsvp
361
+
export interface CommunityLexiconCalendarRsvp {
362
+
$type: 'community.lexicon.calendar.rsvp';
363
+
status?: string;
364
+
subject?: Record<string, any>;
365
+
createdAt?: string;
366
+
}
367
+
368
+
369
+
// Collection: events.smokesignal.app.profile
370
+
// Service: unknown
371
+
// Types: events.smokesignal.app.profile
372
+
export interface EventsSmokesignalAppProfile {
373
+
$type: 'events.smokesignal.app.profile';
374
+
tz?: string;
375
+
}
376
+
377
+
378
+
// Collection: events.smokesignal.calendar.event
379
+
// Service: unknown
380
+
// Types: events.smokesignal.calendar.event
381
+
export interface EventsSmokesignalCalendarEvent {
382
+
$type: 'events.smokesignal.calendar.event';
383
+
mode?: string;
384
+
name?: string;
385
+
text?: string;
386
+
endsAt?: string;
387
+
status?: string;
388
+
location?: Record<string, any>;
389
+
startsAt?: string;
390
+
createdAt?: string;
391
+
}
392
+
393
+
394
+
// Collection: farm.smol.games.skyrdle.score
395
+
// Service: unknown
396
+
// Types: farm.smol.games.skyrdle.score
397
+
export interface FarmSmolGamesSkyrdleScore {
398
+
$type: 'farm.smol.games.skyrdle.score';
399
+
hash?: string;
400
+
isWin?: boolean;
401
+
score?: number;
402
+
guesses?: Record<string, any>[];
403
+
timestamp?: string;
404
+
gameNumber?: number;
405
+
}
406
+
407
+
408
+
// Collection: fyi.bluelinks.links
409
+
// Service: unknown
410
+
// Types: fyi.bluelinks.links
411
+
export interface FyiBluelinksLinks {
412
+
$type: 'fyi.bluelinks.links';
413
+
links?: Record<string, any>[];
414
+
}
415
+
416
+
417
+
// Collection: fyi.unravel.frontpage.comment
418
+
// Service: unknown
419
+
// Types: fyi.unravel.frontpage.comment
420
+
export interface FyiUnravelFrontpageComment {
421
+
$type: 'fyi.unravel.frontpage.comment';
422
+
post?: Record<string, any>;
423
+
content?: string;
424
+
createdAt?: string;
425
+
}
426
+
427
+
428
+
// Collection: fyi.unravel.frontpage.post
429
+
// Service: unknown
430
+
// Types: fyi.unravel.frontpage.post
431
+
export interface FyiUnravelFrontpagePost {
432
+
$type: 'fyi.unravel.frontpage.post';
433
+
url?: string;
434
+
title?: string;
435
+
createdAt?: string;
436
+
}
437
+
438
+
439
+
// Collection: fyi.unravel.frontpage.vote
440
+
// Service: unknown
441
+
// Types: fyi.unravel.frontpage.vote
442
+
export interface FyiUnravelFrontpageVote {
443
+
$type: 'fyi.unravel.frontpage.vote';
444
+
subject?: Record<string, any>;
445
+
createdAt?: string;
446
+
}
447
+
448
+
449
+
// Collection: im.flushing.right.now
450
+
// Service: unknown
451
+
// Types: im.flushing.right.now
452
+
export interface ImFlushingRightNow {
453
+
$type: 'im.flushing.right.now';
454
+
text?: string;
455
+
emoji?: string;
456
+
createdAt?: string;
457
+
}
458
+
459
+
460
+
// Collection: link.woosh.linkPage
461
+
// Service: unknown
462
+
// Types: link.woosh.linkPage
463
+
export interface LinkWooshLinkPage {
464
+
$type: 'link.woosh.linkPage';
465
+
collections?: Record<string, any>[];
466
+
}
467
+
468
+
469
+
// Collection: my.skylights.rel
470
+
// Service: unknown
471
+
// Types: my.skylights.rel
472
+
export interface MySkylightsRel {
473
+
$type: 'my.skylights.rel';
474
+
item?: Record<string, any>;
475
+
note?: Record<string, any>;
476
+
rating?: Record<string, any>;
477
+
}
478
+
479
+
480
+
// Collection: org.owdproject.application.windows
481
+
// Service: unknown
482
+
// Types: org.owdproject.application.windows
483
+
export interface OrgOwdprojectApplicationWindows {
484
+
$type: 'org.owdproject.application.windows';
485
+
}
486
+
487
+
488
+
// Collection: org.owdproject.desktop
489
+
// Service: unknown
490
+
// Types: org.owdproject.desktop
491
+
export interface OrgOwdprojectDesktop {
492
+
$type: 'org.owdproject.desktop';
493
+
state?: Record<string, any>;
494
+
}
495
+
496
+
497
+
// Collection: org.scrapboard.list
498
+
// Service: unknown
499
+
// Types: org.scrapboard.list
500
+
export interface OrgScrapboardList {
501
+
$type: 'org.scrapboard.list';
502
+
name?: string;
503
+
createdAt?: string;
504
+
description?: string;
505
+
}
506
+
507
+
508
+
// Collection: org.scrapboard.listitem
509
+
// Service: unknown
510
+
// Types: org.scrapboard.listitem
511
+
export interface OrgScrapboardListitem {
512
+
$type: 'org.scrapboard.listitem';
513
+
url?: string;
514
+
list?: string;
515
+
createdAt?: string;
516
+
}
517
+
518
+
519
+
// Collection: place.stream.chat.message
520
+
// Service: unknown
521
+
// Types: place.stream.chat.message
522
+
export interface PlaceStreamChatMessage {
523
+
$type: 'place.stream.chat.message';
524
+
text?: string;
525
+
streamer?: string;
526
+
createdAt?: string;
527
+
}
528
+
529
+
530
+
// Collection: place.stream.chat.profile
531
+
// Service: unknown
532
+
// Types: place.stream.chat.profile
533
+
export interface PlaceStreamChatProfile {
534
+
$type: 'place.stream.chat.profile';
535
+
color?: Record<string, any>;
536
+
}
537
+
538
+
539
+
// Collection: pub.leaflet.document
540
+
// Service: unknown
541
+
// Types: pub.leaflet.document
542
+
export interface PubLeafletDocument {
543
+
$type: 'pub.leaflet.document';
544
+
pages?: Record<string, any>[];
545
+
title?: string;
546
+
author?: string;
547
+
postRef?: Record<string, any>;
548
+
description?: string;
549
+
publication?: string;
550
+
publishedAt?: string;
551
+
}
552
+
553
+
554
+
// Collection: pub.leaflet.graph.subscription
555
+
// Service: unknown
556
+
// Types: pub.leaflet.graph.subscription
557
+
export interface PubLeafletGraphSubscription {
558
+
$type: 'pub.leaflet.graph.subscription';
559
+
publication?: string;
560
+
}
561
+
562
+
563
+
// Collection: pub.leaflet.publication
564
+
// Service: unknown
565
+
// Types: pub.leaflet.publication
566
+
export interface PubLeafletPublication {
567
+
$type: 'pub.leaflet.publication';
568
+
icon?: Record<string, any>;
569
+
name?: string;
570
+
base_path?: string;
571
+
description?: string;
572
+
}
573
+
574
+
575
+
// Collection: sh.tangled.actor.profile
576
+
// Service: sh.tangled
577
+
// Types: sh.tangled.actor.profile
578
+
export interface ShTangledActorProfile {
579
+
$type: 'sh.tangled.actor.profile';
580
+
links?: string[];
581
+
stats?: string[];
582
+
bluesky?: boolean;
583
+
location?: string;
584
+
description?: string;
585
+
pinnedRepositories?: string[];
586
+
}
587
+
588
+
589
+
// Collection: sh.tangled.feed.star
590
+
// Service: sh.tangled
591
+
// Types: sh.tangled.feed.star
592
+
export interface ShTangledFeedStar {
593
+
$type: 'sh.tangled.feed.star';
594
+
subject?: string;
595
+
createdAt?: string;
596
+
}
597
+
598
+
599
+
// Collection: sh.tangled.publicKey
600
+
// Service: sh.tangled
601
+
// Types: sh.tangled.publicKey
602
+
export interface ShTangledPublicKey {
603
+
$type: 'sh.tangled.publicKey';
604
+
key?: string;
605
+
name?: string;
606
+
createdAt?: string;
607
+
}
608
+
609
+
610
+
// Collection: sh.tangled.repo
611
+
// Service: sh.tangled
612
+
// Types: sh.tangled.repo
613
+
export interface ShTangledRepo {
614
+
$type: 'sh.tangled.repo';
615
+
knot?: string;
616
+
name?: string;
617
+
owner?: string;
618
+
createdAt?: string;
619
+
}
620
+
621
+
622
+
// Collection: so.sprk.actor.profile
623
+
// Service: unknown
624
+
// Types: so.sprk.actor.profile
625
+
export interface SoSprkActorProfile {
626
+
$type: 'so.sprk.actor.profile';
627
+
avatar?: Record<string, any>;
628
+
description?: string;
629
+
displayName?: string;
630
+
}
631
+
632
+
633
+
// Collection: so.sprk.feed.like
634
+
// Service: unknown
635
+
// Types: so.sprk.feed.like
636
+
export interface SoSprkFeedLike {
637
+
$type: 'so.sprk.feed.like';
638
+
subject?: Record<string, any>;
639
+
createdAt?: string;
640
+
}
641
+
642
+
643
+
// Collection: so.sprk.feed.story
644
+
// Service: unknown
645
+
// Types: so.sprk.feed.story
646
+
export interface SoSprkFeedStory {
647
+
$type: 'so.sprk.feed.story';
648
+
tags?: any[];
649
+
media?: Record<string, any>;
650
+
createdAt?: string;
651
+
selfLabels?: any[];
652
+
}
653
+
654
+
655
+
// Collection: social.grain.actor.profile
656
+
// Service: grain.social
657
+
// Types: social.grain.actor.profile
658
+
export interface SocialGrainActorProfile {
659
+
$type: 'social.grain.actor.profile';
660
+
avatar?: Record<string, any>;
661
+
description?: string;
662
+
displayName?: string;
663
+
}
664
+
665
+
666
+
// Collection: social.grain.favorite
667
+
// Service: grain.social
668
+
// Types: social.grain.favorite
669
+
export interface SocialGrainFavorite {
670
+
$type: 'social.grain.favorite';
671
+
subject?: string;
672
+
createdAt?: string;
673
+
}
674
+
675
+
676
+
// Collection: social.grain.gallery
677
+
// Service: grain.social
678
+
// Types: social.grain.gallery
679
+
export interface SocialGrainGallery {
680
+
$type: 'social.grain.gallery';
681
+
title?: string;
682
+
createdAt?: string;
683
+
updatedAt?: string;
684
+
description?: string;
685
+
}
686
+
687
+
688
+
// Collection: social.grain.gallery.item
689
+
// Service: grain.social
690
+
// Types: social.grain.gallery.item
691
+
export interface SocialGrainGalleryItem {
692
+
$type: 'social.grain.gallery.item';
693
+
item?: string;
694
+
gallery?: string;
695
+
position?: number;
696
+
createdAt?: string;
697
+
}
698
+
699
+
700
+
// Collection: social.grain.graph.follow
701
+
// Service: grain.social
702
+
// Types: social.grain.graph.follow
703
+
export interface SocialGrainGraphFollow {
704
+
$type: 'social.grain.graph.follow';
705
+
subject?: string;
706
+
createdAt?: string;
707
+
}
708
+
709
+
710
+
// Collection: social.grain.photo
711
+
// Service: grain.social
712
+
// Types: social.grain.photo
713
+
export interface SocialGrainPhoto {
714
+
$type: 'social.grain.photo';
715
+
alt?: string;
716
+
cid?: string;
717
+
did?: string;
718
+
uri?: string;
719
+
photo?: Record<string, any>;
720
+
createdAt?: string;
721
+
indexedAt?: string;
722
+
aspectRatio?: Record<string, any>;
723
+
}
724
+
725
+
726
+
// Collection: social.grain.photo.exif
727
+
// Service: grain.social
728
+
// Types: social.grain.photo.exif
729
+
export interface SocialGrainPhotoExif {
730
+
$type: 'social.grain.photo.exif';
731
+
iSO?: number;
732
+
make?: string;
733
+
flash?: string;
734
+
model?: string;
735
+
photo?: string;
736
+
fNumber?: number;
737
+
lensMake?: string;
738
+
createdAt?: string;
739
+
lensModel?: string;
740
+
exposureTime?: number;
741
+
dateTimeOriginal?: string;
742
+
focalLengthIn35mmFormat?: number;
743
+
}
744
+
745
+
746
+
// Collection: social.pinksky.app.preference
747
+
// Service: unknown
748
+
// Types: social.pinksky.app.preference
749
+
export interface SocialPinkskyAppPreference {
750
+
$type: 'social.pinksky.app.preference';
751
+
slug?: string;
752
+
value?: string;
753
+
createdAt?: string;
754
+
}
755
+
756
+
757
+
// Union type for all discovered types
758
+
export type DiscoveredTypes = 'app.bsky.actor.profile' | 'app.bsky.feed.like' | 'app.bsky.feed.post' | 'app.bsky.feed.postgate' | 'app.bsky.feed.repost' | 'app.bsky.feed.threadgate' | 'app.bsky.graph.block' | 'app.bsky.graph.follow' | 'app.bsky.graph.list' | 'app.bsky.graph.listitem' | 'app.bsky.graph.starterpack' | 'app.bsky.graph.verification' | 'app.popsky.list' | 'app.popsky.listItem' | 'app.popsky.profile' | 'app.popsky.review' | 'app.rocksky.album' | 'app.rocksky.artist' | 'app.rocksky.like' | 'app.rocksky.scrobble' | 'app.rocksky.song' | 'blue.flashes.actor.profile' | 'blue.linkat.board' | 'buzz.bookhive.book' | 'chat.bsky.actor.declaration' | 'chat.roomy.01JPNX7AA9BSM6TY2GWW1TR5V7.catalog' | 'chat.roomy.profile' | 'com.germnetwork.keypackage' | 'com.whtwnd.blog.entry' | 'community.lexicon.calendar.rsvp' | 'events.smokesignal.app.profile' | 'events.smokesignal.calendar.event' | 'farm.smol.games.skyrdle.score' | 'fyi.bluelinks.links' | 'fyi.unravel.frontpage.comment' | 'fyi.unravel.frontpage.post' | 'fyi.unravel.frontpage.vote' | 'im.flushing.right.now' | 'link.woosh.linkPage' | 'my.skylights.rel' | 'org.owdproject.application.windows' | 'org.owdproject.desktop' | 'org.scrapboard.list' | 'org.scrapboard.listitem' | 'place.stream.chat.message' | 'place.stream.chat.profile' | 'pub.leaflet.document' | 'pub.leaflet.graph.subscription' | 'pub.leaflet.publication' | 'sh.tangled.actor.profile' | 'sh.tangled.feed.star' | 'sh.tangled.publicKey' | 'sh.tangled.repo' | 'so.sprk.actor.profile' | 'so.sprk.feed.like' | 'so.sprk.feed.story' | 'social.grain.actor.profile' | 'social.grain.favorite' | 'social.grain.gallery' | 'social.grain.gallery.item' | 'social.grain.graph.follow' | 'social.grain.photo' | 'social.grain.photo.exif' | 'social.pinksky.app.preference';
759
+
+22
src/lib/generated/lexicon-types.ts
+22
src/lib/generated/lexicon-types.ts
···
1
+
// Generated index of all lexicon types
2
+
// Do not edit manually - regenerate with: npm run gen:types
3
+
4
+
import type { AStatusUpdate } from './a-status-update';
5
+
import type { ComWhtwndBlogEntry } from './com-whtwnd-blog-entry';
6
+
import type { SocialGrainGalleryItem } from './social-grain-gallery-item';
7
+
import type { SocialGrainGallery } from './social-grain-gallery';
8
+
import type { SocialGrainPhotoExif } from './social-grain-photo-exif';
9
+
import type { SocialGrainPhoto } from './social-grain-photo';
10
+
11
+
// Union type for all generated lexicon records
12
+
export type GeneratedLexiconUnion = AStatusUpdate | ComWhtwndBlogEntry | SocialGrainGalleryItem | SocialGrainGallery | SocialGrainPhotoExif | SocialGrainPhoto;
13
+
14
+
// Type map for component registry
15
+
export type GeneratedLexiconTypeMap = {
16
+
'AStatusUpdate': AStatusUpdate;
17
+
'ComWhtwndBlogEntry': ComWhtwndBlogEntry;
18
+
'SocialGrainGalleryItem': SocialGrainGalleryItem;
19
+
'SocialGrainGallery': SocialGrainGallery;
20
+
'SocialGrainPhotoExif': SocialGrainPhotoExif;
21
+
'SocialGrainPhoto': SocialGrainPhoto;
22
+
};
+2
-2
src/lib/services/content-renderer.ts
+2
-2
src/lib/services/content-renderer.ts
···
1
1
import { AtprotoBrowser } from '../atproto/atproto-browser';
2
2
import { loadConfig } from '../config/site';
3
-
import type { AtprotoRecord } from '../types/atproto';
3
+
import type { AtprotoRecord } from '../atproto/atproto-browser';
4
4
5
5
export interface ContentRendererOptions {
6
6
showAuthor?: boolean;
···
39
39
'app.bsky.feed.post': 'BlueskyPost',
40
40
'app.bsky.actor.profile#whitewindBlogPost': 'WhitewindBlogPost',
41
41
'app.bsky.actor.profile#leafletPublication': 'LeafletPublication',
42
-
'app.bsky.actor.profile#grainImageGallery': 'GrainImageGallery',
42
+
43
43
'gallery.display': 'GalleryDisplay',
44
44
};
45
45
+178
src/lib/services/content-service.ts
+178
src/lib/services/content-service.ts
···
1
+
import { AtprotoBrowser } from '../atproto/atproto-browser';
2
+
import { loadConfig } from '../config/site';
3
+
4
+
export interface ContentRecord {
5
+
uri: string;
6
+
cid: string;
7
+
value: any;
8
+
indexedAt: string;
9
+
collection: string;
10
+
}
11
+
12
+
export interface ProcessedContent {
13
+
$type: string;
14
+
collection: string;
15
+
uri: string;
16
+
data: any;
17
+
createdAt: Date;
18
+
}
19
+
20
+
export class ContentService {
21
+
private browser: AtprotoBrowser;
22
+
private config: any;
23
+
private cache: Map<string, ProcessedContent[]> = new Map();
24
+
25
+
constructor() {
26
+
this.config = loadConfig();
27
+
this.browser = new AtprotoBrowser();
28
+
}
29
+
30
+
async getContentFromCollection(identifier: string, collection: string): Promise<ProcessedContent[]> {
31
+
const cacheKey = `${identifier}:${collection}`;
32
+
33
+
if (this.cache.has(cacheKey)) {
34
+
return this.cache.get(cacheKey)!;
35
+
}
36
+
37
+
try {
38
+
console.log(`Fetching ${collection} for ${identifier}...`);
39
+
const collectionInfo = await this.browser.getCollectionRecords(identifier, collection);
40
+
console.log(`Collection info for ${collection}:`, collectionInfo);
41
+
42
+
if (!collectionInfo || !collectionInfo.records) {
43
+
console.log(`No records found for ${collection}`);
44
+
return [];
45
+
}
46
+
47
+
console.log(`Found ${collectionInfo.records.length} records for ${collection}`);
48
+
49
+
// Debug: Show first few records
50
+
if (collectionInfo.records.length > 0) {
51
+
console.log(`First record in ${collection}:`, JSON.stringify(collectionInfo.records[0], null, 2));
52
+
}
53
+
54
+
const processed = collectionInfo.records.map(record => this.processRecord(record));
55
+
56
+
this.cache.set(cacheKey, processed);
57
+
return processed;
58
+
} catch (error) {
59
+
console.error(`Error fetching ${collection}:`, error);
60
+
return [];
61
+
}
62
+
}
63
+
64
+
private processRecord(record: ContentRecord): ProcessedContent {
65
+
return {
66
+
$type: record.value.$type || 'unknown',
67
+
collection: record.collection,
68
+
uri: record.uri,
69
+
data: record.value,
70
+
createdAt: new Date(record.value.createdAt || record.indexedAt)
71
+
};
72
+
}
73
+
74
+
// Get galleries specifically
75
+
async getGalleries(identifier: string): Promise<ProcessedContent[]> {
76
+
return this.getContentFromCollection(identifier, 'social.grain.gallery');
77
+
}
78
+
79
+
// Get gallery items specifically with linked photos
80
+
async getGalleryItems(identifier: string): Promise<ProcessedContent[]> {
81
+
console.log(`Fetching gallery items for ${identifier}...`);
82
+
const galleryItems = await this.getContentFromCollection(identifier, 'social.grain.gallery.item');
83
+
console.log(`Found ${galleryItems.length} gallery items`);
84
+
85
+
if (galleryItems.length === 0) {
86
+
console.log('No gallery items found - this might be the issue');
87
+
// Let's also try to fetch galleries to see if they exist
88
+
const galleries = await this.getContentFromCollection(identifier, 'social.grain.gallery');
89
+
console.log(`Found ${galleries.length} galleries`);
90
+
return [];
91
+
}
92
+
93
+
// For each gallery item, try to fetch the linked photo
94
+
const enrichedItems = await Promise.all(
95
+
galleryItems.map(async (item) => {
96
+
console.log(`Processing gallery item: ${item.uri}`);
97
+
console.log(`Item data:`, JSON.stringify(item.data, null, 2));
98
+
99
+
if (item.data.item && typeof item.data.item === 'string') {
100
+
try {
101
+
// Extract the photo URI from the item field
102
+
const photoUri = item.data.item;
103
+
console.log(`Fetching linked photo: ${photoUri}`);
104
+
105
+
// Make sure we have a complete URI with record ID
106
+
if (!photoUri.includes('/social.grain.photo/')) {
107
+
console.log(`Invalid photo URI format: ${photoUri}`);
108
+
return item;
109
+
}
110
+
111
+
const photoRecord = await this.browser.getRecord(photoUri);
112
+
113
+
if (photoRecord && photoRecord.value) {
114
+
console.log(`Found photo record:`, JSON.stringify(photoRecord.value, null, 2));
115
+
// Merge the photo data into the gallery item
116
+
return {
117
+
...item,
118
+
data: {
119
+
...item.data,
120
+
linkedPhoto: photoRecord.value,
121
+
photoUri: photoUri
122
+
}
123
+
};
124
+
} else {
125
+
console.log(`No photo record found for: ${photoUri}`);
126
+
console.log(`Photo record was:`, photoRecord);
127
+
128
+
// Let's try to fetch the photo record directly to see what's happening
129
+
try {
130
+
console.log(`Attempting to fetch photo record directly...`);
131
+
const directResponse = await this.browser.agent.api.com.atproto.repo.getRecord({
132
+
uri: photoUri
133
+
});
134
+
console.log(`Direct API response:`, directResponse);
135
+
} catch (directError) {
136
+
console.log(`Direct API error:`, directError);
137
+
}
138
+
}
139
+
} catch (error) {
140
+
console.log(`Could not fetch linked photo for ${item.uri}:`, error);
141
+
}
142
+
} else {
143
+
console.log(`No item field found in gallery item:`, item.data);
144
+
}
145
+
return item;
146
+
})
147
+
);
148
+
149
+
console.log(`Returning ${enrichedItems.length} enriched gallery items`);
150
+
return enrichedItems;
151
+
}
152
+
153
+
// Get posts
154
+
async getPosts(identifier: string): Promise<ProcessedContent[]> {
155
+
return this.getContentFromCollection(identifier, 'app.bsky.feed.post');
156
+
}
157
+
158
+
// Get profile
159
+
async getProfile(identifier: string): Promise<ProcessedContent[]> {
160
+
return this.getContentFromCollection(identifier, 'app.bsky.actor.profile');
161
+
}
162
+
163
+
// Get all content from multiple collections
164
+
async getAllContent(identifier: string, collections: string[]): Promise<ProcessedContent[]> {
165
+
const allContent: ProcessedContent[] = [];
166
+
167
+
for (const collection of collections) {
168
+
const content = await this.getContentFromCollection(identifier, collection);
169
+
allContent.push(...content);
170
+
}
171
+
172
+
return allContent;
173
+
}
174
+
175
+
clearCache(): void {
176
+
this.cache.clear();
177
+
}
178
+
}
-271
src/lib/services/content-system.ts
-271
src/lib/services/content-system.ts
···
1
-
import { AtprotoBrowser } from '../atproto/atproto-browser';
2
-
import { JetstreamClient } from '../atproto/jetstream-client';
3
-
import { GrainGalleryService } from './grain-gallery-service';
4
-
import { loadConfig } from '../config/site';
5
-
import type { AtprotoRecord } from '../types/atproto';
6
-
7
-
export interface ContentItem {
8
-
uri: string;
9
-
cid: string;
10
-
$type: string;
11
-
collection: string;
12
-
createdAt: string;
13
-
indexedAt: string;
14
-
value: any;
15
-
service: string;
16
-
operation?: 'create' | 'update' | 'delete';
17
-
}
18
-
19
-
export interface ContentFeed {
20
-
items: ContentItem[];
21
-
lastUpdated: string;
22
-
totalItems: number;
23
-
collections: string[];
24
-
}
25
-
26
-
export interface ContentSystemConfig {
27
-
enableStreaming?: boolean;
28
-
buildTimeOnly?: boolean;
29
-
collections?: string[];
30
-
maxItems?: number;
31
-
}
32
-
33
-
export class ContentSystem {
34
-
private browser: AtprotoBrowser;
35
-
private jetstream: JetstreamClient;
36
-
private grainGalleryService: GrainGalleryService;
37
-
private config: any;
38
-
private contentFeed: ContentFeed;
39
-
private isStreaming = false;
40
-
41
-
constructor() {
42
-
this.config = loadConfig();
43
-
this.browser = new AtprotoBrowser();
44
-
this.jetstream = new JetstreamClient();
45
-
this.grainGalleryService = new GrainGalleryService();
46
-
47
-
this.contentFeed = {
48
-
items: [],
49
-
lastUpdated: new Date().toISOString(),
50
-
totalItems: 0,
51
-
collections: []
52
-
};
53
-
}
54
-
55
-
// Initialize content system (build-time)
56
-
async initialize(identifier: string, options: ContentSystemConfig = {}): Promise<ContentFeed> {
57
-
console.log('๐ Initializing content system for:', identifier);
58
-
59
-
try {
60
-
// Get repository info
61
-
const repoInfo = await this.browser.getRepoInfo(identifier);
62
-
if (!repoInfo) {
63
-
throw new Error(`Could not get repository info for: ${identifier}`);
64
-
}
65
-
66
-
console.log('๐ Repository info:', {
67
-
handle: repoInfo.handle,
68
-
did: repoInfo.did,
69
-
collections: repoInfo.collections.length,
70
-
recordCount: repoInfo.recordCount
71
-
});
72
-
73
-
// Gather all content from collections
74
-
const allItems: ContentItem[] = [];
75
-
const collections = options.collections || repoInfo.collections;
76
-
77
-
for (const collection of collections) {
78
-
console.log(`๐ฆ Fetching from collection: ${collection}`);
79
-
const records = await this.browser.getCollectionRecords(identifier, collection, options.maxItems || 100);
80
-
81
-
if (records && records.records) {
82
-
for (const record of records.records) {
83
-
const contentItem: ContentItem = {
84
-
uri: record.uri,
85
-
cid: record.cid,
86
-
$type: record.$type,
87
-
collection: record.collection,
88
-
createdAt: record.value?.createdAt || record.indexedAt,
89
-
indexedAt: record.indexedAt,
90
-
value: record.value,
91
-
service: this.inferService(record.$type, record.collection),
92
-
operation: 'create' // Build-time items are existing
93
-
};
94
-
95
-
allItems.push(contentItem);
96
-
}
97
-
}
98
-
}
99
-
100
-
// Sort by creation date (newest first)
101
-
allItems.sort((a, b) => {
102
-
const dateA = new Date(a.createdAt);
103
-
const dateB = new Date(b.createdAt);
104
-
return dateB.getTime() - dateA.getTime();
105
-
});
106
-
107
-
this.contentFeed = {
108
-
items: allItems,
109
-
lastUpdated: new Date().toISOString(),
110
-
totalItems: allItems.length,
111
-
collections: collections
112
-
};
113
-
114
-
console.log(`โ
Content system initialized with ${allItems.length} items`);
115
-
116
-
// Start streaming if enabled
117
-
if (!options.buildTimeOnly && options.enableStreaming !== false) {
118
-
await this.startStreaming(identifier);
119
-
}
120
-
121
-
return this.contentFeed;
122
-
} catch (error) {
123
-
console.error('Error initializing content system:', error);
124
-
throw error;
125
-
}
126
-
}
127
-
128
-
// Start real-time streaming
129
-
async startStreaming(identifier: string): Promise<void> {
130
-
if (this.isStreaming) {
131
-
console.log('โ ๏ธ Already streaming');
132
-
return;
133
-
}
134
-
135
-
console.log('๐ Starting real-time content streaming...');
136
-
this.isStreaming = true;
137
-
138
-
// Set up jetstream event handlers
139
-
this.jetstream.onRecord((record) => {
140
-
this.handleNewContent(record);
141
-
});
142
-
143
-
this.jetstream.onError((error) => {
144
-
console.error('โ Jetstream error:', error);
145
-
});
146
-
147
-
this.jetstream.onConnect(() => {
148
-
console.log('โ
Connected to real-time stream');
149
-
});
150
-
151
-
this.jetstream.onDisconnect(() => {
152
-
console.log('๐ Disconnected from real-time stream');
153
-
this.isStreaming = false;
154
-
});
155
-
156
-
// Start streaming
157
-
await this.jetstream.startStreaming();
158
-
}
159
-
160
-
// Handle new content from streaming
161
-
private handleNewContent(jetstreamRecord: any): void {
162
-
const contentItem: ContentItem = {
163
-
uri: jetstreamRecord.uri,
164
-
cid: jetstreamRecord.cid,
165
-
$type: jetstreamRecord.$type,
166
-
collection: jetstreamRecord.collection,
167
-
createdAt: jetstreamRecord.value?.createdAt || jetstreamRecord.indexedAt,
168
-
indexedAt: jetstreamRecord.indexedAt,
169
-
value: jetstreamRecord.value,
170
-
service: jetstreamRecord.service,
171
-
operation: jetstreamRecord.operation
172
-
};
173
-
174
-
// Add to beginning of feed (newest first)
175
-
this.contentFeed.items.unshift(contentItem);
176
-
this.contentFeed.totalItems++;
177
-
this.contentFeed.lastUpdated = new Date().toISOString();
178
-
179
-
console.log('๐ New content added:', {
180
-
$type: contentItem.$type,
181
-
collection: contentItem.collection,
182
-
operation: contentItem.operation
183
-
});
184
-
185
-
// Emit event for UI updates
186
-
this.emitContentUpdate(contentItem);
187
-
}
188
-
189
-
// Get current content feed
190
-
getContentFeed(): ContentFeed {
191
-
return this.contentFeed;
192
-
}
193
-
194
-
// Get content by type
195
-
getContentByType($type: string): ContentItem[] {
196
-
return this.contentFeed.items.filter(item => item.$type === $type);
197
-
}
198
-
199
-
// Get content by collection
200
-
getContentByCollection(collection: string): ContentItem[] {
201
-
return this.contentFeed.items.filter(item => item.collection === collection);
202
-
}
203
-
204
-
// Get galleries (using specialized service)
205
-
async getGalleries(identifier: string): Promise<any[]> {
206
-
return await this.grainGalleryService.getGalleries(identifier);
207
-
}
208
-
209
-
// Filter content by function
210
-
filterContent(filterFn: (item: ContentItem) => boolean): ContentItem[] {
211
-
return this.contentFeed.items.filter(filterFn);
212
-
}
213
-
214
-
// Search content
215
-
searchContent(query: string): ContentItem[] {
216
-
const lowerQuery = query.toLowerCase();
217
-
return this.contentFeed.items.filter(item => {
218
-
const text = JSON.stringify(item.value).toLowerCase();
219
-
return text.includes(lowerQuery);
220
-
});
221
-
}
222
-
223
-
// Stop streaming
224
-
stopStreaming(): void {
225
-
if (this.isStreaming) {
226
-
this.jetstream.stopStreaming();
227
-
this.isStreaming = false;
228
-
}
229
-
}
230
-
231
-
// Infer service from record type and collection
232
-
private inferService($type: string, collection: string): string {
233
-
if (collection.startsWith('grain.social') || $type.includes('grain')) return 'grain.social';
234
-
if (collection.startsWith('app.bsky')) return 'bsky.app';
235
-
if (collection.startsWith('sh.tangled')) return 'sh.tangled';
236
-
return 'unknown';
237
-
}
238
-
239
-
// Event system for UI updates
240
-
private listeners: {
241
-
onContentUpdate?: (item: ContentItem) => void;
242
-
onContentAdd?: (item: ContentItem) => void;
243
-
onContentRemove?: (item: ContentItem) => void;
244
-
} = {};
245
-
246
-
onContentUpdate(callback: (item: ContentItem) => void): void {
247
-
this.listeners.onContentUpdate = callback;
248
-
}
249
-
250
-
onContentAdd(callback: (item: ContentItem) => void): void {
251
-
this.listeners.onContentAdd = callback;
252
-
}
253
-
254
-
onContentRemove(callback: (item: ContentItem) => void): void {
255
-
this.listeners.onContentRemove = callback;
256
-
}
257
-
258
-
private emitContentUpdate(item: ContentItem): void {
259
-
this.listeners.onContentUpdate?.(item);
260
-
if (item.operation === 'create') {
261
-
this.listeners.onContentAdd?.(item);
262
-
} else if (item.operation === 'delete') {
263
-
this.listeners.onContentRemove?.(item);
264
-
}
265
-
}
266
-
267
-
// Get streaming status
268
-
getStreamingStatus(): 'streaming' | 'stopped' {
269
-
return this.isStreaming ? 'streaming' : 'stopped';
270
-
}
271
-
}
+55
-137
src/lib/services/grain-gallery-service.ts
+55
-137
src/lib/services/grain-gallery-service.ts
···
1
1
import { AtprotoBrowser } from '../atproto/atproto-browser';
2
2
import { loadConfig } from '../config/site';
3
-
import type { AtprotoRecord } from '../types/atproto';
4
3
import { extractCidFromBlobRef, blobCdnUrl } from '../atproto/blob-url';
5
-
6
-
export interface GrainGalleryItem {
7
-
uri: string;
8
-
cid: string;
9
-
value: {
10
-
$type: string;
11
-
galleryId?: string;
12
-
gallery_id?: string;
13
-
id?: string;
14
-
title?: string;
15
-
description?: string;
16
-
caption?: string;
17
-
image?: {
18
-
url?: string;
19
-
src?: string;
20
-
alt?: string;
21
-
caption?: string;
22
-
};
23
-
photo?: {
24
-
url?: string;
25
-
src?: string;
26
-
alt?: string;
27
-
caption?: string;
28
-
};
29
-
media?: {
30
-
url?: string;
31
-
src?: string;
32
-
alt?: string;
33
-
caption?: string;
34
-
};
35
-
createdAt: string;
36
-
};
37
-
indexedAt: string;
38
-
collection: string;
39
-
}
40
-
41
-
export interface GrainGallery {
42
-
id: string;
43
-
title: string;
44
-
description?: string;
45
-
createdAt: string;
46
-
items: GrainGalleryItem[];
47
-
imageCount: number;
48
-
collections: string[];
49
-
}
4
+
import type { SocialGrainGalleryRecord } from '../generated/social-grain-gallery';
5
+
import type { SocialGrainGalleryItemRecord } from '../generated/social-grain-gallery-item';
6
+
import type { SocialGrainPhotoRecord } from '../generated/social-grain-photo';
7
+
import type { SocialGrainPhotoExifRecord } from '../generated/social-grain-photo-exif';
50
8
51
9
export interface ProcessedGrainGallery {
52
10
id: string;
···
83
41
}
84
42
85
43
// Resolve gallery URI directly from the item record if present
86
-
private extractGalleryUriFromItem(item: any): string | null {
87
-
const value = item?.value ?? item;
44
+
private extractGalleryUriFromItem(item: { value: SocialGrainGalleryItemRecord } | SocialGrainGalleryItemRecord): string | null {
45
+
const value: SocialGrainGalleryItemRecord = (item as any)?.value ?? (item as SocialGrainGalleryItemRecord);
88
46
if (typeof value?.gallery === 'string') return value.gallery;
89
-
// Some variants might use a nested key
90
-
if (typeof value?.galleryUri === 'string') return value.galleryUri;
91
-
return null;
92
-
}
93
-
94
-
// Extract image from gallery item
95
-
private extractImageFromItem(item: GrainGalleryItem): { alt?: string; url: string; caption?: string } | null {
96
-
const value = item.value;
97
-
98
-
// Try different image fields
99
-
const imageFields = ['image', 'photo', 'media'];
100
-
101
-
for (const field of imageFields) {
102
-
const imageData = value[field];
103
-
if (imageData && (imageData.url || imageData.src)) {
104
-
return {
105
-
alt: imageData.alt || imageData.caption || value.caption,
106
-
url: imageData.url || imageData.src,
107
-
caption: imageData.caption || value.caption
108
-
};
109
-
}
110
-
}
111
-
112
47
return null;
113
48
}
114
49
115
50
// Build processed galleries using the authoritative gallery records and item mappings
116
51
private buildProcessedGalleries(
117
-
galleries: AtprotoRecord[],
118
-
items: AtprotoRecord[],
119
-
photosByUri: Map<string, AtprotoRecord>,
120
-
exifByPhotoUri: Map<string, any>
52
+
galleries: Array<{ uri: string; value: SocialGrainGalleryRecord; indexedAt: string; collection: string }>,
53
+
items: Array<{ uri: string; value: SocialGrainGalleryItemRecord }>,
54
+
photosByUri: Map<string, { uri: string; value: SocialGrainPhotoRecord }>,
55
+
exifByPhotoUri: Map<string, SocialGrainPhotoExifRecord>
121
56
): ProcessedGrainGallery[] {
122
57
// Index items by gallery URI
123
-
const itemsByGallery = new Map<string, AtprotoRecord[]>();
58
+
const itemsByGallery = new Map<string, Array<{ uri: string; value: SocialGrainGalleryItemRecord }>>();
124
59
for (const item of items) {
125
60
const galleryUri = this.extractGalleryUriFromItem(item);
126
61
if (!galleryUri) continue;
···
137
72
const galleryItems = itemsByGallery.get(galleryUri) ?? [];
138
73
// Sort by position if available
139
74
galleryItems.sort((a, b) => {
140
-
const pa = Number(a.value?.position ?? 0);
141
-
const pb = Number(b.value?.position ?? 0);
75
+
const pa = Number(a.value.position ?? 0);
76
+
const pb = Number(b.value.position ?? 0);
142
77
return pa - pb;
143
78
});
144
79
145
80
const images: Array<{ alt?: string; url: string; caption?: string; exif?: any }> = [];
146
81
for (const item of galleryItems) {
147
-
const photoUri = typeof item.value?.item === 'string' ? item.value.item : null;
82
+
const photoUri = item.value.item;
148
83
if (!photoUri) continue;
149
84
const photo = photosByUri.get(photoUri);
150
85
if (!photo) continue;
151
86
152
87
// Extract blob CID
153
-
const cid = extractCidFromBlobRef(photo.value?.photo?.ref ?? photo.value?.photo);
88
+
const cid = extractCidFromBlobRef((photo.value as any)?.photo?.ref ?? (photo.value as any)?.photo);
154
89
if (!cid || !did) continue;
155
90
const url = blobCdnUrl(did, cid);
156
91
157
92
const exif = exifByPhotoUri.get(photoUri);
158
93
images.push({
159
94
url,
160
-
alt: photo.value?.alt,
161
-
caption: photo.value?.caption,
162
-
exif: exif ? {
163
-
make: exif.make,
164
-
model: exif.model,
165
-
lensMake: exif.lensMake,
166
-
lensModel: exif.lensModel,
167
-
iSO: exif.iSO,
168
-
fNumber: exif.fNumber,
169
-
exposureTime: exif.exposureTime,
170
-
focalLengthIn35mmFormat: exif.focalLengthIn35mmFormat,
171
-
dateTimeOriginal: exif.dateTimeOriginal,
172
-
} : undefined,
95
+
alt: photo.value.alt,
96
+
caption: (photo.value as any).caption,
97
+
exif: exif
98
+
? {
99
+
make: exif.make,
100
+
model: exif.model,
101
+
lensMake: exif.lensMake,
102
+
lensModel: exif.lensModel,
103
+
iSO: exif.iSO,
104
+
fNumber: exif.fNumber,
105
+
exposureTime: exif.exposureTime,
106
+
focalLengthIn35mmFormat: exif.focalLengthIn35mmFormat,
107
+
dateTimeOriginal: exif.dateTimeOriginal,
108
+
}
109
+
: undefined,
173
110
});
174
111
}
175
112
176
113
processed.push({
177
114
id: galleryUri,
178
-
title: gallery.value?.title || 'Untitled Gallery',
179
-
description: gallery.value?.description,
180
-
createdAt: gallery.value?.createdAt || gallery.indexedAt,
115
+
title: gallery.value.title || 'Untitled Gallery',
116
+
description: gallery.value.description,
117
+
createdAt: gallery.value.createdAt || gallery.indexedAt,
181
118
images,
182
119
itemCount: galleryItems.length,
183
120
collections: [gallery.collection],
···
189
126
return processed;
190
127
}
191
128
192
-
// Process gallery into display format
193
-
private processGalleryForDisplay(gallery: GrainGallery): ProcessedGrainGallery {
194
-
const images: Array<{ alt?: string; url: string; caption?: string }> = [];
195
-
196
-
// Extract images from all items
197
-
for (const item of gallery.items) {
198
-
const image = this.extractImageFromItem(item);
199
-
if (image) {
200
-
images.push(image);
201
-
}
202
-
}
203
-
204
-
return {
205
-
id: gallery.id,
206
-
title: gallery.title,
207
-
description: gallery.description,
208
-
createdAt: gallery.createdAt,
209
-
images,
210
-
itemCount: gallery.items.length,
211
-
collections: gallery.collections
212
-
};
213
-
}
214
-
215
129
// Fetch galleries with items, photos, and exif metadata
216
130
async getGalleries(identifier: string): Promise<ProcessedGrainGallery[]> {
217
131
try {
···
228
142
this.browser.getAllCollectionRecords(identifier, 'social.grain.photo.exif', 5000),
229
143
]);
230
144
231
-
// Build maps for fast lookup
232
-
const photosByUri = new Map<string, AtprotoRecord>();
145
+
// Type and build maps for fast lookup
146
+
const typedGalleries = galleryRecords.map(r => ({
147
+
uri: r.uri,
148
+
value: r.value as SocialGrainGalleryRecord,
149
+
indexedAt: r.indexedAt,
150
+
collection: r.collection,
151
+
}));
152
+
153
+
const typedItems = itemRecords.map(r => ({
154
+
uri: r.uri,
155
+
value: r.value as SocialGrainGalleryItemRecord,
156
+
}));
157
+
158
+
const photosByUri = new Map<string, { uri: string; value: SocialGrainPhotoRecord }>();
233
159
for (const p of photoRecords) {
234
-
photosByUri.set(p.uri, p);
160
+
photosByUri.set(p.uri, { uri: p.uri, value: p.value as SocialGrainPhotoRecord });
235
161
}
236
-
const exifByPhotoUri = new Map<string, any>();
162
+
163
+
const exifByPhotoUri = new Map<string, SocialGrainPhotoExifRecord>();
237
164
for (const e of exifRecords) {
238
-
const photoUri = typeof e.value?.photo === 'string' ? e.value.photo : undefined;
239
-
if (photoUri) exifByPhotoUri.set(photoUri, e.value);
165
+
const ev = e.value as SocialGrainPhotoExifRecord;
166
+
const photoUri = ev.photo;
167
+
if (photoUri) exifByPhotoUri.set(photoUri, ev);
240
168
}
241
169
242
170
const processed = this.buildProcessedGalleries(
243
-
galleryRecords,
244
-
itemRecords,
171
+
typedGalleries,
172
+
typedItems,
245
173
photosByUri,
246
174
exifByPhotoUri,
247
175
);
···
266
194
}
267
195
}
268
196
269
-
// Get gallery items for a specific gallery
270
-
async getGalleryItemsForGallery(identifier: string, galleryId: string): Promise<GrainGalleryItem[]> {
271
-
try {
272
-
const items = await this.getGalleryItems(identifier);
273
-
return items.filter(item => this.extractGalleryId(item) === galleryId);
274
-
} catch (error) {
275
-
console.error('Error getting gallery items:', error);
276
-
return [];
277
-
}
278
-
}
279
197
}
-148
src/lib/types/atproto.ts
-148
src/lib/types/atproto.ts
···
1
-
// Base ATproto record types
2
-
export interface AtprotoRecord {
3
-
uri: string;
4
-
cid: string;
5
-
value: any;
6
-
indexedAt: string;
7
-
}
8
-
9
-
// Bluesky post types with proper embed handling
10
-
export interface BlueskyPost {
11
-
text: string;
12
-
createdAt: string;
13
-
embed?: {
14
-
$type: 'app.bsky.embed.images' | 'app.bsky.embed.external' | 'app.bsky.embed.record';
15
-
images?: Array<{
16
-
alt?: string;
17
-
image: {
18
-
$type: 'blob';
19
-
ref: {
20
-
$link: string;
21
-
};
22
-
mimeType: string;
23
-
size: number;
24
-
};
25
-
aspectRatio?: {
26
-
width: number;
27
-
height: number;
28
-
};
29
-
}>;
30
-
external?: {
31
-
uri: string;
32
-
title: string;
33
-
description?: string;
34
-
};
35
-
record?: {
36
-
uri: string;
37
-
cid: string;
38
-
};
39
-
};
40
-
author?: {
41
-
displayName?: string;
42
-
handle?: string;
43
-
};
44
-
reply?: {
45
-
root: {
46
-
uri: string;
47
-
cid: string;
48
-
};
49
-
parent: {
50
-
uri: string;
51
-
cid: string;
52
-
};
53
-
};
54
-
facets?: Array<{
55
-
index: {
56
-
byteStart: number;
57
-
byteEnd: number;
58
-
};
59
-
features: Array<{
60
-
$type: string;
61
-
[key: string]: any;
62
-
}>;
63
-
}>;
64
-
langs?: string[];
65
-
uri?: string;
66
-
cid?: string;
67
-
}
68
-
69
-
// Custom lexicon types (to be extended)
70
-
export interface CustomLexiconRecord {
71
-
$type: string;
72
-
[key: string]: any;
73
-
}
74
-
75
-
// Whitewind blog post type
76
-
export interface WhitewindBlogPost extends CustomLexiconRecord {
77
-
$type: 'app.bsky.actor.profile#whitewindBlogPost';
78
-
title: string;
79
-
content: string;
80
-
publishedAt: string;
81
-
tags?: string[];
82
-
}
83
-
84
-
// Leaflet publication type
85
-
export interface LeafletPublication extends CustomLexiconRecord {
86
-
$type: 'app.bsky.actor.profile#leafletPublication';
87
-
title: string;
88
-
content: string;
89
-
publishedAt: string;
90
-
category?: string;
91
-
}
92
-
93
-
// Grain social image gallery type
94
-
export interface GrainImageGallery extends CustomLexiconRecord {
95
-
$type: 'app.bsky.actor.profile#grainImageGallery';
96
-
title: string;
97
-
description?: string;
98
-
images: Array<{
99
-
alt: string;
100
-
url: string;
101
-
}>;
102
-
createdAt: string;
103
-
}
104
-
105
-
// Generic grain gallery post type (for posts that contain galleries)
106
-
export interface GrainGalleryPost extends CustomLexiconRecord {
107
-
$type: 'app.bsky.feed.post#grainGallery' | 'app.bsky.feed.post#grainImageGallery';
108
-
text?: string;
109
-
createdAt: string;
110
-
embed?: {
111
-
$type: 'app.bsky.embed.images';
112
-
images?: Array<{
113
-
alt?: string;
114
-
image: {
115
-
$type: 'blob';
116
-
ref: string;
117
-
mimeType: string;
118
-
size: number;
119
-
};
120
-
aspectRatio?: {
121
-
width: number;
122
-
height: number;
123
-
};
124
-
}>;
125
-
};
126
-
}
127
-
128
-
// Union type for all supported content types
129
-
export type SupportedContentType =
130
-
| BlueskyPost
131
-
| WhitewindBlogPost
132
-
| LeafletPublication
133
-
| GrainImageGallery
134
-
| GrainGalleryPost;
135
-
136
-
// Component registry type
137
-
export interface ContentComponent {
138
-
type: string;
139
-
component: any;
140
-
props?: Record<string, any>;
141
-
}
142
-
143
-
// Feed configuration type
144
-
export interface FeedConfig {
145
-
uri: string;
146
-
limit?: number;
147
-
filter?: (record: AtprotoRecord) => boolean;
148
-
}
-145
src/lib/types/generator.ts
-145
src/lib/types/generator.ts
···
1
-
import type { DiscoveredLexicon } from '../atproto/discovery';
2
-
3
-
export interface GeneratedType {
4
-
name: string;
5
-
interface: string;
6
-
$type: string;
7
-
properties: Record<string, any>;
8
-
service: string;
9
-
collection: string;
10
-
}
11
-
12
-
export class TypeGenerator {
13
-
private generatedTypes: Map<string, GeneratedType> = new Map();
14
-
15
-
// Generate TypeScript interface from a discovered lexicon
16
-
generateTypeFromLexicon(lexicon: DiscoveredLexicon): GeneratedType {
17
-
const $type = lexicon.$type;
18
-
19
-
// Skip if already generated
20
-
if (this.generatedTypes.has($type)) {
21
-
return this.generatedTypes.get($type)!;
22
-
}
23
-
24
-
const typeName = this.generateTypeName($type);
25
-
const interfaceCode = this.generateInterfaceCode(typeName, lexicon);
26
-
27
-
const generatedType: GeneratedType = {
28
-
name: typeName,
29
-
interface: interfaceCode,
30
-
$type,
31
-
properties: lexicon.properties,
32
-
service: lexicon.service,
33
-
collection: lexicon.collection
34
-
};
35
-
36
-
this.generatedTypes.set($type, generatedType);
37
-
return generatedType;
38
-
}
39
-
40
-
// Generate type name from $type
41
-
private generateTypeName($type: string): string {
42
-
const parts = $type.split('#');
43
-
if (parts.length > 1) {
44
-
return parts[1].charAt(0).toUpperCase() + parts[1].slice(1);
45
-
}
46
-
47
-
const lastPart = $type.split('.').pop() || 'Unknown';
48
-
return lastPart.charAt(0).toUpperCase() + lastPart.slice(1);
49
-
}
50
-
51
-
// Generate TypeScript interface code
52
-
private generateInterfaceCode(name: string, lexicon: DiscoveredLexicon): string {
53
-
const propertyLines = Object.entries(lexicon.properties).map(([key, type]) => {
54
-
return ` ${key}: ${type};`;
55
-
});
56
-
57
-
return `export interface ${name} extends CustomLexiconRecord {
58
-
$type: '${lexicon.$type}';
59
-
${propertyLines.join('\n')}
60
-
}`;
61
-
}
62
-
63
-
// Generate all types from discovered lexicons
64
-
generateTypesFromLexicons(lexicons: DiscoveredLexicon[]): GeneratedType[] {
65
-
const types: GeneratedType[] = [];
66
-
67
-
lexicons.forEach(lexicon => {
68
-
const type = this.generateTypeFromLexicon(lexicon);
69
-
types.push(type);
70
-
});
71
-
72
-
return types;
73
-
}
74
-
75
-
// Get all generated types
76
-
getAllGeneratedTypes(): GeneratedType[] {
77
-
return Array.from(this.generatedTypes.values());
78
-
}
79
-
80
-
// Generate complete types file content
81
-
generateTypesFile(lexicons: DiscoveredLexicon[]): string {
82
-
const types = this.generateTypesFromLexicons(lexicons);
83
-
84
-
if (types.length === 0) {
85
-
return '// No types generated';
86
-
}
87
-
88
-
const imports = `import type { CustomLexiconRecord } from './atproto';`;
89
-
const interfaces = types.map(type => type.interface).join('\n\n');
90
-
const unionType = this.generateUnionType(types);
91
-
const serviceGroups = this.generateServiceGroups(types);
92
-
93
-
return `${imports}
94
-
95
-
${interfaces}
96
-
97
-
${unionType}
98
-
99
-
${serviceGroups}`;
100
-
}
101
-
102
-
// Generate union type for all generated types
103
-
private generateUnionType(types: GeneratedType[]): string {
104
-
const typeNames = types.map(t => t.name);
105
-
return `// Union type for all generated content types
106
-
export type GeneratedContentType = ${typeNames.join(' | ')};`;
107
-
}
108
-
109
-
// Generate service-specific type groups
110
-
private generateServiceGroups(types: GeneratedType[]): string {
111
-
const serviceGroups = new Map<string, GeneratedType[]>();
112
-
113
-
types.forEach(type => {
114
-
if (!serviceGroups.has(type.service)) {
115
-
serviceGroups.set(type.service, []);
116
-
}
117
-
serviceGroups.get(type.service)!.push(type);
118
-
});
119
-
120
-
let serviceGroupsCode = '';
121
-
serviceGroups.forEach((types, service) => {
122
-
const typeNames = types.map(t => t.name);
123
-
serviceGroupsCode += `
124
-
// ${service} types
125
-
export type ${this.capitalizeService(service)}ContentType = ${typeNames.join(' | ')};`;
126
-
});
127
-
128
-
return serviceGroupsCode;
129
-
}
130
-
131
-
// Capitalize service name for type name
132
-
private capitalizeService(service: string): string {
133
-
return service.split('.').map(part =>
134
-
part.charAt(0).toUpperCase() + part.slice(1)
135
-
).join('');
136
-
}
137
-
138
-
// Clear all generated types
139
-
clear(): void {
140
-
this.generatedTypes.clear();
141
-
}
142
-
}
143
-
144
-
// Global type generator instance
145
-
export const typeGenerator = new TypeGenerator();
+59
src/pages/blog/[rkey].astro
+59
src/pages/blog/[rkey].astro
···
1
+
---
2
+
import Layout from '../../layouts/Layout.astro';
3
+
import { AtprotoBrowser } from '../../lib/atproto/atproto-browser';
4
+
import { loadConfig } from '../../lib/config/site';
5
+
import WhitewindBlogPost from '../../components/content/WhitewindBlogPost.astro';
6
+
7
+
export async function getStaticPaths() {
8
+
const config = loadConfig();
9
+
const browser = new AtprotoBrowser();
10
+
const paths: Array<{ params: { rkey: string } }> = [];
11
+
try {
12
+
const records = await browser.getAllCollectionRecords(config.atproto.handle, 'com.whtwnd.blog.entry', 2000);
13
+
for (const rec of records) {
14
+
if (rec.value?.$type === 'com.whtwnd.blog.entry') {
15
+
const rkey = rec.uri.split('/').pop();
16
+
if (rkey) paths.push({ params: { rkey } });
17
+
}
18
+
}
19
+
} catch (e) {
20
+
console.error('getStaticPaths whitewind', e);
21
+
}
22
+
return paths;
23
+
}
24
+
25
+
const { rkey } = Astro.params as Record<string, string>;
26
+
27
+
const config = loadConfig();
28
+
const browser = new AtprotoBrowser();
29
+
30
+
let record: any = null;
31
+
let title = 'Post';
32
+
33
+
try {
34
+
const did = config.atproto.did || (await browser.resolveHandle(config.atproto.handle));
35
+
if (did) {
36
+
const uri = `at://${did}/com.whtwnd.blog.entry/${rkey}`;
37
+
const rec = await browser.getRecord(uri);
38
+
if (rec && rec.value?.$type === 'com.whtwnd.blog.entry') {
39
+
record = rec.value;
40
+
title = record.title || title;
41
+
}
42
+
}
43
+
} catch (e) {
44
+
console.error('Error loading whitewind post', e);
45
+
}
46
+
---
47
+
48
+
<Layout title={title}>
49
+
<div class="container mx-auto px-4 py-8">
50
+
{record ? (
51
+
<div class="max-w-3xl mx-auto">
52
+
<WhitewindBlogPost record={record} showTags={true} showTimestamp={true} />
53
+
</div>
54
+
) : (
55
+
<div class="text-center text-gray-500 dark:text-gray-400 py-16">Post not found.</div>
56
+
)}
57
+
</div>
58
+
</Layout>
59
+
+89
src/pages/blog.astro
+89
src/pages/blog.astro
···
1
+
---
2
+
import Layout from '../layouts/Layout.astro';
3
+
import { AtprotoBrowser } from '../lib/atproto/atproto-browser';
4
+
import { loadConfig } from '../lib/config/site';
5
+
import type { ComWhtwndBlogEntryRecord } from '../lib/generated/com-whtwnd-blog-entry';
6
+
7
+
const config = loadConfig();
8
+
const browser = new AtprotoBrowser();
9
+
10
+
// Fetch Whitewind blog posts from the repo using generated types
11
+
let posts: Array<{
12
+
uri: string;
13
+
record: ComWhtwndBlogEntryRecord;
14
+
createdAt: string;
15
+
}> = [];
16
+
17
+
try {
18
+
const records = await browser.getAllCollectionRecords(config.atproto.handle, 'com.whtwnd.blog.entry', 2000);
19
+
posts = records
20
+
.filter((r: any) => r.value?.$type === 'com.whtwnd.blog.entry')
21
+
.map((r: any) => {
22
+
const record = r.value as ComWhtwndBlogEntryRecord;
23
+
const createdAt = (record as any).createdAt || r.indexedAt;
24
+
return {
25
+
uri: r.uri,
26
+
record,
27
+
createdAt,
28
+
};
29
+
});
30
+
posts.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
31
+
} catch (e) {
32
+
console.error('Error loading whitewind posts', e);
33
+
}
34
+
35
+
function excerpt(text: string, maxChars = 240) {
36
+
let t = text
37
+
.replace(/!\[[^\]]*\]\([^\)]+\)/g, ' ')
38
+
.replace(/\[[^\]]+\]\(([^\)]+)\)/g, '$1')
39
+
.replace(/`{3}[\s\S]*?`{3}/g, ' ')
40
+
.replace(/`([^`]+)`/g, '$1')
41
+
.replace(/^#{1,6}\s+/gm, '')
42
+
.replace(/[*_]{1,3}([^*_]+)[*_]{1,3}/g, '$1');
43
+
t = t.replace(/\s+/g, ' ').trim();
44
+
if (t.length <= maxChars) return t;
45
+
return t.slice(0, maxChars).trimEnd() + 'โฆ';
46
+
}
47
+
48
+
function postPathFromUri(uri: string) {
49
+
// at://did/.../<collection>/<rkey>
50
+
const parts = uri.split('/');
51
+
const rkey = parts[parts.length - 1];
52
+
return `/blog/${rkey}`;
53
+
}
54
+
---
55
+
56
+
<Layout title="Blog">
57
+
<div class="container mx-auto px-4 py-8">
58
+
<header class="text-center mb-12">
59
+
<h1 class="text-4xl font-bold text-gray-900 dark:text-white mb-4">Blog</h1>
60
+
<p class="text-xl text-gray-600 dark:text-gray-400 max-w-2xl mx-auto">Writing powered by the Whitewind lexicon</p>
61
+
</header>
62
+
63
+
<main class="max-w-3xl mx-auto">
64
+
{posts.length === 0 ? (
65
+
<div class="text-center text-gray-500 dark:text-gray-400 py-16">No posts yet.</div>
66
+
) : (
67
+
<div class="space-y-8">
68
+
{posts.map((p) => (
69
+
<article class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
70
+
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-2">
71
+
<a href={postPathFromUri(p.uri)} class="hover:underline">
72
+
{p.record.title || 'Untitled'}
73
+
</a>
74
+
</h2>
75
+
<div class="text-sm text-gray-500 dark:text-gray-400 mb-3">
76
+
{(() => {
77
+
const d = new Date(p.createdAt);
78
+
return isNaN(d.getTime()) ? '' : d.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
79
+
})()}
80
+
</div>
81
+
<p class="text-gray-700 dark:text-gray-300">{excerpt(p.record.content || '')}</p>
82
+
</article>
83
+
))}
84
+
</div>
85
+
)}
86
+
</main>
87
+
</div>
88
+
</Layout>
89
+
+9
-1
src/pages/index.astro
+9
-1
src/pages/index.astro
···
1
1
---
2
2
import Layout from '../layouts/Layout.astro';
3
3
import ContentFeed from '../components/content/ContentFeed.astro';
4
+
import StatusUpdate from '../components/content/StatusUpdate.astro';
4
5
import { loadConfig } from '../lib/config/site';
5
6
6
7
const config = loadConfig();
···
19
20
<a href="/" class="text-blue-600 dark:text-blue-400 hover:underline">Home</a>
20
21
<a href="/blog" class="text-blue-600 dark:text-blue-400 hover:underline">Blog</a>
21
22
<a href="/galleries" class="text-blue-600 dark:text-blue-400 hover:underline">Galleries</a>
23
+
<a href="/leaflets" class="text-blue-600 dark:text-blue-400 hover:underline">Leaflets</a>
22
24
</nav>
23
25
</header>
24
26
25
27
<main>
26
28
<section class="mb-8">
27
29
<h2 class="text-2xl font-semibold text-gray-900 dark:text-white mb-4">
30
+
Current Status
31
+
</h2>
32
+
<StatusUpdate />
33
+
</section>
34
+
35
+
<section class="mb-8">
36
+
<h2 class="text-2xl font-semibold text-gray-900 dark:text-white mb-4">
28
37
Latest Posts
29
38
</h2>
30
39
<ContentFeed
31
40
limit={10}
32
-
showAuthor={false}
33
41
showTimestamp={true}
34
42
live={true}
35
43
/>
+60
src/pages/leaflets/[leaflet].astro
+60
src/pages/leaflets/[leaflet].astro
···
1
+
---
2
+
import Layout from '../../layouts/Layout.astro';
3
+
import { getCollection, getEntry, render } from "astro:content";
4
+
5
+
export async function getStaticPaths() {
6
+
const documents = await getCollection("documents");
7
+
return documents.map((document) => ({
8
+
params: { leaflet: document.id },
9
+
props: document,
10
+
}));
11
+
}
12
+
13
+
const document = await getEntry("documents", Astro.params.leaflet);
14
+
15
+
if (!document) {
16
+
throw new Error(`Document with id "${Astro.params.leaflet}" not found`);
17
+
}
18
+
19
+
const { Content } = await render(document);
20
+
---
21
+
22
+
<Layout title={document.data.title}>
23
+
<div class="container mx-auto px-4 py-8">
24
+
<header class="mb-8">
25
+
<nav class="mb-6">
26
+
<a href="/leaflets" class="text-blue-600 dark:text-blue-400 hover:underline">
27
+
โ Back to Leaflets
28
+
</a>
29
+
</nav>
30
+
31
+
<h1 class="text-4xl font-bold text-gray-900 dark:text-white mb-4">
32
+
{document.data.title}
33
+
</h1>
34
+
35
+
{document.data.description && (
36
+
<p class="text-xl text-gray-600 dark:text-gray-400 mb-6">
37
+
{document.data.description}
38
+
</p>
39
+
)}
40
+
41
+
<div class="text-sm text-gray-500 dark:text-gray-400 mb-6">
42
+
{document.data.publishedAt && (
43
+
<span>
44
+
{new Date(document.data.publishedAt).toLocaleDateString('en-US', {
45
+
year: 'numeric',
46
+
month: 'long',
47
+
day: 'numeric',
48
+
})}
49
+
</span>
50
+
)}
51
+
</div>
52
+
</header>
53
+
54
+
<main class="max-w-4xl mx-auto">
55
+
<article class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-8">
56
+
<Content />
57
+
</article>
58
+
</main>
59
+
</div>
60
+
</Layout>
+98
src/pages/leaflets.astro
+98
src/pages/leaflets.astro
···
1
+
---
2
+
import Layout from '../layouts/Layout.astro';
3
+
import { getCollection } from "astro:content";
4
+
import { loadConfig } from '../lib/config/site';
5
+
6
+
const config = loadConfig();
7
+
const documents = await getCollection("documents");
8
+
9
+
// Sort documents by published date (newest first)
10
+
const sortedDocuments = documents.sort((a, b) => {
11
+
const dateA = a.data.publishedAt ? new Date(a.data.publishedAt).getTime() : 0;
12
+
const dateB = b.data.publishedAt ? new Date(b.data.publishedAt).getTime() : 0;
13
+
return dateB - dateA;
14
+
});
15
+
---
16
+
17
+
<Layout title="Leaflet Documents">
18
+
<div class="container mx-auto px-4 py-8">
19
+
<header class="text-center mb-12">
20
+
<h1 class="text-4xl font-bold text-gray-900 dark:text-white mb-4">
21
+
Leaflet Documents
22
+
</h1>
23
+
<p class="text-xl text-gray-600 dark:text-gray-400 max-w-2xl mx-auto">
24
+
A collection of my leaflet.pub documents
25
+
</p>
26
+
</header>
27
+
28
+
<main class="max-w-4xl mx-auto">
29
+
{config.atproto.handle && config.atproto.handle !== 'your-handle-here' ? (
30
+
sortedDocuments.length > 0 ? (
31
+
<div class="space-y-6">
32
+
{sortedDocuments.map((document) => (
33
+
<article class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
34
+
<header class="mb-4">
35
+
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-2">
36
+
<a href={`/leaflets/${document.id}`} class="hover:text-blue-600 dark:hover:text-blue-400 transition-colors">
37
+
{document.data.title}
38
+
</a>
39
+
</h2>
40
+
41
+
{document.data.description && (
42
+
<p class="text-gray-600 dark:text-gray-400 mb-3">
43
+
{document.data.description}
44
+
</p>
45
+
)}
46
+
47
+
<div class="text-sm text-gray-500 dark:text-gray-400">
48
+
{document.data.publishedAt && (
49
+
<span>
50
+
Published: {new Date(document.data.publishedAt).toLocaleDateString('en-US', {
51
+
year: 'numeric',
52
+
month: 'long',
53
+
day: 'numeric',
54
+
})}
55
+
</span>
56
+
)}
57
+
</div>
58
+
</header>
59
+
</article>
60
+
))}
61
+
</div>
62
+
) : (
63
+
<div class="text-center py-12">
64
+
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg p-8">
65
+
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">
66
+
No Leaflet Documents Found
67
+
</h3>
68
+
<p class="text-gray-600 dark:text-gray-400 mb-4">
69
+
No leaflet.pub documents were found for your account.
70
+
</p>
71
+
<p class="text-sm text-gray-500 dark:text-gray-500">
72
+
Make sure you have created documents using leaflet.pub and they are properly indexed.
73
+
</p>
74
+
</div>
75
+
</div>
76
+
)
77
+
) : (
78
+
<div class="text-center py-12">
79
+
<div class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-8">
80
+
<h3 class="text-xl font-semibold text-yellow-800 dark:text-yellow-200 mb-4">
81
+
Configuration Required
82
+
</h3>
83
+
<p class="text-yellow-700 dark:text-yellow-300 mb-4">
84
+
To display your Leaflet documents, please configure your Bluesky handle in the environment variables.
85
+
</p>
86
+
<div class="text-sm text-yellow-600 dark:text-yellow-400">
87
+
<p class="mb-2">Create a <code class="bg-yellow-100 dark:bg-yellow-800 px-1 rounded">.env</code> file with:</p>
88
+
<pre class="bg-yellow-100 dark:bg-yellow-800 p-3 rounded text-xs overflow-x-auto">
89
+
ATPROTO_HANDLE=your-handle.bsky.social
90
+
SITE_TITLE=Your Site Title
91
+
SITE_AUTHOR=Your Name</pre>
92
+
</div>
93
+
</div>
94
+
</div>
95
+
)}
96
+
</main>
97
+
</div>
98
+
</Layout>
+17
src/pages/now.astro
+17
src/pages/now.astro
···
1
+
---
2
+
import Layout from '../layouts/Layout.astro';
3
+
import StatusUpdate from '../components/content/StatusUpdate.astro';
4
+
---
5
+
6
+
<Layout title="Now">
7
+
<main class="max-w-2xl mx-auto py-8 px-4 space-y-8">
8
+
<section>
9
+
<h1 class="text-2xl font-bold mb-2">Now</h1>
10
+
<p>This is the now page.</p>
11
+
</section>
12
+
<section>
13
+
<h2 class="text-xl font-bold mb-2">Status</h2>
14
+
<StatusUpdate />
15
+
</section>
16
+
</main>
17
+
</Layout>