first commit

This commit is contained in:
Lars Behrends
2026-01-21 21:40:09 +01:00
commit 4853b860fc
45 changed files with 16072 additions and 0 deletions

8
.env.example Normal file
View File

@@ -0,0 +1,8 @@
# API Configuration
VITE_API_URL=http://192.168.1.102:57000/
# Development settings
VITE_DEV_MODE=false
# Optional: Override API base URL for different environments
# VITE_API_URL=https://your-domain.com/api

43
.gitignore vendored Normal file
View File

@@ -0,0 +1,43 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Environment variables
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Build output
build/
*.tsbuildinfo
# Testing
coverage/
.nyc_output
# Temporary files
*.tmp
*.temp

126
API_INTEGRATION.md Normal file
View File

@@ -0,0 +1,126 @@
# API Integration Guide
This document explains how the React frontend integrates with the PHP backend API.
## API Configuration
### Environment Setup
1. Copy `.env.example` to `.env`:
```bash
cp .env.example .env
```
2. Configure your API URL in `.env`:
```env
VITE_API_URL=http://localhost:8080/api
```
### Available API Endpoints
Based on your backend routes, the following endpoints are available:
#### Authentication
- `POST /api/auth/login` - User login
- `POST /api/auth/register` - User registration
- `POST /api/auth/refresh` - Refresh JWT token
- `GET /api/auth/me` - Get current user (protected)
#### Media
- `GET /api/movies` - List movies with pagination
- `GET /api/movies/{id}` - Get single movie
- `GET /api/tvshows` - List TV shows with pagination
- `GET /api/tvshows/{id}` - Get single TV show
- `GET /api/games` - List games with pagination
- `GET /api/games/{id}` - Get single game
#### Search
- `GET /api/search?q={query}&type={type}` - Search across media types
#### System
- `GET /api/status` - API health check
### API Service Structure
The frontend is organized with:
1. **API Service** (`src/services/api.ts`)
- Axios configuration with interceptors
- Authentication handling
- Endpoint methods
2. **React Hooks** (`src/hooks/useApi.ts`)
- React Query integration
- Custom hooks for each endpoint
- Caching and error handling
3. **Updated Components**
- Movies page now uses `useMovies()` hook
- Dashboard uses `useDashboardStats()` and `useRecentActivity()`
- Search page uses `useSearch()` hook
### Authentication Flow
1. Login via `/api/auth/login`
2. Store JWT token in localStorage
3. Token automatically added to all requests
4. Auto-logout on 401 responses
### Pagination
All list endpoints support:
- `page` - Page number (default: 1)
- `per_page` - Items per page (default: 20)
- `search` - Search term
- `genre` - Filter by genre
- `year` - Filter by year
### Error Handling
- 401: Auto-redirect to login
- 404: Show "not found" message
- 500: Show generic error message
- Network: Show connection error
### Next Steps
1. **Complete Backend Endpoints**: Your MediaController needs implementations for:
- `listMovies()` method
- `getMovie()` method
- Dashboard stats endpoint
- Recent activity endpoint
2. **Add Missing Features**:
- Music API endpoints
- Adult content API endpoints
- Actors/performers API endpoints
- CRUD operations (create, update, delete)
3. **Testing**:
- Test API connectivity
- Test authentication flow
- Test error handling
### Example API Response Format
```json
{
"success": true,
"data": {
"items": [...],
"pagination": {
"total": 100,
"per_page": 20,
"current_page": 1,
"last_page": 5
}
}
}
```
### Development Notes
- The frontend includes fallback mock data
- API calls are made through React Query for caching
- All requests include JWT authentication headers
- CORS is configured for development

120
README.md Normal file
View File

@@ -0,0 +1,120 @@
# Media Collector - Modern React Frontend
A modern React-based frontend for the Media Collector application, built with Vite, TypeScript, and Tailwind CSS.
## Features
- 🎬 **Modern UI**: Clean, responsive design using Tailwind CSS
-**Fast Development**: Vite for lightning-fast development and builds
- 🔷 **TypeScript**: Full type safety throughout the application
- 🎯 **Component-Based**: Modular, reusable React components
- 📱 **Responsive**: Mobile-first design that works on all devices
- 🎭 **Media Management**: Support for movies, TV shows, games, music, and adult content
- 🔍 **Search**: Unified search across all media types
- 📊 **Dashboard**: Overview of your media collection
## Tech Stack
- **React 18** - UI framework
- **TypeScript** - Type safety
- **Vite** - Build tool and dev server
- **Tailwind CSS** - Utility-first CSS framework
- **React Router** - Client-side routing
- **React Query** - Data fetching and caching
- **Heroicons** - Beautiful SVG icons
- **Axios** - HTTP client
## Getting Started
### Prerequisites
- Node.js 18+
- npm or yarn
### Installation
1. Navigate to the frontend directory:
```bash
cd frontend
```
2. Install dependencies:
```bash
npm install
```
3. Start the development server:
```bash
npm run dev
```
The application will be available at `http://localhost:3000`
### Building for Production
```bash
npm run build
```
The built files will be output to `../public/react` for integration with the PHP backend.
## Project Structure
```
src/
├── components/ # Reusable UI components
│ ├── Layout.tsx # Main application layout
│ └── MovieCard.tsx # Movie display component
├── pages/ # Page components
│ ├── Dashboard.tsx # Dashboard overview
│ ├── Movies.tsx # Movies listing
│ ├── TVShows.tsx # TV shows listing
│ ├── Games.tsx # Games listing
│ ├── Music.tsx # Music listing
│ ├── Adult.tsx # Adult content
│ ├── Actors.tsx # Actors/performers
│ └── Search.tsx # Search functionality
├── types/ # TypeScript type definitions
│ └── index.ts # Main types file
├── App.tsx # Main application component
├── main.tsx # Application entry point
└── index.css # Global styles
```
## Available Scripts
- `npm run dev` - Start development server
- `npm run build` - Build for production
- `npm run preview` - Preview production build
- `npm run lint` - Run ESLint
## Configuration
- **Vite**: `vite.config.ts`
- **TypeScript**: `tsconfig.json`
- **Tailwind**: `tailwind.config.js`
- **PostCSS**: `postcss.config.js`
## Integration with PHP Backend
The React frontend is designed to work alongside the existing PHP backend:
1. API requests are proxied from `/api` to the PHP backend
2. Built files are output to `../public/react` for serving
3. The frontend can replace or complement the existing Twig templates
## Development Notes
- The project uses path aliases (`@/`) for clean imports
- Components are built with TypeScript for full type safety
- Tailwind CSS is configured with custom colors and animations
- The layout mirrors the original Twig template structure
## Future Enhancements
- [ ] Complete API integration
- [ ] Add real-time updates
- [ ] Implement advanced filtering
- [ ] Add media details pages
- [ ] Include user preferences
- [ ] Add dark mode support

91
index.html Normal file
View File

@@ -0,0 +1,91 @@
<!DOCTYPE html>
<html lang="de" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>MediaVault - Deine Sammlung</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif'],
},
colors: {
brand: {
50: '#f0f9ff',
100: '#e0f2fe',
400: '#38bdf8',
500: '#0ea5e9',
600: '#0284c7',
900: '#0c4a6e',
},
dark: {
bg: '#0f172a',
surface: '#1e293b',
border: '#334155'
}
},
animation: {
'float': 'float 6s ease-in-out infinite',
},
keyframes: {
float: {
'0%, 100%': { transform: 'translateY(0)' },
'50%': { transform: 'translateY(-10px)' },
}
}
}
}
}
</script>
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
body {
font-family: 'Inter', sans-serif;
background-color: #020617; /* Very dark slate */
/* Mesh Gradient Background */
background-image:
radial-gradient(at 0% 0%, hsla(253,16%,7%,1) 0, transparent 50%),
radial-gradient(at 50% 0%, hsla(225,39%,30%,1) 0, transparent 50%),
radial-gradient(at 100% 0%, hsla(339,49%,30%,1) 0, transparent 50%);
background-attachment: fixed;
}
/* Native-like Scrollbar */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.1);
border-radius: 10px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.2);
}
.glass-panel {
background: rgba(30, 41, 59, 0.7);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.05);
}
.mask-image-gradient {
mask-image: linear-gradient(to bottom, black 50%, transparent 100%);
-webkit-mask-image: linear-gradient(to bottom, black 50%, transparent 100%);
}
</style>
</head>
<body class="text-slate-200 antialiased h-screen overflow-hidden selection:bg-brand-500/30">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

4374
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

42
package.json Normal file
View File

@@ -0,0 +1,42 @@
{
"name": "media-collector-frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
},
"dependencies": {
"@headlessui/react": "^1.7.17",
"@heroicons/react": "^2.0.18",
"@tanstack/react-query": "^5.90.16",
"@tanstack/react-query-devtools": "^5.91.2",
"axios": "^1.6.2",
"clsx": "^2.0.0",
"framer-motion": "^12.24.12",
"lucide-react": "^0.562.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hot-toast": "^2.4.1",
"react-query": "^3.39.3",
"react-router-dom": "^6.20.1"
},
"devDependencies": {
"@types/react": "^18.2.37",
"@types/react-dom": "^18.2.15",
"@typescript-eslint/eslint-plugin": "^6.10.0",
"@typescript-eslint/parser": "^6.10.0",
"@vitejs/plugin-react": "^4.1.1",
"autoprefixer": "^10.4.16",
"eslint": "^8.53.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.4",
"postcss": "^8.4.32",
"tailwindcss": "^3.3.6",
"typescript": "^5.2.2",
"vite": "^4.5.0"
}
}

6
postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

48
src/App.tsx Normal file
View File

@@ -0,0 +1,48 @@
import React from 'react'
import { Routes, Route } from 'react-router-dom'
import Layout from './components/Layout'
import ErrorBoundary from './components/ErrorBoundary'
import Dashboard from './pages/Dashboard'
import Movies from './pages/Movies'
import MovieDetail from './pages/MovieDetail'
import TVShows from './pages/TVShows'
import TVShowDetail from './pages/TVShowDetail'
import GamesPage from './pages/GamesPage'
import GameDetail from './pages/GameDetail'
import Music from './pages/Music'
import Adult from './pages/Adult'
import AdultDetail from './pages/AdultDetail'
import Actors from './pages/Actors'
import ActorDetail from './pages/ActorDetail'
import Search from './pages/Search'
import Games from './pages/Games'
function App() {
return (
<div className="h-screen w-screen overflow-hidden">
<ErrorBoundary>
<Layout>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/movies" element={<Movies />} />
<Route path="/movies/:id" element={<MovieDetail />} />
<Route path="/tvshows" element={<TVShows />} />
<Route path="/tvshows/:id" element={<TVShowDetail />} />
<Route path="/games" element={<Games />} />
<Route path="/games/:id" element={<GameDetail />} />
<Route path="/music" element={<Music />} />
<Route path="/music/:id" element={<Music />} />
<Route path="/adult" element={<Adult />} />
<Route path="/adult/:id" element={<AdultDetail />} />
<Route path="/actors" element={<Actors />} />
<Route path="/actors/:id" element={<ActorDetail />} />
<Route path="/search" element={<Search />} />
</Routes>
</Layout>
</ErrorBoundary>
</div>
)
}
export default App

View File

@@ -0,0 +1,245 @@
import { useState } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { Filter, X } from 'lucide-react'
interface FilterOptions {
sources: string[]
genres: string[]
years: string[]
}
interface FilterState {
source: string
genre: string
year: string
search: string
}
interface ContentFiltersProps {
filters: FilterState
availableFilters: FilterOptions
onFiltersChange: (filters: FilterState) => void
onSearchChange: (search: string) => void
className?: string
}
export default function ContentFilters({
filters,
availableFilters,
onFiltersChange,
onSearchChange,
className = ''
}: ContentFiltersProps) {
const [showFilters, setShowFilters] = useState(false)
const handleFilterChange = (key: keyof FilterState, value: string) => {
const newFilters = { ...filters, [key]: value }
onFiltersChange(newFilters)
}
const handleClearFilters = () => {
onFiltersChange({
source: '',
genre: '',
year: '',
search: ''
})
onSearchChange('')
}
const hasActiveFilters = filters.source || filters.genre || filters.year || filters.search
const sources = ['All', ...availableFilters.sources]
const genres = ['All', ...availableFilters.genres]
const years = ['All', ...availableFilters.years]
return (
<div className={`space-y-4 ${className}`}>
{/* Filter Toggle and Search */}
<div className="flex flex-col sm:flex-row gap-4">
{/* Search Bar */}
<div className="flex-1 relative">
<input
type="text"
placeholder="Search content..."
value={filters.search}
onChange={(e) => {
onSearchChange(e.target.value)
handleFilterChange('search', e.target.value)
}}
className="w-full px-4 py-2.5 bg-white/10 dark:bg-gray-800/50 border border-white/10 rounded-xl text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all"
/>
</div>
{/* Filter Toggle Button */}
<motion.button
onClick={() => setShowFilters(!showFilters)}
className={`flex items-center gap-2 px-4 py-2.5 rounded-xl border transition-all ${
hasActiveFilters
? 'bg-purple-600/20 border-purple-500/50 text-purple-400'
: 'bg-white/10 border-white/10 text-gray-300 hover:bg-white/20'
}`}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
<Filter className="w-4 h-4" />
Filters
{hasActiveFilters && (
<span className="w-2 h-2 bg-purple-400 rounded-full"></span>
)}
</motion.button>
</div>
{/* Expandable Filter Panel */}
<AnimatePresence>
{showFilters && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
className="bg-white/5 dark:bg-gray-800/50 rounded-xl p-6 border border-white/10"
>
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-white">Filter Options</h3>
<button
onClick={() => setShowFilters(false)}
className="text-gray-400 hover:text-white transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{/* Source Filter */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Source
</label>
<select
value={filters.source}
onChange={(e) => handleFilterChange('source', e.target.value)}
className="w-full px-3 py-2 bg-white/10 border border-white/10 rounded-lg text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all"
>
{sources.map(source => (
<option key={source} value={source === 'All' ? '' : source}>
{source}
</option>
))}
</select>
</div>
{/* Genre Filter */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Genre
</label>
<select
value={filters.genre}
onChange={(e) => handleFilterChange('genre', e.target.value)}
className="w-full px-3 py-2 bg-white/10 border border-white/10 rounded-lg text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all"
>
{genres.map(genre => (
<option key={genre} value={genre === 'All' ? '' : genre}>
{genre}
</option>
))}
</select>
</div>
{/* Year Filter */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Year
</label>
<select
value={filters.year}
onChange={(e) => handleFilterChange('year', e.target.value)}
className="w-full px-3 py-2 bg-white/10 border border-white/10 rounded-lg text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all"
>
{years.map(year => (
<option key={year} value={year === 'All' ? '' : year}>
{year}
</option>
))}
</select>
</div>
</div>
{/* Clear Filters Button */}
<div className="mt-4 flex justify-end">
<button
onClick={handleClearFilters}
className="px-4 py-2 text-sm bg-red-600/20 border border-red-500/50 text-red-400 rounded-lg hover:bg-red-600/30 transition-colors"
>
Clear All Filters
</button>
</div>
</motion.div>
)}
</AnimatePresence>
{/* Active Filter Tags */}
{hasActiveFilters && (
<div className="flex flex-wrap gap-2">
{filters.search && (
<FilterTag
label="Search"
value={filters.search}
onRemove={() => {
handleFilterChange('search', '')
onSearchChange('')
}}
/>
)}
{filters.source && (
<FilterTag
label="Source"
value={filters.source}
onRemove={() => handleFilterChange('source', '')}
/>
)}
{filters.genre && (
<FilterTag
label="Genre"
value={filters.genre}
onRemove={() => handleFilterChange('genre', '')}
/>
)}
{filters.year && (
<FilterTag
label="Year"
value={filters.year}
onRemove={() => handleFilterChange('year', '')}
/>
)}
</div>
)}
</div>
)
}
interface FilterTagProps {
label: string
value: string
onRemove: () => void
}
function FilterTag({ label, value, onRemove }: FilterTagProps) {
return (
<motion.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
className="inline-flex items-center gap-1 px-3 py-1 bg-purple-600/20 border border-purple-500/50 text-purple-300 rounded-full text-sm"
>
<span className="font-medium">{label}:</span>
<span>{value}</span>
<button
onClick={onRemove}
className="ml-1 text-purple-400 hover:text-purple-200 transition-colors"
>
<X className="w-3 h-3" />
</button>
</motion.div>
)
}

View File

@@ -0,0 +1,52 @@
import React, { Component, ReactNode } from 'react'
import { ExclamationTriangleIcon } from '@heroicons/react/24/outline'
interface Props {
children: ReactNode
}
interface State {
hasError: boolean
error?: Error
}
class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props)
this.state = { hasError: false }
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error }
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error('Error caught by boundary:', error, errorInfo)
}
render() {
if (this.state.hasError) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<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 mb-2">Something went wrong</h2>
<p className="text-gray-600 mb-6">
{this.state.error?.message || 'An unexpected error occurred'}
</p>
<button
onClick={() => window.location.reload()}
className="btn btn-primary"
>
Reload Page
</button>
</div>
</div>
)
}
return this.props.children
}
}
export default ErrorBoundary

402
src/components/GameCard.tsx Normal file
View File

@@ -0,0 +1,402 @@
import { Link } from 'react-router-dom'
import { motion } from 'framer-motion'
import {
ComputerDesktopIcon as GamepadIcon,
StarIcon,
ClockIcon,
PlayIcon,
HeartIcon,
CheckCircleIcon,
TrophyIcon
} from '@heroicons/react/24/outline'
import { clsx } from 'clsx'
import { Tooltip } from './MicroInteractions'
export interface GameItem {
id: number
title: string
poster_url?: string
backdrop_url?: string
rating?: number
release_date?: string
playtime_hours?: number
source_name?: string
platform?: string
platforms?: string[]
platform_count?: number
platform_details?: Array<{ platform: string; display_name: string }>
developer?: string
genres?: string[]
completion_status?: 'BEATEN' | 'PLAYING' | 'UNPLAYED' | 'COMPLETED'
last_played?: string
community_score?: number
critic_score?: number
max_completion?: number
total_playtime?: number
}
export interface GameCardProps {
game: GameItem
viewMode: 'grid' | 'list' | 'covers'
coverSize?: 'small' | 'medium' | 'large' | 'xlarge'
className?: string
}
const completionStatusConfig = {
BEATEN: {
icon: TrophyIcon,
color: 'from-yellow-500 to-amber-600',
bgColor: 'bg-yellow-100 text-yellow-800',
label: 'BEATEN'
},
PLAYING: {
icon: PlayIcon,
color: 'from-blue-500 to-blue-600',
bgColor: 'bg-blue-100 text-blue-800',
label: 'PLAYING'
},
COMPLETED: {
icon: CheckCircleIcon,
color: 'from-green-500 to-green-600',
bgColor: 'bg-green-100 text-green-800',
label: 'COMPLETED'
},
UNPLAYED: {
icon: GamepadIcon,
color: 'from-gray-500 to-gray-600',
bgColor: 'bg-gray-100 text-gray-800',
label: 'UNPLAYED'
}
}
export default function GameCard({
game,
viewMode,
coverSize = 'medium',
className = ''
}: GameCardProps) {
// Cover size mappings
const listCoverSizeClasses = {
small: { width: '40px', height: '60px' },
medium: { width: '64px', height: '96px' },
large: { width: '80px', height: '120px' },
xlarge: { width: '96px', height: '144px' }
}
const coversHeightClasses = {
small: 'h-[28rem]',
medium: 'h-[28rem]',
large: 'h-[28rem]',
xlarge: 'h-[28rem]'
}
const statusConfig = game.completion_status ? completionStatusConfig[game.completion_status] : completionStatusConfig.UNPLAYED
const StatusIcon = statusConfig.icon
const formatPlaytime = (hours?: number) => {
if (!hours) return null
if (hours < 1) return `${Math.round(hours * 60)}m`
return `${hours}h`
}
const formatYear = (date?: string) => {
if (!date) return null
const parsedDate = new Date(date)
return isNaN(parsedDate.getTime()) ? null : parsedDate.getFullYear()
}
// Determine if this is a grouped game
const isGrouped = game.platform_count && game.platform_count > 1
const displayPlatforms = game.platforms || (game.platform ? [game.platform] : [])
const displayPlaytime = game.playtime_hours || (game.total_playtime ? game.total_playtime / 60 : undefined)
const displayCompletion = game.max_completion || game.completion_status
// List view
if (viewMode === 'list') {
return (
<motion.div
className={clsx(
"flex gap-4 p-4 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 hover:shadow-lg transition-shadow cursor-pointer",
className
)}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
whileHover={{ y: -2 }}
layout
>
<Link to={`/games/${game.id}`} className="flex gap-4 w-full">
<div className="w-32 h-48 flex-shrink-0 bg-gray-200 dark:bg-gray-700 rounded-lg overflow-hidden">
{game.poster_url ? (
<img
src={game.poster_url.startsWith('http') ? game.poster_url : `${(import.meta as any).env?.VITE_API_URL?.replace('/api', '') || ''}/images/playnite/${game.poster_url}`}
alt={game.title}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full bg-gradient-to-br from-green-400 to-emerald-500 flex items-center justify-center">
<GamepadIcon className="w-8 h-8 text-white/50" />
</div>
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between">
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-1">{game.title}</h3>
<div className="flex items-center gap-4 text-sm text-gray-500 dark:text-gray-400 mb-2">
<div className="flex items-center gap-1">
<ClockIcon className="w-4 h-4" />
<span>{formatPlaytime(displayPlaytime) || 'Unknown'}</span>
</div>
{isGrouped ? (
<div className="flex items-center gap-1">
<span className="px-2 py-1 bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-200 rounded text-xs font-medium">
{game.platform_count} Platforms
</span>
<div className="flex gap-1">
{displayPlatforms.slice(0, 3).map((platform, index) => (
<span
key={platform}
className="px-2 py-1 bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-200 rounded text-xs font-medium"
>
{platform}
</span>
))}
{displayPlatforms.length > 3 && (
<span className="px-2 py-1 bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 text-xs rounded">
+{displayPlatforms.length - 3}
</span>
)}
</div>
</div>
) : game.platform ? (
<span className="px-2 py-1 bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-200 rounded text-xs font-medium">
{game.platform}
</span>
) : null}
{game.developer && (
<span>{game.developer}</span>
)}
{game.release_date && (
<span>{formatYear(game.release_date)}</span>
)}
</div>
{game.genres && Array.isArray(game.genres) && game.genres.length > 0 && (
<div className="flex flex-wrap gap-1 mb-2">
{game.genres.slice(0, 3).map((genre, index) => (
<span
key={typeof genre === 'string' ? genre : `genre-${index}`}
className="px-2 py-1 bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-200 rounded text-xs"
>
{typeof genre === 'string' ? genre : 'Unknown'}
</span>
))}
{game.genres.length > 3 && (
<span className="px-2 py-1 bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 text-xs rounded">
+{game.genres.length - 3}
</span>
)}
</div>
)}
</div>
<div className="flex items-center gap-2">
{game.rating && (
<div className="flex items-center gap-1">
<StarIcon className="w-4 h-4 text-yellow-500" />
<span className="text-sm font-medium">{game.rating}/10</span>
</div>
)}
{game.completion_status && (
<span className={clsx("px-2 py-1 rounded-full text-xs font-medium", statusConfig.bgColor)}>
{statusConfig.label}
</span>
)}
<motion.button
className="p-2 bg-green-600 rounded-full hover:bg-green-700 transition-colors"
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
>
<PlayIcon className="w-4 h-4 text-white" />
</motion.button>
</div>
</div>
</div>
</Link>
</motion.div>
)
}
// Covers view
if (viewMode === 'covers') {
return (
<motion.div
className={clsx(
"relative group cursor-pointer overflow-hidden",
className
)}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
whileHover={{ y: -5 }}
layout
>
<Link to={`/games/${game.id}`}>
{/* Calculate height based on 2:3 aspect ratio */}
{(() => {
const aspectRatio = 2/3; // Game posters are 2:3 aspect ratio
const height = 200 / aspectRatio; // Base width of 200px
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: '200px',
height: `${height}px`
}}
>
{game.poster_url ? (
<img
src={game.poster_url.startsWith('http') ? game.poster_url : `${(import.meta as any).env?.VITE_API_URL?.replace('/api', '') || ''}/images/playnite/${game.poster_url}`}
alt={game.title}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full bg-gradient-to-br from-green-400 to-emerald-500 flex items-center justify-center">
<GamepadIcon 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-green-600 rounded-full hover:bg-green-700 transition-colors"
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
>
<PlayIcon className="w-3 h-3 text-white" />
</motion.button>
</div>
{game.completion_status && game.completion_status !== 'UNPLAYED' && (
<div className="absolute top-2 left-2">
<Tooltip text={statusConfig.label}>
<StatusIcon className="w-4 h-4 text-green-400" />
</Tooltip>
</div>
)}
</div>
</div>
);
})()}
</Link>
</motion.div>
)
}
// Grid view (default)
return (
<motion.div
className={clsx(
"group cursor-pointer overflow-hidden rounded-xl",
"transform transition-all duration-300",
className
)}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
whileHover={{ y: -5 }}
layout
>
<Link to={`/games/${game.id}`}>
<div className="relative overflow-hidden rounded-xl bg-gray-200 dark:bg-gray-800">
{game.poster_url ? (
<img
src={game.poster_url.startsWith('http') ? game.poster_url : `${(import.meta as any).env?.VITE_API_URL?.replace('/api', '') || ''}/images/playnite/${game.poster_url}`}
alt={game.title}
className="w-full aspect-[2/3] object-cover group-hover:scale-105 transition-transform duration-300"
/>
) : (
<div className="w-full aspect-[2/3] bg-gradient-to-br from-green-400 to-emerald-500 flex items-center justify-center">
<GamepadIcon 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-green-600 rounded-full hover:bg-green-700 transition-colors"
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
>
<PlayIcon 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 }}
>
<HeartIcon className="w-4 h-4 text-white" />
</motion.button>
{game.completion_status && game.completion_status !== 'UNPLAYED' && (
<Tooltip text={statusConfig.label}>
<StatusIcon className="w-5 h-5 text-green-400" />
</Tooltip>
)}
</div>
<h3 className="text-white font-semibold text-sm line-clamp-2 mb-1">{game.title}</h3>
<div className="flex items-center gap-2 flex-wrap">
{isGrouped ? (
<>
<span className="px-2 py-1 bg-blue-500/80 text-white rounded text-xs font-medium">
{game.platform_count} Platforms
</span>
{displayPlatforms.slice(0, 2).map((platform) => (
<span
key={platform}
className="px-2 py-1 bg-green-500/80 text-white rounded text-xs font-medium"
>
{platform}
</span>
))}
{displayPlatforms.length > 2 && (
<span className="px-2 py-1 bg-gray-500/80 text-white rounded text-xs font-medium">
+{displayPlatforms.length - 2}
</span>
)}
</>
) : game.platform ? (
<span className="px-2 py-1 bg-green-500/80 text-white rounded text-xs font-medium">
{game.platform}
</span>
) : null}
{game.rating && (
<span className="flex items-center gap-1 px-2 py-1 bg-yellow-500/80 text-white rounded text-xs font-medium">
<StarIcon className="w-3 h-3" />
{game.rating}
</span>
)}
</div>
</div>
</div>
{/* Completion status indicator */}
{game.completion_status && game.completion_status !== 'UNPLAYED' && (
<motion.div
className="absolute top-3 right-3 bg-white/90 backdrop-blur-sm px-3 py-1 rounded-full text-xs font-semibold shadow-lg"
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: "spring", stiffness: 500 }}
>
<div className="flex items-center gap-1">
<StatusIcon className="w-3 h-3" />
{statusConfig.label}
</div>
</motion.div>
)}
</div>
</Link>
</motion.div>
)
}

368
src/components/Layout.tsx Normal file
View File

@@ -0,0 +1,368 @@
import React, { useState, useEffect, createContext } from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import {
Search, Plus, Loader2, LayoutGrid, List, Barcode, ZoomIn, ZoomOut, Bell, Menu, X
} from 'lucide-react'
import { Sidebar } from './Sidebar'
import SearchDropdown from './SearchDropdown'
import Pagination from './Pagination'
import ContentFilters from './ContentFilters'
interface LayoutProps {
children: React.ReactNode
}
interface ViewContextType {
viewMode: 'grid' | 'list' | 'cover'
gridColumns: number
coverSize: number
onViewModeChange: (mode: 'grid' | 'list' | 'cover') => void
onGridColumnsChange: (columns: number) => void
onCoverSizeChange: (size: number) => void
PaginationComponent: React.ComponentType<any>
FiltersComponent: React.ComponentType<any>
}
// Create View Context
export const ViewContext = createContext<ViewContextType | null>(null)
// Global state for view controls
let globalViewMode: 'grid' | 'list' | 'cover' = 'grid'
let globalGridColumns: number = 5
let globalCoverSize: number = 200
export default function Layout({ children }: LayoutProps) {
const [currentView, setCurrentView] = useState('dashboard')
const [viewMode, setViewMode] = useState<'grid' | 'list' | 'cover'>(globalViewMode)
const [gridColumns, setGridColumns] = useState<number>(globalGridColumns)
const [coverSize, setCoverSize] = useState<number>(globalCoverSize)
const [searchInput, setSearchInput] = useState('')
const [showSearchDropdown, setShowSearchDropdown] = useState(false)
const [isLoading] = useState(false)
const [stats] = useState<any>(null)
const [isSidebarOpen, setIsSidebarOpen] = useState(false)
const [isMobile, setIsMobile] = useState(false)
const location = useLocation()
const navigate = useNavigate()
// Update global state when local state changes
useEffect(() => {
globalViewMode = viewMode
}, [viewMode])
useEffect(() => {
globalGridColumns = gridColumns
}, [gridColumns])
useEffect(() => {
globalCoverSize = coverSize
}, [coverSize])
// Map current path to view
useEffect(() => {
const path = location.pathname.replace('/', '') || 'dashboard'
setCurrentView(path)
}, [location.pathname])
// Handle mobile detection
useEffect(() => {
const checkMobile = () => {
setIsMobile(window.innerWidth < 768)
if (window.innerWidth >= 768) {
setIsSidebarOpen(false)
}
}
checkMobile()
window.addEventListener('resize', checkMobile)
return () => window.removeEventListener('resize', checkMobile)
}, [])
// Adjust grid columns based on screen size
useEffect(() => {
const adjustGridColumns = () => {
if (window.innerWidth < 640) {
setGridColumns(2)
} else if (window.innerWidth < 1024) {
setGridColumns(3)
} else if (window.innerWidth < 1280) {
setGridColumns(4)
}
}
if (viewMode === 'grid') {
adjustGridColumns()
window.addEventListener('resize', adjustGridColumns)
return () => window.removeEventListener('resize', adjustGridColumns)
}
}, [viewMode])
// Search handlers
const handleSearchFocus = () => {
setShowSearchDropdown(true)
}
const handleSearchChange = (value: string) => {
setSearchInput(value)
if (!showSearchDropdown && value.trim()) {
setShowSearchDropdown(true)
}
}
const handleSearchKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && searchInput.trim()) {
navigate(`/search?q=${encodeURIComponent(searchInput.trim())}${currentView !== 'dashboard' ? `&type=${currentView}` : ''}`)
setShowSearchDropdown(false)
}
if (e.key === 'Escape') {
setShowSearchDropdown(false)
}
}
const closeSearchDropdown = () => {
setShowSearchDropdown(false)
}
const getViewTitle = () => {
const titles: Record<string, string> = {
dashboard: 'Dashboard',
movies: 'Filme',
tvshows: 'Serien',
games: 'Spiele',
music: 'Musik',
adult: 'Adult Library (18+)',
actors: 'Personen',
settings: 'Einstellungen'
}
return titles[currentView] || 'Bibliothek'
}
const isNavControlsVisible = currentView !== 'settings' && currentView !== 'dashboard'
const isAdultView = currentView === 'adult'
const showCoverControls = isAdultView && viewMode === 'cover'
// Create view context value
const viewContextValue: ViewContextType = {
viewMode,
gridColumns,
coverSize,
onViewModeChange: setViewMode,
onGridColumnsChange: setGridColumns,
onCoverSizeChange: setCoverSize,
PaginationComponent: Pagination,
FiltersComponent: ContentFilters
}
// Debug logs
console.log('Layout.tsx - State:', { viewMode, gridColumns, coverSize, currentView })
console.log('Layout.tsx - viewContextValue:', viewContextValue)
return (
<div className="flex h-screen overflow-hidden relative">
{/* Mobile Menu Overlay */}
{isMobile && isSidebarOpen && (
<div
className="fixed inset-0 bg-black/50 z-40 md:hidden"
onClick={() => setIsSidebarOpen(false)}
/>
)}
{/* Mobile Menu Button */}
{isMobile && (
<button
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
className="fixed top-4 left-4 z-50 p-2 bg-slate-800/90 backdrop-blur-sm rounded-lg border border-white/10 text-white hover:bg-slate-700/90 transition-colors md:hidden"
>
{isSidebarOpen ? <X size={20} /> : <Menu size={20} />}
</button>
)}
{/* Modern Sidebar */}
<div className={`
${isMobile ? (isSidebarOpen ? 'translate-x-0' : '-translate-x-full') : 'translate-x-0'}
fixed md:relative z-50 md:z-auto transition-transform duration-300 ease-in-out
`}>
<Sidebar
currentView={currentView}
onChangeView={(view) => {
const path = view === 'dashboard' ? '/' : `/${view}`
navigate(path)
if (isMobile) setIsSidebarOpen(false)
}}
stats={stats}
/>
</div>
{/* Main Content Area - Card-like container */}
<main className="flex-1 my-2 md:my-4 mr-2 md:mr-4 bg-dark-surface/60 backdrop-blur-2xl rounded-[16px] md:rounded-[32px] border border-white/5 flex flex-col overflow-hidden shadow-2xl relative">
{/* Modern Floating Header */}
<div className={`px-4 md:px-8 py-3 md:py-5 flex items-center justify-between shrink-0 z-20 flex-wrap gap-4 ${isMobile ? 'bg-slate-900/95 backdrop-blur-sm border-b border-white/10' : ''}`}>
{/* Left: Title & Add */}
<div className="flex items-center gap-3 md:gap-6 min-w-0">
<h2 className="text-lg md:text-2xl font-bold text-white tracking-tight capitalize truncate">
{getViewTitle()}
</h2>
{isNavControlsVisible && !isMobile && (
<button
className="flex items-center justify-center w-8 h-8 md:w-10 md:h-10 rounded-full bg-brand-500 hover:bg-brand-400 text-white transition-all hover:scale-105 shadow-lg shadow-brand-500/25 shrink-0"
title="Add Media"
>
<Plus size={16} strokeWidth={3} />
</button>
)}
</div>
{/* Center: Spotlight Search (Hidden on Settings or Detail) */}
{isNavControlsVisible && (
<div className={`flex-1 ${isMobile ? 'order-3 w-full' : 'max-w-xl mx-8'} relative group`}>
<div className="absolute inset-0 bg-brand-500/10 rounded-full blur-xl opacity-0 group-hover:opacity-100 transition-opacity duration-500"></div>
<div className="relative flex items-center bg-slate-800/50 border border-white/10 hover:border-white/20 hover:bg-slate-800 rounded-full px-3 md:px-4 h-10 md:h-12 transition-all">
<Search size={16} className="text-slate-400 mr-2 md:mr-3" />
<input
type="text"
placeholder={isMobile ? "Suche..." : "Suche in deiner Sammlung..."}
value={searchInput}
onFocus={handleSearchFocus}
onChange={(e) => handleSearchChange(e.target.value)}
onKeyDown={handleSearchKeyDown}
className="flex-1 bg-transparent text-white placeholder-slate-500 focus:outline-none text-sm"
/>
{isLoading && <Loader2 size={14} className="animate-spin text-slate-400 mr-2"/>}
{!isMobile && <div className="h-5 w-px bg-white/10 mx-3"></div>}
{!isMobile && (
<button className="text-slate-400 hover:text-white transition-colors">
<Barcode size={18} />
</button>
)}
{/* Search Dropdown */}
{showSearchDropdown && (
<SearchDropdown
query={searchInput}
onClose={closeSearchDropdown}
currentSection={currentView === 'dashboard' ? undefined : currentView}
/>
)}
</div>
</div>
)}
{!isNavControlsVisible && <div className="flex-1"></div>}
{/* Right: Controls */}
<div className="flex items-center gap-2 md:gap-4">
{/* Notifications (Mock) - Hidden on mobile */}
{!isMobile && (
<button className="relative p-2 text-slate-400 hover:text-white transition-colors">
<Bell size={20} />
<span className="absolute top-2 right-2 w-2 h-2 bg-red-500 rounded-full"></span>
</button>
)}
{!isMobile && <div className="h-6 w-px bg-white/10"></div>}
{/* View Toggles (Hidden on Settings or Detail) */}
{viewMode === 'grid' && isNavControlsVisible && (
<div className="flex items-center gap-1 md:gap-2 bg-slate-800/50 border border-white/5 rounded-full px-2 md:px-3 py-1.5">
<ZoomOut size={12} className="text-slate-400" />
<input
type="range"
min="2"
max="8"
step="1"
value={gridColumns}
onChange={(e) => {
const newColumns = parseInt(e.target.value)
console.log('Layout.tsx - Grid slider changed:', newColumns)
setGridColumns(newColumns)
}}
className="w-12 md:w-16 h-1 bg-slate-600 rounded-lg appearance-none cursor-pointer accent-brand-500"
/>
<ZoomIn size={12} className="text-slate-400" />
</div>
)}
{/* Cover Size Controls for Adult View */}
{showCoverControls && (
<div className="flex items-center gap-1 md:gap-2 bg-slate-800/50 border border-white/5 rounded-full px-2 md:px-3 py-1.5">
<ZoomOut size={12} className="text-slate-400" />
<input
type="range"
min="100"
max="400"
step="25"
value={coverSize}
onChange={(e) => {
const newSize = parseInt(e.target.value)
console.log('Layout.tsx - Cover slider changed:', newSize)
setCoverSize(newSize)
}}
className="w-16 md:w-20 h-1 bg-slate-600 rounded-lg appearance-none cursor-pointer accent-brand-500"
/>
<ZoomIn size={12} className="text-slate-400" />
{!isMobile && <span className="text-xs text-slate-400 ml-1">{coverSize}px</span>}
</div>
)}
{isNavControlsVisible && (
<div className="flex bg-slate-800/50 p-0.5 md:p-1 rounded-full border border-white/5">
<button
onClick={() => {
console.log('Layout.tsx - View mode changed to: list')
setViewMode('list')
}}
className={`p-1.5 md:p-2 rounded-full transition-all ${viewMode === 'list' ? 'bg-white text-brand-900 shadow' : 'text-slate-400 hover:text-white'}`}
>
<List size={14} />
</button>
<button
onClick={() => {
console.log('Layout.tsx - View mode changed to: grid')
setViewMode('grid')
}}
className={`p-1.5 md:p-2 rounded-full transition-all ${viewMode === 'grid' ? 'bg-white text-brand-900 shadow' : 'text-slate-400 hover:text-white'}`}
>
<LayoutGrid size={14} />
</button>
<button
onClick={() => {
console.log('Layout.tsx - View mode changed to: cover')
setViewMode('cover')
}}
className={`p-1.5 md:p-2 rounded-full transition-all ${viewMode === 'cover' ? 'bg-white text-brand-900 shadow' : 'text-slate-400 hover:text-white'}`}
title="Cover View"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="3" width="18" height="18" rx="2" />
<path d="M3 9h18" />
<path d="M9 21V9" />
</svg>
</button>
</div>
)}
</div>
</div>
{/* Page Content */}
<div className="flex-1 overflow-hidden">
<div className="h-full overflow-y-auto custom-scrollbar">
<div className="">
{/* Wrap children in ViewContext Provider */}
<ViewContext.Provider value={viewContextValue}>
{children}
</ViewContext.Provider>
</div>
</div>
</div>
</main>
</div>
)
}
// Export global state accessors for child components
export const getGlobalViewMode = () => globalViewMode
export const getGlobalGridColumns = () => globalGridColumns
export const getGlobalCoverSize = () => globalCoverSize

View File

@@ -0,0 +1,146 @@
import React from 'react'
import { motion } from 'framer-motion'
interface LoadingSkeletonProps {
className?: string
variant?: 'card' | 'list' | 'text' | 'avatar' | 'button'
width?: string | number
height?: string | number
lines?: number
}
export default function LoadingSkeleton({
className = '',
variant = 'text',
width,
height,
lines = 1
}: LoadingSkeletonProps) {
const baseClasses = 'skeleton rounded-lg'
const variantClasses = {
card: 'h-64 w-full',
list: 'h-20 w-full',
text: 'h-4 w-full',
avatar: 'h-12 w-12 rounded-full',
button: 'h-10 w-24'
}
const style = {
width: width || undefined,
height: height || undefined
}
if (variant === 'text' && lines > 1) {
return (
<div className={`space-y-2 ${className}`}>
{Array.from({ length: lines }).map((_, index) => (
<motion.div
key={index}
className={`${baseClasses} ${variantClasses[variant]}`}
style={{
...style,
width: index === lines - 1 ? '70%' : '100%'
}}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: index * 0.1 }}
/>
))}
</div>
)
}
return (
<motion.div
className={`${baseClasses} ${variantClasses[variant]} ${className}`}
style={style}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
/>
)
}
// Card skeleton component
export function CardSkeleton() {
return (
<motion.div
className="card p-6 space-y-4"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
>
<div className="flex items-center space-x-4">
<LoadingSkeleton variant="avatar" />
<div className="flex-1 space-y-2">
<LoadingSkeleton variant="text" width="60%" />
<LoadingSkeleton variant="text" width="40%" />
</div>
</div>
<LoadingSkeleton variant="text" lines={3} />
</motion.div>
)
}
// Movie card skeleton
export function MovieCardSkeleton() {
return (
<motion.div
className="card card-hover overflow-hidden"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
>
<LoadingSkeleton variant="card" height="320px" />
<div className="p-4 space-y-3">
<LoadingSkeleton variant="text" width="80%" />
<div className="flex items-center justify-between">
<LoadingSkeleton variant="text" width="60px" />
<LoadingSkeleton variant="text" width="80px" />
</div>
<div className="flex gap-2">
<LoadingSkeleton variant="button" width="60px" />
<LoadingSkeleton variant="button" width="60px" />
</div>
</div>
</motion.div>
)
}
// Stats card skeleton
export function StatsCardSkeleton() {
return (
<motion.div
className="card p-6"
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ type: "spring", stiffness: 200 }}
>
<div className="flex items-center space-x-4">
<LoadingSkeleton variant="avatar" width="48px" height="48px" />
<div className="flex-1">
<LoadingSkeleton variant="text" width="40%" />
<LoadingSkeleton variant="text" width="30%" height="24px" />
</div>
</div>
</motion.div>
)
}
// List item skeleton
export function ListItemSkeleton() {
return (
<motion.div
className="flex items-center space-x-4 p-4 rounded-xl hover:bg-gray-50"
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
>
<LoadingSkeleton variant="avatar" width="40px" height="60px" />
<div className="flex-1 space-y-2">
<LoadingSkeleton variant="text" width="70%" />
<div className="flex items-center space-x-2">
<LoadingSkeleton variant="button" width="50px" />
<LoadingSkeleton variant="button" width="60px" />
</div>
</div>
</motion.div>
)
}

View File

@@ -0,0 +1,500 @@
import { Link } from 'react-router-dom'
import { motion } from 'framer-motion'
import {
FilmIcon,
TvIcon,
MusicalNoteIcon,
ComputerDesktopIcon as GamepadIcon,
VideoCameraIcon,
StarIcon,
ClockIcon,
PlayIcon,
HeartIcon,
UserGroupIcon
} from '@heroicons/react/24/outline'
import { clsx } from 'clsx'
export type MediaType = 'movie' | 'tvshow' | 'game' | 'music' | 'adult' | 'actors'
export interface MediaItem {
id: number
title: string
poster_url?: string
backdrop_url?: string
rating?: number
release_date?: string
runtime_minutes?: number
source_name?: string
watched?: boolean
genres?: string[]
overview?: string
director?: string
writer?: string
cast?: string
year?: number
episodes?: number
seasons?: number
platform?: string
developer?: string
album?: string
artist?: string
studio?: string
metadata?: any
// Actor-specific fields
name?: string
thumbnail_path?: string
}
export interface MediaCardProps {
media: MediaItem
mediaType: MediaType
viewMode: 'grid' | 'list' | 'covers'
coverSize?: 'small' | 'medium' | 'large' | 'xlarge'
className?: string
}
const mediaTypeConfig = {
movie: {
icon: FilmIcon,
color: 'from-blue-500 to-blue-600',
hoverColor: 'hover:bg-blue-50',
route: '/movies'
},
tvshow: {
icon: TvIcon,
color: 'from-purple-500 to-purple-600',
hoverColor: 'hover:bg-purple-50',
route: '/tvshows'
},
game: {
icon: GamepadIcon,
color: 'from-green-500 to-green-600',
hoverColor: 'hover:bg-green-50',
route: '/games'
},
music: {
icon: MusicalNoteIcon,
color: 'from-pink-500 to-pink-600',
hoverColor: 'hover:bg-pink-50',
route: '/music'
},
adult: {
icon: VideoCameraIcon,
color: 'from-red-500 to-red-600',
hoverColor: 'hover:bg-red-50',
route: '/adult'
},
actors: {
icon: UserGroupIcon,
color: 'from-indigo-500 to-indigo-600',
hoverColor: 'hover:bg-indigo-50',
route: '/actors'
}
}
export default function MediaCard({
media,
mediaType,
viewMode,
coverSize = 'medium',
className = ''
}: MediaCardProps) {
const config = mediaTypeConfig[mediaType]
const Icon = config.icon
// Cover size mappings
const listCoverSizeClasses = {
small: { width: '40px', height: '60px' },
medium: { width: '64px', height: '96px' },
large: { width: '80px', height: '120px' },
xlarge: { width: '96px', height: '144px' }
}
const coversHeightClasses = {
small: 'h-[28rem]',
medium: 'h-[28rem]',
large: 'h-[28rem]',
xlarge: 'h-[28rem]'
}
// Handle image URL
const getImageUrl = (url?: string | any) => {
if (!url || typeof url !== 'string') return null
if (url.startsWith('http')) return url
if (url.startsWith('/images/')) {
const apiUrl = (import.meta as any).env?.VITE_API_URL?.replace('/api', '') || ''
return `${apiUrl}${url}`
}
const apiUrl = (import.meta as any).env?.VITE_API_URL?.replace('/api', '') || ''
return `${apiUrl}/images/${url}`
}
// For actors, use thumbnail_path, otherwise use poster_url
const posterUrl = getImageUrl(
mediaType === 'actors'
? (media.thumbnail_path || media.poster_url)
: media.poster_url
)
const formatRuntime = (minutes?: number) => {
if (!minutes) return null
const hours = Math.floor(minutes / 60)
const mins = minutes % 60
return `${hours}h ${mins}m`
}
const formatYear = (date?: string | any) => {
if (!date || typeof date !== 'string') return null
const parsedDate = new Date(date)
return isNaN(parsedDate.getTime()) ? null : parsedDate.getFullYear()
}
// Get display title (use name for actors, title for others)
const displayTitle = mediaType === 'actors'
? (typeof media.name === 'string' ? media.name : (typeof media.title === 'string' ? media.title : 'Unknown'))
: (typeof media.title === 'string' ? media.title : 'Unknown')
// List view
if (viewMode === 'list') {
return (
<motion.li
className={clsx(
"px-4 py-3 hover:bg-gray-50 transition-all duration-200 rounded-xl group",
config.hoverColor,
className
)}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
whileHover={{ x: 5 }}
>
<div className="flex justify-between items-center">
<div className="flex items-center">
<motion.div
className="relative"
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
{posterUrl ? (
<img
className="rounded-xl shadow-md group-hover:shadow-lg transition-shadow duration-300"
style={listCoverSizeClasses[coverSize]}
src={posterUrl}
alt={displayTitle}
/>
) : (
<div className="bg-gradient-to-br from-gray-100 to-gray-200 rounded-xl mr-3 flex items-center justify-center shadow-md" style={listCoverSizeClasses[coverSize]}>
<Icon className="text-gray-400" width={32} height={32} />
</div>
)}
{media.watched && (
<motion.div
className="absolute -top-1 -right-1 w-4 h-4 bg-green-500 rounded-full border-2 border-white shadow-lg"
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: "spring", stiffness: 500 }}
/>
)}
</motion.div>
<div className="flex-1 ml-4">
<h3 className="text-sm font-semibold mb-1">
<Link
to={`${config.route}/${media.id}`}
className="no-underline text-gray-900 hover:text-blue-600 transition-colors duration-200"
>
{displayTitle}
</Link>
</h3>
<div className="flex items-center gap-3 text-sm text-gray-600">
{media.release_date && (
<span className="px-2 py-1 bg-gray-100 rounded-full text-xs">{formatYear(media.release_date)}</span>
)}
{media.rating && (
<span className="flex items-center gap-1 px-2 py-1 bg-yellow-100 text-yellow-800 rounded-full text-xs font-medium">
<StarIcon className="w-3 h-3" />
{media.rating}/10
</span>
)}
{media.source_name && typeof media.source_name === 'string' && (
<span className="text-xs">{media.source_name}</span>
)}
</div>
</div>
</div>
<motion.div
className="opacity-0 group-hover:opacity-100 transition-opacity duration-200"
initial={{ scale: 0.8 }}
whileHover={{ scale: 1 }}
>
<PlayIcon className="w-5 h-5 text-blue-500" />
</motion.div>
</div>
</motion.li>
)
}
// Covers view
if (viewMode === 'covers') {
return (
<motion.div
className={clsx(
"relative group cursor-pointer overflow-hidden",
className
)}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
whileHover={{ y: -5 }}
>
<Link to={`${config.route}/${media.id}`}>
<motion.div
className={clsx(
`relative overflow-hidden rounded-2xl shadow-xl group-hover:shadow-2xl transition-all duration-300 ${coversHeightClasses[coverSize]}`
)}
whileHover={{ scale: 1.02 }}
>
{posterUrl ? (
<motion.img
src={posterUrl}
alt={displayTitle}
className={`w-full aspect-[2/3] object-cover`}
whileHover={{ scale: 1.1 }}
transition={{ duration: 0.3 }}
/>
) : (
<div className={`w-full aspect-[2/3] bg-gradient-to-br from-gray-100 to-gray-200 rounded-2xl flex items-center justify-center`}>
<Icon className="text-gray-400 w-16 h-16" />
</div>
)}
{/* Overlay */}
<motion.div
className="absolute inset-0 bg-gradient-to-t from-black/90 via-black/50 to-transparent"
initial={{ opacity: 0 }}
whileHover={{ opacity: 1 }}
transition={{ duration: 0.3 }}
>
<div className="absolute bottom-0 left-0 right-0 p-4">
<motion.h3
className="text-white font-bold text-lg mb-2 line-clamp-2"
initial={{ y: 20, opacity: 0 }}
whileHover={{ y: 0, opacity: 1 }}
transition={{ delay: 0.1 }}
>
{displayTitle}
</motion.h3>
<div className="flex items-center gap-2 text-sm text-gray-300">
{media.release_date && (
<span>{formatYear(media.release_date)}</span>
)}
{media.rating && (
<span className="flex items-center gap-1 px-2 py-1 bg-yellow-500/20 text-yellow-300 rounded-full text-xs font-medium">
<StarIcon className="w-3 h-3" />
{media.rating}
</span>
)}
</div>
<motion.div
className="flex items-center gap-2 mt-3"
initial={{ y: 20, opacity: 0 }}
whileHover={{ y: 0, opacity: 1 }}
transition={{ delay: 0.2 }}
>
<motion.button
className="p-2 bg-blue-500 rounded-full text-white hover:bg-blue-600 transition-colors"
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
>
<PlayIcon className="w-4 h-4" />
</motion.button>
<motion.button
className="p-2 bg-white/20 backdrop-blur-sm rounded-full text-white hover:bg-white/30 transition-colors"
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
>
<HeartIcon className="w-4 h-4" />
</motion.button>
</motion.div>
</div>
</motion.div>
{/* Watched indicator */}
{media.watched && (
<motion.div
className="absolute top-3 right-3 bg-green-500 text-white px-3 py-1 rounded-full text-xs font-semibold shadow-lg"
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: "spring", stiffness: 500 }}
>
Watched
</motion.div>
)}
</motion.div>
</Link>
</motion.div>
)
}
// Grid view (default)
return (
<motion.div
className={clsx(
"card card-hover bg-white/90 backdrop-blur-sm rounded-2xl overflow-hidden",
"transform transition-all duration-300",
className
)}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
whileHover={{ y: -8, boxShadow: "0 25px 50px -12px rgba(0, 0, 0, 0.25)" }}
>
<Link to={`${config.route}/${media.id}`}>
<div className="relative overflow-hidden">
<motion.div
className="relative overflow-hidden"
whileHover={{ scale: 1.05 }}
transition={{ duration: 0.3 }}
>
{posterUrl ? (
<img
src={posterUrl}
alt={displayTitle}
className={`w-full aspect-[2/3] object-cover`}
/>
) : (
<div className={`w-full aspect-[2/3] bg-gradient-to-br from-gray-100 to-gray-200 rounded-xl flex items-center justify-center`}>
<Icon className="text-gray-400 w-12 h-12" />
</div>
)}
</motion.div>
{/* Watched indicator */}
{media.watched && (
<motion.div
className="absolute top-3 right-3 bg-green-500 text-white px-3 py-1 rounded-full text-xs font-semibold shadow-lg"
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: "spring", stiffness: 500 }}
>
Watched
</motion.div>
)}
{/* Hover overlay */}
<motion.div
className="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex items-end p-4"
whileHover={{ opacity: 1 }}
>
<motion.div
className="flex gap-2"
initial={{ y: 20, opacity: 0 }}
whileHover={{ y: 0, opacity: 1 }}
transition={{ delay: 0.1 }}
>
<motion.button
className="p-2 bg-blue-500 rounded-full text-white hover:bg-blue-600 transition-colors shadow-lg"
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
>
<PlayIcon className="w-4 h-4" />
</motion.button>
<motion.button
className="p-2 bg-white/20 backdrop-blur-sm rounded-full text-white hover:bg-white/30 transition-colors shadow-lg"
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
>
<HeartIcon className="w-4 h-4" />
</motion.button>
</motion.div>
</motion.div>
</div>
<div className="p-4">
<motion.h3
className="font-semibold text-gray-900 mb-2 line-clamp-2 hover:text-blue-600 transition-colors duration-200"
whileHover={{ color: "#2563eb" }}
>
{displayTitle}
</motion.h3>
<div className="flex items-center justify-between text-sm text-gray-600 mb-2">
{media.release_date && (
<span className="px-2 py-1 bg-gray-100 rounded-full text-xs">{formatYear(media.release_date)}</span>
)}
<span className="text-xs">{typeof media.source_name === 'string' ? media.source_name : ''}</span>
</div>
{media.rating && (
<motion.div
className="flex items-center gap-1 text-sm mb-2"
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.2 }}
>
<StarIcon className="w-4 h-4 text-yellow-500" />
<span className="font-medium">{media.rating}/10</span>
</motion.div>
)}
{media.runtime_minutes && (
<div className="flex items-center gap-1 text-sm text-gray-600 mb-2">
<ClockIcon className="w-4 h-4" />
<span>{formatRuntime(media.runtime_minutes)}</span>
</div>
)}
{/* Media-specific metadata */}
<div className="flex items-center gap-2 text-xs text-gray-500 mb-2">
{mediaType === 'tvshow' && media.episodes && (
<span>{media.episodes} episodes</span>
)}
{mediaType === 'tvshow' && media.seasons && (
<span>{media.seasons} seasons</span>
)}
{mediaType === 'game' && media.platform && typeof media.platform === 'string' && (
<span>{media.platform}</span>
)}
{mediaType === 'game' && media.developer && typeof media.developer === 'string' && (
<span>{media.developer}</span>
)}
{mediaType === 'music' && media.artist && typeof media.artist === 'string' && (
<span>{media.artist}</span>
)}
{mediaType === 'music' && media.album && typeof media.album === 'string' && (
<span>{media.album}</span>
)}
</div>
{media.genres && Array.isArray(media.genres) && media.genres.length > 0 && (
<motion.div
className="mt-3 flex flex-wrap gap-1"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
>
{media.genres.slice(0, 3).map((genre, index) => (
<motion.span
key={typeof genre === 'string' ? genre : `genre-${index}`}
className={clsx(
"px-2 py-1 text-xs rounded-full font-medium",
`bg-gradient-to-r ${config.color.replace('500', '100').replace('600', '200')} text-${config.color.split('-')[0]}-800`
)}
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 0.4 + index * 0.1 }}
whileHover={{ scale: 1.05 }}
>
{typeof genre === 'string' ? genre : 'Unknown'}
</motion.span>
))}
{media.genres.length > 3 && (
<span className="px-2 py-1 bg-gray-100 text-gray-600 text-xs rounded-full">
+{media.genres.length - 3}
</span>
)}
</motion.div>
)}
</div>
</Link>
</motion.div>
)
}

View File

@@ -0,0 +1,584 @@
import { useState } from 'react'
import { useParams, Link } from 'react-router-dom'
import { motion, AnimatePresence } from 'framer-motion'
import { useQuery } from '@tanstack/react-query'
import {
FilmIcon,
TvIcon,
MusicalNoteIcon,
VideoCameraIcon,
StarIcon,
ClockIcon,
CalendarIcon,
ArrowLeftIcon,
PlayIcon,
HeartIcon,
ShareIcon,
PlusIcon,
CheckIcon,
UserIcon,
GlobeAltIcon,
CurrencyDollarIcon,
FilmIcon as FilmStripIcon,
SparklesIcon,
MicrophoneIcon,
ComputerDesktopIcon
} from '@heroicons/react/24/outline'
import { Tooltip, PulseDot } from './MicroInteractions'
import { MediaItem, MediaType } from './MediaCard'
const mediaTypeConfig = {
movie: {
icon: FilmIcon,
title: 'Movie',
route: '/movies',
color: 'from-blue-500 to-blue-600',
metadataFields: ['director', 'writer', 'genre', 'cast']
},
tvshow: {
icon: TvIcon,
title: 'TV Show',
route: '/tvshows',
color: 'from-purple-500 to-purple-600',
metadataFields: ['creator', 'genre', 'cast', 'seasons', 'episodes']
},
game: {
icon: GamepadIcon,
title: 'Game',
route: '/games',
color: 'from-green-500 to-green-600',
metadataFields: ['developer', 'publisher', 'genre', 'platform']
},
music: {
icon: MusicalNoteIcon,
title: 'Album',
route: '/music',
color: 'from-pink-500 to-pink-600',
metadataFields: ['artist', 'genre', 'label', 'tracks']
},
adult: {
icon: VideoCameraIcon,
title: 'Adult Video',
route: '/adult',
color: 'from-red-500 to-red-600',
metadataFields: ['studio', 'genre', 'cast', 'duration']
}
}
interface MediaDetailProps {
mediaType: MediaType
apiEndpoint: (id: number) => Promise<MediaItem>
}
export default function MediaDetail({ mediaType, apiEndpoint }: MediaDetailProps) {
const { id } = useParams<{ id: string }>()
const mediaId = parseInt(id || '0')
const [isWatched, setIsWatched] = useState(false)
const [isFavorite, setIsFavorite] = useState(false)
const [showShareMenu, setShowShareMenu] = useState(false)
const config = mediaTypeConfig[mediaType]
const Icon = config.icon
const { data: media, isLoading, error } = useQuery({
queryKey: [mediaType, mediaId],
queryFn: () => apiEndpoint(mediaId),
enabled: !!mediaId
})
// Handle image URL
const getImageUrl = (url?: string) => {
if (!url) return null
if (url.startsWith('http')) return url
if (url.startsWith('/images/')) {
const apiUrl = (import.meta as any).env?.VITE_API_URL?.replace('/api', '') || ''
return `${apiUrl}${url}`
}
const apiUrl = (import.meta as any).env?.VITE_API_URL?.replace('/api', '') || ''
return `${apiUrl}/images/${url}`
}
const posterUrl = getImageUrl(media?.poster_url)
const backdropUrl = getImageUrl(media?.backdrop_url)
if (isLoading) {
return (
<div className="min-h-screen gradient-bg flex items-center justify-center">
<motion.div
className="text-center"
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
>
<div className="relative">
<div className="w-16 h-16 border-4 border-blue-200 rounded-full"></div>
<motion.div
className="absolute top-0 left-0 w-16 h-16 border-4 border-blue-600 rounded-full border-t-transparent"
animate={{ rotate: 360 }}
transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
/>
</div>
<motion.p
className="mt-6 text-gray-600 text-lg"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
>
Loading {config.title.toLowerCase()} details...
</motion.p>
</motion.div>
</div>
)
}
if (error || !media) {
return (
<div className="min-h-screen gradient-bg flex items-center justify-center">
<motion.div
className="text-center max-w-md"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
>
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: "spring", stiffness: 200 }}
>
<Icon className="mx-auto h-20 w-20 text-gray-400 mb-6" />
</motion.div>
<h2 className="text-3xl font-bold text-gray-900 mb-3">{config.title} Not Found</h2>
<p className="text-gray-600 mb-8 text-lg">The {config.title.toLowerCase()} you're looking for doesn't exist or has been removed.</p>
<motion.div
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
<Link
to={config.route}
className="btn btn-primary"
>
<ArrowLeftIcon className="w-5 h-5 mr-2" />
Back to {config.title}s
</Link>
</motion.div>
</motion.div>
</div>
)
}
const formatRuntime = (minutes?: number) => {
if (!minutes) return null
const hours = Math.floor(minutes / 60)
const mins = minutes % 60
return `${hours}h ${mins}m`
}
const formatYear = (date?: string) => {
if (!date) return null
return new Date(date).getFullYear()
}
const renderMetadataField = (field: string, value: any) => {
if (!value) return null
const fieldConfig: Record<string, { label: string; icon: any }> = {
director: { label: 'Director', icon: UserIcon },
writer: { label: 'Writer', icon: FilmStripIcon },
creator: { label: 'Creator', icon: UserIcon },
developer: { label: 'Developer', icon: ComputerDesktopIcon },
publisher: { label: 'Publisher', icon: FilmStripIcon },
artist: { label: 'Artist', icon: MicrophoneIcon },
studio: { label: 'Studio', icon: VideoCameraIcon },
genre: { label: 'Genre', icon: SparklesIcon },
cast: { label: 'Cast', icon: UserIcon },
seasons: { label: 'Seasons', icon: TvIcon },
episodes: { label: 'Episodes', icon: TvIcon },
platform: { label: 'Platform', icon: ComputerDesktopIcon },
label: { label: 'Label', icon: MusicalNoteIcon },
tracks: { label: 'Tracks', icon: MusicalNoteIcon },
duration: { label: 'Duration', icon: ClockIcon }
}
const config = fieldConfig[field]
if (!config) return null
const FieldIcon = config.icon
return (
<div>
<h3 className="text-lg font-semibold text-gray-900 mb-2 flex items-center gap-2">
<FieldIcon className="w-5 h-5 text-blue-500" />
{config.label}
</h3>
{field === 'genre' && typeof value === 'string' ? (
<div className="flex flex-wrap gap-2">
{value.split(',').map((g: string) => (
<span key={g} className="px-3 py-1 bg-gradient-to-r from-blue-100 to-blue-200 text-blue-800 rounded-full text-sm font-medium">
{g.trim()}
</span>
))}
</div>
) : (
<p className="text-gray-700 text-lg">{value}</p>
)}
</div>
)
}
return (
<div className="min-h-screen gradient-bg">
{/* Backdrop */}
{backdropUrl && (
<div className="relative h-96 lg:h-[500px] overflow-hidden">
<motion.img
src={backdropUrl}
alt={media.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-black/80 via-black/40 to-transparent"></div>
{/* Back button */}
<motion.div
className="absolute top-4 left-4 z-10"
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.3 }}
>
<Tooltip text={`Back to ${config.title.toLowerCase()}s`}>
<Link
to={config.route}
className="inline-flex items-center px-4 py-2 bg-black/50 backdrop-blur-sm text-white rounded-xl hover:bg-black/70 transition-all duration-200"
>
<ArrowLeftIcon className="w-5 h-5 mr-2" />
Back
</Link>
</Tooltip>
</motion.div>
{/* Floating action buttons */}
<motion.div
className="absolute top-4 right-4 z-10 flex gap-2"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.4 }}
>
<Tooltip text="Add to favorites">
<motion.button
onClick={() => setIsFavorite(!isFavorite)}
className="p-3 bg-black/50 backdrop-blur-sm text-white rounded-xl hover:bg-black/70 transition-all duration-200"
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
>
<HeartIcon className={`w-5 h-5 ${isFavorite ? 'fill-red-500 text-red-500' : ''}`} />
</motion.button>
</Tooltip>
<Tooltip text="Share">
<motion.button
onClick={() => setShowShareMenu(!showShareMenu)}
className="p-3 bg-black/50 backdrop-blur-sm text-white rounded-xl hover:bg-black/70 transition-all duration-200 relative"
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
>
<ShareIcon className="w-5 h-5" />
<AnimatePresence>
{showShareMenu && (
<motion.div
className="absolute right-0 top-full mt-2 bg-white rounded-xl shadow-2xl p-2 min-w-[150px]"
initial={{ opacity: 0, scale: 0.9, y: -10 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9, y: -10 }}
>
{['Copy Link', 'Facebook', 'Twitter', 'Email'].map((item) => (
<motion.button
key={item}
className="block w-full text-left px-3 py-2 text-gray-700 hover:bg-gray-100 rounded-lg transition-colors text-sm"
whileHover={{ x: 5 }}
>
{item}
</motion.button>
))}
</motion.div>
)}
</AnimatePresence>
</motion.button>
</Tooltip>
</motion.div>
{/* Media title overlay */}
<motion.div
className="absolute bottom-6 left-6 right-6"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.5 }}
>
<h1 className="text-4xl lg:text-6xl font-bold text-white mb-3 drop-shadow-lg">
{media.title}
</h1>
<div className="flex items-center gap-4 text-white/90">
{media.release_date && (
<span className="flex items-center gap-1">
<CalendarIcon className="w-4 h-4" />
{formatYear(media.release_date)}
</span>
)}
{media.runtime_minutes && (
<span className="flex items-center gap-1">
<ClockIcon className="w-4 h-4" />
{formatRuntime(media.runtime_minutes)}
</span>
)}
{media.rating && (
<span className="flex items-center gap-1">
<StarIcon className="w-4 h-4 text-yellow-400" />
{media.rating}/10
</span>
)}
</div>
</motion.div>
</div>
)}
<div className="container mx-auto px-4 py-8">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Poster and basic info */}
<motion.div
className="lg:col-span-1"
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.6 }}
>
<div className="sticky top-4 space-y-6">
{/* Poster */}
<motion.div
className="relative group overflow-hidden"
whileHover={{ scale: 1.02 }}
>
{posterUrl ? (
<img
src={posterUrl}
alt={media.title}
className="w-full rounded-2xl shadow-2xl"
/>
) : (
<div className="w-full aspect-[2/3] bg-gradient-to-br from-gray-100 to-gray-200 rounded-2xl flex items-center justify-center">
<Icon className="text-gray-400 w-20 h-20" />
</div>
)}
{/* Play button overlay */}
<motion.div
className="absolute inset-0 bg-black/60 rounded-2xl flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-300"
whileHover={{ opacity: 1 }}
>
<motion.button
className="p-4 bg-blue-600 rounded-full text-white hover:bg-blue-700 transition-colors"
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
>
<PlayIcon className="w-8 h-8" />
</motion.button>
</motion.div>
</motion.div>
{/* Action buttons */}
<div className="flex gap-3">
<motion.button
onClick={() => setIsWatched(!isWatched)}
className={`flex-1 btn flex items-center justify-center gap-2 ${
isWatched ? 'bg-green-600 hover:bg-green-700' : 'btn-primary'
}`}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
{isWatched ? (
<><CheckIcon className="w-5 h-5" /> Watched</>
) : (
<><PlusIcon className="w-5 h-5" /> Mark as Watched</>
)}
</motion.button>
<motion.button
onClick={() => setIsFavorite(!isFavorite)}
className={`p-3 rounded-xl border-2 transition-all duration-200 ${
isFavorite
? 'border-red-500 bg-red-50 text-red-500'
: 'border-gray-200 hover:border-red-300 hover:bg-red-50'
}`}
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
>
<HeartIcon className={`w-5 h-5 ${isFavorite ? 'fill-current' : ''}`} />
</motion.button>
</div>
{/* Media info */}
<div className="card p-6 space-y-4">
<h3 className="text-lg font-bold text-gray-900 flex items-center gap-2">
<SparklesIcon className="w-5 h-5 text-blue-500" />
{config.title} Details
</h3>
{media.rating && (
<div className="flex items-center justify-between">
<span className="text-gray-600">Rating</span>
<div className="flex items-center gap-2">
<StarIcon className="w-5 h-5 text-yellow-500" />
<span className="text-lg font-bold">{media.rating}/10</span>
</div>
</div>
)}
{media.runtime_minutes && (
<div className="flex items-center justify-between">
<span className="text-gray-600">Runtime</span>
<div className="flex items-center gap-2">
<ClockIcon className="w-5 h-5 text-gray-400" />
<span className="font-medium">{formatRuntime(media.runtime_minutes)}</span>
</div>
</div>
)}
{media.release_date && (
<div className="flex items-center justify-between">
<span className="text-gray-600">Release</span>
<div className="flex items-center gap-2">
<ComputerDesktopIcon className="w-5 h-5 text-blue-500" />
<span className="font-medium">{formatYear(media.release_date)}</span>
</div>
</div>
)}
{media.source_name && (
<div className="flex items-center justify-between">
<span className="text-gray-600">Source</span>
<span className="px-3 py-1 bg-blue-100 text-blue-800 rounded-full text-sm font-medium">
{media.source_name}
</span>
</div>
)}
{isWatched && (
<div className="flex items-center justify-between pt-2 border-t border-gray-200">
<span className="text-gray-600">Status</span>
<div className="flex items-center gap-2">
<PulseDot color="green" />
<span className="text-green-600 font-medium">Watched</span>
</div>
</div>
)}
</div>
</div>
</motion.div>
{/* Details */}
<motion.div
className="lg:col-span-2 space-y-8"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.7 }}
>
{/* Overview */}
{media.overview && (
<motion.div
className="card p-8"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.8 }}
>
<h2 className="text-2xl font-bold text-gray-900 mb-4 flex items-center gap-2">
<FilmStripIcon className="w-6 h-6 text-blue-500" />
Overview
</h2>
<p className="text-gray-700 leading-relaxed text-lg">{media.overview}</p>
</motion.div>
)}
{/* Metadata */}
<motion.div
className="card p-8"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.9 }}
>
<h2 className="text-2xl font-bold text-gray-900 mb-6 flex items-center gap-2">
<UserIcon className="w-6 h-6 text-blue-500" />
Details
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{config.metadataFields.map((field) =>
renderMetadataField(field, (media as any)[field])
)}
</div>
</motion.div>
{/* Additional metadata */}
{media.metadata && typeof media.metadata === 'object' && (
<motion.div
className="card p-8"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 1.0 }}
>
<h2 className="text-2xl font-bold text-gray-900 mb-6 flex items-center gap-2">
<GlobeAltIcon className="w-6 h-6 text-blue-500" />
Additional Information
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{media.metadata.budget && (
<div className="flex items-center justify-between p-4 bg-gray-50 rounded-xl">
<div className="flex items-center gap-2">
<CurrencyDollarIcon className="w-5 h-5 text-gray-400" />
<span className="text-gray-600">Budget</span>
</div>
<span className="text-gray-900 font-bold text-lg">
${media.metadata.budget.toLocaleString()}
</span>
</div>
)}
{media.metadata.revenue && (
<div className="flex items-center justify-between p-4 bg-gray-50 rounded-xl">
<div className="flex items-center gap-2">
<CurrencyDollarIcon className="w-5 h-5 text-green-500" />
<span className="text-gray-600">Revenue</span>
</div>
<span className="text-green-600 font-bold text-lg">
${media.metadata.revenue.toLocaleString()}
</span>
</div>
)}
{media.metadata.original_language && (
<div className="flex items-center justify-between p-4 bg-gray-50 rounded-xl">
<div className="flex items-center gap-2">
<GlobeAltIcon className="w-5 h-5 text-gray-400" />
<span className="text-gray-600">Language</span>
</div>
<span className="text-gray-900 font-medium">
{media.metadata.original_language.toUpperCase()}
</span>
</div>
)}
{media.metadata.status && (
<div className="flex items-center justify-between p-4 bg-gray-50 rounded-xl">
<div className="flex items-center gap-2">
<FilmIcon className="w-5 h-5 text-gray-400" />
<span className="text-gray-600">Status</span>
</div>
<span className="px-3 py-1 bg-blue-100 text-blue-800 rounded-full text-sm font-medium">
{media.metadata.status}
</span>
</div>
)}
</div>
</motion.div>
)}
</motion.div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,255 @@
import React from 'react'
import { motion, AnimatePresence } from 'framer-motion'
interface TooltipProps {
text: string
children: React.ReactNode
position?: 'top' | 'bottom' | 'left' | 'right'
}
export function Tooltip({ text, children, position = 'top' }: TooltipProps) {
const [isVisible, setIsVisible] = React.useState(false)
const positionClasses = {
top: 'bottom-full left-1/2 transform -translate-x-1/2 mb-2',
bottom: 'top-full left-1/2 transform -translate-x-1/2 mt-2',
left: 'right-full top-1/2 transform -translate-y-1/2 mr-2',
right: 'left-full top-1/2 transform -translate-y-1/2 ml-2'
}
return (
<div className="relative inline-block">
<div
onMouseEnter={() => setIsVisible(true)}
onMouseLeave={() => setIsVisible(false)}
>
{children}
</div>
<AnimatePresence>
{isVisible && (
<motion.div
className={`absolute ${positionClasses[position]} z-50 px-2 py-1 text-xs text-white bg-gray-900 rounded-lg shadow-lg whitespace-nowrap`}
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
transition={{ duration: 0.2 }}
>
{text}
<div className="absolute w-2 h-2 bg-gray-900 transform rotate-45" />
</motion.div>
)}
</AnimatePresence>
</div>
)
}
interface RippleEffectProps {
children: React.ReactNode
className?: string
}
export function RippleEffect({ children, className = '' }: RippleEffectProps) {
const [ripples, setRipples] = React.useState<Array<{ id: number; x: number; y: number }>>([])
const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
const rect = e.currentTarget.getBoundingClientRect()
const x = e.clientX - rect.left
const y = e.clientY - rect.top
const newRipple = { id: Date.now(), x, y }
setRipples(prev => [...prev, newRipple])
setTimeout(() => {
setRipples(prev => prev.filter(r => r.id !== newRipple.id))
}, 600)
}
return (
<div
className={`relative overflow-hidden ${className}`}
onClick={handleClick}
>
{children}
<AnimatePresence>
{ripples.map(ripple => (
<motion.div
key={ripple.id}
className="absolute bg-white/30 rounded-full pointer-events-none"
style={{
left: ripple.x - 10,
top: ripple.y - 10,
width: 20,
height: 20
}}
initial={{ scale: 0, opacity: 1 }}
animate={{ scale: 4, opacity: 0 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.6 }}
/>
))}
</AnimatePresence>
</div>
)
}
interface PulseDotProps {
size?: 'sm' | 'md' | 'lg'
color?: 'blue' | 'green' | 'red' | 'yellow' | 'purple'
}
export function PulseDot({ size = 'md', color = 'blue' }: PulseDotProps) {
const sizeClasses = {
sm: 'w-2 h-2',
md: 'w-3 h-3',
lg: 'w-4 h-4'
}
const colorClasses = {
blue: 'bg-blue-500',
green: 'bg-green-500',
red: 'bg-red-500',
yellow: 'bg-yellow-500',
purple: 'bg-purple-500'
}
return (
<div className="relative">
<div className={`${sizeClasses[size]} ${colorClasses[color]} rounded-full`} />
<motion.div
className={`absolute inset-0 ${colorClasses[color]} rounded-full`}
animate={{ scale: [1, 1.5, 1], opacity: [1, 0, 1] }}
transition={{ duration: 2, repeat: Infinity }}
/>
</div>
)
}
interface SlideToggleProps {
isOn: boolean
onToggle: () => void
size?: 'sm' | 'md' | 'lg'
}
export function SlideToggle({ isOn, onToggle, size = 'md' }: SlideToggleProps) {
const sizeClasses = {
sm: 'w-8 h-4',
md: 'w-11 h-6',
lg: 'w-14 h-8'
}
const dotSizes = {
sm: 'w-3 h-3',
md: 'w-4 h-4',
lg: 'w-6 h-6'
}
return (
<motion.button
className={`${sizeClasses[size]} rounded-full transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 ${
isOn ? 'bg-blue-600' : 'bg-gray-300'
}`}
onClick={onToggle}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
<motion.div
className={`${dotSizes[size]} bg-white rounded-full shadow-md`}
animate={{ x: isOn ? (size === 'sm' ? 16 : size === 'md' ? 20 : 24) : 2 }}
transition={{ type: 'spring', stiffness: 500, damping: 30 }}
/>
</motion.button>
)
}
interface ProgressRingProps {
progress: number
size?: number
strokeWidth?: number
color?: string
}
export function ProgressRing({
progress,
size = 60,
strokeWidth = 4,
color = '#3B82F6'
}: ProgressRingProps) {
const radius = (size - strokeWidth) / 2
const circumference = radius * 2 * Math.PI
const strokeDashoffset = circumference - (progress / 100) * circumference
return (
<div className="relative inline-flex items-center justify-center">
<svg width={size} height={size} className="transform -rotate-90">
<circle
cx={size / 2}
cy={size / 2}
r={radius}
stroke="#E5E7EB"
strokeWidth={strokeWidth}
fill="none"
/>
<motion.circle
cx={size / 2}
cy={size / 2}
r={radius}
stroke={color}
strokeWidth={strokeWidth}
fill="none"
strokeDasharray={circumference}
initial={{ strokeDashoffset: circumference }}
animate={{ strokeDashoffset }}
transition={{ duration: 0.5, ease: 'easeInOut' }}
strokeLinecap="round"
/>
</svg>
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-sm font-semibold text-gray-700">
{Math.round(progress)}%
</span>
</div>
</div>
)
}
interface FloatingActionButtonProps {
onClick: () => void
icon: React.ReactNode
position?: 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left'
color?: 'blue' | 'green' | 'red' | 'purple'
}
export function FloatingActionButton({
onClick,
icon,
position = 'bottom-right',
color = 'blue'
}: FloatingActionButtonProps) {
const positionClasses = {
'bottom-right': 'bottom-6 right-6',
'bottom-left': 'bottom-6 left-6',
'top-right': 'top-6 right-6',
'top-left': 'top-6 left-6'
}
const colorClasses = {
blue: 'bg-blue-600 hover:bg-blue-700',
green: 'bg-green-600 hover:bg-green-700',
red: 'bg-red-600 hover:bg-red-700',
purple: 'bg-purple-600 hover:bg-purple-700'
}
return (
<motion.button
className={`fixed ${positionClasses[position]} ${colorClasses[color]} text-white rounded-full shadow-lg p-4 z-50`}
onClick={onClick}
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: 'spring', stiffness: 300, damping: 20 }}
>
{icon}
</motion.button>
)
}

View File

@@ -0,0 +1,370 @@
import React from 'react'
import { Link } from 'react-router-dom'
import { motion } from 'framer-motion'
import { FilmIcon, StarIcon, ClockIcon, PlayIcon, HeartIcon } from '@heroicons/react/24/outline'
import { Movie, ViewMode } from '../types'
import { clsx } from 'clsx'
interface MovieCardProps {
movie: Movie
viewMode: ViewMode
coverSize?: 'small' | 'medium' | 'large' | 'xlarge'
}
export default function MovieCard({ movie, viewMode, coverSize = 'medium' }: MovieCardProps) {
// Cover size mappings
const listCoverSizeClasses = {
small: { width: '40px', height: '60px' },
medium: { width: '64px', height: '96px' },
large: { width: '80px', height: '120px' },
xlarge: { width: '96px', height: '144px' }
}
const coversHeightClasses = {
small: 'h-[28rem]',
medium: 'h-[28rem]',
large: 'h-[28rem]',
xlarge: 'h-[28rem]'
}
const gridHeightClasses = {
small: 'h-48',
medium: 'h-72',
large: 'h-96',
xlarge: 'h-[28rem]'
}
// Handle image URL - if it's a relative path, prepend with API base URL
const getImageUrl = (url: string) => {
if (!url) return null
// If it's already a full URL, return as is
if (url.startsWith('http')) return url
// If it's a relative path starting with /images/, prepend API base
if (url.startsWith('/images/')) {
const apiUrl = (import.meta as any).env?.VITE_API_URL?.replace('/api', '') || ''
return `${apiUrl}${url}`
}
// Otherwise, assume it's relative to API
const apiUrl = (import.meta as any).env?.VITE_API_URL?.replace('/api', '') || ''
return `${apiUrl}/images/${url}`
}
const posterUrl = getImageUrl(movie.poster_url)
const releaseYear = movie.release_date ? new Date(movie.release_date).getFullYear() : null
const formatRuntime = (minutes?: number) => {
if (!minutes) return null
const hours = Math.floor(minutes / 60)
const mins = minutes % 60
return `${hours}h ${mins}m`
}
const formatYear = (date?: string) => {
if (!date) return null
return new Date(date).getFullYear()
}
if (viewMode === 'list') {
return (
<motion.li
className="px-4 py-3 hover:bg-gray-50 transition-all duration-200 rounded-xl group"
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
whileHover={{ x: 5, backgroundColor: "rgba(59, 130, 246, 0.05)" }}
>
<div className="flex justify-between items-center">
<div className="flex items-center">
<motion.div
className="relative"
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
{movie.poster_url ? (
<img
className="rounded-xl shadow-md group-hover:shadow-lg transition-shadow duration-300"
style={listCoverSizeClasses[coverSize]}
src={posterUrl || ''}
alt={movie.title}
/>
) : (
<div className="bg-gradient-to-br from-gray-100 to-gray-200 rounded-xl mr-3 flex items-center justify-center shadow-md" style={listCoverSizeClasses[coverSize]}>
<FilmIcon className="text-gray-400" width={32} height={32} />
</div>
)}
{(movie.watched === true) && (
<motion.div
className="absolute -top-1 -right-1 w-4 h-4 bg-green-500 rounded-full border-2 border-white shadow-lg"
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: "spring", stiffness: 500 }}
/>
)}
</motion.div>
<div className="flex-1 ml-4">
<h3 className="text-sm font-semibold mb-1">
<Link
to={`/movies/${movie.id}`}
className="no-underline text-gray-900 hover:text-blue-600 transition-colors duration-200"
>
{movie.title}
</Link>
</h3>
<div className="flex items-center gap-3 text-sm text-gray-600">
{movie.release_date && (
<span className="px-2 py-1 bg-gray-100 rounded-full text-xs">{formatYear(movie.release_date)}</span>
)}
{movie.rating && (
<span className="flex items-center gap-1 px-2 py-1 bg-yellow-100 text-yellow-800 rounded-full text-xs font-medium">
<StarIcon className="w-3 h-3" />
{movie.rating}/10
</span>
)}
<span className="text-xs">{movie.source_name}</span>
</div>
</div>
</div>
<motion.div
className="opacity-0 group-hover:opacity-100 transition-opacity duration-200"
initial={{ scale: 0.8 }}
whileHover={{ scale: 1 }}
>
<PlayIcon className="w-5 h-5 text-blue-500" />
</motion.div>
</div>
</motion.li>
)
}
if (viewMode === 'covers') {
return (
<motion.div
className="relative group cursor-pointer overflow-hidden"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
whileHover={{ y: -5 }}
>
<Link to={`/movies/${movie.id}`}>
<motion.div
className={`relative overflow-hidden rounded-2xl shadow-xl group-hover:shadow-2xl transition-all duration-300 ${coversHeightClasses[coverSize]}`}
whileHover={{ scale: 1.02 }}
>
{movie.poster_url ? (
<motion.img
src={posterUrl || ''}
alt={movie.title}
className={`w-full aspect-[2/3] object-cover`}
whileHover={{ scale: 1.1 }}
transition={{ duration: 0.3 }}
/>
) : (
<div className={`w-full aspect-[2/3] bg-gradient-to-br from-gray-100 to-gray-200 rounded-2xl flex items-center justify-center`}>
<FilmIcon className="text-gray-400 w-16 h-16" />
</div>
)}
{/* Overlay */}
<motion.div
className="absolute inset-0 bg-gradient-to-t from-black/90 via-black/50 to-transparent"
initial={{ opacity: 0 }}
whileHover={{ opacity: 1 }}
transition={{ duration: 0.3 }}
>
<div className="absolute bottom-0 left-0 right-0 p-4">
<motion.h3
className="text-white font-bold text-lg mb-2 line-clamp-2"
initial={{ y: 20, opacity: 0 }}
whileHover={{ y: 0, opacity: 1 }}
transition={{ delay: 0.1 }}
>
{movie.title}
</motion.h3>
<div className="flex items-center gap-2 text-sm text-gray-300">
{movie.release_date && (
<span>{formatYear(movie.release_date)}</span>
)}
{movie.rating && (
<span className="flex items-center gap-1 px-2 py-1 bg-yellow-500/20 text-yellow-300 rounded-full text-xs font-medium">
<StarIcon className="w-3 h-3" />
{movie.rating}
</span>
)}
</div>
<motion.div
className="flex items-center gap-2 mt-3"
initial={{ y: 20, opacity: 0 }}
whileHover={{ y: 0, opacity: 1 }}
transition={{ delay: 0.2 }}
>
<motion.button
className="p-2 bg-blue-500 rounded-full text-white hover:bg-blue-600 transition-colors"
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
>
<PlayIcon className="w-4 h-4" />
</motion.button>
<motion.button
className="p-2 bg-white/20 backdrop-blur-sm rounded-full text-white hover:bg-white/30 transition-colors"
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
>
<HeartIcon className="w-4 h-4" />
</motion.button>
</motion.div>
</div>
</motion.div>
{/* Watched indicator */}
{(movie.watched === true) && (
<motion.div
className="absolute top-3 right-3 bg-green-500 text-white px-3 py-1 rounded-full text-xs font-semibold shadow-lg"
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: "spring", stiffness: 500 }}
>
Watched
</motion.div>
)}
</motion.div>
</Link>
</motion.div>
)
}
// Grid view (default)
return (
<motion.div
className={clsx(
"card card-hover bg-white/90 backdrop-blur-sm rounded-2xl overflow-hidden",
"transform transition-all duration-300"
)}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
whileHover={{ y: -8, boxShadow: "0 25px 50px -12px rgba(0, 0, 0, 0.25)" }}
>
<Link to={`/movies/${movie.id}`}>
<div className="relative overflow-hidden">
<motion.div
className="relative overflow-hidden"
whileHover={{ scale: 1.05 }}
transition={{ duration: 0.3 }}
>
{posterUrl ? (
<img
src={posterUrl}
alt={movie.title}
className="w-full aspect-[2/3] object-cover rounded-2xl shadow-2xl"
/>
) : (
<div className="w-full aspect-[2/3] bg-gradient-to-br from-gray-100 to-gray-200 rounded-2xl flex items-center justify-center">
<FilmIcon className="text-gray-400 w-20 h-20" />
</div>
)}
</motion.div>
{/* Watched indicator */}
{movie.watched === true && (
<motion.div
className="absolute top-3 right-3 bg-green-500 text-white px-3 py-1 rounded-full text-xs font-semibold shadow-lg"
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: "spring", stiffness: 500 }}
>
Watched
</motion.div>
)}
{/* Hover overlay */}
<motion.div
className="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex items-end p-4"
whileHover={{ opacity: 1 }}
>
<motion.div
className="flex gap-2"
initial={{ y: 20, opacity: 0 }}
whileHover={{ y: 0, opacity: 1 }}
transition={{ delay: 0.1 }}
>
<motion.button
className="p-2 bg-blue-500 rounded-full text-white hover:bg-blue-600 transition-colors shadow-lg"
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
>
<PlayIcon className="w-4 h-4" />
</motion.button>
<motion.button
className="p-2 bg-white/20 backdrop-blur-sm rounded-full text-white hover:bg-white/30 transition-colors shadow-lg"
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
>
<HeartIcon className="w-4 h-4" />
</motion.button>
</motion.div>
</motion.div>
</div>
<div className="p-4">
<motion.h3
className="font-semibold text-gray-900 mb-2 line-clamp-2 hover:text-blue-600 transition-colors duration-200"
whileHover={{ color: "#2563eb" }}
>
{movie.title}
</motion.h3>
<div className="flex items-center justify-between text-sm text-gray-600 mb-2">
{movie.release_date && (
<span className="px-2 py-1 bg-gray-100 rounded-full text-xs">{formatYear(movie.release_date)}</span>
)}
<span className="text-xs">{movie.source_name}</span>
</div>
{movie.rating && (
<motion.div
className="flex items-center gap-1 text-sm mb-2"
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.2 }}
>
<StarIcon className="w-4 h-4 text-yellow-500" />
<span className="font-medium">{movie.rating}/10</span>
</motion.div>
)}
{movie.runtime_minutes && (
<div className="flex items-center gap-1 text-sm text-gray-600 mb-2">
<ClockIcon className="w-4 h-4" />
<span>{formatRuntime(movie.runtime_minutes)}</span>
</div>
)}
{movie.genres && movie.genres.length > 0 && (
<motion.div
className="mt-3 flex flex-wrap gap-1"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
>
{movie.genres.slice(0, 3).map((genre, index) => (
<motion.span
key={genre}
className="px-2 py-1 bg-gradient-to-r from-blue-100 to-blue-200 text-blue-800 text-xs rounded-full font-medium"
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 0.4 + index * 0.1 }}
whileHover={{ scale: 1.05 }}
>
{genre}
</motion.span>
))}
{movie.genres.length > 3 && (
<span className="px-2 py-1 bg-gray-100 text-gray-600 text-xs rounded-full">
+{movie.genres.length - 3}
</span>
)}
</motion.div>
)}
</div>
</Link>
</motion.div>
)
}

View File

@@ -0,0 +1,128 @@
import { motion } from 'framer-motion'
interface PaginationProps {
currentPage: number
lastPage: number
total: number
onPageChange: (page: number) => void
itemsPerPage: number
onItemsPerPageChange: (items: number) => void
}
export default function Pagination({
currentPage,
lastPage,
total,
onPageChange,
itemsPerPage,
onItemsPerPageChange
}: PaginationProps) {
const handlePrevious = () => {
if (currentPage > 1) {
onPageChange(currentPage - 1)
}
}
const handleNext = () => {
if (currentPage < lastPage) {
onPageChange(currentPage + 1)
}
}
const handlePageSelect = (page: number) => {
onPageChange(page)
}
// Generate page numbers to show
const getVisiblePages = () => {
const pages: number[] = []
const maxVisible = 5
if (lastPage <= maxVisible) {
for (let i = 1; i <= lastPage; i++) {
pages.push(i)
}
} else {
const start = Math.max(1, currentPage - 2)
const end = Math.min(lastPage, start + maxVisible - 1)
for (let i = start; i <= end; i++) {
pages.push(i)
}
}
return pages
}
if (lastPage <= 1) {
return null
}
return (
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 mt-8 p-4 bg-white/5 dark:bg-gray-800/50 rounded-xl border border-white/10">
{/* Items per page selector */}
<div className="flex items-center gap-2">
<span className="text-sm text-gray-600 dark:text-gray-400">Show:</span>
<select
value={itemsPerPage}
onChange={(e) => onItemsPerPageChange(parseInt(e.target.value))}
className="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent"
>
<option value={10}>10</option>
<option value={20}>20</option>
<option value={50}>50</option>
<option value={100}>100</option>
</select>
<span className="text-sm text-gray-600 dark:text-gray-400">
of {total} items
</span>
</div>
{/* Pagination controls */}
<div className="flex items-center gap-2">
<motion.button
onClick={handlePrevious}
disabled={currentPage === 1}
className="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white hover:bg-gray-100 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
whileHover={{ scale: currentPage !== 1 ? 1.05 : 1 }}
whileTap={{ scale: currentPage !== 1 ? 0.95 : 1 }}
>
Previous
</motion.button>
<div className="flex items-center gap-1">
{getVisiblePages().map((page) => (
<motion.button
key={page}
onClick={() => handlePageSelect(page)}
className={`w-8 h-8 text-sm rounded-lg transition-colors ${
currentPage === page
? 'bg-purple-600 text-white'
: 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white hover:bg-gray-100 dark:hover:bg-gray-600'
}`}
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
>
{page}
</motion.button>
))}
</div>
<motion.button
onClick={handleNext}
disabled={currentPage === lastPage}
className="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white hover:bg-gray-100 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
whileHover={{ scale: currentPage !== lastPage ? 1.05 : 1 }}
whileTap={{ scale: currentPage !== lastPage ? 0.95 : 1 }}
>
Next
</motion.button>
</div>
{/* Page info */}
<div className="text-sm text-gray-600 dark:text-gray-400">
Page {currentPage} of {lastPage}
</div>
</div>
)
}

View File

@@ -0,0 +1,309 @@
import { useState, useEffect, useRef } from 'react'
import { motion } from 'framer-motion'
import { useNavigate } from 'react-router-dom'
import { useSearch } from '../hooks/useApi'
import {
Film,
Tv,
Music,
Gamepad2,
FileVideo,
Users,
FileText,
Search,
Loader2
} from 'lucide-react'
interface SearchDropdownProps {
query: string
onClose: () => void
currentSection?: string
}
interface SearchResult {
id: number
title?: string
name?: string
poster_url?: string
backdrop_url?: string
thumbnail_path?: string
year?: number
rating?: number
media_type: string
}
export default function SearchDropdown({ query, onClose, currentSection }: SearchDropdownProps) {
const [debouncedQuery, setDebouncedQuery] = useState('')
const [selectedIndex, setSelectedIndex] = useState(0)
const navigate = useNavigate()
const dropdownRef = useRef<HTMLDivElement>(null)
// Debounce search query
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedQuery(query.trim())
}, 300)
return () => clearTimeout(timer)
}, [query])
// Search API call - only search in current section if specified
const { data: searchResults, isLoading } = useSearch({
q: debouncedQuery,
type: (currentSection === 'movies' ? 'movie' :
currentSection === 'tvshows' ? 'tvshow' :
currentSection === 'games' ? 'game' :
currentSection === 'music' ? 'music' :
currentSection === 'adult' ? 'adult' :
currentSection === 'actors' ? 'actors' : 'all') as any,
per_page: 5
}) as any
// Close on click outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
onClose()
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [onClose])
// Keyboard navigation
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
const allResults = getAllResults()
if (allResults.length === 0) return
const totalItems = allResults.length
switch (event.key) {
case 'ArrowDown':
event.preventDefault()
setSelectedIndex(prev => (prev + 1) % totalItems)
break
case 'ArrowUp':
event.preventDefault()
setSelectedIndex(prev => prev === 0 ? totalItems - 1 : prev - 1)
break
case 'Enter':
event.preventDefault()
if (allResults[selectedIndex]) {
handleResultClick(allResults[selectedIndex])
}
break
case 'Escape':
event.preventDefault()
onClose()
break
}
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [selectedIndex, onClose])
const getAllResults = (): SearchResult[] => {
if (!searchResults) return []
const results: SearchResult[] = []
if (searchResults.movies?.items) {
results.push(...searchResults.movies.items.map((item: any) => ({ ...item, media_type: 'movie' })))
}
if (searchResults.tvshows?.items) {
results.push(...searchResults.tvshows.items.map((item: any) => ({ ...item, media_type: 'tvshow' })))
}
if (searchResults.games?.items) {
results.push(...searchResults.games.items.map((item: any) => ({ ...item, media_type: 'game' })))
}
if (searchResults.artists?.items) {
results.push(...searchResults.artists.items.map((item: any) => ({ ...item, media_type: 'music' })))
}
if (searchResults.actors?.items) {
results.push(...searchResults.actors.items.map((item: any) => ({ ...item, media_type: 'actors' })))
}
if (searchResults.adult?.items) {
results.push(...searchResults.adult.items.map((item: any) => ({ ...item, media_type: 'adult' })))
}
return results
}
const handleResultClick = (result: SearchResult) => {
const route = `/${result.media_type === 'movie' ? 'movies' :
result.media_type === 'tvshow' ? 'tvshows' :
result.media_type === 'game' ? 'games' :
result.media_type === 'music' ? 'music' :
result.media_type === 'actors' ? 'actors' :
result.media_type === 'adult' ? 'adult' : 'movies'}/${result.id}`
navigate(route)
onClose()
}
const getMediaTypeIcon = (mediaType: string) => {
switch (mediaType) {
case 'movie': return Film
case 'tvshow': return Tv
case 'game': return Gamepad2
case 'music': return Music
case 'adult': return FileVideo
case 'actors': return Users
default: return FileText
}
}
const getMediaTypeColor = (mediaType: string) => {
switch (mediaType) {
case 'movie': return 'from-blue-500 to-blue-600'
case 'tvshow': return 'from-purple-500 to-purple-600'
case 'game': return 'from-green-500 to-green-600'
case 'music': return 'from-pink-500 to-pink-600'
case 'adult': return 'from-red-500 to-red-600'
case 'actors': return 'from-indigo-500 to-indigo-600'
default: return 'from-gray-500 to-gray-600'
}
}
const getImage = (result: SearchResult): string | undefined => {
const imagePath = result.poster_url || result.backdrop_url || result.thumbnail_path
if (!imagePath) return undefined
// If it's already a full HTTP URL, use as-is
if (imagePath.startsWith('http')) {
return imagePath
}
// If it starts with /images/, use as-is (already correct path)
if (imagePath.startsWith('/images/')) {
return `${(import.meta as any).env?.VITE_API_URL?.replace('/api', '') || ''}${imagePath}`
}
// If it's a relative path without /images/, add the prefix
return `${(import.meta as any).env?.VITE_API_URL?.replace('/api', '') || ''}/images/${imagePath}`
}
const allResults = getAllResults()
if (!debouncedQuery) {
return (
<motion.div
ref={dropdownRef}
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="absolute top-full left-0 right-0 mt-2 bg-slate-800/95 backdrop-blur-xl border border-white/10 rounded-2xl shadow-2xl overflow-hidden z-50"
style={{ minWidth: '400px' }}
>
<div className="p-6 text-center">
<Search className="w-12 h-12 text-slate-500 mx-auto mb-3" />
<p className="text-slate-400 text-sm">Gib einen Suchbegriff ein um Vorschauergebnisse zu sehen</p>
</div>
</motion.div>
)
}
return (
<motion.div
ref={dropdownRef}
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="absolute top-full left-0 right-0 mt-2 bg-slate-800/95 backdrop-blur-xl border border-white/10 rounded-2xl shadow-2xl overflow-hidden z-50 max-h-96 overflow-y-auto"
style={{ minWidth: '400px' }}
>
{isLoading ? (
<div className="p-6 text-center">
<Loader2 className="w-8 h-8 text-brand-500 animate-spin mx-auto mb-3" />
<p className="text-slate-400 text-sm">Suche läuft...</p>
</div>
) : allResults.length === 0 ? (
<div className="p-6 text-center">
<FileText className="w-12 h-12 text-slate-500 mx-auto mb-3" />
<p className="text-slate-400 text-sm">Keine Ergebnisse für "{debouncedQuery}" gefunden</p>
</div>
) : (
<div className="py-2">
{allResults.map((result, index) => {
const Icon = getMediaTypeIcon(result.media_type)
const colorClass = getMediaTypeColor(result.media_type)
const isSelected = index === selectedIndex
return (
<motion.div
key={`${result.media_type}-${result.id}`}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: index * 0.05 }}
className={`px-4 py-3 cursor-pointer transition-all ${
isSelected ? 'bg-brand-500/20 border-l-2 border-brand-500' : 'hover:bg-white/5'
}`}
onClick={() => handleResultClick(result)}
>
<div className="flex items-center gap-3">
{/* Thumbnail */}
<div className="w-12 h-16 rounded-lg overflow-hidden flex-shrink-0 bg-slate-700">
{getImage(result) ? (
<img
src={getImage(result)}
alt={result.title || result.name}
className="w-full h-full object-cover"
onError={(e) => {
e.currentTarget.src = '/images/placeholder.jpg'
}}
/>
) : (
<div className={`w-full h-full bg-gradient-to-br ${colorClass} flex items-center justify-center`}>
<Icon className="w-6 h-6 text-white/50" />
</div>
)}
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<div className={`p-1 rounded bg-gradient-to-r ${colorClass}`}>
<Icon className="w-3 h-3 text-white" />
</div>
<h4 className="text-white font-medium text-sm truncate">{result.title || result.name}</h4>
</div>
<div className="flex items-center gap-2 text-xs text-slate-400">
{result.year && <span>{result.year}</span>}
{result.rating && (
<>
<span></span>
<span> {parseFloat(String(result.rating)).toFixed(1)}</span>
</>
)}
</div>
</div>
</div>
</motion.div>
)
})}
{/* View all results link */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: allResults.length * 0.05 }}
className="border-t border-white/10 px-4 py-3"
>
<button
onClick={() => {
navigate(`/search?q=${encodeURIComponent(debouncedQuery)}${currentSection && currentSection !== 'all' ? `&type=${currentSection}` : ''}`)
onClose()
}}
className="w-full text-center text-brand-400 hover:text-brand-300 text-sm font-medium transition-colors"
>
Alle Ergebnisse für "{debouncedQuery}" anzeigen
</button>
</motion.div>
</div>
)}
</motion.div>
)
}

144
src/components/Sidebar.tsx Normal file
View File

@@ -0,0 +1,144 @@
import React from 'react';
import { Box, Layers, LogOut } from 'lucide-react';
// Mock NAV_ITEMS for compatibility
const NAV_ITEMS = [
{ id: 'dashboard', label: 'Dashboard', icon: React.createElement(Layers) },
{ id: 'movies', label: 'Filme', icon: React.createElement(Box) },
{ id: 'tvshows', label: 'Serien', icon: React.createElement(Box) },
{ id: 'games', label: 'Spiele', icon: React.createElement(Box) },
{ id: 'music', label: 'Musik', icon: React.createElement(Box) },
{ id: 'adult', label: 'Adult', icon: React.createElement(Box) },
{ id: 'actors', label: 'Personen', icon: React.createElement(Box) },
{ id: 'settings', label: 'Einstellungen', icon: React.createElement(Box) },
];
interface SidebarProps {
currentView: string;
onChangeView: (view: string) => void;
stats: any;
}
export const Sidebar: React.FC<SidebarProps> = ({ currentView, onChangeView, stats }) => {
const getCount = (id: string) => {
if (!stats) return 0;
if (id === 'dashboard') return 0;
// Removed 'all' logic
if (stats.typeCounts && stats.typeCounts[id] !== undefined) {
return stats.typeCounts[id];
}
return 0;
};
return (
<div className="w-[260px] flex flex-col h-full shrink-0 py-6 pl-6 pr-2">
{/* App Logo / Brand Area */}
<div className="px-4 mb-8 flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-brand-500 to-indigo-600 flex items-center justify-center shadow-lg shadow-brand-500/20 text-white font-bold text-lg">
MV
</div>
<div>
<h1 className="font-bold text-white tracking-tight text-lg">MediaVault</h1>
<p className="text-[10px] text-slate-400 font-medium uppercase tracking-wider">Collection</p>
</div>
</div>
{/* Navigation Container */}
<div className="flex-1 overflow-y-auto custom-scrollbar space-y-8">
{/* Main Group */}
<div>
<div className="px-4 mb-3 text-[11px] font-bold text-slate-500 uppercase tracking-widest opacity-80">Hauptmenü</div>
<nav className="space-y-1">
{NAV_ITEMS.filter(item => item.id === 'dashboard' || item.id === 'settings').map((item) => {
const isActive = currentView === item.id;
return (
<button
key={item.id}
onClick={() => onChangeView(item.id)}
className={`w-full flex items-center justify-between px-4 py-3 rounded-xl transition-all duration-300 group ${
isActive
? 'bg-brand-600 text-white shadow-lg shadow-brand-500/20 font-medium'
: 'text-slate-400 hover:bg-white/5 hover:text-slate-100'
}`}
>
<div className="flex items-center gap-3">
{/* Icon wrapper to ensure uniform size */}
<span className={`${isActive ? 'text-white' : 'text-slate-400 group-hover:text-slate-200'}`}>
{React.cloneElement(item.icon as React.ReactElement<any>, { size: 18 })}
</span>
<span className="text-sm">{item.label}</span>
</div>
</button>
);
})}
</nav>
</div>
{/* Library Group */}
<div>
<div className="px-4 mb-3 text-[11px] font-bold text-slate-500 uppercase tracking-widest opacity-80">Kategorien</div>
<nav className="space-y-1">
{NAV_ITEMS.filter(item => item.id !== 'dashboard' && item.id !== 'settings').map((item) => {
const isActive = currentView === item.id;
const count = getCount(item.id);
const isAdult = item.id === 'adult';
return (
<button
key={item.id}
onClick={() => onChangeView(item.id)}
className={`w-full flex items-center justify-between px-4 py-3 rounded-xl transition-all duration-300 group ${
isActive
? isAdult ? 'bg-red-900/40 text-red-200 border border-red-500/20' : 'bg-white/10 text-white font-medium border border-white/5'
: isAdult ? 'text-red-400/60 hover:bg-red-500/10 hover:text-red-300' : 'text-slate-400 hover:bg-white/5 hover:text-slate-100'
}`}
>
<div className="flex items-center gap-3">
<span className={`${isActive ? (isAdult ? 'text-red-400' : 'text-brand-400') : (isAdult ? 'text-red-400/60' : 'text-slate-500 group-hover:text-slate-300')}`}>
{React.cloneElement(item.icon as React.ReactElement<any>, { size: 18 })}
</span>
<span className="text-sm">{item.label}</span>
</div>
{count > 0 && (
<span className={`text-[10px] px-2 py-0.5 rounded-full font-semibold transition-colors ${isActive ? 'bg-white text-brand-900' : 'bg-slate-800 text-slate-500 group-hover:bg-slate-700 group-hover:text-slate-300'}`}>
{count}
</span>
)}
</button>
);
})}
</nav>
</div>
{/* Platform Folders (Visual Decoration) */}
<div>
<div className="px-4 mb-3 text-[11px] font-bold text-slate-500 uppercase tracking-widest opacity-80">Plattformen</div>
<div className="space-y-1">
{['Steam', 'PlayStation', 'Xbox', 'Switch', 'VR'].map((p, idx) => (
<div key={idx} className="px-4 py-2 flex items-center gap-3 text-sm text-slate-500 hover:text-slate-300 cursor-pointer transition-colors group">
<Box size={14} className="group-hover:text-brand-400 transition-colors" />
<span>{p}</span>
</div>
))}
</div>
</div>
</div>
{/* User / Footer Area */}
<div className="mt-4 px-4 pt-4 border-t border-white/5">
<div className="flex items-center gap-3 p-2 rounded-xl hover:bg-white/5 cursor-pointer transition-colors">
<div className="w-8 h-8 rounded-full bg-slate-700 border border-white/10 flex items-center justify-center">
<span className="text-xs font-bold text-white">JD</span>
</div>
<div className="flex-1 min-w-0">
<div className="text-xs font-bold text-white truncate">John Doe</div>
<div className="text-[10px] text-slate-500 truncate">Pro Account</div>
</div>
<LogOut size={14} className="text-slate-500 hover:text-white" />
</div>
</div>
</div>
);
};

335
src/hooks/useApi.ts Normal file
View File

@@ -0,0 +1,335 @@
import { useQuery, UseQueryOptions } from '@tanstack/react-query'
import { useMutation, UseMutationOptions } from '@tanstack/react-query'
import {
moviesApi,
tvShowsApi,
gamesApi,
searchApi,
dashboardApi,
authApi,
adultApi,
actorsApi
} from '../services/api'
// Movies hooks
export function useMovies(params?: {
page?: number
per_page?: number
genre?: string
year?: number
search?: string
}) {
return useQuery({
queryKey: ['movies', params],
queryFn: () => moviesApi.list(params),
placeholderData: (previousData: any) => previousData,
staleTime: 5 * 60 * 1000, // 5 minutes
gcTime: 10 * 60 * 1000, // 10 minutes
})
}
export function useMovie(id: number) {
return useQuery({
queryKey: ['movie', id],
queryFn: () => moviesApi.get(id),
enabled: !!id,
staleTime: 5 * 60 * 1000,
gcTime: 10 * 60 * 1000,
})
}
export function useCreateMovie(options?: UseMutationOptions<any, Error, any>) {
return useMutation({
mutationFn: moviesApi.create,
...options
})
}
export function useUpdateMovie(options?: UseMutationOptions<any, Error, { id: number; data: any }>) {
return useMutation({
mutationFn: ({ id, data }) => moviesApi.update(id, data),
...options
})
}
export function useDeleteMovie(options?: UseMutationOptions<void, Error, number>) {
return useMutation({
mutationFn: moviesApi.delete,
...options
})
}
// TV Shows hooks
export function useTvShows(params?: {
page?: number
per_page?: number
genre?: string
year?: number
search?: string
}) {
return useQuery({
queryKey: ['tvshows', params?.page, params?.per_page, params?.genre, params?.year, params?.search],
queryFn: () => tvShowsApi.list(params),
placeholderData: (previousData: any) => previousData,
staleTime: 5 * 60 * 1000,
gcTime: 10 * 60 * 1000,
})
}
export function useTvShow(id: number) {
return useQuery({
queryKey: ['tvshow', id],
queryFn: () => tvShowsApi.get(id),
enabled: !!id,
staleTime: 5 * 60 * 1000,
gcTime: 10 * 60 * 1000,
})
}
export function useCreateTvShow(options?: UseMutationOptions<any, Error, any>) {
return useMutation({
mutationFn: tvShowsApi.create,
...options
})
}
export function useUpdateTvShow(options?: UseMutationOptions<any, Error, { id: number; data: any }>) {
return useMutation({
mutationFn: ({ id, data }) => tvShowsApi.update(id, data),
...options
})
}
export function useDeleteTvShow(options?: UseMutationOptions<void, Error, number>) {
return useMutation({
mutationFn: tvShowsApi.delete,
...options
})
}
// Games hooks
export function useGames(params?: {
page?: number
per_page?: number
genre?: string
year?: number
search?: string
platform?: string
developer?: string
completion_status?: string
source_name?: string
rating?: string
sort?: string
order?: string
}) {
return useQuery({
queryKey: ['games', params],
queryFn: () => gamesApi.list(params),
placeholderData: (previousData: any) => previousData,
staleTime: 5 * 60 * 1000,
gcTime: 10 * 60 * 1000,
})
}
interface PaginatedResponse<T> {
items: T[]
pagination: {
total: number
per_page: number
current_page: number
last_page: number
}
}
export function useGame(id: number) {
return useQuery({
queryKey: ['game', id],
queryFn: () => gamesApi.get(id),
enabled: !!id,
staleTime: 5 * 60 * 1000,
gcTime: 10 * 60 * 1000,
})
}
export function useCreateGame(options?: UseMutationOptions<any, Error, any>) {
return useMutation({
mutationFn: gamesApi.create,
...options
})
}
export function useUpdateGame(options?: UseMutationOptions<any, Error, { id: number; data: any }>) {
return useMutation({
mutationFn: ({ id, data }) => gamesApi.update(id, data),
...options
})
}
export function useDeleteGame(options?: UseMutationOptions<void, Error, number>) {
return useMutation({
mutationFn: gamesApi.delete,
...options
})
}
// Grouped Games hook
export function useGroupedGames(params?: {
page?: number
per_page?: number
search?: string
genres?: string[]
platforms?: string[]
features?: string[]
playtime_filter?: string
sort?: string
}) {
return useQuery({
queryKey: ['grouped-games', params],
queryFn: () => gamesApi.getGrouped(params),
placeholderData: (previousData: any) => previousData,
staleTime: 5 * 60 * 1000,
gcTime: 10 * 60 * 1000,
})
}
// Search hooks
export function useSearch(params: {
q: string
type?: 'all' | 'movie' | 'tvshow' | 'game' | 'music' | 'actors'
page?: number
per_page?: number
}) {
return useQuery({
queryKey: ['search', params],
queryFn: () => searchApi.search(params),
enabled: !!params.q,
placeholderData: (previousData: any) => previousData,
staleTime: 2 * 60 * 1000, // 2 minutes for search
gcTime: 5 * 60 * 1000,
})
}
// Dashboard hooks
export function useDashboardStats() {
return useQuery({
queryKey: ['dashboard-stats'],
queryFn: dashboardApi.getStats,
staleTime: 2 * 60 * 1000, // 2 minutes for stats
gcTime: 5 * 60 * 1000,
})
}
export function useRecentActivity() {
return useQuery({
queryKey: ['recent-activity'],
queryFn: dashboardApi.getRecentActivity,
staleTime: 1 * 60 * 1000, // 1 minute for activity
gcTime: 5 * 60 * 1000,
refetchInterval: 5 * 60 * 1000, // Refetch every 5 minutes
})
}
// Auth hooks
export function useAuth() {
return useQuery({
queryKey: ['auth-user'],
queryFn: authApi.getCurrentUser,
enabled: authApi.isAuthenticated(),
staleTime: 15 * 60 * 1000, // 15 minutes
gcTime: 30 * 60 * 1000,
})
}
export function useLogin(options?: UseMutationOptions<any, Error, { username: string; password: string }>) {
return useMutation({
mutationFn: ({ username, password }) => authApi.login({ username, password }),
...options
})
}
export function useRegister(options?: UseMutationOptions<any, Error, { username: string; email: string; password: string }>) {
return useMutation({
mutationFn: ({ username, email, password }) => authApi.register({ username, email, password }),
...options
})
}
export function useLogout() {
return useMutation({
mutationFn: () => authApi.logout()
})
}
// Adult content hooks
export function useAdults(params?: {
page?: number
per_page?: number
genre?: string
year?: number
search?: string
source?: string
}) {
return useQuery({
queryKey: ['adult', params?.page, params?.per_page, params?.genre, params?.year, params?.search, params?.source],
queryFn: () => adultApi.list(params),
staleTime: 5 * 60 * 1000,
gcTime: 10 * 60 * 1000,
})
}
export function useAdult(id: number) {
return useQuery({
queryKey: ['adult', id],
queryFn: () => adultApi.get(id),
staleTime: 5 * 60 * 1000,
gcTime: 10 * 60 * 1000,
})
}
// Actors hooks
export function useActors(params?: {
page?: number
per_page?: number
search?: string
gender?: string
adult?: boolean
sort?: string
order?: string
}) {
return useQuery({
queryKey: ['actors', params?.page, params?.per_page, params?.search, params?.gender, params?.adult, params?.sort, params?.order],
queryFn: () => actorsApi.list(params),
staleTime: 5 * 60 * 1000,
gcTime: 10 * 60 * 1000,
})
}
export function useActor(id: number) {
return useQuery({
queryKey: ['actor', id],
queryFn: () => actorsApi.get(id),
staleTime: 5 * 60 * 1000,
gcTime: 10 * 60 * 1000,
})
}
export function useCreateAdult(options?: UseMutationOptions<any, Error, any>) {
return useMutation({
mutationFn: adultApi.create,
...options
})
}
export function useUpdateAdult(options?: UseMutationOptions<any, Error, { id: number; data: any }>) {
return useMutation({
mutationFn: ({ id, data }) => adultApi.update(id, data),
...options
})
}
export function useDeleteAdult(options?: UseMutationOptions<void, Error, number>) {
return useMutation({
mutationFn: adultApi.delete,
...options
})
}

214
src/index.css Normal file
View File

@@ -0,0 +1,214 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap');
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
html {
font-family: 'Inter', system-ui, sans-serif;
}
body {
@apply bg-slate-50 text-slate-900;
font-feature-settings: "rlig" 1, "calt" 1;
}
.dark body {
@apply bg-slate-950 text-slate-100;
}
}
@layer components {
.btn {
@apply inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-medium transition-all duration-150 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none hover:scale-[1.02] active:scale-[0.98];
}
.btn-primary {
@apply bg-slate-900 text-white hover:bg-slate-800 focus:ring-slate-500 shadow-sm hover:shadow-md transition-all duration-200 dark:bg-white dark:text-slate-900 dark:hover:bg-slate-100 dark:focus:ring-slate-400;
}
.btn-secondary {
@apply bg-white text-slate-700 border border-slate-200 hover:bg-slate-50 hover:border-slate-300 focus:ring-slate-500 shadow-sm hover:shadow-md transition-all duration-200 dark:bg-slate-800 dark:text-slate-200 dark:border-slate-600 dark:hover:bg-slate-700 dark:hover:border-slate-500 dark:focus:ring-slate-400;
}
.btn-ghost {
@apply text-gray-600 hover:text-gray-900 hover:bg-gray-100 focus:ring-gray-500 rounded-lg dark:text-gray-400 dark:hover:text-white dark:hover:bg-gray-800 dark:focus:ring-gray-400;
}
.card {
@apply glass-card overflow-hidden transition-all duration-200 hover:shadow-lg hover:scale-[1.01];
}
.card-hover {
@apply transition-all duration-200 hover:shadow-lg hover:-translate-y-1 hover:scale-[1.01];
}
.sidebar-item {
@apply flex items-center px-3 py-2 text-sm font-medium rounded-lg transition-all duration-150;
}
.sidebar-item-modern {
@apply flex items-center px-3 py-2 text-sm font-medium rounded-lg transition-all duration-150 border border-transparent hover:scale-[1.01];
}
.sidebar-item-active-purple {
@apply bg-purple-100 text-purple-700 border border-purple-200 font-semibold rounded-lg;
}
.dark .sidebar-item-active-purple {
@apply bg-purple-900/30 text-purple-300 border border-purple-700/50 font-semibold rounded-lg;
}
.sidebar-item-active {
@apply bg-gradient-to-r from-blue-600 to-blue-700 text-white shadow-lg;
}
.sidebar-item-active-modern {
@apply bg-slate-900 text-white shadow-sm border border-slate-800;
}
.dark .sidebar-item-active-modern {
@apply bg-white text-slate-900 shadow-sm border border-slate-200;
}
.sidebar-item-inactive {
@apply text-slate-300 hover:bg-slate-700/50 hover:text-white;
}
.sidebar-item-inactive-modern {
@apply text-slate-300 hover:bg-slate-700/60 hover:text-white hover:shadow-lg hover:border hover:border-white/10;
}
.sidebar-item-inactive-modern-dark {
@apply text-slate-400 hover:bg-slate-800/50 hover:text-slate-200;
}
.sidebar-item-inactive-modern-light {
@apply text-slate-600 hover:bg-slate-100/70 hover:text-slate-900;
}
.glass-effect {
@apply bg-white/10 backdrop-blur-md border border-white/20;
}
.glass-card {
@apply bg-white border border-slate-200/60 shadow-sm;
}
.glass-card-dark {
@apply bg-slate-800 border border-slate-700/60 shadow-sm;
}
.gradient-border {
@apply relative bg-gradient-to-r from-blue-500 via-purple-500 to-pink-500 p-0.5 rounded-2xl;
}
.gradient-border-inner {
@apply bg-white dark:bg-gray-900 rounded-2xl;
}
.gradient-bg {
@apply bg-gradient-to-br from-slate-50 via-white to-slate-50;
}
.gradient-bg-dark {
@apply bg-gradient-to-br from-slate-900 via-gray-900 to-slate-900;
}
.gradient-text {
@apply bg-gradient-to-r from-slate-600 to-slate-900 bg-clip-text text-transparent;
}
.dark .gradient-text {
@apply bg-gradient-to-r from-slate-300 to-slate-100 bg-clip-text text-transparent;
}
.skeleton {
@apply animate-pulse bg-gradient-to-r from-gray-200 via-gray-300 to-gray-200 bg-[length:200%_100%];
animation: shimmer 1.5s ease-in-out infinite;
}
.fade-in {
animation: fadeIn 0.5s ease-out;
}
.slide-up {
animation: slideUp 0.4s ease-out;
}
.bounce-in {
animation: bounceIn 0.6s cubic-bezier(0.68, -0.55, 0.265, 1.55);
}
/* Modern Glass Panel Styles */
.glass-panel {
background: rgba(30, 41, 59, 0.7);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.05);
}
.custom-scrollbar {
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.1) transparent;
}
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.1);
border-radius: 10px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.2);
}
}
@layer utilities {
.text-balance {
text-wrap: balance;
}
.animate-in {
animation: fadeIn 0.3s ease-out;
}
.animate-out {
animation: fadeOut 0.3s ease-out;
}
}
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes fadeOut {
from { opacity: 1; transform: translateY(0); }
to { opacity: 0; transform: translateY(-10px); }
}
@keyframes slideUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes bounceIn {
0% { opacity: 0; transform: scale(0.3); }
50% { opacity: 1; transform: scale(1.05); }
70% { transform: scale(0.9); }
100% { opacity: 1; transform: scale(1); }
}

36
src/main.tsx Normal file
View File

@@ -0,0 +1,36 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { Toaster } from 'react-hot-toast'
import App from './App.tsx'
import './index.css'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
refetchOnWindowFocus: false,
},
},
})
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
<Toaster
position="top-right"
toastOptions={{
duration: 4000,
style: {
background: '#363636',
color: '#fff',
},
}}
/>
</BrowserRouter>
</QueryClientProvider>
</React.StrictMode>,
)

877
src/pages/ActorDetail.tsx Normal file
View File

@@ -0,0 +1,877 @@
import { useParams, useNavigate } from 'react-router-dom'
import { motion } from 'framer-motion'
import {
ArrowLeft,
User,
Film,
Tv,
Video,
Heart,
Play,
Calendar,
MapPin,
Star,
Activity,
Ruler,
Weight,
Palette,
Scissors,
Eye,
Globe,
Loader2
} from 'lucide-react'
import { useActor } from '../hooks/useApi'
interface ActorDetailProps {
viewMode?: 'grid' | 'list'
gridColumns?: number
onViewModeChange?: (mode: 'grid' | 'list') => void
onGridColumnsChange?: (columns: number) => void
}
interface ActorDetail {
id: number
name: string
thumbnail_path?: string
metadata?: any
movies?: any[]
tvshows?: any[]
adult_videos?: any[]
}
export default function ActorDetail({
viewMode = 'grid',
gridColumns = 4,
onViewModeChange,
onGridColumnsChange
}: ActorDetailProps) {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const actorId = parseInt(id || '0')
const { data: actor, isLoading, error } = useActor(actorId)
const handleBack = () => {
navigate('/actors')
}
const getActorImage = (actor: ActorDetail) => {
if (actor.thumbnail_path) {
// If it's already a full HTTP URL, use as-is
if (actor.thumbnail_path.startsWith('http')) {
return actor.thumbnail_path
}
// If it starts with /images/, use as-is (already correct path)
if (actor.thumbnail_path.startsWith('/images/')) {
return `${(import.meta as any).env?.VITE_API_URL?.replace('/api', '') || ''}${actor.thumbnail_path}`
}
// If it's a relative path without /images/, add the prefix
return `${(import.meta as any).env?.VITE_API_URL?.replace('/api', '') || ''}/images/${actor.thumbnail_path}`
}
return null
}
const renderMeasurements = (actor: ActorDetail) => {
const metadata = actor.metadata ? (typeof actor.metadata === 'string' ? JSON.parse(actor.metadata) : actor.metadata) : {}
const measurements = metadata.measurements || {}
if (!Object.keys(measurements).length && !metadata.height && !metadata.weight && !metadata.cup_size && !metadata.hair_color && !metadata.eye_color) return null
return (
<div className="bg-dark-surface rounded-2xl border border-dark-border p-6 shadow-lg">
<div className="flex items-center gap-2 pb-4 border-b border-white/5">
<Activity className="text-brand-500" />
<h3 className="font-bold text-white text-lg">Physical Information</h3>
</div>
<div className="grid grid-cols-1 gap-3 mt-4">
{metadata.height && (
<div className="flex items-center gap-3 p-3 rounded-xl bg-white/5 border border-white/5 hover:bg-white/10 transition-colors">
<div className="p-2 rounded-lg bg-brand-500/20 text-brand-400">
<Ruler className="w-4 h-4" />
</div>
<div>
<div className="text-[10px] uppercase tracking-wider font-bold text-slate-500">Height</div>
<div className="text-sm font-medium text-slate-200">{metadata.height}</div>
</div>
</div>
)}
{metadata.weight && (
<div className="flex items-center gap-3 p-3 rounded-xl bg-white/5 border border-white/5 hover:bg-white/10 transition-colors">
<div className="p-2 rounded-lg bg-brand-500/20 text-brand-400">
<Weight className="w-4 h-4" />
</div>
<div>
<div className="text-[10px] uppercase tracking-wider font-bold text-slate-500">Weight</div>
<div className="text-sm font-medium text-slate-200">{metadata.weight}</div>
</div>
</div>
)}
{measurements.bust && measurements.waist && measurements.hips && (
<div className="pt-2">
<div className="text-[10px] uppercase tracking-wider font-bold text-slate-500 mb-2">Measurements</div>
<div className="grid grid-cols-3 gap-2">
<div className="text-center p-2 rounded-lg bg-white/5 border border-white/5">
<div className="text-[9px] uppercase font-bold text-slate-500">Bust</div>
<div className="text-sm font-bold text-white">{measurements.bust}</div>
</div>
<div className="text-center p-2 rounded-lg bg-white/5 border border-white/5">
<div className="text-[9px] uppercase font-bold text-slate-500">Waist</div>
<div className="text-sm font-bold text-white">{measurements.waist}</div>
</div>
<div className="text-center p-2 rounded-lg bg-white/5 border border-white/5">
<div className="text-[9px] uppercase font-bold text-slate-500">Hips</div>
<div className="text-sm font-bold text-white">{measurements.hips}</div>
</div>
</div>
</div>
)}
{metadata.cup_size && (
<div className="flex items-center gap-3 p-3 rounded-xl bg-white/5 border border-white/5 hover:bg-white/10 transition-colors">
<div className="p-2 rounded-lg bg-brand-500/20 text-brand-400">
<Heart className="w-4 h-4" />
</div>
<div>
<div className="text-[10px] uppercase tracking-wider font-bold text-slate-500">Cup Size</div>
<div className="text-sm font-medium text-slate-200">{metadata.cup_size}</div>
</div>
</div>
)}
<div className="grid grid-cols-2 gap-3">
{metadata.hair_color && (
<div className="flex items-center gap-3 p-3 rounded-xl bg-white/5 border border-white/5 hover:bg-white/10 transition-colors">
<div className="p-2 rounded-lg bg-brand-500/20 text-brand-400">
<Scissors className="w-4 h-4" />
</div>
<div>
<div className="text-[10px] uppercase tracking-wider font-bold text-slate-500">Hair</div>
<div className="text-sm font-medium text-slate-200">{metadata.hair_color}</div>
</div>
</div>
)}
{metadata.eye_color && (
<div className="flex items-center gap-3 p-3 rounded-xl bg-white/5 border border-white/5 hover:bg-white/10 transition-colors">
<div className="p-2 rounded-lg bg-brand-500/20 text-brand-400">
<Eye className="w-4 h-4" />
</div>
<div>
<div className="text-[10px] uppercase tracking-wider font-bold text-slate-500">Eyes</div>
<div className="text-sm font-medium text-slate-200">{metadata.eye_color}</div>
</div>
</div>
)}
</div>
{metadata.piercings && (
<div className="pt-2">
<div className="text-[10px] uppercase tracking-wider font-bold text-slate-500 mb-2">Piercings</div>
<p className="text-sm text-slate-300">{metadata.piercings}</p>
</div>
)}
{metadata.tattoos && (
<div className="pt-2">
<div className="text-[10px] uppercase tracking-wider font-bold text-slate-500 mb-2 flex items-center gap-2">
<Palette size={14} /> Tattoos
</div>
<p className="text-sm text-slate-300">{metadata.tattoos}</p>
</div>
)}
</div>
</div>
)
}
const renderPersonalInfo = (actor: ActorDetail) => {
const metadata = actor.metadata ? (typeof actor.metadata === 'string' ? JSON.parse(actor.metadata) : actor.metadata) : {}
if (!metadata.birth_date && !metadata.birth_place && !metadata.nationality && !metadata.ethnicity && !metadata.age) return null
return (
<div className="bg-dark-surface rounded-2xl border border-dark-border p-6 shadow-lg">
<div className="flex items-center gap-2 pb-4 border-b border-white/5">
<User className="text-brand-500" />
<h3 className="font-bold text-white text-lg">Personal Information</h3>
</div>
<div className="grid grid-cols-1 gap-3 mt-4">
{metadata.birth_date && (
<div className="flex items-center gap-3 p-3 rounded-xl bg-white/5 border border-white/5 hover:bg-white/10 transition-colors">
<div className="p-2 rounded-lg bg-brand-500/20 text-brand-400">
<Calendar className="w-4 h-4" />
</div>
<div>
<div className="text-[10px] uppercase tracking-wider font-bold text-slate-500">Birth Date</div>
<div className="text-sm font-medium text-slate-200">{new Date(metadata.birth_date).toLocaleDateString()}</div>
</div>
</div>
)}
{metadata.age && (
<div className="flex items-center gap-3 p-3 rounded-xl bg-white/5 border border-white/5 hover:bg-white/10 transition-colors">
<div className="p-2 rounded-lg bg-brand-500/20 text-brand-400">
<Star className="w-4 h-4" />
</div>
<div>
<div className="text-[10px] uppercase tracking-wider font-bold text-slate-500">Age</div>
<div className="text-sm font-medium text-slate-200">{metadata.age}</div>
</div>
</div>
)}
{metadata.birth_place && (
<div className="flex items-center gap-3 p-3 rounded-xl bg-white/5 border border-white/5 hover:bg-white/10 transition-colors">
<div className="p-2 rounded-lg bg-brand-500/20 text-brand-400">
<MapPin className="w-4 h-4" />
</div>
<div>
<div className="text-[10px] uppercase tracking-wider font-bold text-slate-500">Birth Place</div>
<div className="text-sm font-medium text-slate-200">{metadata.birth_place}</div>
</div>
</div>
)}
{metadata.nationality && (
<div className="flex items-center gap-3 p-3 rounded-xl bg-white/5 border border-white/5 hover:bg-white/10 transition-colors">
<div className="p-2 rounded-lg bg-brand-500/20 text-brand-400">
<Globe className="w-4 h-4" />
</div>
<div>
<div className="text-[10px] uppercase tracking-wider font-bold text-slate-500">Nationality</div>
<div className="text-sm font-medium text-slate-200">{metadata.nationality}</div>
</div>
</div>
)}
{metadata.ethnicity && (
<div className="flex items-center gap-3 p-3 rounded-xl bg-white/5 border border-white/5 hover:bg-white/10 transition-colors">
<div className="p-2 rounded-lg bg-brand-500/20 text-brand-400">
<User className="w-4 h-4" />
</div>
<div>
<div className="text-[10px] uppercase tracking-wider font-bold text-slate-500">Ethnicity</div>
<div className="text-sm font-medium text-slate-200">{metadata.ethnicity}</div>
</div>
</div>
)}
{metadata.gender && (
<div className="flex items-center gap-3 p-3 rounded-xl bg-white/5 border border-white/5 hover:bg-white/10 transition-colors">
<div className="p-2 rounded-lg bg-brand-500/20 text-brand-400">
<User className="w-4 h-4" />
</div>
<div>
<div className="text-[10px] uppercase tracking-wider font-bold text-slate-500">Gender</div>
<div className="text-sm font-medium text-slate-200 capitalize">{metadata.gender}</div>
</div>
</div>
)}
</div>
</div>
)
}
const renderCareerInfo = (actor: ActorDetail) => {
const metadata = actor.metadata ? (typeof actor.metadata === 'string' ? JSON.parse(actor.metadata) : actor.metadata) : {}
if (!metadata.career_length && !metadata.debut_year && !metadata.retirement_year && !metadata.scene_count) return null
return (
<div className="bg-dark-surface rounded-2xl border border-dark-border p-6 shadow-lg">
<div className="flex items-center gap-2 pb-4 border-b border-white/5">
<Film className="text-brand-500" />
<h3 className="font-bold text-white text-lg">Career Information</h3>
</div>
<div className="grid grid-cols-1 gap-3 mt-4">
{metadata.career_length && (
<div className="flex items-center gap-3 p-3 rounded-xl bg-white/5 border border-white/5 hover:bg-white/10 transition-colors">
<div className="p-2 rounded-lg bg-brand-500/20 text-brand-400">
<Calendar className="w-4 h-4" />
</div>
<div>
<div className="text-[10px] uppercase tracking-wider font-bold text-slate-500">Career Length</div>
<div className="text-sm font-medium text-slate-200">{metadata.career_length}</div>
</div>
</div>
)}
{metadata.debut_year && (
<div className="flex items-center gap-3 p-3 rounded-xl bg-white/5 border border-white/5 hover:bg-white/10 transition-colors">
<div className="p-2 rounded-lg bg-brand-500/20 text-brand-400">
<Star className="w-4 h-4" />
</div>
<div>
<div className="text-[10px] uppercase tracking-wider font-bold text-slate-500">Debut Year</div>
<div className="text-sm font-medium text-slate-200">{metadata.debut_year}</div>
</div>
</div>
)}
{metadata.retirement_year && (
<div className="flex items-center gap-3 p-3 rounded-xl bg-white/5 border border-white/5 hover:bg-white/10 transition-colors">
<div className="p-2 rounded-lg bg-brand-500/20 text-brand-400">
<Calendar className="w-4 h-4" />
</div>
<div>
<div className="text-[10px] uppercase tracking-wider font-bold text-slate-500">Retirement Year</div>
<div className="text-sm font-medium text-slate-200">{metadata.retirement_year}</div>
</div>
</div>
)}
{metadata.scene_count && (
<div className="flex items-center gap-3 p-3 rounded-xl bg-white/5 border border-white/5 hover:bg-white/10 transition-colors">
<div className="p-2 rounded-lg bg-brand-500/20 text-brand-400">
<Film className="w-4 h-4" />
</div>
<div>
<div className="text-[10px] uppercase tracking-wider font-bold text-slate-500">Scene Count</div>
<div className="text-sm font-medium text-slate-200">{metadata.scene_count}</div>
</div>
</div>
)}
{metadata.aliases && metadata.aliases.length > 0 && (
<div className="pt-2">
<div className="text-[10px] uppercase tracking-wider font-bold text-slate-500 mb-2">Aliases</div>
<div className="flex flex-wrap gap-2">
{metadata.aliases.map((alias: string, index: number) => (
<span key={index} className="px-2.5 py-1 rounded-md bg-slate-800 text-slate-300 text-xs border border-white/5">
{alias}
</span>
))}
</div>
</div>
)}
</div>
</div>
)
}
const renderBiography = (actor: ActorDetail) => {
const metadata = actor.metadata ? (typeof actor.metadata === 'string' ? JSON.parse(actor.metadata) : actor.metadata) : {}
if (!metadata.biography && !metadata.details) return null
return (
<div className="bg-dark-surface rounded-2xl border border-dark-border p-6 shadow-lg">
<div className="flex items-center gap-2 pb-4 border-b border-white/5">
<User className="text-brand-500" />
<h3 className="font-bold text-white text-lg">Biography</h3>
</div>
<div className="mt-4 space-y-3">
{metadata.biography && (
<p className="text-slate-300 leading-relaxed">{metadata.biography}</p>
)}
{metadata.details && (
<p className="text-slate-300 leading-relaxed">{metadata.details}</p>
)}
</div>
</div>
)
}
const renderSocialMedia = (actor: ActorDetail) => {
const metadata = actor.metadata ? (typeof actor.metadata === 'string' ? JSON.parse(actor.metadata) : actor.metadata) : {}
const socialMedia = metadata.social_media || {}
if (!socialMedia.website && !metadata.stash_url) return null
return (
<div className="bg-dark-surface rounded-2xl border border-dark-border p-6 shadow-lg">
<div className="flex items-center gap-2 pb-4 border-b border-white/5">
<Globe className="text-brand-500" />
<h3 className="font-bold text-white text-lg">Social Media & Links</h3>
</div>
<div className="space-y-3 mt-4">
{socialMedia.website && (
<a
href={socialMedia.website}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-3 p-3 rounded-xl bg-white/5 border border-white/5 hover:bg-white/10 hover:border-brand-500/40 transition-all group"
>
<div className="p-2 rounded-lg bg-brand-500/20 text-brand-400">
<Globe className="w-4 h-4" />
</div>
<div className="flex-1">
<div className="text-[10px] uppercase tracking-wider font-bold text-slate-500">Website</div>
<div className="text-sm font-medium text-slate-200 group-hover:text-brand-400 transition-colors truncate">
{socialMedia.website}
</div>
</div>
</a>
)}
{metadata.stash_url && (
<a
href={metadata.stash_url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-3 p-3 rounded-xl bg-white/5 border border-white/5 hover:bg-white/10 hover:border-brand-500/40 transition-all group"
>
<div className="p-2 rounded-lg bg-brand-500/20 text-brand-400">
<Heart className="w-4 h-4" />
</div>
<div className="flex-1">
<div className="text-[10px] uppercase tracking-wider font-bold text-slate-500">Stash Profile</div>
<div className="text-sm font-medium text-slate-200 group-hover:text-brand-400 transition-colors">
View Profile
</div>
</div>
</a>
)}
</div>
</div>
)
}
const renderMediaCard = (media: any, type: 'movie' | 'tvshow' | 'adult') => {
const getIcon = () => {
switch (type) {
case 'movie': return <Film className="w-5 h-5" />
case 'tvshow': return <Tv className="w-5 h-5" />
case 'adult': return <Video className="w-5 h-5 text-pink-500" />
}
}
const getImage = () => {
if (media.poster_url || media.thumbnail_path || media.screenshot_url) {
const imagePath = media.poster_url || media.thumbnail_path || media.screenshot_url
if (imagePath.startsWith('http')) {
return imagePath
}
// If it starts with /images/, use as-is (already correct path)
if (imagePath.startsWith('/images/')) {
return `${(import.meta as any).env?.VITE_API_URL?.replace('/api', '') || ''}${imagePath}`
}
// If it's a relative path without /images/, add the prefix
return `${(import.meta as any).env?.VITE_API_URL?.replace('/api', '') || ''}/images/${imagePath}`
}
return null
}
const getAspectRatio = () => {
// Check for poster_aspect_ratio first, then fall back to 2/3
return media.poster_aspect_ratio || 2/3
}
const getPosterStyle = () => {
const aspectRatio = getAspectRatio()
return {
aspectRatio: aspectRatio.toString(),
/*idth: '100%',
height: 'auto'*/
}
}
const getTitle = () => {
return media.title || media.name || 'Unknown'
}
const getYear = () => {
return media.release_date ? new Date(media.release_date).getFullYear() : 'Unknown'
}
const handleCardClick = () => {
switch (type) {
case 'movie':
navigate(`/movies/${media.id}`)
break
case 'tvshow':
navigate(`/tvshows/${media.id}`)
break
case 'adult':
navigate(`/adult/${media.id}`)
break
}
}
// List View Component
if (viewMode === 'list') {
return (
<div
onClick={handleCardClick}
className="group flex items-start gap-4 p-4 rounded-xl bg-dark-surface border border-dark-border hover:border-brand-500/40 hover:bg-slate-800/80 transition-all cursor-pointer overflow-hidden"
>
{/* Poster */}
<div className="w-16 h-24 rounded-lg bg-slate-900 shrink-0 overflow-hidden shadow-lg border border-white/5 relative">
<img src={getImage() || ''} alt={getTitle()} className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-500" />
<div className="absolute top-1 left-1 px-1 rounded bg-black/60 text-[10px] font-bold text-white backdrop-blur-sm">
{media.rating || 'N/A'}
</div>
</div>
{/* Info */}
<div className="flex-1 min-w-0 py-1">
<h4 className="font-bold text-white text-base leading-tight truncate group-hover:text-brand-400 transition-colors">{getTitle()}</h4>
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 mt-1 text-xs text-slate-400">
<span>{getYear()}</span>
<span className="w-1 h-1 rounded-full bg-slate-600"></span>
<span className="uppercase tracking-wider font-medium">{type}</span>
{media.rating && typeof media.rating === 'number' && (
<>
<span className="w-1 h-1 rounded-full bg-slate-600"></span>
<span className="flex items-center gap-1">
<span className="text-yellow-400"></span>
{media.rating.toFixed(1)}
</span>
</>
)}
</div>
<div className="mt-3 flex items-center gap-2">
<div className="px-2 py-1 rounded bg-brand-500/10 border border-brand-500/20 text-brand-300 text-xs font-medium truncate max-w-full">
{type === 'movie' ? 'Movie' : type === 'tvshow' ? 'TV Show' : 'Adult Video'}
</div>
</div>
</div>
{/* Hover Arrow */}
<div className="absolute right-4 top-1/2 -translate-y-1/2 opacity-0 group-hover:opacity-100 transition-opacity transform translate-x-2 group-hover:translate-x-0">
<ArrowLeft className="rotate-180 text-slate-500" />
</div>
</div>
)
}
// Grid View Component (original)
return (
<motion.div
whileHover={{ scale: 1.02 }}
onClick={handleCardClick}
className="bg-slate-50 dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 overflow-hidden hover:shadow-lg transition-shadow cursor-pointer"
>
<div
className="relative bg-slate-200 dark:bg-slate-700"
style={getPosterStyle()}
>
{getImage() ? (
<img
src={getImage()}
alt={getTitle()}
className="w-full h-full object-cover"
onError={(e) => {
console.error('Failed to load image:', getImage(), 'for media:', getTitle())
// Set a fallback background
const target = e.target as HTMLImageElement
target.style.display = 'none'
target.parentElement?.classList.add('bg-gradient-to-br', 'from-gray-300', 'to-gray-400', 'dark:from-gray-600', 'dark:to-gray-700')
// Add icon as fallback
const iconDiv = document.createElement('div')
iconDiv.className = 'w-full h-full flex items-center justify-center'
iconDiv.innerHTML = '<div class="text-gray-500 text-2xl">🎬</div>'
target.parentElement?.appendChild(iconDiv)
}}
/>
) : (
<div className="w-full h-full flex items-center justify-center bg-gradient-to-br from-gray-300 to-gray-400 dark:from-gray-600 dark:to-gray-700">
{getIcon()}
</div>
)}
{/* Play Button Overlay */}
<div className="absolute inset-0 bg-black/40 opacity-0 hover:opacity-100 transition-opacity flex items-center justify-center">
<Play className="w-12 h-12 text-white" />
</div>
</div>
<div className="p-4">
<h4 className="font-semibold text-slate-900 dark:text-slate-100 truncate mb-1">
{getTitle()}
</h4>
<div className="flex items-center justify-between text-sm text-gray-600 dark:text-gray-400">
<span>{getYear()}</span>
{media.rating && typeof media.rating === 'number' && (
<span className="flex items-center gap-1">
<span></span>
{media.rating.toFixed(1)}
</span>
)}
</div>
</div>
</motion.div>
)
}
if (isLoading) {
return (
<div className="flex items-center justify-center h-full">
<div className="flex flex-col items-center gap-4">
<Loader2 className="animate-spin text-brand-500" size={48} />
<div className="text-slate-400">Loading actor details...</div>
</div>
</div>
)
}
if (error || !actor) {
return (
<div className="flex flex-col items-center justify-center h-full">
<div className="text-red-400 mb-4">Failed to load actor details</div>
<button
onClick={handleBack}
className="px-4 py-2 bg-brand-500 hover:bg-brand-400 text-white rounded-lg transition-colors"
>
Back to Actors
</button>
</div>
)
}
const movies = actor.movies || []
const tvshows = actor.tvshows || []
const adultVideos = actor.adult_videos || []
const hasAdultContent = adultVideos.length > 0
return (
<div className="h-full w-full flex flex-col overflow-hidden animate-in fade-in slide-in-from-right-4 duration-300 relative">
{/* Modern Header / Banner */}
<div className="relative h-64 shrink-0 overflow-hidden">
{/* Blurred Background */}
<div className="absolute inset-0">
{actor.thumbnail_path ? (
<img src={getActorImage(actor) || ''} alt="" className="w-full h-full object-cover blur-2xl opacity-40 scale-110" />
) : (
<div className="w-full h-full bg-gradient-to-br from-brand-900 to-slate-900" />
)}
<div className="absolute inset-0 bg-gradient-to-b from-transparent via-dark-bg/50 to-dark-bg" />
</div>
{/* Navigation */}
<div className="absolute top-0 left-0 p-6 z-20">
<button
onClick={handleBack}
className="flex items-center gap-2 px-4 py-2 bg-black/20 hover:bg-black/40 backdrop-blur-md border border-white/10 text-white rounded-full transition-all group"
>
<ArrowLeft size={20} className="group-hover:-translate-x-1 transition-transform" />
<span className="font-medium">Zurück</span>
</button>
</div>
</div>
{/* Content Container (Overlapping Header) */}
<div className="flex-1 overflow-y-auto custom-scrollbar -mt-32 relative z-10">
{/* Profile Identity Card */}
<div className="flex flex-col md:flex-row items-end md:items-start gap-8 mb-12">
{/* Avatar */}
<div className="w-48 h-48 rounded-2xl overflow-hidden border-4 border-dark-bg shadow-2xl bg-slate-800 shrink-0 relative group">
{actor.thumbnail_path ? (
<img
src={getActorImage(actor) || ''}
alt={actor.name}
className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110"
onError={(e) => {
console.error('Failed to load actor thumbnail:', getActorImage(actor), '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-gray-600 to-gray-800"><svg class="w-12 h-12 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 text-slate-600">
<User size={64} />
</div>
)}
</div>
{/* Name & Bio */}
<div className="flex-1 pt-4 min-w-0">
<div className="flex flex-wrap items-center gap-4 mb-2">
<h1 className="text-4xl md:text-6xl font-black text-white tracking-tight drop-shadow-lg">{actor.name}</h1>
</div>
{/* Quick Stats Line */}
<div className="flex flex-wrap items-center gap-4 text-sm font-medium text-slate-300 mb-6">
{actor.metadata?.nationality && (
<div className="flex items-center gap-1.5 text-slate-400">
<MapPin size={16} className="text-brand-500" />
{actor.metadata.nationality}
</div>
)}
<div className="h-1 w-1 rounded-full bg-slate-600"></div>
<div className="flex items-center gap-1.5 text-slate-400">
<Film size={16} className="text-blue-500" />
<span className="text-white">{movies.length + tvshows.length + adultVideos.length}</span> Werke
</div>
{actor.metadata?.age && (
<>
<div className="h-1 w-1 rounded-full bg-slate-600"></div>
<div className="flex items-center gap-1.5 text-slate-400">
<Star size={16} className="text-yellow-500" />
<span className="text-white">{actor.metadata.age}</span> Jahre
</div>
</>
)}
</div>
{/* Bio */}
<p className="text-slate-400 text-lg leading-relaxed max-w-4xl">
{actor.metadata?.biography || actor.metadata?.details || (
<span className="italic opacity-60">Keine Biografie verfügbar.</span>
)}
</p>
</div>
</div>
<div className="grid grid-cols-1 xl:grid-cols-4 gap-8">
{/* LEFT COLUMN: Personal & Physical Info */}
<div className="xl:col-span-1 space-y-6">
{/* Personal Information */}
{renderPersonalInfo(actor)}
{/* Physical Information - Only for adult actors */}
{hasAdultContent && renderMeasurements(actor)}
{/* Career Information */}
{renderCareerInfo(actor)}
{/* Biography */}
{renderBiography(actor)}
{/* Social Media & Links */}
{renderSocialMedia(actor)}
{/* Quick Stats */}
<div className="bg-dark-surface rounded-2xl border border-dark-border p-6 shadow-lg">
<div className="flex items-center gap-2 pb-4 border-b border-white/5">
<User className="text-brand-500" />
<h3 className="font-bold text-white text-lg">Quick Stats</h3>
</div>
<div className="space-y-3 mt-4">
<div className="flex justify-between items-center p-3 rounded-xl bg-white/5 border border-white/5">
<span className="text-slate-400 text-sm">Total Appearances</span>
<span className="font-bold text-slate-200">
{movies.length + tvshows.length + adultVideos.length}
</span>
</div>
<div className="flex justify-between items-center p-3 rounded-xl bg-white/5 border border-white/5">
<span className="text-slate-400 text-sm">Movies</span>
<span className="font-medium text-slate-200">{movies.length}</span>
</div>
<div className="flex justify-between items-center p-3 rounded-xl bg-white/5 border border-white/5">
<span className="text-slate-400 text-sm">TV Shows</span>
<span className="font-medium text-slate-200">{tvshows.length}</span>
</div>
{hasAdultContent && (
<div className="flex justify-between items-center p-3 rounded-xl bg-pink-500/10 border border-pink-500/20">
<span className="text-pink-400 text-sm">Adult Videos</span>
<span className="font-medium text-pink-400">
{adultVideos.length}
</span>
</div>
)}
</div>
</div>
</div>
{/* RIGHT COLUMN: Filmography */}
<div className="xl:col-span-3 space-y-8">
{/* Movies Section */}
{movies.length > 0 && (
<div className="bg-dark-surface rounded-2xl border border-dark-border p-6 shadow-lg">
<div className="flex items-center justify-between mb-6">
<h3 className="text-xl font-bold text-white flex items-center gap-2">
<div className="p-2 bg-blue-500/20 rounded-lg">
<Film className="w-5 h-5 text-blue-400" />
</div>
Movies
</h3>
<span className="text-sm font-medium text-slate-400 bg-slate-800/50 px-3 py-1 rounded-full border border-white/5">
{movies.length}
</span>
</div>
<div className={`grid gap-4 transition-all duration-500 ease-out ${
viewMode === 'grid'
? `grid-cols-2 md:grid-cols-3 lg:grid-cols-${Math.min(gridColumns, 4)} xl:grid-cols-${gridColumns}`
: 'grid-cols-1'
}`}>
{movies.map((movie: any) => (
<div key={movie.id}>
{renderMediaCard(movie, 'movie')}
</div>
))}
</div>
</div>
)}
{/* TV Shows Section */}
{tvshows.length > 0 && (
<div className="bg-dark-surface rounded-2xl border border-dark-border p-6 shadow-lg">
<div className="flex items-center justify-between mb-6">
<h3 className="text-xl font-bold text-white flex items-center gap-2">
<div className="p-2 bg-purple-500/20 rounded-lg">
<Tv className="w-5 h-5 text-purple-400" />
</div>
TV Shows
</h3>
<span className="text-sm font-medium text-slate-400 bg-slate-800/50 px-3 py-1 rounded-full border border-white/5">
{tvshows.length}
</span>
</div>
<div className={`grid gap-4 transition-all duration-500 ease-out ${
viewMode === 'grid'
? `grid-cols-2 md:grid-cols-3 lg:grid-cols-${Math.min(gridColumns, 4)} xl:grid-cols-${gridColumns}`
: 'grid-cols-1'
}`}>
{tvshows.map((tvshow: any) => (
<div key={tvshow.id}>
{renderMediaCard(tvshow, 'tvshow')}
</div>
))}
</div>
</div>
)}
{/* Adult Content Section */}
{adultVideos.length > 0 && (
<div className="bg-gradient-to-br from-pink-500/10 to-purple-500/10 rounded-2xl border border-pink-500/20 p-6 shadow-lg">
<div className="flex items-center justify-between mb-6">
<h3 className="text-xl font-bold text-pink-400 flex items-center gap-2">
<div className="p-2 bg-pink-500/20 rounded-lg">
<Video className="w-5 h-5" />
</div>
Adult Videos
</h3>
<span className="text-sm font-medium text-pink-400 bg-pink-500/10 px-3 py-1 rounded-full border border-pink-500/20">
{adultVideos.length}
</span>
</div>
<div className="bg-pink-500/10 rounded-xl p-4 mb-6 border border-pink-500/20">
<div className="flex items-center gap-3 text-pink-300 mb-2">
<Heart className="w-5 h-5" />
<span className="font-semibold">Adult Content Warning</span>
</div>
<p className="text-pink-400 text-sm">
This section contains adult content. Please verify you are 18+ years old.
</p>
</div>
<div className={`grid gap-4 transition-all duration-500 ease-out ${
viewMode === 'grid'
? `grid-cols-2 md:grid-cols-3 lg:grid-cols-${Math.min(gridColumns, 4)} xl:grid-cols-${gridColumns}`
: 'grid-cols-1'
}`}>
{adultVideos.map((video: any) => (
<div key={video.id}>
{renderMediaCard(video, 'adult')}
</div>
))}
</div>
</div>
)}
{/* No Content */}
{movies.length === 0 && tvshows.length === 0 && adultVideos.length === 0 && (
<div className="bg-dark-surface rounded-2xl border border-dark-border p-12 text-center shadow-lg">
<div className="text-slate-600 text-6xl mb-4">🎬</div>
<h3 className="text-xl font-bold text-slate-200 mb-3">
No Media Found
</h3>
<p className="text-slate-400 max-w-md mx-auto">
This actor hasn't appeared in any movies, TV shows, or adult content yet.
</p>
</div>
)}
</div>
</div>
</div>
</div>
)
}

704
src/pages/Actors.tsx Normal file
View File

@@ -0,0 +1,704 @@
import React, { useState } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { useNavigate } from 'react-router-dom'
import {
UserIcon,
PlayIcon,
FilmIcon,
TvIcon,
VideoCameraIcon,
MagnifyingGlassIcon,
FunnelIcon,
ArrowUpIcon,
ArrowDownIcon,
EyeIcon,
PlusIcon,
LockClosedIcon
} from '@heroicons/react/24/outline'
import { useActors } from '../hooks/useApi'
import { Tooltip } from '../components/MicroInteractions'
interface Actor {
id: number
name: string
thumbnail_path?: string
metadata?: any
movie_count?: number
tv_show_count?: number
adult_video_count?: number
}
export default function Actors() {
// Load preferences from localStorage
const getStoredPreferences = () => {
const stored = localStorage.getItem('actorsPreferences')
return stored ? JSON.parse(stored) : {}
}
const [searchTerm, setSearchTerm] = useState('')
const [selectedGender, setSelectedGender] = useState(() => getStoredPreferences().gender || '')
const [showAdultOnly, setShowAdultOnly] = useState(() => getStoredPreferences().adult || false)
const [sortBy, setSortBy] = useState(() => getStoredPreferences().sortBy || 'name')
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>(() => getStoredPreferences().sortOrder || 'asc')
const [showFilters, setShowFilters] = useState(false)
const [viewMode, setViewMode] = useState<'grid' | 'list' | 'cover'>(() => getStoredPreferences().viewMode || 'grid')
const [itemsPerPage, setItemsPerPage] = useState(() => getStoredPreferences().itemsPerPage || 20)
const [imageSize, setImageSize] = useState(() => getStoredPreferences().imageSize || 200)
const [preserveAspectRatio, setPreserveAspectRatio] = useState(() => getStoredPreferences().preserveAspectRatio !== false)
const [currentPage, setCurrentPage] = useState(1)
const navigate = useNavigate()
// Save preferences to localStorage
const savePreferences = (updates: any) => {
const preferences = {
gender: selectedGender,
adult: showAdultOnly,
sortBy,
sortOrder,
viewMode,
itemsPerPage,
imageSize,
preserveAspectRatio,
...updates
}
localStorage.setItem('actorsPreferences', JSON.stringify(preferences))
}
// Fetch actors
const { data: actorsData, isLoading, error } = useActors({
search: searchTerm || undefined,
gender: selectedGender || undefined,
adult: showAdultOnly || undefined,
sort: sortBy,
order: sortOrder,
page: currentPage,
per_page: itemsPerPage
})
const actors = actorsData?.items || []
const pagination = actorsData?.pagination
// Pagination handlers
const handlePreviousPage = () => {
if (pagination && currentPage > 1) {
setCurrentPage(currentPage - 1)
}
}
const handleNextPage = () => {
if (pagination && currentPage < pagination.last_page) {
setCurrentPage(currentPage + 1)
}
}
const handleActorClick = (actor: Actor) => {
navigate(`/actors/${actor.id}`)
}
// Reset page when filters change
const handleFilterChange = () => {
setCurrentPage(1)
}
const handleSearch = (term: string) => {
setSearchTerm(term)
setCurrentPage(1)
}
const handleSort = (field: string) => {
let newOrder = 'asc'
if (sortBy === field) {
newOrder = sortOrder === 'asc' ? 'desc' : 'asc'
} else {
newOrder = 'asc'
}
setSortBy(field)
setSortOrder(newOrder as 'asc' | 'desc')
setCurrentPage(1)
savePreferences({ sortBy: field, sortOrder: newOrder })
}
const handleGenderChange = (gender: string) => {
setSelectedGender(gender)
setCurrentPage(1)
savePreferences({ gender })
}
const handleAdultChange = (adult: boolean) => {
setShowAdultOnly(adult)
setCurrentPage(1)
savePreferences({ adult })
}
const handleViewModeChange = (mode: 'grid' | 'list' | 'cover') => {
setViewMode(mode)
savePreferences({ viewMode: mode })
}
const handleItemsPerPageChange = (newItemsPerPage: number) => {
setItemsPerPage(newItemsPerPage)
setCurrentPage(1)
savePreferences({ itemsPerPage: newItemsPerPage })
}
const handleImageSizeChange = (newImageSize: number) => {
setImageSize(newImageSize)
savePreferences({ imageSize: newImageSize })
}
const handleAspectRatioChange = (newPreserveAspectRatio: boolean) => {
setPreserveAspectRatio(newPreserveAspectRatio)
savePreferences({ preserveAspectRatio: newPreserveAspectRatio })
}
const handleClearFilters = () => {
setSelectedGender('')
setShowAdultOnly(false)
setSearchTerm('')
setCurrentPage(1)
savePreferences({ gender: '', adult: false })
}
// Mock data for filters
const genders = ['All', 'Male', 'Female', 'Non-binary']
// Sorting options
const sortOptions = [
{ value: 'name', label: 'Name' },
{ value: 'age', label: 'Age' },
{ value: 'media_count', label: 'Total Videos' },
{ value: 'movie_count', label: 'Movies' },
{ value: 'tv_show_count', label: 'TV Shows' },
{ value: 'adult_count', label: 'Adult Videos' }
]
const getActorImage = (actor: Actor) => {
if (actor.thumbnail_path) {
// If it's already a full HTTP URL, use as-is
if (actor.thumbnail_path.startsWith('http')) {
return actor.thumbnail_path
}
// If it starts with /images/, use as-is (already correct path)
if (actor.thumbnail_path.startsWith('/images/')) {
return `${(import.meta as any).env?.VITE_API_URL?.replace('/api', '') || ''}${actor.thumbnail_path}`
}
// If it's a relative path without /images/, add the prefix
return `${(import.meta as any).env?.VITE_API_URL?.replace('/api', '') || ''}/images/${actor.thumbnail_path}`
}
return null
}
const renderMediaCount = (actor: Actor) => {
const movieCount = actor.movie_count || 0
const tvShowCount = actor.tv_show_count || 0
const adultCount = actor.adult_video_count || 0
const total = movieCount + tvShowCount + adultCount
return (
<div className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
{movieCount > 0 && (
<Tooltip content={`${movieCount} Movies`}>
<span className="flex items-center gap-1">
<FilmIcon className="w-4 h-4" />
{movieCount}
</span>
</Tooltip>
)}
{tvShowCount > 0 && (
<Tooltip content={`${tvShowCount} TV Shows`}>
<span className="flex items-center gap-1">
<TvIcon className="w-4 h-4" />
{tvShowCount}
</span>
</Tooltip>
)}
{adultCount > 0 && (
<Tooltip content={`${adultCount} Adult Videos`}>
<span className="flex items-center gap-1 text-pink-600 dark:text-pink-400">
<VideoCameraIcon className="w-4 h-4" />
{adultCount}
</span>
</Tooltip>
)}
<span className="text-gray-500">({total} total)</span>
</div>
)
}
const renderGender = (actor: Actor) => {
const gender = actor.metadata?.gender
if (!gender) return null
const genderColors = {
male: 'text-blue-600 dark:text-blue-400',
female: 'text-pink-600 dark:text-pink-400',
'non-binary': 'text-purple-600 dark:text-purple-400'
}
return (
<span className={`text-xs font-medium ${genderColors[gender.toLowerCase()] || 'text-gray-600 dark:text-gray-400'}`}>
{gender.charAt(0).toUpperCase()}
</span>
)
}
return (
<>
<motion.div
className="flex justify-between items-center"
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
<div>
<motion.h1
className="text-4xl font-bold text-slate-900 dark:text-slate-100 mb-2"
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.1 }}
>
<span className="bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent">
Actors
</span>
</motion.h1>
<motion.p
className="text-slate-600 dark:text-slate-400"
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.2 }}
>
Browse and discover actors from movies, TV shows, and adult content
</motion.p>
</div>
</motion.div>
<div className="min-h-screen transition-colors duration-300">
<div className="container mx-auto px-4 py-6">
{/* Search and Filters */}
<div className="flex flex-col lg:flex-row gap-4 mb-6">
{/* Search Bar */}
<div className="flex-1">
<div className="relative">
<MagnifyingGlassIcon className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
type="text"
placeholder="Search actors..."
value={searchTerm}
onChange={(e) => handleSearch(e.target.value)}
className="w-full pl-10 pr-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
</div>
</div>
{/* Filter Toggle */}
<motion.button
onClick={() => setShowFilters(!showFilters)}
className="btn btn-secondary flex items-center gap-2"
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
<FunnelIcon className="w-4 h-4" />
Filters
</motion.button>
{/* View Mode Toggle */}
<div className="flex bg-gray-200 dark:bg-gray-700 rounded-lg p-1">
<button
onClick={() => handleViewModeChange('grid')}
className={`px-3 py-1 rounded text-sm font-medium transition-colors ${
viewMode === 'grid'
? 'bg-white dark:bg-gray-600 text-gray-900 dark:text-white shadow'
: 'text-gray-600 dark:text-gray-400'
}`}
>
Grid
</button>
<button
onClick={() => handleViewModeChange('list')}
className={`px-3 py-1 rounded text-sm font-medium transition-colors ${
viewMode === 'list'
? 'bg-white dark:bg-gray-600 text-gray-900 dark:text-white shadow'
: 'text-gray-600 dark:text-gray-400'
}`}
>
List
</button>
<button
onClick={() => handleViewModeChange('cover')}
className={`px-3 py-1 rounded text-sm font-medium transition-colors ${
viewMode === 'cover'
? 'bg-white dark:bg-gray-600 text-gray-900 dark:text-white shadow'
: 'text-gray-600 dark:text-gray-400'
}`}
>
Cover
</button>
</div>
{/* Items Per Page Selector */}
<div className="flex items-center gap-2">
<span className="text-sm text-gray-600 dark:text-gray-400">Show:</span>
<select
value={itemsPerPage}
onChange={(e) => handleItemsPerPageChange(parseInt(e.target.value))}
className="px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent"
>
<option value={10}>10</option>
<option value={20}>20</option>
<option value={50}>50</option>
<option value={100}>100</option>
</select>
</div>
{/* Image Size Slider */}
<div className="flex items-center gap-3">
<span className="text-sm text-gray-600 dark:text-gray-400">Size:</span>
<div className="flex items-center gap-2">
<span className="text-xs text-gray-500 dark:text-gray-400">S</span>
<input
type="range"
min="100"
max="400"
step="10"
value={imageSize}
onChange={(e) => handleImageSizeChange(parseInt(e.target.value))}
className="w-24 h-2 bg-gray-200 dark:bg-gray-700 rounded-lg appearance-none cursor-pointer accent-purple-500"
/>
<span className="text-xs text-gray-500 dark:text-gray-400">L</span>
<span className="text-xs text-gray-600 dark:text-gray-400 font-medium w-8">{imageSize}px</span>
</div>
</div>
{/* Aspect Ratio Toggle */}
<div className="flex items-center gap-2">
<span className="text-sm text-gray-600 dark:text-gray-400">Aspect:</span>
<button
onClick={() => handleAspectRatioChange(!preserveAspectRatio)}
className={`px-3 py-1 rounded text-sm font-medium transition-colors ${
preserveAspectRatio
? 'bg-purple-600 text-white'
: 'bg-gray-200 dark:bg-gray-600 text-gray-700 dark:text-gray-300'
}`}
>
{preserveAspectRatio ? '2:3' : 'Stretch'}
</button>
</div>
</div>
{/* Filter Panel */}
<AnimatePresence>
{showFilters && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
className="bg-white dark:bg-gray-800 rounded-xl p-4 mb-6 border border-gray-200 dark:border-gray-700"
>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Gender
</label>
<select
value={selectedGender}
onChange={(e) => handleGenderChange(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent"
>
{genders.map(gender => (
<option key={gender} value={gender === 'All' ? '' : gender}>
{gender}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Adult Content Only
</label>
<select
value={showAdultOnly ? 'true' : 'false'}
onChange={(e) => handleAdultChange(e.target.value === 'true')}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent"
>
<option value="false">All Actors</option>
<option value="true">Adult Actors Only</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Sort By
</label>
<div className="flex gap-2">
<select
value={sortBy}
onChange={(e) => handleSort(e.target.value)}
className="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent"
>
{sortOptions.map(option => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<div className="flex flex-col gap-1">
<motion.button
onClick={() => handleSort(sortBy)}
className={`px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
sortOrder === 'asc'
? 'bg-purple-600 text-white'
: 'bg-gray-200 dark:bg-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-500'
}`}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
<ArrowUpIcon className="w-4 h-4" />
</motion.button>
<motion.button
onClick={() => handleSort(sortBy)}
className={`px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
sortOrder === 'desc'
? 'bg-purple-600 text-white'
: 'bg-gray-200 dark:bg-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-500'
}`}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
<ArrowDownIcon className="w-4 h-4" />
</motion.button>
</div>
</div>
</div>
<div className="flex items-end">
<button
onClick={handleClearFilters}
className="btn btn-secondary w-full"
>
Clear Filters
</button>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
{/* Results Count */}
<div className="flex items-center justify-between mb-6">
<div className="text-gray-600 dark:text-gray-400">
Showing {actors.length} of {pagination?.total || 0} actors
</div>
</div>
{/* Loading State */}
{isLoading && (
<div className="flex justify-center items-center py-12">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"></div>
</div>
)}
{/* Error State */}
{error && (
<div className="text-center py-12">
<div className="text-red-500 mb-4">Failed to load actors</div>
<button
onClick={() => window.location.reload()}
className="btn btn-primary"
>
Retry
</button>
</div>
)}
{/* Actors Grid/List */}
{!isLoading && !error && (
<motion.div
layout
className={
viewMode === 'grid'
? 'grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-6'
: viewMode === 'cover'
? 'flex flex-wrap gap-4 justify-center'
: 'space-y-4'
}
>
{actors.map((actor: Actor, index: number) => (
<motion.div
key={actor.id}
layout
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.1 }}
onClick={() => handleActorClick(actor)}
className={
viewMode === 'grid'
? 'group cursor-pointer'
: viewMode === 'cover'
? 'group cursor-pointer flex-shrink-0'
: 'flex gap-4 p-4 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 hover:shadow-lg transition-shadow cursor-pointer'
}
>
{viewMode === 'grid' ? (
// Grid View
<div className="relative overflow-hidden rounded-xl bg-gray-200 dark:bg-gray-800">
{getActorImage(actor) ? (
<img
src={getActorImage(actor)}
alt={actor.name}
style={{
width: `${imageSize}px`,
height: preserveAspectRatio ? `${imageSize * 1.5}px` : `${imageSize}px`
}}
className={preserveAspectRatio ? "object-cover group-hover:scale-105 transition-transform duration-300" : "object-contain group-hover:scale-105 transition-transform duration-300"}
/>
) : (
<div
style={{
width: `${imageSize}px`,
height: preserveAspectRatio ? `${imageSize * 1.5}px` : `${imageSize}px`
}}
className="flex items-center justify-center bg-gradient-to-br from-blue-400 to-purple-500"
>
<UserIcon className="w-16 h-16 text-white opacity-50" />
</div>
)}
{/* Overlay */}
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300">
<div className="absolute bottom-4 left-4 right-4">
<h3 className="text-white font-bold text-lg mb-1 truncate">{actor.name}</h3>
{renderMediaCount(actor)}
</div>
</div>
{/* Gender Badge */}
{renderGender(actor) && (
<div className="absolute top-2 right-2 bg-white/90 dark:bg-gray-800/90 rounded-full w-6 h-6 flex items-center justify-center">
{renderGender(actor)}
</div>
)}
</div>
) : viewMode === 'cover' ? (
// Cover View (Shelf-style)
<div className="relative group">
<div
className="relative overflow-hidden rounded-t-lg shadow-lg hover:shadow-xl transition-all duration-300 transform hover:-translate-y-1"
style={{
width: `${imageSize * 0.8}px`,
height: `${imageSize * 1.2}px`
}}
>
{getActorImage(actor) ? (
<img
src={getActorImage(actor)}
alt={actor.name}
className="w-full h-full object-cover"
onError={(e) => {
e.currentTarget.src = '/images/placeholder.jpg'
}}
/>
) : (
<div className="w-full h-full bg-gradient-to-br from-blue-400 to-purple-400 flex items-center justify-center">
<UserIcon 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 }}
>
<UserIcon className="w-3 h-3 text-white" />
</motion.button>
</div>
{renderGender(actor) && (
<div className="absolute top-2 left-2 bg-white/90 dark:bg-gray-800/90 rounded-full w-5 h-5 flex items-center justify-center">
{renderGender(actor)}
</div>
)}
</div>
</div>
{/* Title below cover */}
<div style={{ width: `${imageSize * 0.8}px` }} className="pt-2">
<h4 className="text-sm font-medium text-gray-900 dark:text-white text-center truncate">
{actor.name}
</h4>
</div>
</div>
) : (
// List View
<>
<div
className="relative flex-shrink-0 rounded-lg overflow-hidden bg-gray-200 dark:bg-gray-800"
style={{
width: `${Math.min(imageSize * 0.6, 120)}px`,
height: `${Math.min(imageSize * 0.6, 120)}px`
}}
>
{getActorImage(actor) ? (
<img
src={getActorImage(actor)}
alt={actor.name}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center bg-gradient-to-br from-blue-400 to-purple-500">
<UserIcon className="w-8 h-8 text-white opacity-50" />
</div>
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3 mb-2">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white truncate">
{actor.name}
</h3>
{renderGender(actor)}
</div>
{renderMediaCount(actor)}
</div>
</>
)}
</motion.div>
))}
</motion.div>
)}
{/* Pagination */}
{pagination && pagination.last_page > 1 && (
<div className="flex items-center justify-center gap-2 mt-8">
<button
className="btn btn-secondary"
disabled={pagination?.current_page === 1}
onClick={handlePreviousPage}
>
Previous
</button>
<span className="text-gray-600 dark:text-gray-400">
Page {pagination?.current_page || 1} of {pagination?.last_page || 1}
</span>
<button
className="btn btn-secondary"
disabled={pagination?.current_page === pagination?.last_page}
onClick={handleNextPage}
>
Next
</button>
</div>
)}
</div>
</div>
</>
)
}

435
src/pages/Adult copy.tsx Normal file
View File

@@ -0,0 +1,435 @@
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>
)
}

458
src/pages/Adult.tsx Normal file
View File

@@ -0,0 +1,458 @@
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'
import { MediaDetailView } from '../../../frontend/components/MediaDetailView'
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 [detailItem, setDetailItem] = useState<any>(null)
const handleSelect = (item: any) => {
setSelectedId(item.id)
setDetailItem(item)
}
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 with sideview
<div className="flex w-full h-full">
<div className={`${detailItem ? 'w-1/2 hidden md:flex' : 'w-full'} h-full flex flex-col transition-all duration-500`}>
<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>
</div>
{detailItem && (
<div className="w-full md:w-1/2 h-full border-l border-white/5 relative overflow-hidden animate-in slide-in-from-right duration-300">
<MediaDetailView
item={detailItem}
allMedia={mediaItems}
onBack={() => setDetailItem(null)}
onEdit={(item) => console.log('Edit item:', item)}
onToggleFavorite={(id, isFav) => console.log('Toggle favorite:', id, isFav)}
onSelectRelated={(item) => setDetailItem(item)}
onSelectPerson={(name, id) => console.log('Select person:', name, id)}
onViewAll={(type, id) => console.log('View all:', type, id)}
onToggleSelect={(id, isSelected) => console.log('Toggle select:', id, isSelected)}
/>
</div>
)}
</div>
) : viewMode === 'cover' ? (
// Cover View (Shelf-style) - use dynamic cover size
<div className="flex flex-wrap gap-4 justify-center">
{adultContent.map((content: AdultContent) => (
<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>
)
}

435
src/pages/AdultDetail.tsx Normal file
View File

@@ -0,0 +1,435 @@
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>
)
}

220
src/pages/Dashboard.tsx Normal file
View File

@@ -0,0 +1,220 @@
import React from 'react'
import { Link } from 'react-router-dom'
import { motion } from 'framer-motion'
import {
FilmIcon,
TvIcon,
MusicalNoteIcon,
LockClosedIcon,
UserIcon,
PlusIcon,
SparklesIcon
} from '@heroicons/react/24/outline'
import { useDashboardStats, useRecentActivity } from '../hooks/useApi'
export default function Dashboard() {
// Use real API calls
const { data: statsData, isLoading: statsLoading } = useDashboardStats()
const { data: activityData, isLoading: activityLoading } = useRecentActivity()
// Enhanced stats with gradients and better colors
const stats = statsData || [
{ name: 'Total Movies', value: '1,234', icon: FilmIcon, color: 'from-blue-500 to-blue-600', href: '/movies' },
{ name: 'TV Shows', value: '456', icon: TvIcon, color: 'from-purple-500 to-purple-600', href: '/tvshows' },
{ name: 'Games', value: '789', icon: MusicalNoteIcon, color: 'from-green-500 to-green-600', href: '/games' },
{ name: 'Music Albums', value: '321', icon: MusicalNoteIcon, color: 'from-pink-500 to-pink-600', href: '/music' },
{ name: 'Adult Videos', value: '234', icon: LockClosedIcon, color: 'from-red-500 to-red-600', href: '/adult' },
{ name: 'Actors', value: '567', icon: UserIcon, color: 'from-indigo-500 to-indigo-600', href: '/actors' },
]
const recentActivity = activityData || [
{ id: 1, action: 'Added movie', item: 'Inception', time: '2 hours ago', type: 'movie' },
{ id: 2, action: 'Watched', item: 'The Matrix', time: '4 hours ago', type: 'movie' },
{ id: 3, action: 'Added game', item: 'The Legend of Zelda', time: '6 hours ago', type: 'game' },
{ id: 4, action: 'Added album', item: 'Dark Side of the Moon', time: '1 day ago', type: 'music' },
{ id: 5, action: 'Added TV show', item: 'Breaking Bad', time: '2 days ago', type: 'tvshow' },
]
return (
<div className="space-y-8">
{/* Header */}
<motion.div
className="flex justify-between items-center"
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
<div>
<motion.h1
className="text-4xl font-bold text-gray-900 mb-2"
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.1 }}
>
<span className="bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent">
Dashboard
</span>
</motion.h1>
<motion.p
className="text-gray-600 text-lg"
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.2 }}
>
Welcome to your media collection
</motion.p>
</div>
<motion.button
className="btn btn-primary flex items-center gap-2 shadow-xl"
whileHover={{ scale: 1.05, boxShadow: "0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)" }}
whileTap={{ scale: 0.95 }}
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.3 }}
>
<PlusIcon className="w-5 h-5" />
Add Media
</motion.button>
</motion.div>
{/* Stats Grid */}
<motion.div
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.4, duration: 0.6 }}
>
{stats.map((stat, index) => (
<motion.div
key={stat.name}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.5 + index * 0.1 }}
>
<Link
to={stat.href}
className="card card-hover p-6 block group"
>
<div className="flex items-center justify-between">
<div className="flex items-center">
<motion.div
className={`bg-gradient-to-r ${stat.color} p-3 rounded-xl shadow-lg group-hover:shadow-xl transition-all duration-300`}
whileHover={{ scale: 1.1, rotate: 5 }}
whileTap={{ scale: 0.95 }}
>
<stat.icon className="w-6 h-6 text-white" />
</motion.div>
<div className="ml-4">
<p className="text-sm font-medium text-gray-600 mb-1">{stat.name}</p>
<motion.p
className="text-3xl font-bold text-gray-900"
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ delay: 0.6 + index * 0.1, type: "spring", stiffness: 200 }}
>
{stat.value}
</motion.p>
</div>
</div>
<motion.div
className="opacity-0 group-hover:opacity-100 transition-opacity duration-300"
initial={{ x: 10 }}
whileHover={{ x: 0 }}
>
<SparklesIcon className="w-5 h-5 text-blue-500" />
</motion.div>
</div>
</Link>
</motion.div>
))}
</motion.div>
{/* Recent Activity */}
<motion.div
className="card p-6"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.8, duration: 0.6 }}
>
<motion.h2
className="text-xl font-bold text-gray-900 mb-6 flex items-center gap-2"
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.9 }}
>
<SparklesIcon className="w-6 h-6 text-blue-500" />
Recent Activity
</motion.h2>
<div className="space-y-3">
{recentActivity.map((activity, index) => (
<motion.div
key={activity.id}
className="flex items-center justify-between py-3 px-4 rounded-xl hover:bg-gray-50 transition-colors group"
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 1.0 + index * 0.1 }}
whileHover={{ x: 5 }}
>
<div className="flex items-center">
<motion.div
className="w-3 h-3 bg-gradient-to-r from-blue-500 to-purple-500 rounded-full mr-4 shadow-lg"
animate={{ scale: [1, 1.2, 1] }}
transition={{ duration: 2, repeat: Infinity, delay: index * 0.2 }}
/>
<div>
<p className="text-sm font-medium text-gray-900">
{activity.action} <span className="font-semibold text-blue-600">{activity.item}</span>
</p>
<p className="text-xs text-gray-500">{activity.time}</p>
</div>
</div>
<motion.span
className="px-3 py-1 bg-gradient-to-r from-gray-100 to-gray-200 text-gray-700 text-xs rounded-full font-medium shadow-sm group-hover:shadow-md transition-shadow"
whileHover={{ scale: 1.05 }}
>
{activity.type}
</motion.span>
</motion.div>
))}
</div>
</motion.div>
{/* Quick Actions */}
<motion.div
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 1.2, duration: 0.6 }}
>
{[
{ to: "/movies/add", icon: FilmIcon, color: "from-blue-500 to-blue-600", label: "Add Movie" },
{ to: "/tvshows/add", icon: TvIcon, color: "from-purple-500 to-purple-600", label: "Add TV Show" },
{ to: "/games/add", icon: MusicalNoteIcon, color: "from-green-500 to-green-600", label: "Add Game" },
{ to: "/music/add", icon: MusicalNoteIcon, color: "from-pink-500 to-pink-600", label: "Add Album" }
].map((action, index) => (
<motion.div
key={action.label}
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 1.3 + index * 0.1 }}
>
<Link
to={action.to}
className="card p-6 text-center group block"
>
<motion.div
className={`w-12 h-12 bg-gradient-to-r ${action.color} rounded-xl flex items-center justify-center mx-auto mb-3 shadow-lg group-hover:shadow-xl transition-all duration-300`}
whileHover={{ scale: 1.1, rotate: 5 }}
whileTap={{ scale: 0.95 }}
>
<action.icon className="w-6 h-6 text-white" />
</motion.div>
<p className="text-sm font-medium text-gray-900 group-hover:text-blue-600 transition-colors">
{action.label}
</p>
</Link>
</motion.div>
))}
</motion.div>
</div>
)
}

566
src/pages/GameDetail.tsx Normal file
View File

@@ -0,0 +1,566 @@
import { useState } from 'react'
import { useParams, Link, useNavigate } from 'react-router-dom'
import { motion, AnimatePresence } from 'framer-motion'
import { useQuery } from '@tanstack/react-query'
import {
ComputerDesktopIcon as GamepadIcon,
StarIcon,
ClockIcon,
CalendarIcon,
ArrowLeftIcon,
PlayIcon,
HeartIcon,
ShareIcon,
PlusIcon,
CheckIcon,
UserIcon,
GlobeAltIcon,
TrophyIcon,
CheckCircleIcon as CompletedIcon
} from '@heroicons/react/24/outline'
import { gamesApi } from '../services/api'
import { Tooltip, PulseDot } from '../components/MicroInteractions'
export default function GameDetail() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const gameId = parseInt(id || '0')
const [isPlayed, setIsPlayed] = useState(false)
const [isFavorite, setIsFavorite] = useState(false)
const [showShareMenu, setShowShareMenu] = useState(false)
const { data: game, isLoading, error } = useQuery({
queryKey: ['game', gameId],
queryFn: () => gamesApi.get(gameId),
enabled: !!gameId
})
// Handle image URL
const getImageUrl = (url?: string) => {
if (!url) return null
// If it's already a full URL, return as is
if (url.startsWith('http')) return url
// If it's a relative path starting with /images/, prepend API base
if (url.startsWith('/images/')) {
const apiUrl = (import.meta as any).env?.VITE_API_URL?.replace('/api', '') || ''
return `${apiUrl}${url}`
}
// Otherwise, assume it's relative to API
const apiUrl = (import.meta as any).env?.VITE_API_URL?.replace('/api', '') || ''
return `${apiUrl}/images/${url}`
}
const posterUrl = getImageUrl(game?.poster_url)
const backdropUrl = getImageUrl(game?.backdrop_url)
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<motion.div
className="text-center"
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
>
<div className="relative">
<div className="w-16 h-16 border-4 border-green-200 rounded-full"></div>
<motion.div
className="absolute top-0 left-0 w-16 h-16 border-4 border-green-600 rounded-full border-t-transparent"
animate={{ rotate: 360 }}
transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
/>
</div>
<motion.p
className="mt-6 text-gray-600 text-lg"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
>
Loading game details...
</motion.p>
</motion.div>
</div>
)
}
if (error || !game) {
return (
<div className="min-h-screen flex items-center justify-center">
<motion.div
className="text-center max-w-md"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
>
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: "spring", stiffness: 200 }}
>
<GamepadIcon className="mx-auto h-20 w-20 text-gray-400 mb-6" />
</motion.div>
<h2 className="text-3xl font-bold text-gray-900 mb-3">Game Not Found</h2>
<p className="text-gray-600 mb-8 text-lg">The game you're looking for doesn't exist or has been removed.</p>
<motion.div
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
<Link
to="/games"
className="btn btn-primary"
>
<ArrowLeftIcon className="w-5 h-5 mr-2" />
Back to Games
</Link>
</motion.div>
</motion.div>
</div>
)
}
const formatPlaytime = (hours?: number) => {
if (!hours) return null
if (hours < 1) return `${Math.round(hours * 60)}m`
return `${hours}h`
}
const formatYear = (date?: string) => {
if (!date) return null
return new Date(date).getFullYear()
}
const getCompletionStatusIcon = (status?: string) => {
switch (status) {
case 'BEATEN':
return TrophyIcon
case 'PLAYING':
return PlayIcon
case 'COMPLETED':
return CompletedIcon
default:
return GamepadIcon
}
}
const getCompletionStatusColor = (status?: string) => {
switch (status) {
case 'BEATEN':
return 'text-yellow-600 bg-yellow-50'
case 'PLAYING':
return 'text-blue-600 bg-blue-50'
case 'COMPLETED':
return 'text-green-600 bg-green-50'
default:
return 'text-gray-600 bg-gray-50'
}
}
const StatusIcon = getCompletionStatusIcon(game.completion_status)
return (
<div className="min-h-screen">
{/* Backdrop */}
{backdropUrl && (
<div className="relative h-96 lg:h-[500px] overflow-hidden">
<motion.img
src={backdropUrl}
alt={game.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-black/80 via-black/40 to-transparent"></div>
{/* Back button */}
<motion.div
className="absolute top-4 left-4 z-10"
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.3 }}
>
<Tooltip text="Back to games">
<Link
to="/games"
className="inline-flex items-center px-4 py-2 bg-black/50 backdrop-blur-sm text-white rounded-xl hover:bg-black/70 transition-all duration-200"
>
<ArrowLeftIcon className="w-5 h-5 mr-2" />
Back
</Link>
</Tooltip>
</motion.div>
{/* Floating action buttons */}
<motion.div
className="absolute top-4 right-4 z-10 flex gap-2"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.4 }}
>
<Tooltip text="Add to favorites">
<motion.button
onClick={() => setIsFavorite(!isFavorite)}
className="p-3 bg-black/50 backdrop-blur-sm text-white rounded-xl hover:bg-black/70 transition-all duration-200"
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
>
<HeartIcon className={`w-5 h-5 ${isFavorite ? 'fill-red-500 text-red-500' : ''}`} />
</motion.button>
</Tooltip>
<Tooltip text="Share">
<motion.button
onClick={() => setShowShareMenu(!showShareMenu)}
className="p-3 bg-black/50 backdrop-blur-sm text-white rounded-xl hover:bg-black/70 transition-all duration-200 relative"
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
>
<ShareIcon className="w-5 h-5" />
<AnimatePresence>
{showShareMenu && (
<motion.div
className="absolute right-0 top-full mt-2 bg-white rounded-xl shadow-2xl p-2 min-w-[150px]"
initial={{ opacity: 0, scale: 0.9, y: -10 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9, y: -10 }}
>
{['Copy Link', 'Facebook', 'Twitter', 'Email'].map((item) => (
<motion.button
key={item}
className="block w-full text-left px-3 py-2 text-gray-700 hover:bg-gray-100 rounded-lg transition-colors text-sm"
whileHover={{ x: 5 }}
>
{item}
</motion.button>
))}
</motion.div>
)}
</AnimatePresence>
</motion.button>
</Tooltip>
</motion.div>
{/* Game title overlay */}
<motion.div
className="absolute bottom-6 left-6 right-6"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.5 }}
>
<h1 className="text-4xl lg:text-6xl font-bold text-white mb-3 drop-shadow-lg">
{game.title}
</h1>
<div className="flex items-center gap-4 text-white/90">
{game.completion_status && (
<span className={`flex items-center gap-1 px-3 py-1 rounded-full text-sm font-medium ${getCompletionStatusColor(game.completion_status)}`}>
<StatusIcon className="w-4 h-4" />
{game.completion_status}
</span>
)}
{game.release_date && (
<span className="flex items-center gap-1">
<CalendarIcon className="w-4 h-4" />
{formatYear(game.release_date)}
</span>
)}
{game.playtime_hours && (
<span className="flex items-center gap-1">
<ClockIcon className="w-4 h-4" />
{formatPlaytime(game.playtime_hours)}
</span>
)}
{game.rating && (
<span className="flex items-center gap-1">
<StarIcon className="w-4 h-4 text-yellow-400" />
{game.rating}/10
</span>
)}
</div>
</motion.div>
</div>
)}
<div className="container mx-auto px-4 py-8">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Poster and basic info */}
<motion.div
className="lg:col-span-1"
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.6 }}
>
<div className="sticky top-4 space-y-6">
{/* Poster */}
<motion.div
className="relative group overflow-hidden"
whileHover={{ scale: 1.02 }}
>
{posterUrl ? (
<img
src={posterUrl}
alt={game.title}
className="w-3/4 mx-auto rounded-xl shadow-lg"
/>
) : (
<div className="w-3/4 mx-auto aspect-[2/3] bg-gradient-to-br from-green-100 to-green-200 rounded-xl flex items-center justify-center">
<GamepadIcon className="text-green-500 w-12 h-12" />
</div>
)}
{/* Play button overlay */}
<motion.div
className="absolute inset-0 bg-black/40 rounded-xl flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-300"
whileHover={{ opacity: 1 }}
>
<motion.button
className="p-3 bg-green-600/90 backdrop-blur-sm rounded-full text-white hover:bg-green-700 transition-all duration-200"
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
>
<PlayIcon className="w-6 h-6" />
</motion.button>
</motion.div>
</motion.div>
{/* Action buttons */}
<div className="flex gap-2">
<motion.button
onClick={() => setIsPlayed(!isPlayed)}
className={`flex-1 btn flex items-center justify-center gap-1 text-sm py-2 ${
isPlayed ? 'bg-green-600 hover:bg-green-700' : 'btn-primary'
}`}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
{isPlayed ? (
<><CheckIcon className="w-4 h-4" /> Played</>
) : (
<><PlusIcon className="w-4 h-4" /> Mark as Played</>
)}
</motion.button>
<motion.button
onClick={() => setIsFavorite(!isFavorite)}
className={`p-2 rounded-xl border-2 transition-all duration-200 ${
isFavorite
? 'border-red-500 bg-red-50 text-red-500'
: 'border-gray-200 hover:border-red-300 hover:bg-red-50'
}`}
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
>
<HeartIcon className={`w-4 h-4 ${isFavorite ? 'fill-current' : ''}`} />
</motion.button>
</div>
{/* Game info */}
<div className="bg-gradient-to-br from-green-50 to-emerald-50 dark:from-green-900/20 dark:to-emerald-900/20 rounded-xl shadow-md p-5 border border-green-200 dark:border-green-800 space-y-3">
<h3 className="text-base font-bold text-green-800 dark:text-green-200 flex items-center gap-2">
<GamepadIcon className="w-4 h-4" />
Game Details
</h3>
{game.rating && (
<div className="flex items-center justify-between p-2 bg-white/50 dark:bg-gray-800/50 rounded-lg">
<span className="text-green-600 dark:text-green-400 font-medium text-sm">Rating</span>
<div className="flex items-center gap-2">
<StarIcon className="w-4 h-4 text-yellow-500" />
<span className="text-base font-bold text-slate-900 dark:text-slate-100">{game.rating}/10</span>
</div>
</div>
)}
{game.playtime_hours && (
<div className="flex items-center justify-between p-2 bg-white/50 dark:bg-gray-800/50 rounded-lg">
<span className="text-green-600 dark:text-green-400 font-medium text-sm">Playtime</span>
<div className="flex items-center gap-2">
<ClockIcon className="w-4 h-4 text-gray-400" />
<span className="font-medium text-slate-900 dark:text-slate-100 text-sm">{formatPlaytime(game.playtime_hours)}</span>
</div>
</div>
)}
{game.release_date && (
<div className="flex items-center justify-between p-2 bg-white/50 dark:bg-gray-800/50 rounded-lg">
<span className="text-green-600 dark:text-green-400 font-medium text-sm">Release</span>
<div className="flex items-center gap-2">
<CalendarIcon className="w-4 h-4 text-gray-400" />
<span className="font-medium text-slate-900 dark:text-slate-100 text-sm">{formatYear(game.release_date)}</span>
</div>
</div>
)}
{game.platform && (
<div className="flex items-center justify-between p-2 bg-white/50 dark:bg-gray-800/50 rounded-lg">
<span className="text-green-600 dark:text-green-400 font-medium text-sm">Platform</span>
<span className="px-2 py-1 bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-200 rounded-full text-xs font-medium">
{game.platform}
</span>
</div>
)}
{game.source_name && (
<div className="flex items-center justify-between p-2 bg-white/50 dark:bg-gray-800/50 rounded-lg">
<span className="text-green-600 dark:text-green-400 font-medium text-sm">Source</span>
<span className="px-2 py-1 bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-200 rounded-full text-xs font-medium">
{game.source_name}
</span>
</div>
)}
{isPlayed && (
<div className="flex items-center justify-between p-3 bg-green-50 dark:bg-green-900/20 rounded-lg border-t border-green-200 dark:border-green-800">
<span className="text-green-600 dark:text-green-400 font-medium">Status</span>
<div className="flex items-center gap-2">
<PulseDot color="green" />
<span className="text-green-600 dark:text-green-400 font-medium">Played</span>
</div>
</div>
)}
</div>
</div>
</motion.div>
{/* Details */}
<motion.div
className="lg:col-span-2 space-y-8"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.7 }}
>
{/* Overview */}
{game.description && (
<motion.div
className="bg-gradient-to-br from-blue-50 to-indigo-50 dark:from-blue-900/20 dark:to-indigo-900/20 rounded-xl shadow-md p-6 border border-blue-200 dark:border-blue-800"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.8 }}
>
<h2 className="text-xl font-bold text-blue-800 dark:text-blue-200 mb-3 flex items-center gap-2">
<GamepadIcon className="w-5 h-5" />
Overview
</h2>
<p className="text-slate-700 dark:text-slate-300 leading-relaxed">{game.description}</p>
</motion.div>
)}
{/* Gameplay */}
{game.gameplay && (
<motion.div
className="bg-gradient-to-br from-purple-50 to-pink-50 dark:from-purple-900/20 dark:to-pink-900/20 rounded-xl shadow-md p-6 border border-purple-200 dark:border-purple-800"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.9 }}
>
<h2 className="text-xl font-bold text-purple-800 dark:text-purple-200 mb-3 flex items-center gap-2">
<PlayIcon className="w-5 h-5" />
Gameplay
</h2>
<p className="text-slate-700 dark:text-slate-300 leading-relaxed">{game.gameplay}</p>
</motion.div>
)}
{/* Game Details */}
<motion.div
className="bg-gradient-to-br from-green-50 to-emerald-50 dark:from-green-900/20 dark:to-emerald-900/20 rounded-xl shadow-md p-6 border border-green-200 dark:border-green-800"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 1.0 }}
>
<h2 className="text-xl font-bold text-green-800 dark:text-green-200 mb-4 flex items-center gap-2">
<UserIcon className="w-5 h-5" />
Game Information
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{game.developer && (
<div className="bg-white/50 dark:bg-gray-800/50 rounded-lg p-3">
<h3 className="text-base font-semibold text-gray-900 dark:text-white mb-1">Developer</h3>
<p className="text-gray-700 dark:text-gray-300">{game.developer}</p>
</div>
)}
{game.publisher && (
<div className="bg-white/50 dark:bg-gray-800/50 rounded-lg p-3">
<h3 className="text-base font-semibold text-gray-900 dark:text-white mb-1">Publisher</h3>
<p className="text-gray-700 dark:text-gray-300">{game.publisher}</p>
</div>
)}
{game.genres && Array.isArray(game.genres) && game.genres.length > 0 && (
<div className="bg-white/50 dark:bg-gray-800/50 rounded-lg p-3">
<h3 className="text-base font-semibold text-gray-900 dark:text-white mb-2">Genres</h3>
<div className="flex flex-wrap gap-2">
{game.genres.map((genre: string) => (
<span key={genre} className="px-2 py-1 bg-gradient-to-r from-green-100 to-emerald-100 text-green-800 rounded-full text-xs font-medium">
{genre}
</span>
))}
</div>
</div>
)}
{game.series && (
<div className="bg-white/50 dark:bg-gray-800/50 rounded-lg p-3">
<h3 className="text-base font-semibold text-gray-900 dark:text-white mb-1">Series</h3>
<p className="text-gray-700 dark:text-gray-300">{game.series}</p>
</div>
)}
</div>
</motion.div>
{/* Additional metadata */}
{(game.community_score || game.critic_score || game.time_to_beat) && (
<motion.div
className="bg-gradient-to-br from-orange-50 to-amber-50 dark:from-orange-900/20 dark:to-amber-900/20 rounded-xl shadow-md p-6 border border-orange-200 dark:border-orange-800"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 1.1 }}
>
<h2 className="text-xl font-bold text-orange-800 dark:text-orange-200 mb-4 flex items-center gap-2">
<StarIcon className="w-5 h-5" />
Scores & Stats
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{game.community_score && (
<div className="flex items-center justify-between p-4 bg-gray-50 rounded-xl">
<div className="flex items-center gap-2">
<GlobeAltIcon className="w-5 h-5 text-gray-400" />
<span className="text-gray-600">Community Score</span>
</div>
<span className="text-gray-900 font-bold text-lg">
{game.community_score}%
</span>
</div>
)}
{game.critic_score && (
<div className="flex items-center justify-between p-4 bg-gray-50 rounded-xl">
<div className="flex items-center gap-2">
<StarIcon className="w-5 h-5 text-yellow-500" />
<span className="text-gray-600">Critic Score</span>
</div>
<span className="text-gray-900 font-bold text-lg">
{game.critic_score}%
</span>
</div>
)}
{game.time_to_beat && (
<div className="flex items-center justify-between p-4 bg-gray-50 rounded-xl">
<div className="flex items-center gap-2">
<ClockIcon className="w-5 h-5 text-gray-400" />
<span className="text-gray-600">Time to Beat</span>
</div>
<span className="text-gray-900 font-medium">
{game.time_to_beat}
</span>
</div>
)}
</div>
</motion.div>
)}
</motion.div>
</div>
</div>
</div>
)
}

171
src/pages/Games.tsx Normal file
View File

@@ -0,0 +1,171 @@
import React, { useState, useContext } from 'react'
import { motion } from 'framer-motion'
import { useGames } from '../hooks/useApi'
import { ViewContext } from '../components/Layout'
import { useNavigate } from 'react-router-dom'
import { ComputerDesktopIcon as GamepadIcon } from '@heroicons/react/24/outline'
// Import from alternative frontend
import { MediaListView } from '../../../frontend/components/MediaListView'
import { MediaDetailView } from '../../../frontend/components/MediaDetailView'
export default function Games() {
const viewContext = useContext(ViewContext)
const viewMode = viewContext?.viewMode || 'grid'
const gridColumns = viewContext?.gridColumns || 5
const coverSize = viewContext?.coverSize || 200
const PaginationComp = viewContext?.PaginationComponent
const navigate = useNavigate()
const handleContentClick = (content) => {
navigate(`/games/${content.id}`)
}
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 [currentPage, setCurrentPage] = useState(1)
const [pageSize, setPageSize] = useState(24)
const { data: gamesData, isLoading } = useGames({
page: currentPage,
per_page: pageSize
})
const games = gamesData?.items || []
const pagination = gamesData?.pagination
// Convert games to MediaItem format for MediaListView
const mediaItems = games.map(game => ({
id: game.id.toString(),
title: game.title || 'Untitled Game',
type: 'game' as const,
coverUrl: game.cover_url || game.poster_url || '',
rating: Number(game.rating) || 0,
status: 'completed' as const, // Default status
releaseYear: game.release_year || game.year || new Date().getFullYear(),
addedAt: game.created_at || new Date().toISOString(),
favorite: game.favorite || false,
platform: game.platform || game.source_name || 'Unknown',
description: game.description || '',
genres: game.genres || []
}))
// State for MediaListView
const [selectedId, setSelectedId] = useState<string | null>(null)
const [multiSelectedIds, setMultiSelectedIds] = useState<Set<string>>(new Set())
const [detailItem, setDetailItem] = useState<any>(null)
const handleSelect = (item: any) => {
setSelectedId(item.id)
setDetailItem(item)
}
const handleToggleSelect = (id: string) => {
const newSelected = new Set(multiSelectedIds)
if (newSelected.has(id)) {
newSelected.delete(id)
} else {
newSelected.add(id)
}
setMultiSelectedIds(newSelected)
}
return (
<>
{isLoading ? (
<div className={`grid gap-6 ${getGridClass(gridColumns)}`}>
{Array.from({ length: pageSize }, (_, i) => (
<div key={i} className="bg-white dark:bg-slate-800 rounded-xl p-4 animate-pulse">
<div className="bg-slate-200 dark:bg-slate-600 h-32 rounded-lg mb-4"></div>
<div className="h-4 bg-slate-200 dark:bg-slate-600 rounded mb-2"></div>
<div className="h-4 bg-slate-200 dark:bg-slate-600 rounded w-3/4"></div>
</div>
))}
</div>
) : viewMode === 'list' ? (
// Use MediaListView for list mode with sideview
<div className="flex w-full h-full">
<div className={`${detailItem ? 'w-1/2 hidden md:flex' : 'w-full'} h-full flex flex-col transition-all duration-500`}>
<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>
</div>
{detailItem && (
<div className="w-full md:w-1/2 h-full border-l border-white/5 relative overflow-hidden animate-in slide-in-from-right duration-300">
<MediaDetailView
item={detailItem}
allMedia={mediaItems}
onBack={() => setDetailItem(null)}
onEdit={(item) => console.log('Edit item:', item)}
onToggleFavorite={(id, isFav) => console.log('Toggle favorite:', id, isFav)}
onSelectRelated={(item) => setDetailItem(item)}
onSelectPerson={(name, id) => console.log('Select person:', name, id)}
onViewAll={(type, id) => console.log('View all:', type, id)}
/>
</div>
)}
</div>
) : (
// Grid view
<motion.div
className={`grid gap-6 ${getGridClass(gridColumns)}`}
>
{games.map((game, index) => (
<motion.div
key={game.id}
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
onClick={() => handleContentClick(game)}
transition={{ delay: index * 0.03 }}
className="bg-white dark:bg-slate-800 rounded-xl p-6 shadow-lg hover:shadow-xl transition-shadow"
>
<div className="flex gap-4">
<div className="bg-slate-200 dark:bg-slate-600 w-24 h-32 rounded-lg flex-shrink-0"></div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-slate-900 dark:text-slate-100 mb-2">
{game.title || 'Untitled Game'}
</h3>
<p className="text-slate-600 dark:text-slate-400 text-sm">
{game.description || 'No description available'}
</p>
</div>
</div>
</motion.div>
))}
</motion.div>
)}
{!isLoading && pagination && pagination.last_page > 1 && PaginationComp && (
<div className="mt-8">
<PaginationComp
currentPage={currentPage}
lastPage={pagination.last_page}
total={pagination.total}
onPageChange={setCurrentPage}
itemsPerPage={pageSize}
onItemsPerPageChange={setPageSize}
/>
</div>
)}
</>
)
}

412
src/pages/GamesPage.tsx Normal file
View File

@@ -0,0 +1,412 @@
import { useState } from 'react'
import React from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { useQuery } from '@tanstack/react-query'
import {
ComputerDesktopIcon as GamepadIcon,
MagnifyingGlassIcon,
AdjustmentsHorizontalIcon,
TrophyIcon,
PlayIcon,
CheckCircleIcon,
ChevronDownIcon,
ListBulletIcon,
Squares2X2Icon,
PhotoIcon
} from '@heroicons/react/24/outline'
import GameCard, { GameItem } from '../components/GameCard'
import { gamesApi } from '../services/api'
type ViewMode = 'grid' | 'list' | 'covers'
type SortOption = 'title_asc' | 'title_desc' | 'year_asc' | 'year_desc' | 'playtime_desc' | 'rating_desc' | 'last_played_desc'
const sortOptions: { value: SortOption; label: string }[] = [
{ value: 'title_asc', label: 'Title (A-Z)' },
{ value: 'title_desc', label: 'Title (Z-A)' },
{ value: 'year_asc', label: 'Release Year (Oldest First)' },
{ value: 'year_desc', label: 'Release Year (Newest First)' },
{ value: 'playtime_desc', label: 'Most Played' },
{ value: 'rating_desc', label: 'Highest Rated' },
{ value: 'last_played_desc', label: 'Last Played' }
]
const categoryIcons = {
BEATEN: TrophyIcon,
PLAYING: PlayIcon,
COMPLETED: CheckCircleIcon,
UNPLAYED: GamepadIcon
}
const categoryColors = {
BEATEN: 'from-yellow-500 to-amber-600',
PLAYING: 'from-blue-500 to-blue-600',
COMPLETED: 'from-green-500 to-green-600',
UNPLAYED: 'from-gray-500 to-gray-600'
}
export default function GamesPage() {
const [searchTerm, setSearchTerm] = useState('')
const [selectedCategory, setSelectedCategory] = useState<string | null>(null)
const [viewMode, setViewMode] = useState<ViewMode>('grid')
const [sortBy, setSortBy] = useState<SortOption>('title_asc')
const [showFilters, setShowFilters] = useState(false)
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set(['BEATEN', 'PLAYING']))
const { data: gamesData, isLoading, error } = useQuery({
queryKey: ['games', searchTerm, sortBy],
queryFn: () => gamesApi.getAll({ search: searchTerm, sort: sortBy }),
enabled: !selectedCategory
})
const { data: categoryData, isLoading: isLoadingCategory } = useQuery({
queryKey: ['games', 'category', selectedCategory, searchTerm, sortBy],
queryFn: () => gamesApi.getByCategory(selectedCategory!, { search: searchTerm, sort: sortBy }),
enabled: !!selectedCategory
})
const toggleCategory = (category: string) => {
setExpandedCategories(prev => {
const newSet = new Set(prev)
if (newSet.has(category)) {
newSet.delete(category)
} else {
newSet.add(category)
}
return newSet
})
}
const handleSearch = (e: React.FormEvent) => {
e.preventDefault()
}
const clearFilters = () => {
setSearchTerm('')
setSelectedCategory(null)
setSortBy('title_asc')
}
const activeFiltersCount = [
searchTerm,
selectedCategory,
sortBy !== 'title_asc'
].filter(Boolean).length
if (isLoading || isLoadingCategory) {
return (
<div className="min-h-screen flex items-center justify-center">
<motion.div
className="text-center"
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
>
<div className="relative">
<div className="w-16 h-16 border-4 border-green-200 rounded-full"></div>
<motion.div
className="absolute top-0 left-0 w-16 h-16 border-4 border-green-600 rounded-full border-t-transparent"
animate={{ rotate: 360 }}
transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
/>
</div>
<motion.p
className="mt-6 text-gray-600 text-lg"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
>
Loading games...
</motion.p>
</motion.div>
</div>
)
}
if (error) {
return (
<div className="min-h-screen flex items-center justify-center">
<motion.div
className="text-center max-w-md"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
>
<GamepadIcon className="mx-auto h-20 w-20 text-gray-400 mb-6" />
<h2 className="text-3xl font-bold text-gray-900 mb-3">Error Loading Games</h2>
<p className="text-gray-600 mb-8 text-lg">Failed to load your game library. Please try again later.</p>
<motion.button
onClick={() => window.location.reload()}
className="btn btn-primary"
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
Try Again
</motion.button>
</motion.div>
</div>
)
}
const categories = selectedCategory
? [{ name: selectedCategory, count: categoryData?.count || 0, games: categoryData?.games || [] }]
: gamesData?.data || []
return (
<div className="min-h-screen bg-gray-50">
{/* Header */}
<div className="bg-white shadow-sm border-b border-gray-200">
<div className="container mx-auto px-4 py-6">
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-gradient-to-r from-green-500 to-emerald-600 rounded-xl">
<GamepadIcon className="w-6 h-6 text-white" />
</div>
<h1 className="text-3xl font-bold text-gray-900">Games Library</h1>
</div>
{/* Search and Filters */}
<div className="flex flex-col sm:flex-row gap-3 flex-1 lg:max-w-2xl">
<form onSubmit={handleSearch} className="flex-1">
<div className="relative">
<MagnifyingGlassIcon className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
type="text"
placeholder="Search games..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-xl focus:ring-2 focus:ring-green-500 focus:border-transparent"
/>
</div>
</form>
<motion.button
onClick={() => setShowFilters(!showFilters)}
className={`px-4 py-2 rounded-xl border transition-all duration-200 flex items-center gap-2 ${
activeFiltersCount > 0
? 'border-green-500 bg-green-50 text-green-700'
: 'border-gray-300 hover:border-gray-400'
}`}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<AdjustmentsHorizontalIcon className="w-4 h-4" />
Filters
{activeFiltersCount > 0 && (
<span className="bg-green-600 text-white text-xs px-2 py-1 rounded-full">
{activeFiltersCount}
</span>
)}
</motion.button>
</div>
</div>
{/* Filters Panel */}
<AnimatePresence>
{showFilters && (
<motion.div
className="mt-6 p-4 bg-gray-50 rounded-xl border border-gray-200"
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.3 }}
>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{/* Sort */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Sort By</label>
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as SortOption)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
>
{sortOptions.map(option => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
{/* View Mode */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">View Mode</label>
<div className="flex gap-2">
{[
{ mode: 'grid' as ViewMode, icon: Squares2X2Icon, label: 'Grid' },
{ mode: 'list' as ViewMode, icon: ListBulletIcon, label: 'List' },
{ mode: 'covers' as ViewMode, icon: PhotoIcon, label: 'Covers' }
].map(({ mode, icon: Icon, label }) => (
<motion.button
key={mode}
onClick={() => setViewMode(mode)}
className={`flex-1 px-3 py-2 rounded-lg border transition-all duration-200 flex items-center justify-center gap-1 ${
viewMode === mode
? 'border-green-500 bg-green-50 text-green-700'
: 'border-gray-300 hover:border-gray-400'
}`}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<Icon className="w-4 h-4" />
<span className="text-sm">{label}</span>
</motion.button>
))}
</div>
</div>
{/* Clear Filters */}
<div className="flex items-end">
<motion.button
onClick={clearFilters}
className="w-full px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition-all duration-200"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
Clear All Filters
</motion.button>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
{/* Categories and Games */}
<div className="container mx-auto px-4 py-8">
{/* Category Navigation */}
{!selectedCategory && (
<motion.div
className="mb-8"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
>
<div className="flex flex-wrap gap-3">
{categories.map((category: any) => {
const Icon = categoryIcons[category.name as keyof typeof categoryIcons]
return (
<motion.button
key={category.name}
onClick={() => setSelectedCategory(category.name)}
className="group relative px-6 py-3 bg-white rounded-xl shadow-sm border border-gray-200 hover:shadow-md transition-all duration-200"
whileHover={{ scale: 1.02, y: -2 }}
whileTap={{ scale: 0.98 }}
>
<div className="flex items-center gap-3">
<div className={`p-2 bg-gradient-to-r ${categoryColors[category.name as keyof typeof categoryColors]} rounded-lg`}>
<Icon className="w-5 h-5 text-white" />
</div>
<div className="text-left">
<div className="font-semibold text-gray-900">{category.name}</div>
<div className="text-sm text-gray-500">{category.count} games</div>
</div>
</div>
<ChevronDownIcon className="absolute right-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400 group-hover:text-gray-600 transition-colors" />
</motion.button>
)
})}
</div>
</motion.div>
)}
{/* Back to Categories */}
{selectedCategory && (
<motion.div
className="mb-6"
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
>
<motion.button
onClick={() => setSelectedCategory(null)}
className="flex items-center gap-2 px-4 py-2 bg-white rounded-lg border border-gray-300 hover:bg-gray-50 transition-all duration-200"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<ChevronDownIcon className="w-4 h-4 rotate-90" />
Back to Categories
</motion.button>
</motion.div>
)}
{/* Games List */}
<div className="space-y-8">
{categories.map((category: any) => (
<motion.div
key={category.name}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
>
{/* Category Header */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className={`p-2 bg-gradient-to-r ${categoryColors[category.name as keyof typeof categoryColors]} rounded-lg`}>
{React.createElement(categoryIcons[category.name as keyof typeof categoryIcons], {
className: "w-5 h-5 text-white"
})}
</div>
<h2 className="text-2xl font-bold text-gray-900">
{category.name} ({category.count})
</h2>
</div>
{!selectedCategory && (
<motion.button
onClick={() => toggleCategory(category.name)}
className="flex items-center gap-2 px-3 py-1 text-sm text-gray-600 hover:text-gray-900 transition-colors"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
{expandedCategories.has(category.name) ? 'Collapse' : 'Expand'}
<ChevronDownIcon className={`w-4 h-4 transform transition-transform ${
expandedCategories.has(category.name) ? 'rotate-180' : ''
}`} />
</motion.button>
)}
</div>
{/* Games Grid/List */}
<AnimatePresence>
{(expandedCategories.has(category.name) || selectedCategory) && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.3 }}
>
{category.games.length === 0 ? (
<div className="text-center py-12 bg-white rounded-xl border border-gray-200">
<GamepadIcon className="mx-auto w-12 h-12 text-gray-400 mb-4" />
<p className="text-gray-500">No games found in this category</p>
</div>
) : (
<div className={
viewMode === 'grid'
? 'grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-6'
: viewMode === 'list'
? 'space-y-2'
: 'grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 gap-6'
}>
{category.games.map((game: GameItem, index: number) => (
<motion.div
key={game.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.05 }}
>
<GameCard
game={game}
viewMode={viewMode}
/>
</motion.div>
))}
</div>
)}
</motion.div>
)}
</AnimatePresence>
</motion.div>
))}
</div>
</div>
</div>
)
}

567
src/pages/MovieDetail.tsx Normal file
View File

@@ -0,0 +1,567 @@
import { useState } from 'react'
import { useParams, Link, useNavigate } from 'react-router-dom'
import { motion, AnimatePresence } from 'framer-motion'
import { useQuery } from '@tanstack/react-query'
import {
FilmIcon,
StarIcon,
ClockIcon,
CalendarIcon,
ArrowLeftIcon,
PlayIcon,
HeartIcon,
ShareIcon,
PlusIcon,
CheckIcon,
UserIcon,
GlobeAltIcon,
CurrencyDollarIcon,
FilmIcon as FilmStripIcon,
SparklesIcon
} from '@heroicons/react/24/outline'
import { moviesApi } from '../services/api'
import { Tooltip, PulseDot } from '../components/MicroInteractions'
export default function MovieDetail() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const movieId = parseInt(id || '0')
const [isWatched, setIsWatched] = useState(false)
const [isFavorite, setIsFavorite] = useState(false)
const [showShareMenu, setShowShareMenu] = useState(false)
const { data: movie, isLoading, error } = useQuery({
queryKey: ['movie', movieId],
queryFn: () => moviesApi.get(movieId),
enabled: !!movieId
})
// Handle image URL
const getImageUrl = (url?: string) => {
if (!url) return null
// If it's already a full URL, return as is
if (url.startsWith('http')) return url
// If it's a relative path starting with /images/, prepend API base
if (url.startsWith('/images/')) {
const apiUrl = (import.meta as any).env?.VITE_API_URL?.replace('/api', '') || ''
return `${apiUrl}${url}`
}
// Otherwise, assume it's relative to API
const apiUrl = (import.meta as any).env?.VITE_API_URL?.replace('/api', '') || ''
return `${apiUrl}/images/${url}`
}
const posterUrl = getImageUrl(movie?.poster_url)
const backdropUrl = getImageUrl(movie?.backdrop_url)
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<motion.div
className="text-center"
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
>
<div className="relative">
<div className="w-16 h-16 border-4 border-blue-200 rounded-full"></div>
<motion.div
className="absolute top-0 left-0 w-16 h-16 border-4 border-blue-600 rounded-full border-t-transparent"
animate={{ rotate: 360 }}
transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
/>
</div>
<motion.p
className="mt-6 text-gray-600 text-lg"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
>
Loading movie details...
</motion.p>
</motion.div>
</div>
)
}
if (error || !movie) {
return (
<div className="min-h-screen flex items-center justify-center">
<motion.div
className="text-center max-w-md"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
>
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: "spring", stiffness: 200 }}
>
<FilmIcon className="mx-auto h-20 w-20 text-gray-400 mb-6" />
</motion.div>
<h2 className="text-3xl font-bold text-gray-900 mb-3">Movie Not Found</h2>
<p className="text-gray-600 mb-8 text-lg">The movie you're looking for doesn't exist or has been removed.</p>
<motion.div
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
<Link
to="/movies"
className="btn btn-primary"
>
<ArrowLeftIcon className="w-5 h-5 mr-2" />
Back to Movies
</Link>
</motion.div>
</motion.div>
</div>
)
}
const formatRuntime = (minutes?: number) => {
if (!minutes) return null
const hours = Math.floor(minutes / 60)
const mins = minutes % 60
return `${hours}h ${mins}m`
}
const formatYear = (date?: string) => {
if (!date) return null
return new Date(date).getFullYear()
}
return (
<div className="min-h-screen">
{/* Backdrop */}
{backdropUrl && (
<div className="relative h-96 lg:h-[500px] overflow-hidden">
<motion.img
src={backdropUrl}
alt={movie.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-black/80 via-black/40 to-transparent"></div>
{/* Back button */}
<motion.div
className="absolute top-4 left-4 z-10"
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.3 }}
>
<Tooltip text="Back to movies">
<Link
to="/movies"
className="inline-flex items-center px-4 py-2 bg-black/50 backdrop-blur-sm text-white rounded-xl hover:bg-black/70 transition-all duration-200"
>
<ArrowLeftIcon className="w-5 h-5 mr-2" />
Back
</Link>
</Tooltip>
</motion.div>
{/* Floating action buttons */}
<motion.div
className="absolute top-4 right-4 z-10 flex gap-2"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.4 }}
>
<Tooltip text="Add to favorites">
<motion.button
onClick={() => setIsFavorite(!isFavorite)}
className="p-3 bg-black/50 backdrop-blur-sm text-white rounded-xl hover:bg-black/70 transition-all duration-200"
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
>
<HeartIcon className={`w-5 h-5 ${isFavorite ? 'fill-red-500 text-red-500' : ''}`} />
</motion.button>
</Tooltip>
<Tooltip text="Share">
<motion.button
onClick={() => setShowShareMenu(!showShareMenu)}
className="p-3 bg-black/50 backdrop-blur-sm text-white rounded-xl hover:bg-black/70 transition-all duration-200 relative"
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
>
<ShareIcon className="w-5 h-5" />
<AnimatePresence>
{showShareMenu && (
<motion.div
className="absolute right-0 top-full mt-2 bg-white rounded-xl shadow-2xl p-2 min-w-[150px]"
initial={{ opacity: 0, scale: 0.9, y: -10 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9, y: -10 }}
>
{['Copy Link', 'Facebook', 'Twitter', 'Email'].map((item) => (
<motion.button
key={item}
className="block w-full text-left px-3 py-2 text-gray-700 hover:bg-gray-100 rounded-lg transition-colors text-sm"
whileHover={{ x: 5 }}
>
{item}
</motion.button>
))}
</motion.div>
)}
</AnimatePresence>
</motion.button>
</Tooltip>
</motion.div>
{/* Movie title overlay */}
<motion.div
className="absolute bottom-6 left-6 right-6"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.5 }}
>
<h1 className="text-4xl lg:text-6xl font-bold text-white mb-3 drop-shadow-lg">
{movie.title}
</h1>
<div className="flex items-center gap-4 text-white/90">
{movie.release_date && (
<span className="flex items-center gap-1">
<CalendarIcon className="w-4 h-4" />
{formatYear(movie.release_date)}
</span>
)}
{movie.runtime_minutes && (
<span className="flex items-center gap-1">
<ClockIcon className="w-4 h-4" />
{formatRuntime(movie.runtime_minutes)}
</span>
)}
{movie.rating && (
<span className="flex items-center gap-1">
<StarIcon className="w-4 h-4 text-yellow-400" />
{movie.rating}/10
</span>
)}
</div>
</motion.div>
</div>
)}
<div className="container mx-auto px-4 py-8">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Poster and basic info */}
<motion.div
className="lg:col-span-1"
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.6 }}
>
<div className="sticky top-4 space-y-6">
{/* Poster */}
<motion.div
className="relative group overflow-hidden"
whileHover={{ scale: 1.02 }}
>
{posterUrl ? (
<img
src={posterUrl}
alt={movie.title}
className="w-3/4 mx-auto rounded-xl shadow-lg"
/>
) : (
<div className="w-3/4 mx-auto aspect-[2/3] bg-gradient-to-br from-gray-100 to-gray-200 rounded-xl flex items-center justify-center">
<FilmIcon className="text-gray-400 w-12 h-12" />
</div>
)}
{/* Play button overlay */}
<motion.div
className="absolute inset-0 bg-black/40 rounded-xl flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-300"
whileHover={{ opacity: 1 }}
>
<motion.button
className="p-3 bg-blue-600/90 backdrop-blur-sm rounded-full text-white hover:bg-blue-700 transition-all duration-200"
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
>
<PlayIcon className="w-6 h-6" />
</motion.button>
</motion.div>
</motion.div>
{/* Action buttons */}
<div className="flex gap-2">
<motion.button
onClick={() => setIsWatched(!isWatched)}
className={`flex-1 btn flex items-center justify-center gap-1 text-sm py-2 ${
isWatched ? 'bg-green-600 hover:bg-green-700' : 'btn-primary'
}`}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
{isWatched ? (
<><CheckIcon className="w-4 h-4" /> Watched</>
) : (
<><PlusIcon className="w-4 h-4" /> Mark as Watched</>
)}
</motion.button>
<motion.button
onClick={() => setIsFavorite(!isFavorite)}
className={`p-2 rounded-xl border-2 transition-all duration-200 ${
isFavorite
? 'border-red-500 bg-red-50 text-red-500'
: 'border-gray-200 hover:border-red-300 hover:bg-red-50'
}`}
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
>
<HeartIcon className={`w-4 h-4 ${isFavorite ? 'fill-current' : ''}`} />
</motion.button>
</div>
{/* Movie info */}
<div className="bg-gradient-to-br from-blue-50 to-indigo-50 dark:from-blue-900/20 dark:to-indigo-900/20 rounded-xl shadow-md p-5 border border-blue-200 dark:border-blue-800 space-y-3">
<h3 className="text-base font-bold text-blue-800 dark:text-blue-200 flex items-center gap-2">
<SparklesIcon className="w-4 h-4" />
Movie Details
</h3>
{movie.rating && (
<div className="flex items-center justify-between p-2 bg-white/50 dark:bg-gray-800/50 rounded-lg">
<span className="text-blue-600 dark:text-blue-400 font-medium text-sm">Rating</span>
<div className="flex items-center gap-2">
<StarIcon className="w-4 h-4 text-yellow-500" />
<span className="text-base font-bold text-slate-900 dark:text-slate-100">{movie.rating}/10</span>
</div>
</div>
)}
{movie.runtime_minutes && (
<div className="flex items-center justify-between p-2 bg-white/50 dark:bg-gray-800/50 rounded-lg">
<span className="text-blue-600 dark:text-blue-400 font-medium text-sm">Runtime</span>
<div className="flex items-center gap-2">
<ClockIcon className="w-4 h-4 text-gray-400" />
<span className="font-medium text-slate-900 dark:text-slate-100 text-sm">{formatRuntime(movie.runtime_minutes)}</span>
</div>
</div>
)}
{movie.release_date && (
<div className="flex items-center justify-between p-2 bg-white/50 dark:bg-gray-800/50 rounded-lg">
<span className="text-blue-600 dark:text-blue-400 font-medium text-sm">Release</span>
<div className="flex items-center gap-2">
<CalendarIcon className="w-4 h-4 text-gray-400" />
<span className="font-medium text-slate-900 dark:text-slate-100 text-sm">{formatYear(movie.release_date)}</span>
</div>
</div>
)}
{movie.source_name && (
<div className="flex items-center justify-between p-2 bg-white/50 dark:bg-gray-800/50 rounded-lg">
<span className="text-blue-600 dark:text-blue-400 font-medium text-sm">Source</span>
<span className="px-2 py-1 bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-200 rounded-full text-xs font-medium">
{movie.source_name}
</span>
</div>
)}
{isWatched && (
<div className="flex items-center justify-between p-3 bg-green-50 dark:bg-green-900/20 rounded-lg border-t border-green-200 dark:border-green-800">
<span className="text-green-600 dark:text-green-400 font-medium">Status</span>
<div className="flex items-center gap-2">
<PulseDot color="green" />
<span className="text-green-600 dark:text-green-400 font-medium">Watched</span>
</div>
</div>
)}
</div>
</div>
</motion.div>
{/* Details */}
<motion.div
className="lg:col-span-2 space-y-8"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.7 }}
>
{/* Overview */}
{movie.overview && (
<motion.div
className="bg-gradient-to-br from-purple-50 to-pink-50 dark:from-purple-900/20 dark:to-pink-900/20 rounded-xl shadow-md p-6 border border-purple-200 dark:border-purple-800"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.8 }}
>
<h2 className="text-xl font-bold text-purple-800 dark:text-purple-200 mb-3 flex items-center gap-2">
<FilmStripIcon className="w-5 h-5" />
Overview
</h2>
<p className="text-slate-700 dark:text-slate-300 leading-relaxed">{movie.overview}</p>
</motion.div>
)}
{/* Cast & Crew */}
<motion.div
className="bg-gradient-to-br from-green-50 to-emerald-50 dark:from-green-900/20 dark:to-emerald-900/20 rounded-xl shadow-md p-6 border border-green-200 dark:border-green-800"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.9 }}
>
<h2 className="text-xl font-bold text-green-800 dark:text-green-200 mb-4 flex items-center gap-2">
<UserIcon className="w-5 h-5" />
Cast & Crew
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{movie.director && (
<div className="bg-white/50 dark:bg-gray-800/50 rounded-lg p-3">
<h3 className="text-base font-semibold text-gray-900 dark:text-white mb-1">Director</h3>
<p className="text-gray-700 dark:text-gray-300">{movie.director}</p>
</div>
)}
{movie.writer && (
<div className="bg-white/50 dark:bg-gray-800/50 rounded-lg p-3">
<h3 className="text-base font-semibold text-gray-900 dark:text-white mb-1">Writer</h3>
<p className="text-gray-700 dark:text-gray-300">{movie.writer}</p>
</div>
)}
{movie.genre && (
<div className="bg-white/50 dark:bg-gray-800/50 rounded-lg p-3">
<h3 className="text-base font-semibold text-gray-900 dark:text-white mb-2">Genre</h3>
<div className="flex flex-wrap gap-2">
{movie.genre.split(',').map((g: string) => (
<span key={g} className="px-2 py-1 bg-gradient-to-r from-blue-100 to-blue-200 text-blue-800 rounded-full text-xs font-medium">
{g.trim()}
</span>
))}
</div>
</div>
)}
</div>
</motion.div>
{/* Actors Section - Full Width */}
{movie.actors && Array.isArray(movie.actors) && (
<motion.div
className="bg-gradient-to-br from-green-50 to-emerald-50 dark:from-green-900/20 dark:to-emerald-900/20 rounded-xl shadow-md p-6 border border-green-200 dark:border-green-800"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 1.0 }}
>
<h2 className="text-xl font-bold text-green-800 dark:text-green-200 mb-4 flex items-center gap-2">
<UserIcon className="w-5 h-5" />
Cast & Actors
</h2>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 gap-4">
{movie.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 flex-col items-center gap-2">
<div className="w-16 h-16 rounded-full overflow-hidden bg-gray-200 dark:bg-gray-700 flex-shrink-0">
{actor.thumbnail_path ? (
<img
src={getImageUrl(actor.thumbnail_path) || ''}
alt={actor.name}
className="w-full h-full object-cover"
onError={(e) => {
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-6 h-6 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-6 h-6 text-white/50" />
</div>
)}
</div>
<div className="text-center">
<h4 className="font-medium text-gray-900 dark:text-white text-sm truncate w-full">
{typeof actor.name === 'string' ? actor.name : 'Unknown Actor'}
</h4>
</div>
</div>
</motion.div>
))}
</div>
</motion.div>
)}
{/* Additional metadata */}
{movie.metadata && typeof movie.metadata === 'object' && (
<motion.div
className="bg-gradient-to-br from-orange-50 to-amber-50 dark:from-orange-900/20 dark:to-amber-900/20 rounded-xl shadow-md p-6 border border-orange-200 dark:border-orange-800"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 1.0 }}
>
<h2 className="text-xl font-bold text-orange-800 dark:text-orange-200 mb-4 flex items-center gap-2">
<GlobeAltIcon className="w-5 h-5" />
Additional Information
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{movie.metadata.budget && (
<div className="flex items-center justify-between p-4 bg-gray-50 rounded-xl">
<div className="flex items-center gap-2">
<CurrencyDollarIcon className="w-5 h-5 text-gray-400" />
<span className="text-gray-600">Budget</span>
</div>
<span className="text-gray-900 font-bold text-lg">
${movie.metadata.budget.toLocaleString()}
</span>
</div>
)}
{movie.metadata.revenue && (
<div className="flex items-center justify-between p-4 bg-gray-50 rounded-xl">
<div className="flex items-center gap-2">
<CurrencyDollarIcon className="w-5 h-5 text-green-500" />
<span className="text-gray-600">Revenue</span>
</div>
<span className="text-green-600 font-bold text-lg">
${movie.metadata.revenue.toLocaleString()}
</span>
</div>
)}
{movie.metadata.original_language && (
<div className="flex items-center justify-between p-4 bg-gray-50 rounded-xl">
<div className="flex items-center gap-2">
<GlobeAltIcon className="w-5 h-5 text-gray-400" />
<span className="text-gray-600">Language</span>
</div>
<span className="text-gray-900 font-medium">
{movie.metadata.original_language.toUpperCase()}
</span>
</div>
)}
{movie.metadata.status && (
<div className="flex items-center justify-between p-4 bg-gray-50 rounded-xl">
<div className="flex items-center gap-2">
<FilmIcon className="w-5 h-5 text-gray-400" />
<span className="text-gray-600">Status</span>
</div>
<span className="px-3 py-1 bg-blue-100 text-blue-800 rounded-full text-sm font-medium">
{movie.metadata.status}
</span>
</div>
)}
</div>
</motion.div>
)}
</motion.div>
</div>
</div>
</div>
)
}

151
src/pages/Movies.tsx Normal file
View File

@@ -0,0 +1,151 @@
import React, { useState, useContext } from 'react'
import { motion } from 'framer-motion'
import MovieCard from '../components/MovieCard'
import { MovieCardSkeleton } from '../components/LoadingSkeleton'
import { useMovies } from '../hooks/useApi'
import { ViewContext } from '../components/Layout'
import { FilmIcon } from '@heroicons/react/24/outline'
// Import from alternative frontend
import { MediaListView } from '../../../frontend/components/MediaListView'
import { MediaDetailView } from '../../../frontend/components/MediaDetailView'
export default function Movies() {
const viewContext = useContext(ViewContext)
const viewMode = viewContext?.viewMode || 'grid'
const gridColumns = viewContext?.gridColumns || 5
const coverSize = viewContext?.coverSize || 200
const PaginationComp = viewContext?.PaginationComponent
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 [currentPage, setCurrentPage] = useState(1)
const [pageSize, setPageSize] = useState(24)
const { data: moviesData, isLoading } = useMovies({
page: currentPage,
per_page: pageSize
})
const movies = moviesData?.items || []
const pagination = moviesData?.pagination
// Convert movies to MediaItem format for MediaListView
const mediaItems = movies.map(movie => ({
id: movie.id.toString(),
title: movie.title || 'Untitled Movie',
type: 'movie' as const,
coverUrl: movie.poster_url || movie.cover_url || '',
rating: Number(movie.rating) || 0,
status: 'completed' as const, // Default status
releaseYear: movie.release_year || movie.year || new Date().getFullYear(),
addedAt: movie.created_at || new Date().toISOString(),
favorite: movie.favorite || false,
platform: movie.source_name || 'Unknown',
description: movie.description || '',
genres: movie.genres || []
}))
// State for MediaListView
const [selectedId, setSelectedId] = useState<string | null>(null)
const [multiSelectedIds, setMultiSelectedIds] = useState<Set<string>>(new Set())
const [detailItem, setDetailItem] = useState<any>(null)
const handleSelect = (item: any) => {
setSelectedId(item.id)
setDetailItem(item)
}
const handleToggleSelect = (id: string) => {
const newSelected = new Set(multiSelectedIds)
if (newSelected.has(id)) {
newSelected.delete(id)
} else {
newSelected.add(id)
}
setMultiSelectedIds(newSelected)
}
return (
<>
<div className="mx-auto">
{isLoading ? (
<div className={`grid gap-6 ${getGridClass(gridColumns)}`}>
{Array.from({ length: pageSize }, (_, i) => (
<MovieCardSkeleton key={i} />
))}
</div>
) : viewMode === 'list' ? (
// Use MediaListView for list mode with sideview
<div className="flex w-full h-full">
<div className={`${detailItem ? 'w-1/2 hidden md:flex' : 'w-full'} h-full flex flex-col transition-all duration-500`}>
<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>
</div>
{detailItem && (
<div className="w-full md:w-1/2 h-full border-l border-white/5 relative overflow-hidden animate-in slide-in-from-right duration-300">
<MediaDetailView
item={detailItem}
allMedia={mediaItems}
onBack={() => setDetailItem(null)}
onEdit={(item) => console.log('Edit item:', item)}
onToggleFavorite={(id, isFav) => console.log('Toggle favorite:', id, isFav)}
onSelectRelated={(item) => setDetailItem(item)}
onSelectPerson={(name, id) => console.log('Select person:', name, id)}
onViewAll={(type, id) => console.log('View all:', type, id)}
/>
</div>
)}
</div>
) : (
// Grid view with MovieCard
<motion.div
className={`grid gap-6 ${getGridClass(gridColumns)}`}
>
{movies.map((movie, index) => (
<motion.div
key={movie.id}
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: index * 0.03 }}
>
<MovieCard movie={movie} viewMode={viewMode} coverSize={coverSize} />
</motion.div>
))}
</motion.div>
)}
{!isLoading && pagination && pagination.last_page > 1 && PaginationComp && (
<div className="mt-8">
<PaginationComp
currentPage={currentPage}
lastPage={pagination.last_page}
total={pagination.total}
onPageChange={setCurrentPage}
itemsPerPage={pageSize}
onItemsPerPageChange={setPageSize}
/>
</div>
)}
</div>
</>
)
}

18
src/pages/Music.tsx Normal file
View File

@@ -0,0 +1,18 @@
import React from 'react'
export default function Music() {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">Music</h1>
<p className="text-gray-600">Manage your music collection</p>
</div>
<div className="text-center py-12">
<div className="text-gray-400 text-6xl mb-4">🎵</div>
<h3 className="text-lg font-medium text-gray-900 mb-2">Music Page</h3>
<p className="text-gray-600">This page will be implemented with music management features</p>
</div>
</div>
)
}

454
src/pages/Search.tsx Normal file
View File

@@ -0,0 +1,454 @@
import { useState, useEffect } from 'react'
import { useSearchParams, useNavigate } from 'react-router-dom'
import { useSearch } from '../hooks/useApi'
import MediaCard, { MediaType } from '../components/MediaCard'
import { motion } from 'framer-motion'
import {
FilmIcon,
TvIcon,
MusicalNoteIcon,
ComputerDesktopIcon as GamepadIcon,
VideoCameraIcon,
UserGroupIcon,
DocumentTextIcon,
MagnifyingGlassIcon,
SparklesIcon,
XMarkIcon
} from '@heroicons/react/24/outline'
import { clsx } from 'clsx'
export default function Search() {
const [searchParams] = useSearchParams()
const navigate = useNavigate()
const [query, setQuery] = useState(searchParams.get('q') || '')
const [type, setType] = useState(searchParams.get('type') || 'all')
const [viewMode, setViewMode] = useState<'grid' | 'list' | 'covers'>('grid')
const [debouncedQuery, setDebouncedQuery] = useState(query)
// Debounce search query to prevent excessive URL updates
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedQuery(query)
}, 300) // 300ms delay
return () => clearTimeout(timer)
}, [query])
// Update URL when debounced query changes
useEffect(() => {
const params = new URLSearchParams(searchParams)
// Update query parameter
if (debouncedQuery.trim()) {
params.set('q', debouncedQuery.trim())
} else {
params.delete('q')
}
// Update type parameter
if (type !== 'all') {
params.set('type', type)
} else {
params.delete('type')
}
// Navigate to new URL
const newUrl = `/search?${params.toString()}`
if (newUrl !== window.location.pathname + window.location.search) {
navigate(newUrl, { replace: true })
}
}, [debouncedQuery, type, searchParams, navigate])
// Update local state when URL params change (e.g., browser back/forward)
useEffect(() => {
const urlQuery = searchParams.get('q') || ''
const urlType = searchParams.get('type') || 'all'
if (urlQuery !== query) {
setQuery(urlQuery)
setDebouncedQuery(urlQuery)
}
if (urlType !== type) {
setType(urlType)
}
}, [searchParams])
// Handle search input changes (immediate UI update, debounced URL update)
const handleSearchChange = (newQuery: string) => {
setQuery(newQuery)
}
// Handle type filter changes (immediate URL update)
const handleTypeChange = (newType: string) => {
setType(newType)
}
// Use real API search with debounced query
const { data: searchResults, isLoading } = useSearch({
q: debouncedQuery,
type: type as any
})
const getMediaTypeBgGradient = (mediaType: string) => {
switch (mediaType) {
case 'movie': return 'bg-gradient-to-br from-blue-500 to-blue-600'
case 'tvshow': return 'bg-gradient-to-br from-purple-500 to-purple-600'
case 'game': return 'bg-gradient-to-br from-green-500 to-green-600'
case 'music': return 'bg-gradient-to-br from-pink-500 to-pink-600'
case 'adult': return 'bg-gradient-to-br from-red-500 to-red-600'
case 'actors': return 'bg-gradient-to-br from-indigo-500 to-indigo-600'
default: return 'bg-gradient-to-br from-gray-500 to-gray-600'
}
}
const getMediaTypeIcon = (mediaType: string) => {
switch (mediaType) {
case 'movie': return FilmIcon
case 'tvshow': return TvIcon
case 'game': return GamepadIcon
case 'music': return MusicalNoteIcon
case 'adult': return VideoCameraIcon
case 'actors': return UserGroupIcon
default: return DocumentTextIcon
}
}
const getMediaTypeLabel = (mediaType: string) => {
switch (mediaType) {
case 'movie': return 'Movies'
case 'tvshow': return 'TV Shows'
case 'game': return 'Games'
case 'music': return 'Music'
case 'adult': return 'Adult'
case 'actors': return 'Actors'
default: return 'Other'
}
}
const renderSearchResults = () => {
if (!searchResults || isLoading) {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-purple-50 dark:from-slate-900 dark:via-blue-900/20 dark:to-purple-900/20">
<div className="text-center py-20">
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: "spring", stiffness: 200, damping: 15 }}
className="inline-flex items-center justify-center w-20 h-20 bg-gradient-to-br from-blue-500 to-purple-600 rounded-3xl shadow-2xl mb-6"
>
<MagnifyingGlassIcon className="w-10 h-10 text-white" />
</motion.div>
<motion.h3
className="text-2xl font-bold text-slate-900 dark:text-white mb-3"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
>
<span className="bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent">
{debouncedQuery ? `Searching for "${debouncedQuery}"...` : 'Search Your Media'}
</span>
</motion.h3>
<motion.p
className="text-slate-600 dark:text-slate-400 max-w-md mx-auto"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
>
{debouncedQuery ? 'Finding matches across your collection...' : 'Enter a search term to discover movies, shows, games, and more'}
</motion.p>
</div>
</div>
)
}
const results = []
let totalResults = 0
// Collect results from all media types
if (searchResults.movies?.items?.length) {
results.push({ type: 'movie', items: searchResults.movies.items })
totalResults += searchResults.movies.items.length
}
if (searchResults.tvshows?.items?.length) {
results.push({ type: 'tvshow', items: searchResults.tvshows.items })
totalResults += searchResults.tvshows.items.length
}
if (searchResults.games?.items?.length) {
results.push({ type: 'game', items: searchResults.games.items })
totalResults += searchResults.games.items.length
}
if (searchResults.artists?.items?.length) {
results.push({ type: 'music', items: searchResults.artists.items })
totalResults += searchResults.artists.items.length
}
if (searchResults.actors?.items?.length) {
results.push({ type: 'actors', items: searchResults.actors.items })
totalResults += searchResults.actors.items.length
}
if (totalResults === 0) {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-purple-50 dark:from-slate-900 dark:via-blue-900/20 dark:to-purple-900/20">
<div className="text-center py-20">
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: "spring", stiffness: 200, damping: 15 }}
className="inline-flex items-center justify-center w-20 h-20 bg-gradient-to-br from-gray-400 to-gray-600 rounded-3xl shadow-2xl mb-6"
>
<DocumentTextIcon className="w-10 h-10 text-white" />
</motion.div>
<motion.h3
className="text-2xl font-bold text-slate-900 dark:text-white mb-3"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
>
<span className="bg-gradient-to-r from-gray-600 to-gray-800 bg-clip-text text-transparent">
No Results Found
</span>
</motion.h3>
<motion.p
className="text-slate-600 dark:text-slate-400 max-w-md mx-auto"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
>
No results found for "{debouncedQuery}". Try different keywords or adjust your filters.
</motion.p>
</div>
</div>
)
}
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-purple-50 dark:from-slate-900 dark:via-blue-900/20 dark:to-purple-900/20">
{/* Results Header */}
<motion.div
className="text-center py-8"
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
<div className="flex items-center justify-center gap-3 mb-4">
<SparklesIcon className="w-6 h-6 text-purple-500" />
<h3 className="text-3xl font-bold text-slate-900 dark:text-white">
<span className="bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent">
Found {totalResults} Results
</span>
</h3>
<SparklesIcon className="w-6 h-6 text-blue-500" />
</div>
<p className="text-slate-600 dark:text-slate-400">
For "{debouncedQuery}" across your media collection
</p>
</motion.div>
{/* View Mode Toggle */}
<div className="flex justify-center mb-8">
<div className="inline-flex bg-white/80 dark:bg-slate-800/80 backdrop-blur-sm rounded-2xl p-1 shadow-lg border border-slate-200/50 dark:border-slate-700/50">
<button
onClick={() => setViewMode('grid')}
className={`px-4 py-2 rounded-xl text-sm font-medium transition-all duration-200 ${
viewMode === 'grid'
? 'bg-gradient-to-r from-blue-500 to-purple-600 text-white shadow-md'
: 'text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white'
}`}
>
Grid
</button>
<button
onClick={() => setViewMode('list')}
className={`px-4 py-2 rounded-xl text-sm font-medium transition-all duration-200 ${
viewMode === 'list'
? 'bg-gradient-to-r from-blue-500 to-purple-600 text-white shadow-md'
: 'text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white'
}`}
>
List
</button>
<button
onClick={() => setViewMode('covers')}
className={`px-4 py-2 rounded-xl text-sm font-medium transition-all duration-200 ${
viewMode === 'covers'
? 'bg-gradient-to-r from-blue-500 to-purple-600 text-white shadow-md'
: 'text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white'
}`}
>
Cover
</button>
</div>
</div>
{/* Results Sections */}
<div className="max-w-screen-2xl mx-auto px-6 space-y-12">
{results.map((section, sectionIndex) => {
const Icon = getMediaTypeIcon(section.type)
const label = getMediaTypeLabel(section.type)
const bgGradient = getMediaTypeBgGradient(section.type)
return (
<motion.div
key={section.type}
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: sectionIndex * 0.1 }}
>
{/* Section Header */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-4">
<motion.div
className={clsx(
"p-3 rounded-2xl shadow-lg",
bgGradient
)}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
<Icon className="w-6 h-6 text-white" />
</motion.div>
<div>
<h4 className="text-2xl font-bold text-slate-900 dark:text-white">
{label}
</h4>
<p className="text-slate-600 dark:text-slate-400 text-sm">
{section.items.length} items found
</p>
</div>
</div>
<motion.div
className="bg-white/80 dark:bg-slate-800/80 backdrop-blur-sm rounded-2xl px-4 py-2 shadow-lg border border-slate-200/50 dark:border-slate-700/50"
whileHover={{ scale: 1.05 }}
>
<span className="text-sm font-medium text-slate-600 dark:text-slate-400">
{section.items.length} results
</span>
</motion.div>
</div>
{/* Media Grid */}
<div className={clsx(
viewMode === 'grid' && "grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-6",
viewMode === 'list' && "space-y-4",
viewMode === 'covers' && "flex flex-wrap gap-6 justify-center"
)}>
{section.items.map((item, index) => (
<motion.div
key={`${section.type}-${item.id}`}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: sectionIndex * 0.1 + index * 0.05 }}
className={viewMode === 'list' ? "bg-white/80 dark:bg-slate-800/80 backdrop-blur-sm rounded-2xl shadow-lg border border-slate-200/50 dark:border-slate-700/50" : ""}
>
<MediaCard
media={item}
mediaType={section.type as MediaType}
viewMode={viewMode}
coverSize="medium"
/>
</motion.div>
))}
</div>
</motion.div>
)
})}
</div>
</div>
)
}
return (
<div className="min-h-screen">
{/* Search Header */}
<motion.div
className="relative bg-gradient-to-br from-blue-600 via-purple-600 to-pink-600 overflow-hidden"
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
{/* Background Pattern */}
<div className="absolute inset-0 opacity-10">
<div className="absolute inset-0 bg-black/20"></div>
<div className="absolute inset-0" style={{
backgroundImage: `url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23ffffff' fill-opacity='0.4'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E")`,
}}></div>
</div>
<div className="relative max-w-screen-2xl mx-auto px-6 lg:px-8 py-12">
<div className="text-center text-white">
<motion.h1
className="text-4xl lg:text-5xl font-bold mb-4"
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.1 }}
>
<span className="bg-gradient-to-r from-white to-blue-100 bg-clip-text text-transparent">
Media Search
</span>
</motion.h1>
<motion.p
className="text-xl text-white/80 max-w-2xl mx-auto"
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.2 }}
>
Discover movies, TV shows, games, and music across your entire collection
</motion.p>
</div>
</div>
</motion.div>
{/* Search Controls */}
<div className="bg-white/80 dark:bg-slate-800/80 backdrop-blur-sm border-b border-slate-200/50 dark:border-slate-700/50 sticky top-0 z-40 shadow-lg">
<div className="max-w-screen-2xl mx-auto px-6 lg:px-8 py-6">
<div className="flex flex-col lg:flex-row gap-4">
{/* Search Bar */}
<div className="flex-1">
<div className="relative">
<MagnifyingGlassIcon className="absolute left-4 top-1/2 transform -translate-y-1/2 w-5 h-5 text-slate-400" />
<input
type="text"
placeholder="Search movies, shows, games, music..."
value={query}
onChange={(e) => handleSearchChange(e.target.value)}
className="w-full pl-12 pr-12 py-3 bg-white dark:bg-slate-800 border border-slate-300 dark:border-slate-600 rounded-2xl text-slate-900 dark:text-white placeholder-slate-500 dark:placeholder-slate-400 focus:ring-2 focus:ring-purple-500 focus:border-transparent shadow-lg"
/>
{query && (
<motion.button
type="button"
onClick={() => handleSearchChange('')}
className="absolute right-3 top-1/2 transform -translate-y-1/2 p-1 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 transition-colors"
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
>
<XMarkIcon className="w-4 h-4" />
</motion.button>
)}
</div>
</div>
{/* Media Type Filter */}
<div className="flex items-center gap-2">
<select
value={type}
onChange={(e) => handleTypeChange(e.target.value)}
className="px-4 py-3 bg-white dark:bg-slate-800 border border-slate-300 dark:border-slate-600 rounded-2xl text-slate-900 dark:text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent shadow-lg font-medium"
>
<option value="all">All Media</option>
<option value="movies">Movies</option>
<option value="tvshows">TV Shows</option>
<option value="games">Games</option>
<option value="music">Music</option>
<option value="adult">Adult</option>
<option value="actors">Actors</option>
</select>
</div>
</div>
</div>
</div>
{/* Search Results */}
{renderSearchResults()}
</div>
)
}

592
src/pages/TVShowDetail.tsx Normal file
View File

@@ -0,0 +1,592 @@
import React, { useState } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { useParams, useNavigate } from 'react-router-dom'
import {
ArrowLeftIcon,
PlayIcon,
CalendarIcon,
UserIcon,
TvIcon,
FilmIcon,
PlusIcon,
CheckIcon,
EyeIcon,
ExclamationTriangleIcon
} from '@heroicons/react/24/outline'
import { StarIcon as StarIconSolid } from '@heroicons/react/24/solid'
import { useTvShow } from '../hooks/useApi'
import { Tooltip } from '../components/MicroInteractions'
class TVShowDetailErrorBoundary 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('TVShowDetail error:', error, errorInfo)
}
render() {
if (this.state.hasError) {
return (
<div className="min-h-screen flex items-center justify-center">
<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 mb-2">TV Show Error</h2>
<p className="text-gray-600 mb-6">
{this.state.error?.message || 'Failed to load TV show details'}
</p>
<div className="flex gap-4 justify-center">
<button
onClick={() => this.props.navigate('/tvshows')}
className="btn btn-secondary"
>
Back to TV Shows
</button>
<button
onClick={() => window.location.reload()}
className="btn btn-primary"
>
Reload Page
</button>
</div>
</div>
</div>
)
}
return this.props.children
}
}
export default function TVShowDetail() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const [activeTab, setActiveTab] = useState<'overview' | 'seasons' | 'episodes' | 'actors'>('overview')
const [selectedSeason, setSelectedSeason] = useState<number>(1)
// Validate ID and handle invalid cases
const tvShowId = id ? Number(id) : null
const isValidId = tvShowId && !isNaN(tvShowId) && tvShowId > 0
// Early return for invalid ID
if (!isValidId) {
return (
<div className="min-h-screen bg-gray-50 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 mb-2">Invalid TV Show ID</h2>
<p className="text-gray-600 mb-6">The TV show ID is invalid or missing.</p>
<button
onClick={() => navigate('/tvshows')}
className="btn btn-primary"
>
Back to TV Shows
</button>
</div>
</div>
)
}
const { data: tvShow, isLoading, error } = useTvShow(tvShowId)
if (isLoading) {
return (
<div className="min-h-screen">
<div className="animate-pulse">
<div className="h-96 bg-gray-300"></div>
<div className="container mx-auto px-4 py-8">
<div className="h-8 bg-gray-300 rounded w-1/3 mb-4"></div>
<div className="h-4 bg-gray-300 rounded w-2/3 mb-2"></div>
<div className="h-4 bg-gray-300 rounded w-1/2"></div>
</div>
</div>
</div>
)
}
if (error || !tvShow) {
return (
<div className="min-h-screen bg-gray-50 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 mb-2">TV Show Not Found</h2>
<p className="text-gray-600 mb-6">The TV show you're looking for doesn't exist or has been removed.</p>
<button
onClick={() => navigate('/tvshows')}
className="btn btn-primary"
>
Back to TV Shows
</button>
</div>
</div>
)
}
const getImage = (imagePath?: string): string | undefined => {
if (!imagePath) return undefined
// If it's already a full HTTP URL, use as-is
if (imagePath.startsWith('http')) {
return imagePath
}
// If it starts with /images/, use as-is (already correct path)
if (imagePath.startsWith('/images/')) {
return `${(import.meta as any).env?.VITE_API_URL?.replace('/api', '') || ''}${imagePath}`
}
// If it's a relative path without /images/, add the prefix
return `${(import.meta as any).env?.VITE_API_URL?.replace('/api', '') || ''}/images/${imagePath}`
}
const backdropUrl = getImage(tvShow.backdrop_url)
const posterUrl = getImage(tvShow.poster_url)
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`
}
// Use real data from API
const seasons = tvShow?.seasons || []
const actors = tvShow?.actors || []
// Debug: Log the data structure
console.log('TV Show data:', tvShow)
console.log('Seasons:', seasons)
console.log('Actors:', actors)
const filteredEpisodes = seasons
.find((season: any) => season.season_number === selectedSeason)?.episodes || []
// Helper function to safely render episode data
const renderEpisodeTitle = (episode: any) => {
if (typeof episode.title === 'string') return episode.title
if (typeof episode.name === 'string') return episode.name
return 'Untitled Episode'
}
const renderEpisodeOverview = (episode: any): string => {
if (typeof episode.overview === 'string') return episode.overview
return 'No description available'
}
return (
<TVShowDetailErrorBoundary navigate={navigate}>
<div className="min-h-screen">
{/* Hero Section with Backdrop */}
<div className="relative h-96 overflow-hidden">
{backdropUrl ? (
<>
<motion.img
src={backdropUrl}
alt={tvShow.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-blue-600 to-purple-600 dark:from-blue-800 dark:to-purple-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 */}
<motion.button
className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 p-6 bg-white/90 dark:bg-white/80 backdrop-blur-sm rounded-full shadow-2xl hover:bg-white dark:hover:bg-white/90 transition-all duration-200"
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
>
<PlayIcon className="w-12 h-12 text-blue-600 dark:text-blue-500" />
</motion.button>
</div>
{/* Main Content */}
<div className="container mx-auto px-4 py-8">
<div className="flex flex-col lg:flex-row gap-8">
{/* Poster and Basic Info */}
<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={tvShow.title}
className="w-3/4 mx-auto rounded-xl shadow-lg mb-4 dark:shadow-black/20"
whileHover={{ scale: 1.02 }}
/>
) : (
<div className="w-3/4 mx-auto aspect-[2/3] bg-gray-200 dark:bg-gray-800 rounded-xl flex items-center justify-center mb-4">
<TvIcon className="w-16 h-16 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">{tvShow.title}</h1>
{tvShow.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-xs text-gray-600 dark:text-gray-400">
<div className="flex items-center gap-1">
<CalendarIcon className="w-3 h-3" />
<span>{formatYear(tvShow.release_date)}</span>
</div>
<div className="flex items-center gap-1">
<TvIcon className="w-3 h-3" />
<span>{tvShow.number_of_seasons || seasons.length || 0} Seasons</span>
</div>
<div className="flex items-center gap-1">
<FilmIcon className="w-3 h-3" />
<span>{tvShow.number_of_episodes || 0} Episodes</span>
</div>
</div>
{tvShow.rating && (
<div className="flex items-center gap-2">
<div className="flex items-center">
{[...Array(5)].map((_, i) => (
<StarIconSolid
key={i}
className={`w-4 h-4 ${
i < Math.floor(Number(tvShow.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(tvShow.rating)?.toFixed(1)}
</span>
</div>
)}
<div className="flex gap-2">
<motion.button
className="btn btn-primary flex-1 flex items-center justify-center gap-1 text-sm"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<PlayIcon className="w-3 h-3" />
Play Now
</motion.button>
<motion.button
className="btn btn-secondary flex items-center justify-center gap-1 text-sm"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<PlusIcon className="w-3 h-3" />
Add to List
</motion.button>
</div>
</div>
</motion.div>
</div>
{/* Details and Tabs */}
<div className="lg:w-2/3">
<motion.div
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.3 }}
>
{/* Tab Navigation */}
<div className="border-b border-gray-200 dark:border-gray-700 mb-6">
<nav className="flex space-x-8">
{[
{ id: 'overview', label: 'Overview' },
{ id: 'seasons', label: 'Seasons' },
{ id: 'episodes', label: 'Episodes' },
{ id: 'actors', label: 'Cast & Crew' }
].map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id as any)}
className={`py-4 px-1 border-b-2 font-medium text-sm transition-all duration-200 ${
activeTab === tab.id
? 'border-blue-500 text-blue-600 dark:border-blue-400 dark:text-blue-400'
: 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 hover:border-gray-300 dark:hover:border-gray-600'
}`}
>
{tab.label}
</button>
))}
</nav>
</div>
{/* Tab Content */}
<AnimatePresence mode="wait">
{activeTab === 'overview' && (
<motion.div
key="overview"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.3 }}
className="space-y-6"
>
<div className="bg-gradient-to-br from-purple-50 to-pink-50 dark:from-purple-900/20 dark:to-pink-900/20 rounded-xl shadow-md p-5 border border-purple-200 dark:border-purple-800">
<h3 className="text-lg font-semibold text-purple-800 dark:text-purple-200 mb-2">Synopsis</h3>
<p className="text-slate-700 dark:text-slate-300 leading-relaxed text-sm">
{tvShow.overview || 'No synopsis available for this TV show.'}
</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-xl shadow-md p-5 border border-blue-200 dark:border-blue-800">
<h3 className="text-lg font-semibold text-blue-800 dark:text-blue-200 mb-3">Details</h3>
<div className="grid grid-cols-2 gap-3">
<div className="bg-white/50 dark:bg-gray-800/50 rounded-lg p-2">
<span className="text-xs text-blue-600 dark:text-blue-400 font-medium">First Air Date</span>
<p className="font-medium text-slate-900 dark:text-slate-100 text-sm">{formatDate(tvShow.release_date)}</p>
</div>
<div className="bg-white/50 dark:bg-gray-800/50 rounded-lg p-2">
<span className="text-xs text-blue-600 dark:text-blue-400 font-medium">Total Seasons</span>
<p className="font-medium text-slate-900 dark:text-slate-100 text-sm">{tvShow.number_of_seasons || seasons.length || 0}</p>
</div>
<div className="bg-white/50 dark:bg-gray-800/50 rounded-lg p-2">
<span className="text-xs text-blue-600 dark:text-blue-400 font-medium">Total Episodes</span>
<p className="font-medium text-slate-900 dark:text-slate-100 text-sm">{tvShow.number_of_episodes || 0}</p>
</div>
<div className="bg-white/50 dark:bg-gray-800/50 rounded-lg p-2">
<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">{tvShow.source_name}</p>
</div>
</div>
</div>
</motion.div>
)}
{activeTab === 'seasons' && (
<motion.div
key="seasons"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.3 }}
className="space-y-4"
>
<h3 className="text-lg font-semibold text-green-800 dark:text-green-200 mb-3">Seasons</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{seasons.map((season: any) => (
<motion.div
key={season.season_number}
className="bg-gradient-to-br from-green-50 to-emerald-50 dark:from-green-900/20 dark:to-emerald-900/20 rounded-xl shadow-md p-4 hover:shadow-lg transition-all duration-200 cursor-pointer border border-green-200 dark:border-green-800"
whileHover={{ scale: 1.02 }}
onClick={() => {
setSelectedSeason(season.season_number)
setActiveTab('episodes')
}}
>
<div className="flex gap-4">
{season.poster_url ? (
<img
src={getImage(season.poster_url)}
alt={`Season ${season.season_number}`}
className="w-20 h-28 object-cover rounded-lg"
onError={(e) => {
e.currentTarget.src = '/images/placeholder.jpg'
}}
/>
) : (
<div className="w-20 h-28 bg-gray-200 dark:bg-gray-700 rounded-lg flex items-center justify-center">
<TvIcon className="w-8 h-8 text-gray-400 dark:text-gray-600" />
</div>
)}
<div className="flex-1">
<h4 className="font-semibold text-gray-900 dark:text-white">Season {season.season_number}</h4>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">{season.episode_count} Episodes</p>
<p className="text-sm text-gray-500 dark:text-gray-500">
{season.watched_episodes || 0} watched
</p>
</div>
</div>
</motion.div>
))}
</div>
</motion.div>
)}
{activeTab === 'episodes' && (
<motion.div
key="episodes"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.3 }}
className="space-y-4"
>
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-orange-800 dark:text-orange-200 mb-3">Episodes</h3>
<select
value={selectedSeason}
onChange={(e) => setSelectedSeason(Number(e.target.value))}
className="px-3 py-2 border border-gray-200 dark:border-gray-600 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-800 dark:text-white focus:ring-blue-500 dark:focus:ring-blue-400"
>
{seasons.map((season: any) => (
<option key={season.season_number} value={season.season_number}>
Season {season.season_number}
</option>
))}
</select>
</div>
<div className="space-y-3">
{filteredEpisodes.map((episode: any) => (
<motion.div
key={episode.id}
className="bg-gradient-to-br from-orange-50 to-amber-50 dark:from-orange-900/20 dark:to-amber-900/20 rounded-xl shadow-md p-3 hover:shadow-lg transition-all duration-200 border border-orange-200 dark:border-orange-800"
whileHover={{ scale: 1.01 }}
>
<div className="flex gap-4">
{episode.still_url ? (
<img
src={episode.still_url.startsWith('http') ? episode.still_url : `${(import.meta as any).env?.VITE_API_URL?.replace('/api', '') || ''}${episode.still_url}`}
alt={renderEpisodeTitle(episode)}
className="w-32 h-20 object-cover rounded-lg"
/>
) : (
<div className="w-32 h-20 bg-gray-200 dark:bg-gray-700 rounded-lg flex items-center justify-center">
<FilmIcon className="w-8 h-8 text-gray-400 dark:text-gray-600" />
</div>
)}
<div className="flex-1">
<div className="flex items-start justify-between">
<div>
<h4 className="font-semibold text-gray-900 dark:text-white">
S{episode.season_number?.toString().padStart(2, '0')}E{episode.episode_number?.toString().padStart(2, '0')} - {renderEpisodeTitle(episode)}
</h4>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2 line-clamp-2">
{renderEpisodeOverview(episode)}
</p>
<div className="flex items-center gap-4 text-sm text-gray-500 dark:text-gray-500">
<span>{formatDate(episode.air_date)}</span>
{episode.runtime_minutes && (
<span>{formatRuntime(episode.runtime_minutes)}</span>
)}
</div>
</div>
<div className="flex items-center gap-2">
{episode.watched && (
<Tooltip text="Watched">
<EyeIcon className="w-5 h-5 text-green-600 dark:text-green-500" />
</Tooltip>
)}
<motion.button
className="p-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 transition-colors"
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
>
<PlayIcon className="w-4 h-4" />
</motion.button>
</div>
</div>
</div>
</div>
</motion.div>
))}
</div>
</motion.div>
)}
{activeTab === 'actors' && (
<motion.div
key="actors"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.3 }}
className="space-y-4"
>
<h3 className="text-lg font-semibold text-purple-800 dark:text-purple-200 mb-3">Cast & Crew</h3>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
{actors.map((actor: any) => (
<motion.div
key={actor.id || Math.random()}
className="text-center group cursor-pointer"
whileHover={{ scale: 1.05 }}
onClick={() => {
if (actor.id) {
navigate(`/actors/${actor.id}`)
}
}}
>
{actor.thumbnail_path ? (
<img
src={actor.thumbnail_path.startsWith('http')
? actor.thumbnail_path
: `${(import.meta as any).env?.VITE_API_URL?.replace('/api', '') || ''}/images/${actor.thumbnail_path}`
}
alt={actor.name || 'Actor'}
className="w-full h-48 object-cover rounded-xl mb-3 group-hover:shadow-lg transition-shadow"
onError={(e) => {
e.currentTarget.src = '/images/placeholder.jpg'
}}
/>
) : (
<div className="w-full h-48 bg-gray-200 dark:bg-gray-800 rounded-xl mb-3 flex items-center justify-center">
<UserIcon className="w-16 h-16 text-gray-400 dark:text-gray-600" />
</div>
)}
<h4 className="font-medium text-gray-900 dark:text-white group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors">
{actor.name || 'Unknown Actor'}
</h4>
<p className="text-sm text-gray-500 dark:text-gray-400">
{actor.role || actor.character_name || 'Actor'}
</p>
</motion.div>
))}
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
</div>
</div>
</div>
</div>
</TVShowDetailErrorBoundary>
)
}

245
src/pages/TVShows.tsx Normal file
View File

@@ -0,0 +1,245 @@
import React, { useState, useContext } from 'react'
import { motion } from 'framer-motion'
import { useTvShows } from '../hooks/useApi'
import { ViewContext } from '../components/Layout'
import { TvIcon } from '@heroicons/react/24/outline'
import { useNavigate } from 'react-router-dom'
import { Play, Eye, Lock} from 'lucide-react'
// Import from alternative frontend
import { MediaListView } from '../../../frontend/components/MediaListView'
import { MediaDetailView } from '../../../frontend/components/MediaDetailView'
export default function TVShows() {
const viewContext = useContext(ViewContext)
const viewMode = viewContext?.viewMode || 'grid'
const gridColumns = viewContext?.gridColumns || 5
const coverSize = viewContext?.coverSize || 200
const PaginationComp = viewContext?.PaginationComponent
const navigate = useNavigate()
const handleContentClick = (content) => {
navigate(`/tvshows/${content.id}`)
}
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 [currentPage, setCurrentPage] = useState(1)
const [pageSize, setPageSize] = useState(24)
const { data: tvShowsData, isLoading } = useTvShows({
page: currentPage,
per_page: pageSize
})
const tvShows = tvShowsData?.items || []
const pagination = tvShowsData?.pagination
/*
total: tvShowsData?.total || 0,
last_page: tvShowsData?.lastPage || 1,
current_page: currentPage,
per_page: pageSize
}
*/
// Convert TV shows to MediaItem format for MediaListView
const mediaItems = tvShows.map(tvShow => ({
id: tvShow.id.toString(),
title: tvShow.title || 'Untitled TV Show',
type: 'tvshows' as const,
coverUrl: tvShow.poster_url || tvShow.cover_url || '',
rating: Number(tvShow.rating) || 0,
status: 'completed' as const, // Default status
releaseYear: tvShow.release_year || tvShow.year || new Date().getFullYear(),
addedAt: tvShow.created_at || new Date().toISOString(),
favorite: tvShow.favorite || false,
platform: tvShow.source_name || 'Unknown',
description: tvShow.description || '',
genres: tvShow.genres || []
}))
// State for MediaListView
const [selectedId, setSelectedId] = useState<string | null>(null)
const [multiSelectedIds, setMultiSelectedIds] = useState<Set<string>>(new Set())
const [detailItem, setDetailItem] = useState<any>(null)
const handleSelect = (item: any) => {
setSelectedId(item.id)
setDetailItem(item)
}
const handleToggleSelect = (id: string) => {
const newSelected = new Set(multiSelectedIds)
if (newSelected.has(id)) {
newSelected.delete(id)
} else {
newSelected.add(id)
}
setMultiSelectedIds(newSelected)
}
return (
<>
{isLoading ? (
<div className={`grid gap-6 ${getGridClass(gridColumns)}`}>
{Array.from({ length: pageSize }, (_, i) => (
<div key={i} className="bg-white dark:bg-slate-800 rounded-xl p-4 animate-pulse">
<div className="bg-slate-200 dark:bg-slate-600 h-32 rounded-lg mb-4"></div>
<div className="h-4 bg-slate-200 dark:bg-slate-600 rounded mb-2"></div>
<div className="h-4 bg-slate-200 dark:bg-slate-600 rounded w-3/4"></div>
</div>
))}
</div>
) : viewMode === 'list' ? (
// Use MediaListView for list mode with sideview
<div className="flex w-full h-full">
<div className={`${detailItem ? 'w-1/2 hidden md:flex' : 'w-full'} h-full flex flex-col transition-all duration-500`}>
<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>
</div>
{detailItem && (
<div className="w-full md:w-1/2 h-full border-l border-white/5 relative overflow-hidden animate-in slide-in-from-right duration-300">
<MediaDetailView
item={detailItem}
allMedia={mediaItems}
onBack={() => setDetailItem(null)}
onEdit={(item) => console.log('Edit item:', item)}
onToggleFavorite={(id, isFav) => console.log('Toggle favorite:', id, isFav)}
onSelectRelated={(item) => setDetailItem(item)}
onSelectPerson={(name, id) => console.log('Select person:', name, id)}
onViewAll={(type, id) => console.log('View all:', type, id)}
/>
</div>
)}
</div>
) : viewMode === 'grid' ? (
// Grid view
<motion.div
className={`grid gap-6 ${getGridClass(gridColumns)} p-4 md:p-8`}
>
{tvShows.map((tvShow: any, index: number) => (
<motion.div
onClick={() => handleContentClick(tvShow)}
key={tvShow.id}
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: index * 0.03 }}
className="bg-white dark:bg-slate-800 rounded-xl p-6 shadow-lg hover:shadow-xl transition-shadow group cursor-pointer"
>
<div className="flex gap-4">
{tvShow.poster_url ? (
<div className="bg-slate-200 dark:bg-slate-600 w-24 h-32 rounded-lg flex-shrink-0">
<img
src={tvShow.poster_url.startsWith('http') ? tvShow.poster_url : `${(import.meta as any).env?.VITE_API_URL?.replace('/api', '') || ''}/images/${tvShow.poster_url}`}
alt={tvShow.title}
className="w-full h-full object-cover"
/>
</div>
) : (
<div className="bg-slate-200 dark:bg-slate-600 w-24 h-32 rounded-lg flex-shrink-0"></div>
)}
<div className="flex-1">
<h3 className="text-lg font-semibold text-slate-900 dark:text-slate-100 mb-2">
{tvShow.title || 'Untitled TV Show'}
</h3>
<p className="text-slate-600 dark:text-slate-400 text-sm">
{tvShow.description || 'No description available'}
</p>
</div>
</div>
</motion.div>
))}
</motion.div>
) : viewMode === 'cover' ? (
// Cover View (Shelf-style) - use dynamic cover size
<div className="flex flex-wrap gap-4 justify-center">
{tvShows.map((tvShow: any, index: number) => (
<>
<div className="relative group" key={tvShow.id}>
{(() => {
const aspectRatio = tvShow.poster_aspect_ratio || 1.5;
const height = coverSize / aspectRatio;
console.log(tvShow)
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`
}}
>
{tvShow.poster_url ? (
<img
src={tvShow.poster_url.startsWith('http') ? tvShow.poster_url : `${(import.meta as any).env?.VITE_API_URL?.replace('/api', '') || ''}/images/${tvShow.poster_url}`}
alt={tvShow.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>
{tvShow.watched && (
<div className="absolute top-2 left-2">
<Eye className="w-4 h-4 text-green-400" />
</div>
)}
</div>
</div>
);
})()}
</div>
</>
))}
</div>
) : null}
{!isLoading && pagination && pagination.last_page > 1 && PaginationComp && (
<div className="mt-8">
<PaginationComp
currentPage={currentPage}
lastPage={pagination.last_page}
total={pagination.total}
onPageChange={setCurrentPage}
itemsPerPage={pageSize}
onItemsPerPageChange={setPageSize}
/>
</div>
)}
</>
)
}

625
src/services/api.ts Normal file
View File

@@ -0,0 +1,625 @@
import axios, { AxiosInstance, AxiosResponse } from 'axios'
import { PaginatedResponse } from '../types'
// API base configuration
const API_BASE_URL = (import.meta as any).env?.VITE_API_URL || '/api'
// Types for API responses
interface ApiResponse<T = any> {
success: boolean
data?: T
message?: string
error?: string
}
interface LocalPaginatedResponse<T> {
items: T[]
pagination: {
total: number
per_page: number
current_page: number
last_page: number
}
available_filters?: {
genres?: string[]
directors?: string[]
sources?: string[]
}
}
interface SearchResult {
games?: PaginatedResponse<any>
movies?: PaginatedResponse<any>
tvshows?: PaginatedResponse<any>
artists?: PaginatedResponse<any>
actors?: PaginatedResponse<any>
}
// Auth types
interface LoginCredentials {
username: string
password: string
}
interface RegisterData {
username: string
email: string
password: string
}
interface AuthResponse {
user: {
id: number
username: string
email: string
is_admin: boolean
}
token: string
refresh_token: string
}
// Create axios instance with default configuration
const createApiInstance = (): AxiosInstance => {
const instance = axios.create({
baseURL: API_BASE_URL,
timeout: 10000,
headers: {
'Content-Type': 'application/json',
},
})
// Request interceptor to add auth token
instance.interceptors.request.use(
(config) => {
const token = localStorage.getItem('auth_token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error) => {
return Promise.reject(error)
}
)
// Response interceptor to handle common errors
instance.interceptors.response.use(
(response: AxiosResponse) => {
return response
},
(error) => {
if (error.response?.status === 401) {
// Token expired or invalid, clear local storage and redirect to login
localStorage.removeItem('auth_token')
localStorage.removeItem('refresh_token')
window.location.href = '/login'
}
return Promise.reject(error)
}
)
return instance
}
const api = createApiInstance()
// Auth API
export const authApi = {
login: async (credentials: LoginCredentials): Promise<AuthResponse> => {
const response = await api.post('/auth/login', credentials)
const { token, refresh_token } = response.data.data
// Store tokens
localStorage.setItem('auth_token', token)
localStorage.setItem('refresh_token', refresh_token)
return response.data.data
},
register: async (data: RegisterData): Promise<AuthResponse> => {
const response = await api.post('/auth/register', data)
const { token, refresh_token } = response.data.data
// Store tokens
localStorage.setItem('auth_token', token)
localStorage.setItem('refresh_token', refresh_token)
return response.data.data
},
getCurrentUser: async () => {
const response = await api.get('/auth/me')
return response.data.data
},
refreshToken: async () => {
const refreshToken = localStorage.getItem('refresh_token')
if (!refreshToken) {
throw new Error('No refresh token available')
}
const response = await api.post('/auth/refresh', { refresh_token: refreshToken })
const { token, refresh_token } = response.data.data
// Update stored tokens
localStorage.setItem('auth_token', token)
localStorage.setItem('refresh_token', refresh_token)
return response.data.data
},
logout: () => {
localStorage.removeItem('auth_token')
localStorage.removeItem('refresh_token')
},
isAuthenticated: (): boolean => {
return !!localStorage.getItem('auth_token')
},
}
// Movies API
export const moviesApi = {
list: async (params?: {
page?: number
per_page?: number
genre?: string
year?: number
search?: string
}): Promise<LocalPaginatedResponse<any>> => {
try {
const response = await api.get('/movies', { params })
return response.data.data
} catch (error: any) {
console.error('Movies API error:', error)
// Return fallback data if API fails
return {
items: [],
pagination: {
total: 0,
per_page: 20,
current_page: 1,
last_page: 1
}
}
}
},
get: async (id: number): Promise<any> => {
const response = await api.get(`/movies/${id}`)
return response.data.data
},
create: async (data: any): Promise<any> => {
const response = await api.post('/movies', data)
return response.data.data
},
update: async (id: number, data: any): Promise<any> => {
const response = await api.put(`/movies/${id}`, data)
return response.data.data
},
delete: async (id: number): Promise<void> => {
await api.delete(`/movies/${id}`)
},
}
// TV Shows API
export const tvShowsApi = {
list: async (params?: {
page?: number
per_page?: number
genre?: string
year?: number
search?: string
}): Promise<PaginatedResponse<any>> => {
try {
const response = await api.get('/tvshows', { params })
// Handle the wrapped API response structure
if (response.data?.success && response.data?.data) {
return response.data.data
}
return response.data
} catch (error: any) {
console.error('TV Shows API error:', error)
// Return fallback data if API fails
return {
items: [],
pagination: {
total: 0,
per_page: 20,
current_page: 1,
last_page: 1
},
available_filters: {
genres: [],
directors: [],
sources: []
}
}
}
},
get: async (id: number): Promise<any> => {
try {
const response = await api.get(`/tvshows/${id}`)
// Handle the wrapped API response structure
if (response.data?.success && response.data?.data) {
return response.data.data
}
return response.data
} catch (error: any) {
console.error('TV Show API error:', error)
// Return fallback data if API fails
return {
id: id,
title: 'TV Show Not Found',
overview: 'Unable to load TV show details. Please try again later.',
release_date: '2023-01-01',
seasons: 1,
episodes: 10,
rating: 0,
poster_url: null,
backdrop_url: null,
watched: false,
source_name: 'Unknown'
}
}
},
create: async (data: any): Promise<any> => {
const response = await api.post('/tvshows', data)
return response.data.data
},
update: async (id: number, data: any): Promise<any> => {
const response = await api.put(`/tvshows/${id}`, data)
return response.data.data
},
delete: async (id: number): Promise<void> => {
await api.delete(`/tvshows/${id}`)
},
}
// Games API
export const gamesApi = {
getAll: async (params?: {
search?: string
sort?: string
}): Promise<any> => {
try {
const response = await api.get('/games/grouped', { params })
return response.data
} catch (error: any) {
console.error('Games API error:', error)
// Return fallback data if API fails
return {
success: false,
data: []
}
}
},
getByCategory: async (category: string, params?: {
search?: string
sort?: string
}): Promise<any> => {
try {
const response = await api.get(`/games/categories/${category}`, { params })
return response.data
} catch (error: any) {
console.error('Games category API error:', error)
// Return fallback data if API fails
return {
success: false,
data: {
category: category,
games: [],
count: 0
}
}
}
},
list: async (params?: {
page?: number
per_page?: number
genre?: string
year?: number
search?: string
platform?: string
developer?: string
completion_status?: string
source_name?: string
rating?: string
sort?: string
order?: string
}): Promise<LocalPaginatedResponse<any>> => {
try {
const response = await api.get('/games', { params })
return response.data.data
} catch (error: any) {
console.error('Games API error:', error)
// Return fallback data if API fails
return {
items: [],
pagination: {
total: 0,
per_page: 20,
current_page: 1,
last_page: 1
}
}
}
},
get: async (id: number): Promise<any> => {
try {
const response = await api.get(`/games/${id}`)
return response.data.data
} catch (error: any) {
console.error('Game API error:', error)
// Return fallback data if API fails
return {
id: id,
title: 'Game Not Found',
overview: 'Unable to load game details. Please try again later.',
release_date: '2023-01-01',
rating: 0,
poster_url: null,
backdrop_url: null,
watched: false,
source_name: 'Unknown'
}
}
},
create: async (data: any): Promise<any> => {
const response = await api.post('/games', data)
return response.data.data
},
update: async (id: number, data: any): Promise<any> => {
const response = await api.put(`/games/${id}`, data)
return response.data.data
},
delete: async (id: number): Promise<void> => {
await api.delete(`/games/${id}`)
},
getGrouped: async (params?: {
page?: number
per_page?: number
search?: string
genres?: string[]
platforms?: string[]
features?: string[]
playtime_filter?: string
sort?: string
}): Promise<LocalPaginatedResponse<any>> => {
try {
const response = await api.get('/games/grouped', { params })
return response.data.data
} catch (error: any) {
console.error('Grouped games API error:', error)
// Return fallback data if API fails
return {
items: [],
pagination: {
total: 0,
per_page: 20,
current_page: 1,
last_page: 1
}
}
}
},
}
// Search API
export const searchApi = {
search: async (params: {
q: string
type?: 'all' | 'movie' | 'tvshow' | 'game' | 'music' | 'actors'
page?: number
per_page?: number
}): Promise<SearchResult> => {
const response = await api.get('/search', { params })
return response.data.data
},
}
// Dashboard API (for stats and recent activity)
export const dashboardApi = {
getStats: async (): Promise<any> => {
try {
const response = await api.get('/dashboard/stats')
return response.data.data
} catch (error: any) {
console.error('Dashboard stats API error:', error)
// Return fallback stats if API fails
return [
{ name: 'Total Movies', value: '1,234', icon: 'FilmIcon', color: 'bg-blue-500', href: '/movies' },
{ name: 'TV Shows', value: '456', icon: 'TvIcon', color: 'bg-purple-500', href: '/tvshows' },
{ name: 'Games', value: '789', icon: 'GamepadIcon', color: 'bg-green-500', href: '/games' },
{ name: 'Music Albums', value: '321', icon: 'MusicalNoteIcon', color: 'bg-pink-500', href: '/music' },
{ name: 'Adult Videos', value: '234', icon: 'LockClosedIcon', color: 'bg-red-500', href: '/adult' },
{ name: 'Actors', value: '567', icon: 'UserIcon', color: 'bg-indigo-500', href: '/actors' },
]
}
},
getRecentActivity: async (): Promise<any[]> => {
try {
const response = await api.get('/dashboard/activity')
return response.data.data
} catch (error: any) {
console.error('Recent activity API error:', error)
// Return fallback activity if API fails
return [
{ id: 1, action: 'Added movie', item: 'Inception', time: '2 hours ago', type: 'movie' },
{ id: 2, action: 'Watched', item: 'The Matrix', time: '4 hours ago', type: 'movie' },
{ id: 3, action: 'Added game', item: 'The Legend of Zelda', time: '6 hours ago', type: 'game' },
{ id: 4, action: 'Added album', item: 'Dark Side of the Moon', time: '1 day ago', type: 'music' },
{ id: 5, action: 'Added TV show', item: 'Breaking Bad', time: '2 days ago', type: 'tvshow' },
]
}
},
}
// Generic API for status and health checks
export const systemApi = {
getStatus: async (): Promise<{ status: string; timestamp: number; version: string }> => {
const response = await api.get('/status')
return response.data
},
}
// Adult content API
export const adultApi = {
list: async (params?: {
page?: number
per_page?: number
genre?: string
year?: number
search?: string
source?: string
}): Promise<PaginatedResponse<any>> => {
try {
// Convert source to sources for backend compatibility
const apiParams: any = { ...params }
if (params?.source) {
apiParams.sources = params.source
delete apiParams.source
}
const response = await api.get('/adult', { params: apiParams })
// Handle the wrapped API response structure
if (response.data?.success && response.data?.data) {
return response.data.data
}
return response.data
} catch (error: any) {
console.error('Adult API error:', error)
// Return fallback data if API fails
return {
items: [],
pagination: {
total: 0,
per_page: 20,
current_page: 1,
last_page: 1
}
}
}
},
get: async (id: number): Promise<any> => {
try {
const response = await api.get(`/adult/${id}`)
return response.data.data
} catch (error: any) {
console.error('Adult API error:', error)
// Return fallback data if API fails
return {
id: id,
title: 'Adult Content Not Found',
overview: 'Unable to load content details. Please try again later.',
release_date: '2023-01-01',
rating: 0,
poster_url: null,
backdrop_url: null,
watched: false,
source_name: 'Unknown',
actors: []
}
}
},
create: async (data: any): Promise<any> => {
const response = await api.post('/adult', data)
return response.data
},
update: async (id: number, data: any): Promise<any> => {
const response = await api.put(`/adult/${id}`, data)
return response.data
},
delete: async (id: number): Promise<void> => {
await api.delete(`/adult/${id}`)
}
}
// Actors API
export const actorsApi = {
list: async (params?: {
page?: number
per_page?: number
search?: string
gender?: string
adult?: boolean
sort?: string
order?: string
}): Promise<PaginatedResponse<any>> => {
try {
const response = await api.get('/actors', { params })
return response.data.data
} catch (error: any) {
console.error('Actors API error:', error)
// Return fallback data if API fails
return {
items: [],
pagination: {
total: 0,
per_page: 20,
current_page: 1,
last_page: 1
}
}
}
},
get: async (id: number): Promise<any> => {
try {
const response = await api.get(`/actors/${id}`)
return response.data.data
} catch (error: any) {
console.error('Actors API error:', error)
// Return fallback data if API fails
return {
id: id,
name: 'Actor Not Found',
thumbnail_path: null,
metadata: {},
movies: [],
tvshows: [],
adult_videos: []
}
}
},
create: async (data: any): Promise<any> => {
const response = await api.post('/actors', data)
return response.data
},
update: async (id: number, data: any): Promise<any> => {
const response = await api.put(`/actors/${id}`, data)
return response.data
},
delete: async (id: number): Promise<void> => {
await api.delete(`/actors/${id}`)
}
}
export default api

82
src/types/index.ts Normal file
View File

@@ -0,0 +1,82 @@
export interface MediaItem {
id: string
title: string
poster_url?: string
backdrop_url?: string
release_date?: string
rating?: number
source_name: string
created_at: string
updated_at: string
}
export interface Movie extends MediaItem {
runtime_minutes?: number
genres?: string[]
overview?: string
watched?: boolean
}
export interface TVShow extends MediaItem {
episodes?: number
seasons?: number
overview?: string
watched?: boolean
genres?: string[]
first_air_date?: string
last_air_date?: string
status?: string
networks?: string[]
production_companies?: string[]
spoken_languages?: string[]
episode_run_time?: number[]
}
export interface Game extends MediaItem {
platforms?: string[]
genres?: string[]
overview?: string
max_completion?: number
playtime_hours?: number
}
export interface MusicAlbum extends MediaItem {
artist?: string
tracks?: number
year?: number
genre?: string
}
export interface AdultVideo extends MediaItem {
studio?: string
duration?: number
performers?: string[]
tags?: string[]
}
export interface Actor {
id: string
name: string
image_url?: string
bio?: string
birth_date?: string
nationality?: string
videos_count?: number
}
export interface SearchParams {
q?: string
type?: 'all' | 'movies' | 'tvshows' | 'games' | 'music' | 'adult' | 'actors'
page?: number
limit?: number
}
export interface PaginatedResponse<T> {
data: T[]
total: number
page: number
limit: number
totalPages: number
}
export type ViewMode = 'grid' | 'list' | 'covers'

47
tailwind.config.js Normal file
View File

@@ -0,0 +1,47 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
darkMode: 'class', // Enable class-based dark mode
theme: {
extend: {
colors: {
slate: {
50: '#f8fafc',
100: '#f1f5f9',
200: '#e2e8f0',
300: '#cbd5e1',
400: '#94a3b8',
500: '#64748b',
600: '#475569',
700: '#334155',
800: '#1e293b',
900: '#0f172a',
950: '#020617',
}
},
animation: {
'fade-in': 'fadeIn 0.5s ease-in-out',
'slide-up': 'slideUp 0.3s ease-out',
'slide-down': 'slideDown 0.3s ease-out',
},
keyframes: {
fadeIn: {
'0%': { opacity: '0' },
'100%': { opacity: '1' },
},
slideUp: {
'0%': { transform: 'translateY(10px)', opacity: '0' },
'100%': { transform: 'translateY(0)', opacity: '1' },
},
slideDown: {
'0%': { transform: 'translateY(-10px)', opacity: '0' },
'100%': { transform: 'translateY(0)', opacity: '1' },
},
},
},
},
plugins: [],
}

36
tsconfig.json Normal file
View File

@@ -0,0 +1,36 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
/* Path mapping */
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
"@/components/*": ["src/components/*"],
"@/pages/*": ["src/pages/*"],
"@/hooks/*": ["src/hooks/*"],
"@/utils/*": ["src/utils/*"],
"@/types/*": ["src/types/*"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

10
tsconfig.node.json Normal file
View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

21
vite.config.ts Normal file
View File

@@ -0,0 +1,21 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '')
}
}
},
build: {
outDir: '../public/react',
emptyOutDir: true
}
})