Ionosphere.tv
3
fork

Configure Feed

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

feat: curated project list with URLs and talk links

124 projects extracted from 120 talks with best-guess project names
and URLs. Each project shows: name (linked to URL if known), play
button to open the talk, and speaker name. JSON file at
apps/data/atmosphere-projects.json is editable for curation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

+33 -21
+18 -12
apps/ionosphere-appview/src/routes.ts
··· 322 322 const BLOCKED_HANDLES = new Set(['nowbreezing.ntw.app']); 323 323 const filterBlocked = (rows: any[]) => rows.filter((r: any) => !BLOCKED_HANDLES.has(r.author_handle)); 324 324 325 - // Projects: every talk is a project showcase 326 - const projects = db.prepare(` 327 - SELECT t.rkey as talkRkey, t.title as name, t.talk_type as talkType, t.category, 328 - GROUP_CONCAT(DISTINCT s.name) as speakers, 329 - GROUP_CONCAT(DISTINCT s.handle) as handles 330 - FROM talks t 331 - JOIN talk_speakers ts ON ts.talk_uri = t.uri 332 - JOIN speakers s ON s.uri = ts.speaker_uri 333 - WHERE t.starts_at IS NOT NULL 334 - GROUP BY t.rkey 335 - ORDER BY t.starts_at 336 - `).all() as any[]; 325 + // Projects: curated list from JSON 326 + let projects: any[] = []; 327 + try { 328 + const projectsPath = path.resolve(import.meta.dirname, "../../data/atmosphere-projects.json"); 329 + const raw = JSON.parse(readFileSync(projectsPath, "utf-8")); 330 + // Flatten: each project gets its talk context 331 + for (const talk of raw) { 332 + for (const proj of talk.projects) { 333 + projects.push({ 334 + name: proj.name, 335 + url: proj.url || null, 336 + talkRkey: talk.talkRkey, 337 + talkTitle: talk.talkTitle, 338 + speakers: talk.speakers, 339 + }); 340 + } 341 + } 342 + } catch {} 337 343 338 344 // Posts: content_type = 'post' or NULL, top-level only, sorted by likes DESC 339 345 const posts = db.prepare(
+15 -9
apps/ionosphere/src/app/discussion/DiscussionContent.tsx
··· 64 64 65 65 interface Project { 66 66 name: string; 67 + url: string | null; 67 68 talkRkey: string; 68 - talkType: string | null; 69 - category: string | null; 69 + talkTitle: string; 70 70 speakers: string; 71 - handles: string | null; 72 71 } 73 72 74 73 interface DiscussionData { ··· 480 479 if (item.type === "project") { 481 480 const proj = item.project; 482 481 return ( 483 - <div key={proj.talkRkey} className="flex items-baseline gap-1 text-[11px] leading-[20px] truncate" style={style}> 482 + <div key={`${proj.talkRkey}-${proj.name}`} className="flex items-baseline gap-1 text-[11px] leading-[20px] truncate" style={style}> 483 + {proj.url ? ( 484 + <a href={proj.url} target="_blank" rel="noopener" 485 + className="text-amber-300/80 hover:text-amber-200 truncate"> 486 + {proj.name} 487 + </a> 488 + ) : ( 489 + <span className="text-amber-300/60 truncate">{proj.name}</span> 490 + )} 484 491 <button 485 492 onClick={() => handleSelect(proj.talkRkey)} 486 - className="text-amber-300/80 hover:text-amber-200 truncate text-left" 487 - > 488 - {proj.name} 489 - </button> 490 - <span className="text-neutral-600 shrink-0 text-[10px]"> 493 + className="text-neutral-600 hover:text-neutral-300 shrink-0 text-[10px]" 494 + title={proj.talkTitle} 495 + >▶</button> 496 + <span className="text-neutral-700 shrink-0 text-[10px] truncate"> 491 497 {proj.speakers?.split(",")[0]} 492 498 </span> 493 499 </div>