mirror of
https://github.com/MoonTechLab/LunaTV.git
synced 2026-02-21 09:14:42 +08:00
feat: mobile style
This commit is contained in:
@@ -111,7 +111,7 @@ function DetailPageClient() {
|
||||
|
||||
return (
|
||||
<PageLayout activePath='/detail'>
|
||||
<div className='px-10 py-8 overflow-visible'>
|
||||
<div className='px-4 sm:px-10 py-4 sm:py-8 overflow-visible'>
|
||||
{/* 顶部返回按钮已移入右侧信息容器 */}
|
||||
{loading ? (
|
||||
<div className='flex items-center justify-center min-h-[60vh]'>
|
||||
|
||||
@@ -179,7 +179,7 @@ function DoubanPageClient() {
|
||||
|
||||
return (
|
||||
<PageLayout activePath={getActivePath()}>
|
||||
<div className='px-10 py-8 overflow-visible'>
|
||||
<div className='px-4 sm:px-10 py-4 sm:py-8 overflow-visible'>
|
||||
{/* 页面标题 */}
|
||||
<div className='mb-8'>
|
||||
<h1 className='text-3xl font-bold text-gray-800 mb-2'>
|
||||
@@ -200,7 +200,7 @@ function DoubanPageClient() {
|
||||
) : (
|
||||
<>
|
||||
{/* 内容网格 */}
|
||||
<div className='grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-x-8 gap-y-20 px-4'>
|
||||
<div className='grid grid-cols-2 gap-x-2 gap-y-12 px-2 sm:grid-cols-[repeat(auto-fit,minmax(180px,1fr))] sm:gap-x-8 sm:gap-y-20 sm:px-4'>
|
||||
{loading
|
||||
? // 显示骨架屏
|
||||
skeletonData.map((index) => (
|
||||
@@ -208,7 +208,7 @@ function DoubanPageClient() {
|
||||
))
|
||||
: // 显示实际数据
|
||||
doubanData.map((item, index) => (
|
||||
<div key={`${item.title}-${index}`} className='w-44'>
|
||||
<div key={`${item.title}-${index}`} className='w-full'>
|
||||
<DemoCard title={item.title} poster={item.poster} />
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -73,3 +73,17 @@ body {
|
||||
rgba(0, 0, 0, 0.8) 100%
|
||||
);
|
||||
}
|
||||
|
||||
/* 隐藏移动端(<768px)垂直滚动条 */
|
||||
@media (max-width: 767px) {
|
||||
html,
|
||||
body {
|
||||
-ms-overflow-style: none; /* IE & Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
|
||||
html::-webkit-scrollbar,
|
||||
body::-webkit-scrollbar {
|
||||
display: none; /* Chrome Safari */
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,7 +130,7 @@ function HomeClient() {
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className='px-10 py-8'>
|
||||
<div className='px-4 sm:px-10 py-4 sm:py-8 overflow-visible'>
|
||||
{/* 顶部 Tab 切换 */}
|
||||
<div className='mb-8 flex justify-center'>
|
||||
<CapsuleSwitch
|
||||
@@ -150,7 +150,7 @@ function HomeClient() {
|
||||
<h2 className='mb-4 text-xl font-bold text-gray-800 text-left'>
|
||||
我的收藏
|
||||
</h2>
|
||||
<div className='justify-start grid grid-cols-[repeat(auto-fill,_minmax(11rem,_1fr))] gap-x-8 gap-y-20 px-4'>
|
||||
<div className='justify-start grid grid-cols-2 gap-x-2 gap-y-6 px-2 sm:grid-cols-[repeat(auto-fill,_minmax(11rem,_1fr))] sm:gap-x-8 sm:gap-y-20 sm:px-4'>
|
||||
{favoriteItems.map((item) => (
|
||||
<div key={item.id + item.source} className='w-full'>
|
||||
<VideoCard {...item} from='favorites' />
|
||||
@@ -173,7 +173,10 @@ function HomeClient() {
|
||||
</h2>
|
||||
<ScrollableRow scrollDistance={800}>
|
||||
{collections.map((collection) => (
|
||||
<div key={collection.title} className='min-w-[280px] w-72'>
|
||||
<div
|
||||
key={collection.title}
|
||||
className='min-w-[180px] w-44 sm:min-w-[280px] sm:w-72'
|
||||
>
|
||||
<CollectionCard
|
||||
title={collection.title}
|
||||
icon={collection.icon}
|
||||
@@ -196,7 +199,10 @@ function HomeClient() {
|
||||
{loading
|
||||
? // 加载状态显示灰色占位数据
|
||||
Array.from({ length: 8 }).map((_, index) => (
|
||||
<div key={index} className='min-w-[180px] w-44'>
|
||||
<div
|
||||
key={index}
|
||||
className='min-w-[140px] w-36 sm:min-w-[180px] sm:w-44'
|
||||
>
|
||||
<div className='relative aspect-[2/3] w-full overflow-hidden rounded-lg bg-gray-200 animate-pulse'>
|
||||
<div className='absolute inset-0 bg-gray-300'></div>
|
||||
</div>
|
||||
@@ -205,7 +211,10 @@ function HomeClient() {
|
||||
))
|
||||
: // 显示真实数据
|
||||
hotMovies.map((movie, index) => (
|
||||
<div key={index} className='min-w-[180px] w-44'>
|
||||
<div
|
||||
key={index}
|
||||
className='min-w-[140px] w-36 sm:min-w-[180px] sm:w-44'
|
||||
>
|
||||
<DemoCard title={movie.title} poster={movie.poster} />
|
||||
</div>
|
||||
))}
|
||||
@@ -221,7 +230,10 @@ function HomeClient() {
|
||||
{loading
|
||||
? // 加载状态显示灰色占位数据
|
||||
Array.from({ length: 8 }).map((_, index) => (
|
||||
<div key={index} className='min-w-[180px] w-44'>
|
||||
<div
|
||||
key={index}
|
||||
className='min-w-[140px] w-36 sm:min-w-[180px] sm:w-44'
|
||||
>
|
||||
<div className='relative aspect-[2/3] w-full overflow-hidden rounded-lg bg-gray-200 animate-pulse'>
|
||||
<div className='absolute inset-0 bg-gray-300'></div>
|
||||
</div>
|
||||
@@ -230,7 +242,10 @@ function HomeClient() {
|
||||
))
|
||||
: // 显示真实数据
|
||||
hotTvShows.map((show, index) => (
|
||||
<div key={index} className='min-w-[180px] w-44'>
|
||||
<div
|
||||
key={index}
|
||||
className='min-w-[140px] w-36 sm:min-w-[180px] sm:w-44'
|
||||
>
|
||||
<DemoCard title={show.title} poster={show.poster} />
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -99,7 +99,7 @@ function SearchPageClient() {
|
||||
|
||||
return (
|
||||
<PageLayout activePath='/search'>
|
||||
<div className='px-10 py-8 overflow-visible mb-10'>
|
||||
<div className='px-4 sm:px-10 py-4 sm:py-8 overflow-visible mb-10'>
|
||||
{/* 搜索框 */}
|
||||
<div className='mb-8'>
|
||||
<form onSubmit={handleSearch} className='max-w-2xl mx-auto'>
|
||||
@@ -125,7 +125,7 @@ function SearchPageClient() {
|
||||
</div>
|
||||
) : showResults ? (
|
||||
// 搜索结果
|
||||
<div className='justify-start grid grid-cols-[repeat(auto-fill,_minmax(11rem,_1fr))] gap-x-8 gap-y-20 px-4'>
|
||||
<div className='justify-start grid grid-cols-2 gap-x-2 gap-y-20 px-2 sm:grid-cols-[repeat(auto-fill,_minmax(11rem,_1fr))] sm:gap-x-8 sm:gap-y-20 sm:px-4'>
|
||||
{searchResults.map((item) => (
|
||||
<div key={item.id} className='w-full'>
|
||||
<VideoCard {...item} from='search' />
|
||||
|
||||
@@ -23,7 +23,7 @@ const CapsuleSwitch: React.FC<CapsuleSwitchProps> = ({
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => onChange(opt.value)}
|
||||
className={`w-20 px-3 py-2 rounded-full text-sm font-medium transition-all duration-200 ${
|
||||
className={`w-16 px-3 py-1 text-xs sm:w-20 sm:py-2 sm:text-sm rounded-full font-medium transition-all duration-200 ${
|
||||
active === opt.value
|
||||
? 'bg-white text-gray-900 shadow-sm'
|
||||
: 'text-gray-700 hover:text-gray-900'
|
||||
|
||||
@@ -19,7 +19,7 @@ export default function CollectionCard({
|
||||
<div className='relative aspect-[5/3] w-full overflow-hidden rounded-xl bg-gray-200 border border-gray-300/50'>
|
||||
{/* 图标容器 */}
|
||||
<div className='absolute inset-0 flex items-center justify-center'>
|
||||
<Icon className='h-14 w-14 text-gray-600' />
|
||||
<Icon className='h-10 w-10 sm:h-12 sm:w-12 text-gray-600' />
|
||||
</div>
|
||||
|
||||
{/* Hover 蒙版效果 - 参考 DemoCard */}
|
||||
|
||||
@@ -78,7 +78,10 @@ export default function ContinueWatching({ className }: ContinueWatchingProps) {
|
||||
{loading
|
||||
? // 加载状态显示灰色占位数据
|
||||
Array.from({ length: 6 }).map((_, index) => (
|
||||
<div key={index} className='min-w-[180px] w-44'>
|
||||
<div
|
||||
key={index}
|
||||
className='min-w-[140px] w-36 sm:min-w-[180px] sm:w-44'
|
||||
>
|
||||
<div className='relative aspect-[2/3] w-full overflow-hidden rounded-lg bg-gray-200 animate-pulse'>
|
||||
<div className='absolute inset-0 bg-gray-300'></div>
|
||||
</div>
|
||||
@@ -90,7 +93,10 @@ export default function ContinueWatching({ className }: ContinueWatchingProps) {
|
||||
playRecords.map((record) => {
|
||||
const { source, id } = parseKey(record.key);
|
||||
return (
|
||||
<div key={record.key} className='min-w-[180px] w-44'>
|
||||
<div
|
||||
key={record.key}
|
||||
className='min-w-[140px] w-36 sm:min-w-[180px] sm:w-44'
|
||||
>
|
||||
<VideoCard
|
||||
id={id}
|
||||
title={record.title}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const DoubanCardSkeleton = () => {
|
||||
return (
|
||||
<div className='w-44'>
|
||||
<div className='w-full'>
|
||||
<div className='group relative w-full rounded-lg bg-transparent shadow-none flex flex-col'>
|
||||
{/* 海报骨架 - 2:3 比例 */}
|
||||
<div className='relative aspect-[2/3] w-full overflow-hidden rounded-lg bg-gray-200 animate-pulse'>
|
||||
|
||||
99
src/components/MobileBottomNav.tsx
Normal file
99
src/components/MobileBottomNav.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Film,
|
||||
Home,
|
||||
MessageCircleHeart,
|
||||
MountainSnow,
|
||||
Search,
|
||||
Star,
|
||||
Swords,
|
||||
Tv,
|
||||
VenetianMask,
|
||||
} from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
const MobileBottomNav = () => {
|
||||
const pathname = usePathname();
|
||||
|
||||
const navItems = [
|
||||
{ icon: Home, label: '首页', href: '/' },
|
||||
{ icon: Search, label: '搜索', href: '/search' },
|
||||
{
|
||||
icon: Film,
|
||||
label: '热门电影',
|
||||
href: '/douban?type=movie&tag=热门&title=热门电影',
|
||||
},
|
||||
{
|
||||
icon: Tv,
|
||||
label: '热门剧集',
|
||||
href: '/douban?type=tv&tag=热门&title=热门剧集',
|
||||
},
|
||||
{
|
||||
icon: Star,
|
||||
label: '豆瓣 Top250',
|
||||
href: '/douban?type=movie&tag=top250&title=豆瓣 Top250',
|
||||
},
|
||||
{ icon: Swords, label: '美剧', href: '/douban?type=tv&tag=美剧' },
|
||||
{
|
||||
icon: MessageCircleHeart,
|
||||
label: '韩剧',
|
||||
href: '/douban?type=tv&tag=韩剧',
|
||||
},
|
||||
{ icon: MountainSnow, label: '日剧', href: '/douban?type=tv&tag=日剧' },
|
||||
{ icon: VenetianMask, label: '日漫', href: '/douban?type=tv&tag=日本动画' },
|
||||
];
|
||||
|
||||
const isActive = (href: string) => {
|
||||
const typeMatch = href.match(/type=([^&]+)/)?.[1];
|
||||
const tagMatch = href.match(/tag=([^&]+)/)?.[1];
|
||||
|
||||
// 解码URL以进行正确的比较
|
||||
const decodedActive = decodeURIComponent(pathname);
|
||||
const decodedItemHref = decodeURIComponent(href);
|
||||
|
||||
return (
|
||||
decodedActive === decodedItemHref ||
|
||||
(decodedActive.startsWith('/douban') &&
|
||||
decodedActive.includes(`type=${typeMatch}`) &&
|
||||
decodedActive.includes(`tag=${tagMatch}`))
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<nav
|
||||
className='md:hidden fixed left-0 right-0 z-20 bg-white/90 backdrop-blur-xl border-t border-gray-200/50 overflow-x-auto overscroll-x-contain whitespace-nowrap scrollbar-hide'
|
||||
style={{
|
||||
/* 紧贴视口底部,同时在内部留出安全区高度 */
|
||||
bottom: 0,
|
||||
paddingBottom: 'env(safe-area-inset-bottom)',
|
||||
}}
|
||||
>
|
||||
<ul className='flex items-center'>
|
||||
{navItems.map((item) => {
|
||||
const active = isActive(item.href);
|
||||
return (
|
||||
<li key={item.href} className='flex-shrink-0 w-1/4'>
|
||||
<Link
|
||||
href={item.href}
|
||||
className='flex flex-col items-center justify-center w-full h-14 gap-1 text-xs'
|
||||
>
|
||||
<item.icon
|
||||
className={`h-6 w-6 ${
|
||||
active ? 'text-green-600' : 'text-gray-500'
|
||||
}`}
|
||||
/>
|
||||
<span className={active ? 'text-green-600' : 'text-gray-600'}>
|
||||
{item.label}
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
export default MobileBottomNav;
|
||||
18
src/components/MobileHeader.tsx
Normal file
18
src/components/MobileHeader.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
const MobileHeader = () => {
|
||||
return (
|
||||
<header className='md:hidden w-full bg-white/70 backdrop-blur-xl border-b border-gray-200/50 shadow-sm'>
|
||||
<div className='h-12 flex items-center justify-center'>
|
||||
<Link
|
||||
href='/'
|
||||
className='text-2xl font-bold text-green-600 tracking-tight hover:opacity-80 transition-opacity'
|
||||
>
|
||||
MoonTV
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
export default MobileHeader;
|
||||
@@ -1,3 +1,5 @@
|
||||
import MobileBottomNav from './MobileBottomNav';
|
||||
import MobileHeader from './MobileHeader';
|
||||
import { useSidebar } from './Sidebar';
|
||||
import Sidebar from './Sidebar';
|
||||
|
||||
@@ -10,16 +12,33 @@ const PageLayout = ({ children, activePath = '/' }: PageLayoutProps) => {
|
||||
const { isCollapsed } = useSidebar();
|
||||
|
||||
return (
|
||||
<div className='grid grid-cols-[auto_1fr] w-full'>
|
||||
<Sidebar activePath={activePath} />
|
||||
<div
|
||||
className={`min-w-0 transition-all duration-300 ${
|
||||
isCollapsed ? 'col-start-2' : 'col-start-2'
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
<>
|
||||
{/* 桌面端布局 */}
|
||||
<div className='hidden md:grid md:grid-cols-[auto_1fr] w-full'>
|
||||
<Sidebar activePath={activePath} />
|
||||
<div
|
||||
className={`min-w-0 transition-all duration-300 ${
|
||||
isCollapsed ? 'col-start-2' : 'col-start-2'
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 移动端布局 */}
|
||||
<div className='md:hidden flex flex-col min-h-screen w-full'>
|
||||
<MobileHeader />
|
||||
<main
|
||||
className='flex-1 pb-14'
|
||||
style={{
|
||||
paddingBottom: 'calc(3.5rem + env(safe-area-inset-bottom))',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</main>
|
||||
<MobileBottomNav />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -154,7 +154,8 @@ const Sidebar = ({ onToggle, activePath = '/' }: SidebarProps) => {
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider value={contextValue}>
|
||||
<div className='flex'>
|
||||
{/* 在移动端隐藏侧边栏 */}
|
||||
<div className='hidden md:flex'>
|
||||
<aside
|
||||
data-sidebar
|
||||
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 ${
|
||||
|
||||
Reference in New Issue
Block a user