interactive intro to open social

Compare changes

Choose any two refs to compare.

+2 -1
.gitignore
··· 1 1 /target 2 - sandbox/ 2 + sandbox/ 3 + .env
+16
.pre-commit-config.yaml
··· 1 + repos: 2 + - repo: local 3 + hooks: 4 + - id: cargo-fmt 5 + name: cargo fmt 6 + entry: cargo fmt -- 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] 16 + pass_filenames: false
+5
.tangled/workflows/check.yaml
··· 2 2 3 3 when: 4 4 - event: ["push", "pull_request"] 5 + branch: ["main"] 5 6 6 7 dependencies: 7 8 nixpkgs: ··· 9 10 - cargo 10 11 - rustfmt 11 12 - clippy 13 + - gcc 14 + - openssl.dev 15 + - pkg-config 12 16 13 17 steps: 14 18 - name: check formatting ··· 17 21 18 22 - name: run clippy 19 23 command: | 24 + export PKG_CONFIG_PATH=$(echo /nix/store/*openssl*-dev/lib/pkgconfig) 20 25 cargo clippy -- -D warnings
-14
.tangled/workflows/deploy.yaml
··· 1 - engine: nixery 2 - 3 - when: 4 - - event: ["push"] 5 - branch: ["main"] 6 - 7 - dependencies: 8 - nixpkgs: 9 - - flyctl 10 - 11 - steps: 12 - - name: Deploy to fly.io 13 - command: | 14 - flyctl deploy --remote-only
+10
CLAUDE.md
··· 1 + # at-me 2 + 3 + ATProto PDS visualization tool - shows your identity, apps, and data collections. 4 + 5 + ## Critical reminders 6 + 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 + - Never deploy without explicit user request
+668 -11
Cargo.lock
··· 20 20 ] 21 21 22 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]] 23 46 name = "actix-http" 24 47 version = "3.11.2" 25 48 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 39 62 "flate2", 40 63 "foldhash", 41 64 "futures-core", 42 - "h2", 65 + "h2 0.3.27", 43 66 "http 0.2.12", 44 67 "httparse", 45 68 "httpdate", ··· 253 276 ] 254 277 255 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]] 256 291 name = "aho-corasick" 257 292 version = "1.1.3" 258 293 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 372 407 ] 373 408 374 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]] 375 432 name = "async-trait" 376 433 version = "0.1.89" 377 434 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 386 443 name = "at-me" 387 444 version = "0.1.0" 388 445 dependencies = [ 446 + "actix-files", 389 447 "actix-session", 390 448 "actix-web", 449 + "anyhow", 450 + "async-stream", 451 + "async-trait", 391 452 "atrium-api", 392 453 "atrium-common", 393 454 "atrium-identity", 394 455 "atrium-oauth", 456 + "chrono", 457 + "dashmap", 395 458 "env_logger", 459 + "futures-util", 396 460 "hickory-resolver", 397 461 "log", 462 + "once_cell", 463 + "reqwest", 464 + "rocketman", 398 465 "serde", 399 466 "serde_json", 400 467 "tokio", 468 + "urlencoding", 401 469 ] 402 470 403 471 [[package]] ··· 519 587 "miniz_oxide", 520 588 "object", 521 589 "rustc-demangle", 522 - "windows-link", 590 + "windows-link 0.2.0", 523 591 ] 524 592 525 593 [[package]] ··· 549 617 version = "0.20.0" 550 618 source = "registry+https://github.com/rust-lang/crates.io-index" 551 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" 552 626 553 627 [[package]] 554 628 name = "base64" ··· 578 652 ] 579 653 580 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]] 581 680 name = "brotli" 582 681 version = "8.0.2" 583 682 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 603 702 version = "3.19.0" 604 703 source = "registry+https://github.com/rust-lang/crates.io-index" 605 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" 606 711 607 712 [[package]] 608 713 name = "bytes" ··· 648 753 "num-traits", 649 754 "serde", 650 755 "wasm-bindgen", 651 - "windows-link", 756 + "windows-link 0.2.0", 652 757 ] 653 758 654 759 [[package]] ··· 837 942 ] 838 943 839 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]] 840 1015 name = "dashmap" 841 1016 version = "6.1.0" 842 1017 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 893 1068 checksum = "a41953f86f8a05768a6cda24def994fd2f424b04ec5c719cf89989779f199071" 894 1069 dependencies = [ 895 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", 896 1102 ] 897 1103 898 1104 [[package]] ··· 1105 1311 ] 1106 1312 1107 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]] 1108 1326 name = "fnv" 1109 1327 version = "1.0.7" 1110 1328 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1192 1410 dependencies = [ 1193 1411 "futures-core", 1194 1412 "futures-macro", 1413 + "futures-sink", 1195 1414 "futures-task", 1196 1415 "pin-project-lite", 1197 1416 "pin-utils", ··· 1216 1435 checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" 1217 1436 dependencies = [ 1218 1437 "cfg-if", 1438 + "js-sys", 1219 1439 "libc", 1220 1440 "wasi 0.11.1+wasi-snapshot-preview1", 1441 + "wasm-bindgen", 1221 1442 ] 1222 1443 1223 1444 [[package]] ··· 1279 1500 ] 1280 1501 1281 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]] 1282 1522 name = "hashbrown" 1283 1523 version = "0.14.5" 1284 1524 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1416 1656 ] 1417 1657 1418 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]] 1419 1665 name = "httparse" 1420 1666 version = "1.10.1" 1421 1667 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1437 1683 "bytes", 1438 1684 "futures-channel", 1439 1685 "futures-core", 1686 + "h2 0.4.12", 1440 1687 "http 1.3.1", 1441 1688 "http-body", 1442 1689 "httparse", ··· 1449 1696 ] 1450 1697 1451 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]] 1452 1715 name = "hyper-tls" 1453 1716 version = "0.6.0" 1454 1717 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1483 1746 "percent-encoding", 1484 1747 "pin-project-lite", 1485 1748 "socket2 0.6.0", 1749 + "system-configuration", 1486 1750 "tokio", 1487 1751 "tower-service", 1488 1752 "tracing", 1753 + "windows-registry", 1489 1754 ] 1490 1755 1491 1756 [[package]] ··· 1597 1862 "zerotrie", 1598 1863 "zerovec", 1599 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" 1600 1871 1601 1872 [[package]] 1602 1873 name = "idna" ··· 1800 2071 checksum = "d4345964bb142484797b161f473a503a434de77149dd8c7427788c6e13379388" 1801 2072 1802 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]] 1803 2080 name = "libc" 1804 2081 version = "0.2.176" 1805 2082 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1891 2168 checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" 1892 2169 1893 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]] 1894 2181 name = "mime" 1895 2182 version = "0.3.17" 1896 2183 source = "registry+https://github.com/rust-lang/crates.io-index" 1897 2184 checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" 1898 2185 1899 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]] 1900 2197 name = "miniz_oxide" 1901 2198 version = "0.8.9" 1902 2199 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1963 2260 ] 1964 2261 1965 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]] 1966 2272 name = "native-tls" 1967 2273 version = "0.2.14" 1968 2274 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1980 2286 ] 1981 2287 1982 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]] 1983 2298 name = "num-conv" 1984 2299 version = "0.1.0" 1985 2300 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2103 2418 "libc", 2104 2419 "redox_syscall", 2105 2420 "smallvec", 2106 - "windows-link", 2421 + "windows-link 0.2.0", 2107 2422 ] 2108 2423 2109 2424 [[package]] ··· 2182 2497 ] 2183 2498 2184 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]] 2185 2510 name = "primeorder" 2186 2511 version = "0.13.6" 2187 2512 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2326 2651 "async-compression", 2327 2652 "base64 0.22.1", 2328 2653 "bytes", 2654 + "encoding_rs", 2329 2655 "futures-core", 2330 2656 "futures-util", 2657 + "h2 0.4.12", 2331 2658 "http 1.3.1", 2332 2659 "http-body", 2333 2660 "http-body-util", 2334 2661 "hyper", 2662 + "hyper-rustls", 2335 2663 "hyper-tls", 2336 2664 "hyper-util", 2337 2665 "js-sys", 2338 2666 "log", 2667 + "mime", 2339 2668 "native-tls", 2340 2669 "percent-encoding", 2341 2670 "pin-project-lite", ··· 2373 2702 ] 2374 2703 2375 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]] 2376 2743 name = "rustc-demangle" 2377 2744 version = "0.1.26" 2378 2745 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2401 2768 ] 2402 2769 2403 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]] 2404 2817 name = "rustls-pki-types" 2405 2818 version = "1.12.0" 2406 2819 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2410 2823 ] 2411 2824 2412 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]] 2413 2847 name = "rustversion" 2414 2848 version = "1.0.22" 2415 2849 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2435 2869 version = "1.2.0" 2436 2870 source = "registry+https://github.com/rust-lang/crates.io-index" 2437 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 + ] 2438 2882 2439 2883 [[package]] 2440 2884 name = "sec1" ··· 2579 3023 ] 2580 3024 2581 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]] 2582 3035 name = "shlex" 2583 3036 version = "1.3.0" 2584 3037 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2642 3095 ] 2643 3096 2644 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]] 2645 3107 name = "stable_deref_trait" 2646 3108 version = "1.2.0" 2647 3109 source = "registry+https://github.com/rust-lang/crates.io-index" 2648 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" 2649 3117 2650 3118 [[package]] 2651 3119 name = "subtle" ··· 2696 3164 ] 2697 3165 2698 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]] 2699 3188 name = "tagptr" 2700 3189 version = "0.2.0" 2701 3190 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2732 3221 "proc-macro2", 2733 3222 "quote", 2734 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", 2735 3233 ] 2736 3234 2737 3235 [[package]] ··· 2832 3330 ] 2833 3331 2834 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]] 2835 3369 name = "tokio-util" 2836 3370 version = "0.7.16" 2837 3371 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2919 3453 checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" 2920 3454 dependencies = [ 2921 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", 2922 3482 ] 2923 3483 2924 3484 [[package]] ··· 2939 3499 checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" 2940 3500 2941 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]] 2942 3522 name = "typenum" 2943 3523 version = "1.19.0" 2944 3524 source = "registry+https://github.com/rust-lang/crates.io-index" 2945 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" 2946 3532 2947 3533 [[package]] 2948 3534 name = "unicode-ident" ··· 2973 3559 checksum = "eb066959b24b5196ae73cb057f45598450d2c5f71460e98c49b738086eff9c06" 2974 3560 2975 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]] 2976 3568 name = "url" 2977 3569 version = "2.5.7" 2978 3570 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2985 3577 ] 2986 3578 2987 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]] 2988 3592 name = "utf8_iter" 2989 3593 version = "1.0.4" 2990 3594 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3006 3610 "js-sys", 3007 3611 "wasm-bindgen", 3008 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" 3009 3625 3010 3626 [[package]] 3011 3627 name = "vcpkg" ··· 3145 3761 ] 3146 3762 3147 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]] 3148 3770 name = "widestring" 3149 3771 version = "1.2.0" 3150 3772 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3158 3780 dependencies = [ 3159 3781 "windows-implement", 3160 3782 "windows-interface", 3161 - "windows-link", 3162 - "windows-result", 3163 - "windows-strings", 3783 + "windows-link 0.2.0", 3784 + "windows-result 0.4.0", 3785 + "windows-strings 0.5.0", 3164 3786 ] 3165 3787 3166 3788 [[package]] ··· 3187 3809 3188 3810 [[package]] 3189 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" 3190 3818 version = "0.2.0" 3191 3819 source = "registry+https://github.com/rust-lang/crates.io-index" 3192 3820 checksum = "45e46c0661abb7180e7b9c281db115305d49ca1709ab8242adf09666d2173c65" 3193 3821 3194 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]] 3195 3843 name = "windows-result" 3196 3844 version = "0.4.0" 3197 3845 source = "registry+https://github.com/rust-lang/crates.io-index" 3198 3846 checksum = "7084dcc306f89883455a206237404d3eaf961e5bd7e0f312f7c91f57eb44167f" 3199 3847 dependencies = [ 3200 - "windows-link", 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", 3201 3858 ] 3202 3859 3203 3860 [[package]] ··· 3206 3863 source = "registry+https://github.com/rust-lang/crates.io-index" 3207 3864 checksum = "7218c655a553b0bed4426cf54b20d7ba363ef543b52d515b3e48d7fd55318dda" 3208 3865 dependencies = [ 3209 - "windows-link", 3866 + "windows-link 0.2.0", 3210 3867 ] 3211 3868 3212 3869 [[package]] ··· 3251 3908 source = "registry+https://github.com/rust-lang/crates.io-index" 3252 3909 checksum = "6f109e41dd4a3c848907eb83d5a42ea98b3769495597450cf6d153507b166f0f" 3253 3910 dependencies = [ 3254 - "windows-link", 3911 + "windows-link 0.2.0", 3255 3912 ] 3256 3913 3257 3914 [[package]] ··· 3291 3948 source = "registry+https://github.com/rust-lang/crates.io-index" 3292 3949 checksum = "2d42b7b7f66d2a06854650af09cfdf8713e427a439c97ad65a6375318033ac4b" 3293 3950 dependencies = [ 3294 - "windows-link", 3951 + "windows-link 0.2.0", 3295 3952 "windows_aarch64_gnullvm 0.53.0", 3296 3953 "windows_aarch64_msvc 0.53.0", 3297 3954 "windows_i686_gnu 0.53.0",
+11
Cargo.toml
··· 5 5 6 6 [dependencies] 7 7 actix-web = "4.10" 8 + actix-files = "0.6" 8 9 actix-session = { version = "0.10", features = ["cookie-session"] } 9 10 atrium-api = "0.25" 10 11 atrium-common = "0.1" ··· 16 17 hickory-resolver = "0.24" 17 18 env_logger = "0.11" 18 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"
+3
Dockerfile
··· 33 33 # Copy the built binary 34 34 COPY --from=builder /app/target/release/at-me /app/at-me 35 35 36 + # Copy static files 37 + COPY --from=builder /app/static /app/static 38 + 36 39 # Expose port 37 40 EXPOSE 8080 38 41
+17 -5
README.md
··· 1 1 # @me 2 2 3 - an accessible visualization of how your atproto identity connects to third-party apps. 3 + an accessible visualization of how your atproto identity connects to atproto apps. 4 4 5 5 [at-me.fly.dev](https://at-me.fly.dev/) 6 6 7 7 ## what is this 8 8 9 - in decentralized social networks, you own your identity and your data lives in your personal data server. third-party applications create records in your repository using different lexicons (data schemas). 9 + in decentralized social networks, you own your identity and your data lives in your personal data server. atproto applications create records in your repository using different lexicons (data schemas). 10 10 11 - @me shows this visually: your identity at the center, surrounded by the third-party apps that have created data for you. click an app to see what record types it stores, then click a record type to view the actual data. 11 + @me shows this visually: your identity at the center, surrounded by the atproto apps that have created data for you. click an app to see what record types it stores, then click a record type to view the actual data. 12 12 13 13 inspired by [pdsls.dev](https://pdsls.dev). 14 14 15 15 ## running locally 16 16 17 17 ```bash 18 - cargo run 18 + just dev 19 19 ``` 20 20 21 - then visit http://localhost:8080 and sign in with any atproto handle. 21 + this starts the app with hot reloading using `cargo watch`. visit http://localhost:8080 and sign in with any atproto handle. 22 + 23 + to use a different port: 24 + ```bash 25 + just dev-port 3000 26 + ``` 27 + 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
+107
docs/_artifacts/COPY_IMPROVEMENTS.md
··· 1 + # copy improvements 2 + 3 + ## the problem 4 + 5 + the original copy throughout @me was too technical and jargon-heavy for people unfamiliar with atproto. terms like "silos," "atproto identity," "repository," and "Personal Data Server" appeared without context or explanation. this created barriers for the primary audience: regular social media users who might be curious about decentralized social but don't yet understand the tech. 6 + 7 + more importantly, the copy focused on **how the technology works** rather than **why it matters** to users. people don't care about protocols - they care about not losing their followers when platforms change. 8 + 9 + ## the philosophy 10 + 11 + drawing from [overreacted.io/open-social](https://overreacted.io/open-social/), we adopted these principles: 12 + 13 + 1. **lead with relatable problems** - "built 10k followers? if you leave, you lose them all" 14 + 2. **use familiar analogies** - "what if social media worked like email?" 15 + 3. **focus on benefits, not technology** - "switch apps anytime, take everything with you" 16 + 4. **provide breadcrumbs** - link every technical term to official docs so curious users can learn more 17 + 18 + the key insight: if you can't leave without losing something important, the platform has no incentive to respect you. that's the message that resonates with regular users, not "merkle search trees" or "decentralized identity." 19 + 20 + ## what we changed 21 + 22 + ### logged-out experience (login page "what is this?") 23 + 24 + **before:** 25 + - "visualize your atproto identity" 26 + - "the problem with silos" 27 + - "the atproto solution" 28 + - heavy use of jargon, abstract concepts 29 + 30 + **after:** 31 + - "your posts should be yours" - opens with the actual problem people face 32 + - "what if social media worked like email?" - uses an analogy everyone understands 33 + - "see it in action" - simple call to action 34 + - every technical term links to [atproto.com](https://atproto.com) documentation 35 + 36 + **why:** logged-out users know nothing about atproto. this is our chance to make them care before introducing any technical concepts. 37 + 38 + ### logged-in experience (? button modal) 39 + 40 + **before:** 41 + - "@me - your repository" 42 + - focused on platform switching 43 + - generic language about ownership 44 + 45 + **after:** 46 + - "this is your data" - personal and direct 47 + - explains what they're looking at: "you're looking at your Personal Data Server - where your social data actually lives" 48 + - concrete examples: "bluesky for microblogging. whitewind for long-form posts" 49 + - defines "open social" in plain terms: "if you don't like an app, switch" 50 + - ends with clear instructions on how to use the tool 51 + 52 + **why:** once someone is logged in, they're ready for slightly deeper concepts. but we still prioritize clarity over accuracy, using the visualization to teach what a PDS does. 53 + 54 + ### identity/PDS panel (clicking @ in center) 55 + 56 + **before:** 57 + - title: "your repository" 58 + - subtitle: "what you've built" 59 + - comparison boxes about traditional vs atproto platforms 60 + - technical details at bottom 61 + 62 + **after:** 63 + - title: "your personal data server" 64 + - subtitle: "where your social data lives" 65 + - **"your pds location"** box - explicitly states where the PDS is hosted and what's stored there 66 + - **"explore your data"** box - links to `pdsls.dev/{pds-domain}` as a next step 67 + - removed redundant platform comparison (already covered in modals) 68 + - kept technical details (DID, handle) at bottom 69 + 70 + **why:** this panel should immediately answer "what is this thing in the center?" and "where is my data actually stored?" the pdsls.dev link gives power users an immediate action item. 71 + 72 + ## the pattern 73 + 74 + every piece of copy now follows this structure: 75 + 76 + 1. **hook** - relatable problem or question 77 + 2. **explain** - use familiar analogies 78 + 3. **breadcrumb** - link technical terms to docs 79 + 4. **action** - give them something to do or explore 80 + 81 + examples: 82 + - login page: problem โ†’ email analogy โ†’ linked "Personal Data Server" โ†’ "explore demo" 83 + - info modal: "this is your data" โ†’ concrete examples โ†’ linked "open social" โ†’ "how to explore" 84 + - pds panel: "where your social data lives" โ†’ linked PDS location โ†’ pdsls.dev tool โ†’ technical details 85 + 86 + ## success metrics 87 + 88 + we'll know this worked if: 89 + 90 + 1. **bounce rate decreases** on login page 91 + 2. **demo mode usage increases** (people want to see it work) 92 + 3. **pdsls.dev referrals** show users are exploring further 93 + 4. **fewer confused questions** from new users 94 + 95 + more importantly: can you explain this to your non-technical friend and have them understand why they should care? that's the test. 96 + 97 + ## files modified 98 + 99 + - `src/templates.rs` - login page info section, logged-in info modal 100 + - `static/app.js` - identity/PDS panel on @ click 101 + 102 + ## resources 103 + 104 + - [overreacted.io/open-social](https://overreacted.io/open-social/) - the philosophical foundation 105 + - [atproto.com/guides/data-repos](https://atproto.com/guides/data-repos) - what is a PDS 106 + - [atproto.com](https://atproto.com) - protocol overview 107 + - [pdsls.dev](https://pdsls.dev) - tool for exploring PDS contents
+92
docs/firehose.md
··· 1 + # real-time updates via firehose 2 + 3 + at-me visualizes your atproto activity in real-time using the jetstream firehose. 4 + 5 + ## what is the firehose? 6 + 7 + the [atproto firehose](https://docs.bsky.app/docs/advanced-guides/firehose) is a WebSocket stream of all repository events across the network. when you create, update, or delete records in your PDS, these events flow through the firehose. 8 + 9 + we use [jetstream](https://github.com/ericvolp12/jetstream), a more efficient firehose consumer that filters and transforms events. 10 + 11 + ## architecture 12 + 13 + ### backend: rust + server-sent events 14 + 15 + **firehose manager** (`src/firehose.rs`) 16 + - maintains WebSocket connections to jetstream 17 + - one broadcaster per DID being watched 18 + - smart reconnection with exponential backoff 19 + - thread-safe using `tokio` and `Arc<Mutex>` 20 + 21 + **dynamic collection registration** 22 + - when you click "watch live", we fetch your repo's collections via `com.atproto.repo.describeRepo` 23 + - registers event ingesters for ALL collections (not just bluesky) 24 + - this means whitewind, tangled, guestbook, and any future app automatically work 25 + 26 + **event broadcasting** (`src/routes.rs:firehose_watch`) 27 + - server-sent events (SSE) endpoint at `/api/firehose/watch?did=<your-did>` 28 + - filters jetstream events to only those matching your DID and collections 29 + - broadcasts as JSON: `{action, collection, namespace, did, rkey}` 30 + 31 + ### frontend: particles + circles 32 + 33 + **WebSocket to SSE bridge** (`static/app.js`) 34 + - `EventSource` connects to SSE endpoint 35 + - parses incoming events 36 + - creates particle animations 37 + - shows toast notifications 38 + 39 + **particle system** 40 + - creates colored particles (green=create, blue=update, red=delete) 41 + - animates from app circle โ†’ identity (your PDS) 42 + - uses `requestAnimationFrame` for smooth 60fps 43 + - easing with cubic bezier for natural motion 44 + 45 + **dynamic circle management** 46 + - new app? โ†’ `addAppCircle()` creates it on the fly 47 + - delete event? โ†’ `removeAppCircle()` cleans up when particle completes 48 + - circles automatically reposition to maintain even spacing 49 + 50 + ## event flow 51 + 52 + ``` 53 + 1. you create a post in bluesky 54 + 2. bluesky writes to your PDS 55 + 3. your PDS emits event to firehose 56 + 4. jetstream filters and forwards to our backend 57 + 5. backend matches your DID + collection 58 + 6. SSE pushes event to your browser 59 + 7. particle animates from bluesky circle to center 60 + 8. identity pulses when particle arrives 61 + 9. toast shows "created post: hello world..." 62 + ``` 63 + 64 + ## why it works for any app 65 + 66 + traditional approaches hardcode collections like `app.bsky.feed.post`. we don't. 67 + 68 + instead, we: 69 + 1. call `describeRepo` to get YOUR actual collections 70 + 2. register ingesters for everything you have 71 + 3. dynamically create/remove app circles as events flow 72 + 73 + this means if you use: 74 + - whitewind โ†’ see blog posts flow in 75 + - tangled โ†’ see commits flow in 76 + - at-me guestbook โ†’ see signatures flow in 77 + - future apps โ†’ automatically supported 78 + 79 + ## performance notes 80 + 81 + - **caching**: DID resolution cached for 1 hour (`constants::CACHE_TTL`) 82 + - **buffer**: broadcast channel with 100-event buffer 83 + - **reconnection**: 5-second delay between retries 84 + - **cleanup**: connections close when SSE client disconnects 85 + 86 + ## code references 87 + 88 + - firehose manager: `src/firehose.rs` 89 + - SSE endpoint: `src/routes.rs:951` (`firehose_watch`) 90 + - dynamic registration: `src/routes.rs:985` (fetch collections via `describeRepo`) 91 + - particle animation: `static/app.js:1037` (`animateFirehoseParticles`) 92 + - circle lifecycle: `static/app.js:1419` (`addAppCircle`), `static/app.js:1646` (`removeAppCircle`)
+51
docs/lexicon.md
··· 1 + # lexicon 2 + 3 + ## `app.at-me.visit` 4 + 5 + **status**: unofficial, experimental 6 + 7 + this is the record type created when users opt-in to "sign the guestbook" on at-me. 8 + 9 + ### namespace rationale 10 + 11 + we use `app.at-me.visit` rather than a domain-based namespace (like `io.zzstoatzz.*`) because: 12 + 13 + 1. the app is hosted at `at-me.fly.dev`, not under a domain we control 14 + 2. using a personal domain namespace would incorrectly suggest this is an official/owned lexicon 15 + 3. `app.at-me.*` clearly associates records with this specific application 16 + 17 + this is an **unofficial lexicon** - there is no formal schema definition served at a URL. it's a simple, unvalidated record type for analytics/engagement tracking. 18 + 19 + ### record structure 20 + 21 + ```json 22 + { 23 + "$type": "app.at-me.visit", 24 + "timestamp": "2025-10-25T22:30:00Z", 25 + "createdAt": "2025-10-25T22:30:00Z", 26 + "text": "optional message from the visitor" 27 + } 28 + ``` 29 + 30 + **fields:** 31 + - `timestamp` (required): ISO 8601 timestamp of when the signature was created 32 + - `createdAt` (required): ISO 8601 timestamp of when the record was created (typically same as timestamp) 33 + - `text` (optional): a message left by the visitor, max 280 characters 34 + 35 + ### privacy 36 + 37 + - users must explicitly authenticate and click "sign guestbook" to create these records 38 + - records are written to the user's own PDS, which they control 39 + - the app does not store or aggregate this data 40 + - users can delete these records at any time through their PDS 41 + 42 + ### philosophy 43 + 44 + this approach aligns with atproto's principles: 45 + - user data sovereignty (records live in user's PDS) 46 + - transparency (users see exactly what's being written) 47 + - opt-in participation (no tracking without explicit consent) 48 + 49 + ### acknowledgments 50 + 51 + thanks to [@thisismissem.social](https://bsky.app/profile/thisismissem.social) for putting [lexicon-guestbook](https://github.com/FujoWebDev/lexicon-guestbook) on our radar! [@essentialrandom.bsky.social](https://bsky.app/profile/essentialrandom.bsky.social)'s work on that project - a more fully-featured implementation with per-user guestbooks, moderation, and an appview - helped inform the addition of optional text messages to our simpler global guestbook.
+27
docs/oauth.md
··· 1 + # oauth 2 + 3 + at-me uses atproto oauth for authentication. 4 + 5 + ## flow 6 + 7 + 1. user enters handle on landing page 8 + 2. app resolves handle โ†’ DID โ†’ authorization server via did document 9 + 3. authorization server redirects to user's pds for consent 10 + 4. user approves, receives redirect back with auth code 11 + 5. app exchanges code for access token 12 + 6. token stored in session, used for authenticated api calls 13 + 14 + ## scopes 15 + 16 + ```rust 17 + Scope::Known(KnownScope::Atproto), 18 + Scope::Unknown("repo:app.at-me.visit".to_string()), 19 + ``` 20 + 21 + the granular scope `repo:app.at-me.visit` limits write access to only guestbook records. 22 + 23 + ## session management 24 + 25 + sessions use actix-web's cookie-based session middleware. authenticated agents cached in-memory by DID for performance (`AGENT_CACHE`). 26 + 27 + see `src/oauth.rs` for implementation.
+30 -4
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 + 1 19 # deploy to fly.io 2 20 deploy: 3 - fly deploy 21 + fly deploy 22 + 23 + # check code with clippy 24 + check: 25 + cargo clippy 26 + 27 + # format code 28 + fmt: 29 + cargo fmt 4 30 5 - # run locally 6 - dev: 7 - cargo run 31 + # delete all visit records from your PDS 32 + clean-up-my-visits: 33 + uv run scripts/delete_visits.py
+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 + }
+43 -17
src/main.rs
··· 1 - use actix_session::{SessionMiddleware, config::PersistentSession, storage::CookieSessionStore}; 2 - use actix_web::{App, HttpServer, cookie::{Key, time::Duration}, middleware, web}; 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}; 3 5 6 + mod constants; 7 + mod firehose; 8 + mod mst; 4 9 mod oauth; 5 10 mod routes; 6 11 mod templates; ··· 9 14 async fn main() -> std::io::Result<()> { 10 15 env_logger::init(); 11 16 12 - let client = oauth::create_oauth_client(); 17 + // Create the firehose manager (connections created lazily per-DID) 18 + let firehose_manager = firehose::create_firehose_manager(); 13 19 14 - println!("starting server at http://localhost:8080"); 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); 15 33 16 34 HttpServer::new(move || { 17 35 App::new() 18 36 .wrap(middleware::Logger::default()) 19 37 .wrap( 20 - SessionMiddleware::builder( 21 - CookieSessionStore::default(), 22 - Key::from(&[0; 64]), 23 - ) 24 - .cookie_secure(false) 25 - .session_lifecycle( 26 - PersistentSession::default() 27 - .session_ttl(Duration::days(30)) 28 - ) 29 - .build(), 38 + SessionMiddleware::builder(CookieSessionStore::default(), session_key.clone()) 39 + .cookie_secure(false) // Set to true in production with HTTPS 40 + .build(), 30 41 ) 31 - .app_data(web::Data::new(client.clone())) 42 + .app_data(web::Data::new(firehose_manager.clone())) 43 + .app_data(web::Data::new(oauth_client.clone())) 32 44 .service(routes::index) 45 + .service(routes::view) 33 46 .service(routes::login) 34 47 .service(routes::callback) 35 48 .service(routes::client_metadata) 36 49 .service(routes::logout) 37 - .service(routes::restore_session) 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) 38 63 .service(routes::favicon) 64 + .service(Files::new("/static", "./static")) 39 65 }) 40 - .bind(("0.0.0.0", 8080))? 66 + .bind(("0.0.0.0", port))? 41 67 .run() 42 68 .await 43 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 + }
+23 -9
src/oauth.rs
··· 3 3 handle::{AtprotoHandleResolver, AtprotoHandleResolverConfig, DnsTxtResolver}, 4 4 }; 5 5 use atrium_oauth::{ 6 + store::{session::MemorySessionStore, state::MemoryStateStore}, 6 7 AtprotoClientMetadata, AtprotoLocalhostClientMetadata, AuthMethod, DefaultHttpClient, 7 8 GrantType, KnownScope, OAuthClient, OAuthClientConfig, OAuthResolverConfig, Scope, 8 - store::{session::MemorySessionStore, state::MemoryStateStore}, 9 + }; 10 + use hickory_resolver::{ 11 + config::{ResolverConfig, ResolverOpts}, 12 + TokioAsyncResolver, 9 13 }; 10 - use hickory_resolver::{TokioAsyncResolver, config::{ResolverConfig, ResolverOpts}}; 11 14 use std::sync::Arc; 15 + 16 + use crate::constants; 12 17 13 18 #[derive(Clone)] 14 - pub struct HickoryDnsResolver(Arc<TokioAsyncResolver>); 19 + pub struct HickoryDnsResolver(pub Arc<TokioAsyncResolver>); 15 20 16 21 impl DnsTxtResolver for HickoryDnsResolver { 17 22 async fn resolve( ··· 39 44 40 45 pub fn create_oauth_client() -> OAuthClientType { 41 46 let http_client = Arc::new(DefaultHttpClient::default()); 42 - let dns_resolver = HickoryDnsResolver(Arc::new( 43 - TokioAsyncResolver::tokio(ResolverConfig::default(), ResolverOpts::default()), 44 - )); 47 + let dns_resolver = HickoryDnsResolver(Arc::new(TokioAsyncResolver::tokio( 48 + ResolverConfig::default(), 49 + ResolverOpts::default(), 50 + ))); 45 51 46 52 let redirect_uri = std::env::var("OAUTH_REDIRECT_URI") 47 - .unwrap_or_else(|_| "http://127.0.0.1:8080/oauth/callback".to_string()); 53 + .unwrap_or_else(|_| constants::DEFAULT_OAUTH_CALLBACK.to_string()); 48 54 49 55 let is_production = redirect_uri.starts_with("https://"); 50 56 ··· 71 77 redirect_uris: vec![redirect_uri], 72 78 token_endpoint_auth_method: AuthMethod::None, 73 79 grant_types: vec![GrantType::AuthorizationCode, GrantType::RefreshToken], 74 - scopes: vec![Scope::Known(KnownScope::Atproto)], 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 + ], 75 85 jwks_uri: None, 76 86 token_endpoint_auth_signing_alg: None, 77 87 }, ··· 87 97 OAuthClient::new(OAuthClientConfig { 88 98 client_metadata: AtprotoLocalhostClientMetadata { 89 99 redirect_uris: Some(vec![redirect_uri]), 90 - scopes: Some(vec![Scope::Known(KnownScope::Atproto)]), 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 + ]), 91 105 }, 92 106 keys: None, 93 107 resolver,
+1406 -41
src/routes.rs
··· 1 1 use actix_session::Session; 2 2 use actix_web::{get, post, web, HttpResponse, Responder}; 3 - use atrium_oauth::{AuthorizeOptions, CallbackParams, KnownScope, Scope}; 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; 4 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}; 5 16 6 - use crate::oauth::OAuthClientType; 17 + use crate::constants; 18 + use crate::firehose::FirehoseManager; 19 + use crate::mst; 20 + use crate::oauth::{HickoryDnsResolver, OAuthClientType}; 7 21 use crate::templates; 8 22 9 - const FAVICON_SVG: &str = include_str!("../static/favicon.svg"); 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 + } 10 64 65 + // UFOs API response structure 11 66 #[derive(Deserialize)] 12 - pub struct LoginForm { 13 - handle: String, 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>, 14 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"); 15 96 16 97 #[derive(Deserialize)] 17 - pub struct OAuthParams { 18 - state: Option<String>, 19 - iss: Option<String>, 20 - code: Option<String>, 21 - error: Option<String>, 98 + pub struct ViewQuery { 99 + handle: Option<String>, 100 + did: Option<String>, 22 101 } 23 102 24 103 #[get("/")] 25 - pub async fn index(session: Session) -> impl Responder { 26 - let did: Option<String> = session.get("did").unwrap_or(None); 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); 27 119 28 - match did { 29 - Some(did) => HttpResponse::Ok() 30 - .content_type("text/html") 31 - .body(templates::app_page(&did)), 32 - None => HttpResponse::Ok() 33 - .content_type("text/html") 34 - .body(templates::login_page()), 35 - } 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, 36 149 } 37 150 38 151 #[post("/login")] 39 - pub async fn login( 40 - form: web::Form<LoginForm>, 41 - client: web::Data<OAuthClientType>, 42 - ) -> HttpResponse { 152 + pub async fn login(form: web::Form<LoginForm>, client: web::Data<OAuthClientType>) -> HttpResponse { 43 153 let handle = match atrium_api::types::string::Handle::new(form.handle.clone()) { 44 154 Ok(h) => h, 45 155 Err(_) => return HttpResponse::BadRequest().body("invalid handle"), ··· 49 159 .authorize( 50 160 &handle, 51 161 AuthorizeOptions { 52 - scopes: vec![Scope::Known(KnownScope::Atproto)], 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 + ], 53 167 ..Default::default() 54 168 }, 55 169 ) ··· 62 176 } 63 177 } 64 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 + 65 187 #[get("/oauth/callback")] 66 188 pub async fn callback( 67 189 params: web::Query<OAuthParams>, ··· 84 206 }; 85 207 86 208 match client.callback(callback_params).await { 87 - Ok((bsky_session, _)) => { 88 - let agent = atrium_api::agent::Agent::new(bsky_session); 209 + Ok((session_data, _)) => { 210 + // Create agent from session 211 + let agent = Arc::new(atrium_api::agent::Agent::new(session_data)); 89 212 if let Some(did) = agent.did().await { 90 - session.insert("did", did.to_string()).unwrap(); 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 + } 91 223 HttpResponse::SeeOther() 92 - .append_header(("Location", "/")) 224 + .append_header(("Location", format!("/view?did={}&auth=success", did_string))) 93 225 .finish() 94 226 } else { 95 227 HttpResponse::InternalServerError().body("no did") ··· 102 234 #[get("/oauth-client-metadata.json")] 103 235 pub async fn client_metadata() -> HttpResponse { 104 236 let base_url = std::env::var("OAUTH_REDIRECT_URI") 105 - .unwrap_or_else(|_| "http://127.0.0.1:8080/oauth/callback".to_string()) 237 + .unwrap_or_else(|_| constants::DEFAULT_OAUTH_CALLBACK.to_string()) 106 238 .trim_end_matches("/oauth/callback") 107 239 .to_string(); 108 240 ··· 111 243 "client_name": "@me", 112 244 "client_uri": base_url.clone(), 113 245 "redirect_uris": [format!("{}/oauth/callback", base_url)], 114 - "scope": "atproto", 246 + "scope": "atproto repo:app.at-me.visit", 115 247 "grant_types": ["authorization_code", "refresh_token"], 116 248 "response_types": ["code"], 117 249 "token_endpoint_auth_method": "none", ··· 132 264 } 133 265 134 266 #[derive(Deserialize)] 135 - pub struct RestoreSession { 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, 136 847 did: String, 848 + collection: String, 849 + rkey: String, 137 850 } 138 851 139 - #[post("/api/restore-session")] 140 - pub async fn restore_session( 141 - data: web::Json<RestoreSession>, 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( 142 887 session: Session, 888 + body: web::Json<SignGuestbookRequest>, 143 889 ) -> HttpResponse { 144 - session.insert("did", &data.did).unwrap(); 145 - HttpResponse::Ok().finish() 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 + } 146 1416 } 147 1417 148 - #[get("/favicon.svg")] 149 - pub async fn favicon() -> HttpResponse { 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 + 150 1513 HttpResponse::Ok() 151 - .content_type("image/svg+xml") 152 - .body(FAVICON_SVG) 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)) 153 1518 }
+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>
+4 -1049
src/templates.rs
··· 1 - pub fn login_page() -> &'static str { 2 - r#" 3 - <!DOCTYPE html> 4 - <html> 5 - <head> 6 - <meta charset="UTF-8"> 7 - <title>@me - login</title> 8 - <link rel="icon" type="image/svg+xml" href="/favicon.svg"> 9 - <style> 10 - * { margin: 0; padding: 0; box-sizing: border-box; } 11 - body { font-family: 'Monaco', 'Courier New', monospace; display: flex; align-items: center; justify-content: center; height: 100vh; background: #000; color: #0f0; } 12 - .container { text-align: center; } 13 - h1 { font-size: 2rem; margin-bottom: 2rem; } 14 - input { font-family: 'Monaco', 'Courier New', monospace; font-size: 0.9rem; padding: 0.5rem; margin: 0.5rem; background: #000; border: 1px solid #0f0; color: #0f0; } 15 - button { font-family: 'Monaco', 'Courier New', monospace; font-size: 0.9rem; padding: 0.5rem 1rem; cursor: pointer; background: #000; border: 1px solid #0f0; color: #0f0; } 16 - button:hover { background: #0f0; color: #000; } 17 - .hidden { display: none; } 18 - .loading { color: #0f0; opacity: 0.5; } 19 - </style> 20 - </head> 21 - <body> 22 - <div class="container"> 23 - <div id="restoring" class="loading hidden">restoring session...</div> 24 - <form id="loginForm" method="post" action="/login"> 25 - <h1>@me</h1> 26 - <input type="text" name="handle" placeholder="handle.bsky.social" required autofocus> 27 - <button type="submit">login</button> 28 - </form> 29 - </div> 30 - <script> 31 - const savedDid = localStorage.getItem('atme_did'); 32 - if (savedDid) { 33 - document.getElementById('loginForm').classList.add('hidden'); 34 - document.getElementById('restoring').classList.remove('hidden'); 35 - 36 - fetch('/api/restore-session', { 37 - method: 'POST', 38 - headers: { 'Content-Type': 'application/json' }, 39 - body: JSON.stringify({ did: savedDid }) 40 - }).then(r => { 41 - if (r.ok) { 42 - window.location.href = '/'; 43 - } else { 44 - localStorage.removeItem('atme_did'); 45 - document.getElementById('loginForm').classList.remove('hidden'); 46 - document.getElementById('restoring').classList.add('hidden'); 47 - } 48 - }).catch(() => { 49 - localStorage.removeItem('atme_did'); 50 - document.getElementById('loginForm').classList.remove('hidden'); 51 - document.getElementById('restoring').classList.add('hidden'); 52 - }); 53 - } 54 - </script> 55 - </body> 56 - </html> 57 - "# 1 + pub fn landing_page() -> &'static str { 2 + include_str!("templates/landing.html") 58 3 } 59 4 60 5 pub fn app_page(did: &str) -> String { 61 - format!(r#" 62 - <!DOCTYPE html> 63 - <html> 64 - <head> 65 - <meta charset="UTF-8"> 66 - <meta name="viewport" content="width=device-width, initial-scale=1.0"> 67 - <title>@me</title> 68 - <link rel="icon" type="image/svg+xml" href="/favicon.svg"> 69 - <style> 70 - * {{ margin: 0; padding: 0; box-sizing: border-box; }} 71 - 72 - :root {{ 73 - --bg: #f5f1e8; 74 - --text: #4a4238; 75 - --text-light: #8a7a6a; 76 - --text-lighter: #6b5d4f; 77 - --border: #c9bfa8; 78 - --surface: #e5dbc8; 79 - --surface-hover: #d9cdb5; 80 - }} 81 - 82 - @media (prefers-color-scheme: dark) {{ 83 - :root {{ 84 - --bg: #1a1a1a; 85 - --text: #e5e5e5; 86 - --text-light: #a0a0a0; 87 - --text-lighter: #c0c0c0; 88 - --border: #404040; 89 - --surface: #2a2a2a; 90 - --surface-hover: #353535; 91 - }} 92 - }} 93 - 94 - body {{ 95 - font-family: ui-monospace, 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Menlo, 'Courier New', monospace; 96 - height: 100vh; 97 - background: var(--bg); 98 - color: var(--text); 99 - overflow: hidden; 100 - position: relative; 101 - -webkit-font-smoothing: antialiased; 102 - -moz-osx-font-smoothing: grayscale; 103 - }} 104 - 105 - .canvas {{ 106 - width: 100%; 107 - height: 100%; 108 - position: relative; 109 - display: flex; 110 - align-items: center; 111 - justify-content: center; 112 - }} 113 - 114 - .logout {{ 115 - position: fixed; 116 - top: 1.5rem; 117 - right: 1.5rem; 118 - font-size: 0.7rem; 119 - color: var(--text-light); 120 - text-decoration: none; 121 - border: 1px solid var(--border); 122 - padding: 0.4rem 0.8rem; 123 - transition: all 0.2s ease; 124 - z-index: 100; 125 - -webkit-tap-highlight-color: transparent; 126 - cursor: pointer; 127 - border-radius: 2px; 128 - }} 129 - 130 - .logout:hover, .logout:active {{ 131 - background: var(--surface); 132 - color: var(--text); 133 - border-color: var(--text-light); 134 - }} 135 - 136 - @media (max-width: 768px) {{ 137 - .logout {{ 138 - padding: 0.6rem 1rem; 139 - font-size: 0.75rem; 140 - top: 1rem; 141 - right: 1rem; 142 - }} 143 - }} 144 - 145 - .info {{ 146 - position: fixed; 147 - top: 1.5rem; 148 - left: 1.5rem; 149 - width: 32px; 150 - height: 32px; 151 - border-radius: 50%; 152 - border: 1px solid var(--border); 153 - display: flex; 154 - align-items: center; 155 - justify-content: center; 156 - font-size: 0.75rem; 157 - color: var(--text-light); 158 - cursor: pointer; 159 - transition: all 0.2s ease; 160 - z-index: 100; 161 - -webkit-tap-highlight-color: transparent; 162 - }} 163 - 164 - .info:hover, .info:active {{ 165 - background: var(--surface); 166 - color: var(--text); 167 - border-color: var(--text-light); 168 - }} 169 - 170 - @media (max-width: 768px) {{ 171 - .info {{ 172 - width: 40px; 173 - height: 40px; 174 - font-size: 0.85rem; 175 - top: 1rem; 176 - left: 1rem; 177 - }} 178 - }} 179 - 180 - .info-modal {{ 181 - position: fixed; 182 - top: 50%; 183 - left: 50%; 184 - transform: translate(-50%, -50%); 185 - background: var(--surface); 186 - border: 2px solid var(--border); 187 - padding: 2rem; 188 - max-width: 500px; 189 - width: 90%; 190 - z-index: 2000; 191 - display: none; 192 - border-radius: 4px; 193 - }} 194 - 195 - @media (max-width: 768px) {{ 196 - .info-modal {{ 197 - padding: 1.5rem; 198 - width: 95%; 199 - }} 200 - 201 - .info-modal h2 {{ 202 - font-size: 0.9rem; 203 - }} 204 - 205 - .info-modal p {{ 206 - font-size: 0.7rem; 207 - }} 208 - }} 209 - 210 - .info-modal.visible {{ 211 - display: block; 212 - }} 213 - 214 - .info-modal h2 {{ 215 - margin-bottom: 1rem; 216 - font-size: 1rem; 217 - color: var(--text); 218 - }} 219 - 220 - .info-modal p {{ 221 - margin-bottom: 0.75rem; 222 - font-size: 0.75rem; 223 - line-height: 1.5; 224 - color: var(--text-lighter); 225 - }} 226 - 227 - .info-modal button {{ 228 - margin-top: 1rem; 229 - padding: 0.5rem 1rem; 230 - background: var(--bg); 231 - border: 1px solid var(--border); 232 - color: var(--text); 233 - font-family: inherit; 234 - font-size: 0.7rem; 235 - cursor: pointer; 236 - transition: all 0.2s ease; 237 - -webkit-tap-highlight-color: transparent; 238 - border-radius: 2px; 239 - }} 240 - 241 - .info-modal button:hover, .info-modal button:active {{ 242 - background: var(--surface-hover); 243 - border-color: var(--text-light); 244 - }} 245 - 246 - @media (max-width: 768px) {{ 247 - .info-modal button {{ 248 - padding: 0.65rem 1.2rem; 249 - font-size: 0.75rem; 250 - }} 251 - }} 252 - 253 - .overlay {{ 254 - position: fixed; 255 - top: 0; 256 - left: 0; 257 - right: 0; 258 - bottom: 0; 259 - background: rgba(0, 0, 0, 0.5); 260 - z-index: 1999; 261 - display: none; 262 - }} 263 - 264 - .overlay.visible {{ 265 - display: block; 266 - }} 267 - 268 - .identity {{ 269 - position: absolute; 270 - background: var(--surface); 271 - border: 2px solid var(--text-light); 272 - border-radius: 50%; 273 - width: 120px; 274 - height: 120px; 275 - display: flex; 276 - flex-direction: column; 277 - align-items: center; 278 - justify-content: center; 279 - gap: 0.3rem; 280 - z-index: 10; 281 - cursor: pointer; 282 - transition: all 0.2s ease; 283 - -webkit-tap-highlight-color: transparent; 284 - }} 285 - 286 - .identity:hover, .identity:active {{ 287 - transform: scale(1.05); 288 - border-color: var(--text); 289 - box-shadow: 0 0 20px rgba(255, 255, 255, 0.1); 290 - }} 291 - 292 - @media (max-width: 768px) {{ 293 - .identity {{ 294 - width: 100px; 295 - height: 100px; 296 - }} 297 - }} 298 - 299 - .identity-label {{ 300 - font-size: 1.2rem; 301 - color: var(--text); 302 - font-weight: 600; 303 - line-height: 1; 304 - }} 305 - 306 - .identity-value {{ 307 - font-size: 0.7rem; 308 - color: var(--text-lighter); 309 - text-align: center; 310 - word-break: break-word; 311 - max-width: 100px; 312 - font-weight: 400; 313 - }} 314 - 315 - @media (max-width: 768px) {{ 316 - .identity-label {{ 317 - font-size: 1.1rem; 318 - }} 319 - 320 - .identity-value {{ 321 - font-size: 0.65rem; 322 - }} 323 - }} 324 - 325 - .identity-hint {{ 326 - font-size: 0.4rem; 327 - color: var(--text-lighter); 328 - margin-top: 0.2rem; 329 - letter-spacing: 0.05em; 330 - }} 331 - 332 - .app-view {{ 333 - position: absolute; 334 - display: flex; 335 - flex-direction: column; 336 - align-items: center; 337 - gap: 0.4rem; 338 - cursor: pointer; 339 - transition: all 0.2s ease; 340 - opacity: 0.7; 341 - }} 342 - 343 - .app-view:hover {{ 344 - opacity: 1; 345 - transform: scale(1.1); 346 - z-index: 100; 347 - }} 348 - 349 - .app-circle {{ 350 - background: var(--surface-hover); 351 - border: 1px solid var(--border); 352 - border-radius: 50%; 353 - width: 60px; 354 - height: 60px; 355 - display: flex; 356 - align-items: center; 357 - justify-content: center; 358 - transition: all 0.2s ease; 359 - overflow: hidden; 360 - }} 361 - 362 - .app-logo {{ 363 - width: 100%; 364 - height: 100%; 365 - object-fit: cover; 366 - }} 367 - 368 - .app-view:hover .app-circle {{ 369 - background: var(--surface); 370 - border-color: var(--text-light); 371 - }} 372 - 373 - .app-name {{ 374 - font-size: 0.65rem; 375 - color: var(--text); 376 - text-align: center; 377 - max-width: 100px; 378 - }} 379 - 380 - .detail-panel {{ 381 - position: fixed; 382 - top: 0; 383 - left: 0; 384 - bottom: 0; 385 - width: 320px; 386 - background: var(--surface); 387 - border-right: 2px solid var(--border); 388 - padding: 2.5rem 2rem; 389 - overflow-y: auto; 390 - opacity: 0; 391 - transform: translateX(-100%); 392 - transition: all 0.25s ease; 393 - z-index: 1000; 394 - }} 395 - 396 - .detail-panel.visible {{ 397 - opacity: 1; 398 - transform: translateX(0); 399 - }} 400 - 401 - @media (max-width: 768px) {{ 402 - .detail-panel {{ 403 - width: 100%; 404 - padding: 4rem 1.5rem 2rem; 405 - border-right: none; 406 - border-bottom: 2px solid var(--border); 407 - }} 408 - }} 409 - 410 - .detail-panel h3 {{ 411 - margin-bottom: 0.75rem; 412 - font-size: 0.85rem; 413 - color: var(--text); 414 - }} 415 - 416 - .detail-panel .subtitle {{ 417 - font-size: 0.7rem; 418 - color: var(--text-light); 419 - margin-bottom: 1.5rem; 420 - line-height: 1.4; 421 - }} 422 - 423 - .detail-close {{ 424 - position: absolute; 425 - top: 1.5rem; 426 - right: 1.5rem; 427 - width: 32px; 428 - height: 32px; 429 - border: 1px solid var(--border); 430 - background: var(--bg); 431 - color: var(--text-light); 432 - cursor: pointer; 433 - display: flex; 434 - align-items: center; 435 - justify-content: center; 436 - font-size: 1.2rem; 437 - line-height: 1; 438 - transition: all 0.2s ease; 439 - border-radius: 2px; 440 - -webkit-tap-highlight-color: transparent; 441 - }} 442 - 443 - .detail-close:hover, .detail-close:active {{ 444 - background: var(--surface-hover); 445 - border-color: var(--text-light); 446 - color: var(--text); 447 - }} 448 - 449 - @media (max-width: 768px) {{ 450 - .detail-close {{ 451 - top: 1rem; 452 - right: 1rem; 453 - width: 40px; 454 - height: 40px; 455 - font-size: 1.4rem; 456 - }} 457 - }} 458 - 459 - .tree-item {{ 460 - padding: 0.65rem 0.75rem; 461 - font-size: 0.75rem; 462 - color: var(--text-lighter); 463 - background: var(--bg); 464 - border: 1px solid var(--border); 465 - border-radius: 2px; 466 - margin-bottom: 0.5rem; 467 - transition: all 0.15s ease; 468 - cursor: pointer; 469 - -webkit-tap-highlight-color: transparent; 470 - }} 471 - 472 - .tree-item:hover, .tree-item:active {{ 473 - background: var(--surface-hover); 474 - border-color: var(--text-light); 475 - }} 476 - 477 - @media (max-width: 768px) {{ 478 - .tree-item {{ 479 - padding: 0.8rem 0.9rem; 480 - font-size: 0.8rem; 481 - }} 482 - }} 483 - 484 - .tree-item:last-child {{ 485 - margin-bottom: 0; 486 - }} 487 - 488 - .tree-item-header {{ 489 - display: flex; 490 - justify-content: space-between; 491 - align-items: center; 492 - }} 493 - 494 - .tree-item-count {{ 495 - font-size: 0.65rem; 496 - color: var(--text-light); 497 - }} 498 - 499 - .record-list {{ 500 - margin-top: 0.5rem; 501 - padding-top: 0.5rem; 502 - border-top: 1px solid var(--border); 503 - }} 504 - 505 - .record {{ 506 - margin-bottom: 0.5rem; 507 - background: var(--bg); 508 - border: 1px solid var(--border); 509 - border-radius: 4px; 510 - font-size: 0.65rem; 511 - color: var(--text-light); 512 - transition: all 0.15s ease; 513 - overflow: hidden; 514 - }} 515 - 516 - .record:hover {{ 517 - border-color: var(--text-light); 518 - background: var(--surface); 519 - }} 520 - 521 - .record:last-child {{ 522 - margin-bottom: 0; 523 - }} 524 - 525 - .record-header {{ 526 - display: flex; 527 - justify-content: space-between; 528 - align-items: center; 529 - padding: 0.5rem 0.6rem; 530 - background: var(--surface); 531 - border-bottom: 1px solid var(--border); 532 - }} 533 - 534 - .record-label {{ 535 - font-size: 0.6rem; 536 - color: var(--text-lighter); 537 - font-weight: 500; 538 - }} 539 - 540 - .copy-btn {{ 541 - background: var(--bg); 542 - border: 1px solid var(--border); 543 - color: var(--text-light); 544 - font-family: inherit; 545 - font-size: 0.55rem; 546 - padding: 0.2rem 0.5rem; 547 - cursor: pointer; 548 - transition: all 0.15s ease; 549 - border-radius: 2px; 550 - -webkit-tap-highlight-color: transparent; 551 - }} 552 - 553 - .copy-btn:hover, .copy-btn:active {{ 554 - background: var(--surface-hover); 555 - border-color: var(--text-light); 556 - color: var(--text); 557 - }} 558 - 559 - .copy-btn.copied {{ 560 - color: var(--text); 561 - border-color: var(--text); 562 - }} 563 - 564 - .record-content {{ 565 - padding: 0.6rem; 566 - }} 567 - 568 - .record-content pre {{ 569 - margin: 0; 570 - white-space: pre-wrap; 571 - word-break: break-word; 572 - line-height: 1.5; 573 - font-size: 0.625rem; 574 - }} 575 - 576 - .load-more {{ 577 - margin-top: 0.5rem; 578 - padding: 0.4rem 0.6rem; 579 - background: var(--bg); 580 - border: 1px solid var(--border); 581 - color: var(--text); 582 - font-family: inherit; 583 - font-size: 0.65rem; 584 - cursor: pointer; 585 - width: 100%; 586 - transition: all 0.15s ease; 587 - -webkit-tap-highlight-color: transparent; 588 - border-radius: 2px; 589 - }} 590 - 591 - .load-more:hover, .load-more:active {{ 592 - background: var(--surface-hover); 593 - border-color: var(--text-light); 594 - }} 595 - 596 - @media (max-width: 768px) {{ 597 - .load-more {{ 598 - padding: 0.6rem 0.8rem; 599 - font-size: 0.7rem; 600 - }} 601 - }} 602 - 603 - .footer {{ 604 - position: fixed; 605 - bottom: 1rem; 606 - left: 50%; 607 - transform: translateX(-50%); 608 - font-size: 0.65rem; 609 - color: var(--text-light); 610 - z-index: 100; 611 - }} 612 - 613 - .footer a {{ 614 - color: var(--text-light); 615 - text-decoration: none; 616 - border-bottom: 1px solid transparent; 617 - transition: border-color 0.2s ease; 618 - }} 619 - 620 - .footer a:hover {{ 621 - border-bottom-color: var(--text-light); 622 - }} 623 - 624 - .loading {{ color: var(--text-light); font-size: 0.75rem; }} 625 - </style> 626 - </head> 627 - <body> 628 - <div class="info" id="infoBtn">i</div> 629 - <a href="javascript:void(0)" id="logoutBtn" class="logout">logout</a> 630 - 631 - <div class="overlay" id="overlay"></div> 632 - <div class="info-modal" id="infoModal"> 633 - <h2>@me - your at protocol identity</h2> 634 - <p>in decentralized social networks, you own your identity and your data lives in your personal data server (pds).</p> 635 - <p>third-party applications create records in your repository using different lexicons (data schemas). for example, bluesky creates posts, white wind stores blog entries, tangled.org hosts code repositories, and frontpage aggregates links - all in the same place.</p> 636 - <p>this visualization shows your identity at the center, surrounded by the third-party apps that have created data for you. click an app to see what types of records it stores, then click a record type to see the actual data.</p> 637 - <button id="closeInfo">got it</button> 638 - </div> 639 - 640 - <div class="canvas"> 641 - <div class="identity"> 642 - <div class="identity-label">@</div> 643 - <div class="identity-value" id="handle">loading...</div> 644 - <div class="identity-hint">tap for details</div> 645 - </div> 646 - <div id="field" class="loading">loading...</div> 647 - </div> 648 - <div id="detail" class="detail-panel"></div> 649 - 650 - <div class="footer"> 651 - <a href="https://tangled.org/@zzstoatzz.io/at-me" target="_blank" rel="noopener noreferrer">view source</a> 652 - </div> 653 - <script> 654 - const did = '{}'; 655 - localStorage.setItem('atme_did', did); 656 - 657 - let globalPds = null; 658 - let globalHandle = null; 659 - 660 - // Try to fetch app avatar from their bsky profile 661 - async function fetchAppAvatar(namespace) {{ 662 - try {{ 663 - // Reverse namespace to get domain (e.g., io.zzstoatzz -> zzstoatzz.io) 664 - const reversed = namespace.split('.').reverse().join('.'); 665 - // Try reversed domain, then reversed.bsky.social 666 - const handles = [reversed, `${{reversed}}.bsky.social`]; 667 - 668 - for (const handle of handles) {{ 669 - try {{ 670 - const didRes = await fetch(`https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${{handle}}`); 671 - if (!didRes.ok) continue; 672 - 673 - const {{ did }} = await didRes.json(); 674 - const profileRes = await fetch(`https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${{did}}`); 675 - if (!profileRes.ok) continue; 676 - 677 - const profile = await profileRes.json(); 678 - if (profile.avatar) {{ 679 - return profile.avatar; 680 - }} 681 - }} catch (e) {{ 682 - continue; 683 - }} 684 - }} 685 - }} catch (e) {{ 686 - console.log('Could not fetch avatar for', namespace); 687 - }} 688 - return null; 689 - }} 690 - 691 - // Logout handler 692 - document.getElementById('logoutBtn').addEventListener('click', (e) => {{ 693 - e.preventDefault(); 694 - localStorage.removeItem('atme_did'); 695 - window.location.href = '/logout'; 696 - }}); 697 - 698 - // Info modal handlers 699 - document.getElementById('infoBtn').addEventListener('click', () => {{ 700 - document.getElementById('infoModal').classList.add('visible'); 701 - document.getElementById('overlay').classList.add('visible'); 702 - }}); 703 - 704 - document.getElementById('closeInfo').addEventListener('click', () => {{ 705 - document.getElementById('infoModal').classList.remove('visible'); 706 - document.getElementById('overlay').classList.remove('visible'); 707 - }}); 708 - 709 - document.getElementById('overlay').addEventListener('click', () => {{ 710 - document.getElementById('infoModal').classList.remove('visible'); 711 - document.getElementById('overlay').classList.remove('visible'); 712 - const detail = document.getElementById('detail'); 713 - detail.classList.remove('visible'); 714 - }}); 715 - 716 - // First resolve DID to get PDS endpoint and handle 717 - fetch('https://plc.directory/' + did) 718 - .then(r => r.json()) 719 - .then(didDoc => {{ 720 - const pds = didDoc.service.find(s => s.type === 'AtprotoPersonalDataServer')?.serviceEndpoint; 721 - const handle = didDoc.alsoKnownAs?.[0]?.replace('at://', '') || did; 722 - 723 - globalPds = pds; 724 - globalHandle = handle; 725 - 726 - // Update identity display with handle 727 - document.getElementById('handle').textContent = handle; 728 - 729 - // Add identity click handler to show PDS info 730 - document.querySelector('.identity').addEventListener('click', () => {{ 731 - const detail = document.getElementById('detail'); 732 - const pdsHost = pds.replace('https://', '').replace('http://', ''); 733 - detail.innerHTML = ` 734 - <button class="detail-close" id="detailClose">ร—</button> 735 - <h3>your identity</h3> 736 - <div class="subtitle">decentralized identifier & storage</div> 737 - <div class="tree-item"> 738 - <div class="tree-item-header"> 739 - <span style="color: var(--text-light);">did</span> 740 - <span style="font-size: 0.6rem; color: var(--text);">${{did}}</span> 741 - </div> 742 - </div> 743 - <div class="tree-item"> 744 - <div class="tree-item-header"> 745 - <span style="color: var(--text-light);">handle</span> 746 - <span style="font-size: 0.6rem; color: var(--text);">@${{handle}}</span> 747 - </div> 748 - </div> 749 - <div class="tree-item"> 750 - <div class="tree-item-header"> 751 - <span style="color: var(--text-light);">personal data server</span> 752 - <span style="font-size: 0.6rem; color: var(--text);">${{pds}}</span> 753 - </div> 754 - </div> 755 - <div style="margin-top: 1rem; padding: 0.6rem; background: var(--bg); border-radius: 4px; font-size: 0.65rem; line-height: 1.5; color: var(--text-lighter);"> 756 - your data lives at <strong style="color: var(--text);">${{pdsHost}}</strong>. apps like bluesky write to and read from this server. you control @<strong style="color: var(--text);">${{handle}}</strong> and can move it to a different server anytime. 757 - </div> 758 - `; 759 - detail.classList.add('visible'); 760 - 761 - // Add close button handler 762 - document.getElementById('detailClose').addEventListener('click', (e) => {{ 763 - e.stopPropagation(); 764 - detail.classList.remove('visible'); 765 - }}); 766 - }}); 767 - 768 - // Get all collections from PDS 769 - return fetch(`${{pds}}/xrpc/com.atproto.repo.describeRepo?repo=${{did}}`); 770 - }}) 771 - .then(r => r.json()) 772 - .then(repo => {{ 773 - const collections = repo.collections || []; 774 - 775 - // Group by app namespace (first two parts of lexicon) 776 - const apps = {{}}; 777 - collections.forEach(collection => {{ 778 - const parts = collection.split('.'); 779 - if (parts.length >= 2) {{ 780 - const namespace = `${{parts[0]}}.${{parts[1]}}`; 781 - if (!apps[namespace]) apps[namespace] = []; 782 - apps[namespace].push(collection); 783 - }} 784 - }}); 785 - 786 - const field = document.getElementById('field'); 787 - field.innerHTML = ''; 788 - field.classList.remove('loading'); 789 - 790 - const appNames = Object.keys(apps).sort(); 791 - const radius = 240; 792 - const centerX = window.innerWidth / 2; 793 - const centerY = window.innerHeight / 2; 794 - 795 - appNames.forEach((namespace, i) => {{ 796 - const angle = (i / appNames.length) * 2 * Math.PI - Math.PI / 2; // Start from top 797 - const x = centerX + radius * Math.cos(angle) - 25; 798 - const y = centerY + radius * Math.sin(angle) - 30; 799 - 800 - const div = document.createElement('div'); 801 - div.className = 'app-view'; 802 - div.style.left = `${{x}}px`; 803 - div.style.top = `${{y}}px`; 804 - 805 - const firstLetter = namespace.split('.')[1]?.[0]?.toUpperCase() || namespace[0].toUpperCase(); 806 - 807 - div.innerHTML = ` 808 - <div class="app-circle" data-namespace="${{namespace}}">${{firstLetter}}</div> 809 - <div class="app-name">${{namespace}}</div> 810 - `; 811 - 812 - // Try to fetch and display avatar 813 - fetchAppAvatar(namespace).then(avatarUrl => {{ 814 - if (avatarUrl) {{ 815 - const circle = div.querySelector('.app-circle'); 816 - circle.innerHTML = `<img src="${{avatarUrl}}" class="app-logo" alt="${{namespace}}" />`; 817 - }} 818 - }}); 819 - 820 - div.addEventListener('click', () => {{ 821 - const detail = document.getElementById('detail'); 822 - const collections = apps[namespace]; 823 - 824 - let html = ` 825 - <button class="detail-close" id="detailClose">ร—</button> 826 - <h3>${{namespace}}</h3> 827 - <div class="subtitle">records stored in your pds:</div> 828 - `; 829 - 830 - if (collections && collections.length > 0) {{ 831 - // Group collections by sub-namespace (third segment) 832 - const grouped = {{}}; 833 - collections.forEach(lexicon => {{ 834 - const parts = lexicon.split('.'); 835 - const subNamespace = parts.slice(2).join('.'); 836 - const firstPart = parts[2] || lexicon; 837 - 838 - if (!grouped[firstPart]) grouped[firstPart] = []; 839 - grouped[firstPart].push({{ lexicon, subNamespace }}); 840 - }}); 841 - 842 - // Sort and display grouped items 843 - Object.keys(grouped).sort().forEach(group => {{ 844 - const items = grouped[group]; 845 - 846 - if (items.length === 1 && items[0].subNamespace === group) {{ 847 - // Single item with no further nesting 848 - html += ` 849 - <div class="tree-item" data-lexicon="${{items[0].lexicon}}"> 850 - <div class="tree-item-header"> 851 - <span>${{group}}</span> 852 - <span class="tree-item-count">loading...</span> 853 - </div> 854 - </div> 855 - `; 856 - }} else {{ 857 - // Group header 858 - html += `<div style="margin-bottom: 0.75rem;">`; 859 - html += `<div style="font-size: 0.7rem; color: var(--text-light); margin-bottom: 0.4rem; font-weight: 500;">${{group}}</div>`; 860 - 861 - // Items in group 862 - items.sort((a, b) => a.subNamespace.localeCompare(b.subNamespace)).forEach(item => {{ 863 - const displayName = item.subNamespace.split('.').slice(1).join('.') || item.subNamespace; 864 - html += ` 865 - <div class="tree-item" data-lexicon="${{item.lexicon}}" style="margin-left: 0.75rem;"> 866 - <div class="tree-item-header"> 867 - <span>${{displayName}}</span> 868 - <span class="tree-item-count">loading...</span> 869 - </div> 870 - </div> 871 - `; 872 - }}); 873 - html += `</div>`; 874 - }} 875 - }}); 876 - }} else {{ 877 - html += `<div class="tree-item">no collections found</div>`; 878 - }} 879 - 880 - detail.innerHTML = html; 881 - detail.classList.add('visible'); 882 - 883 - // Add close button handler 884 - document.getElementById('detailClose').addEventListener('click', (e) => {{ 885 - e.stopPropagation(); 886 - detail.classList.remove('visible'); 887 - }}); 888 - 889 - // Fetch record counts for each collection 890 - if (collections && collections.length > 0) {{ 891 - collections.forEach(lexicon => {{ 892 - fetch(`${{globalPds}}/xrpc/com.atproto.repo.listRecords?repo=${{did}}&collection=${{lexicon}}&limit=1`) 893 - .then(r => r.json()) 894 - .then(data => {{ 895 - const item = detail.querySelector(`[data-lexicon="${{lexicon}}"]`); 896 - if (item) {{ 897 - const countSpan = item.querySelector('.tree-item-count'); 898 - // The cursor field indicates there are more records 899 - countSpan.textContent = data.records?.length > 0 ? 'has records' : 'empty'; 900 - }} 901 - }}) 902 - .catch(e => {{ 903 - console.error('Error fetching count for', lexicon, e); 904 - const item = detail.querySelector(`[data-lexicon="${{lexicon}}"]`); 905 - if (item) {{ 906 - const countSpan = item.querySelector('.tree-item-count'); 907 - countSpan.textContent = 'error'; 908 - }} 909 - }}); 910 - }}); 911 - }} 912 - 913 - // Add click handlers to tree items to fetch actual records 914 - detail.querySelectorAll('.tree-item[data-lexicon]').forEach(item => {{ 915 - item.addEventListener('click', (e) => {{ 916 - e.stopPropagation(); 917 - const lexicon = item.dataset.lexicon; 918 - const existingRecords = item.querySelector('.record-list'); 919 - 920 - if (existingRecords) {{ 921 - existingRecords.remove(); 922 - return; 923 - }} 924 - 925 - const recordListDiv = document.createElement('div'); 926 - recordListDiv.className = 'record-list'; 927 - recordListDiv.innerHTML = '<div class="loading">loading records...</div>'; 928 - item.appendChild(recordListDiv); 929 - 930 - fetch(`${{globalPds}}/xrpc/com.atproto.repo.listRecords?repo=${{did}}&collection=${{lexicon}}&limit=5`) 931 - .then(r => r.json()) 932 - .then(data => {{ 933 - if (data.records && data.records.length > 0) {{ 934 - let recordsHtml = ''; 935 - data.records.forEach((record, idx) => {{ 936 - const json = JSON.stringify(record.value, null, 2); 937 - const recordId = `record-${{Date.now()}}-${{idx}}`; 938 - recordsHtml += ` 939 - <div class="record"> 940 - <div class="record-header"> 941 - <span class="record-label">record</span> 942 - <button class="copy-btn" data-content="${{encodeURIComponent(json)}}" data-record-id="${{recordId}}">copy</button> 943 - </div> 944 - <div class="record-content"> 945 - <pre>${{json}}</pre> 946 - </div> 947 - </div> 948 - `; 949 - }}); 950 - 951 - if (data.cursor && data.records.length === 5) {{ 952 - recordsHtml += `<button class="load-more" data-cursor="${{data.cursor}}" data-lexicon="${{lexicon}}">load more</button>`; 953 - }} 954 - 955 - recordListDiv.innerHTML = recordsHtml; 956 - 957 - // Use event delegation for copy and load more buttons 958 - recordListDiv.addEventListener('click', (e) => {{ 959 - // Handle copy button 960 - if (e.target.classList.contains('copy-btn')) {{ 961 - e.stopPropagation(); 962 - const copyBtn = e.target; 963 - const content = decodeURIComponent(copyBtn.dataset.content); 964 - 965 - navigator.clipboard.writeText(content).then(() => {{ 966 - const originalText = copyBtn.textContent; 967 - copyBtn.textContent = 'copied!'; 968 - copyBtn.classList.add('copied'); 969 - setTimeout(() => {{ 970 - copyBtn.textContent = originalText; 971 - copyBtn.classList.remove('copied'); 972 - }}, 1500); 973 - }}).catch(err => {{ 974 - console.error('Failed to copy:', err); 975 - copyBtn.textContent = 'error'; 976 - setTimeout(() => {{ 977 - copyBtn.textContent = 'copy'; 978 - }}, 1500); 979 - }}); 980 - }} 981 - 982 - // Handle load more button 983 - if (e.target.classList.contains('load-more')) {{ 984 - e.stopPropagation(); 985 - const loadMoreBtn = e.target; 986 - const cursor = loadMoreBtn.dataset.cursor; 987 - const lexicon = loadMoreBtn.dataset.lexicon; 988 - 989 - loadMoreBtn.textContent = 'loading...'; 990 - 991 - fetch(`${{globalPds}}/xrpc/com.atproto.repo.listRecords?repo=${{did}}&collection=${{lexicon}}&limit=5&cursor=${{cursor}}`) 992 - .then(r => r.json()) 993 - .then(moreData => {{ 994 - let moreHtml = ''; 995 - moreData.records.forEach((record, idx) => {{ 996 - const json = JSON.stringify(record.value, null, 2); 997 - const recordId = `record-more-${{Date.now()}}-${{idx}}`; 998 - moreHtml += ` 999 - <div class="record"> 1000 - <div class="record-header"> 1001 - <span class="record-label">record</span> 1002 - <button class="copy-btn" data-content="${{encodeURIComponent(json)}}" data-record-id="${{recordId}}">copy</button> 1003 - </div> 1004 - <div class="record-content"> 1005 - <pre>${{json}}</pre> 1006 - </div> 1007 - </div> 1008 - `; 1009 - }}); 1010 - 1011 - loadMoreBtn.remove(); 1012 - recordListDiv.insertAdjacentHTML('beforeend', moreHtml); 1013 - 1014 - if (moreData.cursor && moreData.records.length === 5) {{ 1015 - recordListDiv.insertAdjacentHTML('beforeend', 1016 - `<button class="load-more" data-cursor="${{moreData.cursor}}" data-lexicon="${{lexicon}}">load more</button>` 1017 - ); 1018 - }} 1019 - }}); 1020 - }} 1021 - }}); 1022 - }} else {{ 1023 - recordListDiv.innerHTML = '<div class="record">no records found</div>'; 1024 - }} 1025 - }}) 1026 - .catch(e => {{ 1027 - console.error('Error fetching records:', e); 1028 - recordListDiv.innerHTML = '<div class="record">error loading records</div>'; 1029 - }}); 1030 - }}); 1031 - }}); 1032 - }}); 1033 - 1034 - field.appendChild(div); 1035 - }}); 1036 - 1037 - // Close detail panel when clicking canvas 1038 - const canvas = document.querySelector('.canvas'); 1039 - canvas.addEventListener('click', (e) => {{ 1040 - if (e.target === canvas) {{ 1041 - document.getElementById('detail').classList.remove('visible'); 1042 - }} 1043 - }}); 1044 - }}) 1045 - .catch(e => {{ 1046 - document.getElementById('field').innerHTML = 'error loading records'; 1047 - console.error(e); 1048 - }}); 1049 - </script> 1050 - </body> 1051 - </html> 1052 - "#, did) 6 + let template = include_str!("templates/app.html"); 7 + template.replace("{DID}", did) 1053 8 }
+2607
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 + let globalPds = null; 6 + let globalHandle = null; 7 + let globalApps = null; // Store apps for repositioning on resize 8 + let pageOwnerHasSigned = false; // Track if the page owner (did) has signed the guestbook 9 + let hiddenApps = new Set(); // Track which apps are hidden by filter 10 + 11 + // ============================================================================ 12 + // APP FILTER FUNCTIONALITY 13 + // ============================================================================ 14 + 15 + // Parse hidden apps from URL param 16 + function getHiddenAppsFromUrl() { 17 + const params = new URLSearchParams(window.location.search); 18 + const hideParam = params.get('hide'); 19 + if (hideParam) { 20 + return new Set(hideParam.split(',').filter(Boolean)); 21 + } 22 + return null; 23 + } 24 + 25 + // Update URL with current hidden apps (without page reload) 26 + function updateUrlWithFilters() { 27 + const params = new URLSearchParams(window.location.search); 28 + if (hiddenApps.size > 0) { 29 + params.set('hide', [...hiddenApps].join(',')); 30 + } else { 31 + params.delete('hide'); 32 + } 33 + const newUrl = params.toString() 34 + ? `${window.location.pathname}?${params.toString()}` 35 + : window.location.pathname; 36 + history.replaceState(null, '', newUrl); 37 + } 38 + 39 + // Load hidden apps from URL param first, then localStorage 40 + function loadHiddenApps() { 41 + // URL takes precedence over localStorage 42 + const urlHidden = getHiddenAppsFromUrl(); 43 + if (urlHidden) { 44 + hiddenApps = urlHidden; 45 + return; 46 + } 47 + 48 + try { 49 + const stored = localStorage.getItem(`atme_hidden_apps_${did}`); 50 + if (stored) { 51 + hiddenApps = new Set(JSON.parse(stored)); 52 + } 53 + } catch (e) { 54 + hiddenApps = new Set(); 55 + } 56 + } 57 + 58 + // Save hidden apps to localStorage and update URL 59 + function saveHiddenApps() { 60 + try { 61 + localStorage.setItem(`atme_hidden_apps_${did}`, JSON.stringify([...hiddenApps])); 62 + } catch (e) { 63 + // Silently fail 64 + } 65 + updateUrlWithFilters(); 66 + } 67 + 68 + // Update filter button state 69 + function updateFilterButton() { 70 + const filterBtn = document.getElementById('filterBtn'); 71 + const filterCount = document.getElementById('filterCount'); 72 + 73 + if (hiddenApps.size > 0) { 74 + filterBtn.classList.add('has-filters'); 75 + filterCount.textContent = hiddenApps.size; 76 + filterCount.style.display = 'inline-block'; 77 + } else { 78 + filterBtn.classList.remove('has-filters'); 79 + filterCount.style.display = 'none'; 80 + } 81 + } 82 + 83 + // Apply filters to app circles and reposition visible ones 84 + function applyFilters() { 85 + const appViews = document.querySelectorAll('.app-view'); 86 + const visibleApps = []; 87 + 88 + appViews.forEach(view => { 89 + const circle = view.querySelector('.app-circle'); 90 + if (circle) { 91 + const namespace = circle.dataset.namespace; 92 + if (hiddenApps.has(namespace)) { 93 + view.classList.add('filtered'); 94 + } else { 95 + view.classList.remove('filtered'); 96 + visibleApps.push(view); 97 + } 98 + } 99 + }); 100 + 101 + // Reposition visible apps evenly around the circle 102 + if (visibleApps.length > 0 && globalApps) { 103 + const vmin = Math.min(window.innerWidth, window.innerHeight); 104 + const isMobile = window.innerWidth < 768; 105 + const visibleCount = visibleApps.length; 106 + 107 + let circleSize = globalApps._circleSize || 50; 108 + let radius; 109 + 110 + if (isMobile) { 111 + if (visibleCount <= 5) { 112 + radius = vmin * 0.38; 113 + } else if (visibleCount <= 10) { 114 + radius = vmin * 0.4; 115 + } else if (visibleCount <= 20) { 116 + radius = vmin * 0.42; 117 + } else { 118 + radius = vmin * 0.44; 119 + } 120 + radius = Math.max(radius, 120); 121 + } else { 122 + radius = Math.max(vmin * 0.35, 150); 123 + } 124 + 125 + const centerX = window.innerWidth / 2; 126 + const centerY = window.innerHeight / 2; 127 + const circleOffset = circleSize / 2; 128 + 129 + visibleApps.forEach((view, i) => { 130 + const angle = (i / visibleCount) * 2 * Math.PI - Math.PI / 2; 131 + const x = centerX + radius * Math.cos(angle) - circleOffset; 132 + const y = centerY + radius * Math.sin(angle) - circleOffset; 133 + view.style.left = `${x}px`; 134 + view.style.top = `${y}px`; 135 + }); 136 + } 137 + 138 + updateFilterButton(); 139 + saveHiddenApps(); 140 + } 141 + 142 + // Populate filter list with apps 143 + function populateFilterList() { 144 + if (!globalApps) return; 145 + 146 + const filterList = document.getElementById('filterList'); 147 + const appNames = Object.keys(globalApps).filter(k => k !== '_circleSize').sort(); 148 + 149 + filterList.innerHTML = appNames.map(namespace => { 150 + const displayName = namespace.split('.').reverse().join('.'); 151 + const isChecked = !hiddenApps.has(namespace); 152 + return ` 153 + <div class="filter-item ${isChecked ? 'checked' : ''}" data-namespace="${namespace}"> 154 + <div class="filter-checkbox"> 155 + <svg class="filter-checkbox-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor"> 156 + <polyline points="20 6 9 17 4 12"></polyline> 157 + </svg> 158 + </div> 159 + <span class="filter-label">${displayName}</span> 160 + </div> 161 + `; 162 + }).join(''); 163 + 164 + // Add click handlers 165 + filterList.querySelectorAll('.filter-item').forEach(item => { 166 + item.addEventListener('click', () => { 167 + const namespace = item.dataset.namespace; 168 + if (hiddenApps.has(namespace)) { 169 + hiddenApps.delete(namespace); 170 + item.classList.add('checked'); 171 + } else { 172 + hiddenApps.add(namespace); 173 + item.classList.remove('checked'); 174 + } 175 + applyFilters(); 176 + }); 177 + }); 178 + } 179 + 180 + // Initialize filter panel handlers 181 + function initFilterPanel() { 182 + const filterBtn = document.getElementById('filterBtn'); 183 + const filterPanel = document.getElementById('filterPanel'); 184 + const filterShowAll = document.getElementById('filterShowAll'); 185 + const filterHideUnresolved = document.getElementById('filterHideUnresolved'); 186 + const filterHideAll = document.getElementById('filterHideAll'); 187 + 188 + if (!filterBtn || !filterPanel || !filterShowAll || !filterHideUnresolved || !filterHideAll) { 189 + console.error('Filter panel elements not found:', { filterBtn, filterPanel, filterShowAll, filterHideUnresolved, filterHideAll }); 190 + return; 191 + } 192 + 193 + // Toggle panel 194 + filterBtn.addEventListener('click', (e) => { 195 + e.stopPropagation(); 196 + filterPanel.classList.toggle('visible'); 197 + filterBtn.classList.toggle('active'); 198 + if (filterPanel.classList.contains('visible')) { 199 + populateFilterList(); 200 + } 201 + }); 202 + 203 + // Close panel when clicking outside 204 + document.addEventListener('click', (e) => { 205 + if (!filterPanel.contains(e.target) && e.target !== filterBtn && !filterBtn.contains(e.target)) { 206 + filterPanel.classList.remove('visible'); 207 + filterBtn.classList.remove('active'); 208 + } 209 + }); 210 + 211 + // Show all 212 + filterShowAll.addEventListener('click', (e) => { 213 + e.preventDefault(); 214 + e.stopPropagation(); 215 + hiddenApps.clear(); 216 + populateFilterList(); 217 + applyFilters(); 218 + }); 219 + 220 + // Show only valid (hide unresolved domains) 221 + filterHideUnresolved.addEventListener('click', (e) => { 222 + e.preventDefault(); 223 + e.stopPropagation(); 224 + // Find all apps with invalid-link class and hide them 225 + const appViews = document.querySelectorAll('.app-view'); 226 + hiddenApps.clear(); 227 + appViews.forEach(view => { 228 + const link = view.querySelector('.app-name'); 229 + const circle = view.querySelector('.app-circle'); 230 + if (link && link.classList.contains('invalid-link') && circle) { 231 + hiddenApps.add(circle.dataset.namespace); 232 + } 233 + }); 234 + populateFilterList(); 235 + applyFilters(); 236 + }); 237 + 238 + // Hide all 239 + filterHideAll.addEventListener('click', (e) => { 240 + e.preventDefault(); 241 + e.stopPropagation(); 242 + if (!globalApps) return; 243 + const appNames = Object.keys(globalApps).filter(k => k !== '_circleSize'); 244 + hiddenApps = new Set(appNames); 245 + populateFilterList(); 246 + applyFilters(); 247 + }); 248 + } 249 + 250 + // Load filters on startup 251 + loadHiddenApps(); 252 + 253 + // Adaptive handle text sizing 254 + function adaptHandleTextSize(handleEl) { 255 + const identity = handleEl.closest('.identity'); 256 + if (!identity) return; 257 + 258 + // Get identity circle size 259 + const maxWidth = identity.offsetWidth * 0.85; // 85% of circle width for padding 260 + 261 + // Start with the CSS-defined font size and scale down if needed 262 + const computedStyle = window.getComputedStyle(handleEl); 263 + const maxFontSize = parseFloat(computedStyle.fontSize); 264 + let fontSize = maxFontSize; 265 + 266 + // Create temporary element to measure text width 267 + const measure = document.createElement('span'); 268 + measure.style.visibility = 'hidden'; 269 + measure.style.position = 'absolute'; 270 + measure.style.whiteSpace = 'nowrap'; 271 + measure.style.fontFamily = computedStyle.fontFamily; 272 + measure.textContent = handleEl.textContent; 273 + document.body.appendChild(measure); 274 + 275 + // Reduce font size until text fits 276 + while (fontSize > 8) { // minimum 8px 277 + measure.style.fontSize = fontSize + 'px'; 278 + if (measure.offsetWidth <= maxWidth) break; 279 + fontSize -= 0.5; 280 + } 281 + 282 + document.body.removeChild(measure); 283 + handleEl.style.fontSize = fontSize + 'px'; 284 + } 285 + 286 + // Fetch app avatar from server 287 + async function fetchAppAvatar(namespace) { 288 + try { 289 + const response = await fetch(`/api/avatar?namespace=${encodeURIComponent(namespace)}`); 290 + const data = await response.json(); 291 + return data.avatarUrl; 292 + } catch (e) { 293 + return null; 294 + } 295 + } 296 + 297 + async function fetchAppAvatars(namespaces) { 298 + if (!Array.isArray(namespaces) || namespaces.length === 0) return {}; 299 + const uniqueNamespaces = [...new Set(namespaces.filter(Boolean))]; 300 + if (!uniqueNamespaces.length) return {}; 301 + 302 + try { 303 + const response = await fetch('/api/avatar/batch', { 304 + method: 'POST', 305 + headers: { 'Content-Type': 'application/json' }, 306 + body: JSON.stringify({ namespaces: uniqueNamespaces }) 307 + }); 308 + if (!response.ok) return {}; 309 + const data = await response.json(); 310 + return data.avatars || {}; 311 + } catch (e) { 312 + return {}; 313 + } 314 + } 315 + 316 + // Info modal handlers 317 + document.getElementById('infoBtn').addEventListener('click', () => { 318 + document.getElementById('infoModal').classList.add('visible'); 319 + document.getElementById('overlay').classList.add('visible'); 320 + }); 321 + 322 + document.getElementById('closeInfo').addEventListener('click', () => { 323 + document.getElementById('infoModal').classList.remove('visible'); 324 + document.getElementById('overlay').classList.remove('visible'); 325 + }); 326 + 327 + document.getElementById('overlay').addEventListener('click', () => { 328 + document.getElementById('infoModal').classList.remove('visible'); 329 + document.getElementById('overlay').classList.remove('visible'); 330 + const detail = document.getElementById('detail'); 331 + detail.classList.remove('visible'); 332 + }); 333 + 334 + // Update loading progress 335 + const loadingProgress = document.getElementById('loadingProgress'); 336 + if (loadingProgress) { 337 + loadingProgress.textContent = 'fetching identity data...'; 338 + } 339 + 340 + // Fetch initialization data from server 341 + fetch(`/api/init?did=${encodeURIComponent(did)}`) 342 + .then(r => { 343 + if (loadingProgress) { 344 + loadingProgress.textContent = 'processing namespaces...'; 345 + } 346 + return r.json(); 347 + }) 348 + .then(initData => { 349 + globalPds = initData.pds; 350 + globalHandle = initData.handle; 351 + 352 + // Store viewed person's info for guestbook button 353 + viewedHandle = initData.handle; 354 + viewedAvatar = initData.avatar; 355 + 356 + // Update POV indicator 357 + const povHandleEl = document.getElementById('povHandle'); 358 + if (povHandleEl) { 359 + povHandleEl.textContent = `@${viewedHandle}`; 360 + povHandleEl.href = `https://bsky.app/profile/${viewedHandle}`; 361 + } 362 + 363 + // Display user's avatar if available 364 + const avatarEl = document.getElementById('avatar'); 365 + if (initData.avatar && avatarEl) { 366 + avatarEl.src = initData.avatar; 367 + avatarEl.alt = initData.handle; 368 + } 369 + 370 + // Update guestbook button with viewed person's info 371 + updateGuestbookButton(); 372 + 373 + // Convert apps array to object for easier access 374 + const apps = {}; 375 + const allCollections = []; 376 + initData.apps.forEach(app => { 377 + apps[app.namespace] = app.collections; 378 + allCollections.push(...app.collections); 379 + }); 380 + 381 + // Store apps globally for repositioning 382 + globalApps = apps; 383 + 384 + // Add identity click handler now that we have the data 385 + const pdsHost = globalPds.replace('https://', '').replace('http://', ''); 386 + document.querySelector('.identity').addEventListener('click', () => { 387 + const detail = document.getElementById('detail'); 388 + const appCount = initData.apps.length; 389 + 390 + detail.innerHTML = ` 391 + <button class="detail-close" id="detailClose">ร—</button> 392 + <h3>your personal data server</h3> 393 + <div class="subtitle">where your social data lives</div> 394 + 395 + <div class="stats-box"> 396 + <div class="stat"> 397 + <div class="stat-value">${allCollections.length}</div> 398 + <div class="stat-label">record types</div> 399 + </div> 400 + <div class="stat"> 401 + <div class="stat-value">${appCount}</div> 402 + <div class="stat-label">apps</div> 403 + </div> 404 + </div> 405 + 406 + <div class="ownership-box yours"> 407 + <div class="ownership-header">your pds location</div> 408 + <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> 409 + </div> 410 + 411 + <div class="ownership-box"> 412 + <div class="ownership-header">explore your data</div> 413 + <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> 414 + </div> 415 + 416 + <a href="https://bsky.app/profile/${globalHandle}" target="_blank" rel="noopener noreferrer" class="tree-item" style="text-decoration: none; display: block; margin-top: 1rem;"> 417 + <div class="tree-item-header"> 418 + <div style="display: flex; align-items: center; gap: 0.5rem;"> 419 + <svg width="16" height="16" viewBox="0 0 600 530" fill="none" xmlns="http://www.w3.org/2000/svg"> 420 + <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)" /> 421 + </svg> 422 + <span style="color: var(--text-light);">view profile on bluesky</span> 423 + </div> 424 + <span style="font-size: 0.6rem; color: var(--text);">โ†—</span> 425 + </div> 426 + </a> 427 + 428 + <div style="margin-top: 1.5rem; padding-top: 1rem; border-top: 1px solid var(--border);"> 429 + <div style="font-size: 0.65rem; color: var(--text-light); margin-bottom: 0.5rem;">technical details</div> 430 + <div class="tree-item"> 431 + <div class="tree-item-header"> 432 + <span style="color: var(--text-light);">did</span> 433 + <span style="font-size: 0.55rem; color: var(--text);">${did}</span> 434 + </div> 435 + </div> 436 + <div class="tree-item"> 437 + <div class="tree-item-header"> 438 + <span style="color: var(--text-light);">handle</span> 439 + <span style="font-size: 0.6rem; color: var(--text);">@${globalHandle}</span> 440 + </div> 441 + </div> 442 + </div> 443 + `; 444 + detail.classList.add('visible'); 445 + 446 + // Add close button handler 447 + document.getElementById('detailClose').addEventListener('click', (e) => { 448 + e.stopPropagation(); 449 + detail.classList.remove('visible'); 450 + }); 451 + }); 452 + 453 + const field = document.getElementById('field'); 454 + field.innerHTML = ''; 455 + field.classList.remove('loading'); 456 + 457 + const appNames = Object.keys(apps).sort(); 458 + const appCount = appNames.length; 459 + 460 + // Hide labels on mobile when there are too many apps 461 + const isMobileView = window.innerWidth < 768; 462 + if (isMobileView && appCount > 20) { 463 + field.classList.add('many-apps'); 464 + } else { 465 + field.classList.remove('many-apps'); 466 + } 467 + 468 + // Calculate circle size and radius based on viewport and app count 469 + const vmin = Math.min(window.innerWidth, window.innerHeight); 470 + const isMobile = window.innerWidth < 768; 471 + 472 + let circleSize; 473 + let radius; 474 + 475 + if (isMobile) { 476 + // Mobile: more aggressive scaling for many apps 477 + if (appCount <= 5) { 478 + circleSize = Math.min(60, vmin * 0.08); 479 + radius = vmin * 0.38; 480 + } else if (appCount <= 10) { 481 + circleSize = Math.min(50, vmin * 0.07); 482 + radius = vmin * 0.4; 483 + } else if (appCount <= 20) { 484 + circleSize = Math.min(40, vmin * 0.055); 485 + radius = vmin * 0.42; 486 + } else { 487 + circleSize = Math.min(32, vmin * 0.045); 488 + radius = vmin * 0.44; 489 + } 490 + circleSize = Math.max(circleSize, 28); // Smaller minimum on mobile 491 + radius = Math.max(radius, 120); 492 + } else { 493 + // Desktop: original logic with slight tweaks 494 + if (appCount <= 5) { 495 + circleSize = Math.min(70, vmin * 0.1); 496 + } else if (appCount <= 10) { 497 + circleSize = Math.min(60, vmin * 0.09); 498 + } else if (appCount <= 20) { 499 + circleSize = Math.min(50, vmin * 0.07); 500 + } else { 501 + circleSize = Math.min(40, vmin * 0.06); 502 + } 503 + circleSize = Math.max(circleSize, 35); 504 + radius = Math.max(vmin * 0.35, 150); 505 + } 506 + 507 + const centerX = window.innerWidth / 2; 508 + const centerY = window.innerHeight / 2; 509 + 510 + // Store circle size for resize handler 511 + globalApps._circleSize = circleSize; 512 + 513 + // Create all app divs first 514 + const appDivs = appNames.map((namespace, i) => { 515 + const angle = (i / appNames.length) * 2 * Math.PI - Math.PI / 2; // Start from top 516 + const circleOffset = circleSize / 2; 517 + const x = centerX + radius * Math.cos(angle) - circleOffset; 518 + const y = centerY + radius * Math.sin(angle) - circleOffset; 519 + 520 + const div = document.createElement('div'); 521 + div.className = 'app-view'; 522 + div.style.left = `${x}px`; 523 + div.style.top = `${y}px`; 524 + 525 + const firstLetter = namespace.split('.')[1]?.[0]?.toUpperCase() || namespace[0].toUpperCase(); 526 + 527 + // Reverse namespace for display (app.bsky -> bsky.app) 528 + const displayName = namespace.split('.').reverse().join('.'); 529 + const url = `https://${displayName}`; 530 + 531 + div.innerHTML = ` 532 + <div class="app-circle" data-namespace="${namespace}" style="width: ${circleSize}px; height: ${circleSize}px; font-size: ${circleSize * 0.4}px;">${firstLetter}</div> 533 + <a href="${url}" target="_blank" rel="noopener noreferrer" class="app-name" data-url="${url}">${displayName} โ†—</a> 534 + `; 535 + 536 + return { div, namespace }; 537 + }); 538 + 539 + // Fetch all avatars concurrently via batch endpoint 540 + fetchAppAvatars(appNames).then(avatarMap => { 541 + appDivs.forEach(({ div, namespace }) => { 542 + const avatarUrl = avatarMap[namespace]; 543 + if (avatarUrl) { 544 + const circle = div.querySelector('.app-circle'); 545 + circle.innerHTML = `<img src="${avatarUrl}" class="app-logo" alt="${namespace}" />`; 546 + } 547 + }); 548 + }); 549 + 550 + // Collect validation promises to apply default filter after all complete 551 + const validationPromises = []; 552 + 553 + appDivs.forEach(({ div, namespace }, i) => { 554 + // Reverse namespace for display and create URL 555 + const displayName = namespace.split('.').reverse().join('.'); 556 + const url = `https://${displayName}`; 557 + 558 + // Validate URL 559 + const validationPromise = fetch(`/api/validate-url?url=${encodeURIComponent(url)}`) 560 + .then(r => r.json()) 561 + .then(data => { 562 + const link = div.querySelector('.app-name'); 563 + if (!data.valid) { 564 + link.classList.add('invalid-link'); 565 + link.setAttribute('title', 'this domain is not reachable'); 566 + link.style.pointerEvents = 'none'; 567 + // Remove arrow from invalid links 568 + link.textContent = displayName; 569 + } 570 + }) 571 + .catch(() => { 572 + // Silently fail validation check 573 + }); 574 + validationPromises.push(validationPromise); 575 + 576 + div.addEventListener('click', () => { 577 + const detail = document.getElementById('detail'); 578 + const collections = apps[namespace]; 579 + 580 + // Reverse namespace for display and create URL 581 + const displayName = namespace.split('.').reverse().join('.'); 582 + const appUrl = `https://${displayName}`; 583 + 584 + // Check if the link was already validated as invalid 585 + const appLink = div.querySelector('.app-name'); 586 + const isInvalid = appLink && appLink.classList.contains('invalid-link'); 587 + 588 + let html = ` 589 + <button class="detail-close" id="detailClose">ร—</button> 590 + <h3> 591 + ${isInvalid 592 + ? `<span style="color: var(--text-light); opacity: 0.5;">${displayName}</span>` 593 + : `<a href="${appUrl}" target="_blank" rel="noopener noreferrer" style="color: var(--text); text-decoration: none; border-bottom: 1px solid var(--border);">${displayName} โ†—</a>` 594 + } 595 + </h3> 596 + <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> 597 + `; 598 + 599 + if (collections && collections.length > 0) { 600 + // Group collections by sub-namespace (third segment) 601 + const grouped = {}; 602 + collections.forEach(lexicon => { 603 + const parts = lexicon.split('.'); 604 + const subNamespace = parts.slice(2).join('.'); 605 + const firstPart = parts[2] || lexicon; 606 + 607 + if (!grouped[firstPart]) grouped[firstPart] = []; 608 + grouped[firstPart].push({ lexicon, subNamespace }); 609 + }); 610 + 611 + // Sort and display grouped items 612 + Object.keys(grouped).sort().forEach(group => { 613 + const items = grouped[group]; 614 + 615 + if (items.length === 1 && items[0].subNamespace === group) { 616 + // Single item with no further nesting 617 + html += ` 618 + <div class="tree-item" data-lexicon="${items[0].lexicon}"> 619 + <div class="tree-item-header"> 620 + <span>${group}</span> 621 + <span class="tree-item-count">loading...</span> 622 + </div> 623 + </div> 624 + `; 625 + } else { 626 + // Group header 627 + html += `<div style="margin-bottom: 0.75rem;">`; 628 + html += `<div style="font-size: 0.7rem; color: var(--text-light); margin-bottom: 0.4rem; font-weight: 500;">${group}</div>`; 629 + 630 + // Items in group 631 + items.sort((a, b) => a.subNamespace.localeCompare(b.subNamespace)).forEach(item => { 632 + const displayName = item.subNamespace.split('.').slice(1).join('.') || item.subNamespace; 633 + html += ` 634 + <div class="tree-item" data-lexicon="${item.lexicon}" style="margin-left: 0.75rem;"> 635 + <div class="tree-item-header"> 636 + <span>${displayName}</span> 637 + <span class="tree-item-count">loading...</span> 638 + </div> 639 + </div> 640 + `; 641 + }); 642 + html += `</div>`; 643 + } 644 + }); 645 + } else { 646 + html += `<div class="tree-item">no collections found</div>`; 647 + } 648 + 649 + detail.innerHTML = html; 650 + detail.classList.add('visible'); 651 + 652 + // Add close button handler 653 + document.getElementById('detailClose').addEventListener('click', (e) => { 654 + e.stopPropagation(); 655 + detail.classList.remove('visible'); 656 + }); 657 + 658 + // Fetch record counts for each collection 659 + if (collections && collections.length > 0) { 660 + collections.forEach(lexicon => { 661 + fetch(`${globalPds}/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=${lexicon}&limit=1`) 662 + .then(r => r.json()) 663 + .then(data => { 664 + const item = detail.querySelector(`[data-lexicon="${lexicon}"]`); 665 + if (item) { 666 + const countSpan = item.querySelector('.tree-item-count'); 667 + // The cursor field indicates there are more records 668 + countSpan.textContent = data.records?.length > 0 ? 'has records' : 'empty'; 669 + } 670 + }) 671 + .catch(e => { 672 + console.error('Error fetching count for', lexicon, e); 673 + const item = detail.querySelector(`[data-lexicon="${lexicon}"]`); 674 + if (item) { 675 + const countSpan = item.querySelector('.tree-item-count'); 676 + countSpan.textContent = 'error'; 677 + } 678 + }); 679 + }); 680 + } 681 + 682 + // Add click handlers to tree items to fetch actual records 683 + detail.querySelectorAll('.tree-item[data-lexicon]').forEach(item => { 684 + item.addEventListener('click', (e) => { 685 + e.stopPropagation(); 686 + const lexicon = item.dataset.lexicon; 687 + const existingContent = item.querySelector('.collection-content'); 688 + 689 + if (existingContent) { 690 + existingContent.remove(); 691 + return; 692 + } 693 + 694 + // Create container for tabs and content 695 + const contentDiv = document.createElement('div'); 696 + contentDiv.className = 'collection-content'; 697 + 698 + // Will add tabs after we know record count 699 + contentDiv.innerHTML = ` 700 + <div class="collection-view-content"> 701 + <div class="collection-view records-view active"> 702 + <div class="loading">loading records...</div> 703 + </div> 704 + <div class="collection-view structure-view"> 705 + <div class="loading">loading structure...</div> 706 + </div> 707 + </div> 708 + `; 709 + item.appendChild(contentDiv); 710 + 711 + const recordsView = contentDiv.querySelector('.records-view'); 712 + const structureView = contentDiv.querySelector('.structure-view'); 713 + 714 + // Load records first to determine if we should show structure tab 715 + fetch(`${globalPds}/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=${lexicon}&limit=10`) 716 + .then(r => r.json()) 717 + .then(data => { 718 + // Add tabs if there are enough records for structure view 719 + const hasEnoughRecords = data.records && data.records.length >= 5; 720 + if (hasEnoughRecords) { 721 + const tabsHtml = ` 722 + <div class="collection-tabs"> 723 + <button class="collection-tab active" data-tab="records">records</button> 724 + <button class="collection-tab" data-tab="structure">mst</button> 725 + </div> 726 + `; 727 + contentDiv.insertAdjacentHTML('afterbegin', tabsHtml); 728 + 729 + // Tab switching logic 730 + contentDiv.querySelectorAll('.collection-tab').forEach(tab => { 731 + tab.addEventListener('click', (e) => { 732 + e.stopPropagation(); 733 + const tabName = tab.dataset.tab; 734 + 735 + // Update active tab 736 + contentDiv.querySelectorAll('.collection-tab').forEach(t => t.classList.remove('active')); 737 + tab.classList.add('active'); 738 + 739 + // Update active view 740 + contentDiv.querySelectorAll('.collection-view').forEach(v => v.classList.remove('active')); 741 + if (tabName === 'records') { 742 + recordsView.classList.add('active'); 743 + } else if (tabName === 'structure') { 744 + structureView.classList.add('active'); 745 + // Load structure if not already loaded 746 + if (structureView.querySelector('.loading')) { 747 + loadMSTStructure(lexicon, structureView); 748 + } 749 + } 750 + }); 751 + }); 752 + } 753 + 754 + if (data.records && data.records.length > 0) { 755 + let recordsHtml = ''; 756 + data.records.forEach((record, idx) => { 757 + const json = JSON.stringify(record.value, null, 2); 758 + const recordId = `record-${Date.now()}-${idx}`; 759 + recordsHtml += ` 760 + <div class="record"> 761 + <div class="record-header"> 762 + <span class="record-label">record</span> 763 + <button class="copy-btn" data-content="${encodeURIComponent(json)}" data-record-id="${recordId}">copy</button> 764 + </div> 765 + <div class="record-content"> 766 + <pre>${json}</pre> 767 + </div> 768 + </div> 769 + `; 770 + }); 771 + 772 + if (data.cursor && data.records.length === 5) { 773 + recordsHtml += `<button class="load-more" data-cursor="${data.cursor}" data-lexicon="${lexicon}">load more</button>`; 774 + } 775 + 776 + recordsView.innerHTML = recordsHtml; 777 + 778 + // Use event delegation for copy and load more buttons 779 + recordsView.addEventListener('click', (e) => { 780 + // Handle copy button 781 + if (e.target.classList.contains('copy-btn')) { 782 + e.stopPropagation(); 783 + const copyBtn = e.target; 784 + const content = decodeURIComponent(copyBtn.dataset.content); 785 + 786 + navigator.clipboard.writeText(content).then(() => { 787 + const originalText = copyBtn.textContent; 788 + copyBtn.textContent = 'copied!'; 789 + copyBtn.classList.add('copied'); 790 + setTimeout(() => { 791 + copyBtn.textContent = originalText; 792 + copyBtn.classList.remove('copied'); 793 + }, 1500); 794 + }).catch(err => { 795 + console.error('Failed to copy:', err); 796 + copyBtn.textContent = 'error'; 797 + setTimeout(() => { 798 + copyBtn.textContent = 'copy'; 799 + }, 1500); 800 + }); 801 + } 802 + 803 + // Handle load more button 804 + if (e.target.classList.contains('load-more')) { 805 + e.stopPropagation(); 806 + const loadMoreBtn = e.target; 807 + const cursor = loadMoreBtn.dataset.cursor; 808 + const lexicon = loadMoreBtn.dataset.lexicon; 809 + 810 + loadMoreBtn.textContent = 'loading...'; 811 + 812 + fetch(`${globalPds}/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=${lexicon}&limit=5&cursor=${cursor}`) 813 + .then(r => r.json()) 814 + .then(moreData => { 815 + let moreHtml = ''; 816 + moreData.records.forEach((record, idx) => { 817 + const json = JSON.stringify(record.value, null, 2); 818 + const recordId = `record-more-${Date.now()}-${idx}`; 819 + moreHtml += ` 820 + <div class="record"> 821 + <div class="record-header"> 822 + <span class="record-label">record</span> 823 + <button class="copy-btn" data-content="${encodeURIComponent(json)}" data-record-id="${recordId}">copy</button> 824 + </div> 825 + <div class="record-content"> 826 + <pre>${json}</pre> 827 + </div> 828 + </div> 829 + `; 830 + }); 831 + 832 + loadMoreBtn.remove(); 833 + recordsView.insertAdjacentHTML('beforeend', moreHtml); 834 + 835 + if (moreData.cursor && moreData.records.length === 5) { 836 + recordsView.insertAdjacentHTML('beforeend', 837 + `<button class="load-more" data-cursor="${moreData.cursor}" data-lexicon="${lexicon}">load more</button>` 838 + ); 839 + } 840 + }); 841 + } 842 + }); 843 + } else { 844 + recordsView.innerHTML = '<div class="record">no records found</div>'; 845 + } 846 + }) 847 + .catch(e => { 848 + console.error('Error fetching records:', e); 849 + recordsView.innerHTML = '<div class="record">error loading records</div>'; 850 + }); 851 + }); 852 + }); 853 + }); 854 + 855 + field.appendChild(div); 856 + }); 857 + 858 + // Close detail panel when clicking canvas 859 + const canvas = document.querySelector('.canvas'); 860 + canvas.addEventListener('click', (e) => { 861 + if (e.target === canvas) { 862 + document.getElementById('detail').classList.remove('visible'); 863 + } 864 + }); 865 + 866 + // Add window resize handler to reposition app circles 867 + let resizeTimeout; 868 + window.addEventListener('resize', () => { 869 + clearTimeout(resizeTimeout); 870 + resizeTimeout = setTimeout(() => { 871 + repositionAppCircles(); 872 + }, 50); // Faster debounce for smoother updates 873 + }); 874 + 875 + // Initialize filter panel 876 + initFilterPanel(); 877 + 878 + // After all URL validations complete, apply default "valid" filter (hide unresolved) 879 + Promise.all(validationPromises).then(() => { 880 + // Only apply default if user hasn't set any filters yet AND no URL param was provided 881 + const hasUrlFilters = getHiddenAppsFromUrl() !== null; 882 + if (hiddenApps.size === 0 && !hasUrlFilters) { 883 + // Hide apps with invalid-link class by default 884 + const appViews = document.querySelectorAll('.app-view'); 885 + appViews.forEach(view => { 886 + const link = view.querySelector('.app-name'); 887 + const circle = view.querySelector('.app-circle'); 888 + if (link && link.classList.contains('invalid-link') && circle) { 889 + hiddenApps.add(circle.dataset.namespace); 890 + } 891 + }); 892 + } 893 + applyFilters(); 894 + }); 895 + }) 896 + .catch(e => { 897 + document.getElementById('field').innerHTML = 'error loading records'; 898 + console.error(e); 899 + }); 900 + 901 + // Function to reposition app circles on window resize 902 + function repositionAppCircles() { 903 + if (!globalApps) return; 904 + 905 + const appViews = document.querySelectorAll('.app-view'); 906 + const appNames = Object.keys(globalApps).filter(k => k !== '_circleSize').sort(); 907 + const appCount = appNames.length; 908 + 909 + // Update label visibility on resize 910 + const field = document.getElementById('field'); 911 + const isMobileView = window.innerWidth < 768; 912 + if (isMobileView && appCount > 20) { 913 + field.classList.add('many-apps'); 914 + } else { 915 + field.classList.remove('many-apps'); 916 + } 917 + 918 + // Recalculate circle size and radius based on viewport and app count 919 + const vmin = Math.min(window.innerWidth, window.innerHeight); 920 + const isMobile = window.innerWidth < 768; 921 + 922 + let circleSize; 923 + let radius; 924 + 925 + if (isMobile) { 926 + // Mobile: more aggressive scaling for many apps 927 + if (appCount <= 5) { 928 + circleSize = Math.min(60, vmin * 0.08); 929 + radius = vmin * 0.38; 930 + } else if (appCount <= 10) { 931 + circleSize = Math.min(50, vmin * 0.07); 932 + radius = vmin * 0.4; 933 + } else if (appCount <= 20) { 934 + circleSize = Math.min(40, vmin * 0.055); 935 + radius = vmin * 0.42; 936 + } else { 937 + circleSize = Math.min(32, vmin * 0.045); 938 + radius = vmin * 0.44; 939 + } 940 + circleSize = Math.max(circleSize, 28); 941 + radius = Math.max(radius, 120); 942 + } else { 943 + // Desktop: original logic with slight tweaks 944 + if (appCount <= 5) { 945 + circleSize = Math.min(70, vmin * 0.1); 946 + } else if (appCount <= 10) { 947 + circleSize = Math.min(60, vmin * 0.09); 948 + } else if (appCount <= 20) { 949 + circleSize = Math.min(50, vmin * 0.07); 950 + } else { 951 + circleSize = Math.min(40, vmin * 0.06); 952 + } 953 + circleSize = Math.max(circleSize, 35); 954 + radius = Math.max(vmin * 0.35, 150); 955 + } 956 + 957 + // Update stored circle size 958 + globalApps._circleSize = circleSize; 959 + 960 + // Recalculate center 961 + const centerX = window.innerWidth / 2; 962 + const centerY = window.innerHeight / 2; 963 + 964 + appViews.forEach((div, i) => { 965 + const angle = (i / appNames.length) * 2 * Math.PI - Math.PI / 2; 966 + const circleOffset = circleSize / 2; 967 + const x = centerX + radius * Math.cos(angle) - circleOffset; 968 + const y = centerY + radius * Math.sin(angle) - circleOffset; 969 + 970 + div.style.left = `${x}px`; 971 + div.style.top = `${y}px`; 972 + 973 + // Update circle size 974 + const circle = div.querySelector('.app-circle'); 975 + if (circle) { 976 + circle.style.width = `${circleSize}px`; 977 + circle.style.height = `${circleSize}px`; 978 + circle.style.fontSize = `${circleSize * 0.4}px`; 979 + } 980 + }); 981 + } 982 + 983 + // MST Visualization Functions 984 + async function loadMSTStructure(lexicon, containerView) { 985 + try { 986 + // Call server endpoint to build MST 987 + const response = await fetch(`/api/mst?pds=${encodeURIComponent(globalPds)}&did=${encodeURIComponent(did)}&collection=${encodeURIComponent(lexicon)}`); 988 + const data = await response.json(); 989 + 990 + if (data.error) { 991 + containerView.innerHTML = `<div class="mst-info"><p>${data.error}</p></div>`; 992 + return; 993 + } 994 + 995 + const { root, recordCount } = data; 996 + 997 + // Render structure 998 + containerView.innerHTML = ` 999 + <div class="mst-info"> 1000 + <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> 1001 + </div> 1002 + <canvas class="mst-canvas" id="mstCanvas-${Date.now()}"></canvas> 1003 + `; 1004 + 1005 + // Render tree on canvas 1006 + setTimeout(() => { 1007 + const canvas = containerView.querySelector('.mst-canvas'); 1008 + if (canvas) { 1009 + renderMSTTree(canvas, root); 1010 + } 1011 + }, 50); 1012 + 1013 + } catch (e) { 1014 + console.error('Error loading MST structure:', e); 1015 + containerView.innerHTML = '<div class="mst-info"><p>error loading structure</p></div>'; 1016 + } 1017 + } 1018 + 1019 + function renderMSTTree(canvas, tree) { 1020 + const ctx = canvas.getContext('2d'); 1021 + const width = canvas.width = canvas.offsetWidth; 1022 + const height = canvas.height = canvas.offsetHeight; 1023 + 1024 + // Calculate tree layout 1025 + const layout = layoutTree(tree, width, height); 1026 + 1027 + // Get CSS colors 1028 + const borderColor = getComputedStyle(document.documentElement).getPropertyValue('--border').trim(); 1029 + const textColor = getComputedStyle(document.documentElement).getPropertyValue('--text').trim(); 1030 + const textLightColor = getComputedStyle(document.documentElement).getPropertyValue('--text-light').trim(); 1031 + const surfaceColor = getComputedStyle(document.documentElement).getPropertyValue('--surface').trim(); 1032 + const surfaceHoverColor = getComputedStyle(document.documentElement).getPropertyValue('--surface-hover').trim(); 1033 + const bgColor = getComputedStyle(document.documentElement).getPropertyValue('--bg').trim(); 1034 + 1035 + let hoveredNode = null; 1036 + 1037 + function draw() { 1038 + // Clear canvas 1039 + ctx.clearRect(0, 0, width, height); 1040 + 1041 + // Draw connections first 1042 + layout.forEach(node => { 1043 + if (node.children) { 1044 + node.children.forEach(child => { 1045 + ctx.beginPath(); 1046 + ctx.moveTo(node.x, node.y); 1047 + ctx.lineTo(child.x, child.y); 1048 + ctx.strokeStyle = borderColor; 1049 + ctx.lineWidth = 1; 1050 + ctx.stroke(); 1051 + }); 1052 + } 1053 + }); 1054 + 1055 + // Draw nodes 1056 + layout.forEach(node => { 1057 + const isRoot = node.depth === -1; 1058 + const isLeaf = !node.children || node.children.length === 0; 1059 + const isHovered = hoveredNode === node; 1060 + 1061 + // Node circle 1062 + ctx.beginPath(); 1063 + ctx.arc(node.x, node.y, isRoot ? 12 : 8, 0, Math.PI * 2); 1064 + 1065 + ctx.fillStyle = isRoot ? textColor : isLeaf ? surfaceHoverColor : surfaceColor; 1066 + ctx.fill(); 1067 + 1068 + ctx.strokeStyle = isHovered ? textColor : borderColor; 1069 + ctx.lineWidth = isRoot ? 2 : isHovered ? 2 : 1; 1070 + ctx.stroke(); 1071 + }); 1072 + 1073 + // Draw label for hovered node 1074 + if (hoveredNode && hoveredNode.key && hoveredNode.key !== 'root') { 1075 + const padding = 6; 1076 + const fontSize = 10; 1077 + ctx.font = `${fontSize}px monospace`; 1078 + const textWidth = ctx.measureText(hoveredNode.key).width; 1079 + 1080 + // Position tooltip above node 1081 + const tooltipX = hoveredNode.x; 1082 + const tooltipY = hoveredNode.y - 20; 1083 + const boxWidth = textWidth + padding * 2; 1084 + const boxHeight = fontSize + padding * 2; 1085 + 1086 + // Draw tooltip background 1087 + ctx.fillStyle = bgColor; 1088 + ctx.fillRect(tooltipX - boxWidth / 2, tooltipY - boxHeight / 2, boxWidth, boxHeight); 1089 + 1090 + // Draw tooltip border 1091 + ctx.strokeStyle = borderColor; 1092 + ctx.lineWidth = 1; 1093 + ctx.strokeRect(tooltipX - boxWidth / 2, tooltipY - boxHeight / 2, boxWidth, boxHeight); 1094 + 1095 + // Draw text 1096 + ctx.fillStyle = textColor; 1097 + ctx.textAlign = 'center'; 1098 + ctx.textBaseline = 'middle'; 1099 + ctx.fillText(hoveredNode.key, tooltipX, tooltipY); 1100 + } 1101 + } 1102 + 1103 + // Mouse move handler 1104 + canvas.addEventListener('mousemove', (e) => { 1105 + const rect = canvas.getBoundingClientRect(); 1106 + const mouseX = e.clientX - rect.left; 1107 + const mouseY = e.clientY - rect.top; 1108 + 1109 + let foundNode = null; 1110 + for (const node of layout) { 1111 + const isRoot = node.depth === -1; 1112 + const radius = isRoot ? 12 : 8; 1113 + const dist = Math.sqrt((mouseX - node.x) ** 2 + (mouseY - node.y) ** 2); 1114 + if (dist <= radius) { 1115 + foundNode = node; 1116 + break; 1117 + } 1118 + } 1119 + 1120 + if (foundNode !== hoveredNode) { 1121 + hoveredNode = foundNode; 1122 + canvas.style.cursor = hoveredNode ? 'pointer' : 'default'; 1123 + draw(); 1124 + } 1125 + }); 1126 + 1127 + // Mouse leave handler 1128 + canvas.addEventListener('mouseleave', () => { 1129 + if (hoveredNode) { 1130 + hoveredNode = null; 1131 + canvas.style.cursor = 'default'; 1132 + draw(); 1133 + } 1134 + }); 1135 + 1136 + // Click handler 1137 + canvas.addEventListener('click', (e) => { 1138 + if (hoveredNode && hoveredNode.key && hoveredNode.key !== 'root') { 1139 + showNodeModal(hoveredNode); 1140 + } 1141 + }); 1142 + 1143 + // Initial draw 1144 + draw(); 1145 + } 1146 + 1147 + function showNodeModal(node) { 1148 + // Create modal 1149 + const modal = document.createElement('div'); 1150 + modal.className = 'mst-node-modal'; 1151 + modal.innerHTML = ` 1152 + <div class="mst-node-modal-content"> 1153 + <button class="mst-node-close">ร—</button> 1154 + <h3>record in MST</h3> 1155 + <div class="mst-node-info"> 1156 + <div class="mst-node-field"> 1157 + <span class="mst-node-label">TID:</span> 1158 + <span class="mst-node-value">${node.key}</span> 1159 + </div> 1160 + <div class="mst-node-field"> 1161 + <span class="mst-node-label">CID:</span> 1162 + <span class="mst-node-value">${node.cid}</span> 1163 + </div> 1164 + ${node.uri ? ` 1165 + <div class="mst-node-field"> 1166 + <span class="mst-node-label">URI:</span> 1167 + <span class="mst-node-value">${node.uri}</span> 1168 + </div> 1169 + ` : ''} 1170 + </div> 1171 + <div class="mst-node-explanation"> 1172 + <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> 1173 + </div> 1174 + ${node.value ? ` 1175 + <div class="mst-node-data"> 1176 + <div class="mst-node-data-header">record data</div> 1177 + <pre>${JSON.stringify(node.value, null, 2)}</pre> 1178 + </div> 1179 + ` : ''} 1180 + </div> 1181 + `; 1182 + 1183 + // Add to DOM 1184 + document.body.appendChild(modal); 1185 + 1186 + // Close handlers 1187 + modal.querySelector('.mst-node-close').addEventListener('click', () => { 1188 + modal.remove(); 1189 + }); 1190 + 1191 + modal.addEventListener('click', (e) => { 1192 + if (e.target === modal) { 1193 + modal.remove(); 1194 + } 1195 + }); 1196 + } 1197 + 1198 + function layoutTree(tree, width, height) { 1199 + const nodes = []; 1200 + const padding = 40; 1201 + const availableWidth = width - padding * 2; 1202 + const availableHeight = height - padding * 2; 1203 + 1204 + // Calculate max depth and total nodes at each depth 1205 + const depthCounts = {}; 1206 + function countDepths(node, depth) { 1207 + if (!depthCounts[depth]) depthCounts[depth] = 0; 1208 + depthCounts[depth]++; 1209 + if (node.children) { 1210 + node.children.forEach(child => countDepths(child, depth + 1)); 1211 + } 1212 + } 1213 + countDepths(tree, 0); 1214 + 1215 + const maxDepth = Math.max(...Object.keys(depthCounts).map(Number)); 1216 + const verticalSpacing = availableHeight / (maxDepth + 1); 1217 + 1218 + // Track positions at each depth to avoid overlap 1219 + const positionsByDepth = {}; 1220 + 1221 + function traverse(node, depth, minX, maxX) { 1222 + if (!positionsByDepth[depth]) positionsByDepth[depth] = []; 1223 + 1224 + // Calculate position based on available space 1225 + const x = (minX + maxX) / 2; 1226 + const y = padding + verticalSpacing * depth; 1227 + 1228 + const layoutNode = { ...node, x, y }; 1229 + nodes.push(layoutNode); 1230 + positionsByDepth[depth].push(x); 1231 + 1232 + if (node.children && node.children.length > 0) { 1233 + layoutNode.children = []; 1234 + const childWidth = (maxX - minX) / node.children.length; 1235 + 1236 + node.children.forEach((child, idx) => { 1237 + const childMinX = minX + childWidth * idx; 1238 + const childMaxX = minX + childWidth * (idx + 1); 1239 + const childLayout = traverse(child, depth + 1, childMinX, childMaxX); 1240 + layoutNode.children.push(childLayout); 1241 + }); 1242 + } 1243 + 1244 + return layoutNode; 1245 + } 1246 + 1247 + traverse(tree, 0, padding, width - padding); 1248 + return nodes; 1249 + } 1250 + 1251 + // ============================================================================ 1252 + // FIREHOSE VISUALIZATION 1253 + // ============================================================================ 1254 + 1255 + // Particle class for animating firehose events 1256 + class FirehoseParticle { 1257 + constructor(startX, startY, endX, endY, color, metadata) { 1258 + this.x = startX; 1259 + this.y = startY; 1260 + this.startX = startX; 1261 + this.startY = startY; 1262 + this.endX = endX; 1263 + this.endY = endY; 1264 + this.color = color; 1265 + this.metadata = metadata; // {action, collection, namespace} 1266 + this.progress = 0; 1267 + this.speed = 0.008; // Slower, more graceful 1268 + this.size = 6; // Slightly larger core 1269 + this.glowSize = 14; // Softer, wider glow 1270 + } 1271 + 1272 + update() { 1273 + if (this.progress < 1) { 1274 + this.progress += this.speed; 1275 + // Gentle ease-out for organic feel 1276 + const eased = 1 - Math.pow(1 - this.progress, 3); 1277 + 1278 + this.x = this.startX + (this.endX - this.startX) * eased; 1279 + this.y = this.startY + (this.endY - this.startY) * eased; 1280 + } 1281 + return this.progress < 1; 1282 + } 1283 + 1284 + draw(ctx) { 1285 + // Calculate fade based on progress for elegant entry/exit 1286 + const fadeIn = Math.min(this.progress * 4, 1); // Fade in over first 25% 1287 + const fadeOut = this.progress > 0.8 ? 1 - ((this.progress - 0.8) / 0.2) : 1; // Fade out in last 20% 1288 + const opacity = Math.min(fadeIn, fadeOut); 1289 + 1290 + // Outer glow - softer and more diffuse 1291 + ctx.beginPath(); 1292 + ctx.arc(this.x, this.y, this.glowSize, 0, Math.PI * 2); 1293 + const gradient = ctx.createRadialGradient( 1294 + this.x, this.y, 0, 1295 + this.x, this.y, this.glowSize 1296 + ); 1297 + // Use lower opacity for subtlety 1298 + gradient.addColorStop(0, this.color + Math.floor(opacity * 60).toString(16).padStart(2, '0')); 1299 + gradient.addColorStop(0.5, this.color + Math.floor(opacity * 30).toString(16).padStart(2, '0')); 1300 + gradient.addColorStop(1, this.color + '00'); 1301 + ctx.fillStyle = gradient; 1302 + ctx.fill(); 1303 + 1304 + // Inner particle - subtle core 1305 + ctx.beginPath(); 1306 + ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2); 1307 + ctx.fillStyle = this.color + Math.floor(opacity * 180).toString(16).padStart(2, '0'); 1308 + ctx.fill(); 1309 + } 1310 + } 1311 + 1312 + // Firehose state 1313 + let firehoseParticles = []; 1314 + let firehoseCanvas = null; 1315 + let firehoseCtx = null; 1316 + let firehoseAnimationId = null; 1317 + let firehoseEventSource = null; 1318 + let isWatchingLive = false; 1319 + 1320 + function initFirehoseCanvas() { 1321 + // Create canvas overlay 1322 + firehoseCanvas = document.createElement('canvas'); 1323 + firehoseCanvas.id = 'firehoseCanvas'; 1324 + firehoseCanvas.style.position = 'fixed'; 1325 + firehoseCanvas.style.top = '0'; 1326 + firehoseCanvas.style.left = '0'; 1327 + firehoseCanvas.style.width = '100%'; 1328 + firehoseCanvas.style.height = '100%'; 1329 + firehoseCanvas.style.pointerEvents = 'none'; 1330 + firehoseCanvas.style.zIndex = '50'; 1331 + firehoseCanvas.width = window.innerWidth; 1332 + firehoseCanvas.height = window.innerHeight; 1333 + 1334 + document.body.appendChild(firehoseCanvas); 1335 + firehoseCtx = firehoseCanvas.getContext('2d'); 1336 + 1337 + // Handle window resize 1338 + window.addEventListener('resize', () => { 1339 + firehoseCanvas.width = window.innerWidth; 1340 + firehoseCanvas.height = window.innerHeight; 1341 + }); 1342 + } 1343 + 1344 + function animateFirehoseParticles() { 1345 + if (!firehoseCtx) return; 1346 + 1347 + firehoseCtx.clearRect(0, 0, firehoseCanvas.width, firehoseCanvas.height); 1348 + 1349 + // Update and draw all particles 1350 + firehoseParticles = firehoseParticles.filter(particle => { 1351 + const alive = particle.update(); 1352 + if (alive) { 1353 + particle.draw(firehoseCtx); 1354 + } else { 1355 + // Particle reached destination - pulse the identity/PDS 1356 + pulseIdentity(); 1357 + 1358 + // If this was a delete event, lazily check if app should be removed 1359 + if (particle.metadata && particle.metadata.action === 'delete') { 1360 + maybeRemoveAppCircle(particle.metadata.namespace); 1361 + } 1362 + } 1363 + return alive; 1364 + }); 1365 + 1366 + if (isWatchingLive) { 1367 + firehoseAnimationId = requestAnimationFrame(animateFirehoseParticles); 1368 + } 1369 + } 1370 + 1371 + function pulseIdentity() { 1372 + const identity = document.querySelector('.identity'); 1373 + if (identity) { 1374 + // Subtle but visible pulse with contextual glow 1375 + const textColor = getComputedStyle(document.documentElement).getPropertyValue('--text').trim(); 1376 + identity.style.transition = 'all 0.5s cubic-bezier(0.4, 0, 0.2, 1)'; 1377 + // Maintain centering transform while applying gentle scale 1378 + identity.style.transform = 'translate(-50%, -50%) scale(1.03)'; 1379 + identity.style.boxShadow = `0 0 25px ${textColor}50, 0 0 45px ${textColor}25`; 1380 + 1381 + setTimeout(() => { 1382 + identity.style.transition = 'all 0.7s cubic-bezier(0.4, 0, 0.2, 1)'; 1383 + identity.style.transform = 'translate(-50%, -50%)'; 1384 + identity.style.boxShadow = ''; 1385 + }, 400); 1386 + } 1387 + } 1388 + 1389 + async function fetchRecordDetails(pds, did, collection, rkey) { 1390 + try { 1391 + const response = await fetch( 1392 + `/api/record?pds=${encodeURIComponent(pds)}&did=${encodeURIComponent(did)}&collection=${encodeURIComponent(collection)}&rkey=${encodeURIComponent(rkey)}` 1393 + ); 1394 + const data = await response.json(); 1395 + if (data.error) return null; 1396 + return data.value; 1397 + } catch (e) { 1398 + console.error('Error fetching record:', e); 1399 + return null; 1400 + } 1401 + } 1402 + 1403 + function formatToastMessage(action, collection, record) { 1404 + const actionText = { 1405 + 'create': 'created', 1406 + 'update': 'updated', 1407 + 'delete': 'deleted' 1408 + }[action] || action; 1409 + 1410 + // If we don't have record details, fall back to basic message with code-formatted collection 1411 + if (!record) { 1412 + return { 1413 + action: `${actionText} record`, 1414 + details: `<code style="background: var(--bg); padding: 0.1rem 0.3rem; border-radius: 2px; font-size: 0.6rem;">${collection}</code>` 1415 + }; 1416 + } 1417 + 1418 + // Format based on collection type 1419 + if (collection === 'app.bsky.feed.post') { 1420 + const text = record.text || ''; 1421 + const preview = text.length > 50 ? text.substring(0, 50) + '...' : text; 1422 + return { 1423 + action: `${actionText} post`, 1424 + details: preview || 'no text' 1425 + }; 1426 + } else if (collection === 'app.bsky.feed.like') { 1427 + return { 1428 + action: `${actionText} like`, 1429 + details: '' 1430 + }; 1431 + } else if (collection === 'app.bsky.feed.repost') { 1432 + return { 1433 + action: `${actionText} repost`, 1434 + details: '' 1435 + }; 1436 + } else if (collection === 'app.bsky.graph.follow') { 1437 + return { 1438 + action: `${actionText} follow`, 1439 + details: '' 1440 + }; 1441 + } else if (collection === 'app.bsky.actor.profile') { 1442 + const displayName = record.displayName || ''; 1443 + return { 1444 + action: `${actionText} profile`, 1445 + details: displayName || 'updated profile' 1446 + }; 1447 + } 1448 + 1449 + // Default for unknown collections with code formatting 1450 + return { 1451 + action: `${actionText} record`, 1452 + details: `<code style="background: var(--bg); padding: 0.1rem 0.3rem; border-radius: 2px; font-size: 0.6rem;">${collection}</code>` 1453 + }; 1454 + } 1455 + 1456 + async function showFirehoseToast(event) { 1457 + const toast = document.getElementById('firehoseToast'); 1458 + const actionEl = toast.querySelector('.firehose-toast-action'); 1459 + const collectionEl = toast.querySelector('.firehose-toast-collection'); 1460 + const linkEl = document.getElementById('firehoseToastLink'); 1461 + 1462 + // Hide link for delete events, show for others 1463 + if (event.action === 'delete') { 1464 + linkEl.style.display = 'none'; 1465 + } else { 1466 + linkEl.style.display = 'inline-block'; 1467 + // Build PDS link for the record 1468 + if (globalPds && event.did && event.collection && event.rkey) { 1469 + const recordUrl = `${globalPds}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(event.did)}&collection=${encodeURIComponent(event.collection)}&rkey=${encodeURIComponent(event.rkey)}`; 1470 + linkEl.href = recordUrl; 1471 + } 1472 + } 1473 + 1474 + // Fetch record details if available (skip for deletes) 1475 + let record = null; 1476 + if (event.action !== 'delete' && event.rkey && globalPds) { 1477 + record = await fetchRecordDetails(globalPds, event.did, event.collection, event.rkey); 1478 + } 1479 + 1480 + const formatted = formatToastMessage(event.action, event.collection, record); 1481 + 1482 + actionEl.textContent = formatted.action; 1483 + collectionEl.innerHTML = formatted.details; 1484 + 1485 + toast.classList.add('visible'); 1486 + setTimeout(() => { 1487 + toast.classList.remove('visible'); 1488 + }, 4000); // Slightly longer to read details 1489 + } 1490 + 1491 + function getParticleColor() { 1492 + // Use theme-aware color that represents data flow 1493 + // Get the text color from CSS variables and use it with reduced opacity 1494 + const textColor = getComputedStyle(document.documentElement).getPropertyValue('--text').trim(); 1495 + 1496 + // If we can parse it as rgb, use it; otherwise fall back to a neutral color 1497 + if (textColor.startsWith('rgb')) { 1498 + // Extract RGB values and return hex 1499 + const match = textColor.match(/(\d+),\s*(\d+),\s*(\d+)/); 1500 + if (match) { 1501 + const r = parseInt(match[1]); 1502 + const g = parseInt(match[2]); 1503 + const b = parseInt(match[3]); 1504 + return '#' + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1); 1505 + } 1506 + } 1507 + 1508 + // Fallback: soft blue-gray that works in both themes and represents "information flow" 1509 + return '#8ba4b8'; 1510 + } 1511 + 1512 + function createFirehoseParticle(event) { 1513 + // Get target identity/PDS position (where data is written) 1514 + const identity = document.querySelector('.identity'); 1515 + if (!identity) return; 1516 + 1517 + const identityRect = identity.getBoundingClientRect(); 1518 + const endX = identityRect.left + identityRect.width / 2; 1519 + const endY = identityRect.top + identityRect.height / 2; 1520 + 1521 + // Get source app circle position (where the action happened) 1522 + let appCircle = document.querySelector(`[data-namespace="${event.namespace}"]`); 1523 + 1524 + // If app circle doesn't exist and this is a create event, add it dynamically 1525 + if (!appCircle && event.action === 'create') { 1526 + // Reverse namespace for URL (app.at-me -> at-me.app) 1527 + const displayName = event.namespace.split('.').reverse().join('.'); 1528 + const url = `https://${displayName}`; 1529 + 1530 + // Add the app circle with the collection from the event 1531 + if (globalApps && !globalApps[event.namespace]) { 1532 + globalApps[event.namespace] = [event.collection]; 1533 + addAppCircle(event.namespace, url); 1534 + appCircle = document.querySelector(`[data-namespace="${event.namespace}"]`); 1535 + } 1536 + } 1537 + 1538 + let startX, startY; 1539 + if (appCircle) { 1540 + // App circle exists - start from there 1541 + const appRect = appCircle.getBoundingClientRect(); 1542 + startX = appRect.left + appRect.width / 2; 1543 + startY = appRect.top + appRect.height / 2; 1544 + } else { 1545 + // No app circle (shouldn't happen for creates, but fallback) 1546 + // Start from identity/PDS and just pulse it 1547 + startX = endX; 1548 + startY = endY; 1549 + } 1550 + 1551 + // Create particle (flows from app TO PDS, or pulses at PDS if no app circle) 1552 + // Color represents data flow, not specific to action type 1553 + const particle = new FirehoseParticle( 1554 + startX, startY, 1555 + endX, endY, 1556 + getParticleColor(), 1557 + { 1558 + action: event.action, 1559 + collection: event.collection, 1560 + namespace: event.namespace 1561 + } 1562 + ); 1563 + 1564 + firehoseParticles.push(particle); 1565 + } 1566 + 1567 + function connectFirehose() { 1568 + if (!did || firehoseEventSource) return; 1569 + 1570 + const url = `/api/firehose/watch?did=${encodeURIComponent(did)}`; 1571 + firehoseEventSource = new EventSource(url); 1572 + 1573 + const watchBtn = document.getElementById('watchLiveBtn'); 1574 + const watchLabel = watchBtn.querySelector('.watch-label'); 1575 + 1576 + firehoseEventSource.onopen = () => { 1577 + watchLabel.textContent = 'watching...'; 1578 + watchBtn.classList.add('active'); 1579 + }; 1580 + 1581 + firehoseEventSource.onmessage = (e) => { 1582 + try { 1583 + const data = JSON.parse(e.data); 1584 + 1585 + // Skip connection message 1586 + if (data.type === 'connected') return; 1587 + 1588 + // Create particle animation 1589 + createFirehoseParticle(data); 1590 + 1591 + // Show toast notification 1592 + showFirehoseToast(data); 1593 + } catch (error) { 1594 + console.error('Error processing firehose message:', error); 1595 + } 1596 + }; 1597 + 1598 + firehoseEventSource.onerror = (error) => { 1599 + console.error('Firehose error:', error); 1600 + watchLabel.textContent = 'connection error'; 1601 + 1602 + // Attempt to reconnect after delay 1603 + if (isWatchingLive) { 1604 + setTimeout(() => { 1605 + if (firehoseEventSource) { 1606 + firehoseEventSource.close(); 1607 + firehoseEventSource = null; 1608 + } 1609 + if (isWatchingLive) { 1610 + watchLabel.textContent = 'reconnecting...'; 1611 + connectFirehose(); 1612 + } 1613 + }, 3000); 1614 + } 1615 + }; 1616 + } 1617 + 1618 + function disconnectFirehose() { 1619 + if (firehoseEventSource) { 1620 + firehoseEventSource.close(); 1621 + firehoseEventSource = null; 1622 + } 1623 + 1624 + if (firehoseAnimationId) { 1625 + cancelAnimationFrame(firehoseAnimationId); 1626 + firehoseAnimationId = null; 1627 + } 1628 + 1629 + firehoseParticles = []; 1630 + if (firehoseCtx) { 1631 + firehoseCtx.clearRect(0, 0, firehoseCanvas.width, firehoseCanvas.height); 1632 + } 1633 + } 1634 + 1635 + // Toggle watch live 1636 + document.addEventListener('DOMContentLoaded', () => { 1637 + const watchBtn = document.getElementById('watchLiveBtn'); 1638 + if (!watchBtn) return; 1639 + 1640 + const watchLabel = watchBtn.querySelector('.watch-label'); 1641 + 1642 + function startWatching() { 1643 + if (isWatchingLive) return; 1644 + 1645 + isWatchingLive = true; 1646 + watchLabel.textContent = 'connecting...'; 1647 + initFirehoseCanvas(); 1648 + connectFirehose(); 1649 + animateFirehoseParticles(); 1650 + 1651 + // Update URL 1652 + const url = new URL(window.location); 1653 + url.searchParams.set('watching', 'true'); 1654 + window.history.replaceState({}, '', url); 1655 + } 1656 + 1657 + function stopWatching() { 1658 + if (!isWatchingLive) return; 1659 + 1660 + isWatchingLive = false; 1661 + watchLabel.textContent = 'watch live'; 1662 + watchBtn.classList.remove('active'); 1663 + disconnectFirehose(); 1664 + 1665 + // Clean up canvas 1666 + if (firehoseCanvas) { 1667 + firehoseCanvas.remove(); 1668 + firehoseCanvas = null; 1669 + firehoseCtx = null; 1670 + } 1671 + 1672 + // Update URL 1673 + const url = new URL(window.location); 1674 + url.searchParams.delete('watching'); 1675 + window.history.replaceState({}, '', url); 1676 + } 1677 + 1678 + watchBtn.addEventListener('click', () => { 1679 + if (isWatchingLive) { 1680 + stopWatching(); 1681 + } else { 1682 + startWatching(); 1683 + } 1684 + }); 1685 + 1686 + // Check for watching parameter on load 1687 + const urlParams = new URLSearchParams(window.location.search); 1688 + if (urlParams.get('watching') === 'true') { 1689 + startWatching(); 1690 + } 1691 + }); 1692 + 1693 + // ============================================================================ 1694 + // GUESTBOOK FEATURE 1695 + // ============================================================================ 1696 + 1697 + let isAuthenticated = false; 1698 + let authenticatedDid = null; // The DID of the logged-in user 1699 + let authenticatedHandle = null; // The handle of the logged-in user 1700 + let authenticatedAvatar = null; // The avatar of the logged-in user 1701 + let hasRecords = false; 1702 + let viewedHandle = null; // Handle of the person whose page we're viewing 1703 + let viewedAvatar = null; // Avatar of the person whose page we're viewing 1704 + 1705 + // Function to dynamically add an app circle to the UI 1706 + function addAppCircle(namespace, url) { 1707 + // Check if app circle DOM element already exists 1708 + const existingCircle = document.querySelector(`[data-namespace="${namespace}"]`); 1709 + if (existingCircle) { 1710 + return; // already rendered 1711 + } 1712 + 1713 + // Add to globalApps if not already there (preserve any existing collections) 1714 + if (!globalApps) globalApps = {}; 1715 + if (!globalApps[namespace]) { 1716 + globalApps[namespace] = []; // No collections yet 1717 + } 1718 + 1719 + const field = document.getElementById('field'); 1720 + const appViews = document.querySelectorAll('.app-view'); 1721 + const appNames = Object.keys(globalApps).filter(k => k !== '_circleSize').sort(); 1722 + const appCount = appNames.length; 1723 + const appIndex = appNames.indexOf(namespace); 1724 + 1725 + if (appIndex === -1) return; // namespace not found in sorted list 1726 + 1727 + // Recalculate positions for all apps 1728 + const vmin = Math.min(window.innerWidth, window.innerHeight); 1729 + const isMobile = window.innerWidth < 768; 1730 + 1731 + let circleSize = globalApps._circleSize || 50; 1732 + let radius; 1733 + 1734 + if (isMobile) { 1735 + if (appCount <= 5) { 1736 + circleSize = Math.min(60, vmin * 0.08); 1737 + radius = vmin * 0.38; 1738 + } else if (appCount <= 10) { 1739 + circleSize = Math.min(50, vmin * 0.07); 1740 + radius = vmin * 0.4; 1741 + } else if (appCount <= 20) { 1742 + circleSize = Math.min(40, vmin * 0.055); 1743 + radius = vmin * 0.42; 1744 + } else { 1745 + circleSize = Math.min(32, vmin * 0.045); 1746 + radius = vmin * 0.44; 1747 + } 1748 + circleSize = Math.max(circleSize, 28); 1749 + radius = Math.max(radius, 120); 1750 + } else { 1751 + if (appCount <= 5) { 1752 + circleSize = Math.min(70, vmin * 0.1); 1753 + } else if (appCount <= 10) { 1754 + circleSize = Math.min(60, vmin * 0.09); 1755 + } else if (appCount <= 20) { 1756 + circleSize = Math.min(50, vmin * 0.07); 1757 + } else { 1758 + circleSize = Math.min(40, vmin * 0.06); 1759 + } 1760 + circleSize = Math.max(circleSize, 35); 1761 + radius = Math.max(vmin * 0.35, 150); 1762 + } 1763 + 1764 + globalApps._circleSize = circleSize; 1765 + 1766 + const centerX = window.innerWidth / 2; 1767 + const centerY = window.innerHeight / 2; 1768 + 1769 + // Calculate position for new app 1770 + const angle = (appIndex / appNames.length) * 2 * Math.PI - Math.PI / 2; 1771 + const circleOffset = circleSize / 2; 1772 + const x = centerX + radius * Math.cos(angle) - circleOffset; 1773 + const y = centerY + radius * Math.sin(angle) - circleOffset; 1774 + 1775 + // Create app div 1776 + const div = document.createElement('div'); 1777 + div.className = 'app-view'; 1778 + div.style.left = `${x}px`; 1779 + div.style.top = `${y}px`; 1780 + 1781 + const firstLetter = namespace.split('.')[1]?.[0]?.toUpperCase() || namespace[0].toUpperCase(); 1782 + 1783 + // Reverse namespace for display (app.at-me -> at-me.app) 1784 + const displayName = namespace.split('.').reverse().join('.'); 1785 + 1786 + div.innerHTML = ` 1787 + <div class="app-circle" data-namespace="${namespace}" style="width: ${circleSize}px; height: ${circleSize}px; font-size: ${circleSize * 0.4}px;">${firstLetter}</div> 1788 + <a href="${url}" target="_blank" rel="noopener noreferrer" class="app-name" data-url="${url}">${displayName} โ†—</a> 1789 + `; 1790 + 1791 + field.appendChild(div); 1792 + 1793 + // Apply filter if this app is hidden 1794 + if (hiddenApps.has(namespace)) { 1795 + div.classList.add('filtered'); 1796 + } 1797 + 1798 + // Fetch avatar 1799 + fetchAppAvatar(namespace).then(avatarUrl => { 1800 + if (avatarUrl) { 1801 + const circle = div.querySelector('.app-circle'); 1802 + circle.innerHTML = `<img src="${avatarUrl}" class="app-logo" alt="${namespace}" />`; 1803 + } 1804 + }); 1805 + 1806 + // Add click handler 1807 + div.addEventListener('click', () => { 1808 + const detail = document.getElementById('detail'); 1809 + const collections = globalApps[namespace] || []; 1810 + 1811 + detail.innerHTML = ` 1812 + <button class="detail-close" id="detailClose">ร—</button> 1813 + <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> 1814 + <div class="subtitle">guestbook for your PDS</div> 1815 + <div class="tree-item" data-lexicon="app.at-me.visit"> 1816 + <div class="tree-item-header"> 1817 + <span>visit</span> 1818 + <span class="tree-item-count">loading...</span> 1819 + </div> 1820 + </div> 1821 + `; 1822 + detail.classList.add('visible'); 1823 + 1824 + document.getElementById('detailClose').addEventListener('click', (e) => { 1825 + e.stopPropagation(); 1826 + detail.classList.remove('visible'); 1827 + }); 1828 + 1829 + // Fetch record count 1830 + const collection = 'app.at-me.visit'; 1831 + fetch(`${globalPds}/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=${collection}&limit=1`) 1832 + .then(r => r.json()) 1833 + .then(data => { 1834 + const item = detail.querySelector(`[data-lexicon="${collection}"]`); 1835 + if (item) { 1836 + const countSpan = item.querySelector('.tree-item-count'); 1837 + countSpan.textContent = data.records?.length > 0 ? 'has records' : 'empty'; 1838 + } 1839 + }) 1840 + .catch(e => { 1841 + console.error('Error fetching count for', collection, e); 1842 + const item = detail.querySelector(`[data-lexicon="${collection}"]`); 1843 + if (item) { 1844 + const countSpan = item.querySelector('.tree-item-count'); 1845 + countSpan.textContent = 'error'; 1846 + } 1847 + }); 1848 + 1849 + // Add click handler to expand and show records 1850 + detail.querySelector('.tree-item[data-lexicon]').addEventListener('click', (e) => { 1851 + e.stopPropagation(); 1852 + const item = e.currentTarget; 1853 + const lexicon = item.dataset.lexicon; 1854 + const existingContent = item.querySelector('.collection-content'); 1855 + 1856 + if (existingContent) { 1857 + existingContent.remove(); 1858 + return; 1859 + } 1860 + 1861 + // Create container for content 1862 + const contentDiv = document.createElement('div'); 1863 + contentDiv.className = 'collection-content'; 1864 + contentDiv.innerHTML = ` 1865 + <div class="collection-view-content"> 1866 + <div class="collection-view records-view active"> 1867 + <div class="loading">loading records...</div> 1868 + </div> 1869 + </div> 1870 + `; 1871 + item.appendChild(contentDiv); 1872 + 1873 + const recordsView = contentDiv.querySelector('.records-view'); 1874 + 1875 + // Load records 1876 + fetch(`${globalPds}/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=${lexicon}&limit=10`) 1877 + .then(r => r.json()) 1878 + .then(data => { 1879 + if (data.records && data.records.length > 0) { 1880 + let recordsHtml = ''; 1881 + data.records.forEach((record, idx) => { 1882 + const json = JSON.stringify(record.value, null, 2); 1883 + const recordId = `record-${Date.now()}-${idx}`; 1884 + recordsHtml += ` 1885 + <div class="record"> 1886 + <div class="record-header"> 1887 + <span class="record-label">record</span> 1888 + <button class="copy-btn" data-content="${encodeURIComponent(json)}" data-record-id="${recordId}">copy</button> 1889 + </div> 1890 + <div class="record-content"> 1891 + <pre>${json}</pre> 1892 + </div> 1893 + </div> 1894 + `; 1895 + }); 1896 + recordsView.innerHTML = recordsHtml; 1897 + 1898 + // Add copy button handlers 1899 + recordsView.addEventListener('click', (e) => { 1900 + if (e.target.classList.contains('copy-btn')) { 1901 + e.stopPropagation(); 1902 + const copyBtn = e.target; 1903 + const content = decodeURIComponent(copyBtn.dataset.content); 1904 + 1905 + navigator.clipboard.writeText(content).then(() => { 1906 + const originalText = copyBtn.textContent; 1907 + copyBtn.textContent = 'copied!'; 1908 + copyBtn.classList.add('copied'); 1909 + setTimeout(() => { 1910 + copyBtn.textContent = originalText; 1911 + copyBtn.classList.remove('copied'); 1912 + }, 1500); 1913 + }).catch(err => { 1914 + console.error('Failed to copy:', err); 1915 + copyBtn.textContent = 'error'; 1916 + setTimeout(() => { 1917 + copyBtn.textContent = 'copy'; 1918 + }, 1500); 1919 + }); 1920 + } 1921 + }); 1922 + } else { 1923 + recordsView.innerHTML = '<div class="record">no records found</div>'; 1924 + } 1925 + }) 1926 + .catch(e => { 1927 + console.error('Error fetching records:', e); 1928 + recordsView.innerHTML = '<div class="record">error loading records</div>'; 1929 + }); 1930 + }); 1931 + }); 1932 + 1933 + // Reposition all existing apps to make room 1934 + repositionAppCircles(); 1935 + } 1936 + 1937 + // Function to remove an app circle from the UI 1938 + function removeAppCircle(namespace) { 1939 + // Find and remove the DOM element 1940 + const appElement = document.querySelector(`.app-view [data-namespace="${namespace}"]`)?.closest('.app-view'); 1941 + if (appElement) { 1942 + appElement.remove(); 1943 + } 1944 + 1945 + // Remove from globalApps 1946 + if (globalApps && globalApps[namespace]) { 1947 + delete globalApps[namespace]; 1948 + } 1949 + 1950 + // Reposition remaining circles 1951 + repositionAppCircles(); 1952 + } 1953 + 1954 + // Check if a namespace still has records (any collection with count > 0) 1955 + async function namespaceHasRecords(namespace) { 1956 + if (!globalApps || !globalApps[namespace] || !globalPds) { 1957 + return false; 1958 + } 1959 + 1960 + // Check each collection in this namespace 1961 + for (const collection of globalApps[namespace]) { 1962 + try { 1963 + const url = `${globalPds}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(did)}&collection=${encodeURIComponent(collection)}&limit=1`; 1964 + const response = await fetch(url); 1965 + if (response.ok) { 1966 + const data = await response.json(); 1967 + if (data.records && data.records.length > 0) { 1968 + return true; // Found at least one record 1969 + } 1970 + } 1971 + } catch (e) { 1972 + console.error(`Error checking records for ${collection}:`, e); 1973 + } 1974 + } 1975 + return false; 1976 + } 1977 + 1978 + // Lazily check and remove app circle if namespace has no more records 1979 + async function maybeRemoveAppCircle(namespace) { 1980 + const hasRecords = await namespaceHasRecords(namespace); 1981 + if (!hasRecords) { 1982 + removeAppCircle(namespace); 1983 + } 1984 + } 1985 + 1986 + // Check auth status on page load 1987 + async function checkAuthStatus() { 1988 + try { 1989 + const response = await fetch('/api/auth/status'); 1990 + const data = await response.json(); 1991 + const wasAuthenticated = isAuthenticated; 1992 + isAuthenticated = data.authenticated; 1993 + authenticatedDid = data.did || null; 1994 + authenticatedHandle = data.handle || null; 1995 + authenticatedAvatar = data.avatar || null; 1996 + hasRecords = data.hasRecords; 1997 + updateGuestbookButton(); 1998 + 1999 + // Show welcome toast if just authenticated AND viewing own page 2000 + const viewingOwnPage = isAuthenticated && authenticatedDid === did; 2001 + if (isAuthenticated && !wasAuthenticated && viewingOwnPage) { 2002 + const urlParams = new URLSearchParams(window.location.search); 2003 + if (urlParams.get('auth') === 'success') { 2004 + showAuthSuccessToast(); 2005 + // Clean up URL 2006 + urlParams.delete('auth'); 2007 + const newUrl = window.location.pathname + (urlParams.toString() ? '?' + urlParams.toString() : ''); 2008 + window.history.replaceState({}, '', newUrl); 2009 + } 2010 + } 2011 + } catch (e) { 2012 + console.error('[Guestbook] Failed to check auth status:', e); 2013 + } 2014 + } 2015 + 2016 + function showAuthSuccessToast() { 2017 + const toast = document.getElementById('firehoseToast'); 2018 + const actionEl = toast.querySelector('.firehose-toast-action'); 2019 + const collectionEl = toast.querySelector('.firehose-toast-collection'); 2020 + const linkEl = document.getElementById('firehoseToastLink'); 2021 + 2022 + // Hide link for this toast 2023 + linkEl.style.display = 'none'; 2024 + 2025 + actionEl.textContent = 'signed in successfully'; 2026 + collectionEl.innerHTML = 'you may now sign or unsign the guestbook with your identity'; 2027 + 2028 + toast.classList.add('visible'); 2029 + setTimeout(() => { 2030 + toast.classList.remove('visible'); 2031 + }, 5000); 2032 + } 2033 + 2034 + async function checkPageOwnerSignature() { 2035 + // Check if the page owner (did) has signed the guestbook by querying their PDS directly 2036 + try { 2037 + const response = await fetch(`/api/guestbook/check-signature?did=${encodeURIComponent(did)}`); 2038 + if (!response.ok) return false; 2039 + 2040 + const data = await response.json(); 2041 + pageOwnerHasSigned = data.hasSigned; 2042 + 2043 + updateGuestbookSign(); 2044 + return pageOwnerHasSigned; 2045 + } catch (error) { 2046 + console.error('[Guestbook] Error checking page owner signature:', error); 2047 + return false; 2048 + } 2049 + } 2050 + 2051 + function updateGuestbookSign() { 2052 + const sign = document.querySelector('.guestbook-sign'); 2053 + if (sign) { 2054 + sign.textContent = pageOwnerHasSigned ? 'you already signed' : 'sign the guest list'; 2055 + } 2056 + } 2057 + 2058 + function updateGuestbookButton() { 2059 + const signGuestbookBtn = document.getElementById('signGuestbookBtn'); 2060 + if (!signGuestbookBtn) return; 2061 + 2062 + const avatarImg = document.getElementById('guestbookAvatar'); 2063 + const iconSpan = signGuestbookBtn.querySelector('.guestbook-icon'); 2064 + const textSpan = signGuestbookBtn.querySelector('.guestbook-text'); 2065 + 2066 + if (!iconSpan || !textSpan) { 2067 + console.warn('[Guestbook] Button structure missing icon or text span'); 2068 + return; 2069 + } 2070 + 2071 + // Remove all state classes 2072 + signGuestbookBtn.classList.remove('signed', 'pulse'); 2073 + signGuestbookBtn.style.background = ''; 2074 + signGuestbookBtn.style.color = ''; 2075 + signGuestbookBtn.style.opacity = ''; 2076 + signGuestbookBtn.style.cursor = ''; 2077 + 2078 + const viewingOwnPage = isAuthenticated && authenticatedDid === did; 2079 + 2080 + // If page owner has already signed, show "signed" state (regardless of who's viewing) 2081 + if (pageOwnerHasSigned) { 2082 + if (avatarImg) avatarImg.style.display = 'none'; 2083 + 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>'; 2084 + iconSpan.style.display = 'flex'; 2085 + textSpan.textContent = 'signed'; 2086 + signGuestbookBtn.classList.add('signed'); 2087 + signGuestbookBtn.setAttribute('title', viewingOwnPage ? 'you\'ve signed the guestbook' : 'this user has signed the guestbook'); 2088 + signGuestbookBtn.disabled = false; // Allow clicking to view/unsign if own page 2089 + } else { 2090 + // NOT signed - ALWAYS show the page owner's avatar (viewedAvatar), regardless of auth state 2091 + if (viewedAvatar && avatarImg) { 2092 + avatarImg.src = viewedAvatar; 2093 + avatarImg.style.display = 'block'; 2094 + iconSpan.style.display = 'none'; 2095 + } else { 2096 + // No avatar - hide both avatar and icon, just show text 2097 + if (avatarImg) avatarImg.style.display = 'none'; 2098 + iconSpan.style.display = 'none'; 2099 + } 2100 + 2101 + textSpan.textContent = 'sign as'; 2102 + 2103 + if (isAuthenticated) { 2104 + if (viewingOwnPage) { 2105 + // Viewing own page, authenticated, ready to sign 2106 + signGuestbookBtn.style.background = 'var(--surface)'; 2107 + signGuestbookBtn.style.color = 'var(--text)'; 2108 + signGuestbookBtn.classList.add('pulse'); 2109 + signGuestbookBtn.setAttribute('title', 'click to sign the guestbook'); 2110 + signGuestbookBtn.disabled = false; 2111 + } else { 2112 + // Authenticated but viewing someone else's page - disabled 2113 + signGuestbookBtn.setAttribute('title', 'visit your own page to sign'); 2114 + signGuestbookBtn.disabled = true; 2115 + signGuestbookBtn.style.opacity = '0.5'; 2116 + signGuestbookBtn.style.cursor = 'not-allowed'; 2117 + } 2118 + } else { 2119 + // Not authenticated - allow them to try to sign as this user 2120 + signGuestbookBtn.setAttribute('title', `sign in as @${viewedHandle || 'user'}`); 2121 + signGuestbookBtn.disabled = false; 2122 + } 2123 + } 2124 + } 2125 + 2126 + function showHandleConfirmation(suggestedHandle) { 2127 + // Create modal overlay 2128 + const overlay = document.createElement('div'); 2129 + overlay.className = 'overlay'; 2130 + overlay.style.display = 'block'; 2131 + 2132 + // Create modal 2133 + const modal = document.createElement('div'); 2134 + modal.className = 'info-modal'; 2135 + modal.style.display = 'block'; 2136 + modal.style.maxWidth = '400px'; 2137 + 2138 + modal.innerHTML = ` 2139 + <h2>confirm identity</h2> 2140 + <p style="margin-bottom: 1rem;">are you <strong>@${suggestedHandle}</strong>?</p> 2141 + <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> 2142 + <div style="display: flex; gap: 0.5rem; justify-content: flex-end;"> 2143 + <button id="cancelBtn" style="background: var(--bg);">no, cancel</button> 2144 + <button id="confirmBtn" style="background: var(--surface-hover);">yes, that's me</button> 2145 + </div> 2146 + `; 2147 + 2148 + document.body.appendChild(overlay); 2149 + document.body.appendChild(modal); 2150 + 2151 + const cancelBtn = document.getElementById('cancelBtn'); 2152 + const confirmBtn = document.getElementById('confirmBtn'); 2153 + 2154 + // Cancel 2155 + const closeModal = () => { 2156 + modal.remove(); 2157 + overlay.remove(); 2158 + }; 2159 + 2160 + cancelBtn.addEventListener('click', closeModal); 2161 + overlay.addEventListener('click', closeModal); 2162 + 2163 + // Confirm 2164 + confirmBtn.addEventListener('click', () => { 2165 + // Submit login form with the suggested handle 2166 + const form = document.createElement('form'); 2167 + form.method = 'POST'; 2168 + form.action = '/login'; 2169 + const hiddenInput = document.createElement('input'); 2170 + hiddenInput.type = 'hidden'; 2171 + hiddenInput.name = 'handle'; 2172 + hiddenInput.value = suggestedHandle; 2173 + form.appendChild(hiddenInput); 2174 + document.body.appendChild(form); 2175 + form.submit(); 2176 + }); 2177 + } 2178 + 2179 + function showWatchPrompt(onContinue) { 2180 + // If watch is already enabled, skip the prompt 2181 + if (isWatchingLive) { 2182 + onContinue(); 2183 + return; 2184 + } 2185 + 2186 + const overlay = document.createElement('div'); 2187 + overlay.className = 'overlay'; 2188 + overlay.style.display = 'block'; 2189 + 2190 + const modal = document.createElement('div'); 2191 + modal.className = 'info-modal'; 2192 + modal.style.display = 'block'; 2193 + modal.style.maxWidth = '450px'; 2194 + 2195 + modal.innerHTML = ` 2196 + <h2>watch it happen</h2> 2197 + <p style="margin-bottom: 1rem;">want to see your app activity in real-time?</p> 2198 + <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> 2199 + <div style="display: flex; gap: 0.5rem; justify-content: flex-end;"> 2200 + <button id="skipBtn" style="background: var(--bg);">skip</button> 2201 + <button id="watchBtn" style="background: var(--surface-hover);">enable watch live</button> 2202 + </div> 2203 + `; 2204 + 2205 + document.body.appendChild(overlay); 2206 + document.body.appendChild(modal); 2207 + 2208 + const skipBtn = document.getElementById('skipBtn'); 2209 + const watchBtn = document.getElementById('watchBtn'); 2210 + 2211 + const closeModal = () => { 2212 + modal.remove(); 2213 + overlay.remove(); 2214 + }; 2215 + 2216 + skipBtn.addEventListener('click', () => { 2217 + closeModal(); 2218 + onContinue(); 2219 + }); 2220 + 2221 + watchBtn.addEventListener('click', () => { 2222 + closeModal(); 2223 + // Enable watch mode 2224 + const watchLiveBtn = document.getElementById('watchLiveBtn'); 2225 + if (watchLiveBtn && !isWatchingLive) { 2226 + watchLiveBtn.click(); 2227 + } 2228 + // Give it a moment to connect 2229 + setTimeout(onContinue, 500); 2230 + }); 2231 + } 2232 + 2233 + function showMessageInputModal(onConfirm) { 2234 + const overlay = document.createElement('div'); 2235 + overlay.className = 'overlay'; 2236 + overlay.style.display = 'block'; 2237 + 2238 + const modal = document.createElement('div'); 2239 + modal.className = 'info-modal'; 2240 + modal.style.display = 'block'; 2241 + modal.style.maxWidth = '450px'; 2242 + 2243 + modal.innerHTML = ` 2244 + <h2>sign the guestbook</h2> 2245 + <p style="margin-bottom: 1rem; color: var(--text-light);">leave an optional message (or leave blank for just a signature)</p> 2246 + <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> 2247 + <div style="display: flex; gap: 0.5rem; justify-content: flex-end;"> 2248 + <button id="cancelMessageBtn" style="background: var(--bg);">cancel</button> 2249 + <button id="confirmMessageBtn" style="background: var(--surface-hover);">sign</button> 2250 + </div> 2251 + `; 2252 + 2253 + document.body.appendChild(overlay); 2254 + document.body.appendChild(modal); 2255 + 2256 + const textarea = document.getElementById('guestbookMessageInput'); 2257 + const cancelBtn = document.getElementById('cancelMessageBtn'); 2258 + const confirmBtn = document.getElementById('confirmMessageBtn'); 2259 + 2260 + const closeModal = () => { 2261 + modal.remove(); 2262 + overlay.remove(); 2263 + }; 2264 + 2265 + cancelBtn.addEventListener('click', closeModal); 2266 + overlay.addEventListener('click', closeModal); 2267 + 2268 + confirmBtn.addEventListener('click', () => { 2269 + const text = textarea.value.trim(); 2270 + closeModal(); 2271 + onConfirm(text); 2272 + }); 2273 + 2274 + // Focus the textarea 2275 + setTimeout(() => textarea.focus(), 100); 2276 + } 2277 + 2278 + function showUnsignModal() { 2279 + const overlay = document.createElement('div'); 2280 + overlay.className = 'overlay'; 2281 + overlay.style.display = 'block'; 2282 + 2283 + const modal = document.createElement('div'); 2284 + modal.className = 'info-modal'; 2285 + modal.style.display = 'block'; 2286 + modal.style.maxWidth = '400px'; 2287 + 2288 + modal.innerHTML = ` 2289 + <h2>unsign guestbook</h2> 2290 + <p style="margin-bottom: 1rem;">you've already signed the guestbook. want to delete your visit record?</p> 2291 + <div style="display: flex; gap: 0.5rem; justify-content: flex-end;"> 2292 + <button id="cancelUnsignBtn" style="background: var(--bg);">cancel</button> 2293 + <button id="confirmUnsignBtn" style="background: var(--surface-hover);">delete record</button> 2294 + </div> 2295 + `; 2296 + 2297 + document.body.appendChild(overlay); 2298 + document.body.appendChild(modal); 2299 + 2300 + const cancelBtn = document.getElementById('cancelUnsignBtn'); 2301 + const confirmBtn = document.getElementById('confirmUnsignBtn'); 2302 + 2303 + const closeModal = () => { 2304 + modal.remove(); 2305 + overlay.remove(); 2306 + }; 2307 + 2308 + cancelBtn.addEventListener('click', closeModal); 2309 + overlay.addEventListener('click', closeModal); 2310 + 2311 + confirmBtn.addEventListener('click', async () => { 2312 + // Close unsign modal first 2313 + closeModal(); 2314 + 2315 + // Show watch prompt before deleting 2316 + showWatchPrompt(async () => { 2317 + // Perform deletion 2318 + try { 2319 + const response = await fetch('/api/sign-guestbook', { 2320 + method: 'DELETE' 2321 + }); 2322 + 2323 + const data = await response.json(); 2324 + 2325 + if (data.success) { 2326 + // Refresh page owner signature status and auth status to update button 2327 + await checkPageOwnerSignature(); 2328 + await checkAuthStatus(); 2329 + } else { 2330 + throw new Error(data.error || 'Unknown error'); 2331 + } 2332 + } catch (error) { 2333 + console.error('[Guestbook] Error unsigning:', error); 2334 + } 2335 + }); 2336 + }); 2337 + } 2338 + 2339 + document.addEventListener('DOMContentLoaded', async () => { 2340 + const signGuestbookBtn = document.getElementById('signGuestbookBtn'); 2341 + if (!signGuestbookBtn) { 2342 + console.error('[Guestbook] Sign guestbook button not found!'); 2343 + return; 2344 + } 2345 + 2346 + // Check if page owner has signed (no auth required) 2347 + await checkPageOwnerSignature(); 2348 + 2349 + // Check auth status on load 2350 + checkAuthStatus(); 2351 + 2352 + signGuestbookBtn.addEventListener('click', async () => { 2353 + const viewingOwnPage = isAuthenticated && authenticatedDid === did; 2354 + 2355 + // Only allow actions if viewing own page OR not authenticated (to show login) 2356 + if (!viewingOwnPage && isAuthenticated) { 2357 + // Authenticated but viewing someone else's page - do nothing 2358 + return; 2359 + } 2360 + 2361 + // If page owner already signed, handle unsigning or identity confirmation 2362 + if (pageOwnerHasSigned) { 2363 + if (!isAuthenticated) { 2364 + // Unauthenticated user - show identity confirmation to sign in and then unsign 2365 + if (viewedHandle) { 2366 + showHandleConfirmation(viewedHandle); 2367 + } 2368 + return; 2369 + } else if (viewingOwnPage) { 2370 + // Authenticated as page owner - show unsign modal 2371 + showUnsignModal(); 2372 + return; 2373 + } 2374 + // If authenticated as someone else, the button is disabled, so this shouldn't be reached 2375 + } 2376 + 2377 + // If not authenticated and page owner hasn't signed, show confirmation to sign in and then sign 2378 + if (!isAuthenticated) { 2379 + if (viewedHandle) { 2380 + showHandleConfirmation(viewedHandle); 2381 + } 2382 + return; 2383 + } 2384 + 2385 + // Authenticated and viewing own page - show message input, then watch prompt, then sign 2386 + if (viewingOwnPage) { 2387 + showMessageInputModal(async (messageText) => { 2388 + showWatchPrompt(async () => { 2389 + signGuestbookBtn.disabled = true; 2390 + const iconSpan = signGuestbookBtn.querySelector('.guestbook-icon'); 2391 + const textSpan = signGuestbookBtn.querySelector('.guestbook-text'); 2392 + 2393 + if (iconSpan && textSpan) { 2394 + const originalIcon = iconSpan.innerHTML; 2395 + const originalText = textSpan.textContent; 2396 + 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>'; 2397 + textSpan.textContent = 'signing...'; 2398 + 2399 + try { 2400 + const body = {}; 2401 + if (messageText && messageText.trim()) { 2402 + body.text = messageText.trim(); 2403 + } 2404 + 2405 + const response = await fetch('/api/sign-guestbook', { 2406 + method: 'POST', 2407 + headers: { 2408 + 'Content-Type': 'application/json', 2409 + }, 2410 + body: JSON.stringify(body) 2411 + }); 2412 + 2413 + const data = await response.json(); 2414 + 2415 + if (data.success) { 2416 + 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>'; 2417 + textSpan.textContent = 'signed!'; 2418 + // Refresh page owner signature status and auth status to update button 2419 + await checkPageOwnerSignature(); 2420 + await checkAuthStatus(); 2421 + setTimeout(() => { 2422 + signGuestbookBtn.disabled = false; 2423 + }, 1000); 2424 + } else { 2425 + throw new Error(data.error || 'Unknown error'); 2426 + } 2427 + } catch (error) { 2428 + console.error('[Guestbook] Error signing guestbook:', error); 2429 + 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>'; 2430 + textSpan.textContent = 'error'; 2431 + setTimeout(() => { 2432 + iconSpan.innerHTML = originalIcon; 2433 + textSpan.textContent = originalText; 2434 + signGuestbookBtn.disabled = false; 2435 + }, 2000); 2436 + } 2437 + } 2438 + }); 2439 + }); 2440 + } 2441 + }); 2442 + 2443 + // View guestbook button handler 2444 + const viewGuestbookBtn = document.getElementById('viewGuestbookBtn'); 2445 + const guestbookModal = document.getElementById('guestbookModal'); 2446 + const guestbookClose = document.getElementById('guestbookClose'); 2447 + const guestbookContent = document.getElementById('guestbookContent'); 2448 + 2449 + if (viewGuestbookBtn && guestbookModal && guestbookClose && guestbookContent) { 2450 + viewGuestbookBtn.addEventListener('click', () => { 2451 + showGuestbookModal(); 2452 + }); 2453 + 2454 + guestbookClose.addEventListener('click', () => { 2455 + guestbookModal.classList.remove('visible'); 2456 + }); 2457 + 2458 + // Add Escape key handler for closing guest list modal 2459 + document.addEventListener('keydown', (e) => { 2460 + if (e.key === 'Escape' && guestbookModal.classList.contains('visible')) { 2461 + guestbookModal.classList.remove('visible'); 2462 + } 2463 + }); 2464 + } 2465 + }); 2466 + 2467 + async function showGuestbookModal() { 2468 + const guestbookModal = document.getElementById('guestbookModal'); 2469 + const guestbookContent = document.getElementById('guestbookContent'); 2470 + 2471 + if (!guestbookModal || !guestbookContent) return; 2472 + 2473 + // Show modal with loading state 2474 + guestbookModal.classList.add('visible'); 2475 + guestbookContent.innerHTML = ` 2476 + <div class="guestbook-paper"> 2477 + <div class="guestbook-loading"> 2478 + <div class="guestbook-loading-spinner"></div> 2479 + <div class="guestbook-loading-text">loading signatures...</div> 2480 + </div> 2481 + </div> 2482 + `; 2483 + 2484 + try { 2485 + // Fetch ALL signatures globally (not filtered by DID) 2486 + const response = await fetch(`/api/guestbook/signatures`); 2487 + if (!response.ok) { 2488 + throw new Error('Failed to fetch signatures'); 2489 + } 2490 + 2491 + const signatures = await response.json(); 2492 + 2493 + if (signatures.length === 0) { 2494 + guestbookContent.innerHTML = ` 2495 + <div class="guestbook-paper"> 2496 + <h1 class="guestbook-paper-title">the @me guest list</h1> 2497 + <p class="guestbook-paper-subtitle">visitors to this application</p> 2498 + <div class="guestbook-empty"> 2499 + <div class="guestbook-empty-text">no signatures yet. be the first to sign!</div> 2500 + </div> 2501 + </div> 2502 + `; 2503 + return; 2504 + } 2505 + 2506 + // Helper function to format timestamp 2507 + const formatDate = (isoString) => { 2508 + if (!isoString) return ''; 2509 + const date = new Date(isoString); 2510 + if (isNaN(date)) return ''; 2511 + const month = String(date.getMonth() + 1).padStart(2, '0'); 2512 + const day = String(date.getDate()).padStart(2, '0'); 2513 + const year = date.getFullYear(); 2514 + return `${month}/${day}/${year}`; 2515 + }; 2516 + 2517 + // Render signatures with paper aesthetic 2518 + let html = ` 2519 + <div class="guestbook-paper"> 2520 + <h1 class="guestbook-paper-title">the @me guest list</h1> 2521 + <p class="guestbook-paper-subtitle">visitors to this application</p> 2522 + <div class="guestbook-tally">${signatures.length} signature${signatures.length !== 1 ? 's' : ''}</div> 2523 + <div class="guestbook-signatures-list"> 2524 + `; 2525 + 2526 + signatures.forEach((sig, index) => { 2527 + const handle = sig.handle || 'unknown'; 2528 + const did = sig.did || 'did:unknown'; 2529 + // Add at:// prefix if not present for pdsls.dev URL 2530 + const atUri = did.startsWith('at://') ? did : `at://${did}`; 2531 + const formattedDate = formatDate(sig.timestamp); 2532 + const pdsHost = 'pdsls.dev'; // Use pdsls.dev for looking up DIDs 2533 + 2534 + html += ` 2535 + <div class="guestbook-paper-signature"> 2536 + <div class="guestbook-did" data-did="${did}" data-index="${index}"> 2537 + ${did} 2538 + <span class="guestbook-did-tooltip">copied!</span> 2539 + </div> 2540 + <div class="guestbook-metadata"> 2541 + ${sig.text ? ` 2542 + <div class="guestbook-message">${sig.text}</div> 2543 + ` : ''} 2544 + <div class="guestbook-metadata-item"> 2545 + <span class="guestbook-metadata-label">handle:</span> 2546 + <span class="guestbook-metadata-value">@${handle}</span> 2547 + </div> 2548 + ${formattedDate ? ` 2549 + <div class="guestbook-metadata-item"> 2550 + <span class="guestbook-metadata-label">signed:</span> 2551 + <span class="guestbook-metadata-value">${formattedDate}</span> 2552 + </div> 2553 + ` : ''} 2554 + <div class="guestbook-metadata-item"> 2555 + <span class="guestbook-metadata-label">bluesky:</span> 2556 + <a href="https://bsky.app/profile/${handle}" target="_blank" rel="noopener noreferrer" class="guestbook-metadata-link">view profile โ†—</a> 2557 + </div> 2558 + <div class="guestbook-metadata-item"> 2559 + <span class="guestbook-metadata-label">pdsls.dev:</span> 2560 + <a href="https://${pdsHost}/${atUri}/app.at-me.visit" target="_blank" rel="noopener noreferrer" class="guestbook-metadata-link">view on pdsls.dev โ†—</a> 2561 + </div> 2562 + </div> 2563 + </div> 2564 + `; 2565 + }); 2566 + 2567 + html += ` 2568 + </div> 2569 + </div> 2570 + `; 2571 + 2572 + guestbookContent.innerHTML = html; 2573 + 2574 + // Add click handlers to DIDs for copying 2575 + document.querySelectorAll('.guestbook-did').forEach(didElement => { 2576 + didElement.addEventListener('click', async (e) => { 2577 + e.stopPropagation(); 2578 + const did = didElement.dataset.did; 2579 + 2580 + try { 2581 + await navigator.clipboard.writeText(did); 2582 + 2583 + // Add copied class for animation 2584 + didElement.classList.add('copied'); 2585 + 2586 + // Remove after animation 2587 + setTimeout(() => { 2588 + didElement.classList.remove('copied'); 2589 + }, 2000); 2590 + } catch (err) { 2591 + console.error('[Guestbook] Failed to copy DID:', err); 2592 + } 2593 + }); 2594 + }); 2595 + } catch (error) { 2596 + console.error('[Guestbook] Error loading signatures:', error); 2597 + guestbookContent.innerHTML = ` 2598 + <div class="guestbook-paper"> 2599 + <h1 class="guestbook-paper-title">Guestbook</h1> 2600 + <p class="guestbook-paper-subtitle">Visitors to this Personal Data Server</p> 2601 + <div class="guestbook-empty"> 2602 + <div class="guestbook-empty-text">Error loading signatures. Please try again.</div> 2603 + </div> 2604 + </div> 2605 + `; 2606 + } 2607 + }
+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

This is a binary file and will not be displayed.

+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 + }