mirror of
https://github.com/MoonTechLab/LunaTV.git
synced 2026-02-21 17:24:41 +08:00
feat: scroll x
This commit is contained in:
@@ -59,7 +59,7 @@ export default function DetailPage() {
|
||||
<div className='text-gray-500'>未找到视频详情</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className='w-full max-w-[90%]'>
|
||||
<div className='w-full max-w-[95%]'>
|
||||
{/* 主信息区:左图右文 */}
|
||||
<div className='flex flex-col md:flex-row gap-8 mb-8 bg-transparent rounded-xl p-6'>
|
||||
{/* 封面 */}
|
||||
|
||||
@@ -2,10 +2,27 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer utilities {
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none; /* Chrome, Safari and Opera */
|
||||
}
|
||||
}
|
||||
|
||||
:root {
|
||||
--foreground-rgb: 255, 255, 255;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
color: rgb(var(--foreground-rgb));
|
||||
background: linear-gradient(
|
||||
@@ -18,7 +35,6 @@ body {
|
||||
#d3dde6 100%
|
||||
);
|
||||
background-attachment: fixed;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* 自定义滚动条样式 */
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useState } from 'react';
|
||||
import CapsuleSwitch from '@/components/CapsuleSwitch';
|
||||
import DemoCard from '@/components/DemoCard';
|
||||
import PageLayout from '@/components/layout/PageLayout';
|
||||
import ScrollableRow from '@/components/ScrollableRow';
|
||||
import VideoCard from '@/components/VideoCard';
|
||||
|
||||
const defaultPoster =
|
||||
@@ -45,6 +46,38 @@ const mockData = {
|
||||
source: 'dyttzy',
|
||||
source_name: '电影天堂',
|
||||
},
|
||||
{
|
||||
id: '332',
|
||||
title: '三体',
|
||||
poster: defaultPoster,
|
||||
source: 'dyttzy',
|
||||
source_name: '电影天堂',
|
||||
episodes: 30,
|
||||
},
|
||||
{
|
||||
id: '4231',
|
||||
title: '狂飙',
|
||||
poster: defaultPoster,
|
||||
episodes: 39,
|
||||
source: 'dyttzy',
|
||||
source_name: '电影天堂',
|
||||
},
|
||||
{
|
||||
id: '3342',
|
||||
title: '三体',
|
||||
poster: defaultPoster,
|
||||
source: 'dyttzy',
|
||||
source_name: '电影天堂',
|
||||
episodes: 30,
|
||||
},
|
||||
{
|
||||
id: '8',
|
||||
title: '狂飙',
|
||||
poster: defaultPoster,
|
||||
episodes: 39,
|
||||
source: 'dyttzy',
|
||||
source_name: '电影天堂',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -66,49 +99,49 @@ export default function Home() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='max-w-[90%] mx-auto'>
|
||||
<div className='max-w-[95%] mx-auto'>
|
||||
{/* 继续观看 */}
|
||||
<section className='mb-12'>
|
||||
<section className='mb-8'>
|
||||
<h2 className='mb-4 text-xl font-bold text-gray-800 text-left'>
|
||||
继续观看
|
||||
</h2>
|
||||
<div className='flex space-x-8 overflow-x-auto pb-2'>
|
||||
{[...mockData.recentMovies, ...mockData.recentTvShows]
|
||||
.slice(0, 4)
|
||||
.map((item) => (
|
||||
<div key={item.id} className='min-w-[192px] w-48'>
|
||||
<ScrollableRow>
|
||||
{[...mockData.recentMovies, ...mockData.recentTvShows].map(
|
||||
(item) => (
|
||||
<div key={item.id} className='min-w-[180px] w-44'>
|
||||
<VideoCard {...item} progress={Math.random() * 100} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</ScrollableRow>
|
||||
</section>
|
||||
|
||||
{/* 最新电影 */}
|
||||
<section className='mb-12'>
|
||||
<section className='mb-8'>
|
||||
<h2 className='mb-4 text-xl font-bold text-gray-800 text-left'>
|
||||
最新电影
|
||||
</h2>
|
||||
<div className='grid grid-cols-5 gap-8'>
|
||||
<ScrollableRow>
|
||||
{mockData.recentMovies.map((movie) => (
|
||||
<div key={movie.id} className='w-48'>
|
||||
<div key={movie.id} className='min-w-[180px] w-44'>
|
||||
<DemoCard {...movie} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollableRow>
|
||||
</section>
|
||||
|
||||
{/* 最新电视剧 */}
|
||||
<section className='mb-12'>
|
||||
<section className='mb-8'>
|
||||
<h2 className='mb-4 text-xl font-bold text-gray-800 text-left'>
|
||||
最新电视剧
|
||||
</h2>
|
||||
<div className='grid grid-cols-5 gap-8'>
|
||||
<ScrollableRow>
|
||||
{mockData.recentTvShows.map((show) => (
|
||||
<div key={show.id} className='w-48'>
|
||||
<div key={show.id} className='min-w-[180px] w-44'>
|
||||
<DemoCard {...show} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollableRow>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -75,7 +75,7 @@ export default function SearchPage() {
|
||||
|
||||
return (
|
||||
<PageLayout activePath='/search'>
|
||||
<div className='px-10 py-8'>
|
||||
<div className='px-10 py-8 overflow-visible'>
|
||||
{/* 搜索框 */}
|
||||
<div className='mb-8'>
|
||||
<form onSubmit={handleSearch} className='max-w-2xl mx-auto'>
|
||||
@@ -94,21 +94,21 @@ export default function SearchPage() {
|
||||
</div>
|
||||
|
||||
{/* 搜索结果或搜索历史 */}
|
||||
<div className='max-w-[90%] mx-auto mt-12'>
|
||||
<div className='max-w-[95%] mx-auto mt-12 overflow-visible'>
|
||||
{isLoading ? (
|
||||
<div className='flex justify-center items-center h-40'>
|
||||
<div className='animate-spin rounded-full h-8 w-8 border-b-2 border-green-500'></div>
|
||||
</div>
|
||||
) : showResults ? (
|
||||
// 搜索结果
|
||||
<div className='grid grid-cols-6 gap-7'>
|
||||
<div className='grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-x-8 gap-y-20 px-4'>
|
||||
{searchResults.map((item) => (
|
||||
<div key={item.id} className='w-44'>
|
||||
<VideoCard {...item} />
|
||||
</div>
|
||||
))}
|
||||
{searchResults.length === 0 && (
|
||||
<div className='col-span-5 text-center text-gray-500 py-8'>
|
||||
<div className='col-span-full text-center text-gray-500 py-8'>
|
||||
未找到相关结果
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -62,7 +62,7 @@ const DemoCard = ({ title, poster }: DemoCardProps) => {
|
||||
|
||||
return (
|
||||
<div
|
||||
className='group relative w-full overflow-hidden rounded-lg bg-transparent shadow-none flex flex-col cursor-pointer'
|
||||
className='group relative w-full rounded-lg bg-transparent shadow-none flex flex-col cursor-pointer'
|
||||
onClick={handleClick}
|
||||
>
|
||||
{/* 海报图片 - 2:3 比例 */}
|
||||
@@ -84,7 +84,7 @@ const DemoCard = ({ title, poster }: DemoCardProps) => {
|
||||
</div>
|
||||
</div>
|
||||
{/* 信息层 */}
|
||||
<div className='p-2 bg-transparent'>
|
||||
<div className='absolute top-[calc(100%+0.5rem)] left-0 right-0'>
|
||||
<div className='flex flex-col items-center justify-center'>
|
||||
<span className='text-gray-900 font-semibold truncate w-full text-center'>
|
||||
{title}
|
||||
|
||||
155
src/components/ScrollableRow.tsx
Normal file
155
src/components/ScrollableRow.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
interface ScrollableRowProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function ScrollableRow({ children }: ScrollableRowProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [showLeftScroll, setShowLeftScroll] = useState(false);
|
||||
const [showRightScroll, setShowRightScroll] = useState(false);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
const checkScroll = () => {
|
||||
if (containerRef.current) {
|
||||
const { scrollWidth, clientWidth, scrollLeft } = containerRef.current;
|
||||
|
||||
// 计算是否需要左右滚动按钮
|
||||
const threshold = 1; // 容差值,避免浮点误差
|
||||
const canScrollRight =
|
||||
scrollWidth - (scrollLeft + clientWidth) > threshold;
|
||||
const canScrollLeft = scrollLeft > threshold;
|
||||
|
||||
setShowRightScroll(canScrollRight);
|
||||
setShowLeftScroll(canScrollLeft);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// 多次延迟检查,确保内容已完全渲染
|
||||
checkScroll();
|
||||
|
||||
// 监听窗口大小变化
|
||||
window.addEventListener('resize', checkScroll);
|
||||
|
||||
// 创建一个 ResizeObserver 来监听容器大小变化
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
// 延迟执行检查
|
||||
checkScroll();
|
||||
});
|
||||
|
||||
if (containerRef.current) {
|
||||
resizeObserver.observe(containerRef.current);
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', checkScroll);
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, [children]); // 依赖 children,当子组件变化时重新检查
|
||||
|
||||
// 添加一个额外的效果来监听子组件的变化
|
||||
useEffect(() => {
|
||||
if (containerRef.current) {
|
||||
// 监听 DOM 变化
|
||||
const observer = new MutationObserver(() => {
|
||||
setTimeout(checkScroll, 100);
|
||||
});
|
||||
|
||||
observer.observe(containerRef.current, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
attributeFilter: ['style', 'class'],
|
||||
});
|
||||
|
||||
return () => observer.disconnect();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleScrollRightClick = () => {
|
||||
if (containerRef.current) {
|
||||
containerRef.current.scrollBy({ left: 800, behavior: 'smooth' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleScrollLeftClick = () => {
|
||||
if (containerRef.current) {
|
||||
containerRef.current.scrollBy({ left: -800, behavior: 'smooth' });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className='relative'
|
||||
onMouseEnter={() => {
|
||||
setIsHovered(true);
|
||||
// 当鼠标进入时重新检查一次
|
||||
checkScroll();
|
||||
}}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className='flex space-x-6 overflow-x-auto scrollbar-hide pb-14'
|
||||
onScroll={checkScroll}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
{showLeftScroll && (
|
||||
<div
|
||||
className={`absolute left-0 top-0 bottom-0 w-16 flex items-center justify-center z-50 transition-opacity duration-200 ${
|
||||
isHovered ? 'opacity-100' : 'opacity-0'
|
||||
}`}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className='absolute inset-0'
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<div
|
||||
className='absolute inset-0 flex items-center justify-center'
|
||||
style={{ top: '40%', bottom: '60%' }}
|
||||
>
|
||||
<button
|
||||
onClick={handleScrollLeftClick}
|
||||
className='w-12 h-12 bg-white/95 rounded-full shadow-lg flex items-center justify-center hover:bg-white border border-gray-200 transition-transform hover:scale-105'
|
||||
>
|
||||
<ChevronLeft className='w-6 h-6 text-gray-600' />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showRightScroll && (
|
||||
<div
|
||||
className={`absolute right-0 top-0 bottom-0 w-16 flex items-center justify-center z-50 transition-opacity duration-200 ${
|
||||
isHovered ? 'opacity-100' : 'opacity-0'
|
||||
}`}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className='absolute inset-0'
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<div
|
||||
className='absolute inset-0 flex items-center justify-center'
|
||||
style={{ top: '40%', bottom: '60%' }}
|
||||
>
|
||||
<button
|
||||
onClick={handleScrollRightClick}
|
||||
className='w-12 h-12 bg-white/95 rounded-full shadow-lg flex items-center justify-center hover:bg-white border border-gray-200 transition-transform hover:scale-105'
|
||||
>
|
||||
<ChevronRight className='w-6 h-6 text-gray-600' />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -78,7 +78,7 @@ export default function VideoCard({
|
||||
|
||||
return (
|
||||
<Link href={`/detail?source=${source}&id=${id}`}>
|
||||
<div className='group relative w-full overflow-hidden rounded-lg bg-transparent shadow-none flex flex-col'>
|
||||
<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'>
|
||||
<Image src={poster} alt={title} fill className='object-cover' />
|
||||
@@ -122,7 +122,7 @@ export default function VideoCard({
|
||||
</div>
|
||||
|
||||
{/* 信息层 */}
|
||||
<div className='p-2 bg-transparent'>
|
||||
<div className='absolute top-[calc(100%+0.5rem)] left-0 right-0'>
|
||||
<div className='flex flex-col items-center justify-center'>
|
||||
<span className='text-gray-900 font-semibold truncate w-full text-center'>
|
||||
{title}
|
||||
|
||||
@@ -10,10 +10,10 @@ const PageLayout = ({ children, activePath = '/' }: PageLayoutProps) => {
|
||||
const { isCollapsed } = useSidebar();
|
||||
|
||||
return (
|
||||
<div className='grid grid-cols-[auto_1fr] min-h-screen'>
|
||||
<div className='grid grid-cols-[auto_1fr] w-full'>
|
||||
<Sidebar activePath={activePath} />
|
||||
<div
|
||||
className={`transition-all duration-300 ${
|
||||
className={`min-w-0 transition-all duration-300 ${
|
||||
isCollapsed ? 'col-start-2' : 'col-start-2'
|
||||
}`}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user