详情点击可大图
This commit is contained in:
@@ -8,6 +8,8 @@ import { createPortal } from 'react-dom';
|
||||
import { getTMDBImageUrl } from '@/lib/tmdb.client';
|
||||
import { processImageUrl } from '@/lib/utils';
|
||||
|
||||
import ImageViewer from '@/components/ImageViewer';
|
||||
|
||||
interface DetailPanelProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
@@ -91,6 +93,8 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
||||
const [expandedEpisodes, setExpandedEpisodes] = useState<Set<number>>(new Set());
|
||||
const [selectedSeason, setSelectedSeason] = useState<number>(1);
|
||||
const [seasonsLoaded, setSeasonsLoaded] = useState(false);
|
||||
const [showImageViewer, setShowImageViewer] = useState(false);
|
||||
const [selectedImage, setSelectedImage] = useState<string>('');
|
||||
|
||||
// 拖动滚动状态
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
@@ -99,6 +103,12 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
||||
const [scrollLeft, setScrollLeft] = useState(0);
|
||||
const episodesScrollRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
// 图片点击处理
|
||||
const handleImageClick = (imageUrl: string) => {
|
||||
setSelectedImage(imageUrl);
|
||||
setShowImageViewer(true);
|
||||
};
|
||||
|
||||
// 确保组件在客户端挂载后才渲染 Portal
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
@@ -608,7 +618,10 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
||||
{/* 海报和基本信息 */}
|
||||
<div className="flex gap-6 mb-6">
|
||||
{detailData.poster && (
|
||||
<div className="relative w-32 h-48 rounded-lg overflow-hidden bg-gray-100 dark:bg-gray-800 flex-shrink-0">
|
||||
<div
|
||||
className="relative w-32 h-48 rounded-lg overflow-hidden bg-gray-100 dark:bg-gray-800 flex-shrink-0 cursor-pointer hover:opacity-90 transition-opacity"
|
||||
onClick={() => handleImageClick(detailData.poster!)}
|
||||
>
|
||||
<Image src={detailData.poster} alt={detailData.title} fill className="object-cover" draggable={false} />
|
||||
</div>
|
||||
)}
|
||||
@@ -788,7 +801,13 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
||||
}`}
|
||||
>
|
||||
{season.poster_path && (
|
||||
<div className="relative w-12 h-16 rounded overflow-hidden bg-gray-200 dark:bg-gray-700 flex-shrink-0">
|
||||
<div
|
||||
className="relative w-12 h-16 rounded overflow-hidden bg-gray-200 dark:bg-gray-700 flex-shrink-0 hover:opacity-80 transition-opacity"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleImageClick(processImageUrl(getTMDBImageUrl(season.poster_path, 'w500')));
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
src={processImageUrl(getTMDBImageUrl(season.poster_path, 'w92'))}
|
||||
alt={season.name}
|
||||
@@ -840,7 +859,10 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
||||
style={{ pointerEvents: isDragging ? 'none' : 'auto' }}
|
||||
>
|
||||
{episode.still_path && (
|
||||
<div className="relative w-full h-36 rounded overflow-hidden bg-gray-200 dark:bg-gray-700 mb-2">
|
||||
<div
|
||||
className="relative w-full h-36 rounded overflow-hidden bg-gray-200 dark:bg-gray-700 mb-2 cursor-pointer hover:opacity-90 transition-opacity"
|
||||
onClick={() => handleImageClick(processImageUrl(getTMDBImageUrl(episode.still_path, 'w500')))}
|
||||
>
|
||||
<Image
|
||||
src={processImageUrl(getTMDBImageUrl(episode.still_path, 'w300'))}
|
||||
alt={episode.name}
|
||||
@@ -889,6 +911,16 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 图片查看器 */}
|
||||
{showImageViewer && (
|
||||
<ImageViewer
|
||||
isOpen={showImageViewer}
|
||||
onClose={() => setShowImageViewer(false)}
|
||||
imageUrl={selectedImage}
|
||||
alt={detailData?.title || title}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
175
src/components/ImageViewer.tsx
Normal file
175
src/components/ImageViewer.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
'use client';
|
||||
|
||||
import { X } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
interface ImageViewerProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
imageUrl: string;
|
||||
alt?: string;
|
||||
}
|
||||
|
||||
const ImageViewer: React.FC<ImageViewerProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
imageUrl,
|
||||
alt = '图片',
|
||||
}) => {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [isAnimating, setIsAnimating] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
// 确保组件在客户端挂载后才渲染 Portal
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
// 控制动画状态
|
||||
useEffect(() => {
|
||||
let animationId: number;
|
||||
let timer: NodeJS.Timeout;
|
||||
|
||||
if (isOpen) {
|
||||
setIsVisible(true);
|
||||
animationId = requestAnimationFrame(() => {
|
||||
animationId = requestAnimationFrame(() => {
|
||||
setIsAnimating(true);
|
||||
});
|
||||
});
|
||||
} else {
|
||||
setIsAnimating(false);
|
||||
timer = setTimeout(() => {
|
||||
setIsVisible(false);
|
||||
}, 200);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (animationId) {
|
||||
cancelAnimationFrame(animationId);
|
||||
}
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
// 阻止背景滚动
|
||||
useEffect(() => {
|
||||
if (isVisible) {
|
||||
const scrollY = window.scrollY;
|
||||
const scrollX = window.scrollX;
|
||||
const body = document.body;
|
||||
const html = document.documentElement;
|
||||
|
||||
const scrollBarWidth = window.innerWidth - html.clientWidth;
|
||||
|
||||
const originalBodyStyle = {
|
||||
position: body.style.position,
|
||||
top: body.style.top,
|
||||
left: body.style.left,
|
||||
right: body.style.right,
|
||||
width: body.style.width,
|
||||
paddingRight: body.style.paddingRight,
|
||||
overflow: body.style.overflow,
|
||||
};
|
||||
|
||||
body.style.position = 'fixed';
|
||||
body.style.top = `-${scrollY}px`;
|
||||
body.style.left = `-${scrollX}px`;
|
||||
body.style.right = '0';
|
||||
body.style.width = '100%';
|
||||
body.style.overflow = 'hidden';
|
||||
body.style.paddingRight = `${scrollBarWidth}px`;
|
||||
|
||||
return () => {
|
||||
body.style.position = originalBodyStyle.position;
|
||||
body.style.top = originalBodyStyle.top;
|
||||
body.style.left = originalBodyStyle.left;
|
||||
body.style.right = originalBodyStyle.right;
|
||||
body.style.width = originalBodyStyle.width;
|
||||
body.style.paddingRight = originalBodyStyle.paddingRight;
|
||||
body.style.overflow = originalBodyStyle.overflow;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
window.scrollTo(scrollX, scrollY);
|
||||
});
|
||||
};
|
||||
}
|
||||
}, [isVisible]);
|
||||
|
||||
// ESC键关闭
|
||||
useEffect(() => {
|
||||
const handleEsc = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
if (isVisible) {
|
||||
document.addEventListener('keydown', handleEsc);
|
||||
return () => document.removeEventListener('keydown', handleEsc);
|
||||
}
|
||||
}, [isVisible, onClose]);
|
||||
|
||||
if (!isVisible || !mounted) return null;
|
||||
|
||||
const content = (
|
||||
<div className="fixed inset-0 z-[9999] flex items-center justify-center p-0 sm:p-4">
|
||||
{/* 背景遮罩 */}
|
||||
<div
|
||||
className={`absolute inset-0 bg-black/80 transition-opacity duration-200 ease-out ${
|
||||
isAnimating ? 'opacity-100' : 'opacity-0'
|
||||
}`}
|
||||
onClick={onClose}
|
||||
style={{
|
||||
backdropFilter: 'blur(4px)',
|
||||
willChange: 'opacity',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 关闭按钮 */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 z-10 p-2 rounded-full bg-black/50 hover:bg-black/70 transition-colors duration-150"
|
||||
aria-label="关闭"
|
||||
>
|
||||
<X size={24} className="text-white" />
|
||||
</button>
|
||||
|
||||
{/* 图片容器 */}
|
||||
<div
|
||||
className="relative max-w-[100vw] max-h-[100vh] sm:max-w-[90vw] sm:max-h-[90vh] transition-all duration-200 ease-out"
|
||||
style={{
|
||||
willChange: 'transform, opacity',
|
||||
backfaceVisibility: 'hidden',
|
||||
transform: isAnimating ? 'scale(1) translateZ(0)' : 'scale(0.95) translateZ(0)',
|
||||
opacity: isAnimating ? 1 : 0,
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="relative w-full h-full">
|
||||
<Image
|
||||
src={imageUrl}
|
||||
alt={alt}
|
||||
width={1200}
|
||||
height={1800}
|
||||
className="object-contain max-w-[100vw] max-h-[100vh] sm:max-w-[90vw] sm:max-h-[90vh] w-auto h-auto"
|
||||
style={{
|
||||
maxWidth: '100vw',
|
||||
maxHeight: '100vh',
|
||||
}}
|
||||
priority
|
||||
quality={100}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return createPortal(content, document.body);
|
||||
};
|
||||
|
||||
export default ImageViewer;
|
||||
@@ -24,6 +24,7 @@ interface MobileActionSheetProps {
|
||||
currentEpisode?: number; // 当前集数
|
||||
totalEpisodes?: number; // 总集数
|
||||
origin?: 'vod' | 'live';
|
||||
onPosterClick?: () => void; // 海报点击回调
|
||||
}
|
||||
|
||||
const MobileActionSheet: React.FC<MobileActionSheetProps> = ({
|
||||
@@ -38,6 +39,7 @@ const MobileActionSheet: React.FC<MobileActionSheetProps> = ({
|
||||
currentEpisode,
|
||||
totalEpisodes,
|
||||
origin = 'vod',
|
||||
onPosterClick,
|
||||
}) => {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [isAnimating, setIsAnimating] = useState(false);
|
||||
@@ -221,7 +223,13 @@ const MobileActionSheet: React.FC<MobileActionSheetProps> = ({
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-100 dark:border-gray-800">
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
{poster && (
|
||||
<div className="relative w-12 h-16 rounded-lg overflow-hidden bg-gray-100 dark:bg-gray-800 flex-shrink-0">
|
||||
<div
|
||||
className="relative w-12 h-16 rounded-lg overflow-hidden bg-gray-100 dark:bg-gray-800 flex-shrink-0 cursor-pointer hover:opacity-90 transition-opacity"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onPosterClick?.();
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
src={poster}
|
||||
alt={title}
|
||||
|
||||
@@ -27,6 +27,7 @@ import { useLongPress } from '@/hooks/useLongPress';
|
||||
import AIChatPanel from '@/components/AIChatPanel';
|
||||
import DetailPanel from '@/components/DetailPanel';
|
||||
import { ImagePlaceholder } from '@/components/ImagePlaceholder';
|
||||
import ImageViewer from '@/components/ImageViewer';
|
||||
import MobileActionSheet from '@/components/MobileActionSheet';
|
||||
|
||||
export interface VideoCardProps {
|
||||
@@ -112,6 +113,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
||||
const [aiEnabled, setAiEnabled] = useState(false);
|
||||
const [aiDefaultMessageWithVideo, setAiDefaultMessageWithVideo] = useState('');
|
||||
const [showDetailPanel, setShowDetailPanel] = useState(false);
|
||||
const [showImageViewer, setShowImageViewer] = useState(false);
|
||||
|
||||
// 检查AI功能是否启用
|
||||
useEffect(() => {
|
||||
@@ -734,6 +736,10 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
||||
referrerPolicy='no-referrer'
|
||||
loading='lazy'
|
||||
onLoadingComplete={() => setIsLoading(true)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowImageViewer(true);
|
||||
}}
|
||||
onError={(e) => {
|
||||
// 图片加载失败时的重试机制
|
||||
const img = e.target as HTMLImageElement;
|
||||
@@ -749,7 +755,8 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
WebkitTouchCallout: 'none',
|
||||
pointerEvents: 'none', // 图片不响应任何指针事件
|
||||
pointerEvents: 'auto', // 改为auto以响应点击事件
|
||||
cursor: 'pointer', // 添加指针样式
|
||||
} as React.CSSProperties}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
@@ -1478,6 +1485,9 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
||||
currentEpisode={currentEpisode}
|
||||
totalEpisodes={actualEpisodes}
|
||||
origin={origin}
|
||||
onPosterClick={() => {
|
||||
setShowImageViewer(true);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* AI问片面板 */}
|
||||
@@ -1515,6 +1525,16 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
||||
source={source}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 图片查看器 */}
|
||||
{showImageViewer && (
|
||||
<ImageViewer
|
||||
isOpen={showImageViewer}
|
||||
onClose={() => setShowImageViewer(false)}
|
||||
imageUrl={processImageUrl(actualPoster)}
|
||||
alt={actualTitle}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user