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