@@ -1,46 +1,158 @@
import Head from 'next/head' ;
import { useCallback , useEffect , useRef , useState } from 'react' ;
import { Heart , MessageCircle , Bookmark , Share2 , Plus , Play , Pause , Volume2 , VolumeX , ChevronUp , ChevronDown , Search , Home , Compass , Users , Music2 , Maximize , X , Check , MapPin , Send , ArrowUpRight , UserRound } from 'lucide-react' ;
import { videos , compact } from '../lib/videos' ;
type Reaction = { liked? :boolean ; saved? :boolean ; followed? :boolean } ;
type Comment = { name :string ; text :string } ;
const initialComments :Comment [ ] = [ { name : '小岛同学' , text : '这一刻,连时间都慢下来了。' } , { name : '今天也要开心' , text : '好喜欢这个氛围 ✨' } , { name : '路过人间' , text : '收藏了,留给忙碌的一天。' } ] ;
export default function Feed ( ) {
const [ index , setIndex ] = useState ( 0 ) , [ playing , setPlaying ] = useState ( true ) , [ muted , setMuted ] = useState ( true ) , [ progress , setProgress ] = useState ( 0 ) , [ duration , setDuration ] = useState ( 8 ) , [ reactions , setReactions ] = useState < Record < string , Reaction > > ( { } ) , [ ready , setReady ] = useState ( false ) , [ panel , setPanel ] = useState < 'comments' | 'search' | null > ( null ) , [ toast , setToast ] = useState ( '' ) , [ burst , setBurst ] = useState ( false ) , [ query , setQuery ] = useState ( '' ) , [ draft , setDraft ] = useState ( '' ) , [ comments , setComments ] = useState < Record < string , Comment [ ] > > ( { } ) , [ tab , setTab ] = useState ( '推荐' ) , [ error , setError ] = useState ( false ) ;
const video = useRef < HTMLVideoElement > ( null ) , stage = useRef < HTMLElement > ( null ) , touch = useRef ( { x :0 , y :0 } ) , lastScroll = useRef ( 0 ) , clickTimer = useRef < ReturnType < typeof setTimeout > | null > ( null ) , toastTimer = useRef < ReturnType < typeof setTimeout > | null > ( null ) , burstTimer = useRef < ReturnType < typeof setTimeout > | null > ( null ) ;
const item = videos [ index ] , reaction = reactions [ item . id ] || { } ;
const notify = useCallback ( ( text :string ) = > { setToast ( text ) ; if ( toastTimer . current ) clearTimeout ( toastTimer . current ) ; toastTimer . current = setTimeout ( ( ) = > setToast ( '' ) , 2400 ) ; } , [ ] ) ;
useEffect ( ( ) = > { try { const data = JSON . parse ( localStorage . getItem ( 'flow-reactions' ) || '{}' ) ; if ( data && typeof data === 'object' && ! Array . isArray ( data ) ) setReactions ( data ) ; } catch { } setReady ( true ) ; const id = new URLSearchParams ( location . search ) . get ( 'video' ) ; const found = videos . findIndex ( v = > v . id === id ) ; if ( found >= 0 ) setIndex ( found ) ; return ( ) = > { if ( clickTimer . current ) clearTimeout ( clickTimer . current ) ; if ( toastTimer . current ) clearTimeout ( toastTimer . current ) ; if ( burstTimer . current ) clearTimeout ( burstTimer . current ) ; } ; } , [ ] ) ;
useEffect ( ( ) = > { if ( ready ) try { localStorage . setItem ( 'flow-reactions' , JSON . stringify ( reactions ) ) ; } catch { } } , [ reactions , ready ] ) ;
const next = useCallback ( ( direction :number ) = > { setIndex ( i = > ( i + direction + videos . length ) % videos . length ) ; setProgress ( 0 ) ; setPlaying ( true ) ; setError ( false ) ; setBurst ( false ) ; setDraft ( '' ) ; if ( clickTimer . current ) clearTimeout ( clickTimer . current ) ; } , [ ] ) ;
useEffect ( ( ) = > { const el = video . current ; if ( ! el ) return ; if ( playing ) { el . play ( ) . catch ( ( ) = > setPlaying ( false ) ) ; } else el . pause ( ) ; } , [ index , playing ] ) ;
useEffect ( ( ) = > { function key ( e :KeyboardEvent ) { if ( ( e . target as HTMLElement ) . closest ( 'input,textarea,button' ) || panel ) return ; if ( e . code === 'ArrowDown' || e . code === 'ArrowUp' ) { e . preventDefault ( ) ; next ( e . code === 'ArrowDown' ? 1 : - 1 ) ; } if ( e . code === 'Space' ) { e . preventDefault ( ) ; setPlaying ( p = > ! p ) ; } if ( e . code === 'KeyM' ) setMuted ( m = > ! m ) ; } window . addEventListener ( 'keydown' , key ) ; return ( ) = > window . removeEventListener ( 'keydown' , key ) ; } , [ next , panel ] ) ;
useEffect ( ( ) = > { const el = stage . current ; if ( ! el ) return ; const wheel = ( e :WheelEvent ) = > { if ( panel || Math . abs ( e . deltaY ) < 12 | | e.ctrlKey ) return ; e.preventDefault ( ) ; if ( Date.now ( ) - lastScroll.current < 750 ) return ; lastScroll.current = Date.now ( ) ; next ( e.deltaY > 0 ? 1 : - 1 ) ; } ; el.addEventListener ( 'wheel' , wheel , { passive : false } ) ; return ( ) = > el . removeEventListener ( 'wheel' , wheel ) ; } , [ next , panel ] ) ;
useEffect ( ( ) = > { if ( ! panel ) return ; const before = document . activeElement as HTMLElement ; const dialog = document . querySelector < HTMLElement > ( '[role="dialog"]' ) ; dialog ? . querySelector < HTMLElement > ( 'button,input' ) ? . focus ( ) ; const key = ( e :KeyboardEvent ) = > { if ( e . key === 'Escape' ) setPanel ( null ) ; if ( e . key === 'Tab' ) { const list = dialog ? . querySelectorAll < HTMLElement > ( 'button,input,[tabindex="0"]' ) ; if ( ! list ? . length ) return ; const first = list [ 0 ] , last = list [ list . length - 1 ] ; if ( e . shiftKey && document . activeElement === first ) { e . preventDefault ( ) ; last . focus ( ) ; } else if ( ! e . shiftKey && document . activeElement === last ) { e . preventDefault ( ) ; first . focus ( ) ; } } } ; document . addEventListener ( 'keydown' , key ) ; return ( ) = > { document . removeEventListener ( 'keydown' , key ) ; before ? . focus ( ) ; } ; } , [ panel ] ) ;
function toggle ( field :keyof Reaction ) { setReactions ( r = > ( { . . . r , [ item . id ] : { . . . r [ item . id ] , [ field ] : ! r [ item . id ] ? . [ field ] } } ) ) ; }
function like ( ) { setReactions ( r = > ( { . . . r , [ item . id ] : { . . . r [ item . id ] , liked :true } } ) ) ; setBurst ( true ) ; if ( burstTimer . current ) clearTimeout ( burstTimer . current ) ; burstTimer . current = setTimeout ( ( ) = > setBurst ( false ) , 800 ) ; }
async function share ( ) { const url = ` ${ location . origin } /?video= ${ item . id } ` ; try { if ( navigator . share ) { await navigator . share ( { title :item.title , url } ) ; } else { await navigator . clipboard . writeText ( url ) ; notify ( '视频链接已复制' ) ; } } catch ( e ) { if ( ( e as Error ) . name !== 'AbortError' ) notify ( '请复制地址栏链接进行分享' ) ; } }
const time = ( s :number ) = > ` 00: ${ Math . floor ( s ) . toString ( ) . padStart ( 2 , '0' ) } ` ;
const results = videos . filter ( v = > ` ${ v . title } ${ v . author } ${ v . tags . join ( '' ) } ` . includes ( query . trim ( ) ) ) ;
return < > < Head > < title > 瞬 间 FLOW · 发 现 生 活 的 另 一 面 < / title > < meta name = "description" content = "沉浸式短视频体验,记录每一个值得停留的瞬间。" / > < meta name = "viewport" content = "width=device-width, initial-scale=1, viewport-fit=cover" / > < meta name = "theme-color" content = "#0b0b0e" / > < / Head >
< div className = "app-shell" >
< aside className = "sidebar" > < a className = "brand" href = "/" aria-label = "瞬间首页" > < span className = "brand-mark" > < Music2 size = { 28 } / > < / span > 瞬 间 < span className = "brand-en" > FLOW < / span > < / a > < button className = "search-box" onClick = { ( ) = > setPanel ( 'search' ) } > < Search size = { 19 } / > < span > 搜 索 感 兴 趣 的 内 容 < / span > < kbd > / < / kbd > < / button > < nav > { [ { name : '推荐' , icon :Home } , { name : '发现' , icon :Compass } , { name : '关注' , icon :Users } ] . map ( ( { name , icon :Icon } ) = > < button key = { name } className = { ` nav-item ${ tab === name ? 'active' : '' } ` } onClick = { ( ) = > { setTab ( name ) ; if ( name === '发现' ) setPanel ( 'search' ) ; if ( name === '关注' ) notify ( '关注作者后,关注标记会保存在此设备' ) ; } } > < Icon size = { 23 } / > { name } { name === '推荐' && < span className = "active-dot" / > } < / button > ) } < / nav > < div className = "sidebar-line" / > < p className = "sidebar-label" > 精 选 频 道 < / p > { [ '生活的浪漫' , '自然放映厅' , '城市漫游' ] . map ( ( label , i ) = > < button key = { label } className = "channel" onClick = { ( ) = > { setIndex ( i ) ; setProgress ( 0 ) ; setPlaying ( true ) ; setError ( false ) ; } } > < span className = { ` channel-dot dot- ${ i } ` } / > { label } < ArrowUpRight size = { 14 } / > < / button > ) } < div className = "sidebar-bottom" > < div className = "mini-brand" > 每 一 刻 , 都 值 得 被 看 见 < span > MAKE EVERY MOMENT COUNT . < / span > < / div > < p > 关 于 瞬 间 · 使 用 指 南 · 联 系 我 们 < / p > < p > © 2026 瞬 间 FLOW < / p > < span className = "demo-pill" > < span / > 本 地 演 示 · 无 需 登 录 < / span > < / div > < / aside >
< main className = "main" > < header className = "desktop-header" > < div > < span className = "eyebrow" > FOR YOU < / span > < h1 > 发 现 生 活 的 另 一 面 < span > 精 选 推 荐 < / span > < / h1 > < / div > < span className = "header-note" > < span / > 偶 遇 一 个 好 瞬 间 < / span > < / header >
< section ref = { stage } className = { ` stage scene- ${ item . id } ` } aria-label = "短视频播放器" onTouchStart = { e = > { touch . current = { x :e.touches [ 0 ] . clientX , y :e.touches [ 0 ] . clientY } ; } } onTouchEnd = { e = > { if ( panel || ( e . target as HTMLElement ) . closest ( 'button:not(.video-hit),input' ) ) return ; const dy = e . changedTouches [ 0 ] . clientY - touch . current . y , dx = e . changedTouches [ 0 ] . clientX - touch . current . x ; if ( Math . abs ( dy ) > 55 && Math . abs ( dy ) > Math . abs ( dx ) ) { e . preventDefault ( ) ; next ( dy < 0 ? 1 : - 1 ) ; }}} >
< video key = { item . id } ref = { video } className = "video" src = { item . src } poster = { item . poster } loop playsInline muted = { muted } autoPlay preload = "auto" onTimeUpdate = { e = > setProgress ( e . currentTarget . currentTime ) } onLoadedMetadata = { e = > setDuration ( e . currentTarget . duration ) } onPlay = { ( ) = > setPlaying ( true ) } onPause = { ( ) = > setPlaying ( false ) } onError = { ( ) = > { setError ( true ) ; setPlaying ( false ) ; } } / >
< div className = "scrim" / > < button className = "video-hit" aria-label = { playing ? '暂停视频' : '播放视频' } onClick = { ( ) = > { if ( clickTimer . current ) clearTimeout ( clickTimer . current ) ; clickTimer . current = setTimeout ( ( ) = > setPlaying ( p = > ! p ) , 220 ) ; } } onDoubleClick = { ( ) = > { if ( clickTimer . current ) clearTimeout ( clickTimer . current ) ; like ( ) ; } } / >
< div className = "mobile-top" > < button aria-label = { muted ? '开启声音' : '静音' } onClick = { ( ) = > setMuted ( m = > ! m ) } > { muted ? < VolumeX size = { 23 } / > : < Volume2 size = { 23 } / > } < / button > < div > < button onClick = { ( ) = > notify ( '点击作者头像即可关注' ) } > 关 注 < / button > < button className = "selected" onClick = { ( ) = > setTab ( '推荐' ) } > 推 荐 < / button > < / div > < button aria-label = "搜索视频" onClick = { ( ) = > setPanel ( 'search' ) } > < Search size = { 24 } / > < / button > < / div >
< div className = "stage-top" > < span className = "curated" > < span / > 今 日 精 选 < / span > < span className = "location" > < MapPin size = { 14 } / > { item . location } < / span > < button aria-label = { muted ? '开启声音' : '静音' } className = "sound-top" onClick = { ( ) = > setMuted ( m = > ! m ) } > { muted ? < VolumeX size = { 19 } / > : < Volume2 size = { 19 } / > } < / button > < / div >
< div className = "scene-copy" aria-hidden = "true" > < span > { item . label } < / span > < strong > { item . subtitle } < / strong > < i > THE LITTLE THINGS . THE BIG FEELINGS . < / i > < / div >
{ ! playing && ! error && < span className = "paused" > < Play size = { 54 } fill = "currentColor" / > < / span > } { burst && < span className = "heart-burst" > < Heart size = { 110 } fill = "currentColor" / > < / span > } { error && < div className = "video-error" > 视 频 暂 时 无 法 加 载 < button onClick = { ( ) = > { setError ( false ) ; video . current ? . load ( ) ; setPlaying ( true ) ; } } > 重 新 加 载 < / button > < / div > }
< div className = "video-info" > < span className = "editor-label" > 瞬 间 精 选 < span > · < / span > 风 景 与 生 活 < / span > < h2 > @ { item . author } < span className = "verified" > < Check size = { 11 } / > < / span > < / h2 > < h3 > { item . title } < / h3 > < p > { item . description } < / p > < div className = "tags" > { item . tags . map ( t = > < button key = { t } onClick = { ( ) = > { setQuery ( t ) ; setPanel ( 'search' ) ; } } > # { t } < / button > ) } < / div > < div className = "music" > < Music2 size = { 15 } / > < span > { item . music } < / span > < span className = "music-bars" > ▂ ▅ ▃ ▆ ▂ < / span > < / div > < / div >
< div className = "actions" > < button className = "avatar" style = { { background :item.color } } aria-label = { reaction . followed ? '取消关注作者' : '关注作者' } aria-pressed = { ! ! reaction . followed } onClick = { ( ) = > toggle ( 'followed' ) } > { item . avatar } < span > { reaction . followed ? < Check size = { 13 } / > : < Plus size = { 13 } / > } < / span > < / button > < button className = { ` action ${ reaction . liked ? 'liked' : '' } ` } aria-label = "点赞" aria-pressed = { ! ! reaction . liked } onClick = { ( ) = > toggle ( 'liked' ) } > < Heart fill = "currentColor" / > < span > { compact ( item . likes + ( reaction.liked?1 :0 ) ) } < / span > < / button > < button className = "action" aria-label = "查看评论" onClick = { ( ) = > setPanel ( 'comments' ) } > < MessageCircle fill = "currentColor" / > < span > { item . comments + ( comments [ item . id ] ? . length || 0 ) } < / span > < / button > < button className = { ` action ${ reaction . saved ? 'saved' : '' } ` } aria-label = "收藏" aria-pressed = { ! ! reaction . saved } onClick = { ( ) = > toggle ( 'saved' ) } > < Bookmark fill = "currentColor" / > < span > { compact ( item . saves + ( reaction.saved?1 :0 ) ) } < / span > < / button > < button className = "action" aria-label = "分享视频" onClick = { share } > < Share2 fill = "currentColor" / > < span > 分 享 < / span > < / button > < span className = { ` record ${ playing ? 'spinning' : '' } ` } > < span style = { { background :item.color } } > < Music2 size = { 16 } / > < / span > < / span > < / div >
< div className = "player-controls" > < input className = "progress" type = "range" aria-label = "播放进度" min = "0" max = { duration || 8 } step = "0.05" value = { progress } style = { { '--progress' : ` ${ progress / ( duration || 8 ) * 100 } % ` } as React . CSSProperties } onChange = { e = > { const value = Number ( e . target . value ) ; if ( video . current ) video . current . currentTime = value ; setProgress ( value ) ; } } / > < div className = "control-row" > < button aria-label = { playing ? '暂停' : '播放' } onClick = { ( ) = > setPlaying ( p = > ! p ) } > { playing ? < Pause size = { 18 } fill = "currentColor" / > : < Play size = { 18 } fill = "currentColor" / > } < / button > < span className = "time" > { time ( progress ) } < span > / { time ( duration ) } < / span > < / span > < span className = "control-hint" > 滚 动 发 现 更 多 精 彩 < / span > < button className = "quality" onClick = { ( ) = > notify ( '当前为本地生成的高清演示视频' ) } > 高 清 < span > HD < / span > < / button > < button aria-label = { muted ? '开启声音' : '静音' } onClick = { ( ) = > setMuted ( m = > ! m ) } > { muted ? < VolumeX size = { 20 } / > : < Volume2 size = { 20 } / > } < / button > < button aria-label = "全屏" onClick = { ( ) = > { if ( document . fullscreenElement ) document . exitFullscreen ( ) ; else stage . current ? . requestFullscreen ? . ( ) . catch ( ( ) = > notify ( '此浏览器暂不支持全屏' ) ) ; } } > < Maximize size = { 18 } / > < / button > < / div > < / div >
< / section > < div className = "desktop-footer" > < span > < span className = "key" > ↑ < / span > < span className = "key" > ↓ < / span > 切 换 视 频 < span className = "footer-divider" / > < span className = "key space" > Space < / span > 暂 停 / 播 放 < / span > < span > < span className = "current-number" > 0 { index + 1 } < / span > / 0 { videos . length } < span className = "footer-divider" / > 好 风 景 , 循 环 看 < / span > < / div >
< div className = "next-controls" > < button aria-label = "上一个视频" onClick = { ( ) = > next ( - 1 ) } > < ChevronUp / > < / button > < span / > < button aria-label = "下一个视频" onClick = { ( ) = > next ( 1 ) } > < ChevronDown / > < / button > < / div >
< / main > < nav className = "mobile-bottom" > < button className = "selected" onClick = { ( ) = > { setTab ( '推荐' ) ; setPanel ( null ) ; } } > < Home fill = "currentColor" / > 首 页 < / button > < button onClick = { ( ) = > setPanel ( 'search' ) } > < Compass / > 发 现 < / button > < button className = "create" aria-label = "发布提示" onClick = { ( ) = > notify ( '当前为浏览演示,暂未开放发布' ) } > < Plus / > < / button > < button onClick = { ( ) = > setPanel ( 'comments' ) } > < MessageCircle / > 消 息 < / button > < button onClick = { ( ) = > notify ( '无需登录,点赞与收藏保存在此设备' ) } > < UserRound / > 我 < / button > < / nav >
< / div >
{ panel && < div className = "modal-backdrop" onClick = { ( ) = > setPanel ( null ) } > < section className = "panel" role = "dialog" aria-modal = "true" aria-labelledby = "panel-title" onClick = { e = > e . stopPropagation ( ) } > < header > < h2 id = "panel-title" > { panel === 'comments' ? ` 评论 · ${ item . comments + ( comments [ item . id ] ? . length || 0 ) } ` : '发现好瞬间' } < / h2 > < button aria-label = "关闭" onClick = { ( ) = > setPanel ( null ) } > < X / > < / button > < / header > { panel === 'comments' ? < > < p className = "panel-note" > 演 示 评 论 · 新 评 论 仅 在 本 次 浏 览 中 保 留 < / p > < div className = "comments" > { [ . . . initialComments , . . . comments [ item . id ] || [ ] ] . map ( ( c , i ) = > < article key = { i } > < span className = "comment-avatar" > { c . name [ 0 ] } < / span > < div > < strong > { c . name } < / strong > < p > { c . text } < / p > < small > 刚 刚 · 回 复 < / small > < / div > < Heart size = { 15 } / > < / article > ) } < / div > < form onSubmit = { e = > { e . preventDefault ( ) ; if ( ! draft . trim ( ) ) return ; setComments ( c = > ( { . . . c , [ item . id ] : [ . . . c [ item . id ] || [ ] , { name : '访客' , text :draft.trim ( ) } ] } ) ) ; setDraft ( '' ) ; } } > < input aria-label = "评论内容" placeholder = "留下你的心情…" value = { draft } maxLength = { 200 } onChange = { e = > setDraft ( e . target . value ) } / > < button aria-label = "发送评论" disabled = { ! draft . trim ( ) } > < Send size = { 20 } / > < / button > < / form > < / > : < > < div className = "search-input" > < Search size = { 20 } / > < input aria-label = "搜索内容" placeholder = "搜索标题、作者或标签" value = { query } onChange = { e = > setQuery ( e . target . value ) } / > < / div > < div className = "search-results" > { results . map ( v = > < button key = { v . id } onClick = { ( ) = > { setIndex ( videos . findIndex ( x = > x . id === v . id ) ) ; setPlaying ( true ) ; setProgress ( 0 ) ; setError ( false ) ; setPanel ( null ) ; } } > < img src = { v . poster } alt = "" / > < span > < strong > { v . title } < / strong > < small > @ { v . author } · { v . tags [ 0 ] } < / small > < / span > < Play size = { 18 } / > < / button > ) } { results . length === 0 && < p > 还 没 有 找 到 , 试 试 “ 日 落 ” 或 “ 海 ” 。 < / p > } < / div > < / > } < / section > < / div > }
< div className = { ` toast ${ toast ? 'visible' : '' } ` } role = "status" > { toast } < / div >
< / > ;
import { ChevronUp , ChevronDown , Search , Home , Compass , Music2 , X , Send , UserRound , Plus , MessageCircle , Heart , Play , VolumeX , Volume2 } from 'lucide-react' ;
import { videos } from '../lib/videos' ;
import { SwipeFeed , type SwipeFeedHandle } from '../components/SwipeFeed' ;
import { VideoCard , type Reaction } from '../components/VideoCard' ;
import { pagePosition } from '../lib/gestures' ;
type Comment = { name : string ; text : string } ;
const initialComments : Comment [ ] = [
{ name : '小岛同学' , text : '这一刻,连时间都慢下来了。' } ,
{ name : '今天也要开心' , text : '好喜欢这个氛围 ✨' } ,
{ name : '路过人间' , text : '收藏了,留给忙碌的一天。' } ,
] ;
export default function Feed() {
const [ index , setIndex ] = useState ( 0 ) ;
const [ playing , setPlaying ] = useState ( true ) ;
const [ muted , setMuted ] = useState ( true ) ;
const [ reactions , setReactions ] = useState < Record < string , Reaction > > ( { } ) ;
const [ ready , setReady ] = useState ( false ) ;
const [ panel , setPanel ] = useState < 'comments' | 'search' | 'share' | null > ( null ) ;
const [ closing , setClosing ] = useState ( false ) ;
const [ toast , setToast ] = useState ( '' ) ;
const [ query , setQuery ] = useState ( '' ) ;
const [ draft , setDraft ] = useState ( '' ) ;
const [ comments , setComments ] = useState < Record < string , Comment [ ] > > ( { } ) ;
const [ shareUrl , setShareUrl ] = useState ( '' ) ;
const feed = useRef < SwipeFeedHandle > ( null ) ;
const toastTimer = useRef < ReturnType < typeof setTimeout > | null > ( null ) ;
const closeTimer = useRef < ReturnType < typeof setTimeout > | null > ( null ) ;
const drawer = useRef < HTMLElement > ( null ) ;
const sheetGesture = useRef < { startY : number ; dy : number } | null > ( null ) ;
const item = videos [ index ] ;
const notify = useCallback ( ( text : string ) = > {
setToast ( text ) ;
if ( toastTimer . current ) clearTimeout ( toastTimer . current ) ;
toastTimer . current = setTimeout ( ( ) = > setToast ( '' ) , 2400 ) ;
} , [ ] ) ;
const closePanel = useCallback ( ( ) = > {
setClosing ( true ) ;
if ( closeTimer . current ) clearTimeout ( closeTimer . current ) ;
closeTimer . current = setTimeout ( ( ) = > { setPanel ( null ) ; setClosing ( false ) ; } , 240 ) ;
} , [ ] ) ;
useEffect ( ( ) = > {
try {
const data = JSON . parse ( localStorage . getItem ( 'flow-reactions' ) || '{}' ) ;
if ( data && typeof data === 'object' && ! Array . isArray ( data ) ) setReactions ( data ) ;
} catch { }
setReady ( true ) ;
const id = new URLSearchParams ( location . search ) . get ( 'video' ) ;
const found = videos . findIndex ( video = > video . id === id ) ;
if ( found >= 0 ) setIndex ( found ) ;
return ( ) = > {
if ( toastTimer . current ) clearTimeout ( toastTimer . current ) ;
if ( closeTimer . current ) clearTimeout ( closeTimer . current ) ;
} ;
} , [ ] ) ;
useEffect ( ( ) = > {
if ( ready ) try { localStorage . setItem ( 'flow-reactions' , JSON . stringify ( reactions ) ) ; } catch { }
} , [ reactions , ready ] ) ;
const change = useCallback ( ( direction : number ) = > {
setIndex ( value = > ( value + direction + videos . length ) % videos . length ) ;
setPlaying ( true ) ;
setDraft ( '' ) ;
} , [ ] ) ;
useEffect ( ( ) = > {
const key = ( event : KeyboardEvent ) = > {
if ( ( event . target as HTMLElement ) . closest ( 'input,textarea' ) || panel ) return ;
if ( event . code === 'ArrowDown' || event . code === 'ArrowUp' ) {
event . preventDefault ( ) ; feed . current ? . next ( event . code === 'ArrowDown' ? 1 : - 1 ) ;
}
if ( event . code === 'Space' && ! ( event . target as HTMLElement ) . closest ( 'button' ) ) { event . preventDefault ( ) ; setPlaying ( value = > ! value ) ; }
if ( event . code === 'KeyM' ) setMuted ( value = > ! value ) ;
if ( event . code === 'Slash' ) { event . preventDefault ( ) ; setPanel ( 'search' ) ; }
} ;
window . addEventListener ( 'keydown' , key ) ;
return ( ) = > window . removeEventListener ( 'keydown' , key ) ;
} , [ panel ] ) ;
useEffect ( ( ) = > {
if ( ! panel ) return ;
const before = document . activeElement as HTMLElement ;
const element = drawer . current ;
element ? . querySelector < HTMLElement > ( 'button,input' ) ? . focus ( ) ;
const key = ( event : KeyboardEvent ) = > {
if ( event . key === 'Escape' ) closePanel ( ) ;
if ( event . key !== 'Tab' ) return ;
const elements = element ? . querySelectorAll < HTMLElement > ( 'button:not(:disabled),input,[tabindex="0"]' ) ;
if ( ! elements ? . length ) return ;
const first = elements [ 0 ] , last = elements [ elements . length - 1 ] ;
if ( event . shiftKey && document . activeElement === first ) { event . preventDefault ( ) ; last . focus ( ) ; }
else if ( ! event . shiftKey && document . activeElement === last ) { event . preventDefault ( ) ; first . focus ( ) ; }
} ;
document . addEventListener ( 'keydown' , key ) ;
return ( ) = > { document . removeEventListener ( 'keydown' , key ) ; before ? . focus ( ) ; } ;
} , [ panel , closePanel ] ) ;
function react ( id : string , field : keyof Reaction , force? : boolean ) {
setReactions ( previous = > ( { . . . previous , [ id ] : { . . . previous [ id ] , [ field ] : force ? ? ! previous [ id ] ? . [ field ] } } ) ) ;
}
async function share() {
const url = ` ${ location . origin } /?video= ${ item . id } ` ;
setShareUrl ( url ) ;
try {
if ( navigator . share ) await navigator . share ( { title : item.title , url } ) ;
else if ( navigator . clipboard ) { await navigator . clipboard . writeText ( url ) ; notify ( '视频链接已复制' ) ; }
else setPanel ( 'share' ) ;
} catch ( error ) { if ( ( error as Error ) . name !== 'AbortError' ) setPanel ( 'share' ) ; }
}
const results = videos . filter ( video = > ` ${ video . title } ${ video . author } ${ video . tags . join ( '' ) } ` . includes ( query . trim ( ) ) ) ;
return < >
< Head > < title > 瞬 间 FLOW · 发 现 生 活 的 另 一 面 < / title > < meta name = "description" content = "沉浸式短视频体验,记录每一个值得停留的瞬间。" / > < meta name = "viewport" content = "width=device-width, initial-scale=1, viewport-fit=cover" / > < meta name = "theme-color" content = "#08080b" / > < / Head >
< div className = "app-shell" inert = { ! ! panel } >
< main className = "main" >
< SwipeFeed ref = { feed } onChange = { change } disabled = { ! ! panel } >
{ videos . map ( ( video , videoIndex ) = > {
return < VideoCard key = { video . id } item = { video } position = { pagePosition ( videoIndex , index ) } active = { videoIndex === index }
playing = { playing } muted = { muted } reaction = { reactions [ video . id ] || { } } commentCount = { comments [ video . id ] ? . length || 0 }
setPlaying = { setPlaying } toggleMuted = { ( ) = > setMuted ( value = > ! value ) } onReact = { ( field , force ) = > react ( video . id , field , force ) }
onComment = { ( ) = > setPanel ( 'comments' ) } onShare = { share } onTag = { tag = > { setQuery ( tag ) ; setPanel ( 'search' ) ; } } / > ;
} ) }
< / SwipeFeed >
< header className = "feed-header" >
< a className = "brand" href = "/" aria-label = "瞬间首页" > < Music2 / > < strong > 瞬 间 < / strong > < span > FLOW < / span > < / a >
< button className = "mobile-sound" aria-label = { muted ? '开启声音' : '静音' } onClick = { ( ) = > setMuted ( value = > ! value ) } > { muted ? < VolumeX / > : < Volume2 / > } < / button >
< nav className = "feed-tabs" > < button onClick = { ( ) = > notify ( '点击作者头像即可关注' ) } > 关 注 < / button > < button className = "selected" > 推 荐 < / button > < / nav >
< button className = "search-trigger" aria-label = "搜索视频" onClick = { ( ) = > setPanel ( 'search' ) } > < Search / > < span > 搜 索 你 感 兴 趣 的 内 容 < / span > < kbd > / < / kbd > < / button >
< / header >
< div className = "next-controls" > < button aria-label = "上一个视频" onClick = { ( ) = > feed . current ? . next ( - 1 ) } > < ChevronUp / > < / button > < span / > < button aria-label = "下一个视频" onClick = { ( ) = > feed . current ? . next ( 1 ) } > < ChevronDown / > < / button > < / div >
< div className = "feed-position" aria-hidden = "true" > { videos . map ( ( video , i ) = > < i key = { video . id } className = { i === index ? 'active' : '' } / > ) } < / div >
< / main >
< nav className = "mobile-bottom" > < button className = "selected" onClick = { ( ) = > notify ( '正在浏览推荐视频' ) } > < Home fill = "currentColor" / > 首 页 < / button > < button onClick = { ( ) = > setPanel ( 'search' ) } > < Compass / > 发 现 < / button > < button className = "create" aria-label = "发布提示" onClick = { ( ) = > notify ( '当前为浏览演示,暂未开放发布' ) } > < Plus / > < / button > < button onClick = { ( ) = > setPanel ( 'comments' ) } > < MessageCircle / > 消 息 < / button > < button onClick = { ( ) = > notify ( '无需登录,点赞与收藏保存在此设备' ) } > < UserRound / > 我 < / button > < / nav >
< / div >
{ panel && < div className = { ` modal-backdrop ${ closing ? 'closing' : '' } ` } onClick = { closePanel } >
< section ref = { drawer } className = "panel" role = "dialog" aria-modal = "true" aria-labelledby = "panel-title" onClick = { event = > event . stopPropagation ( ) } >
< div className = "sheet-grip" onPointerDown = { event = > { sheetGesture . current = { startY : event.clientY , dy : 0 } ; event . currentTarget . setPointerCapture ( event . pointerId ) ; } }
onPointerMove = { event = > { if ( ! sheetGesture . current || ! drawer . current ) return ; const dy = Math . max ( 0 , event . clientY - sheetGesture . current . startY ) ; sheetGesture . current . dy = dy ; drawer . current . style . transform = ` translateY( ${ dy } px) ` ; } }
onPointerUp = { ( ) = > { if ( ! sheetGesture . current || ! drawer . current ) return ; if ( sheetGesture . current . dy > 90 ) closePanel ( ) ; drawer . current . style . transform = '' ; sheetGesture . current = null ; } } > < span / > < / div >
< header > < h2 id = "panel-title" > { panel === 'comments' ? ` 评论 · ${ item . comments + ( comments [ item . id ] ? . length || 0 ) } ` : panel === 'search' ? '发现好瞬间' : '分享这一刻' } < / h2 > < button aria-label = "关闭" onClick = { closePanel } > < X / > < / button > < / header >
{ panel === 'comments' ? < >
< p className = "panel-note" > 演 示 评 论 · 新 评 论 仅 在 本 次 浏 览 中 保 留 < / p >
< div className = "comments" > { [ . . . initialComments , . . . comments [ item . id ] || [ ] ] . map ( ( comment , i ) = > < article key = { ` ${ i } - ${ comment . text } ` } > < span className = "comment-avatar" > { comment . name [ 0 ] } < / span > < div > < strong > { comment . name } < / strong > < p > { comment . text } < / p > < small > 刚 刚 · 回 复 < / small > < / div > < Heart size = { 15 } / > < / article > ) } < / div >
< form onSubmit = { event = > { event . preventDefault ( ) ; if ( ! draft . trim ( ) ) return ; setComments ( previous = > ( { . . . previous , [ item . id ] : [ . . . previous [ item . id ] || [ ] , { name : '访客' , text : draft.trim ( ) } ] } ) ) ; setDraft ( '' ) ; } } > < input aria-label = "评论内容" placeholder = "留下你的心情…" value = { draft } maxLength = { 200 } onChange = { event = > setDraft ( event . target . value ) } / > < button aria-label = "发送评论" disabled = { ! draft . trim ( ) } > < Send size = { 20 } / > < / button > < / form >
< / > : panel === 'search' ? < >
< div className = "search-input" > < Search size = { 20 } / > < input aria-label = "搜索内容" placeholder = "搜索标题、作者或标签" value = { query } onChange = { event = > setQuery ( event . target . value ) } / > < / div >
< div className = "search-results" > { results . map ( video = > < button key = { video . id } onClick = { ( ) = > { setIndex ( videos . findIndex ( v = > v . id === video . id ) ) ; setPlaying ( true ) ; closePanel ( ) ; } } > < img src = { video . poster } alt = "" / > < span > < strong > { video . title } < / strong > < small > @ { video . author } · { video . tags [ 0 ] } < / small > < / span > < Play size = { 18 } / > < / button > ) } { ! results . length && < p > 还 没 有 找 到 , 试 试 “ 日 落 ” 或 “ 海 ” 。 < / p > } < / div >
< / > : < > < p className = "panel-note" > 复 制 下 面 的 链 接 , 即 可 分 享 当 前 视 频 。 < / p > < input className = "share-link" aria-label = "视频链接" readOnly value = { shareUrl } onFocus = { event = > event . target . select ( ) } / > < / > }
< / section >
< / div > }
< div className = { ` toast ${ toast ? 'visible' : '' } ` } role = "status" > { toast } < / div >
< / > ;
}