mirror of
https://github.com/ceratic/MediaCollectorLibaryFrontend.git
synced 2026-05-13 23:56:45 +02:00
first commit
This commit is contained in:
8
.env.example
Normal file
8
.env.example
Normal 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
43
.gitignore
vendored
Normal 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
126
API_INTEGRATION.md
Normal 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
120
README.md
Normal 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
91
index.html
Normal 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
4374
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
42
package.json
Normal file
42
package.json
Normal 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
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
48
src/App.tsx
Normal file
48
src/App.tsx
Normal 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
|
||||
245
src/components/ContentFilters.tsx
Normal file
245
src/components/ContentFilters.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
52
src/components/ErrorBoundary.tsx
Normal file
52
src/components/ErrorBoundary.tsx
Normal 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
402
src/components/GameCard.tsx
Normal 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
368
src/components/Layout.tsx
Normal 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
|
||||
146
src/components/LoadingSkeleton.tsx
Normal file
146
src/components/LoadingSkeleton.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
500
src/components/MediaCard.tsx
Normal file
500
src/components/MediaCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
584
src/components/MediaDetail.tsx
Normal file
584
src/components/MediaDetail.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
255
src/components/MicroInteractions.tsx
Normal file
255
src/components/MicroInteractions.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
370
src/components/MovieCard.tsx
Normal file
370
src/components/MovieCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
128
src/components/Pagination.tsx
Normal file
128
src/components/Pagination.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
309
src/components/SearchDropdown.tsx
Normal file
309
src/components/SearchDropdown.tsx
Normal 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
144
src/components/Sidebar.tsx
Normal 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
335
src/hooks/useApi.ts
Normal 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
214
src/index.css
Normal 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
36
src/main.tsx
Normal 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
877
src/pages/ActorDetail.tsx
Normal 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
704
src/pages/Actors.tsx
Normal 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
435
src/pages/Adult copy.tsx
Normal 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
458
src/pages/Adult.tsx
Normal 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
435
src/pages/AdultDetail.tsx
Normal 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
220
src/pages/Dashboard.tsx
Normal 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
566
src/pages/GameDetail.tsx
Normal 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
171
src/pages/Games.tsx
Normal 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
412
src/pages/GamesPage.tsx
Normal 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
567
src/pages/MovieDetail.tsx
Normal 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
151
src/pages/Movies.tsx
Normal 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
18
src/pages/Music.tsx
Normal 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
454
src/pages/Search.tsx
Normal 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
592
src/pages/TVShowDetail.tsx
Normal 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
245
src/pages/TVShows.tsx
Normal 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
625
src/services/api.ts
Normal 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
82
src/types/index.ts
Normal 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
47
tailwind.config.js
Normal 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
36
tsconfig.json
Normal 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
10
tsconfig.node.json
Normal 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
21
vite.config.ts
Normal 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
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user