refactor: 企业级目录重构与播放/关于页体验升级
- 按 app / shared / entities / features 分层,路由懒加载并抽出布局组件 - 请求层改为可插拔拦截器,统一错误类型、超时取消与端点拆分 - 抽取播放器、骨架屏、网格等复用组件,点赞收藏接入统一交互反馈 - 关于页重做为无边框极光风格,补充 iOS / Google Play 下载入口 - 修复播放页滚轮切集吞事件、页面误滚动、后台持续播放与声音记忆问题 - 语言按本地偏好与 IP 识别,国旗改为内联 SVG - 清理未使用的旧站点文件,补充 ESLint 配置
This commit is contained in:
1 parent
8dd5283892
commit
ecfbd3b8b5
90 files changed
+4535
-886
No files matched your search
+21
-121
@@ -1,133 +1,33 @@
|
||||
// Author: fruit.com.cn
|
||||
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';
|
||||
import { Suspense, useState } from 'react';
|
||||
import { Outlet, useLocation } from 'react-router-dom';
|
||||
import { useLocale, useUi } from '../app/providers/AppProviders';
|
||||
import { BottomNav } from './components/BottomNav';
|
||||
import { Sidebar } from './components/Sidebar';
|
||||
import { TopBar } from './components/TopBar';
|
||||
import { UploadVideoDialog } from './components/UploadVideoDialog';
|
||||
|
||||
export function AppShell() {
|
||||
const { locale, query, setQuery, sidebarOpen, setSidebarOpen } = useAppStore();
|
||||
const navigate = useNavigate();
|
||||
const { locale } = useLocale();
|
||||
const { sidebarOpen, setSidebarOpen } = useUi();
|
||||
const location = useLocation();
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
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';
|
||||
// 移动端为抽屉式侧边栏,点击任一菜单项后必须收起
|
||||
const closeSidebarOnNavigate = (event) => {
|
||||
if (!event.target.closest?.('a')) return;
|
||||
if (window.matchMedia('(max-width: 760px)').matches) setSidebarOpen(false);
|
||||
};
|
||||
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();
|
||||
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">
|
||||
<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-expanded={searchOpen} aria-label={locale.search} type="button"><Search size={22} /></button>
|
||||
<LanguageSwitcher />
|
||||
<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>
|
||||
|
||||
<aside className={`sidebar ${sidebarOpen ? 'open' : 'closed'}`} onClick={closeSidebarOnNavigate}>
|
||||
<nav className="sidebar-group">
|
||||
{primaryItems.map(([Icon, label, to]) => (
|
||||
<NavLink to={to} className={() => navClass(to)} key={to}>
|
||||
<Icon size={19} />
|
||||
<span className="sidebar-label">{label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
<div className="nav-rule" />
|
||||
<div className="sidebar-group">
|
||||
<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} className={() => navClass(to)} key={label}>
|
||||
<Icon size={19} />
|
||||
<span className="sidebar-label">{label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
<div className="nav-rule" />
|
||||
<div className="sidebar-group">
|
||||
{aboutItems.map(([Icon, label, to]) => (
|
||||
<NavLink to={to} className={() => navClass(to)} key={label}>
|
||||
<Icon size={19} />
|
||||
<span className="sidebar-label">{label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<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} 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>}
|
||||
<TopBar onToggleSidebar={() => setSidebarOpen(!sidebarOpen)} onUpload={() => setUploadOpen(true)} />
|
||||
<Sidebar />
|
||||
<main className={`content ${sidebarOpen ? '' : 'content-wide'}`}>
|
||||
<Suspense fallback={<div className="route-loading" aria-busy="true" />}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
{!immersive && <footer className="site-footer">© {new Date().getFullYear()} {locale.copyright}</footer>}
|
||||
</main>
|
||||
<BottomNav />
|
||||
{sidebarOpen && (
|
||||
<button className="scrim mobile-only" onClick={() => setSidebarOpen(false)} aria-label="Close" type="button" />
|
||||
)}
|
||||
<UploadVideoDialog open={uploadOpen} onClose={() => setUploadOpen(false)} />
|
||||
</>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user