Ionosphere.tv
3
fork

Configure Feed

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

feat: comprehensive Projects section from all conference talks

Every talk is a project showcase — serves all ~100 talks as individual
flow items in the Projects section, flowing across columns. Each shows
talk title (amber, clickable to open side panel) + first speaker name.
Fixed projects JSON path. Renamed nav to Community.

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

+34 -53
+12 -21
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 326 - let projects: any[] = []; 327 - try { 328 - const projectsPath = path.resolve(import.meta.dirname, "../data/atmosphere-projects.json"); 329 - projects = JSON.parse(readFileSync(projectsPath, "utf-8")); 330 - // Enrich with talk rkeys 331 - const talksByHandle = new Map<string, string>(); 332 - const speakerTalks = db.prepare(` 333 - SELECT s.handle, t.rkey, t.title FROM speakers s 334 - JOIN talk_speakers ts ON ts.speaker_uri = s.uri 335 - JOIN talks t ON t.uri = ts.talk_uri 336 - WHERE s.handle IS NOT NULL AND t.starts_at IS NOT NULL 337 - `).all() as any[]; 338 - for (const st of speakerTalks) { 339 - if (!talksByHandle.has(st.handle)) talksByHandle.set(st.handle, st.rkey); 340 - } 341 - projects = projects.map((p: any) => ({ 342 - ...p, 343 - talkRkey: p.handle ? talksByHandle.get(p.handle) || null : null, 344 - })); 345 - } catch {} 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[]; 346 337 347 338 // Posts: content_type = 'post' or NULL, top-level only, sorted by likes DESC 348 339 const posts = db.prepare(
+22 -32
apps/ionosphere/src/app/discussion/DiscussionContent.tsx
··· 64 64 65 65 interface Project { 66 66 name: string; 67 - url: string | null; 68 - handle: string | null; 69 - description: string; 70 - talkRkey: string | null; 67 + talkRkey: string; 68 + talkType: string | null; 69 + category: string | null; 70 + speakers: string; 71 + handles: string | null; 71 72 } 72 73 73 74 interface DiscussionData { ··· 84 85 | { type: "item"; item: DiscussionItem } 85 86 | { type: "stats"; stats: Stats } 86 87 | { type: "vodDirectory"; sites: string[] } 87 - | { type: "projectDirectory"; projects: Project[] }; 88 + | { type: "project"; project: Project }; 88 89 89 90 type FilterKey = "all" | "posts" | "blogs" | "photos" | "projects" | "videos"; 90 91 ··· 94 95 if (item.type === "stats") return 76; 95 96 if (item.type === "heading") return 32; 96 97 if (item.type === "vodDirectory") return 86; 97 - if (item.type === "projectDirectory") return Math.ceil((item.projects.length / 3) * 24) + 30; 98 + if (item.type === "project") return 22; 98 99 if (item.type === "item" && item.item.image_url) { 99 100 const imgWidth = (columnWidth || 240) - 18; // 18px left padding 100 101 const aspect = item.item.image_aspect || 1.33; // default 4:3 ··· 272 273 if (filter === "all" || filter === "projects") { 273 274 if (data.projects?.length > 0) { 274 275 items.push({ type: "heading", label: "Projects" }); 275 - items.push({ type: "projectDirectory", projects: data.projects }); 276 + for (const proj of data.projects) { 277 + items.push({ type: "project", project: proj }); 278 + } 276 279 } 277 280 } 278 281 ··· 474 477 ); 475 478 } 476 479 477 - if (item.type === "projectDirectory") { 480 + if (item.type === "project") { 481 + const proj = item.project; 478 482 return ( 479 - <div key="project-dir" className="mb-2" style={style}> 480 - <div className="flex flex-wrap gap-1.5"> 481 - {item.projects.map((proj) => ( 482 - <span key={proj.name} className="inline-flex items-center gap-1"> 483 - {proj.url ? ( 484 - <a href={proj.url} target="_blank" rel="noopener" 485 - className="text-[11px] px-2 py-0.5 rounded-full bg-amber-500/10 text-amber-300 hover:bg-amber-500/20" 486 - title={proj.description}> 487 - {proj.name} 488 - </a> 489 - ) : ( 490 - <span className="text-[11px] px-2 py-0.5 rounded-full bg-neutral-800 text-neutral-400" 491 - title={proj.description}> 492 - {proj.name} 493 - </span> 494 - )} 495 - {proj.talkRkey && ( 496 - <button onClick={() => handleSelect(proj.talkRkey!)} 497 - className="text-[9px] text-neutral-600 hover:text-neutral-300" 498 - title="Watch talk">&#9654;</button> 499 - )} 500 - </span> 501 - ))} 502 - </div> 483 + <div key={proj.talkRkey} className="flex items-baseline gap-1 text-[11px] leading-[20px] truncate" style={style}> 484 + <button 485 + 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]"> 491 + {proj.speakers?.split(",")[0]} 492 + </span> 503 493 </div> 504 494 ); 505 495 }