160 lines
11 KiB
TypeScript
160 lines
11 KiB
TypeScript
import Head from 'next/head';
|
||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||
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 }; if (drawer.current) drawer.current.style.transition = 'none'; 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; drawer.current.style.transition = ''; if (sheetGesture.current.dy > 90) closePanel(); else drawer.current.style.transform = ''; sheetGesture.current = null; }}
|
||
onPointerCancel={() => { if (drawer.current) { drawer.current.style.transition = ''; 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>
|
||
</>;
|
||
}
|