Skip to content

Commit 4db867d

Browse files
add light-palette web UI (home grid + profile/DID-doc view); serve public/ statically
1 parent e144439 commit 4db867d

2 files changed

Lines changed: 173 additions & 0 deletions

File tree

public/index.html

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
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 &mdash; 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/&lt;pubkey&gt;.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) => ({'&':'&amp;','<':'&lt;','>':'&gt;'}[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(/&quot;(@?\w+)&quot;:/g, '<span class="k">&quot;$1&quot;</span>:')
103+
.replace(/: (&quot;[^&]*?&quot;)/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>

src/server.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
11
// Thin read API over the indexed social graph.
22
import express from 'express';
3+
import path from 'node:path';
4+
import { fileURLToPath } from 'node:url';
35
import { getProfile, getFollows, getRelays, recentProfiles, connect } from './db.js';
46
import { buildDidDocument } from './diddoc.js';
57
import 'dotenv/config';
68

9+
const PUBLIC = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'public');
10+
711
export async function startServer(port = process.env.PORT || 3000) {
812
await connect();
913
const app = express();
14+
app.use(express.static(PUBLIC));
1015

1116
app.get('/api/profiles', async (_req, res, next) => {
1217
try { res.json(await recentProfiles(10)); } catch (e) { next(e); }

0 commit comments

Comments
 (0)