7373 footer {text-align : center;color : var (--faint );font-size : .8rem ;margin-top : 3rem }
7474 footer a {color : var (--accent )}
7575 .loading {text-align : center;color : var (--faint );padding : 3rem ;font-size : .9rem }
76+
77+ /* search */
78+ .searchwrap {margin : 1.9rem auto .2rem ;max-width : 31rem }
79+ .searchwrap input {width : 100% ;font-family : var (--sans );font-size : 1rem ;color : var (--ink );background : # fff ;
80+ border : 1px solid var (--line );border-radius : 999px ;padding : .82rem 1.25rem ;outline : none;
81+ box-shadow : 0 1px 2px rgba (20 , 20 , 30 , .04 );transition : border-color .15s , box-shadow .15s }
82+ .searchwrap input : focus {border-color : var (--accent );box-shadow : 0 0 0 4px var (--accent-soft )}
83+ .searchwrap input ::placeholder {color : var (--faint )}
84+ .hero .stat strong {color : var (--muted );font-weight : 700 }
85+ /* counts + section label */
86+ .card .counts {font-size : .8rem ;color : var (--muted );margin-top : .5rem }
87+ .card .counts strong {color : var (--ink );font-weight : 600 }
88+ .seclabel {font-size : .74rem ;font-weight : 700 ;letter-spacing : .08em ;text-transform : uppercase;color : var (--faint );margin : .4rem 0 1rem }
89+ .chip strong {font-weight : 700 }
7690</ style >
7791</ head >
7892< body >
8195 < span class ="mark "> < span class ="dot "> </ span > Beacon</ span >
8296 < h1 > A directory of < em style ="font-style:italic "> did:nostr</ em > identities</ h1 >
8397 < p > Profiles, the follow graph, and a verifiable DID document for every key — indexed live from Nostr.</ p >
98+ < div class ="searchwrap "> < input id ="q " type ="search " placeholder ="Search by name, nip05, npub, or hex pubkey… " autocomplete ="off " spellcheck ="false "> </ div >
8499 < div class ="stat " id ="stat "> </ div >
85100 </ div >
86101 < div id ="view "> </ div >
@@ -108,32 +123,61 @@ <h1>A directory of <em style="font-style:italic">did:nostr</em> identities</h1>
108123 . replace ( / : ( & q u o t ; [ ^ & ] * ?& q u o t ; ) / g, ': <span class="s">$1</span>' ) ;
109124}
110125
126+ const fmt = ( n ) => {
127+ n = Number ( n ) || 0 ;
128+ if ( n >= 1e6 ) return ( n / 1e6 ) . toFixed ( n < 1e7 ? 1 : 0 ) + 'M' ;
129+ if ( n >= 1e3 ) return ( n / 1e3 ) . toFixed ( n < 1e4 ? 1 : 0 ) + 'k' ;
130+ return String ( n ) ;
131+ } ;
132+
133+ function cardHtml ( p ) {
134+ const c = content ( p ) ;
135+ const ct = p . _counts || { } ;
136+ return `<div class="card" data-pk="${ p . pubkey } ">
137+ ${ avatar ( c , p . pubkey ) }
138+ <div class="name">${ esc ( c . name || c . display_name || short ( p . pubkey ) ) } </div>
139+ ${ c . nip05 ? `<div class="nip05">${ esc ( c . nip05 ) } </div>` : '' }
140+ <div class="counts"><strong>${ fmt ( ct . followers ) } </strong> followers · <strong>${ fmt ( ct . following ) } </strong> following</div>
141+ ${ c . about ? `<div class="about">${ esc ( c . about ) } </div>` : '' }
142+ <div class="did">did:nostr:${ short ( p . pubkey ) } </div>
143+ </div>` ;
144+ }
145+
146+ function renderGrid ( profiles , label ) {
147+ view . innerHTML = `${ label ? `<div class="seclabel">${ label } </div>` : '' } <div class="grid">${ profiles . map ( cardHtml ) . join ( '' ) } </div>` ;
148+ view . querySelectorAll ( '.card' ) . forEach ( ( el ) => { el . onclick = ( ) => { location . hash = el . dataset . pk ; } ; } ) ;
149+ }
150+
151+ async function loadStats ( ) {
152+ const s = await fetch ( '/api/stats' ) . then ( ( r ) => r . json ( ) ) . catch ( ( ) => null ) ;
153+ if ( s ) $ ( '#stat' ) . innerHTML = `<strong>${ fmt ( s . profiles ) } </strong> identities · <strong>${ fmt ( s . follows ) } </strong> follow lists · <strong>${ fmt ( s . relays ) } </strong> relays` ;
154+ }
155+
111156async function home ( ) {
112- location . hash = '' ;
113- view . innerHTML = '<div class="loading">loading recent identities…</div>' ;
157+ if ( $ ( '#q' ) ) $ ( '#q' ) . value = '' ;
158+ view . innerHTML = '<div class="loading">loading identities…</div>' ;
114159 const profiles = await fetch ( '/api/profiles?limit=36' ) . then ( ( r ) => r . json ( ) ) . catch ( ( ) => [ ] ) ;
115- $ ( '#stat' ) . textContent = profiles . length ? `${ profiles . length } recently active identities` : '' ;
116160 if ( ! profiles . length ) { view . innerHTML = '<div class="loading">No profiles indexed yet. Start the indexer and refresh.</div>' ; return ; }
117- view . innerHTML = `<div class="grid">${ profiles . map ( ( p ) => {
118- const c = content ( p ) ;
119- return `<div class="card" data-pk="${ p . pubkey } ">
120- ${ avatar ( c , p . pubkey ) }
121- <div class="name">${ esc ( c . name || c . display_name || short ( p . pubkey ) ) } </div>
122- ${ c . nip05 ? `<div class="nip05">${ esc ( c . nip05 ) } </div>` : '' }
123- ${ c . about ? `<div class="about">${ esc ( c . about ) } </div>` : '' }
124- <div class="did">did:nostr:${ short ( p . pubkey ) } </div>
125- </div>` ;
126- } ) . join ( '' ) } </div>`;
127- view . querySelectorAll ( '.card' ) . forEach ( ( el ) => el . onclick = ( ) => { location . hash = el . dataset . pk ; } ) ;
161+ renderGrid ( profiles , 'Recently active' ) ;
128162}
129163
164+ async function searchView ( q ) {
165+ view . innerHTML = '<div class="loading">searching…</div>' ;
166+ const results = await fetch ( `/api/search?q=${ encodeURIComponent ( q ) } ` ) . then ( ( r ) => r . json ( ) ) . catch ( ( ) => [ ] ) ;
167+ if ( ! Array . isArray ( results ) || ! results . length ) { view . innerHTML = `<div class="loading">No matches for “${ esc ( q ) } ”.</div>` ; return ; }
168+ renderGrid ( results , `${ results . length } result${ results . length > 1 ? 's' : '' } for “${ esc ( q ) } ”` ) ;
169+ }
170+
171+ // strip our UI-only count fields so the displayed JSON is the pure DID document
172+ const didOnly = ( { followersCount, followingCount, ...d } ) => d ;
173+
130174async function profile ( pk ) {
131175 view . innerHTML = '<div class="loading">resolving DID document…</div>' ;
132- $ ( '#stat' ) . textContent = '' ;
133176 const doc = await fetch ( `/api/did/${ pk } ` ) . then ( ( r ) => r . json ( ) ) . catch ( ( ) => null ) ;
134177 if ( ! doc || doc . error ) { view . innerHTML = '<div class="loading">Could not resolve this identity.</div>' ; return ; }
135178 const c = doc . profile || { } ;
136179 const follows = doc . follows || [ ] ;
180+ const followers = doc . followersCount , following = doc . followingCount ?? follows . length ;
137181 view . innerHTML = `<div class="detail">
138182 <div class="back" id="back">← all identities</div>
139183 <div class="phead">
@@ -143,30 +187,41 @@ <h2>${esc(c.name || short(pk))}</h2>
143187 ${ c . nip05 ? `<div class="nip05">${ esc ( c . nip05 ) } </div>` : '' }
144188 ${ c . about ? `<div class="about">${ esc ( c . about ) } </div>` : '' }
145189 <div class="chips">
190+ <span class="chip"><strong>${ fmt ( followers ) } </strong> followers</span>
191+ <span class="chip"><strong>${ fmt ( following ) } </strong> following</span>
192+ ${ doc . service ?. length ? `<span class="chip">${ doc . service . length } relay${ doc . service . length > 1 ? 's' : '' } </span>` : '' }
146193 ${ c . website ? `<a class="chip" href="${ esc ( c . website ) } " target="_blank" rel="noopener">${ esc ( c . website . replace ( / ^ h t t p s ? : \/ \/ / , '' ) ) } </a>` : '' }
147194 ${ c . lud16 ? `<span class="chip">⚡ ${ esc ( c . lud16 ) } </span>` : '' }
148- ${ doc . service ?. length ? `<span class="chip">${ doc . service . length } relay${ doc . service . length > 1 ? 's' : '' } </span>` : '' }
149- <span class="chip">${ follows . length } following</span>
150195 </div>
151196 </div>
152197 </div>
153198 <a class="didline" href="/.well-known/did/nostr/${ pk } .json" target="_blank" rel="noopener" title="Resolve the DID document (application/did+json)"><span>${ esc ( doc . id ) } </span><span class="resolve">resolve ↗</span></a>
154199 <div class="sections">
155- <div class="panel"><h3>DID document</h3><pre>${ highlight ( doc ) } </pre></div>
200+ <div class="panel"><h3>DID document</h3><pre>${ highlight ( didOnly ( doc ) ) } </pre></div>
156201 <div class="panel"><h3>Following · ${ follows . length } </h3>
157202 ${ follows . length ? `<div class="follows">${ follows . slice ( 0 , 96 ) . map ( ( d ) => { const fk = d . replace ( 'did:nostr:' , '' ) ; return `<a class="fav" style="${ gradStyle ( fk ) } " title="${ esc ( fk ) } " href="#${ fk } "></a>` ; } ) . join ( '' ) } </div>`
158203 : '<div style="color:var(--faint);font-size:.9rem">No follow list indexed.</div>' }
159204 </div>
160205 </div>
161206 </div>` ;
162- $ ( '#back' ) . onclick = home ;
207+ $ ( '#back' ) . onclick = ( ) => { location . hash = '' ; } ;
208+ }
209+
210+ let searchTimer ;
211+ function onSearch ( ) {
212+ const q = $ ( '#q' ) . value . trim ( ) ;
213+ clearTimeout ( searchTimer ) ;
214+ searchTimer = setTimeout ( ( ) => { if ( ! q ) route ( ) ; else searchView ( q ) ; } , 220 ) ;
163215}
164216
165217function route ( ) {
166218 const pk = location . hash . slice ( 1 ) ;
167219 if ( / ^ [ 0 - 9 a - f ] { 64 } $ / . test ( pk ) ) profile ( pk ) ; else home ( ) ;
168220}
221+
222+ $ ( '#q' ) . addEventListener ( 'input' , onSearch ) ;
169223window . addEventListener ( 'hashchange' , route ) ;
224+ loadStats ( ) ;
170225route ( ) ;
171226</ script >
172227</ body >
0 commit comments