Ionosphere.tv
3
fork

Configure Feed

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

feat: rename to Community, add Projects section, reorder sections

- Nav: Discussion → Community
- Top posts capped at 20 in All view
- New Projects section: 38 ATProto projects with URLs, descriptions,
and talk links rendered as amber pill grid
- Section order: Top Posts → Blog Posts → Projects → Photos → Videos → More Posts
- VOD site pills now clickable links

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

+81 -6
+23
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 {} 346 + 325 347 // Posts: content_type = 'post' or NULL, top-level only, sorted by likes DESC 326 348 const posts = db.prepare( 327 349 `SELECT m.uri, m.author_did, m.text, m.created_at, m.likes, m.reposts, m.replies, ··· 410 432 blogs: filteredBlogs, 411 433 videos: filteredVideos, 412 434 photos: filteredPhotos, 435 + projects, 413 436 vodSites, 414 437 stats: { 415 438 totalPosts: statsRow?.totalPosts || 0,
+1 -1
apps/ionosphere/src/app/components/NavHeader.tsx
··· 10 10 { href: "/speakers", label: "Speakers" }, 11 11 { href: "/concepts", label: "Concepts" }, 12 12 { href: "/concordance", label: "Index" }, 13 - { href: "/discussion", label: "Discussion" }, 13 + { href: "/discussion", label: "Community" }, 14 14 ]; 15 15 16 16 export default function NavHeader() {
+57 -5
apps/ionosphere/src/app/discussion/DiscussionContent.tsx
··· 62 62 uniqueAuthors: number; 63 63 } 64 64 65 + interface Project { 66 + name: string; 67 + url: string | null; 68 + handle: string | null; 69 + description: string; 70 + talkRkey: string | null; 71 + } 72 + 65 73 interface DiscussionData { 66 74 posts: DiscussionItem[]; 67 75 blogs: DiscussionItem[]; 68 76 videos: DiscussionItem[]; 77 + projects: Project[]; 69 78 vodSites: string[]; 70 79 stats: Stats; 71 80 } ··· 74 83 | { type: "heading"; label: string } 75 84 | { type: "item"; item: DiscussionItem } 76 85 | { type: "stats"; stats: Stats } 77 - | { type: "vodDirectory"; sites: string[] }; 86 + | { type: "vodDirectory"; sites: string[] } 87 + | { type: "projectDirectory"; projects: Project[] }; 78 88 79 - type FilterKey = "all" | "posts" | "blogs" | "videos" | "photos"; 89 + type FilterKey = "all" | "posts" | "blogs" | "photos" | "projects" | "videos"; 80 90 81 91 // --- Height estimation --- 82 92 ··· 84 94 if (item.type === "stats") return 76; 85 95 if (item.type === "heading") return 32; 86 96 if (item.type === "vodDirectory") return 86; 97 + if (item.type === "projectDirectory") return Math.ceil((item.projects.length / 3) * 24) + 30; 87 98 if (item.type === "item" && item.item.image_url) { 88 99 const imgWidth = (columnWidth || 240) - 18; // 18px left padding 89 100 const aspect = item.item.image_aspect || 1.33; // default 4:3 ··· 169 180 all: [ 170 181 { key: "Top Posts", label: "T" }, 171 182 { key: "Recaps & Blog Posts", label: "R" }, 183 + { key: "Projects", label: "J" }, 172 184 { key: "Photos", label: "P" }, 173 185 { key: "Videos & VOD Sites", label: "V" }, 174 186 { key: "More Posts", label: "+" }, 175 187 ], 176 188 posts: [{ key: "Top Posts", label: "T" }], 177 189 blogs: [{ key: "Recaps & Blog Posts", label: "R" }], 190 + projects: [{ key: "Projects", label: "J" }], 178 191 photos: [{ key: "Photos", label: "P" }], 179 192 videos: [{ key: "Videos & VOD Sites", label: "V" }], 180 193 }; ··· 235 248 // Stats card at the beginning 236 249 items.push({ type: "stats", stats: data.stats }); 237 250 238 - const TOP_POSTS_COUNT = 30; 251 + const TOP_POSTS_COUNT = 20; 239 252 240 253 if (filter === "all" || filter === "posts") { 241 254 if (data.posts.length > 0) { ··· 253 266 for (const blog of data.blogs) { 254 267 items.push({ type: "item", item: blog }); 255 268 } 269 + } 270 + } 271 + 272 + if (filter === "all" || filter === "projects") { 273 + if (data.projects?.length > 0) { 274 + items.push({ type: "heading", label: "Projects" }); 275 + items.push({ type: "projectDirectory", projects: data.projects }); 256 276 } 257 277 } 258 278 ··· 445 465 return ( 446 466 <div key="vod-dir" className="mb-2 flex flex-wrap gap-1.5" style={style}> 447 467 {item.sites.map((site) => ( 448 - <span key={site} className="text-[10px] px-2 py-0.5 rounded-full bg-neutral-800 text-neutral-400"> 468 + <a key={site} href={`https://${site}`} target="_blank" rel="noopener" 469 + className="text-[10px] px-2 py-0.5 rounded-full bg-purple-500/10 text-purple-400 hover:bg-purple-500/20"> 449 470 {site} 450 - </span> 471 + </a> 451 472 ))} 452 473 </div> 453 474 ); 454 475 } 455 476 477 + if (item.type === "projectDirectory") { 478 + 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> 503 + </div> 504 + ); 505 + } 506 + 456 507 // item 457 508 const di = item.item; 458 509 return ( ··· 529 580 { key: "all" as FilterKey, label: "All" }, 530 581 { key: "posts" as FilterKey, label: "Top Posts" }, 531 582 { key: "blogs" as FilterKey, label: "Recaps & Blog Posts" }, 583 + { key: "projects" as FilterKey, label: "Projects" }, 532 584 { key: "photos" as FilterKey, label: "Photos" }, 533 585 { key: "videos" as FilterKey, label: "Videos & VOD Sites" }, 534 586 ]).map((f) => (