first time onboarding
+52
Cargo.lock
+52
Cargo.lock
···
19
19
"tracing",
20
20
]
21
21
22
+
[[package]]
23
+
name = "actix-files"
24
+
version = "0.6.8"
25
+
source = "registry+https://github.com/rust-lang/crates.io-index"
26
+
checksum = "6c0d87f10d70e2948ad40e8edea79c8e77c6c66e0250a4c1f09b690465199576"
27
+
dependencies = [
28
+
"actix-http",
29
+
"actix-service",
30
+
"actix-utils",
31
+
"actix-web",
32
+
"bitflags",
33
+
"bytes",
34
+
"derive_more 2.0.1",
35
+
"futures-core",
36
+
"http-range",
37
+
"log",
38
+
"mime",
39
+
"mime_guess",
40
+
"percent-encoding",
41
+
"pin-project-lite",
42
+
"v_htmlescape",
43
+
]
44
+
22
45
[[package]]
23
46
name = "actix-http"
24
47
version = "3.11.2"
···
386
409
name = "at-me"
387
410
version = "0.1.0"
388
411
dependencies = [
412
+
"actix-files",
389
413
"actix-session",
390
414
"actix-web",
391
415
"atrium-api",
···
1415
1439
"pin-project-lite",
1416
1440
]
1417
1441
1442
+
[[package]]
1443
+
name = "http-range"
1444
+
version = "0.1.5"
1445
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1446
+
checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573"
1447
+
1418
1448
[[package]]
1419
1449
name = "httparse"
1420
1450
version = "1.10.1"
···
1896
1926
source = "registry+https://github.com/rust-lang/crates.io-index"
1897
1927
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
1898
1928
1929
+
[[package]]
1930
+
name = "mime_guess"
1931
+
version = "2.0.5"
1932
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1933
+
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
1934
+
dependencies = [
1935
+
"mime",
1936
+
"unicase",
1937
+
]
1938
+
1899
1939
[[package]]
1900
1940
name = "miniz_oxide"
1901
1941
version = "0.8.9"
···
2944
2984
source = "registry+https://github.com/rust-lang/crates.io-index"
2945
2985
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
2946
2986
2987
+
[[package]]
2988
+
name = "unicase"
2989
+
version = "2.8.1"
2990
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2991
+
checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539"
2992
+
2947
2993
[[package]]
2948
2994
name = "unicode-ident"
2949
2995
version = "1.0.19"
···
3007
3053
"wasm-bindgen",
3008
3054
]
3009
3055
3056
+
[[package]]
3057
+
name = "v_htmlescape"
3058
+
version = "0.15.8"
3059
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3060
+
checksum = "4e8257fbc510f0a46eb602c10215901938b5c2a7d5e70fc11483b1d3c9b5b18c"
3061
+
3010
3062
[[package]]
3011
3063
name = "vcpkg"
3012
3064
version = "0.2.15"
+1
Cargo.toml
+1
Cargo.toml
+2
src/main.rs
+2
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
4
5
mod oauth;
5
6
mod routes;
···
36
37
.service(routes::logout)
37
38
.service(routes::restore_session)
38
39
.service(routes::favicon)
40
+
.service(Files::new("/static", "./static"))
39
41
})
40
42
.bind(("0.0.0.0", 8080))?
41
43
.run()
+156
-617
src/templates.rs
+156
-617
src/templates.rs
···
194
194
by <a href="https://bsky.app/profile/zzstoatzz.io" target="_blank" rel="noopener noreferrer">@zzstoatzz.io</a>
195
195
</div>
196
196
197
-
<script>
198
-
// Check for saved session
199
-
const savedDid = localStorage.getItem('atme_did');
200
-
if (savedDid) {
201
-
document.getElementById('loginForm').classList.add('hidden');
202
-
document.getElementById('restoring').classList.remove('hidden');
203
-
204
-
fetch('/api/restore-session', {
205
-
method: 'POST',
206
-
headers: { 'Content-Type': 'application/json' },
207
-
body: JSON.stringify({ did: savedDid })
208
-
}).then(r => {
209
-
if (r.ok) {
210
-
window.location.href = '/';
211
-
} else {
212
-
localStorage.removeItem('atme_did');
213
-
document.getElementById('loginForm').classList.remove('hidden');
214
-
document.getElementById('restoring').classList.add('hidden');
215
-
}
216
-
}).catch(() => {
217
-
localStorage.removeItem('atme_did');
218
-
document.getElementById('loginForm').classList.remove('hidden');
219
-
document.getElementById('restoring').classList.add('hidden');
220
-
});
221
-
}
222
-
223
-
// Fetch and cache atmosphere data
224
-
async function fetchAtmosphere() {
225
-
const CACHE_KEY = 'atme_atmosphere';
226
-
const CACHE_DURATION = 24 * 60 * 60 * 1000; // 24 hours
227
-
228
-
const cached = localStorage.getItem(CACHE_KEY);
229
-
if (cached) {
230
-
const { data, timestamp } = JSON.parse(cached);
231
-
if (Date.now() - timestamp < CACHE_DURATION) {
232
-
return data;
233
-
}
234
-
}
235
-
236
-
try {
237
-
const response = await fetch('https://ufos-api.microcosm.blue/collections?order=dids-estimate&limit=50');
238
-
const json = await response.json();
239
-
240
-
// Group by namespace (first two segments)
241
-
const namespaces = {};
242
-
json.collections.forEach(col => {
243
-
const parts = col.nsid.split('.');
244
-
if (parts.length >= 2) {
245
-
const ns = `${parts[0]}.${parts[1]}`;
246
-
if (!namespaces[ns]) {
247
-
namespaces[ns] = {
248
-
namespace: ns,
249
-
dids_total: 0,
250
-
records_total: 0,
251
-
collections: []
252
-
};
253
-
}
254
-
namespaces[ns].dids_total += col.dids_estimate;
255
-
namespaces[ns].records_total += col.creates;
256
-
namespaces[ns].collections.push(col.nsid);
257
-
}
258
-
});
259
-
260
-
const data = Object.values(namespaces).sort((a, b) => b.dids_total - a.dids_total).slice(0, 30);
261
-
262
-
localStorage.setItem(CACHE_KEY, JSON.stringify({
263
-
data,
264
-
timestamp: Date.now()
265
-
}));
266
-
267
-
return data;
268
-
} catch (e) {
269
-
console.error('Failed to fetch atmosphere data:', e);
270
-
return [];
271
-
}
272
-
}
273
-
274
-
// Try to fetch app avatar
275
-
async function fetchAppAvatar(namespace) {
276
-
const reversed = namespace.split('.').reverse().join('.');
277
-
const handles = [reversed, `${reversed}.bsky.social`];
278
-
279
-
for (const handle of handles) {
280
-
try {
281
-
const didRes = await fetch(`https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${handle}`);
282
-
if (!didRes.ok) continue;
283
-
284
-
const { did } = await didRes.json();
285
-
const profileRes = await fetch(`https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${did}`);
286
-
if (!profileRes.ok) continue;
287
-
288
-
const profile = await profileRes.json();
289
-
if (profile.avatar) return profile.avatar;
290
-
} catch (e) {
291
-
continue;
292
-
}
293
-
}
294
-
return null;
295
-
}
296
-
297
-
// Render atmosphere
298
-
async function renderAtmosphere() {
299
-
const data = await fetchAtmosphere();
300
-
if (!data.length) return;
301
-
302
-
const atmosphere = document.getElementById('atmosphere');
303
-
const maxSize = Math.max(...data.map(d => d.dids_total));
304
-
305
-
data.forEach((app, i) => {
306
-
const orb = document.createElement('div');
307
-
orb.className = 'app-orb';
308
-
309
-
// Size based on user count (20-80px)
310
-
const size = 20 + (app.dids_total / maxSize) * 60;
311
-
312
-
// Position in 3D space
313
-
const angle = (i / data.length) * Math.PI * 2;
314
-
const radius = 250 + (i % 3) * 100;
315
-
const y = (i % 5) * 80 - 160;
316
-
const x = Math.cos(angle) * radius;
317
-
const z = Math.sin(angle) * radius;
318
-
319
-
orb.style.width = `${size}px`;
320
-
orb.style.height = `${size}px`;
321
-
orb.style.left = `calc(50% + ${x}px)`;
322
-
orb.style.top = `calc(50% + ${y}px)`;
323
-
orb.style.transform = `translateZ(${z}px) translate(-50%, -50%)`;
324
-
orb.style.background = `radial-gradient(circle, rgba(255,255,255,0.1), rgba(255,255,255,0.02))`;
325
-
orb.style.border = '1px solid rgba(255,255,255,0.1)';
326
-
orb.style.boxShadow = '0 0 20px rgba(255,255,255,0.1)';
327
-
328
-
// Fallback letter
329
-
const letter = app.namespace.split('.')[1]?.[0]?.toUpperCase() || app.namespace[0].toUpperCase();
330
-
orb.innerHTML = `<div class="fallback">${letter}</div>`;
331
-
332
-
// Tooltip
333
-
const tooltip = document.createElement('div');
334
-
tooltip.className = 'app-tooltip';
335
-
const users = app.dids_total >= 1000000
336
-
? `${(app.dids_total / 1000000).toFixed(1)}M users`
337
-
: `${(app.dids_total / 1000).toFixed(0)}K users`;
338
-
tooltip.textContent = `${app.namespace} • ${users}`;
339
-
orb.appendChild(tooltip);
340
-
341
-
atmosphere.appendChild(orb);
342
-
343
-
// Fetch and apply avatar
344
-
fetchAppAvatar(app.namespace).then(avatarUrl => {
345
-
if (avatarUrl) {
346
-
orb.innerHTML = `<img src="${avatarUrl}" alt="${app.namespace}" />`;
347
-
orb.appendChild(tooltip);
348
-
}
349
-
});
350
-
});
351
-
}
352
-
353
-
renderAtmosphere();
354
-
</script>
197
+
<script src="/static/login.js"></script>
355
198
</body>
356
199
</html>
357
200
"#
···
413
256
414
257
.logout {{
415
258
position: fixed;
416
-
top: 1.5rem;
417
-
right: 1.5rem;
418
-
font-size: 0.7rem;
259
+
top: clamp(1rem, 2vmin, 1.5rem);
260
+
right: clamp(1rem, 2vmin, 1.5rem);
261
+
font-size: clamp(0.65rem, 1.4vmin, 0.75rem);
419
262
color: var(--text-light);
420
263
text-decoration: none;
421
264
border: 1px solid var(--border);
422
-
padding: 0.4rem 0.8rem;
265
+
padding: clamp(0.4rem, 1vmin, 0.5rem) clamp(0.8rem, 2vmin, 1rem);
423
266
transition: all 0.2s ease;
424
267
z-index: 100;
425
268
-webkit-tap-highlight-color: transparent;
···
433
276
border-color: var(--text-light);
434
277
}}
435
278
436
-
@media (max-width: 768px) {{
437
-
.logout {{
438
-
padding: 0.6rem 1rem;
439
-
font-size: 0.75rem;
440
-
top: 1rem;
441
-
right: 1rem;
442
-
}}
443
-
}}
444
-
445
279
.info {{
446
280
position: fixed;
447
-
top: 1.5rem;
448
-
left: 1.5rem;
449
-
width: 32px;
450
-
height: 32px;
281
+
top: clamp(1rem, 2vmin, 1.5rem);
282
+
left: clamp(1rem, 2vmin, 1.5rem);
283
+
width: clamp(32px, 6vmin, 40px);
284
+
height: clamp(32px, 6vmin, 40px);
451
285
border-radius: 50%;
452
286
border: 1px solid var(--border);
453
287
display: flex;
454
288
align-items: center;
455
289
justify-content: center;
456
-
font-size: 0.75rem;
290
+
font-size: clamp(0.7rem, 1.5vmin, 0.85rem);
457
291
color: var(--text-light);
458
292
cursor: pointer;
459
293
transition: all 0.2s ease;
···
467
301
border-color: var(--text-light);
468
302
}}
469
303
470
-
@media (max-width: 768px) {{
471
-
.info {{
472
-
width: 40px;
473
-
height: 40px;
474
-
font-size: 0.85rem;
475
-
top: 1rem;
476
-
left: 1rem;
477
-
}}
478
-
}}
479
-
480
304
.info-modal {{
481
305
position: fixed;
482
306
top: 50%;
···
570
394
background: var(--surface);
571
395
border: 2px solid var(--text-light);
572
396
border-radius: 50%;
573
-
width: 120px;
574
-
height: 120px;
397
+
width: clamp(100px, 20vmin, 140px);
398
+
height: clamp(100px, 20vmin, 140px);
575
399
display: flex;
576
400
flex-direction: column;
577
401
align-items: center;
578
402
justify-content: center;
579
-
gap: 0.3rem;
403
+
gap: clamp(0.2rem, 1vmin, 0.3rem);
404
+
padding: clamp(0.4rem, 1vmin, 0.6rem);
580
405
z-index: 10;
581
406
cursor: pointer;
582
407
transition: all 0.2s ease;
···
589
414
box-shadow: 0 0 20px rgba(255, 255, 255, 0.1);
590
415
}}
591
416
592
-
@media (max-width: 768px) {{
593
-
.identity {{
594
-
width: 100px;
595
-
height: 100px;
596
-
}}
597
-
}}
598
-
599
417
.identity-label {{
600
-
font-size: 1.2rem;
418
+
font-size: clamp(1rem, 2vmin, 1.2rem);
601
419
color: var(--text);
602
420
font-weight: 600;
603
421
line-height: 1;
604
422
}}
605
423
606
424
.identity-value {{
607
-
font-size: 0.7rem;
425
+
font-size: clamp(0.6rem, 1.2vmin, 0.7rem);
608
426
color: var(--text-lighter);
609
427
text-align: center;
610
428
word-break: break-word;
611
-
max-width: 100px;
429
+
max-width: 90%;
612
430
font-weight: 400;
613
-
}}
614
-
615
-
@media (max-width: 768px) {{
616
-
.identity-label {{
617
-
font-size: 1.1rem;
618
-
}}
619
-
620
-
.identity-value {{
621
-
font-size: 0.65rem;
622
-
}}
431
+
line-height: 1.2;
623
432
}}
624
433
625
434
.identity-hint {{
626
-
font-size: 0.4rem;
435
+
font-size: clamp(0.35rem, 0.8vmin, 0.45rem);
627
436
color: var(--text-lighter);
628
437
margin-top: 0.2rem;
629
438
letter-spacing: 0.05em;
630
439
}}
631
440
441
+
.identity-avatar {{
442
+
width: clamp(30px, 6vmin, 45px);
443
+
height: clamp(30px, 6vmin, 45px);
444
+
border-radius: 50%;
445
+
object-fit: cover;
446
+
border: 2px solid var(--text-light);
447
+
margin-bottom: clamp(0.2rem, 1vmin, 0.3rem);
448
+
}}
449
+
632
450
.app-view {{
633
451
position: absolute;
634
452
display: flex;
635
453
flex-direction: column;
636
454
align-items: center;
637
-
gap: 0.4rem;
455
+
gap: clamp(0.3rem, 1vmin, 0.5rem);
638
456
cursor: pointer;
639
457
transition: all 0.2s ease;
640
458
opacity: 0.7;
···
650
468
background: var(--surface-hover);
651
469
border: 1px solid var(--border);
652
470
border-radius: 50%;
653
-
width: 60px;
654
-
height: 60px;
471
+
width: clamp(45px, 8vmin, 60px);
472
+
height: clamp(45px, 8vmin, 60px);
655
473
display: flex;
656
474
align-items: center;
657
475
justify-content: center;
658
476
transition: all 0.2s ease;
659
477
overflow: hidden;
478
+
font-size: clamp(1rem, 2vmin, 1.5rem);
660
479
}}
661
480
662
481
.app-logo {{
···
671
490
}}
672
491
673
492
.app-name {{
674
-
font-size: 0.65rem;
493
+
font-size: clamp(0.6rem, 1.2vmin, 0.7rem);
675
494
color: var(--text);
676
495
text-align: center;
677
-
max-width: 100px;
496
+
max-width: clamp(80px, 15vmin, 120px);
678
497
}}
679
498
680
499
.detail-panel {{
···
902
721
903
722
.footer {{
904
723
position: fixed;
905
-
bottom: 1rem;
724
+
bottom: clamp(0.75rem, 2vmin, 1rem);
906
725
left: 50%;
907
726
transform: translateX(-50%);
908
-
font-size: 0.65rem;
909
-
color: var(--text-light);
727
+
font-size: clamp(0.6rem, 1.2vmin, 0.7rem);
728
+
color: var(--text);
910
729
z-index: 100;
730
+
background: var(--surface);
731
+
padding: clamp(0.4rem, 1vmin, 0.5rem) clamp(0.75rem, 2vmin, 1rem);
732
+
border-radius: 4px;
733
+
border: 1px solid var(--border);
911
734
}}
912
735
913
736
.footer a {{
914
-
color: var(--text-light);
737
+
color: var(--text);
915
738
text-decoration: none;
916
739
border-bottom: 1px solid transparent;
917
-
transition: border-color 0.2s ease;
740
+
transition: all 0.2s ease;
918
741
}}
919
742
920
743
.footer a:hover {{
921
-
border-bottom-color: var(--text-light);
744
+
border-bottom-color: var(--text);
922
745
}}
923
746
924
747
.loading {{ color: var(--text-light); font-size: 0.75rem; }}
748
+
749
+
.onboarding-overlay {{
750
+
position: fixed;
751
+
inset: 0;
752
+
background: transparent;
753
+
z-index: 3000;
754
+
display: none;
755
+
opacity: 0;
756
+
transition: opacity 0.3s ease;
757
+
pointer-events: none;
758
+
}}
759
+
760
+
.onboarding-overlay.active {{
761
+
display: block;
762
+
opacity: 1;
763
+
}}
764
+
765
+
.onboarding-spotlight {{
766
+
position: absolute;
767
+
border: 2px solid rgba(255, 255, 255, 0.9);
768
+
border-radius: 50%;
769
+
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.75), 0 0 40px rgba(255, 255, 255, 0.5);
770
+
pointer-events: none;
771
+
transition: all 0.5s ease;
772
+
}}
773
+
774
+
.onboarding-content {{
775
+
position: fixed;
776
+
background: var(--surface);
777
+
border: 2px solid var(--border);
778
+
padding: clamp(1rem, 3vmin, 2rem);
779
+
max-width: min(400px, 90vw);
780
+
z-index: 3001;
781
+
border-radius: 4px;
782
+
transition: all 0.3s ease;
783
+
pointer-events: auto;
784
+
}}
785
+
786
+
.onboarding-content h3 {{
787
+
font-size: clamp(0.9rem, 2vmin, 1.1rem);
788
+
margin-bottom: clamp(0.5rem, 1.5vmin, 0.75rem);
789
+
color: var(--text);
790
+
font-weight: 500;
791
+
}}
792
+
793
+
.onboarding-content p {{
794
+
font-size: clamp(0.7rem, 1.5vmin, 0.85rem);
795
+
color: var(--text-light);
796
+
line-height: 1.5;
797
+
margin-bottom: clamp(1rem, 2vmin, 1.25rem);
798
+
}}
799
+
800
+
.onboarding-actions {{
801
+
display: flex;
802
+
gap: clamp(0.5rem, 1.5vmin, 0.75rem);
803
+
justify-content: flex-end;
804
+
}}
805
+
806
+
.onboarding-actions button {{
807
+
font-family: inherit;
808
+
font-size: clamp(0.7rem, 1.5vmin, 0.8rem);
809
+
padding: clamp(0.4rem, 1vmin, 0.5rem) clamp(0.8rem, 2vmin, 1rem);
810
+
background: transparent;
811
+
border: 1px solid var(--border);
812
+
color: var(--text);
813
+
cursor: pointer;
814
+
transition: all 0.2s ease;
815
+
border-radius: 2px;
816
+
}}
817
+
818
+
.onboarding-actions button:hover {{
819
+
background: var(--surface-hover);
820
+
border-color: var(--text-light);
821
+
}}
822
+
823
+
.onboarding-actions button.primary {{
824
+
background: var(--surface-hover);
825
+
border-color: var(--text-light);
826
+
}}
827
+
828
+
.onboarding-progress {{
829
+
display: flex;
830
+
gap: clamp(0.4rem, 1vmin, 0.5rem);
831
+
justify-content: center;
832
+
margin-top: clamp(0.75rem, 2vmin, 1rem);
833
+
}}
834
+
835
+
.onboarding-progress span {{
836
+
width: clamp(6px, 1.5vmin, 8px);
837
+
height: clamp(6px, 1.5vmin, 8px);
838
+
border-radius: 50%;
839
+
background: var(--border);
840
+
transition: background 0.3s ease;
841
+
}}
842
+
843
+
.onboarding-progress span.active {{
844
+
background: var(--text);
845
+
}}
846
+
847
+
.onboarding-progress span.done {{
848
+
background: var(--text-light);
849
+
}}
925
850
</style>
926
851
</head>
927
852
<body>
928
-
<div class="info" id="infoBtn">i</div>
853
+
<div class="info" id="infoBtn">?</div>
929
854
<a href="javascript:void(0)" id="logoutBtn" class="logout">logout</a>
930
855
931
856
<div class="overlay" id="overlay"></div>
···
935
860
<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>
936
861
<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>
937
862
<button id="closeInfo">got it</button>
863
+
<button id="restartTour" onclick="window.restartOnboarding()" style="margin-left: 0.5rem; background: var(--surface-hover);">restart tour</button>
864
+
</div>
865
+
866
+
<div class="onboarding-overlay" id="onboardingOverlay">
867
+
<div class="onboarding-spotlight" id="onboardingSpotlight"></div>
868
+
<div class="onboarding-content" id="onboardingContent"></div>
938
869
</div>
939
870
940
871
<div class="canvas">
···
951
882
<a href="https://tangled.org/@zzstoatzz.io/at-me" target="_blank" rel="noopener noreferrer">view source</a>
952
883
</div>
953
884
<script>
954
-
const did = '{}';
955
-
localStorage.setItem('atme_did', did);
956
-
957
-
let globalPds = null;
958
-
let globalHandle = null;
959
-
960
-
// Try to fetch app avatar from their bsky profile
961
-
async function fetchAppAvatar(namespace) {{
962
-
try {{
963
-
// Reverse namespace to get domain (e.g., io.zzstoatzz -> zzstoatzz.io)
964
-
const reversed = namespace.split('.').reverse().join('.');
965
-
// Try reversed domain, then reversed.bsky.social
966
-
const handles = [reversed, `${{reversed}}.bsky.social`];
967
-
968
-
for (const handle of handles) {{
969
-
try {{
970
-
const didRes = await fetch(`https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${{handle}}`);
971
-
if (!didRes.ok) continue;
972
-
973
-
const {{ did }} = await didRes.json();
974
-
const profileRes = await fetch(`https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${{did}}`);
975
-
if (!profileRes.ok) continue;
976
-
977
-
const profile = await profileRes.json();
978
-
if (profile.avatar) {{
979
-
return profile.avatar;
980
-
}}
981
-
}} catch (e) {{
982
-
continue;
983
-
}}
984
-
}}
985
-
}} catch (e) {{
986
-
console.log('Could not fetch avatar for', namespace);
987
-
}}
988
-
return null;
989
-
}}
990
-
991
-
// Logout handler
992
-
document.getElementById('logoutBtn').addEventListener('click', (e) => {{
993
-
e.preventDefault();
994
-
localStorage.removeItem('atme_did');
995
-
window.location.href = '/logout';
996
-
}});
997
-
998
-
// Info modal handlers
999
-
document.getElementById('infoBtn').addEventListener('click', () => {{
1000
-
document.getElementById('infoModal').classList.add('visible');
1001
-
document.getElementById('overlay').classList.add('visible');
1002
-
}});
1003
-
1004
-
document.getElementById('closeInfo').addEventListener('click', () => {{
1005
-
document.getElementById('infoModal').classList.remove('visible');
1006
-
document.getElementById('overlay').classList.remove('visible');
1007
-
}});
1008
-
1009
-
document.getElementById('overlay').addEventListener('click', () => {{
1010
-
document.getElementById('infoModal').classList.remove('visible');
1011
-
document.getElementById('overlay').classList.remove('visible');
1012
-
const detail = document.getElementById('detail');
1013
-
detail.classList.remove('visible');
1014
-
}});
1015
-
1016
-
// First resolve DID to get PDS endpoint and handle
1017
-
fetch('https://plc.directory/' + did)
1018
-
.then(r => r.json())
1019
-
.then(didDoc => {{
1020
-
const pds = didDoc.service.find(s => s.type === 'AtprotoPersonalDataServer')?.serviceEndpoint;
1021
-
const handle = didDoc.alsoKnownAs?.[0]?.replace('at://', '') || did;
1022
-
1023
-
globalPds = pds;
1024
-
globalHandle = handle;
1025
-
1026
-
// Update identity display with handle
1027
-
document.getElementById('handle').textContent = handle;
1028
-
1029
-
// Add identity click handler to show PDS info
1030
-
document.querySelector('.identity').addEventListener('click', () => {{
1031
-
const detail = document.getElementById('detail');
1032
-
const pdsHost = pds.replace('https://', '').replace('http://', '');
1033
-
detail.innerHTML = `
1034
-
<button class="detail-close" id="detailClose">×</button>
1035
-
<h3>your identity</h3>
1036
-
<div class="subtitle">decentralized identifier & storage</div>
1037
-
<div class="tree-item">
1038
-
<div class="tree-item-header">
1039
-
<span style="color: var(--text-light);">did</span>
1040
-
<span style="font-size: 0.6rem; color: var(--text);">${{did}}</span>
1041
-
</div>
1042
-
</div>
1043
-
<div class="tree-item">
1044
-
<div class="tree-item-header">
1045
-
<span style="color: var(--text-light);">handle</span>
1046
-
<span style="font-size: 0.6rem; color: var(--text);">@${{handle}}</span>
1047
-
</div>
1048
-
</div>
1049
-
<div class="tree-item">
1050
-
<div class="tree-item-header">
1051
-
<span style="color: var(--text-light);">personal data server</span>
1052
-
<span style="font-size: 0.6rem; color: var(--text);">${{pds}}</span>
1053
-
</div>
1054
-
</div>
1055
-
<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);">
1056
-
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.
1057
-
</div>
1058
-
`;
1059
-
detail.classList.add('visible');
1060
-
1061
-
// Add close button handler
1062
-
document.getElementById('detailClose').addEventListener('click', (e) => {{
1063
-
e.stopPropagation();
1064
-
detail.classList.remove('visible');
1065
-
}});
1066
-
}});
1067
-
1068
-
// Get all collections from PDS
1069
-
return fetch(`${{pds}}/xrpc/com.atproto.repo.describeRepo?repo=${{did}}`);
1070
-
}})
1071
-
.then(r => r.json())
1072
-
.then(repo => {{
1073
-
const collections = repo.collections || [];
1074
-
1075
-
// Group by app namespace (first two parts of lexicon)
1076
-
const apps = {{}};
1077
-
collections.forEach(collection => {{
1078
-
const parts = collection.split('.');
1079
-
if (parts.length >= 2) {{
1080
-
const namespace = `${{parts[0]}}.${{parts[1]}}`;
1081
-
if (!apps[namespace]) apps[namespace] = [];
1082
-
apps[namespace].push(collection);
1083
-
}}
1084
-
}});
1085
-
1086
-
const field = document.getElementById('field');
1087
-
field.innerHTML = '';
1088
-
field.classList.remove('loading');
1089
-
1090
-
const appNames = Object.keys(apps).sort();
1091
-
const radius = 240;
1092
-
const centerX = window.innerWidth / 2;
1093
-
const centerY = window.innerHeight / 2;
1094
-
1095
-
appNames.forEach((namespace, i) => {{
1096
-
const angle = (i / appNames.length) * 2 * Math.PI - Math.PI / 2; // Start from top
1097
-
const x = centerX + radius * Math.cos(angle) - 25;
1098
-
const y = centerY + radius * Math.sin(angle) - 30;
1099
-
1100
-
const div = document.createElement('div');
1101
-
div.className = 'app-view';
1102
-
div.style.left = `${{x}}px`;
1103
-
div.style.top = `${{y}}px`;
1104
-
1105
-
const firstLetter = namespace.split('.')[1]?.[0]?.toUpperCase() || namespace[0].toUpperCase();
1106
-
1107
-
div.innerHTML = `
1108
-
<div class="app-circle" data-namespace="${{namespace}}">${{firstLetter}}</div>
1109
-
<div class="app-name">${{namespace}}</div>
1110
-
`;
1111
-
1112
-
// Try to fetch and display avatar
1113
-
fetchAppAvatar(namespace).then(avatarUrl => {{
1114
-
if (avatarUrl) {{
1115
-
const circle = div.querySelector('.app-circle');
1116
-
circle.innerHTML = `<img src="${{avatarUrl}}" class="app-logo" alt="${{namespace}}" />`;
1117
-
}}
1118
-
}});
1119
-
1120
-
div.addEventListener('click', () => {{
1121
-
const detail = document.getElementById('detail');
1122
-
const collections = apps[namespace];
1123
-
1124
-
let html = `
1125
-
<button class="detail-close" id="detailClose">×</button>
1126
-
<h3>${{namespace}}</h3>
1127
-
<div class="subtitle">records stored in your pds:</div>
1128
-
`;
1129
-
1130
-
if (collections && collections.length > 0) {{
1131
-
// Group collections by sub-namespace (third segment)
1132
-
const grouped = {{}};
1133
-
collections.forEach(lexicon => {{
1134
-
const parts = lexicon.split('.');
1135
-
const subNamespace = parts.slice(2).join('.');
1136
-
const firstPart = parts[2] || lexicon;
1137
-
1138
-
if (!grouped[firstPart]) grouped[firstPart] = [];
1139
-
grouped[firstPart].push({{ lexicon, subNamespace }});
1140
-
}});
1141
-
1142
-
// Sort and display grouped items
1143
-
Object.keys(grouped).sort().forEach(group => {{
1144
-
const items = grouped[group];
1145
-
1146
-
if (items.length === 1 && items[0].subNamespace === group) {{
1147
-
// Single item with no further nesting
1148
-
html += `
1149
-
<div class="tree-item" data-lexicon="${{items[0].lexicon}}">
1150
-
<div class="tree-item-header">
1151
-
<span>${{group}}</span>
1152
-
<span class="tree-item-count">loading...</span>
1153
-
</div>
1154
-
</div>
1155
-
`;
1156
-
}} else {{
1157
-
// Group header
1158
-
html += `<div style="margin-bottom: 0.75rem;">`;
1159
-
html += `<div style="font-size: 0.7rem; color: var(--text-light); margin-bottom: 0.4rem; font-weight: 500;">${{group}}</div>`;
1160
-
1161
-
// Items in group
1162
-
items.sort((a, b) => a.subNamespace.localeCompare(b.subNamespace)).forEach(item => {{
1163
-
const displayName = item.subNamespace.split('.').slice(1).join('.') || item.subNamespace;
1164
-
html += `
1165
-
<div class="tree-item" data-lexicon="${{item.lexicon}}" style="margin-left: 0.75rem;">
1166
-
<div class="tree-item-header">
1167
-
<span>${{displayName}}</span>
1168
-
<span class="tree-item-count">loading...</span>
1169
-
</div>
1170
-
</div>
1171
-
`;
1172
-
}});
1173
-
html += `</div>`;
1174
-
}}
1175
-
}});
1176
-
}} else {{
1177
-
html += `<div class="tree-item">no collections found</div>`;
1178
-
}}
1179
-
1180
-
detail.innerHTML = html;
1181
-
detail.classList.add('visible');
1182
-
1183
-
// Add close button handler
1184
-
document.getElementById('detailClose').addEventListener('click', (e) => {{
1185
-
e.stopPropagation();
1186
-
detail.classList.remove('visible');
1187
-
}});
1188
-
1189
-
// Fetch record counts for each collection
1190
-
if (collections && collections.length > 0) {{
1191
-
collections.forEach(lexicon => {{
1192
-
fetch(`${{globalPds}}/xrpc/com.atproto.repo.listRecords?repo=${{did}}&collection=${{lexicon}}&limit=1`)
1193
-
.then(r => r.json())
1194
-
.then(data => {{
1195
-
const item = detail.querySelector(`[data-lexicon="${{lexicon}}"]`);
1196
-
if (item) {{
1197
-
const countSpan = item.querySelector('.tree-item-count');
1198
-
// The cursor field indicates there are more records
1199
-
countSpan.textContent = data.records?.length > 0 ? 'has records' : 'empty';
1200
-
}}
1201
-
}})
1202
-
.catch(e => {{
1203
-
console.error('Error fetching count for', lexicon, e);
1204
-
const item = detail.querySelector(`[data-lexicon="${{lexicon}}"]`);
1205
-
if (item) {{
1206
-
const countSpan = item.querySelector('.tree-item-count');
1207
-
countSpan.textContent = 'error';
1208
-
}}
1209
-
}});
1210
-
}});
1211
-
}}
1212
-
1213
-
// Add click handlers to tree items to fetch actual records
1214
-
detail.querySelectorAll('.tree-item[data-lexicon]').forEach(item => {{
1215
-
item.addEventListener('click', (e) => {{
1216
-
e.stopPropagation();
1217
-
const lexicon = item.dataset.lexicon;
1218
-
const existingRecords = item.querySelector('.record-list');
1219
-
1220
-
if (existingRecords) {{
1221
-
existingRecords.remove();
1222
-
return;
1223
-
}}
1224
-
1225
-
const recordListDiv = document.createElement('div');
1226
-
recordListDiv.className = 'record-list';
1227
-
recordListDiv.innerHTML = '<div class="loading">loading records...</div>';
1228
-
item.appendChild(recordListDiv);
1229
-
1230
-
fetch(`${{globalPds}}/xrpc/com.atproto.repo.listRecords?repo=${{did}}&collection=${{lexicon}}&limit=5`)
1231
-
.then(r => r.json())
1232
-
.then(data => {{
1233
-
if (data.records && data.records.length > 0) {{
1234
-
let recordsHtml = '';
1235
-
data.records.forEach((record, idx) => {{
1236
-
const json = JSON.stringify(record.value, null, 2);
1237
-
const recordId = `record-${{Date.now()}}-${{idx}}`;
1238
-
recordsHtml += `
1239
-
<div class="record">
1240
-
<div class="record-header">
1241
-
<span class="record-label">record</span>
1242
-
<button class="copy-btn" data-content="${{encodeURIComponent(json)}}" data-record-id="${{recordId}}">copy</button>
1243
-
</div>
1244
-
<div class="record-content">
1245
-
<pre>${{json}}</pre>
1246
-
</div>
1247
-
</div>
1248
-
`;
1249
-
}});
1250
-
1251
-
if (data.cursor && data.records.length === 5) {{
1252
-
recordsHtml += `<button class="load-more" data-cursor="${{data.cursor}}" data-lexicon="${{lexicon}}">load more</button>`;
1253
-
}}
1254
-
1255
-
recordListDiv.innerHTML = recordsHtml;
1256
-
1257
-
// Use event delegation for copy and load more buttons
1258
-
recordListDiv.addEventListener('click', (e) => {{
1259
-
// Handle copy button
1260
-
if (e.target.classList.contains('copy-btn')) {{
1261
-
e.stopPropagation();
1262
-
const copyBtn = e.target;
1263
-
const content = decodeURIComponent(copyBtn.dataset.content);
1264
-
1265
-
navigator.clipboard.writeText(content).then(() => {{
1266
-
const originalText = copyBtn.textContent;
1267
-
copyBtn.textContent = 'copied!';
1268
-
copyBtn.classList.add('copied');
1269
-
setTimeout(() => {{
1270
-
copyBtn.textContent = originalText;
1271
-
copyBtn.classList.remove('copied');
1272
-
}}, 1500);
1273
-
}}).catch(err => {{
1274
-
console.error('Failed to copy:', err);
1275
-
copyBtn.textContent = 'error';
1276
-
setTimeout(() => {{
1277
-
copyBtn.textContent = 'copy';
1278
-
}}, 1500);
1279
-
}});
1280
-
}}
1281
-
1282
-
// Handle load more button
1283
-
if (e.target.classList.contains('load-more')) {{
1284
-
e.stopPropagation();
1285
-
const loadMoreBtn = e.target;
1286
-
const cursor = loadMoreBtn.dataset.cursor;
1287
-
const lexicon = loadMoreBtn.dataset.lexicon;
1288
-
1289
-
loadMoreBtn.textContent = 'loading...';
1290
-
1291
-
fetch(`${{globalPds}}/xrpc/com.atproto.repo.listRecords?repo=${{did}}&collection=${{lexicon}}&limit=5&cursor=${{cursor}}`)
1292
-
.then(r => r.json())
1293
-
.then(moreData => {{
1294
-
let moreHtml = '';
1295
-
moreData.records.forEach((record, idx) => {{
1296
-
const json = JSON.stringify(record.value, null, 2);
1297
-
const recordId = `record-more-${{Date.now()}}-${{idx}}`;
1298
-
moreHtml += `
1299
-
<div class="record">
1300
-
<div class="record-header">
1301
-
<span class="record-label">record</span>
1302
-
<button class="copy-btn" data-content="${{encodeURIComponent(json)}}" data-record-id="${{recordId}}">copy</button>
1303
-
</div>
1304
-
<div class="record-content">
1305
-
<pre>${{json}}</pre>
1306
-
</div>
1307
-
</div>
1308
-
`;
1309
-
}});
1310
-
1311
-
loadMoreBtn.remove();
1312
-
recordListDiv.insertAdjacentHTML('beforeend', moreHtml);
1313
-
1314
-
if (moreData.cursor && moreData.records.length === 5) {{
1315
-
recordListDiv.insertAdjacentHTML('beforeend',
1316
-
`<button class="load-more" data-cursor="${{moreData.cursor}}" data-lexicon="${{lexicon}}">load more</button>`
1317
-
);
1318
-
}}
1319
-
}});
1320
-
}}
1321
-
}});
1322
-
}} else {{
1323
-
recordListDiv.innerHTML = '<div class="record">no records found</div>';
1324
-
}}
1325
-
}})
1326
-
.catch(e => {{
1327
-
console.error('Error fetching records:', e);
1328
-
recordListDiv.innerHTML = '<div class="record">error loading records</div>';
1329
-
}});
1330
-
}});
1331
-
}});
1332
-
}});
1333
-
1334
-
field.appendChild(div);
1335
-
}});
1336
-
1337
-
// Close detail panel when clicking canvas
1338
-
const canvas = document.querySelector('.canvas');
1339
-
canvas.addEventListener('click', (e) => {{
1340
-
if (e.target === canvas) {{
1341
-
document.getElementById('detail').classList.remove('visible');
1342
-
}}
1343
-
}});
1344
-
}})
1345
-
.catch(e => {{
1346
-
document.getElementById('field').innerHTML = 'error loading records';
1347
-
console.error(e);
1348
-
}});
885
+
window.DID = '{}';
1349
886
</script>
887
+
<script src="/static/app.js"></script>
888
+
<script src="/static/onboarding.js"></script>
1350
889
</body>
1351
890
</html>
1352
891
"#, did)
+417
static/app.js
+417
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
+
// Try to fetch app avatar from their bsky profile
9
+
async function fetchAppAvatar(namespace) {
10
+
try {
11
+
// Reverse namespace to get domain (e.g., io.zzstoatzz -> zzstoatzz.io)
12
+
const reversed = namespace.split('.').reverse().join('.');
13
+
// Try reversed domain, then reversed.bsky.social
14
+
const handles = [reversed, `${reversed}.bsky.social`];
15
+
16
+
for (const handle of handles) {
17
+
try {
18
+
const didRes = await fetch(`https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${handle}`);
19
+
if (!didRes.ok) continue;
20
+
21
+
const { did } = await didRes.json();
22
+
const profileRes = await fetch(`https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${did}`);
23
+
if (!profileRes.ok) continue;
24
+
25
+
const profile = await profileRes.json();
26
+
if (profile.avatar) {
27
+
return profile.avatar;
28
+
}
29
+
} catch (e) {
30
+
// Silently continue to next handle
31
+
continue;
32
+
}
33
+
}
34
+
} catch (e) {
35
+
// Expected for namespaces without Bluesky accounts
36
+
}
37
+
return null;
38
+
}
39
+
40
+
// Logout handler
41
+
document.getElementById('logoutBtn').addEventListener('click', (e) => {
42
+
e.preventDefault();
43
+
localStorage.removeItem('atme_did');
44
+
window.location.href = '/logout';
45
+
});
46
+
47
+
// Info modal handlers
48
+
document.getElementById('infoBtn').addEventListener('click', () => {
49
+
document.getElementById('infoModal').classList.add('visible');
50
+
document.getElementById('overlay').classList.add('visible');
51
+
});
52
+
53
+
document.getElementById('closeInfo').addEventListener('click', () => {
54
+
document.getElementById('infoModal').classList.remove('visible');
55
+
document.getElementById('overlay').classList.remove('visible');
56
+
});
57
+
58
+
document.getElementById('overlay').addEventListener('click', () => {
59
+
document.getElementById('infoModal').classList.remove('visible');
60
+
document.getElementById('overlay').classList.remove('visible');
61
+
const detail = document.getElementById('detail');
62
+
detail.classList.remove('visible');
63
+
});
64
+
65
+
// First resolve DID to get PDS endpoint and handle
66
+
fetch('https://plc.directory/' + did)
67
+
.then(r => r.json())
68
+
.then(didDoc => {
69
+
const pds = didDoc.service.find(s => s.type === 'AtprotoPersonalDataServer')?.serviceEndpoint;
70
+
const handle = didDoc.alsoKnownAs?.[0]?.replace('at://', '') || did;
71
+
72
+
globalPds = pds;
73
+
globalHandle = handle;
74
+
75
+
// Update identity display with handle
76
+
document.getElementById('handle').textContent = handle;
77
+
78
+
// Try to fetch and display user's avatar
79
+
fetch(`https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${did}`)
80
+
.then(r => r.json())
81
+
.then(profile => {
82
+
if (profile.avatar) {
83
+
const identity = document.querySelector('.identity');
84
+
const avatarImg = document.createElement('img');
85
+
avatarImg.src = profile.avatar;
86
+
avatarImg.className = 'identity-avatar';
87
+
avatarImg.alt = handle;
88
+
// Insert avatar before the @ label
89
+
identity.insertBefore(avatarImg, identity.firstChild);
90
+
}
91
+
})
92
+
.catch(() => {
93
+
// User may not have an avatar set
94
+
});
95
+
96
+
// Add identity click handler to show PDS info
97
+
document.querySelector('.identity').addEventListener('click', () => {
98
+
const detail = document.getElementById('detail');
99
+
const pdsHost = pds.replace('https://', '').replace('http://', '');
100
+
detail.innerHTML = `
101
+
<button class="detail-close" id="detailClose">×</button>
102
+
<h3>your identity</h3>
103
+
<div class="subtitle">decentralized identifier & storage</div>
104
+
<div class="tree-item">
105
+
<div class="tree-item-header">
106
+
<span style="color: var(--text-light);">did</span>
107
+
<span style="font-size: 0.6rem; color: var(--text);">${did}</span>
108
+
</div>
109
+
</div>
110
+
<div class="tree-item">
111
+
<div class="tree-item-header">
112
+
<span style="color: var(--text-light);">handle</span>
113
+
<span style="font-size: 0.6rem; color: var(--text);">@${handle}</span>
114
+
</div>
115
+
</div>
116
+
<div class="tree-item">
117
+
<div class="tree-item-header">
118
+
<span style="color: var(--text-light);">personal data server</span>
119
+
<span style="font-size: 0.6rem; color: var(--text);">${pds}</span>
120
+
</div>
121
+
</div>
122
+
<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);">
123
+
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.
124
+
</div>
125
+
`;
126
+
detail.classList.add('visible');
127
+
128
+
// Add close button handler
129
+
document.getElementById('detailClose').addEventListener('click', (e) => {
130
+
e.stopPropagation();
131
+
detail.classList.remove('visible');
132
+
});
133
+
});
134
+
135
+
// Get all collections from PDS
136
+
return fetch(`${pds}/xrpc/com.atproto.repo.describeRepo?repo=${did}`);
137
+
})
138
+
.then(r => r.json())
139
+
.then(repo => {
140
+
const collections = repo.collections || [];
141
+
142
+
// Group by app namespace (first two parts of lexicon)
143
+
const apps = {};
144
+
collections.forEach(collection => {
145
+
const parts = collection.split('.');
146
+
if (parts.length >= 2) {
147
+
const namespace = `${parts[0]}.${parts[1]}`;
148
+
if (!apps[namespace]) apps[namespace] = [];
149
+
apps[namespace].push(collection);
150
+
}
151
+
});
152
+
153
+
const field = document.getElementById('field');
154
+
field.innerHTML = '';
155
+
field.classList.remove('loading');
156
+
157
+
const appNames = Object.keys(apps).sort();
158
+
// Responsive radius: use viewport-relative sizing with min/max bounds
159
+
const vmin = Math.min(window.innerWidth, window.innerHeight);
160
+
const radius = Math.max(vmin * 0.35, 150); // 35% of smallest dimension, min 150px
161
+
const centerX = window.innerWidth / 2;
162
+
const centerY = window.innerHeight / 2;
163
+
164
+
appNames.forEach((namespace, i) => {
165
+
const angle = (i / appNames.length) * 2 * Math.PI - Math.PI / 2; // Start from top
166
+
const x = centerX + radius * Math.cos(angle) - 30;
167
+
const y = centerY + radius * Math.sin(angle) - 30;
168
+
169
+
const div = document.createElement('div');
170
+
div.className = 'app-view';
171
+
div.style.left = `${x}px`;
172
+
div.style.top = `${y}px`;
173
+
174
+
const firstLetter = namespace.split('.')[1]?.[0]?.toUpperCase() || namespace[0].toUpperCase();
175
+
176
+
div.innerHTML = `
177
+
<div class="app-circle" data-namespace="${namespace}">${firstLetter}</div>
178
+
<div class="app-name">${namespace}</div>
179
+
`;
180
+
181
+
// Try to fetch and display avatar
182
+
fetchAppAvatar(namespace).then(avatarUrl => {
183
+
if (avatarUrl) {
184
+
const circle = div.querySelector('.app-circle');
185
+
circle.innerHTML = `<img src="${avatarUrl}" class="app-logo" alt="${namespace}" />`;
186
+
}
187
+
});
188
+
189
+
div.addEventListener('click', () => {
190
+
const detail = document.getElementById('detail');
191
+
const collections = apps[namespace];
192
+
193
+
let html = `
194
+
<button class="detail-close" id="detailClose">×</button>
195
+
<h3>${namespace}</h3>
196
+
<div class="subtitle">records stored in your pds:</div>
197
+
`;
198
+
199
+
if (collections && collections.length > 0) {
200
+
// Group collections by sub-namespace (third segment)
201
+
const grouped = {};
202
+
collections.forEach(lexicon => {
203
+
const parts = lexicon.split('.');
204
+
const subNamespace = parts.slice(2).join('.');
205
+
const firstPart = parts[2] || lexicon;
206
+
207
+
if (!grouped[firstPart]) grouped[firstPart] = [];
208
+
grouped[firstPart].push({ lexicon, subNamespace });
209
+
});
210
+
211
+
// Sort and display grouped items
212
+
Object.keys(grouped).sort().forEach(group => {
213
+
const items = grouped[group];
214
+
215
+
if (items.length === 1 && items[0].subNamespace === group) {
216
+
// Single item with no further nesting
217
+
html += `
218
+
<div class="tree-item" data-lexicon="${items[0].lexicon}">
219
+
<div class="tree-item-header">
220
+
<span>${group}</span>
221
+
<span class="tree-item-count">loading...</span>
222
+
</div>
223
+
</div>
224
+
`;
225
+
} else {
226
+
// Group header
227
+
html += `<div style="margin-bottom: 0.75rem;">`;
228
+
html += `<div style="font-size: 0.7rem; color: var(--text-light); margin-bottom: 0.4rem; font-weight: 500;">${group}</div>`;
229
+
230
+
// Items in group
231
+
items.sort((a, b) => a.subNamespace.localeCompare(b.subNamespace)).forEach(item => {
232
+
const displayName = item.subNamespace.split('.').slice(1).join('.') || item.subNamespace;
233
+
html += `
234
+
<div class="tree-item" data-lexicon="${item.lexicon}" style="margin-left: 0.75rem;">
235
+
<div class="tree-item-header">
236
+
<span>${displayName}</span>
237
+
<span class="tree-item-count">loading...</span>
238
+
</div>
239
+
</div>
240
+
`;
241
+
});
242
+
html += `</div>`;
243
+
}
244
+
});
245
+
} else {
246
+
html += `<div class="tree-item">no collections found</div>`;
247
+
}
248
+
249
+
detail.innerHTML = html;
250
+
detail.classList.add('visible');
251
+
252
+
// Add close button handler
253
+
document.getElementById('detailClose').addEventListener('click', (e) => {
254
+
e.stopPropagation();
255
+
detail.classList.remove('visible');
256
+
});
257
+
258
+
// Fetch record counts for each collection
259
+
if (collections && collections.length > 0) {
260
+
collections.forEach(lexicon => {
261
+
fetch(`${globalPds}/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=${lexicon}&limit=1`)
262
+
.then(r => r.json())
263
+
.then(data => {
264
+
const item = detail.querySelector(`[data-lexicon="${lexicon}"]`);
265
+
if (item) {
266
+
const countSpan = item.querySelector('.tree-item-count');
267
+
// The cursor field indicates there are more records
268
+
countSpan.textContent = data.records?.length > 0 ? 'has records' : 'empty';
269
+
}
270
+
})
271
+
.catch(e => {
272
+
console.error('Error fetching count for', lexicon, e);
273
+
const item = detail.querySelector(`[data-lexicon="${lexicon}"]`);
274
+
if (item) {
275
+
const countSpan = item.querySelector('.tree-item-count');
276
+
countSpan.textContent = 'error';
277
+
}
278
+
});
279
+
});
280
+
}
281
+
282
+
// Add click handlers to tree items to fetch actual records
283
+
detail.querySelectorAll('.tree-item[data-lexicon]').forEach(item => {
284
+
item.addEventListener('click', (e) => {
285
+
e.stopPropagation();
286
+
const lexicon = item.dataset.lexicon;
287
+
const existingRecords = item.querySelector('.record-list');
288
+
289
+
if (existingRecords) {
290
+
existingRecords.remove();
291
+
return;
292
+
}
293
+
294
+
const recordListDiv = document.createElement('div');
295
+
recordListDiv.className = 'record-list';
296
+
recordListDiv.innerHTML = '<div class="loading">loading records...</div>';
297
+
item.appendChild(recordListDiv);
298
+
299
+
fetch(`${globalPds}/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=${lexicon}&limit=5`)
300
+
.then(r => r.json())
301
+
.then(data => {
302
+
if (data.records && data.records.length > 0) {
303
+
let recordsHtml = '';
304
+
data.records.forEach((record, idx) => {
305
+
const json = JSON.stringify(record.value, null, 2);
306
+
const recordId = `record-${Date.now()}-${idx}`;
307
+
recordsHtml += `
308
+
<div class="record">
309
+
<div class="record-header">
310
+
<span class="record-label">record</span>
311
+
<button class="copy-btn" data-content="${encodeURIComponent(json)}" data-record-id="${recordId}">copy</button>
312
+
</div>
313
+
<div class="record-content">
314
+
<pre>${json}</pre>
315
+
</div>
316
+
</div>
317
+
`;
318
+
});
319
+
320
+
if (data.cursor && data.records.length === 5) {
321
+
recordsHtml += `<button class="load-more" data-cursor="${data.cursor}" data-lexicon="${lexicon}">load more</button>`;
322
+
}
323
+
324
+
recordListDiv.innerHTML = recordsHtml;
325
+
326
+
// Use event delegation for copy and load more buttons
327
+
recordListDiv.addEventListener('click', (e) => {
328
+
// Handle copy button
329
+
if (e.target.classList.contains('copy-btn')) {
330
+
e.stopPropagation();
331
+
const copyBtn = e.target;
332
+
const content = decodeURIComponent(copyBtn.dataset.content);
333
+
334
+
navigator.clipboard.writeText(content).then(() => {
335
+
const originalText = copyBtn.textContent;
336
+
copyBtn.textContent = 'copied!';
337
+
copyBtn.classList.add('copied');
338
+
setTimeout(() => {
339
+
copyBtn.textContent = originalText;
340
+
copyBtn.classList.remove('copied');
341
+
}, 1500);
342
+
}).catch(err => {
343
+
console.error('Failed to copy:', err);
344
+
copyBtn.textContent = 'error';
345
+
setTimeout(() => {
346
+
copyBtn.textContent = 'copy';
347
+
}, 1500);
348
+
});
349
+
}
350
+
351
+
// Handle load more button
352
+
if (e.target.classList.contains('load-more')) {
353
+
e.stopPropagation();
354
+
const loadMoreBtn = e.target;
355
+
const cursor = loadMoreBtn.dataset.cursor;
356
+
const lexicon = loadMoreBtn.dataset.lexicon;
357
+
358
+
loadMoreBtn.textContent = 'loading...';
359
+
360
+
fetch(`${globalPds}/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=${lexicon}&limit=5&cursor=${cursor}`)
361
+
.then(r => r.json())
362
+
.then(moreData => {
363
+
let moreHtml = '';
364
+
moreData.records.forEach((record, idx) => {
365
+
const json = JSON.stringify(record.value, null, 2);
366
+
const recordId = `record-more-${Date.now()}-${idx}`;
367
+
moreHtml += `
368
+
<div class="record">
369
+
<div class="record-header">
370
+
<span class="record-label">record</span>
371
+
<button class="copy-btn" data-content="${encodeURIComponent(json)}" data-record-id="${recordId}">copy</button>
372
+
</div>
373
+
<div class="record-content">
374
+
<pre>${json}</pre>
375
+
</div>
376
+
</div>
377
+
`;
378
+
});
379
+
380
+
loadMoreBtn.remove();
381
+
recordListDiv.insertAdjacentHTML('beforeend', moreHtml);
382
+
383
+
if (moreData.cursor && moreData.records.length === 5) {
384
+
recordListDiv.insertAdjacentHTML('beforeend',
385
+
`<button class="load-more" data-cursor="${moreData.cursor}" data-lexicon="${lexicon}">load more</button>`
386
+
);
387
+
}
388
+
});
389
+
}
390
+
});
391
+
} else {
392
+
recordListDiv.innerHTML = '<div class="record">no records found</div>';
393
+
}
394
+
})
395
+
.catch(e => {
396
+
console.error('Error fetching records:', e);
397
+
recordListDiv.innerHTML = '<div class="record">error loading records</div>';
398
+
});
399
+
});
400
+
});
401
+
});
402
+
403
+
field.appendChild(div);
404
+
});
405
+
406
+
// Close detail panel when clicking canvas
407
+
const canvas = document.querySelector('.canvas');
408
+
canvas.addEventListener('click', (e) => {
409
+
if (e.target === canvas) {
410
+
document.getElementById('detail').classList.remove('visible');
411
+
}
412
+
});
413
+
})
414
+
.catch(e => {
415
+
document.getElementById('field').innerHTML = 'error loading records';
416
+
console.error(e);
417
+
});
+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();
+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 personal data server (pds).',
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
+
}