mirror of
https://github.com/ceratic/MediaCollectorLibaryFrontend.git
synced 2026-05-13 23:56:45 +02:00
435 lines
16 KiB
TypeScript
435 lines
16 KiB
TypeScript
import { useState, useContext } from 'react'
|
|
import { motion } from 'framer-motion'
|
|
import { useNavigate } from 'react-router-dom'
|
|
import {
|
|
Play,
|
|
Plus,
|
|
Eye,
|
|
Lock,
|
|
AlertTriangle,
|
|
Star,
|
|
Heart,
|
|
Circle,
|
|
CheckCircle2
|
|
} from 'lucide-react'
|
|
import { useAdults } from '../hooks/useApi'
|
|
import { Tooltip } from '../components/MicroInteractions'
|
|
import { ViewContext } from '../components/Layout'
|
|
|
|
// Import from alternative frontend
|
|
import { MediaListView } from '../../../frontend/components/MediaListView'
|
|
|
|
interface PaginatedResponse<T> {
|
|
items: T[]
|
|
pagination: {
|
|
total: number
|
|
per_page: number
|
|
current_page: number
|
|
last_page: number
|
|
}
|
|
available_filters?: {
|
|
sources?: string[]
|
|
genres?: string[]
|
|
years?: string[]
|
|
}
|
|
}
|
|
|
|
interface AdultContent {
|
|
id: number
|
|
title: string
|
|
overview?: string
|
|
poster_url?: string
|
|
poster_aspect_ratio?: number
|
|
backdrop_url?: string
|
|
screenshot_url?: string
|
|
release_date?: string
|
|
rating?: number
|
|
runtime_minutes?: number
|
|
watched?: boolean
|
|
source_name?: string
|
|
genre?: string
|
|
studio?: string
|
|
cast?: string[]
|
|
director?: string
|
|
year?: number
|
|
}
|
|
|
|
export default function Adult() {
|
|
// Get view context from Layout
|
|
const viewContext = useContext(ViewContext)
|
|
|
|
// Extract values from context or use defaults
|
|
const viewMode = viewContext?.viewMode || 'grid'
|
|
const gridColumns = viewContext?.gridColumns || 5
|
|
const coverSize = viewContext?.coverSize || 200
|
|
const PaginationComp = viewContext?.PaginationComponent
|
|
const FiltersComp = viewContext?.FiltersComponent
|
|
|
|
// Load preferences from localStorage
|
|
const getStoredPreferences = () => {
|
|
const stored = localStorage.getItem('adultPreferences')
|
|
return stored ? JSON.parse(stored) : {}
|
|
}
|
|
|
|
const [searchTerm, setSearchTerm] = useState('')
|
|
const [selectedGenre, setSelectedGenre] = useState('')
|
|
const [selectedYear, setSelectedYear] = useState('')
|
|
const [selectedSource, setSelectedSource] = useState('')
|
|
const [currentPage, setCurrentPage] = useState(1)
|
|
const navigate = useNavigate()
|
|
|
|
const [itemsPerPage, setItemsPerPage] = useState(() => getStoredPreferences().itemsPerPage || 20)
|
|
|
|
// Save preferences to localStorage
|
|
const savePreferences = (updates: any) => {
|
|
const preferences = {
|
|
itemsPerPage,
|
|
...updates
|
|
}
|
|
localStorage.setItem('adultPreferences', JSON.stringify(preferences))
|
|
}
|
|
// Fetch adult content
|
|
const { data: adultData, isLoading, error } = useAdults({
|
|
search: searchTerm || undefined,
|
|
genre: selectedGenre || undefined,
|
|
year: selectedYear ? parseInt(selectedYear) : undefined,
|
|
source: selectedSource || undefined,
|
|
page: currentPage,
|
|
per_page: itemsPerPage
|
|
}) as { data?: PaginatedResponse<AdultContent>; isLoading: boolean; error: any }
|
|
|
|
const adultContent = adultData?.items || []
|
|
const pagination = adultData?.pagination
|
|
const availableFilters = adultData?.available_filters || {}
|
|
const availableSources = availableFilters.sources || ['XBVR', 'Stash', 'Other']
|
|
|
|
// Convert adult content to MediaItem format for MediaListView
|
|
const mediaItems = adultContent.map(content => ({
|
|
id: content.id.toString(),
|
|
title: content.title || 'Untitled Adult Content',
|
|
type: 'adult' as const,
|
|
coverUrl: content.poster_url || content.screenshot_url || '',
|
|
rating: Number(content.rating) || 0,
|
|
status: 'completed' as const,
|
|
releaseYear: content.year || (content.release_date ? new Date(content.release_date).getFullYear() : new Date().getFullYear()),
|
|
addedAt: new Date().toISOString(),
|
|
favorite: false,
|
|
platform: content.source_name || 'Unknown',
|
|
description: content.overview || '',
|
|
genres: content.genre ? [content.genre] : []
|
|
}))
|
|
|
|
// State for MediaListView
|
|
const [selectedId, setSelectedId] = useState<string | null>(null)
|
|
const [multiSelectedIds, setMultiSelectedIds] = useState<Set<string>>(new Set())
|
|
|
|
const handleSelect = (item: any) => {
|
|
setSelectedId(item.id)
|
|
}
|
|
|
|
const handleToggleSelect = (id: string) => {
|
|
const newSelected = new Set(multiSelectedIds)
|
|
if (newSelected.has(id)) {
|
|
newSelected.delete(id)
|
|
} else {
|
|
newSelected.add(id)
|
|
}
|
|
setMultiSelectedIds(newSelected)
|
|
}
|
|
|
|
// Pagination handlers
|
|
const handlePageChange = (page: number) => {
|
|
setCurrentPage(page)
|
|
}
|
|
|
|
const handleItemsPerPageChange = (newItemsPerPage: number) => {
|
|
setItemsPerPage(newItemsPerPage)
|
|
setCurrentPage(1)
|
|
savePreferences({ itemsPerPage: newItemsPerPage })
|
|
}
|
|
|
|
const handleContentClick = (content: AdultContent) => {
|
|
navigate(`/adult/${content.id}`)
|
|
}
|
|
|
|
// Filter handlers
|
|
const handleFiltersChange = (filters: any) => {
|
|
setSelectedSource(filters.source || '')
|
|
setSelectedGenre(filters.genre || '')
|
|
setSelectedYear(filters.year || '')
|
|
setCurrentPage(1)
|
|
}
|
|
|
|
const handleSearch = (term: string) => {
|
|
setSearchTerm(term)
|
|
setCurrentPage(1)
|
|
}
|
|
|
|
// Mock genres for adult content
|
|
const genres = [
|
|
'All', 'Action', 'Adventure', 'Comedy', 'Drama', 'Fantasy',
|
|
'Horror', 'Mystery', 'Romance', 'Sci-Fi', 'Thriller', 'Other'
|
|
]
|
|
|
|
// Mock years
|
|
const currentYear = new Date().getFullYear()
|
|
const years = ['All', ...Array.from({ length: 10 }, (_, i) => (currentYear - i).toString())]
|
|
|
|
// Prepare filter data for FiltersComponent
|
|
const filterState = {
|
|
search: searchTerm,
|
|
source: selectedSource,
|
|
genre: selectedGenre,
|
|
year: selectedYear
|
|
}
|
|
|
|
const filterOptions = {
|
|
sources: availableSources || [],
|
|
genres: availableFilters.genres || genres.slice(1),
|
|
years: availableFilters.years || years.slice(1)
|
|
}
|
|
|
|
// Helper function to get grid classes based on column count
|
|
const getGridClass = (columns: number) => {
|
|
const columnClasses = {
|
|
2: 'grid-cols-1 md:grid-cols-2',
|
|
3: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3',
|
|
4: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4',
|
|
5: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5',
|
|
6: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-6',
|
|
7: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-7',
|
|
8: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-8'
|
|
}
|
|
return columnClasses[columns as keyof typeof columnClasses] || columnClasses[5]
|
|
}
|
|
|
|
const formatYear = (date?: string) => {
|
|
if (!date) return 'Unknown'
|
|
return new Date(date).getFullYear()
|
|
}
|
|
|
|
const renderRating = (rating?: number | string) => {
|
|
const numericRating = typeof rating === 'string' ? parseFloat(rating) : rating
|
|
if (!numericRating || typeof numericRating !== 'number' || isNaN(numericRating)) return null
|
|
return (
|
|
<div className="flex items-center gap-1">
|
|
{[...Array(5)].map((_, i) => (
|
|
<Star
|
|
key={i}
|
|
className={`w-4 h-4 ${
|
|
i < Math.floor(numericRating) ? 'text-yellow-400 dark:text-yellow-500 fill-yellow-400' : 'text-gray-300 dark:text-gray-600'
|
|
}`}
|
|
/>
|
|
))}
|
|
<span className="text-sm text-gray-600 dark:text-gray-400 ml-1">{numericRating.toFixed(1)}</span>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
|
<div className="animate-pulse">
|
|
<div className="h-48 bg-gray-200 dark:bg-gray-800"></div>
|
|
<div className="container mx-auto px-4 py-8">
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
|
{[...Array(8)].map((_, i) => (
|
|
<div key={i} className="bg-gray-200 dark:bg-gray-800 rounded-xl h-64"></div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center">
|
|
<div className="text-center">
|
|
<AlertTriangle 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">Failed to load adult content. Please try again later.</p>
|
|
<button className="btn btn-primary" onClick={() => window.location.reload()}>
|
|
Reload Page
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="min-h-screen transition-colors duration-300">
|
|
{/* Filters Component */}
|
|
{FiltersComp && (
|
|
<FiltersComp
|
|
filters={filterState}
|
|
availableFilters={filterOptions}
|
|
onFiltersChange={handleFiltersChange}
|
|
onSearchChange={handleSearch}
|
|
className="mb-6"
|
|
/>
|
|
)}
|
|
|
|
{/* Content Grid/List/Cover */}
|
|
{adultContent.length === 0 ? (
|
|
<div className="text-center py-12">
|
|
<Lock className="w-16 h-16 text-gray-400 mx-auto mb-4" />
|
|
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-2">No Content Found</h3>
|
|
<p className="text-gray-600 dark:text-gray-400">
|
|
Try adjusting your search or filters to find what you're looking for.
|
|
</p>
|
|
</div>
|
|
) : viewMode === 'list' ? (
|
|
// Use MediaListView for list mode
|
|
<div className="h-[600px] border border-slate-200 dark:border-slate-700 rounded-lg overflow-hidden">
|
|
<MediaListView
|
|
items={mediaItems}
|
|
onSelect={handleSelect}
|
|
selectedId={selectedId}
|
|
onToggleSelect={handleToggleSelect}
|
|
multiSelectedIds={multiSelectedIds}
|
|
/>
|
|
</div>
|
|
) : viewMode === 'cover' ? (
|
|
// Cover View (Shelf-style) - use dynamic cover size
|
|
<div className="flex flex-wrap gap-4 justify-center">
|
|
{adultContent.map((content: AdultContent, index: number) => (
|
|
<div className="relative group" key={content.id}>
|
|
{(() => {
|
|
const aspectRatio = content.poster_aspect_ratio || 1.5;
|
|
const height = coverSize / aspectRatio;
|
|
return (
|
|
<div
|
|
className="relative overflow-hidden rounded-t-lg shadow-lg hover:shadow-xl transition-all duration-300 transform hover:-translate-y-1"
|
|
style={{
|
|
width: `${coverSize}px`,
|
|
height: `${height}px`
|
|
}}
|
|
>
|
|
{content.poster_url ? (
|
|
<img
|
|
src={content.poster_url.startsWith('http') ? content.poster_url : `${(import.meta as any).env?.VITE_API_URL?.replace('/api', '') || ''}${content.poster_url}`}
|
|
alt={content.title}
|
|
className="w-full h-full object-cover"
|
|
/>
|
|
) : (
|
|
<div className="w-full h-full bg-gradient-to-br from-purple-400 to-pink-400 flex items-center justify-center">
|
|
<Lock className="w-8 h-8 text-white/50" />
|
|
</div>
|
|
)}
|
|
|
|
{/* Shelf effect - bottom shadow */}
|
|
<div className="absolute bottom-0 left-0 right-0 h-2 bg-gradient-to-t from-black/30 to-transparent"></div>
|
|
|
|
{/* Hover overlay */}
|
|
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300">
|
|
<div className="absolute top-2 right-2">
|
|
<motion.button
|
|
className="p-1 bg-purple-600 rounded-full hover:bg-purple-700 transition-colors"
|
|
whileHover={{ scale: 1.1 }}
|
|
whileTap={{ scale: 0.9 }}
|
|
>
|
|
<Play className="w-3 h-3 text-white" />
|
|
</motion.button>
|
|
</div>
|
|
{content.watched && (
|
|
<div className="absolute top-2 left-2">
|
|
<Eye className="w-4 h-4 text-green-400" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})()}
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<motion.div
|
|
layout
|
|
className={`grid gap-6 ${getGridClass(gridColumns)}`}
|
|
>
|
|
{adultContent.map((content: AdultContent, index: number) => (
|
|
<motion.div
|
|
key={content.id}
|
|
layout
|
|
initial={{ opacity: 0, y: 20 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ delay: index * 0.1 }}
|
|
onClick={() => handleContentClick(content)}
|
|
className="group cursor-pointer"
|
|
>
|
|
{/* Grid View */}
|
|
<div className="relative overflow-hidden rounded-xl bg-gray-200 dark:bg-gray-800">
|
|
{content.poster_url || content.screenshot_url ? (
|
|
<img
|
|
src={
|
|
(content.poster_url || content.screenshot_url)?.startsWith('http')
|
|
? (content.poster_url || content.screenshot_url)
|
|
: `${(import.meta as any).env?.VITE_API_URL?.replace('/api', '') || ''}${content.poster_url || content.screenshot_url}`
|
|
}
|
|
alt={content.title}
|
|
className="w-full h-64 object-cover group-hover:scale-105 transition-transform duration-300"
|
|
/>
|
|
) : (
|
|
<div className="w-full h-64 bg-gradient-to-br from-purple-400 to-pink-400 flex items-center justify-center">
|
|
<Lock className="w-16 h-16 text-white/50" />
|
|
</div>
|
|
)}
|
|
|
|
{/* Overlay */}
|
|
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/20 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300">
|
|
<div className="absolute bottom-0 left-0 right-0 p-4">
|
|
<div className="flex items-center gap-2 mb-2">
|
|
<motion.button
|
|
className="p-2 bg-purple-600 rounded-full hover:bg-purple-700 transition-colors"
|
|
whileHover={{ scale: 1.1 }}
|
|
whileTap={{ scale: 0.9 }}
|
|
>
|
|
<Play className="w-4 h-4 text-white" />
|
|
</motion.button>
|
|
<motion.button
|
|
className="p-2 bg-white/20 backdrop-blur-sm rounded-full hover:bg-white/30 transition-colors"
|
|
whileHover={{ scale: 1.1 }}
|
|
whileTap={{ scale: 0.9 }}
|
|
>
|
|
<Plus className="w-4 h-4 text-white" />
|
|
</motion.button>
|
|
{content.watched && (
|
|
<Tooltip text="Watched">
|
|
<Eye className="w-5 h-5 text-green-400" />
|
|
</Tooltip>
|
|
)}
|
|
</div>
|
|
<h3 className="text-white font-semibold text-sm line-clamp-2 mb-1">{content.title}</h3>
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
{content.source_name && (
|
|
<div className="px-2 py-1 bg-blue-500/80 text-white rounded text-xs font-medium">
|
|
{content.source_name}
|
|
</div>
|
|
)}
|
|
{content.rating && renderRating(content.rating)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
))}
|
|
</motion.div>
|
|
)}
|
|
|
|
{/* Pagination Component */}
|
|
{PaginationComp && pagination && pagination.last_page > 1 && (
|
|
<PaginationComp
|
|
currentPage={currentPage}
|
|
lastPage={pagination.last_page}
|
|
total={pagination.total}
|
|
onPageChange={handlePageChange}
|
|
itemsPerPage={itemsPerPage}
|
|
onItemsPerPageChange={handleItemsPerPageChange}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|