···
by <a href="https://bsky.app/profile/zzstoatzz.io" target="_blank" rel="noopener noreferrer">@zzstoatzz.io</a>
198
-
// Check for saved session
199
-
const savedDid = localStorage.getItem('atme_did');
201
-
document.getElementById('loginForm').classList.add('hidden');
202
-
document.getElementById('restoring').classList.remove('hidden');
204
-
fetch('/api/restore-session', {
206
-
headers: { 'Content-Type': 'application/json' },
207
-
body: JSON.stringify({ did: savedDid })
210
-
window.location.href = '/';
212
-
localStorage.removeItem('atme_did');
213
-
document.getElementById('loginForm').classList.remove('hidden');
214
-
document.getElementById('restoring').classList.add('hidden');
217
-
localStorage.removeItem('atme_did');
218
-
document.getElementById('loginForm').classList.remove('hidden');
219
-
document.getElementById('restoring').classList.add('hidden');
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
228
-
const cached = localStorage.getItem(CACHE_KEY);
230
-
const { data, timestamp } = JSON.parse(cached);
231
-
if (Date.now() - timestamp < CACHE_DURATION) {
237
-
const response = await fetch('https://ufos-api.microcosm.blue/collections?order=dids-estimate&limit=50');
238
-
const json = await response.json();
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]) {
254
-
namespaces[ns].dids_total += col.dids_estimate;
255
-
namespaces[ns].records_total += col.creates;
256
-
namespaces[ns].collections.push(col.nsid);
260
-
const data = Object.values(namespaces).sort((a, b) => b.dids_total - a.dids_total).slice(0, 30);
262
-
localStorage.setItem(CACHE_KEY, JSON.stringify({
264
-
timestamp: Date.now()
269
-
console.error('Failed to fetch atmosphere data:', e);
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`];
279
-
for (const handle of handles) {
281
-
const didRes = await fetch(`https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${handle}`);
282
-
if (!didRes.ok) continue;
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;
288
-
const profile = await profileRes.json();
289
-
if (profile.avatar) return profile.avatar;
297
-
// Render atmosphere
298
-
async function renderAtmosphere() {
299
-
const data = await fetchAtmosphere();
300
-
if (!data.length) return;
302
-
const atmosphere = document.getElementById('atmosphere');
303
-
const maxSize = Math.max(...data.map(d => d.dids_total));
305
-
data.forEach((app, i) => {
306
-
const orb = document.createElement('div');
307
-
orb.className = 'app-orb';
309
-
// Size based on user count (20-80px)
310
-
const size = 20 + (app.dids_total / maxSize) * 60;
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;
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)';
329
-
const letter = app.namespace.split('.')[1]?.[0]?.toUpperCase() || app.namespace[0].toUpperCase();
330
-
orb.innerHTML = `<div class="fallback">${letter}</div>`;
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);
341
-
atmosphere.appendChild(orb);
343
-
// Fetch and apply avatar
344
-
fetchAppAvatar(app.namespace).then(avatarUrl => {
346
-
orb.innerHTML = `<img src="${avatarUrl}" alt="${app.namespace}" />`;
347
-
orb.appendChild(tooltip);
353
-
renderAtmosphere();
197
+
<script src="/static/login.js"></script>
···
259
+
top: clamp(1rem, 2vmin, 1.5rem);
260
+
right: clamp(1rem, 2vmin, 1.5rem);
261
+
font-size: clamp(0.65rem, 1.4vmin, 0.75rem);
color: var(--text-light);
border: 1px solid var(--border);
422
-
padding: 0.4rem 0.8rem;
265
+
padding: clamp(0.4rem, 1vmin, 0.5rem) clamp(0.8rem, 2vmin, 1rem);
transition: all 0.2s ease;
-webkit-tap-highlight-color: transparent;
···
border-color: var(--text-light);
436
-
@media (max-width: 768px) {{
438
-
padding: 0.6rem 1rem;
439
-
font-size: 0.75rem;
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);
border: 1px solid var(--border);
456
-
font-size: 0.75rem;
290
+
font-size: clamp(0.7rem, 1.5vmin, 0.85rem);
color: var(--text-light);
transition: all 0.2s ease;
···
border-color: var(--text-light);
470
-
@media (max-width: 768px) {{
474
-
font-size: 0.85rem;
···
background: var(--surface);
border: 2px solid var(--text-light);
397
+
width: clamp(100px, 20vmin, 140px);
398
+
height: clamp(100px, 20vmin, 140px);
403
+
gap: clamp(0.2rem, 1vmin, 0.3rem);
404
+
padding: clamp(0.4rem, 1vmin, 0.6rem);
transition: all 0.2s ease;
···
box-shadow: 0 0 20px rgba(255, 255, 255, 0.1);
592
-
@media (max-width: 768px) {{
418
+
font-size: clamp(1rem, 2vmin, 1.2rem);
425
+
font-size: clamp(0.6rem, 1.2vmin, 0.7rem);
color: var(--text-lighter);
615
-
@media (max-width: 768px) {{
621
-
font-size: 0.65rem;
435
+
font-size: clamp(0.35rem, 0.8vmin, 0.45rem);
color: var(--text-lighter);
441
+
.identity-avatar {{
442
+
width: clamp(30px, 6vmin, 45px);
443
+
height: clamp(30px, 6vmin, 45px);
444
+
border-radius: 50%;
446
+
border: 2px solid var(--text-light);
447
+
margin-bottom: clamp(0.2rem, 1vmin, 0.3rem);
455
+
gap: clamp(0.3rem, 1vmin, 0.5rem);
transition: all 0.2s ease;
···
background: var(--surface-hover);
border: 1px solid var(--border);
471
+
width: clamp(45px, 8vmin, 60px);
472
+
height: clamp(45px, 8vmin, 60px);
transition: all 0.2s ease;
478
+
font-size: clamp(1rem, 2vmin, 1.5rem);
···
674
-
font-size: 0.65rem;
493
+
font-size: clamp(0.6rem, 1.2vmin, 0.7rem);
496
+
max-width: clamp(80px, 15vmin, 120px);
···
724
+
bottom: clamp(0.75rem, 2vmin, 1rem);
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);
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);
914
-
color: var(--text-light);
737
+
color: var(--text);
border-bottom: 1px solid transparent;
917
-
transition: border-color 0.2s ease;
740
+
transition: all 0.2s ease;
921
-
border-bottom-color: var(--text-light);
744
+
border-bottom-color: var(--text);
.loading {{ color: var(--text-light); font-size: 0.75rem; }}
749
+
.onboarding-overlay {{
752
+
background: transparent;
756
+
transition: opacity 0.3s ease;
757
+
pointer-events: none;
760
+
.onboarding-overlay.active {{
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;
774
+
.onboarding-content {{
776
+
background: var(--surface);
777
+
border: 2px solid var(--border);
778
+
padding: clamp(1rem, 3vmin, 2rem);
779
+
max-width: min(400px, 90vw);
781
+
border-radius: 4px;
782
+
transition: all 0.3s ease;
783
+
pointer-events: auto;
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);
793
+
.onboarding-content p {{
794
+
font-size: clamp(0.7rem, 1.5vmin, 0.85rem);
795
+
color: var(--text-light);
797
+
margin-bottom: clamp(1rem, 2vmin, 1.25rem);
800
+
.onboarding-actions {{
802
+
gap: clamp(0.5rem, 1.5vmin, 0.75rem);
803
+
justify-content: flex-end;
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);
814
+
transition: all 0.2s ease;
815
+
border-radius: 2px;
818
+
.onboarding-actions button:hover {{
819
+
background: var(--surface-hover);
820
+
border-color: var(--text-light);
823
+
.onboarding-actions button.primary {{
824
+
background: var(--surface-hover);
825
+
border-color: var(--text-light);
828
+
.onboarding-progress {{
830
+
gap: clamp(0.4rem, 1vmin, 0.5rem);
831
+
justify-content: center;
832
+
margin-top: clamp(0.75rem, 2vmin, 1rem);
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;
843
+
.onboarding-progress span.active {{
844
+
background: var(--text);
847
+
.onboarding-progress span.done {{
848
+
background: var(--text-light);
928
-
<div class="info" id="infoBtn">i</div>
853
+
<div class="info" id="infoBtn">?</div>
<a href="javascript:void(0)" id="logoutBtn" class="logout">logout</a>
<div class="overlay" id="overlay"></div>
···
<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>
<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>
<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>
866
+
<div class="onboarding-overlay" id="onboardingOverlay">
867
+
<div class="onboarding-spotlight" id="onboardingSpotlight"></div>
868
+
<div class="onboarding-content" id="onboardingContent"></div>
···
<a href="https://tangled.org/@zzstoatzz.io/at-me" target="_blank" rel="noopener noreferrer">view source</a>
955
-
localStorage.setItem('atme_did', did);
957
-
let globalPds = null;
958
-
let globalHandle = null;
960
-
// Try to fetch app avatar from their bsky profile
961
-
async function fetchAppAvatar(namespace) {{
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`];
968
-
for (const handle of handles) {{
970
-
const didRes = await fetch(`https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${{handle}}`);
971
-
if (!didRes.ok) continue;
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;
977
-
const profile = await profileRes.json();
978
-
if (profile.avatar) {{
979
-
return profile.avatar;
986
-
console.log('Could not fetch avatar for', namespace);
992
-
document.getElementById('logoutBtn').addEventListener('click', (e) => {{
993
-
e.preventDefault();
994
-
localStorage.removeItem('atme_did');
995
-
window.location.href = '/logout';
998
-
// Info modal handlers
999
-
document.getElementById('infoBtn').addEventListener('click', () => {{
1000
-
document.getElementById('infoModal').classList.add('visible');
1001
-
document.getElementById('overlay').classList.add('visible');
1004
-
document.getElementById('closeInfo').addEventListener('click', () => {{
1005
-
document.getElementById('infoModal').classList.remove('visible');
1006
-
document.getElementById('overlay').classList.remove('visible');
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');
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;
1024
-
globalHandle = handle;
1026
-
// Update identity display with handle
1027
-
document.getElementById('handle').textContent = handle;
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>
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>
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>
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.
1059
-
detail.classList.add('visible');
1061
-
// Add close button handler
1062
-
document.getElementById('detailClose').addEventListener('click', (e) => {{
1063
-
e.stopPropagation();
1064
-
detail.classList.remove('visible');
1068
-
// Get all collections from PDS
1069
-
return fetch(`${{pds}}/xrpc/com.atproto.repo.describeRepo?repo=${{did}}`);
1071
-
.then(r => r.json())
1073
-
const collections = repo.collections || [];
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);
1086
-
const field = document.getElementById('field');
1087
-
field.innerHTML = '';
1088
-
field.classList.remove('loading');
1090
-
const appNames = Object.keys(apps).sort();
1091
-
const radius = 240;
1092
-
const centerX = window.innerWidth / 2;
1093
-
const centerY = window.innerHeight / 2;
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;
1100
-
const div = document.createElement('div');
1101
-
div.className = 'app-view';
1102
-
div.style.left = `${{x}}px`;
1103
-
div.style.top = `${{y}}px`;
1105
-
const firstLetter = namespace.split('.')[1]?.[0]?.toUpperCase() || namespace[0].toUpperCase();
1108
-
<div class="app-circle" data-namespace="${{namespace}}">${{firstLetter}}</div>
1109
-
<div class="app-name">${{namespace}}</div>
1112
-
// Try to fetch and display avatar
1113
-
fetchAppAvatar(namespace).then(avatarUrl => {{
1115
-
const circle = div.querySelector('.app-circle');
1116
-
circle.innerHTML = `<img src="${{avatarUrl}}" class="app-logo" alt="${{namespace}}" />`;
1120
-
div.addEventListener('click', () => {{
1121
-
const detail = document.getElementById('detail');
1122
-
const collections = apps[namespace];
1125
-
<button class="detail-close" id="detailClose">×</button>
1126
-
<h3>${{namespace}}</h3>
1127
-
<div class="subtitle">records stored in your pds:</div>
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;
1138
-
if (!grouped[firstPart]) grouped[firstPart] = [];
1139
-
grouped[firstPart].push({{ lexicon, subNamespace }});
1142
-
// Sort and display grouped items
1143
-
Object.keys(grouped).sort().forEach(group => {{
1144
-
const items = grouped[group];
1146
-
if (items.length === 1 && items[0].subNamespace === group) {{
1147
-
// Single item with no further nesting
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>
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>`;
1162
-
items.sort((a, b) => a.subNamespace.localeCompare(b.subNamespace)).forEach(item => {{
1163
-
const displayName = item.subNamespace.split('.').slice(1).join('.') || item.subNamespace;
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>
1177
-
html += `<div class="tree-item">no collections found</div>`;
1180
-
detail.innerHTML = html;
1181
-
detail.classList.add('visible');
1183
-
// Add close button handler
1184
-
document.getElementById('detailClose').addEventListener('click', (e) => {{
1185
-
e.stopPropagation();
1186
-
detail.classList.remove('visible');
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())
1195
-
const item = detail.querySelector(`[data-lexicon="${{lexicon}}"]`);
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';
1203
-
console.error('Error fetching count for', lexicon, e);
1204
-
const item = detail.querySelector(`[data-lexicon="${{lexicon}}"]`);
1206
-
const countSpan = item.querySelector('.tree-item-count');
1207
-
countSpan.textContent = 'error';
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');
1220
-
if (existingRecords) {{
1221
-
existingRecords.remove();
1225
-
const recordListDiv = document.createElement('div');
1226
-
recordListDiv.className = 'record-list';
1227
-
recordListDiv.innerHTML = '<div class="loading">loading records...</div>';
1228
-
item.appendChild(recordListDiv);
1230
-
fetch(`${{globalPds}}/xrpc/com.atproto.repo.listRecords?repo=${{did}}&collection=${{lexicon}}&limit=5`)
1231
-
.then(r => r.json())
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}}`;
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>
1244
-
<div class="record-content">
1245
-
<pre>${{json}}</pre>
1251
-
if (data.cursor && data.records.length === 5) {{
1252
-
recordsHtml += `<button class="load-more" data-cursor="${{data.cursor}}" data-lexicon="${{lexicon}}">load more</button>`;
1255
-
recordListDiv.innerHTML = recordsHtml;
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);
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');
1273
-
}}).catch(err => {{
1274
-
console.error('Failed to copy:', err);
1275
-
copyBtn.textContent = 'error';
1276
-
setTimeout(() => {{
1277
-
copyBtn.textContent = 'copy';
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;
1289
-
loadMoreBtn.textContent = 'loading...';
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}}`;
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>
1304
-
<div class="record-content">
1305
-
<pre>${{json}}</pre>
1311
-
loadMoreBtn.remove();
1312
-
recordListDiv.insertAdjacentHTML('beforeend', moreHtml);
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>`
1323
-
recordListDiv.innerHTML = '<div class="record">no records found</div>';
1327
-
console.error('Error fetching records:', e);
1328
-
recordListDiv.innerHTML = '<div class="record">error loading records</div>';
1334
-
field.appendChild(div);
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');
1346
-
document.getElementById('field').innerHTML = 'error loading records';
887
+
<script src="/static/app.js"></script>
888
+
<script src="/static/onboarding.js"></script>