From 0e665758ab9b60bd2e48adfc8adf360fa2484078 Mon Sep 17 00:00:00 2001 From: Chad Curtis Date: Mon, 2 Mar 2026 01:46:20 -0600 Subject: [PATCH] Redesign videos page to YouTube-style vertical feed Replace the fragmented horizontal-scroll sections with a single clean vertical list of video cards (thumbnail + title + author + timestamp), matching the normie-familiar YouTube homepage pattern. - Normal videos (kind 21): full-width aspect-video card with large thumbnail, YouTube-style info row (avatar left, title/author/time right) - Shorts (kind 22): compact portrait card (100px wide thumbnail + text), tapping still opens the TikTok-style snap-scroll player - Live now shelf: compact horizontal strip with smaller chips, sits above the feed behind a border separator rather than floating mid-page - Empty state links directly to 'Switch to Global' inline --- src/pages/VideosFeedPage.tsx | 424 ++++++++++++++++++++--------------- 1 file changed, 243 insertions(+), 181 deletions(-) diff --git a/src/pages/VideosFeedPage.tsx b/src/pages/VideosFeedPage.tsx index 0ed9c9ab..6678c419 100644 --- a/src/pages/VideosFeedPage.tsx +++ b/src/pages/VideosFeedPage.tsx @@ -1,21 +1,17 @@ /** - * VideosFeedPage — unified video + stream feed. + * VideosFeedPage — YouTube-style vertical video feed. * - * ┌─ Follows | Global tabs ─────────────────────┐ - * ├─ Live Now horizontal strip (live-only) ──────┤ - * ├─ Videos (kind 21) grid ──────────────────────┤ - * ├─ Shorts (kind 22) — inline snap-scroll ──────┤ - * │ (exactly like VinesFeedPage, within column) │ - * └──────────────────────────────────────────────┘ - * - * Global: sort:hot (ditto relay, limit 8/page) - * Follows: chronological (useFeed, limit 8/page via PAGE_SIZE override) - * Streams: live-only query, limit 10 + * Layout (top to bottom): + * ┌─ Follows | Global tabs ──────────────────────────┐ + * ├─ Live Now strip (horizontal, compact) ────────────┤ + * ├─ Video card (thumbnail + title + author + time) ──┤ + * ├─ Video card ... │ + * └───────────────────────────────────────────────────┘ */ import { useState, useEffect, useMemo, useRef } from 'react'; import { Link } from 'react-router-dom'; -import { ArrowLeft, Film, Radio, Play, Clock, Eye } from 'lucide-react'; +import { ArrowLeft, Film, Radio, Play, Clock, Eye, Tv2 } from 'lucide-react'; import { useSeoMeta } from '@unhead/react'; import { nip19 } from 'nostr-tools'; import { Blurhash } from 'react-blurhash'; @@ -49,8 +45,8 @@ import { VineCard } from '@/pages/VinesFeedPage'; const videosDef = getExtraKindDef('videos')!; -/** Items per page for video feeds — enough to fill the horizontal row with overflow. */ -const VIDEO_PAGE_SIZE = 12; +/** Items per page for video feeds. */ +const VIDEO_PAGE_SIZE = 20; type FeedTab = 'follows' | 'global'; @@ -61,7 +57,6 @@ function getTag(tags: string[][], name: string): string | undefined { } function parseVideoImeta(tags: string[][]): { url?: string; thumbnail?: string; duration?: string; blurhash?: string } { - // Standalone fallback tags (checked after imeta) const standaloneThumb = getTag(tags, 'thumb') ?? getTag(tags, 'image'); for (const tag of tags) { @@ -75,7 +70,6 @@ function parseVideoImeta(tags: string[][]): { url?: string; thumbnail?: string; if (parts.url) { return { url: parts.url, - // imeta uses "image" key for thumbnail; fall back to standalone tags thumbnail: parts.image ?? parts.thumb ?? standaloneThumb, duration: parts.duration, blurhash: parts.blurhash, @@ -114,7 +108,7 @@ function TabButton({ label, active, onClick, disabled }: { ); } -// ── Author chip ─────────────────────────────────────────────────────────────── +// ── Author chip (inline, for video card row) ────────────────────────────────── function CardAuthor({ pubkey }: { pubkey: string }) { const author = useAuthor(pubkey); @@ -142,12 +136,13 @@ function CardAuthor({ pubkey }: { pubkey: string }) { ); } -// ── Video grid card (kind 21) ───────────────────────────────────────────────── +// ── Main video card (YouTube-style, stacked vertically) ─────────────────────── -function VideoGridCard({ event }: { event: NostrEvent }) { +function VideoCard({ event }: { event: NostrEvent }) { const { thumbnail, duration, blurhash } = parseVideoImeta(event.tags); const title = getTag(event.tags, 'title') ?? (event.content.slice(0, 120) || 'Untitled'); const dur = fmtDuration(duration); + const isShort = event.kind === 22; const [imgLoaded, setImgLoaded] = useState(false); const noteId = nip19.noteEncode(event.id); @@ -162,17 +157,31 @@ function VideoGridCard({ event }: { event: NostrEvent }) { tabIndex={0} onKeyDown={(e) => e.key === 'Enter' && onClick()} > -
+ {/* Thumbnail */} +
{blurhash && ( - + style={{ width: '100%', height: '100%' }} + /> )} {thumbnail ? ( {title} setImgLoaded(true)} /> @@ -181,36 +190,106 @@ function VideoGridCard({ event }: { event: NostrEvent }) {
)} -
+ + {/* Play overlay on hover */} +
+ + {/* Duration badge */} {dur && ( -
{dur}
+
+ {dur} +
+ )} + + {/* Short badge */} + {isShort && ( +
+ Short +
)}
-
- -

{title}

-

{timeAgo(event.created_at)}

+ + {/* Info row */} +
+ {/* Author avatar (large, left column like YouTube) */} + + + {/* Title + meta */} +
+

+ {title} +

+ +

+ + {timeAgo(event.created_at)} +

+
); } -function VideoSkeleton() { +/** Just the avatar circle for the YouTube-style left column. */ +function AuthorAvatarOnly({ pubkey }: { pubkey: string }) { + const author = useAuthor(pubkey); + const metadata = author.data?.metadata; + const displayName = getDisplayName(metadata, pubkey); + const profileUrl = useProfileUrl(pubkey, metadata); + + if (author.isLoading) return ; + + return ( + e.stopPropagation()}> + + + {displayName[0]?.toUpperCase()} + + + ); +} + +/** Just the author name line. */ +function AuthorNameOnly({ pubkey }: { pubkey: string }) { + const author = useAuthor(pubkey); + const metadata = author.data?.metadata; + const displayName = getDisplayName(metadata, pubkey); + const profileUrl = useProfileUrl(pubkey, metadata); + + if (author.isLoading) return ; + + return ( + e.stopPropagation()} + > + {displayName} + + ); +} + +function VideoCardSkeleton() { return (
-
- - +
+ +
+ + + +
+
); } -// ── Live streams — targeted query: status=live only, limit 10 ───────────────── +// ── Live streams — horizontal compact shelf ─────────────────────────────────── function useLiveStreams(tab: FeedTab) { const { nostr } = useNostr(); @@ -235,7 +314,7 @@ function useLiveStreams(tab: FeedTab) { }); } -function LiveStreamCard({ event }: { event: NostrEvent }) { +function LiveStreamChip({ event }: { event: NostrEvent }) { const title = getTag(event.tags, 'title') || 'Untitled Stream'; const imageUrl = getTag(event.tags, 'image'); const viewers = getTag(event.tags, 'current_participants'); @@ -249,48 +328,38 @@ function LiveStreamCard({ event }: { event: NostrEvent }) { const author = useAuthor(event.pubkey); const meta = author.data?.metadata; const displayName = getDisplayName(meta, event.pubkey); - const profileUrl = useProfileUrl(event.pubkey, meta); return (
e.key === 'Enter' && onClick()} > -
+
{imageUrl ? ( {title} ) : (
- +
)} -
- - LIVE - +
+ + + LIVE +
{viewers && ( -
- {viewers} +
+ {viewers}
)}
-
- e.stopPropagation()} className="shrink-0 mt-0.5"> - - - {displayName[0]?.toUpperCase()} - - -
-

{title}

-

{displayName}

-
-
+

{title}

+

{displayName}

); } @@ -300,59 +369,20 @@ function LiveStreamsStrip({ tab }: { tab: FeedTab }) { if (liveEvents.length === 0) return null; return ( -
-

+
+
- Live Now - {liveEvents.length} -

-
- {liveEvents.map((e) => )} +

Live now

+ {liveEvents.length} stream{liveEvents.length !== 1 ? 's' : ''} +
+
+ {liveEvents.map((e) => )}
); } -// ── Shorts grid thumbnail ───────────────────────────────────────────────────── - -function ShortThumb({ event, onClick }: { event: NostrEvent; onClick: () => void }) { - const { thumbnail, blurhash } = parseVideoImeta(event.tags); - const title = getTag(event.tags, 'title') ?? (event.content.slice(0, 60) || 'Short'); - const [imgLoaded, setImgLoaded] = useState(false); - - return ( - - ); -} - -// ── Shorts full-screen player (VineCard) with back-to-grid button ───────────── +// ── Shorts player (full-screen snap, reuses VineCard) ───────────────────────── function ShortsPlayer({ events, @@ -366,7 +396,6 @@ function ShortsPlayer({ const [activeIndex, setActiveIndex] = useState(startIndex); const containerRef = useRef(null); - // Scroll to startIndex on mount useEffect(() => { const container = containerRef.current; if (!container) return; @@ -374,7 +403,6 @@ function ShortsPlayer({ // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - // IntersectionObserver syncs activeIndex as user scrolls useEffect(() => { const container = containerRef.current; if (!container) return; @@ -393,7 +421,6 @@ function ShortsPlayer({ return () => observer.disconnect(); }, [events]); - // Keyboard nav + Escape to go back useEffect(() => { const handler = (e: KeyboardEvent) => { const container = containerRef.current; @@ -415,10 +442,8 @@ function ShortsPlayer({ return () => window.removeEventListener('keydown', handler); }, [onClose, activeIndex, events.length]); - // Same structure as VinesFeedPage: tab bar + snap container, filling the feed column return (
- {/* Tab bar — same chrome as VinesTabBar, back button replaces tabs */}
- - {/* Snap-scroll VineCard column — identical sizing to VinesFeedPage */}
void }) { - if (events.length === 0) return null; - - return ( -
-

- Shorts -

-
- {events.map((e, i) => ( -
- onOpen(i)} /> -
- ))} -
-
- ); -} - // ── Page ────────────────────────────────────────────────────────────────────── export function VideosFeedPage() { @@ -491,17 +490,15 @@ export function VideosFeedPage() { useEffect(() => { if (user) setFeedTab('follows'); }, [user]); - // ── Follows: chronological, small page ── + // ── Follows: chronological ── const followsQuery = useFeed('follows', { kinds: [21, 22] }); - // ── Global: sort:hot, limit 8/page ── + // ── Global: sort:hot ── const globalQuery = useInfiniteHotFeed([21, 22], feedTab === 'global', VIDEO_PAGE_SIZE); const activeQuery = feedTab === 'follows' ? followsQuery : globalQuery; const { data: rawData, isPending, isLoading } = activeQuery; - - const videoEvents = useMemo(() => { if (!rawData?.pages) return []; const seen = new Set(); @@ -519,15 +516,13 @@ export function VideosFeedPage() { }); }, [rawData?.pages, muteItems, feedTab]); - const normalVideos = useMemo(() => videoEvents.filter((e) => e.kind === 21), [videoEvents]); + // Shorts (kind 22) get their own player when tapped const shorts = useMemo(() => videoEvents.filter((e) => e.kind === 22), [videoEvents]); - const [shortsPlayerIndex, setShortsPlayerIndex] = useState(null); const showSkeleton = isPending || (isLoading && !rawData); - // When the shorts player is open, render it directly as the page root — - // same flex-1 column that VinesFeedPage uses, fully replacing the feed UI. + // Shorts full-screen player if (shortsPlayerIndex !== null) { return ( - {/* Header */} +
+ {/* ── Header ── */}
@@ -552,59 +547,126 @@ export function VideosFeedPage() {
- {/* Follows / Global tabs */} + {/* ── Tabs ── */}
setFeedTab('follows')} disabled={!user} /> setFeedTab('global')} />
- {/* Live streams strip — follows tab filters by followed authors */} + {/* ── Live strip ── */} + {/* ── Feed ── */} {showSkeleton ? ( -
-
- -
- {Array.from({ length: 4 }).map((_, i) => ( -
- ))} -
-
+
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))}
) : videoEvents.length === 0 ? (
- +

- No videos yet.{feedTab === 'follows' ? ' Follow some creators or switch to Global.' : ' Check your relay connections or come back soon.'} + {feedTab === 'follows' + ? 'No videos from people you follow yet. Try Global.' + : 'No videos found. Check your relay connections or come back soon.'}

+ {feedTab === 'follows' && ( + + )}
) : ( -
- {/* Normal videos — horizontal scroll row */} - {normalVideos.length > 0 && ( -
-

- Videos -

-
- {normalVideos.map((e) => ( -
- -
- ))} -
-
- )} - - {/* Shorts — horizontal scroll row of portrait thumbs, tap opens player */} - {shorts.length > 0 && } +
+ {videoEvents.map((event) => { + if (event.kind === 22) { + // Shorts get a portrait card that opens the TikTok-style player + const shortIndex = shorts.indexOf(event); + return ( +
setShortsPlayerIndex(shortIndex)} + role="button" + tabIndex={0} + onKeyDown={(e) => e.key === 'Enter' && setShortsPlayerIndex(shortIndex)} + > + + +
+ ); + } + return ; + })}
)} -
); } + +/** Portrait thumbnail (left column) for a short in the vertical feed. */ +function ShortCardLeft({ event }: { event: NostrEvent }) { + const { thumbnail, blurhash } = parseVideoImeta(event.tags); + const title = getTag(event.tags, 'title') ?? 'Short'; + const [imgLoaded, setImgLoaded] = useState(false); + + return ( +
+ {blurhash && ( + + )} + {thumbnail ? ( + {title} setImgLoaded(true)} + /> + ) : ( +
+ +
+ )} +
+
+ +
+
+
+ Short +
+
+ ); +} + +/** Text info (right column) for a short in the vertical feed. */ +function ShortCardRight({ event }: { event: NostrEvent }) { + const title = getTag(event.tags, 'title') ?? (event.content.slice(0, 80) || 'Short'); + + return ( +
+

+ {title} +

+ +

+ + {timeAgo(event.created_at)} +

+
+ ); +}