|
| 1 | +<!DOCTYPE html> |
| 2 | +<html lang="en"> |
| 3 | +<head> |
| 4 | +<meta charset="UTF-8"> |
| 5 | +<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| 6 | +<title>Beacon · a directory of did:nostr identities</title> |
| 7 | +<meta name="description" content="A living directory of did:nostr identities — profiles, the follow graph, and a verifiable DID document for every key."> |
| 8 | +<link rel="preconnect" href="https://fonts.googleapis.com"> |
| 9 | +<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> |
| 10 | +<link href="https://fonts.googleapis.com/css2?family=Newsreader:opsz,wght@6..72,400..600&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet"> |
| 11 | +<meta name="theme-color" content="#FBFAF8"> |
| 12 | +<style> |
| 13 | + :root{ |
| 14 | + --bg:#FBFAF8; --surface:#fff; --ink:#1a1a23; --muted:#65656f; --faint:#9a9aa6; |
| 15 | + --line:#ECEAE3; --accent:#6d5efc; --accent2:#22b8cf; --accent-soft:#efecff; |
| 16 | + --serif:'Newsreader',Georgia,serif; --sans:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif; --mono:'JetBrains Mono',ui-monospace,monospace; |
| 17 | + } |
| 18 | + *{margin:0;padding:0;box-sizing:border-box} |
| 19 | + body{background:var(--bg);color:var(--ink);font-family:var(--sans);line-height:1.6;-webkit-font-smoothing:antialiased} |
| 20 | + ::selection{background:var(--accent-soft)} |
| 21 | + a{color:inherit;text-decoration:none} |
| 22 | + main{max-width:1040px;margin:0 auto;padding:0 1.2rem 5rem} |
| 23 | + |
| 24 | + /* hero */ |
| 25 | + .hero{position:relative;text-align:center;padding:5rem 1rem 3rem;overflow:hidden} |
| 26 | + .hero::before{content:"";position:absolute;inset:-40% 0 auto;height:420px;z-index:0; |
| 27 | + background:radial-gradient(40% 60% at 30% 20%,rgba(109,94,252,.22),transparent 70%), |
| 28 | + radial-gradient(45% 55% at 75% 30%,rgba(34,184,207,.18),transparent 70%); |
| 29 | + filter:blur(8px)} |
| 30 | + .hero>*{position:relative;z-index:1} |
| 31 | + .mark{display:inline-flex;align-items:center;gap:.5rem;font-weight:700;letter-spacing:-.01em;font-size:.95rem;color:var(--muted)} |
| 32 | + .dot{width:12px;height:12px;border-radius:50%;background:radial-gradient(circle at 35% 35%,#fff,var(--accent));box-shadow:0 0 18px 2px rgba(109,94,252,.55)} |
| 33 | + h1{font-family:var(--serif);font-size:clamp(2.6rem,6vw,4rem);font-weight:600;letter-spacing:-.02em;line-height:1.04;margin:1.2rem 0 .6rem} |
| 34 | + .hero p{color:var(--muted);font-size:1.12rem;max-width:30rem;margin:0 auto} |
| 35 | + .hero .stat{margin-top:1.4rem;font-family:var(--mono);font-size:.8rem;color:var(--faint)} |
| 36 | + |
| 37 | + /* grid of profiles */ |
| 38 | + .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:1rem;margin-top:1rem} |
| 39 | + .card{background:var(--surface);border:1px solid var(--line);border-radius:16px;padding:1.2rem;cursor:pointer; |
| 40 | + transition:transform .18s ease,box-shadow .18s ease,border-color .18s;box-shadow:0 1px 2px rgba(20,20,30,.04)} |
| 41 | + .card:hover{transform:translateY(-3px);box-shadow:0 12px 30px rgba(80,70,160,.13);border-color:#dcd8f5} |
| 42 | + .av{width:56px;height:56px;border-radius:50%;object-fit:cover;background:var(--accent-soft);display:grid;place-items:center; |
| 43 | + font-weight:700;color:var(--accent);font-size:1.3rem;overflow:hidden} |
| 44 | + .card .name{font-family:var(--serif);font-size:1.25rem;font-weight:600;margin:.7rem 0 .1rem;line-height:1.2} |
| 45 | + .card .nip05{color:var(--accent);font-size:.82rem;font-weight:500} |
| 46 | + .card .about{color:var(--muted);font-size:.9rem;margin-top:.5rem;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden} |
| 47 | + .card .did{font-family:var(--mono);font-size:.7rem;color:var(--faint);margin-top:.7rem} |
| 48 | + |
| 49 | + /* detail */ |
| 50 | + .detail{padding-top:2rem} |
| 51 | + .back{display:inline-flex;align-items:center;gap:.4rem;color:var(--muted);font-size:.88rem;font-weight:500;margin-bottom:1.6rem;cursor:pointer} |
| 52 | + .back:hover{color:var(--accent)} |
| 53 | + .phead{display:flex;gap:1.3rem;align-items:flex-start} |
| 54 | + .phead .av{width:88px;height:88px;font-size:2rem;flex:none} |
| 55 | + .phead h2{font-family:var(--serif);font-size:2.1rem;font-weight:600;line-height:1.1} |
| 56 | + .phead .nip05{color:var(--accent);font-weight:500} |
| 57 | + .phead .about{color:var(--muted);margin-top:.5rem;max-width:42rem} |
| 58 | + .chips{margin-top:.7rem;display:flex;gap:.5rem;flex-wrap:wrap} |
| 59 | + .chip{font-size:.78rem;font-weight:500;background:var(--accent-soft);color:var(--accent);padding:.25rem .7rem;border-radius:999px} |
| 60 | + .didline{font-family:var(--mono);font-size:.8rem;color:var(--muted);background:#fff;border:1px solid var(--line);border-radius:10px;padding:.55rem .75rem;margin-top:1rem;word-break:break-all} |
| 61 | + .sections{display:grid;grid-template-columns:1fr;gap:1.4rem;margin-top:2rem} |
| 62 | + @media(min-width:760px){.sections{grid-template-columns:1.1fr 1fr}} |
| 63 | + .panel{background:var(--surface);border:1px solid var(--line);border-radius:16px;padding:1.2rem 1.3rem;box-shadow:0 1px 2px rgba(20,20,30,.04)} |
| 64 | + .panel h3{font-size:.74rem;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:var(--faint);margin-bottom:.9rem} |
| 65 | + .follows{display:flex;flex-wrap:wrap;gap:.5rem} |
| 66 | + .follows .fav{width:34px;height:34px;border-radius:50%;background:var(--accent-soft);object-fit:cover} |
| 67 | + pre{font-family:var(--mono);font-size:.74rem;line-height:1.55;color:#2a2a35;white-space:pre-wrap;word-break:break-word;max-height:460px;overflow:auto} |
| 68 | + .k{color:var(--accent)} .s{color:#1c8c6b} |
| 69 | + footer{text-align:center;color:var(--faint);font-size:.8rem;margin-top:3rem} |
| 70 | + footer a{color:var(--accent)} |
| 71 | + .loading{text-align:center;color:var(--faint);padding:3rem;font-size:.9rem} |
| 72 | +</style> |
| 73 | +</head> |
| 74 | +<body> |
| 75 | +<main> |
| 76 | + <div class="hero"> |
| 77 | + <span class="mark"><span class="dot"></span> Beacon</span> |
| 78 | + <h1>A directory of <em style="font-style:italic">did:nostr</em> identities</h1> |
| 79 | + <p>Profiles, the follow graph, and a verifiable DID document for every key — indexed live from Nostr.</p> |
| 80 | + <div class="stat" id="stat"></div> |
| 81 | + </div> |
| 82 | + <div id="view"></div> |
| 83 | + <footer>powered by <a href="https://github.com/JavaScriptSolidServer/beacon">beacon</a> · each profile resolves at <code>/.well-known/did/nostr/<pubkey>.json</code></footer> |
| 84 | +</main> |
| 85 | + |
| 86 | +<script type="module"> |
| 87 | +const $ = (s) => document.querySelector(s); |
| 88 | +const view = $('#view'); |
| 89 | +const esc = (s) => String(s ?? '').replace(/[&<>]/g, (c) => ({'&':'&','<':'<','>':'>'}[c])); |
| 90 | +const short = (pk) => pk ? pk.slice(0, 8) + '…' + pk.slice(-4) : ''; |
| 91 | +const content = (p) => { try { return JSON.parse(p.content || '{}'); } catch { return {}; } }; |
| 92 | +const initial = (c, pk) => (c.name || c.display_name || pk || '?').trim()[0]?.toUpperCase() || '?'; |
| 93 | + |
| 94 | +function avatar(c, pk, cls = 'av') { |
| 95 | + const pic = c.picture; |
| 96 | + if (pic) return `<img class="${cls}" src="${esc(pic)}" alt="" onerror="this.replaceWith(Object.assign(document.createElement('div'),{className:'${cls}',textContent:'${esc(initial(c,pk))}'}))">`; |
| 97 | + return `<div class="${cls}">${esc(initial(c, pk))}</div>`; |
| 98 | +} |
| 99 | + |
| 100 | +function highlight(obj) { |
| 101 | + return esc(JSON.stringify(obj, null, 2)) |
| 102 | + .replace(/"(@?\w+)":/g, '<span class="k">"$1"</span>:') |
| 103 | + .replace(/: ("[^&]*?")/g, ': <span class="s">$1</span>'); |
| 104 | +} |
| 105 | + |
| 106 | +async function home() { |
| 107 | + location.hash = ''; |
| 108 | + view.innerHTML = '<div class="loading">loading recent identities…</div>'; |
| 109 | + const profiles = await fetch('/api/profiles').then((r) => r.json()).catch(() => []); |
| 110 | + $('#stat').textContent = profiles.length ? `${profiles.length} recently active identities` : ''; |
| 111 | + if (!profiles.length) { view.innerHTML = '<div class="loading">No profiles indexed yet. Start the indexer and refresh.</div>'; return; } |
| 112 | + view.innerHTML = `<div class="grid">${profiles.map((p) => { |
| 113 | + const c = content(p); |
| 114 | + return `<div class="card" data-pk="${p.pubkey}"> |
| 115 | + ${avatar(c, p.pubkey)} |
| 116 | + <div class="name">${esc(c.name || c.display_name || short(p.pubkey))}</div> |
| 117 | + ${c.nip05 ? `<div class="nip05">${esc(c.nip05)}</div>` : ''} |
| 118 | + ${c.about ? `<div class="about">${esc(c.about)}</div>` : ''} |
| 119 | + <div class="did">did:nostr:${short(p.pubkey)}</div> |
| 120 | + </div>`; |
| 121 | + }).join('')}</div>`; |
| 122 | + view.querySelectorAll('.card').forEach((el) => el.onclick = () => { location.hash = el.dataset.pk; }); |
| 123 | +} |
| 124 | + |
| 125 | +async function profile(pk) { |
| 126 | + view.innerHTML = '<div class="loading">resolving DID document…</div>'; |
| 127 | + $('#stat').textContent = ''; |
| 128 | + const doc = await fetch(`/api/did/${pk}`).then((r) => r.json()).catch(() => null); |
| 129 | + if (!doc || doc.error) { view.innerHTML = '<div class="loading">Could not resolve this identity.</div>'; return; } |
| 130 | + const c = doc.profile || {}; |
| 131 | + const follows = doc.follows || []; |
| 132 | + view.innerHTML = `<div class="detail"> |
| 133 | + <div class="back" id="back">← all identities</div> |
| 134 | + <div class="phead"> |
| 135 | + ${avatar(c, pk)} |
| 136 | + <div> |
| 137 | + <h2>${esc(c.name || short(pk))}</h2> |
| 138 | + ${c.nip05 ? `<div class="nip05">${esc(c.nip05)}</div>` : ''} |
| 139 | + ${c.about ? `<div class="about">${esc(c.about)}</div>` : ''} |
| 140 | + <div class="chips"> |
| 141 | + ${c.website ? `<a class="chip" href="${esc(c.website)}" target="_blank" rel="noopener">${esc(c.website.replace(/^https?:\/\//, ''))}</a>` : ''} |
| 142 | + ${c.lud16 ? `<span class="chip">⚡ ${esc(c.lud16)}</span>` : ''} |
| 143 | + ${doc.service?.length ? `<span class="chip">${doc.service.length} relay${doc.service.length > 1 ? 's' : ''}</span>` : ''} |
| 144 | + <span class="chip">${follows.length} following</span> |
| 145 | + </div> |
| 146 | + </div> |
| 147 | + </div> |
| 148 | + <div class="didline">${esc(doc.id)}</div> |
| 149 | + <div class="sections"> |
| 150 | + <div class="panel"><h3>DID document</h3><pre>${highlight(doc)}</pre></div> |
| 151 | + <div class="panel"><h3>Following · ${follows.length}</h3> |
| 152 | + ${follows.length ? `<div class="follows">${follows.slice(0, 60).map((d) => `<a class="fav" title="${esc(d)}" href="#${d.replace('did:nostr:', '')}"></a>`).join('')}</div>` |
| 153 | + : '<div style="color:var(--faint);font-size:.9rem">No follow list indexed.</div>'} |
| 154 | + </div> |
| 155 | + </div> |
| 156 | + </div>`; |
| 157 | + $('#back').onclick = home; |
| 158 | +} |
| 159 | + |
| 160 | +function route() { |
| 161 | + const pk = location.hash.slice(1); |
| 162 | + if (/^[0-9a-f]{64}$/.test(pk)) profile(pk); else home(); |
| 163 | +} |
| 164 | +window.addEventListener('hashchange', route); |
| 165 | +route(); |
| 166 | +</script> |
| 167 | +</body> |
| 168 | +</html> |
0 commit comments