Files
TiktokUI/pages/index.tsx
hexin-codex-bot 2c7b81981c
Build and deploy Docker / deploy (push) Failing after 6m29s
fix: preserve continuous drawer and heart exit animations
2026-10-08 16:12:33 +08:00

160 lines
11 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
</>;
}