An entry for the streamplace vod showcase
1
fork

Configure Feed

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

feat(web): add search field and results page

- Add search input in header (press Enter to search)
- Create /search page with live filtering
- Search by video title or creator name
- Responsive grid layout for results
- Empty state and no-results feedback

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

+543 -19
+6
apps/web/src/main.tsx
··· 4 4 import { UI4Editorial } from './ui/UI4Editorial.tsx' 5 5 import { ProfilePage } from './ui/ProfilePage.tsx' 6 6 import { DocsPage } from './ui/DocsPage.tsx' 7 + import { SearchPage } from './ui/SearchPage.tsx' 7 8 import { onRouteChange, matchPath } from './router.ts' 8 9 9 10 function Router() { ··· 23 24 const profileMatch = matchPath('/profile/:did', route) 24 25 if (profileMatch) { 25 26 return <ProfilePage did={profileMatch.did} /> 27 + } 28 + 29 + // Check for search route 30 + if (route === '/search') { 31 + return <SearchPage /> 26 32 } 27 33 28 34 // Check for docs routes
+460
apps/web/src/ui/SearchPage.tsx
··· 1 + /** 2 + * Search Results Page 3 + */ 4 + 5 + import { useState, useEffect, useMemo } from "react" 6 + import { listVideos, batchCheckMetadata, batchGetProfiles, formatDuration, type Video, type VideoMetadata, type Profile } from "../api" 7 + import { navigate, getSearchParams } from "../router" 8 + 9 + const styles = ` 10 + @import url('https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=Inter:wght@400;500;600&display=swap'); 11 + 12 + .search-page { 13 + min-height: 100vh; 14 + background: var(--bg); 15 + color: var(--text); 16 + font-family: 'Inter', system-ui, sans-serif; 17 + } 18 + 19 + .search-header { 20 + padding: 1.5rem 2rem; 21 + display: flex; 22 + justify-content: space-between; 23 + align-items: center; 24 + border-bottom: 1px solid var(--border); 25 + max-width: 1600px; 26 + margin: 0 auto; 27 + } 28 + 29 + .search-logo { 30 + font-family: 'Instrument Serif', Georgia, serif; 31 + font-size: 1.75rem; 32 + font-weight: 400; 33 + margin: 0; 34 + cursor: pointer; 35 + letter-spacing: -0.02em; 36 + } 37 + 38 + .search-logo:hover { 39 + opacity: 0.7; 40 + } 41 + 42 + .search-input-wrapper { 43 + position: relative; 44 + flex: 1; 45 + max-width: 600px; 46 + margin: 0 2rem; 47 + } 48 + 49 + .search-input { 50 + width: 100%; 51 + padding: 0.75rem 1rem 0.75rem 2.5rem; 52 + background: var(--bg-secondary); 53 + border: 1px solid var(--border); 54 + border-radius: 8px; 55 + color: var(--text); 56 + font-size: 1rem; 57 + font-family: inherit; 58 + transition: border-color 0.2s; 59 + } 60 + 61 + .search-input::placeholder { 62 + color: var(--text-muted); 63 + } 64 + 65 + .search-input:focus { 66 + outline: none; 67 + border-color: var(--accent); 68 + } 69 + 70 + .search-input-icon { 71 + position: absolute; 72 + left: 0.875rem; 73 + top: 50%; 74 + transform: translateY(-50%); 75 + color: var(--text-muted); 76 + font-size: 1rem; 77 + } 78 + 79 + .search-back { 80 + font-size: 0.875rem; 81 + font-weight: 500; 82 + color: var(--text-muted); 83 + cursor: pointer; 84 + transition: color 0.2s; 85 + } 86 + 87 + .search-back:hover { 88 + color: var(--text); 89 + } 90 + 91 + .search-content { 92 + max-width: 1600px; 93 + margin: 0 auto; 94 + padding: 2rem; 95 + } 96 + 97 + .search-results-header { 98 + margin-bottom: 2rem; 99 + } 100 + 101 + .search-results-title { 102 + font-family: 'Instrument Serif', Georgia, serif; 103 + font-size: 2rem; 104 + font-weight: 400; 105 + margin: 0 0 0.5rem; 106 + } 107 + 108 + .search-results-count { 109 + font-size: 0.875rem; 110 + color: var(--text-muted); 111 + } 112 + 113 + .search-grid { 114 + display: grid; 115 + grid-template-columns: repeat(3, 1fr); 116 + gap: 2rem; 117 + } 118 + 119 + @media (max-width: 1000px) { 120 + .search-grid { grid-template-columns: repeat(2, 1fr); } 121 + } 122 + 123 + @media (max-width: 600px) { 124 + .search-grid { grid-template-columns: 1fr; } 125 + } 126 + 127 + .search-card { 128 + cursor: pointer; 129 + } 130 + 131 + .search-card-media { 132 + aspect-ratio: 16 / 10; 133 + border-radius: 8px; 134 + overflow: hidden; 135 + margin-bottom: 1rem; 136 + background: var(--bg-secondary); 137 + position: relative; 138 + } 139 + 140 + .search-card-img { 141 + width: 100%; 142 + height: 100%; 143 + object-fit: cover; 144 + transition: transform 0.4s ease; 145 + } 146 + 147 + .search-card:hover .search-card-img { 148 + transform: scale(1.05); 149 + } 150 + 151 + .search-card-fallback { 152 + width: 100%; 153 + height: 100%; 154 + display: flex; 155 + flex-direction: column; 156 + align-items: center; 157 + justify-content: center; 158 + background: var(--card-bg); 159 + color: var(--text); 160 + gap: 0.75rem; 161 + } 162 + 163 + .search-card-fallback-icon { 164 + font-size: 2rem; 165 + opacity: 0.5; 166 + } 167 + 168 + .search-card-fallback-text { 169 + font-family: 'Instrument Serif', Georgia, serif; 170 + font-size: 0.875rem; 171 + text-align: center; 172 + padding: 0 1rem; 173 + line-height: 1.3; 174 + opacity: 0.7; 175 + display: -webkit-box; 176 + -webkit-line-clamp: 2; 177 + -webkit-box-orient: vertical; 178 + overflow: hidden; 179 + } 180 + 181 + .search-card-duration { 182 + position: absolute; 183 + bottom: 0.75rem; 184 + right: 0.75rem; 185 + padding: 0.25rem 0.5rem; 186 + background: rgba(0,0,0,0.8); 187 + color: #fff; 188 + font-size: 0.75rem; 189 + font-weight: 500; 190 + border-radius: 4px; 191 + } 192 + 193 + .search-card-title { 194 + font-family: 'Instrument Serif', Georgia, serif; 195 + font-size: 1.375rem; 196 + font-weight: 400; 197 + line-height: 1.3; 198 + margin: 0 0 0.5rem; 199 + } 200 + 201 + .search-card-meta { 202 + display: flex; 203 + align-items: center; 204 + gap: 0.75rem; 205 + font-size: 0.8125rem; 206 + color: var(--text-muted); 207 + } 208 + 209 + .search-card-creator { 210 + display: flex; 211 + align-items: center; 212 + gap: 0.5rem; 213 + cursor: pointer; 214 + transition: opacity 0.2s; 215 + } 216 + 217 + .search-card-creator:hover { 218 + opacity: 0.7; 219 + } 220 + 221 + .search-card-avatar { 222 + width: 24px; 223 + height: 24px; 224 + border-radius: 50%; 225 + object-fit: cover; 226 + } 227 + 228 + .search-card-avatar-fallback { 229 + width: 24px; 230 + height: 24px; 231 + border-radius: 50%; 232 + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); 233 + display: flex; 234 + align-items: center; 235 + justify-content: center; 236 + font-size: 0.625rem; 237 + font-weight: 600; 238 + color: #fff; 239 + } 240 + 241 + .search-empty { 242 + text-align: center; 243 + padding: 4rem 2rem; 244 + } 245 + 246 + .search-empty-icon { 247 + font-size: 4rem; 248 + margin-bottom: 1rem; 249 + opacity: 0.3; 250 + } 251 + 252 + .search-empty-title { 253 + font-family: 'Instrument Serif', Georgia, serif; 254 + font-size: 1.5rem; 255 + margin: 0 0 0.5rem; 256 + } 257 + 258 + .search-empty-text { 259 + color: var(--text-muted); 260 + } 261 + 262 + /* Skeleton */ 263 + @keyframes shimmer { 264 + 0% { background-position: -200% 0; } 265 + 100% { background-position: 200% 0; } 266 + } 267 + 268 + .search-skeleton { 269 + background: linear-gradient(90deg, var(--bg-secondary) 25%, var(--border) 50%, var(--bg-secondary) 75%); 270 + background-size: 200% 100%; 271 + animation: shimmer 1.5s infinite; 272 + border-radius: 8px; 273 + } 274 + 275 + .search-skeleton-card { 276 + display: flex; 277 + flex-direction: column; 278 + gap: 1rem; 279 + } 280 + 281 + .search-skeleton-media { 282 + aspect-ratio: 16 / 10; 283 + } 284 + 285 + .search-skeleton-title { 286 + height: 1.5rem; 287 + width: 80%; 288 + } 289 + 290 + .search-skeleton-meta { 291 + height: 1rem; 292 + width: 40%; 293 + } 294 + ` 295 + 296 + export function SearchPage() { 297 + const [query, setQuery] = useState(() => getSearchParams().get('q') || '') 298 + const [videos, setVideos] = useState<Video[]>([]) 299 + const [metadataMap, setMetadataMap] = useState<Map<string, VideoMetadata>>(new Map()) 300 + const [profileMap, setProfileMap] = useState<Map<string, Profile>>(new Map()) 301 + const [isLoading, setIsLoading] = useState(true) 302 + 303 + useEffect(() => { 304 + listVideos(100).then(async (r) => { 305 + setVideos(r.videos) 306 + setIsLoading(false) 307 + 308 + const uris = r.videos.map((v) => v.uri) 309 + const metadataResults = await batchCheckMetadata(uris) 310 + setMetadataMap(new Map( 311 + [...metadataResults.entries()].filter(([, m]) => m !== null) as [string, VideoMetadata][] 312 + )) 313 + 314 + const uniqueCreators = [...new Set(r.videos.map((v) => v.creator).filter(Boolean))] 315 + const profileResults = await batchGetProfiles(uniqueCreators) 316 + setProfileMap(profileResults) 317 + }) 318 + }, []) 319 + 320 + const results = useMemo(() => { 321 + if (!query.trim()) return [] 322 + const lowerQuery = query.toLowerCase() 323 + return videos.filter((v) => { 324 + // Search in title 325 + if (v.title.toLowerCase().includes(lowerQuery)) return true 326 + // Search in creator name/handle 327 + const profile = profileMap.get(v.creator) 328 + if (profile?.displayName?.toLowerCase().includes(lowerQuery)) return true 329 + if (profile?.handle?.toLowerCase().includes(lowerQuery)) return true 330 + return false 331 + }) 332 + }, [videos, query, profileMap]) 333 + 334 + const handleSearch = (newQuery: string) => { 335 + setQuery(newQuery) 336 + if (newQuery.trim()) { 337 + window.history.replaceState({}, '', `/search?q=${encodeURIComponent(newQuery)}`) 338 + } else { 339 + window.history.replaceState({}, '', '/search') 340 + } 341 + } 342 + 343 + const handleCreatorClick = (e: React.MouseEvent, did: string) => { 344 + e.stopPropagation() 345 + navigate(`/profile/${did}`) 346 + } 347 + 348 + return ( 349 + <> 350 + <style>{styles}</style> 351 + <div className="search-page"> 352 + <header className="search-header"> 353 + <h1 className="search-logo" onClick={() => navigate('/')}>Streamhut</h1> 354 + <div className="search-input-wrapper"> 355 + <span className="search-input-icon">&#128269;</span> 356 + <input 357 + type="text" 358 + className="search-input" 359 + placeholder="Search videos and creators..." 360 + value={query} 361 + onChange={(e) => handleSearch(e.target.value)} 362 + autoFocus 363 + /> 364 + </div> 365 + <span className="search-back" onClick={() => navigate('/')}> 366 + &larr; Back 367 + </span> 368 + </header> 369 + 370 + <div className="search-content"> 371 + {query.trim() && ( 372 + <div className="search-results-header"> 373 + <h2 className="search-results-title"> 374 + Results for "{query}" 375 + </h2> 376 + <p className="search-results-count"> 377 + {isLoading ? 'Searching...' : `${results.length} ${results.length === 1 ? 'video' : 'videos'} found`} 378 + </p> 379 + </div> 380 + )} 381 + 382 + {isLoading ? ( 383 + <div className="search-grid"> 384 + {[1, 2, 3, 4, 5, 6].map((i) => ( 385 + <div key={i} className="search-skeleton-card"> 386 + <div className="search-skeleton search-skeleton-media" /> 387 + <div className="search-skeleton search-skeleton-title" /> 388 + <div className="search-skeleton search-skeleton-meta" /> 389 + </div> 390 + ))} 391 + </div> 392 + ) : !query.trim() ? ( 393 + <div className="search-empty"> 394 + <div className="search-empty-icon">&#128269;</div> 395 + <h3 className="search-empty-title">Search for videos</h3> 396 + <p className="search-empty-text">Enter a title or creator name to find videos</p> 397 + </div> 398 + ) : results.length > 0 ? ( 399 + <div className="search-grid"> 400 + {results.map((video) => { 401 + const meta = metadataMap.get(video.uri) 402 + const profile = video.creator ? profileMap.get(video.creator) : undefined 403 + const creatorName = profile?.displayName || profile?.handle || null 404 + return ( 405 + <article 406 + key={video.uri} 407 + className="search-card" 408 + onClick={() => navigate(`/?play=${encodeURIComponent(video.uri)}`)} 409 + > 410 + <div className="search-card-media"> 411 + {meta?.thumbnailUrl ? ( 412 + <img className="search-card-img" src={meta.thumbnailUrl} alt="" /> 413 + ) : ( 414 + <div className="search-card-fallback"> 415 + <span className="search-card-fallback-icon">▶</span> 416 + <span className="search-card-fallback-text">{video.title}</span> 417 + </div> 418 + )} 419 + <span className="search-card-duration">{formatDuration(video.duration)}</span> 420 + </div> 421 + <h3 className="search-card-title">{video.title}</h3> 422 + <div className="search-card-meta"> 423 + {profile && ( 424 + <div 425 + className="search-card-creator" 426 + onClick={(e) => handleCreatorClick(e, video.creator)} 427 + > 428 + {profile.avatar ? ( 429 + <img className="search-card-avatar" src={profile.avatar} alt="" /> 430 + ) : ( 431 + <div className="search-card-avatar-fallback"> 432 + {creatorName?.[0]?.toUpperCase() || '?'} 433 + </div> 434 + )} 435 + <span>{creatorName}</span> 436 + </div> 437 + )} 438 + <span> 439 + {new Date(video.createdAt).toLocaleDateString('en-US', { 440 + month: 'short', 441 + day: 'numeric' 442 + })} 443 + </span> 444 + </div> 445 + </article> 446 + ) 447 + })} 448 + </div> 449 + ) : ( 450 + <div className="search-empty"> 451 + <div className="search-empty-icon">&#128533;</div> 452 + <h3 className="search-empty-title">No results found</h3> 453 + <p className="search-empty-text">Try a different search term</p> 454 + </div> 455 + )} 456 + </div> 457 + </div> 458 + </> 459 + ) 460 + }
+77 -19
apps/web/src/ui/UI4Editorial.tsx
··· 94 94 letter-spacing: -0.02em; 95 95 } 96 96 97 + .editorial-header-right { 98 + display: flex; 99 + align-items: center; 100 + gap: 2rem; 101 + } 102 + 103 + .editorial-search { 104 + position: relative; 105 + } 106 + 107 + .editorial-search-input { 108 + width: 220px; 109 + padding: 0.5rem 1rem 0.5rem 2.25rem; 110 + background: var(--bg-secondary); 111 + border: 1px solid var(--border); 112 + border-radius: 8px; 113 + color: var(--text); 114 + font-size: 0.875rem; 115 + font-family: inherit; 116 + transition: all 0.2s; 117 + } 118 + 119 + .editorial-search-input::placeholder { 120 + color: var(--text-muted); 121 + } 122 + 123 + .editorial-search-input:focus { 124 + outline: none; 125 + border-color: var(--accent); 126 + width: 280px; 127 + } 128 + 129 + .editorial-search-icon { 130 + position: absolute; 131 + left: 0.75rem; 132 + top: 50%; 133 + transform: translateY(-50%); 134 + color: var(--text-muted); 135 + font-size: 0.875rem; 136 + pointer-events: none; 137 + } 138 + 97 139 .editorial-nav { 98 140 display: flex; 99 141 gap: 2rem; ··· 1307 1349 <div className="editorial"> 1308 1350 <header className="editorial-header"> 1309 1351 <h1 className="editorial-logo">Streamhut</h1> 1310 - <nav className="editorial-nav"> 1311 - <a 1312 - href="#" 1313 - className={activeTab === 'videos' ? 'active' : ''} 1314 - onClick={(e) => { e.preventDefault(); setActiveTab('videos') }} 1315 - > 1316 - Videos 1317 - </a> 1318 - <a 1319 - href="#" 1320 - className={activeTab === 'creators' ? 'active' : ''} 1321 - onClick={(e) => { e.preventDefault(); setActiveTab('creators') }} 1322 - > 1323 - Creators 1324 - </a> 1325 - <a href="/docs" onClick={(e) => { e.preventDefault(); navigate('/docs') }}> 1326 - Docs 1327 - </a> 1328 - </nav> 1352 + <div className="editorial-header-right"> 1353 + <div className="editorial-search"> 1354 + <span className="editorial-search-icon">&#128269;</span> 1355 + <input 1356 + type="text" 1357 + className="editorial-search-input" 1358 + placeholder="Search videos..." 1359 + onKeyDown={(e) => { 1360 + if (e.key === 'Enter') { 1361 + const query = (e.target as HTMLInputElement).value.trim() 1362 + if (query) navigate(`/search?q=${encodeURIComponent(query)}`) 1363 + } 1364 + }} 1365 + /> 1366 + </div> 1367 + <nav className="editorial-nav"> 1368 + <a 1369 + href="#" 1370 + className={activeTab === 'videos' ? 'active' : ''} 1371 + onClick={(e) => { e.preventDefault(); setActiveTab('videos') }} 1372 + > 1373 + Videos 1374 + </a> 1375 + <a 1376 + href="#" 1377 + className={activeTab === 'creators' ? 'active' : ''} 1378 + onClick={(e) => { e.preventDefault(); setActiveTab('creators') }} 1379 + > 1380 + Creators 1381 + </a> 1382 + <a href="/docs" onClick={(e) => { e.preventDefault(); navigate('/docs') }}> 1383 + Docs 1384 + </a> 1385 + </nav> 1386 + </div> 1329 1387 </header> 1330 1388 1331 1389 {activeTab === 'videos' && (