atproto user agency toolkit for individuals and groups
7
fork

Configure Feed

Select the types of activity you want to include in your feed.

Improve dashboard UX: account search, profile display, always-on replication

- Remove hasReplicateDids gate so ReplicationManager initializes with
empty DID list (allows adding DIDs via dashboard without pre-config)
- Enrich /oauth/status with profile info (avatar, displayName, handle)
from public Bluesky API
- Show account profile in System Overview and Account Connection cards
- Replace plain DID input with account search typeahead in Replicated
DIDs section (uses public app.bsky.actor.searchActorsTypeahead)
- Show avatar + display name + handle for tracked DIDs (async resolved)
- Rename header from "P2PDS Admin" to "P2PDS"

+447 -39
+31 -2
src/oauth/routes.ts
··· 85 85 86 86 /** 87 87 * Session status endpoint for dashboard polling. 88 + * Includes profile info (avatar, displayName, handle) from public API. 88 89 */ 89 90 app.get("/oauth/status", async (c) => { 90 91 try { 91 92 const hasSession = await pdsClient.hasSession(); 93 + if (!hasSession) { 94 + return c.json({ authenticated: false, did: null }); 95 + } 96 + 97 + const did = config.DID ?? null; 98 + let profile: { displayName?: string; handle?: string; avatar?: string } = {}; 99 + 100 + if (did) { 101 + try { 102 + const res = await fetch( 103 + `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(did)}`, 104 + ); 105 + if (res.ok) { 106 + const data = await res.json() as Record<string, unknown>; 107 + profile = { 108 + displayName: data.displayName as string | undefined, 109 + handle: data.handle as string | undefined, 110 + avatar: data.avatar as string | undefined, 111 + }; 112 + } 113 + } catch { 114 + // Profile fetch is best-effort 115 + } 116 + } 117 + 92 118 return c.json({ 93 - authenticated: hasSession, 94 - did: hasSession ? config.DID : null, 119 + authenticated: true, 120 + did, 121 + displayName: profile.displayName ?? null, 122 + handle: profile.handle ?? null, 123 + avatar: profile.avatar ?? null, 95 124 }); 96 125 } catch { 97 126 return c.json({ authenticated: false, did: null });
+2 -7
src/server.ts
··· 98 98 } 99 99 } 100 100 101 - // Determine if we have DIDs to replicate (from config and/or policies) 102 - const hasReplicateDids = 103 - config.REPLICATE_DIDS.length > 0 || 104 - (policyEngine && policyEngine.getExplicitDids().length > 0); 105 - 106 - // Initialize replication manager and replicated repo reader (if IPFS enabled and DIDs configured) 101 + // Initialize replication manager and replicated repo reader (if IPFS enabled) 107 102 let replicationManager: ReplicationManager | undefined; 108 103 let replicatedRepoReader: ReplicatedRepoReader | undefined; 109 - if (ipfsService && hasReplicateDids && repoManager) { 104 + if (ipfsService && repoManager) { 110 105 replicationManager = new ReplicationManager( 111 106 db, 112 107 config,
+414 -30
src/xrpc/admin.ts
··· 228 228 .did-source-unknown { background: #f3f4f6; color: #6b7280; } 229 229 .add-did-error { color: #ef4444; font-size: 0.82rem; margin-bottom: 0.5rem; min-height: 1.2em; } 230 230 .add-did-success { color: #22c55e; font-size: 0.82rem; margin-bottom: 0.5rem; min-height: 1.2em; } 231 + .account-search-wrap { position: relative; } 232 + .account-search-wrap input { 233 + width: 100%; padding: 0.5rem 0.7rem 0.5rem 2rem; font-family: inherit; font-size: 0.85rem; 234 + border: 1px solid #ccc; border-radius: 4px; outline: none; background: #fff; 235 + } 236 + .account-search-wrap input:focus { border-color: #000; box-shadow: 0 0 0 2px rgba(0,0,0,0.06); } 237 + .account-search-icon { 238 + position: absolute; left: 0.6rem; top: 50%; transform: translateY(-50%); 239 + color: #999; font-size: 0.85rem; pointer-events: none; line-height: 1; 240 + } 241 + .account-results { 242 + position: absolute; top: calc(100% + 4px); left: 0; right: 0; z-index: 100; 243 + background: #fff; border: 1px solid #ddd; border-radius: 6px; 244 + box-shadow: 0 4px 16px rgba(0,0,0,0.12); max-height: 280px; overflow-y: auto; 245 + display: none; 246 + } 247 + .account-results.visible { display: block; } 248 + .account-result-item { 249 + display: flex; align-items: center; gap: 0.6rem; padding: 0.5rem 0.7rem; 250 + cursor: pointer; border-bottom: 1px solid #f0f0f0; transition: background 0.1s; 251 + } 252 + .account-result-item:last-child { border-bottom: none; } 253 + .account-result-item:hover, .account-result-item.active { background: #f5f5f5; } 254 + .account-result-avatar { 255 + width: 32px; height: 32px; border-radius: 50%; background: #e5e7eb; 256 + flex-shrink: 0; object-fit: cover; 257 + } 258 + .account-result-avatar-placeholder { 259 + width: 32px; height: 32px; border-radius: 50%; background: #e5e7eb; 260 + flex-shrink: 0; display: flex; align-items: center; justify-content: center; 261 + color: #9ca3af; font-size: 0.75rem; font-weight: 600; 262 + } 263 + .account-result-info { flex: 1; min-width: 0; } 264 + .account-result-name { font-size: 0.85rem; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } 265 + .account-result-handle { font-size: 0.75rem; color: #666; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } 266 + .account-selected { 267 + display: flex; align-items: center; gap: 0.7rem; padding: 0.6rem 0.8rem; 268 + background: #f8f8f8; border: 1px solid #ddd; border-radius: 6px; margin-bottom: 0.6rem; 269 + } 270 + .account-selected-info { flex: 1; min-width: 0; } 271 + .account-selected-name { font-size: 0.9rem; font-weight: 600; } 272 + .account-selected-handle { font-size: 0.8rem; color: #666; } 273 + .account-selected-clear { 274 + padding: 0.2rem 0.5rem; font-family: inherit; font-size: 0.75rem; 275 + border: 1px solid #ccc; border-radius: 4px; cursor: pointer; background: #fff; color: #666; 276 + flex-shrink: 0; 277 + } 278 + .account-selected-clear:hover { border-color: #999; color: #000; } 279 + .account-connect-btn { 280 + padding: 0.5rem 1.2rem; font-family: inherit; font-size: 0.85rem; 281 + border: 1px solid #000; border-radius: 4px; cursor: pointer; background: #000; color: #fff; 282 + font-weight: 600; transition: opacity 0.15s; 283 + } 284 + .account-connect-btn:hover { opacity: 0.85; } 285 + .account-connect-btn:disabled { opacity: 0.35; cursor: not-allowed; } 286 + .account-no-results { padding: 0.7rem; text-align: center; color: #999; font-size: 0.82rem; } 287 + .account-searching { padding: 0.7rem; text-align: center; color: #999; font-size: 0.82rem; } 231 288 </style> 232 289 </head> 233 290 <body> 234 291 <header> 235 - <h1>P2PDS Admin</h1> 292 + <h1>P2PDS</h1> 236 293 <span class="badge" id="version-badge">v-</span> 237 294 <div class="meta"> 238 295 <span id="last-refresh">-</span> ··· 258 315 <section class="card" id="section-replication"> 259 316 <h2>Replicated DIDs</h2> 260 317 <div class="add-did-form"> 261 - <input type="text" id="add-did-input" placeholder="did:plc:..." autocomplete="off"> 262 - <button id="add-did-btn">Add DID</button> 318 + <div class="account-search-wrap" id="did-search-wrap" style="flex:1"> 319 + <span class="account-search-icon">&#128269;</span> 320 + <input type="text" id="add-did-input" placeholder="Search account or paste did:plc:..." autocomplete="off" spellcheck="false"> 321 + <div class="account-results" id="did-search-results"></div> 322 + </div> 323 + <button id="add-did-btn">Add</button> 263 324 </div> 325 + <div id="add-did-selected" style="display:none;margin-bottom:0.5rem"></div> 264 326 <div id="add-did-msg" class="add-did-error"></div> 265 327 <div id="replication-content" class="loading">Loading...</div> 266 328 </section> ··· 349 411 return res.json(); 350 412 } 351 413 414 + var cachedAccountStatus = null; 415 + 352 416 function renderOverview(data) { 353 417 const el = document.getElementById("overview-content"); 354 418 const net = data.network || {}; 355 419 const fh = data.firehose || {}; 420 + var accountHtml = ""; 421 + if (cachedAccountStatus && cachedAccountStatus.authenticated) { 422 + var a = cachedAccountStatus; 423 + var avatarHtml = a.avatar 424 + ? '<img src="' + esc(a.avatar) + '" alt="" style="width:32px;height:32px;border-radius:50%;vertical-align:middle;margin-right:0.5rem">' 425 + : ''; 426 + accountHtml = '<dt>Account</dt><dd>' 427 + + avatarHtml 428 + + '<strong>' + esc(a.displayName || a.handle || a.did) + '</strong>' 429 + + (a.handle ? ' <span style="color:#666">@' + esc(a.handle) + '</span>' : '') 430 + + '</dd>'; 431 + } else { 432 + accountHtml = '<dt>Account</dt><dd style="color:#999">Not connected</dd>'; 433 + } 356 434 el.innerHTML = '<dl class="kv">' 435 + + accountHtml 357 436 + "<dt>DID</dt><dd>" + esc(data.did) + "</dd>" 358 437 + "<dt>Version</dt><dd>" + esc(data.version) + "</dd>" 359 438 + "<dt>Peer ID</dt><dd>" + esc(net.peerId) + "</dd>" ··· 380 459 + '</div>'; 381 460 } 382 461 462 + var profileCache = {}; 463 + 464 + function fetchProfile(did) { 465 + if (profileCache[did]) return Promise.resolve(profileCache[did]); 466 + return fetch("https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=" + encodeURIComponent(did)) 467 + .then(function(r) { return r.ok ? r.json() : null; }) 468 + .then(function(p) { if (p) profileCache[did] = p; return p; }) 469 + .catch(function() { return null; }); 470 + } 471 + 472 + function renderAccountCell(did, profile) { 473 + if (!profile) return esc(did); 474 + var av = profile.avatar 475 + ? '<img src="' + esc(profile.avatar) + '" alt="" style="width:24px;height:24px;border-radius:50%;vertical-align:middle;margin-right:0.4rem">' 476 + : '<span style="display:inline-block;width:24px;height:24px;border-radius:50%;background:#e0e0e0;text-align:center;line-height:24px;font-size:0.7rem;vertical-align:middle;margin-right:0.4rem">' 477 + + esc((profile.handle || did)[0].toUpperCase()) + '</span>'; 478 + return av 479 + + '<strong style="font-size:0.85rem">' + esc(profile.displayName || profile.handle) + '</strong>' 480 + + ' <span style="color:#666;font-size:0.8rem">@' + esc(profile.handle) + '</span>' 481 + + '<div style="color:#999;font-size:0.7rem;margin-top:1px">' + esc(did) + '</div>'; 482 + } 483 + 383 484 function renderReplication(data) { 384 485 const el = document.getElementById("replication-content"); 385 486 const repl = data.replication; ··· 387 488 const states = repl.syncStates || []; 388 489 const sources = repl.didSources || {}; 389 490 if (states.length === 0) { el.innerHTML = "No tracked DIDs"; return; } 390 - let html = "<table><thead><tr><th>DID</th><th>Source</th><th>Status</th><th>Last Sync</th><th>Error</th><th></th></tr></thead><tbody>"; 491 + let html = "<table><thead><tr><th>Account</th><th>Source</th><th>Status</th><th>Last Sync</th><th>Error</th><th></th></tr></thead><tbody>"; 391 492 for (const s of states) { 392 493 const st = s.status || "pending"; 393 494 const src = sources[s.did] || "unknown"; 394 495 const rid = "detail-" + s.did.replace(/[^a-zA-Z0-9]/g, "_"); 496 + var cellId = "acct-" + s.did.replace(/[^a-zA-Z0-9]/g, "_"); 395 497 html += '<tr class="clickable" data-did="' + esc(s.did) + '" data-rid="' + rid + '">' 396 - + "<td>" + esc(s.did) + "</td>" 498 + + '<td id="' + cellId + '" style="min-width:200px">' + esc(s.did) + '</td>' 397 499 + "<td>" + didSourceBadge(src) + "</td>" 398 500 + "<td>" + statusDot(st) + "</td>" 399 501 + "<td>" + timeAgo(s.lastSyncAt) + "</td>" ··· 404 506 } 405 507 html += "</tbody></table>"; 406 508 el.innerHTML = html; 509 + 510 + // Async profile resolution for each DID row 511 + for (const s of states) { 512 + (function(did) { 513 + var cellId = "acct-" + did.replace(/[^a-zA-Z0-9]/g, "_"); 514 + fetchProfile(did).then(function(p) { 515 + var cell = document.getElementById(cellId); 516 + if (cell && p) cell.innerHTML = renderAccountCell(did, p); 517 + }); 518 + })(s.did); 519 + } 407 520 408 521 el.querySelectorAll("tr.clickable").forEach(function(row) { 409 522 row.addEventListener("click", async function() { ··· 529 642 el.innerHTML = html; 530 643 } 531 644 645 + var accountSearchState = { selectedHandle: null, selectedActor: null, activeIndex: -1 }; 646 + var accountSearchTimer = null; 647 + 532 648 async function refreshAccount() { 533 649 var el = document.getElementById("account-content"); 534 650 try { 535 651 var res = await fetch("/oauth/status"); 536 652 if (!res.ok) { el.innerHTML = '<span style="color:#999">OAuth not enabled</span>'; return; } 537 653 var data = await res.json(); 654 + cachedAccountStatus = data; 538 655 if (data.authenticated) { 539 - el.innerHTML = '<dl class="kv">' 540 - + '<dt>Status</dt><dd><span class="dot dot-synced"></span>Connected</dd>' 541 - + '<dt>DID</dt><dd>' + esc(data.did) + '</dd>' 542 - + '</dl>'; 656 + var avatarHtml = data.avatar 657 + ? '<img src="' + esc(data.avatar) + '" alt="" style="width:48px;height:48px;border-radius:50%;margin-right:1rem">' 658 + : '<div style="width:48px;height:48px;border-radius:50%;background:#e0e0e0;display:flex;align-items:center;justify-content:center;margin-right:1rem;font-size:1.2rem;font-weight:600">' 659 + + esc((data.handle || data.did || "?")[0].toUpperCase()) + '</div>'; 660 + el.innerHTML = '<div style="display:flex;align-items:center;margin-bottom:0.75rem">' 661 + + avatarHtml 662 + + '<div>' 663 + + '<div style="font-weight:600;font-size:1rem">' + esc(data.displayName || data.handle || "Connected") + '</div>' 664 + + (data.handle ? '<div style="color:#666;font-size:0.85rem">@' + esc(data.handle) + '</div>' : '') 665 + + '<div style="color:#999;font-size:0.75rem;margin-top:0.15rem">' + esc(data.did) + '</div>' 666 + + '</div>' 667 + + '</div>' 668 + + '<span class="dot dot-synced"></span> Authenticated'; 543 669 } else { 544 - el.innerHTML = '<div class="add-did-form">' 545 - + '<input type="text" id="oauth-handle" placeholder="handle (e.g. alice.bsky.social)" autocomplete="off">' 546 - + '<button id="oauth-connect-btn">Connect Account</button>' 670 + accountSearchState = { selectedHandle: null, selectedActor: null, activeIndex: -1 }; 671 + el.innerHTML = '<div id="account-search-container">' 672 + + '<div id="account-selected-display" style="display:none"></div>' 673 + + '<div class="account-search-wrap" id="account-search-wrap">' 674 + + '<span class="account-search-icon">&#128269;</span>' 675 + + '<input type="text" id="account-search-input" placeholder="Search for a Bluesky account..." autocomplete="off" spellcheck="false">' 676 + + '<div class="account-results" id="account-results"></div>' 547 677 + '</div>' 548 - + '<div style="font-size:0.8rem;color:#666;margin-top:0.3rem">Authenticate with your AT Protocol account to publish records to your PDS.</div>'; 549 - document.getElementById("oauth-connect-btn").addEventListener("click", function() { 550 - var handle = document.getElementById("oauth-handle").value.trim(); 551 - if (!handle) return; 552 - window.location.href = "/oauth/login?handle=" + encodeURIComponent(handle); 553 - }); 554 - document.getElementById("oauth-handle").addEventListener("keydown", function(e) { 555 - if (e.key === "Enter") document.getElementById("oauth-connect-btn").click(); 556 - }); 678 + + '<div style="margin-top:0.6rem">' 679 + + '<button class="account-connect-btn" id="account-connect-btn" disabled>Connect Account</button>' 680 + + '</div>' 681 + + '</div>' 682 + + '<div style="font-size:0.8rem;color:#666;margin-top:0.5rem">Search for your Bluesky account to authenticate via OAuth and publish records to your PDS.</div>'; 683 + setupAccountSearch(); 557 684 } 558 685 } catch (e) { 559 686 el.innerHTML = '<span style="color:#999">OAuth not enabled</span>'; 560 687 } 561 688 } 562 689 690 + function setupAccountSearch() { 691 + var input = document.getElementById("account-search-input"); 692 + var results = document.getElementById("account-results"); 693 + var connectBtn = document.getElementById("account-connect-btn"); 694 + 695 + input.addEventListener("input", function() { 696 + var q = this.value.trim(); 697 + accountSearchState.activeIndex = -1; 698 + if (q.length < 2) { 699 + results.classList.remove("visible"); 700 + results.innerHTML = ""; 701 + return; 702 + } 703 + if (accountSearchTimer) clearTimeout(accountSearchTimer); 704 + accountSearchTimer = setTimeout(function() { searchAccounts(q); }, 250); 705 + }); 706 + 707 + input.addEventListener("keydown", function(e) { 708 + var items = results.querySelectorAll(".account-result-item"); 709 + if (e.key === "ArrowDown") { 710 + e.preventDefault(); 711 + accountSearchState.activeIndex = Math.min(accountSearchState.activeIndex + 1, items.length - 1); 712 + updateActiveResult(items); 713 + } else if (e.key === "ArrowUp") { 714 + e.preventDefault(); 715 + accountSearchState.activeIndex = Math.max(accountSearchState.activeIndex - 1, 0); 716 + updateActiveResult(items); 717 + } else if (e.key === "Enter") { 718 + e.preventDefault(); 719 + if (accountSearchState.activeIndex >= 0 && items[accountSearchState.activeIndex]) { 720 + items[accountSearchState.activeIndex].click(); 721 + } else if (accountSearchState.selectedHandle) { 722 + connectBtn.click(); 723 + } else { 724 + // If text looks like a handle, allow direct entry 725 + var val = input.value.trim(); 726 + if (val && val.includes(".")) { 727 + selectAccount({ handle: val, displayName: null, avatar: null, did: null }); 728 + } 729 + } 730 + } else if (e.key === "Escape") { 731 + results.classList.remove("visible"); 732 + accountSearchState.activeIndex = -1; 733 + } 734 + }); 735 + 736 + input.addEventListener("focus", function() { 737 + if (results.children.length > 0) results.classList.add("visible"); 738 + }); 739 + 740 + document.addEventListener("click", function(e) { 741 + if (!e.target.closest("#account-search-container")) { 742 + results.classList.remove("visible"); 743 + } 744 + }); 745 + 746 + connectBtn.addEventListener("click", function() { 747 + var handle = accountSearchState.selectedHandle; 748 + if (!handle) return; 749 + window.location.href = "/oauth/login?handle=" + encodeURIComponent(handle); 750 + }); 751 + } 752 + 753 + function updateActiveResult(items) { 754 + for (var i = 0; i < items.length; i++) { 755 + items[i].classList.toggle("active", i === accountSearchState.activeIndex); 756 + } 757 + if (accountSearchState.activeIndex >= 0 && items[accountSearchState.activeIndex]) { 758 + items[accountSearchState.activeIndex].scrollIntoView({ block: "nearest" }); 759 + } 760 + } 761 + 762 + async function searchAccounts(query) { 763 + var results = document.getElementById("account-results"); 764 + results.innerHTML = '<div class="account-searching">Searching...</div>'; 765 + results.classList.add("visible"); 766 + try { 767 + var url = "https://public.api.bsky.app/xrpc/app.bsky.actor.searchActorsTypeahead?q=" 768 + + encodeURIComponent(query) + "&limit=8"; 769 + var res = await fetch(url); 770 + if (!res.ok) throw new Error("Search failed"); 771 + var data = await res.json(); 772 + var actors = data.actors || []; 773 + if (actors.length === 0) { 774 + results.innerHTML = '<div class="account-no-results">No accounts found</div>'; 775 + return; 776 + } 777 + var html = ""; 778 + for (var i = 0; i < actors.length; i++) { 779 + var a = actors[i]; 780 + var avatarHtml = a.avatar 781 + ? '<img class="account-result-avatar" src="' + esc(a.avatar) + '" alt="" loading="lazy">' 782 + : '<div class="account-result-avatar-placeholder">' + esc((a.handle || "?")[0].toUpperCase()) + '</div>'; 783 + html += '<div class="account-result-item" data-index="' + i + '" ' 784 + + 'data-handle="' + esc(a.handle) + '" ' 785 + + 'data-did="' + esc(a.did) + '" ' 786 + + 'data-displayname="' + esc(a.displayName || "") + '" ' 787 + + 'data-avatar="' + esc(a.avatar || "") + '">' 788 + + avatarHtml 789 + + '<div class="account-result-info">' 790 + + '<div class="account-result-name">' + esc(a.displayName || a.handle) + '</div>' 791 + + '<div class="account-result-handle">@' + esc(a.handle) + '</div>' 792 + + '</div>' 793 + + '</div>'; 794 + } 795 + results.innerHTML = html; 796 + accountSearchState.activeIndex = -1; 797 + results.querySelectorAll(".account-result-item").forEach(function(item) { 798 + item.addEventListener("click", function() { 799 + selectAccount({ 800 + handle: this.dataset.handle, 801 + did: this.dataset.did, 802 + displayName: this.dataset.displayname || null, 803 + avatar: this.dataset.avatar || null 804 + }); 805 + }); 806 + item.addEventListener("mouseenter", function() { 807 + var idx = parseInt(this.dataset.index, 10); 808 + accountSearchState.activeIndex = idx; 809 + updateActiveResult(results.querySelectorAll(".account-result-item")); 810 + }); 811 + }); 812 + } catch (e) { 813 + results.innerHTML = '<div class="account-no-results">Search error. You can type a handle directly and press Enter.</div>'; 814 + } 815 + } 816 + 817 + function selectAccount(actor) { 818 + accountSearchState.selectedHandle = actor.handle; 819 + accountSearchState.selectedActor = actor; 820 + var results = document.getElementById("account-results"); 821 + results.classList.remove("visible"); 822 + results.innerHTML = ""; 823 + var searchWrap = document.getElementById("account-search-wrap"); 824 + searchWrap.style.display = "none"; 825 + var display = document.getElementById("account-selected-display"); 826 + var avatarHtml = actor.avatar 827 + ? '<img class="account-result-avatar" src="' + esc(actor.avatar) + '" alt="">' 828 + : '<div class="account-result-avatar-placeholder">' + esc((actor.handle || "?")[0].toUpperCase()) + '</div>'; 829 + display.innerHTML = '<div class="account-selected">' 830 + + avatarHtml 831 + + '<div class="account-selected-info">' 832 + + '<div class="account-selected-name">' + esc(actor.displayName || actor.handle) + '</div>' 833 + + '<div class="account-selected-handle">@' + esc(actor.handle) + (actor.did ? ' &middot; ' + esc(actor.did) : '') + '</div>' 834 + + '</div>' 835 + + '<button class="account-selected-clear" id="account-clear-btn">Change</button>' 836 + + '</div>'; 837 + display.style.display = "block"; 838 + document.getElementById("account-connect-btn").disabled = false; 839 + document.getElementById("account-clear-btn").addEventListener("click", function() { 840 + accountSearchState.selectedHandle = null; 841 + accountSearchState.selectedActor = null; 842 + display.style.display = "none"; 843 + display.innerHTML = ""; 844 + searchWrap.style.display = ""; 845 + document.getElementById("account-connect-btn").disabled = true; 846 + var input = document.getElementById("account-search-input"); 847 + input.value = ""; 848 + input.focus(); 849 + }); 850 + } 851 + 563 852 async function refresh() { 564 853 try { 565 854 const [overview, network, policies, syncHistory] = await Promise.all([ ··· 568 857 apiFetch("org.p2pds.admin.getPolicies"), 569 858 apiFetch("org.p2pds.admin.getSyncHistory", { limit: "20" }), 570 859 ]); 860 + await refreshAccount(); 571 861 renderOverview(overview); 572 - refreshAccount(); 573 862 renderMetrics(overview); 574 863 renderReplication(overview); 575 864 renderSyncHistory(syncHistory); ··· 590 879 591 880 refresh(); 592 881 593 - // Add DID button handler 594 - document.getElementById("add-did-btn").addEventListener("click", async function() { 882 + // Add DID — account search widget 883 + var didSearchState = { selectedDid: null, activeIndex: -1 }; 884 + var didSearchTimer = null; 885 + 886 + async function addDidSubmit() { 595 887 var input = document.getElementById("add-did-input"); 596 888 var msgEl = document.getElementById("add-did-msg"); 597 - var did = input.value.trim(); 598 - if (!did) { msgEl.className = "add-did-error"; msgEl.textContent = "Enter a DID"; return; } 889 + var selectedEl = document.getElementById("add-did-selected"); 890 + var did = didSearchState.selectedDid || input.value.trim(); 891 + if (!did) { msgEl.className = "add-did-error"; msgEl.textContent = "Search for an account or paste a DID"; return; } 599 892 msgEl.className = ""; msgEl.textContent = ""; 600 893 try { 601 894 var result = await apiPost("org.p2pds.admin.addDid", { did: did }); ··· 607 900 ? did + " already tracked (source: " + result.source + ")" 608 901 : "Added " + did; 609 902 input.value = ""; 903 + didSearchState.selectedDid = null; 904 + selectedEl.style.display = "none"; 905 + selectedEl.innerHTML = ""; 906 + document.getElementById("did-search-wrap").style.display = ""; 610 907 refresh(); 611 908 } 612 909 } catch (err) { msgEl.className = "add-did-error"; msgEl.textContent = "Error: " + err.message; } 613 - }); 910 + } 911 + 912 + function didSelectActor(actor) { 913 + didSearchState.selectedDid = actor.did; 914 + didSearchState.activeIndex = -1; 915 + var results = document.getElementById("did-search-results"); 916 + results.classList.remove("visible"); 917 + results.innerHTML = ""; 918 + document.getElementById("did-search-wrap").style.display = "none"; 919 + var selectedEl = document.getElementById("add-did-selected"); 920 + var avatarHtml = actor.avatar 921 + ? '<img class="account-result-avatar" src="' + esc(actor.avatar) + '" alt="">' 922 + : '<div class="account-result-avatar-placeholder">' + esc((actor.handle || "?")[0].toUpperCase()) + '</div>'; 923 + selectedEl.innerHTML = '<div class="account-selected">' 924 + + avatarHtml 925 + + '<div class="account-selected-info">' 926 + + '<div class="account-selected-name">' + esc(actor.displayName || actor.handle) + '</div>' 927 + + '<div class="account-selected-handle">@' + esc(actor.handle) + (actor.did ? ' &middot; ' + esc(actor.did) : '') + '</div>' 928 + + '</div>' 929 + + '<button class="account-selected-clear" id="did-clear-btn">Change</button>' 930 + + '</div>'; 931 + selectedEl.style.display = "block"; 932 + document.getElementById("did-clear-btn").addEventListener("click", function() { 933 + didSearchState.selectedDid = null; 934 + selectedEl.style.display = "none"; 935 + selectedEl.innerHTML = ""; 936 + document.getElementById("did-search-wrap").style.display = ""; 937 + document.getElementById("add-did-input").value = ""; 938 + document.getElementById("add-did-input").focus(); 939 + }); 940 + } 941 + 942 + (function setupDidSearch() { 943 + var input = document.getElementById("add-did-input"); 944 + var results = document.getElementById("did-search-results"); 945 + 946 + input.addEventListener("input", function() { 947 + var q = this.value.trim(); 948 + didSearchState.activeIndex = -1; 949 + if (q.length < 2 || q.startsWith("did:")) { 950 + results.classList.remove("visible"); 951 + results.innerHTML = ""; 952 + return; 953 + } 954 + if (didSearchTimer) clearTimeout(didSearchTimer); 955 + didSearchTimer = setTimeout(function() { 956 + results.innerHTML = '<div class="account-searching">Searching...</div>'; 957 + results.classList.add("visible"); 958 + fetch("https://public.api.bsky.app/xrpc/app.bsky.actor.searchActorsTypeahead?q=" + encodeURIComponent(q) + "&limit=6") 959 + .then(function(r) { return r.ok ? r.json() : { actors: [] }; }) 960 + .then(function(data) { 961 + var actors = data.actors || []; 962 + if (actors.length === 0) { results.innerHTML = '<div class="account-no-results">No accounts found</div>'; return; } 963 + var html = ""; 964 + for (var i = 0; i < actors.length; i++) { 965 + var a = actors[i]; 966 + var avHtml = a.avatar 967 + ? '<img class="account-result-avatar" src="' + esc(a.avatar) + '" alt="" loading="lazy">' 968 + : '<div class="account-result-avatar-placeholder">' + esc((a.handle || "?")[0].toUpperCase()) + '</div>'; 969 + html += '<div class="account-result-item" data-index="' + i + '" data-handle="' + esc(a.handle) + '" data-did="' + esc(a.did) + '" data-displayname="' + esc(a.displayName || "") + '" data-avatar="' + esc(a.avatar || "") + '">' 970 + + avHtml + '<div class="account-result-info"><div class="account-result-name">' + esc(a.displayName || a.handle) + '</div><div class="account-result-handle">@' + esc(a.handle) + '</div></div></div>'; 971 + } 972 + results.innerHTML = html; 973 + didSearchState.activeIndex = -1; 974 + results.querySelectorAll(".account-result-item").forEach(function(item) { 975 + item.addEventListener("click", function() { 976 + didSelectActor({ handle: this.dataset.handle, did: this.dataset.did, displayName: this.dataset.displayname || null, avatar: this.dataset.avatar || null }); 977 + }); 978 + item.addEventListener("mouseenter", function() { 979 + didSearchState.activeIndex = parseInt(this.dataset.index, 10); 980 + var items = results.querySelectorAll(".account-result-item"); 981 + for (var j = 0; j < items.length; j++) items[j].classList.toggle("active", j === didSearchState.activeIndex); 982 + }); 983 + }); 984 + }) 985 + .catch(function() { results.innerHTML = '<div class="account-no-results">Search error</div>'; }); 986 + }, 250); 987 + }); 988 + 989 + input.addEventListener("keydown", function(e) { 990 + var items = results.querySelectorAll(".account-result-item"); 991 + if (e.key === "ArrowDown") { e.preventDefault(); didSearchState.activeIndex = Math.min(didSearchState.activeIndex + 1, items.length - 1); for (var i = 0; i < items.length; i++) items[i].classList.toggle("active", i === didSearchState.activeIndex); if (items[didSearchState.activeIndex]) items[didSearchState.activeIndex].scrollIntoView({ block: "nearest" }); } 992 + else if (e.key === "ArrowUp") { e.preventDefault(); didSearchState.activeIndex = Math.max(didSearchState.activeIndex - 1, 0); for (var i = 0; i < items.length; i++) items[i].classList.toggle("active", i === didSearchState.activeIndex); } 993 + else if (e.key === "Enter") { e.preventDefault(); if (didSearchState.activeIndex >= 0 && items[didSearchState.activeIndex]) { items[didSearchState.activeIndex].click(); } else { addDidSubmit(); } } 994 + else if (e.key === "Escape") { results.classList.remove("visible"); didSearchState.activeIndex = -1; } 995 + }); 996 + 997 + input.addEventListener("focus", function() { if (results.children.length > 0 && !input.value.trim().startsWith("did:")) results.classList.add("visible"); }); 998 + document.addEventListener("click", function(e) { if (!e.target.closest("#did-search-wrap") && !e.target.closest("#add-did-selected")) results.classList.remove("visible"); }); 999 + })(); 614 1000 615 - document.getElementById("add-did-input").addEventListener("keydown", function(e) { 616 - if (e.key === "Enter") document.getElementById("add-did-btn").click(); 617 - }); 1001 + document.getElementById("add-did-btn").addEventListener("click", addDidSubmit); 618 1002 </script> 619 1003 </body> 620 1004 </html>`;