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.

Remove You Are Here, enlarge character image, add AtmosphereConf thumbs, fix DID resolution

- Removed You are here indicator from navigation
- Increased character image size from 160px to 200px
- Added padding to header for larger image (240px)
- Live streams now show AtmosphereConf thumbnail
- Improved DID resolution with sequential fetching to avoid rate limits
- Fixed display name logic for handles vs DIDs

jack 867a7c15 e22d08ec

+43 -65
+10 -10
src/app/icarly.css
··· 65 65 background: linear-gradient(180deg, #4A5A8A 0%, #7BA05B 40%, #A6CC3A 100%); 66 66 border: 4px solid #fff; 67 67 border-bottom: none; 68 - padding: 15px 200px 30px 30px; 68 + padding: 15px 240px 30px 30px; 69 69 position: relative; 70 - min-height: 240px; 70 + min-height: 260px; 71 71 box-shadow: 0 0 20px rgba(0,0,0,0.3); 72 72 display: grid; 73 - grid-template-columns: 300px 1fr 200px; 73 + grid-template-columns: 320px 1fr 180px; 74 74 grid-template-rows: auto auto 1fr; 75 75 grid-template-areas: 76 76 "homepage homepage homepage" 77 77 "logo search widgets" 78 78 "date search widgets"; 79 - gap: 15px 30px; 79 + gap: 15px 25px; 80 80 } 81 81 82 82 /* Homepage button */ ··· 315 315 /* Character image - positioned in top right */ 316 316 .character-image { 317 317 position: absolute; 318 - right: 25px; 319 - top: 20px; 320 - width: 160px; 321 - height: 160px; 322 - border: 5px solid #fff; 323 - box-shadow: 6px 6px 0 rgba(0,0,0,0.3); 318 + right: 20px; 319 + top: 15px; 320 + width: 200px; 321 + height: 200px; 322 + border: 6px solid #fff; 323 + box-shadow: 8px 8px 0 rgba(0,0,0,0.3); 324 324 overflow: hidden; 325 325 z-index: 10; 326 326 animation: float 5s ease-in-out infinite;
+26 -28
src/components/HomeClient.tsx
··· 52 52 const data = await videosRes.json(); 53 53 const allVideos = data.records as { uri: string; cid: string; value: VideoRecord }[]; 54 54 55 - // Resolve handles 56 - const videosWithHandles = await Promise.all( 57 - allVideos.map(async (video) => { 58 - try { 59 - const handleRes = await fetch(`https://public.api.bsky.app/xrpc/com.atproto.identity.resolveDid?did=${video.value.creator}`); 60 - if (!handleRes.ok) throw new Error('Failed to resolve handle'); 61 - const handleData = await handleRes.json(); 62 - return { 63 - ...video, 64 - handle: handleData.handle || video.value.creator, 65 - }; 66 - } catch { 67 - return { 68 - ...video, 69 - handle: video.value.creator, 70 - }; 71 - } 72 - }) 73 - ); 55 + // Resolve handles with batching to avoid rate limits 56 + const resolveHandle = async (did: string): Promise<string> => { 57 + try { 58 + // Add delay to avoid rate limiting 59 + await new Promise(resolve => setTimeout(resolve, 50)); 60 + const handleRes = await fetch(`https://public.api.bsky.app/xrpc/com.atproto.identity.resolveDid?did=${did}`); 61 + if (!handleRes.ok) throw new Error('Failed to resolve handle'); 62 + const handleData = await handleRes.json(); 63 + return handleData.handle || did; 64 + } catch (error) { 65 + console.warn(`Failed to resolve handle for ${did}:`, error); 66 + return did; 67 + } 68 + }; 69 + 70 + // Resolve handles sequentially to avoid overwhelming the API 71 + const videosWithHandles = []; 72 + for (const video of allVideos) { 73 + const handle = await resolveHandle(video.value.creator); 74 + videosWithHandles.push({ 75 + ...video, 76 + handle, 77 + }); 78 + } 74 79 75 80 setVideos(videosWithHandles); 76 81 } catch (e) { ··· 192 197 <Image 193 198 src="/character-image.png" 194 199 alt="iStream Team" 195 - width={160} 196 - height={160} 200 + width={200} 201 + height={200} 197 202 style={{ objectFit: 'cover' }} 198 203 priority 199 204 /> ··· 202 207 203 208 {/* Navigation Tabs */} 204 209 <nav className="nav-tabs"> 205 - {/* You are here indicator */} 206 - <div className="you-are-here" style={{ 207 - opacity: activeTab ? 1 : 0, 208 - }}> 209 - 📍 You are here! 210 - </div> 211 - 212 210 {tabs.map((tab) => ( 213 211 tab.external ? ( 214 212 <a
+1 -23
src/components/IStream.tsx
··· 179 179 <div style={{ 180 180 position: 'relative', 181 181 paddingTop: '56.25%', 182 - background: 'linear-gradient(135deg, #FF4444, #C71585)', 182 + background: 'url(/atmosphere-conf-thumb.png) center/cover no-repeat', 183 183 borderBottom: '4px solid #62166F', 184 184 }}> 185 - {stream.author.avatar ? ( 186 - <Image 187 - src={stream.author.avatar} 188 - alt={stream.author.displayName || stream.author.handle} 189 - fill 190 - style={{ objectFit: 'cover' }} 191 - /> 192 - ) : ( 193 - <div style={{ 194 - position: 'absolute', 195 - top: 0, 196 - left: 0, 197 - right: 0, 198 - bottom: 0, 199 - display: 'flex', 200 - alignItems: 'center', 201 - justifyContent: 'center', 202 - fontSize: '60px', 203 - }}> 204 - 📺 205 - </div> 206 - )} 207 185 208 186 {/* Live Badge */} 209 187 <div style={{
+6 -4
src/components/VideoCard.tsx
··· 25 25 if (STREAM_DISPLAY_NAMES[handle]) { 26 26 return STREAM_DISPLAY_NAMES[handle]; 27 27 } 28 + // If it's a DID, show shortened version 29 + if (handle.startsWith('did:')) { 30 + const parts = handle.split(':'); 31 + const lastPart = parts[parts.length - 1]; 32 + return lastPart.slice(0, 12) + '...'; 33 + } 28 34 // If it's a full handle like "username.bsky.social", show just the username part 29 35 if (handle.includes('.')) { 30 36 return handle.split('.')[0]; 31 - } 32 - // If it's still a DID after resolution attempt, show full DID 33 - if (handle.startsWith('did:')) { 34 - return handle; 35 37 } 36 38 return handle; 37 39 }