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): navy-only theme and creators section

- Remove theme toggle, keep Navy as the only theme
- Add Creators tab with grid of content creators
- Show video count and total duration per creator
- Update ProfilePage to use CSS variables
- Add Docs link to navigation

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

+325 -351
+1 -68
apps/web/src/index.css
··· 1 - /* Base styles and theme variables */ 1 + /* Base styles - Navy theme */ 2 2 :root { 3 - /* Navy theme (default) */ 4 3 --text: #c8d4e3; 5 4 --text-h: #e8eef5; 6 5 --text-muted: #8fa3bd; ··· 25 24 -moz-osx-font-smoothing: grayscale; 26 25 } 27 26 28 - /* Light theme */ 29 - [data-theme="light"] { 30 - --text: #333; 31 - --text-h: #111; 32 - --text-muted: #666; 33 - --bg: #fafafa; 34 - --bg-secondary: #fff; 35 - --card-bg: #fff; 36 - --border: #e5e5e5; 37 - --accent: #4f46e5; 38 - --accent-hover: #4338ca; 39 - --accent-label: #dc2626; 40 - } 41 - 42 - /* Dark theme */ 43 - [data-theme="dark"] { 44 - --text: #e5e5e5; 45 - --text-h: #ffffff; 46 - --text-muted: #888; 47 - --bg: #0a0a0a; 48 - --bg-secondary: #1a1a1a; 49 - --card-bg: #1a1a1a; 50 - --border: #2a2a2a; 51 - --accent: #646cff; 52 - --accent-hover: #535bf2; 53 - --accent-label: #e63946; 54 - } 55 - 56 - /* Navy-blue pastel theme */ 57 - [data-theme="navy"] { 58 - --text: #c8d4e3; 59 - --text-h: #e8eef5; 60 - --text-muted: #8fa3bd; 61 - --bg: #1a2332; 62 - --bg-secondary: #232f42; 63 - --card-bg: #232f42; 64 - --border: #2d3d54; 65 - --accent: #6b8cce; 66 - --accent-hover: #5a7bc4; 67 - --accent-label: #f0a3a3; 68 - } 69 - 70 27 * { 71 28 box-sizing: border-box; 72 29 } ··· 90 47 button { 91 48 font-family: inherit; 92 49 } 93 - 94 - /* Theme toggle button styles */ 95 - .theme-toggle { 96 - display: flex; 97 - align-items: center; 98 - gap: 0.5rem; 99 - padding: 0.5rem 0.75rem; 100 - background: var(--bg-secondary); 101 - border: 1px solid var(--border); 102 - border-radius: 8px; 103 - color: var(--text); 104 - font-size: 0.8125rem; 105 - cursor: pointer; 106 - transition: all 0.2s; 107 - } 108 - 109 - .theme-toggle:hover { 110 - border-color: var(--accent); 111 - } 112 - 113 - .theme-toggle svg { 114 - width: 16px; 115 - height: 16px; 116 - }
-50
apps/web/src/lib/theme.ts
··· 1 - /** 2 - * Theme management with localStorage persistence 3 - * 4 - * Themes: system (follows OS), light, dark, navy (navy-blue pastel) 5 - */ 6 - 7 - export type Theme = 'system' | 'light' | 'dark' | 'navy' 8 - 9 - const STORAGE_KEY = 'streamhut-theme' 10 - 11 - export function getStoredTheme(): Theme { 12 - if (typeof window === 'undefined') return 'navy' 13 - const stored = localStorage.getItem(STORAGE_KEY) 14 - if (stored === 'light' || stored === 'dark' || stored === 'navy' || stored === 'system') { 15 - return stored 16 - } 17 - return 'navy' 18 - } 19 - 20 - export function setStoredTheme(theme: Theme): void { 21 - localStorage.setItem(STORAGE_KEY, theme) 22 - } 23 - 24 - export function getEffectiveTheme(theme: Theme): 'light' | 'dark' | 'navy' { 25 - if (theme === 'system') { 26 - if (typeof window === 'undefined') return 'navy' 27 - return window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark' 28 - } 29 - return theme 30 - } 31 - 32 - export function applyTheme(theme: Theme): void { 33 - const effective = getEffectiveTheme(theme) 34 - document.documentElement.setAttribute('data-theme', effective) 35 - } 36 - 37 - // Initialize theme on load (call this early to prevent flash) 38 - export function initTheme(): void { 39 - const theme = getStoredTheme() 40 - applyTheme(theme) 41 - 42 - // Listen for system theme changes when in system mode 43 - if (theme === 'system') { 44 - window.matchMedia('(prefers-color-scheme: light)').addEventListener('change', () => { 45 - if (getStoredTheme() === 'system') { 46 - applyTheme('system') 47 - } 48 - }) 49 - } 50 - }
-4
apps/web/src/main.tsx
··· 1 1 import { StrictMode, useState, useEffect } from 'react' 2 2 import { createRoot } from 'react-dom/client' 3 3 import './index.css' 4 - import { initTheme } from './lib/theme.ts' 5 4 import { UI4Editorial } from './ui/UI4Editorial.tsx' 6 5 import { ProfilePage } from './ui/ProfilePage.tsx' 7 6 import { DocsPage } from './ui/DocsPage.tsx' 8 7 import { onRouteChange, matchPath } from './router.ts' 9 - 10 - // Initialize theme before render to prevent flash 11 - initTheme() 12 8 13 9 function Router() { 14 10 const [route, setRoute] = useState(window.location.pathname)
+14 -67
apps/web/src/ui/ProfilePage.tsx
··· 11 11 12 12 .profile-page { 13 13 min-height: 100vh; 14 - background: #fff; 15 - color: #111; 14 + background: var(--bg); 15 + color: var(--text); 16 16 font-family: 'Inter', system-ui, sans-serif; 17 17 } 18 18 19 - @media (prefers-color-scheme: dark) { 20 - .profile-page { 21 - background: #0a0a0a; 22 - color: #f5f5f5; 23 - } 24 - } 25 - 26 19 .profile-header { 27 20 padding: 1.5rem 2rem; 28 21 display: flex; 29 22 justify-content: space-between; 30 23 align-items: center; 31 - border-bottom: 1px solid #eee; 24 + border-bottom: 1px solid var(--border); 32 25 max-width: 1600px; 33 26 margin: 0 auto; 34 - } 35 - 36 - @media (prefers-color-scheme: dark) { 37 - .profile-header { border-color: #222; } 38 27 } 39 28 40 29 .profile-logo { ··· 91 80 height: 180px; 92 81 border-radius: 50%; 93 82 object-fit: cover; 94 - background: #e5e5e5; 83 + background: var(--border); 95 84 flex-shrink: 0; 96 85 } 97 86 98 - @media (prefers-color-scheme: dark) { 99 - .profile-avatar { background: #222; } 100 - } 101 - 102 87 .profile-avatar-fallback { 103 88 width: 180px; 104 89 height: 180px; ··· 133 118 134 119 .profile-handle { 135 120 font-size: 1.125rem; 136 - color: #666; 121 + color: var(--text-muted); 137 122 margin-bottom: 1.5rem; 138 123 } 139 124 140 - @media (prefers-color-scheme: dark) { 141 - .profile-handle { color: #999; } 142 - } 143 - 144 125 .profile-description { 145 126 font-size: 1rem; 146 127 line-height: 1.6; 147 - color: #444; 128 + color: var(--text); 148 129 max-width: 600px; 149 130 } 150 131 151 - @media (prefers-color-scheme: dark) { 152 - .profile-description { color: #bbb; } 153 - } 154 - 155 132 .profile-stats { 156 133 display: flex; 157 134 gap: 2rem; ··· 175 152 176 153 .profile-stat-label { 177 154 font-size: 0.875rem; 178 - color: #666; 179 - } 180 - 181 - @media (prefers-color-scheme: dark) { 182 - .profile-stat-label { color: #999; } 155 + color: var(--text-muted); 183 156 } 184 157 185 158 .profile-section { 186 159 max-width: 1600px; 187 160 margin: 0 auto; 188 161 padding: 3rem 2rem; 189 - border-top: 1px solid #eee; 190 - } 191 - 192 - @media (prefers-color-scheme: dark) { 193 - .profile-section { border-color: #222; } 162 + border-top: 1px solid var(--border); 194 163 } 195 164 196 165 .profile-section-title { ··· 223 192 border-radius: 8px; 224 193 overflow: hidden; 225 194 margin-bottom: 1rem; 226 - background: #f5f5f5; 195 + background: var(--bg-secondary); 227 196 position: relative; 228 197 } 229 198 230 - @media (prefers-color-scheme: dark) { 231 - .profile-card-media { background: #1a1a1a; } 232 - } 233 - 234 199 .profile-card-img { 235 200 width: 100%; 236 201 height: 100%; ··· 249 214 flex-direction: column; 250 215 align-items: center; 251 216 justify-content: center; 252 - background: linear-gradient(135deg, #1a1a1a 0%, #2d2d2d 100%); 253 - color: #fff; 217 + background: var(--card-bg); 218 + color: var(--text); 254 219 gap: 0.75rem; 255 220 } 256 221 257 - @media (prefers-color-scheme: light) { 258 - .profile-card-fallback { 259 - background: linear-gradient(135deg, #f0f0f0 0%, #e0e0e0 100%); 260 - color: #333; 261 - } 262 - } 263 - 264 222 .profile-card-fallback-icon { 265 223 font-size: 2rem; 266 224 opacity: 0.5; ··· 301 259 302 260 .profile-card-meta { 303 261 font-size: 0.8125rem; 304 - color: #777; 262 + color: var(--text-muted); 305 263 } 306 264 307 265 .profile-empty { 308 266 text-align: center; 309 267 padding: 4rem 2rem; 310 - color: #666; 311 - } 312 - 313 - @media (prefers-color-scheme: dark) { 314 - .profile-empty { color: #999; } 268 + color: var(--text-muted); 315 269 } 316 270 317 271 .profile-empty-icon { ··· 327 281 } 328 282 329 283 .profile-skeleton { 330 - background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%); 284 + background: linear-gradient(90deg, var(--bg-secondary) 25%, var(--border) 50%, var(--bg-secondary) 75%); 331 285 background-size: 200% 100%; 332 286 animation: shimmer 1.5s infinite; 333 287 border-radius: 8px; 334 - } 335 - 336 - @media (prefers-color-scheme: dark) { 337 - .profile-skeleton { 338 - background: linear-gradient(90deg, #1a1a1a 25%, #2a2a2a 50%, #1a1a1a 75%); 339 - background-size: 200% 100%; 340 - } 341 288 } 342 289 343 290 .profile-skeleton-avatar {
+310 -162
apps/web/src/ui/UI4Editorial.tsx
··· 12 12 import { useState, useEffect, useRef, useMemo, useCallback } from "react" 13 13 import { listVideos, batchCheckMetadata, batchGetProfiles, formatDuration, getPlaylistUrl, type Video, type VideoMetadata, type Profile } from "../api" 14 14 import { sortByRecommendation, recordWatch, getContinueWatching } from "../lib/watchHistory" 15 - import { getStoredTheme, setStoredTheme, applyTheme, type Theme } from "../lib/theme" 16 15 import { navigate } from "../router" 17 16 import Hls from "hls.js" 18 17 ··· 856 855 aspect-ratio: 4 / 3; 857 856 border-radius: 8px; 858 857 } 858 + 859 + /* Creators section */ 860 + .editorial-creators-grid { 861 + display: grid; 862 + grid-template-columns: repeat(4, 1fr); 863 + gap: 1.5rem; 864 + } 865 + 866 + @media (max-width: 1200px) { 867 + .editorial-creators-grid { grid-template-columns: repeat(3, 1fr); } 868 + } 869 + 870 + @media (max-width: 800px) { 871 + .editorial-creators-grid { grid-template-columns: repeat(2, 1fr); } 872 + } 873 + 874 + @media (max-width: 500px) { 875 + .editorial-creators-grid { grid-template-columns: 1fr; } 876 + } 877 + 878 + .editorial-creator-card { 879 + display: flex; 880 + align-items: center; 881 + gap: 1rem; 882 + padding: 1rem; 883 + background: var(--card-bg); 884 + border: 1px solid var(--border); 885 + border-radius: 12px; 886 + cursor: pointer; 887 + transition: all 0.2s; 888 + } 889 + 890 + .editorial-creator-card:hover { 891 + border-color: var(--accent); 892 + transform: translateY(-2px); 893 + } 894 + 895 + .editorial-creator-avatar { 896 + width: 64px; 897 + height: 64px; 898 + border-radius: 50%; 899 + object-fit: cover; 900 + flex-shrink: 0; 901 + } 902 + 903 + .editorial-creator-avatar-fallback { 904 + width: 64px; 905 + height: 64px; 906 + border-radius: 50%; 907 + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); 908 + display: flex; 909 + align-items: center; 910 + justify-content: center; 911 + font-family: 'Instrument Serif', Georgia, serif; 912 + font-size: 1.5rem; 913 + color: #fff; 914 + flex-shrink: 0; 915 + } 916 + 917 + .editorial-creator-info { 918 + flex: 1; 919 + min-width: 0; 920 + } 921 + 922 + .editorial-creator-name { 923 + font-family: 'Instrument Serif', Georgia, serif; 924 + font-size: 1.125rem; 925 + font-weight: 400; 926 + margin: 0 0 0.25rem; 927 + white-space: nowrap; 928 + overflow: hidden; 929 + text-overflow: ellipsis; 930 + } 931 + 932 + .editorial-creator-handle { 933 + font-size: 0.8125rem; 934 + color: var(--text-muted); 935 + margin-bottom: 0.5rem; 936 + white-space: nowrap; 937 + overflow: hidden; 938 + text-overflow: ellipsis; 939 + } 940 + 941 + .editorial-creator-stats { 942 + font-size: 0.75rem; 943 + color: var(--text-muted); 944 + } 945 + 946 + .editorial-creator-stats strong { 947 + color: var(--text); 948 + font-weight: 600; 949 + } 859 950 ` 860 951 861 952 interface CardProps { ··· 1140 1231 ) 1141 1232 } 1142 1233 1143 - // Theme toggle component 1144 - function ThemeToggle({ theme, onChange }: { theme: Theme; onChange: (t: Theme) => void }) { 1145 - const themes: { value: Theme; label: string; icon: string }[] = [ 1146 - { value: 'system', label: 'System', icon: '💻' }, 1147 - { value: 'light', label: 'Light', icon: '☀️' }, 1148 - { value: 'dark', label: 'Dark', icon: '🌙' }, 1149 - { value: 'navy', label: 'Navy', icon: '🌊' }, 1150 - ] 1151 - const current = themes.find(t => t.value === theme) || themes[0] 1152 - const nextIndex = (themes.findIndex(t => t.value === theme) + 1) % themes.length 1153 - 1154 - return ( 1155 - <button 1156 - className="theme-toggle" 1157 - onClick={() => onChange(themes[nextIndex].value)} 1158 - title={`Theme: ${current.label}. Click to switch.`} 1159 - > 1160 - <span>{current.icon}</span> 1161 - <span>{current.label}</span> 1162 - </button> 1163 - ) 1164 - } 1165 - 1166 1234 export function UI4Editorial() { 1167 1235 const [videos, setVideos] = useState<Video[]>([]) 1168 1236 const [metadataMap, setMetadataMap] = useState<Map<string, VideoMetadata>>(new Map()) ··· 1170 1238 const [playing, setPlaying] = useState<Video | null>(null) 1171 1239 const [continueWatching, setContinueWatching] = useState<Array<{ uri: string; progress: number }>>([]) 1172 1240 const [isLoading, setIsLoading] = useState(true) 1173 - const [theme, setThemeState] = useState<Theme>(getStoredTheme) 1174 - 1175 - const handleThemeChange = useCallback((newTheme: Theme) => { 1176 - setThemeState(newTheme) 1177 - setStoredTheme(newTheme) 1178 - applyTheme(newTheme) 1179 - }, []) 1241 + const [activeTab, setActiveTab] = useState<'videos' | 'creators'>('videos') 1180 1242 1181 1243 useEffect(() => { 1182 1244 listVideos(50).then(async (r) => { ··· 1219 1281 [otherVideos] 1220 1282 ) 1221 1283 1284 + // Compute creators with video counts 1285 + const creators = useMemo(() => { 1286 + const creatorStats = new Map<string, { count: number; totalDuration: number }>() 1287 + for (const video of videos) { 1288 + if (!video.creator) continue 1289 + const existing = creatorStats.get(video.creator) || { count: 0, totalDuration: 0 } 1290 + creatorStats.set(video.creator, { 1291 + count: existing.count + 1, 1292 + totalDuration: existing.totalDuration + video.duration, 1293 + }) 1294 + } 1295 + return [...creatorStats.entries()] 1296 + .map(([did, stats]) => ({ did, ...stats, profile: profileMap.get(did) })) 1297 + .sort((a, b) => b.count - a.count) 1298 + }, [videos, profileMap]) 1299 + 1222 1300 const continueVideos = continueWatching 1223 1301 .map(({ uri, progress }) => ({ video: videos.find((v) => v.uri === uri), progress })) 1224 1302 .filter((x) => x.video) as Array<{ video: Video; progress: number }> ··· 1229 1307 <div className="editorial"> 1230 1308 <header className="editorial-header"> 1231 1309 <h1 className="editorial-logo">Streamhut</h1> 1232 - <div style={{ display: 'flex', alignItems: 'center', gap: '2rem' }}> 1233 - <nav className="editorial-nav"> 1234 - <a href="#" className="active">Videos</a> 1235 - <a href="#">Creators</a> 1236 - </nav> 1237 - <ThemeToggle theme={theme} onChange={handleThemeChange} /> 1238 - </div> 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> 1239 1329 </header> 1240 1330 1241 - {continueVideos.length > 0 && ( 1242 - <div className="editorial-continue"> 1243 - <div className="editorial-continue-inner"> 1244 - <span className="editorial-continue-label">Continue Watching</span> 1245 - <div className="editorial-continue-items"> 1246 - {continueVideos.map(({ video, progress }) => { 1247 - const meta = metadataMap.get(video.uri) 1248 - const thumb = meta?.thumbnailUrl || null 1249 - return ( 1331 + {activeTab === 'videos' && ( 1332 + <> 1333 + {continueVideos.length > 0 && ( 1334 + <div className="editorial-continue"> 1335 + <div className="editorial-continue-inner"> 1336 + <span className="editorial-continue-label">Continue Watching</span> 1337 + <div className="editorial-continue-items"> 1338 + {continueVideos.map(({ video, progress }) => { 1339 + const meta = metadataMap.get(video.uri) 1340 + const thumb = meta?.thumbnailUrl || null 1341 + return ( 1342 + <div 1343 + key={video.uri} 1344 + className="editorial-continue-item" 1345 + onClick={() => setPlaying(video)} 1346 + > 1347 + {thumb ? ( 1348 + <img className="editorial-continue-thumb" src={thumb} alt="" /> 1349 + ) : ( 1350 + <div className="editorial-continue-thumb" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '1rem', opacity: 0.4 }}>▶</div> 1351 + )} 1352 + <div className="editorial-continue-info"> 1353 + <div className="editorial-continue-title">{video.title}</div> 1354 + <div className="editorial-continue-progress"> 1355 + <div 1356 + className="editorial-continue-progress-bar" 1357 + style={{ width: `${progress * 100}%` }} 1358 + /> 1359 + </div> 1360 + </div> 1361 + </div> 1362 + ) 1363 + })} 1364 + </div> 1365 + </div> 1366 + </div> 1367 + )} 1368 + 1369 + {isLoading ? ( 1370 + <div className="editorial-skeleton-hero"> 1371 + <div className="editorial-skeleton-hero-content"> 1372 + <div className="editorial-skeleton editorial-skeleton-hero-label" /> 1373 + <div className="editorial-skeleton editorial-skeleton-hero-title" /> 1374 + <div className="editorial-skeleton editorial-skeleton-hero-excerpt" /> 1375 + </div> 1376 + <div className="editorial-skeleton editorial-skeleton-hero-media" /> 1377 + </div> 1378 + ) : featured && ( 1379 + <section className="editorial-hero"> 1380 + <div className="editorial-hero-content"> 1381 + <div className="editorial-hero-label">Featured</div> 1382 + <h2 className="editorial-hero-title">{featured.title}</h2> 1383 + <div className="editorial-hero-meta"> 1250 1384 <div 1251 - key={video.uri} 1252 - className="editorial-continue-item" 1253 - onClick={() => setPlaying(video)} 1385 + className="editorial-hero-meta-link" 1386 + onClick={() => featured.creator && navigate(`/profile/${featured.creator}`)} 1254 1387 > 1255 - {thumb ? ( 1256 - <img className="editorial-continue-thumb" src={thumb} alt="" /> 1257 - ) : ( 1258 - <div className="editorial-continue-thumb" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '1rem', opacity: 0.4 }}>▶</div> 1388 + {featuredProfile?.avatar && ( 1389 + <img className="editorial-hero-avatar" src={featuredProfile.avatar} alt="" /> 1259 1390 )} 1260 - <div className="editorial-continue-info"> 1261 - <div className="editorial-continue-title">{video.title}</div> 1262 - <div className="editorial-continue-progress"> 1263 - <div 1264 - className="editorial-continue-progress-bar" 1265 - style={{ width: `${progress * 100}%` }} 1266 - /> 1391 + <div> 1392 + <div className="editorial-hero-author"> 1393 + {featuredProfile?.displayName || featuredProfile?.handle || "Creator"} 1267 1394 </div> 1395 + <div>{formatDuration(featured.duration)}</div> 1268 1396 </div> 1269 1397 </div> 1270 - ) 1271 - })} 1398 + </div> 1399 + </div> 1400 + <div className="editorial-hero-media" onClick={() => setPlaying(featured)}> 1401 + {featuredMeta?.thumbnailUrl ? ( 1402 + <img 1403 + className="editorial-hero-img" 1404 + src={featuredMeta.thumbnailUrl} 1405 + alt="" 1406 + /> 1407 + ) : ( 1408 + <ThumbnailFallback title={featured.title} /> 1409 + )} 1410 + <div className="editorial-hero-play"> 1411 + <div className="editorial-hero-play-btn">▶</div> 1412 + </div> 1413 + </div> 1414 + </section> 1415 + )} 1416 + 1417 + <section className="editorial-section"> 1418 + <div className="editorial-section-header"> 1419 + <h2 className="editorial-section-title">Latest</h2> 1272 1420 </div> 1273 - </div> 1274 - </div> 1421 + <div className="editorial-grid"> 1422 + {isLoading ? ( 1423 + <> 1424 + <SkeletonCard /> 1425 + <SkeletonCard /> 1426 + <SkeletonCard /> 1427 + <SkeletonCard /> 1428 + <SkeletonCard /> 1429 + <SkeletonCard /> 1430 + </> 1431 + ) : ( 1432 + recent.map((video) => ( 1433 + <Card 1434 + key={video.uri} 1435 + video={video} 1436 + metadata={metadataMap.get(video.uri)} 1437 + profile={video.creator ? profileMap.get(video.creator) : undefined} 1438 + onClick={() => setPlaying(video)} 1439 + /> 1440 + )) 1441 + )} 1442 + </div> 1443 + </section> 1444 + 1445 + <section className="editorial-section"> 1446 + <div className="editorial-section-header"> 1447 + <h2 className="editorial-section-title">Recommended For You</h2> 1448 + </div> 1449 + <div className="editorial-grid"> 1450 + {isLoading ? ( 1451 + <> 1452 + <SkeletonCard /> 1453 + <SkeletonCard /> 1454 + <SkeletonCard /> 1455 + <SkeletonCard /> 1456 + <SkeletonCard /> 1457 + <SkeletonCard /> 1458 + </> 1459 + ) : ( 1460 + recommended.slice(0, 6).map((video) => ( 1461 + <Card 1462 + key={video.uri} 1463 + video={video} 1464 + metadata={metadataMap.get(video.uri)} 1465 + profile={video.creator ? profileMap.get(video.creator) : undefined} 1466 + onClick={() => setPlaying(video)} 1467 + /> 1468 + )) 1469 + )} 1470 + </div> 1471 + </section> 1472 + </> 1275 1473 )} 1276 1474 1277 - {isLoading ? ( 1278 - <div className="editorial-skeleton-hero"> 1279 - <div className="editorial-skeleton-hero-content"> 1280 - <div className="editorial-skeleton editorial-skeleton-hero-label" /> 1281 - <div className="editorial-skeleton editorial-skeleton-hero-title" /> 1282 - <div className="editorial-skeleton editorial-skeleton-hero-excerpt" /> 1475 + {activeTab === 'creators' && ( 1476 + <section className="editorial-section"> 1477 + <div className="editorial-section-header"> 1478 + <h2 className="editorial-section-title">Creators</h2> 1283 1479 </div> 1284 - <div className="editorial-skeleton editorial-skeleton-hero-media" /> 1285 - </div> 1286 - ) : featured && ( 1287 - <section className="editorial-hero"> 1288 - <div className="editorial-hero-content"> 1289 - <div className="editorial-hero-label">Featured</div> 1290 - <h2 className="editorial-hero-title">{featured.title}</h2> 1291 - <div className="editorial-hero-meta"> 1292 - <div 1293 - className="editorial-hero-meta-link" 1294 - onClick={() => featured.creator && navigate(`/profile/${featured.creator}`)} 1295 - > 1296 - {featuredProfile?.avatar && ( 1297 - <img className="editorial-hero-avatar" src={featuredProfile.avatar} alt="" /> 1298 - )} 1299 - <div> 1300 - <div className="editorial-hero-author"> 1301 - {featuredProfile?.displayName || featuredProfile?.handle || "Creator"} 1480 + {isLoading ? ( 1481 + <div className="editorial-creators-grid"> 1482 + {[1, 2, 3, 4].map((i) => ( 1483 + <div key={i} className="editorial-creator-card" style={{ opacity: 0.5 }}> 1484 + <div className="editorial-skeleton" style={{ width: 64, height: 64, borderRadius: '50%' }} /> 1485 + <div className="editorial-creator-info"> 1486 + <div className="editorial-skeleton" style={{ height: '1.125rem', width: '70%', marginBottom: '0.5rem' }} /> 1487 + <div className="editorial-skeleton" style={{ height: '0.75rem', width: '50%' }} /> 1302 1488 </div> 1303 - <div>{formatDuration(featured.duration)}</div> 1304 1489 </div> 1305 - </div> 1490 + ))} 1306 1491 </div> 1307 - </div> 1308 - <div className="editorial-hero-media" onClick={() => setPlaying(featured)}> 1309 - {featuredMeta?.thumbnailUrl ? ( 1310 - <img 1311 - className="editorial-hero-img" 1312 - src={featuredMeta.thumbnailUrl} 1313 - alt="" 1314 - /> 1315 - ) : ( 1316 - <ThumbnailFallback title={featured.title} /> 1317 - )} 1318 - <div className="editorial-hero-play"> 1319 - <div className="editorial-hero-play-btn">▶</div> 1492 + ) : creators.length > 0 ? ( 1493 + <div className="editorial-creators-grid"> 1494 + {creators.map(({ did, count, totalDuration, profile }) => { 1495 + const displayName = profile?.displayName || profile?.handle || did.slice(0, 20) + '...' 1496 + return ( 1497 + <div 1498 + key={did} 1499 + className="editorial-creator-card" 1500 + onClick={() => navigate(`/profile/${did}`)} 1501 + > 1502 + {profile?.avatar ? ( 1503 + <img className="editorial-creator-avatar" src={profile.avatar} alt="" /> 1504 + ) : ( 1505 + <div className="editorial-creator-avatar-fallback"> 1506 + {displayName[0]?.toUpperCase()} 1507 + </div> 1508 + )} 1509 + <div className="editorial-creator-info"> 1510 + <h3 className="editorial-creator-name">{displayName}</h3> 1511 + {profile?.handle && ( 1512 + <div className="editorial-creator-handle">@{profile.handle}</div> 1513 + )} 1514 + <div className="editorial-creator-stats"> 1515 + <strong>{count}</strong> {count === 1 ? 'video' : 'videos'} · {formatDuration(totalDuration)} 1516 + </div> 1517 + </div> 1518 + </div> 1519 + ) 1520 + })} 1320 1521 </div> 1321 - </div> 1522 + ) : ( 1523 + <p style={{ color: 'var(--text-muted)', textAlign: 'center', padding: '3rem' }}> 1524 + No creators found. 1525 + </p> 1526 + )} 1322 1527 </section> 1323 1528 )} 1324 - 1325 - <section className="editorial-section"> 1326 - <div className="editorial-section-header"> 1327 - <h2 className="editorial-section-title">Latest</h2> 1328 - <a href="#" className="editorial-section-link">View all →</a> 1329 - </div> 1330 - <div className="editorial-grid"> 1331 - {isLoading ? ( 1332 - <> 1333 - <SkeletonCard /> 1334 - <SkeletonCard /> 1335 - <SkeletonCard /> 1336 - <SkeletonCard /> 1337 - <SkeletonCard /> 1338 - <SkeletonCard /> 1339 - </> 1340 - ) : ( 1341 - recent.map((video) => ( 1342 - <Card 1343 - key={video.uri} 1344 - video={video} 1345 - metadata={metadataMap.get(video.uri)} 1346 - profile={video.creator ? profileMap.get(video.creator) : undefined} 1347 - onClick={() => setPlaying(video)} 1348 - /> 1349 - )) 1350 - )} 1351 - </div> 1352 - </section> 1353 - 1354 - <section className="editorial-section"> 1355 - <div className="editorial-section-header"> 1356 - <h2 className="editorial-section-title">Recommended For You</h2> 1357 - </div> 1358 - <div className="editorial-grid"> 1359 - {isLoading ? ( 1360 - <> 1361 - <SkeletonCard /> 1362 - <SkeletonCard /> 1363 - <SkeletonCard /> 1364 - <SkeletonCard /> 1365 - <SkeletonCard /> 1366 - <SkeletonCard /> 1367 - </> 1368 - ) : ( 1369 - recommended.slice(0, 6).map((video) => ( 1370 - <Card 1371 - key={video.uri} 1372 - video={video} 1373 - metadata={metadataMap.get(video.uri)} 1374 - profile={video.creator ? profileMap.get(video.creator) : undefined} 1375 - onClick={() => setPlaying(video)} 1376 - /> 1377 - )) 1378 - )} 1379 - </div> 1380 - </section> 1381 1529 1382 1530 {playing && ( 1383 1531 <Player