WIP: My personal website
0

Configure Feed

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

teal and bookhive features

+1878 -43
+1
.gitignore
··· 26 26 # Extras 27 27 .playwright-mcp/ 28 28 cache.db 29 + cache-live.db
+13
lexicons.json
··· 1 1 { 2 2 "version": 1, 3 3 "lexicons": [ 4 + "buzz.bookhive.book", 4 5 "site.standard.publication" 5 6 ], 6 7 "resolutions": { 8 + "buzz.bookhive.book": { 9 + "uri": "at://did:plc:enu2j5xjlqsjaylv3du4myh4/com.atproto.lexicon.schema/buzz.bookhive.book", 10 + "cid": "bafyreialscgpknm7si2e2imqbejkpvk3djcww7bim3rnvgqeyw6s7rnpcu" 11 + }, 12 + "buzz.bookhive.defs": { 13 + "uri": "at://did:plc:enu2j5xjlqsjaylv3du4myh4/com.atproto.lexicon.schema/buzz.bookhive.defs", 14 + "cid": "bafyreidsae7soy4e3mqwp2blxqkjov544lietetq6lyd4n5rugsmr2l6jq" 15 + }, 7 16 "com.atproto.label.defs": { 8 17 "uri": "at://did:plc:6msi3pj7krzih5qxqtryxlzw/com.atproto.lexicon.schema/com.atproto.label.defs", 9 18 "cid": "bafyreidp2wpcbzl2qiob2uwoc7lhntojx3tjcr553mmahw2f5cumtm3wpm" 19 + }, 20 + "com.atproto.repo.strongRef": { 21 + "uri": "at://did:plc:6msi3pj7krzih5qxqtryxlzw/com.atproto.lexicon.schema/com.atproto.repo.strongRef", 22 + "cid": "bafyreifrkdbnkvfjujntdaeigolnrjj3srrs53tfixjhmacclps72qlov4" 10 23 }, 11 24 "site.standard.publication": { 12 25 "uri": "at://did:plc:re3ebnp5v7ffagz6rb6xfei4/com.atproto.lexicon.schema/site.standard.publication",
+100
lexicons/buzz/bookhive/book.json
··· 1 + { 2 + "id": "buzz.bookhive.book", 3 + "defs": { 4 + "main": { 5 + "key": "tid", 6 + "type": "record", 7 + "record": { 8 + "type": "object", 9 + "required": [ 10 + "title", 11 + "authors", 12 + "hiveId", 13 + "createdAt" 14 + ], 15 + "properties": { 16 + "cover": { 17 + "type": "blob", 18 + "accept": [ 19 + "image/png", 20 + "image/jpeg" 21 + ], 22 + "maxSize": 1000000, 23 + "description": "Cover image of the book" 24 + }, 25 + "owned": { 26 + "type": "boolean", 27 + "description": "Whether the user owns this book" 28 + }, 29 + "stars": { 30 + "type": "integer", 31 + "maximum": 10, 32 + "minimum": 1, 33 + "description": "Number of stars given to the book (1-10) which will be mapped to 1-5 stars" 34 + }, 35 + "title": { 36 + "type": "string", 37 + "maxLength": 512, 38 + "minLength": 1, 39 + "description": "The title of the book" 40 + }, 41 + "hiveId": { 42 + "type": "string", 43 + "description": "The book's hive id, used to correlate user's books with the hive" 44 + }, 45 + "review": { 46 + "type": "string", 47 + "description": "The book's review", 48 + "maxGraphemes": 15000 49 + }, 50 + "status": { 51 + "type": "string", 52 + "knownValues": [ 53 + "buzz.bookhive.defs#finished", 54 + "buzz.bookhive.defs#reading", 55 + "buzz.bookhive.defs#wantToRead", 56 + "buzz.bookhive.defs#abandoned" 57 + ] 58 + }, 59 + "authors": { 60 + "type": "string", 61 + "maxLength": 2048, 62 + "minLength": 1, 63 + "description": "The authors of the book (tab separated)" 64 + }, 65 + "createdAt": { 66 + "type": "string", 67 + "format": "datetime" 68 + }, 69 + "startedAt": { 70 + "type": "string", 71 + "format": "datetime", 72 + "description": "The date the user started reading the book" 73 + }, 74 + "finishedAt": { 75 + "type": "string", 76 + "format": "datetime", 77 + "description": "The date the user finished reading the book" 78 + }, 79 + "hiveBookUri": { 80 + "type": "string", 81 + "description": "AT-URI of the canonical catalogBook record in @bookhive.buzz" 82 + }, 83 + "identifiers": { 84 + "ref": "buzz.bookhive.defs#bookIdentifiers", 85 + "type": "ref", 86 + "description": "External identifiers for the book" 87 + }, 88 + "bookProgress": { 89 + "ref": "buzz.bookhive.defs#bookProgress", 90 + "type": "ref", 91 + "description": "Progress tracking details for the book" 92 + } 93 + } 94 + }, 95 + "description": "A book in the user's library" 96 + } 97 + }, 98 + "$type": "com.atproto.lexicon.schema", 99 + "lexicon": 1 100 + }
+338
lexicons/buzz/bookhive/defs.json
··· 1 + { 2 + "id": "buzz.bookhive.defs", 3 + "defs": { 4 + "review": { 5 + "type": "object", 6 + "required": [ 7 + "review", 8 + "createdAt", 9 + "did", 10 + "handle" 11 + ], 12 + "properties": { 13 + "did": { 14 + "type": "string", 15 + "description": "The DID of the user who made the review" 16 + }, 17 + "stars": { 18 + "type": "integer", 19 + "description": "The number of stars given to the book" 20 + }, 21 + "handle": { 22 + "type": "string", 23 + "description": "The handle of the user who made the review" 24 + }, 25 + "review": { 26 + "type": "string", 27 + "description": "The review content" 28 + }, 29 + "createdAt": { 30 + "type": "string", 31 + "format": "datetime", 32 + "description": "The date the review was created" 33 + } 34 + } 35 + }, 36 + "comment": { 37 + "type": "object", 38 + "required": [ 39 + "comment", 40 + "createdAt", 41 + "book", 42 + "parent", 43 + "did", 44 + "handle" 45 + ], 46 + "properties": { 47 + "did": { 48 + "type": "string", 49 + "description": "The DID of the user who made the comment" 50 + }, 51 + "book": { 52 + "ref": "com.atproto.repo.strongRef", 53 + "type": "ref" 54 + }, 55 + "handle": { 56 + "type": "string", 57 + "description": "The handle of the user who made the comment" 58 + }, 59 + "parent": { 60 + "ref": "com.atproto.repo.strongRef", 61 + "type": "ref" 62 + }, 63 + "comment": { 64 + "type": "string", 65 + "maxLength": 100000, 66 + "description": "The content of the comment.", 67 + "maxGraphemes": 10000 68 + }, 69 + "createdAt": { 70 + "type": "string", 71 + "format": "datetime", 72 + "description": "Client-declared timestamp when this comment was originally created." 73 + } 74 + } 75 + }, 76 + "profile": { 77 + "type": "object", 78 + "required": [ 79 + "displayName", 80 + "handle", 81 + "booksRead", 82 + "reviews" 83 + ], 84 + "properties": { 85 + "avatar": { 86 + "type": "string" 87 + }, 88 + "handle": { 89 + "type": "string" 90 + }, 91 + "reviews": { 92 + "type": "integer", 93 + "minimum": 0 94 + }, 95 + "booksRead": { 96 + "type": "integer", 97 + "minimum": 0 98 + }, 99 + "description": { 100 + "type": "string" 101 + }, 102 + "displayName": { 103 + "type": "string" 104 + }, 105 + "isFollowing": { 106 + "type": "boolean", 107 + "description": "Whether the authed user is following this profile" 108 + } 109 + } 110 + }, 111 + "reading": { 112 + "type": "token", 113 + "description": "User is currently reading the book" 114 + }, 115 + "activity": { 116 + "type": "object", 117 + "required": [ 118 + "type", 119 + "createdAt", 120 + "hiveId", 121 + "title", 122 + "userDid", 123 + "userHandle" 124 + ], 125 + "properties": { 126 + "type": { 127 + "type": "string", 128 + "knownValues": [ 129 + "review", 130 + "rated", 131 + "started", 132 + "finished" 133 + ] 134 + }, 135 + "title": { 136 + "type": "string", 137 + "description": "The title of the book" 138 + }, 139 + "hiveId": { 140 + "type": "string", 141 + "description": "The hive id of the book" 142 + }, 143 + "userDid": { 144 + "type": "string", 145 + "description": "The DID of the user who added the book" 146 + }, 147 + "createdAt": { 148 + "type": "string", 149 + "format": "datetime" 150 + }, 151 + "userHandle": { 152 + "type": "string", 153 + "description": "The handle of the user who added the book" 154 + } 155 + } 156 + }, 157 + "finished": { 158 + "type": "token", 159 + "description": "User has finished reading the book" 160 + }, 161 + "userBook": { 162 + "type": "object", 163 + "required": [ 164 + "userDid", 165 + "title", 166 + "authors", 167 + "hiveId", 168 + "createdAt", 169 + "thumbnail" 170 + ], 171 + "properties": { 172 + "cover": { 173 + "type": "string", 174 + "description": "Cover image of the book" 175 + }, 176 + "owned": { 177 + "type": "boolean", 178 + "description": "Whether the user owns this book" 179 + }, 180 + "stars": { 181 + "type": "integer", 182 + "maximum": 10, 183 + "minimum": 1, 184 + "description": "Number of stars given to the book (1-10) which will be mapped to 1-5 stars" 185 + }, 186 + "title": { 187 + "type": "string", 188 + "maxLength": 512, 189 + "minLength": 1, 190 + "description": "The title of the book" 191 + }, 192 + "hiveId": { 193 + "type": "string", 194 + "description": "The book's hive id, used to correlate user's books with the hive" 195 + }, 196 + "rating": { 197 + "type": "integer", 198 + "maximum": 1000, 199 + "minimum": 0, 200 + "description": "Average rating (0-1000)" 201 + }, 202 + "review": { 203 + "type": "string", 204 + "description": "The book's review", 205 + "maxGraphemes": 15000 206 + }, 207 + "status": { 208 + "type": "string", 209 + "knownValues": [ 210 + "buzz.bookhive.defs#finished", 211 + "buzz.bookhive.defs#reading", 212 + "buzz.bookhive.defs#wantToRead", 213 + "buzz.bookhive.defs#abandoned" 214 + ] 215 + }, 216 + "authors": { 217 + "type": "string", 218 + "maxLength": 2048, 219 + "minLength": 1, 220 + "description": "The authors of the book (tab separated)" 221 + }, 222 + "userDid": { 223 + "type": "string", 224 + "description": "The DID of the user who added the book" 225 + }, 226 + "createdAt": { 227 + "type": "string", 228 + "format": "datetime" 229 + }, 230 + "startedAt": { 231 + "type": "string", 232 + "format": "datetime", 233 + "description": "The date the user started reading the book" 234 + }, 235 + "thumbnail": { 236 + "type": "string", 237 + "description": "Cover image of the book" 238 + }, 239 + "finishedAt": { 240 + "type": "string", 241 + "format": "datetime", 242 + "description": "The date the user finished reading the book" 243 + }, 244 + "userHandle": { 245 + "type": "string", 246 + "description": "The handle of the user who added the book" 247 + }, 248 + "description": { 249 + "type": "string", 250 + "maxLength": 5000, 251 + "description": "Book description/summary" 252 + }, 253 + "identifiers": { 254 + "ref": "buzz.bookhive.defs#bookIdentifiers", 255 + "type": "ref", 256 + "description": "External identifiers for the book" 257 + }, 258 + "bookProgress": { 259 + "ref": "buzz.bookhive.defs#bookProgress", 260 + "type": "ref", 261 + "description": "Progress tracking information for the book" 262 + } 263 + } 264 + }, 265 + "abandoned": { 266 + "type": "token", 267 + "description": "User has abandoned the book" 268 + }, 269 + "wantToRead": { 270 + "type": "token", 271 + "description": "User wants to read the book" 272 + }, 273 + "bookProgress": { 274 + "type": "object", 275 + "required": [ 276 + "updatedAt" 277 + ], 278 + "properties": { 279 + "percent": { 280 + "type": "integer", 281 + "maximum": 100, 282 + "minimum": 0, 283 + "description": "How far through the book the reader is (0-100)" 284 + }, 285 + "updatedAt": { 286 + "type": "string", 287 + "format": "datetime", 288 + "description": "When the progress was last updated" 289 + }, 290 + "totalPages": { 291 + "type": "integer", 292 + "minimum": 1, 293 + "description": "Total number of pages in the book" 294 + }, 295 + "currentPage": { 296 + "type": "integer", 297 + "minimum": 1, 298 + "description": "Current page the user is on" 299 + }, 300 + "totalChapters": { 301 + "type": "integer", 302 + "minimum": 1, 303 + "description": "Total number of chapters in the book" 304 + }, 305 + "currentChapter": { 306 + "type": "integer", 307 + "minimum": 1, 308 + "description": "Current chapter the user is on" 309 + } 310 + }, 311 + "description": "Reading progress tracking data" 312 + }, 313 + "bookIdentifiers": { 314 + "type": "object", 315 + "properties": { 316 + "hiveId": { 317 + "type": "string", 318 + "description": "BookHive's internal ID" 319 + }, 320 + "isbn10": { 321 + "type": "string", 322 + "description": "10-digit ISBN" 323 + }, 324 + "isbn13": { 325 + "type": "string", 326 + "description": "13-digit ISBN" 327 + }, 328 + "goodreadsId": { 329 + "type": "string", 330 + "description": "Goodreads book ID" 331 + } 332 + }, 333 + "description": "External identifiers for a book" 334 + } 335 + }, 336 + "$type": "com.atproto.lexicon.schema", 337 + "lexicon": 1 338 + }
+25
lexicons/com/atproto/repo/strongRef.json
··· 1 + { 2 + "id": "com.atproto.repo.strongRef", 3 + "defs": { 4 + "main": { 5 + "type": "object", 6 + "required": [ 7 + "uri", 8 + "cid" 9 + ], 10 + "properties": { 11 + "cid": { 12 + "type": "string", 13 + "format": "cid" 14 + }, 15 + "uri": { 16 + "type": "string", 17 + "format": "at-uri" 18 + } 19 + } 20 + } 21 + }, 22 + "$type": "com.atproto.lexicon.schema", 23 + "lexicon": 1, 24 + "description": "A URI with a content-hash fingerprint." 25 + }
+31
lexicons/fm/teal/alpha/actor/status.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "fm.teal.alpha.actor.status", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "description": "This lexicon is in a not officially released state. It is subject to change. | A declaration of the status of the actor. Only one can be shown at a time. If there are multiple, the latest record should be picked and earlier records should be deleted or tombstoned.", 8 + "key": "literal:self", 9 + "record": { 10 + "type": "object", 11 + "required": ["time", "item"], 12 + "properties": { 13 + "time": { 14 + "type": "string", 15 + "format": "datetime", 16 + "description": "The RFC 3339 formatted time of when the item was recorded" 17 + }, 18 + "expiry": { 19 + "type": "string", 20 + "format": "datetime", 21 + "description": "The RFC 3339 formatted time of the expiry time of the item. If unavailable, default to 10 minutes past the start time." 22 + }, 23 + "item": { 24 + "type": "ref", 25 + "ref": "fm.teal.alpha.feed.defs#playView" 26 + } 27 + } 28 + } 29 + } 30 + } 31 + }
+94
lexicons/fm/teal/alpha/feed/defs.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "fm.teal.alpha.feed.defs", 4 + "description": "This lexicon is in a not officially released state. It is subject to change. | Misc. items related to feeds.", 5 + "defs": { 6 + "playView": { 7 + "type": "object", 8 + "required": ["trackName", "artists"], 9 + "properties": { 10 + "trackName": { 11 + "type": "string", 12 + "minLength": 1, 13 + "maxLength": 256, 14 + "maxGraphemes": 2560, 15 + "description": "The name of the track" 16 + }, 17 + "trackMbId": { 18 + "type": "string", 19 + "format": "uri", 20 + "description": "The MusicBrainz ID URI of the track, formatted as mbid:<uuid>" 21 + }, 22 + "recordingMbId": { 23 + "type": "string", 24 + "format": "uri", 25 + "description": "The MusicBrainz recording ID URI of the track, formatted as mbid:<uuid>" 26 + }, 27 + "duration": { 28 + "type": "integer", 29 + "description": "The length of the track in seconds" 30 + }, 31 + "artists": { 32 + "type": "array", 33 + "items": { 34 + "type": "ref", 35 + "ref": "#artist" 36 + }, 37 + "description": "Array of artists in order of original appearance." 38 + }, 39 + "releaseName": { 40 + "type": "string", 41 + "maxLength": 256, 42 + "maxGraphemes": 2560, 43 + "description": "The name of the release/album" 44 + }, 45 + "releaseMbId": { 46 + "type": "string", 47 + "format": "uri", 48 + "description": "The MusicBrainz release ID URI, formatted as mbid:<uuid>" 49 + }, 50 + "isrc": { 51 + "type": "string", 52 + "description": "The ISRC code associated with the recording" 53 + }, 54 + "originUrl": { 55 + "type": "string", 56 + "description": "The URL associated with this track" 57 + }, 58 + "musicServiceBaseDomain": { 59 + "type": "string", 60 + "description": "The base domain of the music service. e.g. music.apple.com, tidal.com, spotify.com. Defaults to 'local' if not provided." 61 + }, 62 + "submissionClientAgent": { 63 + "type": "string", 64 + "maxLength": 256, 65 + "maxGraphemes": 2560, 66 + "description": "A user-agent style string specifying the user agent. e.g. tealtracker/0.0.1b (Linux; Android 13; SM-A715F). Defaults to 'manual/unknown' if not provided." 67 + }, 68 + "playedTime": { 69 + "type": "string", 70 + "format": "datetime", 71 + "description": "The unix timestamp of when the track was played" 72 + } 73 + } 74 + }, 75 + "artist": { 76 + "type": "object", 77 + "required": ["artistName"], 78 + "properties": { 79 + "artistName": { 80 + "type": "string", 81 + "minLength": 1, 82 + "maxLength": 256, 83 + "maxGraphemes": 2560, 84 + "description": "The name of the artist" 85 + }, 86 + "artistMbId": { 87 + "type": "string", 88 + "format": "uri", 89 + "description": "The MusicBrainz artist ID URI, formatted as mbid:<uuid>" 90 + } 91 + } 92 + } 93 + } 94 + }
src/lib/assets/projects/attoolbox.webp

This is a binary file and will not be displayed.

+109
src/lib/components/FreeTime.svelte
··· 1 + <script lang="ts"> 2 + import { reveal } from '$lib/actions/reveal'; 3 + import type { CurrentlyReading, NowPlaying } from '$lib/types'; 4 + 5 + let { 6 + currentlyReading, 7 + nowPlaying 8 + }: { currentlyReading: CurrentlyReading | null; nowPlaying: NowPlaying | null } = $props(); 9 + 10 + // Hide the album art and reveal the music-note fallback if Cover Art Archive 11 + // has no front cover for this release (the URL 404s). 12 + let coverArtFailed = $state(false); 13 + </script> 14 + 15 + {#if currentlyReading || nowPlaying} 16 + <div id="freetime" class="mt-10 flex flex-col items-center justify-center md:mt-20" use:reveal> 17 + <div class="flex flex-col items-center justify-center"> 18 + <h1 class="text-center font-urbanist text-2xl font-semibold md:text-5xl">Free Time</h1> 19 + <span class="text-md mt-2 px-2 text-center font-urbanist md:mt-4 md:px-5 md:text-xl"> 20 + What I'm reading &amp; listening to right now 21 + </span> 22 + </div> 23 + <div class="container mt-10 grid gap-10 p-4 md:grid-cols-2"> 24 + {#if currentlyReading} 25 + <div class="card bg-base-100 shadow-sm transition duration-300 hover:-translate-y-1"> 26 + <div class="card-body flex-row items-start gap-5"> 27 + {#if currentlyReading.cover} 28 + <img 29 + class="h-40 w-28 flex-none rounded-md object-cover shadow-sm" 30 + src={currentlyReading.cover} 31 + alt={currentlyReading.title} 32 + /> 33 + {/if} 34 + <div class="flex flex-col"> 35 + <span class="badge font-urbanist badge-primary">Currently reading</span> 36 + <h2 class="mt-2 card-title font-urbanist text-2xl font-black"> 37 + {currentlyReading.title} 38 + </h2> 39 + <p class="text-md font-urbanist font-medium opacity-60"> 40 + {currentlyReading.authors} 41 + </p> 42 + <div class="mt-3 card-actions"> 43 + <a 44 + class="btn font-urbanist btn-sm btn-primary" 45 + href={currentlyReading.bookUrl} 46 + target="_blank" 47 + rel="noopener noreferrer" 48 + > 49 + Read 50 + </a> 51 + </div> 52 + </div> 53 + </div> 54 + </div> 55 + {/if} 56 + {#if nowPlaying} 57 + <div class="card bg-base-100 shadow-sm transition duration-300 hover:-translate-y-1"> 58 + <div class="card-body flex-row items-start gap-5"> 59 + {#if nowPlaying.coverArt && !coverArtFailed} 60 + <img 61 + class="h-32 w-32 flex-none rounded-md object-cover shadow-sm" 62 + src={nowPlaying.coverArt} 63 + alt={nowPlaying.releaseName ?? nowPlaying.trackName} 64 + onerror={() => (coverArtFailed = true)} 65 + /> 66 + {:else} 67 + <div 68 + class="flex h-32 w-32 flex-none items-center justify-center rounded-md bg-base-300" 69 + > 70 + <svg 71 + xmlns="http://www.w3.org/2000/svg" 72 + class="h-12 w-12 opacity-60" 73 + viewBox="0 0 24 24" 74 + fill="currentColor" 75 + > 76 + <path 77 + d="M12 3v10.55A4 4 0 1 0 14 17V7h4V3h-6zm-2 16a2 2 0 1 1 0-4 2 2 0 0 1 0 4z" 78 + /> 79 + </svg> 80 + </div> 81 + {/if} 82 + <div class="flex flex-col"> 83 + <span class="badge font-urbanist badge-secondary">Now listening</span> 84 + <h2 class="mt-2 card-title font-urbanist text-2xl font-black"> 85 + {nowPlaying.trackName} 86 + </h2> 87 + <p class="text-md font-urbanist font-medium opacity-60">{nowPlaying.artists}</p> 88 + {#if nowPlaying.releaseName} 89 + <p class="font-urbanist text-sm font-medium opacity-40">{nowPlaying.releaseName}</p> 90 + {/if} 91 + {#if nowPlaying.url} 92 + <div class="mt-3 card-actions"> 93 + <a 94 + class="btn font-urbanist btn-sm btn-primary" 95 + href={nowPlaying.url} 96 + target="_blank" 97 + rel="noopener noreferrer" 98 + > 99 + Listen 100 + </a> 101 + </div> 102 + {/if} 103 + </div> 104 + </div> 105 + </div> 106 + {/if} 107 + </div> 108 + </div> 109 + {/if}
+1 -1
src/lib/components/Hero.svelte
··· 28 28 >, as well as a hobbyist with embedded firmwares in Rust 🦀. Sometimes serious, mostly just 29 29 killing time. 30 30 </p> 31 - <div class="flex gap-2 max-lg:justify-center"> 31 + <div class="grid grid-cols-2 gap-1 max-lg:justify-center md:grid-cols-3 md:gap-2"> 32 32 <a 33 33 href="https://bsky.app/profile/pds.dad" 34 34 class="btn rounded-full text-xl font-bold not-italic btn-ghost md:btn-lg"
+36 -36
src/lib/components/NavBar.svelte
··· 8 8 { name: 'Home', href: '#home' }, 9 9 { name: 'Projects', href: '#projects' }, 10 10 { name: 'Writings', href: '#writings' }, 11 - { name: 'Freetime', href: '#freetime' }, 11 + { name: 'Free Time', href: '#freetime' }, 12 12 { name: 'Sponsors', href: '#sponsors' } 13 13 ]; 14 14 ··· 37 37 38 38 let active = $state(navigation[0].href); 39 39 40 - onMount(() => { 41 - const sections = navigation 42 - .map((item) => document.getElementById(item.href.slice(1))) 43 - .filter((el): el is HTMLElement => el !== null); 40 + // onMount(() => { 41 + // const sections = navigation 42 + // .map((item) => document.getElementById(item.href.slice(1))) 43 + // .filter((el): el is HTMLElement => el !== null); 44 44 45 - // Track which sections currently cross the activation band so we can 46 - // always pick the topmost one even when several overlap it. 47 - const intersecting = new Set<string>(); 45 + // // Track which sections currently cross the activation band so we can 46 + // // always pick the topmost one even when several overlap it. 47 + // const intersecting = new Set<string>(); 48 48 49 - const updateActive = () => { 50 - const next = navigation.find((item) => intersecting.has(item.href)); 51 - if (!next || next.href === active) return; 52 - active = next.href; 53 - // Use SvelteKit's replaceState (not the native history.replaceState): 54 - // it updates the URL without scrolling *and* preserves the router's 55 - // internal history.state. Calling native history.replaceState(null, …) 56 - // here wipes SvelteKit's `sveltekit:history`/`sveltekit:navigation` 57 - // state, which corrupts the router's scroll handling and makes anchor 58 - // links intermittently fail to scroll. 59 - replaceState(next.href, {}); 60 - }; 49 + // const updateActive = () => { 50 + // const next = navigation.find((item) => intersecting.has(item.href)); 51 + // if (!next || next.href === active) return; 52 + // active = next.href; 53 + // // Use SvelteKit's replaceState (not the native history.replaceState): 54 + // // it updates the URL without scrolling *and* preserves the router's 55 + // // internal history.state. Calling native history.replaceState(null, …) 56 + // // here wipes SvelteKit's `sveltekit:history`/`sveltekit:navigation` 57 + // // state, which corrupts the router's scroll handling and makes anchor 58 + // // links intermittently fail to scroll. 59 + // replaceState(next.href, {}); 60 + // }; 61 61 62 - const observer = new IntersectionObserver( 63 - (entries) => { 64 - for (const entry of entries) { 65 - const hash = `#${entry.target.id}`; 66 - if (entry.isIntersecting) intersecting.add(hash); 67 - else intersecting.delete(hash); 68 - } 69 - updateActive(); 70 - }, 71 - // Thin band ~45% down the viewport: a section becomes active as it 72 - // crosses that line. 73 - { rootMargin: '-45% 0px -50% 0px', threshold: 0 } 74 - ); 62 + // const observer = new IntersectionObserver( 63 + // (entries) => { 64 + // for (const entry of entries) { 65 + // const hash = `#${entry.target.id}`; 66 + // if (entry.isIntersecting) intersecting.add(hash); 67 + // else intersecting.delete(hash); 68 + // } 69 + // updateActive(); 70 + // }, 71 + // // Thin band ~45% down the viewport: a section becomes active as it 72 + // // crosses that line. 73 + // { rootMargin: '-45% 0px -50% 0px', threshold: 0 } 74 + // ); 75 75 76 - sections.forEach((section) => observer.observe(section)); 77 - return () => observer.disconnect(); 78 - }); 76 + // sections.forEach((section) => observer.observe(section)); 77 + // return () => observer.disconnect(); 78 + // }); 79 79 </script> 80 80 81 81 <div class="sticky top-0 z-50 flex justify-center py-4">
+7
src/lib/components/OpenSourceProjects.svelte
··· 6 6 import badger from '$lib/assets/projects/badger.jpg'; 7 7 import twentyfortyeight from '$lib/assets/projects/2048.png'; 8 8 import giveaways from '$lib/assets/projects/giveaways.webp'; 9 + import atToolbox from '$lib/assets/projects/attoolbox.webp'; 9 10 10 11 type Project = { 11 12 name: string; ··· 42 43 'A simple Rust firmware written for the Badger 2040 W from Pimoroni. Counts wifi networks found, reads a shtc3 sensor for temperature and humidity, and displays the results on the Badger display.', 43 44 image: badger, 44 45 href: 'https://tangled.org/pds.dad/rusty-badger' 46 + }, 47 + { 48 + name: 'AT Toobox', 49 + description: 'An iOS app that makes atproto actions easy with shortcuts', 50 + image: atToolbox, 51 + href: 'https://apps.apple.com/us/app/at-toolbox/id6747999688' 45 52 }, 46 53 { 47 54 name: 'at://2048',
+5
src/lib/lexicons/buzz.ts
··· 1 + /* 2 + * THIS FILE WAS GENERATED BY "@atproto/lex". DO NOT EDIT. 3 + */ 4 + 5 + export * as bookhive from './buzz/bookhive.js'
+6
src/lib/lexicons/buzz/bookhive.ts
··· 1 + /* 2 + * THIS FILE WAS GENERATED BY "@atproto/lex". DO NOT EDIT. 3 + */ 4 + 5 + export * as book from './bookhive/book.js' 6 + export * as defs from './bookhive/defs.js'
+151
src/lib/lexicons/buzz/bookhive/book.defs.ts
··· 1 + /* 2 + * THIS FILE WAS GENERATED BY "@atproto/lex". DO NOT EDIT. 3 + */ 4 + 5 + import { l } from '@atproto/lex' 6 + import * as BookhiveDefs from './defs.defs.js' 7 + 8 + const $nsid = 'buzz.bookhive.book' 9 + 10 + export { $nsid } 11 + 12 + /** A book in the user's library */ 13 + type Main = { 14 + $type: 'buzz.bookhive.book' 15 + 16 + /** 17 + * Cover image of the book 18 + */ 19 + cover?: l.BlobRef 20 + 21 + /** 22 + * Whether the user owns this book 23 + */ 24 + owned?: boolean 25 + 26 + /** 27 + * Number of stars given to the book (1-10) which will be mapped to 1-5 stars 28 + */ 29 + stars?: number 30 + 31 + /** 32 + * The title of the book 33 + */ 34 + title: string 35 + 36 + /** 37 + * The book's hive id, used to correlate user's books with the hive 38 + */ 39 + hiveId: string 40 + 41 + /** 42 + * The book's review 43 + */ 44 + review?: string 45 + status?: 46 + | 'buzz.bookhive.defs#finished' 47 + | 'buzz.bookhive.defs#reading' 48 + | 'buzz.bookhive.defs#wantToRead' 49 + | 'buzz.bookhive.defs#abandoned' 50 + | l.UnknownString 51 + 52 + /** 53 + * The authors of the book (tab separated) 54 + */ 55 + authors: string 56 + createdAt: l.DatetimeString 57 + 58 + /** 59 + * The date the user started reading the book 60 + */ 61 + startedAt?: l.DatetimeString 62 + 63 + /** 64 + * The date the user finished reading the book 65 + */ 66 + finishedAt?: l.DatetimeString 67 + 68 + /** 69 + * AT-URI of the canonical catalogBook record in @bookhive.buzz 70 + */ 71 + hiveBookUri?: string 72 + 73 + /** 74 + * External identifiers for the book 75 + */ 76 + identifiers?: BookhiveDefs.BookIdentifiers 77 + 78 + /** 79 + * Progress tracking details for the book 80 + */ 81 + bookProgress?: BookhiveDefs.BookProgress 82 + } 83 + 84 + export type { Main } 85 + 86 + /** A book in the user's library */ 87 + const main = /*#__PURE__*/ l.record<'tid', Main>( 88 + 'tid', 89 + $nsid, 90 + /*#__PURE__*/ l.object({ 91 + cover: /*#__PURE__*/ l.optional( 92 + /*#__PURE__*/ l.blob({ 93 + accept: ['image/png', 'image/jpeg'], 94 + maxSize: 1000000, 95 + }), 96 + ), 97 + owned: /*#__PURE__*/ l.optional(/*#__PURE__*/ l.boolean()), 98 + stars: /*#__PURE__*/ l.optional( 99 + /*#__PURE__*/ l.integer({ maximum: 10, minimum: 1 }), 100 + ), 101 + title: /*#__PURE__*/ l.string({ maxLength: 512, minLength: 1 }), 102 + hiveId: /*#__PURE__*/ l.string(), 103 + review: /*#__PURE__*/ l.optional( 104 + /*#__PURE__*/ l.string({ maxGraphemes: 15000 }), 105 + ), 106 + status: /*#__PURE__*/ l.optional( 107 + /*#__PURE__*/ l.string<{ 108 + knownValues: [ 109 + 'buzz.bookhive.defs#finished', 110 + 'buzz.bookhive.defs#reading', 111 + 'buzz.bookhive.defs#wantToRead', 112 + 'buzz.bookhive.defs#abandoned', 113 + ] 114 + }>(), 115 + ), 116 + authors: /*#__PURE__*/ l.string({ maxLength: 2048, minLength: 1 }), 117 + createdAt: /*#__PURE__*/ l.string({ format: 'datetime' }), 118 + startedAt: /*#__PURE__*/ l.optional( 119 + /*#__PURE__*/ l.string({ format: 'datetime' }), 120 + ), 121 + finishedAt: /*#__PURE__*/ l.optional( 122 + /*#__PURE__*/ l.string({ format: 'datetime' }), 123 + ), 124 + hiveBookUri: /*#__PURE__*/ l.optional(/*#__PURE__*/ l.string()), 125 + identifiers: /*#__PURE__*/ l.optional( 126 + /*#__PURE__*/ l.ref<BookhiveDefs.BookIdentifiers>( 127 + (() => BookhiveDefs.bookIdentifiers) as any, 128 + ), 129 + ), 130 + bookProgress: /*#__PURE__*/ l.optional( 131 + /*#__PURE__*/ l.ref<BookhiveDefs.BookProgress>( 132 + (() => BookhiveDefs.bookProgress) as any, 133 + ), 134 + ), 135 + }), 136 + ) 137 + 138 + export { main } 139 + 140 + export const $type = $nsid 141 + export const $isTypeOf = /*#__PURE__*/ main.isTypeOf.bind(main) 142 + export const $build = /*#__PURE__*/ main.build.bind(main) 143 + export const $assert = /*#__PURE__*/ main.assert.bind(main) 144 + export const $check = /*#__PURE__*/ main.check.bind(main) 145 + export const $cast = /*#__PURE__*/ main.cast.bind(main) 146 + export const $ifMatches = /*#__PURE__*/ main.ifMatches.bind(main) 147 + export const $matches = /*#__PURE__*/ main.matches.bind(main) 148 + export const $parse = /*#__PURE__*/ main.parse.bind(main) 149 + export const $safeParse = /*#__PURE__*/ main.safeParse.bind(main) 150 + export const $validate = /*#__PURE__*/ main.validate.bind(main) 151 + export const $safeValidate = /*#__PURE__*/ main.safeValidate.bind(main)
+6
src/lib/lexicons/buzz/bookhive/book.ts
··· 1 + /* 2 + * THIS FILE WAS GENERATED BY "@atproto/lex". DO NOT EDIT. 3 + */ 4 + 5 + export * from './book.defs.js' 6 + export { main as default } from './book.defs.js'
+469
src/lib/lexicons/buzz/bookhive/defs.defs.ts
··· 1 + /* 2 + * THIS FILE WAS GENERATED BY "@atproto/lex". DO NOT EDIT. 3 + */ 4 + 5 + import { l } from '@atproto/lex' 6 + import * as RepoStrongRef from '../../com/atproto/repo/strongRef.defs.js' 7 + 8 + const $nsid = 'buzz.bookhive.defs' 9 + 10 + export { $nsid } 11 + 12 + type Review = { 13 + $type?: 'buzz.bookhive.defs#review' 14 + 15 + /** 16 + * The DID of the user who made the review 17 + */ 18 + did: string 19 + 20 + /** 21 + * The number of stars given to the book 22 + */ 23 + stars?: number 24 + 25 + /** 26 + * The handle of the user who made the review 27 + */ 28 + handle: string 29 + 30 + /** 31 + * The review content 32 + */ 33 + review: string 34 + 35 + /** 36 + * The date the review was created 37 + */ 38 + createdAt: l.DatetimeString 39 + } 40 + 41 + export type { Review } 42 + 43 + const review = /*#__PURE__*/ l.typedObject<Review>( 44 + $nsid, 45 + 'review', 46 + /*#__PURE__*/ l.object({ 47 + did: /*#__PURE__*/ l.string(), 48 + stars: /*#__PURE__*/ l.optional(/*#__PURE__*/ l.integer()), 49 + handle: /*#__PURE__*/ l.string(), 50 + review: /*#__PURE__*/ l.string(), 51 + createdAt: /*#__PURE__*/ l.string({ format: 'datetime' }), 52 + }), 53 + ) 54 + 55 + export { review } 56 + 57 + type Comment = { 58 + $type?: 'buzz.bookhive.defs#comment' 59 + 60 + /** 61 + * The DID of the user who made the comment 62 + */ 63 + did: string 64 + book: RepoStrongRef.Main 65 + 66 + /** 67 + * The handle of the user who made the comment 68 + */ 69 + handle: string 70 + parent: RepoStrongRef.Main 71 + 72 + /** 73 + * The content of the comment. 74 + */ 75 + comment: string 76 + 77 + /** 78 + * Client-declared timestamp when this comment was originally created. 79 + */ 80 + createdAt: l.DatetimeString 81 + } 82 + 83 + export type { Comment } 84 + 85 + const comment = /*#__PURE__*/ l.typedObject<Comment>( 86 + $nsid, 87 + 'comment', 88 + /*#__PURE__*/ l.object({ 89 + did: /*#__PURE__*/ l.string(), 90 + book: /*#__PURE__*/ l.ref<RepoStrongRef.Main>( 91 + (() => RepoStrongRef.main) as any, 92 + ), 93 + handle: /*#__PURE__*/ l.string(), 94 + parent: /*#__PURE__*/ l.ref<RepoStrongRef.Main>( 95 + (() => RepoStrongRef.main) as any, 96 + ), 97 + comment: /*#__PURE__*/ l.string({ maxLength: 100000, maxGraphemes: 10000 }), 98 + createdAt: /*#__PURE__*/ l.string({ format: 'datetime' }), 99 + }), 100 + ) 101 + 102 + export { comment } 103 + 104 + type Profile = { 105 + $type?: 'buzz.bookhive.defs#profile' 106 + avatar?: string 107 + handle: string 108 + reviews: number 109 + booksRead: number 110 + description?: string 111 + displayName: string 112 + 113 + /** 114 + * Whether the authed user is following this profile 115 + */ 116 + isFollowing?: boolean 117 + } 118 + 119 + export type { Profile } 120 + 121 + const profile = /*#__PURE__*/ l.typedObject<Profile>( 122 + $nsid, 123 + 'profile', 124 + /*#__PURE__*/ l.object({ 125 + avatar: /*#__PURE__*/ l.optional(/*#__PURE__*/ l.string()), 126 + handle: /*#__PURE__*/ l.string(), 127 + reviews: /*#__PURE__*/ l.integer({ minimum: 0 }), 128 + booksRead: /*#__PURE__*/ l.integer({ minimum: 0 }), 129 + description: /*#__PURE__*/ l.optional(/*#__PURE__*/ l.string()), 130 + displayName: /*#__PURE__*/ l.string(), 131 + isFollowing: /*#__PURE__*/ l.optional(/*#__PURE__*/ l.boolean()), 132 + }), 133 + ) 134 + 135 + export { profile } 136 + 137 + /** User is currently reading the book */ 138 + type Reading = 'buzz.bookhive.defs#reading' 139 + 140 + export type { Reading } 141 + 142 + /** User is currently reading the book */ 143 + const reading = /*#__PURE__*/ l.token($nsid, 'reading') 144 + 145 + export { reading } 146 + 147 + type Activity = { 148 + $type?: 'buzz.bookhive.defs#activity' 149 + type: 'review' | 'rated' | 'started' | 'finished' | l.UnknownString 150 + 151 + /** 152 + * The title of the book 153 + */ 154 + title: string 155 + 156 + /** 157 + * The hive id of the book 158 + */ 159 + hiveId: string 160 + 161 + /** 162 + * The DID of the user who added the book 163 + */ 164 + userDid: string 165 + createdAt: l.DatetimeString 166 + 167 + /** 168 + * The handle of the user who added the book 169 + */ 170 + userHandle: string 171 + } 172 + 173 + export type { Activity } 174 + 175 + const activity = /*#__PURE__*/ l.typedObject<Activity>( 176 + $nsid, 177 + 'activity', 178 + /*#__PURE__*/ l.object({ 179 + type: /*#__PURE__*/ l.string<{ 180 + knownValues: ['review', 'rated', 'started', 'finished'] 181 + }>(), 182 + title: /*#__PURE__*/ l.string(), 183 + hiveId: /*#__PURE__*/ l.string(), 184 + userDid: /*#__PURE__*/ l.string(), 185 + createdAt: /*#__PURE__*/ l.string({ format: 'datetime' }), 186 + userHandle: /*#__PURE__*/ l.string(), 187 + }), 188 + ) 189 + 190 + export { activity } 191 + 192 + /** User has finished reading the book */ 193 + type Finished = 'buzz.bookhive.defs#finished' 194 + 195 + export type { Finished } 196 + 197 + /** User has finished reading the book */ 198 + const finished = /*#__PURE__*/ l.token($nsid, 'finished') 199 + 200 + export { finished } 201 + 202 + type UserBook = { 203 + $type?: 'buzz.bookhive.defs#userBook' 204 + 205 + /** 206 + * Cover image of the book 207 + */ 208 + cover?: string 209 + 210 + /** 211 + * Whether the user owns this book 212 + */ 213 + owned?: boolean 214 + 215 + /** 216 + * Number of stars given to the book (1-10) which will be mapped to 1-5 stars 217 + */ 218 + stars?: number 219 + 220 + /** 221 + * The title of the book 222 + */ 223 + title: string 224 + 225 + /** 226 + * The book's hive id, used to correlate user's books with the hive 227 + */ 228 + hiveId: string 229 + 230 + /** 231 + * Average rating (0-1000) 232 + */ 233 + rating?: number 234 + 235 + /** 236 + * The book's review 237 + */ 238 + review?: string 239 + status?: 240 + | 'buzz.bookhive.defs#finished' 241 + | 'buzz.bookhive.defs#reading' 242 + | 'buzz.bookhive.defs#wantToRead' 243 + | 'buzz.bookhive.defs#abandoned' 244 + | l.UnknownString 245 + 246 + /** 247 + * The authors of the book (tab separated) 248 + */ 249 + authors: string 250 + 251 + /** 252 + * The DID of the user who added the book 253 + */ 254 + userDid: string 255 + createdAt: l.DatetimeString 256 + 257 + /** 258 + * The date the user started reading the book 259 + */ 260 + startedAt?: l.DatetimeString 261 + 262 + /** 263 + * Cover image of the book 264 + */ 265 + thumbnail: string 266 + 267 + /** 268 + * The date the user finished reading the book 269 + */ 270 + finishedAt?: l.DatetimeString 271 + 272 + /** 273 + * The handle of the user who added the book 274 + */ 275 + userHandle?: string 276 + 277 + /** 278 + * Book description/summary 279 + */ 280 + description?: string 281 + 282 + /** 283 + * External identifiers for the book 284 + */ 285 + identifiers?: BookIdentifiers 286 + 287 + /** 288 + * Progress tracking information for the book 289 + */ 290 + bookProgress?: BookProgress 291 + } 292 + 293 + export type { UserBook } 294 + 295 + const userBook = /*#__PURE__*/ l.typedObject<UserBook>( 296 + $nsid, 297 + 'userBook', 298 + /*#__PURE__*/ l.object({ 299 + cover: /*#__PURE__*/ l.optional(/*#__PURE__*/ l.string()), 300 + owned: /*#__PURE__*/ l.optional(/*#__PURE__*/ l.boolean()), 301 + stars: /*#__PURE__*/ l.optional( 302 + /*#__PURE__*/ l.integer({ maximum: 10, minimum: 1 }), 303 + ), 304 + title: /*#__PURE__*/ l.string({ maxLength: 512, minLength: 1 }), 305 + hiveId: /*#__PURE__*/ l.string(), 306 + rating: /*#__PURE__*/ l.optional( 307 + /*#__PURE__*/ l.integer({ maximum: 1000, minimum: 0 }), 308 + ), 309 + review: /*#__PURE__*/ l.optional( 310 + /*#__PURE__*/ l.string({ maxGraphemes: 15000 }), 311 + ), 312 + status: /*#__PURE__*/ l.optional( 313 + /*#__PURE__*/ l.string<{ 314 + knownValues: [ 315 + 'buzz.bookhive.defs#finished', 316 + 'buzz.bookhive.defs#reading', 317 + 'buzz.bookhive.defs#wantToRead', 318 + 'buzz.bookhive.defs#abandoned', 319 + ] 320 + }>(), 321 + ), 322 + authors: /*#__PURE__*/ l.string({ maxLength: 2048, minLength: 1 }), 323 + userDid: /*#__PURE__*/ l.string(), 324 + createdAt: /*#__PURE__*/ l.string({ format: 'datetime' }), 325 + startedAt: /*#__PURE__*/ l.optional( 326 + /*#__PURE__*/ l.string({ format: 'datetime' }), 327 + ), 328 + thumbnail: /*#__PURE__*/ l.string(), 329 + finishedAt: /*#__PURE__*/ l.optional( 330 + /*#__PURE__*/ l.string({ format: 'datetime' }), 331 + ), 332 + userHandle: /*#__PURE__*/ l.optional(/*#__PURE__*/ l.string()), 333 + description: /*#__PURE__*/ l.optional( 334 + /*#__PURE__*/ l.string({ maxLength: 5000 }), 335 + ), 336 + identifiers: /*#__PURE__*/ l.optional( 337 + /*#__PURE__*/ l.ref<BookIdentifiers>((() => bookIdentifiers) as any), 338 + ), 339 + bookProgress: /*#__PURE__*/ l.optional( 340 + /*#__PURE__*/ l.ref<BookProgress>((() => bookProgress) as any), 341 + ), 342 + }), 343 + ) 344 + 345 + export { userBook } 346 + 347 + /** User has abandoned the book */ 348 + type Abandoned = 'buzz.bookhive.defs#abandoned' 349 + 350 + export type { Abandoned } 351 + 352 + /** User has abandoned the book */ 353 + const abandoned = /*#__PURE__*/ l.token($nsid, 'abandoned') 354 + 355 + export { abandoned } 356 + 357 + /** User wants to read the book */ 358 + type WantToRead = 'buzz.bookhive.defs#wantToRead' 359 + 360 + export type { WantToRead } 361 + 362 + /** User wants to read the book */ 363 + const wantToRead = /*#__PURE__*/ l.token($nsid, 'wantToRead') 364 + 365 + export { wantToRead } 366 + 367 + /** Reading progress tracking data */ 368 + type BookProgress = { 369 + $type?: 'buzz.bookhive.defs#bookProgress' 370 + 371 + /** 372 + * How far through the book the reader is (0-100) 373 + */ 374 + percent?: number 375 + 376 + /** 377 + * When the progress was last updated 378 + */ 379 + updatedAt: l.DatetimeString 380 + 381 + /** 382 + * Total number of pages in the book 383 + */ 384 + totalPages?: number 385 + 386 + /** 387 + * Current page the user is on 388 + */ 389 + currentPage?: number 390 + 391 + /** 392 + * Total number of chapters in the book 393 + */ 394 + totalChapters?: number 395 + 396 + /** 397 + * Current chapter the user is on 398 + */ 399 + currentChapter?: number 400 + } 401 + 402 + export type { BookProgress } 403 + 404 + /** Reading progress tracking data */ 405 + const bookProgress = /*#__PURE__*/ l.typedObject<BookProgress>( 406 + $nsid, 407 + 'bookProgress', 408 + /*#__PURE__*/ l.object({ 409 + percent: /*#__PURE__*/ l.optional( 410 + /*#__PURE__*/ l.integer({ maximum: 100, minimum: 0 }), 411 + ), 412 + updatedAt: /*#__PURE__*/ l.string({ format: 'datetime' }), 413 + totalPages: /*#__PURE__*/ l.optional( 414 + /*#__PURE__*/ l.integer({ minimum: 1 }), 415 + ), 416 + currentPage: /*#__PURE__*/ l.optional( 417 + /*#__PURE__*/ l.integer({ minimum: 1 }), 418 + ), 419 + totalChapters: /*#__PURE__*/ l.optional( 420 + /*#__PURE__*/ l.integer({ minimum: 1 }), 421 + ), 422 + currentChapter: /*#__PURE__*/ l.optional( 423 + /*#__PURE__*/ l.integer({ minimum: 1 }), 424 + ), 425 + }), 426 + ) 427 + 428 + export { bookProgress } 429 + 430 + /** External identifiers for a book */ 431 + type BookIdentifiers = { 432 + $type?: 'buzz.bookhive.defs#bookIdentifiers' 433 + 434 + /** 435 + * BookHive's internal ID 436 + */ 437 + hiveId?: string 438 + 439 + /** 440 + * 10-digit ISBN 441 + */ 442 + isbn10?: string 443 + 444 + /** 445 + * 13-digit ISBN 446 + */ 447 + isbn13?: string 448 + 449 + /** 450 + * Goodreads book ID 451 + */ 452 + goodreadsId?: string 453 + } 454 + 455 + export type { BookIdentifiers } 456 + 457 + /** External identifiers for a book */ 458 + const bookIdentifiers = /*#__PURE__*/ l.typedObject<BookIdentifiers>( 459 + $nsid, 460 + 'bookIdentifiers', 461 + /*#__PURE__*/ l.object({ 462 + hiveId: /*#__PURE__*/ l.optional(/*#__PURE__*/ l.string()), 463 + isbn10: /*#__PURE__*/ l.optional(/*#__PURE__*/ l.string()), 464 + isbn13: /*#__PURE__*/ l.optional(/*#__PURE__*/ l.string()), 465 + goodreadsId: /*#__PURE__*/ l.optional(/*#__PURE__*/ l.string()), 466 + }), 467 + ) 468 + 469 + export { bookIdentifiers }
+5
src/lib/lexicons/buzz/bookhive/defs.ts
··· 1 + /* 2 + * THIS FILE WAS GENERATED BY "@atproto/lex". DO NOT EDIT. 3 + */ 4 + 5 + export * from './defs.defs.js'
+1
src/lib/lexicons/com/atproto.ts
··· 3 3 */ 4 4 5 5 export * as label from './atproto/label.js' 6 + export * as repo from './atproto/repo.js'
+5
src/lib/lexicons/com/atproto/repo.ts
··· 1 + /* 2 + * THIS FILE WAS GENERATED BY "@atproto/lex". DO NOT EDIT. 3 + */ 4 + 5 + export * as strongRef from './repo/strongRef.js'
+41
src/lib/lexicons/com/atproto/repo/strongRef.defs.ts
··· 1 + /* 2 + * THIS FILE WAS GENERATED BY "@atproto/lex". DO NOT EDIT. 3 + */ 4 + 5 + import { l } from '@atproto/lex' 6 + 7 + const $nsid = 'com.atproto.repo.strongRef' 8 + 9 + export { $nsid } 10 + 11 + type Main = { 12 + $type?: 'com.atproto.repo.strongRef' 13 + cid: l.CidString 14 + uri: l.AtUriString 15 + } 16 + 17 + export type { Main } 18 + 19 + const main = /*#__PURE__*/ l.typedObject<Main>( 20 + $nsid, 21 + 'main', 22 + /*#__PURE__*/ l.object({ 23 + cid: /*#__PURE__*/ l.string({ format: 'cid' }), 24 + uri: /*#__PURE__*/ l.string({ format: 'at-uri' }), 25 + }), 26 + ) 27 + 28 + export { main } 29 + 30 + export const $type = $nsid 31 + export const $isTypeOf = /*#__PURE__*/ main.isTypeOf.bind(main) 32 + export const $build = /*#__PURE__*/ main.build.bind(main) 33 + export const $assert = /*#__PURE__*/ main.assert.bind(main) 34 + export const $check = /*#__PURE__*/ main.check.bind(main) 35 + export const $cast = /*#__PURE__*/ main.cast.bind(main) 36 + export const $ifMatches = /*#__PURE__*/ main.ifMatches.bind(main) 37 + export const $matches = /*#__PURE__*/ main.matches.bind(main) 38 + export const $parse = /*#__PURE__*/ main.parse.bind(main) 39 + export const $safeParse = /*#__PURE__*/ main.safeParse.bind(main) 40 + export const $validate = /*#__PURE__*/ main.validate.bind(main) 41 + export const $safeValidate = /*#__PURE__*/ main.safeValidate.bind(main)
+6
src/lib/lexicons/com/atproto/repo/strongRef.ts
··· 1 + /* 2 + * THIS FILE WAS GENERATED BY "@atproto/lex". DO NOT EDIT. 3 + */ 4 + 5 + export * from './strongRef.defs.js' 6 + export { main as default } from './strongRef.defs.js'
+5
src/lib/lexicons/fm.ts
··· 1 + /* 2 + * THIS FILE WAS GENERATED BY "@atproto/lex". DO NOT EDIT. 3 + */ 4 + 5 + export * as teal from './fm/teal.js'
+5
src/lib/lexicons/fm/teal.ts
··· 1 + /* 2 + * THIS FILE WAS GENERATED BY "@atproto/lex". DO NOT EDIT. 3 + */ 4 + 5 + export * as alpha from './teal/alpha.js'
+6
src/lib/lexicons/fm/teal/alpha.ts
··· 1 + /* 2 + * THIS FILE WAS GENERATED BY "@atproto/lex". DO NOT EDIT. 3 + */ 4 + 5 + export * as actor from './alpha/actor.js' 6 + export * as feed from './alpha/feed.js'
+5
src/lib/lexicons/fm/teal/alpha/actor.ts
··· 1 + /* 2 + * THIS FILE WAS GENERATED BY "@atproto/lex". DO NOT EDIT. 3 + */ 4 + 5 + export * as status from './actor/status.js'
+58
src/lib/lexicons/fm/teal/alpha/actor/status.defs.ts
··· 1 + /* 2 + * THIS FILE WAS GENERATED BY "@atproto/lex". DO NOT EDIT. 3 + */ 4 + 5 + import { l } from '@atproto/lex' 6 + import * as FeedDefs from '../feed/defs.defs.js' 7 + 8 + const $nsid = 'fm.teal.alpha.actor.status' 9 + 10 + export { $nsid } 11 + 12 + /** This lexicon is in a not officially released state. It is subject to change. | A declaration of the status of the actor. Only one can be shown at a time. If there are multiple, the latest record should be picked and earlier records should be deleted or tombstoned. */ 13 + type Main = { 14 + $type: 'fm.teal.alpha.actor.status' 15 + 16 + /** 17 + * The RFC 3339 formatted time of when the item was recorded 18 + */ 19 + time: l.DatetimeString 20 + 21 + /** 22 + * The RFC 3339 formatted time of the expiry time of the item. If unavailable, default to 10 minutes past the start time. 23 + */ 24 + expiry?: l.DatetimeString 25 + item: FeedDefs.PlayView 26 + } 27 + 28 + export type { Main } 29 + 30 + /** This lexicon is in a not officially released state. It is subject to change. | A declaration of the status of the actor. Only one can be shown at a time. If there are multiple, the latest record should be picked and earlier records should be deleted or tombstoned. */ 31 + const main = /*#__PURE__*/ l.record<'literal:self', Main>( 32 + 'literal:self', 33 + $nsid, 34 + /*#__PURE__*/ l.object({ 35 + time: /*#__PURE__*/ l.string({ format: 'datetime' }), 36 + expiry: /*#__PURE__*/ l.optional( 37 + /*#__PURE__*/ l.string({ format: 'datetime' }), 38 + ), 39 + item: /*#__PURE__*/ l.ref<FeedDefs.PlayView>( 40 + (() => FeedDefs.playView) as any, 41 + ), 42 + }), 43 + ) 44 + 45 + export { main } 46 + 47 + export const $type = $nsid 48 + export const $isTypeOf = /*#__PURE__*/ main.isTypeOf.bind(main) 49 + export const $build = /*#__PURE__*/ main.build.bind(main) 50 + export const $assert = /*#__PURE__*/ main.assert.bind(main) 51 + export const $check = /*#__PURE__*/ main.check.bind(main) 52 + export const $cast = /*#__PURE__*/ main.cast.bind(main) 53 + export const $ifMatches = /*#__PURE__*/ main.ifMatches.bind(main) 54 + export const $matches = /*#__PURE__*/ main.matches.bind(main) 55 + export const $parse = /*#__PURE__*/ main.parse.bind(main) 56 + export const $safeParse = /*#__PURE__*/ main.safeParse.bind(main) 57 + export const $validate = /*#__PURE__*/ main.validate.bind(main) 58 + export const $safeValidate = /*#__PURE__*/ main.safeValidate.bind(main)
+6
src/lib/lexicons/fm/teal/alpha/actor/status.ts
··· 1 + /* 2 + * THIS FILE WAS GENERATED BY "@atproto/lex". DO NOT EDIT. 3 + */ 4 + 5 + export * from './status.defs.js' 6 + export { main as default } from './status.defs.js'
+5
src/lib/lexicons/fm/teal/alpha/feed.ts
··· 1 + /* 2 + * THIS FILE WAS GENERATED BY "@atproto/lex". DO NOT EDIT. 3 + */ 4 + 5 + export * as defs from './feed/defs.js'
+147
src/lib/lexicons/fm/teal/alpha/feed/defs.defs.ts
··· 1 + /* 2 + * THIS FILE WAS GENERATED BY "@atproto/lex". DO NOT EDIT. 3 + */ 4 + 5 + import { l } from '@atproto/lex' 6 + 7 + const $nsid = 'fm.teal.alpha.feed.defs' 8 + 9 + export { $nsid } 10 + 11 + type PlayView = { 12 + $type?: 'fm.teal.alpha.feed.defs#playView' 13 + 14 + /** 15 + * The name of the track 16 + */ 17 + trackName: string 18 + 19 + /** 20 + * The MusicBrainz ID URI of the track, formatted as mbid:<uuid> 21 + */ 22 + trackMbId?: l.UriString 23 + 24 + /** 25 + * The MusicBrainz recording ID URI of the track, formatted as mbid:<uuid> 26 + */ 27 + recordingMbId?: l.UriString 28 + 29 + /** 30 + * The length of the track in seconds 31 + */ 32 + duration?: number 33 + 34 + /** 35 + * Array of artists in order of original appearance. 36 + */ 37 + artists: Artist[] 38 + 39 + /** 40 + * The name of the release/album 41 + */ 42 + releaseName?: string 43 + 44 + /** 45 + * The MusicBrainz release ID URI, formatted as mbid:<uuid> 46 + */ 47 + releaseMbId?: l.UriString 48 + 49 + /** 50 + * The ISRC code associated with the recording 51 + */ 52 + isrc?: string 53 + 54 + /** 55 + * The URL associated with this track 56 + */ 57 + originUrl?: string 58 + 59 + /** 60 + * The base domain of the music service. e.g. music.apple.com, tidal.com, spotify.com. Defaults to 'local' if not provided. 61 + */ 62 + musicServiceBaseDomain?: string 63 + 64 + /** 65 + * A user-agent style string specifying the user agent. e.g. tealtracker/0.0.1b (Linux; Android 13; SM-A715F). Defaults to 'manual/unknown' if not provided. 66 + */ 67 + submissionClientAgent?: string 68 + 69 + /** 70 + * The unix timestamp of when the track was played 71 + */ 72 + playedTime?: l.DatetimeString 73 + } 74 + 75 + export type { PlayView } 76 + 77 + const playView = /*#__PURE__*/ l.typedObject<PlayView>( 78 + $nsid, 79 + 'playView', 80 + /*#__PURE__*/ l.object({ 81 + trackName: /*#__PURE__*/ l.string({ 82 + minLength: 1, 83 + maxLength: 256, 84 + maxGraphemes: 2560, 85 + }), 86 + trackMbId: /*#__PURE__*/ l.optional( 87 + /*#__PURE__*/ l.string({ format: 'uri' }), 88 + ), 89 + recordingMbId: /*#__PURE__*/ l.optional( 90 + /*#__PURE__*/ l.string({ format: 'uri' }), 91 + ), 92 + duration: /*#__PURE__*/ l.optional(/*#__PURE__*/ l.integer()), 93 + artists: /*#__PURE__*/ l.array( 94 + /*#__PURE__*/ l.ref<Artist>((() => artist) as any), 95 + ), 96 + releaseName: /*#__PURE__*/ l.optional( 97 + /*#__PURE__*/ l.string({ maxLength: 256, maxGraphemes: 2560 }), 98 + ), 99 + releaseMbId: /*#__PURE__*/ l.optional( 100 + /*#__PURE__*/ l.string({ format: 'uri' }), 101 + ), 102 + isrc: /*#__PURE__*/ l.optional(/*#__PURE__*/ l.string()), 103 + originUrl: /*#__PURE__*/ l.optional(/*#__PURE__*/ l.string()), 104 + musicServiceBaseDomain: /*#__PURE__*/ l.optional(/*#__PURE__*/ l.string()), 105 + submissionClientAgent: /*#__PURE__*/ l.optional( 106 + /*#__PURE__*/ l.string({ maxLength: 256, maxGraphemes: 2560 }), 107 + ), 108 + playedTime: /*#__PURE__*/ l.optional( 109 + /*#__PURE__*/ l.string({ format: 'datetime' }), 110 + ), 111 + }), 112 + ) 113 + 114 + export { playView } 115 + 116 + type Artist = { 117 + $type?: 'fm.teal.alpha.feed.defs#artist' 118 + 119 + /** 120 + * The name of the artist 121 + */ 122 + artistName: string 123 + 124 + /** 125 + * The MusicBrainz artist ID URI, formatted as mbid:<uuid> 126 + */ 127 + artistMbId?: l.UriString 128 + } 129 + 130 + export type { Artist } 131 + 132 + const artist = /*#__PURE__*/ l.typedObject<Artist>( 133 + $nsid, 134 + 'artist', 135 + /*#__PURE__*/ l.object({ 136 + artistName: /*#__PURE__*/ l.string({ 137 + minLength: 1, 138 + maxLength: 256, 139 + maxGraphemes: 2560, 140 + }), 141 + artistMbId: /*#__PURE__*/ l.optional( 142 + /*#__PURE__*/ l.string({ format: 'uri' }), 143 + ), 144 + }), 145 + ) 146 + 147 + export { artist }
+5
src/lib/lexicons/fm/teal/alpha/feed/defs.ts
··· 1 + /* 2 + * THIS FILE WAS GENERATED BY "@atproto/lex". DO NOT EDIT. 3 + */ 4 + 5 + export * from './defs.defs.js'
+2
src/lib/lexicons/index.ts
··· 2 2 * THIS FILE WAS GENERATED BY "@atproto/lex". DO NOT EDIT. 3 3 */ 4 4 5 + export * as buzz from './buzz.js' 5 6 export * as com from './com.js' 7 + export * as fm from './fm.js' 6 8 export * as site from './site.js'
+1 -1
src/lib/lexicons/site/standard/theme.ts
··· 2 2 * THIS FILE WAS GENERATED BY "@atproto/lex". DO NOT EDIT. 3 3 */ 4 4 5 - export * as color from './theme/color.js' 6 5 export * as basic from './theme/basic.js' 6 + export * as color from './theme/color.js'
+15
src/lib/types.ts
··· 11 11 href: string; 12 12 image: string | null; 13 13 }; 14 + 15 + export type CurrentlyReading = { 16 + title: string; 17 + authors: string; 18 + bookUrl: string; 19 + cover: string | null; 20 + }; 21 + 22 + export type NowPlaying = { 23 + trackName: string; 24 + artists: string; 25 + releaseName: string | null; 26 + url: string | null; 27 + coverArt: string | null; 28 + };
+165 -4
src/routes/+page.server.ts
··· 1 1 import { env } from '$env/dynamic/private'; 2 2 import { Client, getBlobCidString, type AtIdentifierString, type AtUriString } from '@atproto/lex'; 3 - import { site } from '$lib/lexicons'; 3 + import { buzz, fm, site } from '$lib/lexicons'; 4 4 import type { Main as PublicationRecord } from '$lib/lexicons/site/standard/publication'; 5 - import type { Publication, Sponsor } from '$lib/types'; 5 + import type { Main as BookRecord } from '$lib/lexicons/buzz/bookhive/book'; 6 + import type { CurrentlyReading, NowPlaying, Publication, Sponsor } from '$lib/types'; 6 7 import type { PageServerLoad } from './$types'; 7 8 import { SQLiteCache } from 'sqlite-cache'; 8 9 9 10 const SPONSORS_LOGIN = 'fatfingers23'; 10 11 const CACHE_TTL_MS = 86_400_000; // 1 day 12 + const LIVE_CACHE_TTL_MS = 60_000; // 1 min — "now playing" changes often 11 13 12 14 const cache = new SQLiteCache({ path: './cache.db', ttl: CACHE_TTL_MS }); 15 + const liveCache = new SQLiteCache({ path: './cache-live.db', ttl: LIVE_CACHE_TTL_MS }); 13 16 14 17 type SponsorNode = { 15 18 login: string; ··· 133 136 } 134 137 } 135 138 139 + async function fetchCurrentlyReading(): Promise<CurrentlyReading | null> { 140 + const cached = cache.get('currentlyReading') as { value: CurrentlyReading | null } | null; 141 + if (cached) { 142 + return cached.value; 143 + } 144 + 145 + const did = env.ATPROTO_DID; 146 + const pds = env.ATPROTO_PDS; 147 + if (!did || !pds) { 148 + console.warn('ATPROTO_DID / ATPROTO_PDS are not set — Currently reading will be hidden.'); 149 + return null; 150 + } 151 + 152 + try { 153 + const client = new Client(pds); 154 + const res = await client.list(buzz.bookhive.book, { 155 + repo: did as AtIdentifierString, 156 + limit: 100 157 + }); 158 + const records = res.records as ReadonlyArray<{ value: BookRecord; uri: AtUriString }>; 159 + const reading = records.find((r) => r.value.status === 'buzz.bookhive.defs#reading'); 160 + 161 + let currentlyReading: CurrentlyReading | null = null; 162 + if (reading) { 163 + const v = reading.value; 164 + currentlyReading = { 165 + title: v.title, 166 + bookUrl: `https://bookhive.buzz/books/${v.hiveId}`, 167 + authors: v.authors.split('\t').join(', '), 168 + cover: v.cover 169 + ? `${pds}/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(did)}&cid=${getBlobCidString(v.cover)}` 170 + : null 171 + }; 172 + } 173 + 174 + cache.set('currentlyReading', { value: currentlyReading }); 175 + return currentlyReading; 176 + } catch (error) { 177 + console.warn('Failed to fetch currently reading book:', error); 178 + return null; 179 + } 180 + } 181 + 182 + // MusicBrainz requires an identifying User-Agent or it will block requests. 183 + const MB_USER_AGENT = 'baileys-website/1.0 (https://tangled.org/pds.dad/my-website)'; 184 + 185 + /// Got to go isrc to a mbid to get cover art 186 + async function resolveCoverArt( 187 + fetch: typeof globalThis.fetch, 188 + opts: { isrc?: string | null; releaseMbId?: string | null; releaseName?: string | null } 189 + ): Promise<string | null> { 190 + const candidates: string[] = []; 191 + 192 + if (opts.isrc) { 193 + try { 194 + const isrcRes = await fetch( 195 + `https://musicbrainz.org/ws/2/isrc/${encodeURIComponent(opts.isrc)}?fmt=json`, 196 + { headers: { 'User-Agent': MB_USER_AGENT } } 197 + ); 198 + const recordingId = isrcRes.ok 199 + ? ((await isrcRes.json()) as { recordings?: { id: string }[] }).recordings?.[0]?.id 200 + : undefined; 201 + 202 + if (recordingId) { 203 + const relRes = await fetch( 204 + `https://musicbrainz.org/ws/2/release?recording=${recordingId}&fmt=json`, 205 + { headers: { 'User-Agent': MB_USER_AGENT } } 206 + ); 207 + if (relRes.ok) { 208 + const releases = 209 + ((await relRes.json()) as { releases?: { id: string; title?: string }[] }).releases ?? 210 + []; 211 + // An ISRC's recording often appears on many releases (singles, 212 + // comps, regional editions). Prefer the one whose title matches 213 + // what's playing. 214 + const wanted = opts.releaseName?.toLowerCase(); 215 + const seen = new Set<string>(); 216 + for (const r of [ 217 + ...releases.filter((r) => wanted && r.title?.toLowerCase() === wanted), 218 + ...releases 219 + ]) { 220 + if (!seen.has(r.id)) { 221 + seen.add(r.id); 222 + candidates.push(r.id); 223 + } 224 + } 225 + } 226 + } 227 + } catch (error) { 228 + console.warn('MusicBrainz ISRC lookup failed:', error); 229 + } 230 + } 231 + 232 + // Fallback: release MBID supplied directly by the status record. 233 + if (opts.releaseMbId) candidates.push(opts.releaseMbId); 234 + 235 + // Not every release has art in the CAA — probe candidates and return the 236 + // first that resolves to an actual image. 237 + for (const mbid of candidates.slice(0, 5)) { 238 + const url = `https://coverartarchive.org/release/${mbid}/front-500`; 239 + try { 240 + const res = await fetch(url, { method: 'HEAD', redirect: 'follow' }); 241 + if (res.ok) return url; 242 + } catch { 243 + // ignore and try the next candidate 244 + } 245 + } 246 + 247 + return null; 248 + } 249 + 250 + async function fetchNowPlaying(fetch: typeof globalThis.fetch): Promise<NowPlaying | null> { 251 + const cached = liveCache.get('nowPlaying') as { value: NowPlaying | null } | null; 252 + if (cached) { 253 + return cached.value; 254 + } 255 + 256 + const did = env.ATPROTO_DID; 257 + const pds = env.ATPROTO_PDS; 258 + if (!did || !pds) { 259 + console.warn('ATPROTO_DID / ATPROTO_PDS are not set — Now playing will be hidden.'); 260 + return null; 261 + } 262 + 263 + try { 264 + const client = new Client(pds); 265 + const res = await client.get(fm.teal.alpha.actor.status, { 266 + repo: did as AtIdentifierString 267 + }); 268 + const item = res.value.item; 269 + 270 + const coverArt = await resolveCoverArt(fetch, { 271 + isrc: item.isrc ?? null, 272 + releaseMbId: item.releaseMbId?.replace(/^mbid:/, '') ?? null, 273 + releaseName: item.releaseName ?? null 274 + }); 275 + 276 + const nowPlaying: NowPlaying = { 277 + trackName: item.trackName, 278 + artists: item.artists.map((a) => a.artistName).join(', '), 279 + releaseName: item.releaseName ?? null, 280 + url: item.originUrl ?? null, 281 + coverArt 282 + }; 283 + 284 + liveCache.set('nowPlaying', { value: nowPlaying }); 285 + return nowPlaying; 286 + } catch (error) { 287 + console.warn('Failed to fetch now playing status:', error); 288 + return null; 289 + } 290 + } 291 + 136 292 export const load: PageServerLoad = async ({ request, fetch }) => { 137 293 const host = request.headers.get('host') ?? ''; 138 294 const pumpkin = host.includes('pds.dad'); 139 295 140 - const [sponsors, publications] = await Promise.all([fetchSponsors(fetch), fetchPublications()]); 296 + const [sponsors, publications, currentlyReading, nowPlaying] = await Promise.all([ 297 + fetchSponsors(fetch), 298 + fetchPublications(), 299 + fetchCurrentlyReading(), 300 + fetchNowPlaying(fetch) 301 + ]); 141 302 142 - return { pumpkin, sponsors, publications }; 303 + return { pumpkin, sponsors, publications, currentlyReading, nowPlaying }; 143 304 };
+3 -1
src/routes/+page.svelte
··· 3 3 import Hero from '$lib/components/Hero.svelte'; 4 4 import OpenSourceProjects from '$lib/components/OpenSourceProjects.svelte'; 5 5 import Writing from '$lib/components/Writing.svelte'; 6 + import FreeTime from '$lib/components/FreeTime.svelte'; 6 7 import Services from '$lib/components/Services.svelte'; 7 8 import Sponsors from '$lib/components/Sponsors.svelte'; 8 9 import Team from '$lib/components/Team.svelte'; ··· 10 11 import type { PageProps } from './$types'; 11 12 12 13 let { data }: PageProps = $props(); 13 - const { pumpkin, sponsors, publications } = data; 14 + const { pumpkin, sponsors, publications, currentlyReading, nowPlaying } = data; 14 15 </script> 15 16 16 17 <svelte:head> ··· 26 27 <Hero {pumpkin} /> 27 28 <OpenSourceProjects /> 28 29 <Writing {publications} /> 30 + <FreeTime {currentlyReading} {nowPlaying} /> 29 31 <!-- <Services /> --> 30 32 <Sponsors {sponsors} /> 31 33 <!-- <Team /> -->