增加动漫数据源配置

This commit is contained in:
mtvpls
2026-05-28 16:00:07 +08:00
parent dc31a788fc
commit cc98b21d66
16 changed files with 4448 additions and 2153 deletions
+746 -399
View File
@@ -1,10 +1,22 @@
'use client';
import { Calendar, Clock, ExternalLink, Film, Globe, Images, Star, Tag, Users, X } from 'lucide-react';
import {
Calendar,
Clock,
ExternalLink,
Film,
Globe,
Images,
Star,
Tag,
Users,
X,
} from 'lucide-react';
import Image from 'next/image';
import React, { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { getBangumiSubject } from '@/lib/bangumi.client';
import { getTMDBImageUrl } from '@/lib/tmdb.client';
import { processImageUrl } from '@/lib/utils';
@@ -105,9 +117,14 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
const [detailData, setDetailData] = useState<DetailData | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [seasonData, setSeasonData] = useState<{ seasons: any[]; episodes: Episode[] } | null>(null);
const [seasonData, setSeasonData] = useState<{
seasons: any[];
episodes: Episode[];
} | null>(null);
const [loadingSeasons, setLoadingSeasons] = useState(false);
const [expandedEpisodes, setExpandedEpisodes] = useState<Set<number>>(new Set());
const [expandedEpisodes, setExpandedEpisodes] = useState<Set<number>>(
new Set()
);
const [selectedSeason, setSelectedSeason] = useState<number>(1);
const [seasonsLoaded, setSeasonsLoaded] = useState(false);
const [showImageViewer, setShowImageViewer] = useState(false);
@@ -122,12 +139,16 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
const [galleryViewportWidth, setGalleryViewportWidth] = useState(0);
const galleryScrollRef = React.useRef<HTMLDivElement>(null);
// 数据源状态管理
const [currentSource, setCurrentSource] = useState<'douban' | 'bangumi' | 'cms' | 'tmdb'>('tmdb');
const [originalSource, setOriginalSource] = useState<'douban' | 'bangumi' | 'cms' | 'tmdb'>('tmdb');
const [currentSource, setCurrentSource] = useState<
'douban' | 'bangumi' | 'cms' | 'tmdb'
>('tmdb');
const [originalSource, setOriginalSource] = useState<
'douban' | 'bangumi' | 'cms' | 'tmdb'
>('tmdb');
const [isUsingTmdb, setIsUsingTmdb] = useState(false);
const [originalDetailData, setOriginalDetailData] = useState<DetailData | null>(null);
const [originalDetailData, setOriginalDetailData] =
useState<DetailData | null>(null);
const getExternalUrl = () => {
if (currentSource === 'douban' && doubanId) {
@@ -387,14 +408,19 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
if (sourceId && source) {
try {
const response = await fetch(
`/api/source-detail?id=${encodeURIComponent(sourceId)}&source=${encodeURIComponent(source)}&title=${encodeURIComponent(title)}`
`/api/source-detail?id=${encodeURIComponent(
sourceId
)}&source=${encodeURIComponent(
source
)}&title=${encodeURIComponent(title)}`
);
if (response.ok) {
const data = await response.json();
const detailData = {
title: data.title || title,
intro: data.desc || '',
episodesCount: data.episodes?.length || cmsData.episodes?.length,
episodesCount:
data.episodes?.length || cmsData.episodes?.length,
poster: data.poster || poster,
year: data.year,
};
@@ -415,11 +441,10 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
setCurrentSource('bangumi');
setOriginalSource('bangumi');
const actualBangumiId = bangumiId || doubanId;
const response = await fetch(`https://api.bgm.tv/v0/subjects/${actualBangumiId}`);
if (!response.ok) {
throw new Error('获取Bangumi详情失败');
if (!actualBangumiId) {
throw new Error('Bangumi ID 缺失');
}
const data = await response.json();
const data = await getBangumiSubject(actualBangumiId);
const detailData = {
title: data.name_cn || data.name,
@@ -517,10 +542,19 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
const seasonStr = match[1];
// 中文数字转数字
const chineseNumbers: Record<string, number> = {
'一': 1, '二': 2, '三': 3, '四': 4, '五': 5,
'六': 6, '七': 7, '八': 8, '九': 9, '十': 10,
: 1,
: 2,
: 3,
: 4,
: 5,
: 6,
: 7,
: 8,
: 9,
: 10,
};
extractedSeasonNumber = chineseNumbers[seasonStr] || parseInt(seasonStr) || undefined;
extractedSeasonNumber =
chineseNumbers[seasonStr] || parseInt(seasonStr) || undefined;
}
break;
}
@@ -540,7 +574,9 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
const mediaType = result.media_type || type;
// 获取详情
const detailResponse = await fetch(`/api/tmdb/detail?id=${detailId}&type=${mediaType}`);
const detailResponse = await fetch(
`/api/tmdb/detail?id=${detailId}&type=${mediaType}`
);
if (!detailResponse.ok) {
throw new Error('获取TMDB详情失败');
}
@@ -566,17 +602,26 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
mediaType === 'movie'
? detailResult.title
: seasonData?.name
? `${detailResult.name} ${seasonData.name}`
: detailResult.name,
? `${detailResult.name} ${seasonData.name}`
: detailResult.name,
originalTitle:
mediaType === 'movie' ? detailResult.original_title : detailResult.original_name,
mediaType === 'movie'
? detailResult.original_title
: detailResult.original_name,
year:
mediaType === 'movie'
? detailResult.release_date?.substring(0, 4)
: seasonData?.air_date?.substring(0, 4) || detailResult.first_air_date?.substring(0, 4),
poster: (seasonData?.poster_path || detailResult.poster_path)
? processImageUrl(getTMDBImageUrl(seasonData?.poster_path || detailResult.poster_path, 'w500'))
: poster,
: seasonData?.air_date?.substring(0, 4) ||
detailResult.first_air_date?.substring(0, 4),
poster:
seasonData?.poster_path || detailResult.poster_path
? processImageUrl(
getTMDBImageUrl(
seasonData?.poster_path || detailResult.poster_path,
'w500'
)
)
: poster,
rating: detailResult.vote_average
? {
value: detailResult.vote_average,
@@ -587,8 +632,11 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
genres: detailResult.genres?.map((g: any) => g.name),
countries: detailResult.production_countries?.map((c: any) => c.name),
languages: detailResult.spoken_languages?.map((l: any) => l.name),
duration: detailResult.runtime ? `${detailResult.runtime}分钟` : undefined,
episodesCount: seasonData?.episodes?.length || detailResult.number_of_episodes,
duration: detailResult.runtime
? `${detailResult.runtime}分钟`
: undefined,
episodesCount:
seasonData?.episodes?.length || detailResult.number_of_episodes,
releaseDate:
mediaType === 'movie'
? detailResult.release_date
@@ -609,7 +657,21 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
};
fetchDetail();
}, [isOpen, doubanId, bangumiId, isBangumi, tmdbId, title, type, seasonNumber, poster, cmsData, sourceId, source, isUsingTmdb]);
}, [
isOpen,
doubanId,
bangumiId,
isBangumi,
tmdbId,
title,
type,
seasonNumber,
poster,
cmsData,
sourceId,
source,
isUsingTmdb,
]);
// 切换数据源的函数
const handleToggleSource = async () => {
@@ -664,10 +726,19 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
const seasonStr = match[1];
// 中文数字转数字
const chineseNumbers: Record<string, number> = {
'一': 1, '二': 2, '三': 3, '四': 4, '五': 5,
'六': 6, '七': 7, '八': 8, '九': 9, '十': 10,
: 1,
: 2,
: 3,
: 4,
: 5,
: 6,
: 7,
: 8,
: 9,
: 10,
};
extractedSeasonNumber = chineseNumbers[seasonStr] || parseInt(seasonStr) || undefined;
extractedSeasonNumber =
chineseNumbers[seasonStr] || parseInt(seasonStr) || undefined;
}
break;
}
@@ -687,7 +758,9 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
const mediaType = result.media_type || type;
// 获取详情
const detailResponse = await fetch(`/api/tmdb/detail?id=${detailId}&type=${mediaType}`);
const detailResponse = await fetch(
`/api/tmdb/detail?id=${detailId}&type=${mediaType}`
);
if (!detailResponse.ok) {
throw new Error('获取TMDB详情失败');
}
@@ -713,17 +786,26 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
mediaType === 'movie'
? detailResult.title
: seasonData?.name
? `${detailResult.name} ${seasonData.name}`
: detailResult.name,
? `${detailResult.name} ${seasonData.name}`
: detailResult.name,
originalTitle:
mediaType === 'movie' ? detailResult.original_title : detailResult.original_name,
mediaType === 'movie'
? detailResult.original_title
: detailResult.original_name,
year:
mediaType === 'movie'
? detailResult.release_date?.substring(0, 4)
: seasonData?.air_date?.substring(0, 4) || detailResult.first_air_date?.substring(0, 4),
poster: (seasonData?.poster_path || detailResult.poster_path)
? processImageUrl(getTMDBImageUrl(seasonData?.poster_path || detailResult.poster_path, 'w500'))
: poster,
: seasonData?.air_date?.substring(0, 4) ||
detailResult.first_air_date?.substring(0, 4),
poster:
seasonData?.poster_path || detailResult.poster_path
? processImageUrl(
getTMDBImageUrl(
seasonData?.poster_path || detailResult.poster_path,
'w500'
)
)
: poster,
rating: detailResult.vote_average
? {
value: detailResult.vote_average,
@@ -734,8 +816,11 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
genres: detailResult.genres?.map((g: any) => g.name),
countries: detailResult.production_countries?.map((c: any) => c.name),
languages: detailResult.spoken_languages?.map((l: any) => l.name),
duration: detailResult.runtime ? `${detailResult.runtime}分钟` : undefined,
episodesCount: seasonData?.episodes?.length || detailResult.number_of_episodes,
duration: detailResult.runtime
? `${detailResult.runtime}分钟`
: undefined,
episodesCount:
seasonData?.episodes?.length || detailResult.number_of_episodes,
releaseDate:
mediaType === 'movie'
? detailResult.release_date
@@ -758,7 +843,12 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
// 异步获取季度和集数详情(仅TMDB)
useEffect(() => {
if (!detailData?.tmdbId || !detailData?.mediaType || detailData.mediaType !== 'tv' || seasonsLoaded) {
if (
!detailData?.tmdbId ||
!detailData?.mediaType ||
detailData.mediaType !== 'tv' ||
seasonsLoaded
) {
return;
}
@@ -766,7 +856,9 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
setLoadingSeasons(true);
try {
// 获取所有季度
const seasonsResponse = await fetch(`/api/tmdb/seasons?tvId=${detailData.tmdbId}`);
const seasonsResponse = await fetch(
`/api/tmdb/seasons?tvId=${detailData.tmdbId}`
);
if (!seasonsResponse.ok) return;
const seasonsData = await seasonsResponse.json();
@@ -794,24 +886,36 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
};
fetchSeasonData();
}, [detailData?.tmdbId, detailData?.mediaType, detailData?.seasonNumber, seasonsLoaded]);
}, [
detailData?.tmdbId,
detailData?.mediaType,
detailData?.seasonNumber,
seasonsLoaded,
]);
// 自动滚动到当前集数
useEffect(() => {
if (!currentEpisode || !seasonData?.episodes || !episodesScrollRef.current || currentSource !== 'tmdb') {
if (
!currentEpisode ||
!seasonData?.episodes ||
!episodesScrollRef.current ||
currentSource !== 'tmdb'
) {
return;
}
// 等待 DOM 更新后再滚动
const timer = setTimeout(() => {
const episodeElement = document.getElementById(`episode-${currentEpisode}`);
const episodeElement = document.getElementById(
`episode-${currentEpisode}`
);
if (episodeElement && episodesScrollRef.current) {
// 计算滚动位置,使当前集数居中显示
const container = episodesScrollRef.current;
const elementLeft = episodeElement.offsetLeft;
const elementWidth = episodeElement.offsetWidth;
const containerWidth = container.offsetWidth;
const scrollLeft = elementLeft - (containerWidth / 2) + (elementWidth / 2);
const scrollLeft = elementLeft - containerWidth / 2 + elementWidth / 2;
container.scrollLeft = scrollLeft;
}
@@ -822,7 +926,11 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
// 异步获取演职人员信息(仅TMDB)
useEffect(() => {
if (!detailData?.tmdbId || !detailData?.mediaType || currentSource !== 'tmdb') {
if (
!detailData?.tmdbId ||
!detailData?.mediaType ||
currentSource !== 'tmdb'
) {
return;
}
@@ -840,30 +948,39 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
const creditsData = await creditsResponse.json();
// 更新演员和导演信息
setDetailData(prev => prev ? {
...prev,
directors: creditsData.crew
?.filter((person: any) => person.job === 'Director')
.slice(0, 5)
.map((person: any) => ({
name: person.name,
profile_path: person.profile_path,
})) || prev.directors,
actors: creditsData.cast
?.slice(0, 15)
.map((person: any) => ({
name: person.name,
character: person.character,
profile_path: person.profile_path,
})) || prev.actors,
} : null);
setDetailData((prev) =>
prev
? {
...prev,
directors:
creditsData.crew
?.filter((person: any) => person.job === 'Director')
.slice(0, 5)
.map((person: any) => ({
name: person.name,
profile_path: person.profile_path,
})) || prev.directors,
actors:
creditsData.cast?.slice(0, 15).map((person: any) => ({
name: person.name,
character: person.character,
profile_path: person.profile_path,
})) || prev.actors,
}
: null
);
} catch (err) {
console.error('获取演职人员信息失败:', err);
}
};
fetchCredits();
}, [detailData?.tmdbId, detailData?.mediaType, currentSource, detailData?.actors]);
}, [
detailData?.tmdbId,
detailData?.mediaType,
currentSource,
detailData?.actors,
]);
// 切换季度时获取集数
const handleSeasonChange = async (seasonNumber: number) => {
@@ -879,25 +996,43 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
const episodesData = await episodesResponse.json();
// 从当前 seasonData 中查找季度信息
const season = seasonData?.seasons.find((s: any) => s.season_number === seasonNumber);
const season = seasonData?.seasons.find(
(s: any) => s.season_number === seasonNumber
);
setSeasonData(prev => ({
setSeasonData((prev) => ({
seasons: prev?.seasons || [],
episodes: episodesData.episodes || [],
}));
// 更新季度元信息
setDetailData(prev => prev ? {
...prev,
title: (episodesData.name || season?.name)
? `${prev.seriesTitle || prev.title} ${episodesData.name || season?.name}`
: prev.title,
intro: episodesData.overview || season?.overview || prev.overview,
poster: season?.poster_path ? getTMDBImageUrl(season.poster_path, 'w500') : prev.poster,
releaseDate: episodesData.air_date || season?.air_date || prev.releaseDate,
year: episodesData.air_date?.substring(0, 4) || season?.air_date?.substring(0, 4) || prev.year,
episodesCount: episodesData.episodes?.length || season?.episode_count || prev.episodesCount,
} : null);
setDetailData((prev) =>
prev
? {
...prev,
title:
episodesData.name || season?.name
? `${prev.seriesTitle || prev.title} ${
episodesData.name || season?.name
}`
: prev.title,
intro: episodesData.overview || season?.overview || prev.overview,
poster: season?.poster_path
? getTMDBImageUrl(season.poster_path, 'w500')
: prev.poster,
releaseDate:
episodesData.air_date || season?.air_date || prev.releaseDate,
year:
episodesData.air_date?.substring(0, 4) ||
season?.air_date?.substring(0, 4) ||
prev.year,
episodesCount:
episodesData.episodes?.length ||
season?.episode_count ||
prev.episodesCount,
}
: null
);
setExpandedEpisodes(new Set());
} catch (err) {
@@ -1006,7 +1141,7 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
const galleryEntryButton = canShowGalleryEntry ? (
<button
onClick={openGallery}
className="inline-flex items-center gap-2 px-3 py-1.5 text-sm rounded-lg bg-blue-500 hover:bg-blue-600 text-white transition-colors"
className='inline-flex items-center gap-2 px-3 py-1.5 text-sm rounded-lg bg-blue-500 hover:bg-blue-600 text-white transition-colors'
>
<Images size={16} />
@@ -1016,7 +1151,15 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
const virtualGalleryLayout = React.useMemo(() => {
if (galleryImages.length === 0 || galleryViewportWidth <= 0) {
return {
visibleItems: [] as Array<GalleryImage & { top: number; left: number; renderWidth: number; renderHeight: number; index: number }>,
visibleItems: [] as Array<
GalleryImage & {
top: number;
left: number;
renderWidth: number;
renderHeight: number;
index: number;
}
>,
totalHeight: 0,
usedWidth: 0,
};
@@ -1026,8 +1169,11 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
const overscan = 800;
const horizontalPadding = 32;
const width = Math.max(galleryViewportWidth - horizontalPadding, 0);
const columnCount = width >= 1280 ? 5 : width >= 1024 ? 4 : width >= 640 ? 3 : 2;
const columnWidth = Math.floor((width - gap * (columnCount - 1)) / columnCount);
const columnCount =
width >= 1280 ? 5 : width >= 1024 ? 4 : width >= 640 ? 3 : 2;
const columnWidth = Math.floor(
(width - gap * (columnCount - 1)) / columnCount
);
const usedWidth = columnWidth * columnCount + gap * (columnCount - 1);
const columnHeights = new Array(columnCount).fill(0);
@@ -1039,7 +1185,12 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
}
}
const ratio = image.width && image.height ? image.height / image.width : (image.imageType === 'poster' ? 1.5 : 0.5625);
const ratio =
image.width && image.height
? image.height / image.width
: image.imageType === 'poster'
? 1.5
: 0.5625;
const renderHeight = Math.max(Math.round(columnWidth * ratio), 80);
const top = columnHeights[targetColumn];
const left = targetColumn * (columnWidth + gap);
@@ -1058,32 +1209,52 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
const totalHeight = Math.max(...columnHeights, 0);
const minVisibleTop = Math.max(galleryScrollTop - overscan, 0);
const maxVisibleBottom = galleryScrollTop + galleryViewportHeight + overscan;
const visibleItems = items.filter(item => item.top + item.renderHeight >= minVisibleTop && item.top <= maxVisibleBottom);
const maxVisibleBottom =
galleryScrollTop + galleryViewportHeight + overscan;
const visibleItems = items.filter(
(item) =>
item.top + item.renderHeight >= minVisibleTop &&
item.top <= maxVisibleBottom
);
return { visibleItems, totalHeight, usedWidth };
}, [galleryImages, galleryScrollTop, galleryViewportHeight, galleryViewportWidth]);
}, [
galleryImages,
galleryScrollTop,
galleryViewportHeight,
galleryViewportWidth,
]);
const galleryBody = (
<div ref={galleryScrollRef} className="flex-1 overflow-y-auto overflow-x-hidden p-4">
<div
ref={galleryScrollRef}
className='flex-1 overflow-y-auto overflow-x-hidden p-4'
>
{galleryLoading && (
<div className="flex items-center justify-center py-20">
<div className="animate-spin rounded-full h-10 w-10 border-b-2 border-green-500"></div>
<div className='flex items-center justify-center py-20'>
<div className='animate-spin rounded-full h-10 w-10 border-b-2 border-green-500'></div>
</div>
)}
{!galleryLoading && galleryError && (
<div className="text-center py-12 text-red-500 dark:text-red-400">{galleryError}</div>
<div className='text-center py-12 text-red-500 dark:text-red-400'>
{galleryError}
</div>
)}
{!galleryLoading && !galleryError && galleryImages.length === 0 && (
<div className="text-center py-12 text-gray-500 dark:text-gray-400"></div>
<div className='text-center py-12 text-gray-500 dark:text-gray-400'>
</div>
)}
{!galleryLoading && !galleryError && galleryImages.length > 0 && (
<div
className="relative mx-auto"
style={{ height: virtualGalleryLayout.totalHeight, width: virtualGalleryLayout.usedWidth || '100%' }}
className='relative mx-auto'
style={{
height: virtualGalleryLayout.totalHeight,
width: virtualGalleryLayout.usedWidth || '100%',
}}
>
{virtualGalleryLayout.visibleItems.map((image) => {
const imageUrl = getTMDBImageUrl(
@@ -1098,7 +1269,7 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
return (
<div
key={`${image.imageType}-${image.file_path}-${image.index}`}
className="group absolute"
className='group absolute'
style={{
top: image.top,
left: image.left,
@@ -1107,16 +1278,18 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
}}
>
<div
className="relative w-full h-full overflow-hidden rounded-md bg-gray-100 dark:bg-gray-800 cursor-pointer hover:opacity-90 transition-opacity"
className='relative w-full h-full overflow-hidden rounded-md bg-gray-100 dark:bg-gray-800 cursor-pointer hover:opacity-90 transition-opacity'
onClick={() => handleImageClick(imageUrl)}
>
<ProxyImage
originalSrc={thumbUrl}
alt={`${detailData?.title || title}-gallery-${image.index + 1}`}
className="absolute inset-0 w-full h-full object-cover"
alt={`${detailData?.title || title}-gallery-${
image.index + 1
}`}
className='absolute inset-0 w-full h-full object-cover'
draggable={false}
/>
<div className="absolute left-2 top-2 px-2 py-0.5 rounded-full text-xs bg-black/60 text-white">
<div className='absolute left-2 top-2 px-2 py-0.5 rounded-full text-xs bg-black/60 text-white'>
{image.imageType === 'poster' ? '海报' : '剧照'}
</div>
</div>
@@ -1129,49 +1302,55 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
);
const galleryHeader = (
<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>
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100"></h3>
<h3 className='text-lg font-semibold text-gray-900 dark:text-gray-100'>
</h3>
{!galleryLoading && (
<p className="text-sm text-gray-500 dark:text-gray-400">
<p className='text-sm text-gray-500 dark:text-gray-400'>
{galleryTotal}
</p>
)}
</div>
<button
onClick={() => setShowGallery(false)}
className="p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
aria-label="关闭照片墙"
className='p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors'
aria-label='关闭照片墙'
>
<X size={20} className="text-gray-500 dark:text-gray-400" />
<X size={20} className='text-gray-500 dark:text-gray-400' />
</button>
</div>
);
const galleryModal = showGallery ? (useDrawer ? (
<div className="fixed inset-0 z-[10000] flex items-center justify-end pointer-events-none">
<div className={`relative ${drawerWidth} h-full bg-white dark:bg-gray-900 shadow-2xl overflow-hidden flex flex-col pointer-events-auto`}>
{galleryHeader}
{galleryBody}
const galleryModal = showGallery ? (
useDrawer ? (
<div className='fixed inset-0 z-[10000] flex items-center justify-end pointer-events-none'>
<div
className={`relative ${drawerWidth} h-full bg-white dark:bg-gray-900 shadow-2xl overflow-hidden flex flex-col pointer-events-auto`}
>
{galleryHeader}
{galleryBody}
</div>
</div>
</div>
) : (
<div className="fixed inset-0 z-[10000] flex items-center justify-center p-4">
<div
className="absolute inset-0 bg-black/60"
onClick={() => setShowGallery(false)}
/>
<div className="relative w-full max-w-6xl max-h-[90vh] bg-white dark:bg-gray-900 rounded-2xl shadow-2xl overflow-hidden flex flex-col">
{galleryHeader}
{galleryBody}
) : (
<div className='fixed inset-0 z-[10000] flex items-center justify-center p-4'>
<div
className='absolute inset-0 bg-black/60'
onClick={() => setShowGallery(false)}
/>
<div className='relative w-full max-w-6xl max-h-[90vh] bg-white dark:bg-gray-900 rounded-2xl shadow-2xl overflow-hidden flex flex-col'>
{galleryHeader}
{galleryBody}
</div>
</div>
</div>
)) : null;
)
) : null;
if (!isVisible || !mounted) return null;
const content = useDrawer ? (
<div className="fixed inset-0 z-[9999] flex items-center justify-end pointer-events-none">
<div className='fixed inset-0 z-[9999] flex items-center justify-end pointer-events-none'>
{/* 详情面板 - 抽屉模式 */}
<div
className={`relative ${drawerWidth} h-full bg-white dark:bg-gray-900 shadow-2xl overflow-hidden flex flex-col transition-transform duration-300 ease-out pointer-events-auto ${
@@ -1179,76 +1358,92 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
}`}
>
{/* 头部 */}
<div className="flex items-center justify-between p-4 border-b border-gray-100 dark:border-gray-800 sticky top-0 bg-white dark:bg-gray-900 z-10">
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100"></h2>
<div className="flex items-center gap-2">
<div className='flex items-center justify-between p-4 border-b border-gray-100 dark:border-gray-800 sticky top-0 bg-white dark:bg-gray-900 z-10'>
<h2 className='text-xl font-semibold text-gray-900 dark:text-gray-100'>
</h2>
<div className='flex items-center gap-2'>
{externalUrl && (
<button
onClick={() => window.open(externalUrl, '_blank', 'noopener,noreferrer')}
className="p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors duration-150"
title="打开外部页面"
aria-label="打开外部页面"
onClick={() =>
window.open(externalUrl, '_blank', 'noopener,noreferrer')
}
className='p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors duration-150'
title='打开外部页面'
aria-label='打开外部页面'
>
<ExternalLink size={18} className="text-gray-500 dark:text-gray-400" />
<ExternalLink
size={18}
className='text-gray-500 dark:text-gray-400'
/>
</button>
)}
<button
onClick={onClose}
className="p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors duration-150"
title="关闭"
aria-label="关闭"
className='p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors duration-150'
title='关闭'
aria-label='关闭'
>
<X size={20} className="text-gray-500 dark:text-gray-400" />
<X size={20} className='text-gray-500 dark:text-gray-400' />
</button>
</div>
</div>
{/* 内容区域 */}
<div className="overflow-y-auto max-h-[calc(90vh-4rem)]">
<div className='overflow-y-auto max-h-[calc(90vh-4rem)]'>
{loading && (
<div className="flex items-center justify-center py-20">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-green-500"></div>
<div className='flex items-center justify-center py-20'>
<div className='animate-spin rounded-full h-12 w-12 border-b-2 border-green-500'></div>
</div>
)}
{error && (
<div className="p-6">
<div className="text-center mb-6">
<p className="text-red-500 dark:text-red-400">{error}</p>
<div className='p-6'>
<div className='text-center mb-6'>
<p className='text-red-500 dark:text-red-400'>{error}</p>
</div>
{/* 数据源显示和切换 - 错误时也显示 */}
<div className="mt-6 pt-4 border-t border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between gap-3 flex-wrap">
<div className="flex items-center gap-2">
<span className="text-sm text-gray-500 dark:text-gray-400">:</span>
<span className="text-sm font-medium text-gray-700 dark:text-gray-300 uppercase">
<div className='mt-6 pt-4 border-t border-gray-200 dark:border-gray-700'>
<div className='flex items-center justify-between gap-3 flex-wrap'>
<div className='flex items-center gap-2'>
<span className='text-sm text-gray-500 dark:text-gray-400'>
:
</span>
<span className='text-sm font-medium text-gray-700 dark:text-gray-300 uppercase'>
{currentSource === 'douban' && 'Douban'}
{currentSource === 'bangumi' && 'Bangumi'}
{currentSource === 'cms' && 'CMS'}
{currentSource === 'tmdb' && 'TMDB'}
</span>
</div>
<div className="flex items-center gap-2 flex-wrap">
<div className='flex items-center gap-2 flex-wrap'>
{galleryEntryButton}
{currentSource !== 'tmdb' && (
<button
onClick={handleToggleSource}
disabled={loading}
className="px-3 py-1.5 text-sm rounded-lg bg-green-500 hover:bg-green-600 text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
className='px-3 py-1.5 text-sm rounded-lg bg-green-500 hover:bg-green-600 text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed'
>
TMDB
</button>
)}
{currentSource === 'tmdb' && originalSource !== 'tmdb' && originalDetailData && (
<button
onClick={handleToggleSource}
disabled={loading}
className="px-3 py-1.5 text-sm rounded-lg bg-gray-500 hover:bg-gray-600 text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{originalSource === 'douban' ? 'Douban' : originalSource === 'bangumi' ? 'Bangumi' : 'CMS'}
</button>
)}
{currentSource === 'tmdb' &&
originalSource !== 'tmdb' &&
originalDetailData && (
<button
onClick={handleToggleSource}
disabled={loading}
className='px-3 py-1.5 text-sm rounded-lg bg-gray-500 hover:bg-gray-600 text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed'
>
{' '}
{originalSource === 'douban'
? 'Douban'
: originalSource === 'bangumi'
? 'Bangumi'
: 'CMS'}
</button>
)}
</div>
</div>
</div>
@@ -1256,47 +1451,48 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
)}
{!loading && !error && detailData && (
<div className="p-6">
<div className='p-6'>
{/* 海报和基本信息 */}
<div className="flex gap-6 mb-6">
<div className='flex gap-6 mb-6'>
{detailData.poster && (
<div className="flex flex-col items-start gap-3 flex-shrink-0">
<div className='flex flex-col items-start gap-3 flex-shrink-0'>
<div
className="relative w-32 h-48 rounded-lg overflow-hidden bg-gray-100 dark:bg-gray-800 cursor-pointer hover:opacity-90 transition-opacity"
className='relative w-32 h-48 rounded-lg overflow-hidden bg-gray-100 dark:bg-gray-800 cursor-pointer hover:opacity-90 transition-opacity'
onClick={() => handleImageClick(detailData.poster!)}
>
<ProxyImage
originalSrc={detailData.poster}
alt={detailData.title}
className="absolute inset-0 w-full h-full object-cover"
className='absolute inset-0 w-full h-full object-cover'
draggable={false}
/>
</div>
{galleryEntryButton}
</div>
)}
<div className="flex-1 min-w-0">
<h3 className="text-2xl font-bold text-gray-900 dark:text-gray-100 mb-2">
<div className='flex-1 min-w-0'>
<h3 className='text-2xl font-bold text-gray-900 dark:text-gray-100 mb-2'>
{detailData.title}
</h3>
{detailData.originalTitle && detailData.originalTitle !== detailData.title && (
<p className="text-sm text-gray-500 dark:text-gray-400 mb-3">
{detailData.originalTitle}
</p>
)}
{detailData.originalTitle &&
detailData.originalTitle !== detailData.title && (
<p className='text-sm text-gray-500 dark:text-gray-400 mb-3'>
{detailData.originalTitle}
</p>
)}
{/* 评分 */}
{detailData.rating && (
<div className="flex items-center gap-2 mb-3">
<div className='flex items-center gap-2 mb-3'>
<Star
size={20}
className="text-yellow-500 fill-yellow-500"
className='text-yellow-500 fill-yellow-500'
/>
<span className="text-lg font-semibold text-gray-900 dark:text-gray-100">
<span className='text-lg font-semibold text-gray-900 dark:text-gray-100'>
{detailData.rating.value.toFixed(1)}
</span>
{detailData.rating.count > 0 && (
<span className="text-sm text-gray-500 dark:text-gray-400">
<span className='text-sm text-gray-500 dark:text-gray-400'>
({detailData.rating.count} )
</span>
)}
@@ -1305,11 +1501,11 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
{/* 类型标签 */}
{detailData.genres && detailData.genres.length > 0 && (
<div className="flex flex-wrap gap-2 mb-3">
<div className='flex flex-wrap gap-2 mb-3'>
{detailData.genres.map((genre, index) => (
<span
key={index}
className="px-2 py-1 text-xs rounded bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300"
className='px-2 py-1 text-xs rounded bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300'
>
{genre}
</span>
@@ -1318,21 +1514,21 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
)}
{/* 年份和时长 */}
<div className="flex flex-wrap gap-4 text-sm text-gray-600 dark:text-gray-400">
<div className='flex flex-wrap gap-4 text-sm text-gray-600 dark:text-gray-400'>
{detailData.year && (
<div className="flex items-center gap-1">
<div className='flex items-center gap-1'>
<Calendar size={16} />
<span>{detailData.year}</span>
</div>
)}
{detailData.duration && (
<div className="flex items-center gap-1">
<div className='flex items-center gap-1'>
<Clock size={16} />
<span>{detailData.duration}</span>
</div>
)}
{detailData.episodesCount && (
<div className="flex items-center gap-1">
<div className='flex items-center gap-1'>
<Film size={16} />
<span>{detailData.episodesCount} </span>
</div>
@@ -1343,11 +1539,11 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
{/* 简介 */}
{(detailData.intro || detailData.overview) && (
<div className="mb-6">
<h4 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">
<div className='mb-6'>
<h4 className='text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2'>
</h4>
<p className="text-gray-700 dark:text-gray-300 leading-relaxed whitespace-pre-wrap">
<p className='text-gray-700 dark:text-gray-300 leading-relaxed whitespace-pre-wrap'>
{detailData.intro || detailData.overview}
</p>
</div>
@@ -1355,20 +1551,20 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
{/* 导演和演员 */}
{detailData.directors && detailData.directors.length > 0 && (
<div className="mb-4">
<h4 className="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-2 flex items-center gap-2">
<div className='mb-4'>
<h4 className='text-sm font-semibold text-gray-900 dark:text-gray-100 mb-2 flex items-center gap-2'>
<Users size={16} />
</h4>
<p className="text-gray-700 dark:text-gray-300">
<p className='text-gray-700 dark:text-gray-300'>
{detailData.directors.map((d) => d.name).join(', ')}
</p>
</div>
)}
{detailData.actors && detailData.actors.length > 0 && (
<div className="mb-4">
<h4 className="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-2 flex items-center gap-2">
<div className='mb-4'>
<h4 className='text-sm font-semibold text-gray-900 dark:text-gray-100 mb-2 flex items-center gap-2'>
<Users size={16} />
</h4>
@@ -1379,47 +1575,61 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
onMouseMove={handleActorsMouseMove}
onMouseUp={handleActorsMouseUp}
onMouseLeave={handleActorsMouseLeave}
className="overflow-x-auto -mx-6 px-6 cursor-grab active:cursor-grabbing"
className='overflow-x-auto -mx-6 px-6 cursor-grab active:cursor-grabbing'
style={{
scrollbarWidth: 'thin',
scrollBehavior: isActorsDragging ? 'auto' : 'smooth'
scrollBehavior: isActorsDragging ? 'auto' : 'smooth',
}}
>
<div className="flex gap-4 pb-2">
<div className='flex gap-4 pb-2'>
{detailData.actors.map((actor, index) => (
<div
key={index}
className="flex flex-col items-center flex-shrink-0"
style={{ pointerEvents: isActorsDragging ? 'none' : 'auto' }}
className='flex flex-col items-center flex-shrink-0'
style={{
pointerEvents: isActorsDragging ? 'none' : 'auto',
}}
>
{actor.profile_path ? (
<div
className="relative w-20 h-20 rounded-full overflow-hidden bg-gray-200 dark:bg-gray-700 mb-2 cursor-pointer hover:opacity-80 transition-opacity"
onClick={() => handleImageClick(getTMDBImageUrl(actor.profile_path || null, 'w185'))}
className='relative w-20 h-20 rounded-full overflow-hidden bg-gray-200 dark:bg-gray-700 mb-2 cursor-pointer hover:opacity-80 transition-opacity'
onClick={() =>
handleImageClick(
getTMDBImageUrl(
actor.profile_path || null,
'w185'
)
)
}
>
<ProxyImage
originalSrc={getTMDBImageUrl(actor.profile_path || null, 'w185')}
originalSrc={getTMDBImageUrl(
actor.profile_path || null,
'w185'
)}
alt={actor.name}
className="absolute inset-0 w-full h-full object-cover"
className='absolute inset-0 w-full h-full object-cover'
draggable={false}
/>
</div>
) : (
<div className="w-20 h-20 rounded-full bg-gray-200 dark:bg-gray-700 mb-2 flex items-center justify-center">
<Users size={28} className="text-gray-400" />
<div className='w-20 h-20 rounded-full bg-gray-200 dark:bg-gray-700 mb-2 flex items-center justify-center'>
<Users size={28} className='text-gray-400' />
</div>
)}
<a
href={`https://baike.baidu.com/item/${encodeURIComponent(actor.name)}`}
target="_blank"
rel="noopener noreferrer"
className="text-xs font-medium text-gray-900 dark:text-gray-100 text-center w-20 line-clamp-2 hover:text-green-600 dark:hover:text-green-400 transition-colors cursor-pointer"
href={`https://baike.baidu.com/item/${encodeURIComponent(
actor.name
)}`}
target='_blank'
rel='noopener noreferrer'
className='text-xs font-medium text-gray-900 dark:text-gray-100 text-center w-20 line-clamp-2 hover:text-green-600 dark:hover:text-green-400 transition-colors cursor-pointer'
onClick={(e) => e.stopPropagation()}
>
{actor.name}
</a>
{actor.character && (
<p className="text-xs text-gray-500 dark:text-gray-400 text-center w-20 line-clamp-2">
<p className='text-xs text-gray-500 dark:text-gray-400 text-center w-20 line-clamp-2'>
{actor.character}
</p>
)}
@@ -1428,22 +1638,25 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
</div>
</div>
) : (
<p className="text-gray-700 dark:text-gray-300">
{detailData.actors.slice(0, 10).map((a) => a.name).join(', ')}
<p className='text-gray-700 dark:text-gray-300'>
{detailData.actors
.slice(0, 10)
.map((a) => a.name)
.join(', ')}
</p>
)}
</div>
)}
{/* 制作信息 */}
<div className="grid grid-cols-2 gap-4 text-sm">
<div className='grid grid-cols-2 gap-4 text-sm'>
{detailData.countries && detailData.countries.length > 0 && (
<div>
<h4 className="font-semibold text-gray-900 dark:text-gray-100 mb-1 flex items-center gap-1">
<h4 className='font-semibold text-gray-900 dark:text-gray-100 mb-1 flex items-center gap-1'>
<Globe size={14} />
/
</h4>
<p className="text-gray-700 dark:text-gray-300">
<p className='text-gray-700 dark:text-gray-300'>
{detailData.countries.join(', ')}
</p>
</div>
@@ -1451,11 +1664,11 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
{detailData.languages && detailData.languages.length > 0 && (
<div>
<h4 className="font-semibold text-gray-900 dark:text-gray-100 mb-1 flex items-center gap-1">
<h4 className='font-semibold text-gray-900 dark:text-gray-100 mb-1 flex items-center gap-1'>
<Tag size={14} />
</h4>
<p className="text-gray-700 dark:text-gray-300">
<p className='text-gray-700 dark:text-gray-300'>
{detailData.languages.join(', ')}
</p>
</div>
@@ -1463,28 +1676,34 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
{detailData.releaseDate && (
<div>
<h4 className="font-semibold text-gray-900 dark:text-gray-100 mb-1 flex items-center gap-1">
<h4 className='font-semibold text-gray-900 dark:text-gray-100 mb-1 flex items-center gap-1'>
<Calendar size={14} />
</h4>
<p className="text-gray-700 dark:text-gray-300">{detailData.releaseDate}</p>
<p className='text-gray-700 dark:text-gray-300'>
{detailData.releaseDate}
</p>
</div>
)}
{detailData.status && (
<div>
<h4 className="font-semibold text-gray-900 dark:text-gray-100 mb-1"></h4>
<p className="text-gray-700 dark:text-gray-300">{detailData.status}</p>
<h4 className='font-semibold text-gray-900 dark:text-gray-100 mb-1'>
</h4>
<p className='text-gray-700 dark:text-gray-300'>
{detailData.status}
</p>
</div>
)}
</div>
{/* 季度和集数信息(仅TMDB电视剧) */}
{detailData.mediaType === 'tv' && (
<div className="mt-6">
<div className='mt-6'>
{loadingSeasons && (
<div className="flex items-center justify-center py-4">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-green-500"></div>
<div className='flex items-center justify-center py-4'>
<div className='animate-spin rounded-full h-8 w-8 border-b-2 border-green-500'></div>
</div>
)}
@@ -1492,15 +1711,17 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
<>
{/* 季度列表 */}
{seasonData.seasons.length > 0 && (
<div className="mb-6">
<h4 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3">
<div className='mb-6'>
<h4 className='text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3'>
</h4>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
<div className='grid grid-cols-2 sm:grid-cols-3 gap-3'>
{seasonData.seasons.map((season: any) => (
<div
key={season.id}
onClick={() => handleSeasonChange(season.season_number)}
onClick={() =>
handleSeasonChange(season.season_number)
}
className={`flex items-center gap-2 p-2 rounded cursor-pointer transition-colors ${
selectedSeason === season.season_number
? 'bg-green-100 dark:bg-green-900/30 ring-2 ring-green-500'
@@ -1509,25 +1730,33 @@ 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 hover:opacity-80 transition-opacity"
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(getTMDBImageUrl(season.poster_path, 'w500'));
handleImageClick(
getTMDBImageUrl(
season.poster_path,
'w500'
)
);
}}
>
<ProxyImage
originalSrc={getTMDBImageUrl(season.poster_path, 'w92')}
originalSrc={getTMDBImageUrl(
season.poster_path,
'w92'
)}
alt={season.name}
className="absolute inset-0 w-full h-full object-cover"
className='absolute inset-0 w-full h-full object-cover'
draggable={false}
/>
</div>
)}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
<div className='flex-1 min-w-0'>
<p className='text-sm font-medium text-gray-900 dark:text-gray-100 truncate'>
{season.name}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
<p className='text-xs text-gray-500 dark:text-gray-400'>
{season.episode_count}
</p>
</div>
@@ -1540,8 +1769,10 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
{/* 集数列表 */}
{seasonData.episodes.length > 0 && (
<div>
<h4 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3">
{seasonData.seasons.find((s: any) => s.season_number === selectedSeason)?.name || `${selectedSeason}`}
<h4 className='text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3'>
{seasonData.seasons.find(
(s: any) => s.season_number === selectedSeason
)?.name || `${selectedSeason}`}
</h4>
<div
ref={episodesScrollRef}
@@ -1549,16 +1780,19 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseLeave}
className="overflow-x-auto -mx-6 px-6 cursor-grab active:cursor-grabbing"
className='overflow-x-auto -mx-6 px-6 cursor-grab active:cursor-grabbing'
style={{
scrollbarWidth: 'thin',
scrollBehavior: isDragging ? 'auto' : 'smooth'
scrollBehavior: isDragging ? 'auto' : 'smooth',
}}
>
<div className="flex gap-3 py-2">
<div className='flex gap-3 py-2'>
{seasonData.episodes.map((episode: Episode) => {
const isExpanded = expandedEpisodes.has(episode.id);
const isCurrentEpisode = currentEpisode === episode.episode_number;
const isExpanded = expandedEpisodes.has(
episode.id
);
const isCurrentEpisode =
currentEpisode === episode.episode_number;
return (
<div
key={episode.id}
@@ -1568,28 +1802,45 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
? 'bg-green-100 dark:bg-green-900/30 ring-2 ring-green-500'
: 'bg-gray-50 dark:bg-gray-800'
}`}
style={{ pointerEvents: isDragging ? 'none' : 'auto' }}
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 cursor-pointer hover:opacity-90 transition-opacity"
onClick={() => handleImageClick(getTMDBImageUrl(episode.still_path, 'w500'))}
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(
getTMDBImageUrl(
episode.still_path,
'w500'
)
)
}
>
<ProxyImage
originalSrc={getTMDBImageUrl(episode.still_path, 'w300')}
originalSrc={getTMDBImageUrl(
episode.still_path,
'w300'
)}
alt={episode.name}
className="absolute inset-0 w-full h-full object-cover"
className='absolute inset-0 w-full h-full object-cover'
draggable={false}
/>
</div>
)}
<p className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-1">
{episode.episode_number}: {episode.name}
<p className='text-sm font-medium text-gray-900 dark:text-gray-100 mb-1'>
{episode.episode_number}:{' '}
{episode.name}
</p>
{episode.overview && (
<p
onClick={() => {
const newExpanded = new Set(expandedEpisodes);
const newExpanded = new Set(
expandedEpisodes
);
if (isExpanded) {
newExpanded.delete(episode.id);
} else {
@@ -1597,13 +1848,15 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
}
setExpandedEpisodes(newExpanded);
}}
className={`text-xs text-gray-600 dark:text-gray-400 cursor-pointer ${isExpanded ? '' : 'line-clamp-3'}`}
className={`text-xs text-gray-600 dark:text-gray-400 cursor-pointer ${
isExpanded ? '' : 'line-clamp-3'
}`}
>
{episode.overview}
</p>
)}
{episode.air_date && (
<p className="text-xs text-gray-500 dark:text-gray-500 mt-1">
<p className='text-xs text-gray-500 dark:text-gray-500 mt-1'>
{episode.air_date}
</p>
)}
@@ -1620,37 +1873,46 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
)}
{/* 数据源显示和切换 */}
<div className="mt-6 pt-4 border-t border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between gap-3 flex-wrap">
<div className="flex items-center gap-2">
<span className="text-sm text-gray-500 dark:text-gray-400">:</span>
<span className="text-sm font-medium text-gray-700 dark:text-gray-300 uppercase">
<div className='mt-6 pt-4 border-t border-gray-200 dark:border-gray-700'>
<div className='flex items-center justify-between gap-3 flex-wrap'>
<div className='flex items-center gap-2'>
<span className='text-sm text-gray-500 dark:text-gray-400'>
:
</span>
<span className='text-sm font-medium text-gray-700 dark:text-gray-300 uppercase'>
{currentSource === 'douban' && 'Douban'}
{currentSource === 'bangumi' && 'Bangumi'}
{currentSource === 'cms' && 'CMS'}
{currentSource === 'tmdb' && 'TMDB'}
</span>
</div>
<div className="flex items-center gap-2 flex-wrap">
<div className='flex items-center gap-2 flex-wrap'>
{galleryEntryButton}
{currentSource !== 'tmdb' && (
<button
onClick={handleToggleSource}
disabled={loading}
className="px-3 py-1.5 text-sm rounded-lg bg-green-500 hover:bg-green-600 text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
className='px-3 py-1.5 text-sm rounded-lg bg-green-500 hover:bg-green-600 text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed'
>
TMDB
</button>
)}
{currentSource === 'tmdb' && originalSource !== 'tmdb' && originalDetailData && (
<button
onClick={handleToggleSource}
disabled={loading}
className="px-3 py-1.5 text-sm rounded-lg bg-gray-500 hover:bg-gray-600 text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{originalSource === 'douban' ? 'Douban' : originalSource === 'bangumi' ? 'Bangumi' : 'CMS'}
</button>
)}
{currentSource === 'tmdb' &&
originalSource !== 'tmdb' &&
originalDetailData && (
<button
onClick={handleToggleSource}
disabled={loading}
className='px-3 py-1.5 text-sm rounded-lg bg-gray-500 hover:bg-gray-600 text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed'
>
{' '}
{originalSource === 'douban'
? 'Douban'
: originalSource === 'bangumi'
? 'Bangumi'
: 'CMS'}
</button>
)}
</div>
</div>
</div>
@@ -1671,7 +1933,7 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
)}
</div>
) : (
<div className="fixed inset-0 z-[9999] flex items-center justify-center p-4">
<div className='fixed inset-0 z-[9999] flex items-center justify-center p-4'>
{/* 背景遮罩 */}
<div
className={`absolute inset-0 bg-black/50 transition-opacity duration-200 ease-out ${
@@ -1686,59 +1948,70 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
{/* 详情面板 - 居中模式 */}
<div
className="relative w-full max-w-2xl max-h-[90vh] bg-white dark:bg-gray-900 rounded-2xl shadow-2xl overflow-hidden transition-all duration-200 ease-out"
className='relative w-full max-w-2xl max-h-[90vh] bg-white dark:bg-gray-900 rounded-2xl shadow-2xl overflow-hidden transition-all duration-200 ease-out'
style={{
willChange: 'transform, opacity',
backfaceVisibility: 'hidden',
transform: isAnimating ? 'scale(1) translateZ(0)' : 'scale(0.95) translateZ(0)',
transform: isAnimating
? 'scale(1) translateZ(0)'
: 'scale(0.95) translateZ(0)',
opacity: isAnimating ? 1 : 0,
}}
>
{/* 头部 */}
<div className="flex items-center justify-between p-4 border-b border-gray-100 dark:border-gray-800 sticky top-0 bg-white dark:bg-gray-900 z-10">
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100"></h2>
<div className="flex items-center gap-2">
<div className='flex items-center justify-between p-4 border-b border-gray-100 dark:border-gray-800 sticky top-0 bg-white dark:bg-gray-900 z-10'>
<h2 className='text-xl font-semibold text-gray-900 dark:text-gray-100'>
</h2>
<div className='flex items-center gap-2'>
{externalUrl && (
<button
onClick={() => window.open(externalUrl, '_blank', 'noopener,noreferrer')}
className="p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors duration-150"
title="打开外部页面"
aria-label="打开外部页面"
onClick={() =>
window.open(externalUrl, '_blank', 'noopener,noreferrer')
}
className='p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors duration-150'
title='打开外部页面'
aria-label='打开外部页面'
>
<ExternalLink size={18} className="text-gray-500 dark:text-gray-400" />
<ExternalLink
size={18}
className='text-gray-500 dark:text-gray-400'
/>
</button>
)}
<button
onClick={onClose}
className="p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors duration-150"
title="关闭"
aria-label="关闭"
className='p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors duration-150'
title='关闭'
aria-label='关闭'
>
<X size={20} className="text-gray-500 dark:text-gray-400" />
<X size={20} className='text-gray-500 dark:text-gray-400' />
</button>
</div>
</div>
{/* 内容区域 */}
<div className="overflow-y-auto max-h-[calc(90vh-4rem)]">
<div className='overflow-y-auto max-h-[calc(90vh-4rem)]'>
{loading && (
<div className="flex items-center justify-center py-20">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-green-500"></div>
<div className='flex items-center justify-center py-20'>
<div className='animate-spin rounded-full h-12 w-12 border-b-2 border-green-500'></div>
</div>
)}
{error && (
<div className="p-6">
<div className="text-center mb-6">
<p className="text-red-500 dark:text-red-400">{error}</p>
<div className='p-6'>
<div className='text-center mb-6'>
<p className='text-red-500 dark:text-red-400'>{error}</p>
</div>
{/* 数据源显示和切换 - 错误时也显示 */}
<div className="mt-6 pt-4 border-t border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-sm text-gray-500 dark:text-gray-400">:</span>
<span className="text-sm font-medium text-gray-700 dark:text-gray-300 uppercase">
<div className='mt-6 pt-4 border-t border-gray-200 dark:border-gray-700'>
<div className='flex items-center justify-between'>
<div className='flex items-center gap-2'>
<span className='text-sm text-gray-500 dark:text-gray-400'>
:
</span>
<span className='text-sm font-medium text-gray-700 dark:text-gray-300 uppercase'>
{currentSource === 'douban' && 'Douban'}
{currentSource === 'bangumi' && 'Bangumi'}
{currentSource === 'cms' && 'CMS'}
@@ -1749,67 +2022,75 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
<button
onClick={handleToggleSource}
disabled={loading}
className="px-3 py-1.5 text-sm rounded-lg bg-green-500 hover:bg-green-600 text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
className='px-3 py-1.5 text-sm rounded-lg bg-green-500 hover:bg-green-600 text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed'
>
TMDB
</button>
)}
{currentSource === 'tmdb' && originalSource !== 'tmdb' && originalDetailData && (
<button
onClick={handleToggleSource}
disabled={loading}
className="px-3 py-1.5 text-sm rounded-lg bg-gray-500 hover:bg-gray-600 text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{originalSource === 'douban' ? 'Douban' : originalSource === 'bangumi' ? 'Bangumi' : 'CMS'}
</button>
)}
{currentSource === 'tmdb' &&
originalSource !== 'tmdb' &&
originalDetailData && (
<button
onClick={handleToggleSource}
disabled={loading}
className='px-3 py-1.5 text-sm rounded-lg bg-gray-500 hover:bg-gray-600 text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed'
>
{' '}
{originalSource === 'douban'
? 'Douban'
: originalSource === 'bangumi'
? 'Bangumi'
: 'CMS'}
</button>
)}
</div>
</div>
</div>
)}
{!loading && !error && detailData && (
<div className="p-6">
<div className='p-6'>
{/* 海报和基本信息 */}
<div className="flex gap-6 mb-6">
<div className='flex gap-6 mb-6'>
{detailData.poster && (
<div className="flex flex-col items-start gap-3 flex-shrink-0">
<div className='flex flex-col items-start gap-3 flex-shrink-0'>
<div
className="relative w-32 h-48 rounded-lg overflow-hidden bg-gray-100 dark:bg-gray-800 cursor-pointer hover:opacity-90 transition-opacity"
className='relative w-32 h-48 rounded-lg overflow-hidden bg-gray-100 dark:bg-gray-800 cursor-pointer hover:opacity-90 transition-opacity'
onClick={() => handleImageClick(detailData.poster!)}
>
<ProxyImage
originalSrc={detailData.poster}
alt={detailData.title}
className="absolute inset-0 w-full h-full object-cover"
className='absolute inset-0 w-full h-full object-cover'
draggable={false}
/>
</div>
{galleryEntryButton}
</div>
)}
<div className="flex-1 min-w-0">
<h3 className="text-2xl font-bold text-gray-900 dark:text-gray-100 mb-2">
<div className='flex-1 min-w-0'>
<h3 className='text-2xl font-bold text-gray-900 dark:text-gray-100 mb-2'>
{detailData.title}
</h3>
{detailData.originalTitle && detailData.originalTitle !== detailData.title && (
<p className="text-sm text-gray-500 dark:text-gray-400 mb-3">
{detailData.originalTitle}
</p>
)}
{detailData.originalTitle &&
detailData.originalTitle !== detailData.title && (
<p className='text-sm text-gray-500 dark:text-gray-400 mb-3'>
{detailData.originalTitle}
</p>
)}
{/* 评分 */}
{detailData.rating && (
<div className="flex items-center gap-2 mb-3">
<div className='flex items-center gap-2 mb-3'>
<Star
size={20}
className="text-yellow-500 fill-yellow-500"
className='text-yellow-500 fill-yellow-500'
/>
<span className="text-lg font-semibold text-gray-900 dark:text-gray-100">
<span className='text-lg font-semibold text-gray-900 dark:text-gray-100'>
{detailData.rating.value.toFixed(1)}
</span>
{detailData.rating.count > 0 && (
<span className="text-sm text-gray-500 dark:text-gray-400">
<span className='text-sm text-gray-500 dark:text-gray-400'>
({detailData.rating.count} )
</span>
)}
@@ -1818,11 +2099,11 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
{/* 类型标签 */}
{detailData.genres && detailData.genres.length > 0 && (
<div className="flex flex-wrap gap-2 mb-3">
<div className='flex flex-wrap gap-2 mb-3'>
{detailData.genres.map((genre, index) => (
<span
key={index}
className="px-2 py-1 text-xs rounded bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300"
className='px-2 py-1 text-xs rounded bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300'
>
{genre}
</span>
@@ -1831,21 +2112,21 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
)}
{/* 年份和时长 */}
<div className="flex flex-wrap gap-4 text-sm text-gray-600 dark:text-gray-400">
<div className='flex flex-wrap gap-4 text-sm text-gray-600 dark:text-gray-400'>
{detailData.year && (
<div className="flex items-center gap-1">
<div className='flex items-center gap-1'>
<Calendar size={16} />
<span>{detailData.year}</span>
</div>
)}
{detailData.duration && (
<div className="flex items-center gap-1">
<div className='flex items-center gap-1'>
<Clock size={16} />
<span>{detailData.duration}</span>
</div>
)}
{detailData.episodesCount && (
<div className="flex items-center gap-1">
<div className='flex items-center gap-1'>
<Film size={16} />
<span>{detailData.episodesCount} </span>
</div>
@@ -1856,11 +2137,11 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
{/* 简介 */}
{(detailData.intro || detailData.overview) && (
<div className="mb-6">
<h4 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">
<div className='mb-6'>
<h4 className='text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2'>
</h4>
<p className="text-gray-700 dark:text-gray-300 leading-relaxed whitespace-pre-wrap">
<p className='text-gray-700 dark:text-gray-300 leading-relaxed whitespace-pre-wrap'>
{detailData.intro || detailData.overview}
</p>
</div>
@@ -1868,20 +2149,20 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
{/* 导演和演员 */}
{detailData.directors && detailData.directors.length > 0 && (
<div className="mb-4">
<h4 className="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-2 flex items-center gap-2">
<div className='mb-4'>
<h4 className='text-sm font-semibold text-gray-900 dark:text-gray-100 mb-2 flex items-center gap-2'>
<Users size={16} />
</h4>
<p className="text-gray-700 dark:text-gray-300">
<p className='text-gray-700 dark:text-gray-300'>
{detailData.directors.map((d) => d.name).join(', ')}
</p>
</div>
)}
{detailData.actors && detailData.actors.length > 0 && (
<div className="mb-4">
<h4 className="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-2 flex items-center gap-2">
<div className='mb-4'>
<h4 className='text-sm font-semibold text-gray-900 dark:text-gray-100 mb-2 flex items-center gap-2'>
<Users size={16} />
</h4>
@@ -1892,47 +2173,61 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
onMouseMove={handleActorsMouseMove}
onMouseUp={handleActorsMouseUp}
onMouseLeave={handleActorsMouseLeave}
className="overflow-x-auto -mx-6 px-6 cursor-grab active:cursor-grabbing"
className='overflow-x-auto -mx-6 px-6 cursor-grab active:cursor-grabbing'
style={{
scrollbarWidth: 'thin',
scrollBehavior: isActorsDragging ? 'auto' : 'smooth'
scrollBehavior: isActorsDragging ? 'auto' : 'smooth',
}}
>
<div className="flex gap-4 pb-2">
<div className='flex gap-4 pb-2'>
{detailData.actors.map((actor, index) => (
<div
key={index}
className="flex flex-col items-center flex-shrink-0"
style={{ pointerEvents: isActorsDragging ? 'none' : 'auto' }}
className='flex flex-col items-center flex-shrink-0'
style={{
pointerEvents: isActorsDragging ? 'none' : 'auto',
}}
>
{actor.profile_path ? (
<div
className="relative w-20 h-20 rounded-full overflow-hidden bg-gray-200 dark:bg-gray-700 mb-2 cursor-pointer hover:opacity-80 transition-opacity"
onClick={() => handleImageClick(getTMDBImageUrl(actor.profile_path || null, 'w185'))}
className='relative w-20 h-20 rounded-full overflow-hidden bg-gray-200 dark:bg-gray-700 mb-2 cursor-pointer hover:opacity-80 transition-opacity'
onClick={() =>
handleImageClick(
getTMDBImageUrl(
actor.profile_path || null,
'w185'
)
)
}
>
<ProxyImage
originalSrc={getTMDBImageUrl(actor.profile_path || null, 'w185')}
originalSrc={getTMDBImageUrl(
actor.profile_path || null,
'w185'
)}
alt={actor.name}
className="absolute inset-0 w-full h-full object-cover"
className='absolute inset-0 w-full h-full object-cover'
draggable={false}
/>
</div>
) : (
<div className="w-20 h-20 rounded-full bg-gray-200 dark:bg-gray-700 mb-2 flex items-center justify-center">
<Users size={28} className="text-gray-400" />
<div className='w-20 h-20 rounded-full bg-gray-200 dark:bg-gray-700 mb-2 flex items-center justify-center'>
<Users size={28} className='text-gray-400' />
</div>
)}
<a
href={`https://baike.baidu.com/item/${encodeURIComponent(actor.name)}`}
target="_blank"
rel="noopener noreferrer"
className="text-xs font-medium text-gray-900 dark:text-gray-100 text-center w-20 line-clamp-2 hover:text-green-600 dark:hover:text-green-400 transition-colors cursor-pointer"
href={`https://baike.baidu.com/item/${encodeURIComponent(
actor.name
)}`}
target='_blank'
rel='noopener noreferrer'
className='text-xs font-medium text-gray-900 dark:text-gray-100 text-center w-20 line-clamp-2 hover:text-green-600 dark:hover:text-green-400 transition-colors cursor-pointer'
onClick={(e) => e.stopPropagation()}
>
{actor.name}
</a>
{actor.character && (
<p className="text-xs text-gray-500 dark:text-gray-400 text-center w-20 line-clamp-2">
<p className='text-xs text-gray-500 dark:text-gray-400 text-center w-20 line-clamp-2'>
{actor.character}
</p>
)}
@@ -1941,22 +2236,25 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
</div>
</div>
) : (
<p className="text-gray-700 dark:text-gray-300">
{detailData.actors.slice(0, 10).map((a) => a.name).join(', ')}
<p className='text-gray-700 dark:text-gray-300'>
{detailData.actors
.slice(0, 10)
.map((a) => a.name)
.join(', ')}
</p>
)}
</div>
)}
{/* 制作信息 */}
<div className="grid grid-cols-2 gap-4 text-sm">
<div className='grid grid-cols-2 gap-4 text-sm'>
{detailData.countries && detailData.countries.length > 0 && (
<div>
<h4 className="font-semibold text-gray-900 dark:text-gray-100 mb-1 flex items-center gap-1">
<h4 className='font-semibold text-gray-900 dark:text-gray-100 mb-1 flex items-center gap-1'>
<Globe size={14} />
/
</h4>
<p className="text-gray-700 dark:text-gray-300">
<p className='text-gray-700 dark:text-gray-300'>
{detailData.countries.join(', ')}
</p>
</div>
@@ -1964,11 +2262,11 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
{detailData.languages && detailData.languages.length > 0 && (
<div>
<h4 className="font-semibold text-gray-900 dark:text-gray-100 mb-1 flex items-center gap-1">
<h4 className='font-semibold text-gray-900 dark:text-gray-100 mb-1 flex items-center gap-1'>
<Tag size={14} />
</h4>
<p className="text-gray-700 dark:text-gray-300">
<p className='text-gray-700 dark:text-gray-300'>
{detailData.languages.join(', ')}
</p>
</div>
@@ -1976,28 +2274,34 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
{detailData.releaseDate && (
<div>
<h4 className="font-semibold text-gray-900 dark:text-gray-100 mb-1 flex items-center gap-1">
<h4 className='font-semibold text-gray-900 dark:text-gray-100 mb-1 flex items-center gap-1'>
<Calendar size={14} />
</h4>
<p className="text-gray-700 dark:text-gray-300">{detailData.releaseDate}</p>
<p className='text-gray-700 dark:text-gray-300'>
{detailData.releaseDate}
</p>
</div>
)}
{detailData.status && (
<div>
<h4 className="font-semibold text-gray-900 dark:text-gray-100 mb-1"></h4>
<p className="text-gray-700 dark:text-gray-300">{detailData.status}</p>
<h4 className='font-semibold text-gray-900 dark:text-gray-100 mb-1'>
</h4>
<p className='text-gray-700 dark:text-gray-300'>
{detailData.status}
</p>
</div>
)}
</div>
{/* 季度和集数信息(仅TMDB电视剧) */}
{detailData.mediaType === 'tv' && (
<div className="mt-6">
<div className='mt-6'>
{loadingSeasons && (
<div className="flex items-center justify-center py-4">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-green-500"></div>
<div className='flex items-center justify-center py-4'>
<div className='animate-spin rounded-full h-8 w-8 border-b-2 border-green-500'></div>
</div>
)}
@@ -2005,15 +2309,17 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
<>
{/* 季度列表 */}
{seasonData.seasons.length > 0 && (
<div className="mb-6">
<h4 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3">
<div className='mb-6'>
<h4 className='text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3'>
</h4>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
<div className='grid grid-cols-2 sm:grid-cols-3 gap-3'>
{seasonData.seasons.map((season: any) => (
<div
key={season.id}
onClick={() => handleSeasonChange(season.season_number)}
onClick={() =>
handleSeasonChange(season.season_number)
}
className={`flex items-center gap-2 p-2 rounded cursor-pointer transition-colors ${
selectedSeason === season.season_number
? 'bg-green-100 dark:bg-green-900/30 ring-2 ring-green-500'
@@ -2022,25 +2328,33 @@ 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 hover:opacity-80 transition-opacity"
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(getTMDBImageUrl(season.poster_path, 'w500'));
handleImageClick(
getTMDBImageUrl(
season.poster_path,
'w500'
)
);
}}
>
<ProxyImage
originalSrc={getTMDBImageUrl(season.poster_path, 'w92')}
originalSrc={getTMDBImageUrl(
season.poster_path,
'w92'
)}
alt={season.name}
className="absolute inset-0 w-full h-full object-cover"
className='absolute inset-0 w-full h-full object-cover'
draggable={false}
/>
</div>
)}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
<div className='flex-1 min-w-0'>
<p className='text-sm font-medium text-gray-900 dark:text-gray-100 truncate'>
{season.name}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
<p className='text-xs text-gray-500 dark:text-gray-400'>
{season.episode_count}
</p>
</div>
@@ -2053,8 +2367,10 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
{/* 集数列表 */}
{seasonData.episodes.length > 0 && (
<div>
<h4 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3">
{seasonData.seasons.find((s: any) => s.season_number === selectedSeason)?.name || `${selectedSeason}`}
<h4 className='text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3'>
{seasonData.seasons.find(
(s: any) => s.season_number === selectedSeason
)?.name || `${selectedSeason}`}
</h4>
<div
ref={episodesScrollRef}
@@ -2062,16 +2378,19 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseLeave}
className="overflow-x-auto -mx-6 px-6 cursor-grab active:cursor-grabbing"
className='overflow-x-auto -mx-6 px-6 cursor-grab active:cursor-grabbing'
style={{
scrollbarWidth: 'thin',
scrollBehavior: isDragging ? 'auto' : 'smooth'
scrollBehavior: isDragging ? 'auto' : 'smooth',
}}
>
<div className="flex gap-3 py-2">
<div className='flex gap-3 py-2'>
{seasonData.episodes.map((episode: Episode) => {
const isExpanded = expandedEpisodes.has(episode.id);
const isCurrentEpisode = currentEpisode === episode.episode_number;
const isExpanded = expandedEpisodes.has(
episode.id
);
const isCurrentEpisode =
currentEpisode === episode.episode_number;
return (
<div
key={episode.id}
@@ -2081,28 +2400,45 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
? 'bg-green-100 dark:bg-green-900/30 ring-2 ring-green-500'
: 'bg-gray-50 dark:bg-gray-800'
}`}
style={{ pointerEvents: isDragging ? 'none' : 'auto' }}
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 cursor-pointer hover:opacity-90 transition-opacity"
onClick={() => handleImageClick(getTMDBImageUrl(episode.still_path, 'w500'))}
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(
getTMDBImageUrl(
episode.still_path,
'w500'
)
)
}
>
<ProxyImage
originalSrc={getTMDBImageUrl(episode.still_path, 'w300')}
originalSrc={getTMDBImageUrl(
episode.still_path,
'w300'
)}
alt={episode.name}
className="absolute inset-0 w-full h-full object-cover"
className='absolute inset-0 w-full h-full object-cover'
draggable={false}
/>
</div>
)}
<p className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-1">
{episode.episode_number}: {episode.name}
<p className='text-sm font-medium text-gray-900 dark:text-gray-100 mb-1'>
{episode.episode_number}:{' '}
{episode.name}
</p>
{episode.overview && (
<p
onClick={() => {
const newExpanded = new Set(expandedEpisodes);
const newExpanded = new Set(
expandedEpisodes
);
if (isExpanded) {
newExpanded.delete(episode.id);
} else {
@@ -2110,13 +2446,15 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
}
setExpandedEpisodes(newExpanded);
}}
className={`text-xs text-gray-600 dark:text-gray-400 cursor-pointer ${isExpanded ? '' : 'line-clamp-3'}`}
className={`text-xs text-gray-600 dark:text-gray-400 cursor-pointer ${
isExpanded ? '' : 'line-clamp-3'
}`}
>
{episode.overview}
</p>
)}
{episode.air_date && (
<p className="text-xs text-gray-500 dark:text-gray-500 mt-1">
<p className='text-xs text-gray-500 dark:text-gray-500 mt-1'>
{episode.air_date}
</p>
)}
@@ -2133,11 +2471,13 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
)}
{/* 数据源显示和切换 */}
<div className="mt-6 pt-4 border-t border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-sm text-gray-500 dark:text-gray-400">:</span>
<span className="text-sm font-medium text-gray-700 dark:text-gray-300 uppercase">
<div className='mt-6 pt-4 border-t border-gray-200 dark:border-gray-700'>
<div className='flex items-center justify-between'>
<div className='flex items-center gap-2'>
<span className='text-sm text-gray-500 dark:text-gray-400'>
:
</span>
<span className='text-sm font-medium text-gray-700 dark:text-gray-300 uppercase'>
{currentSource === 'douban' && 'Douban'}
{currentSource === 'bangumi' && 'Bangumi'}
{currentSource === 'cms' && 'CMS'}
@@ -2148,20 +2488,27 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
<button
onClick={handleToggleSource}
disabled={loading}
className="px-3 py-1.5 text-sm rounded-lg bg-green-500 hover:bg-green-600 text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
className='px-3 py-1.5 text-sm rounded-lg bg-green-500 hover:bg-green-600 text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed'
>
TMDB
</button>
)}
{currentSource === 'tmdb' && originalSource !== 'tmdb' && originalDetailData && (
<button
onClick={handleToggleSource}
disabled={loading}
className="px-3 py-1.5 text-sm rounded-lg bg-gray-500 hover:bg-gray-600 text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{originalSource === 'douban' ? 'Douban' : originalSource === 'bangumi' ? 'Bangumi' : 'CMS'}
</button>
)}
{currentSource === 'tmdb' &&
originalSource !== 'tmdb' &&
originalDetailData && (
<button
onClick={handleToggleSource}
disabled={loading}
className='px-3 py-1.5 text-sm rounded-lg bg-gray-500 hover:bg-gray-600 text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed'
>
{' '}
{originalSource === 'douban'
? 'Douban'
: originalSource === 'bangumi'
? 'Bangumi'
: 'CMS'}
</button>
)}
</div>
</div>
</div>