8686 .card .counts strong {color : var (--ink );font-weight : 600 }
8787 .seclabel {font-size : .74rem ;font-weight : 700 ;letter-spacing : .08em ;text-transform : uppercase;color : var (--faint );margin : .4rem 0 1rem }
8888 .chip strong {font-weight : 700 }
89+
90+ /* topbar */
91+ .topbar {display : flex;align-items : center;justify-content : space-between;gap : 1rem ;max-width : 1040px ;margin : 0 auto;padding : 1.1rem 1.2rem 0 }
92+ .tb-brand {display : inline-flex;align-items : center;gap : .5rem ;font-weight : 700 ;letter-spacing : -.01em ;color : var (--muted );font-size : .95rem }
93+ .tb-nav {display : flex;align-items : center;gap : 1.1rem }
94+ .tb-nav > a {font-size : .88rem ;font-weight : 500 ;color : var (--muted )}
95+ .tb-nav > a : hover {color : var (--accent )}
96+ .tb-cta {background : var (--accent );color : # fff!important ;padding : .5rem .9rem ;border-radius : 999px ;font-weight : 600 ;box-shadow : 0 4px 14px rgba (109 , 94 , 252 , .3 );transition : transform .15s , box-shadow .15s }
97+ .tb-cta : hover {transform : translateY (-1px );box-shadow : 0 8px 22px rgba (109 , 94 , 252 , .4 )}
98+ @media (max-width : 560px ){.tb-nav > a : not (.tb-cta ){display : none}}
99+ /* link page */
100+ .link-panel {max-width : 620px ;margin : 0 auto}
101+ .link-panel h2 {font-family : var (--serif );font-size : 1.8rem ;font-weight : 600 ;margin-bottom : .6rem }
102+ .link-panel p {color : var (--muted );margin-bottom : .4rem }
103+ .link-form {display : flex;flex-wrap : wrap;gap : .6rem ;margin-top : 1.1rem }
104+ .link-form input {flex : 1 ;min-width : 240px ;font-family : var (--mono );font-size : .85rem ;border : 1px solid var (--line );border-radius : 10px ;padding : .7rem .9rem ;outline : none}
105+ .link-form input : focus {border-color : var (--accent );box-shadow : 0 0 0 4px var (--accent-soft )}
106+ .cta {background : var (--accent );color : # fff ;border : none;border-radius : 10px ;padding : .7rem 1.2rem ;font-weight : 600 ;font-size : .9rem ;cursor : pointer;font-family : var (--sans )}
107+ .cta : disabled {opacity : .55 ;cursor : default}
108+ .link-status {width : 100% ;font-size : .88rem ;color : var (--muted );margin-top : .5rem ;line-height : 1.5 }
109+ .link-status .ok {color : # 1c8c6b } .link-status .err {color : # c0392b }
89110</ style >
90111</ head >
91112< body >
113+ < header class ="topbar ">
114+ < a class ="tb-brand " href ="/ "> < span class ="dot "> </ span > nostr.social</ a >
115+ < nav class ="tb-nav ">
116+ < a href ="/link "> Link your pod</ a >
117+ < a class ="tb-cta " href ="https://jss.live/sso/?resolver=https://nostr.social " target ="_blank " rel ="noopener "> Sign in with did:nostr</ a >
118+ </ nav >
119+ </ header >
92120< main >
93121 < div class ="hero ">
94- < span class ="mark "> < span class ="dot "> </ span > Beacon</ span >
95122 < h1 > A directory of < em style ="font-style:italic "> did:nostr</ em > identities</ h1 >
96123 < p > Profiles, the follow graph, and a verifiable DID document for every key — indexed live from Nostr.</ p >
97124 < div class ="searchwrap "> < input id ="q " type ="search " placeholder ="Search by name, nip05, npub, or hex pubkey… " autocomplete ="off " spellcheck ="false "> </ div >
@@ -206,6 +233,88 @@ <h2>${esc(c.name || short(pk))}</h2>
206233 $ ( '#back' ) . onclick = ( ) => navigate ( '/' ) ;
207234}
208235
236+ // ---- link your pod (onboarding) ----
237+ const RELAYS = [ 'wss://relay.damus.io' , 'wss://nos.lol' , 'wss://relay.primal.net' ] ;
238+
239+ function publishEvent ( event ) {
240+ return Promise . all ( RELAYS . map ( ( url ) => new Promise ( ( resolve ) => {
241+ let ws , done = false ;
242+ const finish = ( ok ) => { if ( ! done ) { done = true ; try { ws . close ( ) ; } catch { } resolve ( { url, ok } ) ; } } ;
243+ try { ws = new WebSocket ( url ) ; } catch { return finish ( false ) ; }
244+ ws . onopen = ( ) => ws . send ( JSON . stringify ( [ 'EVENT' , event ] ) ) ;
245+ ws . onmessage = ( m ) => { try { const d = JSON . parse ( m . data ) ; if ( d [ 0 ] === 'OK' && d [ 1 ] === event . id ) finish ( ! ! d [ 2 ] ) ; } catch { } } ;
246+ ws . onerror = ( ) => finish ( false ) ;
247+ setTimeout ( ( ) => finish ( false ) , 6000 ) ;
248+ } ) ) ) ;
249+ }
250+
251+ const linkForm = ( val ) => `<div class="link-form">
252+ <input id="webid" type="url" placeholder="https://you.solid.social/profile/card#me" value="${ esc ( val ) } ">
253+ <button id="dolink" class="cta">Publish link</button>
254+ <div id="linkstatus" class="link-status"></div>
255+ </div>` ;
256+
257+ async function linkView ( ) {
258+ if ( $ ( '#q' ) ) $ ( '#q' ) . value = '' ;
259+ $ ( '#stat' ) . innerHTML = '' ;
260+ const panel = ( inner ) => { view . innerHTML = `<div class="detail"><div class="back" id="back">← all identities</div><div class="panel link-panel">${ inner } </div></div>` ; $ ( '#back' ) . onclick = ( ) => navigate ( '/' ) ; } ;
261+
262+ view . innerHTML = '<div class="loading">checking your signer…</div>' ;
263+ if ( ! window . nostr ) {
264+ panel ( `<h2>Link your pod</h2>
265+ <p>Linking your nostr key to a Solid pod lets <strong>did:nostr</strong> resolve to your WebID — so one-click <em>Sign in with did:nostr</em> works.</p>
266+ <p>First you need a NIP-07 signer extension:</p>
267+ <div class="chips" style="margin-top:.8rem">
268+ <a class="chip" href="https://github.com/JavaScriptSolidServer/podkey" target="_blank" rel="noopener">Podkey ↗</a>
269+ <a class="chip" href="https://github.com/melvincarvalho/xlogin" target="_blank" rel="noopener">xlogin ↗</a>
270+ </div>` ) ;
271+ return ;
272+ }
273+ let pubkey ;
274+ try { pubkey = ( await window . nostr . getPublicKey ( ) ) . toLowerCase ( ) ; }
275+ catch { panel ( '<h2>Link your pod</h2><p>Your signer declined to share your public key. Reload and try again.</p>' ) ; return ; }
276+
277+ const doc = await fetch ( `/api/did/${ pubkey } ` ) . then ( ( r ) => r . json ( ) ) . catch ( ( ) => null ) ;
278+ const aka = ( doc ?. alsoKnownAs || [ ] ) . find ( ( x ) => / ^ h t t p s ? : \/ \/ / . test ( x ) ) ;
279+ if ( aka ) {
280+ panel ( `<h2>You're linked ✓</h2>
281+ <p><code>did:nostr:${ short ( pubkey ) } </code> resolves to your WebID:</p>
282+ <div class="didline"><span>${ esc ( aka ) } </span></div>
283+ <p style="margin-top:1.1rem">Test it: <a href="https://jss.live/sso/?resolver=https://nostr.social" target="_blank" rel="noopener" style="color:var(--accent);font-weight:600">Sign in with did:nostr →</a></p>
284+ <details style="margin-top:1.2rem"><summary style="cursor:pointer;color:var(--muted)">Change linked pod</summary>${ linkForm ( aka ) } </details>` ) ;
285+ } else {
286+ panel ( `<h2>Link your pod</h2>
287+ <p><code>did:nostr:${ short ( pubkey ) } </code> isn't linked to a Solid pod yet. Enter your WebID (your pod's profile URL) and we'll publish an <code>alsoKnownAs</code> to your nostr profile (kind 0), signed by your key.</p>
288+ ${ linkForm ( '' ) } ` ) ;
289+ }
290+ wireLink ( pubkey ) ;
291+ }
292+
293+ function wireLink ( pubkey ) {
294+ const btn = $ ( '#dolink' ) ; if ( ! btn ) return ;
295+ const status = $ ( '#linkstatus' ) ;
296+ const say = ( msg , kind = '' ) => { status . textContent = msg ; status . className = 'link-status' + ( kind ? ' ' + kind : '' ) ; } ;
297+ btn . onclick = async ( ) => {
298+ const webid = $ ( '#webid' ) . value . trim ( ) ;
299+ if ( ! / ^ h t t p s ? : \/ \/ .+ / . test ( webid ) ) return say ( 'Enter a valid WebID URL (https://…).' , 'err' ) ;
300+ btn . disabled = true ; say ( 'Building your updated profile…' ) ;
301+ try {
302+ const raw = await fetch ( `/api/profile/${ pubkey } ` ) . then ( ( r ) => r . json ( ) ) . catch ( ( ) => null ) ;
303+ let c = { } ; try { c = JSON . parse ( raw ?. content || '{}' ) ; } catch { }
304+ c . alsoKnownAs = [ webid ] ;
305+ const unsigned = { kind : 0 , pubkey, created_at : Math . floor ( Date . now ( ) / 1000 ) , tags : [ ] , content : JSON . stringify ( c ) } ;
306+ say ( 'Requesting your signature…' ) ;
307+ const signed = await window . nostr . signEvent ( unsigned ) ;
308+ say ( 'Publishing to relays…' ) ;
309+ const results = await publishEvent ( signed ) ;
310+ const ok = results . filter ( ( r ) => r . ok ) . length ;
311+ if ( ok ) say ( `Published to ${ ok } /${ results . length } relays. did:nostr:${ short ( pubkey ) } now points to ${ webid } . beacon will re-index shortly, then Sign in with did:nostr works.` , 'ok' ) ;
312+ else say ( 'No relay accepted the event — try again or check your relays.' , 'err' ) ;
313+ } catch ( err ) { say ( 'Signing/publishing failed: ' + ( err ?. message || err ) , 'err' ) ; }
314+ finally { btn . disabled = false ; }
315+ } ;
316+ }
317+
209318function navigate ( path ) { if ( location . pathname !== path ) history . pushState ( { } , '' , path ) ; route ( ) ; }
210319
211320let searchTimer ;
@@ -216,6 +325,7 @@ <h2>${esc(c.name || short(pk))}</h2>
216325}
217326
218327function route ( ) {
328+ if ( location . pathname === '/link' ) return linkView ( ) ;
219329 const m = location . pathname . match ( / ^ \/ ( [ 0 - 9 a - f ] { 64 } ) $ / i) ;
220330 if ( m ) return profile ( m [ 1 ] . toLowerCase ( ) ) ;
221331 const h = location . hash . slice ( 1 ) ; // legacy #<hex> links still resolve
0 commit comments