+20
.tangled/workflows/check.yaml
+20
.tangled/workflows/check.yaml
···
1
+
engine: nixery
2
+
3
+
when:
4
+
- event: ["push", "pull_request"]
5
+
6
+
dependencies:
7
+
nixpkgs:
8
+
- rustc
9
+
- cargo
10
+
- rustfmt
11
+
- clippy
12
+
13
+
steps:
14
+
- name: check formatting
15
+
command: |
16
+
cargo fmt --check
17
+
18
+
- name: run clippy
19
+
command: |
20
+
cargo clippy -- -D warnings
+2
-2
.tangled/workflows/deploy.yaml
+2
-2
.tangled/workflows/deploy.yaml
+658
-11
Cargo.lock
+658
-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",
395
456
"env_logger",
457
+
"futures-util",
396
458
"hickory-resolver",
397
459
"log",
460
+
"reqwest",
461
+
"rocketman",
398
462
"serde",
399
463
"serde_json",
400
464
"tokio",
···
519
583
"miniz_oxide",
520
584
"object",
521
585
"rustc-demangle",
522
-
"windows-link",
586
+
"windows-link 0.2.0",
523
587
]
524
588
525
589
[[package]]
···
552
616
553
617
[[package]]
554
618
name = "base64"
619
+
version = "0.21.7"
620
+
source = "registry+https://github.com/rust-lang/crates.io-index"
621
+
checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
622
+
623
+
[[package]]
624
+
name = "base64"
555
625
version = "0.22.1"
556
626
source = "registry+https://github.com/rust-lang/crates.io-index"
557
627
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
···
578
648
]
579
649
580
650
[[package]]
651
+
name = "bon"
652
+
version = "3.8.1"
653
+
source = "registry+https://github.com/rust-lang/crates.io-index"
654
+
checksum = "ebeb9aaf9329dff6ceb65c689ca3db33dbf15f324909c60e4e5eef5701ce31b1"
655
+
dependencies = [
656
+
"bon-macros",
657
+
"rustversion",
658
+
]
659
+
660
+
[[package]]
661
+
name = "bon-macros"
662
+
version = "3.8.1"
663
+
source = "registry+https://github.com/rust-lang/crates.io-index"
664
+
checksum = "77e9d642a7e3a318e37c2c9427b5a6a48aa1ad55dcd986f3034ab2239045a645"
665
+
dependencies = [
666
+
"darling 0.21.3",
667
+
"ident_case",
668
+
"prettyplease",
669
+
"proc-macro2",
670
+
"quote",
671
+
"rustversion",
672
+
"syn 2.0.106",
673
+
]
674
+
675
+
[[package]]
581
676
name = "brotli"
582
677
version = "8.0.2"
583
678
source = "registry+https://github.com/rust-lang/crates.io-index"
···
603
698
version = "3.19.0"
604
699
source = "registry+https://github.com/rust-lang/crates.io-index"
605
700
checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43"
701
+
702
+
[[package]]
703
+
name = "byteorder"
704
+
version = "1.5.0"
705
+
source = "registry+https://github.com/rust-lang/crates.io-index"
706
+
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
606
707
607
708
[[package]]
608
709
name = "bytes"
···
648
749
"num-traits",
649
750
"serde",
650
751
"wasm-bindgen",
651
-
"windows-link",
752
+
"windows-link 0.2.0",
652
753
]
653
754
654
755
[[package]]
···
837
938
]
838
939
839
940
[[package]]
941
+
name = "darling"
942
+
version = "0.20.11"
943
+
source = "registry+https://github.com/rust-lang/crates.io-index"
944
+
checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee"
945
+
dependencies = [
946
+
"darling_core 0.20.11",
947
+
"darling_macro 0.20.11",
948
+
]
949
+
950
+
[[package]]
951
+
name = "darling"
952
+
version = "0.21.3"
953
+
source = "registry+https://github.com/rust-lang/crates.io-index"
954
+
checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0"
955
+
dependencies = [
956
+
"darling_core 0.21.3",
957
+
"darling_macro 0.21.3",
958
+
]
959
+
960
+
[[package]]
961
+
name = "darling_core"
962
+
version = "0.20.11"
963
+
source = "registry+https://github.com/rust-lang/crates.io-index"
964
+
checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e"
965
+
dependencies = [
966
+
"fnv",
967
+
"ident_case",
968
+
"proc-macro2",
969
+
"quote",
970
+
"strsim",
971
+
"syn 2.0.106",
972
+
]
973
+
974
+
[[package]]
975
+
name = "darling_core"
976
+
version = "0.21.3"
977
+
source = "registry+https://github.com/rust-lang/crates.io-index"
978
+
checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4"
979
+
dependencies = [
980
+
"fnv",
981
+
"ident_case",
982
+
"proc-macro2",
983
+
"quote",
984
+
"strsim",
985
+
"syn 2.0.106",
986
+
]
987
+
988
+
[[package]]
989
+
name = "darling_macro"
990
+
version = "0.20.11"
991
+
source = "registry+https://github.com/rust-lang/crates.io-index"
992
+
checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
993
+
dependencies = [
994
+
"darling_core 0.20.11",
995
+
"quote",
996
+
"syn 2.0.106",
997
+
]
998
+
999
+
[[package]]
1000
+
name = "darling_macro"
1001
+
version = "0.21.3"
1002
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1003
+
checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81"
1004
+
dependencies = [
1005
+
"darling_core 0.21.3",
1006
+
"quote",
1007
+
"syn 2.0.106",
1008
+
]
1009
+
1010
+
[[package]]
840
1011
name = "dashmap"
841
1012
version = "6.1.0"
842
1013
source = "registry+https://github.com/rust-lang/crates.io-index"
···
893
1064
checksum = "a41953f86f8a05768a6cda24def994fd2f424b04ec5c719cf89989779f199071"
894
1065
dependencies = [
895
1066
"powerfmt",
1067
+
]
1068
+
1069
+
[[package]]
1070
+
name = "derive_builder"
1071
+
version = "0.20.2"
1072
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1073
+
checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947"
1074
+
dependencies = [
1075
+
"derive_builder_macro",
1076
+
]
1077
+
1078
+
[[package]]
1079
+
name = "derive_builder_core"
1080
+
version = "0.20.2"
1081
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1082
+
checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8"
1083
+
dependencies = [
1084
+
"darling 0.20.11",
1085
+
"proc-macro2",
1086
+
"quote",
1087
+
"syn 2.0.106",
1088
+
]
1089
+
1090
+
[[package]]
1091
+
name = "derive_builder_macro"
1092
+
version = "0.20.2"
1093
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1094
+
checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c"
1095
+
dependencies = [
1096
+
"derive_builder_core",
1097
+
"syn 2.0.106",
896
1098
]
897
1099
898
1100
[[package]]
···
1105
1307
]
1106
1308
1107
1309
[[package]]
1310
+
name = "flume"
1311
+
version = "0.11.1"
1312
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1313
+
checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095"
1314
+
dependencies = [
1315
+
"futures-core",
1316
+
"futures-sink",
1317
+
"nanorand",
1318
+
"spin",
1319
+
]
1320
+
1321
+
[[package]]
1108
1322
name = "fnv"
1109
1323
version = "1.0.7"
1110
1324
source = "registry+https://github.com/rust-lang/crates.io-index"
···
1192
1406
dependencies = [
1193
1407
"futures-core",
1194
1408
"futures-macro",
1409
+
"futures-sink",
1195
1410
"futures-task",
1196
1411
"pin-project-lite",
1197
1412
"pin-utils",
···
1216
1431
checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592"
1217
1432
dependencies = [
1218
1433
"cfg-if",
1434
+
"js-sys",
1219
1435
"libc",
1220
1436
"wasi 0.11.1+wasi-snapshot-preview1",
1437
+
"wasm-bindgen",
1221
1438
]
1222
1439
1223
1440
[[package]]
···
1279
1496
]
1280
1497
1281
1498
[[package]]
1499
+
name = "h2"
1500
+
version = "0.4.12"
1501
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1502
+
checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386"
1503
+
dependencies = [
1504
+
"atomic-waker",
1505
+
"bytes",
1506
+
"fnv",
1507
+
"futures-core",
1508
+
"futures-sink",
1509
+
"http 1.3.1",
1510
+
"indexmap",
1511
+
"slab",
1512
+
"tokio",
1513
+
"tokio-util",
1514
+
"tracing",
1515
+
]
1516
+
1517
+
[[package]]
1282
1518
name = "hashbrown"
1283
1519
version = "0.14.5"
1284
1520
source = "registry+https://github.com/rust-lang/crates.io-index"
···
1416
1652
]
1417
1653
1418
1654
[[package]]
1655
+
name = "http-range"
1656
+
version = "0.1.5"
1657
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1658
+
checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573"
1659
+
1660
+
[[package]]
1419
1661
name = "httparse"
1420
1662
version = "1.10.1"
1421
1663
source = "registry+https://github.com/rust-lang/crates.io-index"
···
1437
1679
"bytes",
1438
1680
"futures-channel",
1439
1681
"futures-core",
1682
+
"h2 0.4.12",
1440
1683
"http 1.3.1",
1441
1684
"http-body",
1442
1685
"httparse",
···
1449
1692
]
1450
1693
1451
1694
[[package]]
1695
+
name = "hyper-rustls"
1696
+
version = "0.27.7"
1697
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1698
+
checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58"
1699
+
dependencies = [
1700
+
"http 1.3.1",
1701
+
"hyper",
1702
+
"hyper-util",
1703
+
"rustls 0.23.31",
1704
+
"rustls-pki-types",
1705
+
"tokio",
1706
+
"tokio-rustls 0.26.2",
1707
+
"tower-service",
1708
+
]
1709
+
1710
+
[[package]]
1452
1711
name = "hyper-tls"
1453
1712
version = "0.6.0"
1454
1713
source = "registry+https://github.com/rust-lang/crates.io-index"
···
1483
1742
"percent-encoding",
1484
1743
"pin-project-lite",
1485
1744
"socket2 0.6.0",
1745
+
"system-configuration",
1486
1746
"tokio",
1487
1747
"tower-service",
1488
1748
"tracing",
1749
+
"windows-registry",
1489
1750
]
1490
1751
1491
1752
[[package]]
···
1597
1858
"zerotrie",
1598
1859
"zerovec",
1599
1860
]
1861
+
1862
+
[[package]]
1863
+
name = "ident_case"
1864
+
version = "1.0.1"
1865
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1866
+
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
1600
1867
1601
1868
[[package]]
1602
1869
name = "idna"
···
1800
2067
checksum = "d4345964bb142484797b161f473a503a434de77149dd8c7427788c6e13379388"
1801
2068
1802
2069
[[package]]
2070
+
name = "lazy_static"
2071
+
version = "1.5.0"
2072
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2073
+
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
2074
+
2075
+
[[package]]
1803
2076
name = "libc"
1804
2077
version = "0.2.176"
1805
2078
source = "registry+https://github.com/rust-lang/crates.io-index"
···
1891
2164
checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273"
1892
2165
1893
2166
[[package]]
2167
+
name = "metrics"
2168
+
version = "0.24.2"
2169
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2170
+
checksum = "25dea7ac8057892855ec285c440160265225438c3c45072613c25a4b26e98ef5"
2171
+
dependencies = [
2172
+
"ahash",
2173
+
"portable-atomic",
2174
+
]
2175
+
2176
+
[[package]]
1894
2177
name = "mime"
1895
2178
version = "0.3.17"
1896
2179
source = "registry+https://github.com/rust-lang/crates.io-index"
1897
2180
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
1898
2181
1899
2182
[[package]]
2183
+
name = "mime_guess"
2184
+
version = "2.0.5"
2185
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2186
+
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
2187
+
dependencies = [
2188
+
"mime",
2189
+
"unicase",
2190
+
]
2191
+
2192
+
[[package]]
1900
2193
name = "miniz_oxide"
1901
2194
version = "0.8.9"
1902
2195
source = "registry+https://github.com/rust-lang/crates.io-index"
···
1963
2256
]
1964
2257
1965
2258
[[package]]
2259
+
name = "nanorand"
2260
+
version = "0.7.0"
2261
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2262
+
checksum = "6a51313c5820b0b02bd422f4b44776fbf47961755c74ce64afc73bfad10226c3"
2263
+
dependencies = [
2264
+
"getrandom 0.2.16",
2265
+
]
2266
+
2267
+
[[package]]
1966
2268
name = "native-tls"
1967
2269
version = "0.2.14"
1968
2270
source = "registry+https://github.com/rust-lang/crates.io-index"
···
1980
2282
]
1981
2283
1982
2284
[[package]]
2285
+
name = "nu-ansi-term"
2286
+
version = "0.50.3"
2287
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2288
+
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
2289
+
dependencies = [
2290
+
"windows-sys 0.61.1",
2291
+
]
2292
+
2293
+
[[package]]
1983
2294
name = "num-conv"
1984
2295
version = "0.1.0"
1985
2296
source = "registry+https://github.com/rust-lang/crates.io-index"
···
2103
2414
"libc",
2104
2415
"redox_syscall",
2105
2416
"smallvec",
2106
-
"windows-link",
2417
+
"windows-link 0.2.0",
2107
2418
]
2108
2419
2109
2420
[[package]]
···
2182
2493
]
2183
2494
2184
2495
[[package]]
2496
+
name = "prettyplease"
2497
+
version = "0.2.37"
2498
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2499
+
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
2500
+
dependencies = [
2501
+
"proc-macro2",
2502
+
"syn 2.0.106",
2503
+
]
2504
+
2505
+
[[package]]
2185
2506
name = "primeorder"
2186
2507
version = "0.13.6"
2187
2508
source = "registry+https://github.com/rust-lang/crates.io-index"
···
2326
2647
"async-compression",
2327
2648
"base64 0.22.1",
2328
2649
"bytes",
2650
+
"encoding_rs",
2329
2651
"futures-core",
2330
2652
"futures-util",
2653
+
"h2 0.4.12",
2331
2654
"http 1.3.1",
2332
2655
"http-body",
2333
2656
"http-body-util",
2334
2657
"hyper",
2658
+
"hyper-rustls",
2335
2659
"hyper-tls",
2336
2660
"hyper-util",
2337
2661
"js-sys",
2338
2662
"log",
2663
+
"mime",
2339
2664
"native-tls",
2340
2665
"percent-encoding",
2341
2666
"pin-project-lite",
···
2373
2698
]
2374
2699
2375
2700
[[package]]
2701
+
name = "ring"
2702
+
version = "0.17.14"
2703
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2704
+
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
2705
+
dependencies = [
2706
+
"cc",
2707
+
"cfg-if",
2708
+
"getrandom 0.2.16",
2709
+
"libc",
2710
+
"untrusted",
2711
+
"windows-sys 0.52.0",
2712
+
]
2713
+
2714
+
[[package]]
2715
+
name = "rocketman"
2716
+
version = "0.2.5"
2717
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2718
+
checksum = "90cfc4ee9daf6e9d0ee217b9709aa3bd6c921e6926aa15c6ff5ba9162c2c649a"
2719
+
dependencies = [
2720
+
"anyhow",
2721
+
"async-trait",
2722
+
"bon",
2723
+
"derive_builder",
2724
+
"flume",
2725
+
"futures-util",
2726
+
"metrics",
2727
+
"rand 0.8.5",
2728
+
"serde",
2729
+
"serde_json",
2730
+
"tokio",
2731
+
"tokio-tungstenite",
2732
+
"tracing",
2733
+
"tracing-subscriber",
2734
+
"url",
2735
+
"zstd",
2736
+
]
2737
+
2738
+
[[package]]
2376
2739
name = "rustc-demangle"
2377
2740
version = "0.1.26"
2378
2741
source = "registry+https://github.com/rust-lang/crates.io-index"
···
2401
2764
]
2402
2765
2403
2766
[[package]]
2767
+
name = "rustls"
2768
+
version = "0.21.12"
2769
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2770
+
checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e"
2771
+
dependencies = [
2772
+
"log",
2773
+
"ring",
2774
+
"rustls-webpki 0.101.7",
2775
+
"sct",
2776
+
]
2777
+
2778
+
[[package]]
2779
+
name = "rustls"
2780
+
version = "0.23.31"
2781
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2782
+
checksum = "c0ebcbd2f03de0fc1122ad9bb24b127a5a6cd51d72604a3f3c50ac459762b6cc"
2783
+
dependencies = [
2784
+
"once_cell",
2785
+
"rustls-pki-types",
2786
+
"rustls-webpki 0.103.4",
2787
+
"subtle",
2788
+
"zeroize",
2789
+
]
2790
+
2791
+
[[package]]
2792
+
name = "rustls-native-certs"
2793
+
version = "0.6.3"
2794
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2795
+
checksum = "a9aace74cb666635c918e9c12bc0d348266037aa8eb599b5cba565709a8dff00"
2796
+
dependencies = [
2797
+
"openssl-probe",
2798
+
"rustls-pemfile",
2799
+
"schannel",
2800
+
"security-framework",
2801
+
]
2802
+
2803
+
[[package]]
2804
+
name = "rustls-pemfile"
2805
+
version = "1.0.4"
2806
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2807
+
checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c"
2808
+
dependencies = [
2809
+
"base64 0.21.7",
2810
+
]
2811
+
2812
+
[[package]]
2404
2813
name = "rustls-pki-types"
2405
2814
version = "1.12.0"
2406
2815
source = "registry+https://github.com/rust-lang/crates.io-index"
···
2410
2819
]
2411
2820
2412
2821
[[package]]
2822
+
name = "rustls-webpki"
2823
+
version = "0.101.7"
2824
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2825
+
checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765"
2826
+
dependencies = [
2827
+
"ring",
2828
+
"untrusted",
2829
+
]
2830
+
2831
+
[[package]]
2832
+
name = "rustls-webpki"
2833
+
version = "0.103.4"
2834
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2835
+
checksum = "0a17884ae0c1b773f1ccd2bd4a8c72f16da897310a98b0e84bf349ad5ead92fc"
2836
+
dependencies = [
2837
+
"ring",
2838
+
"rustls-pki-types",
2839
+
"untrusted",
2840
+
]
2841
+
2842
+
[[package]]
2413
2843
name = "rustversion"
2414
2844
version = "1.0.22"
2415
2845
source = "registry+https://github.com/rust-lang/crates.io-index"
···
2435
2865
version = "1.2.0"
2436
2866
source = "registry+https://github.com/rust-lang/crates.io-index"
2437
2867
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
2868
+
2869
+
[[package]]
2870
+
name = "sct"
2871
+
version = "0.7.1"
2872
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2873
+
checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414"
2874
+
dependencies = [
2875
+
"ring",
2876
+
"untrusted",
2877
+
]
2438
2878
2439
2879
[[package]]
2440
2880
name = "sec1"
···
2579
3019
]
2580
3020
2581
3021
[[package]]
3022
+
name = "sharded-slab"
3023
+
version = "0.1.7"
3024
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3025
+
checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
3026
+
dependencies = [
3027
+
"lazy_static",
3028
+
]
3029
+
3030
+
[[package]]
2582
3031
name = "shlex"
2583
3032
version = "1.3.0"
2584
3033
source = "registry+https://github.com/rust-lang/crates.io-index"
···
2642
3091
]
2643
3092
2644
3093
[[package]]
3094
+
name = "spin"
3095
+
version = "0.9.8"
3096
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3097
+
checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
3098
+
dependencies = [
3099
+
"lock_api",
3100
+
]
3101
+
3102
+
[[package]]
2645
3103
name = "stable_deref_trait"
2646
3104
version = "1.2.0"
2647
3105
source = "registry+https://github.com/rust-lang/crates.io-index"
2648
3106
checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3"
3107
+
3108
+
[[package]]
3109
+
name = "strsim"
3110
+
version = "0.11.1"
3111
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3112
+
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
2649
3113
2650
3114
[[package]]
2651
3115
name = "subtle"
···
2696
3160
]
2697
3161
2698
3162
[[package]]
3163
+
name = "system-configuration"
3164
+
version = "0.6.1"
3165
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3166
+
checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b"
3167
+
dependencies = [
3168
+
"bitflags",
3169
+
"core-foundation",
3170
+
"system-configuration-sys",
3171
+
]
3172
+
3173
+
[[package]]
3174
+
name = "system-configuration-sys"
3175
+
version = "0.6.0"
3176
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3177
+
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
3178
+
dependencies = [
3179
+
"core-foundation-sys",
3180
+
"libc",
3181
+
]
3182
+
3183
+
[[package]]
2699
3184
name = "tagptr"
2700
3185
version = "0.2.0"
2701
3186
source = "registry+https://github.com/rust-lang/crates.io-index"
···
2732
3217
"proc-macro2",
2733
3218
"quote",
2734
3219
"syn 2.0.106",
3220
+
]
3221
+
3222
+
[[package]]
3223
+
name = "thread_local"
3224
+
version = "1.1.9"
3225
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3226
+
checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
3227
+
dependencies = [
3228
+
"cfg-if",
2735
3229
]
2736
3230
2737
3231
[[package]]
···
2832
3326
]
2833
3327
2834
3328
[[package]]
3329
+
name = "tokio-rustls"
3330
+
version = "0.24.1"
3331
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3332
+
checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081"
3333
+
dependencies = [
3334
+
"rustls 0.21.12",
3335
+
"tokio",
3336
+
]
3337
+
3338
+
[[package]]
3339
+
name = "tokio-rustls"
3340
+
version = "0.26.2"
3341
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3342
+
checksum = "8e727b36a1a0e8b74c376ac2211e40c2c8af09fb4013c60d910495810f008e9b"
3343
+
dependencies = [
3344
+
"rustls 0.23.31",
3345
+
"tokio",
3346
+
]
3347
+
3348
+
[[package]]
3349
+
name = "tokio-tungstenite"
3350
+
version = "0.20.1"
3351
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3352
+
checksum = "212d5dcb2a1ce06d81107c3d0ffa3121fe974b73f068c8282cb1c32328113b6c"
3353
+
dependencies = [
3354
+
"futures-util",
3355
+
"log",
3356
+
"rustls 0.21.12",
3357
+
"rustls-native-certs",
3358
+
"tokio",
3359
+
"tokio-rustls 0.24.1",
3360
+
"tungstenite",
3361
+
"webpki-roots",
3362
+
]
3363
+
3364
+
[[package]]
2835
3365
name = "tokio-util"
2836
3366
version = "0.7.16"
2837
3367
source = "registry+https://github.com/rust-lang/crates.io-index"
···
2919
3449
checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678"
2920
3450
dependencies = [
2921
3451
"once_cell",
3452
+
"valuable",
3453
+
]
3454
+
3455
+
[[package]]
3456
+
name = "tracing-log"
3457
+
version = "0.2.0"
3458
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3459
+
checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
3460
+
dependencies = [
3461
+
"log",
3462
+
"once_cell",
3463
+
"tracing-core",
3464
+
]
3465
+
3466
+
[[package]]
3467
+
name = "tracing-subscriber"
3468
+
version = "0.3.20"
3469
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3470
+
checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5"
3471
+
dependencies = [
3472
+
"nu-ansi-term",
3473
+
"sharded-slab",
3474
+
"smallvec",
3475
+
"thread_local",
3476
+
"tracing-core",
3477
+
"tracing-log",
2922
3478
]
2923
3479
2924
3480
[[package]]
···
2939
3495
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
2940
3496
2941
3497
[[package]]
3498
+
name = "tungstenite"
3499
+
version = "0.20.1"
3500
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3501
+
checksum = "9e3dac10fd62eaf6617d3a904ae222845979aec67c615d1c842b4002c7666fb9"
3502
+
dependencies = [
3503
+
"byteorder",
3504
+
"bytes",
3505
+
"data-encoding",
3506
+
"http 0.2.12",
3507
+
"httparse",
3508
+
"log",
3509
+
"rand 0.8.5",
3510
+
"rustls 0.21.12",
3511
+
"sha1",
3512
+
"thiserror",
3513
+
"url",
3514
+
"utf-8",
3515
+
]
3516
+
3517
+
[[package]]
2942
3518
name = "typenum"
2943
3519
version = "1.19.0"
2944
3520
source = "registry+https://github.com/rust-lang/crates.io-index"
2945
3521
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
2946
3522
2947
3523
[[package]]
3524
+
name = "unicase"
3525
+
version = "2.8.1"
3526
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3527
+
checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539"
3528
+
3529
+
[[package]]
2948
3530
name = "unicode-ident"
2949
3531
version = "1.0.19"
2950
3532
source = "registry+https://github.com/rust-lang/crates.io-index"
···
2973
3555
checksum = "eb066959b24b5196ae73cb057f45598450d2c5f71460e98c49b738086eff9c06"
2974
3556
2975
3557
[[package]]
3558
+
name = "untrusted"
3559
+
version = "0.9.0"
3560
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3561
+
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
3562
+
3563
+
[[package]]
2976
3564
name = "url"
2977
3565
version = "2.5.7"
2978
3566
source = "registry+https://github.com/rust-lang/crates.io-index"
···
2985
3573
]
2986
3574
2987
3575
[[package]]
3576
+
name = "utf-8"
3577
+
version = "0.7.6"
3578
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3579
+
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
3580
+
3581
+
[[package]]
2988
3582
name = "utf8_iter"
2989
3583
version = "1.0.4"
2990
3584
source = "registry+https://github.com/rust-lang/crates.io-index"
···
3006
3600
"js-sys",
3007
3601
"wasm-bindgen",
3008
3602
]
3603
+
3604
+
[[package]]
3605
+
name = "v_htmlescape"
3606
+
version = "0.15.8"
3607
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3608
+
checksum = "4e8257fbc510f0a46eb602c10215901938b5c2a7d5e70fc11483b1d3c9b5b18c"
3609
+
3610
+
[[package]]
3611
+
name = "valuable"
3612
+
version = "0.1.1"
3613
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3614
+
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
3009
3615
3010
3616
[[package]]
3011
3617
name = "vcpkg"
···
3145
3751
]
3146
3752
3147
3753
[[package]]
3754
+
name = "webpki-roots"
3755
+
version = "0.25.4"
3756
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3757
+
checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1"
3758
+
3759
+
[[package]]
3148
3760
name = "widestring"
3149
3761
version = "1.2.0"
3150
3762
source = "registry+https://github.com/rust-lang/crates.io-index"
···
3158
3770
dependencies = [
3159
3771
"windows-implement",
3160
3772
"windows-interface",
3161
-
"windows-link",
3162
-
"windows-result",
3163
-
"windows-strings",
3773
+
"windows-link 0.2.0",
3774
+
"windows-result 0.4.0",
3775
+
"windows-strings 0.5.0",
3164
3776
]
3165
3777
3166
3778
[[package]]
···
3187
3799
3188
3800
[[package]]
3189
3801
name = "windows-link"
3802
+
version = "0.1.3"
3803
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3804
+
checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a"
3805
+
3806
+
[[package]]
3807
+
name = "windows-link"
3190
3808
version = "0.2.0"
3191
3809
source = "registry+https://github.com/rust-lang/crates.io-index"
3192
3810
checksum = "45e46c0661abb7180e7b9c281db115305d49ca1709ab8242adf09666d2173c65"
3193
3811
3194
3812
[[package]]
3813
+
name = "windows-registry"
3814
+
version = "0.5.3"
3815
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3816
+
checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e"
3817
+
dependencies = [
3818
+
"windows-link 0.1.3",
3819
+
"windows-result 0.3.4",
3820
+
"windows-strings 0.4.2",
3821
+
]
3822
+
3823
+
[[package]]
3824
+
name = "windows-result"
3825
+
version = "0.3.4"
3826
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3827
+
checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6"
3828
+
dependencies = [
3829
+
"windows-link 0.1.3",
3830
+
]
3831
+
3832
+
[[package]]
3195
3833
name = "windows-result"
3196
3834
version = "0.4.0"
3197
3835
source = "registry+https://github.com/rust-lang/crates.io-index"
3198
3836
checksum = "7084dcc306f89883455a206237404d3eaf961e5bd7e0f312f7c91f57eb44167f"
3199
3837
dependencies = [
3200
-
"windows-link",
3838
+
"windows-link 0.2.0",
3839
+
]
3840
+
3841
+
[[package]]
3842
+
name = "windows-strings"
3843
+
version = "0.4.2"
3844
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3845
+
checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57"
3846
+
dependencies = [
3847
+
"windows-link 0.1.3",
3201
3848
]
3202
3849
3203
3850
[[package]]
···
3206
3853
source = "registry+https://github.com/rust-lang/crates.io-index"
3207
3854
checksum = "7218c655a553b0bed4426cf54b20d7ba363ef543b52d515b3e48d7fd55318dda"
3208
3855
dependencies = [
3209
-
"windows-link",
3856
+
"windows-link 0.2.0",
3210
3857
]
3211
3858
3212
3859
[[package]]
···
3251
3898
source = "registry+https://github.com/rust-lang/crates.io-index"
3252
3899
checksum = "6f109e41dd4a3c848907eb83d5a42ea98b3769495597450cf6d153507b166f0f"
3253
3900
dependencies = [
3254
-
"windows-link",
3901
+
"windows-link 0.2.0",
3255
3902
]
3256
3903
3257
3904
[[package]]
···
3291
3938
source = "registry+https://github.com/rust-lang/crates.io-index"
3292
3939
checksum = "2d42b7b7f66d2a06854650af09cfdf8713e427a439c97ad65a6375318033ac4b"
3293
3940
dependencies = [
3294
-
"windows-link",
3941
+
"windows-link 0.2.0",
3295
3942
"windows_aarch64_gnullvm 0.53.0",
3296
3943
"windows_aarch64_msvc 0.53.0",
3297
3944
"windows_i686_gnu 0.53.0",
+7
Cargo.toml
+7
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"
+4
Dockerfile
+4
Dockerfile
···
14
14
15
15
# Copy source code
16
16
COPY src ./src
17
+
COPY static ./static
17
18
18
19
# Build for release
19
20
RUN cargo build --release
···
31
32
32
33
# Copy the built binary
33
34
COPY --from=builder /app/target/release/at-me /app/at-me
35
+
36
+
# Copy static files
37
+
COPY --from=builder /app/static /app/static
34
38
35
39
# Expose port
36
40
EXPOSE 8080
+200
src/firehose.rs
+200
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
+
/// Represents a firehose event that will be sent to the browser
17
+
#[derive(Debug, Clone, Serialize, Deserialize)]
18
+
#[serde(rename_all = "camelCase")]
19
+
pub struct FirehoseEvent {
20
+
pub did: String,
21
+
pub action: String, // "create", "update", or "delete"
22
+
pub collection: String,
23
+
pub rkey: String,
24
+
pub namespace: String, // e.g., "app.bsky" extracted from collection
25
+
}
26
+
27
+
/// Broadcaster for firehose events
28
+
pub type FirehoseBroadcaster = Arc<broadcast::Sender<FirehoseEvent>>;
29
+
30
+
/// Manager for DID-specific firehose connections
31
+
pub type FirehoseManager = Arc<Mutex<HashMap<String, FirehoseBroadcaster>>>;
32
+
33
+
/// A generic ingester that broadcasts all events
34
+
struct BroadcastIngester {
35
+
broadcaster: FirehoseBroadcaster,
36
+
}
37
+
38
+
#[async_trait]
39
+
impl LexiconIngestor for BroadcastIngester {
40
+
async fn ingest(&self, message: Event<Value>) -> Result<()> {
41
+
// Only process commit events
42
+
let Some(commit) = &message.commit else {
43
+
return Ok(());
44
+
};
45
+
46
+
// Extract namespace from collection (e.g., "app.bsky.feed.post" -> "app.bsky")
47
+
let collection_parts: Vec<&str> = commit.collection.split('.').collect();
48
+
let namespace = if collection_parts.len() >= 2 {
49
+
format!("{}.{}", collection_parts[0], collection_parts[1])
50
+
} else {
51
+
commit.collection.clone()
52
+
};
53
+
54
+
let action = match commit.operation {
55
+
Operation::Create => "create",
56
+
Operation::Update => "update",
57
+
Operation::Delete => "delete",
58
+
};
59
+
60
+
let firehose_event = FirehoseEvent {
61
+
did: message.did.clone(),
62
+
action: action.to_string(),
63
+
collection: commit.collection.clone(),
64
+
rkey: commit.rkey.clone(),
65
+
namespace: namespace.clone(),
66
+
};
67
+
68
+
info!(
69
+
"Received event: {} {} {} (namespace: {})",
70
+
action, message.did, commit.collection, namespace
71
+
);
72
+
73
+
// Broadcast the event (ignore if no receivers)
74
+
match self.broadcaster.send(firehose_event) {
75
+
Ok(receivers) => {
76
+
info!("Broadcast to {} receivers", receivers);
77
+
}
78
+
Err(_) => {
79
+
// No receivers, that's ok
80
+
}
81
+
}
82
+
83
+
Ok(())
84
+
}
85
+
}
86
+
87
+
/// Create a new FirehoseManager
88
+
pub fn create_firehose_manager() -> FirehoseManager {
89
+
Arc::new(Mutex::new(HashMap::new()))
90
+
}
91
+
92
+
/// Get or create a firehose broadcaster for a specific DID
93
+
pub async fn get_or_create_broadcaster(
94
+
manager: &FirehoseManager,
95
+
did: String,
96
+
) -> FirehoseBroadcaster {
97
+
// Check if we already have a broadcaster for this DID
98
+
{
99
+
let broadcasters = manager.lock().unwrap();
100
+
if let Some(broadcaster) = broadcasters.get(&did) {
101
+
info!("Reusing existing firehose connection for DID: {}", did);
102
+
return broadcaster.clone();
103
+
}
104
+
}
105
+
106
+
info!("Creating new firehose connection for DID: {}", did);
107
+
108
+
// Create a broadcast channel with a buffer of 100 events
109
+
let (tx, _rx) = broadcast::channel::<FirehoseEvent>(100);
110
+
let broadcaster = Arc::new(tx);
111
+
112
+
// Store in manager
113
+
{
114
+
let mut broadcasters = manager.lock().unwrap();
115
+
broadcasters.insert(did.clone(), broadcaster.clone());
116
+
}
117
+
118
+
// Clone for the spawn
119
+
let broadcaster_clone = broadcaster.clone();
120
+
let did_clone = did.clone();
121
+
122
+
tokio::spawn(async move {
123
+
loop {
124
+
info!("Starting Jetstream connection for DID: {}...", did_clone);
125
+
126
+
// Configure Jetstream to receive events ONLY for this DID
127
+
let opts = JetstreamOptions::builder()
128
+
.wanted_dids(vec![did_clone.clone()])
129
+
.build();
130
+
let jetstream = JetstreamConnection::new(opts);
131
+
132
+
let mut ingesters: HashMap<String, Box<dyn LexiconIngestor + Send + Sync>> =
133
+
HashMap::new();
134
+
135
+
// Register ingesters for common Bluesky collections
136
+
let collections = vec![
137
+
"app.bsky.feed.post",
138
+
"app.bsky.feed.like",
139
+
"app.bsky.feed.repost",
140
+
"app.bsky.graph.follow",
141
+
"app.bsky.actor.profile",
142
+
];
143
+
144
+
for collection in collections {
145
+
ingesters.insert(
146
+
collection.to_string(),
147
+
Box::new(BroadcastIngester {
148
+
broadcaster: broadcaster_clone.clone(),
149
+
}),
150
+
);
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(5)).await;
191
+
continue;
192
+
}
193
+
194
+
info!("Jetstream connection dropped for DID: {}, reconnecting in 5 seconds...", did_clone);
195
+
tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
196
+
}
197
+
});
198
+
199
+
broadcaster
200
+
}
+14
src/main.rs
+14
src/main.rs
···
1
1
use actix_session::{SessionMiddleware, config::PersistentSession, storage::CookieSessionStore};
2
2
use actix_web::{App, HttpServer, cookie::{Key, time::Duration}, middleware, web};
3
+
use actix_files::Files;
3
4
5
+
mod firehose;
6
+
mod mst;
4
7
mod oauth;
5
8
mod routes;
6
9
mod templates;
···
10
13
env_logger::init();
11
14
12
15
let client = oauth::create_oauth_client();
16
+
17
+
// Create the firehose manager (connections created lazily per-DID)
18
+
let firehose_manager = firehose::create_firehose_manager();
13
19
14
20
println!("starting server at http://localhost:8080");
15
21
···
29
35
.build(),
30
36
)
31
37
.app_data(web::Data::new(client.clone()))
38
+
.app_data(web::Data::new(firehose_manager.clone()))
32
39
.service(routes::index)
33
40
.service(routes::login)
34
41
.service(routes::callback)
35
42
.service(routes::client_metadata)
36
43
.service(routes::logout)
37
44
.service(routes::restore_session)
45
+
.service(routes::get_mst)
46
+
.service(routes::init)
47
+
.service(routes::get_avatar)
48
+
.service(routes::validate_url)
49
+
.service(routes::get_record)
50
+
.service(routes::firehose_watch)
38
51
.service(routes::favicon)
52
+
.service(Files::new("/static", "./static"))
39
53
})
40
54
.bind(("0.0.0.0", 8080))?
41
55
.run()
+164
src/mst.rs
+164
src/mst.rs
···
1
+
use serde::{Deserialize, Serialize};
2
+
use std::collections::HashMap;
3
+
4
+
#[derive(Debug, Serialize, Deserialize, Clone)]
5
+
pub struct Record {
6
+
pub uri: String,
7
+
pub cid: String,
8
+
pub value: serde_json::Value,
9
+
}
10
+
11
+
#[derive(Debug, Serialize, Clone)]
12
+
#[serde(rename_all = "camelCase")]
13
+
pub struct MSTNode {
14
+
pub key: String,
15
+
pub cid: Option<String>,
16
+
pub uri: Option<String>,
17
+
pub value: Option<serde_json::Value>,
18
+
pub depth: i32,
19
+
pub children: Vec<MSTNode>,
20
+
}
21
+
22
+
#[derive(Debug, Serialize)]
23
+
#[serde(rename_all = "camelCase")]
24
+
pub struct MSTResponse {
25
+
pub root: MSTNode,
26
+
pub record_count: usize,
27
+
}
28
+
29
+
pub fn build_mst(records: Vec<Record>) -> MSTResponse {
30
+
let record_count = records.len();
31
+
32
+
// Extract and sort by key
33
+
let mut nodes: Vec<MSTNode> = records
34
+
.into_iter()
35
+
.map(|r| {
36
+
let key = r.uri.split('/').last().unwrap_or("").to_string();
37
+
MSTNode {
38
+
key: key.clone(),
39
+
cid: Some(r.cid),
40
+
uri: Some(r.uri),
41
+
value: Some(r.value),
42
+
depth: calculate_key_depth(&key),
43
+
children: vec![],
44
+
}
45
+
})
46
+
.collect();
47
+
48
+
nodes.sort_by(|a, b| a.key.cmp(&b.key));
49
+
50
+
// Build tree structure
51
+
let root = build_tree(nodes);
52
+
53
+
MSTResponse {
54
+
root,
55
+
record_count,
56
+
}
57
+
}
58
+
59
+
fn calculate_key_depth(key: &str) -> i32 {
60
+
// Simplified depth calculation based on key hash
61
+
let mut hash: i32 = 0;
62
+
for ch in key.chars() {
63
+
hash = hash.wrapping_shl(5).wrapping_sub(hash).wrapping_add(ch as i32);
64
+
}
65
+
66
+
// Count leading zero bits (approximation)
67
+
let abs_hash = hash.abs() as u32;
68
+
let binary = format!("{:032b}", abs_hash);
69
+
70
+
let mut depth = 0;
71
+
let chars: Vec<char> = binary.chars().collect();
72
+
let mut i = 0;
73
+
while i < chars.len() - 1 {
74
+
if chars[i] == '0' && chars[i + 1] == '0' {
75
+
depth += 1;
76
+
i += 2;
77
+
} else {
78
+
break;
79
+
}
80
+
}
81
+
82
+
depth.min(5)
83
+
}
84
+
85
+
fn build_tree(nodes: Vec<MSTNode>) -> MSTNode {
86
+
if nodes.is_empty() {
87
+
return MSTNode {
88
+
key: "root".to_string(),
89
+
cid: None,
90
+
uri: None,
91
+
value: None,
92
+
depth: -1,
93
+
children: vec![],
94
+
};
95
+
}
96
+
97
+
// Group by depth
98
+
let mut by_depth: HashMap<i32, Vec<MSTNode>> = HashMap::new();
99
+
for node in nodes {
100
+
by_depth.entry(node.depth).or_insert_with(Vec::new).push(node);
101
+
}
102
+
103
+
let mut depths: Vec<i32> = by_depth.keys().copied().collect();
104
+
depths.sort();
105
+
106
+
// Build tree bottom-up
107
+
let mut current_level: Vec<MSTNode> = by_depth.remove(&depths[depths.len() - 1]).unwrap_or_default();
108
+
109
+
// Work backwards through depths
110
+
for i in (0..depths.len() - 1).rev() {
111
+
let depth = depths[i];
112
+
let mut parent_nodes = by_depth.remove(&depth).unwrap_or_default();
113
+
114
+
// Distribute children to parents
115
+
let children_per_parent = if parent_nodes.is_empty() {
116
+
0
117
+
} else {
118
+
(current_level.len() + parent_nodes.len() - 1) / parent_nodes.len()
119
+
};
120
+
121
+
for (i, parent) in parent_nodes.iter_mut().enumerate() {
122
+
let start = i * children_per_parent;
123
+
let end = ((i + 1) * children_per_parent).min(current_level.len());
124
+
if start < current_level.len() {
125
+
parent.children = current_level.drain(start..end).collect();
126
+
}
127
+
}
128
+
129
+
current_level = parent_nodes;
130
+
}
131
+
132
+
// Create root and attach top-level nodes
133
+
MSTNode {
134
+
key: "root".to_string(),
135
+
cid: None,
136
+
uri: None,
137
+
value: None,
138
+
depth: -1,
139
+
children: current_level,
140
+
}
141
+
}
142
+
143
+
pub async fn fetch_records(pds: &str, did: &str, collection: &str) -> Result<Vec<Record>, String> {
144
+
let url = format!(
145
+
"{}/xrpc/com.atproto.repo.listRecords?repo={}&collection={}&limit=100",
146
+
pds, did, collection
147
+
);
148
+
149
+
let response = reqwest::get(&url)
150
+
.await
151
+
.map_err(|e| format!("Failed to fetch records: {}", e))?;
152
+
153
+
#[derive(Deserialize)]
154
+
struct ListRecordsResponse {
155
+
records: Vec<Record>,
156
+
}
157
+
158
+
let data: ListRecordsResponse = response
159
+
.json()
160
+
.await
161
+
.map_err(|e| format!("Failed to parse response: {}", e))?;
162
+
163
+
Ok(data.records)
164
+
}
+317
src/routes.rs
+317
src/routes.rs
···
3
3
use atrium_oauth::{AuthorizeOptions, CallbackParams, KnownScope, Scope};
4
4
use serde::Deserialize;
5
5
6
+
use crate::firehose::FirehoseManager;
7
+
use crate::mst;
6
8
use crate::oauth::OAuthClientType;
7
9
use crate::templates;
8
10
···
151
153
.content_type("image/svg+xml")
152
154
.body(FAVICON_SVG)
153
155
}
156
+
157
+
#[derive(Deserialize)]
158
+
pub struct MSTQuery {
159
+
pds: String,
160
+
did: String,
161
+
collection: String,
162
+
}
163
+
164
+
#[get("/api/mst")]
165
+
pub async fn get_mst(query: web::Query<MSTQuery>) -> HttpResponse {
166
+
match mst::fetch_records(&query.pds, &query.did, &query.collection).await {
167
+
Ok(records) => {
168
+
if records.is_empty() {
169
+
return HttpResponse::Ok().json(serde_json::json!({
170
+
"error": "no records found"
171
+
}));
172
+
}
173
+
174
+
let mst_data = mst::build_mst(records);
175
+
HttpResponse::Ok().json(mst_data)
176
+
}
177
+
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({
178
+
"error": e
179
+
})),
180
+
}
181
+
}
182
+
183
+
#[derive(Deserialize)]
184
+
pub struct InitQuery {
185
+
did: String,
186
+
}
187
+
188
+
#[derive(serde::Serialize)]
189
+
#[serde(rename_all = "camelCase")]
190
+
pub struct AppInfo {
191
+
namespace: String,
192
+
collections: Vec<String>,
193
+
}
194
+
195
+
#[derive(serde::Serialize)]
196
+
#[serde(rename_all = "camelCase")]
197
+
pub struct InitResponse {
198
+
did: String,
199
+
handle: String,
200
+
pds: String,
201
+
avatar: Option<String>,
202
+
apps: Vec<AppInfo>,
203
+
}
204
+
205
+
#[get("/api/init")]
206
+
pub async fn init(query: web::Query<InitQuery>) -> HttpResponse {
207
+
let did = &query.did;
208
+
209
+
// Fetch DID document
210
+
let did_doc_url = format!("https://plc.directory/{}", did);
211
+
let did_doc_response = match reqwest::get(&did_doc_url).await {
212
+
Ok(r) => r,
213
+
Err(e) => return HttpResponse::InternalServerError().json(serde_json::json!({
214
+
"error": format!("failed to fetch DID document: {}", e)
215
+
})),
216
+
};
217
+
218
+
let did_doc: serde_json::Value = match did_doc_response.json().await {
219
+
Ok(d) => d,
220
+
Err(e) => return HttpResponse::InternalServerError().json(serde_json::json!({
221
+
"error": format!("failed to parse DID document: {}", e)
222
+
})),
223
+
};
224
+
225
+
// Extract PDS and handle
226
+
let pds = did_doc["service"]
227
+
.as_array()
228
+
.and_then(|services| {
229
+
services.iter().find(|s| {
230
+
s["type"].as_str() == Some("AtprotoPersonalDataServer")
231
+
})
232
+
})
233
+
.and_then(|s| s["serviceEndpoint"].as_str())
234
+
.unwrap_or("")
235
+
.to_string();
236
+
237
+
let handle = did_doc["alsoKnownAs"]
238
+
.as_array()
239
+
.and_then(|aka| aka.get(0))
240
+
.and_then(|v| v.as_str())
241
+
.map(|s| s.replace("at://", ""))
242
+
.unwrap_or_else(|| did.to_string());
243
+
244
+
// Fetch user avatar from Bluesky
245
+
let avatar = fetch_user_avatar(did).await;
246
+
247
+
// Fetch collections from PDS
248
+
let repo_url = format!("{}/xrpc/com.atproto.repo.describeRepo?repo={}", pds, did);
249
+
let repo_response = match reqwest::get(&repo_url).await {
250
+
Ok(r) => r,
251
+
Err(e) => return HttpResponse::InternalServerError().json(serde_json::json!({
252
+
"error": format!("failed to fetch repo: {}", e)
253
+
})),
254
+
};
255
+
256
+
let repo_data: serde_json::Value = match repo_response.json().await {
257
+
Ok(d) => d,
258
+
Err(e) => return HttpResponse::InternalServerError().json(serde_json::json!({
259
+
"error": format!("failed to parse repo: {}", e)
260
+
})),
261
+
};
262
+
263
+
let collections = repo_data["collections"]
264
+
.as_array()
265
+
.map(|arr| {
266
+
arr.iter()
267
+
.filter_map(|v| v.as_str().map(String::from))
268
+
.collect::<Vec<String>>()
269
+
})
270
+
.unwrap_or_default();
271
+
272
+
// Group by namespace
273
+
let mut apps: std::collections::HashMap<String, Vec<String>> = std::collections::HashMap::new();
274
+
for collection in collections {
275
+
let parts: Vec<&str> = collection.split('.').collect();
276
+
if parts.len() >= 2 {
277
+
let namespace = format!("{}.{}", parts[0], parts[1]);
278
+
apps.entry(namespace)
279
+
.or_insert_with(Vec::new)
280
+
.push(collection);
281
+
}
282
+
}
283
+
284
+
let apps_list: Vec<AppInfo> = apps
285
+
.into_iter()
286
+
.map(|(namespace, collections)| AppInfo { namespace, collections })
287
+
.collect();
288
+
289
+
HttpResponse::Ok().json(InitResponse {
290
+
did: did.to_string(),
291
+
handle,
292
+
pds,
293
+
avatar,
294
+
apps: apps_list,
295
+
})
296
+
}
297
+
298
+
async fn fetch_user_avatar(did: &str) -> Option<String> {
299
+
let profile_url = format!("https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor={}", did);
300
+
if let Ok(response) = reqwest::get(&profile_url).await {
301
+
if let Ok(profile) = response.json::<serde_json::Value>().await {
302
+
return profile["avatar"].as_str().map(String::from);
303
+
}
304
+
}
305
+
None
306
+
}
307
+
308
+
#[derive(Deserialize)]
309
+
pub struct AvatarQuery {
310
+
namespace: String,
311
+
}
312
+
313
+
#[get("/api/avatar")]
314
+
pub async fn get_avatar(query: web::Query<AvatarQuery>) -> HttpResponse {
315
+
let namespace = &query.namespace;
316
+
317
+
// Reverse namespace to get domain (e.g., io.zzstoatzz -> zzstoatzz.io)
318
+
let reversed: String = namespace.split('.').rev().collect::<Vec<&str>>().join(".");
319
+
let handles = vec![
320
+
reversed.clone(),
321
+
format!("{}.bsky.social", reversed),
322
+
];
323
+
324
+
for handle in handles {
325
+
// Try to resolve handle to DID
326
+
let resolve_url = format!("https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle={}", handle);
327
+
if let Ok(response) = reqwest::get(&resolve_url).await {
328
+
if let Ok(data) = response.json::<serde_json::Value>().await {
329
+
if let Some(did) = data["did"].as_str() {
330
+
// Try to get profile
331
+
let profile_url = format!("https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor={}", did);
332
+
if let Ok(profile_response) = reqwest::get(&profile_url).await {
333
+
if let Ok(profile) = profile_response.json::<serde_json::Value>().await {
334
+
if let Some(avatar) = profile["avatar"].as_str() {
335
+
return HttpResponse::Ok().json(serde_json::json!({
336
+
"avatarUrl": avatar
337
+
}));
338
+
}
339
+
}
340
+
}
341
+
}
342
+
}
343
+
}
344
+
}
345
+
346
+
HttpResponse::Ok().json(serde_json::json!({
347
+
"avatarUrl": null
348
+
}))
349
+
}
350
+
351
+
#[derive(Deserialize)]
352
+
pub struct ValidateUrlQuery {
353
+
url: String,
354
+
}
355
+
356
+
#[get("/api/validate-url")]
357
+
pub async fn validate_url(query: web::Query<ValidateUrlQuery>) -> HttpResponse {
358
+
let url = &query.url;
359
+
360
+
// Build client with redirect following and timeout
361
+
let client = reqwest::Client::builder()
362
+
.timeout(std::time::Duration::from_secs(3))
363
+
.redirect(reqwest::redirect::Policy::limited(5))
364
+
.build()
365
+
.unwrap();
366
+
367
+
// Try HEAD first, fall back to GET if HEAD doesn't succeed
368
+
let is_valid = match client.head(url).send().await {
369
+
Ok(response) => {
370
+
let status = response.status();
371
+
if status.is_success() || status.is_redirection() {
372
+
true
373
+
} else {
374
+
// HEAD returned error status (like 405), try GET
375
+
match client.get(url).send().await {
376
+
Ok(get_response) => get_response.status().is_success(),
377
+
Err(_) => false,
378
+
}
379
+
}
380
+
}
381
+
Err(_) => {
382
+
// HEAD request failed completely, try GET as fallback
383
+
match client.get(url).send().await {
384
+
Ok(response) => response.status().is_success(),
385
+
Err(_) => false,
386
+
}
387
+
}
388
+
};
389
+
390
+
HttpResponse::Ok().json(serde_json::json!({
391
+
"valid": is_valid
392
+
}))
393
+
}
394
+
395
+
#[derive(Deserialize)]
396
+
pub struct RecordQuery {
397
+
pds: String,
398
+
did: String,
399
+
collection: String,
400
+
rkey: String,
401
+
}
402
+
403
+
#[get("/api/record")]
404
+
pub async fn get_record(query: web::Query<RecordQuery>) -> HttpResponse {
405
+
let record_url = format!(
406
+
"{}/xrpc/com.atproto.repo.getRecord?repo={}&collection={}&rkey={}",
407
+
query.pds, query.did, query.collection, query.rkey
408
+
);
409
+
410
+
match reqwest::get(&record_url).await {
411
+
Ok(response) => {
412
+
if !response.status().is_success() {
413
+
return HttpResponse::Ok().json(serde_json::json!({
414
+
"error": "record not found"
415
+
}));
416
+
}
417
+
418
+
match response.json::<serde_json::Value>().await {
419
+
Ok(data) => HttpResponse::Ok().json(data),
420
+
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({
421
+
"error": format!("failed to parse record: {}", e)
422
+
})),
423
+
}
424
+
}
425
+
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({
426
+
"error": format!("failed to fetch record: {}", e)
427
+
})),
428
+
}
429
+
}
430
+
431
+
#[derive(Deserialize)]
432
+
pub struct FirehoseQuery {
433
+
did: String,
434
+
}
435
+
436
+
#[get("/api/firehose/watch")]
437
+
pub async fn firehose_watch(
438
+
query: web::Query<FirehoseQuery>,
439
+
manager: web::Data<FirehoseManager>,
440
+
) -> HttpResponse {
441
+
let did = query.did.clone();
442
+
443
+
// Get or create a broadcaster for this DID
444
+
let broadcaster = crate::firehose::get_or_create_broadcaster(&manager, did.clone()).await;
445
+
let mut rx = broadcaster.subscribe();
446
+
447
+
log::info!("SSE connection established for DID: {}", did);
448
+
449
+
let stream = async_stream::stream! {
450
+
// Send initial connection message
451
+
yield Ok::<_, actix_web::Error>(
452
+
web::Bytes::from(format!("data: {{\"type\":\"connected\"}}\n\n"))
453
+
);
454
+
455
+
log::info!("Sent initial connection message to client");
456
+
457
+
// Stream firehose events (already filtered by DID at Jetstream level)
458
+
while let Ok(event) = rx.recv().await {
459
+
log::info!("Sending event to client: {} {} {}", event.action, event.did, event.collection);
460
+
let json = serde_json::to_string(&event).unwrap_or_default();
461
+
yield Ok(web::Bytes::from(format!("data: {}\n\n", json)));
462
+
}
463
+
};
464
+
465
+
HttpResponse::Ok()
466
+
.content_type("text/event-stream")
467
+
.insert_header(("Cache-Control", "no-cache"))
468
+
.insert_header(("X-Accel-Buffering", "no"))
469
+
.streaming(Box::pin(stream))
470
+
}
+852
-364
src/templates.rs
+852
-364
src/templates.rs
···
4
4
<html>
5
5
<head>
6
6
<meta charset="UTF-8">
7
-
<title>@me - login</title>
7
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
8
+
<title>@me - explore your atproto identity</title>
8
9
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
10
+
11
+
<!-- Open Graph / Facebook -->
12
+
<meta property="og:type" content="website">
13
+
<meta property="og:url" content="https://at-me.fly.dev/">
14
+
<meta property="og:title" content="@me - explore your atproto identity">
15
+
<meta property="og:description" 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" content="visualize your decentralized identity and see what apps have stored data in your Personal Data Server">
23
+
<meta property="twitter:image" content="https://at-me.fly.dev/static/og-image.png">
24
+
9
25
<style>
10
26
* { 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; }
27
+
28
+
body {
29
+
font-family: ui-monospace, 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Menlo, 'Courier New', monospace;
30
+
height: 100vh;
31
+
background: radial-gradient(ellipse at center, #0a0a0f 0%, #000000 100%);
32
+
color: #e5e5e5;
33
+
overflow: hidden;
34
+
perspective: 1000px;
35
+
}
36
+
37
+
.atmosphere {
38
+
position: fixed;
39
+
inset: 0;
40
+
transform-style: preserve-3d;
41
+
animation: rotate 120s infinite linear;
42
+
}
43
+
44
+
@keyframes rotate {
45
+
from { transform: rotateY(0deg); }
46
+
to { transform: rotateY(360deg); }
47
+
}
48
+
49
+
.app-orb {
50
+
position: absolute;
51
+
border-radius: 50%;
52
+
display: flex;
53
+
align-items: center;
54
+
justify-content: center;
55
+
transition: all 0.3s ease;
56
+
cursor: pointer;
57
+
backdrop-filter: blur(4px);
58
+
}
59
+
60
+
.app-orb:hover {
61
+
transform: scale(1.2) !important;
62
+
z-index: 100;
63
+
}
64
+
65
+
.app-orb img {
66
+
width: 100%;
67
+
height: 100%;
68
+
border-radius: 50%;
69
+
object-fit: cover;
70
+
}
71
+
72
+
.app-orb .fallback {
73
+
font-size: 1.5rem;
74
+
font-weight: 600;
75
+
color: rgba(255, 255, 255, 0.9);
76
+
}
77
+
78
+
.app-tooltip {
79
+
position: absolute;
80
+
background: rgba(10, 10, 15, 0.95);
81
+
border: 1px solid rgba(255, 255, 255, 0.1);
82
+
padding: 0.5rem 0.75rem;
83
+
border-radius: 4px;
84
+
font-size: 0.7rem;
85
+
white-space: nowrap;
86
+
pointer-events: none;
87
+
opacity: 0;
88
+
transition: opacity 0.2s;
89
+
z-index: 1000;
90
+
}
91
+
92
+
.app-orb:hover .app-tooltip {
93
+
opacity: 1;
94
+
}
95
+
96
+
.container {
97
+
position: fixed;
98
+
inset: 0;
99
+
display: flex;
100
+
align-items: center;
101
+
justify-content: center;
102
+
z-index: 10;
103
+
}
104
+
105
+
.login-card {
106
+
background: transparent;
107
+
border: 1px solid rgba(255, 255, 255, 0.1);
108
+
padding: 2.5rem 3rem;
109
+
border-radius: 8px;
110
+
backdrop-filter: blur(2px);
111
+
text-align: center;
112
+
box-shadow: none;
113
+
}
114
+
115
+
h1 {
116
+
font-size: 2rem;
117
+
margin-bottom: 0.5rem;
118
+
font-weight: 300;
119
+
letter-spacing: 0.05em;
120
+
}
121
+
122
+
.subtitle {
123
+
font-size: 0.75rem;
124
+
color: rgba(255, 255, 255, 0.5);
125
+
margin-bottom: 2rem;
126
+
}
127
+
128
+
input {
129
+
font-family: inherit;
130
+
font-size: 0.9rem;
131
+
padding: 0.75rem 1rem;
132
+
margin-bottom: 1rem;
133
+
background: rgba(10, 10, 15, 0.8);
134
+
border: 1px solid rgba(255, 255, 255, 0.2);
135
+
border-radius: 4px;
136
+
color: #e5e5e5;
137
+
width: 100%;
138
+
min-width: 300px;
139
+
transition: all 0.2s;
140
+
}
141
+
142
+
input:focus {
143
+
outline: none;
144
+
border-color: rgba(255, 255, 255, 0.4);
145
+
background: rgba(10, 10, 15, 0.9);
146
+
}
147
+
148
+
input::placeholder {
149
+
color: rgba(255, 255, 255, 0.3);
150
+
}
151
+
152
+
button {
153
+
font-family: inherit;
154
+
font-size: 0.9rem;
155
+
padding: 0.75rem 2rem;
156
+
cursor: pointer;
157
+
background: rgba(10, 10, 15, 0.8);
158
+
border: 1px solid rgba(255, 255, 255, 0.2);
159
+
border-radius: 4px;
160
+
color: #e5e5e5;
161
+
transition: all 0.2s;
162
+
width: 100%;
163
+
}
164
+
165
+
button:hover {
166
+
background: rgba(10, 10, 15, 0.9);
167
+
border-color: rgba(255, 255, 255, 0.4);
168
+
}
169
+
17
170
.hidden { display: none; }
18
-
.loading { color: #0f0; opacity: 0.5; }
171
+
.loading { color: rgba(255, 255, 255, 0.5); font-size: 0.9rem; }
172
+
173
+
.footer {
174
+
position: fixed;
175
+
bottom: 1rem;
176
+
left: 50%;
177
+
transform: translateX(-50%);
178
+
font-size: 0.7rem;
179
+
color: rgba(255, 255, 255, 0.3);
180
+
z-index: 20;
181
+
}
182
+
183
+
.footer a {
184
+
color: rgba(255, 255, 255, 0.5);
185
+
text-decoration: none;
186
+
transition: color 0.2s;
187
+
}
188
+
189
+
.footer a:hover {
190
+
color: rgba(255, 255, 255, 0.8);
191
+
}
19
192
</style>
20
193
</head>
21
194
<body>
195
+
<div class="atmosphere" id="atmosphere"></div>
196
+
22
197
<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>
198
+
<div class="login-card">
199
+
<div id="restoring" class="loading hidden">restoring session...</div>
200
+
<form id="loginForm" method="post" action="/login">
201
+
<h1>@me</h1>
202
+
<div class="subtitle">explore the atmosphere</div>
203
+
<input type="text" name="handle" placeholder="handle.bsky.social" required autofocus>
204
+
<button type="submit">enter</button>
205
+
</form>
206
+
</div>
29
207
</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
208
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>
209
+
<div class="footer">
210
+
by <a href="https://bsky.app/profile/zzstoatzz.io" target="_blank" rel="noopener noreferrer">@zzstoatzz.io</a>
211
+
</div>
212
+
213
+
<script src="/static/login.js"></script>
55
214
</body>
56
215
</html>
57
216
"#
···
64
223
<head>
65
224
<meta charset="UTF-8">
66
225
<meta name="viewport" content="width=device-width, initial-scale=1.0">
67
-
<title>@me</title>
226
+
<title>@me - explore your atproto identity</title>
68
227
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
228
+
229
+
<!-- Open Graph / Facebook -->
230
+
<meta property="og:type" content="website">
231
+
<meta property="og:url" content="https://at-me.fly.dev/">
232
+
<meta property="og:title" content="@me - explore your atproto identity">
233
+
<meta property="og:description" content="visualize your decentralized identity and see what apps have stored data in your Personal Data Server">
234
+
<meta property="og:image" content="https://at-me.fly.dev/static/og-image.png">
235
+
236
+
<!-- Twitter -->
237
+
<meta property="twitter:card" content="summary_large_image">
238
+
<meta property="twitter:url" content="https://at-me.fly.dev/">
239
+
<meta property="twitter:title" content="@me - explore your atproto identity">
240
+
<meta property="twitter:description" content="visualize your decentralized identity and see what apps have stored data in your Personal Data Server">
241
+
<meta property="twitter:image" content="https://at-me.fly.dev/static/og-image.png">
242
+
69
243
<style>
70
244
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
71
245
···
113
287
114
288
.logout {{
115
289
position: fixed;
116
-
top: 1.5rem;
117
-
right: 1.5rem;
118
-
font-size: 0.7rem;
290
+
top: clamp(1rem, 2vmin, 1.5rem);
291
+
right: clamp(1rem, 2vmin, 1.5rem);
292
+
font-size: clamp(0.65rem, 1.4vmin, 0.75rem);
119
293
color: var(--text-light);
120
294
text-decoration: none;
121
295
border: 1px solid var(--border);
122
-
padding: 0.4rem 0.8rem;
296
+
padding: clamp(0.4rem, 1vmin, 0.5rem) clamp(0.8rem, 2vmin, 1rem);
123
297
transition: all 0.2s ease;
124
298
z-index: 100;
125
299
-webkit-tap-highlight-color: transparent;
126
300
cursor: pointer;
127
301
border-radius: 2px;
302
+
display: flex;
303
+
align-items: center;
128
304
}}
129
305
130
306
.logout:hover, .logout:active {{
···
133
309
border-color: var(--text-light);
134
310
}}
135
311
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
312
.info {{
146
313
position: fixed;
147
-
top: 1.5rem;
148
-
left: 1.5rem;
149
-
width: 32px;
150
-
height: 32px;
314
+
top: clamp(1rem, 2vmin, 1.5rem);
315
+
left: clamp(1rem, 2vmin, 1.5rem);
316
+
width: clamp(32px, 6vmin, 40px);
317
+
height: clamp(32px, 6vmin, 40px);
151
318
border-radius: 50%;
152
319
border: 1px solid var(--border);
153
320
display: flex;
154
321
align-items: center;
155
322
justify-content: center;
156
-
font-size: 0.75rem;
323
+
font-size: clamp(0.7rem, 1.5vmin, 0.85rem);
157
324
color: var(--text-light);
158
325
cursor: pointer;
159
326
transition: all 0.2s ease;
···
165
332
background: var(--surface);
166
333
color: var(--text);
167
334
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
335
}}
179
336
180
337
.info-modal {{
···
270
427
background: var(--surface);
271
428
border: 2px solid var(--text-light);
272
429
border-radius: 50%;
273
-
width: 120px;
274
-
height: 120px;
430
+
width: clamp(100px, 20vmin, 140px);
431
+
height: clamp(100px, 20vmin, 140px);
275
432
display: flex;
276
433
flex-direction: column;
277
434
align-items: center;
278
435
justify-content: center;
279
-
gap: 0.3rem;
436
+
gap: clamp(0.2rem, 1vmin, 0.3rem);
437
+
padding: clamp(0.4rem, 1vmin, 0.6rem);
280
438
z-index: 10;
281
439
cursor: pointer;
282
440
transition: all 0.2s ease;
···
289
447
box-shadow: 0 0 20px rgba(255, 255, 255, 0.1);
290
448
}}
291
449
292
-
@media (max-width: 768px) {{
293
-
.identity {{
294
-
width: 100px;
295
-
height: 100px;
296
-
}}
297
-
}}
298
-
299
450
.identity-label {{
300
-
font-size: 0.45rem;
301
-
color: var(--text-lighter);
302
-
letter-spacing: 0.1em;
451
+
font-size: clamp(1rem, 2vmin, 1.2rem);
452
+
color: var(--text);
453
+
font-weight: 600;
454
+
line-height: 1;
303
455
}}
304
456
305
457
.identity-value {{
306
-
font-size: 0.75rem;
307
-
color: var(--text);
458
+
font-size: clamp(0.6rem, 1.2vmin, 0.7rem);
459
+
color: var(--text-lighter);
308
460
text-align: center;
309
461
word-break: break-word;
310
-
max-width: 100px;
311
-
font-weight: 500;
462
+
max-width: 90%;
463
+
font-weight: 400;
464
+
line-height: 1.2;
312
465
}}
313
466
314
467
.identity-hint {{
315
-
font-size: 0.4rem;
468
+
font-size: clamp(0.35rem, 0.8vmin, 0.45rem);
316
469
color: var(--text-lighter);
317
470
margin-top: 0.2rem;
318
471
letter-spacing: 0.05em;
319
472
}}
320
473
474
+
.identity-pds-label {{
475
+
position: absolute;
476
+
bottom: clamp(-1.5rem, -3vmin, -2rem);
477
+
font-size: clamp(0.55rem, 1.1vmin, 0.65rem);
478
+
color: var(--text-light);
479
+
letter-spacing: 0.05em;
480
+
font-weight: 500;
481
+
}}
482
+
483
+
.identity-avatar {{
484
+
width: clamp(30px, 6vmin, 45px);
485
+
height: clamp(30px, 6vmin, 45px);
486
+
border-radius: 50%;
487
+
object-fit: cover;
488
+
border: 2px solid var(--text-light);
489
+
margin-bottom: clamp(0.2rem, 1vmin, 0.3rem);
490
+
}}
491
+
321
492
.app-view {{
322
493
position: absolute;
323
494
display: flex;
324
495
flex-direction: column;
325
496
align-items: center;
326
-
gap: 0.4rem;
497
+
gap: clamp(0.3rem, 1vmin, 0.5rem);
327
498
cursor: pointer;
328
499
transition: all 0.2s ease;
329
500
opacity: 0.7;
···
339
510
background: var(--surface-hover);
340
511
border: 1px solid var(--border);
341
512
border-radius: 50%;
342
-
width: 60px;
343
-
height: 60px;
513
+
width: clamp(45px, 8vmin, 60px);
514
+
height: clamp(45px, 8vmin, 60px);
344
515
display: flex;
345
516
align-items: center;
346
517
justify-content: center;
347
518
transition: all 0.2s ease;
519
+
overflow: hidden;
520
+
font-size: clamp(1rem, 2vmin, 1.5rem);
521
+
}}
522
+
523
+
.app-logo {{
524
+
width: 100%;
525
+
height: 100%;
526
+
object-fit: cover;
348
527
}}
349
528
350
529
.app-view:hover .app-circle {{
···
353
532
}}
354
533
355
534
.app-name {{
356
-
font-size: 0.65rem;
535
+
font-size: clamp(0.6rem, 1.2vmin, 0.7rem);
357
536
color: var(--text);
358
537
text-align: center;
359
-
max-width: 100px;
538
+
max-width: clamp(80px, 15vmin, 120px);
539
+
text-decoration: none;
540
+
display: block;
541
+
}}
542
+
543
+
.app-name:hover {{
544
+
text-decoration: underline;
545
+
color: var(--text);
546
+
}}
547
+
548
+
.app-name.invalid-link {{
549
+
color: var(--text-light);
550
+
opacity: 0.5;
551
+
cursor: not-allowed;
552
+
}}
553
+
554
+
.app-name.invalid-link:hover {{
555
+
text-decoration: none;
556
+
color: var(--text-light);
360
557
}}
361
558
362
559
.detail-panel {{
···
364
561
top: 0;
365
562
left: 0;
366
563
bottom: 0;
367
-
width: 320px;
564
+
width: 500px;
368
565
background: var(--surface);
369
566
border-right: 2px solid var(--border);
370
567
padding: 2.5rem 2rem;
···
373
570
transform: translateX(-100%);
374
571
transition: all 0.25s ease;
375
572
z-index: 1000;
573
+
scrollbar-width: none;
574
+
-ms-overflow-style: none;
575
+
}}
576
+
577
+
.detail-panel::-webkit-scrollbar {{
578
+
display: none;
376
579
}}
377
580
378
581
.detail-panel.visible {{
···
478
681
color: var(--text-light);
479
682
}}
480
683
684
+
.collection-content {{
685
+
margin-top: 0.5rem;
686
+
padding-top: 0.5rem;
687
+
border-top: 1px solid var(--border);
688
+
}}
689
+
690
+
.collection-tabs {{
691
+
display: flex;
692
+
gap: 0;
693
+
margin-bottom: 0.75rem;
694
+
border: 1px solid var(--border);
695
+
border-radius: 2px;
696
+
overflow: hidden;
697
+
}}
698
+
699
+
.collection-tab {{
700
+
flex: 1;
701
+
padding: 0.5rem 0.75rem;
702
+
background: var(--bg);
703
+
border: none;
704
+
border-right: 1px solid var(--border);
705
+
color: var(--text-light);
706
+
font-family: inherit;
707
+
font-size: 0.65rem;
708
+
cursor: pointer;
709
+
transition: all 0.15s ease;
710
+
-webkit-tap-highlight-color: transparent;
711
+
}}
712
+
713
+
.collection-tab:last-child {{
714
+
border-right: none;
715
+
}}
716
+
717
+
.collection-tab:hover {{
718
+
background: var(--surface);
719
+
color: var(--text);
720
+
}}
721
+
722
+
.collection-tab.active {{
723
+
background: var(--surface-hover);
724
+
color: var(--text);
725
+
font-weight: 500;
726
+
}}
727
+
728
+
.collection-view-content {{
729
+
position: relative;
730
+
}}
731
+
732
+
.collection-view {{
733
+
display: none;
734
+
}}
735
+
736
+
.collection-view.active {{
737
+
display: block;
738
+
}}
739
+
740
+
.structure-view {{
741
+
min-height: 600px;
742
+
}}
743
+
744
+
.mst-canvas {{
745
+
width: 100%;
746
+
height: 600px;
747
+
border: 1px solid var(--border);
748
+
border-radius: 4px;
749
+
background: var(--bg);
750
+
margin-top: 0.5rem;
751
+
}}
752
+
753
+
.mst-info {{
754
+
background: var(--bg);
755
+
border: 1px solid var(--border);
756
+
padding: 0.75rem;
757
+
border-radius: 4px;
758
+
margin-bottom: 0.75rem;
759
+
}}
760
+
761
+
.mst-info p {{
762
+
font-size: 0.65rem;
763
+
color: var(--text-lighter);
764
+
line-height: 1.5;
765
+
margin: 0;
766
+
}}
767
+
768
+
.mst-node-modal {{
769
+
position: fixed;
770
+
inset: 0;
771
+
background: rgba(0, 0, 0, 0.75);
772
+
display: flex;
773
+
align-items: center;
774
+
justify-content: center;
775
+
z-index: 3000;
776
+
padding: 1rem;
777
+
}}
778
+
779
+
.mst-node-modal-content {{
780
+
background: var(--surface);
781
+
border: 2px solid var(--border);
782
+
padding: 2rem;
783
+
border-radius: 4px;
784
+
max-width: 600px;
785
+
width: 100%;
786
+
max-height: 80vh;
787
+
overflow-y: auto;
788
+
position: relative;
789
+
}}
790
+
791
+
.mst-node-close {{
792
+
position: absolute;
793
+
top: 1rem;
794
+
right: 1rem;
795
+
width: 32px;
796
+
height: 32px;
797
+
border: 1px solid var(--border);
798
+
background: var(--bg);
799
+
color: var(--text-light);
800
+
cursor: pointer;
801
+
display: flex;
802
+
align-items: center;
803
+
justify-content: center;
804
+
font-size: 1.2rem;
805
+
line-height: 1;
806
+
transition: all 0.2s ease;
807
+
border-radius: 2px;
808
+
}}
809
+
810
+
.mst-node-close:hover {{
811
+
background: var(--surface-hover);
812
+
border-color: var(--text-light);
813
+
color: var(--text);
814
+
}}
815
+
816
+
.mst-node-modal-content h3 {{
817
+
margin-bottom: 1rem;
818
+
font-size: 0.9rem;
819
+
color: var(--text);
820
+
}}
821
+
822
+
.mst-node-info {{
823
+
background: var(--bg);
824
+
border: 1px solid var(--border);
825
+
padding: 0.75rem;
826
+
border-radius: 4px;
827
+
margin-bottom: 1rem;
828
+
}}
829
+
830
+
.mst-node-field {{
831
+
display: flex;
832
+
gap: 0.5rem;
833
+
margin-bottom: 0.5rem;
834
+
font-size: 0.65rem;
835
+
}}
836
+
837
+
.mst-node-field:last-child {{
838
+
margin-bottom: 0;
839
+
}}
840
+
841
+
.mst-node-label {{
842
+
color: var(--text-light);
843
+
font-weight: 500;
844
+
min-width: 40px;
845
+
}}
846
+
847
+
.mst-node-value {{
848
+
color: var(--text);
849
+
word-break: break-all;
850
+
font-family: monospace;
851
+
}}
852
+
853
+
.mst-node-explanation {{
854
+
background: var(--bg);
855
+
border: 1px solid var(--border);
856
+
padding: 0.75rem;
857
+
border-radius: 4px;
858
+
margin-bottom: 1rem;
859
+
}}
860
+
861
+
.mst-node-explanation p {{
862
+
font-size: 0.65rem;
863
+
color: var(--text-lighter);
864
+
line-height: 1.5;
865
+
margin: 0;
866
+
}}
867
+
868
+
.mst-node-data {{
869
+
background: var(--bg);
870
+
border: 1px solid var(--border);
871
+
border-radius: 4px;
872
+
overflow: hidden;
873
+
}}
874
+
875
+
.mst-node-data-header {{
876
+
font-size: 0.65rem;
877
+
color: var(--text-light);
878
+
padding: 0.5rem 0.75rem;
879
+
border-bottom: 1px solid var(--border);
880
+
font-weight: 500;
881
+
}}
882
+
883
+
.mst-node-data pre {{
884
+
margin: 0;
885
+
padding: 0.75rem;
886
+
font-size: 0.625rem;
887
+
color: var(--text);
888
+
white-space: pre-wrap;
889
+
word-break: break-word;
890
+
line-height: 1.5;
891
+
}}
892
+
481
893
.record-list {{
482
894
margin-top: 0.5rem;
483
895
padding-top: 0.5rem;
···
485
897
}}
486
898
487
899
.record {{
488
-
padding: 0.6rem;
489
900
margin-bottom: 0.5rem;
490
901
background: var(--bg);
491
902
border: 1px solid var(--border);
···
493
904
font-size: 0.65rem;
494
905
color: var(--text-light);
495
906
transition: all 0.15s ease;
907
+
overflow: hidden;
496
908
}}
497
909
498
910
.record:hover {{
···
504
916
margin-bottom: 0;
505
917
}}
506
918
507
-
.record pre {{
919
+
.record-header {{
920
+
display: flex;
921
+
justify-content: space-between;
922
+
align-items: center;
923
+
padding: 0.5rem 0.6rem;
924
+
background: var(--surface);
925
+
border-bottom: 1px solid var(--border);
926
+
}}
927
+
928
+
.record-label {{
929
+
font-size: 0.6rem;
930
+
color: var(--text-lighter);
931
+
font-weight: 500;
932
+
}}
933
+
934
+
.copy-btn {{
935
+
background: var(--bg);
936
+
border: 1px solid var(--border);
937
+
color: var(--text-light);
938
+
font-family: inherit;
939
+
font-size: 0.55rem;
940
+
padding: 0.2rem 0.5rem;
941
+
cursor: pointer;
942
+
transition: all 0.15s ease;
943
+
border-radius: 2px;
944
+
-webkit-tap-highlight-color: transparent;
945
+
}}
946
+
947
+
.copy-btn:hover, .copy-btn:active {{
948
+
background: var(--surface-hover);
949
+
border-color: var(--text-light);
950
+
color: var(--text);
951
+
}}
952
+
953
+
.copy-btn.copied {{
954
+
color: var(--text);
955
+
border-color: var(--text);
956
+
}}
957
+
958
+
.record-content {{
959
+
padding: 0.6rem;
960
+
}}
961
+
962
+
.record-content pre {{
508
963
margin: 0;
509
964
white-space: pre-wrap;
510
965
word-break: break-word;
511
966
line-height: 1.5;
967
+
font-size: 0.625rem;
512
968
}}
513
969
514
970
.load-more {{
···
540
996
541
997
.footer {{
542
998
position: fixed;
543
-
bottom: 1rem;
999
+
bottom: clamp(0.75rem, 2vmin, 1rem);
544
1000
left: 50%;
545
1001
transform: translateX(-50%);
546
-
font-size: 0.65rem;
547
-
color: var(--text-light);
1002
+
font-size: clamp(0.6rem, 1.2vmin, 0.7rem);
1003
+
color: var(--text);
548
1004
z-index: 100;
1005
+
background: var(--surface);
1006
+
padding: clamp(0.4rem, 1vmin, 0.5rem) clamp(0.75rem, 2vmin, 1rem);
1007
+
border-radius: 4px;
1008
+
border: 1px solid var(--border);
549
1009
}}
550
1010
551
1011
.footer a {{
552
-
color: var(--text-light);
1012
+
color: var(--text);
553
1013
text-decoration: none;
554
1014
border-bottom: 1px solid transparent;
555
-
transition: border-color 0.2s ease;
1015
+
transition: all 0.2s ease;
556
1016
}}
557
1017
558
1018
.footer a:hover {{
559
-
border-bottom-color: var(--text-light);
1019
+
border-bottom-color: var(--text);
560
1020
}}
561
1021
562
1022
.loading {{ color: var(--text-light); font-size: 0.75rem; }}
563
-
</style>
564
-
</head>
565
-
<body>
566
-
<div class="info" id="infoBtn">i</div>
567
-
<a href="javascript:void(0)" id="logoutBtn" class="logout">logout</a>
568
1023
569
-
<div class="overlay" id="overlay"></div>
570
-
<div class="info-modal" id="infoModal">
571
-
<h2>@me - your at protocol identity</h2>
572
-
<p>in decentralized social networks, you own your identity and your data lives in your personal data server (pds).</p>
573
-
<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>
574
-
<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>
575
-
<button id="closeInfo">got it</button>
576
-
</div>
1024
+
.onboarding-overlay {{
1025
+
position: fixed;
1026
+
inset: 0;
1027
+
background: transparent;
1028
+
z-index: 3000;
1029
+
display: none;
1030
+
opacity: 0;
1031
+
transition: opacity 0.3s ease;
1032
+
pointer-events: none;
1033
+
}}
577
1034
578
-
<div class="canvas">
579
-
<div class="identity">
580
-
<div class="identity-label">@</div>
581
-
<div class="identity-value" id="handle">loading...</div>
582
-
<div class="identity-hint">tap for details</div>
583
-
</div>
584
-
<div id="field" class="loading">loading...</div>
585
-
</div>
586
-
<div id="detail" class="detail-panel"></div>
1035
+
.onboarding-overlay.active {{
1036
+
display: block;
1037
+
opacity: 1;
1038
+
}}
587
1039
588
-
<div class="footer">
589
-
<a href="https://tangled.org/@zzstoatzz.io/at-me" target="_blank" rel="noopener noreferrer">view source</a>
590
-
</div>
591
-
<script>
592
-
const did = '{}';
593
-
localStorage.setItem('atme_did', did);
1040
+
.onboarding-spotlight {{
1041
+
position: absolute;
1042
+
border: 2px solid rgba(255, 255, 255, 0.9);
1043
+
border-radius: 50%;
1044
+
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.75), 0 0 40px rgba(255, 255, 255, 0.5);
1045
+
pointer-events: none;
1046
+
transition: all 0.5s ease;
1047
+
}}
594
1048
595
-
let globalPds = null;
596
-
let globalHandle = null;
1049
+
.onboarding-content {{
1050
+
position: fixed;
1051
+
background: var(--surface);
1052
+
border: 2px solid var(--border);
1053
+
padding: clamp(1rem, 3vmin, 2rem);
1054
+
max-width: min(400px, 90vw);
1055
+
z-index: 3001;
1056
+
border-radius: 4px;
1057
+
transition: all 0.3s ease;
1058
+
pointer-events: auto;
1059
+
}}
597
1060
598
-
// Logout handler
599
-
document.getElementById('logoutBtn').addEventListener('click', (e) => {{
600
-
e.preventDefault();
601
-
localStorage.removeItem('atme_did');
602
-
window.location.href = '/logout';
603
-
}});
1061
+
.onboarding-content h3 {{
1062
+
font-size: clamp(0.9rem, 2vmin, 1.1rem);
1063
+
margin-bottom: clamp(0.5rem, 1.5vmin, 0.75rem);
1064
+
color: var(--text);
1065
+
font-weight: 500;
1066
+
}}
604
1067
605
-
// Info modal handlers
606
-
document.getElementById('infoBtn').addEventListener('click', () => {{
607
-
document.getElementById('infoModal').classList.add('visible');
608
-
document.getElementById('overlay').classList.add('visible');
609
-
}});
1068
+
.onboarding-content p {{
1069
+
font-size: clamp(0.7rem, 1.5vmin, 0.85rem);
1070
+
color: var(--text-light);
1071
+
line-height: 1.5;
1072
+
margin-bottom: clamp(1rem, 2vmin, 1.25rem);
1073
+
}}
610
1074
611
-
document.getElementById('closeInfo').addEventListener('click', () => {{
612
-
document.getElementById('infoModal').classList.remove('visible');
613
-
document.getElementById('overlay').classList.remove('visible');
614
-
}});
1075
+
.onboarding-actions {{
1076
+
display: flex;
1077
+
gap: clamp(0.5rem, 1.5vmin, 0.75rem);
1078
+
justify-content: flex-end;
1079
+
}}
615
1080
616
-
document.getElementById('overlay').addEventListener('click', () => {{
617
-
document.getElementById('infoModal').classList.remove('visible');
618
-
document.getElementById('overlay').classList.remove('visible');
619
-
const detail = document.getElementById('detail');
620
-
detail.classList.remove('visible');
621
-
}});
1081
+
.onboarding-actions button {{
1082
+
font-family: inherit;
1083
+
font-size: clamp(0.7rem, 1.5vmin, 0.8rem);
1084
+
padding: clamp(0.4rem, 1vmin, 0.5rem) clamp(0.8rem, 2vmin, 1rem);
1085
+
background: transparent;
1086
+
border: 1px solid var(--border);
1087
+
color: var(--text);
1088
+
cursor: pointer;
1089
+
transition: all 0.2s ease;
1090
+
border-radius: 2px;
1091
+
}}
622
1092
623
-
// First resolve DID to get PDS endpoint and handle
624
-
fetch('https://plc.directory/' + did)
625
-
.then(r => r.json())
626
-
.then(didDoc => {{
627
-
const pds = didDoc.service.find(s => s.type === 'AtprotoPersonalDataServer')?.serviceEndpoint;
628
-
const handle = didDoc.alsoKnownAs?.[0]?.replace('at://', '') || did;
1093
+
.onboarding-actions button:hover {{
1094
+
background: var(--surface-hover);
1095
+
border-color: var(--text-light);
1096
+
}}
629
1097
630
-
globalPds = pds;
631
-
globalHandle = handle;
1098
+
.onboarding-actions button.primary {{
1099
+
background: var(--surface-hover);
1100
+
border-color: var(--text-light);
1101
+
}}
632
1102
633
-
// Update identity display with handle
634
-
document.getElementById('handle').textContent = handle;
1103
+
.onboarding-progress {{
1104
+
display: flex;
1105
+
gap: clamp(0.4rem, 1vmin, 0.5rem);
1106
+
justify-content: center;
1107
+
margin-top: clamp(0.75rem, 2vmin, 1rem);
1108
+
}}
635
1109
636
-
// Add identity click handler to show PDS info
637
-
document.querySelector('.identity').addEventListener('click', () => {{
638
-
const detail = document.getElementById('detail');
639
-
const pdsHost = pds.replace('https://', '').replace('http://', '');
640
-
detail.innerHTML = `
641
-
<button class="detail-close" id="detailClose">ร</button>
642
-
<h3>your identity</h3>
643
-
<div class="subtitle">decentralized identifier & storage</div>
644
-
<div class="tree-item">
645
-
<div class="tree-item-header">
646
-
<span style="color: var(--text-light);">did</span>
647
-
<span style="font-size: 0.6rem; color: var(--text);">${{did}}</span>
648
-
</div>
649
-
</div>
650
-
<div class="tree-item">
651
-
<div class="tree-item-header">
652
-
<span style="color: var(--text-light);">handle</span>
653
-
<span style="font-size: 0.6rem; color: var(--text);">@${{handle}}</span>
654
-
</div>
655
-
</div>
656
-
<div class="tree-item">
657
-
<div class="tree-item-header">
658
-
<span style="color: var(--text-light);">personal data server</span>
659
-
<span style="font-size: 0.6rem; color: var(--text);">${{pds}}</span>
660
-
</div>
661
-
</div>
662
-
<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);">
663
-
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.
664
-
</div>
665
-
`;
666
-
detail.classList.add('visible');
1110
+
.onboarding-progress span {{
1111
+
width: clamp(6px, 1.5vmin, 8px);
1112
+
height: clamp(6px, 1.5vmin, 8px);
1113
+
border-radius: 50%;
1114
+
background: var(--border);
1115
+
transition: background 0.3s ease;
1116
+
}}
667
1117
668
-
// Add close button handler
669
-
document.getElementById('detailClose').addEventListener('click', (e) => {{
670
-
e.stopPropagation();
671
-
detail.classList.remove('visible');
672
-
}});
673
-
}});
1118
+
.onboarding-progress span.active {{
1119
+
background: var(--text);
1120
+
}}
674
1121
675
-
// Get all collections from PDS
676
-
return fetch(`${{pds}}/xrpc/com.atproto.repo.describeRepo?repo=${{did}}`);
677
-
}})
678
-
.then(r => r.json())
679
-
.then(repo => {{
680
-
const collections = repo.collections || [];
1122
+
.onboarding-progress span.done {{
1123
+
background: var(--text-light);
1124
+
}}
681
1125
682
-
// Group by app namespace (first two parts of lexicon)
683
-
const apps = {{}};
684
-
collections.forEach(collection => {{
685
-
const parts = collection.split('.');
686
-
if (parts.length >= 2) {{
687
-
const namespace = `${{parts[0]}}.${{parts[1]}}`;
688
-
if (!apps[namespace]) apps[namespace] = [];
689
-
apps[namespace].push(collection);
690
-
}}
691
-
}});
1126
+
.stats-box {{
1127
+
display: flex;
1128
+
gap: 1.5rem;
1129
+
margin: 1.5rem 0;
1130
+
padding: 1rem;
1131
+
background: var(--bg);
1132
+
border-radius: 4px;
1133
+
border: 1px solid var(--border);
1134
+
}}
692
1135
693
-
const field = document.getElementById('field');
694
-
field.innerHTML = '';
695
-
field.classList.remove('loading');
1136
+
.stat {{
1137
+
flex: 1;
1138
+
text-align: center;
1139
+
}}
696
1140
697
-
const appNames = Object.keys(apps).sort();
698
-
const radius = 240;
699
-
const centerX = window.innerWidth / 2;
700
-
const centerY = window.innerHeight / 2;
1141
+
.stat-value {{
1142
+
font-size: 1.8rem;
1143
+
font-weight: 600;
1144
+
color: var(--text);
1145
+
margin-bottom: 0.25rem;
1146
+
}}
701
1147
702
-
appNames.forEach((namespace, i) => {{
703
-
const angle = (i / appNames.length) * 2 * Math.PI - Math.PI / 2; // Start from top
704
-
const x = centerX + radius * Math.cos(angle) - 25;
705
-
const y = centerY + radius * Math.sin(angle) - 30;
1148
+
.stat-label {{
1149
+
font-size: 0.65rem;
1150
+
color: var(--text-light);
1151
+
text-transform: uppercase;
1152
+
letter-spacing: 0.05em;
1153
+
}}
706
1154
707
-
const div = document.createElement('div');
708
-
div.className = 'app-view';
709
-
div.style.left = `${{x}}px`;
710
-
div.style.top = `${{y}}px`;
1155
+
.ownership-box {{
1156
+
margin: 1rem 0;
1157
+
padding: 1rem;
1158
+
background: var(--bg);
1159
+
border-radius: 4px;
1160
+
border: 1px solid var(--border);
1161
+
}}
711
1162
712
-
const firstLetter = namespace.split('.')[1]?.[0]?.toUpperCase() || namespace[0].toUpperCase();
1163
+
.ownership-box.yours {{
1164
+
background: rgba(76, 175, 80, 0.05);
1165
+
border-color: rgba(76, 175, 80, 0.3);
1166
+
}}
713
1167
714
-
div.innerHTML = `
715
-
<div class="app-circle">${{firstLetter}}</div>
716
-
<div class="app-name">${{namespace}}</div>
717
-
`;
1168
+
@media (prefers-color-scheme: dark) {{
1169
+
.ownership-box.yours {{
1170
+
background: rgba(76, 175, 80, 0.08);
1171
+
border-color: rgba(76, 175, 80, 0.4);
1172
+
}}
1173
+
}}
718
1174
719
-
div.addEventListener('click', () => {{
720
-
const detail = document.getElementById('detail');
721
-
const collections = apps[namespace];
1175
+
.ownership-header {{
1176
+
font-size: 0.7rem;
1177
+
font-weight: 600;
1178
+
color: var(--text);
1179
+
margin-bottom: 0.5rem;
1180
+
text-transform: uppercase;
1181
+
letter-spacing: 0.05em;
1182
+
}}
722
1183
723
-
let html = `
724
-
<button class="detail-close" id="detailClose">ร</button>
725
-
<h3>${{namespace}}</h3>
726
-
<div class="subtitle">records stored in your pds:</div>
727
-
`;
1184
+
.ownership-text {{
1185
+
font-size: 0.7rem;
1186
+
color: var(--text-lighter);
1187
+
line-height: 1.5;
1188
+
}}
728
1189
729
-
if (collections && collections.length > 0) {{
730
-
collections.sort().forEach(lexicon => {{
731
-
const shortName = lexicon.split('.').slice(2).join('.') || lexicon;
732
-
html += `
733
-
<div class="tree-item" data-lexicon="${{lexicon}}">
734
-
<div class="tree-item-header">
735
-
<span>${{shortName}}</span>
736
-
<span class="tree-item-count">loading...</span>
737
-
</div>
738
-
</div>
739
-
`;
740
-
}});
741
-
}} else {{
742
-
html += `<div class="tree-item">no collections found</div>`;
743
-
}}
1190
+
.ownership-text strong {{
1191
+
color: var(--text);
1192
+
}}
744
1193
745
-
detail.innerHTML = html;
746
-
detail.classList.add('visible');
1194
+
.watch-live-btn {{
1195
+
position: fixed;
1196
+
top: clamp(1rem, 2vmin, 1.5rem);
1197
+
right: clamp(6rem, 14vmin, 9rem);
1198
+
font-family: inherit;
1199
+
font-size: clamp(0.65rem, 1.4vmin, 0.75rem);
1200
+
color: var(--text-light);
1201
+
border: 1px solid var(--border);
1202
+
background: var(--bg);
1203
+
padding: clamp(0.4rem, 1vmin, 0.5rem) clamp(0.8rem, 2vmin, 1rem);
1204
+
transition: all 0.2s ease;
1205
+
z-index: 100;
1206
+
cursor: pointer;
1207
+
border-radius: 2px;
1208
+
display: flex;
1209
+
align-items: center;
1210
+
gap: 0.5rem;
1211
+
}}
747
1212
748
-
// Add close button handler
749
-
document.getElementById('detailClose').addEventListener('click', (e) => {{
750
-
e.stopPropagation();
751
-
detail.classList.remove('visible');
752
-
}});
1213
+
.watch-live-btn:hover {{
1214
+
background: var(--surface);
1215
+
color: var(--text);
1216
+
border-color: var(--text-light);
1217
+
}}
753
1218
754
-
// Fetch record counts for each collection
755
-
if (collections && collections.length > 0) {{
756
-
collections.forEach(lexicon => {{
757
-
fetch(`${{globalPds}}/xrpc/com.atproto.repo.listRecords?repo=${{did}}&collection=${{lexicon}}&limit=1`)
758
-
.then(r => r.json())
759
-
.then(data => {{
760
-
const item = detail.querySelector(`[data-lexicon="${{lexicon}}"]`);
761
-
if (item) {{
762
-
const countSpan = item.querySelector('.tree-item-count');
763
-
// The cursor field indicates there are more records
764
-
countSpan.textContent = data.records?.length > 0 ? 'has records' : 'empty';
765
-
}}
766
-
}})
767
-
.catch(e => {{
768
-
console.error('Error fetching count for', lexicon, e);
769
-
const item = detail.querySelector(`[data-lexicon="${{lexicon}}"]`);
770
-
if (item) {{
771
-
const countSpan = item.querySelector('.tree-item-count');
772
-
countSpan.textContent = 'error';
773
-
}}
774
-
}});
775
-
}});
776
-
}}
1219
+
.watch-live-btn.active {{
1220
+
background: var(--surface-hover);
1221
+
color: var(--text);
1222
+
border-color: var(--text);
1223
+
}}
777
1224
778
-
// Add click handlers to tree items to fetch actual records
779
-
detail.querySelectorAll('.tree-item[data-lexicon]').forEach(item => {{
780
-
item.addEventListener('click', (e) => {{
781
-
e.stopPropagation();
782
-
const lexicon = item.dataset.lexicon;
783
-
const existingRecords = item.querySelector('.record-list');
1225
+
.watch-indicator {{
1226
+
width: 8px;
1227
+
height: 8px;
1228
+
border-radius: 50%;
1229
+
background: var(--text-light);
1230
+
display: none;
1231
+
}}
784
1232
785
-
if (existingRecords) {{
786
-
existingRecords.remove();
787
-
return;
788
-
}}
1233
+
.watch-live-btn.active .watch-indicator {{
1234
+
display: block;
1235
+
animation: pulse 2s ease-in-out infinite;
1236
+
}}
789
1237
790
-
const recordListDiv = document.createElement('div');
791
-
recordListDiv.className = 'record-list';
792
-
recordListDiv.innerHTML = '<div class="loading">loading records...</div>';
793
-
item.appendChild(recordListDiv);
1238
+
@keyframes pulse {{
1239
+
0%, 100% {{ opacity: 1; }}
1240
+
50% {{ opacity: 0.3; }}
1241
+
}}
794
1242
795
-
fetch(`${{globalPds}}/xrpc/com.atproto.repo.listRecords?repo=${{did}}&collection=${{lexicon}}&limit=5`)
796
-
.then(r => r.json())
797
-
.then(data => {{
798
-
if (data.records && data.records.length > 0) {{
799
-
let recordsHtml = '';
800
-
data.records.forEach(record => {{
801
-
const json = JSON.stringify(record.value, null, 2);
802
-
recordsHtml += `<div class="record"><pre>${{json}}</pre></div>`;
803
-
}});
1243
+
.firehose-toast {{
1244
+
position: fixed;
1245
+
top: clamp(4rem, 8vmin, 5rem);
1246
+
right: clamp(1rem, 2vmin, 1.5rem);
1247
+
background: var(--surface);
1248
+
border: 1px solid var(--border);
1249
+
padding: 0.75rem 1rem;
1250
+
border-radius: 4px;
1251
+
font-size: 0.7rem;
1252
+
color: var(--text);
1253
+
z-index: 200;
1254
+
opacity: 0;
1255
+
transform: translateY(-10px);
1256
+
transition: all 0.3s ease;
1257
+
pointer-events: none;
1258
+
max-width: 300px;
1259
+
}}
804
1260
805
-
if (data.cursor) {{
806
-
recordsHtml += `<button class="load-more" data-cursor="${{data.cursor}}" data-lexicon="${{lexicon}}">load more</button>`;
807
-
}}
1261
+
.firehose-toast.visible {{
1262
+
opacity: 1;
1263
+
transform: translateY(0);
1264
+
pointer-events: auto;
1265
+
}}
808
1266
809
-
recordListDiv.innerHTML = recordsHtml;
1267
+
.firehose-toast-action {{
1268
+
font-weight: 600;
1269
+
color: var(--text);
1270
+
}}
810
1271
811
-
// Use event delegation for load more buttons
812
-
recordListDiv.addEventListener('click', (e) => {{
813
-
if (e.target.classList.contains('load-more')) {{
814
-
e.stopPropagation();
815
-
const loadMoreBtn = e.target;
816
-
const cursor = loadMoreBtn.dataset.cursor;
817
-
const lexicon = loadMoreBtn.dataset.lexicon;
1272
+
.firehose-toast-collection {{
1273
+
color: var(--text-light);
1274
+
font-size: 0.65rem;
1275
+
margin-top: 0.25rem;
1276
+
}}
818
1277
819
-
loadMoreBtn.textContent = 'loading...';
1278
+
.firehose-toast-link {{
1279
+
display: inline-block;
1280
+
color: var(--text-light);
1281
+
font-size: 0.6rem;
1282
+
margin-top: 0.5rem;
1283
+
text-decoration: none;
1284
+
border-bottom: 1px solid transparent;
1285
+
transition: all 0.2s ease;
1286
+
pointer-events: auto;
1287
+
}}
820
1288
821
-
fetch(`${{globalPds}}/xrpc/com.atproto.repo.listRecords?repo=${{did}}&collection=${{lexicon}}&limit=5&cursor=${{cursor}}`)
822
-
.then(r => r.json())
823
-
.then(moreData => {{
824
-
let moreHtml = '';
825
-
moreData.records.forEach(record => {{
826
-
const json = JSON.stringify(record.value, null, 2);
827
-
moreHtml += `<div class="record"><pre>${{json}}</pre></div>`;
828
-
}});
1289
+
.firehose-toast-link:hover {{
1290
+
color: var(--text);
1291
+
border-bottom-color: var(--text);
1292
+
}}
829
1293
830
-
loadMoreBtn.remove();
831
-
recordListDiv.insertAdjacentHTML('beforeend', moreHtml);
1294
+
@media (max-width: 768px) {{
1295
+
.watch-live-btn {{
1296
+
right: clamp(1rem, 2vmin, 1.5rem);
1297
+
top: clamp(4rem, 8vmin, 5rem);
1298
+
}}
832
1299
833
-
if (moreData.cursor) {{
834
-
recordListDiv.insertAdjacentHTML('beforeend',
835
-
`<button class="load-more" data-cursor="${{moreData.cursor}}" data-lexicon="${{lexicon}}">load more</button>`
836
-
);
837
-
}}
838
-
}});
839
-
}}
840
-
}});
841
-
}} else {{
842
-
recordListDiv.innerHTML = '<div class="record">no records found</div>';
843
-
}}
844
-
}})
845
-
.catch(e => {{
846
-
console.error('Error fetching records:', e);
847
-
recordListDiv.innerHTML = '<div class="record">error loading records</div>';
848
-
}});
849
-
}});
850
-
}});
851
-
}});
1300
+
.firehose-toast {{
1301
+
top: clamp(7rem, 12vmin, 8rem);
1302
+
right: clamp(1rem, 2vmin, 1.5rem);
1303
+
left: clamp(1rem, 2vmin, 1.5rem);
1304
+
max-width: none;
1305
+
}}
1306
+
}}
1307
+
</style>
1308
+
</head>
1309
+
<body>
1310
+
<div class="info" id="infoBtn">?</div>
1311
+
<button class="watch-live-btn" id="watchLiveBtn">
1312
+
<span class="watch-indicator"></span>
1313
+
<span class="watch-label">watch live</span>
1314
+
</button>
1315
+
<a href="javascript:void(0)" id="logoutBtn" class="logout">logout</a>
852
1316
853
-
field.appendChild(div);
854
-
}});
1317
+
<div class="firehose-toast" id="firehoseToast">
1318
+
<div class="firehose-toast-action"></div>
1319
+
<div class="firehose-toast-collection"></div>
1320
+
<a class='firehose-toast-link' id='firehoseToastLink' href='#' target='_blank' rel='noopener noreferrer'>view record</a>
1321
+
</div>
855
1322
856
-
// Close detail panel when clicking canvas
857
-
const canvas = document.querySelector('.canvas');
858
-
canvas.addEventListener('click', (e) => {{
859
-
if (e.target === canvas) {{
860
-
document.getElementById('detail').classList.remove('visible');
861
-
}}
862
-
}});
863
-
}})
864
-
.catch(e => {{
865
-
document.getElementById('field').innerHTML = 'error loading records';
866
-
console.error(e);
867
-
}});
1323
+
<div class="overlay" id="overlay"></div>
1324
+
<div class="info-modal" id="infoModal">
1325
+
<h2>@me - your repository</h2>
1326
+
<p>on traditional social platforms, your content is locked in. want to switch? you start from zero. you build their network, they control the distribution.</p>
1327
+
<p>on atproto, you own everything. your data lives in your <a href="https://atproto.com/guides/overview" target="_blank" rel="noopener noreferrer" style="color: var(--text); text-decoration: underline;">Personal Data Server (PDS)</a>. apps like bluesky, whitewind, and frontpage just write to YOUR space. switch apps anytime, take it all with you.</p>
1328
+
<p>click your @ in the center to see what you've built. click any app to see what it's stored in your repository.</p>
1329
+
<button id="closeInfo">got it</button>
1330
+
<button id="restartTour" onclick="window.restartOnboarding()" style="margin-left: 0.5rem; background: var(--surface-hover);">restart tour</button>
1331
+
</div>
1332
+
1333
+
<div class="onboarding-overlay" id="onboardingOverlay">
1334
+
<div class="onboarding-spotlight" id="onboardingSpotlight"></div>
1335
+
<div class="onboarding-content" id="onboardingContent"></div>
1336
+
</div>
1337
+
1338
+
<div class="canvas">
1339
+
<div class="identity">
1340
+
<div class="identity-label">@</div>
1341
+
<div class="identity-value" id="handle">loading...</div>
1342
+
<div class="identity-hint">tap for details</div>
1343
+
<div class="identity-pds-label">Your PDS</div>
1344
+
</div>
1345
+
<div id="field" class="loading">loading...</div>
1346
+
</div>
1347
+
<div id="detail" class="detail-panel"></div>
1348
+
1349
+
<div class="footer">
1350
+
<a href="https://tangled.org/@zzstoatzz.io/at-me" target="_blank" rel="noopener noreferrer">view source</a>
1351
+
</div>
1352
+
<script>
1353
+
window.DID = '{}';
868
1354
</script>
1355
+
<script src="/static/app.js"></script>
1356
+
<script src="/static/onboarding.js"></script>
869
1357
</body>
870
1358
</html>
871
1359
"#, did)
+1115
static/app.js
+1115
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
+
8
+
// Fetch app avatar from server
9
+
async function fetchAppAvatar(namespace) {
10
+
try {
11
+
const response = await fetch(`/api/avatar?namespace=${encodeURIComponent(namespace)}`);
12
+
const data = await response.json();
13
+
return data.avatarUrl;
14
+
} catch (e) {
15
+
return null;
16
+
}
17
+
}
18
+
19
+
// Logout handler
20
+
document.getElementById('logoutBtn').addEventListener('click', (e) => {
21
+
e.preventDefault();
22
+
localStorage.removeItem('atme_did');
23
+
window.location.href = '/logout';
24
+
});
25
+
26
+
// Info modal handlers
27
+
document.getElementById('infoBtn').addEventListener('click', () => {
28
+
document.getElementById('infoModal').classList.add('visible');
29
+
document.getElementById('overlay').classList.add('visible');
30
+
});
31
+
32
+
document.getElementById('closeInfo').addEventListener('click', () => {
33
+
document.getElementById('infoModal').classList.remove('visible');
34
+
document.getElementById('overlay').classList.remove('visible');
35
+
});
36
+
37
+
document.getElementById('overlay').addEventListener('click', () => {
38
+
document.getElementById('infoModal').classList.remove('visible');
39
+
document.getElementById('overlay').classList.remove('visible');
40
+
const detail = document.getElementById('detail');
41
+
detail.classList.remove('visible');
42
+
});
43
+
44
+
// Fetch initialization data from server
45
+
fetch(`/api/init?did=${encodeURIComponent(did)}`)
46
+
.then(r => r.json())
47
+
.then(initData => {
48
+
globalPds = initData.pds;
49
+
globalHandle = initData.handle;
50
+
51
+
// Update identity display with handle
52
+
document.getElementById('handle').textContent = initData.handle;
53
+
54
+
// Display user's avatar if available
55
+
if (initData.avatar) {
56
+
const identity = document.querySelector('.identity');
57
+
const avatarImg = document.createElement('img');
58
+
avatarImg.src = initData.avatar;
59
+
avatarImg.className = 'identity-avatar';
60
+
avatarImg.alt = initData.handle;
61
+
// Insert avatar before the @ label
62
+
identity.insertBefore(avatarImg, identity.firstChild);
63
+
}
64
+
65
+
// Convert apps array to object for easier access
66
+
const apps = {};
67
+
const allCollections = [];
68
+
initData.apps.forEach(app => {
69
+
apps[app.namespace] = app.collections;
70
+
allCollections.push(...app.collections);
71
+
});
72
+
73
+
// Add identity click handler now that we have the data
74
+
const pdsHost = globalPds.replace('https://', '').replace('http://', '');
75
+
document.querySelector('.identity').addEventListener('click', () => {
76
+
const detail = document.getElementById('detail');
77
+
const appCount = Object.keys(apps).length;
78
+
79
+
detail.innerHTML = `
80
+
<button class="detail-close" id="detailClose">ร</button>
81
+
<h3>your repository</h3>
82
+
<div class="subtitle">what you've built</div>
83
+
84
+
<div class="stats-box">
85
+
<div class="stat">
86
+
<div class="stat-value">${allCollections.length}</div>
87
+
<div class="stat-label">record types</div>
88
+
</div>
89
+
<div class="stat">
90
+
<div class="stat-value">${appCount}</div>
91
+
<div class="stat-label">apps</div>
92
+
</div>
93
+
</div>
94
+
95
+
<div class="ownership-box">
96
+
<div class="ownership-header">on traditional platforms</div>
97
+
<div class="ownership-text">your content is locked in. switching platforms means starting over. you build their network, they own the distribution.</div>
98
+
</div>
99
+
100
+
<div class="ownership-box yours">
101
+
<div class="ownership-header">on atproto</div>
102
+
<div class="ownership-text">your content, your server. apps just read and write to <strong>${pdsHost}</strong>. switch apps anytime, take your data anywhere.</div>
103
+
</div>
104
+
105
+
<div style="margin-top: 1.5rem; padding-top: 1rem; border-top: 1px solid var(--border);">
106
+
<div style="font-size: 0.65rem; color: var(--text-light); margin-bottom: 0.5rem;">technical details</div>
107
+
<div class="tree-item">
108
+
<div class="tree-item-header">
109
+
<span style="color: var(--text-light);">did</span>
110
+
<span style="font-size: 0.55rem; color: var(--text);">${did}</span>
111
+
</div>
112
+
</div>
113
+
<div class="tree-item">
114
+
<div class="tree-item-header">
115
+
<span style="color: var(--text-light);">handle</span>
116
+
<span style="font-size: 0.6rem; color: var(--text);">@${globalHandle}</span>
117
+
</div>
118
+
</div>
119
+
</div>
120
+
`;
121
+
detail.classList.add('visible');
122
+
123
+
// Add close button handler
124
+
document.getElementById('detailClose').addEventListener('click', (e) => {
125
+
e.stopPropagation();
126
+
detail.classList.remove('visible');
127
+
});
128
+
});
129
+
130
+
const field = document.getElementById('field');
131
+
field.innerHTML = '';
132
+
field.classList.remove('loading');
133
+
134
+
const appNames = Object.keys(apps).sort();
135
+
// Responsive radius: use viewport-relative sizing with min/max bounds
136
+
const vmin = Math.min(window.innerWidth, window.innerHeight);
137
+
const radius = Math.max(vmin * 0.35, 150); // 35% of smallest dimension, min 150px
138
+
const centerX = window.innerWidth / 2;
139
+
const centerY = window.innerHeight / 2;
140
+
141
+
appNames.forEach((namespace, i) => {
142
+
const angle = (i / appNames.length) * 2 * Math.PI - Math.PI / 2; // Start from top
143
+
const x = centerX + radius * Math.cos(angle) - 30;
144
+
const y = centerY + radius * Math.sin(angle) - 30;
145
+
146
+
const div = document.createElement('div');
147
+
div.className = 'app-view';
148
+
div.style.left = `${x}px`;
149
+
div.style.top = `${y}px`;
150
+
151
+
const firstLetter = namespace.split('.')[1]?.[0]?.toUpperCase() || namespace[0].toUpperCase();
152
+
153
+
// Reverse namespace for display (app.bsky -> bsky.app)
154
+
const displayName = namespace.split('.').reverse().join('.');
155
+
const url = `https://${displayName}`;
156
+
157
+
div.innerHTML = `
158
+
<div class="app-circle" data-namespace="${namespace}">${firstLetter}</div>
159
+
<a href="${url}" target="_blank" rel="noopener noreferrer" class="app-name" data-url="${url}">${displayName}</a>
160
+
`;
161
+
162
+
// Try to fetch and display avatar
163
+
fetchAppAvatar(namespace).then(avatarUrl => {
164
+
if (avatarUrl) {
165
+
const circle = div.querySelector('.app-circle');
166
+
circle.innerHTML = `<img src="${avatarUrl}" class="app-logo" alt="${namespace}" />`;
167
+
}
168
+
});
169
+
170
+
// Validate URL
171
+
fetch(`/api/validate-url?url=${encodeURIComponent(url)}`)
172
+
.then(r => r.json())
173
+
.then(data => {
174
+
const link = div.querySelector('.app-name');
175
+
if (!data.valid) {
176
+
link.classList.add('invalid-link');
177
+
link.setAttribute('title', 'this domain is not reachable');
178
+
link.style.pointerEvents = 'none';
179
+
}
180
+
})
181
+
.catch(() => {
182
+
// Silently fail validation check
183
+
});
184
+
185
+
div.addEventListener('click', () => {
186
+
const detail = document.getElementById('detail');
187
+
const collections = apps[namespace];
188
+
189
+
let html = `
190
+
<button class="detail-close" id="detailClose">ร</button>
191
+
<h3>${namespace}</h3>
192
+
<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>
193
+
`;
194
+
195
+
if (collections && collections.length > 0) {
196
+
// Group collections by sub-namespace (third segment)
197
+
const grouped = {};
198
+
collections.forEach(lexicon => {
199
+
const parts = lexicon.split('.');
200
+
const subNamespace = parts.slice(2).join('.');
201
+
const firstPart = parts[2] || lexicon;
202
+
203
+
if (!grouped[firstPart]) grouped[firstPart] = [];
204
+
grouped[firstPart].push({ lexicon, subNamespace });
205
+
});
206
+
207
+
// Sort and display grouped items
208
+
Object.keys(grouped).sort().forEach(group => {
209
+
const items = grouped[group];
210
+
211
+
if (items.length === 1 && items[0].subNamespace === group) {
212
+
// Single item with no further nesting
213
+
html += `
214
+
<div class="tree-item" data-lexicon="${items[0].lexicon}">
215
+
<div class="tree-item-header">
216
+
<span>${group}</span>
217
+
<span class="tree-item-count">loading...</span>
218
+
</div>
219
+
</div>
220
+
`;
221
+
} else {
222
+
// Group header
223
+
html += `<div style="margin-bottom: 0.75rem;">`;
224
+
html += `<div style="font-size: 0.7rem; color: var(--text-light); margin-bottom: 0.4rem; font-weight: 500;">${group}</div>`;
225
+
226
+
// Items in group
227
+
items.sort((a, b) => a.subNamespace.localeCompare(b.subNamespace)).forEach(item => {
228
+
const displayName = item.subNamespace.split('.').slice(1).join('.') || item.subNamespace;
229
+
html += `
230
+
<div class="tree-item" data-lexicon="${item.lexicon}" style="margin-left: 0.75rem;">
231
+
<div class="tree-item-header">
232
+
<span>${displayName}</span>
233
+
<span class="tree-item-count">loading...</span>
234
+
</div>
235
+
</div>
236
+
`;
237
+
});
238
+
html += `</div>`;
239
+
}
240
+
});
241
+
} else {
242
+
html += `<div class="tree-item">no collections found</div>`;
243
+
}
244
+
245
+
detail.innerHTML = html;
246
+
detail.classList.add('visible');
247
+
248
+
// Add close button handler
249
+
document.getElementById('detailClose').addEventListener('click', (e) => {
250
+
e.stopPropagation();
251
+
detail.classList.remove('visible');
252
+
});
253
+
254
+
// Fetch record counts for each collection
255
+
if (collections && collections.length > 0) {
256
+
collections.forEach(lexicon => {
257
+
fetch(`${globalPds}/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=${lexicon}&limit=1`)
258
+
.then(r => r.json())
259
+
.then(data => {
260
+
const item = detail.querySelector(`[data-lexicon="${lexicon}"]`);
261
+
if (item) {
262
+
const countSpan = item.querySelector('.tree-item-count');
263
+
// The cursor field indicates there are more records
264
+
countSpan.textContent = data.records?.length > 0 ? 'has records' : 'empty';
265
+
}
266
+
})
267
+
.catch(e => {
268
+
console.error('Error fetching count for', lexicon, e);
269
+
const item = detail.querySelector(`[data-lexicon="${lexicon}"]`);
270
+
if (item) {
271
+
const countSpan = item.querySelector('.tree-item-count');
272
+
countSpan.textContent = 'error';
273
+
}
274
+
});
275
+
});
276
+
}
277
+
278
+
// Add click handlers to tree items to fetch actual records
279
+
detail.querySelectorAll('.tree-item[data-lexicon]').forEach(item => {
280
+
item.addEventListener('click', (e) => {
281
+
e.stopPropagation();
282
+
const lexicon = item.dataset.lexicon;
283
+
const existingContent = item.querySelector('.collection-content');
284
+
285
+
if (existingContent) {
286
+
existingContent.remove();
287
+
return;
288
+
}
289
+
290
+
// Create container for tabs and content
291
+
const contentDiv = document.createElement('div');
292
+
contentDiv.className = 'collection-content';
293
+
294
+
// Will add tabs after we know record count
295
+
contentDiv.innerHTML = `
296
+
<div class="collection-view-content">
297
+
<div class="collection-view records-view active">
298
+
<div class="loading">loading records...</div>
299
+
</div>
300
+
<div class="collection-view structure-view">
301
+
<div class="loading">loading structure...</div>
302
+
</div>
303
+
</div>
304
+
`;
305
+
item.appendChild(contentDiv);
306
+
307
+
const recordsView = contentDiv.querySelector('.records-view');
308
+
const structureView = contentDiv.querySelector('.structure-view');
309
+
310
+
// Load records first to determine if we should show structure tab
311
+
fetch(`${globalPds}/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=${lexicon}&limit=10`)
312
+
.then(r => r.json())
313
+
.then(data => {
314
+
// Add tabs if there are enough records for structure view
315
+
const hasEnoughRecords = data.records && data.records.length >= 5;
316
+
if (hasEnoughRecords) {
317
+
const tabsHtml = `
318
+
<div class="collection-tabs">
319
+
<button class="collection-tab active" data-tab="records">records</button>
320
+
<button class="collection-tab" data-tab="structure">mst</button>
321
+
</div>
322
+
`;
323
+
contentDiv.insertAdjacentHTML('afterbegin', tabsHtml);
324
+
325
+
// Tab switching logic
326
+
contentDiv.querySelectorAll('.collection-tab').forEach(tab => {
327
+
tab.addEventListener('click', (e) => {
328
+
e.stopPropagation();
329
+
const tabName = tab.dataset.tab;
330
+
331
+
// Update active tab
332
+
contentDiv.querySelectorAll('.collection-tab').forEach(t => t.classList.remove('active'));
333
+
tab.classList.add('active');
334
+
335
+
// Update active view
336
+
contentDiv.querySelectorAll('.collection-view').forEach(v => v.classList.remove('active'));
337
+
if (tabName === 'records') {
338
+
recordsView.classList.add('active');
339
+
} else if (tabName === 'structure') {
340
+
structureView.classList.add('active');
341
+
// Load structure if not already loaded
342
+
if (structureView.querySelector('.loading')) {
343
+
loadMSTStructure(lexicon, structureView);
344
+
}
345
+
}
346
+
});
347
+
});
348
+
}
349
+
350
+
if (data.records && data.records.length > 0) {
351
+
let recordsHtml = '';
352
+
data.records.forEach((record, idx) => {
353
+
const json = JSON.stringify(record.value, null, 2);
354
+
const recordId = `record-${Date.now()}-${idx}`;
355
+
recordsHtml += `
356
+
<div class="record">
357
+
<div class="record-header">
358
+
<span class="record-label">record</span>
359
+
<button class="copy-btn" data-content="${encodeURIComponent(json)}" data-record-id="${recordId}">copy</button>
360
+
</div>
361
+
<div class="record-content">
362
+
<pre>${json}</pre>
363
+
</div>
364
+
</div>
365
+
`;
366
+
});
367
+
368
+
if (data.cursor && data.records.length === 5) {
369
+
recordsHtml += `<button class="load-more" data-cursor="${data.cursor}" data-lexicon="${lexicon}">load more</button>`;
370
+
}
371
+
372
+
recordsView.innerHTML = recordsHtml;
373
+
374
+
// Use event delegation for copy and load more buttons
375
+
recordsView.addEventListener('click', (e) => {
376
+
// Handle copy button
377
+
if (e.target.classList.contains('copy-btn')) {
378
+
e.stopPropagation();
379
+
const copyBtn = e.target;
380
+
const content = decodeURIComponent(copyBtn.dataset.content);
381
+
382
+
navigator.clipboard.writeText(content).then(() => {
383
+
const originalText = copyBtn.textContent;
384
+
copyBtn.textContent = 'copied!';
385
+
copyBtn.classList.add('copied');
386
+
setTimeout(() => {
387
+
copyBtn.textContent = originalText;
388
+
copyBtn.classList.remove('copied');
389
+
}, 1500);
390
+
}).catch(err => {
391
+
console.error('Failed to copy:', err);
392
+
copyBtn.textContent = 'error';
393
+
setTimeout(() => {
394
+
copyBtn.textContent = 'copy';
395
+
}, 1500);
396
+
});
397
+
}
398
+
399
+
// Handle load more button
400
+
if (e.target.classList.contains('load-more')) {
401
+
e.stopPropagation();
402
+
const loadMoreBtn = e.target;
403
+
const cursor = loadMoreBtn.dataset.cursor;
404
+
const lexicon = loadMoreBtn.dataset.lexicon;
405
+
406
+
loadMoreBtn.textContent = 'loading...';
407
+
408
+
fetch(`${globalPds}/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=${lexicon}&limit=5&cursor=${cursor}`)
409
+
.then(r => r.json())
410
+
.then(moreData => {
411
+
let moreHtml = '';
412
+
moreData.records.forEach((record, idx) => {
413
+
const json = JSON.stringify(record.value, null, 2);
414
+
const recordId = `record-more-${Date.now()}-${idx}`;
415
+
moreHtml += `
416
+
<div class="record">
417
+
<div class="record-header">
418
+
<span class="record-label">record</span>
419
+
<button class="copy-btn" data-content="${encodeURIComponent(json)}" data-record-id="${recordId}">copy</button>
420
+
</div>
421
+
<div class="record-content">
422
+
<pre>${json}</pre>
423
+
</div>
424
+
</div>
425
+
`;
426
+
});
427
+
428
+
loadMoreBtn.remove();
429
+
recordsView.insertAdjacentHTML('beforeend', moreHtml);
430
+
431
+
if (moreData.cursor && moreData.records.length === 5) {
432
+
recordsView.insertAdjacentHTML('beforeend',
433
+
`<button class="load-more" data-cursor="${moreData.cursor}" data-lexicon="${lexicon}">load more</button>`
434
+
);
435
+
}
436
+
});
437
+
}
438
+
});
439
+
} else {
440
+
recordsView.innerHTML = '<div class="record">no records found</div>';
441
+
}
442
+
})
443
+
.catch(e => {
444
+
console.error('Error fetching records:', e);
445
+
recordsView.innerHTML = '<div class="record">error loading records</div>';
446
+
});
447
+
});
448
+
});
449
+
});
450
+
451
+
field.appendChild(div);
452
+
});
453
+
454
+
// Close detail panel when clicking canvas
455
+
const canvas = document.querySelector('.canvas');
456
+
canvas.addEventListener('click', (e) => {
457
+
if (e.target === canvas) {
458
+
document.getElementById('detail').classList.remove('visible');
459
+
}
460
+
});
461
+
})
462
+
.catch(e => {
463
+
document.getElementById('field').innerHTML = 'error loading records';
464
+
console.error(e);
465
+
});
466
+
467
+
// MST Visualization Functions
468
+
async function loadMSTStructure(lexicon, containerView) {
469
+
try {
470
+
// Call server endpoint to build MST
471
+
const response = await fetch(`/api/mst?pds=${encodeURIComponent(globalPds)}&did=${encodeURIComponent(did)}&collection=${encodeURIComponent(lexicon)}`);
472
+
const data = await response.json();
473
+
474
+
if (data.error) {
475
+
containerView.innerHTML = `<div class="mst-info"><p>${data.error}</p></div>`;
476
+
return;
477
+
}
478
+
479
+
const { root, recordCount } = data;
480
+
481
+
// Render structure
482
+
containerView.innerHTML = `
483
+
<div class="mst-info">
484
+
<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>
485
+
</div>
486
+
<canvas class="mst-canvas" id="mstCanvas-${Date.now()}"></canvas>
487
+
`;
488
+
489
+
// Render tree on canvas
490
+
setTimeout(() => {
491
+
const canvas = containerView.querySelector('.mst-canvas');
492
+
if (canvas) {
493
+
renderMSTTree(canvas, root);
494
+
}
495
+
}, 50);
496
+
497
+
} catch (e) {
498
+
console.error('Error loading MST structure:', e);
499
+
containerView.innerHTML = '<div class="mst-info"><p>error loading structure</p></div>';
500
+
}
501
+
}
502
+
503
+
function renderMSTTree(canvas, tree) {
504
+
const ctx = canvas.getContext('2d');
505
+
const width = canvas.width = canvas.offsetWidth;
506
+
const height = canvas.height = canvas.offsetHeight;
507
+
508
+
// Calculate tree layout
509
+
const layout = layoutTree(tree, width, height);
510
+
511
+
// Get CSS colors
512
+
const borderColor = getComputedStyle(document.documentElement).getPropertyValue('--border').trim();
513
+
const textColor = getComputedStyle(document.documentElement).getPropertyValue('--text').trim();
514
+
const textLightColor = getComputedStyle(document.documentElement).getPropertyValue('--text-light').trim();
515
+
const surfaceColor = getComputedStyle(document.documentElement).getPropertyValue('--surface').trim();
516
+
const surfaceHoverColor = getComputedStyle(document.documentElement).getPropertyValue('--surface-hover').trim();
517
+
const bgColor = getComputedStyle(document.documentElement).getPropertyValue('--bg').trim();
518
+
519
+
let hoveredNode = null;
520
+
521
+
function draw() {
522
+
// Clear canvas
523
+
ctx.clearRect(0, 0, width, height);
524
+
525
+
// Draw connections first
526
+
layout.forEach(node => {
527
+
if (node.children) {
528
+
node.children.forEach(child => {
529
+
ctx.beginPath();
530
+
ctx.moveTo(node.x, node.y);
531
+
ctx.lineTo(child.x, child.y);
532
+
ctx.strokeStyle = borderColor;
533
+
ctx.lineWidth = 1;
534
+
ctx.stroke();
535
+
});
536
+
}
537
+
});
538
+
539
+
// Draw nodes
540
+
layout.forEach(node => {
541
+
const isRoot = node.depth === -1;
542
+
const isLeaf = !node.children || node.children.length === 0;
543
+
const isHovered = hoveredNode === node;
544
+
545
+
// Node circle
546
+
ctx.beginPath();
547
+
ctx.arc(node.x, node.y, isRoot ? 12 : 8, 0, Math.PI * 2);
548
+
549
+
ctx.fillStyle = isRoot ? textColor : isLeaf ? surfaceHoverColor : surfaceColor;
550
+
ctx.fill();
551
+
552
+
ctx.strokeStyle = isHovered ? textColor : borderColor;
553
+
ctx.lineWidth = isRoot ? 2 : isHovered ? 2 : 1;
554
+
ctx.stroke();
555
+
});
556
+
557
+
// Draw label for hovered node
558
+
if (hoveredNode && hoveredNode.key && hoveredNode.key !== 'root') {
559
+
const padding = 6;
560
+
const fontSize = 10;
561
+
ctx.font = `${fontSize}px monospace`;
562
+
const textWidth = ctx.measureText(hoveredNode.key).width;
563
+
564
+
// Position tooltip above node
565
+
const tooltipX = hoveredNode.x;
566
+
const tooltipY = hoveredNode.y - 20;
567
+
const boxWidth = textWidth + padding * 2;
568
+
const boxHeight = fontSize + padding * 2;
569
+
570
+
// Draw tooltip background
571
+
ctx.fillStyle = bgColor;
572
+
ctx.fillRect(tooltipX - boxWidth / 2, tooltipY - boxHeight / 2, boxWidth, boxHeight);
573
+
574
+
// Draw tooltip border
575
+
ctx.strokeStyle = borderColor;
576
+
ctx.lineWidth = 1;
577
+
ctx.strokeRect(tooltipX - boxWidth / 2, tooltipY - boxHeight / 2, boxWidth, boxHeight);
578
+
579
+
// Draw text
580
+
ctx.fillStyle = textColor;
581
+
ctx.textAlign = 'center';
582
+
ctx.textBaseline = 'middle';
583
+
ctx.fillText(hoveredNode.key, tooltipX, tooltipY);
584
+
}
585
+
}
586
+
587
+
// Mouse move handler
588
+
canvas.addEventListener('mousemove', (e) => {
589
+
const rect = canvas.getBoundingClientRect();
590
+
const mouseX = e.clientX - rect.left;
591
+
const mouseY = e.clientY - rect.top;
592
+
593
+
let foundNode = null;
594
+
for (const node of layout) {
595
+
const isRoot = node.depth === -1;
596
+
const radius = isRoot ? 12 : 8;
597
+
const dist = Math.sqrt((mouseX - node.x) ** 2 + (mouseY - node.y) ** 2);
598
+
if (dist <= radius) {
599
+
foundNode = node;
600
+
break;
601
+
}
602
+
}
603
+
604
+
if (foundNode !== hoveredNode) {
605
+
hoveredNode = foundNode;
606
+
canvas.style.cursor = hoveredNode ? 'pointer' : 'default';
607
+
draw();
608
+
}
609
+
});
610
+
611
+
// Mouse leave handler
612
+
canvas.addEventListener('mouseleave', () => {
613
+
if (hoveredNode) {
614
+
hoveredNode = null;
615
+
canvas.style.cursor = 'default';
616
+
draw();
617
+
}
618
+
});
619
+
620
+
// Click handler
621
+
canvas.addEventListener('click', (e) => {
622
+
if (hoveredNode && hoveredNode.key && hoveredNode.key !== 'root') {
623
+
showNodeModal(hoveredNode);
624
+
}
625
+
});
626
+
627
+
// Initial draw
628
+
draw();
629
+
}
630
+
631
+
function showNodeModal(node) {
632
+
// Create modal
633
+
const modal = document.createElement('div');
634
+
modal.className = 'mst-node-modal';
635
+
modal.innerHTML = `
636
+
<div class="mst-node-modal-content">
637
+
<button class="mst-node-close">ร</button>
638
+
<h3>record in MST</h3>
639
+
<div class="mst-node-info">
640
+
<div class="mst-node-field">
641
+
<span class="mst-node-label">TID:</span>
642
+
<span class="mst-node-value">${node.key}</span>
643
+
</div>
644
+
<div class="mst-node-field">
645
+
<span class="mst-node-label">CID:</span>
646
+
<span class="mst-node-value">${node.cid}</span>
647
+
</div>
648
+
${node.uri ? `
649
+
<div class="mst-node-field">
650
+
<span class="mst-node-label">URI:</span>
651
+
<span class="mst-node-value">${node.uri}</span>
652
+
</div>
653
+
` : ''}
654
+
</div>
655
+
<div class="mst-node-explanation">
656
+
<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>
657
+
</div>
658
+
${node.value ? `
659
+
<div class="mst-node-data">
660
+
<div class="mst-node-data-header">record data</div>
661
+
<pre>${JSON.stringify(node.value, null, 2)}</pre>
662
+
</div>
663
+
` : ''}
664
+
</div>
665
+
`;
666
+
667
+
// Add to DOM
668
+
document.body.appendChild(modal);
669
+
670
+
// Close handlers
671
+
modal.querySelector('.mst-node-close').addEventListener('click', () => {
672
+
modal.remove();
673
+
});
674
+
675
+
modal.addEventListener('click', (e) => {
676
+
if (e.target === modal) {
677
+
modal.remove();
678
+
}
679
+
});
680
+
}
681
+
682
+
function layoutTree(tree, width, height) {
683
+
const nodes = [];
684
+
const padding = 40;
685
+
const availableWidth = width - padding * 2;
686
+
const availableHeight = height - padding * 2;
687
+
688
+
// Calculate max depth and total nodes at each depth
689
+
const depthCounts = {};
690
+
function countDepths(node, depth) {
691
+
if (!depthCounts[depth]) depthCounts[depth] = 0;
692
+
depthCounts[depth]++;
693
+
if (node.children) {
694
+
node.children.forEach(child => countDepths(child, depth + 1));
695
+
}
696
+
}
697
+
countDepths(tree, 0);
698
+
699
+
const maxDepth = Math.max(...Object.keys(depthCounts).map(Number));
700
+
const verticalSpacing = availableHeight / (maxDepth + 1);
701
+
702
+
// Track positions at each depth to avoid overlap
703
+
const positionsByDepth = {};
704
+
705
+
function traverse(node, depth, minX, maxX) {
706
+
if (!positionsByDepth[depth]) positionsByDepth[depth] = [];
707
+
708
+
// Calculate position based on available space
709
+
const x = (minX + maxX) / 2;
710
+
const y = padding + verticalSpacing * depth;
711
+
712
+
const layoutNode = { ...node, x, y };
713
+
nodes.push(layoutNode);
714
+
positionsByDepth[depth].push(x);
715
+
716
+
if (node.children && node.children.length > 0) {
717
+
layoutNode.children = [];
718
+
const childWidth = (maxX - minX) / node.children.length;
719
+
720
+
node.children.forEach((child, idx) => {
721
+
const childMinX = minX + childWidth * idx;
722
+
const childMaxX = minX + childWidth * (idx + 1);
723
+
const childLayout = traverse(child, depth + 1, childMinX, childMaxX);
724
+
layoutNode.children.push(childLayout);
725
+
});
726
+
}
727
+
728
+
return layoutNode;
729
+
}
730
+
731
+
traverse(tree, 0, padding, width - padding);
732
+
return nodes;
733
+
}
734
+
735
+
// ============================================================================
736
+
// FIREHOSE VISUALIZATION
737
+
// ============================================================================
738
+
739
+
// Particle class for animating firehose events
740
+
class FirehoseParticle {
741
+
constructor(startX, startY, endX, endY, color, metadata) {
742
+
this.x = startX;
743
+
this.y = startY;
744
+
this.startX = startX;
745
+
this.startY = startY;
746
+
this.endX = endX;
747
+
this.endY = endY;
748
+
this.color = color;
749
+
this.metadata = metadata; // {action, collection, namespace}
750
+
this.progress = 0;
751
+
this.speed = 0.012; // Slower for visibility
752
+
this.size = 5;
753
+
this.glowSize = 10;
754
+
}
755
+
756
+
update() {
757
+
if (this.progress < 1) {
758
+
this.progress += this.speed;
759
+
// Cubic ease-in-out
760
+
const eased = this.progress < 0.5
761
+
? 4 * this.progress * this.progress * this.progress
762
+
: 1 - Math.pow(-2 * this.progress + 2, 3) / 2;
763
+
764
+
this.x = this.startX + (this.endX - this.startX) * eased;
765
+
this.y = this.startY + (this.endY - this.startY) * eased;
766
+
}
767
+
return this.progress < 1;
768
+
}
769
+
770
+
draw(ctx) {
771
+
// Outer glow
772
+
ctx.beginPath();
773
+
ctx.arc(this.x, this.y, this.glowSize, 0, Math.PI * 2);
774
+
const gradient = ctx.createRadialGradient(
775
+
this.x, this.y, 0,
776
+
this.x, this.y, this.glowSize
777
+
);
778
+
gradient.addColorStop(0, this.color + '80');
779
+
gradient.addColorStop(1, this.color + '00');
780
+
ctx.fillStyle = gradient;
781
+
ctx.fill();
782
+
783
+
// Inner particle
784
+
ctx.beginPath();
785
+
ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2);
786
+
ctx.fillStyle = this.color;
787
+
ctx.fill();
788
+
}
789
+
}
790
+
791
+
// Firehose state
792
+
let firehoseParticles = [];
793
+
let firehoseCanvas = null;
794
+
let firehoseCtx = null;
795
+
let firehoseAnimationId = null;
796
+
let firehoseEventSource = null;
797
+
let isWatchingLive = false;
798
+
799
+
function initFirehoseCanvas() {
800
+
// Create canvas overlay
801
+
firehoseCanvas = document.createElement('canvas');
802
+
firehoseCanvas.id = 'firehoseCanvas';
803
+
firehoseCanvas.style.position = 'fixed';
804
+
firehoseCanvas.style.top = '0';
805
+
firehoseCanvas.style.left = '0';
806
+
firehoseCanvas.style.width = '100%';
807
+
firehoseCanvas.style.height = '100%';
808
+
firehoseCanvas.style.pointerEvents = 'none';
809
+
firehoseCanvas.style.zIndex = '50';
810
+
firehoseCanvas.width = window.innerWidth;
811
+
firehoseCanvas.height = window.innerHeight;
812
+
813
+
document.body.appendChild(firehoseCanvas);
814
+
firehoseCtx = firehoseCanvas.getContext('2d');
815
+
816
+
// Handle window resize
817
+
window.addEventListener('resize', () => {
818
+
firehoseCanvas.width = window.innerWidth;
819
+
firehoseCanvas.height = window.innerHeight;
820
+
});
821
+
}
822
+
823
+
function animateFirehoseParticles() {
824
+
if (!firehoseCtx) return;
825
+
826
+
firehoseCtx.clearRect(0, 0, firehoseCanvas.width, firehoseCanvas.height);
827
+
828
+
// Update and draw all particles
829
+
firehoseParticles = firehoseParticles.filter(particle => {
830
+
const alive = particle.update();
831
+
if (alive) {
832
+
particle.draw(firehoseCtx);
833
+
} else {
834
+
// Particle reached destination - pulse the identity/PDS
835
+
pulseIdentity();
836
+
}
837
+
return alive;
838
+
});
839
+
840
+
if (isWatchingLive) {
841
+
firehoseAnimationId = requestAnimationFrame(animateFirehoseParticles);
842
+
}
843
+
}
844
+
845
+
function pulseIdentity() {
846
+
const identity = document.querySelector('.identity');
847
+
if (identity) {
848
+
identity.style.transition = 'all 0.3s ease';
849
+
identity.style.transform = 'scale(1.15)';
850
+
identity.style.boxShadow = '0 0 25px rgba(255, 255, 255, 0.6)';
851
+
852
+
setTimeout(() => {
853
+
identity.style.transform = '';
854
+
identity.style.boxShadow = '';
855
+
}, 300);
856
+
}
857
+
}
858
+
859
+
async function fetchRecordDetails(pds, did, collection, rkey) {
860
+
try {
861
+
const response = await fetch(
862
+
`/api/record?pds=${encodeURIComponent(pds)}&did=${encodeURIComponent(did)}&collection=${encodeURIComponent(collection)}&rkey=${encodeURIComponent(rkey)}`
863
+
);
864
+
const data = await response.json();
865
+
if (data.error) return null;
866
+
return data.value;
867
+
} catch (e) {
868
+
console.error('Error fetching record:', e);
869
+
return null;
870
+
}
871
+
}
872
+
873
+
function formatToastMessage(action, collection, record) {
874
+
const actionText = {
875
+
'create': 'created',
876
+
'update': 'updated',
877
+
'delete': 'deleted'
878
+
}[action] || action;
879
+
880
+
// If we don't have record details, fall back to basic message
881
+
if (!record) {
882
+
return {
883
+
action: `${actionText} record`,
884
+
details: collection
885
+
};
886
+
}
887
+
888
+
// Format based on collection type
889
+
if (collection === 'app.bsky.feed.post') {
890
+
const text = record.text || '';
891
+
const preview = text.length > 50 ? text.substring(0, 50) + '...' : text;
892
+
return {
893
+
action: `${actionText} post`,
894
+
details: preview || 'no text'
895
+
};
896
+
} else if (collection === 'app.bsky.feed.like') {
897
+
return {
898
+
action: `${actionText} like`,
899
+
details: ''
900
+
};
901
+
} else if (collection === 'app.bsky.feed.repost') {
902
+
return {
903
+
action: `${actionText} repost`,
904
+
details: ''
905
+
};
906
+
} else if (collection === 'app.bsky.graph.follow') {
907
+
return {
908
+
action: `${actionText} follow`,
909
+
details: ''
910
+
};
911
+
} else if (collection === 'app.bsky.actor.profile') {
912
+
const displayName = record.displayName || '';
913
+
return {
914
+
action: `${actionText} profile`,
915
+
details: displayName || 'updated profile'
916
+
};
917
+
}
918
+
919
+
// Default for unknown collections
920
+
return {
921
+
action: `${actionText} record`,
922
+
details: collection
923
+
};
924
+
}
925
+
926
+
async function showFirehoseToast(event) {
927
+
const toast = document.getElementById('firehoseToast');
928
+
const actionEl = toast.querySelector('.firehose-toast-action');
929
+
const collectionEl = toast.querySelector('.firehose-toast-collection');
930
+
const linkEl = document.getElementById('firehoseToastLink');
931
+
932
+
// Build PDS link for the record
933
+
if (globalPds && event.did && event.collection && event.rkey) {
934
+
const recordUrl = `${globalPds}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(event.did)}&collection=${encodeURIComponent(event.collection)}&rkey=${encodeURIComponent(event.rkey)}`;
935
+
linkEl.href = recordUrl;
936
+
}
937
+
938
+
// Fetch record details if available (skip for deletes)
939
+
let record = null;
940
+
if (event.action !== 'delete' && event.rkey && globalPds) {
941
+
record = await fetchRecordDetails(globalPds, event.did, event.collection, event.rkey);
942
+
}
943
+
944
+
const formatted = formatToastMessage(event.action, event.collection, record);
945
+
946
+
actionEl.textContent = formatted.action;
947
+
collectionEl.textContent = formatted.details;
948
+
949
+
toast.classList.add('visible');
950
+
setTimeout(() => {
951
+
toast.classList.remove('visible');
952
+
}, 4000); // Slightly longer to read details
953
+
}
954
+
955
+
function getParticleColor(action) {
956
+
const colors = {
957
+
'create': '#4ade80', // green
958
+
'update': '#60a5fa', // blue
959
+
'delete': '#f87171' // red
960
+
};
961
+
return colors[action] || '#a0a0a0';
962
+
}
963
+
964
+
function createFirehoseParticle(event) {
965
+
// Get source app circle position (where the action happened)
966
+
const appCircle = document.querySelector(`[data-namespace="${event.namespace}"]`);
967
+
if (!appCircle) return;
968
+
969
+
const appRect = appCircle.getBoundingClientRect();
970
+
const startX = appRect.left + appRect.width / 2;
971
+
const startY = appRect.top + appRect.height / 2;
972
+
973
+
// Get target identity/PDS position (where data is written)
974
+
const identity = document.querySelector('.identity');
975
+
if (!identity) return;
976
+
977
+
const identityRect = identity.getBoundingClientRect();
978
+
const endX = identityRect.left + identityRect.width / 2;
979
+
const endY = identityRect.top + identityRect.height / 2;
980
+
981
+
// Create particle (flows from app TO PDS)
982
+
const particle = new FirehoseParticle(
983
+
startX, startY,
984
+
endX, endY,
985
+
getParticleColor(event.action),
986
+
{
987
+
action: event.action,
988
+
collection: event.collection,
989
+
namespace: event.namespace
990
+
}
991
+
);
992
+
993
+
firehoseParticles.push(particle);
994
+
}
995
+
996
+
function connectFirehose() {
997
+
console.log('[Firehose] connectFirehose called, did =', did, 'existing connection?', !!firehoseEventSource);
998
+
if (!did || firehoseEventSource) {
999
+
console.warn('[Firehose] Exiting early - did:', did, 'firehoseEventSource:', firehoseEventSource);
1000
+
return;
1001
+
}
1002
+
1003
+
const url = `/api/firehose/watch?did=${encodeURIComponent(did)}`;
1004
+
console.log('[Firehose] Connecting to:', url);
1005
+
1006
+
firehoseEventSource = new EventSource(url);
1007
+
1008
+
const watchBtn = document.getElementById('watchLiveBtn');
1009
+
const watchLabel = watchBtn.querySelector('.watch-label');
1010
+
1011
+
firehoseEventSource.onopen = () => {
1012
+
console.log('Firehose connected');
1013
+
watchLabel.textContent = 'watching...';
1014
+
watchBtn.classList.add('active');
1015
+
};
1016
+
1017
+
firehoseEventSource.onmessage = (e) => {
1018
+
try {
1019
+
const data = JSON.parse(e.data);
1020
+
1021
+
// Skip connection message
1022
+
if (data.type === 'connected') {
1023
+
console.log('Firehose connection established');
1024
+
return;
1025
+
}
1026
+
1027
+
console.log('Firehose event:', data);
1028
+
1029
+
// Create particle animation
1030
+
createFirehoseParticle(data);
1031
+
1032
+
// Show toast notification
1033
+
showFirehoseToast(data);
1034
+
} catch (error) {
1035
+
console.error('Error processing firehose message:', error);
1036
+
}
1037
+
};
1038
+
1039
+
firehoseEventSource.onerror = (error) => {
1040
+
console.error('Firehose error:', error);
1041
+
watchLabel.textContent = 'connection error';
1042
+
1043
+
// Attempt to reconnect after delay
1044
+
if (isWatchingLive) {
1045
+
setTimeout(() => {
1046
+
if (firehoseEventSource) {
1047
+
firehoseEventSource.close();
1048
+
firehoseEventSource = null;
1049
+
}
1050
+
if (isWatchingLive) {
1051
+
watchLabel.textContent = 'reconnecting...';
1052
+
connectFirehose();
1053
+
}
1054
+
}, 3000);
1055
+
}
1056
+
};
1057
+
}
1058
+
1059
+
function disconnectFirehose() {
1060
+
if (firehoseEventSource) {
1061
+
firehoseEventSource.close();
1062
+
firehoseEventSource = null;
1063
+
}
1064
+
1065
+
if (firehoseAnimationId) {
1066
+
cancelAnimationFrame(firehoseAnimationId);
1067
+
firehoseAnimationId = null;
1068
+
}
1069
+
1070
+
firehoseParticles = [];
1071
+
if (firehoseCtx) {
1072
+
firehoseCtx.clearRect(0, 0, firehoseCanvas.width, firehoseCanvas.height);
1073
+
}
1074
+
}
1075
+
1076
+
// Toggle watch live
1077
+
document.addEventListener('DOMContentLoaded', () => {
1078
+
console.log('[Firehose] DOMContentLoaded fired, setting up watch button');
1079
+
const watchBtn = document.getElementById('watchLiveBtn');
1080
+
if (!watchBtn) {
1081
+
console.error('[Firehose] Watch button not found!');
1082
+
return;
1083
+
}
1084
+
1085
+
console.log('[Firehose] Watch button found, attaching click handler');
1086
+
const watchLabel = watchBtn.querySelector('.watch-label');
1087
+
1088
+
watchBtn.addEventListener('click', () => {
1089
+
console.log('[Firehose] Watch button clicked! isWatchingLive was:', isWatchingLive);
1090
+
isWatchingLive = !isWatchingLive;
1091
+
console.log('[Firehose] isWatchingLive now:', isWatchingLive);
1092
+
1093
+
if (isWatchingLive) {
1094
+
// Start watching
1095
+
console.log('[Firehose] Starting watch mode');
1096
+
watchLabel.textContent = 'connecting...';
1097
+
initFirehoseCanvas();
1098
+
connectFirehose();
1099
+
animateFirehoseParticles();
1100
+
} else {
1101
+
// Stop watching
1102
+
console.log('[Firehose] Stopping watch mode');
1103
+
watchLabel.textContent = 'watch live';
1104
+
watchBtn.classList.remove('active');
1105
+
disconnectFirehose();
1106
+
1107
+
// Clean up canvas
1108
+
if (firehoseCanvas) {
1109
+
firehoseCanvas.remove();
1110
+
firehoseCanvas = null;
1111
+
firehoseCtx = null;
1112
+
}
1113
+
}
1114
+
});
1115
+
});
+157
static/login.js
+157
static/login.js
···
1
+
// Check for saved session
2
+
const savedDid = localStorage.getItem('atme_did');
3
+
if (savedDid) {
4
+
document.getElementById('loginForm').classList.add('hidden');
5
+
document.getElementById('restoring').classList.remove('hidden');
6
+
7
+
fetch('/api/restore-session', {
8
+
method: 'POST',
9
+
headers: { 'Content-Type': 'application/json' },
10
+
body: JSON.stringify({ did: savedDid })
11
+
}).then(r => {
12
+
if (r.ok) {
13
+
window.location.href = '/';
14
+
} else {
15
+
localStorage.removeItem('atme_did');
16
+
document.getElementById('loginForm').classList.remove('hidden');
17
+
document.getElementById('restoring').classList.add('hidden');
18
+
}
19
+
}).catch(() => {
20
+
localStorage.removeItem('atme_did');
21
+
document.getElementById('loginForm').classList.remove('hidden');
22
+
document.getElementById('restoring').classList.add('hidden');
23
+
});
24
+
}
25
+
26
+
// Fetch and cache atmosphere data
27
+
async function fetchAtmosphere() {
28
+
const CACHE_KEY = 'atme_atmosphere';
29
+
const CACHE_DURATION = 24 * 60 * 60 * 1000; // 24 hours
30
+
31
+
const cached = localStorage.getItem(CACHE_KEY);
32
+
if (cached) {
33
+
const { data, timestamp } = JSON.parse(cached);
34
+
if (Date.now() - timestamp < CACHE_DURATION) {
35
+
return data;
36
+
}
37
+
}
38
+
39
+
try {
40
+
const response = await fetch('https://ufos-api.microcosm.blue/collections?order=dids-estimate&limit=50');
41
+
const json = await response.json();
42
+
43
+
// Group by namespace (first two segments)
44
+
const namespaces = {};
45
+
json.collections.forEach(col => {
46
+
const parts = col.nsid.split('.');
47
+
if (parts.length >= 2) {
48
+
const ns = `${parts[0]}.${parts[1]}`;
49
+
if (!namespaces[ns]) {
50
+
namespaces[ns] = {
51
+
namespace: ns,
52
+
dids_total: 0,
53
+
records_total: 0,
54
+
collections: []
55
+
};
56
+
}
57
+
namespaces[ns].dids_total += col.dids_estimate;
58
+
namespaces[ns].records_total += col.creates;
59
+
namespaces[ns].collections.push(col.nsid);
60
+
}
61
+
});
62
+
63
+
const data = Object.values(namespaces).sort((a, b) => b.dids_total - a.dids_total).slice(0, 30);
64
+
65
+
localStorage.setItem(CACHE_KEY, JSON.stringify({
66
+
data,
67
+
timestamp: Date.now()
68
+
}));
69
+
70
+
return data;
71
+
} catch (e) {
72
+
console.error('Failed to fetch atmosphere data:', e);
73
+
return [];
74
+
}
75
+
}
76
+
77
+
// Try to fetch app avatar
78
+
async function fetchAppAvatar(namespace) {
79
+
const reversed = namespace.split('.').reverse().join('.');
80
+
const handles = [reversed, `${reversed}.bsky.social`];
81
+
82
+
for (const handle of handles) {
83
+
try {
84
+
const didRes = await fetch(`https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${handle}`);
85
+
if (!didRes.ok) continue;
86
+
87
+
const { did } = await didRes.json();
88
+
const profileRes = await fetch(`https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${did}`);
89
+
if (!profileRes.ok) continue;
90
+
91
+
const profile = await profileRes.json();
92
+
if (profile.avatar) return profile.avatar;
93
+
} catch (e) {
94
+
// Silently continue to next handle
95
+
continue;
96
+
}
97
+
}
98
+
return null;
99
+
}
100
+
101
+
// Render atmosphere
102
+
async function renderAtmosphere() {
103
+
const data = await fetchAtmosphere();
104
+
if (!data.length) return;
105
+
106
+
const atmosphere = document.getElementById('atmosphere');
107
+
const maxSize = Math.max(...data.map(d => d.dids_total));
108
+
109
+
data.forEach((app, i) => {
110
+
const orb = document.createElement('div');
111
+
orb.className = 'app-orb';
112
+
113
+
// Size based on user count (20-80px)
114
+
const size = 20 + (app.dids_total / maxSize) * 60;
115
+
116
+
// Position in 3D space
117
+
const angle = (i / data.length) * Math.PI * 2;
118
+
const radius = 250 + (i % 3) * 100;
119
+
const y = (i % 5) * 80 - 160;
120
+
const x = Math.cos(angle) * radius;
121
+
const z = Math.sin(angle) * radius;
122
+
123
+
orb.style.width = `${size}px`;
124
+
orb.style.height = `${size}px`;
125
+
orb.style.left = `calc(50% + ${x}px)`;
126
+
orb.style.top = `calc(50% + ${y}px)`;
127
+
orb.style.transform = `translateZ(${z}px) translate(-50%, -50%)`;
128
+
orb.style.background = `radial-gradient(circle, rgba(255,255,255,0.1), rgba(255,255,255,0.02))`;
129
+
orb.style.border = '1px solid rgba(255,255,255,0.1)';
130
+
orb.style.boxShadow = '0 0 20px rgba(255,255,255,0.1)';
131
+
132
+
// Fallback letter
133
+
const letter = app.namespace.split('.')[1]?.[0]?.toUpperCase() || app.namespace[0].toUpperCase();
134
+
orb.innerHTML = `<div class="fallback">${letter}</div>`;
135
+
136
+
// Tooltip
137
+
const tooltip = document.createElement('div');
138
+
tooltip.className = 'app-tooltip';
139
+
const users = app.dids_total >= 1000000
140
+
? `${(app.dids_total / 1000000).toFixed(1)}M users`
141
+
: `${(app.dids_total / 1000).toFixed(0)}K users`;
142
+
tooltip.textContent = `${app.namespace} โข ${users}`;
143
+
orb.appendChild(tooltip);
144
+
145
+
atmosphere.appendChild(orb);
146
+
147
+
// Fetch and apply avatar
148
+
fetchAppAvatar(app.namespace).then(avatarUrl => {
149
+
if (avatarUrl) {
150
+
orb.innerHTML = `<img src="${avatarUrl}" alt="${app.namespace}" />`;
151
+
orb.appendChild(tooltip);
152
+
}
153
+
});
154
+
});
155
+
}
156
+
157
+
renderAtmosphere();
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>
+191
static/onboarding.js
+191
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: 'third-party 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
+
content.innerHTML = `
87
+
<h3>${step.title}</h3>
88
+
<p>${step.description}</p>
89
+
<div class="onboarding-actions">
90
+
<button id="skipOnboarding" class="onboarding-skip">skip</button>
91
+
<button id="nextOnboarding" class="onboarding-next">
92
+
${stepIndex === steps.length - 1 ? 'got it' : 'next'}
93
+
</button>
94
+
</div>
95
+
<div class="onboarding-progress">
96
+
${steps.map((_, i) => `<span class="${i === stepIndex ? 'active' : i < stepIndex ? 'done' : ''}"></span>`).join('')}
97
+
</div>
98
+
`;
99
+
100
+
// Position content relative to spotlight
101
+
let contentTop, contentLeft;
102
+
const contentMaxWidth = Math.min(400, window.innerWidth * 0.9); // responsive max-width
103
+
const contentHeight = 250; // approximate height
104
+
const margin = Math.max(20, window.innerWidth * 0.05); // responsive margin
105
+
106
+
if (step.position === 'bottom') {
107
+
contentTop = rect.bottom + padding + margin;
108
+
contentLeft = rect.left + rect.width / 2;
109
+
110
+
// Check if it would go off bottom
111
+
if (contentTop + contentHeight > window.innerHeight) {
112
+
contentTop = rect.top - padding - contentHeight - margin;
113
+
}
114
+
} else if (step.position === 'center') {
115
+
contentTop = window.innerHeight / 2 - contentHeight / 2;
116
+
contentLeft = window.innerWidth / 2;
117
+
} else {
118
+
contentTop = rect.top - padding - contentHeight - margin;
119
+
contentLeft = rect.left + rect.width / 2;
120
+
121
+
// Check if it would go off top
122
+
if (contentTop < margin) {
123
+
contentTop = rect.bottom + padding + margin;
124
+
}
125
+
}
126
+
127
+
// Ensure content stays on screen horizontally
128
+
const halfWidth = contentMaxWidth / 2;
129
+
if (contentLeft - halfWidth < margin) {
130
+
contentLeft = halfWidth + margin;
131
+
} else if (contentLeft + halfWidth > window.innerWidth - margin) {
132
+
contentLeft = window.innerWidth - halfWidth - margin;
133
+
}
134
+
135
+
// Ensure content stays on screen vertically
136
+
if (contentTop < margin) {
137
+
contentTop = margin;
138
+
} else if (contentTop + contentHeight > window.innerHeight - margin) {
139
+
contentTop = window.innerHeight - contentHeight - margin;
140
+
}
141
+
142
+
content.style.top = `${contentTop}px`;
143
+
content.style.left = `${contentLeft}px`;
144
+
content.style.transform = 'translate(-50%, 0)';
145
+
content.classList.add('active');
146
+
147
+
// Add event listeners
148
+
document.getElementById('skipOnboarding').addEventListener('click', hideOnboarding);
149
+
document.getElementById('nextOnboarding').addEventListener('click', () => {
150
+
showStep(stepIndex + 1);
151
+
});
152
+
}
153
+
154
+
// Initialize onboarding
155
+
function initOnboarding() {
156
+
const seen = localStorage.getItem(ONBOARDING_KEY);
157
+
158
+
if (!seen) {
159
+
// Wait for app circles to render
160
+
setTimeout(() => {
161
+
showOnboarding();
162
+
}, 1000);
163
+
}
164
+
}
165
+
166
+
// ESC key handler
167
+
document.addEventListener('keydown', (e) => {
168
+
if (e.key === 'Escape') {
169
+
const overlay = document.getElementById('onboardingOverlay');
170
+
if (overlay && overlay.style.display === 'block') {
171
+
hideOnboarding();
172
+
}
173
+
}
174
+
});
175
+
176
+
// Help button handler to restart onboarding
177
+
window.restartOnboarding = function() {
178
+
localStorage.removeItem(ONBOARDING_KEY);
179
+
document.getElementById('infoModal').classList.remove('visible');
180
+
document.getElementById('overlay').classList.remove('visible');
181
+
setTimeout(() => {
182
+
showOnboarding();
183
+
}, 300);
184
+
};
185
+
186
+
// Start onboarding after page loads
187
+
if (document.readyState === 'loading') {
188
+
document.addEventListener('DOMContentLoaded', initOnboarding);
189
+
} else {
190
+
initOnboarding();
191
+
}