A personal website powered by Astro and ATProto
3
fork

Configure Feed

Select the types of activity you want to include in your feed.

misc changes

+1381 -137
+32
src/lexicons/social.grain.photo.exif.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "social.grain.photo.exif", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "description": "Basic EXIF metadata for a photo. Integers are scaled by 1000000 to accommodate decimal values and potentially other tags in the future.", 8 + "key": "tid", 9 + "record": { 10 + "type": "object", 11 + "required": [ 12 + "photo", 13 + "createdAt" 14 + ], 15 + "properties": { 16 + "photo": { "type": "string", "format": "at-uri" }, 17 + "createdAt": { "type": "string", "format": "datetime" }, 18 + "dateTimeOriginal": { "type": "string", "format": "datetime" }, 19 + "exposureTime": { "type": "integer" }, 20 + "fNumber": { "type": "integer" }, 21 + "flash": { "type": "string" }, 22 + "focalLengthIn35mmFormat": { "type": "integer" }, 23 + "iSO": { "type": "integer" }, 24 + "lensMake": { "type": "string" }, 25 + "lensModel": { "type": "string" }, 26 + "make": { "type": "string" }, 27 + "model": { "type": "string" } 28 + } 29 + } 30 + } 31 + } 32 + }
+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 +
+40
src/lib/atproto/blob.ts
··· 1 + import { AtpAgent } from '@atproto/api' 2 + import { loadConfig } from '../config/site' 3 + 4 + export type BlobVariant = 'full' | 'avatar' | 'feed' 5 + 6 + export function blobCdnUrl(did: string, cid: string, variant: BlobVariant = 'full'): string { 7 + // Default to PDS sync getBlob endpoint; AppView/CDN variants can be swapped in later 8 + const base = 'https://bsky.social/xrpc/com.atproto.sync.getBlob' 9 + const params = new URLSearchParams({ did, cid }) 10 + return `${base}?${params.toString()}` 11 + } 12 + 13 + export async function fetchBlob(did: string, cid: string, agent?: AtpAgent): Promise<Blob> { 14 + const cfg = loadConfig() 15 + const atp = agent ?? new AtpAgent({ service: cfg.atproto.pdsUrl || 'https://bsky.social' }) 16 + const res = await atp.com.atproto.sync.getBlob({ did, cid }) 17 + // @atproto/api returns a Response-like object with data as ArrayBuffer 18 + const arrayBuf = await res.data.blob() 19 + return new Blob([arrayBuf]) 20 + } 21 + 22 + // Extract CID string from common embed image blob refs 23 + export function extractCidFromBlobRef(ref: unknown): string | null { 24 + // Formats we might encounter: 25 + // - string (cid) 26 + // - { $link: string } 27 + // - BlobRef object with toString() 28 + if (typeof ref === 'string') return ref 29 + if (ref && typeof ref === 'object') { 30 + const anyRef = ref as any 31 + if (typeof anyRef.$link === 'string') return anyRef.$link 32 + if (typeof anyRef.toString === 'function') { 33 + const s = anyRef.toString() 34 + if (s && typeof s === 'string') return s 35 + } 36 + } 37 + return null 38 + } 39 + 40 +
+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;
+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
··· 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 + };
+19
src/lib/generated/social-grain-gallery.ts
··· 1 + // Generated from lexicon schema: social.grain.gallery 2 + // Do not edit manually - regenerate with: npm run gen:types 3 + 4 + export interface SocialGrainGalleryRecord { 5 + title: string; 6 + description?: string; 7 + facets?: any[]; 8 + labels?: any; 9 + updatedAt?: string; 10 + createdAt: string; 11 + } 12 + 13 + export interface SocialGrainGallery { 14 + $type: 'social.grain.gallery'; 15 + value: SocialGrainGalleryRecord; 16 + } 17 + 18 + // Helper type for discriminated unions 19 + export type SocialGrainGalleryUnion = SocialGrainGallery;
+25
src/lib/generated/social-grain-photo-exif.ts
··· 1 + // Generated from lexicon schema: social.grain.photo.exif 2 + // Do not edit manually - regenerate with: npm run gen:types 3 + 4 + export interface SocialGrainPhotoExifRecord { 5 + photo: string; 6 + createdAt: string; 7 + dateTimeOriginal?: string; 8 + exposureTime?: number; 9 + fNumber?: number; 10 + flash?: string; 11 + focalLengthIn35mmFormat?: number; 12 + iSO?: number; 13 + lensMake?: string; 14 + lensModel?: string; 15 + make?: string; 16 + model?: string; 17 + } 18 + 19 + export interface SocialGrainPhotoExif { 20 + $type: 'social.grain.photo.exif'; 21 + value: SocialGrainPhotoExifRecord; 22 + } 23 + 24 + // Helper type for discriminated unions 25 + export type SocialGrainPhotoExifUnion = SocialGrainPhotoExif;
+17
src/lib/generated/social-grain-photo.ts
··· 1 + // Generated from lexicon schema: social.grain.photo 2 + // Do not edit manually - regenerate with: npm run gen:types 3 + 4 + export interface SocialGrainPhotoRecord { 5 + photo: any; 6 + alt?: string; 7 + aspectRatio: any; 8 + createdAt: string; 9 + } 10 + 11 + export interface SocialGrainPhoto { 12 + $type: 'social.grain.photo'; 13 + value: SocialGrainPhotoRecord; 14 + } 15 + 16 + // Helper type for discriminated unions 17 + export type SocialGrainPhotoUnion = SocialGrainPhoto;
+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 + }
+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 +
+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 +
+8
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(); ··· 23 24 </header> 24 25 25 26 <main> 27 + <section class="mb-8"> 28 + <h2 class="text-2xl font-semibold text-gray-900 dark:text-white mb-4"> 29 + Current Status 30 + </h2> 31 + <StatusUpdate /> 32 + </section> 33 + 26 34 <section class="mb-8"> 27 35 <h2 class="text-2xl font-semibold text-gray-900 dark:text-white mb-4"> 28 36 Latest Posts
+10
src/pages/now.astro
··· 1 + --- 2 + import Layout from '../layouts/Layout.astro'; 3 + --- 4 + 5 + <Layout title="Now"> 6 + <div class="flex flex-col items-center h-screen"> 7 + <h1 class="text-2xl font-bold">Now</h1> 8 + <p>This is the now page.</p> 9 + </div> 10 + </Layout>