mirror of
https://github.com/ceratic/MediaCollectorLibaryFrontend.git
synced 2026-05-13 23:56:45 +02:00
436 lines
20 KiB
TypeScript
436 lines
20 KiB
TypeScript
import React from 'react'
|
|
import { motion } from 'framer-motion'
|
|
import { useParams, useNavigate } from 'react-router-dom'
|
|
import {
|
|
ArrowLeftIcon,
|
|
PlayIcon,
|
|
CalendarIcon,
|
|
UserIcon,
|
|
FilmIcon,
|
|
PlusIcon,
|
|
CheckIcon,
|
|
ExclamationTriangleIcon,
|
|
LockClosedIcon
|
|
} from '@heroicons/react/24/outline'
|
|
import { StarIcon as StarIconSolid } from '@heroicons/react/24/solid'
|
|
import { useAdult } from '../hooks/useApi'
|
|
import { Tooltip } from '../components/MicroInteractions'
|
|
|
|
class AdultDetailErrorBoundary extends React.Component<
|
|
{ children: React.ReactNode; navigate: (to: string) => void },
|
|
{ hasError: boolean; error?: Error }
|
|
> {
|
|
constructor(props: any) {
|
|
super(props)
|
|
this.state = { hasError: false }
|
|
}
|
|
|
|
static getDerivedStateFromError(error: Error) {
|
|
return { hasError: true, error }
|
|
}
|
|
|
|
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
|
console.error('AdultDetail error:', error, errorInfo)
|
|
}
|
|
|
|
render() {
|
|
if (this.state.hasError) {
|
|
return (
|
|
<div className="text-center">
|
|
<ExclamationTriangleIcon className="w-16 h-16 text-red-500 mx-auto mb-4" />
|
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">Content Error</h2>
|
|
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
|
{this.state.error?.message || 'Failed to load content details'}
|
|
</p>
|
|
<div className="flex gap-4 justify-center">
|
|
<button
|
|
onClick={() => this.props.navigate('/adult')}
|
|
className="btn btn-secondary"
|
|
>
|
|
Back to Adult
|
|
</button>
|
|
<button
|
|
onClick={() => window.location.reload()}
|
|
className="btn btn-primary"
|
|
>
|
|
Reload Page
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return this.props.children
|
|
}
|
|
}
|
|
|
|
export default function AdultDetail() {
|
|
const { id } = useParams<{ id: string }>()
|
|
const navigate = useNavigate()
|
|
|
|
// Validate ID and handle invalid cases
|
|
const contentId = id ? Number(id) : null
|
|
const isValidId = contentId && !isNaN(contentId) && contentId > 0
|
|
|
|
// Early return for invalid ID
|
|
if (!isValidId) {
|
|
return (
|
|
<div className="min-h-screen flex items-center justify-center">
|
|
<div className="text-center">
|
|
<div className="text-gray-400 text-6xl mb-4">🔒</div>
|
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">Invalid Content ID</h2>
|
|
<p className="text-gray-600 dark:text-gray-400 mb-6">The content ID is invalid or missing.</p>
|
|
<button
|
|
onClick={() => navigate('/adult')}
|
|
className="btn btn-primary"
|
|
>
|
|
Back to Adult
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const { data: adultContent, isLoading, error } = useAdult(contentId)
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="min-h-screen">
|
|
<div className="animate-pulse">
|
|
<div className="h-96 bg-gray-300 dark:bg-gray-700"></div>
|
|
<div className="container mx-auto px-4 py-8">
|
|
<div className="h-8 bg-gray-300 dark:bg-gray-700 rounded w-1/3 mb-4"></div>
|
|
<div className="h-4 bg-gray-300 dark:bg-gray-700 rounded w-2/3 mb-2"></div>
|
|
<div className="h-4 bg-gray-300 dark:bg-gray-700 rounded w-1/2"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (error || !adultContent) {
|
|
return (
|
|
<div className="min-h-screen flex items-center justify-center">
|
|
<div className="text-center">
|
|
<div className="text-gray-400 text-6xl mb-4">🔒</div>
|
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">Content Not Found</h2>
|
|
<p className="text-gray-600 dark:text-gray-400 mb-6">The content you're looking for doesn't exist or has been removed.</p>
|
|
<button
|
|
onClick={() => navigate('/adult')}
|
|
className="btn btn-primary"
|
|
>
|
|
Back to Adult
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const posterUrl = adultContent.poster_url || adultContent.screenshot_url ?
|
|
((adultContent.poster_url || adultContent.screenshot_url).startsWith('http') ? (adultContent.poster_url || adultContent.screenshot_url) : `${(import.meta as any).env?.VITE_API_URL?.replace('/api', '') || ''}${adultContent.poster_url || adultContent.screenshot_url}`) :
|
|
null
|
|
|
|
const backdropUrl = adultContent.backdrop_url ?
|
|
(adultContent.backdrop_url.startsWith('http') ? adultContent.backdrop_url : `${(import.meta as any).env?.VITE_API_URL?.replace('/api', '') || ''}${adultContent.backdrop_url}`) :
|
|
null
|
|
|
|
const getPosterAspectRatio = () => {
|
|
// Check for poster_aspect_ratio first, then fall back to 2/3
|
|
return adultContent.poster_aspect_ratio || 2/3
|
|
}
|
|
|
|
const formatYear = (date?: string) => {
|
|
if (!date) return 'Unknown'
|
|
return new Date(date).getFullYear()
|
|
}
|
|
|
|
const formatDate = (date?: string) => {
|
|
if (!date) return 'Unknown'
|
|
return new Date(date).toLocaleDateString('en-US', {
|
|
year: 'numeric',
|
|
month: 'long',
|
|
day: 'numeric'
|
|
})
|
|
}
|
|
|
|
const formatRuntime = (minutes?: number) => {
|
|
if (!minutes) return 'Unknown'
|
|
const hours = Math.floor(minutes / 60)
|
|
const mins = minutes % 60
|
|
return `${hours}h ${mins}m`
|
|
}
|
|
|
|
return (
|
|
<AdultDetailErrorBoundary navigate={navigate}>
|
|
<div className="min-h-screen transition-colors duration-300">
|
|
{/* Hero Section - Compact */}
|
|
<div className="relative h-64 overflow-hidden">
|
|
{backdropUrl ? (
|
|
<>
|
|
<motion.img
|
|
src={backdropUrl}
|
|
alt={adultContent.title}
|
|
className="w-full h-full object-cover"
|
|
initial={{ scale: 1.1 }}
|
|
animate={{ scale: 1 }}
|
|
transition={{ duration: 1.5 }}
|
|
/>
|
|
<div className="absolute inset-0 bg-gradient-to-t from-gray-900 via-gray-900/50 to-transparent dark:from-black dark:via-black/50 dark:to-transparent"></div>
|
|
</>
|
|
) : (
|
|
<div className="w-full h-full bg-gradient-to-br from-purple-600 to-pink-600 dark:from-purple-800 dark:to-pink-800"></div>
|
|
)}
|
|
|
|
{/* Back Button */}
|
|
<motion.button
|
|
onClick={() => navigate(-1)}
|
|
className="absolute top-4 left-4 p-2 bg-black/50 dark:bg-black/70 backdrop-blur-sm rounded-lg text-white hover:bg-black/70 dark:hover:bg-black/80 transition-all duration-200"
|
|
whileHover={{ scale: 1.05 }}
|
|
whileTap={{ scale: 0.95 }}
|
|
>
|
|
<ArrowLeftIcon className="w-5 h-5" />
|
|
</motion.button>
|
|
|
|
{/* Play Button - Compact */}
|
|
<motion.button
|
|
className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 p-4 bg-white/90 dark:bg-white/80 backdrop-blur-sm rounded-full shadow-xl hover:bg-white dark:hover:bg-white/90 transition-all duration-200"
|
|
whileHover={{ scale: 1.1 }}
|
|
whileTap={{ scale: 0.9 }}
|
|
>
|
|
<PlayIcon className="w-8 h-8 text-purple-600 dark:text-purple-500" />
|
|
</motion.button>
|
|
</div>
|
|
|
|
{/* Main Content - Compact */}
|
|
<div className="container mx-auto px-4 py-6">
|
|
<div className="flex flex-col lg:flex-row gap-6">
|
|
{/* Poster and Basic Info - Compact */}
|
|
<div className="lg:w-1/3">
|
|
<motion.div
|
|
initial={{ opacity: 0, x: -20 }}
|
|
animate={{ opacity: 1, x: 0 }}
|
|
transition={{ delay: 0.2 }}
|
|
>
|
|
{posterUrl ? (
|
|
<motion.img
|
|
src={posterUrl}
|
|
alt={adultContent.title}
|
|
className="w-full rounded-lg shadow-lg mb-4 dark:shadow-black/20"
|
|
whileHover={{ scale: 1.02 }}
|
|
onError={(e) => {
|
|
console.error('Failed to load poster:', posterUrl, 'for adult content:', adultContent.title)
|
|
// Set a fallback background
|
|
const target = e.target as HTMLImageElement
|
|
target.style.display = 'none'
|
|
const parent = target.parentElement
|
|
if (parent) {
|
|
parent.className = 'w-full bg-gray-200 dark:bg-gray-800 rounded-lg flex items-center justify-center mb-4'
|
|
parent.style.aspectRatio = getPosterAspectRatio().toString()
|
|
const iconDiv = document.createElement('div')
|
|
iconDiv.className = 'text-gray-400 dark:text-gray-600'
|
|
iconDiv.innerHTML = '<svg class="w-24 h-24" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m0 5.25v3.75a4.5 4.5 0 10-9 0v-3.75m0-5.25H6m13.5 0A2.25 2.25 0 0017.25 8.5h-10.5A2.25 2.25 0 004.5 10.75v10.5A2.25 2.25 0 006.75 23.5h10.5a2.25 2.25 0 002.25-2.25v-10.5z" /></svg>'
|
|
parent.appendChild(iconDiv)
|
|
}
|
|
}}
|
|
/>
|
|
) : (
|
|
<div
|
|
className="w-full bg-gray-200 dark:bg-gray-800 rounded-lg flex items-center justify-center mb-4"
|
|
style={{ aspectRatio: getPosterAspectRatio() }}
|
|
>
|
|
<LockClosedIcon className="w-24 h-24 text-gray-400 dark:text-gray-600" />
|
|
</div>
|
|
)}
|
|
|
|
<div className="space-y-3">
|
|
<div className="flex items-center gap-3">
|
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">{adultContent.title}</h1>
|
|
{adultContent.watched && (
|
|
<Tooltip text="Watched">
|
|
<CheckIcon className="w-5 h-5 text-green-600 dark:text-green-500" />
|
|
</Tooltip>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex items-center gap-3 text-sm text-gray-600 dark:text-gray-400">
|
|
<div className="flex items-center gap-1">
|
|
<CalendarIcon className="w-4 h-4" />
|
|
<span>{formatYear(adultContent.release_date)}</span>
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<FilmIcon className="w-4 h-4" />
|
|
<span>Adult Content</span>
|
|
</div>
|
|
</div>
|
|
|
|
{adultContent.rating && (
|
|
<div className="flex items-center gap-2">
|
|
<div className="flex items-center">
|
|
{[...Array(5)].map((_, i) => (
|
|
<StarIconSolid
|
|
key={i}
|
|
className={`w-5 h-5 ${
|
|
i < Math.floor(Number(adultContent.rating) || 0)
|
|
? 'text-yellow-400 dark:text-yellow-500'
|
|
: 'text-gray-300 dark:text-gray-600'
|
|
}`}
|
|
/>
|
|
))}
|
|
</div>
|
|
<span className="text-base font-semibold text-gray-900 dark:text-white">
|
|
{Number(adultContent.rating)?.toFixed(1)}
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex gap-2">
|
|
<motion.button
|
|
className="btn btn-primary flex-1 flex items-center justify-center gap-2 text-sm py-2"
|
|
whileHover={{ scale: 1.02 }}
|
|
whileTap={{ scale: 0.98 }}
|
|
>
|
|
<PlayIcon className="w-4 h-4" />
|
|
Play Now
|
|
</motion.button>
|
|
<motion.button
|
|
className="btn btn-secondary flex items-center justify-center gap-2 text-sm py-2"
|
|
whileHover={{ scale: 1.02 }}
|
|
whileTap={{ scale: 0.98 }}
|
|
>
|
|
<PlusIcon className="w-4 h-4" />
|
|
Add to List
|
|
</motion.button>
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
</div>
|
|
|
|
{/* Details - Compact */}
|
|
<div className="lg:w-2/3">
|
|
<motion.div
|
|
initial={{ opacity: 0, x: 20 }}
|
|
animate={{ opacity: 1, x: 0 }}
|
|
transition={{ delay: 0.3 }}
|
|
className="space-y-4"
|
|
>
|
|
<div className="bg-gradient-to-br from-purple-50 to-pink-50 dark:from-purple-900/20 dark:to-pink-900/20 rounded-2xl shadow-lg p-6 border border-purple-200 dark:border-purple-800">
|
|
<h3 className="text-lg font-semibold text-purple-800 dark:text-purple-200 mb-3 flex items-center gap-2">
|
|
<UserIcon className="w-5 h-5" />
|
|
Description
|
|
</h3>
|
|
<p className="text-slate-700 dark:text-slate-300 leading-relaxed text-sm">
|
|
{adultContent.overview || 'No description available for this content.'}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="bg-gradient-to-br from-blue-50 to-indigo-50 dark:from-blue-900/20 dark:to-indigo-900/20 rounded-2xl shadow-lg p-6 border border-blue-200 dark:border-blue-800">
|
|
<h3 className="text-lg font-semibold text-blue-800 dark:text-blue-200 mb-4 flex items-center gap-2">
|
|
<CalendarIcon className="w-5 h-5" />
|
|
Details
|
|
</h3>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="bg-white/50 dark:bg-gray-800/50 rounded-lg p-3">
|
|
<span className="text-xs text-blue-600 dark:text-blue-400 font-medium">Release Date</span>
|
|
<p className="font-medium text-slate-900 dark:text-slate-100 text-sm mt-1">{formatDate(adultContent.release_date)}</p>
|
|
</div>
|
|
<div className="bg-white/50 dark:bg-gray-800/50 rounded-lg p-3">
|
|
<span className="text-xs text-blue-600 dark:text-blue-400 font-medium">Duration</span>
|
|
<p className="font-medium text-slate-900 dark:text-slate-100 text-sm mt-1">
|
|
{adultContent.runtime_minutes ? formatRuntime(adultContent.runtime_minutes) : 'Unknown'}
|
|
</p>
|
|
</div>
|
|
<div className="bg-white/50 dark:bg-gray-800/50 rounded-lg p-3">
|
|
<span className="text-xs text-blue-600 dark:text-blue-400 font-medium">Rating</span>
|
|
<p className="font-medium text-slate-900 dark:text-slate-100 text-sm mt-1">
|
|
{adultContent.rating ? `${adultContent.rating}/10` : 'Not Rated'}
|
|
</p>
|
|
</div>
|
|
<div className="bg-white/50 dark:bg-gray-800/50 rounded-lg p-3">
|
|
<span className="text-xs text-blue-600 dark:text-blue-400 font-medium">Source</span>
|
|
<p className="font-medium text-slate-900 dark:text-slate-100 text-sm mt-1">{adultContent.source_name || 'Unknown'}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="bg-gradient-to-br from-green-50 to-emerald-50 dark:from-green-900/20 dark:to-emerald-900/20 rounded-2xl shadow-lg p-6 border border-green-200 dark:border-green-800">
|
|
<h3 className="text-lg font-semibold text-green-800 dark:text-green-200 mb-4 flex items-center gap-2">
|
|
<UserIcon className="w-5 h-5" />
|
|
Cast & Actors
|
|
</h3>
|
|
{adultContent.actors && adultContent.actors.length > 0 ? (
|
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
|
|
{adultContent.actors.map((actor: any) => (
|
|
<motion.div
|
|
key={actor.id}
|
|
whileHover={{ scale: 1.02 }}
|
|
onClick={() => navigate(`/actors/${actor.id}`)}
|
|
className="bg-gray-50 dark:bg-gray-800 rounded-lg p-3 border border-gray-200 dark:border-gray-700 hover:shadow-lg transition-all cursor-pointer"
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-10 h-10 rounded-full overflow-hidden bg-gray-200 dark:bg-gray-700 flex-shrink-0">
|
|
{actor.thumbnail_path ? (
|
|
<img
|
|
src={actor.thumbnail_path.startsWith('http') ? actor.thumbnail_path : `${(import.meta as any).env?.VITE_API_URL?.replace('/api', '') || ''}${actor.thumbnail_path}`}
|
|
alt={actor.name}
|
|
className="w-full h-full object-cover"
|
|
onError={(e) => {
|
|
console.error('Failed to load actor thumbnail:', actor.thumbnail_path, 'for actor:', actor.name)
|
|
const target = e.target as HTMLImageElement
|
|
target.style.display = 'none'
|
|
const parent = target.parentElement
|
|
if (parent) {
|
|
parent.innerHTML = '<div class="w-full h-full flex items-center justify-center bg-gradient-to-br from-blue-400 to-purple-500"><svg class="w-5 h-5 text-white/50" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM12 10.5a5.25 5.25 0 007.5 0m-7.5 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25c0-.621-.504-1.125-1.125-1.125h-1.5z" /></svg></div>'
|
|
}
|
|
}}
|
|
/>
|
|
) : (
|
|
<div className="w-full h-full flex items-center justify-center bg-gradient-to-br from-blue-400 to-purple-500">
|
|
<UserIcon className="w-5 h-5 text-white/50" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<h4 className="font-medium text-gray-900 dark:text-white truncate text-sm">
|
|
{actor.name}
|
|
</h4>
|
|
{actor.metadata?.age && (
|
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
|
Age: {actor.metadata.age}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<p className="text-gray-500 dark:text-gray-400 italic text-sm">
|
|
No actor information available for this content.
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
<div className="bg-gradient-to-br from-red-50 to-orange-50 dark:from-red-900/20 dark:to-orange-900/20 rounded-2xl shadow-lg p-6 border border-red-200 dark:border-red-800">
|
|
<h3 className="text-lg font-semibold text-red-800 dark:text-red-200 mb-3 flex items-center gap-2">
|
|
<ExclamationTriangleIcon className="w-5 h-5" />
|
|
Content Warning
|
|
</h3>
|
|
<div className="bg-red-100 dark:bg-red-900/40 rounded-xl p-4 border border-red-300 dark:border-red-700">
|
|
<div className="flex items-center gap-2 text-red-800 dark:text-red-200">
|
|
<LockClosedIcon className="w-4 h-4" />
|
|
<span className="font-medium text-sm">Adult Content - 18+ Only</span>
|
|
</div>
|
|
<p className="text-red-700 dark:text-red-300 text-xs mt-1">
|
|
This content is intended for mature audiences only. Viewer discretion is advised.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</AdultDetailErrorBoundary>
|
|
)
|
|
}
|