vod frog, frog with the vods
5
fork

Configure Feed

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

add wavy avatar to video cards, load creator videos on profile page via GraphQL

goose.art ca8ef6d8 1c3b4e2f

+199 -25
+34 -9
src/lib/VideoCard.svelte
··· 19 19 resolveHandle, 20 20 getThumbnailUrl, 21 21 getPlaylistUrl, 22 + getProfile, 22 23 type VideoRecord 23 24 } from './api'; 24 25 import { getCardOffsets, seededRandom } from './theme'; 25 26 import WavyBorder from './WavyBorder.svelte'; 27 + import WavyCircle from './WavyCircle.svelte'; 26 28 27 29 let { video, onSelect }: { video: VideoRecord; onSelect: (video: VideoRecord) => void } = 28 30 $props(); 29 31 30 32 let creatorHandle = $state(''); 33 + let creatorAvatar = $state(''); 31 34 let thumbnailUrl: string | null = $state(null); 32 35 33 36 // Extract rkey from URI for seeding ··· 53 56 54 57 onMount(() => { 55 58 creatorHandle = video.value.creator; 56 - resolveHandle(video.value.creator).then((h) => (creatorHandle = h)); 59 + resolveHandle(video.value.creator).then((h) => { 60 + creatorHandle = h; 61 + const handle = h.replace('@', ''); 62 + getProfile(handle).then((p) => { if (p.avatar) creatorAvatar = p.avatar; }).catch(() => {}); 63 + }); 57 64 getThumbnailUrl(video).then((url) => (thumbnailUrl = url)); 58 65 }); 59 66 ··· 197 204 </div> 198 205 <div class="info"> 199 206 <h3 class="title">{video.value.title}</h3> 200 - <a href="/profile/{creatorHandle.replace('@', '')}" class="creator" onclick={(e: MouseEvent) => e.stopPropagation()}>{creatorHandle}</a> 207 + <a href="/profile/{creatorHandle.replace('@', '')}" class="creator-row" onclick={(e: MouseEvent) => e.stopPropagation()}> 208 + {#if creatorAvatar} 209 + <WavyCircle seed={video.value.creator} fill="#FFDEED" strokeColor="#0A182B" strokeWidth={1.2} size={24}> 210 + <img src={creatorAvatar} alt="" /> 211 + </WavyCircle> 212 + {/if} 213 + <span class="creator-name">{creatorHandle}</span> 214 + </a> 201 215 <p class="date">{formatDate(video.value.createdAt)}</p> 202 216 </div> 203 217 </WavyBorder> ··· 323 337 overflow: hidden; 324 338 } 325 339 326 - .creator { 327 - display: block; 328 - margin: 4px 0 0; 340 + .creator-row { 341 + display: flex; 342 + align-items: center; 343 + gap: 6px; 344 + margin: 6px 0 0; 345 + text-decoration: none; 346 + transition: transform 0.15s; 347 + } 348 + 349 + .creator-row:hover { 350 + transform: translateX(2px); 351 + } 352 + 353 + .creator-row:hover .creator-name { 354 + color: #FF3992; 355 + } 356 + 357 + .creator-name { 329 358 font-family: 'Fang', system-ui, sans-serif; 330 359 font-size: 0.8rem; 331 360 color: #0A182B; ··· 333 362 text-decoration: underline; 334 363 text-decoration-color: #FF3992; 335 364 transition: color 0.15s; 336 - } 337 - 338 - .creator:hover { 339 - color: #FF3992; 340 365 } 341 366 342 367 .date {
+58
src/lib/api.ts
··· 135 135 }; 136 136 } 137 137 138 + /** Fetch videos by a specific creator DID */ 139 + export async function listVideosByCreator(creatorDid: string): Promise<VideoRecord[]> { 140 + const query = ` 141 + query GetVideosByCreator($creatorDid: String!) { 142 + placeStreamVideo(first: 50, where: { creator: { equalTo: $creatorDid } }) { 143 + edges { 144 + node { 145 + uri 146 + cid 147 + creator 148 + title 149 + duration 150 + createdAt 151 + livestream 152 + source { 153 + ref 154 + size 155 + mimeType 156 + } 157 + } 158 + } 159 + } 160 + }`; 161 + 162 + const res = await fetch(VODSLICE_API, { 163 + method: 'POST', 164 + headers: { 'Content-Type': 'application/json' }, 165 + body: JSON.stringify({ query, variables: { creatorDid } }) 166 + }); 167 + 168 + if (!res.ok) throw new Error(`Failed to fetch creator videos: ${res.status}`); 169 + const json = await res.json(); 170 + const edges = json.data?.placeStreamVideo?.edges || []; 171 + 172 + return edges.map((edge: VodSliceEdge) => { 173 + const n = edge.node; 174 + let livestream: { cid: string; uri: string } | undefined; 175 + if (typeof n.livestream === 'string') { 176 + try { livestream = JSON.parse(n.livestream); } catch { /* ignore */ } 177 + } else if (n.livestream && typeof n.livestream === 'object') { 178 + livestream = n.livestream as { uri: string; cid: string }; 179 + } 180 + return { 181 + uri: n.uri, 182 + cid: n.cid || '', 183 + value: { 184 + $type: COLLECTION, 185 + title: n.title, 186 + source: { ...n.source, $type: 'place.stream.muxl.defs#archiveBlob' }, 187 + creator: n.creator, 188 + duration: n.duration, 189 + createdAt: n.createdAt, 190 + livestream 191 + } 192 + }; 193 + }); 194 + } 195 + 138 196 /** Fetch all videos (for deep-link lookup). Uses pagination internally. */ 139 197 export async function fetchAllVideos(): Promise<VideoRecord[]> { 140 198 const all: VideoRecord[] = [];
+107 -16
src/routes/profile/[handle]/+page.svelte
··· 1 1 <script lang="ts"> 2 2 import { onMount } from 'svelte'; 3 3 import { page } from '$app/stores'; 4 - import { getProfile, type BskyProfile } from '$lib/api'; 4 + import { getProfile, listVideosByCreator, getPlaylistUrl, formatDuration, formatDate, type BskyProfile, type VideoRecord } from '$lib/api'; 5 5 import WavyBorder from '$lib/WavyBorder.svelte'; 6 + import WavyCircle from '$lib/WavyCircle.svelte'; 7 + import VideoCard from '$lib/VideoCard.svelte'; 8 + import VideoPlayer from '$lib/VideoPlayer.svelte'; 6 9 import FrogHeader from '$lib/FrogHeader.svelte'; 7 10 8 11 let profile: BskyProfile | null = $state(null); 12 + let videos: VideoRecord[] = $state([]); 9 13 let loading = $state(true); 10 14 let error = $state(''); 15 + let selectedVideo: VideoRecord | null = $state(null); 11 16 12 17 let handle = $derived($page.params.handle); 13 18 ··· 16 21 error = ''; 17 22 try { 18 23 profile = await getProfile(handle); 24 + // Fetch videos created by this account 25 + if (profile?.did) { 26 + videos = await listVideosByCreator(profile.did); 27 + } 19 28 } catch (e: any) { 20 29 error = e.message; 21 30 } 22 31 loading = false; 23 32 }); 33 + 34 + function selectVideo(video: VideoRecord) { 35 + selectedVideo = video; 36 + window.scrollTo({ top: 0, behavior: 'smooth' }); 37 + } 38 + 39 + function closePlayer() { 40 + selectedVideo = null; 41 + } 24 42 </script> 25 43 26 44 <svelte:head> ··· 28 46 </svelte:head> 29 47 30 48 <div class="profile-page"> 31 - <FrogHeader /> 49 + <FrogHeader onHomeClick={() => { window.location.href = '/'; }} /> 32 50 33 51 {#if loading} 34 52 <div class="loading"> ··· 52 70 <div class="profile-card"> 53 71 {#if profile.avatar} 54 72 <div class="avatar-wrapper"> 55 - <WavyBorder seed="profile-avatar" fill="#FFDEED" strokeColor="#0A182B" strokeWidth={1.5} padding={4}> 56 - <img src={profile.avatar} alt={profile.displayName || profile.handle} class="avatar" /> 57 - </WavyBorder> 73 + <WavyCircle seed={profile.did} fill="#FFDEED" strokeColor="#0A182B" strokeWidth={1.5} size={100}> 74 + <img src={profile.avatar} alt={profile.displayName || profile.handle} /> 75 + </WavyCircle> 58 76 </div> 59 77 {/if} 60 78 ··· 93 111 </WavyBorder> 94 112 </div> 95 113 </div> 114 + 115 + <!-- Selected video player --> 116 + {#if selectedVideo} 117 + <section class="player-section"> 118 + <VideoPlayer src={getPlaylistUrl(selectedVideo.uri)} /> 119 + <div class="player-info"> 120 + <WavyBorder seed="profile-player-info" fill="#39FF44" strokeColor="#0A182B" strokeWidth={1.8} padding={48}> 121 + <h2 class="player-title">{selectedVideo.value.title}</h2> 122 + <p class="player-meta"> 123 + {formatDate(selectedVideo.value.createdAt)} 124 + <span class="dot">·</span> 125 + {formatDuration(selectedVideo.value.duration)} 126 + </p> 127 + </WavyBorder> 128 + </div> 129 + </section> 130 + {/if} 131 + 132 + <!-- Videos by this creator --> 133 + {#if videos.length > 0} 134 + <h2 class="videos-heading">videos</h2> 135 + <section class="grid"> 136 + {#each videos as video (video.uri)} 137 + <VideoCard {video} onSelect={selectVideo} /> 138 + {/each} 139 + </section> 140 + {:else} 141 + <p class="no-videos">no videos found for this creator</p> 142 + {/if} 96 143 {/if} 97 144 98 145 <div class="back-link"> ··· 102 149 103 150 <style> 104 151 .profile-page { 105 - max-width: 800px; 152 + max-width: 1300px; 106 153 margin: 0 auto; 107 - padding: 0 clamp(24px, 7vw, 80px) 60px; 154 + padding: 0 clamp(24px, 7vw, 120px) 60px; 108 155 } 109 156 110 157 .loading { ··· 166 213 } 167 214 168 215 .avatar-wrapper { 169 - width: clamp(80px, 15vw, 120px); 170 216 margin-bottom: 8px; 171 217 } 172 218 173 - .avatar { 174 - width: 100%; 175 - display: block; 176 - aspect-ratio: 1; 177 - object-fit: cover; 178 - border-radius: 0; 179 - } 180 - 181 219 .display-name { 182 220 font-family: 'PicNic', cursive, system-ui; 183 221 font-size: clamp(1.8rem, 4vw, 2.8rem); ··· 241 279 242 280 .profile-link:hover { 243 281 color: #FF3992; 282 + } 283 + 284 + /* Video player section */ 285 + .player-section { 286 + margin: 30px 0; 287 + } 288 + 289 + .player-info { 290 + padding: 20px 4px; 291 + } 292 + 293 + .player-title { 294 + margin: 0; 295 + font-family: 'PicNic', cursive, system-ui; 296 + font-size: clamp(1.4rem, 3vw, 2rem); 297 + color: #0A182B; 298 + } 299 + 300 + .player-meta { 301 + margin: 6px 0 0; 302 + font-family: 'Fang', system-ui, sans-serif; 303 + color: #0A182B; 304 + opacity: 0.7; 305 + font-size: 0.85rem; 306 + } 307 + 308 + .dot { 309 + margin: 0 4px; 310 + opacity: 0.4; 311 + } 312 + 313 + /* Videos grid */ 314 + .videos-heading { 315 + font-family: 'PicNic', cursive, system-ui; 316 + font-size: clamp(1.5rem, 3vw, 2rem); 317 + color: #0A182B; 318 + margin: 30px 0 10px; 319 + } 320 + 321 + .grid { 322 + display: grid; 323 + grid-template-columns: repeat(auto-fill, minmax(min(300px, 100%), 1fr)); 324 + gap: clamp(32px, 5vw, 50px); 325 + padding: 20px clamp(16px, 3vw, 24px); 326 + } 327 + 328 + .no-videos { 329 + font-family: 'PicNic', cursive, system-ui; 330 + font-size: 1.1rem; 331 + color: #0A182B; 332 + opacity: 0.6; 333 + text-align: center; 334 + padding: 40px; 244 335 } 245 336 246 337 .back-link {