Ionosphere.tv
3
fork

Configure Feed

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

docs: conference discussion page implementation plan

5 tasks: schema migration, wider fetch script (20+ VOD domains, blogs,
OG metadata, talk matching), getDiscussion API endpoint, multi-column
DiscussionContent component, nav integration.

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

+743
+743
docs/superpowers/plans/2026-04-12-conference-discussion.md
··· 1 + # Conference Discussion Page Implementation Plan 2 + 3 + > **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. 4 + 5 + **Goal:** Build a curated multi-column "Conference Discussion" page showing top Bluesky posts, blog recaps, and VOD site links from ATmosphereConf, with talk deep-links and filterable sections. 6 + 7 + **Architecture:** Extend the mentions table with `content_type`, `external_url`, and `og_title` columns. A new fetch phase searches 20+ VOD domains and blog/recap queries, classifies content, and fetches OG metadata. A new XRPC endpoint serves discussion data grouped by type. The frontend uses the concordance `IndexContent.tsx` greedy-column pattern with section-based flow items and a filter bar. 8 + 9 + **Tech Stack:** SQLite (schema), Node.js (fetch scripts), Hono (API), React/Next.js with greedy column-fill layout 10 + 11 + --- 12 + 13 + ## File Structure 14 + 15 + | File | Action | Responsibility | 16 + |------|--------|---------------| 17 + | `apps/ionosphere-appview/src/db.ts` | Modify | Add 3 columns to mentions table | 18 + | `scripts/fetch-discussion.mjs` | Create | Wider search: VOD domains, blog recaps, OG metadata, talk matching | 19 + | `apps/ionosphere-appview/src/routes.ts` | Modify | Add `getDiscussion` endpoint | 20 + | `apps/ionosphere/src/lib/api.ts` | Modify | Add `getDiscussion()` client | 21 + | `apps/ionosphere/src/app/discussion/page.tsx` | Create | Route + SSR data fetch | 22 + | `apps/ionosphere/src/app/discussion/DiscussionContent.tsx` | Create | Multi-column layout with filter, section nav, click-to-play | 23 + | `apps/ionosphere/src/app/components/NavHeader.tsx` | Modify | Add "Discussion" nav item | 24 + 25 + --- 26 + 27 + ## Task 1: Schema Migration — Add Columns 28 + 29 + **Files:** 30 + - Modify: `apps/ionosphere-appview/src/db.ts:167-187` 31 + 32 + - [ ] **Step 1: Add columns to schema and run migration** 33 + 34 + In `db.ts`, add after the mentions table CREATE statement (inside the same `db.exec` block): 35 + 36 + ```sql 37 + -- Add columns if they don't exist (idempotent via try/catch in migration) 38 + ``` 39 + 40 + Since SQLite doesn't support `ADD COLUMN IF NOT EXISTS`, add a migration block after the main `db.exec`. Find the existing migration section and add: 41 + 42 + ```typescript 43 + // Mentions table extensions 44 + try { db.exec("ALTER TABLE mentions ADD COLUMN content_type TEXT DEFAULT 'post'"); } catch {} 45 + try { db.exec("ALTER TABLE mentions ADD COLUMN external_url TEXT"); } catch {} 46 + try { db.exec("ALTER TABLE mentions ADD COLUMN og_title TEXT"); } catch {} 47 + try { db.exec("ALTER TABLE mentions ADD COLUMN talk_rkey TEXT"); } catch {} 48 + ``` 49 + 50 + Also run these directly on the SQLite database: 51 + 52 + ```bash 53 + sqlite3 apps/data/ionosphere.sqlite "ALTER TABLE mentions ADD COLUMN content_type TEXT DEFAULT 'post';" 2>/dev/null 54 + sqlite3 apps/data/ionosphere.sqlite "ALTER TABLE mentions ADD COLUMN external_url TEXT;" 2>/dev/null 55 + sqlite3 apps/data/ionosphere.sqlite "ALTER TABLE mentions ADD COLUMN og_title TEXT;" 2>/dev/null 56 + sqlite3 apps/data/ionosphere.sqlite "ALTER TABLE mentions ADD COLUMN talk_rkey TEXT;" 2>/dev/null 57 + ``` 58 + 59 + - [ ] **Step 2: Verify columns** 60 + 61 + ```bash 62 + sqlite3 apps/data/ionosphere.sqlite "PRAGMA table_info(mentions);" | grep -E "content_type|external_url|og_title|talk_rkey" 63 + ``` 64 + 65 + Expected: 4 new columns listed. 66 + 67 + - [ ] **Step 3: Commit** 68 + 69 + ```bash 70 + git add apps/ionosphere-appview/src/db.ts 71 + git commit -m "feat: add content_type, external_url, og_title, talk_rkey to mentions" 72 + ``` 73 + 74 + --- 75 + 76 + ## Task 2: Discussion Fetch Script 77 + 78 + **Files:** 79 + - Create: `scripts/fetch-discussion.mjs` 80 + 81 + This script runs as a separate batch job (does not modify `fetch-mentions.mjs`). It: 82 + 1. Searches for blog/recap posts via multiple keyword queries 83 + 2. Searches for VOD site links via `domain:` queries across 20+ domains 84 + 3. Classifies each post as `blog`, `video`, or `post` 85 + 4. Extracts external URLs from facets 86 + 5. Fetches OG titles for blog posts 87 + 6. Matches posts to talks via ionosphere.tv URL parsing or speaker @-mention cross-referencing 88 + 7. Upserts into the mentions table with the new columns populated 89 + 8. Backfills profiles for any new authors 90 + 91 + - [ ] **Step 1: Create the fetch script** 92 + 93 + ```javascript 94 + import { createRequire } from 'module'; 95 + const require = createRequire( 96 + new URL('../apps/ionosphere-appview/package.json', import.meta.url).pathname 97 + ); 98 + const { BskyAgent } = require('@atproto/api'); 99 + const Database = require('better-sqlite3'); 100 + 101 + import { fileURLToPath } from 'url'; 102 + import { dirname, join } from 'path'; 103 + 104 + const __dirname = dirname(fileURLToPath(import.meta.url)); 105 + const DB_PATH = join(__dirname, '..', 'apps', 'data', 'ionosphere.sqlite'); 106 + 107 + const agent = new BskyAgent({ service: 'https://bsky.social' }); 108 + function sleep(ms) { return new Promise(r => setTimeout(r, ms)); } 109 + 110 + // ── VOD domains ──────────────────────────────────────────────────── 111 + 112 + const VOD_DOMAINS = [ 113 + 'stream.place', 'vods.sky.boo', 'vod.atverkackt.de', 'ionosphere.tv', 114 + 'atmosphereconf-vods.wisp.place', 'rpg.actor', 'vod.j4ck.xyz', 115 + 'atmosphere-vods.j4ck.xyz', 'atmosphereconf-tv.btao.org', 116 + 'stream-bsky.pages.dev', 'sites.wisp.place', 'vods.ajbird.net', 117 + 'streamhut.wisp.place', 'conf-vods.wisp.place', 'aetheros.computer', 118 + 'atmo.rsvp', 'atmosphereconf.org', 'youtube.com', 119 + ]; 120 + 121 + // ── Blog/recap queries ───────────────────────────────────────────── 122 + 123 + const BLOG_QUERIES = [ 124 + 'atmosphereconf recap', 125 + 'atmosphereconf wrote', 126 + 'atmosphereconf writeup', 127 + 'atmosphereconf takeaway', 128 + 'atmosphereconf reflection', 129 + 'atmosphereconf blog', 130 + 'atmosphere conference wrote', 131 + 'atmosphere conference recap', 132 + ]; 133 + 134 + // ── Helpers ───────────────────────────────────────────────────────── 135 + 136 + function extractLinks(post) { 137 + return (post.record?.facets || []) 138 + .flatMap(f => f.features || []) 139 + .filter(f => f.uri) 140 + .map(f => f.uri); 141 + } 142 + 143 + function classifyPost(post, searchDomain) { 144 + const links = extractLinks(post); 145 + const text = (post.record?.text || '').toLowerCase(); 146 + 147 + // If searched by a VOD domain, it's a video 148 + if (searchDomain && VOD_DOMAINS.includes(searchDomain)) return 'video'; 149 + 150 + // Check links for known blog patterns 151 + for (const link of links) { 152 + try { 153 + const url = new URL(link); 154 + if (VOD_DOMAINS.some(d => url.hostname.endsWith(d))) return 'video'; 155 + } catch {} 156 + } 157 + 158 + // Blog indicators 159 + if (text.includes('wrote') || text.includes('recap') || text.includes('writeup') || 160 + text.includes('blog') || text.includes('reflection')) { 161 + if (links.some(l => !VOD_DOMAINS.some(d => l.includes(d)))) return 'blog'; 162 + } 163 + 164 + return 'post'; 165 + } 166 + 167 + function extractPrimaryUrl(post, contentType) { 168 + const links = extractLinks(post); 169 + if (contentType === 'video') { 170 + return links.find(l => VOD_DOMAINS.some(d => l.includes(d))) || links[0] || null; 171 + } 172 + if (contentType === 'blog') { 173 + return links.find(l => !VOD_DOMAINS.some(d => l.includes(d)) && !l.includes('bsky.app')) || links[0] || null; 174 + } 175 + return links[0] || null; 176 + } 177 + 178 + function matchTalkByUrl(url, talksByRkey) { 179 + if (!url) return null; 180 + const match = url.match(/ionosphere\.tv\/talks\/([^/?#]+)/); 181 + if (match && talksByRkey.has(match[1])) return match[1]; 182 + return null; 183 + } 184 + 185 + function matchTalkBySpeaker(post, speakerHandleToTalks) { 186 + const mentions = (post.record?.facets || []) 187 + .flatMap(f => f.features || []) 188 + .filter(f => f.$type === 'app.bsky.richtext.facet#mention') 189 + .map(f => f.did); 190 + 191 + // Also check text for @handle patterns 192 + const text = post.record?.text || ''; 193 + const handleMatches = text.match(/@([\w.-]+)/g) || []; 194 + 195 + for (const handle of handleMatches) { 196 + const clean = handle.replace('@', ''); 197 + const talks = speakerHandleToTalks.get(clean); 198 + if (talks?.length === 1) return talks[0]; // unambiguous match 199 + } 200 + return null; 201 + } 202 + 203 + async function fetchOgTitle(url) { 204 + try { 205 + const controller = new AbortController(); 206 + const timeout = setTimeout(() => controller.abort(), 5000); 207 + const res = await fetch(url, { 208 + signal: controller.signal, 209 + headers: { 'User-Agent': 'ionosphere.tv/1.0' }, 210 + redirect: 'follow', 211 + }); 212 + clearTimeout(timeout); 213 + if (!res.ok) return null; 214 + const html = await res.text(); 215 + // Extract og:title 216 + const ogMatch = html.match(/<meta[^>]+property=["']og:title["'][^>]+content=["']([^"']+)["']/i) 217 + || html.match(/<meta[^>]+content=["']([^"']+)["'][^>]+property=["']og:title["']/i); 218 + if (ogMatch) return ogMatch[1]; 219 + // Fallback to <title> 220 + const titleMatch = html.match(/<title[^>]*>([^<]+)<\/title>/i); 221 + return titleMatch ? titleMatch[1].trim() : null; 222 + } catch { 223 + return null; 224 + } 225 + } 226 + 227 + // ── Main ─────────────────────────────────────────────────────────── 228 + 229 + async function main() { 230 + console.log('=== Fetch Discussion Content ===\n'); 231 + 232 + await agent.login({ 233 + identifier: 'ionosphere.tv', 234 + password: process.env.BOT_PASSWORD, 235 + }); 236 + console.log('Authenticated\n'); 237 + 238 + const db = new Database(DB_PATH); 239 + 240 + // Ensure new columns exist 241 + try { db.exec("ALTER TABLE mentions ADD COLUMN content_type TEXT DEFAULT 'post'"); } catch {} 242 + try { db.exec("ALTER TABLE mentions ADD COLUMN external_url TEXT"); } catch {} 243 + try { db.exec("ALTER TABLE mentions ADD COLUMN og_title TEXT"); } catch {} 244 + try { db.exec("ALTER TABLE mentions ADD COLUMN talk_rkey TEXT"); } catch {} 245 + 246 + const upsert = db.prepare(` 247 + INSERT INTO mentions (uri, talk_uri, author_did, author_handle, text, created_at, 248 + talk_offset_ms, byte_position, likes, reposts, replies, parent_uri, 249 + mention_type, indexed_at, content_type, external_url, og_title, talk_rkey) 250 + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 251 + ON CONFLICT(uri) DO UPDATE SET 252 + likes=excluded.likes, reposts=excluded.reposts, replies=excluded.replies, 253 + content_type=excluded.content_type, external_url=excluded.external_url, 254 + og_title=excluded.og_title, talk_rkey=excluded.talk_rkey, indexed_at=excluded.indexed_at 255 + `); 256 + 257 + // Load talk data for matching 258 + const talks = db.prepare("SELECT DISTINCT rkey, title, uri FROM talks WHERE starts_at IS NOT NULL").all(); 259 + const talksByRkey = new Map(talks.map(t => [t.rkey, t])); 260 + 261 + const speakerTalks = db.prepare(` 262 + SELECT s.handle, t.rkey 263 + FROM speakers s 264 + JOIN talk_speakers ts ON ts.speaker_uri = s.uri 265 + JOIN talks t ON t.uri = ts.talk_uri 266 + WHERE s.handle IS NOT NULL 267 + `).all(); 268 + const speakerHandleToTalks = new Map(); 269 + for (const { handle, rkey } of speakerTalks) { 270 + if (!speakerHandleToTalks.has(handle)) speakerHandleToTalks.set(handle, []); 271 + speakerHandleToTalks.get(handle).push(rkey); 272 + } 273 + 274 + const allPosts = new Map(); 275 + 276 + // Phase 1: VOD domain searches 277 + console.log('--- Phase 1: VOD domains ---'); 278 + for (const domain of VOD_DOMAINS) { 279 + try { 280 + const res = await agent.app.bsky.feed.searchPosts({ 281 + q: 'atmosphere OR atmosphereconf', 282 + domain, 283 + since: '2026-03-25T00:00:00Z', 284 + sort: 'top', 285 + limit: 100, 286 + }); 287 + const posts = res.data?.posts || []; 288 + for (const p of posts) { 289 + if (!allPosts.has(p.uri)) allPosts.set(p.uri, { post: p, searchDomain: domain }); 290 + } 291 + if (posts.length > 0) console.log(` ${domain}: ${posts.length} posts`); 292 + await sleep(200); 293 + } catch (e) { 294 + // Some domains may not return results 295 + } 296 + } 297 + 298 + // Phase 2: Blog/recap queries 299 + console.log('\n--- Phase 2: Blog/recap queries ---'); 300 + for (const q of BLOG_QUERIES) { 301 + try { 302 + const res = await agent.app.bsky.feed.searchPosts({ 303 + q, 304 + since: '2026-03-25T00:00:00Z', 305 + sort: 'top', 306 + limit: 50, 307 + }); 308 + const posts = res.data?.posts || []; 309 + for (const p of posts) { 310 + if (!allPosts.has(p.uri)) allPosts.set(p.uri, { post: p, searchDomain: null }); 311 + } 312 + if (posts.length > 0) console.log(` "${q}": ${posts.length} posts`); 313 + await sleep(200); 314 + } catch {} 315 + } 316 + 317 + // Phase 3: Top conference posts (sorted by engagement) 318 + console.log('\n--- Phase 3: Top conference posts ---'); 319 + for (const q of ['atmosphereconf', 'atmosphere conf', '#atmosphereconf', '#ATmosphere']) { 320 + try { 321 + const res = await agent.app.bsky.feed.searchPosts({ 322 + q, 323 + since: '2026-03-25T00:00:00Z', 324 + sort: 'top', 325 + limit: 100, 326 + }); 327 + const posts = res.data?.posts || []; 328 + for (const p of posts) { 329 + if (!allPosts.has(p.uri)) allPosts.set(p.uri, { post: p, searchDomain: null }); 330 + } 331 + if (posts.length > 0) console.log(` "${q}": ${posts.length} posts`); 332 + await sleep(200); 333 + } catch {} 334 + } 335 + 336 + console.log(`\nTotal unique posts: ${allPosts.size}`); 337 + 338 + // Phase 4: Classify, extract URLs, match talks, fetch OG titles 339 + console.log('\n--- Phase 4: Classify and enrich ---'); 340 + let blogCount = 0, videoCount = 0, postCount = 0, ogFetched = 0; 341 + const now = new Date().toISOString(); 342 + 343 + const batchInsert = db.transaction((items) => { 344 + for (const item of items) { 345 + upsert.run(...item); 346 + } 347 + }); 348 + 349 + const rows = []; 350 + for (const [uri, { post: p, searchDomain }] of allPosts) { 351 + const contentType = classifyPost(p, searchDomain); 352 + const externalUrl = extractPrimaryUrl(p, contentType); 353 + let talkRkey = matchTalkByUrl(externalUrl, talksByRkey); 354 + if (!talkRkey) talkRkey = matchTalkBySpeaker(p, speakerHandleToTalks); 355 + 356 + const talkUri = talkRkey ? (talksByRkey.get(talkRkey)?.uri || null) : null; 357 + 358 + if (contentType === 'blog') blogCount++; 359 + else if (contentType === 'video') videoCount++; 360 + else postCount++; 361 + 362 + rows.push([ 363 + p.uri, talkUri, p.author.did, p.author.handle, 364 + p.record?.text, p.record?.createdAt, 365 + null, null, // talk_offset_ms, byte_position 366 + p.likeCount || 0, p.repostCount || 0, p.replyCount || 0, 367 + null, // parent_uri 368 + 'discussion', now, 369 + contentType, externalUrl, null, talkRkey, 370 + ]); 371 + } 372 + 373 + batchInsert(rows); 374 + console.log(` Posts: ${postCount}, Blog posts: ${blogCount}, Videos: ${videoCount}`); 375 + 376 + // Phase 5: Fetch OG titles for blog posts 377 + console.log('\n--- Phase 5: OG titles ---'); 378 + const blogRows = db.prepare( 379 + "SELECT uri, external_url FROM mentions WHERE content_type = 'blog' AND external_url IS NOT NULL AND og_title IS NULL" 380 + ).all(); 381 + 382 + const updateOg = db.prepare("UPDATE mentions SET og_title = ? WHERE uri = ?"); 383 + for (const row of blogRows) { 384 + const title = await fetchOgTitle(row.external_url); 385 + if (title) { 386 + updateOg.run(title, row.uri); 387 + ogFetched++; 388 + console.log(` ${row.external_url} → ${title}`); 389 + } 390 + await sleep(100); 391 + } 392 + console.log(` OG titles fetched: ${ogFetched}/${blogRows.length}`); 393 + 394 + // Phase 6: Backfill profiles 395 + console.log('\n--- Phase 6: Profile backfill ---'); 396 + const missing = db.prepare(` 397 + SELECT DISTINCT m.author_did FROM mentions m 398 + LEFT JOIN profiles p ON m.author_did = p.did WHERE p.did IS NULL 399 + `).all(); 400 + 401 + const profileUpsert = db.prepare( 402 + "INSERT OR REPLACE INTO profiles (did, handle, display_name, avatar_url, fetched_at) VALUES (?, ?, ?, ?, ?)" 403 + ); 404 + let profilesFetched = 0; 405 + for (const { author_did: did } of missing) { 406 + try { 407 + const res = await fetch( 408 + `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(did)}` 409 + ); 410 + if (res.ok) { 411 + const data = await res.json(); 412 + profileUpsert.run(did, data.handle || null, data.displayName || null, data.avatar || null, now); 413 + profilesFetched++; 414 + } 415 + } catch {} 416 + await sleep(50); 417 + } 418 + console.log(` New profiles: ${profilesFetched}`); 419 + 420 + // Also backfill talk_rkey for existing during_talk mentions 421 + console.log('\n--- Phase 7: Backfill talk_rkey on existing mentions ---'); 422 + const updated = db.prepare(` 423 + UPDATE mentions SET talk_rkey = ( 424 + SELECT t.rkey FROM talks t WHERE t.uri = mentions.talk_uri LIMIT 1 425 + ) WHERE talk_uri IS NOT NULL AND talk_rkey IS NULL 426 + `).run(); 427 + console.log(` Updated ${updated.changes} existing mentions with talk_rkey`); 428 + 429 + // Summary 430 + const stats = db.prepare(` 431 + SELECT content_type, COUNT(*) as c FROM mentions 432 + WHERE content_type IS NOT NULL GROUP BY content_type 433 + `).all(); 434 + console.log('\n=== DONE ==='); 435 + for (const s of stats) console.log(` ${s.content_type}: ${s.c}`); 436 + console.log(` Total: ${db.prepare('SELECT COUNT(*) as c FROM mentions').get().c}`); 437 + 438 + db.close(); 439 + } 440 + 441 + main().catch(console.error); 442 + ``` 443 + 444 + - [ ] **Step 2: Run the script** 445 + 446 + ```bash 447 + source apps/ionosphere-appview/.env && BOT_PASSWORD="$BOT_PASSWORD" node scripts/fetch-discussion.mjs 448 + ``` 449 + 450 + Expected: Finds posts across VOD domains and blog queries, classifies them, fetches OG titles, and backfills existing mentions with `talk_rkey`. 451 + 452 + - [ ] **Step 3: Verify** 453 + 454 + ```bash 455 + sqlite3 apps/data/ionosphere.sqlite "SELECT content_type, COUNT(*) FROM mentions WHERE content_type IS NOT NULL GROUP BY content_type;" 456 + sqlite3 apps/data/ionosphere.sqlite "SELECT og_title, external_url FROM mentions WHERE og_title IS NOT NULL LIMIT 5;" 457 + sqlite3 apps/data/ionosphere.sqlite "SELECT COUNT(*) FROM mentions WHERE talk_rkey IS NOT NULL;" 458 + ``` 459 + 460 + - [ ] **Step 4: Commit** 461 + 462 + ```bash 463 + git add scripts/fetch-discussion.mjs 464 + git commit -m "feat: wider search for discussion content — VOD sites, blogs, OG metadata" 465 + ``` 466 + 467 + --- 468 + 469 + ## Task 3: API Endpoint — getDiscussion 470 + 471 + **Files:** 472 + - Modify: `apps/ionosphere-appview/src/routes.ts` (after getMentions, ~line 310) 473 + - Modify: `apps/ionosphere/src/lib/api.ts` 474 + 475 + - [ ] **Step 1: Add getDiscussion route** 476 + 477 + Add after the getMentions handler: 478 + 479 + ```typescript 480 + app.get("/xrpc/tv.ionosphere.getDiscussion", (c) => { 481 + const profileJoin = ` 482 + LEFT JOIN profiles p ON m.author_did = p.did 483 + `; 484 + const selectCols = ` 485 + m.uri, m.author_did, m.text, m.created_at, m.likes, m.reposts, m.replies, 486 + m.content_type, m.external_url, m.og_title, m.talk_rkey, m.mention_type, 487 + COALESCE(p.handle, m.author_handle) as author_handle, 488 + p.display_name as author_display_name, 489 + p.avatar_url as author_avatar_url 490 + `; 491 + 492 + // Top posts: highest engagement, exclude thread replies 493 + const posts = db.prepare(` 494 + SELECT ${selectCols}, 495 + (SELECT t.title FROM talks t WHERE t.rkey = m.talk_rkey LIMIT 1) as talk_title 496 + FROM mentions m ${profileJoin} 497 + WHERE m.parent_uri IS NULL 498 + AND (m.content_type IS NULL OR m.content_type = 'post') 499 + ORDER BY m.likes DESC 500 + LIMIT 200 501 + `).all(); 502 + 503 + // Blog posts 504 + const blogs = db.prepare(` 505 + SELECT ${selectCols}, 506 + (SELECT t.title FROM talks t WHERE t.rkey = m.talk_rkey LIMIT 1) as talk_title 507 + FROM mentions m ${profileJoin} 508 + WHERE m.content_type = 'blog' AND m.parent_uri IS NULL 509 + ORDER BY m.likes DESC 510 + `).all(); 511 + 512 + // Videos 513 + const videos = db.prepare(` 514 + SELECT ${selectCols}, 515 + (SELECT t.title FROM talks t WHERE t.rkey = m.talk_rkey LIMIT 1) as talk_title 516 + FROM mentions m ${profileJoin} 517 + WHERE m.content_type = 'video' AND m.parent_uri IS NULL 518 + ORDER BY m.likes DESC 519 + `).all(); 520 + 521 + // VOD site domains 522 + const vodSites = db.prepare(` 523 + SELECT DISTINCT 524 + REPLACE(REPLACE(REPLACE(external_url, 'https://', ''), 'http://', ''), SUBSTR(REPLACE(REPLACE(external_url, 'https://', ''), 'http://', ''), INSTR(REPLACE(REPLACE(external_url, 'https://', ''), 'http://', ''), '/')), '') as domain 525 + FROM mentions 526 + WHERE content_type = 'video' AND external_url IS NOT NULL 527 + `).all().map((r: any) => r.domain).filter(Boolean); 528 + 529 + // Stats 530 + const stats = { 531 + totalPosts: db.prepare("SELECT COUNT(*) as c FROM mentions WHERE parent_uri IS NULL").get() as any, 532 + blogCount: blogs.length, 533 + vodSiteCount: new Set(vodSites).size, 534 + uniqueAuthors: db.prepare("SELECT COUNT(DISTINCT author_did) as c FROM mentions").get() as any, 535 + }; 536 + 537 + return c.json({ 538 + posts, 539 + blogs, 540 + videos, 541 + vodSites: [...new Set(vodSites)], 542 + stats: { 543 + totalPosts: stats.totalPosts.c, 544 + blogCount: stats.blogCount, 545 + vodSiteCount: stats.vodSiteCount, 546 + uniqueAuthors: stats.uniqueAuthors.c, 547 + }, 548 + }); 549 + }); 550 + ``` 551 + 552 + - [ ] **Step 2: Add frontend API client** 553 + 554 + In `apps/ionosphere/src/lib/api.ts`, add: 555 + 556 + ```typescript 557 + export async function getDiscussion() { 558 + return fetchApi<{ 559 + posts: any[]; blogs: any[]; videos: any[]; 560 + vodSites: string[]; 561 + stats: { totalPosts: number; blogCount: number; vodSiteCount: number; uniqueAuthors: number }; 562 + }>("/xrpc/tv.ionosphere.getDiscussion"); 563 + } 564 + ``` 565 + 566 + - [ ] **Step 3: Commit** 567 + 568 + ```bash 569 + git add apps/ionosphere-appview/src/routes.ts apps/ionosphere/src/lib/api.ts 570 + git commit -m "feat: add getDiscussion XRPC endpoint" 571 + ``` 572 + 573 + --- 574 + 575 + ## Task 4: Discussion Page and Content Component 576 + 577 + **Files:** 578 + - Create: `apps/ionosphere/src/app/discussion/page.tsx` 579 + - Create: `apps/ionosphere/src/app/discussion/DiscussionContent.tsx` 580 + 581 + - [ ] **Step 1: Create the page route** 582 + 583 + `apps/ionosphere/src/app/discussion/page.tsx`: 584 + 585 + ```tsx 586 + import DiscussionContent from "./DiscussionContent"; 587 + import { getDiscussion } from "@/lib/api"; 588 + 589 + export default async function DiscussionPage() { 590 + const data = await getDiscussion().catch(() => ({ 591 + posts: [], blogs: [], videos: [], vodSites: [], 592 + stats: { totalPosts: 0, blogCount: 0, vodSiteCount: 0, uniqueAuthors: 0 }, 593 + })); 594 + 595 + return <DiscussionContent data={data} />; 596 + } 597 + ``` 598 + 599 + - [ ] **Step 2: Create the DiscussionContent component** 600 + 601 + This is the main component. It follows `IndexContent.tsx` patterns: greedy column-fill, section nav, filter bar, click-to-play panel. The file will be ~400 lines. 602 + 603 + Create `apps/ionosphere/src/app/discussion/DiscussionContent.tsx`: 604 + 605 + The component should implement: 606 + 607 + 1. **Data types**: `DiscussionItem` with uri, author_handle, author_display_name, author_avatar_url, text, likes, reposts, content_type, external_url, og_title, talk_rkey, talk_title 608 + 2. **Flow items**: `{ type: "heading", label: string }` or `{ type: "item", item: DiscussionItem }` or `{ type: "vodDirectory", sites: string[] }` or `{ type: "stats", stats: Stats }` 609 + 3. **Filter state**: `"all" | "posts" | "blogs" | "videos"` — filters which sections appear in the flow 610 + 4. **Column layout**: Reuse the greedy-fill pattern from IndexContent: measure container, compute columns, fill greedily with height estimation 611 + 5. **Section nav**: T (Top Posts) / R (Recaps) / V (Videos) sidebar buttons 612 + 6. **Item rendering**: Each item is a compact block: 613 + - Line 1: 14px avatar + handle (blue) + like count (muted) 614 + - Line 2: Post text (neutral-400, 1-2 lines truncated) or og_title for blogs 615 + - Line 3 (optional): Talk link → (neutral-500) + external link ↗ (green for blogs, purple for videos) 616 + 7. **Click handler**: Click on a talk link → opens right panel with talk video + transcript (same as concordance) 617 + 8. **Mobile**: Single-column progressive rendering (same as concordance MobileConcordance) 618 + 619 + Key measurements for column fill: 620 + - `ITEM_HEIGHT = 58` (3 lines × ~19px + 4px margin) 621 + - `HEADING_HEIGHT = 28` 622 + - `STATS_HEIGHT = 60` 623 + - `VOD_DIRECTORY_HEIGHT = 80` 624 + 625 + Filter bar at top: pill buttons styled like: 626 + ```tsx 627 + <button className={`text-xs px-3 py-1 rounded-full transition-colors ${ 628 + active ? "bg-blue-500/20 text-blue-300" : "text-neutral-500 hover:text-neutral-300" 629 + }`}>All</button> 630 + ``` 631 + 632 + Section headings in the flow: 633 + ```tsx 634 + <h3 className="text-[11px] font-bold text-neutral-500 uppercase tracking-wide border-b border-neutral-800 pb-1 mb-1 mt-2 first:mt-0"> 635 + {label} 636 + </h3> 637 + ``` 638 + 639 + Item rendering: 640 + ```tsx 641 + <div className="mb-1.5 text-[12px] leading-[1.5]"> 642 + <div className="flex items-baseline gap-1"> 643 + {item.author_avatar_url ? ( 644 + <img src={item.author_avatar_url} className="w-3.5 h-3.5 rounded-full shrink-0 relative top-[2px]" /> 645 + ) : ( 646 + <div className="w-3.5 h-3.5 rounded-full bg-neutral-700 shrink-0 relative top-[2px]" /> 647 + )} 648 + <span className="text-blue-400 text-[11px] truncate">{item.author_handle}</span> 649 + <span className="text-neutral-600 text-[10px] ml-auto shrink-0">{item.likes}♡</span> 650 + </div> 651 + <div className="text-neutral-400 pl-[18px] line-clamp-2 -mt-px"> 652 + {item.og_title || item.text} 653 + </div> 654 + {(item.talk_rkey || item.external_url) && ( 655 + <div className="pl-[18px] mt-0.5 flex gap-2"> 656 + {item.talk_rkey && ( 657 + <button onClick={() => handleSelect(item.talk_rkey)} className="text-neutral-500 text-[10px] hover:text-neutral-300"> 658 + {item.talk_title || 'Talk'} → 659 + </button> 660 + )} 661 + {item.external_url && ( 662 + <a href={item.external_url} target="_blank" rel="noopener" className={`text-[10px] ${ 663 + item.content_type === 'blog' ? 'text-emerald-500' : item.content_type === 'video' ? 'text-purple-400' : 'text-neutral-500' 664 + }`}> 665 + {new URL(item.external_url).hostname} ↗ 666 + </a> 667 + )} 668 + </div> 669 + )} 670 + </div> 671 + ``` 672 + 673 + VOD directory: 674 + ```tsx 675 + <div className="p-2 bg-neutral-900 rounded mb-2"> 676 + <div className="text-neutral-600 text-[10px] font-semibold mb-1">VOD JAM SITES</div> 677 + <div className="flex flex-wrap gap-1"> 678 + {sites.map(s => ( 679 + <a key={s} href={`https://${s}`} target="_blank" rel="noopener" 680 + className="text-purple-400 text-[10px] bg-purple-500/10 px-1.5 py-0.5 rounded">{s}</a> 681 + ))} 682 + </div> 683 + </div> 684 + ``` 685 + 686 + Stats card: 687 + ```tsx 688 + <div className="p-2 bg-neutral-900 rounded mb-2 flex gap-4 justify-center text-center"> 689 + <div><div className="text-blue-400 text-lg font-bold">{stats.totalPosts}</div><div className="text-neutral-600 text-[9px]">posts</div></div> 690 + <div><div className="text-emerald-400 text-lg font-bold">{stats.blogCount}</div><div className="text-neutral-600 text-[9px]">recaps</div></div> 691 + <div><div className="text-purple-400 text-lg font-bold">{stats.vodSiteCount}</div><div className="text-neutral-600 text-[9px]">VOD sites</div></div> 692 + <div><div className="text-amber-400 text-lg font-bold">{stats.uniqueAuthors}</div><div className="text-neutral-600 text-[9px]">people</div></div> 693 + </div> 694 + ``` 695 + 696 + The right panel for click-to-play reuses the exact same pattern as IndexContent: fetch talk data, open `<TimestampProvider>` with `<VideoPlayer>` and `<TranscriptView>`. 697 + 698 + - [ ] **Step 3: Commit** 699 + 700 + ```bash 701 + git add apps/ionosphere/src/app/discussion/ 702 + git commit -m "feat: conference discussion page with multi-column layout" 703 + ``` 704 + 705 + --- 706 + 707 + ## Task 5: Nav Update and Verification 708 + 709 + **Files:** 710 + - Modify: `apps/ionosphere/src/app/components/NavHeader.tsx:7-13` 711 + 712 + - [ ] **Step 1: Add Discussion to nav** 713 + 714 + In NavHeader.tsx, update the NAV_ITEMS array (line 7-13): 715 + 716 + ```typescript 717 + const NAV_ITEMS = [ 718 + { href: "/talks", label: "Talks" }, 719 + { href: "/tracks", label: "Tracks" }, 720 + { href: "/speakers", label: "Speakers" }, 721 + { href: "/concepts", label: "Concepts" }, 722 + { href: "/concordance", label: "Index" }, 723 + { href: "/discussion", label: "Discussion" }, 724 + ]; 725 + ``` 726 + 727 + - [ ] **Step 2: Restart appview and frontend** 728 + 729 + Restart both servers (kill existing, re-launch on ports 3010 and 3011). 730 + 731 + - [ ] **Step 3: Verify** 732 + 733 + 1. Check API: `curl -s http://localhost:3010/xrpc/tv.ionosphere.getDiscussion | node -e "process.stdin.on('data',d=>{const j=JSON.parse(d);console.log('Posts:',j.posts.length,'Blogs:',j.blogs.length,'Videos:',j.videos.length,'VOD sites:',j.vodSites.length)})"` 734 + 2. Open http://localhost:3011/discussion — verify multi-column layout, section headers, filter bar, clickable items 735 + 3. Click a post with a talk link → verify right panel opens with video + transcript 736 + 4. Test filter pills: "Blog Posts" should show only blog section, "Videos" only video section 737 + 738 + - [ ] **Step 4: Commit** 739 + 740 + ```bash 741 + git add apps/ionosphere/src/app/components/NavHeader.tsx 742 + git commit -m "feat: add Discussion to site navigation" 743 + ```