Ionosphere.tv
3
fork

Configure Feed

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

feat: Discussion page with multi-column layout, API endpoint, and nav link

Add getDiscussion API endpoint returning posts/blogs/videos sorted by likes,
Discussion page with greedy column-fill layout matching the concordance Index,
filter bar (All/Posts/Blogs/Videos), click-to-play panel, and nav header link.

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

+641
+79
apps/ionosphere-appview/src/routes.ts
··· 318 318 return c.json({ mentions, total: mentions.length }); 319 319 }); 320 320 321 + app.get("/xrpc/tv.ionosphere.getDiscussion", (c) => { 322 + // Posts: content_type = 'post' or NULL, top-level only, sorted by likes DESC 323 + const posts = db.prepare( 324 + `SELECT m.uri, m.author_did, m.text, m.created_at, m.likes, m.reposts, m.replies, 325 + m.content_type, m.external_url, m.og_title, m.talk_rkey, m.mention_type, 326 + COALESCE(p.handle, m.author_handle) as author_handle, 327 + p.display_name as author_display_name, 328 + p.avatar_url as author_avatar_url, 329 + (SELECT t.title FROM talks t WHERE t.rkey = m.talk_rkey LIMIT 1) as talk_title 330 + FROM mentions m 331 + LEFT JOIN profiles p ON m.author_did = p.did 332 + WHERE (m.content_type IS NULL OR m.content_type = 'post') AND m.parent_uri IS NULL 333 + ORDER BY m.likes DESC 334 + LIMIT 200` 335 + ).all(); 336 + 337 + // Blogs: content_type = 'blog', top-level only 338 + const blogs = db.prepare( 339 + `SELECT m.uri, m.author_did, m.text, m.created_at, m.likes, m.reposts, m.replies, 340 + m.content_type, m.external_url, m.og_title, m.talk_rkey, m.mention_type, 341 + COALESCE(p.handle, m.author_handle) as author_handle, 342 + p.display_name as author_display_name, 343 + p.avatar_url as author_avatar_url, 344 + (SELECT t.title FROM talks t WHERE t.rkey = m.talk_rkey LIMIT 1) as talk_title 345 + FROM mentions m 346 + LEFT JOIN profiles p ON m.author_did = p.did 347 + WHERE m.content_type = 'blog' AND m.parent_uri IS NULL 348 + ORDER BY m.likes DESC` 349 + ).all(); 350 + 351 + // Videos: content_type = 'video', top-level only 352 + const videos = db.prepare( 353 + `SELECT m.uri, m.author_did, m.text, m.created_at, m.likes, m.reposts, m.replies, 354 + m.content_type, m.external_url, m.og_title, m.talk_rkey, m.mention_type, 355 + COALESCE(p.handle, m.author_handle) as author_handle, 356 + p.display_name as author_display_name, 357 + p.avatar_url as author_avatar_url, 358 + (SELECT t.title FROM talks t WHERE t.rkey = m.talk_rkey LIMIT 1) as talk_title 359 + FROM mentions m 360 + LEFT JOIN profiles p ON m.author_did = p.did 361 + WHERE m.content_type = 'video' AND m.parent_uri IS NULL 362 + ORDER BY m.likes DESC` 363 + ).all(); 364 + 365 + // VOD sites: unique domains from video external_urls 366 + const vodRows = db.prepare( 367 + `SELECT DISTINCT m.external_url FROM mentions m 368 + WHERE m.content_type = 'video' AND m.external_url IS NOT NULL AND m.parent_uri IS NULL` 369 + ).all() as any[]; 370 + const vodSites = [...new Set( 371 + vodRows.map((r: any) => { 372 + try { return new URL(r.external_url).hostname; } catch { return null; } 373 + }).filter(Boolean) 374 + )] as string[]; 375 + 376 + // Stats 377 + const statsRow = db.prepare( 378 + `SELECT 379 + COUNT(*) as totalPosts, 380 + COUNT(CASE WHEN content_type = 'blog' THEN 1 END) as blogCount, 381 + COUNT(DISTINCT author_did) as uniqueAuthors 382 + FROM mentions 383 + WHERE parent_uri IS NULL` 384 + ).get() as any; 385 + 386 + return c.json({ 387 + posts, 388 + blogs, 389 + videos, 390 + vodSites, 391 + stats: { 392 + totalPosts: statsRow?.totalPosts || 0, 393 + blogCount: statsRow?.blogCount || 0, 394 + vodSiteCount: vodSites.length, 395 + uniqueAuthors: statsRow?.uniqueAuthors || 0, 396 + }, 397 + }); 398 + }); 399 + 321 400 app.get("/xrpc/tv.ionosphere.getConceptClusters", (c) => { 322 401 try { 323 402 const clustersPath = path.resolve(import.meta.dirname, "../data/concept-clusters.json");
+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 14 ]; 14 15 15 16 export default function NavHeader() {
+538
apps/ionosphere/src/app/discussion/DiscussionContent.tsx
··· 1 + "use client"; 2 + 3 + import React, { useState, useMemo, useEffect, useCallback, useRef } from "react"; 4 + import { TimestampProvider, useTimestamp } from "@/app/components/TimestampProvider"; 5 + import VideoPlayer from "@/app/components/VideoPlayer"; 6 + import TranscriptView from "@/app/components/TranscriptView"; 7 + import { fetchComments, type CommentData } from "@/lib/comments"; 8 + 9 + function InitialSeek({ timestampNs }: { timestampNs: number }) { 10 + const { seekTo } = useTimestamp(); 11 + useEffect(() => { 12 + let cancelled = false; 13 + function trySeekAndPlay() { 14 + if (cancelled) return; 15 + const video = document.querySelector<HTMLVideoElement>("video"); 16 + if (!video) { setTimeout(trySeekAndPlay, 100); return; } 17 + function doSeekAndPlay() { 18 + if (cancelled) return; 19 + if (timestampNs > 0) seekTo(timestampNs); 20 + video!.play().catch(() => {}); 21 + } 22 + if (video.readyState >= 2) { doSeekAndPlay(); return; } 23 + video.addEventListener("loadeddata", doSeekAndPlay, { once: true }); 24 + video.addEventListener("canplay", doSeekAndPlay, { once: true }); 25 + video.play().catch(() => {}); 26 + } 27 + trySeekAndPlay(); 28 + return () => { cancelled = true; }; 29 + }, [timestampNs, seekTo]); 30 + return null; 31 + } 32 + 33 + // --- Types --- 34 + 35 + interface DiscussionItem { 36 + uri: string; 37 + author_did: string; 38 + text: string; 39 + created_at: string; 40 + likes: number; 41 + reposts: number; 42 + replies: number; 43 + content_type: string | null; 44 + external_url: string | null; 45 + og_title: string | null; 46 + talk_rkey: string | null; 47 + mention_type: string | null; 48 + author_handle: string; 49 + author_display_name: string | null; 50 + author_avatar_url: string | null; 51 + talk_title: string | null; 52 + } 53 + 54 + interface Stats { 55 + totalPosts: number; 56 + blogCount: number; 57 + vodSiteCount: number; 58 + uniqueAuthors: number; 59 + } 60 + 61 + interface DiscussionData { 62 + posts: DiscussionItem[]; 63 + blogs: DiscussionItem[]; 64 + videos: DiscussionItem[]; 65 + vodSites: string[]; 66 + stats: Stats; 67 + } 68 + 69 + type FlowItem = 70 + | { type: "heading"; label: string } 71 + | { type: "item"; item: DiscussionItem } 72 + | { type: "stats"; stats: Stats } 73 + | { type: "vodDirectory"; sites: string[] }; 74 + 75 + type FilterKey = "all" | "posts" | "blogs" | "videos"; 76 + 77 + // --- Height estimation --- 78 + 79 + function estimateItemHeight(item: FlowItem): number { 80 + if (item.type === "stats") return 70; 81 + if (item.type === "heading") return 28; 82 + if (item.type === "vodDirectory") return 80; 83 + // item 84 + return 58; 85 + } 86 + 87 + // --- Greedy column fill --- 88 + 89 + interface FilledColumn { 90 + items: FlowItem[]; 91 + endIndex: number; 92 + usedHeight: number; 93 + extraSpacing: number; 94 + } 95 + 96 + function fillColumn(flowItems: FlowItem[], startIndex: number, columnHeight: number): FilledColumn { 97 + const items: FlowItem[] = []; 98 + let used = 0; 99 + let i = startIndex; 100 + 101 + while (i < flowItems.length) { 102 + const h = estimateItemHeight(flowItems[i]); 103 + if (used + h > columnHeight && items.length > 0) break; 104 + items.push(flowItems[i]); 105 + used += h; 106 + i++; 107 + } 108 + 109 + const remaining = Math.max(0, columnHeight - used); 110 + const extraSpacing = items.length > 1 ? Math.min(remaining / (items.length - 1), 6) : 0; 111 + 112 + return { items, endIndex: i, usedHeight: used, extraSpacing }; 113 + } 114 + 115 + // --- Mobile single-column with progressive rendering --- 116 + 117 + const MobileDiscussion = React.forwardRef<HTMLDivElement, { 118 + flowItems: FlowItem[]; 119 + renderItem: (item: FlowItem, extraMargin: number) => React.ReactNode; 120 + }>(function MobileDiscussion({ flowItems, renderItem }, ref) { 121 + const BATCH = 200; 122 + const [visibleCount, setVisibleCount] = useState(BATCH); 123 + const sentinelRef = useRef<HTMLDivElement>(null); 124 + 125 + useEffect(() => { 126 + const sentinel = sentinelRef.current; 127 + if (!sentinel) return; 128 + const observer = new IntersectionObserver( 129 + ([entry]) => { 130 + if (entry.isIntersecting) { 131 + setVisibleCount((prev) => Math.min(prev + BATCH, flowItems.length)); 132 + } 133 + }, 134 + { rootMargin: "400px" } 135 + ); 136 + observer.observe(sentinel); 137 + return () => observer.disconnect(); 138 + }, [flowItems.length]); 139 + 140 + return ( 141 + <div ref={ref} className="flex-1 min-h-0 overflow-y-auto"> 142 + {flowItems.slice(0, visibleCount).map((item) => renderItem(item, 0))} 143 + {visibleCount < flowItems.length && ( 144 + <div ref={sentinelRef} className="text-center text-neutral-600 text-xs py-4"> 145 + Loading more... 146 + </div> 147 + )} 148 + </div> 149 + ); 150 + }); 151 + 152 + // --- Section nav labels per filter --- 153 + 154 + const SECTION_NAV: Record<FilterKey, { key: string; label: string }[]> = { 155 + all: [ 156 + { key: "Top Posts", label: "T" }, 157 + { key: "Recaps & Blog Posts", label: "R" }, 158 + { key: "Videos & VOD Sites", label: "V" }, 159 + ], 160 + posts: [{ key: "Top Posts", label: "T" }], 161 + blogs: [{ key: "Recaps & Blog Posts", label: "R" }], 162 + videos: [{ key: "Videos & VOD Sites", label: "V" }], 163 + }; 164 + 165 + // --- Component --- 166 + 167 + export default function DiscussionContent({ data }: { data: DiscussionData }) { 168 + const [filter, setFilter] = useState<FilterKey>("all"); 169 + 170 + const [selectedTalk, setSelectedTalk] = useState<{ 171 + rkey: string; title: string; videoUri: string; 172 + offsetNs: number; document: any; seekToNs: number; 173 + talkUri: string; 174 + } | null>(null); 175 + 176 + const [comments, setComments] = useState<CommentData[]>([]); 177 + const [widePlayer, setWidePlayer] = useState(false); 178 + const [showMobilePlayer, setShowMobilePlayer] = useState(false); 179 + 180 + const containerRef = useRef<HTMLDivElement>(null); 181 + const columnsRef = useRef<HTMLDivElement>(null); 182 + const [columnWidth, setColumnWidth] = useState(280); 183 + const [columnHeight, setColumnHeight] = useState(600); 184 + const [numCols, setNumCols] = useState(4); 185 + 186 + // Measure container 187 + const filterBarRef = useRef<HTMLDivElement>(null); 188 + useEffect(() => { 189 + const el = containerRef.current; 190 + if (!el) return; 191 + const measure = () => { 192 + const available = el.clientWidth; 193 + if (available < 640) { 194 + setNumCols(1); 195 + setColumnWidth(available - 32); 196 + setColumnHeight(0); 197 + return; 198 + } 199 + const padding = 32; 200 + const usable = available - padding; 201 + const cols = Math.max(2, Math.floor(usable / 280)); 202 + const gap = (cols - 1) * 24; 203 + setColumnWidth(Math.max(200, Math.floor((usable - gap) / cols))); 204 + setNumCols(cols); 205 + const filterH = filterBarRef.current?.offsetHeight || 0; 206 + setColumnHeight(el.clientHeight - filterH - 20); 207 + }; 208 + measure(); 209 + const observer = new ResizeObserver(measure); 210 + observer.observe(el); 211 + return () => observer.disconnect(); 212 + }, []); 213 + 214 + // Build flow items from data based on filter 215 + const flowItems = useMemo(() => { 216 + const items: FlowItem[] = []; 217 + 218 + // Stats card at the beginning 219 + items.push({ type: "stats", stats: data.stats }); 220 + 221 + if (filter === "all" || filter === "posts") { 222 + if (data.posts.length > 0) { 223 + items.push({ type: "heading", label: "Top Posts" }); 224 + for (const post of data.posts) { 225 + items.push({ type: "item", item: post }); 226 + } 227 + } 228 + } 229 + 230 + if (filter === "all" || filter === "blogs") { 231 + if (data.blogs.length > 0) { 232 + items.push({ type: "heading", label: "Recaps & Blog Posts" }); 233 + for (const blog of data.blogs) { 234 + items.push({ type: "item", item: blog }); 235 + } 236 + } 237 + } 238 + 239 + if (filter === "all" || filter === "videos") { 240 + if (data.videos.length > 0) { 241 + items.push({ type: "heading", label: "Videos & VOD Sites" }); 242 + for (const video of data.videos) { 243 + items.push({ type: "item", item: video }); 244 + } 245 + } 246 + if (data.vodSites.length > 0) { 247 + items.push({ type: "vodDirectory", sites: data.vodSites }); 248 + } 249 + } 250 + 251 + return items; 252 + }, [data, filter]); 253 + 254 + // Section-to-index mapping 255 + const sectionToIndex = useMemo(() => { 256 + const map = new Map<string, number>(); 257 + for (let i = 0; i < flowItems.length; i++) { 258 + const item = flowItems[i]; 259 + if (item.type === "heading" && !map.has(item.label)) { 260 + map.set(item.label, i); 261 + } 262 + } 263 + return map; 264 + }, [flowItems]); 265 + 266 + // Start index for column fill 267 + const [startIndex, setStartIndex] = useState(0); 268 + 269 + // Reset on filter change 270 + useEffect(() => { setStartIndex(0); }, [filter]); 271 + 272 + // Greedy-fill columns 273 + const filled = useMemo(() => { 274 + if (numCols <= 1 || columnHeight <= 0) return []; 275 + const cols: FilledColumn[] = []; 276 + let idx = startIndex; 277 + for (let c = 0; c < numCols + 1; c++) { 278 + if (idx >= flowItems.length) { 279 + cols.push({ items: [], endIndex: idx, usedHeight: 0, extraSpacing: 0 }); 280 + } else { 281 + const col = fillColumn(flowItems, idx, columnHeight); 282 + cols.push(col); 283 + idx = col.endIndex; 284 + } 285 + } 286 + return cols; 287 + }, [startIndex, numCols, columnHeight, flowItems]); 288 + 289 + const visibleFilled = filled.slice(0, numCols); 290 + 291 + // Current section for nav highlighting 292 + const currentSection = useMemo(() => { 293 + let section = ""; 294 + for (let i = 0; i <= startIndex && i < flowItems.length; i++) { 295 + if (flowItems[i].type === "heading") section = (flowItems[i] as { type: "heading"; label: string }).label; 296 + } 297 + return section; 298 + }, [startIndex, flowItems]); 299 + 300 + // Scroll 301 + const canScrollForward = filled.length > 0 && filled[filled.length - 1].endIndex < flowItems.length; 302 + const canScrollBack = startIndex > 0; 303 + 304 + const scrollForward = useCallback(() => { 305 + if (visibleFilled.length > 0 && visibleFilled[0].endIndex < flowItems.length) { 306 + setStartIndex(visibleFilled[0].endIndex); 307 + } 308 + }, [visibleFilled, flowItems.length]); 309 + 310 + const scrollBack = useCallback(() => { 311 + if (startIndex <= 0) return; 312 + let idx = startIndex - 1; 313 + let used = 0; 314 + while (idx >= 0) { 315 + const h = estimateItemHeight(flowItems[idx]); 316 + if (used + h > columnHeight && used > 0) break; 317 + used += h; 318 + idx--; 319 + } 320 + setStartIndex(Math.max(0, idx + 1)); 321 + }, [startIndex, flowItems, columnHeight]); 322 + 323 + // Wheel handler 324 + useEffect(() => { 325 + if (numCols <= 1) return; 326 + const el = containerRef.current; 327 + if (!el) return; 328 + const onWheel = (e: WheelEvent) => { 329 + e.preventDefault(); 330 + if (e.deltaY > 0) scrollForward(); 331 + else if (e.deltaY < 0) scrollBack(); 332 + }; 333 + el.addEventListener("wheel", onWheel, { passive: false }); 334 + return () => el.removeEventListener("wheel", onWheel); 335 + }, [numCols, scrollForward, scrollBack]); 336 + 337 + const handleSelect = useCallback( 338 + async (rkey: string) => { 339 + try { 340 + const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:9401"; 341 + const res = await fetch(`${API_BASE}/xrpc/tv.ionosphere.getTalk?rkey=${encodeURIComponent(rkey)}`); 342 + if (!res.ok) return; 343 + const { talk } = await res.json(); 344 + const doc = talk.document ? JSON.parse(talk.document) : null; 345 + setSelectedTalk({ 346 + rkey, title: talk.title, videoUri: talk.video_uri, 347 + offsetNs: talk.video_offset_ns || 0, 348 + document: doc?.facets?.length > 0 ? doc : null, seekToNs: 0, 349 + talkUri: talk.uri, 350 + }); 351 + setShowMobilePlayer(true); 352 + fetchComments(rkey).then(setComments); 353 + } catch {} 354 + }, 355 + [] 356 + ); 357 + 358 + const scrollToSection = useCallback((sectionLabel: string) => { 359 + if (numCols <= 1) { 360 + const el = document.getElementById(`section-${sectionLabel.replace(/\s+/g, "-")}`); 361 + if (el) el.scrollIntoView({ behavior: "instant", block: "start" }); 362 + } else { 363 + const idx = sectionToIndex.get(sectionLabel); 364 + if (idx !== undefined) setStartIndex(idx); 365 + } 366 + }, [sectionToIndex, numCols]); 367 + 368 + // Render a flow item 369 + const renderItem = (item: FlowItem, extraMargin: number) => { 370 + const style = extraMargin > 0 ? { marginBottom: extraMargin } : undefined; 371 + 372 + if (item.type === "stats") { 373 + return ( 374 + <div key="stats" className="mb-2 p-2 rounded bg-neutral-900 border border-neutral-800 text-[11px] text-neutral-500" style={style}> 375 + <div className="flex gap-4 flex-wrap"> 376 + <span>{item.stats.totalPosts.toLocaleString()} posts</span> 377 + <span>{item.stats.blogCount} recaps</span> 378 + <span>{item.stats.vodSiteCount} VOD sites</span> 379 + <span>{item.stats.uniqueAuthors} authors</span> 380 + </div> 381 + </div> 382 + ); 383 + } 384 + 385 + if (item.type === "heading") { 386 + return ( 387 + <h2 key={`h-${item.label}`} id={`section-${item.label.replace(/\s+/g, "-")}`} 388 + className="text-[13px] font-bold text-neutral-500 border-b border-neutral-800 pb-0.5 mb-1 mt-2 first:mt-0" style={style}> 389 + {item.label} 390 + </h2> 391 + ); 392 + } 393 + 394 + if (item.type === "vodDirectory") { 395 + return ( 396 + <div key="vod-dir" className="mb-2 flex flex-wrap gap-1.5" style={style}> 397 + {item.sites.map((site) => ( 398 + <span key={site} className="text-[10px] px-2 py-0.5 rounded-full bg-neutral-800 text-neutral-400"> 399 + {site} 400 + </span> 401 + ))} 402 + </div> 403 + ); 404 + } 405 + 406 + // item 407 + const di = item.item; 408 + return ( 409 + <div key={di.uri} className="mb-1.5 text-[12px] leading-[1.5]" style={style}> 410 + <div className="flex items-baseline gap-1"> 411 + {di.author_avatar_url ? ( 412 + <img src={di.author_avatar_url} alt="" className="w-3.5 h-3.5 rounded-full shrink-0 relative top-[2px]" /> 413 + ) : ( 414 + <div className="w-3.5 h-3.5 rounded-full bg-neutral-700 shrink-0 relative top-[2px]" /> 415 + )} 416 + <span className="text-blue-400 text-[11px] truncate">{di.author_handle}</span> 417 + <span className="text-neutral-600 text-[10px] ml-auto shrink-0">{di.likes || 0}&#9825;</span> 418 + </div> 419 + <div className="text-neutral-400 pl-[18px] line-clamp-2 -mt-px"> 420 + {di.og_title || di.text} 421 + </div> 422 + {(di.talk_rkey || di.external_url) && ( 423 + <div className="pl-[18px] mt-0.5 flex gap-2 text-[10px]"> 424 + {di.talk_rkey && ( 425 + <button onClick={() => handleSelect(di.talk_rkey!)} 426 + className="text-neutral-500 hover:text-neutral-300 truncate"> 427 + {di.talk_title || "Talk"} &rarr; 428 + </button> 429 + )} 430 + {di.external_url && ( 431 + <a href={di.external_url} target="_blank" rel="noopener" 432 + className={di.content_type === "blog" ? "text-emerald-500" : di.content_type === "video" ? "text-purple-400" : "text-neutral-500"}> 433 + {(() => { try { return new URL(di.external_url).hostname; } catch { return "link"; } })()} &#8599; 434 + </a> 435 + )} 436 + </div> 437 + )} 438 + </div> 439 + ); 440 + }; 441 + 442 + return ( 443 + <div className="h-full flex"> 444 + {/* Section nav */} 445 + <nav className="shrink-0 w-8 flex flex-col items-center justify-center gap-0 border-r border-neutral-800 py-1"> 446 + {SECTION_NAV[filter].map((s) => ( 447 + <button 448 + key={s.key} 449 + onClick={() => scrollToSection(s.key)} 450 + className={`text-[11px] leading-none transition-colors w-6 h-6 flex items-center justify-center ${ 451 + s.key === currentSection ? "text-neutral-100 font-bold" : "text-neutral-500 hover:text-neutral-100" 452 + }`} 453 + > 454 + {s.label} 455 + </button> 456 + ))} 457 + </nav> 458 + 459 + {/* Main area */} 460 + <div ref={containerRef} className={`flex-1 min-w-0 flex flex-col overflow-hidden px-4 pt-3 pb-2 ${showMobilePlayer ? "hidden md:flex" : ""}`}> 461 + {/* Filter bar */} 462 + <div ref={filterBarRef} className="flex items-center gap-2 mb-2 shrink-0"> 463 + {([ 464 + { key: "all" as FilterKey, label: "All" }, 465 + { key: "posts" as FilterKey, label: "Top Posts" }, 466 + { key: "blogs" as FilterKey, label: "Recaps & Blog Posts" }, 467 + { key: "videos" as FilterKey, label: "Videos & VOD Sites" }, 468 + ]).map((f) => ( 469 + <button key={f.key} onClick={() => setFilter(f.key)} 470 + className={`text-xs px-3 py-1 rounded-full transition-colors ${ 471 + filter === f.key ? "bg-blue-500/20 text-blue-300" : "text-neutral-500 hover:text-neutral-300" 472 + }`}>{f.label}</button> 473 + ))} 474 + <span className="text-sm text-neutral-500 ml-auto shrink-0"> 475 + {data.stats.totalPosts.toLocaleString()} posts 476 + </span> 477 + </div> 478 + 479 + {/* Columns */} 480 + {numCols <= 1 ? ( 481 + <MobileDiscussion ref={columnsRef} flowItems={flowItems} renderItem={renderItem} /> 482 + ) : ( 483 + <div ref={columnsRef} className="flex gap-6 flex-1 min-h-0"> 484 + {visibleFilled.map((col, colIdx) => ( 485 + <div key={`${startIndex}-${colIdx}`} className="min-w-0 flex-1 overflow-hidden"> 486 + {col.items.map((item) => renderItem(item, col.extraSpacing))} 487 + </div> 488 + ))} 489 + </div> 490 + )} 491 + </div> 492 + 493 + {/* Right: player panel */} 494 + <div className={ 495 + !selectedTalk 496 + ? "hidden" 497 + : showMobilePlayer 498 + ? `flex w-full ${widePlayer ? "md:w-2/3" : "md:w-[400px]"} shrink-0 md:border-l border-neutral-800 flex-col transition-all` 499 + : `hidden md:flex ${widePlayer ? "md:w-2/3" : "md:w-[400px]"} shrink-0 border-l border-neutral-800 flex-col transition-all` 500 + }> 501 + {selectedTalk ? ( 502 + <TimestampProvider key={selectedTalk.rkey + selectedTalk.seekToNs}> 503 + <InitialSeek timestampNs={selectedTalk.seekToNs} /> 504 + <div className="p-3 border-b border-neutral-800 text-sm font-medium flex items-center gap-2"> 505 + <button 506 + onClick={() => { setShowMobilePlayer(false); setSelectedTalk(null); }} 507 + className="md:hidden text-neutral-400 hover:text-neutral-200 transition-colors shrink-0 text-sm" 508 + >&larr; Back</button> 509 + <button 510 + onClick={() => setWidePlayer(!widePlayer)} 511 + className="text-neutral-500 hover:text-neutral-200 transition-colors shrink-0 hidden md:block" 512 + title={widePlayer ? "Collapse player" : "Expand player"} 513 + >{widePlayer ? "\u2192" : "\u2190"}</button> 514 + <a href={`/talks/${selectedTalk.rkey}`} className="truncate hover:text-neutral-100 transition-colors min-w-0">{selectedTalk.title}</a> 515 + <a 516 + href={`/talks/${selectedTalk.rkey}`} 517 + className="text-neutral-500 hover:text-neutral-200 transition-colors shrink-0 text-xs ml-1" 518 + title="Open full talk page" 519 + >&#x2197;</a> 520 + </div> 521 + <div className="shrink-0 bg-black overflow-hidden"> 522 + <VideoPlayer videoUri={selectedTalk.videoUri} offsetNs={selectedTalk.offsetNs} /> 523 + </div> 524 + {selectedTalk.document && ( 525 + <div className="flex-1 min-h-0"> 526 + <TranscriptView document={selectedTalk.document} transcriptUri={selectedTalk.talkUri} comments={comments} onCommentPublished={() => fetchComments(selectedTalk.rkey).then(setComments)} /> 527 + </div> 528 + )} 529 + </TimestampProvider> 530 + ) : ( 531 + <div className="flex-1 flex items-center justify-center text-neutral-600 text-sm p-6 text-center"> 532 + Click a talk link to play 533 + </div> 534 + )} 535 + </div> 536 + </div> 537 + ); 538 + }
+13
apps/ionosphere/src/app/discussion/page.tsx
··· 1 + import DiscussionContent from "./DiscussionContent"; 2 + import { getDiscussion } from "@/lib/api"; 3 + 4 + export default async function DiscussionPage() { 5 + const data = await getDiscussion().catch(() => ({ 6 + posts: [], 7 + blogs: [], 8 + videos: [], 9 + vodSites: [], 10 + stats: { totalPosts: 0, blogCount: 0, vodSiteCount: 0, uniqueAuthors: 0 }, 11 + })); 12 + return <DiscussionContent data={data} />; 13 + }
+10
apps/ionosphere/src/lib/api.ts
··· 42 42 return fetchApi<{ clusters: any[] }>("/xrpc/tv.ionosphere.getConceptClusters"); 43 43 } 44 44 45 + export async function getDiscussion() { 46 + return fetchApi<{ 47 + posts: any[]; 48 + blogs: any[]; 49 + videos: any[]; 50 + vodSites: string[]; 51 + stats: { totalPosts: number; blogCount: number; vodSiteCount: number; uniqueAuthors: number }; 52 + }>("/xrpc/tv.ionosphere.getDiscussion"); 53 + } 54 + 45 55 export async function getTracks() { 46 56 return fetchApi<{ tracks: any[] }>("/xrpc/tv.ionosphere.getTracks"); 47 57 }