feat: complete YouTube-style site experience for YMB

- rebuild home feed with sticky server-driven categories and skeletons
- add full-screen shorts feed with likes, comments, saves and follows
- rebuild watch page with same-drama episode swiping and locked states
- wire search, watch history and episode stats to the app API
- add about page with app store links and an upload video dialog
- add SVG language flags, formatted view counts and site icons
This commit is contained in:
Zhou Weidong committed 2026-11-11 17:53:45 +08:00
1 parent fb8001cf46
commit eced2890ae
31 files changed
+1858 -195

No files matched your search

+77 -30
View File
@@ -1,41 +1,85 @@
// Author: fruit.com.cn
import { Bell, Bookmark, ChevronDown, ChevronRight, Compass, Film, History, Home, ListVideo, Menu, Mic, Music2, Plus, Radio, Search, ThumbsUp, UserRound, Video, X } from 'lucide-react';
import { useState } from 'react';
import { NavLink, Outlet, useNavigate } from 'react-router-dom';
import { Bell, Compass, History, Home, Info, ListVideo, Menu, Plus, Search, Upload, UserRound, X } from 'lucide-react';
import { useEffect, useState, useRef } from 'react';
import { NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom';
import { useAppStore } from '../app/store';
import { LanguageSwitcher } from '../components/LanguageSwitcher';
import { UploadVideoDialog } from '../components/UploadVideoDialog';
export function AppShell() {
const { locale, query, setQuery, sidebarOpen, setSidebarOpen } = useAppStore();
const navigate = useNavigate();
const location = useLocation();
const [searchOpen, setSearchOpen] = useState(false);
const primaryItems = [[Home, locale.home, '/'], [Compass, locale.shorts, '/shorts'], [UserRound, locale.subscriptions, '/subscriptions']];
const libraryItems = [[History, locale.history, '/history'], [ListVideo, locale.playlists, '/library'], [Bookmark, locale.watchLater, '/library'], [ThumbsUp, locale.likedVideos, '/library'], [Video, locale.myVideos, '/library']];
const exploreItems = [[Music2, locale.music, '/search?genre=music'], [Film, locale.movies, '/search?genre=movies'], [Radio, locale.live, '/shorts']];
const [createOpen, setCreateOpen] = useState(false);
const [uploadOpen, setUploadOpen] = useState(false);
const searchInputRef = useRef(null);
const createRef = useRef(null);
const primaryItems = [[Home, locale.home, '/'], [Compass, locale.shorts, '/shorts'], [ListVideo, locale.subscriptions, '/subscriptions']];
const libraryItems = [[History, locale.history, '/history']];
const aboutItems = [[Info, locale.about, '/about']];
const immersive = location.pathname === '/shorts';
useEffect(() => {
if (!createOpen) return undefined;
const closeMenu = (event) => {
if (!createRef.current?.contains(event.target)) setCreateOpen(false);
};
document.addEventListener('mousedown', closeMenu);
return () => document.removeEventListener('mousedown', closeMenu);
}, [createOpen]);
const submitSearch = (event) => {
event.preventDefault();
navigate(`/search?q=${encodeURIComponent(query)}`);
setSearchOpen(false);
const keyword = query.trim();
navigate(keyword ? `/search?q=${encodeURIComponent(keyword)}` : '/search');
};
const navClass = (to) => {
const [path, search = ''] = to.split('?');
if (location.pathname !== path) return '';
const current = new URLSearchParams(location.search);
return [...new URLSearchParams(search)].every(([key, value]) => current.get(key) === value) ? 'active' : '';
};
return (
<>
<header className="topbar">
<button className="icon-button topbar-menu" onClick={() => setSidebarOpen(!sidebarOpen)} aria-label="Menu" type="button">
<Menu size={22} />
</button>
<NavLink className="brand" to="/" aria-label="YMB">
<img src="/brand/ymb-logo-blue.png" alt="YMB" />
</NavLink>
<form className={`search-form ${searchOpen ? 'open' : ''}`} onSubmit={submitSearch}>
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder={locale.search} aria-label={locale.search} />
<button aria-label="Search" type="submit"><Search size={19} /></button>
</form>
<button className="voice-search icon-button" aria-label={locale.voiceSearch} type="button"><Mic size={20} /></button>
<div className="topbar-left">
<button className="icon-button topbar-menu" onClick={() => setSidebarOpen(!sidebarOpen)} aria-label="Menu" type="button">
<Menu size={24} />
</button>
<NavLink className="brand" to="/" aria-label="YMB">
<img src="/brand/ymb-logo-blue.png" alt="YMB" />
</NavLink>
</div>
<div className="topbar-center">
<form className={`search-form ${searchOpen ? 'open' : ''}`} onSubmit={submitSearch}>
<span className="search-field">
<input ref={searchInputRef} value={query} onChange={(event) => setQuery(event.target.value)} placeholder={locale.search} aria-label={locale.search} />
{query && (
<button className="search-clear" type="button" aria-label={locale.clearSearch} onClick={() => { setQuery(''); searchInputRef.current?.focus(); }}>
<X size={18} />
</button>
)}
</span>
<button aria-label="Search" type="submit"><Search size={20} /></button>
</form>
</div>
<div className="top-actions">
<button className="icon-button mobile-search" onClick={() => setSearchOpen(!searchOpen)} aria-label={locale.search} type="button"><Search size={20} /></button>
<button className="icon-button mobile-search" onClick={() => setSearchOpen(!searchOpen)} aria-expanded={searchOpen} aria-label={locale.search} type="button"><Search size={22} /></button>
<LanguageSwitcher />
<button className="create-button" type="button"><Plus size={19} /><span>{locale.create}</span></button>
<button className="icon-button notification-button" aria-label={locale.notifications} type="button"><Bell size={19} /></button>
<div className="create-wrap" ref={createRef}>
<button className="create-button" type="button" aria-haspopup="menu" aria-expanded={createOpen} onClick={() => setCreateOpen((open) => !open)}>
<Plus size={20} /><span>{locale.create}</span>
</button>
{createOpen && (
<div className="create-menu" role="menu">
<button type="button" role="menuitem" onClick={() => { setCreateOpen(false); setUploadOpen(true); }}>
<Upload size={18} /><span>{locale.uploadVideo}</span>
</button>
</div>
)}
</div>
<button className="icon-button notification-button" aria-label={locale.notifications} type="button"><Bell size={20} /></button>
<span className="profile-chip" aria-label={locale.account}>N</span>
</div>
</header>
@@ -43,7 +87,7 @@ export function AppShell() {
<aside className={`sidebar ${sidebarOpen ? 'open' : 'closed'}`}>
<nav className="sidebar-group">
{primaryItems.map(([Icon, label, to]) => (
<NavLink to={to} end={to === '/'} key={to}>
<NavLink to={to} className={() => navClass(to)} key={to}>
<Icon size={19} />
<span className="sidebar-label">{label}</span>
</NavLink>
@@ -51,9 +95,12 @@ export function AppShell() {
</nav>
<div className="nav-rule" />
<div className="sidebar-group">
<div className="sidebar-group-title"><span>{locale.you}</span><ChevronRight size={16} /></div>
<NavLink to="/library" className={() => navClass('/library')}>
<UserRound size={19} />
<span className="sidebar-label">{locale.you}</span>
</NavLink>
{libraryItems.map(([Icon, label, to]) => (
<NavLink to={to} key={label}>
<NavLink to={to} className={() => navClass(to)} key={label}>
<Icon size={19} />
<span className="sidebar-label">{label}</span>
</NavLink>
@@ -61,22 +108,22 @@ export function AppShell() {
</div>
<div className="nav-rule" />
<div className="sidebar-group">
<div className="sidebar-group-title">{locale.explore}</div>
{exploreItems.map(([Icon, label, to]) => (
<NavLink to={to} key={label}>
{aboutItems.map(([Icon, label, to]) => (
<NavLink to={to} className={() => navClass(to)} key={label}>
<Icon size={19} />
<span className="sidebar-label">{label}</span>
</NavLink>
))}
<button className="genre-link" type="button"><ChevronDown size={19} /><span className="sidebar-label">{locale.more}</span></button>
</div>
</aside>
<main className={`content ${sidebarOpen ? '' : 'content-wide'}`}><Outlet /></main>
<main className={`content ${sidebarOpen ? '' : 'content-wide'}`}><Outlet />{!immersive && <footer className="site-footer">© {new Date().getFullYear()} {locale.copyright}</footer>}</main>
<nav className="bottom-nav">
{primaryItems.map(([Icon, label, to]) => <NavLink to={to} key={to}><Icon size={19} />{label}</NavLink>)}
{primaryItems.map(([Icon, label, to]) => <NavLink to={to} className={() => navClass(to)} key={to}><Icon size={24} /><span>{label}</span></NavLink>)}
<NavLink to="/library" className={() => navClass('/library')}><UserRound size={24} /><span>{locale.you}</span></NavLink>
</nav>
{sidebarOpen && <button className="scrim mobile-only" onClick={() => setSidebarOpen(false)} aria-label="Close" type="button"><X /></button>}
<UploadVideoDialog open={uploadOpen} onClose={() => setUploadOpen(false)} />
</>
);
}