A stream.place VOD client inspired by icarly.com
1
fork

Configure Feed

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

Improve infinite browsing and media interactions

jack 382c020b f367b315

+749 -220
public/iStream-upper-right-image.png

This is a binary file and will not be displayed.

+112 -46
src/app/icarly.css
··· 28 28 overflow-x: hidden; 29 29 } 30 30 31 + @keyframes bounce { 32 + 0%, 100% { transform: translateY(0); } 33 + 25% { transform: translateY(-15px); } 34 + 50% { transform: translateY(-8px); } 35 + 75% { transform: translateY(-12px); } 36 + } 37 + 38 + @keyframes shake { 39 + 0%, 100% { transform: translateX(0); } 40 + 10%, 30%, 50%, 70%, 90% { transform: translateX(-5px); } 41 + 20%, 40%, 60%, 80% { transform: translateX(5px); } 42 + } 43 + 44 + @keyframes colorFlash { 45 + 0%, 100% { filter: hue-rotate(0deg); } 46 + 25% { filter: hue-rotate(90deg); } 47 + 50% { filter: hue-rotate(180deg); } 48 + 75% { filter: hue-rotate(270deg); } 49 + } 50 + 51 + @keyframes spinFull { 52 + from { transform: rotate(0deg) scale(1); } 53 + 50% { transform: rotate(180deg) scale(1.2); } 54 + to { transform: rotate(360deg) scale(1); } 55 + } 56 + 57 + @keyframes jiggle { 58 + 0%, 100% { transform: rotate(0deg); } 59 + 25% { transform: rotate(-8deg); } 60 + 50% { transform: rotate(8deg); } 61 + 75% { transform: rotate(-8deg); } 62 + } 63 + 31 64 /* Animations - ONLY for floating elements */ 32 65 @keyframes float { 33 66 0%, 100% { transform: translateY(0); } ··· 60 93 position: relative; 61 94 } 62 95 63 - /* Header Section - Clean Layout */ 96 + /* Header Section - THINNER Compact Layout */ 64 97 .icarly-header { 65 98 background: linear-gradient(180deg, #384C8B 0%, #5A7A9B 30%, #7BA05B 60%, #A6CC3A 100%); 66 99 border: 4px solid #fff; 67 100 border-bottom: none; 68 - padding: 20px 30px 30px 30px; 101 + padding: 10px 20px; 69 102 position: relative; 70 - min-height: 300px; 103 + min-height: 160px; 71 104 box-shadow: 0 0 20px rgba(0,0,0,0.3); 72 105 display: grid; 73 - grid-template-columns: 1fr auto 1fr; 74 - grid-template-rows: auto auto 1fr; 106 + grid-template-columns: auto 1fr auto; 107 + grid-template-rows: auto auto; 75 108 grid-template-areas: 76 - "homepage . rightcol" 77 - "logo logo rightcol" 78 - "search date rightcol"; 79 - gap: 15px 30px; 109 + "leftcol middle rightcol" 110 + "leftcol middle rightcol"; 111 + gap: 20px; 112 + align-items: center; 113 + } 114 + 115 + /* Left column - Homepage + Logo stacked */ 116 + .header-left-column { 117 + grid-area: leftcol; 118 + display: flex; 119 + flex-direction: column; 120 + align-items: center; 121 + gap: 5px; 122 + justify-self: start; 123 + align-self: start; 80 124 } 81 125 82 126 /* Homepage button */ 83 127 .homepage-btn { 84 - grid-area: homepage; 85 - justify-self: center; 86 128 background: #000; 87 129 color: #fff; 88 - padding: 8px 20px; 130 + padding: 6px 18px; 89 131 font-weight: bold; 90 - font-size: 16px; 132 + font-size: 15px; 91 133 display: flex; 92 134 align-items: center; 93 135 gap: 8px; ··· 97 139 box-shadow: 4px 4px 0 rgba(0,0,0,0.3); 98 140 animation: glow 2s infinite; 99 141 z-index: 20; 100 - align-self: start; 101 142 } 102 143 103 144 .homepage-btn:hover { ··· 110 151 animation: spin 3s linear infinite; 111 152 } 112 153 113 - /* Logo styling - ENLARGED */ 154 + /* Logo styling - EXTRA LARGE */ 114 155 .icarly-logo { 115 - grid-area: logo; 116 - align-self: center; 117 - justify-self: center; 118 156 background: transparent; 119 157 padding: 0; 120 158 border: none; ··· 124 162 display: flex; 125 163 align-items: center; 126 164 justify-content: center; 127 - max-width: 300px; 165 + max-width: 650px; 166 + margin-top: -10px; 128 167 } 129 168 130 169 .icarly-logo:hover { ··· 134 173 .icarly-logo img { 135 174 height: auto; 136 175 width: 100%; 137 - max-height: 80px; 176 + max-height: 250px; 138 177 object-fit: contain; 139 178 } 140 179 180 + /* Middle column - Date and Search */ 181 + .header-middle-column { 182 + grid-area: middle; 183 + display: flex; 184 + flex-direction: column; 185 + align-items: center; 186 + justify-content: center; 187 + gap: 15px; 188 + } 189 + 141 190 /* Date display */ 142 191 .date-display { 143 - grid-area: date; 144 - align-self: start; 145 - justify-self: start; 146 192 color: #fff; 147 - font-size: 18px; 193 + font-size: 17px; 148 194 font-weight: bold; 149 195 text-shadow: 2px 2px 0 rgba(0,0,0,0.3); 150 196 z-index: 5; 151 - padding-left: 30px; 197 + text-align: center; 152 198 } 153 199 154 200 .lemon-tube { 155 201 color: #FF6347; 156 - font-size: 24px; 202 + font-size: 22px; 157 203 font-weight: bold; 158 204 text-shadow: 3px 3px 0 #fff, 3px 3px 8px rgba(0,0,0,0.3); 159 - margin-top: 5px; 205 + margin-top: 3px; 160 206 } 161 207 162 208 /* Search section */ 163 209 .search-section { 164 - grid-area: search; 165 - align-self: center; 166 - justify-self: end; 167 210 text-align: center; 168 211 z-index: 5; 169 - margin-right: 30px; 170 212 } 171 213 172 214 .search-label { 173 215 color: #fff; 174 - font-size: 24px; 216 + font-size: 22px; 175 217 font-weight: bold; 176 218 text-shadow: 2px 2px 0 rgba(0,0,0,0.3); 177 - margin-bottom: 10px; 219 + margin-bottom: 8px; 178 220 } 179 221 180 222 .search-box { ··· 184 226 } 185 227 186 228 .search-input { 187 - width: 260px; 188 - padding: 10px 18px; 229 + width: 240px; 230 + padding: 8px 15px; 189 231 border: 3px solid #fff; 190 - font-size: 16px; 232 + font-size: 15px; 191 233 outline: none; 192 234 font-family: 'Comic Neue', cursive; 193 235 box-shadow: 4px 4px 0 rgba(0,0,0,0.2); ··· 200 242 .search-btn { 201 243 background: #fff; 202 244 border: 3px solid #000; 203 - width: 42px; 204 - height: 42px; 245 + width: 40px; 246 + height: 40px; 205 247 cursor: pointer; 206 248 display: flex; 207 249 align-items: center; ··· 214 256 transform: scale(1.1) rotate(10deg); 215 257 } 216 258 217 - /* Right column container */ 259 + /* Right column container - MORE SPACING */ 218 260 .header-right-column { 219 261 grid-area: rightcol; 220 262 display: flex; 221 263 flex-direction: column; 222 264 align-items: center; 223 - gap: 12px; 265 + gap: 18px; 224 266 padding-top: 0px; 225 267 justify-self: end; 226 268 } 227 269 228 - /* Character image */ 270 + /* Character image - SMALLER */ 229 271 .character-image { 230 - width: 220px; 231 - height: 220px; 232 - border: 6px solid #fff; 272 + width: 160px; 273 + height: 160px; 274 + border: 5px solid #fff; 233 275 box-shadow: 8px 8px 0 rgba(0,0,0,0.4); 234 276 overflow: hidden; 235 277 background: #fff; ··· 246 288 object-fit: cover; 247 289 } 248 290 249 - /* Buttons container */ 291 + /* Buttons container - MORE SPACING */ 250 292 .header-buttons { 251 293 display: flex; 252 294 flex-direction: column; 253 - gap: 8px; 295 + gap: 14px; 254 296 width: 100%; 255 297 align-items: center; 256 298 } ··· 268 310 text-decoration: none; 269 311 display: block; 270 312 box-shadow: 4px 4px 0 rgba(0,0,0,0.3); 313 + background: transparent; 314 + color: inherit; 315 + font-family: 'Comic Neue', cursive; 271 316 } 272 317 273 318 .header-btn:hover { 274 319 transform: translateY(-2px); 275 320 box-shadow: 0 6px 0 rgba(0,0,0,0.2); 321 + } 322 + 323 + /* Animation classes for button clicks */ 324 + .header-btn.animate-bounce { 325 + animation: bounce 0.6s ease-out; 326 + } 327 + 328 + .header-btn.animate-shake { 329 + animation: shake 0.5s ease-in-out; 330 + } 331 + 332 + .header-btn.animate-spin { 333 + animation: spinFull 0.8s ease-in-out; 334 + } 335 + 336 + .header-btn.animate-jiggle { 337 + animation: jiggle 0.4s ease-in-out; 338 + } 339 + 340 + .header-btn.animate-flash { 341 + animation: colorFlash 0.8s ease-in-out; 276 342 } 277 343 278 344 .login-header-btn {
+137 -53
src/components/BlogPosts.tsx
··· 1 1 'use client'; 2 2 3 - import { useState, useEffect } from 'react'; 3 + import { useState, useEffect, useRef, useCallback } from 'react'; 4 4 5 5 interface Post { 6 6 uri: string; ··· 24 24 isThread?: boolean; 25 25 } 26 26 27 + interface FeedCursor { 28 + handle: string; 29 + cursor?: string; 30 + } 31 + 27 32 export default function BlogPosts() { 28 33 const [posts, setPosts] = useState<Post[]>([]); 29 34 const [loading, setLoading] = useState(true); 35 + const [loadingMore, setLoadingMore] = useState(false); 36 + const [cursors, setCursors] = useState<FeedCursor[]>([]); 37 + const observerRef = useRef<IntersectionObserver | null>(null); 38 + const loadMoreRef = useRef<HTMLDivElement>(null); 39 + const seenUris = useRef<Set<string>>(new Set()); 30 40 31 - useEffect(() => { 32 - async function fetchPosts() { 33 - try { 34 - const handles = [ 35 - 'stream1.atmosphereconf.org', 36 - 'stream2.atmosphereconf.org', 37 - 'stream3.atmosphereconf.org', 38 - 'iame.li', 39 - 'stream.place' 40 - ]; 41 - const allPosts: Post[] = []; 42 - const seenUris = new Set<string>(); 41 + const handles = [ 42 + 'stream1.atmosphereconf.org', 43 + 'stream2.atmosphereconf.org', 44 + 'stream3.atmosphereconf.org', 45 + 'iame.li', 46 + 'stream.place' 47 + ]; 43 48 44 - for (const handle of handles) { 45 - const response = await fetch(`https://api.bsky.app/xrpc/app.bsky.feed.getAuthorFeed?actor=${handle}&limit=20`); 46 - if (!response.ok) continue; 49 + const fetchMorePosts = async () => { 50 + if (loadingMore) return; 51 + 52 + setLoadingMore(true); 53 + try { 54 + const newPosts: Post[] = []; 55 + const newCursors: FeedCursor[] = []; 56 + 57 + for (const cursorData of cursors.length > 0 ? cursors : handles.map(h => ({ handle: h, cursor: undefined }))) { 58 + const cursorParam = cursorData.cursor ? `&cursor=${cursorData.cursor}` : ''; 59 + const response = await fetch(`https://api.bsky.app/xrpc/app.bsky.feed.getAuthorFeed?actor=${cursorData.handle}&limit=20${cursorParam}`); 60 + 61 + if (!response.ok) { 62 + newCursors.push(cursorData); 63 + continue; 64 + } 65 + 66 + const data = await response.json(); 67 + 68 + for (const item of data.feed) { 69 + if (seenUris.current.has(item.post.uri)) { 70 + continue; 71 + } 47 72 48 - const data = await response.json(); 73 + if (item.reply && item.reply.parent?.author?.did !== item.post.author.did) { 74 + continue; 75 + } 76 + 77 + seenUris.current.add(item.post.uri); 49 78 50 - for (const item of data.feed) { 51 - // Skip duplicates 52 - if (seenUris.has(item.post.uri)) { 53 - continue; 54 - } 55 - 56 - if (item.reply && item.reply.parent?.author?.did !== item.post.author.did) { 57 - continue; 58 - } 59 - 60 - seenUris.add(item.post.uri); 61 - 62 - const post: Post = { 63 - uri: item.post.uri, 64 - cid: item.post.cid, 65 - author: item.post.author, 66 - record: item.post.record, 67 - replyCount: item.post.replyCount, 68 - repostCount: item.post.repostCount, 69 - likeCount: item.post.likeCount, 70 - indexedAt: item.post.indexedAt, 71 - isRepost: item.reason?.$type === 'app.bsky.feed.defs#reasonRepost', 72 - isThread: item.reply && item.reply.parent?.author?.did === item.post.author.did, 73 - }; 79 + const post: Post = { 80 + uri: item.post.uri, 81 + cid: item.post.cid, 82 + author: item.post.author, 83 + record: item.post.record, 84 + replyCount: item.post.replyCount, 85 + repostCount: item.post.repostCount, 86 + likeCount: item.post.likeCount, 87 + indexedAt: item.post.indexedAt, 88 + isRepost: item.reason?.$type === 'app.bsky.feed.defs#reasonRepost', 89 + isThread: item.reply && item.reply.parent?.author?.did === item.post.author.did, 90 + }; 74 91 75 - allPosts.push(post); 76 - } 92 + newPosts.push(post); 77 93 } 78 94 79 - allPosts.sort((a, b) => new Date(b.indexedAt).getTime() - new Date(a.indexedAt).getTime()); 80 - 81 - setPosts(allPosts.slice(0, 50)); 82 - } catch (error) { 83 - console.error('Failed to fetch posts:', error); 84 - } finally { 85 - setLoading(false); 95 + // Save cursor for next fetch 96 + if (data.cursor) { 97 + newCursors.push({ handle: cursorData.handle, cursor: data.cursor }); 98 + } 86 99 } 100 + 101 + newPosts.sort((a, b) => new Date(b.indexedAt).getTime() - new Date(a.indexedAt).getTime()); 102 + 103 + setPosts(prev => [...prev, ...newPosts]); 104 + setCursors(newCursors); 105 + } catch (error) { 106 + console.error('Failed to fetch posts:', error); 107 + } finally { 108 + setLoadingMore(false); 109 + setLoading(false); 87 110 } 111 + }; 88 112 89 - fetchPosts(); 113 + useEffect(() => { 114 + fetchMorePosts(); 90 115 }, []); 91 116 117 + // Infinite scroll observer 118 + const handleObserver = useCallback((entries: IntersectionObserverEntry[]) => { 119 + const target = entries[0]; 120 + if (target.isIntersecting && !loadingMore && cursors.length > 0) { 121 + fetchMorePosts(); 122 + } 123 + }, [loadingMore, cursors]); 124 + 125 + useEffect(() => { 126 + const option = { 127 + root: null, 128 + rootMargin: '200px', 129 + threshold: 0 130 + }; 131 + 132 + observerRef.current = new IntersectionObserver(handleObserver, option); 133 + 134 + if (loadMoreRef.current) { 135 + observerRef.current.observe(loadMoreRef.current); 136 + } 137 + 138 + return () => { 139 + if (observerRef.current) { 140 + observerRef.current.disconnect(); 141 + } 142 + }; 143 + }, [handleObserver]); 144 + 145 + // Convert Bluesky URI to web URL 146 + const getPostUrl = (uri: string) => { 147 + // uri format: at://did:plc:xxx/app.bsky.feed.post/xxxxx 148 + const parts = uri.replace('at://', '').split('/'); 149 + const did = parts[0]; 150 + const postId = parts[2]; 151 + return `https://bsky.app/profile/${did}/post/${postId}`; 152 + }; 153 + 92 154 if (loading) { 93 155 return ( 94 156 <div style={{ textAlign: 'center', padding: '50px', color: '#fff', fontSize: '20px' }}> ··· 100 162 return ( 101 163 <div className="blog-posts-container"> 102 164 {posts.map((post) => ( 103 - <div key={post.uri} className="blog-post"> 165 + <a 166 + key={post.uri} 167 + href={getPostUrl(post.uri)} 168 + target="_blank" 169 + rel="noopener noreferrer" 170 + className="blog-post" 171 + style={{ textDecoration: 'none', color: 'inherit', cursor: 'pointer', display: 'block' }} 172 + > 104 173 <div className="blog-post-header"> 105 174 <div className="profile-pic-weird"> 106 175 {post.author.avatar ? ( ··· 153 222 .replace(/(https?:\/\/[^\s]+)/g, '<a href="$1" target="_blank" rel="noopener noreferrer">$1</a>') 154 223 }} 155 224 /> 225 + </a> 226 + ))} 227 + 228 + {/* Loading trigger for infinite scroll */} 229 + {cursors.length > 0 && ( 230 + <div ref={loadMoreRef} style={{ 231 + height: '50px', 232 + display: 'flex', 233 + alignItems: 'center', 234 + justifyContent: 'center', 235 + color: '#fff', 236 + fontSize: '16px', 237 + fontWeight: 'bold' 238 + }}> 239 + {loadingMore ? 'Loading more posts...' : 'Scroll for more...'} 156 240 </div> 157 - ))} 241 + )} 158 242 </div> 159 243 ); 160 244 }
+60 -46
src/components/HomeClient.tsx
··· 144 144 setActiveTab(tabId); 145 145 }; 146 146 147 + // Random animation handler for header buttons 148 + const handleButtonClick = (e: React.MouseEvent<HTMLButtonElement>) => { 149 + const animations = ['animate-bounce', 'animate-shake', 'animate-spin', 'animate-jiggle', 'animate-flash']; 150 + const randomAnimation = animations[Math.floor(Math.random() * animations.length)]; 151 + 152 + const button = e.currentTarget; 153 + button.classList.add(randomAnimation); 154 + 155 + // Remove animation class after it completes 156 + setTimeout(() => { 157 + button.classList.remove(randomAnimation); 158 + }, 1000); 159 + }; 160 + 147 161 return ( 148 162 <div className="icarly-container"> 149 163 {/* Header */} 150 164 <header className="icarly-header"> 151 - {/* Homepage button */} 152 - <Link href="/" className="homepage-btn"> 153 - Homepage 154 - </Link> 155 - 156 - {/* Logo - Using transparent version */} 157 - <Link href="/" className="icarly-logo"> 158 - <Image 159 - src="/iStream_Logo.png" 160 - alt="iStream" 161 - width={400} 162 - height={211} 163 - style={{ objectFit: 'contain', width: '100%', height: 'auto' }} 164 - priority 165 - /> 166 - </Link> 167 - 168 - {/* Search */} 169 - <div className="search-section"> 170 - <div className="search-label">search our site</div> 171 - <div className="search-box"> 172 - <input 173 - type="text" 174 - className="search-input" 175 - value={searchQuery} 176 - onChange={(e) => setSearchQuery(e.target.value)} 177 - placeholder="Search videos..." 165 + {/* Left column - Homepage button + Logo stacked */} 166 + <div className="header-left-column"> 167 + <Link href="/" className="homepage-btn"> 168 + Homepage 169 + </Link> 170 + 171 + <Link href="/" className="icarly-logo"> 172 + <Image 173 + src="/iStream_Logo.png" 174 + alt="iStream" 175 + width={400} 176 + height={211} 177 + style={{ objectFit: 'contain', width: '100%', height: 'auto' }} 178 + priority 178 179 /> 179 - <button className="search-btn">🔍</button> 180 - </div> 180 + </Link> 181 181 </div> 182 182 183 - {/* Date */} 184 - <div className="date-display"> 185 - <div>Today is {today}</div> 186 - <div className="lemon-tube">&quot;LEMON TUBE&quot;</div> 183 + {/* Middle column - Date + Search */} 184 + <div className="header-middle-column"> 185 + <div className="date-display"> 186 + <div>Today is {today}</div> 187 + <div className="lemon-tube">&quot;LEMON TUBE&quot;</div> 188 + </div> 189 + 190 + <div className="search-section"> 191 + <div className="search-label">search our site</div> 192 + <div className="search-box"> 193 + <input 194 + type="text" 195 + className="search-input" 196 + value={searchQuery} 197 + onChange={(e) => setSearchQuery(e.target.value)} 198 + placeholder="Search videos..." 199 + /> 200 + <button className="search-btn">🔍</button> 201 + </div> 202 + </div> 187 203 </div> 188 204 189 205 {/* Right side column with character image and buttons */} ··· 193 209 <Image 194 210 src="/character-image.png" 195 211 alt="iStream Team" 196 - width={200} 197 - height={200} 212 + width={160} 213 + height={160} 198 214 style={{ objectFit: 'cover' }} 199 215 priority 200 216 /> 201 217 </div> 202 218 203 - {/* Buttons stacked below */} 219 + {/* Buttons stacked below - REMOVED Info button */} 204 220 <div className="header-buttons"> 205 - <a 206 - href="https://stream.place/login" 207 - target="_blank" 208 - rel="noopener noreferrer" 221 + <button 209 222 className="header-btn login-header-btn" 223 + onClick={handleButtonClick} 210 224 > 211 225 login → 212 - </a> 213 - <div className="header-btn info-header-btn"> 214 - <span className="btn-highlight">INFO</span> about iStream! 215 - </div> 216 - <div className="header-btn feedback-header-btn"> 226 + </button> 227 + <button 228 + className="header-btn feedback-header-btn" 229 + onClick={handleButtonClick} 230 + > 217 231 feedback <small>click &apos;til it hurts &gt;&gt;</small> 218 - </div> 232 + </button> 219 233 </div> 220 234 </div> 221 235 </header>
+44 -23
src/components/INews.tsx
··· 13 13 const newsItems: NewsItem[] = [ 14 14 { 15 15 id: 1, 16 - headline: "SPENCER'S SCULPTURE ACCIDENTALLY STREAMS TO ATMOSPHERECONF!", 16 + headline: "BLUESKY HITS 25 MILLION USERS - ATPROTO POWERS NEXT GEN SOCIAL!", 17 17 category: "BREAKING", 18 18 time: "2 mins ago", 19 19 icon: "🔥" 20 20 }, 21 21 { 22 22 id: 2, 23 - headline: "GIBBY DROPS MIC DURING LIVESTREAM - VIEWERS GO WILD!", 23 + headline: "NEW PDS LAUNCHES: ATMOSPHERECONF.ORG JOINS THE FEDERATION!", 24 24 category: "VIRAL", 25 25 time: "15 mins ago", 26 - icon: "🎤" 26 + icon: "🌐" 27 27 }, 28 28 { 29 29 id: 3, 30 - headline: "ATPROTO PRESENTS: GIBBY VS THE TECHNICAL DIFFICULTIES", 30 + headline: "ATPROTO LEXICON UPDATE ENABLES CROSS-APP VIDEO STREAMING", 31 31 category: "TECH", 32 32 time: "1 hour ago", 33 33 icon: "⚡" 34 34 }, 35 35 { 36 36 id: 4, 37 - headline: "LEWBOT HOSTS SURPRISE DANCE PARTY IN STREAM 2 CHAT!", 37 + headline: "STREAM.PLACE PDS BECOMES FIRST INDEPENDENT VIDEO HOST!", 38 38 category: "ENTERTAINMENT", 39 39 time: "2 hours ago", 40 - icon: "💃" 40 + icon: "🎬" 41 41 }, 42 42 { 43 43 id: 5, 44 - headline: "STREAM.PLACE TEAM ACCIDENTALLY BUILDS WORKING VIDEO APP", 44 + headline: "DID:PLC RESOLVER HANDLES 1 BILLION REQUESTS IN 24 HOURS", 45 45 category: "WOW", 46 46 time: "3 hours ago", 47 - icon: "🎬" 47 + icon: "🚀" 48 48 }, 49 49 { 50 50 id: 6, 51 - headline: "GIBBY'S RANDOM SPAM BREAKS THE INTERNET (AGAIN)", 51 + headline: "JETSTREAM FIREHOSE ACHIEVES REAL-TIME FEDERATION SYNC", 52 52 category: "TECH", 53 53 time: "4 hours ago", 54 54 icon: "📱" 55 55 }, 56 56 { 57 57 id: 7, 58 - headline: "ATMOSPHERECONF ATTENDEES SPOTTED EATING SPAGHETTI TACOS!", 59 - category: "FOOD", 58 + headline: "ATMOSPHERECONF 2025 SHOWCASES ATPROTO INNOVATIONS!", 59 + category: "EVENT", 60 60 time: "5 hours ago", 61 - icon: "🌮" 61 + icon: "🎤" 62 62 }, 63 63 { 64 64 id: 8, 65 - headline: "LEWBOT SINGS HAPPY BIRTHDAY TO EVERYONE AT ONCE", 66 - category: "MUSIC", 65 + headline: "LABELER SERVICES NOW SUPPORT CUSTOM MODERATION POLICIES", 66 + category: "SAFETY", 67 67 time: "6 hours ago", 68 - icon: "🎂" 68 + icon: "🛡️" 69 69 }, 70 70 { 71 71 id: 9, 72 - headline: "STREAM 3 ACHIEVES WORLD RECORD FOR MOST RANDOM CONVERSATIONS", 73 - category: "RECORDS", 72 + headline: "FEED GENERATORS REVOLUTIONIZE CONTENT DISCOVERY ON ATPROTO", 73 + category: "DISCOVERY", 74 74 time: "8 hours ago", 75 - icon: "🏆" 75 + icon: "🔍" 76 76 }, 77 77 { 78 78 id: 10, 79 - headline: "GIBBY'S NEW CATCHPHRASE TRENDS WORLDWIDE: 'I'M GIBBY!'", 79 + headline: "DECENTRALIZED IDENTITY: DIDs REACH 30 MILLION MILESTONE", 80 80 category: "CULTURE", 81 81 time: "10 hours ago", 82 82 icon: "🌍" 83 83 }, 84 84 { 85 85 id: 11, 86 - headline: "ATPROTO DEVS DISCOVER VIDEO IS JUST FAST PICTURES", 86 + headline: "RESEARCHERS PROVE ATPROTO SCALES TO PLANETARY FEDERATION", 87 87 category: "SCIENCE", 88 88 time: "12 hours ago", 89 89 icon: "🔬" 90 90 }, 91 91 { 92 92 id: 12, 93 - headline: "SAM'S REMOTE CONTROL PRANK GOES WRONG DURING LIVE STREAM", 94 - category: "DRAMA", 93 + headline: "APP.BSKY LEXICON GETS MAJOR UPDATE WITH NEW RECORD TYPES", 94 + category: "PROTOCOL", 95 + time: "1 day ago", 96 + icon: "📘" 97 + }, 98 + { 99 + id: 13, 100 + headline: "COMMUNITY BUILDS 100+ CUSTOM APPS ON ATPROTO!", 101 + category: "COMMUNITY", 95 102 time: "1 day ago", 96 - icon: "📺" 103 + icon: "💙" 104 + }, 105 + { 106 + id: 14, 107 + headline: "ISTREAM VOD PLATFORM DEMONSTRATES FEDERATED VIDEO FUTURE", 108 + category: "INNOVATION", 109 + time: "2 days ago", 110 + icon: "📹" 111 + }, 112 + { 113 + id: 15, 114 + headline: "HANDLE RESOLUTION SPEEDS UP 10X WITH NEW INFRASTRUCTURE", 115 + category: "PERFORMANCE", 116 + time: "2 days ago", 117 + icon: "⚡" 97 118 } 98 119 ]; 99 120
+323 -45
src/components/SnapsGrid.tsx
··· 1 1 'use client'; 2 2 3 - import { useState, useEffect } from 'react'; 3 + import { useState, useEffect, useRef, useCallback } from 'react'; 4 4 5 5 interface ImagePost { 6 6 uri: string; ··· 19 19 createdAt: string; 20 20 } 21 21 22 + interface FeedCursor { 23 + handle: string; 24 + cursor?: string; 25 + } 26 + 22 27 export default function SnapsGrid() { 23 28 const [images, setImages] = useState<ImagePost[]>([]); 24 29 const [loading, setLoading] = useState(true); 30 + const [loadingMore, setLoadingMore] = useState(false); 31 + const [cursors, setCursors] = useState<FeedCursor[]>([]); 32 + const [selectedImage, setSelectedImage] = useState<{url: string; alt: string} | null>(null); 33 + const [zoomLevel, setZoomLevel] = useState(1); 34 + const observerRef = useRef<IntersectionObserver | null>(null); 35 + const loadMoreRef = useRef<HTMLDivElement>(null); 36 + const seenUris = useRef<Set<string>>(new Set()); 25 37 26 - useEffect(() => { 27 - async function fetchImages() { 28 - try { 29 - const handles = [ 30 - 'stream1.atmosphereconf.org', 31 - 'stream2.atmosphereconf.org', 32 - 'stream3.atmosphereconf.org', 33 - 'iame.li', 34 - 'stream.place' 35 - ]; 36 - const allImages: ImagePost[] = []; 37 - const seenUris = new Set<string>(); 38 + const handles = [ 39 + 'stream1.atmosphereconf.org', 40 + 'stream2.atmosphereconf.org', 41 + 'stream3.atmosphereconf.org', 42 + 'iame.li', 43 + 'stream.place' 44 + ]; 38 45 39 - for (const handle of handles) { 40 - const response = await fetch(`https://api.bsky.app/xrpc/app.bsky.feed.getAuthorFeed?actor=${handle}&limit=50`); 41 - if (!response.ok) continue; 46 + const fetchMoreImages = async () => { 47 + if (loadingMore) return; 48 + 49 + setLoadingMore(true); 50 + try { 51 + const newImages: ImagePost[] = []; 52 + const newCursors: FeedCursor[] = []; 42 53 43 - const data = await response.json(); 54 + for (const cursorData of cursors.length > 0 ? cursors : handles.map(h => ({ handle: h, cursor: undefined }))) { 55 + const cursorParam = cursorData.cursor ? `&cursor=${cursorData.cursor}` : ''; 56 + const response = await fetch(`https://api.bsky.app/xrpc/app.bsky.feed.getAuthorFeed?actor=${cursorData.handle}&limit=50${cursorParam}`); 44 57 45 - for (const item of data.feed) { 46 - if (seenUris.has(item.post.uri)) continue; 58 + if (!response.ok) { 59 + newCursors.push(cursorData); 60 + continue; 61 + } 47 62 48 - const embed = item.post.embed; 49 - if (embed?.$type === 'app.bsky.embed.images#view' && embed.images?.length > 0) { 50 - seenUris.add(item.post.uri); 51 - allImages.push({ 52 - uri: item.post.uri, 53 - cid: item.post.cid, 54 - author: item.post.author, 55 - images: embed.images.map((img: { thumb: string; fullsize: string; alt?: string }) => ({ 56 - thumb: img.thumb, 57 - fullsize: img.fullsize, 58 - alt: img.alt || 'Image', 59 - })), 60 - text: item.post.record.text || '', 61 - createdAt: item.post.record.createdAt, 62 - }); 63 - } 63 + const data = await response.json(); 64 + 65 + for (const item of data.feed) { 66 + if (seenUris.current.has(item.post.uri)) continue; 67 + 68 + const embed = item.post.embed; 69 + if (embed?.$type === 'app.bsky.embed.images#view' && embed.images?.length > 0) { 70 + seenUris.current.add(item.post.uri); 71 + newImages.push({ 72 + uri: item.post.uri, 73 + cid: item.post.cid, 74 + author: item.post.author, 75 + images: embed.images.map((img: { thumb: string; fullsize: string; alt?: string }) => ({ 76 + thumb: img.thumb, 77 + fullsize: img.fullsize, 78 + alt: img.alt || 'Image', 79 + })), 80 + text: item.post.record.text || '', 81 + createdAt: item.post.record.createdAt, 82 + }); 64 83 } 65 84 } 66 85 67 - allImages.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); 68 - setImages(allImages.slice(0, 50)); 69 - } catch (error) { 70 - console.error('Failed to fetch images:', error); 71 - } finally { 72 - setLoading(false); 86 + // Save cursor for next fetch 87 + if (data.cursor) { 88 + newCursors.push({ handle: cursorData.handle, cursor: data.cursor }); 89 + } 73 90 } 91 + 92 + newImages.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); 93 + setImages(prev => [...prev, ...newImages]); 94 + setCursors(newCursors); 95 + } catch (error) { 96 + console.error('Failed to fetch images:', error); 97 + } finally { 98 + setLoadingMore(false); 99 + setLoading(false); 74 100 } 101 + }; 75 102 76 - fetchImages(); 103 + useEffect(() => { 104 + fetchMoreImages(); 77 105 }, []); 78 106 107 + // Infinite scroll observer 108 + const handleObserver = useCallback((entries: IntersectionObserverEntry[]) => { 109 + const target = entries[0]; 110 + if (target.isIntersecting && !loadingMore && cursors.length > 0) { 111 + fetchMoreImages(); 112 + } 113 + }, [loadingMore, cursors]); 114 + 115 + useEffect(() => { 116 + const option = { 117 + root: null, 118 + rootMargin: '200px', 119 + threshold: 0 120 + }; 121 + 122 + observerRef.current = new IntersectionObserver(handleObserver, option); 123 + 124 + if (loadMoreRef.current) { 125 + observerRef.current.observe(loadMoreRef.current); 126 + } 127 + 128 + return () => { 129 + if (observerRef.current) { 130 + observerRef.current.disconnect(); 131 + } 132 + }; 133 + }, [handleObserver]); 134 + 135 + const handleImageClick = (url: string, alt: string) => { 136 + setSelectedImage({ url, alt }); 137 + setZoomLevel(1); 138 + }; 139 + 140 + const handleZoom = (delta: number) => { 141 + setZoomLevel(prev => Math.max(0.5, Math.min(3, prev + delta))); 142 + }; 143 + 79 144 if (loading) { 80 145 return ( 81 146 <div style={{ textAlign: 'center', padding: '50px', color: '#fff', fontSize: '20px' }}> ··· 85 150 } 86 151 87 152 return ( 88 - <div style={{ position: 'relative' }}> 153 + <> 154 + {/* Native Image Viewer with Old Monitor Effect */} 155 + {selectedImage && ( 156 + <div 157 + style={{ 158 + position: 'fixed', 159 + top: 0, 160 + left: 0, 161 + right: 0, 162 + bottom: 0, 163 + backgroundColor: 'rgba(20, 34, 52, 0.72)', 164 + zIndex: 9999, 165 + display: 'flex', 166 + alignItems: 'center', 167 + justifyContent: 'center', 168 + flexDirection: 'column', 169 + padding: '20px', 170 + }} 171 + onClick={() => setSelectedImage(null)} 172 + > 173 + {/* Old Monitor Frame */} 174 + <div style={{ 175 + position: 'relative', 176 + maxWidth: '90vw', 177 + maxHeight: '85vh', 178 + background: 'linear-gradient(145deg, #4c5566, #2b3240)', 179 + padding: '40px', 180 + borderRadius: '20px', 181 + boxShadow: '0 0 40px rgba(0,0,0,0.45), inset 0 0 22px rgba(0,0,0,0.25)', 182 + border: '8px solid #3f4656', 183 + }} 184 + onClick={(e) => e.stopPropagation()} 185 + > 186 + {/* CRT Screen Effect Container */} 187 + <div style={{ 188 + position: 'relative', 189 + overflow: 'hidden', 190 + borderRadius: '5px', 191 + boxShadow: 'inset 0 0 14px rgba(0,0,0,0.42)', 192 + background: '#0f1622', 193 + }}> 194 + {/* Scanlines */} 195 + <div style={{ 196 + position: 'absolute', 197 + top: 0, 198 + left: 0, 199 + right: 0, 200 + bottom: 0, 201 + background: 'repeating-linear-gradient(0deg, rgba(0,0,0,0.08), rgba(0,0,0,0.08) 1px, transparent 1px, transparent 2px)', 202 + pointerEvents: 'none', 203 + zIndex: 2, 204 + }} /> 205 + 206 + {/* Screen Flicker */} 207 + <div style={{ 208 + position: 'absolute', 209 + top: 0, 210 + left: 0, 211 + right: 0, 212 + bottom: 0, 213 + background: 'rgba(255,255,255,0.02)', 214 + pointerEvents: 'none', 215 + zIndex: 3, 216 + animation: 'flicker 0.15s infinite', 217 + }} /> 218 + 219 + {/* Vignette */} 220 + <div style={{ 221 + position: 'absolute', 222 + top: 0, 223 + left: 0, 224 + right: 0, 225 + bottom: 0, 226 + background: 'radial-gradient(ellipse at center, transparent 18%, rgba(0,0,0,0.38) 100%)', 227 + pointerEvents: 'none', 228 + zIndex: 1, 229 + }} /> 230 + 231 + {/* Image */} 232 + <img 233 + src={selectedImage.url} 234 + alt={selectedImage.alt} 235 + style={{ 236 + display: 'block', 237 + maxWidth: '80vw', 238 + maxHeight: '70vh', 239 + transform: `scale(${zoomLevel})`, 240 + transition: 'transform 0.2s', 241 + filter: 'contrast(1.04) brightness(1.08) saturate(1.05)', 242 + imageRendering: 'crisp-edges', 243 + }} 244 + onClick={(e) => e.stopPropagation()} 245 + /> 246 + </div> 247 + 248 + {/* Zoom Controls */} 249 + <div style={{ 250 + marginTop: '15px', 251 + display: 'flex', 252 + gap: '10px', 253 + justifyContent: 'center', 254 + }}> 255 + <button 256 + onClick={(e) => { e.stopPropagation(); handleZoom(-0.25); }} 257 + style={{ 258 + background: '#62166F', 259 + color: '#fff', 260 + border: '3px solid #fff', 261 + padding: '8px 20px', 262 + fontSize: '18px', 263 + fontWeight: 'bold', 264 + cursor: 'pointer', 265 + boxShadow: '3px 3px 0 rgba(0,0,0,0.3)', 266 + fontFamily: 'Comic Neue, cursive', 267 + }} 268 + > 269 + Zoom Out - 270 + </button> 271 + <button 272 + onClick={(e) => { e.stopPropagation(); setZoomLevel(1); }} 273 + style={{ 274 + background: '#A6CC3A', 275 + color: '#62166F', 276 + border: '3px solid #fff', 277 + padding: '8px 20px', 278 + fontSize: '18px', 279 + fontWeight: 'bold', 280 + cursor: 'pointer', 281 + boxShadow: '3px 3px 0 rgba(0,0,0,0.3)', 282 + fontFamily: 'Comic Neue, cursive', 283 + }} 284 + > 285 + Reset 286 + </button> 287 + <button 288 + onClick={(e) => { e.stopPropagation(); handleZoom(0.25); }} 289 + style={{ 290 + background: '#62166F', 291 + color: '#fff', 292 + border: '3px solid #fff', 293 + padding: '8px 20px', 294 + fontSize: '18px', 295 + fontWeight: 'bold', 296 + cursor: 'pointer', 297 + boxShadow: '3px 3px 0 rgba(0,0,0,0.3)', 298 + fontFamily: 'Comic Neue, cursive', 299 + }} 300 + > 301 + Zoom In + 302 + </button> 303 + </div> 304 + 305 + {/* Close button */} 306 + <button 307 + onClick={() => setSelectedImage(null)} 308 + style={{ 309 + position: 'absolute', 310 + top: '10px', 311 + right: '10px', 312 + background: '#E91E8C', 313 + color: '#fff', 314 + border: '3px solid #fff', 315 + padding: '5px 15px', 316 + fontSize: '20px', 317 + fontWeight: 'bold', 318 + cursor: 'pointer', 319 + borderRadius: '5px', 320 + boxShadow: '3px 3px 0 rgba(0,0,0,0.3)', 321 + fontFamily: 'Comic Neue, cursive', 322 + }} 323 + > 324 + 325 + </button> 326 + </div> 327 + 328 + {/* Instructions */} 329 + <div style={{ 330 + marginTop: '20px', 331 + color: '#fff', 332 + fontSize: '16px', 333 + fontFamily: 'Comic Neue, cursive', 334 + textAlign: 'center', 335 + }}> 336 + Click outside or press X to close 337 + </div> 338 + </div> 339 + )} 340 + 341 + <style jsx>{` 342 + @keyframes flicker { 343 + 0% { opacity: 0.97; } 344 + 50% { opacity: 1; } 345 + 100% { opacity: 0.97; } 346 + } 347 + `}</style> 348 + 349 + <div style={{ position: 'relative' }}> 89 350 {/* Floating decorative elements */} 90 351 <div className="floating-shape" style={{ 91 352 position: 'absolute', ··· 181 442 src={image.thumb} 182 443 alt={image.alt} 183 444 loading="lazy" 184 - onClick={() => window.open(image.fullsize, '_blank')} 445 + onClick={() => handleImageClick(image.fullsize, image.alt)} 185 446 style={{ cursor: 'pointer' }} 186 447 /> 187 448 </div> ··· 189 450 )} 190 451 </div> 191 452 453 + {/* Loading trigger for infinite scroll */} 454 + {cursors.length > 0 && ( 455 + <div ref={loadMoreRef} style={{ 456 + height: '50px', 457 + display: 'flex', 458 + alignItems: 'center', 459 + justifyContent: 'center', 460 + color: '#fff', 461 + fontSize: '16px', 462 + fontWeight: 'bold', 463 + marginTop: '20px' 464 + }}> 465 + {loadingMore ? '📸 Loading more snaps...' : 'Scroll for more...'} 466 + </div> 467 + )} 468 + 192 469 {/* More floating fun elements */} 193 470 <div style={{ 194 471 position: 'absolute', ··· 207 484 }}> 208 485 🌟 Awesome Photos! 🌟 209 486 </div> 210 - </div> 487 + </div> 488 + </> 211 489 ); 212 490 }
+73 -7
src/components/VideoPlayer.tsx
··· 1 1 'use client'; 2 2 3 - import { useEffect, useRef } from 'react'; 3 + import { useEffect, useRef, useState } from 'react'; 4 4 import Hls from 'hls.js'; 5 5 6 6 const VOD_BETA_BASE = 'https://vod-beta.stream.place/xrpc'; ··· 11 11 12 12 export default function VideoPlayer({ videoUri }: VideoPlayerProps) { 13 13 const videoRef = useRef<HTMLVideoElement>(null); 14 + const [isLoading, setIsLoading] = useState(true); 14 15 15 16 useEffect(() => { 16 17 const video = videoRef.current; 17 18 if (!video) return; 18 19 20 + setIsLoading(true); 21 + 22 + const handleCanPlay = () => setIsLoading(false); 23 + const handleLoadedData = () => setIsLoading(false); 24 + 25 + video.addEventListener('canplay', handleCanPlay); 26 + video.addEventListener('loadeddata', handleLoadedData); 27 + 19 28 if (Hls.isSupported()) { 20 29 const hls = new Hls(); 21 30 const playlistUrl = `${VOD_BETA_BASE}/place.stream.playback.getVideoPlaylist?uri=${encodeURIComponent(videoUri)}`; 22 31 hls.loadSource(playlistUrl); 23 32 hls.attachMedia(video); 24 - return () => hls.destroy(); 33 + 34 + hls.on(Hls.Events.MANIFEST_PARSED, () => { 35 + setIsLoading(false); 36 + }); 37 + 38 + return () => { 39 + video.removeEventListener('canplay', handleCanPlay); 40 + video.removeEventListener('loadeddata', handleLoadedData); 41 + hls.destroy(); 42 + }; 25 43 } 26 44 27 45 if (video.canPlayType('application/vnd.apple.mpegurl')) { 28 46 const playlistUrl = `${VOD_BETA_BASE}/place.stream.playback.getVideoPlaylist?uri=${encodeURIComponent(videoUri)}`; 29 47 video.src = playlistUrl; 30 48 } 49 + 50 + return () => { 51 + video.removeEventListener('canplay', handleCanPlay); 52 + video.removeEventListener('loadeddata', handleLoadedData); 53 + }; 31 54 }, [videoUri]); 32 55 33 56 return ( 34 - <video 35 - ref={videoRef} 36 - controls 37 - style={{ width: '100%', maxHeight: '70vh', backgroundColor: '#000' }} 38 - /> 57 + <div style={{ position: 'relative', width: '100%', backgroundColor: '#000' }}> 58 + <video 59 + ref={videoRef} 60 + controls 61 + style={{ 62 + width: '100%', 63 + maxHeight: '70vh', 64 + backgroundColor: '#000', 65 + display: isLoading ? 'none' : 'block' 66 + }} 67 + /> 68 + 69 + {isLoading && ( 70 + <div style={{ 71 + position: 'absolute', 72 + top: 0, 73 + left: 0, 74 + width: '100%', 75 + height: '500px', 76 + backgroundColor: '#FFE5F5', 77 + display: 'flex', 78 + alignItems: 'center', 79 + justifyContent: 'center', 80 + overflow: 'hidden' 81 + }}> 82 + <style>{` 83 + @keyframes slide-character { 84 + 0% { 85 + transform: translateX(-150px); 86 + } 87 + 100% { 88 + transform: translateX(calc(100vw + 150px)); 89 + } 90 + } 91 + `}</style> 92 + <img 93 + src="/iStream-upper-right-image.png" 94 + alt="Loading..." 95 + style={{ 96 + position: 'absolute', 97 + height: '120px', 98 + width: 'auto', 99 + animation: 'slide-character 3s linear infinite' 100 + }} 101 + /> 102 + </div> 103 + )} 104 + </div> 39 105 ); 40 106 }