+16
.pre-commit-config.yaml
+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
+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
-14
.tangled/workflows/deploy.yaml
+10
CLAUDE.md
+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
+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
+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
+3
Dockerfile
+17
-5
README.md
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
static/og-image.png
This is a binary file and will not be displayed.
+31
static/og-image.svg
+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
+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
+
}