Files
MoonTVPlus/src/components/Sidebar.tsx

333 lines
12 KiB
TypeScript
Raw Normal View History

2025-08-12 21:50:58 +08:00
/* eslint-disable @typescript-eslint/no-explicit-any */
'use client';
2025-12-22 00:42:27 +08:00
import { Cat, Clover, Film, FolderOpen, Home, Menu, Radio, Search, Star, Tv, Users } from 'lucide-react';
2025-08-12 21:50:58 +08:00
import Link from 'next/link';
2025-11-30 21:33:05 +08:00
import { usePathname, useSearchParams } from 'next/navigation';
2025-08-12 21:50:58 +08:00
import {
createContext,
useCallback,
useContext,
useEffect,
useLayoutEffect,
useState,
} from 'react';
import { useSite } from './SiteProvider';
2025-12-08 10:03:25 +08:00
import { useWatchRoomContextSafe } from './WatchRoomProvider';
2025-08-12 21:50:58 +08:00
interface SidebarContextType {
isCollapsed: boolean;
}
const SidebarContext = createContext<SidebarContextType>({
isCollapsed: false,
});
export const useSidebar = () => useContext(SidebarContext);
// 可替换为你自己的 logo 图片
const Logo = () => {
const { siteName } = useSite();
return (
<Link
href='/'
className='flex items-center justify-center h-16 select-none hover:opacity-80 transition-opacity duration-200'
>
<span className='text-2xl font-bold text-green-600 tracking-tight'>
{siteName}
</span>
</Link>
);
};
interface SidebarProps {
onToggle?: (collapsed: boolean) => void;
activePath?: string;
}
// 在浏览器环境下通过全局变量缓存折叠状态,避免组件重新挂载时出现初始值闪烁
declare global {
interface Window {
__sidebarCollapsed?: boolean;
RUNTIME_CONFIG?: {
EnableComments: boolean;
};
2025-08-12 21:50:58 +08:00
}
}
const Sidebar = ({ onToggle, activePath = '/' }: SidebarProps) => {
const pathname = usePathname();
const searchParams = useSearchParams();
2025-12-08 10:03:25 +08:00
const watchRoomContext = useWatchRoomContextSafe();
2025-08-12 21:50:58 +08:00
// 若同一次 SPA 会话中已经读取过折叠状态,则直接复用,避免闪烁
const [isCollapsed, setIsCollapsed] = useState<boolean>(() => {
if (
typeof window !== 'undefined' &&
typeof window.__sidebarCollapsed === 'boolean'
) {
return window.__sidebarCollapsed;
}
return false; // 默认展开
});
// 首次挂载时读取 localStorage以便刷新后仍保持上次的折叠状态
useLayoutEffect(() => {
const saved = localStorage.getItem('sidebarCollapsed');
if (saved !== null) {
const val = JSON.parse(saved);
setIsCollapsed(val);
window.__sidebarCollapsed = val;
}
}, []);
// 当折叠状态变化时,同步到 <html> data 属性,供首屏 CSS 使用
useLayoutEffect(() => {
if (typeof document !== 'undefined') {
if (isCollapsed) {
document.documentElement.dataset.sidebarCollapsed = 'true';
} else {
delete document.documentElement.dataset.sidebarCollapsed;
}
}
}, [isCollapsed]);
const [active, setActive] = useState(activePath);
useEffect(() => {
2025-11-30 21:33:05 +08:00
// 立即根据当前路径更新状态,不等待页面加载
const getCurrentFullPath = () => {
const queryString = searchParams.toString();
return queryString ? `${pathname}?${queryString}` : pathname;
};
const fullPath = getCurrentFullPath();
setActive(fullPath);
}, [pathname, searchParams]);
2025-08-12 21:50:58 +08:00
const handleToggle = useCallback(() => {
const newState = !isCollapsed;
setIsCollapsed(newState);
localStorage.setItem('sidebarCollapsed', JSON.stringify(newState));
if (typeof window !== 'undefined') {
window.__sidebarCollapsed = newState;
}
onToggle?.(newState);
}, [isCollapsed, onToggle]);
const contextValue = {
isCollapsed,
};
const [menuItems, setMenuItems] = useState([
{
icon: Film,
label: '电影',
href: '/douban?type=movie',
},
{
icon: Tv,
label: '剧集',
href: '/douban?type=tv',
},
{
icon: Cat,
label: '动漫',
href: '/douban?type=anime',
},
{
icon: Clover,
label: '综艺',
href: '/douban?type=show',
},
2025-08-24 00:26:48 +08:00
{
icon: Radio,
label: '直播',
href: '/live',
},
2025-08-12 21:50:58 +08:00
]);
useEffect(() => {
2025-08-13 22:07:28 +08:00
const runtimeConfig = (window as any).RUNTIME_CONFIG;
2025-12-08 10:03:25 +08:00
// 基础菜单项(不包括观影室)
let items = [
{
icon: Film,
label: '电影',
href: '/douban?type=movie',
},
{
icon: Tv,
label: '剧集',
href: '/douban?type=tv',
},
{
icon: Cat,
label: '动漫',
href: '/douban?type=anime',
},
{
icon: Clover,
label: '综艺',
href: '/douban?type=show',
},
{
icon: Radio,
label: '直播',
href: '/live',
},
];
2025-12-22 00:42:27 +08:00
// 如果配置了 OpenList添加私人影库入口
if (runtimeConfig?.OPENLIST_ENABLED) {
items.push({
icon: FolderOpen,
label: '私人影库',
href: '/private-library',
});
}
2025-12-08 10:03:25 +08:00
// 如果启用观影室,添加观影室入口
if (watchRoomContext?.isEnabled) {
items.push({
icon: Users,
label: '观影室',
href: '/watch-room',
});
}
// 添加自定义分类(如果有)
2025-08-13 22:07:28 +08:00
if (runtimeConfig?.CUSTOM_CATEGORIES?.length > 0) {
2025-12-08 10:03:25 +08:00
items.push({
icon: Star,
label: '自定义',
href: '/douban?type=custom',
});
2025-08-13 22:07:28 +08:00
}
2025-12-08 10:03:25 +08:00
setMenuItems(items);
}, [watchRoomContext?.isEnabled]);
2025-08-12 21:50:58 +08:00
return (
<SidebarContext.Provider value={contextValue}>
{/* 在移动端隐藏侧边栏 */}
<div className='hidden md:flex'>
<aside
data-sidebar
2025-08-13 00:30:31 +08:00
className={`fixed top-0 left-0 h-screen bg-white/40 backdrop-blur-xl transition-all duration-300 border-r border-gray-200/50 z-10 shadow-lg dark:bg-gray-900/70 dark:border-gray-700/50 ${isCollapsed ? 'w-16' : 'w-64'
}`}
2025-08-12 21:50:58 +08:00
style={{
backdropFilter: 'blur(20px)',
WebkitBackdropFilter: 'blur(20px)',
}}
>
<div className='flex h-full flex-col'>
{/* 顶部 Logo 区域 */}
<div className='relative h-16'>
<div
2025-08-13 00:30:31 +08:00
className={`absolute inset-0 flex items-center justify-center transition-opacity duration-200 ${isCollapsed ? 'opacity-0' : 'opacity-100'
}`}
2025-08-12 21:50:58 +08:00
>
<div className='w-[calc(100%-4rem)] flex justify-center'>
{!isCollapsed && <Logo />}
</div>
</div>
<button
onClick={handleToggle}
2025-08-13 00:30:31 +08:00
className={`absolute top-1/2 -translate-y-1/2 flex items-center justify-center w-8 h-8 rounded-lg text-gray-500 hover:text-gray-700 hover:bg-gray-100/50 transition-colors duration-200 z-10 dark:text-gray-400 dark:hover:text-gray-200 dark:hover:bg-gray-700/50 ${isCollapsed ? 'left-1/2 -translate-x-1/2' : 'right-2'
}`}
2025-08-12 21:50:58 +08:00
>
<Menu className='h-4 w-4' />
</button>
</div>
{/* 首页和搜索导航 */}
<nav className='px-2 mt-4 space-y-1'>
<Link
href='/'
2025-11-30 21:33:05 +08:00
prefetch={false}
onClick={(e) => {
// 确保点击事件立即生效,不被其他状态更新阻塞
e.currentTarget.blur();
}}
2025-08-12 21:50:58 +08:00
data-active={active === '/'}
2025-08-13 00:30:31 +08:00
className={`group flex items-center rounded-lg px-2 py-2 pl-4 text-gray-700 hover:bg-gray-100/30 hover:text-green-600 data-[active=true]:bg-green-500/20 data-[active=true]:text-green-700 font-medium transition-colors duration-200 min-h-[40px] dark:text-gray-300 dark:hover:text-green-400 dark:data-[active=true]:bg-green-500/10 dark:data-[active=true]:text-green-400 ${isCollapsed ? 'w-full max-w-none mx-0' : 'mx-0'
} gap-3 justify-start`}
2025-08-12 21:50:58 +08:00
>
<div className='w-4 h-4 flex items-center justify-center'>
<Home className='h-4 w-4 text-gray-500 group-hover:text-green-600 data-[active=true]:text-green-700 dark:text-gray-400 dark:group-hover:text-green-400 dark:data-[active=true]:text-green-400' />
</div>
{!isCollapsed && (
<span className='whitespace-nowrap transition-opacity duration-200 opacity-100'>
</span>
)}
</Link>
<Link
href='/search'
data-active={active === '/search'}
2025-08-13 00:30:31 +08:00
className={`group flex items-center rounded-lg px-2 py-2 pl-4 text-gray-700 hover:bg-gray-100/30 hover:text-green-600 data-[active=true]:bg-green-500/20 data-[active=true]:text-green-700 font-medium transition-colors duration-200 min-h-[40px] dark:text-gray-300 dark:hover:text-green-400 dark:data-[active=true]:bg-green-500/10 dark:data-[active=true]:text-green-400 ${isCollapsed ? 'w-full max-w-none mx-0' : 'mx-0'
} gap-3 justify-start`}
2025-08-12 21:50:58 +08:00
>
<div className='w-4 h-4 flex items-center justify-center'>
<Search className='h-4 w-4 text-gray-500 group-hover:text-green-600 data-[active=true]:text-green-700 dark:text-gray-400 dark:group-hover:text-green-400 dark:data-[active=true]:text-green-400' />
</div>
{!isCollapsed && (
<span className='whitespace-nowrap transition-opacity duration-200 opacity-100'>
</span>
)}
</Link>
</nav>
{/* 菜单项 */}
<div className='flex-1 overflow-y-auto px-2 pt-4'>
<div className='space-y-1'>
{menuItems.map((item) => {
// 检查当前路径是否匹配这个菜单项
const typeMatch = item.href.match(/type=([^&]+)/)?.[1];
// 解码URL以进行正确的比较
const decodedActive = decodeURIComponent(active);
const decodedItemHref = decodeURIComponent(item.href);
const isActive =
decodedActive === decodedItemHref ||
(decodedActive.startsWith('/douban') &&
decodedActive.includes(`type=${typeMatch}`));
const Icon = item.icon;
return (
<Link
key={item.label}
href={item.href}
data-active={isActive}
2025-08-13 00:30:31 +08:00
className={`group flex items-center rounded-lg px-2 py-2 pl-4 text-sm text-gray-700 hover:bg-gray-100/30 hover:text-green-600 data-[active=true]:bg-green-500/20 data-[active=true]:text-green-700 transition-colors duration-200 min-h-[40px] dark:text-gray-300 dark:hover:text-green-400 dark:data-[active=true]:bg-green-500/10 dark:data-[active=true]:text-green-400 ${isCollapsed ? 'w-full max-w-none mx-0' : 'mx-0'
} gap-3 justify-start`}
2025-08-12 21:50:58 +08:00
>
<div className='w-4 h-4 flex items-center justify-center'>
<Icon className='h-4 w-4 text-gray-500 group-hover:text-green-600 data-[active=true]:text-green-700 dark:text-gray-400 dark:group-hover:text-green-400 dark:data-[active=true]:text-green-400' />
</div>
{!isCollapsed && (
<span className='whitespace-nowrap transition-opacity duration-200 opacity-100'>
{item.label}
</span>
)}
</Link>
);
})}
</div>
</div>
</div>
</aside>
<div
2025-08-13 00:30:31 +08:00
className={`transition-all duration-300 sidebar-offset ${isCollapsed ? 'w-16' : 'w-64'
}`}
2025-08-12 21:50:58 +08:00
></div>
</div>
</SidebarContext.Provider>
);
};
export default Sidebar;