dev vouch dev on at. thats about it atvouch.dev
8
fork

Configure Feed

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

show remote vouches

Luna 6fc351ce 4bda1580

+141 -3
+36 -2
frontend/src/App.css
··· 76 76 /* ── layout ── */ 77 77 78 78 .container { 79 - max-width: 960px; 79 + max-width: 1200px; 80 80 margin: 0 auto; 81 81 padding: 3rem 2rem; 82 82 } ··· 406 406 opacity: 1; 407 407 } 408 408 409 + /* ── right sidebar (remote vouches) ── */ 410 + 411 + .sidebar-right { 412 + width: 240px; 413 + flex-shrink: 0; 414 + border: 1px solid var(--border); 415 + border-top: 3px solid var(--text-dim); 416 + padding: 1.25rem; 417 + background: var(--bg-raised); 418 + } 419 + 420 + .vouch-mutual { 421 + color: var(--text-faint); 422 + font-size: 10px; 423 + letter-spacing: 0.06em; 424 + text-transform: uppercase; 425 + flex-shrink: 0; 426 + } 427 + 428 + .vouch-back-btn { 429 + font-size: 10px; 430 + padding: 0.2rem 0.5rem; 431 + border: 1px solid var(--accent-dim); 432 + background: none; 433 + color: var(--accent); 434 + text-transform: uppercase; 435 + letter-spacing: 0.06em; 436 + flex-shrink: 0; 437 + } 438 + 409 439 /* ── footer ── */ 410 440 411 441 .footer { ··· 419 449 420 450 /* ── responsive ── */ 421 451 422 - @media (max-width: 640px) { 452 + @media (max-width: 980px) { 423 453 .container { 424 454 padding: 2rem 1rem; 425 455 } ··· 433 463 } 434 464 435 465 .sidebar { 466 + width: 100%; 467 + } 468 + 469 + .sidebar-right { 436 470 width: 100%; 437 471 } 438 472
+104
frontend/src/App.tsx
··· 13 13 deleteVouch, 14 14 listVouches, 15 15 checkVouchPaths, 16 + fetchVouchers, 17 + resolveDidToHandle, 16 18 type CheckResult, 17 19 type VouchEntry, 18 20 } from "./api"; ··· 238 240 <CreateVouchSection agent={agent} onVouchCreated={refreshVouches} /> 239 241 <CheckVouchSection agent={agent} /> 240 242 </main> 243 + <RemoteVouchesSection agent={agent} myVouches={vouches} onVouchCreated={refreshVouches} /> 241 244 </div> 242 245 </div> 243 246 ); ··· 344 347 <CheckResultDisplay result={result.data} handle={result.handle} /> 345 348 )} 346 349 </section> 350 + ); 351 + } 352 + 353 + interface RemoteVoucher { 354 + did: string; 355 + handle: string | null; 356 + } 357 + 358 + function RemoteVouchesSection({ 359 + agent, 360 + myVouches, 361 + onVouchCreated, 362 + }: { 363 + agent: OAuthUserAgent; 364 + myVouches: VouchEntry[] | null; 365 + onVouchCreated: () => void; 366 + }) { 367 + const [vouchers, setVouchers] = useState<RemoteVoucher[] | null>(null); 368 + const [loading, setLoading] = useState(true); 369 + const [error, setError] = useState<string | null>(null); 370 + const [vouchingFor, setVouchingFor] = useState<Set<string>>(new Set()); 371 + 372 + useEffect(() => { 373 + (async () => { 374 + setLoading(true); 375 + setError(null); 376 + try { 377 + const dids = await fetchVouchers(agent.sub); 378 + const resolved: RemoteVoucher[] = await Promise.all( 379 + dids.map(async (did) => { 380 + let handle: string | null = null; 381 + try { 382 + handle = await resolveDidToHandle(did); 383 + } catch { 384 + // leave as null 385 + } 386 + return { did, handle }; 387 + }), 388 + ); 389 + setVouchers(resolved); 390 + } catch (err) { 391 + setError(String(err)); 392 + } 393 + setLoading(false); 394 + })(); 395 + }, [agent]); 396 + 397 + const alreadyVouched = new Set(myVouches?.filter((v) => v.valid).map((v) => v.did) ?? []); 398 + 399 + const handleVouch = async (voucher: RemoteVoucher) => { 400 + const label = voucher.handle ?? voucher.did; 401 + setVouchingFor((prev) => new Set(prev).add(voucher.did)); 402 + try { 403 + await createVouch(agent, voucher.handle ?? voucher.did); 404 + onVouchCreated(); 405 + } catch (err) { 406 + alert(`Failed to vouch for ${label}: ${err}`); 407 + } 408 + setVouchingFor((prev) => { 409 + const next = new Set(prev); 410 + next.delete(voucher.did); 411 + return next; 412 + }); 413 + }; 414 + 415 + return ( 416 + <aside className="sidebar sidebar-right"> 417 + <h2>Remote vouches</h2> 418 + {error && <div className="error">{error}</div>} 419 + {loading ? ( 420 + <p className="muted">Loading...</p> 421 + ) : vouchers !== null && vouchers.length === 0 ? ( 422 + <p className="muted">No one has vouched for you yet.</p> 423 + ) : ( 424 + <ul className="vouch-list"> 425 + {vouchers?.map((v) => ( 426 + <li key={v.did}> 427 + <a 428 + className="vouch-handle" 429 + href={`https://bsky.app/profile/${v.handle ?? v.did}`} 430 + target="_blank" 431 + rel="noopener noreferrer" 432 + > 433 + {v.handle ?? v.did} 434 + </a> 435 + {alreadyVouched.has(v.did) ? ( 436 + <span className="vouch-mutual">mutual</span> 437 + ) : ( 438 + <button 439 + className="vouch-back-btn" 440 + disabled={vouchingFor.has(v.did)} 441 + onClick={() => handleVouch(v)} 442 + > 443 + {vouchingFor.has(v.did) ? "..." : "vouch"} 444 + </button> 445 + )} 446 + </li> 447 + ))} 448 + </ul> 449 + )} 450 + </aside> 347 451 ); 348 452 } 349 453
+1 -1
frontend/src/api.ts
··· 22 22 return data.handle; 23 23 } 24 24 25 - async function fetchVouchers(targetDID: string): Promise<string[]> { 25 + export async function fetchVouchers(targetDID: string): Promise<string[]> { 26 26 const params = new URLSearchParams({ 27 27 target: targetDID, 28 28 collection: "dev.atvouch.graph.vouch",