interactive intro to open social at-me.zzstoatzz.io

refactor: migrate from Rust backend to pure client-side JS with Vite

- Remove Rust/Axum backend (Cargo.toml, src/*.rs, Dockerfile, fly.toml)
- Add Vite build system with MPA configuration
- Split monolithic HTML into modular ES modules:
- src/landing/: Landing page with atmosphere visualization
- src/view/: Main app with ATProto visualization, filters, MST viewer
- Add tangled CI for wisp.place deployment
- Fix avatar centering and app circle spacing
- Simplify domain validation (trust reversed ATProto namespaces)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

+4641 -12677
+6 -3
.gitignore
··· 1 - /target 2 - sandbox/ 3 - .env 1 + node_modules/ 2 + dist/ 3 + .env 4 + .env.local 5 + *.log 6 + *.tmp
+4 -11
.pre-commit-config.yaml
··· 1 1 repos: 2 2 - repo: local 3 3 hooks: 4 - - id: cargo-fmt 5 - name: cargo fmt 6 - entry: cargo fmt -- 4 + - id: vite-build 5 + name: vite build 6 + entry: bun run build 7 7 language: system 8 - types: [rust] 9 - pass_filenames: true 10 - 11 - - id: cargo-clippy 12 - name: cargo clippy 13 - entry: cargo clippy -- -D warnings 14 - language: system 15 - types: [rust] 8 + types: [javascript] 16 9 pass_filenames: false
-25
.tangled/workflows/check.yaml
··· 1 - engine: nixery 2 - 3 - when: 4 - - event: ["push", "pull_request"] 5 - branch: ["main"] 6 - 7 - dependencies: 8 - nixpkgs: 9 - - rustc 10 - - cargo 11 - - rustfmt 12 - - clippy 13 - - gcc 14 - - openssl.dev 15 - - pkg-config 16 - 17 - steps: 18 - - name: check formatting 19 - command: | 20 - cargo fmt --check 21 - 22 - - name: run clippy 23 - command: | 24 - export PKG_CONFIG_PATH=$(echo /nix/store/*openssl*-dev/lib/pkgconfig) 25 - cargo clippy -- -D warnings
+27
.tangled/workflows/ci.yaml
··· 1 + when: 2 + - event: ["push"] 3 + branch: main 4 + 5 + engine: nixery 6 + 7 + dependencies: 8 + nixpkgs: 9 + - bun 10 + - curl 11 + 12 + environment: 13 + WISP_DID: "did:plc:xbtmt2zjwlrfegqvch7fboei" 14 + WISP_SITE_NAME: "at-me" 15 + 16 + steps: 17 + - name: install dependencies 18 + command: bun install 19 + 20 + - name: build 21 + command: bun run build 22 + 23 + - name: deploy to wisp 24 + command: | 25 + curl -sSL https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-x86_64-linux -o wisp-cli 26 + chmod +x wisp-cli 27 + ./wisp-cli deploy "$WISP_DID" --path ./dist --site "$WISP_SITE_NAME" --password "$WISP_APP_PASSWORD"
+21 -3
CLAUDE.md
··· 2 2 3 3 ATProto PDS visualization tool - shows your identity, apps, and data collections. 4 4 5 + ## Tech Stack 6 + 7 + - Pure client-side JavaScript (no backend) 8 + - Vite for development and building 9 + - Direct ATProto API calls (PDS, PLC directory, Bluesky AppView) 10 + - Jetstream WebSocket for firehose streaming 11 + - Client-side MST (Merkle Search Tree) visualization 12 + 13 + ## Development 14 + 15 + - Use `bun run dev` for local development with hot reloading 16 + - `bun run build` to build for production 17 + - `bun run preview` to preview the production build 18 + 19 + ## Key files 20 + 21 + - `index.html` - Landing page with handle search and atmosphere visualization 22 + - `view.html` - Main visualization page showing PDS data, MST visualization 23 + - `public/` - Static assets (favicon, OG image, OAuth metadata) 24 + - `public/oauth-client-metadata.json` - OAuth client configuration for public clients 25 + 5 26 ## Critical reminders 6 27 7 - - Use `just dev` for local development - cargo watch provides hot reloading for src/ and static/ changes, no need to manually restart 8 - - Python: use `uv` or `uvx`, NEVER pip ([uv docs](https://docs.astral.sh/uv/)) 9 - - ATProto client: always pass PDS URL at initialization to avoid JWT issues 10 28 - Never deploy without explicit user request
-4255
Cargo.lock
··· 1 - # This file is automatically @generated by Cargo. 2 - # It is not intended for manual editing. 3 - version = 4 4 - 5 - [[package]] 6 - name = "actix-codec" 7 - version = "0.5.2" 8 - source = "registry+https://github.com/rust-lang/crates.io-index" 9 - checksum = "5f7b0a21988c1bf877cf4759ef5ddaac04c1c9fe808c9142ecb78ba97d97a28a" 10 - dependencies = [ 11 - "bitflags", 12 - "bytes", 13 - "futures-core", 14 - "futures-sink", 15 - "memchr", 16 - "pin-project-lite", 17 - "tokio", 18 - "tokio-util", 19 - "tracing", 20 - ] 21 - 22 - [[package]] 23 - name = "actix-files" 24 - version = "0.6.8" 25 - source = "registry+https://github.com/rust-lang/crates.io-index" 26 - checksum = "6c0d87f10d70e2948ad40e8edea79c8e77c6c66e0250a4c1f09b690465199576" 27 - dependencies = [ 28 - "actix-http", 29 - "actix-service", 30 - "actix-utils", 31 - "actix-web", 32 - "bitflags", 33 - "bytes", 34 - "derive_more 2.0.1", 35 - "futures-core", 36 - "http-range", 37 - "log", 38 - "mime", 39 - "mime_guess", 40 - "percent-encoding", 41 - "pin-project-lite", 42 - "v_htmlescape", 43 - ] 44 - 45 - [[package]] 46 - name = "actix-http" 47 - version = "3.11.2" 48 - source = "registry+https://github.com/rust-lang/crates.io-index" 49 - checksum = "7926860314cbe2fb5d1f13731e387ab43bd32bca224e82e6e2db85de0a3dba49" 50 - dependencies = [ 51 - "actix-codec", 52 - "actix-rt", 53 - "actix-service", 54 - "actix-utils", 55 - "base64 0.22.1", 56 - "bitflags", 57 - "brotli", 58 - "bytes", 59 - "bytestring", 60 - "derive_more 2.0.1", 61 - "encoding_rs", 62 - "flate2", 63 - "foldhash", 64 - "futures-core", 65 - "h2 0.3.27", 66 - "http 0.2.12", 67 - "httparse", 68 - "httpdate", 69 - "itoa", 70 - "language-tags", 71 - "local-channel", 72 - "mime", 73 - "percent-encoding", 74 - "pin-project-lite", 75 - "rand 0.9.2", 76 - "sha1", 77 - "smallvec", 78 - "tokio", 79 - "tokio-util", 80 - "tracing", 81 - "zstd", 82 - ] 83 - 84 - [[package]] 85 - name = "actix-macros" 86 - version = "0.2.4" 87 - source = "registry+https://github.com/rust-lang/crates.io-index" 88 - checksum = "e01ed3140b2f8d422c68afa1ed2e85d996ea619c988ac834d255db32138655cb" 89 - dependencies = [ 90 - "quote", 91 - "syn 2.0.106", 92 - ] 93 - 94 - [[package]] 95 - name = "actix-router" 96 - version = "0.5.3" 97 - source = "registry+https://github.com/rust-lang/crates.io-index" 98 - checksum = "13d324164c51f63867b57e73ba5936ea151b8a41a1d23d1031eeb9f70d0236f8" 99 - dependencies = [ 100 - "bytestring", 101 - "cfg-if", 102 - "http 0.2.12", 103 - "regex", 104 - "regex-lite", 105 - "serde", 106 - "tracing", 107 - ] 108 - 109 - [[package]] 110 - name = "actix-rt" 111 - version = "2.11.0" 112 - source = "registry+https://github.com/rust-lang/crates.io-index" 113 - checksum = "92589714878ca59a7626ea19734f0e07a6a875197eec751bb5d3f99e64998c63" 114 - dependencies = [ 115 - "futures-core", 116 - "tokio", 117 - ] 118 - 119 - [[package]] 120 - name = "actix-server" 121 - version = "2.6.0" 122 - source = "registry+https://github.com/rust-lang/crates.io-index" 123 - checksum = "a65064ea4a457eaf07f2fba30b4c695bf43b721790e9530d26cb6f9019ff7502" 124 - dependencies = [ 125 - "actix-rt", 126 - "actix-service", 127 - "actix-utils", 128 - "futures-core", 129 - "futures-util", 130 - "mio", 131 - "socket2 0.5.10", 132 - "tokio", 133 - "tracing", 134 - ] 135 - 136 - [[package]] 137 - name = "actix-service" 138 - version = "2.0.3" 139 - source = "registry+https://github.com/rust-lang/crates.io-index" 140 - checksum = "9e46f36bf0e5af44bdc4bdb36fbbd421aa98c79a9bce724e1edeb3894e10dc7f" 141 - dependencies = [ 142 - "futures-core", 143 - "pin-project-lite", 144 - ] 145 - 146 - [[package]] 147 - name = "actix-session" 148 - version = "0.10.1" 149 - source = "registry+https://github.com/rust-lang/crates.io-index" 150 - checksum = "efe6976a74f34f1b6d07a6c05aadc0ed0359304a7781c367fa5b4029418db08f" 151 - dependencies = [ 152 - "actix-service", 153 - "actix-utils", 154 - "actix-web", 155 - "anyhow", 156 - "derive_more 1.0.0", 157 - "rand 0.8.5", 158 - "serde", 159 - "serde_json", 160 - "tracing", 161 - ] 162 - 163 - [[package]] 164 - name = "actix-utils" 165 - version = "3.0.1" 166 - source = "registry+https://github.com/rust-lang/crates.io-index" 167 - checksum = "88a1dcdff1466e3c2488e1cb5c36a71822750ad43839937f85d2f4d9f8b705d8" 168 - dependencies = [ 169 - "local-waker", 170 - "pin-project-lite", 171 - ] 172 - 173 - [[package]] 174 - name = "actix-web" 175 - version = "4.11.0" 176 - source = "registry+https://github.com/rust-lang/crates.io-index" 177 - checksum = "a597b77b5c6d6a1e1097fddde329a83665e25c5437c696a3a9a4aa514a614dea" 178 - dependencies = [ 179 - "actix-codec", 180 - "actix-http", 181 - "actix-macros", 182 - "actix-router", 183 - "actix-rt", 184 - "actix-server", 185 - "actix-service", 186 - "actix-utils", 187 - "actix-web-codegen", 188 - "bytes", 189 - "bytestring", 190 - "cfg-if", 191 - "cookie", 192 - "derive_more 2.0.1", 193 - "encoding_rs", 194 - "foldhash", 195 - "futures-core", 196 - "futures-util", 197 - "impl-more", 198 - "itoa", 199 - "language-tags", 200 - "log", 201 - "mime", 202 - "once_cell", 203 - "pin-project-lite", 204 - "regex", 205 - "regex-lite", 206 - "serde", 207 - "serde_json", 208 - "serde_urlencoded", 209 - "smallvec", 210 - "socket2 0.5.10", 211 - "time", 212 - "tracing", 213 - "url", 214 - ] 215 - 216 - [[package]] 217 - name = "actix-web-codegen" 218 - version = "4.3.0" 219 - source = "registry+https://github.com/rust-lang/crates.io-index" 220 - checksum = "f591380e2e68490b5dfaf1dd1aa0ebe78d84ba7067078512b4ea6e4492d622b8" 221 - dependencies = [ 222 - "actix-router", 223 - "proc-macro2", 224 - "quote", 225 - "syn 2.0.106", 226 - ] 227 - 228 - [[package]] 229 - name = "addr2line" 230 - version = "0.25.1" 231 - source = "registry+https://github.com/rust-lang/crates.io-index" 232 - checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" 233 - dependencies = [ 234 - "gimli", 235 - ] 236 - 237 - [[package]] 238 - name = "adler2" 239 - version = "2.0.1" 240 - source = "registry+https://github.com/rust-lang/crates.io-index" 241 - checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" 242 - 243 - [[package]] 244 - name = "aead" 245 - version = "0.5.2" 246 - source = "registry+https://github.com/rust-lang/crates.io-index" 247 - checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" 248 - dependencies = [ 249 - "crypto-common", 250 - "generic-array", 251 - ] 252 - 253 - [[package]] 254 - name = "aes" 255 - version = "0.8.4" 256 - source = "registry+https://github.com/rust-lang/crates.io-index" 257 - checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" 258 - dependencies = [ 259 - "cfg-if", 260 - "cipher", 261 - "cpufeatures", 262 - ] 263 - 264 - [[package]] 265 - name = "aes-gcm" 266 - version = "0.10.3" 267 - source = "registry+https://github.com/rust-lang/crates.io-index" 268 - checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" 269 - dependencies = [ 270 - "aead", 271 - "aes", 272 - "cipher", 273 - "ctr", 274 - "ghash", 275 - "subtle", 276 - ] 277 - 278 - [[package]] 279 - name = "ahash" 280 - version = "0.8.12" 281 - source = "registry+https://github.com/rust-lang/crates.io-index" 282 - checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" 283 - dependencies = [ 284 - "cfg-if", 285 - "once_cell", 286 - "version_check", 287 - "zerocopy", 288 - ] 289 - 290 - [[package]] 291 - name = "aho-corasick" 292 - version = "1.1.3" 293 - source = "registry+https://github.com/rust-lang/crates.io-index" 294 - checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" 295 - dependencies = [ 296 - "memchr", 297 - ] 298 - 299 - [[package]] 300 - name = "alloc-no-stdlib" 301 - version = "2.0.4" 302 - source = "registry+https://github.com/rust-lang/crates.io-index" 303 - checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" 304 - 305 - [[package]] 306 - name = "alloc-stdlib" 307 - version = "0.2.2" 308 - source = "registry+https://github.com/rust-lang/crates.io-index" 309 - checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" 310 - dependencies = [ 311 - "alloc-no-stdlib", 312 - ] 313 - 314 - [[package]] 315 - name = "allocator-api2" 316 - version = "0.2.21" 317 - source = "registry+https://github.com/rust-lang/crates.io-index" 318 - checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" 319 - 320 - [[package]] 321 - name = "android_system_properties" 322 - version = "0.1.5" 323 - source = "registry+https://github.com/rust-lang/crates.io-index" 324 - checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" 325 - dependencies = [ 326 - "libc", 327 - ] 328 - 329 - [[package]] 330 - name = "anstream" 331 - version = "0.6.21" 332 - source = "registry+https://github.com/rust-lang/crates.io-index" 333 - checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" 334 - dependencies = [ 335 - "anstyle", 336 - "anstyle-parse", 337 - "anstyle-query", 338 - "anstyle-wincon", 339 - "colorchoice", 340 - "is_terminal_polyfill", 341 - "utf8parse", 342 - ] 343 - 344 - [[package]] 345 - name = "anstyle" 346 - version = "1.0.13" 347 - source = "registry+https://github.com/rust-lang/crates.io-index" 348 - checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" 349 - 350 - [[package]] 351 - name = "anstyle-parse" 352 - version = "0.2.7" 353 - source = "registry+https://github.com/rust-lang/crates.io-index" 354 - checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" 355 - dependencies = [ 356 - "utf8parse", 357 - ] 358 - 359 - [[package]] 360 - name = "anstyle-query" 361 - version = "1.1.4" 362 - source = "registry+https://github.com/rust-lang/crates.io-index" 363 - checksum = "9e231f6134f61b71076a3eab506c379d4f36122f2af15a9ff04415ea4c3339e2" 364 - dependencies = [ 365 - "windows-sys 0.60.2", 366 - ] 367 - 368 - [[package]] 369 - name = "anstyle-wincon" 370 - version = "3.0.10" 371 - source = "registry+https://github.com/rust-lang/crates.io-index" 372 - checksum = "3e0633414522a32ffaac8ac6cc8f748e090c5717661fddeea04219e2344f5f2a" 373 - dependencies = [ 374 - "anstyle", 375 - "once_cell_polyfill", 376 - "windows-sys 0.60.2", 377 - ] 378 - 379 - [[package]] 380 - name = "anyhow" 381 - version = "1.0.100" 382 - source = "registry+https://github.com/rust-lang/crates.io-index" 383 - checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" 384 - 385 - [[package]] 386 - name = "async-compression" 387 - version = "0.4.32" 388 - source = "registry+https://github.com/rust-lang/crates.io-index" 389 - checksum = "5a89bce6054c720275ac2432fbba080a66a2106a44a1b804553930ca6909f4e0" 390 - dependencies = [ 391 - "compression-codecs", 392 - "compression-core", 393 - "futures-core", 394 - "pin-project-lite", 395 - "tokio", 396 - ] 397 - 398 - [[package]] 399 - name = "async-lock" 400 - version = "3.4.1" 401 - source = "registry+https://github.com/rust-lang/crates.io-index" 402 - checksum = "5fd03604047cee9b6ce9de9f70c6cd540a0520c813cbd49bae61f33ab80ed1dc" 403 - dependencies = [ 404 - "event-listener", 405 - "event-listener-strategy", 406 - "pin-project-lite", 407 - ] 408 - 409 - [[package]] 410 - name = "async-stream" 411 - version = "0.3.6" 412 - source = "registry+https://github.com/rust-lang/crates.io-index" 413 - checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" 414 - dependencies = [ 415 - "async-stream-impl", 416 - "futures-core", 417 - "pin-project-lite", 418 - ] 419 - 420 - [[package]] 421 - name = "async-stream-impl" 422 - version = "0.3.6" 423 - source = "registry+https://github.com/rust-lang/crates.io-index" 424 - checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" 425 - dependencies = [ 426 - "proc-macro2", 427 - "quote", 428 - "syn 2.0.106", 429 - ] 430 - 431 - [[package]] 432 - name = "async-trait" 433 - version = "0.1.89" 434 - source = "registry+https://github.com/rust-lang/crates.io-index" 435 - checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" 436 - dependencies = [ 437 - "proc-macro2", 438 - "quote", 439 - "syn 2.0.106", 440 - ] 441 - 442 - [[package]] 443 - name = "at-me" 444 - version = "0.1.0" 445 - dependencies = [ 446 - "actix-files", 447 - "actix-session", 448 - "actix-web", 449 - "anyhow", 450 - "async-stream", 451 - "async-trait", 452 - "atrium-api", 453 - "atrium-common", 454 - "atrium-identity", 455 - "atrium-oauth", 456 - "chrono", 457 - "dashmap", 458 - "env_logger", 459 - "futures-util", 460 - "hickory-resolver", 461 - "log", 462 - "once_cell", 463 - "reqwest", 464 - "rocketman", 465 - "serde", 466 - "serde_json", 467 - "tokio", 468 - "urlencoding", 469 - ] 470 - 471 - [[package]] 472 - name = "atomic-waker" 473 - version = "1.1.2" 474 - source = "registry+https://github.com/rust-lang/crates.io-index" 475 - checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" 476 - 477 - [[package]] 478 - name = "atrium-api" 479 - version = "0.25.6" 480 - source = "registry+https://github.com/rust-lang/crates.io-index" 481 - checksum = "ef9d5e9352fd27d99383ae1db2b6a6aa239e683a7e750e8d73a73996d82b1fd2" 482 - dependencies = [ 483 - "atrium-common", 484 - "atrium-xrpc", 485 - "chrono", 486 - "http 1.3.1", 487 - "ipld-core", 488 - "langtag", 489 - "regex", 490 - "serde", 491 - "serde_bytes", 492 - "serde_json", 493 - "thiserror", 494 - "tokio", 495 - "trait-variant", 496 - ] 497 - 498 - [[package]] 499 - name = "atrium-common" 500 - version = "0.1.2" 501 - source = "registry+https://github.com/rust-lang/crates.io-index" 502 - checksum = "9ed5610654043faa396a5a15afac0ac646d76aebe45aebd7cef4f8b96b0ab7f4" 503 - dependencies = [ 504 - "dashmap", 505 - "lru", 506 - "moka", 507 - "thiserror", 508 - "tokio", 509 - "trait-variant", 510 - "web-time", 511 - ] 512 - 513 - [[package]] 514 - name = "atrium-identity" 515 - version = "0.1.7" 516 - source = "registry+https://github.com/rust-lang/crates.io-index" 517 - checksum = "4d3a56cd2bb695308cb078be80a46a7a2caf79203eda27803f13ee6a38b98378" 518 - dependencies = [ 519 - "atrium-api", 520 - "atrium-common", 521 - "atrium-xrpc", 522 - "serde", 523 - "serde_html_form", 524 - "serde_json", 525 - "thiserror", 526 - "trait-variant", 527 - ] 528 - 529 - [[package]] 530 - name = "atrium-oauth" 531 - version = "0.1.5" 532 - source = "registry+https://github.com/rust-lang/crates.io-index" 533 - checksum = "6969f29ff0a4100d05d3988f012504385ff1d7c9db82410e26830ded8da621fb" 534 - dependencies = [ 535 - "atrium-api", 536 - "atrium-common", 537 - "atrium-identity", 538 - "atrium-xrpc", 539 - "base64 0.22.1", 540 - "chrono", 541 - "dashmap", 542 - "ecdsa", 543 - "elliptic-curve", 544 - "jose-jwa", 545 - "jose-jwk", 546 - "p256", 547 - "rand 0.8.5", 548 - "reqwest", 549 - "serde", 550 - "serde_html_form", 551 - "serde_json", 552 - "sha2", 553 - "thiserror", 554 - "tokio", 555 - "trait-variant", 556 - ] 557 - 558 - [[package]] 559 - name = "atrium-xrpc" 560 - version = "0.12.3" 561 - source = "registry+https://github.com/rust-lang/crates.io-index" 562 - checksum = "0216ad50ce34e9ff982e171c3659e65dedaa2ed5ac2994524debdc9a9647ffa8" 563 - dependencies = [ 564 - "http 1.3.1", 565 - "serde", 566 - "serde_html_form", 567 - "serde_json", 568 - "thiserror", 569 - "trait-variant", 570 - ] 571 - 572 - [[package]] 573 - name = "autocfg" 574 - version = "1.5.0" 575 - source = "registry+https://github.com/rust-lang/crates.io-index" 576 - checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" 577 - 578 - [[package]] 579 - name = "backtrace" 580 - version = "0.3.76" 581 - source = "registry+https://github.com/rust-lang/crates.io-index" 582 - checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" 583 - dependencies = [ 584 - "addr2line", 585 - "cfg-if", 586 - "libc", 587 - "miniz_oxide", 588 - "object", 589 - "rustc-demangle", 590 - "windows-link 0.2.0", 591 - ] 592 - 593 - [[package]] 594 - name = "base-x" 595 - version = "0.2.11" 596 - source = "registry+https://github.com/rust-lang/crates.io-index" 597 - checksum = "4cbbc9d0964165b47557570cce6c952866c2678457aca742aafc9fb771d30270" 598 - 599 - [[package]] 600 - name = "base16ct" 601 - version = "0.2.0" 602 - source = "registry+https://github.com/rust-lang/crates.io-index" 603 - checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" 604 - 605 - [[package]] 606 - name = "base256emoji" 607 - version = "1.0.2" 608 - source = "registry+https://github.com/rust-lang/crates.io-index" 609 - checksum = "b5e9430d9a245a77c92176e649af6e275f20839a48389859d1661e9a128d077c" 610 - dependencies = [ 611 - "const-str", 612 - "match-lookup", 613 - ] 614 - 615 - [[package]] 616 - name = "base64" 617 - version = "0.20.0" 618 - source = "registry+https://github.com/rust-lang/crates.io-index" 619 - checksum = "0ea22880d78093b0cbe17c89f64a7d457941e65759157ec6cb31a31d652b05e5" 620 - 621 - [[package]] 622 - name = "base64" 623 - version = "0.21.7" 624 - source = "registry+https://github.com/rust-lang/crates.io-index" 625 - checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" 626 - 627 - [[package]] 628 - name = "base64" 629 - version = "0.22.1" 630 - source = "registry+https://github.com/rust-lang/crates.io-index" 631 - checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" 632 - 633 - [[package]] 634 - name = "base64ct" 635 - version = "1.8.0" 636 - source = "registry+https://github.com/rust-lang/crates.io-index" 637 - checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" 638 - 639 - [[package]] 640 - name = "bitflags" 641 - version = "2.9.4" 642 - source = "registry+https://github.com/rust-lang/crates.io-index" 643 - checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394" 644 - 645 - [[package]] 646 - name = "block-buffer" 647 - version = "0.10.4" 648 - source = "registry+https://github.com/rust-lang/crates.io-index" 649 - checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" 650 - dependencies = [ 651 - "generic-array", 652 - ] 653 - 654 - [[package]] 655 - name = "bon" 656 - version = "3.8.1" 657 - source = "registry+https://github.com/rust-lang/crates.io-index" 658 - checksum = "ebeb9aaf9329dff6ceb65c689ca3db33dbf15f324909c60e4e5eef5701ce31b1" 659 - dependencies = [ 660 - "bon-macros", 661 - "rustversion", 662 - ] 663 - 664 - [[package]] 665 - name = "bon-macros" 666 - version = "3.8.1" 667 - source = "registry+https://github.com/rust-lang/crates.io-index" 668 - checksum = "77e9d642a7e3a318e37c2c9427b5a6a48aa1ad55dcd986f3034ab2239045a645" 669 - dependencies = [ 670 - "darling 0.21.3", 671 - "ident_case", 672 - "prettyplease", 673 - "proc-macro2", 674 - "quote", 675 - "rustversion", 676 - "syn 2.0.106", 677 - ] 678 - 679 - [[package]] 680 - name = "brotli" 681 - version = "8.0.2" 682 - source = "registry+https://github.com/rust-lang/crates.io-index" 683 - checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" 684 - dependencies = [ 685 - "alloc-no-stdlib", 686 - "alloc-stdlib", 687 - "brotli-decompressor", 688 - ] 689 - 690 - [[package]] 691 - name = "brotli-decompressor" 692 - version = "5.0.0" 693 - source = "registry+https://github.com/rust-lang/crates.io-index" 694 - checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" 695 - dependencies = [ 696 - "alloc-no-stdlib", 697 - "alloc-stdlib", 698 - ] 699 - 700 - [[package]] 701 - name = "bumpalo" 702 - version = "3.19.0" 703 - source = "registry+https://github.com/rust-lang/crates.io-index" 704 - checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" 705 - 706 - [[package]] 707 - name = "byteorder" 708 - version = "1.5.0" 709 - source = "registry+https://github.com/rust-lang/crates.io-index" 710 - checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" 711 - 712 - [[package]] 713 - name = "bytes" 714 - version = "1.10.1" 715 - source = "registry+https://github.com/rust-lang/crates.io-index" 716 - checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" 717 - 718 - [[package]] 719 - name = "bytestring" 720 - version = "1.5.0" 721 - source = "registry+https://github.com/rust-lang/crates.io-index" 722 - checksum = "113b4343b5f6617e7ad401ced8de3cc8b012e73a594347c307b90db3e9271289" 723 - dependencies = [ 724 - "bytes", 725 - ] 726 - 727 - [[package]] 728 - name = "cc" 729 - version = "1.2.40" 730 - source = "registry+https://github.com/rust-lang/crates.io-index" 731 - checksum = "e1d05d92f4b1fd76aad469d46cdd858ca761576082cd37df81416691e50199fb" 732 - dependencies = [ 733 - "find-msvc-tools", 734 - "jobserver", 735 - "libc", 736 - "shlex", 737 - ] 738 - 739 - [[package]] 740 - name = "cfg-if" 741 - version = "1.0.3" 742 - source = "registry+https://github.com/rust-lang/crates.io-index" 743 - checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" 744 - 745 - [[package]] 746 - name = "chrono" 747 - version = "0.4.42" 748 - source = "registry+https://github.com/rust-lang/crates.io-index" 749 - checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" 750 - dependencies = [ 751 - "iana-time-zone", 752 - "js-sys", 753 - "num-traits", 754 - "serde", 755 - "wasm-bindgen", 756 - "windows-link 0.2.0", 757 - ] 758 - 759 - [[package]] 760 - name = "cid" 761 - version = "0.11.1" 762 - source = "registry+https://github.com/rust-lang/crates.io-index" 763 - checksum = "3147d8272e8fa0ccd29ce51194dd98f79ddfb8191ba9e3409884e751798acf3a" 764 - dependencies = [ 765 - "core2", 766 - "multibase", 767 - "multihash", 768 - "serde", 769 - "serde_bytes", 770 - "unsigned-varint", 771 - ] 772 - 773 - [[package]] 774 - name = "cipher" 775 - version = "0.4.4" 776 - source = "registry+https://github.com/rust-lang/crates.io-index" 777 - checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" 778 - dependencies = [ 779 - "crypto-common", 780 - "inout", 781 - ] 782 - 783 - [[package]] 784 - name = "colorchoice" 785 - version = "1.0.4" 786 - source = "registry+https://github.com/rust-lang/crates.io-index" 787 - checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" 788 - 789 - [[package]] 790 - name = "compression-codecs" 791 - version = "0.4.31" 792 - source = "registry+https://github.com/rust-lang/crates.io-index" 793 - checksum = "ef8a506ec4b81c460798f572caead636d57d3d7e940f998160f52bd254bf2d23" 794 - dependencies = [ 795 - "compression-core", 796 - "flate2", 797 - "memchr", 798 - ] 799 - 800 - [[package]] 801 - name = "compression-core" 802 - version = "0.4.29" 803 - source = "registry+https://github.com/rust-lang/crates.io-index" 804 - checksum = "e47641d3deaf41fb1538ac1f54735925e275eaf3bf4d55c81b137fba797e5cbb" 805 - 806 - [[package]] 807 - name = "concurrent-queue" 808 - version = "2.5.0" 809 - source = "registry+https://github.com/rust-lang/crates.io-index" 810 - checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" 811 - dependencies = [ 812 - "crossbeam-utils", 813 - ] 814 - 815 - [[package]] 816 - name = "const-oid" 817 - version = "0.9.6" 818 - source = "registry+https://github.com/rust-lang/crates.io-index" 819 - checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" 820 - 821 - [[package]] 822 - name = "const-str" 823 - version = "0.4.3" 824 - source = "registry+https://github.com/rust-lang/crates.io-index" 825 - checksum = "2f421161cb492475f1661ddc9815a745a1c894592070661180fdec3d4872e9c3" 826 - 827 - [[package]] 828 - name = "cookie" 829 - version = "0.16.2" 830 - source = "registry+https://github.com/rust-lang/crates.io-index" 831 - checksum = "e859cd57d0710d9e06c381b550c06e76992472a8c6d527aecd2fc673dcc231fb" 832 - dependencies = [ 833 - "aes-gcm", 834 - "base64 0.20.0", 835 - "hkdf", 836 - "hmac", 837 - "percent-encoding", 838 - "rand 0.8.5", 839 - "sha2", 840 - "subtle", 841 - "time", 842 - "version_check", 843 - ] 844 - 845 - [[package]] 846 - name = "core-foundation" 847 - version = "0.9.4" 848 - source = "registry+https://github.com/rust-lang/crates.io-index" 849 - checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" 850 - dependencies = [ 851 - "core-foundation-sys", 852 - "libc", 853 - ] 854 - 855 - [[package]] 856 - name = "core-foundation-sys" 857 - version = "0.8.7" 858 - source = "registry+https://github.com/rust-lang/crates.io-index" 859 - checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" 860 - 861 - [[package]] 862 - name = "core2" 863 - version = "0.4.0" 864 - source = "registry+https://github.com/rust-lang/crates.io-index" 865 - checksum = "b49ba7ef1ad6107f8824dbe97de947cbaac53c44e7f9756a1fba0d37c1eec505" 866 - dependencies = [ 867 - "memchr", 868 - ] 869 - 870 - [[package]] 871 - name = "cpufeatures" 872 - version = "0.2.17" 873 - source = "registry+https://github.com/rust-lang/crates.io-index" 874 - checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" 875 - dependencies = [ 876 - "libc", 877 - ] 878 - 879 - [[package]] 880 - name = "crc32fast" 881 - version = "1.5.0" 882 - source = "registry+https://github.com/rust-lang/crates.io-index" 883 - checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" 884 - dependencies = [ 885 - "cfg-if", 886 - ] 887 - 888 - [[package]] 889 - name = "crossbeam-channel" 890 - version = "0.5.15" 891 - source = "registry+https://github.com/rust-lang/crates.io-index" 892 - checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" 893 - dependencies = [ 894 - "crossbeam-utils", 895 - ] 896 - 897 - [[package]] 898 - name = "crossbeam-epoch" 899 - version = "0.9.18" 900 - source = "registry+https://github.com/rust-lang/crates.io-index" 901 - checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" 902 - dependencies = [ 903 - "crossbeam-utils", 904 - ] 905 - 906 - [[package]] 907 - name = "crossbeam-utils" 908 - version = "0.8.21" 909 - source = "registry+https://github.com/rust-lang/crates.io-index" 910 - checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" 911 - 912 - [[package]] 913 - name = "crypto-bigint" 914 - version = "0.5.5" 915 - source = "registry+https://github.com/rust-lang/crates.io-index" 916 - checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" 917 - dependencies = [ 918 - "generic-array", 919 - "rand_core 0.6.4", 920 - "subtle", 921 - "zeroize", 922 - ] 923 - 924 - [[package]] 925 - name = "crypto-common" 926 - version = "0.1.6" 927 - source = "registry+https://github.com/rust-lang/crates.io-index" 928 - checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" 929 - dependencies = [ 930 - "generic-array", 931 - "rand_core 0.6.4", 932 - "typenum", 933 - ] 934 - 935 - [[package]] 936 - name = "ctr" 937 - version = "0.9.2" 938 - source = "registry+https://github.com/rust-lang/crates.io-index" 939 - checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" 940 - dependencies = [ 941 - "cipher", 942 - ] 943 - 944 - [[package]] 945 - name = "darling" 946 - version = "0.20.11" 947 - source = "registry+https://github.com/rust-lang/crates.io-index" 948 - checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" 949 - dependencies = [ 950 - "darling_core 0.20.11", 951 - "darling_macro 0.20.11", 952 - ] 953 - 954 - [[package]] 955 - name = "darling" 956 - version = "0.21.3" 957 - source = "registry+https://github.com/rust-lang/crates.io-index" 958 - checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" 959 - dependencies = [ 960 - "darling_core 0.21.3", 961 - "darling_macro 0.21.3", 962 - ] 963 - 964 - [[package]] 965 - name = "darling_core" 966 - version = "0.20.11" 967 - source = "registry+https://github.com/rust-lang/crates.io-index" 968 - checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" 969 - dependencies = [ 970 - "fnv", 971 - "ident_case", 972 - "proc-macro2", 973 - "quote", 974 - "strsim", 975 - "syn 2.0.106", 976 - ] 977 - 978 - [[package]] 979 - name = "darling_core" 980 - version = "0.21.3" 981 - source = "registry+https://github.com/rust-lang/crates.io-index" 982 - checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" 983 - dependencies = [ 984 - "fnv", 985 - "ident_case", 986 - "proc-macro2", 987 - "quote", 988 - "strsim", 989 - "syn 2.0.106", 990 - ] 991 - 992 - [[package]] 993 - name = "darling_macro" 994 - version = "0.20.11" 995 - source = "registry+https://github.com/rust-lang/crates.io-index" 996 - checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" 997 - dependencies = [ 998 - "darling_core 0.20.11", 999 - "quote", 1000 - "syn 2.0.106", 1001 - ] 1002 - 1003 - [[package]] 1004 - name = "darling_macro" 1005 - version = "0.21.3" 1006 - source = "registry+https://github.com/rust-lang/crates.io-index" 1007 - checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" 1008 - dependencies = [ 1009 - "darling_core 0.21.3", 1010 - "quote", 1011 - "syn 2.0.106", 1012 - ] 1013 - 1014 - [[package]] 1015 - name = "dashmap" 1016 - version = "6.1.0" 1017 - source = "registry+https://github.com/rust-lang/crates.io-index" 1018 - checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" 1019 - dependencies = [ 1020 - "cfg-if", 1021 - "crossbeam-utils", 1022 - "hashbrown 0.14.5", 1023 - "lock_api", 1024 - "once_cell", 1025 - "parking_lot_core", 1026 - ] 1027 - 1028 - [[package]] 1029 - name = "data-encoding" 1030 - version = "2.9.0" 1031 - source = "registry+https://github.com/rust-lang/crates.io-index" 1032 - checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" 1033 - 1034 - [[package]] 1035 - name = "data-encoding-macro" 1036 - version = "0.1.18" 1037 - source = "registry+https://github.com/rust-lang/crates.io-index" 1038 - checksum = "47ce6c96ea0102f01122a185683611bd5ac8d99e62bc59dd12e6bda344ee673d" 1039 - dependencies = [ 1040 - "data-encoding", 1041 - "data-encoding-macro-internal", 1042 - ] 1043 - 1044 - [[package]] 1045 - name = "data-encoding-macro-internal" 1046 - version = "0.1.16" 1047 - source = "registry+https://github.com/rust-lang/crates.io-index" 1048 - checksum = "8d162beedaa69905488a8da94f5ac3edb4dd4788b732fadb7bd120b2625c1976" 1049 - dependencies = [ 1050 - "data-encoding", 1051 - "syn 2.0.106", 1052 - ] 1053 - 1054 - [[package]] 1055 - name = "der" 1056 - version = "0.7.10" 1057 - source = "registry+https://github.com/rust-lang/crates.io-index" 1058 - checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" 1059 - dependencies = [ 1060 - "const-oid", 1061 - "zeroize", 1062 - ] 1063 - 1064 - [[package]] 1065 - name = "deranged" 1066 - version = "0.5.4" 1067 - source = "registry+https://github.com/rust-lang/crates.io-index" 1068 - checksum = "a41953f86f8a05768a6cda24def994fd2f424b04ec5c719cf89989779f199071" 1069 - dependencies = [ 1070 - "powerfmt", 1071 - ] 1072 - 1073 - [[package]] 1074 - name = "derive_builder" 1075 - version = "0.20.2" 1076 - source = "registry+https://github.com/rust-lang/crates.io-index" 1077 - checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" 1078 - dependencies = [ 1079 - "derive_builder_macro", 1080 - ] 1081 - 1082 - [[package]] 1083 - name = "derive_builder_core" 1084 - version = "0.20.2" 1085 - source = "registry+https://github.com/rust-lang/crates.io-index" 1086 - checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" 1087 - dependencies = [ 1088 - "darling 0.20.11", 1089 - "proc-macro2", 1090 - "quote", 1091 - "syn 2.0.106", 1092 - ] 1093 - 1094 - [[package]] 1095 - name = "derive_builder_macro" 1096 - version = "0.20.2" 1097 - source = "registry+https://github.com/rust-lang/crates.io-index" 1098 - checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" 1099 - dependencies = [ 1100 - "derive_builder_core", 1101 - "syn 2.0.106", 1102 - ] 1103 - 1104 - [[package]] 1105 - name = "derive_more" 1106 - version = "1.0.0" 1107 - source = "registry+https://github.com/rust-lang/crates.io-index" 1108 - checksum = "4a9b99b9cbbe49445b21764dc0625032a89b145a2642e67603e1c936f5458d05" 1109 - dependencies = [ 1110 - "derive_more-impl 1.0.0", 1111 - ] 1112 - 1113 - [[package]] 1114 - name = "derive_more" 1115 - version = "2.0.1" 1116 - source = "registry+https://github.com/rust-lang/crates.io-index" 1117 - checksum = "093242cf7570c207c83073cf82f79706fe7b8317e98620a47d5be7c3d8497678" 1118 - dependencies = [ 1119 - "derive_more-impl 2.0.1", 1120 - ] 1121 - 1122 - [[package]] 1123 - name = "derive_more-impl" 1124 - version = "1.0.0" 1125 - source = "registry+https://github.com/rust-lang/crates.io-index" 1126 - checksum = "cb7330aeadfbe296029522e6c40f315320aba36fc43a5b3632f3795348f3bd22" 1127 - dependencies = [ 1128 - "proc-macro2", 1129 - "quote", 1130 - "syn 2.0.106", 1131 - "unicode-xid", 1132 - ] 1133 - 1134 - [[package]] 1135 - name = "derive_more-impl" 1136 - version = "2.0.1" 1137 - source = "registry+https://github.com/rust-lang/crates.io-index" 1138 - checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3" 1139 - dependencies = [ 1140 - "proc-macro2", 1141 - "quote", 1142 - "syn 2.0.106", 1143 - "unicode-xid", 1144 - ] 1145 - 1146 - [[package]] 1147 - name = "digest" 1148 - version = "0.10.7" 1149 - source = "registry+https://github.com/rust-lang/crates.io-index" 1150 - checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" 1151 - dependencies = [ 1152 - "block-buffer", 1153 - "const-oid", 1154 - "crypto-common", 1155 - "subtle", 1156 - ] 1157 - 1158 - [[package]] 1159 - name = "displaydoc" 1160 - version = "0.2.5" 1161 - source = "registry+https://github.com/rust-lang/crates.io-index" 1162 - checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" 1163 - dependencies = [ 1164 - "proc-macro2", 1165 - "quote", 1166 - "syn 2.0.106", 1167 - ] 1168 - 1169 - [[package]] 1170 - name = "ecdsa" 1171 - version = "0.16.9" 1172 - source = "registry+https://github.com/rust-lang/crates.io-index" 1173 - checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" 1174 - dependencies = [ 1175 - "der", 1176 - "digest", 1177 - "elliptic-curve", 1178 - "rfc6979", 1179 - "signature", 1180 - ] 1181 - 1182 - [[package]] 1183 - name = "elliptic-curve" 1184 - version = "0.13.8" 1185 - source = "registry+https://github.com/rust-lang/crates.io-index" 1186 - checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" 1187 - dependencies = [ 1188 - "base16ct", 1189 - "crypto-bigint", 1190 - "digest", 1191 - "ff", 1192 - "generic-array", 1193 - "group", 1194 - "rand_core 0.6.4", 1195 - "sec1", 1196 - "subtle", 1197 - "zeroize", 1198 - ] 1199 - 1200 - [[package]] 1201 - name = "encoding_rs" 1202 - version = "0.8.35" 1203 - source = "registry+https://github.com/rust-lang/crates.io-index" 1204 - checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" 1205 - dependencies = [ 1206 - "cfg-if", 1207 - ] 1208 - 1209 - [[package]] 1210 - name = "enum-as-inner" 1211 - version = "0.6.1" 1212 - source = "registry+https://github.com/rust-lang/crates.io-index" 1213 - checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" 1214 - dependencies = [ 1215 - "heck", 1216 - "proc-macro2", 1217 - "quote", 1218 - "syn 2.0.106", 1219 - ] 1220 - 1221 - [[package]] 1222 - name = "env_filter" 1223 - version = "0.1.3" 1224 - source = "registry+https://github.com/rust-lang/crates.io-index" 1225 - checksum = "186e05a59d4c50738528153b83b0b0194d3a29507dfec16eccd4b342903397d0" 1226 - dependencies = [ 1227 - "log", 1228 - "regex", 1229 - ] 1230 - 1231 - [[package]] 1232 - name = "env_logger" 1233 - version = "0.11.8" 1234 - source = "registry+https://github.com/rust-lang/crates.io-index" 1235 - checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f" 1236 - dependencies = [ 1237 - "anstream", 1238 - "anstyle", 1239 - "env_filter", 1240 - "jiff", 1241 - "log", 1242 - ] 1243 - 1244 - [[package]] 1245 - name = "equivalent" 1246 - version = "1.0.2" 1247 - source = "registry+https://github.com/rust-lang/crates.io-index" 1248 - checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" 1249 - 1250 - [[package]] 1251 - name = "errno" 1252 - version = "0.3.14" 1253 - source = "registry+https://github.com/rust-lang/crates.io-index" 1254 - checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" 1255 - dependencies = [ 1256 - "libc", 1257 - "windows-sys 0.61.1", 1258 - ] 1259 - 1260 - [[package]] 1261 - name = "event-listener" 1262 - version = "5.4.1" 1263 - source = "registry+https://github.com/rust-lang/crates.io-index" 1264 - checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" 1265 - dependencies = [ 1266 - "concurrent-queue", 1267 - "parking", 1268 - "pin-project-lite", 1269 - ] 1270 - 1271 - [[package]] 1272 - name = "event-listener-strategy" 1273 - version = "0.5.4" 1274 - source = "registry+https://github.com/rust-lang/crates.io-index" 1275 - checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" 1276 - dependencies = [ 1277 - "event-listener", 1278 - "pin-project-lite", 1279 - ] 1280 - 1281 - [[package]] 1282 - name = "fastrand" 1283 - version = "2.3.0" 1284 - source = "registry+https://github.com/rust-lang/crates.io-index" 1285 - checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" 1286 - 1287 - [[package]] 1288 - name = "ff" 1289 - version = "0.13.1" 1290 - source = "registry+https://github.com/rust-lang/crates.io-index" 1291 - checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" 1292 - dependencies = [ 1293 - "rand_core 0.6.4", 1294 - "subtle", 1295 - ] 1296 - 1297 - [[package]] 1298 - name = "find-msvc-tools" 1299 - version = "0.1.3" 1300 - source = "registry+https://github.com/rust-lang/crates.io-index" 1301 - checksum = "0399f9d26e5191ce32c498bebd31e7a3ceabc2745f0ac54af3f335126c3f24b3" 1302 - 1303 - [[package]] 1304 - name = "flate2" 1305 - version = "1.1.4" 1306 - source = "registry+https://github.com/rust-lang/crates.io-index" 1307 - checksum = "dc5a4e564e38c699f2880d3fda590bedc2e69f3f84cd48b457bd892ce61d0aa9" 1308 - dependencies = [ 1309 - "crc32fast", 1310 - "miniz_oxide", 1311 - ] 1312 - 1313 - [[package]] 1314 - name = "flume" 1315 - version = "0.11.1" 1316 - source = "registry+https://github.com/rust-lang/crates.io-index" 1317 - checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" 1318 - dependencies = [ 1319 - "futures-core", 1320 - "futures-sink", 1321 - "nanorand", 1322 - "spin", 1323 - ] 1324 - 1325 - [[package]] 1326 - name = "fnv" 1327 - version = "1.0.7" 1328 - source = "registry+https://github.com/rust-lang/crates.io-index" 1329 - checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" 1330 - 1331 - [[package]] 1332 - name = "foldhash" 1333 - version = "0.1.5" 1334 - source = "registry+https://github.com/rust-lang/crates.io-index" 1335 - checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" 1336 - 1337 - [[package]] 1338 - name = "foreign-types" 1339 - version = "0.3.2" 1340 - source = "registry+https://github.com/rust-lang/crates.io-index" 1341 - checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" 1342 - dependencies = [ 1343 - "foreign-types-shared", 1344 - ] 1345 - 1346 - [[package]] 1347 - name = "foreign-types-shared" 1348 - version = "0.1.1" 1349 - source = "registry+https://github.com/rust-lang/crates.io-index" 1350 - checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" 1351 - 1352 - [[package]] 1353 - name = "form_urlencoded" 1354 - version = "1.2.2" 1355 - source = "registry+https://github.com/rust-lang/crates.io-index" 1356 - checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" 1357 - dependencies = [ 1358 - "percent-encoding", 1359 - ] 1360 - 1361 - [[package]] 1362 - name = "futures-channel" 1363 - version = "0.3.31" 1364 - source = "registry+https://github.com/rust-lang/crates.io-index" 1365 - checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" 1366 - dependencies = [ 1367 - "futures-core", 1368 - ] 1369 - 1370 - [[package]] 1371 - name = "futures-core" 1372 - version = "0.3.31" 1373 - source = "registry+https://github.com/rust-lang/crates.io-index" 1374 - checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" 1375 - 1376 - [[package]] 1377 - name = "futures-io" 1378 - version = "0.3.31" 1379 - source = "registry+https://github.com/rust-lang/crates.io-index" 1380 - checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" 1381 - 1382 - [[package]] 1383 - name = "futures-macro" 1384 - version = "0.3.31" 1385 - source = "registry+https://github.com/rust-lang/crates.io-index" 1386 - checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" 1387 - dependencies = [ 1388 - "proc-macro2", 1389 - "quote", 1390 - "syn 2.0.106", 1391 - ] 1392 - 1393 - [[package]] 1394 - name = "futures-sink" 1395 - version = "0.3.31" 1396 - source = "registry+https://github.com/rust-lang/crates.io-index" 1397 - checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" 1398 - 1399 - [[package]] 1400 - name = "futures-task" 1401 - version = "0.3.31" 1402 - source = "registry+https://github.com/rust-lang/crates.io-index" 1403 - checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" 1404 - 1405 - [[package]] 1406 - name = "futures-util" 1407 - version = "0.3.31" 1408 - source = "registry+https://github.com/rust-lang/crates.io-index" 1409 - checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" 1410 - dependencies = [ 1411 - "futures-core", 1412 - "futures-macro", 1413 - "futures-sink", 1414 - "futures-task", 1415 - "pin-project-lite", 1416 - "pin-utils", 1417 - "slab", 1418 - ] 1419 - 1420 - [[package]] 1421 - name = "generic-array" 1422 - version = "0.14.7" 1423 - source = "registry+https://github.com/rust-lang/crates.io-index" 1424 - checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" 1425 - dependencies = [ 1426 - "typenum", 1427 - "version_check", 1428 - "zeroize", 1429 - ] 1430 - 1431 - [[package]] 1432 - name = "getrandom" 1433 - version = "0.2.16" 1434 - source = "registry+https://github.com/rust-lang/crates.io-index" 1435 - checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" 1436 - dependencies = [ 1437 - "cfg-if", 1438 - "js-sys", 1439 - "libc", 1440 - "wasi 0.11.1+wasi-snapshot-preview1", 1441 - "wasm-bindgen", 1442 - ] 1443 - 1444 - [[package]] 1445 - name = "getrandom" 1446 - version = "0.3.3" 1447 - source = "registry+https://github.com/rust-lang/crates.io-index" 1448 - checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" 1449 - dependencies = [ 1450 - "cfg-if", 1451 - "libc", 1452 - "r-efi", 1453 - "wasi 0.14.7+wasi-0.2.4", 1454 - ] 1455 - 1456 - [[package]] 1457 - name = "ghash" 1458 - version = "0.5.1" 1459 - source = "registry+https://github.com/rust-lang/crates.io-index" 1460 - checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" 1461 - dependencies = [ 1462 - "opaque-debug", 1463 - "polyval", 1464 - ] 1465 - 1466 - [[package]] 1467 - name = "gimli" 1468 - version = "0.32.3" 1469 - source = "registry+https://github.com/rust-lang/crates.io-index" 1470 - checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" 1471 - 1472 - [[package]] 1473 - name = "group" 1474 - version = "0.13.0" 1475 - source = "registry+https://github.com/rust-lang/crates.io-index" 1476 - checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" 1477 - dependencies = [ 1478 - "ff", 1479 - "rand_core 0.6.4", 1480 - "subtle", 1481 - ] 1482 - 1483 - [[package]] 1484 - name = "h2" 1485 - version = "0.3.27" 1486 - source = "registry+https://github.com/rust-lang/crates.io-index" 1487 - checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" 1488 - dependencies = [ 1489 - "bytes", 1490 - "fnv", 1491 - "futures-core", 1492 - "futures-sink", 1493 - "futures-util", 1494 - "http 0.2.12", 1495 - "indexmap", 1496 - "slab", 1497 - "tokio", 1498 - "tokio-util", 1499 - "tracing", 1500 - ] 1501 - 1502 - [[package]] 1503 - name = "h2" 1504 - version = "0.4.12" 1505 - source = "registry+https://github.com/rust-lang/crates.io-index" 1506 - checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386" 1507 - dependencies = [ 1508 - "atomic-waker", 1509 - "bytes", 1510 - "fnv", 1511 - "futures-core", 1512 - "futures-sink", 1513 - "http 1.3.1", 1514 - "indexmap", 1515 - "slab", 1516 - "tokio", 1517 - "tokio-util", 1518 - "tracing", 1519 - ] 1520 - 1521 - [[package]] 1522 - name = "hashbrown" 1523 - version = "0.14.5" 1524 - source = "registry+https://github.com/rust-lang/crates.io-index" 1525 - checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" 1526 - 1527 - [[package]] 1528 - name = "hashbrown" 1529 - version = "0.15.5" 1530 - source = "registry+https://github.com/rust-lang/crates.io-index" 1531 - checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" 1532 - dependencies = [ 1533 - "allocator-api2", 1534 - "equivalent", 1535 - "foldhash", 1536 - ] 1537 - 1538 - [[package]] 1539 - name = "hashbrown" 1540 - version = "0.16.0" 1541 - source = "registry+https://github.com/rust-lang/crates.io-index" 1542 - checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" 1543 - 1544 - [[package]] 1545 - name = "heck" 1546 - version = "0.5.0" 1547 - source = "registry+https://github.com/rust-lang/crates.io-index" 1548 - checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" 1549 - 1550 - [[package]] 1551 - name = "hickory-proto" 1552 - version = "0.24.4" 1553 - source = "registry+https://github.com/rust-lang/crates.io-index" 1554 - checksum = "92652067c9ce6f66ce53cc38d1169daa36e6e7eb7dd3b63b5103bd9d97117248" 1555 - dependencies = [ 1556 - "async-trait", 1557 - "cfg-if", 1558 - "data-encoding", 1559 - "enum-as-inner", 1560 - "futures-channel", 1561 - "futures-io", 1562 - "futures-util", 1563 - "idna", 1564 - "ipnet", 1565 - "once_cell", 1566 - "rand 0.8.5", 1567 - "thiserror", 1568 - "tinyvec", 1569 - "tokio", 1570 - "tracing", 1571 - "url", 1572 - ] 1573 - 1574 - [[package]] 1575 - name = "hickory-resolver" 1576 - version = "0.24.4" 1577 - source = "registry+https://github.com/rust-lang/crates.io-index" 1578 - checksum = "cbb117a1ca520e111743ab2f6688eddee69db4e0ea242545a604dce8a66fd22e" 1579 - dependencies = [ 1580 - "cfg-if", 1581 - "futures-util", 1582 - "hickory-proto", 1583 - "ipconfig", 1584 - "lru-cache", 1585 - "once_cell", 1586 - "parking_lot", 1587 - "rand 0.8.5", 1588 - "resolv-conf", 1589 - "smallvec", 1590 - "thiserror", 1591 - "tokio", 1592 - "tracing", 1593 - ] 1594 - 1595 - [[package]] 1596 - name = "hkdf" 1597 - version = "0.12.4" 1598 - source = "registry+https://github.com/rust-lang/crates.io-index" 1599 - checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" 1600 - dependencies = [ 1601 - "hmac", 1602 - ] 1603 - 1604 - [[package]] 1605 - name = "hmac" 1606 - version = "0.12.1" 1607 - source = "registry+https://github.com/rust-lang/crates.io-index" 1608 - checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" 1609 - dependencies = [ 1610 - "digest", 1611 - ] 1612 - 1613 - [[package]] 1614 - name = "http" 1615 - version = "0.2.12" 1616 - source = "registry+https://github.com/rust-lang/crates.io-index" 1617 - checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" 1618 - dependencies = [ 1619 - "bytes", 1620 - "fnv", 1621 - "itoa", 1622 - ] 1623 - 1624 - [[package]] 1625 - name = "http" 1626 - version = "1.3.1" 1627 - source = "registry+https://github.com/rust-lang/crates.io-index" 1628 - checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" 1629 - dependencies = [ 1630 - "bytes", 1631 - "fnv", 1632 - "itoa", 1633 - ] 1634 - 1635 - [[package]] 1636 - name = "http-body" 1637 - version = "1.0.1" 1638 - source = "registry+https://github.com/rust-lang/crates.io-index" 1639 - checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" 1640 - dependencies = [ 1641 - "bytes", 1642 - "http 1.3.1", 1643 - ] 1644 - 1645 - [[package]] 1646 - name = "http-body-util" 1647 - version = "0.1.3" 1648 - source = "registry+https://github.com/rust-lang/crates.io-index" 1649 - checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" 1650 - dependencies = [ 1651 - "bytes", 1652 - "futures-core", 1653 - "http 1.3.1", 1654 - "http-body", 1655 - "pin-project-lite", 1656 - ] 1657 - 1658 - [[package]] 1659 - name = "http-range" 1660 - version = "0.1.5" 1661 - source = "registry+https://github.com/rust-lang/crates.io-index" 1662 - checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573" 1663 - 1664 - [[package]] 1665 - name = "httparse" 1666 - version = "1.10.1" 1667 - source = "registry+https://github.com/rust-lang/crates.io-index" 1668 - checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" 1669 - 1670 - [[package]] 1671 - name = "httpdate" 1672 - version = "1.0.3" 1673 - source = "registry+https://github.com/rust-lang/crates.io-index" 1674 - checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" 1675 - 1676 - [[package]] 1677 - name = "hyper" 1678 - version = "1.7.0" 1679 - source = "registry+https://github.com/rust-lang/crates.io-index" 1680 - checksum = "eb3aa54a13a0dfe7fbe3a59e0c76093041720fdc77b110cc0fc260fafb4dc51e" 1681 - dependencies = [ 1682 - "atomic-waker", 1683 - "bytes", 1684 - "futures-channel", 1685 - "futures-core", 1686 - "h2 0.4.12", 1687 - "http 1.3.1", 1688 - "http-body", 1689 - "httparse", 1690 - "itoa", 1691 - "pin-project-lite", 1692 - "pin-utils", 1693 - "smallvec", 1694 - "tokio", 1695 - "want", 1696 - ] 1697 - 1698 - [[package]] 1699 - name = "hyper-rustls" 1700 - version = "0.27.7" 1701 - source = "registry+https://github.com/rust-lang/crates.io-index" 1702 - checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" 1703 - dependencies = [ 1704 - "http 1.3.1", 1705 - "hyper", 1706 - "hyper-util", 1707 - "rustls 0.23.31", 1708 - "rustls-pki-types", 1709 - "tokio", 1710 - "tokio-rustls 0.26.2", 1711 - "tower-service", 1712 - ] 1713 - 1714 - [[package]] 1715 - name = "hyper-tls" 1716 - version = "0.6.0" 1717 - source = "registry+https://github.com/rust-lang/crates.io-index" 1718 - checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" 1719 - dependencies = [ 1720 - "bytes", 1721 - "http-body-util", 1722 - "hyper", 1723 - "hyper-util", 1724 - "native-tls", 1725 - "tokio", 1726 - "tokio-native-tls", 1727 - "tower-service", 1728 - ] 1729 - 1730 - [[package]] 1731 - name = "hyper-util" 1732 - version = "0.1.17" 1733 - source = "registry+https://github.com/rust-lang/crates.io-index" 1734 - checksum = "3c6995591a8f1380fcb4ba966a252a4b29188d51d2b89e3a252f5305be65aea8" 1735 - dependencies = [ 1736 - "base64 0.22.1", 1737 - "bytes", 1738 - "futures-channel", 1739 - "futures-core", 1740 - "futures-util", 1741 - "http 1.3.1", 1742 - "http-body", 1743 - "hyper", 1744 - "ipnet", 1745 - "libc", 1746 - "percent-encoding", 1747 - "pin-project-lite", 1748 - "socket2 0.6.0", 1749 - "system-configuration", 1750 - "tokio", 1751 - "tower-service", 1752 - "tracing", 1753 - "windows-registry", 1754 - ] 1755 - 1756 - [[package]] 1757 - name = "iana-time-zone" 1758 - version = "0.1.64" 1759 - source = "registry+https://github.com/rust-lang/crates.io-index" 1760 - checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" 1761 - dependencies = [ 1762 - "android_system_properties", 1763 - "core-foundation-sys", 1764 - "iana-time-zone-haiku", 1765 - "js-sys", 1766 - "log", 1767 - "wasm-bindgen", 1768 - "windows-core", 1769 - ] 1770 - 1771 - [[package]] 1772 - name = "iana-time-zone-haiku" 1773 - version = "0.1.2" 1774 - source = "registry+https://github.com/rust-lang/crates.io-index" 1775 - checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" 1776 - dependencies = [ 1777 - "cc", 1778 - ] 1779 - 1780 - [[package]] 1781 - name = "icu_collections" 1782 - version = "2.0.0" 1783 - source = "registry+https://github.com/rust-lang/crates.io-index" 1784 - checksum = "200072f5d0e3614556f94a9930d5dc3e0662a652823904c3a75dc3b0af7fee47" 1785 - dependencies = [ 1786 - "displaydoc", 1787 - "potential_utf", 1788 - "yoke", 1789 - "zerofrom", 1790 - "zerovec", 1791 - ] 1792 - 1793 - [[package]] 1794 - name = "icu_locale_core" 1795 - version = "2.0.0" 1796 - source = "registry+https://github.com/rust-lang/crates.io-index" 1797 - checksum = "0cde2700ccaed3872079a65fb1a78f6c0a36c91570f28755dda67bc8f7d9f00a" 1798 - dependencies = [ 1799 - "displaydoc", 1800 - "litemap", 1801 - "tinystr", 1802 - "writeable", 1803 - "zerovec", 1804 - ] 1805 - 1806 - [[package]] 1807 - name = "icu_normalizer" 1808 - version = "2.0.0" 1809 - source = "registry+https://github.com/rust-lang/crates.io-index" 1810 - checksum = "436880e8e18df4d7bbc06d58432329d6458cc84531f7ac5f024e93deadb37979" 1811 - dependencies = [ 1812 - "displaydoc", 1813 - "icu_collections", 1814 - "icu_normalizer_data", 1815 - "icu_properties", 1816 - "icu_provider", 1817 - "smallvec", 1818 - "zerovec", 1819 - ] 1820 - 1821 - [[package]] 1822 - name = "icu_normalizer_data" 1823 - version = "2.0.0" 1824 - source = "registry+https://github.com/rust-lang/crates.io-index" 1825 - checksum = "00210d6893afc98edb752b664b8890f0ef174c8adbb8d0be9710fa66fbbf72d3" 1826 - 1827 - [[package]] 1828 - name = "icu_properties" 1829 - version = "2.0.1" 1830 - source = "registry+https://github.com/rust-lang/crates.io-index" 1831 - checksum = "016c619c1eeb94efb86809b015c58f479963de65bdb6253345c1a1276f22e32b" 1832 - dependencies = [ 1833 - "displaydoc", 1834 - "icu_collections", 1835 - "icu_locale_core", 1836 - "icu_properties_data", 1837 - "icu_provider", 1838 - "potential_utf", 1839 - "zerotrie", 1840 - "zerovec", 1841 - ] 1842 - 1843 - [[package]] 1844 - name = "icu_properties_data" 1845 - version = "2.0.1" 1846 - source = "registry+https://github.com/rust-lang/crates.io-index" 1847 - checksum = "298459143998310acd25ffe6810ed544932242d3f07083eee1084d83a71bd632" 1848 - 1849 - [[package]] 1850 - name = "icu_provider" 1851 - version = "2.0.0" 1852 - source = "registry+https://github.com/rust-lang/crates.io-index" 1853 - checksum = "03c80da27b5f4187909049ee2d72f276f0d9f99a42c306bd0131ecfe04d8e5af" 1854 - dependencies = [ 1855 - "displaydoc", 1856 - "icu_locale_core", 1857 - "stable_deref_trait", 1858 - "tinystr", 1859 - "writeable", 1860 - "yoke", 1861 - "zerofrom", 1862 - "zerotrie", 1863 - "zerovec", 1864 - ] 1865 - 1866 - [[package]] 1867 - name = "ident_case" 1868 - version = "1.0.1" 1869 - source = "registry+https://github.com/rust-lang/crates.io-index" 1870 - checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" 1871 - 1872 - [[package]] 1873 - name = "idna" 1874 - version = "1.1.0" 1875 - source = "registry+https://github.com/rust-lang/crates.io-index" 1876 - checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" 1877 - dependencies = [ 1878 - "idna_adapter", 1879 - "smallvec", 1880 - "utf8_iter", 1881 - ] 1882 - 1883 - [[package]] 1884 - name = "idna_adapter" 1885 - version = "1.2.1" 1886 - source = "registry+https://github.com/rust-lang/crates.io-index" 1887 - checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" 1888 - dependencies = [ 1889 - "icu_normalizer", 1890 - "icu_properties", 1891 - ] 1892 - 1893 - [[package]] 1894 - name = "impl-more" 1895 - version = "0.1.9" 1896 - source = "registry+https://github.com/rust-lang/crates.io-index" 1897 - checksum = "e8a5a9a0ff0086c7a148acb942baaabeadf9504d10400b5a05645853729b9cd2" 1898 - 1899 - [[package]] 1900 - name = "indexmap" 1901 - version = "2.11.4" 1902 - source = "registry+https://github.com/rust-lang/crates.io-index" 1903 - checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5" 1904 - dependencies = [ 1905 - "equivalent", 1906 - "hashbrown 0.16.0", 1907 - ] 1908 - 1909 - [[package]] 1910 - name = "inout" 1911 - version = "0.1.4" 1912 - source = "registry+https://github.com/rust-lang/crates.io-index" 1913 - checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" 1914 - dependencies = [ 1915 - "generic-array", 1916 - ] 1917 - 1918 - [[package]] 1919 - name = "io-uring" 1920 - version = "0.7.10" 1921 - source = "registry+https://github.com/rust-lang/crates.io-index" 1922 - checksum = "046fa2d4d00aea763528b4950358d0ead425372445dc8ff86312b3c69ff7727b" 1923 - dependencies = [ 1924 - "bitflags", 1925 - "cfg-if", 1926 - "libc", 1927 - ] 1928 - 1929 - [[package]] 1930 - name = "ipconfig" 1931 - version = "0.3.2" 1932 - source = "registry+https://github.com/rust-lang/crates.io-index" 1933 - checksum = "b58db92f96b720de98181bbbe63c831e87005ab460c1bf306eb2622b4707997f" 1934 - dependencies = [ 1935 - "socket2 0.5.10", 1936 - "widestring", 1937 - "windows-sys 0.48.0", 1938 - "winreg", 1939 - ] 1940 - 1941 - [[package]] 1942 - name = "ipld-core" 1943 - version = "0.4.2" 1944 - source = "registry+https://github.com/rust-lang/crates.io-index" 1945 - checksum = "104718b1cc124d92a6d01ca9c9258a7df311405debb3408c445a36452f9bf8db" 1946 - dependencies = [ 1947 - "cid", 1948 - "serde", 1949 - "serde_bytes", 1950 - ] 1951 - 1952 - [[package]] 1953 - name = "ipnet" 1954 - version = "2.11.0" 1955 - source = "registry+https://github.com/rust-lang/crates.io-index" 1956 - checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" 1957 - 1958 - [[package]] 1959 - name = "iri-string" 1960 - version = "0.7.8" 1961 - source = "registry+https://github.com/rust-lang/crates.io-index" 1962 - checksum = "dbc5ebe9c3a1a7a5127f920a418f7585e9e758e911d0466ed004f393b0e380b2" 1963 - dependencies = [ 1964 - "memchr", 1965 - "serde", 1966 - ] 1967 - 1968 - [[package]] 1969 - name = "is_terminal_polyfill" 1970 - version = "1.70.1" 1971 - source = "registry+https://github.com/rust-lang/crates.io-index" 1972 - checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" 1973 - 1974 - [[package]] 1975 - name = "itoa" 1976 - version = "1.0.15" 1977 - source = "registry+https://github.com/rust-lang/crates.io-index" 1978 - checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" 1979 - 1980 - [[package]] 1981 - name = "jiff" 1982 - version = "0.2.15" 1983 - source = "registry+https://github.com/rust-lang/crates.io-index" 1984 - checksum = "be1f93b8b1eb69c77f24bbb0afdf66f54b632ee39af40ca21c4365a1d7347e49" 1985 - dependencies = [ 1986 - "jiff-static", 1987 - "log", 1988 - "portable-atomic", 1989 - "portable-atomic-util", 1990 - "serde", 1991 - ] 1992 - 1993 - [[package]] 1994 - name = "jiff-static" 1995 - version = "0.2.15" 1996 - source = "registry+https://github.com/rust-lang/crates.io-index" 1997 - checksum = "03343451ff899767262ec32146f6d559dd759fdadf42ff0e227c7c48f72594b4" 1998 - dependencies = [ 1999 - "proc-macro2", 2000 - "quote", 2001 - "syn 2.0.106", 2002 - ] 2003 - 2004 - [[package]] 2005 - name = "jobserver" 2006 - version = "0.1.34" 2007 - source = "registry+https://github.com/rust-lang/crates.io-index" 2008 - checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" 2009 - dependencies = [ 2010 - "getrandom 0.3.3", 2011 - "libc", 2012 - ] 2013 - 2014 - [[package]] 2015 - name = "jose-b64" 2016 - version = "0.1.2" 2017 - source = "registry+https://github.com/rust-lang/crates.io-index" 2018 - checksum = "bec69375368709666b21c76965ce67549f2d2db7605f1f8707d17c9656801b56" 2019 - dependencies = [ 2020 - "base64ct", 2021 - "serde", 2022 - "subtle", 2023 - "zeroize", 2024 - ] 2025 - 2026 - [[package]] 2027 - name = "jose-jwa" 2028 - version = "0.1.2" 2029 - source = "registry+https://github.com/rust-lang/crates.io-index" 2030 - checksum = "9ab78e053fe886a351d67cf0d194c000f9d0dcb92906eb34d853d7e758a4b3a7" 2031 - dependencies = [ 2032 - "serde", 2033 - ] 2034 - 2035 - [[package]] 2036 - name = "jose-jwk" 2037 - version = "0.1.2" 2038 - source = "registry+https://github.com/rust-lang/crates.io-index" 2039 - checksum = "280fa263807fe0782ecb6f2baadc28dffc04e00558a58e33bfdb801d11fd58e7" 2040 - dependencies = [ 2041 - "jose-b64", 2042 - "jose-jwa", 2043 - "p256", 2044 - "serde", 2045 - "zeroize", 2046 - ] 2047 - 2048 - [[package]] 2049 - name = "js-sys" 2050 - version = "0.3.81" 2051 - source = "registry+https://github.com/rust-lang/crates.io-index" 2052 - checksum = "ec48937a97411dcb524a265206ccd4c90bb711fca92b2792c407f268825b9305" 2053 - dependencies = [ 2054 - "once_cell", 2055 - "wasm-bindgen", 2056 - ] 2057 - 2058 - [[package]] 2059 - name = "langtag" 2060 - version = "0.3.4" 2061 - source = "registry+https://github.com/rust-lang/crates.io-index" 2062 - checksum = "ed60c85f254d6ae8450cec15eedd921efbc4d1bdf6fcf6202b9a58b403f6f805" 2063 - dependencies = [ 2064 - "serde", 2065 - ] 2066 - 2067 - [[package]] 2068 - name = "language-tags" 2069 - version = "0.3.2" 2070 - source = "registry+https://github.com/rust-lang/crates.io-index" 2071 - checksum = "d4345964bb142484797b161f473a503a434de77149dd8c7427788c6e13379388" 2072 - 2073 - [[package]] 2074 - name = "lazy_static" 2075 - version = "1.5.0" 2076 - source = "registry+https://github.com/rust-lang/crates.io-index" 2077 - checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" 2078 - 2079 - [[package]] 2080 - name = "libc" 2081 - version = "0.2.176" 2082 - source = "registry+https://github.com/rust-lang/crates.io-index" 2083 - checksum = "58f929b4d672ea937a23a1ab494143d968337a5f47e56d0815df1e0890ddf174" 2084 - 2085 - [[package]] 2086 - name = "linked-hash-map" 2087 - version = "0.5.6" 2088 - source = "registry+https://github.com/rust-lang/crates.io-index" 2089 - checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" 2090 - 2091 - [[package]] 2092 - name = "linux-raw-sys" 2093 - version = "0.11.0" 2094 - source = "registry+https://github.com/rust-lang/crates.io-index" 2095 - checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" 2096 - 2097 - [[package]] 2098 - name = "litemap" 2099 - version = "0.8.0" 2100 - source = "registry+https://github.com/rust-lang/crates.io-index" 2101 - checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" 2102 - 2103 - [[package]] 2104 - name = "local-channel" 2105 - version = "0.1.5" 2106 - source = "registry+https://github.com/rust-lang/crates.io-index" 2107 - checksum = "b6cbc85e69b8df4b8bb8b89ec634e7189099cea8927a276b7384ce5488e53ec8" 2108 - dependencies = [ 2109 - "futures-core", 2110 - "futures-sink", 2111 - "local-waker", 2112 - ] 2113 - 2114 - [[package]] 2115 - name = "local-waker" 2116 - version = "0.1.4" 2117 - source = "registry+https://github.com/rust-lang/crates.io-index" 2118 - checksum = "4d873d7c67ce09b42110d801813efbc9364414e356be9935700d368351657487" 2119 - 2120 - [[package]] 2121 - name = "lock_api" 2122 - version = "0.4.14" 2123 - source = "registry+https://github.com/rust-lang/crates.io-index" 2124 - checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" 2125 - dependencies = [ 2126 - "scopeguard", 2127 - ] 2128 - 2129 - [[package]] 2130 - name = "log" 2131 - version = "0.4.28" 2132 - source = "registry+https://github.com/rust-lang/crates.io-index" 2133 - checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" 2134 - 2135 - [[package]] 2136 - name = "lru" 2137 - version = "0.12.5" 2138 - source = "registry+https://github.com/rust-lang/crates.io-index" 2139 - checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" 2140 - dependencies = [ 2141 - "hashbrown 0.15.5", 2142 - ] 2143 - 2144 - [[package]] 2145 - name = "lru-cache" 2146 - version = "0.1.2" 2147 - source = "registry+https://github.com/rust-lang/crates.io-index" 2148 - checksum = "31e24f1ad8321ca0e8a1e0ac13f23cb668e6f5466c2c57319f6a5cf1cc8e3b1c" 2149 - dependencies = [ 2150 - "linked-hash-map", 2151 - ] 2152 - 2153 - [[package]] 2154 - name = "match-lookup" 2155 - version = "0.1.1" 2156 - source = "registry+https://github.com/rust-lang/crates.io-index" 2157 - checksum = "1265724d8cb29dbbc2b0f06fffb8bf1a8c0cf73a78eede9ba73a4a66c52a981e" 2158 - dependencies = [ 2159 - "proc-macro2", 2160 - "quote", 2161 - "syn 1.0.109", 2162 - ] 2163 - 2164 - [[package]] 2165 - name = "memchr" 2166 - version = "2.7.6" 2167 - source = "registry+https://github.com/rust-lang/crates.io-index" 2168 - checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" 2169 - 2170 - [[package]] 2171 - name = "metrics" 2172 - version = "0.24.2" 2173 - source = "registry+https://github.com/rust-lang/crates.io-index" 2174 - checksum = "25dea7ac8057892855ec285c440160265225438c3c45072613c25a4b26e98ef5" 2175 - dependencies = [ 2176 - "ahash", 2177 - "portable-atomic", 2178 - ] 2179 - 2180 - [[package]] 2181 - name = "mime" 2182 - version = "0.3.17" 2183 - source = "registry+https://github.com/rust-lang/crates.io-index" 2184 - checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" 2185 - 2186 - [[package]] 2187 - name = "mime_guess" 2188 - version = "2.0.5" 2189 - source = "registry+https://github.com/rust-lang/crates.io-index" 2190 - checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" 2191 - dependencies = [ 2192 - "mime", 2193 - "unicase", 2194 - ] 2195 - 2196 - [[package]] 2197 - name = "miniz_oxide" 2198 - version = "0.8.9" 2199 - source = "registry+https://github.com/rust-lang/crates.io-index" 2200 - checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" 2201 - dependencies = [ 2202 - "adler2", 2203 - "simd-adler32", 2204 - ] 2205 - 2206 - [[package]] 2207 - name = "mio" 2208 - version = "1.0.4" 2209 - source = "registry+https://github.com/rust-lang/crates.io-index" 2210 - checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" 2211 - dependencies = [ 2212 - "libc", 2213 - "log", 2214 - "wasi 0.11.1+wasi-snapshot-preview1", 2215 - "windows-sys 0.59.0", 2216 - ] 2217 - 2218 - [[package]] 2219 - name = "moka" 2220 - version = "0.12.11" 2221 - source = "registry+https://github.com/rust-lang/crates.io-index" 2222 - checksum = "8261cd88c312e0004c1d51baad2980c66528dfdb2bee62003e643a4d8f86b077" 2223 - dependencies = [ 2224 - "async-lock", 2225 - "crossbeam-channel", 2226 - "crossbeam-epoch", 2227 - "crossbeam-utils", 2228 - "equivalent", 2229 - "event-listener", 2230 - "futures-util", 2231 - "parking_lot", 2232 - "portable-atomic", 2233 - "rustc_version", 2234 - "smallvec", 2235 - "tagptr", 2236 - "uuid", 2237 - ] 2238 - 2239 - [[package]] 2240 - name = "multibase" 2241 - version = "0.9.2" 2242 - source = "registry+https://github.com/rust-lang/crates.io-index" 2243 - checksum = "8694bb4835f452b0e3bb06dbebb1d6fc5385b6ca1caf2e55fd165c042390ec77" 2244 - dependencies = [ 2245 - "base-x", 2246 - "base256emoji", 2247 - "data-encoding", 2248 - "data-encoding-macro", 2249 - ] 2250 - 2251 - [[package]] 2252 - name = "multihash" 2253 - version = "0.19.3" 2254 - source = "registry+https://github.com/rust-lang/crates.io-index" 2255 - checksum = "6b430e7953c29dd6a09afc29ff0bb69c6e306329ee6794700aee27b76a1aea8d" 2256 - dependencies = [ 2257 - "core2", 2258 - "serde", 2259 - "unsigned-varint", 2260 - ] 2261 - 2262 - [[package]] 2263 - name = "nanorand" 2264 - version = "0.7.0" 2265 - source = "registry+https://github.com/rust-lang/crates.io-index" 2266 - checksum = "6a51313c5820b0b02bd422f4b44776fbf47961755c74ce64afc73bfad10226c3" 2267 - dependencies = [ 2268 - "getrandom 0.2.16", 2269 - ] 2270 - 2271 - [[package]] 2272 - name = "native-tls" 2273 - version = "0.2.14" 2274 - source = "registry+https://github.com/rust-lang/crates.io-index" 2275 - checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" 2276 - dependencies = [ 2277 - "libc", 2278 - "log", 2279 - "openssl", 2280 - "openssl-probe", 2281 - "openssl-sys", 2282 - "schannel", 2283 - "security-framework", 2284 - "security-framework-sys", 2285 - "tempfile", 2286 - ] 2287 - 2288 - [[package]] 2289 - name = "nu-ansi-term" 2290 - version = "0.50.3" 2291 - source = "registry+https://github.com/rust-lang/crates.io-index" 2292 - checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" 2293 - dependencies = [ 2294 - "windows-sys 0.61.1", 2295 - ] 2296 - 2297 - [[package]] 2298 - name = "num-conv" 2299 - version = "0.1.0" 2300 - source = "registry+https://github.com/rust-lang/crates.io-index" 2301 - checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" 2302 - 2303 - [[package]] 2304 - name = "num-traits" 2305 - version = "0.2.19" 2306 - source = "registry+https://github.com/rust-lang/crates.io-index" 2307 - checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" 2308 - dependencies = [ 2309 - "autocfg", 2310 - ] 2311 - 2312 - [[package]] 2313 - name = "object" 2314 - version = "0.37.3" 2315 - source = "registry+https://github.com/rust-lang/crates.io-index" 2316 - checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" 2317 - dependencies = [ 2318 - "memchr", 2319 - ] 2320 - 2321 - [[package]] 2322 - name = "once_cell" 2323 - version = "1.21.3" 2324 - source = "registry+https://github.com/rust-lang/crates.io-index" 2325 - checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" 2326 - 2327 - [[package]] 2328 - name = "once_cell_polyfill" 2329 - version = "1.70.1" 2330 - source = "registry+https://github.com/rust-lang/crates.io-index" 2331 - checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" 2332 - 2333 - [[package]] 2334 - name = "opaque-debug" 2335 - version = "0.3.1" 2336 - source = "registry+https://github.com/rust-lang/crates.io-index" 2337 - checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" 2338 - 2339 - [[package]] 2340 - name = "openssl" 2341 - version = "0.10.73" 2342 - source = "registry+https://github.com/rust-lang/crates.io-index" 2343 - checksum = "8505734d46c8ab1e19a1dce3aef597ad87dcb4c37e7188231769bd6bd51cebf8" 2344 - dependencies = [ 2345 - "bitflags", 2346 - "cfg-if", 2347 - "foreign-types", 2348 - "libc", 2349 - "once_cell", 2350 - "openssl-macros", 2351 - "openssl-sys", 2352 - ] 2353 - 2354 - [[package]] 2355 - name = "openssl-macros" 2356 - version = "0.1.1" 2357 - source = "registry+https://github.com/rust-lang/crates.io-index" 2358 - checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" 2359 - dependencies = [ 2360 - "proc-macro2", 2361 - "quote", 2362 - "syn 2.0.106", 2363 - ] 2364 - 2365 - [[package]] 2366 - name = "openssl-probe" 2367 - version = "0.1.6" 2368 - source = "registry+https://github.com/rust-lang/crates.io-index" 2369 - checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" 2370 - 2371 - [[package]] 2372 - name = "openssl-sys" 2373 - version = "0.9.109" 2374 - source = "registry+https://github.com/rust-lang/crates.io-index" 2375 - checksum = "90096e2e47630d78b7d1c20952dc621f957103f8bc2c8359ec81290d75238571" 2376 - dependencies = [ 2377 - "cc", 2378 - "libc", 2379 - "pkg-config", 2380 - "vcpkg", 2381 - ] 2382 - 2383 - [[package]] 2384 - name = "p256" 2385 - version = "0.13.2" 2386 - source = "registry+https://github.com/rust-lang/crates.io-index" 2387 - checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" 2388 - dependencies = [ 2389 - "ecdsa", 2390 - "elliptic-curve", 2391 - "primeorder", 2392 - "sha2", 2393 - ] 2394 - 2395 - [[package]] 2396 - name = "parking" 2397 - version = "2.2.1" 2398 - source = "registry+https://github.com/rust-lang/crates.io-index" 2399 - checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" 2400 - 2401 - [[package]] 2402 - name = "parking_lot" 2403 - version = "0.12.5" 2404 - source = "registry+https://github.com/rust-lang/crates.io-index" 2405 - checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" 2406 - dependencies = [ 2407 - "lock_api", 2408 - "parking_lot_core", 2409 - ] 2410 - 2411 - [[package]] 2412 - name = "parking_lot_core" 2413 - version = "0.9.12" 2414 - source = "registry+https://github.com/rust-lang/crates.io-index" 2415 - checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" 2416 - dependencies = [ 2417 - "cfg-if", 2418 - "libc", 2419 - "redox_syscall", 2420 - "smallvec", 2421 - "windows-link 0.2.0", 2422 - ] 2423 - 2424 - [[package]] 2425 - name = "percent-encoding" 2426 - version = "2.3.2" 2427 - source = "registry+https://github.com/rust-lang/crates.io-index" 2428 - checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" 2429 - 2430 - [[package]] 2431 - name = "pin-project-lite" 2432 - version = "0.2.16" 2433 - source = "registry+https://github.com/rust-lang/crates.io-index" 2434 - checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" 2435 - 2436 - [[package]] 2437 - name = "pin-utils" 2438 - version = "0.1.0" 2439 - source = "registry+https://github.com/rust-lang/crates.io-index" 2440 - checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" 2441 - 2442 - [[package]] 2443 - name = "pkg-config" 2444 - version = "0.3.32" 2445 - source = "registry+https://github.com/rust-lang/crates.io-index" 2446 - checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" 2447 - 2448 - [[package]] 2449 - name = "polyval" 2450 - version = "0.6.2" 2451 - source = "registry+https://github.com/rust-lang/crates.io-index" 2452 - checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" 2453 - dependencies = [ 2454 - "cfg-if", 2455 - "cpufeatures", 2456 - "opaque-debug", 2457 - "universal-hash", 2458 - ] 2459 - 2460 - [[package]] 2461 - name = "portable-atomic" 2462 - version = "1.11.1" 2463 - source = "registry+https://github.com/rust-lang/crates.io-index" 2464 - checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" 2465 - 2466 - [[package]] 2467 - name = "portable-atomic-util" 2468 - version = "0.2.4" 2469 - source = "registry+https://github.com/rust-lang/crates.io-index" 2470 - checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" 2471 - dependencies = [ 2472 - "portable-atomic", 2473 - ] 2474 - 2475 - [[package]] 2476 - name = "potential_utf" 2477 - version = "0.1.3" 2478 - source = "registry+https://github.com/rust-lang/crates.io-index" 2479 - checksum = "84df19adbe5b5a0782edcab45899906947ab039ccf4573713735ee7de1e6b08a" 2480 - dependencies = [ 2481 - "zerovec", 2482 - ] 2483 - 2484 - [[package]] 2485 - name = "powerfmt" 2486 - version = "0.2.0" 2487 - source = "registry+https://github.com/rust-lang/crates.io-index" 2488 - checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" 2489 - 2490 - [[package]] 2491 - name = "ppv-lite86" 2492 - version = "0.2.21" 2493 - source = "registry+https://github.com/rust-lang/crates.io-index" 2494 - checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" 2495 - dependencies = [ 2496 - "zerocopy", 2497 - ] 2498 - 2499 - [[package]] 2500 - name = "prettyplease" 2501 - version = "0.2.37" 2502 - source = "registry+https://github.com/rust-lang/crates.io-index" 2503 - checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" 2504 - dependencies = [ 2505 - "proc-macro2", 2506 - "syn 2.0.106", 2507 - ] 2508 - 2509 - [[package]] 2510 - name = "primeorder" 2511 - version = "0.13.6" 2512 - source = "registry+https://github.com/rust-lang/crates.io-index" 2513 - checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" 2514 - dependencies = [ 2515 - "elliptic-curve", 2516 - ] 2517 - 2518 - [[package]] 2519 - name = "proc-macro2" 2520 - version = "1.0.101" 2521 - source = "registry+https://github.com/rust-lang/crates.io-index" 2522 - checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de" 2523 - dependencies = [ 2524 - "unicode-ident", 2525 - ] 2526 - 2527 - [[package]] 2528 - name = "quote" 2529 - version = "1.0.41" 2530 - source = "registry+https://github.com/rust-lang/crates.io-index" 2531 - checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1" 2532 - dependencies = [ 2533 - "proc-macro2", 2534 - ] 2535 - 2536 - [[package]] 2537 - name = "r-efi" 2538 - version = "5.3.0" 2539 - source = "registry+https://github.com/rust-lang/crates.io-index" 2540 - checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" 2541 - 2542 - [[package]] 2543 - name = "rand" 2544 - version = "0.8.5" 2545 - source = "registry+https://github.com/rust-lang/crates.io-index" 2546 - checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" 2547 - dependencies = [ 2548 - "libc", 2549 - "rand_chacha 0.3.1", 2550 - "rand_core 0.6.4", 2551 - ] 2552 - 2553 - [[package]] 2554 - name = "rand" 2555 - version = "0.9.2" 2556 - source = "registry+https://github.com/rust-lang/crates.io-index" 2557 - checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" 2558 - dependencies = [ 2559 - "rand_chacha 0.9.0", 2560 - "rand_core 0.9.3", 2561 - ] 2562 - 2563 - [[package]] 2564 - name = "rand_chacha" 2565 - version = "0.3.1" 2566 - source = "registry+https://github.com/rust-lang/crates.io-index" 2567 - checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" 2568 - dependencies = [ 2569 - "ppv-lite86", 2570 - "rand_core 0.6.4", 2571 - ] 2572 - 2573 - [[package]] 2574 - name = "rand_chacha" 2575 - version = "0.9.0" 2576 - source = "registry+https://github.com/rust-lang/crates.io-index" 2577 - checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" 2578 - dependencies = [ 2579 - "ppv-lite86", 2580 - "rand_core 0.9.3", 2581 - ] 2582 - 2583 - [[package]] 2584 - name = "rand_core" 2585 - version = "0.6.4" 2586 - source = "registry+https://github.com/rust-lang/crates.io-index" 2587 - checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" 2588 - dependencies = [ 2589 - "getrandom 0.2.16", 2590 - ] 2591 - 2592 - [[package]] 2593 - name = "rand_core" 2594 - version = "0.9.3" 2595 - source = "registry+https://github.com/rust-lang/crates.io-index" 2596 - checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" 2597 - dependencies = [ 2598 - "getrandom 0.3.3", 2599 - ] 2600 - 2601 - [[package]] 2602 - name = "redox_syscall" 2603 - version = "0.5.18" 2604 - source = "registry+https://github.com/rust-lang/crates.io-index" 2605 - checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" 2606 - dependencies = [ 2607 - "bitflags", 2608 - ] 2609 - 2610 - [[package]] 2611 - name = "regex" 2612 - version = "1.11.3" 2613 - source = "registry+https://github.com/rust-lang/crates.io-index" 2614 - checksum = "8b5288124840bee7b386bc413c487869b360b2b4ec421ea56425128692f2a82c" 2615 - dependencies = [ 2616 - "aho-corasick", 2617 - "memchr", 2618 - "regex-automata", 2619 - "regex-syntax", 2620 - ] 2621 - 2622 - [[package]] 2623 - name = "regex-automata" 2624 - version = "0.4.11" 2625 - source = "registry+https://github.com/rust-lang/crates.io-index" 2626 - checksum = "833eb9ce86d40ef33cb1306d8accf7bc8ec2bfea4355cbdebb3df68b40925cad" 2627 - dependencies = [ 2628 - "aho-corasick", 2629 - "memchr", 2630 - "regex-syntax", 2631 - ] 2632 - 2633 - [[package]] 2634 - name = "regex-lite" 2635 - version = "0.1.7" 2636 - source = "registry+https://github.com/rust-lang/crates.io-index" 2637 - checksum = "943f41321c63ef1c92fd763bfe054d2668f7f225a5c29f0105903dc2fc04ba30" 2638 - 2639 - [[package]] 2640 - name = "regex-syntax" 2641 - version = "0.8.6" 2642 - source = "registry+https://github.com/rust-lang/crates.io-index" 2643 - checksum = "caf4aa5b0f434c91fe5c7f1ecb6a5ece2130b02ad2a590589dda5146df959001" 2644 - 2645 - [[package]] 2646 - name = "reqwest" 2647 - version = "0.12.23" 2648 - source = "registry+https://github.com/rust-lang/crates.io-index" 2649 - checksum = "d429f34c8092b2d42c7c93cec323bb4adeb7c67698f70839adec842ec10c7ceb" 2650 - dependencies = [ 2651 - "async-compression", 2652 - "base64 0.22.1", 2653 - "bytes", 2654 - "encoding_rs", 2655 - "futures-core", 2656 - "futures-util", 2657 - "h2 0.4.12", 2658 - "http 1.3.1", 2659 - "http-body", 2660 - "http-body-util", 2661 - "hyper", 2662 - "hyper-rustls", 2663 - "hyper-tls", 2664 - "hyper-util", 2665 - "js-sys", 2666 - "log", 2667 - "mime", 2668 - "native-tls", 2669 - "percent-encoding", 2670 - "pin-project-lite", 2671 - "rustls-pki-types", 2672 - "serde", 2673 - "serde_json", 2674 - "serde_urlencoded", 2675 - "sync_wrapper", 2676 - "tokio", 2677 - "tokio-native-tls", 2678 - "tokio-util", 2679 - "tower", 2680 - "tower-http", 2681 - "tower-service", 2682 - "url", 2683 - "wasm-bindgen", 2684 - "wasm-bindgen-futures", 2685 - "web-sys", 2686 - ] 2687 - 2688 - [[package]] 2689 - name = "resolv-conf" 2690 - version = "0.7.5" 2691 - source = "registry+https://github.com/rust-lang/crates.io-index" 2692 - checksum = "6b3789b30bd25ba102de4beabd95d21ac45b69b1be7d14522bab988c526d6799" 2693 - 2694 - [[package]] 2695 - name = "rfc6979" 2696 - version = "0.4.0" 2697 - source = "registry+https://github.com/rust-lang/crates.io-index" 2698 - checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" 2699 - dependencies = [ 2700 - "hmac", 2701 - "subtle", 2702 - ] 2703 - 2704 - [[package]] 2705 - name = "ring" 2706 - version = "0.17.14" 2707 - source = "registry+https://github.com/rust-lang/crates.io-index" 2708 - checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" 2709 - dependencies = [ 2710 - "cc", 2711 - "cfg-if", 2712 - "getrandom 0.2.16", 2713 - "libc", 2714 - "untrusted", 2715 - "windows-sys 0.52.0", 2716 - ] 2717 - 2718 - [[package]] 2719 - name = "rocketman" 2720 - version = "0.2.5" 2721 - source = "registry+https://github.com/rust-lang/crates.io-index" 2722 - checksum = "90cfc4ee9daf6e9d0ee217b9709aa3bd6c921e6926aa15c6ff5ba9162c2c649a" 2723 - dependencies = [ 2724 - "anyhow", 2725 - "async-trait", 2726 - "bon", 2727 - "derive_builder", 2728 - "flume", 2729 - "futures-util", 2730 - "metrics", 2731 - "rand 0.8.5", 2732 - "serde", 2733 - "serde_json", 2734 - "tokio", 2735 - "tokio-tungstenite", 2736 - "tracing", 2737 - "tracing-subscriber", 2738 - "url", 2739 - "zstd", 2740 - ] 2741 - 2742 - [[package]] 2743 - name = "rustc-demangle" 2744 - version = "0.1.26" 2745 - source = "registry+https://github.com/rust-lang/crates.io-index" 2746 - checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" 2747 - 2748 - [[package]] 2749 - name = "rustc_version" 2750 - version = "0.4.1" 2751 - source = "registry+https://github.com/rust-lang/crates.io-index" 2752 - checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" 2753 - dependencies = [ 2754 - "semver", 2755 - ] 2756 - 2757 - [[package]] 2758 - name = "rustix" 2759 - version = "1.1.2" 2760 - source = "registry+https://github.com/rust-lang/crates.io-index" 2761 - checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" 2762 - dependencies = [ 2763 - "bitflags", 2764 - "errno", 2765 - "libc", 2766 - "linux-raw-sys", 2767 - "windows-sys 0.61.1", 2768 - ] 2769 - 2770 - [[package]] 2771 - name = "rustls" 2772 - version = "0.21.12" 2773 - source = "registry+https://github.com/rust-lang/crates.io-index" 2774 - checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" 2775 - dependencies = [ 2776 - "log", 2777 - "ring", 2778 - "rustls-webpki 0.101.7", 2779 - "sct", 2780 - ] 2781 - 2782 - [[package]] 2783 - name = "rustls" 2784 - version = "0.23.31" 2785 - source = "registry+https://github.com/rust-lang/crates.io-index" 2786 - checksum = "c0ebcbd2f03de0fc1122ad9bb24b127a5a6cd51d72604a3f3c50ac459762b6cc" 2787 - dependencies = [ 2788 - "once_cell", 2789 - "rustls-pki-types", 2790 - "rustls-webpki 0.103.4", 2791 - "subtle", 2792 - "zeroize", 2793 - ] 2794 - 2795 - [[package]] 2796 - name = "rustls-native-certs" 2797 - version = "0.6.3" 2798 - source = "registry+https://github.com/rust-lang/crates.io-index" 2799 - checksum = "a9aace74cb666635c918e9c12bc0d348266037aa8eb599b5cba565709a8dff00" 2800 - dependencies = [ 2801 - "openssl-probe", 2802 - "rustls-pemfile", 2803 - "schannel", 2804 - "security-framework", 2805 - ] 2806 - 2807 - [[package]] 2808 - name = "rustls-pemfile" 2809 - version = "1.0.4" 2810 - source = "registry+https://github.com/rust-lang/crates.io-index" 2811 - checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" 2812 - dependencies = [ 2813 - "base64 0.21.7", 2814 - ] 2815 - 2816 - [[package]] 2817 - name = "rustls-pki-types" 2818 - version = "1.12.0" 2819 - source = "registry+https://github.com/rust-lang/crates.io-index" 2820 - checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79" 2821 - dependencies = [ 2822 - "zeroize", 2823 - ] 2824 - 2825 - [[package]] 2826 - name = "rustls-webpki" 2827 - version = "0.101.7" 2828 - source = "registry+https://github.com/rust-lang/crates.io-index" 2829 - checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" 2830 - dependencies = [ 2831 - "ring", 2832 - "untrusted", 2833 - ] 2834 - 2835 - [[package]] 2836 - name = "rustls-webpki" 2837 - version = "0.103.4" 2838 - source = "registry+https://github.com/rust-lang/crates.io-index" 2839 - checksum = "0a17884ae0c1b773f1ccd2bd4a8c72f16da897310a98b0e84bf349ad5ead92fc" 2840 - dependencies = [ 2841 - "ring", 2842 - "rustls-pki-types", 2843 - "untrusted", 2844 - ] 2845 - 2846 - [[package]] 2847 - name = "rustversion" 2848 - version = "1.0.22" 2849 - source = "registry+https://github.com/rust-lang/crates.io-index" 2850 - checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" 2851 - 2852 - [[package]] 2853 - name = "ryu" 2854 - version = "1.0.20" 2855 - source = "registry+https://github.com/rust-lang/crates.io-index" 2856 - checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" 2857 - 2858 - [[package]] 2859 - name = "schannel" 2860 - version = "0.1.28" 2861 - source = "registry+https://github.com/rust-lang/crates.io-index" 2862 - checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" 2863 - dependencies = [ 2864 - "windows-sys 0.61.1", 2865 - ] 2866 - 2867 - [[package]] 2868 - name = "scopeguard" 2869 - version = "1.2.0" 2870 - source = "registry+https://github.com/rust-lang/crates.io-index" 2871 - checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" 2872 - 2873 - [[package]] 2874 - name = "sct" 2875 - version = "0.7.1" 2876 - source = "registry+https://github.com/rust-lang/crates.io-index" 2877 - checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" 2878 - dependencies = [ 2879 - "ring", 2880 - "untrusted", 2881 - ] 2882 - 2883 - [[package]] 2884 - name = "sec1" 2885 - version = "0.7.3" 2886 - source = "registry+https://github.com/rust-lang/crates.io-index" 2887 - checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" 2888 - dependencies = [ 2889 - "base16ct", 2890 - "der", 2891 - "generic-array", 2892 - "subtle", 2893 - "zeroize", 2894 - ] 2895 - 2896 - [[package]] 2897 - name = "security-framework" 2898 - version = "2.11.1" 2899 - source = "registry+https://github.com/rust-lang/crates.io-index" 2900 - checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" 2901 - dependencies = [ 2902 - "bitflags", 2903 - "core-foundation", 2904 - "core-foundation-sys", 2905 - "libc", 2906 - "security-framework-sys", 2907 - ] 2908 - 2909 - [[package]] 2910 - name = "security-framework-sys" 2911 - version = "2.15.0" 2912 - source = "registry+https://github.com/rust-lang/crates.io-index" 2913 - checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" 2914 - dependencies = [ 2915 - "core-foundation-sys", 2916 - "libc", 2917 - ] 2918 - 2919 - [[package]] 2920 - name = "semver" 2921 - version = "1.0.27" 2922 - source = "registry+https://github.com/rust-lang/crates.io-index" 2923 - checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" 2924 - 2925 - [[package]] 2926 - name = "serde" 2927 - version = "1.0.228" 2928 - source = "registry+https://github.com/rust-lang/crates.io-index" 2929 - checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" 2930 - dependencies = [ 2931 - "serde_core", 2932 - "serde_derive", 2933 - ] 2934 - 2935 - [[package]] 2936 - name = "serde_bytes" 2937 - version = "0.11.19" 2938 - source = "registry+https://github.com/rust-lang/crates.io-index" 2939 - checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" 2940 - dependencies = [ 2941 - "serde", 2942 - "serde_core", 2943 - ] 2944 - 2945 - [[package]] 2946 - name = "serde_core" 2947 - version = "1.0.228" 2948 - source = "registry+https://github.com/rust-lang/crates.io-index" 2949 - checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" 2950 - dependencies = [ 2951 - "serde_derive", 2952 - ] 2953 - 2954 - [[package]] 2955 - name = "serde_derive" 2956 - version = "1.0.228" 2957 - source = "registry+https://github.com/rust-lang/crates.io-index" 2958 - checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" 2959 - dependencies = [ 2960 - "proc-macro2", 2961 - "quote", 2962 - "syn 2.0.106", 2963 - ] 2964 - 2965 - [[package]] 2966 - name = "serde_html_form" 2967 - version = "0.2.8" 2968 - source = "registry+https://github.com/rust-lang/crates.io-index" 2969 - checksum = "b2f2d7ff8a2140333718bb329f5c40fc5f0865b84c426183ce14c97d2ab8154f" 2970 - dependencies = [ 2971 - "form_urlencoded", 2972 - "indexmap", 2973 - "itoa", 2974 - "ryu", 2975 - "serde_core", 2976 - ] 2977 - 2978 - [[package]] 2979 - name = "serde_json" 2980 - version = "1.0.145" 2981 - source = "registry+https://github.com/rust-lang/crates.io-index" 2982 - checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" 2983 - dependencies = [ 2984 - "itoa", 2985 - "memchr", 2986 - "ryu", 2987 - "serde", 2988 - "serde_core", 2989 - ] 2990 - 2991 - [[package]] 2992 - name = "serde_urlencoded" 2993 - version = "0.7.1" 2994 - source = "registry+https://github.com/rust-lang/crates.io-index" 2995 - checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" 2996 - dependencies = [ 2997 - "form_urlencoded", 2998 - "itoa", 2999 - "ryu", 3000 - "serde", 3001 - ] 3002 - 3003 - [[package]] 3004 - name = "sha1" 3005 - version = "0.10.6" 3006 - source = "registry+https://github.com/rust-lang/crates.io-index" 3007 - checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" 3008 - dependencies = [ 3009 - "cfg-if", 3010 - "cpufeatures", 3011 - "digest", 3012 - ] 3013 - 3014 - [[package]] 3015 - name = "sha2" 3016 - version = "0.10.9" 3017 - source = "registry+https://github.com/rust-lang/crates.io-index" 3018 - checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" 3019 - dependencies = [ 3020 - "cfg-if", 3021 - "cpufeatures", 3022 - "digest", 3023 - ] 3024 - 3025 - [[package]] 3026 - name = "sharded-slab" 3027 - version = "0.1.7" 3028 - source = "registry+https://github.com/rust-lang/crates.io-index" 3029 - checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" 3030 - dependencies = [ 3031 - "lazy_static", 3032 - ] 3033 - 3034 - [[package]] 3035 - name = "shlex" 3036 - version = "1.3.0" 3037 - source = "registry+https://github.com/rust-lang/crates.io-index" 3038 - checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" 3039 - 3040 - [[package]] 3041 - name = "signal-hook-registry" 3042 - version = "1.4.6" 3043 - source = "registry+https://github.com/rust-lang/crates.io-index" 3044 - checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b" 3045 - dependencies = [ 3046 - "libc", 3047 - ] 3048 - 3049 - [[package]] 3050 - name = "signature" 3051 - version = "2.2.0" 3052 - source = "registry+https://github.com/rust-lang/crates.io-index" 3053 - checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" 3054 - dependencies = [ 3055 - "digest", 3056 - "rand_core 0.6.4", 3057 - ] 3058 - 3059 - [[package]] 3060 - name = "simd-adler32" 3061 - version = "0.3.7" 3062 - source = "registry+https://github.com/rust-lang/crates.io-index" 3063 - checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" 3064 - 3065 - [[package]] 3066 - name = "slab" 3067 - version = "0.4.11" 3068 - source = "registry+https://github.com/rust-lang/crates.io-index" 3069 - checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" 3070 - 3071 - [[package]] 3072 - name = "smallvec" 3073 - version = "1.15.1" 3074 - source = "registry+https://github.com/rust-lang/crates.io-index" 3075 - checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" 3076 - 3077 - [[package]] 3078 - name = "socket2" 3079 - version = "0.5.10" 3080 - source = "registry+https://github.com/rust-lang/crates.io-index" 3081 - checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" 3082 - dependencies = [ 3083 - "libc", 3084 - "windows-sys 0.52.0", 3085 - ] 3086 - 3087 - [[package]] 3088 - name = "socket2" 3089 - version = "0.6.0" 3090 - source = "registry+https://github.com/rust-lang/crates.io-index" 3091 - checksum = "233504af464074f9d066d7b5416c5f9b894a5862a6506e306f7b816cdd6f1807" 3092 - dependencies = [ 3093 - "libc", 3094 - "windows-sys 0.59.0", 3095 - ] 3096 - 3097 - [[package]] 3098 - name = "spin" 3099 - version = "0.9.8" 3100 - source = "registry+https://github.com/rust-lang/crates.io-index" 3101 - checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" 3102 - dependencies = [ 3103 - "lock_api", 3104 - ] 3105 - 3106 - [[package]] 3107 - name = "stable_deref_trait" 3108 - version = "1.2.0" 3109 - source = "registry+https://github.com/rust-lang/crates.io-index" 3110 - checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" 3111 - 3112 - [[package]] 3113 - name = "strsim" 3114 - version = "0.11.1" 3115 - source = "registry+https://github.com/rust-lang/crates.io-index" 3116 - checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" 3117 - 3118 - [[package]] 3119 - name = "subtle" 3120 - version = "2.6.1" 3121 - source = "registry+https://github.com/rust-lang/crates.io-index" 3122 - checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" 3123 - 3124 - [[package]] 3125 - name = "syn" 3126 - version = "1.0.109" 3127 - source = "registry+https://github.com/rust-lang/crates.io-index" 3128 - checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" 3129 - dependencies = [ 3130 - "proc-macro2", 3131 - "quote", 3132 - "unicode-ident", 3133 - ] 3134 - 3135 - [[package]] 3136 - name = "syn" 3137 - version = "2.0.106" 3138 - source = "registry+https://github.com/rust-lang/crates.io-index" 3139 - checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6" 3140 - dependencies = [ 3141 - "proc-macro2", 3142 - "quote", 3143 - "unicode-ident", 3144 - ] 3145 - 3146 - [[package]] 3147 - name = "sync_wrapper" 3148 - version = "1.0.2" 3149 - source = "registry+https://github.com/rust-lang/crates.io-index" 3150 - checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" 3151 - dependencies = [ 3152 - "futures-core", 3153 - ] 3154 - 3155 - [[package]] 3156 - name = "synstructure" 3157 - version = "0.13.2" 3158 - source = "registry+https://github.com/rust-lang/crates.io-index" 3159 - checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" 3160 - dependencies = [ 3161 - "proc-macro2", 3162 - "quote", 3163 - "syn 2.0.106", 3164 - ] 3165 - 3166 - [[package]] 3167 - name = "system-configuration" 3168 - version = "0.6.1" 3169 - source = "registry+https://github.com/rust-lang/crates.io-index" 3170 - checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" 3171 - dependencies = [ 3172 - "bitflags", 3173 - "core-foundation", 3174 - "system-configuration-sys", 3175 - ] 3176 - 3177 - [[package]] 3178 - name = "system-configuration-sys" 3179 - version = "0.6.0" 3180 - source = "registry+https://github.com/rust-lang/crates.io-index" 3181 - checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" 3182 - dependencies = [ 3183 - "core-foundation-sys", 3184 - "libc", 3185 - ] 3186 - 3187 - [[package]] 3188 - name = "tagptr" 3189 - version = "0.2.0" 3190 - source = "registry+https://github.com/rust-lang/crates.io-index" 3191 - checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" 3192 - 3193 - [[package]] 3194 - name = "tempfile" 3195 - version = "3.23.0" 3196 - source = "registry+https://github.com/rust-lang/crates.io-index" 3197 - checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" 3198 - dependencies = [ 3199 - "fastrand", 3200 - "getrandom 0.3.3", 3201 - "once_cell", 3202 - "rustix", 3203 - "windows-sys 0.61.1", 3204 - ] 3205 - 3206 - [[package]] 3207 - name = "thiserror" 3208 - version = "1.0.69" 3209 - source = "registry+https://github.com/rust-lang/crates.io-index" 3210 - checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" 3211 - dependencies = [ 3212 - "thiserror-impl", 3213 - ] 3214 - 3215 - [[package]] 3216 - name = "thiserror-impl" 3217 - version = "1.0.69" 3218 - source = "registry+https://github.com/rust-lang/crates.io-index" 3219 - checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" 3220 - dependencies = [ 3221 - "proc-macro2", 3222 - "quote", 3223 - "syn 2.0.106", 3224 - ] 3225 - 3226 - [[package]] 3227 - name = "thread_local" 3228 - version = "1.1.9" 3229 - source = "registry+https://github.com/rust-lang/crates.io-index" 3230 - checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" 3231 - dependencies = [ 3232 - "cfg-if", 3233 - ] 3234 - 3235 - [[package]] 3236 - name = "time" 3237 - version = "0.3.44" 3238 - source = "registry+https://github.com/rust-lang/crates.io-index" 3239 - checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" 3240 - dependencies = [ 3241 - "deranged", 3242 - "itoa", 3243 - "num-conv", 3244 - "powerfmt", 3245 - "serde", 3246 - "time-core", 3247 - "time-macros", 3248 - ] 3249 - 3250 - [[package]] 3251 - name = "time-core" 3252 - version = "0.1.6" 3253 - source = "registry+https://github.com/rust-lang/crates.io-index" 3254 - checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" 3255 - 3256 - [[package]] 3257 - name = "time-macros" 3258 - version = "0.2.24" 3259 - source = "registry+https://github.com/rust-lang/crates.io-index" 3260 - checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" 3261 - dependencies = [ 3262 - "num-conv", 3263 - "time-core", 3264 - ] 3265 - 3266 - [[package]] 3267 - name = "tinystr" 3268 - version = "0.8.1" 3269 - source = "registry+https://github.com/rust-lang/crates.io-index" 3270 - checksum = "5d4f6d1145dcb577acf783d4e601bc1d76a13337bb54e6233add580b07344c8b" 3271 - dependencies = [ 3272 - "displaydoc", 3273 - "zerovec", 3274 - ] 3275 - 3276 - [[package]] 3277 - name = "tinyvec" 3278 - version = "1.10.0" 3279 - source = "registry+https://github.com/rust-lang/crates.io-index" 3280 - checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" 3281 - dependencies = [ 3282 - "tinyvec_macros", 3283 - ] 3284 - 3285 - [[package]] 3286 - name = "tinyvec_macros" 3287 - version = "0.1.1" 3288 - source = "registry+https://github.com/rust-lang/crates.io-index" 3289 - checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" 3290 - 3291 - [[package]] 3292 - name = "tokio" 3293 - version = "1.47.1" 3294 - source = "registry+https://github.com/rust-lang/crates.io-index" 3295 - checksum = "89e49afdadebb872d3145a5638b59eb0691ea23e46ca484037cfab3b76b95038" 3296 - dependencies = [ 3297 - "backtrace", 3298 - "bytes", 3299 - "io-uring", 3300 - "libc", 3301 - "mio", 3302 - "parking_lot", 3303 - "pin-project-lite", 3304 - "signal-hook-registry", 3305 - "slab", 3306 - "socket2 0.6.0", 3307 - "tokio-macros", 3308 - "windows-sys 0.59.0", 3309 - ] 3310 - 3311 - [[package]] 3312 - name = "tokio-macros" 3313 - version = "2.5.0" 3314 - source = "registry+https://github.com/rust-lang/crates.io-index" 3315 - checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" 3316 - dependencies = [ 3317 - "proc-macro2", 3318 - "quote", 3319 - "syn 2.0.106", 3320 - ] 3321 - 3322 - [[package]] 3323 - name = "tokio-native-tls" 3324 - version = "0.3.1" 3325 - source = "registry+https://github.com/rust-lang/crates.io-index" 3326 - checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" 3327 - dependencies = [ 3328 - "native-tls", 3329 - "tokio", 3330 - ] 3331 - 3332 - [[package]] 3333 - name = "tokio-rustls" 3334 - version = "0.24.1" 3335 - source = "registry+https://github.com/rust-lang/crates.io-index" 3336 - checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" 3337 - dependencies = [ 3338 - "rustls 0.21.12", 3339 - "tokio", 3340 - ] 3341 - 3342 - [[package]] 3343 - name = "tokio-rustls" 3344 - version = "0.26.2" 3345 - source = "registry+https://github.com/rust-lang/crates.io-index" 3346 - checksum = "8e727b36a1a0e8b74c376ac2211e40c2c8af09fb4013c60d910495810f008e9b" 3347 - dependencies = [ 3348 - "rustls 0.23.31", 3349 - "tokio", 3350 - ] 3351 - 3352 - [[package]] 3353 - name = "tokio-tungstenite" 3354 - version = "0.20.1" 3355 - source = "registry+https://github.com/rust-lang/crates.io-index" 3356 - checksum = "212d5dcb2a1ce06d81107c3d0ffa3121fe974b73f068c8282cb1c32328113b6c" 3357 - dependencies = [ 3358 - "futures-util", 3359 - "log", 3360 - "rustls 0.21.12", 3361 - "rustls-native-certs", 3362 - "tokio", 3363 - "tokio-rustls 0.24.1", 3364 - "tungstenite", 3365 - "webpki-roots", 3366 - ] 3367 - 3368 - [[package]] 3369 - name = "tokio-util" 3370 - version = "0.7.16" 3371 - source = "registry+https://github.com/rust-lang/crates.io-index" 3372 - checksum = "14307c986784f72ef81c89db7d9e28d6ac26d16213b109ea501696195e6e3ce5" 3373 - dependencies = [ 3374 - "bytes", 3375 - "futures-core", 3376 - "futures-sink", 3377 - "pin-project-lite", 3378 - "tokio", 3379 - ] 3380 - 3381 - [[package]] 3382 - name = "tower" 3383 - version = "0.5.2" 3384 - source = "registry+https://github.com/rust-lang/crates.io-index" 3385 - checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" 3386 - dependencies = [ 3387 - "futures-core", 3388 - "futures-util", 3389 - "pin-project-lite", 3390 - "sync_wrapper", 3391 - "tokio", 3392 - "tower-layer", 3393 - "tower-service", 3394 - ] 3395 - 3396 - [[package]] 3397 - name = "tower-http" 3398 - version = "0.6.6" 3399 - source = "registry+https://github.com/rust-lang/crates.io-index" 3400 - checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" 3401 - dependencies = [ 3402 - "bitflags", 3403 - "bytes", 3404 - "futures-util", 3405 - "http 1.3.1", 3406 - "http-body", 3407 - "iri-string", 3408 - "pin-project-lite", 3409 - "tower", 3410 - "tower-layer", 3411 - "tower-service", 3412 - ] 3413 - 3414 - [[package]] 3415 - name = "tower-layer" 3416 - version = "0.3.3" 3417 - source = "registry+https://github.com/rust-lang/crates.io-index" 3418 - checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" 3419 - 3420 - [[package]] 3421 - name = "tower-service" 3422 - version = "0.3.3" 3423 - source = "registry+https://github.com/rust-lang/crates.io-index" 3424 - checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" 3425 - 3426 - [[package]] 3427 - name = "tracing" 3428 - version = "0.1.41" 3429 - source = "registry+https://github.com/rust-lang/crates.io-index" 3430 - checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" 3431 - dependencies = [ 3432 - "log", 3433 - "pin-project-lite", 3434 - "tracing-attributes", 3435 - "tracing-core", 3436 - ] 3437 - 3438 - [[package]] 3439 - name = "tracing-attributes" 3440 - version = "0.1.30" 3441 - source = "registry+https://github.com/rust-lang/crates.io-index" 3442 - checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" 3443 - dependencies = [ 3444 - "proc-macro2", 3445 - "quote", 3446 - "syn 2.0.106", 3447 - ] 3448 - 3449 - [[package]] 3450 - name = "tracing-core" 3451 - version = "0.1.34" 3452 - source = "registry+https://github.com/rust-lang/crates.io-index" 3453 - checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" 3454 - dependencies = [ 3455 - "once_cell", 3456 - "valuable", 3457 - ] 3458 - 3459 - [[package]] 3460 - name = "tracing-log" 3461 - version = "0.2.0" 3462 - source = "registry+https://github.com/rust-lang/crates.io-index" 3463 - checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" 3464 - dependencies = [ 3465 - "log", 3466 - "once_cell", 3467 - "tracing-core", 3468 - ] 3469 - 3470 - [[package]] 3471 - name = "tracing-subscriber" 3472 - version = "0.3.20" 3473 - source = "registry+https://github.com/rust-lang/crates.io-index" 3474 - checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" 3475 - dependencies = [ 3476 - "nu-ansi-term", 3477 - "sharded-slab", 3478 - "smallvec", 3479 - "thread_local", 3480 - "tracing-core", 3481 - "tracing-log", 3482 - ] 3483 - 3484 - [[package]] 3485 - name = "trait-variant" 3486 - version = "0.1.2" 3487 - source = "registry+https://github.com/rust-lang/crates.io-index" 3488 - checksum = "70977707304198400eb4835a78f6a9f928bf41bba420deb8fdb175cd965d77a7" 3489 - dependencies = [ 3490 - "proc-macro2", 3491 - "quote", 3492 - "syn 2.0.106", 3493 - ] 3494 - 3495 - [[package]] 3496 - name = "try-lock" 3497 - version = "0.2.5" 3498 - source = "registry+https://github.com/rust-lang/crates.io-index" 3499 - checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" 3500 - 3501 - [[package]] 3502 - name = "tungstenite" 3503 - version = "0.20.1" 3504 - source = "registry+https://github.com/rust-lang/crates.io-index" 3505 - checksum = "9e3dac10fd62eaf6617d3a904ae222845979aec67c615d1c842b4002c7666fb9" 3506 - dependencies = [ 3507 - "byteorder", 3508 - "bytes", 3509 - "data-encoding", 3510 - "http 0.2.12", 3511 - "httparse", 3512 - "log", 3513 - "rand 0.8.5", 3514 - "rustls 0.21.12", 3515 - "sha1", 3516 - "thiserror", 3517 - "url", 3518 - "utf-8", 3519 - ] 3520 - 3521 - [[package]] 3522 - name = "typenum" 3523 - version = "1.19.0" 3524 - source = "registry+https://github.com/rust-lang/crates.io-index" 3525 - checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" 3526 - 3527 - [[package]] 3528 - name = "unicase" 3529 - version = "2.8.1" 3530 - source = "registry+https://github.com/rust-lang/crates.io-index" 3531 - checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" 3532 - 3533 - [[package]] 3534 - name = "unicode-ident" 3535 - version = "1.0.19" 3536 - source = "registry+https://github.com/rust-lang/crates.io-index" 3537 - checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d" 3538 - 3539 - [[package]] 3540 - name = "unicode-xid" 3541 - version = "0.2.6" 3542 - source = "registry+https://github.com/rust-lang/crates.io-index" 3543 - checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" 3544 - 3545 - [[package]] 3546 - name = "universal-hash" 3547 - version = "0.5.1" 3548 - source = "registry+https://github.com/rust-lang/crates.io-index" 3549 - checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" 3550 - dependencies = [ 3551 - "crypto-common", 3552 - "subtle", 3553 - ] 3554 - 3555 - [[package]] 3556 - name = "unsigned-varint" 3557 - version = "0.8.0" 3558 - source = "registry+https://github.com/rust-lang/crates.io-index" 3559 - checksum = "eb066959b24b5196ae73cb057f45598450d2c5f71460e98c49b738086eff9c06" 3560 - 3561 - [[package]] 3562 - name = "untrusted" 3563 - version = "0.9.0" 3564 - source = "registry+https://github.com/rust-lang/crates.io-index" 3565 - checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" 3566 - 3567 - [[package]] 3568 - name = "url" 3569 - version = "2.5.7" 3570 - source = "registry+https://github.com/rust-lang/crates.io-index" 3571 - checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" 3572 - dependencies = [ 3573 - "form_urlencoded", 3574 - "idna", 3575 - "percent-encoding", 3576 - "serde", 3577 - ] 3578 - 3579 - [[package]] 3580 - name = "urlencoding" 3581 - version = "2.1.3" 3582 - source = "registry+https://github.com/rust-lang/crates.io-index" 3583 - checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" 3584 - 3585 - [[package]] 3586 - name = "utf-8" 3587 - version = "0.7.6" 3588 - source = "registry+https://github.com/rust-lang/crates.io-index" 3589 - checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" 3590 - 3591 - [[package]] 3592 - name = "utf8_iter" 3593 - version = "1.0.4" 3594 - source = "registry+https://github.com/rust-lang/crates.io-index" 3595 - checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" 3596 - 3597 - [[package]] 3598 - name = "utf8parse" 3599 - version = "0.2.2" 3600 - source = "registry+https://github.com/rust-lang/crates.io-index" 3601 - checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" 3602 - 3603 - [[package]] 3604 - name = "uuid" 3605 - version = "1.18.1" 3606 - source = "registry+https://github.com/rust-lang/crates.io-index" 3607 - checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" 3608 - dependencies = [ 3609 - "getrandom 0.3.3", 3610 - "js-sys", 3611 - "wasm-bindgen", 3612 - ] 3613 - 3614 - [[package]] 3615 - name = "v_htmlescape" 3616 - version = "0.15.8" 3617 - source = "registry+https://github.com/rust-lang/crates.io-index" 3618 - checksum = "4e8257fbc510f0a46eb602c10215901938b5c2a7d5e70fc11483b1d3c9b5b18c" 3619 - 3620 - [[package]] 3621 - name = "valuable" 3622 - version = "0.1.1" 3623 - source = "registry+https://github.com/rust-lang/crates.io-index" 3624 - checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" 3625 - 3626 - [[package]] 3627 - name = "vcpkg" 3628 - version = "0.2.15" 3629 - source = "registry+https://github.com/rust-lang/crates.io-index" 3630 - checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" 3631 - 3632 - [[package]] 3633 - name = "version_check" 3634 - version = "0.9.5" 3635 - source = "registry+https://github.com/rust-lang/crates.io-index" 3636 - checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" 3637 - 3638 - [[package]] 3639 - name = "want" 3640 - version = "0.3.1" 3641 - source = "registry+https://github.com/rust-lang/crates.io-index" 3642 - checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" 3643 - dependencies = [ 3644 - "try-lock", 3645 - ] 3646 - 3647 - [[package]] 3648 - name = "wasi" 3649 - version = "0.11.1+wasi-snapshot-preview1" 3650 - source = "registry+https://github.com/rust-lang/crates.io-index" 3651 - checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" 3652 - 3653 - [[package]] 3654 - name = "wasi" 3655 - version = "0.14.7+wasi-0.2.4" 3656 - source = "registry+https://github.com/rust-lang/crates.io-index" 3657 - checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" 3658 - dependencies = [ 3659 - "wasip2", 3660 - ] 3661 - 3662 - [[package]] 3663 - name = "wasip2" 3664 - version = "1.0.1+wasi-0.2.4" 3665 - source = "registry+https://github.com/rust-lang/crates.io-index" 3666 - checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" 3667 - dependencies = [ 3668 - "wit-bindgen", 3669 - ] 3670 - 3671 - [[package]] 3672 - name = "wasm-bindgen" 3673 - version = "0.2.104" 3674 - source = "registry+https://github.com/rust-lang/crates.io-index" 3675 - checksum = "c1da10c01ae9f1ae40cbfac0bac3b1e724b320abfcf52229f80b547c0d250e2d" 3676 - dependencies = [ 3677 - "cfg-if", 3678 - "once_cell", 3679 - "rustversion", 3680 - "wasm-bindgen-macro", 3681 - "wasm-bindgen-shared", 3682 - ] 3683 - 3684 - [[package]] 3685 - name = "wasm-bindgen-backend" 3686 - version = "0.2.104" 3687 - source = "registry+https://github.com/rust-lang/crates.io-index" 3688 - checksum = "671c9a5a66f49d8a47345ab942e2cb93c7d1d0339065d4f8139c486121b43b19" 3689 - dependencies = [ 3690 - "bumpalo", 3691 - "log", 3692 - "proc-macro2", 3693 - "quote", 3694 - "syn 2.0.106", 3695 - "wasm-bindgen-shared", 3696 - ] 3697 - 3698 - [[package]] 3699 - name = "wasm-bindgen-futures" 3700 - version = "0.4.54" 3701 - source = "registry+https://github.com/rust-lang/crates.io-index" 3702 - checksum = "7e038d41e478cc73bae0ff9b36c60cff1c98b8f38f8d7e8061e79ee63608ac5c" 3703 - dependencies = [ 3704 - "cfg-if", 3705 - "js-sys", 3706 - "once_cell", 3707 - "wasm-bindgen", 3708 - "web-sys", 3709 - ] 3710 - 3711 - [[package]] 3712 - name = "wasm-bindgen-macro" 3713 - version = "0.2.104" 3714 - source = "registry+https://github.com/rust-lang/crates.io-index" 3715 - checksum = "7ca60477e4c59f5f2986c50191cd972e3a50d8a95603bc9434501cf156a9a119" 3716 - dependencies = [ 3717 - "quote", 3718 - "wasm-bindgen-macro-support", 3719 - ] 3720 - 3721 - [[package]] 3722 - name = "wasm-bindgen-macro-support" 3723 - version = "0.2.104" 3724 - source = "registry+https://github.com/rust-lang/crates.io-index" 3725 - checksum = "9f07d2f20d4da7b26400c9f4a0511e6e0345b040694e8a75bd41d578fa4421d7" 3726 - dependencies = [ 3727 - "proc-macro2", 3728 - "quote", 3729 - "syn 2.0.106", 3730 - "wasm-bindgen-backend", 3731 - "wasm-bindgen-shared", 3732 - ] 3733 - 3734 - [[package]] 3735 - name = "wasm-bindgen-shared" 3736 - version = "0.2.104" 3737 - source = "registry+https://github.com/rust-lang/crates.io-index" 3738 - checksum = "bad67dc8b2a1a6e5448428adec4c3e84c43e561d8c9ee8a9e5aabeb193ec41d1" 3739 - dependencies = [ 3740 - "unicode-ident", 3741 - ] 3742 - 3743 - [[package]] 3744 - name = "web-sys" 3745 - version = "0.3.81" 3746 - source = "registry+https://github.com/rust-lang/crates.io-index" 3747 - checksum = "9367c417a924a74cae129e6a2ae3b47fabb1f8995595ab474029da749a8be120" 3748 - dependencies = [ 3749 - "js-sys", 3750 - "wasm-bindgen", 3751 - ] 3752 - 3753 - [[package]] 3754 - name = "web-time" 3755 - version = "1.1.0" 3756 - source = "registry+https://github.com/rust-lang/crates.io-index" 3757 - checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" 3758 - dependencies = [ 3759 - "js-sys", 3760 - "wasm-bindgen", 3761 - ] 3762 - 3763 - [[package]] 3764 - name = "webpki-roots" 3765 - version = "0.25.4" 3766 - source = "registry+https://github.com/rust-lang/crates.io-index" 3767 - checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" 3768 - 3769 - [[package]] 3770 - name = "widestring" 3771 - version = "1.2.0" 3772 - source = "registry+https://github.com/rust-lang/crates.io-index" 3773 - checksum = "dd7cf3379ca1aac9eea11fba24fd7e315d621f8dfe35c8d7d2be8b793726e07d" 3774 - 3775 - [[package]] 3776 - name = "windows-core" 3777 - version = "0.62.1" 3778 - source = "registry+https://github.com/rust-lang/crates.io-index" 3779 - checksum = "6844ee5416b285084d3d3fffd743b925a6c9385455f64f6d4fa3031c4c2749a9" 3780 - dependencies = [ 3781 - "windows-implement", 3782 - "windows-interface", 3783 - "windows-link 0.2.0", 3784 - "windows-result 0.4.0", 3785 - "windows-strings 0.5.0", 3786 - ] 3787 - 3788 - [[package]] 3789 - name = "windows-implement" 3790 - version = "0.60.1" 3791 - source = "registry+https://github.com/rust-lang/crates.io-index" 3792 - checksum = "edb307e42a74fb6de9bf3a02d9712678b22399c87e6fa869d6dfcd8c1b7754e0" 3793 - dependencies = [ 3794 - "proc-macro2", 3795 - "quote", 3796 - "syn 2.0.106", 3797 - ] 3798 - 3799 - [[package]] 3800 - name = "windows-interface" 3801 - version = "0.59.2" 3802 - source = "registry+https://github.com/rust-lang/crates.io-index" 3803 - checksum = "c0abd1ddbc6964ac14db11c7213d6532ef34bd9aa042c2e5935f59d7908b46a5" 3804 - dependencies = [ 3805 - "proc-macro2", 3806 - "quote", 3807 - "syn 2.0.106", 3808 - ] 3809 - 3810 - [[package]] 3811 - name = "windows-link" 3812 - version = "0.1.3" 3813 - source = "registry+https://github.com/rust-lang/crates.io-index" 3814 - checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" 3815 - 3816 - [[package]] 3817 - name = "windows-link" 3818 - version = "0.2.0" 3819 - source = "registry+https://github.com/rust-lang/crates.io-index" 3820 - checksum = "45e46c0661abb7180e7b9c281db115305d49ca1709ab8242adf09666d2173c65" 3821 - 3822 - [[package]] 3823 - name = "windows-registry" 3824 - version = "0.5.3" 3825 - source = "registry+https://github.com/rust-lang/crates.io-index" 3826 - checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" 3827 - dependencies = [ 3828 - "windows-link 0.1.3", 3829 - "windows-result 0.3.4", 3830 - "windows-strings 0.4.2", 3831 - ] 3832 - 3833 - [[package]] 3834 - name = "windows-result" 3835 - version = "0.3.4" 3836 - source = "registry+https://github.com/rust-lang/crates.io-index" 3837 - checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" 3838 - dependencies = [ 3839 - "windows-link 0.1.3", 3840 - ] 3841 - 3842 - [[package]] 3843 - name = "windows-result" 3844 - version = "0.4.0" 3845 - source = "registry+https://github.com/rust-lang/crates.io-index" 3846 - checksum = "7084dcc306f89883455a206237404d3eaf961e5bd7e0f312f7c91f57eb44167f" 3847 - dependencies = [ 3848 - "windows-link 0.2.0", 3849 - ] 3850 - 3851 - [[package]] 3852 - name = "windows-strings" 3853 - version = "0.4.2" 3854 - source = "registry+https://github.com/rust-lang/crates.io-index" 3855 - checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" 3856 - dependencies = [ 3857 - "windows-link 0.1.3", 3858 - ] 3859 - 3860 - [[package]] 3861 - name = "windows-strings" 3862 - version = "0.5.0" 3863 - source = "registry+https://github.com/rust-lang/crates.io-index" 3864 - checksum = "7218c655a553b0bed4426cf54b20d7ba363ef543b52d515b3e48d7fd55318dda" 3865 - dependencies = [ 3866 - "windows-link 0.2.0", 3867 - ] 3868 - 3869 - [[package]] 3870 - name = "windows-sys" 3871 - version = "0.48.0" 3872 - source = "registry+https://github.com/rust-lang/crates.io-index" 3873 - checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" 3874 - dependencies = [ 3875 - "windows-targets 0.48.5", 3876 - ] 3877 - 3878 - [[package]] 3879 - name = "windows-sys" 3880 - version = "0.52.0" 3881 - source = "registry+https://github.com/rust-lang/crates.io-index" 3882 - checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" 3883 - dependencies = [ 3884 - "windows-targets 0.52.6", 3885 - ] 3886 - 3887 - [[package]] 3888 - name = "windows-sys" 3889 - version = "0.59.0" 3890 - source = "registry+https://github.com/rust-lang/crates.io-index" 3891 - checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" 3892 - dependencies = [ 3893 - "windows-targets 0.52.6", 3894 - ] 3895 - 3896 - [[package]] 3897 - name = "windows-sys" 3898 - version = "0.60.2" 3899 - source = "registry+https://github.com/rust-lang/crates.io-index" 3900 - checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" 3901 - dependencies = [ 3902 - "windows-targets 0.53.4", 3903 - ] 3904 - 3905 - [[package]] 3906 - name = "windows-sys" 3907 - version = "0.61.1" 3908 - source = "registry+https://github.com/rust-lang/crates.io-index" 3909 - checksum = "6f109e41dd4a3c848907eb83d5a42ea98b3769495597450cf6d153507b166f0f" 3910 - dependencies = [ 3911 - "windows-link 0.2.0", 3912 - ] 3913 - 3914 - [[package]] 3915 - name = "windows-targets" 3916 - version = "0.48.5" 3917 - source = "registry+https://github.com/rust-lang/crates.io-index" 3918 - checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" 3919 - dependencies = [ 3920 - "windows_aarch64_gnullvm 0.48.5", 3921 - "windows_aarch64_msvc 0.48.5", 3922 - "windows_i686_gnu 0.48.5", 3923 - "windows_i686_msvc 0.48.5", 3924 - "windows_x86_64_gnu 0.48.5", 3925 - "windows_x86_64_gnullvm 0.48.5", 3926 - "windows_x86_64_msvc 0.48.5", 3927 - ] 3928 - 3929 - [[package]] 3930 - name = "windows-targets" 3931 - version = "0.52.6" 3932 - source = "registry+https://github.com/rust-lang/crates.io-index" 3933 - checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" 3934 - dependencies = [ 3935 - "windows_aarch64_gnullvm 0.52.6", 3936 - "windows_aarch64_msvc 0.52.6", 3937 - "windows_i686_gnu 0.52.6", 3938 - "windows_i686_gnullvm 0.52.6", 3939 - "windows_i686_msvc 0.52.6", 3940 - "windows_x86_64_gnu 0.52.6", 3941 - "windows_x86_64_gnullvm 0.52.6", 3942 - "windows_x86_64_msvc 0.52.6", 3943 - ] 3944 - 3945 - [[package]] 3946 - name = "windows-targets" 3947 - version = "0.53.4" 3948 - source = "registry+https://github.com/rust-lang/crates.io-index" 3949 - checksum = "2d42b7b7f66d2a06854650af09cfdf8713e427a439c97ad65a6375318033ac4b" 3950 - dependencies = [ 3951 - "windows-link 0.2.0", 3952 - "windows_aarch64_gnullvm 0.53.0", 3953 - "windows_aarch64_msvc 0.53.0", 3954 - "windows_i686_gnu 0.53.0", 3955 - "windows_i686_gnullvm 0.53.0", 3956 - "windows_i686_msvc 0.53.0", 3957 - "windows_x86_64_gnu 0.53.0", 3958 - "windows_x86_64_gnullvm 0.53.0", 3959 - "windows_x86_64_msvc 0.53.0", 3960 - ] 3961 - 3962 - [[package]] 3963 - name = "windows_aarch64_gnullvm" 3964 - version = "0.48.5" 3965 - source = "registry+https://github.com/rust-lang/crates.io-index" 3966 - checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" 3967 - 3968 - [[package]] 3969 - name = "windows_aarch64_gnullvm" 3970 - version = "0.52.6" 3971 - source = "registry+https://github.com/rust-lang/crates.io-index" 3972 - checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" 3973 - 3974 - [[package]] 3975 - name = "windows_aarch64_gnullvm" 3976 - version = "0.53.0" 3977 - source = "registry+https://github.com/rust-lang/crates.io-index" 3978 - checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" 3979 - 3980 - [[package]] 3981 - name = "windows_aarch64_msvc" 3982 - version = "0.48.5" 3983 - source = "registry+https://github.com/rust-lang/crates.io-index" 3984 - checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" 3985 - 3986 - [[package]] 3987 - name = "windows_aarch64_msvc" 3988 - version = "0.52.6" 3989 - source = "registry+https://github.com/rust-lang/crates.io-index" 3990 - checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" 3991 - 3992 - [[package]] 3993 - name = "windows_aarch64_msvc" 3994 - version = "0.53.0" 3995 - source = "registry+https://github.com/rust-lang/crates.io-index" 3996 - checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" 3997 - 3998 - [[package]] 3999 - name = "windows_i686_gnu" 4000 - version = "0.48.5" 4001 - source = "registry+https://github.com/rust-lang/crates.io-index" 4002 - checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" 4003 - 4004 - [[package]] 4005 - name = "windows_i686_gnu" 4006 - version = "0.52.6" 4007 - source = "registry+https://github.com/rust-lang/crates.io-index" 4008 - checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" 4009 - 4010 - [[package]] 4011 - name = "windows_i686_gnu" 4012 - version = "0.53.0" 4013 - source = "registry+https://github.com/rust-lang/crates.io-index" 4014 - checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" 4015 - 4016 - [[package]] 4017 - name = "windows_i686_gnullvm" 4018 - version = "0.52.6" 4019 - source = "registry+https://github.com/rust-lang/crates.io-index" 4020 - checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" 4021 - 4022 - [[package]] 4023 - name = "windows_i686_gnullvm" 4024 - version = "0.53.0" 4025 - source = "registry+https://github.com/rust-lang/crates.io-index" 4026 - checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" 4027 - 4028 - [[package]] 4029 - name = "windows_i686_msvc" 4030 - version = "0.48.5" 4031 - source = "registry+https://github.com/rust-lang/crates.io-index" 4032 - checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" 4033 - 4034 - [[package]] 4035 - name = "windows_i686_msvc" 4036 - version = "0.52.6" 4037 - source = "registry+https://github.com/rust-lang/crates.io-index" 4038 - checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" 4039 - 4040 - [[package]] 4041 - name = "windows_i686_msvc" 4042 - version = "0.53.0" 4043 - source = "registry+https://github.com/rust-lang/crates.io-index" 4044 - checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" 4045 - 4046 - [[package]] 4047 - name = "windows_x86_64_gnu" 4048 - version = "0.48.5" 4049 - source = "registry+https://github.com/rust-lang/crates.io-index" 4050 - checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" 4051 - 4052 - [[package]] 4053 - name = "windows_x86_64_gnu" 4054 - version = "0.52.6" 4055 - source = "registry+https://github.com/rust-lang/crates.io-index" 4056 - checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" 4057 - 4058 - [[package]] 4059 - name = "windows_x86_64_gnu" 4060 - version = "0.53.0" 4061 - source = "registry+https://github.com/rust-lang/crates.io-index" 4062 - checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" 4063 - 4064 - [[package]] 4065 - name = "windows_x86_64_gnullvm" 4066 - version = "0.48.5" 4067 - source = "registry+https://github.com/rust-lang/crates.io-index" 4068 - checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" 4069 - 4070 - [[package]] 4071 - name = "windows_x86_64_gnullvm" 4072 - version = "0.52.6" 4073 - source = "registry+https://github.com/rust-lang/crates.io-index" 4074 - checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" 4075 - 4076 - [[package]] 4077 - name = "windows_x86_64_gnullvm" 4078 - version = "0.53.0" 4079 - source = "registry+https://github.com/rust-lang/crates.io-index" 4080 - checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" 4081 - 4082 - [[package]] 4083 - name = "windows_x86_64_msvc" 4084 - version = "0.48.5" 4085 - source = "registry+https://github.com/rust-lang/crates.io-index" 4086 - checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" 4087 - 4088 - [[package]] 4089 - name = "windows_x86_64_msvc" 4090 - version = "0.52.6" 4091 - source = "registry+https://github.com/rust-lang/crates.io-index" 4092 - checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" 4093 - 4094 - [[package]] 4095 - name = "windows_x86_64_msvc" 4096 - version = "0.53.0" 4097 - source = "registry+https://github.com/rust-lang/crates.io-index" 4098 - checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" 4099 - 4100 - [[package]] 4101 - name = "winreg" 4102 - version = "0.50.0" 4103 - source = "registry+https://github.com/rust-lang/crates.io-index" 4104 - checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" 4105 - dependencies = [ 4106 - "cfg-if", 4107 - "windows-sys 0.48.0", 4108 - ] 4109 - 4110 - [[package]] 4111 - name = "wit-bindgen" 4112 - version = "0.46.0" 4113 - source = "registry+https://github.com/rust-lang/crates.io-index" 4114 - checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" 4115 - 4116 - [[package]] 4117 - name = "writeable" 4118 - version = "0.6.1" 4119 - source = "registry+https://github.com/rust-lang/crates.io-index" 4120 - checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" 4121 - 4122 - [[package]] 4123 - name = "yoke" 4124 - version = "0.8.0" 4125 - source = "registry+https://github.com/rust-lang/crates.io-index" 4126 - checksum = "5f41bb01b8226ef4bfd589436a297c53d118f65921786300e427be8d487695cc" 4127 - dependencies = [ 4128 - "serde", 4129 - "stable_deref_trait", 4130 - "yoke-derive", 4131 - "zerofrom", 4132 - ] 4133 - 4134 - [[package]] 4135 - name = "yoke-derive" 4136 - version = "0.8.0" 4137 - source = "registry+https://github.com/rust-lang/crates.io-index" 4138 - checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" 4139 - dependencies = [ 4140 - "proc-macro2", 4141 - "quote", 4142 - "syn 2.0.106", 4143 - "synstructure", 4144 - ] 4145 - 4146 - [[package]] 4147 - name = "zerocopy" 4148 - version = "0.8.27" 4149 - source = "registry+https://github.com/rust-lang/crates.io-index" 4150 - checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c" 4151 - dependencies = [ 4152 - "zerocopy-derive", 4153 - ] 4154 - 4155 - [[package]] 4156 - name = "zerocopy-derive" 4157 - version = "0.8.27" 4158 - source = "registry+https://github.com/rust-lang/crates.io-index" 4159 - checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" 4160 - dependencies = [ 4161 - "proc-macro2", 4162 - "quote", 4163 - "syn 2.0.106", 4164 - ] 4165 - 4166 - [[package]] 4167 - name = "zerofrom" 4168 - version = "0.1.6" 4169 - source = "registry+https://github.com/rust-lang/crates.io-index" 4170 - checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" 4171 - dependencies = [ 4172 - "zerofrom-derive", 4173 - ] 4174 - 4175 - [[package]] 4176 - name = "zerofrom-derive" 4177 - version = "0.1.6" 4178 - source = "registry+https://github.com/rust-lang/crates.io-index" 4179 - checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" 4180 - dependencies = [ 4181 - "proc-macro2", 4182 - "quote", 4183 - "syn 2.0.106", 4184 - "synstructure", 4185 - ] 4186 - 4187 - [[package]] 4188 - name = "zeroize" 4189 - version = "1.8.2" 4190 - source = "registry+https://github.com/rust-lang/crates.io-index" 4191 - checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" 4192 - dependencies = [ 4193 - "serde", 4194 - ] 4195 - 4196 - [[package]] 4197 - name = "zerotrie" 4198 - version = "0.2.2" 4199 - source = "registry+https://github.com/rust-lang/crates.io-index" 4200 - checksum = "36f0bbd478583f79edad978b407914f61b2972f5af6fa089686016be8f9af595" 4201 - dependencies = [ 4202 - "displaydoc", 4203 - "yoke", 4204 - "zerofrom", 4205 - ] 4206 - 4207 - [[package]] 4208 - name = "zerovec" 4209 - version = "0.11.4" 4210 - source = "registry+https://github.com/rust-lang/crates.io-index" 4211 - checksum = "e7aa2bd55086f1ab526693ecbe444205da57e25f4489879da80635a46d90e73b" 4212 - dependencies = [ 4213 - "yoke", 4214 - "zerofrom", 4215 - "zerovec-derive", 4216 - ] 4217 - 4218 - [[package]] 4219 - name = "zerovec-derive" 4220 - version = "0.11.1" 4221 - source = "registry+https://github.com/rust-lang/crates.io-index" 4222 - checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" 4223 - dependencies = [ 4224 - "proc-macro2", 4225 - "quote", 4226 - "syn 2.0.106", 4227 - ] 4228 - 4229 - [[package]] 4230 - name = "zstd" 4231 - version = "0.13.3" 4232 - source = "registry+https://github.com/rust-lang/crates.io-index" 4233 - checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" 4234 - dependencies = [ 4235 - "zstd-safe", 4236 - ] 4237 - 4238 - [[package]] 4239 - name = "zstd-safe" 4240 - version = "7.2.4" 4241 - source = "registry+https://github.com/rust-lang/crates.io-index" 4242 - checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" 4243 - dependencies = [ 4244 - "zstd-sys", 4245 - ] 4246 - 4247 - [[package]] 4248 - name = "zstd-sys" 4249 - version = "2.0.16+zstd.1.5.7" 4250 - source = "registry+https://github.com/rust-lang/crates.io-index" 4251 - checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" 4252 - dependencies = [ 4253 - "cc", 4254 - "pkg-config", 4255 - ]
-29
Cargo.toml
··· 1 - [package] 2 - name = "at-me" 3 - version = "0.1.0" 4 - edition = "2021" 5 - 6 - [dependencies] 7 - actix-web = "4.10" 8 - actix-files = "0.6" 9 - actix-session = { version = "0.10", features = ["cookie-session"] } 10 - atrium-api = "0.25" 11 - atrium-common = "0.1" 12 - atrium-oauth = "0.1.0" 13 - atrium-identity = "0.1.3" 14 - serde = { version = "1.0", features = ["derive"] } 15 - serde_json = "1.0" 16 - tokio = { version = "1", features = ["macros", "rt-multi-thread"] } 17 - hickory-resolver = "0.24" 18 - env_logger = "0.11" 19 - log = "0.4" 20 - reqwest = { version = "0.12", features = ["json"] } 21 - rocketman = "0.2.0" 22 - futures-util = "0.3" 23 - anyhow = "1.0" 24 - async-stream = "0.3" 25 - async-trait = "0.1" 26 - once_cell = "1.20" 27 - dashmap = "6.1" 28 - chrono = { version = "0.4", features = ["serde"] } 29 - urlencoding = "2.1"
-43
Dockerfile
··· 1 - # Build stage 2 - FROM rustlang/rust:nightly-slim AS builder 3 - 4 - # Install build dependencies 5 - RUN apt-get update && apt-get install -y \ 6 - pkg-config \ 7 - libssl-dev \ 8 - && rm -rf /var/lib/apt/lists/* 9 - 10 - WORKDIR /app 11 - 12 - # Copy manifests 13 - COPY Cargo.toml Cargo.lock ./ 14 - 15 - # Copy source code 16 - COPY src ./src 17 - COPY static ./static 18 - 19 - # Build for release 20 - RUN cargo build --release 21 - 22 - # Runtime stage 23 - FROM debian:bookworm-slim 24 - 25 - # Install runtime dependencies 26 - RUN apt-get update && apt-get install -y \ 27 - ca-certificates \ 28 - libssl3 \ 29 - && rm -rf /var/lib/apt/lists/* 30 - 31 - WORKDIR /app 32 - 33 - # Copy the built binary 34 - COPY --from=builder /app/target/release/at-me /app/at-me 35 - 36 - # Copy static files 37 - COPY --from=builder /app/static /app/static 38 - 39 - # Expose port 40 - EXPOSE 8080 41 - 42 - # Run the binary 43 - CMD ["./at-me"]
+16 -13
README.md
··· 2 2 3 3 an accessible visualization of how your atproto identity connects to atproto apps. 4 4 5 - [at-me.fly.dev](https://at-me.fly.dev/) 5 + [at-me.wisp.place](https://at-me.wisp.place/) 6 6 7 7 ## what is this 8 8 ··· 15 15 ## running locally 16 16 17 17 ```bash 18 - just dev 18 + bun install 19 + bun run dev 19 20 ``` 20 21 21 - this starts the app with hot reloading using `cargo watch`. visit http://localhost:8080 and sign in with any atproto handle. 22 + visit http://localhost:3030 to explore any atproto handle. 23 + 24 + ## commands 25 + 26 + - `bun run dev` - start dev server with hot reloading 27 + - `bun run build` - build for production (outputs to `dist/`) 28 + - `bun run preview` - preview production build locally 22 29 23 - to use a different port: 24 - ```bash 25 - just dev-port 3000 26 - ``` 30 + ## tech stack 27 31 28 - see the [justfile](./justfile) for all available commands: 29 - - `just build` - build release binary 30 - - `just test` - run tests 31 - - `just deploy` - deploy to fly.io 32 - - `just check` - run clippy 33 - - `just fmt` - format code 32 + - pure client-side javascript (no backend) 33 + - vite for development and building 34 + - direct atproto API calls (PDS, PLC directory, Bluesky AppView) 35 + - jetstream websocket for live firehose streaming 36 + - client-side MST (merkle search tree) visualization
bun.lockb

This is a binary file and will not be displayed.

-20
fly.toml
··· 1 - app = "at-me" 2 - primary_region = "ord" 3 - 4 - [build] 5 - 6 - [env] 7 - OAUTH_REDIRECT_URI = "https://at-me.fly.dev/oauth/callback" 8 - 9 - [http_service] 10 - internal_port = 8080 11 - force_https = true 12 - auto_stop_machines = "suspend" 13 - auto_start_machines = true 14 - min_machines_running = 0 15 - processes = ["app"] 16 - 17 - [[vm]] 18 - memory = "256mb" 19 - cpu_kind = "shared" 20 - cpus = 1
+70
index.html
··· 1 + <!DOCTYPE html> 2 + <html> 3 + <head> 4 + <meta charset="UTF-8"> 5 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 + <title>@me - explore your atproto identity</title> 7 + <link rel="icon" type="image/svg+xml" href="/favicon.svg"> 8 + 9 + <!-- Open Graph / Facebook --> 10 + <meta property="og:type" content="website"> 11 + <meta property="og:url" content="https://at-me.fly.dev/"> 12 + <meta property="og:title" content="@me - explore your atproto identity"> 13 + <meta property="og:description" content="visualize your decentralized identity and see what apps have stored data in your Personal Data Server"> 14 + <meta property="og:image" content="https://at-me.fly.dev/static/og-image.png"> 15 + 16 + <!-- Twitter --> 17 + <meta property="twitter:card" content="summary_large_image"> 18 + <meta property="twitter:url" content="https://at-me.fly.dev/"> 19 + <meta property="twitter:title" content="@me - explore your atproto identity"> 20 + <meta property="twitter:description" content="visualize your decentralized identity and see what apps have stored data in your Personal Data Server"> 21 + <meta property="twitter:image" content="https://at-me.fly.dev/static/og-image.png"> 22 + </head> 23 + <body> 24 + <div class="atmosphere" id="atmosphere"></div> 25 + 26 + <div class="container"> 27 + <div class="search-card"> 28 + <h1>@me</h1> 29 + <div class="subtitle">explore the atmosphere</div> 30 + <form id="searchForm" onsubmit="event.preventDefault(); handleSearch();"> 31 + <div class="input-wrapper"> 32 + <input type="text" id="handleInput" placeholder="enter any handle" autofocus autocomplete="off" autocapitalize="off" spellcheck="false"> 33 + <span class="search-spinner" id="searchSpinner" style="display: none;">...</span> 34 + <div class="autocomplete-results" id="autocompleteResults"></div> 35 + </div> 36 + <button type="submit">explore</button> 37 + </form> 38 + 39 + <div class="divider">try these</div> 40 + <div class="suggestions"> 41 + <button class="suggestion-btn" onclick="viewHandle('why.bsky.team')">why.bsky.team</button> 42 + <button class="suggestion-btn" onclick="viewHandle('baileytownsend.dev')">baileytownsend.dev</button> 43 + <button class="suggestion-btn" onclick="viewHandle('bad-example.com')">bad-example.com</button> 44 + <button class="suggestion-btn" onclick="viewHandle('void.comind.network')">void.comind.network</button> 45 + </div> 46 + 47 + <button type="button" class="info-toggle" onclick="toggleInfo()">what is this?</button> 48 + 49 + <div class="info-content" id="infoContent"> 50 + <div class="info-section"> 51 + <h3>you should own your data</h3> 52 + <p>today's social platforms own your data. built 10k followers? wrote years of posts? if you leave, you lose it all. you don't own your network - they do.</p> 53 + 54 + <h3>what if social media worked like email?</h3> 55 + <p>with email, you can switch from gmail to protonmail and keep your contacts. same idea here: your posts and followers live on your own server (called a <a href="https://atproto.com/guides/data-repos" target="_blank" rel="noopener noreferrer">Personal Data Server</a>). apps like <a href="https://bsky.app" target="_blank" rel="noopener noreferrer">bluesky</a> just connect to it.</p> 56 + 57 + <h3>see it in action</h3> 58 + <p>enter any handle above to see how <a href="https://atproto.com" target="_blank" rel="noopener noreferrer">atproto</a> stores their social data.</p> 59 + </div> 60 + </div> 61 + </div> 62 + </div> 63 + 64 + <div class="footer"> 65 + by <a href="https://bsky.app/profile/zzstoatzz.io" target="_blank" rel="noopener noreferrer">@zzstoatzz.io</a> 66 + </div> 67 + 68 + <script type="module" src="/src/landing/main.js"></script> 69 + </body> 70 + </html>
-33
justfile
··· 1 - # development tasks for at-me 2 - 3 - # run the app with hot reloading 4 - dev: 5 - cargo watch -w src -w static -x 'run' 6 - 7 - # run on specific port with hot reloading 8 - dev-port PORT='3000': 9 - PORT={{PORT}} cargo watch -w src -w static -x 'run' 10 - 11 - # build the project 12 - build: 13 - cargo build --release 14 - 15 - # run tests 16 - test: 17 - cargo test 18 - 19 - # deploy to fly.io 20 - deploy: 21 - fly deploy 22 - 23 - # check code with clippy 24 - check: 25 - cargo clippy 26 - 27 - # format code 28 - fmt: 29 - cargo fmt 30 - 31 - # delete all visit records from your PDS 32 - clean-up-my-visits: 33 - uv run scripts/delete_visits.py
+19
package.json
··· 1 + { 2 + "name": "at-me", 3 + "version": "1.0.0", 4 + "description": "ATProto PDS visualization tool", 5 + "type": "module", 6 + "scripts": { 7 + "dev": "vite", 8 + "build": "vite build", 9 + "preview": "vite preview" 10 + }, 11 + "dependencies": { 12 + "@atcute/client": "^2.0.0", 13 + "@atcute/oauth-browser-client": "^1.0.0", 14 + "@skyware/firehose": "^0.3.0" 15 + }, 16 + "devDependencies": { 17 + "vite": "^6.0.0" 18 + } 19 + }
+1
public/_redirects
··· 1 + /view /view.html 200
+12
public/oauth-client-metadata.json
··· 1 + { 2 + "client_id": "https://at-me.fly.dev/oauth-client-metadata.json", 3 + "client_name": "at-me", 4 + "client_uri": "https://at-me.fly.dev", 5 + "redirect_uris": ["https://at-me.fly.dev/app.html"], 6 + "scope": "atproto transition:generic", 7 + "grant_types": ["authorization_code", "refresh_token"], 8 + "response_types": ["code"], 9 + "token_endpoint_auth_method": "none", 10 + "application_type": "web", 11 + "dpop_bound_access_tokens": true 12 + }
-118
scripts/delete_visits.py
··· 1 - #!/usr/bin/env -S uv run --script --quiet 2 - # /// script 3 - # requires-python = ">=3.12" 4 - # dependencies = ["atproto", "pydantic-settings"] 5 - # /// 6 - """ 7 - Delete all app.at-me.visit records from your PDS. 8 - 9 - Usage: 10 - ./scripts/delete_visits.py 11 - 12 - Credentials are loaded from .env file (ATP_HANDLE, ATP_PASSWORD, ATP_PDS_URL). 13 - """ 14 - 15 - import os 16 - import warnings 17 - 18 - # suppress all pydantic warnings 19 - warnings.filterwarnings("ignore", message=".*default.*Field.*") 20 - 21 - from atproto import Client 22 - from pydantic_settings import BaseSettings, SettingsConfigDict 23 - 24 - 25 - class Settings(BaseSettings): 26 - """App settings loaded from environment variables""" 27 - 28 - model_config = SettingsConfigDict( 29 - env_file=os.environ.get("ENV_FILE", ".env"), extra="ignore" 30 - ) 31 - 32 - atp_handle: str 33 - atp_password: str 34 - atp_pds_url: str = "https://bsky.social" 35 - 36 - 37 - def delete_visit_records(client: Client) -> int: 38 - """Delete all app.at-me.visit records and return count deleted.""" 39 - deleted_count = 0 40 - cursor = None 41 - 42 - print("fetching visit records...") 43 - 44 - while True: 45 - try: 46 - params = { 47 - 'repo': client.me.did, 48 - 'collection': 'app.at-me.visit', 49 - 'limit': 100 50 - } 51 - if cursor: 52 - params['cursor'] = cursor 53 - 54 - response = client.com.atproto.repo.list_records(params=params) 55 - 56 - if not response.records: 57 - break 58 - 59 - print(f"found {len(response.records)} records...") 60 - 61 - for record in response.records: 62 - # Extract rkey from URI (at://did/collection/rkey) 63 - uri_parts = record.uri.split('/') 64 - rkey = uri_parts[-1] 65 - 66 - print(f"deleting record {rkey}...") 67 - try: 68 - client.com.atproto.repo.delete_record( 69 - data={ 70 - 'repo': client.me.did, 71 - 'collection': 'app.at-me.visit', 72 - 'rkey': rkey 73 - } 74 - ) 75 - deleted_count += 1 76 - except Exception as e: 77 - print(f" error deleting {rkey}: {e}") 78 - 79 - cursor = getattr(response, 'cursor', None) 80 - if not cursor: 81 - break 82 - 83 - except Exception as e: 84 - print(f"error listing records: {e}") 85 - break 86 - 87 - return deleted_count 88 - 89 - 90 - def main(): 91 - """Main function to delete visit records""" 92 - try: 93 - settings = Settings() # type: ignore 94 - except Exception as e: 95 - print(f"error loading settings (ensure .env file exists with ATP_HANDLE, ATP_PASSWORD, ATP_PDS_URL): {e}") 96 - return 97 - 98 - client = Client(base_url=settings.atp_pds_url) 99 - 100 - print(f"logging in as {settings.atp_handle}...") 101 - try: 102 - client.login(settings.atp_handle, settings.atp_password) 103 - except Exception as e: 104 - print(f"error logging in: {e}") 105 - return 106 - 107 - print(f"logged in as {client.me.handle}") 108 - 109 - deleted = delete_visit_records(client) 110 - 111 - if deleted > 0: 112 - print(f"\nsuccessfully deleted {deleted} visit record(s)") 113 - else: 114 - print("\nno visit records found") 115 - 116 - 117 - if __name__ == '__main__': 118 - main()
-32
src/constants.rs
··· 1 - use std::time::Duration; 2 - 3 - // API Endpoints 4 - pub const BSKY_API_RESOLVE_HANDLE: &str = 5 - "https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle"; 6 - pub const BSKY_API_GET_PROFILE: &str = "https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile"; 7 - pub const BSKY_API_SEARCH_ACTORS: &str = 8 - "https://public.api.bsky.app/xrpc/app.bsky.actor.searchActors"; 9 - pub const PLC_DIRECTORY: &str = "https://plc.directory"; 10 - 11 - // Server Configuration 12 - pub const DEFAULT_PORT: &str = "8080"; 13 - pub const DEFAULT_OAUTH_CALLBACK: &str = "http://127.0.0.1:8080/oauth/callback"; 14 - 15 - // Session Keys 16 - pub const SESSION_KEY_DID: &str = "did"; 17 - 18 - // Cache Configuration 19 - pub const CACHE_TTL_SECONDS: u64 = 3600; // 1 hour 20 - pub const CACHE_TTL: Duration = Duration::from_secs(CACHE_TTL_SECONDS); 21 - pub const HTTP_CACHE_CONTROL: &str = "public, max-age=3600"; 22 - 23 - // Firehose Configuration 24 - pub const FIREHOSE_RECONNECT_DELAY_SECONDS: u64 = 5; 25 - pub const FIREHOSE_BROADCAST_BUFFER: usize = 100; 26 - 27 - // Guestbook 28 - pub const GUESTBOOK_COLLECTION: &str = "app.at-me.visit"; 29 - 30 - // MST Configuration 31 - pub const MST_MAX_DEPTH: i32 = 5; 32 - pub const MST_FETCH_LIMIT: u32 = 100;
-210
src/firehose.rs
··· 1 - use anyhow::Result; 2 - use async_trait::async_trait; 3 - use log::{error, info}; 4 - use rocketman::{ 5 - connection::JetstreamConnection, 6 - ingestion::LexiconIngestor, 7 - options::JetstreamOptions, 8 - types::event::{Event, Operation}, 9 - }; 10 - use serde::{Deserialize, Serialize}; 11 - use serde_json::Value; 12 - use std::collections::HashMap; 13 - use std::sync::{Arc, Mutex}; 14 - use tokio::sync::broadcast; 15 - 16 - use crate::constants; 17 - 18 - /// Represents a firehose event that will be sent to the browser 19 - #[derive(Debug, Clone, Serialize, Deserialize)] 20 - #[serde(rename_all = "camelCase")] 21 - pub struct FirehoseEvent { 22 - pub did: String, 23 - pub action: String, // "create", "update", or "delete" 24 - pub collection: String, 25 - pub rkey: String, 26 - pub namespace: String, // e.g., "app.bsky" extracted from collection 27 - } 28 - 29 - /// Broadcaster for firehose events 30 - pub type FirehoseBroadcaster = Arc<broadcast::Sender<FirehoseEvent>>; 31 - 32 - /// Manager for DID-specific firehose connections 33 - pub type FirehoseManager = Arc<Mutex<HashMap<String, FirehoseBroadcaster>>>; 34 - 35 - /// A generic ingester that broadcasts all events 36 - struct BroadcastIngester { 37 - broadcaster: FirehoseBroadcaster, 38 - } 39 - 40 - #[async_trait] 41 - impl LexiconIngestor for BroadcastIngester { 42 - async fn ingest(&self, message: Event<Value>) -> Result<()> { 43 - // Only process commit events 44 - let Some(commit) = &message.commit else { 45 - return Ok(()); 46 - }; 47 - 48 - // Extract namespace from collection (e.g., "app.bsky.feed.post" -> "app.bsky") 49 - let collection_parts: Vec<&str> = commit.collection.split('.').collect(); 50 - let namespace = if collection_parts.len() >= 2 { 51 - format!("{}.{}", collection_parts[0], collection_parts[1]) 52 - } else { 53 - commit.collection.clone() 54 - }; 55 - 56 - let action = match commit.operation { 57 - Operation::Create => "create", 58 - Operation::Update => "update", 59 - Operation::Delete => "delete", 60 - }; 61 - 62 - let firehose_event = FirehoseEvent { 63 - did: message.did.clone(), 64 - action: action.to_string(), 65 - collection: commit.collection.clone(), 66 - rkey: commit.rkey.clone(), 67 - namespace: namespace.clone(), 68 - }; 69 - 70 - info!( 71 - "Received event: {} {} {} (namespace: {})", 72 - action, message.did, commit.collection, namespace 73 - ); 74 - 75 - // Broadcast the event (ignore if no receivers) 76 - match self.broadcaster.send(firehose_event) { 77 - Ok(receivers) => { 78 - info!("Broadcast to {} receivers", receivers); 79 - } 80 - Err(_) => { 81 - // No receivers, that's ok 82 - } 83 - } 84 - 85 - Ok(()) 86 - } 87 - } 88 - 89 - /// Create a new FirehoseManager 90 - pub fn create_firehose_manager() -> FirehoseManager { 91 - Arc::new(Mutex::new(HashMap::new())) 92 - } 93 - 94 - /// Get or create a firehose broadcaster for a specific DID 95 - pub async fn get_or_create_broadcaster( 96 - manager: &FirehoseManager, 97 - did: String, 98 - collections: Vec<String>, 99 - ) -> FirehoseBroadcaster { 100 - // Check if we already have a broadcaster for this DID 101 - { 102 - let broadcasters = manager.lock().unwrap(); 103 - if let Some(broadcaster) = broadcasters.get(&did) { 104 - info!("Reusing existing firehose connection for DID: {}", did); 105 - return broadcaster.clone(); 106 - } 107 - } 108 - 109 - info!( 110 - "Creating new firehose connection for DID: {} with {} collections", 111 - did, 112 - collections.len() 113 - ); 114 - 115 - // Create a broadcast channel with a buffer of 100 events 116 - let (tx, _rx) = broadcast::channel::<FirehoseEvent>(constants::FIREHOSE_BROADCAST_BUFFER); 117 - let broadcaster = Arc::new(tx); 118 - 119 - // Store in manager 120 - { 121 - let mut broadcasters = manager.lock().unwrap(); 122 - broadcasters.insert(did.clone(), broadcaster.clone()); 123 - } 124 - 125 - // Clone for the spawn 126 - let broadcaster_clone = broadcaster.clone(); 127 - let did_clone = did.clone(); 128 - 129 - tokio::spawn(async move { 130 - loop { 131 - info!("Starting Jetstream connection for DID: {}...", did_clone); 132 - 133 - // Configure Jetstream to receive events ONLY for this DID 134 - let opts = JetstreamOptions::builder() 135 - .wanted_dids(vec![did_clone.clone()]) 136 - .build(); 137 - let jetstream = JetstreamConnection::new(opts); 138 - 139 - let mut ingesters: HashMap<String, Box<dyn LexiconIngestor + Send + Sync>> = 140 - HashMap::new(); 141 - 142 - // Register ingesters for ALL collections from the user's repo 143 - for collection in &collections { 144 - ingesters.insert( 145 - collection.to_string(), 146 - Box::new(BroadcastIngester { 147 - broadcaster: broadcaster_clone.clone(), 148 - }), 149 - ); 150 - info!("Registered ingester for collection: {}", collection); 151 - } 152 - 153 - // Get channels 154 - let msg_rx = jetstream.get_msg_rx(); 155 - let reconnect_tx = jetstream.get_reconnect_tx(); 156 - 157 - // Cursor for tracking last processed message 158 - let cursor: Arc<Mutex<Option<u64>>> = Arc::new(Mutex::new(None)); 159 - let c_cursor = cursor.clone(); 160 - 161 - // Spawn task to process messages using proper handler 162 - tokio::spawn(async move { 163 - info!("Starting message processing loop for DID-filtered connection"); 164 - while let Ok(message) = msg_rx.recv_async().await { 165 - if let Err(e) = rocketman::handler::handle_message( 166 - message, 167 - &ingesters, 168 - reconnect_tx.clone(), 169 - c_cursor.clone(), 170 - ) 171 - .await 172 - { 173 - error!("Error processing message: {}", e); 174 - } 175 - } 176 - }); 177 - 178 - // Connect to Jetstream 179 - let failed = { 180 - let connect_result = jetstream.connect(cursor).await; 181 - if let Err(e) = connect_result { 182 - error!("Jetstream connection failed for DID {}: {}", did_clone, e); 183 - true 184 - } else { 185 - false 186 - } 187 - }; 188 - 189 - if failed { 190 - tokio::time::sleep(tokio::time::Duration::from_secs( 191 - constants::FIREHOSE_RECONNECT_DELAY_SECONDS, 192 - )) 193 - .await; 194 - continue; 195 - } 196 - 197 - info!( 198 - "Jetstream connection dropped for DID: {}, reconnecting in {} seconds...", 199 - did_clone, 200 - constants::FIREHOSE_RECONNECT_DELAY_SECONDS 201 - ); 202 - tokio::time::sleep(tokio::time::Duration::from_secs( 203 - constants::FIREHOSE_RECONNECT_DELAY_SECONDS, 204 - )) 205 - .await; 206 - } 207 - }); 208 - 209 - broadcaster 210 - }
+338
src/landing/main.js
··· 1 + // ============================================================================ 2 + // LANDING PAGE - Main Entry Point 3 + // ============================================================================ 4 + 5 + import './styles.css'; 6 + 7 + // ============================================================================ 8 + // ATPROTO UTILITIES 9 + // ============================================================================ 10 + 11 + // Resolve a handle to a DID 12 + async function resolveHandle(handle) { 13 + try { 14 + const response = await fetch(`https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`); 15 + if (response.ok) { 16 + const data = await response.json(); 17 + return data.did; 18 + } 19 + } catch (e) { 20 + console.error('Failed to resolve handle via Bluesky:', e); 21 + } 22 + return null; 23 + } 24 + 25 + // Get profile from Bluesky AppView 26 + async function getProfile(handleOrDid) { 27 + try { 28 + const response = await fetch(`https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(handleOrDid)}`); 29 + if (response.ok) { 30 + return await response.json(); 31 + } 32 + } catch (e) { 33 + console.error('Failed to get profile:', e); 34 + } 35 + return null; 36 + } 37 + 38 + // Search for handles using Bluesky's search 39 + async function searchHandles(query) { 40 + try { 41 + const response = await fetch(`https://public.api.bsky.app/xrpc/app.bsky.actor.searchActorsTypeahead?q=${encodeURIComponent(query)}&limit=8`); 42 + if (response.ok) { 43 + const data = await response.json(); 44 + return data.actors.map(actor => ({ 45 + handle: actor.handle, 46 + displayName: actor.displayName || actor.handle, 47 + avatarUrl: actor.avatar || null 48 + })); 49 + } 50 + } catch (e) { 51 + console.error('Search failed:', e); 52 + } 53 + return []; 54 + } 55 + 56 + // Get app avatar from the namespace's well-known profile 57 + async function getAppAvatar(namespace) { 58 + const domain = namespace.split('.').reverse().join('.'); 59 + try { 60 + const profile = await getProfile(domain); 61 + if (profile && profile.avatar) { 62 + return profile.avatar; 63 + } 64 + } catch (e) { 65 + // Silently fail 66 + } 67 + return null; 68 + } 69 + 70 + // Batch fetch app avatars 71 + async function fetchAppAvatars(namespaces) { 72 + const avatars = {}; 73 + const promises = namespaces.map(async (ns) => { 74 + const avatar = await getAppAvatar(ns); 75 + if (avatar) { 76 + avatars[ns] = avatar; 77 + } 78 + }); 79 + await Promise.all(promises); 80 + return avatars; 81 + } 82 + 83 + // ============================================================================ 84 + // SEARCH & NAVIGATION 85 + // ============================================================================ 86 + 87 + let searchTimeout = null; 88 + let autocompleteResults = []; 89 + 90 + function handleSearch() { 91 + const handle = document.getElementById('handleInput').value.trim(); 92 + if (handle) { 93 + viewHandle(handle); 94 + } 95 + } 96 + 97 + function viewHandle(handle) { 98 + window.location.href = `/view?handle=${encodeURIComponent(handle)}`; 99 + } 100 + 101 + function toggleInfo() { 102 + document.getElementById('infoContent').classList.toggle('expanded'); 103 + } 104 + 105 + // Expose to window for onclick handlers 106 + window.handleSearch = handleSearch; 107 + window.viewHandle = viewHandle; 108 + window.toggleInfo = toggleInfo; 109 + 110 + // ============================================================================ 111 + // AUTOCOMPLETE 112 + // ============================================================================ 113 + 114 + function escapeHtml(text) { 115 + const div = document.createElement('div'); 116 + div.textContent = text; 117 + return div.innerHTML; 118 + } 119 + 120 + function hideResults() { 121 + document.getElementById('autocompleteResults').classList.remove('show'); 122 + } 123 + 124 + function selectHandle(handle) { 125 + document.getElementById('handleInput').value = handle; 126 + autocompleteResults = []; 127 + hideResults(); 128 + viewHandle(handle); 129 + } 130 + 131 + // Expose to window for onclick handlers 132 + window.selectHandle = selectHandle; 133 + 134 + async function doSearch(query) { 135 + const spinner = document.getElementById('searchSpinner'); 136 + 137 + if (query.length < 2) { 138 + autocompleteResults = []; 139 + hideResults(); 140 + return; 141 + } 142 + 143 + spinner.style.display = 'block'; 144 + 145 + try { 146 + autocompleteResults = await searchHandles(query); 147 + renderResults(); 148 + } catch (e) { 149 + console.error('search failed:', e); 150 + } finally { 151 + spinner.style.display = 'none'; 152 + } 153 + } 154 + 155 + function renderResults() { 156 + const resultsDiv = document.getElementById('autocompleteResults'); 157 + 158 + if (autocompleteResults.length === 0) { 159 + hideResults(); 160 + return; 161 + } 162 + 163 + resultsDiv.innerHTML = autocompleteResults.map(result => ` 164 + <button type="button" class="autocomplete-item" onclick="selectHandle('${result.handle}')"> 165 + ${result.avatarUrl 166 + ? `<img src="${result.avatarUrl}" alt="" class="autocomplete-avatar">` 167 + : `<div class="autocomplete-avatar-placeholder">${result.handle[0].toUpperCase()}</div>` 168 + } 169 + <div class="autocomplete-info"> 170 + <div class="autocomplete-name">${escapeHtml(result.displayName)}</div> 171 + <div class="autocomplete-handle">@${escapeHtml(result.handle)}</div> 172 + </div> 173 + </button> 174 + `).join(''); 175 + 176 + resultsDiv.classList.add('show'); 177 + } 178 + 179 + function initAutocomplete() { 180 + const handleInput = document.getElementById('handleInput'); 181 + const resultsDiv = document.getElementById('autocompleteResults'); 182 + 183 + handleInput.addEventListener('input', () => { 184 + if (searchTimeout) clearTimeout(searchTimeout); 185 + searchTimeout = setTimeout(() => doSearch(handleInput.value), 300); 186 + }); 187 + 188 + handleInput.addEventListener('keydown', (e) => { 189 + if (e.key === 'Escape') { 190 + hideResults(); 191 + } 192 + }); 193 + 194 + handleInput.addEventListener('focus', () => { 195 + if (autocompleteResults.length > 0) { 196 + resultsDiv.classList.add('show'); 197 + } 198 + }); 199 + 200 + document.addEventListener('click', (e) => { 201 + if (!e.target.closest('.input-wrapper')) { 202 + hideResults(); 203 + } 204 + }); 205 + } 206 + 207 + // ============================================================================ 208 + // ATMOSPHERE VISUALIZATION 209 + // ============================================================================ 210 + 211 + async function fetchAtmosphere() { 212 + const CACHE_KEY = 'atme_atmosphere'; 213 + const CACHE_DURATION = 24 * 60 * 60 * 1000; // 24 hours 214 + 215 + const cached = localStorage.getItem(CACHE_KEY); 216 + if (cached) { 217 + const { data, timestamp } = JSON.parse(cached); 218 + if (Date.now() - timestamp < CACHE_DURATION) { 219 + return data; 220 + } 221 + } 222 + 223 + try { 224 + const response = await fetch('https://ufos-api.microcosm.blue/collections?order=dids-estimate&limit=50'); 225 + const json = await response.json(); 226 + 227 + // Group by namespace (first two segments) 228 + const namespaces = {}; 229 + json.collections.forEach(col => { 230 + const parts = col.nsid.split('.'); 231 + if (parts.length >= 2) { 232 + const ns = `${parts[0]}.${parts[1]}`; 233 + if (!namespaces[ns]) { 234 + namespaces[ns] = { 235 + namespace: ns, 236 + dids_total: 0, 237 + records_total: 0, 238 + collections: [] 239 + }; 240 + } 241 + namespaces[ns].dids_total += col.dids_estimate; 242 + namespaces[ns].records_total += col.creates; 243 + namespaces[ns].collections.push(col.nsid); 244 + } 245 + }); 246 + 247 + const data = Object.values(namespaces).sort((a, b) => b.dids_total - a.dids_total).slice(0, 30); 248 + 249 + localStorage.setItem(CACHE_KEY, JSON.stringify({ 250 + data, 251 + timestamp: Date.now() 252 + })); 253 + 254 + return data; 255 + } catch (e) { 256 + console.error('Failed to fetch atmosphere data:', e); 257 + return []; 258 + } 259 + } 260 + 261 + async function renderAtmosphere() { 262 + const data = await fetchAtmosphere(); 263 + if (!data.length) return; 264 + 265 + const atmosphere = document.getElementById('atmosphere'); 266 + const maxSize = Math.max(...data.map(d => d.dids_total)); 267 + 268 + const namespaces = data.map(app => app.namespace); 269 + const avatarPromise = fetchAppAvatars(namespaces); 270 + const orbRegistry = []; 271 + 272 + data.forEach((app, i) => { 273 + const orb = document.createElement('div'); 274 + orb.className = 'app-orb'; 275 + 276 + // Size based on user count (20-80px) 277 + const size = 20 + (app.dids_total / maxSize) * 60; 278 + 279 + // Position in 3D space 280 + const angle = (i / data.length) * Math.PI * 2; 281 + const radius = 250 + (i % 3) * 100; 282 + const y = (i % 5) * 80 - 160; 283 + const x = Math.cos(angle) * radius; 284 + const z = Math.sin(angle) * radius; 285 + 286 + orb.style.width = `${size}px`; 287 + orb.style.height = `${size}px`; 288 + orb.style.left = `calc(50% + ${x}px)`; 289 + orb.style.top = `calc(50% + ${y}px)`; 290 + orb.style.transform = `translateZ(${z}px) translate(-50%, -50%)`; 291 + orb.style.background = `radial-gradient(circle, rgba(255,255,255,0.1), rgba(255,255,255,0.02))`; 292 + orb.style.border = '1px solid rgba(255,255,255,0.1)'; 293 + orb.style.boxShadow = '0 0 20px rgba(255,255,255,0.1)'; 294 + 295 + // Fallback letter 296 + const letter = app.namespace.split('.')[1]?.[0]?.toUpperCase() || app.namespace[0].toUpperCase(); 297 + orb.innerHTML = `<div class="fallback">${letter}</div>`; 298 + 299 + // Tooltip 300 + const tooltip = document.createElement('div'); 301 + tooltip.className = 'app-tooltip'; 302 + const users = app.dids_total >= 1000000 303 + ? `${(app.dids_total / 1000000).toFixed(1)}M users` 304 + : `${(app.dids_total / 1000).toFixed(0)}K users`; 305 + tooltip.textContent = `${app.namespace} - ${users}`; 306 + orb.appendChild(tooltip); 307 + 308 + atmosphere.appendChild(orb); 309 + 310 + orbRegistry.push({ orb, tooltip, namespace: app.namespace }); 311 + }); 312 + 313 + avatarPromise.then(avatarMap => { 314 + orbRegistry.forEach(({ orb, tooltip, namespace }) => { 315 + const avatarUrl = avatarMap[namespace]; 316 + if (avatarUrl) { 317 + orb.innerHTML = `<img src="${avatarUrl}" alt="${namespace}" />`; 318 + orb.appendChild(tooltip); 319 + } 320 + }); 321 + }); 322 + } 323 + 324 + // ============================================================================ 325 + // INITIALIZATION 326 + // ============================================================================ 327 + 328 + function init() { 329 + initAutocomplete(); 330 + renderAtmosphere(); 331 + } 332 + 333 + // Start when DOM is ready 334 + if (document.readyState === 'loading') { 335 + document.addEventListener('DOMContentLoaded', init); 336 + } else { 337 + init(); 338 + }
+420
src/landing/styles.css
··· 1 + /* Landing page styles */ 2 + 3 + * { margin: 0; padding: 0; box-sizing: border-box; } 4 + 5 + body { 6 + font-family: ui-monospace, 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Menlo, 'Courier New', monospace; 7 + min-height: 100vh; 8 + background: radial-gradient(ellipse at center, #0a0a0f 0%, #000000 100%); 9 + color: #e5e5e5; 10 + overflow: hidden; 11 + perspective: 1000px; 12 + } 13 + 14 + @media (max-width: 768px) { 15 + body { 16 + overflow-y: auto; 17 + overflow-x: hidden; 18 + } 19 + } 20 + 21 + .atmosphere { 22 + position: fixed; 23 + inset: 0; 24 + transform-style: preserve-3d; 25 + animation: rotate 120s infinite linear; 26 + } 27 + 28 + @keyframes rotate { 29 + from { transform: rotateY(0deg); } 30 + to { transform: rotateY(360deg); } 31 + } 32 + 33 + .app-orb { 34 + position: absolute; 35 + border-radius: 50%; 36 + display: flex; 37 + align-items: center; 38 + justify-content: center; 39 + transition: all 0.3s ease; 40 + cursor: pointer; 41 + backdrop-filter: blur(4px); 42 + } 43 + 44 + .app-orb:hover { 45 + transform: scale(1.2) !important; 46 + z-index: 100; 47 + } 48 + 49 + .app-orb img { 50 + width: 100%; 51 + height: 100%; 52 + border-radius: 50%; 53 + object-fit: cover; 54 + } 55 + 56 + .app-orb .fallback { 57 + font-size: 1.5rem; 58 + font-weight: 600; 59 + color: rgba(255, 255, 255, 0.9); 60 + } 61 + 62 + .app-tooltip { 63 + position: absolute; 64 + background: rgba(10, 10, 15, 0.95); 65 + border: 1px solid rgba(255, 255, 255, 0.1); 66 + padding: 0.5rem 0.75rem; 67 + border-radius: 4px; 68 + font-size: 0.7rem; 69 + white-space: nowrap; 70 + pointer-events: none; 71 + opacity: 0; 72 + transition: opacity 0.2s; 73 + z-index: 1000; 74 + } 75 + 76 + .app-orb:hover .app-tooltip { 77 + opacity: 1; 78 + } 79 + 80 + .container { 81 + position: fixed; 82 + inset: 0; 83 + display: flex; 84 + align-items: center; 85 + justify-content: center; 86 + z-index: 10; 87 + } 88 + 89 + @media (max-width: 768px) { 90 + .container { 91 + position: relative; 92 + min-height: 100vh; 93 + padding: 2rem 0; 94 + } 95 + } 96 + 97 + .search-card { 98 + background: transparent; 99 + border: 1px solid rgba(255, 255, 255, 0.1); 100 + padding: 2.5rem 3rem; 101 + border-radius: 8px; 102 + backdrop-filter: blur(2px); 103 + text-align: center; 104 + max-width: min(500px, 90vw); 105 + } 106 + 107 + h1 { 108 + font-size: 2rem; 109 + margin-bottom: 0.5rem; 110 + font-weight: 300; 111 + letter-spacing: 0.05em; 112 + } 113 + 114 + .subtitle { 115 + font-size: 0.75rem; 116 + color: rgba(255, 255, 255, 0.5); 117 + margin-bottom: 2rem; 118 + } 119 + 120 + input { 121 + font-family: inherit; 122 + font-size: 0.9rem; 123 + padding: 0.75rem 1rem; 124 + margin-bottom: 1rem; 125 + background: rgba(10, 10, 15, 0.8); 126 + border: 1px solid rgba(255, 255, 255, 0.2); 127 + border-radius: 4px; 128 + color: #e5e5e5; 129 + width: 100%; 130 + transition: all 0.2s; 131 + } 132 + 133 + input:focus { 134 + outline: none; 135 + border-color: rgba(255, 255, 255, 0.4); 136 + background: rgba(10, 10, 15, 0.9); 137 + } 138 + 139 + input::placeholder { 140 + color: rgba(255, 255, 255, 0.3); 141 + } 142 + 143 + .input-wrapper { 144 + position: relative; 145 + width: 100%; 146 + } 147 + 148 + .autocomplete-results { 149 + position: absolute; 150 + z-index: 100; 151 + width: 100%; 152 + max-height: 240px; 153 + overflow-y: auto; 154 + background: rgba(10, 10, 15, 0.98); 155 + border: 1px solid rgba(255, 255, 255, 0.2); 156 + border-radius: 4px; 157 + margin-top: 0.25rem; 158 + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5); 159 + display: none; 160 + scrollbar-width: thin; 161 + scrollbar-color: rgba(255, 255, 255, 0.2) rgba(10, 10, 15, 0.5); 162 + } 163 + 164 + .autocomplete-results::-webkit-scrollbar { 165 + width: 8px; 166 + } 167 + 168 + .autocomplete-results::-webkit-scrollbar-track { 169 + background: rgba(10, 10, 15, 0.5); 170 + border-radius: 4px; 171 + } 172 + 173 + .autocomplete-results::-webkit-scrollbar-thumb { 174 + background: rgba(255, 255, 255, 0.2); 175 + border-radius: 4px; 176 + } 177 + 178 + .autocomplete-results::-webkit-scrollbar-thumb:hover { 179 + background: rgba(255, 255, 255, 0.3); 180 + } 181 + 182 + .autocomplete-results.show { 183 + display: block; 184 + } 185 + 186 + .autocomplete-item { 187 + width: 100%; 188 + display: flex; 189 + align-items: center; 190 + gap: 0.75rem; 191 + padding: 0.75rem; 192 + background: transparent; 193 + border: none; 194 + border-bottom: 1px solid rgba(255, 255, 255, 0.1); 195 + color: #e5e5e5; 196 + text-align: left; 197 + font-family: inherit; 198 + cursor: pointer; 199 + transition: background 0.15s; 200 + } 201 + 202 + .autocomplete-item:last-child { 203 + border-bottom: none; 204 + } 205 + 206 + .autocomplete-item:hover { 207 + background: rgba(255, 255, 255, 0.1); 208 + } 209 + 210 + .autocomplete-avatar { 211 + width: 36px; 212 + height: 36px; 213 + border-radius: 50%; 214 + object-fit: cover; 215 + border: 1px solid rgba(255, 255, 255, 0.2); 216 + flex-shrink: 0; 217 + } 218 + 219 + .autocomplete-avatar-placeholder { 220 + width: 36px; 221 + height: 36px; 222 + border-radius: 50%; 223 + background: rgba(255, 255, 255, 0.1); 224 + flex-shrink: 0; 225 + display: flex; 226 + align-items: center; 227 + justify-content: center; 228 + font-size: 0.9rem; 229 + color: rgba(255, 255, 255, 0.5); 230 + } 231 + 232 + .autocomplete-info { 233 + flex: 1; 234 + min-width: 0; 235 + overflow: hidden; 236 + } 237 + 238 + .autocomplete-name { 239 + font-weight: 500; 240 + color: rgba(255, 255, 255, 0.9); 241 + margin-bottom: 0.125rem; 242 + overflow: hidden; 243 + text-overflow: ellipsis; 244 + white-space: nowrap; 245 + font-size: 0.85rem; 246 + } 247 + 248 + .autocomplete-handle { 249 + font-size: 0.75rem; 250 + color: rgba(255, 255, 255, 0.5); 251 + overflow: hidden; 252 + text-overflow: ellipsis; 253 + white-space: nowrap; 254 + } 255 + 256 + .search-spinner { 257 + position: absolute; 258 + right: 0.75rem; 259 + top: 50%; 260 + transform: translateY(-50%); 261 + color: rgba(255, 255, 255, 0.4); 262 + font-size: 0.75rem; 263 + } 264 + 265 + button { 266 + font-family: inherit; 267 + font-size: 0.9rem; 268 + padding: 0.75rem 2rem; 269 + cursor: pointer; 270 + background: rgba(10, 10, 15, 0.8); 271 + border: 1px solid rgba(255, 255, 255, 0.2); 272 + border-radius: 4px; 273 + color: #e5e5e5; 274 + transition: all 0.2s; 275 + width: 100%; 276 + } 277 + 278 + button:hover { 279 + background: rgba(10, 10, 15, 0.9); 280 + border-color: rgba(255, 255, 255, 0.4); 281 + } 282 + 283 + .divider { 284 + display: flex; 285 + align-items: center; 286 + gap: 1rem; 287 + margin: 1.5rem 0 1rem; 288 + color: rgba(255, 255, 255, 0.3); 289 + font-size: 0.7rem; 290 + } 291 + 292 + .divider::before, 293 + .divider::after { 294 + content: ''; 295 + flex: 1; 296 + height: 1px; 297 + background: rgba(255, 255, 255, 0.1); 298 + } 299 + 300 + .suggestions { 301 + display: flex; 302 + gap: 0.75rem; 303 + flex-wrap: wrap; 304 + justify-content: center; 305 + } 306 + 307 + .suggestion-btn { 308 + font-family: inherit; 309 + font-size: 0.8rem; 310 + padding: 0.5rem 1rem; 311 + cursor: pointer; 312 + background: transparent; 313 + border: 1px solid rgba(255, 255, 255, 0.15); 314 + border-radius: 4px; 315 + color: rgba(255, 255, 255, 0.6); 316 + transition: all 0.2s; 317 + width: auto; 318 + } 319 + 320 + .suggestion-btn:hover { 321 + background: rgba(10, 10, 15, 0.5); 322 + border-color: rgba(255, 255, 255, 0.3); 323 + color: rgba(255, 255, 255, 0.8); 324 + } 325 + 326 + .info-toggle { 327 + margin-top: 1.5rem; 328 + color: rgba(255, 255, 255, 0.5); 329 + font-size: 0.75rem; 330 + cursor: pointer; 331 + border: none; 332 + background: none; 333 + padding: 0.5rem; 334 + transition: color 0.2s; 335 + text-decoration: underline; 336 + text-underline-offset: 2px; 337 + } 338 + 339 + .info-toggle:hover { 340 + color: rgba(255, 255, 255, 0.8); 341 + } 342 + 343 + .info-content { 344 + max-height: 0; 345 + overflow: hidden; 346 + transition: max-height 0.3s ease; 347 + margin-top: 1rem; 348 + } 349 + 350 + .info-content.expanded { 351 + max-height: 500px; 352 + overflow-y: auto; 353 + } 354 + 355 + @media (max-width: 768px) { 356 + .info-content.expanded { 357 + max-height: none; 358 + overflow-y: visible; 359 + } 360 + } 361 + 362 + .info-section { 363 + background: rgba(10, 10, 15, 0.6); 364 + border: 1px solid rgba(255, 255, 255, 0.1); 365 + border-radius: 4px; 366 + padding: 1.5rem; 367 + text-align: left; 368 + } 369 + 370 + .info-section h3 { 371 + font-size: 0.85rem; 372 + font-weight: 500; 373 + margin-bottom: 0.75rem; 374 + color: rgba(255, 255, 255, 0.9); 375 + } 376 + 377 + .info-section p { 378 + font-size: 0.7rem; 379 + line-height: 1.6; 380 + color: rgba(255, 255, 255, 0.6); 381 + margin-bottom: 1rem; 382 + } 383 + 384 + .info-section p:last-child { 385 + margin-bottom: 0; 386 + } 387 + 388 + .info-section strong { 389 + color: rgba(255, 255, 255, 0.85); 390 + } 391 + 392 + .info-section a { 393 + color: rgba(255, 255, 255, 0.8); 394 + text-decoration: underline; 395 + text-underline-offset: 2px; 396 + } 397 + 398 + .info-section a:hover { 399 + color: rgba(255, 255, 255, 1); 400 + } 401 + 402 + .footer { 403 + position: fixed; 404 + bottom: 1rem; 405 + left: 50%; 406 + transform: translateX(-50%); 407 + font-size: 0.7rem; 408 + color: rgba(255, 255, 255, 0.3); 409 + z-index: 20; 410 + } 411 + 412 + .footer a { 413 + color: rgba(255, 255, 255, 0.5); 414 + text-decoration: none; 415 + transition: color 0.2s; 416 + } 417 + 418 + .footer a:hover { 419 + color: rgba(255, 255, 255, 0.8); 420 + }
-69
src/main.rs
··· 1 - use actix_files::Files; 2 - use actix_session::{storage::CookieSessionStore, SessionMiddleware}; 3 - use actix_web::cookie::Key; 4 - use actix_web::{middleware, web, App, HttpServer}; 5 - 6 - mod constants; 7 - mod firehose; 8 - mod mst; 9 - mod oauth; 10 - mod routes; 11 - mod templates; 12 - 13 - #[actix_web::main] 14 - async fn main() -> std::io::Result<()> { 15 - env_logger::init(); 16 - 17 - // Create the firehose manager (connections created lazily per-DID) 18 - let firehose_manager = firehose::create_firehose_manager(); 19 - 20 - // Create OAuth client 21 - let oauth_client = oauth::create_oauth_client(); 22 - 23 - // Generate a random session key (in production, this should be stored securely) 24 - let session_key = Key::generate(); 25 - 26 - // Get port from environment variable, default to 8080 27 - let port = std::env::var("PORT") 28 - .unwrap_or_else(|_| constants::DEFAULT_PORT.to_string()) 29 - .parse::<u16>() 30 - .expect("PORT must be a valid number"); 31 - 32 - println!("starting server at http://localhost:{}", port); 33 - 34 - HttpServer::new(move || { 35 - App::new() 36 - .wrap(middleware::Logger::default()) 37 - .wrap( 38 - SessionMiddleware::builder(CookieSessionStore::default(), session_key.clone()) 39 - .cookie_secure(false) // Set to true in production with HTTPS 40 - .build(), 41 - ) 42 - .app_data(web::Data::new(firehose_manager.clone())) 43 - .app_data(web::Data::new(oauth_client.clone())) 44 - .service(routes::index) 45 - .service(routes::view) 46 - .service(routes::login) 47 - .service(routes::callback) 48 - .service(routes::client_metadata) 49 - .service(routes::logout) 50 - .service(routes::get_mst) 51 - .service(routes::init) 52 - .service(routes::get_avatar) 53 - .service(routes::get_avatar_batch) 54 - .service(routes::search_handles) 55 - .service(routes::validate_url) 56 - .service(routes::get_record) 57 - .service(routes::auth_status) 58 - .service(routes::sign_guestbook) 59 - .service(routes::unsign_guestbook) 60 - .service(routes::get_guestbook_signatures) 61 - .service(routes::check_page_owner_signature) 62 - .service(routes::firehose_watch) 63 - .service(routes::favicon) 64 - .service(Files::new("/static", "./static")) 65 - }) 66 - .bind(("0.0.0.0", port))? 67 - .run() 68 - .await 69 - }
-171
src/mst.rs
··· 1 - use serde::{Deserialize, Serialize}; 2 - use std::collections::HashMap; 3 - 4 - use crate::constants; 5 - 6 - #[derive(Debug, Serialize, Deserialize, Clone)] 7 - pub struct Record { 8 - pub uri: String, 9 - pub cid: String, 10 - pub value: serde_json::Value, 11 - } 12 - 13 - #[derive(Debug, Serialize, Clone)] 14 - #[serde(rename_all = "camelCase")] 15 - pub struct MSTNode { 16 - pub key: String, 17 - pub cid: Option<String>, 18 - pub uri: Option<String>, 19 - pub value: Option<serde_json::Value>, 20 - pub depth: i32, 21 - pub children: Vec<MSTNode>, 22 - } 23 - 24 - #[derive(Debug, Serialize)] 25 - #[serde(rename_all = "camelCase")] 26 - pub struct MSTResponse { 27 - pub root: MSTNode, 28 - pub record_count: usize, 29 - } 30 - 31 - pub fn build_mst(records: Vec<Record>) -> MSTResponse { 32 - let record_count = records.len(); 33 - 34 - // Extract and sort by key 35 - let mut nodes: Vec<MSTNode> = records 36 - .into_iter() 37 - .map(|r| { 38 - let key = r.uri.split('/').next_back().unwrap_or("").to_string(); 39 - MSTNode { 40 - key: key.clone(), 41 - cid: Some(r.cid), 42 - uri: Some(r.uri), 43 - value: Some(r.value), 44 - depth: calculate_key_depth(&key), 45 - children: vec![], 46 - } 47 - }) 48 - .collect(); 49 - 50 - nodes.sort_by(|a, b| a.key.cmp(&b.key)); 51 - 52 - // Build tree structure 53 - let root = build_tree(nodes); 54 - 55 - MSTResponse { root, record_count } 56 - } 57 - 58 - fn calculate_key_depth(key: &str) -> i32 { 59 - // Simplified depth calculation based on key hash 60 - let mut hash: i32 = 0; 61 - for ch in key.chars() { 62 - hash = hash 63 - .wrapping_shl(5) 64 - .wrapping_sub(hash) 65 - .wrapping_add(ch as i32); 66 - } 67 - 68 - // Count leading zero bits (approximation) 69 - let abs_hash = hash.unsigned_abs(); 70 - let binary = format!("{:032b}", abs_hash); 71 - 72 - let mut depth = 0; 73 - let chars: Vec<char> = binary.chars().collect(); 74 - let mut i = 0; 75 - while i < chars.len() - 1 { 76 - if chars[i] == '0' && chars[i + 1] == '0' { 77 - depth += 1; 78 - i += 2; 79 - } else { 80 - break; 81 - } 82 - } 83 - 84 - depth.min(constants::MST_MAX_DEPTH) 85 - } 86 - 87 - fn build_tree(nodes: Vec<MSTNode>) -> MSTNode { 88 - if nodes.is_empty() { 89 - return MSTNode { 90 - key: "root".to_string(), 91 - cid: None, 92 - uri: None, 93 - value: None, 94 - depth: -1, 95 - children: vec![], 96 - }; 97 - } 98 - 99 - // Group by depth 100 - let mut by_depth: HashMap<i32, Vec<MSTNode>> = HashMap::new(); 101 - for node in nodes { 102 - by_depth.entry(node.depth).or_default().push(node); 103 - } 104 - 105 - let mut depths: Vec<i32> = by_depth.keys().copied().collect(); 106 - depths.sort(); 107 - 108 - // Build tree bottom-up 109 - let mut current_level: Vec<MSTNode> = by_depth 110 - .remove(&depths[depths.len() - 1]) 111 - .unwrap_or_default(); 112 - 113 - // Work backwards through depths 114 - for i in (0..depths.len() - 1).rev() { 115 - let depth = depths[i]; 116 - let mut parent_nodes = by_depth.remove(&depth).unwrap_or_default(); 117 - 118 - // Distribute children to parents 119 - let children_per_parent = if parent_nodes.is_empty() { 120 - 0 121 - } else { 122 - current_level.len().div_ceil(parent_nodes.len()) 123 - }; 124 - 125 - for (i, parent) in parent_nodes.iter_mut().enumerate() { 126 - let start = i * children_per_parent; 127 - let end = ((i + 1) * children_per_parent).min(current_level.len()); 128 - if start < current_level.len() { 129 - parent.children = current_level.drain(start..end).collect(); 130 - } 131 - } 132 - 133 - current_level = parent_nodes; 134 - } 135 - 136 - // Create root and attach top-level nodes 137 - MSTNode { 138 - key: "root".to_string(), 139 - cid: None, 140 - uri: None, 141 - value: None, 142 - depth: -1, 143 - children: current_level, 144 - } 145 - } 146 - 147 - pub async fn fetch_records(pds: &str, did: &str, collection: &str) -> Result<Vec<Record>, String> { 148 - let url = format!( 149 - "{}/xrpc/com.atproto.repo.listRecords?repo={}&collection={}&limit={}", 150 - pds, 151 - did, 152 - collection, 153 - constants::MST_FETCH_LIMIT 154 - ); 155 - 156 - let response = reqwest::get(&url) 157 - .await 158 - .map_err(|e| format!("Failed to fetch records: {}", e))?; 159 - 160 - #[derive(Deserialize)] 161 - struct ListRecordsResponse { 162 - records: Vec<Record>, 163 - } 164 - 165 - let data: ListRecordsResponse = response 166 - .json() 167 - .await 168 - .map_err(|e| format!("Failed to parse response: {}", e))?; 169 - 170 - Ok(data.records) 171 - }
-114
src/oauth.rs
··· 1 - use atrium_identity::{ 2 - did::{CommonDidResolver, CommonDidResolverConfig, DEFAULT_PLC_DIRECTORY_URL}, 3 - handle::{AtprotoHandleResolver, AtprotoHandleResolverConfig, DnsTxtResolver}, 4 - }; 5 - use atrium_oauth::{ 6 - store::{session::MemorySessionStore, state::MemoryStateStore}, 7 - AtprotoClientMetadata, AtprotoLocalhostClientMetadata, AuthMethod, DefaultHttpClient, 8 - GrantType, KnownScope, OAuthClient, OAuthClientConfig, OAuthResolverConfig, Scope, 9 - }; 10 - use hickory_resolver::{ 11 - config::{ResolverConfig, ResolverOpts}, 12 - TokioAsyncResolver, 13 - }; 14 - use std::sync::Arc; 15 - 16 - use crate::constants; 17 - 18 - #[derive(Clone)] 19 - pub struct HickoryDnsResolver(pub Arc<TokioAsyncResolver>); 20 - 21 - impl DnsTxtResolver for HickoryDnsResolver { 22 - async fn resolve( 23 - &self, 24 - domain: &str, 25 - ) -> Result<Vec<String>, Box<dyn std::error::Error + Send + Sync>> { 26 - Ok(self 27 - .0 28 - .txt_lookup(domain) 29 - .await? 30 - .iter() 31 - .map(|txt| txt.to_string()) 32 - .collect()) 33 - } 34 - } 35 - 36 - pub type OAuthClientType = Arc< 37 - OAuthClient< 38 - MemoryStateStore, 39 - MemorySessionStore, 40 - CommonDidResolver<DefaultHttpClient>, 41 - AtprotoHandleResolver<HickoryDnsResolver, DefaultHttpClient>, 42 - >, 43 - >; 44 - 45 - pub fn create_oauth_client() -> OAuthClientType { 46 - let http_client = Arc::new(DefaultHttpClient::default()); 47 - let dns_resolver = HickoryDnsResolver(Arc::new(TokioAsyncResolver::tokio( 48 - ResolverConfig::default(), 49 - ResolverOpts::default(), 50 - ))); 51 - 52 - let redirect_uri = std::env::var("OAUTH_REDIRECT_URI") 53 - .unwrap_or_else(|_| constants::DEFAULT_OAUTH_CALLBACK.to_string()); 54 - 55 - let is_production = redirect_uri.starts_with("https://"); 56 - 57 - let resolver = OAuthResolverConfig { 58 - did_resolver: CommonDidResolver::new(CommonDidResolverConfig { 59 - plc_directory_url: DEFAULT_PLC_DIRECTORY_URL.to_string(), 60 - http_client: http_client.clone(), 61 - }), 62 - handle_resolver: AtprotoHandleResolver::new(AtprotoHandleResolverConfig { 63 - dns_txt_resolver: dns_resolver, 64 - http_client: http_client.clone(), 65 - }), 66 - authorization_server_metadata: Default::default(), 67 - protected_resource_metadata: Default::default(), 68 - }; 69 - 70 - if is_production { 71 - let base_url = redirect_uri.trim_end_matches("/oauth/callback"); 72 - Arc::new( 73 - OAuthClient::new(OAuthClientConfig { 74 - client_metadata: AtprotoClientMetadata { 75 - client_id: format!("{}/oauth-client-metadata.json", base_url), 76 - client_uri: Some(base_url.to_string()), 77 - redirect_uris: vec![redirect_uri], 78 - token_endpoint_auth_method: AuthMethod::None, 79 - grant_types: vec![GrantType::AuthorizationCode, GrantType::RefreshToken], 80 - scopes: vec![ 81 - Scope::Known(KnownScope::Atproto), 82 - // Granular scope for guestbook records only 83 - Scope::Unknown("repo:app.at-me.visit".to_string()), 84 - ], 85 - jwks_uri: None, 86 - token_endpoint_auth_signing_alg: None, 87 - }, 88 - keys: None, 89 - resolver, 90 - state_store: MemoryStateStore::default(), 91 - session_store: MemorySessionStore::default(), 92 - }) 93 - .expect("failed to create oauth client"), 94 - ) 95 - } else { 96 - Arc::new( 97 - OAuthClient::new(OAuthClientConfig { 98 - client_metadata: AtprotoLocalhostClientMetadata { 99 - redirect_uris: Some(vec![redirect_uri]), 100 - scopes: Some(vec![ 101 - Scope::Known(KnownScope::Atproto), 102 - // Granular scope for guestbook records only 103 - Scope::Unknown("repo:app.at-me.visit".to_string()), 104 - ]), 105 - }, 106 - keys: None, 107 - resolver, 108 - state_store: MemoryStateStore::default(), 109 - session_store: MemorySessionStore::default(), 110 - }) 111 - .expect("failed to create oauth client"), 112 - ) 113 - } 114 - }
-1518
src/routes.rs
··· 1 - use actix_session::Session; 2 - use actix_web::{get, post, web, HttpResponse, Responder}; 3 - use atrium_identity::did::CommonDidResolver; 4 - use atrium_identity::handle::AtprotoHandleResolver; 5 - use atrium_oauth::DefaultHttpClient; 6 - use atrium_oauth::{AuthorizeOptions, CallbackParams, KnownScope, OAuthSession, Scope}; 7 - use dashmap::DashMap; 8 - use futures_util::future; 9 - use futures_util::stream::{FuturesUnordered, StreamExt}; 10 - use once_cell::sync::Lazy; 11 - use serde::Deserialize; 12 - use serde::Serialize; 13 - use std::collections::{HashMap, HashSet}; 14 - use std::sync::{Arc, Mutex}; 15 - use std::time::{Duration, Instant}; 16 - 17 - use crate::constants; 18 - use crate::firehose::FirehoseManager; 19 - use crate::mst; 20 - use crate::oauth::{HickoryDnsResolver, OAuthClientType}; 21 - use crate::templates; 22 - 23 - // Avatar cache with 1 hour TTL 24 - struct CachedAvatar { 25 - url: Option<String>, 26 - timestamp: Instant, 27 - } 28 - 29 - static AVATAR_CACHE: Lazy<Mutex<HashMap<String, CachedAvatar>>> = 30 - Lazy::new(|| Mutex::new(HashMap::new())); 31 - 32 - // DID resolution cache with 1 hour TTL 33 - struct CachedDid { 34 - did: Option<String>, 35 - timestamp: Instant, 36 - } 37 - 38 - static DID_CACHE: Lazy<DashMap<String, CachedDid>> = Lazy::new(DashMap::new); 39 - 40 - static HTTP_CLIENT: Lazy<reqwest::Client> = Lazy::new(|| { 41 - reqwest::Client::builder() 42 - .user_agent("at-me/1.0 (+https://at-me.zzstoatzz.io)") 43 - .pool_idle_timeout(Some(Duration::from_secs(30))) 44 - .connect_timeout(Duration::from_secs(4)) 45 - .timeout(Duration::from_secs(6)) 46 - .build() 47 - .expect("failed to build shared http client") 48 - }); 49 - 50 - async fn http_get(url: &str) -> Result<reqwest::Response, reqwest::Error> { 51 - HTTP_CLIENT.get(url).send().await 52 - } 53 - 54 - // Guestbook signature struct 55 - #[derive(Serialize, Clone)] 56 - #[serde(rename_all = "camelCase")] 57 - pub struct GuestbookSignature { 58 - pub did: String, 59 - pub handle: Option<String>, 60 - pub avatar: Option<String>, 61 - pub timestamp: String, 62 - pub text: Option<String>, 63 - } 64 - 65 - // UFOs API response structure 66 - #[derive(Deserialize)] 67 - struct UfosRecord { 68 - did: String, 69 - record: serde_json::Value, 70 - } 71 - 72 - // Cache for UFOs API guestbook signatures 73 - struct CachedGuestbookSignatures { 74 - signatures: Vec<GuestbookSignature>, 75 - } 76 - 77 - static GUESTBOOK_CACHE: Lazy<Mutex<Option<CachedGuestbookSignatures>>> = 78 - Lazy::new(|| Mutex::new(None)); 79 - 80 - // OAuth session type matching our OAuth client configuration 81 - type OAuthSessionType = OAuthSession< 82 - DefaultHttpClient, 83 - CommonDidResolver<DefaultHttpClient>, 84 - AtprotoHandleResolver<HickoryDnsResolver, DefaultHttpClient>, 85 - atrium_common::store::memory::MemoryStore< 86 - atrium_api::types::string::Did, 87 - atrium_oauth::store::session::Session, 88 - >, 89 - >; 90 - 91 - // OAuth session cache - stores authenticated agents by DID 92 - static AGENT_CACHE: Lazy<DashMap<String, Arc<atrium_api::agent::Agent<OAuthSessionType>>>> = 93 - Lazy::new(DashMap::new); 94 - 95 - const FAVICON_SVG: &str = include_str!("../static/favicon.svg"); 96 - 97 - #[derive(Deserialize)] 98 - pub struct ViewQuery { 99 - handle: Option<String>, 100 - did: Option<String>, 101 - } 102 - 103 - #[get("/")] 104 - pub async fn index() -> impl Responder { 105 - HttpResponse::Ok() 106 - .content_type("text/html") 107 - .body(templates::landing_page()) 108 - } 109 - 110 - #[get("/view")] 111 - pub async fn view(query: web::Query<ViewQuery>) -> HttpResponse { 112 - // Accept either did or handle parameter 113 - let did = if let Some(did_param) = &query.did { 114 - // DID provided directly 115 - did_param.clone() 116 - } else if let Some(handle) = &query.handle { 117 - // Handle provided - resolve to DID 118 - let resolve_url = format!("{}?handle={}", constants::BSKY_API_RESOLVE_HANDLE, handle); 119 - 120 - match http_get(&resolve_url).await { 121 - Ok(response) => match response.json::<serde_json::Value>().await { 122 - Ok(data) => match data["did"].as_str() { 123 - Some(did) => did.to_string(), 124 - None => return HttpResponse::BadRequest().body("failed to resolve handle"), 125 - }, 126 - Err(_) => return HttpResponse::BadRequest().body("failed to parse response"), 127 - }, 128 - Err(_) => return HttpResponse::BadRequest().body("failed to resolve handle"), 129 - } 130 - } else { 131 - return HttpResponse::BadRequest().body("missing handle or did parameter"); 132 - }; 133 - 134 - HttpResponse::Ok() 135 - .content_type("text/html") 136 - .body(templates::app_page(&did)) 137 - } 138 - 139 - #[get("/favicon.svg")] 140 - pub async fn favicon() -> HttpResponse { 141 - HttpResponse::Ok() 142 - .content_type("image/svg+xml") 143 - .body(FAVICON_SVG) 144 - } 145 - 146 - #[derive(Deserialize)] 147 - pub struct LoginForm { 148 - handle: String, 149 - } 150 - 151 - #[post("/login")] 152 - pub async fn login(form: web::Form<LoginForm>, client: web::Data<OAuthClientType>) -> HttpResponse { 153 - let handle = match atrium_api::types::string::Handle::new(form.handle.clone()) { 154 - Ok(h) => h, 155 - Err(_) => return HttpResponse::BadRequest().body("invalid handle"), 156 - }; 157 - 158 - match client 159 - .authorize( 160 - &handle, 161 - AuthorizeOptions { 162 - scopes: vec![ 163 - Scope::Known(KnownScope::Atproto), 164 - // Granular scope for guestbook records only 165 - Scope::Unknown("repo:app.at-me.visit".to_string()), 166 - ], 167 - ..Default::default() 168 - }, 169 - ) 170 - .await 171 - { 172 - Ok(url) => HttpResponse::SeeOther() 173 - .append_header(("Location", url)) 174 - .finish(), 175 - Err(_) => HttpResponse::InternalServerError().body("oauth error"), 176 - } 177 - } 178 - 179 - #[derive(Deserialize)] 180 - pub struct OAuthParams { 181 - state: Option<String>, 182 - iss: Option<String>, 183 - code: Option<String>, 184 - error: Option<String>, 185 - } 186 - 187 - #[get("/oauth/callback")] 188 - pub async fn callback( 189 - params: web::Query<OAuthParams>, 190 - client: web::Data<OAuthClientType>, 191 - session: Session, 192 - ) -> HttpResponse { 193 - if let Some(error) = &params.error { 194 - return HttpResponse::BadRequest().body(format!("oauth error: {}", error)); 195 - } 196 - 197 - let code = match &params.code { 198 - Some(c) => c.clone(), 199 - None => return HttpResponse::BadRequest().body("missing code"), 200 - }; 201 - 202 - let callback_params = CallbackParams { 203 - code, 204 - state: params.state.clone(), 205 - iss: params.iss.clone(), 206 - }; 207 - 208 - match client.callback(callback_params).await { 209 - Ok((session_data, _)) => { 210 - // Create agent from session 211 - let agent = Arc::new(atrium_api::agent::Agent::new(session_data)); 212 - if let Some(did) = agent.did().await { 213 - let did_string = did.to_string(); 214 - 215 - // Store agent in cache for later authenticated requests 216 - AGENT_CACHE.insert(did_string.clone(), agent); 217 - 218 - // Store DID in actix session 219 - if let Err(e) = session.insert(constants::SESSION_KEY_DID, &did_string) { 220 - return HttpResponse::InternalServerError() 221 - .body(format!("session error: {}", e)); 222 - } 223 - HttpResponse::SeeOther() 224 - .append_header(("Location", format!("/view?did={}&auth=success", did_string))) 225 - .finish() 226 - } else { 227 - HttpResponse::InternalServerError().body("no did") 228 - } 229 - } 230 - Err(e) => HttpResponse::InternalServerError().body(format!("callback error: {}", e)), 231 - } 232 - } 233 - 234 - #[get("/oauth-client-metadata.json")] 235 - pub async fn client_metadata() -> HttpResponse { 236 - let base_url = std::env::var("OAUTH_REDIRECT_URI") 237 - .unwrap_or_else(|_| constants::DEFAULT_OAUTH_CALLBACK.to_string()) 238 - .trim_end_matches("/oauth/callback") 239 - .to_string(); 240 - 241 - let metadata = serde_json::json!({ 242 - "client_id": format!("{}/oauth-client-metadata.json", base_url), 243 - "client_name": "@me", 244 - "client_uri": base_url.clone(), 245 - "redirect_uris": [format!("{}/oauth/callback", base_url)], 246 - "scope": "atproto repo:app.at-me.visit", 247 - "grant_types": ["authorization_code", "refresh_token"], 248 - "response_types": ["code"], 249 - "token_endpoint_auth_method": "none", 250 - "dpop_bound_access_tokens": true 251 - }); 252 - 253 - HttpResponse::Ok() 254 - .content_type("application/json") 255 - .body(metadata.to_string()) 256 - } 257 - 258 - #[get("/logout")] 259 - pub async fn logout(session: Session) -> HttpResponse { 260 - session.purge(); 261 - HttpResponse::SeeOther() 262 - .append_header(("Location", "/")) 263 - .finish() 264 - } 265 - 266 - #[derive(Deserialize)] 267 - pub struct MSTQuery { 268 - pds: String, 269 - did: String, 270 - collection: String, 271 - } 272 - 273 - #[get("/api/mst")] 274 - pub async fn get_mst(query: web::Query<MSTQuery>) -> HttpResponse { 275 - match mst::fetch_records(&query.pds, &query.did, &query.collection).await { 276 - Ok(records) => { 277 - if records.is_empty() { 278 - return HttpResponse::Ok().json(serde_json::json!({ 279 - "error": "no records found" 280 - })); 281 - } 282 - 283 - let mst_data = mst::build_mst(records); 284 - HttpResponse::Ok().json(mst_data) 285 - } 286 - Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({ 287 - "error": e 288 - })), 289 - } 290 - } 291 - 292 - #[derive(Deserialize)] 293 - pub struct InitQuery { 294 - did: String, 295 - } 296 - 297 - #[derive(serde::Serialize)] 298 - #[serde(rename_all = "camelCase")] 299 - pub struct AppInfo { 300 - namespace: String, 301 - namespaces: Vec<String>, // for merged apps 302 - collections: Vec<String>, 303 - did: Option<String>, // DID of the namespace owner (if resolvable) 304 - } 305 - 306 - #[derive(serde::Serialize)] 307 - #[serde(rename_all = "camelCase")] 308 - pub struct InitResponse { 309 - did: String, 310 - handle: String, 311 - pds: String, 312 - avatar: Option<String>, 313 - apps: Vec<AppInfo>, 314 - } 315 - 316 - // Resolve a namespace to its owner DID by reversing and trying as a handle 317 - async fn resolve_namespace_to_did(namespace: &str) -> Option<String> { 318 - // Check cache first 319 - if let Some(cached) = DID_CACHE.get(namespace) { 320 - if cached.timestamp.elapsed() < constants::CACHE_TTL { 321 - return cached.did.clone(); 322 - } 323 - } 324 - 325 - // Cache miss or expired - resolve the DID 326 - // Reverse namespace to get potential domain (e.g., app.bsky -> bsky.app) 327 - let reversed: String = namespace.split('.').rev().collect::<Vec<&str>>().join("."); 328 - 329 - let handles = [reversed.clone(), format!("{}.bsky.social", reversed)]; 330 - 331 - // Try all handle variations concurrently 332 - let futures: Vec<_> = handles 333 - .iter() 334 - .map(|handle| try_resolve_handle_to_did(handle)) 335 - .collect(); 336 - 337 - let results = future::join_all(futures).await; 338 - 339 - // Return first successful resolution 340 - let resolved_did = results.into_iter().flatten().next(); 341 - 342 - // Cache the result (even if None) 343 - DID_CACHE.insert( 344 - namespace.to_string(), 345 - CachedDid { 346 - did: resolved_did.clone(), 347 - timestamp: Instant::now(), 348 - }, 349 - ); 350 - 351 - resolved_did 352 - } 353 - 354 - async fn try_resolve_handle_to_did(handle: &str) -> Option<String> { 355 - let resolve_url = format!("{}?handle={}", constants::BSKY_API_RESOLVE_HANDLE, handle); 356 - 357 - match http_get(&resolve_url).await { 358 - Ok(response) => match response.json::<serde_json::Value>().await { 359 - Ok(data) => data["did"].as_str().map(String::from), 360 - Err(_) => None, 361 - }, 362 - Err(_) => None, 363 - } 364 - } 365 - 366 - #[get("/api/init")] 367 - pub async fn init(query: web::Query<InitQuery>) -> HttpResponse { 368 - let did = &query.did; 369 - 370 - // Fetch DID document 371 - let did_doc_url = format!("{}/{}", constants::PLC_DIRECTORY, did); 372 - let did_doc_response = match http_get(&did_doc_url).await { 373 - Ok(r) => r, 374 - Err(e) => { 375 - return HttpResponse::InternalServerError().json(serde_json::json!({ 376 - "error": format!("failed to fetch DID document: {}", e) 377 - })) 378 - } 379 - }; 380 - 381 - let did_doc: serde_json::Value = match did_doc_response.json().await { 382 - Ok(d) => d, 383 - Err(e) => { 384 - return HttpResponse::InternalServerError().json(serde_json::json!({ 385 - "error": format!("failed to parse DID document: {}", e) 386 - })) 387 - } 388 - }; 389 - 390 - // Extract PDS and handle 391 - let pds = did_doc["service"] 392 - .as_array() 393 - .and_then(|services| { 394 - services 395 - .iter() 396 - .find(|s| s["type"].as_str() == Some("AtprotoPersonalDataServer")) 397 - }) 398 - .and_then(|s| s["serviceEndpoint"].as_str()) 399 - .unwrap_or("") 400 - .to_string(); 401 - 402 - let handle = did_doc["alsoKnownAs"] 403 - .as_array() 404 - .and_then(|aka| aka.first()) 405 - .and_then(|v| v.as_str()) 406 - .map(|s| s.replace("at://", "")) 407 - .unwrap_or_else(|| did.to_string()); 408 - 409 - // Fetch user avatar from Bluesky 410 - let avatar = fetch_user_avatar(did).await; 411 - 412 - // Fetch collections from PDS 413 - let repo_url = format!("{}/xrpc/com.atproto.repo.describeRepo?repo={}", pds, did); 414 - let repo_response = match http_get(&repo_url).await { 415 - Ok(r) => r, 416 - Err(e) => { 417 - return HttpResponse::InternalServerError().json(serde_json::json!({ 418 - "error": format!("failed to fetch repo: {}", e) 419 - })) 420 - } 421 - }; 422 - 423 - let repo_data: serde_json::Value = match repo_response.json().await { 424 - Ok(d) => d, 425 - Err(e) => { 426 - return HttpResponse::InternalServerError().json(serde_json::json!({ 427 - "error": format!("failed to parse repo: {}", e) 428 - })) 429 - } 430 - }; 431 - 432 - let collections = repo_data["collections"] 433 - .as_array() 434 - .map(|arr| { 435 - arr.iter() 436 - .filter_map(|v| v.as_str().map(String::from)) 437 - .collect::<Vec<String>>() 438 - }) 439 - .unwrap_or_default(); 440 - 441 - // Group collections by namespace 442 - let mut namespace_to_collections: std::collections::HashMap<String, Vec<String>> = 443 - std::collections::HashMap::new(); 444 - for collection in collections { 445 - let parts: Vec<&str> = collection.split('.').collect(); 446 - if parts.len() >= 2 { 447 - let namespace = format!("{}.{}", parts[0], parts[1]); 448 - namespace_to_collections 449 - .entry(namespace) 450 - .or_default() 451 - .push(collection); 452 - } 453 - } 454 - 455 - // Only resolve DIDs for known namespaces that should merge (like app.bsky + chat.bsky) 456 - // This avoids expensive HTTP requests for every namespace 457 - let known_merge_namespaces = vec!["app.bsky", "chat.bsky"]; 458 - let namespaces: Vec<String> = namespace_to_collections.keys().cloned().collect(); 459 - 460 - let resolution_futures: Vec<_> = namespaces 461 - .iter() 462 - .map(|ns| { 463 - let ns = ns.clone(); 464 - let known = known_merge_namespaces.clone(); 465 - async move { 466 - if known.contains(&ns.as_str()) { 467 - resolve_namespace_to_did(&ns).await 468 - } else { 469 - None 470 - } 471 - } 472 - }) 473 - .collect(); 474 - 475 - let resolved_dids = future::join_all(resolution_futures).await; 476 - 477 - // Build map of namespace -> (did, collections) 478 - let namespace_data: Vec<(String, Option<String>, Vec<String>)> = namespaces 479 - .into_iter() 480 - .zip(resolved_dids.into_iter()) 481 - .map(|(ns, did)| { 482 - let collections = namespace_to_collections 483 - .get(&ns) 484 - .cloned() 485 - .unwrap_or_default(); 486 - (ns, did, collections) 487 - }) 488 - .collect(); 489 - 490 - // Apply fallback: if namespace didn't resolve, try to find a sibling namespace with same domain 491 - let mut namespace_to_did: std::collections::HashMap<String, Option<String>> = 492 - std::collections::HashMap::new(); 493 - for (namespace, did_opt, _) in &namespace_data { 494 - namespace_to_did.insert(namespace.clone(), did_opt.clone()); 495 - } 496 - 497 - // Build domain -> DIDs map for fallback 498 - let mut domain_to_dids: std::collections::HashMap<String, Vec<String>> = 499 - std::collections::HashMap::new(); 500 - for (namespace, did_opt, _) in &namespace_data { 501 - if let Some(did) = did_opt { 502 - // Extract second-level domain (e.g., "app.bsky" -> "bsky", "chat.bsky" -> "bsky") 503 - let parts: Vec<&str> = namespace.split('.').collect(); 504 - if parts.len() >= 2 { 505 - let domain = parts[1].to_string(); 506 - domain_to_dids.entry(domain).or_default().push(did.clone()); 507 - } 508 - } 509 - } 510 - 511 - // Apply fallback for namespaces that didn't resolve 512 - let mut resolved_namespace_data: Vec<(String, Option<String>, Vec<String>)> = Vec::new(); 513 - for (namespace, did_opt, collections) in namespace_data { 514 - let final_did = if did_opt.is_none() { 515 - // Try fallback: find other namespace with same second-level domain 516 - let parts: Vec<&str> = namespace.split('.').collect(); 517 - if parts.len() >= 2 { 518 - let domain = parts[1].to_string(); 519 - if let Some(dids) = domain_to_dids.get(&domain) { 520 - // Use the first DID we found for this domain 521 - dids.first().cloned() 522 - } else { 523 - None 524 - } 525 - } else { 526 - None 527 - } 528 - } else { 529 - did_opt 530 - }; 531 - resolved_namespace_data.push((namespace, final_did, collections)); 532 - } 533 - 534 - // Group by DID for merging 535 - let mut did_to_namespaces: std::collections::HashMap<String, Vec<(String, Vec<String>)>> = 536 - std::collections::HashMap::new(); 537 - let mut no_did_apps: Vec<(String, Vec<String>)> = Vec::new(); 538 - 539 - for (namespace, did_opt, collections) in resolved_namespace_data { 540 - if let Some(did) = did_opt { 541 - did_to_namespaces 542 - .entry(did) 543 - .or_default() 544 - .push((namespace, collections)); 545 - } else { 546 - no_did_apps.push((namespace, collections)); 547 - } 548 - } 549 - 550 - // Create AppInfo objects - merge namespaces with same DID 551 - let mut apps_list: Vec<AppInfo> = Vec::new(); 552 - 553 - // Add merged apps (same DID) 554 - for (did, namespace_groups) in did_to_namespaces { 555 - let mut all_namespaces: Vec<String> = Vec::new(); 556 - let mut all_collections: Vec<String> = Vec::new(); 557 - 558 - for (ns, collections) in namespace_groups { 559 - all_namespaces.push(ns); 560 - all_collections.extend(collections); 561 - } 562 - 563 - // Sort for consistency 564 - all_namespaces.sort(); 565 - all_collections.sort(); 566 - all_collections.dedup(); 567 - 568 - // Use first namespace as primary 569 - let primary_namespace = all_namespaces.first().cloned().unwrap_or_default(); 570 - 571 - apps_list.push(AppInfo { 572 - namespace: primary_namespace, 573 - namespaces: all_namespaces, 574 - collections: all_collections, 575 - did: Some(did), 576 - }); 577 - } 578 - 579 - // Add apps that couldn't be resolved to a DID 580 - for (namespace, mut collections) in no_did_apps { 581 - collections.sort(); 582 - collections.dedup(); 583 - apps_list.push(AppInfo { 584 - namespace: namespace.clone(), 585 - namespaces: vec![namespace], 586 - collections, 587 - did: None, 588 - }); 589 - } 590 - 591 - HttpResponse::Ok().json(InitResponse { 592 - did: did.to_string(), 593 - handle, 594 - pds, 595 - avatar, 596 - apps: apps_list, 597 - }) 598 - } 599 - 600 - async fn fetch_user_avatar(did: &str) -> Option<String> { 601 - let profile_url = format!("{}?actor={}", constants::BSKY_API_GET_PROFILE, did); 602 - if let Ok(response) = http_get(&profile_url).await { 603 - if let Ok(profile) = response.json::<serde_json::Value>().await { 604 - return profile["avatar"].as_str().map(String::from); 605 - } 606 - } 607 - None 608 - } 609 - 610 - #[derive(Deserialize)] 611 - pub struct SearchHandlesQuery { 612 - q: String, 613 - } 614 - 615 - #[derive(Serialize)] 616 - #[serde(rename_all = "camelCase")] 617 - pub struct HandleSearchResult { 618 - did: String, 619 - handle: String, 620 - display_name: String, 621 - avatar_url: Option<String>, 622 - } 623 - 624 - #[get("/api/search/handles")] 625 - pub async fn search_handles(query: web::Query<SearchHandlesQuery>) -> HttpResponse { 626 - let q = &query.q; 627 - 628 - if q.len() < 2 { 629 - return HttpResponse::Ok().json(serde_json::json!({ 630 - "results": [] 631 - })); 632 - } 633 - 634 - let search_url = format!( 635 - "{}?q={}&limit=8", 636 - constants::BSKY_API_SEARCH_ACTORS, 637 - urlencoding::encode(q) 638 - ); 639 - 640 - match http_get(&search_url).await { 641 - Ok(response) => match response.json::<serde_json::Value>().await { 642 - Ok(data) => { 643 - let results: Vec<HandleSearchResult> = data["actors"] 644 - .as_array() 645 - .map(|actors| { 646 - actors 647 - .iter() 648 - .map(|actor| HandleSearchResult { 649 - did: actor["did"].as_str().unwrap_or("").to_string(), 650 - handle: actor["handle"].as_str().unwrap_or("").to_string(), 651 - display_name: actor["displayName"] 652 - .as_str() 653 - .unwrap_or_else(|| actor["handle"].as_str().unwrap_or("")) 654 - .to_string(), 655 - avatar_url: actor["avatar"].as_str().map(String::from), 656 - }) 657 - .collect() 658 - }) 659 - .unwrap_or_default(); 660 - 661 - HttpResponse::Ok().json(serde_json::json!({ 662 - "results": results 663 - })) 664 - } 665 - Err(e) => { 666 - log::error!("Failed to parse search response: {}", e); 667 - HttpResponse::Ok().json(serde_json::json!({ 668 - "results": [] 669 - })) 670 - } 671 - }, 672 - Err(e) => { 673 - log::error!("Failed to search handles: {}", e); 674 - HttpResponse::Ok().json(serde_json::json!({ 675 - "results": [] 676 - })) 677 - } 678 - } 679 - } 680 - 681 - #[derive(Deserialize)] 682 - pub struct AvatarQuery { 683 - namespace: String, 684 - } 685 - 686 - #[derive(Deserialize)] 687 - pub struct AvatarBatchRequest { 688 - namespaces: Vec<String>, 689 - } 690 - 691 - #[get("/api/avatar")] 692 - pub async fn get_avatar(query: web::Query<AvatarQuery>) -> HttpResponse { 693 - let namespace = &query.namespace; 694 - 695 - // Check cache first 696 - { 697 - let cache = AVATAR_CACHE.lock().unwrap(); 698 - if let Some(cached) = cache.get(namespace) { 699 - if cached.timestamp.elapsed() < constants::CACHE_TTL { 700 - return HttpResponse::Ok() 701 - .insert_header(("Cache-Control", constants::HTTP_CACHE_CONTROL)) 702 - .json(serde_json::json!({ 703 - "avatarUrl": cached.url 704 - })); 705 - } 706 - } 707 - } 708 - 709 - // Cache miss or expired - fetch avatar 710 - let avatar_url = fetch_avatar_for_namespace(namespace).await; 711 - 712 - // Cache the result 713 - { 714 - let mut cache = AVATAR_CACHE.lock().unwrap(); 715 - cache.insert( 716 - namespace.clone(), 717 - CachedAvatar { 718 - url: avatar_url.clone(), 719 - timestamp: Instant::now(), 720 - }, 721 - ); 722 - } 723 - 724 - HttpResponse::Ok() 725 - .insert_header(("Cache-Control", constants::HTTP_CACHE_CONTROL)) 726 - .json(serde_json::json!({ 727 - "avatarUrl": avatar_url 728 - })) 729 - } 730 - 731 - #[post("/api/avatar/batch")] 732 - pub async fn get_avatar_batch(payload: web::Json<AvatarBatchRequest>) -> HttpResponse { 733 - let mut requested: Vec<String> = Vec::new(); 734 - let mut seen = HashSet::new(); 735 - 736 - for raw in &payload.namespaces { 737 - let trimmed = raw.trim(); 738 - if trimmed.is_empty() { 739 - continue; 740 - } 741 - if seen.insert(trimmed.to_string()) { 742 - requested.push(trimmed.to_string()); 743 - } 744 - } 745 - 746 - let mut avatars: HashMap<String, Option<String>> = HashMap::new(); 747 - let mut to_fetch: Vec<String> = Vec::new(); 748 - 749 - { 750 - let cache = AVATAR_CACHE.lock().unwrap(); 751 - for namespace in &requested { 752 - if let Some(entry) = cache.get(namespace) { 753 - if entry.timestamp.elapsed() < constants::CACHE_TTL { 754 - avatars.insert(namespace.clone(), entry.url.clone()); 755 - continue; 756 - } 757 - } 758 - to_fetch.push(namespace.clone()); 759 - } 760 - } 761 - 762 - if !to_fetch.is_empty() { 763 - let mut futures: FuturesUnordered<_> = to_fetch 764 - .into_iter() 765 - .map(|namespace| async move { 766 - let avatar_url = fetch_avatar_for_namespace(&namespace).await; 767 - (namespace, avatar_url) 768 - }) 769 - .collect(); 770 - 771 - while let Some((namespace, avatar_url)) = futures.next().await { 772 - { 773 - let mut cache = AVATAR_CACHE.lock().unwrap(); 774 - cache.insert( 775 - namespace.clone(), 776 - CachedAvatar { 777 - url: avatar_url.clone(), 778 - timestamp: Instant::now(), 779 - }, 780 - ); 781 - } 782 - avatars.insert(namespace, avatar_url); 783 - } 784 - } 785 - 786 - HttpResponse::Ok() 787 - .insert_header(("Cache-Control", constants::HTTP_CACHE_CONTROL)) 788 - .json(serde_json::json!({ 789 - "avatars": avatars 790 - })) 791 - } 792 - 793 - async fn fetch_avatar_for_namespace(namespace: &str) -> Option<String> { 794 - if let Some(did) = resolve_namespace_to_did(namespace).await { 795 - return fetch_user_avatar(&did).await; 796 - } 797 - None 798 - } 799 - 800 - #[derive(Deserialize)] 801 - pub struct ValidateUrlQuery { 802 - url: String, 803 - } 804 - 805 - #[get("/api/validate-url")] 806 - pub async fn validate_url(query: web::Query<ValidateUrlQuery>) -> HttpResponse { 807 - let url = &query.url; 808 - 809 - // Build client with redirect following and timeout 810 - let client = reqwest::Client::builder() 811 - .timeout(std::time::Duration::from_secs(3)) 812 - .redirect(reqwest::redirect::Policy::limited(5)) 813 - .build() 814 - .unwrap(); 815 - 816 - // Try HEAD first, fall back to GET if HEAD doesn't succeed 817 - let is_valid = match client.head(url).send().await { 818 - Ok(response) => { 819 - let status = response.status(); 820 - if status.is_success() || status.is_redirection() { 821 - true 822 - } else { 823 - // HEAD returned error status (like 405), try GET 824 - match client.get(url).send().await { 825 - Ok(get_response) => get_response.status().is_success(), 826 - Err(_) => false, 827 - } 828 - } 829 - } 830 - Err(_) => { 831 - // HEAD request failed completely, try GET as fallback 832 - match client.get(url).send().await { 833 - Ok(response) => response.status().is_success(), 834 - Err(_) => false, 835 - } 836 - } 837 - }; 838 - 839 - HttpResponse::Ok().json(serde_json::json!({ 840 - "valid": is_valid 841 - })) 842 - } 843 - 844 - #[derive(Deserialize)] 845 - pub struct RecordQuery { 846 - pds: String, 847 - did: String, 848 - collection: String, 849 - rkey: String, 850 - } 851 - 852 - #[get("/api/record")] 853 - pub async fn get_record(query: web::Query<RecordQuery>) -> HttpResponse { 854 - let record_url = format!( 855 - "{}/xrpc/com.atproto.repo.getRecord?repo={}&collection={}&rkey={}", 856 - query.pds, query.did, query.collection, query.rkey 857 - ); 858 - 859 - match http_get(&record_url).await { 860 - Ok(response) => { 861 - if !response.status().is_success() { 862 - return HttpResponse::Ok().json(serde_json::json!({ 863 - "error": "record not found" 864 - })); 865 - } 866 - 867 - match response.json::<serde_json::Value>().await { 868 - Ok(data) => HttpResponse::Ok().json(data), 869 - Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({ 870 - "error": format!("failed to parse record: {}", e) 871 - })), 872 - } 873 - } 874 - Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({ 875 - "error": format!("failed to fetch record: {}", e) 876 - })), 877 - } 878 - } 879 - 880 - #[derive(Deserialize)] 881 - pub struct SignGuestbookRequest { 882 - text: Option<String>, 883 - } 884 - 885 - #[post("/api/sign-guestbook")] 886 - pub async fn sign_guestbook( 887 - session: Session, 888 - body: web::Json<SignGuestbookRequest>, 889 - ) -> HttpResponse { 890 - // Check if user is logged in 891 - let did: Option<String> = match session.get(constants::SESSION_KEY_DID) { 892 - Ok(d) => d, 893 - Err(_) => { 894 - return HttpResponse::Unauthorized().json(serde_json::json!({ 895 - "error": "not authenticated" 896 - })) 897 - } 898 - }; 899 - 900 - let did = match did { 901 - Some(d) => d, 902 - None => { 903 - return HttpResponse::Unauthorized().json(serde_json::json!({ 904 - "error": "not authenticated" 905 - })) 906 - } 907 - }; 908 - 909 - // Retrieve authenticated agent from cache 910 - let agent = match AGENT_CACHE.get(&did) { 911 - Some(a) => a.clone(), 912 - None => { 913 - return HttpResponse::Unauthorized().json(serde_json::json!({ 914 - "error": "session expired, please log in again" 915 - })) 916 - } 917 - }; 918 - 919 - // Create the visit record with optional text 920 - let mut record_json = serde_json::json!({ 921 - "$type": constants::GUESTBOOK_COLLECTION, 922 - "timestamp": chrono::Utc::now().to_rfc3339(), 923 - "createdAt": chrono::Utc::now().to_rfc3339(), 924 - }); 925 - 926 - // Add text field if provided 927 - if let Some(text) = &body.text { 928 - if !text.trim().is_empty() { 929 - record_json["text"] = serde_json::Value::String(text.clone()); 930 - } 931 - } 932 - 933 - // Convert to Unknown type 934 - let record: atrium_api::types::Unknown = serde_json::from_value(record_json) 935 - .map_err(|e| { 936 - HttpResponse::InternalServerError().json(serde_json::json!({ 937 - "error": format!("failed to serialize record: {}", e) 938 - })) 939 - }) 940 - .unwrap(); 941 - 942 - // Create the record in the user's PDS 943 - let input = atrium_api::com::atproto::repo::create_record::InputData { 944 - collection: atrium_api::types::string::Nsid::new( 945 - constants::GUESTBOOK_COLLECTION.to_string(), 946 - ) 947 - .unwrap(), 948 - record, 949 - repo: atrium_api::types::string::AtIdentifier::Did( 950 - atrium_api::types::string::Did::new(did.clone()).unwrap(), 951 - ), 952 - rkey: None, 953 - swap_commit: None, 954 - validate: None, 955 - }; 956 - 957 - match agent.api.com.atproto.repo.create_record(input.into()).await { 958 - Ok(output) => { 959 - // Fetch fresh data from UFOs and add this signature 960 - match fetch_signatures_from_ufos().await { 961 - Ok(mut signatures) => { 962 - // Add the user's signature to the cache 963 - let (handle, avatar) = fetch_profile_info(&did).await; 964 - let new_signature = GuestbookSignature { 965 - did: did.clone(), 966 - handle, 967 - avatar, 968 - timestamp: chrono::Utc::now().to_rfc3339(), 969 - text: body.text.clone(), 970 - }; 971 - 972 - // Add at the beginning (most recent) 973 - signatures.insert(0, new_signature); 974 - 975 - // Update cache 976 - { 977 - let mut cache = GUESTBOOK_CACHE.lock().unwrap(); 978 - *cache = Some(CachedGuestbookSignatures { signatures }); 979 - } 980 - 981 - log::info!("Added signature to cache for DID: {}", did); 982 - } 983 - Err(e) => { 984 - log::warn!( 985 - "Failed to update cache after signing, invalidating instead: {}", 986 - e 987 - ); 988 - invalidate_guestbook_cache(); 989 - } 990 - } 991 - 992 - HttpResponse::Ok().json(serde_json::json!({ 993 - "success": true, 994 - "uri": output.data.uri, 995 - "cid": output.data.cid, 996 - })) 997 - } 998 - Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({ 999 - "error": format!("failed to create record: {}", e) 1000 - })), 1001 - } 1002 - } 1003 - 1004 - use actix_web::delete; 1005 - 1006 - #[get("/api/auth/status")] 1007 - pub async fn auth_status(session: Session) -> HttpResponse { 1008 - let did: Option<String> = session.get(constants::SESSION_KEY_DID).unwrap_or(None); 1009 - 1010 - let mut has_records = false; 1011 - let mut handle: Option<String> = None; 1012 - let mut avatar: Option<String> = None; 1013 - 1014 - // If authenticated, check if user has guestbook records and fetch their profile 1015 - if let Some(ref did_str) = did { 1016 - if let Some(agent) = AGENT_CACHE.get(did_str) { 1017 - let list_input = atrium_api::com::atproto::repo::list_records::ParametersData { 1018 - collection: atrium_api::types::string::Nsid::new( 1019 - constants::GUESTBOOK_COLLECTION.to_string(), 1020 - ) 1021 - .unwrap(), 1022 - repo: atrium_api::types::string::AtIdentifier::Did( 1023 - atrium_api::types::string::Did::new(did_str.clone()).unwrap(), 1024 - ), 1025 - cursor: None, 1026 - limit: Some(atrium_api::types::LimitedNonZeroU8::try_from(1).unwrap()), 1027 - reverse: None, 1028 - }; 1029 - 1030 - if let Ok(output) = agent 1031 - .api 1032 - .com 1033 - .atproto 1034 - .repo 1035 - .list_records(list_input.into()) 1036 - .await 1037 - { 1038 - has_records = !output.data.records.is_empty(); 1039 - } 1040 - } 1041 - 1042 - // Fetch profile info for authenticated user 1043 - let (fetched_handle, fetched_avatar) = fetch_profile_info(did_str).await; 1044 - handle = fetched_handle; 1045 - avatar = fetched_avatar; 1046 - } 1047 - 1048 - HttpResponse::Ok().json(serde_json::json!({ 1049 - "authenticated": did.is_some(), 1050 - "did": did, 1051 - "handle": handle, 1052 - "avatar": avatar, 1053 - "hasRecords": has_records 1054 - })) 1055 - } 1056 - 1057 - #[delete("/api/sign-guestbook")] 1058 - pub async fn unsign_guestbook(session: Session) -> HttpResponse { 1059 - // Check if user is logged in 1060 - let did: Option<String> = match session.get(constants::SESSION_KEY_DID) { 1061 - Ok(d) => d, 1062 - Err(_) => { 1063 - return HttpResponse::Unauthorized().json(serde_json::json!({ 1064 - "error": "not authenticated" 1065 - })) 1066 - } 1067 - }; 1068 - 1069 - let did = match did { 1070 - Some(d) => d, 1071 - None => { 1072 - return HttpResponse::Unauthorized().json(serde_json::json!({ 1073 - "error": "not authenticated" 1074 - })) 1075 - } 1076 - }; 1077 - 1078 - // Retrieve authenticated agent from cache 1079 - let agent = match AGENT_CACHE.get(&did) { 1080 - Some(a) => a.clone(), 1081 - None => { 1082 - return HttpResponse::Unauthorized().json(serde_json::json!({ 1083 - "error": "session expired, please log in again" 1084 - })) 1085 - } 1086 - }; 1087 - 1088 - // List all guestbook records for this user 1089 - let list_input = atrium_api::com::atproto::repo::list_records::ParametersData { 1090 - collection: atrium_api::types::string::Nsid::new( 1091 - constants::GUESTBOOK_COLLECTION.to_string(), 1092 - ) 1093 - .unwrap(), 1094 - repo: atrium_api::types::string::AtIdentifier::Did( 1095 - atrium_api::types::string::Did::new(did.clone()).unwrap(), 1096 - ), 1097 - cursor: None, 1098 - limit: Some(atrium_api::types::LimitedNonZeroU8::try_from(100).unwrap()), 1099 - reverse: None, 1100 - }; 1101 - 1102 - let records = match agent 1103 - .api 1104 - .com 1105 - .atproto 1106 - .repo 1107 - .list_records(list_input.into()) 1108 - .await 1109 - { 1110 - Ok(output) => output.data.records, 1111 - Err(e) => { 1112 - return HttpResponse::InternalServerError().json(serde_json::json!({ 1113 - "error": format!("failed to list records: {}", e) 1114 - })) 1115 - } 1116 - }; 1117 - 1118 - if records.is_empty() { 1119 - return HttpResponse::Ok().json(serde_json::json!({ 1120 - "success": true, 1121 - "deleted": 0, 1122 - "message": "no guestbook records found" 1123 - })); 1124 - } 1125 - 1126 - // Delete all guestbook records 1127 - let mut deleted_count = 0; 1128 - for record in records { 1129 - // Extract rkey from URI (at://did/collection/rkey) 1130 - let uri_parts: Vec<&str> = record.uri.split('/').collect(); 1131 - if let Some(rkey) = uri_parts.last() { 1132 - let delete_input = atrium_api::com::atproto::repo::delete_record::InputData { 1133 - collection: atrium_api::types::string::Nsid::new( 1134 - constants::GUESTBOOK_COLLECTION.to_string(), 1135 - ) 1136 - .unwrap(), 1137 - repo: atrium_api::types::string::AtIdentifier::Did( 1138 - atrium_api::types::string::Did::new(did.clone()).unwrap(), 1139 - ), 1140 - rkey: atrium_api::types::string::RecordKey::new(rkey.to_string()).unwrap(), 1141 - swap_commit: None, 1142 - swap_record: None, 1143 - }; 1144 - 1145 - match agent 1146 - .api 1147 - .com 1148 - .atproto 1149 - .repo 1150 - .delete_record(delete_input.into()) 1151 - .await 1152 - { 1153 - Ok(_) => deleted_count += 1, 1154 - Err(e) => { 1155 - log::error!("Failed to delete record {}: {}", rkey, e); 1156 - } 1157 - } 1158 - } 1159 - } 1160 - 1161 - // Fetch fresh data from UFOs and remove this DID 1162 - match fetch_signatures_from_ufos().await { 1163 - Ok(mut signatures) => { 1164 - // Remove the user's signature from the cache 1165 - signatures.retain(|sig| sig.did != did); 1166 - 1167 - // Update cache 1168 - { 1169 - let mut cache = GUESTBOOK_CACHE.lock().unwrap(); 1170 - *cache = Some(CachedGuestbookSignatures { 1171 - signatures: signatures.clone(), 1172 - }); 1173 - } 1174 - } 1175 - Err(e) => { 1176 - log::warn!( 1177 - "Failed to update cache after unsigning, invalidating instead: {}", 1178 - e 1179 - ); 1180 - invalidate_guestbook_cache(); 1181 - } 1182 - } 1183 - 1184 - HttpResponse::Ok().json(serde_json::json!({ 1185 - "success": true, 1186 - "deleted": deleted_count 1187 - })) 1188 - } 1189 - 1190 - #[derive(Deserialize)] 1191 - pub struct FirehoseQuery { 1192 - did: String, 1193 - } 1194 - 1195 - #[get("/api/guestbook/signatures")] 1196 - pub async fn get_guestbook_signatures() -> HttpResponse { 1197 - // Check cache first 1198 - { 1199 - let cache = GUESTBOOK_CACHE.lock().unwrap(); 1200 - if let Some(cached) = cache.as_ref() { 1201 - // Cache is valid - return cached signatures 1202 - log::info!( 1203 - "Returning {} signatures from cache", 1204 - cached.signatures.len() 1205 - ); 1206 - log::info!( 1207 - "Cached signature DIDs: {:?}", 1208 - cached.signatures.iter().map(|s| &s.did).collect::<Vec<_>>() 1209 - ); 1210 - return HttpResponse::Ok() 1211 - .insert_header(("Cache-Control", "public, max-age=10")) 1212 - .json(&cached.signatures); 1213 - } 1214 - } 1215 - 1216 - // Cache miss or invalidated - fetch from UFOs API 1217 - log::info!("Cache miss - fetching from UFOs API"); 1218 - match fetch_signatures_from_ufos().await { 1219 - Ok(signatures) => { 1220 - // Update cache 1221 - { 1222 - let mut cache = GUESTBOOK_CACHE.lock().unwrap(); 1223 - *cache = Some(CachedGuestbookSignatures { 1224 - signatures: signatures.clone(), 1225 - }); 1226 - } 1227 - 1228 - log::info!("Returning {} signatures from UFOs API", signatures.len()); 1229 - HttpResponse::Ok() 1230 - .insert_header(("Cache-Control", "public, max-age=10")) 1231 - .json(signatures) 1232 - } 1233 - Err(e) => { 1234 - log::error!("Failed to fetch signatures from UFOs: {}", e); 1235 - HttpResponse::InternalServerError().json(serde_json::json!({ 1236 - "error": e 1237 - })) 1238 - } 1239 - } 1240 - } 1241 - 1242 - async fn fetch_profile_info(did: &str) -> (Option<String>, Option<String>) { 1243 - // Fetch DID document for handle 1244 - let did_doc_url = format!("{}/{}", constants::PLC_DIRECTORY, did); 1245 - let handle = if let Ok(response) = http_get(&did_doc_url).await { 1246 - if let Ok(doc) = response.json::<serde_json::Value>().await { 1247 - doc["alsoKnownAs"] 1248 - .as_array() 1249 - .and_then(|aka| aka.first()) 1250 - .and_then(|v| v.as_str()) 1251 - .map(|s| s.replace("at://", "")) 1252 - } else { 1253 - None 1254 - } 1255 - } else { 1256 - None 1257 - }; 1258 - 1259 - // Fetch avatar 1260 - let avatar = fetch_user_avatar(did).await; 1261 - 1262 - (handle, avatar) 1263 - } 1264 - 1265 - async fn fetch_signatures_from_ufos() -> Result<Vec<GuestbookSignature>, String> { 1266 - // Fetch all guestbook records from UFOs API 1267 - let ufos_url = format!( 1268 - "https://ufos-api.microcosm.blue/records?collection={}", 1269 - constants::GUESTBOOK_COLLECTION 1270 - ); 1271 - 1272 - log::info!("Fetching guestbook signatures from UFOs API"); 1273 - 1274 - let response = http_get(&ufos_url) 1275 - .await 1276 - .map_err(|e| format!("failed to fetch from UFOs API: {}", e))?; 1277 - 1278 - let records: Vec<UfosRecord> = response 1279 - .json() 1280 - .await 1281 - .map_err(|e| format!("failed to parse UFOs response: {}", e))?; 1282 - 1283 - log::info!("Fetched {} records from UFOs API", records.len()); 1284 - 1285 - // Fetch profile info for each DID in parallel 1286 - let profile_futures: Vec<_> = records 1287 - .iter() 1288 - .map(|record| { 1289 - let did = record.did.clone(); 1290 - let timestamp = record.record["createdAt"] 1291 - .as_str() 1292 - .unwrap_or("") 1293 - .to_string(); 1294 - let text = record.record["text"].as_str().map(String::from); 1295 - async move { 1296 - let (handle, avatar) = fetch_profile_info(&did).await; 1297 - GuestbookSignature { 1298 - did, 1299 - handle, 1300 - avatar, 1301 - timestamp, 1302 - text, 1303 - } 1304 - } 1305 - }) 1306 - .collect(); 1307 - 1308 - let mut signatures = future::join_all(profile_futures).await; 1309 - 1310 - // Sort by timestamp (most recent first) 1311 - signatures.sort_by(|a, b| b.timestamp.cmp(&a.timestamp)); 1312 - 1313 - log::info!( 1314 - "Processed {} signatures with profile info", 1315 - signatures.len() 1316 - ); 1317 - 1318 - Ok(signatures) 1319 - } 1320 - 1321 - fn invalidate_guestbook_cache() { 1322 - let mut cache = GUESTBOOK_CACHE.lock().unwrap(); 1323 - *cache = None; 1324 - log::info!("Invalidated guestbook cache"); 1325 - } 1326 - 1327 - #[derive(Deserialize)] 1328 - pub struct CheckSignatureQuery { 1329 - did: String, 1330 - } 1331 - 1332 - #[get("/api/guestbook/check-signature")] 1333 - pub async fn check_page_owner_signature(query: web::Query<CheckSignatureQuery>) -> HttpResponse { 1334 - let did = &query.did; 1335 - 1336 - log::info!( 1337 - "Checking if DID has signed guestbook by querying their PDS: {}", 1338 - did 1339 - ); 1340 - 1341 - // Fetch DID document to get PDS URL 1342 - let did_doc_url = format!("{}/{}", constants::PLC_DIRECTORY, did); 1343 - let pds = match http_get(&did_doc_url).await { 1344 - Ok(response) => match response.json::<serde_json::Value>().await { 1345 - Ok(doc) => doc["service"] 1346 - .as_array() 1347 - .and_then(|services| { 1348 - services 1349 - .iter() 1350 - .find(|s| s["type"].as_str() == Some("AtprotoPersonalDataServer")) 1351 - }) 1352 - .and_then(|s| s["serviceEndpoint"].as_str()) 1353 - .unwrap_or("") 1354 - .to_string(), 1355 - Err(e) => { 1356 - log::error!("Failed to parse DID document: {}", e); 1357 - return HttpResponse::InternalServerError().json(serde_json::json!({ 1358 - "error": "failed to fetch DID document" 1359 - })); 1360 - } 1361 - }, 1362 - Err(e) => { 1363 - log::error!("Failed to fetch DID document: {}", e); 1364 - return HttpResponse::InternalServerError().json(serde_json::json!({ 1365 - "error": "failed to fetch DID document" 1366 - })); 1367 - } 1368 - }; 1369 - 1370 - // Query the PDS for guestbook records 1371 - let records_url = format!( 1372 - "{}/xrpc/com.atproto.repo.listRecords?repo={}&collection={}&limit=1", 1373 - pds, 1374 - did, 1375 - constants::GUESTBOOK_COLLECTION 1376 - ); 1377 - 1378 - match http_get(&records_url).await { 1379 - Ok(response) => { 1380 - if !response.status().is_success() { 1381 - // No records found or collection doesn't exist 1382 - log::info!("No guestbook records found for DID: {}", did); 1383 - return HttpResponse::Ok().json(serde_json::json!({ 1384 - "hasSigned": false 1385 - })); 1386 - } 1387 - 1388 - match response.json::<serde_json::Value>().await { 1389 - Ok(data) => { 1390 - let has_records = data["records"] 1391 - .as_array() 1392 - .map(|arr| !arr.is_empty()) 1393 - .unwrap_or(false); 1394 - 1395 - log::info!("DID {} has signed: {}", did, has_records); 1396 - 1397 - HttpResponse::Ok().json(serde_json::json!({ 1398 - "hasSigned": has_records 1399 - })) 1400 - } 1401 - Err(e) => { 1402 - log::error!("Failed to parse records response: {}", e); 1403 - HttpResponse::InternalServerError().json(serde_json::json!({ 1404 - "error": "failed to parse records" 1405 - })) 1406 - } 1407 - } 1408 - } 1409 - Err(e) => { 1410 - log::error!("Failed to fetch records from PDS: {}", e); 1411 - HttpResponse::InternalServerError().json(serde_json::json!({ 1412 - "error": "failed to fetch records" 1413 - })) 1414 - } 1415 - } 1416 - } 1417 - 1418 - #[get("/api/firehose/watch")] 1419 - pub async fn firehose_watch( 1420 - query: web::Query<FirehoseQuery>, 1421 - manager: web::Data<FirehoseManager>, 1422 - ) -> HttpResponse { 1423 - let did = query.did.clone(); 1424 - 1425 - // Fetch DID document to get PDS 1426 - let did_doc_url = format!("{}/{}", constants::PLC_DIRECTORY, did); 1427 - let did_doc = match http_get(&did_doc_url).await { 1428 - Ok(r) => match r.json::<serde_json::Value>().await { 1429 - Ok(d) => d, 1430 - Err(e) => { 1431 - log::error!("Failed to parse DID document: {}", e); 1432 - return HttpResponse::InternalServerError().json(serde_json::json!({ 1433 - "error": "failed to fetch user data" 1434 - })); 1435 - } 1436 - }, 1437 - Err(e) => { 1438 - log::error!("Failed to fetch DID document: {}", e); 1439 - return HttpResponse::InternalServerError().json(serde_json::json!({ 1440 - "error": "failed to fetch user data" 1441 - })); 1442 - } 1443 - }; 1444 - 1445 - let pds = did_doc["service"] 1446 - .as_array() 1447 - .and_then(|services| { 1448 - services 1449 - .iter() 1450 - .find(|s| s["type"].as_str() == Some("AtprotoPersonalDataServer")) 1451 - }) 1452 - .and_then(|s| s["serviceEndpoint"].as_str()) 1453 - .unwrap_or("") 1454 - .to_string(); 1455 - 1456 - // Fetch collections from PDS 1457 - let repo_url = format!("{}/xrpc/com.atproto.repo.describeRepo?repo={}", pds, did); 1458 - let mut collections = match http_get(&repo_url).await { 1459 - Ok(r) => match r.json::<serde_json::Value>().await { 1460 - Ok(repo_data) => repo_data["collections"] 1461 - .as_array() 1462 - .map(|arr| { 1463 - arr.iter() 1464 - .filter_map(|v| v.as_str().map(String::from)) 1465 - .collect::<Vec<String>>() 1466 - }) 1467 - .unwrap_or_default(), 1468 - Err(e) => { 1469 - log::error!("Failed to parse repo data: {}", e); 1470 - vec![] 1471 - } 1472 - }, 1473 - Err(e) => { 1474 - log::error!("Failed to fetch repo: {}", e); 1475 - vec![] 1476 - } 1477 - }; 1478 - 1479 - // Always include guestbook collection, even if it doesn't exist yet 1480 - if !collections.contains(&constants::GUESTBOOK_COLLECTION.to_string()) { 1481 - collections.push(constants::GUESTBOOK_COLLECTION.to_string()); 1482 - } 1483 - 1484 - log::info!( 1485 - "Fetched {} collections for DID: {} (including guestbook)", 1486 - collections.len(), 1487 - did 1488 - ); 1489 - 1490 - // Get or create a broadcaster for this DID with its collections 1491 - let broadcaster = 1492 - crate::firehose::get_or_create_broadcaster(&manager, did.clone(), collections).await; 1493 - let mut rx = broadcaster.subscribe(); 1494 - 1495 - log::info!("SSE connection established for DID: {}", did); 1496 - 1497 - let stream = async_stream::stream! { 1498 - // Send initial connection message 1499 - yield Ok::<_, actix_web::Error>( 1500 - web::Bytes::from("data: {\"type\":\"connected\"}\n\n".to_string()) 1501 - ); 1502 - 1503 - log::info!("Sent initial connection message to client"); 1504 - 1505 - // Stream firehose events (already filtered by DID at Jetstream level) 1506 - while let Ok(event) = rx.recv().await { 1507 - log::info!("Sending event to client: {} {} {}", event.action, event.did, event.collection); 1508 - let json = serde_json::to_string(&event).unwrap_or_default(); 1509 - yield Ok(web::Bytes::from(format!("data: {}\n\n", json))); 1510 - } 1511 - }; 1512 - 1513 - HttpResponse::Ok() 1514 - .content_type("text/event-stream") 1515 - .insert_header(("Cache-Control", "no-cache")) 1516 - .insert_header(("X-Accel-Buffering", "no")) 1517 - .streaming(Box::pin(stream)) 1518 - }
-8
src/templates.rs
··· 1 - pub fn landing_page() -> &'static str { 2 - include_str!("templates/landing.html") 3 - } 4 - 5 - pub fn app_page(did: &str) -> String { 6 - let template = include_str!("templates/app.html"); 7 - template.replace("{DID}", did) 8 - }
-2217
src/templates/app.html
··· 1 - <!DOCTYPE html> 2 - <html> 3 - 4 - <head> 5 - <meta charset="UTF-8"> 6 - <meta name="viewport" content="width=device-width, initial-scale=1.0"> 7 - <title>@me - explore your atproto identity</title> 8 - <link rel="icon" type="image/svg+xml" href="/favicon.svg"> 9 - 10 - <!-- Open Graph / Facebook --> 11 - <meta property="og:type" content="website"> 12 - <meta property="og:url" content="https://at-me.fly.dev/"> 13 - <meta property="og:title" content="@me - explore your atproto identity"> 14 - <meta property="og:description" 15 - content="visualize your decentralized identity and see what apps have stored data in your Personal Data Server"> 16 - <meta property="og:image" content="https://at-me.fly.dev/static/og-image.png"> 17 - 18 - <!-- Twitter --> 19 - <meta property="twitter:card" content="summary_large_image"> 20 - <meta property="twitter:url" content="https://at-me.fly.dev/"> 21 - <meta property="twitter:title" content="@me - explore your atproto identity"> 22 - <meta property="twitter:description" 23 - content="visualize your decentralized identity and see what apps have stored data in your Personal Data Server"> 24 - <meta property="twitter:image" content="https://at-me.fly.dev/static/og-image.png"> 25 - 26 - <style> 27 - * { 28 - margin: 0; 29 - padding: 0; 30 - box-sizing: border-box; 31 - } 32 - 33 - :root { 34 - --bg: #f5f1e8; 35 - --text: #4a4238; 36 - --text-light: #8a7a6a; 37 - --text-lighter: #6b5d4f; 38 - --border: #c9bfa8; 39 - --surface: #e5dbc8; 40 - --surface-hover: #d9cdb5; 41 - } 42 - 43 - @media (prefers-color-scheme: dark) { 44 - :root { 45 - --bg: #1a1a1a; 46 - --text: #e5e5e5; 47 - --text-light: #a0a0a0; 48 - --text-lighter: #c0c0c0; 49 - --border: #404040; 50 - --surface: #2a2a2a; 51 - --surface-hover: #353535; 52 - } 53 - } 54 - 55 - body { 56 - font-family: ui-monospace, 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Menlo, 'Courier New', monospace; 57 - height: 100vh; 58 - background: var(--bg); 59 - color: var(--text); 60 - overflow: hidden; 61 - position: relative; 62 - -webkit-font-smoothing: antialiased; 63 - -moz-osx-font-smoothing: grayscale; 64 - } 65 - 66 - .canvas { 67 - position: fixed; 68 - inset: 0; 69 - } 70 - 71 - .info { 72 - position: fixed; 73 - bottom: clamp(0.75rem, 2vmin, 1rem); 74 - left: clamp(0.75rem, 2vmin, 1rem); 75 - width: clamp(32px, 7vmin, 40px); 76 - height: clamp(32px, 7vmin, 40px); 77 - display: flex; 78 - align-items: center; 79 - justify-content: center; 80 - font-size: clamp(0.85rem, 1.8vmin, 1rem); 81 - color: var(--text-light); 82 - cursor: pointer; 83 - transition: all 0.2s ease; 84 - z-index: 100; 85 - -webkit-tap-highlight-color: transparent; 86 - } 87 - 88 - .info:hover, 89 - .info:active { 90 - color: var(--text); 91 - } 92 - 93 - .info-modal { 94 - position: fixed; 95 - top: 50%; 96 - left: 50%; 97 - transform: translate(-50%, -50%); 98 - background: var(--surface); 99 - border: 2px solid var(--border); 100 - padding: 2rem; 101 - max-width: 500px; 102 - width: 90%; 103 - z-index: 2000; 104 - display: none; 105 - border-radius: 4px; 106 - } 107 - 108 - @media (max-width: 768px) { 109 - .info-modal { 110 - padding: 1.5rem; 111 - width: 95%; 112 - } 113 - 114 - .info-modal h2 { 115 - font-size: 0.9rem; 116 - } 117 - 118 - .info-modal p { 119 - font-size: 0.7rem; 120 - } 121 - } 122 - 123 - .info-modal.visible { 124 - display: block; 125 - } 126 - 127 - .info-modal h2 { 128 - margin-bottom: 1rem; 129 - font-size: 1rem; 130 - color: var(--text); 131 - } 132 - 133 - .info-modal p { 134 - margin-bottom: 0.75rem; 135 - font-size: 0.75rem; 136 - line-height: 1.5; 137 - color: var(--text-lighter); 138 - } 139 - 140 - .info-modal button { 141 - margin-top: 1rem; 142 - padding: 0.5rem 1rem; 143 - background: var(--bg); 144 - border: 1px solid var(--border); 145 - color: var(--text); 146 - font-family: inherit; 147 - font-size: 0.7rem; 148 - cursor: pointer; 149 - transition: all 0.2s ease; 150 - -webkit-tap-highlight-color: transparent; 151 - border-radius: 2px; 152 - } 153 - 154 - .info-modal button:hover, 155 - .info-modal button:active { 156 - background: var(--surface-hover); 157 - border-color: var(--text-light); 158 - } 159 - 160 - @media (max-width: 768px) { 161 - .info-modal button { 162 - padding: 0.65rem 1.2rem; 163 - font-size: 0.75rem; 164 - } 165 - } 166 - 167 - .overlay { 168 - position: fixed; 169 - top: 0; 170 - left: 0; 171 - right: 0; 172 - bottom: 0; 173 - background: rgba(0, 0, 0, 0.5); 174 - z-index: 1999; 175 - display: none; 176 - } 177 - 178 - .overlay.visible { 179 - display: block; 180 - } 181 - 182 - .identity { 183 - position: absolute; 184 - left: 50%; 185 - top: 50%; 186 - transform: translate(-50%, -50%); 187 - background: var(--surface); 188 - border: 2px solid var(--text-light); 189 - border-radius: 50%; 190 - width: clamp(100px, 20vmin, 140px); 191 - height: clamp(100px, 20vmin, 140px); 192 - display: flex; 193 - flex-direction: column; 194 - align-items: center; 195 - justify-content: center; 196 - z-index: 10; 197 - cursor: pointer; 198 - transition: all 0.2s ease; 199 - -webkit-tap-highlight-color: transparent; 200 - } 201 - 202 - .identity:hover, 203 - .identity:active { 204 - transform: translate(-50%, -50%) scale(1.05); 205 - border-color: var(--text); 206 - box-shadow: 0 0 20px rgba(255, 255, 255, 0.1); 207 - } 208 - 209 - .identity-label { 210 - font-size: clamp(1rem, 2vmin, 1.2rem); 211 - color: var(--text); 212 - font-weight: 600; 213 - line-height: 1; 214 - } 215 - 216 - .identity-value { 217 - font-size: 0.7rem; 218 - color: var(--text-lighter); 219 - text-align: center; 220 - white-space: nowrap; 221 - font-weight: 400; 222 - line-height: 1.2; 223 - } 224 - 225 - .identity-value:hover { 226 - opacity: 0.7; 227 - } 228 - 229 - 230 - .identity-pds-label { 231 - position: absolute; 232 - bottom: clamp(-1.5rem, -3vmin, -2rem); 233 - font-size: clamp(0.55rem, 1.1vmin, 0.65rem); 234 - color: var(--text-light); 235 - letter-spacing: 0.05em; 236 - font-weight: 500; 237 - text-decoration: none; 238 - white-space: nowrap; 239 - transition: opacity 0.2s ease; 240 - } 241 - 242 - .identity-pds-label:hover { 243 - opacity: 0.7; 244 - } 245 - 246 - .identity-avatar { 247 - width: 100%; 248 - height: 100%; 249 - border-radius: 50%; 250 - object-fit: cover; 251 - } 252 - 253 - .app-view { 254 - position: absolute; 255 - display: flex; 256 - flex-direction: column; 257 - align-items: center; 258 - gap: clamp(0.3rem, 1vmin, 0.5rem); 259 - cursor: pointer; 260 - transition: all 0.2s ease; 261 - opacity: 0.7; 262 - } 263 - 264 - .app-view:hover { 265 - opacity: 1; 266 - transform: scale(1.1); 267 - z-index: 100; 268 - } 269 - 270 - .app-circle { 271 - background: var(--surface-hover); 272 - border: 1px solid var(--border); 273 - border-radius: 50%; 274 - width: clamp(55px, 10vmin, 70px); 275 - height: clamp(55px, 10vmin, 70px); 276 - display: flex; 277 - align-items: center; 278 - justify-content: center; 279 - transition: all 0.2s ease; 280 - overflow: hidden; 281 - font-size: clamp(1rem, 2vmin, 1.5rem); 282 - } 283 - 284 - .app-logo { 285 - width: 100%; 286 - height: 100%; 287 - object-fit: cover; 288 - } 289 - 290 - .app-view:hover .app-circle { 291 - background: var(--surface); 292 - border-color: var(--text-light); 293 - } 294 - 295 - .app-name { 296 - font-size: clamp(0.55rem, 1.2vmin, 0.7rem); 297 - color: var(--text); 298 - text-align: center; 299 - max-width: clamp(70px, 15vmin, 120px); 300 - text-decoration: none; 301 - display: block; 302 - overflow: hidden; 303 - text-overflow: ellipsis; 304 - white-space: nowrap; 305 - } 306 - 307 - @media (max-width: 768px) { 308 - .app-name { 309 - font-size: clamp(0.5rem, 1vmin, 0.6rem); 310 - max-width: clamp(60px, 12vmin, 100px); 311 - } 312 - 313 - /* Hide labels when there are too many apps on mobile */ 314 - #field.many-apps .app-name { 315 - display: none; 316 - } 317 - } 318 - 319 - .app-name:hover { 320 - text-decoration: underline; 321 - color: var(--text); 322 - } 323 - 324 - .app-name.invalid-link { 325 - color: var(--text-light); 326 - opacity: 0.5; 327 - cursor: not-allowed; 328 - } 329 - 330 - .app-name.invalid-link:hover { 331 - text-decoration: none; 332 - color: var(--text-light); 333 - } 334 - 335 - .detail-panel { 336 - position: fixed; 337 - top: 0; 338 - left: 0; 339 - bottom: 0; 340 - width: 500px; 341 - background: var(--surface); 342 - border-right: 2px solid var(--border); 343 - padding: 2.5rem 2rem; 344 - overflow-y: auto; 345 - opacity: 0; 346 - transform: translateX(-100%); 347 - transition: all 0.25s ease; 348 - z-index: 1000; 349 - scrollbar-width: none; 350 - -ms-overflow-style: none; 351 - } 352 - 353 - .detail-panel::-webkit-scrollbar { 354 - display: none; 355 - } 356 - 357 - .detail-panel.visible { 358 - opacity: 1; 359 - transform: translateX(0); 360 - } 361 - 362 - @media (max-width: 768px) { 363 - .detail-panel { 364 - width: 100%; 365 - padding: 4rem 1.5rem 2rem; 366 - border-right: none; 367 - border-bottom: 2px solid var(--border); 368 - } 369 - } 370 - 371 - .detail-panel h3 { 372 - margin-bottom: 0.75rem; 373 - font-size: 0.85rem; 374 - color: var(--text); 375 - } 376 - 377 - .detail-panel .subtitle { 378 - font-size: 0.7rem; 379 - color: var(--text-light); 380 - margin-bottom: 1.5rem; 381 - line-height: 1.4; 382 - } 383 - 384 - .detail-close { 385 - position: absolute; 386 - top: 1.5rem; 387 - right: 1.5rem; 388 - width: 32px; 389 - height: 32px; 390 - border: 1px solid var(--border); 391 - background: var(--bg); 392 - color: var(--text-light); 393 - cursor: pointer; 394 - display: flex; 395 - align-items: center; 396 - justify-content: center; 397 - font-size: 1.2rem; 398 - line-height: 1; 399 - transition: all 0.2s ease; 400 - border-radius: 2px; 401 - -webkit-tap-highlight-color: transparent; 402 - } 403 - 404 - .detail-close:hover, 405 - .detail-close:active { 406 - background: var(--surface-hover); 407 - border-color: var(--text-light); 408 - color: var(--text); 409 - } 410 - 411 - @media (max-width: 768px) { 412 - .detail-close { 413 - top: 1rem; 414 - right: 1rem; 415 - width: 40px; 416 - height: 40px; 417 - font-size: 1.4rem; 418 - } 419 - } 420 - 421 - .tree-item { 422 - padding: 0.65rem 0.75rem; 423 - font-size: 0.75rem; 424 - color: var(--text-lighter); 425 - background: var(--bg); 426 - border: 1px solid var(--border); 427 - border-radius: 2px; 428 - margin-bottom: 0.5rem; 429 - transition: all 0.15s ease; 430 - cursor: pointer; 431 - -webkit-tap-highlight-color: transparent; 432 - } 433 - 434 - .tree-item:hover, 435 - .tree-item:active { 436 - background: var(--surface-hover); 437 - border-color: var(--text-light); 438 - } 439 - 440 - @media (max-width: 768px) { 441 - .tree-item { 442 - padding: 0.8rem 0.9rem; 443 - font-size: 0.8rem; 444 - } 445 - } 446 - 447 - .tree-item:last-child { 448 - margin-bottom: 0; 449 - } 450 - 451 - .tree-item-header { 452 - display: flex; 453 - justify-content: space-between; 454 - align-items: center; 455 - } 456 - 457 - .tree-item-count { 458 - font-size: 0.65rem; 459 - color: var(--text-light); 460 - } 461 - 462 - .collection-content { 463 - margin-top: 0.5rem; 464 - padding-top: 0.5rem; 465 - border-top: 1px solid var(--border); 466 - } 467 - 468 - .collection-tabs { 469 - display: flex; 470 - gap: 0; 471 - margin-bottom: 0.75rem; 472 - border: 1px solid var(--border); 473 - border-radius: 2px; 474 - overflow: hidden; 475 - } 476 - 477 - .collection-tab { 478 - flex: 1; 479 - padding: 0.5rem 0.75rem; 480 - background: var(--bg); 481 - border: none; 482 - border-right: 1px solid var(--border); 483 - color: var(--text-light); 484 - font-family: inherit; 485 - font-size: 0.65rem; 486 - cursor: pointer; 487 - transition: all 0.15s ease; 488 - -webkit-tap-highlight-color: transparent; 489 - } 490 - 491 - .collection-tab:last-child { 492 - border-right: none; 493 - } 494 - 495 - .collection-tab:hover { 496 - background: var(--surface); 497 - color: var(--text); 498 - } 499 - 500 - .collection-tab.active { 501 - background: var(--surface-hover); 502 - color: var(--text); 503 - font-weight: 500; 504 - } 505 - 506 - .collection-view-content { 507 - position: relative; 508 - } 509 - 510 - .collection-view { 511 - display: none; 512 - } 513 - 514 - .collection-view.active { 515 - display: block; 516 - } 517 - 518 - .structure-view { 519 - min-height: 600px; 520 - } 521 - 522 - .mst-canvas { 523 - width: 100%; 524 - height: 600px; 525 - border: 1px solid var(--border); 526 - border-radius: 4px; 527 - background: var(--bg); 528 - margin-top: 0.5rem; 529 - } 530 - 531 - .mst-info { 532 - background: var(--bg); 533 - border: 1px solid var(--border); 534 - padding: 0.75rem; 535 - border-radius: 4px; 536 - margin-bottom: 0.75rem; 537 - } 538 - 539 - .mst-info p { 540 - font-size: 0.65rem; 541 - color: var(--text-lighter); 542 - line-height: 1.5; 543 - margin: 0; 544 - } 545 - 546 - .mst-node-modal { 547 - position: fixed; 548 - inset: 0; 549 - background: rgba(0, 0, 0, 0.75); 550 - display: flex; 551 - align-items: center; 552 - justify-content: center; 553 - z-index: 3000; 554 - padding: 1rem; 555 - } 556 - 557 - .mst-node-modal-content { 558 - background: var(--surface); 559 - border: 2px solid var(--border); 560 - padding: 2rem; 561 - border-radius: 4px; 562 - max-width: 600px; 563 - width: 100%; 564 - max-height: 80vh; 565 - overflow-y: auto; 566 - position: relative; 567 - } 568 - 569 - .mst-node-close { 570 - position: absolute; 571 - top: 1rem; 572 - right: 1rem; 573 - width: 32px; 574 - height: 32px; 575 - border: 1px solid var(--border); 576 - background: var(--bg); 577 - color: var(--text-light); 578 - cursor: pointer; 579 - display: flex; 580 - align-items: center; 581 - justify-content: center; 582 - font-size: 1.2rem; 583 - line-height: 1; 584 - transition: all 0.2s ease; 585 - border-radius: 2px; 586 - } 587 - 588 - .mst-node-close:hover { 589 - background: var(--surface-hover); 590 - border-color: var(--text-light); 591 - color: var(--text); 592 - } 593 - 594 - .mst-node-modal-content h3 { 595 - margin-bottom: 1rem; 596 - font-size: 0.9rem; 597 - color: var(--text); 598 - } 599 - 600 - .mst-node-info { 601 - background: var(--bg); 602 - border: 1px solid var(--border); 603 - padding: 0.75rem; 604 - border-radius: 4px; 605 - margin-bottom: 1rem; 606 - } 607 - 608 - .mst-node-field { 609 - display: flex; 610 - gap: 0.5rem; 611 - margin-bottom: 0.5rem; 612 - font-size: 0.65rem; 613 - } 614 - 615 - .mst-node-field:last-child { 616 - margin-bottom: 0; 617 - } 618 - 619 - .mst-node-label { 620 - color: var(--text-light); 621 - font-weight: 500; 622 - min-width: 40px; 623 - } 624 - 625 - .mst-node-value { 626 - color: var(--text); 627 - word-break: break-all; 628 - font-family: monospace; 629 - } 630 - 631 - .mst-node-explanation { 632 - background: var(--bg); 633 - border: 1px solid var(--border); 634 - padding: 0.75rem; 635 - border-radius: 4px; 636 - margin-bottom: 1rem; 637 - } 638 - 639 - .mst-node-explanation p { 640 - font-size: 0.65rem; 641 - color: var(--text-lighter); 642 - line-height: 1.5; 643 - margin: 0; 644 - } 645 - 646 - .mst-node-data { 647 - background: var(--bg); 648 - border: 1px solid var(--border); 649 - border-radius: 4px; 650 - overflow: hidden; 651 - } 652 - 653 - .mst-node-data-header { 654 - font-size: 0.65rem; 655 - color: var(--text-light); 656 - padding: 0.5rem 0.75rem; 657 - border-bottom: 1px solid var(--border); 658 - font-weight: 500; 659 - } 660 - 661 - .mst-node-data pre { 662 - margin: 0; 663 - padding: 0.75rem; 664 - font-size: 0.625rem; 665 - color: var(--text); 666 - white-space: pre-wrap; 667 - word-break: break-word; 668 - line-height: 1.5; 669 - } 670 - 671 - .record-list { 672 - margin-top: 0.5rem; 673 - padding-top: 0.5rem; 674 - border-top: 1px solid var(--border); 675 - } 676 - 677 - .record { 678 - margin-bottom: 0.5rem; 679 - background: var(--bg); 680 - border: 1px solid var(--border); 681 - border-radius: 4px; 682 - font-size: 0.65rem; 683 - color: var(--text-light); 684 - transition: all 0.15s ease; 685 - overflow: hidden; 686 - } 687 - 688 - .record:hover { 689 - border-color: var(--text-light); 690 - background: var(--surface); 691 - } 692 - 693 - .record:last-child { 694 - margin-bottom: 0; 695 - } 696 - 697 - .record-header { 698 - display: flex; 699 - justify-content: space-between; 700 - align-items: center; 701 - padding: 0.5rem 0.6rem; 702 - background: var(--surface); 703 - border-bottom: 1px solid var(--border); 704 - } 705 - 706 - .record-label { 707 - font-size: 0.6rem; 708 - color: var(--text-lighter); 709 - font-weight: 500; 710 - } 711 - 712 - .copy-btn { 713 - background: var(--bg); 714 - border: 1px solid var(--border); 715 - color: var(--text-light); 716 - font-family: inherit; 717 - font-size: 0.55rem; 718 - padding: 0.2rem 0.5rem; 719 - cursor: pointer; 720 - transition: all 0.15s ease; 721 - border-radius: 2px; 722 - -webkit-tap-highlight-color: transparent; 723 - } 724 - 725 - .copy-btn:hover, 726 - .copy-btn:active { 727 - background: var(--surface-hover); 728 - border-color: var(--text-light); 729 - color: var(--text); 730 - } 731 - 732 - .copy-btn.copied { 733 - color: var(--text); 734 - border-color: var(--text); 735 - } 736 - 737 - .record-content { 738 - padding: 0.6rem; 739 - } 740 - 741 - .record-content pre { 742 - margin: 0; 743 - white-space: pre-wrap; 744 - word-break: break-word; 745 - line-height: 1.5; 746 - font-size: 0.625rem; 747 - } 748 - 749 - .load-more { 750 - margin-top: 0.5rem; 751 - padding: 0.4rem 0.6rem; 752 - background: var(--bg); 753 - border: 1px solid var(--border); 754 - color: var(--text); 755 - font-family: inherit; 756 - font-size: 0.65rem; 757 - cursor: pointer; 758 - width: 100%; 759 - transition: all 0.15s ease; 760 - -webkit-tap-highlight-color: transparent; 761 - border-radius: 2px; 762 - } 763 - 764 - .load-more:hover, 765 - .load-more:active { 766 - background: var(--surface-hover); 767 - border-color: var(--text-light); 768 - } 769 - 770 - @media (max-width: 768px) { 771 - .load-more { 772 - padding: 0.6rem 0.8rem; 773 - font-size: 0.7rem; 774 - } 775 - } 776 - 777 - 778 - #field.loading { 779 - position: fixed; 780 - inset: 0; 781 - display: flex; 782 - flex-direction: column; 783 - align-items: center; 784 - justify-content: center; 785 - gap: 1.5rem; 786 - z-index: 1000; 787 - background: var(--bg); 788 - } 789 - 790 - #field.loading~.identity { 791 - display: none; 792 - } 793 - 794 - .loading-spinner { 795 - width: 48px; 796 - height: 48px; 797 - border: 3px solid var(--border); 798 - border-top-color: var(--text); 799 - border-radius: 50%; 800 - animation: spin 0.8s linear infinite; 801 - } 802 - 803 - @keyframes spin { 804 - to { 805 - transform: rotate(360deg); 806 - } 807 - } 808 - 809 - .loading-text { 810 - color: var(--text); 811 - font-size: 0.85rem; 812 - font-weight: 500; 813 - letter-spacing: 0.05em; 814 - } 815 - 816 - .loading-progress { 817 - color: var(--text-light); 818 - font-size: 0.7rem; 819 - } 820 - 821 - .onboarding-overlay { 822 - position: fixed; 823 - inset: 0; 824 - background: transparent; 825 - z-index: 3000; 826 - display: none; 827 - opacity: 0; 828 - transition: opacity 0.3s ease; 829 - pointer-events: none; 830 - } 831 - 832 - .onboarding-overlay.active { 833 - display: block; 834 - opacity: 1; 835 - } 836 - 837 - .onboarding-spotlight { 838 - position: absolute; 839 - border: 2px solid rgba(255, 255, 255, 0.9); 840 - border-radius: 50%; 841 - box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.75), 0 0 40px rgba(255, 255, 255, 0.5); 842 - pointer-events: none; 843 - transition: all 0.5s ease; 844 - } 845 - 846 - .onboarding-content { 847 - position: fixed; 848 - background: var(--surface); 849 - border: 2px solid var(--border); 850 - padding: clamp(1rem, 3vmin, 2rem); 851 - max-width: min(400px, 90vw); 852 - z-index: 3001; 853 - border-radius: 4px; 854 - transition: all 0.3s ease; 855 - pointer-events: auto; 856 - } 857 - 858 - .onboarding-content h3 { 859 - font-size: clamp(0.9rem, 2vmin, 1.1rem); 860 - margin-bottom: clamp(0.5rem, 1.5vmin, 0.75rem); 861 - color: var(--text); 862 - font-weight: 500; 863 - } 864 - 865 - .onboarding-content p { 866 - font-size: clamp(0.7rem, 1.5vmin, 0.85rem); 867 - color: var(--text-light); 868 - line-height: 1.5; 869 - margin-bottom: clamp(1rem, 2vmin, 1.25rem); 870 - } 871 - 872 - .onboarding-actions { 873 - display: flex; 874 - gap: clamp(0.5rem, 1.5vmin, 0.75rem); 875 - justify-content: flex-end; 876 - } 877 - 878 - .onboarding-actions button { 879 - font-family: inherit; 880 - font-size: clamp(0.7rem, 1.5vmin, 0.8rem); 881 - padding: clamp(0.4rem, 1vmin, 0.5rem) clamp(0.8rem, 2vmin, 1rem); 882 - background: transparent; 883 - border: 1px solid var(--border); 884 - color: var(--text); 885 - cursor: pointer; 886 - transition: all 0.2s ease; 887 - border-radius: 2px; 888 - } 889 - 890 - .onboarding-actions button:hover { 891 - background: var(--surface-hover); 892 - border-color: var(--text-light); 893 - } 894 - 895 - .onboarding-actions button.primary { 896 - background: var(--surface-hover); 897 - border-color: var(--text-light); 898 - } 899 - 900 - .onboarding-progress { 901 - display: flex; 902 - gap: clamp(0.4rem, 1vmin, 0.5rem); 903 - justify-content: center; 904 - margin-top: clamp(0.75rem, 2vmin, 1rem); 905 - } 906 - 907 - .onboarding-progress span { 908 - width: clamp(6px, 1.5vmin, 8px); 909 - height: clamp(6px, 1.5vmin, 8px); 910 - border-radius: 50%; 911 - background: var(--border); 912 - transition: background 0.3s ease; 913 - } 914 - 915 - .onboarding-progress span.active { 916 - background: var(--text); 917 - } 918 - 919 - .onboarding-progress span.done { 920 - background: var(--text-light); 921 - } 922 - 923 - .stats-box { 924 - display: flex; 925 - gap: 1.5rem; 926 - margin: 1.5rem 0; 927 - padding: 1rem; 928 - background: var(--bg); 929 - border-radius: 4px; 930 - border: 1px solid var(--border); 931 - } 932 - 933 - .stat { 934 - flex: 1; 935 - text-align: center; 936 - } 937 - 938 - .stat-value { 939 - font-size: 1.8rem; 940 - font-weight: 600; 941 - color: var(--text); 942 - margin-bottom: 0.25rem; 943 - } 944 - 945 - .stat-label { 946 - font-size: 0.65rem; 947 - color: var(--text-light); 948 - text-transform: uppercase; 949 - letter-spacing: 0.05em; 950 - } 951 - 952 - .ownership-box { 953 - margin: 1rem 0; 954 - padding: 1rem; 955 - background: var(--bg); 956 - border-radius: 4px; 957 - border: 1px solid var(--border); 958 - } 959 - 960 - .ownership-box.yours { 961 - background: rgba(76, 175, 80, 0.05); 962 - border-color: rgba(76, 175, 80, 0.3); 963 - } 964 - 965 - @media (prefers-color-scheme: dark) { 966 - .ownership-box.yours { 967 - background: rgba(76, 175, 80, 0.08); 968 - border-color: rgba(76, 175, 80, 0.4); 969 - } 970 - } 971 - 972 - .ownership-header { 973 - font-size: 0.7rem; 974 - font-weight: 600; 975 - color: var(--text); 976 - margin-bottom: 0.5rem; 977 - text-transform: uppercase; 978 - letter-spacing: 0.05em; 979 - } 980 - 981 - .ownership-text { 982 - font-size: 0.7rem; 983 - color: var(--text-lighter); 984 - line-height: 1.5; 985 - } 986 - 987 - .ownership-text strong { 988 - color: var(--text); 989 - } 990 - 991 - .home-btn { 992 - position: fixed; 993 - top: clamp(1rem, 2vmin, 1.5rem); 994 - left: clamp(1rem, 2vmin, 1.5rem); 995 - font-family: inherit; 996 - font-size: clamp(0.85rem, 1.8vmin, 1rem); 997 - color: var(--text-light); 998 - border: 1px solid var(--border); 999 - background: var(--bg); 1000 - padding: clamp(0.4rem, 1vmin, 0.5rem) clamp(0.6rem, 1.5vmin, 0.8rem); 1001 - transition: all 0.2s ease; 1002 - z-index: 100; 1003 - cursor: pointer; 1004 - border-radius: 2px; 1005 - text-decoration: none; 1006 - display: inline-flex; 1007 - align-items: center; 1008 - justify-content: center; 1009 - width: clamp(32px, 7vmin, 40px); 1010 - height: clamp(32px, 7vmin, 40px); 1011 - } 1012 - 1013 - .home-btn:hover, 1014 - .home-btn:active { 1015 - background: var(--surface); 1016 - color: var(--text); 1017 - border-color: var(--text-light); 1018 - } 1019 - 1020 - .watch-live-btn { 1021 - font-family: inherit; 1022 - font-size: clamp(0.65rem, 1.4vmin, 0.75rem); 1023 - color: var(--text-light); 1024 - border: 1px solid var(--border); 1025 - background: var(--bg); 1026 - padding: clamp(0.4rem, 1vmin, 0.5rem) clamp(0.8rem, 2vmin, 1rem); 1027 - transition: all 0.2s ease; 1028 - cursor: pointer; 1029 - border-radius: 2px; 1030 - display: flex; 1031 - align-items: center; 1032 - gap: clamp(0.3rem, 0.8vmin, 0.5rem); 1033 - } 1034 - 1035 - .watch-live-btn:hover, 1036 - .watch-live-btn:active { 1037 - background: var(--surface); 1038 - color: var(--text); 1039 - border-color: var(--text-light); 1040 - } 1041 - 1042 - .watch-live-btn.active { 1043 - background: var(--surface-hover); 1044 - color: var(--text); 1045 - border-color: var(--text); 1046 - } 1047 - 1048 - .watch-indicator { 1049 - width: clamp(8px, 2vmin, 10px); 1050 - height: clamp(8px, 2vmin, 10px); 1051 - border-radius: 50%; 1052 - background: var(--text-light); 1053 - display: none; 1054 - } 1055 - 1056 - .watch-live-btn.active .watch-indicator { 1057 - display: block; 1058 - animation: pulse 2s ease-in-out infinite; 1059 - } 1060 - 1061 - /* Top right button container for filter and watch live */ 1062 - .top-right-buttons { 1063 - position: fixed; 1064 - top: clamp(1rem, 2vmin, 1.5rem); 1065 - right: clamp(1rem, 2vmin, 1.5rem); 1066 - display: flex; 1067 - flex-direction: row; 1068 - align-items: center; 1069 - gap: clamp(0.5rem, 1vmin, 0.75rem); 1070 - z-index: 100; 1071 - } 1072 - 1073 - @media (max-width: 768px) { 1074 - .top-right-buttons { 1075 - flex-direction: column; 1076 - align-items: flex-end; 1077 - } 1078 - } 1079 - 1080 - .filter-btn { 1081 - font-family: inherit; 1082 - font-size: clamp(0.65rem, 1.4vmin, 0.75rem); 1083 - color: var(--text-light); 1084 - border: 1px solid var(--border); 1085 - background: var(--bg); 1086 - padding: clamp(0.4rem, 1vmin, 0.5rem) clamp(0.8rem, 2vmin, 1rem); 1087 - transition: all 0.2s ease; 1088 - cursor: pointer; 1089 - border-radius: 2px; 1090 - display: flex; 1091 - align-items: center; 1092 - gap: clamp(0.3rem, 0.8vmin, 0.5rem); 1093 - } 1094 - 1095 - .filter-btn:hover, 1096 - .filter-btn:active { 1097 - background: var(--surface); 1098 - color: var(--text); 1099 - border-color: var(--text-light); 1100 - } 1101 - 1102 - .filter-btn.active { 1103 - background: var(--surface-hover); 1104 - color: var(--text); 1105 - border-color: var(--text); 1106 - } 1107 - 1108 - .filter-btn.has-filters { 1109 - border-color: var(--text-light); 1110 - } 1111 - 1112 - .filter-count { 1113 - font-size: 0.6rem; 1114 - background: var(--text-light); 1115 - color: var(--bg); 1116 - padding: 0.1rem 0.35rem; 1117 - border-radius: 2px; 1118 - font-weight: 500; 1119 - } 1120 - 1121 - .filter-panel { 1122 - position: fixed; 1123 - top: clamp(3.5rem, 7vmin, 4.5rem); 1124 - right: clamp(1rem, 2vmin, 1.5rem); 1125 - background: var(--surface); 1126 - border: 1px solid var(--border); 1127 - border-radius: 4px; 1128 - padding: 1rem; 1129 - z-index: 250; 1130 - max-height: 60vh; 1131 - overflow-y: auto; 1132 - min-width: 200px; 1133 - max-width: 280px; 1134 - display: none; 1135 - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); 1136 - } 1137 - 1138 - @media (max-width: 768px) { 1139 - .filter-panel { 1140 - top: clamp(6rem, 12vmin, 8rem); 1141 - } 1142 - } 1143 - 1144 - @media (prefers-color-scheme: dark) { 1145 - .filter-panel { 1146 - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); 1147 - } 1148 - } 1149 - 1150 - .filter-panel.visible { 1151 - display: block; 1152 - } 1153 - 1154 - .filter-panel-header { 1155 - display: flex; 1156 - justify-content: space-between; 1157 - align-items: center; 1158 - margin-bottom: 0.75rem; 1159 - padding-bottom: 0.5rem; 1160 - border-bottom: 1px solid var(--border); 1161 - } 1162 - 1163 - .filter-panel-title { 1164 - font-size: 0.7rem; 1165 - font-weight: 500; 1166 - color: var(--text); 1167 - text-transform: lowercase; 1168 - } 1169 - 1170 - .filter-panel-actions { 1171 - display: flex; 1172 - gap: 0.5rem; 1173 - } 1174 - 1175 - .filter-action-btn { 1176 - font-family: inherit; 1177 - font-size: 0.6rem; 1178 - color: var(--text-light); 1179 - background: transparent; 1180 - border: none; 1181 - cursor: pointer; 1182 - padding: 0.2rem 0; 1183 - transition: color 0.2s ease; 1184 - } 1185 - 1186 - .filter-action-btn:hover { 1187 - color: var(--text); 1188 - } 1189 - 1190 - .filter-list { 1191 - display: flex; 1192 - flex-direction: column; 1193 - gap: 0.25rem; 1194 - } 1195 - 1196 - .filter-item { 1197 - display: flex; 1198 - align-items: center; 1199 - gap: 0.5rem; 1200 - padding: 0.4rem 0.5rem; 1201 - border-radius: 2px; 1202 - cursor: pointer; 1203 - transition: background 0.15s ease; 1204 - } 1205 - 1206 - .filter-item:hover { 1207 - background: var(--surface-hover); 1208 - } 1209 - 1210 - .filter-checkbox { 1211 - width: 14px; 1212 - height: 14px; 1213 - border: 1px solid var(--border); 1214 - border-radius: 2px; 1215 - background: var(--bg); 1216 - display: flex; 1217 - align-items: center; 1218 - justify-content: center; 1219 - flex-shrink: 0; 1220 - transition: all 0.15s ease; 1221 - } 1222 - 1223 - .filter-item.checked .filter-checkbox { 1224 - background: var(--text); 1225 - border-color: var(--text); 1226 - } 1227 - 1228 - .filter-checkbox-icon { 1229 - width: 10px; 1230 - height: 10px; 1231 - stroke: var(--bg); 1232 - stroke-width: 2; 1233 - opacity: 0; 1234 - transition: opacity 0.15s ease; 1235 - } 1236 - 1237 - .filter-item.checked .filter-checkbox-icon { 1238 - opacity: 1; 1239 - } 1240 - 1241 - .filter-label { 1242 - font-size: 0.7rem; 1243 - color: var(--text-lighter); 1244 - overflow: hidden; 1245 - text-overflow: ellipsis; 1246 - white-space: nowrap; 1247 - } 1248 - 1249 - .filter-item.checked .filter-label { 1250 - color: var(--text); 1251 - } 1252 - 1253 - .app-view.filtered { 1254 - display: none !important; 1255 - } 1256 - 1257 - @keyframes pulse { 1258 - 1259 - 0%, 1260 - 100% { 1261 - opacity: 1; 1262 - } 1263 - 1264 - 50% { 1265 - opacity: 0.3; 1266 - } 1267 - } 1268 - 1269 - @keyframes pulse-glow { 1270 - 1271 - 0%, 1272 - 100% { 1273 - transform: scale(1); 1274 - box-shadow: 0 0 0 rgba(255, 255, 255, 0); 1275 - } 1276 - 1277 - 50% { 1278 - transform: scale(1.05); 1279 - box-shadow: 0 0 15px rgba(255, 255, 255, 0.3); 1280 - } 1281 - } 1282 - 1283 - @keyframes gentle-pulse { 1284 - 1285 - 0%, 1286 - 100% { 1287 - transform: scale(1); 1288 - box-shadow: 0 0 0 0 var(--text-light); 1289 - } 1290 - 1291 - 50% { 1292 - transform: scale(1.02); 1293 - box-shadow: 0 0 0 3px rgba(160, 160, 160, 0.2); 1294 - } 1295 - } 1296 - 1297 - .sign-guestbook-btn.pulse { 1298 - animation: gentle-pulse 2s ease-in-out infinite; 1299 - } 1300 - 1301 - .firehose-toast { 1302 - position: fixed; 1303 - top: clamp(4rem, 8vmin, 5rem); 1304 - right: clamp(1rem, 2vmin, 1.5rem); 1305 - background: var(--surface); 1306 - border: 1px solid var(--border); 1307 - padding: 0.75rem 1rem; 1308 - border-radius: 4px; 1309 - font-size: 0.7rem; 1310 - color: var(--text); 1311 - z-index: 200; 1312 - opacity: 0; 1313 - transform: translateY(-10px); 1314 - transition: all 0.3s ease; 1315 - pointer-events: none; 1316 - max-width: min(300px, calc(100vw - 2rem)); 1317 - width: max-content; 1318 - } 1319 - 1320 - @media (max-width: 768px) { 1321 - .firehose-toast { 1322 - top: clamp(7rem, 14vmin, 9rem); 1323 - } 1324 - } 1325 - 1326 - .firehose-toast.visible { 1327 - opacity: 1; 1328 - transform: translateY(0); 1329 - pointer-events: auto; 1330 - } 1331 - 1332 - .firehose-toast-action { 1333 - font-weight: 600; 1334 - color: var(--text); 1335 - } 1336 - 1337 - .firehose-toast-collection { 1338 - color: var(--text-light); 1339 - font-size: 0.65rem; 1340 - margin-top: 0.25rem; 1341 - } 1342 - 1343 - .firehose-toast-link { 1344 - display: inline-block; 1345 - color: var(--text-light); 1346 - font-size: 0.6rem; 1347 - margin-top: 0.5rem; 1348 - text-decoration: none; 1349 - border-bottom: 1px solid transparent; 1350 - transition: all 0.2s ease; 1351 - pointer-events: auto; 1352 - } 1353 - 1354 - .firehose-toast-link:hover { 1355 - color: var(--text); 1356 - border-bottom-color: var(--text); 1357 - } 1358 - 1359 - .sign-guestbook-btn { 1360 - font-family: inherit; 1361 - font-size: clamp(0.65rem, 1.4vmin, 0.75rem); 1362 - color: var(--text-light); 1363 - border: 1px solid var(--border); 1364 - background: var(--bg); 1365 - padding: clamp(0.4rem, 1vmin, 0.5rem) clamp(0.6rem, 1.5vmin, 0.8rem); 1366 - transition: all 0.2s ease; 1367 - cursor: pointer; 1368 - border-radius: 2px; 1369 - display: flex; 1370 - align-items: center; 1371 - gap: clamp(0.3rem, 0.8vmin, 0.5rem); 1372 - height: clamp(32px, 7vmin, 40px); 1373 - white-space: nowrap; 1374 - } 1375 - 1376 - .sign-guestbook-btn:hover, 1377 - .sign-guestbook-btn:active { 1378 - background: var(--surface); 1379 - color: var(--text); 1380 - border-color: var(--text-light); 1381 - } 1382 - 1383 - .sign-guestbook-btn:disabled { 1384 - opacity: 0.5; 1385 - cursor: not-allowed; 1386 - } 1387 - 1388 - .sign-guestbook-btn.signed { 1389 - border-color: var(--text-light); 1390 - background: var(--surface); 1391 - } 1392 - 1393 - .guestbook-icon { 1394 - display: flex; 1395 - align-items: center; 1396 - line-height: 1; 1397 - } 1398 - 1399 - .guestbook-icon svg, 1400 - .home-btn svg, 1401 - .info svg, 1402 - .view-guestbook-btn svg { 1403 - display: block; 1404 - } 1405 - 1406 - .guestbook-avatar { 1407 - width: clamp(20px, 4.5vmin, 24px); 1408 - height: clamp(20px, 4.5vmin, 24px); 1409 - border-radius: 50%; 1410 - object-fit: cover; 1411 - border: 1px solid var(--border); 1412 - flex-shrink: 0; 1413 - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.15), 0 1px 2px rgba(0, 0, 0, 0.1); 1414 - } 1415 - 1416 - @media (prefers-color-scheme: dark) { 1417 - .guestbook-avatar { 1418 - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3), 0 1px 2px rgba(0, 0, 0, 0.2); 1419 - } 1420 - } 1421 - 1422 - .guestbook-buttons-container { 1423 - position: fixed; 1424 - bottom: clamp(0.75rem, 2vmin, 1rem); 1425 - right: clamp(0.75rem, 2vmin, 1rem); 1426 - display: flex; 1427 - flex-direction: row; 1428 - align-items: center; 1429 - gap: clamp(0.5rem, 1.5vmin, 0.75rem); 1430 - z-index: 100; 1431 - } 1432 - 1433 - .view-guestbook-btn { 1434 - font-family: inherit; 1435 - font-size: clamp(0.85rem, 1.8vmin, 1rem); 1436 - color: var(--text-light); 1437 - border: 1px solid var(--border); 1438 - background: var(--bg); 1439 - padding: clamp(0.4rem, 1vmin, 0.5rem) clamp(0.6rem, 1.5vmin, 0.8rem); 1440 - transition: all 0.2s ease; 1441 - cursor: pointer; 1442 - border-radius: 2px; 1443 - width: clamp(32px, 7vmin, 40px); 1444 - height: clamp(32px, 7vmin, 40px); 1445 - display: flex; 1446 - align-items: center; 1447 - justify-content: center; 1448 - } 1449 - 1450 - .view-guestbook-btn:hover, 1451 - .view-guestbook-btn:active { 1452 - background: var(--surface); 1453 - color: var(--text); 1454 - border-color: var(--text-light); 1455 - } 1456 - 1457 - 1458 - 1459 - .guestbook-modal { 1460 - position: fixed; 1461 - inset: 0; 1462 - background: var(--bg); 1463 - z-index: 2000; 1464 - display: none; 1465 - overflow-y: auto; 1466 - padding: clamp(4rem, 8vmin, 6rem) clamp(1rem, 3vmin, 2rem) clamp(2rem, 4vmin, 3rem); 1467 - } 1468 - 1469 - .guestbook-modal.visible { 1470 - display: block; 1471 - } 1472 - 1473 - /* Paper guestbook aesthetic */ 1474 - .guestbook-paper { 1475 - max-width: 700px; 1476 - margin: 0 auto; 1477 - background: 1478 - repeating-linear-gradient(0deg, 1479 - transparent, 1480 - transparent 31px, 1481 - rgba(212, 197, 168, 0.15) 31px, 1482 - rgba(212, 197, 168, 0.15) 32px), 1483 - linear-gradient(to bottom, #fdfcf8 0%, #f9f7f1 100%); 1484 - border: 1px solid #d4c5a8; 1485 - box-shadow: 1486 - 0 4px 6px rgba(0, 0, 0, 0.1), 1487 - 0 2px 4px rgba(0, 0, 0, 0.06), 1488 - inset 0 0 80px rgba(255, 248, 240, 0.6); 1489 - padding: clamp(2.5rem, 6vmin, 4rem) clamp(2rem, 5vmin, 3rem); 1490 - position: relative; 1491 - } 1492 - 1493 - @media (prefers-color-scheme: dark) { 1494 - .guestbook-paper { 1495 - background: 1496 - repeating-linear-gradient(0deg, 1497 - transparent, 1498 - transparent 31px, 1499 - rgba(90, 80, 70, 0.2) 31px, 1500 - rgba(90, 80, 70, 0.2) 32px), 1501 - linear-gradient(to bottom, #2a2520 0%, #1f1b17 100%); 1502 - border-color: #3a3530; 1503 - box-shadow: 1504 - 0 4px 6px rgba(0, 0, 0, 0.5), 1505 - 0 2px 4px rgba(0, 0, 0, 0.3), 1506 - inset 0 0 80px rgba(60, 50, 40, 0.4); 1507 - } 1508 - } 1509 - 1510 - .guestbook-paper::before { 1511 - content: ''; 1512 - position: absolute; 1513 - top: 0; 1514 - left: clamp(2rem, 5vmin, 3rem); 1515 - width: 2px; 1516 - height: 100%; 1517 - background: linear-gradient(to bottom, 1518 - transparent 0%, 1519 - rgba(212, 100, 100, 0.2) 5%, 1520 - rgba(212, 100, 100, 0.2) 95%, 1521 - transparent 100%); 1522 - } 1523 - 1524 - @media (prefers-color-scheme: dark) { 1525 - .guestbook-paper::before { 1526 - background: linear-gradient(to bottom, 1527 - transparent 0%, 1528 - rgba(180, 80, 80, 0.15) 5%, 1529 - rgba(180, 80, 80, 0.15) 95%, 1530 - transparent 100%); 1531 - } 1532 - } 1533 - 1534 - .guestbook-paper-title { 1535 - font-family: ui-monospace, 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Menlo, 'Courier New', monospace; 1536 - font-size: clamp(1.8rem, 4.5vmin, 2.5rem); 1537 - color: #3a2f25; 1538 - text-align: center; 1539 - margin-bottom: clamp(0.5rem, 1.5vmin, 1rem); 1540 - font-weight: 500; 1541 - letter-spacing: 0.02em; 1542 - } 1543 - 1544 - @media (prefers-color-scheme: dark) { 1545 - .guestbook-paper-title { 1546 - color: #d4c5a8; 1547 - } 1548 - } 1549 - 1550 - .guestbook-paper-subtitle { 1551 - font-family: ui-monospace, 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Menlo, 'Courier New', monospace; 1552 - font-size: clamp(0.75rem, 1.6vmin, 0.9rem); 1553 - color: #6b5d4f; 1554 - text-align: center; 1555 - margin-bottom: clamp(2rem, 5vmin, 3rem); 1556 - font-style: normal; 1557 - } 1558 - 1559 - @media (prefers-color-scheme: dark) { 1560 - .guestbook-paper-subtitle { 1561 - color: #8a7a6a; 1562 - } 1563 - } 1564 - 1565 - .guestbook-tally { 1566 - font-family: ui-monospace, 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Menlo, 'Courier New', monospace; 1567 - text-align: center; 1568 - font-size: clamp(0.7rem, 1.8vmin, 0.85rem); 1569 - color: #6b5d4f; 1570 - margin: clamp(1rem, 2.5vmin, 1.5rem) 0 0; 1571 - font-weight: 500; 1572 - letter-spacing: 0.03em; 1573 - text-transform: lowercase; 1574 - } 1575 - 1576 - @media (prefers-color-scheme: dark) { 1577 - .guestbook-tally { 1578 - color: #8a7a6a; 1579 - } 1580 - } 1581 - 1582 - .guestbook-signatures-list { 1583 - margin-top: clamp(1.5rem, 4vmin, 2.5rem); 1584 - } 1585 - 1586 - .guestbook-message { 1587 - font-family: 'Brush Script MT', cursive, 'Georgia', serif; 1588 - font-size: clamp(1rem, 2.3vmin, 1.25rem); 1589 - color: #3a2f25; 1590 - line-height: 1.6; 1591 - margin-bottom: clamp(0.5rem, 1.2vmin, 0.75rem); 1592 - font-style: italic; 1593 - } 1594 - 1595 - @media (prefers-color-scheme: dark) { 1596 - .guestbook-message { 1597 - color: #d4c5a8; 1598 - } 1599 - } 1600 - 1601 - .guestbook-paper-signature { 1602 - padding: clamp(1rem, 2.5vmin, 1.5rem) 0; 1603 - border-bottom: 1px solid rgba(212, 197, 168, 0.3); 1604 - position: relative; 1605 - cursor: pointer; 1606 - transition: all 0.3s ease; 1607 - } 1608 - 1609 - .guestbook-paper-signature:last-child { 1610 - border-bottom: none; 1611 - } 1612 - 1613 - .guestbook-paper-signature:hover { 1614 - background: rgba(255, 248, 240, 0.3); 1615 - padding-left: 0.5rem; 1616 - padding-right: 0.5rem; 1617 - margin-left: -0.5rem; 1618 - margin-right: -0.5rem; 1619 - } 1620 - 1621 - @media (prefers-color-scheme: dark) { 1622 - .guestbook-paper-signature { 1623 - border-bottom-color: rgba(90, 80, 70, 0.3); 1624 - } 1625 - 1626 - .guestbook-paper-signature:hover { 1627 - background: rgba(60, 50, 40, 0.3); 1628 - } 1629 - } 1630 - 1631 - .guestbook-did { 1632 - font-family: 'Brush Script MT', cursive, 'Georgia', serif; 1633 - font-size: clamp(1.1rem, 2.5vmin, 1.4rem); 1634 - color: #2a2520; 1635 - margin-bottom: clamp(0.3rem, 0.8vmin, 0.5rem); 1636 - font-weight: 400; 1637 - letter-spacing: 0.02em; 1638 - word-break: break-all; 1639 - cursor: pointer; 1640 - transition: all 0.2s ease; 1641 - position: relative; 1642 - } 1643 - 1644 - .guestbook-did:hover { 1645 - color: #4a4238; 1646 - transform: translateX(2px); 1647 - } 1648 - 1649 - .guestbook-did.copied { 1650 - color: #4a4238; 1651 - } 1652 - 1653 - .guestbook-did-tooltip { 1654 - position: absolute; 1655 - top: 50%; 1656 - left: 100%; 1657 - transform: translate(10px, -50%); 1658 - background: var(--surface); 1659 - border: 1px solid var(--border); 1660 - padding: 0.25rem 0.5rem; 1661 - font-size: 0.65rem; 1662 - font-family: ui-monospace, monospace; 1663 - color: var(--text); 1664 - border-radius: 2px; 1665 - white-space: nowrap; 1666 - opacity: 0; 1667 - pointer-events: none; 1668 - transition: opacity 0.2s ease; 1669 - } 1670 - 1671 - .guestbook-did.copied .guestbook-did-tooltip { 1672 - opacity: 1; 1673 - } 1674 - 1675 - @media (prefers-color-scheme: dark) { 1676 - .guestbook-did { 1677 - color: #c9bfa8; 1678 - } 1679 - 1680 - .guestbook-did:hover { 1681 - color: #d4c5a8; 1682 - } 1683 - 1684 - .guestbook-did.copied { 1685 - color: #d4c5a8; 1686 - } 1687 - } 1688 - 1689 - .guestbook-metadata { 1690 - display: flex; 1691 - flex-direction: column; 1692 - gap: clamp(0.25rem, 0.6vmin, 0.4rem); 1693 - opacity: 0; 1694 - max-height: 0; 1695 - overflow: hidden; 1696 - transition: all 0.3s ease; 1697 - font-size: clamp(0.65rem, 1.4vmin, 0.75rem); 1698 - color: #6b5d4f; 1699 - font-family: ui-monospace, 'SF Mono', Monaco, monospace; 1700 - } 1701 - 1702 - @media (prefers-color-scheme: dark) { 1703 - .guestbook-metadata { 1704 - color: #8a7a6a; 1705 - } 1706 - } 1707 - 1708 - .guestbook-paper-signature:hover .guestbook-metadata { 1709 - opacity: 1; 1710 - max-height: 100px; 1711 - margin-top: clamp(0.5rem, 1.2vmin, 0.75rem); 1712 - } 1713 - 1714 - .guestbook-metadata-item { 1715 - display: flex; 1716 - align-items: center; 1717 - gap: 0.5rem; 1718 - } 1719 - 1720 - .guestbook-metadata-label { 1721 - font-weight: 500; 1722 - color: #8a7a6a; 1723 - } 1724 - 1725 - @media (prefers-color-scheme: dark) { 1726 - .guestbook-metadata-label { 1727 - color: #6b5d4f; 1728 - } 1729 - } 1730 - 1731 - .guestbook-metadata-value { 1732 - color: #4a4238; 1733 - } 1734 - 1735 - @media (prefers-color-scheme: dark) { 1736 - .guestbook-metadata-value { 1737 - color: #a0a0a0; 1738 - } 1739 - } 1740 - 1741 - .guestbook-metadata-link { 1742 - color: #6b5d4f; 1743 - text-decoration: none; 1744 - border-bottom: 1px solid transparent; 1745 - transition: all 0.2s ease; 1746 - } 1747 - 1748 - .guestbook-metadata-link:hover { 1749 - color: #4a4238; 1750 - border-bottom-color: #4a4238; 1751 - } 1752 - 1753 - @media (prefers-color-scheme: dark) { 1754 - .guestbook-metadata-link { 1755 - color: #8a7a6a; 1756 - } 1757 - 1758 - .guestbook-metadata-link:hover { 1759 - color: #c9bfa8; 1760 - border-bottom-color: #c9bfa8; 1761 - } 1762 - } 1763 - 1764 - .guestbook-close { 1765 - position: fixed; 1766 - top: clamp(1rem, 2vmin, 1.5rem); 1767 - right: clamp(1rem, 2vmin, 1.5rem); 1768 - width: clamp(40px, 8vmin, 48px); 1769 - height: clamp(40px, 8vmin, 48px); 1770 - border: 2px solid var(--border); 1771 - background: var(--surface); 1772 - color: var(--text-light); 1773 - cursor: pointer; 1774 - display: flex; 1775 - align-items: center; 1776 - justify-content: center; 1777 - font-size: clamp(1.2rem, 3vmin, 1.5rem); 1778 - line-height: 1; 1779 - transition: all 0.2s ease; 1780 - border-radius: 4px; 1781 - z-index: 2001; 1782 - } 1783 - 1784 - .guestbook-close:hover, 1785 - .guestbook-close:active { 1786 - background: var(--surface-hover); 1787 - border-color: var(--text-light); 1788 - color: var(--text); 1789 - } 1790 - 1791 - .guestbook-signature { 1792 - background: var(--surface); 1793 - border: 1px solid var(--border); 1794 - border-radius: 4px; 1795 - padding: clamp(0.75rem, 2vmin, 1rem); 1796 - margin-bottom: clamp(0.75rem, 2vmin, 1rem); 1797 - display: flex; 1798 - align-items: center; 1799 - gap: clamp(0.75rem, 2vmin, 1rem); 1800 - transition: all 0.2s ease; 1801 - } 1802 - 1803 - .guestbook-signature:hover { 1804 - border-color: var(--text-light); 1805 - background: var(--surface-hover); 1806 - } 1807 - 1808 - .guestbook-avatar { 1809 - width: clamp(40px, 8vmin, 48px); 1810 - height: clamp(40px, 8vmin, 48px); 1811 - border-radius: 50%; 1812 - background: var(--bg); 1813 - border: 1px solid var(--border); 1814 - object-fit: cover; 1815 - flex-shrink: 0; 1816 - } 1817 - 1818 - .guestbook-avatar-placeholder { 1819 - width: clamp(40px, 8vmin, 48px); 1820 - height: clamp(40px, 8vmin, 48px); 1821 - border-radius: 50%; 1822 - background: var(--bg); 1823 - border: 1px solid var(--border); 1824 - display: flex; 1825 - align-items: center; 1826 - justify-content: center; 1827 - font-size: clamp(1rem, 2.5vmin, 1.25rem); 1828 - color: var(--text-light); 1829 - flex-shrink: 0; 1830 - } 1831 - 1832 - .guestbook-info { 1833 - flex: 1; 1834 - min-width: 0; 1835 - } 1836 - 1837 - .guestbook-handle { 1838 - font-size: clamp(0.75rem, 1.6vmin, 0.9rem); 1839 - color: var(--text); 1840 - font-weight: 500; 1841 - margin-bottom: 0.25rem; 1842 - overflow: hidden; 1843 - text-overflow: ellipsis; 1844 - white-space: nowrap; 1845 - } 1846 - 1847 - .guestbook-timestamp { 1848 - font-size: clamp(0.6rem, 1.3vmin, 0.7rem); 1849 - color: var(--text-light); 1850 - } 1851 - 1852 - .guestbook-empty { 1853 - max-width: 800px; 1854 - margin: 0 auto; 1855 - text-align: center; 1856 - padding: clamp(3rem, 8vmin, 5rem) clamp(1rem, 3vmin, 2rem); 1857 - } 1858 - 1859 - .guestbook-empty-icon { 1860 - font-size: clamp(2rem, 6vmin, 3rem); 1861 - margin-bottom: clamp(1rem, 3vmin, 1.5rem); 1862 - opacity: 0.3; 1863 - } 1864 - 1865 - .guestbook-empty-text { 1866 - font-size: clamp(0.75rem, 1.6vmin, 0.9rem); 1867 - color: var(--text-light); 1868 - line-height: 1.5; 1869 - } 1870 - 1871 - .guestbook-loading { 1872 - max-width: 800px; 1873 - margin: 0 auto; 1874 - text-align: center; 1875 - padding: clamp(3rem, 8vmin, 5rem) clamp(1rem, 3vmin, 2rem); 1876 - } 1877 - 1878 - .guestbook-loading-spinner { 1879 - width: 40px; 1880 - height: 40px; 1881 - border: 3px solid var(--border); 1882 - border-top-color: var(--text); 1883 - border-radius: 50%; 1884 - animation: spin 0.8s linear infinite; 1885 - margin: 0 auto clamp(1rem, 3vmin, 1.5rem); 1886 - } 1887 - 1888 - .guestbook-loading-text { 1889 - font-size: clamp(0.75rem, 1.6vmin, 0.9rem); 1890 - color: var(--text-light); 1891 - } 1892 - 1893 - /* Retro neon guestbook sign */ 1894 - .guestbook-sign { 1895 - position: fixed; 1896 - bottom: clamp(3.5rem, 8.5vmin, 5.5rem); 1897 - right: clamp(0.75rem, 2vmin, 1rem); 1898 - font-family: ui-monospace, 'SF Mono', Monaco, monospace; 1899 - font-size: clamp(0.6rem, 1.3vmin, 0.7rem); 1900 - color: var(--text-light); 1901 - text-transform: lowercase; 1902 - letter-spacing: 0.1em; 1903 - z-index: 50; 1904 - opacity: 0.6; 1905 - text-shadow: 0 0 4px currentColor; 1906 - animation: neon-flicker 8s infinite; 1907 - pointer-events: none; 1908 - user-select: none; 1909 - white-space: nowrap; 1910 - } 1911 - 1912 - @media (prefers-color-scheme: dark) { 1913 - .guestbook-sign { 1914 - color: #ff6b9d; 1915 - opacity: 0.5; 1916 - text-shadow: 0 0 6px currentColor, 0 0 12px rgba(255, 107, 157, 0.3); 1917 - } 1918 - } 1919 - 1920 - /* POV indicator - subtle top banner */ 1921 - .pov-indicator { 1922 - position: fixed; 1923 - left: 50%; 1924 - top: clamp(1rem, 2vmin, 1.5rem); 1925 - transform: translateX(-50%); 1926 - font-family: ui-monospace, 'SF Mono', Monaco, monospace; 1927 - font-size: clamp(0.65rem, 1.4vmin, 0.75rem); 1928 - color: var(--text-light); 1929 - text-transform: lowercase; 1930 - letter-spacing: 0.12em; 1931 - z-index: 50; 1932 - opacity: 0.4; 1933 - text-shadow: 0 0 3px currentColor; 1934 - animation: pov-subtle-flicker 37s infinite; 1935 - pointer-events: none; 1936 - user-select: none; 1937 - text-align: center; 1938 - line-height: 1.4; 1939 - } 1940 - 1941 - .pov-handle { 1942 - display: inline; 1943 - margin-left: 0.3rem; 1944 - font-size: inherit; 1945 - opacity: 0.9; 1946 - pointer-events: auto; 1947 - text-decoration: none; 1948 - color: inherit; 1949 - transition: opacity 0.2s ease; 1950 - } 1951 - 1952 - .pov-handle:hover { 1953 - opacity: 1; 1954 - text-decoration: underline; 1955 - } 1956 - 1957 - @media (prefers-color-scheme: dark) { 1958 - .pov-indicator { 1959 - color: #8ab4f8; 1960 - opacity: 0.35; 1961 - text-shadow: 0 0 4px currentColor, 0 0 8px rgba(138, 180, 248, 0.2); 1962 - } 1963 - } 1964 - 1965 - /* Guestbook sign flicker - 13 second loop */ 1966 - @keyframes neon-flicker { 1967 - 1968 - 0%, 1969 - 19%, 1970 - 21%, 1971 - 23%, 1972 - 25%, 1973 - 54%, 1974 - 56%, 1975 - 100% { 1976 - opacity: 0.6; 1977 - text-shadow: 0 0 4px currentColor; 1978 - } 1979 - 1980 - 20%, 1981 - 24%, 1982 - 55% { 1983 - opacity: 0.2; 1984 - text-shadow: none; 1985 - } 1986 - } 1987 - 1988 - /* POV indicator flicker - subtle 37 second loop, flickers TO brightness */ 1989 - @keyframes pov-subtle-flicker { 1990 - 1991 - 0%, 1992 - 98% { 1993 - opacity: 0.4; 1994 - text-shadow: 0 0 3px currentColor; 1995 - } 1996 - 1997 - 17%, 1998 - 17.3%, 1999 - 17.6% { 2000 - opacity: 0.75; 2001 - text-shadow: 0 0 8px currentColor, 0 0 12px currentColor; 2002 - } 2003 - 2004 - 17.15%, 2005 - 17.45% { 2006 - opacity: 0.5; 2007 - text-shadow: 0 0 4px currentColor; 2008 - } 2009 - 2010 - 71%, 2011 - 71.2% { 2012 - opacity: 0.8; 2013 - text-shadow: 0 0 10px currentColor, 0 0 15px currentColor; 2014 - } 2015 - 2016 - 71.1% { 2017 - opacity: 0.45; 2018 - text-shadow: 0 0 3px currentColor; 2019 - } 2020 - } 2021 - 2022 - @media (prefers-color-scheme: dark) { 2023 - @keyframes neon-flicker { 2024 - 2025 - 0%, 2026 - 19%, 2027 - 21%, 2028 - 23%, 2029 - 25%, 2030 - 54%, 2031 - 56%, 2032 - 100% { 2033 - opacity: 0.5; 2034 - text-shadow: 0 0 6px currentColor, 0 0 12px rgba(255, 107, 157, 0.3); 2035 - } 2036 - 2037 - 20%, 2038 - 24%, 2039 - 55% { 2040 - opacity: 0.15; 2041 - text-shadow: 0 0 2px currentColor; 2042 - } 2043 - } 2044 - 2045 - @keyframes pov-subtle-flicker { 2046 - 2047 - 0%, 2048 - 98% { 2049 - opacity: 0.35; 2050 - text-shadow: 0 0 4px currentColor, 0 0 8px rgba(138, 180, 248, 0.2); 2051 - } 2052 - 2053 - 17%, 2054 - 17.3%, 2055 - 17.6% { 2056 - opacity: 0.75; 2057 - text-shadow: 0 0 12px currentColor, 0 0 20px rgba(138, 180, 248, 0.6); 2058 - } 2059 - 2060 - 17.15%, 2061 - 17.45% { 2062 - opacity: 0.45; 2063 - text-shadow: 0 0 6px currentColor, 0 0 10px rgba(138, 180, 248, 0.3); 2064 - } 2065 - 2066 - 71%, 2067 - 71.2% { 2068 - opacity: 0.8; 2069 - text-shadow: 0 0 15px currentColor, 0 0 25px rgba(138, 180, 248, 0.7); 2070 - } 2071 - 2072 - 71.1% { 2073 - opacity: 0.4; 2074 - text-shadow: 0 0 5px currentColor, 0 0 9px rgba(138, 180, 248, 0.25); 2075 - } 2076 - } 2077 - } 2078 - </style> 2079 - </head> 2080 - 2081 - <body> 2082 - <a href="/" class="home-btn" title="back to landing"> 2083 - <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" 2084 - stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 2085 - <path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" /> 2086 - <polyline points="9 22 9 12 15 12 15 22" /> 2087 - </svg> 2088 - </a> 2089 - <div class="info" id="infoBtn" title="learn about your data"> 2090 - <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" 2091 - stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 2092 - <circle cx="12" cy="12" r="10" /> 2093 - <path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" /> 2094 - <path d="M12 17h.01" /> 2095 - </svg> 2096 - </div> 2097 - <div class="top-right-buttons"> 2098 - <button class="filter-btn" id="filterBtn"> 2099 - <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" 2100 - stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 2101 - <polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3" /> 2102 - </svg> 2103 - <span class="filter-label-text">filter</span> 2104 - <span class="filter-count" id="filterCount" style="display: none;"></span> 2105 - </button> 2106 - <button class="watch-live-btn" id="watchLiveBtn"> 2107 - <span class="watch-indicator"></span> 2108 - <span class="watch-label">watch live</span> 2109 - </button> 2110 - </div> 2111 - <div class="filter-panel" id="filterPanel"> 2112 - <div class="filter-panel-header"> 2113 - <span class="filter-panel-title">show apps</span> 2114 - <div class="filter-panel-actions"> 2115 - <button type="button" class="filter-action-btn" id="filterShowAll">all</button> 2116 - <button type="button" class="filter-action-btn" id="filterHideUnresolved">valid</button> 2117 - <button type="button" class="filter-action-btn" id="filterHideAll">none</button> 2118 - </div> 2119 - </div> 2120 - <div class="filter-list" id="filterList"></div> 2121 - </div> 2122 - <div class="pov-indicator">point of view of <a class="pov-handle" id="povHandle" href="#" target="_blank" 2123 - rel="noopener noreferrer"></a></div> 2124 - <div class="guestbook-sign">sign the guest list</div> 2125 - <div class="guestbook-buttons-container"> 2126 - <button class="view-guestbook-btn" id="viewGuestbookBtn" title="view all signatures"> 2127 - <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" 2128 - stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 2129 - <line x1="8" x2="21" y1="6" y2="6" /> 2130 - <line x1="8" x2="21" y1="12" y2="12" /> 2131 - <line x1="8" x2="21" y1="18" y2="18" /> 2132 - <line x1="3" x2="3.01" y1="6" y2="6" /> 2133 - <line x1="3" x2="3.01" y1="12" y2="12" /> 2134 - <line x1="3" x2="3.01" y1="18" y2="18" /> 2135 - </svg> 2136 - </button> 2137 - <button class="sign-guestbook-btn" id="signGuestbookBtn" title="sign the guestbook"> 2138 - <span class="guestbook-icon"> 2139 - <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" 2140 - stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 2141 - <path d="M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H20v20H6.5a2.5 2.5 0 0 1 0-5H20" /> 2142 - </svg> 2143 - </span> 2144 - <span class="guestbook-text">sign guestbook</span> 2145 - <img class="guestbook-avatar" id="guestbookAvatar" style="display: none;" /> 2146 - </button> 2147 - </div> 2148 - 2149 - <div class="firehose-toast" id="firehoseToast"> 2150 - <div class="firehose-toast-action"></div> 2151 - <div class="firehose-toast-collection"></div> 2152 - <a class='firehose-toast-link' id='firehoseToastLink' href='#' target='_blank' rel='noopener noreferrer'>view 2153 - record</a> 2154 - </div> 2155 - 2156 - <div class="overlay" id="overlay"></div> 2157 - <div class="info-modal" id="infoModal"> 2158 - <h2>this is your data</h2> 2159 - <p>this visualization shows your <a href="https://atproto.com/guides/data-repos" target="_blank" 2160 - rel="noopener noreferrer" style="color: var(--text); text-decoration: underline;">Personal Data 2161 - Server</a> - where your social data actually lives. unlike traditional platforms that lock everything in 2162 - their database, your posts, likes, and follows are stored here, on infrastructure you control.</p> 2163 - <p>each circle represents an app that writes to your space. <a href="https://bsky.app" target="_blank" 2164 - rel="noopener noreferrer" style="color: var(--text); text-decoration: underline;">bluesky</a> for 2165 - microblogging. <a href="https://whtwnd.com" target="_blank" rel="noopener noreferrer" 2166 - style="color: var(--text); text-decoration: underline;">whitewind</a> for long-form posts. <a 2167 - href="https://tangled.org" target="_blank" rel="noopener noreferrer" 2168 - style="color: var(--text); text-decoration: underline;">tangled.org</a> for code hosting. they're all 2169 - just different views of the same underlying data - <strong>your</strong> data.</p> 2170 - <p>this is what "<a href="https://overreacted.io/open-social/" target="_blank" rel="noopener noreferrer" 2171 - style="color: var(--text); text-decoration: underline;">open social</a>" means: your followers, your 2172 - content, your connections - they all belong to you, not the app. switch apps anytime and take everything 2173 - with you. no platform can hold your social graph hostage.</p> 2174 - <p style="margin-bottom: 1rem;"><strong>how to explore:</strong> click your avatar in the center to see the 2175 - details of your identity. click any app to browse the records it's created in your repository.</p> 2176 - <button id="closeInfo">got it</button> 2177 - <button id="restartTour" onclick="window.restartOnboarding()" 2178 - style="margin-left: 0.5rem; background: var(--surface-hover);">restart tour</button> 2179 - <p 2180 - style="margin-top: 1.5rem; padding-top: 1rem; border-top: 1px solid var(--border); font-size: 0.7rem; color: var(--text-light); display: flex; align-items: center; gap: 0.4rem; flex-wrap: wrap;"> 2181 - <span>view <a href="https://tangled.org/@zzstoatzz.io/at-me" target="_blank" rel="noopener noreferrer" 2182 - style="color: var(--text); text-decoration: underline;">the source code</a> on</span> 2183 - <a href="https://tangled.org" target="_blank" rel="noopener noreferrer" 2184 - style="color: var(--text); text-decoration: underline;">tangled.org</a> 2185 - </p> 2186 - </div> 2187 - 2188 - <div class="onboarding-overlay" id="onboardingOverlay"> 2189 - <div class="onboarding-spotlight" id="onboardingSpotlight"></div> 2190 - <div class="onboarding-content" id="onboardingContent"></div> 2191 - </div> 2192 - 2193 - <div class="guestbook-modal" id="guestbookModal"> 2194 - <button class="guestbook-close" id="guestbookClose">×</button> 2195 - <div id="guestbookContent"></div> 2196 - </div> 2197 - 2198 - <div class="canvas"> 2199 - <div class="identity"> 2200 - <img class="identity-avatar" id="avatar" /> 2201 - <div class="identity-pds-label">You</div> 2202 - </div> 2203 - <div id="field" class="loading"> 2204 - <div class="loading-spinner"></div> 2205 - <div class="loading-text">loading your data</div> 2206 - </div> 2207 - </div> 2208 - <div id="detail" class="detail-panel"></div> 2209 - 2210 - <script> 2211 - window.DID = '{DID}'; 2212 - </script> 2213 - <script src="/static/app.js"></script> 2214 - <script src="/static/onboarding.js"></script> 2215 - </body> 2216 - 2217 - </html>
-735
src/templates/landing.html
··· 1 - <!DOCTYPE html> 2 - <html> 3 - <head> 4 - <meta charset="UTF-8"> 5 - <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 - <title>@me - explore your atproto identity</title> 7 - <link rel="icon" type="image/svg+xml" href="/favicon.svg"> 8 - 9 - <!-- Open Graph / Facebook --> 10 - <meta property="og:type" content="website"> 11 - <meta property="og:url" content="https://at-me.fly.dev/"> 12 - <meta property="og:title" content="@me - explore your atproto identity"> 13 - <meta property="og:description" content="visualize your decentralized identity and see what apps have stored data in your Personal Data Server"> 14 - <meta property="og:image" content="https://at-me.fly.dev/static/og-image.png"> 15 - 16 - <!-- Twitter --> 17 - <meta property="twitter:card" content="summary_large_image"> 18 - <meta property="twitter:url" content="https://at-me.fly.dev/"> 19 - <meta property="twitter:title" content="@me - explore your atproto identity"> 20 - <meta property="twitter:description" content="visualize your decentralized identity and see what apps have stored data in your Personal Data Server"> 21 - <meta property="twitter:image" content="https://at-me.fly.dev/static/og-image.png"> 22 - 23 - <style> 24 - * { margin: 0; padding: 0; box-sizing: border-box; } 25 - 26 - body { 27 - font-family: ui-monospace, 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Menlo, 'Courier New', monospace; 28 - min-height: 100vh; 29 - background: radial-gradient(ellipse at center, #0a0a0f 0%, #000000 100%); 30 - color: #e5e5e5; 31 - overflow: hidden; 32 - perspective: 1000px; 33 - } 34 - 35 - @media (max-width: 768px) { 36 - body { 37 - overflow-y: auto; 38 - overflow-x: hidden; 39 - } 40 - } 41 - 42 - .atmosphere { 43 - position: fixed; 44 - inset: 0; 45 - transform-style: preserve-3d; 46 - animation: rotate 120s infinite linear; 47 - } 48 - 49 - @keyframes rotate { 50 - from { transform: rotateY(0deg); } 51 - to { transform: rotateY(360deg); } 52 - } 53 - 54 - .app-orb { 55 - position: absolute; 56 - border-radius: 50%; 57 - display: flex; 58 - align-items: center; 59 - justify-content: center; 60 - transition: all 0.3s ease; 61 - cursor: pointer; 62 - backdrop-filter: blur(4px); 63 - } 64 - 65 - .app-orb:hover { 66 - transform: scale(1.2) !important; 67 - z-index: 100; 68 - } 69 - 70 - .app-orb img { 71 - width: 100%; 72 - height: 100%; 73 - border-radius: 50%; 74 - object-fit: cover; 75 - } 76 - 77 - .app-orb .fallback { 78 - font-size: 1.5rem; 79 - font-weight: 600; 80 - color: rgba(255, 255, 255, 0.9); 81 - } 82 - 83 - .app-tooltip { 84 - position: absolute; 85 - background: rgba(10, 10, 15, 0.95); 86 - border: 1px solid rgba(255, 255, 255, 0.1); 87 - padding: 0.5rem 0.75rem; 88 - border-radius: 4px; 89 - font-size: 0.7rem; 90 - white-space: nowrap; 91 - pointer-events: none; 92 - opacity: 0; 93 - transition: opacity 0.2s; 94 - z-index: 1000; 95 - } 96 - 97 - .app-orb:hover .app-tooltip { 98 - opacity: 1; 99 - } 100 - 101 - .container { 102 - position: fixed; 103 - inset: 0; 104 - display: flex; 105 - align-items: center; 106 - justify-content: center; 107 - z-index: 10; 108 - } 109 - 110 - @media (max-width: 768px) { 111 - .container { 112 - position: relative; 113 - min-height: 100vh; 114 - padding: 2rem 0; 115 - } 116 - } 117 - 118 - .search-card { 119 - background: transparent; 120 - border: 1px solid rgba(255, 255, 255, 0.1); 121 - padding: 2.5rem 3rem; 122 - border-radius: 8px; 123 - backdrop-filter: blur(2px); 124 - text-align: center; 125 - max-width: min(500px, 90vw); 126 - } 127 - 128 - h1 { 129 - font-size: 2rem; 130 - margin-bottom: 0.5rem; 131 - font-weight: 300; 132 - letter-spacing: 0.05em; 133 - } 134 - 135 - .subtitle { 136 - font-size: 0.75rem; 137 - color: rgba(255, 255, 255, 0.5); 138 - margin-bottom: 2rem; 139 - } 140 - 141 - input { 142 - font-family: inherit; 143 - font-size: 0.9rem; 144 - padding: 0.75rem 1rem; 145 - margin-bottom: 1rem; 146 - background: rgba(10, 10, 15, 0.8); 147 - border: 1px solid rgba(255, 255, 255, 0.2); 148 - border-radius: 4px; 149 - color: #e5e5e5; 150 - width: 100%; 151 - transition: all 0.2s; 152 - } 153 - 154 - input:focus { 155 - outline: none; 156 - border-color: rgba(255, 255, 255, 0.4); 157 - background: rgba(10, 10, 15, 0.9); 158 - } 159 - 160 - input::placeholder { 161 - color: rgba(255, 255, 255, 0.3); 162 - } 163 - 164 - .input-wrapper { 165 - position: relative; 166 - width: 100%; 167 - } 168 - 169 - .autocomplete-results { 170 - position: absolute; 171 - z-index: 100; 172 - width: 100%; 173 - max-height: 240px; 174 - overflow-y: auto; 175 - background: rgba(10, 10, 15, 0.98); 176 - border: 1px solid rgba(255, 255, 255, 0.2); 177 - border-radius: 4px; 178 - margin-top: 0.25rem; 179 - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5); 180 - display: none; 181 - scrollbar-width: thin; 182 - scrollbar-color: rgba(255, 255, 255, 0.2) rgba(10, 10, 15, 0.5); 183 - } 184 - 185 - .autocomplete-results::-webkit-scrollbar { 186 - width: 8px; 187 - } 188 - 189 - .autocomplete-results::-webkit-scrollbar-track { 190 - background: rgba(10, 10, 15, 0.5); 191 - border-radius: 4px; 192 - } 193 - 194 - .autocomplete-results::-webkit-scrollbar-thumb { 195 - background: rgba(255, 255, 255, 0.2); 196 - border-radius: 4px; 197 - } 198 - 199 - .autocomplete-results::-webkit-scrollbar-thumb:hover { 200 - background: rgba(255, 255, 255, 0.3); 201 - } 202 - 203 - .autocomplete-results.show { 204 - display: block; 205 - } 206 - 207 - .autocomplete-item { 208 - width: 100%; 209 - display: flex; 210 - align-items: center; 211 - gap: 0.75rem; 212 - padding: 0.75rem; 213 - background: transparent; 214 - border: none; 215 - border-bottom: 1px solid rgba(255, 255, 255, 0.1); 216 - color: #e5e5e5; 217 - text-align: left; 218 - font-family: inherit; 219 - cursor: pointer; 220 - transition: background 0.15s; 221 - } 222 - 223 - .autocomplete-item:last-child { 224 - border-bottom: none; 225 - } 226 - 227 - .autocomplete-item:hover { 228 - background: rgba(255, 255, 255, 0.1); 229 - } 230 - 231 - .autocomplete-avatar { 232 - width: 36px; 233 - height: 36px; 234 - border-radius: 50%; 235 - object-fit: cover; 236 - border: 1px solid rgba(255, 255, 255, 0.2); 237 - flex-shrink: 0; 238 - } 239 - 240 - .autocomplete-avatar-placeholder { 241 - width: 36px; 242 - height: 36px; 243 - border-radius: 50%; 244 - background: rgba(255, 255, 255, 0.1); 245 - flex-shrink: 0; 246 - display: flex; 247 - align-items: center; 248 - justify-content: center; 249 - font-size: 0.9rem; 250 - color: rgba(255, 255, 255, 0.5); 251 - } 252 - 253 - .autocomplete-info { 254 - flex: 1; 255 - min-width: 0; 256 - overflow: hidden; 257 - } 258 - 259 - .autocomplete-name { 260 - font-weight: 500; 261 - color: rgba(255, 255, 255, 0.9); 262 - margin-bottom: 0.125rem; 263 - overflow: hidden; 264 - text-overflow: ellipsis; 265 - white-space: nowrap; 266 - font-size: 0.85rem; 267 - } 268 - 269 - .autocomplete-handle { 270 - font-size: 0.75rem; 271 - color: rgba(255, 255, 255, 0.5); 272 - overflow: hidden; 273 - text-overflow: ellipsis; 274 - white-space: nowrap; 275 - } 276 - 277 - .search-spinner { 278 - position: absolute; 279 - right: 0.75rem; 280 - top: 50%; 281 - transform: translateY(-50%); 282 - color: rgba(255, 255, 255, 0.4); 283 - font-size: 0.75rem; 284 - } 285 - 286 - button { 287 - font-family: inherit; 288 - font-size: 0.9rem; 289 - padding: 0.75rem 2rem; 290 - cursor: pointer; 291 - background: rgba(10, 10, 15, 0.8); 292 - border: 1px solid rgba(255, 255, 255, 0.2); 293 - border-radius: 4px; 294 - color: #e5e5e5; 295 - transition: all 0.2s; 296 - width: 100%; 297 - } 298 - 299 - button:hover { 300 - background: rgba(10, 10, 15, 0.9); 301 - border-color: rgba(255, 255, 255, 0.4); 302 - } 303 - 304 - .divider { 305 - display: flex; 306 - align-items: center; 307 - gap: 1rem; 308 - margin: 1.5rem 0 1rem; 309 - color: rgba(255, 255, 255, 0.3); 310 - font-size: 0.7rem; 311 - } 312 - 313 - .divider::before, 314 - .divider::after { 315 - content: ''; 316 - flex: 1; 317 - height: 1px; 318 - background: rgba(255, 255, 255, 0.1); 319 - } 320 - 321 - .suggestions { 322 - display: flex; 323 - gap: 0.75rem; 324 - flex-wrap: wrap; 325 - justify-content: center; 326 - } 327 - 328 - .suggestion-btn { 329 - font-family: inherit; 330 - font-size: 0.8rem; 331 - padding: 0.5rem 1rem; 332 - cursor: pointer; 333 - background: transparent; 334 - border: 1px solid rgba(255, 255, 255, 0.15); 335 - border-radius: 4px; 336 - color: rgba(255, 255, 255, 0.6); 337 - transition: all 0.2s; 338 - width: auto; 339 - } 340 - 341 - .suggestion-btn:hover { 342 - background: rgba(10, 10, 15, 0.5); 343 - border-color: rgba(255, 255, 255, 0.3); 344 - color: rgba(255, 255, 255, 0.8); 345 - } 346 - 347 - .info-toggle { 348 - margin-top: 1.5rem; 349 - color: rgba(255, 255, 255, 0.5); 350 - font-size: 0.75rem; 351 - cursor: pointer; 352 - border: none; 353 - background: none; 354 - padding: 0.5rem; 355 - transition: color 0.2s; 356 - text-decoration: underline; 357 - text-underline-offset: 2px; 358 - } 359 - 360 - .info-toggle:hover { 361 - color: rgba(255, 255, 255, 0.8); 362 - } 363 - 364 - .info-content { 365 - max-height: 0; 366 - overflow: hidden; 367 - transition: max-height 0.3s ease; 368 - margin-top: 1rem; 369 - } 370 - 371 - .info-content.expanded { 372 - max-height: 500px; 373 - overflow-y: auto; 374 - } 375 - 376 - @media (max-width: 768px) { 377 - .info-content.expanded { 378 - max-height: none; 379 - overflow-y: visible; 380 - } 381 - } 382 - 383 - .info-section { 384 - background: rgba(10, 10, 15, 0.6); 385 - border: 1px solid rgba(255, 255, 255, 0.1); 386 - border-radius: 4px; 387 - padding: 1.5rem; 388 - text-align: left; 389 - } 390 - 391 - .info-section h3 { 392 - font-size: 0.85rem; 393 - font-weight: 500; 394 - margin-bottom: 0.75rem; 395 - color: rgba(255, 255, 255, 0.9); 396 - } 397 - 398 - .info-section p { 399 - font-size: 0.7rem; 400 - line-height: 1.6; 401 - color: rgba(255, 255, 255, 0.6); 402 - margin-bottom: 1rem; 403 - } 404 - 405 - .info-section p:last-child { 406 - margin-bottom: 0; 407 - } 408 - 409 - .info-section strong { 410 - color: rgba(255, 255, 255, 0.85); 411 - } 412 - 413 - .info-section a { 414 - color: rgba(255, 255, 255, 0.8); 415 - text-decoration: underline; 416 - text-underline-offset: 2px; 417 - } 418 - 419 - .info-section a:hover { 420 - color: rgba(255, 255, 255, 1); 421 - } 422 - 423 - .footer { 424 - position: fixed; 425 - bottom: 1rem; 426 - left: 50%; 427 - transform: translateX(-50%); 428 - font-size: 0.7rem; 429 - color: rgba(255, 255, 255, 0.3); 430 - z-index: 20; 431 - } 432 - 433 - .footer a { 434 - color: rgba(255, 255, 255, 0.5); 435 - text-decoration: none; 436 - transition: color 0.2s; 437 - } 438 - 439 - .footer a:hover { 440 - color: rgba(255, 255, 255, 0.8); 441 - } 442 - </style> 443 - </head> 444 - <body> 445 - <div class="atmosphere" id="atmosphere"></div> 446 - 447 - <div class="container"> 448 - <div class="search-card"> 449 - <h1>@me</h1> 450 - <div class="subtitle">explore the atmosphere</div> 451 - <form id="searchForm" onsubmit="event.preventDefault(); handleSearch();"> 452 - <div class="input-wrapper"> 453 - <input type="text" id="handleInput" placeholder="enter any handle" autofocus autocomplete="off" autocapitalize="off" spellcheck="false"> 454 - <span class="search-spinner" id="searchSpinner" style="display: none;">...</span> 455 - <div class="autocomplete-results" id="autocompleteResults"></div> 456 - </div> 457 - <button type="submit">explore</button> 458 - </form> 459 - 460 - <div class="divider">try these</div> 461 - <div class="suggestions"> 462 - <button class="suggestion-btn" onclick="viewHandle('why.bsky.team')">why.bsky.team</button> 463 - <button class="suggestion-btn" onclick="viewHandle('baileytownsend.dev')">baileytownsend.dev</button> 464 - <button class="suggestion-btn" onclick="viewHandle('bad-example.com')">bad-example.com</button> 465 - <button class="suggestion-btn" onclick="viewHandle('void.comind.network')">void.comind.network</button> 466 - </div> 467 - 468 - <button type="button" class="info-toggle" onclick="toggleInfo()">what is this?</button> 469 - 470 - <div class="info-content" id="infoContent"> 471 - <div class="info-section"> 472 - <h3>you should own your data</h3> 473 - <p>today's social platforms own your data. built 10k followers? wrote years of posts? if you leave, you lose it all. you don't own your network - they do.</p> 474 - 475 - <h3>what if social media worked like email?</h3> 476 - <p>with email, you can switch from gmail to protonmail and keep your contacts. same idea here: your posts and followers live on your own server (called a <a href="https://atproto.com/guides/data-repos" target="_blank" rel="noopener noreferrer">Personal Data Server</a>). apps like <a href="https://bsky.app" target="_blank" rel="noopener noreferrer">bluesky</a> just connect to it.</p> 477 - 478 - <h3>see it in action</h3> 479 - <p>enter any handle above to see how <a href="https://atproto.com" target="_blank" rel="noopener noreferrer">atproto</a> stores their social data.</p> 480 - </div> 481 - </div> 482 - </div> 483 - </div> 484 - 485 - <div class="footer"> 486 - by <a href="https://bsky.app/profile/zzstoatzz.io" target="_blank" rel="noopener noreferrer">@zzstoatzz.io</a> 487 - </div> 488 - 489 - <script> 490 - // Autocomplete state 491 - let searchTimeout = null; 492 - let autocompleteResults = []; 493 - 494 - function handleSearch() { 495 - const handle = document.getElementById('handleInput').value.trim(); 496 - if (handle) { 497 - viewHandle(handle); 498 - } 499 - } 500 - 501 - function viewHandle(handle) { 502 - window.location.href = '/view?handle=' + encodeURIComponent(handle); 503 - } 504 - 505 - function toggleInfo() { 506 - document.getElementById('infoContent').classList.toggle('expanded'); 507 - } 508 - 509 - // Autocomplete functionality 510 - const handleInput = document.getElementById('handleInput'); 511 - const resultsDiv = document.getElementById('autocompleteResults'); 512 - const spinner = document.getElementById('searchSpinner'); 513 - 514 - async function searchHandles(query) { 515 - if (query.length < 2) { 516 - autocompleteResults = []; 517 - hideResults(); 518 - return; 519 - } 520 - 521 - spinner.style.display = 'block'; 522 - 523 - try { 524 - const response = await fetch(`/api/search/handles?q=${encodeURIComponent(query)}`); 525 - if (response.ok) { 526 - const data = await response.json(); 527 - autocompleteResults = data.results; 528 - renderResults(); 529 - } 530 - } catch (e) { 531 - console.error('search failed:', e); 532 - } finally { 533 - spinner.style.display = 'none'; 534 - } 535 - } 536 - 537 - function renderResults() { 538 - if (autocompleteResults.length === 0) { 539 - hideResults(); 540 - return; 541 - } 542 - 543 - resultsDiv.innerHTML = autocompleteResults.map(result => ` 544 - <button type="button" class="autocomplete-item" onclick="selectHandle('${result.handle}')"> 545 - ${result.avatarUrl 546 - ? `<img src="${result.avatarUrl}" alt="" class="autocomplete-avatar">` 547 - : `<div class="autocomplete-avatar-placeholder">${result.handle[0].toUpperCase()}</div>` 548 - } 549 - <div class="autocomplete-info"> 550 - <div class="autocomplete-name">${escapeHtml(result.displayName)}</div> 551 - <div class="autocomplete-handle">@${escapeHtml(result.handle)}</div> 552 - </div> 553 - </button> 554 - `).join(''); 555 - 556 - resultsDiv.classList.add('show'); 557 - } 558 - 559 - function escapeHtml(text) { 560 - const div = document.createElement('div'); 561 - div.textContent = text; 562 - return div.innerHTML; 563 - } 564 - 565 - function hideResults() { 566 - resultsDiv.classList.remove('show'); 567 - } 568 - 569 - function selectHandle(handle) { 570 - handleInput.value = handle; 571 - autocompleteResults = []; 572 - hideResults(); 573 - viewHandle(handle); 574 - } 575 - 576 - handleInput.addEventListener('input', () => { 577 - if (searchTimeout) clearTimeout(searchTimeout); 578 - searchTimeout = setTimeout(() => searchHandles(handleInput.value), 300); 579 - }); 580 - 581 - handleInput.addEventListener('keydown', (e) => { 582 - if (e.key === 'Escape') { 583 - hideResults(); 584 - } 585 - }); 586 - 587 - handleInput.addEventListener('focus', () => { 588 - if (autocompleteResults.length > 0) { 589 - resultsDiv.classList.add('show'); 590 - } 591 - }); 592 - 593 - document.addEventListener('click', (e) => { 594 - if (!e.target.closest('.input-wrapper')) { 595 - hideResults(); 596 - } 597 - }); 598 - 599 - // Atmosphere rendering 600 - async function fetchAtmosphere() { 601 - const CACHE_KEY = 'atme_atmosphere'; 602 - const CACHE_DURATION = 24 * 60 * 60 * 1000; // 24 hours 603 - 604 - const cached = localStorage.getItem(CACHE_KEY); 605 - if (cached) { 606 - const { data, timestamp } = JSON.parse(cached); 607 - if (Date.now() - timestamp < CACHE_DURATION) { 608 - return data; 609 - } 610 - } 611 - 612 - try { 613 - const response = await fetch('https://ufos-api.microcosm.blue/collections?order=dids-estimate&limit=50'); 614 - const json = await response.json(); 615 - 616 - // Group by namespace (first two segments) 617 - const namespaces = {}; 618 - json.collections.forEach(col => { 619 - const parts = col.nsid.split('.'); 620 - if (parts.length >= 2) { 621 - const ns = `${parts[0]}.${parts[1]}`; 622 - if (!namespaces[ns]) { 623 - namespaces[ns] = { 624 - namespace: ns, 625 - dids_total: 0, 626 - records_total: 0, 627 - collections: [] 628 - }; 629 - } 630 - namespaces[ns].dids_total += col.dids_estimate; 631 - namespaces[ns].records_total += col.creates; 632 - namespaces[ns].collections.push(col.nsid); 633 - } 634 - }); 635 - 636 - const data = Object.values(namespaces).sort((a, b) => b.dids_total - a.dids_total).slice(0, 30); 637 - 638 - localStorage.setItem(CACHE_KEY, JSON.stringify({ 639 - data, 640 - timestamp: Date.now() 641 - })); 642 - 643 - return data; 644 - } catch (e) { 645 - console.error('Failed to fetch atmosphere data:', e); 646 - return []; 647 - } 648 - } 649 - 650 - async function fetchAppAvatars(namespaces) { 651 - if (!Array.isArray(namespaces) || !namespaces.length) return {}; 652 - const deduped = [...new Set(namespaces.filter(Boolean))]; 653 - if (!deduped.length) return {}; 654 - 655 - try { 656 - const response = await fetch('/api/avatar/batch', { 657 - method: 'POST', 658 - headers: { 'Content-Type': 'application/json' }, 659 - body: JSON.stringify({ namespaces: deduped }) 660 - }); 661 - if (!response.ok) return {}; 662 - const data = await response.json(); 663 - return data.avatars || {}; 664 - } catch (e) { 665 - return {}; 666 - } 667 - } 668 - 669 - async function renderAtmosphere() { 670 - const data = await fetchAtmosphere(); 671 - if (!data.length) return; 672 - 673 - const atmosphere = document.getElementById('atmosphere'); 674 - const maxSize = Math.max(...data.map(d => d.dids_total)); 675 - 676 - const namespaces = data.map(app => app.namespace); 677 - const avatarPromise = fetchAppAvatars(namespaces); 678 - const orbRegistry = []; 679 - 680 - data.forEach((app, i) => { 681 - const orb = document.createElement('div'); 682 - orb.className = 'app-orb'; 683 - 684 - // Size based on user count (20-80px) 685 - const size = 20 + (app.dids_total / maxSize) * 60; 686 - 687 - // Position in 3D space 688 - const angle = (i / data.length) * Math.PI * 2; 689 - const radius = 250 + (i % 3) * 100; 690 - const y = (i % 5) * 80 - 160; 691 - const x = Math.cos(angle) * radius; 692 - const z = Math.sin(angle) * radius; 693 - 694 - orb.style.width = `${size}px`; 695 - orb.style.height = `${size}px`; 696 - orb.style.left = `calc(50% + ${x}px)`; 697 - orb.style.top = `calc(50% + ${y}px)`; 698 - orb.style.transform = `translateZ(${z}px) translate(-50%, -50%)`; 699 - orb.style.background = `radial-gradient(circle, rgba(255,255,255,0.1), rgba(255,255,255,0.02))`; 700 - orb.style.border = '1px solid rgba(255,255,255,0.1)'; 701 - orb.style.boxShadow = '0 0 20px rgba(255,255,255,0.1)'; 702 - 703 - // Fallback letter 704 - const letter = app.namespace.split('.')[1]?.[0]?.toUpperCase() || app.namespace[0].toUpperCase(); 705 - orb.innerHTML = `<div class="fallback">${letter}</div>`; 706 - 707 - // Tooltip 708 - const tooltip = document.createElement('div'); 709 - tooltip.className = 'app-tooltip'; 710 - const users = app.dids_total >= 1000000 711 - ? `${(app.dids_total / 1000000).toFixed(1)}M users` 712 - : `${(app.dids_total / 1000).toFixed(0)}K users`; 713 - tooltip.textContent = `${app.namespace} • ${users}`; 714 - orb.appendChild(tooltip); 715 - 716 - atmosphere.appendChild(orb); 717 - 718 - orbRegistry.push({ orb, tooltip, namespace: app.namespace }); 719 - }); 720 - 721 - avatarPromise.then(avatarMap => { 722 - orbRegistry.forEach(({ orb, tooltip, namespace }) => { 723 - const avatarUrl = avatarMap[namespace]; 724 - if (avatarUrl) { 725 - orb.innerHTML = `<img src="${avatarUrl}" alt="${namespace}" />`; 726 - orb.appendChild(tooltip); 727 - } 728 - }); 729 - }); 730 - } 731 - 732 - renderAtmosphere(); 733 - </script> 734 - </body> 735 - </html>
+173
src/view/atproto.js
··· 1 + // ============================================================================ 2 + // ATPROTO UTILITIES - Client-side DID/PDS resolution 3 + // ============================================================================ 4 + 5 + import { state } from './state.js'; 6 + 7 + const DOMAIN_REDIRECTS = { 8 + 'tangled.sh': 'tangled.org', 9 + }; 10 + 11 + export function applyDomainRedirect(domain) { 12 + return DOMAIN_REDIRECTS[domain] || domain; 13 + } 14 + 15 + export function escapeHtml(text) { 16 + const div = document.createElement('div'); 17 + div.textContent = text; 18 + return div.innerHTML; 19 + } 20 + 21 + // Resolve a handle to a DID 22 + export async function resolveHandle(handle) { 23 + try { 24 + const response = await fetch(`https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`); 25 + if (response.ok) { 26 + const data = await response.json(); 27 + return data.did; 28 + } 29 + } catch (e) { 30 + console.error('Failed to resolve handle:', e); 31 + } 32 + return null; 33 + } 34 + 35 + // Resolve a DID to its DID document (for PDS endpoint) 36 + export async function resolveDid(did) { 37 + try { 38 + // For did:plc, use the plc.directory 39 + if (did.startsWith('did:plc:')) { 40 + const response = await fetch(`https://plc.directory/${did}`); 41 + if (response.ok) { 42 + return await response.json(); 43 + } 44 + } 45 + // For did:web, resolve via .well-known 46 + if (did.startsWith('did:web:')) { 47 + const domain = did.replace('did:web:', ''); 48 + const response = await fetch(`https://${domain}/.well-known/did.json`); 49 + if (response.ok) { 50 + return await response.json(); 51 + } 52 + } 53 + } catch (e) { 54 + console.error('Failed to resolve DID:', e); 55 + } 56 + return null; 57 + } 58 + 59 + // Get PDS endpoint from DID document 60 + export function getPdsFromDidDoc(didDoc) { 61 + if (!didDoc || !didDoc.service) return null; 62 + const atprotoService = didDoc.service.find(s => 63 + s.type === 'AtprotoPersonalDataServer' || 64 + s.id === '#atproto_pds' 65 + ); 66 + return atprotoService?.serviceEndpoint || null; 67 + } 68 + 69 + // Get handle from DID document 70 + export function getHandleFromDidDoc(didDoc) { 71 + if (!didDoc || !didDoc.alsoKnownAs) return null; 72 + const atUri = didDoc.alsoKnownAs.find(u => u.startsWith('at://')); 73 + return atUri ? atUri.replace('at://', '') : null; 74 + } 75 + 76 + // Get profile from Bluesky AppView 77 + export async function getProfile(handleOrDid) { 78 + try { 79 + const response = await fetch(`https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(handleOrDid)}`); 80 + if (response.ok) { 81 + return await response.json(); 82 + } 83 + } catch (e) { 84 + console.error('Failed to get profile:', e); 85 + } 86 + return null; 87 + } 88 + 89 + // Describe the repo to get all collections 90 + export async function describeRepo(pds, did) { 91 + try { 92 + const response = await fetch(`${pds}/xrpc/com.atproto.repo.describeRepo?repo=${encodeURIComponent(did)}`); 93 + if (response.ok) { 94 + return await response.json(); 95 + } 96 + } catch (e) { 97 + console.error('Failed to describe repo:', e); 98 + } 99 + return null; 100 + } 101 + 102 + // List records in a collection 103 + export async function listRecords(pds, did, collection, limit = 10, cursor = null) { 104 + try { 105 + let url = `${pds}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(did)}&collection=${encodeURIComponent(collection)}&limit=${limit}`; 106 + if (cursor) url += `&cursor=${cursor}`; 107 + const response = await fetch(url); 108 + if (response.ok) { 109 + return await response.json(); 110 + } 111 + } catch (e) { 112 + console.error('Failed to list records:', e); 113 + } 114 + return null; 115 + } 116 + 117 + // Get app avatar from the namespace's well-known profile 118 + export async function getAppAvatar(namespace) { 119 + const domain = namespace.split('.').reverse().join('.'); 120 + const redirectedDomain = applyDomainRedirect(domain); 121 + try { 122 + const profile = await getProfile(redirectedDomain); 123 + if (profile && profile.avatar) { 124 + return profile.avatar; 125 + } 126 + } catch (e) { 127 + // Silently fail 128 + } 129 + return null; 130 + } 131 + 132 + // Batch fetch app avatars 133 + export async function fetchAppAvatars(namespaces) { 134 + const avatars = {}; 135 + const promises = namespaces.map(async (ns) => { 136 + const avatar = await getAppAvatar(ns); 137 + if (avatar) { 138 + avatars[ns] = avatar; 139 + } 140 + }); 141 + await Promise.all(promises); 142 + return avatars; 143 + } 144 + 145 + // Validate app URLs - only mark as invalid if URL is malformed 146 + // Namespaces come from ATProto lexicons, so reversed domains are valid by definition 147 + export async function validateAppUrls(appDivs) { 148 + // Clear previous invalid apps 149 + state.invalidApps.clear(); 150 + 151 + appDivs.forEach(({ div, namespace }) => { 152 + const link = div.querySelector('.app-name'); 153 + const url = link?.dataset.url; 154 + if (!url) return; 155 + 156 + try { 157 + // Just check if it's a valid URL - don't try to fetch or validate the domain 158 + new URL(url); 159 + } catch (e) { 160 + // Malformed URL - mark as invalid 161 + if (link) { 162 + const displayName = link.textContent.replace(' ↗', '').replace(' \u2197', ''); 163 + link.classList.add('invalid-link'); 164 + link.setAttribute('title', 'malformed URL'); 165 + link.style.pointerEvents = 'none'; 166 + link.textContent = displayName; 167 + } 168 + if (namespace) { 169 + state.invalidApps.add(namespace); 170 + } 171 + } 172 + }); 173 + }
+155
src/view/base.css
··· 1 + * { 2 + margin: 0; 3 + padding: 0; 4 + box-sizing: border-box; 5 + } 6 + 7 + :root { 8 + --bg: #f5f1e8; 9 + --text: #4a4238; 10 + --text-light: #8a7a6a; 11 + --text-lighter: #6b5d4f; 12 + --border: #c9bfa8; 13 + --surface: #e5dbc8; 14 + --surface-hover: #d9cdb5; 15 + } 16 + 17 + @media (prefers-color-scheme: dark) { 18 + :root { 19 + --bg: #1a1a1a; 20 + --text: #e5e5e5; 21 + --text-light: #a0a0a0; 22 + --text-lighter: #c0c0c0; 23 + --border: #404040; 24 + --surface: #2a2a2a; 25 + --surface-hover: #353535; 26 + } 27 + } 28 + 29 + body { 30 + font-family: ui-monospace, 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Menlo, 'Courier New', monospace; 31 + height: 100vh; 32 + background: var(--bg); 33 + color: var(--text); 34 + overflow: hidden; 35 + position: relative; 36 + -webkit-font-smoothing: antialiased; 37 + -moz-osx-font-smoothing: grayscale; 38 + } 39 + 40 + .canvas { 41 + position: fixed; 42 + inset: 0; 43 + } 44 + 45 + .info { 46 + position: fixed; 47 + bottom: clamp(0.75rem, 2vmin, 1rem); 48 + left: clamp(0.75rem, 2vmin, 1rem); 49 + width: clamp(32px, 7vmin, 40px); 50 + height: clamp(32px, 7vmin, 40px); 51 + display: flex; 52 + align-items: center; 53 + justify-content: center; 54 + font-size: clamp(0.85rem, 1.8vmin, 1rem); 55 + color: var(--text-light); 56 + cursor: pointer; 57 + transition: all 0.2s ease; 58 + z-index: 100; 59 + -webkit-tap-highlight-color: transparent; 60 + } 61 + 62 + .info:hover, 63 + .info:active { 64 + color: var(--text); 65 + } 66 + 67 + .info-modal { 68 + position: fixed; 69 + top: 50%; 70 + left: 50%; 71 + transform: translate(-50%, -50%); 72 + background: var(--surface); 73 + border: 2px solid var(--border); 74 + padding: 2rem; 75 + max-width: 500px; 76 + width: 90%; 77 + z-index: 2000; 78 + display: none; 79 + border-radius: 4px; 80 + } 81 + 82 + @media (max-width: 768px) { 83 + .info-modal { 84 + padding: 1.5rem; 85 + width: 95%; 86 + } 87 + 88 + .info-modal h2 { 89 + font-size: 0.9rem; 90 + } 91 + 92 + .info-modal p { 93 + font-size: 0.7rem; 94 + } 95 + } 96 + 97 + .info-modal.visible { 98 + display: block; 99 + } 100 + 101 + .info-modal h2 { 102 + margin-bottom: 1rem; 103 + font-size: 1rem; 104 + color: var(--text); 105 + } 106 + 107 + .info-modal p { 108 + margin-bottom: 0.75rem; 109 + font-size: 0.75rem; 110 + line-height: 1.5; 111 + color: var(--text-lighter); 112 + } 113 + 114 + .info-modal button { 115 + margin-top: 1rem; 116 + padding: 0.5rem 1rem; 117 + background: var(--bg); 118 + border: 1px solid var(--border); 119 + color: var(--text); 120 + font-family: inherit; 121 + font-size: 0.7rem; 122 + cursor: pointer; 123 + transition: all 0.2s ease; 124 + -webkit-tap-highlight-color: transparent; 125 + border-radius: 2px; 126 + } 127 + 128 + .info-modal button:hover, 129 + .info-modal button:active { 130 + background: var(--surface-hover); 131 + border-color: var(--text-light); 132 + } 133 + 134 + @media (max-width: 768px) { 135 + .info-modal button { 136 + padding: 0.65rem 1.2rem; 137 + font-size: 0.75rem; 138 + } 139 + } 140 + 141 + .overlay { 142 + position: fixed; 143 + top: 0; 144 + left: 0; 145 + right: 0; 146 + bottom: 0; 147 + background: rgba(0, 0, 0, 0.5); 148 + z-index: 1999; 149 + display: none; 150 + } 151 + 152 + .overlay.visible { 153 + display: block; 154 + } 155 +
+262
src/view/controls.css
··· 1 + .home-btn { 2 + position: fixed; 3 + top: clamp(1rem, 2vmin, 1.5rem); 4 + left: clamp(1rem, 2vmin, 1.5rem); 5 + font-family: inherit; 6 + font-size: clamp(0.85rem, 1.8vmin, 1rem); 7 + color: var(--text-light); 8 + border: 1px solid var(--border); 9 + background: var(--bg); 10 + padding: clamp(0.4rem, 1vmin, 0.5rem) clamp(0.6rem, 1.5vmin, 0.8rem); 11 + transition: all 0.2s ease; 12 + z-index: 100; 13 + cursor: pointer; 14 + border-radius: 2px; 15 + text-decoration: none; 16 + display: inline-flex; 17 + align-items: center; 18 + justify-content: center; 19 + width: clamp(32px, 7vmin, 40px); 20 + height: clamp(32px, 7vmin, 40px); 21 + } 22 + 23 + .home-btn:hover, 24 + .home-btn:active { 25 + background: var(--surface); 26 + color: var(--text); 27 + border-color: var(--text-light); 28 + } 29 + 30 + .watch-live-btn { 31 + font-family: inherit; 32 + font-size: clamp(0.65rem, 1.4vmin, 0.75rem); 33 + color: var(--text-light); 34 + border: 1px solid var(--border); 35 + background: var(--bg); 36 + padding: clamp(0.4rem, 1vmin, 0.5rem) clamp(0.8rem, 2vmin, 1rem); 37 + transition: all 0.2s ease; 38 + cursor: pointer; 39 + border-radius: 2px; 40 + display: flex; 41 + align-items: center; 42 + gap: clamp(0.3rem, 0.8vmin, 0.5rem); 43 + } 44 + 45 + .watch-live-btn:hover, 46 + .watch-live-btn:active { 47 + background: var(--surface); 48 + color: var(--text); 49 + border-color: var(--text-light); 50 + } 51 + 52 + .watch-live-btn.active { 53 + background: var(--surface-hover); 54 + color: var(--text); 55 + border-color: var(--text); 56 + } 57 + 58 + .watch-indicator { 59 + width: clamp(8px, 2vmin, 10px); 60 + height: clamp(8px, 2vmin, 10px); 61 + border-radius: 50%; 62 + background: var(--text-light); 63 + display: none; 64 + } 65 + 66 + .watch-live-btn.active .watch-indicator { 67 + display: block; 68 + animation: pulse 2s ease-in-out infinite; 69 + } 70 + 71 + @keyframes pulse { 72 + 0%, 100% { opacity: 0.5; } 73 + 50% { opacity: 1; } 74 + } 75 + 76 + .top-right-buttons { 77 + position: fixed; 78 + top: clamp(1rem, 2vmin, 1.5rem); 79 + right: clamp(1rem, 2vmin, 1.5rem); 80 + display: flex; 81 + flex-direction: row; 82 + align-items: center; 83 + gap: clamp(0.5rem, 1vmin, 0.75rem); 84 + z-index: 100; 85 + } 86 + 87 + @media (max-width: 768px) { 88 + .top-right-buttons { 89 + flex-direction: column; 90 + align-items: flex-end; 91 + } 92 + } 93 + 94 + .filter-btn { 95 + font-family: inherit; 96 + font-size: clamp(0.65rem, 1.4vmin, 0.75rem); 97 + color: var(--text-light); 98 + border: 1px solid var(--border); 99 + background: var(--bg); 100 + padding: clamp(0.4rem, 1vmin, 0.5rem) clamp(0.8rem, 2vmin, 1rem); 101 + transition: all 0.2s ease; 102 + cursor: pointer; 103 + border-radius: 2px; 104 + display: flex; 105 + align-items: center; 106 + gap: clamp(0.3rem, 0.8vmin, 0.5rem); 107 + } 108 + 109 + .filter-btn:hover, 110 + .filter-btn:active { 111 + background: var(--surface); 112 + color: var(--text); 113 + border-color: var(--text-light); 114 + } 115 + 116 + .filter-btn.active { 117 + background: var(--surface-hover); 118 + color: var(--text); 119 + border-color: var(--text); 120 + } 121 + 122 + .filter-btn.has-filters { 123 + border-color: var(--text-light); 124 + } 125 + 126 + .filter-count { 127 + font-size: 0.6rem; 128 + background: var(--text-light); 129 + color: var(--bg); 130 + padding: 0.1rem 0.35rem; 131 + border-radius: 2px; 132 + font-weight: 500; 133 + } 134 + 135 + .info { 136 + position: fixed; 137 + bottom: clamp(0.75rem, 2vmin, 1rem); 138 + left: clamp(0.75rem, 2vmin, 1rem); 139 + width: clamp(32px, 7vmin, 40px); 140 + height: clamp(32px, 7vmin, 40px); 141 + display: flex; 142 + align-items: center; 143 + justify-content: center; 144 + font-size: clamp(0.85rem, 1.8vmin, 1rem); 145 + color: var(--text-light); 146 + cursor: pointer; 147 + transition: all 0.2s ease; 148 + z-index: 100; 149 + -webkit-tap-highlight-color: transparent; 150 + } 151 + 152 + .info:hover, 153 + .info:active { 154 + color: var(--text); 155 + } 156 + 157 + .info-modal { 158 + position: fixed; 159 + top: 50%; 160 + left: 50%; 161 + transform: translate(-50%, -50%); 162 + background: var(--surface); 163 + border: 2px solid var(--border); 164 + padding: 2rem; 165 + max-width: 500px; 166 + width: 90%; 167 + z-index: 2000; 168 + display: none; 169 + border-radius: 4px; 170 + } 171 + 172 + @media (max-width: 768px) { 173 + .info-modal { 174 + padding: 1.5rem; 175 + width: 95%; 176 + } 177 + 178 + .info-modal h2 { 179 + font-size: 0.9rem; 180 + } 181 + 182 + .info-modal p { 183 + font-size: 0.7rem; 184 + } 185 + } 186 + 187 + .info-modal.visible { 188 + display: block; 189 + } 190 + 191 + .info-modal h2 { 192 + margin-bottom: 1rem; 193 + font-size: 1rem; 194 + color: var(--text); 195 + } 196 + 197 + .info-modal p { 198 + margin-bottom: 0.75rem; 199 + font-size: 0.75rem; 200 + line-height: 1.5; 201 + color: var(--text-lighter); 202 + } 203 + 204 + .info-modal button { 205 + margin-top: 1rem; 206 + padding: 0.5rem 1rem; 207 + background: var(--bg); 208 + border: 1px solid var(--border); 209 + color: var(--text); 210 + font-family: inherit; 211 + font-size: 0.7rem; 212 + cursor: pointer; 213 + transition: all 0.2s ease; 214 + -webkit-tap-highlight-color: transparent; 215 + border-radius: 2px; 216 + } 217 + 218 + .info-modal button:hover, 219 + .info-modal button:active { 220 + background: var(--surface-hover); 221 + border-color: var(--text-light); 222 + } 223 + 224 + @media (max-width: 768px) { 225 + .info-modal button { 226 + padding: 0.65rem 1.2rem; 227 + font-size: 0.75rem; 228 + } 229 + } 230 + 231 + .overlay { 232 + position: fixed; 233 + top: 0; 234 + left: 0; 235 + right: 0; 236 + bottom: 0; 237 + background: rgba(0, 0, 0, 0.5); 238 + z-index: 1999; 239 + display: none; 240 + } 241 + 242 + .overlay.visible { 243 + display: block; 244 + } 245 + 246 + .pov-indicator { 247 + position: fixed; 248 + bottom: clamp(0.75rem, 2vmin, 1rem); 249 + right: clamp(0.75rem, 2vmin, 1rem); 250 + font-size: clamp(0.55rem, 1.2vmin, 0.65rem); 251 + color: var(--text-light); 252 + z-index: 100; 253 + } 254 + 255 + .pov-handle { 256 + color: var(--text); 257 + text-decoration: none; 258 + } 259 + 260 + .pov-handle:hover { 261 + text-decoration: underline; 262 + }
+359
src/view/detail.css
··· 1 + .detail-panel { 2 + position: fixed; 3 + top: 0; 4 + left: 0; 5 + bottom: 0; 6 + width: 500px; 7 + background: var(--surface); 8 + border-right: 2px solid var(--border); 9 + padding: 2.5rem 2rem; 10 + overflow-y: auto; 11 + opacity: 0; 12 + transform: translateX(-100%); 13 + transition: all 0.25s ease; 14 + z-index: 1000; 15 + scrollbar-width: none; 16 + -ms-overflow-style: none; 17 + } 18 + 19 + .detail-panel::-webkit-scrollbar { 20 + display: none; 21 + } 22 + 23 + .detail-panel.visible { 24 + opacity: 1; 25 + transform: translateX(0); 26 + } 27 + 28 + @media (max-width: 768px) { 29 + .detail-panel { 30 + width: 100%; 31 + padding: 4rem 1.5rem 2rem; 32 + border-right: none; 33 + border-bottom: 2px solid var(--border); 34 + } 35 + } 36 + 37 + .detail-panel h3 { 38 + margin-bottom: 0.75rem; 39 + font-size: 0.85rem; 40 + color: var(--text); 41 + } 42 + 43 + .detail-panel .subtitle { 44 + font-size: 0.7rem; 45 + color: var(--text-light); 46 + margin-bottom: 1.5rem; 47 + line-height: 1.4; 48 + } 49 + 50 + .detail-close { 51 + position: absolute; 52 + top: 1.5rem; 53 + right: 1.5rem; 54 + width: 32px; 55 + height: 32px; 56 + border: 1px solid var(--border); 57 + background: var(--bg); 58 + color: var(--text-light); 59 + cursor: pointer; 60 + display: flex; 61 + align-items: center; 62 + justify-content: center; 63 + font-size: 1.2rem; 64 + line-height: 1; 65 + transition: all 0.2s ease; 66 + border-radius: 2px; 67 + -webkit-tap-highlight-color: transparent; 68 + } 69 + 70 + .detail-close:hover, 71 + .detail-close:active { 72 + background: var(--surface-hover); 73 + border-color: var(--text-light); 74 + color: var(--text); 75 + } 76 + 77 + @media (max-width: 768px) { 78 + .detail-close { 79 + top: 1rem; 80 + right: 1rem; 81 + width: 40px; 82 + height: 40px; 83 + font-size: 1.4rem; 84 + } 85 + } 86 + 87 + .tree-item { 88 + padding: 0.65rem 0.75rem; 89 + font-size: 0.75rem; 90 + color: var(--text-lighter); 91 + background: var(--bg); 92 + border: 1px solid var(--border); 93 + border-radius: 2px; 94 + margin-bottom: 0.5rem; 95 + transition: all 0.15s ease; 96 + cursor: pointer; 97 + -webkit-tap-highlight-color: transparent; 98 + } 99 + 100 + .tree-item:hover, 101 + .tree-item:active { 102 + background: var(--surface-hover); 103 + border-color: var(--text-light); 104 + } 105 + 106 + @media (max-width: 768px) { 107 + .tree-item { 108 + padding: 0.8rem 0.9rem; 109 + font-size: 0.8rem; 110 + } 111 + } 112 + 113 + .tree-item:last-child { 114 + margin-bottom: 0; 115 + } 116 + 117 + .tree-item-header { 118 + display: flex; 119 + justify-content: space-between; 120 + align-items: center; 121 + } 122 + 123 + .tree-item-count { 124 + font-size: 0.65rem; 125 + color: var(--text-light); 126 + } 127 + 128 + .collection-content { 129 + margin-top: 0.5rem; 130 + padding-top: 0.5rem; 131 + border-top: 1px solid var(--border); 132 + } 133 + 134 + .collection-tabs { 135 + display: flex; 136 + gap: 0; 137 + margin-bottom: 0.75rem; 138 + border: 1px solid var(--border); 139 + border-radius: 2px; 140 + overflow: hidden; 141 + } 142 + 143 + .collection-tab { 144 + flex: 1; 145 + padding: 0.5rem 0.75rem; 146 + background: var(--bg); 147 + border: none; 148 + border-right: 1px solid var(--border); 149 + color: var(--text-light); 150 + font-family: inherit; 151 + font-size: 0.65rem; 152 + cursor: pointer; 153 + transition: all 0.15s ease; 154 + -webkit-tap-highlight-color: transparent; 155 + } 156 + 157 + .collection-tab:last-child { 158 + border-right: none; 159 + } 160 + 161 + .collection-tab:hover { 162 + background: var(--surface); 163 + color: var(--text); 164 + } 165 + 166 + .collection-tab.active { 167 + background: var(--surface-hover); 168 + color: var(--text); 169 + font-weight: 500; 170 + } 171 + 172 + .collection-view-content { 173 + position: relative; 174 + } 175 + 176 + .collection-view { 177 + display: none; 178 + } 179 + 180 + .collection-view.active { 181 + display: block; 182 + } 183 + 184 + .structure-view { 185 + min-height: 600px; 186 + } 187 + 188 + .mst-canvas { 189 + width: 100%; 190 + height: 600px; 191 + border: 1px solid var(--border); 192 + border-radius: 4px; 193 + background: var(--bg); 194 + margin-top: 0.5rem; 195 + } 196 + 197 + .mst-info { 198 + background: var(--bg); 199 + border: 1px solid var(--border); 200 + padding: 0.75rem; 201 + border-radius: 4px; 202 + margin-bottom: 0.75rem; 203 + } 204 + 205 + .mst-info p { 206 + font-size: 0.65rem; 207 + color: var(--text-lighter); 208 + line-height: 1.5; 209 + margin: 0; 210 + } 211 + 212 + .record-list { 213 + margin-top: 0.5rem; 214 + padding-top: 0.5rem; 215 + border-top: 1px solid var(--border); 216 + } 217 + 218 + .record { 219 + margin-bottom: 0.5rem; 220 + background: var(--bg); 221 + border: 1px solid var(--border); 222 + border-radius: 4px; 223 + font-size: 0.65rem; 224 + color: var(--text-light); 225 + transition: all 0.15s ease; 226 + overflow: hidden; 227 + } 228 + 229 + .record:hover { 230 + border-color: var(--text-light); 231 + background: var(--surface); 232 + } 233 + 234 + .record:last-child { 235 + margin-bottom: 0; 236 + } 237 + 238 + .record-header { 239 + display: flex; 240 + justify-content: space-between; 241 + align-items: center; 242 + padding: 0.5rem 0.6rem; 243 + background: var(--surface); 244 + border-bottom: 1px solid var(--border); 245 + } 246 + 247 + .record-label { 248 + font-size: 0.6rem; 249 + color: var(--text-lighter); 250 + font-weight: 500; 251 + } 252 + 253 + .copy-btn { 254 + background: var(--bg); 255 + border: 1px solid var(--border); 256 + color: var(--text-light); 257 + font-family: inherit; 258 + font-size: 0.55rem; 259 + padding: 0.2rem 0.5rem; 260 + cursor: pointer; 261 + transition: all 0.15s ease; 262 + border-radius: 2px; 263 + -webkit-tap-highlight-color: transparent; 264 + } 265 + 266 + .copy-btn:hover, 267 + .copy-btn:active { 268 + background: var(--surface-hover); 269 + border-color: var(--text-light); 270 + color: var(--text); 271 + } 272 + 273 + .copy-btn.copied { 274 + color: var(--text); 275 + border-color: var(--text); 276 + } 277 + 278 + .record-content { 279 + padding: 0.6rem; 280 + } 281 + 282 + .record-content pre { 283 + margin: 0; 284 + white-space: pre-wrap; 285 + word-break: break-word; 286 + line-height: 1.5; 287 + font-size: 0.625rem; 288 + } 289 + 290 + .load-more { 291 + margin-top: 0.5rem; 292 + padding: 0.4rem 0.6rem; 293 + background: var(--bg); 294 + border: 1px solid var(--border); 295 + color: var(--text); 296 + font-family: inherit; 297 + font-size: 0.65rem; 298 + cursor: pointer; 299 + width: 100%; 300 + transition: all 0.15s ease; 301 + -webkit-tap-highlight-color: transparent; 302 + border-radius: 2px; 303 + } 304 + 305 + .load-more:hover, 306 + .load-more:active { 307 + background: var(--surface-hover); 308 + border-color: var(--text-light); 309 + } 310 + 311 + @media (max-width: 768px) { 312 + .load-more { 313 + padding: 0.6rem 0.8rem; 314 + font-size: 0.7rem; 315 + } 316 + } 317 + 318 + #field.loading { 319 + position: fixed; 320 + inset: 0; 321 + display: flex; 322 + flex-direction: column; 323 + align-items: center; 324 + justify-content: center; 325 + gap: 1.5rem; 326 + z-index: 1000; 327 + background: var(--bg); 328 + } 329 + 330 + #field.loading~.identity { 331 + display: none; 332 + } 333 + 334 + .loading-spinner { 335 + width: 48px; 336 + height: 48px; 337 + border: 3px solid var(--border); 338 + border-top-color: var(--text); 339 + border-radius: 50%; 340 + animation: spin 0.8s linear infinite; 341 + } 342 + 343 + @keyframes spin { 344 + to { 345 + transform: rotate(360deg); 346 + } 347 + } 348 + 349 + .loading-text { 350 + color: var(--text); 351 + font-size: 0.85rem; 352 + font-weight: 500; 353 + letter-spacing: 0.05em; 354 + } 355 + 356 + .loading-progress { 357 + color: var(--text-light); 358 + font-size: 0.7rem; 359 + }
+158
src/view/filter.css
··· 1 + .filter-panel { 2 + position: fixed; 3 + top: clamp(3.5rem, 7vmin, 4.5rem); 4 + right: clamp(1rem, 2vmin, 1.5rem); 5 + background: var(--surface); 6 + border: 1px solid var(--border); 7 + border-radius: 4px; 8 + padding: 1rem; 9 + z-index: 250; 10 + max-height: 60vh; 11 + overflow-y: auto; 12 + min-width: 200px; 13 + max-width: 280px; 14 + display: none; 15 + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); 16 + } 17 + 18 + @media (max-width: 768px) { 19 + .filter-panel { 20 + top: clamp(6rem, 12vmin, 8rem); 21 + } 22 + } 23 + 24 + @media (prefers-color-scheme: dark) { 25 + .filter-panel { 26 + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); 27 + } 28 + } 29 + 30 + .filter-panel.visible { 31 + display: block; 32 + } 33 + 34 + .filter-panel-header { 35 + display: flex; 36 + justify-content: space-between; 37 + align-items: center; 38 + margin-bottom: 0.75rem; 39 + padding-bottom: 0.5rem; 40 + border-bottom: 1px solid var(--border); 41 + } 42 + 43 + .filter-panel-title { 44 + font-size: 0.7rem; 45 + font-weight: 500; 46 + color: var(--text); 47 + text-transform: lowercase; 48 + } 49 + 50 + .filter-panel-actions { 51 + display: flex; 52 + gap: 0.5rem; 53 + } 54 + 55 + .filter-action-btn { 56 + font-family: inherit; 57 + font-size: 0.6rem; 58 + color: var(--text-light); 59 + background: transparent; 60 + border: none; 61 + cursor: pointer; 62 + padding: 0.2rem 0; 63 + transition: color 0.2s ease; 64 + } 65 + 66 + .filter-action-btn:hover { 67 + color: var(--text); 68 + } 69 + 70 + .filter-list { 71 + display: flex; 72 + flex-direction: column; 73 + gap: 0.25rem; 74 + } 75 + 76 + .filter-item { 77 + display: flex; 78 + align-items: center; 79 + gap: 0.5rem; 80 + padding: 0.4rem 0.5rem; 81 + border-radius: 2px; 82 + cursor: pointer; 83 + transition: background 0.15s ease; 84 + } 85 + 86 + .filter-item:hover { 87 + background: var(--surface-hover); 88 + } 89 + 90 + .filter-checkbox { 91 + width: 14px; 92 + height: 14px; 93 + border: 1px solid var(--border); 94 + border-radius: 2px; 95 + background: var(--bg); 96 + display: flex; 97 + align-items: center; 98 + justify-content: center; 99 + flex-shrink: 0; 100 + transition: all 0.15s ease; 101 + } 102 + 103 + .filter-item.checked .filter-checkbox { 104 + background: var(--text); 105 + border-color: var(--text); 106 + } 107 + 108 + .filter-checkbox-icon { 109 + width: 10px; 110 + height: 10px; 111 + stroke: var(--bg); 112 + stroke-width: 2; 113 + opacity: 0; 114 + transition: opacity 0.15s ease; 115 + } 116 + 117 + .filter-item.checked .filter-checkbox-icon { 118 + opacity: 1; 119 + } 120 + 121 + .filter-label { 122 + font-size: 0.7rem; 123 + color: var(--text-lighter); 124 + overflow: hidden; 125 + text-overflow: ellipsis; 126 + white-space: nowrap; 127 + } 128 + 129 + .filter-item.checked .filter-label { 130 + color: var(--text); 131 + } 132 + 133 + .app-view.filtered { 134 + display: none !important; 135 + } 136 + 137 + @keyframes pulse { 138 + 0%, 139 + 100% { 140 + opacity: 1; 141 + } 142 + 50% { 143 + opacity: 0.3; 144 + } 145 + } 146 + 147 + @keyframes gentle-pulse { 148 + 0%, 149 + 100% { 150 + transform: scale(1); 151 + box-shadow: 0 0 0 0 var(--text-light); 152 + } 153 + 50% { 154 + transform: scale(1.02); 155 + box-shadow: 0 0 0 3px rgba(160, 160, 160, 0.2); 156 + } 157 + } 158 +
+245
src/view/filters.js
··· 1 + // ============================================================================ 2 + // FILTER PANEL 3 + // ============================================================================ 4 + 5 + import { state } from './state.js'; 6 + import { applyDomainRedirect } from './atproto.js'; 7 + 8 + function loadHiddenApps() { 9 + const params = new URLSearchParams(window.location.search); 10 + const hideParam = params.get('hide'); 11 + if (hideParam) { 12 + state.hiddenApps = new Set(hideParam.split(',').filter(Boolean)); 13 + return; 14 + } 15 + 16 + try { 17 + const stored = localStorage.getItem(`atme_hidden_apps_${state.did}`); 18 + if (stored) { 19 + state.hiddenApps = new Set(JSON.parse(stored)); 20 + } 21 + } catch (e) { 22 + state.hiddenApps = new Set(); 23 + } 24 + } 25 + 26 + function saveHiddenApps() { 27 + try { 28 + localStorage.setItem(`atme_hidden_apps_${state.did}`, JSON.stringify([...state.hiddenApps])); 29 + } catch (e) {} 30 + 31 + const params = new URLSearchParams(window.location.search); 32 + if (state.hiddenApps.size > 0) { 33 + params.set('hide', [...state.hiddenApps].join(',')); 34 + } else { 35 + params.delete('hide'); 36 + } 37 + const newUrl = params.toString() 38 + ? `${window.location.pathname}?${params.toString()}` 39 + : window.location.pathname; 40 + history.replaceState(null, '', newUrl); 41 + } 42 + 43 + function updateFilterButton() { 44 + const filterBtn = document.getElementById('filterBtn'); 45 + const filterCount = document.getElementById('filterCount'); 46 + 47 + if (state.hiddenApps.size > 0) { 48 + filterBtn.classList.add('has-filters'); 49 + filterCount.textContent = state.hiddenApps.size; 50 + filterCount.style.display = 'inline-block'; 51 + } else { 52 + filterBtn.classList.remove('has-filters'); 53 + filterCount.style.display = 'none'; 54 + } 55 + } 56 + 57 + export function applyFilters() { 58 + const appViews = document.querySelectorAll('.app-view'); 59 + appViews.forEach(view => { 60 + const circle = view.querySelector('.app-circle'); 61 + if (circle) { 62 + const namespace = circle.dataset.namespace; 63 + if (state.hiddenApps.has(namespace)) { 64 + view.classList.add('filtered'); 65 + } else { 66 + view.classList.remove('filtered'); 67 + } 68 + } 69 + }); 70 + updateFilterButton(); 71 + saveHiddenApps(); 72 + // Reposition visible apps in a circle 73 + repositionAppCircles(); 74 + } 75 + 76 + function populateFilterList() { 77 + if (!state.globalApps) return; 78 + 79 + const filterList = document.getElementById('filterList'); 80 + const appNames = Object.keys(state.globalApps).filter(k => k !== '_circleSize').sort(); 81 + 82 + filterList.innerHTML = appNames.map(namespace => { 83 + const rawDisplayName = namespace.split('.').reverse().join('.'); 84 + const displayName = applyDomainRedirect(rawDisplayName); 85 + const isChecked = !state.hiddenApps.has(namespace); 86 + const isInvalid = state.invalidApps.has(namespace); 87 + return ` 88 + <div class="filter-item ${isChecked ? 'checked' : ''}" data-namespace="${namespace}"> 89 + <div class="filter-checkbox"> 90 + <svg class="filter-checkbox-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor"> 91 + <polyline points="20 6 9 17 4 12"></polyline> 92 + </svg> 93 + </div> 94 + <span class="filter-label">${displayName}${isInvalid ? ' (unresolved)' : ''}</span> 95 + </div> 96 + `; 97 + }).join(''); 98 + 99 + filterList.querySelectorAll('.filter-item').forEach(item => { 100 + item.addEventListener('click', () => { 101 + const namespace = item.dataset.namespace; 102 + if (state.hiddenApps.has(namespace)) { 103 + state.hiddenApps.delete(namespace); 104 + item.classList.add('checked'); 105 + } else { 106 + state.hiddenApps.add(namespace); 107 + item.classList.remove('checked'); 108 + } 109 + applyFilters(); 110 + }); 111 + }); 112 + } 113 + 114 + export function initFilterPanel() { 115 + loadHiddenApps(); 116 + 117 + // Clean up stale hidden apps that no longer exist 118 + if (state.globalApps) { 119 + const validNamespaces = new Set(Object.keys(state.globalApps).filter(k => k !== '_circleSize')); 120 + state.hiddenApps = new Set([...state.hiddenApps].filter(ns => validNamespaces.has(ns))); 121 + } 122 + 123 + const filterBtn = document.getElementById('filterBtn'); 124 + const filterPanel = document.getElementById('filterPanel'); 125 + const filterShowAll = document.getElementById('filterShowAll'); 126 + const filterHideAll = document.getElementById('filterHideAll'); 127 + const filterHideUnresolved = document.getElementById('filterHideUnresolved'); 128 + 129 + filterBtn.addEventListener('click', (e) => { 130 + e.stopPropagation(); 131 + filterPanel.classList.toggle('visible'); 132 + filterBtn.classList.toggle('active'); 133 + if (filterPanel.classList.contains('visible')) { 134 + populateFilterList(); 135 + } 136 + }); 137 + 138 + document.addEventListener('click', (e) => { 139 + if (!filterPanel.contains(e.target) && e.target !== filterBtn && !filterBtn.contains(e.target)) { 140 + filterPanel.classList.remove('visible'); 141 + filterBtn.classList.remove('active'); 142 + } 143 + }); 144 + 145 + filterShowAll.addEventListener('click', (e) => { 146 + e.preventDefault(); 147 + e.stopPropagation(); 148 + state.hiddenApps.clear(); 149 + populateFilterList(); 150 + applyFilters(); 151 + }); 152 + 153 + filterHideAll.addEventListener('click', (e) => { 154 + e.preventDefault(); 155 + e.stopPropagation(); 156 + if (!state.globalApps) return; 157 + const appNames = Object.keys(state.globalApps).filter(k => k !== '_circleSize'); 158 + state.hiddenApps = new Set(appNames); 159 + populateFilterList(); 160 + applyFilters(); 161 + }); 162 + 163 + // "valid" button - hide unresolved/invalid apps, show valid ones 164 + filterHideUnresolved.addEventListener('click', (e) => { 165 + e.preventDefault(); 166 + e.stopPropagation(); 167 + if (!state.globalApps) return; 168 + // Hide only invalid apps, show all valid ones 169 + state.hiddenApps = new Set(state.invalidApps); 170 + populateFilterList(); 171 + applyFilters(); 172 + }); 173 + 174 + applyFilters(); 175 + } 176 + 177 + export function repositionAppCircles() { 178 + if (!state.globalApps) return; 179 + 180 + const allAppViews = document.querySelectorAll('.app-view'); 181 + // Get only visible (non-filtered) app views 182 + const visibleAppViews = Array.from(allAppViews).filter(view => !view.classList.contains('filtered')); 183 + const visibleCount = visibleAppViews.length; 184 + 185 + if (visibleCount === 0) return; 186 + 187 + const vmin = Math.min(window.innerWidth, window.innerHeight); 188 + const isMobile = window.innerWidth < 768; 189 + 190 + let circleSize, radius; 191 + if (isMobile) { 192 + if (visibleCount <= 5) { 193 + circleSize = Math.min(60, vmin * 0.08); 194 + radius = vmin * 0.38; 195 + } else if (visibleCount <= 10) { 196 + circleSize = Math.min(50, vmin * 0.07); 197 + radius = vmin * 0.4; 198 + } else if (visibleCount <= 20) { 199 + circleSize = Math.min(40, vmin * 0.055); 200 + radius = vmin * 0.42; 201 + } else { 202 + circleSize = Math.min(32, vmin * 0.045); 203 + radius = vmin * 0.44; 204 + } 205 + circleSize = Math.max(circleSize, 28); 206 + radius = Math.max(radius, 120); 207 + } else { 208 + if (visibleCount <= 5) { 209 + circleSize = Math.min(70, vmin * 0.1); 210 + } else if (visibleCount <= 10) { 211 + circleSize = Math.min(60, vmin * 0.09); 212 + } else if (visibleCount <= 20) { 213 + circleSize = Math.min(50, vmin * 0.07); 214 + } else { 215 + circleSize = Math.min(40, vmin * 0.06); 216 + } 217 + circleSize = Math.max(circleSize, 35); 218 + // Calculate radius to ensure minimum spacing between apps 219 + // Arc length between apps should be at least circleSize + gap 220 + const minGap = 30; 221 + const minRadiusForSpacing = (visibleCount * (circleSize + minGap)) / (2 * Math.PI); 222 + radius = Math.max(vmin * 0.35, minRadiusForSpacing, 150); 223 + } 224 + 225 + const centerX = window.innerWidth / 2; 226 + const centerY = window.innerHeight / 2; 227 + 228 + // Position only visible apps evenly around the circle 229 + visibleAppViews.forEach((div, i) => { 230 + const angle = (i / visibleCount) * 2 * Math.PI - Math.PI / 2; 231 + const circleOffset = circleSize / 2; 232 + const x = centerX + radius * Math.cos(angle) - circleOffset; 233 + const y = centerY + radius * Math.sin(angle) - circleOffset; 234 + 235 + div.style.left = `${x}px`; 236 + div.style.top = `${y}px`; 237 + 238 + const circle = div.querySelector('.app-circle'); 239 + if (circle) { 240 + circle.style.width = `${circleSize}px`; 241 + circle.style.height = `${circleSize}px`; 242 + circle.style.fontSize = `${circleSize * 0.4}px`; 243 + } 244 + }); 245 + }
+58
src/view/firehose.css
··· 1 + .firehose-toast { 2 + position: fixed; 3 + top: clamp(4rem, 8vmin, 5rem); 4 + right: clamp(1rem, 2vmin, 1.5rem); 5 + background: var(--surface); 6 + border: 1px solid var(--border); 7 + padding: 0.75rem 1rem; 8 + border-radius: 4px; 9 + font-size: 0.7rem; 10 + color: var(--text); 11 + z-index: 200; 12 + opacity: 0; 13 + transform: translateY(-10px); 14 + transition: all 0.3s ease; 15 + pointer-events: none; 16 + max-width: min(300px, calc(100vw - 2rem)); 17 + width: max-content; 18 + } 19 + 20 + @media (max-width: 768px) { 21 + .firehose-toast { 22 + top: clamp(7rem, 14vmin, 9rem); 23 + } 24 + } 25 + 26 + .firehose-toast.visible { 27 + opacity: 1; 28 + transform: translateY(0); 29 + pointer-events: auto; 30 + } 31 + 32 + .firehose-toast-action { 33 + font-weight: 600; 34 + color: var(--text); 35 + } 36 + 37 + .firehose-toast-collection { 38 + color: var(--text-light); 39 + font-size: 0.65rem; 40 + margin-top: 0.25rem; 41 + } 42 + 43 + .firehose-toast-link { 44 + display: inline-block; 45 + color: var(--text-light); 46 + font-size: 0.6rem; 47 + margin-top: 0.5rem; 48 + text-decoration: none; 49 + border-bottom: 1px solid transparent; 50 + transition: all 0.2s ease; 51 + pointer-events: auto; 52 + } 53 + 54 + .firehose-toast-link:hover { 55 + color: var(--text); 56 + border-bottom-color: var(--text); 57 + } 58 +
+131
src/view/firehose.js
··· 1 + // ============================================================================ 2 + // FIREHOSE (Jetstream WebSocket) 3 + // ============================================================================ 4 + 5 + import { state } from './state.js'; 6 + import { 7 + createFirehoseParticle, 8 + initFirehoseCanvas, 9 + animateFirehoseParticles, 10 + cleanupFirehoseCanvas 11 + } from './particles.js'; 12 + 13 + function connectFirehose() { 14 + if (!state.did || state.jetstreamWs) return; 15 + 16 + const watchBtn = document.getElementById('watchLiveBtn'); 17 + const watchLabel = watchBtn.querySelector('.watch-label'); 18 + 19 + // Connect to Jetstream filtering by this DID 20 + const wsUrl = `wss://jetstream2.us-east.bsky.network/subscribe?wantedDids=${encodeURIComponent(state.did)}`; 21 + state.jetstreamWs = new WebSocket(wsUrl); 22 + 23 + state.jetstreamWs.onopen = () => { 24 + watchLabel.textContent = 'watching...'; 25 + watchBtn.classList.add('active'); 26 + }; 27 + 28 + state.jetstreamWs.onmessage = (event) => { 29 + try { 30 + const data = JSON.parse(event.data); 31 + if (data.kind === 'commit' && data.commit) { 32 + const commit = data.commit; 33 + const collection = commit.collection; 34 + const operation = commit.operation; 35 + 36 + // Get namespace from collection 37 + const parts = collection.split('.'); 38 + const namespace = parts.length >= 2 ? `${parts[0]}.${parts[1]}` : collection; 39 + 40 + const eventData = { 41 + action: operation, 42 + collection: collection, 43 + namespace: namespace, 44 + rkey: commit.rkey 45 + }; 46 + 47 + // Create particle animation 48 + createFirehoseParticle(eventData); 49 + 50 + // Show toast notification 51 + showFirehoseToast(eventData); 52 + } 53 + } catch (e) { 54 + console.error('Error processing Jetstream message:', e); 55 + } 56 + }; 57 + 58 + state.jetstreamWs.onerror = (error) => { 59 + console.error('Jetstream error:', error); 60 + watchLabel.textContent = 'connection error'; 61 + }; 62 + 63 + state.jetstreamWs.onclose = () => { 64 + if (state.isWatchingLive) { 65 + watchLabel.textContent = 'reconnecting...'; 66 + setTimeout(() => { 67 + state.jetstreamWs = null; 68 + if (state.isWatchingLive) connectFirehose(); 69 + }, 3000); 70 + } 71 + }; 72 + } 73 + 74 + function disconnectFirehose() { 75 + if (state.jetstreamWs) { 76 + state.jetstreamWs.close(); 77 + state.jetstreamWs = null; 78 + } 79 + } 80 + 81 + function showFirehoseToast(event) { 82 + const toast = document.getElementById('firehoseToast'); 83 + const actionEl = toast.querySelector('.firehose-toast-action'); 84 + const collectionEl = toast.querySelector('.firehose-toast-collection'); 85 + const linkEl = document.getElementById('firehoseToastLink'); 86 + 87 + const actionText = { 88 + 'create': 'created', 89 + 'update': 'updated', 90 + 'delete': 'deleted' 91 + }[event.action] || event.action; 92 + 93 + actionEl.textContent = `${actionText} record`; 94 + collectionEl.innerHTML = `<code style="background: var(--bg); padding: 0.1rem 0.3rem; border-radius: 2px; font-size: 0.6rem;">${event.collection}</code>`; 95 + 96 + if (event.action === 'delete') { 97 + linkEl.style.display = 'none'; 98 + } else { 99 + linkEl.style.display = 'inline-block'; 100 + if (state.globalPds && event.rkey) { 101 + linkEl.href = `${state.globalPds}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(state.did)}&collection=${encodeURIComponent(event.collection)}&rkey=${encodeURIComponent(event.rkey)}`; 102 + } 103 + } 104 + 105 + toast.classList.add('visible'); 106 + setTimeout(() => { 107 + toast.classList.remove('visible'); 108 + }, 4000); 109 + } 110 + 111 + export function initFirehoseUI() { 112 + // Watch live button handler 113 + document.getElementById('watchLiveBtn').addEventListener('click', () => { 114 + const watchBtn = document.getElementById('watchLiveBtn'); 115 + const watchLabel = watchBtn.querySelector('.watch-label'); 116 + 117 + if (state.isWatchingLive) { 118 + state.isWatchingLive = false; 119 + watchLabel.textContent = 'watch live'; 120 + watchBtn.classList.remove('active'); 121 + disconnectFirehose(); 122 + cleanupFirehoseCanvas(); 123 + } else { 124 + state.isWatchingLive = true; 125 + watchLabel.textContent = 'connecting...'; 126 + initFirehoseCanvas(); 127 + animateFirehoseParticles(); 128 + connectFirehose(); 129 + } 130 + }); 131 + }
+71
src/view/guestbook-state.js
··· 1 + // ============================================================================ 2 + // GUESTBOOK STATE (via Microcosm UFOs API) 3 + // ============================================================================ 4 + 5 + import { state } from './state.js'; 6 + 7 + const UFOS_API = 'https://ufos-api.microcosm.blue'; 8 + export let allGuestbookSignatures = []; 9 + 10 + export async function fetchAllGuestbookSignatures() { 11 + try { 12 + const response = await fetch(`${UFOS_API}/records?collection=app.at-me.visit`); 13 + if (response.ok) { 14 + allGuestbookSignatures = await response.json(); 15 + } 16 + } catch (e) { 17 + console.error('Error fetching guestbook signatures from Microcosm:', e); 18 + } 19 + } 20 + 21 + export async function checkGuestbookState() { 22 + if (!state.did) return; 23 + 24 + // Fetch all signatures if we haven't yet 25 + if (allGuestbookSignatures.length === 0) { 26 + await fetchAllGuestbookSignatures(); 27 + } 28 + 29 + // Check if the viewed user has signed the guestbook 30 + state.pageOwnerHasSigned = allGuestbookSignatures.some(sig => sig.did === state.did); 31 + 32 + updateGuestbookUI(); 33 + } 34 + 35 + export function updateGuestbookUI() { 36 + const signEl = document.querySelector('.guestbook-sign'); 37 + const signBtn = document.getElementById('signGuestbookBtn'); 38 + const avatarImg = document.getElementById('guestbookAvatar'); 39 + const iconSpan = signBtn?.querySelector('.guestbook-icon'); 40 + const textSpan = signBtn?.querySelector('.guestbook-text'); 41 + 42 + if (signEl) { 43 + signEl.textContent = state.pageOwnerHasSigned ? 'you already signed' : 'sign the guest list'; 44 + } 45 + 46 + if (!signBtn || !iconSpan || !textSpan) return; 47 + 48 + signBtn.classList.remove('signed', 'pulse'); 49 + 50 + if (state.pageOwnerHasSigned) { 51 + if (avatarImg) avatarImg.style.display = 'none'; 52 + iconSpan.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>'; 53 + iconSpan.style.display = 'flex'; 54 + textSpan.textContent = 'signed'; 55 + signBtn.classList.add('signed'); 56 + signBtn.setAttribute('title', 'this user has signed the guestbook'); 57 + } else { 58 + // Show the viewed user's avatar 59 + if (state.viewedAvatar && avatarImg) { 60 + avatarImg.src = state.viewedAvatar; 61 + avatarImg.style.display = 'block'; 62 + iconSpan.style.display = 'none'; 63 + } else { 64 + if (avatarImg) avatarImg.style.display = 'none'; 65 + iconSpan.style.display = 'none'; 66 + } 67 + textSpan.textContent = 'sign as'; 68 + signBtn.classList.add('pulse'); 69 + signBtn.setAttribute('title', `sign in as @${state.globalHandle || 'user'}`); 70 + } 71 + }
+78
src/view/guestbook-ui.js
··· 1 + // ============================================================================ 2 + // GUESTBOOK (simplified - just viewing, no OAuth write for now) 3 + // ============================================================================ 4 + 5 + import { state } from './state.js'; 6 + import { escapeHtml } from './atproto.js'; 7 + import { allGuestbookSignatures, fetchAllGuestbookSignatures } from './guestbook-state.js'; 8 + 9 + export function initGuestbookUI() { 10 + document.getElementById('viewGuestbookBtn').addEventListener('click', async () => { 11 + const modal = document.getElementById('guestbookModal'); 12 + const content = document.getElementById('guestbookContent'); 13 + 14 + modal.classList.add('visible'); 15 + content.innerHTML = ` 16 + <div class="guestbook-loading"> 17 + <div class="guestbook-loading-spinner"></div> 18 + <div class="guestbook-loading-text">loading signatures...</div> 19 + </div> 20 + `; 21 + 22 + // Fetch all guestbook signatures from Microcosm UFOs API 23 + if (allGuestbookSignatures.length === 0) { 24 + await fetchAllGuestbookSignatures(); 25 + } 26 + 27 + if (allGuestbookSignatures.length > 0) { 28 + // Sort by time_us descending (most recent first) 29 + const sorted = [...allGuestbookSignatures].sort((a, b) => b.time_us - a.time_us); 30 + 31 + content.innerHTML = ` 32 + <div class="guestbook-paper"> 33 + <div class="guestbook-paper-title">guestbook</div> 34 + <div class="guestbook-paper-subtitle">visitors who have signed</div> 35 + <div class="guestbook-tally">${sorted.length} signature${sorted.length !== 1 ? 's' : ''}</div> 36 + <div class="guestbook-signatures-list"> 37 + ${sorted.map(sig => ` 38 + <div class="guestbook-paper-signature"> 39 + <a class="guestbook-did" href="?did=${encodeURIComponent(sig.did)}" title="view ${sig.did}">${sig.did}</a> 40 + ${sig.record?.text ? `<div class="guestbook-message">${escapeHtml(sig.record.text)}</div>` : ''} 41 + </div> 42 + `).join('')} 43 + </div> 44 + </div> 45 + `; 46 + } else { 47 + content.innerHTML = ` 48 + <div class="guestbook-paper"> 49 + <div class="guestbook-paper-title">guestbook</div> 50 + <div class="guestbook-paper-subtitle">no signatures yet</div> 51 + <div class="guestbook-tally">be the first to sign!</div> 52 + </div> 53 + `; 54 + } 55 + }); 56 + 57 + document.getElementById('guestbookClose').addEventListener('click', () => { 58 + document.getElementById('guestbookModal').classList.remove('visible'); 59 + }); 60 + 61 + // Escape key to close guestbook modal 62 + document.addEventListener('keydown', (e) => { 63 + if (e.key === 'Escape') { 64 + document.getElementById('guestbookModal').classList.remove('visible'); 65 + } 66 + }); 67 + 68 + // Sign guestbook button - for now just show a message about OAuth 69 + document.getElementById('signGuestbookBtn').addEventListener('click', () => { 70 + if (state.pageOwnerHasSigned) { 71 + // Already signed - show the guestbook 72 + document.getElementById('viewGuestbookBtn').click(); 73 + } else { 74 + // Not signed - would need OAuth 75 + alert(`OAuth signing coming soon! For now, visit your own PDS to create an app.at-me.visit record.`); 76 + } 77 + }); 78 + }
+405
src/view/guestbook.css
··· 1 + .guestbook-buttons-container { 2 + position: fixed; 3 + bottom: clamp(0.75rem, 2vmin, 1rem); 4 + right: clamp(0.75rem, 2vmin, 1rem); 5 + display: flex; 6 + flex-direction: row; 7 + align-items: center; 8 + gap: clamp(0.5rem, 1.5vmin, 0.75rem); 9 + z-index: 100; 10 + } 11 + 12 + .view-guestbook-btn { 13 + font-family: inherit; 14 + font-size: clamp(0.85rem, 1.8vmin, 1rem); 15 + color: var(--text-light); 16 + border: 1px solid var(--border); 17 + background: var(--bg); 18 + padding: clamp(0.4rem, 1vmin, 0.5rem) clamp(0.6rem, 1.5vmin, 0.8rem); 19 + transition: all 0.2s ease; 20 + cursor: pointer; 21 + border-radius: 2px; 22 + width: clamp(32px, 7vmin, 40px); 23 + height: clamp(32px, 7vmin, 40px); 24 + display: flex; 25 + align-items: center; 26 + justify-content: center; 27 + } 28 + 29 + .view-guestbook-btn:hover, 30 + .view-guestbook-btn:active { 31 + background: var(--surface); 32 + color: var(--text); 33 + border-color: var(--text-light); 34 + } 35 + 36 + .sign-guestbook-btn { 37 + font-family: inherit; 38 + font-size: clamp(0.65rem, 1.4vmin, 0.75rem); 39 + color: var(--text-light); 40 + border: 1px solid var(--border); 41 + background: var(--bg); 42 + padding: clamp(0.4rem, 1vmin, 0.5rem) clamp(0.6rem, 1.5vmin, 0.8rem); 43 + transition: all 0.2s ease; 44 + cursor: pointer; 45 + border-radius: 2px; 46 + display: flex; 47 + align-items: center; 48 + gap: clamp(0.3rem, 0.8vmin, 0.5rem); 49 + height: clamp(32px, 7vmin, 40px); 50 + white-space: nowrap; 51 + } 52 + 53 + .sign-guestbook-btn:hover, 54 + .sign-guestbook-btn:active { 55 + background: var(--surface); 56 + color: var(--text); 57 + border-color: var(--text-light); 58 + } 59 + 60 + .sign-guestbook-btn:disabled { 61 + opacity: 0.5; 62 + cursor: not-allowed; 63 + } 64 + 65 + .sign-guestbook-btn.signed { 66 + border-color: var(--text-light); 67 + background: var(--surface); 68 + } 69 + 70 + .sign-guestbook-btn.pulse { 71 + animation: gentle-pulse 2s ease-in-out infinite; 72 + } 73 + 74 + .guestbook-icon { 75 + display: flex; 76 + align-items: center; 77 + line-height: 1; 78 + } 79 + 80 + .guestbook-avatar { 81 + width: clamp(20px, 4.5vmin, 24px); 82 + height: clamp(20px, 4.5vmin, 24px); 83 + border-radius: 50%; 84 + object-fit: cover; 85 + border: 1px solid var(--border); 86 + flex-shrink: 0; 87 + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.15), 0 1px 2px rgba(0, 0, 0, 0.1); 88 + } 89 + 90 + @media (prefers-color-scheme: dark) { 91 + .guestbook-avatar { 92 + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3), 0 1px 2px rgba(0, 0, 0, 0.2); 93 + } 94 + } 95 + 96 + .guestbook-modal { 97 + position: fixed; 98 + inset: 0; 99 + background: var(--bg); 100 + z-index: 2000; 101 + display: none; 102 + overflow-y: auto; 103 + padding: clamp(4rem, 8vmin, 6rem) clamp(1rem, 3vmin, 2rem) clamp(2rem, 4vmin, 3rem); 104 + } 105 + 106 + .guestbook-modal.visible { 107 + display: block; 108 + } 109 + 110 + .guestbook-paper { 111 + max-width: 700px; 112 + margin: 0 auto; 113 + background: 114 + repeating-linear-gradient(0deg, 115 + transparent, 116 + transparent 31px, 117 + rgba(212, 197, 168, 0.15) 31px, 118 + rgba(212, 197, 168, 0.15) 32px), 119 + linear-gradient(to bottom, #fdfcf8 0%, #f9f7f1 100%); 120 + border: 1px solid #d4c5a8; 121 + box-shadow: 122 + 0 4px 6px rgba(0, 0, 0, 0.1), 123 + 0 2px 4px rgba(0, 0, 0, 0.06), 124 + inset 0 0 80px rgba(255, 248, 240, 0.6); 125 + padding: clamp(2.5rem, 6vmin, 4rem) clamp(2rem, 5vmin, 3rem); 126 + position: relative; 127 + } 128 + 129 + @media (prefers-color-scheme: dark) { 130 + .guestbook-paper { 131 + background: 132 + repeating-linear-gradient(0deg, 133 + transparent, 134 + transparent 31px, 135 + rgba(90, 80, 70, 0.2) 31px, 136 + rgba(90, 80, 70, 0.2) 32px), 137 + linear-gradient(to bottom, #2a2520 0%, #1f1b17 100%); 138 + border-color: #3a3530; 139 + box-shadow: 140 + 0 4px 6px rgba(0, 0, 0, 0.5), 141 + 0 2px 4px rgba(0, 0, 0, 0.3), 142 + inset 0 0 80px rgba(60, 50, 40, 0.4); 143 + } 144 + } 145 + 146 + .guestbook-paper::before { 147 + content: ''; 148 + position: absolute; 149 + top: 0; 150 + left: clamp(2rem, 5vmin, 3rem); 151 + width: 2px; 152 + height: 100%; 153 + background: linear-gradient(to bottom, 154 + transparent 0%, 155 + rgba(212, 100, 100, 0.2) 5%, 156 + rgba(212, 100, 100, 0.2) 95%, 157 + transparent 100%); 158 + } 159 + 160 + @media (prefers-color-scheme: dark) { 161 + .guestbook-paper::before { 162 + background: linear-gradient(to bottom, 163 + transparent 0%, 164 + rgba(180, 80, 80, 0.15) 5%, 165 + rgba(180, 80, 80, 0.15) 95%, 166 + transparent 100%); 167 + } 168 + } 169 + 170 + .guestbook-paper-title { 171 + font-family: ui-monospace, 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Menlo, 'Courier New', monospace; 172 + font-size: clamp(1.8rem, 4.5vmin, 2.5rem); 173 + color: #3a2f25; 174 + text-align: center; 175 + margin-bottom: clamp(0.5rem, 1.5vmin, 1rem); 176 + font-weight: 500; 177 + letter-spacing: 0.02em; 178 + } 179 + 180 + @media (prefers-color-scheme: dark) { 181 + .guestbook-paper-title { 182 + color: #d4c5a8; 183 + } 184 + } 185 + 186 + .guestbook-paper-subtitle { 187 + font-family: ui-monospace, 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Menlo, 'Courier New', monospace; 188 + font-size: clamp(0.75rem, 1.6vmin, 0.9rem); 189 + color: #6b5d4f; 190 + text-align: center; 191 + margin-bottom: clamp(2rem, 5vmin, 3rem); 192 + font-style: normal; 193 + } 194 + 195 + @media (prefers-color-scheme: dark) { 196 + .guestbook-paper-subtitle { 197 + color: #8a7a6a; 198 + } 199 + } 200 + 201 + .guestbook-tally { 202 + font-family: ui-monospace, 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Menlo, 'Courier New', monospace; 203 + text-align: center; 204 + font-size: clamp(0.7rem, 1.8vmin, 0.85rem); 205 + color: #6b5d4f; 206 + margin: clamp(1rem, 2.5vmin, 1.5rem) 0 0; 207 + font-weight: 500; 208 + letter-spacing: 0.03em; 209 + text-transform: lowercase; 210 + } 211 + 212 + @media (prefers-color-scheme: dark) { 213 + .guestbook-tally { 214 + color: #8a7a6a; 215 + } 216 + } 217 + 218 + .guestbook-signatures-list { 219 + margin-top: clamp(1.5rem, 4vmin, 2.5rem); 220 + } 221 + 222 + .guestbook-paper-signature { 223 + padding: clamp(1rem, 2.5vmin, 1.5rem) 0; 224 + border-bottom: 1px solid rgba(212, 197, 168, 0.3); 225 + position: relative; 226 + cursor: pointer; 227 + transition: all 0.3s ease; 228 + } 229 + 230 + .guestbook-paper-signature:last-child { 231 + border-bottom: none; 232 + } 233 + 234 + .guestbook-paper-signature:hover { 235 + background: rgba(255, 248, 240, 0.3); 236 + padding-left: 0.5rem; 237 + padding-right: 0.5rem; 238 + margin-left: -0.5rem; 239 + margin-right: -0.5rem; 240 + } 241 + 242 + @media (prefers-color-scheme: dark) { 243 + .guestbook-paper-signature { 244 + border-bottom-color: rgba(90, 80, 70, 0.3); 245 + } 246 + 247 + .guestbook-paper-signature:hover { 248 + background: rgba(60, 50, 40, 0.3); 249 + } 250 + } 251 + 252 + .guestbook-did { 253 + font-family: 'Brush Script MT', cursive, 'Georgia', serif; 254 + font-size: clamp(1.1rem, 2.5vmin, 1.4rem); 255 + color: #2a2520; 256 + margin-bottom: clamp(0.3rem, 0.8vmin, 0.5rem); 257 + font-weight: 400; 258 + letter-spacing: 0.02em; 259 + word-break: break-all; 260 + cursor: pointer; 261 + transition: all 0.2s ease; 262 + position: relative; 263 + } 264 + 265 + .guestbook-did:hover { 266 + color: #4a4238; 267 + transform: translateX(2px); 268 + } 269 + 270 + @media (prefers-color-scheme: dark) { 271 + .guestbook-did { 272 + color: #c9bfa8; 273 + } 274 + 275 + .guestbook-did:hover { 276 + color: #d4c5a8; 277 + } 278 + } 279 + 280 + .guestbook-message { 281 + font-size: 0.65rem; 282 + color: #6b6052; 283 + font-style: italic; 284 + margin-top: 0.2rem; 285 + padding-left: 0.5rem; 286 + border-left: 2px solid #d4c5a866; 287 + } 288 + 289 + @media (prefers-color-scheme: dark) { 290 + .guestbook-message { 291 + color: #a89f8c; 292 + border-left-color: #c9bfa866; 293 + } 294 + } 295 + 296 + .guestbook-close { 297 + position: fixed; 298 + top: clamp(1rem, 2vmin, 1.5rem); 299 + right: clamp(1rem, 2vmin, 1.5rem); 300 + width: clamp(40px, 8vmin, 48px); 301 + height: clamp(40px, 8vmin, 48px); 302 + border: 2px solid var(--border); 303 + background: var(--surface); 304 + color: var(--text-light); 305 + cursor: pointer; 306 + display: flex; 307 + align-items: center; 308 + justify-content: center; 309 + font-size: clamp(1.2rem, 3vmin, 1.5rem); 310 + line-height: 1; 311 + transition: all 0.2s ease; 312 + border-radius: 4px; 313 + z-index: 2001; 314 + } 315 + 316 + .guestbook-close:hover, 317 + .guestbook-close:active { 318 + background: var(--surface-hover); 319 + border-color: var(--text-light); 320 + color: var(--text); 321 + } 322 + 323 + .guestbook-loading { 324 + max-width: 800px; 325 + margin: 0 auto; 326 + text-align: center; 327 + padding: clamp(3rem, 8vmin, 5rem) clamp(1rem, 3vmin, 2rem); 328 + } 329 + 330 + .guestbook-loading-spinner { 331 + width: 40px; 332 + height: 40px; 333 + border: 3px solid var(--border); 334 + border-top-color: var(--text); 335 + border-radius: 50%; 336 + animation: spin 0.8s linear infinite; 337 + margin: 0 auto clamp(1rem, 3vmin, 1.5rem); 338 + } 339 + 340 + .guestbook-loading-text { 341 + font-size: clamp(0.75rem, 1.6vmin, 0.9rem); 342 + color: var(--text-light); 343 + } 344 + 345 + .guestbook-sign { 346 + position: fixed; 347 + bottom: clamp(3.5rem, 8.5vmin, 5.5rem); 348 + right: clamp(0.75rem, 2vmin, 1rem); 349 + font-family: ui-monospace, 'SF Mono', Monaco, monospace; 350 + font-size: clamp(0.6rem, 1.3vmin, 0.7rem); 351 + color: var(--text-light); 352 + text-transform: lowercase; 353 + letter-spacing: 0.1em; 354 + z-index: 50; 355 + opacity: 0.6; 356 + text-shadow: 0 0 4px currentColor; 357 + animation: neon-flicker 8s infinite; 358 + pointer-events: none; 359 + user-select: none; 360 + white-space: nowrap; 361 + } 362 + 363 + @media (prefers-color-scheme: dark) { 364 + .guestbook-sign { 365 + color: #ff6b9d; 366 + opacity: 0.5; 367 + text-shadow: 0 0 6px currentColor, 0 0 12px rgba(255, 107, 157, 0.3); 368 + } 369 + } 370 + 371 + .pov-indicator { 372 + position: fixed; 373 + left: 50%; 374 + top: clamp(1rem, 2vmin, 1.5rem); 375 + transform: translateX(-50%); 376 + font-family: ui-monospace, 'SF Mono', Monaco, monospace; 377 + font-size: clamp(0.65rem, 1.4vmin, 0.75rem); 378 + color: var(--text-light); 379 + text-transform: lowercase; 380 + letter-spacing: 0.12em; 381 + z-index: 50; 382 + opacity: 0.4; 383 + text-shadow: 0 0 3px currentColor; 384 + animation: pov-subtle-flicker 37s infinite; 385 + pointer-events: none; 386 + user-select: none; 387 + text-align: center; 388 + line-height: 1.4; 389 + } 390 + 391 + .pov-handle { 392 + display: inline; 393 + margin-left: 0.3rem; 394 + font-size: inherit; 395 + opacity: 0.9; 396 + pointer-events: auto; 397 + text-decoration: none; 398 + color: inherit; 399 + transition: opacity 0.2s ease; 400 + } 401 + 402 + .pov-handle:hover { 403 + opacity: 1; 404 + text-decoration: underline; 405 + }
+173
src/view/layout.css
··· 1 + .identity { 2 + position: absolute; 3 + left: 50%; 4 + top: 50%; 5 + transform: translate(-50%, -50%); 6 + background: var(--surface); 7 + border: 2px solid var(--text-light); 8 + border-radius: 50%; 9 + width: clamp(100px, 20vmin, 140px); 10 + height: clamp(100px, 20vmin, 140px); 11 + display: flex; 12 + flex-direction: column; 13 + align-items: center; 14 + justify-content: center; 15 + z-index: 10; 16 + cursor: pointer; 17 + transition: all 0.2s ease; 18 + -webkit-tap-highlight-color: transparent; 19 + } 20 + 21 + .identity:hover, 22 + .identity:active { 23 + transform: translate(-50%, -50%) scale(1.05); 24 + border-color: var(--text); 25 + box-shadow: 0 0 20px rgba(255, 255, 255, 0.1); 26 + } 27 + 28 + .identity.pulse { 29 + animation: identityPulse 0.3s ease-out; 30 + } 31 + 32 + @keyframes identityPulse { 33 + 0% { box-shadow: 0 0 0 0 rgba(139, 164, 184, 0.4); } 34 + 70% { box-shadow: 0 0 0 15px rgba(139, 164, 184, 0); } 35 + 100% { box-shadow: 0 0 0 0 rgba(139, 164, 184, 0); } 36 + } 37 + 38 + .identity-label { 39 + font-size: clamp(1rem, 2vmin, 1.2rem); 40 + color: var(--text); 41 + font-weight: 600; 42 + line-height: 1; 43 + } 44 + 45 + .identity-value { 46 + font-size: 0.7rem; 47 + color: var(--text-lighter); 48 + text-align: center; 49 + white-space: nowrap; 50 + font-weight: 400; 51 + line-height: 1.2; 52 + } 53 + 54 + .identity-value:hover { 55 + opacity: 0.7; 56 + } 57 + 58 + .identity-pds-label { 59 + position: absolute; 60 + bottom: clamp(-1.5rem, -3vmin, -2rem); 61 + font-size: clamp(0.55rem, 1.1vmin, 0.65rem); 62 + color: var(--text-light); 63 + letter-spacing: 0.05em; 64 + font-weight: 500; 65 + text-decoration: none; 66 + white-space: nowrap; 67 + transition: opacity 0.2s ease; 68 + } 69 + 70 + .identity-pds-label:hover { 71 + opacity: 0.7; 72 + } 73 + 74 + .identity-avatar { 75 + position: absolute; 76 + top: 0; 77 + left: 0; 78 + width: 100%; 79 + height: 100%; 80 + border-radius: 50%; 81 + object-fit: cover; 82 + } 83 + 84 + .identity-handle { 85 + position: absolute; 86 + bottom: -1.8rem; 87 + left: 50%; 88 + transform: translateX(-50%); 89 + font-size: clamp(0.7rem, 1.5vmin, 0.85rem); 90 + color: var(--text-light); 91 + white-space: nowrap; 92 + } 93 + 94 + .app-view { 95 + position: absolute; 96 + display: flex; 97 + flex-direction: column; 98 + align-items: center; 99 + gap: clamp(0.3rem, 1vmin, 0.5rem); 100 + cursor: pointer; 101 + transition: all 0.2s ease; 102 + opacity: 0.7; 103 + } 104 + 105 + .app-view:hover { 106 + opacity: 1; 107 + transform: scale(1.1); 108 + z-index: 100; 109 + } 110 + 111 + .app-circle { 112 + background: var(--surface-hover); 113 + border: 1px solid var(--border); 114 + border-radius: 50%; 115 + width: clamp(55px, 10vmin, 70px); 116 + height: clamp(55px, 10vmin, 70px); 117 + display: flex; 118 + align-items: center; 119 + justify-content: center; 120 + transition: all 0.2s ease; 121 + overflow: hidden; 122 + font-size: clamp(1rem, 2vmin, 1.5rem); 123 + } 124 + 125 + .app-logo { 126 + width: 100%; 127 + height: 100%; 128 + object-fit: cover; 129 + } 130 + 131 + .app-view:hover .app-circle { 132 + background: var(--surface); 133 + border-color: var(--text-light); 134 + } 135 + 136 + .app-name { 137 + font-size: clamp(0.55rem, 1.2vmin, 0.7rem); 138 + color: var(--text); 139 + text-align: center; 140 + max-width: clamp(70px, 15vmin, 120px); 141 + text-decoration: none; 142 + display: block; 143 + overflow: hidden; 144 + text-overflow: ellipsis; 145 + white-space: nowrap; 146 + } 147 + 148 + @media (max-width: 768px) { 149 + .app-name { 150 + font-size: clamp(0.5rem, 1vmin, 0.6rem); 151 + max-width: clamp(60px, 12vmin, 100px); 152 + } 153 + 154 + #field.many-apps .app-name { 155 + display: none; 156 + } 157 + } 158 + 159 + .app-name:hover { 160 + text-decoration: underline; 161 + color: var(--text); 162 + } 163 + 164 + .app-name.invalid-link { 165 + color: var(--text-light); 166 + opacity: 0.5; 167 + cursor: not-allowed; 168 + } 169 + 170 + .app-name.invalid-link:hover { 171 + text-decoration: none; 172 + color: var(--text-light); 173 + }
+133
src/view/main.js
··· 1 + // ============================================================================ 2 + // MAIN ENTRY POINT - View Page 3 + // ============================================================================ 4 + 5 + import './styles.css'; 6 + import { state, urlParams, paramDid, paramHandle } from './state.js'; 7 + import { 8 + resolveHandle, 9 + resolveDid, 10 + getPdsFromDidDoc, 11 + getHandleFromDidDoc, 12 + getProfile, 13 + describeRepo 14 + } from './atproto.js'; 15 + import { renderVisualization } from './visualization.js'; 16 + import { checkGuestbookState } from './guestbook-state.js'; 17 + import { initGuestbookUI } from './guestbook-ui.js'; 18 + import { initFirehoseUI } from './firehose.js'; 19 + 20 + // ============================================================================ 21 + // INITIALIZATION 22 + // ============================================================================ 23 + 24 + async function init() { 25 + const statusEl = document.getElementById('status'); 26 + 27 + try { 28 + // Get DID from URL params 29 + let did = paramDid; 30 + 31 + // If handle provided, resolve to DID 32 + if (!did && paramHandle) { 33 + statusEl.textContent = 'resolving handle...'; 34 + did = await resolveHandle(paramHandle); 35 + if (!did) { 36 + statusEl.textContent = 'could not resolve handle'; 37 + return; 38 + } 39 + } 40 + 41 + if (!did) { 42 + statusEl.textContent = 'no identity specified'; 43 + return; 44 + } 45 + 46 + // Store DID in state 47 + state.did = did; 48 + 49 + statusEl.textContent = 'resolving identity...'; 50 + 51 + // Resolve DID document 52 + const didDoc = await resolveDid(did); 53 + if (!didDoc) { 54 + statusEl.textContent = 'could not resolve DID'; 55 + return; 56 + } 57 + 58 + // Get PDS endpoint 59 + const pds = getPdsFromDidDoc(didDoc); 60 + if (!pds) { 61 + statusEl.textContent = 'could not find PDS'; 62 + return; 63 + } 64 + 65 + // Get handle from DID doc 66 + const handle = getHandleFromDidDoc(didDoc); 67 + state.globalPds = pds; 68 + state.globalHandle = handle || did; 69 + 70 + // Update identity display 71 + const handleEl = document.getElementById('handleDisplay'); 72 + if (handleEl) { 73 + handleEl.textContent = `@${state.globalHandle}`; 74 + } 75 + 76 + statusEl.textContent = 'loading profile...'; 77 + 78 + // Get profile for avatar 79 + const profile = await getProfile(did); 80 + if (profile?.avatar) { 81 + state.viewedAvatar = profile.avatar; 82 + const avatarEl = document.getElementById('identityAvatar'); 83 + if (avatarEl) { 84 + avatarEl.src = profile.avatar; 85 + avatarEl.style.display = 'block'; 86 + } 87 + } 88 + 89 + statusEl.textContent = 'discovering apps...'; 90 + 91 + // Describe repo to get collections 92 + const repoInfo = await describeRepo(pds, did); 93 + if (!repoInfo?.collections) { 94 + statusEl.textContent = 'could not load repository'; 95 + return; 96 + } 97 + 98 + // Group collections by namespace (first two parts) 99 + const apps = {}; 100 + repoInfo.collections.forEach(collection => { 101 + const parts = collection.split('.'); 102 + const namespace = parts.length >= 2 ? `${parts[0]}.${parts[1]}` : collection; 103 + if (!apps[namespace]) apps[namespace] = []; 104 + apps[namespace].push(collection); 105 + }); 106 + 107 + state.globalApps = apps; 108 + 109 + // Hide status 110 + statusEl.style.display = 'none'; 111 + 112 + // Render visualization 113 + renderVisualization(apps, profile); 114 + 115 + // Initialize UI components 116 + initGuestbookUI(); 117 + initFirehoseUI(); 118 + 119 + // Check guestbook state 120 + await checkGuestbookState(); 121 + 122 + } catch (error) { 123 + console.error('Initialization error:', error); 124 + statusEl.textContent = 'an error occurred'; 125 + } 126 + } 127 + 128 + // Start the app when DOM is ready 129 + if (document.readyState === 'loading') { 130 + document.addEventListener('DOMContentLoaded', init); 131 + } else { 132 + init(); 133 + }
+150
src/view/mst.css
··· 1 + .mst-canvas { 2 + width: 100%; 3 + height: 600px; 4 + border: 1px solid var(--border); 5 + border-radius: 4px; 6 + background: var(--bg); 7 + margin-top: 0.5rem; 8 + } 9 + 10 + .mst-info { 11 + background: var(--bg); 12 + border: 1px solid var(--border); 13 + padding: 0.75rem; 14 + border-radius: 4px; 15 + margin-bottom: 0.75rem; 16 + } 17 + 18 + .mst-info p { 19 + font-size: 0.65rem; 20 + color: var(--text-lighter); 21 + line-height: 1.5; 22 + margin: 0; 23 + } 24 + 25 + .mst-node-modal { 26 + position: fixed; 27 + inset: 0; 28 + background: rgba(0, 0, 0, 0.75); 29 + display: flex; 30 + align-items: center; 31 + justify-content: center; 32 + z-index: 3000; 33 + padding: 1rem; 34 + } 35 + 36 + .mst-node-modal-content { 37 + background: var(--surface); 38 + border: 2px solid var(--border); 39 + padding: 2rem; 40 + border-radius: 4px; 41 + max-width: 600px; 42 + width: 100%; 43 + max-height: 80vh; 44 + overflow-y: auto; 45 + position: relative; 46 + } 47 + 48 + .mst-node-close { 49 + position: absolute; 50 + top: 1rem; 51 + right: 1rem; 52 + width: 32px; 53 + height: 32px; 54 + border: 1px solid var(--border); 55 + background: var(--bg); 56 + color: var(--text-light); 57 + cursor: pointer; 58 + display: flex; 59 + align-items: center; 60 + justify-content: center; 61 + font-size: 1.2rem; 62 + line-height: 1; 63 + transition: all 0.2s ease; 64 + border-radius: 2px; 65 + } 66 + 67 + .mst-node-close:hover { 68 + background: var(--surface-hover); 69 + border-color: var(--text-light); 70 + color: var(--text); 71 + } 72 + 73 + .mst-node-modal-content h3 { 74 + margin-bottom: 1rem; 75 + font-size: 0.9rem; 76 + color: var(--text); 77 + } 78 + 79 + .mst-node-info { 80 + background: var(--bg); 81 + border: 1px solid var(--border); 82 + padding: 0.75rem; 83 + border-radius: 4px; 84 + margin-bottom: 1rem; 85 + } 86 + 87 + .mst-node-field { 88 + display: flex; 89 + gap: 0.5rem; 90 + margin-bottom: 0.5rem; 91 + font-size: 0.65rem; 92 + } 93 + 94 + .mst-node-field:last-child { 95 + margin-bottom: 0; 96 + } 97 + 98 + .mst-node-label { 99 + color: var(--text-light); 100 + font-weight: 500; 101 + min-width: 40px; 102 + } 103 + 104 + .mst-node-value { 105 + color: var(--text); 106 + word-break: break-all; 107 + font-family: monospace; 108 + } 109 + 110 + .mst-node-explanation { 111 + background: var(--bg); 112 + border: 1px solid var(--border); 113 + padding: 0.75rem; 114 + border-radius: 4px; 115 + margin-bottom: 1rem; 116 + } 117 + 118 + .mst-node-explanation p { 119 + font-size: 0.65rem; 120 + color: var(--text-lighter); 121 + line-height: 1.5; 122 + margin: 0; 123 + } 124 + 125 + .mst-node-data { 126 + background: var(--bg); 127 + border: 1px solid var(--border); 128 + border-radius: 4px; 129 + overflow: hidden; 130 + } 131 + 132 + .mst-node-data-header { 133 + font-size: 0.65rem; 134 + color: var(--text-light); 135 + padding: 0.5rem 0.75rem; 136 + border-bottom: 1px solid var(--border); 137 + font-weight: 500; 138 + } 139 + 140 + .mst-node-data pre { 141 + margin: 0; 142 + padding: 0.75rem; 143 + font-size: 0.625rem; 144 + color: var(--text); 145 + white-space: pre-wrap; 146 + word-break: break-word; 147 + line-height: 1.5; 148 + max-height: 300px; 149 + overflow-y: auto; 150 + }
+353
src/view/mst.js
··· 1 + // ============================================================================ 2 + // MST (Merkle Search Tree) VISUALIZATION 3 + // ============================================================================ 4 + 5 + import { state } from './state.js'; 6 + import { listRecords, escapeHtml } from './atproto.js'; 7 + 8 + const MST_MAX_DEPTH = 5; 9 + const MST_FETCH_LIMIT = 100; 10 + 11 + function calculateKeyDepth(key) { 12 + // Hash the key using a simple hash function 13 + let hash = 0; 14 + for (let i = 0; i < key.length; i++) { 15 + hash = (hash << 5) - hash + key.charCodeAt(i); 16 + hash = hash & hash; // Convert to 32bit integer 17 + } 18 + 19 + // Count leading zero pairs in binary representation 20 + const absHash = Math.abs(hash) >>> 0; 21 + const binary = absHash.toString(2).padStart(32, '0'); 22 + 23 + let depth = 0; 24 + for (let i = 0; i < binary.length - 1; i += 2) { 25 + if (binary[i] === '0' && binary[i + 1] === '0') { 26 + depth++; 27 + } else { 28 + break; 29 + } 30 + } 31 + 32 + return Math.min(depth, MST_MAX_DEPTH); 33 + } 34 + 35 + function buildMST(records) { 36 + const recordCount = records.length; 37 + 38 + // Extract and sort by key (rkey from URI) 39 + let nodes = records.map(r => { 40 + const key = r.uri.split('/').pop() || ''; 41 + return { 42 + key: key, 43 + cid: r.cid, 44 + uri: r.uri, 45 + value: r.value, 46 + depth: calculateKeyDepth(key), 47 + children: [] 48 + }; 49 + }); 50 + 51 + nodes.sort((a, b) => a.key.localeCompare(b.key)); 52 + 53 + // Build tree structure 54 + const root = buildTree(nodes); 55 + 56 + return { root, recordCount }; 57 + } 58 + 59 + function buildTree(nodes) { 60 + if (nodes.length === 0) { 61 + return { 62 + key: 'root', 63 + cid: null, 64 + uri: null, 65 + value: null, 66 + depth: -1, 67 + children: [] 68 + }; 69 + } 70 + 71 + // Group by depth 72 + const byDepth = {}; 73 + for (const node of nodes) { 74 + if (!byDepth[node.depth]) byDepth[node.depth] = []; 75 + byDepth[node.depth].push(node); 76 + } 77 + 78 + const depths = Object.keys(byDepth).map(Number).sort((a, b) => a - b); 79 + 80 + // Build tree bottom-up 81 + let currentLevel = byDepth[depths[depths.length - 1]] || []; 82 + 83 + // Work backwards through depths 84 + for (let i = depths.length - 2; i >= 0; i--) { 85 + const depth = depths[i]; 86 + const parentNodes = byDepth[depth] || []; 87 + 88 + if (parentNodes.length === 0) continue; 89 + 90 + // Distribute children to parents 91 + const childrenPerParent = Math.ceil(currentLevel.length / parentNodes.length); 92 + 93 + for (let j = 0; j < parentNodes.length; j++) { 94 + const start = j * childrenPerParent; 95 + const end = Math.min((j + 1) * childrenPerParent, currentLevel.length); 96 + if (start < currentLevel.length) { 97 + parentNodes[j].children = currentLevel.slice(start, end); 98 + } 99 + } 100 + 101 + currentLevel = parentNodes; 102 + } 103 + 104 + // Create root and attach top-level nodes 105 + return { 106 + key: 'root', 107 + cid: null, 108 + uri: null, 109 + value: null, 110 + depth: -1, 111 + children: currentLevel 112 + }; 113 + } 114 + 115 + export async function loadMSTStructure(lexicon, containerView) { 116 + try { 117 + // Fetch records for MST building 118 + const data = await listRecords(state.globalPds, state.did, lexicon, MST_FETCH_LIMIT); 119 + 120 + if (!data?.records?.length) { 121 + containerView.innerHTML = '<div class="mst-info"><p>no records to visualize</p></div>'; 122 + return; 123 + } 124 + 125 + const { root, recordCount } = buildMST(data.records); 126 + 127 + containerView.innerHTML = ` 128 + <div class="mst-info"> 129 + <p>this shows the <a href="https://atproto.com/specs/repository#mst-structure" target="_blank" rel="noopener noreferrer" style="color: var(--text); text-decoration: underline;">Merkle Search Tree (MST)</a> structure used to store your ${recordCount} record${recordCount !== 1 ? 's' : ''} in your repository. records are organized by their <a href="https://atproto.com/specs/record-key#record-key-type-tid" target="_blank" rel="noopener noreferrer" style="color: var(--text); text-decoration: underline;">TIDs</a> (timestamp identifiers), which determines how they're arranged in the tree.</p> 130 + </div> 131 + <canvas class="mst-canvas" id="mstCanvas-${Date.now()}"></canvas> 132 + `; 133 + 134 + setTimeout(() => { 135 + const canvas = containerView.querySelector('.mst-canvas'); 136 + if (canvas) { 137 + renderMSTTree(canvas, root); 138 + } 139 + }, 50); 140 + 141 + } catch (e) { 142 + console.error('Error loading MST structure:', e); 143 + containerView.innerHTML = '<div class="mst-info"><p>error loading structure</p></div>'; 144 + } 145 + } 146 + 147 + function renderMSTTree(canvas, tree) { 148 + const ctx = canvas.getContext('2d'); 149 + const width = canvas.width = canvas.offsetWidth; 150 + const height = canvas.height = canvas.offsetHeight; 151 + 152 + const layout = layoutTree(tree, width, height); 153 + 154 + const borderColor = getComputedStyle(document.documentElement).getPropertyValue('--border').trim(); 155 + const textColor = getComputedStyle(document.documentElement).getPropertyValue('--text').trim(); 156 + const surfaceColor = getComputedStyle(document.documentElement).getPropertyValue('--surface').trim(); 157 + const surfaceHoverColor = getComputedStyle(document.documentElement).getPropertyValue('--surface-hover').trim(); 158 + const bgColor = getComputedStyle(document.documentElement).getPropertyValue('--bg').trim(); 159 + 160 + let hoveredNode = null; 161 + 162 + function draw() { 163 + ctx.clearRect(0, 0, width, height); 164 + 165 + // Draw connections first 166 + layout.forEach(node => { 167 + if (node.children) { 168 + node.children.forEach(child => { 169 + ctx.beginPath(); 170 + ctx.moveTo(node.x, node.y); 171 + ctx.lineTo(child.x, child.y); 172 + ctx.strokeStyle = borderColor; 173 + ctx.lineWidth = 1; 174 + ctx.stroke(); 175 + }); 176 + } 177 + }); 178 + 179 + // Draw nodes 180 + layout.forEach(node => { 181 + const isRoot = node.depth === -1; 182 + const isLeaf = !node.children || node.children.length === 0; 183 + const isHovered = hoveredNode === node; 184 + 185 + ctx.beginPath(); 186 + ctx.arc(node.x, node.y, isRoot ? 12 : 8, 0, Math.PI * 2); 187 + 188 + ctx.fillStyle = isRoot ? textColor : isLeaf ? surfaceHoverColor : surfaceColor; 189 + ctx.fill(); 190 + 191 + ctx.strokeStyle = isHovered ? textColor : borderColor; 192 + ctx.lineWidth = isRoot ? 2 : isHovered ? 2 : 1; 193 + ctx.stroke(); 194 + }); 195 + 196 + // Draw label for hovered node 197 + if (hoveredNode && hoveredNode.key && hoveredNode.key !== 'root') { 198 + const padding = 6; 199 + const fontSize = 10; 200 + ctx.font = `${fontSize}px monospace`; 201 + const textWidth = ctx.measureText(hoveredNode.key).width; 202 + 203 + const tooltipX = hoveredNode.x; 204 + const tooltipY = hoveredNode.y - 20; 205 + const boxWidth = textWidth + padding * 2; 206 + const boxHeight = fontSize + padding * 2; 207 + 208 + ctx.fillStyle = bgColor; 209 + ctx.fillRect(tooltipX - boxWidth / 2, tooltipY - boxHeight / 2, boxWidth, boxHeight); 210 + 211 + ctx.strokeStyle = borderColor; 212 + ctx.lineWidth = 1; 213 + ctx.strokeRect(tooltipX - boxWidth / 2, tooltipY - boxHeight / 2, boxWidth, boxHeight); 214 + 215 + ctx.fillStyle = textColor; 216 + ctx.textAlign = 'center'; 217 + ctx.textBaseline = 'middle'; 218 + ctx.fillText(hoveredNode.key, tooltipX, tooltipY); 219 + } 220 + } 221 + 222 + canvas.addEventListener('mousemove', (e) => { 223 + const rect = canvas.getBoundingClientRect(); 224 + const mouseX = e.clientX - rect.left; 225 + const mouseY = e.clientY - rect.top; 226 + 227 + let foundNode = null; 228 + for (const node of layout) { 229 + const isRoot = node.depth === -1; 230 + const radius = isRoot ? 12 : 8; 231 + const dist = Math.sqrt((mouseX - node.x) ** 2 + (mouseY - node.y) ** 2); 232 + if (dist <= radius) { 233 + foundNode = node; 234 + break; 235 + } 236 + } 237 + 238 + if (foundNode !== hoveredNode) { 239 + hoveredNode = foundNode; 240 + canvas.style.cursor = hoveredNode ? 'pointer' : 'default'; 241 + draw(); 242 + } 243 + }); 244 + 245 + canvas.addEventListener('mouseleave', () => { 246 + if (hoveredNode) { 247 + hoveredNode = null; 248 + canvas.style.cursor = 'default'; 249 + draw(); 250 + } 251 + }); 252 + 253 + canvas.addEventListener('click', (e) => { 254 + if (hoveredNode && hoveredNode.key && hoveredNode.key !== 'root') { 255 + showNodeModal(hoveredNode); 256 + } 257 + }); 258 + 259 + draw(); 260 + } 261 + 262 + function layoutTree(tree, width, height) { 263 + const nodes = []; 264 + const padding = 40; 265 + const availableHeight = height - padding * 2; 266 + 267 + // Calculate max depth 268 + const depthCounts = {}; 269 + function countDepths(node, depth) { 270 + if (!depthCounts[depth]) depthCounts[depth] = 0; 271 + depthCounts[depth]++; 272 + if (node.children) { 273 + node.children.forEach(child => countDepths(child, depth + 1)); 274 + } 275 + } 276 + countDepths(tree, 0); 277 + 278 + const maxDepth = Math.max(...Object.keys(depthCounts).map(Number)); 279 + const verticalSpacing = availableHeight / (maxDepth + 1); 280 + 281 + function traverse(node, depth, minX, maxX) { 282 + const x = (minX + maxX) / 2; 283 + const y = padding + verticalSpacing * depth; 284 + 285 + const layoutNode = { ...node, x, y }; 286 + nodes.push(layoutNode); 287 + 288 + if (node.children && node.children.length > 0) { 289 + layoutNode.children = []; 290 + const childWidth = (maxX - minX) / node.children.length; 291 + 292 + node.children.forEach((child, idx) => { 293 + const childMinX = minX + childWidth * idx; 294 + const childMaxX = minX + childWidth * (idx + 1); 295 + const childLayout = traverse(child, depth + 1, childMinX, childMaxX); 296 + layoutNode.children.push(childLayout); 297 + }); 298 + } 299 + 300 + return layoutNode; 301 + } 302 + 303 + traverse(tree, 0, padding, width - padding); 304 + return nodes; 305 + } 306 + 307 + function showNodeModal(node) { 308 + const modal = document.createElement('div'); 309 + modal.className = 'mst-node-modal'; 310 + modal.innerHTML = ` 311 + <div class="mst-node-modal-content"> 312 + <button class="mst-node-close">x</button> 313 + <h3>record in MST</h3> 314 + <div class="mst-node-info"> 315 + <div class="mst-node-field"> 316 + <span class="mst-node-label">TID:</span> 317 + <span class="mst-node-value">${node.key}</span> 318 + </div> 319 + <div class="mst-node-field"> 320 + <span class="mst-node-label">CID:</span> 321 + <span class="mst-node-value">${node.cid || 'N/A'}</span> 322 + </div> 323 + ${node.uri ? ` 324 + <div class="mst-node-field"> 325 + <span class="mst-node-label">URI:</span> 326 + <span class="mst-node-value">${node.uri}</span> 327 + </div> 328 + ` : ''} 329 + </div> 330 + <div class="mst-node-explanation"> 331 + <p>this is a leaf node in your Merkle Search Tree. the TID (timestamp identifier) determines its position in the tree. records are sorted by TID, making range queries efficient.</p> 332 + </div> 333 + ${node.value ? ` 334 + <div class="mst-node-data"> 335 + <div class="mst-node-data-header">record data</div> 336 + <pre>${escapeHtml(JSON.stringify(node.value, null, 2))}</pre> 337 + </div> 338 + ` : ''} 339 + </div> 340 + `; 341 + 342 + document.body.appendChild(modal); 343 + 344 + modal.querySelector('.mst-node-close').addEventListener('click', () => { 345 + modal.remove(); 346 + }); 347 + 348 + modal.addEventListener('click', (e) => { 349 + if (e.target === modal) { 350 + modal.remove(); 351 + } 352 + }); 353 + }
+156
src/view/particles.js
··· 1 + // ============================================================================ 2 + // FIREHOSE PARTICLE ANIMATION 3 + // ============================================================================ 4 + 5 + import { state } from './state.js'; 6 + 7 + export class FirehoseParticle { 8 + constructor(startX, startY, endX, endY, color, metadata) { 9 + this.x = startX; 10 + this.y = startY; 11 + this.startX = startX; 12 + this.startY = startY; 13 + this.endX = endX; 14 + this.endY = endY; 15 + this.color = color; 16 + this.metadata = metadata; 17 + this.progress = 0; 18 + this.speed = 0.008; 19 + this.size = 6; 20 + this.glowSize = 14; 21 + } 22 + 23 + update() { 24 + if (this.progress < 1) { 25 + this.progress += this.speed; 26 + const eased = 1 - Math.pow(1 - this.progress, 3); 27 + this.x = this.startX + (this.endX - this.startX) * eased; 28 + this.y = this.startY + (this.endY - this.startY) * eased; 29 + } 30 + return this.progress < 1; 31 + } 32 + 33 + draw(ctx) { 34 + const fadeIn = Math.min(this.progress * 4, 1); 35 + const fadeOut = this.progress > 0.8 ? 1 - ((this.progress - 0.8) / 0.2) : 1; 36 + const opacity = Math.min(fadeIn, fadeOut); 37 + 38 + ctx.beginPath(); 39 + ctx.arc(this.x, this.y, this.glowSize, 0, Math.PI * 2); 40 + const gradient = ctx.createRadialGradient( 41 + this.x, this.y, 0, 42 + this.x, this.y, this.glowSize 43 + ); 44 + gradient.addColorStop(0, this.color + Math.floor(opacity * 60).toString(16).padStart(2, '0')); 45 + gradient.addColorStop(0.5, this.color + Math.floor(opacity * 30).toString(16).padStart(2, '0')); 46 + gradient.addColorStop(1, this.color + '00'); 47 + ctx.fillStyle = gradient; 48 + ctx.fill(); 49 + 50 + ctx.beginPath(); 51 + ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2); 52 + ctx.fillStyle = this.color + Math.floor(opacity * 180).toString(16).padStart(2, '0'); 53 + ctx.fill(); 54 + } 55 + } 56 + 57 + export function initFirehoseCanvas() { 58 + if (state.firehoseCanvas) return; 59 + 60 + state.firehoseCanvas = document.createElement('canvas'); 61 + state.firehoseCanvas.id = 'firehoseCanvas'; 62 + state.firehoseCanvas.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:50;'; 63 + state.firehoseCanvas.width = window.innerWidth; 64 + state.firehoseCanvas.height = window.innerHeight; 65 + document.body.appendChild(state.firehoseCanvas); 66 + state.firehoseCtx = state.firehoseCanvas.getContext('2d'); 67 + 68 + window.addEventListener('resize', () => { 69 + if (state.firehoseCanvas) { 70 + state.firehoseCanvas.width = window.innerWidth; 71 + state.firehoseCanvas.height = window.innerHeight; 72 + } 73 + }); 74 + } 75 + 76 + function getParticleColor() { 77 + const textColor = getComputedStyle(document.documentElement).getPropertyValue('--text').trim(); 78 + if (textColor.startsWith('rgb')) { 79 + const match = textColor.match(/(\d+),\s*(\d+),\s*(\d+)/); 80 + if (match) { 81 + const r = parseInt(match[1]); 82 + const g = parseInt(match[2]); 83 + const b = parseInt(match[3]); 84 + return '#' + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1); 85 + } 86 + } 87 + return '#8ba4b8'; 88 + } 89 + 90 + export function createFirehoseParticle(event) { 91 + const identity = document.querySelector('.identity'); 92 + if (!identity) return; 93 + 94 + const identityRect = identity.getBoundingClientRect(); 95 + const endX = identityRect.left + identityRect.width / 2; 96 + const endY = identityRect.top + identityRect.height / 2; 97 + 98 + // Find the app circle for this event 99 + const appCircle = document.querySelector(`[data-namespace="${event.namespace}"]`); 100 + 101 + let startX, startY; 102 + if (appCircle) { 103 + const appRect = appCircle.getBoundingClientRect(); 104 + startX = appRect.left + appRect.width / 2; 105 + startY = appRect.top + appRect.height / 2; 106 + } else { 107 + startX = endX; 108 + startY = endY; 109 + } 110 + 111 + const particle = new FirehoseParticle( 112 + startX, startY, 113 + endX, endY, 114 + getParticleColor(), 115 + { action: event.action, collection: event.collection, namespace: event.namespace } 116 + ); 117 + state.firehoseParticles.push(particle); 118 + } 119 + 120 + function pulseIdentity() { 121 + const identity = document.querySelector('.identity'); 122 + if (!identity) return; 123 + identity.classList.add('pulse'); 124 + setTimeout(() => identity.classList.remove('pulse'), 300); 125 + } 126 + 127 + export function animateFirehoseParticles() { 128 + if (!state.firehoseCtx) return; 129 + 130 + state.firehoseCtx.clearRect(0, 0, state.firehoseCanvas.width, state.firehoseCanvas.height); 131 + 132 + state.firehoseParticles = state.firehoseParticles.filter(particle => { 133 + const alive = particle.update(); 134 + if (alive) { 135 + particle.draw(state.firehoseCtx); 136 + } else { 137 + pulseIdentity(); 138 + } 139 + return alive; 140 + }); 141 + 142 + state.firehoseAnimationId = requestAnimationFrame(animateFirehoseParticles); 143 + } 144 + 145 + export function cleanupFirehoseCanvas() { 146 + if (state.firehoseAnimationId) { 147 + cancelAnimationFrame(state.firehoseAnimationId); 148 + state.firehoseAnimationId = null; 149 + } 150 + state.firehoseParticles = []; 151 + if (state.firehoseCanvas) { 152 + state.firehoseCanvas.remove(); 153 + state.firehoseCanvas = null; 154 + state.firehoseCtx = null; 155 + } 156 + }
+27
src/view/state.js
··· 1 + // Shared state for the view page 2 + 3 + export const state = { 4 + did: null, 5 + globalPds: null, 6 + globalHandle: null, 7 + globalApps: null, 8 + hiddenApps: new Set(), 9 + invalidApps: new Set(), 10 + pageOwnerHasSigned: false, 11 + viewedAvatar: null, 12 + 13 + // Firehose animation state 14 + firehoseParticles: [], 15 + firehoseCanvas: null, 16 + firehoseCtx: null, 17 + firehoseAnimationId: null, 18 + 19 + // WebSocket state 20 + jetstreamWs: null, 21 + isWatchingLive: false 22 + }; 23 + 24 + // URL params 25 + export const urlParams = new URLSearchParams(window.location.search); 26 + export const paramDid = urlParams.get('did'); 27 + export const paramHandle = urlParams.get('handle');
+9
src/view/styles.css
··· 1 + /* View page styles */ 2 + @import './base.css'; 3 + @import './layout.css'; 4 + @import './detail.css'; 5 + @import './controls.css'; 6 + @import './filter.css'; 7 + @import './firehose.css'; 8 + @import './guestbook.css'; 9 + @import './mst.css';
+410
src/view/visualization.js
··· 1 + // ============================================================================ 2 + // VISUALIZATION 3 + // ============================================================================ 4 + 5 + import { state } from './state.js'; 6 + import { 7 + applyDomainRedirect, 8 + escapeHtml, 9 + fetchAppAvatars, 10 + validateAppUrls, 11 + listRecords 12 + } from './atproto.js'; 13 + import { initFilterPanel, repositionAppCircles } from './filters.js'; 14 + import { loadMSTStructure } from './mst.js'; 15 + 16 + export function renderVisualization(apps, profile) { 17 + const field = document.getElementById('field'); 18 + field.innerHTML = ''; 19 + field.classList.remove('loading'); 20 + 21 + const appNames = Object.keys(apps).sort(); 22 + const appCount = appNames.length; 23 + const allCollections = Object.values(apps).flat(); 24 + 25 + // Hide labels on mobile when there are too many apps 26 + const isMobileView = window.innerWidth < 768; 27 + if (isMobileView && appCount > 20) { 28 + field.classList.add('many-apps'); 29 + } 30 + 31 + // Calculate dimensions 32 + const vmin = Math.min(window.innerWidth, window.innerHeight); 33 + const isMobile = window.innerWidth < 768; 34 + 35 + let circleSize, radius; 36 + if (isMobile) { 37 + if (appCount <= 5) { 38 + circleSize = Math.min(60, vmin * 0.08); 39 + radius = vmin * 0.38; 40 + } else if (appCount <= 10) { 41 + circleSize = Math.min(50, vmin * 0.07); 42 + radius = vmin * 0.4; 43 + } else if (appCount <= 20) { 44 + circleSize = Math.min(40, vmin * 0.055); 45 + radius = vmin * 0.42; 46 + } else { 47 + circleSize = Math.min(32, vmin * 0.045); 48 + radius = vmin * 0.44; 49 + } 50 + circleSize = Math.max(circleSize, 28); 51 + radius = Math.max(radius, 120); 52 + } else { 53 + if (appCount <= 5) { 54 + circleSize = Math.min(70, vmin * 0.1); 55 + } else if (appCount <= 10) { 56 + circleSize = Math.min(60, vmin * 0.09); 57 + } else if (appCount <= 20) { 58 + circleSize = Math.min(50, vmin * 0.07); 59 + } else { 60 + circleSize = Math.min(40, vmin * 0.06); 61 + } 62 + circleSize = Math.max(circleSize, 35); 63 + radius = Math.max(vmin * 0.35, 150); 64 + } 65 + 66 + const centerX = window.innerWidth / 2; 67 + const centerY = window.innerHeight / 2; 68 + 69 + state.globalApps._circleSize = circleSize; 70 + 71 + // Create app circles 72 + const appDivs = appNames.map((namespace, i) => { 73 + const angle = (i / appNames.length) * 2 * Math.PI - Math.PI / 2; 74 + const circleOffset = circleSize / 2; 75 + const x = centerX + radius * Math.cos(angle) - circleOffset; 76 + const y = centerY + radius * Math.sin(angle) - circleOffset; 77 + 78 + const div = document.createElement('div'); 79 + div.className = 'app-view'; 80 + div.style.left = `${x}px`; 81 + div.style.top = `${y}px`; 82 + 83 + const firstLetter = namespace.split('.')[1]?.[0]?.toUpperCase() || namespace[0].toUpperCase(); 84 + const rawDisplayName = namespace.split('.').reverse().join('.'); 85 + const displayName = applyDomainRedirect(rawDisplayName); 86 + const url = `https://${displayName}`; 87 + 88 + div.innerHTML = ` 89 + <div class="app-circle" data-namespace="${namespace}" style="width: ${circleSize}px; height: ${circleSize}px; font-size: ${circleSize * 0.4}px;">${firstLetter}</div> 90 + <a href="${url}" target="_blank" rel="noopener noreferrer" class="app-name" data-url="${url}">${displayName} &#8599;</a> 91 + `; 92 + 93 + div.addEventListener('click', () => showAppDetail(namespace, apps[namespace], displayName, url)); 94 + 95 + return { div, namespace }; 96 + }); 97 + 98 + // Add all divs to field 99 + appDivs.forEach(({ div }) => field.appendChild(div)); 100 + 101 + // Fetch avatars asynchronously 102 + fetchAppAvatars(appNames).then(avatarMap => { 103 + appDivs.forEach(({ div, namespace }) => { 104 + const avatarUrl = avatarMap[namespace]; 105 + if (avatarUrl) { 106 + const circle = div.querySelector('.app-circle'); 107 + circle.innerHTML = `<img src="${avatarUrl}" class="app-logo" alt="${namespace}" />`; 108 + } 109 + }); 110 + }); 111 + 112 + // Validate app URLs (client-side check via image load) 113 + validateAppUrls(appDivs); 114 + 115 + // Set up identity click handler 116 + setupIdentityClickHandler(allCollections, appCount, profile); 117 + 118 + // Set up filter panel 119 + initFilterPanel(); 120 + 121 + // Handle window resize 122 + let resizeTimeout; 123 + window.addEventListener('resize', () => { 124 + clearTimeout(resizeTimeout); 125 + resizeTimeout = setTimeout(repositionAppCircles, 50); 126 + }); 127 + } 128 + 129 + function setupIdentityClickHandler(allCollections, appCount, profile) { 130 + const pdsHost = state.globalPds.replace('https://', '').replace('http://', ''); 131 + 132 + document.querySelector('.identity').addEventListener('click', () => { 133 + const detail = document.getElementById('detail'); 134 + 135 + detail.innerHTML = ` 136 + <button class="detail-close" id="detailClose">x</button> 137 + <h3>your personal data server</h3> 138 + <div class="subtitle">where your social data lives</div> 139 + 140 + <div class="stats-box"> 141 + <div class="stat"> 142 + <div class="stat-value">${allCollections.length}</div> 143 + <div class="stat-label">record types</div> 144 + </div> 145 + <div class="stat"> 146 + <div class="stat-value">${appCount}</div> 147 + <div class="stat-label">apps</div> 148 + </div> 149 + </div> 150 + 151 + <div class="ownership-box yours"> 152 + <div class="ownership-header">your pds location</div> 153 + <div class="ownership-text">your <a href="https://atproto.com/guides/data-repos" target="_blank" rel="noopener noreferrer" style="color: var(--text); text-decoration: underline;">Personal Data Server</a> is hosted at <a href="${state.globalPds}" target="_blank" rel="noopener noreferrer" style="color: var(--text); text-decoration: underline;"><strong>${pdsHost}</strong></a>. all your posts, likes, and follows are stored here. apps like bluesky just connect to it.</div> 154 + </div> 155 + 156 + <div class="ownership-box"> 157 + <div class="ownership-header">explore your data</div> 158 + <div class="ownership-text">want to see everything stored on your PDS? check out <a href="https://pdsls.dev/${pdsHost}" target="_blank" rel="noopener noreferrer" style="color: var(--text); text-decoration: underline;">pdsls.dev/${pdsHost}</a> - a tool for browsing all the records in your repository.</div> 159 + </div> 160 + 161 + <a href="https://bsky.app/profile/${state.globalHandle}" target="_blank" rel="noopener noreferrer" class="tree-item" style="text-decoration: none; display: block; margin-top: 1rem;"> 162 + <div class="tree-item-header"> 163 + <div style="display: flex; align-items: center; gap: 0.5rem;"> 164 + <svg width="16" height="16" viewBox="0 0 600 530" fill="none" xmlns="http://www.w3.org/2000/svg"> 165 + <path d="M135.72 44.03c66.496 49.921 138.02 151.14 164.28 205.46 26.262-54.316 97.782-155.54 164.28-205.46 47.98-36.021 125.72-63.892 125.72 24.795 0 17.712-10.155 148.79-16.111 170.07-20.703 73.984-96.144 92.854-163.25 81.433 117.3 19.964 147.14 86.092 82.697 152.22-122.39 125.59-175.91-31.511-189.63-71.766-2.514-7.3797-3.6904-10.832-3.7077-7.8964-0.0174-2.9357-1.1937 0.51669-3.7077 7.8964-13.714 40.255-67.233 197.36-189.63 71.766-64.444-66.128-34.605-132.26 82.697-152.22-67.108 11.421-142.55-7.4491-163.25-81.433-5.9562-21.282-16.111-152.36-16.111-170.07 0-88.687 77.742-60.816 125.72-24.795z" fill="var(--text)" /> 166 + </svg> 167 + <span style="color: var(--text-light);">view profile on bluesky</span> 168 + </div> 169 + <span style="font-size: 0.6rem; color: var(--text);">&#8599;</span> 170 + </div> 171 + </a> 172 + 173 + <div style="margin-top: 1.5rem; padding-top: 1rem; border-top: 1px solid var(--border);"> 174 + <div style="font-size: 0.65rem; color: var(--text-light); margin-bottom: 0.5rem;">technical details</div> 175 + <div class="tree-item"> 176 + <div class="tree-item-header"> 177 + <span style="color: var(--text-light);">did</span> 178 + <span style="font-size: 0.55rem; color: var(--text);">${state.did}</span> 179 + </div> 180 + </div> 181 + <div class="tree-item"> 182 + <div class="tree-item-header"> 183 + <span style="color: var(--text-light);">handle</span> 184 + <span style="font-size: 0.6rem; color: var(--text);">@${state.globalHandle}</span> 185 + </div> 186 + </div> 187 + </div> 188 + `; 189 + detail.classList.add('visible'); 190 + 191 + document.getElementById('detailClose').addEventListener('click', (e) => { 192 + e.stopPropagation(); 193 + detail.classList.remove('visible'); 194 + }); 195 + }); 196 + } 197 + 198 + async function showAppDetail(namespace, collections, displayName, appUrl) { 199 + const detail = document.getElementById('detail'); 200 + 201 + let html = ` 202 + <button class="detail-close" id="detailClose">x</button> 203 + <h3><a href="${appUrl}" target="_blank" rel="noopener noreferrer" style="color: var(--text); text-decoration: none; border-bottom: 1px solid var(--border);">${displayName} &#8599;</a></h3> 204 + <div class="subtitle">records stored in your <a href="https://atproto.com/guides/self-hosting" target="_blank" rel="noopener noreferrer" style="color: var(--text); text-decoration: underline;">PDS</a>:</div> 205 + `; 206 + 207 + if (collections && collections.length > 0) { 208 + const grouped = {}; 209 + collections.forEach(lexicon => { 210 + const parts = lexicon.split('.'); 211 + const subNamespace = parts.slice(2).join('.'); 212 + const firstPart = parts[2] || lexicon; 213 + 214 + if (!grouped[firstPart]) grouped[firstPart] = []; 215 + grouped[firstPart].push({ lexicon, subNamespace }); 216 + }); 217 + 218 + Object.keys(grouped).sort().forEach(group => { 219 + const items = grouped[group]; 220 + 221 + if (items.length === 1 && items[0].subNamespace === group) { 222 + html += ` 223 + <div class="tree-item" data-lexicon="${items[0].lexicon}"> 224 + <div class="tree-item-header"> 225 + <span>${group}</span> 226 + <span class="tree-item-count">loading...</span> 227 + </div> 228 + </div> 229 + `; 230 + } else { 231 + html += `<div style="margin-bottom: 0.75rem;">`; 232 + html += `<div style="font-size: 0.7rem; color: var(--text-light); margin-bottom: 0.4rem; font-weight: 500;">${group}</div>`; 233 + 234 + items.sort((a, b) => a.subNamespace.localeCompare(b.subNamespace)).forEach(item => { 235 + const itemDisplayName = item.subNamespace.split('.').slice(1).join('.') || item.subNamespace; 236 + html += ` 237 + <div class="tree-item" data-lexicon="${item.lexicon}" style="margin-left: 0.75rem;"> 238 + <div class="tree-item-header"> 239 + <span>${itemDisplayName}</span> 240 + <span class="tree-item-count">loading...</span> 241 + </div> 242 + </div> 243 + `; 244 + }); 245 + html += `</div>`; 246 + } 247 + }); 248 + } else { 249 + html += `<div class="tree-item">no collections found</div>`; 250 + } 251 + 252 + detail.innerHTML = html; 253 + detail.classList.add('visible'); 254 + 255 + document.getElementById('detailClose').addEventListener('click', (e) => { 256 + e.stopPropagation(); 257 + detail.classList.remove('visible'); 258 + }); 259 + 260 + // Fetch record counts 261 + if (collections) { 262 + for (const lexicon of collections) { 263 + const data = await listRecords(state.globalPds, state.did, lexicon, 1); 264 + const item = detail.querySelector(`[data-lexicon="${lexicon}"]`); 265 + if (item) { 266 + const countSpan = item.querySelector('.tree-item-count'); 267 + countSpan.textContent = data?.records?.length > 0 ? 'has records' : 'empty'; 268 + } 269 + } 270 + } 271 + 272 + // Add click handlers for expanding collections 273 + detail.querySelectorAll('.tree-item[data-lexicon]').forEach(item => { 274 + item.addEventListener('click', (e) => { 275 + e.stopPropagation(); 276 + expandCollection(item); 277 + }); 278 + }); 279 + } 280 + 281 + async function expandCollection(item) { 282 + const lexicon = item.dataset.lexicon; 283 + const existingContent = item.querySelector('.collection-content'); 284 + 285 + if (existingContent) { 286 + existingContent.remove(); 287 + return; 288 + } 289 + 290 + const contentDiv = document.createElement('div'); 291 + contentDiv.className = 'collection-content'; 292 + contentDiv.innerHTML = ` 293 + <div class="collection-view-content"> 294 + <div class="collection-view records-view active"> 295 + <div class="loading">loading records...</div> 296 + </div> 297 + <div class="collection-view structure-view"> 298 + <div class="loading">loading structure...</div> 299 + </div> 300 + </div> 301 + `; 302 + item.appendChild(contentDiv); 303 + 304 + const recordsView = contentDiv.querySelector('.records-view'); 305 + const structureView = contentDiv.querySelector('.structure-view'); 306 + const data = await listRecords(state.globalPds, state.did, lexicon, 10); 307 + 308 + // Add tabs if there are enough records for MST view 309 + const hasEnoughRecords = data?.records?.length >= 5; 310 + if (hasEnoughRecords) { 311 + const tabsHtml = ` 312 + <div class="collection-tabs"> 313 + <button class="collection-tab active" data-tab="records">records</button> 314 + <button class="collection-tab" data-tab="structure">mst</button> 315 + </div> 316 + `; 317 + contentDiv.insertAdjacentHTML('afterbegin', tabsHtml); 318 + 319 + // Tab switching logic 320 + contentDiv.querySelectorAll('.collection-tab').forEach(tab => { 321 + tab.addEventListener('click', (e) => { 322 + e.stopPropagation(); 323 + const tabName = tab.dataset.tab; 324 + 325 + contentDiv.querySelectorAll('.collection-tab').forEach(t => t.classList.remove('active')); 326 + tab.classList.add('active'); 327 + 328 + contentDiv.querySelectorAll('.collection-view').forEach(v => v.classList.remove('active')); 329 + if (tabName === 'records') { 330 + recordsView.classList.add('active'); 331 + } else if (tabName === 'structure') { 332 + structureView.classList.add('active'); 333 + if (structureView.querySelector('.loading')) { 334 + loadMSTStructure(lexicon, structureView); 335 + } 336 + } 337 + }); 338 + }); 339 + } 340 + 341 + if (data?.records?.length > 0) { 342 + let recordsHtml = data.records.map((record, idx) => { 343 + const json = JSON.stringify(record.value, null, 2); 344 + return ` 345 + <div class="record"> 346 + <div class="record-header"> 347 + <span class="record-label">record</span> 348 + <button class="copy-btn" data-content="${encodeURIComponent(json)}">copy</button> 349 + </div> 350 + <div class="record-content"> 351 + <pre>${escapeHtml(json)}</pre> 352 + </div> 353 + </div> 354 + `; 355 + }).join(''); 356 + 357 + if (data.cursor) { 358 + recordsHtml += `<button class="load-more" data-cursor="${data.cursor}" data-lexicon="${lexicon}">load more</button>`; 359 + } 360 + 361 + recordsView.innerHTML = recordsHtml; 362 + 363 + // Event delegation for copy and load more 364 + recordsView.addEventListener('click', async (e) => { 365 + if (e.target.classList.contains('copy-btn')) { 366 + e.stopPropagation(); 367 + const content = decodeURIComponent(e.target.dataset.content); 368 + await navigator.clipboard.writeText(content); 369 + e.target.textContent = 'copied!'; 370 + setTimeout(() => { e.target.textContent = 'copy'; }, 1500); 371 + } 372 + 373 + if (e.target.classList.contains('load-more')) { 374 + e.stopPropagation(); 375 + const cursor = e.target.dataset.cursor; 376 + const lex = e.target.dataset.lexicon; 377 + e.target.textContent = 'loading...'; 378 + 379 + const moreData = await listRecords(state.globalPds, state.did, lex, 10, cursor); 380 + if (moreData?.records) { 381 + let moreHtml = moreData.records.map(record => { 382 + const json = JSON.stringify(record.value, null, 2); 383 + return ` 384 + <div class="record"> 385 + <div class="record-header"> 386 + <span class="record-label">record</span> 387 + <button class="copy-btn" data-content="${encodeURIComponent(json)}">copy</button> 388 + </div> 389 + <div class="record-content"> 390 + <pre>${escapeHtml(json)}</pre> 391 + </div> 392 + </div> 393 + `; 394 + }).join(''); 395 + 396 + e.target.remove(); 397 + recordsView.insertAdjacentHTML('beforeend', moreHtml); 398 + 399 + if (moreData.cursor) { 400 + recordsView.insertAdjacentHTML('beforeend', 401 + `<button class="load-more" data-cursor="${moreData.cursor}" data-lexicon="${lex}">load more</button>` 402 + ); 403 + } 404 + } 405 + } 406 + }); 407 + } else { 408 + recordsView.innerHTML = '<div class="record">no records found</div>'; 409 + } 410 + }
-2624
static/app.js
··· 1 - // DID is set as window.DID by the template 2 - const did = window.DID; 3 - localStorage.setItem('atme_did', did); 4 - 5 - // Domain redirects - map old domains to their new canonical domains 6 - // Some ATProto apps have moved domains but their lexicons still use the old namespace 7 - const DOMAIN_REDIRECTS = { 8 - 'tangled.sh': 'tangled.org', 9 - }; 10 - 11 - // Apply domain redirect if one exists 12 - function applyDomainRedirect(domain) { 13 - return DOMAIN_REDIRECTS[domain] || domain; 14 - } 15 - 16 - let globalPds = null; 17 - let globalHandle = null; 18 - let globalApps = null; // Store apps for repositioning on resize 19 - let pageOwnerHasSigned = false; // Track if the page owner (did) has signed the guestbook 20 - let hiddenApps = new Set(); // Track which apps are hidden by filter 21 - 22 - // ============================================================================ 23 - // APP FILTER FUNCTIONALITY 24 - // ============================================================================ 25 - 26 - // Parse hidden apps from URL param 27 - function getHiddenAppsFromUrl() { 28 - const params = new URLSearchParams(window.location.search); 29 - const hideParam = params.get('hide'); 30 - if (hideParam) { 31 - return new Set(hideParam.split(',').filter(Boolean)); 32 - } 33 - return null; 34 - } 35 - 36 - // Update URL with current hidden apps (without page reload) 37 - function updateUrlWithFilters() { 38 - const params = new URLSearchParams(window.location.search); 39 - if (hiddenApps.size > 0) { 40 - params.set('hide', [...hiddenApps].join(',')); 41 - } else { 42 - params.delete('hide'); 43 - } 44 - const newUrl = params.toString() 45 - ? `${window.location.pathname}?${params.toString()}` 46 - : window.location.pathname; 47 - history.replaceState(null, '', newUrl); 48 - } 49 - 50 - // Load hidden apps from URL param first, then localStorage 51 - function loadHiddenApps() { 52 - // URL takes precedence over localStorage 53 - const urlHidden = getHiddenAppsFromUrl(); 54 - if (urlHidden) { 55 - hiddenApps = urlHidden; 56 - return; 57 - } 58 - 59 - try { 60 - const stored = localStorage.getItem(`atme_hidden_apps_${did}`); 61 - if (stored) { 62 - hiddenApps = new Set(JSON.parse(stored)); 63 - } 64 - } catch (e) { 65 - hiddenApps = new Set(); 66 - } 67 - } 68 - 69 - // Save hidden apps to localStorage and update URL 70 - function saveHiddenApps() { 71 - try { 72 - localStorage.setItem(`atme_hidden_apps_${did}`, JSON.stringify([...hiddenApps])); 73 - } catch (e) { 74 - // Silently fail 75 - } 76 - updateUrlWithFilters(); 77 - } 78 - 79 - // Update filter button state 80 - function updateFilterButton() { 81 - const filterBtn = document.getElementById('filterBtn'); 82 - const filterCount = document.getElementById('filterCount'); 83 - 84 - if (hiddenApps.size > 0) { 85 - filterBtn.classList.add('has-filters'); 86 - filterCount.textContent = hiddenApps.size; 87 - filterCount.style.display = 'inline-block'; 88 - } else { 89 - filterBtn.classList.remove('has-filters'); 90 - filterCount.style.display = 'none'; 91 - } 92 - } 93 - 94 - // Apply filters to app circles and reposition visible ones 95 - function applyFilters() { 96 - const appViews = document.querySelectorAll('.app-view'); 97 - const visibleApps = []; 98 - 99 - appViews.forEach(view => { 100 - const circle = view.querySelector('.app-circle'); 101 - if (circle) { 102 - const namespace = circle.dataset.namespace; 103 - if (hiddenApps.has(namespace)) { 104 - view.classList.add('filtered'); 105 - } else { 106 - view.classList.remove('filtered'); 107 - visibleApps.push(view); 108 - } 109 - } 110 - }); 111 - 112 - // Reposition visible apps evenly around the circle 113 - if (visibleApps.length > 0 && globalApps) { 114 - const vmin = Math.min(window.innerWidth, window.innerHeight); 115 - const isMobile = window.innerWidth < 768; 116 - const visibleCount = visibleApps.length; 117 - 118 - let circleSize = globalApps._circleSize || 50; 119 - let radius; 120 - 121 - if (isMobile) { 122 - if (visibleCount <= 5) { 123 - radius = vmin * 0.38; 124 - } else if (visibleCount <= 10) { 125 - radius = vmin * 0.4; 126 - } else if (visibleCount <= 20) { 127 - radius = vmin * 0.42; 128 - } else { 129 - radius = vmin * 0.44; 130 - } 131 - radius = Math.max(radius, 120); 132 - } else { 133 - radius = Math.max(vmin * 0.35, 150); 134 - } 135 - 136 - const centerX = window.innerWidth / 2; 137 - const centerY = window.innerHeight / 2; 138 - const circleOffset = circleSize / 2; 139 - 140 - visibleApps.forEach((view, i) => { 141 - const angle = (i / visibleCount) * 2 * Math.PI - Math.PI / 2; 142 - const x = centerX + radius * Math.cos(angle) - circleOffset; 143 - const y = centerY + radius * Math.sin(angle) - circleOffset; 144 - view.style.left = `${x}px`; 145 - view.style.top = `${y}px`; 146 - }); 147 - } 148 - 149 - updateFilterButton(); 150 - saveHiddenApps(); 151 - } 152 - 153 - // Populate filter list with apps 154 - function populateFilterList() { 155 - if (!globalApps) return; 156 - 157 - const filterList = document.getElementById('filterList'); 158 - const appNames = Object.keys(globalApps).filter(k => k !== '_circleSize').sort(); 159 - 160 - filterList.innerHTML = appNames.map(namespace => { 161 - const rawDisplayName = namespace.split('.').reverse().join('.'); 162 - const displayName = applyDomainRedirect(rawDisplayName); 163 - const isChecked = !hiddenApps.has(namespace); 164 - return ` 165 - <div class="filter-item ${isChecked ? 'checked' : ''}" data-namespace="${namespace}"> 166 - <div class="filter-checkbox"> 167 - <svg class="filter-checkbox-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor"> 168 - <polyline points="20 6 9 17 4 12"></polyline> 169 - </svg> 170 - </div> 171 - <span class="filter-label">${displayName}</span> 172 - </div> 173 - `; 174 - }).join(''); 175 - 176 - // Add click handlers 177 - filterList.querySelectorAll('.filter-item').forEach(item => { 178 - item.addEventListener('click', () => { 179 - const namespace = item.dataset.namespace; 180 - if (hiddenApps.has(namespace)) { 181 - hiddenApps.delete(namespace); 182 - item.classList.add('checked'); 183 - } else { 184 - hiddenApps.add(namespace); 185 - item.classList.remove('checked'); 186 - } 187 - applyFilters(); 188 - }); 189 - }); 190 - } 191 - 192 - // Initialize filter panel handlers 193 - function initFilterPanel() { 194 - const filterBtn = document.getElementById('filterBtn'); 195 - const filterPanel = document.getElementById('filterPanel'); 196 - const filterShowAll = document.getElementById('filterShowAll'); 197 - const filterHideUnresolved = document.getElementById('filterHideUnresolved'); 198 - const filterHideAll = document.getElementById('filterHideAll'); 199 - 200 - if (!filterBtn || !filterPanel || !filterShowAll || !filterHideUnresolved || !filterHideAll) { 201 - console.error('Filter panel elements not found:', { filterBtn, filterPanel, filterShowAll, filterHideUnresolved, filterHideAll }); 202 - return; 203 - } 204 - 205 - // Toggle panel 206 - filterBtn.addEventListener('click', (e) => { 207 - e.stopPropagation(); 208 - filterPanel.classList.toggle('visible'); 209 - filterBtn.classList.toggle('active'); 210 - if (filterPanel.classList.contains('visible')) { 211 - populateFilterList(); 212 - } 213 - }); 214 - 215 - // Close panel when clicking outside 216 - document.addEventListener('click', (e) => { 217 - if (!filterPanel.contains(e.target) && e.target !== filterBtn && !filterBtn.contains(e.target)) { 218 - filterPanel.classList.remove('visible'); 219 - filterBtn.classList.remove('active'); 220 - } 221 - }); 222 - 223 - // Show all 224 - filterShowAll.addEventListener('click', (e) => { 225 - e.preventDefault(); 226 - e.stopPropagation(); 227 - hiddenApps.clear(); 228 - populateFilterList(); 229 - applyFilters(); 230 - }); 231 - 232 - // Show only valid (hide unresolved domains) 233 - filterHideUnresolved.addEventListener('click', (e) => { 234 - e.preventDefault(); 235 - e.stopPropagation(); 236 - // Find all apps with invalid-link class and hide them 237 - const appViews = document.querySelectorAll('.app-view'); 238 - hiddenApps.clear(); 239 - appViews.forEach(view => { 240 - const link = view.querySelector('.app-name'); 241 - const circle = view.querySelector('.app-circle'); 242 - if (link && link.classList.contains('invalid-link') && circle) { 243 - hiddenApps.add(circle.dataset.namespace); 244 - } 245 - }); 246 - populateFilterList(); 247 - applyFilters(); 248 - }); 249 - 250 - // Hide all 251 - filterHideAll.addEventListener('click', (e) => { 252 - e.preventDefault(); 253 - e.stopPropagation(); 254 - if (!globalApps) return; 255 - const appNames = Object.keys(globalApps).filter(k => k !== '_circleSize'); 256 - hiddenApps = new Set(appNames); 257 - populateFilterList(); 258 - applyFilters(); 259 - }); 260 - } 261 - 262 - // Load filters on startup 263 - loadHiddenApps(); 264 - 265 - // Adaptive handle text sizing 266 - function adaptHandleTextSize(handleEl) { 267 - const identity = handleEl.closest('.identity'); 268 - if (!identity) return; 269 - 270 - // Get identity circle size 271 - const maxWidth = identity.offsetWidth * 0.85; // 85% of circle width for padding 272 - 273 - // Start with the CSS-defined font size and scale down if needed 274 - const computedStyle = window.getComputedStyle(handleEl); 275 - const maxFontSize = parseFloat(computedStyle.fontSize); 276 - let fontSize = maxFontSize; 277 - 278 - // Create temporary element to measure text width 279 - const measure = document.createElement('span'); 280 - measure.style.visibility = 'hidden'; 281 - measure.style.position = 'absolute'; 282 - measure.style.whiteSpace = 'nowrap'; 283 - measure.style.fontFamily = computedStyle.fontFamily; 284 - measure.textContent = handleEl.textContent; 285 - document.body.appendChild(measure); 286 - 287 - // Reduce font size until text fits 288 - while (fontSize > 8) { // minimum 8px 289 - measure.style.fontSize = fontSize + 'px'; 290 - if (measure.offsetWidth <= maxWidth) break; 291 - fontSize -= 0.5; 292 - } 293 - 294 - document.body.removeChild(measure); 295 - handleEl.style.fontSize = fontSize + 'px'; 296 - } 297 - 298 - // Fetch app avatar from server 299 - async function fetchAppAvatar(namespace) { 300 - try { 301 - const response = await fetch(`/api/avatar?namespace=${encodeURIComponent(namespace)}`); 302 - const data = await response.json(); 303 - return data.avatarUrl; 304 - } catch (e) { 305 - return null; 306 - } 307 - } 308 - 309 - async function fetchAppAvatars(namespaces) { 310 - if (!Array.isArray(namespaces) || namespaces.length === 0) return {}; 311 - const uniqueNamespaces = [...new Set(namespaces.filter(Boolean))]; 312 - if (!uniqueNamespaces.length) return {}; 313 - 314 - try { 315 - const response = await fetch('/api/avatar/batch', { 316 - method: 'POST', 317 - headers: { 'Content-Type': 'application/json' }, 318 - body: JSON.stringify({ namespaces: uniqueNamespaces }) 319 - }); 320 - if (!response.ok) return {}; 321 - const data = await response.json(); 322 - return data.avatars || {}; 323 - } catch (e) { 324 - return {}; 325 - } 326 - } 327 - 328 - // Info modal handlers 329 - document.getElementById('infoBtn').addEventListener('click', () => { 330 - document.getElementById('infoModal').classList.add('visible'); 331 - document.getElementById('overlay').classList.add('visible'); 332 - }); 333 - 334 - document.getElementById('closeInfo').addEventListener('click', () => { 335 - document.getElementById('infoModal').classList.remove('visible'); 336 - document.getElementById('overlay').classList.remove('visible'); 337 - }); 338 - 339 - document.getElementById('overlay').addEventListener('click', () => { 340 - document.getElementById('infoModal').classList.remove('visible'); 341 - document.getElementById('overlay').classList.remove('visible'); 342 - const detail = document.getElementById('detail'); 343 - detail.classList.remove('visible'); 344 - }); 345 - 346 - // Update loading progress 347 - const loadingProgress = document.getElementById('loadingProgress'); 348 - if (loadingProgress) { 349 - loadingProgress.textContent = 'fetching identity data...'; 350 - } 351 - 352 - // Fetch initialization data from server 353 - fetch(`/api/init?did=${encodeURIComponent(did)}`) 354 - .then(r => { 355 - if (loadingProgress) { 356 - loadingProgress.textContent = 'processing namespaces...'; 357 - } 358 - return r.json(); 359 - }) 360 - .then(initData => { 361 - globalPds = initData.pds; 362 - globalHandle = initData.handle; 363 - 364 - // Store viewed person's info for guestbook button 365 - viewedHandle = initData.handle; 366 - viewedAvatar = initData.avatar; 367 - 368 - // Update POV indicator 369 - const povHandleEl = document.getElementById('povHandle'); 370 - if (povHandleEl) { 371 - povHandleEl.textContent = `@${viewedHandle}`; 372 - povHandleEl.href = `https://bsky.app/profile/${viewedHandle}`; 373 - } 374 - 375 - // Display user's avatar if available 376 - const avatarEl = document.getElementById('avatar'); 377 - if (initData.avatar && avatarEl) { 378 - avatarEl.src = initData.avatar; 379 - avatarEl.alt = initData.handle; 380 - } 381 - 382 - // Update guestbook button with viewed person's info 383 - updateGuestbookButton(); 384 - 385 - // Convert apps array to object for easier access 386 - const apps = {}; 387 - const allCollections = []; 388 - initData.apps.forEach(app => { 389 - apps[app.namespace] = app.collections; 390 - allCollections.push(...app.collections); 391 - }); 392 - 393 - // Store apps globally for repositioning 394 - globalApps = apps; 395 - 396 - // Add identity click handler now that we have the data 397 - const pdsHost = globalPds.replace('https://', '').replace('http://', ''); 398 - document.querySelector('.identity').addEventListener('click', () => { 399 - const detail = document.getElementById('detail'); 400 - const appCount = initData.apps.length; 401 - 402 - detail.innerHTML = ` 403 - <button class="detail-close" id="detailClose">×</button> 404 - <h3>your personal data server</h3> 405 - <div class="subtitle">where your social data lives</div> 406 - 407 - <div class="stats-box"> 408 - <div class="stat"> 409 - <div class="stat-value">${allCollections.length}</div> 410 - <div class="stat-label">record types</div> 411 - </div> 412 - <div class="stat"> 413 - <div class="stat-value">${appCount}</div> 414 - <div class="stat-label">apps</div> 415 - </div> 416 - </div> 417 - 418 - <div class="ownership-box yours"> 419 - <div class="ownership-header">your pds location</div> 420 - <div class="ownership-text">your <a href="https://atproto.com/guides/data-repos" target="_blank" rel="noopener noreferrer" style="color: var(--text); text-decoration: underline;">Personal Data Server</a> is hosted at <a href="${globalPds}" target="_blank" rel="noopener noreferrer" style="color: var(--text); text-decoration: underline;"><strong>${pdsHost}</strong></a>. all your posts, likes, and follows are stored here. apps like bluesky just connect to it.</div> 421 - </div> 422 - 423 - <div class="ownership-box"> 424 - <div class="ownership-header">explore your data</div> 425 - <div class="ownership-text">want to see everything stored on your PDS? check out <a href="https://pdsls.dev/${pdsHost}" target="_blank" rel="noopener noreferrer" style="color: var(--text); text-decoration: underline;">pdsls.dev/${pdsHost}</a> - a tool for browsing all the records in your repository.</div> 426 - </div> 427 - 428 - <a href="https://bsky.app/profile/${globalHandle}" target="_blank" rel="noopener noreferrer" class="tree-item" style="text-decoration: none; display: block; margin-top: 1rem;"> 429 - <div class="tree-item-header"> 430 - <div style="display: flex; align-items: center; gap: 0.5rem;"> 431 - <svg width="16" height="16" viewBox="0 0 600 530" fill="none" xmlns="http://www.w3.org/2000/svg"> 432 - <path d="M135.72 44.03c66.496 49.921 138.02 151.14 164.28 205.46 26.262-54.316 97.782-155.54 164.28-205.46 47.98-36.021 125.72-63.892 125.72 24.795 0 17.712-10.155 148.79-16.111 170.07-20.703 73.984-96.144 92.854-163.25 81.433 117.3 19.964 147.14 86.092 82.697 152.22-122.39 125.59-175.91-31.511-189.63-71.766-2.514-7.3797-3.6904-10.832-3.7077-7.8964-0.0174-2.9357-1.1937 0.51669-3.7077 7.8964-13.714 40.255-67.233 197.36-189.63 71.766-64.444-66.128-34.605-132.26 82.697-152.22-67.108 11.421-142.55-7.4491-163.25-81.433-5.9562-21.282-16.111-152.36-16.111-170.07 0-88.687 77.742-60.816 125.72-24.795z" fill="var(--text)" /> 433 - </svg> 434 - <span style="color: var(--text-light);">view profile on bluesky</span> 435 - </div> 436 - <span style="font-size: 0.6rem; color: var(--text);">↗</span> 437 - </div> 438 - </a> 439 - 440 - <div style="margin-top: 1.5rem; padding-top: 1rem; border-top: 1px solid var(--border);"> 441 - <div style="font-size: 0.65rem; color: var(--text-light); margin-bottom: 0.5rem;">technical details</div> 442 - <div class="tree-item"> 443 - <div class="tree-item-header"> 444 - <span style="color: var(--text-light);">did</span> 445 - <span style="font-size: 0.55rem; color: var(--text);">${did}</span> 446 - </div> 447 - </div> 448 - <div class="tree-item"> 449 - <div class="tree-item-header"> 450 - <span style="color: var(--text-light);">handle</span> 451 - <span style="font-size: 0.6rem; color: var(--text);">@${globalHandle}</span> 452 - </div> 453 - </div> 454 - </div> 455 - `; 456 - detail.classList.add('visible'); 457 - 458 - // Add close button handler 459 - document.getElementById('detailClose').addEventListener('click', (e) => { 460 - e.stopPropagation(); 461 - detail.classList.remove('visible'); 462 - }); 463 - }); 464 - 465 - const field = document.getElementById('field'); 466 - field.innerHTML = ''; 467 - field.classList.remove('loading'); 468 - 469 - const appNames = Object.keys(apps).sort(); 470 - const appCount = appNames.length; 471 - 472 - // Hide labels on mobile when there are too many apps 473 - const isMobileView = window.innerWidth < 768; 474 - if (isMobileView && appCount > 20) { 475 - field.classList.add('many-apps'); 476 - } else { 477 - field.classList.remove('many-apps'); 478 - } 479 - 480 - // Calculate circle size and radius based on viewport and app count 481 - const vmin = Math.min(window.innerWidth, window.innerHeight); 482 - const isMobile = window.innerWidth < 768; 483 - 484 - let circleSize; 485 - let radius; 486 - 487 - if (isMobile) { 488 - // Mobile: more aggressive scaling for many apps 489 - if (appCount <= 5) { 490 - circleSize = Math.min(60, vmin * 0.08); 491 - radius = vmin * 0.38; 492 - } else if (appCount <= 10) { 493 - circleSize = Math.min(50, vmin * 0.07); 494 - radius = vmin * 0.4; 495 - } else if (appCount <= 20) { 496 - circleSize = Math.min(40, vmin * 0.055); 497 - radius = vmin * 0.42; 498 - } else { 499 - circleSize = Math.min(32, vmin * 0.045); 500 - radius = vmin * 0.44; 501 - } 502 - circleSize = Math.max(circleSize, 28); // Smaller minimum on mobile 503 - radius = Math.max(radius, 120); 504 - } else { 505 - // Desktop: original logic with slight tweaks 506 - if (appCount <= 5) { 507 - circleSize = Math.min(70, vmin * 0.1); 508 - } else if (appCount <= 10) { 509 - circleSize = Math.min(60, vmin * 0.09); 510 - } else if (appCount <= 20) { 511 - circleSize = Math.min(50, vmin * 0.07); 512 - } else { 513 - circleSize = Math.min(40, vmin * 0.06); 514 - } 515 - circleSize = Math.max(circleSize, 35); 516 - radius = Math.max(vmin * 0.35, 150); 517 - } 518 - 519 - const centerX = window.innerWidth / 2; 520 - const centerY = window.innerHeight / 2; 521 - 522 - // Store circle size for resize handler 523 - globalApps._circleSize = circleSize; 524 - 525 - // Create all app divs first 526 - const appDivs = appNames.map((namespace, i) => { 527 - const angle = (i / appNames.length) * 2 * Math.PI - Math.PI / 2; // Start from top 528 - const circleOffset = circleSize / 2; 529 - const x = centerX + radius * Math.cos(angle) - circleOffset; 530 - const y = centerY + radius * Math.sin(angle) - circleOffset; 531 - 532 - const div = document.createElement('div'); 533 - div.className = 'app-view'; 534 - div.style.left = `${x}px`; 535 - div.style.top = `${y}px`; 536 - 537 - const firstLetter = namespace.split('.')[1]?.[0]?.toUpperCase() || namespace[0].toUpperCase(); 538 - 539 - // Reverse namespace for display (app.bsky -> bsky.app) 540 - const rawDisplayName = namespace.split('.').reverse().join('.'); 541 - const displayName = applyDomainRedirect(rawDisplayName); 542 - const url = `https://${displayName}`; 543 - 544 - div.innerHTML = ` 545 - <div class="app-circle" data-namespace="${namespace}" style="width: ${circleSize}px; height: ${circleSize}px; font-size: ${circleSize * 0.4}px;">${firstLetter}</div> 546 - <a href="${url}" target="_blank" rel="noopener noreferrer" class="app-name" data-url="${url}">${displayName} ↗</a> 547 - `; 548 - 549 - return { div, namespace }; 550 - }); 551 - 552 - // Fetch all avatars concurrently via batch endpoint 553 - fetchAppAvatars(appNames).then(avatarMap => { 554 - appDivs.forEach(({ div, namespace }) => { 555 - const avatarUrl = avatarMap[namespace]; 556 - if (avatarUrl) { 557 - const circle = div.querySelector('.app-circle'); 558 - circle.innerHTML = `<img src="${avatarUrl}" class="app-logo" alt="${namespace}" />`; 559 - } 560 - }); 561 - }); 562 - 563 - // Collect validation promises to apply default filter after all complete 564 - const validationPromises = []; 565 - 566 - appDivs.forEach(({ div, namespace }, i) => { 567 - // Reverse namespace for display and create URL 568 - const rawDisplayName = namespace.split('.').reverse().join('.'); 569 - const displayName = applyDomainRedirect(rawDisplayName); 570 - const url = `https://${displayName}`; 571 - 572 - // Validate URL 573 - const validationPromise = fetch(`/api/validate-url?url=${encodeURIComponent(url)}`) 574 - .then(r => r.json()) 575 - .then(data => { 576 - const link = div.querySelector('.app-name'); 577 - if (!data.valid) { 578 - link.classList.add('invalid-link'); 579 - link.setAttribute('title', 'this domain is not reachable'); 580 - link.style.pointerEvents = 'none'; 581 - // Remove arrow from invalid links 582 - link.textContent = displayName; 583 - } 584 - }) 585 - .catch(() => { 586 - // Silently fail validation check 587 - }); 588 - validationPromises.push(validationPromise); 589 - 590 - div.addEventListener('click', () => { 591 - const detail = document.getElementById('detail'); 592 - const collections = apps[namespace]; 593 - 594 - // Reverse namespace for display and create URL 595 - const rawDisplayName = namespace.split('.').reverse().join('.'); 596 - const displayName = applyDomainRedirect(rawDisplayName); 597 - const appUrl = `https://${displayName}`; 598 - 599 - // Check if the link was already validated as invalid 600 - const appLink = div.querySelector('.app-name'); 601 - const isInvalid = appLink && appLink.classList.contains('invalid-link'); 602 - 603 - let html = ` 604 - <button class="detail-close" id="detailClose">×</button> 605 - <h3> 606 - ${isInvalid 607 - ? `<span style="color: var(--text-light); opacity: 0.5;">${displayName}</span>` 608 - : `<a href="${appUrl}" target="_blank" rel="noopener noreferrer" style="color: var(--text); text-decoration: none; border-bottom: 1px solid var(--border);">${displayName} ↗</a>` 609 - } 610 - </h3> 611 - <div class="subtitle">records stored in your <a href="https://atproto.com/guides/self-hosting" target="_blank" rel="noopener noreferrer" style="color: var(--text); text-decoration: underline;">PDS</a>:</div> 612 - `; 613 - 614 - if (collections && collections.length > 0) { 615 - // Group collections by sub-namespace (third segment) 616 - const grouped = {}; 617 - collections.forEach(lexicon => { 618 - const parts = lexicon.split('.'); 619 - const subNamespace = parts.slice(2).join('.'); 620 - const firstPart = parts[2] || lexicon; 621 - 622 - if (!grouped[firstPart]) grouped[firstPart] = []; 623 - grouped[firstPart].push({ lexicon, subNamespace }); 624 - }); 625 - 626 - // Sort and display grouped items 627 - Object.keys(grouped).sort().forEach(group => { 628 - const items = grouped[group]; 629 - 630 - if (items.length === 1 && items[0].subNamespace === group) { 631 - // Single item with no further nesting 632 - html += ` 633 - <div class="tree-item" data-lexicon="${items[0].lexicon}"> 634 - <div class="tree-item-header"> 635 - <span>${group}</span> 636 - <span class="tree-item-count">loading...</span> 637 - </div> 638 - </div> 639 - `; 640 - } else { 641 - // Group header 642 - html += `<div style="margin-bottom: 0.75rem;">`; 643 - html += `<div style="font-size: 0.7rem; color: var(--text-light); margin-bottom: 0.4rem; font-weight: 500;">${group}</div>`; 644 - 645 - // Items in group 646 - items.sort((a, b) => a.subNamespace.localeCompare(b.subNamespace)).forEach(item => { 647 - const displayName = item.subNamespace.split('.').slice(1).join('.') || item.subNamespace; 648 - html += ` 649 - <div class="tree-item" data-lexicon="${item.lexicon}" style="margin-left: 0.75rem;"> 650 - <div class="tree-item-header"> 651 - <span>${displayName}</span> 652 - <span class="tree-item-count">loading...</span> 653 - </div> 654 - </div> 655 - `; 656 - }); 657 - html += `</div>`; 658 - } 659 - }); 660 - } else { 661 - html += `<div class="tree-item">no collections found</div>`; 662 - } 663 - 664 - detail.innerHTML = html; 665 - detail.classList.add('visible'); 666 - 667 - // Add close button handler 668 - document.getElementById('detailClose').addEventListener('click', (e) => { 669 - e.stopPropagation(); 670 - detail.classList.remove('visible'); 671 - }); 672 - 673 - // Fetch record counts for each collection 674 - if (collections && collections.length > 0) { 675 - collections.forEach(lexicon => { 676 - fetch(`${globalPds}/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=${lexicon}&limit=1`) 677 - .then(r => r.json()) 678 - .then(data => { 679 - const item = detail.querySelector(`[data-lexicon="${lexicon}"]`); 680 - if (item) { 681 - const countSpan = item.querySelector('.tree-item-count'); 682 - // The cursor field indicates there are more records 683 - countSpan.textContent = data.records?.length > 0 ? 'has records' : 'empty'; 684 - } 685 - }) 686 - .catch(e => { 687 - console.error('Error fetching count for', lexicon, e); 688 - const item = detail.querySelector(`[data-lexicon="${lexicon}"]`); 689 - if (item) { 690 - const countSpan = item.querySelector('.tree-item-count'); 691 - countSpan.textContent = 'error'; 692 - } 693 - }); 694 - }); 695 - } 696 - 697 - // Add click handlers to tree items to fetch actual records 698 - detail.querySelectorAll('.tree-item[data-lexicon]').forEach(item => { 699 - item.addEventListener('click', (e) => { 700 - e.stopPropagation(); 701 - const lexicon = item.dataset.lexicon; 702 - const existingContent = item.querySelector('.collection-content'); 703 - 704 - if (existingContent) { 705 - existingContent.remove(); 706 - return; 707 - } 708 - 709 - // Create container for tabs and content 710 - const contentDiv = document.createElement('div'); 711 - contentDiv.className = 'collection-content'; 712 - 713 - // Will add tabs after we know record count 714 - contentDiv.innerHTML = ` 715 - <div class="collection-view-content"> 716 - <div class="collection-view records-view active"> 717 - <div class="loading">loading records...</div> 718 - </div> 719 - <div class="collection-view structure-view"> 720 - <div class="loading">loading structure...</div> 721 - </div> 722 - </div> 723 - `; 724 - item.appendChild(contentDiv); 725 - 726 - const recordsView = contentDiv.querySelector('.records-view'); 727 - const structureView = contentDiv.querySelector('.structure-view'); 728 - 729 - // Load records first to determine if we should show structure tab 730 - fetch(`${globalPds}/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=${lexicon}&limit=10`) 731 - .then(r => r.json()) 732 - .then(data => { 733 - // Add tabs if there are enough records for structure view 734 - const hasEnoughRecords = data.records && data.records.length >= 5; 735 - if (hasEnoughRecords) { 736 - const tabsHtml = ` 737 - <div class="collection-tabs"> 738 - <button class="collection-tab active" data-tab="records">records</button> 739 - <button class="collection-tab" data-tab="structure">mst</button> 740 - </div> 741 - `; 742 - contentDiv.insertAdjacentHTML('afterbegin', tabsHtml); 743 - 744 - // Tab switching logic 745 - contentDiv.querySelectorAll('.collection-tab').forEach(tab => { 746 - tab.addEventListener('click', (e) => { 747 - e.stopPropagation(); 748 - const tabName = tab.dataset.tab; 749 - 750 - // Update active tab 751 - contentDiv.querySelectorAll('.collection-tab').forEach(t => t.classList.remove('active')); 752 - tab.classList.add('active'); 753 - 754 - // Update active view 755 - contentDiv.querySelectorAll('.collection-view').forEach(v => v.classList.remove('active')); 756 - if (tabName === 'records') { 757 - recordsView.classList.add('active'); 758 - } else if (tabName === 'structure') { 759 - structureView.classList.add('active'); 760 - // Load structure if not already loaded 761 - if (structureView.querySelector('.loading')) { 762 - loadMSTStructure(lexicon, structureView); 763 - } 764 - } 765 - }); 766 - }); 767 - } 768 - 769 - if (data.records && data.records.length > 0) { 770 - let recordsHtml = ''; 771 - data.records.forEach((record, idx) => { 772 - const json = JSON.stringify(record.value, null, 2); 773 - const recordId = `record-${Date.now()}-${idx}`; 774 - recordsHtml += ` 775 - <div class="record"> 776 - <div class="record-header"> 777 - <span class="record-label">record</span> 778 - <button class="copy-btn" data-content="${encodeURIComponent(json)}" data-record-id="${recordId}">copy</button> 779 - </div> 780 - <div class="record-content"> 781 - <pre>${json}</pre> 782 - </div> 783 - </div> 784 - `; 785 - }); 786 - 787 - if (data.cursor && data.records.length === 5) { 788 - recordsHtml += `<button class="load-more" data-cursor="${data.cursor}" data-lexicon="${lexicon}">load more</button>`; 789 - } 790 - 791 - recordsView.innerHTML = recordsHtml; 792 - 793 - // Use event delegation for copy and load more buttons 794 - recordsView.addEventListener('click', (e) => { 795 - // Handle copy button 796 - if (e.target.classList.contains('copy-btn')) { 797 - e.stopPropagation(); 798 - const copyBtn = e.target; 799 - const content = decodeURIComponent(copyBtn.dataset.content); 800 - 801 - navigator.clipboard.writeText(content).then(() => { 802 - const originalText = copyBtn.textContent; 803 - copyBtn.textContent = 'copied!'; 804 - copyBtn.classList.add('copied'); 805 - setTimeout(() => { 806 - copyBtn.textContent = originalText; 807 - copyBtn.classList.remove('copied'); 808 - }, 1500); 809 - }).catch(err => { 810 - console.error('Failed to copy:', err); 811 - copyBtn.textContent = 'error'; 812 - setTimeout(() => { 813 - copyBtn.textContent = 'copy'; 814 - }, 1500); 815 - }); 816 - } 817 - 818 - // Handle load more button 819 - if (e.target.classList.contains('load-more')) { 820 - e.stopPropagation(); 821 - const loadMoreBtn = e.target; 822 - const cursor = loadMoreBtn.dataset.cursor; 823 - const lexicon = loadMoreBtn.dataset.lexicon; 824 - 825 - loadMoreBtn.textContent = 'loading...'; 826 - 827 - fetch(`${globalPds}/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=${lexicon}&limit=5&cursor=${cursor}`) 828 - .then(r => r.json()) 829 - .then(moreData => { 830 - let moreHtml = ''; 831 - moreData.records.forEach((record, idx) => { 832 - const json = JSON.stringify(record.value, null, 2); 833 - const recordId = `record-more-${Date.now()}-${idx}`; 834 - moreHtml += ` 835 - <div class="record"> 836 - <div class="record-header"> 837 - <span class="record-label">record</span> 838 - <button class="copy-btn" data-content="${encodeURIComponent(json)}" data-record-id="${recordId}">copy</button> 839 - </div> 840 - <div class="record-content"> 841 - <pre>${json}</pre> 842 - </div> 843 - </div> 844 - `; 845 - }); 846 - 847 - loadMoreBtn.remove(); 848 - recordsView.insertAdjacentHTML('beforeend', moreHtml); 849 - 850 - if (moreData.cursor && moreData.records.length === 5) { 851 - recordsView.insertAdjacentHTML('beforeend', 852 - `<button class="load-more" data-cursor="${moreData.cursor}" data-lexicon="${lexicon}">load more</button>` 853 - ); 854 - } 855 - }); 856 - } 857 - }); 858 - } else { 859 - recordsView.innerHTML = '<div class="record">no records found</div>'; 860 - } 861 - }) 862 - .catch(e => { 863 - console.error('Error fetching records:', e); 864 - recordsView.innerHTML = '<div class="record">error loading records</div>'; 865 - }); 866 - }); 867 - }); 868 - }); 869 - 870 - field.appendChild(div); 871 - }); 872 - 873 - // Close detail panel when clicking canvas 874 - const canvas = document.querySelector('.canvas'); 875 - canvas.addEventListener('click', (e) => { 876 - if (e.target === canvas) { 877 - document.getElementById('detail').classList.remove('visible'); 878 - } 879 - }); 880 - 881 - // Add window resize handler to reposition app circles 882 - let resizeTimeout; 883 - window.addEventListener('resize', () => { 884 - clearTimeout(resizeTimeout); 885 - resizeTimeout = setTimeout(() => { 886 - repositionAppCircles(); 887 - }, 50); // Faster debounce for smoother updates 888 - }); 889 - 890 - // Initialize filter panel 891 - initFilterPanel(); 892 - 893 - // After all URL validations complete, apply default "valid" filter (hide unresolved) 894 - Promise.all(validationPromises).then(() => { 895 - // Only apply default if user hasn't set any filters yet AND no URL param was provided 896 - const hasUrlFilters = getHiddenAppsFromUrl() !== null; 897 - if (hiddenApps.size === 0 && !hasUrlFilters) { 898 - // Hide apps with invalid-link class by default 899 - const appViews = document.querySelectorAll('.app-view'); 900 - appViews.forEach(view => { 901 - const link = view.querySelector('.app-name'); 902 - const circle = view.querySelector('.app-circle'); 903 - if (link && link.classList.contains('invalid-link') && circle) { 904 - hiddenApps.add(circle.dataset.namespace); 905 - } 906 - }); 907 - } 908 - applyFilters(); 909 - }); 910 - }) 911 - .catch(e => { 912 - document.getElementById('field').innerHTML = 'error loading records'; 913 - console.error(e); 914 - }); 915 - 916 - // Function to reposition app circles on window resize 917 - function repositionAppCircles() { 918 - if (!globalApps) return; 919 - 920 - const appViews = document.querySelectorAll('.app-view'); 921 - const appNames = Object.keys(globalApps).filter(k => k !== '_circleSize').sort(); 922 - const appCount = appNames.length; 923 - 924 - // Update label visibility on resize 925 - const field = document.getElementById('field'); 926 - const isMobileView = window.innerWidth < 768; 927 - if (isMobileView && appCount > 20) { 928 - field.classList.add('many-apps'); 929 - } else { 930 - field.classList.remove('many-apps'); 931 - } 932 - 933 - // Recalculate circle size and radius based on viewport and app count 934 - const vmin = Math.min(window.innerWidth, window.innerHeight); 935 - const isMobile = window.innerWidth < 768; 936 - 937 - let circleSize; 938 - let radius; 939 - 940 - if (isMobile) { 941 - // Mobile: more aggressive scaling for many apps 942 - if (appCount <= 5) { 943 - circleSize = Math.min(60, vmin * 0.08); 944 - radius = vmin * 0.38; 945 - } else if (appCount <= 10) { 946 - circleSize = Math.min(50, vmin * 0.07); 947 - radius = vmin * 0.4; 948 - } else if (appCount <= 20) { 949 - circleSize = Math.min(40, vmin * 0.055); 950 - radius = vmin * 0.42; 951 - } else { 952 - circleSize = Math.min(32, vmin * 0.045); 953 - radius = vmin * 0.44; 954 - } 955 - circleSize = Math.max(circleSize, 28); 956 - radius = Math.max(radius, 120); 957 - } else { 958 - // Desktop: original logic with slight tweaks 959 - if (appCount <= 5) { 960 - circleSize = Math.min(70, vmin * 0.1); 961 - } else if (appCount <= 10) { 962 - circleSize = Math.min(60, vmin * 0.09); 963 - } else if (appCount <= 20) { 964 - circleSize = Math.min(50, vmin * 0.07); 965 - } else { 966 - circleSize = Math.min(40, vmin * 0.06); 967 - } 968 - circleSize = Math.max(circleSize, 35); 969 - radius = Math.max(vmin * 0.35, 150); 970 - } 971 - 972 - // Update stored circle size 973 - globalApps._circleSize = circleSize; 974 - 975 - // Recalculate center 976 - const centerX = window.innerWidth / 2; 977 - const centerY = window.innerHeight / 2; 978 - 979 - appViews.forEach((div, i) => { 980 - const angle = (i / appNames.length) * 2 * Math.PI - Math.PI / 2; 981 - const circleOffset = circleSize / 2; 982 - const x = centerX + radius * Math.cos(angle) - circleOffset; 983 - const y = centerY + radius * Math.sin(angle) - circleOffset; 984 - 985 - div.style.left = `${x}px`; 986 - div.style.top = `${y}px`; 987 - 988 - // Update circle size 989 - const circle = div.querySelector('.app-circle'); 990 - if (circle) { 991 - circle.style.width = `${circleSize}px`; 992 - circle.style.height = `${circleSize}px`; 993 - circle.style.fontSize = `${circleSize * 0.4}px`; 994 - } 995 - }); 996 - } 997 - 998 - // MST Visualization Functions 999 - async function loadMSTStructure(lexicon, containerView) { 1000 - try { 1001 - // Call server endpoint to build MST 1002 - const response = await fetch(`/api/mst?pds=${encodeURIComponent(globalPds)}&did=${encodeURIComponent(did)}&collection=${encodeURIComponent(lexicon)}`); 1003 - const data = await response.json(); 1004 - 1005 - if (data.error) { 1006 - containerView.innerHTML = `<div class="mst-info"><p>${data.error}</p></div>`; 1007 - return; 1008 - } 1009 - 1010 - const { root, recordCount } = data; 1011 - 1012 - // Render structure 1013 - containerView.innerHTML = ` 1014 - <div class="mst-info"> 1015 - <p>this shows the <a href="https://atproto.com/specs/repository#mst-structure" target="_blank" rel="noopener noreferrer" style="color: var(--text); text-decoration: underline;">Merkle Search Tree (MST)</a> structure used to store your ${recordCount} record${recordCount !== 1 ? 's' : ''} in your repository. records are organized by their <a href="https://atproto.com/specs/record-key#record-key-type-tid" target="_blank" rel="noopener noreferrer" style="color: var(--text); text-decoration: underline;">TIDs</a> (timestamp identifiers), which determines how they're arranged in the tree.</p> 1016 - </div> 1017 - <canvas class="mst-canvas" id="mstCanvas-${Date.now()}"></canvas> 1018 - `; 1019 - 1020 - // Render tree on canvas 1021 - setTimeout(() => { 1022 - const canvas = containerView.querySelector('.mst-canvas'); 1023 - if (canvas) { 1024 - renderMSTTree(canvas, root); 1025 - } 1026 - }, 50); 1027 - 1028 - } catch (e) { 1029 - console.error('Error loading MST structure:', e); 1030 - containerView.innerHTML = '<div class="mst-info"><p>error loading structure</p></div>'; 1031 - } 1032 - } 1033 - 1034 - function renderMSTTree(canvas, tree) { 1035 - const ctx = canvas.getContext('2d'); 1036 - const width = canvas.width = canvas.offsetWidth; 1037 - const height = canvas.height = canvas.offsetHeight; 1038 - 1039 - // Calculate tree layout 1040 - const layout = layoutTree(tree, width, height); 1041 - 1042 - // Get CSS colors 1043 - const borderColor = getComputedStyle(document.documentElement).getPropertyValue('--border').trim(); 1044 - const textColor = getComputedStyle(document.documentElement).getPropertyValue('--text').trim(); 1045 - const textLightColor = getComputedStyle(document.documentElement).getPropertyValue('--text-light').trim(); 1046 - const surfaceColor = getComputedStyle(document.documentElement).getPropertyValue('--surface').trim(); 1047 - const surfaceHoverColor = getComputedStyle(document.documentElement).getPropertyValue('--surface-hover').trim(); 1048 - const bgColor = getComputedStyle(document.documentElement).getPropertyValue('--bg').trim(); 1049 - 1050 - let hoveredNode = null; 1051 - 1052 - function draw() { 1053 - // Clear canvas 1054 - ctx.clearRect(0, 0, width, height); 1055 - 1056 - // Draw connections first 1057 - layout.forEach(node => { 1058 - if (node.children) { 1059 - node.children.forEach(child => { 1060 - ctx.beginPath(); 1061 - ctx.moveTo(node.x, node.y); 1062 - ctx.lineTo(child.x, child.y); 1063 - ctx.strokeStyle = borderColor; 1064 - ctx.lineWidth = 1; 1065 - ctx.stroke(); 1066 - }); 1067 - } 1068 - }); 1069 - 1070 - // Draw nodes 1071 - layout.forEach(node => { 1072 - const isRoot = node.depth === -1; 1073 - const isLeaf = !node.children || node.children.length === 0; 1074 - const isHovered = hoveredNode === node; 1075 - 1076 - // Node circle 1077 - ctx.beginPath(); 1078 - ctx.arc(node.x, node.y, isRoot ? 12 : 8, 0, Math.PI * 2); 1079 - 1080 - ctx.fillStyle = isRoot ? textColor : isLeaf ? surfaceHoverColor : surfaceColor; 1081 - ctx.fill(); 1082 - 1083 - ctx.strokeStyle = isHovered ? textColor : borderColor; 1084 - ctx.lineWidth = isRoot ? 2 : isHovered ? 2 : 1; 1085 - ctx.stroke(); 1086 - }); 1087 - 1088 - // Draw label for hovered node 1089 - if (hoveredNode && hoveredNode.key && hoveredNode.key !== 'root') { 1090 - const padding = 6; 1091 - const fontSize = 10; 1092 - ctx.font = `${fontSize}px monospace`; 1093 - const textWidth = ctx.measureText(hoveredNode.key).width; 1094 - 1095 - // Position tooltip above node 1096 - const tooltipX = hoveredNode.x; 1097 - const tooltipY = hoveredNode.y - 20; 1098 - const boxWidth = textWidth + padding * 2; 1099 - const boxHeight = fontSize + padding * 2; 1100 - 1101 - // Draw tooltip background 1102 - ctx.fillStyle = bgColor; 1103 - ctx.fillRect(tooltipX - boxWidth / 2, tooltipY - boxHeight / 2, boxWidth, boxHeight); 1104 - 1105 - // Draw tooltip border 1106 - ctx.strokeStyle = borderColor; 1107 - ctx.lineWidth = 1; 1108 - ctx.strokeRect(tooltipX - boxWidth / 2, tooltipY - boxHeight / 2, boxWidth, boxHeight); 1109 - 1110 - // Draw text 1111 - ctx.fillStyle = textColor; 1112 - ctx.textAlign = 'center'; 1113 - ctx.textBaseline = 'middle'; 1114 - ctx.fillText(hoveredNode.key, tooltipX, tooltipY); 1115 - } 1116 - } 1117 - 1118 - // Mouse move handler 1119 - canvas.addEventListener('mousemove', (e) => { 1120 - const rect = canvas.getBoundingClientRect(); 1121 - const mouseX = e.clientX - rect.left; 1122 - const mouseY = e.clientY - rect.top; 1123 - 1124 - let foundNode = null; 1125 - for (const node of layout) { 1126 - const isRoot = node.depth === -1; 1127 - const radius = isRoot ? 12 : 8; 1128 - const dist = Math.sqrt((mouseX - node.x) ** 2 + (mouseY - node.y) ** 2); 1129 - if (dist <= radius) { 1130 - foundNode = node; 1131 - break; 1132 - } 1133 - } 1134 - 1135 - if (foundNode !== hoveredNode) { 1136 - hoveredNode = foundNode; 1137 - canvas.style.cursor = hoveredNode ? 'pointer' : 'default'; 1138 - draw(); 1139 - } 1140 - }); 1141 - 1142 - // Mouse leave handler 1143 - canvas.addEventListener('mouseleave', () => { 1144 - if (hoveredNode) { 1145 - hoveredNode = null; 1146 - canvas.style.cursor = 'default'; 1147 - draw(); 1148 - } 1149 - }); 1150 - 1151 - // Click handler 1152 - canvas.addEventListener('click', (e) => { 1153 - if (hoveredNode && hoveredNode.key && hoveredNode.key !== 'root') { 1154 - showNodeModal(hoveredNode); 1155 - } 1156 - }); 1157 - 1158 - // Initial draw 1159 - draw(); 1160 - } 1161 - 1162 - function showNodeModal(node) { 1163 - // Create modal 1164 - const modal = document.createElement('div'); 1165 - modal.className = 'mst-node-modal'; 1166 - modal.innerHTML = ` 1167 - <div class="mst-node-modal-content"> 1168 - <button class="mst-node-close">×</button> 1169 - <h3>record in MST</h3> 1170 - <div class="mst-node-info"> 1171 - <div class="mst-node-field"> 1172 - <span class="mst-node-label">TID:</span> 1173 - <span class="mst-node-value">${node.key}</span> 1174 - </div> 1175 - <div class="mst-node-field"> 1176 - <span class="mst-node-label">CID:</span> 1177 - <span class="mst-node-value">${node.cid}</span> 1178 - </div> 1179 - ${node.uri ? ` 1180 - <div class="mst-node-field"> 1181 - <span class="mst-node-label">URI:</span> 1182 - <span class="mst-node-value">${node.uri}</span> 1183 - </div> 1184 - ` : ''} 1185 - </div> 1186 - <div class="mst-node-explanation"> 1187 - <p>this is a leaf node in your Merkle Search Tree. the TID (timestamp identifier) determines its position in the tree. records are sorted by TID, making range queries efficient.</p> 1188 - </div> 1189 - ${node.value ? ` 1190 - <div class="mst-node-data"> 1191 - <div class="mst-node-data-header">record data</div> 1192 - <pre>${JSON.stringify(node.value, null, 2)}</pre> 1193 - </div> 1194 - ` : ''} 1195 - </div> 1196 - `; 1197 - 1198 - // Add to DOM 1199 - document.body.appendChild(modal); 1200 - 1201 - // Close handlers 1202 - modal.querySelector('.mst-node-close').addEventListener('click', () => { 1203 - modal.remove(); 1204 - }); 1205 - 1206 - modal.addEventListener('click', (e) => { 1207 - if (e.target === modal) { 1208 - modal.remove(); 1209 - } 1210 - }); 1211 - } 1212 - 1213 - function layoutTree(tree, width, height) { 1214 - const nodes = []; 1215 - const padding = 40; 1216 - const availableWidth = width - padding * 2; 1217 - const availableHeight = height - padding * 2; 1218 - 1219 - // Calculate max depth and total nodes at each depth 1220 - const depthCounts = {}; 1221 - function countDepths(node, depth) { 1222 - if (!depthCounts[depth]) depthCounts[depth] = 0; 1223 - depthCounts[depth]++; 1224 - if (node.children) { 1225 - node.children.forEach(child => countDepths(child, depth + 1)); 1226 - } 1227 - } 1228 - countDepths(tree, 0); 1229 - 1230 - const maxDepth = Math.max(...Object.keys(depthCounts).map(Number)); 1231 - const verticalSpacing = availableHeight / (maxDepth + 1); 1232 - 1233 - // Track positions at each depth to avoid overlap 1234 - const positionsByDepth = {}; 1235 - 1236 - function traverse(node, depth, minX, maxX) { 1237 - if (!positionsByDepth[depth]) positionsByDepth[depth] = []; 1238 - 1239 - // Calculate position based on available space 1240 - const x = (minX + maxX) / 2; 1241 - const y = padding + verticalSpacing * depth; 1242 - 1243 - const layoutNode = { ...node, x, y }; 1244 - nodes.push(layoutNode); 1245 - positionsByDepth[depth].push(x); 1246 - 1247 - if (node.children && node.children.length > 0) { 1248 - layoutNode.children = []; 1249 - const childWidth = (maxX - minX) / node.children.length; 1250 - 1251 - node.children.forEach((child, idx) => { 1252 - const childMinX = minX + childWidth * idx; 1253 - const childMaxX = minX + childWidth * (idx + 1); 1254 - const childLayout = traverse(child, depth + 1, childMinX, childMaxX); 1255 - layoutNode.children.push(childLayout); 1256 - }); 1257 - } 1258 - 1259 - return layoutNode; 1260 - } 1261 - 1262 - traverse(tree, 0, padding, width - padding); 1263 - return nodes; 1264 - } 1265 - 1266 - // ============================================================================ 1267 - // FIREHOSE VISUALIZATION 1268 - // ============================================================================ 1269 - 1270 - // Particle class for animating firehose events 1271 - class FirehoseParticle { 1272 - constructor(startX, startY, endX, endY, color, metadata) { 1273 - this.x = startX; 1274 - this.y = startY; 1275 - this.startX = startX; 1276 - this.startY = startY; 1277 - this.endX = endX; 1278 - this.endY = endY; 1279 - this.color = color; 1280 - this.metadata = metadata; // {action, collection, namespace} 1281 - this.progress = 0; 1282 - this.speed = 0.008; // Slower, more graceful 1283 - this.size = 6; // Slightly larger core 1284 - this.glowSize = 14; // Softer, wider glow 1285 - } 1286 - 1287 - update() { 1288 - if (this.progress < 1) { 1289 - this.progress += this.speed; 1290 - // Gentle ease-out for organic feel 1291 - const eased = 1 - Math.pow(1 - this.progress, 3); 1292 - 1293 - this.x = this.startX + (this.endX - this.startX) * eased; 1294 - this.y = this.startY + (this.endY - this.startY) * eased; 1295 - } 1296 - return this.progress < 1; 1297 - } 1298 - 1299 - draw(ctx) { 1300 - // Calculate fade based on progress for elegant entry/exit 1301 - const fadeIn = Math.min(this.progress * 4, 1); // Fade in over first 25% 1302 - const fadeOut = this.progress > 0.8 ? 1 - ((this.progress - 0.8) / 0.2) : 1; // Fade out in last 20% 1303 - const opacity = Math.min(fadeIn, fadeOut); 1304 - 1305 - // Outer glow - softer and more diffuse 1306 - ctx.beginPath(); 1307 - ctx.arc(this.x, this.y, this.glowSize, 0, Math.PI * 2); 1308 - const gradient = ctx.createRadialGradient( 1309 - this.x, this.y, 0, 1310 - this.x, this.y, this.glowSize 1311 - ); 1312 - // Use lower opacity for subtlety 1313 - gradient.addColorStop(0, this.color + Math.floor(opacity * 60).toString(16).padStart(2, '0')); 1314 - gradient.addColorStop(0.5, this.color + Math.floor(opacity * 30).toString(16).padStart(2, '0')); 1315 - gradient.addColorStop(1, this.color + '00'); 1316 - ctx.fillStyle = gradient; 1317 - ctx.fill(); 1318 - 1319 - // Inner particle - subtle core 1320 - ctx.beginPath(); 1321 - ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2); 1322 - ctx.fillStyle = this.color + Math.floor(opacity * 180).toString(16).padStart(2, '0'); 1323 - ctx.fill(); 1324 - } 1325 - } 1326 - 1327 - // Firehose state 1328 - let firehoseParticles = []; 1329 - let firehoseCanvas = null; 1330 - let firehoseCtx = null; 1331 - let firehoseAnimationId = null; 1332 - let firehoseEventSource = null; 1333 - let isWatchingLive = false; 1334 - 1335 - function initFirehoseCanvas() { 1336 - // Create canvas overlay 1337 - firehoseCanvas = document.createElement('canvas'); 1338 - firehoseCanvas.id = 'firehoseCanvas'; 1339 - firehoseCanvas.style.position = 'fixed'; 1340 - firehoseCanvas.style.top = '0'; 1341 - firehoseCanvas.style.left = '0'; 1342 - firehoseCanvas.style.width = '100%'; 1343 - firehoseCanvas.style.height = '100%'; 1344 - firehoseCanvas.style.pointerEvents = 'none'; 1345 - firehoseCanvas.style.zIndex = '50'; 1346 - firehoseCanvas.width = window.innerWidth; 1347 - firehoseCanvas.height = window.innerHeight; 1348 - 1349 - document.body.appendChild(firehoseCanvas); 1350 - firehoseCtx = firehoseCanvas.getContext('2d'); 1351 - 1352 - // Handle window resize 1353 - window.addEventListener('resize', () => { 1354 - firehoseCanvas.width = window.innerWidth; 1355 - firehoseCanvas.height = window.innerHeight; 1356 - }); 1357 - } 1358 - 1359 - function animateFirehoseParticles() { 1360 - if (!firehoseCtx) return; 1361 - 1362 - firehoseCtx.clearRect(0, 0, firehoseCanvas.width, firehoseCanvas.height); 1363 - 1364 - // Update and draw all particles 1365 - firehoseParticles = firehoseParticles.filter(particle => { 1366 - const alive = particle.update(); 1367 - if (alive) { 1368 - particle.draw(firehoseCtx); 1369 - } else { 1370 - // Particle reached destination - pulse the identity/PDS 1371 - pulseIdentity(); 1372 - 1373 - // If this was a delete event, lazily check if app should be removed 1374 - if (particle.metadata && particle.metadata.action === 'delete') { 1375 - maybeRemoveAppCircle(particle.metadata.namespace); 1376 - } 1377 - } 1378 - return alive; 1379 - }); 1380 - 1381 - if (isWatchingLive) { 1382 - firehoseAnimationId = requestAnimationFrame(animateFirehoseParticles); 1383 - } 1384 - } 1385 - 1386 - function pulseIdentity() { 1387 - const identity = document.querySelector('.identity'); 1388 - if (identity) { 1389 - // Subtle but visible pulse with contextual glow 1390 - const textColor = getComputedStyle(document.documentElement).getPropertyValue('--text').trim(); 1391 - identity.style.transition = 'all 0.5s cubic-bezier(0.4, 0, 0.2, 1)'; 1392 - // Maintain centering transform while applying gentle scale 1393 - identity.style.transform = 'translate(-50%, -50%) scale(1.03)'; 1394 - identity.style.boxShadow = `0 0 25px ${textColor}50, 0 0 45px ${textColor}25`; 1395 - 1396 - setTimeout(() => { 1397 - identity.style.transition = 'all 0.7s cubic-bezier(0.4, 0, 0.2, 1)'; 1398 - identity.style.transform = 'translate(-50%, -50%)'; 1399 - identity.style.boxShadow = ''; 1400 - }, 400); 1401 - } 1402 - } 1403 - 1404 - async function fetchRecordDetails(pds, did, collection, rkey) { 1405 - try { 1406 - const response = await fetch( 1407 - `/api/record?pds=${encodeURIComponent(pds)}&did=${encodeURIComponent(did)}&collection=${encodeURIComponent(collection)}&rkey=${encodeURIComponent(rkey)}` 1408 - ); 1409 - const data = await response.json(); 1410 - if (data.error) return null; 1411 - return data.value; 1412 - } catch (e) { 1413 - console.error('Error fetching record:', e); 1414 - return null; 1415 - } 1416 - } 1417 - 1418 - function formatToastMessage(action, collection, record) { 1419 - const actionText = { 1420 - 'create': 'created', 1421 - 'update': 'updated', 1422 - 'delete': 'deleted' 1423 - }[action] || action; 1424 - 1425 - // If we don't have record details, fall back to basic message with code-formatted collection 1426 - if (!record) { 1427 - return { 1428 - action: `${actionText} record`, 1429 - details: `<code style="background: var(--bg); padding: 0.1rem 0.3rem; border-radius: 2px; font-size: 0.6rem;">${collection}</code>` 1430 - }; 1431 - } 1432 - 1433 - // Format based on collection type 1434 - if (collection === 'app.bsky.feed.post') { 1435 - const text = record.text || ''; 1436 - const preview = text.length > 50 ? text.substring(0, 50) + '...' : text; 1437 - return { 1438 - action: `${actionText} post`, 1439 - details: preview || 'no text' 1440 - }; 1441 - } else if (collection === 'app.bsky.feed.like') { 1442 - return { 1443 - action: `${actionText} like`, 1444 - details: '' 1445 - }; 1446 - } else if (collection === 'app.bsky.feed.repost') { 1447 - return { 1448 - action: `${actionText} repost`, 1449 - details: '' 1450 - }; 1451 - } else if (collection === 'app.bsky.graph.follow') { 1452 - return { 1453 - action: `${actionText} follow`, 1454 - details: '' 1455 - }; 1456 - } else if (collection === 'app.bsky.actor.profile') { 1457 - const displayName = record.displayName || ''; 1458 - return { 1459 - action: `${actionText} profile`, 1460 - details: displayName || 'updated profile' 1461 - }; 1462 - } 1463 - 1464 - // Default for unknown collections with code formatting 1465 - return { 1466 - action: `${actionText} record`, 1467 - details: `<code style="background: var(--bg); padding: 0.1rem 0.3rem; border-radius: 2px; font-size: 0.6rem;">${collection}</code>` 1468 - }; 1469 - } 1470 - 1471 - async function showFirehoseToast(event) { 1472 - const toast = document.getElementById('firehoseToast'); 1473 - const actionEl = toast.querySelector('.firehose-toast-action'); 1474 - const collectionEl = toast.querySelector('.firehose-toast-collection'); 1475 - const linkEl = document.getElementById('firehoseToastLink'); 1476 - 1477 - // Hide link for delete events, show for others 1478 - if (event.action === 'delete') { 1479 - linkEl.style.display = 'none'; 1480 - } else { 1481 - linkEl.style.display = 'inline-block'; 1482 - // Build PDS link for the record 1483 - if (globalPds && event.did && event.collection && event.rkey) { 1484 - const recordUrl = `${globalPds}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(event.did)}&collection=${encodeURIComponent(event.collection)}&rkey=${encodeURIComponent(event.rkey)}`; 1485 - linkEl.href = recordUrl; 1486 - } 1487 - } 1488 - 1489 - // Fetch record details if available (skip for deletes) 1490 - let record = null; 1491 - if (event.action !== 'delete' && event.rkey && globalPds) { 1492 - record = await fetchRecordDetails(globalPds, event.did, event.collection, event.rkey); 1493 - } 1494 - 1495 - const formatted = formatToastMessage(event.action, event.collection, record); 1496 - 1497 - actionEl.textContent = formatted.action; 1498 - collectionEl.innerHTML = formatted.details; 1499 - 1500 - toast.classList.add('visible'); 1501 - setTimeout(() => { 1502 - toast.classList.remove('visible'); 1503 - }, 4000); // Slightly longer to read details 1504 - } 1505 - 1506 - function getParticleColor() { 1507 - // Use theme-aware color that represents data flow 1508 - // Get the text color from CSS variables and use it with reduced opacity 1509 - const textColor = getComputedStyle(document.documentElement).getPropertyValue('--text').trim(); 1510 - 1511 - // If we can parse it as rgb, use it; otherwise fall back to a neutral color 1512 - if (textColor.startsWith('rgb')) { 1513 - // Extract RGB values and return hex 1514 - const match = textColor.match(/(\d+),\s*(\d+),\s*(\d+)/); 1515 - if (match) { 1516 - const r = parseInt(match[1]); 1517 - const g = parseInt(match[2]); 1518 - const b = parseInt(match[3]); 1519 - return '#' + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1); 1520 - } 1521 - } 1522 - 1523 - // Fallback: soft blue-gray that works in both themes and represents "information flow" 1524 - return '#8ba4b8'; 1525 - } 1526 - 1527 - function createFirehoseParticle(event) { 1528 - // Get target identity/PDS position (where data is written) 1529 - const identity = document.querySelector('.identity'); 1530 - if (!identity) return; 1531 - 1532 - const identityRect = identity.getBoundingClientRect(); 1533 - const endX = identityRect.left + identityRect.width / 2; 1534 - const endY = identityRect.top + identityRect.height / 2; 1535 - 1536 - // Get source app circle position (where the action happened) 1537 - let appCircle = document.querySelector(`[data-namespace="${event.namespace}"]`); 1538 - 1539 - // If app circle doesn't exist and this is a create event, add it dynamically 1540 - if (!appCircle && event.action === 'create') { 1541 - // Reverse namespace for URL (app.at-me -> at-me.app) 1542 - const rawDisplayName = event.namespace.split('.').reverse().join('.'); 1543 - const displayName = applyDomainRedirect(rawDisplayName); 1544 - const url = `https://${displayName}`; 1545 - 1546 - // Add the app circle with the collection from the event 1547 - if (globalApps && !globalApps[event.namespace]) { 1548 - globalApps[event.namespace] = [event.collection]; 1549 - addAppCircle(event.namespace, url); 1550 - appCircle = document.querySelector(`[data-namespace="${event.namespace}"]`); 1551 - } 1552 - } 1553 - 1554 - let startX, startY; 1555 - if (appCircle) { 1556 - // App circle exists - start from there 1557 - const appRect = appCircle.getBoundingClientRect(); 1558 - startX = appRect.left + appRect.width / 2; 1559 - startY = appRect.top + appRect.height / 2; 1560 - } else { 1561 - // No app circle (shouldn't happen for creates, but fallback) 1562 - // Start from identity/PDS and just pulse it 1563 - startX = endX; 1564 - startY = endY; 1565 - } 1566 - 1567 - // Create particle (flows from app TO PDS, or pulses at PDS if no app circle) 1568 - // Color represents data flow, not specific to action type 1569 - const particle = new FirehoseParticle( 1570 - startX, startY, 1571 - endX, endY, 1572 - getParticleColor(), 1573 - { 1574 - action: event.action, 1575 - collection: event.collection, 1576 - namespace: event.namespace 1577 - } 1578 - ); 1579 - 1580 - firehoseParticles.push(particle); 1581 - } 1582 - 1583 - function connectFirehose() { 1584 - if (!did || firehoseEventSource) return; 1585 - 1586 - const url = `/api/firehose/watch?did=${encodeURIComponent(did)}`; 1587 - firehoseEventSource = new EventSource(url); 1588 - 1589 - const watchBtn = document.getElementById('watchLiveBtn'); 1590 - const watchLabel = watchBtn.querySelector('.watch-label'); 1591 - 1592 - firehoseEventSource.onopen = () => { 1593 - watchLabel.textContent = 'watching...'; 1594 - watchBtn.classList.add('active'); 1595 - }; 1596 - 1597 - firehoseEventSource.onmessage = (e) => { 1598 - try { 1599 - const data = JSON.parse(e.data); 1600 - 1601 - // Skip connection message 1602 - if (data.type === 'connected') return; 1603 - 1604 - // Create particle animation 1605 - createFirehoseParticle(data); 1606 - 1607 - // Show toast notification 1608 - showFirehoseToast(data); 1609 - } catch (error) { 1610 - console.error('Error processing firehose message:', error); 1611 - } 1612 - }; 1613 - 1614 - firehoseEventSource.onerror = (error) => { 1615 - console.error('Firehose error:', error); 1616 - watchLabel.textContent = 'connection error'; 1617 - 1618 - // Attempt to reconnect after delay 1619 - if (isWatchingLive) { 1620 - setTimeout(() => { 1621 - if (firehoseEventSource) { 1622 - firehoseEventSource.close(); 1623 - firehoseEventSource = null; 1624 - } 1625 - if (isWatchingLive) { 1626 - watchLabel.textContent = 'reconnecting...'; 1627 - connectFirehose(); 1628 - } 1629 - }, 3000); 1630 - } 1631 - }; 1632 - } 1633 - 1634 - function disconnectFirehose() { 1635 - if (firehoseEventSource) { 1636 - firehoseEventSource.close(); 1637 - firehoseEventSource = null; 1638 - } 1639 - 1640 - if (firehoseAnimationId) { 1641 - cancelAnimationFrame(firehoseAnimationId); 1642 - firehoseAnimationId = null; 1643 - } 1644 - 1645 - firehoseParticles = []; 1646 - if (firehoseCtx) { 1647 - firehoseCtx.clearRect(0, 0, firehoseCanvas.width, firehoseCanvas.height); 1648 - } 1649 - } 1650 - 1651 - // Toggle watch live 1652 - document.addEventListener('DOMContentLoaded', () => { 1653 - const watchBtn = document.getElementById('watchLiveBtn'); 1654 - if (!watchBtn) return; 1655 - 1656 - const watchLabel = watchBtn.querySelector('.watch-label'); 1657 - 1658 - function startWatching() { 1659 - if (isWatchingLive) return; 1660 - 1661 - isWatchingLive = true; 1662 - watchLabel.textContent = 'connecting...'; 1663 - initFirehoseCanvas(); 1664 - connectFirehose(); 1665 - animateFirehoseParticles(); 1666 - 1667 - // Update URL 1668 - const url = new URL(window.location); 1669 - url.searchParams.set('watching', 'true'); 1670 - window.history.replaceState({}, '', url); 1671 - } 1672 - 1673 - function stopWatching() { 1674 - if (!isWatchingLive) return; 1675 - 1676 - isWatchingLive = false; 1677 - watchLabel.textContent = 'watch live'; 1678 - watchBtn.classList.remove('active'); 1679 - disconnectFirehose(); 1680 - 1681 - // Clean up canvas 1682 - if (firehoseCanvas) { 1683 - firehoseCanvas.remove(); 1684 - firehoseCanvas = null; 1685 - firehoseCtx = null; 1686 - } 1687 - 1688 - // Update URL 1689 - const url = new URL(window.location); 1690 - url.searchParams.delete('watching'); 1691 - window.history.replaceState({}, '', url); 1692 - } 1693 - 1694 - watchBtn.addEventListener('click', () => { 1695 - if (isWatchingLive) { 1696 - stopWatching(); 1697 - } else { 1698 - startWatching(); 1699 - } 1700 - }); 1701 - 1702 - // Check for watching parameter on load 1703 - const urlParams = new URLSearchParams(window.location.search); 1704 - if (urlParams.get('watching') === 'true') { 1705 - startWatching(); 1706 - } 1707 - }); 1708 - 1709 - // ============================================================================ 1710 - // GUESTBOOK FEATURE 1711 - // ============================================================================ 1712 - 1713 - let isAuthenticated = false; 1714 - let authenticatedDid = null; // The DID of the logged-in user 1715 - let authenticatedHandle = null; // The handle of the logged-in user 1716 - let authenticatedAvatar = null; // The avatar of the logged-in user 1717 - let hasRecords = false; 1718 - let viewedHandle = null; // Handle of the person whose page we're viewing 1719 - let viewedAvatar = null; // Avatar of the person whose page we're viewing 1720 - 1721 - // Function to dynamically add an app circle to the UI 1722 - function addAppCircle(namespace, url) { 1723 - // Check if app circle DOM element already exists 1724 - const existingCircle = document.querySelector(`[data-namespace="${namespace}"]`); 1725 - if (existingCircle) { 1726 - return; // already rendered 1727 - } 1728 - 1729 - // Add to globalApps if not already there (preserve any existing collections) 1730 - if (!globalApps) globalApps = {}; 1731 - if (!globalApps[namespace]) { 1732 - globalApps[namespace] = []; // No collections yet 1733 - } 1734 - 1735 - const field = document.getElementById('field'); 1736 - const appViews = document.querySelectorAll('.app-view'); 1737 - const appNames = Object.keys(globalApps).filter(k => k !== '_circleSize').sort(); 1738 - const appCount = appNames.length; 1739 - const appIndex = appNames.indexOf(namespace); 1740 - 1741 - if (appIndex === -1) return; // namespace not found in sorted list 1742 - 1743 - // Recalculate positions for all apps 1744 - const vmin = Math.min(window.innerWidth, window.innerHeight); 1745 - const isMobile = window.innerWidth < 768; 1746 - 1747 - let circleSize = globalApps._circleSize || 50; 1748 - let radius; 1749 - 1750 - if (isMobile) { 1751 - if (appCount <= 5) { 1752 - circleSize = Math.min(60, vmin * 0.08); 1753 - radius = vmin * 0.38; 1754 - } else if (appCount <= 10) { 1755 - circleSize = Math.min(50, vmin * 0.07); 1756 - radius = vmin * 0.4; 1757 - } else if (appCount <= 20) { 1758 - circleSize = Math.min(40, vmin * 0.055); 1759 - radius = vmin * 0.42; 1760 - } else { 1761 - circleSize = Math.min(32, vmin * 0.045); 1762 - radius = vmin * 0.44; 1763 - } 1764 - circleSize = Math.max(circleSize, 28); 1765 - radius = Math.max(radius, 120); 1766 - } else { 1767 - if (appCount <= 5) { 1768 - circleSize = Math.min(70, vmin * 0.1); 1769 - } else if (appCount <= 10) { 1770 - circleSize = Math.min(60, vmin * 0.09); 1771 - } else if (appCount <= 20) { 1772 - circleSize = Math.min(50, vmin * 0.07); 1773 - } else { 1774 - circleSize = Math.min(40, vmin * 0.06); 1775 - } 1776 - circleSize = Math.max(circleSize, 35); 1777 - radius = Math.max(vmin * 0.35, 150); 1778 - } 1779 - 1780 - globalApps._circleSize = circleSize; 1781 - 1782 - const centerX = window.innerWidth / 2; 1783 - const centerY = window.innerHeight / 2; 1784 - 1785 - // Calculate position for new app 1786 - const angle = (appIndex / appNames.length) * 2 * Math.PI - Math.PI / 2; 1787 - const circleOffset = circleSize / 2; 1788 - const x = centerX + radius * Math.cos(angle) - circleOffset; 1789 - const y = centerY + radius * Math.sin(angle) - circleOffset; 1790 - 1791 - // Create app div 1792 - const div = document.createElement('div'); 1793 - div.className = 'app-view'; 1794 - div.style.left = `${x}px`; 1795 - div.style.top = `${y}px`; 1796 - 1797 - const firstLetter = namespace.split('.')[1]?.[0]?.toUpperCase() || namespace[0].toUpperCase(); 1798 - 1799 - // Reverse namespace for display (app.at-me -> at-me.app) 1800 - const rawDisplayName = namespace.split('.').reverse().join('.'); 1801 - const displayName = applyDomainRedirect(rawDisplayName); 1802 - 1803 - div.innerHTML = ` 1804 - <div class="app-circle" data-namespace="${namespace}" style="width: ${circleSize}px; height: ${circleSize}px; font-size: ${circleSize * 0.4}px;">${firstLetter}</div> 1805 - <a href="${url}" target="_blank" rel="noopener noreferrer" class="app-name" data-url="${url}">${displayName} ↗</a> 1806 - `; 1807 - 1808 - field.appendChild(div); 1809 - 1810 - // Apply filter if this app is hidden 1811 - if (hiddenApps.has(namespace)) { 1812 - div.classList.add('filtered'); 1813 - } 1814 - 1815 - // Fetch avatar 1816 - fetchAppAvatar(namespace).then(avatarUrl => { 1817 - if (avatarUrl) { 1818 - const circle = div.querySelector('.app-circle'); 1819 - circle.innerHTML = `<img src="${avatarUrl}" class="app-logo" alt="${namespace}" />`; 1820 - } 1821 - }); 1822 - 1823 - // Add click handler 1824 - div.addEventListener('click', () => { 1825 - const detail = document.getElementById('detail'); 1826 - const collections = globalApps[namespace] || []; 1827 - 1828 - detail.innerHTML = ` 1829 - <button class="detail-close" id="detailClose">×</button> 1830 - <h3><a href="${url}" target="_blank" rel="noopener noreferrer" style="color: var(--text); text-decoration: none; border-bottom: 1px solid var(--border);">${displayName} ↗</a></h3> 1831 - <div class="subtitle">guestbook for your PDS</div> 1832 - <div class="tree-item" data-lexicon="app.at-me.visit"> 1833 - <div class="tree-item-header"> 1834 - <span>visit</span> 1835 - <span class="tree-item-count">loading...</span> 1836 - </div> 1837 - </div> 1838 - `; 1839 - detail.classList.add('visible'); 1840 - 1841 - document.getElementById('detailClose').addEventListener('click', (e) => { 1842 - e.stopPropagation(); 1843 - detail.classList.remove('visible'); 1844 - }); 1845 - 1846 - // Fetch record count 1847 - const collection = 'app.at-me.visit'; 1848 - fetch(`${globalPds}/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=${collection}&limit=1`) 1849 - .then(r => r.json()) 1850 - .then(data => { 1851 - const item = detail.querySelector(`[data-lexicon="${collection}"]`); 1852 - if (item) { 1853 - const countSpan = item.querySelector('.tree-item-count'); 1854 - countSpan.textContent = data.records?.length > 0 ? 'has records' : 'empty'; 1855 - } 1856 - }) 1857 - .catch(e => { 1858 - console.error('Error fetching count for', collection, e); 1859 - const item = detail.querySelector(`[data-lexicon="${collection}"]`); 1860 - if (item) { 1861 - const countSpan = item.querySelector('.tree-item-count'); 1862 - countSpan.textContent = 'error'; 1863 - } 1864 - }); 1865 - 1866 - // Add click handler to expand and show records 1867 - detail.querySelector('.tree-item[data-lexicon]').addEventListener('click', (e) => { 1868 - e.stopPropagation(); 1869 - const item = e.currentTarget; 1870 - const lexicon = item.dataset.lexicon; 1871 - const existingContent = item.querySelector('.collection-content'); 1872 - 1873 - if (existingContent) { 1874 - existingContent.remove(); 1875 - return; 1876 - } 1877 - 1878 - // Create container for content 1879 - const contentDiv = document.createElement('div'); 1880 - contentDiv.className = 'collection-content'; 1881 - contentDiv.innerHTML = ` 1882 - <div class="collection-view-content"> 1883 - <div class="collection-view records-view active"> 1884 - <div class="loading">loading records...</div> 1885 - </div> 1886 - </div> 1887 - `; 1888 - item.appendChild(contentDiv); 1889 - 1890 - const recordsView = contentDiv.querySelector('.records-view'); 1891 - 1892 - // Load records 1893 - fetch(`${globalPds}/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=${lexicon}&limit=10`) 1894 - .then(r => r.json()) 1895 - .then(data => { 1896 - if (data.records && data.records.length > 0) { 1897 - let recordsHtml = ''; 1898 - data.records.forEach((record, idx) => { 1899 - const json = JSON.stringify(record.value, null, 2); 1900 - const recordId = `record-${Date.now()}-${idx}`; 1901 - recordsHtml += ` 1902 - <div class="record"> 1903 - <div class="record-header"> 1904 - <span class="record-label">record</span> 1905 - <button class="copy-btn" data-content="${encodeURIComponent(json)}" data-record-id="${recordId}">copy</button> 1906 - </div> 1907 - <div class="record-content"> 1908 - <pre>${json}</pre> 1909 - </div> 1910 - </div> 1911 - `; 1912 - }); 1913 - recordsView.innerHTML = recordsHtml; 1914 - 1915 - // Add copy button handlers 1916 - recordsView.addEventListener('click', (e) => { 1917 - if (e.target.classList.contains('copy-btn')) { 1918 - e.stopPropagation(); 1919 - const copyBtn = e.target; 1920 - const content = decodeURIComponent(copyBtn.dataset.content); 1921 - 1922 - navigator.clipboard.writeText(content).then(() => { 1923 - const originalText = copyBtn.textContent; 1924 - copyBtn.textContent = 'copied!'; 1925 - copyBtn.classList.add('copied'); 1926 - setTimeout(() => { 1927 - copyBtn.textContent = originalText; 1928 - copyBtn.classList.remove('copied'); 1929 - }, 1500); 1930 - }).catch(err => { 1931 - console.error('Failed to copy:', err); 1932 - copyBtn.textContent = 'error'; 1933 - setTimeout(() => { 1934 - copyBtn.textContent = 'copy'; 1935 - }, 1500); 1936 - }); 1937 - } 1938 - }); 1939 - } else { 1940 - recordsView.innerHTML = '<div class="record">no records found</div>'; 1941 - } 1942 - }) 1943 - .catch(e => { 1944 - console.error('Error fetching records:', e); 1945 - recordsView.innerHTML = '<div class="record">error loading records</div>'; 1946 - }); 1947 - }); 1948 - }); 1949 - 1950 - // Reposition all existing apps to make room 1951 - repositionAppCircles(); 1952 - } 1953 - 1954 - // Function to remove an app circle from the UI 1955 - function removeAppCircle(namespace) { 1956 - // Find and remove the DOM element 1957 - const appElement = document.querySelector(`.app-view [data-namespace="${namespace}"]`)?.closest('.app-view'); 1958 - if (appElement) { 1959 - appElement.remove(); 1960 - } 1961 - 1962 - // Remove from globalApps 1963 - if (globalApps && globalApps[namespace]) { 1964 - delete globalApps[namespace]; 1965 - } 1966 - 1967 - // Reposition remaining circles 1968 - repositionAppCircles(); 1969 - } 1970 - 1971 - // Check if a namespace still has records (any collection with count > 0) 1972 - async function namespaceHasRecords(namespace) { 1973 - if (!globalApps || !globalApps[namespace] || !globalPds) { 1974 - return false; 1975 - } 1976 - 1977 - // Check each collection in this namespace 1978 - for (const collection of globalApps[namespace]) { 1979 - try { 1980 - const url = `${globalPds}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(did)}&collection=${encodeURIComponent(collection)}&limit=1`; 1981 - const response = await fetch(url); 1982 - if (response.ok) { 1983 - const data = await response.json(); 1984 - if (data.records && data.records.length > 0) { 1985 - return true; // Found at least one record 1986 - } 1987 - } 1988 - } catch (e) { 1989 - console.error(`Error checking records for ${collection}:`, e); 1990 - } 1991 - } 1992 - return false; 1993 - } 1994 - 1995 - // Lazily check and remove app circle if namespace has no more records 1996 - async function maybeRemoveAppCircle(namespace) { 1997 - const hasRecords = await namespaceHasRecords(namespace); 1998 - if (!hasRecords) { 1999 - removeAppCircle(namespace); 2000 - } 2001 - } 2002 - 2003 - // Check auth status on page load 2004 - async function checkAuthStatus() { 2005 - try { 2006 - const response = await fetch('/api/auth/status'); 2007 - const data = await response.json(); 2008 - const wasAuthenticated = isAuthenticated; 2009 - isAuthenticated = data.authenticated; 2010 - authenticatedDid = data.did || null; 2011 - authenticatedHandle = data.handle || null; 2012 - authenticatedAvatar = data.avatar || null; 2013 - hasRecords = data.hasRecords; 2014 - updateGuestbookButton(); 2015 - 2016 - // Show welcome toast if just authenticated AND viewing own page 2017 - const viewingOwnPage = isAuthenticated && authenticatedDid === did; 2018 - if (isAuthenticated && !wasAuthenticated && viewingOwnPage) { 2019 - const urlParams = new URLSearchParams(window.location.search); 2020 - if (urlParams.get('auth') === 'success') { 2021 - showAuthSuccessToast(); 2022 - // Clean up URL 2023 - urlParams.delete('auth'); 2024 - const newUrl = window.location.pathname + (urlParams.toString() ? '?' + urlParams.toString() : ''); 2025 - window.history.replaceState({}, '', newUrl); 2026 - } 2027 - } 2028 - } catch (e) { 2029 - console.error('[Guestbook] Failed to check auth status:', e); 2030 - } 2031 - } 2032 - 2033 - function showAuthSuccessToast() { 2034 - const toast = document.getElementById('firehoseToast'); 2035 - const actionEl = toast.querySelector('.firehose-toast-action'); 2036 - const collectionEl = toast.querySelector('.firehose-toast-collection'); 2037 - const linkEl = document.getElementById('firehoseToastLink'); 2038 - 2039 - // Hide link for this toast 2040 - linkEl.style.display = 'none'; 2041 - 2042 - actionEl.textContent = 'signed in successfully'; 2043 - collectionEl.innerHTML = 'you may now sign or unsign the guestbook with your identity'; 2044 - 2045 - toast.classList.add('visible'); 2046 - setTimeout(() => { 2047 - toast.classList.remove('visible'); 2048 - }, 5000); 2049 - } 2050 - 2051 - async function checkPageOwnerSignature() { 2052 - // Check if the page owner (did) has signed the guestbook by querying their PDS directly 2053 - try { 2054 - const response = await fetch(`/api/guestbook/check-signature?did=${encodeURIComponent(did)}`); 2055 - if (!response.ok) return false; 2056 - 2057 - const data = await response.json(); 2058 - pageOwnerHasSigned = data.hasSigned; 2059 - 2060 - updateGuestbookSign(); 2061 - return pageOwnerHasSigned; 2062 - } catch (error) { 2063 - console.error('[Guestbook] Error checking page owner signature:', error); 2064 - return false; 2065 - } 2066 - } 2067 - 2068 - function updateGuestbookSign() { 2069 - const sign = document.querySelector('.guestbook-sign'); 2070 - if (sign) { 2071 - sign.textContent = pageOwnerHasSigned ? 'you already signed' : 'sign the guest list'; 2072 - } 2073 - } 2074 - 2075 - function updateGuestbookButton() { 2076 - const signGuestbookBtn = document.getElementById('signGuestbookBtn'); 2077 - if (!signGuestbookBtn) return; 2078 - 2079 - const avatarImg = document.getElementById('guestbookAvatar'); 2080 - const iconSpan = signGuestbookBtn.querySelector('.guestbook-icon'); 2081 - const textSpan = signGuestbookBtn.querySelector('.guestbook-text'); 2082 - 2083 - if (!iconSpan || !textSpan) { 2084 - console.warn('[Guestbook] Button structure missing icon or text span'); 2085 - return; 2086 - } 2087 - 2088 - // Remove all state classes 2089 - signGuestbookBtn.classList.remove('signed', 'pulse'); 2090 - signGuestbookBtn.style.background = ''; 2091 - signGuestbookBtn.style.color = ''; 2092 - signGuestbookBtn.style.opacity = ''; 2093 - signGuestbookBtn.style.cursor = ''; 2094 - 2095 - const viewingOwnPage = isAuthenticated && authenticatedDid === did; 2096 - 2097 - // If page owner has already signed, show "signed" state (regardless of who's viewing) 2098 - if (pageOwnerHasSigned) { 2099 - if (avatarImg) avatarImg.style.display = 'none'; 2100 - iconSpan.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>'; 2101 - iconSpan.style.display = 'flex'; 2102 - textSpan.textContent = 'signed'; 2103 - signGuestbookBtn.classList.add('signed'); 2104 - signGuestbookBtn.setAttribute('title', viewingOwnPage ? 'you\'ve signed the guestbook' : 'this user has signed the guestbook'); 2105 - signGuestbookBtn.disabled = false; // Allow clicking to view/unsign if own page 2106 - } else { 2107 - // NOT signed - ALWAYS show the page owner's avatar (viewedAvatar), regardless of auth state 2108 - if (viewedAvatar && avatarImg) { 2109 - avatarImg.src = viewedAvatar; 2110 - avatarImg.style.display = 'block'; 2111 - iconSpan.style.display = 'none'; 2112 - } else { 2113 - // No avatar - hide both avatar and icon, just show text 2114 - if (avatarImg) avatarImg.style.display = 'none'; 2115 - iconSpan.style.display = 'none'; 2116 - } 2117 - 2118 - textSpan.textContent = 'sign as'; 2119 - 2120 - if (isAuthenticated) { 2121 - if (viewingOwnPage) { 2122 - // Viewing own page, authenticated, ready to sign 2123 - signGuestbookBtn.style.background = 'var(--surface)'; 2124 - signGuestbookBtn.style.color = 'var(--text)'; 2125 - signGuestbookBtn.classList.add('pulse'); 2126 - signGuestbookBtn.setAttribute('title', 'click to sign the guestbook'); 2127 - signGuestbookBtn.disabled = false; 2128 - } else { 2129 - // Authenticated but viewing someone else's page - disabled 2130 - signGuestbookBtn.setAttribute('title', 'visit your own page to sign'); 2131 - signGuestbookBtn.disabled = true; 2132 - signGuestbookBtn.style.opacity = '0.5'; 2133 - signGuestbookBtn.style.cursor = 'not-allowed'; 2134 - } 2135 - } else { 2136 - // Not authenticated - allow them to try to sign as this user 2137 - signGuestbookBtn.setAttribute('title', `sign in as @${viewedHandle || 'user'}`); 2138 - signGuestbookBtn.disabled = false; 2139 - } 2140 - } 2141 - } 2142 - 2143 - function showHandleConfirmation(suggestedHandle) { 2144 - // Create modal overlay 2145 - const overlay = document.createElement('div'); 2146 - overlay.className = 'overlay'; 2147 - overlay.style.display = 'block'; 2148 - 2149 - // Create modal 2150 - const modal = document.createElement('div'); 2151 - modal.className = 'info-modal'; 2152 - modal.style.display = 'block'; 2153 - modal.style.maxWidth = '400px'; 2154 - 2155 - modal.innerHTML = ` 2156 - <h2>confirm identity</h2> 2157 - <p style="margin-bottom: 1rem;">are you <strong>@${suggestedHandle}</strong>?</p> 2158 - <p style="margin-bottom: 1rem; color: var(--text-light); font-size: 0.7rem;">only the owner of this identity can authenticate as @${suggestedHandle}. you'll be redirected to sign in.</p> 2159 - <div style="display: flex; gap: 0.5rem; justify-content: flex-end;"> 2160 - <button id="cancelBtn" style="background: var(--bg);">no, cancel</button> 2161 - <button id="confirmBtn" style="background: var(--surface-hover);">yes, that's me</button> 2162 - </div> 2163 - `; 2164 - 2165 - document.body.appendChild(overlay); 2166 - document.body.appendChild(modal); 2167 - 2168 - const cancelBtn = document.getElementById('cancelBtn'); 2169 - const confirmBtn = document.getElementById('confirmBtn'); 2170 - 2171 - // Cancel 2172 - const closeModal = () => { 2173 - modal.remove(); 2174 - overlay.remove(); 2175 - }; 2176 - 2177 - cancelBtn.addEventListener('click', closeModal); 2178 - overlay.addEventListener('click', closeModal); 2179 - 2180 - // Confirm 2181 - confirmBtn.addEventListener('click', () => { 2182 - // Submit login form with the suggested handle 2183 - const form = document.createElement('form'); 2184 - form.method = 'POST'; 2185 - form.action = '/login'; 2186 - const hiddenInput = document.createElement('input'); 2187 - hiddenInput.type = 'hidden'; 2188 - hiddenInput.name = 'handle'; 2189 - hiddenInput.value = suggestedHandle; 2190 - form.appendChild(hiddenInput); 2191 - document.body.appendChild(form); 2192 - form.submit(); 2193 - }); 2194 - } 2195 - 2196 - function showWatchPrompt(onContinue) { 2197 - // If watch is already enabled, skip the prompt 2198 - if (isWatchingLive) { 2199 - onContinue(); 2200 - return; 2201 - } 2202 - 2203 - const overlay = document.createElement('div'); 2204 - overlay.className = 'overlay'; 2205 - overlay.style.display = 'block'; 2206 - 2207 - const modal = document.createElement('div'); 2208 - modal.className = 'info-modal'; 2209 - modal.style.display = 'block'; 2210 - modal.style.maxWidth = '450px'; 2211 - 2212 - modal.innerHTML = ` 2213 - <h2>watch it happen</h2> 2214 - <p style="margin-bottom: 1rem;">want to see your app activity in real-time?</p> 2215 - <p style="margin-bottom: 1.5rem; color: var(--text-lighter);">turn on "watch live" to see your data flowing into your PDS as it happens.</p> 2216 - <div style="display: flex; gap: 0.5rem; justify-content: flex-end;"> 2217 - <button id="skipBtn" style="background: var(--bg);">skip</button> 2218 - <button id="watchBtn" style="background: var(--surface-hover);">enable watch live</button> 2219 - </div> 2220 - `; 2221 - 2222 - document.body.appendChild(overlay); 2223 - document.body.appendChild(modal); 2224 - 2225 - const skipBtn = document.getElementById('skipBtn'); 2226 - const watchBtn = document.getElementById('watchBtn'); 2227 - 2228 - const closeModal = () => { 2229 - modal.remove(); 2230 - overlay.remove(); 2231 - }; 2232 - 2233 - skipBtn.addEventListener('click', () => { 2234 - closeModal(); 2235 - onContinue(); 2236 - }); 2237 - 2238 - watchBtn.addEventListener('click', () => { 2239 - closeModal(); 2240 - // Enable watch mode 2241 - const watchLiveBtn = document.getElementById('watchLiveBtn'); 2242 - if (watchLiveBtn && !isWatchingLive) { 2243 - watchLiveBtn.click(); 2244 - } 2245 - // Give it a moment to connect 2246 - setTimeout(onContinue, 500); 2247 - }); 2248 - } 2249 - 2250 - function showMessageInputModal(onConfirm) { 2251 - const overlay = document.createElement('div'); 2252 - overlay.className = 'overlay'; 2253 - overlay.style.display = 'block'; 2254 - 2255 - const modal = document.createElement('div'); 2256 - modal.className = 'info-modal'; 2257 - modal.style.display = 'block'; 2258 - modal.style.maxWidth = '450px'; 2259 - 2260 - modal.innerHTML = ` 2261 - <h2>sign the guestbook</h2> 2262 - <p style="margin-bottom: 1rem; color: var(--text-light);">leave an optional message (or leave blank for just a signature)</p> 2263 - <textarea id="guestbookMessageInput" placeholder="share your thoughts..." style="width: 100%; min-height: 80px; padding: 0.75rem; background: var(--bg); border: 1px solid var(--border); border-radius: 4px; color: var(--text); font-family: inherit; font-size: 0.8rem; resize: vertical; margin-bottom: 1rem;" maxlength="280"></textarea> 2264 - <div style="display: flex; gap: 0.5rem; justify-content: flex-end;"> 2265 - <button id="cancelMessageBtn" style="background: var(--bg);">cancel</button> 2266 - <button id="confirmMessageBtn" style="background: var(--surface-hover);">sign</button> 2267 - </div> 2268 - `; 2269 - 2270 - document.body.appendChild(overlay); 2271 - document.body.appendChild(modal); 2272 - 2273 - const textarea = document.getElementById('guestbookMessageInput'); 2274 - const cancelBtn = document.getElementById('cancelMessageBtn'); 2275 - const confirmBtn = document.getElementById('confirmMessageBtn'); 2276 - 2277 - const closeModal = () => { 2278 - modal.remove(); 2279 - overlay.remove(); 2280 - }; 2281 - 2282 - cancelBtn.addEventListener('click', closeModal); 2283 - overlay.addEventListener('click', closeModal); 2284 - 2285 - confirmBtn.addEventListener('click', () => { 2286 - const text = textarea.value.trim(); 2287 - closeModal(); 2288 - onConfirm(text); 2289 - }); 2290 - 2291 - // Focus the textarea 2292 - setTimeout(() => textarea.focus(), 100); 2293 - } 2294 - 2295 - function showUnsignModal() { 2296 - const overlay = document.createElement('div'); 2297 - overlay.className = 'overlay'; 2298 - overlay.style.display = 'block'; 2299 - 2300 - const modal = document.createElement('div'); 2301 - modal.className = 'info-modal'; 2302 - modal.style.display = 'block'; 2303 - modal.style.maxWidth = '400px'; 2304 - 2305 - modal.innerHTML = ` 2306 - <h2>unsign guestbook</h2> 2307 - <p style="margin-bottom: 1rem;">you've already signed the guestbook. want to delete your visit record?</p> 2308 - <div style="display: flex; gap: 0.5rem; justify-content: flex-end;"> 2309 - <button id="cancelUnsignBtn" style="background: var(--bg);">cancel</button> 2310 - <button id="confirmUnsignBtn" style="background: var(--surface-hover);">delete record</button> 2311 - </div> 2312 - `; 2313 - 2314 - document.body.appendChild(overlay); 2315 - document.body.appendChild(modal); 2316 - 2317 - const cancelBtn = document.getElementById('cancelUnsignBtn'); 2318 - const confirmBtn = document.getElementById('confirmUnsignBtn'); 2319 - 2320 - const closeModal = () => { 2321 - modal.remove(); 2322 - overlay.remove(); 2323 - }; 2324 - 2325 - cancelBtn.addEventListener('click', closeModal); 2326 - overlay.addEventListener('click', closeModal); 2327 - 2328 - confirmBtn.addEventListener('click', async () => { 2329 - // Close unsign modal first 2330 - closeModal(); 2331 - 2332 - // Show watch prompt before deleting 2333 - showWatchPrompt(async () => { 2334 - // Perform deletion 2335 - try { 2336 - const response = await fetch('/api/sign-guestbook', { 2337 - method: 'DELETE' 2338 - }); 2339 - 2340 - const data = await response.json(); 2341 - 2342 - if (data.success) { 2343 - // Refresh page owner signature status and auth status to update button 2344 - await checkPageOwnerSignature(); 2345 - await checkAuthStatus(); 2346 - } else { 2347 - throw new Error(data.error || 'Unknown error'); 2348 - } 2349 - } catch (error) { 2350 - console.error('[Guestbook] Error unsigning:', error); 2351 - } 2352 - }); 2353 - }); 2354 - } 2355 - 2356 - document.addEventListener('DOMContentLoaded', async () => { 2357 - const signGuestbookBtn = document.getElementById('signGuestbookBtn'); 2358 - if (!signGuestbookBtn) { 2359 - console.error('[Guestbook] Sign guestbook button not found!'); 2360 - return; 2361 - } 2362 - 2363 - // Check if page owner has signed (no auth required) 2364 - await checkPageOwnerSignature(); 2365 - 2366 - // Check auth status on load 2367 - checkAuthStatus(); 2368 - 2369 - signGuestbookBtn.addEventListener('click', async () => { 2370 - const viewingOwnPage = isAuthenticated && authenticatedDid === did; 2371 - 2372 - // Only allow actions if viewing own page OR not authenticated (to show login) 2373 - if (!viewingOwnPage && isAuthenticated) { 2374 - // Authenticated but viewing someone else's page - do nothing 2375 - return; 2376 - } 2377 - 2378 - // If page owner already signed, handle unsigning or identity confirmation 2379 - if (pageOwnerHasSigned) { 2380 - if (!isAuthenticated) { 2381 - // Unauthenticated user - show identity confirmation to sign in and then unsign 2382 - if (viewedHandle) { 2383 - showHandleConfirmation(viewedHandle); 2384 - } 2385 - return; 2386 - } else if (viewingOwnPage) { 2387 - // Authenticated as page owner - show unsign modal 2388 - showUnsignModal(); 2389 - return; 2390 - } 2391 - // If authenticated as someone else, the button is disabled, so this shouldn't be reached 2392 - } 2393 - 2394 - // If not authenticated and page owner hasn't signed, show confirmation to sign in and then sign 2395 - if (!isAuthenticated) { 2396 - if (viewedHandle) { 2397 - showHandleConfirmation(viewedHandle); 2398 - } 2399 - return; 2400 - } 2401 - 2402 - // Authenticated and viewing own page - show message input, then watch prompt, then sign 2403 - if (viewingOwnPage) { 2404 - showMessageInputModal(async (messageText) => { 2405 - showWatchPrompt(async () => { 2406 - signGuestbookBtn.disabled = true; 2407 - const iconSpan = signGuestbookBtn.querySelector('.guestbook-icon'); 2408 - const textSpan = signGuestbookBtn.querySelector('.guestbook-text'); 2409 - 2410 - if (iconSpan && textSpan) { 2411 - const originalIcon = iconSpan.innerHTML; 2412 - const originalText = textSpan.textContent; 2413 - iconSpan.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg>'; 2414 - textSpan.textContent = 'signing...'; 2415 - 2416 - try { 2417 - const body = {}; 2418 - if (messageText && messageText.trim()) { 2419 - body.text = messageText.trim(); 2420 - } 2421 - 2422 - const response = await fetch('/api/sign-guestbook', { 2423 - method: 'POST', 2424 - headers: { 2425 - 'Content-Type': 'application/json', 2426 - }, 2427 - body: JSON.stringify(body) 2428 - }); 2429 - 2430 - const data = await response.json(); 2431 - 2432 - if (data.success) { 2433 - iconSpan.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>'; 2434 - textSpan.textContent = 'signed!'; 2435 - // Refresh page owner signature status and auth status to update button 2436 - await checkPageOwnerSignature(); 2437 - await checkAuthStatus(); 2438 - setTimeout(() => { 2439 - signGuestbookBtn.disabled = false; 2440 - }, 1000); 2441 - } else { 2442 - throw new Error(data.error || 'Unknown error'); 2443 - } 2444 - } catch (error) { 2445 - console.error('[Guestbook] Error signing guestbook:', error); 2446 - iconSpan.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" x2="12" y1="8" y2="12"/><line x1="12" x2="12.01" y1="16" y2="16"/></svg>'; 2447 - textSpan.textContent = 'error'; 2448 - setTimeout(() => { 2449 - iconSpan.innerHTML = originalIcon; 2450 - textSpan.textContent = originalText; 2451 - signGuestbookBtn.disabled = false; 2452 - }, 2000); 2453 - } 2454 - } 2455 - }); 2456 - }); 2457 - } 2458 - }); 2459 - 2460 - // View guestbook button handler 2461 - const viewGuestbookBtn = document.getElementById('viewGuestbookBtn'); 2462 - const guestbookModal = document.getElementById('guestbookModal'); 2463 - const guestbookClose = document.getElementById('guestbookClose'); 2464 - const guestbookContent = document.getElementById('guestbookContent'); 2465 - 2466 - if (viewGuestbookBtn && guestbookModal && guestbookClose && guestbookContent) { 2467 - viewGuestbookBtn.addEventListener('click', () => { 2468 - showGuestbookModal(); 2469 - }); 2470 - 2471 - guestbookClose.addEventListener('click', () => { 2472 - guestbookModal.classList.remove('visible'); 2473 - }); 2474 - 2475 - // Add Escape key handler for closing guest list modal 2476 - document.addEventListener('keydown', (e) => { 2477 - if (e.key === 'Escape' && guestbookModal.classList.contains('visible')) { 2478 - guestbookModal.classList.remove('visible'); 2479 - } 2480 - }); 2481 - } 2482 - }); 2483 - 2484 - async function showGuestbookModal() { 2485 - const guestbookModal = document.getElementById('guestbookModal'); 2486 - const guestbookContent = document.getElementById('guestbookContent'); 2487 - 2488 - if (!guestbookModal || !guestbookContent) return; 2489 - 2490 - // Show modal with loading state 2491 - guestbookModal.classList.add('visible'); 2492 - guestbookContent.innerHTML = ` 2493 - <div class="guestbook-paper"> 2494 - <div class="guestbook-loading"> 2495 - <div class="guestbook-loading-spinner"></div> 2496 - <div class="guestbook-loading-text">loading signatures...</div> 2497 - </div> 2498 - </div> 2499 - `; 2500 - 2501 - try { 2502 - // Fetch ALL signatures globally (not filtered by DID) 2503 - const response = await fetch(`/api/guestbook/signatures`); 2504 - if (!response.ok) { 2505 - throw new Error('Failed to fetch signatures'); 2506 - } 2507 - 2508 - const signatures = await response.json(); 2509 - 2510 - if (signatures.length === 0) { 2511 - guestbookContent.innerHTML = ` 2512 - <div class="guestbook-paper"> 2513 - <h1 class="guestbook-paper-title">the @me guest list</h1> 2514 - <p class="guestbook-paper-subtitle">visitors to this application</p> 2515 - <div class="guestbook-empty"> 2516 - <div class="guestbook-empty-text">no signatures yet. be the first to sign!</div> 2517 - </div> 2518 - </div> 2519 - `; 2520 - return; 2521 - } 2522 - 2523 - // Helper function to format timestamp 2524 - const formatDate = (isoString) => { 2525 - if (!isoString) return ''; 2526 - const date = new Date(isoString); 2527 - if (isNaN(date)) return ''; 2528 - const month = String(date.getMonth() + 1).padStart(2, '0'); 2529 - const day = String(date.getDate()).padStart(2, '0'); 2530 - const year = date.getFullYear(); 2531 - return `${month}/${day}/${year}`; 2532 - }; 2533 - 2534 - // Render signatures with paper aesthetic 2535 - let html = ` 2536 - <div class="guestbook-paper"> 2537 - <h1 class="guestbook-paper-title">the @me guest list</h1> 2538 - <p class="guestbook-paper-subtitle">visitors to this application</p> 2539 - <div class="guestbook-tally">${signatures.length} signature${signatures.length !== 1 ? 's' : ''}</div> 2540 - <div class="guestbook-signatures-list"> 2541 - `; 2542 - 2543 - signatures.forEach((sig, index) => { 2544 - const handle = sig.handle || 'unknown'; 2545 - const did = sig.did || 'did:unknown'; 2546 - // Add at:// prefix if not present for pdsls.dev URL 2547 - const atUri = did.startsWith('at://') ? did : `at://${did}`; 2548 - const formattedDate = formatDate(sig.timestamp); 2549 - const pdsHost = 'pdsls.dev'; // Use pdsls.dev for looking up DIDs 2550 - 2551 - html += ` 2552 - <div class="guestbook-paper-signature"> 2553 - <div class="guestbook-did" data-did="${did}" data-index="${index}"> 2554 - ${did} 2555 - <span class="guestbook-did-tooltip">copied!</span> 2556 - </div> 2557 - <div class="guestbook-metadata"> 2558 - ${sig.text ? ` 2559 - <div class="guestbook-message">${sig.text}</div> 2560 - ` : ''} 2561 - <div class="guestbook-metadata-item"> 2562 - <span class="guestbook-metadata-label">handle:</span> 2563 - <span class="guestbook-metadata-value">@${handle}</span> 2564 - </div> 2565 - ${formattedDate ? ` 2566 - <div class="guestbook-metadata-item"> 2567 - <span class="guestbook-metadata-label">signed:</span> 2568 - <span class="guestbook-metadata-value">${formattedDate}</span> 2569 - </div> 2570 - ` : ''} 2571 - <div class="guestbook-metadata-item"> 2572 - <span class="guestbook-metadata-label">bluesky:</span> 2573 - <a href="https://bsky.app/profile/${handle}" target="_blank" rel="noopener noreferrer" class="guestbook-metadata-link">view profile ↗</a> 2574 - </div> 2575 - <div class="guestbook-metadata-item"> 2576 - <span class="guestbook-metadata-label">pdsls.dev:</span> 2577 - <a href="https://${pdsHost}/${atUri}/app.at-me.visit" target="_blank" rel="noopener noreferrer" class="guestbook-metadata-link">view on pdsls.dev ↗</a> 2578 - </div> 2579 - </div> 2580 - </div> 2581 - `; 2582 - }); 2583 - 2584 - html += ` 2585 - </div> 2586 - </div> 2587 - `; 2588 - 2589 - guestbookContent.innerHTML = html; 2590 - 2591 - // Add click handlers to DIDs for copying 2592 - document.querySelectorAll('.guestbook-did').forEach(didElement => { 2593 - didElement.addEventListener('click', async (e) => { 2594 - e.stopPropagation(); 2595 - const did = didElement.dataset.did; 2596 - 2597 - try { 2598 - await navigator.clipboard.writeText(did); 2599 - 2600 - // Add copied class for animation 2601 - didElement.classList.add('copied'); 2602 - 2603 - // Remove after animation 2604 - setTimeout(() => { 2605 - didElement.classList.remove('copied'); 2606 - }, 2000); 2607 - } catch (err) { 2608 - console.error('[Guestbook] Failed to copy DID:', err); 2609 - } 2610 - }); 2611 - }); 2612 - } catch (error) { 2613 - console.error('[Guestbook] Error loading signatures:', error); 2614 - guestbookContent.innerHTML = ` 2615 - <div class="guestbook-paper"> 2616 - <h1 class="guestbook-paper-title">Guestbook</h1> 2617 - <p class="guestbook-paper-subtitle">Visitors to this Personal Data Server</p> 2618 - <div class="guestbook-empty"> 2619 - <div class="guestbook-empty-text">Error loading signatures. Please try again.</div> 2620 - </div> 2621 - </div> 2622 - `; 2623 - } 2624 - }
static/favicon.svg public/favicon.svg
-200
static/login.js
··· 1 - // Check if we're exiting demo mode 2 - const urlParams = new URLSearchParams(window.location.search); 3 - if (urlParams.get('clear_demo') === 'true') { 4 - localStorage.removeItem('atme_did'); 5 - // Clear the query param from the URL 6 - window.history.replaceState({}, document.title, '/'); 7 - } 8 - 9 - // Check for saved session 10 - const savedDid = localStorage.getItem('atme_did'); 11 - if (savedDid) { 12 - document.getElementById('loginForm').classList.add('hidden'); 13 - document.getElementById('restoring').classList.remove('hidden'); 14 - 15 - fetch('/api/restore-session', { 16 - method: 'POST', 17 - headers: { 'Content-Type': 'application/json' }, 18 - body: JSON.stringify({ did: savedDid }) 19 - }).then(r => { 20 - if (r.ok) { 21 - window.location.href = '/'; 22 - } else { 23 - localStorage.removeItem('atme_did'); 24 - document.getElementById('loginForm').classList.remove('hidden'); 25 - document.getElementById('restoring').classList.add('hidden'); 26 - } 27 - }).catch(() => { 28 - localStorage.removeItem('atme_did'); 29 - document.getElementById('loginForm').classList.remove('hidden'); 30 - document.getElementById('restoring').classList.add('hidden'); 31 - }); 32 - } 33 - 34 - // Fetch and cache atmosphere data 35 - async function fetchAtmosphere() { 36 - const CACHE_KEY = 'atme_atmosphere'; 37 - const CACHE_DURATION = 24 * 60 * 60 * 1000; // 24 hours 38 - 39 - const cached = localStorage.getItem(CACHE_KEY); 40 - if (cached) { 41 - const { data, timestamp } = JSON.parse(cached); 42 - if (Date.now() - timestamp < CACHE_DURATION) { 43 - return data; 44 - } 45 - } 46 - 47 - try { 48 - const response = await fetch('https://ufos-api.microcosm.blue/collections?order=dids-estimate&limit=50'); 49 - const json = await response.json(); 50 - 51 - // Group by namespace (first two segments) 52 - const namespaces = {}; 53 - json.collections.forEach(col => { 54 - const parts = col.nsid.split('.'); 55 - if (parts.length >= 2) { 56 - const ns = `${parts[0]}.${parts[1]}`; 57 - if (!namespaces[ns]) { 58 - namespaces[ns] = { 59 - namespace: ns, 60 - dids_total: 0, 61 - records_total: 0, 62 - collections: [] 63 - }; 64 - } 65 - namespaces[ns].dids_total += col.dids_estimate; 66 - namespaces[ns].records_total += col.creates; 67 - namespaces[ns].collections.push(col.nsid); 68 - } 69 - }); 70 - 71 - const data = Object.values(namespaces).sort((a, b) => b.dids_total - a.dids_total).slice(0, 30); 72 - 73 - localStorage.setItem(CACHE_KEY, JSON.stringify({ 74 - data, 75 - timestamp: Date.now() 76 - })); 77 - 78 - return data; 79 - } catch (e) { 80 - console.error('Failed to fetch atmosphere data:', e); 81 - return []; 82 - } 83 - } 84 - 85 - async function fetchAppAvatars(namespaces) { 86 - if (!Array.isArray(namespaces) || !namespaces.length) return {}; 87 - const deduped = [...new Set(namespaces.filter(Boolean))]; 88 - if (!deduped.length) return {}; 89 - 90 - try { 91 - const response = await fetch('/api/avatar/batch', { 92 - method: 'POST', 93 - headers: { 'Content-Type': 'application/json' }, 94 - body: JSON.stringify({ namespaces: deduped }) 95 - }); 96 - if (!response.ok) return {}; 97 - const data = await response.json(); 98 - return data.avatars || {}; 99 - } catch (e) { 100 - return {}; 101 - } 102 - } 103 - 104 - // Render atmosphere 105 - async function renderAtmosphere() { 106 - const data = await fetchAtmosphere(); 107 - if (!data.length) return; 108 - 109 - const atmosphere = document.getElementById('atmosphere'); 110 - const maxSize = Math.max(...data.map(d => d.dids_total)); 111 - 112 - const namespaces = data.map(app => app.namespace); 113 - const avatarPromise = fetchAppAvatars(namespaces); 114 - const orbRegistry = []; 115 - 116 - data.forEach((app, i) => { 117 - const orb = document.createElement('div'); 118 - orb.className = 'app-orb'; 119 - 120 - // Size based on user count (20-80px) 121 - const size = 20 + (app.dids_total / maxSize) * 60; 122 - 123 - // Position in 3D space 124 - const angle = (i / data.length) * Math.PI * 2; 125 - const radius = 250 + (i % 3) * 100; 126 - const y = (i % 5) * 80 - 160; 127 - const x = Math.cos(angle) * radius; 128 - const z = Math.sin(angle) * radius; 129 - 130 - orb.style.width = `${size}px`; 131 - orb.style.height = `${size}px`; 132 - orb.style.left = `calc(50% + ${x}px)`; 133 - orb.style.top = `calc(50% + ${y}px)`; 134 - orb.style.transform = `translateZ(${z}px) translate(-50%, -50%)`; 135 - orb.style.background = `radial-gradient(circle, rgba(255,255,255,0.1), rgba(255,255,255,0.02))`; 136 - orb.style.border = '1px solid rgba(255,255,255,0.1)'; 137 - orb.style.boxShadow = '0 0 20px rgba(255,255,255,0.1)'; 138 - 139 - // Fallback letter 140 - const letter = app.namespace.split('.')[1]?.[0]?.toUpperCase() || app.namespace[0].toUpperCase(); 141 - orb.innerHTML = `<div class="fallback">${letter}</div>`; 142 - 143 - // Tooltip 144 - const tooltip = document.createElement('div'); 145 - tooltip.className = 'app-tooltip'; 146 - const users = app.dids_total >= 1000000 147 - ? `${(app.dids_total / 1000000).toFixed(1)}M users` 148 - : `${(app.dids_total / 1000).toFixed(0)}K users`; 149 - tooltip.textContent = `${app.namespace} • ${users}`; 150 - orb.appendChild(tooltip); 151 - 152 - atmosphere.appendChild(orb); 153 - 154 - orbRegistry.push({ orb, tooltip, namespace: app.namespace }); 155 - }); 156 - 157 - avatarPromise.then(avatarMap => { 158 - orbRegistry.forEach(({ orb, tooltip, namespace }) => { 159 - const avatarUrl = avatarMap[namespace]; 160 - if (avatarUrl) { 161 - orb.innerHTML = `<img src="${avatarUrl}" alt="${namespace}" />`; 162 - orb.appendChild(tooltip); 163 - } 164 - }); 165 - }); 166 - } 167 - 168 - renderAtmosphere(); 169 - 170 - // Info toggle 171 - const infoToggle = document.getElementById('infoToggle'); 172 - if (infoToggle) { 173 - infoToggle.addEventListener('click', () => { 174 - const content = document.getElementById('infoContent'); 175 - const toggle = document.getElementById('infoToggle'); 176 - 177 - if (content && toggle) { 178 - if (content.classList.contains('expanded')) { 179 - content.classList.remove('expanded'); 180 - toggle.textContent = 'what is this?'; 181 - } else { 182 - content.classList.add('expanded'); 183 - toggle.textContent = 'close'; 184 - } 185 - } 186 - }); 187 - } 188 - 189 - // Demo mode 190 - const demoBtn = document.getElementById('demoBtn'); 191 - if (demoBtn) { 192 - demoBtn.addEventListener('click', () => { 193 - // Store demo flag and navigate 194 - sessionStorage.setItem('atme_demo_mode', 'true'); 195 - sessionStorage.setItem('atme_demo_handle', 'bad-example.com'); 196 - 197 - // Navigate to demo - this will trigger the login flow with the demo handle 198 - window.location.href = '/demo'; 199 - }); 200 - }
static/og-image.png public/og-image.png
-31
static/og-image.svg
··· 1 - <svg width="1200" height="630" viewBox="0 0 1200 630" xmlns="http://www.w3.org/2000/svg"> 2 - <!-- Background gradient --> 3 - <defs> 4 - <radialGradient id="bg" cx="50%" cy="50%"> 5 - <stop offset="0%" style="stop-color:#0a0a0f;stop-opacity:1" /> 6 - <stop offset="100%" style="stop-color:#000000;stop-opacity:1" /> 7 - </radialGradient> 8 - </defs> 9 - 10 - <!-- Background --> 11 - <rect width="1200" height="630" fill="url(#bg)"/> 12 - 13 - <!-- Orbital rings --> 14 - <circle cx="600" cy="315" r="180" fill="none" stroke="rgba(255,255,255,0.1)" stroke-width="1"/> 15 - <circle cx="600" cy="315" r="240" fill="none" stroke="rgba(255,255,255,0.05)" stroke-width="1"/> 16 - 17 - <!-- Center circle --> 18 - <circle cx="600" cy="315" r="120" fill="rgba(20,20,25,0.8)" stroke="rgba(255,255,255,0.3)" stroke-width="2"/> 19 - 20 - <!-- @ symbol --> 21 - <text x="600" y="350" font-family="ui-monospace, 'SF Mono', Monaco, monospace" font-size="120" fill="#e5e5e5" text-anchor="middle" font-weight="300">@</text> 22 - 23 - <!-- Title --> 24 - <text x="600" y="480" font-family="ui-monospace, 'SF Mono', Monaco, monospace" font-size="32" fill="#e5e5e5" text-anchor="middle" font-weight="300" letter-spacing="0.05em">explore your atproto identity</text> 25 - 26 - <!-- Small decorative circles representing apps --> 27 - <circle cx="720" cy="260" r="20" fill="rgba(40,40,50,0.6)" stroke="rgba(255,255,255,0.2)" stroke-width="1"/> 28 - <circle cx="480" cy="290" r="18" fill="rgba(40,40,50,0.6)" stroke="rgba(255,255,255,0.2)" stroke-width="1"/> 29 - <circle cx="680" cy="390" r="22" fill="rgba(40,40,50,0.6)" stroke="rgba(255,255,255,0.2)" stroke-width="1"/> 30 - <circle cx="520" cy="350" r="16" fill="rgba(40,40,50,0.6)" stroke="rgba(255,255,255,0.2)" stroke-width="1"/> 31 - </svg>
-195
static/onboarding.js
··· 1 - // Onboarding overlay for first-time users 2 - const ONBOARDING_KEY = 'atme_onboarding_seen'; 3 - 4 - const steps = [ 5 - { 6 - target: '.identity', 7 - title: 'this is you', 8 - description: 'your global identity and handle. your data is hosted at your <a href="https://atproto.com/guides/overview" target="_blank" rel="noopener noreferrer" style="color: inherit; text-decoration: underline;">Personal Data Server (PDS)</a>.', 9 - position: 'bottom' 10 - }, 11 - { 12 - target: '.canvas', 13 - title: 'atproto applications', 14 - description: 'these apps use your global identity to write public records to your PDS. they can also read records you\'ve created.', 15 - position: 'center' 16 - }, 17 - { 18 - target: '.app-view', 19 - title: 'explore your records', 20 - description: 'click any app to see what records it has written to your PDS.', 21 - position: 'bottom' 22 - } 23 - ]; 24 - 25 - let currentStep = 0; 26 - 27 - function showOnboarding() { 28 - const overlay = document.getElementById('onboardingOverlay'); 29 - if (!overlay) return; 30 - 31 - overlay.style.display = 'block'; 32 - setTimeout(() => { 33 - overlay.style.opacity = '1'; 34 - showStep(0); 35 - }, 50); 36 - } 37 - 38 - function hideOnboarding() { 39 - const overlay = document.getElementById('onboardingOverlay'); 40 - const spotlight = document.getElementById('onboardingSpotlight'); 41 - const content = document.getElementById('onboardingContent'); 42 - 43 - if (overlay) { 44 - overlay.style.opacity = '0'; 45 - setTimeout(() => { 46 - overlay.style.display = 'none'; 47 - }, 300); 48 - } 49 - 50 - if (spotlight) spotlight.classList.remove('active'); 51 - if (content) content.classList.remove('active'); 52 - 53 - localStorage.setItem(ONBOARDING_KEY, 'true'); 54 - } 55 - 56 - function showStep(stepIndex) { 57 - if (stepIndex >= steps.length) { 58 - hideOnboarding(); 59 - return; 60 - } 61 - 62 - currentStep = stepIndex; 63 - const step = steps[stepIndex]; 64 - const target = document.querySelector(step.target); 65 - 66 - if (!target) { 67 - console.warn('Onboarding target not found:', step.target); 68 - showStep(stepIndex + 1); 69 - return; 70 - } 71 - 72 - const spotlight = document.getElementById('onboardingSpotlight'); 73 - const content = document.getElementById('onboardingContent'); 74 - 75 - // Position spotlight on target 76 - const rect = target.getBoundingClientRect(); 77 - const padding = step.target === '.canvas' ? 100 : 20; 78 - 79 - spotlight.style.left = `${rect.left - padding}px`; 80 - spotlight.style.top = `${rect.top - padding}px`; 81 - spotlight.style.width = `${rect.width + padding * 2}px`; 82 - spotlight.style.height = `${rect.height + padding * 2}px`; 83 - spotlight.classList.add('active'); 84 - 85 - // Position content 86 - const isLastStep = stepIndex === steps.length - 1; 87 - content.innerHTML = ` 88 - <h3>${step.title}</h3> 89 - <p>${step.description}</p> 90 - <div class="onboarding-actions"> 91 - ${!isLastStep ? '<button id="skipOnboarding" class="onboarding-skip">skip</button>' : ''} 92 - <button id="nextOnboarding" class="onboarding-next"> 93 - ${isLastStep ? 'got it' : 'next'} 94 - </button> 95 - </div> 96 - <div class="onboarding-progress"> 97 - ${steps.map((_, i) => `<span class="${i === stepIndex ? 'active' : i < stepIndex ? 'done' : ''}"></span>`).join('')} 98 - </div> 99 - `; 100 - 101 - // Position content relative to spotlight 102 - let contentTop, contentLeft; 103 - const contentMaxWidth = Math.min(400, window.innerWidth * 0.9); // responsive max-width 104 - const contentHeight = 250; // approximate height 105 - const margin = Math.max(20, window.innerWidth * 0.05); // responsive margin 106 - 107 - if (step.position === 'bottom') { 108 - contentTop = rect.bottom + padding + margin; 109 - contentLeft = rect.left + rect.width / 2; 110 - 111 - // Check if it would go off bottom 112 - if (contentTop + contentHeight > window.innerHeight) { 113 - contentTop = rect.top - padding - contentHeight - margin; 114 - } 115 - } else if (step.position === 'center') { 116 - contentTop = window.innerHeight / 2 - contentHeight / 2; 117 - contentLeft = window.innerWidth / 2; 118 - } else { 119 - contentTop = rect.top - padding - contentHeight - margin; 120 - contentLeft = rect.left + rect.width / 2; 121 - 122 - // Check if it would go off top 123 - if (contentTop < margin) { 124 - contentTop = rect.bottom + padding + margin; 125 - } 126 - } 127 - 128 - // Ensure content stays on screen horizontally 129 - const halfWidth = contentMaxWidth / 2; 130 - if (contentLeft - halfWidth < margin) { 131 - contentLeft = halfWidth + margin; 132 - } else if (contentLeft + halfWidth > window.innerWidth - margin) { 133 - contentLeft = window.innerWidth - halfWidth - margin; 134 - } 135 - 136 - // Ensure content stays on screen vertically 137 - if (contentTop < margin) { 138 - contentTop = margin; 139 - } else if (contentTop + contentHeight > window.innerHeight - margin) { 140 - contentTop = window.innerHeight - contentHeight - margin; 141 - } 142 - 143 - content.style.top = `${contentTop}px`; 144 - content.style.left = `${contentLeft}px`; 145 - content.style.transform = 'translate(-50%, 0)'; 146 - content.classList.add('active'); 147 - 148 - // Add event listeners 149 - const skipBtn = document.getElementById('skipOnboarding'); 150 - if (skipBtn) { 151 - skipBtn.addEventListener('click', hideOnboarding); 152 - } 153 - document.getElementById('nextOnboarding').addEventListener('click', () => { 154 - showStep(stepIndex + 1); 155 - }); 156 - } 157 - 158 - // Initialize onboarding 159 - function initOnboarding() { 160 - const seen = localStorage.getItem(ONBOARDING_KEY); 161 - 162 - if (!seen) { 163 - // Wait for app circles to render 164 - setTimeout(() => { 165 - showOnboarding(); 166 - }, 1000); 167 - } 168 - } 169 - 170 - // ESC key handler 171 - document.addEventListener('keydown', (e) => { 172 - if (e.key === 'Escape') { 173 - const overlay = document.getElementById('onboardingOverlay'); 174 - if (overlay && overlay.style.display === 'block') { 175 - hideOnboarding(); 176 - } 177 - } 178 - }); 179 - 180 - // Help button handler to restart onboarding 181 - window.restartOnboarding = function() { 182 - localStorage.removeItem(ONBOARDING_KEY); 183 - document.getElementById('infoModal').classList.remove('visible'); 184 - document.getElementById('overlay').classList.remove('visible'); 185 - setTimeout(() => { 186 - showOnboarding(); 187 - }, 300); 188 - }; 189 - 190 - // Start onboarding after page loads 191 - if (document.readyState === 'loading') { 192 - document.addEventListener('DOMContentLoaded', initOnboarding); 193 - } else { 194 - initOnboarding(); 195 - }
+168
view.html
··· 1 + <!DOCTYPE html> 2 + <html> 3 + 4 + <head> 5 + <meta charset="UTF-8"> 6 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 7 + <title>@me - explore your atproto identity</title> 8 + <link rel="icon" type="image/svg+xml" href="/favicon.svg"> 9 + 10 + <!-- Open Graph / Facebook --> 11 + <meta property="og:type" content="website"> 12 + <meta property="og:url" content="https://at-me.fly.dev/"> 13 + <meta property="og:title" content="@me - explore your atproto identity"> 14 + <meta property="og:description" 15 + content="visualize your decentralized identity and see what apps have stored data in your Personal Data Server"> 16 + <meta property="og:image" content="https://at-me.fly.dev/static/og-image.png"> 17 + 18 + <!-- Twitter --> 19 + <meta property="twitter:card" content="summary_large_image"> 20 + <meta property="twitter:url" content="https://at-me.fly.dev/"> 21 + <meta property="twitter:title" content="@me - explore your atproto identity"> 22 + <meta property="twitter:description" 23 + content="visualize your decentralized identity and see what apps have stored data in your Personal Data Server"> 24 + <meta property="twitter:image" content="https://at-me.fly.dev/static/og-image.png"> 25 + </head> 26 + 27 + <body> 28 + <a href="/" class="home-btn" title="back to landing"> 29 + <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" 30 + stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 31 + <path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" /> 32 + <polyline points="9 22 9 12 15 12 15 22" /> 33 + </svg> 34 + </a> 35 + <div class="info" id="infoBtn" title="learn about your data"> 36 + <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" 37 + stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 38 + <circle cx="12" cy="12" r="10" /> 39 + <path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" /> 40 + <path d="M12 17h.01" /> 41 + </svg> 42 + </div> 43 + <div class="top-right-buttons"> 44 + <button class="filter-btn" id="filterBtn"> 45 + <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" 46 + stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 47 + <polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3" /> 48 + </svg> 49 + <span class="filter-label-text">filter</span> 50 + <span class="filter-count" id="filterCount" style="display: none;"></span> 51 + </button> 52 + <button class="watch-live-btn" id="watchLiveBtn"> 53 + <span class="watch-indicator"></span> 54 + <span class="watch-label">watch live</span> 55 + </button> 56 + </div> 57 + <div class="filter-panel" id="filterPanel"> 58 + <div class="filter-panel-header"> 59 + <span class="filter-panel-title">show apps</span> 60 + <div class="filter-panel-actions"> 61 + <button type="button" class="filter-action-btn" id="filterShowAll">all</button> 62 + <button type="button" class="filter-action-btn" id="filterHideUnresolved">valid</button> 63 + <button type="button" class="filter-action-btn" id="filterHideAll">none</button> 64 + </div> 65 + </div> 66 + <div class="filter-list" id="filterList"></div> 67 + </div> 68 + <div class="pov-indicator">point of view of <a class="pov-handle" id="povHandle" href="#" target="_blank" 69 + rel="noopener noreferrer"></a></div> 70 + <div class="guestbook-sign">sign the guest list</div> 71 + <div class="guestbook-buttons-container"> 72 + <button class="view-guestbook-btn" id="viewGuestbookBtn" title="view all signatures"> 73 + <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" 74 + stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 75 + <line x1="8" x2="21" y1="6" y2="6" /> 76 + <line x1="8" x2="21" y1="12" y2="12" /> 77 + <line x1="8" x2="21" y1="18" y2="18" /> 78 + <line x1="3" x2="3.01" y1="6" y2="6" /> 79 + <line x1="3" x2="3.01" y1="12" y2="12" /> 80 + <line x1="3" x2="3.01" y1="18" y2="18" /> 81 + </svg> 82 + </button> 83 + <button class="sign-guestbook-btn" id="signGuestbookBtn" title="sign the guestbook"> 84 + <span class="guestbook-icon"> 85 + <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" 86 + stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 87 + <path d="M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H20v20H6.5a2.5 2.5 0 0 1 0-5H20" /> 88 + </svg> 89 + </span> 90 + <span class="guestbook-text">sign guestbook</span> 91 + <img class="guestbook-avatar" id="guestbookAvatar" style="display: none;" /> 92 + </button> 93 + </div> 94 + 95 + <div class="firehose-toast" id="firehoseToast"> 96 + <div class="firehose-toast-action"></div> 97 + <div class="firehose-toast-collection"></div> 98 + <a class='firehose-toast-link' id='firehoseToastLink' href='#' target='_blank' rel='noopener noreferrer'>view 99 + record</a> 100 + </div> 101 + 102 + <div class="overlay" id="overlay"></div> 103 + <div class="info-modal" id="infoModal"> 104 + <h2>this is your data</h2> 105 + <p>this visualization shows your <a href="https://atproto.com/guides/data-repos" target="_blank" 106 + rel="noopener noreferrer" style="color: var(--text); text-decoration: underline;">Personal Data 107 + Server</a> - where your social data actually lives. unlike traditional platforms that lock everything in 108 + their database, your posts, likes, and follows are stored here, on infrastructure you control.</p> 109 + <p>each circle represents an app that writes to your space. <a href="https://bsky.app" target="_blank" 110 + rel="noopener noreferrer" style="color: var(--text); text-decoration: underline;">bluesky</a> for 111 + microblogging. <a href="https://whtwnd.com" target="_blank" rel="noopener noreferrer" 112 + style="color: var(--text); text-decoration: underline;">whitewind</a> for long-form posts. <a 113 + href="https://tangled.org" target="_blank" rel="noopener noreferrer" 114 + style="color: var(--text); text-decoration: underline;">tangled.org</a> for code hosting. they're all 115 + just different views of the same underlying data - <strong>your</strong> data.</p> 116 + <p>this is what "<a href="https://overreacted.io/open-social/" target="_blank" rel="noopener noreferrer" 117 + style="color: var(--text); text-decoration: underline;">open social</a>" means: your followers, your 118 + content, your connections - they all belong to you, not the app. switch apps anytime and take everything 119 + with you. no platform can hold your social graph hostage.</p> 120 + <p style="margin-bottom: 1rem;"><strong>how to explore:</strong> click your avatar in the center to see the 121 + details of your identity. click any app to browse the records it's created in your repository.</p> 122 + <button id="closeInfo">got it</button> 123 + <p 124 + style="margin-top: 1.5rem; padding-top: 1rem; border-top: 1px solid var(--border); font-size: 0.7rem; color: var(--text-light); display: flex; align-items: center; gap: 0.4rem; flex-wrap: wrap;"> 125 + <span>view <a href="https://tangled.org/@zzstoatzz.io/at-me" target="_blank" rel="noopener noreferrer" 126 + style="color: var(--text); text-decoration: underline;">the source code</a> on</span> 127 + <a href="https://tangled.org" target="_blank" rel="noopener noreferrer" 128 + style="color: var(--text); text-decoration: underline;">tangled.org</a> 129 + </p> 130 + </div> 131 + 132 + <div class="guestbook-modal" id="guestbookModal"> 133 + <button class="guestbook-close" id="guestbookClose">x</button> 134 + <div id="guestbookContent"></div> 135 + </div> 136 + 137 + <div class="canvas"> 138 + <div class="identity"> 139 + <img class="identity-avatar" id="identityAvatar" /> 140 + <div class="identity-handle" id="handleDisplay"></div> 141 + </div> 142 + <div id="field" class="loading"> 143 + <div class="loading-spinner"></div> 144 + <div class="loading-text">loading your data</div> 145 + <div class="loading-progress" id="status">resolving identity...</div> 146 + </div> 147 + </div> 148 + <div id="detail" class="detail-panel"></div> 149 + 150 + <script type="module" src="/src/view/main.js"></script> 151 + <script> 152 + // Info modal handlers (kept inline as they're simple UI toggles) 153 + document.getElementById('infoBtn').addEventListener('click', () => { 154 + document.getElementById('infoModal').classList.add('visible'); 155 + document.getElementById('overlay').classList.add('visible'); 156 + }); 157 + document.getElementById('closeInfo').addEventListener('click', () => { 158 + document.getElementById('infoModal').classList.remove('visible'); 159 + document.getElementById('overlay').classList.remove('visible'); 160 + }); 161 + document.getElementById('overlay').addEventListener('click', () => { 162 + document.getElementById('infoModal').classList.remove('visible'); 163 + document.getElementById('overlay').classList.remove('visible'); 164 + }); 165 + </script> 166 + </body> 167 + 168 + </html>
+33
vite.config.js
··· 1 + import { defineConfig } from 'vite'; 2 + 3 + export default defineConfig({ 4 + root: '.', 5 + publicDir: 'public', 6 + base: './', 7 + build: { 8 + outDir: 'dist', 9 + rollupOptions: { 10 + input: { 11 + main: 'index.html', 12 + view: 'view.html' 13 + } 14 + } 15 + }, 16 + server: { 17 + port: 3030 18 + }, 19 + appType: 'mpa', 20 + plugins: [ 21 + { 22 + name: 'rewrite-view', 23 + configureServer(server) { 24 + server.middlewares.use((req, res, next) => { 25 + if (req.url.startsWith('/view') && !req.url.includes('.html')) { 26 + req.url = req.url.replace('/view', '/view.html'); 27 + } 28 + next(); 29 + }); 30 + } 31 + } 32 + ] 33 + });