详情点击可大图
This commit is contained in:
@@ -8,6 +8,8 @@ import { createPortal } from 'react-dom';
|
|||||||
import { getTMDBImageUrl } from '@/lib/tmdb.client';
|
import { getTMDBImageUrl } from '@/lib/tmdb.client';
|
||||||
import { processImageUrl } from '@/lib/utils';
|
import { processImageUrl } from '@/lib/utils';
|
||||||
|
|
||||||
|
import ImageViewer from '@/components/ImageViewer';
|
||||||
|
|
||||||
interface DetailPanelProps {
|
interface DetailPanelProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -91,6 +93,8 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
|||||||
const [expandedEpisodes, setExpandedEpisodes] = useState<Set<number>>(new Set());
|
const [expandedEpisodes, setExpandedEpisodes] = useState<Set<number>>(new Set());
|
||||||
const [selectedSeason, setSelectedSeason] = useState<number>(1);
|
const [selectedSeason, setSelectedSeason] = useState<number>(1);
|
||||||
const [seasonsLoaded, setSeasonsLoaded] = useState(false);
|
const [seasonsLoaded, setSeasonsLoaded] = useState(false);
|
||||||
|
const [showImageViewer, setShowImageViewer] = useState(false);
|
||||||
|
const [selectedImage, setSelectedImage] = useState<string>('');
|
||||||
|
|
||||||
// 拖动滚动状态
|
// 拖动滚动状态
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
@@ -99,6 +103,12 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
|||||||
const [scrollLeft, setScrollLeft] = useState(0);
|
const [scrollLeft, setScrollLeft] = useState(0);
|
||||||
const episodesScrollRef = React.useRef<HTMLDivElement>(null);
|
const episodesScrollRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// 图片点击处理
|
||||||
|
const handleImageClick = (imageUrl: string) => {
|
||||||
|
setSelectedImage(imageUrl);
|
||||||
|
setShowImageViewer(true);
|
||||||
|
};
|
||||||
|
|
||||||
// 确保组件在客户端挂载后才渲染 Portal
|
// 确保组件在客户端挂载后才渲染 Portal
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMounted(true);
|
setMounted(true);
|
||||||
@@ -608,7 +618,10 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
|||||||
{/* 海报和基本信息 */}
|
{/* 海报和基本信息 */}
|
||||||
<div className="flex gap-6 mb-6">
|
<div className="flex gap-6 mb-6">
|
||||||
{detailData.poster && (
|
{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} />
|
<Image src={detailData.poster} alt={detailData.title} fill className="object-cover" draggable={false} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -788,7 +801,13 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{season.poster_path && (
|
{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
|
<Image
|
||||||
src={processImageUrl(getTMDBImageUrl(season.poster_path, 'w92'))}
|
src={processImageUrl(getTMDBImageUrl(season.poster_path, 'w92'))}
|
||||||
alt={season.name}
|
alt={season.name}
|
||||||
@@ -840,7 +859,10 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
|||||||
style={{ pointerEvents: isDragging ? 'none' : 'auto' }}
|
style={{ pointerEvents: isDragging ? 'none' : 'auto' }}
|
||||||
>
|
>
|
||||||
{episode.still_path && (
|
{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
|
<Image
|
||||||
src={processImageUrl(getTMDBImageUrl(episode.still_path, 'w300'))}
|
src={processImageUrl(getTMDBImageUrl(episode.still_path, 'w300'))}
|
||||||
alt={episode.name}
|
alt={episode.name}
|
||||||
@@ -889,6 +911,16 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 图片查看器 */}
|
||||||
|
{showImageViewer && (
|
||||||
|
<ImageViewer
|
||||||
|
isOpen={showImageViewer}
|
||||||
|
onClose={() => setShowImageViewer(false)}
|
||||||
|
imageUrl={selectedImage}
|
||||||
|
alt={detailData?.title || title}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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; // 当前集数
|
currentEpisode?: number; // 当前集数
|
||||||
totalEpisodes?: number; // 总集数
|
totalEpisodes?: number; // 总集数
|
||||||
origin?: 'vod' | 'live';
|
origin?: 'vod' | 'live';
|
||||||
|
onPosterClick?: () => void; // 海报点击回调
|
||||||
}
|
}
|
||||||
|
|
||||||
const MobileActionSheet: React.FC<MobileActionSheetProps> = ({
|
const MobileActionSheet: React.FC<MobileActionSheetProps> = ({
|
||||||
@@ -38,6 +39,7 @@ const MobileActionSheet: React.FC<MobileActionSheetProps> = ({
|
|||||||
currentEpisode,
|
currentEpisode,
|
||||||
totalEpisodes,
|
totalEpisodes,
|
||||||
origin = 'vod',
|
origin = 'vod',
|
||||||
|
onPosterClick,
|
||||||
}) => {
|
}) => {
|
||||||
const [isVisible, setIsVisible] = useState(false);
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
const [isAnimating, setIsAnimating] = 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 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">
|
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||||
{poster && (
|
{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
|
<Image
|
||||||
src={poster}
|
src={poster}
|
||||||
alt={title}
|
alt={title}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import { useLongPress } from '@/hooks/useLongPress';
|
|||||||
import AIChatPanel from '@/components/AIChatPanel';
|
import AIChatPanel from '@/components/AIChatPanel';
|
||||||
import DetailPanel from '@/components/DetailPanel';
|
import DetailPanel from '@/components/DetailPanel';
|
||||||
import { ImagePlaceholder } from '@/components/ImagePlaceholder';
|
import { ImagePlaceholder } from '@/components/ImagePlaceholder';
|
||||||
|
import ImageViewer from '@/components/ImageViewer';
|
||||||
import MobileActionSheet from '@/components/MobileActionSheet';
|
import MobileActionSheet from '@/components/MobileActionSheet';
|
||||||
|
|
||||||
export interface VideoCardProps {
|
export interface VideoCardProps {
|
||||||
@@ -112,6 +113,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
const [aiEnabled, setAiEnabled] = useState(false);
|
const [aiEnabled, setAiEnabled] = useState(false);
|
||||||
const [aiDefaultMessageWithVideo, setAiDefaultMessageWithVideo] = useState('');
|
const [aiDefaultMessageWithVideo, setAiDefaultMessageWithVideo] = useState('');
|
||||||
const [showDetailPanel, setShowDetailPanel] = useState(false);
|
const [showDetailPanel, setShowDetailPanel] = useState(false);
|
||||||
|
const [showImageViewer, setShowImageViewer] = useState(false);
|
||||||
|
|
||||||
// 检查AI功能是否启用
|
// 检查AI功能是否启用
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -734,6 +736,10 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
referrerPolicy='no-referrer'
|
referrerPolicy='no-referrer'
|
||||||
loading='lazy'
|
loading='lazy'
|
||||||
onLoadingComplete={() => setIsLoading(true)}
|
onLoadingComplete={() => setIsLoading(true)}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setShowImageViewer(true);
|
||||||
|
}}
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
// 图片加载失败时的重试机制
|
// 图片加载失败时的重试机制
|
||||||
const img = e.target as HTMLImageElement;
|
const img = e.target as HTMLImageElement;
|
||||||
@@ -749,7 +755,8 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
WebkitUserSelect: 'none',
|
WebkitUserSelect: 'none',
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
WebkitTouchCallout: 'none',
|
WebkitTouchCallout: 'none',
|
||||||
pointerEvents: 'none', // 图片不响应任何指针事件
|
pointerEvents: 'auto', // 改为auto以响应点击事件
|
||||||
|
cursor: 'pointer', // 添加指针样式
|
||||||
} as React.CSSProperties}
|
} as React.CSSProperties}
|
||||||
onContextMenu={(e) => {
|
onContextMenu={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -1478,6 +1485,9 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
currentEpisode={currentEpisode}
|
currentEpisode={currentEpisode}
|
||||||
totalEpisodes={actualEpisodes}
|
totalEpisodes={actualEpisodes}
|
||||||
origin={origin}
|
origin={origin}
|
||||||
|
onPosterClick={() => {
|
||||||
|
setShowImageViewer(true);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* AI问片面板 */}
|
{/* AI问片面板 */}
|
||||||
@@ -1515,6 +1525,16 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
source={source}
|
source={source}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 图片查看器 */}
|
||||||
|
{showImageViewer && (
|
||||||
|
<ImageViewer
|
||||||
|
isOpen={showImageViewer}
|
||||||
|
onClose={() => setShowImageViewer(false)}
|
||||||
|
imageUrl={processImageUrl(actualPoster)}
|
||||||
|
alt={actualTitle}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user